@bendyline/squisq-react 1.4.1 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/squisq-react",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
4
4
  "description": "React component library for doc playback, block rendering, and media layers",
5
5
  "license": "MIT",
6
6
  "author": "Bendyline",
@@ -53,7 +53,7 @@
53
53
  "react-dom": "^18.0.0 || ^19.0.0"
54
54
  },
55
55
  "dependencies": {
56
- "@bendyline/squisq": "1.5.1"
56
+ "@bendyline/squisq": "1.5.2"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@types/react": "18.3.28",
@@ -19,9 +19,20 @@ interface DocControlsSlideshowProps {
19
19
  }
20
20
 
21
21
  export function DocControlsSlideshow({ state, slideNav }: DocControlsSlideshowProps) {
22
- const { currentBlockIndex, totalBlocks } = state;
22
+ const {
23
+ currentBlockIndex,
24
+ currentSlideLabel,
25
+ currentSlideNumber,
26
+ totalBlocks,
27
+ totalSlideNumber,
28
+ } = state;
23
29
  const isFirst = currentBlockIndex <= 0;
24
30
  const isLast = currentBlockIndex >= totalBlocks - 1;
31
+ const counterText =
32
+ totalBlocks > 0
33
+ ? (currentSlideLabel ??
34
+ `${currentSlideNumber ?? currentBlockIndex + 1} / ${totalSlideNumber ?? totalBlocks}`)
35
+ : '—';
25
36
 
26
37
  return (
27
38
  <div
@@ -91,7 +102,7 @@ export function DocControlsSlideshow({ state, slideNav }: DocControlsSlideshowPr
91
102
  letterSpacing: '0.02em',
92
103
  }}
93
104
  >
94
- {totalBlocks > 0 ? `${currentBlockIndex + 1} / ${totalBlocks}` : '—'}
105
+ {counterText}
95
106
  </span>
96
107
 
97
108
  {/* Next button */}
package/src/DocPlayer.tsx CHANGED
@@ -199,6 +199,11 @@ interface DocPlayerProps {
199
199
  * template-annotated sections as inline SVG cards. No audio, no timeline.
200
200
  */
201
201
  displayMode?: DisplayMode;
202
+ /**
203
+ * Whether to synthesize and show the managed cover slide from
204
+ * `doc.startBlock`. Defaults to true for existing documents.
205
+ */
206
+ showCoverSlide?: boolean;
202
207
  /** Caption display style (default: 'standard').
203
208
  * 'social' shows large centered words with the active word highlighted. */
204
209
  captionStyle?: CaptionStyle;
@@ -274,6 +279,7 @@ function DocPlayerContent({
274
279
  onBlockMarkers,
275
280
  forceViewport,
276
281
  displayMode = 'video',
282
+ showCoverSlide = true,
277
283
  theme,
278
284
  surface,
279
285
  captionStyle = 'standard',
@@ -403,6 +409,7 @@ function DocPlayerContent({
403
409
  // Expand cover block (startBlock) if present - uses active viewport
404
410
  const coverBlock = useMemo((): Block | null => {
405
411
  const startBlockConfig = doc.startBlock as StartBlockConfig | undefined;
412
+ if (!showCoverSlide) return null;
406
413
  if (!startBlockConfig) return null;
407
414
 
408
415
  const context = createTemplateContext(effectiveTheme, 0, 1, activeViewport);
@@ -415,7 +422,24 @@ function DocPlayerContent({
415
422
  audioSegment: -1,
416
423
  layers,
417
424
  };
418
- }, [doc.startBlock, activeViewport, effectiveTheme]);
425
+ }, [doc.startBlock, activeViewport, effectiveTheme, showCoverSlide]);
426
+
427
+ // Slideshow mode treats the managed cover as a static slide before block 1.
428
+ // It has no timeline startTime, so keep its visibility separate from audio.
429
+ const hasManagedCover = !!coverBlock;
430
+ const [slideshowCoverVisible, setSlideshowCoverVisible] = useState(false);
431
+ const slideshowCoverInitKeyRef = useRef('');
432
+ useEffect(() => {
433
+ const initKey = `${isSlideshowMode}:${hasManagedCover}:${renderMode}`;
434
+ if (slideshowCoverInitKeyRef.current === initKey) return;
435
+ slideshowCoverInitKeyRef.current = initKey;
436
+ if (isSlideshowMode && hasManagedCover && !renderMode) {
437
+ setSlideshowCoverVisible(true);
438
+ pause();
439
+ } else {
440
+ setSlideshowCoverVisible(false);
441
+ }
442
+ }, [isSlideshowMode, hasManagedCover, renderMode, pause]);
419
443
 
420
444
  // Render-mode cover block control: allows Playwright to force-show the cover block
421
445
  const [coverForced, setCoverForced] = useState(false);
@@ -431,6 +455,7 @@ function DocPlayerContent({
431
455
  // Track when cover is showing at rest (before play)
432
456
  const atRest = !!(
433
457
  coverBlock &&
458
+ !isSlideshowMode &&
434
459
  !isPlaying &&
435
460
  currentTime === 0 &&
436
461
  !hasPlayedOnce.current &&
@@ -440,7 +465,7 @@ function DocPlayerContent({
440
465
  if (atRest) coverWasShowing.current = true;
441
466
 
442
467
  useEffect(() => {
443
- if (isPlaying && coverWasShowing.current && coverBlock && !renderMode) {
468
+ if (isPlaying && coverWasShowing.current && coverBlock && !renderMode && !isSlideshowMode) {
444
469
  coverWasShowing.current = false;
445
470
  hasPlayedOnce.current = true;
446
471
  setCoverGraceActive(true);
@@ -450,7 +475,7 @@ function DocPlayerContent({
450
475
  // re-run (coverWasShowing.current is now false).
451
476
  coverGraceTimer.current = setTimeout(() => setCoverGraceActive(false), 3000);
452
477
  }
453
- }, [isPlaying, coverBlock, renderMode]);
478
+ }, [isPlaying, coverBlock, renderMode, isSlideshowMode]);
454
479
 
455
480
  // Always clear the grace timer on unmount
456
481
  useEffect(() => () => clearTimeout(coverGraceTimer.current), []);
@@ -458,14 +483,31 @@ function DocPlayerContent({
458
483
  // Determine if we should show the cover block
459
484
  // Show cover when: has cover block, not playing, at time 0, not in render mode
460
485
  // OR during the grace period after first play, OR when coverForced (render mode)
461
- // Cover block is suppressed in slideshow and linear mode — start directly on content
462
- const showCoverBlock =
486
+ const showVideoCoverBlock =
463
487
  !isSlideshowMode &&
464
488
  !isLinearMode &&
465
- coverBlock &&
489
+ !!coverBlock &&
466
490
  (coverForced ||
467
491
  coverGraceActive ||
468
492
  (!isPlaying && currentTime === 0 && !hasPlayedOnce.current && !renderMode && !autoPlay));
493
+ const showSlideshowCover = !!(
494
+ isSlideshowMode &&
495
+ !isLinearMode &&
496
+ !renderMode &&
497
+ coverBlock &&
498
+ slideshowCoverVisible
499
+ );
500
+ const showCoverBlock = showVideoCoverBlock || showSlideshowCover;
501
+
502
+ const slideshowHasCover = !!(isSlideshowMode && !renderMode && coverBlock);
503
+ const slideshowSlideIndex = slideshowHasCover
504
+ ? slideshowCoverVisible
505
+ ? 0
506
+ : currentBlockIndex + 1
507
+ : currentBlockIndex;
508
+ const slideshowTotalSlides = slideshowHasCover
509
+ ? expandedBlocks.length + 1
510
+ : expandedBlocks.length;
469
511
 
470
512
  // Auto-play if enabled (wait for audio to be ready)
471
513
  // Use a ref to track if we've already auto-played to avoid repeating on every render
@@ -714,8 +756,8 @@ function DocPlayerContent({
714
756
  isPlaying,
715
757
  currentTime,
716
758
  totalDuration,
717
- currentBlockIndex,
718
- totalBlocks: expandedBlocks.length,
759
+ currentBlockIndex: slideshowSlideIndex,
760
+ totalBlocks: slideshowTotalSlides,
719
761
  docProgress,
720
762
  hasCaptions: !!hasCaptions,
721
763
  captionsEnabled,
@@ -724,15 +766,19 @@ function DocPlayerContent({
724
766
  currentSegmentIndex: currentSegment,
725
767
  currentSegmentName:
726
768
  segmentTitleMap.get(currentSegment) ?? doc.audio.segments[currentSegment]?.name ?? null,
727
- currentBlock: currentBlock ?? null,
769
+ currentBlock: showSlideshowCover ? coverBlock : (currentBlock ?? null),
770
+ currentSlideLabel: showSlideshowCover ? 'Cover' : undefined,
771
+ currentSlideNumber:
772
+ slideshowHasCover && !showSlideshowCover ? currentBlockIndex + 1 : undefined,
773
+ totalSlideNumber: slideshowHasCover ? expandedBlocks.length : undefined,
728
774
  }),
729
775
  // eslint-disable-next-line react-hooks/exhaustive-deps -- doc.audio.segments is stable within a given doc
730
776
  [
731
777
  isPlaying,
732
778
  currentTime,
733
779
  totalDuration,
734
- currentBlockIndex,
735
- expandedBlocks.length,
780
+ slideshowSlideIndex,
781
+ slideshowTotalSlides,
736
782
  docProgress,
737
783
  hasCaptions,
738
784
  captionsEnabled,
@@ -741,6 +787,11 @@ function DocPlayerContent({
741
787
  currentSegment,
742
788
  segmentTitleMap,
743
789
  currentBlock,
790
+ currentBlockIndex,
791
+ showSlideshowCover,
792
+ coverBlock,
793
+ slideshowHasCover,
794
+ expandedBlocks.length,
744
795
  ],
745
796
  );
746
797
 
@@ -762,24 +813,56 @@ function DocPlayerContent({
762
813
  const slideNavActions: SlideNavActions = useMemo(
763
814
  () => ({
764
815
  nextSlide: () => {
816
+ if (slideshowHasCover && slideshowCoverVisible) {
817
+ const target = expandedBlocks[0];
818
+ if (target) {
819
+ setSlideshowCoverVisible(false);
820
+ seekTo(target.startTime);
821
+ pause();
822
+ }
823
+ return;
824
+ }
765
825
  if (currentBlockIndex < expandedBlocks.length - 1) {
766
826
  const target = expandedBlocks[currentBlockIndex + 1];
767
827
  if (target) {
828
+ setSlideshowCoverVisible(false);
768
829
  seekTo(target.startTime);
769
830
  pause();
770
831
  }
771
832
  }
772
833
  },
773
834
  prevSlide: () => {
835
+ if (slideshowHasCover && !slideshowCoverVisible && currentBlockIndex <= 0) {
836
+ setSlideshowCoverVisible(true);
837
+ seekTo(0);
838
+ pause();
839
+ return;
840
+ }
774
841
  if (currentBlockIndex > 0) {
775
842
  const target = expandedBlocks[currentBlockIndex - 1];
776
843
  if (target) {
844
+ setSlideshowCoverVisible(false);
777
845
  seekTo(target.startTime);
778
846
  pause();
779
847
  }
780
848
  }
781
849
  },
782
850
  goToSlide: (index: number) => {
851
+ if (slideshowHasCover) {
852
+ if (index === 0) {
853
+ setSlideshowCoverVisible(true);
854
+ seekTo(0);
855
+ pause();
856
+ return;
857
+ }
858
+ const target = expandedBlocks[index - 1];
859
+ if (target) {
860
+ setSlideshowCoverVisible(false);
861
+ seekTo(target.startTime);
862
+ pause();
863
+ }
864
+ return;
865
+ }
783
866
  if (index >= 0 && index < expandedBlocks.length) {
784
867
  const target = expandedBlocks[index];
785
868
  if (target) {
@@ -789,7 +872,7 @@ function DocPlayerContent({
789
872
  }
790
873
  },
791
874
  }),
792
- [currentBlockIndex, expandedBlocks, seekTo, pause],
875
+ [currentBlockIndex, expandedBlocks, seekTo, pause, slideshowHasCover, slideshowCoverVisible],
793
876
  );
794
877
 
795
878
  // Drag-to-swipe navigation for slideshow mode. Inert unless in slideshow mode,
@@ -798,8 +881,8 @@ function DocPlayerContent({
798
881
  const swipe = useSlideSwipe({
799
882
  enabled: swipeEnabled,
800
883
  containerRef,
801
- canGoNext: currentBlockIndex < expandedBlocks.length - 1,
802
- canGoPrev: currentBlockIndex > 0,
884
+ canGoNext: slideshowSlideIndex < slideshowTotalSlides - 1,
885
+ canGoPrev: slideshowSlideIndex > 0,
803
886
  onNext: slideNavActions.nextSlide,
804
887
  onPrev: slideNavActions.prevSlide,
805
888
  });
@@ -873,7 +956,7 @@ function DocPlayerContent({
873
956
  }, [blockMarkers, onBlockMarkers]);
874
957
 
875
958
  // Keep expandedBlocks length in a ref so keyboard handler stays stable
876
- expandedBlocksLenRef.current = expandedBlocks.length;
959
+ expandedBlocksLenRef.current = isSlideshowMode ? slideshowTotalSlides : expandedBlocks.length;
877
960
 
878
961
  // Handle keyboard controls — uses refs for frequently-changing values
879
962
  // (currentTime, totalDuration, expandedBlocks.length) to avoid
@@ -1,5 +1,5 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { render } from '@testing-library/react';
1
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
3
  import { DocPlayer } from '../DocPlayer';
4
4
  import type { Doc } from '@bendyline/squisq/schemas';
5
5
 
@@ -12,7 +12,25 @@ function minimalDoc(): Doc {
12
12
  };
13
13
  }
14
14
 
15
+ function docWithCover(): Doc {
16
+ return {
17
+ ...minimalDoc(),
18
+ startBlock: {
19
+ title: 'Managed Cover',
20
+ subtitle: 'Generated by Squisq',
21
+ },
22
+ };
23
+ }
24
+
15
25
  describe('DocPlayer smoke test', () => {
26
+ beforeAll(() => {
27
+ vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => {});
28
+ });
29
+
30
+ afterAll(() => {
31
+ vi.restoreAllMocks();
32
+ });
33
+
16
34
  it('renders without crashing in video mode (default)', () => {
17
35
  const { container } = render(<DocPlayer doc={minimalDoc()} basePath="/test" />);
18
36
  expect(container.firstChild).toBeTruthy();
@@ -25,6 +43,37 @@ describe('DocPlayer smoke test', () => {
25
43
  expect(container.firstChild).toBeTruthy();
26
44
  });
27
45
 
46
+ it('shows the managed cover as the first slideshow entry by default', async () => {
47
+ const { container } = render(
48
+ <DocPlayer doc={docWithCover()} basePath="/test" displayMode="slideshow" />,
49
+ );
50
+ await waitFor(() => expect(container.textContent).toContain('Managed Cover'));
51
+ expect(screen.getByTestId('slide-counter').textContent).toBe('Cover');
52
+ });
53
+
54
+ it('can suppress the managed cover slide', () => {
55
+ const { container } = render(
56
+ <DocPlayer
57
+ doc={docWithCover()}
58
+ basePath="/test"
59
+ displayMode="slideshow"
60
+ showCoverSlide={false}
61
+ />,
62
+ );
63
+ expect(container.textContent).not.toContain('Managed Cover');
64
+ expect(screen.getByTestId('slide-counter').textContent).toBe('1 / 1');
65
+ });
66
+
67
+ it('advances from slideshow cover to slide 1', async () => {
68
+ const { container } = render(
69
+ <DocPlayer doc={docWithCover()} basePath="/test" displayMode="slideshow" />,
70
+ );
71
+ await waitFor(() => expect(screen.getByTestId('slide-counter').textContent).toBe('Cover'));
72
+ fireEvent.click(screen.getByTestId('slide-next'));
73
+ await waitFor(() => expect(screen.getByTestId('slide-counter').textContent).toBe('1 / 1'));
74
+ expect(container.textContent).not.toContain('Managed Cover');
75
+ });
76
+
28
77
  it('renders without crashing in linear mode', () => {
29
78
  const { container } = render(
30
79
  <DocPlayer doc={minimalDoc()} basePath="/test" displayMode="linear" />,
package/src/types.ts CHANGED
@@ -82,6 +82,12 @@ export interface PlaybackState {
82
82
  currentSegmentName: string | null;
83
83
  /** Current block data (for extracting image info, etc.) */
84
84
  currentBlock: Block | null;
85
+ /** Optional display label for non-block slides, e.g. the managed cover. */
86
+ currentSlideLabel?: string;
87
+ /** Optional human-facing slide number, separate from internal nav index. */
88
+ currentSlideNumber?: number;
89
+ /** Optional human-facing slide total, separate from internal nav total. */
90
+ totalSlideNumber?: number;
85
91
  }
86
92
 
87
93
  /** Playback actions exposed to external control components */