@bendyline/squisq-react 2.2.0 → 2.3.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.
@@ -0,0 +1,1604 @@
1
+ import {
2
+ MermaidDiagram
3
+ } from "./chunk-WLUZTUNZ.js";
4
+ import {
5
+ useMediaUrl,
6
+ useResourcePolicy
7
+ } from "./chunk-LR3AIGDD.js";
8
+
9
+ // src/utils/animationUtils.ts
10
+ import {
11
+ getAnimationStyle,
12
+ getDefaultAnimationDuration,
13
+ getTransitionClass,
14
+ getAnimationProgress
15
+ } from "@bendyline/squisq/doc";
16
+
17
+ // src/layers/ImageLayer.tsx
18
+ import { cssFilterForTreatment } from "@bendyline/squisq/doc";
19
+
20
+ // src/utils/layerUtils.ts
21
+ function resolveValue(value, dimension) {
22
+ if (typeof value === "number") {
23
+ return value;
24
+ }
25
+ if (value.endsWith("%")) {
26
+ const percent = parseFloat(value);
27
+ return percent / 100 * dimension;
28
+ }
29
+ return parseFloat(value);
30
+ }
31
+ function getAnchorOffset(anchor, width, height) {
32
+ switch (anchor) {
33
+ case "center":
34
+ return { x: -width / 2, y: -height / 2 };
35
+ case "top-right":
36
+ return { x: -width, y: 0 };
37
+ case "bottom-left":
38
+ return { x: 0, y: -height };
39
+ case "bottom-right":
40
+ return { x: -width, y: -height };
41
+ case "top-left":
42
+ default:
43
+ return { x: 0, y: 0 };
44
+ }
45
+ }
46
+
47
+ // src/layers/ImageLayer.tsx
48
+ import { jsx } from "react/jsx-runtime";
49
+ function ImageLayer({
50
+ layer,
51
+ basePath,
52
+ viewport,
53
+ blockTime,
54
+ animationsEnabled = true
55
+ }) {
56
+ const { content, position, animation } = layer;
57
+ const x = resolveValue(position.x, viewport.width);
58
+ const y = resolveValue(position.y, viewport.height);
59
+ const width = position.width ? resolveValue(position.width, viewport.width) : viewport.width;
60
+ const height = position.height ? resolveValue(position.height, viewport.height) : viewport.height;
61
+ const offset = getAnchorOffset(position.anchor, width, height);
62
+ const finalX = x + offset.x;
63
+ const finalY = y + offset.y;
64
+ const src = useMediaUrl(content.src, basePath);
65
+ const animStyle = getAnimationStyle(animation, blockTime);
66
+ const filter = cssFilterForTreatment(content.treatment, content.blur);
67
+ const preserveAspectRatio = getPreserveAspectRatio(content.fit);
68
+ const isCover = content.fit === "cover";
69
+ const isSpatialAnim = animation && SPATIAL_ANIMATION_TYPES.has(animation.type);
70
+ const usesPortraitPan = isCover && shouldUsePortraitPan(viewport, width, height, animationsEnabled, animation);
71
+ if (usesPortraitPan) {
72
+ const panClass = getPortraitPanClass(animation);
73
+ const panStyle = getPortraitPanStyle(isSpatialAnim ? animation : void 0);
74
+ const containerAnim = isSpatialAnim ? { className: "", style: {} } : animStyle;
75
+ return /* @__PURE__ */ jsx(
76
+ "g",
77
+ {
78
+ className: `block-layer block-layer--image ${containerAnim.className}`,
79
+ style: containerAnim.style,
80
+ "data-layer-id": layer.id,
81
+ "data-image-framing": "portrait-pan",
82
+ children: /* @__PURE__ */ jsx("foreignObject", { x: finalX, y: finalY, width, height, children: /* @__PURE__ */ jsx(
83
+ "div",
84
+ {
85
+ style: {
86
+ width: `${width}px`,
87
+ height: `${height}px`,
88
+ overflow: "hidden"
89
+ },
90
+ children: /* @__PURE__ */ jsx(
91
+ "img",
92
+ {
93
+ src,
94
+ alt: content.alt || "",
95
+ className: panClass,
96
+ style: {
97
+ width: `${width}px`,
98
+ height: `${height}px`,
99
+ objectFit: "cover",
100
+ objectPosition: "center",
101
+ display: "block",
102
+ pointerEvents: "none",
103
+ ...filter ? { filter } : {},
104
+ ...content.blur && content.blur > 0 ? { transform: "scale(1.06)" } : {},
105
+ ...panStyle
106
+ }
107
+ }
108
+ )
109
+ }
110
+ ) })
111
+ }
112
+ );
113
+ }
114
+ if (isCover && isSpatialAnim && animation) {
115
+ const kbAnim = remapToKenBurns(animation);
116
+ const kbStyle = getAnimationStyle(kbAnim, blockTime);
117
+ return /* @__PURE__ */ jsx("g", { className: "block-layer block-layer--image", "data-layer-id": layer.id, children: /* @__PURE__ */ jsx("foreignObject", { x: finalX, y: finalY, width, height, children: /* @__PURE__ */ jsx(
118
+ "div",
119
+ {
120
+ style: {
121
+ width: `${width}px`,
122
+ height: `${height}px`,
123
+ overflow: "hidden"
124
+ },
125
+ children: /* @__PURE__ */ jsx(
126
+ "img",
127
+ {
128
+ src,
129
+ alt: content.alt || "",
130
+ className: kbStyle.className,
131
+ style: {
132
+ width: `${width}px`,
133
+ height: `${height}px`,
134
+ objectFit: "cover",
135
+ objectPosition: "center",
136
+ display: "block",
137
+ pointerEvents: "none",
138
+ transformOrigin: "center center",
139
+ ...filter ? { filter } : {},
140
+ ...kbStyle.style
141
+ }
142
+ }
143
+ )
144
+ }
145
+ ) }) });
146
+ }
147
+ if (isCover) {
148
+ return /* @__PURE__ */ jsx(
149
+ "g",
150
+ {
151
+ className: `block-layer block-layer--image ${animStyle.className}`,
152
+ style: animStyle.style,
153
+ "data-layer-id": layer.id,
154
+ children: /* @__PURE__ */ jsx("foreignObject", { x: finalX, y: finalY, width, height, children: /* @__PURE__ */ jsx(
155
+ "img",
156
+ {
157
+ src,
158
+ alt: content.alt || "",
159
+ style: {
160
+ width: `${width}px`,
161
+ height: `${height}px`,
162
+ objectFit: "cover",
163
+ objectPosition: "center",
164
+ display: "block",
165
+ pointerEvents: "none",
166
+ ...filter ? { filter } : {},
167
+ // Over-scan blurred imagery so the soft edges never reveal
168
+ // the frame behind the layer.
169
+ ...content.blur && content.blur > 0 ? { transform: "scale(1.06)" } : {}
170
+ }
171
+ }
172
+ ) })
173
+ }
174
+ );
175
+ }
176
+ return /* @__PURE__ */ jsx(
177
+ "g",
178
+ {
179
+ className: `block-layer block-layer--image ${animStyle.className}`,
180
+ style: animStyle.style,
181
+ "data-layer-id": layer.id,
182
+ children: /* @__PURE__ */ jsx(
183
+ "image",
184
+ {
185
+ href: src,
186
+ x: finalX,
187
+ y: finalY,
188
+ width,
189
+ height,
190
+ preserveAspectRatio,
191
+ style: { pointerEvents: "none", ...filter ? { filter } : {} }
192
+ }
193
+ )
194
+ }
195
+ );
196
+ }
197
+ function getPreserveAspectRatio(fit) {
198
+ switch (fit) {
199
+ case "cover":
200
+ return "xMidYMid slice";
201
+ case "fill":
202
+ return "none";
203
+ case "contain":
204
+ default:
205
+ return "xMidYMid meet";
206
+ }
207
+ }
208
+ var SPATIAL_ANIMATION_TYPES = /* @__PURE__ */ new Set(["panLeft", "panRight", "slowZoom", "zoomIn", "zoomOut"]);
209
+ var PORTRAIT_ASPECT_CUTOFF = 0.83;
210
+ function shouldUsePortraitPan(viewport, layerWidth, layerHeight, animationsEnabled, animation) {
211
+ if (!animationsEnabled || animation?.type === "none") return false;
212
+ if (viewport.width / viewport.height >= PORTRAIT_ASPECT_CUTOFF) return false;
213
+ return layerWidth >= viewport.width * 0.7 && layerHeight >= viewport.height * 0.7;
214
+ }
215
+ function getPortraitPanClass(animation) {
216
+ const pansBack = animation?.type === "panRight" || animation?.type === "slowZoom" && animation.panDirection === "right";
217
+ return pansBack ? "squisq-image--portrait-pan-left" : "squisq-image--portrait-pan-right";
218
+ }
219
+ function getPortraitPanStyle(animation) {
220
+ return {
221
+ "--portrait-pan-duration": `${animation?.duration ?? 12}s`,
222
+ "--portrait-pan-delay": `${animation?.delay ?? 0}s`,
223
+ "--portrait-pan-easing": animation?.easing ?? "ease-in-out"
224
+ };
225
+ }
226
+ function remapToKenBurns(anim) {
227
+ switch (anim.type) {
228
+ case "panLeft":
229
+ return { ...anim, type: "slowZoom", panDirection: "left" };
230
+ case "panRight":
231
+ return { ...anim, type: "slowZoom", panDirection: "right" };
232
+ case "zoomIn":
233
+ return { ...anim, type: "slowZoom", direction: "in" };
234
+ case "zoomOut":
235
+ return { ...anim, type: "slowZoom", direction: "out" };
236
+ default:
237
+ return anim;
238
+ }
239
+ }
240
+
241
+ // src/layers/TextLayer.tsx
242
+ import { useId, useMemo } from "react";
243
+ import { DEFAULT_DOC_FONT } from "@bendyline/squisq/schemas";
244
+ import {
245
+ parseHtmlToNodes,
246
+ sanitizeHtmlNodes,
247
+ stringifyHtmlNodes
248
+ } from "@bendyline/squisq/markdown";
249
+ import {
250
+ hasIconMarker,
251
+ splitIconMarkers,
252
+ stripIconMarkers,
253
+ iconClass
254
+ } from "@bendyline/squisq/icon-marker";
255
+
256
+ // src/utils/fillStyle.tsx
257
+ import { jsx as jsx2, jsxs } from "react/jsx-runtime";
258
+ function borderDashArray(style, strokeWidth) {
259
+ if (!style || style === "solid") return void 0;
260
+ const w = Math.max(1, strokeWidth ?? 1);
261
+ if (style === "dotted") return `${w} ${w * 2}`;
262
+ return `${w * 3} ${w * 2}`;
263
+ }
264
+ function gradientVector(angle = 0) {
265
+ const a = angle * Math.PI / 180;
266
+ const dx = Math.sin(a);
267
+ const dy = Math.cos(a);
268
+ return { x1: 0.5 - dx / 2, y1: 0.5 - dy / 2, x2: 0.5 + dx / 2, y2: 0.5 + dy / 2 };
269
+ }
270
+ function gradientDefId(layerId) {
271
+ return `squisq-grad-${layerId}`;
272
+ }
273
+ function resolveFill(layerId, color, gradient, pattern) {
274
+ if (pattern) {
275
+ const id = `squisq-pattern-${layerId}`;
276
+ return { fill: `url(#${id})`, def: patternDef(id, pattern) };
277
+ }
278
+ if (gradient) {
279
+ const id = gradientDefId(layerId);
280
+ const v = gradientVector(gradient.angle);
281
+ return {
282
+ fill: `url(#${id})`,
283
+ def: /* @__PURE__ */ jsxs("linearGradient", { id, x1: v.x1, y1: v.y1, x2: v.x2, y2: v.y2, children: [
284
+ /* @__PURE__ */ jsx2("stop", { offset: "0%", stopColor: gradient.from }),
285
+ /* @__PURE__ */ jsx2("stop", { offset: "100%", stopColor: gradient.to })
286
+ ] })
287
+ };
288
+ }
289
+ return { fill: color, def: null };
290
+ }
291
+ function patternDef(id, pattern) {
292
+ const size = pattern.size ?? 24;
293
+ const opacity = pattern.opacity ?? 1;
294
+ const color = pattern.color;
295
+ return /* @__PURE__ */ jsxs(
296
+ "pattern",
297
+ {
298
+ id,
299
+ width: size,
300
+ height: size,
301
+ patternUnits: "userSpaceOnUse",
302
+ patternTransform: pattern.kind === "diagonal" ? "rotate(45)" : void 0,
303
+ children: [
304
+ pattern.kind === "dots" && /* @__PURE__ */ jsx2(
305
+ "circle",
306
+ {
307
+ cx: size / 2,
308
+ cy: size / 2,
309
+ r: Math.max(1, size / 12),
310
+ fill: color,
311
+ opacity
312
+ }
313
+ ),
314
+ pattern.kind === "grid" && /* @__PURE__ */ jsx2(
315
+ "path",
316
+ {
317
+ d: `M ${size} 0 L 0 0 0 ${size}`,
318
+ fill: "none",
319
+ stroke: color,
320
+ strokeWidth: 1,
321
+ opacity
322
+ }
323
+ ),
324
+ pattern.kind === "diagonal" && /* @__PURE__ */ jsx2("line", { x1: 0, y1: 0, x2: 0, y2: size, stroke: color, strokeWidth: 1, opacity })
325
+ ]
326
+ }
327
+ );
328
+ }
329
+ function resolveShapeFilter(layerId, filter) {
330
+ if (!filter || filter.type !== "noise") return { filterAttr: void 0, def: null };
331
+ const id = `squisq-noise-${layerId}`;
332
+ const opacity = filter.opacity ?? 0.05;
333
+ return {
334
+ filterAttr: `url(#${id})`,
335
+ def: /* @__PURE__ */ jsxs("filter", { id, x: "0%", y: "0%", width: "100%", height: "100%", children: [
336
+ /* @__PURE__ */ jsx2(
337
+ "feTurbulence",
338
+ {
339
+ type: "fractalNoise",
340
+ baseFrequency: filter.baseFrequency ?? 0.8,
341
+ numOctaves: 2,
342
+ stitchTiles: "stitch",
343
+ result: "noise"
344
+ }
345
+ ),
346
+ /* @__PURE__ */ jsx2("feColorMatrix", { in: "noise", type: "saturate", values: "0", result: "mono" }),
347
+ /* @__PURE__ */ jsx2("feComponentTransfer", { in: "mono", result: "faded", children: /* @__PURE__ */ jsx2("feFuncA", { type: "linear", slope: opacity, intercept: 0 }) }),
348
+ /* @__PURE__ */ jsx2("feComposite", { in: "faded", in2: "SourceGraphic", operator: "in" })
349
+ ] })
350
+ };
351
+ }
352
+
353
+ // src/layers/TextLayer.tsx
354
+ import { Fragment, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
355
+ function TextLayer(props) {
356
+ if (props.layer.content.html?.trim()) return /* @__PURE__ */ jsx3(RichTextLayer, { ...props });
357
+ if (hasIconMarker(props.layer.content.text ?? "")) return /* @__PURE__ */ jsx3(IconTextLayer, { ...props });
358
+ return /* @__PURE__ */ jsx3(PlainTextLayer, { ...props });
359
+ }
360
+ function escapeHtml(value) {
361
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
362
+ }
363
+ function iconRunsToHtml(text) {
364
+ return splitIconMarkers(text).map(
365
+ (run) => run.type === "icon" ? `<i class="${iconClass(run.family, run.name)}" aria-hidden="true"></i>` : escapeHtml(run.text).replace(/\n/g, "<br>")
366
+ ).join("");
367
+ }
368
+ function IconTextLayer({ layer, viewport, blockTime }) {
369
+ const { content, position, animation } = layer;
370
+ const { text, style } = content;
371
+ const rawX = resolveValue(position.x, viewport.width);
372
+ const rawY = resolveValue(position.y, viewport.height);
373
+ const boxWidth = position.width != null ? resolveValue(position.width, viewport.width) : viewport.width;
374
+ const anchor = position.anchor ?? "top-left";
375
+ const lineHeight = style.lineHeight || 1.4;
376
+ const lineHeightPx = style.fontSize * lineHeight;
377
+ const padding = style.padding ?? 0;
378
+ const plain = stripIconMarkers(text ?? "");
379
+ const lines = plain.split("\n").flatMap((line) => wrapText(line, style.fontSize, boxWidth));
380
+ const boxHeight = position.height != null ? resolveValue(position.height, viewport.height) : Math.max(lineHeightPx, lines.length * lineHeightPx) + padding * 2;
381
+ const boxX = rawX - anchorAxis(anchor, boxWidth, "x");
382
+ const boxY = rawY - anchorAxis(anchor, boxHeight, "y");
383
+ const animStyle = getAnimationStyle(animation, blockTime);
384
+ const html = useMemo(() => iconRunsToHtml(text ?? ""), [text]);
385
+ const verticalJustify = style.verticalAlign === "top" ? "flex-start" : style.verticalAlign === "bottom" ? "flex-end" : "center";
386
+ const boxStyle = {
387
+ boxSizing: "border-box",
388
+ width: "100%",
389
+ height: "100%",
390
+ display: "flex",
391
+ flexDirection: "column",
392
+ justifyContent: verticalJustify,
393
+ padding,
394
+ color: style.color,
395
+ fontSize: `${style.fontSize}px`,
396
+ fontFamily: style.fontFamily || DEFAULT_DOC_FONT,
397
+ fontWeight: style.fontWeight || "normal",
398
+ fontStyle: style.fontStyle || "normal",
399
+ lineHeight,
400
+ textAlign: style.textAlign ?? "left",
401
+ ...style.shadow ? { textShadow: "0 2px 3px rgba(0,0,0,0.7)" } : {},
402
+ ...animStyle.style
403
+ };
404
+ return /* @__PURE__ */ jsx3("g", { className: `block-layer block-layer--text ${animStyle.className}`, "data-layer-id": layer.id, children: /* @__PURE__ */ jsx3(
405
+ "foreignObject",
406
+ {
407
+ x: boxX,
408
+ y: boxY,
409
+ width: boxWidth,
410
+ height: boxHeight,
411
+ style: { overflow: "visible" },
412
+ children: /* @__PURE__ */ jsx3(
413
+ "div",
414
+ {
415
+ ...{ xmlns: "http://www.w3.org/1999/xhtml" },
416
+ style: boxStyle,
417
+ children: /* @__PURE__ */ jsx3(
418
+ "div",
419
+ {
420
+ style: { width: "100%", whiteSpace: "pre-wrap", wordBreak: "break-word" },
421
+ "aria-label": plain,
422
+ dangerouslySetInnerHTML: { __html: html }
423
+ }
424
+ )
425
+ }
426
+ )
427
+ }
428
+ ) });
429
+ }
430
+ function PlainTextLayer({ layer, viewport, blockTime }) {
431
+ const defsId = `${useId().replace(/:/g, "")}-${layer.id}`;
432
+ const { content, position, animation } = layer;
433
+ const { text, style } = content;
434
+ const rawX = resolveValue(position.x, viewport.width);
435
+ const rawY = resolveValue(position.y, viewport.height);
436
+ const boxWidth = position.width != null ? resolveValue(position.width, viewport.width) : void 0;
437
+ const boxHeight = position.height != null ? resolveValue(position.height, viewport.height) : void 0;
438
+ const maxWidth = boxWidth;
439
+ const textAnchor = getTextAnchor(style.textAlign, position.anchor);
440
+ const dominantBaseline = getDominantBaseline(style.verticalAlign, position.anchor);
441
+ const anchor = position.anchor ?? "top-left";
442
+ const x = pivotX(rawX, boxWidth, anchor, textAnchor);
443
+ const y = pivotY(rawY, boxHeight, anchor, dominantBaseline);
444
+ const animStyle = getAnimationStyle(animation, blockTime);
445
+ const rawLines = (text ?? "").split("\n");
446
+ let lines = maxWidth ? rawLines.reduce(
447
+ (acc, line) => acc.concat(wrapText(line, style.fontSize, maxWidth)),
448
+ []
449
+ ) : rawLines;
450
+ if (style.maxLines && lines.length > style.maxLines) {
451
+ lines = lines.slice(0, style.maxLines);
452
+ const last = lines[lines.length - 1];
453
+ lines[lines.length - 1] = last.replace(/\s*$/, "") + "...";
454
+ }
455
+ const lineHeight = style.lineHeight || 1.4;
456
+ const lineHeightPx = style.fontSize * lineHeight;
457
+ const textStyles = {
458
+ fontSize: `${style.fontSize}px`,
459
+ fontFamily: style.fontFamily || DEFAULT_DOC_FONT,
460
+ fontWeight: style.fontWeight || "normal",
461
+ fontStyle: style.fontStyle || "normal",
462
+ fill: style.color,
463
+ ...animStyle.style
464
+ };
465
+ const filterId = style.shadow ? `shadow-${defsId}` : void 0;
466
+ return /* @__PURE__ */ jsxs2("g", { className: `block-layer block-layer--text ${animStyle.className}`, "data-layer-id": layer.id, children: [
467
+ style.shadow && /* @__PURE__ */ jsx3("defs", { children: /* @__PURE__ */ jsx3("filter", { id: filterId, x: "-20%", y: "-20%", width: "140%", height: "140%", children: /* @__PURE__ */ jsx3("feDropShadow", { dx: "0", dy: "2", stdDeviation: "3", floodColor: "rgba(0,0,0,0.7)" }) }) }),
468
+ /* @__PURE__ */ jsx3(
469
+ TextBox,
470
+ {
471
+ layerId: defsId,
472
+ style,
473
+ box: boxWidth != null && boxHeight != null ? {
474
+ x: rawX - anchorAxis(anchor, boxWidth, "x"),
475
+ y: rawY - anchorAxis(anchor, boxHeight, "y"),
476
+ width: boxWidth,
477
+ height: boxHeight
478
+ } : {
479
+ x: x - (style.padding || 16),
480
+ y: y - style.fontSize - (style.padding || 16),
481
+ width: getTextBoxWidth(lines, style) + (style.padding || 16) * 2,
482
+ height: lines.length * lineHeightPx + (style.padding || 16) * 2
483
+ }
484
+ }
485
+ ),
486
+ /* @__PURE__ */ jsx3(
487
+ "text",
488
+ {
489
+ x,
490
+ y,
491
+ textAnchor,
492
+ dominantBaseline,
493
+ style: textStyles,
494
+ filter: filterId ? `url(#${filterId})` : void 0,
495
+ children: lines.map((line, i) => /* @__PURE__ */ jsxs2("tspan", { x, dy: i === 0 ? 0 : lineHeightPx, children: [
496
+ line || "\xA0",
497
+ " "
498
+ ] }, i))
499
+ }
500
+ )
501
+ ] });
502
+ }
503
+ function RichTextLayer({ layer, viewport, blockTime }) {
504
+ const { content, position, animation } = layer;
505
+ const { html, style } = content;
506
+ const rawX = resolveValue(position.x, viewport.width);
507
+ const rawY = resolveValue(position.y, viewport.height);
508
+ const boxWidth = position.width != null ? resolveValue(position.width, viewport.width) : viewport.width;
509
+ const boxHeight = position.height != null ? resolveValue(position.height, viewport.height) : style.fontSize * (style.lineHeight || 1.4) * 2;
510
+ const anchor = position.anchor ?? "top-left";
511
+ const boxX = rawX - anchorAxis(anchor, boxWidth, "x");
512
+ const boxY = rawY - anchorAxis(anchor, boxHeight, "y");
513
+ const safeHtml = useMemo(
514
+ () => stringifyHtmlNodes(sanitizeHtmlNodes(parseHtmlToNodes(html ?? ""))),
515
+ [html]
516
+ );
517
+ const animStyle = getAnimationStyle(animation, blockTime);
518
+ const verticalJustify = style.verticalAlign === "middle" ? "center" : style.verticalAlign === "bottom" ? "flex-end" : "flex-start";
519
+ const hasBorder = !!(style.borderColor && (style.borderWidth ?? 0) > 0);
520
+ const boxStyle = {
521
+ boxSizing: "border-box",
522
+ width: "100%",
523
+ height: "100%",
524
+ display: "flex",
525
+ flexDirection: "column",
526
+ justifyContent: verticalJustify,
527
+ padding: style.padding ?? 0,
528
+ color: style.color,
529
+ fontSize: `${style.fontSize}px`,
530
+ fontFamily: style.fontFamily || DEFAULT_DOC_FONT,
531
+ fontWeight: style.fontWeight || "normal",
532
+ fontStyle: style.fontStyle || "normal",
533
+ lineHeight: style.lineHeight || 1.4,
534
+ textAlign: style.textAlign ?? "left",
535
+ overflow: "hidden",
536
+ ...style.background ? { background: style.background } : {},
537
+ ...hasBorder ? {
538
+ border: `${style.borderWidth}px ${style.borderStyle ?? "solid"} ${style.borderColor}`,
539
+ borderRadius: 4
540
+ } : {},
541
+ ...style.shadow ? { textShadow: "0 2px 3px rgba(0,0,0,0.7)" } : {},
542
+ ...animStyle.style
543
+ };
544
+ const cls = `squisq-rich-text-${cssId(layer.id)}`;
545
+ const scopedCss = `.${cls}{margin:0}.${cls} p{margin:0 0 .4em}.${cls} h1,.${cls} h2,.${cls} h3,.${cls} h4,.${cls} h5,.${cls} h6{margin:0 0 .3em;line-height:1.2}.${cls} ul,.${cls} ol{margin:0 0 .4em;padding-left:1.2em;list-style-position:inside}.${cls} li{margin:0}.${cls} li>p{display:inline;margin:0}.${cls} *:first-child{margin-top:0}.${cls} *:last-child{margin-bottom:0}.${cls} a{color:inherit;text-decoration:underline}`;
546
+ return /* @__PURE__ */ jsx3("g", { className: `block-layer block-layer--text ${animStyle.className}`, "data-layer-id": layer.id, children: /* @__PURE__ */ jsx3("foreignObject", { x: boxX, y: boxY, width: boxWidth, height: boxHeight, children: /* @__PURE__ */ jsxs2(
547
+ "div",
548
+ {
549
+ ...{ xmlns: "http://www.w3.org/1999/xhtml" },
550
+ style: boxStyle,
551
+ children: [
552
+ /* @__PURE__ */ jsx3("style", { children: scopedCss }),
553
+ /* @__PURE__ */ jsx3(
554
+ "div",
555
+ {
556
+ className: cls,
557
+ "aria-label": content.text,
558
+ style: { width: "100%", whiteSpace: "pre-wrap", wordBreak: "break-word" },
559
+ dangerouslySetInnerHTML: { __html: safeHtml }
560
+ }
561
+ )
562
+ ]
563
+ }
564
+ ) }) });
565
+ }
566
+ function cssId(id) {
567
+ return id.replace(/[^a-zA-Z0-9_-]/g, "-");
568
+ }
569
+ function TextBox({
570
+ layerId,
571
+ style,
572
+ box
573
+ }) {
574
+ const hasFill = !!(style.background || style.backgroundGradient);
575
+ const hasBorder = !!(style.borderColor && (style.borderWidth ?? 0) > 0);
576
+ if (!hasFill && !hasBorder) return null;
577
+ const { fill, def } = resolveFill(layerId, style.background, style.backgroundGradient);
578
+ const dash = borderDashArray(style.borderStyle, style.borderWidth);
579
+ return /* @__PURE__ */ jsxs2(Fragment, { children: [
580
+ def && /* @__PURE__ */ jsx3("defs", { children: def }),
581
+ /* @__PURE__ */ jsx3(
582
+ "rect",
583
+ {
584
+ x: box.x,
585
+ y: box.y,
586
+ width: box.width,
587
+ height: box.height,
588
+ fill: hasFill ? fill : "none",
589
+ fillOpacity: hasFill ? style.backgroundOpacity : void 0,
590
+ stroke: hasBorder ? style.borderColor : void 0,
591
+ strokeWidth: hasBorder ? style.borderWidth : void 0,
592
+ strokeDasharray: hasBorder ? dash : void 0,
593
+ rx: 4,
594
+ ry: 4
595
+ }
596
+ )
597
+ ] });
598
+ }
599
+ function getTextAnchor(align, anchor) {
600
+ if (align === "center") return "middle";
601
+ if (align === "right") return "end";
602
+ if (align === "left") return "start";
603
+ if (anchor?.includes("right")) return "end";
604
+ if (anchor === "center") return "middle";
605
+ return "start";
606
+ }
607
+ function getDominantBaseline(verticalAlign, anchor) {
608
+ if (verticalAlign === "top") return "text-before-edge";
609
+ if (verticalAlign === "middle") return "middle";
610
+ if (verticalAlign === "bottom") return "text-after-edge";
611
+ if (anchor?.includes("bottom")) return "text-after-edge";
612
+ if (anchor === "center") return "middle";
613
+ return "text-before-edge";
614
+ }
615
+ function pivotX(rawX, width, anchor, textAnchor) {
616
+ if (width == null) return rawX;
617
+ const boxLeft = rawX - anchorAxis(anchor, width, "x");
618
+ if (textAnchor === "middle") return boxLeft + width / 2;
619
+ if (textAnchor === "end") return boxLeft + width;
620
+ return boxLeft;
621
+ }
622
+ function pivotY(rawY, height, anchor, dominantBaseline) {
623
+ if (height == null) return rawY;
624
+ const boxTop = rawY - anchorAxis(anchor, height, "y");
625
+ if (dominantBaseline === "middle") return boxTop + height / 2;
626
+ if (dominantBaseline === "text-after-edge") return boxTop + height;
627
+ return boxTop;
628
+ }
629
+ function anchorAxis(anchor, size, axis) {
630
+ if (anchor === "center") return size / 2;
631
+ if (axis === "x") return anchor.includes("right") ? size : 0;
632
+ return anchor.includes("bottom") ? size : 0;
633
+ }
634
+ function getTextBoxWidth(lines, style) {
635
+ const maxLineLength = Math.max(...lines.map((l) => l.length));
636
+ return maxLineLength * style.fontSize * 0.55;
637
+ }
638
+ function wrapText(text, fontSize, maxWidth) {
639
+ if (!text.trim()) return [""];
640
+ const avgCharWidth = fontSize * 0.5;
641
+ const charsPerLine = Math.floor(maxWidth / avgCharWidth);
642
+ if (charsPerLine <= 0) return [text];
643
+ const words = text.split(/\s+/);
644
+ const lines = [];
645
+ let currentLine = "";
646
+ for (const word of words) {
647
+ const testLine = currentLine ? `${currentLine} ${word}` : word;
648
+ if (testLine.length <= charsPerLine) {
649
+ currentLine = testLine;
650
+ } else {
651
+ if (currentLine) {
652
+ lines.push(currentLine);
653
+ }
654
+ if (word.length > charsPerLine) {
655
+ let remaining = word;
656
+ while (remaining.length > charsPerLine) {
657
+ lines.push(remaining.slice(0, charsPerLine));
658
+ remaining = remaining.slice(charsPerLine);
659
+ }
660
+ currentLine = remaining;
661
+ } else {
662
+ currentLine = word;
663
+ }
664
+ }
665
+ }
666
+ if (currentLine) {
667
+ lines.push(currentLine);
668
+ }
669
+ return lines.length > 0 ? lines : [""];
670
+ }
671
+
672
+ // src/layers/ShapeLayer.tsx
673
+ import { useId as useId2 } from "react";
674
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
675
+ var FULL_BLEED_OVERSCAN = 1;
676
+ function ShapeLayer({ layer, viewport, blockTime }) {
677
+ const { content, position, animation } = layer;
678
+ const defsId = `${useId2().replace(/:/g, "")}-${layer.id}`;
679
+ const rawX = resolveValue(position.x, viewport.width);
680
+ const rawY = resolveValue(position.y, viewport.height);
681
+ const width = position.width ? resolveValue(position.width, viewport.width) : 100;
682
+ const height = position.height ? resolveValue(position.height, viewport.height) : 100;
683
+ const anchorOffset = getAnchorOffset(position.anchor, width, height);
684
+ const x = rawX + anchorOffset.x;
685
+ const y = rawY + anchorOffset.y;
686
+ const isUnborderedFullBleedRect = content.shape === "rect" && x === 0 && y === 0 && width === viewport.width && height === viewport.height && !content.stroke && !content.borderRadius;
687
+ const overscan = isUnborderedFullBleedRect ? FULL_BLEED_OVERSCAN : 0;
688
+ const paintX = x - overscan;
689
+ const paintY = y - overscan;
690
+ const paintWidth = width + overscan * 2;
691
+ const paintHeight = height + overscan * 2;
692
+ const animStyle = getAnimationStyle(animation, blockTime);
693
+ const fill = content.fill || "none";
694
+ const isCSSGradient = typeof fill === "string" && fill.includes("gradient(");
695
+ if (content.shape === "rect" && isCSSGradient && !content.gradient) {
696
+ return /* @__PURE__ */ jsx4(
697
+ "g",
698
+ {
699
+ className: `block-layer block-layer--shape ${animStyle.className}`,
700
+ style: animStyle.style,
701
+ "data-layer-id": layer.id,
702
+ children: /* @__PURE__ */ jsx4("foreignObject", { x: paintX, y: paintY, width: paintWidth, height: paintHeight, children: /* @__PURE__ */ jsx4(
703
+ "div",
704
+ {
705
+ style: {
706
+ width: `${paintWidth}px`,
707
+ height: `${paintHeight}px`,
708
+ background: fill,
709
+ borderRadius: content.borderRadius ? `${content.borderRadius}px` : void 0,
710
+ pointerEvents: "none"
711
+ }
712
+ }
713
+ ) })
714
+ }
715
+ );
716
+ }
717
+ const { fill: fillValue, def: fillDef } = resolveFill(
718
+ defsId,
719
+ fill,
720
+ content.gradient,
721
+ content.pattern
722
+ );
723
+ const { filterAttr, def: filterDef } = resolveShapeFilter(defsId, content.filter);
724
+ const dash = borderDashArray(content.borderStyle, content.strokeWidth);
725
+ const shapeProps = {
726
+ fill: fillValue,
727
+ fillOpacity: content.fillOpacity,
728
+ stroke: content.stroke,
729
+ strokeWidth: content.strokeWidth,
730
+ strokeDasharray: dash,
731
+ ...filterAttr ? { filter: filterAttr } : {}
732
+ };
733
+ return /* @__PURE__ */ jsxs3(
734
+ "g",
735
+ {
736
+ className: `block-layer block-layer--shape ${animStyle.className}`,
737
+ style: animStyle.style,
738
+ "data-layer-id": layer.id,
739
+ children: [
740
+ (fillDef || filterDef) && /* @__PURE__ */ jsxs3("defs", { children: [
741
+ fillDef,
742
+ filterDef
743
+ ] }),
744
+ content.shape === "rect" && /* @__PURE__ */ jsx4(
745
+ "rect",
746
+ {
747
+ x: paintX,
748
+ y: paintY,
749
+ width: paintWidth,
750
+ height: paintHeight,
751
+ rx: content.borderRadius,
752
+ ry: content.borderRadius,
753
+ ...shapeProps
754
+ }
755
+ ),
756
+ content.shape === "circle" && /* @__PURE__ */ jsx4(
757
+ "circle",
758
+ {
759
+ cx: x + width / 2,
760
+ cy: y + height / 2,
761
+ r: Math.min(width, height) / 2,
762
+ ...shapeProps
763
+ }
764
+ ),
765
+ content.shape === "line" && /* @__PURE__ */ jsx4(
766
+ "line",
767
+ {
768
+ x1: x,
769
+ y1: y,
770
+ x2: x + width,
771
+ y2: y + height,
772
+ stroke: content.stroke || "#ffffff",
773
+ strokeWidth: content.strokeWidth || 2,
774
+ strokeDasharray: dash
775
+ }
776
+ )
777
+ ]
778
+ }
779
+ );
780
+ }
781
+
782
+ // src/layers/PathLayer.tsx
783
+ import { useId as useId3 } from "react";
784
+ import { markerPath, shapePath } from "@bendyline/squisq/doc";
785
+ import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
786
+ function effectivePath(layer, viewport) {
787
+ const { content, position } = layer;
788
+ if (!content.shapeKind) return content.d;
789
+ const w = position.width ? resolveValue(position.width, viewport.width) : 0;
790
+ const h = position.height ? resolveValue(position.height, viewport.height) : 0;
791
+ const rawX = resolveValue(position.x, viewport.width);
792
+ const rawY = resolveValue(position.y, viewport.height);
793
+ const anchor = getAnchorOffset(position.anchor, w, h);
794
+ const derived = shapePath(content.shapeKind, rawX + anchor.x, rawY + anchor.y, w, h);
795
+ return derived ?? content.d;
796
+ }
797
+ function readLegacyArrow(content) {
798
+ return content.arrow;
799
+ }
800
+ function effectiveMarker(explicit, legacyArrow, end) {
801
+ if (explicit) return explicit;
802
+ const wants = legacyArrow === "both" || legacyArrow === end;
803
+ return wants ? "arrow" : "none";
804
+ }
805
+ function PathLayer({ layer, viewport, blockTime }) {
806
+ const { content, animation, id } = layer;
807
+ const defsId = `${useId3().replace(/:/g, "")}-${id}`;
808
+ const d = effectivePath(layer, viewport);
809
+ const stroke = content.stroke ?? "#1e293b";
810
+ const strokeWidth = content.strokeWidth ?? 2;
811
+ const { fill, def: fillDef } = resolveFill(defsId, content.fill ?? "none", content.gradient);
812
+ const dash = content.borderStyle ? borderDashArray(content.borderStyle, strokeWidth) : content.dasharray;
813
+ const animStyle = getAnimationStyle(animation, blockTime);
814
+ const startId = `marker-start-${defsId}`;
815
+ const endId = `marker-end-${defsId}`;
816
+ const legacyArrow = readLegacyArrow(content);
817
+ const start = markerPath(effectiveMarker(content.startMarker, legacyArrow, "start"), "start");
818
+ const end = markerPath(effectiveMarker(content.endMarker, legacyArrow, "end"), "end");
819
+ return /* @__PURE__ */ jsxs4(
820
+ "g",
821
+ {
822
+ className: `block-layer block-layer--path ${animStyle.className}`,
823
+ style: animStyle.style,
824
+ "data-layer-id": id,
825
+ children: [
826
+ /* @__PURE__ */ jsxs4("defs", { children: [
827
+ fillDef,
828
+ end && /* @__PURE__ */ jsx5(MarkerDef, { id: endId, dir: "end", d: end.d, filled: end.filled, stroke }),
829
+ start && /* @__PURE__ */ jsx5(MarkerDef, { id: startId, dir: "start", d: start.d, filled: start.filled, stroke })
830
+ ] }),
831
+ /* @__PURE__ */ jsx5(
832
+ "path",
833
+ {
834
+ d,
835
+ stroke,
836
+ strokeWidth,
837
+ fill,
838
+ fillOpacity: content.fillOpacity,
839
+ strokeDasharray: dash,
840
+ markerStart: start ? `url(#${startId})` : void 0,
841
+ markerEnd: end ? `url(#${endId})` : void 0
842
+ }
843
+ )
844
+ ]
845
+ }
846
+ );
847
+ }
848
+ function MarkerDef({
849
+ id,
850
+ dir,
851
+ d,
852
+ filled,
853
+ stroke
854
+ }) {
855
+ return /* @__PURE__ */ jsx5(
856
+ "marker",
857
+ {
858
+ id,
859
+ viewBox: "0 0 10 10",
860
+ refX: dir === "end" ? 9 : 1,
861
+ refY: 5,
862
+ markerWidth: 4,
863
+ markerHeight: 4,
864
+ orient: "auto-start-reverse",
865
+ markerUnits: "strokeWidth",
866
+ children: /* @__PURE__ */ jsx5(
867
+ "path",
868
+ {
869
+ d,
870
+ fill: filled ? stroke : "none",
871
+ stroke: filled ? "none" : stroke,
872
+ strokeWidth: filled ? void 0 : 1.5
873
+ }
874
+ )
875
+ }
876
+ );
877
+ }
878
+
879
+ // src/layers/MapLayer.tsx
880
+ import { useId as useId4, useState, useEffect } from "react";
881
+ import { ResourcePolicyError as ResourcePolicyError2 } from "@bendyline/squisq/markdown";
882
+
883
+ // src/utils/mapTileUtils.ts
884
+ import {
885
+ isResourceUrlAllowed,
886
+ ResourcePolicyError
887
+ } from "@bendyline/squisq/markdown";
888
+ var TILE_PROVIDERS = {
889
+ terrain: {
890
+ url: "https://tile.opentopomap.org/{z}/{x}/{y}.png",
891
+ attribution: "Map: OpenTopoMap (CC-BY-SA)",
892
+ maxZoom: 17
893
+ },
894
+ road: {
895
+ url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
896
+ attribution: "\xA9 OpenStreetMap contributors",
897
+ maxZoom: 19
898
+ },
899
+ satellite: {
900
+ url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
901
+ attribution: "Imagery: Esri, Maxar, Earthstar",
902
+ maxZoom: 18
903
+ },
904
+ toner: {
905
+ url: "https://tiles.stadiamaps.com/tiles/stamen_toner/{z}/{x}/{y}.png",
906
+ attribution: "Map: Stadia Maps, Stamen Design",
907
+ maxZoom: 20
908
+ },
909
+ watercolor: {
910
+ url: "https://tiles.stadiamaps.com/tiles/stamen_watercolor/{z}/{x}/{y}.jpg",
911
+ attribution: "Map: Stadia Maps, Stamen Design",
912
+ maxZoom: 16
913
+ }
914
+ };
915
+ function latLngToTile(lat, lng, zoom) {
916
+ const n = Math.pow(2, zoom);
917
+ const x = Math.floor((lng + 180) / 360 * n);
918
+ const latRad = lat * Math.PI / 180;
919
+ const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
920
+ return { x, y };
921
+ }
922
+ function getPixelOffset(lat, lng, zoom, tileSize = 256) {
923
+ const n = Math.pow(2, zoom);
924
+ const xTile = (lng + 180) / 360 * n;
925
+ const latRad = lat * Math.PI / 180;
926
+ const yTile = (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n;
927
+ return {
928
+ x: (xTile - Math.floor(xTile)) * tileSize,
929
+ y: (yTile - Math.floor(yTile)) * tileSize
930
+ };
931
+ }
932
+ function getTilesForViewport(centerLat, centerLng, zoom, viewportWidth, viewportHeight, tileSize = 256) {
933
+ const centerTile = latLngToTile(centerLat, centerLng, zoom);
934
+ const pixelOffset = getPixelOffset(centerLat, centerLng, zoom, tileSize);
935
+ const tilesX = Math.ceil(viewportWidth / tileSize) + 1;
936
+ const tilesY = Math.ceil(viewportHeight / tileSize) + 1;
937
+ const startX = centerTile.x - Math.floor(tilesX / 2);
938
+ const startY = centerTile.y - Math.floor(tilesY / 2);
939
+ const centerScreenX = viewportWidth / 2 - pixelOffset.x;
940
+ const centerScreenY = viewportHeight / 2 - pixelOffset.y;
941
+ const tiles = [];
942
+ for (let dy = 0; dy < tilesY; dy++) {
943
+ for (let dx = 0; dx < tilesX; dx++) {
944
+ const tileX = startX + dx;
945
+ const tileY = startY + dy;
946
+ const screenX = centerScreenX + (tileX - centerTile.x) * tileSize;
947
+ const screenY = centerScreenY + (tileY - centerTile.y) * tileSize;
948
+ tiles.push({ x: tileX, y: tileY, screenX, screenY });
949
+ }
950
+ }
951
+ return tiles;
952
+ }
953
+ function buildTileUrl(provider, x, y, z) {
954
+ return provider.url.replace("{z}", String(z)).replace("{x}", String(x)).replace("{y}", String(y));
955
+ }
956
+ async function fetchTileImage(url) {
957
+ return new Promise((resolve, reject) => {
958
+ const img = new Image();
959
+ img.crossOrigin = "anonymous";
960
+ img.onload = () => resolve(img);
961
+ img.onerror = () => reject(new Error(`Failed to load tile: ${url}`));
962
+ img.src = url;
963
+ });
964
+ }
965
+ async function composeMapImage(options) {
966
+ const { center, zoom, style, width, height, markers = [], showAttribution = true } = options;
967
+ const provider = TILE_PROVIDERS[style];
968
+ const tileSize = provider.tileSize || 256;
969
+ const clampedZoom = Math.min(zoom, provider.maxZoom);
970
+ const probeUrl = buildTileUrl(provider, 0, 0, clampedZoom);
971
+ if (!isResourceUrlAllowed(probeUrl, options.policy)) {
972
+ throw new ResourcePolicyError(
973
+ "RESOURCE_BLOCKED",
974
+ `Map tiles for style "${style}" are blocked by the resource policy (${provider.url}). Composing a map contacts the tile host; supply the map as \`staticSrc\` to render it without any remote request.`
975
+ );
976
+ }
977
+ const canvas = document.createElement("canvas");
978
+ canvas.width = width;
979
+ canvas.height = height;
980
+ const ctx = canvas.getContext("2d");
981
+ if (!ctx) throw new Error("Failed to get canvas context");
982
+ ctx.fillStyle = style === "toner" ? "#ffffff" : "#e5e7eb";
983
+ ctx.fillRect(0, 0, width, height);
984
+ const tiles = getTilesForViewport(center.lat, center.lng, clampedZoom, width, height, tileSize);
985
+ const tilePromises = tiles.map(async (tile) => {
986
+ const url = buildTileUrl(provider, tile.x, tile.y, clampedZoom);
987
+ try {
988
+ const img = await fetchTileImage(url);
989
+ ctx.drawImage(img, tile.screenX, tile.screenY, tileSize, tileSize);
990
+ } catch (err) {
991
+ console.warn(`Tile load failed: ${url}`, err);
992
+ }
993
+ });
994
+ await Promise.all(tilePromises);
995
+ for (const marker of markers) {
996
+ drawMarker(ctx, marker, center, clampedZoom, width, height, tileSize);
997
+ }
998
+ if (showAttribution) {
999
+ drawAttribution(ctx, provider.attribution, width, height);
1000
+ }
1001
+ return canvas.toDataURL("image/png");
1002
+ }
1003
+ function drawMarker(ctx, marker, center, zoom, width, height, tileSize) {
1004
+ const centerTile = latLngToTile(center.lat, center.lng, zoom);
1005
+ const markerTile = latLngToTile(marker.lat, marker.lng, zoom);
1006
+ const centerOffset = getPixelOffset(center.lat, center.lng, zoom, tileSize);
1007
+ const markerOffset = getPixelOffset(marker.lat, marker.lng, zoom, tileSize);
1008
+ const dx = (markerTile.x - centerTile.x) * tileSize + (markerOffset.x - centerOffset.x);
1009
+ const dy = (markerTile.y - centerTile.y) * tileSize + (markerOffset.y - centerOffset.y);
1010
+ const screenX = width / 2 + dx;
1011
+ const screenY = height / 2 + dy;
1012
+ const color = marker.color || "#ef4444";
1013
+ const icon = marker.icon || "pin";
1014
+ ctx.save();
1015
+ if (icon === "pin") {
1016
+ ctx.fillStyle = color;
1017
+ ctx.beginPath();
1018
+ ctx.arc(screenX, screenY - 12, 8, Math.PI, 0, false);
1019
+ ctx.lineTo(screenX, screenY);
1020
+ ctx.closePath();
1021
+ ctx.fill();
1022
+ ctx.fillStyle = "#ffffff";
1023
+ ctx.beginPath();
1024
+ ctx.arc(screenX, screenY - 12, 3, 0, Math.PI * 2);
1025
+ ctx.fill();
1026
+ } else if (icon === "circle") {
1027
+ ctx.fillStyle = color;
1028
+ ctx.beginPath();
1029
+ ctx.arc(screenX, screenY, 8, 0, Math.PI * 2);
1030
+ ctx.fill();
1031
+ ctx.strokeStyle = "#ffffff";
1032
+ ctx.lineWidth = 2;
1033
+ ctx.stroke();
1034
+ } else if (icon === "star") {
1035
+ ctx.fillStyle = color;
1036
+ drawStar(ctx, screenX, screenY, 5, 10, 5);
1037
+ ctx.fill();
1038
+ }
1039
+ if (marker.label) {
1040
+ ctx.fillStyle = "#1f2937";
1041
+ ctx.font = "bold 12px system-ui, sans-serif";
1042
+ ctx.textAlign = "center";
1043
+ ctx.fillText(marker.label, screenX, screenY + 20);
1044
+ }
1045
+ ctx.restore();
1046
+ }
1047
+ function drawStar(ctx, cx, cy, spikes, outerRadius, innerRadius) {
1048
+ let rot = Math.PI / 2 * 3;
1049
+ const step = Math.PI / spikes;
1050
+ ctx.beginPath();
1051
+ ctx.moveTo(cx, cy - outerRadius);
1052
+ for (let i = 0; i < spikes; i++) {
1053
+ ctx.lineTo(cx + Math.cos(rot) * outerRadius, cy + Math.sin(rot) * outerRadius);
1054
+ rot += step;
1055
+ ctx.lineTo(cx + Math.cos(rot) * innerRadius, cy + Math.sin(rot) * innerRadius);
1056
+ rot += step;
1057
+ }
1058
+ ctx.lineTo(cx, cy - outerRadius);
1059
+ ctx.closePath();
1060
+ }
1061
+ function drawAttribution(ctx, text, width, height) {
1062
+ ctx.save();
1063
+ ctx.fillStyle = "rgba(255, 255, 255, 0.8)";
1064
+ const padding = 4;
1065
+ ctx.font = "10px system-ui, sans-serif";
1066
+ const textWidth = ctx.measureText(text).width;
1067
+ ctx.fillRect(width - textWidth - padding * 2 - 4, height - 16, textWidth + padding * 2, 14);
1068
+ ctx.fillStyle = "#374151";
1069
+ ctx.textAlign = "right";
1070
+ ctx.fillText(text, width - padding - 4, height - 5);
1071
+ ctx.restore();
1072
+ }
1073
+
1074
+ // src/layers/MapLayer.tsx
1075
+ import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
1076
+ function MapLayer({ layer, basePath, viewport, blockTime }) {
1077
+ const { content, position, animation } = layer;
1078
+ const clipId = `map-clip-${useId4().replace(/:/g, "")}-${layer.id}`;
1079
+ const [mapImageUrl, setMapImageUrl] = useState(null);
1080
+ const [isLoading, setIsLoading] = useState(true);
1081
+ const [error, setError] = useState(null);
1082
+ const [blockedByPolicy, setBlockedByPolicy] = useState(false);
1083
+ const x = resolveValue(position.x, viewport.width);
1084
+ const y = resolveValue(position.y, viewport.height);
1085
+ const width = position.width ? resolveValue(position.width, viewport.width) : viewport.width;
1086
+ const height = position.height ? resolveValue(position.height, viewport.height) : viewport.height;
1087
+ const offset = getAnchorOffset(position.anchor, width, height);
1088
+ const finalX = x + offset.x;
1089
+ const finalY = y + offset.y;
1090
+ const staticSrc = useMediaUrl(content.staticSrc ?? "", basePath);
1091
+ const resourcePolicy = useResourcePolicy();
1092
+ useEffect(() => {
1093
+ let cancelled = false;
1094
+ if (content.staticSrc) {
1095
+ setMapImageUrl(staticSrc || null);
1096
+ setIsLoading(false);
1097
+ return;
1098
+ }
1099
+ setIsLoading(true);
1100
+ setError(null);
1101
+ setBlockedByPolicy(false);
1102
+ composeMapImage({
1103
+ center: content.center,
1104
+ zoom: content.zoom,
1105
+ style: content.style,
1106
+ width,
1107
+ height,
1108
+ markers: content.markers,
1109
+ showAttribution: content.showAttribution !== false,
1110
+ policy: resourcePolicy
1111
+ }).then((dataUrl) => {
1112
+ if (!cancelled) {
1113
+ setMapImageUrl(dataUrl);
1114
+ setIsLoading(false);
1115
+ }
1116
+ }).catch((err) => {
1117
+ if (!cancelled) {
1118
+ const blocked = err instanceof ResourcePolicyError2;
1119
+ if (!blocked) console.error("Failed to compose map:", err);
1120
+ setError(err instanceof Error ? err.message : String(err));
1121
+ setBlockedByPolicy(blocked);
1122
+ setIsLoading(false);
1123
+ }
1124
+ });
1125
+ return () => {
1126
+ cancelled = true;
1127
+ };
1128
+ }, [
1129
+ content.center,
1130
+ content.zoom,
1131
+ content.style,
1132
+ content.staticSrc,
1133
+ staticSrc,
1134
+ content.markers,
1135
+ content.showAttribution,
1136
+ width,
1137
+ height,
1138
+ basePath,
1139
+ resourcePolicy
1140
+ ]);
1141
+ const animStyle = getAnimationStyle(animation, blockTime);
1142
+ if (isLoading) {
1143
+ return /* @__PURE__ */ jsxs5(
1144
+ "g",
1145
+ {
1146
+ className: `block-layer block-layer--map ${animStyle.className}`,
1147
+ style: animStyle.style,
1148
+ "data-layer-id": layer.id,
1149
+ children: [
1150
+ /* @__PURE__ */ jsx6("rect", { x: finalX, y: finalY, width, height, fill: "#e5e7eb" }),
1151
+ /* @__PURE__ */ jsx6(
1152
+ "text",
1153
+ {
1154
+ x: finalX + width / 2,
1155
+ y: finalY + height / 2,
1156
+ textAnchor: "middle",
1157
+ dominantBaseline: "middle",
1158
+ fill: "#9ca3af",
1159
+ fontSize: "24",
1160
+ fontFamily: "system-ui, sans-serif",
1161
+ children: "Loading map..."
1162
+ }
1163
+ )
1164
+ ]
1165
+ }
1166
+ );
1167
+ }
1168
+ if (error || !mapImageUrl) {
1169
+ return /* @__PURE__ */ jsxs5(
1170
+ "g",
1171
+ {
1172
+ className: `block-layer block-layer--map ${animStyle.className}`,
1173
+ style: animStyle.style,
1174
+ "data-layer-id": layer.id,
1175
+ children: [
1176
+ /* @__PURE__ */ jsx6(
1177
+ "rect",
1178
+ {
1179
+ x: finalX,
1180
+ y: finalY,
1181
+ width,
1182
+ height,
1183
+ fill: blockedByPolicy ? "#f3f4f6" : "#fef2f2"
1184
+ }
1185
+ ),
1186
+ /* @__PURE__ */ jsx6(
1187
+ "text",
1188
+ {
1189
+ x: finalX + width / 2,
1190
+ y: finalY + height / 2,
1191
+ textAnchor: "middle",
1192
+ dominantBaseline: "middle",
1193
+ fill: blockedByPolicy ? "#6b7280" : "#dc2626",
1194
+ fontSize: "18",
1195
+ fontFamily: "system-ui, sans-serif",
1196
+ children: blockedByPolicy ? "Map unavailable offline" : "Map failed to load"
1197
+ }
1198
+ )
1199
+ ]
1200
+ }
1201
+ );
1202
+ }
1203
+ return /* @__PURE__ */ jsxs5(
1204
+ "g",
1205
+ {
1206
+ className: `block-layer block-layer--map ${animStyle.className}`,
1207
+ style: animStyle.style,
1208
+ "data-layer-id": layer.id,
1209
+ children: [
1210
+ /* @__PURE__ */ jsx6("defs", { children: /* @__PURE__ */ jsx6("clipPath", { id: clipId, children: /* @__PURE__ */ jsx6("rect", { x: finalX, y: finalY, width, height }) }) }),
1211
+ /* @__PURE__ */ jsx6("g", { clipPath: `url(#${clipId})`, children: /* @__PURE__ */ jsx6(
1212
+ "image",
1213
+ {
1214
+ href: mapImageUrl,
1215
+ x: finalX,
1216
+ y: finalY,
1217
+ width,
1218
+ height,
1219
+ preserveAspectRatio: "xMidYMid slice",
1220
+ style: { pointerEvents: "none" }
1221
+ }
1222
+ ) })
1223
+ ]
1224
+ }
1225
+ );
1226
+ }
1227
+
1228
+ // src/layers/VideoLayer.tsx
1229
+ import { useRef, useEffect as useEffect2 } from "react";
1230
+ import { jsx as jsx7 } from "react/jsx-runtime";
1231
+ var VIDEO_SYNC_DRIFT_SECONDS = 0.2;
1232
+ function VideoLayer({ layer, basePath, viewport, blockTime, isPlaying }) {
1233
+ const { content, position } = layer;
1234
+ const videoRef = useRef(null);
1235
+ const hasStartedRef = useRef(false);
1236
+ const startAt = content.startAt ?? 0;
1237
+ const gated = blockTime < startAt;
1238
+ const x = resolveValue(position.x, viewport.width);
1239
+ const y = resolveValue(position.y, viewport.height);
1240
+ const width = position.width ? resolveValue(position.width, viewport.width) : viewport.width;
1241
+ const height = position.height ? resolveValue(position.height, viewport.height) : viewport.height;
1242
+ const offset = getAnchorOffset(position.anchor, width, height);
1243
+ const finalX = x + offset.x;
1244
+ const finalY = y + offset.y;
1245
+ const src = useMediaUrl(content.src, basePath);
1246
+ const resolvedPoster = useMediaUrl(content.posterSrc || "", basePath);
1247
+ const posterSrc = content.posterSrc ? resolvedPoster : void 0;
1248
+ useEffect2(() => {
1249
+ const video = videoRef.current;
1250
+ if (!video) return;
1251
+ video.currentTime = content.clipStart;
1252
+ hasStartedRef.current = true;
1253
+ if (isPlaying) {
1254
+ const playPromise = video.play();
1255
+ if (playPromise) {
1256
+ playPromise.catch(() => {
1257
+ });
1258
+ }
1259
+ }
1260
+ const handleTimeUpdate = () => {
1261
+ if (video.currentTime >= content.clipEnd) {
1262
+ video.pause();
1263
+ video.currentTime = content.clipEnd;
1264
+ }
1265
+ };
1266
+ video.addEventListener("timeupdate", handleTimeUpdate);
1267
+ return () => {
1268
+ video.removeEventListener("timeupdate", handleTimeUpdate);
1269
+ video.pause();
1270
+ };
1271
+ }, [src, content.clipStart, content.clipEnd]);
1272
+ useEffect2(() => {
1273
+ const video = videoRef.current;
1274
+ if (!video || !hasStartedRef.current) return;
1275
+ const targetTime = gated ? content.clipStart : Math.min(content.clipEnd, content.clipStart + Math.max(0, blockTime - startAt));
1276
+ if (Math.abs(video.currentTime - targetTime) > VIDEO_SYNC_DRIFT_SECONDS) {
1277
+ video.currentTime = targetTime;
1278
+ }
1279
+ if (gated) {
1280
+ video.pause();
1281
+ return;
1282
+ }
1283
+ if (targetTime >= content.clipEnd) {
1284
+ video.pause();
1285
+ return;
1286
+ }
1287
+ if (isPlaying) {
1288
+ const playPromise = video.play();
1289
+ if (playPromise) {
1290
+ playPromise.catch(() => {
1291
+ });
1292
+ }
1293
+ } else {
1294
+ video.pause();
1295
+ }
1296
+ }, [isPlaying, gated, blockTime, startAt, src, content.clipStart, content.clipEnd]);
1297
+ return /* @__PURE__ */ jsx7("g", { className: "block-layer block-layer--video", "data-layer-id": layer.id, children: /* @__PURE__ */ jsx7("foreignObject", { x: finalX, y: finalY, width, height, children: /* @__PURE__ */ jsx7(
1298
+ "video",
1299
+ {
1300
+ ref: videoRef,
1301
+ src,
1302
+ poster: posterSrc,
1303
+ muted: true,
1304
+ playsInline: true,
1305
+ preload: "auto",
1306
+ "data-clip-start": content.clipStart,
1307
+ "data-clip-end": content.clipEnd,
1308
+ "data-start-at": startAt,
1309
+ style: {
1310
+ width: `${width}px`,
1311
+ height: `${height}px`,
1312
+ objectFit: content.fit || "cover",
1313
+ objectPosition: "center",
1314
+ display: "block",
1315
+ pointerEvents: "none"
1316
+ }
1317
+ }
1318
+ ) }) });
1319
+ }
1320
+
1321
+ // src/layers/TableLayer.tsx
1322
+ import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
1323
+ function TableLayer({ layer, viewport, blockTime }) {
1324
+ const { content, position, animation } = layer;
1325
+ const { headers, rows, align, style } = content;
1326
+ const x = resolveValue(position.x, viewport.width);
1327
+ const y = resolveValue(position.y, viewport.height);
1328
+ const width = position.width ? resolveValue(position.width, viewport.width) : viewport.width;
1329
+ const height = position.height ? resolveValue(position.height, viewport.height) : viewport.height;
1330
+ const offset = getAnchorOffset(position.anchor, width, height);
1331
+ const finalX = x + offset.x;
1332
+ const finalY = y + offset.y;
1333
+ const animStyle = getAnimationStyle(animation, blockTime);
1334
+ const cellAlign = (ci) => {
1335
+ const a = align?.[ci];
1336
+ return a ? { textAlign: a } : void 0;
1337
+ };
1338
+ const borderRadius = style.borderRadius ?? 8;
1339
+ return /* @__PURE__ */ jsx8(
1340
+ "g",
1341
+ {
1342
+ className: `block-layer block-layer--table ${animStyle.className}`,
1343
+ style: animStyle.style,
1344
+ "data-layer-id": layer.id,
1345
+ children: /* @__PURE__ */ jsx8("foreignObject", { x: finalX, y: finalY, width, height, children: /* @__PURE__ */ jsx8(
1346
+ "div",
1347
+ {
1348
+ ...{ xmlns: "http://www.w3.org/1999/xhtml" },
1349
+ style: {
1350
+ width: `${width}px`,
1351
+ height: `${height}px`,
1352
+ display: "flex",
1353
+ alignItems: "center",
1354
+ justifyContent: "center",
1355
+ padding: "16px",
1356
+ boxSizing: "border-box"
1357
+ },
1358
+ children: /* @__PURE__ */ jsxs6(
1359
+ "table",
1360
+ {
1361
+ style: {
1362
+ width: "100%",
1363
+ borderCollapse: "separate",
1364
+ borderSpacing: 0,
1365
+ fontSize: `${style.fontSize}px`,
1366
+ fontFamily: style.fontFamily ?? "system-ui, sans-serif",
1367
+ overflow: "hidden",
1368
+ borderRadius: `${borderRadius}px`,
1369
+ border: `1px solid ${style.borderColor}`
1370
+ },
1371
+ children: [
1372
+ headers.length > 0 && /* @__PURE__ */ jsx8("thead", { children: /* @__PURE__ */ jsx8("tr", { children: headers.map((header, ci) => /* @__PURE__ */ jsx8(
1373
+ "th",
1374
+ {
1375
+ style: {
1376
+ background: style.headerBackground,
1377
+ color: style.headerColor,
1378
+ fontFamily: style.headerFontFamily ?? style.fontFamily ?? "system-ui, sans-serif",
1379
+ fontWeight: 600,
1380
+ padding: "12px 16px",
1381
+ borderBottom: `2px solid ${style.borderColor}`,
1382
+ borderRight: ci < headers.length - 1 ? `1px solid ${style.borderColor}` : void 0,
1383
+ ...cellAlign(ci)
1384
+ },
1385
+ children: header
1386
+ },
1387
+ ci
1388
+ )) }) }),
1389
+ rows.length > 0 && /* @__PURE__ */ jsx8("tbody", { children: rows.map((row, ri) => /* @__PURE__ */ jsx8("tr", { children: row.map((cell, ci) => /* @__PURE__ */ jsx8(
1390
+ "td",
1391
+ {
1392
+ style: {
1393
+ background: style.cellBackground,
1394
+ color: style.cellColor,
1395
+ padding: "10px 16px",
1396
+ borderBottom: ri < rows.length - 1 ? `1px solid ${style.borderColor}` : void 0,
1397
+ borderRight: ci < row.length - 1 ? `1px solid ${style.borderColor}` : void 0,
1398
+ ...cellAlign(ci)
1399
+ },
1400
+ children: cell
1401
+ },
1402
+ ci
1403
+ )) }, ri)) })
1404
+ ]
1405
+ }
1406
+ )
1407
+ }
1408
+ ) })
1409
+ }
1410
+ );
1411
+ }
1412
+
1413
+ // src/layers/TreeLayer.tsx
1414
+ import { useState as useState2 } from "react";
1415
+ import { jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
1416
+ function faClass(token, fallback) {
1417
+ const name = token && token.trim() ? token.trim() : fallback;
1418
+ const colon = name.indexOf(":");
1419
+ if (colon > 0) {
1420
+ const family = name.slice(0, colon).replace(/^fa-/, "");
1421
+ return `fa-${family} fa-${name.slice(colon + 1)}`;
1422
+ }
1423
+ return `fa-solid fa-${name}`;
1424
+ }
1425
+ function TreeLayer({ layer, viewport, blockTime }) {
1426
+ const { content, position, animation } = layer;
1427
+ const { items, style } = content;
1428
+ const x = resolveValue(position.x, viewport.width);
1429
+ const y = resolveValue(position.y, viewport.height);
1430
+ const width = position.width ? resolveValue(position.width, viewport.width) : viewport.width;
1431
+ const height = position.height ? resolveValue(position.height, viewport.height) : viewport.height;
1432
+ const offset = getAnchorOffset(position.anchor, width, height);
1433
+ const animStyle = getAnimationStyle(animation, blockTime);
1434
+ return /* @__PURE__ */ jsx9(
1435
+ "g",
1436
+ {
1437
+ className: `block-layer block-layer--tree ${animStyle.className}`,
1438
+ style: animStyle.style,
1439
+ "data-layer-id": layer.id,
1440
+ children: /* @__PURE__ */ jsx9("foreignObject", { x: x + offset.x, y: y + offset.y, width, height, children: /* @__PURE__ */ jsx9(
1441
+ "div",
1442
+ {
1443
+ ...{ xmlns: "http://www.w3.org/1999/xhtml" },
1444
+ className: "squisq-treelayer",
1445
+ style: {
1446
+ width: `${width}px`,
1447
+ height: `${height}px`,
1448
+ display: "flex",
1449
+ flexDirection: "column",
1450
+ justifyContent: "center",
1451
+ padding: "24px 32px",
1452
+ boxSizing: "border-box",
1453
+ fontFamily: style.fontFamily ?? "system-ui, sans-serif",
1454
+ fontSize: `${style.fontSize}px`,
1455
+ lineHeight: 1.7,
1456
+ overflow: "hidden"
1457
+ },
1458
+ children: /* @__PURE__ */ jsx9(TreeList, { items, depth: 0, style })
1459
+ }
1460
+ ) })
1461
+ }
1462
+ );
1463
+ }
1464
+ function TreeList({
1465
+ items,
1466
+ depth,
1467
+ style
1468
+ }) {
1469
+ return /* @__PURE__ */ jsx9(
1470
+ "ul",
1471
+ {
1472
+ style: {
1473
+ listStyle: "none",
1474
+ margin: 0,
1475
+ padding: 0,
1476
+ paddingLeft: depth === 0 ? 0 : `${style.indentPx}px`,
1477
+ borderLeft: depth === 0 ? "none" : `1px solid ${style.connectorColor}`
1478
+ },
1479
+ children: items.map((item) => /* @__PURE__ */ jsx9(TreeRow, { item, style }, item.id))
1480
+ }
1481
+ );
1482
+ }
1483
+ function TreeRow({
1484
+ item,
1485
+ style
1486
+ }) {
1487
+ const hasChildren = item.children.length > 0;
1488
+ const [collapsed, setCollapsed] = useState2(false);
1489
+ const isDir = item.isDir || hasChildren;
1490
+ const iconCls = isDir ? faClass(style.folderIcon, collapsed ? "folder" : "folder-open") : faClass(style.fileIcon, "file");
1491
+ return /* @__PURE__ */ jsxs7("li", { style: { position: "relative" }, children: [
1492
+ /* @__PURE__ */ jsxs7("div", { style: { display: "flex", alignItems: "baseline", gap: "8px", padding: "1px 0" }, children: [
1493
+ hasChildren ? /* @__PURE__ */ jsx9(
1494
+ "button",
1495
+ {
1496
+ type: "button",
1497
+ "aria-label": collapsed ? "Expand" : "Collapse",
1498
+ onClick: () => setCollapsed((c) => !c),
1499
+ style: {
1500
+ flex: "0 0 auto",
1501
+ width: "1em",
1502
+ border: "none",
1503
+ background: "transparent",
1504
+ cursor: "pointer",
1505
+ color: style.connectorColor,
1506
+ padding: 0,
1507
+ fontSize: "0.8em"
1508
+ },
1509
+ children: /* @__PURE__ */ jsx9(
1510
+ "i",
1511
+ {
1512
+ className: `fa-solid ${collapsed ? "fa-chevron-right" : "fa-chevron-down"}`,
1513
+ "aria-hidden": "true"
1514
+ }
1515
+ )
1516
+ }
1517
+ ) : /* @__PURE__ */ jsx9("span", { style: { flex: "0 0 auto", width: "1em" } }),
1518
+ /* @__PURE__ */ jsx9(
1519
+ "i",
1520
+ {
1521
+ className: iconCls,
1522
+ "aria-hidden": "true",
1523
+ style: { flex: "0 0 auto", color: style.iconColor, width: "1.2em", textAlign: "center" }
1524
+ }
1525
+ ),
1526
+ /* @__PURE__ */ jsx9(
1527
+ "span",
1528
+ {
1529
+ style: { color: isDir ? style.dirColor : style.rowColor, fontWeight: isDir ? 600 : 400 },
1530
+ children: item.label
1531
+ }
1532
+ ),
1533
+ item.comment ? /* @__PURE__ */ jsx9("span", { style: { color: style.commentColor, fontSize: "0.85em", fontStyle: "italic" }, children: item.comment }) : null
1534
+ ] }),
1535
+ hasChildren && !collapsed ? /* @__PURE__ */ jsx9(TreeList, { items: item.children, depth: 1, style }) : null
1536
+ ] });
1537
+ }
1538
+
1539
+ // src/layers/MermaidLayer.tsx
1540
+ import { jsx as jsx10 } from "react/jsx-runtime";
1541
+ function MermaidLayer({ layer, viewport, blockTime, theme }) {
1542
+ const { position, content, animation } = layer;
1543
+ const width = resolveValue(position.width ?? viewport.width, viewport.width);
1544
+ const height = resolveValue(position.height ?? viewport.height, viewport.height);
1545
+ let x = resolveValue(position.x, viewport.width);
1546
+ let y = resolveValue(position.y, viewport.height);
1547
+ const anchor = position.anchor ?? "top-left";
1548
+ if (anchor === "center") {
1549
+ x -= width / 2;
1550
+ y -= height / 2;
1551
+ } else {
1552
+ if (anchor.endsWith("right")) x -= width;
1553
+ if (anchor.startsWith("bottom")) y -= height;
1554
+ }
1555
+ const animStyle = getAnimationStyle(animation, blockTime);
1556
+ const panelStyle = {
1557
+ boxSizing: "border-box",
1558
+ width: "100%",
1559
+ height: "100%",
1560
+ padding: content.padding ?? 16,
1561
+ borderRadius: 16,
1562
+ background: content.background,
1563
+ color: content.foreground,
1564
+ overflow: "hidden"
1565
+ };
1566
+ return /* @__PURE__ */ jsx10(
1567
+ "g",
1568
+ {
1569
+ className: `block-layer block-layer--mermaid ${animStyle.className}`,
1570
+ "data-layer-id": layer.id,
1571
+ style: animStyle.style,
1572
+ children: /* @__PURE__ */ jsx10("foreignObject", { x, y, width, height, children: /* @__PURE__ */ jsx10(
1573
+ "div",
1574
+ {
1575
+ ...{ xmlns: "http://www.w3.org/1999/xhtml" },
1576
+ className: "squisq-mermaid-layer-frame",
1577
+ style: panelStyle,
1578
+ children: /* @__PURE__ */ jsx10(
1579
+ MermaidDiagram,
1580
+ {
1581
+ source: content.source,
1582
+ ariaLabel: "Mermaid diagram on slide",
1583
+ theme
1584
+ }
1585
+ )
1586
+ }
1587
+ ) })
1588
+ }
1589
+ );
1590
+ }
1591
+
1592
+ export {
1593
+ getAnimationStyle,
1594
+ getTransitionClass,
1595
+ ImageLayer,
1596
+ TextLayer,
1597
+ ShapeLayer,
1598
+ PathLayer,
1599
+ MapLayer,
1600
+ VideoLayer,
1601
+ TableLayer,
1602
+ TreeLayer,
1603
+ MermaidLayer
1604
+ };