@bendyline/squisq-editor-react 1.5.2 → 1.6.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 (165) hide show
  1. package/dist/index.d.ts +757 -20
  2. package/dist/index.js +16910 -6844
  3. package/dist/index.js.map +1 -1
  4. package/package.json +4 -4
  5. package/src/BlockCardView.tsx +121 -0
  6. package/src/BlockPreviewPanel.tsx +69 -0
  7. package/src/BlockPropertiesPopover.tsx +191 -0
  8. package/src/EditorContext.tsx +143 -0
  9. package/src/EditorShell.tsx +200 -120
  10. package/src/FolderView.tsx +131 -0
  11. package/src/Icon.tsx +26 -0
  12. package/src/ImageEditor.tsx +69 -22
  13. package/src/OutlinePanel.tsx +38 -3
  14. package/src/PlainHtmlPreview.tsx +30 -3
  15. package/src/PreviewControls.tsx +180 -8
  16. package/src/RawEditor.tsx +216 -29
  17. package/src/RecorderEntry.tsx +9 -16
  18. package/src/TemplateAnnotation.ts +44 -0
  19. package/src/TemplatePicker.tsx +329 -54
  20. package/src/ThemeCustomizerPanel.tsx +30 -336
  21. package/src/ThemePicker.tsx +112 -3
  22. package/src/TimelineBlockPreview.tsx +37 -0
  23. package/src/TimelineTrack.tsx +671 -0
  24. package/src/Toolbar.tsx +528 -174
  25. package/src/Tooltip.tsx +22 -4
  26. package/src/TransitionPicker.tsx +351 -0
  27. package/src/VersionHistoryPanel.tsx +61 -31
  28. package/src/ViewMenuPanel.tsx +17 -14
  29. package/src/WysiwygEditor.tsx +161 -65
  30. package/src/__tests__/blockProperties.test.ts +92 -0
  31. package/src/__tests__/blockRange.test.ts +105 -0
  32. package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
  33. package/src/__tests__/createShapeLayer.test.ts +46 -0
  34. package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
  35. package/src/__tests__/embeddedMedia.test.ts +48 -0
  36. package/src/__tests__/headingTransition.test.ts +138 -0
  37. package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
  38. package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
  39. package/src/__tests__/recorderMediaInsert.test.ts +86 -0
  40. package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
  41. package/src/__tests__/templatePickerMetadata.test.ts +32 -0
  42. package/src/__tests__/timelineSource.test.ts +134 -0
  43. package/src/__tests__/tiptapBridge.test.ts +92 -0
  44. package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
  45. package/src/__tests__/tooltip.test.tsx +72 -0
  46. package/src/__tests__/transitionCatalog.test.ts +64 -0
  47. package/src/__tests__/useBlockNavigator.test.tsx +67 -0
  48. package/src/__tests__/useMediaRecorder.test.ts +24 -0
  49. package/src/__tests__/useTimelineClock.test.ts +21 -0
  50. package/src/blockProperties.ts +88 -0
  51. package/src/blockRange.ts +132 -0
  52. package/src/buildPreviewDoc.ts +98 -9
  53. package/src/customTemplates/AddBin.tsx +126 -0
  54. package/src/customTemplates/CustomLayoutManager.tsx +233 -0
  55. package/src/customTemplates/CustomTemplateContext.tsx +182 -0
  56. package/src/customTemplates/LayerToolbar.tsx +580 -0
  57. package/src/customTemplates/ShapeGlyph.tsx +47 -0
  58. package/src/customTemplates/TemplateDesigner.tsx +430 -0
  59. package/src/customTemplates/__tests__/library.test.ts +88 -0
  60. package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
  61. package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
  62. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
  63. package/src/customTemplates/designer.css +673 -0
  64. package/src/customTemplates/index.ts +31 -0
  65. package/src/customTemplates/library.ts +97 -0
  66. package/src/customTemplates/normalizePositions.ts +75 -0
  67. package/src/customTemplates/shapeDefs.ts +131 -0
  68. package/src/customTemplates/thumbnail.tsx +63 -0
  69. package/src/customTemplates/tokenDefs.ts +60 -0
  70. package/src/customTemplates/useDocCustomTemplates.ts +52 -0
  71. package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
  72. package/src/customThemes/CustomThemeContext.tsx +179 -0
  73. package/src/customThemes/CustomThemeDialog.tsx +286 -0
  74. package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
  75. package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
  76. package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
  77. package/src/customThemes/customThemeLibrary.ts +97 -0
  78. package/src/customThemes/index.ts +31 -0
  79. package/src/customThemes/themeControls.tsx +229 -0
  80. package/src/customThemes/themeDraft.ts +272 -0
  81. package/src/customThemes/useDocCustomThemes.ts +49 -0
  82. package/src/diagram/DiagramCanvas.tsx +240 -0
  83. package/src/diagram/DiagramExtension.ts +209 -0
  84. package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
  85. package/src/diagram/DiagramWidget.tsx +270 -0
  86. package/src/diagram/diagramCommands.ts +604 -0
  87. package/src/diagram/diagramConstants.ts +17 -0
  88. package/src/diagram/useDiagramData.ts +126 -0
  89. package/src/embeddedMedia.ts +78 -0
  90. package/src/frontmatter.ts +29 -0
  91. package/src/headingTransition.ts +231 -0
  92. package/src/imageEditor/CanvasSurface.tsx +383 -88
  93. package/src/imageEditor/PropertiesPanel.tsx +47 -1
  94. package/src/imageEditor/Toolbar.tsx +229 -16
  95. package/src/imageEditor/createShapeLayer.ts +280 -0
  96. package/src/imageEditor/icons.tsx +34 -114
  97. package/src/imageEditor/image-editor.css +54 -5
  98. package/src/imageEditor/state.ts +23 -3
  99. package/src/index.ts +77 -0
  100. package/src/recorder/RecorderModal.tsx +120 -53
  101. package/src/recorder/RecorderPanel.tsx +2 -26
  102. package/src/recorder/hooks/useMediaRecorder.ts +17 -2
  103. package/src/recorder/insertMediaBlock.ts +30 -0
  104. package/src/resolveBlockVisual.ts +33 -0
  105. package/src/scene/Scene.tsx +540 -0
  106. package/src/scene/SceneBlockExtension.ts +198 -0
  107. package/src/scene/SceneBlockToolbar.tsx +201 -0
  108. package/src/scene/SceneBlockWidget.tsx +434 -0
  109. package/src/scene/ScenePropsBar.tsx +85 -0
  110. package/src/scene/SceneSelection.tsx +107 -0
  111. package/src/scene/SceneViewport.tsx +102 -0
  112. package/src/scene/ShapePalette.tsx +181 -0
  113. package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
  114. package/src/scene/__tests__/bezierEdit.test.ts +85 -0
  115. package/src/scene/__tests__/blockLayers.test.ts +57 -0
  116. package/src/scene/__tests__/shapeLayers.test.ts +106 -0
  117. package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
  118. package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
  119. package/src/scene/adapters/DiagramAdapter.ts +168 -0
  120. package/src/scene/adapters/DrawingAdapter.ts +415 -0
  121. package/src/scene/adapters/LayoutAdapter.ts +310 -0
  122. package/src/scene/adapters/blockLayers.ts +159 -0
  123. package/src/scene/commands/SceneCommand.ts +70 -0
  124. package/src/scene/commands/drawingCommands.ts +318 -0
  125. package/src/scene/commands/layoutCommands.ts +301 -0
  126. package/src/scene/hooks/useSceneHitTest.ts +105 -0
  127. package/src/scene/hooks/useScenePanZoom.ts +147 -0
  128. package/src/scene/hooks/useSceneSelection.ts +62 -0
  129. package/src/scene/index.ts +95 -0
  130. package/src/scene/layers/DiagramEdges.tsx +127 -0
  131. package/src/scene/layers/edgeGeometry.ts +77 -0
  132. package/src/scene/layers/nodeCard.tsx +145 -0
  133. package/src/scene/layers/renderLayer.tsx +70 -0
  134. package/src/scene/layers/shapeLayers.ts +201 -0
  135. package/src/scene/paths/bezierEdit.ts +208 -0
  136. package/src/scene/scene.css +649 -0
  137. package/src/scene/text/SceneTextOverlay.tsx +161 -0
  138. package/src/scene/text/sceneTextChannel.ts +40 -0
  139. package/src/scene/text/sceneTextConfig.ts +27 -0
  140. package/src/scene/text/sceneTiptap.ts +36 -0
  141. package/src/scene/text/useSceneTextEditing.ts +39 -0
  142. package/src/scene/tools/ConnectTool.ts +111 -0
  143. package/src/scene/tools/DrawingConnectTool.ts +161 -0
  144. package/src/scene/tools/PathTool.ts +158 -0
  145. package/src/scene/tools/PlaceTool.ts +47 -0
  146. package/src/scene/tools/SceneTool.ts +75 -0
  147. package/src/scene/tools/SelectTool.ts +284 -0
  148. package/src/scene/tools/ShapeTool.ts +144 -0
  149. package/src/scene/tools/TextTool.ts +72 -0
  150. package/src/scene/tools/TokenTool.ts +95 -0
  151. package/src/scene/tools/createDrawShapeTool.ts +82 -0
  152. package/src/styles/diagram.css +183 -0
  153. package/src/styles/editor.css +1615 -203
  154. package/src/styles/folder-view.css +210 -0
  155. package/src/styles/image-edit-affordance.css +2 -2
  156. package/src/styles/index.css +4 -0
  157. package/src/timelineSource.ts +244 -0
  158. package/src/tiptapBridge.ts +115 -34
  159. package/src/tooltipPlacement.ts +13 -0
  160. package/src/transitionCatalog.ts +159 -0
  161. package/src/types/monaco-shims.d.ts +10 -0
  162. package/src/useBlockNavigator.ts +153 -0
  163. package/src/useMonacoLoader.ts +105 -0
  164. package/src/useTimelineClock.ts +76 -0
  165. package/src/utils/dropUtils.ts +1 -1
