@bendyline/squisq-react 2.0.1 → 2.2.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 (87) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE.md +20 -0
  3. package/README.md +6 -0
  4. package/dist/index.d.ts +101 -96
  5. package/dist/index.js +1483 -782
  6. package/dist/squisq-player.full.global.js +4165 -0
  7. package/dist/squisq-player.global.js +561 -168
  8. package/dist/standalone-source.d.ts +1 -0
  9. package/dist/standalone-source.js +1 -1
  10. package/dist/styles/index.css +64 -0
  11. package/package.json +8 -4
  12. package/dist/index.js.map +0 -1
  13. package/dist/squisq-player.css +0 -2
  14. package/dist/squisq-player.css.map +0 -1
  15. package/dist/squisq-player.global.js.map +0 -1
  16. package/src/BlockRenderer.tsx +0 -194
  17. package/src/CaptionOverlay.tsx +0 -111
  18. package/src/DocControlsBottom.tsx +0 -109
  19. package/src/DocControlsOverlay.tsx +0 -190
  20. package/src/DocControlsSidebar.tsx +0 -113
  21. package/src/DocControlsSlideshow.tsx +0 -360
  22. package/src/DocPlayer.tsx +0 -1528
  23. package/src/DocPlayerWithSidebar.tsx +0 -155
  24. package/src/DocProgressBar.tsx +0 -257
  25. package/src/InlineAudioPlayer.tsx +0 -46
  26. package/src/InlineVideoPlayer.tsx +0 -70
  27. package/src/LinearDocView.tsx +0 -569
  28. package/src/MarkdownRenderer.tsx +0 -624
  29. package/src/MediaClipLayer.tsx +0 -145
  30. package/src/SocialCaptionOverlay.tsx +0 -255
  31. package/src/__tests__/BlockRenderer.test.tsx +0 -235
  32. package/src/__tests__/DocControlsSlideshow.test.tsx +0 -221
  33. package/src/__tests__/DocPlayer.test.tsx +0 -635
  34. package/src/__tests__/DocPlayerStylesSentinel.test.tsx +0 -41
  35. package/src/__tests__/DocProgressBar.test.tsx +0 -102
  36. package/src/__tests__/JsonView.test.tsx +0 -111
  37. package/src/__tests__/LinearDocView.test.tsx +0 -426
  38. package/src/__tests__/MapLayer.test.tsx +0 -63
  39. package/src/__tests__/MarkdownRenderer.test.tsx +0 -357
  40. package/src/__tests__/MediaClipLayer.test.tsx +0 -70
  41. package/src/__tests__/MediaContext.test.tsx +0 -51
  42. package/src/__tests__/PathLayer.test.tsx +0 -84
  43. package/src/__tests__/TableLayer.test.tsx +0 -142
  44. package/src/__tests__/VideoLayer.test.tsx +0 -94
  45. package/src/__tests__/exports.test.ts +0 -55
  46. package/src/__tests__/fillStyle.test.tsx +0 -160
  47. package/src/__tests__/standaloneEntry.test.tsx +0 -103
  48. package/src/__tests__/transitionStyles.test.ts +0 -125
  49. package/src/__tests__/useAudioSync.test.ts +0 -49
  50. package/src/__tests__/useDocPlayback.transition.test.ts +0 -113
  51. package/src/__tests__/useJsonViewTokens.test.ts +0 -41
  52. package/src/__tests__/useSlideSwipe.test.ts +0 -81
  53. package/src/__tests__/useViewportOrientation.test.ts +0 -22
  54. package/src/hooks/AudioController.ts +0 -114
  55. package/src/hooks/MediaContext.tsx +0 -97
  56. package/src/hooks/index.ts +0 -11
  57. package/src/hooks/useAudioSync.ts +0 -456
  58. package/src/hooks/useAutoSurface.ts +0 -33
  59. package/src/hooks/useDocPlayback.ts +0 -265
  60. package/src/hooks/useMediaSchedule.ts +0 -39
  61. package/src/hooks/useSlideSwipe.ts +0 -265
  62. package/src/hooks/useViewportOrientation.ts +0 -115
  63. package/src/index.ts +0 -70
  64. package/src/jsonView/JsonView.tsx +0 -51
  65. package/src/jsonView/RenderNode.tsx +0 -51
  66. package/src/jsonView/index.ts +0 -2
  67. package/src/jsonView/json-view.css +0 -206
  68. package/src/jsonView/useJsonViewTokens.ts +0 -32
  69. package/src/jsonView/viewers.tsx +0 -343
  70. package/src/layers/ImageLayer.tsx +0 -297
  71. package/src/layers/MapLayer.tsx +0 -185
  72. package/src/layers/PathLayer.tsx +0 -155
  73. package/src/layers/ShapeLayer.tsx +0 -158
  74. package/src/layers/TableLayer.tsx +0 -129
  75. package/src/layers/TextLayer.tsx +0 -571
  76. package/src/layers/TreeLayer.tsx +0 -167
  77. package/src/layers/VideoLayer.tsx +0 -171
  78. package/src/layers/index.ts +0 -6
  79. package/src/standalone-entry.tsx +0 -337
  80. package/src/standalone-source.d.ts +0 -10
  81. package/src/styles/doc-animations.css +0 -2431
  82. package/src/styles/index.css +0 -7
  83. package/src/types.ts +0 -183
  84. package/src/utils/animationUtils.ts +0 -13
  85. package/src/utils/fillStyle.tsx +0 -148
  86. package/src/utils/layerUtils.ts +0 -42
  87. package/src/utils/mapTileUtils.ts +0 -375
