@bendyline/squisq-editor-react 1.5.3 → 1.6.1

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 (186) hide show
  1. package/README.md +57 -10
  2. package/dist/index.d.ts +1114 -52
  3. package/dist/index.js +18446 -8050
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
  6. package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
  7. package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
  8. package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
  9. package/dist/styles/index.css +13867 -0
  10. package/package.json +15 -7
  11. package/src/BlockCardView.tsx +121 -0
  12. package/src/BlockPreviewPanel.tsx +69 -0
  13. package/src/BlockPropertiesPopover.tsx +191 -0
  14. package/src/EditorContext.tsx +165 -16
  15. package/src/EditorShell.tsx +265 -144
  16. package/src/FolderView.tsx +131 -0
  17. package/src/Icon.tsx +26 -0
  18. package/src/ImageEditor.tsx +69 -22
  19. package/src/OutlinePanel.tsx +64 -7
  20. package/src/PlainHtmlPreview.tsx +30 -3
  21. package/src/PreviewControls.tsx +491 -122
  22. package/src/PreviewPanel.tsx +15 -9
  23. package/src/RawEditor.tsx +179 -17
  24. package/src/RecorderEntry.tsx +9 -16
  25. package/src/TemplateAnnotation.ts +44 -0
  26. package/src/TemplatePicker.tsx +329 -54
  27. package/src/ThemeCustomizerPanel.tsx +30 -336
  28. package/src/ThemePicker.tsx +112 -3
  29. package/src/TimelineBlockPreview.tsx +37 -0
  30. package/src/TimelineTrack.tsx +671 -0
  31. package/src/Toolbar.tsx +544 -180
  32. package/src/Tooltip.tsx +22 -4
  33. package/src/TransitionPicker.tsx +351 -0
  34. package/src/VersionHistoryPanel.tsx +4 -16
  35. package/src/ViewMenuPanel.tsx +17 -14
  36. package/src/WysiwygEditor.tsx +161 -65
  37. package/src/__tests__/blockProperties.test.ts +92 -0
  38. package/src/__tests__/blockRange.test.ts +105 -0
  39. package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
  40. package/src/__tests__/codeContextSectionView.test.tsx +95 -0
  41. package/src/__tests__/codeContextZoneManager.test.ts +127 -0
  42. package/src/__tests__/createShapeLayer.test.ts +46 -0
  43. package/src/__tests__/diffContextSections.test.ts +39 -0
  44. package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
  45. package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
  46. package/src/__tests__/editorShellProps.test.tsx +96 -0
  47. package/src/__tests__/embeddedMedia.test.ts +48 -0
  48. package/src/__tests__/headingTransition.test.ts +138 -0
  49. package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
  50. package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
  51. package/src/__tests__/previewControls.test.tsx +70 -0
  52. package/src/__tests__/recorderMediaInsert.test.ts +86 -0
  53. package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
  54. package/src/__tests__/templatePickerMetadata.test.ts +32 -0
  55. package/src/__tests__/timelineSource.test.ts +134 -0
  56. package/src/__tests__/tiptapBridge.test.ts +92 -0
  57. package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
  58. package/src/__tests__/tooltip.test.tsx +72 -0
  59. package/src/__tests__/transitionCatalog.test.ts +64 -0
  60. package/src/__tests__/useBlockNavigator.test.tsx +67 -0
  61. package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
  62. package/src/__tests__/useMediaRecorder.test.ts +41 -0
  63. package/src/__tests__/useTimelineClock.test.ts +21 -0
  64. package/src/blockProperties.ts +88 -0
  65. package/src/blockRange.ts +132 -0
  66. package/src/buildPreviewDoc.ts +98 -9
  67. package/src/codeContext/CodeContextSectionView.tsx +124 -0
  68. package/src/codeContext/CodeContextZoneManager.ts +149 -0
  69. package/src/codeContext/CodeContextZones.tsx +121 -0
  70. package/src/codeContext/diffContextSections.ts +38 -0
  71. package/src/codeContext/types.ts +75 -0
  72. package/src/customTemplates/AddBin.tsx +126 -0
  73. package/src/customTemplates/CustomLayoutManager.tsx +233 -0
  74. package/src/customTemplates/CustomTemplateContext.tsx +182 -0
  75. package/src/customTemplates/LayerToolbar.tsx +580 -0
  76. package/src/customTemplates/ShapeGlyph.tsx +47 -0
  77. package/src/customTemplates/TemplateDesigner.tsx +430 -0
  78. package/src/customTemplates/__tests__/library.test.ts +88 -0
  79. package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
  80. package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
  81. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
  82. package/src/customTemplates/designer.css +673 -0
  83. package/src/customTemplates/index.ts +31 -0
  84. package/src/customTemplates/library.ts +97 -0
  85. package/src/customTemplates/normalizePositions.ts +75 -0
  86. package/src/customTemplates/shapeDefs.ts +131 -0
  87. package/src/customTemplates/thumbnail.tsx +63 -0
  88. package/src/customTemplates/tokenDefs.ts +60 -0
  89. package/src/customTemplates/useDocCustomTemplates.ts +52 -0
  90. package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
  91. package/src/customThemes/CustomThemeContext.tsx +179 -0
  92. package/src/customThemes/CustomThemeDialog.tsx +286 -0
  93. package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
  94. package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
  95. package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
  96. package/src/customThemes/customThemeLibrary.ts +97 -0
  97. package/src/customThemes/index.ts +31 -0
  98. package/src/customThemes/themeControls.tsx +229 -0
  99. package/src/customThemes/themeDraft.ts +272 -0
  100. package/src/customThemes/useDocCustomThemes.ts +49 -0
  101. package/src/diagram/DiagramCanvas.tsx +240 -0
  102. package/src/diagram/DiagramExtension.ts +209 -0
  103. package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
  104. package/src/diagram/DiagramWidget.tsx +270 -0
  105. package/src/diagram/diagramCommands.ts +604 -0
  106. package/src/diagram/diagramConstants.ts +17 -0
  107. package/src/diagram/useDiagramData.ts +126 -0
  108. package/src/embeddedMedia.ts +78 -0
  109. package/src/frontmatter.ts +29 -0
  110. package/src/headingTransition.ts +231 -0
  111. package/src/imageEditor/CanvasSurface.tsx +383 -88
  112. package/src/imageEditor/PropertiesPanel.tsx +47 -1
  113. package/src/imageEditor/Toolbar.tsx +229 -16
  114. package/src/imageEditor/createShapeLayer.ts +280 -0
  115. package/src/imageEditor/icons.tsx +34 -114
  116. package/src/imageEditor/image-editor.css +54 -5
  117. package/src/imageEditor/state.ts +23 -3
  118. package/src/index.ts +109 -1
  119. package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
  120. package/src/recorder/RecorderModal.tsx +120 -53
  121. package/src/recorder/RecorderPanel.tsx +2 -26
  122. package/src/recorder/hooks/useMediaRecorder.ts +17 -11
  123. package/src/recorder/insertMediaBlock.ts +30 -0
  124. package/src/resolveBlockVisual.ts +33 -0
  125. package/src/scene/Scene.tsx +540 -0
  126. package/src/scene/SceneBlockExtension.ts +198 -0
  127. package/src/scene/SceneBlockToolbar.tsx +201 -0
  128. package/src/scene/SceneBlockWidget.tsx +434 -0
  129. package/src/scene/ScenePropsBar.tsx +85 -0
  130. package/src/scene/SceneSelection.tsx +107 -0
  131. package/src/scene/SceneViewport.tsx +102 -0
  132. package/src/scene/ShapePalette.tsx +181 -0
  133. package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
  134. package/src/scene/__tests__/bezierEdit.test.ts +85 -0
  135. package/src/scene/__tests__/blockLayers.test.ts +57 -0
  136. package/src/scene/__tests__/shapeLayers.test.ts +106 -0
  137. package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
  138. package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
  139. package/src/scene/adapters/DiagramAdapter.ts +168 -0
  140. package/src/scene/adapters/DrawingAdapter.ts +415 -0
  141. package/src/scene/adapters/LayoutAdapter.ts +310 -0
  142. package/src/scene/adapters/blockLayers.ts +159 -0
  143. package/src/scene/commands/SceneCommand.ts +70 -0
  144. package/src/scene/commands/drawingCommands.ts +318 -0
  145. package/src/scene/commands/layoutCommands.ts +301 -0
  146. package/src/scene/hooks/useSceneHitTest.ts +105 -0
  147. package/src/scene/hooks/useScenePanZoom.ts +147 -0
  148. package/src/scene/hooks/useSceneSelection.ts +62 -0
  149. package/src/scene/index.ts +95 -0
  150. package/src/scene/layers/DiagramEdges.tsx +127 -0
  151. package/src/scene/layers/edgeGeometry.ts +77 -0
  152. package/src/scene/layers/nodeCard.tsx +145 -0
  153. package/src/scene/layers/renderLayer.tsx +70 -0
  154. package/src/scene/layers/shapeLayers.ts +201 -0
  155. package/src/scene/paths/bezierEdit.ts +208 -0
  156. package/src/scene/scene.css +649 -0
  157. package/src/scene/text/SceneTextOverlay.tsx +161 -0
  158. package/src/scene/text/sceneTextChannel.ts +40 -0
  159. package/src/scene/text/sceneTextConfig.ts +27 -0
  160. package/src/scene/text/sceneTiptap.ts +36 -0
  161. package/src/scene/text/useSceneTextEditing.ts +39 -0
  162. package/src/scene/tools/ConnectTool.ts +111 -0
  163. package/src/scene/tools/DrawingConnectTool.ts +161 -0
  164. package/src/scene/tools/PathTool.ts +158 -0
  165. package/src/scene/tools/PlaceTool.ts +47 -0
  166. package/src/scene/tools/SceneTool.ts +75 -0
  167. package/src/scene/tools/SelectTool.ts +284 -0
  168. package/src/scene/tools/ShapeTool.ts +144 -0
  169. package/src/scene/tools/TextTool.ts +72 -0
  170. package/src/scene/tools/TokenTool.ts +95 -0
  171. package/src/scene/tools/createDrawShapeTool.ts +82 -0
  172. package/src/styles/code-context.css +155 -0
  173. package/src/styles/diagram.css +183 -0
  174. package/src/styles/editor.css +1708 -150
  175. package/src/styles/folder-view.css +210 -0
  176. package/src/styles/image-edit-affordance.css +2 -2
  177. package/src/styles/index.css +5 -0
  178. package/src/timelineSource.ts +244 -0
  179. package/src/tiptapBridge.ts +115 -34
  180. package/src/tooltipPlacement.ts +13 -0
  181. package/src/transitionCatalog.ts +159 -0
  182. package/src/types/monaco-shims.d.ts +10 -0
  183. package/src/useBlockNavigator.ts +153 -0
  184. package/src/useMonacoLoader.ts +23 -1
  185. package/src/useTimelineClock.ts +76 -0
  186. package/src/utils/dropUtils.ts +1 -1
