@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.
@@ -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.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.0"
56
+ "@bendyline/squisq": "2.0.1"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@types/react": "18.3.28",
@@ -153,6 +153,7 @@ function LayerRenderer({
153
153
  basePath={basePath}
154
154
  viewport={viewport}
155
155
  blockTime={blockTime}
156
+ animationsEnabled={animationsEnabled}
156
157
  />
157
158
  );
158
159
  case 'text':
@@ -454,6 +454,9 @@ export function LinearDocView({
454
454
  .squisq-linear-content p {
455
455
  margin-bottom: 0.75em;
456
456
  }
457
+ .squisq-linear-content p + p {
458
+ margin-top: 1.25em;
459
+ }
457
460
  .squisq-linear-content ul,
458
461
  .squisq-linear-content ol {
459
462
  padding-left: 2em;
@@ -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](gezel-nav:src%2Fa.ts) [b](javascript:alert(1))');
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={['gezel-nav']} />);
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('gezel-nav:src%2Fa.ts');
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({ layer, basePath, viewport, blockTime }: ImageLayerProps) {
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={x} y={y} width={width} height={height}>
76
+ <foreignObject x={paintX} y={paintY} width={paintWidth} height={paintHeight}>
55
77
  <div
56
78
  style={{
57
- width: `${width}px`,
58
- height: `${height}px`,
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={x}
103
- y={y}
104
- width={width}
105
- height={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
  }