@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
@@ -0,0 +1,179 @@
1
+ /**
2
+ * CustomThemeContext — exposes the merged set of doc-level + library custom
3
+ * themes to the rest of the editor (chiefly ThemePicker and CustomThemeDialog).
4
+ *
5
+ * The theme analog of CustomTemplateContext. The host wraps the editor in a
6
+ * provider, supplying:
7
+ * - `docThemes`: the list currently sourced from the active doc's
8
+ * frontmatter (read from `Doc.customThemes`).
9
+ * - `onDocThemesChange`: a callback the host uses to persist a change back
10
+ * into the doc (e.g. by updating the markdown source).
11
+ *
12
+ * Library themes come from `localStorage` via `customThemeLibrary.ts` and are
13
+ * managed entirely within the context — the host doesn't see them.
14
+ *
15
+ * `applyTheme(theme)` is the bridge between the two pools: when a user picks a
16
+ * library theme, we copy its definition into the doc's `customThemes` (if it
17
+ * isn't already there) so the doc remains self-sufficient for SSR / export.
18
+ * Selecting it as the active theme (writing `squisq-theme: <id>`) is a
19
+ * separate step the caller performs; this just ensures the doc-scoped resolve
20
+ * has the definition — exactly as `applyTemplate` seeds `Doc.customTemplates`.
21
+ */
22
+
23
+ import {
24
+ createContext,
25
+ useCallback,
26
+ useContext,
27
+ useEffect,
28
+ useMemo,
29
+ useState,
30
+ type ReactNode,
31
+ } from 'react';
32
+ import type { Theme } from '@bendyline/squisq/schemas';
33
+ import {
34
+ listLibraryThemes,
35
+ saveLibraryTheme as saveLibraryThemeRaw,
36
+ deleteLibraryTheme as deleteLibraryThemeRaw,
37
+ } from './customThemeLibrary';
38
+
39
+ export interface CustomThemeContextValue {
40
+ /** Themes inlined into the current doc's frontmatter. */
41
+ docThemes: Theme[];
42
+ /** Themes the user has saved to their browser-local library. */
43
+ libraryThemes: Theme[];
44
+ /**
45
+ * Convenience: the union of doc + library, with library entries skipped
46
+ * when a doc theme of the same id already exists. Used by the picker so a
47
+ * single "Custom" section covers both sources without duplicates.
48
+ */
49
+ allThemes: Theme[];
50
+
51
+ /**
52
+ * Persist a theme into the current doc. Replaces any existing entry with
53
+ * the same `id`. Triggers the host's `onDocThemesChange` so the doc's
54
+ * frontmatter ends up updated.
55
+ */
56
+ upsertDocTheme: (theme: Theme) => void;
57
+ /** Persist a theme into the user's library (replaces by id). */
58
+ upsertLibraryTheme: (theme: Theme) => void;
59
+ /** Remove a doc-level theme by id. */
60
+ removeDocTheme: (id: string) => void;
61
+ /** Remove a library theme by id. */
62
+ removeLibraryTheme: (id: string) => void;
63
+ /**
64
+ * Apply a theme to the current doc. If the theme is already in the doc,
65
+ * this is a no-op. If it's only in the library, it's copied into the doc
66
+ * so SSR / export work. Returns the resolved definition so callers can
67
+ * also write the `squisq-theme` selection.
68
+ */
69
+ applyTheme: (theme: Theme) => Theme;
70
+ }
71
+
72
+ const CustomThemeContext = createContext<CustomThemeContextValue | null>(null);
73
+
74
+ export interface CustomThemeProviderProps {
75
+ /** The current doc's custom themes (from `Doc.customThemes`). */
76
+ docThemes: Theme[];
77
+ /**
78
+ * Callback the host uses to persist a new doc-theme list back into the doc
79
+ * (e.g. by writing the encoded payload into the markdown's
80
+ * `squisq-custom-themes` frontmatter key).
81
+ */
82
+ onDocThemesChange: (next: Theme[]) => void;
83
+ children: ReactNode;
84
+ }
85
+
86
+ export function CustomThemeProvider({
87
+ docThemes,
88
+ onDocThemesChange,
89
+ children,
90
+ }: CustomThemeProviderProps) {
91
+ const [libraryThemes, setLibraryThemes] = useState<Theme[]>(() => listLibraryThemes());
92
+
93
+ // Re-read the library when this provider mounts so cross-tab edits (or
94
+ // initial first-paint quirks) are picked up. We don't subscribe to
95
+ // localStorage events in v1 — opening the picker re-renders and re-reads,
96
+ // which is enough for the single-tab common case.
97
+ useEffect(() => {
98
+ setLibraryThemes(listLibraryThemes());
99
+ }, []);
100
+
101
+ const upsertDocTheme = useCallback(
102
+ (theme: Theme) => {
103
+ const existingIdx = docThemes.findIndex((t) => t.id === theme.id);
104
+ const next =
105
+ existingIdx >= 0
106
+ ? docThemes.map((t, i) => (i === existingIdx ? theme : t))
107
+ : [...docThemes, theme];
108
+ onDocThemesChange(next);
109
+ },
110
+ [docThemes, onDocThemesChange],
111
+ );
112
+
113
+ const removeDocTheme = useCallback(
114
+ (id: string) => {
115
+ const next = docThemes.filter((t) => t.id !== id);
116
+ if (next.length !== docThemes.length) onDocThemesChange(next);
117
+ },
118
+ [docThemes, onDocThemesChange],
119
+ );
120
+
121
+ const upsertLibraryTheme = useCallback((theme: Theme) => {
122
+ setLibraryThemes(saveLibraryThemeRaw(theme));
123
+ }, []);
124
+
125
+ const removeLibraryTheme = useCallback((id: string) => {
126
+ setLibraryThemes(deleteLibraryThemeRaw(id));
127
+ }, []);
128
+
129
+ const applyTheme = useCallback(
130
+ (theme: Theme) => {
131
+ // If the doc already has this theme (matching id), leave both alone —
132
+ // the existing entry might be a customized copy.
133
+ const inDoc = docThemes.find((t) => t.id === theme.id);
134
+ if (!inDoc) upsertDocTheme(theme);
135
+ return inDoc ?? theme;
136
+ },
137
+ [docThemes, upsertDocTheme],
138
+ );
139
+
140
+ const allThemes = useMemo(() => {
141
+ const seen = new Set(docThemes.map((t) => t.id));
142
+ return [...docThemes, ...libraryThemes.filter((t) => !seen.has(t.id))];
143
+ }, [docThemes, libraryThemes]);
144
+
145
+ const value = useMemo<CustomThemeContextValue>(
146
+ () => ({
147
+ docThemes,
148
+ libraryThemes,
149
+ allThemes,
150
+ upsertDocTheme,
151
+ upsertLibraryTheme,
152
+ removeDocTheme,
153
+ removeLibraryTheme,
154
+ applyTheme,
155
+ }),
156
+ [
157
+ docThemes,
158
+ libraryThemes,
159
+ allThemes,
160
+ upsertDocTheme,
161
+ upsertLibraryTheme,
162
+ removeDocTheme,
163
+ removeLibraryTheme,
164
+ applyTheme,
165
+ ],
166
+ );
167
+
168
+ return <CustomThemeContext.Provider value={value}>{children}</CustomThemeContext.Provider>;
169
+ }
170
+
171
+ /**
172
+ * Hook returning the current context. Returns null when no provider is
173
+ * mounted — callers can degrade to "no custom themes" rather than throwing,
174
+ * since the feature is optional.
175
+ */
176
+ // eslint-disable-next-line react-refresh/only-export-components -- hook is co-located with its provider by design, mirroring CustomTemplateContext
177
+ export function useCustomThemes(): CustomThemeContextValue | null {
178
+ return useContext(CustomThemeContext);
179
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * CustomThemeDialog
3
+ *
4
+ * Docked, **non-modal** theme designer pane — the theme analog of
5
+ * `TemplateDesigner`. Because applying theme properties (colors, fonts, …) is
6
+ * WYSIWYG, the designer docks into the editor's content row (below the
7
+ * toolbars, to the right of the preview) rather than covering the canvas with
8
+ * a modal; the preview shrinks to make room, so it stays fully visible and
9
+ * interactive while the user edits. `onChange` fires the compiled theme on
10
+ * every edit so the host can preview it via `PreviewSettingsProvider`'s
11
+ * `themeOverride`; the doc is only mutated on Save.
12
+ *
13
+ * The user picks a base theme to inherit render style / layout from, edits seed
14
+ * colors (primary / secondary / tertiary), an add/remove list of accent colors,
15
+ * fonts and style presets, and saves to the document or the browser-local
16
+ * library (`saveTarget: 'doc' | 'library'`).
17
+ *
18
+ * Rendered as a plain flex-item pane (a fixed-width, full-height column) — the
19
+ * host (`ThemeDesignerDock` inside `EditorShell`'s content row) places it as a
20
+ * sibling of the preview so the flexbox reflows the preview narrower.
21
+ *
22
+ * The editing model (draft + `compileDraft`) and the form rows are shared with
23
+ * the lighter `ThemeCustomizerPanel` popover via `themeDraft` + `themeControls`.
24
+ */
25
+
26
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
27
+ import type { Theme, ThemeSeedColors } from '@bendyline/squisq/schemas';
28
+ import { resolveTheme } from '@bendyline/squisq/schemas';
29
+ import { ThemePicker } from '../ThemePicker';
30
+ import {
31
+ type Draft,
32
+ type AccentInput,
33
+ themeToDraft,
34
+ compileDraft,
35
+ accentsFromTheme,
36
+ BORDER_RADIUS_PRESETS,
37
+ ANIMATION_SPEED_PRESETS,
38
+ TEXT_SHADOW_PRESETS,
39
+ CONTRAST_PRESETS,
40
+ IMAGE_TREATMENT_PRESETS,
41
+ type BorderRadiusPreset,
42
+ type AnimationSpeedPreset,
43
+ type TextShadowPreset,
44
+ type ContrastPreset,
45
+ type ImageTreatmentPreset,
46
+ } from './themeDraft';
47
+ import { Section, SeedColorRow, FontPicker, PresetRow, AccentEditor } from './themeControls';
48
+
49
+ /** Where a saved theme lands — mirrors `DesignerSaveTarget` for templates. */
50
+ export type ThemeSaveTarget = 'doc' | 'library';
51
+
52
+ export interface CustomThemeDialogProps {
53
+ /** Theme being edited, or null to author a new one. */
54
+ value: Theme | null;
55
+ /** Fired on every edit with the compiled theme (host previews via themeOverride). */
56
+ onChange?: (theme: Theme) => void;
57
+ /** Fired when the user saves, with the chosen target. */
58
+ onSave: (theme: Theme, target: ThemeSaveTarget) => void;
59
+ /** Dismiss (Cancel / Esc / close button). */
60
+ onClose: () => void;
61
+ }
62
+
63
+ function initialDraft(value: Theme | null): Draft {
64
+ const d = themeToDraft(value);
65
+ // Re-opening an existing custom theme: treat its accents as authored so a
66
+ // seed-only edit doesn't silently drop them back to the base's schemes.
67
+ if (value) d.accentsEdited = true;
68
+ return d;
69
+ }
70
+
71
+ export function CustomThemeDialog({ value, onChange, onSave, onClose }: CustomThemeDialogProps) {
72
+ const [draft, setDraft] = useState<Draft>(() => initialDraft(value));
73
+
74
+ const nameRef = useRef<HTMLInputElement>(null);
75
+ useEffect(() => {
76
+ nameRef.current?.focus();
77
+ nameRef.current?.select();
78
+ }, []);
79
+
80
+ useEffect(() => {
81
+ const onKey = (e: KeyboardEvent) => {
82
+ if (e.key === 'Escape') onClose();
83
+ };
84
+ document.addEventListener('keydown', onKey);
85
+ return () => document.removeEventListener('keydown', onKey);
86
+ }, [onClose]);
87
+
88
+ const compile = useCallback(
89
+ (d: Draft): Theme => {
90
+ const base = d.baseId ? resolveTheme(d.baseId) : undefined;
91
+ return compileDraft(d, { existingId: value?.id, base });
92
+ },
93
+ [value?.id],
94
+ );
95
+
96
+ const updateDraft = useCallback(
97
+ (patch: Partial<Draft> | ((d: Draft) => Draft)) => {
98
+ setDraft((prev) => {
99
+ const next = typeof patch === 'function' ? patch(prev) : { ...prev, ...patch };
100
+ try {
101
+ onChange?.(compile(next));
102
+ } catch {
103
+ // Invalid intermediate state (e.g. bad hex mid-typing) — skip preview.
104
+ }
105
+ return next;
106
+ });
107
+ },
108
+ [onChange, compile],
109
+ );
110
+
111
+ const updateSeed = useCallback(
112
+ (key: keyof ThemeSeedColors, hex: string) =>
113
+ updateDraft((d) => ({ ...d, seeds: { ...d.seeds, [key]: hex } })),
114
+ [updateDraft],
115
+ );
116
+
117
+ const handleSave = useCallback(
118
+ (target: ThemeSaveTarget) => {
119
+ try {
120
+ onSave(compile(draft), target);
121
+ } catch {
122
+ // A compile failure here means the draft is invalid; leave the dialog open.
123
+ }
124
+ },
125
+ [draft, onSave, compile],
126
+ );
127
+
128
+ // Accents show the base's schemes until the user edits them (then the draft's
129
+ // own list wins). Editing any accent flips `accentsEdited` so the compiled
130
+ // theme replaces `colorSchemes` wholesale.
131
+ const base = useMemo(() => (draft.baseId ? resolveTheme(draft.baseId) : null), [draft.baseId]);
132
+ const displayAccents: AccentInput[] = draft.accentsEdited
133
+ ? draft.accents
134
+ : accentsFromTheme(base);
135
+ const onAccentsChange = useCallback(
136
+ (next: AccentInput[]) => updateDraft({ accents: next, accentsEdited: true }),
137
+ [updateDraft],
138
+ );
139
+
140
+ return (
141
+ <div className="squisq-custom-theme-pane" role="dialog" aria-label="Design custom theme">
142
+ <div className="squisq-doc-settings-header">
143
+ <h2 className="squisq-doc-settings-title">
144
+ {value ? 'Edit custom theme' : 'New custom theme'}
145
+ </h2>
146
+ <button
147
+ type="button"
148
+ className="squisq-doc-settings-close"
149
+ onClick={onClose}
150
+ aria-label="Close"
151
+ >
152
+ &times;
153
+ </button>
154
+ </div>
155
+
156
+ <div className="squisq-doc-settings-body squisq-theme-customizer-body squisq-custom-theme-pane-body">
157
+ <Section title="Name">
158
+ <input
159
+ ref={nameRef}
160
+ type="text"
161
+ className="squisq-theme-customizer-input"
162
+ value={draft.name}
163
+ onChange={(e) => updateDraft({ name: e.target.value })}
164
+ aria-label="Theme name"
165
+ />
166
+ </Section>
167
+
168
+ <Section title="Base theme" hint="Inherit render style, motion, and layout from a base.">
169
+ <ThemePicker
170
+ value={draft.baseId ?? ''}
171
+ onChange={(id) => updateDraft({ baseId: id || undefined })}
172
+ includeDefault
173
+ variant="full"
174
+ ariaLabel="Base theme"
175
+ />
176
+ </Section>
177
+
178
+ <Section
179
+ title="Colors"
180
+ hint="Primary, secondary, and tertiary (accent). The rest is derived."
181
+ >
182
+ <SeedColorRow
183
+ label="Primary"
184
+ value={draft.seeds.primary}
185
+ onChange={(v) => updateSeed('primary', v)}
186
+ />
187
+ <SeedColorRow
188
+ label="Secondary"
189
+ value={draft.seeds.secondary ?? ''}
190
+ onChange={(v) => updateSeed('secondary', v)}
191
+ />
192
+ <SeedColorRow
193
+ label="Tertiary"
194
+ value={draft.seeds.accent ?? ''}
195
+ onChange={(v) => updateSeed('accent', v)}
196
+ />
197
+ <SeedColorRow
198
+ label="Background"
199
+ value={draft.seeds.background ?? ''}
200
+ onChange={(v) => updateSeed('background', v)}
201
+ />
202
+ <SeedColorRow
203
+ label="Text"
204
+ value={draft.seeds.text ?? ''}
205
+ onChange={(v) => updateSeed('text', v)}
206
+ />
207
+ </Section>
208
+
209
+ <Section title="Accents" hint="Per-block accent colors. Add as many as you like.">
210
+ <AccentEditor accents={displayAccents} onChange={onAccentsChange} />
211
+ </Section>
212
+
213
+ <Section title="Typography">
214
+ <FontPicker
215
+ label="Heading"
216
+ value={draft.titleFont}
217
+ onChange={(next) => updateDraft({ titleFont: next })}
218
+ />
219
+ <FontPicker
220
+ label="Body"
221
+ value={draft.bodyFont}
222
+ onChange={(next) => updateDraft({ bodyFont: next })}
223
+ />
224
+ </Section>
225
+
226
+ <Section title="Style">
227
+ <PresetRow
228
+ label="Border radius"
229
+ value={draft.borderRadius}
230
+ options={Object.keys(BORDER_RADIUS_PRESETS) as BorderRadiusPreset[]}
231
+ onChange={(v) => updateDraft({ borderRadius: v })}
232
+ />
233
+ <PresetRow
234
+ label="Animation"
235
+ value={draft.animationSpeed}
236
+ options={Object.keys(ANIMATION_SPEED_PRESETS) as AnimationSpeedPreset[]}
237
+ onChange={(v) => updateDraft({ animationSpeed: v })}
238
+ />
239
+ <PresetRow
240
+ label="Text shadow"
241
+ value={draft.textShadow}
242
+ options={Object.keys(TEXT_SHADOW_PRESETS) as TextShadowPreset[]}
243
+ onChange={(v) => updateDraft({ textShadow: v })}
244
+ />
245
+ <PresetRow
246
+ label="Contrast"
247
+ value={draft.contrast}
248
+ options={CONTRAST_PRESETS as readonly ContrastPreset[]}
249
+ onChange={(v) => updateDraft({ contrast: v })}
250
+ />
251
+ <PresetRow
252
+ label="Image grade"
253
+ value={draft.imageTreatment}
254
+ options={IMAGE_TREATMENT_PRESETS as readonly ImageTreatmentPreset[]}
255
+ onChange={(v) => updateDraft({ imageTreatment: v })}
256
+ />
257
+ </Section>
258
+ </div>
259
+
260
+ <div className="squisq-doc-settings-footer squisq-custom-theme-pane-footer">
261
+ <button
262
+ type="button"
263
+ className="squisq-doc-settings-btn squisq-doc-settings-btn--secondary"
264
+ onClick={onClose}
265
+ >
266
+ Cancel
267
+ </button>
268
+ <button
269
+ type="button"
270
+ className="squisq-doc-settings-btn squisq-doc-settings-btn--secondary"
271
+ onClick={() => handleSave('library')}
272
+ title="Save to your browser-local library so other docs can use it"
273
+ >
274
+ Save to library
275
+ </button>
276
+ <button
277
+ type="button"
278
+ className="squisq-doc-settings-btn squisq-doc-settings-btn--primary"
279
+ onClick={() => handleSave('doc')}
280
+ >
281
+ Save to document
282
+ </button>
283
+ </div>
284
+ </div>
285
+ );
286
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * CustomThemeContext — dual-catalog (doc + library) behavior, mirroring the
3
+ * custom-templates context: merged `allThemes`, doc/library upserts, and
4
+ * `applyTheme` copying a library theme into the doc for self-sufficiency.
5
+ */
6
+
7
+ // @vitest-environment jsdom
8
+
9
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
10
+ import { renderHook, act } from '@testing-library/react';
11
+ import type { ReactNode } from 'react';
12
+ import { CustomThemeProvider, useCustomThemes } from '../CustomThemeContext';
13
+ import { clearThemeLibrary, saveLibraryTheme } from '../customThemeLibrary';
14
+ import { compileTheme } from '@bendyline/squisq/schemas';
15
+ import type { Theme } from '@bendyline/squisq/schemas';
16
+
17
+ const t = (id: string, name = id): Theme =>
18
+ compileTheme({ id, name, seedColors: { primary: '#3182ce' } });
19
+
20
+ function makeWrapper(docThemes: Theme[], onDocThemesChange = vi.fn()) {
21
+ return ({ children }: { children: ReactNode }) => (
22
+ <CustomThemeProvider docThemes={docThemes} onDocThemesChange={onDocThemesChange}>
23
+ {children}
24
+ </CustomThemeProvider>
25
+ );
26
+ }
27
+
28
+ describe('CustomThemeContext', () => {
29
+ beforeEach(() => clearThemeLibrary());
30
+
31
+ it('allThemes merges doc + library, doc wins on id clash', () => {
32
+ saveLibraryTheme(t('lib', 'Library'));
33
+ saveLibraryTheme(t('shared', 'Library Shared'));
34
+ const { result } = renderHook(() => useCustomThemes(), {
35
+ wrapper: makeWrapper([t('shared', 'Doc Shared')]),
36
+ });
37
+ expect(result.current!.allThemes.map((x) => x.id).sort()).toEqual(['lib', 'shared']);
38
+ expect(result.current!.allThemes.find((x) => x.id === 'shared')!.name).toBe('Doc Shared');
39
+ });
40
+
41
+ it('upsertDocTheme calls onDocThemesChange with the appended list', () => {
42
+ const onChange = vi.fn();
43
+ const { result } = renderHook(() => useCustomThemes(), { wrapper: makeWrapper([], onChange) });
44
+ act(() => result.current!.upsertDocTheme(t('a')));
45
+ expect(onChange).toHaveBeenCalledWith([expect.objectContaining({ id: 'a' })]);
46
+ });
47
+
48
+ it('applyTheme copies a library theme into the doc', () => {
49
+ const onChange = vi.fn();
50
+ const { result } = renderHook(() => useCustomThemes(), { wrapper: makeWrapper([], onChange) });
51
+ act(() => result.current!.applyTheme(t('lib')));
52
+ expect(onChange).toHaveBeenCalledWith([expect.objectContaining({ id: 'lib' })]);
53
+ });
54
+
55
+ it('applyTheme is a no-op when the theme is already in the doc', () => {
56
+ const onChange = vi.fn();
57
+ const inDoc = t('a');
58
+ const { result } = renderHook(() => useCustomThemes(), {
59
+ wrapper: makeWrapper([inDoc], onChange),
60
+ });
61
+ act(() => result.current!.applyTheme(inDoc));
62
+ expect(onChange).not.toHaveBeenCalled();
63
+ });
64
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * CustomThemeDialog — the designer modal. Verifies live-preview `onChange`,
3
+ * the two save targets, and that adding an accent yields `colorSchemes`.
4
+ */
5
+
6
+ // @vitest-environment jsdom
7
+
8
+ import { describe, it, expect, vi } from 'vitest';
9
+ import { render, screen, fireEvent } from '@testing-library/react';
10
+ import { CustomThemeDialog } from '../CustomThemeDialog';
11
+
12
+ describe('CustomThemeDialog', () => {
13
+ it('edits the name, previews live, and saves a compiled theme to the document', () => {
14
+ const onSave = vi.fn();
15
+ const onChange = vi.fn();
16
+ render(
17
+ <CustomThemeDialog value={null} onChange={onChange} onSave={onSave} onClose={() => {}} />,
18
+ );
19
+
20
+ fireEvent.change(screen.getByLabelText('Theme name'), { target: { value: 'Brandy' } });
21
+ expect(onChange).toHaveBeenCalled(); // live preview fired
22
+
23
+ fireEvent.click(screen.getByText('Save to document'));
24
+ expect(onSave).toHaveBeenCalledTimes(1);
25
+ const [theme, target] = onSave.mock.calls[0];
26
+ expect(target).toBe('doc');
27
+ expect(theme.name).toBe('Brandy');
28
+ expect(theme.schemaVersion).toBe('1');
29
+ expect(theme.id).toMatch(/^custom-/);
30
+ });
31
+
32
+ it('saves to the library via the library button', () => {
33
+ const onSave = vi.fn();
34
+ render(<CustomThemeDialog value={null} onSave={onSave} onClose={() => {}} />);
35
+ fireEvent.click(screen.getByText('Save to library'));
36
+ expect(onSave.mock.calls[0][1]).toBe('library');
37
+ });
38
+
39
+ it('adding an accent produces colorSchemes in the compiled theme', () => {
40
+ const onSave = vi.fn();
41
+ render(<CustomThemeDialog value={null} onSave={onSave} onClose={() => {}} />);
42
+ fireEvent.click(screen.getByText('+ Add accent'));
43
+ fireEvent.click(screen.getByText('Save to document'));
44
+ const theme = onSave.mock.calls[0][0];
45
+ expect(Object.keys(theme.colorSchemes).length).toBeGreaterThan(0);
46
+ });
47
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * customThemeLibrary — localStorage-backed library of user-defined themes.
3
+ * The theme analog of customTemplates' library test. Uses jsdom's localStorage.
4
+ */
5
+
6
+ // @vitest-environment jsdom
7
+
8
+ import { describe, it, expect, beforeEach } from 'vitest';
9
+ import {
10
+ listLibraryThemes,
11
+ saveLibraryTheme,
12
+ deleteLibraryTheme,
13
+ clearThemeLibrary,
14
+ } from '../customThemeLibrary';
15
+ import { compileTheme } from '@bendyline/squisq/schemas';
16
+ import type { Theme } from '@bendyline/squisq/schemas';
17
+
18
+ function theme(id: string, name = id): Theme {
19
+ return compileTheme({ id, name, seedColors: { primary: '#3182ce' } });
20
+ }
21
+
22
+ describe('customThemeLibrary', () => {
23
+ beforeEach(() => clearThemeLibrary());
24
+
25
+ it('save → list round-trips, sorted by name', () => {
26
+ saveLibraryTheme(theme('b', 'Beta'));
27
+ saveLibraryTheme(theme('a', 'Alpha'));
28
+ expect(listLibraryThemes().map((t) => t.name)).toEqual(['Alpha', 'Beta']);
29
+ });
30
+
31
+ it('save replaces by id', () => {
32
+ saveLibraryTheme(theme('a', 'Alpha'));
33
+ saveLibraryTheme(theme('a', 'Alpha 2'));
34
+ const list = listLibraryThemes();
35
+ expect(list).toHaveLength(1);
36
+ expect(list[0].name).toBe('Alpha 2');
37
+ });
38
+
39
+ it('delete removes by id', () => {
40
+ saveLibraryTheme(theme('a'));
41
+ saveLibraryTheme(theme('b'));
42
+ deleteLibraryTheme('a');
43
+ expect(listLibraryThemes().map((t) => t.id)).toEqual(['b']);
44
+ });
45
+
46
+ it('clear empties the library', () => {
47
+ saveLibraryTheme(theme('a'));
48
+ clearThemeLibrary();
49
+ expect(listLibraryThemes()).toEqual([]);
50
+ });
51
+ });