@bendyline/squisq-editor-react 1.6.2 → 2.0.1

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 (218) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1275 -183
  3. package/dist/index.js +20345 -12777
  4. package/dist/index.js.map +1 -1
  5. package/dist/monaco.d.ts +1 -0
  6. package/dist/monaco.js +4 -0
  7. package/dist/monaco.js.map +1 -0
  8. package/dist/styles/index.css +1607 -241
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +30 -15
  11. package/src/DocumentSettingsDialog.tsx +39 -33
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +349 -261
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +107 -14
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +362 -89
  20. package/src/PreviewPanel.tsx +312 -78
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/RecorderEntry.tsx +2 -0
  23. package/src/StatusBar.tsx +5 -2
  24. package/src/TemplateAnnotation.ts +12 -3
  25. package/src/TemplatePicker.tsx +75 -21
  26. package/src/ThemeCustomizerPanel.tsx +8 -0
  27. package/src/ThemePicker.tsx +10 -5
  28. package/src/TimelineBlockPreview.tsx +1 -1
  29. package/src/Toolbar.tsx +357 -51
  30. package/src/TransitionPicker.tsx +36 -4
  31. package/src/ViewMenuPanel.tsx +12 -14
  32. package/src/WysiwygEditor.tsx +124 -91
  33. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  34. package/src/__tests__/blockTagActivity.test.ts +183 -0
  35. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  36. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  37. package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
  38. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  39. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  40. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  41. package/src/__tests__/documentSettingsDialog.test.tsx +51 -1
  42. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  43. package/src/__tests__/editorScrollSync.test.ts +65 -0
  44. package/src/__tests__/editorShellProps.test.tsx +123 -1
  45. package/src/__tests__/emojiPicker.test.tsx +2 -42
  46. package/src/__tests__/headingTransition.test.ts +0 -12
  47. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  48. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  49. package/src/__tests__/imageEditorShell.test.tsx +15 -1
  50. package/src/__tests__/jsonEditor.test.tsx +56 -1
  51. package/src/__tests__/layersPanel.test.tsx +75 -5
  52. package/src/__tests__/majorApiTypes.test.ts +26 -0
  53. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  54. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  55. package/src/__tests__/narrationSave.test.ts +148 -0
  56. package/src/__tests__/outlinePanel.test.tsx +93 -3
  57. package/src/__tests__/outlineSource.test.ts +217 -0
  58. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  59. package/src/__tests__/presentationMode.test.tsx +290 -0
  60. package/src/__tests__/previewControls.test.tsx +382 -5
  61. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  62. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  63. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  64. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  65. package/src/__tests__/recorderTheme.test.tsx +42 -0
  66. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  67. package/src/__tests__/selectionConversions.test.ts +80 -0
  68. package/src/__tests__/statusBar.test.tsx +27 -0
  69. package/src/__tests__/teleprompter.test.tsx +190 -0
  70. package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
  71. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  72. package/src/__tests__/tiptapBridge.test.ts +71 -7
  73. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  74. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  75. package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
  76. package/src/__tests__/transformStyleId.test.ts +13 -0
  77. package/src/__tests__/useImageEditor.test.tsx +67 -0
  78. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  79. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  80. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  81. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  82. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  83. package/src/asciiDiagram/AsciiDiagramWidget.tsx +254 -0
  84. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  85. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  86. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  87. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +284 -0
  88. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  89. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  90. package/src/asciiDiagram/asciiDiagramCommands.ts +174 -0
  91. package/src/asciiDiagram/asciiDiagramData.ts +149 -0
  92. package/src/asciiDiagram/asciiDiagramOps.ts +238 -0
  93. package/src/asciiDiagram/asciiPaste.ts +19 -0
  94. package/src/blockTagActivity.ts +233 -0
  95. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  96. package/src/buildPreviewDoc.ts +19 -12
  97. package/src/codeContext/types.ts +1 -1
  98. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  99. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  100. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  101. package/src/customTemplates/__tests__/library.test.ts +8 -0
  102. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  103. package/src/customTemplates/designer.css +158 -99
  104. package/src/customTemplates/library.ts +16 -3
  105. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  106. package/src/customThemes/CustomThemeContext.tsx +6 -0
  107. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  108. package/src/customThemes/ImportThemeSection.tsx +178 -0
  109. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  110. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  111. package/src/customThemes/customThemeLibrary.ts +7 -4
  112. package/src/customThemes/index.ts +7 -1
  113. package/src/customThemes/themeDraft.ts +23 -7
  114. package/src/diagram/DiagramCanvas.tsx +25 -6
  115. package/src/diagram/types.ts +35 -0
  116. package/src/editorScrollSync.ts +73 -0
  117. package/src/emojiData.ts +3 -23
  118. package/src/frontmatterSettings.ts +23 -0
  119. package/src/headingTransition.ts +0 -15
  120. package/src/imageEditor/CanvasSurface.tsx +29 -23
  121. package/src/imageEditor/LayersPanel.tsx +78 -3
  122. package/src/imageEditor/Toolbar.tsx +5 -1
  123. package/src/imageEditor/icons.tsx +4 -0
  124. package/src/imageEditor/image-editor.css +75 -0
  125. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  126. package/src/imageEditor/useImageEditor.ts +71 -14
  127. package/src/index.ts +198 -20
  128. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  129. package/src/jsonEditor/RenderNode.tsx +24 -3
  130. package/src/jsonEditor/editors.tsx +86 -19
  131. package/src/mediaEntries.ts +12 -0
  132. package/src/monaco.ts +35 -0
  133. package/src/monacoWorkers.ts +89 -0
  134. package/src/outlineSource.ts +171 -0
  135. package/src/presentation/PresentationMode.tsx +596 -0
  136. package/src/rawEditorIsolation.ts +18 -0
  137. package/src/recorder/RecorderButton.tsx +9 -1
  138. package/src/recorder/RecorderModal.tsx +84 -41
  139. package/src/recorder/RecorderPanel.tsx +9 -1
  140. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  141. package/src/resolveBlockVisual.ts +10 -4
  142. package/src/scene/Scene.tsx +48 -18
  143. package/src/scene/SceneBlockExtension.ts +17 -10
  144. package/src/scene/SceneBlockWidget.tsx +5 -1
  145. package/src/scene/SceneViewport.tsx +7 -3
  146. package/src/scene/ShapePalette.tsx +17 -2
  147. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  148. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  149. package/src/scene/__tests__/sceneIsolation.test.tsx +119 -0
  150. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  151. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  152. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  153. package/src/scene/commands/SceneCommand.ts +17 -3
  154. package/src/scene/index.ts +1 -6
  155. package/src/scene/layers/DiagramEdges.tsx +21 -7
  156. package/src/scene/layers/edgeGeometry.ts +9 -1
  157. package/src/scene/layers/nodeCard.tsx +27 -8
  158. package/src/scene/scene.css +5 -2
  159. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  160. package/src/scene/text/sceneTextChannel.ts +26 -20
  161. package/src/scene/text/sceneTextConfig.ts +4 -0
  162. package/src/scene/tools/ConnectTool.ts +13 -4
  163. package/src/scene/tools/SceneTool.ts +8 -0
  164. package/src/scene/tools/SelectTool.ts +39 -23
  165. package/src/scene/tools/ShapeTool.ts +2 -3
  166. package/src/selectionConversions.ts +155 -0
  167. package/src/styles/ascii-diagram.css +79 -0
  168. package/src/styles/ascii-timeline.css +596 -0
  169. package/src/styles/editor.css +736 -120
  170. package/src/styles/index.css +3 -0
  171. package/src/styles/tree-view.css +189 -0
  172. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  173. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  174. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  175. package/src/teleprompter/TeleprompterView.tsx +338 -0
  176. package/src/teleprompter/canvasRenderer.ts +161 -0
  177. package/src/teleprompter/floatingWindow.ts +352 -0
  178. package/src/teleprompter/index.ts +61 -0
  179. package/src/teleprompter/pcmWorklet.ts +62 -0
  180. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  181. package/src/teleprompter/recording/narrationSave.ts +134 -0
  182. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  183. package/src/teleprompter/scrollModel.ts +85 -0
  184. package/src/teleprompter/teleprompterTheme.ts +303 -0
  185. package/src/teleprompter/types.ts +38 -0
  186. package/src/teleprompter/useFloatingWindow.ts +74 -0
  187. package/src/teleprompter/useMicAnalysis.ts +211 -0
  188. package/src/teleprompter/useTeleprompter.ts +421 -0
  189. package/src/templateContentPreviewResolver.ts +12 -5
  190. package/src/timeline/TimelineEditorWidget.tsx +861 -0
  191. package/src/timeline/TimelineViewExtension.ts +252 -0
  192. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +414 -0
  193. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  194. package/src/timeline/__tests__/timelineCommands.test.ts +359 -0
  195. package/src/timeline/__tests__/timelineOps.test.ts +277 -0
  196. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  197. package/src/timeline/timelineCommands.ts +421 -0
  198. package/src/timeline/timelineData.ts +54 -0
  199. package/src/timeline/timelineOps.ts +381 -0
  200. package/src/timeline/timelinePaste.ts +8 -0
  201. package/src/tiptapBridge.ts +47 -21
  202. package/src/transformStyleId.ts +17 -0
  203. package/src/treeview/TreeOutlineWidget.tsx +390 -0
  204. package/src/treeview/TreeViewExtension.ts +250 -0
  205. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  206. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  207. package/src/treeview/__tests__/treeOps.test.ts +177 -0
  208. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  209. package/src/treeview/__tests__/treeViewCommands.test.ts +179 -0
  210. package/src/treeview/treeOps.ts +231 -0
  211. package/src/treeview/treePaste.ts +13 -0
  212. package/src/treeview/treeViewCommands.ts +99 -0
  213. package/src/treeview/treeViewData.ts +41 -0
  214. package/src/useMonacoLoader.ts +11 -34
  215. package/src/wysiwygImageUpload.ts +113 -0
  216. package/src/diagram/DiagramExtension.ts +0 -209
  217. package/src/diagram/DiagramWidget.tsx +0 -272
  218. package/src/diagram/useDiagramData.ts +0 -126
