Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion Wiki/changelog/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ type: Changelog
title: CLI Changelog
description: Release-level history for the Open Knowledge CLI.
tags: [openknowledge, cli, changelog]
timestamp: 2026-07-30T00:00:00Z
timestamp: 2026-07-31T00:00:00Z
---

# CLI Changelog
Expand All @@ -13,6 +13,19 @@ page records release-level changes.

## Unreleased

### 2026-07-31 — Mermaid diagram viewport

- A click, Enter, or Space now opens a rendered Mermaid diagram in a
viewport-filling dialog.
- Toolbar, wheel, pinch, drag, and arrow-key controls provide zoom and pan.
- **Fit** fits the complete diagram in the viewport.
**100%** centers the diagram at its original scale.
- The local viewer and interactive HTML exports use the same controls.
- Source: `packages/web/src/viewer/`,
`packages/web/scripts/browser.e2e.mjs`.
- Docs: `Wiki/features/commands/view.md`,
`Wiki/features/exporters/html.md`.

### 2026-07-31 — Bounded scheduled-job storage

- A scheduled job now runs at most once for each job ID and schedule slot.
Expand Down
10 changes: 9 additions & 1 deletion Wiki/features/commands/view.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ type: Command Documentation
title: openknowledge view
description: Browse a local or connected knowledge base in the web viewer.
tags: [openknowledge, cli, command, viewer]
timestamp: 2026-07-30T00:00:00Z
timestamp: 2026-07-31T00:00:00Z
---

# `openknowledge view`
Expand Down Expand Up @@ -40,6 +40,13 @@ Head injection also reads `OPENKNOWLEDGE_HEAD_FILE`,
- The viewer renders Markdown and follows local links. It renders fenced
`mermaid` blocks as diagrams. It also shows note panels, source graphs,
validation context, highlighted assets, and media or PDF previews.
- Click a rendered Mermaid diagram to open a viewport-filling dialog.
You can also focus the diagram and press Enter or Space.
Use the toolbar, wheel, or pinch gesture to zoom.
Drag the diagram, or use the arrow keys, to pan.
Select **Fit** to fit the complete diagram in the viewport.
Select **100%** to center the diagram at its original scale.
Press Escape to close the dialog.
- The link behavior control is next to viewer settings. Open-beside mode is the
default. The control can select the current panel mode.
The browser stores this selection. Hold Shift during activation to use the
Expand Down Expand Up @@ -110,6 +117,7 @@ Theme and source-link configuration comes from
> - `packages/cli/cmd/openknowledge/viewer_templates.go`
> - `packages/cli/cmd/openknowledge/viewer_assets/`
> - `packages/web/src/viewer/`
> - `packages/web/scripts/browser.e2e.mjs`
> - `packages/cli/cmd/openknowledge/viewer_test.go`
> - `packages/cli/internal/okf/search.go`
>
Expand Down
7 changes: 6 additions & 1 deletion Wiki/features/exporters/html.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ type: Exporter Documentation
title: HTML Exporter
description: Publish an Open Knowledge bundle as a static site.
tags: [openknowledge, cli, exporter, html]
timestamp: 2026-07-18T00:00:00Z
timestamp: 2026-07-31T00:00:00Z
---

# HTML Exporter
Expand Down Expand Up @@ -59,6 +59,10 @@ The deployment owns trusted inline scripts from `--head-file` or `--head-html`.
These scripts can require a CSP nonce or hash.
Use `--script-src` for trusted external scripts.

The local viewer and interactive HTML exports use the same diagram controls.
Both viewer modes provide zoom, pan, **Fit**, and **100%** controls in a
viewport-filling dialog.

Plain mode writes only semantic HTML pages.
It omits viewer assets, discovery files, search data, source controls, and frontmatter chrome.

Expand Down Expand Up @@ -138,6 +142,7 @@ Then, it registers the materialized source.
> - `packages/cli/cmd/openknowledge/viewer_discovery.go`
> - `packages/cli/cmd/openknowledge/viewer_theme.go`
> - `packages/web/src/viewer/`
> - `packages/web/scripts/browser.e2e.mjs`
> - `packages/web/vite.viewer.config.ts`
> - `packages/cli/internal/okf/export_test.go`
>
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/cmd/openknowledge/viewer_assets/viewer.css

Large diffs are not rendered by default.

754 changes: 377 additions & 377 deletions packages/cli/cmd/openknowledge/viewer_assets/viewer.js

Large diffs are not rendered by default.

