@bendyline/squisq-react 2.0.1 → 2.2.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/LICENSE +21 -0
- package/NOTICE.md +20 -0
- package/README.md +6 -0
- package/dist/index.d.ts +101 -96
- package/dist/index.js +1483 -782
- package/dist/squisq-player.full.global.js +4165 -0
- package/dist/squisq-player.global.js +561 -168
- package/dist/standalone-source.d.ts +1 -0
- package/dist/standalone-source.js +1 -1
- package/dist/styles/index.css +64 -0
- package/package.json +8 -4
- package/dist/index.js.map +0 -1
- package/dist/squisq-player.css +0 -2
- package/dist/squisq-player.css.map +0 -1
- package/dist/squisq-player.global.js.map +0 -1
- package/src/BlockRenderer.tsx +0 -194
- package/src/CaptionOverlay.tsx +0 -111
- package/src/DocControlsBottom.tsx +0 -109
- package/src/DocControlsOverlay.tsx +0 -190
- package/src/DocControlsSidebar.tsx +0 -113
- package/src/DocControlsSlideshow.tsx +0 -360
- package/src/DocPlayer.tsx +0 -1528
- package/src/DocPlayerWithSidebar.tsx +0 -155
- package/src/DocProgressBar.tsx +0 -257
- package/src/InlineAudioPlayer.tsx +0 -46
- package/src/InlineVideoPlayer.tsx +0 -70
- package/src/LinearDocView.tsx +0 -569
- package/src/MarkdownRenderer.tsx +0 -624
- package/src/MediaClipLayer.tsx +0 -145
- package/src/SocialCaptionOverlay.tsx +0 -255
- package/src/__tests__/BlockRenderer.test.tsx +0 -235
- package/src/__tests__/DocControlsSlideshow.test.tsx +0 -221
- package/src/__tests__/DocPlayer.test.tsx +0 -635
- package/src/__tests__/DocPlayerStylesSentinel.test.tsx +0 -41
- package/src/__tests__/DocProgressBar.test.tsx +0 -102
- package/src/__tests__/JsonView.test.tsx +0 -111
- package/src/__tests__/LinearDocView.test.tsx +0 -426
- package/src/__tests__/MapLayer.test.tsx +0 -63
- package/src/__tests__/MarkdownRenderer.test.tsx +0 -357
- package/src/__tests__/MediaClipLayer.test.tsx +0 -70
- package/src/__tests__/MediaContext.test.tsx +0 -51
- package/src/__tests__/PathLayer.test.tsx +0 -84
- package/src/__tests__/TableLayer.test.tsx +0 -142
- package/src/__tests__/VideoLayer.test.tsx +0 -94
- package/src/__tests__/exports.test.ts +0 -55
- package/src/__tests__/fillStyle.test.tsx +0 -160
- package/src/__tests__/standaloneEntry.test.tsx +0 -103
- package/src/__tests__/transitionStyles.test.ts +0 -125
- package/src/__tests__/useAudioSync.test.ts +0 -49
- package/src/__tests__/useDocPlayback.transition.test.ts +0 -113
- package/src/__tests__/useJsonViewTokens.test.ts +0 -41
- package/src/__tests__/useSlideSwipe.test.ts +0 -81
- package/src/__tests__/useViewportOrientation.test.ts +0 -22
- package/src/hooks/AudioController.ts +0 -114
- package/src/hooks/MediaContext.tsx +0 -97
- package/src/hooks/index.ts +0 -11
- package/src/hooks/useAudioSync.ts +0 -456
- package/src/hooks/useAutoSurface.ts +0 -33
- package/src/hooks/useDocPlayback.ts +0 -265
- package/src/hooks/useMediaSchedule.ts +0 -39
- package/src/hooks/useSlideSwipe.ts +0 -265
- package/src/hooks/useViewportOrientation.ts +0 -115
- package/src/index.ts +0 -70
- package/src/jsonView/JsonView.tsx +0 -51
- package/src/jsonView/RenderNode.tsx +0 -51
- package/src/jsonView/index.ts +0 -2
- package/src/jsonView/json-view.css +0 -206
- package/src/jsonView/useJsonViewTokens.ts +0 -32
- package/src/jsonView/viewers.tsx +0 -343
- package/src/layers/ImageLayer.tsx +0 -297
- package/src/layers/MapLayer.tsx +0 -185
- package/src/layers/PathLayer.tsx +0 -155
- package/src/layers/ShapeLayer.tsx +0 -158
- package/src/layers/TableLayer.tsx +0 -129
- package/src/layers/TextLayer.tsx +0 -571
- package/src/layers/TreeLayer.tsx +0 -167
- package/src/layers/VideoLayer.tsx +0 -171
- package/src/layers/index.ts +0 -6
- package/src/standalone-entry.tsx +0 -337
- package/src/standalone-source.d.ts +0 -10
- package/src/styles/doc-animations.css +0 -2431
- package/src/styles/index.css +0 -7
- package/src/types.ts +0 -183
- package/src/utils/animationUtils.ts +0 -13
- package/src/utils/fillStyle.tsx +0 -148
- package/src/utils/layerUtils.ts +0 -42
- package/src/utils/mapTileUtils.ts +0 -375
package/src/layers/TextLayer.tsx
DELETED
|
@@ -1,571 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TextLayer Component
|
|
3
|
-
*
|
|
4
|
-
* Renders a text layer within an SVG block. Supports multi-line text,
|
|
5
|
-
* styling options (font, color, shadow), and animations like fadeIn
|
|
6
|
-
* and typewriter effects.
|
|
7
|
-
*
|
|
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.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { useId, useMemo, type CSSProperties } from 'react';
|
|
20
|
-
import type { TextLayer as TextLayerType } from '@bendyline/squisq/schemas';
|
|
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';
|
|
35
|
-
import { getAnimationStyle } from '../utils/animationUtils';
|
|
36
|
-
import { resolveValue } from '../utils/layerUtils';
|
|
37
|
-
import { resolveFill, borderDashArray } from '../utils/fillStyle';
|
|
38
|
-
|
|
39
|
-
interface TextLayerProps {
|
|
40
|
-
layer: TextLayerType;
|
|
41
|
-
/** Viewport dimensions for percentage calculations */
|
|
42
|
-
viewport: { width: number; height: number };
|
|
43
|
-
/** Current time relative to block start */
|
|
44
|
-
blockTime: number;
|
|
45
|
-
}
|
|
46
|
-
|
|
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) {
|
|
86
|
-
const { content, position, animation } = layer;
|
|
87
|
-
const { text, style } = content;
|
|
88
|
-
|
|
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 defsId = `${useId().replace(/:/g, '')}-${layer.id}`;
|
|
169
|
-
const { content, position, animation } = layer;
|
|
170
|
-
const { text, style } = content;
|
|
171
|
-
|
|
172
|
-
// Resolve position values to pixels. `position.x/y` is the layer's
|
|
173
|
-
// anchor *point*; `position.anchor` says where on the box that point
|
|
174
|
-
// sits (matching `layerBounds` in the editor).
|
|
175
|
-
const rawX = resolveValue(position.x, viewport.width);
|
|
176
|
-
const rawY = resolveValue(position.y, viewport.height);
|
|
177
|
-
const boxWidth =
|
|
178
|
-
position.width != null ? resolveValue(position.width, viewport.width) : undefined;
|
|
179
|
-
const boxHeight =
|
|
180
|
-
position.height != null ? resolveValue(position.height, viewport.height) : undefined;
|
|
181
|
-
const maxWidth = boxWidth;
|
|
182
|
-
|
|
183
|
-
const textAnchor = getTextAnchor(style.textAlign, position.anchor);
|
|
184
|
-
const dominantBaseline = getDominantBaseline(style.verticalAlign, position.anchor);
|
|
185
|
-
|
|
186
|
-
// Place the text's pivot at the box edge/centre matching the chosen
|
|
187
|
-
// alignment. This is algebraically identical to the legacy point-anchor
|
|
188
|
-
// behavior for existing content (where x/y were already the desired
|
|
189
|
-
// pivot — e.g. templates using x:'50%' + anchor:'center'), and makes
|
|
190
|
-
// box-relative H/V alignment work for layers anchored at their
|
|
191
|
-
// top-left (as the template designer creates them).
|
|
192
|
-
const anchor = position.anchor ?? 'top-left';
|
|
193
|
-
const x = pivotX(rawX, boxWidth, anchor, textAnchor);
|
|
194
|
-
const y = pivotY(rawY, boxHeight, anchor, dominantBaseline);
|
|
195
|
-
|
|
196
|
-
// Get animation styles
|
|
197
|
-
const animStyle = getAnimationStyle(animation, blockTime);
|
|
198
|
-
|
|
199
|
-
// Split text into lines, and wrap if maxWidth is specified
|
|
200
|
-
const rawLines = (text ?? '').split('\n');
|
|
201
|
-
let lines = maxWidth
|
|
202
|
-
? rawLines.reduce<string[]>(
|
|
203
|
-
(acc, line) => acc.concat(wrapText(line, style.fontSize, maxWidth)),
|
|
204
|
-
[],
|
|
205
|
-
)
|
|
206
|
-
: rawLines;
|
|
207
|
-
|
|
208
|
-
// Truncate to maxLines if specified
|
|
209
|
-
if (style.maxLines && lines.length > style.maxLines) {
|
|
210
|
-
lines = lines.slice(0, style.maxLines);
|
|
211
|
-
// Add ellipsis to last visible line
|
|
212
|
-
const last = lines[lines.length - 1];
|
|
213
|
-
lines[lines.length - 1] = last.replace(/\s*$/, '') + '...';
|
|
214
|
-
}
|
|
215
|
-
const lineHeight = style.lineHeight || 1.4;
|
|
216
|
-
const lineHeightPx = style.fontSize * lineHeight;
|
|
217
|
-
|
|
218
|
-
// Build text styles
|
|
219
|
-
const textStyles: Record<string, string | number> = {
|
|
220
|
-
fontSize: `${style.fontSize}px`,
|
|
221
|
-
fontFamily: style.fontFamily || DEFAULT_DOC_FONT,
|
|
222
|
-
fontWeight: style.fontWeight || 'normal',
|
|
223
|
-
fontStyle: style.fontStyle || 'normal',
|
|
224
|
-
fill: style.color,
|
|
225
|
-
...animStyle.style,
|
|
226
|
-
};
|
|
227
|
-
|
|
228
|
-
// Add shadow filter if requested
|
|
229
|
-
const filterId = style.shadow ? `shadow-${defsId}` : undefined;
|
|
230
|
-
|
|
231
|
-
return (
|
|
232
|
-
<g className={`block-layer block-layer--text ${animStyle.className}`} data-layer-id={layer.id}>
|
|
233
|
-
{/* Shadow filter definition */}
|
|
234
|
-
{style.shadow && (
|
|
235
|
-
<defs>
|
|
236
|
-
<filter id={filterId} x="-20%" y="-20%" width="140%" height="140%">
|
|
237
|
-
<feDropShadow dx="0" dy="2" stdDeviation="3" floodColor="rgba(0,0,0,0.7)" />
|
|
238
|
-
</filter>
|
|
239
|
-
</defs>
|
|
240
|
-
)}
|
|
241
|
-
|
|
242
|
-
{/* Background / border box. When the layer has an explicit box
|
|
243
|
-
(width + height — as the template designer always creates), the
|
|
244
|
-
fill and border cover that rectangle: text is, after all, a
|
|
245
|
-
rectangle. Without a box we fall back to a snug rect hugging the
|
|
246
|
-
text (legacy behavior for point-anchored text). */}
|
|
247
|
-
<TextBox
|
|
248
|
-
layerId={defsId}
|
|
249
|
-
style={style}
|
|
250
|
-
box={
|
|
251
|
-
boxWidth != null && boxHeight != null
|
|
252
|
-
? {
|
|
253
|
-
x: rawX - anchorAxis(anchor, boxWidth, 'x'),
|
|
254
|
-
y: rawY - anchorAxis(anchor, boxHeight, 'y'),
|
|
255
|
-
width: boxWidth,
|
|
256
|
-
height: boxHeight,
|
|
257
|
-
}
|
|
258
|
-
: {
|
|
259
|
-
x: x - (style.padding || 16),
|
|
260
|
-
y: y - style.fontSize - (style.padding || 16),
|
|
261
|
-
width: getTextBoxWidth(lines, style) + (style.padding || 16) * 2,
|
|
262
|
-
height: lines.length * lineHeightPx + (style.padding || 16) * 2,
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
/>
|
|
266
|
-
|
|
267
|
-
{/* Text element with tspans for each line */}
|
|
268
|
-
<text
|
|
269
|
-
x={x}
|
|
270
|
-
y={y}
|
|
271
|
-
textAnchor={textAnchor as 'start' | 'middle' | 'end'}
|
|
272
|
-
dominantBaseline={dominantBaseline as 'text-before-edge' | 'middle' | 'text-after-edge'}
|
|
273
|
-
style={textStyles}
|
|
274
|
-
filter={filterId ? `url(#${filterId})` : undefined}
|
|
275
|
-
>
|
|
276
|
-
{lines.map((line, i) => (
|
|
277
|
-
<tspan key={i} x={x} dy={i === 0 ? 0 : lineHeightPx}>
|
|
278
|
-
{line || '\u00A0'} {/* Non-breaking space for empty lines */}
|
|
279
|
-
</tspan>
|
|
280
|
-
))}
|
|
281
|
-
</text>
|
|
282
|
-
</g>
|
|
283
|
-
);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* Rich-text path: render `content.html` as sanitized HTML inside a
|
|
288
|
-
* `<foreignObject>` so individual runs can carry their own formatting
|
|
289
|
-
* (bold/italic/links, and headings/lists for layout textboxes). The box
|
|
290
|
-
* geometry matches `PlainTextLayer`/`layerBounds`, so selection and
|
|
291
|
-
* drag/resize stay aligned. `content.text` remains the plain projection
|
|
292
|
-
* used by export/search.
|
|
293
|
-
*/
|
|
294
|
-
function RichTextLayer({ layer, viewport, blockTime }: TextLayerProps) {
|
|
295
|
-
const { content, position, animation } = layer;
|
|
296
|
-
const { html, style } = content;
|
|
297
|
-
|
|
298
|
-
const rawX = resolveValue(position.x, viewport.width);
|
|
299
|
-
const rawY = resolveValue(position.y, viewport.height);
|
|
300
|
-
const boxWidth =
|
|
301
|
-
position.width != null ? resolveValue(position.width, viewport.width) : viewport.width;
|
|
302
|
-
const boxHeight =
|
|
303
|
-
position.height != null
|
|
304
|
-
? resolveValue(position.height, viewport.height)
|
|
305
|
-
: style.fontSize * (style.lineHeight || 1.4) * 2;
|
|
306
|
-
const anchor = position.anchor ?? 'top-left';
|
|
307
|
-
const boxX = rawX - anchorAxis(anchor, boxWidth, 'x');
|
|
308
|
-
const boxY = rawY - anchorAxis(anchor, boxHeight, 'y');
|
|
309
|
-
|
|
310
|
-
// Re-sanitize at render time — `html` is untrusted. Memoized so the
|
|
311
|
-
// per-frame `blockTime` updates don't re-parse the HTML each frame.
|
|
312
|
-
const safeHtml = useMemo(
|
|
313
|
-
() => stringifyHtmlNodes(sanitizeHtmlNodes(parseHtmlToNodes(html ?? ''))),
|
|
314
|
-
[html],
|
|
315
|
-
);
|
|
316
|
-
|
|
317
|
-
const animStyle = getAnimationStyle(animation, blockTime);
|
|
318
|
-
|
|
319
|
-
const verticalJustify =
|
|
320
|
-
style.verticalAlign === 'middle'
|
|
321
|
-
? 'center'
|
|
322
|
-
: style.verticalAlign === 'bottom'
|
|
323
|
-
? 'flex-end'
|
|
324
|
-
: 'flex-start';
|
|
325
|
-
|
|
326
|
-
const hasBorder = !!(style.borderColor && (style.borderWidth ?? 0) > 0);
|
|
327
|
-
// Box decoration uses CSS (foreignObject is HTML); gradients/backgroundOpacity
|
|
328
|
-
// are deferred to the SVG path — solid background covers the common case.
|
|
329
|
-
const boxStyle: CSSProperties = {
|
|
330
|
-
boxSizing: 'border-box',
|
|
331
|
-
width: '100%',
|
|
332
|
-
height: '100%',
|
|
333
|
-
display: 'flex',
|
|
334
|
-
flexDirection: 'column',
|
|
335
|
-
justifyContent: verticalJustify,
|
|
336
|
-
padding: style.padding ?? 0,
|
|
337
|
-
color: style.color,
|
|
338
|
-
fontSize: `${style.fontSize}px`,
|
|
339
|
-
fontFamily: style.fontFamily || DEFAULT_DOC_FONT,
|
|
340
|
-
fontWeight: style.fontWeight || 'normal',
|
|
341
|
-
fontStyle: style.fontStyle || 'normal',
|
|
342
|
-
lineHeight: style.lineHeight || 1.4,
|
|
343
|
-
textAlign: style.textAlign ?? 'left',
|
|
344
|
-
overflow: 'hidden',
|
|
345
|
-
...(style.background ? { background: style.background } : {}),
|
|
346
|
-
...(hasBorder
|
|
347
|
-
? {
|
|
348
|
-
border: `${style.borderWidth}px ${style.borderStyle ?? 'solid'} ${style.borderColor}`,
|
|
349
|
-
borderRadius: 4,
|
|
350
|
-
}
|
|
351
|
-
: {}),
|
|
352
|
-
...(style.shadow ? { textShadow: '0 2px 3px rgba(0,0,0,0.7)' } : {}),
|
|
353
|
-
...animStyle.style,
|
|
354
|
-
};
|
|
355
|
-
|
|
356
|
-
// Scoped reset so authored block elements (headings, lists, paragraphs)
|
|
357
|
-
// render tightly inside the box rather than with the UA's large margins.
|
|
358
|
-
const cls = `squisq-rich-text-${cssId(layer.id)}`;
|
|
359
|
-
const scopedCss =
|
|
360
|
-
`.${cls}{margin:0}` +
|
|
361
|
-
`.${cls} p{margin:0 0 .4em}` +
|
|
362
|
-
`.${cls} h1,.${cls} h2,.${cls} h3,.${cls} h4,.${cls} h5,.${cls} h6{margin:0 0 .3em;line-height:1.2}` +
|
|
363
|
-
// `list-style-position: inside` keeps the bullet/number next to its text
|
|
364
|
-
// — with `outside` (the default) a centered or middle-aligned list leaves
|
|
365
|
-
// the marker stranded at the box's left padding, far from the text. The
|
|
366
|
-
// editor wraps each item's text in a `<p>`, so flatten that to inline or
|
|
367
|
-
// the block paragraph drops below the (inline) marker.
|
|
368
|
-
`.${cls} ul,.${cls} ol{margin:0 0 .4em;padding-left:1.2em;list-style-position:inside}` +
|
|
369
|
-
`.${cls} li{margin:0}` +
|
|
370
|
-
`.${cls} li>p{display:inline;margin:0}` +
|
|
371
|
-
`.${cls} *:first-child{margin-top:0}.${cls} *:last-child{margin-bottom:0}` +
|
|
372
|
-
`.${cls} a{color:inherit;text-decoration:underline}`;
|
|
373
|
-
|
|
374
|
-
return (
|
|
375
|
-
<g className={`block-layer block-layer--text ${animStyle.className}`} data-layer-id={layer.id}>
|
|
376
|
-
<foreignObject x={boxX} y={boxY} width={boxWidth} height={boxHeight}>
|
|
377
|
-
{/* xmlns is required for HTML inside SVG foreignObject (see TableLayer) */}
|
|
378
|
-
<div
|
|
379
|
-
{...({ xmlns: 'http://www.w3.org/1999/xhtml' } as Record<string, string>)}
|
|
380
|
-
style={boxStyle}
|
|
381
|
-
>
|
|
382
|
-
<style>{scopedCss}</style>
|
|
383
|
-
<div
|
|
384
|
-
className={cls}
|
|
385
|
-
aria-label={content.text}
|
|
386
|
-
style={{ width: '100%', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
|
387
|
-
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
|
388
|
-
/>
|
|
389
|
-
</div>
|
|
390
|
-
</foreignObject>
|
|
391
|
-
</g>
|
|
392
|
-
);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/** Sanitize a layer id into a CSS-class-safe suffix. */
|
|
396
|
-
function cssId(id: string): string {
|
|
397
|
-
return id.replace(/[^a-zA-Z0-9_-]/g, '-');
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
/**
|
|
401
|
-
* The text layer's background + border rect. Renders nothing unless a
|
|
402
|
-
* fill (solid or gradient) or a visible border is configured.
|
|
403
|
-
*/
|
|
404
|
-
function TextBox({
|
|
405
|
-
layerId,
|
|
406
|
-
style,
|
|
407
|
-
box,
|
|
408
|
-
}: {
|
|
409
|
-
layerId: string;
|
|
410
|
-
style: import('@bendyline/squisq/schemas').TextStyle;
|
|
411
|
-
box: { x: number; y: number; width: number; height: number };
|
|
412
|
-
}) {
|
|
413
|
-
const hasFill = !!(style.background || style.backgroundGradient);
|
|
414
|
-
const hasBorder = !!(style.borderColor && (style.borderWidth ?? 0) > 0);
|
|
415
|
-
if (!hasFill && !hasBorder) return null;
|
|
416
|
-
|
|
417
|
-
const { fill, def } = resolveFill(layerId, style.background, style.backgroundGradient);
|
|
418
|
-
const dash = borderDashArray(style.borderStyle, style.borderWidth);
|
|
419
|
-
return (
|
|
420
|
-
<>
|
|
421
|
-
{def && <defs>{def}</defs>}
|
|
422
|
-
<rect
|
|
423
|
-
x={box.x}
|
|
424
|
-
y={box.y}
|
|
425
|
-
width={box.width}
|
|
426
|
-
height={box.height}
|
|
427
|
-
fill={hasFill ? fill : 'none'}
|
|
428
|
-
fillOpacity={hasFill ? style.backgroundOpacity : undefined}
|
|
429
|
-
stroke={hasBorder ? style.borderColor : undefined}
|
|
430
|
-
strokeWidth={hasBorder ? style.borderWidth : undefined}
|
|
431
|
-
strokeDasharray={hasBorder ? dash : undefined}
|
|
432
|
-
rx={4}
|
|
433
|
-
ry={4}
|
|
434
|
-
/>
|
|
435
|
-
</>
|
|
436
|
-
);
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/**
|
|
440
|
-
* Map text alignment to SVG text-anchor.
|
|
441
|
-
*/
|
|
442
|
-
function getTextAnchor(align?: 'left' | 'center' | 'right', anchor?: string): string {
|
|
443
|
-
// Explicit alignment takes precedence
|
|
444
|
-
if (align === 'center') return 'middle';
|
|
445
|
-
if (align === 'right') return 'end';
|
|
446
|
-
if (align === 'left') return 'start';
|
|
447
|
-
|
|
448
|
-
// Otherwise infer from position anchor
|
|
449
|
-
if (anchor?.includes('right')) return 'end';
|
|
450
|
-
if (anchor === 'center') return 'middle';
|
|
451
|
-
return 'start';
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
/**
|
|
455
|
-
* Map vertical alignment (or, when unset, the position anchor) to SVG
|
|
456
|
-
* dominant-baseline. Explicit `verticalAlign` wins so the editor can set
|
|
457
|
-
* top/middle/bottom independently of the box-placement anchor.
|
|
458
|
-
*/
|
|
459
|
-
function getDominantBaseline(verticalAlign?: 'top' | 'middle' | 'bottom', anchor?: string): string {
|
|
460
|
-
if (verticalAlign === 'top') return 'text-before-edge';
|
|
461
|
-
if (verticalAlign === 'middle') return 'middle';
|
|
462
|
-
if (verticalAlign === 'bottom') return 'text-after-edge';
|
|
463
|
-
|
|
464
|
-
if (anchor?.includes('bottom')) return 'text-after-edge';
|
|
465
|
-
if (anchor === 'center') return 'middle';
|
|
466
|
-
return 'text-before-edge';
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
/**
|
|
470
|
-
* Horizontal pivot for the text. Derives the box's left edge from the
|
|
471
|
-
* anchor point (`rawX`) and the box width, then offsets to the edge/centre
|
|
472
|
-
* matching `textAnchor`. Falls back to the raw point when no width is set.
|
|
473
|
-
*/
|
|
474
|
-
function pivotX(
|
|
475
|
-
rawX: number,
|
|
476
|
-
width: number | undefined,
|
|
477
|
-
anchor: string,
|
|
478
|
-
textAnchor: string,
|
|
479
|
-
): number {
|
|
480
|
-
if (width == null) return rawX;
|
|
481
|
-
const boxLeft = rawX - anchorAxis(anchor, width, 'x');
|
|
482
|
-
if (textAnchor === 'middle') return boxLeft + width / 2;
|
|
483
|
-
if (textAnchor === 'end') return boxLeft + width;
|
|
484
|
-
return boxLeft;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
/**
|
|
488
|
-
* Vertical pivot for the text. Mirror of {@link pivotX} for the box's top
|
|
489
|
-
* edge and the resolved dominant-baseline. Falls back to the raw point
|
|
490
|
-
* when no height is set.
|
|
491
|
-
*/
|
|
492
|
-
function pivotY(
|
|
493
|
-
rawY: number,
|
|
494
|
-
height: number | undefined,
|
|
495
|
-
anchor: string,
|
|
496
|
-
dominantBaseline: string,
|
|
497
|
-
): number {
|
|
498
|
-
if (height == null) return rawY;
|
|
499
|
-
const boxTop = rawY - anchorAxis(anchor, height, 'y');
|
|
500
|
-
if (dominantBaseline === 'middle') return boxTop + height / 2;
|
|
501
|
-
if (dominantBaseline === 'text-after-edge') return boxTop + height;
|
|
502
|
-
return boxTop;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
/** Anchor-point offset along one axis, matching `layerBounds` in the editor. */
|
|
506
|
-
function anchorAxis(anchor: string, size: number, axis: 'x' | 'y'): number {
|
|
507
|
-
if (anchor === 'center') return size / 2;
|
|
508
|
-
if (axis === 'x') return anchor.includes('right') ? size : 0;
|
|
509
|
-
return anchor.includes('bottom') ? size : 0;
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
/**
|
|
513
|
-
* Estimate text box width based on content (rough approximation).
|
|
514
|
-
*/
|
|
515
|
-
function getTextBoxWidth(lines: string[], style: { fontSize: number }): number {
|
|
516
|
-
const maxLineLength = Math.max(...lines.map((l) => l.length));
|
|
517
|
-
// Rough estimate: average character width is ~0.5 * fontSize
|
|
518
|
-
return maxLineLength * style.fontSize * 0.55;
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
/**
|
|
522
|
-
* Wrap text to fit within a maximum width.
|
|
523
|
-
* Uses character-based estimation for line breaking.
|
|
524
|
-
*/
|
|
525
|
-
function wrapText(text: string, fontSize: number, maxWidth: number): string[] {
|
|
526
|
-
if (!text.trim()) return [''];
|
|
527
|
-
|
|
528
|
-
// Estimate characters per line (average char width ~0.5 * fontSize for most fonts)
|
|
529
|
-
const avgCharWidth = fontSize * 0.5;
|
|
530
|
-
const charsPerLine = Math.floor(maxWidth / avgCharWidth);
|
|
531
|
-
|
|
532
|
-
if (charsPerLine <= 0) return [text];
|
|
533
|
-
|
|
534
|
-
const words = text.split(/\s+/);
|
|
535
|
-
const lines: string[] = [];
|
|
536
|
-
let currentLine = '';
|
|
537
|
-
|
|
538
|
-
for (const word of words) {
|
|
539
|
-
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
|
540
|
-
|
|
541
|
-
if (testLine.length <= charsPerLine) {
|
|
542
|
-
currentLine = testLine;
|
|
543
|
-
} else {
|
|
544
|
-
// Current line is full, start new line
|
|
545
|
-
if (currentLine) {
|
|
546
|
-
lines.push(currentLine);
|
|
547
|
-
}
|
|
548
|
-
// Handle words longer than a line
|
|
549
|
-
if (word.length > charsPerLine) {
|
|
550
|
-
// Break long word
|
|
551
|
-
let remaining = word;
|
|
552
|
-
while (remaining.length > charsPerLine) {
|
|
553
|
-
lines.push(remaining.slice(0, charsPerLine));
|
|
554
|
-
remaining = remaining.slice(charsPerLine);
|
|
555
|
-
}
|
|
556
|
-
currentLine = remaining;
|
|
557
|
-
} else {
|
|
558
|
-
currentLine = word;
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
// Add remaining text
|
|
564
|
-
if (currentLine) {
|
|
565
|
-
lines.push(currentLine);
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
return lines.length > 0 ? lines : [''];
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
export default TextLayer;
|