@bendyline/squisq-editor-react 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +10 -2
  2. package/dist/index.d.ts +579 -47
  3. package/dist/index.js +7477 -2943
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/index.css +1008 -38
  6. package/package.json +6 -5
  7. package/src/DocumentSettingsDialog.tsx +32 -18
  8. package/src/EditorContext.tsx +27 -0
  9. package/src/EditorShell.tsx +25 -1
  10. package/src/PreviewControls.tsx +77 -29
  11. package/src/PreviewPanel.tsx +5 -1
  12. package/src/RawEditor.tsx +12 -0
  13. package/src/RecorderEntry.tsx +5 -0
  14. package/src/Toolbar.tsx +479 -36
  15. package/src/WysiwygEditor.tsx +25 -11
  16. package/src/__tests__/buildPreviewDocContent.test.ts +17 -0
  17. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  18. package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
  19. package/src/__tests__/editorShellProps.test.tsx +65 -0
  20. package/src/__tests__/findMode.test.tsx +104 -0
  21. package/src/__tests__/findModel.test.ts +55 -0
  22. package/src/__tests__/markdownCodeFence.test.ts +45 -0
  23. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  24. package/src/__tests__/mediaReferences.test.ts +15 -0
  25. package/src/__tests__/previewControls.test.tsx +195 -0
  26. package/src/__tests__/recorderTheme.test.tsx +42 -0
  27. package/src/__tests__/selectionConversions.test.ts +80 -0
  28. package/src/__tests__/tiptapBridge.test.ts +57 -7
  29. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  30. package/src/__tests__/toolbarSelectionConversion.test.tsx +190 -0
  31. package/src/__tests__/writeCanvasSettings.test.ts +15 -0
  32. package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
  33. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +20 -1
  34. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +58 -1
  35. package/src/asciiDiagram/asciiDiagramCommands.ts +36 -10
  36. package/src/asciiDiagram/asciiDiagramData.ts +33 -0
  37. package/src/asciiDiagram/asciiDiagramOps.ts +19 -0
  38. package/src/buildPreviewDoc.ts +9 -7
  39. package/src/codeContext/types.ts +1 -1
  40. package/src/codeSnippet/CodeSnippetExtension.ts +205 -0
  41. package/src/codeSnippet/CodeSnippetWidget.tsx +109 -0
  42. package/src/codeSnippet/__tests__/CodeSnippetExtension.test.ts +95 -0
  43. package/src/codeSnippet/__tests__/codeSnippetLanguages.test.ts +50 -0
  44. package/src/codeSnippet/codeSnippetCommands.ts +21 -0
  45. package/src/codeSnippet/codeSnippetData.ts +43 -0
  46. package/src/codeSnippet/codeSnippetLanguages.ts +216 -0
  47. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  48. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  49. package/src/diagram/DiagramCanvas.tsx +17 -2
  50. package/src/find/FindHighlightExtension.ts +81 -0
  51. package/src/find/FindToolbar.tsx +314 -0
  52. package/src/find/findModel.ts +77 -0
  53. package/src/frontmatterSettings.ts +23 -0
  54. package/src/index.ts +96 -1
  55. package/src/markdownCodeFence.ts +72 -0
  56. package/src/mediaReferences.ts +5 -3
  57. package/src/mermaid/MermaidDiagramCanvas.tsx +693 -0
  58. package/src/mermaid/MermaidDiagramExtension.ts +243 -0
  59. package/src/mermaid/MermaidDiagramTypeThumbnail.tsx +184 -0
  60. package/src/mermaid/MermaidDiagramWidget.tsx +653 -0
  61. package/src/mermaid/MermaidShapePalette.tsx +245 -0
  62. package/src/mermaid/__tests__/MermaidDiagramExtension.test.ts +361 -0
  63. package/src/mermaid/__tests__/mermaidDiagramTypes.test.ts +35 -0
  64. package/src/mermaid/__tests__/mermaidRenderer.test.ts +49 -0
  65. package/src/mermaid/__tests__/mermaidSourceOps.test.ts +213 -0
  66. package/src/mermaid/__tests__/mermaidSyntax.test.ts +71 -0
  67. package/src/mermaid/mermaidCommands.ts +34 -0
  68. package/src/mermaid/mermaidData.ts +31 -0
  69. package/src/mermaid/mermaidDiagramTypes.ts +325 -0
  70. package/src/mermaid/mermaidModel.ts +31 -0
  71. package/src/mermaid/mermaidRenderer.ts +181 -0
  72. package/src/mermaid/mermaidShapes.ts +113 -0
  73. package/src/mermaid/mermaidSourceOps.ts +454 -0
  74. package/src/recorder/RecorderButton.tsx +9 -1
  75. package/src/recorder/RecorderModal.tsx +84 -41
  76. package/src/recorder/RecorderPanel.tsx +9 -1
  77. package/src/scene/Scene.tsx +46 -15
  78. package/src/scene/SceneBlockToolbar.tsx +29 -14
  79. package/src/scene/SceneViewControls.tsx +43 -0
  80. package/src/scene/__tests__/fitScale.test.ts +19 -0
  81. package/src/scene/__tests__/sceneIsolation.test.tsx +27 -1
  82. package/src/scene/__tests__/useScenePanZoom.test.ts +28 -1
  83. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  84. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  85. package/src/scene/commands/SceneCommand.ts +13 -2
  86. package/src/scene/fitScale.ts +17 -0
  87. package/src/scene/hooks/useScenePanZoom.ts +11 -4
  88. package/src/scene/scene.css +85 -3
  89. package/src/scene/tools/SelectTool.ts +5 -5
  90. package/src/selectionConversions.ts +155 -0
  91. package/src/styles/ascii-timeline.css +101 -4
  92. package/src/styles/code-snippet.css +76 -0
  93. package/src/styles/editor.css +352 -2
  94. package/src/styles/index.css +2 -0
  95. package/src/styles/mermaid-diagram.css +487 -0
  96. package/src/styles/tree-view.css +51 -1
  97. package/src/timeline/TimelineEditorWidget.tsx +200 -41
  98. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +61 -2
  99. package/src/timeline/__tests__/timelineCommands.test.ts +32 -0
  100. package/src/timeline/__tests__/timelineOps.test.ts +55 -0
  101. package/src/timeline/timelineCommands.ts +54 -0
  102. package/src/timeline/timelineOps.ts +107 -3
  103. package/src/tiptapBridge.ts +23 -5
  104. package/src/treeview/TreeOutlineWidget.tsx +153 -3
  105. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  106. package/src/treeview/__tests__/treeOps.test.ts +52 -0
  107. package/src/treeview/__tests__/treeViewCommands.test.ts +16 -0
  108. package/src/treeview/treeOps.ts +59 -0
  109. package/src/treeview/treeViewCommands.ts +5 -0
  110. package/src/writeCanvasSettings.ts +30 -0
