@bendyline/squisq-editor-react 1.6.1 → 2.0.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 (208) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1304 -208
  3. package/dist/index.js +22838 -14494
  4. package/dist/index.js.map +1 -1
  5. package/dist/monaco.d.ts +1 -0
  6. package/dist/monaco.js +4 -0
  7. package/dist/monaco.js.map +1 -0
  8. package/dist/styles/index.css +2384 -408
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +48 -17
  11. package/src/DocumentSettingsDialog.tsx +13 -21
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +403 -270
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +276 -40
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +466 -93
  20. package/src/PreviewPanel.tsx +313 -77
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/StatusBar.tsx +5 -2
  23. package/src/TemplateAnnotation.ts +34 -10
  24. package/src/TemplateContentPreview.tsx +56 -0
  25. package/src/TemplatePicker.tsx +355 -134
  26. package/src/ThemeCustomizerPanel.tsx +30 -15
  27. package/src/ThemePicker.tsx +10 -5
  28. package/src/TimelineBlockPreview.tsx +1 -1
  29. package/src/Toolbar.tsx +719 -238
  30. package/src/TransitionPicker.tsx +44 -5
  31. package/src/ViewMenuPanel.tsx +12 -14
  32. package/src/ViewSwitcher.tsx +4 -4
  33. package/src/WysiwygEditor.tsx +167 -92
  34. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  35. package/src/__tests__/blockTagActivity.test.ts +183 -0
  36. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  37. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  38. package/src/__tests__/buildPreviewDocTransition.test.ts +22 -3
  39. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  40. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  41. package/src/__tests__/documentSettingsDialog.test.tsx +29 -1
  42. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  43. package/src/__tests__/editorScrollSync.test.ts +65 -0
  44. package/src/__tests__/editorShellProps.test.tsx +390 -1
  45. package/src/__tests__/emojiPicker.test.tsx +2 -42
  46. package/src/__tests__/headingTransition.test.ts +53 -15
  47. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  48. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  49. package/src/__tests__/imageEditorShell.test.tsx +38 -1
  50. package/src/__tests__/jsonEditor.test.tsx +56 -1
  51. package/src/__tests__/layersPanel.test.tsx +75 -5
  52. package/src/__tests__/majorApiTypes.test.ts +26 -0
  53. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  54. package/src/__tests__/mediaReferences.test.ts +82 -0
  55. package/src/__tests__/narrationSave.test.ts +148 -0
  56. package/src/__tests__/outlinePanel.test.tsx +93 -3
  57. package/src/__tests__/outlineSource.test.ts +217 -0
  58. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  59. package/src/__tests__/presentationMode.test.tsx +290 -0
  60. package/src/__tests__/previewControls.test.tsx +312 -6
  61. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  62. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  63. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  64. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  65. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  66. package/src/__tests__/statusBar.test.tsx +27 -0
  67. package/src/__tests__/teleprompter.test.tsx +190 -0
  68. package/src/__tests__/templateAnnotationRoundTrip.test.ts +49 -2
  69. package/src/__tests__/templateContentPreview.test.ts +101 -0
  70. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  71. package/src/__tests__/tiptapBridge.test.ts +70 -0
  72. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  73. package/src/__tests__/transformStyleId.test.ts +13 -0
  74. package/src/__tests__/useImageEditor.test.tsx +67 -0
  75. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  76. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  77. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  78. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  79. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  80. package/src/asciiDiagram/AsciiDiagramWidget.tsx +224 -0
  81. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  82. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  83. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  84. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +227 -0
  85. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  86. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  87. package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
  88. package/src/asciiDiagram/asciiDiagramData.ts +116 -0
  89. package/src/asciiDiagram/asciiDiagramOps.ts +219 -0
  90. package/src/asciiDiagram/asciiPaste.ts +19 -0
  91. package/src/blockTagActivity.ts +233 -0
  92. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  93. package/src/buildPreviewDoc.ts +19 -12
  94. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  95. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  96. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  97. package/src/customTemplates/__tests__/library.test.ts +8 -0
  98. package/src/customTemplates/designer.css +158 -99
  99. package/src/customTemplates/library.ts +16 -3
  100. package/src/customThemes/CustomThemeContext.tsx +6 -0
  101. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  102. package/src/customThemes/ImportThemeSection.tsx +178 -0
  103. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  104. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  105. package/src/customThemes/customThemeLibrary.ts +7 -4
  106. package/src/customThemes/index.ts +7 -1
  107. package/src/customThemes/themeDraft.ts +23 -7
  108. package/src/diagram/DiagramCanvas.tsx +9 -4
  109. package/src/diagram/types.ts +35 -0
  110. package/src/editorScrollSync.ts +73 -0
  111. package/src/emojiData.ts +3 -23
  112. package/src/headingTransition.ts +85 -25
  113. package/src/imageEditor/CanvasSurface.tsx +29 -23
  114. package/src/imageEditor/LayersPanel.tsx +78 -3
  115. package/src/imageEditor/Toolbar.tsx +5 -1
  116. package/src/imageEditor/icons.tsx +4 -0
  117. package/src/imageEditor/image-editor.css +75 -0
  118. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  119. package/src/imageEditor/useImageEditor.ts +71 -14
  120. package/src/index.ts +193 -20
  121. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  122. package/src/jsonEditor/RenderNode.tsx +24 -3
  123. package/src/jsonEditor/editors.tsx +86 -19
  124. package/src/mediaEntries.ts +12 -0
  125. package/src/mediaReferences.ts +299 -0
  126. package/src/monaco.ts +35 -0
  127. package/src/monacoWorkers.ts +89 -0
  128. package/src/outlineSource.ts +171 -0
  129. package/src/presentation/PresentationMode.tsx +596 -0
  130. package/src/rawEditorIsolation.ts +18 -0
  131. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  132. package/src/resolveBlockVisual.ts +10 -4
  133. package/src/scene/Scene.tsx +101 -25
  134. package/src/scene/SceneBlockExtension.ts +17 -10
  135. package/src/scene/SceneBlockWidget.tsx +11 -4
  136. package/src/scene/SceneSelection.tsx +19 -15
  137. package/src/scene/SceneSideToolbar.tsx +89 -0
  138. package/src/scene/SceneViewport.tsx +7 -3
  139. package/src/scene/ShapePalette.tsx +17 -2
  140. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  141. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  142. package/src/scene/__tests__/sceneIsolation.test.tsx +93 -0
  143. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  144. package/src/scene/commands/SceneCommand.ts +4 -1
  145. package/src/scene/index.ts +1 -6
  146. package/src/scene/layers/DiagramEdges.tsx +24 -9
  147. package/src/scene/layers/edgeGeometry.ts +31 -4
  148. package/src/scene/layers/nodeCard.tsx +27 -8
  149. package/src/scene/scene.css +145 -2
  150. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  151. package/src/scene/text/sceneTextChannel.ts +26 -20
  152. package/src/scene/text/sceneTextConfig.ts +4 -0
  153. package/src/scene/tools/ConnectTool.ts +126 -28
  154. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  155. package/src/scene/tools/SceneTool.ts +10 -0
  156. package/src/scene/tools/SelectTool.ts +34 -18
  157. package/src/scene/tools/ShapeTool.ts +2 -3
  158. package/src/styles/ascii-diagram.css +79 -0
  159. package/src/styles/ascii-timeline.css +499 -0
  160. package/src/styles/editor.css +1566 -219
  161. package/src/styles/index.css +3 -0
  162. package/src/styles/tree-view.css +139 -0
  163. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  164. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  165. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  166. package/src/teleprompter/TeleprompterView.tsx +338 -0
  167. package/src/teleprompter/canvasRenderer.ts +161 -0
  168. package/src/teleprompter/floatingWindow.ts +352 -0
  169. package/src/teleprompter/index.ts +61 -0
  170. package/src/teleprompter/pcmWorklet.ts +62 -0
  171. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  172. package/src/teleprompter/recording/narrationSave.ts +134 -0
  173. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  174. package/src/teleprompter/scrollModel.ts +85 -0
  175. package/src/teleprompter/teleprompterTheme.ts +303 -0
  176. package/src/teleprompter/types.ts +38 -0
  177. package/src/teleprompter/useFloatingWindow.ts +74 -0
  178. package/src/teleprompter/useMicAnalysis.ts +211 -0
  179. package/src/teleprompter/useTeleprompter.ts +421 -0
  180. package/src/templateContentPreviewResolver.ts +360 -0
  181. package/src/timeline/TimelineEditorWidget.tsx +702 -0
  182. package/src/timeline/TimelineViewExtension.ts +252 -0
  183. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
  184. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  185. package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
  186. package/src/timeline/__tests__/timelineOps.test.ts +222 -0
  187. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  188. package/src/timeline/timelineCommands.ts +367 -0
  189. package/src/timeline/timelineData.ts +54 -0
  190. package/src/timeline/timelineOps.ts +277 -0
  191. package/src/timeline/timelinePaste.ts +8 -0
  192. package/src/tiptapBridge.ts +84 -49
  193. package/src/transformStyleId.ts +17 -0
  194. package/src/treeview/TreeOutlineWidget.tsx +240 -0
  195. package/src/treeview/TreeViewExtension.ts +250 -0
  196. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  197. package/src/treeview/__tests__/treeOps.test.ts +125 -0
  198. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  199. package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
  200. package/src/treeview/treeOps.ts +172 -0
  201. package/src/treeview/treePaste.ts +13 -0
  202. package/src/treeview/treeViewCommands.ts +94 -0
  203. package/src/treeview/treeViewData.ts +41 -0
  204. package/src/useMonacoLoader.ts +11 -34
  205. package/src/wysiwygImageUpload.ts +113 -0
  206. package/src/diagram/DiagramExtension.ts +0 -209
  207. package/src/diagram/DiagramWidget.tsx +0 -270
  208. package/src/diagram/useDiagramData.ts +0 -126
