@bendyline/squisq-editor-react 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +10 -2
  2. package/dist/index.d.ts +579 -47
  3. package/dist/index.js +7477 -2943
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/index.css +1008 -38
  6. package/package.json +6 -5
  7. package/src/DocumentSettingsDialog.tsx +32 -18
  8. package/src/EditorContext.tsx +27 -0
  9. package/src/EditorShell.tsx +25 -1
  10. package/src/PreviewControls.tsx +77 -29
  11. package/src/PreviewPanel.tsx +5 -1
  12. package/src/RawEditor.tsx +12 -0
  13. package/src/RecorderEntry.tsx +5 -0
  14. package/src/Toolbar.tsx +479 -36
  15. package/src/WysiwygEditor.tsx +25 -11
  16. package/src/__tests__/buildPreviewDocContent.test.ts +17 -0
  17. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  18. package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
  19. package/src/__tests__/editorShellProps.test.tsx +65 -0
  20. package/src/__tests__/findMode.test.tsx +104 -0
  21. package/src/__tests__/findModel.test.ts +55 -0
  22. package/src/__tests__/markdownCodeFence.test.ts +45 -0
  23. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  24. package/src/__tests__/mediaReferences.test.ts +15 -0
  25. package/src/__tests__/previewControls.test.tsx +195 -0
  26. package/src/__tests__/recorderTheme.test.tsx +42 -0
  27. package/src/__tests__/selectionConversions.test.ts +80 -0
  28. package/src/__tests__/tiptapBridge.test.ts +57 -7
  29. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  30. package/src/__tests__/toolbarSelectionConversion.test.tsx +190 -0
  31. package/src/__tests__/writeCanvasSettings.test.ts +15 -0
  32. package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
  33. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +20 -1
  34. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +58 -1
  35. package/src/asciiDiagram/asciiDiagramCommands.ts +36 -10
  36. package/src/asciiDiagram/asciiDiagramData.ts +33 -0
  37. package/src/asciiDiagram/asciiDiagramOps.ts +19 -0
  38. package/src/buildPreviewDoc.ts +9 -7
  39. package/src/codeContext/types.ts +1 -1
  40. package/src/codeSnippet/CodeSnippetExtension.ts +205 -0
  41. package/src/codeSnippet/CodeSnippetWidget.tsx +109 -0
  42. package/src/codeSnippet/__tests__/CodeSnippetExtension.test.ts +95 -0
  43. package/src/codeSnippet/__tests__/codeSnippetLanguages.test.ts +50 -0
  44. package/src/codeSnippet/codeSnippetCommands.ts +21 -0
  45. package/src/codeSnippet/codeSnippetData.ts +43 -0
  46. package/src/codeSnippet/codeSnippetLanguages.ts +216 -0
  47. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  48. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  49. package/src/diagram/DiagramCanvas.tsx +17 -2
  50. package/src/find/FindHighlightExtension.ts +81 -0
  51. package/src/find/FindToolbar.tsx +314 -0
  52. package/src/find/findModel.ts +77 -0
  53. package/src/frontmatterSettings.ts +23 -0
  54. package/src/index.ts +96 -1
  55. package/src/markdownCodeFence.ts +72 -0
  56. package/src/mediaReferences.ts +5 -3
  57. package/src/mermaid/MermaidDiagramCanvas.tsx +693 -0
  58. package/src/mermaid/MermaidDiagramExtension.ts +243 -0
  59. package/src/mermaid/MermaidDiagramTypeThumbnail.tsx +184 -0
  60. package/src/mermaid/MermaidDiagramWidget.tsx +653 -0
  61. package/src/mermaid/MermaidShapePalette.tsx +245 -0
  62. package/src/mermaid/__tests__/MermaidDiagramExtension.test.ts +361 -0
  63. package/src/mermaid/__tests__/mermaidDiagramTypes.test.ts +35 -0
  64. package/src/mermaid/__tests__/mermaidRenderer.test.ts +49 -0
  65. package/src/mermaid/__tests__/mermaidSourceOps.test.ts +213 -0
  66. package/src/mermaid/__tests__/mermaidSyntax.test.ts +71 -0
  67. package/src/mermaid/mermaidCommands.ts +34 -0
  68. package/src/mermaid/mermaidData.ts +31 -0
  69. package/src/mermaid/mermaidDiagramTypes.ts +325 -0
  70. package/src/mermaid/mermaidModel.ts +31 -0
  71. package/src/mermaid/mermaidRenderer.ts +181 -0
  72. package/src/mermaid/mermaidShapes.ts +113 -0
  73. package/src/mermaid/mermaidSourceOps.ts +454 -0
  74. package/src/recorder/RecorderButton.tsx +9 -1
  75. package/src/recorder/RecorderModal.tsx +84 -41
  76. package/src/recorder/RecorderPanel.tsx +9 -1
  77. package/src/scene/Scene.tsx +46 -15
  78. package/src/scene/SceneBlockToolbar.tsx +29 -14
  79. package/src/scene/SceneViewControls.tsx +43 -0
  80. package/src/scene/__tests__/fitScale.test.ts +19 -0
  81. package/src/scene/__tests__/sceneIsolation.test.tsx +27 -1
  82. package/src/scene/__tests__/useScenePanZoom.test.ts +28 -1
  83. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  84. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  85. package/src/scene/commands/SceneCommand.ts +13 -2
  86. package/src/scene/fitScale.ts +17 -0
  87. package/src/scene/hooks/useScenePanZoom.ts +11 -4
  88. package/src/scene/scene.css +85 -3
  89. package/src/scene/tools/SelectTool.ts +5 -5
  90. package/src/selectionConversions.ts +155 -0
  91. package/src/styles/ascii-timeline.css +101 -4
  92. package/src/styles/code-snippet.css +76 -0
  93. package/src/styles/editor.css +352 -2
  94. package/src/styles/index.css +2 -0
  95. package/src/styles/mermaid-diagram.css +487 -0
  96. package/src/styles/tree-view.css +51 -1
  97. package/src/timeline/TimelineEditorWidget.tsx +200 -41
  98. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +61 -2
  99. package/src/timeline/__tests__/timelineCommands.test.ts +32 -0
  100. package/src/timeline/__tests__/timelineOps.test.ts +55 -0
  101. package/src/timeline/timelineCommands.ts +54 -0
  102. package/src/timeline/timelineOps.ts +107 -3
  103. package/src/tiptapBridge.ts +23 -5
  104. package/src/treeview/TreeOutlineWidget.tsx +153 -3
  105. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  106. package/src/treeview/__tests__/treeOps.test.ts +52 -0
  107. package/src/treeview/__tests__/treeViewCommands.test.ts +16 -0
  108. package/src/treeview/treeOps.ts +59 -0
  109. package/src/treeview/treeViewCommands.ts +5 -0
  110. package/src/writeCanvasSettings.ts +30 -0