package/src/Toolbar.tsx CHANGED
@@ -52,6 +52,27 @@ import type { PickerEntry } from './emojiData';
52
52
  import { createPortal } from 'react-dom';
53
53
  import { PreviewModeMenu, displayModeLabel, usePreviewSettingsOptional } from './PreviewControls';
54
54
  import { filterVisibleMediaEntries } from './mediaEntries';
55
+ import {
56
+ selectionToTable,
57
+ selectionToTableMarkdown,
58
+ selectionToTaskItems,
59
+ selectionToTaskListMarkdown,
60
+ type SelectionTaskItem,
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';
55
76
 
56
77
  const VIEWS: { id: EditorView; label: string; shortLabel?: string; shortcut: string }[] = [
57
78
  { id: 'wysiwyg', label: 'Write', shortcut: '⌘1' },
@@ -221,6 +242,14 @@ const BUTTONS: ToolbarButton[] = [
221
242
  group: 'media',
222
243
  faIcon: 'fa-solid fa-diagram-project',
223
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
+ },
224
253
  {
225
254
  id: 'tree',
226
255
  label: 'tree',
@@ -273,7 +302,10 @@ const BUTTONS: ToolbarButton[] = [
273
302
 
274
303
  const FIRST_MEDIA_INDEX = BUTTONS.findIndex((b) => b.group === 'media');
275
304
  const MEDIA_BUTTONS = BUTTONS.filter((b) => b.group === 'media');
305
+ const CONVERT_BUTTONS = MEDIA_BUTTONS.filter((b) => b.id === 'table' || b.id === 'tasklist');
276
306
  const INSERT_MENU_WIDTH = 200;
307
+ const CODE_SNIPPET_MENU_WIDTH = 220;
308
+ const MERMAID_TYPE_MENU_WIDTH = 520;
277
309
  const TASK_LIST_ITEMS = ['Task 1', 'Task 2', 'Task 3'] as const;
278
310
  const TASK_LIST_MARKDOWN = TASK_LIST_ITEMS.map((item) => `- [ ] ${item}`).join('\n');
279
311
 
@@ -297,18 +329,35 @@ function fileCountBadge(count: number): string {
297
329
  return count > 99 ? '99+' : String(count);
298
330
  }
299
331
 
300
- function taskListContent(): JSONContent {
332
+ function paragraphContent(text: string): JSONContent {
333
+ return text ? { type: 'paragraph', content: [{ type: 'text', text }] } : { type: 'paragraph' };
334
+ }
335
+
336
+ function tableContent(rows: string[][]): JSONContent {
337
+ return {
338
+ type: 'table',
339
+ content: rows.map((row, rowIndex) => ({
340
+ type: 'tableRow',
341
+ content: row.map((cell) => ({
342
+ type: rowIndex === 0 ? 'tableHeader' : 'tableCell',
343
+ content: [paragraphContent(cell)],
344
+ })),
345
+ })),
346
+ };
347
+ }
348
+
349
+ function taskListContent(
350
+ items: readonly SelectionTaskItem[] = TASK_LIST_ITEMS.map((text) => ({
351
+ checked: false,
352
+ text,
353
+ })),
354
+ ): JSONContent {
301
355
  return {
302
356
  type: 'taskList',
303
- content: TASK_LIST_ITEMS.map((item) => ({
357
+ content: items.map((item) => ({
304
358
  type: 'taskItem',
305
- attrs: { checked: false },
306
- content: [
307
- {
308
- type: 'paragraph',
309
- content: [{ type: 'text', text: item }],
310
- },
311
- ],
359
+ attrs: { checked: item.checked },
360
+ content: [paragraphContent(item.text)],
312
361
  })),
313
362
  };
314
363
  }
@@ -319,6 +368,26 @@ function insertTaskList(editor: TiptapEditor): void {
319
368
  editor.chain().focus().insertContent(content).run();
320
369
  }
321
370
 
371
+ /**
372
+ * Expand a full-line Tiptap text selection to the surrounding top-level node
373
+ * boundaries. Inserting a list/table at an in-paragraph range makes
374
+ * ProseMirror preserve the emptied paragraph before or after the new block.
375
+ * Partial-line selections deliberately retain their exact range.
376
+ */
377
+ function blockConversionRange(editor: TiptapEditor): { from: number; to: number } {
378
+ const { from, to, $from, $to } = editor.state.selection;
379
+ return {
380
+ from:
381
+ $from.depth === 1 && $from.parent.isTextblock && $from.parentOffset === 0
382
+ ? $from.before(1)
383
+ : from,
384
+ to:
385
+ $to.depth === 1 && $to.parent.isTextblock && $to.parentOffset === $to.parent.content.size
386
+ ? $to.after(1)
387
+ : to,
388
+ };
389
+ }
390
+
322
391
  // ─── Tiptap active-state map ────────────────────────────
323
392
 
324
393
  /** Returns true if the given button id is currently active in Tiptap */
@@ -377,17 +446,23 @@ const LAYOUT_STARTER_MARKDOWN = `\n## Layout {[layout]}\n\n### {#text-1} {[text
377
446
  * fences (the fence is the source of truth; `AsciiDiagramExtension` mounts
378
447
  * the interactive canvas over it). This exact art is the codec's own
379
448
  * canonical rendering of a two-node flow, so it re-parses and re-renders
380
- * byte-stably.
449
+ * byte-stably. The leading rows/columns are deliberate: ASCII has no
450
+ * separate canvas-origin metadata, so this persisted gutter gives the first
451
+ * node real grid space to move north/west. Without it, content-fit makes the
452
+ * node look centered while its authored coordinate is still (0, 0), and a
453
+ * drag into the apparent margin clamps straight back to that corner.
381
454
  */
382
455
  const DIAGRAM_STARTER_ART = [
383
- '┌─────────┐',
384
- '│ Start │',
385
- '└────┬────┘',
386
- ' │',
387
- '',
388
- '┌────┴────┐',
389
- '│ Next │',
390
- '└─────────┘',
456
+ '',
457
+ '',
458
+ ' ┌─────────┐',
459
+ ' Start │',
460
+ ' └────┬────┘',
461
+ '',
462
+ '',
463
+ ' ┌────┴────┐',
464
+ ' │ Next │',
465
+ ' └─────────┘',
391
466
  ].join('\n');
392
467
  /**
393
468
  * Fenced form for raw / code views — tagged with the explicit `diagram`
@@ -401,6 +476,15 @@ function insertAsciiDiagramBlock(editor: TiptapEditor): void {
401
476
  insertFenceBlock(editor, DIAGRAM_STARTER_ART, 'diagram');
402
477
  }
403
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
+
404
488
  /**
405
489
  * Starter art for a new file tree — an ASCII tree fence (the fence is the
406
490
  * source of truth; `TreeViewExtension` mounts the interactive outline).
@@ -586,6 +670,8 @@ export function Toolbar({
586
670
  activeSceneText,
587
671
  mediaProvider,
588
672
  editorMode,
673
+ findMode,
674
+ setFindMode,
589
675
  layoutMode,
590
676
  activeBlockStartLine,
591
677
  versioning,
@@ -693,9 +779,19 @@ export function Toolbar({
693
779
  // buttons with a single "+" button. Portaled out to avoid overflow:hidden clipping.
694
780
  const insertMenuButtonRef = useRef<HTMLButtonElement | null>(null);
695
781
  const insertMenuRef = useRef<HTMLDivElement | null>(null);
782
+ const codeSnippetMenuRef = useRef<HTMLDivElement | null>(null);
783
+ const mermaidTypeMenuRef = useRef<HTMLDivElement | null>(null);
696
784
  const [insertMenuAnchor, setInsertMenuAnchor] = useState<{ top: number; left: number } | null>(
697
785
  null,
698
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);
699
795
 
700
796
  const openInsertMenu = useCallback((trigger?: HTMLElement | null) => {
701
797
  const btn = trigger ?? insertMenuButtonRef.current;
@@ -709,9 +805,47 @@ export function Toolbar({
709
805
  left = Math.max(margin, rect.right - INSERT_MENU_WIDTH);
710
806
  }
711
807
  setInsertMenuAnchor({ top: rect.bottom + gap, left });
808
+ setCodeSnippetMenuAnchor(null);
809
+ setMermaidTypeMenuAnchor(null);
810
+ }, []);
811
+
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);
712
842
  }, []);
713
843
 
714
- const closeInsertMenu = useCallback(() => setInsertMenuAnchor(null), []);
844
+ const closeInsertMenu = useCallback(() => {
845
+ setInsertMenuAnchor(null);
846
+ setCodeSnippetMenuAnchor(null);
847
+ setMermaidTypeMenuAnchor(null);
848
+ }, []);
715
849
 
716
850
  // ── Overflow detection ────────────────────────────────
717
851
  const actionsRef = useRef<HTMLDivElement>(null);
@@ -730,6 +864,13 @@ export function Toolbar({
730
864
  // Custom layout manager dialog (list of doc/library layouts + designer)
731
865
  const [showLayoutManager, setShowLayoutManager] = useState(false);
732
866
 
867
+ useEffect(() => {
868
+ if (!findMode) return;
869
+ setShowOverflow(false);
870
+ closeInsertMenu();
871
+ closeEmojiPicker();
872
+ }, [closeEmojiPicker, closeInsertMenu, findMode]);
873
+
733
874
  const overflowIndex = measuredOverflowIndex;
734
875
  const clippedContextual = new Set(clippedContextualKey ? clippedContextualKey.split('|') : []);
735
876
 
@@ -823,6 +964,8 @@ export function Toolbar({
823
964
  const handleClick = (e: MouseEvent) => {
824
965
  if (insertMenuButtonRef.current?.contains(e.target as Node)) return;
825
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;
826
969
  closeInsertMenu();
827
970
  };
828
971
  document.addEventListener('mousedown', handleClick);
@@ -953,6 +1096,11 @@ export function Toolbar({
953
1096
  // mounts the interactive canvas over the inserted starter.
954
1097
  insertAsciiDiagramBlock(tiptapEditor);
955
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;
956
1104
  case 'tree':
957
1105
  // File trees are ASCII tree fences; TreeViewExtension mounts the
958
1106
  // interactive outline over the inserted starter.
@@ -1124,6 +1272,11 @@ export function Toolbar({
1124
1272
  newCursorOffset = replacement.length;
1125
1273
  break;
1126
1274
  }
1275
+ case 'complexdiagram': {
1276
+ replacement = MERMAID_STARTER_MARKDOWN;
1277
+ newCursorOffset = replacement.length;
1278
+ break;
1279
+ }
1127
1280
  case 'tree': {
1128
1281
  replacement = TREE_STARTER_MARKDOWN;
1129
1282
  newCursorOffset = replacement.length;
@@ -1214,6 +1367,9 @@ export function Toolbar({
1214
1367
  case 'diagram':
1215
1368
  insertion = DIAGRAM_STARTER_MARKDOWN;
1216
1369
  break;
1370
+ case 'complexdiagram':
1371
+ insertion = MERMAID_STARTER_MARKDOWN;
1372
+ break;
1217
1373
  case 'tree':
1218
1374
  insertion = TREE_STARTER_MARKDOWN;
1219
1375
  break;
@@ -1235,6 +1391,137 @@ export function Toolbar({
1235
1391
  [monacoEditor, markdownSource, setMarkdownSource],
1236
1392
  );
1237
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
+
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
+
1464
+ const readSelectedText = useCallback((): string => {
1465
+ // Canvas text overlays intentionally have a smaller schema without tables
1466
+ // or task lists. Ignore their selection instead of surfacing actions that
1467
+ // cannot be represented by those layers.
1468
+ if (activeSceneText) return '';
1469
+
1470
+ if (activeView === 'wysiwyg' && tiptapEditor) {
1471
+ const { from, to, empty } = tiptapEditor.state.selection;
1472
+ return empty ? '' : tiptapEditor.state.doc.textBetween(from, to, '\n');
1473
+ }
1474
+
1475
+ if (activeView === 'raw' && monacoEditor) {
1476
+ const selection = monacoEditor.getSelection();
1477
+ const model = monacoEditor.getModel();
1478
+ return selection && model ? model.getValueInRange(selection) : '';
1479
+ }
1480
+
1481
+ return '';
1482
+ }, [activeSceneText, activeView, monacoEditor, tiptapEditor]);
1483
+
1484
+ const handleConvertSelection = useCallback(
1485
+ (target: 'table' | 'tasklist') => {
1486
+ const selectedText = readSelectedText();
1487
+ if (!selectedText.trim()) return;
1488
+
1489
+ if (activeView === 'wysiwyg' && tiptapEditor) {
1490
+ const content =
1491
+ target === 'table'
1492
+ ? tableContent(selectionToTable(selectedText).rows)
1493
+ : taskListContent(selectionToTaskItems(selectedText));
1494
+ tiptapEditor
1495
+ .chain()
1496
+ .focus()
1497
+ .insertContentAt(blockConversionRange(tiptapEditor), content)
1498
+ .run();
1499
+ return;
1500
+ }
1501
+
1502
+ if (activeView === 'raw' && monacoEditor) {
1503
+ const selection = monacoEditor.getSelection();
1504
+ if (!selection) return;
1505
+ const converted =
1506
+ target === 'table'
1507
+ ? selectionToTableMarkdown(selectedText)
1508
+ : selectionToTaskListMarkdown(selectedText);
1509
+ if (!converted) return;
1510
+
1511
+ // Monaco line selections commonly include the newline after the last
1512
+ // selected line. Retain selected edge newlines so the replacement
1513
+ // block never runs into the surrounding Markdown.
1514
+ const leadingNewline = /^(?:\r?\n)/.test(selectedText) ? '\n' : '';
1515
+ const trailingNewline = /(?:\r?\n)$/.test(selectedText) ? '\n' : '';
1516
+ monacoEditor.executeEdits('toolbar-convert-selection', [
1517
+ { range: selection, text: leadingNewline + converted + trailingNewline },
1518
+ ]);
1519
+ monacoEditor.focus();
1520
+ }
1521
+ },
1522
+ [activeView, monacoEditor, readSelectedText, tiptapEditor],
1523
+ );
1524
+
1238
1525
  // ── Image upload handler ───────────────────────────────
1239
1526
  const handleImageFile = useCallback(
1240
1527
  async (file: File) => {
@@ -1471,6 +1758,10 @@ export function Toolbar({
1471
1758
  if (sceneTextLevel === 'block') return BLOCK_SCENE_BUTTONS.has(id);
1472
1759
  return INLINE_SCENE_BUTTONS.has(id);
1473
1760
  };
1761
+ const showConvertActions =
1762
+ insertMenuAnchor !== null &&
1763
+ readSelectedText().trim().length > 0 &&
1764
+ CONVERT_BUTTONS.some((button) => buttonAllowed(button.id));
1474
1765
 
1475
1766
  // ── Progressive heading disclosure ───────────────────
1476
1767
  // H1\u2013H3 are always visible. H4 appears once the document already
@@ -1480,14 +1771,12 @@ export function Toolbar({
1480
1771
  const maxHeadingLevelInDoc = useMemo(() => {
1481
1772
  if (!markdownSource) return 0;
1482
1773
  let max = 0;
1483
- let inFence = false;
1484
- 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];
1485
1779
  const line = rawLine.trimEnd();
1486
- if (/^\s*```/.test(line)) {
1487
- inFence = !inFence;
1488
- continue;
1489
- }
1490
- if (inFence) continue;
1491
1780
  const m = /^(#{1,6})\s+\S/.exec(line);
1492
1781
  if (m && m[1].length > max) max = m[1].length;
1493
1782
  }
@@ -1538,6 +1827,12 @@ export function Toolbar({
1538
1827
  setRawTransition(EMPTY_TRANSITION);
1539
1828
  return;
1540
1829
  }
1830
+ if (isMarkdownFencedCodeLine(model.getValue(), pos.lineNumber)) {
1831
+ setRawTemplate(null);
1832
+ setRawHeadingLine(null);
1833
+ setRawTransition(EMPTY_TRANSITION);
1834
+ return;
1835
+ }
1541
1836
  const line = model.getLineContent(pos.lineNumber);
1542
1837
  const headingMatch = line.match(/^#{1,6}\s+(.+)$/);
1543
1838
  if (!headingMatch) {
@@ -1677,6 +1972,7 @@ export function Toolbar({
1677
1972
  const model = monacoEditor.getModel();
1678
1973
  const pos = monacoEditor.getPosition();
1679
1974
  if (!model || !pos) return;
1975
+ if (isMarkdownFencedCodeLine(model.getValue(), pos.lineNumber)) return;
1680
1976
  const lineNumber = pos.lineNumber;
1681
1977
  const lineText = model.getLineContent(lineNumber);
1682
1978
  const headingMatch = lineText.match(/^(#{1,6}\s+)(.+)$/);
@@ -1741,6 +2037,7 @@ export function Toolbar({
1741
2037
  const model = monacoEditor.getModel();
1742
2038
  const pos = monacoEditor.getPosition();
1743
2039
  if (!model || !pos) return;
2040
+ if (isMarkdownFencedCodeLine(model.getValue(), pos.lineNumber)) return;
1744
2041
  const lineNumber = pos.lineNumber;
1745
2042
  const lineText = model.getLineContent(lineNumber);
1746
2043
  const newLine = setHeadingLineTransition(lineText, next);
@@ -1788,12 +2085,17 @@ export function Toolbar({
1788
2085
  currentTransition !== null && clippedContextual.has('transition');
1789
2086
  const showBlockSectionInOverflow = showTemplateInOverflow || showTransitionInOverflow;
1790
2087
  const overflowBlockLabel = currentTemplate ? templateLabel(currentTemplate) : 'Heading';
2088
+ const showToolbarOverflow =
2089
+ !findMode &&
2090
+ !isPreview &&
2091
+ !isCodeMode &&
2092
+ (overflowIndex !== null || clippedContextual.size > 0);
1791
2093
 
1792
2094
  return (
1793
2095
  <div
1794
- className={`squisq-toolbar ${className || ''}`}
2096
+ className={`squisq-toolbar${findMode ? ' squisq-toolbar--find' : ''} ${className || ''}`}
1795
2097
  role="toolbar"
1796
- aria-label="Formatting toolbar"
2098
+ aria-label={findMode ? 'Find toolbar' : 'Formatting toolbar'}
1797
2099
  >
1798
2100
  {/* Hidden file input for image picker */}
1799
2101
  <input
@@ -1809,7 +2111,7 @@ export function Toolbar({
1809
2111
  }}
1810
2112
  />
1811
2113
  {/* Left slot — before view tabs */}
1812
- {slotLeft}
2114
+ {!findMode && slotLeft}
1813
2115
  {/* View tabs — hidden when only one view is available (e.g. code mode). */}
1814
2116
  {showViewTabs && (
1815
2117
  <div className="squisq-toolbar-view-tabs" role="tablist" aria-label="Editor view">
@@ -1872,9 +2174,9 @@ export function Toolbar({
1872
2174
  </div>
1873
2175
  )}
1874
2176
  {/* After-tabs slot — left side, before formatting or preview controls. */}
1875
- {slotAfterTabs}
2177
+ {findMode ? <FindToolbar onClose={() => setFindMode(false)} /> : slotAfterTabs}
1876
2178
  {/* Formatting buttons — hidden in preview mode and code mode */}
1877
- {!isPreview && !isCodeMode && (
2179
+ {!findMode && !isPreview && !isCodeMode && (
1878
2180
  <div className="squisq-toolbar-actions" ref={actionsRef}>
1879
2181
  {groups.map((group, gi) => (
1880
2182
  <div key={group} className="squisq-toolbar-group">
@@ -2125,7 +2427,7 @@ export function Toolbar({
2125
2427
  {/* Overflow menu — outside the overflow:hidden actions container.
2126
2428
  Also appears when a contextual group (template/transition picker,
2127
2429
  table controls) is clipped, even if every plain button fits. */}
2128
- {!isPreview && !isCodeMode && (overflowIndex !== null || clippedContextual.size > 0) && (
2430
+ {showToolbarOverflow && (
2129
2431
  <div className="squisq-toolbar-overflow" ref={overflowRef}>
2130
2432
  <button
2131
2433
  className={`squisq-toolbar-button squisq-toolbar-overflow-trigger${showOverflow ? ' squisq-toolbar-button--active' : ''}`}
@@ -2295,13 +2597,13 @@ export function Toolbar({
2295
2597
  )}
2296
2598
 
2297
2599
  {/* After-actions slot — after formatting controls */}
2298
- {slotAfterActions}
2600
+ {!findMode && slotAfterActions}
2299
2601
  {/* Spacer — pushes right-side items to the end when the flex:1 actions
2300
2602
  container isn't rendered. In preview mode PreviewToolbarControls
2301
2603
  supplies its own flex:1 filler (and measures that leftover width to
2302
2604
  decide whether to collapse), so a second spacer here would split the
2303
2605
  slack and make the controls collapse too early. */}
2304
- {isCodeMode && <div style={{ flex: 1 }} />}
2606
+ {isCodeMode && !findMode && <div style={{ flex: 1 }} />}
2305
2607
  {/* Version history — renders only when the host enabled versioning
2306
2608
  and a container is wired up. The component owns its own button
2307
2609
  and popover; we just give it a slot in the toolbar. */}
@@ -2392,28 +2694,169 @@ export function Toolbar({
2392
2694
  style={{ position: 'fixed', top: insertMenuAnchor.top, left: insertMenuAnchor.left }}
2393
2695
  role="menu"
2394
2696
  >
2395
- <div className="squisq-insert-menu-header">Insert</div>
2697
+ {showConvertActions && (
2698
+ <>
2699
+ <div className="squisq-insert-menu-header">Convert</div>
2700
+ {CONVERT_BUTTONS.map((btn) => {
2701
+ const label = btn.id === 'table' ? 'Table' : 'Task List';
2702
+ return (
2703
+ <button
2704
+ key={`convert-${btn.id}`}
2705
+ className="squisq-toolbar-overflow-item"
2706
+ disabled={!buttonAllowed(btn.id)}
2707
+ onClick={() => {
2708
+ handleConvertSelection(btn.id as 'table' | 'tasklist');
2709
+ closeInsertMenu();
2710
+ }}
2711
+ role="menuitem"
2712
+ aria-label={`Convert selection to ${label}`}
2713
+ >
2714
+ <span className="squisq-toolbar-overflow-icon">{buttonIcon(btn)}</span>
2715
+ <span>{label}</span>
2716
+ </button>
2717
+ );
2718
+ })}
2719
+ </>
2720
+ )}
2721
+ <div
2722
+ className={`squisq-insert-menu-header${showConvertActions ? ' squisq-insert-menu-header--separated' : ''}`}
2723
+ >
2724
+ Insert
2725
+ </div>
2396
2726
  {MEDIA_BUTTONS.filter((b) => isButtonVisible(b.id)).map((btn) => {
2397
2727
  const disabled = (btn.id === 'image' && !mediaProvider) || !buttonAllowed(btn.id);
2398
2728
  const stripped = btn.title.replace(/^Insert\s+/i, '');
2399
2729
  const label = stripped.charAt(0).toUpperCase() + stripped.slice(1);
2730
+ const hasMermaidTypeMenu = btn.id === 'complexdiagram';
2400
2731
  return (
2401
2732
  <button
2402
2733
  key={btn.id}
2403
2734
  ref={btn.id === 'emoji' ? emojiButtonRef : undefined}
2404
2735
  className="squisq-toolbar-overflow-item"
2405
2736
  disabled={disabled}
2406
- onClick={() => {
2737
+ onClick={(event) => {
2738
+ if (hasMermaidTypeMenu) {
2739
+ if (mermaidTypeMenuAnchor) setMermaidTypeMenuAnchor(null);
2740
+ else openMermaidTypeMenu(event.currentTarget);
2741
+ return;
2742
+ }
2407
2743
  handleAction(btn.id);
2408
2744
  if (btn.id !== 'emoji') closeInsertMenu();
2409
2745
  }}
2410
2746
  role="menuitem"
2747
+ aria-haspopup={hasMermaidTypeMenu ? 'menu' : undefined}
2748
+ aria-expanded={hasMermaidTypeMenu ? mermaidTypeMenuAnchor !== null : undefined}
2411
2749
  >
2412
2750
  <span className="squisq-toolbar-overflow-icon">{buttonIcon(btn)}</span>
2413
2751
  <span>{label}</span>
2752
+ {hasMermaidTypeMenu && <Icon icon="fa-solid fa-chevron-right" />}
2414
2753
  </button>
2415
2754
  );
2416
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
+ ))}
2417
2860
  </div>,
2418
2861
  document.body,
2419
2862
  )}