@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.
Files changed (55) hide show
  1. package/dist/index.d.ts +75 -33
  2. package/dist/index.js +1717 -935
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles/index.css +145 -5
  5. package/package.json +4 -4
  6. package/src/DocumentSettingsDialog.tsx +32 -18
  7. package/src/EditorShell.tsx +1 -1
  8. package/src/PreviewControls.tsx +57 -24
  9. package/src/RecorderEntry.tsx +2 -0
  10. package/src/Toolbar.tsx +164 -19
  11. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  12. package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
  13. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  14. package/src/__tests__/previewControls.test.tsx +164 -0
  15. package/src/__tests__/recorderTheme.test.tsx +42 -0
  16. package/src/__tests__/selectionConversions.test.ts +80 -0
  17. package/src/__tests__/tiptapBridge.test.ts +48 -7
  18. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  19. package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
  20. package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
  21. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +58 -1
  22. package/src/asciiDiagram/asciiDiagramCommands.ts +36 -10
  23. package/src/asciiDiagram/asciiDiagramData.ts +33 -0
  24. package/src/asciiDiagram/asciiDiagramOps.ts +19 -0
  25. package/src/codeContext/types.ts +1 -1
  26. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  27. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  28. package/src/diagram/DiagramCanvas.tsx +16 -2
  29. package/src/frontmatterSettings.ts +23 -0
  30. package/src/index.ts +7 -1
  31. package/src/recorder/RecorderButton.tsx +9 -1
  32. package/src/recorder/RecorderModal.tsx +84 -41
  33. package/src/recorder/RecorderPanel.tsx +9 -1
  34. package/src/scene/__tests__/sceneIsolation.test.tsx +27 -1
  35. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  36. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  37. package/src/scene/commands/SceneCommand.ts +13 -2
  38. package/src/scene/tools/SelectTool.ts +5 -5
  39. package/src/selectionConversions.ts +155 -0
  40. package/src/styles/ascii-timeline.css +101 -4
  41. package/src/styles/editor.css +30 -0
  42. package/src/styles/tree-view.css +51 -1
  43. package/src/timeline/TimelineEditorWidget.tsx +200 -41
  44. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +61 -2
  45. package/src/timeline/__tests__/timelineCommands.test.ts +32 -0
  46. package/src/timeline/__tests__/timelineOps.test.ts +55 -0
  47. package/src/timeline/timelineCommands.ts +54 -0
  48. package/src/timeline/timelineOps.ts +107 -3
  49. package/src/tiptapBridge.ts +23 -5
  50. package/src/treeview/TreeOutlineWidget.tsx +153 -3
  51. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  52. package/src/treeview/__tests__/treeOps.test.ts +52 -0
  53. package/src/treeview/__tests__/treeViewCommands.test.ts +16 -0
  54. package/src/treeview/treeOps.ts +59 -0
  55. 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](gezel:leo), take a look.');
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="gezel"');
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="gezel:leo"');
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 `gezel\:leo` to disambiguate
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](gezel\\:leo).');
82
- expect(html).toContain('data-kind="gezel"');
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](gezel:leo), ping @[Tess](gezel:tess) too.';
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
- * `![alt](src)` — the shape the gezel service's image-attachment
15
+ * `![alt](src)` — 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 { 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}
@@ -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,
@@ -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
  */
@@ -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: { ...l.position, width: cmd.width, height: cmd.height },
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
- | { kind: 'resizeNode'; nodeId: string; width: number; height: number }
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({ kind: 'resizeNode', nodeId, width: cmd.width, height: cmd.height });
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':