@panelwave/player 1.3.0 → 1.5.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,
@@ -11807,6 +11950,14 @@ class SpeechBubblesComponent {
11807
11950
  this.bubbleAudioPlay = new EventEmitter();
11808
11951
  /** Rendered balloon containers, keyed by bubble ID */
11809
11952
  this.renderedBalloons = new Map();
11953
+ /**
11954
+ * Font specs ("15px 'Ames Italic', sans-serif") whose loading this overlay
11955
+ * already requested. Web fonts load lazily on first use, so the first
11956
+ * render may have measured with a fallback face while document.fonts.ready
11957
+ * had long resolved (no font was in flight yet). Each new spec is loaded
11958
+ * explicitly and the balloons re-render once it arrives.
11959
+ */
11960
+ this.fontsRequested = new Set();
11810
11961
  /** ResizeObserver for container size tracking */
11811
11962
  this.resizeObserver = null;
11812
11963
  this.initialized = false;
@@ -11938,11 +12089,13 @@ class SpeechBubblesComponent {
11938
12089
  return;
11939
12090
  if (this.containerWidth <= 0 || this.containerHeight <= 0)
11940
12091
  return;
12092
+ const fontSpecs = new Set();
11941
12093
  for (const bubble of this.bubbles) {
11942
12094
  const text = this.getLocalizedText(bubble);
11943
12095
  if (!text)
11944
12096
  continue;
11945
12097
  const config = this.resolveEffectiveConfig(bubble);
12098
+ fontSpecs.add(`${config.fontSize}px ${config.fontFamily}`);
11946
12099
  const pos = this.computeBubblePosition(bubble);
11947
12100
  // Create wrapper div for positioning
11948
12101
  const wrapper = document.createElement('div');
@@ -11990,17 +12143,46 @@ class SpeechBubblesComponent {
11990
12143
  }
11991
12144
  const visualW = svgWidth * scale;
11992
12145
  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).
12146
+ // Auto-grow (schema 1.8 textFit): when the text needed more room than
12147
+ // the authored size, the engine grew the body. Place the grown body
12148
+ // so the tail tip stays on the speaker (the body moves away from it)
12149
+ // — the same rule the CMS editor applies; glued caption edges and the
12150
+ // panel clamp below still win. Offsets are in balloon px, scaled.
12151
+ let grownOffsetX = 0;
12152
+ let grownOffsetY = 0;
12153
+ if (result.grown) {
12154
+ const authoredRect = {
12155
+ x: -result.authoredWidth / 2, y: -result.authoredHeight / 2,
12156
+ width: result.authoredWidth, height: result.authoredHeight,
12157
+ };
12158
+ const tail = tailOpts && tailOpts.position !== undefined
12159
+ ? { position: tailOpts.position, length: tailOpts.length }
12160
+ : null;
12161
+ const placed = placeGrownBody(authoredRect, { width: result.width, height: result.height }, tail, Number(renderOpts['cornerRadius']) || 0, null);
12162
+ grownOffsetX = (placed.x + placed.width / 2) * scale;
12163
+ grownOffsetY = (placed.y + placed.height / 2) * scale;
12164
+ wrapper.setAttribute('data-grown', 'true');
12165
+ }
12166
+ // The stored box is the balloon BODY (the editor stores the body, not
12167
+ // the tail). The SVG's viewBox also holds the tail and the stroke
12168
+ // padding, so its centre is not the body's: offset of the body centre
12169
+ // from the SVG centre, in balloon px (the body starts at viewBox 0,0).
12170
+ const vb = (result.svg?.getAttribute('viewBox') ?? '').split(/\s+/).map(Number);
12171
+ const hasViewBox = vb.length === 4 && vb.every((v) => Number.isFinite(v));
12172
+ const bodyOffsetX = hasViewBox ? (-vb[0] + result.width / 2) - svgWidth / 2 : 0;
12173
+ const bodyOffsetY = hasViewBox ? (-vb[1] + result.height / 2) - svgHeight / 2 : 0;
12174
+ // Anchor the body at the shape box's center (shifted for a grown
12175
+ // body) — but edges the authored box touches stay glued to the panel
12176
+ // border (narrator / Panel-Top caption boxes keep sitting exactly on
12177
+ // the border even though the visual size no longer equals the stored
12178
+ // box). centerX/centerY are where the SVG's centre goes.
11997
12179
  const boxLeft = (bubble.shape?.x ?? 0) * this.containerWidth;
11998
12180
  const boxTop = (bubble.shape?.y ?? 0) * this.containerHeight;
11999
12181
  const boxRight = boxLeft + (bubble.shape?.w ?? 0) * this.containerWidth;
12000
12182
  const boxBottom = boxTop + (bubble.shape?.h ?? 0) * this.containerHeight;
12001
12183
  const eps = Math.max(4, 0.02 * Math.min(this.containerWidth, this.containerHeight));
12002
- let centerX = pos.left;
12003
- let centerY = pos.top;
12184
+ let centerX = pos.left + grownOffsetX - bodyOffsetX * scale;
12185
+ let centerY = pos.top + grownOffsetY - bodyOffsetY * scale;
12004
12186
  if (boxLeft <= eps)
12005
12187
  centerX = visualW / 2;
12006
12188
  else if (boxRight >= this.containerWidth - eps)
@@ -12049,6 +12231,45 @@ class SpeechBubblesComponent {
12049
12231
  console.warn(`[pw-speech-bubbles] Failed to render balloon "${bubble.id}":`, e);
12050
12232
  }
12051
12233
  }
12234
+ this.ensureFontsLoaded(fontSpecs);
12235
+ }
12236
+ /**
12237
+ * Request every font spec the balloons use that was not requested before;
12238
+ * re-render once a face that was not yet available arrives, so balloons
12239
+ * measured with a fallback face are laid out again with the real one.
12240
+ */
12241
+ ensureFontsLoaded(specs) {
12242
+ if (typeof document === 'undefined' || !document.fonts?.load)
12243
+ return;
12244
+ const pending = [];
12245
+ for (const spec of specs) {
12246
+ if (this.fontsRequested.has(spec))
12247
+ continue;
12248
+ this.fontsRequested.add(spec);
12249
+ let available = false;
12250
+ try {
12251
+ available = document.fonts.check(spec);
12252
+ }
12253
+ catch {
12254
+ available = false;
12255
+ }
12256
+ if (available)
12257
+ continue;
12258
+ try {
12259
+ pending.push(document.fonts.load(spec));
12260
+ }
12261
+ catch {
12262
+ // Unparseable spec (custom family string): nothing to wait for.
12263
+ }
12264
+ }
12265
+ if (pending.length === 0)
12266
+ return;
12267
+ Promise.allSettled(pending).then((results) => {
12268
+ const loadedAny = results.some((r) => r.status === 'fulfilled' && Array.isArray(r.value) && r.value.length > 0);
12269
+ if (loadedAny && this.initialized) {
12270
+ this.renderAllBalloons();
12271
+ }
12272
+ });
12052
12273
  }