@@ -1,265 +0,0 @@
1
- /**
2
- * useDocPlayback Hook
3
- *
4
- * Manages the playback state for a visual doc, including which block
5
- * is currently active, transition states, and synchronization with audio.
6
- *
7
- * This hook provides:
8
- * - Current block determination based on time
9
- * - Transition tracking (entering/exiting blocks)
10
- * - Manual navigation (next/prev block)
11
- * - Time-based seeking
12
- * - Automatic expansion of template blocks
13
- */
14
-
15
- import { useMemo, useCallback, useRef } from 'react';
16
- import type { Doc, Block, DocBlock } from '@bendyline/squisq/schemas';
17
- import type { Theme } from '@bendyline/squisq/schemas';
18
- import {
19
- DEFAULT_THEME,
20
- getBlockAtTime,
21
- resolveBlockTransition,
22
- resolveTransitionDuration,
23
- } from '@bendyline/squisq/schemas';
24
- import {
25
- expandDocBlocks,
26
- flattenRenderableBlocks,
27
- isTemplateBlock,
28
- resolvePersistentLayers,
29
- VIEWPORT_PRESETS,
30
- type ViewportConfig,
31
- } from '@bendyline/squisq/doc';
32
-
33
- interface PlaybackState {
34
- /** Currently visible block */
35
- currentBlock: Block | null;
36
- /** Index of current block */
37
- currentBlockIndex: number;
38
- /** Previous block (for transitions) */
39
- previousBlock: Block | null;
40
- /** Whether current block is entering */
41
- isEntering: boolean;
42
- /** Whether previous block is exiting */
43
- isExiting: boolean;
44
- /** Time relative to current block start */
45
- blockTime: number;
46
- /** Progress through current block (0-1) */
47
- blockProgress: number;
48
- /** Overall progress through doc (0-1) */
49
- docProgress: number;
50
- /** Expanded blocks (templates converted to full blocks with layers) */
51
- blocks: Block[];
52
- }
53
-
54
- interface PlaybackActions {
55
- /** Go to next block */
56
- nextBlock: () => void;
57
- /** Go to previous block */
58
- prevBlock: () => void;
59
- /** Go to specific block by index */
60
- goToBlock: (index: number) => void;
61
- /**
62
- * Let the identified block enter without remounting the outgoing block.
63
- * Used when another interaction (such as a swipe) already removed it.
64
- */
65
- suppressOutgoingForNextBlock: (blockId: string) => void;
66
- }
67
-
68
- export interface UseDocPlaybackOptions {
69
- /** Target viewport used to materialize template blocks. */
70
- viewport?: ViewportConfig;
71
- /** Active theme used for materialization and transition defaults. */
72
- theme?: Theme;
73
- /** Host seek callback used by block navigation actions. */
74
- onSeek?: (time: number) => void;
75
- }
76
-
77
- export function useDocPlayback(
78
- script: Doc | null,
79
- currentTime: number,
80
- options: UseDocPlaybackOptions = {},
81
- ): PlaybackState & PlaybackActions {
82
- const { viewport = VIEWPORT_PRESETS.landscape, theme, onSeek } = options;
83
- // Expand any template blocks into full blocks
84
- const blocks = useMemo(() => {
85
- if (!script?.blocks) {
86
- return [];
87
- }
88
-
89
- // Flatten nested block hierarchy (markdown-derived docs have children).
90
- // `flattenRenderableBlocks` skips the children of container templates
91
- // (`diagram`, `drawing`) — those are consumed by the parent's render as
92
- // nodes/shapes, so they must not also appear as their own slides.
93
- const hasChildren = script.blocks.some((b) => b.children && b.children.length > 0);
94
- const flatBlocks = hasChildren ? flattenRenderableBlocks(script.blocks) : script.blocks;
95
-
96
- // Check if any blocks are templates
97
- const hasTemplates = flatBlocks.some(isTemplateBlock);
98
-
99
- // Doc persistent layers win wholesale; docs without any inherit the
100
- // theme's (see resolvePersistentLayers). Passed as a narrow object so
101
- // the memo deps stay field-precise.
102
- const resolvedTheme = theme ?? DEFAULT_THEME;
103
- const persistentLayers = resolvePersistentLayers(
104
- { persistentLayers: script.persistentLayers },
105
- resolvedTheme,
106
- );
107
-
108
- if (hasTemplates) {
109
- // Extract audio segment timing for proper block synchronization
110
- const audioSegments = script.audio?.segments?.map((seg) => ({
111
- startTime: seg.startTime,
112
- duration: seg.duration,
113
- }));
114
-
115
- // Expand template blocks with audio segment timing, viewport, and persistent layers
116
- const expanded = expandDocBlocks(flatBlocks as DocBlock[], {
117
- audioSegments,
118
- viewport,
119
- persistentLayers,
120
- theme,
121
- // Custom (user-defined) templates inlined into the doc's
122
- // frontmatter — see CustomTemplates.ts. Merged onto the
123
- // built-in registry so blocks annotated with `{[myhero]}`
124
- // resolve through the user's design.
125
- customTemplates: script.customTemplates,
126
- });
127
- return expanded;
128
- }
129
-
130
- // All raw blocks — used as-is except for the theme's default transition
131
- // fallback (copies, never mutations: these blocks are caller-owned).
132
- return flatBlocks.map((block, index) => {
133
- const transition = resolveBlockTransition(block, resolvedTheme, index);
134
- return transition !== block.transition ? { ...block, transition } : block;
135
- });
136
- }, [
137
- script?.blocks,
138
- script?.audio?.segments,
139
- script?.persistentLayers,
140
- script?.customTemplates,
141
- viewport,
142
- theme,
143
- ]);
144
-
145
- // Find current block based on time
146
- const currentBlock = useMemo(() => getBlockAtTime(blocks, currentTime), [blocks, currentTime]);
147
-
148
- const currentBlockIndex = useMemo(
149
- () => (currentBlock ? blocks.indexOf(currentBlock) : -1),
150
- [blocks, currentBlock],
151
- );
152
-
153
- // Calculate block-relative time
154
- const blockTime = useMemo(() => {
155
- if (!currentBlock) return 0;
156
- return Math.max(0, currentTime - currentBlock.startTime);
157
- }, [currentBlock, currentTime]);
158
-
159
- // Calculate progress values
160
- const blockProgress = useMemo(() => {
161
- if (!currentBlock || currentBlock.duration === 0) return 0;
162
- return Math.min(1, blockTime / currentBlock.duration);
163
- }, [currentBlock, blockTime]);
164
-
165
- const docProgress = useMemo(() => {
166
- if (!script || script.duration === 0) return 0;
167
- return Math.min(1, currentTime / script.duration);
168
- }, [script, currentTime]);
169
-
170
- // ─── Transition tracking (synchronous — no effect lag) ──────────────
171
- // `isEntering` is simply "we are within the block's entrance window"
172
- // (blockTime < the transition's duration). Deriving it during render —
173
- // rather than flipping it in an effect a frame AFTER the block changes —
174
- // means a newly-active block renders WITH its entrance state on its very
175
- // first frame. Otherwise the block paints once fully settled and then, a
176
- // frame later, snaps back to the start of its entrance animation: the brief
177
- // "flash then re-animate" seen between blocks. This runs identically for
178
- // real-time playback and frame-seeked render (export) mode.
179
- //
180
- // The block we transitioned FROM (for the crossfade) is tracked with refs
181
- // updated during render — the standard "previous value" pattern — so the
182
- // outgoing block is known on the SAME frame the new block becomes active
183
- // (an effect would lag a frame and drop the crossfade's first frames).
184
- const outgoingBlockRef = useRef<Block | null>(null);
185
- const activeBlockIdRef = useRef<string | null>(null);
186
- const lastRenderedBlockRef = useRef<Block | null>(null);
187
- const suppressOutgoingTargetRef = useRef<string | null>(null);
188
- const suppressOutgoingForNextBlock = useCallback((blockId: string) => {
189
- // The managed cover is outside the document timeline, so revealing block
190
- // one can target the block that is already active underneath it. Clear any
191
- // stale outgoing context immediately; the cover visibility update will
192
- // provide the render that observes this ref change.
193
- if (activeBlockIdRef.current === blockId) {
194
- outgoingBlockRef.current = null;
195
- suppressOutgoingTargetRef.current = null;
196
- return;
197
- }
198
- suppressOutgoingTargetRef.current = blockId;
199
- }, []);
200
- if (currentBlock && currentBlock.id !== activeBlockIdRef.current) {
201
- // A swipe has already carried the outgoing slide fully off-screen. Keep
202
- // the incoming block's own transition, but do not re-mount the old block
203
- // as transition context when the armed destination becomes active.
204
- const suppressOutgoing = suppressOutgoingTargetRef.current === currentBlock.id;
205
- outgoingBlockRef.current = suppressOutgoing ? null : lastRenderedBlockRef.current;
206
- // Consume on the first real block change even if the destination did not
207
- // match (for example, a host performed a different seek in between).
208
- suppressOutgoingTargetRef.current = null;
209
- activeBlockIdRef.current = currentBlock.id;
210
- }
211
- lastRenderedBlockRef.current = currentBlock;
212
-
213
- const transitionDuration = currentBlock?.transition
214
- ? resolveTransitionDuration(currentBlock.transition)
215
- : 0;
216
- const isEntering = !!currentBlock && transitionDuration > 0 && blockTime < transitionDuration;
217
- const outgoingBlock = outgoingBlockRef.current;
218
- // Only crossfade a genuinely different outgoing block (guards restarts/seeks
219
- // where the "previous" resolves to the same block).
220
- const isExiting = isEntering && outgoingBlock != null && outgoingBlock.id !== currentBlock?.id;
221
- const previousBlock = isExiting ? outgoingBlock : null;
222
-
223
- // Manual navigation
224
- const goToBlock = useCallback(
225
- (index: number) => {
226
- if (!script || index < 0 || index >= blocks.length) return;
227
- const targetBlock = blocks[index];
228
- if (targetBlock) {
229
- onSeek?.(targetBlock.startTime);
230
- }
231
- },
232
- [script, blocks, onSeek],
233
- );
234
-
235
- const nextBlock = useCallback(() => {
236
- if (currentBlockIndex < blocks.length - 1) {
237
- return goToBlock(currentBlockIndex + 1);
238
- }
239
- }, [currentBlockIndex, blocks.length, goToBlock]);
240
-
241
- const prevBlock = useCallback(() => {
242
- if (currentBlockIndex > 0) {
243
- return goToBlock(currentBlockIndex - 1);
244
- }
245
- }, [currentBlockIndex, goToBlock]);
246
-
247
- return {
248
- currentBlock,
249
- currentBlockIndex,
250
- previousBlock,
251
- isEntering,
252
- isExiting,
253
- blockTime,
254
- blockProgress,
255
- docProgress,
256
- nextBlock,
257
- prevBlock,
258
- goToBlock,
259
- suppressOutgoingForNextBlock,
260
- /** Expanded blocks (templates converted to full blocks with layers) */
261
- blocks,
262
- };
263
- }
264
-
265
- export default useDocPlayback;
@@ -1,39 +0,0 @@
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
- }
@@ -1,265 +0,0 @@
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
- }
@@ -1,115 +0,0 @@
1
- /**
2
- * useViewportOrientation Hook
3
- *
4
- * Detects the current viewport orientation and returns the appropriate
5
- * VIEWPORT_PRESET for rendering docs. Automatically updates when
6
- * the window is resized.
7
- *
8
- * Thresholds:
9
- * - Portrait: height > width * 1.2 (significantly taller than wide)
10
- * - Square: width and height within 20% of each other
11
- * - Landscape: width > height * 1.2 (significantly wider than tall)
12
- */
13
-
14
- import { useState, useEffect, useMemo } from 'react';
15
- import {
16
- VIEWPORT_PRESETS,
17
- type ViewportConfig,
18
- type ViewportOrientation,
19
- } from '@bendyline/squisq/doc';
20
-
21
- interface UseViewportOrientationResult {
22
- /** Current viewport preset configuration */
23
- viewport: ViewportConfig;
24
- /** Current orientation name */
25
- orientation: ViewportOrientation;
26
- /** Current window dimensions */
27
- windowSize: { width: number; height: number };
28
- }
29
-
30
- /**
31
- * Determine viewport orientation from window dimensions.
32
- */
33
- function getOrientationFromWindow(width: number, height: number): ViewportOrientation {
34
- const ratio = width / height;
35
-
36
- // Use thresholds to determine orientation
37
- // - Ratio > 1.2 = landscape (wider than tall)
38
- // - Ratio < 0.83 (1/1.2) = portrait (taller than wide)
39
- // - Otherwise = square-ish
40
- if (ratio > 1.2) {
41
- return 'landscape';
42
- } else if (ratio < 0.83) {
43
- return 'portrait';
44
- } else {
45
- return 'square';
46
- }
47
- }
48
-
49
- /**
50
- * Get the appropriate viewport preset for an orientation.
51
- */
52
- function getViewportForOrientation(orientation: ViewportOrientation): ViewportConfig {
53
- switch (orientation) {
54
- case 'portrait':
55
- return VIEWPORT_PRESETS.portrait;
56
- case 'square':
57
- return VIEWPORT_PRESETS.square;
58
- case 'landscape':
59
- default:
60
- return VIEWPORT_PRESETS.landscape;
61
- }
62
- }
63
-
64
- /**
65
- * Hook to detect viewport orientation and return appropriate preset.
66
- * Updates automatically when window is resized.
67
- */
68
- export function useViewportOrientation(): UseViewportOrientationResult {
69
- const [windowSize, setWindowSize] = useState(() => ({
70
- width: typeof window !== 'undefined' ? window.innerWidth : 1920,
71
- height: typeof window !== 'undefined' ? window.innerHeight : 1080,
72
- }));
73
-
74
- // Listen for window resize
75
- useEffect(() => {
76
- if (typeof window === 'undefined') return;
77
-
78
- const handleResize = () => {
79
- setWindowSize({
80
- width: window.innerWidth,
81
- height: window.innerHeight,
82
- });
83
- };
84
-
85
- // Debounce resize handler to avoid excessive re-renders
86
- let timeoutId: ReturnType<typeof setTimeout>;
87
- const debouncedResize = () => {
88
- clearTimeout(timeoutId);
89
- timeoutId = setTimeout(handleResize, 100);
90
- };
91
-
92
- window.addEventListener('resize', debouncedResize);
93
- return () => {
94
- window.removeEventListener('resize', debouncedResize);
95
- clearTimeout(timeoutId);
96
- };
97
- }, []);
98
-
99
- // Calculate orientation from window size
100
- const orientation = useMemo(
101
- () => getOrientationFromWindow(windowSize.width, windowSize.height),
102
- [windowSize.width, windowSize.height],
103
- );
104
-
105
- // Get appropriate viewport preset
106
- const viewport = useMemo(() => getViewportForOrientation(orientation), [orientation]);
107
-
108
- return {
109
- viewport,
110
- orientation,
111
- windowSize,
112
- };
113
- }
114
-
115
- export default useViewportOrientation;