@bendyline/squisq-react 0.1.2
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/dist/index.d.ts +563 -0
- package/dist/index.js +3180 -0
- package/dist/index.js.map +1 -0
- package/dist/squisq-player.css +2 -0
- package/dist/squisq-player.css.map +1 -0
- package/dist/squisq-player.global.js +6 -0
- package/dist/squisq-player.global.js.map +1 -0
- package/dist/standalone-source.d.ts +2 -0
- package/dist/standalone-source.js +2 -0
- package/package.json +69 -0
- package/src/BlockRenderer.tsx +146 -0
- package/src/CaptionOverlay.tsx +86 -0
- package/src/DocControlsBottom.tsx +103 -0
- package/src/DocControlsOverlay.tsx +178 -0
- package/src/DocControlsSidebar.tsx +107 -0
- package/src/DocControlsSlideshow.tsx +132 -0
- package/src/DocPlayer.tsx +1005 -0
- package/src/DocPlayerWithSidebar.tsx +138 -0
- package/src/DocProgressBar.tsx +200 -0
- package/src/LinearDocView.tsx +313 -0
- package/src/MarkdownRenderer.tsx +360 -0
- package/src/__tests__/BlockRenderer.test.tsx +105 -0
- package/src/__tests__/DocControlsSlideshow.test.tsx +127 -0
- package/src/__tests__/LinearDocView.test.tsx +180 -0
- package/src/__tests__/MarkdownRenderer.test.tsx +234 -0
- package/src/__tests__/exports.test.ts +55 -0
- package/src/hooks/AudioProvider.ts +114 -0
- package/src/hooks/MediaContext.tsx +81 -0
- package/src/hooks/index.ts +6 -0
- package/src/hooks/useAudioSync.ts +390 -0
- package/src/hooks/useDocPlayback.ts +251 -0
- package/src/hooks/useViewportOrientation.ts +117 -0
- package/src/index.ts +46 -0
- package/src/layers/ImageLayer.tsx +182 -0
- package/src/layers/MapLayer.tsx +184 -0
- package/src/layers/ShapeLayer.tsx +107 -0
- package/src/layers/TextLayer.tsx +197 -0
- package/src/layers/VideoLayer.tsx +150 -0
- package/src/layers/index.ts +5 -0
- package/src/standalone-entry.tsx +228 -0
- package/src/styles/doc-animations.css +458 -0
- package/src/types.ts +152 -0
- package/src/utils/animationUtils.ts +13 -0
- package/src/utils/layerUtils.ts +42 -0
- package/src/utils/mapTileUtils.ts +375 -0
|
@@ -0,0 +1,117 @@
|
|
|
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, use landscape for better readability
|
|
40
|
+
if (ratio > 1.2) {
|
|
41
|
+
return 'landscape';
|
|
42
|
+
} else if (ratio < 0.83) {
|
|
43
|
+
return 'portrait';
|
|
44
|
+
} else {
|
|
45
|
+
// Near-square viewports: use landscape for better text readability
|
|
46
|
+
// Could also use 'square' preset if available and desired
|
|
47
|
+
return 'landscape';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get the appropriate viewport preset for an orientation.
|
|
53
|
+
*/
|
|
54
|
+
function getViewportForOrientation(orientation: ViewportOrientation): ViewportConfig {
|
|
55
|
+
switch (orientation) {
|
|
56
|
+
case 'portrait':
|
|
57
|
+
return VIEWPORT_PRESETS.portrait;
|
|
58
|
+
case 'square':
|
|
59
|
+
return VIEWPORT_PRESETS.square;
|
|
60
|
+
case 'landscape':
|
|
61
|
+
default:
|
|
62
|
+
return VIEWPORT_PRESETS.landscape;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Hook to detect viewport orientation and return appropriate preset.
|
|
68
|
+
* Updates automatically when window is resized.
|
|
69
|
+
*/
|
|
70
|
+
export function useViewportOrientation(): UseViewportOrientationResult {
|
|
71
|
+
const [windowSize, setWindowSize] = useState(() => ({
|
|
72
|
+
width: typeof window !== 'undefined' ? window.innerWidth : 1920,
|
|
73
|
+
height: typeof window !== 'undefined' ? window.innerHeight : 1080,
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
// Listen for window resize
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (typeof window === 'undefined') return;
|
|
79
|
+
|
|
80
|
+
const handleResize = () => {
|
|
81
|
+
setWindowSize({
|
|
82
|
+
width: window.innerWidth,
|
|
83
|
+
height: window.innerHeight,
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Debounce resize handler to avoid excessive re-renders
|
|
88
|
+
let timeoutId: ReturnType<typeof setTimeout>;
|
|
89
|
+
const debouncedResize = () => {
|
|
90
|
+
clearTimeout(timeoutId);
|
|
91
|
+
timeoutId = setTimeout(handleResize, 100);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
window.addEventListener('resize', debouncedResize);
|
|
95
|
+
return () => {
|
|
96
|
+
window.removeEventListener('resize', debouncedResize);
|
|
97
|
+
clearTimeout(timeoutId);
|
|
98
|
+
};
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
// Calculate orientation from window size
|
|
102
|
+
const orientation = useMemo(
|
|
103
|
+
() => getOrientationFromWindow(windowSize.width, windowSize.height),
|
|
104
|
+
[windowSize.width, windowSize.height],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Get appropriate viewport preset
|
|
108
|
+
const viewport = useMemo(() => getViewportForOrientation(orientation), [orientation]);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
viewport,
|
|
112
|
+
orientation,
|
|
113
|
+
windowSize,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export default useViewportOrientation;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Main components
|
|
2
|
+
export { DocPlayer } from './DocPlayer.js';
|
|
3
|
+
export { BlockRenderer, VIEWPORT } from './BlockRenderer.js';
|
|
4
|
+
export { CaptionOverlay } from './CaptionOverlay.js';
|
|
5
|
+
export { DocControlsOverlay } from './DocControlsOverlay.js';
|
|
6
|
+
export { DocControlsBottom } from './DocControlsBottom.js';
|
|
7
|
+
export { DocControlsSidebar } from './DocControlsSidebar.js';
|
|
8
|
+
export { DocControlsSlideshow } from './DocControlsSlideshow.js';
|
|
9
|
+
export { DocPlayerWithSidebar } from './DocPlayerWithSidebar.js';
|
|
10
|
+
export { DocProgressBar } from './DocProgressBar.js';
|
|
11
|
+
export { MarkdownRenderer } from './MarkdownRenderer.js';
|
|
12
|
+
export { LinearDocView } from './LinearDocView.js';
|
|
13
|
+
|
|
14
|
+
// Layer components
|
|
15
|
+
export { ImageLayer } from './layers/ImageLayer.js';
|
|
16
|
+
export { TextLayer } from './layers/TextLayer.js';
|
|
17
|
+
export { ShapeLayer } from './layers/ShapeLayer.js';
|
|
18
|
+
export { VideoLayer } from './layers/VideoLayer.js';
|
|
19
|
+
export { MapLayer } from './layers/MapLayer.js';
|
|
20
|
+
|
|
21
|
+
// Hooks
|
|
22
|
+
export { useAudioSync } from './hooks/useAudioSync.js';
|
|
23
|
+
export { useDocPlayback } from './hooks/useDocPlayback.js';
|
|
24
|
+
export { useViewportOrientation } from './hooks/useViewportOrientation.js';
|
|
25
|
+
export { MediaContext, useMediaProvider, useMediaUrl } from './hooks/MediaContext.js';
|
|
26
|
+
|
|
27
|
+
// Types
|
|
28
|
+
export type { AudioProvider, AudioState, AudioActions } from './hooks/AudioProvider.js';
|
|
29
|
+
export type {
|
|
30
|
+
PlaybackState,
|
|
31
|
+
PlaybackActions,
|
|
32
|
+
BlockMarker,
|
|
33
|
+
ControlsLayout,
|
|
34
|
+
DisplayMode,
|
|
35
|
+
SlideNavActions,
|
|
36
|
+
SquisqRenderAPI,
|
|
37
|
+
SquisqWindow,
|
|
38
|
+
RenderBlockInfo,
|
|
39
|
+
RenderAudioSegmentInfo,
|
|
40
|
+
RenderCaptionInfo,
|
|
41
|
+
RenderChapterInfo,
|
|
42
|
+
} from './types.js';
|
|
43
|
+
export { formatTime } from './types.js';
|
|
44
|
+
|
|
45
|
+
// Utilities
|
|
46
|
+
export { getAnimationStyle, getTransitionClass } from './utils/animationUtils.js';
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ImageLayer Component
|
|
3
|
+
*
|
|
4
|
+
* Renders an image layer within an SVG block. Supports Ken Burns and other
|
|
5
|
+
* animations via CSS classes. Images are rendered using SVG <image> element
|
|
6
|
+
* with proper aspect ratio handling.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ImageLayer as ImageLayerType, Animation } from '@bendyline/squisq/schemas';
|
|
10
|
+
import { getAnimationStyle } from '../utils/animationUtils';
|
|
11
|
+
import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
|
|
12
|
+
import { useMediaUrl } from '../hooks/MediaContext';
|
|
13
|
+
|
|
14
|
+
interface ImageLayerProps {
|
|
15
|
+
layer: ImageLayerType;
|
|
16
|
+
/** Base path for resolving relative image URLs */
|
|
17
|
+
basePath: string;
|
|
18
|
+
/** Viewport dimensions for percentage calculations */
|
|
19
|
+
viewport: { width: number; height: number };
|
|
20
|
+
/** Current time relative to block start (for animation timing) */
|
|
21
|
+
blockTime: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function ImageLayer({ layer, basePath, viewport, blockTime }: ImageLayerProps) {
|
|
25
|
+
const { content, position, animation } = layer;
|
|
26
|
+
|
|
27
|
+
// Resolve position values to pixels
|
|
28
|
+
const x = resolveValue(position.x, viewport.width);
|
|
29
|
+
const y = resolveValue(position.y, viewport.height);
|
|
30
|
+
const width = position.width ? resolveValue(position.width, viewport.width) : viewport.width;
|
|
31
|
+
const height = position.height ? resolveValue(position.height, viewport.height) : viewport.height;
|
|
32
|
+
|
|
33
|
+
// Apply anchor offset
|
|
34
|
+
const offset = getAnchorOffset(position.anchor, width, height);
|
|
35
|
+
const finalX = x + offset.x;
|
|
36
|
+
const finalY = y + offset.y;
|
|
37
|
+
|
|
38
|
+
// Resolve image URL via MediaProvider (if available), falling back to basePath
|
|
39
|
+
const src = useMediaUrl(content.src, basePath);
|
|
40
|
+
|
|
41
|
+
// Get animation styles
|
|
42
|
+
const animStyle = getAnimationStyle(animation, blockTime);
|
|
43
|
+
|
|
44
|
+
// SVG preserveAspectRatio based on fit mode
|
|
45
|
+
const preserveAspectRatio = getPreserveAspectRatio(content.fit);
|
|
46
|
+
const isCover = content.fit === 'cover';
|
|
47
|
+
|
|
48
|
+
// Detect spatial (transform-based) animations that need Ken Burns treatment.
|
|
49
|
+
// These animations should move the image *content* within fixed bounds rather
|
|
50
|
+
// than shifting the entire image container.
|
|
51
|
+
const isSpatialAnim = animation && SPATIAL_ANIMATION_TYPES.has(animation.type);
|
|
52
|
+
|
|
53
|
+
// Ken Burns mode: cover image with spatial animation.
|
|
54
|
+
// Keep the container static, animate the inner <img> within clipped bounds.
|
|
55
|
+
if (isCover && isSpatialAnim && animation) {
|
|
56
|
+
const kbAnim = remapToKenBurns(animation);
|
|
57
|
+
const kbStyle = getAnimationStyle(kbAnim, blockTime);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<g className="block-layer block-layer--image" data-layer-id={layer.id}>
|
|
61
|
+
<foreignObject x={finalX} y={finalY} width={width} height={height}>
|
|
62
|
+
<div
|
|
63
|
+
style={{
|
|
64
|
+
width: '100%',
|
|
65
|
+
height: '100%',
|
|
66
|
+
overflow: 'hidden',
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
<img
|
|
70
|
+
src={src}
|
|
71
|
+
alt={content.alt || ''}
|
|
72
|
+
className={kbStyle.className}
|
|
73
|
+
style={{
|
|
74
|
+
width: '100%',
|
|
75
|
+
height: '100%',
|
|
76
|
+
objectFit: 'cover',
|
|
77
|
+
objectPosition: 'center',
|
|
78
|
+
display: 'block',
|
|
79
|
+
pointerEvents: 'none',
|
|
80
|
+
transformOrigin: 'center center',
|
|
81
|
+
...kbStyle.style,
|
|
82
|
+
}}
|
|
83
|
+
/>
|
|
84
|
+
</div>
|
|
85
|
+
</foreignObject>
|
|
86
|
+
</g>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// For cover mode, use foreignObject with CSS object-fit for more reliable coverage
|
|
91
|
+
// SVG's preserveAspectRatio can be inconsistent across browsers
|
|
92
|
+
if (isCover) {
|
|
93
|
+
return (
|
|
94
|
+
<g
|
|
95
|
+
className={`block-layer block-layer--image ${animStyle.className}`}
|
|
96
|
+
style={animStyle.style}
|
|
97
|
+
data-layer-id={layer.id}
|
|
98
|
+
>
|
|
99
|
+
<foreignObject x={finalX} y={finalY} width={width} height={height}>
|
|
100
|
+
<img
|
|
101
|
+
src={src}
|
|
102
|
+
alt={content.alt || ''}
|
|
103
|
+
style={{
|
|
104
|
+
width: '100%',
|
|
105
|
+
height: '100%',
|
|
106
|
+
objectFit: 'cover',
|
|
107
|
+
objectPosition: 'center',
|
|
108
|
+
display: 'block',
|
|
109
|
+
pointerEvents: 'none',
|
|
110
|
+
}}
|
|
111
|
+
/>
|
|
112
|
+
</foreignObject>
|
|
113
|
+
</g>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// For contain/fill modes, use SVG image element
|
|
118
|
+
return (
|
|
119
|
+
<g
|
|
120
|
+
className={`block-layer block-layer--image ${animStyle.className}`}
|
|
121
|
+
style={animStyle.style}
|
|
122
|
+
data-layer-id={layer.id}
|
|
123
|
+
>
|
|
124
|
+
<image
|
|
125
|
+
href={src}
|
|
126
|
+
x={finalX}
|
|
127
|
+
y={finalY}
|
|
128
|
+
width={width}
|
|
129
|
+
height={height}
|
|
130
|
+
preserveAspectRatio={preserveAspectRatio}
|
|
131
|
+
style={{ pointerEvents: 'none' }}
|
|
132
|
+
/>
|
|
133
|
+
</g>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Map fit mode to SVG preserveAspectRatio.
|
|
139
|
+
*/
|
|
140
|
+
function getPreserveAspectRatio(fit?: 'cover' | 'contain' | 'fill'): string {
|
|
141
|
+
switch (fit) {
|
|
142
|
+
case 'cover':
|
|
143
|
+
return 'xMidYMid slice';
|
|
144
|
+
case 'fill':
|
|
145
|
+
return 'none';
|
|
146
|
+
case 'contain':
|
|
147
|
+
default:
|
|
148
|
+
return 'xMidYMid meet';
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ============================================
|
|
153
|
+
// Ken Burns Helpers
|
|
154
|
+
// ============================================
|
|
155
|
+
|
|
156
|
+
/** Animation types that use CSS transforms (translate/scale). */
|
|
157
|
+
const SPATIAL_ANIMATION_TYPES = new Set(['panLeft', 'panRight', 'slowZoom', 'zoomIn', 'zoomOut']);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Remap animation for Ken Burns inner-image rendering.
|
|
161
|
+
*
|
|
162
|
+
* Pure pan animations (panLeft/panRight) get remapped to combined slowZoom+pan
|
|
163
|
+
* to maintain minimum scale and avoid revealing gaps at image edges.
|
|
164
|
+
* zoomIn/zoomOut get remapped to slowZoom variants (no opacity change, which
|
|
165
|
+
* is inappropriate for sustained ambient motion).
|
|
166
|
+
*/
|
|
167
|
+
function remapToKenBurns(anim: Animation): Animation {
|
|
168
|
+
switch (anim.type) {
|
|
169
|
+
case 'panLeft':
|
|
170
|
+
return { ...anim, type: 'slowZoom', panDirection: 'left' };
|
|
171
|
+
case 'panRight':
|
|
172
|
+
return { ...anim, type: 'slowZoom', panDirection: 'right' };
|
|
173
|
+
case 'zoomIn':
|
|
174
|
+
return { ...anim, type: 'slowZoom', direction: 'in' };
|
|
175
|
+
case 'zoomOut':
|
|
176
|
+
return { ...anim, type: 'slowZoom', direction: 'out' };
|
|
177
|
+
default:
|
|
178
|
+
return anim; // slowZoom variants are already correct
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export default ImageLayer;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MapLayer Component
|
|
3
|
+
*
|
|
4
|
+
* Renders a geographic map layer within an SVG block. Maps are composed from
|
|
5
|
+
* static tile images fetched from free/open-source providers.
|
|
6
|
+
*
|
|
7
|
+
* For video export reliability, maps can use pre-rendered static images via
|
|
8
|
+
* the staticSrc property, avoiding tile loading race conditions during capture.
|
|
9
|
+
*
|
|
10
|
+
* Tile fetching: Tiles are loaded on mount and composited into a data URL
|
|
11
|
+
* for SVG embedding. This ensures correct rendering in both browser and
|
|
12
|
+
* Playwright screenshot contexts.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useState, useEffect } from 'react';
|
|
16
|
+
import type { MapLayer as MapLayerType } from '@bendyline/squisq/schemas';
|
|
17
|
+
import { getAnimationStyle } from '../utils/animationUtils';
|
|
18
|
+
import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
|
|
19
|
+
import { composeMapImage } from '../utils/mapTileUtils';
|
|
20
|
+
|
|
21
|
+
interface MapLayerProps {
|
|
22
|
+
layer: MapLayerType;
|
|
23
|
+
/** Base path for resolving relative image URLs */
|
|
24
|
+
basePath: string;
|
|
25
|
+
/** Viewport dimensions for percentage calculations */
|
|
26
|
+
viewport: { width: number; height: number };
|
|
27
|
+
/** Current time relative to block start (for animation timing) */
|
|
28
|
+
blockTime: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function MapLayer({ layer, basePath, viewport, blockTime }: MapLayerProps) {
|
|
32
|
+
const { content, position, animation } = layer;
|
|
33
|
+
const [mapImageUrl, setMapImageUrl] = useState<string | null>(null);
|
|
34
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
35
|
+
const [error, setError] = useState<string | null>(null);
|
|
36
|
+
|
|
37
|
+
// Resolve position values to pixels
|
|
38
|
+
const x = resolveValue(position.x, viewport.width);
|
|
39
|
+
const y = resolveValue(position.y, viewport.height);
|
|
40
|
+
const width = position.width ? resolveValue(position.width, viewport.width) : viewport.width;
|
|
41
|
+
const height = position.height ? resolveValue(position.height, viewport.height) : viewport.height;
|
|
42
|
+
|
|
43
|
+
// Apply anchor offset
|
|
44
|
+
const offset = getAnchorOffset(position.anchor, width, height);
|
|
45
|
+
const finalX = x + offset.x;
|
|
46
|
+
const finalY = y + offset.y;
|
|
47
|
+
|
|
48
|
+
// Use static image if provided, otherwise fetch and compose tiles
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
let cancelled = false;
|
|
51
|
+
|
|
52
|
+
if (content.staticSrc) {
|
|
53
|
+
// Use pre-rendered static image
|
|
54
|
+
const src = content.staticSrc.startsWith('http')
|
|
55
|
+
? content.staticSrc
|
|
56
|
+
: `${basePath}/${content.staticSrc}`;
|
|
57
|
+
setMapImageUrl(src);
|
|
58
|
+
setIsLoading(false);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Compose map from tiles
|
|
63
|
+
setIsLoading(true);
|
|
64
|
+
setError(null);
|
|
65
|
+
|
|
66
|
+
composeMapImage({
|
|
67
|
+
center: content.center,
|
|
68
|
+
zoom: content.zoom,
|
|
69
|
+
style: content.style,
|
|
70
|
+
width,
|
|
71
|
+
height,
|
|
72
|
+
markers: content.markers,
|
|
73
|
+
showAttribution: content.showAttribution !== false,
|
|
74
|
+
})
|
|
75
|
+
.then((dataUrl) => {
|
|
76
|
+
if (!cancelled) {
|
|
77
|
+
setMapImageUrl(dataUrl);
|
|
78
|
+
setIsLoading(false);
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
.catch((err: unknown) => {
|
|
82
|
+
if (!cancelled) {
|
|
83
|
+
console.error('Failed to compose map:', err);
|
|
84
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
85
|
+
setIsLoading(false);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return () => {
|
|
90
|
+
cancelled = true;
|
|
91
|
+
};
|
|
92
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- content properties are destructured below; center/markers/showAttribution are stable per-render
|
|
93
|
+
}, [
|
|
94
|
+
content.center.lat,
|
|
95
|
+
content.center.lng,
|
|
96
|
+
content.zoom,
|
|
97
|
+
content.style,
|
|
98
|
+
content.staticSrc,
|
|
99
|
+
width,
|
|
100
|
+
height,
|
|
101
|
+
basePath,
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
// Get animation styles
|
|
105
|
+
const animStyle = getAnimationStyle(animation, blockTime);
|
|
106
|
+
|
|
107
|
+
// Render loading state
|
|
108
|
+
if (isLoading) {
|
|
109
|
+
return (
|
|
110
|
+
<g
|
|
111
|
+
className={`block-layer block-layer--map ${animStyle.className}`}
|
|
112
|
+
style={animStyle.style}
|
|
113
|
+
data-layer-id={layer.id}
|
|
114
|
+
>
|
|
115
|
+
<rect x={finalX} y={finalY} width={width} height={height} fill="#e5e7eb" />
|
|
116
|
+
<text
|
|
117
|
+
x={finalX + width / 2}
|
|
118
|
+
y={finalY + height / 2}
|
|
119
|
+
textAnchor="middle"
|
|
120
|
+
dominantBaseline="middle"
|
|
121
|
+
fill="#9ca3af"
|
|
122
|
+
fontSize="24"
|
|
123
|
+
fontFamily="system-ui, sans-serif"
|
|
124
|
+
>
|
|
125
|
+
Loading map...
|
|
126
|
+
</text>
|
|
127
|
+
</g>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Render error state
|
|
132
|
+
if (error || !mapImageUrl) {
|
|
133
|
+
return (
|
|
134
|
+
<g
|
|
135
|
+
className={`block-layer block-layer--map ${animStyle.className}`}
|
|
136
|
+
style={animStyle.style}
|
|
137
|
+
data-layer-id={layer.id}
|
|
138
|
+
>
|
|
139
|
+
<rect x={finalX} y={finalY} width={width} height={height} fill="#fef2f2" />
|
|
140
|
+
<text
|
|
141
|
+
x={finalX + width / 2}
|
|
142
|
+
y={finalY + height / 2}
|
|
143
|
+
textAnchor="middle"
|
|
144
|
+
dominantBaseline="middle"
|
|
145
|
+
fill="#dc2626"
|
|
146
|
+
fontSize="18"
|
|
147
|
+
fontFamily="system-ui, sans-serif"
|
|
148
|
+
>
|
|
149
|
+
Map failed to load
|
|
150
|
+
</text>
|
|
151
|
+
</g>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<g
|
|
157
|
+
className={`block-layer block-layer--map ${animStyle.className}`}
|
|
158
|
+
style={animStyle.style}
|
|
159
|
+
data-layer-id={layer.id}
|
|
160
|
+
>
|
|
161
|
+
{/* Clip path for overflow handling */}
|
|
162
|
+
<defs>
|
|
163
|
+
<clipPath id={`clip-${layer.id}`}>
|
|
164
|
+
<rect x={finalX} y={finalY} width={width} height={height} />
|
|
165
|
+
</clipPath>
|
|
166
|
+
</defs>
|
|
167
|
+
|
|
168
|
+
{/* Map image */}
|
|
169
|
+
<g clipPath={`url(#clip-${layer.id})`}>
|
|
170
|
+
<image
|
|
171
|
+
href={mapImageUrl}
|
|
172
|
+
x={finalX}
|
|
173
|
+
y={finalY}
|
|
174
|
+
width={width}
|
|
175
|
+
height={height}
|
|
176
|
+
preserveAspectRatio="xMidYMid slice"
|
|
177
|
+
style={{ pointerEvents: 'none' }}
|
|
178
|
+
/>
|
|
179
|
+
</g>
|
|
180
|
+
</g>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export default MapLayer;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ShapeLayer Component
|
|
3
|
+
*
|
|
4
|
+
* Renders simple geometric shapes (rect, circle, line) within an SVG block.
|
|
5
|
+
* Useful for visual accents, dividers, and background elements.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ShapeLayer as ShapeLayerType } from '@bendyline/squisq/schemas';
|
|
9
|
+
import { getAnimationStyle } from '../utils/animationUtils';
|
|
10
|
+
import { resolveValue } from '../utils/layerUtils';
|
|
11
|
+
|
|
12
|
+
interface ShapeLayerProps {
|
|
13
|
+
layer: ShapeLayerType;
|
|
14
|
+
/** Viewport dimensions for percentage calculations */
|
|
15
|
+
viewport: { width: number; height: number };
|
|
16
|
+
/** Current time relative to block start */
|
|
17
|
+
blockTime: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
|
|
21
|
+
const { content, position, animation } = layer;
|
|
22
|
+
|
|
23
|
+
// Resolve position values to pixels
|
|
24
|
+
const x = resolveValue(position.x, viewport.width);
|
|
25
|
+
const y = resolveValue(position.y, viewport.height);
|
|
26
|
+
const width = position.width ? resolveValue(position.width, viewport.width) : 100;
|
|
27
|
+
const height = position.height ? resolveValue(position.height, viewport.height) : 100;
|
|
28
|
+
|
|
29
|
+
// Get animation styles
|
|
30
|
+
const animStyle = getAnimationStyle(animation, blockTime);
|
|
31
|
+
|
|
32
|
+
// Check if fill is a CSS gradient (SVG rect doesn't support CSS gradients natively)
|
|
33
|
+
const fill = content.fill || 'none';
|
|
34
|
+
const isCSSGradient = typeof fill === 'string' && fill.includes('gradient(');
|
|
35
|
+
|
|
36
|
+
// For CSS gradients on rect, use foreignObject with an HTML div
|
|
37
|
+
if (content.shape === 'rect' && isCSSGradient) {
|
|
38
|
+
return (
|
|
39
|
+
<g
|
|
40
|
+
className={`block-layer block-layer--shape ${animStyle.className}`}
|
|
41
|
+
style={animStyle.style}
|
|
42
|
+
data-layer-id={layer.id}
|
|
43
|
+
>
|
|
44
|
+
<foreignObject x={x} y={y} width={width} height={height}>
|
|
45
|
+
<div
|
|
46
|
+
style={{
|
|
47
|
+
width: '100%',
|
|
48
|
+
height: '100%',
|
|
49
|
+
background: fill,
|
|
50
|
+
borderRadius: content.borderRadius ? `${content.borderRadius}px` : undefined,
|
|
51
|
+
pointerEvents: 'none',
|
|
52
|
+
}}
|
|
53
|
+
/>
|
|
54
|
+
</foreignObject>
|
|
55
|
+
</g>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Common style props for native SVG shapes
|
|
60
|
+
const shapeProps = {
|
|
61
|
+
fill: fill,
|
|
62
|
+
stroke: content.stroke,
|
|
63
|
+
strokeWidth: content.strokeWidth,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<g
|
|
68
|
+
className={`block-layer block-layer--shape ${animStyle.className}`}
|
|
69
|
+
style={animStyle.style}
|
|
70
|
+
data-layer-id={layer.id}
|
|
71
|
+
>
|
|
72
|
+
{content.shape === 'rect' && (
|
|
73
|
+
<rect
|
|
74
|
+
x={x}
|
|
75
|
+
y={y}
|
|
76
|
+
width={width}
|
|
77
|
+
height={height}
|
|
78
|
+
rx={content.borderRadius}
|
|
79
|
+
ry={content.borderRadius}
|
|
80
|
+
{...shapeProps}
|
|
81
|
+
/>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
{content.shape === 'circle' && (
|
|
85
|
+
<circle
|
|
86
|
+
cx={x + width / 2}
|
|
87
|
+
cy={y + height / 2}
|
|
88
|
+
r={Math.min(width, height) / 2}
|
|
89
|
+
{...shapeProps}
|
|
90
|
+
/>
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
{content.shape === 'line' && (
|
|
94
|
+
<line
|
|
95
|
+
x1={x}
|
|
96
|
+
y1={y}
|
|
97
|
+
x2={x + width}
|
|
98
|
+
y2={y + height}
|
|
99
|
+
stroke={content.stroke || '#ffffff'}
|
|
100
|
+
strokeWidth={content.strokeWidth || 2}
|
|
101
|
+
/>
|
|
102
|
+
)}
|
|
103
|
+
</g>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export default ShapeLayer;
|