@swiftbrowser/web 0.17.8 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/pinch.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * - two touch (or pen) pointers on it: the scale is the distance between them
7
7
  * over the distance when the second came down, the rotation the turn of the
8
- * line between them;
8
+ * line between them, the location their midpoint;
9
9
  * - a trackpad pinch, which Chrome, Edge and Firefox report as `wheel` events
10
10
  * with `ctrlKey` (a scale only);
11
11
  * - Safari's `gesturestart` / `gesturechange` / `gestureend` on a computer
@@ -47,6 +47,10 @@ interface Pinch {
47
47
  rotation: number;
48
48
  startLocation: { x: number; y: number };
49
49
  startAnchor: { x: number; y: number };
50
+ /** Where the fingers' midpoint is now, in the element's coordinates. */
51
+ location: { x: number; y: number };
52
+ /** The element's top leading corner in the viewport when the pinch began. */
53
+ origin: { x: number; y: number };
50
54
  samples: { t: number; scale: number; rotation: number }[];
51
55
  raf: number | null;
52
56
  }
@@ -135,6 +139,8 @@ export class PinchController {
135
139
  rotation: 0,
136
140
  startLocation: { x: round(x, 2), y: round(y, 2) },
137
141
  startAnchor: { x: round(rect.width > 0 ? x / rect.width : 0.5), y: round(rect.height > 0 ? y / rect.height : 0.5) },
142
+ location: { x: round(x, 2), y: round(y, 2) },
143
+ origin: { x: rect.left, y: rect.top },
138
144
  samples: [],
139
145
  raf: null,
140
146
  };
@@ -185,6 +191,7 @@ export class PinchController {
185
191
  rotationVelocity: round(rotationVelocity, 2),
186
192
  startLocation: pinch.startLocation,
187
193
  startAnchor: pinch.startAnchor,
194
+ location: pinch.location,
188
195
  });
189
196
  }
190
197
 
@@ -239,6 +246,7 @@ export class PinchController {
239
246
  state.lastAngle = angle;
240
247
  const rotation = pinch.rotation + step;
241
248
  const scale = Math.hypot(pb.x - pa.x, pb.y - pa.y) / base.distance;
249
+ pinch.location = { x: round((pa.x + pb.x) / 2 - pinch.origin.x, 2), y: round((pa.y + pb.y) / 2 - pinch.origin.y, 2) };
242
250
  this.update(pinch, scale, rotation, event.timeStamp);
243
251
  };
244
252
 
