@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,314 @@
1
+ import { useCallback, useEffect, useId, useRef, useState } from 'react';
2
+ import type { editor as MonacoEditorNs } from 'monaco-editor';
3
+ import { useEditorContext } from '../EditorContext';
4
+ import { Icon } from '../Icon';
5
+ import { updateTiptapFindHighlights } from './FindHighlightExtension';
6
+ import { findTextMatches, normalizeFindIndex } from './findModel';
7
+
8
+ interface HighlightRegistryLike {
9
+ set(name: string, highlight: unknown): void;
10
+ delete(name: string): boolean;
11
+ }
12
+
13
+ interface HighlightConstructorLike {
14
+ new (...ranges: Range[]): unknown;
15
+ }
16
+
17
+ interface PreviewHighlightResult {
18
+ count: number;
19
+ cleanup: () => void;
20
+ }
21
+
22
+ export interface FindToolbarProps {
23
+ onClose: () => void;
24
+ }
25
+
26
+ /** Search controls shown immediately to the right of the Write/Source/Use tabs. */
27
+ export function FindToolbar({ onClose }: FindToolbarProps) {
28
+ const { activeView, markdownSource, tiptapEditor, monacoEditor } = useEditorContext();
29
+ const [query, setQuery] = useState('');
30
+ const [selectedIndex, setSelectedIndex] = useState(0);
31
+ const [matchCount, setMatchCount] = useState(0);
32
+ const inputRef = useRef<HTMLInputElement>(null);
33
+ const reactId = useId().replace(/[^a-zA-Z0-9_-]/g, '');
34
+ const previewHighlightName = `squisq-find-${reactId}`;
35
+ const previewSelectedName = `${previewHighlightName}-selected`;
36
+
37
+ useEffect(() => {
38
+ inputRef.current?.focus();
39
+ }, []);
40
+
41
+ useEffect(() => {
42
+ const style = document.createElement('style');
43
+ style.dataset.squisqFindHighlight = reactId;
44
+ style.textContent = `::highlight(${previewHighlightName}) { background: #fde68a; color: inherit; } ::highlight(${previewSelectedName}) { background: #f59e0b; color: #111827; }`;
45
+ document.head.append(style);
46
+ return () => style.remove();
47
+ }, [previewHighlightName, previewSelectedName, reactId]);
48
+
49
+ useEffect(() => {
50
+ let cleanup = () => {};
51
+ let count = 0;
52
+
53
+ if (activeView === 'raw' && monacoEditor) {
54
+ const model = monacoEditor.getModel();
55
+ const matches = model
56
+ ? model.findMatches(query.trim(), false, false, false, null, false)
57
+ : [];
58
+ count = query.trim() ? matches.length : 0;
59
+ const selected = normalizeFindIndex(selectedIndex, count);
60
+ const decorations = monacoEditor.createDecorationsCollection(
61
+ matches.slice(0, count).map((match, index) => ({
62
+ range: match.range,
63
+ options: monacoDecorationOptions(index === selected),
64
+ })),
65
+ );
66
+ if (count > 0) monacoEditor.revealRangeInCenter(matches[selected].range);
67
+ cleanup = () => decorations.clear();
68
+ } else if (activeView === 'wysiwyg' && tiptapEditor && !tiptapEditor.isDestroyed) {
69
+ count = updateTiptapFindHighlights(tiptapEditor, query, selectedIndex);
70
+ const selected = tiptapEditor.view.dom.querySelector<HTMLElement>(
71
+ '.squisq-find-match--selected',
72
+ );
73
+ selected?.scrollIntoView?.({ block: 'center', inline: 'nearest' });
74
+ cleanup = () => {
75
+ if (!tiptapEditor.isDestroyed) updateTiptapFindHighlights(tiptapEditor, '', 0);
76
+ };
77
+ } else if (activeView === 'preview') {
78
+ const root = inputRef.current
79
+ ?.closest('.squisq-editor-shell')
80
+ ?.querySelector<HTMLElement>('[data-testid="preview-panel"]');
81
+ if (root) {
82
+ const result = applyPreviewHighlights(
83
+ root,
84
+ query,
85
+ selectedIndex,
86
+ previewHighlightName,
87
+ previewSelectedName,
88
+ );
89
+ count = result.count;
90
+ cleanup = result.cleanup;
91
+ }
92
+ }
93
+
94
+ setMatchCount(count);
95
+ setSelectedIndex((current) => normalizeFindIndex(current, count));
96
+ return cleanup;
97
+ }, [
98
+ activeView,
99
+ markdownSource,
100
+ monacoEditor,
101
+ previewHighlightName,
102
+ previewSelectedName,
103
+ query,
104
+ selectedIndex,
105
+ tiptapEditor,
106
+ ]);
107
+
108
+ const moveSelection = useCallback(
109
+ (delta: number) => {
110
+ if (matchCount === 0) return;
111
+ setSelectedIndex((current) => normalizeFindIndex(current + delta, matchCount));
112
+ },
113
+ [matchCount],
114
+ );
115
+
116
+ const resultLabel =
117
+ query.trim() === ''
118
+ ? '0 of 0'
119
+ : matchCount === 0
120
+ ? 'No results'
121
+ : `${normalizeFindIndex(selectedIndex, matchCount) + 1} of ${matchCount}`;
122
+
123
+ return (
124
+ <div className="squisq-find-toolbar" role="search" aria-label="Find in document">
125
+ <div className="squisq-find-field">
126
+ <Icon icon="fa-solid fa-magnifying-glass" />
127
+ <input
128
+ ref={inputRef}
129
+ type="search"
130
+ value={query}
131
+ className="squisq-find-input"
132
+ aria-label="Find in document"
133
+ placeholder="Find in document"
134
+ autoComplete="off"
135
+ spellCheck={false}
136
+ onChange={(event) => {
137
+ setQuery(event.target.value);
138
+ setSelectedIndex(0);
139
+ }}
140
+ onKeyDown={(event) => {
141
+ if (event.key === 'Enter') {
142
+ event.preventDefault();
143
+ moveSelection(event.shiftKey ? -1 : 1);
144
+ } else if (event.key === 'Escape') {
145
+ event.preventDefault();
146
+ onClose();
147
+ }
148
+ }}
149
+ />
150
+ <span className="squisq-find-count" aria-live="polite" aria-atomic="true">
151
+ {resultLabel}
152
+ </span>
153
+ </div>
154
+ <button
155
+ type="button"
156
+ className="squisq-find-button"
157
+ aria-label="Previous match"
158
+ data-tooltip="Previous match (Shift+Enter)"
159
+ disabled={matchCount === 0}
160
+ onClick={() => moveSelection(-1)}
161
+ >
162
+ <Icon icon="fa-solid fa-chevron-up" />
163
+ </button>
164
+ <button
165
+ type="button"
166
+ className="squisq-find-button"
167
+ aria-label="Next match"
168
+ data-tooltip="Next match (Enter)"
169
+ disabled={matchCount === 0}
170
+ onClick={() => moveSelection(1)}
171
+ >
172
+ <Icon icon="fa-solid fa-chevron-down" />
173
+ </button>
174
+ <button
175
+ type="button"
176
+ className="squisq-find-button squisq-find-close"
177
+ aria-label="Close find"
178
+ data-tooltip="Close find (Esc)"
179
+ onClick={onClose}
180
+ >
181
+ <Icon icon="fa-solid fa-xmark" />
182
+ </button>
183
+ </div>
184
+ );
185
+ }
186
+
187
+ function monacoDecorationOptions(selected: boolean): MonacoEditorNs.IModelDecorationOptions {
188
+ return {
189
+ inlineClassName: selected
190
+ ? 'squisq-find-match squisq-find-match--selected'
191
+ : 'squisq-find-match',
192
+ };
193
+ }
194
+
195
+ function applyPreviewHighlights(
196
+ root: HTMLElement,
197
+ query: string,
198
+ selectedIndex: number,
199
+ highlightName: string,
200
+ selectedName: string,
201
+ ): PreviewHighlightResult {
202
+ const ranges = collectTextRanges(root, query);
203
+ const selected = normalizeFindIndex(selectedIndex, ranges.length);
204
+ const css = globalThis.CSS as (typeof CSS & { highlights?: HighlightRegistryLike }) | undefined;
205
+ const HighlightConstructor = (
206
+ globalThis as typeof globalThis & {
207
+ Highlight?: HighlightConstructorLike;
208
+ }
209
+ ).Highlight;
210
+
211
+ if (css?.highlights && HighlightConstructor) {
212
+ css.highlights.set(highlightName, new HighlightConstructor(...ranges));
213
+ if (ranges.length > 0) {
214
+ css.highlights.set(selectedName, new HighlightConstructor(ranges[selected]));
215
+ scrollRangeIntoView(ranges[selected]);
216
+ } else {
217
+ css.highlights.delete(selectedName);
218
+ }
219
+ return {
220
+ count: ranges.length,
221
+ cleanup: () => {
222
+ css.highlights?.delete(highlightName);
223
+ css.highlights?.delete(selectedName);
224
+ },
225
+ };
226
+ }
227
+
228
+ // CSS Custom Highlight is broadly available in current browsers. Keep a
229
+ // DOM-mark fallback for older embedded webviews; Preview is read-only, and
230
+ // every inserted mark is unwrapped when the query changes or Find closes.
231
+ const marks = markPreviewRanges(ranges, selected);
232
+ marks
233
+ .find((mark) => mark.classList.contains('squisq-find-match--selected'))
234
+ ?.scrollIntoView?.({
235
+ block: 'center',
236
+ inline: 'nearest',
237
+ });
238
+ return {
239
+ count: ranges.length,
240
+ cleanup: () => {
241
+ const parents = new Set<Node>();
242
+ for (const mark of marks) {
243
+ const parent = mark.parentNode;
244
+ if (!parent) continue;
245
+ parents.add(parent);
246
+ mark.replaceWith(...Array.from(mark.childNodes));
247
+ }
248
+ parents.forEach((parent) => parent.normalize());
249
+ },
250
+ };
251
+ }
252
+
253
+ function collectTextRanges(root: HTMLElement, query: string): Range[] {
254
+ if (!query.trim()) return [];
255
+ const doc = root.ownerDocument;
256
+ const showText = doc.defaultView?.NodeFilter.SHOW_TEXT ?? 4;
257
+ const walker = doc.createTreeWalker(root, showText);
258
+ const ranges: Range[] = [];
259
+ let current = walker.nextNode();
260
+ while (current) {
261
+ const text = current as Text;
262
+ const parent = text.parentElement;
263
+ if (
264
+ parent &&
265
+ !parent.closest(
266
+ 'button, input, textarea, select, script, style, [aria-hidden="true"], [data-squisq-find-ignore]',
267
+ )
268
+ ) {
269
+ for (const match of findTextMatches(text.data, query)) {
270
+ const range = doc.createRange();
271
+ range.setStart(text, match.from);
272
+ range.setEnd(text, match.to);
273
+ ranges.push(range);
274
+ }
275
+ }
276
+ current = walker.nextNode();
277
+ }
278
+ return ranges;
279
+ }
280
+
281
+ function scrollRangeIntoView(range: Range): void {
282
+ const element =
283
+ range.startContainer instanceof Element
284
+ ? range.startContainer
285
+ : range.startContainer.parentElement;
286
+ (element as HTMLElement | null)?.scrollIntoView?.({ block: 'center', inline: 'nearest' });
287
+ }
288
+
289
+ function markPreviewRanges(ranges: Range[], selectedIndex: number): HTMLElement[] {
290
+ const marks: HTMLElement[] = [];
291
+ // Work backwards so splitting a text node does not invalidate the offsets
292
+ // of an earlier match in that same node.
293
+ [...ranges]
294
+ .map((range, index) => ({ range, index }))
295
+ .reverse()
296
+ .forEach(({ range, index }) => {
297
+ const text = range.startContainer;
298
+ if (!(text instanceof Text) || text !== range.endContainer || !text.parentNode) return;
299
+ const after = text.splitText(range.endOffset);
300
+ const matched = text.splitText(range.startOffset);
301
+ const mark = text.ownerDocument.createElement('mark');
302
+ mark.className =
303
+ index === selectedIndex
304
+ ? 'squisq-find-match squisq-find-match--selected'
305
+ : 'squisq-find-match';
306
+ matched.replaceWith(mark);
307
+ mark.append(matched);
308
+ marks.push(mark);
309
+ // Keep the tail referenced so aggressive DOM implementations do not
310
+ // discard it before the next reverse-ordered range is processed.
311
+ void after;
312
+ });
313
+ return marks;
314
+ }
@@ -0,0 +1,77 @@
1
+ import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
2
+
3
+ export interface FindTextMatch {
4
+ from: number;
5
+ to: number;
6
+ }
7
+
8
+ /**
9
+ * Find non-overlapping, case-insensitive literal matches. Offsets are UTF-16
10
+ * code-unit offsets, matching DOM Range, Monaco, and ProseMirror positions.
11
+ */
12
+ export function findTextMatches(text: string, query: string): FindTextMatch[] {
13
+ const needle = query.trim();
14
+ if (!needle || !text) return [];
15
+
16
+ const matcher = new RegExp(escapeRegExp(needle), 'giu');
17
+ const matches: FindTextMatch[] = [];
18
+ let match: RegExpExecArray | null;
19
+ while ((match = matcher.exec(text)) !== null) {
20
+ matches.push({ from: match.index, to: match.index + match[0].length });
21
+ // Literal non-empty queries always advance, but retain this guard so a
22
+ // future matcher change cannot create an infinite loop.
23
+ if (match[0].length === 0) matcher.lastIndex += 1;
24
+ }
25
+ return matches;
26
+ }
27
+
28
+ export function normalizeFindIndex(index: number, count: number): number {
29
+ if (count <= 0) return 0;
30
+ return ((index % count) + count) % count;
31
+ }
32
+
33
+ /**
34
+ * Resolve visible text matches to ProseMirror document positions. Text from
35
+ * adjacent marked spans in one text block is searched as one run, so a query
36
+ * can cross a bold/italic boundary without matching across separate blocks.
37
+ */
38
+ export function findProseMirrorMatches(doc: ProseMirrorNode, query: string): FindTextMatch[] {
39
+ const matches: FindTextMatch[] = [];
40
+
41
+ doc.descendants((node, blockPos) => {
42
+ if (!node.isTextblock) return true;
43
+
44
+ const spans: Array<FindTextMatch & { docFrom: number }> = [];
45
+ let text = '';
46
+ node.descendants((child, offset) => {
47
+ if (child.isText && child.text) {
48
+ const from = text.length;
49
+ text += child.text;
50
+ spans.push({ from, to: text.length, docFrom: blockPos + 1 + offset });
51
+ } else if (child.isInline) {
52
+ // Prevent a phrase from matching invisibly across an atom or hard break.
53
+ text += '\u0000';
54
+ }
55
+ return true;
56
+ });
57
+
58
+ for (const match of findTextMatches(text, query)) {
59
+ const startSpan = spans.find((span) => match.from >= span.from && match.from < span.to);
60
+ const endOffset = match.to - 1;
61
+ const endSpan = spans.find((span) => endOffset >= span.from && endOffset < span.to);
62
+ if (!startSpan || !endSpan) continue;
63
+ matches.push({
64
+ from: startSpan.docFrom + match.from - startSpan.from,
65
+ to: endSpan.docFrom + match.to - endSpan.from,
66
+ });
67
+ }
68
+
69
+ return false;
70
+ });
71
+
72
+ return matches;
73
+ }
74
+
75
+ function escapeRegExp(value: string): string {
76
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
77
+ }
@@ -0,0 +1,23 @@
1
+ /** Canonical and legacy keys for frontmatter settings managed by the editor. */
2
+ export const FRONTMATTER_SETTING_KEYS = {
3
+ theme: { canonical: 'squisq-theme', legacy: ['themeId', 'theme'] as const },
4
+ transform: { canonical: 'squisq-transform', legacy: 'transform-style' as const },
5
+ captions: { canonical: 'squisq-captions', legacy: 'caption-style' as const },
6
+ coverSlide: { canonical: 'squisq-cover-slide', legacy: 'cover-slide' as const },
7
+ } as const;
8
+
9
+ /** Runtime defaults whose equivalent frontmatter entries can be omitted. */
10
+ export const FRONTMATTER_SETTING_DEFAULTS = {
11
+ theme: 'standard',
12
+ transform: '',
13
+ captions: 'standard',
14
+ coverSlide: true,
15
+ } as const;
16
+
17
+ /** Return `null` when a setting matches its runtime default so writers remove it. */
18
+ export function omitFrontmatterDefault<T extends string | number | boolean>(
19
+ value: T,
20
+ defaultValue: T,
21
+ ): T | null {
22
+ return value === defaultValue ? null : value;
23
+ }
package/src/index.ts CHANGED
@@ -89,6 +89,7 @@ export type { RawEditorProps } from './RawEditor.js';
89
89
 
