@bendyline/squisq-react 1.4.2 → 2.0.1

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 (50) hide show
  1. package/README.md +30 -3
  2. package/dist/index.d.ts +177 -28
  3. package/dist/index.js +1330 -611
  4. package/dist/index.js.map +1 -1
  5. package/dist/squisq-player.css +1 -1
  6. package/dist/squisq-player.css.map +1 -1
  7. package/dist/squisq-player.global.js +57 -37
  8. package/dist/squisq-player.global.js.map +1 -1
  9. package/dist/standalone-source.js +1 -1
  10. package/dist/styles/index.css +28 -0
  11. package/package.json +2 -2
  12. package/src/BlockRenderer.tsx +54 -17
  13. package/src/DocControlsSlideshow.tsx +222 -5
  14. package/src/DocPlayer.tsx +367 -183
  15. package/src/DocPlayerWithSidebar.tsx +4 -0
  16. package/src/DocProgressBar.tsx +40 -1
  17. package/src/LinearDocView.tsx +138 -62
  18. package/src/MarkdownRenderer.tsx +40 -97
  19. package/src/MediaClipLayer.tsx +12 -2
  20. package/src/__tests__/BlockRenderer.test.tsx +138 -8
  21. package/src/__tests__/DocControlsSlideshow.test.tsx +94 -1
  22. package/src/__tests__/DocPlayer.test.tsx +505 -0
  23. package/src/__tests__/DocProgressBar.test.tsx +28 -2
  24. package/src/__tests__/LinearDocView.test.tsx +104 -11
  25. package/src/__tests__/MapLayer.test.tsx +63 -0
  26. package/src/__tests__/MarkdownRenderer.test.tsx +16 -5
  27. package/src/__tests__/MediaClipLayer.test.tsx +70 -0
  28. package/src/__tests__/MediaContext.test.tsx +51 -0
  29. package/src/__tests__/PathLayer.test.tsx +12 -1
  30. package/src/__tests__/VideoLayer.test.tsx +94 -0
  31. package/src/__tests__/fillStyle.test.tsx +50 -2
  32. package/src/__tests__/standaloneEntry.test.tsx +103 -0
  33. package/src/__tests__/useAudioSync.test.ts +49 -0
  34. package/src/__tests__/useDocPlayback.transition.test.ts +48 -5
  35. package/src/__tests__/useViewportOrientation.test.ts +22 -0
  36. package/src/hooks/MediaContext.tsx +12 -3
  37. package/src/hooks/useAudioSync.ts +61 -12
  38. package/src/hooks/useDocPlayback.ts +40 -12
  39. package/src/hooks/useViewportOrientation.ts +2 -4
  40. package/src/index.ts +5 -2
  41. package/src/layers/ImageLayer.tsx +106 -1
  42. package/src/layers/MapLayer.tsx +7 -6
  43. package/src/layers/PathLayer.tsx +20 -11
  44. package/src/layers/ShapeLayer.tsx +33 -9
  45. package/src/layers/TextLayer.tsx +4 -3
  46. package/src/layers/TreeLayer.tsx +167 -0
  47. package/src/layers/VideoLayer.tsx +20 -6
  48. package/src/standalone-entry.tsx +91 -14
  49. package/src/styles/doc-animations.css +36 -0
  50. package/src/types.ts +13 -13
