@bendyline/squisq-react 1.3.2 → 1.4.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 +63 -7
- package/dist/index.js +1171 -666
- package/dist/index.js.map +1 -1
- package/dist/squisq-player.css +1 -1
- package/dist/squisq-player.css.map +1 -1
- package/dist/squisq-player.global.js +17 -13
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/package.json +3 -2
- package/src/BlockRenderer.tsx +15 -7
- package/src/DocPlayer.tsx +65 -12
- package/src/DocProgressBar.tsx +21 -3
- package/src/LinearDocView.tsx +11 -197
- package/src/MarkdownRenderer.tsx +165 -41
- package/src/MediaClipLayer.tsx +135 -0
- package/src/__tests__/DocPlayer.test.tsx +51 -0
- package/src/__tests__/DocProgressBar.test.tsx +76 -0
- package/src/__tests__/MarkdownRenderer.test.tsx +95 -1
- package/src/__tests__/PathLayer.test.tsx +73 -0
- package/src/__tests__/fillStyle.test.tsx +112 -0
- package/src/__tests__/transitionStyles.test.ts +125 -0
- package/src/__tests__/useDocPlayback.transition.test.ts +70 -0
- package/src/hooks/useAudioSync.ts +14 -1
- package/src/hooks/useDocPlayback.ts +81 -100
- package/src/hooks/useMediaSchedule.ts +39 -0
- package/src/index.ts +7 -0
- package/src/layers/ImageLayer.tsx +11 -1
- package/src/layers/PathLayer.tsx +146 -0
- package/src/layers/ShapeLayer.tsx +27 -5
- package/src/layers/TextLayer.tsx +395 -22
- package/src/layers/VideoLayer.tsx +16 -9
- package/src/layers/index.ts +1 -0
- package/src/styles/doc-animations.css +1857 -2
- package/src/utils/fillStyle.tsx +148 -0
package/src/layers/TextLayer.tsx
CHANGED
|
@@ -5,13 +5,36 @@
|
|
|
5
5
|
* styling options (font, color, shadow), and animations like fadeIn
|
|
6
6
|
* and typewriter effects.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* Two rendering paths:
|
|
9
|
+
* - Plain text (`PlainTextLayer`) — SVG `<text>` + `<tspan>` per line, one
|
|
10
|
+
* style for the whole layer. Used for the common case and for
|
|
11
|
+
* backward-compat with content authored before rich text.
|
|
12
|
+
* - Rich text (`RichTextLayer`) — when `content.html` is set, the sanitized
|
|
13
|
+
* inline/block HTML renders inside a `<foreignObject>` so bold/italic/
|
|
14
|
+
* links (and, for layout textboxes, headings/lists) format individual
|
|
15
|
+
* runs. `<foreignObject>` is export-safe (video rasterizes via Chromium,
|
|
16
|
+
* PDF bypasses SVG) and already used by Video/Table layers.
|
|
9
17
|
*/
|
|
10
18
|
|
|
19
|
+
import { useMemo, type CSSProperties } from 'react';
|
|
11
20
|
import type { TextLayer as TextLayerType } from '@bendyline/squisq/schemas';
|
|
12
21
|
import { DEFAULT_DOC_FONT } from '@bendyline/squisq/schemas';
|
|
22
|
+
import {
|
|
23
|
+
parseHtmlToNodes,
|
|
24
|
+
sanitizeHtmlNodes,
|
|
25
|
+
stringifyHtmlNodes,
|
|
26
|
+
} from '@bendyline/squisq/markdown';
|
|
27
|
+
// Import from the standalone marker module (not the `icons` barrel) so the
|
|
28
|
+
// player bundle doesn't pull in the ~2k-entry FontAwesome catalog.
|
|
29
|
+
import {
|
|
30
|
+
hasIconMarker,
|
|
31
|
+
splitIconMarkers,
|
|
32
|
+
stripIconMarkers,
|
|
33
|
+
iconClass,
|
|
34
|
+
} from '@bendyline/squisq/icon-marker';
|
|
13
35
|
import { getAnimationStyle } from '../utils/animationUtils';
|
|
14
36
|
import { resolveValue } from '../utils/layerUtils';
|
|
37
|
+
import { resolveFill, borderDashArray } from '../utils/fillStyle';
|
|
15
38
|
|
|
16
39
|
interface TextLayerProps {
|
|
17
40
|
layer: TextLayerType;
|
|
@@ -21,18 +44,153 @@ interface TextLayerProps {
|
|
|
21
44
|
blockTime: number;
|
|
22
45
|
}
|
|
23
46
|
|
|
24
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Dispatch between the plain SVG-text renderer and the rich HTML renderer
|
|
49
|
+
* based on whether the layer carries `content.html`.
|
|
50
|
+
*/
|
|
51
|
+
export function TextLayer(props: TextLayerProps) {
|
|
52
|
+
if (props.layer.content.html?.trim()) return <RichTextLayer {...props} />;
|
|
53
|
+
// Body text carrying inline-icon markers (authored `{[rocket]}` etc.) can't
|
|
54
|
+
// render in the SVG `<text>` path — an icon is an `<i class="fa-…">` glyph,
|
|
55
|
+
// not a code point we can place in a tspan. Route it through a foreignObject
|
|
56
|
+
// instead, sizing the box to the wrapped content so it doesn't clip.
|
|
57
|
+
if (hasIconMarker(props.layer.content.text ?? '')) return <IconTextLayer {...props} />;
|
|
58
|
+
return <PlainTextLayer {...props} />;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Escape text so it's safe inside the synthesized icon HTML. */
|
|
62
|
+
function escapeHtml(value: string): string {
|
|
63
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Turn a marker-bearing string into HTML: text runs escaped, icons as `<i>`. */
|
|
67
|
+
function iconRunsToHtml(text: string): string {
|
|
68
|
+
return splitIconMarkers(text)
|
|
69
|
+
.map((run) =>
|
|
70
|
+
run.type === 'icon'
|
|
71
|
+
? `<i class="${iconClass(run.family, run.name)}" aria-hidden="true"></i>`
|
|
72
|
+
: escapeHtml(run.text).replace(/\n/g, '<br>'),
|
|
73
|
+
)
|
|
74
|
+
.join('');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Renders template body text that contains inline icons. Geometry mirrors
|
|
79
|
+
* `PlainTextLayer`/`layerBounds` (same anchor math), but the content lives in
|
|
80
|
+
* a `<foreignObject>` so `<i class="fa-…">` glyphs render (export-safe: video
|
|
81
|
+
* rasterizes via Chromium, which has the FA webfont embedded in the render
|
|
82
|
+
* page). Height is derived from the wrapped, icon-free projection when the
|
|
83
|
+
* layer has no explicit height, and overflow is visible to guard estimates.
|
|
84
|
+
*/
|
|
85
|
+
function IconTextLayer({ layer, viewport, blockTime }: TextLayerProps) {
|
|
25
86
|
const { content, position, animation } = layer;
|
|
26
87
|
const { text, style } = content;
|
|
27
88
|
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
89
|
+
const rawX = resolveValue(position.x, viewport.width);
|
|
90
|
+
const rawY = resolveValue(position.y, viewport.height);
|
|
91
|
+
const boxWidth =
|
|
92
|
+
position.width != null ? resolveValue(position.width, viewport.width) : viewport.width;
|
|
93
|
+
const anchor = position.anchor ?? 'top-left';
|
|
94
|
+
const lineHeight = style.lineHeight || 1.4;
|
|
95
|
+
const lineHeightPx = style.fontSize * lineHeight;
|
|
96
|
+
const padding = style.padding ?? 0;
|
|
97
|
+
|
|
98
|
+
// Estimate wrapped line count from the icon-free text so the box is tall
|
|
99
|
+
// enough. Icons roughly occupy one character; the estimate need not be exact
|
|
100
|
+
// because the foreignObject is `overflow: visible`.
|
|
101
|
+
const plain = stripIconMarkers(text ?? '');
|
|
102
|
+
const lines = plain.split('\n').flatMap((line) => wrapText(line, style.fontSize, boxWidth));
|
|
103
|
+
const boxHeight =
|
|
104
|
+
position.height != null
|
|
105
|
+
? resolveValue(position.height, viewport.height)
|
|
106
|
+
: Math.max(lineHeightPx, lines.length * lineHeightPx) + padding * 2;
|
|
107
|
+
|
|
108
|
+
const boxX = rawX - anchorAxis(anchor, boxWidth, 'x');
|
|
109
|
+
const boxY = rawY - anchorAxis(anchor, boxHeight, 'y');
|
|
110
|
+
|
|
111
|
+
const animStyle = getAnimationStyle(animation, blockTime);
|
|
112
|
+
const html = useMemo(() => iconRunsToHtml(text ?? ''), [text]);
|
|
113
|
+
|
|
114
|
+
const verticalJustify =
|
|
115
|
+
style.verticalAlign === 'top'
|
|
116
|
+
? 'flex-start'
|
|
117
|
+
: style.verticalAlign === 'bottom'
|
|
118
|
+
? 'flex-end'
|
|
119
|
+
: 'center';
|
|
120
|
+
|
|
121
|
+
const boxStyle: CSSProperties = {
|
|
122
|
+
boxSizing: 'border-box',
|
|
123
|
+
width: '100%',
|
|
124
|
+
height: '100%',
|
|
125
|
+
display: 'flex',
|
|
126
|
+
flexDirection: 'column',
|
|
127
|
+
justifyContent: verticalJustify,
|
|
128
|
+
padding,
|
|
129
|
+
color: style.color,
|
|
130
|
+
fontSize: `${style.fontSize}px`,
|
|
131
|
+
fontFamily: style.fontFamily || DEFAULT_DOC_FONT,
|
|
132
|
+
fontWeight: style.fontWeight || 'normal',
|
|
133
|
+
fontStyle: style.fontStyle || 'normal',
|
|
134
|
+
lineHeight,
|
|
135
|
+
textAlign: style.textAlign ?? 'left',
|
|
136
|
+
...(style.shadow ? { textShadow: '0 2px 3px rgba(0,0,0,0.7)' } : {}),
|
|
137
|
+
...animStyle.style,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<g className={`block-layer block-layer--text ${animStyle.className}`} data-layer-id={layer.id}>
|
|
142
|
+
<foreignObject
|
|
143
|
+
x={boxX}
|
|
144
|
+
y={boxY}
|
|
145
|
+
width={boxWidth}
|
|
146
|
+
height={boxHeight}
|
|
147
|
+
style={{ overflow: 'visible' }}
|
|
148
|
+
>
|
|
149
|
+
{/* Outer box owns the flex/vertical-centering; the inner div is a
|
|
150
|
+
single flex item so the rich content (text + inline `<i>` icons)
|
|
151
|
+
flows inline instead of each node becoming a stacked flex item. */}
|
|
152
|
+
<div
|
|
153
|
+
{...({ xmlns: 'http://www.w3.org/1999/xhtml' } as Record<string, string>)}
|
|
154
|
+
style={boxStyle}
|
|
155
|
+
>
|
|
156
|
+
<div
|
|
157
|
+
style={{ width: '100%', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
|
158
|
+
aria-label={plain}
|
|
159
|
+
dangerouslySetInnerHTML={{ __html: html }}
|
|
160
|
+
/>
|
|
161
|
+
</div>
|
|
162
|
+
</foreignObject>
|
|
163
|
+
</g>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function PlainTextLayer({ layer, viewport, blockTime }: TextLayerProps) {
|
|
168
|
+
const { content, position, animation } = layer;
|
|
169
|
+
const { text, style } = content;
|
|
170
|
+
|
|
171
|
+
// Resolve position values to pixels. `position.x/y` is the layer's
|
|
172
|
+
// anchor *point*; `position.anchor` says where on the box that point
|
|
173
|
+
// sits (matching `layerBounds` in the editor).
|
|
174
|
+
const rawX = resolveValue(position.x, viewport.width);
|
|
175
|
+
const rawY = resolveValue(position.y, viewport.height);
|
|
176
|
+
const boxWidth =
|
|
177
|
+
position.width != null ? resolveValue(position.width, viewport.width) : undefined;
|
|
178
|
+
const boxHeight =
|
|
179
|
+
position.height != null ? resolveValue(position.height, viewport.height) : undefined;
|
|
180
|
+
const maxWidth = boxWidth;
|
|
32
181
|
|
|
33
|
-
// Apply anchor offset for text alignment
|
|
34
182
|
const textAnchor = getTextAnchor(style.textAlign, position.anchor);
|
|
35
|
-
const dominantBaseline = getDominantBaseline(position.anchor);
|
|
183
|
+
const dominantBaseline = getDominantBaseline(style.verticalAlign, position.anchor);
|
|
184
|
+
|
|
185
|
+
// Place the text's pivot at the box edge/centre matching the chosen
|
|
186
|
+
// alignment. This is algebraically identical to the legacy point-anchor
|
|
187
|
+
// behavior for existing content (where x/y were already the desired
|
|
188
|
+
// pivot — e.g. templates using x:'50%' + anchor:'center'), and makes
|
|
189
|
+
// box-relative H/V alignment work for layers anchored at their
|
|
190
|
+
// top-left (as the template designer creates them).
|
|
191
|
+
const anchor = position.anchor ?? 'top-left';
|
|
192
|
+
const x = pivotX(rawX, boxWidth, anchor, textAnchor);
|
|
193
|
+
const y = pivotY(rawY, boxHeight, anchor, dominantBaseline);
|
|
36
194
|
|
|
37
195
|
// Get animation styles
|
|
38
196
|
const animStyle = getAnimationStyle(animation, blockTime);
|
|
@@ -61,6 +219,7 @@ export function TextLayer({ layer, viewport, blockTime }: TextLayerProps) {
|
|
|
61
219
|
fontSize: `${style.fontSize}px`,
|
|
62
220
|
fontFamily: style.fontFamily || DEFAULT_DOC_FONT,
|
|
63
221
|
fontWeight: style.fontWeight || 'normal',
|
|
222
|
+
fontStyle: style.fontStyle || 'normal',
|
|
64
223
|
fill: style.color,
|
|
65
224
|
...animStyle.style,
|
|
66
225
|
};
|
|
@@ -79,18 +238,30 @@ export function TextLayer({ layer, viewport, blockTime }: TextLayerProps) {
|
|
|
79
238
|
</defs>
|
|
80
239
|
)}
|
|
81
240
|
|
|
82
|
-
{/* Background box
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
241
|
+
{/* Background / border box. When the layer has an explicit box
|
|
242
|
+
(width + height — as the template designer always creates), the
|
|
243
|
+
fill and border cover that rectangle: text is, after all, a
|
|
244
|
+
rectangle. Without a box we fall back to a snug rect hugging the
|
|
245
|
+
text (legacy behavior for point-anchored text). */}
|
|
246
|
+
<TextBox
|
|
247
|
+
layerId={layer.id}
|
|
248
|
+
style={style}
|
|
249
|
+
box={
|
|
250
|
+
boxWidth != null && boxHeight != null
|
|
251
|
+
? {
|
|
252
|
+
x: rawX - anchorAxis(anchor, boxWidth, 'x'),
|
|
253
|
+
y: rawY - anchorAxis(anchor, boxHeight, 'y'),
|
|
254
|
+
width: boxWidth,
|
|
255
|
+
height: boxHeight,
|
|
256
|
+
}
|
|
257
|
+
: {
|
|
258
|
+
x: x - (style.padding || 16),
|
|
259
|
+
y: y - style.fontSize - (style.padding || 16),
|
|
260
|
+
width: getTextBoxWidth(lines, style) + (style.padding || 16) * 2,
|
|
261
|
+
height: lines.length * lineHeightPx + (style.padding || 16) * 2,
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/>
|
|
94
265
|
|
|
95
266
|
{/* Text element with tspans for each line */}
|
|
96
267
|
<text
|
|
@@ -111,6 +282,159 @@ export function TextLayer({ layer, viewport, blockTime }: TextLayerProps) {
|
|
|
111
282
|
);
|
|
112
283
|
}
|
|
113
284
|
|
|
285
|
+
/**
|
|
286
|
+
* Rich-text path: render `content.html` as sanitized HTML inside a
|
|
287
|
+
* `<foreignObject>` so individual runs can carry their own formatting
|
|
288
|
+
* (bold/italic/links, and headings/lists for layout textboxes). The box
|
|
289
|
+
* geometry matches `PlainTextLayer`/`layerBounds`, so selection and
|
|
290
|
+
* drag/resize stay aligned. `content.text` remains the plain projection
|
|
291
|
+
* used by export/search.
|
|
292
|
+
*/
|
|
293
|
+
function RichTextLayer({ layer, viewport, blockTime }: TextLayerProps) {
|
|
294
|
+
const { content, position, animation } = layer;
|
|
295
|
+
const { html, style } = content;
|
|
296
|
+
|
|
297
|
+
const rawX = resolveValue(position.x, viewport.width);
|
|
298
|
+
const rawY = resolveValue(position.y, viewport.height);
|
|
299
|
+
const boxWidth =
|
|
300
|
+
position.width != null ? resolveValue(position.width, viewport.width) : viewport.width;
|
|
301
|
+
const boxHeight =
|
|
302
|
+
position.height != null
|
|
303
|
+
? resolveValue(position.height, viewport.height)
|
|
304
|
+
: style.fontSize * (style.lineHeight || 1.4) * 2;
|
|
305
|
+
const anchor = position.anchor ?? 'top-left';
|
|
306
|
+
const boxX = rawX - anchorAxis(anchor, boxWidth, 'x');
|
|
307
|
+
const boxY = rawY - anchorAxis(anchor, boxHeight, 'y');
|
|
308
|
+
|
|
309
|
+
// Re-sanitize at render time — `html` is untrusted. Memoized so the
|
|
310
|
+
// per-frame `blockTime` updates don't re-parse the HTML each frame.
|
|
311
|
+
const safeHtml = useMemo(
|
|
312
|
+
() => stringifyHtmlNodes(sanitizeHtmlNodes(parseHtmlToNodes(html ?? ''))),
|
|
313
|
+
[html],
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const animStyle = getAnimationStyle(animation, blockTime);
|
|
317
|
+
|
|
318
|
+
const verticalJustify =
|
|
319
|
+
style.verticalAlign === 'middle'
|
|
320
|
+
? 'center'
|
|
321
|
+
: style.verticalAlign === 'bottom'
|
|
322
|
+
? 'flex-end'
|
|
323
|
+
: 'flex-start';
|
|
324
|
+
|
|
325
|
+
const hasBorder = !!(style.borderColor && (style.borderWidth ?? 0) > 0);
|
|
326
|
+
// Box decoration uses CSS (foreignObject is HTML); gradients/backgroundOpacity
|
|
327
|
+
// are deferred to the SVG path — solid background covers the common case.
|
|
328
|
+
const boxStyle: CSSProperties = {
|
|
329
|
+
boxSizing: 'border-box',
|
|
330
|
+
width: '100%',
|
|
331
|
+
height: '100%',
|
|
332
|
+
display: 'flex',
|
|
333
|
+
flexDirection: 'column',
|
|
334
|
+
justifyContent: verticalJustify,
|
|
335
|
+
padding: style.padding ?? 0,
|
|
336
|
+
color: style.color,
|
|
337
|
+
fontSize: `${style.fontSize}px`,
|
|
338
|
+
fontFamily: style.fontFamily || DEFAULT_DOC_FONT,
|
|
339
|
+
fontWeight: style.fontWeight || 'normal',
|
|
340
|
+
fontStyle: style.fontStyle || 'normal',
|
|
341
|
+
lineHeight: style.lineHeight || 1.4,
|
|
342
|
+
textAlign: style.textAlign ?? 'left',
|
|
343
|
+
overflow: 'hidden',
|
|
344
|
+
...(style.background ? { background: style.background } : {}),
|
|
345
|
+
...(hasBorder
|
|
346
|
+
? {
|
|
347
|
+
border: `${style.borderWidth}px ${style.borderStyle ?? 'solid'} ${style.borderColor}`,
|
|
348
|
+
borderRadius: 4,
|
|
349
|
+
}
|
|
350
|
+
: {}),
|
|
351
|
+
...(style.shadow ? { textShadow: '0 2px 3px rgba(0,0,0,0.7)' } : {}),
|
|
352
|
+
...animStyle.style,
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
// Scoped reset so authored block elements (headings, lists, paragraphs)
|
|
356
|
+
// render tightly inside the box rather than with the UA's large margins.
|
|
357
|
+
const cls = `squisq-rich-text-${cssId(layer.id)}`;
|
|
358
|
+
const scopedCss =
|
|
359
|
+
`.${cls}{margin:0}` +
|
|
360
|
+
`.${cls} p{margin:0 0 .4em}` +
|
|
361
|
+
`.${cls} h1,.${cls} h2,.${cls} h3,.${cls} h4,.${cls} h5,.${cls} h6{margin:0 0 .3em;line-height:1.2}` +
|
|
362
|
+
// `list-style-position: inside` keeps the bullet/number next to its text
|
|
363
|
+
// — with `outside` (the default) a centered or middle-aligned list leaves
|
|
364
|
+
// the marker stranded at the box's left padding, far from the text. The
|
|
365
|
+
// editor wraps each item's text in a `<p>`, so flatten that to inline or
|
|
366
|
+
// the block paragraph drops below the (inline) marker.
|
|
367
|
+
`.${cls} ul,.${cls} ol{margin:0 0 .4em;padding-left:1.2em;list-style-position:inside}` +
|
|
368
|
+
`.${cls} li{margin:0}` +
|
|
369
|
+
`.${cls} li>p{display:inline;margin:0}` +
|
|
370
|
+
`.${cls} *:first-child{margin-top:0}.${cls} *:last-child{margin-bottom:0}` +
|
|
371
|
+
`.${cls} a{color:inherit;text-decoration:underline}`;
|
|
372
|
+
|
|
373
|
+
return (
|
|
374
|
+
<g className={`block-layer block-layer--text ${animStyle.className}`} data-layer-id={layer.id}>
|
|
375
|
+
<foreignObject x={boxX} y={boxY} width={boxWidth} height={boxHeight}>
|
|
376
|
+
{/* xmlns is required for HTML inside SVG foreignObject (see TableLayer) */}
|
|
377
|
+
<div
|
|
378
|
+
{...({ xmlns: 'http://www.w3.org/1999/xhtml' } as Record<string, string>)}
|
|
379
|
+
style={boxStyle}
|
|
380
|
+
>
|
|
381
|
+
<style>{scopedCss}</style>
|
|
382
|
+
<div
|
|
383
|
+
className={cls}
|
|
384
|
+
aria-label={content.text}
|
|
385
|
+
style={{ width: '100%', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
|
386
|
+
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
|
387
|
+
/>
|
|
388
|
+
</div>
|
|
389
|
+
</foreignObject>
|
|
390
|
+
</g>
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Sanitize a layer id into a CSS-class-safe suffix. */
|
|
395
|
+
function cssId(id: string): string {
|
|
396
|
+
return id.replace(/[^a-zA-Z0-9_-]/g, '-');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* The text layer's background + border rect. Renders nothing unless a
|
|
401
|
+
* fill (solid or gradient) or a visible border is configured.
|
|
402
|
+
*/
|
|
403
|
+
function TextBox({
|
|
404
|
+
layerId,
|
|
405
|
+
style,
|
|
406
|
+
box,
|
|
407
|
+
}: {
|
|
408
|
+
layerId: string;
|
|
409
|
+
style: import('@bendyline/squisq/schemas').TextStyle;
|
|
410
|
+
box: { x: number; y: number; width: number; height: number };
|
|
411
|
+
}) {
|
|
412
|
+
const hasFill = !!(style.background || style.backgroundGradient);
|
|
413
|
+
const hasBorder = !!(style.borderColor && (style.borderWidth ?? 0) > 0);
|
|
414
|
+
if (!hasFill && !hasBorder) return null;
|
|
415
|
+
|
|
416
|
+
const { fill, def } = resolveFill(layerId, style.background, style.backgroundGradient);
|
|
417
|
+
const dash = borderDashArray(style.borderStyle, style.borderWidth);
|
|
418
|
+
return (
|
|
419
|
+
<>
|
|
420
|
+
{def && <defs>{def}</defs>}
|
|
421
|
+
<rect
|
|
422
|
+
x={box.x}
|
|
423
|
+
y={box.y}
|
|
424
|
+
width={box.width}
|
|
425
|
+
height={box.height}
|
|
426
|
+
fill={hasFill ? fill : 'none'}
|
|
427
|
+
fillOpacity={hasFill ? style.backgroundOpacity : undefined}
|
|
428
|
+
stroke={hasBorder ? style.borderColor : undefined}
|
|
429
|
+
strokeWidth={hasBorder ? style.borderWidth : undefined}
|
|
430
|
+
strokeDasharray={hasBorder ? dash : undefined}
|
|
431
|
+
rx={4}
|
|
432
|
+
ry={4}
|
|
433
|
+
/>
|
|
434
|
+
</>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
114
438
|
/**
|
|
115
439
|
* Map text alignment to SVG text-anchor.
|
|
116
440
|
*/
|
|
@@ -127,14 +451,63 @@ function getTextAnchor(align?: 'left' | 'center' | 'right', anchor?: string): st
|
|
|
127
451
|
}
|
|
128
452
|
|
|
129
453
|
/**
|
|
130
|
-
* Map position anchor to SVG
|
|
454
|
+
* Map vertical alignment (or, when unset, the position anchor) to SVG
|
|
455
|
+
* dominant-baseline. Explicit `verticalAlign` wins so the editor can set
|
|
456
|
+
* top/middle/bottom independently of the box-placement anchor.
|
|
131
457
|
*/
|
|
132
|
-
function getDominantBaseline(anchor?: string): string {
|
|
458
|
+
function getDominantBaseline(verticalAlign?: 'top' | 'middle' | 'bottom', anchor?: string): string {
|
|
459
|
+
if (verticalAlign === 'top') return 'text-before-edge';
|
|
460
|
+
if (verticalAlign === 'middle') return 'middle';
|
|
461
|
+
if (verticalAlign === 'bottom') return 'text-after-edge';
|
|
462
|
+
|
|
133
463
|
if (anchor?.includes('bottom')) return 'text-after-edge';
|
|
134
464
|
if (anchor === 'center') return 'middle';
|
|
135
465
|
return 'text-before-edge';
|
|
136
466
|
}
|
|
137
467
|
|
|
468
|
+
/**
|
|
469
|
+
* Horizontal pivot for the text. Derives the box's left edge from the
|
|
470
|
+
* anchor point (`rawX`) and the box width, then offsets to the edge/centre
|
|
471
|
+
* matching `textAnchor`. Falls back to the raw point when no width is set.
|
|
472
|
+
*/
|
|
473
|
+
function pivotX(
|
|
474
|
+
rawX: number,
|
|
475
|
+
width: number | undefined,
|
|
476
|
+
anchor: string,
|
|
477
|
+
textAnchor: string,
|
|
478
|
+
): number {
|
|
479
|
+
if (width == null) return rawX;
|
|
480
|
+
const boxLeft = rawX - anchorAxis(anchor, width, 'x');
|
|
481
|
+
if (textAnchor === 'middle') return boxLeft + width / 2;
|
|
482
|
+
if (textAnchor === 'end') return boxLeft + width;
|
|
483
|
+
return boxLeft;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Vertical pivot for the text. Mirror of {@link pivotX} for the box's top
|
|
488
|
+
* edge and the resolved dominant-baseline. Falls back to the raw point
|
|
489
|
+
* when no height is set.
|
|
490
|
+
*/
|
|
491
|
+
function pivotY(
|
|
492
|
+
rawY: number,
|
|
493
|
+
height: number | undefined,
|
|
494
|
+
anchor: string,
|
|
495
|
+
dominantBaseline: string,
|
|
496
|
+
): number {
|
|
497
|
+
if (height == null) return rawY;
|
|
498
|
+
const boxTop = rawY - anchorAxis(anchor, height, 'y');
|
|
499
|
+
if (dominantBaseline === 'middle') return boxTop + height / 2;
|
|
500
|
+
if (dominantBaseline === 'text-after-edge') return boxTop + height;
|
|
501
|
+
return boxTop;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Anchor-point offset along one axis, matching `layerBounds` in the editor. */
|
|
505
|
+
function anchorAxis(anchor: string, size: number, axis: 'x' | 'y'): number {
|
|
506
|
+
if (anchor === 'center') return size / 2;
|
|
507
|
+
if (axis === 'x') return anchor.includes('right') ? size : 0;
|
|
508
|
+
return anchor.includes('bottom') ? size : 0;
|
|
509
|
+
}
|
|
510
|
+
|
|
138
511
|
/**
|
|
139
512
|
* Estimate text box width based on content (rough approximation).
|
|
140
513
|
*/
|
|
@@ -38,17 +38,16 @@ interface VideoLayerProps {
|
|
|
38
38
|
isPlaying?: boolean;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export function VideoLayer({
|
|
42
|
-
layer,
|
|
43
|
-
basePath,
|
|
44
|
-
viewport,
|
|
45
|
-
blockTime: _blockTime,
|
|
46
|
-
isPlaying,
|
|
47
|
-
}: VideoLayerProps) {
|
|
41
|
+
export function VideoLayer({ layer, basePath, viewport, blockTime, isPlaying }: VideoLayerProps) {
|
|
48
42
|
const { content, position } = layer;
|
|
49
43
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
50
44
|
const hasStartedRef = useRef(false);
|
|
51
45
|
|
|
46
|
+
// Seconds into the block before this clip begins. Until then the video is
|
|
47
|
+
// held (paused) at its in-point so a `startAt` offset reads as a delay.
|
|
48
|
+
const startAt = content.startAt ?? 0;
|
|
49
|
+
const gated = blockTime < startAt;
|
|
50
|
+
|
|
52
51
|
// Resolve position values to pixels
|
|
53
52
|
const x = resolveValue(position.x, viewport.width);
|
|
54
53
|
const y = resolveValue(position.y, viewport.height);
|
|
@@ -103,11 +102,18 @@ export function VideoLayer({
|
|
|
103
102
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- isPlaying is handled by the separate sync effect below
|
|
104
103
|
}, [content.src, content.clipStart, content.clipEnd]);
|
|
105
104
|
|
|
106
|
-
// Sync video play/pause with doc playback state
|
|
105
|
+
// Sync video play/pause with doc playback state, honoring the startAt gate.
|
|
107
106
|
useEffect(() => {
|
|
108
107
|
const video = videoRef.current;
|
|
109
108
|
if (!video || !hasStartedRef.current) return;
|
|
110
109
|
|
|
110
|
+
// Before the clip's startAt offset, hold at the in-point.
|
|
111
|
+
if (gated) {
|
|
112
|
+
video.pause();
|
|
113
|
+
video.currentTime = content.clipStart;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
111
117
|
// Don't resume if clip has already reached its end
|
|
112
118
|
if (video.currentTime >= content.clipEnd) return;
|
|
113
119
|
|
|
@@ -119,7 +125,7 @@ export function VideoLayer({
|
|
|
119
125
|
} else {
|
|
120
126
|
video.pause();
|
|
121
127
|
}
|
|
122
|
-
}, [isPlaying, content.clipEnd]);
|
|
128
|
+
}, [isPlaying, gated, content.clipStart, content.clipEnd]);
|
|
123
129
|
|
|
124
130
|
return (
|
|
125
131
|
<g className="block-layer block-layer--video" data-layer-id={layer.id}>
|
|
@@ -133,6 +139,7 @@ export function VideoLayer({
|
|
|
133
139
|
preload="auto"
|
|
134
140
|
data-clip-start={content.clipStart}
|
|
135
141
|
data-clip-end={content.clipEnd}
|
|
142
|
+
data-start-at={startAt}
|
|
136
143
|
style={{
|
|
137
144
|
width: `${width}px`,
|
|
138
145
|
height: `${height}px`,
|
package/src/layers/index.ts
CHANGED