@bendyline/squisq-editor-react 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -2
- package/dist/index.d.ts +579 -47
- package/dist/index.js +7477 -2943
- package/dist/index.js.map +1 -1
- package/dist/styles/index.css +1008 -38
- package/package.json +6 -5
- package/src/DocumentSettingsDialog.tsx +32 -18
- package/src/EditorContext.tsx +27 -0
- package/src/EditorShell.tsx +25 -1
- package/src/PreviewControls.tsx +77 -29
- package/src/PreviewPanel.tsx +5 -1
- package/src/RawEditor.tsx +12 -0
- package/src/RecorderEntry.tsx +5 -0
- package/src/Toolbar.tsx +479 -36
- package/src/WysiwygEditor.tsx +25 -11
- package/src/__tests__/buildPreviewDocContent.test.ts +17 -0
- package/src/__tests__/codeContextSectionView.test.tsx +8 -6
- package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
- package/src/__tests__/editorShellProps.test.tsx +65 -0
- package/src/__tests__/findMode.test.tsx +104 -0
- package/src/__tests__/findModel.test.ts +55 -0
- package/src/__tests__/markdownCodeFence.test.ts +45 -0
- package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
- package/src/__tests__/mediaReferences.test.ts +15 -0
- package/src/__tests__/previewControls.test.tsx +195 -0
- package/src/__tests__/recorderTheme.test.tsx +42 -0
- package/src/__tests__/selectionConversions.test.ts +80 -0
- package/src/__tests__/tiptapBridge.test.ts +57 -7
- package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
- package/src/__tests__/toolbarSelectionConversion.test.tsx +190 -0
- package/src/__tests__/writeCanvasSettings.test.ts +15 -0
- package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
- package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +20 -1
- package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +58 -1
- package/src/asciiDiagram/asciiDiagramCommands.ts +36 -10
- package/src/asciiDiagram/asciiDiagramData.ts +33 -0
- package/src/asciiDiagram/asciiDiagramOps.ts +19 -0
- package/src/buildPreviewDoc.ts +9 -7
- package/src/codeContext/types.ts +1 -1
- package/src/codeSnippet/CodeSnippetExtension.ts +205 -0
- package/src/codeSnippet/CodeSnippetWidget.tsx +109 -0
- package/src/codeSnippet/__tests__/CodeSnippetExtension.test.ts +95 -0
- package/src/codeSnippet/__tests__/codeSnippetLanguages.test.ts +50 -0
- package/src/codeSnippet/codeSnippetCommands.ts +21 -0
- package/src/codeSnippet/codeSnippetData.ts +43 -0
- package/src/codeSnippet/codeSnippetLanguages.ts +216 -0
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
- package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
- package/src/diagram/DiagramCanvas.tsx +17 -2
- package/src/find/FindHighlightExtension.ts +81 -0
- package/src/find/FindToolbar.tsx +314 -0
- package/src/find/findModel.ts +77 -0
- package/src/frontmatterSettings.ts +23 -0
- package/src/index.ts +96 -1
- package/src/markdownCodeFence.ts +72 -0
- package/src/mediaReferences.ts +5 -3
- package/src/mermaid/MermaidDiagramCanvas.tsx +693 -0
- package/src/mermaid/MermaidDiagramExtension.ts +243 -0
- package/src/mermaid/MermaidDiagramTypeThumbnail.tsx +184 -0
- package/src/mermaid/MermaidDiagramWidget.tsx +653 -0
- package/src/mermaid/MermaidShapePalette.tsx +245 -0
- package/src/mermaid/__tests__/MermaidDiagramExtension.test.ts +361 -0
- package/src/mermaid/__tests__/mermaidDiagramTypes.test.ts +35 -0
- package/src/mermaid/__tests__/mermaidRenderer.test.ts +49 -0
- package/src/mermaid/__tests__/mermaidSourceOps.test.ts +213 -0
- package/src/mermaid/__tests__/mermaidSyntax.test.ts +71 -0
- package/src/mermaid/mermaidCommands.ts +34 -0
- package/src/mermaid/mermaidData.ts +31 -0
- package/src/mermaid/mermaidDiagramTypes.ts +325 -0
- package/src/mermaid/mermaidModel.ts +31 -0
- package/src/mermaid/mermaidRenderer.ts +181 -0
- package/src/mermaid/mermaidShapes.ts +113 -0
- package/src/mermaid/mermaidSourceOps.ts +454 -0
- package/src/recorder/RecorderButton.tsx +9 -1
- package/src/recorder/RecorderModal.tsx +84 -41
- package/src/recorder/RecorderPanel.tsx +9 -1
- package/src/scene/Scene.tsx +46 -15
- package/src/scene/SceneBlockToolbar.tsx +29 -14
- package/src/scene/SceneViewControls.tsx +43 -0
- package/src/scene/__tests__/fitScale.test.ts +19 -0
- package/src/scene/__tests__/sceneIsolation.test.tsx +27 -1
- package/src/scene/__tests__/useScenePanZoom.test.ts +28 -1
- package/src/scene/adapters/DrawingAdapter.ts +6 -1
- package/src/scene/adapters/LayoutAdapter.ts +3 -0
- package/src/scene/commands/SceneCommand.ts +13 -2
- package/src/scene/fitScale.ts +17 -0
- package/src/scene/hooks/useScenePanZoom.ts +11 -4
- package/src/scene/scene.css +85 -3
- package/src/scene/tools/SelectTool.ts +5 -5
- package/src/selectionConversions.ts +155 -0
- package/src/styles/ascii-timeline.css +101 -4
- package/src/styles/code-snippet.css +76 -0
- package/src/styles/editor.css +352 -2
- package/src/styles/index.css +2 -0
- package/src/styles/mermaid-diagram.css +487 -0
- package/src/styles/tree-view.css +51 -1
- package/src/timeline/TimelineEditorWidget.tsx +200 -41
- package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +61 -2
- package/src/timeline/__tests__/timelineCommands.test.ts +32 -0
- package/src/timeline/__tests__/timelineOps.test.ts +55 -0
- package/src/timeline/timelineCommands.ts +54 -0
- package/src/timeline/timelineOps.ts +107 -3
- package/src/tiptapBridge.ts +23 -5
- package/src/treeview/TreeOutlineWidget.tsx +153 -3
- package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
- package/src/treeview/__tests__/treeOps.test.ts +52 -0
- package/src/treeview/__tests__/treeViewCommands.test.ts +16 -0
- package/src/treeview/treeOps.ts +59 -0
- package/src/treeview/treeViewCommands.ts +5 -0
- package/src/writeCanvasSettings.ts +30 -0
|
@@ -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 {
|
|
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
|
+
}
|
package/src/mediaReferences.ts
CHANGED
|
@@ -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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|