@bendyline/squisq-react 1.3.2 → 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.
- package/README.md +57 -23
- package/dist/index.d.ts +131 -26
- package/dist/index.js +1475 -755
- package/dist/index.js.map +1 -1
- package/dist/squisq-player.css +1 -1
- package/dist/squisq-player.css.map +1 -1
- package/dist/squisq-player.global.js +49 -13
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/dist/styles/index.css +2263 -0
- package/package.json +9 -5
- package/src/BlockRenderer.tsx +15 -7
- package/src/DocPlayer.tsx +222 -55
- package/src/DocPlayerWithSidebar.tsx +21 -9
- package/src/DocProgressBar.tsx +21 -3
- package/src/LinearDocView.tsx +69 -206
- package/src/MarkdownRenderer.tsx +182 -41
- package/src/MediaClipLayer.tsx +135 -0
- package/src/__tests__/DocPlayer.test.tsx +81 -0
- package/src/__tests__/DocPlayerStylesSentinel.test.tsx +41 -0
- package/src/__tests__/DocProgressBar.test.tsx +76 -0
- package/src/__tests__/LinearDocView.test.tsx +53 -1
- package/src/__tests__/MarkdownRenderer.test.tsx +113 -1
- package/src/__tests__/PathLayer.test.tsx +73 -0
- package/src/__tests__/fillStyle.test.tsx +112 -0
- package/src/__tests__/transitionStyles.test.ts +125 -0
- package/src/__tests__/useDocPlayback.transition.test.ts +70 -0
- package/src/__tests__/useJsonViewTokens.test.ts +41 -0
- package/src/__tests__/useSlideSwipe.test.ts +81 -0
- package/src/hooks/{AudioProvider.ts → AudioController.ts} +3 -3
- package/src/hooks/index.ts +7 -2
- package/src/hooks/useAudioSync.ts +19 -5
- package/src/hooks/useDocPlayback.ts +81 -100
- package/src/hooks/useMediaSchedule.ts +39 -0
- package/src/hooks/useSlideSwipe.ts +265 -0
- package/src/index.ts +8 -1
- package/src/jsonView/useJsonViewTokens.ts +6 -31
- package/src/layers/ImageLayer.tsx +11 -1
- package/src/layers/PathLayer.tsx +146 -0
- package/src/layers/ShapeLayer.tsx +27 -5
- package/src/layers/TextLayer.tsx +395 -22
- package/src/layers/VideoLayer.tsx +16 -9
- package/src/layers/index.ts +1 -0
- package/src/standalone-entry.tsx +1 -1
- package/src/styles/doc-animations.css +1936 -35
- package/src/utils/fillStyle.tsx +148 -0
|
@@ -0,0 +1,39 @@
|
|
|
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
|
+
}
|
|
@@ -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
|
@@ -21,19 +21,26 @@ export type { InlineAudioPlayerProps } from './InlineAudioPlayer.js';
|
|
|
21
21
|
export { ImageLayer } from './layers/ImageLayer.js';
|
|
22
22
|
export { TextLayer } from './layers/TextLayer.js';
|
|
23
23
|
export { ShapeLayer } from './layers/ShapeLayer.js';
|
|
24
|
+
export { PathLayer } from './layers/PathLayer.js';
|
|
24
25
|
export { VideoLayer } from './layers/VideoLayer.js';
|
|
25
26
|
export { TableLayer } from './layers/TableLayer.js';
|
|
26
27
|
export { MapLayer } from './layers/MapLayer.js';
|
|
27
28
|
|
|
29
|
+
// Timed media clips (block.media + doc.documentMedia playback)
|
|
30
|
+
export { MediaClipLayer } from './MediaClipLayer.js';
|
|
31
|
+
export type { MediaClipLayerProps } from './MediaClipLayer.js';
|
|
32
|
+
|
|
28
33
|
// Hooks
|
|
29
34
|
export { useAudioSync } from './hooks/useAudioSync.js';
|
|
35
|
+
export { useMediaSchedule } from './hooks/useMediaSchedule.js';
|
|
36
|
+
export type { MediaScheduleController } from './hooks/useMediaSchedule.js';
|
|
30
37
|
export { useDocPlayback } from './hooks/useDocPlayback.js';
|
|
31
38
|
export { useViewportOrientation } from './hooks/useViewportOrientation.js';
|
|
32
39
|
export { MediaContext, useMediaProvider, useMediaUrl } from './hooks/MediaContext.js';
|
|
33
40
|
export { useAutoSurface } from './hooks/useAutoSurface.js';
|
|
34
41
|
|
|
35
42
|
// Types
|
|
36
|
-
export type {
|
|
43
|
+
export type { AudioController, AudioState, AudioActions } from './hooks/AudioController.js';
|
|
37
44
|
export type {
|
|
38
45
|
PlaybackState,
|
|
39
46
|
PlaybackActions,
|
|
@@ -5,13 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { useMemo } from 'react';
|
|
8
|
-
import {
|
|
9
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { ImageLayer as ImageLayerType, Animation } from '@bendyline/squisq/schemas';
|
|
10
|
+
import { cssFilterForTreatment } from '@bendyline/squisq/doc';
|
|
10
11
|
import { getAnimationStyle } from '../utils/animationUtils';
|
|
11
12
|
import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
|
|
12
13
|
import { useMediaUrl } from '../hooks/MediaContext';
|
|
@@ -41,6 +42,10 @@ export function ImageLayer({ layer, basePath, viewport, blockTime }: ImageLayerP
|
|
|
41
42
|
// Get animation styles
|
|
42
43
|
const animStyle = getAnimationStyle(animation, blockTime);
|
|
43
44
|
|
|
45
|
+
// Theme-derived photographic grade + optional blur, as a CSS filter
|
|
46
|
+
// string (identical in the player and in headless frame capture).
|
|
47
|
+
const filter = cssFilterForTreatment(content.treatment, content.blur);
|
|
48
|
+
|
|
44
49
|
// SVG preserveAspectRatio based on fit mode
|
|
45
50
|
const preserveAspectRatio = getPreserveAspectRatio(content.fit);
|
|
46
51
|
const isCover = content.fit === 'cover';
|
|
@@ -78,6 +83,7 @@ export function ImageLayer({ layer, basePath, viewport, blockTime }: ImageLayerP
|
|
|
78
83
|
display: 'block',
|
|
79
84
|
pointerEvents: 'none',
|
|
80
85
|
transformOrigin: 'center center',
|
|
86
|
+
...(filter ? { filter } : {}),
|
|
81
87
|
...kbStyle.style,
|
|
82
88
|
}}
|
|
83
89
|
/>
|
|
@@ -107,6 +113,10 @@ export function ImageLayer({ layer, basePath, viewport, blockTime }: ImageLayerP
|
|
|
107
113
|
objectPosition: 'center',
|
|
108
114
|
display: 'block',
|
|
109
115
|
pointerEvents: 'none',
|
|
116
|
+
...(filter ? { filter } : {}),
|
|
117
|
+
// Over-scan blurred imagery so the soft edges never reveal
|
|
118
|
+
// the frame behind the layer.
|
|
119
|
+
...(content.blur && content.blur > 0 ? { transform: 'scale(1.06)' } : {}),
|
|
110
120
|
}}
|
|
111
121
|
/>
|
|
112
122
|
</foreignObject>
|
|
@@ -128,7 +138,7 @@ export function ImageLayer({ layer, basePath, viewport, blockTime }: ImageLayerP
|
|
|
128
138
|
width={width}
|
|
129
139
|
height={height}
|
|
130
140
|
preserveAspectRatio={preserveAspectRatio}
|
|
131
|
-
style={{ pointerEvents: 'none' }}
|
|
141
|
+
style={{ pointerEvents: 'none', ...(filter ? { filter } : {}) }}
|
|
132
142
|
/>
|
|
133
143
|
</g>
|
|
134
144
|
);
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PathLayer Component
|
|
3
|
+
*
|
|
4
|
+
* Renders an SVG `<path>` for arbitrary curves, connectors, arrows, and the
|
|
5
|
+
* drawing template's computed shapes. Used by the diagram template for edges
|
|
6
|
+
* between nodes; usable by any template that needs a non-rect/circle/line
|
|
7
|
+
* shape.
|
|
8
|
+
*
|
|
9
|
+
* The path's `d` attribute uses absolute SVG coordinates relative to the
|
|
10
|
+
* block viewport (independent of the layer's `position` box, which is
|
|
11
|
+
* present only so animations and clipping match the other layer types).
|
|
12
|
+
*
|
|
13
|
+
* Exception: when `content.shapeKind` is set (a standard named shape like
|
|
14
|
+
* `diamond` / `star` / `arrow-right`), `d` is re-derived from the layer's
|
|
15
|
+
* `position` box resolved against the viewport. That keeps named shapes
|
|
16
|
+
* movable, resizable, and aspect-ratio-adaptive — matching how the native
|
|
17
|
+
* rect/circle/line `ShapeLayer` behaves — rather than pinned to a baked
|
|
18
|
+
* absolute path.
|
|
19
|
+
*
|
|
20
|
+
* End markers are configured via `startMarker`/`endMarker` (with the legacy
|
|
21
|
+
* `arrow` flag mapping to a filled triangle). Marker geometry comes from
|
|
22
|
+
* `markerPath` in core so the SSR renderer and the editor agree.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { PathLayer as PathLayerType, MarkerStyle } from '@bendyline/squisq/schemas';
|
|
26
|
+
import { markerPath, shapePath } from '@bendyline/squisq/doc';
|
|
27
|
+
import { getAnimationStyle } from '../utils/animationUtils';
|
|
28
|
+
import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
|
|
29
|
+
import { resolveFill, borderDashArray } from '../utils/fillStyle';
|
|
30
|
+
|
|
31
|
+
interface PathLayerProps {
|
|
32
|
+
layer: PathLayerType;
|
|
33
|
+
/** Viewport dimensions — used to resolve `%` positions for named shapes. */
|
|
34
|
+
viewport: { width: number; height: number };
|
|
35
|
+
/** Current time relative to block start. */
|
|
36
|
+
blockTime: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The effective `d`: a named shape (`content.shapeKind`) is re-derived
|
|
41
|
+
* from the resolved `position` box so it tracks moves/resizes and adapts
|
|
42
|
+
* to the viewport; everything else uses the stored absolute path.
|
|
43
|
+
*/
|
|
44
|
+
function effectivePath(layer: PathLayerType, viewport: { width: number; height: number }): string {
|
|
45
|
+
const { content, position } = layer;
|
|
46
|
+
if (!content.shapeKind) return content.d;
|
|
47
|
+
const w = position.width ? resolveValue(position.width, viewport.width) : 0;
|
|
48
|
+
const h = position.height ? resolveValue(position.height, viewport.height) : 0;
|
|
49
|
+
const rawX = resolveValue(position.x, viewport.width);
|
|
50
|
+
const rawY = resolveValue(position.y, viewport.height);
|
|
51
|
+
const anchor = getAnchorOffset(position.anchor, w, h);
|
|
52
|
+
const derived = shapePath(content.shapeKind, rawX + anchor.x, rawY + anchor.y, w, h);
|
|
53
|
+
// Fall back to the stored path if the kind is unknown to `shapePath`.
|
|
54
|
+
return derived ?? content.d;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Resolve the effective marker for an endpoint (explicit field, else `arrow`). */
|
|
58
|
+
function effectiveMarker(
|
|
59
|
+
explicit: MarkerStyle | undefined,
|
|
60
|
+
arrow: PathLayerType['content']['arrow'],
|
|
61
|
+
end: 'start' | 'end',
|
|
62
|
+
): MarkerStyle {
|
|
63
|
+
if (explicit) return explicit;
|
|
64
|
+
const wants = arrow === 'both' || arrow === end;
|
|
65
|
+
return wants ? 'arrow' : 'none';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function PathLayer({ layer, viewport, blockTime }: PathLayerProps) {
|
|
69
|
+
const { content, animation, id } = layer;
|
|
70
|
+
const d = effectivePath(layer, viewport);
|
|
71
|
+
const stroke = content.stroke ?? '#1e293b';
|
|
72
|
+
const strokeWidth = content.strokeWidth ?? 2;
|
|
73
|
+
const { fill, def: fillDef } = resolveFill(id, content.fill ?? 'none', content.gradient);
|
|
74
|
+
// `borderStyle` (named shapes) takes precedence over a raw `dasharray`.
|
|
75
|
+
const dash = content.borderStyle
|
|
76
|
+
? borderDashArray(content.borderStyle, strokeWidth)
|
|
77
|
+
: content.dasharray;
|
|
78
|
+
const animStyle = getAnimationStyle(animation, blockTime);
|
|
79
|
+
|
|
80
|
+
const startId = `marker-start-${id}`;
|
|
81
|
+
const endId = `marker-end-${id}`;
|
|
82
|
+
const start = markerPath(effectiveMarker(content.startMarker, content.arrow, 'start'), 'start');
|
|
83
|
+
const end = markerPath(effectiveMarker(content.endMarker, content.arrow, 'end'), 'end');
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<g
|
|
87
|
+
className={`block-layer block-layer--path ${animStyle.className}`}
|
|
88
|
+
style={animStyle.style}
|
|
89
|
+
data-layer-id={id}
|
|
90
|
+
>
|
|
91
|
+
<defs>
|
|
92
|
+
{fillDef}
|
|
93
|
+
{end && <MarkerDef id={endId} dir="end" d={end.d} filled={end.filled} stroke={stroke} />}
|
|
94
|
+
{start && (
|
|
95
|
+
<MarkerDef id={startId} dir="start" d={start.d} filled={start.filled} stroke={stroke} />
|
|
96
|
+
)}
|
|
97
|
+
</defs>
|
|
98
|
+
<path
|
|
99
|
+
d={d}
|
|
100
|
+
stroke={stroke}
|
|
101
|
+
strokeWidth={strokeWidth}
|
|
102
|
+
fill={fill}
|
|
103
|
+
fillOpacity={content.fillOpacity}
|
|
104
|
+
strokeDasharray={dash}
|
|
105
|
+
markerStart={start ? `url(#${startId})` : undefined}
|
|
106
|
+
markerEnd={end ? `url(#${endId})` : undefined}
|
|
107
|
+
/>
|
|
108
|
+
</g>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function MarkerDef({
|
|
113
|
+
id,
|
|
114
|
+
dir,
|
|
115
|
+
d,
|
|
116
|
+
filled,
|
|
117
|
+
stroke,
|
|
118
|
+
}: {
|
|
119
|
+
id: string;
|
|
120
|
+
dir: 'start' | 'end';
|
|
121
|
+
d: string;
|
|
122
|
+
filled: boolean;
|
|
123
|
+
stroke: string;
|
|
124
|
+
}) {
|
|
125
|
+
return (
|
|
126
|
+
<marker
|
|
127
|
+
id={id}
|
|
128
|
+
viewBox="0 0 10 10"
|
|
129
|
+
refX={dir === 'end' ? 9 : 1}
|
|
130
|
+
refY={5}
|
|
131
|
+
markerWidth={4}
|
|
132
|
+
markerHeight={4}
|
|
133
|
+
orient="auto-start-reverse"
|
|
134
|
+
markerUnits="strokeWidth"
|
|
135
|
+
>
|
|
136
|
+
<path
|
|
137
|
+
d={d}
|
|
138
|
+
fill={filled ? stroke : 'none'}
|
|
139
|
+
stroke={filled ? 'none' : stroke}
|
|
140
|
+
strokeWidth={filled ? undefined : 1.5}
|
|
141
|
+
/>
|
|
142
|
+
</marker>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export default PathLayer;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { ShapeLayer as ShapeLayerType } from '@bendyline/squisq/schemas';
|
|
9
9
|
import { getAnimationStyle } from '../utils/animationUtils';
|
|
10
10
|
import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
|
|
11
|
+
import { resolveFill, resolveShapeFilter, borderDashArray } from '../utils/fillStyle';
|
|
11
12
|
|
|
12
13
|
interface ShapeLayerProps {
|
|
13
14
|
layer: ShapeLayerType;
|
|
@@ -34,12 +35,14 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
|
|
|
34
35
|
// Get animation styles
|
|
35
36
|
const animStyle = getAnimationStyle(animation, blockTime);
|
|
36
37
|
|
|
37
|
-
// Check if fill is a CSS gradient (SVG rect doesn't support CSS gradients natively)
|
|
38
38
|
const fill = content.fill || 'none';
|
|
39
|
+
// Legacy: a CSS gradient string baked into `fill` (e.g. from older docs)
|
|
40
|
+
// only works as an HTML background, so rect renders it via foreignObject.
|
|
41
|
+
// The structured `content.gradient` (preferred) is handled below for all
|
|
42
|
+
// shapes via an SVG <linearGradient>.
|
|
39
43
|
const isCSSGradient = typeof fill === 'string' && fill.includes('gradient(');
|
|
40
44
|
|
|
41
|
-
|
|
42
|
-
if (content.shape === 'rect' && isCSSGradient) {
|
|
45
|
+
if (content.shape === 'rect' && isCSSGradient && !content.gradient) {
|
|
43
46
|
return (
|
|
44
47
|
<g
|
|
45
48
|
className={`block-layer block-layer--shape ${animStyle.className}`}
|
|
@@ -61,11 +64,23 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
|
|
|
61
64
|
);
|
|
62
65
|
}
|
|
63
66
|
|
|
64
|
-
|
|
67
|
+
const { fill: fillValue, def: fillDef } = resolveFill(
|
|
68
|
+
layer.id,
|
|
69
|
+
fill,
|
|
70
|
+
content.gradient,
|
|
71
|
+
content.pattern,
|
|
72
|
+
);
|
|
73
|
+
const { filterAttr, def: filterDef } = resolveShapeFilter(layer.id, content.filter);
|
|
74
|
+
const dash = borderDashArray(content.borderStyle, content.strokeWidth);
|
|
75
|
+
|
|
76
|
+
// Common style props for native SVG shapes. `line` is stroke-only.
|
|
65
77
|
const shapeProps = {
|
|
66
|
-
fill:
|
|
78
|
+
fill: fillValue,
|
|
79
|
+
fillOpacity: content.fillOpacity,
|
|
67
80
|
stroke: content.stroke,
|
|
68
81
|
strokeWidth: content.strokeWidth,
|
|
82
|
+
strokeDasharray: dash,
|
|
83
|
+
...(filterAttr ? { filter: filterAttr } : {}),
|
|
69
84
|
};
|
|
70
85
|
|
|
71
86
|
return (
|
|
@@ -74,6 +89,12 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
|
|
|
74
89
|
style={animStyle.style}
|
|
75
90
|
data-layer-id={layer.id}
|
|
76
91
|
>
|
|
92
|
+
{(fillDef || filterDef) && (
|
|
93
|
+
<defs>
|
|
94
|
+
{fillDef}
|
|
95
|
+
{filterDef}
|
|
96
|
+
</defs>
|
|
97
|
+
)}
|
|
77
98
|
{content.shape === 'rect' && (
|
|
78
99
|
<rect
|
|
79
100
|
x={x}
|
|
@@ -103,6 +124,7 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
|
|
|
103
124
|
y2={y + height}
|
|
104
125
|
stroke={content.stroke || '#ffffff'}
|
|
105
126
|
strokeWidth={content.strokeWidth || 2}
|
|
127
|
+
strokeDasharray={dash}
|
|
106
128
|
/>
|
|
107
129
|
)}
|
|
108
130
|
</g>
|