@webview-native-ui/engine 0.0.4 → 0.0.6

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.
@@ -1,8 +1,7 @@
1
1
  import { projectionURL } from '../native/resource-url.js';
2
2
  import { bounded, boundedListener } from '../native/bridge.js';
3
3
  import { bindProjectionEvents } from './events.js';
4
- import { projectionAncestors } from '../dom/geometry.js';
5
- import { snapshotStyles } from './styles.js';
4
+ import { snapshotStyles, snapshotShadowStyles } from './styles.js';
6
5
  const layout = (candidates, positionIndependent = false) => JSON.stringify(candidates.map(({ control: c }) => [
7
6
  c.id,
8
7
  c.kind,
@@ -14,41 +13,6 @@ const layout = (candidates, positionIndependent = false) => JSON.stringify(candi
14
13
  c.dark,
15
14
  c.rtl,
16
15
  ]));
17
- // Native frames are viewport pixels; absolute children use the target's local
18
- // padding box. Keep the conversion independent of theme dimensions.
19
- const coordinateSpace = (target, win, style = win.getComputedStyle(target)) => {
20
- const rect = target.getBoundingClientRect();
21
- // offsetWidth/Height round fractional CSS sizes, which would introduce drift.
22
- const borderBox = (size, sides) => {
23
- const value = parseFloat(size);
24
- return style.display !== 'inline' && Number.isFinite(value)
25
- ? value +
26
- (style.boxSizing === 'border-box' ? 0 : sides.reduce((sum, side) => sum + (parseFloat(style.getPropertyValue(side)) || 0), 0))
27
- : NaN;
28
- };
29
- const width = borderBox(style.width, ['padding-left', 'padding-right', 'border-left-width', 'border-right-width']);
30
- const height = borderBox(style.height, ['padding-top', 'padding-bottom', 'border-top-width', 'border-bottom-width']);
31
- // Inline dimensions may be auto (or an ignored specified width). Native element
32
- // projection excludes transformed ancestors; use CSS zoom, not rounded offsets.
33
- let zoom = 1;
34
- if (!(width > 0 && height > 0)) {
35
- for (const element of [target, ...projectionAncestors(target)]) {
36
- const value = (element === target ? style : win.getComputedStyle(element)).getPropertyValue('zoom');
37
- zoom *= (parseFloat(value) || 1) / (value.endsWith('%') ? 100 : 1);
38
- }
39
- }
40
- const scaleX = width > 0 ? rect.width / width : zoom;
41
- const scaleY = height > 0 ? rect.height / height : zoom;
42
- return {
43
- x: rect.x,
44
- y: rect.y,
45
- scaleX: scaleX || 1,
46
- scaleY: scaleY || 1,
47
- left: (parseFloat(style.borderLeftWidth) || 0) - target.scrollLeft,
48
- top: (parseFloat(style.borderTopWidth) || 0) - target.scrollTop,
49
- position: style.position,
50
- };
51
- };
52
16
  /** Images are prepared after accepted native updates, never on the overlay's critical path. */
53
17
  const createProjectionEngine = async (scope, transport, callbacks) => {
54
18
  const captureDelayMs = callbacks.captureDelayMs ?? 50;
@@ -61,6 +25,16 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
61
25
  const imageIdentity = Array.from(win.crypto.getRandomValues(new Uint32Array(4)), (value) => value.toString(16)).join('-');
62
26
  const validSource = (source) => source.isConnected && source.ownerDocument === doc;
63
27
  const validTarget = (target) => !!target?.isConnected && target.ownerDocument === doc;
28
+ // Keep current, preparing and pending-release images leased; reuse idle DOM slots.
29
+ const imagePool = new Map();
30
+ const leasedImages = new Set();
31
+ const retireImage = (image) => {
32
+ leasedImages.delete(image);
33
+ image.style.setProperty('display', 'none', 'important');
34
+ image.removeAttribute('data-native-ui-projection-image');
35
+ image.removeAttribute('src');
36
+ pruneIdleImages();
37
+ };
64
38
  const call = transport.send.bind(transport);
65
39
  const imageMarker = 'data-native-ui-snapshot';
66
40
  const sourcesChangedEvent = `${scope.namespace}:sources-change`;
@@ -70,7 +44,11 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
70
44
  const signature = (candidates) => JSON.stringify(candidates.map((candidate) => {
71
45
  if (!scopeIDs.has(candidate.scope))
72
46
  scopeIDs.set(candidate.scope, ++scopeSerial);
73
- return [scopeIDs.get(candidate.scope), layout([candidate], !!callbacks.opaqueUnderlay)];
47
+ const origin = callbacks.transient && !callbacks.opaqueUnderlay ? candidate.scope.element.getBoundingClientRect() : undefined;
48
+ const relative = origin
49
+ ? { ...candidate, control: { ...candidate.control, x: candidate.control.x - origin.x, y: candidate.control.y - origin.y } }
50
+ : candidate;
51
+ return [scopeIDs.get(candidate.scope), layout([relative], !!callbacks.opaqueUnderlay)];
74
52
  }));
75
53
  const sheets = new Map();
76
54
  const rootFor = (element) => {
@@ -78,17 +56,48 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
78
56
  return root.nodeType === 11 && 'host' in root ? root : doc;
79
57
  };
80
58
  const sheetFor = (root) => {
81
- let pair = sheets.get(root);
82
- if (!pair) {
83
- const base = doc.createElement('style'), image = doc.createElement('style');
84
- base.setAttribute('data-native-ui-snapshot-base', '');
85
- image.setAttribute(imageMarker, '');
86
- base.textContent = snapshotStyles;
87
- (root.nodeType === 11 ? root : root.head).append(base, image);
88
- pair = { base, image };
89
- sheets.set(root, pair);
90
- }
91
- return pair.image;
59
+ if (sheets.has(root))
60
+ return;
61
+ const style = doc.createElement('style');
62
+ style.setAttribute('data-native-ui-snapshot-base', '');
63
+ style.textContent = snapshotStyles + (root.nodeType === 11 ? snapshotShadowStyles : '');
64
+ (root.nodeType === 11 ? root : root.head).append(style);
65
+ sheets.set(root, style);
66
+ };
67
+ // Each shadow tree is a separate style scope. Keep these rules alongside the
68
+ // prepared image; only the marker changes during the handoff.
69
+ const finishVisibilityTransitions = (target) => {
70
+ // A visibility transition can keep the old content painted over the image.
71
+ // Do not stop layout, transform or opacity transitions owned by the renderer.
72
+ const animations = [
73
+ ...target.getAnimations({ subtree: true }),
74
+ ...[...(shadowHosts.get(target) ?? [])].flatMap((host) => host.shadowRoot?.getAnimations() ?? []),
75
+ ];
76
+ for (const animation of animations)
77
+ if ('transitionProperty' in animation && animation.transitionProperty === 'visibility')
78
+ animation.cancel();
79
+ };
80
+ const shadowHosts = new Map();
81
+ const syncShadowContent = (target) => {
82
+ const hosts = shadowHosts.get(target) ?? new Set();
83
+ shadowHosts.set(target, hosts);
84
+ const hidden = target.hasAttribute('data-native-ui-snapshot-source') || target.hasAttribute('data-native-ui-snapshot-prepared');
85
+ const visit = (element) => {
86
+ if (element.hasAttribute('data-native-ui-projection-node'))
87
+ return;
88
+ if (element.shadowRoot) {
89
+ hosts.add(element);
90
+ sheetFor(element.shadowRoot);
91
+ for (const child of element.shadowRoot.children)
92
+ visit(child);
93
+ }
94
+ for (const child of element.children)
95
+ visit(child);
96
+ };
97
+ visit(target);
98
+ for (const host of hosts)
99
+ host.toggleAttribute('data-native-ui-snapshot-content', hidden);
100
+ finishVisibilityTransitions(target);
92
101
  };
93
102
  const viewport = () => `${win.innerWidth}:${win.innerHeight}:${win.devicePixelRatio}`;
94
103
  let handoffPending = false;
@@ -109,12 +118,46 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
109
118
  let stopped = false;
110
119
  let flight = false;
111
120
  let latest;
121
+ const pruneIdleImages = () => {
122
+ const targets = new Set(latest?.candidates.map((candidate) => candidate.scope.element));
123
+ for (const [target, pool] of imagePool) {
124
+ const reusable = !stopped && target.isConnected && targets.has(target);
125
+ if (reusable && (active || ending || handoffPending))
126
+ continue;
127
+ const limit = reusable ? 2 * Math.max(1, cached?.scopes.filter((scope) => scope.element === target).length ?? 0) : 0;
128
+ let spare = Math.max(0, limit - pool.filter((image) => leasedImages.has(image)).length);
129
+ const retained = pool.filter((image) => {
130
+ if (leasedImages.has(image) || spare-- > 0)
131
+ return true;
132
+ image.remove();
133
+ return false;
134
+ });
135
+ if (retained.length)
136
+ imagePool.set(target, retained);
137
+ else
138
+ imagePool.delete(target);
139
+ }
140
+ const liveRoots = new Set([
141
+ ...(latest?.candidates.map((candidate) => candidate.scope.element.getRootNode()) ?? []),
142
+ ...(active?.scopes.map((scope) => scope.element.getRootNode()) ?? []),
143
+ ...(cached?.scopes.map((scope) => scope.element.getRootNode()) ?? []),
144
+ ...[...leasedImages].map((image) => image.getRootNode()),
145
+ ...[...shadowHosts.values()].flatMap((hosts) => [...hosts].map((host) => host.shadowRoot)),
146
+ ]);
147
+ for (const [root, pair] of sheets)
148
+ if (root.nodeType === 11 && !liveRoots.has(root)) {
149
+ pair.remove();
150
+ sheets.delete(root);
151
+ }
152
+ };
112
153
  const pendingCleanup = new Set();
113
154
  const cleanup = (action, token, afterPaint) => {
114
155
  const run = () => {
115
156
  if (!pendingCleanup.delete(run))
116
157
  return;
117
158
  void call({ action, token }).catch(() => { });
159
+ if (action === 'restored')
160
+ pruneIdleImages();
118
161
  };
119
162
  pendingCleanup.add(run);
120
163
  if (afterPaint && !stopped)
@@ -131,7 +174,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
131
174
  if (!item || item === cached || item === active)
132
175
  return;
133
176
  item.images.forEach((image) => {
134
- image.src = '';
177
+ retireImage(image);
135
178
  });
136
179
  cleanup('release', item.data.token, afterPaint);
137
180
  };
@@ -207,7 +250,19 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
207
250
  if (result.parts.some((part) => new Set(part.ids.map((id) => selected.find((candidate) => candidate.control.id === id).scope)).size !== 1))
208
251
  return;
209
252
  await bounded(Promise.all(result.parts.map(async (part) => {
210
- const image = doc.createElement('img');
253
+ const target = selected.find((candidate) => part.ids.includes(candidate.control.id)).scope.element;
254
+ let pool = imagePool.get(target);
255
+ if (!pool) {
256
+ pool = [];
257
+ imagePool.set(target, pool);
258
+ }
259
+ let image = pool.find((item) => !leasedImages.has(item));
260
+ if (!image) {
261
+ image = doc.createElement('img');
262
+ image.setAttribute('data-native-ui-projection-node', '');
263
+ pool.push(image);
264
+ }
265
+ leasedImages.add(image);
211
266
  images.push(image);
212
267
  image.src = projectionURL(part.url);
213
268
  await image.decode();
@@ -224,7 +279,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
224
279
  selected.some((candidate) => !validTarget(candidate.scope.element) || ![candidate.element, ...(candidate.sources ?? [])].every(validSource)))
225
280
  return;
226
281
  const previous = cached;
227
- cached = {
282
+ const prepared = {
228
283
  data: result,
229
284
  images,
230
285
  sources: Array.from(new Set(selected.flatMap((item) => [item.element, ...(item.sources ?? [])]))),
@@ -236,12 +291,32 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
236
291
  revision,
237
292
  layout: signature(selected),
238
293
  };
294
+ try {
295
+ if (!render(prepared))
296
+ throw new Error('Projection destination is unavailable');
297
+ }
298
+ catch (error) {
299
+ // Do not publish a token whose DOM could not be installed. Restore the
300
+ // last completed presentation before retiring the failed preparation.
301
+ detach();
302
+ try {
303
+ render();
304
+ cached?.sources.forEach((source) => {
305
+ preparedOwners.add(source);
306
+ preparedSources.add(source);
307
+ });
308
+ }
309
+ catch {
310
+ abandon();
311
+ }
312
+ throw error;
313
+ }
314
+ cached = prepared;
239
315
  clearPrepared();
240
316
  cached.sources.forEach((source) => {
241
317
  preparedOwners.add(source);
242
318
  preparedSources.add(source);
243
319
  });
244
- render();
245
320
  if (callbacks.opaqueUnderlay)
246
321
  void call({ action: 'prepared', token: result.token }).catch(() => { });
247
322
  win.dispatchEvent(new Event(sourcesChangedEvent));
@@ -255,7 +330,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
255
330
  finally {
256
331
  if (!adopted)
257
332
  images.forEach((image) => {
258
- image.src = '';
333
+ retireImage(image);
259
334
  });
260
335
  if (result?.token && !adopted)
261
336
  void call({ action: 'release', token: result.token }).catch(() => { });
@@ -291,6 +366,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
291
366
  if (latest?.revision !== revision)
292
367
  generation++;
293
368
  latest = { revision, candidates };
369
+ pruneIdleImages();
294
370
  if (cached?.revision !== revision || preparedVisualRevision !== visualRevision)
295
371
  schedule();
296
372
  };
@@ -304,7 +380,6 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
304
380
  invalidate();
305
381
  if (active) {
306
382
  updateOffsets();
307
- followBorderAnimation();
308
383
  }
309
384
  visualRevision++;
310
385
  generation++;
@@ -321,17 +396,21 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
321
396
  }));
322
397
  const preparedTargets = new Set();
323
398
  const clearTarget = (target) => {
399
+ shadowHosts.get(target)?.forEach((host) => host.removeAttribute('data-native-ui-snapshot-content'));
324
400
  if (preparedTargets.delete(target))
325
401
  target.removeAttribute('data-native-ui-snapshot-prepared');
326
402
  target.removeAttribute('data-native-ui-snapshot-visible');
327
403
  target.removeAttribute(imageMarker);
404
+ target.removeAttribute('data-native-ui-snapshot-positioned');
328
405
  target.removeAttribute('data-native-ui-snapshot-source');
406
+ finishVisibilityTransitions(target);
407
+ shadowHosts.delete(target);
329
408
  };
330
409
  const detach = () => {
331
410
  presentation++;
332
411
  win.cancelAnimationFrame(paintFrame);
333
- win.cancelAnimationFrame(offsetFrame);
334
- offsetFrame = 0;
412
+ for (const element of offsets.keys())
413
+ element.style.setProperty('opacity', '0', 'important');
335
414
  offsets.clear();
336
415
  paintFrame = 0;
337
416
  handoffPending = false;
@@ -343,11 +422,12 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
343
422
  });
344
423
  rendered.forEach(clearTarget);
345
424
  rendered.clear();
346
- sheets.forEach(({ base, image }) => {
347
- base.remove();
348
- image.remove();
425
+ sheets.forEach((style, root) => {
426
+ if (stopped || (root.nodeType === 11 && !root.host.isConnected)) {
427
+ style.remove();
428
+ sheets.delete(root);
429
+ }
349
430
  });
350
- sheets.clear();
351
431
  };
352
432
  const abandon = (expected = active, afterPaint = ending) => {
353
433
  if (active !== expected)
@@ -371,7 +451,16 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
371
451
  return;
372
452
  }
373
453
  const image = active;
374
- detach();
454
+ presentation++;
455
+ win.cancelAnimationFrame(paintFrame);
456
+ paintFrame = 0;
457
+ handoffPending = presented = false;
458
+ image.sources.forEach((source) => {
459
+ owners.delete(source);
460
+ visibleOwners.delete(source);
461
+ });
462
+ rendered.forEach((target) => target.removeAttribute('data-native-ui-snapshot-source'));
463
+ showImages(image, !!callbacks.opaqueUnderlay);
375
464
  active = undefined;
376
465
  ending = false;
377
466
  win.clearTimeout(restoreTimeout);
@@ -385,7 +474,6 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
385
474
  preparedOwners.add(source);
386
475
  preparedSources.add(source);
387
476
  });
388
- render();
389
477
  callbacks.wake();
390
478
  };