12053
12274
  /**
12054
12275
  * Handle bubble click (called from template for any fallback interaction)
@@ -12100,6 +12321,11 @@ class HotspotsOverlayComponent {
12100
12321
  * Hotspots of the current panel (manifest shape)
12101
12322
  */
12102
12323
  this.hotspots = [];
12324
+ /**
12325
+ * The panel's speech bubbles: a choice hotspot whose label is already
12326
+ * lettered as a bubble keeps its invisible area under `display: auto`.
12327
+ */
12328
+ this.bubbles = [];
12103
12329
  /**
12104
12330
  * Current locale for label resolution
12105
12331
  */
@@ -12149,8 +12375,17 @@ class HotspotsOverlayComponent {
12149
12375
  const goTos = (this.hotspots ?? []).filter((h) => h.action?.type === 'goTo');
12150
12376
  const targets = new Set(goTos.map((h) => h.action.to));
12151
12377
  const isChoice = new Set(targets.size > 1 ? goTos.map((h) => h.id) : []);
12378
+ // A choice whose label is already lettered as a speech bubble of the panel
12379
+ // (an authored "choice" caption sitting on the hotspot) stays an invisible
12380
+ // area under auto — the balloon IS the button; drawing the label again
12381
+ // would show the text twice.
12382
+ const bubbleTexts = new Set((this.bubbles ?? [])
12383
+ .map((b) => this.resolveLocalized(b.text).trim().toLowerCase())
12384
+ .filter((t) => t.length > 0));
12385
+ const letteredAsBubble = (h) => bubbleTexts.has(this.resolveLocalized(h.label).trim().toLowerCase());
12152
12386
  this.choiceIds = new Set((this.hotspots ?? [])
12153
- .filter((h) => h.display === 'button' || ((h.display ?? 'auto') === 'auto' && isChoice.has(h.id)))
12387
+ .filter((h) => h.display === 'button'
12388
+ || ((h.display ?? 'auto') === 'auto' && isChoice.has(h.id) && !letteredAsBubble(h)))
12154
12389
  .map((h) => h.id));
12155
12390
  }
