@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.
- package/LICENSE +21 -0
- package/NOTICE.md +20 -0
- package/README.md +6 -0
- package/dist/index.d.ts +101 -96
- package/dist/index.js +1483 -782
- package/dist/squisq-player.full.global.js +4165 -0
- package/dist/squisq-player.global.js +561 -168
- package/dist/standalone-source.d.ts +1 -0
- package/dist/standalone-source.js +1 -1
- package/dist/styles/index.css +64 -0
- package/package.json +8 -4
- package/dist/index.js.map +0 -1
- package/dist/squisq-player.css +0 -2
- package/dist/squisq-player.css.map +0 -1
- package/dist/squisq-player.global.js.map +0 -1
- package/src/BlockRenderer.tsx +0 -194
- package/src/CaptionOverlay.tsx +0 -111
- package/src/DocControlsBottom.tsx +0 -109
- package/src/DocControlsOverlay.tsx +0 -190
- package/src/DocControlsSidebar.tsx +0 -113
- package/src/DocControlsSlideshow.tsx +0 -360
- package/src/DocPlayer.tsx +0 -1528
- package/src/DocPlayerWithSidebar.tsx +0 -155
- package/src/DocProgressBar.tsx +0 -257
- package/src/InlineAudioPlayer.tsx +0 -46
- package/src/InlineVideoPlayer.tsx +0 -70
- package/src/LinearDocView.tsx +0 -569
- package/src/MarkdownRenderer.tsx +0 -624
- package/src/MediaClipLayer.tsx +0 -145
- package/src/SocialCaptionOverlay.tsx +0 -255
- package/src/__tests__/BlockRenderer.test.tsx +0 -235
- package/src/__tests__/DocControlsSlideshow.test.tsx +0 -221
- package/src/__tests__/DocPlayer.test.tsx +0 -635
- package/src/__tests__/DocPlayerStylesSentinel.test.tsx +0 -41
- package/src/__tests__/DocProgressBar.test.tsx +0 -102
- package/src/__tests__/JsonView.test.tsx +0 -111
- package/src/__tests__/LinearDocView.test.tsx +0 -426
- package/src/__tests__/MapLayer.test.tsx +0 -63
- package/src/__tests__/MarkdownRenderer.test.tsx +0 -357
- package/src/__tests__/MediaClipLayer.test.tsx +0 -70
- package/src/__tests__/MediaContext.test.tsx +0 -51
- package/src/__tests__/PathLayer.test.tsx +0 -84
- package/src/__tests__/TableLayer.test.tsx +0 -142
- package/src/__tests__/VideoLayer.test.tsx +0 -94
- package/src/__tests__/exports.test.ts +0 -55
- package/src/__tests__/fillStyle.test.tsx +0 -160
- package/src/__tests__/standaloneEntry.test.tsx +0 -103
- package/src/__tests__/transitionStyles.test.ts +0 -125
- package/src/__tests__/useAudioSync.test.ts +0 -49
- package/src/__tests__/useDocPlayback.transition.test.ts +0 -113
- package/src/__tests__/useJsonViewTokens.test.ts +0 -41
- package/src/__tests__/useSlideSwipe.test.ts +0 -81
- package/src/__tests__/useViewportOrientation.test.ts +0 -22
- package/src/hooks/AudioController.ts +0 -114
- package/src/hooks/MediaContext.tsx +0 -97
- package/src/hooks/index.ts +0 -11
- package/src/hooks/useAudioSync.ts +0 -456
- package/src/hooks/useAutoSurface.ts +0 -33
- package/src/hooks/useDocPlayback.ts +0 -265
- package/src/hooks/useMediaSchedule.ts +0 -39
- package/src/hooks/useSlideSwipe.ts +0 -265
- package/src/hooks/useViewportOrientation.ts +0 -115
- package/src/index.ts +0 -70
- package/src/jsonView/JsonView.tsx +0 -51
- package/src/jsonView/RenderNode.tsx +0 -51
- package/src/jsonView/index.ts +0 -2
- package/src/jsonView/json-view.css +0 -206
- package/src/jsonView/useJsonViewTokens.ts +0 -32
- package/src/jsonView/viewers.tsx +0 -343
- package/src/layers/ImageLayer.tsx +0 -297
- package/src/layers/MapLayer.tsx +0 -185
- package/src/layers/PathLayer.tsx +0 -155
- package/src/layers/ShapeLayer.tsx +0 -158
- package/src/layers/TableLayer.tsx +0 -129
- package/src/layers/TextLayer.tsx +0 -571
- package/src/layers/TreeLayer.tsx +0 -167
- package/src/layers/VideoLayer.tsx +0 -171
- package/src/layers/index.ts +0 -6
- package/src/standalone-entry.tsx +0 -337
- package/src/standalone-source.d.ts +0 -10
- package/src/styles/doc-animations.css +0 -2431
- package/src/styles/index.css +0 -7
- package/src/types.ts +0 -183
- package/src/utils/animationUtils.ts +0 -13
- package/src/utils/fillStyle.tsx +0 -148
- package/src/utils/layerUtils.ts +0 -42
- 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;
|