@bendyline/squisq-react 1.4.0 → 1.4.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.
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { decideSwipe, type SwipeDecisionInput } from '../hooks/useSlideSwipe';
3
+
4
+ /** A slow drag: long duration so velocity stays well under the flick threshold. */
5
+ function slow(overrides: Partial<SwipeDecisionInput>): SwipeDecisionInput {
6
+ return {
7
+ dx: 0,
8
+ width: 1000,
9
+ elapsedMs: 1000,
10
+ canNext: true,
11
+ canPrev: true,
12
+ ...overrides,
13
+ };
14
+ }
15
+
16
+ describe('decideSwipe', () => {
17
+ describe('distance threshold (30% of width)', () => {
18
+ it('snaps back when the drag is below the threshold', () => {
19
+ // 100px of 1000px = 10% < 30%, and slow enough not to be a flick.
20
+ expect(decideSwipe(slow({ dx: -100 }))).toBe('snap');
21
+ expect(decideSwipe(slow({ dx: 100 }))).toBe('snap');
22
+ });
23
+
24
+ it('advances to the next slide when dragged left past the threshold', () => {
25
+ expect(decideSwipe(slow({ dx: -350 }))).toBe('next');
26
+ });
27
+
28
+ it('goes to the previous slide when dragged right past the threshold', () => {
29
+ expect(decideSwipe(slow({ dx: 350 }))).toBe('prev');
30
+ });
31
+
32
+ it('commits exactly at the threshold', () => {
33
+ expect(decideSwipe(slow({ dx: -300 }))).toBe('next');
34
+ });
35
+ });
36
+
37
+ describe('flick (fast, short drag)', () => {
38
+ it('commits on a quick flick even below the distance threshold', () => {
39
+ // 100px in 100ms = 1.0 px/ms >= 0.5, distance 100 >= 12.
40
+ expect(decideSwipe(slow({ dx: -100, elapsedMs: 100 }))).toBe('next');
41
+ expect(decideSwipe(slow({ dx: 100, elapsedMs: 100 }))).toBe('prev');
42
+ });
43
+
44
+ it('ignores a fast micro-jitter shorter than the minimum flick distance', () => {
45
+ // 8px in 5ms = 1.6 px/ms (fast) but distance 8 < 12 → treat as a click.
46
+ expect(decideSwipe(slow({ dx: -8, elapsedMs: 5 }))).toBe('snap');
47
+ });
48
+ });
49
+
50
+ describe('deck boundaries', () => {
51
+ it('snaps back instead of advancing when there is no next slide', () => {
52
+ expect(decideSwipe(slow({ dx: -350, canNext: false }))).toBe('snap');
53
+ // A flick past the last slide is also refused.
54
+ expect(decideSwipe(slow({ dx: -100, elapsedMs: 100, canNext: false }))).toBe('snap');
55
+ });
56
+
57
+ it('snaps back instead of rewinding when there is no previous slide', () => {
58
+ expect(decideSwipe(slow({ dx: 350, canPrev: false }))).toBe('snap');
59
+ expect(decideSwipe(slow({ dx: 100, elapsedMs: 100, canPrev: false }))).toBe('snap');
60
+ });
61
+ });
62
+
63
+ describe('degenerate inputs', () => {
64
+ it('snaps back on a zero-distance release', () => {
65
+ expect(decideSwipe(slow({ dx: 0 }))).toBe('snap');
66
+ });
67
+
68
+ it('never commits by distance when the container has no measurable width', () => {
69
+ // width 0 → distance threshold is Infinity; only a flick can commit.
70
+ // 500px over 2000ms = 0.25 px/ms, safely below the flick velocity.
71
+ expect(decideSwipe(slow({ dx: -500, width: 0, elapsedMs: 2000 }))).toBe('snap');
72
+ expect(decideSwipe(slow({ dx: -500, width: 0, elapsedMs: 100 }))).toBe('next');
73
+ });
74
+
75
+ it('does not divide by zero on an instantaneous release', () => {
76
+ // elapsedMs 0 → velocity treated as 0; falls back to the distance rule.
77
+ expect(decideSwipe(slow({ dx: -350, elapsedMs: 0 }))).toBe('next');
78
+ expect(decideSwipe(slow({ dx: -100, elapsedMs: 0 }))).toBe('snap');
79
+ });
80
+ });
81
+ });
@@ -1,5 +1,5 @@
1
1
  /**
2
- * AudioProvider - Abstraction for audio playback in DocPlayer
2
+ * AudioController - Abstraction for audio playback in DocPlayer
3
3
  *
4
4
  * This module defines an interface for audio playback operations that can have
5
5
  * different implementations depending on the runtime environment:
@@ -47,9 +47,9 @@ export interface AudioActions {
47
47
  restart: () => Promise<void>;
48
48
  }
49
49
 
50
- export type AudioProvider = AudioState & AudioActions;
50
+ export type AudioController = AudioState & AudioActions;
51
51
 
52
- export interface AudioProviderConfig {
52
+ export interface AudioControllerConfig {
53
53
  /** Audio track with segments */
