@bendyline/squisq-editor-react 2.1.0 → 2.3.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 (415) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +40 -0
  3. package/dist/chunk-54UGTQBO.js +862 -0
  4. package/dist/chunk-5JMHFAVW.js +2408 -0
  5. package/dist/chunk-5Q4JN4I5.js +132 -0
  6. package/dist/chunk-6VDYKI3L.js +49 -0
  7. package/dist/chunk-GS7QWYFT.js +9 -0
  8. package/dist/chunk-MJJK7YQB.js +949 -0
  9. package/dist/chunk-NITZVAXL.js +986 -0
  10. package/dist/chunk-TRCKHRBS.js +35234 -0
  11. package/dist/chunk-V44VP242.js +3256 -0
  12. package/dist/image-editor/index.d.ts +223 -0
  13. package/dist/image-editor/index.js +15 -0
  14. package/dist/index.d.ts +2060 -4040
  15. package/dist/index.js +496 -40014
  16. package/dist/json-editor/index.d.ts +27 -0
  17. package/dist/json-editor/index.js +7 -0
  18. package/dist/monaco.d.ts +1 -1
  19. package/dist/monaco.js +1 -2
  20. package/dist/recorder/index.d.ts +407 -0
  21. package/dist/recorder/index.js +43 -0
  22. package/dist/shell/index.d.ts +9 -0
  23. package/dist/shell/index.js +22 -0
  24. package/dist/shell-BxSCBm4H.d.ts +1064 -0
  25. package/dist/styles/index.css +598 -161
  26. package/dist/teleprompter/index.d.ts +497 -0
  27. package/dist/teleprompter/index.js +57 -0
  28. package/package.json +34 -8
  29. package/dist/index.js.map +0 -1
  30. package/dist/monaco.js.map +0 -1
  31. package/src/BlockCardView.tsx +0 -121
  32. package/src/BlockPreviewPanel.tsx +0 -69
  33. package/src/BlockPropertiesPopover.tsx +0 -222
  34. package/src/DocumentSettingsDialog.tsx +0 -272
  35. package/src/DropZoneOverlay.tsx +0 -137
  36. package/src/EditorContext.tsx +0 -1274
  37. package/src/EditorShell.tsx +0 -1358
  38. package/src/EmojiPicker.tsx +0 -332
  39. package/src/FolderView.tsx +0 -131
  40. package/src/Icon.tsx +0 -26
  41. package/src/ImageEditor.tsx +0 -395
  42. package/src/ImageNodeView.tsx +0 -284
  43. package/src/ImageViewer.tsx +0 -221
  44. package/src/InlineIcon.ts +0 -84
  45. package/src/InlinePreviewGutter.tsx +0 -590
  46. package/src/LinkDialog.tsx +0 -276
  47. package/src/MediaBin.tsx +0 -500
  48. package/src/MentionExtension.tsx +0 -261
  49. package/src/OutlinePanel.tsx +0 -547
  50. package/src/PlainHtmlPreview.tsx +0 -277
  51. package/src/PreviewControls.tsx +0 -1265
  52. package/src/PreviewPanel.tsx +0 -426
  53. package/src/RawEditor.tsx +0 -743
  54. package/src/RecorderEntry.tsx +0 -162
  55. package/src/StatusBar.tsx +0 -51
  56. package/src/TemplateAnnotation.ts +0 -167
  57. package/src/TemplateContentPreview.tsx +0 -56
  58. package/src/TemplatePicker.tsx +0 -1314
  59. package/src/ThemeCustomizerPanel.tsx +0 -304
  60. package/src/ThemePicker.tsx +0 -433
  61. package/src/TimelineBlockPreview.tsx +0 -37
  62. package/src/TimelineTrack.tsx +0 -671
  63. package/src/Toolbar.tsx +0 -2885
  64. package/src/Tooltip.tsx +0 -142
  65. package/src/TransitionPicker.tsx +0 -390
  66. package/src/VersionHistoryPanel.tsx +0 -359
  67. package/src/ViewMenuPanel.tsx +0 -189
  68. package/src/ViewSwitcher.tsx +0 -56
  69. package/src/WysiwygEditor.tsx +0 -806
  70. package/src/__tests__/blockProperties.test.ts +0 -92
  71. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +0 -33
  72. package/src/__tests__/blockRange.test.ts +0 -105
  73. package/src/__tests__/blockTagActivity.test.ts +0 -183
  74. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +0 -75
  75. package/src/__tests__/buildPreviewDocContent.test.ts +0 -65
  76. package/src/__tests__/buildPreviewDocTransition.test.ts +0 -92
  77. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +0 -60
  78. package/src/__tests__/codeContextSectionView.test.tsx +0 -97
  79. package/src/__tests__/codeContextZoneManager.test.ts +0 -127
  80. package/src/__tests__/createShapeLayer.test.ts +0 -46
  81. package/src/__tests__/customLayoutManagerTheme.test.tsx +0 -34
  82. package/src/__tests__/detectMarkdown.test.ts +0 -73
  83. package/src/__tests__/diffContextSections.test.ts +0 -39
  84. package/src/__tests__/documentSettingsDialog.test.tsx +0 -197
  85. package/src/__tests__/drawingShapeRoundTrip.test.ts +0 -49
  86. package/src/__tests__/editorScrollHandoff.test.tsx +0 -102
  87. package/src/__tests__/editorScrollSync.test.ts +0 -65
  88. package/src/__tests__/editorShellCodeContext.test.tsx +0 -86
  89. package/src/__tests__/editorShellProps.test.tsx +0 -550
  90. package/src/__tests__/embeddedMedia.test.ts +0 -48
  91. package/src/__tests__/emojiPicker.test.tsx +0 -93
  92. package/src/__tests__/fileKind.test.ts +0 -112
  93. package/src/__tests__/findMode.test.tsx +0 -104
  94. package/src/__tests__/findModel.test.ts +0 -55
  95. package/src/__tests__/headingTransition.test.ts +0 -176
  96. package/src/__tests__/imageEditAffordance.test.tsx +0 -272
  97. package/src/__tests__/imageEditModalContract.test.tsx +0 -119
  98. package/src/__tests__/imageEditorShell.test.tsx +0 -94
  99. package/src/__tests__/imageEditorState.test.ts +0 -171
  100. package/src/__tests__/inlinePreviewGutter.test.tsx +0 -62
  101. package/src/__tests__/inlinePreviewGutterAllBlocks.test.tsx +0 -103
  102. package/src/__tests__/jsonEditor.test.tsx +0 -223
  103. package/src/__tests__/layersPanel.test.tsx +0 -167
  104. package/src/__tests__/layoutChildRoundTrip.test.ts +0 -71
  105. package/src/__tests__/linkDialogDocPicker.test.tsx +0 -137
  106. package/src/__tests__/majorApiTypes.test.ts +0 -26
  107. package/src/__tests__/markdownCodeFence.test.ts +0 -45
  108. package/src/__tests__/mediaAttachmentFlow.test.ts +0 -110
  109. package/src/__tests__/mediaBinDrop.test.tsx +0 -90
  110. package/src/__tests__/mediaReferences.test.ts +0 -97
  111. package/src/__tests__/narrationSave.test.ts +0 -148
  112. package/src/__tests__/outlinePanel.test.tsx +0 -169
  113. package/src/__tests__/outlineSource.test.ts +0 -217
  114. package/src/__tests__/plainHtmlPreview.test.tsx +0 -125
  115. package/src/__tests__/presentationMode.test.tsx +0 -290
  116. package/src/__tests__/previewControls.test.tsx +0 -571
  117. package/src/__tests__/previewPanelAudioFailure.test.tsx +0 -46
  118. package/src/__tests__/previewPanelPresentation.test.tsx +0 -126
  119. package/src/__tests__/previewPanelTransforms.test.tsx +0 -117
  120. package/src/__tests__/propertiesPanel.test.tsx +0 -69
  121. package/src/__tests__/rawEditorModelIsolation.test.ts +0 -19
  122. package/src/__tests__/recorderFormats.test.ts +0 -146
  123. package/src/__tests__/recorderMediaInsert.test.ts +0 -86
  124. package/src/__tests__/recorderTheme.test.tsx +0 -42
  125. package/src/__tests__/recorderTimingJson.test.ts +0 -41
  126. package/src/__tests__/resolveBlockVisual.test.ts +0 -43
  127. package/src/__tests__/selectionConversions.test.ts +0 -80
  128. package/src/__tests__/statusBar.test.tsx +0 -27
  129. package/src/__tests__/teleprompter.test.tsx +0 -190
  130. package/src/__tests__/templateAnnotationRoundTrip.test.ts +0 -99
  131. package/src/__tests__/templateContentPreview.test.ts +0 -101
  132. package/src/__tests__/templatePickerMetadata.test.ts +0 -32
  133. package/src/__tests__/templatePickerPortal.test.tsx +0 -62
  134. package/src/__tests__/timelineSource.test.ts +0 -134
  135. package/src/__tests__/tiptapBridge.test.ts +0 -575
  136. package/src/__tests__/tiptapBridgeConformance.test.ts +0 -47
  137. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +0 -109
  138. package/src/__tests__/tiptapImageRoundTrip.test.ts +0 -73
  139. package/src/__tests__/toolbarSelectionConversion.test.tsx +0 -190
  140. package/src/__tests__/tooltip.test.tsx +0 -72
  141. package/src/__tests__/transformStyleId.test.ts +0 -13
  142. package/src/__tests__/transitionCatalog.test.ts +0 -64
  143. package/src/__tests__/useBlockNavigator.test.tsx +0 -67
  144. package/src/__tests__/useImageEditor.test.tsx +0 -226
  145. package/src/__tests__/useJsonEditorTokens.test.ts +0 -59
  146. package/src/__tests__/useMediaRecorder.test.ts +0 -302
  147. package/src/__tests__/useTimelineClock.test.ts +0 -21
  148. package/src/__tests__/versionHistory.test.tsx +0 -197
  149. package/src/__tests__/viewMenuPanel.test.tsx +0 -130
  150. package/src/__tests__/writeCanvasSettings.test.ts +0 -15
  151. package/src/__tests__/wysiwygImageUpload.test.ts +0 -69
  152. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +0 -56
  153. package/src/asciiDiagram/AsciiDiagramExtension.ts +0 -343
  154. package/src/asciiDiagram/AsciiDiagramWidget.tsx +0 -254
  155. package/src/asciiDiagram/RepairableDiagramExtension.ts +0 -200
  156. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +0 -164
  157. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +0 -159
  158. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +0 -284
  159. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +0 -114
  160. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +0 -43
  161. package/src/asciiDiagram/asciiDiagramCommands.ts +0 -174
  162. package/src/asciiDiagram/asciiDiagramData.ts +0 -149
  163. package/src/asciiDiagram/asciiDiagramOps.ts +0 -238
  164. package/src/asciiDiagram/asciiPaste.ts +0 -19
  165. package/src/blockProperties.ts +0 -88
  166. package/src/blockRange.ts +0 -132
  167. package/src/blockSlice.ts +0 -75
  168. package/src/blockTagActivity.ts +0 -233
  169. package/src/buildDocumentPreviewMarkdown.ts +0 -168
  170. package/src/buildPreviewDoc.ts +0 -407
  171. package/src/codeContext/CodeContextSectionView.tsx +0 -124
  172. package/src/codeContext/CodeContextZoneManager.ts +0 -149
  173. package/src/codeContext/CodeContextZones.tsx +0 -121
  174. package/src/codeContext/diffContextSections.ts +0 -38
  175. package/src/codeContext/types.ts +0 -75
  176. package/src/codeSnippet/CodeSnippetExtension.ts +0 -205
  177. package/src/codeSnippet/CodeSnippetWidget.tsx +0 -109
  178. package/src/codeSnippet/__tests__/CodeSnippetExtension.test.ts +0 -95
  179. package/src/codeSnippet/__tests__/codeSnippetLanguages.test.ts +0 -50
  180. package/src/codeSnippet/codeSnippetCommands.ts +0 -21
  181. package/src/codeSnippet/codeSnippetData.ts +0 -43
  182. package/src/codeSnippet/codeSnippetLanguages.ts +0 -216
  183. package/src/customTemplates/AddBin.tsx +0 -126
  184. package/src/customTemplates/CustomLayoutManager.tsx +0 -234
  185. package/src/customTemplates/CustomTemplateContext.tsx +0 -188
  186. package/src/customTemplates/LayerToolbar.tsx +0 -580
  187. package/src/customTemplates/ShapeGlyph.tsx +0 -47
  188. package/src/customTemplates/TemplateDesigner.tsx +0 -433
  189. package/src/customTemplates/__tests__/library.test.ts +0 -96
  190. package/src/customTemplates/__tests__/normalizePositions.test.ts +0 -109
  191. package/src/customTemplates/__tests__/shapeDefs.test.ts +0 -49
  192. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +0 -108
  193. package/src/customTemplates/designer.css +0 -732
  194. package/src/customTemplates/index.ts +0 -31
  195. package/src/customTemplates/library.ts +0 -110
  196. package/src/customTemplates/normalizePositions.ts +0 -75
  197. package/src/customTemplates/shapeDefs.ts +0 -131
  198. package/src/customTemplates/thumbnail.tsx +0 -63
  199. package/src/customTemplates/tokenDefs.ts +0 -60
  200. package/src/customTemplates/useDocCustomTemplates.ts +0 -52
  201. package/src/customTemplates/useMemoryLayerAdapter.ts +0 -129
  202. package/src/customThemes/CustomThemeContext.tsx +0 -185
  203. package/src/customThemes/CustomThemeDialog.tsx +0 -316
  204. package/src/customThemes/ImportThemeSection.tsx +0 -178
  205. package/src/customThemes/__tests__/CustomThemeContext.test.tsx +0 -64
  206. package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +0 -47
  207. package/src/customThemes/__tests__/customThemeLibrary.test.ts +0 -60
  208. package/src/customThemes/__tests__/importThemeSection.test.tsx +0 -192
  209. package/src/customThemes/customThemeLibrary.ts +0 -100
  210. package/src/customThemes/index.ts +0 -37
  211. package/src/customThemes/themeControls.tsx +0 -229
  212. package/src/customThemes/themeDraft.ts +0 -288
  213. package/src/customThemes/useDocCustomThemes.ts +0 -49
  214. package/src/detectMarkdown.ts +0 -62
  215. package/src/diagram/DiagramCanvas.tsx +0 -260
  216. package/src/diagram/DiagramMaximizedOverlay.tsx +0 -46
  217. package/src/diagram/diagramCommands.ts +0 -604
  218. package/src/diagram/diagramConstants.ts +0 -17
  219. package/src/diagram/types.ts +0 -35
  220. package/src/editorScrollSync.ts +0 -73
  221. package/src/embeddedMedia.ts +0 -78
  222. package/src/emojiData.ts +0 -1317
  223. package/src/fileKind.ts +0 -158
  224. package/src/find/FindHighlightExtension.ts +0 -81
  225. package/src/find/FindToolbar.tsx +0 -314
  226. package/src/find/findModel.ts +0 -77
  227. package/src/frontmatter.ts +0 -29
  228. package/src/frontmatterSettings.ts +0 -23
  229. package/src/headingTransition.ts +0 -291
  230. package/src/hooks/useFileDrop.ts +0 -266
  231. package/src/imageEditor/CanvasSurface.tsx +0 -703
  232. package/src/imageEditor/ImageVersionHistoryDropdown.tsx +0 -396
  233. package/src/imageEditor/LayersPanel.tsx +0 -218
  234. package/src/imageEditor/PropertiesPanel.tsx +0 -474
  235. package/src/imageEditor/Toolbar.tsx +0 -459
  236. package/src/imageEditor/createShapeLayer.ts +0 -280
  237. package/src/imageEditor/icons.tsx +0 -68
  238. package/src/imageEditor/image-editor.css +0 -574
  239. package/src/imageEditor/layers/EditorImageLayer.tsx +0 -45
  240. package/src/imageEditor/layers/EditorShapeLayer.tsx +0 -62
  241. package/src/imageEditor/layers/EditorTextLayer.tsx +0 -45
  242. package/src/imageEditor/layers/SelectionHandles.tsx +0 -86
  243. package/src/imageEditor/state.ts +0 -173
  244. package/src/imageEditor/useImageEditor.ts +0 -385
  245. package/src/imageEditor/useImageEditorTokens.ts +0 -70
  246. package/src/index.ts +0 -559
  247. package/src/jsonEditor/EmbeddedRichTextField.tsx +0 -81
  248. package/src/jsonEditor/JsonEditor.tsx +0 -81
  249. package/src/jsonEditor/JsonEditorContext.tsx +0 -81
  250. package/src/jsonEditor/RenderNode.tsx +0 -87
  251. package/src/jsonEditor/editors.tsx +0 -745
  252. package/src/jsonEditor/index.ts +0 -2
  253. package/src/jsonEditor/json-editor.css +0 -463
  254. package/src/jsonEditor/useJsonEditorTokens.ts +0 -33
  255. package/src/markdownCodeFence.ts +0 -72
  256. package/src/mediaDragMime.ts +0 -32
  257. package/src/mediaEntries.ts +0 -12
  258. package/src/mediaReferences.ts +0 -301
  259. package/src/mermaid/MermaidDiagramCanvas.tsx +0 -693
  260. package/src/mermaid/MermaidDiagramExtension.ts +0 -243
  261. package/src/mermaid/MermaidDiagramTypeThumbnail.tsx +0 -184
  262. package/src/mermaid/MermaidDiagramWidget.tsx +0 -653
  263. package/src/mermaid/MermaidShapePalette.tsx +0 -245
  264. package/src/mermaid/__tests__/MermaidDiagramExtension.test.ts +0 -361
  265. package/src/mermaid/__tests__/mermaidDiagramTypes.test.ts +0 -35
  266. package/src/mermaid/__tests__/mermaidRenderer.test.ts +0 -49
  267. package/src/mermaid/__tests__/mermaidSourceOps.test.ts +0 -213
  268. package/src/mermaid/__tests__/mermaidSyntax.test.ts +0 -71
  269. package/src/mermaid/mermaidCommands.ts +0 -34
  270. package/src/mermaid/mermaidData.ts +0 -31
  271. package/src/mermaid/mermaidDiagramTypes.ts +0 -325
  272. package/src/mermaid/mermaidModel.ts +0 -31
  273. package/src/mermaid/mermaidRenderer.ts +0 -181
  274. package/src/mermaid/mermaidShapes.ts +0 -113
  275. package/src/mermaid/mermaidSourceOps.ts +0 -454
  276. package/src/monaco.ts +0 -35
  277. package/src/monacoWorkers.ts +0 -89
  278. package/src/outlineSource.ts +0 -171
  279. package/src/presentation/PresentationMode.tsx +0 -596
  280. package/src/rawEditorIsolation.ts +0 -18
  281. package/src/recorder/RecorderButton.tsx +0 -80
  282. package/src/recorder/RecorderModal.tsx +0 -706
  283. package/src/recorder/RecorderPanel.tsx +0 -77
  284. package/src/recorder/formats.ts +0 -159
  285. package/src/recorder/hooks/useMediaRecorder.ts +0 -436
  286. package/src/recorder/hooks/useStreamPreview.ts +0 -47
  287. package/src/recorder/insertMediaBlock.ts +0 -30
  288. package/src/recorder/sources/cameraStream.ts +0 -32
  289. package/src/recorder/sources/micStream.ts +0 -25
  290. package/src/recorder/sources/screenStream.ts +0 -162
  291. package/src/recorder/timingJson.ts +0 -66
  292. package/src/resolveBlockVisual.ts +0 -39
  293. package/src/scene/Scene.tsx +0 -647
  294. package/src/scene/SceneBlockExtension.ts +0 -205
  295. package/src/scene/SceneBlockToolbar.tsx +0 -216
  296. package/src/scene/SceneBlockWidget.tsx +0 -441
  297. package/src/scene/ScenePropsBar.tsx +0 -85
  298. package/src/scene/SceneSelection.tsx +0 -111
  299. package/src/scene/SceneSideToolbar.tsx +0 -89
  300. package/src/scene/SceneViewControls.tsx +0 -43
  301. package/src/scene/SceneViewport.tsx +0 -106
  302. package/src/scene/ShapePalette.tsx +0 -196
  303. package/src/scene/__tests__/DiagramAdapter.test.ts +0 -142
  304. package/src/scene/__tests__/SceneBlockExtension.test.ts +0 -33
  305. package/src/scene/__tests__/bezierEdit.test.ts +0 -85
  306. package/src/scene/__tests__/blockLayers.test.ts +0 -57
  307. package/src/scene/__tests__/fitScale.test.ts +0 -19
  308. package/src/scene/__tests__/sceneIsolation.test.tsx +0 -119
  309. package/src/scene/__tests__/shapeLayers.test.ts +0 -106
  310. package/src/scene/__tests__/useSceneHitTest.test.ts +0 -90
  311. package/src/scene/__tests__/useScenePanZoom.test.ts +0 -130
  312. package/src/scene/adapters/DiagramAdapter.ts +0 -79
  313. package/src/scene/adapters/DrawingAdapter.ts +0 -420
  314. package/src/scene/adapters/LayoutAdapter.ts +0 -313
  315. package/src/scene/adapters/blockLayers.ts +0 -159
  316. package/src/scene/commands/SceneCommand.ts +0 -84
  317. package/src/scene/commands/drawingCommands.ts +0 -318
  318. package/src/scene/commands/layoutCommands.ts +0 -301
  319. package/src/scene/fitScale.ts +0 -17
  320. package/src/scene/hooks/useSceneHitTest.ts +0 -105
  321. package/src/scene/hooks/useScenePanZoom.ts +0 -154
  322. package/src/scene/hooks/useSceneSelection.ts +0 -62
  323. package/src/scene/index.ts +0 -90
  324. package/src/scene/layers/DiagramEdges.tsx +0 -142
  325. package/src/scene/layers/edgeGeometry.ts +0 -104
  326. package/src/scene/layers/nodeCard.tsx +0 -164
  327. package/src/scene/layers/renderLayer.tsx +0 -70
  328. package/src/scene/layers/shapeLayers.ts +0 -201
  329. package/src/scene/paths/bezierEdit.ts +0 -208
  330. package/src/scene/scene.css +0 -874
  331. package/src/scene/text/SceneTextOverlay.tsx +0 -161
  332. package/src/scene/text/sceneTextChannel.ts +0 -46
  333. package/src/scene/text/sceneTextConfig.ts +0 -31
  334. package/src/scene/text/sceneTiptap.ts +0 -36
  335. package/src/scene/text/useSceneTextEditing.ts +0 -39
  336. package/src/scene/tools/ConnectTool.ts +0 -209
  337. package/src/scene/tools/DrawingConnectTool.ts +0 -231
  338. package/src/scene/tools/PathTool.ts +0 -158
  339. package/src/scene/tools/PlaceTool.ts +0 -47
  340. package/src/scene/tools/SceneTool.ts +0 -85
  341. package/src/scene/tools/SelectTool.ts +0 -300
  342. package/src/scene/tools/ShapeTool.ts +0 -143
  343. package/src/scene/tools/TextTool.ts +0 -72
  344. package/src/scene/tools/TokenTool.ts +0 -95
  345. package/src/scene/tools/createDrawShapeTool.ts +0 -82
  346. package/src/selectionConversions.ts +0 -155
  347. package/src/styles/ascii-diagram.css +0 -79
  348. package/src/styles/ascii-timeline.css +0 -596
  349. package/src/styles/code-context.css +0 -155
  350. package/src/styles/code-snippet.css +0 -76
  351. package/src/styles/diagram.css +0 -183
  352. package/src/styles/editor.css +0 -7131
  353. package/src/styles/folder-view.css +0 -210
  354. package/src/styles/image-edit-affordance.css +0 -201
  355. package/src/styles/index.css +0 -20
  356. package/src/styles/mermaid-diagram.css +0 -487
  357. package/src/styles/tree-view.css +0 -189
  358. package/src/teleprompter/TeleprompterControls.tsx +0 -218
  359. package/src/teleprompter/TeleprompterSelfView.tsx +0 -22
  360. package/src/teleprompter/TeleprompterSurface.tsx +0 -267
  361. package/src/teleprompter/TeleprompterView.tsx +0 -338
  362. package/src/teleprompter/canvasRenderer.ts +0 -161
  363. package/src/teleprompter/floatingWindow.ts +0 -352
  364. package/src/teleprompter/index.ts +0 -61
  365. package/src/teleprompter/pcmWorklet.ts +0 -62
  366. package/src/teleprompter/recording/insertPreamble.ts +0 -80
  367. package/src/teleprompter/recording/narrationSave.ts +0 -134
  368. package/src/teleprompter/recording/useNarrationRecorder.ts +0 -367
  369. package/src/teleprompter/scrollModel.ts +0 -85
  370. package/src/teleprompter/teleprompterTheme.ts +0 -303
  371. package/src/teleprompter/types.ts +0 -38
  372. package/src/teleprompter/useFloatingWindow.ts +0 -74
  373. package/src/teleprompter/useMicAnalysis.ts +0 -211
  374. package/src/teleprompter/useTeleprompter.ts +0 -421
  375. package/src/templateContentPreviewResolver.ts +0 -360
  376. package/src/timeline/TimelineEditorWidget.tsx +0 -861
  377. package/src/timeline/TimelineViewExtension.ts +0 -252
  378. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +0 -414
  379. package/src/timeline/__tests__/TimelineViewExtension.test.ts +0 -163
  380. package/src/timeline/__tests__/timelineCommands.test.ts +0 -359
  381. package/src/timeline/__tests__/timelineOps.test.ts +0 -277
  382. package/src/timeline/__tests__/timelinePaste.test.ts +0 -34
  383. package/src/timeline/timelineCommands.ts +0 -421
  384. package/src/timeline/timelineData.ts +0 -54
  385. package/src/timeline/timelineOps.ts +0 -381
  386. package/src/timeline/timelinePaste.ts +0 -8
  387. package/src/timelineSource.ts +0 -244
  388. package/src/tiptap/TiptapAudio.tsx +0 -86
  389. package/src/tiptap/TiptapVideo.tsx +0 -119
  390. package/src/tiptap/useResolvedMediaSrc.ts +0 -47
  391. package/src/tiptapBridge.ts +0 -986
  392. package/src/tooltipPlacement.ts +0 -13
  393. package/src/transformStyleId.ts +0 -17
  394. package/src/transitionCatalog.ts +0 -159
  395. package/src/treeview/TreeOutlineWidget.tsx +0 -390
  396. package/src/treeview/TreeViewExtension.ts +0 -250
  397. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +0 -156
  398. package/src/treeview/__tests__/TreeViewExtension.test.ts +0 -122
  399. package/src/treeview/__tests__/treeOps.test.ts +0 -177
  400. package/src/treeview/__tests__/treePaste.test.ts +0 -40
  401. package/src/treeview/__tests__/treeViewCommands.test.ts +0 -179
  402. package/src/treeview/treeOps.ts +0 -231
  403. package/src/treeview/treePaste.ts +0 -13
  404. package/src/treeview/treeViewCommands.ts +0 -99
  405. package/src/treeview/treeViewData.ts +0 -41
  406. package/src/types/monaco-shims.d.ts +0 -10
  407. package/src/useBlockNavigator.ts +0 -153
  408. package/src/useHeadingLayout.ts +0 -294
  409. package/src/useMonacoLoader.ts +0 -82
  410. package/src/useTimelineClock.ts +0 -76
  411. package/src/utils/collectInlineFontAwesomeCss.ts +0 -69
  412. package/src/utils/dropUtils.ts +0 -136
  413. package/src/utils/normalizeMalformedAssetUrl.ts +0 -22
  414. package/src/writeCanvasSettings.ts +0 -30
  415. package/src/wysiwygImageUpload.ts +0 -113
