@oh-just-another/editor 0.58.2 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +40 -6
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/Diagram.d.ts +19 -1
  5. package/dist/Diagram.d.ts.map +1 -1
  6. package/dist/Diagram.js +173 -180
  7. package/dist/Diagram.js.map +1 -1
  8. package/dist/capabilities.d.ts.map +1 -1
  9. package/dist/capabilities.js +1 -8
  10. package/dist/capabilities.js.map +1 -1
  11. package/dist/constants.d.ts +17 -0
  12. package/dist/constants.d.ts.map +1 -0
  13. package/dist/constants.js +17 -0
  14. package/dist/constants.js.map +1 -0
  15. package/dist/define-shape.d.ts +71 -0
  16. package/dist/define-shape.d.ts.map +1 -0
  17. package/dist/define-shape.js +41 -0
  18. package/dist/define-shape.js.map +1 -0
  19. package/dist/dom-focus.d.ts +7 -4
  20. package/dist/dom-focus.d.ts.map +1 -1
  21. package/dist/dom-focus.js +7 -9
  22. package/dist/dom-focus.js.map +1 -1
  23. package/dist/file-actions.d.ts +52 -0
  24. package/dist/file-actions.d.ts.map +1 -0
  25. package/dist/file-actions.js +201 -0
  26. package/dist/file-actions.js.map +1 -0
  27. package/dist/gif-animation.d.ts +7 -0
  28. package/dist/gif-animation.d.ts.map +1 -0
  29. package/dist/gif-animation.js +99 -0
  30. package/dist/gif-animation.js.map +1 -0
  31. package/dist/index.d.ts +12 -2
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +15 -1
  34. package/dist/index.js.map +1 -1
  35. package/dist/png-export.d.ts +3 -9
  36. package/dist/png-export.d.ts.map +1 -1
  37. package/dist/png-export.js +5 -13
  38. package/dist/png-export.js.map +1 -1
  39. package/dist/themed-portal-container.d.ts +25 -0
  40. package/dist/themed-portal-container.d.ts.map +1 -0
  41. package/dist/themed-portal-container.js +47 -0
  42. package/dist/themed-portal-container.js.map +1 -0
  43. package/package.json +20 -18
package/dist/Diagram.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from "react";
3
3
  import { Clipboard, Copy, Delete, Download, FileDown, FileUp, Grid3x3, Grip, HelpCircle, ImageDown, Library as LibraryIcon, Magnet, Maximize, Minus, Monitor, Moon, MousePointer, Plus, Redo2, RotateCcw, Scissors, Sun, Undo2, ZoomIn, ZoomOut, } from "lucide-react";
4
- import { BottomBar, BottomSheet, ButtonGroup, ContextMenu, DEFAULT_CONTEXT_MENU, DEFAULT_VERTICAL_TOOLBAR, DiagramRoot, CommandPalette, DiagramSurface, HelpButton, HelpDialog, IconButton, LibraryPanel, MainMenu, ResetToContentButton, LinkHoverPopup, LinkDropShapeMenu, LinkCaptionEditor, SelectionFloatingPanel, TextEditorOverlay, FrameNameEditorOverlay, ToastHost, Toolbar, Tooltip, TooltipProvider, TopBar, UILayer, useDiagramOptional, useHelpDialogHotkey, useMobileLayout, usePalettePlacement, useScene, } from "@oh-just-another/react-ui";
4
+ import { BottomBar, BottomSheet, ButtonGroup, ContextMenu, DEFAULT_CONTEXT_MENU, DEFAULT_VERTICAL_TOOLBAR, DiagramRoot, CommandPalette, DiagramSurface, HelpButton, HelpDialog, IconButton, DrawingPanel, LibraryPanel, MainMenu, Minimap, ResetToContentButton, LinkHoverPopup, LinkDropShapeMenu, LinkCaptionEditor, SelectionFloatingPanel, SearchOverlay, StatsPanel, TextEditorOverlay, FrameNameEditorOverlay, PortalContainerProvider, ToastHost, Toolbar, Tooltip, TooltipProvider, TopBar, UILayer, ZenModeProvider, useDiagramOptional, useHelpDialogHotkey, useMobileLayout, usePalettePlacement, useScene, useZenMode, } from "@oh-just-another/react-ui";
5
5
  /**
6
6
  * Lucide icon sizing — `MENU_ICON_SIZE` is for in-row icons of
7
7
  * `MainMenu.Item`, `TOGGLE_ICON_SIZE` is for the segmented Theme /
@@ -19,20 +19,21 @@ const buttonIcon = { size: BUTTON_ICON_SIZE, strokeWidth: BUTTON_ICON_STROKE };
19
19
  /** Default target for the Help-menu "GitHub" link (overridable / hideable via the `repositoryUrl` prop). */
20
20
  const DEFAULT_REPOSITORY_URL = "https://github.com/oh-just-another/diagram";
21
21
  import { formatHotkey } from "@oh-just-another/state";
22
- import { emptyScene } from "@oh-just-another/scene";
23
- import { parseScene, stringifyScene } from "@oh-just-another/serialization";
24
- import { renderSceneToSvg } from "@oh-just-another/renderer-svg";
22
+ import { hydrateScene, isText, } from "@oh-just-another/scene";
25
23
  import { WasmTextShaper } from "@oh-just-another/text-wasm";
26
24
  import { WasmRasterizer } from "@oh-just-another/raster-wasm";
25
+ import { registerBundledFonts } from "@oh-just-another/fonts";
27
26
  import { createRenderWorker } from "@oh-just-another/renderer-canvas";
28
27
  import { registerAnimationAdapter, setActiveRasterizer, setActiveTextShaper, } from "@oh-just-another/renderer-core";
29
28
  import { registerLayoutKind } from "@oh-just-another/scene";
30
29
  import { defaultRegistry } from "@oh-just-another/templates";
31
30
  import { detectCapabilities, logCapabilities, } from "./capabilities";