@@ -49,42 +49,19 @@ export function useMonacoLoader(): UseMonacoLoaderResult {
49
49
  useEffect(() => {
50
50
  if (state.ready) return;
51
51
  if (!monacoPromise) {
52
- // Import the explicit ESM entry file rather than the bare
53
- // package specifier `'monaco-editor'`. The package's
54
- // `package.json` has no `main` / no `exports` (only `module`),
55
- // which trips Vite's strict resolver on both static and
56
- // dynamic imports of the bare name. Pointing at the file
57
- // directly the same path monaco's `module` field points at
58
- // turns this into a regular file-path resolve that doesn't
59
- // care about the manifest's entry fields. Works identically
60
- // in Vite dev / build, vitest's transform pipeline, and any
61
- // bundler-using downstream consumer.
52
+ // Load Monaco through squisq's canonical `./monaco` entry (see that
53
+ // file for exactly which slice of Monaco ships and why). Centralizing
54
+ // the choice there means the editor and any downstream app that imports
55
+ // `@bendyline/squisq-editor-react/monaco` share ONE instance and feature
56
+ // set no host aliasing, no hand-rolled slim entry that silently drops
57
+ // the suggest widget. The import is dynamic so consumers that only use
58
+ // types or `JsonEditor` never pull Monaco into their graph.
62
59
  //
63
- // Use `editor.main.js`, NOT `editor.api.js`. The `api` entry is
64
- // the bare standalone API with zero language contributions —
65
- // RawEditor's `defaultLanguage="typescript"` then mounts with
66
- // no tokenizer registered, and every file renders as
67
- // undifferentiated foreground text (the regression that surfaced
68
- // when a `.ts` file in the chat workspace previewer showed up
69
- // with no syntax coloring). `editor.main.js` re-exports the API
70
- // and additionally pulls in `basic-languages/monaco.contribution`
71
- // (TM grammars for ~70 languages) plus the four rich language
72
- // services (css / html / json / typescript). This is what makes
73
- // `defaultLanguage` actually do anything. The cost is the language
74
- // bundle — but since we load it lazily on first EditorShell mount,
75
- // it stays out of the resolver graph for consumers that only
76
- // import types or `JsonEditor` from this package.
77
- // `editor.main.js` ships without a sibling `.d.ts` (only
78
- // `editor.api.d.ts` is published), so the subpath import has no
79
- // resolvable declaration. The `declare module` shim in
80
- // `src/types/monaco-shims.d.ts` makes it import as `any`; the
81
- // `as unknown as` cast below pins the surface to the full namespace.
82
- // At runtime `main` re-exports the entire `editor.api` surface
83
- // alongside the language contributions, so the cast is sound.
60
+ // `./monaco` re-exports `editor.api` (the only barrel with a `.d.ts`)
61
+ // after side-effect-importing `editor.main.js`; the `as unknown as` cast
62
+ // pins the settled module to the full monaco namespace.
84
63
  monacoPromise = (
85
- import('monaco-editor/esm/vs/editor/editor.main.js') as unknown as Promise<
86
- typeof import('monaco-editor')
87
- >
64
+ import('./monaco.js') as unknown as Promise<typeof import('monaco-editor')>
88
65
  ).then((m) => {
89
66
  loader.config({ monaco: m });
90
67
  monacoNamespace = m;
@@ -0,0 +1,113 @@
1
+ import type { MediaProvider } from '@bendyline/squisq/schemas';
2
+ import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
3
+ import type { EditorView as ProseMirrorView } from '@tiptap/pm/view';
4
+
5
+ type ImageMutationView = Pick<ProseMirrorView, 'state' | 'dispatch'>;
6
+
7
+ /**
8
+ * Upload images while stable placeholder nodes retain the original event
9
+ * position as the surrounding ProseMirror document continues to change.
10
+ */
11
+ export async function uploadAndInsertImages(
12
+ view: ImageMutationView,
13
+ files: File[],
14
+ mediaProvider: MediaProvider,
15
+ onMediaUploaded?: () => void,
16
+ ): Promise<void> {
17
+ const placeholders = files.map(() => `squisq-upload:${uniquePasteToken()}`);
18
+ const imageType = view.state.schema.nodes.image;
19
+ if (!imageType) return;
20
+
21
+ let placeholderTr = view.state.tr;
22
+ for (const token of placeholders) {
23
+ placeholderTr = placeholderTr.replaceSelectionWith(
24
+ imageType.create({
25
+ src: 'data:image/gif;base64,R0lGODlhAQABAAAAACw=',
26
+ alt: 'Uploading image',
27
+ title: token,
28
+ }),
29
+ );
30
+ }
31
+ view.dispatch(placeholderTr);
32
+
33
+ for (let index = 0; index < files.length; index++) {
34
+ const file = files[index];
35
+ const token = placeholders[index];
36
+ try {
37
+ const buffer = await file.arrayBuffer();
38
+ const mimeType = file.type || 'image/png';
39
+ const name =
40
+ file.name && file.name !== 'image.png'
41
+ ? file.name
42
+ : `pasted-${uniquePasteToken()}.${extFromMime(mimeType)}`;
43
+ const relativePath = await mediaProvider.addMedia(name, buffer, mimeType);
44
+ const altText = name.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
45
+ replaceUploadPlaceholder(view, token, relativePath, altText);
46
+ onMediaUploaded?.();
47
+ } catch (err) {
48
+ removeUploadPlaceholder(view, token);
49
+ console.error('Failed to upload dropped image:', err);
50
+ }
51
+ }
52
+ }
53
+
54
+ function findUploadPlaceholder(
55
+ view: ImageMutationView,
56
+ token: string,
57
+ ): { pos: number; node: ProseMirrorNode } | null {
58
+ let found: { pos: number; node: ProseMirrorNode } | null = null;
59
+ view.state.doc.descendants((node, pos) => {
60
+ if (node.type === view.state.schema.nodes.image && node.attrs.title === token) {
61
+ found = { pos, node };
62
+ return false;
63
+ }
64
+ return undefined;
65
+ });
66
+ return found;
67
+ }
68
+
69
+ function replaceUploadPlaceholder(
70
+ view: ImageMutationView,
71
+ token: string,
72
+ src: string,
73
+ alt: string,
74
+ ): void {
75
+ const found = findUploadPlaceholder(view, token);
76
+ if (!found) return;
77
+ view.dispatch(
78
+ view.state.tr.setNodeMarkup(found.pos, undefined, {
79
+ ...found.node.attrs,
80
+ src,
81
+ alt,
82
+ title: null,
83
+ }),
84
+ );
85
+ }
86
+
87
+ function removeUploadPlaceholder(view: ImageMutationView, token: string): void {
88
+ const found = findUploadPlaceholder(view, token);
89
+ if (!found) return;
90
+ view.dispatch(view.state.tr.delete(found.pos, found.pos + found.node.nodeSize));
91
+ }
92
+
93
+ let pasteCounter = 0;
94
+ function uniquePasteToken(): string {
95
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
96
+ return crypto.randomUUID();
97
+ }
98
+ pasteCounter = (pasteCounter + 1) % 1_000_000;
99
+ return `${Date.now()}-${pasteCounter.toString(36)}`;
100
+ }
101
+
102
+ function extFromMime(mime: string): string {
103
+ const map: Record<string, string> = {
104
+ 'image/png': 'png',
105
+ 'image/jpeg': 'jpg',
106
+ 'image/jpg': 'jpg',
107
+ 'image/gif': 'gif',
108
+ 'image/webp': 'webp',
109
+ 'image/svg+xml': 'svg',
110
+ 'image/avif': 'avif',
111
+ };
112
+ return map[mime.toLowerCase()] ?? 'png';
113
+ }
@@ -1,209 +0,0 @@
1
- /**
2
- * DiagramExtension — Tiptap/ProseMirror plugin that:
3
- *
4
- * 1. Mounts a React-Flow canvas (`DiagramWidget`) immediately after every
5
- * heading whose `dataTemplate === 'diagram'`.
6
- * 2. Hides the direct sub-headings of each diagram parent (until the next
7
- * equal-or-shallower heading) by tagging them with a `data-squisq-diagram-child`
8
- * attribute — CSS in `styles/diagram.css` does the actual hiding.
9
- *
10
- * Widgets are rendered as plain DOM nodes attached to a ProseMirror
11
- * `Decoration.widget`. React is mounted into the widget DOM with
12
- * `react-dom/client`'s `createRoot`, and unmounted on the widget's
13
- * `destroy` hook.
14
- */
15
-
16
- import { Extension } from '@tiptap/core';
17
- import { Plugin, PluginKey } from '@tiptap/pm/state';
18
- import { Decoration, DecorationSet } from '@tiptap/pm/view';
19
- import type { EditorState } from '@tiptap/pm/state';
20
- import type { Editor } from '@tiptap/react';
21
- import { createRoot, type Root } from 'react-dom/client';
22
- import { createElement } from 'react';
23
- import type { Node as PMNode } from '@tiptap/pm/model';
24
- import { parsePandocAttrTokens } from '@bendyline/squisq/markdown';
25
- import { DiagramWidget } from './DiagramWidget';
26
-
27
- /**
28
- * Stable identifier for a diagram heading — prefers the persisted
29
- * Pandoc `#id`, falls back to a slugified version of the heading's
30
- * text. Used as part of the widget decoration's `key` so the widget
31
- * survives attribute-only doc changes (drag/resize commits).
32
- */
33
- export function getHeadingKey(node: PMNode): string {
34
- const raw = (node.attrs as Record<string, unknown>).dataBlockAttrs;
35
- if (typeof raw === 'string' && raw.length > 0) {
36
- const attrs = parsePandocAttrTokens(raw);
37
- if (attrs.id) return attrs.id;
38
- }
39
- let text = '';
40
- node.content.forEach((child) => {
41
- if (child.isText) text += child.text ?? '';
42
- });
43
- return (
44
- text
45
- .toLowerCase()
46
- .replace(/[^\w\s-]/g, '')
47
- .replace(/[\s_]+/g, '-')
48
- .replace(/-+/g, '-')
49
- .replace(/^-|-$/g, '') || 'block'
50
- );
51
- }
52
-
53
- /**
54
- * Find the current document position of a diagram heading by its
55
- * stable key. Returns `null` if the heading no longer exists (e.g. the
56
- * user removed it). Used by the widget to re-resolve its parent
57
- * position on every read, since the kept-alive widget's captured
58
- * `parentPos` would otherwise go stale when content above shifts.
59
- */
60
- export function findDiagramHeadingPos(editor: Editor, headingKey: string): number | null {
61
- let found: number | null = null;
62
- editor.state.doc.descendants((node, pos) => {
63
- if (found != null) return false;
64
- if (node.type.name !== 'heading') return;
65
- const attrs = node.attrs as { dataTemplate?: string };
66
- if (attrs.dataTemplate !== 'diagram') return;
67
- if (getHeadingKey(node) === headingKey) {
68
- found = pos;
69
- return false;
70
- }
71
- });
72
- return found;
73
- }
74
-
75
- const KEY = new PluginKey('squisq-diagram');
76
-
77
- interface DiagramRoot {
78
- el: HTMLElement;
79
- root: Root;
80
- }
81
-
82
- function buildDecorations(state: EditorState, editor: Editor): DecorationSet {
83
- const decos: Decoration[] = [];
84
-
85
- state.doc.descendants((node, pos) => {
86
- if (node.type.name !== 'heading') return;
87
- const attrs = node.attrs as { level: number; dataTemplate?: string };
88
- if (attrs.dataTemplate === 'diagram') {
89
- const parentDepth = attrs.level;
90
- const widgetPos = pos + node.nodeSize;
91
- const parentPos = pos;
92
- // Stable widget key derived from the heading's persisted id (or its
93
- // text-derived slug). ProseMirror treats decorations with matching
94
- // keys as equal, so it keeps the existing widget DOM (and the
95
- // mounted React root) across attribute-only doc changes — which
96
- // happen on every drag commit when we write `x=`/`y=` back to the
97
- // heading. Without this, every drop would destroy and recreate the
98
- // canvas, blowing away pan/zoom and making the cards appear to snap
99
- // back to their pre-fit position.
100
- const headingKey = getHeadingKey(node);
101
- decos.push(
102
- Decoration.widget(
103
- widgetPos,
104
- (view) => {
105
- const container = document.createElement('div');
106
- container.className = 'squisq-diagram-widget-host';
107
- container.contentEditable = 'false';
108
- // Stop ProseMirror from treating clicks/keys inside the
109
- // widget as document interactions.
110
- container.addEventListener('mousedown', (e) => e.stopPropagation());
111
- container.addEventListener('keydown', (e) => e.stopPropagation());
112
-
113
- const root = createRoot(container);
114
- root.render(
115
- createElement(DiagramWidget, {
116
- editor,
117
- headingKey,
118
- fallbackParentPos: parentPos,
119
- host: view.dom.parentElement ?? view.dom,
120
- }),
121
- );
122
-
123
- // Stash for destroy.
124
- (container as HTMLElement & { __squisqDiagramRoot?: DiagramRoot }).__squisqDiagramRoot =
125
- {
126
- el: container,
127
- root,
128
- };
129
- return container;
130
- },
131
- {
132
- destroy: (dom) => {
133
- const ref = (dom as HTMLElement & { __squisqDiagramRoot?: DiagramRoot })
134
- .__squisqDiagramRoot;
135
- if (ref) {
136
- // Defer to next tick so React isn't unmounted mid-render.
137
- setTimeout(() => ref.root.unmount(), 0);
138
- }
139
- },
140
- // Cluster the widget tightly after the heading; don't side-bias.
141
- side: 1,
142
- ignoreSelection: true,
143
- key: `squisq-diagram-${headingKey}`,
144
- },
145
- ),
146
- );
147
-
148
- // Mark child headings (depth = parentDepth + 1 .. 6, until next ≤parentDepth).
149
- let cursor = pos + node.nodeSize;
150
- while (cursor < state.doc.content.size) {
151
- const child = state.doc.nodeAt(cursor);
152
- if (!child) break;
153
- if (child.type.name === 'heading') {
154
- const childLevel = (child.attrs as { level: number }).level;
155
- if (childLevel <= parentDepth) break;
156
- decos.push(
157
- Decoration.node(cursor, cursor + child.nodeSize, {
158
- 'data-squisq-diagram-child': 'true',
159
- }),
160
- );
161
- }
162
- cursor += child.nodeSize;
163
- }
164
- }
165
- });
166
-
167
- return DecorationSet.create(state.doc, decos);
168
- }
169
-
170
- export interface DiagramExtensionOptions {
171
- /** When false, the extension is inert (no widgets, no decorations). */
172
- enabled?: boolean;
173
- }
174
-
175
- export const DiagramExtension = Extension.create<DiagramExtensionOptions>({
176
- name: 'squisqDiagram',
177
-
178
- addOptions() {
179
- return {
180
- enabled: true,
181
- };
182
- },
183
-
184
- addProseMirrorPlugins() {
185
- const editor = this.editor as Editor;
186
- const enabled = this.options.enabled !== false;
187
- if (!enabled) return [];
188
-
189
- return [
190
- new Plugin({
191
- key: KEY,
192
- state: {
193
- init: (_config, state) => buildDecorations(state, editor),
194
- apply: (tr, oldDecos, _oldState, newState) => {
195
- if (!tr.docChanged) return oldDecos;
196
- return buildDecorations(newState, editor);
197
- },
198
- },
199
- props: {
200
- decorations(state) {
201
- return this.getState(state);
202
- },
203
- },
204
- }),
205
- ];
206
- },
207
- });
208
-
209
- export default DiagramExtension;
@@ -1,270 +0,0 @@
1
- /**
2
- * DiagramWidget — the Squisq-Scene-backed canvas that sits below a
3
- * `### Title {[diagram]}` heading in the WYSIWYG view.
4
- *
5
- * Owns the maximize toggle. Resolves the parent heading's position
6
- * dynamically each render via `headingKey`, so the widget can survive
7
- * attribute-only doc changes (drag commits) without going stale when
8
- * content above the diagram shifts.
9
- *
10
- * Pushes user actions back through the `diagramCommands` helpers.
11
- */
12
-
13
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
14
- import type { Editor } from '@tiptap/react';
15
- import { DiagramCanvas, type DiagramCommand } from './DiagramCanvas';
16
- import { DIAGRAM_TOOLS, DIAGRAM_VIEWPORT } from './diagramConstants';
17
- import { DiagramMaximizedOverlay } from './DiagramMaximizedOverlay';
18
- import { useDiagramData } from './useDiagramData';
19
- import { findDiagramHeadingPos } from './DiagramExtension';
20
- import { SceneBlockToolbar, type SceneBlockAction } from '../scene/SceneBlockToolbar';
21
- import { nodeIdFromCardLayerId, NODE_WIDTH, NODE_HEIGHT } from '../scene';
22
- import { Icon } from '../Icon';
23
- import {
24
- moveNode,
25
- resizeNode,
26
- addConnection,
27
- removeConnection,
28
- renameNode,
29
- addNode,
30
- removeNode,
31
- getDiagramHeight,
32
- setDiagramHeight,
33
- } from './diagramCommands';
34
-
35
- /** Smallest height the canvas can be dragged to (px). */
36
- const MIN_DIAGRAM_HEIGHT = 160;
37
- /** Default canvas height when none is pinned — must match `.squisq-diagram-inline` in diagram.css. */
38
- const DEFAULT_DIAGRAM_HEIGHT = 420;
39
-
40
- interface DiagramWidgetProps {
41
- editor: Editor;
42
- /** Stable id derived from the parent heading (slug / `#id`). */
43
- headingKey: string;
44
- /** Position of the parent heading at widget-creation time. Used as a
45
- * fallback when the dynamic lookup fails (e.g. before the first
46
- * transaction). */
47
- fallbackParentPos: number;
48
- /** Host element used for portal targeting by the maximize overlay. */
49
- host?: HTMLElement | null;
50
- }
51
-
52
- export function DiagramWidget({ editor, headingKey, fallbackParentPos, host }: DiagramWidgetProps) {
53
- // Re-resolve parentPos on every editor transaction so writes target
54
- // the current heading position even after content above shifts. The same
55
- // pass refreshes the pinned canvas height from the heading annotation so
56
- // an external edit (or markdown reload) is reflected.
57
- const [parentPos, setParentPos] = useState<number>(
58
- () => findDiagramHeadingPos(editor, headingKey) ?? fallbackParentPos,
59
- );
60
- const [pinnedHeight, setPinnedHeight] = useState<number | null>(() =>
61
- getDiagramHeight(editor, findDiagramHeadingPos(editor, headingKey) ?? fallbackParentPos),
62
- );
63
- useEffect(() => {
64
- const onUpdate = () => {
65
- const next = findDiagramHeadingPos(editor, headingKey);
66
- if (next != null && next !== parentPos) setParentPos(next);
67
- setPinnedHeight(getDiagramHeight(editor, next ?? parentPos));
68
- };
69
- editor.on('transaction', onUpdate);
70
- return () => {
71
- editor.off('transaction', onUpdate);
72
- };
73
- }, [editor, headingKey, parentPos]);
74
-
75
- const { nodes, edges } = useDiagramData(editor, parentPos);
76
- const [maximized, setMaximized] = useState(false);
77
-
78
- // Live height while the bottom handle is being dragged (null = not
79
- // dragging). Takes precedence over the persisted height so the canvas
80
- // tracks the pointer before the markdown write lands on release.
81
- const [dragHeight, setDragHeight] = useState<number | null>(null);
82
- const inlineRef = useRef<HTMLDivElement>(null);
83
- const effectiveHeight = dragHeight ?? pinnedHeight;
84
-
85
- const onResizeStart = useCallback(
86
- (e: React.PointerEvent) => {
87
- // The handle owns this gesture — keep ProseMirror and the Scene from
88
- // treating it as a selection / canvas drag.
89
- e.preventDefault();
90
- e.stopPropagation();
91
- const startY = e.clientY;
92
- // Measure the live rendered height so a drag starting from the CSS
93
- // default (no pinned height yet) grows from the right baseline.
94
- const startHeight =
95
- inlineRef.current?.getBoundingClientRect().height ??
96
- effectiveHeight ??
97
- DEFAULT_DIAGRAM_HEIGHT;
98
- const heightAt = (clientY: number) =>
99
- Math.max(MIN_DIAGRAM_HEIGHT, Math.round(startHeight + (clientY - startY)));
100
- const prevCursor = document.body.style.cursor;
101
- const prevSelect = document.body.style.userSelect;
102
- document.body.style.cursor = 'ns-resize';
103
- document.body.style.userSelect = 'none';
104
- const onMove = (ev: PointerEvent) => setDragHeight(heightAt(ev.clientY));
105
- const onUp = (ev: PointerEvent) => {
106
- window.removeEventListener('pointermove', onMove);
107
- window.removeEventListener('pointerup', onUp);
108
- document.body.style.cursor = prevCursor;
109
- document.body.style.userSelect = prevSelect;
110
- const finalH = heightAt(ev.clientY);
111
- setDragHeight(null);
112
- const livePos = findDiagramHeadingPos(editor, headingKey) ?? parentPos;
113
- setDiagramHeight(editor, livePos, finalH);
114
- };
115
- window.addEventListener('pointermove', onMove);
116
- window.addEventListener('pointerup', onUp);
117
- },
118
- [editor, headingKey, parentPos, effectiveHeight],
119
- );
120
-
121
- // Double-click the handle to clear the pinned height (revert to default).
122
- const onResizeReset = useCallback(() => {
123
- const livePos = findDiagramHeadingPos(editor, headingKey) ?? parentPos;
124
- setDragHeight(null);
125
- setDiagramHeight(editor, livePos, null);
126
- }, [editor, headingKey, parentPos]);
127
-
128
- const dispatch = useCallback(
129
- (cmd: DiagramCommand) => {
130
- // Re-resolve at dispatch time too — the React state may not have
131
- // caught up yet when the user issues rapid-fire commands.
132
- const livePos = findDiagramHeadingPos(editor, headingKey) ?? parentPos;
133
- switch (cmd.kind) {
134
- case 'moveNode':
135
- moveNode(editor, livePos, cmd.nodeId, cmd.x, cmd.y);
136
- break;
137
- case 'resizeNode':
138
- resizeNode(editor, livePos, cmd.nodeId, cmd.width, cmd.height);
139
- break;
140
- case 'addConnection':
141
- addConnection(editor, livePos, cmd.source, cmd.target, cmd.type);
142
- break;
143
- case 'removeConnection':
144
- removeConnection(editor, livePos, cmd.source, cmd.target, cmd.type);
145
- break;
146
- case 'renameNode':
147
- renameNode(editor, livePos, cmd.nodeId, cmd.newLabel);
148
- break;
149
- case 'addNode': {
150
- const used = new Set(nodes.map((n) => n.id));
151
- let i = nodes.length + 1;
152
- let id = `node-${i}`;
153
- while (used.has(id)) {
154
- i++;
155
- id = `node-${i}`;
156
- }
157
- addNode(editor, livePos, id, `Node ${i}`, cmd.x, cmd.y);
158
- break;
159
- }
160
- case 'removeNode':
161
- removeNode(editor, livePos, cmd.nodeId);
162
- break;
163
- }
164
- },
165
- [editor, headingKey, parentPos, nodes],
166
- );
167
-
168
- // ── Shared toolbar (above the canvas) ────────────────────
169
- // Tool selection + node CRUD live here rather than inside the canvas,
170
- // so diagram/drawing/layout share one consistent bar above each canvas.
171
- const [activeToolId, setActiveToolId] = useState<string>('select');
172
- const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(() => new Set());
173
- // Map the Scene's selected layer ids (node cards) back to node ids.
174
- const selectedNodeIds = useMemo(
175
- () =>
176
- [...selectedIds]
177
- .map((id) => nodeIdFromCardLayerId(id))
178
- .filter((id): id is string => id != null),
179
- [selectedIds],
180
- );
181
-
182
- const actions: SceneBlockAction[] = [
183
- {
184
- id: 'add-node',
185
- label: 'Node',
186
- icon: <Icon icon="fa-solid fa-plus" />,
187
- title: 'Add node',
188
- onClick: () =>
189
- dispatch({
190
- kind: 'addNode',
191
- x: DIAGRAM_VIEWPORT.width / 2 - NODE_WIDTH / 2,
192
- y: DIAGRAM_VIEWPORT.height / 2 - NODE_HEIGHT / 2,
193
- }),
194
- },
195
- {
196
- id: 'delete',
197
- label: 'Delete',
198
- icon: <Icon icon="fa-solid fa-trash" />,
199
- title: 'Delete selected node',
200
- danger: true,
201
- disabled: selectedNodeIds.length === 0,
202
- onClick: () => selectedNodeIds.forEach((id) => dispatch({ kind: 'removeNode', nodeId: id })),
203
- },
204
- ];
205
-
206
- const toolbar = (
207
- <SceneBlockToolbar
208
- tools={DIAGRAM_TOOLS}
209
- activeToolId={activeToolId}
210
- onSelectTool={setActiveToolId}
211
- actions={actions}
212
- />
213
- );
214
-
215
- const canvas = (
216
- <DiagramCanvas
217
- nodes={nodes}
218
- edges={edges}
219
- onCommand={dispatch}
220
- showMaximize
221
- maximized={maximized}
222
- onToggleMaximize={() => setMaximized((m) => !m)}
223
- activeToolId={activeToolId}
224
- onActiveToolIdChange={setActiveToolId}
225
- onSelectionChange={setSelectedIds}
226
- />
227
- );
228
-
229
- const sideToolbar = <div className="squisq-scene-side-toolbar">{toolbar}</div>;
230
-
231
- if (maximized) {
232
- return (
233
- <div
234
- className="squisq-diagram-inline-placeholder"
235
- style={effectiveHeight != null ? { height: effectiveHeight } : undefined}
236
- >
237
- <DiagramMaximizedOverlay host={host ?? null} onClose={() => setMaximized(false)}>
238
- <div className="squisq-scene-block-max">
239
- {canvas}
240
- {sideToolbar}
241
- </div>
242
- </DiagramMaximizedOverlay>
243
- </div>
244
- );
245
- }
246
-
247
- return (
248
- <div className="squisq-scene-shell">
249
- <div
250
- className="squisq-diagram-inline"
251
- ref={inlineRef}
252
- style={effectiveHeight != null ? { height: effectiveHeight } : undefined}
253
- >
254
- {canvas}
255
- <div
256
- className="squisq-diagram-resize-handle"
257
- onPointerDown={onResizeStart}
258
- onDoubleClick={onResizeReset}
259
- role="separator"
260
- aria-orientation="horizontal"
261
- aria-label="Resize diagram height"
262
- title="Drag to resize · double-click to reset"
263
- />
264
- </div>
265
- {sideToolbar}
266
- </div>
267
- );
268
- }
269
-
270
- export default DiagramWidget;