@bendyline/squisq-editor-react 2.0.1 → 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 +505 -15
- package/dist/index.js +6035 -2283
- package/dist/index.js.map +1 -1
- package/dist/styles/index.css +870 -40
- package/package.json +6 -5
- package/src/EditorContext.tsx +27 -0
- package/src/EditorShell.tsx +24 -0
- package/src/PreviewControls.tsx +20 -5
- package/src/PreviewPanel.tsx +5 -1
- package/src/RawEditor.tsx +12 -0
- package/src/RecorderEntry.tsx +3 -0
- package/src/Toolbar.tsx +315 -17
- package/src/WysiwygEditor.tsx +25 -11
- package/src/__tests__/buildPreviewDocContent.test.ts +17 -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__/mediaReferences.test.ts +15 -0
- package/src/__tests__/previewControls.test.tsx +31 -0
- package/src/__tests__/tiptapBridge.test.ts +9 -0
- package/src/__tests__/toolbarSelectionConversion.test.tsx +26 -0
- package/src/__tests__/writeCanvasSettings.test.ts +15 -0
- package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +20 -1
- package/src/buildPreviewDoc.ts +9 -7
- 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/diagram/DiagramCanvas.tsx +1 -0
- package/src/find/FindHighlightExtension.ts +81 -0
- package/src/find/FindToolbar.tsx +314 -0
- package/src/find/findModel.ts +77 -0
- package/src/index.ts +89 -0
- 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/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__/useScenePanZoom.test.ts +28 -1
- package/src/scene/fitScale.ts +17 -0
- package/src/scene/hooks/useScenePanZoom.ts +11 -4
- package/src/scene/scene.css +85 -3
- package/src/styles/code-snippet.css +76 -0
- package/src/styles/editor.css +322 -2
- package/src/styles/index.css +2 -0
- package/src/styles/mermaid-diagram.css +487 -0
- package/src/writeCanvasSettings.ts +30 -0
package/src/Toolbar.tsx
CHANGED
|
@@ -59,6 +59,20 @@ import {
|
|
|
59
59
|
selectionToTaskListMarkdown,
|
|
60
60
|
type SelectionTaskItem,
|
|
61
61
|
} from './selectionConversions';
|
|
62
|
+
import { isMarkdownFencedCodeLine, markdownFencedCodeLineMask } from './markdownCodeFence';
|
|
63
|
+
import {
|
|
64
|
+
CODE_SNIPPET_LANGUAGES,
|
|
65
|
+
codeSnippetMarkdown,
|
|
66
|
+
type CodeSnippetLanguage,
|
|
67
|
+
} from './codeSnippet/codeSnippetLanguages';
|
|
68
|
+
import {
|
|
69
|
+
DEFAULT_MERMAID_DIAGRAM_TYPE,
|
|
70
|
+
MERMAID_DIAGRAM_TYPES,
|
|
71
|
+
mermaidDiagramMarkdown,
|
|
72
|
+
type MermaidDiagramType,
|
|
73
|
+
} from './mermaid/mermaidDiagramTypes';
|
|
74
|
+
import { MermaidDiagramTypeThumbnail } from './mermaid/MermaidDiagramTypeThumbnail';
|
|
75
|
+
import { FindToolbar } from './find/FindToolbar';
|
|
62
76
|
|
|
63
77
|
const VIEWS: { id: EditorView; label: string; shortLabel?: string; shortcut: string }[] = [
|
|
64
78
|
{ id: 'wysiwyg', label: 'Write', shortcut: '⌘1' },
|
|
@@ -228,6 +242,14 @@ const BUTTONS: ToolbarButton[] = [
|
|
|
228
242
|
group: 'media',
|
|
229
243
|
faIcon: 'fa-solid fa-diagram-project',
|
|
230
244
|
},
|
|
245
|
+
{
|
|
246
|
+
id: 'complexdiagram',
|
|
247
|
+
label: 'complex diagram',
|
|
248
|
+
icon: '',
|
|
249
|
+
title: 'Insert Complex Diagram (Mermaid)',
|
|
250
|
+
group: 'media',
|
|
251
|
+
faIcon: 'fa-solid fa-code-branch',
|
|
252
|
+
},
|
|
231
253
|
{
|
|
232
254
|
id: 'tree',
|
|
233
255
|
label: 'tree',
|
|
@@ -282,6 +304,8 @@ const FIRST_MEDIA_INDEX = BUTTONS.findIndex((b) => b.group === 'media');
|
|
|
282
304
|
const MEDIA_BUTTONS = BUTTONS.filter((b) => b.group === 'media');
|
|
283
305
|
const CONVERT_BUTTONS = MEDIA_BUTTONS.filter((b) => b.id === 'table' || b.id === 'tasklist');
|
|
284
306
|
const INSERT_MENU_WIDTH = 200;
|
|
307
|
+
const CODE_SNIPPET_MENU_WIDTH = 220;
|
|
308
|
+
const MERMAID_TYPE_MENU_WIDTH = 520;
|
|
285
309
|
const TASK_LIST_ITEMS = ['Task 1', 'Task 2', 'Task 3'] as const;
|
|
286
310
|
const TASK_LIST_MARKDOWN = TASK_LIST_ITEMS.map((item) => `- [ ] ${item}`).join('\n');
|
|
287
311
|
|
|
@@ -452,6 +476,15 @@ function insertAsciiDiagramBlock(editor: TiptapEditor): void {
|
|
|
452
476
|
insertFenceBlock(editor, DIAGRAM_STARTER_ART, 'diagram');
|
|
453
477
|
}
|
|
454
478
|
|
|
479
|
+
/** Default source for callers that invoke the legacy single-click action. */
|
|
480
|
+
const MERMAID_STARTER_ART = DEFAULT_MERMAID_DIAGRAM_TYPE.starter;
|
|
481
|
+
const MERMAID_STARTER_MARKDOWN = mermaidDiagramMarkdown(MERMAID_STARTER_ART);
|
|
482
|
+
|
|
483
|
+
/** Insert a Mermaid fence that MermaidDiagramExtension turns into the complex canvas. */
|
|
484
|
+
function insertMermaidDiagramBlock(editor: TiptapEditor, source = MERMAID_STARTER_ART): void {
|
|
485
|
+
insertFenceBlock(editor, source, 'mermaid');
|
|
486
|
+
}
|
|
487
|
+
|
|
455
488
|
/**
|
|
456
489
|
* Starter art for a new file tree — an ASCII tree fence (the fence is the
|
|
457
490
|
* source of truth; `TreeViewExtension` mounts the interactive outline).
|
|
@@ -637,6 +670,8 @@ export function Toolbar({
|
|
|
637
670
|
activeSceneText,
|
|
638
671
|
mediaProvider,
|
|
639
672
|
editorMode,
|
|
673
|
+
findMode,
|
|
674
|
+
setFindMode,
|
|
640
675
|
layoutMode,
|
|
641
676
|
activeBlockStartLine,
|
|
642
677
|
versioning,
|
|
@@ -744,9 +779,19 @@ export function Toolbar({
|
|
|
744
779
|
// buttons with a single "+" button. Portaled out to avoid overflow:hidden clipping.
|
|
745
780
|
const insertMenuButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
746
781
|
const insertMenuRef = useRef<HTMLDivElement | null>(null);
|
|
782
|
+
const codeSnippetMenuRef = useRef<HTMLDivElement | null>(null);
|
|
783
|
+
const mermaidTypeMenuRef = useRef<HTMLDivElement | null>(null);
|
|
747
784
|
const [insertMenuAnchor, setInsertMenuAnchor] = useState<{ top: number; left: number } | null>(
|
|
748
785
|
null,
|
|
749
786
|
);
|
|
787
|
+
const [codeSnippetMenuAnchor, setCodeSnippetMenuAnchor] = useState<{
|
|
788
|
+
top: number;
|
|
789
|
+
left: number;
|
|
790
|
+
} | null>(null);
|
|
791
|
+
const [mermaidTypeMenuAnchor, setMermaidTypeMenuAnchor] = useState<{
|
|
792
|
+
top: number;
|
|
793
|
+
left: number;
|
|
794
|
+
} | null>(null);
|
|
750
795
|
|
|
751
796
|
const openInsertMenu = useCallback((trigger?: HTMLElement | null) => {
|
|
752
797
|
const btn = trigger ?? insertMenuButtonRef.current;
|
|
@@ -760,9 +805,47 @@ export function Toolbar({
|
|
|
760
805
|
left = Math.max(margin, rect.right - INSERT_MENU_WIDTH);
|
|
761
806
|
}
|
|
762
807
|
setInsertMenuAnchor({ top: rect.bottom + gap, left });
|
|
808
|
+
setCodeSnippetMenuAnchor(null);
|
|
809
|
+
setMermaidTypeMenuAnchor(null);
|
|
763
810
|
}, []);
|
|
764
811
|
|
|
765
|
-
const
|
|
812
|
+
const openCodeSnippetMenu = useCallback((trigger: HTMLElement) => {
|
|
813
|
+
const rect = trigger.getBoundingClientRect();
|
|
814
|
+
const gap = 4;
|
|
815
|
+
const margin = 8;
|
|
816
|
+
const vw = window.innerWidth;
|
|
817
|
+
const vh = window.innerHeight;
|
|
818
|
+
let left = rect.right + gap;
|
|
819
|
+
if (left + CODE_SNIPPET_MENU_WIDTH + margin > vw) {
|
|
820
|
+
left = Math.max(margin, rect.left - CODE_SNIPPET_MENU_WIDTH - gap);
|
|
821
|
+
}
|
|
822
|
+
const maxMenuHeight = Math.min(440, vh - margin * 2);
|
|
823
|
+
const top = Math.max(margin, Math.min(rect.top, vh - maxMenuHeight - margin));
|
|
824
|
+
setCodeSnippetMenuAnchor({ top, left });
|
|
825
|
+
setMermaidTypeMenuAnchor(null);
|
|
826
|
+
}, []);
|
|
827
|
+
|
|
828
|
+
const openMermaidTypeMenu = useCallback((trigger: HTMLElement) => {
|
|
829
|
+
const rect = trigger.getBoundingClientRect();
|
|
830
|
+
const gap = 4;
|
|
831
|
+
const margin = 8;
|
|
832
|
+
const vw = window.innerWidth;
|
|
833
|
+
const vh = window.innerHeight;
|
|
834
|
+
let left = rect.right + gap;
|
|
835
|
+
if (left + MERMAID_TYPE_MENU_WIDTH + margin > vw) {
|
|
836
|
+
left = Math.max(margin, rect.left - MERMAID_TYPE_MENU_WIDTH - gap);
|
|
837
|
+
}
|
|
838
|
+
const maxMenuHeight = Math.min(600, vh - margin * 2);
|
|
839
|
+
const top = Math.max(margin, Math.min(rect.top, vh - maxMenuHeight - margin));
|
|
840
|
+
setMermaidTypeMenuAnchor({ top, left });
|
|
841
|
+
setCodeSnippetMenuAnchor(null);
|
|
842
|
+
}, []);
|
|
843
|
+
|
|
844
|
+
const closeInsertMenu = useCallback(() => {
|
|
845
|
+
setInsertMenuAnchor(null);
|
|
846
|
+
setCodeSnippetMenuAnchor(null);
|
|
847
|
+
setMermaidTypeMenuAnchor(null);
|
|
848
|
+
}, []);
|
|
766
849
|
|
|
767
850
|
// ── Overflow detection ────────────────────────────────
|
|
768
851
|
const actionsRef = useRef<HTMLDivElement>(null);
|
|
@@ -781,6 +864,13 @@ export function Toolbar({
|
|
|
781
864
|
// Custom layout manager dialog (list of doc/library layouts + designer)
|
|
782
865
|
const [showLayoutManager, setShowLayoutManager] = useState(false);
|
|
783
866
|
|
|
867
|
+
useEffect(() => {
|
|
868
|
+
if (!findMode) return;
|
|
869
|
+
setShowOverflow(false);
|
|
870
|
+
closeInsertMenu();
|
|
871
|
+
closeEmojiPicker();
|
|
872
|
+
}, [closeEmojiPicker, closeInsertMenu, findMode]);
|
|
873
|
+
|
|
784
874
|
const overflowIndex = measuredOverflowIndex;
|
|
785
875
|
const clippedContextual = new Set(clippedContextualKey ? clippedContextualKey.split('|') : []);
|
|
786
876
|
|
|
@@ -874,6 +964,8 @@ export function Toolbar({
|
|
|
874
964
|
const handleClick = (e: MouseEvent) => {
|
|
875
965
|
if (insertMenuButtonRef.current?.contains(e.target as Node)) return;
|
|
876
966
|
if (insertMenuRef.current?.contains(e.target as Node)) return;
|
|
967
|
+
if (codeSnippetMenuRef.current?.contains(e.target as Node)) return;
|
|
968
|
+
if (mermaidTypeMenuRef.current?.contains(e.target as Node)) return;
|
|
877
969
|
closeInsertMenu();
|
|
878
970
|
};
|
|
879
971
|
document.addEventListener('mousedown', handleClick);
|
|
@@ -1004,6 +1096,11 @@ export function Toolbar({
|
|
|
1004
1096
|
// mounts the interactive canvas over the inserted starter.
|
|
1005
1097
|
insertAsciiDiagramBlock(tiptapEditor);
|
|
1006
1098
|
break;
|
|
1099
|
+
case 'complexdiagram':
|
|
1100
|
+
// Complex diagrams remain full Mermaid source; the Mermaid extension
|
|
1101
|
+
// renders the fence without narrowing it to Squisq's graph model.
|
|
1102
|
+
insertMermaidDiagramBlock(tiptapEditor);
|
|
1103
|
+
break;
|
|
1007
1104
|
case 'tree':
|
|
1008
1105
|
// File trees are ASCII tree fences; TreeViewExtension mounts the
|
|
1009
1106
|
// interactive outline over the inserted starter.
|
|
@@ -1175,6 +1272,11 @@ export function Toolbar({
|
|
|
1175
1272
|
newCursorOffset = replacement.length;
|
|
1176
1273
|
break;
|
|
1177
1274
|
}
|
|
1275
|
+
case 'complexdiagram': {
|
|
1276
|
+
replacement = MERMAID_STARTER_MARKDOWN;
|
|
1277
|
+
newCursorOffset = replacement.length;
|
|
1278
|
+
break;
|
|
1279
|
+
}
|
|
1178
1280
|
case 'tree': {
|
|
1179
1281
|
replacement = TREE_STARTER_MARKDOWN;
|
|
1180
1282
|
newCursorOffset = replacement.length;
|
|
@@ -1265,6 +1367,9 @@ export function Toolbar({
|
|
|
1265
1367
|
case 'diagram':
|
|
1266
1368
|
insertion = DIAGRAM_STARTER_MARKDOWN;
|
|
1267
1369
|
break;
|
|
1370
|
+
case 'complexdiagram':
|
|
1371
|
+
insertion = MERMAID_STARTER_MARKDOWN;
|
|
1372
|
+
break;
|
|
1268
1373
|
case 'tree':
|
|
1269
1374
|
insertion = TREE_STARTER_MARKDOWN;
|
|
1270
1375
|
break;
|
|
@@ -1286,7 +1391,76 @@ export function Toolbar({
|
|
|
1286
1391
|
[monacoEditor, markdownSource, setMarkdownSource],
|
|
1287
1392
|
);
|
|
1288
1393
|
|
|
1394
|
+
const handleCodeSnippetInsert = useCallback(
|
|
1395
|
+
(language: CodeSnippetLanguage) => {
|
|
1396
|
+
if (activeView === 'wysiwyg' && tiptapEditor) {
|
|
1397
|
+
insertFenceBlock(tiptapEditor, language.starter, language.fenceLanguage);
|
|
1398
|
+
closeInsertMenu();
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
if (monacoEditor) {
|
|
1403
|
+
const selection = monacoEditor.getSelection();
|
|
1404
|
+
const model = monacoEditor.getModel();
|
|
1405
|
+
if (!selection || !model) return;
|
|
1406
|
+
const selectedText = model.getValueInRange(selection);
|
|
1407
|
+
const source = selectedText.length > 0 ? selectedText : language.starter;
|
|
1408
|
+
const markdown = codeSnippetMarkdown(language.fenceLanguage, source);
|
|
1409
|
+
monacoEditor.executeEdits('toolbar-code-snippet', [{ range: selection, text: markdown }]);
|
|
1410
|
+
if (selectedText.length === 0) {
|
|
1411
|
+
const sourceOffset = 5 + language.fenceLanguage.length;
|
|
1412
|
+
const sourcePosition = model.getPositionAt(
|
|
1413
|
+
model.getOffsetAt(selection.getStartPosition()) + sourceOffset,
|
|
1414
|
+
);
|
|
1415
|
+
monacoEditor.setPosition(sourcePosition);
|
|
1416
|
+
monacoEditor.revealPositionInCenterIfOutsideViewport(sourcePosition);
|
|
1417
|
+
}
|
|
1418
|
+
monacoEditor.focus();
|
|
1419
|
+
closeInsertMenu();
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
setMarkdownSource(
|
|
1424
|
+
markdownSource + codeSnippetMarkdown(language.fenceLanguage, language.starter),
|
|
1425
|
+
);
|
|
1426
|
+
closeInsertMenu();
|
|
1427
|
+
},
|
|
1428
|
+
[activeView, closeInsertMenu, markdownSource, monacoEditor, setMarkdownSource, tiptapEditor],
|
|
1429
|
+
);
|
|
1430
|
+
|
|
1289
1431
|
// ── Selected-text conversion handlers ─────────────────
|
|
1432
|
+
const handleMermaidDiagramInsert = useCallback(
|
|
1433
|
+
(diagramType: MermaidDiagramType) => {
|
|
1434
|
+
if (activeView === 'wysiwyg' && tiptapEditor) {
|
|
1435
|
+
insertMermaidDiagramBlock(tiptapEditor, diagramType.starter);
|
|
1436
|
+
closeInsertMenu();
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
const markdown = mermaidDiagramMarkdown(diagramType.starter);
|
|
1441
|
+
if (monacoEditor) {
|
|
1442
|
+
const selection = monacoEditor.getSelection();
|
|
1443
|
+
const model = monacoEditor.getModel();
|
|
1444
|
+
if (!selection || !model) return;
|
|
1445
|
+
monacoEditor.executeEdits('toolbar-mermaid-diagram', [
|
|
1446
|
+
{ range: selection, text: markdown },
|
|
1447
|
+
]);
|
|
1448
|
+
const endPosition = model.getPositionAt(
|
|
1449
|
+
model.getOffsetAt(selection.getStartPosition()) + markdown.length,
|
|
1450
|
+
);
|
|
1451
|
+
monacoEditor.setPosition(endPosition);
|
|
1452
|
+
monacoEditor.revealPositionInCenterIfOutsideViewport(endPosition);
|
|
1453
|
+
monacoEditor.focus();
|
|
1454
|
+
closeInsertMenu();
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
setMarkdownSource(markdownSource + markdown);
|
|
1459
|
+
closeInsertMenu();
|
|
1460
|
+
},
|
|
1461
|
+
[activeView, closeInsertMenu, markdownSource, monacoEditor, setMarkdownSource, tiptapEditor],
|
|
1462
|
+
);
|
|
1463
|
+
|
|
1290
1464
|
const readSelectedText = useCallback((): string => {
|
|
1291
1465
|
// Canvas text overlays intentionally have a smaller schema without tables
|
|
1292
1466
|
// or task lists. Ignore their selection instead of surfacing actions that
|
|
@@ -1597,14 +1771,12 @@ export function Toolbar({
|
|
|
1597
1771
|
const maxHeadingLevelInDoc = useMemo(() => {
|
|
1598
1772
|
if (!markdownSource) return 0;
|
|
1599
1773
|
let max = 0;
|
|
1600
|
-
|
|
1601
|
-
|
|
1774
|
+
const lines = markdownSource.split(/\r\n|\r|\n/);
|
|
1775
|
+
const fencedLines = markdownFencedCodeLineMask(markdownSource);
|
|
1776
|
+
for (let index = 0; index < lines.length; index++) {
|
|
1777
|
+
if (fencedLines[index]) continue;
|
|
1778
|
+
const rawLine = lines[index];
|
|
1602
1779
|
const line = rawLine.trimEnd();
|
|
1603
|
-
if (/^\s*```/.test(line)) {
|
|
1604
|
-
inFence = !inFence;
|
|
1605
|
-
continue;
|
|
1606
|
-
}
|
|
1607
|
-
if (inFence) continue;
|
|
1608
1780
|
const m = /^(#{1,6})\s+\S/.exec(line);
|
|
1609
1781
|
if (m && m[1].length > max) max = m[1].length;
|
|
1610
1782
|
}
|
|
@@ -1655,6 +1827,12 @@ export function Toolbar({
|
|
|
1655
1827
|
setRawTransition(EMPTY_TRANSITION);
|
|
1656
1828
|
return;
|
|
1657
1829
|
}
|
|
1830
|
+
if (isMarkdownFencedCodeLine(model.getValue(), pos.lineNumber)) {
|
|
1831
|
+
setRawTemplate(null);
|
|
1832
|
+
setRawHeadingLine(null);
|
|
1833
|
+
setRawTransition(EMPTY_TRANSITION);
|
|
1834
|
+
return;
|
|
1835
|
+
}
|
|
1658
1836
|
const line = model.getLineContent(pos.lineNumber);
|
|
1659
1837
|
const headingMatch = line.match(/^#{1,6}\s+(.+)$/);
|
|
1660
1838
|
if (!headingMatch) {
|
|
@@ -1794,6 +1972,7 @@ export function Toolbar({
|
|
|
1794
1972
|
const model = monacoEditor.getModel();
|
|
1795
1973
|
const pos = monacoEditor.getPosition();
|
|
1796
1974
|
if (!model || !pos) return;
|
|
1975
|
+
if (isMarkdownFencedCodeLine(model.getValue(), pos.lineNumber)) return;
|
|
1797
1976
|
const lineNumber = pos.lineNumber;
|
|
1798
1977
|
const lineText = model.getLineContent(lineNumber);
|
|
1799
1978
|
const headingMatch = lineText.match(/^(#{1,6}\s+)(.+)$/);
|
|
@@ -1858,6 +2037,7 @@ export function Toolbar({
|
|
|
1858
2037
|
const model = monacoEditor.getModel();
|
|
1859
2038
|
const pos = monacoEditor.getPosition();
|
|
1860
2039
|
if (!model || !pos) return;
|
|
2040
|
+
if (isMarkdownFencedCodeLine(model.getValue(), pos.lineNumber)) return;
|
|
1861
2041
|
const lineNumber = pos.lineNumber;
|
|
1862
2042
|
const lineText = model.getLineContent(lineNumber);
|
|
1863
2043
|
const newLine = setHeadingLineTransition(lineText, next);
|
|
@@ -1905,12 +2085,17 @@ export function Toolbar({
|
|
|
1905
2085
|
currentTransition !== null && clippedContextual.has('transition');
|
|
1906
2086
|
const showBlockSectionInOverflow = showTemplateInOverflow || showTransitionInOverflow;
|
|
1907
2087
|
const overflowBlockLabel = currentTemplate ? templateLabel(currentTemplate) : 'Heading';
|
|
2088
|
+
const showToolbarOverflow =
|
|
2089
|
+
!findMode &&
|
|
2090
|
+
!isPreview &&
|
|
2091
|
+
!isCodeMode &&
|
|
2092
|
+
(overflowIndex !== null || clippedContextual.size > 0);
|
|
1908
2093
|
|
|
1909
2094
|
return (
|
|
1910
2095
|
<div
|
|
1911
|
-
className={`squisq-toolbar ${className || ''}`}
|
|
2096
|
+
className={`squisq-toolbar${findMode ? ' squisq-toolbar--find' : ''} ${className || ''}`}
|
|
1912
2097
|
role="toolbar"
|
|
1913
|
-
aria-label=
|
|
2098
|
+
aria-label={findMode ? 'Find toolbar' : 'Formatting toolbar'}
|
|
1914
2099
|
>
|
|
1915
2100
|
{/* Hidden file input for image picker */}
|
|
1916
2101
|
<input
|
|
@@ -1926,7 +2111,7 @@ export function Toolbar({
|
|
|
1926
2111
|
}}
|
|
1927
2112
|
/>
|
|
1928
2113
|
{/* Left slot — before view tabs */}
|
|
1929
|
-
{slotLeft}
|
|
2114
|
+
{!findMode && slotLeft}
|
|
1930
2115
|
{/* View tabs — hidden when only one view is available (e.g. code mode). */}
|
|
1931
2116
|
{showViewTabs && (
|
|
1932
2117
|
<div className="squisq-toolbar-view-tabs" role="tablist" aria-label="Editor view">
|
|
@@ -1989,9 +2174,9 @@ export function Toolbar({
|
|
|
1989
2174
|
</div>
|
|
1990
2175
|
)}
|
|
1991
2176
|
{/* After-tabs slot — left side, before formatting or preview controls. */}
|
|
1992
|
-
{slotAfterTabs}
|
|
2177
|
+
{findMode ? <FindToolbar onClose={() => setFindMode(false)} /> : slotAfterTabs}
|
|
1993
2178
|
{/* Formatting buttons — hidden in preview mode and code mode */}
|
|
1994
|
-
{!isPreview && !isCodeMode && (
|
|
2179
|
+
{!findMode && !isPreview && !isCodeMode && (
|
|
1995
2180
|
<div className="squisq-toolbar-actions" ref={actionsRef}>
|
|
1996
2181
|
{groups.map((group, gi) => (
|
|
1997
2182
|
<div key={group} className="squisq-toolbar-group">
|
|
@@ -2242,7 +2427,7 @@ export function Toolbar({
|
|
|
2242
2427
|
{/* Overflow menu — outside the overflow:hidden actions container.
|
|
2243
2428
|
Also appears when a contextual group (template/transition picker,
|
|
2244
2429
|
table controls) is clipped, even if every plain button fits. */}
|
|
2245
|
-
{
|
|
2430
|
+
{showToolbarOverflow && (
|
|
2246
2431
|
<div className="squisq-toolbar-overflow" ref={overflowRef}>
|
|
2247
2432
|
<button
|
|
2248
2433
|
className={`squisq-toolbar-button squisq-toolbar-overflow-trigger${showOverflow ? ' squisq-toolbar-button--active' : ''}`}
|
|
@@ -2412,13 +2597,13 @@ export function Toolbar({
|
|
|
2412
2597
|
)}
|
|
2413
2598
|
|
|
2414
2599
|
{/* After-actions slot — after formatting controls */}
|
|
2415
|
-
{slotAfterActions}
|
|
2600
|
+
{!findMode && slotAfterActions}
|
|
2416
2601
|
{/* Spacer — pushes right-side items to the end when the flex:1 actions
|
|
2417
2602
|
container isn't rendered. In preview mode PreviewToolbarControls
|
|
2418
2603
|
supplies its own flex:1 filler (and measures that leftover width to
|
|
2419
2604
|
decide whether to collapse), so a second spacer here would split the
|
|
2420
2605
|
slack and make the controls collapse too early. */}
|
|
2421
|
-
{isCodeMode && <div style={{ flex: 1 }} />}
|
|
2606
|
+
{isCodeMode && !findMode && <div style={{ flex: 1 }} />}
|
|
2422
2607
|
{/* Version history — renders only when the host enabled versioning
|
|
2423
2608
|
and a container is wired up. The component owns its own button
|
|
2424
2609
|
and popover; we just give it a slot in the toolbar. */}
|
|
@@ -2542,23 +2727,136 @@ export function Toolbar({
|
|
|
2542
2727
|
const disabled = (btn.id === 'image' && !mediaProvider) || !buttonAllowed(btn.id);
|
|
2543
2728
|
const stripped = btn.title.replace(/^Insert\s+/i, '');
|
|
2544
2729
|
const label = stripped.charAt(0).toUpperCase() + stripped.slice(1);
|
|
2730
|
+
const hasMermaidTypeMenu = btn.id === 'complexdiagram';
|
|
2545
2731
|
return (
|
|
2546
2732
|
<button
|
|
2547
2733
|
key={btn.id}
|
|
2548
2734
|
ref={btn.id === 'emoji' ? emojiButtonRef : undefined}
|
|
2549
2735
|
className="squisq-toolbar-overflow-item"
|
|
2550
2736
|
disabled={disabled}
|
|
2551
|
-
onClick={() => {
|
|
2737
|
+
onClick={(event) => {
|
|
2738
|
+
if (hasMermaidTypeMenu) {
|
|
2739
|
+
if (mermaidTypeMenuAnchor) setMermaidTypeMenuAnchor(null);
|
|
2740
|
+
else openMermaidTypeMenu(event.currentTarget);
|
|
2741
|
+
return;
|
|
2742
|
+
}
|
|
2552
2743
|
handleAction(btn.id);
|
|
2553
2744
|
if (btn.id !== 'emoji') closeInsertMenu();
|
|
2554
2745
|
}}
|
|
2555
2746
|
role="menuitem"
|
|
2747
|
+
aria-haspopup={hasMermaidTypeMenu ? 'menu' : undefined}
|
|
2748
|
+
aria-expanded={hasMermaidTypeMenu ? mermaidTypeMenuAnchor !== null : undefined}
|
|
2556
2749
|
>
|
|
2557
2750
|
<span className="squisq-toolbar-overflow-icon">{buttonIcon(btn)}</span>
|
|
2558
2751
|
<span>{label}</span>
|
|
2752
|
+
{hasMermaidTypeMenu && <Icon icon="fa-solid fa-chevron-right" />}
|
|
2559
2753
|
</button>
|
|
2560
2754
|
);
|
|
2561
2755
|
})}
|
|
2756
|
+
<button
|
|
2757
|
+
className="squisq-toolbar-overflow-item"
|
|
2758
|
+
onClick={(event) => {
|
|
2759
|
+
if (codeSnippetMenuAnchor) setCodeSnippetMenuAnchor(null);
|
|
2760
|
+
else openCodeSnippetMenu(event.currentTarget);
|
|
2761
|
+
}}
|
|
2762
|
+
role="menuitem"
|
|
2763
|
+
aria-label="Insert Code Snippet"
|
|
2764
|
+
aria-haspopup="menu"
|
|
2765
|
+
aria-expanded={codeSnippetMenuAnchor !== null}
|
|
2766
|
+
>
|
|
2767
|
+
<span className="squisq-toolbar-overflow-icon">
|
|
2768
|
+
<Icon icon="fa-solid fa-file-code" />
|
|
2769
|
+
</span>
|
|
2770
|
+
<span>Code Snippet</span>
|
|
2771
|
+
<Icon icon="fa-solid fa-chevron-right" />
|
|
2772
|
+
</button>
|
|
2773
|
+
</div>,
|
|
2774
|
+
document.body,
|
|
2775
|
+
)}
|
|
2776
|
+
|
|
2777
|
+
{mermaidTypeMenuAnchor &&
|
|
2778
|
+
createPortal(
|
|
2779
|
+
<div
|
|
2780
|
+
ref={mermaidTypeMenuRef}
|
|
2781
|
+
className="squisq-insert-menu squisq-mermaid-type-menu"
|
|
2782
|
+
data-theme={colorScheme}
|
|
2783
|
+
style={{
|
|
2784
|
+
position: 'fixed',
|
|
2785
|
+
top: mermaidTypeMenuAnchor.top,
|
|
2786
|
+
left: mermaidTypeMenuAnchor.left,
|
|
2787
|
+
}}
|
|
2788
|
+
role="menu"
|
|
2789
|
+
aria-label="Mermaid diagram type"
|
|
2790
|
+
>
|
|
2791
|
+
<div className="squisq-mermaid-type-menu-heading">
|
|
2792
|
+
<strong>Complex Diagram</strong>
|
|
2793
|
+
<span>Choose a Mermaid diagram type. Flow direction remains a separate edit.</span>
|
|
2794
|
+
</div>
|
|
2795
|
+
{(['Structure', 'Planning', 'Data'] as const).map((category) => (
|
|
2796
|
+
<section key={category} className="squisq-mermaid-type-section">
|
|
2797
|
+
<div className="squisq-insert-menu-header">{category}</div>
|
|
2798
|
+
<div className="squisq-mermaid-type-grid">
|
|
2799
|
+
{MERMAID_DIAGRAM_TYPES.filter((entry) => entry.category === category).map(
|
|
2800
|
+
(entry) => (
|
|
2801
|
+
<button
|
|
2802
|
+
key={entry.id}
|
|
2803
|
+
type="button"
|
|
2804
|
+
className="squisq-mermaid-type-card"
|
|
2805
|
+
onClick={() => handleMermaidDiagramInsert(entry)}
|
|
2806
|
+
role="menuitem"
|
|
2807
|
+
aria-label={`Insert ${entry.label} Mermaid diagram`}
|
|
2808
|
+
>
|
|
2809
|
+
<span className="squisq-mermaid-type-thumbnail">
|
|
2810
|
+
<MermaidDiagramTypeThumbnail preview={entry.preview} />
|
|
2811
|
+
</span>
|
|
2812
|
+
<span className="squisq-mermaid-type-copy">
|
|
2813
|
+
<span className="squisq-mermaid-type-title">
|
|
2814
|
+
{entry.label}
|
|
2815
|
+
{entry.badge && (
|
|
2816
|
+
<span className="squisq-mermaid-type-badge">{entry.badge}</span>
|
|
2817
|
+
)}
|
|
2818
|
+
</span>
|
|
2819
|
+
<span className="squisq-mermaid-type-description">
|
|
2820
|
+
{entry.description}
|
|
2821
|
+
</span>
|
|
2822
|
+
</span>
|
|
2823
|
+
</button>
|
|
2824
|
+
),
|
|
2825
|
+
)}
|
|
2826
|
+
</div>
|
|
2827
|
+
</section>
|
|
2828
|
+
))}
|
|
2829
|
+
</div>,
|
|
2830
|
+
document.body,
|
|
2831
|
+
)}
|
|
2832
|
+
|
|
2833
|
+
{codeSnippetMenuAnchor &&
|
|
2834
|
+
createPortal(
|
|
2835
|
+
<div
|
|
2836
|
+
ref={codeSnippetMenuRef}
|
|
2837
|
+
className="squisq-insert-menu squisq-code-snippet-menu"
|
|
2838
|
+
data-theme={colorScheme}
|
|
2839
|
+
style={{
|
|
2840
|
+
position: 'fixed',
|
|
2841
|
+
top: codeSnippetMenuAnchor.top,
|
|
2842
|
+
left: codeSnippetMenuAnchor.left,
|
|
2843
|
+
}}
|
|
2844
|
+
role="menu"
|
|
2845
|
+
aria-label="Code snippet language"
|
|
2846
|
+
>
|
|
2847
|
+
<div className="squisq-insert-menu-header">Code Snippet</div>
|
|
2848
|
+
{CODE_SNIPPET_LANGUAGES.map((language) => (
|
|
2849
|
+
<button
|
|
2850
|
+
key={language.fenceLanguage}
|
|
2851
|
+
className="squisq-toolbar-overflow-item squisq-code-snippet-language"
|
|
2852
|
+
onClick={() => handleCodeSnippetInsert(language)}
|
|
2853
|
+
role="menuitem"
|
|
2854
|
+
aria-label={`Insert ${language.label} code snippet`}
|
|
2855
|
+
>
|
|
2856
|
+
<span>{language.label}</span>
|
|
2857
|
+
<code>{language.fenceLanguage}</code>
|
|
2858
|
+
</button>
|
|
2859
|
+
))}
|
|
2562
2860
|
</div>,
|
|
2563
2861
|
document.body,
|
|
2564
2862
|
)}
|
package/src/WysiwygEditor.tsx
CHANGED
|
@@ -32,6 +32,8 @@ import { AsciiDiagramExtension } from './asciiDiagram/AsciiDiagramExtension';
|
|
|
32
32
|
import { RepairableDiagramExtension } from './asciiDiagram/RepairableDiagramExtension';
|
|
33
33
|
import { applyRepairCommand } from './asciiDiagram/asciiDiagramCommands';
|
|
34
34
|
import { shouldPasteAsAsciiFence } from './asciiDiagram/asciiPaste';
|
|
35
|
+
import { MermaidDiagramExtension } from './mermaid/MermaidDiagramExtension';
|
|
36
|
+
import { CodeSnippetExtension } from './codeSnippet/CodeSnippetExtension';
|
|
35
37
|
import { TreeViewExtension } from './treeview/TreeViewExtension';
|
|
36
38
|
import { shouldPasteAsTreeFence } from './treeview/treePaste';
|
|
37
39
|
import { TimelineViewExtension } from './timeline/TimelineViewExtension';
|
|
@@ -62,6 +64,8 @@ import { looksLikeMarkdown } from './detectMarkdown';
|
|
|
62
64
|
import { SQUISQ_MEDIA_MIME, parseSquisqMediaPayload } from './mediaDragMime';
|
|
63
65
|
import { usePreviewSettingsOptional } from './PreviewControls';
|
|
64
66
|
import { uploadAndInsertImages } from './wysiwygImageUpload';
|
|
67
|
+
import { writeCanvasSettingsStyle, type WriteCanvasSettings } from './writeCanvasSettings';
|
|
68
|
+
import { FindHighlightExtension } from './find/FindHighlightExtension';
|
|
65
69
|
|
|
66
70
|
type ImageMutationView = Pick<ProseMirrorView, 'state' | 'dispatch'>;
|
|
67
71
|
|
|
@@ -71,16 +75,14 @@ type ImageMutationView = Pick<ProseMirrorView, 'state' | 'dispatch'>;
|
|
|
71
75
|
* `placeholder` prop with a fixed string.
|
|
72
76
|
*/
|
|
73
77
|
const EMPTY_PROMPTS = [
|
|
74
|
-
'Start typing your content, or drop images on top of me
|
|
75
|
-
'Write anything
|
|
76
|
-
'Type away. Markdown syntax works too
|
|
77
|
-
'Chapter 1 begins here
|
|
78
|
-
'Once upon a time
|
|
79
|
-
|
|
80
|
-
'The first word is always the hardest
|
|
81
|
-
'Plot twist: this is where it all starts
|
|
82
|
-
'Write something the future you will thank you for…',
|
|
83
|
-
'Begin at the beginning…',
|
|
78
|
+
'Start typing your content, or drop images on top of me...',
|
|
79
|
+
'Write anything -- paste markdown, drag in images, or just start typing...',
|
|
80
|
+
'Type away. Markdown syntax works too...',
|
|
81
|
+
'Chapter 1 begins here...',
|
|
82
|
+
'Once upon a time...',
|
|
83
|
+
"A blank page. Exciting, isn't it?",
|
|
84
|
+
'The first word is always the hardest...',
|
|
85
|
+
'Plot twist: this is where it all starts...',
|
|
84
86
|
];
|
|
85
87
|
|
|
86
88
|
const BLOCK_TAG_DATA_VALUES = {
|
|
@@ -109,6 +111,8 @@ export interface WysiwygEditorProps {
|
|
|
109
111
|
submitOnEnter?: () => void;
|
|
110
112
|
/** Disable Tiptap editing — renders content but blocks input. */
|
|
111
113
|
readOnly?: boolean;
|
|
114
|
+
/** Host-controlled base text size and line spacing for the Write canvas. */
|
|
115
|
+
writeCanvasSettings?: WriteCanvasSettings;
|
|
112
116
|
}
|
|
113
117
|
|
|
114
118
|
/**
|
|
@@ -120,6 +124,7 @@ export function WysiwygEditor({
|
|
|
120
124
|
className,
|
|
121
125
|
submitOnEnter,
|
|
122
126
|
readOnly = false,
|
|
127
|
+
writeCanvasSettings,
|
|
123
128
|
}: WysiwygEditorProps) {
|
|
124
129
|
const {
|
|
125
130
|
editorSource,
|
|
@@ -199,6 +204,8 @@ export function WysiwygEditor({
|
|
|
199
204
|
HeadingWithTemplate.configure({ levels: [1, 2, 3, 4, 5, 6] }),
|
|
200
205
|
BlockTagActivityExtension,
|
|
201
206
|
AsciiDiagramExtension.configure({ textChannel: sceneTextChannel }),
|
|
207
|
+
MermaidDiagramExtension,
|
|
208
|
+
CodeSnippetExtension,
|
|
202
209
|
RepairableDiagramExtension.configure({ onRepair: applyRepairCommand }),
|
|
203
210
|
TimelineViewExtension,
|
|
204
211
|
TreeViewExtension,
|
|
@@ -220,6 +227,7 @@ export function WysiwygEditor({
|
|
|
220
227
|
Placeholder.configure({ placeholder: resolvedPlaceholder }),
|
|
221
228
|
buildMentionExtension(() => mentionProviderRef.current),
|
|
222
229
|
InlineIcon,
|
|
230
|
+
FindHighlightExtension,
|
|
223
231
|
],
|
|
224
232
|
content: markdownToTiptap(stripFrontmatter(editorSource).body),
|
|
225
233
|
onUpdate: ({ editor: ed }) => {
|
|
@@ -625,7 +633,13 @@ export function WysiwygEditor({
|
|
|
625
633
|
<CustomTemplateProvider docTemplates={docTemplates} onDocTemplatesChange={onDocTemplatesChange}>
|
|
626
634
|
<div
|
|
627
635
|
className={`squisq-wysiwyg-container${className ? ` ${className}` : ''}`}
|
|
628
|
-
style={{
|
|
636
|
+
style={{
|
|
637
|
+
width: '100%',
|
|
638
|
+
height: '100%',
|
|
639
|
+
overflow: 'auto',
|
|
640
|
+
...themeStyle,
|
|
641
|
+
...writeCanvasSettingsStyle(writeCanvasSettings),
|
|
642
|
+
}}
|
|
629
643
|
data-testid="wysiwyg-container"
|
|
630
644
|
data-block-tags={BLOCK_TAG_DATA_VALUES[blockTagVisibility]}
|
|
631
645
|
data-theme-inheritance={themeInheritance}
|
|
@@ -45,4 +45,21 @@ describe('buildPreviewDoc content mapping', () => {
|
|
|
45
45
|
description: 'Pinned description',
|
|
46
46
|
});
|
|
47
47
|
});
|
|
48
|
+
|
|
49
|
+
it('keeps Mermaid source available to the slide materializer for every template', () => {
|
|
50
|
+
const slide = firstPreviewSlide(
|
|
51
|
+
'# Architecture\n\n```mermaid\nflowchart LR\n client --> server\n```',
|
|
52
|
+
);
|
|
53
|
+
expect(slide.contents).toEqual([expect.objectContaining({ type: 'code', lang: 'mermaid' })]);
|
|
54
|
+
|
|
55
|
+
const { layers } = materializeBlockLayers(slide as unknown as DocBlock, {
|
|
56
|
+
persistentLayers: false,
|
|
57
|
+
});
|
|
58
|
+
expect(layers.some((layer) => layer.type === 'mermaid')).toBe(true);
|
|
59
|
+
const narrativeText = layers
|
|
60
|
+
.filter((layer): layer is TextLayer => layer.type === 'text')
|
|
61
|
+
.map((layer) => layer.content.text)
|
|
62
|
+
.join('\n');
|
|
63
|
+
expect(narrativeText).not.toContain('flowchart LR');
|
|
64
|
+
});
|
|
48
65
|
});
|