@bendyline/squisq-react 2.0.0 → 2.0.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/dist/index.d.ts +3 -1
- package/dist/index.js +87 -9
- 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 +46 -43
- package/dist/squisq-player.global.js.map +1 -1
- package/dist/standalone-source.js +1 -1
- package/dist/styles/index.css +28 -0
- package/package.json +2 -2
- package/src/BlockRenderer.tsx +1 -0
- package/src/LinearDocView.tsx +3 -0
- package/src/__tests__/BlockRenderer.test.tsx +59 -0
- package/src/__tests__/LinearDocView.test.tsx +13 -0
- package/src/__tests__/MarkdownRenderer.test.tsx +3 -3
- package/src/__tests__/fillStyle.test.tsx +47 -0
- package/src/layers/ImageLayer.tsx +106 -1
- package/src/layers/ShapeLayer.tsx +29 -7
- package/src/styles/doc-animations.css +36 -0
package/dist/styles/index.css
CHANGED
|
@@ -135,6 +135,22 @@
|
|
|
135
135
|
transform: translateX(0);
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
|
+
@keyframes portraitCoverPanRight {
|
|
139
|
+
from {
|
|
140
|
+
object-position: left center;
|
|
141
|
+
}
|
|
142
|
+
to {
|
|
143
|
+
object-position: right center;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
@keyframes portraitCoverPanLeft {
|
|
147
|
+
from {
|
|
148
|
+
object-position: right center;
|
|
149
|
+
}
|
|
150
|
+
to {
|
|
151
|
+
object-position: left center;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
138
154
|
@keyframes slideInLeft {
|
|
139
155
|
from {
|
|
140
156
|
transform: translateX(-100%);
|
|
@@ -1937,6 +1953,14 @@
|
|
|
1937
1953
|
.anim-panRight {
|
|
1938
1954
|
animation: panRight var(--anim-duration, 8s) var(--anim-easing, linear) var(--anim-delay, 0s) forwards;
|
|
1939
1955
|
}
|
|
1956
|
+
.squisq-image--portrait-pan-right {
|
|
1957
|
+
animation: portraitCoverPanRight var(--portrait-pan-duration, 12s) var(--portrait-pan-easing, ease-in-out) var(--portrait-pan-delay, 0s) forwards;
|
|
1958
|
+
will-change: object-position;
|
|
1959
|
+
}
|
|
1960
|
+
.squisq-image--portrait-pan-left {
|
|
1961
|
+
animation: portraitCoverPanLeft var(--portrait-pan-duration, 12s) var(--portrait-pan-easing, ease-in-out) var(--portrait-pan-delay, 0s) forwards;
|
|
1962
|
+
will-change: object-position;
|
|
1963
|
+
}
|
|
1940
1964
|
.anim-typewriter {
|
|
1941
1965
|
animation: typewriter var(--anim-duration, 2s) var(--anim-easing, steps(40, end)) var(--anim-delay, 0s) forwards;
|
|
1942
1966
|
}
|
|
@@ -2074,6 +2098,10 @@
|
|
|
2074
2098
|
.anim-slowZoom-out,
|
|
2075
2099
|
.anim-slowZoom-panLeft,
|
|
2076
2100
|
.anim-slowZoom-panRight,
|
|
2101
|
+
.anim-panLeft,
|
|
2102
|
+
.anim-panRight,
|
|
2103
|
+
.squisq-image--portrait-pan-left,
|
|
2104
|
+
.squisq-image--portrait-pan-right,
|
|
2077
2105
|
.anim-drift {
|
|
2078
2106
|
animation: none;
|
|
2079
2107
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bendyline/squisq-react",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "React component library for doc playback, block rendering, and media layers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Bendyline",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@bendyline/squisq": "2.0.
|
|
56
|
+
"@bendyline/squisq": "2.0.1"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@types/react": "18.3.28",
|
package/src/BlockRenderer.tsx
CHANGED
package/src/LinearDocView.tsx
CHANGED
|
@@ -41,6 +41,63 @@ describe('BlockRenderer', () => {
|
|
|
41
41
|
expect(svg?.getAttribute('viewBox')).toBe('0 0 1080 1920');
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
it('pans dominant cover images across portrait frames without adding zoom', () => {
|
|
45
|
+
const portraitHero: Block = {
|
|
46
|
+
...minimalBlock,
|
|
47
|
+
layers: [
|
|
48
|
+
{
|
|
49
|
+
type: 'image',
|
|
50
|
+
id: 'portrait-hero',
|
|
51
|
+
content: { src: 'wide-hero.jpg', alt: 'Wide hero', fit: 'cover' },
|
|
52
|
+
position: { x: 0, y: 0, width: '100%', height: '100%' },
|
|
53
|
+
animation: { type: 'slowZoom', duration: 9, direction: 'in' },
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const { container } = render(
|
|
59
|
+
<BlockRenderer
|
|
60
|
+
block={portraitHero}
|
|
61
|
+
blockTime={0}
|
|
62
|
+
basePath="/test"
|
|
63
|
+
viewport={{ width: 1080, height: 1920 }}
|
|
64
|
+
/>,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const layer = container.querySelector('[data-layer-id="portrait-hero"]');
|
|
68
|
+
const image = layer?.querySelector('img');
|
|
69
|
+
expect(layer?.getAttribute('data-image-framing')).toBe('portrait-pan');
|
|
70
|
+
expect(image?.classList.contains('squisq-image--portrait-pan-right')).toBe(true);
|
|
71
|
+
expect(layer?.classList.contains('anim-slowZoom-in')).toBe(false);
|
|
72
|
+
expect(image?.style.objectFit).toBe('cover');
|
|
73
|
+
expect(image?.style.getPropertyValue('--portrait-pan-duration')).toBe('9s');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('keeps smaller portrait cover tiles static', () => {
|
|
77
|
+
const portraitTile: Block = {
|
|
78
|
+
...minimalBlock,
|
|
79
|
+
layers: [
|
|
80
|
+
{
|
|
81
|
+
type: 'image',
|
|
82
|
+
id: 'portrait-tile',
|
|
83
|
+
content: { src: 'tile.jpg', alt: 'Tile', fit: 'cover' },
|
|
84
|
+
position: { x: '5%', y: '5%', width: '42%', height: '42%' },
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const { container } = render(
|
|
90
|
+
<BlockRenderer
|
|
91
|
+
block={portraitTile}
|
|
92
|
+
blockTime={0}
|
|
93
|
+
basePath="/test"
|
|
94
|
+
viewport={{ width: 1080, height: 1920 }}
|
|
95
|
+
/>,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(container.querySelector('[data-image-framing="portrait-pan"]')).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
|
|
44
101
|
it('renders text layers', () => {
|
|
45
102
|
const blockWithText: Block = {
|
|
46
103
|
id: 'text-block',
|
|
@@ -165,11 +222,13 @@ describe('BlockRenderer', () => {
|
|
|
165
222
|
basePath="/test"
|
|
166
223
|
isEntering
|
|
167
224
|
animationsEnabled={false}
|
|
225
|
+
viewport={{ width: 1080, height: 1920 }}
|
|
168
226
|
/>,
|
|
169
227
|
);
|
|
170
228
|
|
|
171
229
|
expect(container.querySelector('svg')?.className.baseVal).not.toContain('transition-');
|
|
172
230
|
expect(container.querySelector('[class*="anim-"]')).toBeNull();
|
|
231
|
+
expect(container.querySelector('[data-image-framing="portrait-pan"]')).toBeNull();
|
|
173
232
|
expect(animatedBlock.transition).toEqual({ type: 'fade', duration: 0.5 });
|
|
174
233
|
expect(animatedBlock.layers?.map((layer) => layer.animation)).toEqual(originalAnimations);
|
|
175
234
|
});
|
|
@@ -335,6 +335,19 @@ describe('LinearDocView markdown prop', () => {
|
|
|
335
335
|
expect(container.textContent).toContain('Paragraph body.');
|
|
336
336
|
});
|
|
337
337
|
|
|
338
|
+
it('adds extra spacing between blank-line-separated paragraphs', () => {
|
|
339
|
+
const { container } = render(
|
|
340
|
+
<LinearDocView markdown={'First paragraph.\n\nSecond paragraph.'} />,
|
|
341
|
+
);
|
|
342
|
+
const paragraphs = container.querySelectorAll('.squisq-md-p');
|
|
343
|
+
const styles = container.querySelector('.squisq-linear-content > style')?.textContent;
|
|
344
|
+
|
|
345
|
+
expect(paragraphs).toHaveLength(2);
|
|
346
|
+
expect(paragraphs[0]?.nextElementSibling).toBe(paragraphs[1]);
|
|
347
|
+
expect(styles).toContain('.squisq-linear-content p + p');
|
|
348
|
+
expect(styles).toContain('margin-top: 1.25em');
|
|
349
|
+
});
|
|
350
|
+
|
|
338
351
|
it('doc wins over markdown when both are provided', () => {
|
|
339
352
|
const doc = mkDoc([mkBlock({ id: 'b', contents: [paragraph(text('Doc body wins'))] })]);
|
|
340
353
|
const { container } = render(<LinearDocView doc={doc} markdown="# Markdown Loses" />);
|
|
@@ -103,13 +103,13 @@ describe('MarkdownRenderer', () => {
|
|
|
103
103
|
});
|
|
104
104
|
|
|
105
105
|
it('linkSchemes allows a host scheme as a real anchor, never executable ones', () => {
|
|
106
|
-
const nodes = parseNodes('[a](
|
|
106
|
+
const nodes = parseNodes('[a](workspace-nav:src%2Fa.ts) [b](javascript:alert(1))');
|
|
107
107
|
const blocked = render(<MarkdownRenderer nodes={nodes} />);
|
|
108
108
|
expect(blocked.container.querySelector('a.squisq-md-link')).toBeNull();
|
|
109
109
|
|
|
110
|
-
const allowed = render(<MarkdownRenderer nodes={nodes} linkSchemes={['
|
|
110
|
+
const allowed = render(<MarkdownRenderer nodes={nodes} linkSchemes={['workspace-nav']} />);
|
|
111
111
|
const a = allowed.container.querySelector('a.squisq-md-link') as HTMLAnchorElement;
|
|
112
|
-
expect(a?.getAttribute('href')).toBe('
|
|
112
|
+
expect(a?.getAttribute('href')).toBe('workspace-nav:src%2Fa.ts');
|
|
113
113
|
// javascript: stays blocked even when a host lists it
|
|
114
114
|
const evil = render(
|
|
115
115
|
<MarkdownRenderer
|
|
@@ -44,6 +44,13 @@ describe('ShapeLayer fill/border', () => {
|
|
|
44
44
|
} as ShapeLayerType;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
function makeFullBleedShape(content: Partial<ShapeLayerType['content']>): ShapeLayerType {
|
|
48
|
+
return {
|
|
49
|
+
...makeShape(content),
|
|
50
|
+
position: { x: 0, y: 0, width: '100%', height: '100%' },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
47
54
|
it('applies fill opacity and a dashed border', () => {
|
|
48
55
|
const { container } = render(
|
|
49
56
|
<svg>
|
|
@@ -79,6 +86,46 @@ describe('ShapeLayer fill/border', () => {
|
|
|
79
86
|
expect(gradient).not.toBeNull();
|
|
80
87
|
expect(container.querySelector('rect')!.getAttribute('fill')).toBe(`url(#${gradient!.id})`);
|
|
81
88
|
});
|
|
89
|
+
|
|
90
|
+
it('overscans a full-bleed solid shade to prevent an image edge seam', () => {
|
|
91
|
+
const { container } = render(
|
|
92
|
+
<svg>
|
|
93
|
+
<ShapeLayer
|
|
94
|
+
layer={makeFullBleedShape({ fill: 'rgba(0, 0, 0, 0.5)' })}
|
|
95
|
+
viewport={viewport}
|
|
96
|
+
blockTime={0}
|
|
97
|
+
/>
|
|
98
|
+
</svg>,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const rect = container.querySelector('rect')!;
|
|
102
|
+
expect(rect.getAttribute('x')).toBe('-1');
|
|
103
|
+
expect(rect.getAttribute('y')).toBe('-1');
|
|
104
|
+
expect(rect.getAttribute('width')).toBe('1002');
|
|
105
|
+
expect(rect.getAttribute('height')).toBe('1002');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('overscans a full-bleed CSS gradient shade and its HTML fill', () => {
|
|
109
|
+
const { container } = render(
|
|
110
|
+
<svg>
|
|
111
|
+
<ShapeLayer
|
|
112
|
+
layer={makeFullBleedShape({
|
|
113
|
+
fill: 'linear-gradient(0deg, rgba(0,0,0,0.8), transparent)',
|
|
114
|
+
})}
|
|
115
|
+
viewport={viewport}
|
|
116
|
+
blockTime={0}
|
|
117
|
+
/>
|
|
118
|
+
</svg>,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const foreignObject = container.querySelector('foreignObject')!;
|
|
122
|
+
expect(foreignObject.getAttribute('x')).toBe('-1');
|
|
123
|
+
expect(foreignObject.getAttribute('y')).toBe('-1');
|
|
124
|
+
expect(foreignObject.getAttribute('width')).toBe('1002');
|
|
125
|
+
expect(foreignObject.getAttribute('height')).toBe('1002');
|
|
126
|
+
expect(foreignObject.querySelector('div')!.style.width).toBe('1002px');
|
|
127
|
+
expect(foreignObject.querySelector('div')!.style.height).toBe('1002px');
|
|
128
|
+
});
|
|
82
129
|
});
|
|
83
130
|
|
|
84
131
|
describe('TextLayer box border', () => {
|
|
@@ -20,9 +20,17 @@ interface ImageLayerProps {
|
|
|
20
20
|
viewport: { width: number; height: number };
|
|
21
21
|
/** Current time relative to block start (for animation timing) */
|
|
22
22
|
blockTime: number;
|
|
23
|
+
/** Whether authored and responsive image motion should run. */
|
|
24
|
+
animationsEnabled?: boolean;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
export function ImageLayer({
|
|
27
|
+
export function ImageLayer({
|
|
28
|
+
layer,
|
|
29
|
+
basePath,
|
|
30
|
+
viewport,
|
|
31
|
+
blockTime,
|
|
32
|
+
animationsEnabled = true,
|
|
33
|
+
}: ImageLayerProps) {
|
|
26
34
|
const { content, position, animation } = layer;
|
|
27
35
|
|
|
28
36
|
// Resolve position values to pixels
|
|
@@ -55,6 +63,61 @@ export function ImageLayer({ layer, basePath, viewport, blockTime }: ImageLayerP
|
|
|
55
63
|
// than shifting the entire image container.
|
|
56
64
|
const isSpatialAnim = animation && SPATIAL_ANIMATION_TYPES.has(animation.type);
|
|
57
65
|
|
|
66
|
+
// Wide cover photos lose most of their composition when centered inside a
|
|
67
|
+
// portrait frame. For dominant imagery, scan the cover crop horizontally so
|
|
68
|
+
// the whole width is revealed over time. `object-position` only moves when
|
|
69
|
+
// the fitted image actually has horizontal overflow, so portrait sources stay
|
|
70
|
+
// visually still. This path deliberately avoids the extra Ken Burns scale:
|
|
71
|
+
// cover already scales the source by the minimum amount needed to fit one
|
|
72
|
+
// viewport dimension.
|
|
73
|
+
const usesPortraitPan =
|
|
74
|
+
isCover && shouldUsePortraitPan(viewport, width, height, animationsEnabled, animation);
|
|
75
|
+
|
|
76
|
+
if (usesPortraitPan) {
|
|
77
|
+
const panClass = getPortraitPanClass(animation);
|
|
78
|
+
const panStyle = getPortraitPanStyle(isSpatialAnim ? animation : undefined);
|
|
79
|
+
// Entrance/opacity animations can safely run on the fixed container while
|
|
80
|
+
// object-position pans the image. Spatial transforms are replaced by the
|
|
81
|
+
// pan so they cannot zoom an already aggressive portrait crop.
|
|
82
|
+
const containerAnim = isSpatialAnim ? { className: '', style: {} } : animStyle;
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<g
|
|
86
|
+
className={`block-layer block-layer--image ${containerAnim.className}`}
|
|
87
|
+
style={containerAnim.style}
|
|
88
|
+
data-layer-id={layer.id}
|
|
89
|
+
data-image-framing="portrait-pan"
|
|
90
|
+
>
|
|
91
|
+
<foreignObject x={finalX} y={finalY} width={width} height={height}>
|
|
92
|
+
<div
|
|
93
|
+
style={{
|
|
94
|
+
width: `${width}px`,
|
|
95
|
+
height: `${height}px`,
|
|
96
|
+
overflow: 'hidden',
|
|
97
|
+
}}
|
|
98
|
+
>
|
|
99
|
+
<img
|
|
100
|
+
src={src}
|
|
101
|
+
alt={content.alt || ''}
|
|
102
|
+
className={panClass}
|
|
103
|
+
style={{
|
|
104
|
+
width: `${width}px`,
|
|
105
|
+
height: `${height}px`,
|
|
106
|
+
objectFit: 'cover',
|
|
107
|
+
objectPosition: 'center',
|
|
108
|
+
display: 'block',
|
|
109
|
+
pointerEvents: 'none',
|
|
110
|
+
...(filter ? { filter } : {}),
|
|
111
|
+
...(content.blur && content.blur > 0 ? { transform: 'scale(1.06)' } : {}),
|
|
112
|
+
...panStyle,
|
|
113
|
+
}}
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
</foreignObject>
|
|
117
|
+
</g>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
58
121
|
// Ken Burns mode: cover image with spatial animation.
|
|
59
122
|
// Keep the container static, animate the inner <img> within clipped bounds.
|
|
60
123
|
if (isCover && isSpatialAnim && animation) {
|
|
@@ -166,6 +229,48 @@ function getPreserveAspectRatio(fit?: 'cover' | 'contain' | 'fill'): string {
|
|
|
166
229
|
/** Animation types that use CSS transforms (translate/scale). */
|
|
167
230
|
const SPATIAL_ANIMATION_TYPES = new Set(['panLeft', 'panRight', 'slowZoom', 'zoomIn', 'zoomOut']);
|
|
168
231
|
|
|
232
|
+
/** Portrait threshold matches useViewportOrientation's 0.83 aspect cutoff. */
|
|
233
|
+
const PORTRAIT_ASPECT_CUTOFF = 0.83;
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Only auto-pan images that act as the slide's dominant visual. This keeps
|
|
237
|
+
* photo-grid tiles, thumbnails, and small authored insets from all moving at
|
|
238
|
+
* once while covering the full-screen autogenerated layouts.
|
|
239
|
+
*/
|
|
240
|
+
function shouldUsePortraitPan(
|
|
241
|
+
viewport: { width: number; height: number },
|
|
242
|
+
layerWidth: number,
|
|
243
|
+
layerHeight: number,
|
|
244
|
+
animationsEnabled: boolean,
|
|
245
|
+
animation: Animation | undefined,
|
|
246
|
+
): boolean {
|
|
247
|
+
if (!animationsEnabled || animation?.type === 'none') return false;
|
|
248
|
+
if (viewport.width / viewport.height >= PORTRAIT_ASPECT_CUTOFF) return false;
|
|
249
|
+
|
|
250
|
+
return layerWidth >= viewport.width * 0.7 && layerHeight >= viewport.height * 0.7;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* `panLeft` means the image moves left (the framing scans toward its right
|
|
255
|
+
* edge); `panRight` is the reverse. Unspecified motion defaults to revealing
|
|
256
|
+
* the source from left to right.
|
|
257
|
+
*/
|
|
258
|
+
function getPortraitPanClass(animation: Animation | undefined): string {
|
|
259
|
+
const pansBack =
|
|
260
|
+
animation?.type === 'panRight' ||
|
|
261
|
+
(animation?.type === 'slowZoom' && animation.panDirection === 'right');
|
|
262
|
+
return pansBack ? 'squisq-image--portrait-pan-left' : 'squisq-image--portrait-pan-right';
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Use authored spatial timing when present, otherwise a slow ambient scan. */
|
|
266
|
+
function getPortraitPanStyle(animation: Animation | undefined): Record<string, string> {
|
|
267
|
+
return {
|
|
268
|
+
'--portrait-pan-duration': `${animation?.duration ?? 12}s`,
|
|
269
|
+
'--portrait-pan-delay': `${animation?.delay ?? 0}s`,
|
|
270
|
+
'--portrait-pan-easing': animation?.easing ?? 'ease-in-out',
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
169
274
|
/**
|
|
170
275
|
* Remap animation for Ken Burns inner-image rendering.
|
|
171
276
|
*
|
|
@@ -19,6 +19,14 @@ interface ShapeLayerProps {
|
|
|
19
19
|
blockTime: number;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Full-bleed HTML and SVG layers can be rasterized on slightly different
|
|
24
|
+
* subpixel boundaries. Extend unbordered background rectangles beyond the
|
|
25
|
+
* viewport so an image cannot peek through at the outermost pixel; the block
|
|
26
|
+
* renderer clips the combined layer stack back to the viewBox.
|
|
27
|
+
*/
|
|
28
|
+
const FULL_BLEED_OVERSCAN = 1;
|
|
29
|
+
|
|
22
30
|
export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
|
|
23
31
|
const { content, position, animation } = layer;
|
|
24
32
|
const defsId = `${useId().replace(/:/g, '')}-${layer.id}`;
|
|
@@ -34,6 +42,20 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
|
|
|
34
42
|
const x = rawX + anchorOffset.x;
|
|
35
43
|
const y = rawY + anchorOffset.y;
|
|
36
44
|
|
|
45
|
+
const isUnborderedFullBleedRect =
|
|
46
|
+
content.shape === 'rect' &&
|
|
47
|
+
x === 0 &&
|
|
48
|
+
y === 0 &&
|
|
49
|
+
width === viewport.width &&
|
|
50
|
+
height === viewport.height &&
|
|
51
|
+
!content.stroke &&
|
|
52
|
+
!content.borderRadius;
|
|
53
|
+
const overscan = isUnborderedFullBleedRect ? FULL_BLEED_OVERSCAN : 0;
|
|
54
|
+
const paintX = x - overscan;
|
|
55
|
+
const paintY = y - overscan;
|
|
56
|
+
const paintWidth = width + overscan * 2;
|
|
57
|
+
const paintHeight = height + overscan * 2;
|
|
58
|
+
|
|
37
59
|
// Get animation styles
|
|
38
60
|
const animStyle = getAnimationStyle(animation, blockTime);
|
|
39
61
|
|
|
@@ -51,11 +73,11 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
|
|
|
51
73
|
style={animStyle.style}
|
|
52
74
|
data-layer-id={layer.id}
|
|
53
75
|
>
|
|
54
|
-
<foreignObject x={
|
|
76
|
+
<foreignObject x={paintX} y={paintY} width={paintWidth} height={paintHeight}>
|
|
55
77
|
<div
|
|
56
78
|
style={{
|
|
57
|
-
width: `${
|
|
58
|
-
height: `${
|
|
79
|
+
width: `${paintWidth}px`,
|
|
80
|
+
height: `${paintHeight}px`,
|
|
59
81
|
background: fill,
|
|
60
82
|
borderRadius: content.borderRadius ? `${content.borderRadius}px` : undefined,
|
|
61
83
|
pointerEvents: 'none',
|
|
@@ -99,10 +121,10 @@ export function ShapeLayer({ layer, viewport, blockTime }: ShapeLayerProps) {
|
|
|
99
121
|
)}
|
|
100
122
|
{content.shape === 'rect' && (
|
|
101
123
|
<rect
|
|
102
|
-
x={
|
|
103
|
-
y={
|
|
104
|
-
width={
|
|
105
|
-
height={
|
|
124
|
+
x={paintX}
|
|
125
|
+
y={paintY}
|
|
126
|
+
width={paintWidth}
|
|
127
|
+
height={paintHeight}
|
|
106
128
|
rx={content.borderRadius}
|
|
107
129
|
ry={content.borderRadius}
|
|
108
130
|
{...shapeProps}
|
|
@@ -178,6 +178,26 @@
|
|
|
178
178
|
}
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
/* Portrait framing for dominant cover photos. Animating object-position scans
|
|
182
|
+
exactly across the crop overflow without adding another scale transform. */
|
|
183
|
+
@keyframes portraitCoverPanRight {
|
|
184
|
+
from {
|
|
185
|
+
object-position: left center;
|
|
186
|
+
}
|
|
187
|
+
to {
|
|
188
|
+
object-position: right center;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
@keyframes portraitCoverPanLeft {
|
|
193
|
+
from {
|
|
194
|
+
object-position: right center;
|
|
195
|
+
}
|
|
196
|
+
to {
|
|
197
|
+
object-position: left center;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
181
201
|
@keyframes slideInLeft {
|
|
182
202
|
from {
|
|
183
203
|
transform: translateX(-100%);
|
|
@@ -2170,6 +2190,18 @@
|
|
|
2170
2190
|
forwards;
|
|
2171
2191
|
}
|
|
2172
2192
|
|
|
2193
|
+
.squisq-image--portrait-pan-right {
|
|
2194
|
+
animation: portraitCoverPanRight var(--portrait-pan-duration, 12s)
|
|
2195
|
+
var(--portrait-pan-easing, ease-in-out) var(--portrait-pan-delay, 0s) forwards;
|
|
2196
|
+
will-change: object-position;
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
.squisq-image--portrait-pan-left {
|
|
2200
|
+
animation: portraitCoverPanLeft var(--portrait-pan-duration, 12s)
|
|
2201
|
+
var(--portrait-pan-easing, ease-in-out) var(--portrait-pan-delay, 0s) forwards;
|
|
2202
|
+
will-change: object-position;
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2173
2205
|
.anim-typewriter {
|
|
2174
2206
|
animation: typewriter var(--anim-duration, 2s) var(--anim-easing, steps(40, end))
|
|
2175
2207
|
var(--anim-delay, 0s) forwards;
|
|
@@ -2369,6 +2401,10 @@
|
|
|
2369
2401
|
.anim-slowZoom-out,
|
|
2370
2402
|
.anim-slowZoom-panLeft,
|
|
2371
2403
|
.anim-slowZoom-panRight,
|
|
2404
|
+
.anim-panLeft,
|
|
2405
|
+
.anim-panRight,
|
|
2406
|
+
.squisq-image--portrait-pan-left,
|
|
2407
|
+
.squisq-image--portrait-pan-right,
|
|
2372
2408
|
.anim-drift {
|
|
2373
2409
|
animation: none;
|
|
2374
2410
|
}
|