@bendyline/squisq-editor-react 2.0.0 → 2.1.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 (110) hide show
  1. package/README.md +10 -2
  2. package/dist/index.d.ts +579 -47
  3. package/dist/index.js +7477 -2943
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/index.css +1008 -38
  6. package/package.json +6 -5
  7. package/src/DocumentSettingsDialog.tsx +32 -18
  8. package/src/EditorContext.tsx +27 -0
  9. package/src/EditorShell.tsx +25 -1
  10. package/src/PreviewControls.tsx +77 -29
  11. package/src/PreviewPanel.tsx +5 -1
  12. package/src/RawEditor.tsx +12 -0
  13. package/src/RecorderEntry.tsx +5 -0
  14. package/src/Toolbar.tsx +479 -36
  15. package/src/WysiwygEditor.tsx +25 -11
  16. package/src/__tests__/buildPreviewDocContent.test.ts +17 -0
  17. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  18. package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
  19. package/src/__tests__/editorShellProps.test.tsx +65 -0
  20. package/src/__tests__/findMode.test.tsx +104 -0
  21. package/src/__tests__/findModel.test.ts +55 -0
  22. package/src/__tests__/markdownCodeFence.test.ts +45 -0
  23. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  24. package/src/__tests__/mediaReferences.test.ts +15 -0
  25. package/src/__tests__/previewControls.test.tsx +195 -0
  26. package/src/__tests__/recorderTheme.test.tsx +42 -0
  27. package/src/__tests__/selectionConversions.test.ts +80 -0
  28. package/src/__tests__/tiptapBridge.test.ts +57 -7
  29. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  30. package/src/__tests__/toolbarSelectionConversion.test.tsx +190 -0
  31. package/src/__tests__/writeCanvasSettings.test.ts +15 -0
  32. package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
  33. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +20 -1
  34. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +58 -1
  35. package/src/asciiDiagram/asciiDiagramCommands.ts +36 -10
  36. package/src/asciiDiagram/asciiDiagramData.ts +33 -0
  37. package/src/asciiDiagram/asciiDiagramOps.ts +19 -0
  38. package/src/buildPreviewDoc.ts +9 -7
  39. package/src/codeContext/types.ts +1 -1
  40. package/src/codeSnippet/CodeSnippetExtension.ts +205 -0
  41. package/src/codeSnippet/CodeSnippetWidget.tsx +109 -0
  42. package/src/codeSnippet/__tests__/CodeSnippetExtension.test.ts +95 -0
  43. package/src/codeSnippet/__tests__/codeSnippetLanguages.test.ts +50 -0
  44. package/src/codeSnippet/codeSnippetCommands.ts +21 -0
  45. package/src/codeSnippet/codeSnippetData.ts +43 -0
  46. package/src/codeSnippet/codeSnippetLanguages.ts +216 -0
  47. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  48. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  49. package/src/diagram/DiagramCanvas.tsx +17 -2
  50. package/src/find/FindHighlightExtension.ts +81 -0
  51. package/src/find/FindToolbar.tsx +314 -0
  52. package/src/find/findModel.ts +77 -0
  53. package/src/frontmatterSettings.ts +23 -0
  54. package/src/index.ts +96 -1
  55. package/src/markdownCodeFence.ts +72 -0
  56. package/src/mediaReferences.ts +5 -3
  57. package/src/mermaid/MermaidDiagramCanvas.tsx +693 -0
  58. package/src/mermaid/MermaidDiagramExtension.ts +243 -0
  59. package/src/mermaid/MermaidDiagramTypeThumbnail.tsx +184 -0
  60. package/src/mermaid/MermaidDiagramWidget.tsx +653 -0
  61. package/src/mermaid/MermaidShapePalette.tsx +245 -0
  62. package/src/mermaid/__tests__/MermaidDiagramExtension.test.ts +361 -0
  63. package/src/mermaid/__tests__/mermaidDiagramTypes.test.ts +35 -0
  64. package/src/mermaid/__tests__/mermaidRenderer.test.ts +49 -0
  65. package/src/mermaid/__tests__/mermaidSourceOps.test.ts +213 -0
  66. package/src/mermaid/__tests__/mermaidSyntax.test.ts +71 -0
  67. package/src/mermaid/mermaidCommands.ts +34 -0
  68. package/src/mermaid/mermaidData.ts +31 -0
  69. package/src/mermaid/mermaidDiagramTypes.ts +325 -0
  70. package/src/mermaid/mermaidModel.ts +31 -0
  71. package/src/mermaid/mermaidRenderer.ts +181 -0
  72. package/src/mermaid/mermaidShapes.ts +113 -0
  73. package/src/mermaid/mermaidSourceOps.ts +454 -0
  74. package/src/recorder/RecorderButton.tsx +9 -1
  75. package/src/recorder/RecorderModal.tsx +84 -41
  76. package/src/recorder/RecorderPanel.tsx +9 -1
  77. package/src/scene/Scene.tsx +46 -15
  78. package/src/scene/SceneBlockToolbar.tsx +29 -14
  79. package/src/scene/SceneViewControls.tsx +43 -0
  80. package/src/scene/__tests__/fitScale.test.ts +19 -0
  81. package/src/scene/__tests__/sceneIsolation.test.tsx +27 -1
  82. package/src/scene/__tests__/useScenePanZoom.test.ts +28 -1
  83. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  84. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  85. package/src/scene/commands/SceneCommand.ts +13 -2
  86. package/src/scene/fitScale.ts +17 -0
  87. package/src/scene/hooks/useScenePanZoom.ts +11 -4
  88. package/src/scene/scene.css +85 -3
  89. package/src/scene/tools/SelectTool.ts +5 -5
  90. package/src/selectionConversions.ts +155 -0
  91. package/src/styles/ascii-timeline.css +101 -4
  92. package/src/styles/code-snippet.css +76 -0
  93. package/src/styles/editor.css +352 -2
  94. package/src/styles/index.css +2 -0
  95. package/src/styles/mermaid-diagram.css +487 -0
  96. package/src/styles/tree-view.css +51 -1
  97. package/src/timeline/TimelineEditorWidget.tsx +200 -41
  98. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +61 -2
  99. package/src/timeline/__tests__/timelineCommands.test.ts +32 -0
  100. package/src/timeline/__tests__/timelineOps.test.ts +55 -0
  101. package/src/timeline/timelineCommands.ts +54 -0
  102. package/src/timeline/timelineOps.ts +107 -3
  103. package/src/tiptapBridge.ts +23 -5
  104. package/src/treeview/TreeOutlineWidget.tsx +153 -3
  105. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  106. package/src/treeview/__tests__/treeOps.test.ts +52 -0
  107. package/src/treeview/__tests__/treeViewCommands.test.ts +16 -0
  108. package/src/treeview/treeOps.ts +59 -0
  109. package/src/treeview/treeViewCommands.ts +5 -0
  110. package/src/writeCanvasSettings.ts +30 -0