391
479
  const retireRestoration = () => {
@@ -395,51 +483,60 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
395
483
  };
396
484
  const targetsFor = (image) => image.scopes.map((scope) => (validTarget(scope.element) ? scope.element : undefined));
397
485
  const rendered = new Set();
486
+ const showImages = (image, visible) => {
487
+ for (const target of rendered) {
488
+ target.toggleAttribute('data-native-ui-snapshot-visible', visible);
489
+ syncShadowContent(target);
490
+ }
491
+ image.images.forEach((element) => element.style.setProperty('opacity', visible ? '1' : '0', 'important'));
492
+ };
398
493
  const preparedKeys = new Map();
399
494
  const offsets = new Map();
400
- let offsetFrame = 0;
401
495
  const updateOffsets = () => {
402
- for (const [target, offset] of offsets) {
403
- if (!target.isConnected)
496
+ const writes = [];
497
+ // Read all geometry before writing positions; never reset a painted image to zero to measure it.
498
+ for (const [element, placement] of offsets) {
499
+ const { target, frame } = placement;
500
+ if (!target.isConnected) {
501
+ element.remove();
404
502
  continue;
405
- const style = win.getComputedStyle(target);
406
- const left = `${offset.x - (parseFloat(style.borderLeftWidth) || 0) + target.scrollLeft}px`;
407
- const top = `${offset.y - (parseFloat(style.borderTopWidth) || 0) + target.scrollTop}px`;
408
- if (offset.style.left !== left)
409
- offset.style.setProperty('left', left, 'important');
410
- if (offset.style.top !== top)
411
- offset.style.setProperty('top', top, 'important');
412
- }
413
- };
414
- const followBorderAnimation = () => {
415
- if (offsetFrame || !active)
416
- return;
417
- const movingBorder = [...offsets.keys()].some((target) => target
418
- .getAnimations?.()
419
- .some((animation) => animation.playState === 'running' &&
420
- animation.effect
421
- ?.getKeyframes()
422
- .some((frame) => ['borderLeftWidth', 'borderTopWidth', 'borderWidth'].some((property) => property in frame))));
423
- if (movingBorder)
424
- offsetFrame = win.requestAnimationFrame(() => {
425
- offsetFrame = 0;
426
- updateOffsets();
427
- followBorderAnimation();
503
+ }
504
+ const parent = target.shadowRoot ?? target;
505
+ if (element.parentNode !== parent)
506
+ parent.insertBefore(element, null);
507
+ const box = target.getBoundingClientRect();
508
+ const rect = element.getBoundingClientRect();
509
+ const width = parseFloat(element.style.width) || frame.width;
510
+ const height = parseFloat(element.style.height) || frame.height;
511
+ const sx = rect.width / width || 1, sy = rect.height / height || 1;
512
+ const initial = placement.scale ?? (placement.scale = { x: sx, y: sy });
513
+ writes.push({
514
+ element,
515
+ left: (parseFloat(element.style.left) || 0) + (box.x + (frame.x * sx) / initial.x - rect.x) / sx,
516
+ top: (parseFloat(element.style.top) || 0) + (box.y + (frame.y * sy) / initial.y - rect.y) / sy,
517
+ width: frame.width / initial.x,
518
+ height: frame.height / initial.y,
428
519
  });
520
+ }
521
+ for (const { element, ...values } of writes)
522
+ for (const [property, value] of Object.entries(values)) {
523
+ const next = `${value}px`;
524
+ if (element.style.getPropertyValue(property) !== next)
525
+ element.style.setProperty(property, next, 'important');
526
+ }
429
527
  };
430
- const render = () => {
431
- const image = active ?? cached;
528
+ const render = (image = active ?? cached) => {
432
529
  if (!image || ending)
433
- return;
530
+ return false;
434
531
  if (!image.sources.every(validSource) || targetsFor(image).some((target) => !target)) {
435
532
  invalidate();
436
- return;
533
+ return false;
437
534
  }
438
535
  const targets = targetsFor(image);
439
536
  if (active) {
440
537
  if (targets.some((target) => !target?.isConnected || !sheets.has(rootFor(target)))) {
441
538
  abandon(active, false);
442
- return;
539
+ return false;
443
540
  }
444
541
  const next = new Set();
445
542
  for (const [index, source] of image.targets.entries()) {
@@ -449,7 +546,6 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
449
546
  continue;
450
547
  target.setAttribute(imageMarker, key);
451
548
  target.setAttribute('data-native-ui-snapshot-source', '');
452
- target.toggleAttribute('data-native-ui-snapshot-visible', presented);
453
549
  next.add(target);
454
550
  }
455
551
  for (const target of rendered)
@@ -457,84 +553,70 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
457
553
  clearTarget(target);
458
554
  rendered.clear();
459
555
  next.forEach((target) => rendered.add(target));
460
- return;
556
+ showImages(image, presented);
557
+ return true;
461
558
  }
559
+ imagePool.forEach((pool) => pool.forEach((element) => {
560
+ if (!image.images.includes(element)) {
561
+ element.style.setProperty('display', 'none', 'important');
562
+ element.removeAttribute('data-native-ui-projection-image');
563
+ }
564
+ }));
462
565
  preparedKeys.clear();
463
566
  offsets.clear();
464
567
  rendered.forEach(clearTarget);
465
568
  rendered.clear();
466
- // Reuse the image stylesheet. Presentation states belong to the runtime's
467
- // permanent CSS, which survives cache replacement and image retirement.
468
- const groups = new Map();
469
569
  for (const target of new Set(targets)) {
470
570
  if (!target)
471
571
  continue;
472
572
  rendered.add(target);
473
- const sheet = sheetFor(rootFor(target));
474
- let group = groups.get(sheet);
475
- if (!group) {
476
- group = { rules: [], positions: [] };
477
- groups.set(sheet, group);
478
- }
479
- const { rules, positions } = group;
480
- // A decoded clone stays concealed even if this cache is invalidated before
481
- // the projection adapter publishes its next Native/Web ownership state.
482
- if (target !== image.targets[targets.indexOf(target)] && !target.hasAttribute('data-native-ui-snapshot-prepared')) {
573
+ sheetFor(rootFor(target));
574
+ const indices = targets.flatMap((owner, index) => (owner === target ? [index] : []));
575
+ const key = `${imageIdentity}-${indices[0]}`;
576
+ target.setAttribute(imageMarker, key);
577
+ if (target !== image.targets[indices[0]]) {
483
578
  target.setAttribute('data-native-ui-snapshot-prepared', '');
484
579
  preparedTargets.add(target);
485
580
  }
486
- target.toggleAttribute('data-native-ui-snapshot-visible', !!active || !!callbacks.opaqueUnderlay);
487
- if (active)
488
- target.setAttribute('data-native-ui-snapshot-source', '');
489
- const indices = targets.flatMap((owner, index) => (owner === target ? [index] : []));
490
- const style = win.getComputedStyle(target);
491
- {
492
- const space = coordinateSpace(target, win, style);
493
- indices.forEach((index) => {
494
- const part = image.data.parts[index];
495
- if (callbacks.opaqueUnderlay && image.frames[index])
496
- return;
497
- image.frames[index] = {
498
- x: callbacks.opaqueUnderlay ? 0 : (part.x - space.x) / space.scaleX,
499
- y: callbacks.opaqueUnderlay ? 0 : (part.y - space.y) / space.scaleY,
500
- width: part.width / space.scaleX,
501
- height: part.height / space.scaleY,
502
- };
503
- });
581
+ target.toggleAttribute('data-native-ui-snapshot-visible', !!callbacks.opaqueUnderlay);
582
+ syncShadowContent(target);
583
+ const box = target.getBoundingClientRect();
584
+ target.toggleAttribute('data-native-ui-snapshot-positioned', win.getComputedStyle(target).position === 'static');
585
+ const parent = target.shadowRoot ?? target;
586
+ for (const index of [...indices].reverse()) {
587
+ const element = image.images[index];
588
+ const part = image.data.parts[index];
589
+ const frame = image.frames[index] ?? {
590
+ x: callbacks.opaqueUnderlay ? 0 : part.x - box.x,
591
+ y: callbacks.opaqueUnderlay ? 0 : part.y - box.y,
592
+ width: part.width,
593
+ height: part.height,
594
+ };
595
+ image.frames[index] = frame;
596
+ preparedKeys.set(image.targets[index], key);
597
+ element.inert = true;
598
+ element.setAttribute('aria-hidden', 'true');
599
+ element.setAttribute('data-native-ui-projection-image', key);
600
+ element.style.cssText =
601
+ 'all:initial!important;position:absolute!important;display:block!important;visibility:visible!important;pointer-events:none!important;image-rendering:pixelated!important;will-change:opacity!important;opacity:0!important;';
602
+ element.style.setProperty('width', `${frame.width}px`, 'important');
603
+ element.style.setProperty('height', `${frame.height}px`, 'important');
604
+ element.style.setProperty('left', '0px', 'important');
605
+ element.style.setProperty('top', '0px', 'important');
606
+ element.style.setProperty('opacity', callbacks.opaqueUnderlay ? '1' : '0', 'important');
607
+ if (element.parentNode !== parent)
608
+ parent.insertBefore(element, null);
609
+ offsets.set(element, { target, frame });
504
610
  }
505
- const frames = indices.map((index) => image.frames[index]);
506
- const x = Math.min(...frames.map((frame) => frame.x));
507
- const y = Math.min(...frames.map((frame) => frame.y));
508
- const right = Math.max(...frames.map((frame) => frame.x + frame.width));
509
- const bottom = Math.max(...frames.map((frame) => frame.y + frame.height));
510
- const left = (parseFloat(style.borderLeftWidth) || 0) - target.scrollLeft;
511
- const top = (parseFloat(style.borderTopWidth) || 0) - target.scrollTop;
512
- const key = `${imageIdentity}-${indices[0]}`;
513
- indices.forEach((index) => preparedKeys.set(image.targets[index], key));
514
- target.setAttribute(imageMarker, key);
515
- rules.push(`[${imageMarker}="${key}"][data-native-ui-snapshot-visible] { ${style.position === 'static' ? 'position:relative!important;' : ''} }`);
516
- const backgrounds = indices.map((index) => {
517
- const frame = image.frames[index];
518
- return `url(${JSON.stringify(image.images[index].src)}) ${frame.x - x}px ${frame.y - y}px / ${frame.width}px ${frame.height}px no-repeat`;
519
- });
520
- const declaration = doc.createElement('span').style;
521
- declaration.cssText = `left:${x - left}px;top:${y - top}px;width:${right - x}px;height:${bottom - y}px;background:${backgrounds.join(',')};`;
522
- for (const property of Array.from(declaration))
523
- declaration.setProperty(property, declaration.getPropertyValue(property), 'important');
524
- // Outrank every manager's shared reset, even when another manager starts later.
525
- rules.push(`[${imageMarker}="${key}"][${imageMarker}][${imageMarker}]::before { ${declaration.cssText} }`);
526
- positions.push({ target, x, y });
527
611
  }
528
612
  image.parents = image.scopes.map((scope) => scope.element.parentNode);
529
- for (const [sheet, { rules, positions }] of groups) {
530
- sheet.textContent = rules.join('\n');
531
- positions.forEach(({ target, x, y }, index) => offsets.set(target, {
532
- x,
533
- y,
534
- style: sheet.sheet.cssRules[index * 2 + 1].style,
535
- }));
536
- }
613
+ updateOffsets();
614
+ return true;
537
615
  };
616
+ const currentParts = (image) => image.images.map((element, index) => {
617
+ const rect = element.getBoundingClientRect();
618
+ return { ...image.data.parts[index], x: rect.x, y: rect.y, width: rect.width, height: rect.height };
619
+ });
538
620
  const begin = () => {
539
621
  retireRestoration();
540
622
  if (stopped || destination === 'none' || active)
@@ -565,10 +647,8 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
565
647
  image.sources.forEach((source) => {
566
648
  owners.add(source);
567
649
  });
568
- rendered.forEach((target) => {
569
- target.setAttribute('data-native-ui-snapshot-source', '');
570
- target.toggleAttribute('data-native-ui-snapshot-visible', presented);
571
- });
650
+ rendered.forEach((target) => target.setAttribute('data-native-ui-snapshot-source', ''));
651
+ showImages(image, presented);
572
652
  // Reconciliation may continue after one frame even if the optional bridge reply
573
653
  // is delayed. The ordered native transport processes cover before that update.
574
654
  paintFrame = win.requestAnimationFrame(() => {
@@ -578,7 +658,11 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
578
658
  handoffPending = false;
579
659
  callbacks.wake();
580
660
  });
581
- void bounded(call({ action: 'cover', token: image.data.token, ...(callbacks.transient ? { transient: true } : {}) }))
661
+ void bounded(call({
662
+ action: 'cover',
663
+ token: image.data.token,
664
+ ...(callbacks.transient ? { transient: true, viewportWidth: win.innerWidth, parts: currentParts(image) } : {}),
665
+ }))
582
666
  .then((result) => {
583
667
  if (!isCurrent())
584
668
  return;
@@ -594,7 +678,6 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
594
678
  image.sources.forEach((source) => visibleOwners.add(source));
595
679
  render();
596
680
  updateOffsets();
597
- followBorderAnimation();
598
681
  }
599
682
  win.cancelAnimationFrame(paintFrame);
600
683
  paintFrame = win.requestAnimationFrame(() => {
@@ -625,6 +708,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
625
708
  }
626
709
  if (callbacks.blocked())
627
710
  return;
711
+ updateOffsets();
628
712
  if (callbacks.transient) {
629
713
  const image = active;
630
714
  const targets = targetsFor(image);
@@ -632,23 +716,14 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
632
716
  abandon(image, false);
633
717
  return;
634
718
  }
635
- const parts = targets.map((target, index) => {
636
- const space = coordinateSpace(target, win);
637
- const frame = image.frames[index];
638
- return {
639
- ...image.data.parts[index],
640
- x: space.x + frame.x * space.scaleX,
641
- y: space.y + frame.y * space.scaleY,
642
- width: frame.width * space.scaleX,
643
- height: frame.height * space.scaleY,
644
- };
645
- });
646
- if (parts.some((part, index) => ['x', 'y', 'width', 'height'].some((key) => part[key] !== image.data.parts[index][key]))) {
647
- if (callbacks.opaqueUnderlay)
648
- image.data = { ...image.data, parts };
649
- else
650
- cached = undefined;
651
- }
719
+ const parts = currentParts(image);
720
+ // Translation changes placement, not pixels. Keep the pre-interaction
721
+ // image until its replacement is ready; resizing still invalidates it.
722
+ if (callbacks.opaqueUnderlay ||
723
+ parts.every((part, index) => part.width === image.data.parts[index].width && part.height === image.data.parts[index].height))
724
+ image.data = { ...image.data, parts };
725
+ else
726
+ cached = undefined;
652
727
  // Both requests use the same ordered bridge. Reprojection need not await this optional copy.
653
728
  void call({ action: 'restore', token: image.data.token, viewportWidth: win.innerWidth, parts }).catch(() => { });
654
729
  }
@@ -689,7 +764,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
689
764
  .catch(() => { })
690
765
  .finally(() => {
691
766
  previous.images.forEach((image) => {
692
- image.src = '';
767
+ retireImage(image);
693
768
  });
694
769
  if (retiring !== pending)
695
770
  return;
@@ -762,10 +837,7 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
762
837
  },
763
838
  consider,
764
839
  changed,
765
- moved: () => {
766
- updateOffsets();
767
- followBorderAnimation();
768
- },
840
+ moved: updateOffsets,
769
841
  get token() {
770
842
  return ending ? undefined : active?.data.token;
771
843
  },
@@ -783,6 +855,8 @@ const createProjectionEngine = async (scope, transport, callbacks) => {
783
855
  async destroy() {
784
856
  stopped = true;
785
857
  invalidate();
858
+ imagePool.forEach((pool) => pool.forEach((image) => image.remove()));
859
+ imagePool.clear();
786
860
  await Promise.allSettled([
787
861
  bounded(Promise.resolve().then(() => listener?.remove())),
788
862
  bounded(Promise.resolve().then(() => eventListener?.remove())),