@bendyline/squisq-editor-react 1.5.3 → 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 +16875 -6883
- 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 +169 -13
- 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 +2 -14
- 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 +8 -1
- 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 +23 -1
- package/src/useTimelineClock.ts +76 -0
- package/src/utils/dropUtils.ts +1 -1
package/src/buildPreviewDoc.ts
CHANGED
|
@@ -11,19 +11,40 @@
|
|
|
11
11
|
* 4. Synthesize a dummy audio segment for timer-based playback
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
15
|
-
import { extractPlainText } from '@bendyline/squisq/markdown';
|
|
14
|
+
import { flattenRenderableBlocks, hasTemplate } from '@bendyline/squisq/doc';
|
|
15
|
+
import { extractPlainText, KNOWN_BLOCK_META_KEYS } from '@bendyline/squisq/markdown';
|
|
16
16
|
import { getChildren } from '@bendyline/squisq/markdown';
|
|
17
|
+
import { iconMarker } from '@bendyline/squisq/icon-marker';
|
|
18
|
+
import type { IconFamily } from '@bendyline/squisq/icons';
|
|
17
19
|
import type { Block, Doc } from '@bendyline/squisq/schemas';
|
|
18
20
|
import type { MarkdownBlockNode, MarkdownList, MarkdownNode } from '@bendyline/squisq/markdown';
|
|
19
21
|
|
|
20
22
|
// ── Helpers ────────────────────────────────────────────────────────
|
|
21
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Like `extractPlainText`, but preserves inline icons as encoded markers so
|
|
26
|
+
* template text can render them as glyphs downstream (see `iconMarker` and
|
|
27
|
+
* `TextLayer`). Mirrors `extractPlainText`'s value/child/list handling; the
|
|
28
|
+
* only addition is the `inlineIcon` interception.
|
|
29
|
+
*/
|
|
30
|
+
function extractRichText(node: MarkdownNode): string {
|
|
31
|
+
if (node.type === 'inlineIcon') {
|
|
32
|
+
const icon = node as unknown as { family: IconFamily; name: string };
|
|
33
|
+
return iconMarker(icon.family, icon.name);
|
|
34
|
+
}
|
|
35
|
+
if ('value' in node && typeof (node as { value?: unknown }).value === 'string') {
|
|
36
|
+
return (node as { value: string }).value;
|
|
37
|
+
}
|
|
38
|
+
const children = getChildren(node);
|
|
39
|
+
const separator = node.type === 'list' || node.type === 'listItem' ? '\n' : '';
|
|
40
|
+
return children.map(extractRichText).join(separator);
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
function extractBodyText(contents: MarkdownBlockNode[] | undefined): string {
|
|
23
44
|
if (!contents || contents.length === 0) return '';
|
|
24
45
|
const parts: string[] = [];
|
|
25
46
|
for (const node of contents) {
|
|
26
|
-
parts.push(
|
|
47
|
+
parts.push(extractRichText(node));
|
|
27
48
|
}
|
|
28
49
|
return parts.join('\n').trim();
|
|
29
50
|
}
|
|
@@ -174,13 +195,23 @@ function getTemplateDefaults(
|
|
|
174
195
|
}
|
|
175
196
|
}
|
|
176
197
|
|
|
177
|
-
function blockToSlide(
|
|
198
|
+
function blockToSlide(
|
|
199
|
+
block: Block,
|
|
200
|
+
index: number,
|
|
201
|
+
knownTemplates?: ReadonlySet<string>,
|
|
202
|
+
): Record<string, unknown> {
|
|
178
203
|
const headingText = block.sourceHeading
|
|
179
204
|
? extractPlainText(block.sourceHeading)
|
|
180
205
|
: block.title || block.id || `Slide ${index + 1}`;
|
|
181
206
|
|
|
182
207
|
const requestedTemplate = block.template || 'sectionHeader';
|
|
183
|
-
|
|
208
|
+
// A template is recognized if it's a built-in OR a user-defined
|
|
209
|
+
// template carried in the doc's `customTemplates` set. Without this,
|
|
210
|
+
// an annotated `{[hero]}` heading would silently fall back to
|
|
211
|
+
// `sectionHeader` because `hasTemplate` only knows built-ins.
|
|
212
|
+
const isCustomTemplate = knownTemplates?.has(requestedTemplate) ?? false;
|
|
213
|
+
const recognized = hasTemplate(requestedTemplate) || isCustomTemplate;
|
|
214
|
+
const template = recognized ? requestedTemplate : 'sectionHeader';
|
|
184
215
|
const defaults = getTemplateDefaults(template, headingText, block);
|
|
185
216
|
|
|
186
217
|
const {
|
|
@@ -196,6 +227,7 @@ function blockToSlide(block: Block, index: number): Record<string, unknown> {
|
|
|
196
227
|
contents: _co,
|
|
197
228
|
sourceHeading: _sh,
|
|
198
229
|
templateOverrides: _to,
|
|
230
|
+
templateData: _td,
|
|
199
231
|
...extraFields
|
|
200
232
|
} = block as unknown as Record<string, unknown>;
|
|
201
233
|
|
|
@@ -204,14 +236,59 @@ function blockToSlide(block: Block, index: number): Record<string, unknown> {
|
|
|
204
236
|
template,
|
|
205
237
|
duration: block.duration,
|
|
206
238
|
audioSegment: 0,
|
|
207
|
-
|
|
239
|
+
// Respect the block's authored transition (set via the toolbar / on-canvas
|
|
240
|
+
// properties palette → `{…}` block attrs). Only fall back to a default fade
|
|
241
|
+
// for blocks past the first when the author hasn't chosen one; the first
|
|
242
|
+
// block has no previous slide to transition in from.
|
|
243
|
+
transition: block.transition ?? (index > 0 ? { type: 'fade', duration: 0.5 } : undefined),
|
|
208
244
|
title: headingText,
|
|
245
|
+
// Custom templates need access to the source block's body content
|
|
246
|
+
// + children so their token resolver (`{content}`, `{children}`,
|
|
247
|
+
// `{image:N}`) substitutes against the user's prose, not just the
|
|
248
|
+
// heading. Built-in templates don't read these fields and risk
|
|
249
|
+
// surprising overlap with their typed inputs, so we only attach
|
|
250
|
+
// them when the slide actually maps to a custom template.
|
|
251
|
+
...(isCustomTemplate && block.contents ? { contents: block.contents } : {}),
|
|
252
|
+
...(isCustomTemplate && block.children ? { children: block.children } : {}),
|
|
209
253
|
...defaults,
|
|
210
254
|
...extraFields,
|
|
211
|
-
|
|
255
|
+
// Structured body data (```json data fences, GFM tables for dataTable)
|
|
256
|
+
// carries typed values; `{[…]}` string overrides win last so an explicit
|
|
257
|
+
// annotation param can still pin any field.
|
|
258
|
+
//
|
|
259
|
+
// Block-meta keys (transition, startTime, duration, …) are the exception:
|
|
260
|
+
// they were already coerced to typed block fields above (e.g.
|
|
261
|
+
// `block.transition` → `{ type, duration, direction }`). Their raw string
|
|
262
|
+
// form also rides along in `templateData`/`templateOverrides` because the
|
|
263
|
+
// author wrote them inside `{[…]}`; left un-stripped, that string would
|
|
264
|
+
// spread back over the typed value here and clobber it — turning
|
|
265
|
+
// `transition=vortex` into the string `"vortex"`, which the player can't
|
|
266
|
+
// animate. Omit them from the content spreads so the typed fields win.
|
|
267
|
+
...omitBlockMeta(block.templateData),
|
|
268
|
+
...omitBlockMeta(block.templateOverrides),
|
|
212
269
|
};
|
|
213
270
|
}
|
|
214
271
|
|
|
272
|
+
/** Keys coerced to typed block fields; must not be re-applied as raw strings. */
|
|
273
|
+
const BLOCK_META_KEYS: ReadonlySet<string> = new Set(Object.keys(KNOWN_BLOCK_META_KEYS));
|
|
274
|
+
|
|
275
|
+
/** Copy of a template-param record with block-meta keys removed. */
|
|
276
|
+
function omitBlockMeta(
|
|
277
|
+
data: Record<string, unknown> | undefined,
|
|
278
|
+
): Record<string, unknown> | undefined {
|
|
279
|
+
if (!data) return data;
|
|
280
|
+
let hit = false;
|
|
281
|
+
const out: Record<string, unknown> = {};
|
|
282
|
+
for (const key of Object.keys(data)) {
|
|
283
|
+
if (BLOCK_META_KEYS.has(key)) {
|
|
284
|
+
hit = true;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
out[key] = data[key];
|
|
288
|
+
}
|
|
289
|
+
return hit ? out : data;
|
|
290
|
+
}
|
|
291
|
+
|
|
215
292
|
const IMAGE_MOTIONS: Array<'zoomIn' | 'zoomOut' | 'panLeft' | 'panRight'> = [
|
|
216
293
|
'zoomIn',
|
|
217
294
|
'zoomOut',
|
|
@@ -229,9 +306,18 @@ const IMAGE_MOTIONS: Array<'zoomIn' | 'zoomOut' | 'panLeft' | 'panRight'> = [
|
|
|
229
306
|
* audio segment.
|
|
230
307
|
*/
|
|
231
308
|
export function buildPreviewDoc(doc: Doc): Doc {
|
|
232
|
-
|
|
309
|
+
// Container templates (`diagram`, `drawing`) render their children as
|
|
310
|
+
// nodes/shapes, so those children must not also become preview slides.
|
|
311
|
+
const flat = flattenRenderableBlocks(doc.blocks);
|
|
233
312
|
const allImages = collectAllDocImages(doc.blocks);
|
|
234
313
|
const usedImageSrcs = new Set<string>();
|
|
314
|
+
// Names of user-defined templates carried by the doc — passed into
|
|
315
|
+
// `blockToSlide` so heading annotations like `{[hero]}` aren't
|
|
316
|
+
// silently downgraded to `sectionHeader` when the template doesn't
|
|
317
|
+
// exist in the built-in registry.
|
|
318
|
+
const knownTemplates = doc.customTemplates
|
|
319
|
+
? new Set(doc.customTemplates.map((d) => d.name))
|
|
320
|
+
: undefined;
|
|
235
321
|
|
|
236
322
|
const slides: Record<string, unknown>[] = [];
|
|
237
323
|
let motionIndex = 0;
|
|
@@ -239,7 +325,7 @@ export function buildPreviewDoc(doc: Doc): Doc {
|
|
|
239
325
|
for (let i = 0; i < flat.length; i++) {
|
|
240
326
|
const block = flat[i];
|
|
241
327
|
const blockImages = extractBlockImages(block.contents);
|
|
242
|
-
const slide = blockToSlide(block, i);
|
|
328
|
+
const slide = blockToSlide(block, i, knownTemplates);
|
|
243
329
|
|
|
244
330
|
if (blockImages.length > 0 && slide.template === 'sectionHeader') {
|
|
245
331
|
const img = blockImages[0];
|
|
@@ -305,5 +391,8 @@ export function buildPreviewDoc(doc: Doc): Doc {
|
|
|
305
391
|
...(doc.captions ? { captions: doc.captions } : {}),
|
|
306
392
|
...(doc.startBlock ? { startBlock: doc.startBlock } : {}),
|
|
307
393
|
...(doc.themeId ? { themeId: doc.themeId } : {}),
|
|
394
|
+
// Custom templates ride along so `useDocPlayback` can merge them
|
|
395
|
+
// onto the registry before expanding slides.
|
|
396
|
+
...(doc.customTemplates ? { customTemplates: doc.customTemplates } : {}),
|
|
308
397
|
};
|
|
309
398
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AddBin — the template designer's "things you can add" sidebar.
|
|
3
|
+
*
|
|
4
|
+
* Three sections, all feeding the same canvas:
|
|
5
|
+
* - Placeholders: dynamic tokens ({title}, {content}, …) substituted at
|
|
6
|
+
* render time. See {@link TOKEN_DEFS}.
|
|
7
|
+
* - Shapes: Squisq's standard shape library. See {@link SHAPE_DEFS}.
|
|
8
|
+
* - Media: drop an image to pin as a static, full-bleed background.
|
|
9
|
+
*
|
|
10
|
+
* Tokens and shapes can be placed two ways — drag onto the canvas, or
|
|
11
|
+
* click to arm the matching tool and then click the canvas. The drag
|
|
12
|
+
* carries the entry id under {@link TOKEN_DRAG_MIME} / {@link SHAPE_DRAG_MIME};
|
|
13
|
+
* the TemplateDesigner's drop handler reads it (see `buildTokenLayer` /
|
|
14
|
+
* `buildShapeLayer`). Media is upload-backed and handled by the designer.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useRef } from 'react';
|
|
18
|
+
import { TOKEN_DEFS, TOKEN_DRAG_MIME } from './tokenDefs';
|
|
19
|
+
import { SHAPE_DEFS, SHAPE_DRAG_MIME } from './shapeDefs';
|
|
20
|
+
import { ShapeGlyph } from './ShapeGlyph';
|
|
21
|
+
|
|
22
|
+
interface AddBinProps {
|
|
23
|
+
/** Currently active tool id — the matching button is highlighted. */
|
|
24
|
+
activeToolId: string;
|
|
25
|
+
/** Switch the Scene's active tool (a token or shape place tool). */
|
|
26
|
+
onActivate: (toolId: string) => void;
|
|
27
|
+
/** Whether media can be added (a MediaProvider is wired up). */
|
|
28
|
+
canAddMedia: boolean;
|
|
29
|
+
/** Add picked image files as static background layer(s). */
|
|
30
|
+
onAddMediaFiles: (files: File[]) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function AddBin({ activeToolId, onActivate, canAddMedia, onAddMediaFiles }: AddBinProps) {
|
|
34
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<aside className="squisq-template-designer-palette" aria-label="Add to layout">
|
|
38
|
+
<h3 className="squisq-template-designer-palette-title">Add</h3>
|
|
39
|
+
|
|
40
|
+
{/* Placeholders — dynamic tokens substituted at render time. */}
|
|
41
|
+
<div className="squisq-template-designer-palette-section">
|
|
42
|
+
<div className="squisq-template-designer-palette-section-title">Placeholders</div>
|
|
43
|
+
<div className="squisq-template-designer-palette-list">
|
|
44
|
+
{TOKEN_DEFS.map((t) => (
|
|
45
|
+
<button
|
|
46
|
+
key={t.id}
|
|
47
|
+
type="button"
|
|
48
|
+
draggable
|
|
49
|
+
className={`squisq-template-designer-palette-item${
|
|
50
|
+
activeToolId === t.id ? ' squisq-template-designer-palette-item--active' : ''
|
|
51
|
+
}`}
|
|
52
|
+
onDragStart={(e) => {
|
|
53
|
+
e.dataTransfer.setData(TOKEN_DRAG_MIME, t.id);
|
|
54
|
+
// Some browsers require text/plain for the drag to start.
|
|
55
|
+
e.dataTransfer.setData('text/plain', t.token);
|
|
56
|
+
e.dataTransfer.effectAllowed = 'copy';
|
|
57
|
+
}}
|
|
58
|
+
onClick={() => onActivate(t.id)}
|
|
59
|
+
title={t.desc}
|
|
60
|
+
>
|
|
61
|
+
<span className="squisq-template-designer-palette-item-preview">{t.token}</span>
|
|
62
|
+
<span className="squisq-template-designer-palette-item-label">{t.label}</span>
|
|
63
|
+
</button>
|
|
64
|
+
))}
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{/* Shapes — the standard shape library. */}
|
|
69
|
+
<div className="squisq-template-designer-palette-section">
|
|
70
|
+
<div className="squisq-template-designer-palette-section-title">Shapes</div>
|
|
71
|
+
<div className="squisq-template-designer-palette-shapes">
|
|
72
|
+
{SHAPE_DEFS.map((s) => (
|
|
73
|
+
<button
|
|
74
|
+
key={s.id}
|
|
75
|
+
type="button"
|
|
76
|
+
draggable
|
|
77
|
+
className={`squisq-template-designer-shape-item${
|
|
78
|
+
activeToolId === s.id ? ' squisq-template-designer-shape-item--active' : ''
|
|
79
|
+
}`}
|
|
80
|
+
onDragStart={(e) => {
|
|
81
|
+
e.dataTransfer.setData(SHAPE_DRAG_MIME, s.id);
|
|
82
|
+
e.dataTransfer.setData('text/plain', s.label);
|
|
83
|
+
e.dataTransfer.effectAllowed = 'copy';
|
|
84
|
+
}}
|
|
85
|
+
onClick={() => onActivate(s.id)}
|
|
86
|
+
title={s.label}
|
|
87
|
+
aria-label={s.label}
|
|
88
|
+
>
|
|
89
|
+
<ShapeGlyph kind={s.kind} rounded={s.rounded} />
|
|
90
|
+
</button>
|
|
91
|
+
))}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
{/* Media — drop an image to pin a static background. */}
|
|
96
|
+
<div className="squisq-template-designer-palette-section">
|
|
97
|
+
<div className="squisq-template-designer-palette-section-title">Media</div>
|
|
98
|
+
<p className="squisq-template-designer-palette-hint">
|
|
99
|
+
{canAddMedia
|
|
100
|
+
? 'Drop an image on the canvas to add a full-bleed background.'
|
|
101
|
+
: 'Connect media storage to add image backgrounds.'}
|
|
102
|
+
</p>
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
className="squisq-template-designer-palette-media-add"
|
|
106
|
+
disabled={!canAddMedia}
|
|
107
|
+
onClick={() => fileInputRef.current?.click()}
|
|
108
|
+
>
|
|
109
|
+
Add image…
|
|
110
|
+
</button>
|
|
111
|
+
<input
|
|
112
|
+
ref={fileInputRef}
|
|
113
|
+
type="file"
|
|
114
|
+
accept="image/*"
|
|
115
|
+
multiple
|
|
116
|
+
style={{ display: 'none' }}
|
|
117
|
+
onChange={(e) => {
|
|
118
|
+
const files = Array.from(e.target.files ?? []);
|
|
119
|
+
if (files.length) onAddMediaFiles(files);
|
|
120
|
+
e.target.value = ''; // allow re-selecting the same file
|
|
121
|
+
}}
|
|
122
|
+
/>
|
|
123
|
+
</div>
|
|
124
|
+
</aside>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CustomLayoutManager — a near-full-window modal for managing custom
|
|
3
|
+
* layouts (templates). The left rail lists every layout available to the
|
|
4
|
+
* document — those inlined in the doc and those saved to the browser
|
|
5
|
+
* library — plus a "New layout" button. The right pane embeds the
|
|
6
|
+
* existing {@link TemplateDesigner} (in `embedded` mode) so the selected
|
|
7
|
+
* layout can be authored in place.
|
|
8
|
+
*
|
|
9
|
+
* The manager seeds its own {@link CustomTemplateProvider} from the
|
|
10
|
+
* active doc (via {@link useDocCustomTemplates}) so it works whether or
|
|
11
|
+
* not the host already mounted a provider — the toolbar that opens it
|
|
12
|
+
* sits outside the WYSIWYG editor's provider.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
16
|
+
import { createPortal } from 'react-dom';
|
|
17
|
+
import type { CustomTemplateDefinition, MediaProvider } from '@bendyline/squisq/schemas';
|
|
18
|
+
import {
|
|
19
|
+
CustomTemplateProvider,
|
|
20
|
+
useCustomTemplates,
|
|
21
|
+
type CustomTemplateContextValue,
|
|
22
|
+
} from './CustomTemplateContext';
|
|
23
|
+
import { TemplateDesigner, type DesignerSaveTarget } from './TemplateDesigner';
|
|
24
|
+
import { TemplateThumbnail } from './thumbnail';
|
|
25
|
+
import { useDocCustomTemplates } from './useDocCustomTemplates';
|
|
26
|
+
import { useEditorContext } from '../EditorContext';
|
|
27
|
+
|
|
28
|
+
export interface CustomLayoutManagerProps {
|
|
29
|
+
/** Close the manager. */
|
|
30
|
+
onClose: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A picked sidebar entry: `'new'` for the blank-slate designer, or an
|
|
35
|
+
* existing template tagged by which pool it came from.
|
|
36
|
+
*
|
|
37
|
+
* `def` carries the definition directly for the just-saved case: the doc
|
|
38
|
+
* re-parses on a 150ms debounce (see EditorContext), so immediately after
|
|
39
|
+
* a save the doc-template list is briefly stale. Holding the saved
|
|
40
|
+
* definition here lets the designer re-seed from it without waiting for —
|
|
41
|
+
* or racing — that re-parse. User-initiated selections omit `def` and
|
|
42
|
+
* resolve from the (by-then fresh) list.
|
|
43
|
+
*/
|
|
44
|
+
type Selection =
|
|
45
|
+
| 'new'
|
|
46
|
+
| { source: 'doc' | 'library'; name: string; def?: CustomTemplateDefinition };
|
|
47
|
+
|
|
48
|
+
export function CustomLayoutManager({ onClose }: CustomLayoutManagerProps) {
|
|
49
|
+
const { docTemplates, onDocTemplatesChange } = useDocCustomTemplates();
|
|
50
|
+
const { mediaProvider } = useEditorContext();
|
|
51
|
+
|
|
52
|
+
// Close on Escape, mirroring the other editor dialogs.
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const onKey = (e: KeyboardEvent) => {
|
|
55
|
+
if (e.key === 'Escape') onClose();
|
|
56
|
+
};
|
|
57
|
+
window.addEventListener('keydown', onKey);
|
|
58
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
59
|
+
}, [onClose]);
|
|
60
|
+
|
|
61
|
+
return createPortal(
|
|
62
|
+
<div
|
|
63
|
+
className="squisq-layout-manager-overlay"
|
|
64
|
+
role="dialog"
|
|
65
|
+
aria-modal="true"
|
|
66
|
+
aria-label="Custom layouts"
|
|
67
|
+
onClick={(e) => {
|
|
68
|
+
if (e.target === e.currentTarget) onClose();
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
<div className="squisq-layout-manager-panel">
|
|
72
|
+
<header className="squisq-layout-manager-header">
|
|
73
|
+
<h2 className="squisq-layout-manager-title">Custom layouts</h2>
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
className="squisq-template-designer-close"
|
|
77
|
+
onClick={onClose}
|
|
78
|
+
aria-label="Close custom layouts"
|
|
79
|
+
title="Close (Esc)"
|
|
80
|
+
>
|
|
81
|
+
×
|
|
82
|
+
</button>
|
|
83
|
+
</header>
|
|
84
|
+
<CustomTemplateProvider
|
|
85
|
+
docTemplates={docTemplates}
|
|
86
|
+
onDocTemplatesChange={onDocTemplatesChange}
|
|
87
|
+
>
|
|
88
|
+
<ManagerBody mediaProvider={mediaProvider} />
|
|
89
|
+
</CustomTemplateProvider>
|
|
90
|
+
</div>
|
|
91
|
+
</div>,
|
|
92
|
+
document.body,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Inner body — runs under the provider so it can read/write both pools. */
|
|
97
|
+
function ManagerBody({ mediaProvider }: { mediaProvider: MediaProvider | null }) {
|
|
98
|
+
const ctx = useCustomTemplates();
|
|
99
|
+
// The provider is mounted just above by CustomLayoutManager, so this is
|
|
100
|
+
// never null in practice; narrow defensively rather than asserting.
|
|
101
|
+
if (!ctx) return null;
|
|
102
|
+
return <ManagerContent ctx={ctx} mediaProvider={mediaProvider} />;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function ManagerContent({
|
|
106
|
+
ctx,
|
|
107
|
+
mediaProvider,
|
|
108
|
+
}: {
|
|
109
|
+
ctx: CustomTemplateContextValue;
|
|
110
|
+
mediaProvider: MediaProvider | null;
|
|
111
|
+
}) {
|
|
112
|
+
const { docTemplates, libraryTemplates, upsertDocTemplate, upsertLibraryTemplate } = ctx;
|
|
113
|
+
|
|
114
|
+
// The doc and library pools are listed in full and kept separate — a
|
|
115
|
+
// layout can legitimately exist in both (the doc's inlined copy and a
|
|
116
|
+
// reusable library copy), so saving a doc layout "to library" produces
|
|
117
|
+
// a visible second entry rather than silently merging into the doc one.
|
|
118
|
+
|
|
119
|
+
// Default selection: first doc layout, else first library layout, else
|
|
120
|
+
// the blank-slate "new" designer.
|
|
121
|
+
const [selection, setSelection] = useState<Selection>(() => {
|
|
122
|
+
if (docTemplates[0]) return { source: 'doc', name: docTemplates[0].name };
|
|
123
|
+
if (libraryTemplates[0]) return { source: 'library', name: libraryTemplates[0].name };
|
|
124
|
+
return 'new';
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Resolve the selected definition (undefined for the "new" slate). A
|
|
128
|
+
// `def` carried on the selection wins over the list lookup so a freshly
|
|
129
|
+
// saved layout shows immediately, before the doc re-parse lands.
|
|
130
|
+
const selected: CustomTemplateDefinition | undefined =
|
|
131
|
+
selection === 'new'
|
|
132
|
+
? undefined
|
|
133
|
+
: (selection.def ??
|
|
134
|
+
(selection.source === 'doc' ? docTemplates : libraryTemplates).find(
|
|
135
|
+
(t) => t.name === selection.name,
|
|
136
|
+
));
|
|
137
|
+
|
|
138
|
+
const handleSave = useCallback(
|
|
139
|
+
(def: CustomTemplateDefinition, target: DesignerSaveTarget) => {
|
|
140
|
+
if (target === 'doc') upsertDocTemplate(def);
|
|
141
|
+
else upsertLibraryTemplate(def);
|
|
142
|
+
// After saving, select the just-saved layout so the list highlights
|
|
143
|
+
// it and further edits target the same entry (rather than the blank
|
|
144
|
+
// "new" slate). Carry the saved def so the designer re-seeds from it
|
|
145
|
+
// even while the doc re-parse (debounced) is still in flight.
|
|
146
|
+
setSelection({ source: target === 'doc' ? 'doc' : 'library', name: def.name, def });
|
|
147
|
+
},
|
|
148
|
+
[upsertDocTemplate, upsertLibraryTemplate],
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const isActive = (source: 'doc' | 'library', name: string) =>
|
|
152
|
+
selection !== 'new' && selection.source === source && selection.name === name;
|
|
153
|
+
|
|
154
|
+
const renderItem = (def: CustomTemplateDefinition, source: 'doc' | 'library') => (
|
|
155
|
+
<button
|
|
156
|
+
key={`${source}:${def.name}`}
|
|
157
|
+
type="button"
|
|
158
|
+
className={`squisq-layout-manager-item${
|
|
159
|
+
isActive(source, def.name) ? ' squisq-layout-manager-item--active' : ''
|
|
160
|
+
}`}
|
|
161
|
+
onClick={() => setSelection({ source, name: def.name })}
|
|
162
|
+
>
|
|
163
|
+
<span className="squisq-layout-manager-item-thumb">
|
|
164
|
+
<TemplateThumbnail def={def} />
|
|
165
|
+
</span>
|
|
166
|
+
<span className="squisq-layout-manager-item-text">
|
|
167
|
+
<span className="squisq-layout-manager-item-label">{def.label || def.name}</span>
|
|
168
|
+
<span className="squisq-layout-manager-item-name">{def.name}</span>
|
|
169
|
+
</span>
|
|
170
|
+
</button>
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<div className="squisq-layout-manager-body">
|
|
175
|
+
<aside className="squisq-layout-manager-sidebar">
|
|
176
|
+
<button
|
|
177
|
+
type="button"
|
|
178
|
+
className={`squisq-layout-manager-new${
|
|
179
|
+
selection === 'new' ? ' squisq-layout-manager-new--active' : ''
|
|
180
|
+
}`}
|
|
181
|
+
onClick={() => setSelection('new')}
|
|
182
|
+
>
|
|
183
|
+
+ New layout
|
|
184
|
+
</button>
|
|
185
|
+
|
|
186
|
+
{docTemplates.length > 0 && (
|
|
187
|
+
<>
|
|
188
|
+
<div className="squisq-layout-manager-section-title">This document</div>
|
|
189
|
+
<div className="squisq-layout-manager-list">
|
|
190
|
+
{docTemplates.map((def) => renderItem(def, 'doc'))}
|
|
191
|
+
</div>
|
|
192
|
+
</>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{libraryTemplates.length > 0 && (
|
|
196
|
+
<>
|
|
197
|
+
<div className="squisq-layout-manager-section-title">Library</div>
|
|
198
|
+
<div className="squisq-layout-manager-list">
|
|
199
|
+
{libraryTemplates.map((def) => renderItem(def, 'library'))}
|
|
200
|
+
</div>
|
|
201
|
+
</>
|
|
202
|
+
)}
|
|
203
|
+
|
|
204
|
+
{docTemplates.length === 0 && libraryTemplates.length === 0 && (
|
|
205
|
+
<p className="squisq-layout-manager-empty-hint">
|
|
206
|
+
No custom layouts yet. Design one on the right and save it to this document or your
|
|
207
|
+
library.
|
|
208
|
+
</p>
|
|
209
|
+
)}
|
|
210
|
+
</aside>
|
|
211
|
+
|
|
212
|
+
<div className="squisq-layout-manager-main">
|
|
213
|
+
<TemplateDesigner
|
|
214
|
+
// Remount when the selection changes so the designer re-seeds
|
|
215
|
+
// its internal state from the newly chosen definition.
|
|
216
|
+
key={selection === 'new' ? 'new' : `${selection.source}:${selection.name}`}
|
|
217
|
+
embedded
|
|
218
|
+
initial={selected}
|
|
219
|
+
mediaProvider={mediaProvider}
|
|
220
|
+
// A layout already in the doc just "Save"s; a new or library
|
|
221
|
+
// layout reads "Save to this doc" since saving adds it.
|
|
222
|
+
primarySaveLabel={
|
|
223
|
+
selection !== 'new' && selection.source === 'doc' ? 'Save' : 'Save to this doc'
|
|
224
|
+
}
|
|
225
|
+
onSave={handleSave}
|
|
226
|
+
onClose={() => {
|
|
227
|
+
/* embedded: the manager frame owns dismissal */
|
|
228
|
+
}}
|
|
229
|
+
/>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
233
|
+
}
|