96 changes: 94 additions & 2 deletions packages/web/scripts/browser.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -188,14 +188,14 @@ test("exported viewer keeps note navigation, explorer context, and settings disc
await page.locator('[data-note-path="guides/rollback.md"]').waitFor({ state: "visible" });
assert.equal(await page.locator("[data-note-path]").count(), 2, "beside mode should open a normal note link beside the active panel");
assert.equal(await page.locator("[data-note-navigator]").count(), 0, "multi-panel mode should not add a fixed bottom navigator");
await page.locator('[data-note-path="index.md"]').click();
await page.locator('[data-note-path="index.md"] .note-chrome').click();
assert.equal(await page.locator('[data-note-path="index.md"][data-active-panel="true"]').count(), 1);
await page.getByRole("link", { name: "rollback guide" }).click({ modifiers: ["Shift"] });
await page.waitForFunction(() => document.querySelectorAll("[data-note-path]").length === 1);
assert.equal(await page.locator("[data-note-path]").count(), 1, "Shift-click should invert beside mode and replace the active panel");
await page.goBack();
await page.waitForFunction(() => document.querySelectorAll("[data-note-path]").length === 2);
await page.locator('[data-note-path="index.md"]').click();
await page.locator('[data-note-path="index.md"] .note-chrome').click();

await page.getByRole("button", { name: "Open file explorer" }).click();
const currentFile = page.locator('.file-sidebar [data-tree-path="index.md"]');
Expand Down Expand Up @@ -258,6 +258,94 @@ test("exported viewer renders Mermaid in initial and dynamic note panels", async
await context.close();
});

test("exported Mermaid diagrams open in a zoomable viewport", async () => {
const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
const page = await context.newPage();
const errors = collectPageErrors(page);

await page.goto(viewerURL, { waitUntil: "networkidle" });
const trigger = page.getByRole("button", { name: "Mermaid diagram 1 in Browser Test Handbook" });
await trigger.click();

const dialog = page.getByRole("dialog", { name: "Mermaid diagram 1 in Browser Test Handbook" });
await dialog.waitFor({ state: "visible" });
const dialogBox = await dialog.boundingBox();
assert.deepEqual(dialogBox && { width: dialogBox.width, height: dialogBox.height }, { width: 1280, height: 720 });

const status = page.getByRole("status", { name: "Diagram zoom" });
const zoomIn = page.getByRole("button", { name: "Zoom in" });
const zoomOut = page.getByRole("button", { name: "Zoom out" });
const actual = page.getByRole("button", { name: "Show diagram at 100%" });
const fit = page.getByRole("button", { name: "Fit diagram to viewport" });
const canvas = page.getByLabel("Diagram canvas. Drag to pan.");
const stage = page.locator(".ok-mermaid-viewport-stage");

const fittedZoom = zoomPercent(await status.innerText());
await zoomIn.click();
assert.ok(zoomPercent(await status.innerText()) > fittedZoom);
await zoomOut.click();
await actual.click();
assert.equal(await status.innerText(), "100%");
await fit.click();
assert.equal(zoomPercent(await status.innerText()), fittedZoom);

await canvas.hover();
await page.mouse.wheel(0, -100);
assert.ok(zoomPercent(await status.innerText()) > fittedZoom);

const canvasBox = await canvas.boundingBox();
assert.ok(canvasBox);
const beforeDrag = await stage.getAttribute("style");
await page.mouse.move(canvasBox.x + canvasBox.width / 2, canvasBox.y + canvasBox.height / 2);
await page.mouse.down();
await page.mouse.move(canvasBox.x + canvasBox.width / 2 + 90, canvasBox.y + canvasBox.height / 2 + 50);
await page.mouse.up();
const afterDrag = await stage.getAttribute("style");
assert.notEqual(afterDrag, beforeDrag);
await canvas.press("ArrowRight");
assert.notEqual(await stage.getAttribute("style"), afterDrag);

await canvas.press("Escape");
await dialog.waitFor({ state: "hidden" });
assert.equal(await trigger.evaluate((element) => document.activeElement === element), true);
const restoredDiagram = page.locator('[data-note-path="index.md"] [data-mermaid-output] svg');
await restoredDiagram.waitFor({ state: "visible" });
assert.equal(await restoredDiagram.count(), 1);

await trigger.press("Enter");
await dialog.waitFor({ state: "visible" });
await page.getByRole("button", { name: "Close diagram viewer" }).click();
await dialog.waitFor({ state: "hidden" });
assert.equal(await trigger.evaluate((element) => document.activeElement === element), true);
await restoredDiagram.waitFor({ state: "visible" });
await page.keyboard.press("Space");
await dialog.waitFor({ state: "visible" });
await page.keyboard.press("Escape");

assert.equal(errors.length, 0, `viewer Mermaid viewport browser errors:\n${errors.join("\n")}`);
await context.close();
});

