@bendyline/squisq-react 2.0.1 → 2.2.0
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/LICENSE +21 -0
- package/NOTICE.md +20 -0
- package/README.md +6 -0
- package/dist/index.d.ts +101 -96
- package/dist/index.js +1483 -782
- package/dist/squisq-player.full.global.js +4165 -0
- package/dist/squisq-player.global.js +561 -168
- package/dist/standalone-source.d.ts +1 -0
- package/dist/standalone-source.js +1 -1
- package/dist/styles/index.css +64 -0
- package/package.json +8 -4
- package/dist/index.js.map +0 -1
- package/dist/squisq-player.css +0 -2
- package/dist/squisq-player.css.map +0 -1
- package/dist/squisq-player.global.js.map +0 -1
- package/src/BlockRenderer.tsx +0 -194
- package/src/CaptionOverlay.tsx +0 -111
- package/src/DocControlsBottom.tsx +0 -109
- package/src/DocControlsOverlay.tsx +0 -190
- package/src/DocControlsSidebar.tsx +0 -113
- package/src/DocControlsSlideshow.tsx +0 -360
- package/src/DocPlayer.tsx +0 -1528
- package/src/DocPlayerWithSidebar.tsx +0 -155
- package/src/DocProgressBar.tsx +0 -257
- package/src/InlineAudioPlayer.tsx +0 -46
- package/src/InlineVideoPlayer.tsx +0 -70
- package/src/LinearDocView.tsx +0 -569
- package/src/MarkdownRenderer.tsx +0 -624
- package/src/MediaClipLayer.tsx +0 -145
- package/src/SocialCaptionOverlay.tsx +0 -255
- package/src/__tests__/BlockRenderer.test.tsx +0 -235
- package/src/__tests__/DocControlsSlideshow.test.tsx +0 -221
- package/src/__tests__/DocPlayer.test.tsx +0 -635
- package/src/__tests__/DocPlayerStylesSentinel.test.tsx +0 -41
- package/src/__tests__/DocProgressBar.test.tsx +0 -102
- package/src/__tests__/JsonView.test.tsx +0 -111
- package/src/__tests__/LinearDocView.test.tsx +0 -426
- package/src/__tests__/MapLayer.test.tsx +0 -63
- package/src/__tests__/MarkdownRenderer.test.tsx +0 -357
- package/src/__tests__/MediaClipLayer.test.tsx +0 -70
- package/src/__tests__/MediaContext.test.tsx +0 -51
- package/src/__tests__/PathLayer.test.tsx +0 -84
- package/src/__tests__/TableLayer.test.tsx +0 -142
- package/src/__tests__/VideoLayer.test.tsx +0 -94
- package/src/__tests__/exports.test.ts +0 -55
- package/src/__tests__/fillStyle.test.tsx +0 -160
- package/src/__tests__/standaloneEntry.test.tsx +0 -103
- package/src/__tests__/transitionStyles.test.ts +0 -125
- package/src/__tests__/useAudioSync.test.ts +0 -49
- package/src/__tests__/useDocPlayback.transition.test.ts +0 -113
- package/src/__tests__/useJsonViewTokens.test.ts +0 -41
- package/src/__tests__/useSlideSwipe.test.ts +0 -81
- package/src/__tests__/useViewportOrientation.test.ts +0 -22
- package/src/hooks/AudioController.ts +0 -114
- package/src/hooks/MediaContext.tsx +0 -97
- package/src/hooks/index.ts +0 -11
- package/src/hooks/useAudioSync.ts +0 -456
- package/src/hooks/useAutoSurface.ts +0 -33
- package/src/hooks/useDocPlayback.ts +0 -265
- package/src/hooks/useMediaSchedule.ts +0 -39
- package/src/hooks/useSlideSwipe.ts +0 -265
- package/src/hooks/useViewportOrientation.ts +0 -115
- package/src/index.ts +0 -70
- package/src/jsonView/JsonView.tsx +0 -51
- package/src/jsonView/RenderNode.tsx +0 -51
- package/src/jsonView/index.ts +0 -2
- package/src/jsonView/json-view.css +0 -206
- package/src/jsonView/useJsonViewTokens.ts +0 -32
- package/src/jsonView/viewers.tsx +0 -343
- package/src/layers/ImageLayer.tsx +0 -297
- package/src/layers/MapLayer.tsx +0 -185
- package/src/layers/PathLayer.tsx +0 -155
- package/src/layers/ShapeLayer.tsx +0 -158
- package/src/layers/TableLayer.tsx +0 -129
- package/src/layers/TextLayer.tsx +0 -571
- package/src/layers/TreeLayer.tsx +0 -167
- package/src/layers/VideoLayer.tsx +0 -171
- package/src/layers/index.ts +0 -6
- package/src/standalone-entry.tsx +0 -337
- package/src/standalone-source.d.ts +0 -10
- package/src/styles/doc-animations.css +0 -2431
- package/src/styles/index.css +0 -7
- package/src/types.ts +0 -183
- package/src/utils/animationUtils.ts +0 -13
- package/src/utils/fillStyle.tsx +0 -148
- package/src/utils/layerUtils.ts +0 -42
- package/src/utils/mapTileUtils.ts +0 -375
package/src/jsonView/viewers.tsx
DELETED
|
@@ -1,343 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Read-only viewer renderers for `<JsonView>`. Each viewer is a small
|
|
3
|
-
* pure function that takes a value + schema slice and renders inline.
|
|
4
|
-
* Group / card-stack viewers recurse into `RenderNode`.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { Fragment, useMemo } from 'react';
|
|
8
|
-
import {
|
|
9
|
-
arrayItemKind,
|
|
10
|
-
type ControlKind,
|
|
11
|
-
type SquisqAnnotatedSchema,
|
|
12
|
-
} from '@bendyline/squisq/jsonForm';
|
|
13
|
-
import { parseMarkdown } from '@bendyline/squisq/markdown';
|
|
14
|
-
import { MarkdownRenderer } from '../MarkdownRenderer';
|
|
15
|
-
import { RenderNode } from './RenderNode';
|
|
16
|
-
|
|
17
|
-
export interface ViewerProps {
|
|
18
|
-
value: unknown;
|
|
19
|
-
schema: SquisqAnnotatedSchema;
|
|
20
|
-
rootSchema: SquisqAnnotatedSchema;
|
|
21
|
-
rootData: unknown;
|
|
22
|
-
pointer: string;
|
|
23
|
-
density: 'comfortable' | 'compact';
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// ── Primitive viewers ──────────────────────────────────────────────
|
|
27
|
-
|
|
28
|
-
export function TextViewer({ value }: ViewerProps) {
|
|
29
|
-
if (value === undefined || value === null || value === '') {
|
|
30
|
-
return <span className="squisq-jv-empty">—</span>;
|
|
31
|
-
}
|
|
32
|
-
return <span className="squisq-jv-value">{String(value)}</span>;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function MultilineViewer({ value }: ViewerProps) {
|
|
36
|
-
if (value === undefined || value === null || value === '') {
|
|
37
|
-
return <span className="squisq-jv-empty">—</span>;
|
|
38
|
-
}
|
|
39
|
-
return <span className="squisq-jv-value squisq-jv-value--multiline">{String(value)}</span>;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function RichTextViewer({ value }: ViewerProps) {
|
|
43
|
-
const nodes = useMemo(() => {
|
|
44
|
-
if (typeof value !== 'string' || value === '') return null;
|
|
45
|
-
try {
|
|
46
|
-
const doc = parseMarkdown(value);
|
|
47
|
-
return doc.children;
|
|
48
|
-
} catch {
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
}, [value]);
|
|
52
|
-
if (!nodes) return <span className="squisq-jv-empty">—</span>;
|
|
53
|
-
return (
|
|
54
|
-
<div className="squisq-jv-richtext">
|
|
55
|
-
<MarkdownRenderer nodes={nodes} />
|
|
56
|
-
</div>
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function NumberViewer({ value }: ViewerProps) {
|
|
61
|
-
if (value === undefined || value === null) {
|
|
62
|
-
return <span className="squisq-jv-empty">—</span>;
|
|
63
|
-
}
|
|
64
|
-
return <span className="squisq-jv-value">{String(value)}</span>;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function BooleanViewer({ value }: ViewerProps) {
|
|
68
|
-
const on = Boolean(value);
|
|
69
|
-
return (
|
|
70
|
-
<span className={`squisq-jv-toggle squisq-jv-toggle--${on ? 'on' : 'off'}`}>
|
|
71
|
-
{on ? 'On' : 'Off'}
|
|
72
|
-
</span>
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function EnumViewer({ value, schema }: ViewerProps) {
|
|
77
|
-
if (value === undefined || value === null || value === '') {
|
|
78
|
-
return <span className="squisq-jv-empty">—</span>;
|
|
79
|
-
}
|
|
80
|
-
const labels = schema.squisq?.enumLabels;
|
|
81
|
-
const display = labels && typeof value === 'string' ? (labels[value] ?? value) : String(value);
|
|
82
|
-
return <span className="squisq-jv-value">{display}</span>;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function ColorViewer({ value }: ViewerProps) {
|
|
86
|
-
if (typeof value !== 'string' || value === '') {
|
|
87
|
-
return <span className="squisq-jv-empty">—</span>;
|
|
88
|
-
}
|
|
89
|
-
return (
|
|
90
|
-
<span className="squisq-jv-color">
|
|
91
|
-
<span className="squisq-jv-color__swatch" style={{ background: value }} aria-hidden />
|
|
92
|
-
<span className="squisq-jv-color__hex">{value}</span>
|
|
93
|
-
</span>
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export function DateViewer({ value, schema }: ViewerProps) {
|
|
98
|
-
if (typeof value !== 'string' || value === '') {
|
|
99
|
-
return <span className="squisq-jv-empty">—</span>;
|
|
100
|
-
}
|
|
101
|
-
const fmt = schema.format;
|
|
102
|
-
let display = value;
|
|
103
|
-
try {
|
|
104
|
-
const date = new Date(value);
|
|
105
|
-
if (!Number.isNaN(date.getTime())) {
|
|
106
|
-
if (fmt === 'time') {
|
|
107
|
-
display = date.toLocaleTimeString();
|
|
108
|
-
} else if (fmt === 'date') {
|
|
109
|
-
display = date.toLocaleDateString();
|
|
110
|
-
} else {
|
|
111
|
-
display = date.toLocaleString();
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
} catch {
|
|
115
|
-
/* fall through to raw value */
|
|
116
|
-
}
|
|
117
|
-
return <span className="squisq-jv-value">{display}</span>;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// ── Composite viewers ─────────────────────────────────────────────
|
|
121
|
-
|
|
122
|
-
export function ChipBinViewer({ value, schema }: ViewerProps) {
|
|
123
|
-
if (!Array.isArray(value) || value.length === 0) {
|
|
124
|
-
return <span className="squisq-jv-empty">—</span>;
|
|
125
|
-
}
|
|
126
|
-
const itemSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items;
|
|
127
|
-
const labels = itemSchema?.squisq?.enumLabels;
|
|
128
|
-
return (
|
|
129
|
-
<div className="squisq-jv-chip-bin">
|
|
130
|
-
{value.map((item, i) => {
|
|
131
|
-
const label =
|
|
132
|
-
labels && typeof item === 'string' ? (labels[item] ?? String(item)) : String(item);
|
|
133
|
-
return (
|
|
134
|
-
<span key={i} className="squisq-jv-chip">
|
|
135
|
-
{label}
|
|
136
|
-
</span>
|
|
137
|
-
);
|
|
138
|
-
})}
|
|
139
|
-
</div>
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
export function CardStackViewer(props: ViewerProps) {
|
|
144
|
-
const { value, schema, rootSchema, rootData, pointer, density } = props;
|
|
145
|
-
if (!Array.isArray(value) || value.length === 0) {
|
|
146
|
-
return <span className="squisq-jv-empty">No items</span>;
|
|
147
|
-
}
|
|
148
|
-
const itemSchema = (Array.isArray(schema.items) ? schema.items[0] : schema.items) ?? {};
|
|
149
|
-
const itemLabel = itemSchema.squisq?.itemLabel;
|
|
150
|
-
return (
|
|
151
|
-
<div className="squisq-jv-card-stack">
|
|
152
|
-
{value.map((item, i) => {
|
|
153
|
-
const title = resolveItemTitle(itemLabel, item, i);
|
|
154
|
-
return (
|
|
155
|
-
<div key={i} className="squisq-jv-card">
|
|
156
|
-
{title ? <h4 className="squisq-jv-card__title">{title}</h4> : null}
|
|
157
|
-
<RenderNode
|
|
158
|
-
value={item}
|
|
159
|
-
schema={itemSchema}
|
|
160
|
-
rootSchema={rootSchema}
|
|
161
|
-
rootData={rootData}
|
|
162
|
-
pointer={`${pointer}/${i}`}
|
|
163
|
-
density={density}
|
|
164
|
-
suppressTopGroupTitle
|
|
165
|
-
/>
|
|
166
|
-
</div>
|
|
167
|
-
);
|
|
168
|
-
})}
|
|
169
|
-
</div>
|
|
170
|
-
);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function resolveItemTitle(
|
|
174
|
-
spec: string | { fromField: string } | undefined,
|
|
175
|
-
item: unknown,
|
|
176
|
-
index: number,
|
|
177
|
-
): string {
|
|
178
|
-
if (!spec) return `Item ${index + 1}`;
|
|
179
|
-
if (typeof spec === 'string') return spec;
|
|
180
|
-
if (item && typeof item === 'object') {
|
|
181
|
-
const v = (item as Record<string, unknown>)[spec.fromField];
|
|
182
|
-
if (typeof v === 'string' && v !== '') return v;
|
|
183
|
-
if (typeof v === 'number') return String(v);
|
|
184
|
-
}
|
|
185
|
-
return `Item ${index + 1}`;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
export function GroupViewer(props: ViewerProps & { suppressTitle?: boolean }) {
|
|
189
|
-
const { value, schema, rootSchema, rootData, pointer, density, suppressTitle } = props;
|
|
190
|
-
const title = !suppressTitle ? (schema.squisq?.label ?? schema.title) : undefined;
|
|
191
|
-
const help = schema.squisq?.help ?? schema.description;
|
|
192
|
-
const obj = (value && typeof value === 'object' ? (value as Record<string, unknown>) : {}) ?? {};
|
|
193
|
-
const propEntries = Object.entries(schema.properties ?? {});
|
|
194
|
-
|
|
195
|
-
return (
|
|
196
|
-
<section className="squisq-jv-group">
|
|
197
|
-
{title ? <h3 className="squisq-jv-group__title">{title}</h3> : null}
|
|
198
|
-
{help ? <p className="squisq-jv-group__help">{help}</p> : null}
|
|
199
|
-
{propEntries.map(([key, propSchema]) => (
|
|
200
|
-
<Fragment key={key}>
|
|
201
|
-
<RowOrSection
|
|
202
|
-
label={propSchema.squisq?.label ?? propSchema.title ?? key}
|
|
203
|
-
help={propSchema.squisq?.help ?? propSchema.description}
|
|
204
|
-
kindHint={propSchema}
|
|
205
|
-
>
|
|
206
|
-
<RenderNode
|
|
207
|
-
value={obj[key]}
|
|
208
|
-
schema={propSchema}
|
|
209
|
-
rootSchema={rootSchema}
|
|
210
|
-
rootData={rootData}
|
|
211
|
-
pointer={`${pointer}/${key}`}
|
|
212
|
-
density={density}
|
|
213
|
-
suppressTopGroupTitle
|
|
214
|
-
/>
|
|
215
|
-
</RowOrSection>
|
|
216
|
-
</Fragment>
|
|
217
|
-
))}
|
|
218
|
-
</section>
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Decide whether to use the inline label/value Row layout, or to drop
|
|
224
|
-
* the row chrome entirely (for nested groups, card-stacks, richtext —
|
|
225
|
-
* which present their own headings).
|
|
226
|
-
*/
|
|
227
|
-
function RowOrSection({
|
|
228
|
-
label,
|
|
229
|
-
help,
|
|
230
|
-
kindHint,
|
|
231
|
-
children,
|
|
232
|
-
}: {
|
|
233
|
-
label: string;
|
|
234
|
-
help?: string;
|
|
235
|
-
kindHint: SquisqAnnotatedSchema;
|
|
236
|
-
children: React.ReactNode;
|
|
237
|
-
}) {
|
|
238
|
-
const composite = isCompositeKind(kindHint);
|
|
239
|
-
if (composite) {
|
|
240
|
-
return <>{children}</>;
|
|
241
|
-
}
|
|
242
|
-
return (
|
|
243
|
-
<div className="squisq-jv-row">
|
|
244
|
-
<div className="squisq-jv-label" title={help}>
|
|
245
|
-
{label}
|
|
246
|
-
</div>
|
|
247
|
-
<div>{children}</div>
|
|
248
|
-
</div>
|
|
249
|
-
);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function isCompositeKind(schema: SquisqAnnotatedSchema): boolean {
|
|
253
|
-
const t = schema.type;
|
|
254
|
-
const arrayLike = t === 'array' || (Array.isArray(t) && t.includes('array'));
|
|
255
|
-
if (arrayLike) {
|
|
256
|
-
return arrayItemKind(schema) === 'object';
|
|
257
|
-
}
|
|
258
|
-
const objectLike = t === 'object' || (Array.isArray(t) && t.includes('object'));
|
|
259
|
-
if (objectLike) return true;
|
|
260
|
-
if (schema.oneOf || schema.anyOf) return true;
|
|
261
|
-
if (schema.format === 'markdown' || schema.squisq?.control === 'richtext') return true;
|
|
262
|
-
return false;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
export function TabsViewer(props: ViewerProps) {
|
|
266
|
-
const { value, schema, rootSchema, rootData, pointer, density } = props;
|
|
267
|
-
const branches = (schema.oneOf ?? schema.anyOf ?? []).slice();
|
|
268
|
-
const matchedIndex = pickMatchingBranch(branches as SquisqAnnotatedSchema[], value);
|
|
269
|
-
const branch = branches[matchedIndex] as SquisqAnnotatedSchema | undefined;
|
|
270
|
-
if (!branch) {
|
|
271
|
-
return <TextViewer {...props} />;
|
|
272
|
-
}
|
|
273
|
-
return (
|
|
274
|
-
<div className="squisq-jv-tabs">
|
|
275
|
-
<span className="squisq-jv-tabs__discriminator">
|
|
276
|
-
{branch.squisq?.label ?? branch.title ?? `Option ${matchedIndex + 1}`}
|
|
277
|
-
</span>
|
|
278
|
-
<RenderNode
|
|
279
|
-
value={value}
|
|
280
|
-
schema={branch}
|
|
281
|
-
rootSchema={rootSchema}
|
|
282
|
-
rootData={rootData}
|
|
283
|
-
pointer={pointer}
|
|
284
|
-
density={density}
|
|
285
|
-
/>
|
|
286
|
-
</div>
|
|
287
|
-
);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function pickMatchingBranch(branches: SquisqAnnotatedSchema[], value: unknown): number {
|
|
291
|
-
for (let i = 0; i < branches.length; i++) {
|
|
292
|
-
if (matchesShape(branches[i], value)) return i;
|
|
293
|
-
}
|
|
294
|
-
return 0;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function matchesShape(schema: SquisqAnnotatedSchema, value: unknown): boolean {
|
|
298
|
-
const t = schema.type;
|
|
299
|
-
const target = Array.isArray(t) ? t.find((x) => x !== 'null') : t;
|
|
300
|
-
if (!target) return true;
|
|
301
|
-
switch (target) {
|
|
302
|
-
case 'string':
|
|
303
|
-
return typeof value === 'string';
|
|
304
|
-
case 'number':
|
|
305
|
-
case 'integer':
|
|
306
|
-
return typeof value === 'number';
|
|
307
|
-
case 'boolean':
|
|
308
|
-
return typeof value === 'boolean';
|
|
309
|
-
case 'null':
|
|
310
|
-
return value === null;
|
|
311
|
-
case 'array':
|
|
312
|
-
return Array.isArray(value);
|
|
313
|
-
case 'object':
|
|
314
|
-
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
315
|
-
default:
|
|
316
|
-
return false;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
// ── Registry ──────────────────────────────────────────────────────
|
|
321
|
-
|
|
322
|
-
// eslint-disable-next-line react-refresh/only-export-components
|
|
323
|
-
export const VIEWERS: Record<ControlKind, React.ComponentType<ViewerProps>> = {
|
|
324
|
-
text: TextViewer,
|
|
325
|
-
multiline: MultilineViewer,
|
|
326
|
-
richtext: RichTextViewer,
|
|
327
|
-
color: ColorViewer,
|
|
328
|
-
date: DateViewer,
|
|
329
|
-
time: DateViewer,
|
|
330
|
-
datetime: DateViewer,
|
|
331
|
-
slider: NumberViewer,
|
|
332
|
-
stepper: NumberViewer,
|
|
333
|
-
segmented: EnumViewer,
|
|
334
|
-
radio: EnumViewer,
|
|
335
|
-
combobox: EnumViewer,
|
|
336
|
-
toggle: BooleanViewer,
|
|
337
|
-
checkbox: BooleanViewer,
|
|
338
|
-
card: GroupViewer,
|
|
339
|
-
group: GroupViewer,
|
|
340
|
-
'card-stack': CardStackViewer,
|
|
341
|
-
'chip-bin': ChipBinViewer,
|
|
342
|
-
tabs: TabsViewer,
|
|
343
|
-
};
|
|
@@ -1,297 +0,0 @@
|
|
|
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 { cssFilterForTreatment } from '@bendyline/squisq/doc';
|
|
11
|
-
import { getAnimationStyle } from '../utils/animationUtils';
|
|
12
|
-
import { resolveValue, getAnchorOffset } from '../utils/layerUtils';
|
|
13
|
-
import { useMediaUrl } from '../hooks/MediaContext';
|
|
14
|
-
|
|
15
|
-
interface ImageLayerProps {
|
|
16
|
-
layer: ImageLayerType;
|
|
17
|
-
/** Base path for resolving relative image URLs */
|
|
18
|
-
basePath: string;
|
|
19
|
-
/** Viewport dimensions for percentage calculations */
|
|
20
|
-
viewport: { width: number; height: number };
|
|
21
|
-
/** Current time relative to block start (for animation timing) */
|
|
22
|
-
blockTime: number;
|
|
23
|
-
/** Whether authored and responsive image motion should run. */
|
|
24
|
-
animationsEnabled?: boolean;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function ImageLayer({
|
|
28
|
-
layer,
|
|
29
|
-
basePath,
|
|
30
|
-
viewport,
|
|
31
|
-
blockTime,
|
|
32
|
-
animationsEnabled = true,
|
|
33
|
-
}: ImageLayerProps) {
|
|
34
|
-
const { content, position, animation } = layer;
|
|
35
|
-
|
|
36
|
-
// Resolve position values to pixels
|
|
37
|
-
const x = resolveValue(position.x, viewport.width);
|
|
38
|
-
const y = resolveValue(position.y, viewport.height);
|
|
39
|
-
const width = position.width ? resolveValue(position.width, viewport.width) : viewport.width;
|
|
40
|
-
const height = position.height ? resolveValue(position.height, viewport.height) : viewport.height;
|
|
41
|
-
|
|
42
|
-
// Apply anchor offset
|
|
43
|
-
const offset = getAnchorOffset(position.anchor, width, height);
|
|
44
|
-
const finalX = x + offset.x;
|
|
45
|
-
const finalY = y + offset.y;
|
|
46
|
-
|
|
47
|
-
// Resolve image URL via MediaProvider (if available), falling back to basePath
|
|
48
|
-
const src = useMediaUrl(content.src, basePath);
|
|
49
|
-
|
|
50
|
-
// Get animation styles
|
|
51
|
-
const animStyle = getAnimationStyle(animation, blockTime);
|
|
52
|
-
|
|
53
|
-
// Theme-derived photographic grade + optional blur, as a CSS filter
|
|
54
|
-
// string (identical in the player and in headless frame capture).
|
|
55
|
-
const filter = cssFilterForTreatment(content.treatment, content.blur);
|
|
56
|
-
|
|
57
|
-
// SVG preserveAspectRatio based on fit mode
|
|
58
|
-
const preserveAspectRatio = getPreserveAspectRatio(content.fit);
|
|
59
|
-
const isCover = content.fit === 'cover';
|
|
60
|
-
|
|
61
|
-
// Detect spatial (transform-based) animations that need Ken Burns treatment.
|
|
62
|
-
// These animations should move the image *content* within fixed bounds rather
|
|
63
|
-
// than shifting the entire image container.
|
|
64
|
-
const isSpatialAnim = animation && SPATIAL_ANIMATION_TYPES.has(animation.type);
|
|
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
|
-
|
|
121
|
-
// Ken Burns mode: cover image with spatial animation.
|
|
122
|
-
// Keep the container static, animate the inner <img> within clipped bounds.
|
|
123
|
-
if (isCover && isSpatialAnim && animation) {
|
|
124
|
-
const kbAnim = remapToKenBurns(animation);
|
|
125
|
-
const kbStyle = getAnimationStyle(kbAnim, blockTime);
|
|
126
|
-
|
|
127
|
-
return (
|
|
128
|
-
<g className="block-layer block-layer--image" data-layer-id={layer.id}>
|
|
129
|
-
<foreignObject x={finalX} y={finalY} width={width} height={height}>
|
|
130
|
-
<div
|
|
131
|
-
style={{
|
|
132
|
-
width: `${width}px`,
|
|
133
|
-
height: `${height}px`,
|
|
134
|
-
overflow: 'hidden',
|
|
135
|
-
}}
|
|
136
|
-
>
|
|
137
|
-
<img
|
|
138
|
-
src={src}
|
|
139
|
-
alt={content.alt || ''}
|
|
140
|
-
className={kbStyle.className}
|
|
141
|
-
style={{
|
|
142
|
-
width: `${width}px`,
|
|
143
|
-
height: `${height}px`,
|
|
144
|
-
objectFit: 'cover',
|
|
145
|
-
objectPosition: 'center',
|
|
146
|
-
display: 'block',
|
|
147
|
-
pointerEvents: 'none',
|
|
148
|
-
transformOrigin: 'center center',
|
|
149
|
-
...(filter ? { filter } : {}),
|
|
150
|
-
...kbStyle.style,
|
|
151
|
-
}}
|
|
152
|
-
/>
|
|
153
|
-
</div>
|
|
154
|
-
</foreignObject>
|
|
155
|
-
</g>
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// For cover mode, use foreignObject with CSS object-fit for more reliable coverage
|
|
160
|
-
// SVG's preserveAspectRatio can be inconsistent across browsers
|
|
161
|
-
if (isCover) {
|
|
162
|
-
return (
|
|
163
|
-
<g
|
|
164
|
-
className={`block-layer block-layer--image ${animStyle.className}`}
|
|
165
|
-
style={animStyle.style}
|
|
166
|
-
data-layer-id={layer.id}
|
|
167
|
-
>
|
|
168
|
-
<foreignObject x={finalX} y={finalY} width={width} height={height}>
|
|
169
|
-
<img
|
|
170
|
-
src={src}
|
|
171
|
-
alt={content.alt || ''}
|
|
172
|
-
style={{
|
|
173
|
-
width: `${width}px`,
|
|
174
|
-
height: `${height}px`,
|
|
175
|
-
objectFit: 'cover',
|
|
176
|
-
objectPosition: 'center',
|
|
177
|
-
display: 'block',
|
|
178
|
-
pointerEvents: 'none',
|
|
179
|
-
...(filter ? { filter } : {}),
|
|
180
|
-
// Over-scan blurred imagery so the soft edges never reveal
|
|
181
|
-
// the frame behind the layer.
|
|
182
|
-
...(content.blur && content.blur > 0 ? { transform: 'scale(1.06)' } : {}),
|
|
183
|
-
}}
|
|
184
|
-
/>
|
|
185
|
-
</foreignObject>
|
|
186
|
-
</g>
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// For contain/fill modes, use SVG image element
|
|
191
|
-
return (
|
|
192
|
-
<g
|
|
193
|
-
className={`block-layer block-layer--image ${animStyle.className}`}
|
|
194
|
-
style={animStyle.style}
|
|
195
|
-
data-layer-id={layer.id}
|
|
196
|
-
>
|
|
197
|
-
<image
|
|
198
|
-
href={src}
|
|
199
|
-
x={finalX}
|
|
200
|
-
y={finalY}
|
|
201
|
-
width={width}
|
|
202
|
-
height={height}
|
|
203
|
-
preserveAspectRatio={preserveAspectRatio}
|
|
204
|
-
style={{ pointerEvents: 'none', ...(filter ? { filter } : {}) }}
|
|
205
|
-
/>
|
|
206
|
-
</g>
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* Map fit mode to SVG preserveAspectRatio.
|
|
212
|
-
*/
|
|
213
|
-
function getPreserveAspectRatio(fit?: 'cover' | 'contain' | 'fill'): string {
|
|
214
|
-
switch (fit) {
|
|
215
|
-
case 'cover':
|
|
216
|
-
return 'xMidYMid slice';
|
|
217
|
-
case 'fill':
|
|
218
|
-
return 'none';
|
|
219
|
-
case 'contain':
|
|
220
|
-
default:
|
|
221
|
-
return 'xMidYMid meet';
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// ============================================
|
|
226
|
-
// Ken Burns Helpers
|
|
227
|
-
// ============================================
|
|
228
|
-
|
|
229
|
-
/** Animation types that use CSS transforms (translate/scale). */
|
|
230
|
-
const SPATIAL_ANIMATION_TYPES = new Set(['panLeft', 'panRight', 'slowZoom', 'zoomIn', 'zoomOut']);
|
|
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
|
-
|
|
274
|
-
/**
|
|
275
|
-
* Remap animation for Ken Burns inner-image rendering.
|
|
276
|
-
*
|
|
277
|
-
* Pure pan animations (panLeft/panRight) get remapped to combined slowZoom+pan
|
|
278
|
-
* to maintain minimum scale and avoid revealing gaps at image edges.
|
|
279
|
-
* zoomIn/zoomOut get remapped to slowZoom variants (no opacity change, which
|
|
280
|
-
* is inappropriate for sustained ambient motion).
|
|
281
|
-
*/
|
|
282
|
-
function remapToKenBurns(anim: Animation): Animation {
|
|
283
|
-
switch (anim.type) {
|
|
284
|
-
case 'panLeft':
|
|
285
|
-
return { ...anim, type: 'slowZoom', panDirection: 'left' };
|
|
286
|
-
case 'panRight':
|
|
287
|
-
return { ...anim, type: 'slowZoom', panDirection: 'right' };
|
|
288
|
-
case 'zoomIn':
|
|
289
|
-
return { ...anim, type: 'slowZoom', direction: 'in' };
|
|
290
|
-
case 'zoomOut':
|
|
291
|
-
return { ...anim, type: 'slowZoom', direction: 'out' };
|
|
292
|
-
default:
|
|
293
|
-
return anim; // slowZoom variants are already correct
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
export default ImageLayer;
|