54
54
  audioTrack: AudioTrack | undefined;
55
55
  /** Base path for resolving audio URLs */
@@ -1,5 +1,10 @@
1
- export { calculateSegmentTiming, findSegmentAtTime } from './AudioProvider';
2
- export type { AudioState, AudioActions, AudioProvider, AudioProviderConfig } from './AudioProvider';
1
+ export { calculateSegmentTiming, findSegmentAtTime } from './AudioController';
2
+ export type {
3
+ AudioState,
4
+ AudioActions,
5
+ AudioController,
6
+ AudioControllerConfig,
7
+ } from './AudioController';
3
8
 
4
9
  export { useAudioSync } from './useAudioSync';
5
10
  export { useDocPlayback } from './useDocPlayback';
@@ -7,20 +7,21 @@
7
7
  * Handles multiple audio segments (MP3 files) by tracking which segment
8
8
  * is currently playing and calculating the overall timeline position.
9
9
  *
10
- * This is the HTML5 Audio implementation of the AudioProvider interface.
11
- * For EFB/MSFS environments, use useCompanionAudioSync instead.
10
+ * This is the HTML5 Audio implementation of the AudioController interface.
11
+ * Hosts that drive audio through an external player (e.g. a native shell)
12
+ * can supply their own AudioController to DocPlayer instead of this hook.
12
13
  */
13
14
 
14
15
  import { useState, useEffect, useRef, useCallback } from 'react';
15
16
  import type { RefObject } from 'react';
16
17
  import type { AudioTrack } from '@bendyline/squisq/schemas';
17
- import type { AudioProvider } from './AudioProvider';
18
+ import type { AudioController } from './AudioController';
18
19
 