@@ -13,7 +13,7 @@
13
13
  * `exportImageEditDoc` from `@bendyline/squisq/imageEdit`.
14
14
  */
15
15
 
16
- import { useCallback, useState } from 'react';
16
+ import { useCallback, useRef, useState } from 'react';
17
17
  import type { ContentContainer } from '@bendyline/squisq/storage';
18
18
  import { exportImageEditDoc, type ImageEditExportFormat } from '@bendyline/squisq/imageEdit';
19
19
  import type { SurfaceScheme, Theme } from '@bendyline/squisq/schemas';
@@ -24,6 +24,13 @@ import { PropertiesPanel } from './imageEditor/PropertiesPanel.js';
24
24
  import { Toolbar } from './imageEditor/Toolbar.js';
25
25
  import { useImageEditor } from './imageEditor/useImageEditor.js';
26
26
  import { useImageEditorTokens } from './imageEditor/useImageEditorTokens.js';
27
+ import {
28
+ createShapeLayer,
29
+ createLinearShapeLayer,
30
+ isLinearShapeKind,
31
+ } from './imageEditor/createShapeLayer.js';
32
+
33
+ const ZOOM_STEPS = [0.0625, 0.083, 0.125, 0.167, 0.25, 0.333, 0.5, 0.667, 1, 1.5, 2, 3, 4, 8, 16];
27
34
 
