@bendyline/squisq-react 1.3.1 → 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.
Files changed (215) hide show
  1. package/dist/index.d.ts +766 -35
  2. package/dist/index.js +4793 -33
  3. package/dist/index.js.map +1 -1
  4. package/dist/squisq-player.css +1 -1
  5. package/dist/squisq-player.css.map +1 -1
  6. package/dist/squisq-player.global.js +17 -13
  7. package/dist/squisq-player.global.js.map +1 -1
  8. package/dist/standalone-source.js +1 -1
  9. package/package.json +3 -2
  10. package/src/BlockRenderer.tsx +15 -7
  11. package/src/DocPlayer.tsx +65 -12
  12. package/src/DocProgressBar.tsx +21 -3
  13. package/src/LinearDocView.tsx +11 -197
  14. package/src/MarkdownRenderer.tsx +165 -41
  15. package/src/MediaClipLayer.tsx +135 -0
  16. package/src/__tests__/DocPlayer.test.tsx +51 -0
  17. package/src/__tests__/DocProgressBar.test.tsx +76 -0
  18. package/src/__tests__/MarkdownRenderer.test.tsx +95 -1
  19. package/src/__tests__/PathLayer.test.tsx +73 -0
  20. package/src/__tests__/fillStyle.test.tsx +112 -0
  21. package/src/__tests__/transitionStyles.test.ts +125 -0
  22. package/src/__tests__/useDocPlayback.transition.test.ts +70 -0
  23. package/src/hooks/useAudioSync.ts +14 -1
  24. package/src/hooks/useDocPlayback.ts +81 -100
  25. package/src/hooks/useMediaSchedule.ts +39 -0
  26. package/src/index.ts +7 -0
  27. package/src/layers/ImageLayer.tsx +11 -1
  28. package/src/layers/PathLayer.tsx +146 -0
  29. package/src/layers/ShapeLayer.tsx +27 -5
  30. package/src/layers/TextLayer.tsx +395 -22
  31. package/src/layers/VideoLayer.tsx +16 -9
  32. package/src/layers/index.ts +1 -0
  33. package/src/styles/doc-animations.css +1857 -2
  34. package/src/utils/fillStyle.tsx +148 -0
  35. package/dist/BlockRenderer.d.ts +0 -37
  36. package/dist/BlockRenderer.d.ts.map +0 -1
  37. package/dist/BlockRenderer.js +0 -54
  38. package/dist/BlockRenderer.js.map +0 -1
  39. package/dist/CaptionOverlay.d.ts +0 -33
  40. package/dist/CaptionOverlay.d.ts.map +0 -1
  41. package/dist/CaptionOverlay.js +0 -44
  42. package/dist/CaptionOverlay.js.map +0 -1
  43. package/dist/DocControlsBottom.d.ts +0 -28
  44. package/dist/DocControlsBottom.d.ts.map +0 -1
  45. package/dist/DocControlsBottom.js +0 -27
  46. package/dist/DocControlsBottom.js.map +0 -1
  47. package/dist/DocControlsOverlay.d.ts +0 -28
  48. package/dist/DocControlsOverlay.d.ts.map +0 -1
  49. package/dist/DocControlsOverlay.js +0 -80
  50. package/dist/DocControlsOverlay.js.map +0 -1
  51. package/dist/DocControlsSidebar.d.ts +0 -27
  52. package/dist/DocControlsSidebar.d.ts.map +0 -1
  53. package/dist/DocControlsSidebar.js +0 -10
  54. package/dist/DocControlsSidebar.js.map +0 -1
  55. package/dist/DocControlsSlideshow.d.ts +0 -20
  56. package/dist/DocControlsSlideshow.d.ts.map +0 -1
  57. package/dist/DocControlsSlideshow.js +0 -69
  58. package/dist/DocControlsSlideshow.js.map +0 -1
  59. package/dist/DocPlayer.d.ts +0 -97
  60. package/dist/DocPlayer.d.ts.map +0 -1
  61. package/dist/DocPlayer.js +0 -657
  62. package/dist/DocPlayer.js.map +0 -1
  63. package/dist/DocPlayerWithSidebar.d.ts +0 -41
  64. package/dist/DocPlayerWithSidebar.d.ts.map +0 -1
  65. package/dist/DocPlayerWithSidebar.js +0 -69
  66. package/dist/DocPlayerWithSidebar.js.map +0 -1
  67. package/dist/DocProgressBar.d.ts +0 -31
  68. package/dist/DocProgressBar.d.ts.map +0 -1
  69. package/dist/DocProgressBar.js +0 -122
  70. package/dist/DocProgressBar.js.map +0 -1
  71. package/dist/InlineAudioPlayer.d.ts +0 -15
  72. package/dist/InlineAudioPlayer.d.ts.map +0 -1
  73. package/dist/InlineAudioPlayer.js +0 -22
  74. package/dist/InlineAudioPlayer.js.map +0 -1
  75. package/dist/InlineVideoPlayer.d.ts +0 -21
  76. package/dist/InlineVideoPlayer.d.ts.map +0 -1
  77. package/dist/InlineVideoPlayer.js +0 -29
  78. package/dist/InlineVideoPlayer.js.map +0 -1
  79. package/dist/LinearDocView.d.ts +0 -73
  80. package/dist/LinearDocView.d.ts.map +0 -1
  81. package/dist/LinearDocView.js +0 -451
  82. package/dist/LinearDocView.js.map +0 -1
  83. package/dist/MarkdownRenderer.d.ts +0 -32
  84. package/dist/MarkdownRenderer.d.ts.map +0 -1
  85. package/dist/MarkdownRenderer.js +0 -259
  86. package/dist/MarkdownRenderer.js.map +0 -1
  87. package/dist/SocialCaptionOverlay.d.ts +0 -27
  88. package/dist/SocialCaptionOverlay.d.ts.map +0 -1
  89. package/dist/SocialCaptionOverlay.js +0 -185
  90. package/dist/SocialCaptionOverlay.js.map +0 -1
  91. package/dist/__tests__/BlockRenderer.test.d.ts +0 -2
  92. package/dist/__tests__/BlockRenderer.test.d.ts.map +0 -1
  93. package/dist/__tests__/BlockRenderer.test.js +0 -82
  94. package/dist/__tests__/BlockRenderer.test.js.map +0 -1
  95. package/dist/__tests__/DocControlsSlideshow.test.d.ts +0 -2
  96. package/dist/__tests__/DocControlsSlideshow.test.d.ts.map +0 -1
  97. package/dist/__tests__/DocControlsSlideshow.test.js +0 -89
  98. package/dist/__tests__/DocControlsSlideshow.test.js.map +0 -1
  99. package/dist/__tests__/JsonView.test.d.ts +0 -2
  100. package/dist/__tests__/JsonView.test.d.ts.map +0 -1
  101. package/dist/__tests__/JsonView.test.js +0 -93
  102. package/dist/__tests__/JsonView.test.js.map +0 -1
  103. package/dist/__tests__/LinearDocView.test.d.ts +0 -2
  104. package/dist/__tests__/LinearDocView.test.d.ts.map +0 -1
  105. package/dist/__tests__/LinearDocView.test.js +0 -250
  106. package/dist/__tests__/LinearDocView.test.js.map +0 -1
  107. package/dist/__tests__/MarkdownRenderer.test.d.ts +0 -2
  108. package/dist/__tests__/MarkdownRenderer.test.d.ts.map +0 -1
  109. package/dist/__tests__/MarkdownRenderer.test.js +0 -208
  110. package/dist/__tests__/MarkdownRenderer.test.js.map +0 -1
  111. package/dist/__tests__/TableLayer.test.d.ts +0 -2
  112. package/dist/__tests__/TableLayer.test.d.ts.map +0 -1
  113. package/dist/__tests__/TableLayer.test.js +0 -121
  114. package/dist/__tests__/TableLayer.test.js.map +0 -1
  115. package/dist/__tests__/exports.test.d.ts +0 -2
  116. package/dist/__tests__/exports.test.d.ts.map +0 -1
  117. package/dist/__tests__/exports.test.js +0 -48
  118. package/dist/__tests__/exports.test.js.map +0 -1
  119. package/dist/hooks/AudioProvider.d.ts +0 -71
  120. package/dist/hooks/AudioProvider.d.ts.map +0 -1
  121. package/dist/hooks/AudioProvider.js +0 -52
  122. package/dist/hooks/AudioProvider.js.map +0 -1
  123. package/dist/hooks/MediaContext.d.ts +0 -35
  124. package/dist/hooks/MediaContext.d.ts.map +0 -1
  125. package/dist/hooks/MediaContext.js +0 -72
  126. package/dist/hooks/MediaContext.js.map +0 -1
  127. package/dist/hooks/index.d.ts +0 -6
  128. package/dist/hooks/index.d.ts.map +0 -1
  129. package/dist/hooks/index.js +0 -5
  130. package/dist/hooks/index.js.map +0 -1
  131. package/dist/hooks/useAudioSync.d.ts +0 -18
  132. package/dist/hooks/useAudioSync.d.ts.map +0 -1
  133. package/dist/hooks/useAudioSync.js +0 -340
  134. package/dist/hooks/useAudioSync.js.map +0 -1
  135. package/dist/hooks/useAutoSurface.d.ts +0 -11
  136. package/dist/hooks/useAutoSurface.d.ts.map +0 -1
  137. package/dist/hooks/useAutoSurface.js +0 -24
  138. package/dist/hooks/useAutoSurface.js.map +0 -1
  139. package/dist/hooks/useDocPlayback.d.ts +0 -47
  140. package/dist/hooks/useDocPlayback.d.ts.map +0 -1
  141. package/dist/hooks/useDocPlayback.js +0 -179
  142. package/dist/hooks/useDocPlayback.js.map +0 -1
  143. package/dist/hooks/useViewportOrientation.d.ts +0 -31
  144. package/dist/hooks/useViewportOrientation.d.ts.map +0 -1
  145. package/dist/hooks/useViewportOrientation.js +0 -92
  146. package/dist/hooks/useViewportOrientation.js.map +0 -1
  147. package/dist/index.d.ts.map +0 -1
  148. package/dist/jsonView/JsonView.d.ts +0 -26
  149. package/dist/jsonView/JsonView.d.ts.map +0 -1
  150. package/dist/jsonView/JsonView.js +0 -12
  151. package/dist/jsonView/JsonView.js.map +0 -1
  152. package/dist/jsonView/RenderNode.d.ts +0 -22
  153. package/dist/jsonView/RenderNode.d.ts.map +0 -1
  154. package/dist/jsonView/RenderNode.js +0 -30
  155. package/dist/jsonView/RenderNode.js.map +0 -1
  156. package/dist/jsonView/index.d.ts +0 -3
  157. package/dist/jsonView/index.d.ts.map +0 -1
  158. package/dist/jsonView/index.js +0 -2
  159. package/dist/jsonView/index.js.map +0 -1
  160. package/dist/jsonView/useJsonViewTokens.d.ts +0 -14
  161. package/dist/jsonView/useJsonViewTokens.d.ts.map +0 -1
  162. package/dist/jsonView/useJsonViewTokens.js +0 -34
  163. package/dist/jsonView/useJsonViewTokens.js.map +0 -1
  164. package/dist/jsonView/viewers.d.ts +0 -30
  165. package/dist/jsonView/viewers.d.ts.map +0 -1
  166. package/dist/jsonView/viewers.js +0 -226
  167. package/dist/jsonView/viewers.js.map +0 -1
  168. package/dist/layers/ImageLayer.d.ts +0 -23
  169. package/dist/layers/ImageLayer.d.ts.map +0 -1
  170. package/dist/layers/ImageLayer.js +0 -104
  171. package/dist/layers/ImageLayer.js.map +0 -1
  172. package/dist/layers/MapLayer.d.ts +0 -29
  173. package/dist/layers/MapLayer.d.ts.map +0 -1
  174. package/dist/layers/MapLayer.js +0 -97
  175. package/dist/layers/MapLayer.js.map +0 -1
  176. package/dist/layers/ShapeLayer.d.ts +0 -20
  177. package/dist/layers/ShapeLayer.d.ts.map +0 -1
  178. package/dist/layers/ShapeLayer.js +0 -39
  179. package/dist/layers/ShapeLayer.js.map +0 -1
  180. package/dist/layers/TableLayer.d.ts +0 -23
  181. package/dist/layers/TableLayer.d.ts.map +0 -1
  182. package/dist/layers/TableLayer.js +0 -58
  183. package/dist/layers/TableLayer.js.map +0 -1
  184. package/dist/layers/TextLayer.d.ts +0 -23
  185. package/dist/layers/TextLayer.d.ts.map +0 -1
  186. package/dist/layers/TextLayer.js +0 -126
  187. package/dist/layers/TextLayer.js.map +0 -1
  188. package/dist/layers/VideoLayer.d.ts +0 -40
  189. package/dist/layers/VideoLayer.d.ts.map +0 -1
  190. package/dist/layers/VideoLayer.js +0 -105
  191. package/dist/layers/VideoLayer.js.map +0 -1
  192. package/dist/layers/index.d.ts +0 -6
  193. package/dist/layers/index.d.ts.map +0 -1
  194. package/dist/layers/index.js +0 -6
  195. package/dist/layers/index.js.map +0 -1
  196. package/dist/standalone-entry.d.ts +0 -69
  197. package/dist/standalone-entry.d.ts.map +0 -1
  198. package/dist/standalone-entry.js +0 -185
  199. package/dist/standalone-entry.js.map +0 -1
  200. package/dist/types.d.ts +0 -154
  201. package/dist/types.d.ts.map +0 -1
  202. package/dist/types.js +0 -21
  203. package/dist/types.js.map +0 -1
  204. package/dist/utils/animationUtils.d.ts +0 -8
  205. package/dist/utils/animationUtils.d.ts.map +0 -1
  206. package/dist/utils/animationUtils.js +0 -8
  207. package/dist/utils/animationUtils.js.map +0 -1
  208. package/dist/utils/layerUtils.d.ts +0 -17
  209. package/dist/utils/layerUtils.d.ts.map +0 -1
  210. package/dist/utils/layerUtils.js +0 -37
  211. package/dist/utils/layerUtils.js.map +0 -1
  212. package/dist/utils/mapTileUtils.d.ts +0 -105
  213. package/dist/utils/mapTileUtils.d.ts.map +0 -1
  214. package/dist/utils/mapTileUtils.js +0 -268
  215. package/dist/utils/mapTileUtils.js.map +0 -1
