@swiftbrowser/web 0.3.1 → 0.4.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/README.md +3 -1
- package/package.json +4 -2
- package/src/animation.ts +154 -3
- package/src/dragDrop.ts +549 -0
- package/src/hostContent.ts +37 -0
- package/src/interaction.ts +128 -22
- package/src/largeTitle.ts +95 -54
- package/src/layout/engine.ts +415 -88
- package/src/layout/index.ts +5 -2
- package/src/layout/measure.ts +2 -0
- package/src/layout/text.ts +6 -3
- package/src/layout/types.ts +16 -1
- package/src/layout/typography.ts +30 -10
- package/src/layoutDom.ts +66 -16
- package/src/pointerEffects.ts +182 -0
- package/src/protocol.ts +188 -9
- package/src/renderer.ts +316 -15
- package/src/scrollEdge.ts +87 -0
- package/src/scrollTo.ts +6 -0
- package/src/styles.css +413 -16
- package/src/typography.ts +5 -2
package/src/dragDrop.ts
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reordering list rows and dragging views (docs/ops-protocol.md,
|
|
3
|
+
* "Reordering rows" and "Drag and drop"). The pointer sessions that start
|
|
4
|
+
* these live in src/interaction.ts; this module does the DOM work:
|
|
5
|
+
*
|
|
6
|
+
* - `RowReorder`: a movable `listrow` lifted by its edit-mode grip (or a long
|
|
7
|
+
* press) follows the pointer up and down among its movable neighbours,
|
|
8
|
+
* which slide out of its way; letting go settles it into its slot and
|
|
9
|
+
* sends `move` with the child index it goes before.
|
|
10
|
+
* - `ViewDrag`: a lifted `draggable` is copied and the copy follows the
|
|
11
|
+
* pointer over the screen; the `dropdestination` under it hears `drop`
|
|
12
|
+
* with `entered` / `exited`, and `performed` when the pointer lifts there.
|
|
13
|
+
* - `ExternalDrops`: text, links and files dragged in from outside the page
|
|
14
|
+
* (the browser's own drag and drop) reach a `dropdestination` whose
|
|
15
|
+
* `accepts` patterns match them, as `items`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { base64Encode } from './base64';
|
|
19
|
+
import type { LayoutNode } from './layout';
|
|
20
|
+
import type { DropEvent, DropItem, ElementId, RendererEvent } from './protocol';
|
|
21
|
+
|
|
22
|
+
/** Duration of the slide a lifted row or view settles with; keep in sync with styles.css. */
|
|
23
|
+
export const DRAG_SETTLE_MS = 300;
|
|
24
|
+
/** A pointer this close to a scroller's edge scrolls it while a row is lifted. */
|
|
25
|
+
const AUTOSCROLL_EDGE = 40;
|
|
26
|
+
/** Fastest autoscroll, in px per frame. */
|
|
27
|
+
const AUTOSCROLL_MAX = 10;
|
|
28
|
+
|
|
29
|
+
const ID_ATTR = 'sbId';
|
|
30
|
+
|
|
31
|
+
export interface DragDropHost {
|
|
32
|
+
readonly root: HTMLElement;
|
|
33
|
+
send(event: RendererEvent): void;
|
|
34
|
+
nodeOf(id: ElementId): LayoutNode | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function idOf(el: Element | null): ElementId | null {
|
|
38
|
+
const id = Number((el as HTMLElement | null)?.dataset[ID_ATTR]);
|
|
39
|
+
return Number.isInteger(id) ? id : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The CSS scale the screen is drawn at (the device frame scales it on small windows). */
|
|
43
|
+
function scaleOf(root: HTMLElement): number {
|
|
44
|
+
const rect = root.getBoundingClientRect();
|
|
45
|
+
return root.offsetWidth > 0 ? rect.width / root.offsetWidth : 1;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function round2(v: number): number {
|
|
49
|
+
return Math.round(v * 100) / 100;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Reordering rows
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One movable `listrow` being dragged. Its neighbours are the contiguous
|
|
58
|
+
* run of movable rows around it in the same card (a section's rows, or the
|
|
59
|
+
* loose rows of the list): the row cannot leave its `ForEach`.
|
|
60
|
+
*/
|
|
61
|
+
export class RowReorder {
|
|
62
|
+
/** The run of movable rows, in DOM order; `from` is the dragged row's index in it. */
|
|
63
|
+
private readonly run: HTMLElement[];
|
|
64
|
+
private readonly from: number;
|
|
65
|
+
private readonly tops: number[];
|
|
66
|
+
private readonly heights: number[];
|
|
67
|
+
private readonly container: HTMLElement;
|
|
68
|
+
private readonly scroller: HTMLElement | null;
|
|
69
|
+
private readonly scrollTop0: number;
|
|
70
|
+
private readonly scale: number;
|
|
71
|
+
/** The dragged row's place among the others (0 ... run.length - 1). */
|
|
72
|
+
private slot: number;
|
|
73
|
+
private dy = 0;
|
|
74
|
+
private clientY = 0;
|
|
75
|
+
private autoscroll: number | null = null;
|
|
76
|
+
private settleTimer: number | null = null;
|
|
77
|
+
private finished = false;
|
|
78
|
+
|
|
79
|
+
constructor(readonly row: HTMLElement, readonly id: ElementId, private readonly host: DragDropHost) {
|
|
80
|
+
this.container = row.parentElement as HTMLElement;
|
|
81
|
+
const siblings = Array.from(this.container.children) as HTMLElement[];
|
|
82
|
+
const index = siblings.indexOf(row);
|
|
83
|
+
const movable = (el: HTMLElement): boolean => el.classList.contains('sb-listrow') && el.hasAttribute('data-sb-movable') && !el.hidden;
|
|
84
|
+
let start = index;
|
|
85
|
+
while (start > 0 && movable(siblings[start - 1]!)) start -= 1;
|
|
86
|
+
let end = index;
|
|
87
|
+
while (end + 1 < siblings.length && movable(siblings[end + 1]!)) end += 1;
|
|
88
|
+
this.run = siblings.slice(start, end + 1);
|
|
89
|
+
this.from = index - start;
|
|
90
|
+
this.slot = this.from;
|
|
91
|
+
this.tops = this.run.map((el) => el.offsetTop);
|
|
92
|
+
this.heights = this.run.map((el) => el.offsetHeight);
|
|
93
|
+
this.scroller = row.closest<HTMLElement>('.sb-list');
|
|
94
|
+
this.scrollTop0 = this.scroller?.scrollTop ?? 0;
|
|
95
|
+
this.scale = scaleOf(host.root);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Lift the row: above its neighbours, with a shadow, following the pointer from now on. */
|
|
99
|
+
start(clientY: number): void {
|
|
100
|
+
this.clientY = clientY;
|
|
101
|
+
this.container.dataset['sbReordering'] = '';
|
|
102
|
+
this.row.dataset['sbLifting'] = '';
|
|
103
|
+
for (const el of this.run) el.style.transition = el === this.row ? 'none' : '';
|
|
104
|
+
this.apply();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The pointer moved `dy` px (client units) since the lift. */
|
|
108
|
+
move(dy: number, clientY: number): void {
|
|
109
|
+
if (this.finished) return;
|
|
110
|
+
this.dy = dy / this.scale;
|
|
111
|
+
this.clientY = clientY;
|
|
112
|
+
this.apply();
|
|
113
|
+
this.updateAutoscroll();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The pointer lifted: settle into the slot, then tell Swift (if the row moved). */
|
|
117
|
+
end(): void {
|
|
118
|
+
this.finish(this.slot);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The browser took the pointer: settle back where the row came from. */
|
|
122
|
+
cancel(): void {
|
|
123
|
+
this.finish(this.from);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Where the row is drawn now (its box offset by the drag), relative to the list origin. */
|
|
127
|
+
private get draggedCenter(): number {
|
|
128
|
+
const scrolled = (this.scroller?.scrollTop ?? 0) - this.scrollTop0;
|
|
129
|
+
return this.tops[this.from]! + this.heights[this.from]! / 2 + this.dy + scrolled;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private apply(): void {
|
|
133
|
+
const scrolled = (this.scroller?.scrollTop ?? 0) - this.scrollTop0;
|
|
134
|
+
this.row.style.transform = `translateY(${round2(this.dy + scrolled)}px)`;
|
|
135
|
+
// The others, in order: the slot is how many of them the dragged row is below.
|
|
136
|
+
const center = this.draggedCenter;
|
|
137
|
+
const others = this.run.filter((_, i) => i !== this.from);
|
|
138
|
+
let slot = 0;
|
|
139
|
+
for (let j = 0; j < others.length; j++) {
|
|
140
|
+
const i = j < this.from ? j : j + 1;
|
|
141
|
+
if (this.tops[i]! + this.heights[i]! / 2 < center) slot = j + 1;
|
|
142
|
+
}
|
|
143
|
+
this.slot = slot;
|
|
144
|
+
const height = this.heights[this.from]!;
|
|
145
|
+
others.forEach((el, j) => {
|
|
146
|
+
const i = j < this.from ? j : j + 1;
|
|
147
|
+
const shift = i < this.from && j >= slot ? height : i > this.from && j < slot ? -height : 0;
|
|
148
|
+
el.style.transform = shift === 0 ? '' : `translateY(${shift}px)`;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private updateAutoscroll(): void {
|
|
153
|
+
const scroller = this.scroller;
|
|
154
|
+
if (!scroller) return;
|
|
155
|
+
const rect = scroller.getBoundingClientRect();
|
|
156
|
+
const fromTop = this.clientY - rect.top;
|
|
157
|
+
const fromBottom = rect.bottom - this.clientY;
|
|
158
|
+
let speed = 0;
|
|
159
|
+
if (fromTop < AUTOSCROLL_EDGE * this.scale) speed = -AUTOSCROLL_MAX * (1 - Math.max(0, fromTop) / (AUTOSCROLL_EDGE * this.scale));
|
|
160
|
+
else if (fromBottom < AUTOSCROLL_EDGE * this.scale) speed = AUTOSCROLL_MAX * (1 - Math.max(0, fromBottom) / (AUTOSCROLL_EDGE * this.scale));
|
|
161
|
+
if (speed === 0) {
|
|
162
|
+
this.stopAutoscroll();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (this.autoscroll !== null) return;
|
|
166
|
+
const step = (): void => {
|
|
167
|
+
if (this.finished) return;
|
|
168
|
+
const before = scroller.scrollTop;
|
|
169
|
+
scroller.scrollTop += speed;
|
|
170
|
+
if (scroller.scrollTop === before) {
|
|
171
|
+
this.stopAutoscroll();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
this.apply();
|
|
175
|
+
this.autoscroll = requestAnimationFrame(step);
|
|
176
|
+
};
|
|
177
|
+
this.autoscroll = requestAnimationFrame(step);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private stopAutoscroll(): void {
|
|
181
|
+
if (this.autoscroll !== null) cancelAnimationFrame(this.autoscroll);
|
|
182
|
+
this.autoscroll = null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The row's top once it sits at `slot`, with the others moved aside (relative to the list origin). */
|
|
186
|
+
private settledTop(slot: number): number {
|
|
187
|
+
if (slot === this.from) return this.tops[this.from]!;
|
|
188
|
+
// Above its old place: it takes the top of the other now below it, which
|
|
189
|
+
// is the run's row `slot`. Below: it sits under the other above it, the
|
|
190
|
+
// run's row `slot` too (the others' index `slot - 1`, past the dragged
|
|
191
|
+
// row), which has moved up by the dragged row's height.
|
|
192
|
+
if (slot < this.from) return this.tops[slot]!;
|
|
193
|
+
return this.tops[slot]! + this.heights[slot]! - this.heights[this.from]!;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private finish(slot: number): void {
|
|
197
|
+
if (this.finished) return;
|
|
198
|
+
this.finished = true;
|
|
199
|
+
this.stopAutoscroll();
|
|
200
|
+
this.slot = slot;
|
|
201
|
+
// Slide the others to their places for this slot and the row into it.
|
|
202
|
+
const scrolled = (this.scroller?.scrollTop ?? 0) - this.scrollTop0;
|
|
203
|
+
this.row.style.transition = '';
|
|
204
|
+
this.row.dataset['sbSettling'] = '';
|
|
205
|
+
const height = this.heights[this.from]!;
|
|
206
|
+
this.run.forEach((el, i) => {
|
|
207
|
+
if (i === this.from) return;
|
|
208
|
+
const j = i < this.from ? i : i - 1;
|
|
209
|
+
const shift = i < this.from && j >= slot ? height : i > this.from && j < slot ? -height : 0;
|
|
210
|
+
el.style.transform = shift === 0 ? '' : `translateY(${shift}px)`;
|
|
211
|
+
});
|
|
212
|
+
const target = this.settledTop(slot) - this.tops[this.from]!;
|
|
213
|
+
const current = this.dy + scrolled;
|
|
214
|
+
this.row.style.transform = `translateY(${round2(target)}px)`;
|
|
215
|
+
const moved = slot !== this.from;
|
|
216
|
+
const done = (): void => {
|
|
217
|
+
this.settleTimer = null;
|
|
218
|
+
for (const el of this.run) {
|
|
219
|
+
el.style.transform = '';
|
|
220
|
+
el.style.transition = '';
|
|
221
|
+
}
|
|
222
|
+
delete this.row.dataset['sbLifting'];
|
|
223
|
+
delete this.row.dataset['sbSettling'];
|
|
224
|
+
delete this.container.dataset['sbReordering'];
|
|
225
|
+
if (moved) {
|
|
226
|
+
const to = this.destination(slot);
|
|
227
|
+
if (to !== null) this.host.send({ type: 'move', id: this.id, to });
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
// Nothing to slide: finish now, so the move reaches Swift within the gesture.
|
|
231
|
+
if (Math.abs(target - current) < 0.5) done();
|
|
232
|
+
else this.settleTimer = window.setTimeout(done, DRAG_SETTLE_MS);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* `move.to`: the index, among the container element's protocol children,
|
|
237
|
+
* of the row the dragged row now goes before (or the child count when it
|
|
238
|
+
* goes last), counted in the order before the move.
|
|
239
|
+
*/
|
|
240
|
+
private destination(slot: number): number | null {
|
|
241
|
+
const slotEl = this.container;
|
|
242
|
+
const containerEl = slotEl.closest<HTMLElement>('.sb-section, .sb-list');
|
|
243
|
+
const containerId = idOf(containerEl);
|
|
244
|
+
if (containerId === null) return null;
|
|
245
|
+
const children = this.host.nodeOf(containerId)?.children ?? [];
|
|
246
|
+
// The row now sits after `slot` others: the next one in the old order
|
|
247
|
+
// (skipping itself) is what it goes before.
|
|
248
|
+
const others = this.run.filter((_, i) => i !== this.from);
|
|
249
|
+
const next = others[slot] ?? null;
|
|
250
|
+
if (next === null) {
|
|
251
|
+
// After the run's last row: before whatever followed the run, else last.
|
|
252
|
+
const last = this.run[this.run.length - 1]!;
|
|
253
|
+
const following = last === this.row ? this.run[this.run.length - 2] ?? null : last;
|
|
254
|
+
const afterRun = (following ?? this.row).nextElementSibling;
|
|
255
|
+
const afterId = idOf(afterRun);
|
|
256
|
+
if (afterId === null) return children.length;
|
|
257
|
+
const index = children.findIndex((child) => child.id === afterId);
|
|
258
|
+
return index < 0 ? children.length : index;
|
|
259
|
+
}
|
|
260
|
+
const nextId = idOf(next);
|
|
261
|
+
if (nextId === null) return null;
|
|
262
|
+
const index = children.findIndex((child) => child.id === nextId);
|
|
263
|
+
return index < 0 ? null : index;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Whether the row is still sliding into place (the `move` is yet to go out). */
|
|
267
|
+
get settling(): boolean {
|
|
268
|
+
return this.settleTimer !== null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// Dragging views
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* A lifted `draggable`: a copy of its element follows the pointer above
|
|
278
|
+
* everything else (the original dims), and the `dropdestination` under the
|
|
279
|
+
* pointer is told as the copy enters, leaves and drops.
|
|
280
|
+
*/
|
|
281
|
+
export class ViewDrag {
|
|
282
|
+
private readonly preview: HTMLElement;
|
|
283
|
+
private readonly origin: { left: number; top: number };
|
|
284
|
+
private readonly scale: number;
|
|
285
|
+
private target: HTMLElement | null = null;
|
|
286
|
+
private finished = false;
|
|
287
|
+
|
|
288
|
+
constructor(readonly el: HTMLElement, readonly id: ElementId, private readonly host: DragDropHost) {
|
|
289
|
+
this.scale = scaleOf(host.root);
|
|
290
|
+
const rootRect = host.root.getBoundingClientRect();
|
|
291
|
+
const rect = el.getBoundingClientRect();
|
|
292
|
+
this.origin = { left: (rect.left - rootRect.left) / this.scale, top: (rect.top - rootRect.top) / this.scale };
|
|
293
|
+
const preview = el.cloneNode(true) as HTMLElement;
|
|
294
|
+
// The copy is not an element of the tree: nothing may find it by id.
|
|
295
|
+
for (const node of [preview, ...Array.from(preview.querySelectorAll<HTMLElement>('[data-sb-id]'))]) {
|
|
296
|
+
delete node.dataset[ID_ATTR];
|
|
297
|
+
node.removeAttribute('data-sb-kind');
|
|
298
|
+
}
|
|
299
|
+
preview.classList.add('sb-drag-preview');
|
|
300
|
+
preview.style.position = 'absolute';
|
|
301
|
+
preview.style.left = `${round2(this.origin.left)}px`;
|
|
302
|
+
preview.style.top = `${round2(this.origin.top)}px`;
|
|
303
|
+
preview.style.width = `${round2(rect.width / this.scale)}px`;
|
|
304
|
+
preview.style.height = `${round2(rect.height / this.scale)}px`;
|
|
305
|
+
preview.style.margin = '0';
|
|
306
|
+
this.preview = preview;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Lift: the copy appears over the original, which dims; Swift makes the payload. */
|
|
310
|
+
start(): void {
|
|
311
|
+
this.host.root.append(this.preview);
|
|
312
|
+
this.el.dataset['sbDragging'] = '';
|
|
313
|
+
this.host.root.dataset['sbDragging'] = '';
|
|
314
|
+
this.host.send({ type: 'dragstart', id: this.id });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
move(dx: number, dy: number, clientX: number, clientY: number): void {
|
|
318
|
+
if (this.finished) return;
|
|
319
|
+
this.preview.style.transform = `translate(${round2(dx / this.scale)}px, ${round2(dy / this.scale)}px)`;
|
|
320
|
+
this.retarget(this.destinationAt(clientX, clientY));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The pointer lifted: drop on the destination under it, or fly back. */
|
|
324
|
+
end(clientX: number, clientY: number): void {
|
|
325
|
+
if (this.finished) return;
|
|
326
|
+
this.finished = true;
|
|
327
|
+
const target = this.destinationAt(clientX, clientY);
|
|
328
|
+
this.retarget(target);
|
|
329
|
+
if (target) {
|
|
330
|
+
const id = idOf(target);
|
|
331
|
+
const rect = target.getBoundingClientRect();
|
|
332
|
+
if (id !== null) {
|
|
333
|
+
this.host.send({
|
|
334
|
+
type: 'drop',
|
|
335
|
+
id,
|
|
336
|
+
phase: 'performed',
|
|
337
|
+
source: this.id,
|
|
338
|
+
x: round2((clientX - rect.left) / this.scale),
|
|
339
|
+
y: round2((clientY - rect.top) / this.scale),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
delete target.dataset['sbTargeted'];
|
|
343
|
+
this.preview.remove();
|
|
344
|
+
this.release();
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
this.flyBack();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** The browser took the pointer: the copy flies back, nothing is dropped. */
|
|
351
|
+
cancel(): void {
|
|
352
|
+
if (this.finished) return;
|
|
353
|
+
this.finished = true;
|
|
354
|
+
this.retarget(null);
|
|
355
|
+
this.flyBack();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private release(): void {
|
|
359
|
+
delete this.el.dataset['sbDragging'];
|
|
360
|
+
delete this.host.root.dataset['sbDragging'];
|
|
361
|
+
this.host.send({ type: 'dragend', id: this.id });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private flyBack(): void {
|
|
365
|
+
const preview = this.preview;
|
|
366
|
+
preview.dataset['sbSettling'] = '';
|
|
367
|
+
preview.style.transform = '';
|
|
368
|
+
window.setTimeout(() => {
|
|
369
|
+
preview.remove();
|
|
370
|
+
this.release();
|
|
371
|
+
}, DRAG_SETTLE_MS);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** The `dropdestination` under the point (the copy lets the pointer through). */
|
|
375
|
+
private destinationAt(clientX: number, clientY: number): HTMLElement | null {
|
|
376
|
+
const under = document.elementFromPoint(clientX, clientY);
|
|
377
|
+
const destination = under?.closest<HTMLElement>('.sb-dropdestination') ?? null;
|
|
378
|
+
return destination && this.host.root.contains(destination) ? destination : null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private retarget(target: HTMLElement | null): void {
|
|
382
|
+
if (target === this.target) return;
|
|
383
|
+
if (this.target) {
|
|
384
|
+
delete this.target.dataset['sbTargeted'];
|
|
385
|
+
this.notify(this.target, 'exited');
|
|
386
|
+
}
|
|
387
|
+
this.target = target;
|
|
388
|
+
if (target) {
|
|
389
|
+
target.dataset['sbTargeted'] = '';
|
|
390
|
+
this.notify(target, 'entered');
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private notify(target: HTMLElement, phase: 'entered' | 'exited'): void {
|
|
395
|
+
const id = idOf(target);
|
|
396
|
+
if (id !== null) this.host.send({ type: 'drop', id, phase, source: this.id, x: 0, y: 0 });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
// Drops from outside the page
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
/** Whether a MIME type matches one of a destination's `accepts` patterns. */
|
|
405
|
+
export function acceptsType(patterns: readonly string[], type: string): boolean {
|
|
406
|
+
const mime = type.split(';')[0]!.trim().toLowerCase() || 'application/octet-stream';
|
|
407
|
+
return patterns.some((pattern) => {
|
|
408
|
+
const p = pattern.toLowerCase();
|
|
409
|
+
if (p === '*/*') return true;
|
|
410
|
+
if (p.endsWith('/*')) return mime.startsWith(p.slice(0, -1));
|
|
411
|
+
return mime === p;
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** The MIME types a browser drag offers: its string items' types, and its files' (kind `file`). */
|
|
416
|
+
function offeredTypes(transfer: DataTransfer): string[] {
|
|
417
|
+
const types: string[] = [];
|
|
418
|
+
for (const item of Array.from(transfer.items ?? [])) {
|
|
419
|
+
types.push(item.kind === 'file' ? item.type || 'application/octet-stream' : item.type);
|
|
420
|
+
}
|
|
421
|
+
if (types.length === 0) types.push(...Array.from(transfer.types));
|
|
422
|
+
return types;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function patternsOf(destination: HTMLElement): string[] {
|
|
426
|
+
return (destination.dataset['sbAccepts'] ?? '').split(' ').filter((p) => p.length > 0);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The browser's drag and drop into the screen: a `dropdestination` whose
|
|
431
|
+
* `accepts` match what is dragged hears `entered` / `exited` as the drag
|
|
432
|
+
* passes, and `performed` with the `items` when it drops. Dropping anywhere
|
|
433
|
+
* on the screen never navigates the page away.
|
|
434
|
+
*/
|
|
435
|
+
export class ExternalDrops {
|
|
436
|
+
private target: HTMLElement | null = null;
|
|
437
|
+
|
|
438
|
+
constructor(private readonly host: DragDropHost) {
|
|
439
|
+
const root = host.root;
|
|
440
|
+
root.addEventListener('dragenter', this.onDragOver);
|
|
441
|
+
root.addEventListener('dragover', this.onDragOver);
|
|
442
|
+
root.addEventListener('dragleave', this.onDragLeave);
|
|
443
|
+
root.addEventListener('drop', this.onDrop);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
dispose(): void {
|
|
447
|
+
const root = this.host.root;
|
|
448
|
+
root.removeEventListener('dragenter', this.onDragOver);
|
|
449
|
+
root.removeEventListener('dragover', this.onDragOver);
|
|
450
|
+
root.removeEventListener('dragleave', this.onDragLeave);
|
|
451
|
+
root.removeEventListener('drop', this.onDrop);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** The destination under `target` that takes what the drag carries. */
|
|
455
|
+
private destinationFor(target: EventTarget | null, transfer: DataTransfer | null): HTMLElement | null {
|
|
456
|
+
const destination = (target as Element | null)?.closest?.<HTMLElement>('.sb-dropdestination') ?? null;
|
|
457
|
+
if (!destination || !transfer || !this.host.root.contains(destination)) return null;
|
|
458
|
+
const patterns = patternsOf(destination);
|
|
459
|
+
return offeredTypes(transfer).some((type) => acceptsType(patterns, type)) ? destination : null;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private readonly onDragOver = (event: DragEvent): void => {
|
|
463
|
+
// A drag the page itself started (a `draggable` lifts through pointer
|
|
464
|
+
// events, not the browser's) never comes this way.
|
|
465
|
+
const destination = this.destinationFor(event.target, event.dataTransfer);
|
|
466
|
+
this.retarget(destination);
|
|
467
|
+
if (destination) {
|
|
468
|
+
event.preventDefault();
|
|
469
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
private readonly onDragLeave = (event: DragEvent): void => {
|
|
474
|
+
const to = event.relatedTarget as Node | null;
|
|
475
|
+
if (!to || !this.host.root.contains(to)) this.retarget(null);
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
private readonly onDrop = (event: DragEvent): void => {
|
|
479
|
+
// Never let the browser open a dropped file in place of the app.
|
|
480
|
+
event.preventDefault();
|
|
481
|
+
const destination = this.destinationFor(event.target, event.dataTransfer);
|
|
482
|
+
const transfer = event.dataTransfer;
|
|
483
|
+
this.retarget(null);
|
|
484
|
+
if (!destination || !transfer) return;
|
|
485
|
+
const id = idOf(destination);
|
|
486
|
+
if (id === null) return;
|
|
487
|
+
const rect = destination.getBoundingClientRect();
|
|
488
|
+
const scale = scaleOf(this.host.root);
|
|
489
|
+
const point = { x: round2((event.clientX - rect.left) / scale), y: round2((event.clientY - rect.top) / scale) };
|
|
490
|
+
const patterns = patternsOf(destination);
|
|
491
|
+
void readItems(transfer, patterns).then((items) => {
|
|
492
|
+
if (items.length === 0) return;
|
|
493
|
+
const drop: DropEvent = { type: 'drop', id, phase: 'performed', items, x: point.x, y: point.y };
|
|
494
|
+
this.host.send(drop);
|
|
495
|
+
});
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
private retarget(target: HTMLElement | null): void {
|
|
499
|
+
if (target === this.target) return;
|
|
500
|
+
if (this.target) {
|
|
501
|
+
delete this.target.dataset['sbTargeted'];
|
|
502
|
+
this.notify(this.target, 'exited');
|
|
503
|
+
}
|
|
504
|
+
this.target = target;
|
|
505
|
+
if (target) {
|
|
506
|
+
target.dataset['sbTargeted'] = '';
|
|
507
|
+
this.notify(target, 'entered');
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private notify(target: HTMLElement, phase: 'entered' | 'exited'): void {
|
|
512
|
+
const id = idOf(target);
|
|
513
|
+
if (id !== null) this.host.send({ type: 'drop', id, phase, x: 0, y: 0 });
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** The drag's items the patterns accept: strings as text, files as base64 bytes. */
|
|
518
|
+
export async function readItems(transfer: DataTransfer, patterns: readonly string[]): Promise<DropItem[]> {
|
|
519
|
+
const reads: Promise<DropItem | null>[] = [];
|
|
520
|
+
const items = Array.from(transfer.items ?? []);
|
|
521
|
+
if (items.length > 0) {
|
|
522
|
+
for (const item of items) {
|
|
523
|
+
if (item.kind === 'file') {
|
|
524
|
+
const file = item.getAsFile();
|
|
525
|
+
const type = file?.type || 'application/octet-stream';
|
|
526
|
+
if (!file || !acceptsType(patterns, type)) continue;
|
|
527
|
+
reads.push(readFile(file, type));
|
|
528
|
+
} else if (item.kind === 'string' && (item.type === 'text/plain' || item.type === 'text/uri-list') && acceptsType(patterns, item.type)) {
|
|
529
|
+
const type = item.type;
|
|
530
|
+
reads.push(new Promise((resolve) => item.getAsString((text) => resolve({ type, text }))));
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
} else {
|
|
534
|
+
// Without `items` (an older browser, a synthetic transfer): the strings only.
|
|
535
|
+
for (const type of ['text/plain', 'text/uri-list']) {
|
|
536
|
+
const text = transfer.getData(type);
|
|
537
|
+
if (text && acceptsType(patterns, type)) reads.push(Promise.resolve({ type, text }));
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
const results = await Promise.all(reads);
|
|
541
|
+
return results.filter((item): item is DropItem => item !== null);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function readFile(file: File, type: string): Promise<DropItem | null> {
|
|
545
|
+
return file
|
|
546
|
+
.arrayBuffer()
|
|
547
|
+
.then((buffer) => ({ type, name: file.name, data: base64Encode(new Uint8Array(buffer)) }))
|
|
548
|
+
.catch(() => null);
|
|
549
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `host` elements: a box the page fills with its own DOM. The producer of the
|
|
3
|
+
* ops (a React tree through @swiftbrowser/react's `Host`, or any page script)
|
|
4
|
+
* registers an element under a key and emits `{ kind: "host", props: {
|
|
5
|
+
* content: key, sizing } }`; the renderer adopts the element into the host's
|
|
6
|
+
* box and the layout engine measures it (src/layout, `measureHost`). When
|
|
7
|
+
* the content changes size on its own, `notifyHostContentChange` asks the
|
|
8
|
+
* renderer to lay the tree out again. DOM-free apart from the element type,
|
|
9
|
+
* so the registry can be imported by headless code.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const contents = new Map<string, HTMLElement>();
|
|
13
|
+
const listeners = new Set<(key: string) => void>();
|
|
14
|
+
|
|
15
|
+
export function registerHostContent(key: string, element: HTMLElement): void {
|
|
16
|
+
contents.set(key, element);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function unregisterHostContent(key: string): void {
|
|
20
|
+
contents.delete(key);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function hostContent(key: string): HTMLElement | undefined {
|
|
24
|
+
return contents.get(key);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Called with the key whose content changed (its size, most often); returns the unsubscribe. */
|
|
28
|
+
export function onHostContentChange(listener: (key: string) => void): () => void {
|
|
29
|
+
listeners.add(listener);
|
|
30
|
+
return () => {
|
|
31
|
+
listeners.delete(listener);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function notifyHostContentChange(key: string): void {
|
|
36
|
+
for (const listener of listeners) listener(key);
|
|
37
|
+
}
|