@webview-native-ui/engine 0.0.3 → 0.0.5

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.
package/README.md CHANGED
@@ -44,6 +44,7 @@ The current API is pre-1.0. Use matching, pinned Engine versions across npm and
44
44
 
45
45
  ## Further reading
46
46
 
47
+ - [Projection container support](docs/projection-containers.md) — supported HTML and Ionic destinations, sizing conditions and wrapper examples.
47
48
  - [Projection behavior and advanced integration](docs/projection.md) — DOM requirements, lifecycle, fallback policies, optional Ionic support and lower-level APIs.
48
49
  - [Development and releases](docs/maintenance.md) — repository structure, validation and publishing.
49
50
 
@@ -13,6 +13,7 @@ export const getUnprojectedVisibility = (element) => {
13
13
  for (const attribute of Array.from(node.attributes)) {
14
14
  if (attribute.name === 'data-native-ui-snapshot-source' ||
15
15
  attribute.name === 'data-native-ui-snapshot-prepared' ||
16
+ attribute.name === 'data-native-ui-snapshot-content' ||
16
17
  attribute.name.startsWith('data-native-ui-session-'))
17
18
  markers.push({ node, name: attribute.name, value: attribute.value });
18
19
  }
@@ -1,5 +1,5 @@
1
1
  import { type ProjectionDestination } from '../projection/events.js';
2
- import type { ScrollPlacement, ListenerHandle, WebProjectionRequest } from '../types.js';
2
+ import type { Frame, ScrollPlacement, ListenerHandle, WebProjectionRequest } from '../types.js';
3
3
  /** Serialized by the native library. One registration owns one fixed fallback container. */
4
4
  export interface NativeSessionConfiguration {
5
5
  id: string;
@@ -12,6 +12,8 @@ export interface NativeSessionConfiguration {
12
12
  captureMode?: 'composited' | 'view';
13
13
  /** Native registration accepts a final placement in its reveal request. */
14
14
  placementHandoff?: boolean;
15
+ /** Android can cover prepared pixels before a Web scroll event arrives. */
16
+ nativeDragHandoff?: boolean;
15
17
  receiveListeners?: {
16
18
  update?: string[];
17
19
  projection?: Partial<Record<ProjectionDestination, string[]>>;
@@ -29,6 +31,13 @@ export interface NativeSessionBridge {
29
31
  action: 'track';
30
32
  viewportWidth: number;
31
33
  scroll?: ScrollPlacement;
34
+ gesture?: {
35
+ viewport: Frame;
36
+ offsetX: number;
37
+ offsetY: number;
38
+ rangeX: number;
39
+ rangeY: number;
40
+ };
32
41
  } | {
33
42
  action: 'connect';
34
43
  } | {
@@ -37,6 +46,7 @@ export interface NativeSessionBridge {
37
46
  }): Promise<any>;
38
47
  listen(listener: (event: {
39
48
  active: boolean;
49
+ precovered?: string;
40
50
  }) => void): Promise<ListenerHandle>;
41
51
  }
42
52
  /** Native startup calls this; application code does not initialize or wrap the plugin. */
@@ -21,6 +21,7 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
21
21
  const overlays = getOverlayCoordinator(document);
22
22
  const effectiveDestination = () => requestedDestination === 'native' && target && overlays.isCovered(target) ? 'web' : requestedDestination;
23
23
  let automaticMotion = false;
24
+ let nativeDragging = false;
24
25
  let placementPending = false;
25
26
  let nativeScroll = false;
26
27
  let scrollOwner;
@@ -51,17 +52,26 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
51
52
  }
52
53
  };
53
54
  const bridge = {
54
- listen: (listener) => nativeBridge.listen(listener),
55
+ listen: (listener) => nativeBridge.listen((event) => {
56
+ listener(event);
57
+ if (!event.active && nativeDragging) {
58
+ nativeDragging = false;
59
+ endAutomaticMotionLater();
60
+ }
61
+ if (event.precovered)
62
+ adoptNativeCover(event.precovered);
63
+ }),
55
64
  send: (request) => {
56
65
  if (!config.placementHandoff)
57
66
  return nativeBridge.send(request);
58
67
  const binding = projectionPlacement(target);
59
68
  if (request.action === 'cover') {
69
+ const alreadyCovered = coveredToken === request.token && binding?.owner === placementOwner && binding.projected;
60
70
  const epoch = ++placementEpoch;
61
71
  coveredToken = request.token;
62
72
  if (binding) {
63
73
  binding.owner = placementOwner;
64
- binding.projected = false;
74
+ binding.projected = !!alreadyCovered;
65
75
  }
66
76
  return nativeBridge
67
77
  .send(request)
@@ -97,7 +107,18 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
97
107
  : request;
98
108
  return nativeBridge
99
109
  .send(payload)
100
- .then((result) => {
110
+ .then(async (result) => {
111
+ // A native drag can race retirement before its notification reaches JS.
112
+ // Keep the prepared underlay until native has applied a fresh return placement.
113
+ if (request.action === 'release' && result?.requiresPlacement) {
114
+ if (binding && (!binding.owner || binding.owner === placementOwner)) {
115
+ result = await nativeBridge.send({ ...request, placement: binding.measure() });
116
+ }
117
+ else {
118
+ await nativeBridge.send({ action: 'visibility', visible: false });
119
+ result = await nativeBridge.send(request);
120
+ }
121
+ }
101
122
  if (epoch === placementEpoch &&
102
123
  (request.action === 'resume' || releasing || request.action === 'reset' || request.action === 'visibility'))
103
124
  clearPlacement();
@@ -247,6 +268,7 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
247
268
  const created = await createWebProjection(createWebProjectionScope(target, config.id), bridge, {
248
269
  wake,
249
270
  captureDelayMs: config.captureDelayMs,
271
+ retainPreparedUntilRelease: config.nativeDragHandoff,
250
272
  blocked: () => destination !== 'native' || automaticMotion || (placementPending && !manager?.token),
251
273
  revision: () => revision,
252
274
  captureRevision: config.captureMode === 'view' ? () => contentRevision : undefined,
@@ -282,7 +304,23 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
282
304
  (element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth));
283
305
  });
284
306
  const scroll = scrollOwner && readScrollPlacement(target, scrollOwner, () => config.id);
285
- const tracking = await bridge.send({ action: 'track', viewportWidth: innerWidth, ...(scroll ? { scroll } : {}) });
307
+ // Gesture gating needs a hit region, not native compositor scroll eligibility.
308
+ // Rounded/clipped Ionic hosts can reject readScrollPlacement while still scrolling in Web.
309
+ const gesture = config.nativeDragHandoff && config.captureMode === 'view' && scrollOwner
310
+ ? {
311
+ viewport: scrollOwner.getBoundingClientRect(),
312
+ offsetX: scrollOwner.scrollLeft,
313
+ offsetY: scrollOwner.scrollTop,
314
+ rangeX: Math.max(0, scrollOwner.scrollWidth - scrollOwner.clientWidth),
315
+ rangeY: Math.max(0, scrollOwner.scrollHeight - scrollOwner.clientHeight),
316
+ }
317
+ : undefined;
318
+ const tracking = await bridge.send({
319
+ action: 'track',
320
+ viewportWidth: innerWidth,
321
+ ...(scroll ? { scroll } : {}),
322
+ ...(gesture ? { gesture } : {}),
323
+ });
286
324
  if (stopped || version !== generation)
287
325
  return;
288
326
  nativeScroll = tracking?.tracked === true;
@@ -356,8 +394,13 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
356
394
  manager?.moved();
357
395
  else
358
396
  manager?.changed();
397
+ endAutomaticMotionLater();
398
+ };
399
+ function endAutomaticMotionLater() {
359
400
  clearTimeout(scrollTimer);
360
401
  scrollTimer = setTimeout(() => {
402
+ if (nativeDragging)
403
+ return;
361
404
  automaticMotion = false;
362
405
  if (stopped || destination !== 'native')
363
406
  return;
@@ -365,7 +408,31 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
365
408
  if (!manager?.restoring)
366
409
  schedule();
367
410
  }, 100);
368
- };
411
+ }
412
+ function adoptNativeCover(token) {
413
+ if (stopped || destination !== 'native' || !target?.isConnected || !manager)
414
+ return;
415
+ nativeDragging = true;
416
+ // A scroll handler may have adopted the same native cover before its notification arrives.
417
+ if (manager.token)
418
+ return;
419
+ if (restoring || manager.restoring)
420
+ generation++;
421
+ coveredToken = token;
422
+ const binding = projectionPlacement(target);
423
+ if (binding) {
424
+ binding.owner = placementOwner;
425
+ binding.projected = true;
426
+ }
427
+ automaticMotion = true;
428
+ beginProjection();
429
+ if (!manager.token) {
430
+ automaticMotion = false;
431
+ void recoverFailedProjection();
432
+ return;
433
+ }
434
+ endAutomaticMotionLater();
435
+ }
369
436
  // Listen on composed-tree ancestors themselves: non-composed scroll events inside
370
437
  // Ionic's shadow tree never reach document, and Node.contains excludes slotted children.
371
438
  const refreshScrollListeners = () => {
@@ -408,6 +475,7 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
408
475
  if (stopped || (!force && next === destination))
409
476
  return;
410
477
  automaticMotion = false;
478
+ nativeDragging = false;
411
479
  clearTimeout(scrollTimer);
412
480
  destination = next;
413
481
  generation++;
@@ -419,13 +487,13 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
419
487
  .then(() => {
420
488
  if (destination !== 'none' || version !== generation || stopped)
421
489
  return;
422
- manager?.invalidate();
490
+ manager?.invalidate(true);
423
491
  setVisibility();
424
492
  })
425
493
  .catch(() => {
426
494
  if (destination !== 'none' || version !== generation || stopped)
427
495
  return;
428
- manager?.invalidate();
496
+ manager?.invalidate(true);
429
497
  setVisibility();
430
498
  });
431
499
  }
@@ -507,8 +575,9 @@ export const startNativeSession = async (config, nativeBridge, plugin = {
507
575
  requestedDestination = destination = 'none';
508
576
  generation++;
509
577
  automaticMotion = false;
578
+ nativeDragging = false;
510
579
  clearTimeout(scrollTimer);
511
- manager?.invalidate();
580
+ manager?.invalidate(true);
512
581
  setVisibility();
513
582
  },
514
583
  };
@@ -3,6 +3,6 @@ export const createWebProjectionScope = (element, namespace) => {
3
3
  if (element.nodeType !== 1 || element.namespaceURI !== 'http://www.w3.org/1999/xhtml' || !element.ownerDocument || !('style' in element))
4
4
  throw new TypeError('A Web projection scope requires an HTMLElement fallback');
5
5
  if (element.matches('img, input, textarea, select, video, audio, canvas, iframe, embed, object, br, hr, area, base, link, meta, source, track, wbr'))
6
- throw new TypeError('A Web projection fallback must support ::before; use its existing container element');
6
+ throw new TypeError('A Web projection fallback must be a container element; use the existing fallback container');
7
7
  return Object.freeze({ element, namespace });
8
8
  };
@@ -1,2 +1,2 @@
1
- /** Image presentation only; Native/Web ownership styling stays with the renderer. */
2
- export declare const snapshotStyles = "\n:is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared]),\n:is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared]) *,\n:is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared])::after,\n:is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared])::part(native) { visibility: hidden !important; }\n[data-native-ui-snapshot][data-native-ui-snapshot]::before {\n all:initial!important;content:\"\"!important;display:block!important;position:absolute!important;\n visibility:visible!important;opacity:0!important;will-change:opacity!important;\n pointer-events:none!important;image-rendering:pixelated!important;\n}\n[data-native-ui-snapshot-visible] { opacity:1!important;overflow:visible!important; }\n[data-native-ui-snapshot-visible][data-native-ui-snapshot][data-native-ui-snapshot]::before { opacity:1!important; }\n";
1
+ export declare const snapshotStyles = "\n@layer native-ui-projection {\n:is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared], [data-native-ui-snapshot-content]), :is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared], [data-native-ui-snapshot-content]) *, :is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared], [data-native-ui-snapshot-content])::part(native) { visibility: hidden !important; }\n:is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared], [data-native-ui-snapshot-content])::before, :is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared], [data-native-ui-snapshot-content])::after,\n:is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared], [data-native-ui-snapshot-content]) *::before, :is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared], [data-native-ui-snapshot-content]) *::after {\n visibility: hidden !important;\n}\n}\n[data-native-ui-snapshot-visible] { opacity:1!important;overflow:visible!important; }\n[data-native-ui-snapshot-visible][data-native-ui-snapshot-positioned] { position:relative!important; }\n";
2
+ export declare const snapshotShadowStyles = "\n@layer native-ui-projection {\n:host([data-native-ui-snapshot-content]),\n:host([data-native-ui-snapshot-content])::before,\n:host([data-native-ui-snapshot-content])::after,\n:host([data-native-ui-snapshot-content]) *,\n:host([data-native-ui-snapshot-content]) *::before,\n:host([data-native-ui-snapshot-content]) *::after {\n visibility: hidden !important;\n}\n}\n";
@@ -1,14 +1,25 @@
1
1
  /** Image presentation only; Native/Web ownership styling stays with the renderer. */
2
+ const hidden = ':is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared], [data-native-ui-snapshot-content])';
2
3
  export const snapshotStyles = `
3
- :is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared]),
4
- :is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared]) *,
5
- :is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared])::after,
6
- :is([data-native-ui-snapshot-source], [data-native-ui-snapshot-prepared])::part(native) { visibility: hidden !important; }
7
- [data-native-ui-snapshot][data-native-ui-snapshot]::before {
8
- all:initial!important;content:""!important;display:block!important;position:absolute!important;
9
- visibility:visible!important;opacity:0!important;will-change:opacity!important;
10
- pointer-events:none!important;image-rendering:pixelated!important;
4
+ @layer native-ui-projection {
5
+ ${hidden}, ${hidden} *, ${hidden}::part(native) { visibility: hidden !important; }
6
+ ${hidden}::before, ${hidden}::after,
7
+ ${hidden} *::before, ${hidden} *::after {
8
+ visibility: hidden !important;
9
+ }
11
10
  }
12
11
  [data-native-ui-snapshot-visible] { opacity:1!important;overflow:visible!important; }
13
- [data-native-ui-snapshot-visible][data-native-ui-snapshot][data-native-ui-snapshot]::before { opacity:1!important; }
12
+ [data-native-ui-snapshot-visible][data-native-ui-snapshot-positioned] { position:relative!important; }
13
+ `;
14
+ export const snapshotShadowStyles = `
15
+ @layer native-ui-projection {
16
+ :host([data-native-ui-snapshot-content]),
17
+ :host([data-native-ui-snapshot-content])::before,
18
+ :host([data-native-ui-snapshot-content])::after,
19
+ :host([data-native-ui-snapshot-content]) *,
20
+ :host([data-native-ui-snapshot-content]) *::before,
21
+ :host([data-native-ui-snapshot-content]) *::after {
22
+ visibility: hidden !important;
23
+ }
24
+ }
14
25
  `;
@@ -6,6 +6,8 @@ export interface WebProjectionOptions {
6
6
  events?: ProjectionEvents;
7
7
  /** Debounce before preparing replacement pixels, in milliseconds. Defaults to 50; never delays handoff. */
8
8
  captureDelayMs?: number;
9
+ /** Preserve an idle underlay while native disarms a possible early drag cover. */
10
+ retainPreparedUntilRelease?: boolean;
9
11
  wake(): void;
10
12
  blocked(): boolean;
11
13
  revision?(): number;
@@ -35,7 +37,7 @@ declare const createProjectionEngine: (scope: WebProjectionScope, transport: Web
35
37
  retireRestoration: () => void;
36
38
  end: () => void;
37
39
  updated: (token: string | undefined, restoredIDs?: string[], revision?: number) => void;
38
- invalidate: () => void;
40
+ invalidate: (nativeHidden?: boolean) => void;
39
41
  destroy(): Promise<void>;
40
42
  }>;
41
43
  type Engine = Awaited<ReturnType<typeof createProjectionEngine>>;