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