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