@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
@@ -1,1334 +0,0 @@
1
- /**
2
- * EditorShell
3
- *
4
- * Top-level shell component that composes the Toolbar, ViewSwitcher, editor
5
- * views, and StatusBar into a complete editing experience. Wraps everything
6
- * in an EditorProvider for shared state.
7
- */
8
-
9
- import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
10
- import {
11
- EditorProvider,
12
- useEditorContext,
13
- type EditorView,
14
- type ImageDisplayMode,
15
- type MentionProvider,
16
- type DocumentLinkProvider,
17
- type ViewPreferences,
18
- type ThemeInheritance,
19
- type BlockTagVisibility,
20
- } from './EditorContext';
21
- import { Toolbar } from './Toolbar';
22
- import { StatusBar } from './StatusBar';
23
- import { RawEditor } from './RawEditor';
24
- import { WysiwygEditor } from './WysiwygEditor';
25
- import { InlinePreviewGutter } from './InlinePreviewGutter';
26
- import { BlockPreviewPanel } from './BlockPreviewPanel';
27
- import { OutlinePanel, OUTLINE_RESPONSIVE_WIDTH } from './OutlinePanel';
28
- import { CodeContextZones } from './codeContext/CodeContextZones';
29
- import type { CodeContext } from './codeContext/types';
30
- import { BlockCardView } from './BlockCardView';
31
- import { TimelineTrack } from './TimelineTrack';
32
- import { PreviewPanel } from './PreviewPanel';
33
- import { ImageViewer } from './ImageViewer';
34
- import { ImageEditor } from './ImageEditor';
35
- import {
36
- PreviewSettingsProvider,
37
- PreviewToolbarControls,
38
- ThemeDesignerDock,
39
- } from './PreviewControls';
40
- import { PresentationModeControl, PresentationModeProvider } from './presentation/PresentationMode';
41
- import { CustomThemeProvider, useDocCustomThemes } from './customThemes';
42
- import { MediaBin } from './MediaBin';
43
- import { DropZoneOverlay } from './DropZoneOverlay';
44
- import { TooltipLayer } from './Tooltip';
45
- import { useFileDrop, type DropTarget } from './hooks/useFileDrop';
46
- import {
47
- partitionFiles,
48
- processMediaFiles,
49
- processTextFile,
50
- processTextFiles,
51
- } from './utils/dropUtils';
52
- import {
53
- collectMediaReferencesFromMarkdown,
54
- removeMediaReferencesFromMarkdown,
55
- } from './mediaReferences';
56
- import { filterVisibleMediaEntries } from './mediaEntries';
57
- import type { MediaProvider, Theme } from '@bendyline/squisq/schemas';
58
- import { DARK_SURFACE, LIGHT_SURFACE } from '@bendyline/squisq/schemas';
59
- import type { ContentContainer } from '@bendyline/squisq/storage';
60
- import {
61
- MemoryContentContainer,
62
- scopeContainer,
63
- createMediaProviderFromContainer,
64
- } from '@bendyline/squisq/storage';
65
- import type { PrunePolicy, SaveVersionResult } from '@bendyline/squisq/versions';
66
- import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react';
67
- import { MediaContext } from '@bendyline/squisq-react';
68
-
69
- export type { EditorColorScheme } from './EditorContext';
70
-
71
- export interface EditorShellProps {
72
- /** Initial markdown content */
73
- initialMarkdown?: string;
74
- /** Initial active view */
75
- /** Initial active view (default: 'wysiwyg') */
76
- initialView?: EditorView;
77
- /** Article ID for Doc generation */
78
- articleId?: string;
79
- /** Base path for media URLs in preview */
80
- basePath?: string;
81
- /** Called when markdown source changes */
82
- onChange?: (source: string) => void;
83
- /**
84
- * Light/dark chrome color scheme for the editor shell — toolbar, tabs,
85
- * status bar, and side panes (default: `'light'`). This is the editor's
86
- * UI mode, **not** a Squisq `Theme` object; the rendered document's
87
- * styling is controlled separately via `themeOverride` / `Doc.themeId`.
88
- */
89
- colorScheme?: 'light' | 'dark';
90
- /** Additional class name */
91
- className?: string;
92
- /** CSS height for the shell container (default: '100vh') */
93
- height?: string;
94
- /**
95
- * Minimum CSS height for the shell. When either `minHeight` or
96
- * `maxHeight` is set, the shell switches to **auto-grow mode**:
97
- * `height` is ignored, the root becomes `height: auto` between the
98
- * bounds, and the content area scrolls internally when content
99
- * exceeds `maxHeight`. Useful for chat composers that should grow
100
- * with content up to some cap.
101
- */
102
- minHeight?: string;
103
- /** See `minHeight`. Upper bound of the auto-grow range. */
104
- maxHeight?: string;
105
- /** Optional MediaProvider for the Files panel. When set (even to null), a Files toggle appears in the toolbar. */
106
- mediaProvider?: MediaProvider | null;
107
- /**
108
- * The workspace-scoped `ContentContainer` for this document — the
109
- * folder that contains the doc, its `_files/` sidecar, sibling
110
- * documents, and any version snapshots. Used for:
111
- * - audio mapping (MP3 discovery + timing.json reading);
112
- * - version history snapshots (when `allowVersioning` is true);
113
- * - reading sibling `.md` files for the recursive HTML export;
114
- * - resolving per-document scoped views (e.g. the image-edit
115
- * sidecar derived via `scopeContainer`).
116
- * Doc-scoped concerns (per-doc media URLs, per-doc asset writes)
117
- * flow through `mediaProvider` instead — typically derived from
118
- * this container via `createMediaProviderFromContainer`.
119
- */
120
- workspaceContainer?: ContentContainer | null;
121
- /**
122
- * Enable version history. Snapshots are stored at
123
- * `.versions/<basename>.<timestamp>.md` inside the same
124
- * `workspaceContainer`, so they ride along with the document when
125
- * the host serializes.
126
- *
127
- * Snapshots fire on idle (controlled by `versioningAutoSaveIdleMs`)
128
- * and can also be triggered host-side via the manager exposed in the
129
- * context (`useEditorContext().versioning`). Has no effect without a
130
- * `workspaceContainer` — a `console.warn` flags the misconfiguration
131
- * in dev.
132
- */
133
- allowVersioning?: boolean;
134
- /**
135
- * Override the document basename used in version filenames. Defaults
136
- * to the basename of the container's primary document path.
137
- */
138
- versionBasename?: string;
139
- /**
140
- * Prune policy applied after each successful save. Defaults to
141
- * `{ type: 'keep-last-n', n: 50 }` so the snapshot count stays bounded.
142
- */
143
- versioningPrunePolicy?: PrunePolicy;
144
- /**
145
- * Idle delay (ms) before the editor auto-saves a version. `0` disables
146
- * auto-save entirely (snapshots are then only saved when the host
147
- * calls `versioning.saveVersion()` from the context). Default: 5000.
148
- */
149
- versioningAutoSaveIdleMs?: number;
150
- /**
151
- * Notified after each `saveVersion` attempt. Fires for both successful
152
- * saves (`reason: 'saved'`) and skips (`'unchanged'`, `'no-document'`,
153
- * `'empty'`). Useful for hosts that want a "Last saved" indicator.
154
- */
155
- onSaveVersion?: (result: SaveVersionResult) => void;
156
- /** Show the Files toggle in the toolbar. Defaults to true when mediaProvider is passed. */
157
- showFilesToggle?: boolean;
158
- /** Content rendered at the left edge of the toolbar, before the view tabs. */
159
- toolbarSlotLeft?: ReactNode;
160
- /** Content rendered after the formatting controls (in the middle area of the toolbar). */
161
- toolbarSlotAfterActions?: ReactNode;
162
- /** Content rendered at the rightmost end of the toolbar, after all other elements. */
163
- toolbarSlotRight?: ReactNode;
164
- /**
165
- * Whether to show the "Play" (preview) tab in the toolbar. When false, the
166
- * tab and its preview panel are hidden, and ⌘3 becomes a no-op. Use this
167
- * when embedding the editor somewhere the slideshow preview doesn't make
168
- * sense (e.g. editing free-form prompt documents). Defaults to true.
169
- */
170
- showPlayTab?: boolean;
171
- /**
172
- * Optional "submit on Enter" callback. When provided, a plain Enter
173
- * keypress fires this callback instead of inserting a newline, and
174
- * Cmd/Ctrl+Enter inserts a newline instead. Matches chat-composer UX
175
- * (Slack, Discord). When omitted, the editor behaves normally.
176
- */
177
- submitOnEnter?: () => void;
178
- /**
179
- * Host-supplied context dictionary rendered inside the Monaco (raw / code)
180
- * surface: collapsible markdown sections injected above anchor lines, plus
181
- * an optional file-top summary. See {@link CodeContext}. Ignored in
182
- * WYSIWYG / preview / image surfaces.
183
- */
184
- codeContext?: CodeContext;
185
- /**
186
- * Let the WYSIWYG editing surface fill its container instead of rendering
187
- * as a centered 800px "page" column. Useful when embedding in chat
188
- * composers, side panels, or any layout where the page metaphor doesn't
189
- * fit. Defaults to false (page mode).
190
- */
191
- fullWidth?: boolean;
192
- /**
193
- * Font-family stack applied to the editor **chrome** — toolbar buttons,
194
- * tabs, status bar, and control surfaces. The actual editing areas
195
- * (Tiptap / Monaco) keep their own fonts so document editing isn't
196
- * affected. Use this when the editor is embedded in a larger product
197
- * that has its own UX type system and you want the controls to blend in.
198
- *
199
- * @example
200
- * ```tsx
201
- * <EditorShell uxFont="'Hanken Grotesk', system-ui, sans-serif" ... />
202
- * ```
203
- */
204
- uxFont?: string;
205
- /**
206
- * Drop the editor's generous page-style padding in favor of a tight
207
- * layout that hugs its container. The default WYSIWYG surface uses
208
- * 16×24px padding suitable for editing long-form documents; chat
209
- * composers want much less. Applies to the editing area only — the
210
- * toolbar, tabs, and status bar keep their normal sizing.
211
- */
212
- thinMargins?: boolean;
213
- /**
214
- * Render the bottom status bar (word / character / line / block counts
215
- * and parse-state indicator). Defaults to `true`. Set to `false` in
216
- * embedded surfaces — chat composers and other short-form inputs —
217
- * where the stats are noise.
218
- */
219
- showStatusBar?: boolean;
220
- /**
221
- * How images should be displayed in the WYSIWYG view. `'inline'`
222
- * (default) flows them at natural size up to the container width;
223
- * `'thumbnail'` constrains each image to a 100×100 box with
224
- * aspect-preserving containment — useful for chat composers and other
225
- * dense surfaces where a full-resolution paste would dominate the
226
- * layout. Storage bytes are unchanged either way.
227
- */
228
- imageDisplayMode?: ImageDisplayMode;
229
- /**
230
- * File name (e.g. `foo.ts`) or bare extension that the content
231
- * represents. When set to a non-markdown/text extension, the shell
232
- * enters **code mode**: Monaco picks the right language based on the
233
- * extension, the WYSIWYG and Preview tabs disappear, and the toolbar
234
- * drops its markdown-specific formatting buttons. Markdown-ish
235
- * extensions (`.md`, `.markdown`, `.mdown`, `.txt`) keep the full
236
- * experience. Omit to get today's markdown behavior unchanged.
237
- */
238
- fileName?: string;
239
- /**
240
- * Explicit Monaco language ID override (e.g. `'typescript'`,
241
- * `'python'`, `'json'`). Wins over the language derived from
242
- * `fileName`. Anything other than `'markdown'` or `'plaintext'`
243
- * switches the shell into code mode.
244
- */
245
- language?: string;
246
- /**
247
- * Optional async provider for `@`-mention suggestions. When supplied,
248
- * typing `@` inside the editor opens a popover of candidates; selecting
249
- * one inserts a `@[Label](scheme:id)` mention token. Used by chat
250
- * composers and any other surface that wants to address named entities
251
- * inline. Omit to disable mentions entirely.
252
- */
253
- mentionProvider?: MentionProvider | null;
254
- /**
255
- * Optional async provider for sibling-document suggestions in the
256
- * link insert dialog. When supplied, the dialog gains a "Browse
257
- * documents" picker so authors can pick a neighbor `.md` by name and
258
- * insert a relative-path link without typing the URL by hand. Hosts
259
- * that organize docs in a workspace (file-system, IndexedDB slot,
260
- * remote API, …) implement this; the editor stays agnostic.
261
- */
262
- documentLinkProvider?: DocumentLinkProvider | null;
263
- /**
264
- * Whether the in-editor media recorder is surfaced in the toolbar.
265
- * Defaults to true — when a `mediaProvider` is wired, a record
266
- * button appears next to the version history. Pass `false` to
267
- * suppress it (read-only embeds, surfaces where camera/screen
268
- * permission prompts would be jarring). Without a `mediaProvider`,
269
- * the button is hidden regardless of this prop.
270
- */
271
- allowRecording?: boolean;
272
- /**
273
- * Whether the Narrate (teleprompter) display mode is offered under the
274
- * Use tab. Defaults to true. Orthogonal to `allowRecording` — the
275
- * prompter is useful without capture (reading for external recording
276
- * software), and the in-mode Record affordance additionally requires
277
- * `allowRecording` + a `mediaProvider`.
278
- */
279
- allowNarrate?: boolean;
280
- /**
281
- * Placeholder text shown in the WYSIWYG editor while the document is
282
- * empty. When omitted, the editor rotates through its own generic
283
- * "start typing…" prompts; pass a value here to override with copy
284
- * that fits the embedding surface (e.g. a chat composer knows who
285
- * the message is going to and can say so).
286
- */
287
- placeholder?: string;
288
- /**
289
- * When true, both editing surfaces become non-editable: Monaco runs in
290
- * `readOnly` mode and Tiptap is set to `editable: false`. The toolbar
291
- * still renders — hide it from the host side if you want a pure preview.
292
- * Useful for reference panels that show file content without inviting
293
- * accidental edits.
294
- */
295
- readOnly?: boolean;
296
- /**
297
- * Image source URL used when the resolved file mode is `image` (PNG,
298
- * JPEG, GIF, WebP, BMP, ICO, AVIF). When this prop is set, the shell
299
- * replaces its text-editing surfaces with a dedicated `ImageViewer`.
300
- *
301
- * Lifecycle of the URL is the caller's responsibility — when fed a
302
- * `blob:` URL, the host should `URL.revokeObjectURL` on unmount or
303
- * src change.
304
- */
305
- imageSrc?: string;
306
- /** Alt text passed through to the underlying ImageViewer. */
307
- imageAlt?: string;
308
- /**
309
- * Whether the image surface should render as a read-only viewer
310
- * (`'view'`, default) or as the editable {@link ImageEditor}
311
- * (`'edit'`). Editing requires {@link EditorShellProps.imageEditorContainer}
312
- * — without it the shell falls back to view mode and logs a warning.
313
- */
314
- imageMode?: 'view' | 'edit';
315
- /**
316
- * Sidecar `ContentContainer` for the image being edited. Conventionally
317
- * scoped to `<basename>_files/` via
318
- * `scopeContainer(parentContainer, basename + '_files')`. The image
319
- * editor persists `state.json`, layer assets in `assets/`, and (when
320
- * `allowVersioning` is true) snapshots in `.versions/` inside it.
321
- */
322
- imageEditorContainer?: ContentContainer;
323
- /**
324
- * Called after the user clicks Export in the image editor and the
325
- * raster blob is produced. When omitted, the editor triggers a
326
- * default browser download.
327
- */
328
- onImageExport?: (blob: Blob, format: 'png' | 'jpeg' | 'webp') => void;
329
- /**
330
- * Show an inline preview gutter to the right of the WYSIWYG editor.
331
- * The gutter renders one small SVG card per template-annotated block in
332
- * the document, letting authors see their rendered output without
333
- * leaving Edit mode. Auto-hidden via container query when the editor
334
- * body is narrower than ~720px. Defaults to `false`.
335
- */
336
- inlinePreview?: boolean;
337
- /**
338
- * Width in pixels for the inline preview gutter. Defaults to 320.
339
- * Only takes effect when {@link EditorShellProps.inlinePreview} is true.
340
- */
341
- inlinePreviewWidth?: number;
342
- /**
343
- * Show an outline pane on the left of the WYSIWYG editor — a
344
- * hierarchical tree of the document's headings (h1 → h2 → h3) with
345
- * click-to-scroll. Auto-hidden via container query on narrow editors.
346
- * Defaults to `false`. The toolbar's View menu can toggle this at
347
- * runtime regardless of the initial value.
348
- */
349
- outline?: boolean;
350
- /**
351
- * Fixed width in pixels for the outline pane. When omitted, the pane sizes
352
- * responsively — never narrower than 260px, growing with the window and
353
- * capped at 460px — so it stretches out when there's horizontal space to
354
- * spare. Only takes effect when {@link EditorShellProps.outline} is true (or
355
- * the View menu has toggled it on).
356
- */
357
- outlineWidth?: number;
358
- /**
359
- * Legacy initial visibility of inline block-template tags on headings.
360
- * `true` maps to always visible and `false` maps to hidden. When omitted,
361
- * {@link blockTagVisibility} defaults to `'active'`.
362
- */
363
- blockTags?: boolean;
364
- /**
365
- * Initial block-tag visibility mode. Takes precedence over `blockTags`.
366
- * Defaults to `'active'` (selected/hovered block only).
367
- */
368
- blockTagVisibility?: BlockTagVisibility;
369
- /**
370
- * How much of the active Squisq theme the WYSIWYG editing surface
371
- * mirrors. Defaults to `'fonts'` — the historical behavior of
372
- * inheriting body / heading fonts only. The View menu can change it
373
- * at runtime.
374
- */
375
- themeInheritance?: ThemeInheritance;
376
- /**
377
- * Bundled view preferences — a serializable JSON blob covering the
378
- * runtime-toggleable view options surfaced in the View menu. When
379
- * provided, fields here override the corresponding individual props
380
- * (`outline`, `inlinePreview`, `showStatusBar`, `blockTagVisibility`,
381
- * `blockTags`). Pair with
382
- * {@link onViewPreferencesChange} to externalize storage of these
383
- * preferences in the host.
384
- */
385
- viewPreferences?: ViewPreferences;
386
- /**
387
- * Notified after each user-driven toggle in the View menu. The
388
- * argument is a full snapshot of all view preferences — hosts can
389
- * persist it as-is. Not called when {@link viewPreferences} is
390
- * changed externally.
391
- */
392
- onViewPreferencesChange?: (prefs: ViewPreferences) => void;
393
- /**
394
- * Override the preview theme with an explicit `Theme` object. When set,
395
- * `Doc.themeId` and the user's theme dropdown selection are ignored for
396
- * the preview surface. Used by the theme customizer to live-preview an
397
- * in-progress theme without mutating the document.
398
- */
399
- themeOverride?: Theme | null;
400
- }
401
-
402
- /**
403
- * Complete markdown editor shell with toolbar, view switcher, and three
404
- * editing modes: Raw (Monaco), WYSIWYG (Tiptap), and Preview.
405
- */
406
- export function EditorShell({
407
- initialMarkdown = '',
408
- initialView = 'wysiwyg',
409
- articleId = 'untitled',
410
- basePath = '/',
411
- onChange,
412
- colorScheme = 'light',
413
- className,
414
- height = '100vh',
415
- minHeight,
416
- maxHeight,
417
- mediaProvider,
418
- workspaceContainer,
419
- allowVersioning = false,
420
- versionBasename,
421
- versioningPrunePolicy,
422
- versioningAutoSaveIdleMs,
423
- onSaveVersion,
424
- showFilesToggle,
425
- toolbarSlotLeft,
426
- toolbarSlotAfterActions,
427
- toolbarSlotRight,
428
- showPlayTab = true,
429
- submitOnEnter,
430
- codeContext,
431
- fullWidth = false,
432
- uxFont,
433
- thinMargins = false,
434
- showStatusBar = true,
435
- imageDisplayMode = 'inline',
436
- fileName,
437
- language,
438
- mentionProvider,
439
- documentLinkProvider,
440
- allowRecording = true,
441
- allowNarrate = true,
442
- placeholder,
443
- readOnly = false,
444
- imageSrc,
445
- imageAlt,
446
- imageMode = 'view',
447
- imageEditorContainer,
448
- onImageExport,
449
- inlinePreview = false,
450
- inlinePreviewWidth = 320,
451
- outline = false,
452
- outlineWidth,
453
- blockTags,
454
- blockTagVisibility,
455
- themeInheritance = 'fonts',
456
- viewPreferences,
457
- onViewPreferencesChange,
458
- themeOverride = null,
459
- }: EditorShellProps) {
460
- const effectiveContainer = workspaceContainer ?? null;
461
-
462
- // If the host gave us a `workspaceContainer` but no explicit `mediaProvider`,
463
- // derive one automatically. Without this, drag-and-drop of an image
464
- // into the editor silently failed (no provider \u2192 nothing to upload to)
465
- // even though we had a perfectly good ContentContainer to write into.
466
- const effectiveMediaProvider = useMemo<MediaProvider | null | undefined>(() => {
467
- if (mediaProvider !== undefined) return mediaProvider;
468
- if (effectiveContainer) return createMediaProviderFromContainer(effectiveContainer);
469
- return undefined;
470
- }, [mediaProvider, effectiveContainer]);
471
-
472
- // Show the toggle when explicitly opted in, or when mediaProvider prop was passed at all
473
- const filesToggleEnabled = showFilesToggle ?? effectiveMediaProvider !== undefined;
474
-
475
- // If the host hides the Play tab but asked for it as the initial view,
476
- // fall back to wysiwyg so we don't boot into a tab the user can't leave.
477
- const effectiveInitialView: EditorView =
478
- !showPlayTab && initialView === 'preview' ? 'wysiwyg' : initialView;
479
-
480
- return (
481
- <MediaContext.Provider value={effectiveMediaProvider ?? null}>
482
- <EditorProvider
483
- initialMarkdown={initialMarkdown}
484
- initialView={effectiveInitialView}
485
- articleId={articleId}
486
- colorScheme={colorScheme}
487
- workspaceContainer={effectiveContainer}
488
- allowVersioning={allowVersioning}
489
- versionBasename={versionBasename}
490
- versioningPrunePolicy={versioningPrunePolicy}
491
- versioningAutoSaveIdleMs={versioningAutoSaveIdleMs}
492
- onSaveVersion={onSaveVersion}
493
- mediaProvider={effectiveMediaProvider}
494
- imageDisplayMode={imageDisplayMode}
495
- mentionProvider={mentionProvider}
496
- documentLinkProvider={documentLinkProvider}
497
- allowRecording={allowRecording}
498
- allowNarrate={allowNarrate}
499
- fileName={fileName}
500
- language={language}
501
- inlinePreview={inlinePreview}
502
- showStatusBar={showStatusBar}
503
- outline={outline}
504
- blockTags={blockTags}
505
- blockTagVisibility={blockTagVisibility}
506
- themeInheritance={themeInheritance}
507
- viewPreferences={viewPreferences}
508
- onViewPreferencesChange={onViewPreferencesChange}
509
- >
510
- <EditorShellInner
511
- basePath={basePath}
512
- onChange={onChange}
513
- className={className}
514
- height={height}
515
- minHeight={minHeight}
516
- maxHeight={maxHeight}
517
- placeholder={placeholder}
518
- mediaProvider={effectiveMediaProvider ?? null}
519
- workspaceContainer={effectiveContainer}
520
- filesToggleEnabled={filesToggleEnabled}
521
- toolbarSlotLeft={toolbarSlotLeft}
522
- toolbarSlotAfterActions={toolbarSlotAfterActions}
523
- toolbarSlotRight={toolbarSlotRight}
524
- showPlayTab={showPlayTab}
525
- submitOnEnter={submitOnEnter}
526
- codeContext={codeContext}
527
- fullWidth={fullWidth}
528
- uxFont={uxFont}
529
- thinMargins={thinMargins}
530
- readOnly={readOnly}
531
- imageSrc={imageSrc}
532
- imageAlt={imageAlt}
533
- imageMode={imageMode}
534
- imageEditorContainer={imageEditorContainer}
535
- onImageExport={onImageExport}
536
- allowVersioning={allowVersioning}
537
- versioningAutoSaveIdleMs={versioningAutoSaveIdleMs}
538
- inlinePreviewWidth={inlinePreviewWidth}
539
- outlineWidth={outlineWidth}
540
- themeOverride={themeOverride}
541
- />
542
- </EditorProvider>
543
- </MediaContext.Provider>
544
- );
545
- }
546
-
547
- interface EditorShellInnerProps {
548
- basePath: string;
549
- onChange?: (source: string) => void;
550
- className?: string;
551
- height: string;
552
- minHeight?: string;
553
- maxHeight?: string;
554
- placeholder?: string;
555
- mediaProvider: MediaProvider | null;
556
- workspaceContainer?: ContentContainer | null;
557
- filesToggleEnabled: boolean;
558
- toolbarSlotLeft?: ReactNode;
559
- toolbarSlotAfterActions?: ReactNode;
560
- toolbarSlotRight?: ReactNode;
561
- showPlayTab: boolean;
562
- submitOnEnter?: () => void;
563
- codeContext?: CodeContext;
564
- fullWidth: boolean;
565
- uxFont?: string;
566
- thinMargins: boolean;
567
- readOnly: boolean;
568
- imageSrc?: string;
569
- imageAlt?: string;
570
- imageMode: 'view' | 'edit';
571
- imageEditorContainer?: ContentContainer;
572
- onImageExport?: (blob: Blob, format: 'png' | 'jpeg' | 'webp') => void;
573
- allowVersioning: boolean;
574
- versioningAutoSaveIdleMs?: number;
575
- inlinePreviewWidth: number;
576
- outlineWidth?: number;
577
- themeOverride: Theme | null;
578
- }
579
-
580
- function EditorShellInner({
581
- basePath,
582
- onChange,
583
- className,
584
- height,
585
- minHeight,
586
- maxHeight,
587
- placeholder,
588
- mediaProvider,
589
- workspaceContainer,
590
- filesToggleEnabled,
591
- toolbarSlotLeft,
592
- toolbarSlotAfterActions,
593
- toolbarSlotRight,
594
- showPlayTab,
595
- submitOnEnter,
596
- codeContext,
597
- fullWidth,
598
- uxFont,
599
- thinMargins,
600
- readOnly,
601
- imageSrc,
602
- imageAlt,
603
- imageMode,
604
- imageEditorContainer,
605
- onImageExport,
606
- allowVersioning,
607
- versioningAutoSaveIdleMs,
608
- inlinePreviewWidth,
609
- outlineWidth,
610
- themeOverride,
611
- }: EditorShellInnerProps) {
612
- const shellRef = useRef<HTMLDivElement>(null);
613
- const {
614
- activeView,
615
- markdownSource,
616
- doc,
617
- colorScheme,
618
- editorMode,
619
- insertAtCursor,
620
- replaceAll,
621
- tiptapEditor,
622
- monacoEditor,
623
- setMarkdownSource,
624
- inlinePreviewVisible,
625
- statusBarVisible,
626
- outlineVisible,
627
- layoutMode,
628
- blockCount,
629
- activeBlockKey,
630
- prevBlock,
631
- nextBlock,
632
- addBlock,
633
- imageEditTarget,
634
- closeImageEdit,
635
- bumpMediaRevision,
636
- mediaRevision,
637
- } = useEditorContext();
638
- // Dual-catalog custom themes (doc + browser library), mirroring how
639
- // CustomTemplateProvider is fed. Wraps the preview subtree so the theme
640
- // picker + designer can read/write both pools.
641
- const { docThemes, onDocThemesChange } = useDocCustomThemes();
642
- const isPreview = activeView === 'preview';
643
- const isCodeMode = editorMode === 'code';
644
- const isImageMode = editorMode === 'image';
645
- const isMarkdownMode = editorMode === 'markdown';
646
- // The block card (one block at a time) applies to the editing surfaces (raw /
647
- // wysiwyg) in markdown mode — never to preview, code, or image. Both the
648
- // block-at-a-time and timeline layouts scope the editor to a single block.
649
- const isCardMode =
650
- isMarkdownMode && (layoutMode === 'block' || layoutMode === 'timeline') && !isPreview;
651
- // Timeline mode additionally shows the horizontal timeline track below.
652
- const isTimelineMode = isMarkdownMode && layoutMode === 'timeline' && !isPreview;
653
- const [showFiles, setShowFiles] = useState(false);
654
- const [mediaRefreshKey, setMediaRefreshKey] = useState(0);
655
- const [mediaCount, setMediaCount] = useState(0);
656
- const mediaListRefreshKey = mediaRefreshKey + mediaRevision;
657
- const usedMediaPaths = useMemo(
658
- () => collectMediaReferencesFromMarkdown(markdownSource),
659
- [markdownSource],
660
- );
661
- // Persistent fallback container for image-edit sidecars when the host
662
- // didn't supply one. Lifted to shell scope so opening the same image
663
- // multiple times sees the same `.imageEdits/<sanitized>/.versions/...`
664
- // snapshots — otherwise each modal mount would start from an empty
665
- // in-memory container and history would vanish on close.
666
- const imageEditFallbackContainerRef = useRef<MemoryContentContainer | null>(null);
667
- if (imageEditFallbackContainerRef.current === null) {
668
- imageEditFallbackContainerRef.current = new MemoryContentContainer();
669
- }
670
- const imageEditFallbackContainer = imageEditFallbackContainerRef.current;
671
- const isDark = colorScheme === 'dark';
672
-
673
- useEffect(() => {
674
- if (!mediaProvider) {
675
- setMediaCount(0);
676
- return;
677
- }
678
-
679
- let cancelled = false;
680
- mediaProvider.listMedia().then(
681
- (entries) => {
682
- if (!cancelled) setMediaCount(filterVisibleMediaEntries(entries).length);
683
- },
684
- (err: unknown) => {
685
- if (!cancelled) {
686
- setMediaCount(0);
687
- console.warn(
688
- '[squisq-editor] Failed to read media list:',
689
- err instanceof Error ? err.message : err,
690
- );
691
- }
692
- },
693
- );
694
-
695
- return () => {
696
- cancelled = true;
697
- };
698
- }, [mediaProvider, mediaListRefreshKey]);
699
-
700
- const handleToggleFiles = useCallback(() => {
701
- setShowFiles((prev) => !prev);
702
- }, []);
703
-
704
- // ── Drag-and-drop file handling ──
705
-
706
- /**
707
- * Insert an uploaded media file at the editor's current cursor.
708
- *
709
- * - In **WYSIWYG** mode, insert an actual tiptap image node via
710
- * `setImage({src, alt})` (images) or a link mark (non-images).
711
- * Going through `setImage` directly mirrors the Toolbar's image
712
- * button and avoids the round-trip through `markdownToTiptap`
713
- * that historically lost `<img>` tags to tag-strip passes.
714
- * - In **raw (Monaco)** or **preview** mode, fall back to
715
- * `insertAtCursor` which emits the markdown snippet.
716
- *
717
- * Without this, upload-via-MediaBin and upload-via-drop both
718
- * added the file to the bin and nowhere else — the composer sent
719
- * an empty body and the downstream consumer reported "nothing came
720
- * through."
721
- */
722
- const insertMediaRef = useCallback(
723
- (relativePath: string, name: string, mimeType: string) => {
724
- const alt = name.replace(/\.[^.]+$/, '').replace(/[-_]/g, ' ');
725
- const isImage = mimeType.startsWith('image/');
726
- const snippet = isImage ? `![${alt}](${relativePath})` : `[${alt}](${relativePath})`;
727
-
728
- if (activeView === 'wysiwyg' && tiptapEditor) {
729
- if (isImage) {
730
- tiptapEditor.chain().focus().setImage({ src: relativePath, alt }).run();
731
- } else {
732
- tiptapEditor
733
- .chain()
734
- .focus()
735
- .insertContent([
736
- {
737
- type: 'text',
738
- marks: [{ type: 'link', attrs: { href: relativePath } }],
739
- text: alt,
740
- },
741
- ])
742
- .run();
743
- }
744
- return;
745
- }
746
- if (activeView === 'raw' && monacoEditor) {
747
- insertAtCursor(snippet);
748
- return;
749
- }
750
- // Preview mode — no interactive editor to insert into. Append
751
- // to markdown source so the ref is still in the buffer.
752
- setMarkdownSource(markdownSource ? `${markdownSource}\n\n${snippet}` : snippet);
753
- },
754
- [activeView, tiptapEditor, monacoEditor, insertAtCursor, markdownSource, setMarkdownSource],
755
- );
756
-
757
- const handleMediaUploaded = useCallback(
758
- (relativePath: string, name: string, mimeType: string) => {
759
- insertMediaRef(relativePath, name, mimeType);
760
- setMediaRefreshKey((k) => k + 1);
761
- },
762
- [insertMediaRef],
763
- );
764
-
765
- const handleMediaRemoved = useCallback(
766
- (relativePath: string) => {
767
- const nextSource = removeMediaReferencesFromMarkdown(markdownSource, relativePath);
768
- if (nextSource !== markdownSource) replaceAll(nextSource);
769
- setMediaRefreshKey((k) => k + 1);
770
- bumpMediaRevision();
771
- },
772
- [bumpMediaRevision, markdownSource, replaceAll],
773
- );
774
-
775
- const handleFileDrop = useCallback(
776
- async (files: File[], target: DropTarget) => {
777
- try {
778
- const { media, text } = partitionFiles(files);
779
-
780
- // Process media files
781
- if (media.length > 0 && mediaProvider) {
782
- const paths = await processMediaFiles(media, mediaProvider);
783
- setMediaRefreshKey((k) => k + 1);
784
- // Auto-open the media bin so the user sees the new files
785
- if (!showFiles) setShowFiles(true);
786
- // Insert each uploaded file as a markdown ref at the cursor so
787
- // the body actually contains the attachment. Without this the
788
- // bin holds the file but the serialized markdown stays empty,
789
- // and anything downstream (chat send, document save) sees no
790
- // reference to the upload.
791
- for (let i = 0; i < media.length; i++) {
792
- const file = media[i];
793
- const path = paths[i];
794
- if (!file || !path) continue;
795
- insertMediaRef(path, file.name, file.type || 'application/octet-stream');
796
- }
797
- }
798
-
799
- // Process text files
800
- if (text.length > 0) {
801
- if (target === 'replace') {
802
- // Replace with first text file
803
- const content = await processTextFile(text[0]);
804
- replaceAll(content);
805
- } else {
806
- // Insert all text files concatenated
807
- const content = await processTextFiles(text);
808
- insertAtCursor(content);
809
- }
810
- }
811
- } catch (err: unknown) {
812
- console.error('Failed to process dropped files:', err instanceof Error ? err.message : err);
813
- }
814
- },
815
- [mediaProvider, showFiles, replaceAll, insertAtCursor, insertMediaRef],
816
- );
817
-
818
- const { isDragging, dragContentType, containerProps, zoneProps } = useFileDrop({
819
- onDrop: handleFileDrop,
820
- enabled: !readOnly,
821
- });
822
-
823
- // Notify parent of changes
824
- useEffect(() => {
825
- onChange?.(markdownSource);
826
- }, [markdownSource, onChange]);
827
-
828
- // View shortcuts bubble only through the editor that currently owns focus.
829
- const handleShellKeyDown = useCallback(
830
- (e: ReactKeyboardEvent<HTMLDivElement>) => {
831
- if (!e.ctrlKey && !e.metaKey) return;
832
- const view =
833
- e.key === '1' ? 'wysiwyg' : e.key === '2' ? 'raw' : e.key === '3' ? 'preview' : null;
834
- if (!view || (view === 'preview' && !showPlayTab)) return;
835
- const button = shellRef.current?.querySelector<HTMLButtonElement>(`[data-view="${view}"]`);
836
- if (!button) return;
837
- e.preventDefault();
838
- e.stopPropagation();
839
- button.click();
840
- },
841
- [showPlayTab],
842
- );
843
-
844
- const autoGrow = minHeight !== undefined || maxHeight !== undefined;
845
-
846
- return (
847
- <div
848
- ref={shellRef}
849
- onKeyDown={handleShellKeyDown}
850
- className={`squisq-editor-shell ${className || ''}`}
851
- data-theme={colorScheme}
852
- data-full-width={fullWidth ? 'true' : undefined}
853
- data-thin-margins={thinMargins ? 'true' : undefined}
854
- data-outline-visible={isMarkdownMode && outlineVisible ? 'true' : undefined}
855
- style={{
856
- display: 'flex',
857
- flexDirection: 'column',
858
- overflow: 'hidden',
859
- ...(autoGrow ? { minHeight, maxHeight } : { height }),
860
- // When a consumer supplies a UX font stack, expose it to the
861
- // editor CSS via this custom property. Chrome elements (toolbar,
862
- // tabs, status bar) consume `--squisq-ux-font` as their
863
- // `font-family`, falling back to the system stack when unset.
864
- ...(uxFont ? ({ '--squisq-ux-font': uxFont } as CSSProperties) : {}),
865
- // Exposed so the toolbar's view-tabs section AND the outline pane read
866
- // one shared width, lining up the view-tabs' right-edge separator with
867
- // the outline's right edge. A fixed `outlineWidth` pins it to px;
868
- // otherwise it's the responsive clamp so the pane stretches when
869
- // there's room. The `data-outline-visible` gate above keeps the
870
- // toolbar override scoped to the case where alignment matters.
871
- ...({
872
- '--squisq-outline-width':
873
- outlineWidth != null ? `${outlineWidth}px` : OUTLINE_RESPONSIVE_WIDTH,
874
- } as CSSProperties),
875
- }}
876
- {...containerProps}
877
- >
878
- <CustomThemeProvider docThemes={docThemes} onDocThemesChange={onDocThemesChange}>
879
- <PreviewSettingsProvider doc={doc} themeOverride={themeOverride}>
880
- <PresentationModeProvider rootRef={shellRef}>
881
- {/* Header. In image mode the full markdown/code Toolbar is replaced
882
- with a minimal slot bar — view tabs, formatting, and preview
883
- controls don't apply to a binary asset. */}
884
- {isImageMode ? (
885
- (toolbarSlotLeft || toolbarSlotRight) && (
886
- <div className="squisq-editor-header squisq-editor-header--image">
887
- {toolbarSlotLeft}
888
- <div style={{ flex: 1 }} />
889
- {toolbarSlotRight}
890
- </div>
891
- )
892
- ) : (
893
- <div className="squisq-editor-header">
894
- <Toolbar
895
- showFiles={showFiles}
896
- fileCount={mediaCount}
897
- onToggleFiles={!isCodeMode && filesToggleEnabled ? handleToggleFiles : undefined}
898
- slotLeft={toolbarSlotLeft}
899
- slotAfterTabs={
900
- !isCodeMode &&
901
- isPreview && (
902
- <>
903
- <PreviewToolbarControls />
904
- <PresentationModeControl />
905
- </>
906
- )
907
- }
908
- slotAfterActions={toolbarSlotAfterActions}
909
- slotRight={toolbarSlotRight}
910
- showPlayTab={showPlayTab}
911
- />
912
- </div>
913
- )}
914
-
915
- {/* Main content area */}
916
- <div
917
- className="squisq-editor-content"
918
- style={{
919
- flex: autoGrow ? '1 1 auto' : 1,
920
- overflowY: autoGrow ? 'auto' : 'hidden',
921
- overflowX: 'hidden',
922
- minHeight: 0,
923
- position: 'relative',
924
- display: 'flex',
925
- }}
926
- >
927
- <div
928
- style={{
929
- flex: autoGrow ? '1 1 auto' : 1,
930
- overflow: autoGrow ? 'visible' : 'hidden',
931
- minHeight: 0,
932
- position: 'relative',
933
- }}
934
- >
935
- {isImageMode &&
936
- imageSrc &&
937
- (imageMode === 'edit' && imageEditorContainer ? (
938
- <ImageEditor
939
- filesContainer={imageEditorContainer}
940
- initialSrc={imageSrc}
941
- allowVersioning={allowVersioning}
942
- versioningAutoSaveIdleMs={versioningAutoSaveIdleMs}
943
- onExport={onImageExport}
944
- surface={colorScheme === 'dark' ? DARK_SURFACE : LIGHT_SURFACE}
945
- />
946
- ) : (
947
- <ImageViewer src={imageSrc} alt={imageAlt} theme={colorScheme} />
948
- ))}
949
- {/* Raw (Monaco) view. Always wrapped in `.squisq-editor-with-gutter`
950
- so toggling a pane on/off doesn't change the editor's tree
951
- position — Monaco stays mounted and `monacoEditor` in
952
- context stays stable, which is what `useHeadingLayout` needs
953
- to compute positions. */}
954
- {!isImageMode && activeView === 'raw' && (
955
- <div className="squisq-editor-with-gutter" key="raw-shell">
956
- {isMarkdownMode && outlineVisible && (
957
- <OutlinePanel key="outline" width={outlineWidth} readOnly={readOnly} />
958
- )}
959
- {isCardMode ? (
960
- <BlockCardView
961
- key="raw-card"
962
- blockCount={blockCount}
963
- activeBlockKey={activeBlockKey}
964
- onPrev={prevBlock}
965
- onNext={nextBlock}
966
- onAdd={addBlock}
967
- >
968
- <div key="raw-editor" className="squisq-raw-editor-container">
969
- <RawEditor
970
- monacoTheme={colorScheme === 'dark' ? 'vs-dark' : 'vs'}
971
- submitOnEnter={submitOnEnter}
972
- readOnly={readOnly}
973
- />
974
- </div>
975
- </BlockCardView>
976
- ) : (
977
- <div key="raw-editor" className="squisq-raw-editor-container">
978
- <RawEditor
979
- monacoTheme={colorScheme === 'dark' ? 'vs-dark' : 'vs'}
980
- submitOnEnter={submitOnEnter}
981
- readOnly={readOnly}
982
- />
983
- </div>
984
- )}
985
- {/* Renders nothing in normal flow — portals context
986
- sections into Monaco view zones via the context's
987
- monacoEditor. Code mode only. */}
988
- {isCodeMode && codeContext && (
989
- <CodeContextZones key="code-context" options={codeContext} />
990
- )}
991
- {isMarkdownMode && isCardMode && inlinePreviewVisible && (
992
- <BlockPreviewPanel key="block-preview" basePath={basePath} />
993
- )}
994
- {isMarkdownMode && !isCardMode && inlinePreviewVisible && (
995
- <InlinePreviewGutter
996
- key="inline"
997
- width={inlinePreviewWidth}
998
- basePath={basePath}
999
- mediaProvider={mediaProvider}
1000
- />
1001
- )}
1002
- </div>
1003
- )}
1004
- {/* WYSIWYG + Preview are markdown-only surfaces — skip them
1005
- entirely in code or image mode so Tiptap never initializes
1006
- and the preview pipeline stays idle. Same always-wrapped
1007
- pattern as the Raw branch above so pane toggles don't
1008
- remount Tiptap. */}
1009
- {isMarkdownMode && activeView === 'wysiwyg' && (
1010
- <div className="squisq-editor-with-gutter" key="wysiwyg-shell">
1011
- {outlineVisible && (
1012
- <OutlinePanel key="outline" width={outlineWidth} readOnly={readOnly} />
1013
- )}
1014
- {isCardMode ? (
1015
- <BlockCardView
1016
- key="wysiwyg-card"
1017
- blockCount={blockCount}
1018
- activeBlockKey={activeBlockKey}
1019
- onPrev={prevBlock}
1020
- onNext={nextBlock}
1021
- onAdd={addBlock}
1022
- >
1023
- <WysiwygEditor
1024
- key="wysiwyg-editor"
1025
- submitOnEnter={submitOnEnter}
1026
- placeholder={placeholder}
1027
- readOnly={readOnly}
1028
- />
1029
- </BlockCardView>
1030
- ) : (
1031
- <WysiwygEditor
1032
- key="wysiwyg-editor"
1033
- submitOnEnter={submitOnEnter}
1034
- placeholder={placeholder}
1035
- readOnly={readOnly}
1036
- />
1037
- )}
1038
- {isCardMode && inlinePreviewVisible && (
1039
- <BlockPreviewPanel key="block-preview" basePath={basePath} />
1040
- )}
1041
- {!isCardMode && inlinePreviewVisible && (
1042
- <InlinePreviewGutter
1043
- key="inline"
1044
- width={inlinePreviewWidth}
1045
- basePath={basePath}
1046
- mediaProvider={mediaProvider}
1047
- />
1048
- )}
1049
- </div>
1050
- )}
1051
- {isMarkdownMode && isPreview && (
1052
- <PreviewPanel basePath={basePath} workspaceContainer={workspaceContainer} />
1053
- )}
1054
- </div>
1055
-
1056
- {isMarkdownMode && showFiles && (
1057
- <MediaBin
1058
- mediaProvider={mediaProvider}
1059
- isDark={isDark}
1060
- refreshKey={mediaListRefreshKey}
1061
- usedMediaPaths={usedMediaPaths}
1062
- onMediaUploaded={handleMediaUploaded}
1063
- onMediaRemoved={handleMediaRemoved}
1064
- onCountChange={setMediaCount}
1065
- />
1066
- )}
1067
-
1068
- {/* Docked custom-theme designer — a flex sibling of the preview, so
1069
- opening it reflows the preview narrower. Renders nothing when the
1070
- designer is closed. */}
1071
- {isMarkdownMode && <ThemeDesignerDock />}
1072
-
1073
- {/* Drop zone overlay — image / text drop UX is markdown-specific.
1074
- In WYSIWYG, image drops are handled directly by Tiptap's
1075
- `handleDrop` (uploads to the MediaProvider and inserts an
1076
- image node at the mouse position). The overlay would sit on
1077
- top with z-index: 50 and intercept the drop, so we skip it
1078
- when the dragged content is media-only on the WYSIWYG view —
1079
- the user gets a one-step "drop where you want it" flow
1080
- instead of a two-step "drop in bin, then insert" flow. */}
1081
- {isMarkdownMode &&
1082
- isDragging &&
1083
- !(activeView === 'wysiwyg' && dragContentType === 'media') && (
1084
- <DropZoneOverlay
1085
- dragContentType={dragContentType}
1086
- zoneProps={zoneProps}
1087
- hasMediaProvider={mediaProvider !== null}
1088
- />
1089
- )}
1090
- </div>
1091
-
1092
- {/* Timeline track — horizontal strip of block + media bars shown
1093
- below the editor in Timeline view. The card editor above (driven by
1094
- the block navigator) shows whichever block is selected here. */}
1095
- {isTimelineMode && <TimelineTrack />}
1096
-
1097
- {/* Status bar — word / char / line / block counts. Host can
1098
- suppress via `showStatusBar={false}` for embedded chat-style
1099
- composers where the stats are noise. The image viewer has its
1100
- own dimension/zoom status row, so suppress here too. */}
1101
- {statusBarVisible && !isImageMode && <StatusBar />}
1102
- </PresentationModeProvider>
1103
- </PreviewSettingsProvider>
1104
- </CustomThemeProvider>
1105
- <TooltipLayer />
1106
- {imageEditTarget !== null && mediaProvider && (
1107
- <ImageEditModal
1108
- relativePath={imageEditTarget}
1109
- container={workspaceContainer ?? imageEditFallbackContainer}
1110
- mediaProvider={mediaProvider}
1111
- onClose={closeImageEdit}
1112
- onSaved={() => {
1113
- bumpMediaRevision();
1114
- closeImageEdit();
1115
- }}
1116
- allowVersioning={allowVersioning}
1117
- versioningAutoSaveIdleMs={versioningAutoSaveIdleMs}
1118
- shellTheme={colorScheme}
1119
- />
1120
- )}
1121
- </div>
1122
- );
1123
- }
1124
-
1125
- // ─── ImageEditModal ────────────────────────────────────────
1126
-
1127
- interface ImageEditModalProps {
1128
- relativePath: string;
1129
- /**
1130
- * Host's `ContentContainer`. When non-null the editor's per-image
1131
- * sidecar is scoped underneath it as `.imageEdits/<sanitized>/` so
1132
- * version snapshots travel with the doc. When null (host wired only a
1133
- * `mediaProvider`) the modal creates a fresh in-memory container for
1134
- * the edit session — export still writes the result back through
1135
- * `mediaProvider`.
1136
- */
1137
- container: ContentContainer | null;
1138
- mediaProvider: MediaProvider;
1139
- onClose: () => void;
1140
- onSaved: () => void;
1141
- allowVersioning: boolean;
1142
- versioningAutoSaveIdleMs?: number;
1143
- /** EditorShell's `colorScheme` prop — 'light' or 'dark'. Threaded through
1144
- * so the image editor chrome matches the host shell. */
1145
- shellTheme?: 'light' | 'dark';
1146
- }
1147
-
1148
- /**
1149
- * Modal overlay that mounts a full `<ImageEditor>` against a sidecar
1150
- * container scoped under `.imageEdits/<sanitized-path>/` of the document's
1151
- * `ContentContainer`. Opens when a user clicks the "Edit" affordance on an
1152
- * image in the WYSIWYG view; on Export, rewrites the original image bytes
1153
- * via `mediaProvider.addMedia(relativePath, blob, mime)` and bumps
1154
- * `mediaRevision` so live `<img>` nodes pick up the new content.
1155
- */
1156
- function ImageEditModal({
1157
- relativePath,
1158
- container,
1159
- mediaProvider,
1160
- onClose,
1161
- onSaved,
1162
- allowVersioning,
1163
- versioningAutoSaveIdleMs,
1164
- shellTheme,
1165
- }: ImageEditModalProps) {
1166
- const modalRef = useRef<HTMLDivElement>(null);
1167
- const surfaceRef = useRef<HTMLDivElement>(null);
1168
- const onCloseRef = useRef(onClose);
1169
- onCloseRef.current = onClose;
1170
- const extension = relativePath.split(/[?#]/, 1)[0]?.split('.').pop()?.toLowerCase();
1171
- const saveFormat: 'png' | 'jpeg' | 'webp' | null =
1172
- extension === 'png'
1173
- ? 'png'
1174
- : extension === 'jpg' || extension === 'jpeg'
1175
- ? 'jpeg'
1176
- : extension === 'webp'
1177
- ? 'webp'
1178
- : null;
1179
- // Each unique image path gets its own sidecar so multiple images in the
1180
- // same doc can be edited independently without colliding state. When the
1181
- // host didn't supply a `container`, fall back to a fresh in-memory one
1182
- // — the edit session is transient anyway and the final raster is what
1183
- // gets written back through `mediaProvider`.
1184
- const sidecar = useMemo(() => {
1185
- const sanitized = relativePath.replace(/[^a-zA-Z0-9._-]+/g, '_');
1186
- let hash = 2166136261;
1187
- for (let i = 0; i < relativePath.length; i++) {
1188
- hash ^= relativePath.charCodeAt(i);
1189
- hash = Math.imul(hash, 16777619);
1190
- }
1191
- const scopedName = `${sanitized}-${(hash >>> 0).toString(36)}`;
1192
- const parent: ContentContainer = container ?? new MemoryContentContainer();
1193
- return scopeContainer(parent, `.imageEdits/${scopedName}`);
1194
- }, [container, relativePath]);
1195
-
1196
- const [initialSrc, setInitialSrc] = useState<string | null>(null);
1197
- const [resolveError, setResolveError] = useState<string | null>(null);
1198
- useEffect(() => {
1199
- let cancelled = false;
1200
- setInitialSrc(null);
1201
- setResolveError(null);
1202
- mediaProvider.resolveUrl(relativePath).then(
1203
- (url) => {
1204
- if (!cancelled) setInitialSrc(url);
1205
- },
1206
- (err: unknown) => {
1207
- if (!cancelled) {
1208
- setResolveError(err instanceof Error ? err.message : String(err));
1209
- }
1210
- },
1211
- );
1212
- return () => {
1213
- cancelled = true;
1214
- };
1215
- }, [mediaProvider, relativePath]);
1216
-
1217
- const handleExport = useCallback(
1218
- (blob: Blob, format: 'png' | 'jpeg' | 'webp') => {
1219
- const mime = `image/${format}`;
1220
- mediaProvider.addMedia(relativePath, blob, mime).then(
1221
- () => onSaved(),
1222
- (err: unknown) => {
1223
- console.error('Failed to write image back:', err instanceof Error ? err.message : err);
1224
- },
1225
- );
1226
- },
1227
- [mediaProvider, relativePath, onSaved],
1228
- );
1229
-
1230
- // Close on Escape — global listener so it works regardless of focus.
1231
- useEffect(() => {
1232
- const previousFocus = document.activeElement as HTMLElement | null;
1233
- const shell = modalRef.current?.closest('.squisq-editor-shell');
1234
- const inertSiblings = shell
1235
- ? Array.from(shell.children).filter((element) => !element.contains(modalRef.current))
1236
- : [];
1237
- inertSiblings.forEach((element) => {
1238
- (element as HTMLElement).inert = true;
1239
- });
1240
- surfaceRef.current
1241
- ?.querySelector<HTMLElement>(
1242
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
1243
- )
1244
- ?.focus();
1245
- const handler = (e: KeyboardEvent) => {
1246
- if (e.key === 'Escape') {
1247
- e.preventDefault();
1248
- onCloseRef.current();
1249
- return;
1250
- }
1251
- if (e.key !== 'Tab' || !surfaceRef.current) return;
1252
- const focusable = Array.from(
1253
- surfaceRef.current.querySelectorAll<HTMLElement>(
1254
- 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
1255
- ),
1256
- );
1257
- if (focusable.length === 0) return;
1258
- const first = focusable[0];
1259
- const last = focusable[focusable.length - 1];
1260
- if (e.shiftKey && document.activeElement === first) {
1261
- e.preventDefault();
1262
- last.focus();
1263
- } else if (!e.shiftKey && document.activeElement === last) {
1264
- e.preventDefault();
1265
- first.focus();
1266
- }
1267
- };
1268
- window.addEventListener('keydown', handler);
1269
- return () => {
1270
- window.removeEventListener('keydown', handler);
1271
- inertSiblings.forEach((element) => {
1272
- (element as HTMLElement).inert = false;
1273
- });
1274
- previousFocus?.focus();
1275
- };
1276
- }, []);
1277
-
1278
- return (
1279
- <div
1280
- ref={modalRef}
1281
- className="squisq-image-edit-modal"
1282
- data-testid="image-edit-modal"
1283
- role="dialog"
1284
- aria-modal="true"
1285
- aria-label={`Edit ${relativePath}`}
1286
- onClick={(e) => {
1287
- // Click on the dim backdrop (but not on the surface) → close.
1288
- if (e.target === e.currentTarget) onClose();
1289
- }}
1290
- >
1291
- <div ref={surfaceRef} className="squisq-image-edit-modal__surface">
1292
- <header className="squisq-image-edit-modal__header">
1293
- <span className="squisq-image-edit-modal__title">Edit image</span>
1294
- <span className="squisq-image-edit-modal__path">{relativePath}</span>
1295
- <button
1296
- type="button"
1297
- className="squisq-image-edit-modal__close"
1298
- data-testid="image-edit-modal-close"
1299
- onClick={onClose}
1300
- aria-label="Close image editor"
1301
- >
1302
- ×
1303
- </button>
1304
- </header>
1305
- <div className="squisq-image-edit-modal__body">
1306
- {resolveError ? (
1307
- <div className="squisq-image-edit-modal__error">
1308
- Failed to load image: {resolveError}
1309
- </div>
1310
- ) : !saveFormat ? (
1311
- <div className="squisq-image-edit-modal__error">
1312
- Editing supports PNG, JPEG, and WebP images without changing the asset type.
1313
- </div>
1314
- ) : !initialSrc ? (
1315
- <div className="squisq-image-edit-modal__loading">Loading image…</div>
1316
- ) : (
1317
- <ImageEditor
1318
- filesContainer={sidecar}
1319
- initialSrc={initialSrc}
1320
- allowVersioning={allowVersioning}
1321
- versioningAutoSaveIdleMs={versioningAutoSaveIdleMs}
1322
- onExport={handleExport}
1323
- saveBehavior="export"
1324
- saveFormat={saveFormat}
1325
- saveLabel="Save and close"
1326
- saveTitle="Save changes back to the image and close"
1327
- surface={shellTheme === 'dark' ? DARK_SURFACE : LIGHT_SURFACE}
1328
- />
1329
- )}
1330
- </div>
1331
- </div>
1332
- </div>
1333
- );
1334
- }