@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.
Files changed (87) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +20 -0
  3. package/README.md +6 -0
  4. package/dist/index.d.ts +101 -96
  5. package/dist/index.js +1483 -782
  6. package/dist/squisq-player.full.global.js +4165 -0
  7. package/dist/squisq-player.global.js +561 -168
  8. package/dist/standalone-source.d.ts +1 -0
  9. package/dist/standalone-source.js +1 -1
  10. package/dist/styles/index.css +64 -0
  11. package/package.json +8 -4
  12. package/dist/index.js.map +0 -1
  13. package/dist/squisq-player.css +0 -2
  14. package/dist/squisq-player.css.map +0 -1
  15. package/dist/squisq-player.global.js.map +0 -1
  16. package/src/BlockRenderer.tsx +0 -194
  17. package/src/CaptionOverlay.tsx +0 -111
  18. package/src/DocControlsBottom.tsx +0 -109
  19. package/src/DocControlsOverlay.tsx +0 -190
  20. package/src/DocControlsSidebar.tsx +0 -113
  21. package/src/DocControlsSlideshow.tsx +0 -360
  22. package/src/DocPlayer.tsx +0 -1528
  23. package/src/DocPlayerWithSidebar.tsx +0 -155
  24. package/src/DocProgressBar.tsx +0 -257
  25. package/src/InlineAudioPlayer.tsx +0 -46
  26. package/src/InlineVideoPlayer.tsx +0 -70
  27. package/src/LinearDocView.tsx +0 -569
  28. package/src/MarkdownRenderer.tsx +0 -624
  29. package/src/MediaClipLayer.tsx +0 -145
  30. package/src/SocialCaptionOverlay.tsx +0 -255
  31. package/src/__tests__/BlockRenderer.test.tsx +0 -235
  32. package/src/__tests__/DocControlsSlideshow.test.tsx +0 -221
  33. package/src/__tests__/DocPlayer.test.tsx +0 -635
  34. package/src/__tests__/DocPlayerStylesSentinel.test.tsx +0 -41
  35. package/src/__tests__/DocProgressBar.test.tsx +0 -102
  36. package/src/__tests__/JsonView.test.tsx +0 -111
  37. package/src/__tests__/LinearDocView.test.tsx +0 -426
  38. package/src/__tests__/MapLayer.test.tsx +0 -63
  39. package/src/__tests__/MarkdownRenderer.test.tsx +0 -357
  40. package/src/__tests__/MediaClipLayer.test.tsx +0 -70
  41. package/src/__tests__/MediaContext.test.tsx +0 -51
  42. package/src/__tests__/PathLayer.test.tsx +0 -84
  43. package/src/__tests__/TableLayer.test.tsx +0 -142
  44. package/src/__tests__/VideoLayer.test.tsx +0 -94
  45. package/src/__tests__/exports.test.ts +0 -55
  46. package/src/__tests__/fillStyle.test.tsx +0 -160
  47. package/src/__tests__/standaloneEntry.test.tsx +0 -103
  48. package/src/__tests__/transitionStyles.test.ts +0 -125
  49. package/src/__tests__/useAudioSync.test.ts +0 -49
  50. package/src/__tests__/useDocPlayback.transition.test.ts +0 -113
  51. package/src/__tests__/useJsonViewTokens.test.ts +0 -41
  52. package/src/__tests__/useSlideSwipe.test.ts +0 -81
  53. package/src/__tests__/useViewportOrientation.test.ts +0 -22
  54. package/src/hooks/AudioController.ts +0 -114
  55. package/src/hooks/MediaContext.tsx +0 -97
  56. package/src/hooks/index.ts +0 -11
  57. package/src/hooks/useAudioSync.ts +0 -456
  58. package/src/hooks/useAutoSurface.ts +0 -33
  59. package/src/hooks/useDocPlayback.ts +0 -265
  60. package/src/hooks/useMediaSchedule.ts +0 -39
  61. package/src/hooks/useSlideSwipe.ts +0 -265
  62. package/src/hooks/useViewportOrientation.ts +0 -115
  63. package/src/index.ts +0 -70
  64. package/src/jsonView/JsonView.tsx +0 -51
  65. package/src/jsonView/RenderNode.tsx +0 -51
  66. package/src/jsonView/index.ts +0 -2
  67. package/src/jsonView/json-view.css +0 -206
  68. package/src/jsonView/useJsonViewTokens.ts +0 -32
  69. package/src/jsonView/viewers.tsx +0 -343
  70. package/src/layers/ImageLayer.tsx +0 -297
  71. package/src/layers/MapLayer.tsx +0 -185
  72. package/src/layers/PathLayer.tsx +0 -155
  73. package/src/layers/ShapeLayer.tsx +0 -158
  74. package/src/layers/TableLayer.tsx +0 -129
  75. package/src/layers/TextLayer.tsx +0 -571
  76. package/src/layers/TreeLayer.tsx +0 -167
  77. package/src/layers/VideoLayer.tsx +0 -171
  78. package/src/layers/index.ts +0 -6
  79. package/src/standalone-entry.tsx +0 -337
  80. package/src/standalone-source.d.ts +0 -10
  81. package/src/styles/doc-animations.css +0 -2431
  82. package/src/styles/index.css +0 -7
  83. package/src/types.ts +0 -183
  84. package/src/utils/animationUtils.ts +0 -13
  85. package/src/utils/fillStyle.tsx +0 -148
  86. package/src/utils/layerUtils.ts +0 -42
  87. package/src/utils/mapTileUtils.ts +0 -375
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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;