90
90
  export { WysiwygEditor } from './WysiwygEditor.js';
91
91
  export type { WysiwygEditorProps } from './WysiwygEditor.js';
92
+ export type { WriteCanvasSettings } from './writeCanvasSettings.js';
92
93
 
93
94
  export { PreviewPanel } from './PreviewPanel.js';
94
95
  export type { PreviewPanelProps } from './PreviewPanel.js';
@@ -263,6 +264,7 @@ export {
263
264
  applyRepairCommand,
264
265
  replaceAsciiFenceText,
265
266
  } from './asciiDiagram/asciiDiagramCommands.js';
267
+ export type { ApplyAsciiDiagramCommandOptions } from './asciiDiagram/asciiDiagramCommands.js';
266
268
  // RepairableDiagramExtension mounts an inline "Repair as diagram" button on
267
269
  // code fences holding BROKEN box art — art too misaligned for clean detection
268
270
  // (so it renders as a faithful code block). One click reconstructs it into
@@ -288,9 +290,98 @@ export {
288
290
  renameNodeOp,
289
291
  resizeNodeOp,
290
292
  sanitizeAsciiLabel,
293
+ translateDiagramOp,
291
294
  } from './asciiDiagram/asciiDiagramOps.js';
292
295
  export { shouldPasteAsAsciiFence } from './asciiDiagram/asciiPaste.js';