@@ -14,197 +14,32 @@
14
14
  * else (templateHints, layoutOverrides, persistentLayers, individual
15
15
  * colorSchemes, animation defaults) inherits from the compiler's
16
16
  * STARTER_THEME and can only be edited by hand-modifying the JSON.
17
- * - **Industry-standard mental model** — primary / secondary / accent +
18
- * derived lighter/darker variants, like Material UI / Tailwind / Radix.
17
+ * - **Shared editing model** — the draft model, `compileDraft`, and the
18
+ * form rows live in `customThemes/themeDraft` + `customThemes/themeControls`,
19
+ * shared with the fuller `CustomThemeDialog` (which adds a base-theme
20
+ * picker and the N-accent editor).
19
21
  */
20
22
 
21
23
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
22
- import type { Theme, FontFamily, ThemeSeedColors } from '@bendyline/squisq/schemas';
24
+ import type { Theme, ThemeSeedColors } from '@bendyline/squisq/schemas';
25
+ import { deriveScale, isHex, serializeTheme } from '@bendyline/squisq/schemas';
23
26
  import {
24
- AVAILABLE_FONT_STACKS,
25
- compileTheme,
26
- deriveScale,
27
- isHex,
28
- serializeTheme,
29
- } from '@bendyline/squisq/schemas';
30
-
31
- // ── Preset → schema-value tables ────────────────────────────────────
32
-
33
- const BORDER_RADIUS_PRESETS = {
34
- sharp: 0,
35
- soft: 6,
36
- rounded: 16,
37
- } as const;
38
- type BorderRadiusPreset = keyof typeof BORDER_RADIUS_PRESETS;
39
-
40
- const ANIMATION_SPEED_PRESETS = {
41
- static: 0,
42
- subtle: 1.4,
43
- normal: 1.0,
44
- expressive: 0.7,
45
- } as const;
46
- type AnimationSpeedPreset = keyof typeof ANIMATION_SPEED_PRESETS;
47
-
48
- const TEXT_SHADOW_PRESETS = {
49
- off: false,
50
- on: true,
51
- } as const;
52
- type TextShadowPreset = keyof typeof TEXT_SHADOW_PRESETS;
53
-
54
- const CONTRAST_PRESETS = ['subtle', 'balanced', 'high'] as const;
55
- type ContrastPreset = (typeof CONTRAST_PRESETS)[number];
56
-
57
- const FALLBACK_OPTIONS = ['sans-serif', 'serif', 'monospace', 'system-ui'] as const;
58
- type FallbackOption = (typeof FALLBACK_OPTIONS)[number];
59
-
60
- // ── Draft state — reflects the editable subset of the schema ────────
61
-
62
- interface CustomFontInput {
63
- kind: 'curated' | 'custom';
64
- stackId?: string;
65
- customName?: string;
66
- customFallback?: FallbackOption;
67
- }
68
-
69
- interface Draft {
70
- name: string;
71
- seeds: ThemeSeedColors;
72
- titleFont: CustomFontInput;
73
- bodyFont: CustomFontInput;
74
- borderRadius: BorderRadiusPreset;
75
- animationSpeed: AnimationSpeedPreset;
76
- textShadow: TextShadowPreset;
77
- contrast: ContrastPreset;
78
- }
79
-
80
- const DEFAULT_DRAFT: Draft = {
81
- name: 'My Theme',
82
- seeds: {
83
- primary: '#3182ce',
84
- secondary: '#4a5568',
85
- accent: '#63b3ed',
86
- background: '#1a202c',
87
- text: '#f7fafc',
88
- },
89
- titleFont: { kind: 'curated', stackId: 'system-serif' },
90
- bodyFont: { kind: 'curated', stackId: 'system-sans' },
91
- borderRadius: 'soft',
92
- animationSpeed: 'normal',
93
- textShadow: 'on',
94
- contrast: 'balanced',
95
- };
96
-
97
- function findRadiusPreset(value: number | undefined): BorderRadiusPreset {
98
- if (value === undefined) return 'soft';
99
- let best: BorderRadiusPreset = 'soft';
100
- let bestDist = Infinity;
101
- (Object.entries(BORDER_RADIUS_PRESETS) as [BorderRadiusPreset, number][]).forEach(([k, v]) => {
102
- const d = Math.abs(v - value);
103
- if (d < bestDist) {
104
- best = k;
105
- bestDist = d;
106
- }
107
- });
108
- return best;
109
- }
110
-
111
- function findAnimationPreset(value: number | undefined): AnimationSpeedPreset {
112
- if (value === undefined || value === 0) return value === 0 ? 'static' : 'normal';
113
- let best: AnimationSpeedPreset = 'normal';
114
- let bestDist = Infinity;
115
- (Object.entries(ANIMATION_SPEED_PRESETS) as [AnimationSpeedPreset, number][]).forEach(
116
- ([k, v]) => {
117
- if (v === 0) return; // 'static' handled above
118
- const d = Math.abs(v - value);
119
- if (d < bestDist) {
120
- best = k;
121
- bestDist = d;
122
- }
123
- },
124
- );
125
- return best;
126
- }
127
-
128
- function fontFamilyToInput(f: FontFamily | undefined, fallbackStackId: string): CustomFontInput {
129
- if (!f) return { kind: 'curated', stackId: fallbackStackId };
130
- if ('stackId' in f) return { kind: 'curated', stackId: f.stackId };
131
- if ('custom' in f)
132
- return {
133
- kind: 'custom',
134
- customName: f.custom.name,
135
- customFallback: f.custom.fallback,
136
- };
137
- return { kind: 'curated', stackId: fallbackStackId };
138
- }
139
-
140
- function inputToFontFamily(input: CustomFontInput): FontFamily {
141
- if (input.kind === 'curated') {
142
- return { stackId: input.stackId ?? 'system-sans' };
143
- }
144
- return {
145
- custom: {
146
- name: input.customName ?? 'Sans',
147
- fallback: input.customFallback ?? 'sans-serif',
148
- },
149
- };
150
- }
151
-
152
- function themeToDraft(theme: Theme | null): Draft {
153
- if (!theme) return { ...DEFAULT_DRAFT };
154
- const seeds: ThemeSeedColors = theme.seedColors ?? {
155
- primary: theme.colors.primary,
156
- secondary: theme.colors.secondary,
157
- accent: theme.colors.highlight,
158
- background: theme.colors.background,
159
- text: theme.colors.text,
160
- };
161
- return {
162
- name: theme.name,
163
- seeds: {
164
- primary: seeds.primary,
165
- secondary: seeds.secondary,
166
- accent: seeds.accent,
167
- background: seeds.background,
168
- text: seeds.text,
169
- },
170
- titleFont: fontFamilyToInput(theme.typography.titleFont, 'system-serif'),
171
- bodyFont: fontFamilyToInput(theme.typography.bodyFont, 'system-sans'),
172
- borderRadius: findRadiusPreset(theme.style.borderRadius),
173
- animationSpeed: findAnimationPreset(theme.style.animationSpeed),
174
- textShadow: theme.style.textShadow === false ? 'off' : 'on',
175
- contrast: 'balanced',
176
- };
177
- }
178
-
179
- function slugify(s: string): string {
180
- return (
181
- s
182
- .toLowerCase()
183
- .replace(/[^a-z0-9]+/g, '-')
184
- .replace(/^-+|-+$/g, '') || 'custom'
185
- );
186
- }
187
-
188
- function compileDraft(draft: Draft, baseId?: string): Theme {
189
- const id = baseId && baseId.startsWith('custom-') ? baseId : `custom-${slugify(draft.name)}`;
190
- return compileTheme(
191
- {
192
- id,
193
- name: draft.name,
194
- seedColors: draft.seeds,
195
- typography: {
196
- titleFont: inputToFontFamily(draft.titleFont),
197
- bodyFont: inputToFontFamily(draft.bodyFont),
198
- },
199
- style: {
200
- borderRadius: BORDER_RADIUS_PRESETS[draft.borderRadius],
201
- animationSpeed: ANIMATION_SPEED_PRESETS[draft.animationSpeed],
202
- textShadow: TEXT_SHADOW_PRESETS[draft.textShadow],
203
- },
204
- },
205
- { contrast: draft.contrast },
206
- );
207
- }
27
+ type Draft,
28
+ DEFAULT_DRAFT,
29
+ themeToDraft,
30
+ compileDraft,
31
+ BORDER_RADIUS_PRESETS,
32
+ ANIMATION_SPEED_PRESETS,
33
+ TEXT_SHADOW_PRESETS,
34
+ CONTRAST_PRESETS,
35
+ IMAGE_TREATMENT_PRESETS,
36
+ type BorderRadiusPreset,
37
+ type AnimationSpeedPreset,
38
+ type TextShadowPreset,
39
+ type ContrastPreset,
40
+ type ImageTreatmentPreset,
41
+ } from './customThemes/themeDraft';
42
+ import { Section, SeedColorRow, FontPicker, PresetRow } from './customThemes/themeControls';
208
43
 
