@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
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rich `texteditor` (one with `runs`; docs/ops-protocol.md, "Rich text
|
|
3
|
+
* editors"): a contenteditable box holding a span per run, which the
|
|
4
|
+
* browser edits in place. The renderer reports what the viewer typed as
|
|
5
|
+
* plain `text` (the app keeps the runs, giving typed text the attributes
|
|
6
|
+
* around it) and, when the selection is bound, the selection in UTF-16
|
|
7
|
+
* offsets; a selection the app sets moves the caret (when the app focuses
|
|
8
|
+
* the editor, if it is not focused). New runs rebuild the spans only when
|
|
9
|
+
* they differ from what is there (typing into a span leaves it as the runs
|
|
10
|
+
* will have it), keeping the caret where it was, and never while a
|
|
11
|
+
* composition is open.
|
|
12
|
+
*
|
|
13
|
+
* Return inserts a newline into the text, as a text view does; pastes and
|
|
14
|
+
* drops arrive as plain text; the browser's own formatting and undo are off
|
|
15
|
+
* (the app formats the runs).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ShapeStyle, TextEditorProps, TextRun, TextSelectionRange } from './protocol';
|
|
19
|
+
import type { TextLayout } from './layout';
|
|
20
|
+
import { editorBlurred, isSelectionBound, markDeleteBackward, markSelectionBound, noteViewerSelection, selectionToApply, takePendingSelection, viewerFocused, wantsDeleteBackward } from './editorSelection';
|
|
21
|
+
import { styleToCSS } from './styles';
|
|
22
|
+
|
|
23
|
+
const RICH = new WeakSet<HTMLElement>();
|
|
24
|
+
/** The runs each editor was last given, to draw again once a composition ends. */
|
|
25
|
+
const latest = new WeakMap<HTMLElement, TextRun[]>();
|
|
26
|
+
const composing = new WeakSet<HTMLElement>();
|
|
27
|
+
|
|
28
|
+
/** Marks the line break that only shows a last, empty line (the text ends in a newline). */
|
|
29
|
+
const END_ATTR = 'sbEnd';
|
|
30
|
+
|
|
31
|
+
export function createRichEditor(): HTMLElement {
|
|
32
|
+
const el = document.createElement('div');
|
|
33
|
+
// Plain text edits only where the browser has them ('plaintext-only'); the
|
|
34
|
+
// handlers below keep a full contenteditable to plain text elsewhere.
|
|
35
|
+
try {
|
|
36
|
+
el.contentEditable = 'plaintext-only';
|
|
37
|
+
} catch {
|
|
38
|
+
el.contentEditable = 'true';
|
|
39
|
+
}
|
|
40
|
+
if (el.contentEditable !== 'plaintext-only') el.contentEditable = 'true';
|
|
41
|
+
el.setAttribute('role', 'textbox');
|
|
42
|
+
el.setAttribute('aria-multiline', 'true');
|
|
43
|
+
el.spellcheck = false;
|
|
44
|
+
RICH.add(el);
|
|
45
|
+
return el;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isRichEditor(el: unknown): boolean {
|
|
49
|
+
return el instanceof HTMLElement && RICH.has(el);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function applyRichEditor(el: HTMLElement, props: TextEditorProps): void {
|
|
53
|
+
if (typeof props.focused === 'boolean') el.dataset['sbFocus'] = String(props.focused);
|
|
54
|
+
else delete el.dataset['sbFocus'];
|
|
55
|
+
markDeleteBackward(el, props.deleteBackward === true);
|
|
56
|
+
markSelectionBound(el, props.selection !== undefined);
|
|
57
|
+
const runs = props.runs ?? [{ text: props.text ?? '' }];
|
|
58
|
+
latest.set(el, runs);
|
|
59
|
+
if (!composing.has(el)) drawRuns(el, runs);
|
|
60
|
+
// A selection the app set (not the viewer's coming back): moved to now,
|
|
61
|
+
// or when the app focuses the editor (src/editorSelection.ts).
|
|
62
|
+
const range = selectionToApply(el, props.selection);
|
|
63
|
+
if (range) setSelectionOffsets(el, range);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Focuses an editor for the app (a bound `focused`): its caret where the app last set the selection, else at the start. */
|
|
67
|
+
export function focusRichEditor(el: HTMLElement): void {
|
|
68
|
+
const range = takePendingSelection(el);
|
|
69
|
+
el.focus({ preventScroll: true });
|
|
70
|
+
if (range) setSelectionOffsets(el, range);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A run's look as a key: what decides its span's style. */
|
|
74
|
+
const runKey = (run: TextRun): string =>
|
|
75
|
+
JSON.stringify([run.font ?? null, run.italic === true, run.underline ?? null, run.strikethrough ?? null, run.foreground ?? null, run.background ?? null]);
|
|
76
|
+
|
|
77
|
+
function drawRuns(el: HTMLElement, runs: TextRun[]): void {
|
|
78
|
+
const shown = runs.map((run, index) => ({ run, index, text: run.text ?? '' })).filter((entry) => entry.text.length > 0);
|
|
79
|
+
const text = shown.map((entry) => entry.text).join('');
|
|
80
|
+
const ends = text.endsWith('\n');
|
|
81
|
+
const nodes = Array.from(el.childNodes);
|
|
82
|
+
const same =
|
|
83
|
+
nodes.length === shown.length + (ends ? 1 : 0) &&
|
|
84
|
+
shown.every((entry, i) => {
|
|
85
|
+
const node = nodes[i];
|
|
86
|
+
return node instanceof HTMLElement && node.dataset['sbRun'] === String(entry.index) && node.dataset['sbKey'] === runKey(entry.run) && node.textContent === entry.text;
|
|
87
|
+
}) &&
|
|
88
|
+
(!ends || (nodes[nodes.length - 1] instanceof HTMLElement && (nodes[nodes.length - 1] as HTMLElement).dataset[END_ATTR] !== undefined));
|
|
89
|
+
if (same) return;
|
|
90
|
+
const focused = el.ownerDocument.activeElement === el;
|
|
91
|
+
const selection = focused ? selectionOffsets(el) : null;
|
|
92
|
+
const doc = el.ownerDocument;
|
|
93
|
+
const spans: Node[] = shown.map((entry) => {
|
|
94
|
+
const span = doc.createElement('span');
|
|
95
|
+
span.className = 'sb-texteditor-run';
|
|
96
|
+
span.dataset['sbRun'] = String(entry.index);
|
|
97
|
+
span.dataset['sbKey'] = runKey(entry.run);
|
|
98
|
+
styleRun(span, entry.run);
|
|
99
|
+
span.textContent = entry.text;
|
|
100
|
+
return span;
|
|
101
|
+
});
|
|
102
|
+
if (ends) {
|
|
103
|
+
const end = doc.createElement('br');
|
|
104
|
+
end.dataset[END_ATTR] = '';
|
|
105
|
+
spans.push(end);
|
|
106
|
+
}
|
|
107
|
+
el.replaceChildren(...spans);
|
|
108
|
+
if (selection) setSelectionOffsets(el, selection);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A run's weight, slant, lines and colors (the layout pass sets its whole font, size and design included, after). */
|
|
112
|
+
function styleRun(span: HTMLElement, run: TextRun): void {
|
|
113
|
+
const s = span.style;
|
|
114
|
+
const weight = (run.font as { weight?: string } | undefined)?.weight;
|
|
115
|
+
if (weight) s.fontWeight = WEIGHTS[weight] ?? '400';
|
|
116
|
+
if (run.italic) s.fontStyle = 'italic';
|
|
117
|
+
const lines = [run.underline ? 'underline' : '', run.strikethrough ? 'line-through' : ''].filter(Boolean);
|
|
118
|
+
if (lines.length > 0) s.textDecorationLine = lines.join(' ');
|
|
119
|
+
if (run.foreground !== undefined) s.color = styleToCSS(run.foreground as ShapeStyle, 'text').solid ?? '';
|
|
120
|
+
if (run.background !== undefined) s.backgroundColor = styleToCSS(run.background as ShapeStyle, 'paint').solid ?? '';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const WEIGHTS: Record<string, string> = {
|
|
124
|
+
ultraLight: '100',
|
|
125
|
+
thin: '200',
|
|
126
|
+
light: '300',
|
|
127
|
+
regular: '400',
|
|
128
|
+
medium: '500',
|
|
129
|
+
semibold: '600',
|
|
130
|
+
bold: '700',
|
|
131
|
+
heavy: '800',
|
|
132
|
+
black: '900',
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** After layout: each run's span in the font the engine measured it in. */
|
|
136
|
+
export function layOutRichEditor(el: HTMLElement, layout: TextLayout | undefined): void {
|
|
137
|
+
const fonts = layout?.rich?.fonts;
|
|
138
|
+
if (!fonts) return;
|
|
139
|
+
for (const span of Array.from(el.children) as HTMLElement[]) {
|
|
140
|
+
const font = fonts[Number(span.dataset['sbRun'])];
|
|
141
|
+
if (!font || span.tagName !== 'SPAN') continue;
|
|
142
|
+
if (span.style.font !== font.css) {
|
|
143
|
+
span.style.font = font.css;
|
|
144
|
+
// Every run keeps the editor's line spacing.
|
|
145
|
+
span.style.lineHeight = 'inherit';
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The editor's text as the viewer left it: its text nodes, a line break as
|
|
152
|
+
* a newline. A last break after a newline is only there to show the empty
|
|
153
|
+
* line (the browser's, or the renderer's own), not a newline of its own.
|
|
154
|
+
*/
|
|
155
|
+
export function richEditorText(el: HTMLElement): string {
|
|
156
|
+
return textOf(el, true);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function textOf(el: HTMLElement, whole: boolean): string {
|
|
160
|
+
let text = '';
|
|
161
|
+
let endsInBreak = false;
|
|
162
|
+
const visit = (node: Node, top: boolean): void => {
|
|
163
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
164
|
+
const value = node.nodeValue ?? '';
|
|
165
|
+
text += value;
|
|
166
|
+
if (value) endsInBreak = false;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (!(node instanceof HTMLElement)) return;
|
|
170
|
+
if (node.tagName === 'BR') {
|
|
171
|
+
if (node.dataset[END_ATTR] !== undefined) return;
|
|
172
|
+
text += '\n';
|
|
173
|
+
endsInBreak = true;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// A block the browser made for a new line (a full contenteditable's Return).
|
|
177
|
+
if (!top && (node.tagName === 'DIV' || node.tagName === 'P') && text.length > 0 && !text.endsWith('\n')) text += '\n';
|
|
178
|
+
node.childNodes.forEach((child) => visit(child, false));
|
|
179
|
+
};
|
|
180
|
+
el.childNodes.forEach((child) => visit(child, false));
|
|
181
|
+
return whole && endsInBreak && text.endsWith('\n\n') ? text.slice(0, -1) : text;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The characters of the editor before a DOM point (a text node's offset, or an element's child index). */
|
|
185
|
+
function offsetOf(el: HTMLElement, container: Node, offset: number): number {
|
|
186
|
+
const range = el.ownerDocument.createRange();
|
|
187
|
+
range.selectNodeContents(el);
|
|
188
|
+
try {
|
|
189
|
+
range.setEnd(container, offset);
|
|
190
|
+
} catch {
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
const fragment = range.cloneContents();
|
|
194
|
+
const holder = el.ownerDocument.createElement('div');
|
|
195
|
+
holder.append(fragment);
|
|
196
|
+
// A caret after the browser's own placeholder break (a `<br>` it keeps
|
|
197
|
+
// after a final newline, which `richEditorText` leaves out) is at the end.
|
|
198
|
+
return Math.min(textOf(holder, false).length, richEditorText(el).length);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The selection inside the editor in UTF-16 offsets, or null when it is elsewhere. */
|
|
202
|
+
export function selectionOffsets(el: HTMLElement): TextSelectionRange | null {
|
|
203
|
+
const selection = el.ownerDocument.getSelection();
|
|
204
|
+
if (!selection || selection.rangeCount === 0) return null;
|
|
205
|
+
const range = selection.getRangeAt(0);
|
|
206
|
+
if (!el.contains(range.startContainer) || !el.contains(range.endContainer)) return null;
|
|
207
|
+
return { start: offsetOf(el, range.startContainer, range.startOffset), end: offsetOf(el, range.endContainer, range.endOffset) };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Where an offset falls in the editor's DOM. */
|
|
211
|
+
function pointAt(el: HTMLElement, offset: number): [Node, number] {
|
|
212
|
+
let left = offset;
|
|
213
|
+
const walker = el.ownerDocument.createTreeWalker(el, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
|
|
214
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
215
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
216
|
+
const length = node.nodeValue?.length ?? 0;
|
|
217
|
+
if (left <= length) return [node, left];
|
|
218
|
+
left -= length;
|
|
219
|
+
} else if (node instanceof HTMLElement && node.tagName === 'BR' && node.dataset[END_ATTR] === undefined) {
|
|
220
|
+
if (left === 0) return [node.parentNode!, Array.prototype.indexOf.call(node.parentNode!.childNodes, node)];
|
|
221
|
+
left -= 1;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return [el, el.childNodes.length - (el.lastChild instanceof HTMLElement && el.lastChild.dataset[END_ATTR] !== undefined ? 1 : 0)];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function setSelectionOffsets(el: HTMLElement, range: TextSelectionRange): void {
|
|
228
|
+
const selection = el.ownerDocument.getSelection();
|
|
229
|
+
if (!selection) return;
|
|
230
|
+
const [startNode, startOffset] = pointAt(el, range.start);
|
|
231
|
+
const [endNode, endOffset] = pointAt(el, range.end);
|
|
232
|
+
try {
|
|
233
|
+
selection.setBaseAndExtent(startNode, startOffset, endNode, endOffset);
|
|
234
|
+
} catch {
|
|
235
|
+
// A point the DOM no longer has: leave the selection where it is.
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
type Report = (event: { type: 'text'; id: number; value: string } | { type: 'selection'; id: number; value: TextSelectionRange } | { type: 'deletebackward'; id: number }) => void;
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The listeners every rich editor under `root` shares: Return, pastes and
|
|
243
|
+
* formatting kept to plain text, compositions, and the selection reported
|
|
244
|
+
* for bound editors. Returns what removes them.
|
|
245
|
+
*/
|
|
246
|
+
export function listenToRichEditors(root: HTMLElement, idOf: (el: HTMLElement) => number | null, report: Report): () => void {
|
|
247
|
+
const doc = root.ownerDocument;
|
|
248
|
+
const editorOf = (target: EventTarget | null): HTMLElement | null => {
|
|
249
|
+
for (let node = target instanceof Node ? target : null; node && node !== root; node = node.parentNode) {
|
|
250
|
+
if (isRichEditor(node)) return node as HTMLElement;
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
};
|
|
254
|
+
const insert = (text: string): void => {
|
|
255
|
+
// The browser's own insertion: it keeps its caret and undo, and raises `input`.
|
|
256
|
+
doc.execCommand('insertText', false, text);
|
|
257
|
+
};
|
|
258
|
+
const onBeforeInput = (event: InputEvent): void => {
|
|
259
|
+
const el = editorOf(event.target);
|
|
260
|
+
if (!el) return;
|
|
261
|
+
const type = event.inputType;
|
|
262
|
+
if (type === 'deleteContentBackward' && wantsDeleteBackward(el)) {
|
|
263
|
+
// Backspace with the caret at the start: nothing to delete, the app hears it.
|
|
264
|
+
const range = selectionOffsets(el);
|
|
265
|
+
if (range && range.start === 0 && range.end === 0) {
|
|
266
|
+
event.preventDefault();
|
|
267
|
+
const id = idOf(el);
|
|
268
|
+
if (id !== null) report({ type: 'deletebackward', id });
|
|
269
|
+
}
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (type === 'insertParagraph' || type === 'insertLineBreak') {
|
|
273
|
+
event.preventDefault();
|
|
274
|
+
insert('\n');
|
|
275
|
+
} else if (type === 'insertFromPaste' || type === 'insertFromDrop') {
|
|
276
|
+
const text = event.dataTransfer?.getData('text/plain');
|
|
277
|
+
if (text === undefined) return;
|
|
278
|
+
event.preventDefault();
|
|
279
|
+
insert(text);
|
|
280
|
+
} else if (type.startsWith('format') || type === 'historyUndo' || type === 'historyRedo') {
|
|
281
|
+
event.preventDefault();
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
// The viewer focused it: their caret, not one the app set before.
|
|
285
|
+
const onFocusIn = (event: FocusEvent): void => {
|
|
286
|
+
const el = editorOf(event.target);
|
|
287
|
+
if (el) viewerFocused(el);
|
|
288
|
+
};
|
|
289
|
+
// Left, it has no caret: the selection the app holds is one to move to again.
|
|
290
|
+
const onFocusOut = (event: FocusEvent): void => {
|
|
291
|
+
const el = editorOf(event.target);
|
|
292
|
+
if (el) editorBlurred(el);
|
|
293
|
+
};
|
|
294
|
+
const onCompositionStart = (event: CompositionEvent): void => {
|
|
295
|
+
const el = editorOf(event.target);
|
|
296
|
+
if (el) composing.add(el);
|
|
297
|
+
};
|
|
298
|
+
const onCompositionEnd = (event: CompositionEvent): void => {
|
|
299
|
+
const el = editorOf(event.target);
|
|
300
|
+
if (!el) return;
|
|
301
|
+
composing.delete(el);
|
|
302
|
+
// The text after the composition, then the runs for it.
|
|
303
|
+
const id = idOf(el);
|
|
304
|
+
if (id !== null) report({ type: 'text', id, value: richEditorText(el) });
|
|
305
|
+
const runs = latest.get(el);
|
|
306
|
+
if (runs) drawRuns(el, runs);
|
|
307
|
+
};
|
|
308
|
+
const onSelectionChange = (): void => {
|
|
309
|
+
const el = editorOf(doc.getSelection()?.anchorNode ?? null);
|
|
310
|
+
if (!el || !isSelectionBound(el)) return;
|
|
311
|
+
const id = idOf(el);
|
|
312
|
+
const range = selectionOffsets(el);
|
|
313
|
+
if (id === null || !range) return;
|
|
314
|
+
if (noteViewerSelection(el, range)) report({ type: 'selection', id, value: range });
|
|
315
|
+
};
|
|
316
|
+
root.addEventListener('beforeinput', onBeforeInput);
|
|
317
|
+
root.addEventListener('focusin', onFocusIn);
|
|
318
|
+
root.addEventListener('focusout', onFocusOut);
|
|
319
|
+
root.addEventListener('compositionstart', onCompositionStart);
|
|
320
|
+
root.addEventListener('compositionend', onCompositionEnd);
|
|
321
|
+
doc.addEventListener('selectionchange', onSelectionChange);
|
|
322
|
+
return () => {
|
|
323
|
+
root.removeEventListener('beforeinput', onBeforeInput);
|
|
324
|
+
root.removeEventListener('focusin', onFocusIn);
|
|
325
|
+
root.removeEventListener('focusout', onFocusOut);
|
|
326
|
+
root.removeEventListener('compositionstart', onCompositionStart);
|
|
327
|
+
root.removeEventListener('compositionend', onCompositionEnd);
|
|
328
|
+
doc.removeEventListener('selectionchange', onSelectionChange);
|
|
329
|
+
};
|
|
330
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.onScrollGeometryChange` (docs/ops-protocol.md, "Scroll geometry, pinned
|
|
3
|
+
* views and animation completion"): a `scrollview` with `reportsGeometry`
|
|
4
|
+
* tells the app where its visible area is in its content and how big both
|
|
5
|
+
* are, as SwiftUI's `ScrollGeometry` does. The renderer reads the offsets
|
|
6
|
+
* from the element (the engine's frames put every scroll view at offset 0)
|
|
7
|
+
* and the sizes from the layout result, after every layout pass and once
|
|
8
|
+
* per animation frame while anything scrolls; the reporter sends an event
|
|
9
|
+
* only when a field changed since the last one, so a pass that moved
|
|
10
|
+
* nothing says nothing.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Rect, Size } from './layout';
|
|
14
|
+
import type { ElementId, ScrollGeometryEvent } from './protocol';
|
|
15
|
+
|
|
16
|
+
export interface ScrollGeometry {
|
|
17
|
+
offset: { x: number; y: number };
|
|
18
|
+
contentSize: Size;
|
|
19
|
+
containerSize: Size;
|
|
20
|
+
contentInsets: { top: number; leading: number; bottom: number; trailing: number };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const round2 = (value: number): number => Math.round(value * 100) / 100;
|
|
24
|
+
|
|
25
|
+
/** The event for a geometry, every number rounded to 2 decimals. */
|
|
26
|
+
export function scrollGeometryEvent(id: ElementId, geometry: ScrollGeometry): ScrollGeometryEvent {
|
|
27
|
+
const { offset, contentSize, containerSize, contentInsets } = geometry;
|
|
28
|
+
return {
|
|
29
|
+
type: 'scrollgeometry',
|
|
30
|
+
id,
|
|
31
|
+
offset: { x: round2(offset.x), y: round2(offset.y) },
|
|
32
|
+
contentSize: { width: round2(contentSize.width), height: round2(contentSize.height) },
|
|
33
|
+
containerSize: { width: round2(containerSize.width), height: round2(containerSize.height) },
|
|
34
|
+
contentInsets: {
|
|
35
|
+
top: round2(contentInsets.top),
|
|
36
|
+
leading: round2(contentInsets.leading),
|
|
37
|
+
bottom: round2(contentInsets.bottom),
|
|
38
|
+
trailing: round2(contentInsets.trailing),
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Sends each scroll view's geometry once per change. */
|
|
44
|
+
export class ScrollGeometryReporter {
|
|
45
|
+
private readonly last = new Map<ElementId, string>();
|
|
46
|
+
|
|
47
|
+
/** The event to send for this geometry, or null when it is the one last sent for the element. */
|
|
48
|
+
report(id: ElementId, geometry: ScrollGeometry): ScrollGeometryEvent | null {
|
|
49
|
+
const event = scrollGeometryEvent(id, geometry);
|
|
50
|
+
const key = JSON.stringify([event.offset, event.contentSize, event.containerSize, event.contentInsets]);
|
|
51
|
+
if (this.last.get(id) === key) return null;
|
|
52
|
+
this.last.set(id, key);
|
|
53
|
+
return event;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The element was removed: one re-created with the same id reports afresh. */
|
|
57
|
+
forget(id: ElementId): void {
|
|
58
|
+
this.last.delete(id);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
clear(): void {
|
|
62
|
+
this.last.clear();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A scroll view's geometry now: its offsets from the element, its sizes
|
|
68
|
+
* from the layout pass (`frame`, `contentSize`; the element's own when the
|
|
69
|
+
* pass has none), and the space below its content a bar covers
|
|
70
|
+
* (`bottomInset`), which it scrolls into.
|
|
71
|
+
*/
|
|
72
|
+
export function readScrollGeometry(el: HTMLElement, frame: Rect | undefined, contentSize: Size | undefined, bottomInset: number): ScrollGeometry {
|
|
73
|
+
return {
|
|
74
|
+
offset: { x: el.scrollLeft, y: el.scrollTop },
|
|
75
|
+
contentSize: contentSize ?? { width: el.scrollWidth, height: el.scrollHeight - bottomInset },
|
|
76
|
+
containerSize: frame ? { width: frame.width, height: frame.height } : { width: el.clientWidth, height: el.clientHeight },
|
|
77
|
+
contentInsets: { top: 0, leading: 0, bottom: bottomInset, trailing: 0 },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.scrollTransition`: a box's effects drawn as it moves through its scroll
|
|
3
|
+
* view, from the three phases the app sends (`ScrollTransition`), without a
|
|
4
|
+
* round trip. After every layout and once per animation frame while
|
|
5
|
+
* anything scrolls, each box's progress (how far out of the scroll view's
|
|
6
|
+
* visible area it is, as a share of its own length, negative past the top
|
|
7
|
+
* or leading edge) picks the effects: `interactive` between the identity
|
|
8
|
+
* phase and the edge's by that share, `animated` the edge's (CSS-animated)
|
|
9
|
+
* once partly out, `identity` the identity phase's.
|
|
10
|
+
*
|
|
11
|
+
* Positions are the boxes' laid-out ones (offsets, not client rects), so a
|
|
12
|
+
* transition's own offset never feeds back into its progress.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ScrollTransition, ScrollTransitionEffect, Style } from './protocol';
|
|
16
|
+
import { effectsCSS } from './styles';
|
|
17
|
+
|
|
18
|
+
interface Registered {
|
|
19
|
+
spec: ScrollTransition;
|
|
20
|
+
/** The box's own effects, which the transition's come after. */
|
|
21
|
+
base: { transform: string; filter: string; opacity: number };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const registered = new Map<HTMLElement, Registered>();
|
|
25
|
+
|
|
26
|
+
const ANIMATED = 'transform 0.35s ease, opacity 0.35s ease, filter 0.35s ease';
|
|
27
|
+
|
|
28
|
+
/** Called by `applyStyle` for every `styled` box: registers, updates or forgets its transition. */
|
|
29
|
+
export function setScrollTransition(el: HTMLElement, style: Style): void {
|
|
30
|
+
const spec = style.scrollTransition;
|
|
31
|
+
if (!spec) {
|
|
32
|
+
if (registered.delete(el)) {
|
|
33
|
+
delete el.dataset['sbScrollTransition'];
|
|
34
|
+
el.style.transition = '';
|
|
35
|
+
}
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const own = effectsCSS(style);
|
|
39
|
+
registered.set(el, {
|
|
40
|
+
spec,
|
|
41
|
+
base: { transform: own.transform ?? '', filter: own.filter ?? '', opacity: style.opacity ?? 1 },
|
|
42
|
+
});
|
|
43
|
+
el.dataset['sbScrollTransition'] = spec.configuration;
|
|
44
|
+
el.style.transition = spec.configuration === 'animated' ? ANIMATED : '';
|
|
45
|
+
draw(el);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `from` → `to` by `t`, field by field (absent fields are the identity's). */
|
|
49
|
+
export function interpolateEffect(from: ScrollTransitionEffect, to: ScrollTransitionEffect, t: number): ScrollTransitionEffect {
|
|
50
|
+
const mix = (a: number, b: number): number => a + (b - a) * t;
|
|
51
|
+
const out: ScrollTransitionEffect = {};
|
|
52
|
+
const ox = mix(from.offset?.x ?? 0, to.offset?.x ?? 0);
|
|
53
|
+
const oy = mix(from.offset?.y ?? 0, to.offset?.y ?? 0);
|
|
54
|
+
if (ox !== 0 || oy !== 0) out.offset = { x: ox, y: oy };
|
|
55
|
+
const sx = mix(from.scale?.x ?? 1, to.scale?.x ?? 1);
|
|
56
|
+
const sy = mix(from.scale?.y ?? 1, to.scale?.y ?? 1);
|
|
57
|
+
if (sx !== 1 || sy !== 1) out.scale = { x: sx, y: sy };
|
|
58
|
+
const scaleAnchor = to.scaleAnchor ?? from.scaleAnchor;
|
|
59
|
+
if (scaleAnchor) out.scaleAnchor = scaleAnchor;
|
|
60
|
+
const rotation = mix(from.rotation ?? 0, to.rotation ?? 0);
|
|
61
|
+
if (rotation !== 0) out.rotation = rotation;
|
|
62
|
+
const rotationAnchor = to.rotationAnchor ?? from.rotationAnchor;
|
|
63
|
+
if (rotationAnchor) out.rotationAnchor = rotationAnchor;
|
|
64
|
+
const r3 = to.rotation3D ?? from.rotation3D;
|
|
65
|
+
if (r3) out.rotation3D = { ...r3, angle: mix(from.rotation3D?.angle ?? 0, to.rotation3D?.angle ?? 0) };
|
|
66
|
+
const opacity = mix(from.opacity ?? 1, to.opacity ?? 1);
|
|
67
|
+
if (opacity !== 1) out.opacity = opacity;
|
|
68
|
+
const blur = mix(from.blur ?? 0, to.blur ?? 0);
|
|
69
|
+
if (blur !== 0) out.blur = blur;
|
|
70
|
+
const brightness = mix(from.brightness ?? 0, to.brightness ?? 0);
|
|
71
|
+
if (brightness !== 0) out.brightness = brightness;
|
|
72
|
+
const contrast = mix(from.contrast ?? 1, to.contrast ?? 1);
|
|
73
|
+
if (contrast !== 1) out.contrast = contrast;
|
|
74
|
+
const saturation = mix(from.saturation ?? 1, to.saturation ?? 1);
|
|
75
|
+
if (saturation !== 1) out.saturation = saturation;
|
|
76
|
+
const grayscale = mix(from.grayscale ?? 0, to.grayscale ?? 0);
|
|
77
|
+
if (grayscale !== 0) out.grayscale = grayscale;
|
|
78
|
+
const hueRotation = mix(from.hueRotation ?? 0, to.hueRotation ?? 0);
|
|
79
|
+
if (hueRotation !== 0) out.hueRotation = hueRotation;
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The effects at `progress` (see the file comment). */
|
|
84
|
+
export function effectAt(spec: ScrollTransition, progress: number): ScrollTransitionEffect {
|
|
85
|
+
if (spec.configuration === 'identity' || progress === 0) return spec.identity;
|
|
86
|
+
const edge = progress < 0 ? spec.topLeading : spec.bottomTrailing;
|
|
87
|
+
if (spec.configuration === 'animated') return edge;
|
|
88
|
+
return interpolateEffect(spec.identity, edge, Math.min(1, Math.abs(progress)));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Where `el` is laid out inside `scroller`'s content (offsets: transforms left out). */
|
|
92
|
+
export function layoutOrigin(el: HTMLElement, scroller: HTMLElement): { x: number; y: number } | null {
|
|
93
|
+
let x = 0;
|
|
94
|
+
let y = 0;
|
|
95
|
+
let node: HTMLElement | null = el;
|
|
96
|
+
while (node && node !== scroller) {
|
|
97
|
+
x += node.offsetLeft;
|
|
98
|
+
y += node.offsetTop;
|
|
99
|
+
const parent = node.offsetParent as HTMLElement | null;
|
|
100
|
+
if (!parent || !scroller.contains(parent)) return parent === scroller ? { x, y } : null;
|
|
101
|
+
node = parent;
|
|
102
|
+
}
|
|
103
|
+
return { x, y };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** How far `el` is out of its scroll view's visible area (0 when it is not in one). */
|
|
107
|
+
export function progressOf(el: HTMLElement, axis?: 'horizontal' | 'vertical'): number {
|
|
108
|
+
const scroller = el.parentElement?.closest<HTMLElement>('.sb-scrollview, .sb-list');
|
|
109
|
+
if (!scroller) return 0;
|
|
110
|
+
const origin = layoutOrigin(el, scroller);
|
|
111
|
+
if (!origin) return 0;
|
|
112
|
+
const horizontal = (axis ?? (scroller.dataset['sbAxes'] === 'horizontal' ? 'horizontal' : 'vertical')) === 'horizontal';
|
|
113
|
+
const length = horizontal ? el.offsetWidth : el.offsetHeight;
|
|
114
|
+
if (length <= 0) return 0;
|
|
115
|
+
const start = horizontal ? origin.x : origin.y;
|
|
116
|
+
const viewStart = horizontal ? scroller.scrollLeft : scroller.scrollTop;
|
|
117
|
+
const viewEnd = viewStart + (horizontal ? scroller.clientWidth : scroller.clientHeight);
|
|
118
|
+
if (start < viewStart - 0.5) return -Math.min(1, (viewStart - start) / length);
|
|
119
|
+
if (start + length > viewEnd + 0.5) return Math.min(1, (start + length - viewEnd) / length);
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function draw(el: HTMLElement): void {
|
|
124
|
+
const entry = registered.get(el);
|
|
125
|
+
if (!entry) return;
|
|
126
|
+
const effect = effectAt(entry.spec, el.isConnected ? progressOf(el, entry.spec.axis) : 0);
|
|
127
|
+
const css = effectsCSS(effect);
|
|
128
|
+
const s = el.style;
|
|
129
|
+
s.transform = [css.transform, entry.base.transform].filter(Boolean).join(' ');
|
|
130
|
+
if (css.transformOrigin) s.transformOrigin = css.transformOrigin;
|
|
131
|
+
s.filter = [entry.base.filter, css.filter].filter(Boolean).join(' ');
|
|
132
|
+
const opacity = entry.base.opacity * (effect.opacity ?? 1);
|
|
133
|
+
s.opacity = opacity === 1 ? '' : String(Math.round(opacity * 1000) / 1000);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Draws every transition at its box's current place: after a layout, and per frame while scrolling. */
|
|
137
|
+
export function drawScrollTransitions(): void {
|
|
138
|
+
for (const el of registered.keys()) {
|
|
139
|
+
if (!el.isConnected) {
|
|
140
|
+
registered.delete(el);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
draw(el);
|
|
144
|
+
}
|
|
145
|
+
}
|