293
296
 
297
+ // Complex diagram editor — every explicit `mermaid` fence is rendered through
298
+ // Mermaid's public renderer inside the shared diagram chrome. Flowcharts add
299
+ // source-backed node/edge gestures and the full Mermaid shape catalog; every
300
+ // other diagram family remains lossless render + source editing.
301
+ export {
302
+ MermaidDiagramExtension,
303
+ MERMAID_DIAGRAM_KEY,
304
+ findMermaidDiagramBlockPos,
305
+ isMermaidDiagramNode,
306
+ isMermaidSourceVisible,
307
+ toggleMermaidSource,
308
+ } from './mermaid/MermaidDiagramExtension.js';
309
+ export type {
310
+ MermaidDiagramBlockEntry,
311
+ MermaidDiagramExtensionOptions,
312
+ MermaidDiagramPluginState,
313
+ } from './mermaid/MermaidDiagramExtension.js';
314
+ export { MermaidDiagramWidget } from './mermaid/MermaidDiagramWidget.js';
315
+ export type { MermaidDiagramWidgetProps } from './mermaid/MermaidDiagramWidget.js';
316
+ export { MermaidDiagramCanvas } from './mermaid/MermaidDiagramCanvas.js';
317
+ export type {
318
+ MermaidDiagramCanvasProps,
319
+ MermaidNodeCanvasAction,
320
+ } from './mermaid/MermaidDiagramCanvas.js';
321
+ export { useMermaidDiagramData } from './mermaid/mermaidData.js';
322
+ export type { MermaidDiagramData } from './mermaid/mermaidData.js';
323
+ export {
324
+ renderMermaidDiagram,
325
+ inspectMermaidSource,
326
+ mermaidErrorMessage,
327
+ } from './mermaid/mermaidRenderer.js';
328
+ export type { MermaidRenderResult } from './mermaid/mermaidRenderer.js';
329
+ export { MermaidShapePalette } from './mermaid/MermaidShapePalette.js';
330
+ export type { MermaidShapePaletteProps } from './mermaid/MermaidShapePalette.js';
331
+ export {
332
+ MERMAID_FLOWCHART_SHAPES,
333
+ isMermaidFlowchartShapeId,
334
+ normalizeMermaidFlowchartShape,
335
+ } from './mermaid/mermaidShapes.js';
336
+ export type { MermaidFlowchartShape, MermaidFlowchartShapeId } from './mermaid/mermaidShapes.js';
337
+ export type {
338
+ MermaidEditableEdge,
339
+ MermaidEditableModel,
340
+ MermaidEditableNode,
341
+ MermaidFlowchartDirection,
342
+ MermaidFlowchartModel,
343
+ } from './mermaid/mermaidModel.js';
344
+ export {
345
+ DEFAULT_MERMAID_DIAGRAM_TYPE,
346
+ MERMAID_DIAGRAM_TYPES,
347
+ mermaidDiagramMarkdown,
348
+ } from './mermaid/mermaidDiagramTypes.js';
349
+ export type {
350
+ MermaidDiagramCategory,
351
+ MermaidDiagramPreview,
352
+ MermaidDiagramType,
353
+ } from './mermaid/mermaidDiagramTypes.js';
354
+ export { MermaidDiagramTypeThumbnail } from './mermaid/MermaidDiagramTypeThumbnail.js';
355
+
356
+ // Code snippets — every ordinary explicit-language fence is replaced in the
357
+ // WYSIWYG surface by a Monaco inset. The fence node remains authoritative, so
358
+ // edits preserve the exact language tag and source when serialized to Markdown.
359
+ export {
360
+ CodeSnippetExtension,
361
+ CODE_SNIPPET_KEY,
362
+ findCodeSnippetBlockPos,
363
+ isCodeSnippetNode,
364
+ } from './codeSnippet/CodeSnippetExtension.js';
365
+ export type {
366
+ CodeSnippetBlockEntry,
367
+ CodeSnippetExtensionOptions,
368
+ CodeSnippetPluginState,
369
+ } from './codeSnippet/CodeSnippetExtension.js';
370
+ export { CodeSnippetWidget } from './codeSnippet/CodeSnippetWidget.js';
371
+ export type { CodeSnippetWidgetProps } from './codeSnippet/CodeSnippetWidget.js';
372
+ export { replaceCodeSnippetText } from './codeSnippet/codeSnippetCommands.js';
373
+ export { useCodeSnippetData } from './codeSnippet/codeSnippetData.js';
374
+ export type { CodeSnippetData } from './codeSnippet/codeSnippetData.js';
375
+ export {
376
+ CODE_SNIPPET_LANGUAGES,
377
+ codeSnippetFenceLanguageToken,
378
+ codeSnippetLanguageLabel,
379
+ codeSnippetMarkdown,
380
+ isCodeSnippetFenceLanguage,
381
+ monacoLanguageForFence,
382
+ } from './codeSnippet/codeSnippetLanguages.js';
383
+ export type { CodeSnippetLanguage } from './codeSnippet/codeSnippetLanguages.js';
384
+
294
385
  // Treeview editor — ASCII file-tree / outline fences get an interactive
