@panelwave/player 1.5.0 → 1.6.1

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.
@@ -5456,6 +5456,28 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
5456
5456
  * Manages audio playback with Web Audio API
5457
5457
  */
5458
5458
  const ROLES = ['ambient', 'music', 'voiceover', 'sfx'];
5459
+ /**
5460
+ * Whether an audio URL must be loaded with `crossOrigin = 'anonymous'`: an
5461
+ * http(s) file on another origin than the page. Same-origin files, data:/
5462
+ * blob: URLs and `file:` pages (the offline self-hosting package) stay in
5463
+ * no-CORS mode — CORS requests from a file: page fail.
5464
+ */
5465
+ function needsCorsMode(url, pageHref = typeof location !== 'undefined' ? location.href : '') {
5466
+ try {
5467
+ const target = new URL(url, pageHref || undefined);
5468
+ if (target.protocol !== 'http:' && target.protocol !== 'https:') {
5469
+ return false;
5470
+ }
5471
+ const page = pageHref ? new URL(pageHref) : null;
5472
+ if (page && page.protocol !== 'http:' && page.protocol !== 'https:') {
5473
+ return false;
5474
+ }
5475
+ return !page || target.origin !== page.origin;
5476
+ }
5477
+ catch {
5478
+ return false;
5479
+ }
5480
+ }
5459
5481
  /**
5460
5482
  * Audio Engine Service
5461
5483
  * Provides Web Audio API-based audio playback with multiple buses.
@@ -5571,10 +5593,10 @@ class AudioEngineService {
5571
5593
  }
5572
5594
  }
5573
5595
  /**
5574
- * Play audio track
5596
+ * Play audio track, optionally from `offsetSeconds` into the file.
5575
5597
  */
