@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.
Files changed (45) hide show
  1. package/dist/index.d.ts +563 -0
  2. package/dist/index.js +3180 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/squisq-player.css +2 -0
  5. package/dist/squisq-player.css.map +1 -0
  6. package/dist/squisq-player.global.js +6 -0
  7. package/dist/squisq-player.global.js.map +1 -0
  8. package/dist/standalone-source.d.ts +2 -0
  9. package/dist/standalone-source.js +2 -0
  10. package/package.json +69 -0
  11. package/src/BlockRenderer.tsx +146 -0
  12. package/src/CaptionOverlay.tsx +86 -0
  13. package/src/DocControlsBottom.tsx +103 -0
  14. package/src/DocControlsOverlay.tsx +178 -0
  15. package/src/DocControlsSidebar.tsx +107 -0
  16. package/src/DocControlsSlideshow.tsx +132 -0
  17. package/src/DocPlayer.tsx +1005 -0
  18. package/src/DocPlayerWithSidebar.tsx +138 -0
  19. package/src/DocProgressBar.tsx +200 -0
  20. package/src/LinearDocView.tsx +313 -0
  21. package/src/MarkdownRenderer.tsx +360 -0
  22. package/src/__tests__/BlockRenderer.test.tsx +105 -0
  23. package/src/__tests__/DocControlsSlideshow.test.tsx +127 -0
  24. package/src/__tests__/LinearDocView.test.tsx +180 -0
  25. package/src/__tests__/MarkdownRenderer.test.tsx +234 -0
  26. package/src/__tests__/exports.test.ts +55 -0
  27. package/src/hooks/AudioProvider.ts +114 -0
  28. package/src/hooks/MediaContext.tsx +81 -0
  29. package/src/hooks/index.ts +6 -0
  30. package/src/hooks/useAudioSync.ts +390 -0
  31. package/src/hooks/useDocPlayback.ts +251 -0
  32. package/src/hooks/useViewportOrientation.ts +117 -0
  33. package/src/index.ts +46 -0
  34. package/src/layers/ImageLayer.tsx +182 -0
  35. package/src/layers/MapLayer.tsx +184 -0
  36. package/src/layers/ShapeLayer.tsx +107 -0
  37. package/src/layers/TextLayer.tsx +197 -0
  38. package/src/layers/VideoLayer.tsx +150 -0
  39. package/src/layers/index.ts +5 -0
  40. package/src/standalone-entry.tsx +228 -0
  41. package/src/styles/doc-animations.css +458 -0
  42. package/src/types.ts +152 -0
  43. package/src/utils/animationUtils.ts +13 -0
  44. package/src/utils/layerUtils.ts +42 -0
  45. 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;