28
35
  export interface ImageEditorProps {
29
36
  /**
@@ -107,6 +114,33 @@ export function ImageEditor(props: ImageEditorProps) {
107
114
  // re-lists without polling.
108
115
  const [historyRefreshKey, setHistoryRefreshKey] = useState(0);
109
116
 
117
+ const surfaceRef = useRef<HTMLDivElement>(null);
118
+ const [zoom, setZoom] = useState(1.0);
119
+
120
+ const handleZoomIn = useCallback(() => {
121
+ setZoom((z) => ZOOM_STEPS.find((s) => s > z + 0.001) ?? 16);
122
+ }, []);
123
+ const handleZoomOut = useCallback(() => {
124
+ setZoom((z) => [...ZOOM_STEPS].reverse().find((s) => s < z - 0.001) ?? 0.0625);
125
+ }, []);
126
+ const handleZoomSet = useCallback((z: number) => {
127
+ setZoom(Math.max(0.0625, Math.min(16, z)));
128
+ }, []);
129
+ const handleZoom1to1 = useCallback(() => setZoom(1), []);
130
+ const handleZoomFit = useCallback(() => {
131
+ if (!state || !surfaceRef.current) return;
132
+ const { clientWidth, clientHeight } = surfaceRef.current;
133
+ const PADDING = 32;
134
+ const fitZ = Math.min(
135
+ (clientWidth - PADDING) / state.doc.canvas.width,
136
+ (clientHeight - PADDING) / state.doc.canvas.height,
137
+ );
138
+ setZoom(Math.max(0.0625, Math.min(16, fitZ)));
139
+ }, [state]);
140
+
141
+ // ID of a newly-created text layer that should immediately enter inline editing.
142
+ const [requestEditLayerId, setRequestEditLayerId] = useState<string | null>(null);
143
+
110
144
  const handleExport = useCallback(
111
145
  async (format: ImageEditExportFormat) => {
112
146
  if (!state) return;
@@ -198,9 +232,11 @@ export function ImageEditor(props: ImageEditorProps) {
198
232
 
199
233
  const handleCreateTextAt = useCallback(
200
234
  (x: number, y: number) => {
235
+ const id = `layer-${Math.random().toString(36).slice(2, 10)}`;
201
236
  dispatch({
202
237
  type: 'add-layer',
203
238
  layer: {
239
+ id,
204
240
  type: 'text',
205
241
  name: 'Text',
206
242
  position: { x: Math.round(x), y: Math.round(y), width: 240, height: 48 },
@@ -211,35 +247,33 @@ export function ImageEditor(props: ImageEditorProps) {
211
247
  },
212
248
  });
213
249
  dispatch({ type: 'set-tool', tool: 'select' });
250
+ setRequestEditLayerId(id);
214
251
  },
215
252
  [dispatch],
216
253
  );
217
254
 
255
+ const shapeKind = state?.shapeKind ?? 'rectangle';
256
+ const shapeDragDraw = isLinearShapeKind(shapeKind);
257
+
218
258
  const handleCreateShapeAt = useCallback(
219
259
  (x: number, y: number) => {
220
- dispatch({
221
- type: 'add-layer',
222
- layer: {
223
- type: 'shape',
224
- name: 'Rectangle',
225
- position: {
226
- x: Math.round(x - 60),
227
- y: Math.round(y - 40),
228
- width: 120,
229
- height: 80,
230
- },
231
- content: {
232
- shape: 'rect',
233
- fill: '#3399ff',
234
- stroke: '#1a4d80',
235
- strokeWidth: 2,
236
- borderRadius: 8,
237
- },
238
- },
239
- });
260
+ const layer = createShapeLayer(shapeKind, x, y);
261
+ const id = `layer-${Math.random().toString(36).slice(2, 10)}`;
262
+ dispatch({ type: 'add-layer', layer: { ...layer, id } });
263
+ dispatch({ type: 'set-tool', tool: 'select' });
264
+ if (layer.type === 'text') {
265
+ setRequestEditLayerId(id);
266
+ }
267
+ },
268
+ [dispatch, shapeKind],
269
+ );
270
+
271
+ const handleCreateShapeFromPoints = useCallback(
272
+ (x1: number, y1: number, x2: number, y2: number) => {
273
+ dispatch({ type: 'add-layer', layer: createLinearShapeLayer(shapeKind, x1, y1, x2, y2) });
240
274
  dispatch({ type: 'set-tool', tool: 'select' });
241
275
  },
242
- [dispatch],
276
+ [dispatch, shapeKind],
243
277
  );
244
278
 
245
279
  if (error) {
@@ -275,6 +309,7 @@ export function ImageEditor(props: ImageEditorProps) {
275
309
  <Toolbar
276
310
  doc={state.doc}
277
311
  tool={state.tool}
312
+ shapeKind={state.shapeKind}
278
313
  dispatch={dispatch}
279
314
  uploadAsset={uploadAsset}
280
315
  onExport={handleExport}
@@ -296,6 +331,12 @@ export function ImageEditor(props: ImageEditorProps) {
296
331
  />
297
332
  ) : null
298
333
  }
334
+ zoom={zoom}
335
+ onZoomIn={handleZoomIn}
336
+ onZoomOut={handleZoomOut}
337
+ onZoomSet={handleZoomSet}
338
+ onZoomFit={handleZoomFit}
339
+ onZoom1to1={handleZoom1to1}
299
340
  />
300
341
  <div className="squisq-image-editor-body">
301
342
  <div className="squisq-image-editor-center">
@@ -307,6 +348,12 @@ export function ImageEditor(props: ImageEditorProps) {
307
348
  dispatch={dispatch}
308
349
  onCreateTextAt={handleCreateTextAt}
309
350
  onCreateShapeAt={handleCreateShapeAt}
351
+ onCreateShapeFromPoints={handleCreateShapeFromPoints}
352
+ shapeDragDraw={shapeDragDraw}
353
+ zoom={zoom}
354
+ onSetZoom={handleZoomSet}
355
+ surfaceRef={surfaceRef}
356
+ requestEditLayerId={requestEditLayerId}
310
357
  />
311
358
  </div>
312
359
  <div className="squisq-image-editor-side">
@@ -25,10 +25,45 @@ export interface OutlinePanelProps {
25
25
  }
26
26
 
27
27
  export function OutlinePanel({ width = 240, className }: OutlinePanelProps) {
28
- const { doc, markdownSource, setMarkdownSource } = useEditorContext();
28
+ const {
29
+ doc,
30
+ markdownSource,
31
+ setMarkdownSource,
32
+ layoutMode,
33
+ goToBlockByLine,
34
+ activeBlockStartLine,
35
+ } = useEditorContext();
29
36
  const paneRef = useRef<HTMLElement | null>(null);
30
37
  const { scrollToBlock } = useHeadingLayout(paneRef);
31
- const activeBlockId = useActiveOutlineBlockId();
38
+ const cursorActiveId = useActiveOutlineBlockId();
39
+
40
+ // In block-at-a-time mode there's no live cursor across the whole document
41
+ // (only the active block is mounted), so the highlight follows the card:
42
+ // match the block whose heading begins on the active slice's source line.
43
+ const blockModeActiveId = useMemo(() => {
44
+ if (layoutMode !== 'block' || activeBlockStartLine == null || !doc) return null;
45
+ const match = flattenBlocks(doc.blocks).find(
46
+ (b) => b.sourceHeading?.position?.start.line === activeBlockStartLine,
47
+ );
48
+ return match?.id ?? null;
49
+ }, [layoutMode, activeBlockStartLine, doc]);
50
+ const activeBlockId = layoutMode === 'block' ? blockModeActiveId : cursorActiveId;
51
+
52
+ // Clicking a row jumps the card to that block in block mode, or scrolls the
53
+ // editor to it in document mode.
54
+ const handleSelect = useCallback(
55
+ (block: Block) => {
56
+ if (layoutMode === 'block') {
57
+ const line = block.sourceHeading?.position?.start.line;
58
+ if (typeof line === 'number') {
59
+ goToBlockByLine(line);
60
+ return;
61
+ }
62
+ }
63
+ scrollToBlock(block);
64
+ },
65
+ [layoutMode, goToBlockByLine, scrollToBlock],
66
+ );
32
67
 
33
68
  // Promote / demote the row's heading by rewriting just the `#` prefix
34
69
  // on the heading line. Falls through when the new depth would leave the
@@ -81,7 +116,7 @@ export function OutlinePanel({ width = 240, className }: OutlinePanelProps) {
81
116
  key={b.id}
82
117
  block={b}
83
118
  activeBlockId={activeBlockId}
84
- onSelect={scrollToBlock}
119
+ onSelect={handleSelect}
85
120
  onChangeLevel={changeHeadingLevel}
86
121
  />
87
122
  ))}
@@ -21,10 +21,25 @@ import type { CSSProperties } from 'react';
21
21
  import { parseMarkdown } from '@bendyline/squisq/markdown';
22
22
  import type { MarkdownDocument, HtmlNode } from '@bendyline/squisq/markdown';
23
23
  import type { MediaProvider, Theme } from '@bendyline/squisq/schemas';
24
- import { markdownDocToPlainHtml } from '@bendyline/squisq-formats/html';
25
24
  import { normalizeMalformedAssetUrl } from './utils/normalizeMalformedAssetUrl';
26
25
  import { collectInlineFontAwesomeCss } from './utils/collectInlineFontAwesomeCss';
27
26
 
27
+ // formats/html is ~50 KB and only needed for the plain-HTML preview pane.
28
+ // Lazy-load on first render so it doesn't sit in the editor's main chunk.
29
+ type RenderFn = typeof import('@bendyline/squisq-formats/html').markdownDocToPlainHtml;
30
+ let cachedRender: RenderFn | null = null;
31
+ let cachedRenderPromise: Promise<RenderFn> | null = null;
32
+ function loadRenderFn(): Promise<RenderFn> {
33
+ if (cachedRender) return Promise.resolve(cachedRender);
34
+ if (!cachedRenderPromise) {
35
+ cachedRenderPromise = import('@bendyline/squisq-formats/html').then((m) => {
36
+ cachedRender = m.markdownDocToPlainHtml;
37
+ return cachedRender;
38
+ });
39
+ }
40
+ return cachedRenderPromise;
41
+ }
42
+
28
43
  export interface PlainHtmlPreviewProps {
29
44
  /** Raw markdown source. */
30
45
  markdown: string;
@@ -129,9 +144,21 @@ export function PlainHtmlPreview({
129
144
  // fetch under `allow-same-origin`.
130
145
  const iconsCss = useMemo(() => collectInlineFontAwesomeCss(), []);
131
146
 
147
+ const [renderFn, setRenderFn] = useState<RenderFn | null>(() => cachedRender);
148
+ useEffect(() => {
149
+ if (renderFn) return;
150
+ let cancelled = false;
151
+ loadRenderFn().then((fn) => {
152
+ if (!cancelled) setRenderFn(() => fn);
153
+ });
154
+ return () => {
155
+ cancelled = true;
156
+ };
157
+ }, [renderFn]);
158
+
132
159
  const html = useMemo(
133
- () => markdownDocToPlainHtml(mdDoc, { title, images: mergedImages, theme, iconsCss }),
134
- [mdDoc, title, mergedImages, theme, iconsCss],
160
+ () => (renderFn ? renderFn(mdDoc, { title, images: mergedImages, theme, iconsCss }) : ''),
161
+ [renderFn, mdDoc, title, mergedImages, theme, iconsCss],
135
162
  );
136
163
 
137
164
  return (
@@ -22,13 +22,19 @@ import {
22
22
  import type { ReactNode } from 'react';
23
23
  import type { DisplayMode, CaptionStyle } from '@bendyline/squisq-react';
24
24
  import type { ViewportPreset, ViewportConfig } from '@bendyline/squisq/schemas';
25
- import { VIEWPORT_PRESETS, getThemeSummaries, resolveTheme } from '@bendyline/squisq/schemas';
25
+ import { VIEWPORT_PRESETS, getThemeSummaries } from '@bendyline/squisq/schemas';
26
26
  import type { Theme } from '@bendyline/squisq/schemas';
27
27
  import { ThemePicker } from './ThemePicker';
28
28
  import { getTransformStyleSummaries } from '@bendyline/squisq/transform';
29
29
  import type { Doc } from '@bendyline/squisq/schemas';
30
30
  import { setFrontmatterValues } from '@bendyline/squisq/markdown';
31
+ import {
32
+ resolveThemeForDoc,
33
+ writeCustomThemesToFrontmatter,
34
+ FRONTMATTER_CUSTOM_THEMES_KEY,
35
+ } from '@bendyline/squisq/doc';
31
36
  import { useEditorContext } from './EditorContext';
37
+ import { useCustomThemes, CustomThemeDialog, type ThemeSaveTarget } from './customThemes';
32
38
 
33
39
  // ── Context ──────────────────────────────────────────────────────
34
40
 
@@ -45,6 +51,23 @@ export interface PreviewSettings {
45
51
  setSelectedTransformStyle: (id: string | null) => void;
46
52
  activeCaptionStyle: CaptionStyle;
47
53
  setSelectedCaptionStyle: (style: CaptionStyle | null) => void;
54
+ /** User-authored themes (doc + browser library) for the picker's "Custom" group. */
55
+ customThemes: Theme[];
56
+ /** Open the custom-theme designer for a theme (or null to create a new one). */
57
+ openThemeDesigner: (theme: Theme | null) => void;
58
+ /** Remove a custom theme from the doc and the library. */
59
+ deleteCustomTheme: (id: string) => void;
60
+ /** Config for the docked theme designer, or null when closed. Rendered by
61
+ * `<ThemeDesignerDock>` in the editor's content row. */
62
+ themeDesigner: ThemeDesignerConfig | null;
63
+ }
64
+
65
+ /** Everything `<ThemeDesignerDock>` needs to render the designer pane. */
66
+ export interface ThemeDesignerConfig {
67
+ value: Theme | null;
68
+ onChange: (theme: Theme) => void;
69
+ onSave: (theme: Theme, target: ThemeSaveTarget) => void;
70
+ onClose: () => void;
48
71
  }
49
72
 
50
73
  const PreviewSettingsContext = createContext<PreviewSettings | null>(null);
@@ -98,12 +121,17 @@ function resolveDisplayMode(value: unknown): DisplayMode | null {
98
121
 
99
122
  const VALID_THEME_IDS = new Set(getThemeSummaries().map((s) => s.id));
100
123
 
101
- function resolveFrontmatterTheme(value: unknown): string | null {
124
+ function resolveFrontmatterTheme(value: unknown, customIds?: Set<string>): string | null {
102
125
  if (typeof value !== 'string') return null;
103
- const v = value.trim().toLowerCase();
126
+ const raw = value.trim();
127
+ const v = raw.toLowerCase();
104
128
  if (VALID_THEME_IDS.has(v)) return v;
105
129
  const normalized = v.replace(/\s+/g, '-');
106
130
  if (VALID_THEME_IDS.has(normalized)) return normalized;
131
+ // Custom theme ids are doc-defined slugs (lowercase from the customizer).
132
+ // Admit them so an inline theme selection isn't rejected back to 'standard'.
133
+ if (customIds?.has(raw)) return raw;
134
+ if (customIds?.has(v)) return v;
107
135
  return null;
108
136
  }
109
137
 
@@ -192,21 +220,43 @@ export function PreviewSettingsProvider({
192
220
  useEffect(() => setSelectedDisplayMode(null), [fmMode]);
193
221
  const activeDisplayMode = selectedDisplayMode ?? fmMode ?? 'video';
194
222
 
223
+ // Custom themes (doc + browser library). `useCustomThemes` returns null when
224
+ // no provider is mounted; the picker then just shows built-ins.
225
+ const custom = useCustomThemes();
226
+ const customThemes = useMemo(() => custom?.allThemes ?? [], [custom]);
227
+ const customIds = useMemo(() => new Set(customThemes.map((t) => t.id)), [customThemes]);
228
+
195
229
  // Theme — persisted to `squisq-theme` (legacy `theme` still read for compat)
196
230
  const fmTheme = useMemo(
197
231
  () =>
198
232
  resolveFrontmatterTheme(
199
233
  readFrontmatterKey(frontmatter, FM_KEYS.theme.canonical, FM_KEYS.theme.legacy),
234
+ customIds,
200
235
  ),
201
- [frontmatter],
236
+ [frontmatter, customIds],
202
237
  );
203
238
  const [selectedThemeId, setSelectedThemeId] = useState<string | null>(null);
204
239
  useEffect(() => setSelectedThemeId(null), [fmTheme]);
205
240
  const resolvedThemeId = selectedThemeId ?? fmTheme ?? 'standard';
206
- const resolvedTheme = useMemo(() => resolveTheme(resolvedThemeId), [resolvedThemeId]);
207
- // themeOverride wins over both dropdown selection and frontmatter
208
- const activeThemeId = themeOverride?.id ?? resolvedThemeId;
209
- const activeTheme = themeOverride ?? resolvedTheme;
241
+ // Doc-scoped resolution: an inline custom theme id resolves from the doc's
242
+ // own `customThemes` before built-ins no global registration needed.
243
+ const resolvedTheme = useMemo(
244
+ () => resolveThemeForDoc(doc, resolvedThemeId),
245
+ [doc, resolvedThemeId],
246
+ );
247
+
248
+ // In-progress theme from the designer dialog; previews live without mutating
249
+ // the doc until the user saves.
250
+ const [previewTheme, setPreviewTheme] = useState<Theme | null>(null);
251
+ const [designer, setDesigner] = useState<{ open: boolean; editing: Theme | null }>({
252
+ open: false,
253
+ editing: null,
254
+ });
255
+
256
+ // Precedence: an active designer preview > external themeOverride > the
257
+ // dropdown/frontmatter selection.
258
+ const activeThemeId = previewTheme?.id ?? themeOverride?.id ?? resolvedThemeId;
259
+ const activeTheme = previewTheme ?? themeOverride ?? resolvedTheme;
210
260
  const handleSetThemeId = useCallback(
211
261
  (id: string | null) => {
212
262
  setSelectedThemeId(id);
@@ -215,6 +265,45 @@ export function PreviewSettingsProvider({
215
265
  [persistFrontmatter],
216
266
  );
217
267
 
268
+ const openThemeDesigner = useCallback((theme: Theme | null) => {
269
+ setDesigner({ open: true, editing: theme });
270
+ setPreviewTheme(theme);
271
+ }, []);
272
+ const closeThemeDesigner = useCallback(() => {
273
+ setDesigner({ open: false, editing: null });
274
+ setPreviewTheme(null);
275
+ }, []);
276
+ const handleDesignerSave = useCallback(
277
+ (theme: Theme, target: ThemeSaveTarget) => {
278
+ if (target === 'library') {
279
+ custom?.upsertLibraryTheme(theme);
280
+ } else {
281
+ // Write the theme payload AND select it in a SINGLE frontmatter update.
282
+ // Two separate `setMarkdownSource` calls (upsertDocTheme + a squisq-theme
283
+ // write) would each derive from the same stale source, so the second
284
+ // would clobber the first and drop the custom-themes payload.
285
+ const docThemes = custom?.docThemes ?? [];
286
+ const idx = docThemes.findIndex((t) => t.id === theme.id);
287
+ const nextThemes =
288
+ idx >= 0 ? docThemes.map((t, i) => (i === idx ? theme : t)) : [...docThemes, theme];
289
+ persistFrontmatter({
290
+ [FRONTMATTER_CUSTOM_THEMES_KEY]: writeCustomThemesToFrontmatter(nextThemes) ?? null,
291
+ [FM_KEYS.theme.canonical]: theme.id,
292
+ });
293
+ setSelectedThemeId(theme.id);
294
+ }
295
+ closeThemeDesigner();
296
+ },
297
+ [custom, persistFrontmatter, closeThemeDesigner],
298
+ );
299
+ const deleteCustomTheme = useCallback(
300
+ (id: string) => {
301
+ custom?.removeDocTheme(id);
302
+ custom?.removeLibraryTheme(id);
303
+ },
304
+ [custom],
305
+ );
306
+
218
307
  // Transform — persisted to `squisq-transform` (legacy `transform-style` read for compat)
219
308
  const fmTransform = useMemo(
220
309
  () =>
@@ -256,6 +345,21 @@ export function PreviewSettingsProvider({
256
345
  [persistFrontmatter],
257
346
  );
258
347
 
348
+ // Config for the docked designer (rendered by `<ThemeDesignerDock>` in the
349
+ // editor content row). Null when closed. setPreviewTheme is a stable setter.
350
+ const themeDesigner = useMemo<ThemeDesignerConfig | null>(
351
+ () =>
352
+ designer.open
353
+ ? {
354
+ value: designer.editing,
355
+ onChange: setPreviewTheme,
356
+ onSave: handleDesignerSave,
357
+ onClose: closeThemeDesigner,
358
+ }
359
+ : null,
360
+ [designer.open, designer.editing, handleDesignerSave, closeThemeDesigner],
361
+ );
362
+
259
363
  const value = useMemo<PreviewSettings>(
260
364
  () => ({
261
365
  activePreset,
@@ -270,6 +374,10 @@ export function PreviewSettingsProvider({
270
374
  setSelectedTransformStyle: handleSetTransformStyle,
271
375
  activeCaptionStyle,
272
376
  setSelectedCaptionStyle: handleSetCaptionStyle,
377
+ customThemes,
378
+ openThemeDesigner,
379
+ deleteCustomTheme,
380
+ themeDesigner,
273
381
  }),
274
382
  [
275
383
  activePreset,
@@ -282,6 +390,10 @@ export function PreviewSettingsProvider({
282
390
  handleSetThemeId,
283
391
  handleSetTransformStyle,
284
392
  handleSetCaptionStyle,
393
+ customThemes,
394
+ openThemeDesigner,
395
+ deleteCustomTheme,
396
+ themeDesigner,
285
397
  ],
286
398
  );
287
399
 
@@ -290,6 +402,25 @@ export function PreviewSettingsProvider({
290
402
  );
291
403
  }
292
404
 
405
+ /**
406
+ * ThemeDesignerDock — renders the docked custom-theme designer pane when open.
407
+ * Placed as a flex sibling of the preview in `EditorShell`'s content row so the
408
+ * preview reflows narrower beside it. Renders nothing when the designer is
409
+ * closed. Must be mounted inside a `PreviewSettingsProvider`.
410
+ */
411
+ export function ThemeDesignerDock() {
412
+ const { themeDesigner } = usePreviewSettings();
413
+ if (!themeDesigner) return null;
414
+ return (
415
+ <CustomThemeDialog
416
+ value={themeDesigner.value}
417
+ onChange={themeDesigner.onChange}
418
+ onSave={themeDesigner.onSave}
419
+ onClose={themeDesigner.onClose}
420
+ />
421
+ );
422
+ }
423
+
293
424
  // ── Dropdown options ─────────────────────────────────────────────
294
425
 
295
426
  const VIEWPORT_OPTIONS: { key: ViewportPreset; label: string }[] = [
@@ -372,6 +503,19 @@ export function PreviewToolbarControls() {
372
503
  return () => document.removeEventListener('mousedown', handler);
373
504
  }, [popoverOpen]);
374
505
 
506
+ // "Edit theme" pencil next to the dropdown. A custom theme opens directly in
507
+ // the designer; a built-in seeds a NEW "Modified <name>" custom theme based
508
+ // on it — Cancel leaves the current theme untouched, Save engages the custom.
509
+ const handleEditCurrentTheme = useCallback(() => {
510
+ const existing = s.customThemes.find((t) => t.id === s.activeThemeId);
511
+ if (existing) {
512
+ s.openThemeDesigner(existing);
513
+ } else {
514
+ const base = s.activeTheme;
515
+ s.openThemeDesigner({ ...base, name: `Modified ${base.name}`, basedOn: base.id });
516
+ }
517
+ }, [s]);
518
+
375
519
  const controls = (
376
520
  <>
377
521
  <PreviewSelect
@@ -396,7 +540,35 @@ export function PreviewToolbarControls() {
396
540
  value={s.activeThemeId}
397
541
  onChange={(v) => s.setSelectedThemeId(v)}
398
542
  ariaLabel="Theme"
543
+ customThemes={s.customThemes}
544
+ onCreateCustom={() => s.openThemeDesigner(null)}
545
+ onEditCustom={(id) =>
546
+ s.openThemeDesigner(s.customThemes.find((t) => t.id === id) ?? null)
547
+ }
548
+ onDeleteCustom={(id) => s.deleteCustomTheme(id)}
399
549
  />
550
+ <button
551
+ type="button"
552
+ className="squisq-theme-edit-btn"
553
+ onClick={handleEditCurrentTheme}
554
+ aria-label="Edit theme"
555
+ title="Edit this theme"
556
+ >
557
+ <svg
558
+ width="14"
559
+ height="14"
560
+ viewBox="0 0 16 16"
561
+ fill="none"
562
+ stroke="currentColor"
563
+ strokeWidth="1.4"
564
+ strokeLinecap="round"
565
+ strokeLinejoin="round"
566
+ aria-hidden="true"
567
+ >
568
+ <path d="M2.5 13.5l1-3 7-7 2 2-7 7-3 1z" />
569
+ <path d="M9.5 4.5l2 2" />
570
+ </svg>
571
+ </button>
400
572
  </div>
401
573
  <PreviewSelect
402
574
  label="Transform"