5576
- async play(track) {
5577
- await this.startTrack(track, 0);
5598
+ async play(track, offsetSeconds = 0) {
5599
+ await this.startTrack(track, offsetSeconds);
5578
5600
  }
5579
5601
  /**
5580
5602
  * Stop audio track
@@ -5859,9 +5881,20 @@ class AudioEngineService {
5859
5881
  if (!bus) {
5860
5882
  throw new Error(`Invalid role: ${track.role}`);
5861
5883
  }
5862
- // Create audio element
5863
- const audio = new Audio(track.url);
5884
+ // Create audio element. A file from another origin (object storage, a
5885
+ // CDN) must be fetched in CORS mode: routed through Web Audio
5886
+ // (createMediaElementSource below), a no-CORS cross-origin element plays
5887
+ // but outputs silence. crossOrigin has to be set before the src.
5888
+ const audio = new Audio();
5889
+ if (needsCorsMode(track.url)) {
5890
+ audio.crossOrigin = 'anonymous';
5891
+ }
5892
+ audio.src = track.url;
5864
5893
  audio.loop = track.loop || false;
5894
+ const rate = typeof track.playbackRate === 'number' && isFinite(track.playbackRate) ? Math.max(0.25, Math.min(4, track.playbackRate)) : 1;
5895
+ if (rate !== 1) {
5896
+ audio.playbackRate = rate;
5897
+ }
5865
5898
  if (offsetSeconds > 0) {
5866
5899
  audio.currentTime = offsetSeconds;
5867
5900
  }
@@ -5884,9 +5917,11 @@ class AudioEngineService {
5884
5917
  audio.addEventListener('ended', active.onEnded);
5885
5918
  }
5886
5919
  try {
5887
- // Apply fade in (shortened when starting mid-fade)
5888
- if (track.fadeIn && track.fadeIn > 0 && offsetSeconds * 1000 < track.fadeIn) {
5889
- await this.fadeIn(track.id, track.fadeIn - offsetSeconds * 1000, volume);
5920
+ // Fade in (shortened when starting mid-fade). The ramp runs while the
5921
+ // element plays — awaiting it first delayed the start by the fade.
5922
+ const fadeMs = track.fadeIn && track.fadeIn > 0 ? track.fadeIn - offsetSeconds * 1000 : 0;
5923
+ if (fadeMs > 0) {
5924
+ void this.fadeIn(track.id, fadeMs, volume);
5890
5925
  }
5891
5926
  // Play audio
5892
5927
  await audio.play();
@@ -6040,6 +6075,381 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
6040
6075
  }]
6041
6076
  }], ctorParameters: () => [] });
6042
6077
 
6078
+ /**
6079
+ * Audio asset resolution shared by the panel audio and timeline (sequence)
6080
+ * audio services: catalog id → the first variant the browser can play →
6081
+ * absolute URL, plus the asset's own role / loop hints.
6082
+ */
6083
+ /**
6084
+ * Resolve an audio asset; `{ error }` explains why it cannot be played (not
6085
+ * in the catalog, not audio, no variants, no URL).
6086
+ */
6087
+ function resolveAudioAsset(asset, assetsBase) {
6088
+ if (!asset) {
6089
+ return { error: 'is not in the asset catalog' };
6090
+ }
6091
+ if (asset.category !== 'audio') {
6092
+ return { error: 'is not an audio asset' };
6093
+ }
6094
+ const audioAsset = asset;
6095
+ const variant = pickPlayableVariant(audioAsset.variants ?? []);
6096
+ if (!variant) {
6097
+ return { error: 'has no audio variants' };
6098
+ }
6099
+ const url = resolveAssetUrl(variant.src, assetsBase, 'audio');
6100
+ if (!url) {
6101
+ return { error: 'has no usable URL' };
6102
+ }
6103
+ return { url, role: audioAsset.role, loop: variant.loop };
6104
+ }
6105
+ /**
6106
+ * First variant the browser reports it can play; otherwise the first one
6107
+ * (the element will surface the real error).
6108
+ */
6109
+ function pickPlayableVariant(variants) {
6110
+ if (variants.length <= 1) {
6111
+ return variants[0];
6112
+ }
6113
+ const probe = createProbe();
6114
+ if (!probe) {
6115
+ return variants[0];
6116
+ }
6117
+ const playable = variants.find((variant) => {
6118
+ const mime = variant.mime || guessMimeType(variant.src);
6119
+ return mime ? probe.canPlayType(mime) !== '' : false;
6120
+ });
6121
+ return playable ?? variants[0];
6122
+ }
6123
+ function createProbe() {
6124
+ if (typeof document === 'undefined') {
6125
+ return null;
6126
+ }
6127
+ try {
6128
+ const element = document.createElement('audio');
6129
+ return typeof element.canPlayType === 'function' ? element : null;
6130
+ }
6131
+ catch {
6132
+ return null;
6133
+ }
6134
+ }
6135
+
6136
+ /**
6137
+ * Timeline (sequence) audio placement for the reader — the same model as the
6138
+ * CMS editor's timeline: the work is one line of panels (chapters in order →
6139
+ * the active format's pages → reading order), each panel lasting its
6140
+ * `durationMs` (else the work's seconds-per-panel, else 5 s).
6141
+ *
6142
+ * A chapter's `sequenceAudioTracks` sit on that line relative to their
6143
+ * chapter's start. `span` (format 1.8.0): 'page' / 'chapter' tracks cover
6144
+ * their startPanelId..endPanelId range; a 'work' track keeps playing across
6145
+ * chapter boundaries until endPanelId (else the work's last panel); every
6146
+ * other track ends with its chapter.
6147
+ *
6148
+ * The reader does not follow a clock, so the timeline is used per panel:
6149
+ * entering a panel tells which tracks cover it, from which offset, and
6150
+ * whether one starts or ends inside it (`cuesForPanel`).
6151
+ */
6152
+ const DEFAULT_PANEL_MS = 5000;
6153
+ /** Panels of a page in reading order (placement order without one). */
6154
+ function pagePanels(page) {
6155
+ if (page.readingOrder?.length) {
6156
+ return page.readingOrder;
6157
+ }
6158
+ return (page.layout?.placements ?? []).map((placement) => placement.panelId);
6159
+ }
6160
+ /** The work's panel line for a page format (null = every page). */
6161
+ function buildWorkTimeline(manifest, format, defaultPanelMs = DEFAULT_PANEL_MS) {
6162
+ const entries = [];
6163
+ const byPanel = new Map();
6164
+ const chapters = new Map();
6165
+ let t = 0;
6166
+ for (const chapter of (manifest?.chapters ?? [])) {
6167
+ const pages = pagesForFormat(chapter.pages, format);
6168
+ const order = pages.length ? pages.flatMap(pagePanels) : chapterReadingOrder(chapter);
6169
+ const chapterStart = t;
6170
+ for (const panelId of order) {
6171
+ const panel = chapter.panels?.[panelId];
6172
+ if (!panel || byPanel.has(panelId)) {
6173
+ continue;
6174
+ }
6175
+ const ms = typeof panel.durationMs === 'number' && panel.durationMs > 0 ? panel.durationMs : defaultPanelMs;
6176
+ const entry = { panelId, chapterId: chapter.id, startMs: t, endMs: t + ms };
6177
+ entries.push(entry);
6178
+ byPanel.set(panelId, entry);
6179
+ t += ms;
6180
+ }
6181
+ if (t > chapterStart) {
6182
+ chapters.set(chapter.id, { startMs: chapterStart, endMs: t });
6183
+ }
6184
+ }
6185
+ return { entries, byPanel, chapters, endMs: t };
6186
+ }
6187
+ /** Where each (format-matching, unmuted) timeline track sits on the work line. */
6188
+ function resolveTrackRanges(manifest, timeline, format) {
6189
+ const ranges = [];
6190
+ for (const chapter of (manifest?.chapters ?? [])) {
6191
+ const extent = timeline.chapters.get(chapter.id);
6192
+ if (!extent) {
6193
+ continue; // no panels of this chapter in this format
6194
+ }
6195
+ for (const track of chapter.sequenceAudioTracks ?? []) {
6196
+ if (!track?.assetId || track.muted) {
6197
+ continue;
6198
+ }
6199
+ if (format && track.format && track.format !== format) {
6200
+ continue;
6201
+ }
6202
+ const span = track.span ?? 'custom';
6203
+ const first = track.startPanelId ? timeline.byPanel.get(track.startPanelId) : undefined;
6204
+ const last = track.endPanelId ? timeline.byPanel.get(track.endPanelId) : undefined;
6205
+ let startMs;
6206
+ let endMs;
6207
+ if ((span === 'page' || span === 'chapter') && first && last) {
6208
+ startMs = first.startMs;
6209
+ endMs = last.endMs;
6210
+ }
6211
+ else {
6212
+ startMs = extent.startMs + Math.max(0, Number(track.startTime) || 0);
6213
+ const duration = Math.max(0, Number(track.duration) || 0);
6214
+ endMs = duration > 0 ? startMs + duration : extent.endMs;
6215
+ }
6216
+ if (span === 'work') {
6217
+ endMs = last ? last.endMs : timeline.endMs;
6218
+ }
6219
+ else {
6220
+ endMs = Math.min(endMs, extent.endMs);
6221
+ }
6222
+ if (endMs > startMs) {
6223
+ ranges.push({ track, chapterId: chapter.id, startMs, endMs });
6224
+ }
6225
+ }
6226
+ }
6227
+ return ranges;
6228
+ }
6229
+ /** The tracks that sound while the reader is on `entry`'s panel. */
6230
+ function cuesForPanel(ranges, entry) {
6231
+ const cues = [];
6232
+ for (const range of ranges) {
6233
+ if (range.startMs >= entry.endMs || range.endMs <= entry.startMs) {
6234
+ continue;
6235
+ }
6236
+ const cue = {
6237
+ range,
6238
+ delayMs: Math.max(0, range.startMs - entry.startMs),
6239
+ offsetMs: Math.max(0, entry.startMs - range.startMs),
6240
+ };
6241
+ if (range.endMs < entry.endMs) {
6242
+ cue.stopAfterMs = range.endMs - entry.startMs;
6243
+ }
6244
+ cues.push(cue);
6245
+ }
6246
+ return cues;
6247
+ }
6248
+
6249
+ /**
6250
+ * Sequence Audio Service
6251
+ *
6252
+ * Plays a work's timeline audio (`chapters[].sequenceAudioTracks` — music,
6253
+ * ambience, voiceover beds) while the reader moves through the panels:
6254
+ *
6255
+ * - entering a panel starts every track that covers it, at the right offset
6256
+ * (a jump into the middle of a scene picks the music up where it is);
6257
+ * - a track keeps playing while the reader stays inside its range, so
6258
+ * music does not restart on every panel; leaving the range fades it out
6259
+ * (its `fadeOut`, else 250 ms);
6260
+ * - a track that starts or ends inside a panel is delayed / stopped there;
6261
+ * - `span: 'work'` tracks continue across chapter boundaries.
6262
+ *
6263
+ * Placement follows the CMS timeline (see sequence-audio-timeline.ts). Roles
6264
+ * map onto the engine buses, so the reader's Audio / SFX / Speech toggles
6265
+ * apply. Tracks refused by the autoplay policy are retried on the first
6266
+ * user gesture.
6267
+ */
6268
+ /** Fade when a track ends because the reader left its range (unless it has its own fadeOut). */
6269
+ const SEQUENCE_AUDIO_LEAVE_FADE_MS = 250;
6270
+ /** Engine ids are prefixed so they never collide with panel audio (asset ids). */
6271
+ const sequenceEngineId = (trackId) => `seq:${trackId}`;
6272
+ class SequenceAudioService {
6273
+ constructor() {
6274
+ this.engine = inject(AudioEngineService);
6275
+ this.manifestService = inject(ManifestService);
6276
+ this.gesture = inject(UserGestureService);
6277
+ /** Tracks the current panel wants, by engine id. */
6278
+ this.desired = new Map();
6279
+ /** Refused by the autoplay policy; retried on the first gesture. */
6280
+ this.blocked = new Set();
6281
+ this.warned = new Set();
6282
+ this.gestureSub = this.gesture.userHasInteracted$.subscribe((interacted) => {
6283
+ if (interacted) {
6284
+ this.onUserGesture();
6285
+ }
6286
+ });
6287
+ }
6288
+ ngOnDestroy() {
6289
+ this.gestureSub.unsubscribe();
6290
+ this.stopAll();
6291
+ }
6292
+ /**
6293
+ * Follow the reader: call on every panel change (and when the page format
6294
+ * changes). `panelId` undefined = silence (cover, locked panel, teardown).
6295
+ */
6296
+ syncPanel(panelId, format) {
6297
+ const { timeline, ranges } = this.layout(format);
6298
+ const entry = panelId ? timeline.byPanel.get(panelId) : undefined;
6299
+ const cues = entry ? cuesForPanel(ranges, entry) : [];
6300
+ const next = new Map(cues.map((cue) => [sequenceEngineId(cue.range.track.id), cue]));
6301
+ for (const [id, playing] of this.desired) {
6302
+ if (!next.has(id)) {
6303
+ this.release(id, this.leaveFade(playing.cue));
6304
+ this.desired.delete(id);
6305
+ }
6306
+ }
6307
+ for (const [id, cue] of next) {
6308
+ const playing = this.desired.get(id);
6309
+ if (playing) {
6310
+ // Still inside its range (started, pending, blocked — or a one-shot
6311
+ // that already ended): never restart it, only re-arm an end that
6312
+ // falls inside this panel.
6313
+ playing.cue = cue;
6314
+ this.armStop(id, playing);
6315
+ continue;
6316
+ }
6317
+ this.schedule(id, cue);
6318
+ }
6319
+ }
6320
+ /** Stop everything this service started. */
6321
+ stopAll() {
6322
+ for (const id of Array.from(this.desired.keys())) {
6323
+ this.release(id, 0);
6324
+ }
6325
+ this.desired.clear();
6326
+ }
6327
+ /** Engine ids of the tracks the current panel wants (started, pending or blocked). */
6328
+ getDesiredTrackIds() {
6329
+ return Array.from(this.desired.keys());
6330
+ }
6331
+ // ---------------------------------------------------------------------------
6332
+ layout(format) {
6333
+ const manifest = this.manifestService.getManifest();
6334
+ const cache = this.layoutCache;
6335
+ if (cache && cache.manifest === manifest && cache.format === format) {
6336
+ return cache;
6337
+ }
6338
+ const seconds = Number(manifest?.settings?.ui?.secondsPerPanel);
6339
+ const defaultMs = Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : DEFAULT_PANEL_MS;
6340
+ const timeline = buildWorkTimeline(manifest, format, defaultMs);
6341
+ const ranges = resolveTrackRanges(manifest, timeline, format);
6342
+ this.layoutCache = { manifest, format, timeline, ranges };
6343
+ return this.layoutCache;
6344
+ }
6345
+ schedule(id, cue) {
6346
+ this.release(id, 0);
6347
+ const playing = { cue };
6348
+ this.desired.set(id, playing);
6349
+ if (cue.delayMs > 0) {
6350
+ playing.startTimer = setTimeout(() => {
6351
+ playing.startTimer = undefined;
6352
+ this.start(id, playing, 0);
6353
+ }, cue.delayMs);
6354
+ }
6355
+ else {
6356
+ this.start(id, playing, cue.offsetMs);
6357
+ }
6358
+ this.armStop(id, playing);
6359
+ }
6360
+ /** Stop the track inside this panel when its range ends here. */
6361
+ armStop(id, playing) {
6362
+ if (playing.stopTimer !== undefined) {
6363
+ clearTimeout(playing.stopTimer);
6364
+ playing.stopTimer = undefined;
6365
+ }
6366
+ const stopAfter = playing.cue.stopAfterMs;
6367
+ if (stopAfter === undefined) {
6368
+ return;
6369
+ }
6370
+ playing.stopTimer = setTimeout(() => {
6371
+ playing.stopTimer = undefined;
6372
+ if (this.desired.get(id) === playing) {
6373
+ this.desired.delete(id);
6374
+ this.release(id, this.leaveFade(playing.cue));
6375
+ }
6376
+ }, stopAfter);
6377
+ }
6378
+ start(id, playing, offsetMs) {
6379
+ const track = playing.cue.range.track;
6380
+ const resolved = resolveAudioAsset(this.manifestService.getAsset(track.assetId), this.manifestService.getManifest()?.assets?.base);
6381
+ if ('error' in resolved) {
6382
+ this.warnOnce(track.assetId, resolved.error);
6383
+ return;
6384
+ }
6385
+ const rate = typeof track.playbackRate === 'number' && isFinite(track.playbackRate) ? Math.max(0.25, Math.min(4, track.playbackRate)) : 1;
6386
+ this.blocked.delete(id);
6387
+ this.engine
6388
+ .play({
6389
+ id,
6390
+ url: resolved.url,
6391
+ role: track.role,
6392
+ loop: track.loop ?? resolved.loop ?? false,
6393
+ volume: typeof track.volume === 'number' && isFinite(track.volume) ? Math.max(0, Math.min(2, track.volume)) : 1,
6394
+ fadeIn: track.fadeIn,
6395
+ fadeOut: track.fadeOut,
6396
+ playbackRate: rate,
6397
+ }, (offsetMs / 1000) * rate)
6398
+ .catch((error) => {
6399
+ if (this.isAutoplayRefusal(error) && !this.gesture.hasInteracted() && this.desired.get(id) === playing) {
6400
+ this.blocked.add(id);
6401
+ }
6402
+ });
6403
+ }
6404
+ release(id, fadeMs) {
6405
+ const playing = this.desired.get(id);
6406
+ if (playing?.startTimer !== undefined) {
6407
+ clearTimeout(playing.startTimer);
6408
+ playing.startTimer = undefined;
6409
+ }
6410
+ if (playing?.stopTimer !== undefined) {
6411
+ clearTimeout(playing.stopTimer);
6412
+ playing.stopTimer = undefined;
6413
+ }
6414
+ this.blocked.delete(id);
6415
+ if (this.engine.isActive(id)) {
6416
+ void this.engine.stop(id, fadeMs);
6417
+ }
6418
+ }
6419
+ leaveFade(cue) {
6420
+ const fadeOut = cue.range.track.fadeOut;
6421
+ return typeof fadeOut === 'number' && fadeOut > 0 ? fadeOut : SEQUENCE_AUDIO_LEAVE_FADE_MS;
6422
+ }
6423
+ onUserGesture() {
6424
+ for (const id of Array.from(this.blocked)) {
6425
+ this.blocked.delete(id);
6426
+ const playing = this.desired.get(id);
6427
+ if (playing) {
6428
+ this.start(id, playing, playing.cue.offsetMs);
6429
+ }
6430
+ }
6431
+ }
6432
+ isAutoplayRefusal(error) {
6433
+ const name = error?.name;
6434
+ return name === 'NotAllowedError' || name === 'AbortError';
6435
+ }
6436
+ warnOnce(assetId, reason) {
6437
+ if (this.warned.has(assetId)) {
6438
+ return;
6439
+ }
6440
+ this.warned.add(assetId);
6441
+ console.warn(`[SequenceAudioService] audio asset "${assetId}" ${reason}; timeline track skipped.`);
6442
+ }
6443
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SequenceAudioService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
6444
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SequenceAudioService, providedIn: 'root' }); }
6445
+ }
6446
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SequenceAudioService, decorators: [{
6447
+ type: Injectable,
6448
+ args: [{
6449
+ providedIn: 'root',
6450
+ }]
6451
+ }], ctorParameters: () => [] });
6452
+
6043
6453
  /**
6044
6454
  * Panel Audio Service
6045
6455
  *
@@ -6250,61 +6660,22 @@ class PanelAudioService {
6250
6660
  return result;
6251
6661
  }
6252
6662
  resolveTrack(entry) {
6253
- const asset = this.manifestService.getAsset(entry.assetId);
6254
- if (!asset || asset.category !== 'audio') {
6255
- this.warnOnce(entry.assetId, asset ? 'is not an audio asset' : 'is not in the asset catalog');
6256
- return null;
6257
- }
6258
- const audioAsset = asset;
6259
- const variant = this.pickVariant(audioAsset.variants ?? []);
6260
- if (!variant) {
6261
- this.warnOnce(entry.assetId, 'has no audio variants');
6262
- return null;
6263
- }
6264
- const base = this.manifestService.getManifest()?.assets?.base;
6265
- const url = resolveAssetUrl(variant.src, base, 'audio');
6266
- if (!url) {
6663
+ const resolved = resolveAudioAsset(this.manifestService.getAsset(entry.assetId), this.manifestService.getManifest()?.assets?.base);
6664
+ if ('error' in resolved) {
6665
+ if (resolved.error !== 'has no usable URL') {
6666
+ this.warnOnce(entry.assetId, resolved.error);
6667
+ }
6267
6668
  return null;
6268
6669
  }
6269
6670
  return {
6270
6671
  id: entry.assetId,
6271
- url,
6272
- role: busForRole(entry.role ?? audioAsset.role),
6273
- loop: entry.loop ?? variant.loop ?? false,
6672
+ url: resolved.url,
6673
+ role: busForRole(entry.role ?? resolved.role),
6674
+ loop: entry.loop ?? resolved.loop ?? false,
6274
6675
  gain: this.clampGain(entry.gain),
6275
6676
  startAtMs: typeof entry.startAtMs === 'number' && entry.startAtMs > 0 ? entry.startAtMs : 0,
6276
6677
  };
6277
6678
  }
6278
- /**
6279
- * First variant the browser reports it can play; otherwise the first one
6280
- * (the element will surface the real error).
6281
- */
6282
- pickVariant(variants) {
6283
- if (variants.length <= 1) {
6284
- return variants[0];
6285
- }
6286
- const probe = this.createProbe();
6287
- if (!probe) {
6288
- return variants[0];
6289
- }
6290
- const playable = variants.find((variant) => {
6291
- const mime = variant.mime || guessMimeType(variant.src);
6292
- return mime ? probe.canPlayType(mime) !== '' : false;
6293
- });
6294
- return playable ?? variants[0];
6295
- }
6296
- createProbe() {
6297
- if (typeof document === 'undefined') {
6298
- return null;
6299
- }
6300
- try {
6301
- const element = document.createElement('audio');
6302
- return typeof element.canPlayType === 'function' ? element : null;
6303
- }
6304
- catch {
6305
- return null;
6306
- }
6307
- }
6308
6679
  clampGain(gain) {
6309
6680
  if (typeof gain !== 'number' || !isFinite(gain)) {
6310
6681
  return 1;
@@ -11932,6 +12303,14 @@ class SpeechBubblesComponent {
11932
12303
  * screens (phones) get smaller lettering.
11933
12304
  */
11934
12305
  this.readingScale = 1;
12306
+ /**
12307
+ * Page view: place a tailed balloon by its AUTHORED tail tip (the speaker
12308
+ * on the artwork) instead of centring its body on the stored box, and keep
12309
+ * the tail at its authored length when the MIN_LETTERING_PX floor boosts
12310
+ * the balloon — the CMS editor's page view rule (PanelRenderer), which the
12311
+ * lettered PDF must reproduce. Panel view keeps the centre placement.
12312
+ */
12313
+ this.anchorTailTip = false;
11935
12314
  /**
11936
12315
  * Work-level balloon config defaults (from settings.typography.balloon_config)
11937
12316
  */
@@ -11987,6 +12366,7 @@ class SpeechBubblesComponent {
11987
12366
  changes['containerWidth'] ||
11988
12367
  changes['containerHeight'] ||
11989
12368
  changes['readingScale'] ||
12369
+ changes['anchorTailTip'] ||
11990
12370
  changes['workBalloonConfig'] ||
11991
12371
  changes['characters'];
11992
12372
  if (needsRerender) {
@@ -12118,7 +12498,8 @@ class SpeechBubblesComponent {
12118
12498
  // NOT proportionally with the authored shape box (which would blow
12119
12499
  // text up on large screens). Capped so a balloon never exceeds its
12120
12500
  // panel container, and clamped against broken measurements.
12121
- let scale = Math.min(4, Math.max(0.25, this.readingScale || 1));
12501
+ const baseScale = Math.min(4, Math.max(0.25, this.readingScale || 1));
12502
+ let scale = baseScale;
12122
12503
  // Never letter below MIN_LETTERING_PX: on a page shown small the balloon
12123
12504
  // grows just enough (still anchored and kept inside the panel below).
12124
12505
  if (config.fontSize > 0) {
@@ -12129,18 +12510,37 @@ class SpeechBubblesComponent {
12129
12510
  // The SVG can run a little wider than maxWidth (stroke, tail), so narrow
12130
12511
  // by the remaining overshoot for up to three passes.
12131
12512
  let maxWidth = Number(renderOpts['maxWidth']) || svgWidth;
12513
+ let usedOpts = renderOpts;
12132
12514
  for (let pass = 0; pass < 3 && svgWidth > 0 && this.containerWidth > 0 && svgWidth * scale > this.containerWidth + 0.5; pass++) {
12133
12515
  const narrower = Math.max(40, Math.floor(maxWidth * (this.containerWidth / (svgWidth * scale)) * 0.99));
12134
12516
  if (narrower >= maxWidth)
12135
12517
  break;
12136
12518
  maxWidth = narrower;
12137
- result = new ComicBalloon(balloonContainer, { ...renderOpts, maxWidth, maxHeight: 0 }).render(text, tailOpts);
12519
+ usedOpts = { ...renderOpts, maxWidth, maxHeight: 0 };
12520
+ result = new ComicBalloon(balloonContainer, usedOpts).render(text, tailOpts);
12138
12521
  svgWidth = result.svg?.style.width ? parseFloat(result.svg.style.width) : maxWidth;
12139
12522
  svgHeight = result.svg?.style.height ? parseFloat(result.svg.style.height) : svgHeight;
12140
12523
  }
12141
12524
  if (svgWidth > 0 && svgHeight > 0) {
12142
12525
  scale = Math.max(0.25, Math.min(scale, this.containerWidth / svgWidth, this.containerHeight / svgHeight));
12143
12526
  }
12527
+ // Page view (anchorTailTip): the tail tip stays on the AUTHORED tip
12528
+ // and a readability boost above the base scale grows only the body —
12529
+ // the tail is rendered at length / boost so its visible length stays
12530
+ // authored (the editor's tail counter-scaling). Connectors keep the
12531
+ // centre rule: the editor glues them onto their target balloon.
12532
+ const tailLength = Number(tailOpts?.length) || 50;
12533
+ const cornerRadius = Number(renderOpts['cornerRadius'] ?? 0.45) || 0;
12534
+ const tipAnchored = this.anchorTailTip && !!tailOpts && tailOpts.position !== undefined
12535
+ && config.balloonType !== 'connector';
12536
+ let renderedTailLength = tailLength;
12537
+ if (tipAnchored && scale > baseScale * 1.001) {
12538
+ renderedTailLength = tailLength * baseScale / scale;
12539
+ result = new ComicBalloon(balloonContainer, usedOpts)
12540
+ .render(text, { ...tailOpts, length: renderedTailLength });
12541
+ svgWidth = result.svg?.style.width ? parseFloat(result.svg.style.width) : svgWidth;
12542
+ svgHeight = result.svg?.style.height ? parseFloat(result.svg.style.height) : svgHeight;
12543
+ }
12144
12544
  const visualW = svgWidth * scale;
12145
12545
  const visualH = svgHeight * scale;
12146
12546
  // Auto-grow (schema 1.8 textFit): when the text needed more room than
@@ -12183,14 +12583,28 @@ class SpeechBubblesComponent {
12183
12583
  const eps = Math.max(4, 0.02 * Math.min(this.containerWidth, this.containerHeight));
12184
12584
  let centerX = pos.left + grownOffsetX - bodyOffsetX * scale;
12185
12585
  let centerY = pos.top + grownOffsetY - bodyOffsetY * scale;
12186
- if (boxLeft <= eps)
12187
- centerX = visualW / 2;
12188
- else if (boxRight >= this.containerWidth - eps)
12189
- centerX = this.containerWidth - visualW / 2;
12190
- if (boxTop <= eps)
12191
- centerY = visualH / 2;
12192
- else if (boxBottom >= this.containerHeight - eps)
12193
- centerY = this.containerHeight - visualH / 2;
12586
+ if (tipAnchored) {
12587
+ // Authored tip: the stored box with the tail at the base scale
12588
+ // (the editor's tip before its re-measure and boost). The rendered
12589
+ // balloon's own tip, relative to its body centre, lands on it — a
12590
+ // grown or boosted body moves away from the speaker, the tip stays.
12591
+ const authoredW = boxRight - boxLeft > 1 ? boxRight - boxLeft : config.maxWidth * baseScale;
12592
+ const authoredH = boxBottom - boxTop > 1 ? boxBottom - boxTop : config.maxHeight * baseScale;
12593
+ const tip = tailConfigToTip({ cx: (boxLeft + boxRight) / 2, cy: (boxTop + boxBottom) / 2, width: authoredW, height: authoredH }, { position: Number(tailOpts.position), length: tailLength * baseScale }, cornerRadius);
12594
+ const localTip = tailConfigToTip({ cx: 0, cy: 0, width: result.width, height: result.height }, { position: Number(tailOpts.position), length: renderedTailLength }, cornerRadius);
12595
+ centerX = tip.x - (localTip.x + bodyOffsetX) * scale;
12596
+ centerY = tip.y - (localTip.y + bodyOffsetY) * scale;
12597
+ }
12598
+ else {
12599
+ if (boxLeft <= eps)
12600
+ centerX = visualW / 2;
12601
+ else if (boxRight >= this.containerWidth - eps)
12602
+ centerX = this.containerWidth - visualW / 2;
12603
+ if (boxTop <= eps)
12604
+ centerY = visualH / 2;
12605
+ else if (boxBottom >= this.containerHeight - eps)
12606
+ centerY = this.containerHeight - visualH / 2;
12607
+ }
12194
12608
  // A balloon bigger than its authored box (small screens) must not be
12195
12609
  // pushed across the panel border and clipped: keep it inside the panel.
12196
12610
  centerX = clampCenter(centerX, visualW, this.containerWidth);
@@ -12279,7 +12693,7 @@ class SpeechBubblesComponent {
12279
12693
  this.bubbleClick.emit(bubble);
12280
12694
  }
12281
12695
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SpeechBubblesComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
12282
- 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 }); }
12696
+ 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 }); }
12283
12697
  }
12284
12698
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: SpeechBubblesComponent, decorators: [{
12285
12699
  type: Component,
@@ -12294,6 +12708,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
12294
12708
  type: Input
12295
12709
  }], readingScale: [{
12296
12710
  type: Input
12711
+ }], anchorTailTip: [{
12712
+ type: Input
12297
12713
  }], workBalloonConfig: [{
12298
12714
  type: Input
12299
12715
  }], characters: [{
@@ -14393,11 +14809,11 @@ class ViewportComponent {
14393
14809
  console.groupEnd();
14394
14810
  }
14395
14811
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ViewportComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
14396
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.14", type: ViewportComponent, isStandalone: true, selector: "pw-viewport", inputs: { panel: "panel", page: "page", pageBackground: "pageBackground", panels: "panels", hiddenPanelIds: "hiddenPanelIds", preloadTargets: "preloadTargets", viewMode: "viewMode", currentPanelId: "currentPanelId", locale: "locale", variableContext: "variableContext", panX: "panX", panY: "panY", zoom: "zoom", reducedMotion: "reducedMotion", transition: "transition", interactive: "interactive", showOverflowArrows: "showOverflowArrows", enableViewportCulling: "enableViewportCulling", enableLazyLoading: "enableLazyLoading", workBalloonConfig: "workBalloonConfig", characters: "characters", speechEnabled: "speechEnabled", graph: "graph", preload: "preload" }, outputs: { viewportClick: "viewportClick", performanceMetrics: "performanceMetrics", transformChange: "transformChange", layerClick: "layerClick", swipe: "swipe", navigatePrevious: "navigatePrevious", navigateNext: "navigateNext", panelDoubleClick: "panelDoubleClick", panelFocus: "panelFocus", hotspotActivate: "hotspotActivate", deadClick: "deadClick", lockedPanelActivate: "lockedPanelActivate" }, host: { listeners: { "window:resize": "onWindowResize()", "mousedown": "onMouseDown($event)", "document:mousemove": "onMouseMove($event)", "document:mouseup": "onMouseUp($event)", "wheel": "onWheel($event)", "touchstart": "onTouchStart($event)", "touchmove": "onTouchMove($event)", "touchend": "onTouchEnd($event)", "touchcancel": "onTouchEnd($event)", "keydown.tab": "onKeydownTab($event)", "keydown.arrowRight": "onKeydownNext($event)", "keydown.arrowDown": "onKeydownNext($event)", "keydown.arrowLeft": "onKeydownPrevious($event)", "keydown.arrowUp": "onKeydownPrevious($event)" } }, usesOnChanges: true, ngImport: i0, template: "<div \n class=\"viewport-container\"\n [class]=\"getViewModeClass()\"\n [class.reduced-motion]=\"reducedMotion\"\n [class.interactive]=\"interactive\"\n [style.background-color]=\"getViewportBackground()\"\n role=\"region\"\n [attr.aria-label]=\"viewMode === 'page' ? 'Page viewport' : 'Panel viewport'\"\n tabindex=\"0\"\n (click)=\"onViewportClick($event)\"\n (keydown.enter)=\"onKeyboardActivate($event)\"\n (keydown.space)=\"onKeyboardActivate($event)\"\n (mousemove)=\"onViewportMouseMove($event)\"\n (dblclick)=\"onViewportDoubleClick($event)\"\n (mouseleave)=\"onViewportMouseLeave()\">\n \n <!-- Panel View -->\n @if (viewMode === 'panel') {\n <div\n class=\"viewport-content\"\n [style.transform]=\"getTransformStyle()\"\n [style.transition]=\"reducedMotion ? 'none' : 'transform 0.3s ease'\"\n [style.--pw-t-duration]=\"transitionDuration\"\n [style.--pw-t-easing]=\"transitionEasing\">\n\n <!-- Outgoing panel during a manifest edge transition -->\n @if (leavingPanel) {\n <div [class]=\"'t-frame-leave ' + leaveAnimationClass\" aria-hidden=\"true\">\n <!-- The outgoing panel holds the end state of its animation. -->\n <div class=\"panel-container\"\n [style.width.px]=\"panelBox?.width\"\n [style.height.px]=\"panelBox?.height\"\n [pwPanelAnimation]=\"leavingPanel.animations\"\n [pwPanelAnimationKey]=\"leavingPanelId || ''\"\n [pwPanelAnimationStatic]=\"true\">\n <div class=\"pw-panel-camera\">\n @if (isLocked(leavingPanel, leavingPanelId)) {\n <pw-locked-panel-placeholder></pw-locked-panel-placeholder>\n } @else {\n @for (layer of leavingPanel.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n [viewMode]=\"viewMode\"\n [viewActive]=\"false\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(leavingPanelId)\"\n [panelId]=\"leavingPanelId\">\n </pw-layer-renderer>\n }\n\n @if (speechEnabled && leavingPanel.speechBubbles && leavingPanel.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"leavingPanel.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"getContainerWidth()\"\n [containerHeight]=\"getContainerHeight()\"\n [readingScale]=\"getReadingScale()\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n </div>\n </div>\n }\n\n <div [class]=\"'t-frame ' + enterAnimationClass\">\n @if (panel) {\n <div class=\"panel-container\" tabindex=\"0\"\n [style.width.px]=\"panelBox?.width\"\n [style.height.px]=\"panelBox?.height\"\n [pwPanelAnimation]=\"panel.animations\"\n [pwPanelAnimationKey]=\"currentPanelId || ''\"\n [pwPanelAnimationReducedMotion]=\"reducedMotion\"\n (click)=\"onPanelContainerClick($event, currentPanelId)\" (keydown.enter)=\"onPanelContainerClick($any($event), currentPanelId)\">\n <!-- Everything that belongs to the artwork; a camera move transforms it as one. -->\n <div class=\"pw-panel-camera\">\n @if (isLocked(panel, currentPanelId)) {\n <pw-locked-panel-placeholder></pw-locked-panel-placeholder>\n } @else {\n <!-- Render layers using LayerRenderer -->\n @for (layer of panel.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n [viewMode]=\"viewMode\"\n [viewActive]=\"true\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(currentPanelId)\"\n [panelId]=\"currentPanelId || ''\"\n (layerClick)=\"layerClick.emit($event)\">\n </pw-layer-renderer>\n }\n\n <!-- Interactive hotspots overlay (below speech bubbles) -->\n @if (interactive && panel.hotspots && panel.hotspots.length > 0) {\n <pw-hotspots-overlay\n [hotspots]=\"panel.hotspots\"\n [bubbles]=\"panel.speechBubbles ?? []\"\n [locale]=\"locale\"\n [containerWidth]=\"getContainerWidth()\"\n [containerHeight]=\"getContainerHeight()\"\n [context]=\"variableContext\"\n (hotspotActivate)=\"onHotspotActivate($event, currentPanelId)\">\n </pw-hotspots-overlay>\n }\n\n <!-- Speech bubbles overlay for panel view -->\n @if (speechEnabled && panel.speechBubbles && panel.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"panel.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"getContainerWidth()\"\n [containerHeight]=\"getContainerHeight()\"\n [readingScale]=\"getReadingScale()\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n </div>\n } @else {\n <div class=\"no-panel\">\n <p>No panel to display</p>\n </div>\n }\n </div>\n </div>\n }\n \n <!-- Page View -->\n @if (viewMode === 'page' && page) {\n <div\n class=\"viewport-page\"\n [style.--pw-page-aspect]=\"pageAspect\"\n [style.--pw-stage-aspect]=\"stageAspect\"\n [style.--pw-fit-x]=\"pageFit.x\"\n [style.--pw-fit-y]=\"pageFit.y\"\n [style.--pw-fit-w]=\"pageFit.w\"\n [style.--pw-fit-h]=\"pageFit.h\">\n <div class=\"page-stage\">\n <!-- Outgoing page during a Page.transitions in/out change -->\n @if (leavingPage) {\n <div\n [class]=\"'pt-frame-leave ' + pageLeaveClass + (pageLeaveAbove ? ' pt-frame-leave--above' : '')\"\n [style.--pw-t-duration]=\"pageLeaveDuration\"\n [style.--pw-t-easing]=\"pageLeaveEasing\"\n aria-hidden=\"true\">\n <div class=\"page-canvas\" [style.background-color]=\"pageBackgroundOf(leavingPage)\">\n @for (placement of getLeavingPagePlacements(); track placement.panelId) {\n <div\n class=\"panel-container\"\n [style.left.%]=\"toPercent(placement.x)\"\n [style.top.%]=\"toPercent(placement.y)\"\n [style.width.%]=\"toPercent(placement.w)\"\n [style.height.%]=\"toPercent(placement.h)\"\n [style.z-index]=\"placement.z || 0\"\n [style.transform]=\"getPanelTransform(placement)\"\n [style.transform-origin]=\"getTransformOrigin(placement)\">\n\n @if (isBranchHidden(placement.panelId)) {\n <div class=\"branch-placeholder\" aria-hidden=\"true\"></div>\n } @else if (getLeavingPanel(placement.panelId); as panelData) {\n <div class=\"panel-content\"\n [style.clip-path]=\"getPanelContentClipPath(placement)\"\n [style.transform]=\"getPanelContentTransform(placement)\"\n [pwPanelAnimation]=\"panelData.animations\"\n [pwPanelAnimationKey]=\"placement.panelId\"\n [pwPanelAnimationStatic]=\"true\">\n <div class=\"pw-panel-camera\">\n @if (isLocked(panelData, placement.panelId)) {\n <pw-locked-panel-placeholder></pw-locked-panel-placeholder>\n } @else {\n @for (layer of panelData.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n [viewMode]=\"viewMode\"\n [viewActive]=\"false\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(placement.panelId)\"\n [panelId]=\"placement.panelId\"\n [placementId]=\"placement.panelId\">\n </pw-layer-renderer>\n }\n\n @if (speechEnabled && panelData.speechBubbles && panelData.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"panelData.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"getPagePanelWidth(placement)\"\n [containerHeight]=\"getPagePanelHeight(placement)\"\n [readingScale]=\"getPageReadingScale(placement)\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n </div>\n }\n </div>\n }\n </div>\n </div>\n }\n\n <div\n [class]=\"'pt-frame ' + pageEnterClass\"\n [style.--pw-t-duration]=\"pageEnterDuration\"\n [style.--pw-t-easing]=\"pageEnterEasing\">\n <div\n class=\"page-canvas\"\n [style.background-color]=\"pageBackgroundOf(page)\"\n [attr.tabindex]=\"-1\"\n (click)=\"onPanelClickWithFocus($event)\"\n (keydown.enter)=\"onCanvasKeyboardActivate($event)\"\n (keydown.space)=\"onCanvasKeyboardActivate($event)\">\n \n @for (placement of getSortedPanels(); track placement.panelId) {\n @if (isPanelVisible(placement)) {\n <div \n class=\"panel-container\"\n [class.panel-focused]=\"isPanelFocused(placement.panelId)\"\n [style.left.%]=\"toPercent(placement.x)\"\n [style.top.%]=\"toPercent(placement.y)\"\n [style.width.%]=\"toPercent(placement.w)\"\n [style.height.%]=\"toPercent(placement.h)\"\n [style.z-index]=\"placement.z || 0\"\n [style.transform]=\"getPanelTransform(placement)\"\n [style.transform-origin]=\"getTransformOrigin(placement)\"\n [attr.data-panel-id]=\"placement.panelId\"\n [class.panel-branch-hidden]=\"isBranchHidden(placement.panelId)\"\n [attr.aria-hidden]=\"isBranchHidden(placement.panelId) ? 'true' : null\"\n [attr.tabindex]=\"isBranchHidden(placement.panelId) || panelHasHotspots(placement.panelId) ? null : 0\"\n [attr.role]=\"isBranchHidden(placement.panelId) || panelHasHotspots(placement.panelId) ? null : 'button'\"\n [attr.aria-label]=\"isBranchHidden(placement.panelId) || panelHasHotspots(placement.panelId) ? null : 'Panel ' + placement.panelId\"\n [attr.loading]=\"getImageLoadingStrategy()\"\n (click)=\"onPanelClick($event, placement.panelId)\"\n (keydown.enter)=\"onPanelKeyboardActivate($event, placement.panelId)\"\n (keydown.space)=\"onPanelKeyboardActivate($event, placement.panelId)\">\n \n @if (isBranchHidden(placement.panelId)) {\n <!-- Not on the reader's path (yet): a grey box keeps the page's shape. -->\n <div class=\"branch-placeholder\"></div>\n } @else if (getPanel(placement.panelId); as panelData) {\n <div class=\"panel-content\"\n [style.clip-path]=\"getPanelContentClipPath(placement)\"\n [style.transform]=\"getPanelContentTransform(placement)\"\n [pwPanelAnimation]=\"panelData.animations\"\n [pwPanelAnimationKey]=\"placement.panelId\"\n [pwPanelAnimationReducedMotion]=\"reducedMotion\"\n tabindex=\"0\"\n (click)=\"onPanelContainerClick($event, placement.panelId)\"\n (keydown.enter)=\"onPanelContainerClick($any($event), placement.panelId)\">\n <div class=\"pw-panel-camera\">\n @if (isLocked(panelData, placement.panelId)) {\n <pw-locked-panel-placeholder\n [actionable]=\"true\"\n (activate)=\"lockedPanelActivate.emit(placement.panelId)\"></pw-locked-panel-placeholder>\n } @else {\n <!-- Render panel layers -->\n @for (layer of panelData.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n [altText]=\"getAltText(layer)\"\n [viewMode]=\"viewMode\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(placement.panelId)\"\n [panelId]=\"placement.panelId\"\n [placementId]=\"placement.panelId\"\n [readingOrderIndex]=\"readingOrderIndexOf(placement.panelId)\"\n [placementZ]=\"placement.z || 0\"\n [placementY]=\"placement.y\"\n [placementX]=\"placement.x\"\n (layerClick)=\"layerClick.emit($event)\">\n </pw-layer-renderer>\n }\n\n <!-- Interactive hotspots overlay for page view panels -->\n @if (interactive && panelData.hotspots && panelData.hotspots.length > 0) {\n <pw-hotspots-overlay\n [hotspots]=\"panelData.hotspots\"\n [bubbles]=\"panelData.speechBubbles ?? []\"\n [locale]=\"locale\"\n [containerWidth]=\"getPagePanelWidth(placement)\"\n [containerHeight]=\"getPagePanelHeight(placement)\"\n [context]=\"variableContext\"\n (hotspotActivate)=\"onHotspotActivate($event, placement.panelId)\">\n </pw-hotspots-overlay>\n }\n\n <!-- Speech bubbles overlay for page view panels -->\n @if (speechEnabled && panelData.speechBubbles && panelData.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"panelData.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"getPagePanelWidth(placement)\"\n [containerHeight]=\"getPagePanelHeight(placement)\"\n [readingScale]=\"getPageReadingScale(placement)\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n </div>\n }\n \n <!-- Focus indicator for rotated panels -->\n @if (isPanelFocused(placement.panelId)) {\n <div class=\"focus-rect\" aria-hidden=\"true\"></div>\n }\n </div>\n }\n }\n </div>\n </div>\n </div>\n </div>\n }\n\n @if (viewMode === 'page' && !page) {\n <div class=\"no-panel\">\n <p>No page to display</p>\n </div>\n }\n\n <!-- Overflow navigation arrows -->\n @if (showOverflowArrows && panel) {\n @if (hasOverflowLeft) {\n <button \n class=\"overflow-arrow overflow-left\"\n type=\"button\"\n aria-label=\"Navigate left\"\n (click)=\"navigateLeft()\">\n <pw-icon name=\"lucideChevronLeft\" />\n </button>\n }\n\n @if (hasOverflowRight) {\n <button \n class=\"overflow-arrow overflow-right\"\n type=\"button\"\n aria-label=\"Navigate right\"\n (click)=\"navigateRight()\">\n <pw-icon name=\"lucideChevronRight\" />\n </button>\n }\n\n @if (hasOverflowTop) {\n <button \n class=\"overflow-arrow overflow-top\"\n type=\"button\"\n aria-label=\"Navigate up\"\n (click)=\"navigateUp()\">\n <pw-icon name=\"lucideChevronUp\" />\n </button>\n }\n\n @if (hasOverflowBottom) {\n <button \n class=\"overflow-arrow overflow-bottom\"\n type=\"button\"\n aria-label=\"Navigate down\"\n (click)=\"navigateDown()\">\n <pw-icon name=\"lucideChevronDown\" />\n </button>\n }\n }\n\n <!-- Navigation arrows (hover zones) -->\n @if (viewMode === 'panel' || (viewMode === 'page' && page)) {\n @if (showLeftArrow) {\n <button \n class=\"nav-arrow nav-arrow-left\"\n type=\"button\"\n [attr.aria-label]=\"viewMode === 'page' ? 'Previous page' : 'Previous panel'\"\n (click)=\"onNavigatePrevious(); $event.stopPropagation()\">\n <span class=\"arrow-icon\"><pw-icon name=\"lucideChevronLeft\" /></span>\n </button>\n }\n\n @if (showRightArrow) {\n <button \n class=\"nav-arrow nav-arrow-right\"\n type=\"button\"\n [attr.aria-label]=\"viewMode === 'page' ? 'Next page' : 'Next panel'\"\n (click)=\"onNavigateNext(); $event.stopPropagation()\">\n <span class=\"arrow-icon\"><pw-icon name=\"lucideChevronRight\" /></span>\n </button>\n }\n }\n</div>\n", styles: [".viewport-container{position:relative;width:100%;height:100%;overflow:hidden;background-color:#1a1a1a;cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none}.viewport-container:active{cursor:grabbing}.viewport-container.interactive{cursor:default}.viewport-content{position:absolute;top:50%;left:50%;transform-origin:center center;will-change:transform}.viewport-content.reduced-motion{transition:none!important}.panel-container{position:relative;background:transparent;width:1280px;height:720px;max-width:100%;max-height:100%}.pw-panel-camera{position:absolute;inset:0}.t-frame{position:relative;z-index:1}.t-frame-leave{position:absolute;inset:0;z-index:0;pointer-events:none}.t-fade-in{animation:pw-t-fade-in var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-zoom-in{animation:pw-t-zoom-in var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-from-right{animation:pw-t-from-right var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-from-left{animation:pw-t-from-left var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-from-bottom{animation:pw-t-from-bottom var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-from-top{animation:pw-t-from-top var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-to-left{animation:pw-t-to-left var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-to-right{animation:pw-t-to-right var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-to-top{animation:pw-t-to-top var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-to-bottom{animation:pw-t-to-bottom var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}@keyframes pw-t-fade-in{0%{opacity:0}to{opacity:1}}@keyframes pw-t-zoom-in{0%{opacity:0;transform:scale(.94)}to{opacity:1;transform:scale(1)}}@keyframes pw-t-from-right{0%{transform:translate(100%)}to{transform:translate(0)}}@keyframes pw-t-from-left{0%{transform:translate(-100%)}to{transform:translate(0)}}@keyframes pw-t-from-bottom{0%{transform:translateY(100%)}to{transform:translateY(0)}}@keyframes pw-t-from-top{0%{transform:translateY(-100%)}to{transform:translateY(0)}}@keyframes pw-t-to-left{0%{transform:translate(0)}to{transform:translate(-100%)}}@keyframes pw-t-to-right{0%{transform:translate(0)}to{transform:translate(100%)}}@keyframes pw-t-to-top{0%{transform:translateY(0)}to{transform:translateY(-100%)}}@keyframes pw-t-to-bottom{0%{transform:translateY(0)}to{transform:translateY(100%)}}.layer{position:absolute;top:0;left:0}.layer-placeholder{padding:20px;background:#0078d71a;border:1px dashed rgba(0,120,215,.3);color:#0078d7;font-size:14px}.no-panel{display:flex;align-items:center;justify-content:center;min-width:400px;min-height:300px;color:#666}.reduced-motion *{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}.overflow-arrow{position:absolute;z-index:100;background:#0009;color:#fff;border:none;border-radius:4px;width:40px;height:40px;font-size:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s ease;box-shadow:0 2px 8px #0000004d}.overflow-arrow:hover{background:#000c;transform:scale(1.1)}.overflow-arrow:active{transform:scale(.95)}.overflow-left{left:20px;top:50%;transform:translateY(-50%)}.overflow-left:hover{transform:translateY(-50%) scale(1.1)}.overflow-right{right:20px;top:50%;transform:translateY(-50%)}.overflow-right:hover{transform:translateY(-50%) scale(1.1)}.overflow-top{top:20px;left:50%;transform:translate(-50%)}.overflow-top:hover{transform:translate(-50%) scale(1.1)}.overflow-bottom{bottom:20px;left:50%;transform:translate(-50%)}.overflow-bottom:hover{transform:translate(-50%) scale(1.1)}.viewport-page{width:100%;height:100%;display:flex;align-items:center;justify-content:center;overflow:hidden;padding:0;box-sizing:border-box;container-type:size}.page-canvas{position:relative;width:100%;aspect-ratio:var(--pw-page-aspect, 16 / 9);background:#1a1a1a;overflow:hidden}.page-stage{position:relative;width:min(100cqw,100cqh * var(--pw-stage-aspect, var(--pw-page-aspect, 1.7778)));aspect-ratio:var(--pw-stage-aspect, var(--pw-page-aspect, 16 / 9));flex:none;overflow:hidden}.page-stage .page-canvas{position:absolute;left:calc(-100% * var(--pw-fit-x, 0) / var(--pw-fit-w, 1));top:calc(-100% * var(--pw-fit-y, 0) / var(--pw-fit-h, 1));width:calc(100% / var(--pw-fit-w, 1));height:calc(100% / var(--pw-fit-h, 1));max-width:none;aspect-ratio:auto}.pt-frame{position:relative;z-index:1;width:100%;height:100%}.pt-frame-leave{position:absolute;inset:0;z-index:0;pointer-events:none}.pt-frame-leave--above{z-index:2}.pt-frame.t-fade-in,.pt-frame.t-zoom-in,.pt-frame.t-from-right,.pt-frame.t-from-left,.pt-frame.t-from-bottom,.pt-frame.t-from-top{will-change:transform,opacity}.t-fade-out{animation:pw-t-fade-out var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-zoom-out{animation:pw-t-zoom-out var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}@keyframes pw-t-fade-out{0%{opacity:1}to{opacity:0}}@keyframes pw-t-zoom-out{0%{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(1.06)}}.branch-placeholder{position:absolute;inset:0;background:#c9ccd1;border-radius:inherit}.viewport-page .panel-container.panel-branch-hidden{pointer-events:none}.viewport-page .panel-container{position:absolute;overflow:hidden;background:#000;border:1px solid rgba(255,255,255,.1);border-radius:2px;transition:transform .2s ease,border-color .2s ease,box-shadow .2s ease;transform-origin:center center;outline:none}.viewport-page .panel-container:hover{border-color:#ffffff4d;cursor:pointer}.viewport-page .panel-container.panel-focused{border-color:#0078d7cc;box-shadow:0 0 0 3px #0078d74d,0 4px 12px #0006;z-index:9999!important}.focus-rect{position:absolute;inset:-4px;border:3px solid rgba(0,120,215,.9);border-radius:4px;pointer-events:none;animation:focusPulse 1.5s ease-in-out infinite}@keyframes focusPulse{0%,to{opacity:1;transform:scale(1)}50%{opacity:.7;transform:scale(1.02)}}@media (prefers-reduced-motion: reduce){.focus-rect{animation:none}.viewport-page .panel-container{transition:none}}.viewport-page .panel-content{position:relative;width:100%;height:100%}.nav-arrow{position:absolute;top:50%;transform:translateY(-50%);width:60px;height:120px;background:#0006;border:none;color:#fff;font-size:48px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .3s ease;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);z-index:50;animation:fadeInArrow .2s ease}.nav-arrow:hover{background:#0009;width:70px}.nav-arrow:active{background:#000000b3;transform:translateY(-50%) scale(.95)}.nav-arrow-left{left:0;border-radius:0 8px 8px 0}.nav-arrow-right{right:0;border-radius:8px 0 0 8px}.arrow-icon{font-size:48px;line-height:1;font-weight:300}@keyframes fadeInArrow{0%{opacity:0}to{opacity:1}}@media (prefers-reduced-motion: reduce){.nav-arrow{animation:none;transition:none}}@media (max-width: 768px){.nav-arrow{width:50px;height:100px;font-size:36px}.nav-arrow:hover{width:55px}.arrow-icon{font-size:36px}}\n"], dependencies: [{ kind: "component", type: LayerRendererComponent, selector: "pw-layer-renderer", inputs: ["layer", "locale", "targetWidth", "altText", "viewMode", "viewActive", "reducedMotion", "panelId", "placementId", "readingOrderIndex", "placementZ", "placementY", "placementX"], outputs: ["layerClick"] }, { kind: "component", type: SpeechBubblesComponent, selector: "pw-speech-bubbles", inputs: ["bubbles", "locale", "containerWidth", "containerHeight", "readingScale", "workBalloonConfig", "characters"], outputs: ["bubbleClick", "bubbleAudioPlay"] }, { kind: "component", type: HotspotsOverlayComponent, selector: "pw-hotspots-overlay", inputs: ["hotspots", "bubbles", "locale", "containerWidth", "containerHeight", "context", "interactive", "showIndicators"], outputs: ["hotspotActivate"] }, { kind: "component", type: PwIconComponent, selector: "pw-icon", inputs: ["name"] }, { kind: "directive", type: PanelAnimationDirective, selector: "[pwPanelAnimation]", inputs: ["pwPanelAnimation", "pwPanelAnimationKey", "pwPanelAnimationReducedMotion", "pwPanelAnimationStatic"] }, { kind: "component", type: LockedPanelComponent, selector: "pw-locked-panel-placeholder", inputs: ["actionable"], outputs: ["activate"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
14812
+ 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 }); }
14397
14813
  }
14398
14814
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: ViewportComponent, decorators: [{
14399
14815
  type: Component,
14400
- args: [{ selector: 'pw-viewport', imports: [LayerRendererComponent, SpeechBubblesComponent, HotspotsOverlayComponent, PwIconComponent, PanelAnimationDirective, LockedPanelComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div \n class=\"viewport-container\"\n [class]=\"getViewModeClass()\"\n [class.reduced-motion]=\"reducedMotion\"\n [class.interactive]=\"interactive\"\n [style.background-color]=\"getViewportBackground()\"\n role=\"region\"\n [attr.aria-label]=\"viewMode === 'page' ? 'Page viewport' : 'Panel viewport'\"\n tabindex=\"0\"\n (click)=\"onViewportClick($event)\"\n (keydown.enter)=\"onKeyboardActivate($event)\"\n (keydown.space)=\"onKeyboardActivate($event)\"\n (mousemove)=\"onViewportMouseMove($event)\"\n (dblclick)=\"onViewportDoubleClick($event)\"\n (mouseleave)=\"onViewportMouseLeave()\">\n \n <!-- Panel View -->\n @if (viewMode === 'panel') {\n <div\n class=\"viewport-content\"\n [style.transform]=\"getTransformStyle()\"\n [style.transition]=\"reducedMotion ? 'none' : 'transform 0.3s ease'\"\n [style.--pw-t-duration]=\"transitionDuration\"\n [style.--pw-t-easing]=\"transitionEasing\">\n\n <!-- Outgoing panel during a manifest edge transition -->\n @if (leavingPanel) {\n <div [class]=\"'t-frame-leave ' + leaveAnimationClass\" aria-hidden=\"true\">\n <!-- The outgoing panel holds the end state of its animation. -->\n <div class=\"panel-container\"\n [style.width.px]=\"panelBox?.width\"\n [style.height.px]=\"panelBox?.height\"\n [pwPanelAnimation]=\"leavingPanel.animations\"\n [pwPanelAnimationKey]=\"leavingPanelId || ''\"\n [pwPanelAnimationStatic]=\"true\">\n <div class=\"pw-panel-camera\">\n @if (isLocked(leavingPanel, leavingPanelId)) {\n <pw-locked-panel-placeholder></pw-locked-panel-placeholder>\n } @else {\n @for (layer of leavingPanel.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n [viewMode]=\"viewMode\"\n [viewActive]=\"false\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(leavingPanelId)\"\n [panelId]=\"leavingPanelId\">\n </pw-layer-renderer>\n }\n\n @if (speechEnabled && leavingPanel.speechBubbles && leavingPanel.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"leavingPanel.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"getContainerWidth()\"\n [containerHeight]=\"getContainerHeight()\"\n [readingScale]=\"getReadingScale()\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n </div>\n </div>\n }\n\n <div [class]=\"'t-frame ' + enterAnimationClass\">\n @if (panel) {\n <div class=\"panel-container\" tabindex=\"0\"\n [style.width.px]=\"panelBox?.width\"\n [style.height.px]=\"panelBox?.height\"\n [pwPanelAnimation]=\"panel.animations\"\n [pwPanelAnimationKey]=\"currentPanelId || ''\"\n [pwPanelAnimationReducedMotion]=\"reducedMotion\"\n (click)=\"onPanelContainerClick($event, currentPanelId)\" (keydown.enter)=\"onPanelContainerClick($any($event), currentPanelId)\">\n <!-- Everything that belongs to the artwork; a camera move transforms it as one. -->\n <div class=\"pw-panel-camera\">\n @if (isLocked(panel, currentPanelId)) {\n <pw-locked-panel-placeholder></pw-locked-panel-placeholder>\n } @else {\n <!-- Render layers using LayerRenderer -->\n @for (layer of panel.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n [viewMode]=\"viewMode\"\n [viewActive]=\"true\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(currentPanelId)\"\n [panelId]=\"currentPanelId || ''\"\n (layerClick)=\"layerClick.emit($event)\">\n </pw-layer-renderer>\n }\n\n <!-- Interactive hotspots overlay (below speech bubbles) -->\n @if (interactive && panel.hotspots && panel.hotspots.length > 0) {\n <pw-hotspots-overlay\n [hotspots]=\"panel.hotspots\"\n [bubbles]=\"panel.speechBubbles ?? []\"\n [locale]=\"locale\"\n [containerWidth]=\"getContainerWidth()\"\n [containerHeight]=\"getContainerHeight()\"\n [context]=\"variableContext\"\n (hotspotActivate)=\"onHotspotActivate($event, currentPanelId)\">\n </pw-hotspots-overlay>\n }\n\n <!-- Speech bubbles overlay for panel view -->\n @if (speechEnabled && panel.speechBubbles && panel.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"panel.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"getContainerWidth()\"\n [containerHeight]=\"getContainerHeight()\"\n [readingScale]=\"getReadingScale()\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n </div>\n } @else {\n <div class=\"no-panel\">\n <p>No panel to display</p>\n </div>\n }\n </div>\n </div>\n }\n \n <!-- Page View -->\n @if (viewMode === 'page' && page) {\n <div\n class=\"viewport-page\"\n [style.--pw-page-aspect]=\"pageAspect\"\n [style.--pw-stage-aspect]=\"stageAspect\"\n [style.--pw-fit-x]=\"pageFit.x\"\n [style.--pw-fit-y]=\"pageFit.y\"\n [style.--pw-fit-w]=\"pageFit.w\"\n [style.--pw-fit-h]=\"pageFit.h\">\n <div class=\"page-stage\">\n <!-- Outgoing page during a Page.transitions in/out change -->\n @if (leavingPage) {\n <div\n [class]=\"'pt-frame-leave ' + pageLeaveClass + (pageLeaveAbove ? ' pt-frame-leave--above' : '')\"\n [style.--pw-t-duration]=\"pageLeaveDuration\"\n [style.--pw-t-easing]=\"pageLeaveEasing\"\n aria-hidden=\"true\">\n <div class=\"page-canvas\" [style.background-color]=\"pageBackgroundOf(leavingPage)\">\n @for (placement of getLeavingPagePlacements(); track placement.panelId) {\n <div\n class=\"panel-container\"\n [style.left.%]=\"toPercent(placement.x)\"\n [style.top.%]=\"toPercent(placement.y)\"\n [style.width.%]=\"toPercent(placement.w)\"\n [style.height.%]=\"toPercent(placement.h)\"\n [style.z-index]=\"placement.z || 0\"\n [style.transform]=\"getPanelTransform(placement)\"\n [style.transform-origin]=\"getTransformOrigin(placement)\">\n\n @if (isBranchHidden(placement.panelId)) {\n <div class=\"branch-placeholder\" aria-hidden=\"true\"></div>\n } @else if (getLeavingPanel(placement.panelId); as panelData) {\n <div class=\"panel-content\"\n [style.clip-path]=\"getPanelContentClipPath(placement)\"\n [style.transform]=\"getPanelContentTransform(placement)\"\n [pwPanelAnimation]=\"panelData.animations\"\n [pwPanelAnimationKey]=\"placement.panelId\"\n [pwPanelAnimationStatic]=\"true\">\n <div class=\"pw-panel-camera\">\n @if (isLocked(panelData, placement.panelId)) {\n <pw-locked-panel-placeholder></pw-locked-panel-placeholder>\n } @else {\n @for (layer of panelData.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n [viewMode]=\"viewMode\"\n [viewActive]=\"false\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(placement.panelId)\"\n [panelId]=\"placement.panelId\"\n [placementId]=\"placement.panelId\">\n </pw-layer-renderer>\n }\n\n @if (speechEnabled && panelData.speechBubbles && panelData.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"panelData.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"getPagePanelWidth(placement)\"\n [containerHeight]=\"getPagePanelHeight(placement)\"\n [readingScale]=\"getPageReadingScale(placement)\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n </div>\n }\n </div>\n }\n </div>\n </div>\n }\n\n <div\n [class]=\"'pt-frame ' + pageEnterClass\"\n [style.--pw-t-duration]=\"pageEnterDuration\"\n [style.--pw-t-easing]=\"pageEnterEasing\">\n <div\n class=\"page-canvas\"\n [style.background-color]=\"pageBackgroundOf(page)\"\n [attr.tabindex]=\"-1\"\n (click)=\"onPanelClickWithFocus($event)\"\n (keydown.enter)=\"onCanvasKeyboardActivate($event)\"\n (keydown.space)=\"onCanvasKeyboardActivate($event)\">\n \n @for (placement of getSortedPanels(); track placement.panelId) {\n @if (isPanelVisible(placement)) {\n <div \n class=\"panel-container\"\n [class.panel-focused]=\"isPanelFocused(placement.panelId)\"\n [style.left.%]=\"toPercent(placement.x)\"\n [style.top.%]=\"toPercent(placement.y)\"\n [style.width.%]=\"toPercent(placement.w)\"\n [style.height.%]=\"toPercent(placement.h)\"\n [style.z-index]=\"placement.z || 0\"\n [style.transform]=\"getPanelTransform(placement)\"\n [style.transform-origin]=\"getTransformOrigin(placement)\"\n [attr.data-panel-id]=\"placement.panelId\"\n [class.panel-branch-hidden]=\"isBranchHidden(placement.panelId)\"\n [attr.aria-hidden]=\"isBranchHidden(placement.panelId) ? 'true' : null\"\n [attr.tabindex]=\"isBranchHidden(placement.panelId) || panelHasHotspots(placement.panelId) ? null : 0\"\n [attr.role]=\"isBranchHidden(placement.panelId) || panelHasHotspots(placement.panelId) ? null : 'button'\"\n [attr.aria-label]=\"isBranchHidden(placement.panelId) || panelHasHotspots(placement.panelId) ? null : 'Panel ' + placement.panelId\"\n [attr.loading]=\"getImageLoadingStrategy()\"\n (click)=\"onPanelClick($event, placement.panelId)\"\n (keydown.enter)=\"onPanelKeyboardActivate($event, placement.panelId)\"\n (keydown.space)=\"onPanelKeyboardActivate($event, placement.panelId)\">\n \n @if (isBranchHidden(placement.panelId)) {\n <!-- Not on the reader's path (yet): a grey box keeps the page's shape. -->\n <div class=\"branch-placeholder\"></div>\n } @else if (getPanel(placement.panelId); as panelData) {\n <div class=\"panel-content\"\n [style.clip-path]=\"getPanelContentClipPath(placement)\"\n [style.transform]=\"getPanelContentTransform(placement)\"\n [pwPanelAnimation]=\"panelData.animations\"\n [pwPanelAnimationKey]=\"placement.panelId\"\n [pwPanelAnimationReducedMotion]=\"reducedMotion\"\n tabindex=\"0\"\n (click)=\"onPanelContainerClick($event, placement.panelId)\"\n (keydown.enter)=\"onPanelContainerClick($any($event), placement.panelId)\">\n <div class=\"pw-panel-camera\">\n @if (isLocked(panelData, placement.panelId)) {\n <pw-locked-panel-placeholder\n [actionable]=\"true\"\n (activate)=\"lockedPanelActivate.emit(placement.panelId)\"></pw-locked-panel-placeholder>\n } @else {\n <!-- Render panel layers -->\n @for (layer of panelData.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n [altText]=\"getAltText(layer)\"\n [viewMode]=\"viewMode\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(placement.panelId)\"\n [panelId]=\"placement.panelId\"\n [placementId]=\"placement.panelId\"\n [readingOrderIndex]=\"readingOrderIndexOf(placement.panelId)\"\n [placementZ]=\"placement.z || 0\"\n [placementY]=\"placement.y\"\n [placementX]=\"placement.x\"\n (layerClick)=\"layerClick.emit($event)\">\n </pw-layer-renderer>\n }\n\n <!-- Interactive hotspots overlay for page view panels -->\n @if (interactive && panelData.hotspots && panelData.hotspots.length > 0) {\n <pw-hotspots-overlay\n [hotspots]=\"panelData.hotspots\"\n [bubbles]=\"panelData.speechBubbles ?? []\"\n [locale]=\"locale\"\n [containerWidth]=\"getPagePanelWidth(placement)\"\n [containerHeight]=\"getPagePanelHeight(placement)\"\n [context]=\"variableContext\"\n (hotspotActivate)=\"onHotspotActivate($event, placement.panelId)\">\n </pw-hotspots-overlay>\n }\n\n <!-- Speech bubbles overlay for page view panels -->\n @if (speechEnabled && panelData.speechBubbles && panelData.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"panelData.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"getPagePanelWidth(placement)\"\n [containerHeight]=\"getPagePanelHeight(placement)\"\n [readingScale]=\"getPageReadingScale(placement)\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n </div>\n }\n \n <!-- Focus indicator for rotated panels -->\n @if (isPanelFocused(placement.panelId)) {\n <div class=\"focus-rect\" aria-hidden=\"true\"></div>\n }\n </div>\n }\n }\n </div>\n </div>\n </div>\n </div>\n }\n\n @if (viewMode === 'page' && !page) {\n <div class=\"no-panel\">\n <p>No page to display</p>\n </div>\n }\n\n <!-- Overflow navigation arrows -->\n @if (showOverflowArrows && panel) {\n @if (hasOverflowLeft) {\n <button \n class=\"overflow-arrow overflow-left\"\n type=\"button\"\n aria-label=\"Navigate left\"\n (click)=\"navigateLeft()\">\n <pw-icon name=\"lucideChevronLeft\" />\n </button>\n }\n\n @if (hasOverflowRight) {\n <button \n class=\"overflow-arrow overflow-right\"\n type=\"button\"\n aria-label=\"Navigate right\"\n (click)=\"navigateRight()\">\n <pw-icon name=\"lucideChevronRight\" />\n </button>\n }\n\n @if (hasOverflowTop) {\n <button \n class=\"overflow-arrow overflow-top\"\n type=\"button\"\n aria-label=\"Navigate up\"\n (click)=\"navigateUp()\">\n <pw-icon name=\"lucideChevronUp\" />\n </button>\n }\n\n @if (hasOverflowBottom) {\n <button \n class=\"overflow-arrow overflow-bottom\"\n type=\"button\"\n aria-label=\"Navigate down\"\n (click)=\"navigateDown()\">\n <pw-icon name=\"lucideChevronDown\" />\n </button>\n }\n }\n\n <!-- Navigation arrows (hover zones) -->\n @if (viewMode === 'panel' || (viewMode === 'page' && page)) {\n @if (showLeftArrow) {\n <button \n class=\"nav-arrow nav-arrow-left\"\n type=\"button\"\n [attr.aria-label]=\"viewMode === 'page' ? 'Previous page' : 'Previous panel'\"\n (click)=\"onNavigatePrevious(); $event.stopPropagation()\">\n <span class=\"arrow-icon\"><pw-icon name=\"lucideChevronLeft\" /></span>\n </button>\n }\n\n @if (showRightArrow) {\n <button \n class=\"nav-arrow nav-arrow-right\"\n type=\"button\"\n [attr.aria-label]=\"viewMode === 'page' ? 'Next page' : 'Next panel'\"\n (click)=\"onNavigateNext(); $event.stopPropagation()\">\n <span class=\"arrow-icon\"><pw-icon name=\"lucideChevronRight\" /></span>\n </button>\n }\n }\n</div>\n", styles: [".viewport-container{position:relative;width:100%;height:100%;overflow:hidden;background-color:#1a1a1a;cursor:grab;touch-action:none;-webkit-user-select:none;user-select:none}.viewport-container:active{cursor:grabbing}.viewport-container.interactive{cursor:default}.viewport-content{position:absolute;top:50%;left:50%;transform-origin:center center;will-change:transform}.viewport-content.reduced-motion{transition:none!important}.panel-container{position:relative;background:transparent;width:1280px;height:720px;max-width:100%;max-height:100%}.pw-panel-camera{position:absolute;inset:0}.t-frame{position:relative;z-index:1}.t-frame-leave{position:absolute;inset:0;z-index:0;pointer-events:none}.t-fade-in{animation:pw-t-fade-in var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-zoom-in{animation:pw-t-zoom-in var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-from-right{animation:pw-t-from-right var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-from-left{animation:pw-t-from-left var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-from-bottom{animation:pw-t-from-bottom var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-from-top{animation:pw-t-from-top var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-to-left{animation:pw-t-to-left var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-to-right{animation:pw-t-to-right var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-to-top{animation:pw-t-to-top var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-to-bottom{animation:pw-t-to-bottom var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}@keyframes pw-t-fade-in{0%{opacity:0}to{opacity:1}}@keyframes pw-t-zoom-in{0%{opacity:0;transform:scale(.94)}to{opacity:1;transform:scale(1)}}@keyframes pw-t-from-right{0%{transform:translate(100%)}to{transform:translate(0)}}@keyframes pw-t-from-left{0%{transform:translate(-100%)}to{transform:translate(0)}}@keyframes pw-t-from-bottom{0%{transform:translateY(100%)}to{transform:translateY(0)}}@keyframes pw-t-from-top{0%{transform:translateY(-100%)}to{transform:translateY(0)}}@keyframes pw-t-to-left{0%{transform:translate(0)}to{transform:translate(-100%)}}@keyframes pw-t-to-right{0%{transform:translate(0)}to{transform:translate(100%)}}@keyframes pw-t-to-top{0%{transform:translateY(0)}to{transform:translateY(-100%)}}@keyframes pw-t-to-bottom{0%{transform:translateY(0)}to{transform:translateY(100%)}}.layer{position:absolute;top:0;left:0}.layer-placeholder{padding:20px;background:#0078d71a;border:1px dashed rgba(0,120,215,.3);color:#0078d7;font-size:14px}.no-panel{display:flex;align-items:center;justify-content:center;min-width:400px;min-height:300px;color:#666}.reduced-motion *{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}.overflow-arrow{position:absolute;z-index:100;background:#0009;color:#fff;border:none;border-radius:4px;width:40px;height:40px;font-size:20px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .2s ease;box-shadow:0 2px 8px #0000004d}.overflow-arrow:hover{background:#000c;transform:scale(1.1)}.overflow-arrow:active{transform:scale(.95)}.overflow-left{left:20px;top:50%;transform:translateY(-50%)}.overflow-left:hover{transform:translateY(-50%) scale(1.1)}.overflow-right{right:20px;top:50%;transform:translateY(-50%)}.overflow-right:hover{transform:translateY(-50%) scale(1.1)}.overflow-top{top:20px;left:50%;transform:translate(-50%)}.overflow-top:hover{transform:translate(-50%) scale(1.1)}.overflow-bottom{bottom:20px;left:50%;transform:translate(-50%)}.overflow-bottom:hover{transform:translate(-50%) scale(1.1)}.viewport-page{width:100%;height:100%;display:flex;align-items:center;justify-content:center;overflow:hidden;padding:0;box-sizing:border-box;container-type:size}.page-canvas{position:relative;width:100%;aspect-ratio:var(--pw-page-aspect, 16 / 9);background:#1a1a1a;overflow:hidden}.page-stage{position:relative;width:min(100cqw,100cqh * var(--pw-stage-aspect, var(--pw-page-aspect, 1.7778)));aspect-ratio:var(--pw-stage-aspect, var(--pw-page-aspect, 16 / 9));flex:none;overflow:hidden}.page-stage .page-canvas{position:absolute;left:calc(-100% * var(--pw-fit-x, 0) / var(--pw-fit-w, 1));top:calc(-100% * var(--pw-fit-y, 0) / var(--pw-fit-h, 1));width:calc(100% / var(--pw-fit-w, 1));height:calc(100% / var(--pw-fit-h, 1));max-width:none;aspect-ratio:auto}.pt-frame{position:relative;z-index:1;width:100%;height:100%}.pt-frame-leave{position:absolute;inset:0;z-index:0;pointer-events:none}.pt-frame-leave--above{z-index:2}.pt-frame.t-fade-in,.pt-frame.t-zoom-in,.pt-frame.t-from-right,.pt-frame.t-from-left,.pt-frame.t-from-bottom,.pt-frame.t-from-top{will-change:transform,opacity}.t-fade-out{animation:pw-t-fade-out var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}.t-zoom-out{animation:pw-t-zoom-out var(--pw-t-duration, .4s) var(--pw-t-easing, ease) both}@keyframes pw-t-fade-out{0%{opacity:1}to{opacity:0}}@keyframes pw-t-zoom-out{0%{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(1.06)}}.branch-placeholder{position:absolute;inset:0;background:#c9ccd1;border-radius:inherit}.viewport-page .panel-container.panel-branch-hidden{pointer-events:none}.viewport-page .panel-container{position:absolute;overflow:hidden;background:#000;border:1px solid rgba(255,255,255,.1);border-radius:2px;transition:transform .2s ease,border-color .2s ease,box-shadow .2s ease;transform-origin:center center;outline:none}.viewport-page .panel-container:hover{border-color:#ffffff4d;cursor:pointer}.viewport-page .panel-container.panel-focused{border-color:#0078d7cc;box-shadow:0 0 0 3px #0078d74d,0 4px 12px #0006;z-index:9999!important}.focus-rect{position:absolute;inset:-4px;border:3px solid rgba(0,120,215,.9);border-radius:4px;pointer-events:none;animation:focusPulse 1.5s ease-in-out infinite}@keyframes focusPulse{0%,to{opacity:1;transform:scale(1)}50%{opacity:.7;transform:scale(1.02)}}@media (prefers-reduced-motion: reduce){.focus-rect{animation:none}.viewport-page .panel-container{transition:none}}.viewport-page .panel-content{position:relative;width:100%;height:100%}.nav-arrow{position:absolute;top:50%;transform:translateY(-50%);width:60px;height:120px;background:#0006;border:none;color:#fff;font-size:48px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .3s ease;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);z-index:50;animation:fadeInArrow .2s ease}.nav-arrow:hover{background:#0009;width:70px}.nav-arrow:active{background:#000000b3;transform:translateY(-50%) scale(.95)}.nav-arrow-left{left:0;border-radius:0 8px 8px 0}.nav-arrow-right{right:0;border-radius:8px 0 0 8px}.arrow-icon{font-size:48px;line-height:1;font-weight:300}@keyframes fadeInArrow{0%{opacity:0}to{opacity:1}}@media (prefers-reduced-motion: reduce){.nav-arrow{animation:none;transition:none}}@media (max-width: 768px){.nav-arrow{width:50px;height:100px;font-size:36px}.nav-arrow:hover{width:55px}.arrow-icon{font-size:36px}}\n"] }]
14816
+ 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"] }]
14401
14817
  }], propDecorators: { panel: [{
14402
14818
  type: Input
14403
14819
  }], page: [{
@@ -15603,7 +16019,7 @@ class CanvasStageComponent {
15603
16019
  return Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y);
15604
16020
  }
15605
16021
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: CanvasStageComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
15606
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.14", type: CanvasStageComponent, isStandalone: true, selector: "pw-canvas-stage", inputs: { canvas: "canvas", panels: "panels", graph: "graph", currentPanelId: "currentPanelId", visitedPanelIds: "visitedPanelIds", locale: "locale", variableContext: "variableContext", speechEnabled: "speechEnabled", workBalloonConfig: "workBalloonConfig", characters: "characters", reducedMotion: "reducedMotion", interactive: "interactive", preload: "preload" }, outputs: { panelTap: "panelTap", navigateNext: "navigateNext", navigatePrevious: "navigatePrevious", viewportClick: "viewportClick", hotspotActivate: "hotspotActivate", cameraChange: "cameraChange" }, usesOnChanges: true, ngImport: i0, template: "<div\n class=\"canvas-viewport\"\n [class.reduced-motion]=\"reducedMotion\"\n [style]=\"backgroundStyle()\"\n role=\"region\"\n aria-label=\"Canvas viewport\"\n tabindex=\"0\"\n (pointerdown)=\"onPointerDown($event)\"\n (pointermove)=\"onPointerMove($event)\"\n (pointerup)=\"onPointerUp($event)\"\n (pointercancel)=\"onPointerUp($event)\"\n (wheel)=\"onWheel($event)\">\n\n <div class=\"canvas-world\" [style.transform]=\"worldTransform\">\n\n <!-- Tiled/stretched backdrop covering the canvas bounds -->\n @if (worldBackgroundStyle(); as bgStyle) {\n <div class=\"canvas-backdrop\" [style]=\"bgStyle\" aria-hidden=\"true\"></div>\n }\n\n <!-- Non-panel set dressing -->\n @for (decoration of visibleDecorations; track decoration.assetId + decoration.x + decoration.y) {\n <img\n class=\"canvas-decoration\"\n [src]=\"assetUrl(decoration.assetId)\"\n [style]=\"decorationStyle(decoration)\"\n alt=\"\"\n aria-hidden=\"true\"\n loading=\"lazy\" />\n }\n\n <!-- Placed panels (virtualized) -->\n @for (entry of stagePlacements; track trackPlacement($index, entry)) {\n <div\n class=\"canvas-panel\"\n [class.current]=\"isCurrent(entry.panelId)\"\n [class.unrevealed]=\"!entry.revealed\"\n [style]=\"placementStyle(entry)\"\n [attr.data-panel-id]=\"entry.panelId\"\n [pwPanelAnimation]=\"entry.revealed ? entry.panel.animations : null\"\n [pwPanelAnimationKey]=\"entry.panelId\"\n [pwPanelAnimationReducedMotion]=\"reducedMotion\"\n [attr.aria-hidden]=\"isCurrent(entry.panelId) ? null : true\"\n [attr.inert]=\"isCurrent(entry.panelId) ? null : ''\">\n\n @if (entry.revealed) {\n <!-- The panel's artwork; a panel camera move (PanelAnimations) transforms it as one. -->\n <div class=\"pw-panel-camera\">\n @if (isLocked(entry.panel, entry.panelId)) {\n <pw-locked-panel-placeholder></pw-locked-panel-placeholder>\n } @else {\n @for (layer of entry.panel.layers; track layer.id) {\n <pw-layer-renderer\n [layer]=\"layer\"\n [locale]=\"locale\"\n viewMode=\"panel\"\n [viewActive]=\"isCurrent(entry.panelId)\"\n [reducedMotion]=\"reducedMotion\"\n [targetWidth]=\"targetWidthFor(entry.panelId)\"\n [panelId]=\"entry.panelId\">\n </pw-layer-renderer>\n }\n\n <!-- Hotspots are interactive on the CURRENT panel (the one the\n camera frames); other panels show them render-only, and a\n tap there keeps its panel-navigation meaning. -->\n @if (entry.panel.hotspots && entry.panel.hotspots.length > 0) {\n <pw-hotspots-overlay\n [hotspots]=\"entry.panel.hotspots\"\n [locale]=\"locale\"\n [containerWidth]=\"entry.placement.w\"\n [containerHeight]=\"entry.placement.h\"\n [context]=\"variableContext\"\n [interactive]=\"interactive && isCurrent(entry.panelId)\"\n (hotspotActivate)=\"hotspotActivate.emit({ hotspot: $event.hotspot, x: $event.x, y: $event.y, panelId: entry.panelId })\">\n </pw-hotspots-overlay>\n }\n\n @if (speechEnabled && entry.panel.speechBubbles && entry.panel.speechBubbles.length > 0) {\n <pw-speech-bubbles\n [bubbles]=\"entry.panel.speechBubbles\"\n [locale]=\"locale\"\n [containerWidth]=\"entry.placement.w\"\n [containerHeight]=\"entry.placement.h\"\n [readingScale]=\"getReadingScale()\"\n [workBalloonConfig]=\"workBalloonConfig\"\n [characters]=\"characters\">\n </pw-speech-bubbles>\n }\n }\n </div>\n } @else {\n <!-- Unrevealed panel: dark veil, no content (spoiler protection) -->\n <div class=\"canvas-panel-veil\" aria-hidden=\"true\"></div>\n }\n </div>\n }\n </div>\n</div>\n", styles: [":host{display:block;width:100%;height:100%}.canvas-viewport{position:relative;width:100%;height:100%;overflow:hidden;background-color:#101014;touch-action:none;cursor:grab;outline:none}.canvas-viewport:active{cursor:grabbing}.canvas-world{position:absolute;top:0;left:0;width:0;height:0;transform-origin:0 0;will-change:transform}.canvas-backdrop{position:absolute;pointer-events:none}.canvas-decoration{position:absolute;pointer-events:none;max-width:none}.canvas-panel{position:absolute;overflow:hidden;background:#000;contain:layout paint;content-visibility:auto;box-shadow:0 8px 40px #0000008c}.canvas-panel.current{box-shadow:0 0 0 2px #ffffff2e,0 8px 48px #0009}.pw-panel-camera{position:absolute;inset:0}.canvas-panel-veil{position:absolute;inset:0;background:radial-gradient(ellipse at center,#1e1e26eb,#0a0a0efa)}.reduced-motion .canvas-world{will-change:auto}\n"], dependencies: [{ kind: "component", type: LayerRendererComponent, selector: "pw-layer-renderer", inputs: ["layer", "locale", "targetWidth", "altText", "viewMode", "viewActive", "reducedMotion", "panelId", "placementId", "readingOrderIndex", "placementZ", "placementY", "placementX"], outputs: ["layerClick"] }, { kind: "component", type: SpeechBubblesComponent, selector: "pw-speech-bubbles", inputs: ["bubbles", "locale", "containerWidth", "containerHeight", "readingScale", "workBalloonConfig", "characters"], outputs: ["bubbleClick", "bubbleAudioPlay"] }, { kind: "component", type: HotspotsOverlayComponent, selector: "pw-hotspots-overlay", inputs: ["hotspots", "bubbles", "locale", "containerWidth", "containerHeight", "context", "interactive", "showIndicators"], outputs: ["hotspotActivate"] }, { kind: "directive", type: PanelAnimationDirective, selector: "[pwPanelAnimation]", inputs: ["pwPanelAnimation", "pwPanelAnimationKey", "pwPanelAnimationReducedMotion", "pwPanelAnimationStatic"] }, { kind: "component", type: LockedPanelComponent, selector: "pw-locked-panel-placeholder", inputs: ["actionable"], outputs: ["activate"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
16022
+ 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 }); }
15607
16023
  }
15608
16024
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImport: i0, type: CanvasStageComponent, decorators: [{
15609
16025
  type: Component,
@@ -19423,6 +19839,7 @@ class PlayerShellComponent {
19423
19839
  this.cdr = inject(ChangeDetectorRef);
19424
19840
  this.audioEngine = inject(AudioEngineService);
19425
19841
  this.panelAudio = inject(PanelAudioService);
19842
+ this.sequenceAudio = inject(SequenceAudioService);
19426
19843
  /**
19427
19844
  * Reading order per panel id (1-based, across chapters), computed once per
19428
19845
  * manifest. Reported as `panelOrder` on panel_view / funnel events and as
@@ -19559,6 +19976,7 @@ class PlayerShellComponent {
19559
19976
  this.autoplayEnabled = false;
19560
19977
  this.videoSequencer.reset();
19561
19978
  this.panelAudio.stopAll();
19979
+ this.sequenceAudio.stopAll();
19562
19980
  this.endAnalyticsSession();
19563
19981
  this.analyticsSessionStarted = this.analyticsSessionEnded = this.workCompleteTracked = false;
19564
19982
  this.lastTrackedPanelId = undefined;
@@ -19625,6 +20043,7 @@ class PlayerShellComponent {
19625
20043
  this.stopAutoplayProgress();
19626
20044
  this.videoSequencer.reset();
19627
20045
  this.panelAudio.stopAll();
20046
+ this.sequenceAudio.stopAll();
19628
20047
  this.destroy$.next();
19629
20048
  this.destroy$.complete();
19630
20049
  }
@@ -20182,6 +20601,9 @@ class PlayerShellComponent {
20182
20601
  (isLockedPanel(panel) || (panelId !== undefined && this.paywallService.isPanelLocked(panelId)));
20183
20602
  // Under the cover the story has not started yet: no panel audio.
20184
20603
  this.panelAudio.syncPanel(panelId, locked || this.coverVisible ? undefined : panel, this.variableContext);
20604
+ // Timeline music/ambience follows the reader's position on the work line
20605
+ // (silent under the cover and on locked panels too).
20606
+ this.sequenceAudio.syncPanel(locked || this.coverVisible ? undefined : panelId, this.activePageFormat);
20185
20607
  }
20186
20608
  /**
20187
20609
  * Hotspot activated in the viewport (click or keyboard): execute its
@@ -21573,6 +21995,8 @@ class PlayerShellComponent {
21573
21995
  return;
21574
21996
  }
21575
21997
  this.activePageFormat = next;
21998
+ // The format decides the panel line timeline audio is placed on.
21999
+ this.syncPanelAudio();
21576
22000
  if (this.viewMode === 'page') {
21577
22001
  const page = this.findPageContainingPanel();
21578
22002
  if (page && page.id !== this.currentPage?.id) {
@@ -22464,5 +22888,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.14", ngImpo
22464
22888
  * Generated bundle index. Do not edit.
22465
22889
  */
22466
22890
 
22467
- 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 };
22891
+ 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 };
22468
22892
  //# sourceMappingURL=panelwave-player.mjs.map