@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.
- package/dist/index.d.ts +757 -20
- package/dist/index.js +16910 -6844
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/BlockCardView.tsx +121 -0
- package/src/BlockPreviewPanel.tsx +69 -0
- package/src/BlockPropertiesPopover.tsx +191 -0
- package/src/EditorContext.tsx +143 -0
- package/src/EditorShell.tsx +200 -120
- package/src/FolderView.tsx +131 -0
- package/src/Icon.tsx +26 -0
- package/src/ImageEditor.tsx +69 -22
- package/src/OutlinePanel.tsx +38 -3
- package/src/PlainHtmlPreview.tsx +30 -3
- package/src/PreviewControls.tsx +180 -8
- package/src/RawEditor.tsx +216 -29
- package/src/RecorderEntry.tsx +9 -16
- package/src/TemplateAnnotation.ts +44 -0
- package/src/TemplatePicker.tsx +329 -54
- package/src/ThemeCustomizerPanel.tsx +30 -336
- package/src/ThemePicker.tsx +112 -3
- package/src/TimelineBlockPreview.tsx +37 -0
- package/src/TimelineTrack.tsx +671 -0
- package/src/Toolbar.tsx +528 -174
- package/src/Tooltip.tsx +22 -4
- package/src/TransitionPicker.tsx +351 -0
- package/src/VersionHistoryPanel.tsx +61 -31
- package/src/ViewMenuPanel.tsx +17 -14
- package/src/WysiwygEditor.tsx +161 -65
- package/src/__tests__/blockProperties.test.ts +92 -0
- package/src/__tests__/blockRange.test.ts +105 -0
- package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
- package/src/__tests__/createShapeLayer.test.ts +46 -0
- package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
- package/src/__tests__/embeddedMedia.test.ts +48 -0
- package/src/__tests__/headingTransition.test.ts +138 -0
- package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
- package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
- package/src/__tests__/recorderMediaInsert.test.ts +86 -0
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
- package/src/__tests__/templatePickerMetadata.test.ts +32 -0
- package/src/__tests__/timelineSource.test.ts +134 -0
- package/src/__tests__/tiptapBridge.test.ts +92 -0
- package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
- package/src/__tests__/tooltip.test.tsx +72 -0
- package/src/__tests__/transitionCatalog.test.ts +64 -0
- package/src/__tests__/useBlockNavigator.test.tsx +67 -0
- package/src/__tests__/useMediaRecorder.test.ts +24 -0
- package/src/__tests__/useTimelineClock.test.ts +21 -0
- package/src/blockProperties.ts +88 -0
- package/src/blockRange.ts +132 -0
- package/src/buildPreviewDoc.ts +98 -9
- package/src/customTemplates/AddBin.tsx +126 -0
- package/src/customTemplates/CustomLayoutManager.tsx +233 -0
- package/src/customTemplates/CustomTemplateContext.tsx +182 -0
- package/src/customTemplates/LayerToolbar.tsx +580 -0
- package/src/customTemplates/ShapeGlyph.tsx +47 -0
- package/src/customTemplates/TemplateDesigner.tsx +430 -0
- package/src/customTemplates/__tests__/library.test.ts +88 -0
- package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
- package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
- package/src/customTemplates/designer.css +673 -0
- package/src/customTemplates/index.ts +31 -0
- package/src/customTemplates/library.ts +97 -0
- package/src/customTemplates/normalizePositions.ts +75 -0
- package/src/customTemplates/shapeDefs.ts +131 -0
- package/src/customTemplates/thumbnail.tsx +63 -0
- package/src/customTemplates/tokenDefs.ts +60 -0
- package/src/customTemplates/useDocCustomTemplates.ts +52 -0
- package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
- package/src/customThemes/CustomThemeContext.tsx +179 -0
- package/src/customThemes/CustomThemeDialog.tsx +286 -0
- package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
- package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
- package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
- package/src/customThemes/customThemeLibrary.ts +97 -0
- package/src/customThemes/index.ts +31 -0
- package/src/customThemes/themeControls.tsx +229 -0
- package/src/customThemes/themeDraft.ts +272 -0
- package/src/customThemes/useDocCustomThemes.ts +49 -0
- package/src/diagram/DiagramCanvas.tsx +240 -0
- package/src/diagram/DiagramExtension.ts +209 -0
- package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
- package/src/diagram/DiagramWidget.tsx +270 -0
- package/src/diagram/diagramCommands.ts +604 -0
- package/src/diagram/diagramConstants.ts +17 -0
- package/src/diagram/useDiagramData.ts +126 -0
- package/src/embeddedMedia.ts +78 -0
- package/src/frontmatter.ts +29 -0
- package/src/headingTransition.ts +231 -0
- package/src/imageEditor/CanvasSurface.tsx +383 -88
- package/src/imageEditor/PropertiesPanel.tsx +47 -1
- package/src/imageEditor/Toolbar.tsx +229 -16
- package/src/imageEditor/createShapeLayer.ts +280 -0
- package/src/imageEditor/icons.tsx +34 -114
- package/src/imageEditor/image-editor.css +54 -5
- package/src/imageEditor/state.ts +23 -3
- package/src/index.ts +77 -0
- package/src/recorder/RecorderModal.tsx +120 -53
- package/src/recorder/RecorderPanel.tsx +2 -26
- package/src/recorder/hooks/useMediaRecorder.ts +17 -2
- package/src/recorder/insertMediaBlock.ts +30 -0
- package/src/resolveBlockVisual.ts +33 -0
- package/src/scene/Scene.tsx +540 -0
- package/src/scene/SceneBlockExtension.ts +198 -0
- package/src/scene/SceneBlockToolbar.tsx +201 -0
- package/src/scene/SceneBlockWidget.tsx +434 -0
- package/src/scene/ScenePropsBar.tsx +85 -0
- package/src/scene/SceneSelection.tsx +107 -0
- package/src/scene/SceneViewport.tsx +102 -0
- package/src/scene/ShapePalette.tsx +181 -0
- package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
- package/src/scene/__tests__/bezierEdit.test.ts +85 -0
- package/src/scene/__tests__/blockLayers.test.ts +57 -0
- package/src/scene/__tests__/shapeLayers.test.ts +106 -0
- package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
- package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
- package/src/scene/adapters/DiagramAdapter.ts +168 -0
- package/src/scene/adapters/DrawingAdapter.ts +415 -0
- package/src/scene/adapters/LayoutAdapter.ts +310 -0
- package/src/scene/adapters/blockLayers.ts +159 -0
- package/src/scene/commands/SceneCommand.ts +70 -0
- package/src/scene/commands/drawingCommands.ts +318 -0
- package/src/scene/commands/layoutCommands.ts +301 -0
- package/src/scene/hooks/useSceneHitTest.ts +105 -0
- package/src/scene/hooks/useScenePanZoom.ts +147 -0
- package/src/scene/hooks/useSceneSelection.ts +62 -0
- package/src/scene/index.ts +95 -0
- package/src/scene/layers/DiagramEdges.tsx +127 -0
- package/src/scene/layers/edgeGeometry.ts +77 -0
- package/src/scene/layers/nodeCard.tsx +145 -0
- package/src/scene/layers/renderLayer.tsx +70 -0
- package/src/scene/layers/shapeLayers.ts +201 -0
- package/src/scene/paths/bezierEdit.ts +208 -0
- package/src/scene/scene.css +649 -0
- package/src/scene/text/SceneTextOverlay.tsx +161 -0
- package/src/scene/text/sceneTextChannel.ts +40 -0
- package/src/scene/text/sceneTextConfig.ts +27 -0
- package/src/scene/text/sceneTiptap.ts +36 -0
- package/src/scene/text/useSceneTextEditing.ts +39 -0
- package/src/scene/tools/ConnectTool.ts +111 -0
- package/src/scene/tools/DrawingConnectTool.ts +161 -0
- package/src/scene/tools/PathTool.ts +158 -0
- package/src/scene/tools/PlaceTool.ts +47 -0
- package/src/scene/tools/SceneTool.ts +75 -0
- package/src/scene/tools/SelectTool.ts +284 -0
- package/src/scene/tools/ShapeTool.ts +144 -0
- package/src/scene/tools/TextTool.ts +72 -0
- package/src/scene/tools/TokenTool.ts +95 -0
- package/src/scene/tools/createDrawShapeTool.ts +82 -0
- package/src/styles/diagram.css +183 -0
- package/src/styles/editor.css +1615 -203
- package/src/styles/folder-view.css +210 -0
- package/src/styles/image-edit-affordance.css +2 -2
- package/src/styles/index.css +4 -0
- package/src/timelineSource.ts +244 -0
- package/src/tiptapBridge.ts +115 -34
- package/src/tooltipPlacement.ts +13 -0
- package/src/transitionCatalog.ts +159 -0
- package/src/types/monaco-shims.d.ts +10 -0
- package/src/useBlockNavigator.ts +153 -0
- package/src/useMonacoLoader.ts +105 -0
- package/src/useTimelineClock.ts +76 -0
- 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
|
+
×
|
|
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
|
+
});
|