@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,1250 +0,0 @@
1
- /* eslint-disable react-refresh/only-export-components */
2
- /**
3
- * PreviewControls
4
- *
5
- * Shared context and inline toolbar component for preview settings
6
- * (viewport format, display mode, theme, transform, caption style).
7
- *
8
- * The context is provided by EditorShell and consumed by both:
9
- * - PreviewControls (toolbar dropdowns, rendered in the main toolbar)
10
- * - PreviewPanel (the actual player, which reads the selected values)
11
- */
12
-
13
- import {
14
- createContext,
15
- useCallback,
16
- useContext,
17
- useState,
18
- useMemo,
19
- useEffect,
20
- useId,
21
- useLayoutEffect,
22
- useRef,
23
- } from 'react';
24
- import type { ReactNode } from 'react';
25
- import { createPortal } from 'react-dom';
26
- import type { DisplayMode, CaptionStyle } from '@bendyline/squisq-react';
27
- import type { ViewportPreset, ViewportConfig } from '@bendyline/squisq/schemas';
28
- import { VIEWPORT_PRESETS, getThemeSummaries } from '@bendyline/squisq/schemas';
29
- import type { Theme } from '@bendyline/squisq/schemas';
30
- import { ThemePicker } from './ThemePicker';
31
- import { getTransformStyleSummaries } from '@bendyline/squisq/transform';
32
- import type { Doc } from '@bendyline/squisq/schemas';
33
- import { readFrontmatterThemeId, setFrontmatterValues } from '@bendyline/squisq/markdown';
34
- import {
35
- resolveThemeForDoc,
36
- writeCustomThemesToFrontmatter,
37
- writeCustomTemplatesToFrontmatter,
38
- FRONTMATTER_CUSTOM_THEMES_KEY,
39
- FRONTMATTER_CUSTOM_TEMPLATES_KEY,
40
- } from '@bendyline/squisq/doc';
41
- import { useEditorContext } from './EditorContext';
42
- import {
43
- useCustomThemes,
44
- CustomThemeDialog,
45
- type ThemeSaveTarget,
46
- type ThemeSaveExtras,
47
- } from './customThemes';
48
- import { Icon } from './Icon';
49
- import { resolvePersistedTransformStyleId } from './transformStyleId';
50
- import {
51
- FRONTMATTER_SETTING_DEFAULTS,
52
- FRONTMATTER_SETTING_KEYS,
53
- omitFrontmatterDefault,
54
- } from './frontmatterSettings';
55
-
56
- // ── Context ──────────────────────────────────────────────────────
57
-
58
- /** Caption selection: off, or one of the two enabled styles. */
59
- export type CaptionMode = 'off' | CaptionStyle;
60
-
61
- export interface PreviewSettings {
62
- activePreset: ViewportPreset;
63
- setSelectedPreset: (preset: ViewportPreset | null) => void;
64
- activeViewport: ViewportConfig;
65
- activeDisplayMode: DisplayMode;
66
- setSelectedDisplayMode: (mode: DisplayMode | null) => void;
67
- activeThemeId: string;
68
- setSelectedThemeId: (id: string | null) => void;
69
- activeTheme: Theme;
70
- activeTransformStyle: string;
71
- setSelectedTransformStyle: (id: string | null) => void;
72
- /** The caption style used when captions are enabled. */
73
- activeCaptionStyle: CaptionStyle;
74
- /** Whether captions are shown at all (the 'off' arm of the tri-state). */
75
- activeCaptionsEnabled: boolean;
76
- /** Set the caption mode: 'off' hides captions, 'standard'/'social' enable
77
- * that style. The single entry point so the toggle buttons persist in one
78
- * frontmatter write. */
79
- setCaptionMode: (mode: CaptionMode) => void;
80
- /** Whether Squisq should synthesize and show its managed cover slide. */
81
- activeCoverSlide: boolean;
82
- /** Enable/disable the managed cover slide. */
83
- setCoverSlideEnabled: (enabled: boolean) => void;
84
- /** User-authored themes (doc + browser library) for the picker's "Custom" group. */
85
- customThemes: Theme[];
86
- /** Open the custom-theme designer for a theme (or null to create a new one). */
87
- openThemeDesigner: (theme: Theme | null) => void;
88
- /** Remove a custom theme from the doc and the library. */
89
- deleteCustomTheme: (id: string) => void;
90
- /** Config for the docked theme designer, or null when closed. Rendered by
91
- * `<ThemeDesignerDock>` in the editor's content row. */
92
- themeDesigner: ThemeDesignerConfig | null;
93
- }
94
-
95
- /** Everything `<ThemeDesignerDock>` needs to render the designer pane. */
96
- export interface ThemeDesignerConfig {
97
- value: Theme | null;
98
- onChange: (theme: Theme) => void;
99
- onSave: (theme: Theme, target: ThemeSaveTarget, extras?: ThemeSaveExtras) => void;
100
- onClose: () => void;
101
- }
102
-
103
- const PreviewSettingsContext = createContext<PreviewSettings | null>(null);
104
-
105
- export function usePreviewSettings(): PreviewSettings {
106
- const ctx = useContext(PreviewSettingsContext);
107
- if (!ctx) throw new Error('usePreviewSettings must be used within PreviewSettingsProvider');
108
- return ctx;
109
- }
110
-
111
- /**
112
- * Like {@link usePreviewSettings} but returns `null` when no provider is
113
- * mounted. For consumers (e.g. WysiwygEditor) that want to react to the
114
- * active theme when available without forcing every test harness to
115
- * wrap them in a PreviewSettingsProvider.
116
- */
117
- export function usePreviewSettingsOptional(): PreviewSettings | null {
118
- return useContext(PreviewSettingsContext);
119
- }
120
-
121
- // ── Frontmatter resolvers ────────────────────────────────────────
122
-
123
- function resolveRenderAs(value: unknown): ViewportPreset | null {
124
- if (typeof value !== 'string') return null;
125
- const v = value.trim().toLowerCase();
126
- const mapping: Record<string, ViewportPreset> = {
127
- landscape: 'landscape',
128
- '16:9': 'landscape',
129
- widescreen: 'landscape',
130
- portrait: 'portrait',
131
- '9:16': 'portrait',
132
- vertical: 'portrait',
133
- stories: 'portrait',
134
- square: 'square',
135
- '1:1': 'square',
136
- standard: 'standard',
137
- '4:3': 'standard',
138
- };
139
- return mapping[v] ?? null;
140
- }
141
-
142
- function resolveDisplayMode(value: unknown): DisplayMode | null {
143
- if (typeof value !== 'string') return null;
144
- const v = value.trim().toLowerCase();
145
- if (v === 'video' || v === 'slideshow' || v === 'linear' || v === 'narrate') return v;
146
- if (v === 'slides' || v === 'presentation' || v === 'deck') return 'slideshow';
147
- if (v === 'teleprompter' || v === 'prompter') return 'narrate';
148
- // Frontmatter uses product-facing names: Document is the plain text/HTML
149
- // preview, Page is the styled Squisq page view. The raw DisplayMode values
150
- // are older and remain stable for the public React API.
151
- if (v === 'page' || v === 'paged') return 'linear';
152
- if (v === 'document' || v === 'scroll' || v === 'html' || v === 'plain' || v === 'reader') {
153
- return 'page';
154
- }
155
- return null;
156
- }
157
-
158
- const VALID_THEME_IDS = new Set(getThemeSummaries().map((s) => s.id));
159
-
160
- function resolveFrontmatterTheme(value: unknown, customIds?: Set<string>): string | null {
161
- if (typeof value !== 'string') return null;
162
- const raw = value.trim();
163
- const v = raw.toLowerCase();
164
- if (VALID_THEME_IDS.has(v)) return v;
165
- const normalized = v.replace(/\s+/g, '-');
166
- if (VALID_THEME_IDS.has(normalized)) return normalized;
167
- // Custom theme ids are doc-defined slugs (lowercase from the customizer).
168
- // Admit them so an inline theme selection isn't rejected back to 'standard'.
169
- if (customIds?.has(raw)) return raw;
170
- if (customIds?.has(v)) return v;
171
- return null;
172
- }
173
-
174
- function resolveFrontmatterCaptionMode(value: unknown): CaptionMode | null {
175
- if (typeof value !== 'string') return null;
176
- const v = value.trim().toLowerCase();
177
- if (v === 'off' || v === 'none' || v === 'hidden' || v === 'false' || v === 'no') return 'off';
178
- if (v === 'standard' || v === 'cc' || v === 'captions') return 'standard';
179
- if (v === 'social' || v === 'instagram' || v === 'tiktok' || v === 'reels') return 'social';
180
- return null;
181
- }
182
-
183
- function resolveFrontmatterBoolean(value: unknown): boolean | null {
184
- if (typeof value === 'boolean') return value;
185
- if (typeof value !== 'string') return null;
186
- const v = value.trim().toLowerCase();
187
- if (v === 'true' || v === 'yes' || v === 'on' || v === 'show' || v === 'visible') return true;
188
- if (v === 'false' || v === 'no' || v === 'off' || v === 'hide' || v === 'hidden') return false;
189
- return null;
190
- }
191
-
192
- // ── Provider ─────────────────────────────────────────────────────
193
-
194
- export interface PreviewSettingsProviderProps {
195
- doc: Doc | null;
196
- children: ReactNode;
197
- /**
198
- * Optional Theme to use for the preview, regardless of `Doc.themeId` or
199
- * the user's theme dropdown selection. Used by the theme customizer to
200
- * preview an in-progress theme without mutating the document. When
201
- * present, `activeTheme` is this value and `activeThemeId` is its `id`.
202
- */
203
- themeOverride?: Theme | null;
204
- }
205
-
206
- function readFrontmatterKey(
207
- fm: Record<string, unknown> | undefined,
208
- canonical: string,
209
- legacy: string,
210
- ): unknown {
211
- if (!fm) return undefined;
212
- return Object.prototype.hasOwnProperty.call(fm, canonical) ? fm[canonical] : fm[legacy];
213
- }
214
-
215
- export function PreviewSettingsProvider({
216
- doc,
217
- children,
218
- themeOverride,
219
- }: PreviewSettingsProviderProps) {
220
- const frontmatter = doc?.frontmatter;
221
- const { markdownSource, setMarkdownSource, allowNarrate } = useEditorContext();
222
-
223
- const persistFrontmatter = useCallback(
224
- (updates: Record<string, string | number | boolean | null | undefined>) => {
225
- const next = setFrontmatterValues(markdownSource, updates);
226
- if (next !== markdownSource) {
227
- setMarkdownSource(next);
228
- }
229
- },
230
- [markdownSource, setMarkdownSource],
231
- );
232
-
233
- // Viewport
234
- const fmPreset = useMemo(
235
- () => resolveRenderAs(frontmatter?.['document-render-as']),
236
- [frontmatter],
237
- );
238
- const [selectedPreset, setSelectedPreset] = useState<ViewportPreset | null>(null);
239
- useEffect(() => setSelectedPreset(null), [fmPreset]);
240
- const activePreset = selectedPreset ?? fmPreset ?? 'landscape';
241
- const activeViewport = VIEWPORT_PRESETS[activePreset];
242
-
243
- // Display mode. A frontmatter-forced `narrate` clamps back to video when
244
- // the host disabled the mode, so hostile frontmatter can't turn it on.
245
- const fmMode = useMemo(() => resolveDisplayMode(frontmatter?.['display-mode']), [frontmatter]);
246
- const [selectedDisplayMode, setSelectedDisplayMode] = useState<DisplayMode | null>(null);
247
- useEffect(() => setSelectedDisplayMode(null), [fmMode]);
248
- const requestedDisplayMode = selectedDisplayMode ?? fmMode ?? 'slideshow';
249
- const activeDisplayMode =
250
- requestedDisplayMode === 'narrate' && !allowNarrate ? 'video' : requestedDisplayMode;
251
-
252
- // Custom themes (doc + browser library). `useCustomThemes` returns null when
253
- // no provider is mounted; document-scoped themes still remain available.
254
- const custom = useCustomThemes();
255
- const docThemes = useMemo(
256
- () => custom?.docThemes ?? doc?.customThemes ?? [],
257
- [custom, doc?.customThemes],
258
- );
259
- const customThemes = useMemo(() => custom?.allThemes ?? docThemes, [custom, docThemes]);
260
- const customIds = useMemo(() => new Set(customThemes.map((t) => t.id)), [customThemes]);
261
-
262
- // Theme — persisted to `squisq-theme`; `themeId` / `theme` remain readable.
263
- const fmTheme = useMemo(
264
- () => resolveFrontmatterTheme(readFrontmatterThemeId(frontmatter), customIds),
265
- [frontmatter, customIds],
266
- );
267
- const [selectedThemeId, setSelectedThemeId] = useState<string | null>(null);
268
- useEffect(() => setSelectedThemeId(null), [fmTheme]);
269
- const resolvedThemeId = selectedThemeId ?? fmTheme ?? FRONTMATTER_SETTING_DEFAULTS.theme;
270
- // Doc themes precede browser-library themes in `allThemes`; choosing a
271
- // library-only entry copies it into the document below for portable export.
272
- const resolvedTheme = useMemo(
273
- () =>
274
- customThemes.find((theme) => theme.id === resolvedThemeId) ??
275
- resolveThemeForDoc(doc, resolvedThemeId),
276
- [customThemes, doc, resolvedThemeId],
277
- );
278
-
279
- // In-progress theme from the designer dialog; previews live without mutating
280
- // the doc until the user saves.
281
- const [previewTheme, setPreviewTheme] = useState<Theme | null>(null);
282
- const [designer, setDesigner] = useState<{ open: boolean; editing: Theme | null }>({
283
- open: false,
284
- editing: null,
285
- });
286
-
287
- // Precedence: an active designer preview > external themeOverride > the
288
- // dropdown/frontmatter selection.
289
- const activeThemeId = previewTheme?.id ?? themeOverride?.id ?? resolvedThemeId;
290
- const activeTheme = previewTheme ?? themeOverride ?? resolvedTheme;
291
- const handleSetThemeId = useCallback(
292
- (id: string | null) => {
293
- setSelectedThemeId(id);
294
- if (id === null) return;
295
- const selectedCustom = customThemes.find((theme) => theme.id === id);
296
- const alreadyDocScoped = docThemes.some((theme) => theme.id === id);
297
- const updates: Record<string, string | null> = {
298
- [FRONTMATTER_SETTING_KEYS.theme.canonical]: omitFrontmatterDefault(
299
- id,
300
- FRONTMATTER_SETTING_DEFAULTS.theme,
301
- ),
302
- [FRONTMATTER_SETTING_KEYS.theme.legacy[0]]: null,
303
- [FRONTMATTER_SETTING_KEYS.theme.legacy[1]]: null,
304
- };
305
- if (selectedCustom && !alreadyDocScoped) {
306
- updates[FRONTMATTER_CUSTOM_THEMES_KEY] =
307
- writeCustomThemesToFrontmatter([...docThemes, selectedCustom]) ?? null;
308
- }
309
- persistFrontmatter(updates);
310
- },
311
- [customThemes, docThemes, persistFrontmatter],
312
- );
313
-
314
- const openThemeDesigner = useCallback((theme: Theme | null) => {
315
- setDesigner({ open: true, editing: theme });
316
- setPreviewTheme(theme);
317
- }, []);
318
- const closeThemeDesigner = useCallback(() => {
319
- setDesigner({ open: false, editing: null });
320
- setPreviewTheme(null);
321
- }, []);
322
- const handleDesignerSave = useCallback(
323
- (theme: Theme, target: ThemeSaveTarget, extras?: ThemeSaveExtras) => {
324
- if (target === 'library') {
325
- // Imported slide layouts are doc-scoped in v1; the dialog hints at this.
326
- custom?.upsertLibraryTheme(theme);
327
- } else {
328
- // Write the theme payload, its selection, AND any imported layout
329
- // templates in a SINGLE frontmatter update. Separate
330
- // `setMarkdownSource` calls would each derive from the same stale
331
- // source, so later writes would clobber earlier ones.
332
- const docThemes = custom?.docThemes ?? [];
333
- const idx = docThemes.findIndex((t) => t.id === theme.id);
334
- const nextThemes =
335
- idx >= 0 ? docThemes.map((t, i) => (i === idx ? theme : t)) : [...docThemes, theme];
336
- const updates: Record<string, string | null> = {
337
- [FRONTMATTER_CUSTOM_THEMES_KEY]: writeCustomThemesToFrontmatter(nextThemes) ?? null,
338
- [FRONTMATTER_SETTING_KEYS.theme.canonical]: theme.id,
339
- };
340
- if (extras?.templates && extras.templates.length > 0) {
341
- const existing = doc?.customTemplates ?? [];
342
- const merged = [
343
- ...existing.filter((t) => !extras.templates!.some((n) => n.name === t.name)),
344
- ...extras.templates,
345
- ];
346
- updates[FRONTMATTER_CUSTOM_TEMPLATES_KEY] =
347
- writeCustomTemplatesToFrontmatter(merged) ?? null;
348
- }
349
- persistFrontmatter(updates);
350
- setSelectedThemeId(theme.id);
351
- }
352
- closeThemeDesigner();
353
- },
354
- [custom, doc, persistFrontmatter, closeThemeDesigner],
355
- );
356
- const deleteCustomTheme = useCallback(
357
- (id: string) => {
358
- custom?.removeDocTheme(id);
359
- custom?.removeLibraryTheme(id);
360
- },
361
- [custom],
362
- );
363
-
364
- // Transform — persisted to `squisq-transform` (legacy `transform-style` read for compat)
365
- const fmTransform = useMemo(
366
- () =>
367
- resolvePersistedTransformStyleId(
368
- readFrontmatterKey(
369
- frontmatter,
370
- FRONTMATTER_SETTING_KEYS.transform.canonical,
371
- FRONTMATTER_SETTING_KEYS.transform.legacy,
372
- ),
373
- ),
374
- [frontmatter],
375
- );
376
- const [selectedTransformStyle, setSelectedTransformStyle] = useState<string | null>(null);
377
- useEffect(() => setSelectedTransformStyle(null), [fmTransform]);
378
- const activeTransformStyle = selectedTransformStyle ?? fmTransform ?? '';
379
- const handleSetTransformStyle = useCallback(
380
- (id: string | null) => {
381
- setSelectedTransformStyle(id);
382
- if (id !== null) {
383
- // Empty string = "None" — remove the key rather than writing a blank value.
384
- persistFrontmatter({
385
- [FRONTMATTER_SETTING_KEYS.transform.canonical]: omitFrontmatterDefault(
386
- id,
387
- FRONTMATTER_SETTING_DEFAULTS.transform,
388
- ),
389
- [FRONTMATTER_SETTING_KEYS.transform.legacy]: null,
390
- });
391
- }
392
- },
393
- [persistFrontmatter],
394
- );
395
-
396
- // Caption mode — 'off' | 'standard' | 'social'. Persisted to `squisq-captions`
397
- // (legacy `caption-style` read for compat). `activeCaptionStyle` is the style
398
- // used when enabled; `activeCaptionsEnabled` is the off/on split.
399
- const fmCaptionMode = useMemo(
400
- () =>
401
- resolveFrontmatterCaptionMode(
402
- readFrontmatterKey(
403
- frontmatter,
404
- FRONTMATTER_SETTING_KEYS.captions.canonical,
405
- FRONTMATTER_SETTING_KEYS.captions.legacy,
406
- ),
407
- ),
408
- [frontmatter],
409
- );
410
- const [selectedCaptionMode, setSelectedCaptionMode] = useState<CaptionMode | null>(null);
411
- useEffect(() => setSelectedCaptionMode(null), [fmCaptionMode]);
412
- const activeCaptionMode =
413
- selectedCaptionMode ?? fmCaptionMode ?? FRONTMATTER_SETTING_DEFAULTS.captions;
414
- const activeCaptionsEnabled = activeCaptionMode !== 'off';
415
- const activeCaptionStyle: CaptionStyle = activeCaptionMode === 'social' ? 'social' : 'standard';
416
- const handleSetCaptionMode = useCallback(
417
- (mode: CaptionMode) => {
418
- setSelectedCaptionMode(mode);
419
- persistFrontmatter({
420
- [FRONTMATTER_SETTING_KEYS.captions.canonical]: omitFrontmatterDefault(
421
- mode,
422
- FRONTMATTER_SETTING_DEFAULTS.captions,
423
- ),
424
- [FRONTMATTER_SETTING_KEYS.captions.legacy]: null,
425
- });
426
- },
427
- [persistFrontmatter],
428
- );
429
-
430
- // Managed cover slide — generated from the document startBlock. Defaults on
431
- // for existing documents; authors can persist an explicit off switch.
432
- const fmCoverSlide = useMemo(
433
- () =>
434
- resolveFrontmatterBoolean(
435
- readFrontmatterKey(
436
- frontmatter,
437
- FRONTMATTER_SETTING_KEYS.coverSlide.canonical,
438
- FRONTMATTER_SETTING_KEYS.coverSlide.legacy,
439
- ),
440
- ),
441
- [frontmatter],
442
- );
443
- const [selectedCoverSlide, setSelectedCoverSlide] = useState<boolean | null>(null);
444
- useEffect(() => setSelectedCoverSlide(null), [fmCoverSlide]);
445
- const activeCoverSlide =
446
- selectedCoverSlide ?? fmCoverSlide ?? FRONTMATTER_SETTING_DEFAULTS.coverSlide;
447
- const handleSetCoverSlideEnabled = useCallback(
448
- (enabled: boolean) => {
449
- setSelectedCoverSlide(enabled);
450
- persistFrontmatter({
451
- // The cover is enabled by default, so only persist the non-default
452
- // state. Pass the boolean through so YAML writes `false`, not
453
- // the string `"false"`.
454
- [FRONTMATTER_SETTING_KEYS.coverSlide.canonical]: omitFrontmatterDefault(
455
- enabled,
456
- FRONTMATTER_SETTING_DEFAULTS.coverSlide,
457
- ),
458
- [FRONTMATTER_SETTING_KEYS.coverSlide.legacy]: null,
459
- });
460
- },
461
- [persistFrontmatter],
462
- );
463
-
464
- // Config for the docked designer (rendered by `<ThemeDesignerDock>` in the
465
- // editor content row). Null when closed. setPreviewTheme is a stable setter.
466
- const themeDesigner = useMemo<ThemeDesignerConfig | null>(
467
- () =>
468
- designer.open
469
- ? {
470
- value: designer.editing,
471
- onChange: setPreviewTheme,
472
- onSave: handleDesignerSave,
473
- onClose: closeThemeDesigner,
474
- }
475
- : null,
476
- [designer.open, designer.editing, handleDesignerSave, closeThemeDesigner],
477
- );
478
-
479
- const value = useMemo<PreviewSettings>(
480
- () => ({
481
- activePreset,
482
- setSelectedPreset,
483
- activeViewport,
484
- activeDisplayMode,
485
- setSelectedDisplayMode,
486
- activeThemeId,
487
- setSelectedThemeId: handleSetThemeId,
488
- activeTheme,
489
- activeTransformStyle,
490
- setSelectedTransformStyle: handleSetTransformStyle,
491
- activeCaptionStyle,
492
- activeCaptionsEnabled,
493
- setCaptionMode: handleSetCaptionMode,
494
- activeCoverSlide,
495
- setCoverSlideEnabled: handleSetCoverSlideEnabled,
496
- customThemes,
497
- openThemeDesigner,
498
- deleteCustomTheme,
499
- themeDesigner,
500
- }),
501
- [
502
- activePreset,
503
- activeViewport,
504
- activeDisplayMode,
505
- activeThemeId,
506
- activeTheme,
507
- activeTransformStyle,
508
- activeCaptionStyle,
509
- activeCaptionsEnabled,
510
- activeCoverSlide,
511
- handleSetThemeId,
512
- handleSetTransformStyle,
513
- handleSetCaptionMode,
514
- handleSetCoverSlideEnabled,
515
- customThemes,
516
- openThemeDesigner,
517
- deleteCustomTheme,
518
- themeDesigner,
519
- ],
520
- );
521
-
522
- return (
523
- <PreviewSettingsContext.Provider value={value}>{children}</PreviewSettingsContext.Provider>
524
- );
525
- }
526
-
527
- /**
528
- * ThemeDesignerDock — renders the docked custom-theme designer pane when open.
529
- * Placed as a flex sibling of the preview in `EditorShell`'s content row so the
530
- * preview reflows narrower beside it. Renders nothing when the designer is
531
- * closed. Must be mounted inside a `PreviewSettingsProvider`.
532
- */
533
- export function ThemeDesignerDock() {
534
- const { themeDesigner } = usePreviewSettings();
535
- if (!themeDesigner) return null;
536
- return (
537
- <CustomThemeDialog
538
- value={themeDesigner.value}
539
- onChange={themeDesigner.onChange}
540
- onSave={themeDesigner.onSave}
541
- onClose={themeDesigner.onClose}
542
- />
543
- );
544
- }
545
-
546
- // ── Dropdown options ─────────────────────────────────────────────
547
-
548
- /**
549
- * Aspect-ratio presets surfaced as the segmented {@link PreviewFormatSwitch}
550
- * on the left of the toolbar. `w`/`h` are the glyph rectangle dimensions (in a
551
- * 16×16 viewBox) drawn by {@link AspectIcon} to depict each ratio.
552
- */
553
- const FORMAT_SWITCH_OPTIONS: { key: ViewportPreset; label: string; w: number; h: number }[] = [
554
- { key: 'landscape', label: '16:9', w: 13, h: 7 },
555
- { key: 'square', label: '1:1', w: 10, h: 10 },
556
- { key: 'portrait', label: '9:16', w: 7, h: 12 },
557
- { key: 'standard', label: '4:3', w: 12, h: 9 },
558
- ];
559
-
560
- const DISPLAY_MODE_OPTIONS: {
561
- key: DisplayMode;
562
- label: string;
563
- icon: string;
564
- summary: string;
565
- }[] = [
566
- {
567
- key: 'slideshow',
568
- label: 'Slideshow',
569
- icon: 'fa-solid fa-images',
570
- summary: 'Present designed slides one at a time.',
571
- },
572
- {
573
- key: 'video',
574
- label: 'Video',
575
- icon: 'fa-solid fa-circle-play',
576
- summary: 'Play an automatically timed presentation.',
577
- },
578
- {
579
- key: 'linear',
580
- label: 'Page',
581
- icon: 'fa-solid fa-window-maximize',
582
- summary: 'Scroll through the fully designed page.',
583
- },
584
- {
585
- key: 'page',
586
- label: 'Document',
587
- icon: 'fa-solid fa-align-left',
588
- summary: 'Read a clean, text-first document.',
589
- },
590
- {
591
- key: 'narrate',
592
- label: 'Narrate',
593
- icon: 'fa-solid fa-microphone-lines',
594
- summary: 'Speak with a voice-paced teleprompter.',
595
- },
596
- ];
597
-
598
- export function displayModeLabel(mode: DisplayMode): string {
599
- return DISPLAY_MODE_OPTIONS.find((option) => option.key === mode)?.label ?? 'Slideshow';
600
- }
601
-
602
- const TRANSFORM_STYLE_OPTIONS = [
603
- { key: '', label: 'None' },
604
- ...getTransformStyleSummaries().map((s) => ({ key: s.id, label: s.name })),
605
- ];
606
-
607
- const SUMMARIZE_TOOLTIP =
608
- 'Extract and summarize content for presentation with these Use modes. Your underlying content is not changed.';
609
-
610
- /**
611
- * Left-to-right priority order for the preview controls. As the toolbar
612
- * narrows, controls drop into the overflow menu from the END of this list
613
- * first (Cover, then Captions, …). Aspect ratio stays inline the longest, but
614
- * still collapses into the same menu when the toolbar is very constrained.
615
- */
616
- type ControlKey = 'format' | 'theme' | 'transform' | 'captions' | 'cover';
617
- const CONTROL_KEYS: ControlKey[] = ['format', 'theme', 'transform', 'captions', 'cover'];
618
-
619
- const PREVIEW_POPOVER_GAP = 4;
620
- const PREVIEW_POPOVER_MARGIN = 8;
621
- const PREVIEW_POPOVER_FALLBACK_WIDTH = 220;
622
-
623
- function clampPreviewPopoverLeft(
624
- triggerRect: DOMRect,
625
- popoverWidth: number,
626
- viewportWidth: number,
627
- ): number {
628
- const maxLeft = Math.max(
629
- PREVIEW_POPOVER_MARGIN,
630
- viewportWidth - popoverWidth - PREVIEW_POPOVER_MARGIN,
631
- );
632
- return Math.min(Math.max(PREVIEW_POPOVER_MARGIN, triggerRect.right - popoverWidth), maxLeft);
633
- }
634
-
635
- // ── Shared styles ────────────────────────────────────────────────
636
-
637
- const labelStyle: React.CSSProperties = {
638
- color: 'var(--squisq-text-muted, #6b7280)',
639
- fontSize: '12px',
640
- whiteSpace: 'nowrap',
641
- };
642
-
643
- const selectStyle: React.CSSProperties = {
644
- padding: '2px 6px',
645
- borderRadius: '4px',
646
- border: '1px solid var(--squisq-border, #d1d5db)',
647
- background: 'var(--squisq-input-bg, #fff)',
648
- color: 'var(--squisq-text, #1f2937)',
649
- fontSize: '12px',
650
- cursor: 'pointer',
651
- };
652
-
653
- // ── Toolbar Controls Component ───────────────────────────────────
654
-
655
- /**
656
- * Inline preview controls rendered in the main toolbar row.
657
- *
658
- * Collapse is *progressive* (a priority-plus pattern): rather than switch the
659
- * whole row in and out at a fixed window-width breakpoint, the controls
660
- * measure how many of them actually fit in the width the toolbar gives them
661
- * and keep that many inline, folding the rest — from the low-priority end of
662
- * {@link CONTROL_KEYS} — into a single ellipsis button's popover. As
663
- * the toolbar widens or narrows, controls migrate one at a time between the
664
- * inline row and the menu, so the available space is always well used and the
665
- * row never wraps onto a second line.
666
- */
667
- export function PreviewToolbarControls() {
668
- const s = usePreviewSettings();
669
- const [visibleCount, setVisibleCount] = useState(CONTROL_KEYS.length);
670
- const [popoverOpen, setPopoverOpen] = useState(false);
671
- // `rootRef` (flex:1) always spans the toolbar's leftover width, so its
672
- // clientWidth is the budget the controls have to lay out in.
673
- const rootRef = useRef<HTMLDivElement>(null);
674
- // Hidden probe rendering every control at natural width; the split between
675
- // inline and overflow is computed from these per-control measurements.
676
- const probeRef = useRef<HTMLDivElement>(null);
677
- const popoverRef = useRef<HTMLDivElement>(null);
678
- const popoverTriggerRef = useRef<HTMLButtonElement>(null);
679
- const popoverPanelRef = useRef<HTMLDivElement>(null);
680
- const [popoverAnchor, setPopoverAnchor] = useState<{ top: number; left: number } | null>(null);
681
-
682
- const updatePopoverPosition = useCallback(() => {
683
- const trigger = popoverTriggerRef.current;
684
- if (!trigger) return;
685
- const triggerRect = trigger.getBoundingClientRect();
686
- const measuredWidth =
687
- popoverPanelRef.current?.getBoundingClientRect().width ?? PREVIEW_POPOVER_FALLBACK_WIDTH;
688
- const popoverWidth = Math.min(measuredWidth, window.innerWidth - PREVIEW_POPOVER_MARGIN * 2);
689
- setPopoverAnchor({
690
- top: triggerRect.bottom + PREVIEW_POPOVER_GAP,
691
- left: clampPreviewPopoverLeft(triggerRect, popoverWidth, window.innerWidth),
692
- });
693
- }, []);
694
-
695
- const closePopover = useCallback(() => {
696
- setPopoverOpen(false);
697
- setPopoverAnchor(null);
698
- }, []);
699
-
700
- // Fit detection: keep as many controls inline as fit, overflow the rest.
701
- useLayoutEffect(() => {
702
- const root = rootRef.current;
703
- const probe = probeRef.current;
704
- if (!root || !probe) return;
705
- const GAP = 6; // matches the row's flex `gap`
706
- const LEAD_PAD = 9; // root's left padding, eaten before any control
707
- const OVERFLOW_TRIGGER_RESERVE = 40; // width kept for the ellipsis button (+ its gap)
708
- const SAFETY = 2;
709
- const measure = () => {
710
- const available = root.clientWidth - LEAD_PAD;
711
- const widths = Array.from(probe.children).map(
712
- (el) => (el as HTMLElement).getBoundingClientRect().width,
713
- );
714
- // Width of the first `n` controls laid out inline (n-1 inter-control gaps).
715
- const rowWidth = (n: number) =>
716
- widths.slice(0, n).reduce((sum, w) => sum + w, 0) + GAP * Math.max(0, n - 1);
717
- // Everything fits → no overflow button needed.
718
- if (rowWidth(widths.length) <= available) {
719
- setVisibleCount(widths.length);
720
- return;
721
- }
722
- // Otherwise reserve room for the ellipsis and fit as many as possible.
723
- const budget = available - OVERFLOW_TRIGGER_RESERVE - GAP - SAFETY;
724
- let count = 0;
725
- while (count < widths.length && rowWidth(count + 1) <= budget) count++;
726
- setVisibleCount(count);
727
- };
728
- const ro = new ResizeObserver(measure);
729
- ro.observe(root);
730
- // Observe the probe too: a control's width can change (e.g. a longer theme
731
- // name) without the toolbar resizing, and that shifts the split.
732
- ro.observe(probe);
733
- measure();
734
- return () => ro.disconnect();
735
- }, []);
736
-
737
- // Close popover on outside click
738
- useEffect(() => {
739
- if (!popoverOpen) return;
740
- const handler = (e: MouseEvent) => {
741
- const target = e.target as Node;
742
- if (popoverRef.current?.contains(target)) return;
743
- if (popoverPanelRef.current?.contains(target)) return;
744
- closePopover();
745
- };
746
- document.addEventListener('mousedown', handler);
747
- return () => document.removeEventListener('mousedown', handler);
748
- }, [closePopover, popoverOpen]);
749
-
750
- useLayoutEffect(() => {
751
- if (!popoverOpen) return;
752
- updatePopoverPosition();
753
- }, [popoverOpen, updatePopoverPosition, visibleCount]);
754
-
755
- useEffect(() => {
756
- if (!popoverOpen) return;
757
- window.addEventListener('resize', updatePopoverPosition);
758
- window.addEventListener('scroll', updatePopoverPosition, true);
759
- return () => {
760
- window.removeEventListener('resize', updatePopoverPosition);
761
- window.removeEventListener('scroll', updatePopoverPosition, true);
762
- };
763
- }, [popoverOpen, updatePopoverPosition]);
764
-
765
- // "Edit theme" pencil next to the dropdown. A custom theme opens directly in
766
- // the designer; a built-in seeds a NEW "Modified <name>" custom theme based
767
- // on it — Cancel leaves the current theme untouched, Save engages the custom.
768
- const handleEditCurrentTheme = useCallback(() => {
769
- const existing = s.customThemes.find((t) => t.id === s.activeThemeId);
770
- if (existing) {
771
- s.openThemeDesigner(existing);
772
- } else {
773
- const base = s.activeTheme;
774
- s.openThemeDesigner({ ...base, name: `Modified ${base.name}`, basedOn: base.id });
775
- }
776
- }, [s]);
777
-
778
- // Render a single control by key. `compact` switches to the stacked
779
- // label-over-control layout used inside the overflow popover.
780
- const renderControl = (key: ControlKey, compact: boolean): ReactNode => {
781
- switch (key) {
782
- case 'format':
783
- return (
784
- <div
785
- key="format"
786
- className={`squisq-preview-control squisq-preview-control--seg${compact ? ' squisq-preview-control--compact' : ''}`}
787
- >
788
- {compact && <label style={labelStyle}>Format:</label>}
789
- <PreviewFormatSwitch />
790
- </div>
791
- );
792
- case 'theme':
793
- return (
794
- <div
795
- key="theme"
796
- className={`squisq-preview-control${compact ? ' squisq-preview-control--compact' : ''}`}
797
- >
798
- <label style={labelStyle}>Theme:</label>
799
- <ThemePicker
800
- value={s.activeThemeId}
801
- onChange={(v) => s.setSelectedThemeId(v)}
802
- ariaLabel="Theme"
803
- customThemes={s.customThemes}
804
- onCreateCustom={() => s.openThemeDesigner(null)}
805
- onEditCustom={(id) =>
806
- s.openThemeDesigner(s.customThemes.find((t) => t.id === id) ?? null)
807
- }
808
- onDeleteCustom={(id) => s.deleteCustomTheme(id)}
809
- />
810
- <button
811
- type="button"
812
- className="squisq-theme-edit-btn"
813
- onClick={handleEditCurrentTheme}
814
- aria-label="Edit theme"
815
- title="Edit this theme"
816
- >
817
- <svg
818
- width="14"
819
- height="14"
820
- viewBox="0 0 16 16"
821
- fill="none"
822
- stroke="currentColor"
823
- strokeWidth="1.4"
824
- strokeLinecap="round"
825
- strokeLinejoin="round"
826
- aria-hidden="true"
827
- >
828
- <path d="M2.5 13.5l1-3 7-7 2 2-7 7-3 1z" />
829
- <path d="M9.5 4.5l2 2" />
830
- </svg>
831
- </button>
832
- </div>
833
- );
834
- case 'transform':
835
- return (
836
- <PreviewSelect
837
- key="transform"
838
- label="Summarize"
839
- labelTooltip={SUMMARIZE_TOOLTIP}
840
- value={s.activeTransformStyle}
841
- options={TRANSFORM_STYLE_OPTIONS}
842
- onChange={(v) => s.setSelectedTransformStyle(v)}
843
- compact={compact}
844
- />
845
- );
846
- case 'captions': {
847
- // Two independent toggles (either can be off): CC = standard captions,
848
- // share icon = social captions. Clicking the active one turns captions
849
- // off; clicking the other switches style (and turns captions on).
850
- const enabled = s.activeCaptionsEnabled;
851
- const ccActive = enabled && s.activeCaptionStyle === 'standard';
852
- const socialActive = enabled && s.activeCaptionStyle === 'social';
853
- return (
854
- <div
855
- key="captions"
856
- className={`squisq-preview-control${compact ? ' squisq-preview-control--compact' : ''}`}
857
- >
858
- <label style={labelStyle}>Captions:</label>
859
- <div className="squisq-preview-seg" role="group" aria-label="Captions">
860
- <button
861
- type="button"
862
- className={`squisq-preview-seg-btn squisq-preview-seg-btn--icon${ccActive ? ' squisq-preview-seg-btn--active' : ''}`}
863
- aria-pressed={ccActive}
864
- aria-label="Standard captions"
865
- title="Standard captions"
866
- onClick={() => s.setCaptionMode(ccActive ? 'off' : 'standard')}
867
- >
868
- <Icon icon="fa-solid fa-closed-captioning" />
869
- </button>
870
- <button
871
- type="button"
872
- className={`squisq-preview-seg-btn squisq-preview-seg-btn--icon${socialActive ? ' squisq-preview-seg-btn--active' : ''}`}
873
- aria-pressed={socialActive}
874
- aria-label="Social captions"
875
- title="Social captions"
876
- onClick={() => s.setCaptionMode(socialActive ? 'off' : 'social')}
877
- >
878
- <Icon icon="fa-solid fa-share-nodes" />
879
- </button>
880
- </div>
881
- </div>
882
- );
883
- }
884
- case 'cover':
885
- return (
886
- <div
887
- key="cover"
888
- className={`squisq-preview-control${compact ? ' squisq-preview-control--compact' : ''}`}
889
- >
890
- <label className="squisq-preview-checkbox">
891
- <input
892
- type="checkbox"
893
- checked={s.activeCoverSlide}
894
- onChange={(e) => s.setCoverSlideEnabled(e.target.checked)}
895
- />
896
- <span>Cover slide</span>
897
- </label>
898
- </div>
899
- );
900
- }
901
- };
902
-
903
- const hasOverflow = visibleCount < CONTROL_KEYS.length;
904
- const visibleKeys = CONTROL_KEYS.slice(0, visibleCount);
905
- const overflowKeys = CONTROL_KEYS.slice(visibleCount);
906
-
907
- useEffect(() => {
908
- if (!hasOverflow && popoverOpen) closePopover();
909
- }, [closePopover, hasOverflow, popoverOpen]);
910
-
911
- // The root is a flex:1 filler so it always spans the toolbar's leftover
912
- // width (which is what the fit measurement reads).
913
- return (
914
- <div
915
- className="squisq-preview-controls"
916
- data-has-overflow={hasOverflow ? 'true' : undefined}
917
- ref={rootRef}
918
- >
919
- {/* Hidden probe — every control at natural width, measured to decide the
920
- inline/overflow split. Absolutely positioned so it never affects
921
- layout. */}
922
- <div className="squisq-preview-controls-probe" ref={probeRef} aria-hidden="true">
923
- {CONTROL_KEYS.map((key) => renderControl(key, false))}
924
- </div>
925
-
926
- {visibleKeys.length > 0 && (
927
- <div className="squisq-preview-controls-inline">
928
- {visibleKeys.map((key) => renderControl(key, false))}
929
- </div>
930
- )}
931
-
932
- {hasOverflow && (
933
- <div className="squisq-preview-controls-compact" ref={popoverRef}>
934
- <button
935
- ref={popoverTriggerRef}
936
- className={`squisq-toolbar-button${popoverOpen ? ' squisq-toolbar-button--active' : ''}`}
937
- onClick={() => {
938
- if (popoverOpen) {
939
- closePopover();
940
- return;
941
- }
942
- updatePopoverPosition();
943
- setPopoverOpen(true);
944
- }}
945
- aria-label="More preview settings"
946
- title="More preview settings"
947
- aria-expanded={popoverOpen}
948
- >
949
- <Icon icon="fa-solid fa-ellipsis" />
950
- </button>
951
- {popoverOpen && popoverAnchor && (
952
- <div
953
- ref={popoverPanelRef}
954
- className="squisq-preview-controls-popover"
955
- style={{ top: popoverAnchor.top, left: popoverAnchor.left }}
956
- >
957
- {overflowKeys.map((key) => renderControl(key, true))}
958
- </div>
959
- )}
960
- </div>
961
- )}
962
- </div>
963
- );
964
- }
965
-
966
- /**
967
- * Segmented display-mode switch retained as a public, embeddable control.
968
- * The editor shell uses {@link PreviewModeMenu} beside its Use tab instead.
969
- * Narrate is hidden when the host disables `allowNarrate`.
970
- */
971
- export function PreviewModeSwitch() {
972
- const s = usePreviewSettings();
973
- const { allowNarrate } = useEditorContext();
974
- const options = DISPLAY_MODE_OPTIONS.filter((opt) => opt.key !== 'narrate' || allowNarrate);
975
- return (
976
- <div className="squisq-preview-seg" role="group" aria-label="Display mode">
977
- {options.map((opt) => {
978
- const active = s.activeDisplayMode === opt.key;
979
- return (
980
- <button
981
- key={opt.key}
982
- type="button"
983
- className={`squisq-preview-seg-btn${active ? ' squisq-preview-seg-btn--active' : ''}`}
984
- aria-pressed={active}
985
- onClick={() => s.setSelectedDisplayMode(opt.key)}
986
- >
987
- {opt.label}
988
- </button>
989
- );
990
- })}
991
- </div>
992
- );
993
- }
994
-
995
- const USE_MODE_MENU_WIDTH = 340;
996
- const USE_MODE_MENU_GAP = 4;
997
- const USE_MODE_MENU_MARGIN = 8;
998
-
999
- /**
1000
- * Dropdown trigger rendered directly beside the Use tab. Selecting a mode
1001
- * also enters the Use view, so the menu works from Write and Source as well
1002
- * as from an already-active preview.
1003
- */
1004
- export interface PreviewModeMenuProps {
1005
- /** Incremented by the parent to open the menu from another control. */
1006
- openRequest?: number;
1007
- }
1008
-
1009
- export function PreviewModeMenu({ openRequest = 0 }: PreviewModeMenuProps) {
1010
- const s = usePreviewSettings();
1011
- const { allowNarrate, colorScheme, setActiveView } = useEditorContext();
1012
- const options = DISPLAY_MODE_OPTIONS.filter((opt) => opt.key !== 'narrate' || allowNarrate);
1013
- const activeLabel = displayModeLabel(s.activeDisplayMode);
1014
- const triggerRef = useRef<HTMLButtonElement>(null);
1015
- const menuRef = useRef<HTMLDivElement>(null);
1016
- const itemIdPrefix = useId();
1017
- const [open, setOpen] = useState(false);
1018
- const [anchor, setAnchor] = useState<{ top: number; left: number } | null>(null);
1019
-
1020
- const updatePosition = useCallback(() => {
1021
- const trigger = triggerRef.current;
1022
- if (!trigger) return;
1023
- const rect = trigger.getBoundingClientRect();
1024
- const menuWidth = Math.min(USE_MODE_MENU_WIDTH, window.innerWidth - USE_MODE_MENU_MARGIN * 2);
1025
- const maxLeft = Math.max(
1026
- USE_MODE_MENU_MARGIN,
1027
- window.innerWidth - menuWidth - USE_MODE_MENU_MARGIN,
1028
- );
1029
- setAnchor({
1030
- top: rect.bottom + USE_MODE_MENU_GAP,
1031
- left: Math.min(Math.max(USE_MODE_MENU_MARGIN, rect.right - menuWidth), maxLeft),
1032
- });
1033
- }, []);
1034
-
1035
- const closeMenu = useCallback((restoreFocus = false) => {
1036
- setOpen(false);
1037
- setAnchor(null);
1038
- if (restoreFocus) triggerRef.current?.focus();
1039
- }, []);
1040
-
1041
- const openMenu = useCallback(() => {
1042
- updatePosition();
1043
- setOpen(true);
1044
- }, [updatePosition]);
1045
-
1046
- useEffect(() => {
1047
- if (openRequest > 0) openMenu();
1048
- }, [openMenu, openRequest]);
1049
-
1050
- useEffect(() => {
1051
- if (!open) return;
1052
- const handlePointerDown = (event: MouseEvent) => {
1053
- const target = event.target as Node;
1054
- if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) return;
1055
- closeMenu();
1056
- };
1057
- const handleKeyDown = (event: KeyboardEvent) => {
1058
- if (event.key !== 'Escape') return;
1059
- event.preventDefault();
1060
- closeMenu(true);
1061
- };
1062
- document.addEventListener('mousedown', handlePointerDown);
1063
- document.addEventListener('keydown', handleKeyDown);
1064
- window.addEventListener('resize', updatePosition);
1065
- window.addEventListener('scroll', updatePosition, true);
1066
- return () => {
1067
- document.removeEventListener('mousedown', handlePointerDown);
1068
- document.removeEventListener('keydown', handleKeyDown);
1069
- window.removeEventListener('resize', updatePosition);
1070
- window.removeEventListener('scroll', updatePosition, true);
1071
- };
1072
- }, [closeMenu, open, updatePosition]);
1073
-
1074
- useLayoutEffect(() => {
1075
- if (!open || !anchor) return;
1076
- const selected = menuRef.current?.querySelector<HTMLButtonElement>('[aria-checked="true"]');
1077
- const first = menuRef.current?.querySelector<HTMLButtonElement>('[role="menuitemradio"]');
1078
- (selected ?? first)?.focus();
1079
- }, [anchor, open]);
1080
-
1081
- return (
1082
- <>
1083
- <button
1084
- ref={triggerRef}
1085
- type="button"
1086
- className={`squisq-use-mode-trigger${open ? ' squisq-use-mode-trigger--open' : ''}`}
1087
- aria-label="Choose Use mode"
1088
- aria-haspopup="menu"
1089
- aria-expanded={open}
1090
- title={`Use mode: ${activeLabel}`}
1091
- onClick={() => (open ? closeMenu() : openMenu())}
1092
- onKeyDown={(event) => {
1093
- if (event.key !== 'ArrowDown') return;
1094
- event.preventDefault();
1095
- openMenu();
1096
- }}
1097
- >
1098
- <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
1099
- <path d="M2 3.5 5 6.5 8 3.5" fill="none" stroke="currentColor" strokeWidth="1.4" />
1100
- </svg>
1101
- </button>
1102
- {open &&
1103
- anchor &&
1104
- createPortal(
1105
- <div
1106
- ref={menuRef}
1107
- className="squisq-use-mode-menu"
1108
- data-theme={colorScheme}
1109
- role="menu"
1110
- aria-label="Use mode"
1111
- style={{ top: anchor.top, left: anchor.left }}
1112
- onKeyDown={(event) => {
1113
- if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
1114
- event.preventDefault();
1115
- const items = Array.from(
1116
- event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]'),
1117
- );
1118
- if (items.length === 0) return;
1119
- const currentIndex = items.indexOf(document.activeElement as HTMLButtonElement);
1120
- const nextIndex =
1121
- event.key === 'Home'
1122
- ? 0
1123
- : event.key === 'End'
1124
- ? items.length - 1
1125
- : event.key === 'ArrowUp'
1126
- ? (currentIndex - 1 + items.length) % items.length
1127
- : (currentIndex + 1) % items.length;
1128
- items[nextIndex]?.focus();
1129
- }}
1130
- >
1131
- {options.map((option) => {
1132
- const selected = option.key === s.activeDisplayMode;
1133
- const labelId = `${itemIdPrefix}-${option.key}-label`;
1134
- const summaryId = `${itemIdPrefix}-${option.key}-summary`;
1135
- return (
1136
- <button
1137
- key={option.key}
1138
- type="button"
1139
- className={`squisq-use-mode-menu-item${selected ? ' squisq-use-mode-menu-item--selected' : ''}`}
1140
- role="menuitemradio"
1141
- aria-checked={selected}
1142
- aria-labelledby={labelId}
1143
- aria-describedby={summaryId}
1144
- onClick={() => {
1145
- s.setSelectedDisplayMode(option.key);
1146
- setActiveView('preview');
1147
- closeMenu(true);
1148
- }}
1149
- >
1150
- <span className="squisq-use-mode-menu-icon" aria-hidden="true">
1151
- <Icon icon={option.icon} />
1152
- </span>
1153
- <span className="squisq-use-mode-menu-copy">
1154
- <span id={labelId} className="squisq-use-mode-menu-label">
1155
- {option.label}
1156
- </span>
1157
- <span id={summaryId} className="squisq-use-mode-menu-summary">
1158
- {option.summary}
1159
- </span>
1160
- </span>
1161
- <span className="squisq-use-mode-menu-check" aria-hidden="true">
1162
- {selected && <Icon icon="fa-solid fa-check" />}
1163
- </span>
1164
- </button>
1165
- );
1166
- })}
1167
- </div>,
1168
- document.body,
1169
- )}
1170
- </>
1171
- );
1172
- }
1173
-
1174
- /** A simple aspect-ratio glyph: a centered rounded rectangle of `w`×`h` in a
1175
- * 16×16 box, so 16:9 reads as a wide box, 1:1 a square, 9:16 a tall box. */
1176
- function AspectIcon({ w, h }: { w: number; h: number }) {
1177
- return (
1178
- <svg
1179
- width="16"
1180
- height="16"
1181
- viewBox="0 0 16 16"
1182
- fill="none"
1183
- stroke="currentColor"
1184
- strokeWidth="1.4"
1185
- aria-hidden="true"
1186
- >
1187
- <rect x={(16 - w) / 2} y={(16 - h) / 2} width={w} height={h} rx="1.5" />
1188
- </svg>
1189
- );
1190
- }
1191
-
1192
- /**
1193
- * Segmented aspect-ratio switch (16:9 / 1:1 / 9:16 / 4:3), used inline in the
1194
- * Use toolbar and inside its overflow popover. Reads and writes the same
1195
- * `activePreset` in preview settings.
1196
- */
1197
- export function PreviewFormatSwitch() {
1198
- const s = usePreviewSettings();
1199
- return (
1200
- <div className="squisq-preview-seg" role="group" aria-label="Aspect ratio">
1201
- {FORMAT_SWITCH_OPTIONS.map((opt) => {
1202
- const active = s.activePreset === opt.key;
1203
- return (
1204
- <button
1205
- key={opt.key}
1206
- type="button"
1207
- className={`squisq-preview-seg-btn squisq-preview-seg-btn--icon${active ? ' squisq-preview-seg-btn--active' : ''}`}
1208
- aria-pressed={active}
1209
- aria-label={opt.label}
1210
- title={opt.label}
1211
- onClick={() => s.setSelectedPreset(opt.key)}
1212
- >
1213
- <AspectIcon w={opt.w} h={opt.h} />
1214
- </button>
1215
- );
1216
- })}
1217
- </div>
1218
- );
1219
- }
1220
-
1221
- function PreviewSelect({
1222
- label,
1223
- labelTooltip,
1224
- value,
1225
- options,
1226
- onChange,
1227
- compact,
1228
- }: {
1229
- label: string;
1230
- labelTooltip?: string;
1231
- value: string;
1232
- options: { key: string; label: string }[];
1233
- onChange: (value: string) => void;
1234
- compact?: boolean;
1235
- }) {
1236
- return (
1237
- <div className={`squisq-preview-control${compact ? ' squisq-preview-control--compact' : ''}`}>
1238
- <label style={labelStyle} title={labelTooltip}>
1239
- {label}:
1240
- </label>
1241
- <select value={value} onChange={(e) => onChange(e.target.value)} style={selectStyle}>
1242
- {options.map((o) => (
1243
- <option key={o.key} value={o.key}>
1244
- {o.label}
1245
- </option>
1246
- ))}
1247
- </select>
1248
- </div>
1249
- );
1250
- }