32
- import { exportSceneToPng } from "./png-export";
31
+ import { installGifAnimationAdapter } from "./gif-animation.js";
32
+ import { useThemedPortalContainer } from "./themed-portal-container.js";
33
+ import { downloadScene, downloadSvg, downloadPng, openSceneFile, copySceneAsImage, registerFileActions, setFileActionNotifier, } from "./file-actions.js";
33
34
  import { isEditableTarget } from "./dom-focus";
34
35
  export const Diagram = forwardRef(function Diagram(props, ref) {
35
- const { initialScene, initialMode = "select", templates, fileDropHandlers, layoutKinds, animationAdapters, onReady, onSceneChange, onSelectionChange, capabilities: capabilityOverrides, workerFactory, hideTopBar, hideBottomBar, hideToolbar, hideLibraryButton, hideMainMenu, hideZoomControls, hideResetToContent, hideHelpButton, hideContextMenu, hideSelectionPanel, renderTopBarLeft, renderTopBarCenter, renderTopBarRight, renderBottomBarLeft, renderBottomBarCenter, renderBottomBarRight, renderMainMenuExtras, onImportTemplates, theme: themeProp, defaultTheme = "system", onThemeChange, persistTheme, repositoryUrl, onConfirm, onNotify, className, style, } = props;
36
+ const { initialScene, initialMode = "select", grid, snap, templates, fileDropHandlers, layoutKinds, animationAdapters, onReady, onSceneChange, onSelectionChange, capabilities: capabilityOverrides, workerFactory, hideTopBar, hideBottomBar, hideToolbar, hideLibraryButton, hideMainMenu, hideZoomControls, hideResetToContent, hideHelpButton, hideContextMenu, hideSelectionPanel, hideDrawingPanel, minimap, renderTopBarLeft, renderTopBarCenter, renderTopBarRight, renderBottomBarLeft, renderBottomBarCenter, renderBottomBarRight, renderMainMenuExtras, onImportTemplates, theme: themeProp, defaultTheme = "system", onThemeChange, persistTheme, repositoryUrl, onConfirm, onNotify, className, style, } = props;
36
37
  // Theme: controlled when `themeProp` is provided, otherwise
37
38
  // self-managed via `internalTheme`. The lazy `useState` initializer
38
39
  // reads from `localStorage` once when `persistTheme` is on.
@@ -52,6 +53,9 @@ export const Diagram = forwardRef(function Diagram(props, ref) {
52
53
  return defaultTheme;
53
54
  });
54
55
  const theme = themeProp ?? internalTheme;
56
+ // Floating UI portals here (a body-level wrapper that mirrors `theme`) so it
57
+ // inherits the app theme instead of the OS `prefers-color-scheme` fallback.
58
+ const portalContainer = useThemedPortalContainer(theme);
55
59
  const changeTheme = useCallback((next) => {
56
60
  if (themeProp === undefined)
57
61
  setInternalTheme(next);
@@ -60,17 +64,30 @@ export const Diagram = forwardRef(function Diagram(props, ref) {
60
64
  }
61
65
  onThemeChange?.(next);
62
66
  }, [themeProp, onThemeChange, storageKey]);
63
- const seed = useMemo(() => initialScene ?? emptyScene(), [initialScene]);
67
+ // Seed scene: host grid/snap props are merged over the defaults; a persisted
68
+ // `initialScene` (user data) wins over them. Depend on primitives so an
69
+ // inline `grid` object prop doesn't re-seed the editor every render.
70
+ const gridEnabled = grid?.enabled;
71
+ const gridStyle = grid?.style;
72
+ const seed = useMemo(() => hydrateScene({
73
+ ...(initialScene ? { saved: initialScene } : {}),
74
+ hostSettings: buildHostSettings(gridEnabled, gridStyle, snap),
75
+ }), [initialScene, gridEnabled, gridStyle, snap]);
64
76
  // Does the initial scene contain any text? Drives whether first paint
65
77
  // waits for the MSDF shaper (see the mount gate below).
66
78
  const sceneHasText = useMemo(() => {
67
79
  for (const s of seed.elements.values())
68
- if (s.type === "text")
80
+ if (isText(s))
69
81
  return true;
70
82
  return false;
71
83
  }, [seed]);
72
84
  // --- Plugin registration ---
73
85
  useEffect(() => {
86
+ // Built-in GIF decoder, registered by default so dropped / pasted GIFs play
87
+ // out of the box. Idempotent + lazy (gifuct-js loads on first decode). A
88
+ // host `animationAdapters` entry with kind "gif" overrides it (those are
89
+ // registered after).
90
+ installGifAnimationAdapter();
74
91
  if (templates)
75
92
  for (const t of templates)
76
93
  defaultRegistry.register(t);
@@ -90,6 +107,9 @@ export const Diagram = forwardRef(function Diagram(props, ref) {
90
107
  // font is ready, so text doesn't render in a fallback font and then
91
108
  // snap to the WASM font ("jump" on load — a FOUT).
92
109
  const [wasmTextSettled, setWasmTextSettled] = useState(false);
110
+ // Flipped once the bundled web fonts finish loading, so the canvas can
111
+ // redraw text in them (the browser doesn't auto-repaint canvas text).
112
+ const [fontsReady, setFontsReady] = useState(false);
93
113
  const detectionRef = useRef(null);
94
114
  const loggedRef = useRef(false);
95
115
  useEffect(() => {
@@ -106,6 +126,18 @@ export const Diagram = forwardRef(function Diagram(props, ref) {
106
126
  }
107
127
  setProfile(detected);
108
128
  const loads = [];
129
+ // Load the bundled fonts so every backend draws the same faces; redraw
130
+ // once they settle.
131
+ loads.push(registerBundledFonts(document).then(() => {
132
+ if (!cancelled)
133
+ setFontsReady(true);
134
+ }, (err) => {
135
+ // Settle even on failure so a text scene still mounts (in the
136
+ // fallback font) instead of hanging on the first-paint gate.
137
+ if (!cancelled)
138
+ setFontsReady(true);
139
+ console.warn("[diagram] bundled fonts load failed", err);
140
+ }));
109
141
  if (detected.wasmText) {
110
142
  loads.push(WasmTextShaper.loadBundled().then((shaper) => {
111
143
  if (cancelled)
@@ -156,19 +188,24 @@ export const Diagram = forwardRef(function Diagram(props, ref) {
156
188
  editor.setMode(editor.mode);
157
189
  return undefined;
158
190
  }, [editor, wasmShaper, wasmRaster]);
159
- // Animation adapters (GIF decoder) are registered in the plugin
160
- // effect above, which runs AFTER the editor's first paint (child
161
- // effects fire before parent ones). Force one render once both the
162
- // editor and the adapters are in place so each animated shape's first
163
- // `getFrameAt` runs — that kicks off the async decode, after which the
164
- // decode→re-render nudge (`onAnimationContentReady`) paints the frame.
165
- // Without this, a paused GIF restored from storage never even starts
166
- // decoding and stays blank.
191
+ // Animation adapters (GIF decoder) are registered in the plugin effect above,
192
+ // which runs AFTER the editor's first paint (child effects fire before parent
193
+ // ones). Force one render once the editor is ready so each animated shape's
194
+ // first `getFrameAt` runs that kicks off the async decode, after which the
195
+ // decode→re-render nudge (`onAnimationContentReady`) paints the frame. The
196
+ // built-in GIF adapter is always registered, so this nudge is unconditional
197
+ // (also re-runs if a host swaps `animationAdapters`). Without it, a paused GIF
198
+ // restored from storage never even starts decoding and stays blank.
167
199
  useEffect(() => {
168
- if (editor && animationAdapters && animationAdapters.length > 0) {
200
+ if (editor)
169
201
  editor.forceRender();
170
- }
171
202
  }, [editor, animationAdapters]);
203
+ // Redraw once the bundled fonts load so canvas text switches from the
204
+ // fallback face to the bundled one.
205
+ useEffect(() => {
206
+ if (editor && fontsReady)
207
+ editor.forceRender();
208
+ }, [editor, fontsReady]);
172
209
  useEffect(() => {
173
210
  if (!editor || (!onSceneChange && !onSelectionChange))
174
211
  return undefined;
@@ -185,30 +222,58 @@ export const Diagram = forwardRef(function Diagram(props, ref) {
185
222
  }
186
223
  });
187
224
  }, [editor, onSceneChange, onSelectionChange]);
188
- // Hold Cmd / Ctrl to temporarily pull a shape off the grid during a
189
- // drag (standard). We read the modifier state off every key event
190
- // (and reset on blur) so a missed keyup can't leave snapping stuck off.
225
+ // Track transform modifiers off every key event (and reset on blur) so a
226
+ // missed keyup can't leave a flag stuck: Cmd/Ctrl pulls a shape off the grid
227
+ // for one drag; Alt resizes about the centre; Shift locks the resize aspect
228
+ // ratio or constrains a move to one axis.
191
229
  useEffect(() => {
192
230
  if (!editor)
193
231
  return undefined;
232
+ const ed = editor;
194
233
  const sync = (e) => {
195
- // Don't track the modifier (or touch snap state) while typing in a
196
- // field — keep the editor's snap-suppress flag inert there.
234
+ // Don't track modifiers (or touch snap state) while typing in a field —
235
+ // keep the editor's transform flags inert there.
236
+ if (isEditableTarget(e.target))
237
+ return;
238
+ ed.setSnapSuppressed(e.metaKey || e.ctrlKey);
239
+ ed.setTransformModifiers({ alt: e.altKey, shift: e.shiftKey });
240
+ };
241
+ // Flowchart CREATE lifecycle: Cmd/Ctrl+Arrow grows a pending preview; the
242
+ // session commits when Cmd/Ctrl is released and cancels on Escape. Keydown
243
+ // handles the cancel; keyup the commit.
244
+ const onKeyDown = (e) => {
245
+ sync(e);
197
246
  if (isEditableTarget(e.target))
198
247
  return;
199
- editor.setSnapSuppressed(e.metaKey || e.ctrlKey);
248
+ if (e.key === "Escape" && ed.flowchartPreview !== null) {
249
+ ed.cancelFlowchart();
250
+ e.preventDefault();
251
+ }
252
+ };
253
+ const onKeyUp = (e) => {
254
+ sync(e);
255
+ // Cmd/Ctrl released while a session is open → commit the preview.
256
+ if (!e.metaKey && !e.ctrlKey && ed.flowchartPreview !== null) {
257
+ ed.commitFlowchart();
258
+ }
200
259
  };
201
260
  const reset = () => {
202
- editor.setSnapSuppressed(false);
261
+ ed.setSnapSuppressed(false);
262
+ ed.setTransformModifiers({ alt: false, shift: false });
263
+ // A window blur can swallow the Cmd/Ctrl keyup — commit any live session
264
+ // so a missed keyup can't strand the preview.
265
+ if (ed.flowchartPreview !== null)
266
+ ed.commitFlowchart();
203
267
  };
204
- window.addEventListener("keydown", sync);
205
- window.addEventListener("keyup", sync);
268
+ window.addEventListener("keydown", onKeyDown);
269
+ window.addEventListener("keyup", onKeyUp);
206
270
  window.addEventListener("blur", reset);
207
271
  return () => {
208
- window.removeEventListener("keydown", sync);
209
- window.removeEventListener("keyup", sync);
272
+ window.removeEventListener("keydown", onKeyDown);
273
+ window.removeEventListener("keyup", onKeyUp);
210
274
  window.removeEventListener("blur", reset);
211
- editor.setSnapSuppressed(false);
275
+ ed.setSnapSuppressed(false);
276
+ ed.setTransformModifiers({ alt: false, shift: false });
212
277
  };
213
278
  }, [editor]);
214
279
  useImperativeHandle(ref, () => ({
@@ -233,24 +298,26 @@ export const Diagram = forwardRef(function Diagram(props, ref) {
233
298
  if (!profile) {
234
299
  return _jsx("div", { className: className, style: style });
235
300
  }
236
- // Hold the first paint of a text-bearing scene until the MSDF shaper
237
- // has settled, so text renders in its final font from frame one (no
238
- // fallback-font WASM-font jump). Only the WebGL2 MSDF path swaps
239
- // fonts like this; Canvas2D always draws system fonts (no jump), and
240
- // text-free scenes have nothing to jump — both mount immediately, so
241
- // we don't pay shaper-load latency on first paint.
301
+ // Hold the first paint of a text-bearing scene until its font is ready, so
302
+ // text renders in its final face from frame one (no fallback-font jump).
303
+ // Every backend now draws the bundled fonts, so all wait on `fontsReady`;
304
+ // the WebGL2 MSDF path also waits on the shaper. Text-free scenes mount
305
+ // immediately and don't pay the load latency.
306
+ if (sceneHasText && !fontsReady) {
307
+ return _jsx("div", { className: className, style: style });
308
+ }
242
309
  if (profile.renderer === "webgl2" && profile.wasmText && !wasmTextSettled && sceneHasText) {
243
310
  return _jsx("div", { className: className, style: style });
244
311
  }
245
- return (_jsx(ToastHost, { children: _jsx(TooltipProvider, { children: _jsx("div", { className: className, "data-diagram-root": true, ...(theme === "system" ? {} : { "data-theme": theme }), style: {
246
- position: "relative",
247
- width: "100%",
248
- height: "100%",
249
- background: "var(--du-canvas-bg)",
250
- ...style,
251
- }, children: _jsx(DiagramRoot, { initialScene: seed, initialMode: initialMode, onReady: handleReady, renderer: profile.renderer, ...(profile.renderer === "offscreen"
252
- ? { workerFactory: workerFactory ?? createRenderWorker }
253
- : {}), ...(wasmShaper ? { textShaper: wasmShaper } : {}), ...(wasmRaster ? { rasterizer: wasmRaster } : {}), children: _jsx(EditorShell, { hideTopBar: hideTopBar, hideBottomBar: hideBottomBar, hideToolbar: hideToolbar, hideLibraryButton: hideLibraryButton, hideMainMenu: hideMainMenu, hideZoomControls: hideZoomControls, hideResetToContent: hideResetToContent, hideHelpButton: hideHelpButton, hideContextMenu: hideContextMenu, hideSelectionPanel: hideSelectionPanel, renderTopBarLeft: renderTopBarLeft, renderTopBarCenter: renderTopBarCenter, renderTopBarRight: renderTopBarRight, renderBottomBarLeft: renderBottomBarLeft, renderBottomBarCenter: renderBottomBarCenter, renderBottomBarRight: renderBottomBarRight, renderMainMenuExtras: renderMainMenuExtras, onImportTemplates: onImportTemplates, repositoryUrl: repositoryUrl, onConfirm: onConfirm, onNotify: onNotify, theme: theme, changeTheme: changeTheme }) }) }) }) }));
312
+ return (_jsx(PortalContainerProvider, { container: portalContainer, children: _jsx(ToastHost, { children: _jsx(TooltipProvider, { children: _jsx("div", { className: className, "data-diagram-root": true, ...(theme === "system" ? {} : { "data-theme": theme }), style: {
313
+ position: "relative",
314
+ width: "100%",
315
+ height: "100%",
316
+ background: "var(--du-canvas-bg)",
317
+ ...style,
318
+ }, children: _jsx(DiagramRoot, { initialScene: seed, initialMode: initialMode, onReady: handleReady, renderer: profile.renderer, ...(profile.renderer === "offscreen"
319
+ ? { workerFactory: workerFactory ?? createRenderWorker }
320
+ : {}), ...(wasmShaper ? { textShaper: wasmShaper } : {}), ...(wasmRaster ? { rasterizer: wasmRaster } : {}), children: _jsx(ZenModeProvider, { children: _jsx(EditorShell, { hideTopBar: hideTopBar, hideBottomBar: hideBottomBar, hideToolbar: hideToolbar, hideLibraryButton: hideLibraryButton, hideMainMenu: hideMainMenu, hideZoomControls: hideZoomControls, hideResetToContent: hideResetToContent, hideHelpButton: hideHelpButton, hideContextMenu: hideContextMenu, hideSelectionPanel: hideSelectionPanel, hideDrawingPanel: hideDrawingPanel, minimap: minimap, renderTopBarLeft: renderTopBarLeft, renderTopBarCenter: renderTopBarCenter, renderTopBarRight: renderTopBarRight, renderBottomBarLeft: renderBottomBarLeft, renderBottomBarCenter: renderBottomBarCenter, renderBottomBarRight: renderBottomBarRight, renderMainMenuExtras: renderMainMenuExtras, onImportTemplates: onImportTemplates, repositoryUrl: repositoryUrl, onConfirm: onConfirm, onNotify: onNotify, theme: theme, changeTheme: changeTheme }) }) }) }) }) }) }));
254
321
  });
255
322
  // Match the primary public name (`<Editor>`) in DevTools / stack traces,
256
323
  // even though the internal forwardRef function is named `Diagram`.
@@ -261,8 +328,11 @@ Diagram.displayName = "Editor";
261
328
  * `useHelpDialogHotkey`) resolve correctly. Composes the
262
329
  * canvas-surface + ui-layer overlay + side panels into one tree.
263
330
  */
264
- const EditorShell = ({ hideTopBar, hideBottomBar, hideToolbar, hideLibraryButton, hideMainMenu, hideZoomControls, hideResetToContent, hideHelpButton, hideContextMenu, hideSelectionPanel, renderTopBarLeft, renderTopBarCenter, renderTopBarRight, renderBottomBarLeft, renderBottomBarCenter, renderBottomBarRight, renderMainMenuExtras, onImportTemplates, repositoryUrl, onConfirm, onNotify, theme, changeTheme, }) => {
331
+ const EditorShell = ({ hideTopBar, hideBottomBar, hideToolbar, hideLibraryButton, hideMainMenu, hideZoomControls, hideResetToContent, hideHelpButton, hideContextMenu, hideSelectionPanel, hideDrawingPanel, minimap, renderTopBarLeft, renderTopBarCenter, renderTopBarRight, renderBottomBarLeft, renderBottomBarCenter, renderBottomBarRight, renderMainMenuExtras, onImportTemplates, repositoryUrl, onConfirm, onNotify, theme, changeTheme, }) => {
265
332
  const editor = useDiagramOptional();
333
+ // Zen mode (⌥Z): hide every chrome surface for focused work, leaving the
334
+ // canvas + the observational overlays (search, stats, command palette).
335
+ const { zen } = useZenMode();
266
336
  // Omitted → project repo; explicit string → that URL; null → no link.
267
337
  const repositoryHref = repositoryUrl === undefined ? DEFAULT_REPOSITORY_URL : repositoryUrl;
268
338
  // Native dialogs by default; hosts can route through their own UI.
@@ -271,8 +341,16 @@ const EditorShell = ({ hideTopBar, hideBottomBar, hideToolbar, hideLibraryButton
271
341
  ((message) => {
272
342
  window.alert(message);
273
343
  });
344
+ // Register the file-ops actions (Save / Open / Export / Copy-as-image)
345
+ // on the shared registry so hosts binding hotkeys / the command palette
346
+ // pick them up, and route their error messages through this shell's
347
+ // notifier (host toast or the alert fallback above).
348
+ useEffect(() => {
349
+ registerFileActions();
350
+ setFileActionNotifier(notify);
351
+ }, [notify]);
274
352
  // Subscribe to scene changes so the Grid toggle in MainMenu reads
275
- // the latest viewport.gridSize / gridStyle. `useScene` is a thin
353
+ // the latest viewport.gridEnabled / gridStyle. `useScene` is a thin
276
354
  // selector hook — re-renders only on scene identity flips.
277
355
  void useScene();
278
356
  const paletteDropHandlers = usePalettePlacement();
@@ -319,7 +397,7 @@ const EditorShell = ({ hideTopBar, hideBottomBar, hideToolbar, hideLibraryButton
319
397
  bottom: 0,
320
398
  left: 0,
321
399
  right: 0,
322
- }, children: [_jsx(DiagramSurface, { style: { position: "absolute", inset: 0 } }), _jsx(TextEditorOverlay, {}), _jsx(FrameNameEditorOverlay, {}), _jsx(LinkHoverPopup, {}), _jsx(LinkDropShapeMenu, {}), _jsx(LinkCaptionEditor, {}), !hideContextMenu && _jsx(ContextMenu, { items: DEFAULT_CONTEXT_MENU })] }), !hideToolbar ? (_jsx(Toolbar, { orientation: "vertical", items: toolbarItems, style: {
400
+ }, children: [_jsx(DiagramSurface, { style: { position: "absolute", inset: 0 } }), _jsx(TextEditorOverlay, {}), _jsx(FrameNameEditorOverlay, {}), _jsx(LinkHoverPopup, {}), _jsx(LinkDropShapeMenu, {}), _jsx(LinkCaptionEditor, {}), !hideContextMenu && _jsx(ContextMenu, { items: DEFAULT_CONTEXT_MENU })] }), !hideToolbar && !zen ? (_jsx(Toolbar, { orientation: "vertical", items: toolbarItems, style: {
323
401
  position: "absolute",
324
402
  // Vertically centred on the left; floats just to the right of
325
403
  // the library when it's open (else flush near the edge).
@@ -329,18 +407,22 @@ const EditorShell = ({ hideTopBar, hideBottomBar, hideToolbar, hideLibraryButton
329
407
  left: `calc(env(safe-area-inset-left, 0px) + ${toolbarLeft}px)`,
330
408
  transform: "translateY(-50%)",
331
409
  zIndex: 60,
332
- } })) : null, _jsxs(UILayer, { children: [!hideTopBar && (_jsx(TopBar, { left: _jsxs(ButtonGroup, { ariaLabel: "Logo and main menu", children: [_jsx("span", { "aria-label": "Diagram", title: "Diagram", className: "du-icon-button", style: {
410
+ } })) : null, _jsxs(UILayer, { children: [!hideTopBar && !zen && (_jsx(TopBar, { left: _jsxs(ButtonGroup, { ariaLabel: "Logo and main menu", children: [_jsx("span", { "aria-label": "Diagram", title: "Diagram", className: "du-icon-button", style: {
333
411
  minWidth: 36,
334
412
  padding: "0 10px",
335
413
  cursor: "default",
336
414
  fontWeight: 600,
337
415
  letterSpacing: 0.5,
338
416
  }, children: "\u2317" }), !hideMainMenu && (_jsxs(MainMenu, { children: [_jsxs(MainMenu.Group, { title: "File", children: [_jsx(MainMenu.Item, { icon: _jsx(FileUp, { ...menuIcon }), onClick: () => {
339
- openSceneFile(editor, notify);
340
- }, children: "Open\u2026" }), _jsx(MainMenu.Item, { icon: _jsx(FileDown, { ...menuIcon }), onClick: () => {
417
+ if (editor)
418
+ openSceneFile(editor);
419
+ }, shortcut: formatHotkey({ key: "O", meta: true }), children: "Open\u2026" }), _jsx(MainMenu.Item, { icon: _jsx(FileDown, { ...menuIcon }), onClick: () => {
341
420
  if (editor)
342
421
  downloadScene(editor.scene);
343
- }, disabled: !editor, children: "Save as JSON" }), _jsxs(MainMenu.Submenu, { icon: _jsx(Download, { ...menuIcon }), label: "Export\u2026", disabled: !editor, children: [_jsx(MainMenu.Item, { icon: _jsx(ImageDown, { ...menuIcon }), onClick: () => editor && void downloadPng(editor, "transparent", notify), disabled: !editor, children: "PNG (transparent)" }), _jsx(MainMenu.Item, { icon: _jsx(ImageDown, { ...menuIcon }), onClick: () => editor && void downloadPng(editor, "color", notify), disabled: !editor, children: "PNG (with background)" }), _jsx(MainMenu.Item, { icon: _jsx(ImageDown, { ...menuIcon }), onClick: () => editor && void downloadPng(editor, "color-and-grid", notify), disabled: !editor, children: "PNG (with background + grid)" }), _jsx(MainMenu.Separator, {}), _jsx(MainMenu.Item, { icon: _jsx(Download, { ...menuIcon }), onClick: () => {
422
+ }, disabled: !editor, shortcut: formatHotkey({ key: "S", meta: true }), children: "Save as JSON" }), _jsx(MainMenu.Item, { icon: _jsx(Copy, { ...menuIcon }), onClick: () => {
423
+ if (editor)
424
+ void copySceneAsImage(editor);
425
+ }, disabled: !editor, shortcut: formatHotkey({ key: "C", shift: true, alt: true }), children: "Copy as image" }), _jsxs(MainMenu.Submenu, { icon: _jsx(Download, { ...menuIcon }), label: "Export\u2026", disabled: !editor, children: [_jsx(MainMenu.Item, { icon: _jsx(ImageDown, { ...menuIcon }), onClick: () => editor && void downloadPng(editor, "transparent"), disabled: !editor, children: "PNG (transparent)" }), _jsx(MainMenu.Item, { icon: _jsx(ImageDown, { ...menuIcon }), onClick: () => editor && void downloadPng(editor, "color"), disabled: !editor, shortcut: formatHotkey({ key: "E", meta: true, shift: true }), children: "PNG (with background)" }), _jsx(MainMenu.Item, { icon: _jsx(ImageDown, { ...menuIcon }), onClick: () => editor && void downloadPng(editor, "color-and-grid"), disabled: !editor, children: "PNG (with background + grid)" }), _jsx(MainMenu.Separator, {}), _jsx(MainMenu.Item, { icon: _jsx(Download, { ...menuIcon }), onClick: () => {
344
426
  if (editor)
345
427
  downloadSvg(editor.scene);
346
428
  }, disabled: !editor, children: "SVG" })] }), _jsx(MainMenu.Item, { icon: _jsx(RotateCcw, { ...menuIcon }), onClick: () => {
@@ -348,10 +430,10 @@ const EditorShell = ({ hideTopBar, hideBottomBar, hideToolbar, hideLibraryButton
348
430
  return;
349
431
  if (confirmDialog("Reset canvas? This clears all shapes.")) {
350
432
  // editor.clear() keeps viewport (zoom /
351
- // pan / gridSize) and layers — only
433
+ // pan / gridEnabled) and layers — only
352
434
  // shapes / edges go. loadScene(emptyScene())
353
- // would also drop the grid because
354
- // DEFAULT_VIEWPORT has no gridSize.
435
+ // would also reset the grid because
436
+ // DEFAULT_VIEWPORT has it disabled.
355
437
  editor.clear();
356
438
  }
357
439
  }, disabled: !editor, children: "Reset canvas" })] }), _jsx(MainMenu.Separator, {}), _jsxs(MainMenu.Group, { title: "Edit", children: [_jsx(MainMenu.Item, { icon: _jsx(Undo2, { ...menuIcon }), shortcut: "\u2318Z", onClick: () => editor?.undo(), disabled: !editor, children: "Undo" }), _jsx(MainMenu.Item, { icon: _jsx(Redo2, { ...menuIcon }), shortcut: "\u21E7\u2318Z", onClick: () => editor?.redo(), disabled: !editor, children: "Redo" }), _jsx(MainMenu.Item, { icon: _jsx(Scissors, { ...menuIcon }), shortcut: "\u2318X", onClick: () => editor?.cutSelected(), disabled: !editor, children: "Cut" }), _jsx(MainMenu.Item, { icon: _jsx(Copy, { ...menuIcon }), shortcut: "\u2318C", onClick: () => editor?.copySelected(), disabled: !editor, children: "Copy" }), _jsx(MainMenu.Item, { icon: _jsx(Clipboard, { ...menuIcon }), shortcut: "\u2318V", onClick: () => editor?.paste(), disabled: !editor, children: "Paste" }), _jsx(MainMenu.Item, { icon: _jsx(MousePointer, { ...menuIcon }), shortcut: "\u2318A", onClick: () => editor?.selectAll(), disabled: !editor, children: "Select all" }), _jsx(MainMenu.Item, { icon: _jsx(Delete, { ...menuIcon }), shortcut: "\u232B", onClick: () => editor?.deleteSelected(), disabled: !editor, children: "Delete selected" })] }), _jsx(MainMenu.Separator, {}), _jsxs(MainMenu.Group, { title: "View", children: [_jsx(MainMenu.Item, { icon: _jsx(Maximize, { ...menuIcon }), shortcut: "\u21E7F", onClick: () => editor?.zoomToFit(), disabled: !editor, children: "Fit to screen" }), _jsx(MainMenu.Item, { icon: _jsx(ZoomIn, { ...menuIcon }), shortcut: "\u2318+", onClick: () => editor?.zoomIn(), disabled: !editor, children: "Zoom in" }), _jsx(MainMenu.Item, { icon: _jsx(ZoomOut, { ...menuIcon }), shortcut: "\u2318\u2212", onClick: () => editor?.zoomOut(), disabled: !editor, children: "Zoom out" })] }), _jsx(MainMenu.Separator, {}), _jsx(MainMenu.Group, { title: "Theme", children: _jsx(MainMenu.Toggle, { value: theme, onChange: changeTheme, options: [
@@ -371,24 +453,39 @@ const EditorShell = ({ hideTopBar, hideBottomBar, hideToolbar, hideLibraryButton
371
453
  { value: "off", label: "Off", icon: _jsx(Minus, { ...toggleIcon }) },
372
454
  ] }) }), _jsx(MainMenu.Separator, {}), _jsxs(MainMenu.Group, { title: "Help", children: [_jsx(MainMenu.Item, { icon: _jsx(HelpCircle, { ...menuIcon }), shortcut: "?", onClick: () => {
373
455
  setHelpOpen(true);
374
- }, children: "Hotkeys" }), repositoryHref ? (_jsx(MainMenu.ItemLink, { href: repositoryHref, external: true, children: "GitHub" })) : null] }), renderMainMenuExtras ? (_jsxs(_Fragment, { children: [_jsx(MainMenu.Separator, {}), renderMainMenuExtras()] })) : null] })), renderTopBarLeft ? renderTopBarLeft() : null] }), center: renderTopBarCenter?.(), right: _jsx(ButtonGroup, { ariaLabel: "Top bar actions", children: renderTopBarRight ? renderTopBarRight() : null }) })), !hideBottomBar && (_jsx(BottomBar, { left: renderBottomBarLeft ? renderBottomBarLeft() : null, center: renderBottomBarCenter ? (renderBottomBarCenter()) : !hideResetToContent ? (_jsx(ResetToContentButton, {})) : null, right: renderBottomBarRight ? (renderBottomBarRight()) : !hideZoomControls ? (
456
+ }, children: "Hotkeys" }), repositoryHref ? (_jsx(MainMenu.ItemLink, { href: repositoryHref, external: true, children: "GitHub" })) : null] }), renderMainMenuExtras ? (_jsxs(_Fragment, { children: [_jsx(MainMenu.Separator, {}), renderMainMenuExtras()] })) : null] })), renderTopBarLeft ? renderTopBarLeft() : null] }), center: renderTopBarCenter?.(), right: _jsx(ButtonGroup, { ariaLabel: "Top bar actions", children: renderTopBarRight ? renderTopBarRight() : null }) })), !hideBottomBar && !zen && (_jsx(BottomBar, { left: renderBottomBarLeft ? renderBottomBarLeft() : null, center: renderBottomBarCenter ? (renderBottomBarCenter()) : !hideResetToContent ? (_jsx(ResetToContentButton, {})) : null, right: renderBottomBarRight ? (renderBottomBarRight()) : !hideZoomControls ? (
375
457
  // Help sits inside the zoom pill group, right next to it.
376
- _jsx(ZoomControls, { trailing: !hideHelpButton ? _jsx(HelpButton, {}) : undefined })) : !hideHelpButton ? (_jsx(HelpButton, {})) : null })), mobile ? (libraryOpen ? (_jsx(BottomSheet, { snapPoints: [0, 60, 92], defaultValue: 60, style: { pointerEvents: "auto" }, onChange: (vh) => {
458
+ _jsx(ZoomControls, { trailing: !hideHelpButton ? _jsx(HelpButton, {}) : undefined })) : !hideHelpButton ? (_jsx(HelpButton, {})) : null })), zen ? null : mobile ? (libraryOpen ? (_jsx(BottomSheet, { snapPoints: [0, 60, 92], defaultValue: 60, style: { pointerEvents: "auto" }, onChange: (vh) => {
377
459
  if (vh <= 0)
378
460
  setLibraryOpen(false);
379
461
  }, children: _jsx(LibraryPanel, { open: true, sheet: true, onClose: () => {
380
462
  setLibraryOpen(false);
381
463
  }, ...(onImportTemplates ? { onImport: onImportTemplates } : {}) }) })) : null) : (_jsx(LibraryPanel, { open: libraryOpen, side: "left", style: { left: 0 }, onClose: () => {
382
464
  setLibraryOpen(false);
383
- }, ...(onImportTemplates ? { onImport: onImportTemplates } : {}) }))] }), !hideSelectionPanel && _jsx(SelectionFloatingPanel, {}), _jsx(HelpDialog, { open: helpOpen, onClose: () => {
465
+ }, ...(onImportTemplates ? { onImport: onImportTemplates } : {}) }))] }), minimap && !zen && (_jsx("div", { style: {
466
+ position: "absolute",
467
+ right: "var(--du-bar-inset, 12px)",
468
+ bottom: "calc(var(--du-bar-inset, 12px) + 52px)",
469
+ border: "1px solid var(--du-border, #d0d0d0)",
470
+ borderRadius: 6,
471
+ background: "var(--du-surface, #fff)",
472
+ boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)",
473
+ overflow: "hidden",
474
+ zIndex: 40,
475
+ }, children: _jsx(Minimap, {}) })), !hideDrawingPanel && !zen && (_jsx("div", { style: {
476
+ position: "absolute",
477
+ top: "calc(var(--du-bar-inset, 12px) + 52px)",
478
+ right: "var(--du-bar-inset, 12px)",
479
+ zIndex: 40,
480
+ }, children: _jsx(DrawingPanel, {}) })), !hideSelectionPanel && !zen && _jsx(SelectionFloatingPanel, {}), _jsx(HelpDialog, { open: helpOpen, onClose: () => {
384
481
  setHelpOpen(false);
385
- } }), _jsx(CommandPalette, {}), void editor] }));
482
+ } }), _jsx(CommandPalette, {}), _jsx(SearchOverlay, {}), _jsx(StatsPanel, {}), void editor] }));
386
483
  };
387
484
  /**
388
485
  * Platform-correct hotkey labels for the zoom-control tooltips — ⌘
389
486
  * glyphs on macOS, "Ctrl+…" elsewhere, mirroring the top toolbar's
390
- * tool tooltips. Descriptors mirror the bound zoom hotkeys in
391
- * `actionZoom.ts` (display uses the minus/plus glyphs).
487
+ * tool tooltips. Mirror the bound zoom hotkeys (display uses the
488
+ * minus/plus glyphs).
392
489
  */
393
490
  const ZOOM_OUT_HOTKEY = formatHotkey({ meta: true, key: "−" });
394
491
  const ZOOM_IN_HOTKEY = formatHotkey({ meta: true, key: "+" });
@@ -431,122 +528,25 @@ const ZoomControls = ({ trailing }) => {
431
528
  editor.zoomToFit();
432
529
  }, children: _jsx(Maximize, { ...buttonIcon }) }), trailing] }));
433
530
  };
434
- // --- MainMenu File-group helpers --------------------------------------------
435
- /**
436
- * Trigger a browser download of arbitrary bytes. Used by the
437
- * Save / Export menu items. Creates a temporary `<a>`, clicks it,
438
- * cleans up the object URL on the next animation frame so the
439
- * browser has time to start the download.
440
- */
441
- const downloadBlob = (blob, filename) => {
442
- const url = URL.createObjectURL(blob);
443
- const a = document.createElement("a");
444
- a.href = url;
445
- a.download = filename;
446
- document.body.appendChild(a);
447
- a.click();
448
- document.body.removeChild(a);
449
- requestAnimationFrame(() => {
450
- URL.revokeObjectURL(url);
451
- });
452
- };
453
- /** "Save as JSON" — serialises the scene through @serialization. */
454
- const downloadScene = (scene) => {
455
- const json = stringifyScene(scene, 2);
456
- downloadBlob(new Blob([json], { type: "application/json" }), "scene.diagram.json");
457
- };
458
- /**
459
- * "Open…" — file picker that accepts `.diagram.json`, parses it,
460
- * and replaces the editor's scene. Resets history (matches the
461
- * default `loadScene` behaviour). User cancellation = no-op.
462
- */
463
- const openSceneFile = (editor, notify) => {
464
- if (!editor)
465
- return;
466
- const input = document.createElement("input");
467
- input.type = "file";
468
- input.accept = "application/json,.json";
469
- input.onchange = () => {
470
- const file = input.files?.[0];
471
- if (!file)
472
- return;
473
- void file.text().then((text) => {
474
- try {
475
- const scene = parseScene(text);
476
- editor.loadScene(scene);
477
- }
478
- catch (err) {
479
- console.error("[diagram] failed to parse scene file:", err);
480
- notify("Failed to parse the file — make sure it was saved through this app's Save action.");
481
- }
482
- });
483
- };
484
- input.click();
485
- };
486
- /**
487
- * "Export as PNG" — renders the **full scene** (not just the visible
488
- * viewport) into an OffscreenCanvas via the standard `renderScene` +
489
- * `renderLinks` pipeline and downloads the result. Three variants
490
- * exposed in the menu:
491
- *
492
- * • transparent — PNG with alpha channel preserved
493
- * • color — solid background fill (host canvas colour)
494
- * • color-and-grid — solid fill + same grid the user sees
495
- *
496
- * Scale fixed at 2× for retina-quality output (matches the standard /
497
- * standard default). The full-scene contract makes this symmetric with
498
- * SVG export, which always emits the whole scene.
499
- *
500
- * Implementation lives in `./png-export.ts` so this file stays
501
- * focused on UI wiring.
502
- */
503
- const PNG_EXPORT_SCALE = 2;
504
- const downloadPng = async (editor, background, notify) => {
505
- const backgroundColor = readCanvasBackgroundColor();
506
- const blob = await exportSceneToPng(editor.scene, {
507
- background,
508
- scale: PNG_EXPORT_SCALE,
509
- backgroundColor,
510
- });
511
- if (!blob) {
512
- // Empty scene — convertToBlob unavailable or no shapes to export.
513
- notify("Nothing to export — the canvas is empty.");
514
- return;
515
- }
516
- downloadBlob(blob, "scene.png");
517
- };
518
- /**
519
- * Read the host's current `--du-canvas-bg` CSS variable. Falls back
520
- * to white if the variable isn't set (e.g. host hasn't loaded the
521
- * react-ui stylesheet). Matches what the user sees behind the
522
- * shapes on the live canvas.
523
- */
524
- const readCanvasBackgroundColor = () => {
525
- const probe = document.querySelector('canvas[data-layer="main"]') ?? document.body;
526
- const value = getComputedStyle(probe).getPropertyValue("--du-canvas-bg").trim();
527
- return value || "#ffffff";
528
- };
529
- /**
530
- * "Export as SVG" — uses `@renderer-svg.renderSceneToSvg` so the
531
- * output is identical to the headless render path (vector, no
532
- * bitmap fall-back, works in any browser). One file per scene.
533
- */
534
- const downloadSvg = (scene) => {
535
- const svg = renderSceneToSvg(scene);
536
- downloadBlob(new Blob([svg], { type: "image/svg+xml" }), "scene.svg");
537
- };
538
531
  // --- Grid toggle helpers ----------------------------------------------------
539
- const DEFAULT_GRID_SIZE = 20;
532
+ /** Translate the `grid` / `snap` props into a partial settings override. */
533
+ const buildHostSettings = (gridEnabled, gridStyle, snap) => ({
534
+ viewport: {
535
+ ...(gridEnabled !== undefined ? { gridEnabled } : {}),
536
+ ...(gridStyle !== undefined ? { gridStyle } : {}),
537
+ ...(snap !== undefined ? { snapToGrid: snap } : {}),
538
+ },
539
+ });
540
540
  /**
541
- * Map the current viewport state to the segmented Grid toggle's
542
- * value. `"off"` when the user hid the grid (gridSize 0 / unset);
543
- * otherwise the stored gridStyle (default `"lines"`).
541
+ * Map the current viewport state to the segmented Grid toggle's value.
542
+ * `"off"` when the grid is disabled; otherwise the stored gridStyle
543
+ * (default `"lines"`).
544
544
  */
545
545
  const gridSelection = (editor) => {
546
546
  if (!editor)
547
547
  return "lines";
548
548
  const vp = editor.scene.viewport;
549
- if (!vp.gridSize || vp.gridSize <= 0)
549
+ if (!vp.gridEnabled)
550
550
  return "off";
551
551
  return vp.gridStyle ?? "lines";
552
552
  };
@@ -556,21 +556,14 @@ const gridSelection = (editor) => {
556
556
  * visibility).
557
557
  */
558
558
  const snapSelection = (editor) => (editor?.snapToGridEnabled ?? true) ? "on" : "off";
559
- /**
560
- * Inverse — translate the toggle's value back into a `setGrid`
561
- * call. Switching from "off" to lines/dots restores the default
562
- * grid size so the user doesn't have to also re-enter it
563
- * separately.
564
- */
559
+ /** Inverse — translate the toggle's value back into a `setGrid` call. */
565
560
  const applyGridSelection = (editor, next) => {
566
561
  if (!editor)
567
562
  return;
568
563
  if (next === "off") {
569
- editor.setGrid({ size: 0 });
564
+ editor.setGrid({ enabled: false });
570
565
  return;
571
566
  }
572
- const vp = editor.scene.viewport;
573
- const size = vp.gridSize && vp.gridSize > 0 ? vp.gridSize : DEFAULT_GRID_SIZE;
574
- editor.setGrid({ size, style: next });
567
+ editor.setGrid({ enabled: true, style: next });
575
568
  };
576
569
  //# sourceMappingURL=Diagram.js.map