12156
12391
  /** The visible button text of a choice hotspot (its label), or '' for plain areas. */
@@ -12264,13 +12499,15 @@ class HotspotsOverlayComponent {
12264
12499
  return Math.min(1, Math.max(0, v));
12265
12500
  }
12266
12501
  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 }); }
12502
+ 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
12503
  }
12269
12504
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: HotspotsOverlayComponent, decorators: [{
12270
12505
  type: Component,
12271
12506
  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
12507
  }], propDecorators: { hotspots: [{
12273
12508
  type: Input
12509
+ }], bubbles: [{
12510
+ type: Input
12274
12511
  }], locale: [{
12275
12512
  type: Input
12276
12513
  }], containerWidth: [{
@@ -12620,6 +12857,63 @@ class ViewportComponent {
12620
12857
  */
12621
12858
  this.focus = NO_FOCUS_TRANSFORM;
12622
12859
  this.focusFrame = null;
12860
+ /**
12861
+ * Panel-view box: the largest box of the panel's artwork aspect that fits
12862
+ * the viewport, so the panel fills the screen and speech bubbles (normalised
12863
+ * to the panel) land on the artwork. Null before the first measurement
12864
+ * (the stylesheet's default box applies).
12865
+ */
12866
+ this.panelBox = null;
12867
+ }
12868
+ /**
12869
+ * Aspect ratio (w / h) of the current panel's artwork: its background
12870
+ * image's largest variant, else the first image or video asset with known
12871
+ * dimensions, else 16:9 — the same reference the CMS editor sizes its
12872
+ * panel-view content rect from.
12873
+ */
12874
+ get panelAspect() {
12875
+ const layers = this.panel?.layers ?? [];
12876
+ const candidates = [
12877
+ ...layers.filter((l) => (l.z ?? 0) === 0 && l.kind === 'image'),
12878
+ ...layers.filter((l) => l.kind === 'image' || l.kind === 'video'),
12879
+ ];
12880
+ for (const layer of candidates) {
12881
+ const assetId = layer.assetId;
12882
+ if (!assetId)
12883
+ continue;
12884
+ const asset = this.manifestService.getAsset(assetId);
12885
+ let w = 0;
12886
+ let h = 0;
12887
+ for (const v of asset?.variants ?? []) {
12888
+ if ((v.w ?? 0) > w && (v.h ?? 0) > 0) {
12889
+ w = v.w ?? 0;
12890
+ h = v.h ?? 0;
12891
+ }
12892
+ }
12893
+ if (!(w > 0 && h > 0) && (asset?.width ?? 0) > 0 && (asset?.height ?? 0) > 0) {
12894
+ w = asset.width;
12895
+ h = asset.height;
12896
+ }
12897
+ if (w > 0 && h > 0)
12898
+ return w / h;
12899
+ }
12900
+ return 16 / 9;
12901
+ }
12902
+ /** Largest box of `aspect` inside a viewport (0 when the viewport is unmeasured). */
12903
+ static fitBox(viewportWidth, viewportHeight, aspect) {
12904
+ if (!(viewportWidth > 0) || !(viewportHeight > 0) || !(aspect > 0))
12905
+ return null;
12906
+ const width = Math.min(viewportWidth, viewportHeight * aspect);
12907
+ return { width: Math.round(width), height: Math.round(width / aspect) };
12908
+ }
12909
+ /**
12910
+ * Page view shows a panel's whole artwork in its box, as the editor does;
12911
+ * the placement's visible area (vx/vy/vw/vh) only crops on compact screens
12912
+ * (phones), where the page's panels run edge to edge.
12913
+ */
12914
+ isCompactHost() {
12915
+ const width = this.elementRef.nativeElement.getBoundingClientRect().width;
12916
+ return width > 0 && width < COMPACT_PAGE_WIDTH;
12623
12917
  }
12624
12918
  /** Recompute the focus placement once the current panel is laid out. */
12625
12919
  scheduleFocusUpdate() {
@@ -12637,18 +12931,39 @@ class ViewportComponent {
12637
12931
  /** Measure viewport + panel box and place the panel on its focus rect. */
12638
12932
  updateFocusTransform() {
12639
12933
  let next = NO_FOCUS_TRANSFORM;
12640
- if (this.viewMode === 'panel' && this.panel?.formatViews) {
12934
+ let changed = false;
12935
+ if (this.viewMode === 'panel' && this.panel) {
12641
12936
  const host = this.elementRef.nativeElement;
12642
12937
  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);
12938
+ // The panel fills the viewport at its artwork's aspect. On a compact
12939
+ // screen (phone) with a focus rect, the panel is enlarged so that the
12940
+ // rect fills the screen instead — a larger image part on a small
12941
+ // screen — and the crop is centred on the rect below.
12942
+ const aspect = this.panelAspect;
12943
+ const rect = this.panel.formatViews && this.isCompactHost()
12944
+ ? pickFocusRect(this.panel.formatViews, viewport.clientWidth, viewport.clientHeight)
12945
+ : null;
12946
+ const box = rect && rect.w > 0 && rect.h > 0
12947
+ ? ViewportComponent.fitBox(viewport.clientWidth / rect.w, viewport.clientHeight / rect.h, aspect)
12948
+ : ViewportComponent.fitBox(viewport.clientWidth, viewport.clientHeight, aspect);
12949
+ if (box && (box.width !== this.panelBox?.width || box.height !== this.panelBox?.height)) {
12950
+ this.panelBox = box;
12951
+ changed = true;
12952
+ }
12953
+ if (rect && box) {
12954
+ next = focusTransform(box.width, box.height, viewport.clientWidth, viewport.clientHeight, rect);
12647
12955
  }
12648
12956
  }
12649
12957
  if (next.offsetX !== this.focus.offsetX || next.offsetY !== this.focus.offsetY || next.scale !== this.focus.scale) {
12650
12958
  this.focus = next;
12959
+ changed = true;
12960
+ }
12961
+ if (changed) {
12651
12962
  this.cdr.markForCheck();
12963
+ // Bubble/hotspot overlays read the container size through input
12964
+ // bindings; the resized box needs one more pass to reach them.
12965
+ if (typeof requestAnimationFrame !== 'undefined')
12966
+ requestAnimationFrame(() => this.cdr.markForCheck());
12652
12967
  }
12653
12968
  }
12654
12969
  ngOnChanges(changes) {
@@ -13545,8 +13860,10 @@ class ViewportComponent {
13545
13860
  const vy = placement.vy ?? 0;
13546
13861
  const vw = placement.vw ?? 1;
13547
13862
  const vh = placement.vh ?? 1;
13548
- // If full panel is visible, no clipping needed
13549
- if (vx === 0 && vy === 0 && vw === 1 && vh === 1) {
13863
+ // If full panel is visible, no clipping needed; on screens that are not
13864
+ // compact the whole artwork is shown anyway (the visible area is a hint
13865
+ // for small screens, see isCompactHost).
13866
+ if ((vx === 0 && vy === 0 && vw === 1 && vh === 1) || !this.isCompactHost()) {
13550
13867
  return 'none';
13551
13868
  }
13552
13869
  // Calculate inset values as percentages
@@ -13564,8 +13881,8 @@ class ViewportComponent {
13564
13881
  getPanelContentTransform(placement) {
13565
13882
  const vx = placement.vx ?? 0;
13566
13883
  const vy = placement.vy ?? 0;
13567
- // If no offset, return none
13568
- if (vx === 0 && vy === 0) {
13884
+ // If no offset, return none (and never shift on screens that are not compact)
13885
+ if ((vx === 0 && vy === 0) || !this.isCompactHost()) {
13569
13886
  return 'none';
13570
13887
  }
13571
13888
  // Translate content by negative offset to show the visible portion
@@ -14076,11 +14393,11 @@ class ViewportComponent {
14076
14393
  console.groupEnd();
14077
14394
  }
14078
14395
  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 }); }
14396
+ 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 [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 [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", "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
14397
  }
14081
14398
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ViewportComponent, decorators: [{
14082
14399
  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"] }]
14400
+ 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 [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 [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
14401
  }], propDecorators: { panel: [{
14085
14402
  type: Input
14086
14403
  }], page: [{
@@ -15286,7 +15603,7 @@ class CanvasStageComponent {
15286
15603
  return Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y);
15287
15604
  }
15288
15605
  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 }); }
15606
+ 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", "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
15607
  }
15291
15608
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: CanvasStageComponent, decorators: [{
15292
15609
  type: Component,
@@ -15798,6 +16115,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
15798
16115
  * Thumbnail Strip Component
15799
16116
  * Displays panel thumbnails for navigation
15800
16117
  */
16118
+ /** Two id lists hold the same ids in the same order. */
16119
+ function sameIds(a, b) {
16120
+ if (a === b)
16121
+ return true;
16122
+ if (!a || !b || a.length !== b.length)
16123
+ return false;
16124
+ return a.every((id, i) => id === b[i]);
16125
+ }
15801
16126
  /**
15802
16127
  * Thumbnail Strip Component
15803
16128
  * Horizontal strip for quick navigation: the cover, then per chapter a
@@ -15873,6 +16198,13 @@ class ThumbnailStripComponent {
15873
16198
  * Handle changes
15874
16199
  */
15875
16200
  ngOnChanges(changes) {
16201
+ // Hosts may hand over a fresh but equal array on every change detection
16202
+ // (e.g. a getter). Treating that as a change would rebuild the strip and
16203
+ // snap it back to the current panel while the reader scrolls it.
16204
+ const ids = changes['currentPanelIds'];
16205
+ if (ids && !ids.firstChange && sameIds(ids.previousValue, ids.currentValue)) {
16206
+ delete changes['currentPanelIds'];
16207
+ }
15876
16208
  const rebuild = ['chapters', 'currentChapterId', 'currentPanelId', 'currentPanelIds', 'lockedPanels', 'coverThumbnail', 'coverCurrent', 'locale'];
15877
16209
  if (rebuild.some((key) => changes[key])) {
15878
16210
  this.buildThumbnailItems();