package/src/Toolbar.tsx DELETED
@@ -1,2885 +0,0 @@
1
- /**
2
- * Toolbar
3
- *
4
- * Formatting toolbar that provides common markdown editing actions.
5
- * In WYSIWYG mode, uses Tiptap's chain commands to toggle marks / set nodes.
6
- * In Raw mode, appends markdown syntax at the cursor (or end of source).
7
- * Hidden in Preview mode.
8
- */
9
-
10
- import type { ReactNode } from 'react';
11
- import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
12
- import type { Editor as TiptapEditor, JSONContent } from '@tiptap/core';
13
- import { TextSelection } from '@tiptap/pm/state';
14
- import { Fragment } from '@tiptap/pm/model';
15
- import type { IRange } from 'monaco-editor';
16
- import type { Block } from '@bendyline/squisq/schemas';
17
- import { VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
18
- import { DEFAULT_THEME, flattenBlocks } from '@bendyline/squisq/doc';
19
- import {
20
- KNOWN_BLOCK_META_KEYS,
21
- matchTrailingTemplateAnnotation,
22
- splitKeyValueToken,
23
- tokenizeAttrTokens,
24
- } from '@bendyline/squisq/markdown';
25
- import { useEditorContext, type EditorView } from './EditorContext';
26
- import { VersionHistoryPanel } from './VersionHistoryPanel';
27
- import { RecorderEntry } from './RecorderEntry';
28
- import { ViewMenuPanel } from './ViewMenuPanel';
29
- import {
30
- TemplatePicker,
31
- TEMPLATE_NAMES,
32
- TEMPLATE_GALLERY_PORTAL_SELECTOR,
33
- templateLabel,
34
- } from './TemplatePicker';
35
- import { TransitionPicker, TRANSITION_FLYOUT_PORTAL_ID } from './TransitionPicker';
36
- import {
37
- readHeadingLineTransition,
38
- setHeadingLineTransition,
39
- readBlockAttrsTransition,
40
- setHeadingAttrsTransition,
41
- EMPTY_TRANSITION,
42
- type TransitionFields,
43
- } from './headingTransition';
44
- import { profileBlockContents, recommendTemplatesForBlock } from '@bendyline/squisq/recommend';
45
- import { findBlockSliceAtLine, findBlockSliceByHeadingIndex } from './blockSlice';
46
- import { LinkDialog } from './LinkDialog';
47
- import { DocumentSettingsDialog } from './DocumentSettingsDialog';
48
- import { EmojiPicker, EMOJI_PICKER_WIDTH, EMOJI_PICKER_MAX_HEIGHT } from './EmojiPicker';
49
- import { CustomLayoutManager } from './customTemplates/CustomLayoutManager';
50
- import { Icon } from './Icon';
51
- import type { PickerEntry } from './emojiData';
52
- import { createPortal } from 'react-dom';
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';
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';
76
-
77
- const VIEWS: { id: EditorView; label: string; shortLabel?: string; shortcut: string }[] = [
78
- { id: 'wysiwyg', label: 'Write', shortcut: '⌘1' },
79
- { id: 'raw', label: 'Source', shortcut: '⌘2' },
80
- { id: 'preview', label: 'Use', shortcut: '⌘3' },
81
- ];
82
-
83
- const BLOCK_META_KEYS = new Set<string>(Object.keys(KNOWN_BLOCK_META_KEYS));
84
-
85
- export interface ToolbarProps {
86
- /** Additional class name */
87
- className?: string;
88
- /** Whether the Files panel is currently shown */
89
- showFiles?: boolean;
90
- /** Number of files currently available through the MediaProvider. */
91
- fileCount?: number;
92
- /** Toggle the Files panel. When provided, a "Files" button appears in the toolbar. */
93
- onToggleFiles?: () => void;
94
- /** Content rendered at the left edge of the toolbar, before the view tabs. */
95
- slotLeft?: ReactNode;
96
- /** Content rendered immediately after the view tabs, on the left side of the
97
- * toolbar (before the formatting controls). Used for the preview mode
98
- * switch in Use view. */
99
- slotAfterTabs?: ReactNode;
100
- /** Content rendered after the formatting controls (in the middle area). */
101
- slotAfterActions?: ReactNode;
102
- /** Content rendered at the rightmost end of the toolbar, after all other elements. */
103
- slotRight?: ReactNode;
104
- /**
105
- * Whether to include the "Play" (preview) tab in the view switcher.
106
- * Defaults to true. Hosts that don't want the slideshow preview — e.g.
107
- * editing free-form prompts — can pass false to suppress it.
108
- */
109
- showPlayTab?: boolean;
110
- }
111
-
112
- interface ToolbarButton {
113
- id: string;
114
- label: string;
115
- /** Text glyph shown when the button has no Font Awesome icon (headings). */
116
- icon: string;
117
- title: string;
118
- group: 'format' | 'lists' | 'structure' | 'insert' | 'media';
119
- /** Font Awesome class string (e.g. `"fa-solid fa-bold"`); omitted for text buttons. */
120
- faIcon?: string;
121
- }
122
-
123
- const BUTTONS: ToolbarButton[] = [
124
- // Format group — B/I/S trio.
125
- {
126
- id: 'bold',
127
- label: 'B',
128
- icon: 'B',
129
- title: 'Bold (Ctrl+B)',
130
- group: 'format',
131
- faIcon: 'fa-solid fa-bold',
132
- },
133
- {
134
- id: 'italic',
135
- label: 'I',
136
- icon: 'I',
137
- title: 'Italic (Ctrl+I)',
138
- group: 'format',
139
- faIcon: 'fa-solid fa-italic',
140
- },
141
- {
142
- id: 'strikethrough',
143
- label: 'S',
144
- icon: 'S',
145
- title: 'Strikethrough',
146
- group: 'format',
147
- faIcon: 'fa-solid fa-strikethrough',
148
- },
149
-
150
- // Lists group — sits between format and structure so bullets/numbers
151
- // are adjacent to the inline formatters people reach for together.
152
- {
153
- id: 'ul',
154
- label: '•',
155
- icon: '•',
156
- title: 'Bullet list',
157
- group: 'lists',
158
- faIcon: 'fa-solid fa-list-ul',
159
- },
160
- {
161
- id: 'ol',
162
- label: '1.',
163
- icon: '1.',
164
- title: 'Numbered list',
165
- group: 'lists',
166
- faIcon: 'fa-solid fa-list-ol',
167
- },
168
-
169
- // Structure group — headings keep their text labels; Font Awesome Free
170
- // has no numbered (H1–H6) heading glyphs, and the numerals are clearer.
171
- { id: 'h1', label: 'H1', icon: 'H1', title: 'Heading 1', group: 'structure' },
172
- { id: 'h2', label: 'H2', icon: 'H2', title: 'Heading 2', group: 'structure' },
173
- { id: 'h3', label: 'H3', icon: 'H3', title: 'Heading 3', group: 'structure' },
174
- { id: 'h4', label: 'H4', icon: 'H4', title: 'Heading 4', group: 'structure' },
175
- { id: 'h5', label: 'H5', icon: 'H5', title: 'Heading 5', group: 'structure' },
176
- { id: 'h6', label: 'H6', icon: 'H6', title: 'Heading 6', group: 'structure' },
177
-
178
- // Insert group — block-level inserts (quote, code blocks, rules)
179
- {
180
- id: 'quote',
181
- label: '❝',
182
- icon: '❝',
183
- title: 'Blockquote',
184
- group: 'insert',
185
- faIcon: 'fa-solid fa-quote-left',
186
- },
187
- {
188
- id: 'codeblock',
189
- label: '{ }',
190
- icon: '{ }',
191
- title: 'Code block',
192
- group: 'insert',
193
- faIcon: 'fa-solid fa-file-code',
194
- },
195
- {
196
- id: 'code',
197
- label: '</>',
198
- icon: '</>',
199
- title: 'Inline code',
200
- group: 'insert',
201
- faIcon: 'fa-solid fa-code',
202
- },
203
- {
204
- id: 'hr',
205
- label: '—',
206
- icon: '—',
207
- title: 'Horizontal rule',
208
- group: 'insert',
209
- faIcon: 'fa-solid fa-minus',
210
- },
211
-
212
- // Media group — links, tables, images, emoji
213
- {
214
- id: 'link',
215
- label: '🔗',
216
- icon: '🔗',
217
- title: 'Insert link',
218
- group: 'media',
219
- faIcon: 'fa-solid fa-link',
220
- },
221
- {
222
- id: 'table',
223
- label: 'table',
224
- icon: '',
225
- title: 'Insert table',
226
- group: 'media',
227
- faIcon: 'fa-solid fa-table',
228
- },
229
- {
230
- id: 'tasklist',
231
- label: 'tasks',
232
- icon: '',
233
- title: 'Insert Task List',
234
- group: 'media',
235
- faIcon: 'fa-solid fa-list-check',
236
- },
237
- {
238
- id: 'diagram',
239
- label: 'diagram',
240
- icon: '',
241
- title: 'Insert diagram',
242
- group: 'media',
243
- faIcon: 'fa-solid fa-diagram-project',
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
- },
253
- {
254
- id: 'tree',
255
- label: 'tree',
256
- icon: '',
257
- title: 'Insert tree',
258
- group: 'media',
259
- faIcon: 'fa-solid fa-folder-tree',
260
- },
261
- {
262
- id: 'timeline',
263
- label: 'timeline',
264
- icon: '',
265
- title: 'Insert timeline',
266
- group: 'media',
267
- faIcon: 'fa-solid fa-timeline',
268
- },
269
- {
270
- id: 'drawing',
271
- label: 'drawing',
272
- icon: '',
273
- title: 'Insert drawing',
274
- group: 'media',
275
- faIcon: 'fa-solid fa-pen-nib',
276
- },
277
- {
278
- id: 'layout',
279
- label: 'layout',
280
- icon: '',
281
- title: 'Insert layout',
282
- group: 'media',
283
- faIcon: 'fa-solid fa-object-group',
284
- },
285
- {
286
- id: 'image',
287
- label: '🖼',
288
- icon: '🖼',
289
- title: 'Insert image',
290
- group: 'media',
291
- faIcon: 'fa-solid fa-image',
292
- },
293
- {
294
- id: 'emoji',
295
- label: '😊',
296
- icon: '😊',
297
- title: 'Insert emoji',
298
- group: 'media',
299
- faIcon: 'fa-solid fa-face-smile',
300
- },
301
- ];
302
-
303
- const FIRST_MEDIA_INDEX = BUTTONS.findIndex((b) => b.group === 'media');
304
- const MEDIA_BUTTONS = BUTTONS.filter((b) => b.group === 'media');
305
- const CONVERT_BUTTONS = MEDIA_BUTTONS.filter((b) => b.id === 'table' || b.id === 'tasklist');
306
- const INSERT_MENU_WIDTH = 200;
307
- const CODE_SNIPPET_MENU_WIDTH = 220;
308
- const MERMAID_TYPE_MENU_WIDTH = 520;
309
- const TASK_LIST_ITEMS = ['Task 1', 'Task 2', 'Task 3'] as const;
310
- const TASK_LIST_MARKDOWN = TASK_LIST_ITEMS.map((item) => `- [ ] ${item}`).join('\n');
311
-
312
- // BUTTONS position per id — stamped on each rendered button as
313
- // data-btn-index so the overflow measurement can map a clipped DOM button
314
- // back to its BUTTONS entry. Walking a counter over the DOM instead would
315
- // drift whenever some entries render no button of their own (hidden H5/H6
316
- // heading levels, the media group collapsed behind the Insert dropdown).
317
- const BUTTON_INDEX_BY_ID = new Map(BUTTONS.map((b, i) => [b.id, i]));
318
-
319
- /** Renders a button's icon: a Font Awesome glyph when set, else the text label. */
320
- function buttonIcon(btn: ToolbarButton): ReactNode {
321
- return btn.faIcon ? <Icon icon={btn.faIcon} /> : btn.icon;
322
- }
323
-
324
- function fileCountLabel(count: number): string {
325
- return `${count} ${count === 1 ? 'file' : 'files'}`;
326
- }
327
-
328
- function fileCountBadge(count: number): string {
329
- return count > 99 ? '99+' : String(count);
330
- }
331
-
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 {
355
- return {
356
- type: 'taskList',
357
- content: items.map((item) => ({
358
- type: 'taskItem',
359
- attrs: { checked: item.checked },
360
- content: [paragraphContent(item.text)],
361
- })),
362
- };
363
- }
364
-
365
- function insertTaskList(editor: TiptapEditor): void {
366
- const supportsTaskList = !!editor.schema.nodes.taskList && !!editor.schema.nodes.taskItem;
367
- const content = supportsTaskList ? taskListContent() : TASK_LIST_MARKDOWN;
368
- editor.chain().focus().insertContent(content).run();
369
- }
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
-
391
- // ─── Tiptap active-state map ────────────────────────────
392
-
393
- /** Returns true if the given button id is currently active in Tiptap */
394
- function isTiptapActive(editor: TiptapEditor, id: string): boolean {
395
- if (!editor) return false;
396
- switch (id) {
397
- case 'bold':
398
- return editor.isActive('bold');
399
- case 'italic':
400
- return editor.isActive('italic');
401
- case 'strikethrough':
402
- return editor.isActive('strike');
403
- case 'code':
404
- return editor.isActive('code');
405
- case 'h1':
406
- return editor.isActive('heading', { level: 1 });
407
- case 'h2':
408
- return editor.isActive('heading', { level: 2 });
409
- case 'h3':
410
- return editor.isActive('heading', { level: 3 });
411
- case 'h4':
412
- return editor.isActive('heading', { level: 4 });
413
- case 'h5':
414
- return editor.isActive('heading', { level: 5 });
415
- case 'h6':
416
- return editor.isActive('heading', { level: 6 });
417
- case 'quote':
418
- return editor.isActive('blockquote');
419
- case 'ul':
420
- return editor.isActive('bulletList');
421
- case 'ol':
422
- return editor.isActive('orderedList');
423
- case 'codeblock':
424
- return editor.isActive('codeBlock');
425
- default:
426
- return false;
427
- }
428
- }
429
-
430
- // ─── Scene-block inserts (diagram / drawing / layout) ───
431
-
432
- /**
433
- * A freshly-inserted layout starts with one centered text layer so the
434
- * canvas isn't a blank surface. Each layer is a readable child sub-block
435
- * (`### {#id} {[type …]}`); a text layer's content is its markdown body.
436
- * Coordinates are absolute within the 1920×1080 scene viewport
437
- * (`SceneBlockWidget`). See `scene/commands/layoutCommands.ts`.
438
- */
439
- const LAYOUT_STARTER_TEXT_PARAMS =
440
- 'x=360 y=380 width=1200 height=320 fontSize=64 fontWeight=bold align=center valign=middle color="#1e293b"';
441
- /** Multi-line markdown for a new layout (raw / code views). */
442
- const LAYOUT_STARTER_MARKDOWN = `\n## Layout {[layout]}\n\n### {#text-1} {[text ${LAYOUT_STARTER_TEXT_PARAMS}]}\n\nLayout\n`;
443
-
444
- /**
445
- * Starter art for a new diagram — diagrams are authored as ASCII-art code
446
- * fences (the fence is the source of truth; `AsciiDiagramExtension` mounts
447
- * the interactive canvas over it). This exact art is the codec's own
448
- * canonical rendering of a two-node flow, so it re-parses and re-renders
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.
454
- */
455
- const DIAGRAM_STARTER_ART = [
456
- '',
457
- '',
458
- ' ┌─────────┐',
459
- ' │ Start │',
460
- ' └────┬────┘',
461
- ' │',
462
- ' ▼',
463
- ' ┌────┴────┐',
464
- ' │ Next │',
465
- ' └─────────┘',
466
- ].join('\n');
467
- /**
468
- * Fenced form for raw / code views — tagged with the explicit `diagram`
469
- * language so the block's identity survives markdown ↔ Tiptap round-trips
470
- * (the language class round-trips; fence meta does not).
471
- */
472
- const DIAGRAM_STARTER_MARKDOWN = '\n```diagram\n' + DIAGRAM_STARTER_ART + '\n```\n';
473
-
474
- /** Insert a starter ASCII-diagram code fence after the current top-level block. */
475
- function insertAsciiDiagramBlock(editor: TiptapEditor): void {
476
- insertFenceBlock(editor, DIAGRAM_STARTER_ART, 'diagram');
477
- }
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
-
488
- /**
489
- * Starter art for a new file tree — an ASCII tree fence (the fence is the
490
- * source of truth; `TreeViewExtension` mounts the interactive outline).
491
- */
492
- const TREE_STARTER_ART = ['src/', '├── index.ts', '└── utils/', ' └── helpers.ts'].join('\n');
493
- const TREE_STARTER_MARKDOWN = '\n```tree\n' + TREE_STARTER_ART + '\n```\n';
494
-
495
- /** Insert a starter ASCII tree code fence after the current top-level block. */
496
- function insertTreeBlock(editor: TiptapEditor): void {
497
- insertFenceBlock(editor, TREE_STARTER_ART, 'tree');
498
- }
499
-
500
- /** Starter multi-event rail for the authored timeline view. */
501
- const TIMELINE_STARTER_ART =
502
- 'Milestones: ● Start {#start} ─────● Review {#review} ─────● Ship {#ship} ───►';
503
- const TIMELINE_STARTER_MARKDOWN = '\n```timeline\n' + TIMELINE_STARTER_ART + '\n```\n';
504
-
505
- /** Insert a starter ASCII timeline code fence after the current top-level block. */
506
- function insertTimelineBlock(editor: TiptapEditor): void {
507
- insertFenceBlock(editor, TIMELINE_STARTER_ART, 'timeline');
508
- }
509
-
510
- /**
511
- * Insert a code fence after the current top-level block. `lang` tags the
512
- * fence's `language` attribute — pass the explicit authored-view tag so its
513
- * identity round-trips through markdown and Tiptap.
514
- */
515
- function insertFenceBlock(editor: TiptapEditor, art: string, lang?: string): void {
516
- editor
517
- .chain()
518
- .focus()
519
- .command(({ tr, state, dispatch }) => {
520
- const codeBlockType = state.schema.nodes.codeBlock;
521
- if (!codeBlockType) return false;
522
- const attrs = lang ? { language: lang } : null;
523
- const block = codeBlockType.create(attrs, state.schema.text(art));
524
- const { $from } = state.selection;
525
- const insertPos = $from.depth > 0 ? $from.after(1) : state.doc.content.size;
526
- if (dispatch) tr.insert(insertPos, block);
527
- return true;
528
- })
529
- .run();
530
- }
531
-
532
- /**
533
- * Insert a block-level heading carrying a Scene template (`diagram` /
534
- * `drawing` / `layout`) at the top level, after the block the caret sits
535
- * in. Going through a command (rather than `insertContent` at the caret)
536
- * keeps the heading from being coerced into inline text when the caret is
537
- * nested inside a list item or other block. The matching extension
538
- * (DiagramExtension / SceneBlockExtension) then mounts the editable canvas
539
- * right below it.
540
- */
541
- function insertTemplateHeading(
542
- editor: TiptapEditor,
543
- opts: { template: string; text: string; level?: number; blockAttrs?: string | null },
544
- ): void {
545
- editor
546
- .chain()
547
- .focus()
548
- .command(({ tr, state, dispatch }) => {
549
- const headingType = state.schema.nodes.heading;
550
- if (!headingType) return false;
551
- const heading = headingType.create(
552
- {
553
- level: opts.level ?? 2,
554
- dataTemplate: opts.template,
555
- dataBlockAttrs: opts.blockAttrs ?? null,
556
- },
557
- state.schema.text(opts.text),
558
- );
559
- const { $from } = state.selection;
560
- const insertPos = $from.depth > 0 ? $from.after(1) : state.doc.content.size;
561
- if (dispatch) {
562
- tr.insert(insertPos, heading);
563
- // Drop the caret into the new heading's text so it can be renamed.
564
- tr.setSelection(TextSelection.create(tr.doc, insertPos + 1));
565
- }
566
- return true;
567
- })
568
- .run();
569
- }
570
-
571
- /**
572
- * Insert a `{[layout]}` block seeded with one text layer. The layer is a
573
- * child sub-block heading (`### {#text-1} {[text …]}`) whose markdown body
574
- * ("Layout") is the text content — the readable counterpart to the old
575
- * base64 `layers=` blob. The parent + child + body go in as one fragment so
576
- * the layout widget mounts with the seed already present.
577
- */
578
- function insertLayoutBlock(editor: TiptapEditor): void {
579
- editor
580
- .chain()
581
- .focus()
582
- .command(({ tr, state, dispatch }) => {
583
- const headingType = state.schema.nodes.heading;
584
- const paragraphType = state.schema.nodes.paragraph;
585
- if (!headingType || !paragraphType) return false;
586
- const parent = headingType.create(
587
- { level: 2, dataTemplate: 'layout' },
588
- state.schema.text('Layout'),
589
- );
590
- const child = headingType.create({
591
- level: 3,
592
- dataTemplate: 'text',
593
- dataTemplateParams: LAYOUT_STARTER_TEXT_PARAMS,
594
- dataBlockAttrs: '#text-1',
595
- });
596
- const body = paragraphType.create(null, state.schema.text('Layout'));
597
- const { $from } = state.selection;
598
- const insertPos = $from.depth > 0 ? $from.after(1) : state.doc.content.size;
599
- if (dispatch) {
600
- tr.insert(insertPos, Fragment.fromArray([parent, child, body]));
601
- tr.setSelection(TextSelection.create(tr.doc, insertPos + 1));
602
- }
603
- return true;
604
- })
605
- .run();
606
- }
607
-
608
- function findBlockBySourceLine(blocks: readonly Block[], lineNumber: number): Block | null {
609
- for (const block of blocks) {
610
- const pos = block.sourceHeading?.position;
611
- if (!pos) continue;
612
- if (pos.start.line <= lineNumber && pos.end.line >= lineNumber) return block;
613
- }
614
- return null;
615
- }
616
-
617
- function splitTemplateAnnotationParams(text: string): { text: string; params: string[] } {
618
- const match = matchTrailingTemplateAnnotation(text);
619
- if (!match) return { text: text.trimEnd(), params: [] };
620
- const tokens = tokenizeAttrTokens(match.inner.trim());
621
- const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
622
- return {
623
- text: text.slice(0, match.index).trimEnd(),
624
- params: keepBlockMetaParamTokens(tokens.slice(firstIsParam ? 0 : 1)),
625
- };
626
- }
627
-
628
- function buildTemplateAnnotation(template: string, params: string[]): string | null {
629
- const parts = template ? [template, ...params] : params;
630
- return parts.length > 0 ? `{[${parts.join(' ')}]}` : null;
631
- }
632
-
633
- function keepBlockMetaParamTokens(tokens: readonly string[]): string[] {
634
- return tokens.filter((token) => {
635
- const kv = splitKeyValueToken(token);
636
- return kv ? BLOCK_META_KEYS.has(kv.key) : false;
637
- });
638
- }
639
-
640
- function keepBlockMetaParamString(inner: string | null | undefined): string | null {
641
- if (!inner) return null;
642
- const params = keepBlockMetaParamTokens(tokenizeAttrTokens(inner));
643
- return params.length > 0 ? params.join(' ') : null;
644
- }
645
-
646
- /**
647
- * Formatting toolbar.
648
- * - WYSIWYG: calls Tiptap chain commands (toggleBold, etc.)
649
- * - Raw: appends markdown syntax to the source
650
- */
651
- export function Toolbar({
652
- className,
653
- showFiles,
654
- fileCount,
655
- onToggleFiles,
656
- slotLeft,
657
- slotAfterTabs,
658
- slotAfterActions,
659
- slotRight,
660
- showPlayTab = true,
661
- }: ToolbarProps) {
662
- const {
663
- activeView,
664
- setActiveView,
665
- markdownSource,
666
- doc,
667
- setMarkdownSource,
668
- tiptapEditor,
669
- monacoEditor,
670
- activeSceneText,
671
- mediaProvider,
672
- editorMode,
673
- findMode,
674
- setFindMode,
675
- layoutMode,
676
- activeBlockStartLine,
677
- versioning,
678
- allowRecording,
679
- documentLinkProvider,
680
- colorScheme,
681
- mediaRevision,
682
- bumpMediaRevision,
683
- } = useEditorContext();
684
- const previewSettings = usePreviewSettingsOptional();
685
- const [useModeMenuRequest, requestUseModeMenu] = useReducer((count: number) => count + 1, 0);
686
- // When a canvas textbox is being edited, its Tiptap instance takes over
687
- // the formatting buttons; otherwise they drive the document editor. The
688
- // `level` gates which buttons apply (inline labels vs. rich textboxes).
689
- const formattingEditor = activeSceneText?.editor ?? tiptapEditor;
690
- const sceneTextLevel = activeSceneText?.level ?? null;
691
- const isCodeMode = editorMode === 'code';
692
- // In code mode only the raw view is meaningful; the WYSIWYG and Preview
693
- // surfaces aren't mounted, so hide their tabs.
694
- const visibleViews = VIEWS.filter((v) => {
695
- if (isCodeMode) return v.id === 'raw';
696
- if (v.id === 'preview' && !showPlayTab) return false;
697
- return true;
698
- });
699
- const showViewTabs = visibleViews.length > 1;
700
- const [scannedFileCount, setScannedFileCount] = useState(0);
701
- const resolvedFileCount = fileCount ?? scannedFileCount;
702
-
703
- useEffect(() => {
704
- if (fileCount !== undefined) return;
705
- if (!mediaProvider) {
706
- setScannedFileCount(0);
707
- return;
708
- }
709
-
710
- let cancelled = false;
711
- mediaProvider.listMedia().then(
712
- (entries) => {
713
- if (!cancelled) setScannedFileCount(filterVisibleMediaEntries(entries).length);
714
- },
715
- () => {
716
- if (!cancelled) setScannedFileCount(0);
717
- },
718
- );
719
-
720
- return () => {
721
- cancelled = true;
722
- };
723
- }, [fileCount, mediaProvider, mediaRevision, showFiles]);
724
-
725
- // Hidden file input for image picker
726
- const imageInputRef = useRef<HTMLInputElement>(null);
727
-
728
- // Link dialog — shared by WYSIWYG and Raw views.
729
- const [linkDialog, setLinkDialog] = useState<{
730
- mode: 'insert' | 'update';
731
- target: 'wysiwyg' | 'raw';
732
- initialText: string;
733
- initialUrl: string;
734
- /** For target='raw': the range to replace when editing an existing
735
- * [text](url) under the cursor. Null means use the current Monaco
736
- * selection (insert at cursor / wrap selection). */
737
- rawRange: IRange | null;
738
- } | null>(null);
739
-
740
- // Emoji picker — toolbar-anchored popover. We track the trigger
741
- // button's screen rect so the picker can position itself just below
742
- // it via createPortal (the toolbar's overflow:hidden actions row
743
- // would otherwise clip the popover).
744
- const emojiButtonRef = useRef<HTMLButtonElement | null>(null);
745
- const [emojiPickerAnchor, setEmojiPickerAnchor] = useState<{
746
- top: number;
747
- left: number;
748
- } | null>(null);
749
-
750
- const openEmojiPicker = useCallback(() => {
751
- const btn = emojiButtonRef.current;
752
- if (!btn) return;
753
- const rect = btn.getBoundingClientRect();
754
- // Position just below the trigger by default, then clamp into the
755
- // visible viewport so the picker is never clipped on the right or
756
- // bottom — flips above the trigger when there isn't room below.
757
- const gap = 6;
758
- const margin = 8;
759
- const vw = window.innerWidth;
760
- const vh = window.innerHeight;
761
- let left = rect.left;
762
- if (left + EMOJI_PICKER_WIDTH + margin > vw) {
763
- left = Math.max(margin, vw - EMOJI_PICKER_WIDTH - margin);
764
- }
765
- let top = rect.bottom + gap;
766
- if (top + EMOJI_PICKER_MAX_HEIGHT + margin > vh) {
767
- const flipped = rect.top - EMOJI_PICKER_MAX_HEIGHT - gap;
768
- // Prefer flipping above when there's more room there; otherwise
769
- // pin to the top edge with margin and let the picker's own
770
- // maxHeight clip it.
771
- top = flipped >= margin ? flipped : margin;
772
- }
773
- setEmojiPickerAnchor({ top, left });
774
- }, []);
775
-
776
- const closeEmojiPicker = useCallback(() => setEmojiPickerAnchor(null), []);
777
-
778
- // Insert menu — toolbar-anchored dropdown that replaces the individual media-group
779
- // buttons with a single "+" button. Portaled out to avoid overflow:hidden clipping.
780
- const insertMenuButtonRef = useRef<HTMLButtonElement | null>(null);
781
- const insertMenuRef = useRef<HTMLDivElement | null>(null);
782
- const codeSnippetMenuRef = useRef<HTMLDivElement | null>(null);
783
- const mermaidTypeMenuRef = useRef<HTMLDivElement | null>(null);
784
- const [insertMenuAnchor, setInsertMenuAnchor] = useState<{ top: number; left: number } | null>(
785
- null,
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);
795
-
796
- const openInsertMenu = useCallback((trigger?: HTMLElement | null) => {
797
- const btn = trigger ?? insertMenuButtonRef.current;
798
- if (!btn) return;
799
- const rect = btn.getBoundingClientRect();
800
- const gap = 4;
801
- const margin = 8;
802
- const vw = window.innerWidth;
803
- let left = rect.left;
804
- if (left + INSERT_MENU_WIDTH + margin > vw) {
805
- left = Math.max(margin, rect.right - INSERT_MENU_WIDTH);
806
- }
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);
842
- }, []);
843
-
844
- const closeInsertMenu = useCallback(() => {
845
- setInsertMenuAnchor(null);
846
- setCodeSnippetMenuAnchor(null);
847
- setMermaidTypeMenuAnchor(null);
848
- }, []);
849
-
850
- // ── Overflow detection ────────────────────────────────
851
- const actionsRef = useRef<HTMLDivElement>(null);
852
- const [measuredOverflowIndex, setMeasuredOverflowIndex] = useState<number | null>(null);
853
- // '|'-joined data-contextual ids of the contextual groups (template /
854
- // transition pickers, table controls) whose right edge doesn't fit in the
855
- // actions row. Stored as a string so identical measurements bail out of
856
- // re-rendering.
857
- const [clippedContextualKey, setClippedContextualKey] = useState('');
858
- const [showOverflow, setShowOverflow] = useState(false);
859
- const overflowRef = useRef<HTMLDivElement>(null);
860
-
861
- // Document settings (frontmatter) dialog
862
- const [showDocSettings, setShowDocSettings] = useState(false);
863
-
864
- // Custom layout manager dialog (list of doc/library layouts + designer)
865
- const [showLayoutManager, setShowLayoutManager] = useState(false);
866
-
867
- useEffect(() => {
868
- if (!findMode) return;
869
- setShowOverflow(false);
870
- closeInsertMenu();
871
- closeEmojiPicker();
872
- }, [closeEmojiPicker, closeInsertMenu, findMode]);
873
-
874
- const overflowIndex = measuredOverflowIndex;
875
- const clippedContextual = new Set(clippedContextualKey ? clippedContextualKey.split('|') : []);
876
-
877
- useEffect(() => {
878
- const container = actionsRef.current;
879
- if (!container) return;
880
-
881
- const measure = () => {
882
- const containerRight = container.getBoundingClientRect().right;
883
- // Contextual groups aren't BUTTONS entries, so they must not shift the
884
- // btnIndex mapping below — measure them separately.
885
- const children = container.querySelectorAll<HTMLElement>(
886
- ':scope > .squisq-toolbar-group:not(.squisq-toolbar-contextual) > .squisq-toolbar-button',
887
- );
888
- let firstHidden: number | null = null;
889
- children.forEach((child) => {
890
- if (firstHidden !== null) return;
891
- // A button is hidden if its right edge extends past the container.
892
- // Its data-btn-index carries its BUTTONS position (see
893
- // BUTTON_INDEX_BY_ID for why the DOM can't be walked positionally).
894
- if (child.getBoundingClientRect().right > containerRight + 2) {
895
- const index = Number(child.dataset.btnIndex);
896
- firstHidden = Number.isNaN(index) ? null : index;
897
- }
898
- });
899
- setMeasuredOverflowIndex(firstHidden);
900
- // Contextual groups (template/transition pickers, table controls) that
901
- // don't fit move into the overflow menu instead of painting cropped.
902
- const clipped: string[] = [];
903
- container
904
- .querySelectorAll<HTMLElement>(':scope > .squisq-toolbar-contextual')
905
- .forEach((group) => {
906
- if (group.getBoundingClientRect().right > containerRight + 2) {
907
- clipped.push(group.dataset.contextual ?? '');
908
- }
909
- });
910
- setClippedContextualKey(clipped.join('|'));
911
- };
912
-
913
- // Observe the row and every group in it: contextual groups mount/unmount
914
- // with the cursor context and change width with their value, all without
915
- // resizing the flex:1 container itself — a container-only observer would
916
- // go stale.
917
- const ro = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure);
918
- const observeAll = () => {
919
- ro?.disconnect();
920
- ro?.observe(container);
921
- container
922
- .querySelectorAll<HTMLElement>(':scope > .squisq-toolbar-group')
923
- .forEach((group) => ro?.observe(group));
924
- };
925
- const mo = new MutationObserver(() => {
926
- observeAll();
927
- measure();
928
- });
929
- mo.observe(container, { childList: true });
930
- if (!ro) window.addEventListener('resize', measure);
931
- observeAll();
932
- measure();
933
- return () => {
934
- ro?.disconnect();
935
- if (!ro) window.removeEventListener('resize', measure);
936
- mo.disconnect();
937
- };
938
- }, [activeView]);
939
-
940
- // Close overflow menu on outside click. Clicks inside the template
941
- // gallery / transition flyout portals count as inside: those popovers are
942
- // hosted by pickers living in this menu, and closing the menu on their
943
- // mousedown would unmount them before the click completes.
944
- useEffect(() => {
945
- if (!showOverflow) return;
946
- const handleClick = (e: MouseEvent) => {
947
- const target = e.target as Node;
948
- if (overflowRef.current?.contains(target)) return;
949
- if (
950
- target instanceof Element &&
951
- target.closest(`${TEMPLATE_GALLERY_PORTAL_SELECTOR}, #${TRANSITION_FLYOUT_PORTAL_ID}`)
952
- ) {
953
- return;
954
- }
955
- setShowOverflow(false);
956
- };
957
- document.addEventListener('mousedown', handleClick);
958
- return () => document.removeEventListener('mousedown', handleClick);
959
- }, [showOverflow]);
960
-
961
- // Close insert menu on outside click
962
- useEffect(() => {
963
- if (!insertMenuAnchor) return;
964
- const handleClick = (e: MouseEvent) => {
965
- if (insertMenuButtonRef.current?.contains(e.target as Node)) return;
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;
969
- closeInsertMenu();
970
- };
971
- document.addEventListener('mousedown', handleClick);
972
- return () => document.removeEventListener('mousedown', handleClick);
973
- }, [insertMenuAnchor, closeInsertMenu]);
974
-
975
- // Open-up vs open-down: the overflow menu is anchored to its trigger with
976
- // `top: 100%` by default. When the toolbar lives near the bottom of a
977
- // small container (e.g. a chat composer), a downward menu gets clipped.
978
- // Measure on open and flip the anchor to `bottom: 100%` if the space
979
- // above the trigger is larger than the space below.
980
- const [overflowPlacement, setOverflowPlacement] = useState<'down' | 'up'>('down');
981
- useEffect(() => {
982
- if (!showOverflow || !overflowRef.current) return;
983
- const trigger = overflowRef.current.querySelector<HTMLElement>(
984
- '.squisq-toolbar-overflow-trigger',
985
- );
986
- if (!trigger) return;
987
- const rect = trigger.getBoundingClientRect();
988
- const spaceBelow = window.innerHeight - rect.bottom;
989
- const spaceAbove = rect.top;
990
- // Assume a typical menu height; exact measurement is unreliable on first
991
- // open because the menu hasn't rendered yet when this runs.
992
- const ESTIMATED_MENU_HEIGHT = 260;
993
- if (spaceBelow < ESTIMATED_MENU_HEIGHT && spaceAbove > spaceBelow) {
994
- setOverflowPlacement('up');
995
- } else {
996
- setOverflowPlacement('down');
997
- }
998
- }, [showOverflow]);
999
-
1000
- // Force re-render when Tiptap selection or formatting state changes
1001
- const [, forceUpdate] = useReducer((c: number) => c + 1, 0);
1002
- useEffect(() => {
1003
- if (!formattingEditor) return;
1004
- formattingEditor.on('transaction', forceUpdate);
1005
- return () => {
1006
- formattingEditor.off('transaction', forceUpdate);
1007
- };
1008
- }, [formattingEditor]);
1009
-
1010
- // ── Tiptap handler ─────────────────────────────────────
1011
- const handleTiptap = useCallback(
1012
- (id: string) => {
1013
- const tiptapEditor = formattingEditor;
1014
- if (!tiptapEditor) return;
1015
- const chain = tiptapEditor.chain().focus();
1016
- switch (id) {
1017
- case 'bold':
1018
- chain.toggleBold().run();
1019
- break;
1020
- case 'italic':
1021
- chain.toggleItalic().run();
1022
- break;
1023
- case 'strikethrough':
1024
- chain.toggleStrike().run();
1025
- break;
1026
- case 'code':
1027
- chain.toggleCode().run();
1028
- break;
1029
- case 'h1':
1030
- chain.toggleHeading({ level: 1 }).run();
1031
- break;
1032
- case 'h2':
1033
- chain.toggleHeading({ level: 2 }).run();
1034
- break;
1035
- case 'h3':
1036
- chain.toggleHeading({ level: 3 }).run();
1037
- break;
1038
- case 'h4':
1039
- chain.toggleHeading({ level: 4 }).run();
1040
- break;
1041
- case 'h5':
1042
- chain.toggleHeading({ level: 5 }).run();
1043
- break;
1044
- case 'h6':
1045
- chain.toggleHeading({ level: 6 }).run();
1046
- break;
1047
- case 'quote':
1048
- chain.toggleBlockquote().run();
1049
- break;
1050
- case 'ul':
1051
- chain.toggleBulletList().run();
1052
- break;
1053
- case 'ol':
1054
- chain.toggleOrderedList().run();
1055
- break;
1056
- case 'codeblock':
1057
- chain.toggleCodeBlock().run();
1058
- break;
1059
- case 'hr':
1060
- chain.setHorizontalRule().run();
1061
- break;
1062
- case 'link': {
1063
- const isActive = tiptapEditor.isActive('link');
1064
- let initialText = '';
1065
- let initialUrl = '';
1066
- if (isActive) {
1067
- // Snap selection to the full link mark so editing replaces
1068
- // the entire `[text](url)` rather than just the cursor word.
1069
- tiptapEditor.chain().focus().extendMarkRange('link').run();
1070
- const sel = tiptapEditor.state.selection;
1071
- initialText = tiptapEditor.state.doc.textBetween(sel.from, sel.to, ' ');
1072
- initialUrl = (tiptapEditor.getAttributes('link') as { href?: string }).href ?? '';
1073
- } else {
1074
- const { from, to, empty } = tiptapEditor.state.selection;
1075
- if (!empty) {
1076
- initialText = tiptapEditor.state.doc.textBetween(from, to, ' ');
1077
- }
1078
- }
1079
- setLinkDialog({
1080
- mode: isActive ? 'update' : 'insert',
1081
- target: 'wysiwyg',
1082
- initialText,
1083
- initialUrl,
1084
- rawRange: null,
1085
- });
1086
- break;
1087
- }
1088
- case 'table':
1089
- tiptapEditor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
1090
- break;
1091
- case 'tasklist':
1092
- insertTaskList(tiptapEditor);
1093
- break;
1094
- case 'diagram':
1095
- // Diagrams are ASCII-art code fences; the AsciiDiagramExtension
1096
- // mounts the interactive canvas over the inserted starter.
1097
- insertAsciiDiagramBlock(tiptapEditor);
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;
1104
- case 'tree':
1105
- // File trees are ASCII tree fences; TreeViewExtension mounts the
1106
- // interactive outline over the inserted starter.
1107
- insertTreeBlock(tiptapEditor);
1108
- break;
1109
- case 'timeline':
1110
- // The authored fence is immediately replaced by the WYSIWYG rail
1111
- // canvas; Preview uses the same events as vector layers.
1112
- insertTimelineBlock(tiptapEditor);
1113
- break;
1114
- case 'drawing':
1115
- // Empty drawing is usable via the canvas's "Shapes ▾" palette;
1116
- // each shape persists as a child heading.
1117
- insertTemplateHeading(tiptapEditor, { template: 'drawing', text: 'Drawing' });
1118
- break;
1119
- case 'layout':
1120
- // Seed with a starter text layer (a child sub-block) so the canvas
1121
- // isn't blank; further layers add via the toolbar Text/Box buttons.
1122
- insertLayoutBlock(tiptapEditor);
1123
- break;
1124
- }
1125
- },
1126
- [formattingEditor],
1127
- );
1128
-
1129
- // ── Raw markdown handler ───────────────────────────────
1130
- const handleRaw = useCallback(
1131
- (id: string) => {
1132
- if (monacoEditor) {
1133
- // Use Monaco's selection API for proper wrap/insert behavior
1134
- const selection = monacoEditor.getSelection();
1135
- const model = monacoEditor.getModel();
1136
- if (!selection || !model) return;
1137
-
1138
- const selectedText = model.getValueInRange(selection);
1139
- const hasSelection = selectedText.length > 0;
1140
-
1141
- let replacement = '';
1142
- let newCursorOffset = 0; // offset from start of replacement to place cursor
1143
-
1144
- // Inline wrapping: wrap selection or insert placeholder
1145
- const wrapInline = (before: string, after: string, placeholder: string) => {
1146
- if (hasSelection) {
1147
- replacement = before + selectedText + after;
1148
- } else {
1149
- replacement = before + placeholder + after;
1150
- // Select the placeholder text after insertion
1151
- newCursorOffset = before.length;
1152
- }
1153
- };
1154
-
1155
- // Block-level: prefix each selected line, or insert a new block
1156
- const prefixLines = (prefix: string, placeholder: string) => {
1157
- if (hasSelection) {
1158
- replacement = selectedText
1159
- .split('\n')
1160
- .map((line: string) => prefix + line)
1161
- .join('\n');
1162
- } else {
1163
- replacement = prefix + placeholder;
1164
- newCursorOffset = prefix.length;
1165
- }
1166
- };
1167
-
1168
- switch (id) {
1169
- case 'bold':
1170
- wrapInline('**', '**', 'bold text');
1171
- break;
1172
- case 'italic':
1173
- wrapInline('*', '*', 'italic text');
1174
- break;
1175
- case 'strikethrough':
1176
- wrapInline('~~', '~~', 'strikethrough');
1177
- break;
1178
- case 'code':
1179
- wrapInline('`', '`', 'code');
1180
- break;
1181
- case 'h1':
1182
- prefixLines('# ', 'Heading 1');
1183
- break;
1184
- case 'h2':
1185
- prefixLines('## ', 'Heading 2');
1186
- break;
1187
- case 'h3':
1188
- prefixLines('### ', 'Heading 3');
1189
- break;
1190
- case 'h4':
1191
- prefixLines('#### ', 'Heading 4');
1192
- break;
1193
- case 'h5':
1194
- prefixLines('##### ', 'Heading 5');
1195
- break;
1196
- case 'h6':
1197
- prefixLines('###### ', 'Heading 6');
1198
- break;
1199
- case 'quote':
1200
- prefixLines('> ', 'Quote');
1201
- break;
1202
- case 'ul':
1203
- prefixLines('- ', 'Item');
1204
- break;
1205
- case 'ol':
1206
- prefixLines('1. ', 'Item');
1207
- break;
1208
- case 'codeblock': {
1209
- const inner = hasSelection ? selectedText : 'code';
1210
- replacement = '```\n' + inner + '\n```';
1211
- if (!hasSelection) newCursorOffset = 4; // after ```\n
1212
- break;
1213
- }
1214
- case 'hr': {
1215
- replacement = '\n---\n';
1216
- break;
1217
- }
1218
- case 'link': {
1219
- // Open the LinkDialog instead of inserting literal text. If the
1220
- // cursor sits inside an existing `[text](url)` on this line,
1221
- // prefill from it and replace the whole match on confirm.
1222
- const lineNumber = selection.startLineNumber;
1223
- const lineText = model.getLineContent(lineNumber);
1224
- const cursorCol = selection.startColumn;
1225
- const linkRe = /\[([^\]]*)\]\(([^)]*)\)/g;
1226
- let match: RegExpExecArray | null;
1227
- let existing: { text: string; url: string; range: IRange } | null = null;
1228
- while ((match = linkRe.exec(lineText)) !== null) {
1229
- const startCol = match.index + 1; // 1-based
1230
- const endCol = startCol + match[0].length;
1231
- if (cursorCol >= startCol && cursorCol <= endCol) {
1232
- existing = {
1233
- text: match[1],
1234
- url: match[2],
1235
- range: {
1236
- startLineNumber: lineNumber,
1237
- startColumn: startCol,
1238
- endLineNumber: lineNumber,
1239
- endColumn: endCol,
1240
- },
1241
- };
1242
- break;
1243
- }
1244
- }
1245
- setLinkDialog({
1246
- mode: existing ? 'update' : 'insert',
1247
- target: 'raw',
1248
- initialText: existing ? existing.text : hasSelection ? selectedText : '',
1249
- initialUrl: existing ? existing.url : '',
1250
- rawRange: existing ? existing.range : null,
1251
- });
1252
- // Skip the executeEdits/setPosition tail below — the dialog will
1253
- // apply its own edit on confirm.
1254
- return;
1255
- }
1256
- case 'table': {
1257
- const tpl =
1258
- '| Header 1 | Header 2 | Header 3 |\n| --- | --- | --- |\n| Cell | Cell | Cell |\n| Cell | Cell | Cell |';
1259
- replacement = '\n' + tpl + '\n';
1260
- newCursorOffset = 3; // after \n|
1261
- break;
1262
- }
1263
- case 'tasklist': {
1264
- replacement = `\n${TASK_LIST_MARKDOWN}\n`;
1265
- newCursorOffset = replacement.length;
1266
- break;
1267
- }
1268
- case 'diagram': {
1269
- // A diagram is an ASCII-art code fence; the WYSIWYG view
1270
- // renders its editable canvas, preview renders it as a slide.
1271
- replacement = DIAGRAM_STARTER_MARKDOWN;
1272
- newCursorOffset = replacement.length;
1273
- break;
1274
- }
1275
- case 'complexdiagram': {
1276
- replacement = MERMAID_STARTER_MARKDOWN;
1277
- newCursorOffset = replacement.length;
1278
- break;
1279
- }
1280
- case 'tree': {
1281
- replacement = TREE_STARTER_MARKDOWN;
1282
- newCursorOffset = replacement.length;
1283
- break;
1284
- }
1285
- case 'timeline': {
1286
- replacement = TIMELINE_STARTER_MARKDOWN;
1287
- newCursorOffset = replacement.length;
1288
- break;
1289
- }
1290
- case 'drawing': {
1291
- replacement = '\n## Drawing {[drawing]}\n';
1292
- newCursorOffset = replacement.length;
1293
- break;
1294
- }
1295
- case 'layout': {
1296
- // Seed a starter text layer (a child sub-block) so it isn't blank.
1297
- replacement = LAYOUT_STARTER_MARKDOWN;
1298
- newCursorOffset = replacement.length;
1299
- break;
1300
- }
1301
- }
1302
-
1303
- // Apply the edit via Monaco's executeEdits for proper undo support
1304
- const range = selection;
1305
- monacoEditor.executeEdits('toolbar', [{ range, text: replacement }]);
1306
-
1307
- // If no selection, move the cursor to the command's preferred edit point.
1308
- if (!hasSelection && newCursorOffset > 0) {
1309
- const startPos = model.getPositionAt(
1310
- model.getOffsetAt(range.getStartPosition()) + newCursorOffset,
1311
- );
1312
- monacoEditor.setPosition(startPos);
1313
- monacoEditor.revealPositionInCenterIfOutsideViewport(startPos);
1314
- }
1315
-
1316
- monacoEditor.focus();
1317
- } else {
1318
- // Fallback: no Monaco instance, just append
1319
- let insertion = '';
1320
- switch (id) {
1321
- case 'bold':
1322
- insertion = '**bold text**';
1323
- break;
1324
- case 'italic':
1325
- insertion = '*italic text*';
1326
- break;
1327
- case 'strikethrough':
1328
- insertion = '~~strikethrough~~';
1329
- break;
1330
- case 'code':
1331
- insertion = '`code`';
1332
- break;
1333
- case 'h1':
1334
- insertion = '\n# Heading 1\n';
1335
- break;
1336
- case 'h2':
1337
- insertion = '\n## Heading 2\n';
1338
- break;
1339
- case 'h3':
1340
- insertion = '\n### Heading 3\n';
1341
- break;
1342
- case 'quote':
1343
- insertion = '\n> Quote\n';
1344
- break;
1345
- case 'ul':
1346
- insertion = '\n- Item\n';
1347
- break;
1348
- case 'ol':
1349
- insertion = '\n1. Item\n';
1350
- break;
1351
- case 'codeblock':
1352
- insertion = '\n```\ncode\n```\n';
1353
- break;
1354
- case 'hr':
1355
- insertion = '\n---\n';
1356
- break;
1357
- case 'link':
1358
- insertion = '[link text](url)';
1359
- break;
1360
- case 'table':
1361
- insertion =
1362
- '\n| Header 1 | Header 2 | Header 3 |\n| --- | --- | --- |\n| Cell | Cell | Cell |\n| Cell | Cell | Cell |\n';
1363
- break;
1364
- case 'tasklist':
1365
- insertion = `\n${TASK_LIST_MARKDOWN}\n`;
1366
- break;
1367
- case 'diagram':
1368
- insertion = DIAGRAM_STARTER_MARKDOWN;
1369
- break;
1370
- case 'complexdiagram':
1371
- insertion = MERMAID_STARTER_MARKDOWN;
1372
- break;
1373
- case 'tree':
1374
- insertion = TREE_STARTER_MARKDOWN;
1375
- break;
1376
- case 'timeline':
1377
- insertion = TIMELINE_STARTER_MARKDOWN;
1378
- break;
1379
- case 'drawing':
1380
- insertion = '\n## Drawing {[drawing]}\n';
1381
- break;
1382
- case 'layout':
1383
- insertion = LAYOUT_STARTER_MARKDOWN;
1384
- break;
1385
- }
1386
- if (insertion) {
1387
- setMarkdownSource(markdownSource + insertion);
1388
- }
1389
- }
1390
- },
1391
- [monacoEditor, markdownSource, setMarkdownSource],
1392
- );
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
-
1525
- // ── Image upload handler ───────────────────────────────
1526
- const handleImageFile = useCallback(
1527
- async (file: File) => {
1528
- if (!mediaProvider) return;
1529
- const buffer = await file.arrayBuffer();
1530
- const relativePath = await mediaProvider.addMedia(file.name, buffer, file.type);
1531
- bumpMediaRevision();
1532
- const altText = file.name.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
1533
-
1534
- if (activeView === 'wysiwyg' && tiptapEditor) {
1535
- tiptapEditor.chain().focus().setImage({ src: relativePath, alt: altText }).run();
1536
- } else if (monacoEditor) {
1537
- const selection = monacoEditor.getSelection();
1538
- const model = monacoEditor.getModel();
1539
- if (selection && model) {
1540
- const md = `![${altText}](${relativePath})`;
1541
- monacoEditor.executeEdits('toolbar', [{ range: selection, text: md }]);
1542
- monacoEditor.focus();
1543
- }
1544
- } else {
1545
- setMarkdownSource(markdownSource + `\n![${altText}](${relativePath})\n`);
1546
- }
1547
- },
1548
- [
1549
- mediaProvider,
1550
- activeView,
1551
- tiptapEditor,
1552
- monacoEditor,
1553
- markdownSource,
1554
- setMarkdownSource,
1555
- bumpMediaRevision,
1556
- ],
1557
- );
1558
-
1559
- const handleAction = useCallback(
1560
- (id: string) => {
1561
- if (id === 'image') {
1562
- imageInputRef.current?.click();
1563
- return;
1564
- }
1565
- if (id === 'emoji') {
1566
- // Toggle the popover: clicking the button again closes it.
1567
- if (emojiPickerAnchor) closeEmojiPicker();
1568
- else openEmojiPicker();
1569
- return;
1570
- }
1571
- // A focused canvas textbox takes the formatting (handleTiptap targets
1572
- // `formattingEditor`); otherwise route to the document editor.
1573
- if (activeSceneText || (activeView === 'wysiwyg' && tiptapEditor)) {
1574
- handleTiptap(id);
1575
- } else {
1576
- handleRaw(id);
1577
- }
1578
- },
1579
- [
1580
- activeView,
1581
- tiptapEditor,
1582
- activeSceneText,
1583
- handleTiptap,
1584
- handleRaw,
1585
- emojiPickerAnchor,
1586
- openEmojiPicker,
1587
- closeEmojiPicker,
1588
- ],
1589
- );
1590
-
1591
- // ── Picker insert (emoji or FontAwesome icon) ──────
1592
- // Inserts a chosen picker entry at the cursor. We bypass
1593
- // `insertAtCursor` (which routes through markdown→Tiptap conversion
1594
- // and wraps the input in a paragraph) so entries land inline at the
1595
- // caret rather than starting a new block. Emoji insert as a plain
1596
- // character; FontAwesome icons insert as the `InlineIcon` Tiptap
1597
- // node so the editor renders them inline immediately.
1598
- const handleEmojiSelect = useCallback(
1599
- (entry: PickerEntry) => {
1600
- if (activeView === 'wysiwyg' && tiptapEditor) {
1601
- if (entry.kind === 'emoji') {
1602
- tiptapEditor.chain().focus().insertContent(entry.char).run();
1603
- } else {
1604
- tiptapEditor
1605
- .chain()
1606
- .focus()
1607
- .insertContent({
1608
- type: 'inlineIcon',
1609
- attrs: { token: entry.token, family: entry.family, name: entry.name },
1610
- })
1611
- .run();
1612
- }
1613
- } else if (activeView === 'raw' && monacoEditor) {
1614
- const insertion = entry.kind === 'emoji' ? entry.char : `{[${entry.token}]}`;
1615
- const position = monacoEditor.getPosition();
1616
- if (position) {
1617
- const range = {
1618
- startLineNumber: position.lineNumber,
1619
- startColumn: position.column,
1620
- endLineNumber: position.lineNumber,
1621
- endColumn: position.column,
1622
- };
1623
- monacoEditor.executeEdits('picker-insert', [{ range, text: insertion }]);
1624
- monacoEditor.focus();
1625
- } else {
1626
- setMarkdownSource(markdownSource + insertion);
1627
- }
1628
- } else {
1629
- const insertion = entry.kind === 'emoji' ? entry.char : `{[${entry.token}]}`;
1630
- setMarkdownSource(markdownSource + insertion);
1631
- }
1632
- closeEmojiPicker();
1633
- },
1634
- [activeView, tiptapEditor, monacoEditor, markdownSource, setMarkdownSource, closeEmojiPicker],
1635
- );
1636
-
1637
- // ── Ctrl+K / Cmd+K → open the link dialog ────────────
1638
- // Mirrors the behaviour of common editors (Word, Google Docs, VS Code's
1639
- // Markdown preview): if the cursor is in a Squisq editor surface, the
1640
- // shortcut routes through the same handler the toolbar Link button uses,
1641
- // which prefills the dialog from the current selection (or the link
1642
- // under the cursor) before opening.
1643
- useEffect(() => {
1644
- const onKeyDown = (e: KeyboardEvent) => {
1645
- if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return;
1646
- if (e.key.toLowerCase() !== 'k') return;
1647
- const target = e.target as HTMLElement | null;
1648
- if (!target) return;
1649
- // Only intercept when focus is inside one of our editor surfaces.
1650
- const inEditor = !!target.closest(
1651
- '.squisq-wysiwyg-editor, .ProseMirror, .squisq-raw-editor-container, .monaco-editor',
1652
- );
1653
- if (!inEditor) return;
1654
- e.preventDefault();
1655
- e.stopPropagation();
1656
- handleAction('link');
1657
- };
1658
- window.addEventListener('keydown', onKeyDown, true);
1659
- return () => window.removeEventListener('keydown', onKeyDown, true);
1660
- }, [handleAction]);
1661
-
1662
- // ── Link dialog confirm ──────────────────────────────
1663
- const handleLinkConfirm = useCallback(
1664
- (text: string, url: string) => {
1665
- if (!linkDialog) return;
1666
- const trimmedUrl = url.trim();
1667
- const trimmedText = text.trim();
1668
-
1669
- // Target the focused canvas textbox editor when one is active, else
1670
- // the document editor (the dialog was opened from the same target).
1671
- const wysiwygTarget = formattingEditor;
1672
- if (linkDialog.target === 'wysiwyg' && wysiwygTarget) {
1673
- if (!trimmedUrl) {
1674
- // Empty URL on update = unlink. On insert with no URL, do nothing.
1675
- if (linkDialog.mode === 'update') {
1676
- wysiwygTarget.chain().focus().unsetLink().run();
1677
- }
1678
- setLinkDialog(null);
1679
- return;
1680
- }
1681
- const visibleText = trimmedText || trimmedUrl;
1682
- const chain = wysiwygTarget.chain().focus();
1683
- // Insert (or replace selection) with text carrying a link mark. When
1684
- // updating an existing link, the selection was extended to the full
1685
- // mark range earlier, so this replaces the entire `[text](url)`.
1686
- chain
1687
- .insertContent({
1688
- type: 'text',
1689
- text: visibleText,
1690
- marks: [{ type: 'link', attrs: { href: trimmedUrl } }],
1691
- })
1692
- .run();
1693
- setLinkDialog(null);
1694
- return;
1695
- }
1696
-
1697
- if (linkDialog.target === 'raw' && monacoEditor) {
1698
- const model = monacoEditor.getModel();
1699
- if (!model) {
1700
- setLinkDialog(null);
1701
- return;
1702
- }
1703
- if (!trimmedUrl && linkDialog.mode === 'update' && linkDialog.rawRange) {
1704
- // Empty URL on update = strip the markdown link, keep the text.
1705
- monacoEditor.executeEdits('toolbar-link-edit', [
1706
- { range: linkDialog.rawRange, text: trimmedText || linkDialog.initialText },
1707
- ]);
1708
- monacoEditor.focus();
1709
- setLinkDialog(null);
1710
- return;
1711
- }
1712
- if (!trimmedUrl) {
1713
- setLinkDialog(null);
1714
- return;
1715
- }
1716
- const visibleText = trimmedText || trimmedUrl;
1717
- const replacement = `[${visibleText}](${trimmedUrl})`;
1718
- const range = linkDialog.rawRange ?? monacoEditor.getSelection();
1719
- if (!range) {
1720
- setLinkDialog(null);
1721
- return;
1722
- }
1723
- monacoEditor.executeEdits('toolbar-link-edit', [{ range, text: replacement }]);
1724
- monacoEditor.focus();
1725
- setLinkDialog(null);
1726
- return;
1727
- }
1728
-
1729
- setLinkDialog(null);
1730
- },
1731
- [linkDialog, formattingEditor, monacoEditor],
1732
- );
1733
-
1734
- const groups = ['format', 'lists', 'structure', 'insert'] as const;
1735
- const isWysiwyg = activeView === 'wysiwyg' && tiptapEditor;
1736
- const isPreview = activeView === 'preview';
1737
-
1738
- // Formatting buttons reflect/drive the focused canvas textbox when one is
1739
- // active, else the document's WYSIWYG editor.
1740
- const formatActive = !!activeSceneText || isWysiwyg;
1741
- // Inline-level textboxes (diagram/drawing labels) only support inline
1742
- // marks — grey out block formatters so the toolbar reflects what applies.
1743
- const INLINE_SCENE_BUTTONS = new Set([
1744
- 'bold',
1745
- 'italic',
1746
- 'strikethrough',
1747
- 'code',
1748
- 'link',
1749
- 'emoji',
1750
- ]);
1751
- // `block`-level textboxes (layout) add lists/blockquote/code block on top of
1752
- // the inline marks — but NOT headings (their content is a sub-block body, so
1753
- // a heading there would re-parse as a sibling sub-block).
1754
- const BLOCK_SCENE_BUTTONS = new Set([...INLINE_SCENE_BUTTONS, 'ul', 'ol', 'quote', 'codeblock']);
1755
- const buttonAllowed = (id: string): boolean => {
1756
- if (!sceneTextLevel) return true;
1757
- if (sceneTextLevel === 'rich') return true;
1758
- if (sceneTextLevel === 'block') return BLOCK_SCENE_BUTTONS.has(id);
1759
- return INLINE_SCENE_BUTTONS.has(id);
1760
- };
1761
- const showConvertActions =
1762
- insertMenuAnchor !== null &&
1763
- readSelectedText().trim().length > 0 &&
1764
- CONVERT_BUTTONS.some((button) => buttonAllowed(button.id));
1765
-
1766
- // ── Progressive heading disclosure ───────────────────
1767
- // H1\u2013H3 are always visible. H4 appears once the document already
1768
- // contains an H3, H5 once it contains an H4, and H6 once it contains
1769
- // an H5. This keeps the toolbar compact for typical short documents
1770
- // while letting deeply nested documents reach every level.
1771
- const maxHeadingLevelInDoc = useMemo(() => {
1772
- if (!markdownSource) return 0;
1773
- let max = 0;
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];
1779
- const line = rawLine.trimEnd();
1780
- const m = /^(#{1,6})\s+\S/.exec(line);
1781
- if (m && m[1].length > max) max = m[1].length;
1782
- }
1783
- return max;
1784
- }, [markdownSource]);
1785
- // Show H(n+1) when the document already contains H(n), starting from H3.
1786
- const visibleHeadingMax = Math.min(6, Math.max(3, maxHeadingLevelInDoc + 1));
1787
- const isButtonVisible = (id: string): boolean => {
1788
- const m = /^h([1-6])$/.exec(id);
1789
- if (!m) return true;
1790
- return Number(m[1]) <= visibleHeadingMax;
1791
- };
1792
- const hasVisibleMediaButtons = MEDIA_BUTTONS.some((b) => isButtonVisible(b.id));
1793
- const showInsertInOverflow =
1794
- overflowIndex !== null && overflowIndex <= FIRST_MEDIA_INDEX && hasVisibleMediaButtons;
1795
-
1796
- // Detect whether cursor is inside a table (WYSIWYG mode only)
1797
- const isInTable = isWysiwyg ? tiptapEditor.isActive('table') : false;
1798
-
1799
- // Detect current heading template (WYSIWYG mode only)
1800
- const wysiwygTemplate = isWysiwyg
1801
- ? tiptapEditor.isActive('heading')
1802
- ? (tiptapEditor.getAttributes('heading')?.dataTemplate ?? '')
1803
- : null
1804
- : null;
1805
-
1806
- // ── Monaco heading detection (Markdown view) ─────────────────────
1807
- // Watch the Monaco cursor and surface the template picker whenever the
1808
- // cursor is on a heading line. `null` hides the picker; '' shows it
1809
- // with no template selected; any other string is the current template.
1810
- const isRawView = activeView === 'raw';
1811
- const [rawTemplate, setRawTemplate] = useState<string | null>(null);
1812
- const [rawHeadingLine, setRawHeadingLine] = useState<number | null>(null);
1813
- const [rawTransition, setRawTransition] = useState<TransitionFields>(EMPTY_TRANSITION);
1814
- useEffect(() => {
1815
- if (!isRawView || !monacoEditor) {
1816
- setRawTemplate(null);
1817
- setRawHeadingLine(null);
1818
- setRawTransition(EMPTY_TRANSITION);
1819
- return;
1820
- }
1821
- const recompute = () => {
1822
- const model = monacoEditor.getModel();
1823
- const pos = monacoEditor.getPosition();
1824
- if (!model || !pos) {
1825
- setRawTemplate(null);
1826
- setRawHeadingLine(null);
1827
- setRawTransition(EMPTY_TRANSITION);
1828
- return;
1829
- }
1830
- if (isMarkdownFencedCodeLine(model.getValue(), pos.lineNumber)) {
1831
- setRawTemplate(null);
1832
- setRawHeadingLine(null);
1833
- setRawTransition(EMPTY_TRANSITION);
1834
- return;
1835
- }
1836
- const line = model.getLineContent(pos.lineNumber);
1837
- const headingMatch = line.match(/^#{1,6}\s+(.+)$/);
1838
- if (!headingMatch) {
1839
- setRawTemplate(null);
1840
- setRawHeadingLine(null);
1841
- setRawTransition(EMPTY_TRANSITION);
1842
- return;
1843
- }
1844
- setRawHeadingLine(pos.lineNumber);
1845
- setRawTransition(readHeadingLineTransition(line));
1846
- const annotMatch = matchTrailingTemplateAnnotation(headingMatch[1]);
1847
- if (annotMatch) {
1848
- const tokens = tokenizeAttrTokens(annotMatch.inner.trim());
1849
- const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
1850
- setRawTemplate(firstIsParam ? '' : (tokens[0] ?? ''));
1851
- } else {
1852
- setRawTemplate('');
1853
- }
1854
- };
1855
- recompute();
1856
- const cursorSub = monacoEditor.onDidChangeCursorPosition(recompute);
1857
- const contentSub = monacoEditor.onDidChangeModelContent(recompute);
1858
- return () => {
1859
- cursorSub.dispose();
1860
- contentSub.dispose();
1861
- };
1862
- }, [isRawView, monacoEditor]);
1863
-
1864
- // Track the index of the heading the WYSIWYG cursor is in among all
1865
- // top-level headings. Used to locate the same heading in the markdown
1866
- // source for content-based template recommendations.
1867
- const [wysiwygHeadingIndex, setWysiwygHeadingIndex] = useState<number | null>(null);
1868
- useEffect(() => {
1869
- if (!isWysiwyg || !tiptapEditor) {
1870
- setWysiwygHeadingIndex(null);
1871
- return;
1872
- }
1873
- const recompute = () => {
1874
- if (!tiptapEditor.isActive('heading')) {
1875
- setWysiwygHeadingIndex(null);
1876
- return;
1877
- }
1878
- const cursor = tiptapEditor.state.selection.from;
1879
- let index = -1;
1880
- let count = 0;
1881
- tiptapEditor.state.doc.descendants((node, pos) => {
1882
- if (node.type.name !== 'heading') return;
1883
- if (pos <= cursor && pos + node.nodeSize > cursor) {
1884
- index = count;
1885
- return false;
1886
- }
1887
- count++;
1888
- });
1889
- setWysiwygHeadingIndex(index >= 0 ? index : null);
1890
- };
1891
- recompute();
1892
- tiptapEditor.on('selectionUpdate', recompute);
1893
- tiptapEditor.on('update', recompute);
1894
- return () => {
1895
- tiptapEditor.off('selectionUpdate', recompute);
1896
- tiptapEditor.off('update', recompute);
1897
- };
1898
- }, [isWysiwyg, tiptapEditor]);
1899
-
1900
- const currentTemplate = isWysiwyg ? wysiwygTemplate : isRawView ? rawTemplate : null;
1901
-
1902
- // Compute recommended templates for the active block. Heading slice
1903
- // comes from markdownSource — raw view supplies the cursor line,
1904
- // WYSIWYG supplies the heading index.
1905
- const recommendedTemplates = useMemo(() => {
1906
- if (currentTemplate === null) return undefined;
1907
- let slice = null;
1908
- if (isRawView && rawHeadingLine !== null) {
1909
- slice = findBlockSliceAtLine(markdownSource, rawHeadingLine);
1910
- } else if (isWysiwyg && wysiwygHeadingIndex !== null) {
1911
- slice = findBlockSliceByHeadingIndex(markdownSource, wysiwygHeadingIndex);
1912
- }
1913
- if (slice === null) return undefined;
1914
- const profile = profileBlockContents(slice);
1915
- return recommendTemplatesForBlock(profile, TEMPLATE_NAMES).recommended;
1916
- }, [currentTemplate, isRawView, isWysiwyg, rawHeadingLine, wysiwygHeadingIndex, markdownSource]);
1917
-
1918
- const templatePreviewBlock = useMemo(() => {
1919
- if (!doc || currentTemplate === null) return null;
1920
- const flat = flattenBlocks(doc.blocks);
1921
- if (flat.length === 0) return null;
1922
-
1923
- if (isRawView && rawHeadingLine !== null) {
1924
- return findBlockBySourceLine(flat, rawHeadingLine);
1925
- }
1926
-
1927
- if (isWysiwyg) {
1928
- if (layoutMode !== 'document' && activeBlockStartLine !== null) {
1929
- return findBlockBySourceLine(flat, activeBlockStartLine);
1930
- }
1931
- if (wysiwygHeadingIndex !== null) {
1932
- return flat[wysiwygHeadingIndex] ?? null;
1933
- }
1934
- }
1935
-
1936
- return null;
1937
- }, [
1938
- activeBlockStartLine,
1939
- currentTemplate,
1940
- doc,
1941
- isRawView,
1942
- isWysiwyg,
1943
- layoutMode,
1944
- rawHeadingLine,
1945
- wysiwygHeadingIndex,
1946
- ]);
1947
-
1948
- const templatePreviewSource = useMemo(
1949
- () =>
1950
- templatePreviewBlock
1951
- ? {
1952
- block: templatePreviewBlock,
1953
- theme: previewSettings?.activeTheme ?? DEFAULT_THEME,
1954
- viewport: previewSettings?.activeViewport ?? VIEWPORT_PRESETS.landscape,
1955
- basePath: '/',
1956
- mediaProvider,
1957
- customTemplates: doc?.customTemplates,
1958
- }
1959
- : undefined,
1960
- [
1961
- mediaProvider,
1962
- doc?.customTemplates,
1963
- previewSettings?.activeTheme,
1964
- previewSettings?.activeViewport,
1965
- templatePreviewBlock,
1966
- ],
1967
- );
1968
-
1969
- const handleTemplatePick = (value: string) => {
1970
- // Raw (Monaco) — rewrite the heading line's annotation suffix in place.
1971
- if (isRawView && monacoEditor) {
1972
- const model = monacoEditor.getModel();
1973
- const pos = monacoEditor.getPosition();
1974
- if (!model || !pos) return;
1975
- if (isMarkdownFencedCodeLine(model.getValue(), pos.lineNumber)) return;
1976
- const lineNumber = pos.lineNumber;
1977
- const lineText = model.getLineContent(lineNumber);
1978
- const headingMatch = lineText.match(/^(#{1,6}\s+)(.+)$/);
1979
- if (!headingMatch) return;
1980
- const prefix = headingMatch[1];
1981
- const { text: bareText, params } = splitTemplateAnnotationParams(headingMatch[2]);
1982
- const annotation = buildTemplateAnnotation(value, params);
1983
- const newLine = annotation ? `${prefix}${bareText} ${annotation}` : `${prefix}${bareText}`;
1984
- monacoEditor.executeEdits('toolbar-template-pick', [
1985
- {
1986
- range: {
1987
- startLineNumber: lineNumber,
1988
- startColumn: 1,
1989
- endLineNumber: lineNumber,
1990
- endColumn: lineText.length + 1,
1991
- },
1992
- text: newLine,
1993
- },
1994
- ]);
1995
- monacoEditor.focus();
1996
- return;
1997
- }
1998
- // WYSIWYG — update the heading node attributes.
1999
- if (!tiptapEditor) return;
2000
- const attrs = tiptapEditor.getAttributes('heading') as {
2001
- dataTemplateParams?: string | null;
2002
- };
2003
- const dataTemplateParams = keepBlockMetaParamString(attrs.dataTemplateParams);
2004
- if (value === '') {
2005
- tiptapEditor
2006
- .chain()
2007
- .focus()
2008
- .updateAttributes('heading', { dataTemplate: null, dataTemplateParams })
2009
- .run();
2010
- } else {
2011
- tiptapEditor
2012
- .chain()
2013
- .focus()
2014
- .updateAttributes('heading', { dataTemplate: value, dataTemplateParams })
2015
- .run();
2016
- }
2017
- };
2018
-
2019
- // ── Current block transition ──────────────────────────
2020
- // Non-null exactly when a block (heading) is active — same condition as
2021
- // the template picker. Read from the Pandoc `{…}` block (canonical), with
2022
- // the `{[…]}` params as a fallback for hand-typed transitions.
2023
- let currentTransition: TransitionFields | null = null;
2024
- if (isWysiwyg && wysiwygTemplate !== null) {
2025
- const attrs = tiptapEditor.getAttributes('heading') as {
2026
- dataBlockAttrs?: string | null;
2027
- dataTemplateParams?: string | null;
2028
- };
2029
- currentTransition = readBlockAttrsTransition(attrs.dataBlockAttrs, attrs.dataTemplateParams);
2030
- } else if (isRawView && rawTemplate !== null) {
2031
- currentTransition = rawTransition;
2032
- }
2033
-
2034
- const handleTransitionPick = (next: TransitionFields) => {
2035
- // Raw (Monaco) — rewrite the heading line, writing into the `{…}` block.
2036
- if (isRawView && monacoEditor) {
2037
- const model = monacoEditor.getModel();
2038
- const pos = monacoEditor.getPosition();
2039
- if (!model || !pos) return;
2040
- if (isMarkdownFencedCodeLine(model.getValue(), pos.lineNumber)) return;
2041
- const lineNumber = pos.lineNumber;
2042
- const lineText = model.getLineContent(lineNumber);
2043
- const newLine = setHeadingLineTransition(lineText, next);
2044
- if (newLine === lineText) return;
2045
- monacoEditor.executeEdits('toolbar-transition-pick', [
2046
- {
2047
- range: {
2048
- startLineNumber: lineNumber,
2049
- startColumn: 1,
2050
- endLineNumber: lineNumber,
2051
- endColumn: lineText.length + 1,
2052
- },
2053
- text: newLine,
2054
- },
2055
- ]);
2056
- monacoEditor.focus();
2057
- return;
2058
- }
2059
- // WYSIWYG — write transitions into `{[…]}` params and migrate legacy
2060
- // Pandoc transition keys out of `dataBlockAttrs`.
2061
- if (!tiptapEditor) return;
2062
- const attrs = tiptapEditor.getAttributes('heading') as {
2063
- dataBlockAttrs?: string | null;
2064
- dataTemplateParams?: string | null;
2065
- };
2066
- const updated = setHeadingAttrsTransition(attrs.dataBlockAttrs, attrs.dataTemplateParams, next);
2067
- tiptapEditor
2068
- .chain()
2069
- .focus()
2070
- .updateAttributes('heading', {
2071
- dataBlockAttrs: updated.blockAttrsInner,
2072
- dataTemplateParams: updated.templateParams,
2073
- })
2074
- .run();
2075
- };
2076
-
2077
- // ── Per-block controls in the overflow menu ────────────
2078
- // The template + transition pickers configure the active heading block, so
2079
- // when either is pushed into the ··· menu we group them under a labeled
2080
- // "<name> Block:" heading. `currentTemplate`/`currentTransition` are non-null
2081
- // together (both gated on a heading being active); an empty template string
2082
- // is a heading with no visual template, labeled "Heading".
2083
- const showTemplateInOverflow = currentTemplate !== null && clippedContextual.has('template');
2084
- const showTransitionInOverflow =
2085
- currentTransition !== null && clippedContextual.has('transition');
2086
- const showBlockSectionInOverflow = showTemplateInOverflow || showTransitionInOverflow;
2087
- const overflowBlockLabel = currentTemplate ? templateLabel(currentTemplate) : 'Heading';
2088
- const showToolbarOverflow =
2089
- !findMode &&
2090
- !isPreview &&
2091
- !isCodeMode &&
2092
- (overflowIndex !== null || clippedContextual.size > 0);
2093
-
2094
- return (
2095
- <div
2096
- className={`squisq-toolbar${findMode ? ' squisq-toolbar--find' : ''} ${className || ''}`}
2097
- role="toolbar"
2098
- aria-label={findMode ? 'Find toolbar' : 'Formatting toolbar'}
2099
- >
2100
- {/* Hidden file input for image picker */}
2101
- <input
2102
- ref={imageInputRef}
2103
- type="file"
2104
- accept="image/*"
2105
- style={{ display: 'none' }}
2106
- onChange={(e) => {
2107
- const file = e.target.files?.[0];
2108
- if (file) handleImageFile(file);
2109
- // Reset so the same file can be re-selected
2110
- e.target.value = '';
2111
- }}
2112
- />
2113
- {/* Left slot — before view tabs */}
2114
- {!findMode && slotLeft}
2115
- {/* View tabs — hidden when only one view is available (e.g. code mode). */}
2116
- {showViewTabs && (
2117
- <div className="squisq-toolbar-view-tabs" role="tablist" aria-label="Editor view">
2118
- {visibleViews.map((view) => {
2119
- const viewLabel =
2120
- view.id === 'preview' && previewSettings
2121
- ? displayModeLabel(previewSettings.activeDisplayMode)
2122
- : view.label;
2123
- const tab = (
2124
- <button
2125
- role="tab"
2126
- data-view={view.id}
2127
- aria-selected={activeView === view.id}
2128
- className={`squisq-toolbar-view-tab${activeView === view.id ? ' squisq-toolbar-view-tab--active' : ''}`}
2129
- onClick={() => {
2130
- if (view.id === 'preview' && activeView === 'preview' && previewSettings) {
2131
- requestUseModeMenu();
2132
- return;
2133
- }
2134
- setActiveView(view.id);
2135
- }}
2136
- data-tooltip={`${viewLabel} (${view.shortcut})`}
2137
- >
2138
- <span
2139
- className="squisq-toolbar-view-tab-label squisq-toolbar-view-tab-label--long"
2140
- data-label={viewLabel}
2141
- >
2142
- {viewLabel}
2143
- </span>
2144
- {view.shortLabel && view.shortLabel !== view.label && (
2145
- <span
2146
- className="squisq-toolbar-view-tab-label squisq-toolbar-view-tab-label--short"
2147
- data-label={view.shortLabel}
2148
- >
2149
- {view.shortLabel}
2150
- </span>
2151
- )}
2152
- </button>
2153
- );
2154
-
2155
- if (view.id !== 'preview' || !previewSettings) {
2156
- return (
2157
- <div key={view.id} className="squisq-toolbar-view-tab-wrap" role="presentation">
2158
- {tab}
2159
- </div>
2160
- );
2161
- }
2162
-
2163
- return (
2164
- <div
2165
- key={view.id}
2166
- className={`squisq-toolbar-view-tab-wrap squisq-toolbar-use-tab${activeView === 'preview' ? ' squisq-toolbar-use-tab--active' : ''}`}
2167
- role="presentation"
2168
- >
2169
- {tab}
2170
- <PreviewModeMenu openRequest={useModeMenuRequest} />
2171
- </div>
2172
- );
2173
- })}
2174
- </div>
2175
- )}
2176
- {/* After-tabs slot — left side, before formatting or preview controls. */}
2177
- {findMode ? <FindToolbar onClose={() => setFindMode(false)} /> : slotAfterTabs}
2178
- {/* Formatting buttons — hidden in preview mode and code mode */}
2179
- {!findMode && !isPreview && !isCodeMode && (
2180
- <div className="squisq-toolbar-actions" ref={actionsRef}>
2181
- {groups.map((group, gi) => (
2182
- <div key={group} className="squisq-toolbar-group">
2183
- {gi > 0 && <div className="squisq-toolbar-separator" />}
2184
- {BUTTONS.filter((b) => b.group === group && isButtonVisible(b.id)).map((btn) => {
2185
- const active =
2186
- btn.id === 'emoji'
2187
- ? emojiPickerAnchor !== null
2188
- : formatActive && formattingEditor
2189
- ? isTiptapActive(formattingEditor, btn.id)
2190
- : false;
2191
- const disabled = (btn.id === 'image' && !mediaProvider) || !buttonAllowed(btn.id);
2192
- return (
2193
- <button
2194
- key={btn.id}
2195
- ref={btn.id === 'emoji' ? emojiButtonRef : undefined}
2196
- className={`squisq-toolbar-button${active ? ' squisq-toolbar-button--active' : ''}`}
2197
- data-btn-index={BUTTON_INDEX_BY_ID.get(btn.id)}
2198
- data-tooltip={disabled ? 'Insert image (requires media provider)' : btn.title}
2199
- onClick={() => handleAction(btn.id)}
2200
- aria-label={btn.title}
2201
- aria-pressed={active}
2202
- disabled={disabled}
2203
- >
2204
- {buttonIcon(btn)}
2205
- </button>
2206
- );
2207
- })}
2208
- </div>
2209
- ))}
2210
-
2211
- {/* Insert menu button — collapses all media-group actions into a single dropdown */}
2212
- <div className="squisq-toolbar-group">
2213
- <div className="squisq-toolbar-separator" />
2214
- <button
2215
- ref={insertMenuButtonRef}
2216
- className={`squisq-toolbar-button${insertMenuAnchor ? ' squisq-toolbar-button--active' : ''}`}
2217
- data-btn-index={FIRST_MEDIA_INDEX}
2218
- data-tooltip="Insert..."
2219
- onClick={() => (insertMenuAnchor ? closeInsertMenu() : openInsertMenu())}
2220
- aria-label="Insert"
2221
- aria-expanded={insertMenuAnchor !== null}
2222
- aria-haspopup="menu"
2223
- >
2224
- <Icon icon="fa-solid fa-plus" />
2225
- </button>
2226
- </div>
2227
-
2228
- {/* Template picker — visible when the cursor is in a heading.
2229
- In WYSIWYG, reads from the heading node's `dataTemplate`; in
2230
- Markdown view, parses the `{[...]}` suffix on the cursor's line.
2231
- The `squisq-toolbar-contextual` class + data-contextual id opt
2232
- this group (and the two below) into the clipping measurement:
2233
- when it doesn't fit it hides here and moves into the ··· menu. */}
2234
- {currentTemplate !== null && (
2235
- <div
2236
- className={`squisq-toolbar-group squisq-toolbar-contextual squisq-template-picker${clippedContextual.has('template') ? ' squisq-toolbar-contextual--clipped' : ''}`}
2237
- data-contextual="template"
2238
- >
2239
- <div className="squisq-toolbar-separator" />
2240
- <TemplatePicker
2241
- value={currentTemplate}
2242
- onChange={handleTemplatePick}
2243
- colorScheme={colorScheme}
2244
- recommended={recommendedTemplates}
2245
- previewSource={templatePreviewSource}
2246
- />
2247
- </div>
2248
- )}
2249
-
2250
- {/* Transition picker — visible alongside the template picker when a
2251
- block (heading) is active. Writes `transition=` into the block's
2252
- Pandoc `{…}` attribute block (the canonical home for transitions). */}
2253
- {currentTransition !== null && (
2254
- <div
2255
- className={`squisq-toolbar-group squisq-toolbar-contextual squisq-transition-picker-group${clippedContextual.has('transition') ? ' squisq-toolbar-contextual--clipped' : ''}`}
2256
- data-contextual="transition"
2257
- >
2258
- <div className="squisq-toolbar-separator" />
2259
- <TransitionPicker
2260
- value={currentTransition}
2261
- onChange={handleTransitionPick}
2262
- colorScheme={colorScheme}
2263
- accentColor={previewSettings?.activeTheme.colors.primary}
2264
- />
2265
- </div>
2266
- )}
2267
-
2268
- {/* Table controls — visible when cursor is in a table (WYSIWYG) */}
2269
- {isInTable && (
2270
- <div
2271
- className={`squisq-toolbar-group squisq-toolbar-contextual squisq-table-controls${clippedContextual.has('table') ? ' squisq-toolbar-contextual--clipped' : ''}`}
2272
- data-contextual="table"
2273
- >
2274
- <div className="squisq-toolbar-separator" />
2275
- <span className="squisq-table-controls-label">Table:</span>
2276
- <button
2277
- className="squisq-toolbar-button"
2278
- data-tooltip="Add column before"
2279
- onClick={() => tiptapEditor!.chain().focus().addColumnBefore().run()}
2280
- aria-label="Add column before"
2281
- >
2282
- <svg
2283
- width="16"
2284
- height="16"
2285
- viewBox="0 0 16 16"
2286
- fill="none"
2287
- stroke="currentColor"
2288
- strokeWidth="1.5"
2289
- strokeLinecap="round"
2290
- >
2291
- <rect x="7" y="2" width="8" height="12" rx="1" />
2292
- <line x1="11" y1="2" x2="11" y2="14" />
2293
- <line x1="1" y1="8" x2="4.5" y2="8" />
2294
- <line x1="2.75" y1="6.25" x2="2.75" y2="9.75" />
2295
- </svg>
2296
- </button>
2297
- <button
2298
- className="squisq-toolbar-button"
2299
- data-tooltip="Add column after"
2300
- onClick={() => tiptapEditor!.chain().focus().addColumnAfter().run()}
2301
- aria-label="Add column after"
2302
- >
2303
- <svg
2304
- width="16"
2305
- height="16"
2306
- viewBox="0 0 16 16"
2307
- fill="none"
2308
- stroke="currentColor"
2309
- strokeWidth="1.5"
2310
- strokeLinecap="round"
2311
- >
2312
- <rect x="1" y="2" width="8" height="12" rx="1" />
2313
- <line x1="5" y1="2" x2="5" y2="14" />
2314
- <line x1="11.5" y1="8" x2="15" y2="8" />
2315
- <line x1="13.25" y1="6.25" x2="13.25" y2="9.75" />
2316
- </svg>
2317
- </button>
2318
- <button
2319
- className="squisq-toolbar-button"
2320
- data-tooltip="Delete column"
2321
- onClick={() => tiptapEditor!.chain().focus().deleteColumn().run()}
2322
- aria-label="Delete column"
2323
- >
2324
- <svg
2325
- width="16"
2326
- height="16"
2327
- viewBox="0 0 16 16"
2328
- fill="none"
2329
- stroke="currentColor"
2330
- strokeWidth="1.5"
2331
- strokeLinecap="round"
2332
- >
2333
- <rect x="4" y="1" width="8" height="14" rx="1" />
2334
- <line x1="6" y1="5.5" x2="10" y2="10.5" />
2335
- <line x1="10" y1="5.5" x2="6" y2="10.5" />
2336
- </svg>
2337
- </button>
2338
- <button
2339
- className="squisq-toolbar-button"
2340
- data-tooltip="Add row above"
2341
- onClick={() => tiptapEditor!.chain().focus().addRowBefore().run()}
2342
- aria-label="Add row above"
2343
- >
2344
- <svg
2345
- width="16"
2346
- height="16"
2347
- viewBox="0 0 16 16"
2348
- fill="none"
2349
- stroke="currentColor"
2350
- strokeWidth="1.5"
2351
- strokeLinecap="round"
2352
- >
2353
- <rect x="2" y="6" width="12" height="9" rx="1" />
2354
- <line x1="2" y1="10.5" x2="14" y2="10.5" />
2355
- <line x1="8" y1="1" x2="8" y2="4.5" />
2356
- <line x1="6.25" y1="2.75" x2="9.75" y2="2.75" />
2357
- </svg>
2358
- </button>
2359
- <button
2360
- className="squisq-toolbar-button"
2361
- data-tooltip="Add row below"
2362
- onClick={() => tiptapEditor!.chain().focus().addRowAfter().run()}
2363
- aria-label="Add row below"
2364
- >
2365
- <svg
2366
- width="16"
2367
- height="16"
2368
- viewBox="0 0 16 16"
2369
- fill="none"
2370
- stroke="currentColor"
2371
- strokeWidth="1.5"
2372
- strokeLinecap="round"
2373
- >
2374
- <rect x="2" y="1" width="12" height="9" rx="1" />
2375
- <line x1="2" y1="5.5" x2="14" y2="5.5" />
2376
- <line x1="8" y1="11.5" x2="8" y2="15" />
2377
- <line x1="6.25" y1="13.25" x2="9.75" y2="13.25" />
2378
- </svg>
2379
- </button>
2380
- <button
2381
- className="squisq-toolbar-button"
2382
- data-tooltip="Delete row"
2383
- onClick={() => tiptapEditor!.chain().focus().deleteRow().run()}
2384
- aria-label="Delete row"
2385
- >
2386
- <svg
2387
- width="16"
2388
- height="16"
2389
- viewBox="0 0 16 16"
2390
- fill="none"
2391
- stroke="currentColor"
2392
- strokeWidth="1.5"
2393
- strokeLinecap="round"
2394
- >
2395
- <rect x="1" y="4" width="14" height="8" rx="1" />
2396
- <line x1="5.5" y1="6" x2="10.5" y2="10" />
2397
- <line x1="10.5" y1="6" x2="5.5" y2="10" />
2398
- </svg>
2399
- </button>
2400
- <button
2401
- className="squisq-toolbar-button squisq-toolbar-button--danger"
2402
- data-tooltip="Delete table"
2403
- onClick={() => tiptapEditor!.chain().focus().deleteTable().run()}
2404
- aria-label="Delete table"
2405
- >
2406
- <svg
2407
- width="16"
2408
- height="16"
2409
- viewBox="0 0 16 16"
2410
- fill="none"
2411
- stroke="currentColor"
2412
- strokeWidth="1.5"
2413
- strokeLinecap="round"
2414
- >
2415
- <rect x="1" y="1" width="14" height="14" rx="1" />
2416
- <line x1="1" y1="5.5" x2="15" y2="5.5" />
2417
- <line x1="5.5" y1="1" x2="5.5" y2="15" />
2418
- <line x1="4.5" y1="4.5" x2="11.5" y2="11.5" strokeWidth="2" />
2419
- <line x1="11.5" y1="4.5" x2="4.5" y2="11.5" strokeWidth="2" />
2420
- </svg>
2421
- </button>
2422
- </div>
2423
- )}
2424
- </div>
2425
- )}
2426
-
2427
- {/* Overflow menu — outside the overflow:hidden actions container.
2428
- Also appears when a contextual group (template/transition picker,
2429
- table controls) is clipped, even if every plain button fits. */}
2430
- {showToolbarOverflow && (
2431
- <div className="squisq-toolbar-overflow" ref={overflowRef}>
2432
- <button
2433
- className={`squisq-toolbar-button squisq-toolbar-overflow-trigger${showOverflow ? ' squisq-toolbar-button--active' : ''}`}
2434
- data-tooltip="More actions"
2435
- onClick={() => setShowOverflow((v) => !v)}
2436
- aria-label="More actions"
2437
- aria-expanded={showOverflow}
2438
- >
2439
- ···
2440
- </button>
2441
- {showOverflow && (
2442
- <div
2443
- className={`squisq-toolbar-overflow-menu squisq-toolbar-overflow-menu--${overflowPlacement}`}
2444
- >
2445
- {BUTTONS.slice(overflowIndex ?? BUTTONS.length)
2446
- .filter((b) => isButtonVisible(b.id))
2447
- // Media buttons are represented by the synthetic Insert dropdown
2448
- // in both the visible toolbar and the overflow menu.
2449
- .filter((b) => b.group !== 'media')
2450
- .map((btn) => {
2451
- const active =
2452
- btn.id === 'emoji'
2453
- ? emojiPickerAnchor !== null
2454
- : formatActive && formattingEditor
2455
- ? isTiptapActive(formattingEditor, btn.id)
2456
- : false;
2457
- const disabled = (btn.id === 'image' && !mediaProvider) || !buttonAllowed(btn.id);
2458
- return (
2459
- <button
2460
- key={btn.id}
2461
- ref={btn.id === 'emoji' ? emojiButtonRef : undefined}
2462
- className={`squisq-toolbar-overflow-item${active ? ' squisq-toolbar-overflow-item--active' : ''}`}
2463
- onClick={() => {
2464
- handleAction(btn.id);
2465
- // Keep the overflow open when opening the emoji
2466
- // picker — otherwise its anchor (the overflow
2467
- // item) unmounts and the popover loses its ref.
2468
- if (btn.id !== 'emoji') setShowOverflow(false);
2469
- }}
2470
- disabled={disabled}
2471
- >
2472
- <span className="squisq-toolbar-overflow-icon">{buttonIcon(btn)}</span>
2473
- <span>{btn.title}</span>
2474
- </button>
2475
- );
2476
- })}
2477
-
2478
- {showInsertInOverflow && (
2479
- <button
2480
- className={`squisq-toolbar-overflow-item${insertMenuAnchor ? ' squisq-toolbar-overflow-item--active' : ''}`}
2481
- onClick={(event) => {
2482
- if (insertMenuAnchor) {
2483
- closeInsertMenu();
2484
- } else {
2485
- openInsertMenu(event.currentTarget);
2486
- }
2487
- setShowOverflow(false);
2488
- }}
2489
- aria-haspopup="menu"
2490
- aria-expanded={insertMenuAnchor !== null}
2491
- >
2492
- <span className="squisq-toolbar-overflow-icon">
2493
- <Icon icon="fa-solid fa-plus" />
2494
- </span>
2495
- <span>Insert...</span>
2496
- </button>
2497
- )}
2498
-
2499
- {/* Per-block section — the template + transition pickers move
2500
- here when clipped, under a divider and a "<name> Block:"
2501
- heading that names the block they configure. */}
2502
- {showBlockSectionInOverflow && (
2503
- <>
2504
- <div
2505
- className="squisq-toolbar-separator"
2506
- style={{ margin: '4px 0', width: '100%', height: 1 }}
2507
- />
2508
- <div className="squisq-toolbar-overflow-heading">{overflowBlockLabel} Block:</div>
2509
- </>
2510
- )}
2511
-
2512
- {/* Contextual: template picker in overflow — only when the
2513
- inline one is clipped, so the control lives in exactly one
2514
- visible place at a time. */}
2515
- {showTemplateInOverflow && (
2516
- <div className="squisq-toolbar-overflow-item squisq-toolbar-overflow-template">
2517
- <TemplatePicker
2518
- value={currentTemplate!}
2519
- onChange={(v) => {
2520
- handleTemplatePick(v);
2521
- setShowOverflow(false);
2522
- }}
2523
- colorScheme={colorScheme}
2524
- recommended={recommendedTemplates}
2525
- previewSource={templatePreviewSource}
2526
- />
2527
- </div>
2528
- )}
2529
-
2530
- {/* Contextual: transition picker in overflow — mirrors the
2531
- template picker above. The menu stays open on change since
2532
- the flyout carries follow-up controls (direction/duration). */}
2533
- {showTransitionInOverflow && (
2534
- <div className="squisq-toolbar-overflow-item squisq-toolbar-overflow-template">
2535
- <TransitionPicker
2536
- value={currentTransition!}
2537
- onChange={handleTransitionPick}
2538
- colorScheme={colorScheme}
2539
- accentColor={previewSettings?.activeTheme.colors.primary}
2540
- />
2541
- </div>
2542
- )}
2543
-
2544
- {/* Contextual: table controls in overflow */}
2545
- {isInTable && clippedContextual.has('table') && (
2546
- <>
2547
- <div
2548
- className="squisq-toolbar-separator"
2549
- style={{ margin: '4px 0', width: '100%', height: 1 }}
2550
- />
2551
- {[
2552
- {
2553
- label: 'Add column before',
2554
- action: () => tiptapEditor!.chain().focus().addColumnBefore().run(),
2555
- },
2556
- {
2557
- label: 'Add column after',
2558
- action: () => tiptapEditor!.chain().focus().addColumnAfter().run(),
2559
- },
2560
- {
2561
- label: 'Delete column',
2562
- action: () => tiptapEditor!.chain().focus().deleteColumn().run(),
2563
- },
2564
- {
2565
- label: 'Add row above',
2566
- action: () => tiptapEditor!.chain().focus().addRowBefore().run(),
2567
- },
2568
- {
2569
- label: 'Add row below',
2570
- action: () => tiptapEditor!.chain().focus().addRowAfter().run(),
2571
- },
2572
- {
2573
- label: 'Delete row',
2574
- action: () => tiptapEditor!.chain().focus().deleteRow().run(),
2575
- },
2576
- {
2577
- label: 'Delete table',
2578
- action: () => tiptapEditor!.chain().focus().deleteTable().run(),
2579
- },
2580
- ].map((item) => (
2581
- <button
2582
- key={item.label}
2583
- className={`squisq-toolbar-overflow-item${item.label.startsWith('Delete') ? ' squisq-toolbar-overflow-item--danger' : ''}`}
2584
- onClick={() => {
2585
- item.action();
2586
- setShowOverflow(false);
2587
- }}
2588
- >
2589
- <span>{item.label}</span>
2590
- </button>
2591
- ))}
2592
- </>
2593
- )}
2594
- </div>
2595
- )}
2596
- </div>
2597
- )}
2598
-
2599
- {/* After-actions slot — after formatting controls */}
2600
- {!findMode && slotAfterActions}
2601
- {/* Spacer — pushes right-side items to the end when the flex:1 actions
2602
- container isn't rendered. In preview mode PreviewToolbarControls
2603
- supplies its own flex:1 filler (and measures that leftover width to
2604
- decide whether to collapse), so a second spacer here would split the
2605
- slack and make the controls collapse too early. */}
2606
- {isCodeMode && !findMode && <div style={{ flex: 1 }} />}
2607
- {/* Version history — renders only when the host enabled versioning
2608
- and a container is wired up. The component owns its own button
2609
- and popover; we just give it a slot in the toolbar. */}
2610
- {versioning && !isCodeMode && <VersionHistoryPanel />}
2611
- {/* Media recorder — surfaces when the host has a mediaProvider
2612
- and hasn't opted out. RecorderEntry returns null when no
2613
- provider is wired, so this stays a no-op for hosts that
2614
- haven't enabled media at all. */}
2615
- {allowRecording && !isCodeMode && mediaProvider && <RecorderEntry />}
2616
- {!isCodeMode && (
2617
- <button
2618
- type="button"
2619
- className="squisq-toolbar-button"
2620
- onClick={() => setShowDocSettings(true)}
2621
- data-tooltip="Document settings"
2622
- aria-label="Document settings"
2623
- >
2624
- <Icon icon="fa-solid fa-file-lines" />
2625
- </button>
2626
- )}
2627
- {!isCodeMode && (
2628
- <button
2629
- type="button"
2630
- className={`squisq-toolbar-button${showLayoutManager ? ' squisq-toolbar-button--active' : ''}`}
2631
- onClick={() => setShowLayoutManager(true)}
2632
- data-tooltip="Custom layouts"
2633
- aria-label="Custom layouts"
2634
- >
2635
- <Icon icon="fa-solid fa-shapes" />
2636
- </button>
2637
- )}
2638
- {!isCodeMode && <ViewMenuPanel />}
2639
- {/* Files toggle — visible when callback is provided */}
2640
- {onToggleFiles && (
2641
- <button
2642
- className={`squisq-toolbar-button squisq-toolbar-files-toggle${showFiles ? ' squisq-toolbar-button--active' : ''}`}
2643
- onClick={onToggleFiles}
2644
- data-tooltip={`${showFiles ? 'Hide' : 'Show'} Files panel${resolvedFileCount > 0 ? ` (${fileCountLabel(resolvedFileCount)})` : ''}`}
2645
- aria-pressed={showFiles}
2646
- aria-label={`Toggle Files panel${resolvedFileCount > 0 ? `, ${fileCountLabel(resolvedFileCount)}` : ''}`}
2647
- >
2648
- <Icon icon="fa-solid fa-paperclip" />
2649
- {resolvedFileCount > 0 && (
2650
- <span className="squisq-toolbar-files-badge" aria-hidden="true">
2651
- {fileCountBadge(resolvedFileCount)}
2652
- </span>
2653
- )}
2654
- </button>
2655
- )}
2656
- {/* Right slot — rightmost end of toolbar */}
2657
- {slotRight}
2658
-
2659
- {/* Document settings (frontmatter) dialog */}
2660
- {showDocSettings && (
2661
- <DocumentSettingsDialog
2662
- markdownSource={markdownSource}
2663
- onSave={(next) => {
2664
- setMarkdownSource(next);
2665
- setShowDocSettings(false);
2666
- }}
2667
- onClose={() => setShowDocSettings(false)}
2668
- />
2669
- )}
2670
-
2671
- {/* Custom layout manager — full-window list + designer dialog. */}
2672
- {showLayoutManager && <CustomLayoutManager onClose={() => setShowLayoutManager(false)} />}
2673
-
2674
- {/* Link insert/edit dialog — shared by WYSIWYG and Raw views. */}
2675
- {linkDialog && (
2676
- <LinkDialog
2677
- mode={linkDialog.mode}
2678
- initialText={linkDialog.initialText}
2679
- initialUrl={linkDialog.initialUrl}
2680
- onConfirm={handleLinkConfirm}
2681
- onClose={() => setLinkDialog(null)}
2682
- documentLinkProvider={documentLinkProvider}
2683
- />
2684
- )}
2685
-
2686
- {/* Insert menu — portaled to the document body so the overflow:hidden
2687
- actions row doesn't clip it. Position computed from trigger rect. */}
2688
- {insertMenuAnchor &&
2689
- createPortal(
2690
- <div
2691
- ref={insertMenuRef}
2692
- className="squisq-insert-menu"
2693
- data-theme={colorScheme}
2694
- style={{ position: 'fixed', top: insertMenuAnchor.top, left: insertMenuAnchor.left }}
2695
- role="menu"
2696
- >
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>
2726
- {MEDIA_BUTTONS.filter((b) => isButtonVisible(b.id)).map((btn) => {
2727
- const disabled = (btn.id === 'image' && !mediaProvider) || !buttonAllowed(btn.id);
2728
- const stripped = btn.title.replace(/^Insert\s+/i, '');
2729
- const label = stripped.charAt(0).toUpperCase() + stripped.slice(1);
2730
- const hasMermaidTypeMenu = btn.id === 'complexdiagram';
2731
- return (
2732
- <button
2733
- key={btn.id}
2734
- ref={btn.id === 'emoji' ? emojiButtonRef : undefined}
2735
- className="squisq-toolbar-overflow-item"
2736
- disabled={disabled}
2737
- onClick={(event) => {
2738
- if (hasMermaidTypeMenu) {
2739
- if (mermaidTypeMenuAnchor) setMermaidTypeMenuAnchor(null);
2740
- else openMermaidTypeMenu(event.currentTarget);
2741
- return;
2742
- }
2743
- handleAction(btn.id);
2744
- if (btn.id !== 'emoji') closeInsertMenu();
2745
- }}
2746
- role="menuitem"
2747
- aria-haspopup={hasMermaidTypeMenu ? 'menu' : undefined}
2748
- aria-expanded={hasMermaidTypeMenu ? mermaidTypeMenuAnchor !== null : undefined}
2749
- >
2750
- <span className="squisq-toolbar-overflow-icon">{buttonIcon(btn)}</span>
2751
- <span>{label}</span>
2752
- {hasMermaidTypeMenu && <Icon icon="fa-solid fa-chevron-right" />}
2753
- </button>
2754
- );
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
- ))}
2860
- </div>,
2861
- document.body,
2862
- )}
2863
-
2864
- {/* Emoji picker — portaled to the document body so the toolbar's
2865
- overflow:hidden actions row doesn't clip the popover. Position
2866
- is computed from the trigger button's screen rect at open. */}
2867
- {emojiPickerAnchor &&
2868
- createPortal(
2869
- <EmojiPicker
2870
- open
2871
- onSelect={handleEmojiSelect}
2872
- onClose={closeEmojiPicker}
2873
- anchorRef={emojiButtonRef as React.RefObject<HTMLElement>}
2874
- theme={colorScheme === 'dark' ? 'dark' : 'light'}
2875
- style={{
2876
- position: 'fixed',
2877
- top: emojiPickerAnchor.top,
2878
- left: emojiPickerAnchor.left,
2879
- }}
2880
- />,
2881
- document.body,
2882
- )}
2883
- </div>
2884
- );
2885
- }