package/src/Toolbar.tsx CHANGED
@@ -29,7 +29,7 @@ import { ViewMenuPanel } from './ViewMenuPanel';
29
29
  import {
30
30
  TemplatePicker,
31
31
  TEMPLATE_NAMES,
32
- TEMPLATE_GALLERY_PORTAL_ID,
32
+ TEMPLATE_GALLERY_PORTAL_SELECTOR,
33
33
  templateLabel,
34
34
  } from './TemplatePicker';
35
35
  import { TransitionPicker, TRANSITION_FLYOUT_PORTAL_ID } from './TransitionPicker';
@@ -50,7 +50,15 @@ import { CustomLayoutManager } from './customTemplates/CustomLayoutManager';
50
50
  import { Icon } from './Icon';
51
51
  import type { PickerEntry } from './emojiData';
52
52
  import { createPortal } from 'react-dom';
53
- import { usePreviewSettingsOptional } from './PreviewControls';
53
+ import { PreviewModeMenu, displayModeLabel, usePreviewSettingsOptional } from './PreviewControls';
54
+ import { filterVisibleMediaEntries } from './mediaEntries';
55
+ import {
56
+ selectionToTable,
57
+ selectionToTableMarkdown,
58
+ selectionToTaskItems,
59
+ selectionToTaskListMarkdown,
60
+ type SelectionTaskItem,
61
+ } from './selectionConversions';
54
62
 
