@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
@@ -12,14 +12,20 @@
12
12
  * - Automatic expansion of template blocks
13
13
  */
14
14
 
15
- import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
15
+ import { useMemo, useCallback, useRef } from 'react';
16
16
  import type { Doc, Block, DocBlock } from '@bendyline/squisq/schemas';
17
17
  import type { Theme } from '@bendyline/squisq/schemas';
18
- import { getBlockAtTime } from '@bendyline/squisq/schemas';
18
+ import {
19
+ DEFAULT_THEME,
20
+ getBlockAtTime,
21
+ resolveBlockTransition,
22
+ resolveTransitionDuration,
23
+ } from '@bendyline/squisq/schemas';
19
24
  import {
20
25
  expandDocBlocks,
21
- flattenBlocks,
26
+ flattenRenderableBlocks,
22
27
  isTemplateBlock,
28
+ resolvePersistentLayers,
23
29
  VIEWPORT_PRESETS,
24
30
  type ViewportConfig,
25
31
  } from '@bendyline/squisq/doc';
@@ -61,29 +67,34 @@ export function useDocPlayback(
61
67
  renderMode: boolean = false,
62
68
  theme?: Theme,
63
69
  ): PlaybackState & PlaybackActions {
64
- const [transitionState, setTransitionState] = useState<{
65
- entering: boolean;
66
- exiting: boolean;
67
- previousBlock: Block | null;
68
- }>({
69
- entering: false,
70
- exiting: false,
71
- previousBlock: null,
72
- });
73
-
70
+ // `renderMode` is retained for API/signature compatibility; block transitions
71
+ // are now computed identically for real-time and render (export) modes.
72
+ void renderMode;
74
73
  // Expand any template blocks into full blocks
75
74
  const blocks = useMemo(() => {
76
75
  if (!script?.blocks) {
77
76
  return [];
78
77
  }
79
78
 
80
- // Flatten nested block hierarchy (markdown-derived docs have children)
79
+ // Flatten nested block hierarchy (markdown-derived docs have children).
80
+ // `flattenRenderableBlocks` skips the children of container templates
81
+ // (`diagram`, `drawing`) — those are consumed by the parent's render as
82
+ // nodes/shapes, so they must not also appear as their own slides.
81
83
  const hasChildren = script.blocks.some((b) => b.children && b.children.length > 0);
82
- const flatBlocks = hasChildren ? flattenBlocks(script.blocks) : script.blocks;
84
+ const flatBlocks = hasChildren ? flattenRenderableBlocks(script.blocks) : script.blocks;
83
85
 
84
86
  // Check if any blocks are templates
85
87
  const hasTemplates = flatBlocks.some(isTemplateBlock);
86
88
 
89
+ // Doc persistent layers win wholesale; docs without any inherit the
90
+ // theme's (see resolvePersistentLayers). Passed as a narrow object so
91
+ // the memo deps stay field-precise.
92
+ const resolvedTheme = theme ?? DEFAULT_THEME;
93
+ const persistentLayers = resolvePersistentLayers(
94
+ { persistentLayers: script.persistentLayers },
95
+ resolvedTheme,
96
+ );
97
+
87
98
  if (hasTemplates) {
88
99
  // Extract audio segment timing for proper block synchronization
89
100
  const audioSegments = script.audio?.segments?.map((seg) => ({
@@ -95,15 +106,31 @@ export function useDocPlayback(
95
106
  const expanded = expandDocBlocks(flatBlocks as DocBlock[], {
96
107
  audioSegments,
97
108
  viewport,
98
- persistentLayers: script.persistentLayers,
109
+ persistentLayers,
99
110
  theme,
111
+ // Custom (user-defined) templates inlined into the doc's
112
+ // frontmatter — see CustomTemplates.ts. Merged onto the
113
+ // built-in registry so blocks annotated with `{[myhero]}`
114
+ // resolve through the user's design.
115
+ customTemplates: script.customTemplates,
100
116
  });
101
117
  return expanded;
102
118
  }
103
119
 
104
- // All raw blocks, use as-is
105
- return flatBlocks;
106
- }, [script?.blocks, script?.audio?.segments, script?.persistentLayers, viewport, theme]);
120
+ // All raw blocks used as-is except for the theme's default transition
121
+ // fallback (copies, never mutations: these blocks are caller-owned).
122
+ return flatBlocks.map((block, index) => {
123
+ const transition = resolveBlockTransition(block, resolvedTheme, index);
124
+ return transition !== block.transition ? { ...block, transition } : block;
125
+ });
126
+ }, [
127
+ script?.blocks,
128
+ script?.audio?.segments,
129
+ script?.persistentLayers,
130
+ script?.customTemplates,
131
+ viewport,
132
+ theme,
133
+ ]);
107
134
 
108
135
  // Find current block based on time
109
136
  const currentBlock = useMemo(() => getBlockAtTime(blocks, currentTime), [blocks, currentTime]);
@@ -130,78 +157,38 @@ export function useDocPlayback(
130
157
  return Math.min(1, currentTime / script.duration);
131
158
  }, [script, currentTime]);
132
159
 
133
- // Track block transitions.
134
- // In render mode, transitions are computed from blockTime so they stay
135
- // synchronized with the seekTo timeline. In normal mode, setTimeout drives
136
- // transitions at the browser's real-time clock speed.
137
- const _prevBlockRef = useMemo(
138
- () => currentBlock,
139
- // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally keyed on index, not block reference
140
- [currentBlockIndex],
141
- );
142
-
143
- useEffect(() => {
144
- if (!currentBlock || renderMode) return;
145
-
146
- // When block changes, trigger transition (real-time mode only)
147
- if (transitionState.previousBlock?.id !== currentBlock.id) {
148
- const transition = currentBlock.transition;
149
- const transitionDuration = transition?.duration || 0;
150
-
151
- if (transitionDuration > 0) {
152
- // Start transition
153
- setTransitionState({
154
- entering: true,
155
- exiting: true,
156
- previousBlock: transitionState.previousBlock,
157
- });
158
-
159
- // End transition after duration
160
- const timer = setTimeout(() => {
161
- setTransitionState({
162
- entering: false,
163
- exiting: false,
164
- previousBlock: currentBlock,
165
- });
166
- }, transitionDuration * 1000);
167
-
168
- return () => clearTimeout(timer);
169
- } else {
170
- // Instant cut
171
- setTransitionState({
172
- entering: false,
173
- exiting: false,
174
- previousBlock: currentBlock,
175
- });
176
- }
177
- }
178
- // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally keyed on currentBlock?.id only; reading transitionState.previousBlock without dep to avoid infinite loop
179
- }, [currentBlock?.id, renderMode]);
180
-
181
- // Render mode: track previous block via ref and compute transition from time
182
- const renderPrevBlockRef = useRef<Block | null>(null);
183
-
184
- useEffect(() => {
185
- if (!renderMode || !currentBlock) return;
186
-
187
- if (transitionState.previousBlock?.id !== currentBlock.id) {
188
- // Block changed — remember the old block for crossfade
189
- const oldPrev = transitionState.previousBlock;
190
- renderPrevBlockRef.current = oldPrev;
191
- // Store current block as the "last seen" for next transition
192
- setTransitionState((prev) => ({
193
- ...prev,
194
- previousBlock: currentBlock,
195
- }));
196
- }
197
- // eslint-disable-next-line react-hooks/exhaustive-deps -- same pattern: keyed on block identity change
198
- }, [currentBlock?.id, renderMode]);
199
-
200
- // In render mode, derive entering/exiting from blockTime
201
- const renderTransitionDuration = currentBlock?.transition?.duration || 0;
202
- const renderIsEntering =
203
- renderMode && renderTransitionDuration > 0 && blockTime < renderTransitionDuration;
204
- const renderIsExiting = renderIsEntering && renderPrevBlockRef.current !== null;
160
+ // ─── Transition tracking (synchronous — no effect lag) ──────────────
161
+ // `isEntering` is simply "we are within the block's entrance window"
162
+ // (blockTime < the transition's duration). Deriving it during render
163
+ // rather than flipping it in an effect a frame AFTER the block changes
164
+ // means a newly-active block renders WITH its entrance state on its very
165
+ // first frame. Otherwise the block paints once fully settled and then, a
166
+ // frame later, snaps back to the start of its entrance animation: the brief
167
+ // "flash then re-animate" seen between blocks. This runs identically for
168
+ // real-time playback and frame-seeked render (export) mode.
169
+ //
170
+ // The block we transitioned FROM (for the crossfade) is tracked with refs
171
+ // updated during render — the standard "previous value" pattern — so the
172
+ // outgoing block is known on the SAME frame the new block becomes active
173
+ // (an effect would lag a frame and drop the crossfade's first frames).
174
+ const outgoingBlockRef = useRef<Block | null>(null);
175
+ const activeBlockIdRef = useRef<string | null>(null);
176
+ const lastRenderedBlockRef = useRef<Block | null>(null);
177
+ if (currentBlock && currentBlock.id !== activeBlockIdRef.current) {
178
+ outgoingBlockRef.current = lastRenderedBlockRef.current;
179
+ activeBlockIdRef.current = currentBlock.id;
180
+ }
181
+ lastRenderedBlockRef.current = currentBlock;
182
+
183
+ const transitionDuration = currentBlock?.transition
184
+ ? resolveTransitionDuration(currentBlock.transition)
185
+ : 0;
186
+ const isEntering = !!currentBlock && transitionDuration > 0 && blockTime < transitionDuration;
187
+ const outgoingBlock = outgoingBlockRef.current;
188
+ // Only crossfade a genuinely different outgoing block (guards restarts/seeks
189
+ // where the "previous" resolves to the same block).
190
+ const isExiting = isEntering && outgoingBlock != null && outgoingBlock.id !== currentBlock?.id;
191
+ const previousBlock = isExiting ? outgoingBlock : null;
205
192
 
206
193
  // Manual navigation
207
194
  const goToBlock = useCallback(
@@ -233,15 +220,9 @@ export function useDocPlayback(
233
220
  return {
234
221
  currentBlock,
235
222
  currentBlockIndex,
236
- previousBlock: renderMode
237
- ? renderIsExiting
238
- ? renderPrevBlockRef.current
239
- : null
240
- : transitionState.exiting
241
- ? transitionState.previousBlock
242
- : null,
243
- isEntering: renderMode ? renderIsEntering : transitionState.entering,
244
- isExiting: renderMode ? renderIsExiting : transitionState.exiting,
223
+ previousBlock,
224
+ isEntering,
225
+ isExiting,
245
226
  blockTime,
246
227
  blockProgress,
247
228
  docProgress,
@@ -0,0 +1,39 @@
1
+ /**
2
+ * useMediaSchedule
3
+ *
4
+ * Pure follower of the playback clock for the media-clip model. Given the
5
+ * resolved {@link ScheduledClip}s and the current time, it returns the clips
6
+ * the player should mount and which of them are active right now.
7
+ * {@link MediaClipLayer} consumes this to drive one hidden `<audio>` /
8
+ * full-bleed `<video>` element per clip. (Annotation-authored clips all render
9
+ * at the player level; template-produced `VideoLayer`s are a separate path and
10
+ * are not part of the schedule.)
11
+ *
12
+ * It owns no clock: `currentTime`/`isPlaying` come from the existing
13
+ * `useAudioSync` provider via `DocPlayer`. With an empty schedule it returns
14
+ * empty lists, so documents without the new media model are unaffected.
15
+ */
16
+
17
+ import { useMemo } from 'react';
18
+ import type { ScheduledClip } from '@bendyline/squisq/schemas';
19
+
20
+ export interface MediaScheduleController {
21
+ /** Clips the player mounts (every scheduled clip). */
22
+ renderClips: ScheduledClip[];
23
+ /** Ids of clips whose [absoluteStart, absoluteEnd) contains currentTime. */
24
+ activeIds: Set<string>;
25
+ }
26
+
27
+ export function useMediaSchedule(
28
+ schedule: ScheduledClip[],
29
+ currentTime: number,
30
+ ): MediaScheduleController {
31
+ const activeIds = useMemo(() => {
32
+ const ids = new Set<string>();
33
+ for (const c of schedule) {
34
+ if (currentTime >= c.absoluteStart && currentTime < c.absoluteEnd) ids.add(c.id);
35
+ }
36
+ return ids;
37
+ }, [schedule, currentTime]);
38
+ return { renderClips: schedule, activeIds };
39
+ }
package/src/index.ts CHANGED
@@ -21,12 +21,19 @@ export type { InlineAudioPlayerProps } from './InlineAudioPlayer.js';
21
21
  export { ImageLayer } from './layers/ImageLayer.js';
22
22
  export { TextLayer } from './layers/TextLayer.js';
23
23
  export { ShapeLayer } from './layers/ShapeLayer.js';
24
+ export { PathLayer } from './layers/PathLayer.js';
24
25
  export { VideoLayer } from './layers/VideoLayer.js';
25
26
  export { TableLayer } from './layers/TableLayer.js';
26
27
  export { MapLayer } from './layers/MapLayer.js';
27
28
 
29
+ // Timed media clips (block.media + doc.documentMedia playback)
30
+ export { MediaClipLayer } from './MediaClipLayer.js';
31
+ export type { MediaClipLayerProps } from './MediaClipLayer.js';
32
+
28
33
  // Hooks
29
34
  export { useAudioSync } from './hooks/useAudioSync.js';
35
+ export { useMediaSchedule } from './hooks/useMediaSchedule.js';
36
+ export type { MediaScheduleController } from './hooks/useMediaSchedule.js';
30
37
  export { useDocPlayback } from './hooks/useDocPlayback.js';
31
38
  export { useViewportOrientation } from './hooks/useViewportOrientation.js';
32
39
  export { MediaContext, useMediaProvider, useMediaUrl } from './hooks/MediaContext.js';
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { ImageLayer as ImageLayerType, Animation } from '@bendyline/squisq/schemas';
10
+ import { cssFilterForTreatment } from '@bendyline/squisq/doc';
10
11
  import { getAnimationStyle } from '../utils/animationUtils';
11
12
  import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
12
13
  import { useMediaUrl } from '../hooks/MediaContext';
@@ -41,6 +42,10 @@ export function ImageLayer({ layer, basePath, viewport, blockTime }: ImageLayerP
41
42
  // Get animation styles
42
43
  const animStyle = getAnimationStyle(animation, blockTime);
43
44
 
45
+ // Theme-derived photographic grade + optional blur, as a CSS filter
46
+ // string (identical in the player and in headless frame capture).
47
+ const filter = cssFilterForTreatment(content.treatment, content.blur);
48
+
44
49
  // SVG preserveAspectRatio based on fit mode
45
50
  const preserveAspectRatio = getPreserveAspectRatio(content.fit);
46
51
  const isCover = content.fit === 'cover';
@@ -78,6 +83,7 @@ export function ImageLayer({ layer, basePath, viewport, blockTime }: ImageLayerP
78
83
  display: 'block',
79
84
  pointerEvents: 'none',
80
85
  transformOrigin: 'center center',
86
+ ...(filter ? { filter } : {}),
81
87
  ...kbStyle.style,
82
88
  }}
83
89
  />
@@ -107,6 +113,10 @@ export function ImageLayer({ layer, basePath, viewport, blockTime }: ImageLayerP
107
113
  objectPosition: 'center',
108
114
  display: 'block',
109
115
  pointerEvents: 'none',
116
+ ...(filter ? { filter } : {}),
117
+ // Over-scan blurred imagery so the soft edges never reveal
118
+ // the frame behind the layer.
119
+ ...(content.blur && content.blur > 0 ? { transform: 'scale(1.06)' } : {}),
110
120
  }}
111
121
  />
112
122
  </foreignObject>
@@ -128,7 +138,7 @@ export function ImageLayer({ layer, basePath, viewport, blockTime }: ImageLayerP
128
138
  width={width}
129
139
  height={height}
130
140
  preserveAspectRatio={preserveAspectRatio}
131
- style={{ pointerEvents: 'none' }}
141
+ style={{ pointerEvents: 'none', ...(filter ? { filter } : {}) }}
132
142
  />
133
143
  </g>
134
144
  );
@@ -0,0 +1,146 @@
1
+ /**
2
+ * PathLayer Component
3
+ *
4
+ * Renders an SVG `<path>` for arbitrary curves, connectors, arrows, and the
5
+ * drawing template's computed shapes. Used by the diagram template for edges
6
+ * between nodes; usable by any template that needs a non-rect/circle/line
7
+ * shape.
8
+ *
9
+ * The path's `d` attribute uses absolute SVG coordinates relative to the
10
+ * block viewport (independent of the layer's `position` box, which is
11
+ * present only so animations and clipping match the other layer types).
12
+ *
13
+ * Exception: when `content.shapeKind` is set (a standard named shape like
14
+ * `diamond` / `star` / `arrow-right`), `d` is re-derived from the layer's
15
+ * `position` box resolved against the viewport. That keeps named shapes
16
+ * movable, resizable, and aspect-ratio-adaptive — matching how the native
17
+ * rect/circle/line `ShapeLayer` behaves — rather than pinned to a baked
18
+ * absolute path.
19
+ *
20
+ * End markers are configured via `startMarker`/`endMarker` (with the legacy
21
+ * `arrow` flag mapping to a filled triangle). Marker geometry comes from
22
+ * `markerPath` in core so the SSR renderer and the editor agree.
23
+ */
24
+
25
+ import type { PathLayer as PathLayerType, MarkerStyle } from '@bendyline/squisq/schemas';
26
+ import { markerPath, shapePath } from '@bendyline/squisq/doc';
27
+ import { getAnimationStyle } from '../utils/animationUtils';
28
+ import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
29
+ import { resolveFill, borderDashArray } from '../utils/fillStyle';
30
+
31
+ interface PathLayerProps {
32
+ layer: PathLayerType;
33
+ /** Viewport dimensions — used to resolve `%` positions for named shapes. */
34
+ viewport: { width: number; height: number };
35
+ /** Current time relative to block start. */
36
+ blockTime: number;
37
+ }
38
+
39
+ /**
40
+ * The effective `d`: a named shape (`content.shapeKind`) is re-derived
41
+ * from the resolved `position` box so it tracks moves/resizes and adapts
42
+ * to the viewport; everything else uses the stored absolute path.
43
+ */
44
+ function effectivePath(layer: PathLayerType, viewport: { width: number; height: number }): string {
45
+ const { content, position } = layer;
46
+ if (!content.shapeKind) return content.d;
47
+ const w = position.width ? resolveValue(position.width, viewport.width) : 0;
48
+ const h = position.height ? resolveValue(position.height, viewport.height) : 0;
49
+ const rawX = resolveValue(position.x, viewport.width);
50
+ const rawY = resolveValue(position.y, viewport.height);
51
+ const anchor = getAnchorOffset(position.anchor, w, h);
52
+ const derived = shapePath(content.shapeKind, rawX + anchor.x, rawY + anchor.y, w, h);
53
+ // Fall back to the stored path if the kind is unknown to `shapePath`.
54
+ return derived ?? content.d;
55
+ }
56
+
57
+ /** Resolve the effective marker for an endpoint (explicit field, else `arrow`). */
58
+ function effectiveMarker(
59
+ explicit: MarkerStyle | undefined,
60
+ arrow: PathLayerType['content']['arrow'],
61
+ end: 'start' | 'end',
62
+ ): MarkerStyle {
63
+ if (explicit) return explicit;
64
+ const wants = arrow === 'both' || arrow === end;
65
+ return wants ? 'arrow' : 'none';
66
+ }
67
+
68
+ export function PathLayer({ layer, viewport, blockTime }: PathLayerProps) {
69
+ const { content, animation, id } = layer;
70
+ const d = effectivePath(layer, viewport);
71
+ const stroke = content.stroke ?? '#1e293b';
72
+ const strokeWidth = content.strokeWidth ?? 2;
73
+ const { fill, def: fillDef } = resolveFill(id, content.fill ?? 'none', content.gradient);
74
+ // `borderStyle` (named shapes) takes precedence over a raw `dasharray`.
75
+ const dash = content.borderStyle
76
+ ? borderDashArray(content.borderStyle, strokeWidth)
77
+ : content.dasharray;
78
+ const animStyle = getAnimationStyle(animation, blockTime);
79
+
80
+ const startId = `marker-start-${id}`;
81
+ const endId = `marker-end-${id}`;
82
+ const start = markerPath(effectiveMarker(content.startMarker, content.arrow, 'start'), 'start');
83
+ const end = markerPath(effectiveMarker(content.endMarker, content.arrow, 'end'), 'end');
84
+
85
+ return (
86
+ <g
87
+ className={`block-layer block-layer--path ${animStyle.className}`}
88
+ style={animStyle.style}
89
+ data-layer-id={id}
90
+ >
91
+ <defs>
92
+ {fillDef}
93
+ {end && <MarkerDef id={endId} dir="end" d={end.d} filled={end.filled} stroke={stroke} />}
94
+ {start && (
95
+ <MarkerDef id={startId} dir="start" d={start.d} filled={start.filled} stroke={stroke} />
96
+ )}
97
+ </defs>
98
+ <path
99
+ d={d}
100
+ stroke={stroke}
101
+ strokeWidth={strokeWidth}
102
+ fill={fill}
103
+ fillOpacity={content.fillOpacity}
104
+ strokeDasharray={dash}
105
+ markerStart={start ? `url(#${startId})` : undefined}
106
+ markerEnd={end ? `url(#${endId})` : undefined}
107
+ />
108
+ </g>
109
+ );
110
+ }
111
+
112
+ function MarkerDef({
113
+ id,
114
+ dir,
115
+ d,
116
+ filled,
117
+ stroke,
118
+ }: {
119
+ id: string;
120
+ dir: 'start' | 'end';
121
+ d: string;
122
+ filled: boolean;
123
+ stroke: string;
124
+ }) {
125
+ return (
126
+ <marker
127
+ id={id}
128
+ viewBox="0 0 10 10"
129
+ refX={dir === 'end' ? 9 : 1}
130
+ refY={5}
131
+ markerWidth={4}
132
+ markerHeight={4}
133
+ orient="auto-start-reverse"
134
+ markerUnits="strokeWidth"
135
+ >
136
+ <path
137
+ d={d}
138
+ fill={filled ? stroke : 'none'}
139
+ stroke={filled ? 'none' : stroke}
140
+ strokeWidth={filled ? undefined : 1.5}
141
+ />
142
+ </marker>
143
+ );
144
+ }
145
+
146
+ export default PathLayer;
@@ -8,6 +8,7 @@
8
8
  import type { ShapeLayer as ShapeLayerType } from '@bendyline/squisq/schemas';
9
9
  import { getAnimationStyle } from '../utils/animationUtils';
10
10
  import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
11
+ import { resolveFill, resolveShapeFilter, borderDashArray } from '../utils/fillStyle';
11
12
 
12
13
  interface ShapeLayerProps {
13
14
  layer: ShapeLayerType;
@@ -34,12 +35,14 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
34
35
  // Get animation styles
35
36
  const animStyle = getAnimationStyle(animation, blockTime);
36
37
 
37
- // Check if fill is a CSS gradient (SVG rect doesn't support CSS gradients natively)
38
38
  const fill = content.fill || 'none';
39
+ // Legacy: a CSS gradient string baked into `fill` (e.g. from older docs)
40
+ // only works as an HTML background, so rect renders it via foreignObject.
41
+ // The structured `content.gradient` (preferred) is handled below for all
42
+ // shapes via an SVG <linearGradient>.
39
43
  const isCSSGradient = typeof fill === 'string' && fill.includes('gradient(');
40
44
 
41
- // For CSS gradients on rect, use foreignObject with an HTML div
42
- if (content.shape === 'rect' && isCSSGradient) {
45
+ if (content.shape === 'rect' && isCSSGradient && !content.gradient) {
43
46
  return (
44
47
  <g
45
48
  className={`block-layer block-layer--shape ${animStyle.className}`}
@@ -61,11 +64,23 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
61
64
  );
62
65
  }
63
66
 
64
- // Common style props for native SVG shapes
67
+ const { fill: fillValue, def: fillDef } = resolveFill(
68
+ layer.id,
69
+ fill,
70
+ content.gradient,
71
+ content.pattern,
72
+ );
73
+ const { filterAttr, def: filterDef } = resolveShapeFilter(layer.id, content.filter);
74
+ const dash = borderDashArray(content.borderStyle, content.strokeWidth);
75
+
76
+ // Common style props for native SVG shapes. `line` is stroke-only.
65
77
  const shapeProps = {
66
- fill: fill,
78
+ fill: fillValue,
79
+ fillOpacity: content.fillOpacity,
67
80
  stroke: content.stroke,
68
81
  strokeWidth: content.strokeWidth,
82
+ strokeDasharray: dash,
83
+ ...(filterAttr ? { filter: filterAttr } : {}),
69
84
  };
70
85
 
71
86
  return (
@@ -74,6 +89,12 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
74
89
  style={animStyle.style}
75
90
  data-layer-id={layer.id}
76
91
  >
92
+ {(fillDef || filterDef) && (
93
+ <defs>
94
+ {fillDef}
95
+ {filterDef}
96
+ </defs>
97
+ )}
77
98
  {content.shape === 'rect' && (
78
99
  <rect
79
100
  x={x}
@@ -103,6 +124,7 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
103
124
  y2={y + height}
104
125
  stroke={content.stroke || '#ffffff'}
105
126
  strokeWidth={content.strokeWidth || 2}
127
+ strokeDasharray={dash}
106
128
  />
107
129
  )}
108
130
  </g>