@@ -5,13 +5,36 @@
5
5
  * styling options (font, color, shadow), and animations like fadeIn
6
6
  * and typewriter effects.
7
7
  *
8
- * Text is rendered using SVG <text> elements with <tspan> for line breaks.
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
- export function TextLayer({ layer, viewport, blockTime }: TextLayerProps) {
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) {
25
86
  const { content, position, animation } = layer;
26
87
  const { text, style } = content;
27
88
 
28
- // Resolve position values to pixels
29
- const x = resolveValue(position.x, viewport.width);
30
- const y = resolveValue(position.y, viewport.height);
31
- const maxWidth = position.width ? resolveValue(position.width, viewport.width) : undefined;
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 if specified */}
83
- {style.background && (
84
- <rect
85
- x={x - (style.padding || 16)}
86
- y={y - style.fontSize - (style.padding || 16)}
87
- width={getTextBoxWidth(lines, style) + (style.padding || 16) * 2}
88
- height={lines.length * lineHeightPx + (style.padding || 16) * 2}
89
- fill={style.background}
90
- rx={4}
91
- ry={4}
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 dominant-baseline.
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`,
@@ -1,5 +1,6 @@
1
1
  export { ImageLayer } from './ImageLayer';
2
2
  export { TextLayer } from './TextLayer';
3
3
  export { ShapeLayer } from './ShapeLayer';
4
+ export { PathLayer } from './PathLayer';
4
5
  export { VideoLayer } from './VideoLayer';
5
6
  export { MapLayer } from './MapLayer';