295
386
  // outline widget; edits re-render the tree art (the fence stays the source
296
387
  // of truth). Peer to the ASCII diagram editor; mutually exclusive with it
@@ -367,7 +458,11 @@ export type { JsonEditorProps } from './jsonEditor/index.js';
367
458
  // `MediaProvider`. Previously published as `@bendyline/squisq-recorder-react`;
368
459
  // folded into editor-react so it ships with the editor it's wired into.
369
460
  export { RecorderModal } from './recorder/RecorderModal.js';
370
- export type { RecorderModalProps, RecorderSaveResult } from './recorder/RecorderModal.js';
461
+ export type {
462
+ RecorderColorScheme,
463
+ RecorderModalProps,
464
+ RecorderSaveResult,
465
+ } from './recorder/RecorderModal.js';
371
466
  export { RecorderButton } from './recorder/RecorderButton.js';
372
467
  export type { RecorderButtonProps } from './recorder/RecorderButton.js';
373
468
  export { RecorderPanel } from './recorder/RecorderPanel.js';
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Source-level Markdown fenced-code detection for editor features that cannot
3
+ * operate on the parsed AST. The returned mask includes opening and closing
4
+ * fence lines, and an unclosed fence remains active through EOF.
5
+ */
6
+
7
+ interface FenceState {
8
+ marker: '`' | '~';
9
+ length: number;
10
+ }
11
+
12
+ const FENCE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
13
+ const CLOSING_FENCE_RE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/;
14
+
15
+ function openingFence(line: string): FenceState | null {
16
+ const match = FENCE_RE.exec(line);
17
+ if (!match) return null;
18
+ const run = match[1];
19
+ const marker = run[0] as '`' | '~';
20
+ // CommonMark forbids backticks in a backtick fence's info string.
21
+ if (marker === '`' && match[2].includes('`')) return null;
22
+ return { marker, length: run.length };
23
+ }
24
+
25
+ function isClosingFence(line: string, fence: FenceState): boolean {
26
+ const match = CLOSING_FENCE_RE.exec(line);
27
+ return !!match && match[1][0] === fence.marker && match[1].length >= fence.length;
28
+ }
29
+
30
+ /** One boolean per physical source line; true means the whole line is fenced code. */
31
+ export function markdownFencedCodeLineMask(source: string): boolean[] {
32
+ const lines = source.split(/\r\n|\r|\n/);
33
+ const mask: boolean[] = [];
34
+ let fence: FenceState | null = null;
35
+
36
+ for (const line of lines) {
37
+ if (fence) {
38
+ mask.push(true);
39
+ if (isClosingFence(line, fence)) fence = null;
40
+ continue;
41
+ }
42
+
43
+ const opening = openingFence(line);
44
+ mask.push(opening != null);
45
+ fence = opening;
46
+ }
47
+
48
+ return mask;
49
+ }
50
+
51
+ /** Whether a 1-based physical source line is an opening, body, or closing fence line. */
52
+ export function isMarkdownFencedCodeLine(source: string, lineNumber: number): boolean {
53
+ if (!Number.isInteger(lineNumber) || lineNumber < 1) return false;
54
+ return markdownFencedCodeLineMask(source)[lineNumber - 1] ?? false;
55
+ }
56
+
57
+ /**
58
+ * Replace fenced-code line contents with spaces while retaining every line
59
+ * ending and character offset. Raw-source scanners can safely inspect the
60
+ * result and use any match offsets against the original source.
61
+ */
62
+ export function maskMarkdownFencedCode(source: string): string {
63
+ const mask = markdownFencedCodeLineMask(source);
64
+ let lineIndex = 0;
65
+ return source
66
+ .split(/(\r\n|\r|\n)/)
67
+ .map((part, index) => {
68
+ if (index % 2 === 1) return part;
69
+ return mask[lineIndex++] ? ' '.repeat(part.length) : part;
70
+ })
71
+ .join('');
72
+ }
@@ -1,4 +1,5 @@
1
1
  import { splitKeyValueToken, tokenizeAttrTokens } from '@bendyline/squisq/markdown';
2
+ import { maskMarkdownFencedCode } from './markdownCodeFence';
2
3
 
3
4
  interface MediaReferenceRange {
4
5
  start: number;
@@ -32,9 +33,10 @@ const HTML_MEDIA_ATTRS = ['src', 'href', 'poster'] as const;
32
33
  */
33
34
  export function collectMediaReferencesFromMarkdown(source: string): ReadonlySet<string> {
34
35
  const refs = new Set<string>();
35
- collectMarkdownInlineReferences(source, refs);
36
- collectHtmlAttributeReferences(source, refs);
37
- collectAnnotationReferences(source, refs);
36
+ const searchableSource = maskMarkdownFencedCode(source);
37
+ collectMarkdownInlineReferences(searchableSource, refs);
38
+ collectHtmlAttributeReferences(searchableSource, refs);
39
+ collectAnnotationReferences(searchableSource, refs);
38
40
  return refs;
39
41
  }
40
42