From cef53874665657f770d10a947b397f486c8458a7 Mon Sep 17 00:00:00 2001 From: Abraham Date: Wed, 22 Jul 2026 15:57:55 -0700 Subject: [PATCH] chore(open-knowledge): consume @visimer/{core,dom} (#2850) * chore(open-knowledge): consume @visimer/{core,dom} @visimer/*@0.1.0 is now on npm and its dist ships the fixes we previously carried as a local patch, so patches/@inkeep%2Fmermaid-wysiwyg-dom@0.1.0.patch is retired. Adds @visimer/* to minimumReleaseAgeExclude so fresh publishes are not blocked by the cooldown. * chore: biome format + import order after visimer rename GitOrigin-RevId: 1a170f1da6c45f0002d53fca29a0d73a97d7c0c3 --- .changeset/visimer-package-bump.md | 5 + THIRD_PARTY_NOTICES.md | 13 +- packages/app/package.json | 4 +- .../src/editor/components/Mermaid.test.tsx | 4 +- .../app/src/editor/components/Mermaid.tsx | 15 +- .../@inkeep%2Fmermaid-wysiwyg-dom@0.1.0.patch | 726 ------------------ pnpm-lock.yaml | 39 +- pnpm-workspace.yaml | 2 +- scripts/generate-third-party-notices.mjs | 4 +- 9 files changed, 42 insertions(+), 770 deletions(-) create mode 100644 .changeset/visimer-package-bump.md delete mode 100644 patches/@inkeep%2Fmermaid-wysiwyg-dom@0.1.0.patch 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 = {