@@ -0,0 +1,109 @@
1
+ /** Monaco inset mounted in place of an ordinary language-tagged code fence. */
2
+
3
+ import { useCallback, useEffect, useState } from 'react';
4
+ import MonacoEditor, { type OnChange } from '@monaco-editor/react';
5
+ import type { Editor } from '@tiptap/react';
6
+ import { Icon } from '../Icon';
7
+ import { useMonacoLoader } from '../useMonacoLoader';
8
+ import { replaceCodeSnippetText } from './codeSnippetCommands';
9
+ import { useCodeSnippetData } from './codeSnippetData';
10
+ import { codeSnippetFenceLanguageToken } from './codeSnippetLanguages';
11
+
12
+ export interface CodeSnippetWidgetProps {
13
+ editor: Editor;
14
+ blockId: string;
15
+ host?: HTMLElement | null;
16
+ }
17
+
18
+ function schemeFromHost(host: HTMLElement | null | undefined): 'light' | 'dark' {
19
+ return host?.closest<HTMLElement>('[data-theme]')?.dataset.theme === 'dark' ? 'dark' : 'light';
20
+ }
21
+
22
+ function useHostColorScheme(host: HTMLElement | null | undefined): 'light' | 'dark' {
23
+ const [scheme, setScheme] = useState<'light' | 'dark'>(() => schemeFromHost(host));
24
+ useEffect(() => {
25
+ const themedAncestor = host?.closest<HTMLElement>('[data-theme]');
26
+ setScheme(schemeFromHost(host));
27
+ if (!themedAncestor || typeof MutationObserver === 'undefined') return;
28
+ const observer = new MutationObserver(() => setScheme(schemeFromHost(host)));
29
+ observer.observe(themedAncestor, { attributes: true, attributeFilter: ['data-theme'] });
30
+ return () => observer.disconnect();
31
+ }, [host]);
32
+ return scheme;
33
+ }
34
+
35
+ function useEditorEditable(editor: Editor): boolean {
36
+ const [editable, setEditable] = useState(editor.isEditable);
37
+ useEffect(() => {
38
+ const sync = () => setEditable(editor.isEditable);
39
+ editor.on('update', sync);
40
+ editor.on('transaction', sync);
41
+ return () => {
42
+ editor.off('update', sync);
43
+ editor.off('transaction', sync);
44
+ };
45
+ }, [editor]);
46
+ return editable;
47
+ }
48
+
49
+ export function CodeSnippetWidget({ editor, blockId, host }: CodeSnippetWidgetProps) {
50
+ const data = useCodeSnippetData(editor, blockId);
51
+ const { ready } = useMonacoLoader();
52
+ const colorScheme = useHostColorScheme(host);
53
+ const editable = useEditorEditable(editor);
54
+
55
+ const handleChange: OnChange = useCallback(
56
+ (value) => {
57
+ if (!editable) return;
58
+ replaceCodeSnippetText(editor, blockId, value ?? '');
59
+ },
60
+ [blockId, editable, editor],
61
+ );
62
+
63
+ if (!data) return null;
64
+ const modelSuffix = codeSnippetFenceLanguageToken(data.fenceLanguage).replace(
65
+ /[^a-z0-9_-]/g,
66
+ '-',
67
+ );
68
+
69
+ return (
70
+ <div className="squisq-code-snippet-shell" data-language={data.fenceLanguage}>
71
+ <div className="squisq-code-snippet-header">
72
+ <span className="squisq-code-snippet-title">
73
+ <Icon icon="fa-solid fa-file-code" />
74
+ <span>{data.label}</span>
75
+ </span>
76
+ <code>{`\`\`\`${data.fenceLanguage}`}</code>
77
+ </div>
78
+ <div className="squisq-code-snippet-editor" aria-label={`${data.label} code editor`}>
79
+ {ready ? (
80
+ <MonacoEditor
81
+ path={`inmemory://squisq/code-snippet/${blockId}.${modelSuffix || 'txt'}`}
82
+ language={data.monacoLanguage}
83
+ theme={colorScheme === 'dark' ? 'vs-dark' : 'vs'}
84
+ value={data.source}
85
+ onChange={handleChange}
86
+ options={{
87
+ automaticLayout: true,
88
+ contextmenu: true,
89
+ folding: true,
90
+ fontSize: 13,
91
+ lineNumbers: 'on',
92
+ minimap: { enabled: false },
93
+ padding: { top: 10, bottom: 10 },
94
+ readOnly: !editable,
95
+ renderLineHighlight: 'line',
96
+ scrollBeyondLastLine: false,
97
+ tabSize: 2,
98
+ wordWrap: 'off',
99
+ }}
100
+ />
101
+ ) : (
102
+ <div className="squisq-code-snippet-loading">Loading code editor…</div>
103
+ )}
104
+ </div>
105
+ </div>
106
+ );
107
+ }
108
+
109
+ export default CodeSnippetWidget;
@@ -0,0 +1,95 @@
1
+ /** @vitest-environment jsdom */
2
+
3
+ import { Editor } from '@tiptap/core';
4
+ import StarterKit from '@tiptap/starter-kit';
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+ import { markdownToTiptap, tiptapToMarkdown } from '../../tiptapBridge';
7
+ import { replaceCodeSnippetText } from '../codeSnippetCommands';
8
+ import {
9
+ CODE_SNIPPET_KEY,
10
+ CodeSnippetExtension,
11
+ findCodeSnippetBlockPos,
12
+ } from '../CodeSnippetExtension';
13
+
14
+ vi.mock('../CodeSnippetWidget', () => ({
15
+ CodeSnippetWidget: () => null,
16
+ }));
17
+
18
+ const editors: Editor[] = [];
19
+
20
+ function makeEditor(markdown: string, enabled = true): Editor {
21
+ const editor = new Editor({
22
+ extensions: [StarterKit, CodeSnippetExtension.configure({ enabled })],
23
+ content: markdownToTiptap(markdown),
24
+ });
25
+ editors.push(editor);
26
+ return editor;
27
+ }
28
+
29
+ function entriesOf(editor: Editor) {
30
+ return CODE_SNIPPET_KEY.getState(editor.state)?.entries ?? [];
31
+ }
32
+
33
+ afterEach(() => {
34
+ for (const editor of editors) editor.destroy();
35
+ editors.length = 0;
36
+ });
37
+
38
+ describe('CodeSnippetExtension', () => {
39
+ it('claims all explicit ordinary code languages, including unknown ones', () => {
40
+ const editor = makeEditor(
41
+ '```typescript\nconst n = 1;\n```\n\n```kusto\nTable | take 5\n```\n',
42
+ );
43
+ expect(entriesOf(editor)).toHaveLength(2);
44
+ });
45
+
46
+ it('does not steal plain, Mermaid, diagram, tree, or timeline fences', () => {
47
+ const editor = makeEditor(
48
+ [
49
+ '```text\nplain\n```',
50
+ '```mermaid\nflowchart LR\n a --> b\n```',
51
+ '```diagram\n+---+\n| A |\n+---+\n```',
52
+ '```tree\nsrc/\n└── index.ts\n```',
53
+ '```timeline\nMilestones: ● Start ─►\n```',
54
+ '```\nuntagged\n```',
55
+ '',
56
+ ].join('\n\n'),
57
+ );
58
+ expect(entriesOf(editor)).toHaveLength(0);
59
+ });
60
+
61
+ it('keeps a stable block id and rewrites only the fence body', () => {
62
+ const editor = makeEditor('```typescript\nconst before = true;\n```\n');
63
+ const [before] = entriesOf(editor);
64
+ editor.commands.insertContentAt(0, '<p>Intro</p>');
65
+ const [shifted] = entriesOf(editor);
66
+ expect(shifted.id).toBe(before.id);
67
+ expect(shifted.pos).toBeGreaterThan(before.pos);
68
+ expect(findCodeSnippetBlockPos(editor, before.id)).toBe(shifted.pos);
69
+
70
+ expect(replaceCodeSnippetText(editor, before.id, 'const after: number = 2;')).toBe(true);
71
+ expect(tiptapToMarkdown(editor.getHTML())).toContain(
72
+ '```typescript\nconst after: number = 2;\n```',
73
+ );
74
+ });
75
+
76
+ it('supports clearing the Monaco model without losing the fence language', () => {
77
+ const editor = makeEditor('```json\n{"key": true}\n```\n');
78
+ const [entry] = entriesOf(editor);
79
+ expect(replaceCodeSnippetText(editor, entry.id, '')).toBe(true);
80
+ const node = editor.state.doc.nodeAt(findCodeSnippetBlockPos(editor, entry.id) ?? -1);
81
+ expect(node?.textContent).toBe('');
82
+ expect(node?.attrs.language).toBe('json');
83
+ });
84
+
85
+ it('round-trips the language tag and source byte-for-byte', () => {
86
+ const markdown = '```typescript\nconst n: number = 1;\n```\n';
87
+ const editor = makeEditor(markdown);
88
+ expect(tiptapToMarkdown(editor.getHTML())).toBe(markdown);
89
+ });
90
+
91
+ it('can be disabled', () => {
92
+ const editor = makeEditor('```typescript\nconst n = 1;\n```\n', false);
93
+ expect(CODE_SNIPPET_KEY.getState(editor.state)).toBeUndefined();
94
+ });
95
+ });
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ CODE_SNIPPET_LANGUAGES,
4
+ codeSnippetFenceLanguageToken,
5
+ codeSnippetLanguageLabel,
6
+ codeSnippetMarkdown,
7
+ isCodeSnippetFenceLanguage,
8
+ monacoLanguageForFence,
9
+ } from '../codeSnippetLanguages';
10
+
11
+ describe('code snippet languages', () => {
12
+ it('offers unique explicit fence languages', () => {
13
+ const languages = CODE_SNIPPET_LANGUAGES.map((entry) => entry.fenceLanguage);
14
+ expect(new Set(languages).size).toBe(languages.length);
15
+ expect(languages).toContain('typescript');
16
+ expect(languages).toContain('json');
17
+ expect(languages).toContain('python');
18
+ });
19
+
20
+ it('keeps specialized authored-view languages out of the Monaco inset', () => {
21
+ for (const language of [
22
+ '',
23
+ 'text',
24
+ 'txt',
25
+ 'plaintext',
26
+ 'ascii',
27
+ 'diagram',
28
+ 'tree',
29
+ 'timeline',
30
+ 'mermaid',
31
+ ]) {
32
+ expect(isCodeSnippetFenceLanguage(language), language).toBe(false);
33
+ }
34
+ expect(isCodeSnippetFenceLanguage('typescript')).toBe(true);
35
+ expect(isCodeSnippetFenceLanguage('kusto')).toBe(true);
36
+ });
37
+
38
+ it('uses the first fence token for Monaco while preserving metadata for round trips', () => {
39
+ expect(codeSnippetFenceLanguageToken(' json data ')).toBe('json');
40
+ expect(monacoLanguageForFence('ts highlight-lines')).toBe('typescript');
41
+ expect(monacoLanguageForFence('yml')).toBe('yaml');
42
+ expect(codeSnippetLanguageLabel('csharp')).toBe('C#');
43
+ });
44
+
45
+ it('creates an explicit language-tagged Markdown fence', () => {
46
+ expect(codeSnippetMarkdown('typescript', 'const n: number = 1;')).toBe(
47
+ '\n```typescript\nconst n: number = 1;\n```\n',
48
+ );
49
+ });
50
+ });
@@ -0,0 +1,21 @@
1
+ /** Lossless edits for language-tagged code fences. */
2
+
3
+ import type { Editor } from '@tiptap/react';
4
+ import { findCodeSnippetBlockPos, isCodeSnippetNode } from './CodeSnippetExtension';
5
+
6
+ export function replaceCodeSnippetText(editor: Editor, blockId: string, nextText: string): boolean {
7
+ const pos = findCodeSnippetBlockPos(editor, blockId);
8
+ if (pos === null) return false;
9
+ const node = editor.state.doc.nodeAt(pos);
10
+ if (!node || !isCodeSnippetNode(node)) return false;
11
+ if (node.textContent === nextText) return true;
12
+
13
+ const from = pos + 1;
14
+ const to = pos + node.nodeSize - 1;
15
+ const tr =
16
+ nextText.length > 0
17
+ ? editor.state.tr.replaceWith(from, to, editor.state.schema.text(nextText))
18
+ : editor.state.tr.delete(from, to);
19
+ editor.view.dispatch(tr);
20
+ return true;
21
+ }
@@ -0,0 +1,43 @@
1
+ /** Live source/language view for one registered code snippet fence. */
2
+
3
+ import { useEffect, useMemo, useState } from 'react';
4
+ import type { Editor } from '@tiptap/react';
5
+ import { findCodeSnippetBlockPos, isCodeSnippetNode } from './CodeSnippetExtension';
6
+ import { codeSnippetLanguageLabel, monacoLanguageForFence } from './codeSnippetLanguages';
7
+
8
+ export interface CodeSnippetData {
9
+ /** Fence language exactly as stored in the ProseMirror node. */
10
+ fenceLanguage: string;
11
+ label: string;
12
+ monacoLanguage: string;
13
+ /** Source exactly as stored inside the code block. */
14
+ source: string;
15
+ }
16
+
17
+ export function useCodeSnippetData(editor: Editor, blockId: string): CodeSnippetData | null {
18
+ const [version, setVersion] = useState(0);
19
+ useEffect(() => {
20
+ const onTransaction = () => setVersion((value) => value + 1);
21
+ editor.on('transaction', onTransaction);
22
+ return () => {
23
+ editor.off('transaction', onTransaction);
24
+ };
25
+ }, [editor]);
26
+
27
+ return useMemo(() => {
28
+ const pos = findCodeSnippetBlockPos(editor, blockId);
29
+ if (pos === null) return null;
30
+ const node = editor.state.doc.nodeAt(pos);
31
+ if (!node || !isCodeSnippetNode(node)) return null;
32
+ const rawLanguage = (node.attrs as { language?: unknown }).language;
33
+ const fenceLanguage = typeof rawLanguage === 'string' ? rawLanguage : '';
34
+ return {
35
+ fenceLanguage,
36
+ label: codeSnippetLanguageLabel(fenceLanguage),
37
+ monacoLanguage: monacoLanguageForFence(fenceLanguage),
38
+ source: node.textContent,
39
+ };
40
+ // `version` is the transaction-backed invalidation token.
41
+ // eslint-disable-next-line react-hooks/exhaustive-deps
42
+ }, [editor, blockId, version]);
43
+ }
@@ -0,0 +1,216 @@
1
+ /** Languages offered by the Insert Code Snippet menu and understood by Monaco. */
2
+
3
+ export interface CodeSnippetLanguage {
4
+ /** Language written after the opening Markdown fence. */
5
+ readonly fenceLanguage: string;
6
+ /** Human-readable picker/header label. */
7
+ readonly label: string;
8
+ /** Monaco language id used for syntax highlighting and language services. */
9
+ readonly monacoLanguage: string;
10
+ /** Small, immediately editable body inserted for a new snippet. */
11
+ readonly starter: string;
12
+ }
13
+
14
+ export const CODE_SNIPPET_LANGUAGES: readonly CodeSnippetLanguage[] = [
15
+ {
16
+ fenceLanguage: 'typescript',
17
+ label: 'TypeScript',
18
+ monacoLanguage: 'typescript',
19
+ starter: "const message: string = 'Hello, world!';",
20
+ },
21
+ {
22
+ fenceLanguage: 'javascript',
23
+ label: 'JavaScript',
24
+ monacoLanguage: 'javascript',
25
+ starter: "const message = 'Hello, world!';",
26
+ },
27
+ {
28
+ fenceLanguage: 'tsx',
29
+ label: 'TSX',
30
+ monacoLanguage: 'typescript',
31
+ starter: 'export function Component() {\n return <div>Hello, world!</div>;\n}',
32
+ },
33
+ {
34
+ fenceLanguage: 'jsx',
35
+ label: 'JSX',
36
+ monacoLanguage: 'javascript',
37
+ starter: 'export function Component() {\n return <div>Hello, world!</div>;\n}',
38
+ },
39
+ {
40
+ fenceLanguage: 'json',
41
+ label: 'JSON',
42
+ monacoLanguage: 'json',
43
+ starter: '{\n "key": "value"\n}',
44
+ },
45
+ {
46
+ fenceLanguage: 'html',
47
+ label: 'HTML',
48
+ monacoLanguage: 'html',
49
+ starter: '<div>Hello, world!</div>',
50
+ },
51
+ {
52
+ fenceLanguage: 'css',
53
+ label: 'CSS',
54
+ monacoLanguage: 'css',
55
+ starter: '.example {\n color: #2563eb;\n}',
56
+ },
57
+ {
58
+ fenceLanguage: 'python',
59
+ label: 'Python',
60
+ monacoLanguage: 'python',
61
+ starter: 'print("Hello, world!")',
62
+ },
63
+ {
64
+ fenceLanguage: 'bash',
65
+ label: 'Shell',
66
+ monacoLanguage: 'shell',
67
+ starter: 'echo "Hello, world!"',
68
+ },
69
+ {
70
+ fenceLanguage: 'sql',
71
+ label: 'SQL',
72
+ monacoLanguage: 'sql',
73
+ starter: 'SELECT *\nFROM table_name;',
74
+ },
75
+ {
76
+ fenceLanguage: 'yaml',
77
+ label: 'YAML',
78
+ monacoLanguage: 'yaml',
79
+ starter: 'key: value',
80
+ },
81
+ {
82
+ fenceLanguage: 'markdown',
83
+ label: 'Markdown',
84
+ monacoLanguage: 'markdown',
85
+ starter: '# Heading',
86
+ },
87
+ {
88
+ fenceLanguage: 'java',
89
+ label: 'Java',
90
+ monacoLanguage: 'java',
91
+ starter: 'class Main {\n public static void main(String[] args) {\n }\n}',
92
+ },
93
+ {
94
+ fenceLanguage: 'csharp',
95
+ label: 'C#',
96
+ monacoLanguage: 'csharp',
97
+ starter: 'Console.WriteLine("Hello, world!");',
98
+ },
99
+ {
100
+ fenceLanguage: 'cpp',
101
+ label: 'C++',
102
+ monacoLanguage: 'cpp',
103
+ starter: '#include <iostream>\n\nint main() {\n return 0;\n}',
104
+ },
105
+ {
106
+ fenceLanguage: 'go',
107
+ label: 'Go',
108
+ monacoLanguage: 'go',
109
+ starter: 'package main\n\nfunc main() {\n}',
110
+ },
111
+ {
112
+ fenceLanguage: 'rust',
113
+ label: 'Rust',
114
+ monacoLanguage: 'rust',
115
+ starter: 'fn main() {\n println!("Hello, world!");\n}',
116
+ },
117
+ {
118
+ fenceLanguage: 'ruby',
119
+ label: 'Ruby',
120
+ monacoLanguage: 'ruby',
121
+ starter: 'puts "Hello, world!"',
122
+ },
123
+ {
124
+ fenceLanguage: 'php',
125
+ label: 'PHP',
126
+ monacoLanguage: 'php',
127
+ starter: '<?php\necho "Hello, world!";',
128
+ },
129
+ {
130
+ fenceLanguage: 'swift',
131
+ label: 'Swift',
132
+ monacoLanguage: 'swift',
133
+ starter: 'print("Hello, world!")',
134
+ },
135
+ {
136
+ fenceLanguage: 'kotlin',
137
+ label: 'Kotlin',
138
+ monacoLanguage: 'kotlin',
139
+ starter: 'fun main() {\n println("Hello, world!")\n}',
140
+ },
141
+ {
142
+ fenceLanguage: 'dockerfile',
143
+ label: 'Dockerfile',
144
+ monacoLanguage: 'dockerfile',
145
+ starter: 'FROM node:22-alpine',
146
+ },
147
+ ] as const;
148
+
149
+ const BY_FENCE_LANGUAGE: ReadonlyMap<string, CodeSnippetLanguage> = new Map(
150
+ CODE_SNIPPET_LANGUAGES.map((language) => [language.fenceLanguage, language]),
151
+ );
152
+
153
+ /** Languages owned by the diagram/tree/timeline editors or their auto-detection gates. */
154
+ const SPECIAL_FENCE_LANGUAGES = new Set([
155
+ 'text',
156
+ 'txt',
157
+ 'plaintext',
158
+ 'plain',
159
+ 'ascii',
160
+ 'diagram',
161
+ 'tree',
162
+ 'timeline',
163
+ 'mermaid',
164
+ ]);
165
+
166
+ const MONACO_LANGUAGE_ALIASES: Readonly<Record<string, string>> = {
167
+ c: 'c',
168
+ 'c++': 'cpp',
169
+ 'c#': 'csharp',
170
+ cs: 'csharp',
171
+ cxx: 'cpp',
172
+ docker: 'dockerfile',
173
+ htm: 'html',
174
+ js: 'javascript',
175
+ jsx: 'javascript',
176
+ md: 'markdown',
177
+ py: 'python',
178
+ rb: 'ruby',
179
+ sh: 'shell',
180
+ shell: 'shell',
181
+ ts: 'typescript',
182
+ tsx: 'typescript',
183
+ yml: 'yaml',
184
+ };
185
+
186
+ /** The first token is the syntax id; any remaining fence metadata stays untouched. */
187
+ export function codeSnippetFenceLanguageToken(language: string | null | undefined): string {
188
+ if (typeof language !== 'string') return '';
189
+ return language.trim().split(/\s+/, 1)[0]?.toLowerCase() ?? '';
190
+ }
191
+
192
+ /** True for explicit language-tagged fences not owned by a richer Squisq block editor. */
193
+ export function isCodeSnippetFenceLanguage(language: string | null | undefined): boolean {
194
+ const token = codeSnippetFenceLanguageToken(language);
195
+ return token.length > 0 && !SPECIAL_FENCE_LANGUAGES.has(token);
196
+ }
197
+
198
+ export function monacoLanguageForFence(language: string | null | undefined): string {
199
+ const token = codeSnippetFenceLanguageToken(language);
200
+ const catalogEntry = BY_FENCE_LANGUAGE.get(token);
201
+ return (catalogEntry?.monacoLanguage ?? MONACO_LANGUAGE_ALIASES[token] ?? token) || 'plaintext';
202
+ }
203
+
204
+ export function codeSnippetLanguageLabel(language: string | null | undefined): string {
205
+ const token = codeSnippetFenceLanguageToken(language);
206
+ const catalogEntry = BY_FENCE_LANGUAGE.get(token);
207
+ if (catalogEntry) return catalogEntry.label;
208
+ return token
209
+ ? token.replace(/(^|[-_])([a-z])/g, (_match, _prefix, letter) => letter.toUpperCase())
210
+ : 'Code';
211
+ }
212
+
213
+ /** Markdown insertion form. The caller decides whether to wrap selected text or use a starter. */
214
+ export function codeSnippetMarkdown(language: string, source: string): string {
215
+ return `\n\`\`\`${language}\n${source}\n\`\`\`\n`;
216
+ }
@@ -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':
@@ -208,6 +222,7 @@ export function DiagramCanvas({
208
222
  showMaximize={showMaximize}
209
223
  maximized={maximized}
210
224
  onToggleMaximize={onToggleMaximize}
225
+ showViewControls
211
226
  showToolbar={false}
212
227
  textEditing={textConfig}
213
228
  />
@@ -0,0 +1,81 @@
1
+ import { Extension, type Editor } from '@tiptap/core';
2
+ import { Plugin, PluginKey } from '@tiptap/pm/state';
3
+ import { Decoration, DecorationSet } from '@tiptap/pm/view';
4
+ import { findProseMirrorMatches, normalizeFindIndex } from './findModel';
5
+
6
+ interface FindHighlightState {
7
+ query: string;
8
+ selectedIndex: number;
9
+ decorations: DecorationSet;
10
+ }
11
+
12
+ interface FindHighlightMeta {
13
+ query: string;
14
+ selectedIndex: number;
15
+ }
16
+
17
+ const FIND_HIGHLIGHT_KEY = new PluginKey<FindHighlightState>('squisq-find-highlight');
18
+
19
+ function buildState(
20
+ doc: Parameters<typeof findProseMirrorMatches>[0],
21
+ query: string,
22
+ selectedIndex: number,
23
+ ): FindHighlightState {
24
+ const matches = findProseMirrorMatches(doc, query);
25
+ const selected = normalizeFindIndex(selectedIndex, matches.length);
26
+ const decorations = matches.map((match, index) =>
27
+ Decoration.inline(match.from, match.to, {
28
+ class:
29
+ index === selected ? 'squisq-find-match squisq-find-match--selected' : 'squisq-find-match',
30
+ }),
31
+ );
32
+ return {
33
+ query,
34
+ selectedIndex: selected,
35
+ decorations: DecorationSet.create(doc, decorations),
36
+ };
37
+ }
38
+
39
+ /** ProseMirror decorations used by the shell's host-triggered Find mode. */
40
+ export const FindHighlightExtension = Extension.create({
41
+ name: 'squisqFindHighlight',
42
+
43
+ addProseMirrorPlugins() {
44
+ return [
45
+ new Plugin<FindHighlightState>({
46
+ key: FIND_HIGHLIGHT_KEY,
47
+ state: {
48
+ init: (_, state) => buildState(state.doc, '', 0),
49
+ apply: (transaction, previous, _oldState, newState) => {
50
+ const meta = transaction.getMeta(FIND_HIGHLIGHT_KEY) as FindHighlightMeta | undefined;
51
+ if (!meta && !transaction.docChanged) return previous;
52
+ return buildState(
53
+ newState.doc,
54
+ meta?.query ?? previous.query,
55
+ meta?.selectedIndex ?? previous.selectedIndex,
56
+ );
57
+ },
58
+ },
59
+ props: {
60
+ decorations: (state) => FIND_HIGHLIGHT_KEY.getState(state)?.decorations ?? null,
61
+ },
62
+ }),
63
+ ];
64
+ },
65
+ });
66
+
67
+ /** Update WYSIWYG highlights and return the current number of matches. */
68
+ export function updateTiptapFindHighlights(
69
+ editor: Editor,
70
+ query: string,
71
+ selectedIndex: number,
72
+ ): number {
73
+ const matches = findProseMirrorMatches(editor.state.doc, query);
74
+ editor.view.dispatch(
75
+ editor.state.tr.setMeta(FIND_HIGHLIGHT_KEY, {
76
+ query,
77
+ selectedIndex: normalizeFindIndex(selectedIndex, matches.length),
78
+ } satisfies FindHighlightMeta),
79
+ );
80
+ return matches.length;
81
+ }