@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.
Files changed (46) hide show
  1. package/README.md +57 -23
  2. package/dist/index.d.ts +131 -26
  3. package/dist/index.js +1475 -755
  4. package/dist/index.js.map +1 -1
  5. package/dist/squisq-player.css +1 -1
  6. package/dist/squisq-player.css.map +1 -1
  7. package/dist/squisq-player.global.js +49 -13
  8. package/dist/squisq-player.global.js.map +1 -1
  9. package/dist/standalone-source.js +1 -1
  10. package/dist/styles/index.css +2263 -0
  11. package/package.json +9 -5
  12. package/src/BlockRenderer.tsx +15 -7
  13. package/src/DocPlayer.tsx +222 -55
  14. package/src/DocPlayerWithSidebar.tsx +21 -9
  15. package/src/DocProgressBar.tsx +21 -3
  16. package/src/LinearDocView.tsx +69 -206
  17. package/src/MarkdownRenderer.tsx +182 -41
  18. package/src/MediaClipLayer.tsx +135 -0
  19. package/src/__tests__/DocPlayer.test.tsx +81 -0
  20. package/src/__tests__/DocPlayerStylesSentinel.test.tsx +41 -0
  21. package/src/__tests__/DocProgressBar.test.tsx +76 -0
  22. package/src/__tests__/LinearDocView.test.tsx +53 -1
  23. package/src/__tests__/MarkdownRenderer.test.tsx +113 -1
  24. package/src/__tests__/PathLayer.test.tsx +73 -0
  25. package/src/__tests__/fillStyle.test.tsx +112 -0
  26. package/src/__tests__/transitionStyles.test.ts +125 -0
  27. package/src/__tests__/useDocPlayback.transition.test.ts +70 -0
  28. package/src/__tests__/useJsonViewTokens.test.ts +41 -0
  29. package/src/__tests__/useSlideSwipe.test.ts +81 -0
  30. package/src/hooks/{AudioProvider.ts → AudioController.ts} +3 -3
  31. package/src/hooks/index.ts +7 -2
  32. package/src/hooks/useAudioSync.ts +19 -5
  33. package/src/hooks/useDocPlayback.ts +81 -100
  34. package/src/hooks/useMediaSchedule.ts +39 -0
  35. package/src/hooks/useSlideSwipe.ts +265 -0
  36. package/src/index.ts +8 -1
  37. package/src/jsonView/useJsonViewTokens.ts +6 -31
  38. package/src/layers/ImageLayer.tsx +11 -1
  39. package/src/layers/PathLayer.tsx +146 -0
  40. package/src/layers/ShapeLayer.tsx +27 -5
  41. package/src/layers/TextLayer.tsx +395 -22
  42. package/src/layers/VideoLayer.tsx +16 -9
  43. package/src/layers/index.ts +1 -0
  44. package/src/standalone-entry.tsx +1 -1
  45. package/src/styles/doc-animations.css +1936 -35
  46. 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 { AudioProvider, AudioState, AudioActions } from './hooks/AudioProvider.js';
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
- applySurface,
10
- resolveFontFamily,
11
- type SurfaceScheme,
12
- type Theme,
13
- } from '@bendyline/squisq/schemas';
14
- import { DEFAULT_THEME } from '@bendyline/squisq/doc';
8
+ import { type SurfaceScheme, type Theme } from '@bendyline/squisq/schemas';
9
+ import { buildJsonFormTokens, resolveJsonFormTheme } from '@bendyline/squisq/jsonForm';
15
10
  import { useAutoSurface } from '../hooks/useAutoSurface';
16
11
 
17
12
  export interface JsonViewTokens {
@@ -29,29 +24,9 @@ export function useJsonViewTokens(
29
24
  const effectiveSurface = surface === 'auto' ? auto : (surface ?? undefined);
30
25
 
31
26
  return useMemo(() => {
32
- const baseTheme = theme ?? DEFAULT_THEME;
33
- const finalTheme = effectiveSurface ? applySurface(baseTheme, effectiveSurface) : baseTheme;
34
-
35
- const titleFont = resolveFontFamily(finalTheme.typography.titleFont, 'system-ui, sans-serif');
36
- const bodyFont = resolveFontFamily(finalTheme.typography.bodyFont, 'system-ui, sans-serif');
37
- const monoFont = resolveFontFamily(
38
- finalTheme.typography.monoFont,
39
- 'ui-monospace, Consolas, monospace',
40
- );
41
-
42
- const style: React.CSSProperties = {
43
- ['--squisq-json-bg' as string]: finalTheme.colors.background,
44
- ['--squisq-json-text' as string]: finalTheme.colors.text,
45
- ['--squisq-json-muted' as string]: finalTheme.colors.textMuted,
46
- ['--squisq-json-primary' as string]: finalTheme.colors.primary,
47
- ['--squisq-json-accent' as string]: finalTheme.colors.secondary,
48
- ['--squisq-json-border' as string]: `color-mix(in srgb, ${finalTheme.colors.textMuted} 35%, transparent)`,
49
- ['--squisq-json-title-font' as string]: titleFont,
50
- ['--squisq-json-body-font' as string]: bodyFont,
51
- ['--squisq-json-mono-font' as string]: monoFont,
52
- ['--squisq-json-radius' as string]: `${finalTheme.style.borderRadius ?? 8}px`,
53
- };
54
-
55
- return { style, theme: finalTheme };
27
+ const style = buildJsonFormTokens(theme, effectiveSurface, {
28
+ prefix: '--squisq-json',
29
+ }) as unknown as React.CSSProperties;
30
+ return { style, theme: resolveJsonFormTheme(theme, effectiveSurface) };
56
31
  }, [theme, effectiveSurface]);
57
32
  }
@@ -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
- // For CSS gradients on rect, use foreignObject with an HTML div
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
- // Common style props for native SVG shapes
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: 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>