@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/package.json +1 -1
- package/src/animation.ts +60 -27
- package/src/appFiles.ts +21 -16
- package/src/audioRecorder.ts +392 -0
- package/src/editorSelection.ts +80 -0
- package/src/files.ts +442 -0
- package/src/gestureComposition.ts +92 -0
- package/src/hostRequests.ts +38 -4
- package/src/interaction.ts +108 -27
- package/src/layout/engine.ts +295 -19
- package/src/layout/types.ts +6 -0
- package/src/layoutDom.ts +13 -5
- package/src/main.ts +4 -0
- package/src/pinch.ts +9 -1
- package/src/pinnedViews.ts +131 -0
- package/src/plainEditor.ts +79 -0
- package/src/protocol.ts +304 -13
- package/src/renderer.ts +354 -43
- package/src/richEditor.ts +330 -0
- package/src/scrollGeometry.ts +79 -0
- package/src/scrollTransition.ts +145 -0
- package/src/styles.css +142 -11
- package/src/symbols.ts +22 -0
- package/src/touchPaging.ts +23 -2
- package/src/undo.ts +126 -0
- package/src/video.ts +59 -8
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
|
+
}
|