@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.
Files changed (70) hide show
  1. package/README.md +10 -2
  2. package/dist/index.d.ts +505 -15
  3. package/dist/index.js +6035 -2283
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/index.css +870 -40
  6. package/package.json +6 -5
  7. package/src/EditorContext.tsx +27 -0
  8. package/src/EditorShell.tsx +24 -0
  9. package/src/PreviewControls.tsx +20 -5
  10. package/src/PreviewPanel.tsx +5 -1
  11. package/src/RawEditor.tsx +12 -0
  12. package/src/RecorderEntry.tsx +3 -0
  13. package/src/Toolbar.tsx +315 -17
  14. package/src/WysiwygEditor.tsx +25 -11
  15. package/src/__tests__/buildPreviewDocContent.test.ts +17 -0
  16. package/src/__tests__/editorShellProps.test.tsx +65 -0
  17. package/src/__tests__/findMode.test.tsx +104 -0
  18. package/src/__tests__/findModel.test.ts +55 -0
  19. package/src/__tests__/markdownCodeFence.test.ts +45 -0
  20. package/src/__tests__/mediaReferences.test.ts +15 -0
  21. package/src/__tests__/previewControls.test.tsx +31 -0
  22. package/src/__tests__/tiptapBridge.test.ts +9 -0
  23. package/src/__tests__/toolbarSelectionConversion.test.tsx +26 -0
  24. package/src/__tests__/writeCanvasSettings.test.ts +15 -0
  25. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +20 -1
  26. package/src/buildPreviewDoc.ts +9 -7
  27. package/src/codeSnippet/CodeSnippetExtension.ts +205 -0
  28. package/src/codeSnippet/CodeSnippetWidget.tsx +109 -0
  29. package/src/codeSnippet/__tests__/CodeSnippetExtension.test.ts +95 -0
  30. package/src/codeSnippet/__tests__/codeSnippetLanguages.test.ts +50 -0
  31. package/src/codeSnippet/codeSnippetCommands.ts +21 -0
  32. package/src/codeSnippet/codeSnippetData.ts +43 -0
  33. package/src/codeSnippet/codeSnippetLanguages.ts +216 -0
  34. package/src/diagram/DiagramCanvas.tsx +1 -0
  35. package/src/find/FindHighlightExtension.ts +81 -0
  36. package/src/find/FindToolbar.tsx +314 -0
  37. package/src/find/findModel.ts +77 -0
  38. package/src/index.ts +89 -0
  39. package/src/markdownCodeFence.ts +72 -0
  40. package/src/mediaReferences.ts +5 -3
  41. package/src/mermaid/MermaidDiagramCanvas.tsx +693 -0
  42. package/src/mermaid/MermaidDiagramExtension.ts +243 -0
  43. package/src/mermaid/MermaidDiagramTypeThumbnail.tsx +184 -0
  44. package/src/mermaid/MermaidDiagramWidget.tsx +653 -0
  45. package/src/mermaid/MermaidShapePalette.tsx +245 -0
  46. package/src/mermaid/__tests__/MermaidDiagramExtension.test.ts +361 -0
  47. package/src/mermaid/__tests__/mermaidDiagramTypes.test.ts +35 -0
  48. package/src/mermaid/__tests__/mermaidRenderer.test.ts +49 -0
  49. package/src/mermaid/__tests__/mermaidSourceOps.test.ts +213 -0
  50. package/src/mermaid/__tests__/mermaidSyntax.test.ts +71 -0
  51. package/src/mermaid/mermaidCommands.ts +34 -0
  52. package/src/mermaid/mermaidData.ts +31 -0
  53. package/src/mermaid/mermaidDiagramTypes.ts +325 -0
  54. package/src/mermaid/mermaidModel.ts +31 -0
  55. package/src/mermaid/mermaidRenderer.ts +181 -0
  56. package/src/mermaid/mermaidShapes.ts +113 -0
  57. package/src/mermaid/mermaidSourceOps.ts +454 -0
  58. package/src/scene/Scene.tsx +46 -15
  59. package/src/scene/SceneBlockToolbar.tsx +29 -14
  60. package/src/scene/SceneViewControls.tsx +43 -0
  61. package/src/scene/__tests__/fitScale.test.ts +19 -0
  62. package/src/scene/__tests__/useScenePanZoom.test.ts +28 -1
  63. package/src/scene/fitScale.ts +17 -0
  64. package/src/scene/hooks/useScenePanZoom.ts +11 -4
  65. package/src/scene/scene.css +85 -3
  66. package/src/styles/code-snippet.css +76 -0
  67. package/src/styles/editor.css +322 -2
  68. package/src/styles/index.css +2 -0
  69. package/src/styles/mermaid-diagram.css +487 -0
  70. 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 closeInsertMenu = useCallback(() => setInsertMenuAnchor(null), []);
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
- let inFence = false;
1601
- for (const rawLine of markdownSource.split('\n')) {
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="Formatting toolbar"
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
- {!isPreview && !isCodeMode && (overflowIndex !== null || clippedContextual.size > 0) && (
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
  )}
@@ -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 paste markdown, drag in images, or just start typing',
76
- 'Type away. Markdown syntax works too',
77
- 'Chapter 1 begins here',
78
- 'Once upon a time',
79
- 'A blank page. Exciting, isn\u2019t it?',
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={{ width: '100%', height: '100%', overflow: 'auto', ...themeStyle }}
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
  });