@@ -36,6 +36,8 @@ interface DocPlayerWithSidebarProps {
36
36
  onTimeUpdate?: (time: number) => void;
37
37
  /** Optional audio controller (if not provided, uses default HTML5 audio) */
38
38
  audioController?: AudioController;
39
+ /** Whether to render slide transitions and per-layer animations (default: true). */
40
+ animationsEnabled?: boolean;
39
41
  muted?: boolean;
40
42
  captionsEnabled?: boolean;
41
43
  isFullscreen?: boolean;
@@ -75,6 +77,7 @@ export function DocPlayerWithSidebar({
75
77
  onEnded,
76
78
  onTimeUpdate,
77
79
  audioController,
80
+ animationsEnabled = true,
78
81
  muted,
79
82
  captionsEnabled,
80
83
  isFullscreen,
@@ -132,6 +135,7 @@ export function DocPlayerWithSidebar({
132
135
  onEnded={onEnded}
133
136
  onTimeUpdate={onTimeUpdate}
134
137
  audioController={audioController}
138
+ animationsEnabled={animationsEnabled}
135
139
  muted={muted}
136
140
  captionsEnabled={captionsEnabled}
137
141
  showControls={isFullscreen}
@@ -63,6 +63,32 @@ export function DocProgressBar({
63
63
  setHoverPosition(null);
64
64
  }, []);
65
65
 
66
+ const handleProgressKeyDown = useCallback(
67
+ (e: React.KeyboardEvent<HTMLDivElement>) => {
68
+ let next: number | null = null;
69
+ switch (e.key) {
70
+ case 'ArrowLeft':
71
+ case 'ArrowDown':
72
+ next = state.currentTime - 5;
73
+ break;
74
+ case 'ArrowRight':
75
+ case 'ArrowUp':
76
+ next = state.currentTime + 5;
77
+ break;
78
+ case 'Home':
79
+ next = 0;
80
+ break;
81
+ case 'End':
82
+ next = state.totalDuration;
83
+ break;
84
+ }
85
+ if (next == null) return;
86
+ e.preventDefault();
87
+ actions.seekTo(Math.max(0, Math.min(state.totalDuration, next)));
88
+ },
89
+ [actions, state.currentTime, state.totalDuration],
90
+ );
91
+
66
92
  const getBlockAtTimeLocal = useCallback(
67
93
  (time: number): { block: Block; index: number } | null => {
68
94
  for (let i = expandedBlocks.length - 1; i >= 0; i--) {
@@ -79,6 +105,8 @@ export function DocProgressBar({
79
105
  return (
80
106
  <div
81
107
  ref={progressBarRef}
108
+ role="group"
109
+ aria-label="Playback timeline"
82
110
  style={{
83
111
  flex: 1,
84
112
  height: '24px',
@@ -98,6 +126,14 @@ export function DocProgressBar({
98
126
  >
99
127
  {/* Track background */}
100
128
  <div
129
+ role="slider"
130
+ tabIndex={0}
131
+ aria-label="Playback position"
132
+ aria-valuemin={0}
133
+ aria-valuemax={state.totalDuration}
134
+ aria-valuenow={Math.max(0, Math.min(state.totalDuration, state.currentTime))}
135
+ aria-valuetext={`${formatTime(state.currentTime)} of ${formatTime(state.totalDuration)}`}
136
+ onKeyDown={handleProgressKeyDown}
101
137
  style={{
102
138
  position: 'absolute',
103
139
  left: 0,
@@ -132,7 +168,8 @@ export function DocProgressBar({
132
168
 
133
169
  {/* Block markers (dots) */}
134
170
  {blockMarkers.map((marker, i) => (
135
- <div
171
+ <button
172
+ type="button"
136
173
  key={`${marker.block.id}-${i}`}
137
174
  style={{
138
175
  position: 'absolute',
@@ -144,11 +181,13 @@ export function DocProgressBar({
144
181
  background:
145
182
  marker.index === state.currentBlockIndex ? '#ffffff' : 'rgba(255,255,255,0.5)',
146
183
  border: '2px solid #5b9bd5',
184
+ padding: 0,
147
185
  cursor: 'pointer',
148
186
  zIndex: 2,
149
187
  transition: 'transform 0.15s, background 0.15s',
150
188
  }}
151
189
  title={marker.title}
190
+ aria-label={`Seek to ${marker.title}`}
152
191
  onClick={(e) => {
153
192
  e.stopPropagation();
154
193
  actions.seekTo(marker.block.startTime);
@@ -13,11 +13,11 @@
13
13
  * - Headings from the block hierarchy rendered as HTML headings
14
14
  * - Body content rendered via MarkdownRenderer
15
15
  * - Template-annotated sections show an SVG card (BlockRenderer)
16
- * using `getLayers()` for on-demand layer computation
16
+ * using `materializeBlockLayers()` for on-demand layer computation
17
17
  * - Blocks are rendered recursively to preserve the heading hierarchy
18
18
  */
19
19
 
20
- import { useMemo } from 'react';
20
+ import { useEffect, useMemo, useRef } from 'react';
21
21
  import { useAutoSurface } from './hooks/useAutoSurface';
22
22
  import type { Doc, Block, DocBlock } from '@bendyline/squisq/schemas';
23
23
  import type { ViewportConfig } from '@bendyline/squisq/schemas';
@@ -29,13 +29,13 @@ import {
29
29
  } from '@bendyline/squisq/schemas';
30
30
  import { VIEWPORT_PRESETS } from '@bendyline/squisq/schemas';
31
31
  import {
32
- getLayers,
33
- hasTemplate,
32
+ materializeBlockLayers,
34
33
  markdownToDoc,
35
34
  DEFAULT_THEME,
36
35
  deriveTemplateInputs,
36
+ isTemplateBlock,
37
37
  } from '@bendyline/squisq/doc';
38
- import type { RenderContext } from '@bendyline/squisq/doc';
38
+ import type { MaterializeBlockLayersOptions } from '@bendyline/squisq/doc';
39
39
  import { extractPlainText, parseMarkdown } from '@bendyline/squisq/markdown';
40
40
  import { BlockRenderer } from './BlockRenderer';
41
41
  import { MarkdownRenderer } from './MarkdownRenderer';
@@ -62,6 +62,8 @@ export interface LinearDocViewProps {
62
62
  className?: string;
63
63
  /** Theme to use for rendering (default: DEFAULT_THEME from the theme library) */
64
64
  theme?: Theme;
65
+ /** Whether inline visual cards render their layer animations (default: true). */
66
+ animationsEnabled?: boolean;
65
67
  /**
66
68
  * Optional surface scheme (light / dark paper) overlaid on top of the
67
69
  * theme's colors. Orthogonal to `theme` — a theme picks editorial
@@ -87,43 +89,35 @@ export interface LinearDocViewProps {
87
89
  * full-size images would dominate the layout.
88
90
  */
89
91
  imageDisplayMode?: ImageDisplayMode;
92
+ /**
93
+ * Let unmodified Up/Down arrows scroll this view even when it does not
94
+ * currently hold focus. Intended for a primary document preview.
95
+ */
96
+ globalKeyboardShortcuts?: boolean;
90
97
  }
91
98
 
92
99
  export type ImageDisplayMode = 'inline' | 'thumbnail';
93
100
 
94
101
  // ── Helpers ────────────────────────────────────────────────────────
95
102
 
96
- // Unknown template names we've already warned about (module-level so each
97
- // name warns at most once per page, not once per render).
98
- const warnedUnknownTemplates = new Set<string>();
99
-
100
103
  /**
101
104
  * Determine whether a block has a template annotation that should be
102
- * rendered as a visual SVG card. A block is "annotated" when:
103
- * 1. Its sourceHeading has a templateAnnotation, AND
104
- * 2. The annotated template exists in the registry
105
- *
106
- * Blocks annotated with a template that is NOT in the registry fall back
107
- * to plain markdown rendering, with a one-shot dev-visible warning per
108
- * unknown template name.
105
+ * rendered as a visual SVG card. Unknown templates remain annotated so the
106
+ * materializer can return a visible fallback and structured diagnostic.
109
107
  */
110
108
  function isAnnotatedBlock(block: Block): boolean {
111
- const annotation = block.sourceHeading?.templateAnnotation;
112
- if (!annotation?.template) return false;
113
- if (!hasTemplate(annotation.template)) {
114
- if (!warnedUnknownTemplates.has(annotation.template)) {
115
- warnedUnknownTemplates.add(annotation.template);
116
- console.warn(
117
- `[squisq] Unknown template "${annotation.template}" rendering the block as plain markdown.`,
118
- );
119
- }
120
- return false;
121
- }
122
- return true;
109
+ return (
110
+ !!block.sourceHeading?.templateAnnotation?.template ||
111
+ (!block.sourceHeading && isTemplateBlock(block as DocBlock))
112
+ );
113
+ }
114
+
115
+ function visualTemplateName(block: Block): string | undefined {
116
+ return block.sourceHeading?.templateAnnotation?.template ?? block.template;
123
117
  }
124
118
 
125
119
  /**
126
- * Count total blocks in a hierarchy (for RenderContext.totalBlocks).
120
+ * Count total blocks in a hierarchy for the materialization context.
127
121
  */
128
122
  function countAll(blocks: Block[]): number {
129
123
  let count = 0;
@@ -140,55 +134,75 @@ interface BlockSectionProps {
140
134
  block: Block;
141
135
  basePath: string;
142
136
  viewport: ViewportConfig;
143
- renderContext: RenderContext;
137
+ renderContext: MaterializeBlockLayersOptions;
144
138
  blockIndex: number;
139
+ blockIndices: ReadonlyMap<Block, number>;
140
+ animationsEnabled: boolean;
145
141
  }
146
142
 
147
143
  /**
148
144
  * Render a single block section: heading + body content or SVG card.
149
145
  * Recurses into children to render the full heading tree.
150
146
  */
151
- function BlockSection({ block, basePath, viewport, renderContext, blockIndex }: BlockSectionProps) {
147
+ function BlockSection({
148
+ block,
149
+ basePath,
150
+ viewport,
151
+ renderContext,
152
+ blockIndex,
153
+ blockIndices,
154
+ animationsEnabled,
155
+ }: BlockSectionProps) {
152
156
  const isAnnotated = isAnnotatedBlock(block);
153
157
 
154
158
  // For annotated blocks, compute layers and build a Block with them
155
159
  const visualBlock = useMemo(() => {
156
160
  if (!isAnnotated) return null;
157
161
 
158
- const annotation = block.sourceHeading!.templateAnnotation!;
159
- const headingText = extractPlainText(block.sourceHeading!);
162
+ const annotation = block.sourceHeading?.templateAnnotation;
163
+ const templateName = visualTemplateName(block) ?? 'sectionHeader';
160
164
 
161
- // Build a TemplateBlock-compatible object
162
- const templateBlock: Record<string, unknown> = {
163
- id: block.id,
164
- template: annotation.template,
165
- startTime: 0,
166
- duration: 1,
167
- audioSegment: 0,
168
- title: headingText,
169
- ...(deriveTemplateInputs(
170
- annotation.template ?? 'sectionHeader',
171
- headingText,
172
- block.contents,
173
- {
174
- placeholders: true,
175
- },
176
- ) ?? {}),
177
- ...annotation.params,
178
- ...block.templateOverrides,
179
- };
165
+ // Authored Markdown blocks derive their typed template inputs from the
166
+ // heading/body. Transform-generated blocks already ARE typed template
167
+ // inputs, so materialize them directly instead of looking for authoring
168
+ // nodes they intentionally do not carry.
169
+ const templateBlock: Record<string, unknown> = annotation
170
+ ? (() => {
171
+ const headingText = extractPlainText(block.sourceHeading!);
172
+ return {
173
+ id: block.id,
174
+ template: templateName,
175
+ startTime: 0,
176
+ duration: 1,
177
+ audioSegment: 0,
178
+ title: headingText,
179
+ contents: block.contents,
180
+ children: block.children,
181
+ ...(deriveTemplateInputs(templateName, headingText, block.contents, {
182
+ placeholders: true,
183
+ }) ?? {}),
184
+ ...annotation.params,
185
+ ...block.templateOverrides,
186
+ };
187
+ })()
188
+ : {
189
+ ...block,
190
+ startTime: block.startTime ?? 0,
191
+ duration: block.duration ?? 1,
192
+ audioSegment: block.audioSegment ?? 0,
193
+ template: templateName,
194
+ };
180
195
 
181
- // Compute layers via getLayers
182
- const ctx: RenderContext = {
196
+ const ctx: MaterializeBlockLayersOptions = {
183
197
  ...renderContext,
184
198
  blockIndex,
185
199
  };
186
- const layers = getLayers(templateBlock as unknown as DocBlock, ctx);
200
+ const { layers } = materializeBlockLayers(templateBlock as unknown as DocBlock, ctx);
187
201
 
188
202
  return {
189
203
  ...block,
190
204
  layers,
191
- template: annotation.template,
205
+ template: templateName,
192
206
  } as Block;
193
207
  }, [block, isAnnotated, renderContext, blockIndex]);
194
208
 
@@ -196,7 +210,8 @@ function BlockSection({ block, basePath, viewport, renderContext, blockIndex }:
196
210
  <div
197
211
  className="squisq-linear-section"
198
212
  data-block-id={block.id}
199
- data-template={isAnnotated ? block.sourceHeading?.templateAnnotation?.template : undefined}
213
+ data-block-index={blockIndex}
214
+ data-template={isAnnotated ? visualTemplateName(block) : undefined}
200
215
  >
201
216
  {/* Render the heading (if present — preamble has no sourceHeading) */}
202
217
  {block.sourceHeading && !isAnnotated && <MarkdownRenderer nodes={[block.sourceHeading]} />}
@@ -224,6 +239,7 @@ function BlockSection({ block, basePath, viewport, renderContext, blockIndex }:
224
239
  blockTime={0}
225
240
  basePath={basePath}
226
241
  viewport={viewport}
242
+ animationsEnabled={animationsEnabled}
227
243
  />
228
244
  </div>
229
245
  </div>
@@ -244,7 +260,9 @@ function BlockSection({ block, basePath, viewport, renderContext, blockIndex }:
244
260
  basePath={basePath}
245
261
  viewport={viewport}
246
262
  renderContext={renderContext}
247
- blockIndex={blockIndex + i + 1}
263
+ blockIndex={blockIndices.get(child) ?? blockIndex + i + 1}
264
+ blockIndices={blockIndices}
265
+ animationsEnabled={animationsEnabled}
248
266
  />
249
267
  ))}
250
268
  </div>
@@ -275,9 +293,12 @@ export function LinearDocView({
275
293
  className,
276
294
  theme,
277
295
  surface,
296
+ animationsEnabled = true,
278
297
  thinMargins = false,
279
298
  imageDisplayMode = 'inline',
299
+ globalKeyboardShortcuts = false,
280
300
  }: LinearDocViewProps) {
301
+ const scrollRef = useRef<HTMLDivElement>(null);
281
302
  const activeViewport = viewport ?? VIEWPORT_PRESETS.landscape;
282
303
 
283
304
  // Parse markdown into a Doc only when no explicit doc is supplied.
@@ -291,10 +312,22 @@ export function LinearDocView({
291
312
  () => (resolvedDoc ? countAll(resolvedDoc.blocks) : 0),
292
313
  [resolvedDoc],
293
314
  );
315
+ const blockIndices = useMemo(() => {
316
+ const indices = new Map<Block, number>();
317
+ let index = 0;
318
+ const visit = (blocks: Block[]) => {
319
+ for (const block of blocks) {
320
+ indices.set(block, index++);
321
+ if (block.children) visit(block.children);
322
+ }
323
+ };
324
+ if (resolvedDoc) visit(resolvedDoc.blocks);
325
+ return indices;
326
+ }, [resolvedDoc]);
294
327
  const autoSurface = useAutoSurface(surface === 'auto');
295
328
  const resolvedSurface: SurfaceScheme | undefined = surface === 'auto' ? autoSurface : surface;
296
329
 
297
- const renderContext: RenderContext = useMemo(() => {
330
+ const renderContext: MaterializeBlockLayersOptions = useMemo(() => {
298
331
  const baseTheme = theme ?? DEFAULT_THEME;
299
332
  const effectiveTheme = resolvedSurface ? applySurface(baseTheme, resolvedSurface) : baseTheme;
300
333
  return {
@@ -304,15 +337,52 @@ export function LinearDocView({
304
337
  // Theme atmosphere (vignette/grain/gradient persistent layers) shows
305
338
  // on the inline template cards so they match the player's look.
306
339
  persistentLayers: effectiveTheme.persistentLayers,
340
+ customTemplates: resolvedDoc?.customTemplates,
307
341
  };
308
- }, [activeViewport, totalBlocks, theme, resolvedSurface]);
342
+ }, [activeViewport, resolvedDoc?.customTemplates, totalBlocks, theme, resolvedSurface]);
309
343
 
310
344
  const activeTheme = renderContext.theme!;
311
345
 
346
+ useEffect(() => {
347
+ if (!globalKeyboardShortcuts) return;
348
+ const handleKeyDown = (event: KeyboardEvent) => {
349
+ if (
350
+ event.defaultPrevented ||
351
+ event.altKey ||
352
+ event.ctrlKey ||
353
+ event.metaKey ||
354
+ event.shiftKey ||
355
+ (event.key !== 'ArrowDown' && event.key !== 'ArrowUp')
356
+ ) {
357
+ return;
358
+ }
359
+ const target = event.target instanceof Element ? event.target : null;
360
+ if (
361
+ target?.closest(
362
+ 'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="textbox"], [role="combobox"], [role="listbox"], [role="menu"], [role="dialog"], [aria-modal="true"], .monaco-editor',
363
+ )
364
+ ) {
365
+ return;
366
+ }
367
+ const scroller = scrollRef.current;
368
+ if (!scroller) return;
369
+ event.preventDefault();
370
+ const distance = Math.max(64, Math.round(scroller.clientHeight * 0.12));
371
+ scroller.scrollBy({
372
+ top: event.key === 'ArrowDown' ? distance : -distance,
373
+ behavior: 'smooth',
374
+ });
375
+ };
376
+ document.addEventListener('keydown', handleKeyDown);
377
+ return () => document.removeEventListener('keydown', handleKeyDown);
378
+ }, [globalKeyboardShortcuts]);
379
+
312
380
  // Nothing to render — keep an empty (but classed) container so hosts can
313
381
  // still target/measure the view.
314
382
  if (!resolvedDoc) {
315
- return <div className={`squisq-linear squisq-linear--empty ${className || ''}`} />;
383
+ return (
384
+ <div ref={scrollRef} className={`squisq-linear squisq-linear--empty ${className || ''}`} />
385
+ );
316
386
  }
317
387
 
318
388
  const bgColor = activeTheme.colors.background;
@@ -325,6 +395,7 @@ export function LinearDocView({
325
395
 
326
396
  return (
327
397
  <div
398
+ ref={scrollRef}
328
399
  className={`squisq-linear ${className || ''}`}
329
400
  style={{
330
401
  width: '100%',
@@ -383,6 +454,9 @@ export function LinearDocView({
383
454
  .squisq-linear-content p {
384
455
  margin-bottom: 0.75em;
385
456
  }
457
+ .squisq-linear-content p + p {
458
+ margin-top: 1.25em;
459
+ }
386
460
  .squisq-linear-content ul,
387
461
  .squisq-linear-content ol {
388
462
  padding-left: 2em;
@@ -484,7 +558,9 @@ export function LinearDocView({
484
558
  basePath={basePath}
485
559
  viewport={activeViewport}
486
560
  renderContext={renderContext}
487
- blockIndex={i}
561
+ blockIndex={blockIndices.get(block) ?? i}
562
+ blockIndices={blockIndices}
563
+ animationsEnabled={animationsEnabled}
488
564
  />
489
565
  ))}
490
566
  </div>