55
63
  const VIEWS: { id: EditorView; label: string; shortLabel?: string; shortcut: string }[] = [
56
64
  { id: 'wysiwyg', label: 'Write', shortcut: '⌘1' },
@@ -220,6 +228,22 @@ const BUTTONS: ToolbarButton[] = [
220
228
  group: 'media',
221
229
  faIcon: 'fa-solid fa-diagram-project',
222
230
  },
231
+ {
232
+ id: 'tree',
233
+ label: 'tree',
234
+ icon: '',
235
+ title: 'Insert tree',
236
+ group: 'media',
237
+ faIcon: 'fa-solid fa-folder-tree',
238
+ },
239
+ {
240
+ id: 'timeline',
241
+ label: 'timeline',
242
+ icon: '',
243
+ title: 'Insert timeline',
244
+ group: 'media',
245
+ faIcon: 'fa-solid fa-timeline',
246
+ },
223
247
  {
224
248
  id: 'drawing',
225
249
  label: 'drawing',
@@ -256,6 +280,7 @@ const BUTTONS: ToolbarButton[] = [
256
280
 
257
281
  const FIRST_MEDIA_INDEX = BUTTONS.findIndex((b) => b.group === 'media');
258
282
  const MEDIA_BUTTONS = BUTTONS.filter((b) => b.group === 'media');
283
+ const CONVERT_BUTTONS = MEDIA_BUTTONS.filter((b) => b.id === 'table' || b.id === 'tasklist');
259
284
  const INSERT_MENU_WIDTH = 200;
260
285
  const TASK_LIST_ITEMS = ['Task 1', 'Task 2', 'Task 3'] as const;
261
286
  const TASK_LIST_MARKDOWN = TASK_LIST_ITEMS.map((item) => `- [ ] ${item}`).join('\n');
@@ -280,18 +305,35 @@ function fileCountBadge(count: number): string {
280
305
  return count > 99 ? '99+' : String(count);
281
306
  }
282
307
 
283
- function taskListContent(): JSONContent {
308
+ function paragraphContent(text: string): JSONContent {
309
+ return text ? { type: 'paragraph', content: [{ type: 'text', text }] } : { type: 'paragraph' };
310
+ }
311
+
312
+ function tableContent(rows: string[][]): JSONContent {
313
+ return {
314
+ type: 'table',
315
+ content: rows.map((row, rowIndex) => ({
316
+ type: 'tableRow',
317
+ content: row.map((cell) => ({
318
+ type: rowIndex === 0 ? 'tableHeader' : 'tableCell',
319
+ content: [paragraphContent(cell)],
320
+ })),
321
+ })),
322
+ };
323
+ }
324
+
325
+ function taskListContent(
326
+ items: readonly SelectionTaskItem[] = TASK_LIST_ITEMS.map((text) => ({
327
+ checked: false,
328
+ text,
329
+ })),
330
+ ): JSONContent {
284
331
  return {
285
332
  type: 'taskList',
286
- content: TASK_LIST_ITEMS.map((item) => ({
333
+ content: items.map((item) => ({
287
334
  type: 'taskItem',
288
- attrs: { checked: false },
289
- content: [
290
- {
291
- type: 'paragraph',
292
- content: [{ type: 'text', text: item }],
293
- },
294
- ],
335
+ attrs: { checked: item.checked },
336
+ content: [paragraphContent(item.text)],
295
337
  })),
296
338
  };
297
339
  }
@@ -302,6 +344,26 @@ function insertTaskList(editor: TiptapEditor): void {
302
344
  editor.chain().focus().insertContent(content).run();
303
345
  }
304
346
 
347
+ /**
348
+ * Expand a full-line Tiptap text selection to the surrounding top-level node
349
+ * boundaries. Inserting a list/table at an in-paragraph range makes
350
+ * ProseMirror preserve the emptied paragraph before or after the new block.
351
+ * Partial-line selections deliberately retain their exact range.
352
+ */
353
+ function blockConversionRange(editor: TiptapEditor): { from: number; to: number } {
354
+ const { from, to, $from, $to } = editor.state.selection;
355
+ return {
356
+ from:
357
+ $from.depth === 1 && $from.parent.isTextblock && $from.parentOffset === 0
358
+ ? $from.before(1)
359
+ : from,
360
+ to:
361
+ $to.depth === 1 && $to.parent.isTextblock && $to.parentOffset === $to.parent.content.size
362
+ ? $to.after(1)
363
+ : to,
364
+ };
365
+ }
366
+
305
367
  // ─── Tiptap active-state map ────────────────────────────
306
368
 
307
369
  /** Returns true if the given button id is currently active in Tiptap */
@@ -355,6 +417,85 @@ const LAYOUT_STARTER_TEXT_PARAMS =
355
417
  /** Multi-line markdown for a new layout (raw / code views). */
356
418
  const LAYOUT_STARTER_MARKDOWN = `\n## Layout {[layout]}\n\n### {#text-1} {[text ${LAYOUT_STARTER_TEXT_PARAMS}]}\n\nLayout\n`;
357
419
 
420
+ /**
421
+ * Starter art for a new diagram — diagrams are authored as ASCII-art code
422
+ * fences (the fence is the source of truth; `AsciiDiagramExtension` mounts
423
+ * the interactive canvas over it). This exact art is the codec's own
424
+ * canonical rendering of a two-node flow, so it re-parses and re-renders
425
+ * byte-stably. The leading rows/columns are deliberate: ASCII has no
426
+ * separate canvas-origin metadata, so this persisted gutter gives the first
427
+ * node real grid space to move north/west. Without it, content-fit makes the
428
+ * node look centered while its authored coordinate is still (0, 0), and a
429
+ * drag into the apparent margin clamps straight back to that corner.
430
+ */
431
+ const DIAGRAM_STARTER_ART = [
432
+ '',
433
+ '',
434
+ ' ┌─────────┐',
435
+ ' │ Start │',
436
+ ' └────┬────┘',
437
+ ' │',
438
+ ' ▼',
439
+ ' ┌────┴────┐',
440
+ ' │ Next │',
441
+ ' └─────────┘',
442
+ ].join('\n');
443
+ /**
444
+ * Fenced form for raw / code views — tagged with the explicit `diagram`
445
+ * language so the block's identity survives markdown ↔ Tiptap round-trips
446
+ * (the language class round-trips; fence meta does not).
447
+ */
448
+ const DIAGRAM_STARTER_MARKDOWN = '\n```diagram\n' + DIAGRAM_STARTER_ART + '\n```\n';
449
+
450
+ /** Insert a starter ASCII-diagram code fence after the current top-level block. */
451
+ function insertAsciiDiagramBlock(editor: TiptapEditor): void {
452
+ insertFenceBlock(editor, DIAGRAM_STARTER_ART, 'diagram');
453
+ }
454
+
455
+ /**
456
+ * Starter art for a new file tree — an ASCII tree fence (the fence is the
457
+ * source of truth; `TreeViewExtension` mounts the interactive outline).
458
+ */
459
+ const TREE_STARTER_ART = ['src/', '├── index.ts', '└── utils/', ' └── helpers.ts'].join('\n');
460
+ const TREE_STARTER_MARKDOWN = '\n```tree\n' + TREE_STARTER_ART + '\n```\n';
461
+
462
+ /** Insert a starter ASCII tree code fence after the current top-level block. */
463
+ function insertTreeBlock(editor: TiptapEditor): void {
464
+ insertFenceBlock(editor, TREE_STARTER_ART, 'tree');
465
+ }
466
+
467
+ /** Starter multi-event rail for the authored timeline view. */
468
+ const TIMELINE_STARTER_ART =
469
+ 'Milestones: ● Start {#start} ─────● Review {#review} ─────● Ship {#ship} ───►';
470
+ const TIMELINE_STARTER_MARKDOWN = '\n```timeline\n' + TIMELINE_STARTER_ART + '\n```\n';
471
+
472
+ /** Insert a starter ASCII timeline code fence after the current top-level block. */
473
+ function insertTimelineBlock(editor: TiptapEditor): void {
474
+ insertFenceBlock(editor, TIMELINE_STARTER_ART, 'timeline');
475
+ }
476
+
477
+ /**
478
+ * Insert a code fence after the current top-level block. `lang` tags the
479
+ * fence's `language` attribute — pass the explicit authored-view tag so its
480
+ * identity round-trips through markdown and Tiptap.
481
+ */
482
+ function insertFenceBlock(editor: TiptapEditor, art: string, lang?: string): void {
483
+ editor
484
+ .chain()
485
+ .focus()
486
+ .command(({ tr, state, dispatch }) => {
487
+ const codeBlockType = state.schema.nodes.codeBlock;
488
+ if (!codeBlockType) return false;
489
+ const attrs = lang ? { language: lang } : null;
490
+ const block = codeBlockType.create(attrs, state.schema.text(art));
491
+ const { $from } = state.selection;
492
+ const insertPos = $from.depth > 0 ? $from.after(1) : state.doc.content.size;
493
+ if (dispatch) tr.insert(insertPos, block);
494
+ return true;
495
+ })
496
+ .run();
497
+ }
498
+
358
499
  /**
359
500
  * Insert a block-level heading carrying a Scene template (`diagram` /
360
501
  * `drawing` / `layout`) at the top level, after the block the caret sits
@@ -506,6 +647,7 @@ export function Toolbar({
506
647
  bumpMediaRevision,
507
648
  } = useEditorContext();
508
649
  const previewSettings = usePreviewSettingsOptional();
650
+ const [useModeMenuRequest, requestUseModeMenu] = useReducer((count: number) => count + 1, 0);
509
651
  // When a canvas textbox is being edited, its Tiptap instance takes over
510
652
  // the formatting buttons; otherwise they drive the document editor. The
511
653
  // `level` gates which buttons apply (inline labels vs. rich textboxes).
@@ -533,7 +675,7 @@ export function Toolbar({
533
675
  let cancelled = false;
534
676
  mediaProvider.listMedia().then(
535
677
  (entries) => {
536
- if (!cancelled) setScannedFileCount(entries.length);
678
+ if (!cancelled) setScannedFileCount(filterVisibleMediaEntries(entries).length);
537
679
  },
538
680
  () => {
539
681
  if (!cancelled) setScannedFileCount(0);
@@ -682,23 +824,25 @@ export function Toolbar({
682
824
  // with the cursor context and change width with their value, all without
683
825
  // resizing the flex:1 container itself — a container-only observer would
684
826
  // go stale.
685
- const ro = new ResizeObserver(measure);
827
+ const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure);
686
828
  const observeAll = () => {
687
- ro.disconnect();
688
- ro.observe(container);
829
+ ro?.disconnect();
830
+ ro?.observe(container);
689
831
  container
690
832
  .querySelectorAll<HTMLElement>(':scope > .squisq-toolbar-group')
691
- .forEach((group) => ro.observe(group));
833
+ .forEach((group) => ro?.observe(group));
692
834
  };
693
835
  const mo = new MutationObserver(() => {
694
836
  observeAll();
695
837
  measure();
696
838
  });
697
839
  mo.observe(container, { childList: true });
840
+ if (!ro) window.addEventListener('resize', measure);
698
841
  observeAll();
699
842
  measure();
700
843
  return () => {
701
- ro.disconnect();
844
+ ro?.disconnect();
845
+ if (!ro) window.removeEventListener('resize', measure);
702
846
  mo.disconnect();
703
847
  };
704
848
  }, [activeView]);
@@ -714,7 +858,7 @@ export function Toolbar({
714
858
  if (overflowRef.current?.contains(target)) return;
715
859
  if (
716
860
  target instanceof Element &&
717
- target.closest(`#${TEMPLATE_GALLERY_PORTAL_ID}, #${TRANSITION_FLYOUT_PORTAL_ID}`)
861
+ target.closest(`${TEMPLATE_GALLERY_PORTAL_SELECTOR}, #${TRANSITION_FLYOUT_PORTAL_ID}`)
718
862
  ) {
719
863
  return;
720
864
  }
@@ -856,9 +1000,19 @@ export function Toolbar({
856
1000
  insertTaskList(tiptapEditor);
857
1001
  break;
858
1002
  case 'diagram':
859
- // Empty diagram is usable via its "+ Add first node" affordance;
860
- // sub-headings under this block become nodes.
861
- insertTemplateHeading(tiptapEditor, { template: 'diagram', text: 'Diagram' });
1003
+ // Diagrams are ASCII-art code fences; the AsciiDiagramExtension
1004
+ // mounts the interactive canvas over the inserted starter.
1005
+ insertAsciiDiagramBlock(tiptapEditor);
1006
+ break;
1007
+ case 'tree':
1008
+ // File trees are ASCII tree fences; TreeViewExtension mounts the
1009
+ // interactive outline over the inserted starter.
1010
+ insertTreeBlock(tiptapEditor);
1011
+ break;
1012
+ case 'timeline':
1013
+ // The authored fence is immediately replaced by the WYSIWYG rail
1014
+ // canvas; Preview uses the same events as vector layers.
1015
+ insertTimelineBlock(tiptapEditor);
862
1016
  break;
863
1017
  case 'drawing':
864
1018
  // Empty drawing is usable via the canvas's "Shapes ▾" palette;
@@ -1015,9 +1169,19 @@ export function Toolbar({
1015
1169
  break;
1016
1170
  }
1017
1171
  case 'diagram': {
1018
- // A diagram is just a heading with the `{[diagram]}` template
1019
- // annotation; the WYSIWYG view renders its editable canvas.
1020
- replacement = '\n## Diagram {[diagram]}\n';
1172
+ // A diagram is an ASCII-art code fence; the WYSIWYG view
1173
+ // renders its editable canvas, preview renders it as a slide.
1174
+ replacement = DIAGRAM_STARTER_MARKDOWN;
1175
+ newCursorOffset = replacement.length;
1176
+ break;
1177
+ }
1178
+ case 'tree': {
1179
+ replacement = TREE_STARTER_MARKDOWN;
1180
+ newCursorOffset = replacement.length;
1181
+ break;
1182
+ }
1183
+ case 'timeline': {
1184
+ replacement = TIMELINE_STARTER_MARKDOWN;
1021
1185
  newCursorOffset = replacement.length;
1022
1186
  break;
1023
1187
  }
@@ -1099,7 +1263,13 @@ export function Toolbar({
1099
1263
  insertion = `\n${TASK_LIST_MARKDOWN}\n`;
1100
1264
  break;
1101
1265
  case 'diagram':
1102
- insertion = '\n## Diagram {[diagram]}\n';
1266
+ insertion = DIAGRAM_STARTER_MARKDOWN;
1267
+ break;
1268
+ case 'tree':
1269
+ insertion = TREE_STARTER_MARKDOWN;
1270
+ break;
1271
+ case 'timeline':
1272
+ insertion = TIMELINE_STARTER_MARKDOWN;
1103
1273
  break;
1104
1274
  case 'drawing':
1105
1275
  insertion = '\n## Drawing {[drawing]}\n';
@@ -1116,6 +1286,68 @@ export function Toolbar({
1116
1286
  [monacoEditor, markdownSource, setMarkdownSource],
1117
1287
  );
1118
1288
 
1289
+ // ── Selected-text conversion handlers ─────────────────
1290
+ const readSelectedText = useCallback((): string => {
1291
+ // Canvas text overlays intentionally have a smaller schema without tables
1292
+ // or task lists. Ignore their selection instead of surfacing actions that
1293
+ // cannot be represented by those layers.
1294
+ if (activeSceneText) return '';
1295
+
1296
+ if (activeView === 'wysiwyg' && tiptapEditor) {
1297
+ const { from, to, empty } = tiptapEditor.state.selection;
1298
+ return empty ? '' : tiptapEditor.state.doc.textBetween(from, to, '\n');
1299
+ }
1300
+
1301
+ if (activeView === 'raw' && monacoEditor) {
1302
+ const selection = monacoEditor.getSelection();
1303
+ const model = monacoEditor.getModel();
1304
+ return selection && model ? model.getValueInRange(selection) : '';
1305
+ }
1306
+
1307
+ return '';
1308
+ }, [activeSceneText, activeView, monacoEditor, tiptapEditor]);
1309
+
1310
+ const handleConvertSelection = useCallback(
1311
+ (target: 'table' | 'tasklist') => {
1312
+ const selectedText = readSelectedText();
1313
+ if (!selectedText.trim()) return;
1314
+
1315
+ if (activeView === 'wysiwyg' && tiptapEditor) {
1316
+ const content =
1317
+ target === 'table'
1318
+ ? tableContent(selectionToTable(selectedText).rows)
1319
+ : taskListContent(selectionToTaskItems(selectedText));
1320
+ tiptapEditor
1321
+ .chain()
1322
+ .focus()
1323
+ .insertContentAt(blockConversionRange(tiptapEditor), content)
1324
+ .run();
1325
+ return;
1326
+ }
1327
+
1328
+ if (activeView === 'raw' && monacoEditor) {
1329
+ const selection = monacoEditor.getSelection();
1330
+ if (!selection) return;
1331
+ const converted =
1332
+ target === 'table'
1333
+ ? selectionToTableMarkdown(selectedText)
1334
+ : selectionToTaskListMarkdown(selectedText);
1335
+ if (!converted) return;
1336
+
1337
+ // Monaco line selections commonly include the newline after the last
1338
+ // selected line. Retain selected edge newlines so the replacement
1339
+ // block never runs into the surrounding Markdown.
1340
+ const leadingNewline = /^(?:\r?\n)/.test(selectedText) ? '\n' : '';
1341
+ const trailingNewline = /(?:\r?\n)$/.test(selectedText) ? '\n' : '';
1342
+ monacoEditor.executeEdits('toolbar-convert-selection', [
1343
+ { range: selection, text: leadingNewline + converted + trailingNewline },
1344
+ ]);
1345
+ monacoEditor.focus();
1346
+ }
1347
+ },
1348
+ [activeView, monacoEditor, readSelectedText, tiptapEditor],
1349
+ );
1350
+
1119
1351
  // ── Image upload handler ───────────────────────────────
1120
1352
  const handleImageFile = useCallback(
1121
1353
  async (file: File) => {
@@ -1352,6 +1584,10 @@ export function Toolbar({
1352
1584
  if (sceneTextLevel === 'block') return BLOCK_SCENE_BUTTONS.has(id);
1353
1585
  return INLINE_SCENE_BUTTONS.has(id);
1354
1586
  };
1587
+ const showConvertActions =
1588
+ insertMenuAnchor !== null &&
1589
+ readSelectedText().trim().length > 0 &&
1590
+ CONVERT_BUTTONS.some((button) => buttonAllowed(button.id));
1355
1591
 
1356
1592
  // ── Progressive heading disclosure ───────────────────
1357
1593
  // H1\u2013H3 are always visible. H4 appears once the document already
@@ -1540,10 +1776,12 @@ export function Toolbar({
1540
1776
  viewport: previewSettings?.activeViewport ?? VIEWPORT_PRESETS.landscape,
1541
1777
  basePath: '/',
1542
1778
  mediaProvider,
1779
+ customTemplates: doc?.customTemplates,
1543
1780
  }
1544
1781
  : undefined,
1545
1782
  [
1546
1783
  mediaProvider,
1784
+ doc?.customTemplates,
1547
1785
  previewSettings?.activeTheme,
1548
1786
  previewSettings?.activeViewport,
1549
1787
  templatePreviewBlock,
@@ -1692,35 +1930,65 @@ export function Toolbar({
1692
1930
  {/* View tabs — hidden when only one view is available (e.g. code mode). */}
1693
1931
  {showViewTabs && (
1694
1932
  <div className="squisq-toolbar-view-tabs" role="tablist" aria-label="Editor view">
1695
- {visibleViews.map((view) => (
1696
- <button
1697
- key={view.id}
1698
- role="tab"
1699
- data-view={view.id}
1700
- aria-selected={activeView === view.id}
1701
- className={`squisq-toolbar-view-tab${activeView === view.id ? ' squisq-toolbar-view-tab--active' : ''}`}
1702
- onClick={() => setActiveView(view.id)}
1703
- data-tooltip={`${view.label} (${view.shortcut})`}
1704
- >
1705
- <span
1706
- className="squisq-toolbar-view-tab-label squisq-toolbar-view-tab-label--long"
1707
- data-label={view.label}
1933
+ {visibleViews.map((view) => {
1934
+ const viewLabel =
1935
+ view.id === 'preview' && previewSettings
1936
+ ? displayModeLabel(previewSettings.activeDisplayMode)
1937
+ : view.label;
1938
+ const tab = (
1939
+ <button
1940
+ role="tab"
1941
+ data-view={view.id}
1942
+ aria-selected={activeView === view.id}
1943
+ className={`squisq-toolbar-view-tab${activeView === view.id ? ' squisq-toolbar-view-tab--active' : ''}`}
1944
+ onClick={() => {
1945
+ if (view.id === 'preview' && activeView === 'preview' && previewSettings) {
1946
+ requestUseModeMenu();
1947
+ return;
1948
+ }
1949
+ setActiveView(view.id);
1950
+ }}
1951
+ data-tooltip={`${viewLabel} (${view.shortcut})`}
1708
1952
  >
1709
- {view.label}
1710
- </span>
1711
- {view.shortLabel && view.shortLabel !== view.label && (
1712
1953
  <span
1713
- className="squisq-toolbar-view-tab-label squisq-toolbar-view-tab-label--short"
1714
- data-label={view.shortLabel}
1954
+ className="squisq-toolbar-view-tab-label squisq-toolbar-view-tab-label--long"
1955
+ data-label={viewLabel}
1715
1956
  >
1716
- {view.shortLabel}
1957
+ {viewLabel}
1717
1958
  </span>
1718
- )}
1719
- </button>
1720
- ))}
1959
+ {view.shortLabel && view.shortLabel !== view.label && (
1960
+ <span
1961
+ className="squisq-toolbar-view-tab-label squisq-toolbar-view-tab-label--short"
1962
+ data-label={view.shortLabel}
1963
+ >
1964
+ {view.shortLabel}
1965
+ </span>
1966
+ )}
1967
+ </button>
1968
+ );
1969
+
1970
+ if (view.id !== 'preview' || !previewSettings) {
1971
+ return (
1972
+ <div key={view.id} className="squisq-toolbar-view-tab-wrap" role="presentation">
1973
+ {tab}
1974
+ </div>
1975
+ );
1976
+ }
1977
+
1978
+ return (
1979
+ <div
1980
+ key={view.id}
1981
+ className={`squisq-toolbar-view-tab-wrap squisq-toolbar-use-tab${activeView === 'preview' ? ' squisq-toolbar-use-tab--active' : ''}`}
1982
+ role="presentation"
1983
+ >
1984
+ {tab}
1985
+ <PreviewModeMenu openRequest={useModeMenuRequest} />
1986
+ </div>
1987
+ );
1988
+ })}
1721
1989
  </div>
1722
1990
  )}
1723
- {/* After-tabs slot — left side, before formatting controls (preview mode switch) */}
1991
+ {/* After-tabs slot — left side, before formatting or preview controls. */}
1724
1992
  {slotAfterTabs}
1725
1993
  {/* Formatting buttons — hidden in preview mode and code mode */}
1726
1994
  {!isPreview && !isCodeMode && (
@@ -1803,7 +2071,12 @@ export function Toolbar({
1803
2071
  data-contextual="transition"
1804
2072
  >
1805
2073
  <div className="squisq-toolbar-separator" />
1806
- <TransitionPicker value={currentTransition} onChange={handleTransitionPick} />
2074
+ <TransitionPicker
2075
+ value={currentTransition}
2076
+ onChange={handleTransitionPick}
2077
+ colorScheme={colorScheme}
2078
+ accentColor={previewSettings?.activeTheme.colors.primary}
2079
+ />
1807
2080
  </div>
1808
2081
  )}
1809
2082
 
@@ -2074,7 +2347,12 @@ export function Toolbar({
2074
2347
  the flyout carries follow-up controls (direction/duration). */}
2075
2348
  {showTransitionInOverflow && (
2076
2349
  <div className="squisq-toolbar-overflow-item squisq-toolbar-overflow-template">
2077
- <TransitionPicker value={currentTransition!} onChange={handleTransitionPick} />
2350
+ <TransitionPicker
2351
+ value={currentTransition!}
2352
+ onChange={handleTransitionPick}
2353
+ colorScheme={colorScheme}
2354
+ accentColor={previewSettings?.activeTheme.colors.primary}
2355
+ />
2078
2356
  </div>
2079
2357
  )}
2080
2358
 
@@ -2231,7 +2509,35 @@ export function Toolbar({
2231
2509
  style={{ position: 'fixed', top: insertMenuAnchor.top, left: insertMenuAnchor.left }}
2232
2510
  role="menu"
2233
2511
  >
2234
- <div className="squisq-insert-menu-header">Insert</div>
2512
+ {showConvertActions && (
2513
+ <>
2514
+ <div className="squisq-insert-menu-header">Convert</div>
2515
+ {CONVERT_BUTTONS.map((btn) => {
2516
+ const label = btn.id === 'table' ? 'Table' : 'Task List';
2517
+ return (
2518
+ <button
2519
+ key={`convert-${btn.id}`}
2520
+ className="squisq-toolbar-overflow-item"
2521
+ disabled={!buttonAllowed(btn.id)}
2522
+ onClick={() => {
2523
+ handleConvertSelection(btn.id as 'table' | 'tasklist');
2524
+ closeInsertMenu();
2525
+ }}
2526
+ role="menuitem"
2527
+ aria-label={`Convert selection to ${label}`}
2528
+ >
2529
+ <span className="squisq-toolbar-overflow-icon">{buttonIcon(btn)}</span>
2530
+ <span>{label}</span>
2531
+ </button>
2532
+ );
2533
+ })}
2534
+ </>
2535
+ )}
2536
+ <div
2537
+ className={`squisq-insert-menu-header${showConvertActions ? ' squisq-insert-menu-header--separated' : ''}`}
2538
+ >
2539
+ Insert
2540
+ </div>
2235
2541
  {MEDIA_BUTTONS.filter((b) => isButtonVisible(b.id)).map((btn) => {
2236
2542
  const disabled = (btn.id === 'image' && !mediaProvider) || !buttonAllowed(btn.id);
2237
2543
  const stripped = btn.title.replace(/^Insert\s+/i, '');
@@ -30,6 +30,10 @@ import {
30
30
  export interface TransitionPickerProps {
31
31
  value: TransitionFields;
32
32
  onChange: (next: TransitionFields) => void;
33
+ /** Surface scheme copied onto the portaled flyout so it can style independently of the shell. */
34
+ colorScheme?: 'light' | 'dark';
35
+ /** Active document-theme accent used for open and selected states. */
36
+ accentColor?: string;
33
37
  }
34
38
 
35
39
  /**
@@ -41,7 +45,12 @@ export const TRANSITION_FLYOUT_PORTAL_ID = 'squisq-transition-flyout-portal';
41
45
 
42
46
  const FLYOUT_ID = TRANSITION_FLYOUT_PORTAL_ID;
43
47
 
44
- export function TransitionPicker({ value, onChange }: TransitionPickerProps) {
48
+ export function TransitionPicker({
49
+ value,
50
+ onChange,
51
+ colorScheme = 'light',
52
+ accentColor,
53
+ }: TransitionPickerProps) {
45
54
  const [open, setOpen] = useState(false);
46
55
  const [style, setStyle] = useState<React.CSSProperties>({});
47
56
  const triggerRef = useRef<HTMLButtonElement>(null);
@@ -119,13 +128,22 @@ export function TransitionPicker({ value, onChange }: TransitionPickerProps) {
119
128
 
120
129
  const flyout = open
121
130
  ? createPortal(
122
- <TransitionFlyout style={style} value={value} onChange={onChange} />,
131
+ <TransitionFlyout
132
+ style={{ ...style, ...blockAccentStyle(accentColor) }}
133
+ value={value}
134
+ onChange={onChange}
135
+ colorScheme={colorScheme}
136
+ />,
123
137
  document.body,
124
138
  )
125
139
  : null;
126
140
 
127
141
  return (
128
- <div className="squisq-transition-picker">
142
+ <div
143
+ className="squisq-transition-picker"
144
+ data-theme={colorScheme}
145
+ style={blockAccentStyle(accentColor)}
146
+ >
129
147
  <button
130
148
  ref={triggerRef}
131
149
  type="button"
@@ -158,10 +176,12 @@ function TransitionFlyout({
158
176
  style,
159
177
  value,
160
178
  onChange,
179
+ colorScheme,
161
180
  }: {
162
181
  style: React.CSSProperties;
163
182
  value: TransitionFields;
164
183
  onChange: (next: TransitionFields) => void;
184
+ colorScheme: 'light' | 'dark';
165
185
  }) {
166
186
  const [query, setQuery] = useState('');
167
187
  const searchRef = useRef<HTMLInputElement>(null);
@@ -211,7 +231,13 @@ function TransitionFlyout({
211
231
  };
212
232
 
213
233
  return (
214
- <div id={FLYOUT_ID} className="squisq-transition-flyout" role="menu" style={style}>
234
+ <div
235
+ id={FLYOUT_ID}
236
+ className="squisq-transition-flyout"
237
+ data-theme={colorScheme}
238
+ role="menu"
239
+ style={style}
240
+ >
215
241
  <div className="squisq-transition-flyout-search">
216
242
  <input
217
243
  ref={searchRef}
@@ -313,6 +339,12 @@ function TransitionFlyout({
313
339
  );
314
340
  }
315
341
 
342
+ function blockAccentStyle(accentColor: string | undefined): React.CSSProperties {
343
+ return accentColor
344
+ ? ({ ['--squisq-block-props-accent' as string]: accentColor } as React.CSSProperties)
345
+ : {};
346
+ }
347
+
316
348
  function TransitionOption({
317
349
  entry,
318
350
  selected,