@@ -22,7 +22,13 @@ import { SceneBlockToolbar, type SceneBlockAction } from '../scene/SceneBlockToo
22
22
  import { SceneSideToolbar } from '../scene/SceneSideToolbar';
23
23
  import { nodeIdFromCardLayerId, NODE_WIDTH, NODE_HEIGHT } from '../scene';
24
24
  import { Icon } from '../Icon';
25
- import { useAsciiDiagramData } from './asciiDiagramData';
25
+ import {
26
+ asciiDiagramToCanvas,
27
+ initialAsciiDiagramCanvasOffset,
28
+ offsetAsciiDiagram,
29
+ useAsciiDiagramData,
30
+ type AsciiDiagramCanvasOffset,
31
+ } from './asciiDiagramData';
26
32
  import { applyAsciiDiagramCommand } from './asciiDiagramCommands';
27
33
  import { isAsciiSourceVisible, toggleAsciiSource } from './AsciiDiagramExtension';
28
34
  import type { SceneTextChannel } from '../scene/text/sceneTextChannel';
@@ -52,12 +58,36 @@ export function AsciiDiagramWidget({
52
58
  const [maximized, setMaximized] = useState(false);
53
59
  const [height, setHeight] = useState<number | null>(null);
54
60
  const [dragHeight, setDragHeight] = useState<number | null>(null);
61
+ const canvasOffsetRef = useRef<AsciiDiagramCanvasOffset | null>(null);
62
+ const [canvasOffsetVersion, setCanvasOffsetVersion] = useState(0);
55
63
  const inlineRef = useRef<HTMLDivElement>(null);
56
64
  const effectiveHeight = dragHeight ?? height;
57
65
 
66
+ if (view && canvasOffsetRef.current === null) {
67
+ canvasOffsetRef.current = initialAsciiDiagramCanvasOffset(view.diagram);
68
+ }
69
+
70
+ const canvasView = useMemo(() => {
71
+ if (!view) return null;
72
+ const offset = canvasOffsetRef.current ?? { col: 0, row: 0 };
73
+ return asciiDiagramToCanvas(offsetAsciiDiagram(view.diagram, offset));
74
+ // The ref is intentionally versioned only when its value is consumed.
75
+ // eslint-disable-next-line react-hooks/exhaustive-deps
76
+ }, [view, canvasOffsetVersion]);
77
+
58
78
  const dispatch = useCallback(
59
79
  (cmd: DiagramCommand) => {
60
- applyAsciiDiagramCommand(editor, blockId, cmd);
80
+ const offset = canvasOffsetRef.current ?? { col: 0, row: 0 };
81
+ const applied = applyAsciiDiagramCommand(editor, blockId, cmd, {
82
+ diagramOffset: offset,
83
+ });
84
+ if (applied && (offset.col !== 0 || offset.row !== 0)) {
85
+ // The same rewrite that applied the edit has now persisted the virtual
86
+ // gutter. Drop the projection offset so the on-screen coordinates do
87
+ // not change while the fence-backed view refreshes.
88
+ canvasOffsetRef.current = { col: 0, row: 0 };
89
+ setCanvasOffsetVersion((version) => version + 1);
90
+ }
61
91
  },
62
92
  [editor, blockId],
63
93
  );
@@ -169,8 +199,8 @@ export function AsciiDiagramWidget({
169
199
  const canvas = (
170
200
  <DiagramCanvas
171
201
  textChannel={textChannel}
172
- nodes={view.nodes}
173
- edges={view.edges}
202
+ nodes={canvasView?.nodes ?? view.nodes}
203
+ edges={canvasView?.edges ?? view.edges}
174
204
  onCommand={dispatch}
175
205
  showMaximize
176
206
  maximized={maximized}
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { Editor } from '@tiptap/core';
8
8
  import StarterKit from '@tiptap/starter-kit';
9
- import { afterEach, beforeAll, describe, expect, it } from 'vitest';
9
+ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
10
10
  import { markdownToTiptap } from '../../tiptapBridge';
11
11
  import { HeadingWithTemplate } from '../../TemplateAnnotation';
12
12
  import {
@@ -138,6 +138,25 @@ describe('AsciiDiagramExtension registry', () => {
138
138
  expect(JSON.stringify(editor.state.doc.toJSON())).toBe(json);
139
139
  });
140
140
 
141
+ it('shows zoom, fit, and fullscreen affordances with fit active by default', async () => {
142
+ const editor = makeEditor('```diagram\n' + ART_A + '\n```\n');
143
+ const root = editor.view.dom;
144
+ await vi.waitFor(() => {
145
+ expect(root.querySelector('[aria-label="Diagram view"]')).not.toBeNull();
146
+ });
147
+
148
+ expect(root.querySelector('button[aria-label="Zoom out"]')).not.toBeNull();
149
+ expect(root.querySelector('button[aria-label="Zoom in"]')).not.toBeNull();
150
+ expect(root.querySelector('button[title^="Maximize"]')).not.toBeNull();
151
+
152
+ const fitButton = root.querySelector<HTMLButtonElement>('button[aria-label="Fit diagram"]')!;
153
+ expect(fitButton.getAttribute('aria-pressed')).toBe('true');
154
+ root.querySelector<HTMLButtonElement>('button[aria-label="Zoom in"]')?.click();
155
+ await vi.waitFor(() => expect(fitButton.getAttribute('aria-pressed')).toBe('false'));
156
+ fitButton.click();
157
+ await vi.waitFor(() => expect(fitButton.getAttribute('aria-pressed')).toBe('true'));
158
+ });
159
+
141
160
  it('enabled: false disables the plugin entirely', () => {
142
161
  const editor = makeEditor('```\n' + ART_A + '\n```\n', { enabled: false });
143
162
  expect(ASCII_DIAGRAM_KEY.getState(editor.state)).toBeUndefined();
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { Editor } from '@tiptap/core';
7
7
  import StarterKit from '@tiptap/starter-kit';
8
- import { afterEach, beforeAll, describe, expect, it } from 'vitest';
8
+ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
9
9
  import { ASCII_CHAR_H, ASCII_CHAR_W, parseAsciiDiagram } from '@bendyline/squisq/doc';
10
10
  import { markdownToTiptap } from '../../tiptapBridge';
11
11
  import { HeadingWithTemplate } from '../../TemplateAnnotation';
@@ -94,6 +94,63 @@ describe('applyAsciiDiagramCommand', () => {
94
94
  expect(reparsed.nodes.find((n) => n.id === 'beta')?.col).toBe(20);
95
95
  });
96
96
 
97
+ it('commits a north/west resize position and size in one fence rewrite', () => {
98
+ const editor = makeEditor('```\n' + ART + '\n```\n');
99
+ const id = firstBlockId(editor);
100
+ const onTransaction = vi.fn();
101
+ editor.on('transaction', onTransaction);
102
+
103
+ const ok = applyAsciiDiagramCommand(editor, id, {
104
+ kind: 'resizeNode',
105
+ nodeId: 'beta',
106
+ x: 18 * ASCII_CHAR_W,
107
+ y: 10 * ASCII_CHAR_H,
108
+ width: 14 * ASCII_CHAR_W,
109
+ height: 5 * ASCII_CHAR_H,
110
+ });
111
+
112
+ expect(ok).toBe(true);
113
+ expect(onTransaction).toHaveBeenCalledTimes(1);
114
+ const beta = parseAsciiDiagram(fenceOf(editor).text).nodes.find((n) => n.id === 'beta');
115
+ expect(beta).toMatchObject({ col: 18, row: 10, wCols: 14, hRows: 5 });
116
+
117
+ editor.commands.undo();
118
+ const restored = parseAsciiDiagram(fenceOf(editor).text).nodes.find((n) => n.id === 'beta');
119
+ expect(restored).toMatchObject({ col: 0, row: 5, wCols: 10, hRows: 3 });
120
+ });
121
+
122
+ it('persists a virtual gutter with the first edit of legacy origin-hugging art', () => {
123
+ const editor = makeEditor('```diagram\n' + ART + '\n```\n');
124
+ const id = firstBlockId(editor);
125
+
126
+ expect(
127
+ applyAsciiDiagramCommand(
128
+ editor,
129
+ id,
130
+ {
131
+ kind: 'resizeNode',
132
+ nodeId: 'alpha',
133
+ x: 6 * ASCII_CHAR_W,
134
+ y: 1 * ASCII_CHAR_H,
135
+ width: 12 * ASCII_CHAR_W,
136
+ height: 4 * ASCII_CHAR_H,
137
+ },
138
+ { diagramOffset: { col: 8, row: 2 } },
139
+ ),
140
+ ).toBe(true);
141
+
142
+ const nodes = parseAsciiDiagram(fenceOf(editor).text).nodes;
143
+ expect(nodes.find((node) => node.id === 'alpha')).toMatchObject({
144
+ col: 6,
145
+ row: 1,
146
+ wCols: 12,
147
+ hRows: 4,
148
+ });
149
+ // The untouched peer receives the persisted origin shift, preserving the
150
+ // same coordinates that were already projected on the canvas.
151
+ expect(nodes.find((node) => node.id === 'beta')).toMatchObject({ col: 8, row: 7 });
152
+ });
153
+
97
154
  it('addConnection adds a reciprocal edge (renders as a double arrow)', () => {
98
155
  const editor = makeEditor('```\n' + ART + '\n```\n');
99
156
  const id = firstBlockId(editor);
@@ -30,8 +30,17 @@ import {
30
30
  removeNodeOp,
31
31
  renameNodeOp,
32
32
  resizeNodeOp,
33
+ translateDiagramOp,
33
34
  } from './asciiDiagramOps';
34
35
 
36
+ export interface ApplyAsciiDiagramCommandOptions {
37
+ /**
38
+ * Virtual grid offset currently shown by the canvas for legacy art. It is
39
+ * folded into the source in the same transaction as the user's first edit.
40
+ */
41
+ diagramOffset?: { col: number; row: number };
42
+ }
43
+
35
44
  /**
36
45
  * Replace the TEXT inside the codeBlock at `pos` and, when `ensureLanguage`
37
46
  * is given, promote its `language` attribute in the SAME transaction (one
@@ -89,6 +98,7 @@ function applyOp(
89
98
  editor: Editor,
90
99
  blockId: string,
91
100
  op: (diagram: AsciiDiagram) => AsciiDiagram,
101
+ diagramOffset?: { col: number; row: number },
92
102
  ): boolean {
93
103
  // Resolve the position at dispatch time — captured positions go stale
94
104
  // the moment anything above the block changes.
@@ -96,11 +106,14 @@ function applyOp(
96
106
  if (pos === null) return false;
97
107
  const node = editor.state.doc.nodeAt(pos);
98
108
  if (!node || node.type.name !== 'codeBlock') return false;
99
- const diagram = parseAsciiDiagramForNode(node);
100
- if (!diagram) return false;
109
+ const sourceDiagram = parseAsciiDiagramForNode(node);
110
+ if (!sourceDiagram) return false;
111
+ const diagram = diagramOffset
112
+ ? translateDiagramOp(sourceDiagram, diagramOffset.col, diagramOffset.row)
113
+ : sourceDiagram;
101
114
 
102
115
  const next = op(diagram);
103
- if (next === diagram) return false;
116
+ if (next === diagram && diagram === sourceDiagram) return false;
104
117
  const rendered = renderAsciiDiagram(next);
105
118
 
106
119
  // Verify before committing: the rendered art must re-parse to the same
@@ -118,29 +131,42 @@ export function applyAsciiDiagramCommand(
118
131
  editor: Editor,
119
132
  blockId: string,
120
133
  cmd: DiagramCommand,
134
+ options: ApplyAsciiDiagramCommandOptions = {},
121
135
  ): boolean {
136
+ const apply = (op: (diagram: AsciiDiagram) => AsciiDiagram) =>
137
+ applyOp(editor, blockId, op, options.diagramOffset);
122
138
  switch (cmd.kind) {
123
139
  case 'moveNode': {
124
140
  const { col, row } = canvasToAsciiCell(cmd.x, cmd.y);
125
- return applyOp(editor, blockId, (d) => moveNodeOp(d, cmd.nodeId, col, row));
141
+ return apply((d) => moveNodeOp(d, cmd.nodeId, col, row));
126
142
  }
127
143
  case 'resizeNode': {
128
144
  const wCols = Math.max(3, Math.round(cmd.width / ASCII_CHAR_W));
129
145
  const hRows = Math.max(3, Math.round(cmd.height / ASCII_CHAR_H));
130
- return applyOp(editor, blockId, (d) => resizeNodeOp(d, cmd.nodeId, wCols, hRows));
146
+ return apply((d) => {
147
+ // A north/west resize changes position and size. Apply both to the
148
+ // same parsed model and render once so collision/layout normalization
149
+ // cannot run between the two halves of one pointer gesture.
150
+ let next = d;
151
+ if (cmd.x !== undefined && cmd.y !== undefined) {
152
+ const { col, row } = canvasToAsciiCell(cmd.x, cmd.y);
153
+ next = moveNodeOp(next, cmd.nodeId, col, row);
154
+ }
155
+ return resizeNodeOp(next, cmd.nodeId, wCols, hRows);
156
+ });
131
157
  }
132
158
  case 'addConnection':
133
- return applyOp(editor, blockId, (d) => addEdgeOp(d, cmd.source, cmd.target, cmd.type));
159
+ return apply((d) => addEdgeOp(d, cmd.source, cmd.target, cmd.type));
134
160
  case 'removeConnection':
135
- return applyOp(editor, blockId, (d) => removeEdgeOp(d, cmd.source, cmd.target, cmd.type));
161
+ return apply((d) => removeEdgeOp(d, cmd.source, cmd.target, cmd.type));
136
162
  case 'renameNode':
137
- return applyOp(editor, blockId, (d) => renameNodeOp(d, cmd.nodeId, cmd.newLabel));
163
+ return apply((d) => renameNodeOp(d, cmd.nodeId, cmd.newLabel));
138
164
  case 'addNode': {
139
165
  const { col, row } = canvasToAsciiCell(cmd.x, cmd.y);
140
- return applyOp(editor, blockId, (d) => addNodeOp(d, { col, row }).diagram);
166
+ return apply((d) => addNodeOp(d, { col, row }).diagram);
141
167
  }
142
168
  case 'removeNode':
143
- return applyOp(editor, blockId, (d) => removeNodeOp(d, cmd.nodeId));
169
+ return apply((d) => removeNodeOp(d, cmd.nodeId));
144
170
  }
145
171
  const _exhaustive: never = cmd;
146
172
  void _exhaustive;
@@ -18,6 +18,16 @@ import {
18
18
  } from '@bendyline/squisq/doc';
19
19
  import type { DiagramEdge, DiagramNode } from '../diagram/types';
20
20
  import { findAsciiDiagramBlockPos, parseAsciiDiagramForNode } from './AsciiDiagramExtension';
21
+ import { translateDiagramOp } from './asciiDiagramOps';
22
+
23
+ /** Persisted grid space reserved north/west of legacy origin-hugging art. */
24
+ export const ASCII_DIAGRAM_GUTTER_COLS = 8;
25
+ export const ASCII_DIAGRAM_GUTTER_ROWS = 2;
26
+
27
+ export interface AsciiDiagramCanvasOffset {
28
+ col: number;
29
+ row: number;
30
+ }
21
31
 
22
32
  export interface AsciiDiagramView {
23
33
  /** Canvas nodes, containers ordered first so their cards paint behind. */
@@ -31,6 +41,29 @@ export interface AsciiDiagramView {
31
41
  diagram: AsciiDiagram;
32
42
  }
33
43
 
44
+ /**
45
+ * Offset needed to give an existing diagram the same editing gutter as a
46
+ * newly inserted one. The widget applies this virtually until the first real
47
+ * canvas edit, which then writes the shifted coordinates into the fence.
48
+ */
49
+ export function initialAsciiDiagramCanvasOffset(diagram: AsciiDiagram): AsciiDiagramCanvasOffset {
50
+ if (diagram.nodes.length === 0) return { col: 0, row: 0 };
51
+ const minCol = Math.min(...diagram.nodes.map((node) => node.col));
52
+ const minRow = Math.min(...diagram.nodes.map((node) => node.row));
53
+ return {
54
+ col: Math.max(0, ASCII_DIAGRAM_GUTTER_COLS - minCol),
55
+ row: Math.max(0, ASCII_DIAGRAM_GUTTER_ROWS - minRow),
56
+ };
57
+ }
58
+
59
+ /** Apply a canvas-only origin offset to a parsed grid model. */
60
+ export function offsetAsciiDiagram(
61
+ diagram: AsciiDiagram,
62
+ offset: AsciiDiagramCanvasOffset,
63
+ ): AsciiDiagram {
64
+ return translateDiagramOp(diagram, offset.col, offset.row);
65
+ }
66
+
34
67
  /** Grid model → canvas model, containers-first for paint order. */
35
68
  export function asciiDiagramToCanvas(
36
69
  diagram: AsciiDiagram,
@@ -40,6 +40,25 @@ function descendantsOf(diagram: AsciiDiagram, nodeId: string): Set<string> {
40
40
  return out;
41
41
  }
42
42
 
43
+ /** Translate every node by the same grid delta (edges/containment are unchanged). */
44
+ export function translateDiagramOp(
45
+ diagram: AsciiDiagram,
46
+ dCol: number,
47
+ dRow: number,
48
+ ): AsciiDiagram {
49
+ const colDelta = Math.round(dCol);
50
+ const rowDelta = Math.round(dRow);
51
+ if (colDelta === 0 && rowDelta === 0) return diagram;
52
+ return {
53
+ ...diagram,
54
+ nodes: diagram.nodes.map((node) => ({
55
+ ...node,
56
+ col: Math.max(0, node.col + colDelta),
57
+ row: Math.max(0, node.row + rowDelta),
58
+ })),
59
+ };
60
+ }
61
+
43
62
  /** Move a node to (col, row); a container drags its whole subtree along. */
44
63
  export function moveNodeOp(
45
64
  diagram: AsciiDiagram,
@@ -44,6 +44,9 @@ function extractBodyText(contents: MarkdownBlockNode[] | undefined): string {
44
44
  if (!contents || contents.length === 0) return '';
45
45
  const parts: string[] = [];
46
46
  for (const node of contents) {
47
+ // Mermaid fences are visual source, not narrative copy. They stay on the
48
+ // slide as `contents` and materialize into Mermaid layers downstream.
49
+ if (node.type === 'code' && node.lang?.trim().toLowerCase() === 'mermaid') continue;
47
50
  parts.push(extractRichText(node));
48
51
  }
49
52
  return parts.join('\n').trim();
@@ -247,13 +250,12 @@ function blockToSlide(
247
250
  // block has no previous slide to transition in from.
248
251
  transition: block.transition ?? (index > 0 ? { type: 'fade', duration: 0.5 } : undefined),
249
252
  title: headingText,
250
- // Custom templates need access to the source block's body content
251
- // + children so their token resolver (`{content}`, `{children}`,
252
- // `{image:N}`) substitutes against the user's prose, not just the
253
- // heading. Built-in templates don't read these fields and risk
254
- // surprising overlap with their typed inputs, so we only attach
255
- // them when the slide actually maps to a custom template.
256
- ...(isCustomTemplate && block.contents ? { contents: block.contents } : {}),
253
+ // Preserve body nodes on every slide. Built-in templates ignore this
254
+ // structural field, while the canonical materializer uses it to retain
255
+ // authored rich elements (Mermaid fences today; other media can follow)
256
+ // independently of the selected visual template.
257
+ ...(block.contents ? { contents: block.contents } : {}),
258
+ // Custom templates additionally consume child blocks through tokens.
257
259
  ...(isCustomTemplate && block.children ? { children: block.children } : {}),
258
260
  ...defaults,
259
261
  ...extraFields,
@@ -56,7 +56,7 @@ export interface CodeContext {
56
56
  /** Line-anchored sections. Array order is preserved for equal lines. */
57
57
  sections?: CodeContextSection[];
58
58
  /**
59
- * Extra URI schemes section links may use (e.g. `['gezel-nav']`).
59
+ * Extra URI schemes section links may use (e.g. `['workspace-nav']`).
60
60
  * http/https/mailto/tel are always allowed; executable schemes
61
61
  * (javascript:, data:) are never allowed regardless.
62
62
  */
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Tiptap extension for ordinary explicit-language code fences.
3
+ *
4
+ * The ProseMirror code block remains authoritative. This plugin hides it and
5
+ * mounts a Monaco editor whose edits rewrite only the node's text content, so
6
+ * fence language and source round-trip through Markdown without a parallel model.
7
+ */
8
+
9
+ import { Extension } from '@tiptap/core';
10
+ import type { Node as PMNode } from '@tiptap/pm/model';
11
+ import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state';
12
+ import { Decoration, DecorationSet } from '@tiptap/pm/view';
13
+ import type { Editor } from '@tiptap/react';
14
+ import { createElement } from 'react';
15
+ import { createRoot, type Root } from 'react-dom/client';
16
+ import { CodeSnippetWidget } from './CodeSnippetWidget';
17
+ import { isCodeSnippetFenceLanguage } from './codeSnippetLanguages';
18
+
19
+ export interface CodeSnippetBlockEntry {
20
+ /** Synthetic session id, stable while this code block remains in the doc. */
21
+ id: string;
22
+ /** Current ProseMirror position of the `codeBlock`. */
23
+ pos: number;
24
+ }
25
+
26
+ export interface CodeSnippetPluginState {
27
+ entries: CodeSnippetBlockEntry[];
28
+ decorations: DecorationSet;
29
+ seq: number;
30
+ }
31
+
32
+ export interface CodeSnippetExtensionOptions {
33
+ /** When false, leave all ordinary language-tagged fences as code blocks. */
34
+ enabled?: boolean;
35
+ }
36
+
37
+ export const CODE_SNIPPET_KEY = new PluginKey<CodeSnippetPluginState>('squisq-code-snippet');
38
+
39
+ export function isCodeSnippetNode(node: PMNode): boolean {
40
+ const language = (node.attrs as { language?: unknown }).language;
41
+ return (
42
+ node.type.name === 'codeBlock' &&
43
+ isCodeSnippetFenceLanguage(typeof language === 'string' ? language : null)
44
+ );
45
+ }
46
+
47
+ export function findCodeSnippetBlockPos(editor: Editor, blockId: string): number | null {
48
+ const state = CODE_SNIPPET_KEY.getState(editor.state);
49
+ return state?.entries.find((entry) => entry.id === blockId)?.pos ?? null;
50
+ }
51
+
52
+ interface WidgetRootRef {
53
+ root: Root;
54
+ }
55
+
56
+ function buildDecorations(
57
+ doc: PMNode,
58
+ entries: readonly CodeSnippetBlockEntry[],
59
+ editor: Editor,
60
+ ): DecorationSet {
61
+ const decorations: Decoration[] = [];
62
+ for (const entry of entries) {
63
+ const node = doc.nodeAt(entry.pos);
64
+ if (!node || !isCodeSnippetNode(node)) continue;
65
+ decorations.push(
66
+ Decoration.node(entry.pos, entry.pos + node.nodeSize, {
67
+ class: 'squisq-code-snippet-fence-hidden',
68
+ }),
69
+ );
70
+ const blockId = entry.id;
71
+ decorations.push(
72
+ Decoration.widget(
73
+ entry.pos + node.nodeSize,
74
+ (view) => {
75
+ const container = document.createElement('div');
76
+ container.className = 'squisq-code-snippet-widget-host';
77
+ container.contentEditable = 'false';
78
+ container.addEventListener('mousedown', (event) => event.stopPropagation());
79
+ container.addEventListener('keydown', (event) => event.stopPropagation());
80
+ const root = createRoot(container);
81
+ root.render(
82
+ createElement(CodeSnippetWidget, {
83
+ editor,
84
+ blockId,
85
+ host: view.dom.parentElement ?? view.dom,
86
+ }),
87
+ );
88
+ (
89
+ container as HTMLElement & { __squisqCodeSnippetRoot?: WidgetRootRef }
90
+ ).__squisqCodeSnippetRoot = { root };
91
+ return container;
92
+ },
93
+ {
94
+ destroy: (dom) => {
95
+ const ref = (dom as HTMLElement & { __squisqCodeSnippetRoot?: WidgetRootRef })
96
+ .__squisqCodeSnippetRoot;
97
+ if (ref) setTimeout(() => ref.root.unmount(), 0);
98
+ },
99
+ ignoreSelection: true,
100
+ key: `squisq-code-snippet-${entry.id}`,
101
+ side: 1,
102
+ },
103
+ ),
104
+ );
105
+ }
106
+ return DecorationSet.create(doc, decorations);
107
+ }
108
+
109
+ function remapEntries(
110
+ tr: Transaction,
111
+ previous: CodeSnippetPluginState,
112
+ doc: PMNode,
113
+ ): { entries: CodeSnippetBlockEntry[]; seq: number } {
114
+ const mapped = new Map<number, string>();
115
+ const claimed = new Set<string>();
116
+
117
+ for (const entry of previous.entries) {
118
+ const result = tr.mapping.mapResult(entry.pos, 1);
119
+ if (!result.deleted) {
120
+ mapped.set(result.pos, entry.id);
121
+ claimed.add(entry.id);
122
+ }
123
+ }
124
+
125
+ // Replacing node content can mark its opening token as replaced even though
126
+ // the same language-tagged code block still occupies the mapped position.
127
+ for (const entry of previous.entries) {
128
+ if (claimed.has(entry.id)) continue;
129
+ const result = tr.mapping.mapResult(entry.pos, 1);
130
+ if (mapped.has(result.pos)) continue;
131
+ if (doc.nodeAt(result.pos)?.type.name === 'codeBlock') {
132
+ mapped.set(result.pos, entry.id);
133
+ claimed.add(entry.id);
134
+ }
135
+ }
136
+
137
+ let seq = previous.seq;
138
+ const entries: CodeSnippetBlockEntry[] = [];
139
+ doc.descendants((node, pos) => {
140
+ if (node.type.name !== 'codeBlock') return;
141
+ if (!isCodeSnippetNode(node)) return false;
142
+ entries.push({ id: mapped.get(pos) ?? `code-snippet-${++seq}`, pos });
143
+ return false;
144
+ });
145
+ return { entries, seq };
146
+ }
147
+
148
+ function applyState(
149
+ tr: Transaction,
150
+ previous: CodeSnippetPluginState,
151
+ editor: Editor,
152
+ doc: PMNode,
153
+ ): CodeSnippetPluginState {
154
+ if (!tr.docChanged) return previous;
155
+ const { entries, seq } = remapEntries(tr, previous, doc);
156
+ return {
157
+ entries,
158
+ seq,
159
+ decorations: buildDecorations(doc, entries, editor),
160
+ };
161
+ }
162
+
163
+ export const CodeSnippetExtension = Extension.create<CodeSnippetExtensionOptions>({
164
+ name: 'squisqCodeSnippet',
165
+
166
+ addOptions() {
167
+ return { enabled: true };
168
+ },
169
+
170
+ addProseMirrorPlugins() {
171
+ if (this.options.enabled === false) return [];
172
+ const editor = this.editor as Editor;
173
+ return [
174
+ new Plugin<CodeSnippetPluginState>({
175
+ key: CODE_SNIPPET_KEY,
176
+ state: {
177
+ init: (_config, state) => {
178
+ let seq = 0;
179
+ const entries: CodeSnippetBlockEntry[] = [];
180
+ state.doc.descendants((node, pos) => {
181
+ if (node.type.name !== 'codeBlock') return;
182
+ if (!isCodeSnippetNode(node)) return false;
183
+ entries.push({ id: `code-snippet-${++seq}`, pos });
184
+ return false;
185
+ });
186
+ return {
187
+ entries,
188
+ seq,
189
+ decorations: buildDecorations(state.doc, entries, editor),
190
+ };
191
+ },
192
+ apply: (tr, previous, _oldState, newState) =>
193
+ applyState(tr, previous, editor, newState.doc),
194
+ },
195
+ props: {
196
+ decorations(state) {
197
+ return this.getState(state)?.decorations ?? DecorationSet.empty;
198
+ },
199
+ },
200
+ }),
201
+ ];
202
+ },
203
+ });
204
+
205
+ export default CodeSnippetExtension;