@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.
- package/README.md +57 -10
- package/dist/index.d.ts +1114 -52
- package/dist/index.js +18446 -8050
- package/dist/index.js.map +1 -1
- package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
- package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
- package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
- package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
- package/dist/styles/index.css +13867 -0
- package/package.json +15 -7
- package/src/BlockCardView.tsx +121 -0
- package/src/BlockPreviewPanel.tsx +69 -0
- package/src/BlockPropertiesPopover.tsx +191 -0
- package/src/EditorContext.tsx +165 -16
- package/src/EditorShell.tsx +265 -144
- package/src/FolderView.tsx +131 -0
- package/src/Icon.tsx +26 -0
- package/src/ImageEditor.tsx +69 -22
- package/src/OutlinePanel.tsx +64 -7
- package/src/PlainHtmlPreview.tsx +30 -3
- package/src/PreviewControls.tsx +491 -122
- package/src/PreviewPanel.tsx +15 -9
- package/src/RawEditor.tsx +179 -17
- 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 +544 -180
- package/src/Tooltip.tsx +22 -4
- package/src/TransitionPicker.tsx +351 -0
- package/src/VersionHistoryPanel.tsx +4 -16
- 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__/codeContextSectionView.test.tsx +95 -0
- package/src/__tests__/codeContextZoneManager.test.ts +127 -0
- package/src/__tests__/createShapeLayer.test.ts +46 -0
- package/src/__tests__/diffContextSections.test.ts +39 -0
- package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
- package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
- package/src/__tests__/editorShellProps.test.tsx +96 -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__/previewControls.test.tsx +70 -0
- 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__/useJsonEditorTokens.test.ts +59 -0
- package/src/__tests__/useMediaRecorder.test.ts +41 -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/codeContext/CodeContextSectionView.tsx +124 -0
- package/src/codeContext/CodeContextZoneManager.ts +149 -0
- package/src/codeContext/CodeContextZones.tsx +121 -0
- package/src/codeContext/diffContextSections.ts +38 -0
- package/src/codeContext/types.ts +75 -0
- 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 +109 -1
- package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
- package/src/recorder/RecorderModal.tsx +120 -53
- package/src/recorder/RecorderPanel.tsx +2 -26
- package/src/recorder/hooks/useMediaRecorder.ts +17 -11
- 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/code-context.css +155 -0
- package/src/styles/diagram.css +183 -0
- package/src/styles/editor.css +1708 -150
- package/src/styles/folder-view.css +210 -0
- package/src/styles/image-edit-affordance.css +2 -2
- package/src/styles/index.css +5 -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 +23 -1
- package/src/useTimelineClock.ts +76 -0
- 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
|
-
* - **
|
|
18
|
-
*
|
|
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,
|
|
24
|
+
import type { Theme, ThemeSeedColors } from '@bendyline/squisq/schemas';
|
|
25
|
+
import { deriveScale, isHex, serializeTheme } from '@bendyline/squisq/schemas';
|
|
23
26
|
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
}
|
package/src/ThemePicker.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
});
|