@panelwave/player 1.3.0 → 1.6.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.
@@ -2191,6 +2191,7 @@ const DEFAULT_BALLOON_CONFIG = {
2191
2191
  cornerRadius: 0.5,
2192
2192
  maxWidth: 120,
2193
2193
  maxHeight: 80,
2194
+ textFit: 'grow',
2194
2195
  fontFamily: "'Ames Italic', sans-serif",
2195
2196
  fontSize: 12,
2196
2197
  strokeWidth: 2,
@@ -2238,6 +2239,7 @@ function balloonConfigToRenderOptions(config) {
2238
2239
  return {
2239
2240
  maxWidth: config.maxWidth,
2240
2241
  maxHeight: config.maxHeight,
2242
+ textFit: config.textFit === 'fixed' ? 'fixed' : 'grow',
2241
2243
  cornerRadius: type === 'rectangle' || type === 'narrator' ? 0 : config.cornerRadius,
2242
2244
  sharpCorners: type === 'narrator',
2243
2245
  isThought: type === 'thought',
@@ -2363,6 +2365,82 @@ function tailTipToTailConfig(bounds, tipX, tipY, cornerRadius) {
2363
2365
  const length = Math.round(Math.max(0, tipDist - edgeDist));
2364
2366
  return { position, length };
2365
2367
  }
2368
+ /**
2369
+ * Edges the authored box touches (within `eps`) on the panel, plus the edges
2370
+ * a caption type is glued to by definition: the cut Panel-Top variants always
2371
+ * keep the top (the cut edge IS the panel border), the corner variants their side.
2372
+ */
2373
+ function grownBodyGlue(authored, panel, balloonType, eps) {
2374
+ const glue = {};
2375
+ if (panel) {
2376
+ const e = eps ?? Math.max(4, 0.02 * Math.min(panel.width, panel.height));
2377
+ if (authored.x <= panel.x + e)
2378
+ glue.left = true;
2379
+ else if (authored.x + authored.width >= panel.x + panel.width - e)
2380
+ glue.right = true;
2381
+ if (authored.y <= panel.y + e)
2382
+ glue.top = true;
2383
+ else if (authored.y + authored.height >= panel.y + panel.height - e)
2384
+ glue.bottom = true;
2385
+ }
2386
+ if (balloonType === 'cutTop' || balloonType === 'cutTopLeft' || balloonType === 'cutTopRight') {
2387
+ glue.top = true;
2388
+ glue.bottom = false;
2389
+ if (balloonType === 'cutTopLeft') {
2390
+ glue.left = true;
2391
+ glue.right = false;
2392
+ }
2393
+ if (balloonType === 'cutTopRight') {
2394
+ glue.right = true;
2395
+ glue.left = false;
2396
+ }
2397
+ }
2398
+ return glue;
2399
+ }
2400
+ /**
2401
+ * Rect of a grown body (`grown` size) so that
2402
+ * 1. a tail balloon keeps its tail tip where the authored box put it (the
2403
+ * body moves away from the speaker, the tail keeps angle and length);
2404
+ * 2. glued edges stay on the panel border (overrides 1 on that axis);
2405
+ * 3. a tailless, unglued balloon keeps its centre;
2406
+ * 4. the body stays inside `panel` when one is given (centred on an axis it
2407
+ * cannot fit) — this is the only rule that may move the tail tip.
2408
+ * Returns the authored rect itself when the size did not change.
2409
+ */
2410
+ function placeGrownBody(authored, grown, tail, cornerRadius, panel, glue = {}) {
2411
+ if (Math.abs(grown.width - authored.width) < 0.5 && Math.abs(grown.height - authored.height) < 0.5) {
2412
+ return { ...authored };
2413
+ }
2414
+ const cx = authored.x + authored.width / 2;
2415
+ const cy = authored.y + authored.height / 2;
2416
+ let x = cx - grown.width / 2;
2417
+ let y = cy - grown.height / 2;
2418
+ if (tail) {
2419
+ const tipAuthored = tailConfigToTip({ cx, cy, width: authored.width, height: authored.height }, tail, cornerRadius);
2420
+ const tipGrown = tailConfigToTip({ cx, cy, width: grown.width, height: grown.height }, tail, cornerRadius);
2421
+ x += tipAuthored.x - tipGrown.x;
2422
+ y += tipAuthored.y - tipGrown.y;
2423
+ }
2424
+ if (glue.left)
2425
+ x = authored.x;
2426
+ else if (glue.right)
2427
+ x = authored.x + authored.width - grown.width;
2428
+ if (glue.top)
2429
+ y = authored.y;
2430
+ else if (glue.bottom)
2431
+ y = authored.y + authored.height - grown.height;
2432
+ if (panel) {
2433
+ if (grown.width >= panel.width)
2434
+ x = panel.x + (panel.width - grown.width) / 2;
2435
+ else
2436
+ x = Math.min(panel.x + panel.width - grown.width, Math.max(panel.x, x));
2437
+ if (grown.height >= panel.height)
2438
+ y = panel.y + (panel.height - grown.height) / 2;
2439
+ else
2440
+ y = Math.min(panel.y + panel.height - grown.height, Math.max(panel.y, y));
2441
+ }
2442
+ return { x, y, width: grown.width, height: grown.height };
2443
+ }
2366
2444
 
2367
2445
  // SYNCED FROM panelwave-cms/apps/cms-frontend/src/app/core/utils/comic-balloon.ts — DO NOT EDIT HERE.
2368
2446
  // Edit the CMS copy, then run in panelwave-cms: npm run sync:balloon
@@ -2375,6 +2453,17 @@ function tailTipToTailConfig(bounds, tipX, tipY, cornerRadius) {
2375
2453
  * reader/preview render balloons exactly like the editor — edit here, then
2376
2454
  * run `npm run sync:balloon` in panelwave-cms.
2377
2455
  */
2456
+ /**
2457
+ * Largest proportional growth factor per dimension (2 = each side may double).
2458
+ * Text that still does not fit at this width is given its natural height.
2459
+ */
2460
+ const BALLOON_GROWTH_MAX = 2;
2461
+ /**
2462
+ * Safety margin on the measured text height when deciding whether a grown
2463
+ * body fits: the editor canvas wraps text with Canvas2D `measureText`, the
2464
+ * engine with DOM layout, and the two can differ by a hair at a line break.
2465
+ */
2466
+ const GROWTH_SLACK = 1.02;
2378
2467
  class ComicBalloon {
2379
2468
  constructor(container, options = {}) {
2380
2469
  this.svg = null;
@@ -2391,6 +2480,7 @@ class ComicBalloon {
2391
2480
  // maxHeight (default 0). Only a missing/non-finite value falls back to 140.
2392
2481
  maxWidth: typeof options.maxWidth === 'number' && Number.isFinite(options.maxWidth) ? options.maxWidth : 140,
2393
2482
  maxHeight: options.maxHeight || 0,
2483
+ textFit: options.textFit === 'fixed' ? 'fixed' : 'grow',
2394
2484
  padding: options.padding || { top: 14, right: 18, bottom: 14, left: 18 },
2395
2485
  strokeWidth: options.strokeWidth || 2,
2396
2486
  strokeColor: options.strokeColor || '#000',
@@ -2424,9 +2514,7 @@ class ComicBalloon {
2424
2514
  this.svg.setAttribute('class', 'comic-balloon-svg');
2425
2515
  // Use the same padding as the canvas drawBalloonText (padding = 14 on each side)
2426
2516
  const textPadding = 14;
2427
- const availableTextWidth = this.options.maxWidth > 0
2428
- ? this.options.maxWidth - textPadding * 2
2429
- : 140 - textPadding * 2;
2517
+ const availableWidthFor = (bodyWidth) => bodyWidth > 0 ? bodyWidth - textPadding * 2 : 140 - textPadding * 2;
2430
2518
  // Text styles shared by the measuring element and the rendered text, so
2431
2519
  // both wrap identically. Everything that inherits is pinned: the rendered
2432
2520
  // text inherits from the host page (player shell, editor), the measuring
@@ -2444,30 +2532,81 @@ class ComicBalloon {
2444
2532
  white-space: pre-wrap;
2445
2533
  word-wrap: break-word;
2446
2534
  `;
2447
- // Create a temporary text element to measure
2535
+ // Create a temporary text element to measure (re-used for every probe of
2536
+ // the growth search below, so one DOM node serves all measurements).
2448
2537
  const tempDiv = document.createElement('div');
2449
2538
  tempDiv.style.cssText = `
2450
2539
  position: absolute;
2451
2540
  visibility: hidden;
2452
- width: ${availableTextWidth}px;
2541
+ width: ${availableWidthFor(this.options.maxWidth)}px;
2453
2542
  ${textStyle}
2454
2543
  `;
2455
2544
  tempDiv.innerHTML = text;
2456
2545
  document.body.appendChild(tempDiv);
2457
- // Measure text
2458
- const textWidth = Math.min(tempDiv.scrollWidth, availableTextWidth);
2459
- const textHeight = tempDiv.scrollHeight;
2460
- document.body.removeChild(tempDiv);
2461
- // Calculate balloon dimensions
2462
2546
  let extraPad = 0;
2463
2547
  if (this.options.isShout)
2464
2548
  extraPad = Math.round(this.options.fontSize * 0.4);
2465
2549
  if (this.options.isThought)
2466
2550
  extraPad = Math.round(this.options.fontSize * 0.4);
2467
- const naturalWidth = textWidth + this.options.padding.left + this.options.padding.right + extraPad * 2;
2468
- const naturalHeight = textHeight + this.options.padding.top + this.options.padding.bottom + extraPad * 2;
2469
- const balloonWidth = this.options.maxWidth > 0 ? this.options.maxWidth : naturalWidth;
2470
- const balloonHeight = this.options.maxHeight > 0 ? this.options.maxHeight : naturalHeight;
2551
+ const padW = this.options.padding.left + this.options.padding.right + extraPad * 2;
2552
+ const padH = this.options.padding.top + this.options.padding.bottom + extraPad * 2;
2553
+ /** Text block size when wrapped inside a body of the given width. */
2554
+ const measureAt = (bodyWidth) => {
2555
+ const available = availableWidthFor(bodyWidth);
2556
+ tempDiv.style.width = `${available}px`;
2557
+ return { textWidth: Math.min(tempDiv.scrollWidth, available), textHeight: tempDiv.scrollHeight };
2558
+ };
2559
+ /** Body height the text needs inside a body of the given width. */
2560
+ const naturalHeightAt = (bodyWidth) => measureAt(bodyWidth).textHeight + padH;
2561
+ // Measure at the authored width
2562
+ let { textWidth, textHeight } = measureAt(this.options.maxWidth);
2563
+ // Calculate balloon dimensions
2564
+ const naturalWidth = textWidth + padW;
2565
+ let naturalHeight = textHeight + padH;
2566
+ const authoredWidth = this.options.maxWidth > 0 ? this.options.maxWidth : naturalWidth;
2567
+ const authoredHeight = this.options.maxHeight > 0 ? this.options.maxHeight : naturalHeight;
2568
+ let balloonWidth = authoredWidth;
2569
+ let balloonHeight = authoredHeight;
2570
+ let growth = 1;
2571
+ // Auto-grow: the authored size is the floor. When the text needs more
2572
+ // room, grow proportionally (the shape keeps its aspect ratio; a wider
2573
+ // body takes fewer lines) up to BALLOON_GROWTH_MAX, else keep that width
2574
+ // and take the natural height — the text always ends up inside the body.
2575
+ if (this.options.textFit !== 'fixed'
2576
+ && this.options.maxWidth > 0 && this.options.maxHeight > 0
2577
+ && naturalHeight * GROWTH_SLACK > authoredHeight + 0.5) {
2578
+ // Ovals are narrower at the top and bottom lines than at the middle,
2579
+ // so a grown oval gets half a line height of vertical room (a quarter
2580
+ // line above and below the block); rectangles need none. Growth is
2581
+ // proportional, so this also widens the body a little.
2582
+ const isOval = this.options.isThought || this.options.isShout || this.options.cornerRadius > 0;
2583
+ const ovalRoom = isOval ? 0.5 * this.options.fontSize * this.options.lineHeight : 0;
2584
+ const fits = (s) => naturalHeightAt(authoredWidth * s) * GROWTH_SLACK + ovalRoom <= authoredHeight * s + 0.5;
2585
+ if (fits(BALLOON_GROWTH_MAX)) {
2586
+ // Binary search for the smallest factor that fits (monotone: wider = fewer lines).
2587
+ let lo = 1;
2588
+ let hi = BALLOON_GROWTH_MAX;
2589
+ for (let i = 0; i < 6; i++) {
2590
+ const mid = (lo + hi) / 2;
2591
+ if (fits(mid))
2592
+ hi = mid;
2593
+ else
2594
+ lo = mid;
2595
+ }
2596
+ growth = hi;
2597
+ balloonWidth = Math.ceil(authoredWidth * growth);
2598
+ balloonHeight = Math.ceil(authoredHeight * growth);
2599
+ }
2600
+ else {
2601
+ growth = BALLOON_GROWTH_MAX;
2602
+ balloonWidth = Math.ceil(authoredWidth * growth);
2603
+ balloonHeight = Math.ceil(naturalHeightAt(balloonWidth) * GROWTH_SLACK + ovalRoom);
2604
+ }
2605
+ ({ textWidth, textHeight } = measureAt(balloonWidth));
2606
+ naturalHeight = textHeight + padH;
2607
+ }
2608
+ document.body.removeChild(tempDiv);
2609
+ const grown = balloonWidth > authoredWidth + 0.5 || balloonHeight > authoredHeight + 0.5;
2471
2610
  const cx = balloonWidth / 2;
2472
2611
  const cy = balloonHeight / 2;
2473
2612
  const rx = balloonWidth / 2;
@@ -2644,6 +2783,10 @@ class ComicBalloon {
2644
2783
  return {
2645
2784
  width: balloonWidth,
2646
2785
  height: balloonHeight,
2786
+ authoredWidth,
2787
+ authoredHeight,
2788
+ grown,
2789
+ growth,
2647
2790
  textOverflows: this.options.maxHeight > 0 && naturalHeight > balloonHeight + 0.5,
2648
2791
  svg: this.svg,
2649
2792
  balloon: this,
@@ -5428,10 +5571,10 @@ class AudioEngineService {
5428
5571
  }
5429
5572
  }
5430
5573
  /**
5431
- * Play audio track
5574
+ * Play audio track, optionally from `offsetSeconds` into the file.
5432
5575
  */
5433
- async play(track) {
5434
- await this.startTrack(track, 0);
5576
+ async play(track, offsetSeconds = 0) {
5577
+ await this.startTrack(track, offsetSeconds);
5435
5578
  }
5436
5579
  /**
5437
5580
  * Stop audio track
@@ -5719,6 +5862,10 @@ class AudioEngineService {
5719
5862
  // Create audio element
5720
5863
  const audio = new Audio(track.url);
5721
5864
  audio.loop = track.loop || false;
5865
+ const rate = typeof track.playbackRate === 'number' && isFinite(track.playbackRate) ? Math.max(0.25, Math.min(4, track.playbackRate)) : 1;
5866
+ if (rate !== 1) {
5867
+ audio.playbackRate = rate;
5868
+ }
5722
5869
  if (offsetSeconds > 0) {
5723
5870
  audio.currentTime = offsetSeconds;
5724
5871
  }
@@ -5741,9 +5888,11 @@ class AudioEngineService {
5741
5888
  audio.addEventListener('ended', active.onEnded);
5742
5889
  }
5743
5890
  try {
5744
- // Apply fade in (shortened when starting mid-fade)
5745
- if (track.fadeIn && track.fadeIn > 0 && offsetSeconds * 1000 < track.fadeIn) {
5746
- await this.fadeIn(track.id, track.fadeIn - offsetSeconds * 1000, volume);
5891
+ // Fade in (shortened when starting mid-fade). The ramp runs while the
5892
+ // element plays — awaiting it first delayed the start by the fade.
5893
+ const fadeMs = track.fadeIn && track.fadeIn > 0 ? track.fadeIn - offsetSeconds * 1000 : 0;
5894
+ if (fadeMs > 0) {
5895
+ void this.fadeIn(track.id, fadeMs, volume);
5747
5896
  }
5748
5897
  // Play audio
5749
5898
  await audio.play();
@@ -5897,6 +6046,381 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
5897
6046
  }]
5898
6047
  }], ctorParameters: () => [] });
5899
6048
 
6049
+ /**
6050
+ * Audio asset resolution shared by the panel audio and timeline (sequence)
6051
+ * audio services: catalog id → the first variant the browser can play →
6052
+ * absolute URL, plus the asset's own role / loop hints.
6053
+ */
6054
+ /**
6055
+ * Resolve an audio asset; `{ error }` explains why it cannot be played (not
6056
+ * in the catalog, not audio, no variants, no URL).
6057
+ */
6058
+ function resolveAudioAsset(asset, assetsBase) {
6059
+ if (!asset) {
6060
+ return { error: 'is not in the asset catalog' };
6061
+ }
6062
+ if (asset.category !== 'audio') {
6063
+ return { error: 'is not an audio asset' };
6064
+ }
6065
+ const audioAsset = asset;
6066
+ const variant = pickPlayableVariant(audioAsset.variants ?? []);
6067
+ if (!variant) {
6068
+ return { error: 'has no audio variants' };
6069
+ }
6070
+ const url = resolveAssetUrl(variant.src, assetsBase, 'audio');
6071
+ if (!url) {
6072
+ return { error: 'has no usable URL' };
6073
+ }
6074
+ return { url, role: audioAsset.role, loop: variant.loop };
6075
+ }
6076
+ /**
6077
+ * First variant the browser reports it can play; otherwise the first one
6078
+ * (the element will surface the real error).
6079
+ */
6080
+ function pickPlayableVariant(variants) {
6081
+ if (variants.length <= 1) {
6082
+ return variants[0];
6083
+ }
6084
+ const probe = createProbe();
6085
+ if (!probe) {
6086
+ return variants[0];
6087
+ }
6088
+ const playable = variants.find((variant) => {
6089
+ const mime = variant.mime || guessMimeType(variant.src);
6090
+ return mime ? probe.canPlayType(mime) !== '' : false;
6091
+ });
6092
+ return playable ?? variants[0];
6093
+ }
6094
+ function createProbe() {
6095
+ if (typeof document === 'undefined') {
6096
+ return null;
6097
+ }
6098
+ try {
6099
+ const element = document.createElement('audio');
6100
+ return typeof element.canPlayType === 'function' ? element : null;
6101
+ }
6102
+ catch {
6103
+ return null;
6104
+ }
6105
+ }
6106
+
6107
+ /**
6108
+ * Timeline (sequence) audio placement for the reader — the same model as the
6109
+ * CMS editor's timeline: the work is one line of panels (chapters in order →
6110
+ * the active format's pages → reading order), each panel lasting its
6111
+ * `durationMs` (else the work's seconds-per-panel, else 5 s).
6112
+ *
6113
+ * A chapter's `sequenceAudioTracks` sit on that line relative to their
6114
+ * chapter's start. `span` (format 1.8.0): 'page' / 'chapter' tracks cover
6115
+ * their startPanelId..endPanelId range; a 'work' track keeps playing across
6116
+ * chapter boundaries until endPanelId (else the work's last panel); every
6117
+ * other track ends with its chapter.
6118
+ *
6119
+ * The reader does not follow a clock, so the timeline is used per panel:
6120
+ * entering a panel tells which tracks cover it, from which offset, and
6121
+ * whether one starts or ends inside it (`cuesForPanel`).
6122
+ */
6123
+ const DEFAULT_PANEL_MS = 5000;
6124
+ /** Panels of a page in reading order (placement order without one). */
6125
+ function pagePanels(page) {
6126
+ if (page.readingOrder?.length) {
6127
+ return page.readingOrder;
6128
+ }
6129
+ return (page.layout?.placements ?? []).map((placement) => placement.panelId);
6130
+ }
6131
+ /** The work's panel line for a page format (null = every page). */
6132
+ function buildWorkTimeline(manifest, format, defaultPanelMs = DEFAULT_PANEL_MS) {
6133
+ const entries = [];
6134
+ const byPanel = new Map();
6135
+ const chapters = new Map();
6136
+ let t = 0;
6137
+ for (const chapter of (manifest?.chapters ?? [])) {
6138
+ const pages = pagesForFormat(chapter.pages, format);
6139
+ const order = pages.length ? pages.flatMap(pagePanels) : chapterReadingOrder(chapter);
6140
+ const chapterStart = t;
6141
+ for (const panelId of order) {
6142
+ const panel = chapter.panels?.[panelId];
6143
+ if (!panel || byPanel.has(panelId)) {
6144
+ continue;
6145
+ }
6146
+ const ms = typeof panel.durationMs === 'number' && panel.durationMs > 0 ? panel.durationMs : defaultPanelMs;
6147
+ const entry = { panelId, chapterId: chapter.id, startMs: t, endMs: t + ms };
6148
+ entries.push(entry);
6149
+ byPanel.set(panelId, entry);
6150
+ t += ms;
6151
+ }
6152
+ if (t > chapterStart) {
6153
+ chapters.set(chapter.id, { startMs: chapterStart, endMs: t });
6154
+ }
6155
+ }
6156
+ return { entries, byPanel, chapters, endMs: t };
6157
+ }
6158
+ /** Where each (format-matching, unmuted) timeline track sits on the work line. */
6159
+ function resolveTrackRanges(manifest, timeline, format) {
6160
+ const ranges = [];
6161
+ for (const chapter of (manifest?.chapters ?? [])) {
6162
+ const extent = timeline.chapters.get(chapter.id);
6163
+ if (!extent) {
6164
+ continue; // no panels of this chapter in this format
6165
+ }
6166
+ for (const track of chapter.sequenceAudioTracks ?? []) {
6167
+ if (!track?.assetId || track.muted) {
6168
+ continue;
6169
+ }
6170
+ if (format && track.format && track.format !== format) {
6171
+ continue;
6172
+ }
6173
+ const span = track.span ?? 'custom';
6174
+ const first = track.startPanelId ? timeline.byPanel.get(track.startPanelId) : undefined;
6175
+ const last = track.endPanelId ? timeline.byPanel.get(track.endPanelId) : undefined;
6176
+ let startMs;
6177
+ let endMs;
6178
+ if ((span === 'page' || span === 'chapter') && first && last) {
6179
+ startMs = first.startMs;
6180
+ endMs = last.endMs;
6181
+ }
6182
+ else {
6183
+ startMs = extent.startMs + Math.max(0, Number(track.startTime) || 0);
6184
+ const duration = Math.max(0, Number(track.duration) || 0);
6185
+ endMs = duration > 0 ? startMs + duration : extent.endMs;
6186
+ }
6187
+ if (span === 'work') {
6188
+ endMs = last ? last.endMs : timeline.endMs;
6189
+ }
6190
+ else {
6191
+ endMs = Math.min(endMs, extent.endMs);
6192
+ }
6193
+ if (endMs > startMs) {
6194
+ ranges.push({ track, chapterId: chapter.id, startMs, endMs });
6195
+ }
6196
+ }
6197
+ }
6198
+ return ranges;
6199
+ }
6200
+ /** The tracks that sound while the reader is on `entry`'s panel. */
6201
+ function cuesForPanel(ranges, entry) {
6202
+ const cues = [];
6203
+ for (const range of ranges) {
6204
+ if (range.startMs >= entry.endMs || range.endMs <= entry.startMs) {
6205
+ continue;
6206
+ }
6207
+ const cue = {
6208
+ range,
6209
+ delayMs: Math.max(0, range.startMs - entry.startMs),
6210
+ offsetMs: Math.max(0, entry.startMs - range.startMs),
6211
+ };
6212
+ if (range.endMs < entry.endMs) {
6213
+ cue.stopAfterMs = range.endMs - entry.startMs;
6214
+ }
6215
+ cues.push(cue);
6216
+ }
6217
+ return cues;
6218
+ }
6219
+
6220
+ /**
6221
+ * Sequence Audio Service
6222
+ *
6223
+ * Plays a work's timeline audio (`chapters[].sequenceAudioTracks` — music,
6224
+ * ambience, voiceover beds) while the reader moves through the panels:
6225
+ *
6226
+ * - entering a panel starts every track that covers it, at the right offset
6227
+ * (a jump into the middle of a scene picks the music up where it is);
6228
+ * - a track keeps playing while the reader stays inside its range, so
6229
+ * music does not restart on every panel; leaving the range fades it out
6230
+ * (its `fadeOut`, else 250 ms);
6231
+ * - a track that starts or ends inside a panel is delayed / stopped there;
6232
+ * - `span: 'work'` tracks continue across chapter boundaries.
6233
+ *
6234
+ * Placement follows the CMS timeline (see sequence-audio-timeline.ts). Roles
6235
+ * map onto the engine buses, so the reader's Audio / SFX / Speech toggles
6236
+ * apply. Tracks refused by the autoplay policy are retried on the first
6237
+ * user gesture.
6238
+ */
6239
+ /** Fade when a track ends because the reader left its range (unless it has its own fadeOut). */
6240
+ const SEQUENCE_AUDIO_LEAVE_FADE_MS = 250;
6241
+ /** Engine ids are prefixed so they never collide with panel audio (asset ids). */
6242
+ const sequenceEngineId = (trackId) => `seq:${trackId}`;
6243
+ class SequenceAudioService {
6244
+ constructor() {
6245
+ this.engine = inject(AudioEngineService);
6246
+ this.manifestService = inject(ManifestService);
6247
+ this.gesture = inject(UserGestureService);
6248
+ /** Tracks the current panel wants, by engine id. */
6249
+ this.desired = new Map();
6250
+ /** Refused by the autoplay policy; retried on the first gesture. */
6251
+ this.blocked = new Set();
6252
+ this.warned = new Set();
6253
+ this.gestureSub = this.gesture.userHasInteracted$.subscribe((interacted) => {
6254
+ if (interacted) {
6255
+ this.onUserGesture();
6256
+ }
6257
+ });
6258
+ }
6259
+ ngOnDestroy() {
6260
+ this.gestureSub.unsubscribe();
6261
+ this.stopAll();
6262
+ }
6263
+ /**
6264
+ * Follow the reader: call on every panel change (and when the page format
6265
+ * changes). `panelId` undefined = silence (cover, locked panel, teardown).
6266
+ */
6267
+ syncPanel(panelId, format) {
6268
+ const { timeline, ranges } = this.layout(format);
6269
+ const entry = panelId ? timeline.byPanel.get(panelId) : undefined;
6270
+ const cues = entry ? cuesForPanel(ranges, entry) : [];
6271
+ const next = new Map(cues.map((cue) => [sequenceEngineId(cue.range.track.id), cue]));
6272
+ for (const [id, playing] of this.desired) {
6273
+ if (!next.has(id)) {
6274
+ this.release(id, this.leaveFade(playing.cue));
6275
+ this.desired.delete(id);
6276
+ }
6277
+ }
6278
+ for (const [id, cue] of next) {
6279
+ const playing = this.desired.get(id);
6280
+ if (playing) {
6281
+ // Still inside its range (started, pending, blocked — or a one-shot
6282
+ // that already ended): never restart it, only re-arm an end that
6283
+ // falls inside this panel.
6284
+ playing.cue = cue;
6285
+ this.armStop(id, playing);
6286
+ continue;
6287
+ }
6288
+ this.schedule(id, cue);
6289
+ }
6290
+ }
6291
+ /** Stop everything this service started. */
6292
+ stopAll() {
6293
+ for (const id of Array.from(this.desired.keys())) {
6294
+ this.release(id, 0);
6295
+ }
6296
+ this.desired.clear();
6297
+ }
6298
+ /** Engine ids of the tracks the current panel wants (started, pending or blocked). */
6299
+ getDesiredTrackIds() {
6300
+ return Array.from(this.desired.keys());
6301
+ }
6302
+ // ---------------------------------------------------------------------------
6303
+ layout(format) {
6304
+ const manifest = this.manifestService.getManifest();
6305
+ const cache = this.layoutCache;
6306
+ if (cache && cache.manifest === manifest && cache.format === format) {
6307
+ return cache;
6308
+ }
6309
+ const seconds = Number(manifest?.settings?.ui?.secondsPerPanel);
6310
+ const defaultMs = Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : DEFAULT_PANEL_MS;
6311
+ const timeline = buildWorkTimeline(manifest, format, defaultMs);
6312
+ const ranges = resolveTrackRanges(manifest, timeline, format);
6313
+ this.layoutCache = { manifest, format, timeline, ranges };
6314
+ return this.layoutCache;
6315
+ }
6316
+ schedule(id, cue) {
6317
+ this.release(id, 0);
6318
+ const playing = { cue };
6319
+ this.desired.set(id, playing);
6320
+ if (cue.delayMs > 0) {
6321
+ playing.startTimer = setTimeout(() => {
6322
+ playing.startTimer = undefined;
6323
+ this.start(id, playing, 0);
6324
+ }, cue.delayMs);
6325
+ }
6326
+ else {
6327
+ this.start(id, playing, cue.offsetMs);
6328
+ }
6329
+ this.armStop(id, playing);
6330
+ }
6331
+ /** Stop the track inside this panel when its range ends here. */
6332
+ armStop(id, playing) {
6333
+ if (playing.stopTimer !== undefined) {
6334
+ clearTimeout(playing.stopTimer);
6335
+ playing.stopTimer = undefined;
6336
+ }
6337
+ const stopAfter = playing.cue.stopAfterMs;
6338
+ if (stopAfter === undefined) {
6339
+ return;
6340
+ }
6341
+ playing.stopTimer = setTimeout(() => {
6342
+ playing.stopTimer = undefined;
6343
+ if (this.desired.get(id) === playing) {
6344
+ this.desired.delete(id);
6345
+ this.release(id, this.leaveFade(playing.cue));
6346
+ }
6347
+ }, stopAfter);
6348
+ }
6349
+ start(id, playing, offsetMs) {
6350
+ const track = playing.cue.range.track;
6351
+ const resolved = resolveAudioAsset(this.manifestService.getAsset(track.assetId), this.manifestService.getManifest()?.assets?.base);
6352
+ if ('error' in resolved) {
6353
+ this.warnOnce(track.assetId, resolved.error);
6354
+ return;
6355
+ }
6356
+ const rate = typeof track.playbackRate === 'number' && isFinite(track.playbackRate) ? Math.max(0.25, Math.min(4, track.playbackRate)) : 1;
6357
+ this.blocked.delete(id);
6358
+ this.engine
6359
+ .play({
6360
+ id,
6361
+ url: resolved.url,
6362
+ role: track.role,
6363
+ loop: track.loop ?? resolved.loop ?? false,
6364
+ volume: typeof track.volume === 'number' && isFinite(track.volume) ? Math.max(0, Math.min(2, track.volume)) : 1,
6365
+ fadeIn: track.fadeIn,
6366
+ fadeOut: track.fadeOut,
6367
+ playbackRate: rate,
6368
+ }, (offsetMs / 1000) * rate)
6369
+ .catch((error) => {
6370
+ if (this.isAutoplayRefusal(error) && !this.gesture.hasInteracted() && this.desired.get(id) === playing) {
6371
+ this.blocked.add(id);
6372
+ }
6373
+ });
6374
+ }
6375
+ release(id, fadeMs) {
6376
+ const playing = this.desired.get(id);
6377
+ if (playing?.startTimer !== undefined) {
6378
+ clearTimeout(playing.startTimer);
6379
+ playing.startTimer = undefined;
6380
+ }
6381
+ if (playing?.stopTimer !== undefined) {
6382
+ clearTimeout(playing.stopTimer);
6383
+ playing.stopTimer = undefined;
6384
+ }
6385
+ this.blocked.delete(id);
6386
+ if (this.engine.isActive(id)) {
6387
+ void this.engine.stop(id, fadeMs);
6388
+ }
6389
+ }
6390
+ leaveFade(cue) {
6391
+ const fadeOut = cue.range.track.fadeOut;
6392
+ return typeof fadeOut === 'number' && fadeOut > 0 ? fadeOut : SEQUENCE_AUDIO_LEAVE_FADE_MS;
6393
+ }
6394
+ onUserGesture() {
6395
+ for (const id of Array.from(this.blocked)) {
6396
+ this.blocked.delete(id);
6397
+ const playing = this.desired.get(id);
6398
+ if (playing) {
6399
+ this.start(id, playing, playing.cue.offsetMs);
6400
+ }
6401
+ }
6402
+ }
6403
+ isAutoplayRefusal(error) {
6404
+ const name = error?.name;
6405
+ return name === 'NotAllowedError' || name === 'AbortError';
6406
+ }
6407
+ warnOnce(assetId, reason) {
6408
+ if (this.warned.has(assetId)) {
6409
+ return;
6410
+ }
6411
+ this.warned.add(assetId);
6412
+ console.warn(`[SequenceAudioService] audio asset "${assetId}" ${reason}; timeline track skipped.`);
6413
+ }
6414
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SequenceAudioService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
6415
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SequenceAudioService, providedIn: 'root' }); }
6416
+ }
6417
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SequenceAudioService, decorators: [{
6418
+ type: Injectable,
6419
+ args: [{
6420
+ providedIn: 'root',
6421
+ }]
6422
+ }], ctorParameters: () => [] });
6423
+
5900
6424
  /**
5901
6425
  * Panel Audio Service
5902
6426
  *
@@ -6107,61 +6631,22 @@ class PanelAudioService {
6107
6631
  return result;
6108
6632
  }
6109
6633
  resolveTrack(entry) {
6110
- const asset = this.manifestService.getAsset(entry.assetId);
6111
- if (!asset || asset.category !== 'audio') {
6112
- this.warnOnce(entry.assetId, asset ? 'is not an audio asset' : 'is not in the asset catalog');
6113
- return null;
6114
- }
6115
- const audioAsset = asset;
6116
- const variant = this.pickVariant(audioAsset.variants ?? []);
6117
- if (!variant) {
6118
- this.warnOnce(entry.assetId, 'has no audio variants');
6119
- return null;
6120
- }
6121
- const base = this.manifestService.getManifest()?.assets?.base;
6122
- const url = resolveAssetUrl(variant.src, base, 'audio');
6123
- if (!url) {
6634
+ const resolved = resolveAudioAsset(this.manifestService.getAsset(entry.assetId), this.manifestService.getManifest()?.assets?.base);
6635
+ if ('error' in resolved) {
6636
+ if (resolved.error !== 'has no usable URL') {
6637
+ this.warnOnce(entry.assetId, resolved.error);
6638
+ }
6124
6639
  return null;
6125
6640
  }
6126
6641
  return {
6127
6642
  id: entry.assetId,
6128
- url,
6129
- role: busForRole(entry.role ?? audioAsset.role),
6130
- loop: entry.loop ?? variant.loop ?? false,
6643
+ url: resolved.url,
6644
+ role: busForRole(entry.role ?? resolved.role),
6645
+ loop: entry.loop ?? resolved.loop ?? false,
6131
6646
  gain: this.clampGain(entry.gain),
6132
6647
  startAtMs: typeof entry.startAtMs === 'number' && entry.startAtMs > 0 ? entry.startAtMs : 0,
6133
6648
  };
6134
6649
  }
6135
- /**
6136
- * First variant the browser reports it can play; otherwise the first one
6137
- * (the element will surface the real error).
6138
- */
6139
- pickVariant(variants) {
6140
- if (variants.length <= 1) {
6141
- return variants[0];
6142
- }
6143
- const probe = this.createProbe();
6144
- if (!probe) {
6145
- return variants[0];
6146
- }
6147
- const playable = variants.find((variant) => {
6148
- const mime = variant.mime || guessMimeType(variant.src);
6149
- return mime ? probe.canPlayType(mime) !== '' : false;
6150
- });
6151
- return playable ?? variants[0];
6152
- }
6153
- createProbe() {
6154
- if (typeof document === 'undefined') {
6155
- return null;
6156
- }
6157
- try {
6158
- const element = document.createElement('audio');
6159
- return typeof element.canPlayType === 'function' ? element : null;
6160
- }
6161
- catch {
6162
- return null;
6163
- }
6164
- }
6165
6650
  clampGain(gain) {
6166
6651
  if (typeof gain !== 'number' || !isFinite(gain)) {
6167
6652
  return 1;
@@ -11789,6 +12274,14 @@ class SpeechBubblesComponent {
11789
12274
  * screens (phones) get smaller lettering.
11790
12275
  */
11791
12276
  this.readingScale = 1;
12277
+ /**
12278
+ * Page view: place a tailed balloon by its AUTHORED tail tip (the speaker
12279
+ * on the artwork) instead of centring its body on the stored box, and keep
12280
+ * the tail at its authored length when the MIN_LETTERING_PX floor boosts
12281
+ * the balloon — the CMS editor's page view rule (PanelRenderer), which the
12282
+ * lettered PDF must reproduce. Panel view keeps the centre placement.
12283
+ */
12284
+ this.anchorTailTip = false;
11792
12285
  /**
11793
12286
  * Work-level balloon config defaults (from settings.typography.balloon_config)
11794
12287
  */
@@ -11807,6 +12300,14 @@ class SpeechBubblesComponent {
11807
12300
  this.bubbleAudioPlay = new EventEmitter();
11808
12301
  /** Rendered balloon containers, keyed by bubble ID */
11809
12302
  this.renderedBalloons = new Map();
12303
+ /**
12304
+ * Font specs ("15px 'Ames Italic', sans-serif") whose loading this overlay
12305
+ * already requested. Web fonts load lazily on first use, so the first
12306
+ * render may have measured with a fallback face while document.fonts.ready
12307
+ * had long resolved (no font was in flight yet). Each new spec is loaded
12308
+ * explicitly and the balloons re-render once it arrives.
12309
+ */
12310
+ this.fontsRequested = new Set();
11810
12311
  /** ResizeObserver for container size tracking */
11811
12312
  this.resizeObserver = null;
11812
12313
  this.initialized = false;
@@ -11836,6 +12337,7 @@ class SpeechBubblesComponent {
11836
12337
  changes['containerWidth'] ||
11837
12338
  changes['containerHeight'] ||
11838
12339
  changes['readingScale'] ||
12340
+ changes['anchorTailTip'] ||
11839
12341
  changes['workBalloonConfig'] ||
11840
12342
  changes['characters'];
11841
12343
  if (needsRerender) {
@@ -11938,11 +12440,13 @@ class SpeechBubblesComponent {
11938
12440
  return;
11939
12441
  if (this.containerWidth <= 0 || this.containerHeight <= 0)
11940
12442
  return;
12443
+ const fontSpecs = new Set();
11941
12444
  for (const bubble of this.bubbles) {
11942
12445
  const text = this.getLocalizedText(bubble);
11943
12446
  if (!text)
11944
12447
  continue;
11945
12448
  const config = this.resolveEffectiveConfig(bubble);
12449
+ fontSpecs.add(`${config.fontSize}px ${config.fontFamily}`);
11946
12450
  const pos = this.computeBubblePosition(bubble);
11947
12451
  // Create wrapper div for positioning
11948
12452
  const wrapper = document.createElement('div');
@@ -11965,7 +12469,8 @@ class SpeechBubblesComponent {
11965
12469
  // NOT proportionally with the authored shape box (which would blow
11966
12470
  // text up on large screens). Capped so a balloon never exceeds its
11967
12471
  // panel container, and clamped against broken measurements.
11968
- let scale = Math.min(4, Math.max(0.25, this.readingScale || 1));
12472
+ const baseScale = Math.min(4, Math.max(0.25, this.readingScale || 1));
12473
+ let scale = baseScale;
11969
12474
  // Never letter below MIN_LETTERING_PX: on a page shown small the balloon
11970
12475
  // grows just enough (still anchored and kept inside the panel below).
11971
12476
  if (config.fontSize > 0) {
@@ -11976,39 +12481,101 @@ class SpeechBubblesComponent {
11976
12481
  // The SVG can run a little wider than maxWidth (stroke, tail), so narrow
11977
12482
  // by the remaining overshoot for up to three passes.
11978
12483
  let maxWidth = Number(renderOpts['maxWidth']) || svgWidth;
12484
+ let usedOpts = renderOpts;
11979
12485
  for (let pass = 0; pass < 3 && svgWidth > 0 && this.containerWidth > 0 && svgWidth * scale > this.containerWidth + 0.5; pass++) {
11980
12486
  const narrower = Math.max(40, Math.floor(maxWidth * (this.containerWidth / (svgWidth * scale)) * 0.99));
11981
12487
  if (narrower >= maxWidth)
11982
12488
  break;
11983
12489
  maxWidth = narrower;
11984
- result = new ComicBalloon(balloonContainer, { ...renderOpts, maxWidth, maxHeight: 0 }).render(text, tailOpts);
12490
+ usedOpts = { ...renderOpts, maxWidth, maxHeight: 0 };
12491
+ result = new ComicBalloon(balloonContainer, usedOpts).render(text, tailOpts);
11985
12492
  svgWidth = result.svg?.style.width ? parseFloat(result.svg.style.width) : maxWidth;
11986
12493
  svgHeight = result.svg?.style.height ? parseFloat(result.svg.style.height) : svgHeight;
11987
12494
  }
11988
12495
  if (svgWidth > 0 && svgHeight > 0) {
11989
12496
  scale = Math.max(0.25, Math.min(scale, this.containerWidth / svgWidth, this.containerHeight / svgHeight));
11990
12497
  }
12498
+ // Page view (anchorTailTip): the tail tip stays on the AUTHORED tip
12499
+ // and a readability boost above the base scale grows only the body —
12500
+ // the tail is rendered at length / boost so its visible length stays
12501
+ // authored (the editor's tail counter-scaling). Connectors keep the
12502
+ // centre rule: the editor glues them onto their target balloon.
12503
+ const tailLength = Number(tailOpts?.length) || 50;
12504
+ const cornerRadius = Number(renderOpts['cornerRadius'] ?? 0.45) || 0;
12505
+ const tipAnchored = this.anchorTailTip && !!tailOpts && tailOpts.position !== undefined
12506
+ && config.balloonType !== 'connector';
12507
+ let renderedTailLength = tailLength;
12508
+ if (tipAnchored && scale > baseScale * 1.001) {
12509
+ renderedTailLength = tailLength * baseScale / scale;
12510
+ result = new ComicBalloon(balloonContainer, usedOpts)
12511
+ .render(text, { ...tailOpts, length: renderedTailLength });
12512
+ svgWidth = result.svg?.style.width ? parseFloat(result.svg.style.width) : svgWidth;
12513
+ svgHeight = result.svg?.style.height ? parseFloat(result.svg.style.height) : svgHeight;
12514
+ }
11991
12515
  const visualW = svgWidth * scale;
11992
12516
  const visualH = svgHeight * scale;
11993
- // Anchor at the shape box's center — but edges the authored box
11994
- // touches stay glued to the panel border (narrator / Panel-Top
11995
- // caption boxes keep sitting exactly on the border even though the
11996
- // visual size no longer equals the stored box).
12517
+ // Auto-grow (schema 1.8 textFit): when the text needed more room than
12518
+ // the authored size, the engine grew the body. Place the grown body
12519
+ // so the tail tip stays on the speaker (the body moves away from it)
12520
+ // — the same rule the CMS editor applies; glued caption edges and the
12521
+ // panel clamp below still win. Offsets are in balloon px, scaled.
12522
+ let grownOffsetX = 0;
12523
+ let grownOffsetY = 0;
12524
+ if (result.grown) {
12525
+ const authoredRect = {
12526
+ x: -result.authoredWidth / 2, y: -result.authoredHeight / 2,
12527
+ width: result.authoredWidth, height: result.authoredHeight,
12528
+ };
12529
+ const tail = tailOpts && tailOpts.position !== undefined
12530
+ ? { position: tailOpts.position, length: tailOpts.length }
12531
+ : null;
12532
+ const placed = placeGrownBody(authoredRect, { width: result.width, height: result.height }, tail, Number(renderOpts['cornerRadius']) || 0, null);
12533
+ grownOffsetX = (placed.x + placed.width / 2) * scale;
12534
+ grownOffsetY = (placed.y + placed.height / 2) * scale;
12535
+ wrapper.setAttribute('data-grown', 'true');
12536
+ }
12537
+ // The stored box is the balloon BODY (the editor stores the body, not
12538
+ // the tail). The SVG's viewBox also holds the tail and the stroke
12539
+ // padding, so its centre is not the body's: offset of the body centre
12540
+ // from the SVG centre, in balloon px (the body starts at viewBox 0,0).
12541
+ const vb = (result.svg?.getAttribute('viewBox') ?? '').split(/\s+/).map(Number);
12542
+ const hasViewBox = vb.length === 4 && vb.every((v) => Number.isFinite(v));
12543
+ const bodyOffsetX = hasViewBox ? (-vb[0] + result.width / 2) - svgWidth / 2 : 0;
12544
+ const bodyOffsetY = hasViewBox ? (-vb[1] + result.height / 2) - svgHeight / 2 : 0;
12545
+ // Anchor the body at the shape box's center (shifted for a grown
12546
+ // body) — but edges the authored box touches stay glued to the panel
12547
+ // border (narrator / Panel-Top caption boxes keep sitting exactly on
12548
+ // the border even though the visual size no longer equals the stored
12549
+ // box). centerX/centerY are where the SVG's centre goes.
11997
12550
  const boxLeft = (bubble.shape?.x ?? 0) * this.containerWidth;
11998
12551
  const boxTop = (bubble.shape?.y ?? 0) * this.containerHeight;
11999
12552
  const boxRight = boxLeft + (bubble.shape?.w ?? 0) * this.containerWidth;
12000
12553
  const boxBottom = boxTop + (bubble.shape?.h ?? 0) * this.containerHeight;
12001
12554
  const eps = Math.max(4, 0.02 * Math.min(this.containerWidth, this.containerHeight));
12002
- let centerX = pos.left;
12003
- let centerY = pos.top;
12004
- if (boxLeft <= eps)
12005
- centerX = visualW / 2;
12006
- else if (boxRight >= this.containerWidth - eps)
12007
- centerX = this.containerWidth - visualW / 2;
12008
- if (boxTop <= eps)
12009
- centerY = visualH / 2;
12010
- else if (boxBottom >= this.containerHeight - eps)
12011
- centerY = this.containerHeight - visualH / 2;
12555
+ let centerX = pos.left + grownOffsetX - bodyOffsetX * scale;
12556
+ let centerY = pos.top + grownOffsetY - bodyOffsetY * scale;
12557
+ if (tipAnchored) {
12558
+ // Authored tip: the stored box with the tail at the base scale
12559
+ // (the editor's tip before its re-measure and boost). The rendered
12560
+ // balloon's own tip, relative to its body centre, lands on it — a
12561
+ // grown or boosted body moves away from the speaker, the tip stays.
12562
+ const authoredW = boxRight - boxLeft > 1 ? boxRight - boxLeft : config.maxWidth * baseScale;
12563
+ const authoredH = boxBottom - boxTop > 1 ? boxBottom - boxTop : config.maxHeight * baseScale;
12564
+ const tip = tailConfigToTip({ cx: (boxLeft + boxRight) / 2, cy: (boxTop + boxBottom) / 2, width: authoredW, height: authoredH }, { position: Number(tailOpts.position), length: tailLength * baseScale }, cornerRadius);
12565
+ const localTip = tailConfigToTip({ cx: 0, cy: 0, width: result.width, height: result.height }, { position: Number(tailOpts.position), length: renderedTailLength }, cornerRadius);
12566
+ centerX = tip.x - (localTip.x + bodyOffsetX) * scale;
12567
+ centerY = tip.y - (localTip.y + bodyOffsetY) * scale;
12568
+ }
12569
+ else {
12570
+ if (boxLeft <= eps)
12571
+ centerX = visualW / 2;
12572
+ else if (boxRight >= this.containerWidth - eps)
12573
+ centerX = this.containerWidth - visualW / 2;
12574
+ if (boxTop <= eps)
12575
+ centerY = visualH / 2;
12576
+ else if (boxBottom >= this.containerHeight - eps)
12577
+ centerY = this.containerHeight - visualH / 2;
12578
+ }
12012
12579
  // A balloon bigger than its authored box (small screens) must not be
12013
12580
  // pushed across the panel border and clipped: keep it inside the panel.
12014
12581
  centerX = clampCenter(centerX, visualW, this.containerWidth);
@@ -12049,6 +12616,45 @@ class SpeechBubblesComponent {
12049
12616
  console.warn(`[pw-speech-bubbles] Failed to render balloon "${bubble.id}":`, e);
12050
12617
  }
12051
12618
  }
12619
+ this.ensureFontsLoaded(fontSpecs);
12620
+ }
12621
+ /**
12622
+ * Request every font spec the balloons use that was not requested before;
12623
+ * re-render once a face that was not yet available arrives, so balloons
12624
+ * measured with a fallback face are laid out again with the real one.
12625
+ */
12626
+ ensureFontsLoaded(specs) {
12627
+ if (typeof document === 'undefined' || !document.fonts?.load)
12628
+ return;
12629
+ const pending = [];
12630
+ for (const spec of specs) {
12631
+ if (this.fontsRequested.has(spec))
12632
+ continue;
12633
+ this.fontsRequested.add(spec);
12634
+ let available = false;
12635
+ try {
12636
+ available = document.fonts.check(spec);
12637
+ }
12638
+ catch {
12639
+ available = false;
12640
+ }
12641
+ if (available)
12642
+ continue;
12643
+ try {
12644
+ pending.push(document.fonts.load(spec));
12645
+ }
12646
+ catch {
12647
+ // Unparseable spec (custom family string): nothing to wait for.
12648
+ }
12649
+ }
12650
+ if (pending.length === 0)
12651
+ return;
12652
+ Promise.allSettled(pending).then((results) => {
12653
+ const loadedAny = results.some((r) => r.status === 'fulfilled' && Array.isArray(r.value) && r.value.length > 0);
12654
+ if (loadedAny && this.initialized) {
12655
+ this.renderAllBalloons();
12656
+ }
12657
+ });
12052
12658
  }
12053
12659
  /**
12054
12660
  * Handle bubble click (called from template for any fallback interaction)
@@ -12058,7 +12664,7 @@ class SpeechBubblesComponent {
12058
12664
  this.bubbleClick.emit(bubble);
12059
12665
  }
12060
12666
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SpeechBubblesComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
12061
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: SpeechBubblesComponent, isStandalone: true, selector: "pw-speech-bubbles", inputs: { bubbles: "bubbles", locale: "locale", containerWidth: "containerWidth", containerHeight: "containerHeight", readingScale: "readingScale", workBalloonConfig: "workBalloonConfig", characters: "characters" }, outputs: { bubbleClick: "bubbleClick", bubbleAudioPlay: "bubbleAudioPlay" }, usesOnChanges: true, ngImport: i0, template: "<!-- Speech bubbles are rendered imperatively via ComicBalloon SVG renderer -->\n<div class=\"speech-bubbles-container\"></div>\n", styles: [":host{display:block;position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:20}.speech-bubbles-container{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none}.speech-bubble-wrapper{position:absolute;pointer-events:auto;cursor:pointer;z-index:10;animation:bubbleAppear .3s ease-out}.speech-bubble-wrapper:focus{outline:2px solid rgba(0,120,255,.6);outline-offset:4px;border-radius:4px}.speech-bubble-wrapper:hover{filter:drop-shadow(0 2px 6px rgba(0,0,0,.15))}.balloon-container{display:inline-block;position:relative}.balloon-container :deep(.comic-balloon-svg){display:block;overflow:visible}@keyframes bubbleAppear{0%{opacity:0;transform:scale(.85)}to{opacity:1;transform:scale(1)}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
12667
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.14", type: SpeechBubblesComponent, isStandalone: true, selector: "pw-speech-bubbles", inputs: { bubbles: "bubbles", locale: "locale", containerWidth: "containerWidth", containerHeight: "containerHeight", readingScale: "readingScale", anchorTailTip: "anchorTailTip", workBalloonConfig: "workBalloonConfig", characters: "characters" }, outputs: { bubbleClick: "bubbleClick", bubbleAudioPlay: "bubbleAudioPlay" }, usesOnChanges: true, ngImport: i0, template: "<!-- Speech bubbles are rendered imperatively via ComicBalloon SVG renderer -->\n<div class=\"speech-bubbles-container\"></div>\n", styles: [":host{display:block;position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:20}.speech-bubbles-container{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none}.speech-bubble-wrapper{position:absolute;pointer-events:auto;cursor:pointer;z-index:10;animation:bubbleAppear .3s ease-out}.speech-bubble-wrapper:focus{outline:2px solid rgba(0,120,255,.6);outline-offset:4px;border-radius:4px}.speech-bubble-wrapper:hover{filter:drop-shadow(0 2px 6px rgba(0,0,0,.15))}.balloon-container{display:inline-block;position:relative}.balloon-container :deep(.comic-balloon-svg){display:block;overflow:visible}@keyframes bubbleAppear{0%{opacity:0;transform:scale(.85)}to{opacity:1;transform:scale(1)}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
12062
12668
  }
12063
12669
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SpeechBubblesComponent, decorators: [{
12064
12670
  type: Component,
@@ -12073,6 +12679,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
12073
12679
  type: Input
12074
12680
  }], readingScale: [{
12075
12681
  type: Input
12682
+ }], anchorTailTip: [{
12683
+ type: Input
12076
12684
  }], workBalloonConfig: [{
12077
12685
  type: Input
12078
12686
  }], characters: [{
@@ -12100,6 +12708,11 @@ class HotspotsOverlayComponent {
12100
12708
  * Hotspots of the current panel (manifest shape)
12101
12709
  */
12102
12710
  this.hotspots = [];
12711
+ /**
12712
+ * The panel's speech bubbles: a choice hotspot whose label is already
12713
+ * lettered as a bubble keeps its invisible area under `display: auto`.
12714
+ */
12715
+ this.bubbles = [];
12103
12716
  /**
12104
12717
  * Current locale for label resolution
12105
12718
  */
@@ -12149,8 +12762,17 @@ class HotspotsOverlayComponent {
12149
12762
  const goTos = (this.hotspots ?? []).filter((h) => h.action?.type === 'goTo');
12150
12763
  const targets = new Set(goTos.map((h) => h.action.to));
12151
12764
  const isChoice = new Set(targets.size > 1 ? goTos.map((h) => h.id) : []);
12765
+ // A choice whose label is already lettered as a speech bubble of the panel
12766
+ // (an authored "choice" caption sitting on the hotspot) stays an invisible
12767
+ // area under auto — the balloon IS the button; drawing the label again
12768
+ // would show the text twice.
12769
+ const bubbleTexts = new Set((this.bubbles ?? [])
12770
+ .map((b) => this.resolveLocalized(b.text).trim().toLowerCase())
12771
+ .filter((t) => t.length > 0));
12772
+ const letteredAsBubble = (h) => bubbleTexts.has(this.resolveLocalized(h.label).trim().toLowerCase());
12152
12773
  this.choiceIds = new Set((this.hotspots ?? [])
12153
- .filter((h) => h.display === 'button' || ((h.display ?? 'auto') === 'auto' && isChoice.has(h.id)))
12774
+ .filter((h) => h.display === 'button'
12775
+ || ((h.display ?? 'auto') === 'auto' && isChoice.has(h.id) && !letteredAsBubble(h)))
12154
12776
  .map((h) => h.id));
12155
12777
  }
12156
12778
  /** The visible button text of a choice hotspot (its label), or '' for plain areas. */
@@ -12264,13 +12886,15 @@ class HotspotsOverlayComponent {
12264
12886
  return Math.min(1, Math.max(0, v));
12265
12887
  }
12266
12888
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: HotspotsOverlayComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
12267
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.14", type: HotspotsOverlayComponent, isStandalone: true, selector: "pw-hotspots-overlay", inputs: { hotspots: "hotspots", locale: "locale", containerWidth: "containerWidth", containerHeight: "containerHeight", context: "context", interactive: "interactive", showIndicators: "showIndicators" }, outputs: { hotspotActivate: "hotspotActivate" }, usesOnChanges: true, ngImport: i0, template: "<svg\n class=\"hotspots-svg\"\n [class.non-interactive]=\"!interactive\"\n [attr.viewBox]=\"'0 0 ' + containerWidth + ' ' + containerHeight\"\n preserveAspectRatio=\"none\"\n aria-hidden=\"false\"\n>\n @for (h of visibleHotspots; track h.id) {\n <g\n class=\"hotspot-shape\"\n [class.choice]=\"choiceIds.has(h.id)\"\n [class.indicator]=\"showIndicators && !choiceIds.has(h.id)\"\n [attr.role]=\"interactive ? 'button' : null\"\n [attr.tabindex]=\"interactive ? 0 : null\"\n [attr.aria-label]=\"resolveLabel(h)\"\n (click)=\"onShapeClick(h, $event)\"\n (keydown)=\"onKeyDown(h, $event)\"\n >\n @switch (h.shape.type) {\n @case ('rect') {\n <rect\n [attr.x]=\"rectAttrs($any(h.shape)).x\"\n [attr.y]=\"rectAttrs($any(h.shape)).y\"\n [attr.width]=\"rectAttrs($any(h.shape)).width\"\n [attr.height]=\"rectAttrs($any(h.shape)).height\"\n rx=\"6\"\n />\n }\n @case ('circle') {\n <circle\n [attr.cx]=\"circleAttrs($any(h.shape)).cx\"\n [attr.cy]=\"circleAttrs($any(h.shape)).cy\"\n [attr.r]=\"circleAttrs($any(h.shape)).r\"\n />\n }\n @case ('polygon') {\n <polygon [attr.points]=\"polygonPoints($any(h.shape))\" />\n }\n }\n @if (choiceText(h); as text) {\n <!-- A choice: its label is the button text (decorative; the g carries the accessible name). -->\n <foreignObject\n class=\"hotspot-label-box\"\n [attr.x]=\"boundsOf(h.shape).x\"\n [attr.y]=\"boundsOf(h.shape).y\"\n [attr.width]=\"boundsOf(h.shape).width\"\n [attr.height]=\"boundsOf(h.shape).height\"\n aria-hidden=\"true\">\n <xhtml:div class=\"hotspot-label\" [style.font-size.px]=\"choiceFontSize(h)\">{{ text }}</xhtml:div>\n </foreignObject>\n }\n </g>\n }\n</svg>\n", styles: [":host{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:15}.hotspots-svg{width:100%;height:100%;display:block}.hotspot-shape rect,.hotspot-shape circle,.hotspot-shape polygon{fill:#fff0;stroke:transparent;stroke-width:2;pointer-events:auto;cursor:pointer}.hotspots-svg.non-interactive .hotspot-shape rect,.hotspots-svg.non-interactive .hotspot-shape circle,.hotspots-svg.non-interactive .hotspot-shape polygon{pointer-events:none;cursor:default}.hotspot-shape:hover rect,.hotspot-shape:focus-visible rect,.hotspot-shape:hover circle,.hotspot-shape:focus-visible circle,.hotspot-shape:hover polygon,.hotspot-shape:focus-visible polygon{stroke:#ffffffd9;fill:#ffffff14}.hotspot-shape:focus{outline:none}.hotspot-shape.choice rect,.hotspot-shape.choice circle,.hotspot-shape.choice polygon{fill:#0a1114b8;stroke:#ffffffd9;stroke-width:1.5}.hotspot-shape.choice:hover rect,.hotspot-shape.choice:focus-visible rect,.hotspot-shape.choice:hover circle,.hotspot-shape.choice:focus-visible circle,.hotspot-shape.choice:hover polygon,.hotspot-shape.choice:focus-visible polygon{fill:#0a1114e0;stroke:#fff;stroke-width:2.5}.hotspot-label-box{pointer-events:none;overflow:visible}.hotspot-label{box-sizing:border-box;width:100%;height:100%;display:flex;align-items:center;justify-content:center;padding:0 .6em;color:#fff;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-weight:600;line-height:1.15;text-align:center;overflow:hidden;text-shadow:0 1px 2px rgba(0,0,0,.6)}.hotspot-shape.indicator rect,.hotspot-shape.indicator circle,.hotspot-shape.indicator polygon{animation:hotspot-pulse 2.4s ease-in-out infinite}@keyframes hotspot-pulse{0%,to{stroke:#fff0}50%{stroke:#ffffff73}}@media (prefers-reduced-motion: reduce){.hotspot-shape.indicator rect,.hotspot-shape.indicator circle,.hotspot-shape.indicator polygon{animation:none}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
12889
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.14", type: HotspotsOverlayComponent, isStandalone: true, selector: "pw-hotspots-overlay", inputs: { hotspots: "hotspots", bubbles: "bubbles", locale: "locale", containerWidth: "containerWidth", containerHeight: "containerHeight", context: "context", interactive: "interactive", showIndicators: "showIndicators" }, outputs: { hotspotActivate: "hotspotActivate" }, usesOnChanges: true, ngImport: i0, template: "<svg\n class=\"hotspots-svg\"\n [class.non-interactive]=\"!interactive\"\n [attr.viewBox]=\"'0 0 ' + containerWidth + ' ' + containerHeight\"\n preserveAspectRatio=\"none\"\n aria-hidden=\"false\"\n>\n @for (h of visibleHotspots; track h.id) {\n <g\n class=\"hotspot-shape\"\n [class.choice]=\"choiceIds.has(h.id)\"\n [class.indicator]=\"showIndicators && !choiceIds.has(h.id)\"\n [attr.role]=\"interactive ? 'button' : null\"\n [attr.tabindex]=\"interactive ? 0 : null\"\n [attr.aria-label]=\"resolveLabel(h)\"\n (click)=\"onShapeClick(h, $event)\"\n (keydown)=\"onKeyDown(h, $event)\"\n >\n @switch (h.shape.type) {\n @case ('rect') {\n <rect\n [attr.x]=\"rectAttrs($any(h.shape)).x\"\n [attr.y]=\"rectAttrs($any(h.shape)).y\"\n [attr.width]=\"rectAttrs($any(h.shape)).width\"\n [attr.height]=\"rectAttrs($any(h.shape)).height\"\n rx=\"6\"\n />\n }\n @case ('circle') {\n <circle\n [attr.cx]=\"circleAttrs($any(h.shape)).cx\"\n [attr.cy]=\"circleAttrs($any(h.shape)).cy\"\n [attr.r]=\"circleAttrs($any(h.shape)).r\"\n />\n }\n @case ('polygon') {\n <polygon [attr.points]=\"polygonPoints($any(h.shape))\" />\n }\n }\n @if (choiceText(h); as text) {\n <!-- A choice: its label is the button text (decorative; the g carries the accessible name). -->\n <foreignObject\n class=\"hotspot-label-box\"\n [attr.x]=\"boundsOf(h.shape).x\"\n [attr.y]=\"boundsOf(h.shape).y\"\n [attr.width]=\"boundsOf(h.shape).width\"\n [attr.height]=\"boundsOf(h.shape).height\"\n aria-hidden=\"true\">\n <xhtml:div class=\"hotspot-label\" [style.font-size.px]=\"choiceFontSize(h)\">{{ text }}</xhtml:div>\n </foreignObject>\n }\n </g>\n }\n</svg>\n", styles: [":host{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:15}.hotspots-svg{width:100%;height:100%;display:block}.hotspot-shape rect,.hotspot-shape circle,.hotspot-shape polygon{fill:#fff0;stroke:transparent;stroke-width:2;pointer-events:auto;cursor:pointer}.hotspots-svg.non-interactive .hotspot-shape rect,.hotspots-svg.non-interactive .hotspot-shape circle,.hotspots-svg.non-interactive .hotspot-shape polygon{pointer-events:none;cursor:default}.hotspot-shape:hover rect,.hotspot-shape:focus-visible rect,.hotspot-shape:hover circle,.hotspot-shape:focus-visible circle,.hotspot-shape:hover polygon,.hotspot-shape:focus-visible polygon{stroke:#ffffffd9;fill:#ffffff14}.hotspot-shape:focus{outline:none}.hotspot-shape.choice rect,.hotspot-shape.choice circle,.hotspot-shape.choice polygon{fill:#0a1114b8;stroke:#ffffffd9;stroke-width:1.5}.hotspot-shape.choice:hover rect,.hotspot-shape.choice:focus-visible rect,.hotspot-shape.choice:hover circle,.hotspot-shape.choice:focus-visible circle,.hotspot-shape.choice:hover polygon,.hotspot-shape.choice:focus-visible polygon{fill:#0a1114e0;stroke:#fff;stroke-width:2.5}.hotspot-label-box{pointer-events:none;overflow:visible}.hotspot-label{box-sizing:border-box;width:100%;height:100%;display:flex;align-items:center;justify-content:center;padding:0 .6em;color:#fff;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-weight:600;line-height:1.15;text-align:center;overflow:hidden;text-shadow:0 1px 2px rgba(0,0,0,.6)}.hotspot-shape.indicator rect,.hotspot-shape.indicator circle,.hotspot-shape.indicator polygon{animation:hotspot-pulse 2.4s ease-in-out infinite}@keyframes hotspot-pulse{0%,to{stroke:#fff0}50%{stroke:#ffffff73}}@media (prefers-reduced-motion: reduce){.hotspot-shape.indicator rect,.hotspot-shape.indicator circle,.hotspot-shape.indicator polygon{animation:none}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
12268
12890
  }
12269
12891
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: HotspotsOverlayComponent, decorators: [{
12270
12892
  type: Component,
12271
12893
  args: [{ selector: 'pw-hotspots-overlay', imports: [], changeDetection: ChangeDetectionStrategy.OnPush, template: "<svg\n class=\"hotspots-svg\"\n [class.non-interactive]=\"!interactive\"\n [attr.viewBox]=\"'0 0 ' + containerWidth + ' ' + containerHeight\"\n preserveAspectRatio=\"none\"\n aria-hidden=\"false\"\n>\n @for (h of visibleHotspots; track h.id) {\n <g\n class=\"hotspot-shape\"\n [class.choice]=\"choiceIds.has(h.id)\"\n [class.indicator]=\"showIndicators && !choiceIds.has(h.id)\"\n [attr.role]=\"interactive ? 'button' : null\"\n [attr.tabindex]=\"interactive ? 0 : null\"\n [attr.aria-label]=\"resolveLabel(h)\"\n (click)=\"onShapeClick(h, $event)\"\n (keydown)=\"onKeyDown(h, $event)\"\n >\n @switch (h.shape.type) {\n @case ('rect') {\n <rect\n [attr.x]=\"rectAttrs($any(h.shape)).x\"\n [attr.y]=\"rectAttrs($any(h.shape)).y\"\n [attr.width]=\"rectAttrs($any(h.shape)).width\"\n [attr.height]=\"rectAttrs($any(h.shape)).height\"\n rx=\"6\"\n />\n }\n @case ('circle') {\n <circle\n [attr.cx]=\"circleAttrs($any(h.shape)).cx\"\n [attr.cy]=\"circleAttrs($any(h.shape)).cy\"\n [attr.r]=\"circleAttrs($any(h.shape)).r\"\n />\n }\n @case ('polygon') {\n <polygon [attr.points]=\"polygonPoints($any(h.shape))\" />\n }\n }\n @if (choiceText(h); as text) {\n <!-- A choice: its label is the button text (decorative; the g carries the accessible name). -->\n <foreignObject\n class=\"hotspot-label-box\"\n [attr.x]=\"boundsOf(h.shape).x\"\n [attr.y]=\"boundsOf(h.shape).y\"\n [attr.width]=\"boundsOf(h.shape).width\"\n [attr.height]=\"boundsOf(h.shape).height\"\n aria-hidden=\"true\">\n <xhtml:div class=\"hotspot-label\" [style.font-size.px]=\"choiceFontSize(h)\">{{ text }}</xhtml:div>\n </foreignObject>\n }\n </g>\n }\n</svg>\n", styles: [":host{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:15}.hotspots-svg{width:100%;height:100%;display:block}.hotspot-shape rect,.hotspot-shape circle,.hotspot-shape polygon{fill:#fff0;stroke:transparent;stroke-width:2;pointer-events:auto;cursor:pointer}.hotspots-svg.non-interactive .hotspot-shape rect,.hotspots-svg.non-interactive .hotspot-shape circle,.hotspots-svg.non-interactive .hotspot-shape polygon{pointer-events:none;cursor:default}.hotspot-shape:hover rect,.hotspot-shape:focus-visible rect,.hotspot-shape:hover circle,.hotspot-shape:focus-visible circle,.hotspot-shape:hover polygon,.hotspot-shape:focus-visible polygon{stroke:#ffffffd9;fill:#ffffff14}.hotspot-shape:focus{outline:none}.hotspot-shape.choice rect,.hotspot-shape.choice circle,.hotspot-shape.choice polygon{fill:#0a1114b8;stroke:#ffffffd9;stroke-width:1.5}.hotspot-shape.choice:hover rect,.hotspot-shape.choice:focus-visible rect,.hotspot-shape.choice:hover circle,.hotspot-shape.choice:focus-visible circle,.hotspot-shape.choice:hover polygon,.hotspot-shape.choice:focus-visible polygon{fill:#0a1114e0;stroke:#fff;stroke-width:2.5}.hotspot-label-box{pointer-events:none;overflow:visible}.hotspot-label{box-sizing:border-box;width:100%;height:100%;display:flex;align-items:center;justify-content:center;padding:0 .6em;color:#fff;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-weight:600;line-height:1.15;text-align:center;overflow:hidden;text-shadow:0 1px 2px rgba(0,0,0,.6)}.hotspot-shape.indicator rect,.hotspot-shape.indicator circle,.hotspot-shape.indicator polygon{animation:hotspot-pulse 2.4s ease-in-out infinite}@keyframes hotspot-pulse{0%,to{stroke:#fff0}50%{stroke:#ffffff73}}@media (prefers-reduced-motion: reduce){.hotspot-shape.indicator rect,.hotspot-shape.indicator circle,.hotspot-shape.indicator polygon{animation:none}}\n"] }]
12272
12894
  }], propDecorators: { hotspots: [{
12273
12895
  type: Input
12896
+ }], bubbles: [{
12897
+ type: Input
12274
12898
  }], locale: [{
12275
12899
  type: Input
12276
12900
  }], containerWidth: [{
@@ -12620,6 +13244,63 @@ class ViewportComponent {
12620
13244
  */
12621
13245
  this.focus = NO_FOCUS_TRANSFORM;
12622
13246
  this.focusFrame = null;
13247
+ /**
13248
+ * Panel-view box: the largest box of the panel's artwork aspect that fits
13249
+ * the viewport, so the panel fills the screen and speech bubbles (normalised
13250
+ * to the panel) land on the artwork. Null before the first measurement
13251
+ * (the stylesheet's default box applies).
13252
+ */
13253
+ this.panelBox = null;
13254
+ }
13255
+ /**
13256
+ * Aspect ratio (w / h) of the current panel's artwork: its background
13257
+ * image's largest variant, else the first image or video asset with known
13258
+ * dimensions, else 16:9 — the same reference the CMS editor sizes its
13259
+ * panel-view content rect from.
13260
+ */
13261
+ get panelAspect() {
13262
+ const layers = this.panel?.layers ?? [];
13263
+ const candidates = [
13264
+ ...layers.filter((l) => (l.z ?? 0) === 0 && l.kind === 'image'),
13265
+ ...layers.filter((l) => l.kind === 'image' || l.kind === 'video'),
13266
+ ];
13267
+ for (const layer of candidates) {
13268
+ const assetId = layer.assetId;
13269
+ if (!assetId)
13270
+ continue;
13271
+ const asset = this.manifestService.getAsset(assetId);
13272
+ let w = 0;
13273
+ let h = 0;
13274
+ for (const v of asset?.variants ?? []) {
13275
+ if ((v.w ?? 0) > w && (v.h ?? 0) > 0) {
13276
+ w = v.w ?? 0;
13277
+ h = v.h ?? 0;
13278
+ }
13279
+ }
13280
+ if (!(w > 0 && h > 0) && (asset?.width ?? 0) > 0 && (asset?.height ?? 0) > 0) {
13281
+ w = asset.width;
13282
+ h = asset.height;
13283
+ }
13284
+ if (w > 0 && h > 0)
13285
+ return w / h;
13286
+ }
13287
+ return 16 / 9;
13288
+ }
13289
+ /** Largest box of `aspect` inside a viewport (0 when the viewport is unmeasured). */
13290
+ static fitBox(viewportWidth, viewportHeight, aspect) {
13291
+ if (!(viewportWidth > 0) || !(viewportHeight > 0) || !(aspect > 0))
13292
+ return null;
13293
+ const width = Math.min(viewportWidth, viewportHeight * aspect);
13294
+ return { width: Math.round(width), height: Math.round(width / aspect) };
13295
+ }
13296
+ /**
13297
+ * Page view shows a panel's whole artwork in its box, as the editor does;
13298
+ * the placement's visible area (vx/vy/vw/vh) only crops on compact screens
13299
+ * (phones), where the page's panels run edge to edge.
13300
+ */
13301
+ isCompactHost() {
13302
+ const width = this.elementRef.nativeElement.getBoundingClientRect().width;
13303
+ return width > 0 && width < COMPACT_PAGE_WIDTH;
12623
13304
  }
12624
13305
  /** Recompute the focus placement once the current panel is laid out. */
12625
13306
  scheduleFocusUpdate() {
@@ -12637,18 +13318,39 @@ class ViewportComponent {
12637
13318
  /** Measure viewport + panel box and place the panel on its focus rect. */
12638
13319
  updateFocusTransform() {
12639
13320
  let next = NO_FOCUS_TRANSFORM;
12640
- if (this.viewMode === 'panel' && this.panel?.formatViews) {
13321
+ let changed = false;
13322
+ if (this.viewMode === 'panel' && this.panel) {
12641
13323
  const host = this.elementRef.nativeElement;
12642
13324
  const viewport = host.querySelector('.viewport-container') ?? host;
12643
- const box = host.querySelector('.t-frame .panel-container');
12644
- if (box) {
12645
- const rect = pickFocusRect(this.panel.formatViews, viewport.clientWidth, viewport.clientHeight);
12646
- next = focusTransform(box.offsetWidth, box.offsetHeight, viewport.clientWidth, viewport.clientHeight, rect);
13325
+ // The panel fills the viewport at its artwork's aspect. On a compact
13326
+ // screen (phone) with a focus rect, the panel is enlarged so that the
13327
+ // rect fills the screen instead — a larger image part on a small
13328
+ // screen — and the crop is centred on the rect below.
13329
+ const aspect = this.panelAspect;
13330
+ const rect = this.panel.formatViews && this.isCompactHost()
13331
+ ? pickFocusRect(this.panel.formatViews, viewport.clientWidth, viewport.clientHeight)
13332
+ : null;
13333
+ const box = rect && rect.w > 0 && rect.h > 0
13334
+ ? ViewportComponent.fitBox(viewport.clientWidth / rect.w, viewport.clientHeight / rect.h, aspect)
13335
+ : ViewportComponent.fitBox(viewport.clientWidth, viewport.clientHeight, aspect);
13336
+ if (box && (box.width !== this.panelBox?.width || box.height !== this.panelBox?.height)) {
13337
+ this.panelBox = box;
13338
+ changed = true;
13339
+ }
13340
+ if (rect && box) {
13341
+ next = focusTransform(box.width, box.height, viewport.clientWidth, viewport.clientHeight, rect);
12647
13342
  }
12648
13343
  }
12649
13344
  if (next.offsetX !== this.focus.offsetX || next.offsetY !== this.focus.offsetY || next.scale !== this.focus.scale) {
12650
13345
  this.focus = next;
13346
+ changed = true;
13347
+ }
13348
+ if (changed) {
12651
13349
  this.cdr.markForCheck();
13350
+ // Bubble/hotspot overlays read the container size through input
13351
+ // bindings; the resized box needs one more pass to reach them.
13352
+ if (typeof requestAnimationFrame !== 'undefined')
13353
+ requestAnimationFrame(() => this.cdr.markForCheck());
12652
13354
  }
12653
13355
  }
12654
13356
  ngOnChanges(changes) {
@@ -13545,8 +14247,10 @@ class ViewportComponent {
13545
14247
  const vy = placement.vy ?? 0;
13546
14248
  const vw = placement.vw ?? 1;
13547
14249
  const vh = placement.vh ?? 1;
13548
- // If full panel is visible, no clipping needed
13549
- if (vx === 0 && vy === 0 && vw === 1 && vh === 1) {
14250
+ // If full panel is visible, no clipping needed; on screens that are not
14251
+ // compact the whole artwork is shown anyway (the visible area is a hint
14252
+ // for small screens, see isCompactHost).
14253
+ if ((vx === 0 && vy === 0 && vw === 1 && vh === 1) || !this.isCompactHost()) {
13550
14254
  return 'none';
13551
14255
  }
13552
14256
  // Calculate inset values as percentages
@@ -13564,8 +14268,8 @@ class ViewportComponent {
13564
14268
  getPanelContentTransform(placement) {
13565
14269
  const vx = placement.vx ?? 0;
13566
14270
  const vy = placement.vy ?? 0;
13567
- // If no offset, return none
13568
- if (vx === 0 && vy === 0) {
14271
+ // If no offset, return none (and never shift on screens that are not compact)
14272
+ if ((vx === 0 && vy === 0) || !this.isCompactHost()) {
13569
14273
  return 'none';
13570
14274
  }
13571
14275
  // Translate content by negative offset to show the visible portion
@@ -14076,11 +14780,11 @@ class ViewportComponent {
14076
14780
  console.groupEnd();
14077
14781
  }
14078
14782
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ViewportComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
14079
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.14", type: ViewportComponent, isStandalone: true, selector: "pw-viewport", inputs: { panel: "panel", page: "page", pageBackground: "pageBackground", panels: "panels", hiddenPanelIds: "hiddenPanelIds", preloadTargets: "preloadTargets", viewMode: "viewMode", currentPanelId: "currentPanelId", locale: "locale", variableContext: "variableContext", panX: "panX", panY: "panY", zoom: "zoom", reducedMotion: "reducedMotion", transition: "transition", interactive: "interactive", showOverflowArrows: "showOverflowArrows", enableViewportCulling: "enableViewportCulling", enableLazyLoading: "enableLazyLoading", workBalloonConfig: "workBalloonConfig", characters: "characters", speechEnabled: "speechEnabled", graph: "graph", preload: "preload" }, outputs: { viewportClick: "viewportClick", performanceMetrics: "performanceMetrics", transformChange: "transformChange", layerClick: "layerClick", swipe: "swipe", navigatePrevious: "navigatePrevious", navigateNext: "navigateNext", panelDoubleClick: "panelDoubleClick", panelFocus: "panelFocus", hotspotActivate: "hotspotActivate", deadClick: "deadClick", lockedPanelActivate: "lockedPanelActivate" }, host: { listeners: { "window:resize": "onWindowResize()", "mousedown": "onMouseDown($event)", "document:mousemove": "onMouseMove($event)", "document:mouseup": "onMouseUp($event)", "wheel": "onWheel($event)", "touchstart": "onTouchStart($event)", "touchmove": "onTouchMove($event)", "touchend": "onTouchEnd($event)", "touchcancel": "onTouchEnd($event)", "keydown.tab": "onKeydownTab($event)", "keydown.arrowRight": "onKeydownNext($event)", "keydown.arrowDown": "onKeydownNext($event)", "keydown.arrowLeft": "onKeydownPrevious($event)", "keydown.arrowUp": "onKeydownPrevious($event)" } }, usesOnChanges: true, ngImport: i0, template: "<div \n class=\"viewport-container\"\n [class]=\"getViewModeClass()\"\n [class.reduced-motion]=\"reducedMotion\"\n [class.interactive]=\"interactive\"\n [style.background-color]=\"getViewportBackground()\"\n role=\"region\"\n [attr.aria-label]=\"viewMode === 'page' ? 'Page viewport' : 'Panel viewport'\"\n tabindex=\"0\"\n (click)=\"onViewportClick($event)\"\n (keydown.enter)=\"onKeyboardActivate($event)\"\n (keydown.space)=\"onKeyboardActivate($event)\"\n (mousemove)=\"onViewportMouseMove($event)\"\n (dblclick)=\"onViewportDoubleClick($event)\"\n (mouseleave)=\"onViewportMouseLeave()\">\n \n <!-- Panel View -->\n @if (viewMode === 'panel') {\n <div\n class=\"viewport-content\"\n [style.transform]=\"getTransformStyle()\"\n [style.transition]=\"reducedMotion ? 'none' : 'transform 0.3s ease'\"\n [style.--pw-t-duration]=\"transitionDuration\"\n [style.--pw-t-easing]=\"transitionEasing\">\n\n <!-- Outgoing panel during a manifest edge transition -->\n @if (leavingPanel) {\n <div [class]=\"'t-frame-leave ' + leaveAnimationClass\" aria-hidden=\"true\">\n <!-- The outgoing panel holds the end state of its animation. -->\n <div class=\"panel-container\"\n [pwPanelAnimation]=\"leavingPanel.animations\"\n [pwPanelAnimationKey]=\"leavingPanelId || ''\"\n [pwPanelAnimationStatic]=\"true\">\n <div class=\"pw-panel-camera\">\n @if (isLocked(leavingPanel, leavingPanelId)) {\n <pw-locked-panel-placeholder></pw-locked-panel-placeholder>\n } @else {\n @for (layer of leavingPanel.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n [viewMode]=\"viewMode\"\n [viewActive]=\"false\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(leavingPanelId)\"\n [panelId]=\"leavingPanelId\">\n </pw-layer-renderer>\n }\n\n @if (speechEnabled && leavingPanel.speechBubbles && leavingPanel.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"leavingPanel.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"getContainerWidth()\"\n [containerHeight]=\"getContainerHeight()\"\n [readingScale]=\"getReadingScale()\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n </div>\n </div>\n }\n\n <div [class]=\"'t-frame ' + enterAnimationClass\">\n @if (panel) {\n <div class=\"panel-container\" tabindex=\"0\"\n [pwPanelAnimation]=\"panel.animations\"\n [pwPanelAnimationKey]=\"currentPanelId || ''\"\n [pwPanelAnimationReducedMotion]=\"reducedMotion\"\n (click)=\"onPanelContainerClick($event, currentPanelId)\" (keydown.enter)=\"onPanelContainerClick($any($event), currentPanelId)\">\n <!-- Everything that belongs to the artwork; a camera move transforms it as one. -->\n <div class=\"pw-panel-camera\">\n @if (isLocked(panel, currentPanelId)) {\n <pw-locked-panel-placeholder></pw-locked-panel-placeholder>\n } @else {\n <!-- Render layers using LayerRenderer -->\n @for (layer of panel.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n [viewMode]=\"viewMode\"\n [viewActive]=\"true\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(currentPanelId)\"\n [panelId]=\"currentPanelId || ''\"\n (layerClick)=\"layerClick.emit($event)\">\n </pw-layer-renderer>\n }\n\n <!-- Interactive hotspots overlay (below speech bubbles) -->\n @if (interactive && panel.hotspots && panel.hotspots.length > 0) {\n <pw-hotspots-overlay\n [hotspots]=\"panel.hotspots\"\n [locale]=\"locale\"\n [containerWidth]=\"getContainerWidth()\"\n [containerHeight]=\"getContainerHeight()\"\n [context]=\"variableContext\"\n (hotspotActivate)=\"onHotspotActivate($event, currentPanelId)\">\n </pw-hotspots-overlay>\n }\n\n <!-- Speech bubbles overlay for panel view -->\n @if (speechEnabled && panel.speechBubbles && panel.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"panel.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"getContainerWidth()\"\n [containerHeight]=\"getContainerHeight()\"\n [readingScale]=\"getReadingScale()\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n </div>\n } @else {\n <div class=\"no-panel\">\n <p>No panel to display</p>\n </div>\n }\n </div>\n </div>\n }\n \n <!-- Page View -->\n @if (viewMode === 'page' && page) {\n <div\n class=\"viewport-page\"\n [style.--pw-page-aspect]=\"pageAspect\"\n [style.--pw-stage-aspect]=\"stageAspect\"\n [style.--pw-fit-x]=\"pageFit.x\"\n [style.--pw-fit-y]=\"pageFit.y\"\n [style.--pw-fit-w]=\"pageFit.w\"\n [style.--pw-fit-h]=\"pageFit.h\">\n <div class=\"page-stage\">\n <!-- Outgoing page during a Page.transitions in/out change -->\n @if (leavingPage) {\n <div\n [class]=\"'pt-frame-leave ' + pageLeaveClass + (pageLeaveAbove ? ' pt-frame-leave--above' : '')\"\n [style.--pw-t-duration]=\"pageLeaveDuration\"\n [style.--pw-t-easing]=\"pageLeaveEasing\"\n aria-hidden=\"true\">\n <div class=\"page-canvas\" [style.background-color]=\"pageBackgroundOf(leavingPage)\">\n @for (placement of getLeavingPagePlacements(); track placement.panelId) {\n <div\n class=\"panel-container\"\n [style.left.%]=\"toPercent(placement.x)\"\n [style.top.%]=\"toPercent(placement.y)\"\n [style.width.%]=\"toPercent(placement.w)\"\n [style.height.%]=\"toPercent(placement.h)\"\n [style.z-index]=\"placement.z || 0\"\n [style.transform]=\"getPanelTransform(placement)\"\n [style.transform-origin]=\"getTransformOrigin(placement)\">\n\n @if (isBranchHidden(placement.panelId)) {\n <div class=\"branch-placeholder\" aria-hidden=\"true\"></div>\n } @else if (getLeavingPanel(placement.panelId); as panelData) {\n <div class=\"panel-content\"\n [style.clip-path]=\"getPanelContentClipPath(placement)\"\n [style.transform]=\"getPanelContentTransform(placement)\"\n [pwPanelAnimation]=\"panelData.animations\"\n [pwPanelAnimationKey]=\"placement.panelId\"\n [pwPanelAnimationStatic]=\"true\">\n <div class=\"pw-panel-camera\">\n @if (isLocked(panelData, placement.panelId)) {\n <pw-locked-panel-placeholder></pw-locked-panel-placeholder>\n } @else {\n @for (layer of panelData.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n [viewMode]=\"viewMode\"\n [viewActive]=\"false\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(placement.panelId)\"\n [panelId]=\"placement.panelId\"\n [placementId]=\"placement.panelId\">\n </pw-layer-renderer>\n }\n\n @if (speechEnabled && panelData.speechBubbles && panelData.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"panelData.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"getPagePanelWidth(placement)\"\n [containerHeight]=\"getPagePanelHeight(placement)\"\n [readingScale]=\"getPageReadingScale(placement)\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n </div>\n }\n </div>\n }\n </div>\n </div>\n }\n\n <div\n [class]=\"'pt-frame ' + pageEnterClass\"\n [style.--pw-t-duration]=\"pageEnterDuration\"\n [style.--pw-t-easing]=\"pageEnterEasing\">\n <div\n class=\"page-canvas\"\n [style.background-color]=\"pageBackgroundOf(page)\"\n [attr.tabindex]=\"-1\"\n (click)=\"onPanelClickWithFocus($event)\"\n (keydown.enter)=\"onCanvasKeyboardActivate($event)\"\n (keydown.space)=\"onCanvasKeyboardActivate($event)\">\n \n @for (placement of getSortedPanels(); track placement.panelId) {\n @if (isPanelVisible(placement)) {\n <div \n class=\"panel-container\"\n [class.panel-focused]=\"isPanelFocused(placement.panelId)\"\n [style.left.%]=\"toPercent(placement.x)\"\n [style.top.%]=\"toPercent(placement.y)\"\n [style.width.%]=\"toPercent(placement.w)\"\n [style.height.%]=\"toPercent(placement.h)\"\n [style.z-index]=\"placement.z || 0\"\n [style.transform]=\"getPanelTransform(placement)\"\n [style.transform-origin]=\"getTransformOrigin(placement)\"\n [attr.data-panel-id]=\"placement.panelId\"\n [class.panel-branch-hidden]=\"isBranchHidden(placement.panelId)\"\n [attr.aria-hidden]=\"isBranchHidden(placement.panelId) ? 'true' : null\"\n [attr.tabindex]=\"isBranchHidden(placement.panelId) || panelHasHotspots(placement.panelId) ? null : 0\"\n [attr.role]=\"isBranchHidden(placement.panelId) || panelHasHotspots(placement.panelId) ? null : 'button'\"\n [attr.aria-label]=\"isBranchHidden(placement.panelId) || panelHasHotspots(placement.panelId) ? null : 'Panel ' + placement.panelId\"\n [attr.loading]=\"getImageLoadingStrategy()\"\n (click)=\"onPanelClick($event, placement.panelId)\"\n (keydown.enter)=\"onPanelKeyboardActivate($event, placement.panelId)\"\n (keydown.space)=\"onPanelKeyboardActivate($event, placement.panelId)\">\n \n @if (isBranchHidden(placement.panelId)) {\n <!-- Not on the reader's path (yet): a grey box keeps the page's shape. -->\n <div class=\"branch-placeholder\"></div>\n } @else if (getPanel(placement.panelId); as panelData) {\n <div class=\"panel-content\"\n [style.clip-path]=\"getPanelContentClipPath(placement)\"\n [style.transform]=\"getPanelContentTransform(placement)\"\n [pwPanelAnimation]=\"panelData.animations\"\n [pwPanelAnimationKey]=\"placement.panelId\"\n [pwPanelAnimationReducedMotion]=\"reducedMotion\"\n tabindex=\"0\"\n (click)=\"onPanelContainerClick($event, placement.panelId)\"\n (keydown.enter)=\"onPanelContainerClick($any($event), placement.panelId)\">\n <div class=\"pw-panel-camera\">\n @if (isLocked(panelData, placement.panelId)) {\n <pw-locked-panel-placeholder\n [actionable]=\"true\"\n (activate)=\"lockedPanelActivate.emit(placement.panelId)\"></pw-locked-panel-placeholder>\n } @else {\n <!-- Render panel layers -->\n @for (layer of panelData.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n [altText]=\"getAltText(layer)\"\n [viewMode]=\"viewMode\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(placement.panelId)\"\n [panelId]=\"placement.panelId\"\n [placementId]=\"placement.panelId\"\n [readingOrderIndex]=\"readingOrderIndexOf(placement.panelId)\"\n [placementZ]=\"placement.z || 0\"\n [placementY]=\"placement.y\"\n [placementX]=\"placement.x\"\n (layerClick)=\"layerClick.emit($event)\">\n </pw-layer-renderer>\n }\n\n <!-- Interactive hotspots overlay for page view panels -->\n @if (interactive && panelData.hotspots && panelData.hotspots.length > 0) {\n <pw-hotspots-overlay\n [hotspots]=\"panelData.hotspots\"\n [locale]=\"locale\"\n [containerWidth]=\"getPagePanelWidth(placement)\"\n [containerHeight]=\"getPagePanelHeight(placement)\"\n [context]=\"variableContext\"\n (hotspotActivate)=\"onHotspotActivate($event, placement.panelId)\">\n </pw-hotspots-overlay>\n }\n\n <!-- Speech bubbles overlay for page view panels -->\n @if (speechEnabled && panelData.speechBubbles && panelData.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"panelData.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"getPagePanelWidth(placement)\"\n [containerHeight]=\"getPagePanelHeight(placement)\"\n [readingScale]=\"getPageReadingScale(placement)\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n </div>\n }\n \n <!-- Focus indicator for rotated panels -->\n @if (isPanelFocused(placement.panelId)) {\n <div class=\"focus-rect\" aria-hidden=\"true\"></div>\n }\n </div>\n }\n }\n </div>\n </div>\n </div>\n </div>\n }\n\n @if (viewMode === 'page' && !page) {\n <div class=\"no-panel\">\n <p>No page to display</p>\n </div>\n }\n\n <!-- Overflow navigation arrows -->\n @if (showOverflowArrows && panel) {\n @if (hasOverflowLeft) {\n <button \n class=\"overflow-arrow overflow-left\"\n type=\"button\"\n aria-label=\"Navigate left\"\n (click)=\"navigateLeft()\">\n <pw-icon name=\"lucideChevronLeft\" />\n </button>\n }\n\n @if (hasOverflowRight) {\n <button \n class=\"overflow-arrow overflow-right\"\n type=\"button\"\n aria-label=\"Navigate right\"\n (click)=\"navigateRight()\">\n <pw-icon name=\"lucideChevronRight\" />\n </button>\n }\n\n @if (hasOverflowTop) {\n <button \n class=\"overflow-arrow overflow-top\"\n type=\"button\"\n aria-label=\"Navigate up\"\n (click)=\"navigateUp()\">\n <pw-icon name=\"lucideChevronUp\" />\n </button>\n }\n\n @if (hasOverflowBottom) {\n <button \n class=\"overflow-arrow overflow-bottom\"\n type=\"button\"\n aria-label=\"Navigate down\"\n (click)=\"navigateDown()\">\n <pw-icon name=\"lucideChevronDown\" />\n </button>\n }\n }\n\n <!-- Navigation arrows (hover zones) -->\n @if (viewMode === 'panel' || (viewMode === 'page' && page)) {\n @if (showLeftArrow) {\n <button \n class=\"nav-arrow nav-arrow-left\"\n type=\"button\"\n [attr.aria-label]=\"viewMode === 'page' ? 'Previous page' : 'Previous panel'\"\n (click)=\"onNavigatePrevious(); $event.stopPropagation()\">\n <span class=\"arrow-icon\"><pw-icon name=\"lucideChevronLeft\" /></span>\n </button>\n }\n\n @if (showRightArrow) {\n <button \n class=\"nav-arrow nav-arrow-right\"\n type=\"button\"\n [attr.aria-label]=\"viewMode === 'page' ? 'Next page' : 'Next panel'\"\n (click)=\"onNavigateNext(); $event.stopPropagation()\">\n <span class=\"arrow-icon\"><pw-icon name=\"lucideChevronRight\" /></span>\n </button>\n }\n }\n</div>\n", styles: [".viewport-container{position:relative;width:100%;height:100%;overflow:hidden;background-color:#1a1a1a;cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none}.viewport-container:active{cursor:grabbing}.viewport-container.interactive{cursor:default}.viewport-content{position:absolute;top:50%;left:50%;transform-origin:center center;will-change:transform}.viewport-content.reduced-motion{transition:none!important}.panel-container{position:relative;background:transparent;width:1280px;height:720px;max-width:100%;max-height:100%}.pw-panel-camera{position:absolute;inset:0}.t-frame{position:relative;z-index:1}.t-frame-leave{position:absolute;inset:0;z-index:0;pointer-events:none}.t-fade-in{animation:pw-t-fade-in var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-zoom-in{animation:pw-t-zoom-in var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-from-right{animation:pw-t-from-right var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-from-left{animation:pw-t-from-left var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-from-bottom{animation:pw-t-from-bottom var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-from-top{animation:pw-t-from-top var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-to-left{animation:pw-t-to-left var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-to-right{animation:pw-t-to-right var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-to-top{animation:pw-t-to-top var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-to-bottom{animation:pw-t-to-bottom var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}@keyframes pw-t-fade-in{0%{opacity:0}to{opacity:1}}@keyframes pw-t-zoom-in{0%{opacity:0;transform:scale(.94)}to{opacity:1;transform:scale(1)}}@keyframes pw-t-from-right{0%{transform:translate(100%)}to{transform:translate(0)}}@keyframes pw-t-from-left{0%{transform:translate(-100%)}to{transform:translate(0)}}@keyframes pw-t-from-bottom{0%{transform:translateY(100%)}to{transform:translateY(0)}}@keyframes pw-t-from-top{0%{transform:translateY(-100%)}to{transform:translateY(0)}}@keyframes pw-t-to-left{0%{transform:translate(0)}to{transform:translate(-100%)}}@keyframes pw-t-to-right{0%{transform:translate(0)}to{transform:translate(100%)}}@keyframes pw-t-to-top{0%{transform:translateY(0)}to{transform:translateY(-100%)}}@keyframes pw-t-to-bottom{0%{transform:translateY(0)}to{transform:translateY(100%)}}.layer{position:absolute;top:0;left:0}.layer-placeholder{padding:20px;background:#0078d71a;border:1px dashed rgba(0,120,215,.3);color:#0078d7;font-size:14px}.no-panel{display:flex;align-items:center;justify-content:center;min-width:400px;min-height:300px;color:#666}.reduced-motion *{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}.overflow-arrow{position:absolute;z-index:100;background:#0009;color:#fff;border:none;border-radius:4px;width:40px;height:40px;font-size:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s ease;box-shadow:0 2px 8px #0000004d}.overflow-arrow:hover{background:#000c;transform:scale(1.1)}.overflow-arrow:active{transform:scale(.95)}.overflow-left{left:20px;top:50%;transform:translateY(-50%)}.overflow-left:hover{transform:translateY(-50%) scale(1.1)}.overflow-right{right:20px;top:50%;transform:translateY(-50%)}.overflow-right:hover{transform:translateY(-50%) scale(1.1)}.overflow-top{top:20px;left:50%;transform:translate(-50%)}.overflow-top:hover{transform:translate(-50%) scale(1.1)}.overflow-bottom{bottom:20px;left:50%;transform:translate(-50%)}.overflow-bottom:hover{transform:translate(-50%) scale(1.1)}.viewport-page{width:100%;height:100%;display:flex;align-items:center;justify-content:center;overflow:hidden;padding:0;box-sizing:border-box;container-type:size}.page-canvas{position:relative;width:100%;aspect-ratio:var(--pw-page-aspect, 16 / 9);background:#1a1a1a;overflow:hidden}.page-stage{position:relative;width:min(100cqw,100cqh * var(--pw-stage-aspect, var(--pw-page-aspect, 1.7778)));aspect-ratio:var(--pw-stage-aspect, var(--pw-page-aspect, 16 / 9));flex:none;overflow:hidden}.page-stage .page-canvas{position:absolute;left:calc(-100% * var(--pw-fit-x, 0) / var(--pw-fit-w, 1));top:calc(-100% * var(--pw-fit-y, 0) / var(--pw-fit-h, 1));width:calc(100% / var(--pw-fit-w, 1));height:calc(100% / var(--pw-fit-h, 1));max-width:none;aspect-ratio:auto}.pt-frame{position:relative;z-index:1;width:100%;height:100%}.pt-frame-leave{position:absolute;inset:0;z-index:0;pointer-events:none}.pt-frame-leave--above{z-index:2}.pt-frame.t-fade-in,.pt-frame.t-zoom-in,.pt-frame.t-from-right,.pt-frame.t-from-left,.pt-frame.t-from-bottom,.pt-frame.t-from-top{will-change:transform,opacity}.t-fade-out{animation:pw-t-fade-out var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-zoom-out{animation:pw-t-zoom-out var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}@keyframes pw-t-fade-out{0%{opacity:1}to{opacity:0}}@keyframes pw-t-zoom-out{0%{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(1.06)}}.branch-placeholder{position:absolute;inset:0;background:#c9ccd1;border-radius:inherit}.viewport-page .panel-container.panel-branch-hidden{pointer-events:none}.viewport-page .panel-container{position:absolute;overflow:hidden;background:#000;border:1px solid rgba(255,255,255,.1);border-radius:2px;transition:transform .2s ease,border-color .2s ease,box-shadow .2s ease;transform-origin:center center;outline:none}.viewport-page .panel-container:hover{border-color:#ffffff4d;cursor:pointer}.viewport-page .panel-container.panel-focused{border-color:#0078d7cc;box-shadow:0 0 0 3px #0078d74d,0 4px 12px #0006;z-index:9999!important}.focus-rect{position:absolute;inset:-4px;border:3px solid rgba(0,120,215,.9);border-radius:4px;pointer-events:none;animation:focusPulse 1.5s ease-in-out infinite}@keyframes focusPulse{0%,to{opacity:1;transform:scale(1)}50%{opacity:.7;transform:scale(1.02)}}@media (prefers-reduced-motion: reduce){.focus-rect{animation:none}.viewport-page .panel-container{transition:none}}.viewport-page .panel-content{position:relative;width:100%;height:100%}.nav-arrow{position:absolute;top:50%;transform:translateY(-50%);width:60px;height:120px;background:#0006;border:none;color:#fff;font-size:48px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .3s ease;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);z-index:50;animation:fadeInArrow .2s ease}.nav-arrow:hover{background:#0009;width:70px}.nav-arrow:active{background:#000000b3;transform:translateY(-50%) scale(.95)}.nav-arrow-left{left:0;border-radius:0 8px 8px 0}.nav-arrow-right{right:0;border-radius:8px 0 0 8px}.arrow-icon{font-size:48px;line-height:1;font-weight:300}@keyframes fadeInArrow{0%{opacity:0}to{opacity:1}}@media (prefers-reduced-motion: reduce){.nav-arrow{animation:none;transition:none}}@media (max-width: 768px){.nav-arrow{width:50px;height:100px;font-size:36px}.nav-arrow:hover{width:55px}.arrow-icon{font-size:36px}}\n"], dependencies: [{ kind: "component", type: LayerRendererComponent, selector: "pw-layer-renderer", inputs: ["layer", "locale", "targetWidth", "altText", "viewMode", "viewActive", "reducedMotion", "panelId", "placementId", "readingOrderIndex", "placementZ", "placementY", "placementX"], outputs: ["layerClick"] }, { kind: "component", type: SpeechBubblesComponent, selector: "pw-speech-bubbles", inputs: ["bubbles", "locale", "containerWidth", "containerHeight", "readingScale", "workBalloonConfig", "characters"], outputs: ["bubbleClick", "bubbleAudioPlay"] }, { kind: "component", type: HotspotsOverlayComponent, selector: "pw-hotspots-overlay", inputs: ["hotspots", "locale", "containerWidth", "containerHeight", "context", "interactive", "showIndicators"], outputs: ["hotspotActivate"] }, { kind: "component", type: PwIconComponent, selector: "pw-icon", inputs: ["name"] }, { kind: "directive", type: PanelAnimationDirective, selector: "[pwPanelAnimation]", inputs: ["pwPanelAnimation", "pwPanelAnimationKey", "pwPanelAnimationReducedMotion", "pwPanelAnimationStatic"] }, { kind: "component", type: LockedPanelComponent, selector: "pw-locked-panel-placeholder", inputs: ["actionable"], outputs: ["activate"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
14783
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.14", type: ViewportComponent, isStandalone: true, selector: "pw-viewport", inputs: { panel: "panel", page: "page", pageBackground: "pageBackground", panels: "panels", hiddenPanelIds: "hiddenPanelIds", preloadTargets: "preloadTargets", viewMode: "viewMode", currentPanelId: "currentPanelId", locale: "locale", variableContext: "variableContext", panX: "panX", panY: "panY", zoom: "zoom", reducedMotion: "reducedMotion", transition: "transition", interactive: "interactive", showOverflowArrows: "showOverflowArrows", enableViewportCulling: "enableViewportCulling", enableLazyLoading: "enableLazyLoading", workBalloonConfig: "workBalloonConfig", characters: "characters", speechEnabled: "speechEnabled", graph: "graph", preload: "preload" }, outputs: { viewportClick: "viewportClick", performanceMetrics: "performanceMetrics", transformChange: "transformChange", layerClick: "layerClick", swipe: "swipe", navigatePrevious: "navigatePrevious", navigateNext: "navigateNext", panelDoubleClick: "panelDoubleClick", panelFocus: "panelFocus", hotspotActivate: "hotspotActivate", deadClick: "deadClick", lockedPanelActivate: "lockedPanelActivate" }, host: { listeners: { "window:resize": "onWindowResize()", "mousedown": "onMouseDown($event)", "document:mousemove": "onMouseMove($event)", "document:mouseup": "onMouseUp($event)", "wheel": "onWheel($event)", "touchstart": "onTouchStart($event)", "touchmove": "onTouchMove($event)", "touchend": "onTouchEnd($event)", "touchcancel": "onTouchEnd($event)", "keydown.tab": "onKeydownTab($event)", "keydown.arrowRight": "onKeydownNext($event)", "keydown.arrowDown": "onKeydownNext($event)", "keydown.arrowLeft": "onKeydownPrevious($event)", "keydown.arrowUp": "onKeydownPrevious($event)" } }, usesOnChanges: true, ngImport: i0, template: "<div \n class=\"viewport-container\"\n [class]=\"getViewModeClass()\"\n [class.reduced-motion]=\"reducedMotion\"\n [class.interactive]=\"interactive\"\n [style.background-color]=\"getViewportBackground()\"\n role=\"region\"\n [attr.aria-label]=\"viewMode === 'page' ? 'Page viewport' : 'Panel viewport'\"\n tabindex=\"0\"\n (click)=\"onViewportClick($event)\"\n (keydown.enter)=\"onKeyboardActivate($event)\"\n (keydown.space)=\"onKeyboardActivate($event)\"\n (mousemove)=\"onViewportMouseMove($event)\"\n (dblclick)=\"onViewportDoubleClick($event)\"\n (mouseleave)=\"onViewportMouseLeave()\">\n \n <!-- Panel View -->\n @if (viewMode === 'panel') {\n <div\n class=\"viewport-content\"\n [style.transform]=\"getTransformStyle()\"\n [style.transition]=\"reducedMotion ? 'none' : 'transform 0.3s ease'\"\n [style.--pw-t-duration]=\"transitionDuration\"\n [style.--pw-t-easing]=\"transitionEasing\">\n\n <!-- Outgoing panel during a manifest edge transition -->\n @if (leavingPanel) {\n <div [class]=\"'t-frame-leave ' + leaveAnimationClass\" aria-hidden=\"true\">\n <!-- The outgoing panel holds the end state of its animation. -->\n <div class=\"panel-container\"\n [style.width.px]=\"panelBox?.width\"\n [style.height.px]=\"panelBox?.height\"\n [pwPanelAnimation]=\"leavingPanel.animations\"\n [pwPanelAnimationKey]=\"leavingPanelId || ''\"\n [pwPanelAnimationStatic]=\"true\">\n <div class=\"pw-panel-camera\">\n @if (isLocked(leavingPanel, leavingPanelId)) {\n <pw-locked-panel-placeholder></pw-locked-panel-placeholder>\n } @else {\n @for (layer of leavingPanel.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n [viewMode]=\"viewMode\"\n [viewActive]=\"false\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(leavingPanelId)\"\n [panelId]=\"leavingPanelId\">\n </pw-layer-renderer>\n }\n\n @if (speechEnabled && leavingPanel.speechBubbles && leavingPanel.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"leavingPanel.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"getContainerWidth()\"\n [containerHeight]=\"getContainerHeight()\"\n [readingScale]=\"getReadingScale()\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n </div>\n </div>\n }\n\n <div [class]=\"'t-frame ' + enterAnimationClass\">\n @if (panel) {\n <div class=\"panel-container\" tabindex=\"0\"\n [style.width.px]=\"panelBox?.width\"\n [style.height.px]=\"panelBox?.height\"\n [pwPanelAnimation]=\"panel.animations\"\n [pwPanelAnimationKey]=\"currentPanelId || ''\"\n [pwPanelAnimationReducedMotion]=\"reducedMotion\"\n (click)=\"onPanelContainerClick($event, currentPanelId)\" (keydown.enter)=\"onPanelContainerClick($any($event), currentPanelId)\">\n <!-- Everything that belongs to the artwork; a camera move transforms it as one. -->\n <div class=\"pw-panel-camera\">\n @if (isLocked(panel, currentPanelId)) {\n <pw-locked-panel-placeholder></pw-locked-panel-placeholder>\n } @else {\n <!-- Render layers using LayerRenderer -->\n @for (layer of panel.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n [viewMode]=\"viewMode\"\n [viewActive]=\"true\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(currentPanelId)\"\n [panelId]=\"currentPanelId || ''\"\n (layerClick)=\"layerClick.emit($event)\">\n </pw-layer-renderer>\n }\n\n <!-- Interactive hotspots overlay (below speech bubbles) -->\n @if (interactive && panel.hotspots && panel.hotspots.length > 0) {\n <pw-hotspots-overlay\n [hotspots]=\"panel.hotspots\"\n [bubbles]=\"panel.speechBubbles ?? []\"\n [locale]=\"locale\"\n [containerWidth]=\"getContainerWidth()\"\n [containerHeight]=\"getContainerHeight()\"\n [context]=\"variableContext\"\n (hotspotActivate)=\"onHotspotActivate($event, currentPanelId)\">\n </pw-hotspots-overlay>\n }\n\n <!-- Speech bubbles overlay for panel view -->\n @if (speechEnabled && panel.speechBubbles && panel.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"panel.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"getContainerWidth()\"\n [containerHeight]=\"getContainerHeight()\"\n [readingScale]=\"getReadingScale()\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n </div>\n } @else {\n <div class=\"no-panel\">\n <p>No panel to display</p>\n </div>\n }\n </div>\n </div>\n }\n \n <!-- Page View -->\n @if (viewMode === 'page' && page) {\n <div\n class=\"viewport-page\"\n [style.--pw-page-aspect]=\"pageAspect\"\n [style.--pw-stage-aspect]=\"stageAspect\"\n [style.--pw-fit-x]=\"pageFit.x\"\n [style.--pw-fit-y]=\"pageFit.y\"\n [style.--pw-fit-w]=\"pageFit.w\"\n [style.--pw-fit-h]=\"pageFit.h\">\n <div class=\"page-stage\">\n <!-- Outgoing page during a Page.transitions in/out change -->\n @if (leavingPage) {\n <div\n [class]=\"'pt-frame-leave ' + pageLeaveClass + (pageLeaveAbove ? ' pt-frame-leave--above' : '')\"\n [style.--pw-t-duration]=\"pageLeaveDuration\"\n [style.--pw-t-easing]=\"pageLeaveEasing\"\n aria-hidden=\"true\">\n <div class=\"page-canvas\" [style.background-color]=\"pageBackgroundOf(leavingPage)\">\n @for (placement of getLeavingPagePlacements(); track placement.panelId) {\n <div\n class=\"panel-container\"\n [style.left.%]=\"toPercent(placement.x)\"\n [style.top.%]=\"toPercent(placement.y)\"\n [style.width.%]=\"toPercent(placement.w)\"\n [style.height.%]=\"toPercent(placement.h)\"\n [style.z-index]=\"placement.z || 0\"\n [style.transform]=\"getPanelTransform(placement)\"\n [style.transform-origin]=\"getTransformOrigin(placement)\">\n\n @if (isBranchHidden(placement.panelId)) {\n <div class=\"branch-placeholder\" aria-hidden=\"true\"></div>\n } @else if (getLeavingPanel(placement.panelId); as panelData) {\n <div class=\"panel-content\"\n [style.clip-path]=\"getPanelContentClipPath(placement)\"\n [style.transform]=\"getPanelContentTransform(placement)\"\n [pwPanelAnimation]=\"panelData.animations\"\n [pwPanelAnimationKey]=\"placement.panelId\"\n [pwPanelAnimationStatic]=\"true\">\n <div class=\"pw-panel-camera\">\n @if (isLocked(panelData, placement.panelId)) {\n <pw-locked-panel-placeholder></pw-locked-panel-placeholder>\n } @else {\n @for (layer of panelData.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n [viewMode]=\"viewMode\"\n [viewActive]=\"false\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(placement.panelId)\"\n [panelId]=\"placement.panelId\"\n [placementId]=\"placement.panelId\">\n </pw-layer-renderer>\n }\n\n @if (speechEnabled && panelData.speechBubbles && panelData.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"panelData.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"getPagePanelWidth(placement)\"\n [containerHeight]=\"getPagePanelHeight(placement)\"\n [readingScale]=\"getPageReadingScale(placement)\"\n [anchorTailTip]=\"true\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n </div>\n }\n </div>\n }\n </div>\n </div>\n }\n\n <div\n [class]=\"'pt-frame ' + pageEnterClass\"\n [style.--pw-t-duration]=\"pageEnterDuration\"\n [style.--pw-t-easing]=\"pageEnterEasing\">\n <div\n class=\"page-canvas\"\n [style.background-color]=\"pageBackgroundOf(page)\"\n [attr.tabindex]=\"-1\"\n (click)=\"onPanelClickWithFocus($event)\"\n (keydown.enter)=\"onCanvasKeyboardActivate($event)\"\n (keydown.space)=\"onCanvasKeyboardActivate($event)\">\n \n @for (placement of getSortedPanels(); track placement.panelId) {\n @if (isPanelVisible(placement)) {\n <div \n class=\"panel-container\"\n [class.panel-focused]=\"isPanelFocused(placement.panelId)\"\n [style.left.%]=\"toPercent(placement.x)\"\n [style.top.%]=\"toPercent(placement.y)\"\n [style.width.%]=\"toPercent(placement.w)\"\n [style.height.%]=\"toPercent(placement.h)\"\n [style.z-index]=\"placement.z || 0\"\n [style.transform]=\"getPanelTransform(placement)\"\n [style.transform-origin]=\"getTransformOrigin(placement)\"\n [attr.data-panel-id]=\"placement.panelId\"\n [class.panel-branch-hidden]=\"isBranchHidden(placement.panelId)\"\n [attr.aria-hidden]=\"isBranchHidden(placement.panelId) ? 'true' : null\"\n [attr.tabindex]=\"isBranchHidden(placement.panelId) || panelHasHotspots(placement.panelId) ? null : 0\"\n [attr.role]=\"isBranchHidden(placement.panelId) || panelHasHotspots(placement.panelId) ? null : 'button'\"\n [attr.aria-label]=\"isBranchHidden(placement.panelId) || panelHasHotspots(placement.panelId) ? null : 'Panel ' + placement.panelId\"\n [attr.loading]=\"getImageLoadingStrategy()\"\n (click)=\"onPanelClick($event, placement.panelId)\"\n (keydown.enter)=\"onPanelKeyboardActivate($event, placement.panelId)\"\n (keydown.space)=\"onPanelKeyboardActivate($event, placement.panelId)\">\n \n @if (isBranchHidden(placement.panelId)) {\n <!-- Not on the reader's path (yet): a grey box keeps the page's shape. -->\n <div class=\"branch-placeholder\"></div>\n } @else if (getPanel(placement.panelId); as panelData) {\n <div class=\"panel-content\"\n [style.clip-path]=\"getPanelContentClipPath(placement)\"\n [style.transform]=\"getPanelContentTransform(placement)\"\n [pwPanelAnimation]=\"panelData.animations\"\n [pwPanelAnimationKey]=\"placement.panelId\"\n [pwPanelAnimationReducedMotion]=\"reducedMotion\"\n tabindex=\"0\"\n (click)=\"onPanelContainerClick($event, placement.panelId)\"\n (keydown.enter)=\"onPanelContainerClick($any($event), placement.panelId)\">\n <div class=\"pw-panel-camera\">\n @if (isLocked(panelData, placement.panelId)) {\n <pw-locked-panel-placeholder\n [actionable]=\"true\"\n (activate)=\"lockedPanelActivate.emit(placement.panelId)\"></pw-locked-panel-placeholder>\n } @else {\n <!-- Render panel layers -->\n @for (layer of panelData.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n [altText]=\"getAltText(layer)\"\n [viewMode]=\"viewMode\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(placement.panelId)\"\n [panelId]=\"placement.panelId\"\n [placementId]=\"placement.panelId\"\n [readingOrderIndex]=\"readingOrderIndexOf(placement.panelId)\"\n [placementZ]=\"placement.z || 0\"\n [placementY]=\"placement.y\"\n [placementX]=\"placement.x\"\n (layerClick)=\"layerClick.emit($event)\">\n </pw-layer-renderer>\n }\n\n <!-- Interactive hotspots overlay for page view panels -->\n @if (interactive && panelData.hotspots && panelData.hotspots.length > 0) {\n <pw-hotspots-overlay\n [hotspots]=\"panelData.hotspots\"\n [bubbles]=\"panelData.speechBubbles ?? []\"\n [locale]=\"locale\"\n [containerWidth]=\"getPagePanelWidth(placement)\"\n [containerHeight]=\"getPagePanelHeight(placement)\"\n [context]=\"variableContext\"\n (hotspotActivate)=\"onHotspotActivate($event, placement.panelId)\">\n </pw-hotspots-overlay>\n }\n\n <!-- Speech bubbles overlay for page view panels -->\n @if (speechEnabled && panelData.speechBubbles && panelData.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"panelData.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"getPagePanelWidth(placement)\"\n [containerHeight]=\"getPagePanelHeight(placement)\"\n [readingScale]=\"getPageReadingScale(placement)\"\n [anchorTailTip]=\"true\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n </div>\n }\n \n <!-- Focus indicator for rotated panels -->\n @if (isPanelFocused(placement.panelId)) {\n <div class=\"focus-rect\" aria-hidden=\"true\"></div>\n }\n </div>\n }\n }\n </div>\n </div>\n </div>\n </div>\n }\n\n @if (viewMode === 'page' && !page) {\n <div class=\"no-panel\">\n <p>No page to display</p>\n </div>\n }\n\n <!-- Overflow navigation arrows -->\n @if (showOverflowArrows && panel) {\n @if (hasOverflowLeft) {\n <button \n class=\"overflow-arrow overflow-left\"\n type=\"button\"\n aria-label=\"Navigate left\"\n (click)=\"navigateLeft()\">\n <pw-icon name=\"lucideChevronLeft\" />\n </button>\n }\n\n @if (hasOverflowRight) {\n <button \n class=\"overflow-arrow overflow-right\"\n type=\"button\"\n aria-label=\"Navigate right\"\n (click)=\"navigateRight()\">\n <pw-icon name=\"lucideChevronRight\" />\n </button>\n }\n\n @if (hasOverflowTop) {\n <button \n class=\"overflow-arrow overflow-top\"\n type=\"button\"\n aria-label=\"Navigate up\"\n (click)=\"navigateUp()\">\n <pw-icon name=\"lucideChevronUp\" />\n </button>\n }\n\n @if (hasOverflowBottom) {\n <button \n class=\"overflow-arrow overflow-bottom\"\n type=\"button\"\n aria-label=\"Navigate down\"\n (click)=\"navigateDown()\">\n <pw-icon name=\"lucideChevronDown\" />\n </button>\n }\n }\n\n <!-- Navigation arrows (hover zones) -->\n @if (viewMode === 'panel' || (viewMode === 'page' && page)) {\n @if (showLeftArrow) {\n <button \n class=\"nav-arrow nav-arrow-left\"\n type=\"button\"\n [attr.aria-label]=\"viewMode === 'page' ? 'Previous page' : 'Previous panel'\"\n (click)=\"onNavigatePrevious(); $event.stopPropagation()\">\n <span class=\"arrow-icon\"><pw-icon name=\"lucideChevronLeft\" /></span>\n </button>\n }\n\n @if (showRightArrow) {\n <button \n class=\"nav-arrow nav-arrow-right\"\n type=\"button\"\n [attr.aria-label]=\"viewMode === 'page' ? 'Next page' : 'Next panel'\"\n (click)=\"onNavigateNext(); $event.stopPropagation()\">\n <span class=\"arrow-icon\"><pw-icon name=\"lucideChevronRight\" /></span>\n </button>\n }\n }\n</div>\n", styles: [".viewport-container{position:relative;width:100%;height:100%;overflow:hidden;background-color:#1a1a1a;cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none}.viewport-container:active{cursor:grabbing}.viewport-container.interactive{cursor:default}.viewport-content{position:absolute;top:50%;left:50%;transform-origin:center center;will-change:transform}.viewport-content.reduced-motion{transition:none!important}.panel-container{position:relative;background:transparent;width:1280px;height:720px;max-width:100%;max-height:100%}.pw-panel-camera{position:absolute;inset:0}.t-frame{position:relative;z-index:1}.t-frame-leave{position:absolute;inset:0;z-index:0;pointer-events:none}.t-fade-in{animation:pw-t-fade-in var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-zoom-in{animation:pw-t-zoom-in var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-from-right{animation:pw-t-from-right var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-from-left{animation:pw-t-from-left var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-from-bottom{animation:pw-t-from-bottom var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-from-top{animation:pw-t-from-top var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-to-left{animation:pw-t-to-left var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-to-right{animation:pw-t-to-right var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-to-top{animation:pw-t-to-top var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-to-bottom{animation:pw-t-to-bottom var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}@keyframes pw-t-fade-in{0%{opacity:0}to{opacity:1}}@keyframes pw-t-zoom-in{0%{opacity:0;transform:scale(.94)}to{opacity:1;transform:scale(1)}}@keyframes pw-t-from-right{0%{transform:translate(100%)}to{transform:translate(0)}}@keyframes pw-t-from-left{0%{transform:translate(-100%)}to{transform:translate(0)}}@keyframes pw-t-from-bottom{0%{transform:translateY(100%)}to{transform:translateY(0)}}@keyframes pw-t-from-top{0%{transform:translateY(-100%)}to{transform:translateY(0)}}@keyframes pw-t-to-left{0%{transform:translate(0)}to{transform:translate(-100%)}}@keyframes pw-t-to-right{0%{transform:translate(0)}to{transform:translate(100%)}}@keyframes pw-t-to-top{0%{transform:translateY(0)}to{transform:translateY(-100%)}}@keyframes pw-t-to-bottom{0%{transform:translateY(0)}to{transform:translateY(100%)}}.layer{position:absolute;top:0;left:0}.layer-placeholder{padding:20px;background:#0078d71a;border:1px dashed rgba(0,120,215,.3);color:#0078d7;font-size:14px}.no-panel{display:flex;align-items:center;justify-content:center;min-width:400px;min-height:300px;color:#666}.reduced-motion *{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}.overflow-arrow{position:absolute;z-index:100;background:#0009;color:#fff;border:none;border-radius:4px;width:40px;height:40px;font-size:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s ease;box-shadow:0 2px 8px #0000004d}.overflow-arrow:hover{background:#000c;transform:scale(1.1)}.overflow-arrow:active{transform:scale(.95)}.overflow-left{left:20px;top:50%;transform:translateY(-50%)}.overflow-left:hover{transform:translateY(-50%) scale(1.1)}.overflow-right{right:20px;top:50%;transform:translateY(-50%)}.overflow-right:hover{transform:translateY(-50%) scale(1.1)}.overflow-top{top:20px;left:50%;transform:translate(-50%)}.overflow-top:hover{transform:translate(-50%) scale(1.1)}.overflow-bottom{bottom:20px;left:50%;transform:translate(-50%)}.overflow-bottom:hover{transform:translate(-50%) scale(1.1)}.viewport-page{width:100%;height:100%;display:flex;align-items:center;justify-content:center;overflow:hidden;padding:0;box-sizing:border-box;container-type:size}.page-canvas{position:relative;width:100%;aspect-ratio:var(--pw-page-aspect, 16 / 9);background:#1a1a1a;overflow:hidden}.page-stage{position:relative;width:min(100cqw,100cqh * var(--pw-stage-aspect, var(--pw-page-aspect, 1.7778)));aspect-ratio:var(--pw-stage-aspect, var(--pw-page-aspect, 16 / 9));flex:none;overflow:hidden}.page-stage .page-canvas{position:absolute;left:calc(-100% * var(--pw-fit-x, 0) / var(--pw-fit-w, 1));top:calc(-100% * var(--pw-fit-y, 0) / var(--pw-fit-h, 1));width:calc(100% / var(--pw-fit-w, 1));height:calc(100% / var(--pw-fit-h, 1));max-width:none;aspect-ratio:auto}.pt-frame{position:relative;z-index:1;width:100%;height:100%}.pt-frame-leave{position:absolute;inset:0;z-index:0;pointer-events:none}.pt-frame-leave--above{z-index:2}.pt-frame.t-fade-in,.pt-frame.t-zoom-in,.pt-frame.t-from-right,.pt-frame.t-from-left,.pt-frame.t-from-bottom,.pt-frame.t-from-top{will-change:transform,opacity}.t-fade-out{animation:pw-t-fade-out var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-zoom-out{animation:pw-t-zoom-out var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}@keyframes pw-t-fade-out{0%{opacity:1}to{opacity:0}}@keyframes pw-t-zoom-out{0%{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(1.06)}}.branch-placeholder{position:absolute;inset:0;background:#c9ccd1;border-radius:inherit}.viewport-page .panel-container.panel-branch-hidden{pointer-events:none}.viewport-page .panel-container{position:absolute;overflow:hidden;background:#000;border:1px solid rgba(255,255,255,.1);border-radius:2px;transition:transform .2s ease,border-color .2s ease,box-shadow .2s ease;transform-origin:center center;outline:none}.viewport-page .panel-container:hover{border-color:#ffffff4d;cursor:pointer}.viewport-page .panel-container.panel-focused{border-color:#0078d7cc;box-shadow:0 0 0 3px #0078d74d,0 4px 12px #0006;z-index:9999!important}.focus-rect{position:absolute;inset:-4px;border:3px solid rgba(0,120,215,.9);border-radius:4px;pointer-events:none;animation:focusPulse 1.5s ease-in-out infinite}@keyframes focusPulse{0%,to{opacity:1;transform:scale(1)}50%{opacity:.7;transform:scale(1.02)}}@media (prefers-reduced-motion: reduce){.focus-rect{animation:none}.viewport-page .panel-container{transition:none}}.viewport-page .panel-content{position:relative;width:100%;height:100%}.nav-arrow{position:absolute;top:50%;transform:translateY(-50%);width:60px;height:120px;background:#0006;border:none;color:#fff;font-size:48px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .3s ease;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);z-index:50;animation:fadeInArrow .2s ease}.nav-arrow:hover{background:#0009;width:70px}.nav-arrow:active{background:#000000b3;transform:translateY(-50%) scale(.95)}.nav-arrow-left{left:0;border-radius:0 8px 8px 0}.nav-arrow-right{right:0;border-radius:8px 0 0 8px}.arrow-icon{font-size:48px;line-height:1;font-weight:300}@keyframes fadeInArrow{0%{opacity:0}to{opacity:1}}@media (prefers-reduced-motion: reduce){.nav-arrow{animation:none;transition:none}}@media (max-width: 768px){.nav-arrow{width:50px;height:100px;font-size:36px}.nav-arrow:hover{width:55px}.arrow-icon{font-size:36px}}\n"], dependencies: [{ kind: "component", type: LayerRendererComponent, selector: "pw-layer-renderer", inputs: ["layer", "locale", "targetWidth", "altText", "viewMode", "viewActive", "reducedMotion", "panelId", "placementId", "readingOrderIndex", "placementZ", "placementY", "placementX"], outputs: ["layerClick"] }, { kind: "component", type: SpeechBubblesComponent, selector: "pw-speech-bubbles", inputs: ["bubbles", "locale", "containerWidth", "containerHeight", "readingScale", "anchorTailTip", "workBalloonConfig", "characters"], outputs: ["bubbleClick", "bubbleAudioPlay"] }, { kind: "component", type: HotspotsOverlayComponent, selector: "pw-hotspots-overlay", inputs: ["hotspots", "bubbles", "locale", "containerWidth", "containerHeight", "context", "interactive", "showIndicators"], outputs: ["hotspotActivate"] }, { kind: "component", type: PwIconComponent, selector: "pw-icon", inputs: ["name"] }, { kind: "directive", type: PanelAnimationDirective, selector: "[pwPanelAnimation]", inputs: ["pwPanelAnimation", "pwPanelAnimationKey", "pwPanelAnimationReducedMotion", "pwPanelAnimationStatic"] }, { kind: "component", type: LockedPanelComponent, selector: "pw-locked-panel-placeholder", inputs: ["actionable"], outputs: ["activate"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
14080
14784
  }
14081
14785
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ViewportComponent, decorators: [{
14082
14786
  type: Component,
14083
- args: [{ selector: 'pw-viewport', imports: [LayerRendererComponent, SpeechBubblesComponent, HotspotsOverlayComponent, PwIconComponent, PanelAnimationDirective, LockedPanelComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div \n class=\"viewport-container\"\n [class]=\"getViewModeClass()\"\n [class.reduced-motion]=\"reducedMotion\"\n [class.interactive]=\"interactive\"\n [style.background-color]=\"getViewportBackground()\"\n role=\"region\"\n [attr.aria-label]=\"viewMode === 'page' ? 'Page viewport' : 'Panel viewport'\"\n tabindex=\"0\"\n (click)=\"onViewportClick($event)\"\n (keydown.enter)=\"onKeyboardActivate($event)\"\n (keydown.space)=\"onKeyboardActivate($event)\"\n (mousemove)=\"onViewportMouseMove($event)\"\n (dblclick)=\"onViewportDoubleClick($event)\"\n (mouseleave)=\"onViewportMouseLeave()\">\n \n <!-- Panel View -->\n @if (viewMode === 'panel') {\n <div\n class=\"viewport-content\"\n [style.transform]=\"getTransformStyle()\"\n [style.transition]=\"reducedMotion ? 'none' : 'transform 0.3s ease'\"\n [style.--pw-t-duration]=\"transitionDuration\"\n [style.--pw-t-easing]=\"transitionEasing\">\n\n <!-- Outgoing panel during a manifest edge transition -->\n @if (leavingPanel) {\n <div [class]=\"'t-frame-leave ' + leaveAnimationClass\" aria-hidden=\"true\">\n <!-- The outgoing panel holds the end state of its animation. -->\n <div class=\"panel-container\"\n [pwPanelAnimation]=\"leavingPanel.animations\"\n [pwPanelAnimationKey]=\"leavingPanelId || ''\"\n [pwPanelAnimationStatic]=\"true\">\n <div class=\"pw-panel-camera\">\n @if (isLocked(leavingPanel, leavingPanelId)) {\n <pw-locked-panel-placeholder></pw-locked-panel-placeholder>\n } @else {\n @for (layer of leavingPanel.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n [viewMode]=\"viewMode\"\n [viewActive]=\"false\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(leavingPanelId)\"\n [panelId]=\"leavingPanelId\">\n </pw-layer-renderer>\n }\n\n @if (speechEnabled && leavingPanel.speechBubbles && leavingPanel.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"leavingPanel.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"getContainerWidth()\"\n [containerHeight]=\"getContainerHeight()\"\n [readingScale]=\"getReadingScale()\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n </div>\n </div>\n }\n\n <div [class]=\"'t-frame ' + enterAnimationClass\">\n @if (panel) {\n <div class=\"panel-container\" tabindex=\"0\"\n [pwPanelAnimation]=\"panel.animations\"\n [pwPanelAnimationKey]=\"currentPanelId || ''\"\n [pwPanelAnimationReducedMotion]=\"reducedMotion\"\n (click)=\"onPanelContainerClick($event, currentPanelId)\" (keydown.enter)=\"onPanelContainerClick($any($event), currentPanelId)\">\n <!-- Everything that belongs to the artwork; a camera move transforms it as one. -->\n <div class=\"pw-panel-camera\">\n @if (isLocked(panel, currentPanelId)) {\n <pw-locked-panel-placeholder></pw-locked-panel-placeholder>\n } @else {\n <!-- Render layers using LayerRenderer -->\n @for (layer of panel.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n [viewMode]=\"viewMode\"\n [viewActive]=\"true\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(currentPanelId)\"\n [panelId]=\"currentPanelId || ''\"\n (layerClick)=\"layerClick.emit($event)\">\n </pw-layer-renderer>\n }\n\n <!-- Interactive hotspots overlay (below speech bubbles) -->\n @if (interactive && panel.hotspots && panel.hotspots.length > 0) {\n <pw-hotspots-overlay\n [hotspots]=\"panel.hotspots\"\n [locale]=\"locale\"\n [containerWidth]=\"getContainerWidth()\"\n [containerHeight]=\"getContainerHeight()\"\n [context]=\"variableContext\"\n (hotspotActivate)=\"onHotspotActivate($event, currentPanelId)\">\n </pw-hotspots-overlay>\n }\n\n <!-- Speech bubbles overlay for panel view -->\n @if (speechEnabled && panel.speechBubbles && panel.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"panel.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"getContainerWidth()\"\n [containerHeight]=\"getContainerHeight()\"\n [readingScale]=\"getReadingScale()\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n </div>\n } @else {\n <div class=\"no-panel\">\n <p>No panel to display</p>\n </div>\n }\n </div>\n </div>\n }\n \n <!-- Page View -->\n @if (viewMode === 'page' && page) {\n <div\n class=\"viewport-page\"\n [style.--pw-page-aspect]=\"pageAspect\"\n [style.--pw-stage-aspect]=\"stageAspect\"\n [style.--pw-fit-x]=\"pageFit.x\"\n [style.--pw-fit-y]=\"pageFit.y\"\n [style.--pw-fit-w]=\"pageFit.w\"\n [style.--pw-fit-h]=\"pageFit.h\">\n <div class=\"page-stage\">\n <!-- Outgoing page during a Page.transitions in/out change -->\n @if (leavingPage) {\n <div\n [class]=\"'pt-frame-leave ' + pageLeaveClass + (pageLeaveAbove ? ' pt-frame-leave--above' : '')\"\n [style.--pw-t-duration]=\"pageLeaveDuration\"\n [style.--pw-t-easing]=\"pageLeaveEasing\"\n aria-hidden=\"true\">\n <div class=\"page-canvas\" [style.background-color]=\"pageBackgroundOf(leavingPage)\">\n @for (placement of getLeavingPagePlacements(); track placement.panelId) {\n <div\n class=\"panel-container\"\n [style.left.%]=\"toPercent(placement.x)\"\n [style.top.%]=\"toPercent(placement.y)\"\n [style.width.%]=\"toPercent(placement.w)\"\n [style.height.%]=\"toPercent(placement.h)\"\n [style.z-index]=\"placement.z || 0\"\n [style.transform]=\"getPanelTransform(placement)\"\n [style.transform-origin]=\"getTransformOrigin(placement)\">\n\n @if (isBranchHidden(placement.panelId)) {\n <div class=\"branch-placeholder\" aria-hidden=\"true\"></div>\n } @else if (getLeavingPanel(placement.panelId); as panelData) {\n <div class=\"panel-content\"\n [style.clip-path]=\"getPanelContentClipPath(placement)\"\n [style.transform]=\"getPanelContentTransform(placement)\"\n [pwPanelAnimation]=\"panelData.animations\"\n [pwPanelAnimationKey]=\"placement.panelId\"\n [pwPanelAnimationStatic]=\"true\">\n <div class=\"pw-panel-camera\">\n @if (isLocked(panelData, placement.panelId)) {\n <pw-locked-panel-placeholder></pw-locked-panel-placeholder>\n } @else {\n @for (layer of panelData.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n [viewMode]=\"viewMode\"\n [viewActive]=\"false\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(placement.panelId)\"\n [panelId]=\"placement.panelId\"\n [placementId]=\"placement.panelId\">\n </pw-layer-renderer>\n }\n\n @if (speechEnabled && panelData.speechBubbles && panelData.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"panelData.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"getPagePanelWidth(placement)\"\n [containerHeight]=\"getPagePanelHeight(placement)\"\n [readingScale]=\"getPageReadingScale(placement)\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n </div>\n }\n </div>\n }\n </div>\n </div>\n }\n\n <div\n [class]=\"'pt-frame ' + pageEnterClass\"\n [style.--pw-t-duration]=\"pageEnterDuration\"\n [style.--pw-t-easing]=\"pageEnterEasing\">\n <div\n class=\"page-canvas\"\n [style.background-color]=\"pageBackgroundOf(page)\"\n [attr.tabindex]=\"-1\"\n (click)=\"onPanelClickWithFocus($event)\"\n (keydown.enter)=\"onCanvasKeyboardActivate($event)\"\n (keydown.space)=\"onCanvasKeyboardActivate($event)\">\n \n @for (placement of getSortedPanels(); track placement.panelId) {\n @if (isPanelVisible(placement)) {\n <div \n class=\"panel-container\"\n [class.panel-focused]=\"isPanelFocused(placement.panelId)\"\n [style.left.%]=\"toPercent(placement.x)\"\n [style.top.%]=\"toPercent(placement.y)\"\n [style.width.%]=\"toPercent(placement.w)\"\n [style.height.%]=\"toPercent(placement.h)\"\n [style.z-index]=\"placement.z || 0\"\n [style.transform]=\"getPanelTransform(placement)\"\n [style.transform-origin]=\"getTransformOrigin(placement)\"\n [attr.data-panel-id]=\"placement.panelId\"\n [class.panel-branch-hidden]=\"isBranchHidden(placement.panelId)\"\n [attr.aria-hidden]=\"isBranchHidden(placement.panelId) ? 'true' : null\"\n [attr.tabindex]=\"isBranchHidden(placement.panelId) || panelHasHotspots(placement.panelId) ? null : 0\"\n [attr.role]=\"isBranchHidden(placement.panelId) || panelHasHotspots(placement.panelId) ? null : 'button'\"\n [attr.aria-label]=\"isBranchHidden(placement.panelId) || panelHasHotspots(placement.panelId) ? null : 'Panel ' + placement.panelId\"\n [attr.loading]=\"getImageLoadingStrategy()\"\n (click)=\"onPanelClick($event, placement.panelId)\"\n (keydown.enter)=\"onPanelKeyboardActivate($event, placement.panelId)\"\n (keydown.space)=\"onPanelKeyboardActivate($event, placement.panelId)\">\n \n @if (isBranchHidden(placement.panelId)) {\n <!-- Not on the reader's path (yet): a grey box keeps the page's shape. -->\n <div class=\"branch-placeholder\"></div>\n } @else if (getPanel(placement.panelId); as panelData) {\n <div class=\"panel-content\"\n [style.clip-path]=\"getPanelContentClipPath(placement)\"\n [style.transform]=\"getPanelContentTransform(placement)\"\n [pwPanelAnimation]=\"panelData.animations\"\n [pwPanelAnimationKey]=\"placement.panelId\"\n [pwPanelAnimationReducedMotion]=\"reducedMotion\"\n tabindex=\"0\"\n (click)=\"onPanelContainerClick($event, placement.panelId)\"\n (keydown.enter)=\"onPanelContainerClick($any($event), placement.panelId)\">\n <div class=\"pw-panel-camera\">\n @if (isLocked(panelData, placement.panelId)) {\n <pw-locked-panel-placeholder\n [actionable]=\"true\"\n (activate)=\"lockedPanelActivate.emit(placement.panelId)\"></pw-locked-panel-placeholder>\n } @else {\n <!-- Render panel layers -->\n @for (layer of panelData.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n [altText]=\"getAltText(layer)\"\n [viewMode]=\"viewMode\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(placement.panelId)\"\n [panelId]=\"placement.panelId\"\n [placementId]=\"placement.panelId\"\n [readingOrderIndex]=\"readingOrderIndexOf(placement.panelId)\"\n [placementZ]=\"placement.z || 0\"\n [placementY]=\"placement.y\"\n [placementX]=\"placement.x\"\n (layerClick)=\"layerClick.emit($event)\">\n </pw-layer-renderer>\n }\n\n <!-- Interactive hotspots overlay for page view panels -->\n @if (interactive && panelData.hotspots && panelData.hotspots.length > 0) {\n <pw-hotspots-overlay\n [hotspots]=\"panelData.hotspots\"\n [locale]=\"locale\"\n [containerWidth]=\"getPagePanelWidth(placement)\"\n [containerHeight]=\"getPagePanelHeight(placement)\"\n [context]=\"variableContext\"\n (hotspotActivate)=\"onHotspotActivate($event, placement.panelId)\">\n </pw-hotspots-overlay>\n }\n\n <!-- Speech bubbles overlay for page view panels -->\n @if (speechEnabled && panelData.speechBubbles && panelData.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"panelData.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"getPagePanelWidth(placement)\"\n [containerHeight]=\"getPagePanelHeight(placement)\"\n [readingScale]=\"getPageReadingScale(placement)\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n </div>\n }\n \n <!-- Focus indicator for rotated panels -->\n @if (isPanelFocused(placement.panelId)) {\n <div class=\"focus-rect\" aria-hidden=\"true\"></div>\n }\n </div>\n }\n }\n </div>\n </div>\n </div>\n </div>\n }\n\n @if (viewMode === 'page' && !page) {\n <div class=\"no-panel\">\n <p>No page to display</p>\n </div>\n }\n\n <!-- Overflow navigation arrows -->\n @if (showOverflowArrows && panel) {\n @if (hasOverflowLeft) {\n <button \n class=\"overflow-arrow overflow-left\"\n type=\"button\"\n aria-label=\"Navigate left\"\n (click)=\"navigateLeft()\">\n <pw-icon name=\"lucideChevronLeft\" />\n </button>\n }\n\n @if (hasOverflowRight) {\n <button \n class=\"overflow-arrow overflow-right\"\n type=\"button\"\n aria-label=\"Navigate right\"\n (click)=\"navigateRight()\">\n <pw-icon name=\"lucideChevronRight\" />\n </button>\n }\n\n @if (hasOverflowTop) {\n <button \n class=\"overflow-arrow overflow-top\"\n type=\"button\"\n aria-label=\"Navigate up\"\n (click)=\"navigateUp()\">\n <pw-icon name=\"lucideChevronUp\" />\n </button>\n }\n\n @if (hasOverflowBottom) {\n <button \n class=\"overflow-arrow overflow-bottom\"\n type=\"button\"\n aria-label=\"Navigate down\"\n (click)=\"navigateDown()\">\n <pw-icon name=\"lucideChevronDown\" />\n </button>\n }\n }\n\n <!-- Navigation arrows (hover zones) -->\n @if (viewMode === 'panel' || (viewMode === 'page' && page)) {\n @if (showLeftArrow) {\n <button \n class=\"nav-arrow nav-arrow-left\"\n type=\"button\"\n [attr.aria-label]=\"viewMode === 'page' ? 'Previous page' : 'Previous panel'\"\n (click)=\"onNavigatePrevious(); $event.stopPropagation()\">\n <span class=\"arrow-icon\"><pw-icon name=\"lucideChevronLeft\" /></span>\n </button>\n }\n\n @if (showRightArrow) {\n <button \n class=\"nav-arrow nav-arrow-right\"\n type=\"button\"\n [attr.aria-label]=\"viewMode === 'page' ? 'Next page' : 'Next panel'\"\n (click)=\"onNavigateNext(); $event.stopPropagation()\">\n <span class=\"arrow-icon\"><pw-icon name=\"lucideChevronRight\" /></span>\n </button>\n }\n }\n</div>\n", styles: [".viewport-container{position:relative;width:100%;height:100%;overflow:hidden;background-color:#1a1a1a;cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none}.viewport-container:active{cursor:grabbing}.viewport-container.interactive{cursor:default}.viewport-content{position:absolute;top:50%;left:50%;transform-origin:center center;will-change:transform}.viewport-content.reduced-motion{transition:none!important}.panel-container{position:relative;background:transparent;width:1280px;height:720px;max-width:100%;max-height:100%}.pw-panel-camera{position:absolute;inset:0}.t-frame{position:relative;z-index:1}.t-frame-leave{position:absolute;inset:0;z-index:0;pointer-events:none}.t-fade-in{animation:pw-t-fade-in var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-zoom-in{animation:pw-t-zoom-in var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-from-right{animation:pw-t-from-right var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-from-left{animation:pw-t-from-left var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-from-bottom{animation:pw-t-from-bottom var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-from-top{animation:pw-t-from-top var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-to-left{animation:pw-t-to-left var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-to-right{animation:pw-t-to-right var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-to-top{animation:pw-t-to-top var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-to-bottom{animation:pw-t-to-bottom var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}@keyframes pw-t-fade-in{0%{opacity:0}to{opacity:1}}@keyframes pw-t-zoom-in{0%{opacity:0;transform:scale(.94)}to{opacity:1;transform:scale(1)}}@keyframes pw-t-from-right{0%{transform:translate(100%)}to{transform:translate(0)}}@keyframes pw-t-from-left{0%{transform:translate(-100%)}to{transform:translate(0)}}@keyframes pw-t-from-bottom{0%{transform:translateY(100%)}to{transform:translateY(0)}}@keyframes pw-t-from-top{0%{transform:translateY(-100%)}to{transform:translateY(0)}}@keyframes pw-t-to-left{0%{transform:translate(0)}to{transform:translate(-100%)}}@keyframes pw-t-to-right{0%{transform:translate(0)}to{transform:translate(100%)}}@keyframes pw-t-to-top{0%{transform:translateY(0)}to{transform:translateY(-100%)}}@keyframes pw-t-to-bottom{0%{transform:translateY(0)}to{transform:translateY(100%)}}.layer{position:absolute;top:0;left:0}.layer-placeholder{padding:20px;background:#0078d71a;border:1px dashed rgba(0,120,215,.3);color:#0078d7;font-size:14px}.no-panel{display:flex;align-items:center;justify-content:center;min-width:400px;min-height:300px;color:#666}.reduced-motion *{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}.overflow-arrow{position:absolute;z-index:100;background:#0009;color:#fff;border:none;border-radius:4px;width:40px;height:40px;font-size:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s ease;box-shadow:0 2px 8px #0000004d}.overflow-arrow:hover{background:#000c;transform:scale(1.1)}.overflow-arrow:active{transform:scale(.95)}.overflow-left{left:20px;top:50%;transform:translateY(-50%)}.overflow-left:hover{transform:translateY(-50%) scale(1.1)}.overflow-right{right:20px;top:50%;transform:translateY(-50%)}.overflow-right:hover{transform:translateY(-50%) scale(1.1)}.overflow-top{top:20px;left:50%;transform:translate(-50%)}.overflow-top:hover{transform:translate(-50%) scale(1.1)}.overflow-bottom{bottom:20px;left:50%;transform:translate(-50%)}.overflow-bottom:hover{transform:translate(-50%) scale(1.1)}.viewport-page{width:100%;height:100%;display:flex;align-items:center;justify-content:center;overflow:hidden;padding:0;box-sizing:border-box;container-type:size}.page-canvas{position:relative;width:100%;aspect-ratio:var(--pw-page-aspect, 16 / 9);background:#1a1a1a;overflow:hidden}.page-stage{position:relative;width:min(100cqw,100cqh * var(--pw-stage-aspect, var(--pw-page-aspect, 1.7778)));aspect-ratio:var(--pw-stage-aspect, var(--pw-page-aspect, 16 / 9));flex:none;overflow:hidden}.page-stage .page-canvas{position:absolute;left:calc(-100% * var(--pw-fit-x, 0) / var(--pw-fit-w, 1));top:calc(-100% * var(--pw-fit-y, 0) / var(--pw-fit-h, 1));width:calc(100% / var(--pw-fit-w, 1));height:calc(100% / var(--pw-fit-h, 1));max-width:none;aspect-ratio:auto}.pt-frame{position:relative;z-index:1;width:100%;height:100%}.pt-frame-leave{position:absolute;inset:0;z-index:0;pointer-events:none}.pt-frame-leave--above{z-index:2}.pt-frame.t-fade-in,.pt-frame.t-zoom-in,.pt-frame.t-from-right,.pt-frame.t-from-left,.pt-frame.t-from-bottom,.pt-frame.t-from-top{will-change:transform,opacity}.t-fade-out{animation:pw-t-fade-out var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-zoom-out{animation:pw-t-zoom-out var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}@keyframes pw-t-fade-out{0%{opacity:1}to{opacity:0}}@keyframes pw-t-zoom-out{0%{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(1.06)}}.branch-placeholder{position:absolute;inset:0;background:#c9ccd1;border-radius:inherit}.viewport-page .panel-container.panel-branch-hidden{pointer-events:none}.viewport-page .panel-container{position:absolute;overflow:hidden;background:#000;border:1px solid rgba(255,255,255,.1);border-radius:2px;transition:transform .2s ease,border-color .2s ease,box-shadow .2s ease;transform-origin:center center;outline:none}.viewport-page .panel-container:hover{border-color:#ffffff4d;cursor:pointer}.viewport-page .panel-container.panel-focused{border-color:#0078d7cc;box-shadow:0 0 0 3px #0078d74d,0 4px 12px #0006;z-index:9999!important}.focus-rect{position:absolute;inset:-4px;border:3px solid rgba(0,120,215,.9);border-radius:4px;pointer-events:none;animation:focusPulse 1.5s ease-in-out infinite}@keyframes focusPulse{0%,to{opacity:1;transform:scale(1)}50%{opacity:.7;transform:scale(1.02)}}@media (prefers-reduced-motion: reduce){.focus-rect{animation:none}.viewport-page .panel-container{transition:none}}.viewport-page .panel-content{position:relative;width:100%;height:100%}.nav-arrow{position:absolute;top:50%;transform:translateY(-50%);width:60px;height:120px;background:#0006;border:none;color:#fff;font-size:48px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .3s ease;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);z-index:50;animation:fadeInArrow .2s ease}.nav-arrow:hover{background:#0009;width:70px}.nav-arrow:active{background:#000000b3;transform:translateY(-50%) scale(.95)}.nav-arrow-left{left:0;border-radius:0 8px 8px 0}.nav-arrow-right{right:0;border-radius:8px 0 0 8px}.arrow-icon{font-size:48px;line-height:1;font-weight:300}@keyframes fadeInArrow{0%{opacity:0}to{opacity:1}}@media (prefers-reduced-motion: reduce){.nav-arrow{animation:none;transition:none}}@media (max-width: 768px){.nav-arrow{width:50px;height:100px;font-size:36px}.nav-arrow:hover{width:55px}.arrow-icon{font-size:36px}}\n"] }]
14787
+ args: [{ selector: 'pw-viewport', imports: [LayerRendererComponent, SpeechBubblesComponent, HotspotsOverlayComponent, PwIconComponent, PanelAnimationDirective, LockedPanelComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div \n class=\"viewport-container\"\n [class]=\"getViewModeClass()\"\n [class.reduced-motion]=\"reducedMotion\"\n [class.interactive]=\"interactive\"\n [style.background-color]=\"getViewportBackground()\"\n role=\"region\"\n [attr.aria-label]=\"viewMode === 'page' ? 'Page viewport' : 'Panel viewport'\"\n tabindex=\"0\"\n (click)=\"onViewportClick($event)\"\n (keydown.enter)=\"onKeyboardActivate($event)\"\n (keydown.space)=\"onKeyboardActivate($event)\"\n (mousemove)=\"onViewportMouseMove($event)\"\n (dblclick)=\"onViewportDoubleClick($event)\"\n (mouseleave)=\"onViewportMouseLeave()\">\n \n <!-- Panel View -->\n @if (viewMode === 'panel') {\n <div\n class=\"viewport-content\"\n [style.transform]=\"getTransformStyle()\"\n [style.transition]=\"reducedMotion ? 'none' : 'transform 0.3s ease'\"\n [style.--pw-t-duration]=\"transitionDuration\"\n [style.--pw-t-easing]=\"transitionEasing\">\n\n <!-- Outgoing panel during a manifest edge transition -->\n @if (leavingPanel) {\n <div [class]=\"'t-frame-leave ' + leaveAnimationClass\" aria-hidden=\"true\">\n <!-- The outgoing panel holds the end state of its animation. -->\n <div class=\"panel-container\"\n [style.width.px]=\"panelBox?.width\"\n [style.height.px]=\"panelBox?.height\"\n [pwPanelAnimation]=\"leavingPanel.animations\"\n [pwPanelAnimationKey]=\"leavingPanelId || ''\"\n [pwPanelAnimationStatic]=\"true\">\n <div class=\"pw-panel-camera\">\n @if (isLocked(leavingPanel, leavingPanelId)) {\n <pw-locked-panel-placeholder></pw-locked-panel-placeholder>\n } @else {\n @for (layer of leavingPanel.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n [viewMode]=\"viewMode\"\n [viewActive]=\"false\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(leavingPanelId)\"\n [panelId]=\"leavingPanelId\">\n </pw-layer-renderer>\n }\n\n @if (speechEnabled && leavingPanel.speechBubbles && leavingPanel.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"leavingPanel.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"getContainerWidth()\"\n [containerHeight]=\"getContainerHeight()\"\n [readingScale]=\"getReadingScale()\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n </div>\n </div>\n }\n\n <div [class]=\"'t-frame ' + enterAnimationClass\">\n @if (panel) {\n <div class=\"panel-container\" tabindex=\"0\"\n [style.width.px]=\"panelBox?.width\"\n [style.height.px]=\"panelBox?.height\"\n [pwPanelAnimation]=\"panel.animations\"\n [pwPanelAnimationKey]=\"currentPanelId || ''\"\n [pwPanelAnimationReducedMotion]=\"reducedMotion\"\n (click)=\"onPanelContainerClick($event, currentPanelId)\" (keydown.enter)=\"onPanelContainerClick($any($event), currentPanelId)\">\n <!-- Everything that belongs to the artwork; a camera move transforms it as one. -->\n <div class=\"pw-panel-camera\">\n @if (isLocked(panel, currentPanelId)) {\n <pw-locked-panel-placeholder></pw-locked-panel-placeholder>\n } @else {\n <!-- Render layers using LayerRenderer -->\n @for (layer of panel.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n [viewMode]=\"viewMode\"\n [viewActive]=\"true\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(currentPanelId)\"\n [panelId]=\"currentPanelId || ''\"\n (layerClick)=\"layerClick.emit($event)\">\n </pw-layer-renderer>\n }\n\n <!-- Interactive hotspots overlay (below speech bubbles) -->\n @if (interactive && panel.hotspots && panel.hotspots.length > 0) {\n <pw-hotspots-overlay\n [hotspots]=\"panel.hotspots\"\n [bubbles]=\"panel.speechBubbles ?? []\"\n [locale]=\"locale\"\n [containerWidth]=\"getContainerWidth()\"\n [containerHeight]=\"getContainerHeight()\"\n [context]=\"variableContext\"\n (hotspotActivate)=\"onHotspotActivate($event, currentPanelId)\">\n </pw-hotspots-overlay>\n }\n\n <!-- Speech bubbles overlay for panel view -->\n @if (speechEnabled && panel.speechBubbles && panel.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"panel.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"getContainerWidth()\"\n [containerHeight]=\"getContainerHeight()\"\n [readingScale]=\"getReadingScale()\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n </div>\n } @else {\n <div class=\"no-panel\">\n <p>No panel to display</p>\n </div>\n }\n </div>\n </div>\n }\n \n <!-- Page View -->\n @if (viewMode === 'page' && page) {\n <div\n class=\"viewport-page\"\n [style.--pw-page-aspect]=\"pageAspect\"\n [style.--pw-stage-aspect]=\"stageAspect\"\n [style.--pw-fit-x]=\"pageFit.x\"\n [style.--pw-fit-y]=\"pageFit.y\"\n [style.--pw-fit-w]=\"pageFit.w\"\n [style.--pw-fit-h]=\"pageFit.h\">\n <div class=\"page-stage\">\n <!-- Outgoing page during a Page.transitions in/out change -->\n @if (leavingPage) {\n <div\n [class]=\"'pt-frame-leave ' + pageLeaveClass + (pageLeaveAbove ? ' pt-frame-leave--above' : '')\"\n [style.--pw-t-duration]=\"pageLeaveDuration\"\n [style.--pw-t-easing]=\"pageLeaveEasing\"\n aria-hidden=\"true\">\n <div class=\"page-canvas\" [style.background-color]=\"pageBackgroundOf(leavingPage)\">\n @for (placement of getLeavingPagePlacements(); track placement.panelId) {\n <div\n class=\"panel-container\"\n [style.left.%]=\"toPercent(placement.x)\"\n [style.top.%]=\"toPercent(placement.y)\"\n [style.width.%]=\"toPercent(placement.w)\"\n [style.height.%]=\"toPercent(placement.h)\"\n [style.z-index]=\"placement.z || 0\"\n [style.transform]=\"getPanelTransform(placement)\"\n [style.transform-origin]=\"getTransformOrigin(placement)\">\n\n @if (isBranchHidden(placement.panelId)) {\n <div class=\"branch-placeholder\" aria-hidden=\"true\"></div>\n } @else if (getLeavingPanel(placement.panelId); as panelData) {\n <div class=\"panel-content\"\n [style.clip-path]=\"getPanelContentClipPath(placement)\"\n [style.transform]=\"getPanelContentTransform(placement)\"\n [pwPanelAnimation]=\"panelData.animations\"\n [pwPanelAnimationKey]=\"placement.panelId\"\n [pwPanelAnimationStatic]=\"true\">\n <div class=\"pw-panel-camera\">\n @if (isLocked(panelData, placement.panelId)) {\n <pw-locked-panel-placeholder></pw-locked-panel-placeholder>\n } @else {\n @for (layer of panelData.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n [viewMode]=\"viewMode\"\n [viewActive]=\"false\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(placement.panelId)\"\n [panelId]=\"placement.panelId\"\n [placementId]=\"placement.panelId\">\n </pw-layer-renderer>\n }\n\n @if (speechEnabled && panelData.speechBubbles && panelData.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"panelData.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"getPagePanelWidth(placement)\"\n [containerHeight]=\"getPagePanelHeight(placement)\"\n [readingScale]=\"getPageReadingScale(placement)\"\n [anchorTailTip]=\"true\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n </div>\n }\n </div>\n }\n </div>\n </div>\n }\n\n <div\n [class]=\"'pt-frame ' + pageEnterClass\"\n [style.--pw-t-duration]=\"pageEnterDuration\"\n [style.--pw-t-easing]=\"pageEnterEasing\">\n <div\n class=\"page-canvas\"\n [style.background-color]=\"pageBackgroundOf(page)\"\n [attr.tabindex]=\"-1\"\n (click)=\"onPanelClickWithFocus($event)\"\n (keydown.enter)=\"onCanvasKeyboardActivate($event)\"\n (keydown.space)=\"onCanvasKeyboardActivate($event)\">\n \n @for (placement of getSortedPanels(); track placement.panelId) {\n @if (isPanelVisible(placement)) {\n <div \n class=\"panel-container\"\n [class.panel-focused]=\"isPanelFocused(placement.panelId)\"\n [style.left.%]=\"toPercent(placement.x)\"\n [style.top.%]=\"toPercent(placement.y)\"\n [style.width.%]=\"toPercent(placement.w)\"\n [style.height.%]=\"toPercent(placement.h)\"\n [style.z-index]=\"placement.z || 0\"\n [style.transform]=\"getPanelTransform(placement)\"\n [style.transform-origin]=\"getTransformOrigin(placement)\"\n [attr.data-panel-id]=\"placement.panelId\"\n [class.panel-branch-hidden]=\"isBranchHidden(placement.panelId)\"\n [attr.aria-hidden]=\"isBranchHidden(placement.panelId) ? 'true' : null\"\n [attr.tabindex]=\"isBranchHidden(placement.panelId) || panelHasHotspots(placement.panelId) ? null : 0\"\n [attr.role]=\"isBranchHidden(placement.panelId) || panelHasHotspots(placement.panelId) ? null : 'button'\"\n [attr.aria-label]=\"isBranchHidden(placement.panelId) || panelHasHotspots(placement.panelId) ? null : 'Panel ' + placement.panelId\"\n [attr.loading]=\"getImageLoadingStrategy()\"\n (click)=\"onPanelClick($event, placement.panelId)\"\n (keydown.enter)=\"onPanelKeyboardActivate($event, placement.panelId)\"\n (keydown.space)=\"onPanelKeyboardActivate($event, placement.panelId)\">\n \n @if (isBranchHidden(placement.panelId)) {\n <!-- Not on the reader's path (yet): a grey box keeps the page's shape. -->\n <div class=\"branch-placeholder\"></div>\n } @else if (getPanel(placement.panelId); as panelData) {\n <div class=\"panel-content\"\n [style.clip-path]=\"getPanelContentClipPath(placement)\"\n [style.transform]=\"getPanelContentTransform(placement)\"\n [pwPanelAnimation]=\"panelData.animations\"\n [pwPanelAnimationKey]=\"placement.panelId\"\n [pwPanelAnimationReducedMotion]=\"reducedMotion\"\n tabindex=\"0\"\n (click)=\"onPanelContainerClick($event, placement.panelId)\"\n (keydown.enter)=\"onPanelContainerClick($any($event), placement.panelId)\">\n <div class=\"pw-panel-camera\">\n @if (isLocked(panelData, placement.panelId)) {\n <pw-locked-panel-placeholder\n [actionable]=\"true\"\n (activate)=\"lockedPanelActivate.emit(placement.panelId)\"></pw-locked-panel-placeholder>\n } @else {\n <!-- Render panel layers -->\n @for (layer of panelData.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n [altText]=\"getAltText(layer)\"\n [viewMode]=\"viewMode\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(placement.panelId)\"\n [panelId]=\"placement.panelId\"\n [placementId]=\"placement.panelId\"\n [readingOrderIndex]=\"readingOrderIndexOf(placement.panelId)\"\n [placementZ]=\"placement.z || 0\"\n [placementY]=\"placement.y\"\n [placementX]=\"placement.x\"\n (layerClick)=\"layerClick.emit($event)\">\n </pw-layer-renderer>\n }\n\n <!-- Interactive hotspots overlay for page view panels -->\n @if (interactive && panelData.hotspots && panelData.hotspots.length > 0) {\n <pw-hotspots-overlay\n [hotspots]=\"panelData.hotspots\"\n [bubbles]=\"panelData.speechBubbles ?? []\"\n [locale]=\"locale\"\n [containerWidth]=\"getPagePanelWidth(placement)\"\n [containerHeight]=\"getPagePanelHeight(placement)\"\n [context]=\"variableContext\"\n (hotspotActivate)=\"onHotspotActivate($event, placement.panelId)\">\n </pw-hotspots-overlay>\n }\n\n <!-- Speech bubbles overlay for page view panels -->\n @if (speechEnabled && panelData.speechBubbles && panelData.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"panelData.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"getPagePanelWidth(placement)\"\n [containerHeight]=\"getPagePanelHeight(placement)\"\n [readingScale]=\"getPageReadingScale(placement)\"\n [anchorTailTip]=\"true\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n </div>\n }\n \n <!-- Focus indicator for rotated panels -->\n @if (isPanelFocused(placement.panelId)) {\n <div class=\"focus-rect\" aria-hidden=\"true\"></div>\n }\n </div>\n }\n }\n </div>\n </div>\n </div>\n </div>\n }\n\n @if (viewMode === 'page' && !page) {\n <div class=\"no-panel\">\n <p>No page to display</p>\n </div>\n }\n\n <!-- Overflow navigation arrows -->\n @if (showOverflowArrows && panel) {\n @if (hasOverflowLeft) {\n <button \n class=\"overflow-arrow overflow-left\"\n type=\"button\"\n aria-label=\"Navigate left\"\n (click)=\"navigateLeft()\">\n <pw-icon name=\"lucideChevronLeft\" />\n </button>\n }\n\n @if (hasOverflowRight) {\n <button \n class=\"overflow-arrow overflow-right\"\n type=\"button\"\n aria-label=\"Navigate right\"\n (click)=\"navigateRight()\">\n <pw-icon name=\"lucideChevronRight\" />\n </button>\n }\n\n @if (hasOverflowTop) {\n <button \n class=\"overflow-arrow overflow-top\"\n type=\"button\"\n aria-label=\"Navigate up\"\n (click)=\"navigateUp()\">\n <pw-icon name=\"lucideChevronUp\" />\n </button>\n }\n\n @if (hasOverflowBottom) {\n <button \n class=\"overflow-arrow overflow-bottom\"\n type=\"button\"\n aria-label=\"Navigate down\"\n (click)=\"navigateDown()\">\n <pw-icon name=\"lucideChevronDown\" />\n </button>\n }\n }\n\n <!-- Navigation arrows (hover zones) -->\n @if (viewMode === 'panel' || (viewMode === 'page' && page)) {\n @if (showLeftArrow) {\n <button \n class=\"nav-arrow nav-arrow-left\"\n type=\"button\"\n [attr.aria-label]=\"viewMode === 'page' ? 'Previous page' : 'Previous panel'\"\n (click)=\"onNavigatePrevious(); $event.stopPropagation()\">\n <span class=\"arrow-icon\"><pw-icon name=\"lucideChevronLeft\" /></span>\n </button>\n }\n\n @if (showRightArrow) {\n <button \n class=\"nav-arrow nav-arrow-right\"\n type=\"button\"\n [attr.aria-label]=\"viewMode === 'page' ? 'Next page' : 'Next panel'\"\n (click)=\"onNavigateNext(); $event.stopPropagation()\">\n <span class=\"arrow-icon\"><pw-icon name=\"lucideChevronRight\" /></span>\n </button>\n }\n }\n</div>\n", styles: [".viewport-container{position:relative;width:100%;height:100%;overflow:hidden;background-color:#1a1a1a;cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none}.viewport-container:active{cursor:grabbing}.viewport-container.interactive{cursor:default}.viewport-content{position:absolute;top:50%;left:50%;transform-origin:center center;will-change:transform}.viewport-content.reduced-motion{transition:none!important}.panel-container{position:relative;background:transparent;width:1280px;height:720px;max-width:100%;max-height:100%}.pw-panel-camera{position:absolute;inset:0}.t-frame{position:relative;z-index:1}.t-frame-leave{position:absolute;inset:0;z-index:0;pointer-events:none}.t-fade-in{animation:pw-t-fade-in var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-zoom-in{animation:pw-t-zoom-in var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-from-right{animation:pw-t-from-right var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-from-left{animation:pw-t-from-left var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-from-bottom{animation:pw-t-from-bottom var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-from-top{animation:pw-t-from-top var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-to-left{animation:pw-t-to-left var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-to-right{animation:pw-t-to-right var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-to-top{animation:pw-t-to-top var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-to-bottom{animation:pw-t-to-bottom var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}@keyframes pw-t-fade-in{0%{opacity:0}to{opacity:1}}@keyframes pw-t-zoom-in{0%{opacity:0;transform:scale(.94)}to{opacity:1;transform:scale(1)}}@keyframes pw-t-from-right{0%{transform:translate(100%)}to{transform:translate(0)}}@keyframes pw-t-from-left{0%{transform:translate(-100%)}to{transform:translate(0)}}@keyframes pw-t-from-bottom{0%{transform:translateY(100%)}to{transform:translateY(0)}}@keyframes pw-t-from-top{0%{transform:translateY(-100%)}to{transform:translateY(0)}}@keyframes pw-t-to-left{0%{transform:translate(0)}to{transform:translate(-100%)}}@keyframes pw-t-to-right{0%{transform:translate(0)}to{transform:translate(100%)}}@keyframes pw-t-to-top{0%{transform:translateY(0)}to{transform:translateY(-100%)}}@keyframes pw-t-to-bottom{0%{transform:translateY(0)}to{transform:translateY(100%)}}.layer{position:absolute;top:0;left:0}.layer-placeholder{padding:20px;background:#0078d71a;border:1px dashed rgba(0,120,215,.3);color:#0078d7;font-size:14px}.no-panel{display:flex;align-items:center;justify-content:center;min-width:400px;min-height:300px;color:#666}.reduced-motion *{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}.overflow-arrow{position:absolute;z-index:100;background:#0009;color:#fff;border:none;border-radius:4px;width:40px;height:40px;font-size:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s ease;box-shadow:0 2px 8px #0000004d}.overflow-arrow:hover{background:#000c;transform:scale(1.1)}.overflow-arrow:active{transform:scale(.95)}.overflow-left{left:20px;top:50%;transform:translateY(-50%)}.overflow-left:hover{transform:translateY(-50%) scale(1.1)}.overflow-right{right:20px;top:50%;transform:translateY(-50%)}.overflow-right:hover{transform:translateY(-50%) scale(1.1)}.overflow-top{top:20px;left:50%;transform:translate(-50%)}.overflow-top:hover{transform:translate(-50%) scale(1.1)}.overflow-bottom{bottom:20px;left:50%;transform:translate(-50%)}.overflow-bottom:hover{transform:translate(-50%) scale(1.1)}.viewport-page{width:100%;height:100%;display:flex;align-items:center;justify-content:center;overflow:hidden;padding:0;box-sizing:border-box;container-type:size}.page-canvas{position:relative;width:100%;aspect-ratio:var(--pw-page-aspect, 16 / 9);background:#1a1a1a;overflow:hidden}.page-stage{position:relative;width:min(100cqw,100cqh * var(--pw-stage-aspect, var(--pw-page-aspect, 1.7778)));aspect-ratio:var(--pw-stage-aspect, var(--pw-page-aspect, 16 / 9));flex:none;overflow:hidden}.page-stage .page-canvas{position:absolute;left:calc(-100% * var(--pw-fit-x, 0) / var(--pw-fit-w, 1));top:calc(-100% * var(--pw-fit-y, 0) / var(--pw-fit-h, 1));width:calc(100% / var(--pw-fit-w, 1));height:calc(100% / var(--pw-fit-h, 1));max-width:none;aspect-ratio:auto}.pt-frame{position:relative;z-index:1;width:100%;height:100%}.pt-frame-leave{position:absolute;inset:0;z-index:0;pointer-events:none}.pt-frame-leave--above{z-index:2}.pt-frame.t-fade-in,.pt-frame.t-zoom-in,.pt-frame.t-from-right,.pt-frame.t-from-left,.pt-frame.t-from-bottom,.pt-frame.t-from-top{will-change:transform,opacity}.t-fade-out{animation:pw-t-fade-out var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-zoom-out{animation:pw-t-zoom-out var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}@keyframes pw-t-fade-out{0%{opacity:1}to{opacity:0}}@keyframes pw-t-zoom-out{0%{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(1.06)}}.branch-placeholder{position:absolute;inset:0;background:#c9ccd1;border-radius:inherit}.viewport-page .panel-container.panel-branch-hidden{pointer-events:none}.viewport-page .panel-container{position:absolute;overflow:hidden;background:#000;border:1px solid rgba(255,255,255,.1);border-radius:2px;transition:transform .2s ease,border-color .2s ease,box-shadow .2s ease;transform-origin:center center;outline:none}.viewport-page .panel-container:hover{border-color:#ffffff4d;cursor:pointer}.viewport-page .panel-container.panel-focused{border-color:#0078d7cc;box-shadow:0 0 0 3px #0078d74d,0 4px 12px #0006;z-index:9999!important}.focus-rect{position:absolute;inset:-4px;border:3px solid rgba(0,120,215,.9);border-radius:4px;pointer-events:none;animation:focusPulse 1.5s ease-in-out infinite}@keyframes focusPulse{0%,to{opacity:1;transform:scale(1)}50%{opacity:.7;transform:scale(1.02)}}@media (prefers-reduced-motion: reduce){.focus-rect{animation:none}.viewport-page .panel-container{transition:none}}.viewport-page .panel-content{position:relative;width:100%;height:100%}.nav-arrow{position:absolute;top:50%;transform:translateY(-50%);width:60px;height:120px;background:#0006;border:none;color:#fff;font-size:48px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .3s ease;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);z-index:50;animation:fadeInArrow .2s ease}.nav-arrow:hover{background:#0009;width:70px}.nav-arrow:active{background:#000000b3;transform:translateY(-50%) scale(.95)}.nav-arrow-left{left:0;border-radius:0 8px 8px 0}.nav-arrow-right{right:0;border-radius:8px 0 0 8px}.arrow-icon{font-size:48px;line-height:1;font-weight:300}@keyframes fadeInArrow{0%{opacity:0}to{opacity:1}}@media (prefers-reduced-motion: reduce){.nav-arrow{animation:none;transition:none}}@media (max-width: 768px){.nav-arrow{width:50px;height:100px;font-size:36px}.nav-arrow:hover{width:55px}.arrow-icon{font-size:36px}}\n"] }]
14084
14788
  }], propDecorators: { panel: [{
14085
14789
  type: Input
14086
14790
  }], page: [{
@@ -15286,7 +15990,7 @@ class CanvasStageComponent {
15286
15990
  return Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y);
15287
15991
  }
15288
15992
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: CanvasStageComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
15289
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.14", type: CanvasStageComponent, isStandalone: true, selector: "pw-canvas-stage", inputs: { canvas: "canvas", panels: "panels", graph: "graph", currentPanelId: "currentPanelId", visitedPanelIds: "visitedPanelIds", locale: "locale", variableContext: "variableContext", speechEnabled: "speechEnabled", workBalloonConfig: "workBalloonConfig", characters: "characters", reducedMotion: "reducedMotion", interactive: "interactive", preload: "preload" }, outputs: { panelTap: "panelTap", navigateNext: "navigateNext", navigatePrevious: "navigatePrevious", viewportClick: "viewportClick", hotspotActivate: "hotspotActivate", cameraChange: "cameraChange" }, usesOnChanges: true, ngImport: i0, template: "<div\n class=\"canvas-viewport\"\n [class.reduced-motion]=\"reducedMotion\"\n [style]=\"backgroundStyle()\"\n role=\"region\"\n aria-label=\"Canvas viewport\"\n tabindex=\"0\"\n (pointerdown)=\"onPointerDown($event)\"\n (pointermove)=\"onPointerMove($event)\"\n (pointerup)=\"onPointerUp($event)\"\n (pointercancel)=\"onPointerUp($event)\"\n (wheel)=\"onWheel($event)\">\n\n <div class=\"canvas-world\" [style.transform]=\"worldTransform\">\n\n <!-- Tiled/stretched backdrop covering the canvas bounds -->\n @if (worldBackgroundStyle(); as bgStyle) {\n <div class=\"canvas-backdrop\" [style]=\"bgStyle\" aria-hidden=\"true\"></div>\n }\n\n <!-- Non-panel set dressing -->\n @for (decoration of visibleDecorations; track decoration.assetId + decoration.x + decoration.y) {\n <img\n class=\"canvas-decoration\"\n [src]=\"assetUrl(decoration.assetId)\"\n [style]=\"decorationStyle(decoration)\"\n alt=\"\"\n aria-hidden=\"true\"\n loading=\"lazy\" />\n }\n\n <!-- Placed panels (virtualized) -->\n @for (entry of stagePlacements; track trackPlacement($index, entry)) {\n <div\n class=\"canvas-panel\"\n [class.current]=\"isCurrent(entry.panelId)\"\n [class.unrevealed]=\"!entry.revealed\"\n [style]=\"placementStyle(entry)\"\n [attr.data-panel-id]=\"entry.panelId\"\n [pwPanelAnimation]=\"entry.revealed ? entry.panel.animations : null\"\n [pwPanelAnimationKey]=\"entry.panelId\"\n [pwPanelAnimationReducedMotion]=\"reducedMotion\"\n [attr.aria-hidden]=\"isCurrent(entry.panelId) ? null : true\"\n [attr.inert]=\"isCurrent(entry.panelId) ? null : ''\">\n\n @if (entry.revealed) {\n <!-- The panel's artwork; a panel camera move (PanelAnimations) transforms it as one. -->\n <div class=\"pw-panel-camera\">\n @if (isLocked(entry.panel, entry.panelId)) {\n <pw-locked-panel-placeholder></pw-locked-panel-placeholder>\n } @else {\n @for (layer of entry.panel.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n viewMode=\"panel\"\n [viewActive]=\"isCurrent(entry.panelId)\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(entry.panelId)\"\n [panelId]=\"entry.panelId\">\n </pw-layer-renderer>\n }\n\n <!-- Hotspots are interactive on the CURRENT panel (the one the\n camera frames); other panels show them render-only, and a\n tap there keeps its panel-navigation meaning. -->\n @if (entry.panel.hotspots && entry.panel.hotspots.length > 0) {\n <pw-hotspots-overlay\n [hotspots]=\"entry.panel.hotspots\"\n [locale]=\"locale\"\n [containerWidth]=\"entry.placement.w\"\n [containerHeight]=\"entry.placement.h\"\n [context]=\"variableContext\"\n [interactive]=\"interactive && isCurrent(entry.panelId)\"\n (hotspotActivate)=\"hotspotActivate.emit({ hotspot: $event.hotspot, x: $event.x, y: $event.y, panelId: entry.panelId })\">\n </pw-hotspots-overlay>\n }\n\n @if (speechEnabled && entry.panel.speechBubbles && entry.panel.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"entry.panel.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"entry.placement.w\"\n [containerHeight]=\"entry.placement.h\"\n [readingScale]=\"getReadingScale()\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n } @else {\n <!-- Unrevealed panel: dark veil, no content (spoiler protection) -->\n <div class=\"canvas-panel-veil\" aria-hidden=\"true\"></div>\n }\n </div>\n }\n </div>\n</div>\n", styles: [":host{display:block;width:100%;height:100%}.canvas-viewport{position:relative;width:100%;height:100%;overflow:hidden;background-color:#101014;touch-action:none;cursor:grab;outline:none}.canvas-viewport:active{cursor:grabbing}.canvas-world{position:absolute;top:0;left:0;width:0;height:0;transform-origin:0 0;will-change:transform}.canvas-backdrop{position:absolute;pointer-events:none}.canvas-decoration{position:absolute;pointer-events:none;max-width:none}.canvas-panel{position:absolute;overflow:hidden;background:#000;contain:layout paint;content-visibility:auto;box-shadow:0 8px 40px #0000008c}.canvas-panel.current{box-shadow:0 0 0 2px #ffffff2e,0 8px 48px #0009}.pw-panel-camera{position:absolute;inset:0}.canvas-panel-veil{position:absolute;inset:0;background:radial-gradient(ellipse at center,#1e1e26eb,#0a0a0efa)}.reduced-motion .canvas-world{will-change:auto}\n"], dependencies: [{ kind: "component", type: LayerRendererComponent, selector: "pw-layer-renderer", inputs: ["layer", "locale", "targetWidth", "altText", "viewMode", "viewActive", "reducedMotion", "panelId", "placementId", "readingOrderIndex", "placementZ", "placementY", "placementX"], outputs: ["layerClick"] }, { kind: "component", type: SpeechBubblesComponent, selector: "pw-speech-bubbles", inputs: ["bubbles", "locale", "containerWidth", "containerHeight", "readingScale", "workBalloonConfig", "characters"], outputs: ["bubbleClick", "bubbleAudioPlay"] }, { kind: "component", type: HotspotsOverlayComponent, selector: "pw-hotspots-overlay", inputs: ["hotspots", "locale", "containerWidth", "containerHeight", "context", "interactive", "showIndicators"], outputs: ["hotspotActivate"] }, { kind: "directive", type: PanelAnimationDirective, selector: "[pwPanelAnimation]", inputs: ["pwPanelAnimation", "pwPanelAnimationKey", "pwPanelAnimationReducedMotion", "pwPanelAnimationStatic"] }, { kind: "component", type: LockedPanelComponent, selector: "pw-locked-panel-placeholder", inputs: ["actionable"], outputs: ["activate"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
15993
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.14", type: CanvasStageComponent, isStandalone: true, selector: "pw-canvas-stage", inputs: { canvas: "canvas", panels: "panels", graph: "graph", currentPanelId: "currentPanelId", visitedPanelIds: "visitedPanelIds", locale: "locale", variableContext: "variableContext", speechEnabled: "speechEnabled", workBalloonConfig: "workBalloonConfig", characters: "characters", reducedMotion: "reducedMotion", interactive: "interactive", preload: "preload" }, outputs: { panelTap: "panelTap", navigateNext: "navigateNext", navigatePrevious: "navigatePrevious", viewportClick: "viewportClick", hotspotActivate: "hotspotActivate", cameraChange: "cameraChange" }, usesOnChanges: true, ngImport: i0, template: "<div\n class=\"canvas-viewport\"\n [class.reduced-motion]=\"reducedMotion\"\n [style]=\"backgroundStyle()\"\n role=\"region\"\n aria-label=\"Canvas viewport\"\n tabindex=\"0\"\n (pointerdown)=\"onPointerDown($event)\"\n (pointermove)=\"onPointerMove($event)\"\n (pointerup)=\"onPointerUp($event)\"\n (pointercancel)=\"onPointerUp($event)\"\n (wheel)=\"onWheel($event)\">\n\n <div class=\"canvas-world\" [style.transform]=\"worldTransform\">\n\n <!-- Tiled/stretched backdrop covering the canvas bounds -->\n @if (worldBackgroundStyle(); as bgStyle) {\n <div class=\"canvas-backdrop\" [style]=\"bgStyle\" aria-hidden=\"true\"></div>\n }\n\n <!-- Non-panel set dressing -->\n @for (decoration of visibleDecorations; track decoration.assetId + decoration.x + decoration.y) {\n <img\n class=\"canvas-decoration\"\n [src]=\"assetUrl(decoration.assetId)\"\n [style]=\"decorationStyle(decoration)\"\n alt=\"\"\n aria-hidden=\"true\"\n loading=\"lazy\" />\n }\n\n <!-- Placed panels (virtualized) -->\n @for (entry of stagePlacements; track trackPlacement($index, entry)) {\n <div\n class=\"canvas-panel\"\n [class.current]=\"isCurrent(entry.panelId)\"\n [class.unrevealed]=\"!entry.revealed\"\n [style]=\"placementStyle(entry)\"\n [attr.data-panel-id]=\"entry.panelId\"\n [pwPanelAnimation]=\"entry.revealed ? entry.panel.animations : null\"\n [pwPanelAnimationKey]=\"entry.panelId\"\n [pwPanelAnimationReducedMotion]=\"reducedMotion\"\n [attr.aria-hidden]=\"isCurrent(entry.panelId) ? null : true\"\n [attr.inert]=\"isCurrent(entry.panelId) ? null : ''\">\n\n @if (entry.revealed) {\n <!-- The panel's artwork; a panel camera move (PanelAnimations) transforms it as one. -->\n <div class=\"pw-panel-camera\">\n @if (isLocked(entry.panel, entry.panelId)) {\n <pw-locked-panel-placeholder></pw-locked-panel-placeholder>\n } @else {\n @for (layer of entry.panel.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n viewMode=\"panel\"\n [viewActive]=\"isCurrent(entry.panelId)\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(entry.panelId)\"\n [panelId]=\"entry.panelId\">\n </pw-layer-renderer>\n }\n\n <!-- Hotspots are interactive on the CURRENT panel (the one the\n camera frames); other panels show them render-only, and a\n tap there keeps its panel-navigation meaning. -->\n @if (entry.panel.hotspots && entry.panel.hotspots.length > 0) {\n <pw-hotspots-overlay\n [hotspots]=\"entry.panel.hotspots\"\n [locale]=\"locale\"\n [containerWidth]=\"entry.placement.w\"\n [containerHeight]=\"entry.placement.h\"\n [context]=\"variableContext\"\n [interactive]=\"interactive && isCurrent(entry.panelId)\"\n (hotspotActivate)=\"hotspotActivate.emit({ hotspot: $event.hotspot, x: $event.x, y: $event.y, panelId: entry.panelId })\">\n </pw-hotspots-overlay>\n }\n\n @if (speechEnabled && entry.panel.speechBubbles && entry.panel.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"entry.panel.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"entry.placement.w\"\n [containerHeight]=\"entry.placement.h\"\n [readingScale]=\"getReadingScale()\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n } @else {\n <!-- Unrevealed panel: dark veil, no content (spoiler protection) -->\n <div class=\"canvas-panel-veil\" aria-hidden=\"true\"></div>\n }\n </div>\n }\n </div>\n</div>\n", styles: [":host{display:block;width:100%;height:100%}.canvas-viewport{position:relative;width:100%;height:100%;overflow:hidden;background-color:#101014;touch-action:none;cursor:grab;outline:none}.canvas-viewport:active{cursor:grabbing}.canvas-world{position:absolute;top:0;left:0;width:0;height:0;transform-origin:0 0;will-change:transform}.canvas-backdrop{position:absolute;pointer-events:none}.canvas-decoration{position:absolute;pointer-events:none;max-width:none}.canvas-panel{position:absolute;overflow:hidden;background:#000;contain:layout paint;content-visibility:auto;box-shadow:0 8px 40px #0000008c}.canvas-panel.current{box-shadow:0 0 0 2px #ffffff2e,0 8px 48px #0009}.pw-panel-camera{position:absolute;inset:0}.canvas-panel-veil{position:absolute;inset:0;background:radial-gradient(ellipse at center,#1e1e26eb,#0a0a0efa)}.reduced-motion .canvas-world{will-change:auto}\n"], dependencies: [{ kind: "component", type: LayerRendererComponent, selector: "pw-layer-renderer", inputs: ["layer", "locale", "targetWidth", "altText", "viewMode", "viewActive", "reducedMotion", "panelId", "placementId", "readingOrderIndex", "placementZ", "placementY", "placementX"], outputs: ["layerClick"] }, { kind: "component", type: SpeechBubblesComponent, selector: "pw-speech-bubbles", inputs: ["bubbles", "locale", "containerWidth", "containerHeight", "readingScale", "anchorTailTip", "workBalloonConfig", "characters"], outputs: ["bubbleClick", "bubbleAudioPlay"] }, { kind: "component", type: HotspotsOverlayComponent, selector: "pw-hotspots-overlay", inputs: ["hotspots", "bubbles", "locale", "containerWidth", "containerHeight", "context", "interactive", "showIndicators"], outputs: ["hotspotActivate"] }, { kind: "directive", type: PanelAnimationDirective, selector: "[pwPanelAnimation]", inputs: ["pwPanelAnimation", "pwPanelAnimationKey", "pwPanelAnimationReducedMotion", "pwPanelAnimationStatic"] }, { kind: "component", type: LockedPanelComponent, selector: "pw-locked-panel-placeholder", inputs: ["actionable"], outputs: ["activate"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
15290
15994
  }
15291
15995
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: CanvasStageComponent, decorators: [{
15292
15996
  type: Component,
@@ -15798,6 +16502,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
15798
16502
  * Thumbnail Strip Component
15799
16503
  * Displays panel thumbnails for navigation
15800
16504
  */
16505
+ /** Two id lists hold the same ids in the same order. */
16506
+ function sameIds(a, b) {
16507
+ if (a === b)
16508
+ return true;
16509
+ if (!a || !b || a.length !== b.length)
16510
+ return false;
16511
+ return a.every((id, i) => id === b[i]);
16512
+ }
15801
16513
  /**
15802
16514
  * Thumbnail Strip Component
15803
16515
  * Horizontal strip for quick navigation: the cover, then per chapter a
@@ -15873,6 +16585,13 @@ class ThumbnailStripComponent {
15873
16585
  * Handle changes
15874
16586
  */
15875
16587
  ngOnChanges(changes) {
16588
+ // Hosts may hand over a fresh but equal array on every change detection
16589
+ // (e.g. a getter). Treating that as a change would rebuild the strip and
16590
+ // snap it back to the current panel while the reader scrolls it.
16591
+ const ids = changes['currentPanelIds'];
16592
+ if (ids && !ids.firstChange && sameIds(ids.previousValue, ids.currentValue)) {
16593
+ delete changes['currentPanelIds'];
16594
+ }
15876
16595
  const rebuild = ['chapters', 'currentChapterId', 'currentPanelId', 'currentPanelIds', 'lockedPanels', 'coverThumbnail', 'coverCurrent', 'locale'];
15877
16596
  if (rebuild.some((key) => changes[key])) {
15878
16597
  this.buildThumbnailItems();
@@ -19091,6 +19810,7 @@ class PlayerShellComponent {
19091
19810
  this.cdr = inject(ChangeDetectorRef);
19092
19811
  this.audioEngine = inject(AudioEngineService);
19093
19812
  this.panelAudio = inject(PanelAudioService);
19813
+ this.sequenceAudio = inject(SequenceAudioService);
19094
19814
  /**
19095
19815
  * Reading order per panel id (1-based, across chapters), computed once per
19096
19816
  * manifest. Reported as `panelOrder` on panel_view / funnel events and as
@@ -19227,6 +19947,7 @@ class PlayerShellComponent {
19227
19947
  this.autoplayEnabled = false;
19228
19948
  this.videoSequencer.reset();
19229
19949
  this.panelAudio.stopAll();
19950
+ this.sequenceAudio.stopAll();
19230
19951
  this.endAnalyticsSession();
19231
19952
  this.analyticsSessionStarted = this.analyticsSessionEnded = this.workCompleteTracked = false;
19232
19953
  this.lastTrackedPanelId = undefined;
@@ -19293,6 +20014,7 @@ class PlayerShellComponent {
19293
20014
  this.stopAutoplayProgress();
19294
20015
  this.videoSequencer.reset();
19295
20016
  this.panelAudio.stopAll();
20017
+ this.sequenceAudio.stopAll();
19296
20018
  this.destroy$.next();
19297
20019
  this.destroy$.complete();
19298
20020
  }
@@ -19850,6 +20572,9 @@ class PlayerShellComponent {
19850
20572
  (isLockedPanel(panel) || (panelId !== undefined && this.paywallService.isPanelLocked(panelId)));
19851
20573
  // Under the cover the story has not started yet: no panel audio.
19852
20574
  this.panelAudio.syncPanel(panelId, locked || this.coverVisible ? undefined : panel, this.variableContext);
20575
+ // Timeline music/ambience follows the reader's position on the work line
20576
+ // (silent under the cover and on locked panels too).
20577
+ this.sequenceAudio.syncPanel(locked || this.coverVisible ? undefined : panelId, this.activePageFormat);
19853
20578
  }
19854
20579
  /**
19855
20580
  * Hotspot activated in the viewport (click or keyboard): execute its
@@ -21241,6 +21966,8 @@ class PlayerShellComponent {
21241
21966
  return;
21242
21967
  }
21243
21968
  this.activePageFormat = next;
21969
+ // The format decides the panel line timeline audio is placed on.
21970
+ this.syncPanelAudio();
21244
21971
  if (this.viewMode === 'page') {
21245
21972
  const page = this.findPageContainingPanel();
21246
21973
  if (page && page.id !== this.currentPage?.id) {
@@ -22132,5 +22859,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
22132
22859
  * Generated bundle index. Do not edit.
22133
22860
  */
22134
22861
 
22135
- export { ANONYMOUS_READER, ActionModalComponent, AgeGateComponent, AssetUrlService, AudioEngineService, BranchChooserComponent, CANVAS_PANEL_BUDGET, CanvasCameraService, CanvasStageComponent, ComicBalloon, DEFAULT_BALLOON_CONFIG, DEFAULT_PAGE_BACKGROUND, DEFAULT_STALL_TIMEOUT_MS, DEFAULT_VIDEO_MUTED, DEFAULT_VIDEO_PLAY_MODE, DEFAULT_VIDEO_START_MODE, EntitlementError, EntitlementService, ExportService, FlowEngineService, HotspotActionService, HotspotsOverlayComponent, HttpEntitlementAdapter, ImageCacheService, LOCATION_PARAMS, ManifestService, MockEntitlementAdapter, NullEntitlementAdapter, PANEL_AUDIO_LEAVE_FADE_MS, PanelAudioService, PaywallOverlayComponent, PaywallService, PlayerComponent, PlayerEvent, PlayerService, PlayerShellComponent, PlayerStateService, PluginHostService, PluginSandboxComponent, PreloadService, PwIconComponent, RESERVED_NAMESPACES, SpeechBubblesComponent, THUMBNAIL_WIDTH, TrackingService, TranslationService, UserGestureService, VISIBILITY_THRESHOLD, VariableStoreService, VideoControllerService, VideoLayerComponent, VideoSequencerService, VisibilityService, applyVariantOverrides, balloonConfigToRenderOptions, balloonConfigToTailOptions, busForRole, chapterOrderFromManifest, coverImageSrc, createBalloon, evaluatePanelAccess, evaluateWorkAccess, findWorkGateRule, fromManifestRule, isExtraLocked, isLockedPanel, locationUrl, mergeBalloonConfig, pageAspectRatio, pageFormatOf, pageFormatsOf, pagesForFormat, panelThumbnailSrc, parseLocationSearch, pickPageFormat, rankPageFormats, readingOrderFromManifest, resolveMuted, resolvePageBackground, resolvePanelVariant, resolvePanels, resolvePlayMode, resolveStartMode, resolveVideoConfig, rulesFromManifest, satisfiesRule, screenClassFor, selectVariant, toEntitlementStatus };
22862
+ export { ANONYMOUS_READER, ActionModalComponent, AgeGateComponent, AssetUrlService, AudioEngineService, BranchChooserComponent, CANVAS_PANEL_BUDGET, CanvasCameraService, CanvasStageComponent, ComicBalloon, DEFAULT_BALLOON_CONFIG, DEFAULT_PAGE_BACKGROUND, DEFAULT_STALL_TIMEOUT_MS, DEFAULT_VIDEO_MUTED, DEFAULT_VIDEO_PLAY_MODE, DEFAULT_VIDEO_START_MODE, EntitlementError, EntitlementService, ExportService, FlowEngineService, HotspotActionService, HotspotsOverlayComponent, HttpEntitlementAdapter, ImageCacheService, LOCATION_PARAMS, ManifestService, MockEntitlementAdapter, NullEntitlementAdapter, PANEL_AUDIO_LEAVE_FADE_MS, PanelAudioService, PaywallOverlayComponent, PaywallService, PlayerComponent, PlayerEvent, PlayerService, PlayerShellComponent, PlayerStateService, PluginHostService, PluginSandboxComponent, PreloadService, PwIconComponent, RESERVED_NAMESPACES, SEQUENCE_AUDIO_LEAVE_FADE_MS, SequenceAudioService, SpeechBubblesComponent, THUMBNAIL_WIDTH, TrackingService, TranslationService, UserGestureService, VISIBILITY_THRESHOLD, VariableStoreService, VideoControllerService, VideoLayerComponent, VideoSequencerService, VisibilityService, applyVariantOverrides, balloonConfigToRenderOptions, balloonConfigToTailOptions, buildWorkTimeline, busForRole, chapterOrderFromManifest, coverImageSrc, createBalloon, cuesForPanel, evaluatePanelAccess, evaluateWorkAccess, findWorkGateRule, fromManifestRule, isExtraLocked, isLockedPanel, locationUrl, mergeBalloonConfig, pageAspectRatio, pageFormatOf, pageFormatsOf, pagesForFormat, panelThumbnailSrc, parseLocationSearch, pickPageFormat, rankPageFormats, readingOrderFromManifest, resolveMuted, resolvePageBackground, resolvePanelVariant, resolvePanels, resolvePlayMode, resolveStartMode, resolveTrackRanges, resolveVideoConfig, rulesFromManifest, satisfiesRule, screenClassFor, selectVariant, toEntitlementStatus };
22136
22863
  //# sourceMappingURL=panelwave-player.mjs.map