@swiftbrowser/web 0.26.6 → 0.26.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swiftbrowser/web",
3
- "version": "0.26.6",
3
+ "version": "0.26.7",
4
4
  "type": "module",
5
5
  "description": "SwiftBrowser web renderer and dev server: applies render ops from a Swift/Wasm app to the DOM inside an iPhone frame.",
6
6
  "repository": {
@@ -1,43 +1,70 @@
1
1
  /**
2
2
  * What a page with a mouse adds to a phone's UI: `hover` events (`.onHover`),
3
- * tooltips (`.help`), cursors (`.pointerStyle`), and SF Symbols' effects
4
- * (`.symbolEffect`) drawn with CSS. The Swift shim draws an iPhone and sends
5
- * none of these; producers in the page (@swiftbrowser/react) do.
3
+ * `continuoushover` events (`.onContinuousHover`: where the mouse is as it
4
+ * moves), tooltips (`.help`), cursors (`.pointerStyle`), and SF Symbols'
5
+ * effects (`.symbolEffect`) drawn with CSS. The Swift shim draws an iPhone
6
+ * and sends none of these; producers in the page (@swiftbrowser/react) do.
6
7
  */
7
8
 
8
- import type { ElementId, Style, SymbolEffectStyle } from './protocol';
9
+ import type { ContinuousHoverEvent, ElementId, HoverEvent, Style, SymbolEffectStyle } from './protocol';
9
10
 
10
11
  // ---------------------------------------------------------------------------
11
12
  // Hover
12
13
  // ---------------------------------------------------------------------------
13
14
 
14
15
  const HOVER_ATTR = 'sbHover';
16
+ const CONTINUOUS_HOVER_ATTR = 'sbContinuousHover';
15
17
 
16
- /** Sends `hover` as a mouse enters and leaves the boxes marked `data-sb-hover` under `root`. */
18
+ /**
19
+ * Sends `hover` as a mouse enters and leaves the boxes marked `data-sb-hover`
20
+ * under `root`, and `continuoushover` as it moves over the boxes marked
21
+ * `data-sb-continuous-hover` (its value the space of the location: the box's
22
+ * own, or the root's), one per animation frame per box, then `ended` as it
23
+ * leaves each.
24
+ */
17
25
  export class HoverTracker {
18
26
  private readonly hovered = new Set<HTMLElement>();
27
+ /** The boxes the mouse is over that follow it, with the location to send at the next frame. */
28
+ private readonly followed = new Map<HTMLElement, { x: number; y: number } | null>();
29
+ private frame: number | null = null;
19
30
 
20
31
  constructor(
21
32
  private readonly root: HTMLElement,
22
- private readonly send: (id: ElementId, value: boolean) => void,
33
+ private readonly send: (event: HoverEvent | ContinuousHoverEvent) => void,
23
34
  ) {
24
35
  root.addEventListener('pointerover', this.onOver);
25
36
  root.addEventListener('pointerout', this.onOut);
37
+ root.addEventListener('pointermove', this.onMove);
26
38
  }
27
39
 
28
40
  dispose(): void {
29
41
  this.root.removeEventListener('pointerover', this.onOver);
30
42
  this.root.removeEventListener('pointerout', this.onOut);
43
+ this.root.removeEventListener('pointermove', this.onMove);
31
44
  this.hovered.clear();
45
+ this.followed.clear();
46
+ if (this.frame !== null) cancelAnimationFrame(this.frame);
47
+ this.frame = null;
32
48
  }
33
49
 
34
50
  private readonly onOver = (event: PointerEvent): void => {
35
51
  if (event.pointerType === 'touch') return;
36
52
  for (let el = event.target as HTMLElement | null; el && el !== this.root; el = el.parentElement) {
37
- if (el.dataset[HOVER_ATTR] === undefined || this.hovered.has(el)) continue;
38
- this.hovered.add(el);
39
- this.report(el, true);
53
+ if (el.dataset[HOVER_ATTR] !== undefined && !this.hovered.has(el)) {
54
+ this.hovered.add(el);
55
+ this.report(el, true);
56
+ }
57
+ if (el.dataset[CONTINUOUS_HOVER_ATTR] !== undefined && !this.followed.has(el)) this.followed.set(el, this.locationIn(el, event));
58
+ }
59
+ if (this.followed.size) this.schedule();
60
+ };
61
+
62
+ private readonly onMove = (event: PointerEvent): void => {
63
+ if (event.pointerType === 'touch' || !this.followed.size) return;
64
+ for (let el = event.target as HTMLElement | null; el && el !== this.root; el = el.parentElement) {
65
+ if (this.followed.has(el)) this.followed.set(el, this.locationIn(el, event));
40
66
  }
67
+ this.schedule();
41
68
  };
42
69
 
43
70
  private readonly onOut = (event: PointerEvent): void => {
@@ -48,11 +75,42 @@ export class HoverTracker {
48
75
  this.hovered.delete(el);
49
76
  this.report(el, false);
50
77
  }
78
+ for (const el of [...this.followed.keys()]) {
79
+ if (next && el.contains(next)) continue;
80
+ this.followed.delete(el);
81
+ const id = this.idOf(el);
82
+ if (id !== null) this.send({ type: 'continuoushover', id, phase: 'ended' });
83
+ }
51
84
  };
52
85
 
53
- private report(el: HTMLElement, value: boolean): void {
86
+ /** Where the mouse is, in the box's own coordinates or the root's, as the box's style asks. */
87
+ private locationIn(el: HTMLElement, event: PointerEvent): { x: number; y: number } {
88
+ const rect = (el.dataset[CONTINUOUS_HOVER_ATTR] === 'global' ? this.root : el).getBoundingClientRect();
89
+ return { x: Math.round((event.clientX - rect.left) * 100) / 100, y: Math.round((event.clientY - rect.top) * 100) / 100 };
90
+ }
91
+
92
+ /** The followed boxes' locations go out at the next frame, however many moves came in. */
93
+ private schedule(): void {
94
+ if (this.frame !== null) return;
95
+ this.frame = requestAnimationFrame(() => {
96
+ this.frame = null;
97
+ for (const [el, location] of this.followed) {
98
+ if (!location) continue;
99
+ this.followed.set(el, null);
100
+ const id = this.idOf(el);
101
+ if (id !== null) this.send({ type: 'continuoushover', id, phase: 'active', location });
102
+ }
103
+ });
104
+ }
105
+
106
+ private idOf(el: HTMLElement): ElementId | null {
54
107
  const id = Number(el.dataset['sbId']);
55
- if (Number.isInteger(id) && el.isConnected) this.send(id, value);
108
+ return Number.isInteger(id) && el.isConnected ? id : null;
109
+ }
110
+
111
+ private report(el: HTMLElement, value: boolean): void {
112
+ const id = this.idOf(el);
113
+ if (id !== null) this.send({ type: 'hover', id, value });
56
114
  }
57
115
  }
58
116
 
@@ -87,6 +145,8 @@ const triggers = new WeakMap<HTMLElement, number>();
87
145
  /** Apply the pointer and effect fields of a `styled` element's style (called on create and update). */
88
146
  export function applyPointerAndEffects(el: HTMLElement, style: Style): void {
89
147
  if (style.hover) el.dataset[HOVER_ATTR] = '';
148
+ if (style.continuousHover) el.dataset[CONTINUOUS_HOVER_ATTR] = style.continuousHover;
149
+ else delete el.dataset[CONTINUOUS_HOVER_ATTR];
90
150
  if (style.help) {
91
151
  el.title = style.help;
92
152
  el.dataset['sbHelp'] = '';
package/src/protocol.ts CHANGED
@@ -1593,6 +1593,7 @@ export type RendererEvent =
1593
1593
  | LazyEvent
1594
1594
  | MenuEvent
1595
1595
  | HoverEvent
1596
+ | ContinuousHoverEvent
1596
1597
  // Scroll geometry and animation completion (the block at the end of this file)
1597
1598
  | ScrollGeometryEvent
1598
1599
  | AnimationCompleteEvent
@@ -2694,6 +2695,13 @@ export interface SymbolEffectStyle {
2694
2695
  export interface Style {
2695
2696
  /** The page sends `hover` events as a mouse enters and leaves the box (`.onHover`). */
2696
2697
  hover?: boolean;
2698
+ /**
2699
+ * `.onContinuousHover(coordinateSpace:)`: the page sends `continuoushover`
2700
+ * events as a mouse moves over the box, its location in the box's own
2701
+ * coordinates (`local`) or the root's, scrolling included (`global`, the
2702
+ * same a `reportsFrame` geometry event reports), and once as it leaves.
2703
+ */
2704
+ continuousHover?: 'local' | 'global';
2697
2705
  /** `.help(_:)`: a tooltip on the box. */
2698
2706
  help?: string;
2699
2707
  /** `.pointerStyle(_:)`: the CSS cursor over the box (`pointer`, `text`, `grab`…). */
@@ -2735,6 +2743,13 @@ export interface HoverEvent {
2735
2743
  value: boolean;
2736
2744
  }
2737
2745
 
2746
+ /**
2747
+ * A mouse (or pen) over a box whose style has `continuousHover`: `active`
2748
+ * with where it is, coalesced to one per animation frame while it moves,
2749
+ * then `ended` once as it leaves. Touch never hovers.
2750
+ */
2751
+ export type ContinuousHoverEvent = { type: 'continuoushover'; id: ElementId; phase: 'active'; location: Point } | { type: 'continuoushover'; id: ElementId; phase: 'ended' };
2752
+
2738
2753
  export interface ScrollToCommand {
2739
2754
  anchor: UnitPoint | null;
2740
2755
  /** Increases with every request (across readers); the page carries out each serial once. */
package/src/renderer.ts CHANGED
@@ -422,7 +422,7 @@ export class Renderer {
422
422
  * for size changes, and the measurements of the current layout pass.
423
423
  */
424
424
  private readonly hostObserver: ResizeObserver | null = null;
425
- /** `hover` events for boxes whose style asks for them (pointerEffects.ts). */
425
+ /** `hover` and `continuoushover` events for boxes whose style asks for them (pointerEffects.ts). */
426
426
  private readonly hover: HoverTracker;
427
427
  /** A frame check of the `reportsFrame` readers is queued for the next animation frame (after a scroll). */
428
428
  private frameCheckQueued = false;
@@ -1171,7 +1171,7 @@ export class Renderer {
1171
1171
  this.stopRichEditors = listenToRichEditors(root, editorId, (event) => this.onEvent(event));
1172
1172
  // After `onInput` above, so a keystroke's `text` goes before its `selection`.
1173
1173
  this.stopPlainEditors = listenToPlainEditors(root, editorId, (event) => this.onEvent(event));
1174
- this.hover = new HoverTracker(root, (id, value) => this.onEvent({ type: 'hover', id, value }));
1174
+ this.hover = new HoverTracker(root, (event) => this.onEvent(event));
1175
1175
  root.addEventListener('scroll', this.queueFrameCheck, { capture: true, passive: true });
1176
1176
  // Scroll transitions and pinned views follow the scroll in the frame it is drawn in.
1177
1177
  root.addEventListener('scroll', drawScrollTransitions, { capture: true, passive: true });
package/src/styles.css CHANGED
@@ -4499,9 +4499,11 @@ svg.sb-path {
4499
4499
  height: auto;
4500
4500
  }
4501
4501
 
4502
- /* `.onHover` (pointerEffects.ts): the whole box hovers, as SwiftUI uses the
4503
- view's frame, so a hover box is hit in its empty space too. */
4504
- .sb-styled[data-sb-hover] {
4502
+ /* `.onHover` and `.onContinuousHover` (pointerEffects.ts): the whole box
4503
+ hovers, as SwiftUI uses the view's frame, so a hover box is hit in its
4504
+ empty space too. */
4505
+ .sb-styled[data-sb-hover],
4506
+ .sb-styled[data-sb-continuous-hover] {
4505
4507
  pointer-events: auto;
4506
4508
  }
4507
4509