@bendyline/squisq-editor-react 2.0.0 → 2.0.1
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/dist/index.d.ts +75 -33
- package/dist/index.js +1717 -935
- package/dist/index.js.map +1 -1
- package/dist/styles/index.css +145 -5
- package/package.json +4 -4
- package/src/DocumentSettingsDialog.tsx +32 -18
- package/src/EditorShell.tsx +1 -1
- package/src/PreviewControls.tsx +57 -24
- package/src/RecorderEntry.tsx +2 -0
- package/src/Toolbar.tsx +164 -19
- package/src/__tests__/codeContextSectionView.test.tsx +8 -6
- package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
- package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
- package/src/__tests__/previewControls.test.tsx +164 -0
- package/src/__tests__/recorderTheme.test.tsx +42 -0
- package/src/__tests__/selectionConversions.test.ts +80 -0
- package/src/__tests__/tiptapBridge.test.ts +48 -7
- package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
- package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
- package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
- 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/codeContext/types.ts +1 -1
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
- package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
- package/src/diagram/DiagramCanvas.tsx +16 -2
- package/src/frontmatterSettings.ts +23 -0
- package/src/index.ts +7 -1
- package/src/recorder/RecorderButton.tsx +9 -1
- package/src/recorder/RecorderModal.tsx +84 -41
- package/src/recorder/RecorderPanel.tsx +9 -1
- package/src/scene/__tests__/sceneIsolation.test.tsx +27 -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/tools/SelectTool.ts +5 -5
- package/src/selectionConversions.ts +155 -0
- package/src/styles/ascii-timeline.css +101 -4
- package/src/styles/editor.css +30 -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
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Editor } from '@tiptap/core';
|
|
3
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
2
4
|
import { markdownToTiptap, tiptapToMarkdown } from '../tiptapBridge';
|
|
3
5
|
|
|
4
6
|
// ---------------------------------------------------------------------------
|
|
@@ -66,25 +68,25 @@ describe('markdownToTiptap', () => {
|
|
|
66
68
|
});
|
|
67
69
|
|
|
68
70
|
it('converts mentions to chip spans', () => {
|
|
69
|
-
const html = markdownToTiptap('Hey @[Leo](
|
|
71
|
+
const html = markdownToTiptap('Hey @[Leo](person:leo), take a look.');
|
|
70
72
|
expect(html).toContain('data-mention="true"');
|
|
71
|
-
expect(html).toContain('data-kind="
|
|
73
|
+
expect(html).toContain('data-kind="person"');
|
|
72
74
|
expect(html).toContain('data-id="leo"');
|
|
73
75
|
expect(html).toContain('data-label="Leo"');
|
|
74
76
|
// "@Leo" appears inside the chip — NOT as a broken link
|
|
75
|
-
expect(html).not.toContain('href="
|
|
77
|
+
expect(html).not.toContain('href="person:leo"');
|
|
76
78
|
});
|
|
77
79
|
|
|
78
80
|
it('tolerates the backslash-escaped colon remark emits', () => {
|
|
79
|
-
// remark-stringify sometimes emits `
|
|
81
|
+
// remark-stringify sometimes emits `person\:leo` to disambiguate
|
|
80
82
|
// from autolink syntax. The bridge should still recognize it.
|
|
81
|
-
const html = markdownToTiptap('Hey @[Leo](
|
|
82
|
-
expect(html).toContain('data-kind="
|
|
83
|
+
const html = markdownToTiptap('Hey @[Leo](person\\:leo).');
|
|
84
|
+
expect(html).toContain('data-kind="person"');
|
|
83
85
|
expect(html).toContain('data-id="leo"');
|
|
84
86
|
});
|
|
85
87
|
|
|
86
88
|
it('round-trips mentions back to markdown', () => {
|
|
87
|
-
const md = 'Hey @[Leo](
|
|
89
|
+
const md = 'Hey @[Leo](person:leo), ping @[Tess](person:tess) too.';
|
|
88
90
|
const html = markdownToTiptap(md);
|
|
89
91
|
const back = tiptapToMarkdown(html);
|
|
90
92
|
expect(back.trim()).toBe(md);
|
|
@@ -158,6 +160,12 @@ describe('markdownToTiptap', () => {
|
|
|
158
160
|
expect(html).toContain('This is a quote');
|
|
159
161
|
});
|
|
160
162
|
|
|
163
|
+
it('groups consecutive quote lines into one visual blockquote', () => {
|
|
164
|
+
const html = markdownToTiptap('> A wise quote\n> -- Ada');
|
|
165
|
+
expect(html.match(/<blockquote>/g)).toHaveLength(1);
|
|
166
|
+
expect(html).toContain('<blockquote><p>A wise quote</p><p>-- Ada</p></blockquote>');
|
|
167
|
+
});
|
|
168
|
+
|
|
161
169
|
it('converts horizontal rules', () => {
|
|
162
170
|
const md = 'Before\n\n---\n\nAfter';
|
|
163
171
|
const html = markdownToTiptap(md);
|
|
@@ -312,6 +320,13 @@ describe('tiptapToMarkdown', () => {
|
|
|
312
320
|
expect(md).toContain('A wise quote');
|
|
313
321
|
});
|
|
314
322
|
|
|
323
|
+
it('keeps adjacent blockquote nodes on adjacent markdown lines', () => {
|
|
324
|
+
const md = tiptapToMarkdown(
|
|
325
|
+
'<blockquote><p>A wise quote</p></blockquote><blockquote><p>-- Ada</p></blockquote>',
|
|
326
|
+
);
|
|
327
|
+
expect(md).toBe('> A wise quote\n> -- Ada\n');
|
|
328
|
+
});
|
|
329
|
+
|
|
315
330
|
it('converts horizontal rules', () => {
|
|
316
331
|
const md = tiptapToMarkdown('<p>Before</p><hr><p>After</p>');
|
|
317
332
|
expect(md).toContain('---');
|
|
@@ -466,6 +481,32 @@ describe('round-trip: markdownToTiptap → tiptapToMarkdown', () => {
|
|
|
466
481
|
expect(roundTrip('> Important note')).toContain('> Important note');
|
|
467
482
|
});
|
|
468
483
|
|
|
484
|
+
it('does not add a blank line before a quote attribution', () => {
|
|
485
|
+
const md = [
|
|
486
|
+
'### A Famous Quote {[quote]}',
|
|
487
|
+
'',
|
|
488
|
+
'> "The best way to predict the future is to invent it."',
|
|
489
|
+
'> -- Alan Kay',
|
|
490
|
+
].join('\n');
|
|
491
|
+
|
|
492
|
+
expect(roundTrip(md)).toBe(md + '\n');
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('does not add a blank line after Tiptap normalizes a quote attribution', () => {
|
|
496
|
+
const md = ['> "The best way to predict the future is to invent it."', '> -- Alan Kay'].join(
|
|
497
|
+
'\n',
|
|
498
|
+
);
|
|
499
|
+
const editor = new Editor({
|
|
500
|
+
extensions: [StarterKit],
|
|
501
|
+
content: markdownToTiptap(md),
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const normalizedHtml = editor.getHTML();
|
|
505
|
+
expect(normalizedHtml.match(/<blockquote>/g)).toHaveLength(1);
|
|
506
|
+
expect(tiptapToMarkdown(normalizedHtml)).toBe(md + '\n');
|
|
507
|
+
editor.destroy();
|
|
508
|
+
});
|
|
509
|
+
|
|
469
510
|
it('preserves unordered lists', () => {
|
|
470
511
|
const result = roundTrip('- Alpha\n- Beta');
|
|
471
512
|
expect(result).toContain('- Alpha');
|
|
@@ -12,7 +12,7 @@ import { tiptapToMarkdown } from '../tiptapBridge';
|
|
|
12
12
|
* when a pasted / uploaded image is inserted into the tiptap
|
|
13
13
|
* editor via `setImage({src, alt})`, does the markdown we serialize
|
|
14
14
|
* out (via `getHTML()` + `tiptapToMarkdown`) contain
|
|
15
|
-
* `` — the shape the
|
|
15
|
+
* `` — the shape the downstream service's image-attachment
|
|
16
16
|
* extractor expects?
|
|
17
17
|
*
|
|
18
18
|
* Unit tests on the regex alone have been green the whole time, and
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { Editor } from '@tiptap/core';
|
|
7
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
8
|
+
import TaskList from '@tiptap/extension-task-list';
|
|
9
|
+
import TaskItem from '@tiptap/extension-task-item';
|
|
10
|
+
import { EditorProvider, useEditorContext, type EditorContextValue } from '../EditorContext';
|
|
11
|
+
import { Toolbar } from '../Toolbar';
|
|
12
|
+
import { tiptapToMarkdown } from '../tiptapBridge';
|
|
13
|
+
|
|
14
|
+
let currentContext: EditorContextValue | null = null;
|
|
15
|
+
|
|
16
|
+
function ContextProbe() {
|
|
17
|
+
currentContext = useEditorContext();
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function context(): EditorContextValue {
|
|
22
|
+
if (!currentContext) throw new Error('EditorContext has not mounted');
|
|
23
|
+
return currentContext;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function monacoWithSelection(selectedText: string) {
|
|
27
|
+
const selection = {
|
|
28
|
+
startLineNumber: 1,
|
|
29
|
+
startColumn: 1,
|
|
30
|
+
endLineNumber: selectedText.split('\n').length,
|
|
31
|
+
endColumn: 1,
|
|
32
|
+
};
|
|
33
|
+
const model = {
|
|
34
|
+
getValueInRange: () => selectedText,
|
|
35
|
+
getLineContent: () => selectedText.split('\n')[0] ?? '',
|
|
36
|
+
};
|
|
37
|
+
const disposable = { dispose: vi.fn() };
|
|
38
|
+
const executeEdits = vi.fn();
|
|
39
|
+
const editor = {
|
|
40
|
+
getSelection: () => selection,
|
|
41
|
+
getModel: () => model,
|
|
42
|
+
getPosition: () => ({ lineNumber: 1, column: 1 }),
|
|
43
|
+
onDidChangeCursorPosition: () => disposable,
|
|
44
|
+
onDidChangeModelContent: () => disposable,
|
|
45
|
+
executeEdits,
|
|
46
|
+
focus: vi.fn(),
|
|
47
|
+
};
|
|
48
|
+
return { editor, executeEdits, selection };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
currentContext = null;
|
|
53
|
+
if (typeof window.matchMedia !== 'function') {
|
|
54
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
55
|
+
configurable: true,
|
|
56
|
+
value: () => ({
|
|
57
|
+
matches: false,
|
|
58
|
+
addEventListener: vi.fn(),
|
|
59
|
+
removeEventListener: vi.fn(),
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (typeof globalThis.ResizeObserver === 'undefined') {
|
|
64
|
+
class ResizeObserverStub {
|
|
65
|
+
observe(): void {}
|
|
66
|
+
unobserve(): void {}
|
|
67
|
+
disconnect(): void {}
|
|
68
|
+
}
|
|
69
|
+
globalThis.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('<Toolbar> selection conversion menu', () => {
|
|
74
|
+
it('shows Convert above Insert and replaces a delimited Monaco selection', async () => {
|
|
75
|
+
const source = 'Name,Role\nAda,Engineer\n';
|
|
76
|
+
const { editor, executeEdits, selection } = monacoWithSelection(source);
|
|
77
|
+
render(
|
|
78
|
+
<EditorProvider initialMarkdown={source} initialView="raw" allowRecording={false}>
|
|
79
|
+
<Toolbar />
|
|
80
|
+
<ContextProbe />
|
|
81
|
+
</EditorProvider>,
|
|
82
|
+
);
|
|
83
|
+
act(() => context().setMonacoEditor(editor as never));
|
|
84
|
+
|
|
85
|
+
fireEvent.click(screen.getByLabelText('Insert'));
|
|
86
|
+
const menu = await screen.findByRole('menu');
|
|
87
|
+
expect(
|
|
88
|
+
within(menu)
|
|
89
|
+
.getAllByText(/^(Convert|Insert)$/)
|
|
90
|
+
.map((node) => node.textContent),
|
|
91
|
+
).toEqual(['Convert', 'Insert']);
|
|
92
|
+
|
|
93
|
+
fireEvent.click(within(menu).getByRole('menuitem', { name: 'Convert selection to Table' }));
|
|
94
|
+
|
|
95
|
+
expect(executeEdits).toHaveBeenCalledWith('toolbar-convert-selection', [
|
|
96
|
+
{
|
|
97
|
+
range: selection,
|
|
98
|
+
text: '| Name | Role |\n| --- | --- |\n| Ada | Engineer |\n',
|
|
99
|
+
},
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('does not show Convert without selected text', async () => {
|
|
104
|
+
const { editor } = monacoWithSelection('');
|
|
105
|
+
render(
|
|
106
|
+
<EditorProvider initialMarkdown="Intro" initialView="raw" allowRecording={false}>
|
|
107
|
+
<Toolbar />
|
|
108
|
+
<ContextProbe />
|
|
109
|
+
</EditorProvider>,
|
|
110
|
+
);
|
|
111
|
+
act(() => context().setMonacoEditor(editor as never));
|
|
112
|
+
|
|
113
|
+
fireEvent.click(screen.getByLabelText('Insert'));
|
|
114
|
+
const menu = await screen.findByRole('menu');
|
|
115
|
+
expect(within(menu).queryByText('Convert')).toBeNull();
|
|
116
|
+
expect(within(menu).queryByRole('menuitem', { name: 'Convert selection to Table' })).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('replaces fully selected Write paragraphs without leaving a blank paragraph', async () => {
|
|
120
|
+
const editor = new Editor({
|
|
121
|
+
extensions: [StarterKit, TaskList, TaskItem],
|
|
122
|
+
content: '<p>First</p><p>Second</p><p>Third</p><h2>After</h2>',
|
|
123
|
+
});
|
|
124
|
+
const paragraphs: Array<{ pos: number; contentSize: number }> = [];
|
|
125
|
+
editor.state.doc.descendants((node, pos) => {
|
|
126
|
+
if (node.type.name === 'paragraph') {
|
|
127
|
+
paragraphs.push({ pos, contentSize: node.content.size });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
const first = paragraphs[0];
|
|
131
|
+
const third = paragraphs[2];
|
|
132
|
+
expect(first).toBeDefined();
|
|
133
|
+
expect(third).toBeDefined();
|
|
134
|
+
editor.commands.setTextSelection({
|
|
135
|
+
from: first.pos + 1,
|
|
136
|
+
to: third.pos + 1 + third.contentSize,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
render(
|
|
140
|
+
<EditorProvider
|
|
141
|
+
initialMarkdown="First\n\nSecond\n\nThird\n\n## After"
|
|
142
|
+
initialView="wysiwyg"
|
|
143
|
+
allowRecording={false}
|
|
144
|
+
>
|
|
145
|
+
<Toolbar />
|
|
146
|
+
<ContextProbe />
|
|
147
|
+
</EditorProvider>,
|
|
148
|
+
);
|
|
149
|
+
act(() => context().setTiptapEditor(editor));
|
|
150
|
+
|
|
151
|
+
fireEvent.click(screen.getByLabelText('Insert'));
|
|
152
|
+
fireEvent.click(
|
|
153
|
+
await screen.findByRole('menuitem', { name: 'Convert selection to Task List' }),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
await waitFor(() => {
|
|
157
|
+
expect(editor.getJSON().content?.map((node) => node.type)).toEqual(['taskList', 'heading']);
|
|
158
|
+
});
|
|
159
|
+
expect(tiptapToMarkdown(editor.getHTML())).toBe(
|
|
160
|
+
'- [ ] First\n- [ ] Second\n- [ ] Third\n\n## After\n',
|
|
161
|
+
);
|
|
162
|
+
editor.destroy();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -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}
|
|
@@ -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/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
|
*/
|
|
@@ -34,6 +34,19 @@ describe('applyCommand', () => {
|
|
|
34
34
|
expect(after[0].position.height).toBe(80);
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
it('resizeLayer applies an optional north/west origin atomically', () => {
|
|
38
|
+
const before: Layer[] = [rect('a', 10, 20, 100, 50)];
|
|
39
|
+
const after = applyCommand(before, {
|
|
40
|
+
kind: 'resizeLayer',
|
|
41
|
+
id: 'a',
|
|
42
|
+
x: 0,
|
|
43
|
+
y: 5,
|
|
44
|
+
width: 110,
|
|
45
|
+
height: 65,
|
|
46
|
+
});
|
|
47
|
+
expect(after[0].position).toMatchObject({ x: 0, y: 5, width: 110, height: 65 });
|
|
48
|
+
});
|
|
49
|
+
|
|
37
50
|
it('addLayer appends', () => {
|
|
38
51
|
const before: Layer[] = [rect('a', 0, 0)];
|
|
39
52
|
const newLayer = rect('b', 100, 100);
|
|
@@ -69,7 +69,13 @@ export function applyCommand(layers: readonly Layer[], cmd: SceneCommand): Layer
|
|
|
69
69
|
l.id === cmd.id
|
|
70
70
|
? ({
|
|
71
71
|
...l,
|
|
72
|
-
position: {
|
|
72
|
+
position: {
|
|
73
|
+
...l.position,
|
|
74
|
+
...(cmd.x !== undefined ? { x: cmd.x } : {}),
|
|
75
|
+
...(cmd.y !== undefined ? { y: cmd.y } : {}),
|
|
76
|
+
width: cmd.width,
|
|
77
|
+
height: cmd.height,
|
|
78
|
+
},
|
|
73
79
|
} as Layer)
|
|
74
80
|
: l,
|
|
75
81
|
);
|
|
@@ -29,7 +29,14 @@ import { DIAGRAM_VIEWPORT, DIAGRAM_TOOLS } from './diagramConstants';
|
|
|
29
29
|
|
|
30
30
|
export type DiagramCommand =
|
|
31
31
|
| { kind: 'moveNode'; nodeId: string; x: number; y: number }
|
|
32
|
-
| {
|
|
32
|
+
| {
|
|
33
|
+
kind: 'resizeNode';
|
|
34
|
+
nodeId: string;
|
|
35
|
+
width: number;
|
|
36
|
+
height: number;
|
|
37
|
+
x?: number;
|
|
38
|
+
y?: number;
|
|
39
|
+
}
|
|
33
40
|
| { kind: 'addConnection'; source: string; target: string; type?: string }
|
|
34
41
|
| { kind: 'removeConnection'; source: string; target: string; type?: string }
|
|
35
42
|
| { kind: 'renameNode'; nodeId: string; newLabel: string }
|
|
@@ -145,7 +152,14 @@ export function DiagramCanvas({
|
|
|
145
152
|
if (!cmd.id.startsWith('node-card-')) return;
|
|
146
153
|
const nodeId = nodeIdFromCardLayerId(cmd.id);
|
|
147
154
|
if (!nodeId) return;
|
|
148
|
-
onCommand({
|
|
155
|
+
onCommand({
|
|
156
|
+
kind: 'resizeNode',
|
|
157
|
+
nodeId,
|
|
158
|
+
width: cmd.width,
|
|
159
|
+
height: cmd.height,
|
|
160
|
+
...(cmd.x !== undefined ? { x: cmd.x } : {}),
|
|
161
|
+
...(cmd.y !== undefined ? { y: cmd.y } : {}),
|
|
162
|
+
});
|
|
149
163
|
return;
|
|
150
164
|
}
|
|
151
165
|
case 'setLayerAttr':
|