test("exported Mermaid viewport fits mobile screens", async () => {
const context = await browser.newContext({ viewport: { width: 390, height: 844 } });
const page = await context.newPage();
const errors = collectPageErrors(page);

await page.goto(viewerURL, { waitUntil: "networkidle" });
await page.getByRole("button", { name: "Mermaid diagram 1 in Browser Test Handbook" }).click();
const dialog = page.getByRole("dialog", { name: "Mermaid diagram 1 in Browser Test Handbook" });
const toolbar = page.getByRole("toolbar", { name: "Diagram zoom controls" });
const dialogBox = await dialog.boundingBox();
const toolbarBox = await toolbar.boundingBox();
assert.deepEqual(dialogBox && { width: dialogBox.width, height: dialogBox.height }, { width: 390, height: 844 });
assert.ok(toolbarBox && toolbarBox.x >= 0 && toolbarBox.x + toolbarBox.width <= 390);
for (const name of ["Zoom out", "Zoom in", "Show diagram at 100%", "Fit diagram to viewport", "Close diagram viewer"]) {
assert.equal(await page.getByRole("button", { name }).isVisible(), true);
}
assert.equal(errors.length, 0, `mobile Mermaid viewport browser errors:\n${errors.join("\n")}`);
await context.close();
});

test("nested exported pages work directly from file URLs", async () => {
const context = await browser.newContext();
const page = await context.newPage();
Expand Down Expand Up @@ -316,6 +404,10 @@ function collectPageErrors(page) {
return errors;
}

function zoomPercent(value) {
return Number.parseInt(value, 10);
}

async function listen(server) {
await new Promise((resolve, reject) => {
server.once("error", reject);
Expand Down
10 changes: 10 additions & 0 deletions packages/web/src/viewer/app.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { bindMermaidViewport, closeMermaidViewport } from "./mermaid-viewport.js";

(function () {
const workspace = document.querySelector("[data-note-workspace]");
Expand Down Expand Up @@ -2684,6 +2685,7 @@
}

function scheduleMermaidThemeRender() {
closeMermaidViewport();
window.clearTimeout(mermaidThemeTimer);
mermaidThemeTimer = window.setTimeout(function () {
enhanceMermaid(stackEl, true);
Expand Down Expand Up @@ -2776,6 +2778,13 @@
return diagram;
}

function mermaidDiagramLabel(diagram) {
const panel = closestElement(diagram, "[data-note-path]");
const title = panel?.querySelector(".note-body h1")?.textContent?.trim() || panel?.dataset.noteTitle || "document";
const diagrams = Array.from(panel?.querySelectorAll("[data-mermaid-diagram]") || [diagram]);
return "Mermaid diagram " + (Math.max(0, diagrams.indexOf(diagram)) + 1) + " in " + title;
}

function enhanceMermaid(scope, force) {
if (!window.mermaid || typeof window.mermaid.render !== "function") {
return;
Expand Down Expand Up @@ -2843,6 +2852,7 @@
output.hidden = false;
sourceBlock.hidden = true;
error.hidden = true;
bindMermaidViewport(output, mermaidDiagramLabel(diagram));
diagram.dataset.mermaidState = "rendered";
} catch {
if (diagram._openKnowledgeMermaidRequest !== requestID) {
Expand Down
45 changes: 45 additions & 0 deletions packages/web/src/viewer/mermaid-viewport-controls.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
function button(text, label, action) {
const element = document.createElement("button");
element.type = "button";
element.textContent = text;
element.dataset.okMermaidAction = action;
element.setAttribute("aria-label", label);
element.setAttribute("title", label);
return element;
}

export function createMermaidViewportControls() {
const dialog = document.createElement("dialog");
dialog.className = "ok-mermaid-viewport";

const toolbar = document.createElement("div");
toolbar.className = "ok-mermaid-viewport-toolbar";
toolbar.setAttribute("role", "toolbar");
toolbar.setAttribute("aria-label", "Diagram zoom controls");

const zoomOut = button("−", "Zoom out", "zoom-out");
const zoomIn = button("+", "Zoom in", "zoom-in");
const actual = button("100%", "Show diagram at 100%", "actual");
const fitButton = button("Fit", "Fit diagram to viewport", "fit");
const closeButton = button("Close", "Close diagram viewer", "close");
const zoom = document.createElement("output");
zoom.className = "ok-mermaid-viewport-zoom";
zoom.dataset.okMermaidZoom = "";
zoom.setAttribute("aria-label", "Diagram zoom");
zoom.setAttribute("aria-live", "polite");
toolbar.append(zoomOut, zoomIn, actual, fitButton, zoom, closeButton);

const canvas = document.createElement("div");
canvas.className = "ok-mermaid-viewport-canvas";
canvas.dataset.okMermaidCanvas = "";
canvas.tabIndex = 0;
canvas.setAttribute("aria-label", "Diagram canvas. Drag to pan.");

const stage = document.createElement("div");
stage.className = "ok-mermaid-viewport-stage";
canvas.append(stage);
dialog.append(toolbar, canvas);
document.body.append(dialog);

return { actual, canvas, closeButton, dialog, fitButton, stage, zoom, zoomIn, zoomOut };
}
Loading