@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.
- package/README.md +10 -2
- package/dist/index.d.ts +579 -47
- package/dist/index.js +7477 -2943
- package/dist/index.js.map +1 -1
- package/dist/styles/index.css +1008 -38
- package/package.json +6 -5
- package/src/DocumentSettingsDialog.tsx +32 -18
- package/src/EditorContext.tsx +27 -0
- package/src/EditorShell.tsx +25 -1
- package/src/PreviewControls.tsx +77 -29
- package/src/PreviewPanel.tsx +5 -1
- package/src/RawEditor.tsx +12 -0
- package/src/RecorderEntry.tsx +5 -0
- package/src/Toolbar.tsx +479 -36
- package/src/WysiwygEditor.tsx +25 -11
- package/src/__tests__/buildPreviewDocContent.test.ts +17 -0
- package/src/__tests__/codeContextSectionView.test.tsx +8 -6
- package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
- package/src/__tests__/editorShellProps.test.tsx +65 -0
- package/src/__tests__/findMode.test.tsx +104 -0
- package/src/__tests__/findModel.test.ts +55 -0
- package/src/__tests__/markdownCodeFence.test.ts +45 -0
- package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
- package/src/__tests__/mediaReferences.test.ts +15 -0
- package/src/__tests__/previewControls.test.tsx +195 -0
- package/src/__tests__/recorderTheme.test.tsx +42 -0
- package/src/__tests__/selectionConversions.test.ts +80 -0
- package/src/__tests__/tiptapBridge.test.ts +57 -7
- package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
- package/src/__tests__/toolbarSelectionConversion.test.tsx +190 -0
- package/src/__tests__/writeCanvasSettings.test.ts +15 -0
- package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
- package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +20 -1
- package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +58 -1
- package/src/asciiDiagram/asciiDiagramCommands.ts +36 -10
- package/src/asciiDiagram/asciiDiagramData.ts +33 -0
- package/src/asciiDiagram/asciiDiagramOps.ts +19 -0
- package/src/buildPreviewDoc.ts +9 -7
- package/src/codeContext/types.ts +1 -1
- package/src/codeSnippet/CodeSnippetExtension.ts +205 -0
- package/src/codeSnippet/CodeSnippetWidget.tsx +109 -0
- package/src/codeSnippet/__tests__/CodeSnippetExtension.test.ts +95 -0
- package/src/codeSnippet/__tests__/codeSnippetLanguages.test.ts +50 -0
- package/src/codeSnippet/codeSnippetCommands.ts +21 -0
- package/src/codeSnippet/codeSnippetData.ts +43 -0
- package/src/codeSnippet/codeSnippetLanguages.ts +216 -0
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
- package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
- package/src/diagram/DiagramCanvas.tsx +17 -2
- package/src/find/FindHighlightExtension.ts +81 -0
- package/src/find/FindToolbar.tsx +314 -0
- package/src/find/findModel.ts +77 -0
- package/src/frontmatterSettings.ts +23 -0
- package/src/index.ts +96 -1
- package/src/markdownCodeFence.ts +72 -0
- package/src/mediaReferences.ts +5 -3
- package/src/mermaid/MermaidDiagramCanvas.tsx +693 -0
- package/src/mermaid/MermaidDiagramExtension.ts +243 -0
- package/src/mermaid/MermaidDiagramTypeThumbnail.tsx +184 -0
- package/src/mermaid/MermaidDiagramWidget.tsx +653 -0
- package/src/mermaid/MermaidShapePalette.tsx +245 -0
- package/src/mermaid/__tests__/MermaidDiagramExtension.test.ts +361 -0
- package/src/mermaid/__tests__/mermaidDiagramTypes.test.ts +35 -0
- package/src/mermaid/__tests__/mermaidRenderer.test.ts +49 -0
- package/src/mermaid/__tests__/mermaidSourceOps.test.ts +213 -0
- package/src/mermaid/__tests__/mermaidSyntax.test.ts +71 -0
- package/src/mermaid/mermaidCommands.ts +34 -0
- package/src/mermaid/mermaidData.ts +31 -0
- package/src/mermaid/mermaidDiagramTypes.ts +325 -0
- package/src/mermaid/mermaidModel.ts +31 -0
- package/src/mermaid/mermaidRenderer.ts +181 -0
- package/src/mermaid/mermaidShapes.ts +113 -0
- package/src/mermaid/mermaidSourceOps.ts +454 -0
- package/src/recorder/RecorderButton.tsx +9 -1
- package/src/recorder/RecorderModal.tsx +84 -41
- package/src/recorder/RecorderPanel.tsx +9 -1
- package/src/scene/Scene.tsx +46 -15
- package/src/scene/SceneBlockToolbar.tsx +29 -14
- package/src/scene/SceneViewControls.tsx +43 -0
- package/src/scene/__tests__/fitScale.test.ts +19 -0
- package/src/scene/__tests__/sceneIsolation.test.tsx +27 -1
- package/src/scene/__tests__/useScenePanZoom.test.ts +28 -1
- package/src/scene/adapters/DrawingAdapter.ts +6 -1
- package/src/scene/adapters/LayoutAdapter.ts +3 -0
- package/src/scene/commands/SceneCommand.ts +13 -2
- package/src/scene/fitScale.ts +17 -0
- package/src/scene/hooks/useScenePanZoom.ts +11 -4
- package/src/scene/scene.css +85 -3
- package/src/scene/tools/SelectTool.ts +5 -5
- package/src/selectionConversions.ts +155 -0
- package/src/styles/ascii-timeline.css +101 -4
- package/src/styles/code-snippet.css +76 -0
- package/src/styles/editor.css +352 -2
- package/src/styles/index.css +2 -0
- package/src/styles/mermaid-diagram.css +487 -0
- package/src/styles/tree-view.css +51 -1
- package/src/timeline/TimelineEditorWidget.tsx +200 -41
- package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +61 -2
- package/src/timeline/__tests__/timelineCommands.test.ts +32 -0
- package/src/timeline/__tests__/timelineOps.test.ts +55 -0
- package/src/timeline/timelineCommands.ts +54 -0
- package/src/timeline/timelineOps.ts +107 -3
- package/src/tiptapBridge.ts +23 -5
- package/src/treeview/TreeOutlineWidget.tsx +153 -3
- package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
- package/src/treeview/__tests__/treeOps.test.ts +52 -0
- package/src/treeview/__tests__/treeViewCommands.test.ts +16 -0
- package/src/treeview/treeOps.ts +59 -0
- package/src/treeview/treeViewCommands.ts +5 -0
- 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 {
|
|
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
|
-
|
|
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
|
|
100
|
-
if (!
|
|
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
|
|
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
|
|
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
|
|
159
|
+
return apply((d) => addEdgeOp(d, cmd.source, cmd.target, cmd.type));
|
|
134
160
|
case 'removeConnection':
|
|
135
|
-
return
|
|
161
|
+
return apply((d) => removeEdgeOp(d, cmd.source, cmd.target, cmd.type));
|
|
136
162
|
case 'renameNode':
|
|
137
|
-
return
|
|
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
|
|
166
|
+
return apply((d) => addNodeOp(d, { col, row }).diagram);
|
|
141
167
|
}
|
|
142
168
|
case 'removeNode':
|
|
143
|
-
return
|
|
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,
|
package/src/buildPreviewDoc.ts
CHANGED
|
@@ -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
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
//
|
|
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,
|
package/src/codeContext/types.ts
CHANGED
|
@@ -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. `['
|
|
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;
|