@swiftbrowser/web 0.17.7 → 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/node/dist/node/apps.d.ts +5 -0
- package/node/dist/node/apps.d.ts.map +1 -1
- package/node/dist/node/apps.js +3 -0
- package/node/dist/node/apps.js.map +1 -1
- package/node/dist/node/config.d.ts +2 -0
- package/node/dist/node/config.d.ts.map +1 -1
- package/node/dist/node/config.js +2 -0
- package/node/dist/node/config.js.map +1 -1
- package/package.json +1 -1
- package/public/_headers +5 -0
- 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 +50 -5
- 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 +10 -1
- package/src/notifications.ts +134 -1
- package/src/pinch.ts +9 -1
- package/src/pinnedViews.ts +131 -0
- package/src/plainEditor.ts +79 -0
- package/src/protocol.ts +307 -14
- 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/vite-env.d.ts +2 -0
package/src/notifications.ts
CHANGED
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
* comes due; the page only answers for the permission and shows what it is
|
|
5
5
|
* told to, with the browser's Notification API. Nothing is scheduled here, so
|
|
6
6
|
* nothing fires once the page is closed.
|
|
7
|
+
*
|
|
8
|
+
* Remote notifications are Web Push ("Push notifications" there): the app's
|
|
9
|
+
* `registerForRemoteNotifications` becomes `pushSubscribe`, which registers
|
|
10
|
+
* the site's service worker (public/sw.js) and subscribes through it; the
|
|
11
|
+
* subscription is the app's device token. The worker shows each push and
|
|
12
|
+
* reports pushes and clicks to the page, which hands them to the app one
|
|
13
|
+
* `receive` request at a time.
|
|
7
14
|
*/
|
|
8
15
|
|
|
9
16
|
import type { NotificationRequestParams } from './protocol';
|
|
@@ -11,11 +18,26 @@ import type { NotificationRequestParams } from './protocol';
|
|
|
11
18
|
/** `NotificationPermission`, or no Notification API at all. */
|
|
12
19
|
export type NotificationPermissionState = NotificationPermission | 'unsupported';
|
|
13
20
|
|
|
21
|
+
/** A push subscription as `PushSubscription.toJSON()` gives it: what a server sends to. */
|
|
22
|
+
export interface PushSubscriptionInfo {
|
|
23
|
+
endpoint: string;
|
|
24
|
+
keys: { p256dh: string; auth: string };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What the service worker reports: a push that arrived, or a click on a notification it showed. */
|
|
28
|
+
export type PushEvent = { type: 'push'; payload: unknown } | { type: 'click'; payload: unknown; action: string };
|
|
29
|
+
|
|
14
30
|
export interface NotificationResult {
|
|
15
31
|
/** `status` and `request`. */
|
|
16
32
|
permission?: NotificationPermissionState;
|
|
17
33
|
/** `show`: whether a notification appeared. */
|
|
18
34
|
shown?: boolean;
|
|
35
|
+
/** `pushSubscribe`. */
|
|
36
|
+
subscription?: PushSubscriptionInfo;
|
|
37
|
+
/** `receive`: the event's fields. */
|
|
38
|
+
type?: PushEvent['type'];
|
|
39
|
+
payload?: unknown;
|
|
40
|
+
action?: string;
|
|
19
41
|
}
|
|
20
42
|
|
|
21
43
|
/** The browser's notification facilities; tests substitute them. */
|
|
@@ -28,6 +50,17 @@ export interface NotificationHost {
|
|
|
28
50
|
close(tags: readonly string[]): void;
|
|
29
51
|
/** `navigator.setAppBadge` (0 clears), where the browser has it. */
|
|
30
52
|
setBadge?(count: number): Promise<void>;
|
|
53
|
+
/** Web Push through the service worker, where the browser has it. */
|
|
54
|
+
push?: PushHost;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The push service, as the service worker reaches it; tests substitute it. */
|
|
58
|
+
export interface PushHost {
|
|
59
|
+
/** Subscribes with the key (registering the worker first); the subscription. */
|
|
60
|
+
subscribe(applicationServerKey: string): Promise<PushSubscriptionInfo>;
|
|
61
|
+
unsubscribe(): Promise<void>;
|
|
62
|
+
/** Resolves with the next push or click the worker reported. */
|
|
63
|
+
receive(): Promise<PushEvent>;
|
|
31
64
|
}
|
|
32
65
|
|
|
33
66
|
export async function performNotification(params: NotificationRequestParams, host: NotificationHost | undefined): Promise<NotificationResult> {
|
|
@@ -55,13 +88,29 @@ export async function performNotification(params: NotificationRequestParams, hos
|
|
|
55
88
|
case 'badge':
|
|
56
89
|
await host?.setBadge?.(params.badge ?? 0).catch(() => undefined);
|
|
57
90
|
return {};
|
|
91
|
+
case 'pushSubscribe': {
|
|
92
|
+
if (!host?.push) throw new Error('unsupported: this browser has no push service');
|
|
93
|
+
if (!params.applicationServerKey) throw new Error('push: no application server key (webPushKey in swiftbrowser.json)');
|
|
94
|
+
// A subscription needs the permission: asked for here when it has not been.
|
|
95
|
+
const granted = permission === 'default' ? await host.requestPermission() : permission;
|
|
96
|
+
if (granted !== 'granted') throw new Error('denied: notifications are not allowed');
|
|
97
|
+
return { subscription: await host.push.subscribe(params.applicationServerKey) };
|
|
98
|
+
}
|
|
99
|
+
case 'pushUnsubscribe':
|
|
100
|
+
await host?.push?.unsubscribe();
|
|
101
|
+
return {};
|
|
102
|
+
case 'receive': {
|
|
103
|
+
if (!host?.push) throw new Error('unsupported: this browser has no push service');
|
|
104
|
+
const event = await host.push.receive();
|
|
105
|
+
return event.type === 'click' ? { type: 'click', payload: event.payload, action: event.action } : { type: 'push', payload: event.payload };
|
|
106
|
+
}
|
|
58
107
|
default:
|
|
59
108
|
throw new Error(`unsupported: notification action ${String((params as { action: unknown }).action)}`);
|
|
60
109
|
}
|
|
61
110
|
}
|
|
62
111
|
|
|
63
112
|
/** The Notification API, or nothing where the browser has none (an iPhone tab not on the Home Screen). */
|
|
64
|
-
export function browserNotificationHost(): NotificationHost | undefined {
|
|
113
|
+
export function browserNotificationHost(push?: PushHost): NotificationHost | undefined {
|
|
65
114
|
if (typeof window === 'undefined' || typeof Notification === 'undefined') return undefined;
|
|
66
115
|
const open = new Map<string, Notification>();
|
|
67
116
|
const badgeNavigator = navigator as Navigator & { setAppBadge?: (n?: number) => Promise<void>; clearAppBadge?: () => Promise<void> };
|
|
@@ -105,5 +154,89 @@ export function browserNotificationHost(): NotificationHost | undefined {
|
|
|
105
154
|
count > 0 ? badgeNavigator.setAppBadge!(count) : (badgeNavigator.clearAppBadge?.() ?? Promise.resolve()),
|
|
106
155
|
}
|
|
107
156
|
: {}),
|
|
157
|
+
...(push ? { push } : {}),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The message types the service worker (public/sw.js) posts to the page. */
|
|
162
|
+
export const PUSH_MESSAGE = 'swiftbrowser:push';
|
|
163
|
+
export const CLICK_MESSAGE = 'swiftbrowser:notificationclick';
|
|
164
|
+
|
|
165
|
+
/** The push event a service worker message carries, or null when `data` is not one. */
|
|
166
|
+
export function pushEventOf(data: unknown): PushEvent | null {
|
|
167
|
+
if (typeof data !== 'object' || data === null) return null;
|
|
168
|
+
const message = data as { type?: unknown; payload?: unknown; action?: unknown };
|
|
169
|
+
if (message.type === PUSH_MESSAGE) return { type: 'push', payload: message.payload ?? {} };
|
|
170
|
+
if (message.type === CLICK_MESSAGE) return { type: 'click', payload: message.payload ?? {}, action: typeof message.action === 'string' ? message.action : '' };
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A VAPID public key (base64url, as servers print it) as the bytes `subscribe` takes. */
|
|
175
|
+
export function applicationServerKeyBytes(key: string): Uint8Array<ArrayBuffer> {
|
|
176
|
+
const base64 = key.replace(/-/g, '+').replace(/_/g, '/');
|
|
177
|
+
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
|
|
178
|
+
const binary = atob(padded);
|
|
179
|
+
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
|
180
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
181
|
+
return bytes;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The subscription's `toJSON()`, checked for the fields a server needs. */
|
|
185
|
+
function subscriptionInfo(subscription: PushSubscription): PushSubscriptionInfo {
|
|
186
|
+
const json = subscription.toJSON();
|
|
187
|
+
const keys = json.keys ?? {};
|
|
188
|
+
if (!json.endpoint || typeof keys['p256dh'] !== 'string' || typeof keys['auth'] !== 'string') throw new Error('push: the subscription has no keys');
|
|
189
|
+
return { endpoint: json.endpoint, keys: { p256dh: keys['p256dh'], auth: keys['auth'] } };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Web Push through the service worker at `workerURL` with `scope` (the
|
|
194
|
+
* site's base), or nothing where the browser has no push service (Safari
|
|
195
|
+
* before 16.4; an iPhone tab not on the Home Screen). Listens for the
|
|
196
|
+
* worker's messages from the start: a click that opened the page is posted
|
|
197
|
+
* before the app asks for it.
|
|
198
|
+
*/
|
|
199
|
+
export function browserPushHost(workerURL: string, scope: string): PushHost | undefined {
|
|
200
|
+
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator) || !navigator.serviceWorker || typeof PushManager === 'undefined') return undefined;
|
|
201
|
+
const container = navigator.serviceWorker;
|
|
202
|
+
const queue: PushEvent[] = [];
|
|
203
|
+
const waiting: ((event: PushEvent) => void)[] = [];
|
|
204
|
+
container.addEventListener('message', (message: MessageEvent) => {
|
|
205
|
+
const event = pushEventOf(message.data);
|
|
206
|
+
if (!event) return;
|
|
207
|
+
const wake = waiting.shift();
|
|
208
|
+
if (wake) wake(event);
|
|
209
|
+
else queue.push(event);
|
|
210
|
+
});
|
|
211
|
+
const registration = async (): Promise<ServiceWorkerRegistration> => {
|
|
212
|
+
const existing = await container.getRegistration(scope);
|
|
213
|
+
if (existing?.active && existing.scope === new URL(scope, location.href).href) return existing;
|
|
214
|
+
return container.register(workerURL, { scope });
|
|
215
|
+
};
|
|
216
|
+
return {
|
|
217
|
+
subscribe: async (key) => {
|
|
218
|
+
const worker = await registration();
|
|
219
|
+
await container.ready;
|
|
220
|
+
const options: PushSubscriptionOptionsInit = { userVisibleOnly: true, applicationServerKey: applicationServerKeyBytes(key) };
|
|
221
|
+
const current = await worker.pushManager.getSubscription();
|
|
222
|
+
try {
|
|
223
|
+
return subscriptionInfo(current ?? (await worker.pushManager.subscribe(options)));
|
|
224
|
+
} catch (error) {
|
|
225
|
+
// A subscription under another key stands in the way: replaced.
|
|
226
|
+
if (!current) throw error;
|
|
227
|
+
await current.unsubscribe().catch(() => undefined);
|
|
228
|
+
return subscriptionInfo(await worker.pushManager.subscribe(options));
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
unsubscribe: async () => {
|
|
232
|
+
const worker = await container.getRegistration(scope);
|
|
233
|
+
const current = await worker?.pushManager.getSubscription();
|
|
234
|
+
await current?.unsubscribe();
|
|
235
|
+
},
|
|
236
|
+
receive: () => {
|
|
237
|
+
const queued = queue.shift();
|
|
238
|
+
if (queued) return Promise.resolve(queued);
|
|
239
|
+
return new Promise((resolve) => waiting.push(resolve));
|
|
240
|
+
},
|
|
108
241
|
};
|
|
109
242
|
}
|
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
|
+
}
|