@@ -0,0 +1,131 @@
1
+ /**
2
+ * `pinnedViews` (docs/ops-protocol.md, "Scroll geometry, pinned views and
3
+ * animation completion"): a lazy stack or grid marked `pinned` keeps each
4
+ * `section` child's header at the leading edge of the scroll view's visible
5
+ * area while the section passes, and the next section's header pushes it
6
+ * out, as `LazyVStack(pinnedViews: [.sectionHeaders])` does; a pinned footer
7
+ * stays at the trailing edge while its section is in view the same way.
8
+ *
9
+ * The renderer registers the marked stacks; after every layout and on every
10
+ * scroll event each header is moved by a transform along the axis. The
11
+ * laid-out positions are left alone (offsets, not client rects), so the
12
+ * transform never feeds back into where the header is reckoned to be.
13
+ */
14
+
15
+ import type { PinnedView } from './protocol';
16
+ import { layoutOrigin } from './scrollTransition';
17
+
18
+ export interface Span {
19
+ start: number;
20
+ length: number;
21
+ }
22
+
23
+ /**
24
+ * How far a header moves toward the trailing edge to stay at `viewStart`:
25
+ * nothing while its section's start is in view, then as far as the view has
26
+ * scrolled past it, and no further than its section's end (where the next
27
+ * section's header pushes it out).
28
+ */
29
+ export function pinnedHeaderOffset(viewStart: number, header: Span, sectionEnd: number): number {
30
+ const room = Math.max(0, sectionEnd - header.start - header.length);
31
+ return Math.min(room, Math.max(0, viewStart - header.start));
32
+ }
33
+
34
+ /**
35
+ * How far a footer moves toward the leading edge (negative) to stay at
36
+ * `viewEnd` while its section is in view: no further than its section's
37
+ * start, where the previous section's footer takes over.
38
+ */
39
+ export function pinnedFooterOffset(viewEnd: number, footer: Span, sectionStart: number): number {
40
+ const room = Math.min(0, sectionStart - footer.start);
41
+ return Math.max(room, Math.min(0, viewEnd - footer.start - footer.length));
42
+ }
43
+
44
+ const registered = new Map<HTMLElement, Set<PinnedView>>();
45
+
46
+ /** Called by the renderer for every stack and grid: registers, updates or forgets its pinned views. */
47
+ export function setPinnedViews(el: HTMLElement, pinned: PinnedView[] | undefined): void {
48
+ const views = new Set<PinnedView>(Array.isArray(pinned) ? pinned.filter((view) => view === 'sectionHeaders' || view === 'sectionFooters') : []);
49
+ if (views.size === 0) {
50
+ if (registered.delete(el)) {
51
+ delete el.dataset['sbPinned'];
52
+ for (const section of sections(el)) {
53
+ for (const kind of ['sectionheader', 'sectionfooter'] as const) {
54
+ const view = supplement(section, kind);
55
+ if (view) {
56
+ view.style.transform = '';
57
+ view.style.zIndex = '';
58
+ }
59
+ }
60
+ }
61
+ }
62
+ return;
63
+ }
64
+ registered.set(el, views);
65
+ el.dataset['sbPinned'] = [...views].join(' ');
66
+ draw(el, views);
67
+ }
68
+
69
+ function sections(stack: HTMLElement): HTMLElement[] {
70
+ return [...stack.children].filter((child): child is HTMLElement => child instanceof HTMLElement && child.classList.contains('sb-section'));
71
+ }
72
+
73
+ function supplement(section: HTMLElement, kind: 'sectionheader' | 'sectionfooter'): HTMLElement | null {
74
+ for (const child of section.children) {
75
+ if (child instanceof HTMLElement && child.classList.contains(`sb-${kind}`)) return child;
76
+ }
77
+ return null;
78
+ }
79
+
80
+ /** The inset of a bar the scroll view runs under (largeTitle.ts sets `--sb-top-inset`). */
81
+ function topInset(scroller: HTMLElement): number {
82
+ return parseFloat(scroller.style.getPropertyValue('--sb-top-inset')) || 0;
83
+ }
84
+
85
+ function draw(stack: HTMLElement, views: Set<PinnedView>): void {
86
+ const scroller = stack.parentElement?.closest<HTMLElement>('.sb-scrollview, .sb-list');
87
+ if (!scroller) return;
88
+ const horizontal = stack.classList.contains('sb-hstack') || stack.classList.contains('sb-lazyhgrid');
89
+ const viewStart = horizontal ? scroller.scrollLeft : scroller.scrollTop + topInset(scroller);
90
+ const viewEnd = (horizontal ? scroller.scrollLeft + scroller.clientWidth : scroller.scrollTop + scroller.clientHeight);
91
+ for (const section of sections(stack)) {
92
+ const origin = layoutOrigin(section, scroller);
93
+ if (!origin) continue;
94
+ const sectionStart = horizontal ? origin.x : origin.y;
95
+ const sectionEnd = sectionStart + (horizontal ? section.offsetWidth : section.offsetHeight);
96
+ if (views.has('sectionHeaders')) {
97
+ const header = supplement(section, 'sectionheader');
98
+ if (header) {
99
+ const at = layoutOrigin(header, scroller);
100
+ const span: Span = { start: horizontal ? (at?.x ?? sectionStart) : (at?.y ?? sectionStart), length: horizontal ? header.offsetWidth : header.offsetHeight };
101
+ move(header, horizontal, pinnedHeaderOffset(viewStart, span, sectionEnd));
102
+ }
103
+ }
104
+ if (views.has('sectionFooters')) {
105
+ const footer = supplement(section, 'sectionfooter');
106
+ if (footer) {
107
+ const at = layoutOrigin(footer, scroller);
108
+ const span: Span = { start: horizontal ? (at?.x ?? sectionEnd) : (at?.y ?? sectionEnd), length: horizontal ? footer.offsetWidth : footer.offsetHeight };
109
+ move(footer, horizontal, pinnedFooterOffset(viewEnd, span, sectionStart));
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ function move(view: HTMLElement, horizontal: boolean, offset: number): void {
116
+ const rounded = Math.round(offset * 100) / 100;
117
+ view.style.transform = rounded === 0 ? '' : horizontal ? `translateX(${rounded}px)` : `translateY(${rounded}px)`;
118
+ // Over the rows of its section, which come after it in the stack.
119
+ view.style.zIndex = '1';
120
+ }
121
+
122
+ /** Moves every pinned view to its place: after a layout, and on every scroll event. */
123
+ export function drawPinnedViews(): void {
124
+ for (const [stack, views] of registered) {
125
+ if (!stack.isConnected) {
126
+ registered.delete(stack);
127
+ continue;
128
+ }
129
+ draw(stack, views);
130
+ }
131
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The plain `texteditor` (a textarea) with a bound `selection`
3
+ * (docs/ops-protocol.md, "Rich text editors"): it reports the viewer's
4
+ * selection (`selectionStart`/`selectionEnd`, UTF-16 offsets as a string's
5
+ * indices) and moves to one the app sets, under the rich editor's rules
6
+ * (src/editorSelection.ts): at once while focused, else when the app focuses
7
+ * it; the selection the app sends back is not applied again, and one the
8
+ * viewer did not move is not reported again.
9
+ */
10
+
11
+ import type { TextEditorProps, TextSelectionRange } from './protocol';
12
+ import { editorBlurred, isSelectionBound, markSelectionBound, noteViewerSelection, selectionToApply, takePendingSelection, viewerFocused } from './editorSelection';
13
+
14
+ export function isPlainEditor(el: unknown): el is HTMLTextAreaElement {
15
+ return el instanceof HTMLTextAreaElement && el.classList.contains('sb-texteditor');
16
+ }
17
+
18
+ /** After the text: a selection the app set (not the viewer's coming back), moved to now, or when the app focuses the editor. */
19
+ export function applyPlainEditorSelection(area: HTMLTextAreaElement, props: TextEditorProps): void {
20
+ markSelectionBound(area, props.selection !== undefined);
21
+ const range = selectionToApply(area, props.selection);
22
+ if (range) area.setSelectionRange(range.start, range.end);
23
+ }
24
+
25
+ /** Focuses an editor for the app (a bound `focused`): its caret where the app last set the selection, else where the browser leaves it. */
26
+ export function focusPlainEditor(area: HTMLTextAreaElement): void {
27
+ const range = takePendingSelection(area);
28
+ area.focus({ preventScroll: true });
29
+ if (range) area.setSelectionRange(range.start, range.end);
30
+ }
31
+
32
+ type Report = (event: { type: 'selection'; id: number; value: TextSelectionRange }) => void;
33
+
34
+ /**
35
+ * The listeners every plain editor under `root` shares: the selection
36
+ * reported for bound editors. Returns what removes them.
37
+ */
38
+ export function listenToPlainEditors(root: HTMLElement, idOf: (el: HTMLElement) => number | null, report: Report): () => void {
39
+ const doc = root.ownerDocument;
40
+ const editorOf = (target: EventTarget | null): HTMLTextAreaElement | null => (isPlainEditor(target) && root.contains(target) ? target : null);
41
+ const reportSelection = (area: HTMLTextAreaElement): void => {
42
+ if (!isSelectionBound(area)) return;
43
+ const id = idOf(area);
44
+ if (id === null) return;
45
+ const range = { start: area.selectionStart, end: area.selectionEnd };
46
+ if (noteViewerSelection(area, range)) report({ type: 'selection', id, value: range });
47
+ };
48
+ // Browsers differ in what a caret move in a textarea raises (the document's
49
+ // `selectionchange`, the control's `select`, or nothing until the key or
50
+ // button is up, as after an input): each is a look at the selection, which
51
+ // is reported once however many of them fire.
52
+ const onSelectionChange = (): void => {
53
+ const area = editorOf(doc.activeElement);
54
+ if (area) reportSelection(area);
55
+ };
56
+ const onEditorEvent = (event: Event): void => {
57
+ const area = editorOf(event.target);
58
+ if (area) reportSelection(area);
59
+ };
60
+ const onFocusIn = (event: FocusEvent): void => {
61
+ const area = editorOf(event.target);
62
+ if (area) viewerFocused(area);
63
+ };
64
+ const onFocusOut = (event: FocusEvent): void => {
65
+ const area = editorOf(event.target);
66
+ if (area) editorBlurred(area);
67
+ };
68
+ const looks = ['select', 'keyup', 'mouseup', 'input'] as const;
69
+ for (const type of looks) root.addEventListener(type, onEditorEvent);
70
+ root.addEventListener('focusin', onFocusIn);
71
+ root.addEventListener('focusout', onFocusOut);
72
+ doc.addEventListener('selectionchange', onSelectionChange);
73
+ return () => {
74
+ for (const type of looks) root.removeEventListener(type, onEditorEvent);
75
+ root.removeEventListener('focusin', onFocusIn);
76
+ root.removeEventListener('focusout', onFocusOut);
77
+ doc.removeEventListener('selectionchange', onSelectionChange);
78
+ };
79
+ }