diff --git a/.changeset/visimer-package-bump.md b/.changeset/visimer-package-bump.md
new file mode 100644
index 000000000..bc2471bdb
--- /dev/null
+++ b/.changeset/visimer-package-bump.md
@@ -0,0 +1,5 @@
+---
+"@inkeep/open-knowledge": patch
+---
+
+Mermaid WYSIWYG rendering now depends on `@visimer/{core,dom}@0.1.0` instead of `@inkeep/mermaid-wysiwyg-{core,dom}@0.1.0`. The upstream project moved to a scoped package name; the published dist ships the fixes we previously patched in tree, so the local patch is dropped.
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 418afa2a7..9eb10dd1d 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -6247,15 +6247,15 @@ Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam,
The packages below are authored and published by Inkeep and are licensed under GPL-3.0-or-later, the same license as OpenKnowledge itself (see the repository LICENSE for the full text). They are listed for completeness; they are not third-party material.
-### `@inkeep/mermaid-wysiwyg-core@0.1.0`
-Homepage: https://github.com/inkeep/mermaid-wysiwyg#readme
+### `@visimer/core@0.1.0`
+Homepage: https://github.com/inkeep/visimer#readme
-Copyright (C) 2007 Free Software Foundation, Inc.
+Copyright (c) 2026 Inkeep
-### `@inkeep/mermaid-wysiwyg-dom@0.1.0`
-Homepage: https://github.com/inkeep/mermaid-wysiwyg#readme
+### `@visimer/dom@0.1.0`
+Homepage: https://github.com/inkeep/visimer#readme
-Copyright (C) 2007 Free Software Foundation, Inc.
+Copyright (c) 2026 Inkeep
---
@@ -6266,7 +6266,6 @@ The following MIT-licensed packages are patched in this repository via pnpm's `p
| Package | Patch file |
| --- | --- |
| `@handlewithcare/remark-prosemirror@0.1.5` | `patches/@handlewithcare%2Fremark-prosemirror@0.1.5.patch` |
-| `@inkeep/mermaid-wysiwyg-dom@0.1.0` | `patches/@inkeep%2Fmermaid-wysiwyg-dom@0.1.0.patch` |
| `@pierre/trees@1.0.0-beta.4` | `patches/@pierre%2Ftrees@1.0.0-beta.4.patch` |
| `@tiptap/extension-drag-handle@3.22.3` | `patches/@tiptap%2Fextension-drag-handle@3.22.3.patch` |
| `@tiptap/y-tiptap@3.0.3` | `patches/@tiptap%2Fy-tiptap@3.0.3.patch` |
diff --git a/packages/app/package.json b/packages/app/package.json
index 85b6682f4..a0fae7472 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -97,8 +97,8 @@
"@lezer/markdown": "^1.6.3",
"@lingui/core": "^6.0.1",
"@lingui/react": "^6.0.1",
- "@inkeep/mermaid-wysiwyg-core": "^0.1.0",
- "@inkeep/mermaid-wysiwyg-dom": "^0.1.0",
+ "@visimer/core": "^0.1.0",
+ "@visimer/dom": "^0.1.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.204.0",
"@opentelemetry/instrumentation": "^0.204.0",
diff --git a/packages/app/src/editor/components/Mermaid.test.tsx b/packages/app/src/editor/components/Mermaid.test.tsx
index 284f1e189..95eeca36d 100644
--- a/packages/app/src/editor/components/Mermaid.test.tsx
+++ b/packages/app/src/editor/components/Mermaid.test.tsx
@@ -3,11 +3,11 @@
*
* Same testing-library-free convention as Math.test.tsx: `renderToString`
* from `react-dom/server` is the substrate. Mermaid renders via `useEffect`
- * + an async lazy import + the mermaid-wysiwyg canvas view, so under
+ * + an async lazy import + the visimer canvas view, so under
* `renderToString` the component lands in its initial placeholder state
* (the effect fires only on real mount). Live SVG output is exercised via
* the Playwright visual-regression suite; canvas behavior (selection,
- * popovers, in-place edits) is owned by the `mermaid-wysiwyg` package's
+ * popovers, in-place edits) is owned by the `visimer` package's
* own test suite.
*/
diff --git a/packages/app/src/editor/components/Mermaid.tsx b/packages/app/src/editor/components/Mermaid.tsx
index f2611e7b9..7a5f22cc1 100644
--- a/packages/app/src/editor/components/Mermaid.tsx
+++ b/packages/app/src/editor/components/Mermaid.tsx
@@ -7,8 +7,8 @@
* for documents without diagrams; cost at first diagram is ~150 KB
* gzipped (entry ~11 KB + lazy diagram-type chunks 24-45 KB each).
*
- * Rendering + editing are delegated to `mermaid-wysiwyg`
- * (`@inkeep/mermaid-wysiwyg-core` + `@inkeep/mermaid-wysiwyg-dom`): the canvas view
+ * Rendering + editing are delegated to `visimer`
+ * (`@visimer/core` + `@visimer/dom`): the canvas view
* renders through our own lazy `mermaid` instance (full fidelity, same
* theming as before), correlates the SVG back to source entities, and
* overlays interaction — click-select with a per-entity action popover
@@ -38,10 +38,10 @@
* container below.
*/
-import type { MermaidWysiwygEditor } from '@inkeep/mermaid-wysiwyg-core';
-import type { MermaidCanvasView } from '@inkeep/mermaid-wysiwyg-dom';
import { Trans, useLingui } from '@lingui/react/macro';
import type { default as PanZoomNS, PanzoomObject } from '@panzoom/panzoom';
+import type { MermaidWysiwygEditor } from '@visimer/core';
+import type { MermaidCanvasView } from '@visimer/dom';
import {
AlertTriangle,
ArrowDown,
@@ -125,13 +125,10 @@ function loadMermaid() {
}
let wysiwygPromise: Promise<
- [typeof import('@inkeep/mermaid-wysiwyg-core'), typeof import('@inkeep/mermaid-wysiwyg-dom')]
+ [typeof import('@visimer/core'), typeof import('@visimer/dom')]
> | null = null;
function loadWysiwyg() {
- wysiwygPromise ||= Promise.all([
- import('@inkeep/mermaid-wysiwyg-core'),
- import('@inkeep/mermaid-wysiwyg-dom'),
- ]).catch((err) => {
+ wysiwygPromise ||= Promise.all([import('@visimer/core'), import('@visimer/dom')]).catch((err) => {
wysiwygPromise = null;
throw err;
});
diff --git a/patches/@inkeep%2Fmermaid-wysiwyg-dom@0.1.0.patch b/patches/@inkeep%2Fmermaid-wysiwyg-dom@0.1.0.patch
deleted file mode 100644
index d958a23c7..000000000
--- a/patches/@inkeep%2Fmermaid-wysiwyg-dom@0.1.0.patch
+++ /dev/null
@@ -1,726 +0,0 @@
-diff --git a/dist/index.d.ts b/dist/index.d.ts
-index 211acd6fc0923b393a9d2fa322c7690904c04ea1..52dce7a207bad14e8619faebe8f3e7b1a8cf5e1a 100644
---- a/dist/index.d.ts
-+++ b/dist/index.d.ts
-@@ -22,6 +22,11 @@ interface ViewOptions {
- mermaidConfig?: Record;
- debounceMs?: number;
- readOnly?: boolean;
-+ /**
-+ * Canvas pan/zoom: fit-to-canvas on first render, drag empty space to pan,
-+ * ctrl/cmd+wheel (or trackpad pinch) to zoom, plus corner zoom controls.
-+ */
-+ panZoom?: boolean;
- /** CSS color for selection/hover/ghost-edge chrome */
- accentColor?: string;
- /** kind of edge created by drag-to-connect */
-@@ -61,6 +66,11 @@ declare class MermaidCanvasView {
- private plusMenu;
- private hoveredLifeline;
- private svg;
-+ /** tracks external transforms on the svg (host pan/zoom toolbars) */
-+ private svgTransformObserver;
-+ private popoverFollowRaf;
-+ /** which of an entity's twin elements the user clicked (popover anchor) */
-+ private popoverAnchorPick;
- private tool;
- private readOnly;
- private debounceMs;
-@@ -83,6 +93,17 @@ declare class MermaidCanvasView {
- private pendingEditEntity;
- private inPlaceSession;
- private lastError;
-+ /** a render was requested while an in-place edit was typing; applied on finish */
-+ private renderHeldByEdit;
-+ private panZoomEnabled;
-+ private zoomScale;
-+ private zoomTx;
-+ private zoomTy;
-+ /** the user panned/zoomed — keep their viewport across re-renders */
-+ private hasUserView;
-+ private svgSize;
-+ private panSession;
-+ private zoomControls;
- /** per-instance staleness counter; a shared one would drop renders across instances */
- private renderSeq;
- constructor(options: ViewOptions);
-@@ -100,6 +121,15 @@ declare class MermaidCanvasView {
- /** Add a node and immediately open its inline label editor. */
- addNode(shape?: ShapeId, label?: string): void;
- private scheduleRender;
-+ private setupPanZoom;
-+ /** zoom about the canvas center (used by the corner controls) */
-+ zoomBy(factor: number): void;
-+ private zoomAt;
-+ /** scale + center the diagram to the canvas (the default view) */
-+ fitView(): void;
-+ private applyViewTransform;
-+ /** pixel-size the fresh svg from its viewBox so the transform is the only scaling */
-+ private prepareSvgForPanZoom;
- render(): Promise;
- private bindSvg;
- private entityFromEvent;
-@@ -108,6 +138,8 @@ declare class MermaidCanvasView {
- /** topmost of the elements belonging to an entity (e.g. a participant's top box) */
- private topmostEntityElement;
- private onSvgClick;
-+ /** position of `el` among all elements carrying this entity id (DOM order) */
-+ private entityElementIndex;
- private onSvgDblClick;
- private onSvgPointerDown;
- private entityElement;
-@@ -123,6 +155,8 @@ declare class MermaidCanvasView {
- * restored.
- */
- private editInPlace;
-+ /** non-narrowing accessor: render() re-reads the session after awaits */
-+ private activeInPlaceSession;
- /** re-attach a live in-place editing session after a render replaced the DOM */
- private resumeInPlaceSession;
- /**
-@@ -233,6 +267,7 @@ declare class Popover {
- left: number;
- top: number;
- width: number;
-+ height?: number;
- }, actions: PopoverAction[]): void;
- private togglePanel;
- hide(): void;
-diff --git a/dist/index.js b/dist/index.js
-index 7bb142de61632addf365b5ad2416957f29a38016..1b20b1dc1301323c5c2f23dd1ebfa2d137949ace 100644
---- a/dist/index.js
-+++ b/dist/index.js
-@@ -460,6 +460,10 @@ var ICONS = {
- ),
- user: icon(''),
- minus: icon(''),
-+ plus: icon(''),
-+ maximize: icon(
-+ ''
-+ ),
- ellipsis: icon(''),
- chevronDown: icon('')
- };
-@@ -508,10 +512,22 @@ var Popover = class {
- });
- el.appendChild(btn);
- }
-- el.style.left = `${anchorRect.left + anchorRect.width / 2}px`;
-- el.style.top = `${Math.max(anchorRect.top - 8, 34)}px`;
- this.host.appendChild(el);
- this.el = el;
-+ const bounds = this.host.parentElement;
-+ let centerX = anchorRect.left + anchorRect.width / 2;
-+ const half = el.offsetWidth / 2;
-+ if (bounds && bounds.clientWidth > el.offsetWidth + 8) {
-+ centerX = Math.min(Math.max(centerX, half + 4 + bounds.scrollLeft), bounds.scrollLeft + bounds.clientWidth - half - 4);
-+ }
-+ el.style.left = `${centerX}px`;
-+ const above = anchorRect.top - 8;
-+ if (above - el.offsetHeight < 4) {
-+ el.classList.add("mw-popover-below");
-+ el.style.top = `${anchorRect.top + (anchorRect.height ?? 0) + 8}px`;
-+ } else {
-+ el.style.top = `${above}px`;
-+ }
- }
- togglePanel(panel) {
- if (this.panelEl) {
-@@ -571,6 +587,7 @@ var Popover = class {
- }
- };
- var POPOVER_CSS = `
-+.mw-popover.mw-popover-below { transform: translate(-50%, 0); }
- .mw-popover {
- position: absolute; z-index: 20; transform: translate(-50%, -100%);
- display: flex; align-items: center; gap: 2px; padding: 4px;
-@@ -655,6 +672,16 @@ var BASE_CSS = `
- .mw-canvas .mw-svg-host { user-select: none; -webkit-user-select: none; }
- .mw-canvas .mw-svg-host { min-height: 100%; display: flex; align-items: flex-start; justify-content: center; padding: 16px; box-sizing: border-box; }
- .mw-canvas svg { max-width: 100%; height: auto; }
-+.mw-canvas.mw-panzoom { overflow: hidden; }
-+.mw-canvas.mw-panzoom .mw-svg-host { position: absolute; inset: 0; display: block; padding: 0; min-height: 0; overflow: hidden; }
-+.mw-canvas.mw-panzoom .mw-svg-host svg { position: absolute; left: 0; top: 0; max-width: none; height: auto; transform-origin: 0 0; }
-+.mw-canvas.mw-panzoom .mw-svg-host { cursor: grab; }
-+.mw-canvas.mw-panzoom.mw-panning .mw-svg-host { cursor: grabbing; }
-+.mw-canvas.mw-panzoom [data-mw-entity] { cursor: pointer; }
-+.mw-zoom-controls { position: absolute; right: 10px; bottom: 10px; display: flex; gap: 4px; z-index: 6; }
-+.mw-zoom-btn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 0; border-radius: 7px; border: 1px solid var(--mw-chrome-border, rgba(128,128,128,.35)); background: var(--mw-chrome-bg, rgba(24,24,27,.92)); color: var(--mw-chrome-fg, #e4e4e7); cursor: pointer; }
-+.mw-zoom-btn:hover { background: var(--mw-chrome-hover, rgba(63,63,70,.9)); }
-+.mw-zoom-btn svg { width: 14px; height: 14px; }
- .mw-canvas [data-mw-entity] { cursor: pointer; }
- .mw-canvas.mw-readonly [data-mw-entity] { cursor: default; }
- .mw-canvas.mw-tool-connect [data-mw-entity^="node:"] { cursor: crosshair; }
-@@ -687,6 +714,41 @@ var BASE_CSS = `
- `;
- var styleInjected = false;
- var renderIdCounter = 0;
-+function readLabelHtml(label) {
-+ return (label.innerHTML ?? "").replace(/
/gi, "
").trim();
-+}
-+function caretOffsetWithin(root) {
-+ const sel = window.getSelection();
-+ if (!sel?.anchorNode || !root.contains(sel.anchorNode)) return -1;
-+ const range = document.createRange();
-+ range.selectNodeContents(root);
-+ range.setEnd(sel.anchorNode, sel.anchorOffset);
-+ return range.toString().length;
-+}
-+function placeCaretAt(root, offset) {
-+ const sel = window.getSelection();
-+ if (!sel) return;
-+ const range = document.createRange();
-+ let remaining = Math.max(0, offset);
-+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
-+ let node = walker.nextNode();
-+ while (node) {
-+ const len = node.textContent?.length ?? 0;
-+ if (remaining <= len) {
-+ range.setStart(node, remaining);
-+ range.collapse(true);
-+ sel.removeAllRanges();
-+ sel.addRange(range);
-+ return;
-+ }
-+ remaining -= len;
-+ node = walker.nextNode();
-+ }
-+ range.selectNodeContents(root);
-+ range.collapse(false);
-+ sel.removeAllRanges();
-+ sel.addRange(range);
-+}
- var MermaidCanvasView = class {
- editor;
- container;
-@@ -702,6 +764,11 @@ var MermaidCanvasView = class {
- plusMenu = null;
- hoveredLifeline = null;
- svg = null;
-+ /** tracks external transforms on the svg (host pan/zoom toolbars) */
-+ svgTransformObserver = null;
-+ popoverFollowRaf = 0;
-+ /** which of an entity's twin elements the user clicked (popover anchor) */
-+ popoverAnchorPick = null;
- tool = "select";
- readOnly;
- debounceMs;
-@@ -724,6 +791,18 @@ var MermaidCanvasView = class {
- pendingEditEntity = null;
- inPlaceSession = null;
- lastError = null;
-+ /** a render was requested while an in-place edit was typing; applied on finish */
-+ renderHeldByEdit = false;
-+ // pan/zoom state (only used when options.panZoom is set)
-+ panZoomEnabled;
-+ zoomScale = 1;
-+ zoomTx = 0;
-+ zoomTy = 0;
-+ /** the user panned/zoomed — keep their viewport across re-renders */
-+ hasUserView = false;
-+ svgSize = null;
-+ panSession = null;
-+ zoomControls = null;
- /** per-instance staleness counter; a shared one would drop renders across instances */
- renderSeq = 0;
- constructor(options) {
-@@ -731,6 +810,7 @@ var MermaidCanvasView = class {
- this.container = options.container;
- this.mermaid = options.mermaid;
- this.readOnly = options.readOnly ?? false;
-+ this.panZoomEnabled = options.panZoom ?? false;
- this.debounceMs = options.debounceMs ?? 200;
- this.defaultEdge = options.defaultEdge ?? {};
- this.hooks = options.hooks ?? {};
-@@ -762,6 +842,17 @@ var MermaidCanvasView = class {
- const keydown = (e) => this.onKeyDown(e);
- this.container.addEventListener("keydown", keydown);
- this.disposers.push(() => this.container.removeEventListener("keydown", keydown));
-+ const docPointerDown = (e) => {
-+ const target = e.target;
-+ if (!target || this.container.contains(target)) return;
-+ this.inPlaceSession?.finish?.(true);
-+ this.clearLifelineUi();
-+ this.popover.hide();
-+ if (this.editor.selection.length) this.editor.clearSelection("canvas");
-+ };
-+ document.addEventListener("pointerdown", docPointerDown, true);
-+ this.disposers.push(() => document.removeEventListener("pointerdown", docPointerDown, true));
-+ if (this.panZoomEnabled) this.setupPanZoom();
- void this.render();
- }
- on(event, fn) {
-@@ -817,7 +908,168 @@ var MermaidCanvasView = class {
- if (this.renderTimer) clearTimeout(this.renderTimer);
- this.renderTimer = setTimeout(() => void this.render(), this.debounceMs);
- }
-+ // ----- pan / zoom -----
-+ setupPanZoom() {
-+ this.container.classList.add("mw-panzoom");
-+ const onWheel = (e) => {
-+ if (!e.ctrlKey && !e.metaKey) return;
-+ e.preventDefault();
-+ const delta = Math.max(-40, Math.min(40, e.deltaY));
-+ this.zoomAt(e.clientX, e.clientY, Math.exp(-delta * 0.01));
-+ };
-+ this.container.addEventListener("wheel", onWheel, { passive: false });
-+ this.disposers.push(() => this.container.removeEventListener("wheel", onWheel));
-+ const onDown = (e) => {
-+ if (e.button !== 0) return;
-+ if (this.tool === "connect" || e.altKey) return;
-+ if (this.inlineInput || this.inPlaceSession) return;
-+ const target = e.target;
-+ if (!target || !this.svgHost.contains(target)) return;
-+ let el = target;
-+ while (el && el !== this.container) {
-+ if (el.getAttribute?.("data-mw-entity")) return;
-+ el = el.parentElement;
-+ }
-+ this.panSession = { pointerId: e.pointerId, x: e.clientX, y: e.clientY, tx: this.zoomTx, ty: this.zoomTy, moved: false };
-+ };
-+ const onMove = (e) => {
-+ const pan = this.panSession;
-+ if (!pan || e.pointerId !== pan.pointerId) return;
-+ const dx = e.clientX - pan.x;
-+ const dy = e.clientY - pan.y;
-+ if (!pan.moved && Math.hypot(dx, dy) > 4) {
-+ pan.moved = true;
-+ this.container.classList.add("mw-panning");
-+ try {
-+ this.container.setPointerCapture(e.pointerId);
-+ } catch {
-+ }
-+ this.popover.hide();
-+ }
-+ if (pan.moved) {
-+ this.zoomTx = pan.tx + dx;
-+ this.zoomTy = pan.ty + dy;
-+ this.hasUserView = true;
-+ this.applyViewTransform();
-+ }
-+ };
-+ const onUp = (e) => {
-+ const pan = this.panSession;
-+ if (!pan || e.pointerId !== pan.pointerId) return;
-+ this.panSession = null;
-+ this.container.classList.remove("mw-panning");
-+ if (pan.moved) {
-+ this.suppressNextClick = true;
-+ this.updatePopover();
-+ }
-+ };
-+ this.container.addEventListener("pointerdown", onDown);
-+ this.container.addEventListener("pointermove", onMove);
-+ this.container.addEventListener("pointerup", onUp);
-+ this.container.addEventListener("pointercancel", onUp);
-+ this.disposers.push(() => {
-+ this.container.removeEventListener("pointerdown", onDown);
-+ this.container.removeEventListener("pointermove", onMove);
-+ this.container.removeEventListener("pointerup", onUp);
-+ this.container.removeEventListener("pointercancel", onUp);
-+ });
-+ const controls = document.createElement("div");
-+ controls.className = "mw-zoom-controls";
-+ const btn = (title, iconMarkup, fn) => {
-+ const b = document.createElement("button");
-+ b.type = "button";
-+ b.className = "mw-zoom-btn";
-+ b.title = title;
-+ b.innerHTML = iconMarkup;
-+ b.addEventListener("pointerdown", (e) => e.stopPropagation());
-+ b.addEventListener("click", (e) => {
-+ e.stopPropagation();
-+ fn();
-+ });
-+ controls.appendChild(b);
-+ };
-+ btn("Zoom out", ICONS.minus, () => this.zoomBy(1 / 1.25));
-+ btn("Zoom in", ICONS.plus, () => this.zoomBy(1.25));
-+ btn("Fit diagram", ICONS.maximize, () => this.fitView());
-+ this.container.appendChild(controls);
-+ this.zoomControls = controls;
-+ if (typeof ResizeObserver !== "undefined") {
-+ const ro = new ResizeObserver(() => {
-+ if (!this.hasUserView) this.fitView();
-+ });
-+ ro.observe(this.container);
-+ this.disposers.push(() => ro.disconnect());
-+ }
-+ }
-+ /** zoom about the canvas center (used by the corner controls) */
-+ zoomBy(factor) {
-+ const rect = this.container.getBoundingClientRect();
-+ this.zoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, factor);
-+ }
-+ zoomAt(clientX, clientY, factor) {
-+ if (!this.panZoomEnabled) return;
-+ const rect = this.container.getBoundingClientRect();
-+ const px = clientX - rect.left;
-+ const py = clientY - rect.top;
-+ const next = Math.min(Math.max(this.zoomScale * factor, 0.1), 4);
-+ const k = next / this.zoomScale;
-+ if (k === 1) return;
-+ this.zoomTx = px - (px - this.zoomTx) * k;
-+ this.zoomTy = py - (py - this.zoomTy) * k;
-+ this.zoomScale = next;
-+ this.hasUserView = true;
-+ this.applyViewTransform();
-+ this.updatePopover();
-+ }
-+ /** scale + center the diagram to the canvas (the default view) */
-+ fitView() {
-+ if (!this.panZoomEnabled || !this.svg || !this.svgSize) return;
-+ const cw = this.container.clientWidth;
-+ const ch = this.container.clientHeight;
-+ if (!cw || !ch) return;
-+ const pad = 28;
-+ const { width, height } = this.svgSize;
-+ const s = Math.min((cw - pad * 2) / width, (ch - pad * 2) / height);
-+ this.zoomScale = Math.min(Math.max(s, 0.1), 2);
-+ this.zoomTx = (cw - width * this.zoomScale) / 2;
-+ this.zoomTy = (ch - height * this.zoomScale) / 2;
-+ this.hasUserView = false;
-+ this.applyViewTransform();
-+ this.updatePopover();
-+ }
-+ applyViewTransform() {
-+ if (!this.svg) return;
-+ this.svg.style.transform = `translate(${this.zoomTx}px, ${this.zoomTy}px) scale(${this.zoomScale})`;
-+ }
-+ /** pixel-size the fresh svg from its viewBox so the transform is the only scaling */
-+ prepareSvgForPanZoom() {
-+ const svg = this.svg;
-+ if (!svg) return;
-+ let width = 0;
-+ let height = 0;
-+ const vb = svg.viewBox?.baseVal;
-+ if (vb && vb.width && vb.height) {
-+ width = vb.width;
-+ height = vb.height;
-+ } else {
-+ const bb = svg.getBBox?.();
-+ width = bb?.width || 800;
-+ height = bb?.height || 600;
-+ }
-+ this.svgSize = { width, height };
-+ svg.removeAttribute("width");
-+ svg.removeAttribute("height");
-+ svg.style.width = `${width}px`;
-+ svg.style.height = `${height}px`;
-+ svg.style.maxWidth = "none";
-+ if (this.hasUserView) this.applyViewTransform();
-+ else this.fitView();
-+ }
- async render() {
-+ if (this.inPlaceSession) {
-+ this.renderHeldByEdit = true;
-+ return;
-+ }
- const code = this.editor.code;
- if (code === this.lastRenderedCode) {
- if (this.lastError) {
-@@ -833,19 +1085,30 @@ var MermaidCanvasView = class {
- try {
- const { svg } = await this.mermaid.render(id, code);
- if (seq !== this.renderSeq) return;
-+ let liveEdit = null;
-+ const midFlightSession = this.activeInPlaceSession();
-+ const editingLabel = midFlightSession?.label;
-+ if (midFlightSession && editingLabel?.isConnected) {
-+ liveEdit = { value: readLabelHtml(editingLabel), caret: caretOffsetWithin(editingLabel) };
-+ if (midFlightSession.liveTimer) {
-+ clearTimeout(midFlightSession.liveTimer);
-+ midFlightSession.liveTimer = null;
-+ }
-+ }
- this.lastRenderedCode = code;
- this.lastError = null;
- this.errorBadge.style.display = "none";
- this.svgHost.innerHTML = svg;
- this.svg = this.svgHost.querySelector("svg");
- if (this.svg) {
-- this.svg.style.maxWidth = "100%";
-+ if (this.panZoomEnabled) this.prepareSvgForPanZoom();
-+ else this.svg.style.maxWidth = "100%";
- this.bindSvg();
- }
- this.editor.setDiagnostics([]);
- this.emit("render", { ok: true });
-- if (this.inPlaceSession) {
-- this.resumeInPlaceSession();
-+ if (this.activeInPlaceSession()) {
-+ this.resumeInPlaceSession(liveEdit);
- } else if (this.pendingEditEntity) {
- const entity = this.pendingEditEntity;
- this.pendingEditEntity = null;
-@@ -880,6 +1143,22 @@ var MermaidCanvasView = class {
- svg.addEventListener("pointermove", (e) => this.onLifelineHover(e));
- svg.addEventListener("pointerleave", () => this.scheduleLifelineClear());
- }
-+ this.svgTransformObserver?.disconnect();
-+ if (typeof MutationObserver !== "undefined") {
-+ this.svgTransformObserver = new MutationObserver(() => {
-+ if (this.popoverFollowRaf || !this.popover.isOpen) return;
-+ const schedule = typeof requestAnimationFrame === "function" ? requestAnimationFrame : (fn) => setTimeout(fn, 16);
-+ this.popoverFollowRaf = schedule(() => {
-+ this.popoverFollowRaf = 0;
-+ this.updatePopover();
-+ });
-+ });
-+ this.svgTransformObserver.observe(svg, {
-+ attributes: true,
-+ attributeFilter: ["style", "transform"],
-+ subtree: true
-+ });
-+ }
- this.applySelectionStyles();
- }
- entityFromEvent(e) {
-@@ -914,20 +1193,30 @@ var MermaidCanvasView = class {
- return;
- }
- if (this.inlineInput || this.inPlaceSession) return;
-- const entity = this.entityFromEvent(e);
-- if (entity) {
-+ const hit = this.entityHitFromEvent(e);
-+ if (hit) {
-+ const entity = hit.id;
- this.hooks.onEntityClick?.(entity, e);
-+ this.popoverAnchorPick = { entityId: entity, index: this.entityElementIndex(entity, hit.el) };
- if (e.shiftKey || e.metaKey || e.ctrlKey) {
- const sel = this.editor.selection.includes(entity) ? this.editor.selection.filter((s) => s !== entity) : [...this.editor.selection, entity];
- this.editor.setSelection(sel, "canvas");
- } else {
- this.editor.setSelection([entity], "canvas");
-+ this.updatePopover();
- }
- this.container.focus({ preventScroll: true });
- } else {
- this.editor.clearSelection("canvas");
- }
- }
-+ /** position of `el` among all elements carrying this entity id (DOM order) */
-+ entityElementIndex(entityId, el) {
-+ if (!this.svg) return 0;
-+ const els = [...this.svg.querySelectorAll(`[data-mw-entity="${CSS.escape(entityId)}"]`)];
-+ const index = els.indexOf(el);
-+ return index >= 0 ? index : 0;
-+ }
- onSvgDblClick(e) {
- if (this.readOnly) return;
- const memberEl = e.target.closest?.("[data-mw-member-line]");
-@@ -1098,6 +1387,9 @@ var MermaidCanvasView = class {
- if (this.readOnly) return;
- const { flowchart, sequence } = this.editor.result;
- this.closeInlineEditor(false);
-+ if (this.inPlaceSession && this.inPlaceSession.entityId !== entityId) {
-+ this.inPlaceSession.finish?.(true);
-+ }
- this.popover.hide();
- const hint = anchorHint && anchorHint.getAttribute("data-mw-entity") === entityId ? anchorHint : null;
- let anchor = null;
-@@ -1210,6 +1502,10 @@ var MermaidCanvasView = class {
- const label = anchor.matches?.(labelSelector) ? anchor.querySelector("p") ?? anchor : anchor.querySelector(`${labelSelector} p`) ?? anchor.querySelector(labelSelector);
- if (!label || !(label instanceof HTMLElement)) return false;
- const resuming = this.inPlaceSession?.entityId === entityId;
-+ if (resuming && this.inPlaceSession.label === label && label.isConnected) {
-+ label.focus();
-+ return true;
-+ }
- if (!resuming) {
- this.inPlaceSession = {
- entityId,
-@@ -1218,31 +1514,36 @@ var MermaidCanvasView = class {
- lastCommitted: current,
- caretOffset: -1,
- commitValue,
-- liveTimer: null
-+ liveTimer: null,
-+ label: null,
-+ finish: null
- };
- }
- const session = this.inPlaceSession;
-+ session.label = label;
- label.setAttribute("contenteditable", "true");
-+ const clipHost = label.closest("foreignObject");
-+ const prevWhiteSpace = label.style.whiteSpace;
-+ const prevClipOverflow = clipHost?.style.overflow ?? "";
-+ label.style.whiteSpace = "nowrap";
-+ if (clipHost) clipHost.style.overflow = "visible";
- label.focus();
-- const selection = window.getSelection();
-- const range = document.createRange();
-- if (resuming && session.caretOffset >= 0 && label.firstChild) {
-- const textNode = label.firstChild;
-- const len = textNode.textContent?.length ?? 0;
-- range.setStart(textNode, Math.min(session.caretOffset, len));
-- range.collapse(true);
-+ if (resuming && session.caretOffset >= 0) {
-+ placeCaretAt(label, session.caretOffset);
- } else {
-+ const selection = window.getSelection();
-+ const range = document.createRange();
- range.selectNodeContents(label);
-+ selection?.removeAllRanges();
-+ selection?.addRange(range);
- }
-- selection?.removeAllRanges();
-- selection?.addRange(range);
- const rememberCaret = () => {
-- const sel = window.getSelection();
-- if (sel && sel.anchorNode && label.contains(sel.anchorNode)) session.caretOffset = sel.anchorOffset;
-+ const offset = caretOffsetWithin(label);
-+ if (offset >= 0) session.caretOffset = offset;
- };
- const liveCommit = () => {
- if (this.inPlaceSession !== session) return;
-- const value = (label.textContent ?? "").trim();
-+ const value = readLabelHtml(label);
- if (!value || value === session.lastCommitted) return;
- rememberCaret();
- session.lastCommitted = value;
-@@ -1253,16 +1554,22 @@ var MermaidCanvasView = class {
- this.inPlaceSession = null;
- if (session.liveTimer) clearTimeout(session.liveTimer);
- label.removeAttribute("contenteditable");
-+ label.style.whiteSpace = prevWhiteSpace;
-+ if (clipHost) clipHost.style.overflow = prevClipOverflow;
- label.removeEventListener("keydown", onKey);
- label.removeEventListener("blur", onBlur);
- label.removeEventListener("input", onInput);
-- const value = (label.textContent ?? "").trim();
-+ const value = readLabelHtml(label);
- if (commit) {
- if (value && value !== session.lastCommitted) session.commitValue(value);
- else if (!value) label.textContent = session.lastCommitted;
- } else {
-+ label.innerHTML = session.original;
- if (session.lastCommitted !== session.original) session.commitValue(session.original);
-- else label.textContent = session.original;
-+ }
-+ if (this.renderHeldByEdit) {
-+ this.renderHeldByEdit = false;
-+ this.scheduleRender();
- }
- this.updatePopover();
- };
-@@ -1287,13 +1594,18 @@ var MermaidCanvasView = class {
- }
- }, 60);
- };
-+ session.finish = finish;
- label.addEventListener("keydown", onKey);
- label.addEventListener("blur", onBlur);
- label.addEventListener("input", onInput);
- return true;
- }
-+ /** non-narrowing accessor: render() re-reads the session after awaits */
-+ activeInPlaceSession() {
-+ return this.inPlaceSession;
-+ }
- /** re-attach a live in-place editing session after a render replaced the DOM */
-- resumeInPlaceSession() {
-+ resumeInPlaceSession(liveEdit) {
- const session = this.inPlaceSession;
- if (!session) return;
- if (!this.editor.entityExists(session.entityId)) {
-@@ -1306,6 +1618,13 @@ var MermaidCanvasView = class {
- return;
- }
- this.editInPlace(session.entityId, anchor, session.labelSelector, session.lastCommitted, session.commitValue);
-+ const label = this.inPlaceSession === session ? session.label : null;
-+ if (!label || !liveEdit) return;
-+ if (liveEdit.value && liveEdit.value !== session.lastCommitted && liveEdit.value !== readLabelHtml(label)) {
-+ label.innerHTML = liveEdit.value;
-+ label.dispatchEvent(new Event("input"));
-+ }
-+ if (liveEdit.caret >= 0) placeCaretAt(label, liveEdit.caret);
- }
- /**
- * Seamless in-place editing for targets that can't be contenteditable
-@@ -1317,13 +1636,17 @@ var MermaidCanvasView = class {
- findTextTarget(anchor, current) {
- const isLeafText = (el) => (el.tagName === "text" || el.tagName === "SPAN" || el.tagName === "P" || el.tagName === "DIV" || el.tagName === "tspan") && (el.children.length === 0 || el.tagName === "text") && (el.textContent?.trim().length ?? 0) > 0;
- if (isLeafText(anchor)) return anchor;
-- const leaves = [...anchor.querySelectorAll("text, tspan, span, p, div")].filter(isLeafText);
-+ let leaves = [...anchor.querySelectorAll("text, tspan, span, p, div")].filter(isLeafText);
-+ if (!leaves.length && anchor.parentElement) {
-+ leaves = [...anchor.parentElement.querySelectorAll("text, tspan, span, p, div")].filter(isLeafText);
-+ }
- return leaves.find((el) => el.textContent?.trim() === current.trim()) ?? leaves.find((el) => current.trim().startsWith(el.textContent?.trim() ?? "\0")) ?? leaves[0] ?? anchor;
- }
- openOverlayEditor(anchor, current, commitValue) {
- anchor = this.findTextTarget(anchor, current);
- const box = this.hostRect(anchor);
-- const cs = window.getComputedStyle(anchor);
-+ const paintSource = anchor.querySelector("tspan") ?? anchor;
-+ const cs = window.getComputedStyle(paintSource);
- const div = document.createElement("div");
- div.className = "mw-inplace-editor";
- div.contentEditable = "true";
-@@ -1337,9 +1660,11 @@ var MermaidCanvasView = class {
- div.style.lineHeight = `${Math.max(box.height, 12)}px`;
- div.style.fontStyle = cs.fontStyle;
- div.style.fontWeight = cs.fontWeight;
-- div.style.fontSize = cs.fontSize;
-+ const zoom = this.panZoomEnabled ? this.zoomScale : 1;
-+ const basePx = Number.parseFloat(cs.fontSize);
-+ div.style.fontSize = Number.isFinite(basePx) ? `${basePx * zoom}px` : cs.fontSize;
- div.style.fontFamily = cs.fontFamily;
-- const fill = cs.fill && cs.fill !== "none" && anchor instanceof SVGElement ? cs.fill : cs.color;
-+ const fill = cs.fill && cs.fill !== "none" && paintSource instanceof SVGElement ? cs.fill : cs.color;
- div.style.color = fill || "inherit";
- this.overlayHost.appendChild(div);
- this.inlineInput = div;
-@@ -1435,15 +1760,21 @@ var MermaidCanvasView = class {
- const sel = this.editor.selection;
- if (sel.length !== 1 || !this.svg) return;
- const id = sel[0];
-+ const els = [...this.svg.querySelectorAll(`[data-mw-entity="${CSS.escape(id)}"]`)];
- let anchor = null;
-- let anchorTop = Infinity;
-- this.svg.querySelectorAll(`[data-mw-entity="${CSS.escape(id)}"]`).forEach((el) => {
-- const top = el.getBoundingClientRect().top;
-- if (top < anchorTop) {
-- anchorTop = top;
-- anchor = el;
-+ const pick = this.popoverAnchorPick;
-+ if (pick && pick.entityId === id && els[pick.index]) {
-+ anchor = els[pick.index];
-+ } else {
-+ let anchorTop = Infinity;
-+ for (const el of els) {
-+ const top = el.getBoundingClientRect().top;
-+ if (top < anchorTop) {
-+ anchorTop = top;
-+ anchor = el;
-+ }
- }
-- });
-+ }
- if (!anchor) return;
- const actions = this.actionsFor(id);
- if (!actions.length) return;
-@@ -2088,15 +2419,20 @@ var MermaidCanvasView = class {
- destroy() {
- if (this.renderTimer) clearTimeout(this.renderTimer);
- if (this.lifelineClearTimer) clearTimeout(this.lifelineClearTimer);
-+ if (this.inPlaceSession?.liveTimer) clearTimeout(this.inPlaceSession.liveTimer);
-+ this.inPlaceSession = null;
-+ this.svgTransformObserver?.disconnect();
-+ this.svgTransformObserver = null;
- this.disposers.forEach((d) => d());
- this.closeInlineEditor(false);
- this.cancelConnect();
- this.clearLifelineUi();
- this.popover.hide();
-- this.container.classList.remove("mw-canvas", "mw-tool-connect", "mw-readonly");
-+ this.container.classList.remove("mw-canvas", "mw-tool-connect", "mw-readonly", "mw-panzoom", "mw-panning");
- this.svgHost.remove();
- this.overlayHost.remove();
- this.errorBadge.remove();
-+ this.zoomControls?.remove();
- }
- };
- export {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b60efe1a2..b5d0b699e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -122,9 +122,6 @@ patchedDependencies:
'@handlewithcare/remark-prosemirror@0.1.5':
hash: a6b60cc8640e04973be2c8123e0dbd73156b54388a57cd2b76191c2fd8ac2865
path: patches/@handlewithcare%2Fremark-prosemirror@0.1.5.patch
- '@inkeep/mermaid-wysiwyg-dom@0.1.0':
- hash: e1e3cd09ba940870d38765f74f33c1d0d582004731630c8bb807201d38ee81c9
- path: patches/@inkeep%2Fmermaid-wysiwyg-dom@0.1.0.patch
'@pierre/trees@1.0.0-beta.4':
hash: 86ac804ac75d9f365ee9ef918460463a211ac5201ebc3cf04fc0534c5b3adf88
path: patches/@pierre%2Ftrees@1.0.0-beta.4.patch
@@ -383,12 +380,6 @@ importers:
'@hookform/resolvers':
specifier: ^5.0.0
version: 5.4.0(react-hook-form@7.81.0(react@19.2.5))
- '@inkeep/mermaid-wysiwyg-core':
- specifier: ^0.1.0
- version: 0.1.0
- '@inkeep/mermaid-wysiwyg-dom':
- specifier: ^0.1.0
- version: 0.1.0(patch_hash=e1e3cd09ba940870d38765f74f33c1d0d582004731630c8bb807201d38ee81c9)
'@inkeep/open-knowledge-core':
specifier: workspace:*
version: link:../core
@@ -479,6 +470,12 @@ importers:
'@uiw/codemirror-theme-basic':
specifier: ^4.25.9
version: 4.25.11(@codemirror/language@6.12.4)(@codemirror/state@6.6.0)(@codemirror/view@6.43.3)
+ '@visimer/core':
+ specifier: ^0.1.0
+ version: 0.1.0
+ '@visimer/dom':
+ specifier: ^0.1.0
+ version: 0.1.0
'@xterm/addon-fit':
specifier: 0.11.0
version: 0.11.0
@@ -2557,12 +2554,6 @@ packages:
'@inkeep/cxkit-types@0.5.119':
resolution: {integrity: sha512-knp2rnA8hPw+eH6TUu5KM1Y824Vm8Pie0E/vEQZb3tZ0XI3ioXyEj8ym8ovcICuHsuDIGtiATtK35QVjeabQlw==}
- '@inkeep/mermaid-wysiwyg-core@0.1.0':
- resolution: {integrity: sha512-h0NjJzoyYBTP41+ERIOSU1U682UbY1AGF2cFICFg0SAskTaa+Ih4wv2ihVoAhavsGBGESL2TXLVX446tuJtT3Q==}
-
- '@inkeep/mermaid-wysiwyg-dom@0.1.0':
- resolution: {integrity: sha512-/kyARspcCz/Nz12Urp8U44MXMKp7SuY8ns7odkEvXVlgDsS40DgRlpjkB0qg3XQnwlwoqo2MDT5VJjtpJUTptg==}
-
'@inquirer/ansi@2.0.7':
resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'}
@@ -6067,6 +6058,12 @@ packages:
'@vimeo/player@2.30.4':
resolution: {integrity: sha512-M8m1UAhJSb+KCWuXDLWHViwj+3YY/0ogwFquRfMHs9e9LYjXT9iB7n+sOCKwUusbiXuU2HKmXx+FEGHtYZfUSg==}
+ '@visimer/core@0.1.0':
+ resolution: {integrity: sha512-PIvkhrWtsRq/a/i88Yb0shvKC1oaZPA5UBLMs9/MjYZ4JMZVXKtvgqjAw3hg7hNN9lyos3qTlNGnmFuMOUJ1Eg==}
+
+ '@visimer/dom@0.1.0':
+ resolution: {integrity: sha512-hvBtIma0hZDIKhqMtIqEjueX4nVq353RdULBxykjj/d7OO+QoVzDIUxD3nG3732MLpjfVoLtpHbT07lWDx2Qmw==}
+
'@vitejs/plugin-react@6.0.1':
resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -12802,12 +12799,6 @@ snapshots:
'@inkeep/cxkit-types@0.5.119': {}
- '@inkeep/mermaid-wysiwyg-core@0.1.0': {}
-
- '@inkeep/mermaid-wysiwyg-dom@0.1.0(patch_hash=e1e3cd09ba940870d38765f74f33c1d0d582004731630c8bb807201d38ee81c9)':
- dependencies:
- '@inkeep/mermaid-wysiwyg-core': 0.1.0
-
'@inquirer/ansi@2.0.7': {}
'@inquirer/checkbox@5.2.1(@types/node@24.13.3)':
@@ -16193,6 +16184,12 @@ snapshots:
native-promise-only: 0.8.1
weakmap-polyfill: 2.0.4
+ '@visimer/core@0.1.0': {}
+
+ '@visimer/dom@0.1.0':
+ dependencies:
+ '@visimer/core': 0.1.0
+
'@vitejs/plugin-react@6.0.1(@rolldown/plugin-babel@0.2.2(@babel/core@7.29.0)(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.0.8(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0)))(babel-plugin-react-compiler@0.0.0-experimental-a8e64ef-20260402)(vite@8.0.8(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.0)(yaml@2.9.0))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.7
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 0385f6931..454eb2afa 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -11,6 +11,7 @@ packages:
minimumReleaseAge: 4320
minimumReleaseAgeExclude:
- '@inkeep/*'
+ - '@visimer/*'
# Patch application failures fail the install CLOSED (never silently skipped) —
# the markdown pipeline depends on the pinned/patched remark-prosemirror hunks.
@@ -178,4 +179,3 @@ patchedDependencies:
'react-medium-image-zoom@5.4.3': patches/react-medium-image-zoom@5.4.3.patch
'@tiptap/extension-drag-handle@3.22.3': patches/@tiptap%2Fextension-drag-handle@3.22.3.patch
'@pierre/trees@1.0.0-beta.4': patches/@pierre%2Ftrees@1.0.0-beta.4.patch
- '@inkeep/mermaid-wysiwyg-dom@0.1.0': patches/@inkeep%2Fmermaid-wysiwyg-dom@0.1.0.patch
diff --git a/scripts/generate-third-party-notices.mjs b/scripts/generate-third-party-notices.mjs
index 633d6a9c7..f2a1942b8 100644
--- a/scripts/generate-third-party-notices.mjs
+++ b/scripts/generate-third-party-notices.mjs
@@ -269,8 +269,8 @@ function readNoticeText(pkgDir) {
* instead of the fail-closed audit bucket.
*/
const FIRST_PARTY_GPL_PACKAGES = new Set([
- '@inkeep/mermaid-wysiwyg-core',
- '@inkeep/mermaid-wysiwyg-dom',
+ '@visimer/core',
+ '@visimer/dom',
]);
const SPDX_OVERRIDES = {