19
20
  export function useAudioSync(
20
21
  audioRef: RefObject<HTMLAudioElement>,
21
22
  audioTrack: AudioTrack | undefined,
22
23
  basePath: string = '',
23
- ): AudioProvider {
24
+ ): AudioController {
24
25
  const [currentTime, setCurrentTime] = useState(0);
25
26
  const [isPlaying, setIsPlaying] = useState(false);
26
27
  const [currentSegment, setCurrentSegment] = useState(0);
@@ -0,0 +1,265 @@
1
+ /**
2
+ * useSlideSwipe Hook
3
+ *
4
+ * Drag-to-swipe navigation for slideshow mode. Press on the current slide to
5
+ * "pick it up", drag left/right, and on release either snap back (small drag)
6
+ * or advance to the next/previous slide (large drag or a quick flick).
7
+ *
8
+ * The gesture only translates the *current* slide (a "pick up & toss" feel) —
9
+ * the incoming slide arrives via its normal enter transition once navigation
10
+ * commits. The current slide is already mounted and settled, so translating it
11
+ * is a pure `transform` with no animation conflict.
12
+ *
13
+ * Mirrors the repo's canonical pointer-drag pattern (imageEditor/CanvasSurface):
14
+ * ref-held drag start + `setPointerCapture` + window pointermove/up/cancel +
15
+ * a threshold decision on release. Navigation itself is delegated to the
16
+ * caller's `onNext`/`onPrev` (DocPlayer's `slideNavActions`).
17
+ */
18
+
19
+ import { useCallback, useEffect, useRef, useState } from 'react';
20
+ import type { RefObject } from 'react';
21
+
22
+ /** Fraction of the container width a drag must cross to commit a slide change. */
23
+ const DISTANCE_RATIO = 0.3;
24
+ /** A fast flick commits even below the distance threshold (px per millisecond). */
25
+ const FLICK_VELOCITY = 0.5;
26
+ /** Ignore flicks shorter than this so a click's micro-jitter never navigates (px). */
27
+ const MIN_FLICK_DISTANCE = 12;
28
+ /** Resistance factor applied when dragging past the first/last slide. */
29
+ const RUBBER_BAND = 0.35;
30
+ /** Default settle animation duration (ms). Must match the CSS in doc-animations.css. */
31
+ const DEFAULT_SETTLE_MS = 260;
32
+
33
+ /** The lifecycle of a swipe gesture. */
34
+ export type SwipePhase = 'idle' | 'dragging' | 'settling';
35
+
36
+ /** Outcome of a completed drag. */
37
+ export type SwipeDecision = 'next' | 'prev' | 'snap';
38
+
39
+ export interface SwipeDecisionInput {
40
+ /** Horizontal delta from drag start (px). Negative = leftward = next. */
41
+ dx: number;
42
+ /** Width of the slide container (px). */
43
+ width: number;
44
+ /** How long the drag lasted (ms). */
45
+ elapsedMs: number;
46
+ /** Whether a next slide exists. */
47
+ canNext: boolean;
48
+ /** Whether a previous slide exists. */
49
+ canPrev: boolean;
50
+ }
51
+
52
+ /**
53
+ * Pure decision: given a completed drag, should we advance, go back, or snap
54
+ * back? Committing requires either crossing the distance threshold or a fast
55
+ * flick, *and* a slide existing in that direction. Extracted from the hook so
56
+ * the threshold logic is testable without a DOM.
57
+ */
58
+ export function decideSwipe({
59
+ dx,
60
+ width,
61
+ elapsedMs,
62
+ canNext,
63
+ canPrev,
64
+ }: SwipeDecisionInput): SwipeDecision {
65
+ const distanceThreshold = width > 0 ? width * DISTANCE_RATIO : Infinity;
66
+ const velocity = elapsedMs > 0 ? Math.abs(dx) / elapsedMs : 0;
67
+ const passesDistance = Math.abs(dx) >= distanceThreshold;
68
+ const passesFlick = velocity >= FLICK_VELOCITY && Math.abs(dx) >= MIN_FLICK_DISTANCE;
69
+
70
+ if (!passesDistance && !passesFlick) return 'snap';
71
+ if (dx < 0) return canNext ? 'next' : 'snap'; // dragged left → next slide
72
+ if (dx > 0) return canPrev ? 'prev' : 'snap'; // dragged right → previous slide
73
+ return 'snap';
74
+ }
75
+
76
+ export interface UseSlideSwipeOptions {
77
+ /** Master gate — the gesture is inert unless enabled (slideshow mode, not headless). */
78
+ enabled: boolean;
79
+ /** Ref to the player container, used to measure width for the threshold. */
80
+ containerRef: RefObject<HTMLElement>;
81
+ /** Whether a next slide exists (`currentBlockIndex < total - 1`). */
82
+ canGoNext: boolean;
83
+ /** Whether a previous slide exists (`currentBlockIndex > 0`). */
84
+ canGoPrev: boolean;
85
+ /** Commit to the next slide. */
86
+ onNext: () => void;
87
+ /** Commit to the previous slide. */
88
+ onPrev: () => void;
89
+ /** Settle animation duration (ms); must match the CSS. Defaults to 260. */
90
+ settleMs?: number;
91
+ }
92
+
93
+ export interface UseSlideSwipeResult {
94
+ /** Live horizontal offset to apply to the active slide (px). */
95
+ offsetPx: number;
96
+ /** Current gesture phase — drives the transform transition class. */
97
+ phase: SwipePhase;
98
+ /** Attach to the container's `onPointerDown`. */
99
+ onPointerDown: (e: React.PointerEvent) => void;
100
+ }
101
+
102
+ interface DragState {
103
+ pointerId: number;
104
+ startX: number;
105
+ startTime: number;
106
+ target: Element;
107
+ }
108
+
109
+ /**
110
+ * Manage the swipe gesture state machine for a slideshow player.
111
+ */
112
+ export function useSlideSwipe(opts: UseSlideSwipeOptions): UseSlideSwipeResult {
113
+ const settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
114
+
115
+ const [offsetPx, setOffsetPx] = useState(0);
116
+ const [phase, setPhase] = useState<SwipePhase>('idle');
117
+
118
+ // Keep the latest options in a ref so the window listeners (bound once) never
119
+ // read stale callbacks/flags.
120
+ const optsRef = useRef(opts);
121
+ optsRef.current = opts;
122
+
123
+ const dragRef = useRef<DragState | null>(null);
124
+ const phaseRef = useRef<SwipePhase>('idle');
125
+ phaseRef.current = phase;
126
+ const settleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
127
+ const settleRaf = useRef<number | null>(null);
128
+
129
+ const clearPending = useCallback(() => {
130
+ if (settleTimer.current != null) {
131
+ clearTimeout(settleTimer.current);
132
+ settleTimer.current = null;
133
+ }
134
+ if (settleRaf.current != null) {
135
+ cancelAnimationFrame(settleRaf.current);
136
+ settleRaf.current = null;
137
+ }
138
+ }, []);
139
+
140
+ const onPointerDown = useCallback((e: React.PointerEvent) => {
141
+ const o = optsRef.current;
142
+ if (!o.enabled) return;
143
+ // Don't interrupt an in-flight fling/snap.
144
+ if (phaseRef.current === 'settling') return;
145
+ // Only the primary mouse button initiates a drag; touch/pen always do.
146
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
147
+ // Let interactive chrome (prev/next buttons, links) handle their own events.
148
+ const target = e.target as Element;
149
+ if (target.closest?.('button, a, input, textarea, select, [data-no-swipe]')) return;
150
+
151
+ dragRef.current = {
152
+ pointerId: e.pointerId,
153
+ startX: e.clientX,
154
+ startTime: performance.now(),
155
+ target,
156
+ };
157
+ try {
158
+ target.setPointerCapture?.(e.pointerId);
159
+ } catch {
160
+ // Pointer capture is best-effort; window listeners still receive events.
161
+ }
162
+ setPhase('dragging');
163
+ setOffsetPx(0);
164
+ }, []);
165
+
166
+ // Bind move/up/cancel on window once. Handlers read live state from refs, so
167
+ // they never need re-binding and never go stale.
168
+ useEffect(() => {
169
+ function currentWidth(): number {
170
+ return optsRef.current.containerRef.current?.getBoundingClientRect().width ?? 0;
171
+ }
172
+
173
+ function endDrag(drag: DragState) {
174
+ dragRef.current = null;
175
+ try {
176
+ drag.target.releasePointerCapture?.(drag.pointerId);
177
+ } catch {
178
+ // ignore — capture may already be released
179
+ }
180
+ }
181
+
182
+ function settleTo(target: number, onArrive?: () => void) {
183
+ // Switch on the transition class first (offset unchanged), then move to the
184
+ // target on the next frame so the browser reliably animates the change.
185
+ setPhase('settling');
186
+ settleRaf.current = requestAnimationFrame(() => {
187
+ settleRaf.current = null;
188
+ setOffsetPx(target);
189
+ settleTimer.current = setTimeout(() => {
190
+ settleTimer.current = null;
191
+ onArrive?.();
192
+ setOffsetPx(0);
193
+ setPhase('idle');
194
+ }, settleMs);
195
+ });
196
+ }
197
+
198
+ function onMove(e: PointerEvent) {
199
+ const drag = dragRef.current;
200
+ if (!drag || e.pointerId !== drag.pointerId) return;
201
+ const o = optsRef.current;
202
+ const raw = e.clientX - drag.startX;
203
+ const blocked = (raw > 0 && !o.canGoPrev) || (raw < 0 && !o.canGoNext);
204
+ setOffsetPx(blocked ? raw * RUBBER_BAND : raw);
205
+ }
206
+
207
+ function onUp(e: PointerEvent) {
208
+ const drag = dragRef.current;
209
+ if (!drag || e.pointerId !== drag.pointerId) return;
210
+ endDrag(drag);
211
+ const o = optsRef.current;
212
+ const rawDx = e.clientX - drag.startX;
213
+ const width = currentWidth();
214
+ const elapsedMs = performance.now() - drag.startTime;
215
+ const decision = decideSwipe({
216
+ dx: rawDx,
217
+ width,
218
+ elapsedMs,
219
+ canNext: o.canGoNext,
220
+ canPrev: o.canGoPrev,
221
+ });
222
+
223
+ if (decision === 'snap') {
224
+ settleTo(0);
225
+ return;
226
+ }
227
+ // Fling fully off-screen in the drag direction, then commit navigation.
228
+ // Never move the slide back toward center first (guard against over-drag).
229
+ const distance = Math.max(width, Math.abs(rawDx));
230
+ const target = decision === 'next' ? -distance : distance;
231
+ settleTo(target, decision === 'next' ? o.onNext : o.onPrev);
232
+ }
233
+
234
+ function onCancel(e: PointerEvent) {
235
+ const drag = dragRef.current;
236
+ if (!drag || e.pointerId !== drag.pointerId) return;
237
+ endDrag(drag);
238
+ settleTo(0);
239
+ }
240
+
241
+ window.addEventListener('pointermove', onMove);
242
+ window.addEventListener('pointerup', onUp);
243
+ window.addEventListener('pointercancel', onCancel);
244
+ return () => {
245
+ window.removeEventListener('pointermove', onMove);
246
+ window.removeEventListener('pointerup', onUp);
247
+ window.removeEventListener('pointercancel', onCancel);
248
+ };
249
+ }, [settleMs]);
250
+
251
+ // Reset all in-flight state if the gesture is disabled mid-drag (e.g. mode switch).
252
+ useEffect(() => {
253
+ if (!opts.enabled) {
254
+ dragRef.current = null;
255
+ clearPending();
256
+ setPhase('idle');
257
+ setOffsetPx(0);
258
+ }
259
+ }, [opts.enabled, clearPending]);
260
+
261
+ // Cancel any pending settle on unmount.
262
+ useEffect(() => clearPending, [clearPending]);
263
+
264
+ return { offsetPx, phase, onPointerDown };
265
+ }
package/src/index.ts CHANGED
@@ -40,7 +40,7 @@ export { MediaContext, useMediaProvider, useMediaUrl } from './hooks/MediaContex
40
40
  export { useAutoSurface } from './hooks/useAutoSurface.js';
41
41
 
42
42
  // Types
43
- export type { AudioProvider, AudioState, AudioActions } from './hooks/AudioProvider.js';
43
+ export type { AudioController, AudioState, AudioActions } from './hooks/AudioController.js';
44
44
  export type {
45
45
  PlaybackState,
46
46
  PlaybackActions,
@@ -5,13 +5,8 @@
5
5
  */
6
6
 
7
7
  import { useMemo } from 'react';
8
- import {
9
- applySurface,
10
- resolveFontFamily,
11
- type SurfaceScheme,
12
- type Theme,
13
- } from '@bendyline/squisq/schemas';
14
- import { DEFAULT_THEME } from '@bendyline/squisq/doc';
8
+ import { type SurfaceScheme, type Theme } from '@bendyline/squisq/schemas';
9
+ import { buildJsonFormTokens, resolveJsonFormTheme } from '@bendyline/squisq/jsonForm';
15
10
  import { useAutoSurface } from '../hooks/useAutoSurface';
16
11
 
17
12
  export interface JsonViewTokens {
@@ -29,29 +24,9 @@ export function useJsonViewTokens(
29
24
  const effectiveSurface = surface === 'auto' ? auto : (surface ?? undefined);
30
25
 
31
26
  return useMemo(() => {
32
- const baseTheme = theme ?? DEFAULT_THEME;
33
- const finalTheme = effectiveSurface ? applySurface(baseTheme, effectiveSurface) : baseTheme;
34
-
35
- const titleFont = resolveFontFamily(finalTheme.typography.titleFont, 'system-ui, sans-serif');
36
- const bodyFont = resolveFontFamily(finalTheme.typography.bodyFont, 'system-ui, sans-serif');
37
- const monoFont = resolveFontFamily(
38
- finalTheme.typography.monoFont,
39
- 'ui-monospace, Consolas, monospace',
40
- );
41
-
42
- const style: React.CSSProperties = {
43
- ['--squisq-json-bg' as string]: finalTheme.colors.background,
44
- ['--squisq-json-text' as string]: finalTheme.colors.text,
45
- ['--squisq-json-muted' as string]: finalTheme.colors.textMuted,
46
- ['--squisq-json-primary' as string]: finalTheme.colors.primary,
47
- ['--squisq-json-accent' as string]: finalTheme.colors.secondary,
48
- ['--squisq-json-border' as string]: `color-mix(in srgb, ${finalTheme.colors.textMuted} 35%, transparent)`,
49
- ['--squisq-json-title-font' as string]: titleFont,
50
- ['--squisq-json-body-font' as string]: bodyFont,
51
- ['--squisq-json-mono-font' as string]: monoFont,
52
- ['--squisq-json-radius' as string]: `${finalTheme.style.borderRadius ?? 8}px`,
53
- };
54
-
55
- return { style, theme: finalTheme };
27
+ const style = buildJsonFormTokens(theme, effectiveSurface, {
28
+ prefix: '--squisq-json',
29
+ }) as unknown as React.CSSProperties;
30
+ return { style, theme: resolveJsonFormTheme(theme, effectiveSurface) };
56
31
  }, [theme, effectiveSurface]);
57
32
  }
@@ -206,7 +206,7 @@ export function mount(element: Element, doc: Doc, options: MountOptions = {}): v
206
206
  });
207
207
  } else {
208
208
  content = createElement(DocPlayer, {
209
- script: finalDoc,
209
+ doc: finalDoc,
210
210
  basePath,
211
211
  displayMode: 'slideshow',
212
212
  autoPlay: renderMode ? false : autoPlay,
@@ -2215,6 +2215,32 @@
2215
2215
  z-index: 1;
2216
2216
  }
2217
2217
 
2218
+ /* Drag-to-swipe (slideshow mode) — the active slide tracks the pointer 1:1
2219
+ while dragging (no transition), then eases to its resting position (snap
2220
+ back) or flings off-screen (commit) on release. The 260ms below must match
2221
+ DEFAULT_SETTLE_MS in hooks/useSlideSwipe.ts. Like the block slide/push
2222
+ transitions, this is a brief, purposeful motion, so it is intentionally left
2223
+ running under prefers-reduced-motion (only long ambient loops are frozen). */
2224
+ .doc-player__block--active.doc-player__block--dragging {
2225
+ transition: none;
2226
+ }
2227
+
2228
+ .doc-player__block--active.doc-player__block--settling {
2229
+ transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1);
2230
+ }
2231
+
2232
+ .doc-player--swipe {
2233
+ cursor: grab;
2234
+ /* Slideshow presentation surface: suppress text selection so a mouse drag
2235
+ never starts a selection instead of a swipe. */
2236
+ -webkit-user-select: none;
2237
+ user-select: none;
2238
+ }
2239
+
2240
+ .doc-player--swipe.doc-player--grabbing {
2241
+ cursor: grabbing;
2242
+ }
2243
+
2218
2244
  /* Tap-to-toggle play/pause feedback */
2219
2245
  .doc-player__tap-feedback {
2220
2246
  position: absolute;
@@ -2347,3 +2373,23 @@
2347
2373
  animation: none;
2348
2374
  }
2349
2375
  }
2376
+
2377
+ /* ============================================
2378
+ Empty state
2379
+ Rendered by <DocPlayer> when neither `doc` nor `markdown` is provided.
2380
+ ============================================ */
2381
+ .doc-player--empty {
2382
+ min-height: 120px;
2383
+ width: 100%;
2384
+ background: #f0f0f0;
2385
+ }
2386
+
2387
+ /* ============================================
2388
+ Missing-stylesheet sentinel
2389
+ DocPlayer reads this custom property at mount (dev builds only) and
2390
+ warns when it computes empty — i.e. when the host app forgot to import
2391
+ "@bendyline/squisq-react/styles".
2392
+ ============================================ */
2393
+ .doc-player {
2394
+ --squisq-styles-loaded: 1;
2395
+ }