209
44
  // ── Component ───────────────────────────────────────────────────────
210
45
 
@@ -257,7 +92,7 @@ export function ThemeCustomizerPanel({
257
92
  setDraft((prev) => {
258
93
  const next = typeof patch === 'function' ? patch(prev) : { ...prev, ...patch };
259
94
  try {
260
- const compiled = compileDraft(next, value?.id);
95
+ const compiled = compileDraft(next, { existingId: value?.id });
261
96
  onChange(compiled);
262
97
  } catch {
263
98
  // Invalid intermediate state (e.g., bad hex while typing) — skip emit.
@@ -277,7 +112,7 @@ export function ThemeCustomizerPanel({
277
112
 
278
113
  const handleSave = useCallback(() => {
279
114
  try {
280
- const compiled = compileDraft(draft, value?.id);
115
+ const compiled = compileDraft(draft, { existingId: value?.id });
281
116
  onSave?.(compiled, serializeTheme(compiled));
282
117
  } catch {
283
118
  // Validation should already have surfaced via the disabled state.
@@ -424,6 +259,12 @@ export function ThemeCustomizerPanel({
424
259
  options={CONTRAST_PRESETS as readonly ContrastPreset[]}
425
260
  onChange={(v) => updateDraft({ contrast: v })}
426
261
  />
262
+ <PresetRow
263
+ label="Image grade"
264
+ value={draft.imageTreatment}
265
+ options={IMAGE_TREATMENT_PRESETS as readonly ImageTreatmentPreset[]}
266
+ onChange={(v) => updateDraft({ imageTreatment: v })}
267
+ />
427
268
  </Section>
428
269
  </div>
429
270
 
@@ -446,150 +287,3 @@ export function ThemeCustomizerPanel({
446
287
  </div>
447
288
  );
448
289
  }
449
-
450
- // ── Subcomponents ───────────────────────────────────────────────────
451
-
452
- function Section({
453
- title,
454
- hint,
455
- children,
456
- }: {
457
- title: string;
458
- hint?: string;
459
- children: React.ReactNode;
460
- }) {
461
- return (
462
- <div className="squisq-theme-customizer-section">
463
- <div className="squisq-theme-customizer-section-title">{title}</div>
464
- {hint && <div className="squisq-theme-customizer-section-hint">{hint}</div>}
465
- <div className="squisq-theme-customizer-section-body">{children}</div>
466
- </div>
467
- );
468
- }
469
-
470
- function SeedColorRow({
471
- label,
472
- value,
473
- onChange,
474
- }: {
475
- label: string;
476
- value: string;
477
- onChange: (hex: string) => void;
478
- }) {
479
- const safeValue = isHex(value) ? value : '#000000';
480
- return (
481
- <label className="squisq-theme-customizer-row">
482
- <span className="squisq-theme-customizer-row-label">{label}</span>
483
- <input
484
- type="color"
485
- className="squisq-theme-customizer-color"
486
- value={safeValue}
487
- onChange={(e) => onChange(e.target.value)}
488
- />
489
- <input
490
- type="text"
491
- className="squisq-theme-customizer-input squisq-theme-customizer-input--hex"
492
- value={value}
493
- onChange={(e) => onChange(e.target.value)}
494
- spellCheck={false}
495
- aria-label={`${label} hex value`}
496
- />
497
- </label>
498
- );
499
- }
500
-
501
- function FontPicker({
502
- label,
503
- value,
504
- onChange,
505
- }: {
506
- label: string;
507
- value: CustomFontInput;
508
- onChange: (next: CustomFontInput) => void;
509
- }) {
510
- return (
511
- <div className="squisq-theme-customizer-row squisq-theme-customizer-row--font">
512
- <span className="squisq-theme-customizer-row-label">{label}</span>
513
- <select
514
- className="squisq-theme-customizer-input"
515
- value={value.kind === 'custom' ? '__custom__' : (value.stackId ?? '')}
516
- onChange={(e) => {
517
- const v = e.target.value;
518
- if (v === '__custom__') {
519
- onChange({
520
- kind: 'custom',
521
- customName: value.customName ?? '',
522
- customFallback: value.customFallback ?? 'sans-serif',
523
- });
524
- } else {
525
- onChange({ kind: 'curated', stackId: v });
526
- }
527
- }}
528
- aria-label={`${label} font`}
529
- >
530
- {AVAILABLE_FONT_STACKS.map((stack) => (
531
- <option key={stack.id} value={stack.id}>
532
- {stack.label}
533
- </option>
534
- ))}
535
- <option value="__custom__">Custom…</option>
536
- </select>
537
- {value.kind === 'custom' && (
538
- <>
539
- <input
540
- type="text"
541
- className="squisq-theme-customizer-input"
542
- placeholder="Font name"
543
- value={value.customName ?? ''}
544
- onChange={(e) => onChange({ ...value, customName: e.target.value })}
545
- aria-label={`${label} custom font name`}
546
- />
547
- <select
548
- className="squisq-theme-customizer-input"
549
- value={value.customFallback ?? 'sans-serif'}
550
- onChange={(e) =>
551
- onChange({ ...value, customFallback: e.target.value as FallbackOption })
552
- }
553
- aria-label={`${label} custom font fallback`}
554
- >
555
- {FALLBACK_OPTIONS.map((opt) => (
556
- <option key={opt} value={opt}>
557
- {opt}
558
- </option>
559
- ))}
560
- </select>
561
- </>
562
- )}
563
- </div>
564
- );
565
- }
566
-
567
- function PresetRow<T extends string>({
568
- label,
569
- value,
570
- options,
571
- onChange,
572
- }: {
573
- label: string;
574
- value: T;
575
- options: readonly T[];
576
- onChange: (v: T) => void;
577
- }) {
578
- return (
579
- <label className="squisq-theme-customizer-row">
580
- <span className="squisq-theme-customizer-row-label">{label}</span>
581
- <select
582
- className="squisq-theme-customizer-input"
583
- value={value}
584
- onChange={(e) => onChange(e.target.value as T)}
585
- aria-label={label}
586
- >
587
- {options.map((o) => (
588
- <option key={o} value={o}>
589
- {o}
590
- </option>
591
- ))}
592
- </select>
593
- </label>
594
- );
595
- }
@@ -41,6 +41,18 @@ export interface ThemePickerProps {
41
41
  variant?: 'compact' | 'full';
42
42
  /** Accessible label, e.g. "Theme". */
43
43
  ariaLabel?: string;
44
+ /**
45
+ * User-authored themes to list in a "Custom" group (doc + library, from
46
+ * `useCustomThemes().allThemes`). When omitted, only built-ins show — so
47
+ * the base-theme picker and other embeds stay built-ins only.
48
+ */
49
+ customThemes?: Theme[];
50
+ /** When provided, renders a "+ Create custom theme" row that calls this. */
51
+ onCreateCustom?: () => void;
52
+ /** Per-custom-card edit affordance (opens the designer for that theme). */
53
+ onEditCustom?: (id: string) => void;
54
+ /** Per-custom-card delete affordance. */
55
+ onDeleteCustom?: (id: string) => void;
44
56
  }
45
57
 
46
58
  // ── Helpers ───────────────────────────────────────────────────────
@@ -113,15 +125,37 @@ export function ThemePicker({
113
125
  includeDefault,
114
126
  variant = 'compact',
115
127
  ariaLabel = 'Theme',
128
+ customThemes,
129
+ onCreateCustom,
130
+ onEditCustom,
131
+ onDeleteCustom,
116
132
  }: ThemePickerProps) {
117
133
  const [open, setOpen] = useState(false);
118
134
  const [popoverStyle, setPopoverStyle] = useState<React.CSSProperties>({});
119
135
  const triggerRef = useRef<HTMLButtonElement>(null);
120
136
 
137
+ // Custom entries are computed per-render (built-ins stay static at module
138
+ // load). A custom theme is already a full Theme, so it becomes an entry
139
+ // directly — no `resolveTheme` needed.
140
+ const customEntries = useMemo<ThemeEntry[]>(
141
+ () =>
142
+ (customThemes ?? []).map((t) => ({
143
+ id: t.id,
144
+ name: t.name,
145
+ description: t.description,
146
+ theme: t,
147
+ })),
148
+ [customThemes],
149
+ );
150
+ const findEntry = useCallback(
151
+ (id: string): ThemeEntry | undefined => entryById(id) ?? customEntries.find((e) => e.id === id),
152
+ [customEntries],
153
+ );
154
+
121
155
  const selectedEntry = useMemo<ThemeEntry | null>(() => {
122
156
  if (!value) return null;
123
- return entryById(value) ?? null;
124
- }, [value]);
157
+ return findEntry(value) ?? null;
158
+ }, [value, findEntry]);
125
159
 
126
160
  // The trigger always wants *something* to preview. When `includeDefault`
127
161
  // is on and the value is empty, we treat the selection as the implicit
@@ -142,7 +176,12 @@ export function ThemePicker({
142
176
  const gap = 4;
143
177
  const vw = window.innerWidth;
144
178
  const vh = window.innerHeight;
145
- const popoverWidth = Math.max(280, rect.width);
179
+ // Aim for a multi-column popover, but never wider than the viewport.
180
+ // The CSS grid packs as many theme cards per row as this width allows,
181
+ // so clamping to the available width makes a narrow window collapse to
182
+ // fewer columns (down to one) instead of overrunning the edge.
183
+ const available = vw - margin * 2;
184
+ const popoverWidth = Math.min(available, Math.max(560, rect.width));
146
185
  const maxLeft = Math.max(margin, vw - popoverWidth - margin);
147
186
  const left = Math.min(Math.max(margin, rect.left), maxLeft);
148
187
 
@@ -267,6 +306,76 @@ export function ThemePicker({
267
306
  </span>
268
307
  </button>
269
308
  ))}
309
+ {customEntries.length > 0 && (
310
+ <div className="squisq-theme-picker-group-label">Custom</div>
311
+ )}
312
+ {customEntries.map((entry) => (
313
+ <div key={entry.id} className="squisq-theme-picker-custom-wrap">
314
+ <button
315
+ type="button"
316
+ role="option"
317
+ aria-selected={value === entry.id}
318
+ aria-label={entry.name}
319
+ className={`squisq-theme-picker-row${value === entry.id ? ' squisq-theme-picker-row--selected' : ''}`}
320
+ onClick={() => handleSelect(entry.id)}
321
+ title={entry.description ?? entry.name}
322
+ >
323
+ <ThemeNameChip theme={entry.theme} label={entry.name} />
324
+ <span className="squisq-theme-picker-row-meta">
325
+ <Swatches theme={entry.theme} />
326
+ {entry.description && (
327
+ <span className="squisq-theme-picker-row-desc">{entry.description}</span>
328
+ )}
329
+ </span>
330
+ </button>
331
+ {(onEditCustom || onDeleteCustom) && (
332
+ <span className="squisq-theme-picker-card-actions">
333
+ {onEditCustom && (
334
+ <button
335
+ type="button"
336
+ className="squisq-theme-picker-card-action"
337
+ onClick={() => onEditCustom(entry.id)}
338
+ aria-label={`Edit ${entry.name}`}
339
+ title="Edit theme"
340
+ >
341
+
342
+ </button>
343
+ )}
344
+ {onDeleteCustom && (
345
+ <button
346
+ type="button"
347
+ className="squisq-theme-picker-card-action"
348
+ onClick={() => onDeleteCustom(entry.id)}
349
+ aria-label={`Delete ${entry.name}`}
350
+ title="Delete theme"
351
+ >
352
+ ×
353
+ </button>
354
+ )}
355
+ </span>
356
+ )}
357
+ </div>
358
+ ))}
359
+ {onCreateCustom && (
360
+ <button
361
+ type="button"
362
+ className="squisq-theme-picker-row squisq-theme-picker-create"
363
+ onClick={() => {
364
+ setOpen(false);
365
+ onCreateCustom();
366
+ }}
367
+ >
368
+ <span className="squisq-theme-picker-create-plus" aria-hidden="true">
369
+ +
370
+ </span>
371
+ <span className="squisq-theme-picker-row-meta">
372
+ <span className="squisq-theme-picker-row-name">Create custom theme…</span>
373
+ <span className="squisq-theme-picker-row-desc">
374
+ Design your own colors, accents, and fonts.
375
+ </span>
376
+ </span>
377
+ </button>
378
+ )}
270
379
  </div>,
271
380
  document.body,
272
381
  )
@@ -0,0 +1,37 @@
1
+ /**
2
+ * TimelineBlockPreview
3
+ *
4
+ * Renders a tiny slideshow-style thumbnail of a single block, used to fill each
5
+ * bar on the timeline. Reuses the same rendering path as the inline preview
6
+ * gutter and the slideshow: `buildPreviewDoc` → `getLayers` → `BlockRenderer`.
7
+ */
8
+
9
+ import { memo } from 'react';
10
+ import type { Block, ViewportConfig, MediaProvider } from '@bendyline/squisq/schemas';
11
+ import { VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
12
+ import { BlockRenderer, MediaContext } from '@bendyline/squisq-react';
13
+
14
+ export interface BlockThumbnailProps {
15
+ /** A block already resolved via `resolveBlockVisual`. */
16
+ visual: Block;
17
+ viewport?: ViewportConfig;
18
+ basePath?: string;
19
+ mediaProvider?: MediaProvider | null;
20
+ }
21
+
22
+ /**
23
+ * Memoized so the SVG only re-renders when the resolved block changes — keeps
24
+ * the timeline smooth while dragging (where bar geometry updates every frame).
25
+ */
26
+ export const BlockThumbnail = memo(function BlockThumbnail({
27
+ visual,
28
+ viewport = VIEWPORT_PRESETS.landscape,
29
+ basePath = '/',
30
+ mediaProvider = null,
31
+ }: BlockThumbnailProps) {
32
+ return (
33
+ <MediaContext.Provider value={mediaProvider}>
34
+ <BlockRenderer block={visual} blockTime={0} basePath={basePath} viewport={viewport} />
35
+ </MediaContext.Provider>
36
+ );
37
+ });