@swiftbrowser/web 0.19.0 → 0.23.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/app/index.html +11 -7
- package/package.json +1 -1
- package/src/appFiles.ts +12 -3
- package/src/audioEngine.ts +29 -5
- package/src/deviceFrame.ts +188 -0
- package/src/dragDrop.ts +66 -10
- package/src/editorSelection.ts +38 -3
- package/src/files.ts +68 -13
- package/src/hostRequests.ts +43 -3
- package/src/interaction.ts +6 -1
- package/src/layout/engine.ts +2 -0
- package/src/main.ts +12 -1
- package/src/page.css +65 -0
- package/src/pinnedViews.ts +38 -1
- package/src/plainEditor.ts +24 -3
- package/src/protocol.ts +41 -1
- package/src/renderer.ts +146 -14
- package/src/scrollTransition.ts +2 -1
- package/src/styles.css +167 -7
- package/src/styles.ts +9 -6
- package/src/viewRegistry.ts +58 -0
package/app/index.html
CHANGED
|
@@ -40,9 +40,16 @@
|
|
|
40
40
|
var coarse = typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches;
|
|
41
41
|
var device = mode === 'device' || (mode !== 'frame' && (coarse || site.pageMode === 'device'));
|
|
42
42
|
document.documentElement.setAttribute('data-sb-mode', device ? 'device' : 'frame');
|
|
43
|
+
// The preview's device (src/deviceFrame.ts in @swiftbrowser/web): ?device=, else the last one picked.
|
|
44
|
+
var frame = new URLSearchParams(location.search).get('device');
|
|
45
|
+
var embedded = new URLSearchParams(location.search).get('embed') === '1';
|
|
46
|
+
try { frame = frame || (embedded ? null : localStorage.getItem('sb-device-frame')); } catch (e) {}
|
|
47
|
+
document.documentElement.setAttribute('data-sb-frame', frame === 'ipad' || frame === 'none' ? frame : 'iphone');
|
|
48
|
+
// ?toolbar=0: the toolbar's close button hid it (src/deviceFrame.ts).
|
|
49
|
+
if (new URLSearchParams(location.search).get('toolbar') === '0') document.documentElement.setAttribute('data-sb-toolbar', 'hidden');
|
|
43
50
|
// ?embed=1: the page is in another's frame (the playground's
|
|
44
51
|
// simulator), which has its own controls: the device alone.
|
|
45
|
-
if (
|
|
52
|
+
if (embedded) document.documentElement.setAttribute('data-sb-embed', '');
|
|
46
53
|
})();
|
|
47
54
|
// Point the manifest, the home-screen icon and the titles at the app
|
|
48
55
|
// this page runs (?app=, else the site's default), so Share -> Add to
|
|
@@ -73,9 +80,6 @@
|
|
|
73
80
|
</head>
|
|
74
81
|
<body>
|
|
75
82
|
<header class="toolbar" role="toolbar" aria-label="SwiftBrowser controls">
|
|
76
|
-
<span class="toolbar-brand">SwiftBrowser</span>
|
|
77
|
-
<span class="toolbar-sep" aria-hidden="true"></span>
|
|
78
|
-
<span class="toolbar-label">App</span>
|
|
79
83
|
<span id="app-name" class="toolbar-value">Loading…</span>
|
|
80
84
|
<span class="toolbar-spacer"></span>
|
|
81
85
|
<span id="build-status" class="toolbar-build" role="status" aria-live="polite" hidden>Rebuilding…</span>
|
|
@@ -114,9 +118,9 @@
|
|
|
114
118
|
</svg>
|
|
115
119
|
<!-- Wi-Fi -->
|
|
116
120
|
<svg class="glyph glyph-wifi" viewBox="0 0 16.3 12.33" width="16.3" height="12.33">
|
|
117
|
-
<path
|
|
118
|
-
<path
|
|
119
|
-
<path d="M8.15 12.
|
|
121
|
+
<path d="M0.02 3.3A12.15 12.15 0 0 1 16.28 3.3L14.41 5.38A9.35 9.35 0 0 0 1.89 5.38Z" />
|
|
122
|
+
<path d="M2.8 6.38A8 8 0 0 1 13.5 6.38L11.66 8.43A5.25 5.25 0 0 0 4.64 8.43Z" />
|
|
123
|
+
<path d="M8.15 12.33L5.54 9.43A3.9 3.9 0 0 1 10.76 9.43Z" />
|
|
120
124
|
</svg>
|
|
121
125
|
<!-- Battery -->
|
|
122
126
|
<svg class="glyph glyph-battery" viewBox="0 0 28 13" width="28" height="13">
|
package/package.json
CHANGED
package/src/appFiles.ts
CHANGED
|
@@ -125,10 +125,15 @@ export interface FilesPersistence {
|
|
|
125
125
|
clear(): Promise<void>;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
/**
|
|
128
|
+
/**
|
|
129
|
+
* A row of the `entries` store: a file's bytes or a directory, with when it
|
|
130
|
+
* was last written where the writer said. A React app's `fileManager`
|
|
131
|
+
* (files.ts) may keep a file as the `Blob` the viewer handed over, which
|
|
132
|
+
* IndexedDB stores without reading; the module's loader reads one on load.
|
|
133
|
+
*/
|
|
129
134
|
export interface EntryRow {
|
|
130
135
|
path: string;
|
|
131
|
-
data?: Uint8Array;
|
|
136
|
+
data?: Uint8Array | Blob;
|
|
132
137
|
directory?: boolean;
|
|
133
138
|
/** Milliseconds since the epoch; rows the module wrote (and older rows) have none. */
|
|
134
139
|
modified?: number;
|
|
@@ -181,7 +186,11 @@ export function indexedDBFiles(app: string, factory: IDBFactory = indexedDB): Fi
|
|
|
181
186
|
const db = await database();
|
|
182
187
|
const rows = await request(db.transaction(ENTRIES, 'readonly').objectStore(ENTRIES).getAll() as IDBRequest<EntryRow[]>);
|
|
183
188
|
const entries: HomeEntries = new Map();
|
|
184
|
-
for (const row of rows)
|
|
189
|
+
for (const row of rows) {
|
|
190
|
+
if (row.directory) entries.set(row.path, null);
|
|
191
|
+
else if (row.data !== undefined && typeof Blob !== 'undefined' && row.data instanceof Blob) entries.set(row.path, new Uint8Array(await row.data.arrayBuffer()));
|
|
192
|
+
else entries.set(row.path, new Uint8Array((row.data as Uint8Array | undefined) ?? new Uint8Array()));
|
|
193
|
+
}
|
|
185
194
|
return entries;
|
|
186
195
|
},
|
|
187
196
|
apply(changes) {
|
package/src/audioEngine.ts
CHANGED
|
@@ -68,10 +68,11 @@ export type AudioEngineCommand =
|
|
|
68
68
|
| { action: 'playerTime'; node: number }
|
|
69
69
|
| { action: 'loadZones'; node: number; zones: SamplerZone[]; attack: number; release: number }
|
|
70
70
|
| { action: 'loadInstrument'; node: number; url: string; program?: number; bankMSB?: number; bankLSB?: number }
|
|
71
|
-
|
|
72
|
-
| { action: '
|
|
73
|
-
| { action: '
|
|
74
|
-
| { action: '
|
|
71
|
+
// `beat`: when on a package node's transport (a DSP program's); a sampler ignores it.
|
|
72
|
+
| { action: 'startNote'; node: number; note: number; velocity: number; channel: number; beat?: number }
|
|
73
|
+
| { action: 'stopNote'; node: number; note: number; channel: number; beat?: number }
|
|
74
|
+
| { action: 'controller'; node: number; controller: number; value: number; channel: number; beat?: number }
|
|
75
|
+
| { action: 'pitchBend'; node: number; value: number; channel: number; beat?: number }
|
|
75
76
|
| { action: 'meter'; node: number };
|
|
76
77
|
|
|
77
78
|
/** The note commands an instrument takes: a sampler, or a node of a kind a package adds. */
|
|
@@ -149,7 +150,7 @@ export function browserAudioEngine(files: () => FilesHost | undefined): AudioEng
|
|
|
149
150
|
if (typeof AudioContext === 'undefined') return undefined;
|
|
150
151
|
let context: AudioContext | null = null;
|
|
151
152
|
return new WebAudioEngineHost({
|
|
152
|
-
context: () => (context ??= new AudioContext({ latencyHint: 'interactive' })),
|
|
153
|
+
context: () => (context ??= resumeOnActivation(new AudioContext({ latencyHint: 'interactive' }))),
|
|
153
154
|
read: async (url) => {
|
|
154
155
|
const host = files();
|
|
155
156
|
if (url.startsWith('file:') && host) {
|
|
@@ -166,6 +167,29 @@ export function browserAudioEngine(files: () => FilesHost | undefined): AudioEng
|
|
|
166
167
|
});
|
|
167
168
|
}
|
|
168
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Resumes `context` at each user activation while it is not running. A
|
|
172
|
+
* page's context starts only inside one, and a touch's start is not one
|
|
173
|
+
* (Safari and Chrome count its end, a click, a key): an app that starts its
|
|
174
|
+
* engine as a finger lands, a keyboard's note, would otherwise leave the
|
|
175
|
+
* context suspended and the engine silent. Safari also suspends or
|
|
176
|
+
* interrupts a running context (a call, the page in the background), and the
|
|
177
|
+
* next tap brings it back.
|
|
178
|
+
*/
|
|
179
|
+
function resumeOnActivation(context: AudioContext): AudioContext {
|
|
180
|
+
if (typeof document === 'undefined') return context;
|
|
181
|
+
const events = ['touchend', 'pointerup', 'click', 'keydown'] as const;
|
|
182
|
+
const resume = (): void => {
|
|
183
|
+
if (context.state === 'closed') {
|
|
184
|
+
for (const type of events) document.removeEventListener(type, resume, true);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (context.state !== 'running') context.resume().catch(() => undefined);
|
|
188
|
+
};
|
|
189
|
+
for (const type of events) document.addEventListener(type, resume, { capture: true, passive: true });
|
|
190
|
+
return context;
|
|
191
|
+
}
|
|
192
|
+
|
|
169
193
|
/** Decibels as a gain. */
|
|
170
194
|
export const gainOf = (decibels: number): number => Math.pow(10, decibels / 20);
|
|
171
195
|
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which device a desktop preview draws the app in (frame mode only; a real
|
|
3
|
+
* phone or tablet is device mode, deviceMode.ts): an iPhone (the default),
|
|
4
|
+
* an iPad in landscape, or no device at all, the app filling the window
|
|
5
|
+
* under the toolbar. styles.css and page.css draw each from the document's
|
|
6
|
+
* `data-sb-frame`; the layout engine gets the device's safe area, and its
|
|
7
|
+
* size from the screen's box, so an iPad (or a wide window) lays out in the
|
|
8
|
+
* regular size class, sidebars and all.
|
|
9
|
+
*
|
|
10
|
+
* `?device=iphone|ipad|none` picks one (and is what the picker writes, so a
|
|
11
|
+
* link shares it); otherwise the last one picked on this site, from
|
|
12
|
+
* localStorage. The toolbar's close button (an x in a circle, at its end)
|
|
13
|
+
* hides the toolbar and writes `?toolbar=0`, so a link shows the app alone;
|
|
14
|
+
* without that parameter the toolbar is back. The pages' inline scripts apply the same rule before the
|
|
15
|
+
* first paint (app/index.html's, and the native apps' web/index.html), so
|
|
16
|
+
* an iPad never flashes an iPhone first.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { EdgeInsets } from './layout';
|
|
20
|
+
|
|
21
|
+
export type DeviceFrame = 'iphone' | 'ipad' | 'none';
|
|
22
|
+
|
|
23
|
+
export const DEVICE_FRAMES: readonly { id: DeviceFrame; label: string }[] = [
|
|
24
|
+
{ id: 'iphone', label: 'iPhone' },
|
|
25
|
+
{ id: 'ipad', label: 'iPad' },
|
|
26
|
+
{ id: 'none', label: 'None' },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The safe area each device gives the app: an iPhone 15 Pro's status bar and
|
|
31
|
+
* home indicator, an iPad's (24 and 20 points, as iPadOS draws them in
|
|
32
|
+
* landscape), and none without a device.
|
|
33
|
+
*/
|
|
34
|
+
export const FRAME_SAFE_AREAS: Readonly<Record<DeviceFrame, EdgeInsets>> = {
|
|
35
|
+
iphone: { top: 59, leading: 0, bottom: 34, trailing: 0 },
|
|
36
|
+
ipad: { top: 24, leading: 0, bottom: 20, trailing: 0 },
|
|
37
|
+
none: { top: 0, leading: 0, bottom: 0, trailing: 0 },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Where the last device picked is kept. */
|
|
41
|
+
export const DEVICE_FRAME_KEY = 'sb-device-frame';
|
|
42
|
+
|
|
43
|
+
const isFrame = (value: unknown): value is DeviceFrame => value === 'iphone' || value === 'ipad' || value === 'none';
|
|
44
|
+
|
|
45
|
+
/** `?device=`, else the stored choice, else the iPhone. */
|
|
46
|
+
export function decideDeviceFrame(search: string = location.search, storage: Pick<Storage, 'getItem'> | null = safeStorage()): DeviceFrame {
|
|
47
|
+
const asked = new URLSearchParams(search).get('device');
|
|
48
|
+
if (isFrame(asked)) return asked;
|
|
49
|
+
let stored: string | null = null;
|
|
50
|
+
try {
|
|
51
|
+
stored = storage?.getItem(DEVICE_FRAME_KEY) ?? null;
|
|
52
|
+
} catch {
|
|
53
|
+
// Storage blocked: the default.
|
|
54
|
+
}
|
|
55
|
+
return isFrame(stored) ? stored : 'iphone';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Marks the document with the device (`data-sb-frame`) and gives the status
|
|
60
|
+
* bar the date an iPad shows beside the time.
|
|
61
|
+
*/
|
|
62
|
+
export function applyDeviceFrame(frame: DeviceFrame, doc: Document = document): void {
|
|
63
|
+
doc.documentElement.dataset['sbFrame'] = frame;
|
|
64
|
+
const time = doc.querySelector('.status-time');
|
|
65
|
+
if (time && !time.querySelector('.status-date')) {
|
|
66
|
+
const date = doc.createElement('span');
|
|
67
|
+
date.className = 'status-date';
|
|
68
|
+
date.textContent = 'Tue Jan 9';
|
|
69
|
+
time.append(date);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What the picker drives: the renderer (or a mounted React root's). */
|
|
74
|
+
export interface DeviceFrameTarget {
|
|
75
|
+
setSafeArea(insets: EdgeInsets | null): void;
|
|
76
|
+
viewportChanged(): void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface DeviceFramePickerOptions {
|
|
80
|
+
/** The page's toolbar, which gets the picker. */
|
|
81
|
+
toolbar?: HTMLElement | null;
|
|
82
|
+
/** The toolbar item the picker goes before; the theme toggle by default, else the end. */
|
|
83
|
+
before?: Element | null;
|
|
84
|
+
search?: string;
|
|
85
|
+
storage?: Pick<Storage, 'getItem' | 'setItem'> | null;
|
|
86
|
+
/** Called after each change of device, the new one applied. */
|
|
87
|
+
onChange?: (frame: DeviceFrame) => void;
|
|
88
|
+
/** The close button at the toolbar's end (`installToolbarClose`); on unless false. */
|
|
89
|
+
closeButton?: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A desktop preview's device: applies the chosen one, lays the app out for
|
|
94
|
+
* it, and adds a Device picker to the toolbar that switches it in place (the
|
|
95
|
+
* app keeps its state) and remembers it. Does nothing in device mode, where
|
|
96
|
+
* the page is the device. Returns the device in use.
|
|
97
|
+
*/
|
|
98
|
+
export function installDeviceFrame(target: DeviceFrameTarget, options: DeviceFramePickerOptions = {}): DeviceFrame {
|
|
99
|
+
const doc = document;
|
|
100
|
+
if (doc.documentElement.dataset['sbMode'] === 'device') return 'iphone';
|
|
101
|
+
const storage = options.storage === undefined ? safeStorage() : options.storage;
|
|
102
|
+
let frame = decideDeviceFrame(options.search ?? location.search, storage);
|
|
103
|
+
const show = (next: DeviceFrame): void => {
|
|
104
|
+
frame = next;
|
|
105
|
+
applyDeviceFrame(next, doc);
|
|
106
|
+
target.setSafeArea(FRAME_SAFE_AREAS[next]);
|
|
107
|
+
target.viewportChanged();
|
|
108
|
+
};
|
|
109
|
+
show(frame);
|
|
110
|
+
|
|
111
|
+
const toolbar = options.toolbar === undefined ? doc.querySelector<HTMLElement>('.toolbar') : options.toolbar;
|
|
112
|
+
if (toolbar && options.closeButton !== false) installToolbarClose(toolbar, target);
|
|
113
|
+
if (!toolbar || toolbar.querySelector('#device-frame')) return frame;
|
|
114
|
+
const label = doc.createElement('label');
|
|
115
|
+
label.className = 'toolbar-label';
|
|
116
|
+
label.htmlFor = 'device-frame';
|
|
117
|
+
label.textContent = 'Device';
|
|
118
|
+
const select = doc.createElement('select');
|
|
119
|
+
select.id = 'device-frame';
|
|
120
|
+
select.className = 'toolbar-select';
|
|
121
|
+
for (const { id, label: text } of DEVICE_FRAMES) select.append(new Option(text, id, false, id === frame));
|
|
122
|
+
select.addEventListener('change', () => {
|
|
123
|
+
if (!isFrame(select.value)) return;
|
|
124
|
+
show(select.value);
|
|
125
|
+
try {
|
|
126
|
+
storage?.setItem(DEVICE_FRAME_KEY, select.value);
|
|
127
|
+
} catch {
|
|
128
|
+
// Storage full or blocked: the choice lasts the page.
|
|
129
|
+
}
|
|
130
|
+
const url = new URL(location.href);
|
|
131
|
+
if (select.value === 'iphone') url.searchParams.delete('device');
|
|
132
|
+
else url.searchParams.set('device', select.value);
|
|
133
|
+
history.replaceState(history.state, '', url);
|
|
134
|
+
options.onChange?.(frame);
|
|
135
|
+
});
|
|
136
|
+
const before = options.before === undefined ? toolbar.querySelector('#theme-toggle') : options.before;
|
|
137
|
+
if (before && before.parentElement === toolbar) {
|
|
138
|
+
toolbar.insertBefore(label, before);
|
|
139
|
+
toolbar.insertBefore(select, before);
|
|
140
|
+
} else {
|
|
141
|
+
toolbar.append(label, select);
|
|
142
|
+
}
|
|
143
|
+
return frame;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Whether the URL asks for the page without its toolbar (`?toolbar=0`). */
|
|
147
|
+
export function toolbarHidden(search: string = location.search): boolean {
|
|
148
|
+
return new URLSearchParams(search).get('toolbar') === '0';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* An x in a circle at the end of the toolbar that hides it, for the app
|
|
153
|
+
* alone in the page (a screenshot, a demo): the document gets
|
|
154
|
+
* `data-sb-toolbar="hidden"` (page.css), the URL `?toolbar=0` so a reload
|
|
155
|
+
* or a shared link keeps it hidden, and the renderer re-reads the screen's
|
|
156
|
+
* size, which grows without a device. The pages' inline scripts hide the
|
|
157
|
+
* toolbar before the first paint when the URL asks.
|
|
158
|
+
*/
|
|
159
|
+
export function installToolbarClose(toolbar: HTMLElement, target?: Pick<DeviceFrameTarget, 'viewportChanged'>): HTMLButtonElement {
|
|
160
|
+
const doc = toolbar.ownerDocument;
|
|
161
|
+
const existing = toolbar.querySelector<HTMLButtonElement>('.toolbar-close');
|
|
162
|
+
if (existing) return existing;
|
|
163
|
+
if (toolbarHidden()) doc.documentElement.dataset['sbToolbar'] = 'hidden';
|
|
164
|
+
const button = doc.createElement('button');
|
|
165
|
+
button.type = 'button';
|
|
166
|
+
button.className = 'toolbar-close';
|
|
167
|
+
button.setAttribute('aria-label', 'Hide the toolbar');
|
|
168
|
+
button.title = 'Hide the toolbar (remove ?toolbar=0 from the address to show it again)';
|
|
169
|
+
button.innerHTML =
|
|
170
|
+
'<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true"><circle cx="10" cy="10" r="10" /><path d="M6.5 6.5l7 7M13.5 6.5l-7 7" /></svg>';
|
|
171
|
+
button.addEventListener('click', () => {
|
|
172
|
+
doc.documentElement.dataset['sbToolbar'] = 'hidden';
|
|
173
|
+
const url = new URL(location.href);
|
|
174
|
+
url.searchParams.set('toolbar', '0');
|
|
175
|
+
history.replaceState(history.state, '', url);
|
|
176
|
+
target?.viewportChanged();
|
|
177
|
+
});
|
|
178
|
+
toolbar.append(button);
|
|
179
|
+
return button;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function safeStorage(): Storage | null {
|
|
183
|
+
try {
|
|
184
|
+
return typeof localStorage === 'undefined' ? null : localStorage;
|
|
185
|
+
} catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
package/src/dragDrop.ts
CHANGED
|
@@ -32,6 +32,12 @@ export interface DragDropHost {
|
|
|
32
32
|
readonly root: HTMLElement;
|
|
33
33
|
send(event: RendererEvent): void;
|
|
34
34
|
nodeOf(id: ElementId): LayoutNode | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* How a dropped file reaches the app: as base64 `data` (a Swift app, whose
|
|
37
|
+
* events cross into linear memory), or by `reference` (a React app in the
|
|
38
|
+
* same page gets the `File` itself, and a dropped folder's files by path).
|
|
39
|
+
*/
|
|
40
|
+
fileDrops?(): 'bytes' | 'reference';
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
function idOf(el: Element | null): ElementId | null {
|
|
@@ -488,7 +494,7 @@ export class ExternalDrops {
|
|
|
488
494
|
const scale = scaleOf(this.host.root);
|
|
489
495
|
const point = { x: round2((event.clientX - rect.left) / scale), y: round2((event.clientY - rect.top) / scale) };
|
|
490
496
|
const patterns = patternsOf(destination);
|
|
491
|
-
void readItems(transfer, patterns).then((items) => {
|
|
497
|
+
void readItems(transfer, patterns, { reference: this.host.fileDrops?.() === 'reference' }).then((items) => {
|
|
492
498
|
if (items.length === 0) return;
|
|
493
499
|
const drop: DropEvent = { type: 'drop', id, phase: 'performed', items, x: point.x, y: point.y };
|
|
494
500
|
this.host.send(drop);
|
|
@@ -514,36 +520,86 @@ export class ExternalDrops {
|
|
|
514
520
|
}
|
|
515
521
|
}
|
|
516
522
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
523
|
+
export interface ReadItemsOptions {
|
|
524
|
+
/** Carry each file by reference (`file`, and `path` inside a dropped folder) instead of reading it into base64 `data`. */
|
|
525
|
+
reference?: boolean;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** The entry behind a dragged item where the browser offers one (`webkitGetAsEntry`), for folders. */
|
|
529
|
+
type EntryItem = Pick<DataTransferItem, 'kind' | 'type' | 'getAsFile' | 'getAsString'> & { webkitGetAsEntry?: () => FileSystemEntry | null };
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* The drag's items the patterns accept: strings as text, files as base64
|
|
533
|
+
* bytes (or by reference, `options.reference`). A dropped folder is walked
|
|
534
|
+
* (`webkitGetAsEntry`, which every browser with drag and drop offers) and
|
|
535
|
+
* each file in it that the patterns accept comes as an item of its own,
|
|
536
|
+
* named by its path from the folder, the folder's name first, as the folder
|
|
537
|
+
* chooser names picked files (hostRequests.ts `folderFiles`).
|
|
538
|
+
*/
|
|
539
|
+
export async function readItems(transfer: DataTransfer, patterns: readonly string[], options: ReadItemsOptions = {}): Promise<DropItem[]> {
|
|
540
|
+
const reads: Promise<DropItem[]>[] = [];
|
|
520
541
|
const items = Array.from(transfer.items ?? []);
|
|
521
542
|
if (items.length > 0) {
|
|
522
543
|
for (const item of items) {
|
|
523
544
|
if (item.kind === 'file') {
|
|
545
|
+
// The entry and the file must be taken now, inside the event: the item is empty afterwards.
|
|
546
|
+
const entry = (item as EntryItem).webkitGetAsEntry?.() ?? null;
|
|
547
|
+
if (entry?.isDirectory) {
|
|
548
|
+
reads.push(readDirectory(entry as FileSystemDirectoryEntry, entry.name, patterns, options));
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
524
551
|
const file = item.getAsFile();
|
|
525
552
|
const type = file?.type || 'application/octet-stream';
|
|
526
553
|
if (!file || !acceptsType(patterns, type)) continue;
|
|
527
|
-
reads.push(readFile(file, type));
|
|
554
|
+
reads.push(readFile(file, type, undefined, options).then((read) => (read ? [read] : [])));
|
|
528
555
|
} else if (item.kind === 'string' && (item.type === 'text/plain' || item.type === 'text/uri-list') && acceptsType(patterns, item.type)) {
|
|
529
556
|
const type = item.type;
|
|
530
|
-
reads.push(new Promise((resolve) => item.getAsString((text) => resolve({ type, text }))));
|
|
557
|
+
reads.push(new Promise((resolve) => item.getAsString((text) => resolve([{ type, text }]))));
|
|
531
558
|
}
|
|
532
559
|
}
|
|
533
560
|
} else {
|
|
534
561
|
// Without `items` (an older browser, a synthetic transfer): the strings only.
|
|
535
562
|
for (const type of ['text/plain', 'text/uri-list']) {
|
|
536
563
|
const text = transfer.getData(type);
|
|
537
|
-
if (text && acceptsType(patterns, type)) reads.push(Promise.resolve({ type, text }));
|
|
564
|
+
if (text && acceptsType(patterns, type)) reads.push(Promise.resolve([{ type, text }]));
|
|
538
565
|
}
|
|
539
566
|
}
|
|
540
567
|
const results = await Promise.all(reads);
|
|
541
|
-
return results.
|
|
568
|
+
return results.flat();
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Every file under a dropped folder's entry, at any depth, that the patterns accept, named by its path from the folder. */
|
|
572
|
+
async function readDirectory(directory: FileSystemDirectoryEntry, path: string, patterns: readonly string[], options: ReadItemsOptions): Promise<DropItem[]> {
|
|
573
|
+
const entries = await allEntries(directory);
|
|
574
|
+
const out: DropItem[][] = await Promise.all(
|
|
575
|
+
entries.map(async (entry) => {
|
|
576
|
+
const entryPath = `${path}/${entry.name}`;
|
|
577
|
+
if (entry.isDirectory) return readDirectory(entry as FileSystemDirectoryEntry, entryPath, patterns, options);
|
|
578
|
+
const file = await new Promise<File | null>((resolve) => (entry as FileSystemFileEntry).file(resolve, () => resolve(null)));
|
|
579
|
+
const type = file?.type || 'application/octet-stream';
|
|
580
|
+
if (!file || !acceptsType(patterns, type)) return [];
|
|
581
|
+
const read = await readFile(file, type, entryPath, options);
|
|
582
|
+
return read ? [read] : [];
|
|
583
|
+
}),
|
|
584
|
+
);
|
|
585
|
+
return out.flat();
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** A directory entry's children: `readEntries` answers a batch at a time, until an empty one. */
|
|
589
|
+
async function allEntries(directory: FileSystemDirectoryEntry): Promise<FileSystemEntry[]> {
|
|
590
|
+
const reader = directory.createReader();
|
|
591
|
+
const all: FileSystemEntry[] = [];
|
|
592
|
+
for (;;) {
|
|
593
|
+
const batch = await new Promise<FileSystemEntry[]>((resolve) => reader.readEntries(resolve, () => resolve([])));
|
|
594
|
+
if (batch.length === 0) return all;
|
|
595
|
+
all.push(...batch);
|
|
596
|
+
}
|
|
542
597
|
}
|
|
543
598
|
|
|
544
|
-
function readFile(file: File, type: string): Promise<DropItem | null> {
|
|
599
|
+
function readFile(file: File, type: string, path: string | undefined, options: ReadItemsOptions): Promise<DropItem | null> {
|
|
600
|
+
if (options.reference) return Promise.resolve({ type, name: file.name, file, ...(path !== undefined ? { path } : {}) });
|
|
545
601
|
return file
|
|
546
602
|
.arrayBuffer()
|
|
547
|
-
.then((buffer) => ({ type, name: file.name, data: base64Encode(new Uint8Array(buffer)) }))
|
|
603
|
+
.then((buffer) => ({ type, name: file.name, data: base64Encode(new Uint8Array(buffer)), ...(path !== undefined ? { path } : {}) }))
|
|
548
604
|
.catch(() => null);
|
|
549
605
|
}
|
package/src/editorSelection.ts
CHANGED
|
@@ -11,6 +11,8 @@ import type { TextSelectionRange } from './protocol';
|
|
|
11
11
|
|
|
12
12
|
/** The selection each bound editor last reported, or was last moved to. */
|
|
13
13
|
const reported = new WeakMap<HTMLElement, string>();
|
|
14
|
+
/** The text each editor last reported (what the viewer typed): a `text` coming back equal to it is an echo, any other came from outside. */
|
|
15
|
+
const reportedText = new WeakMap<HTMLElement, string>();
|
|
14
16
|
/** A selection the app set while the editor was not focused: where its caret goes when the app focuses it. */
|
|
15
17
|
const pending = new WeakMap<HTMLElement, TextSelectionRange>();
|
|
16
18
|
|
|
@@ -41,14 +43,47 @@ export function wantsDeleteBackward(el: HTMLElement): boolean {
|
|
|
41
43
|
return el.dataset['sbDeleteBackward'] !== undefined;
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
/** The editor reported `text` (the viewer typed it). */
|
|
47
|
+
export function noteReportedText(el: HTMLElement, text: string): void {
|
|
48
|
+
reportedText.set(el, text);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Where a `text` the app sends came from: the echo of what the editor
|
|
53
|
+
* reported, or outside (another person's edit, the app's own change). The
|
|
54
|
+
* caret survives an echo on its own; an outside change moves what is under
|
|
55
|
+
* it, so the caret is moved too (`shiftOffset`), or the app's selection
|
|
56
|
+
* re-applied even when its numbers did not change.
|
|
57
|
+
*/
|
|
58
|
+
export function textArrived(el: HTMLElement, text: string): 'echo' | 'remote' {
|
|
59
|
+
return reportedText.get(el) === text ? 'echo' : 'remote';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Where an offset into `before` is in `after`: the same offset before the
|
|
64
|
+
* change (the common prefix), moved by the change's length after it (the
|
|
65
|
+
* common suffix), and at the change's end inside it.
|
|
66
|
+
*/
|
|
67
|
+
export function shiftOffset(before: string, after: string, offset: number): number {
|
|
68
|
+
const shortest = Math.min(before.length, after.length);
|
|
69
|
+
let prefix = 0;
|
|
70
|
+
while (prefix < shortest && before.charCodeAt(prefix) === after.charCodeAt(prefix)) prefix += 1;
|
|
71
|
+
let suffix = 0;
|
|
72
|
+
while (suffix < shortest - prefix && before.charCodeAt(before.length - 1 - suffix) === after.charCodeAt(after.length - 1 - suffix)) suffix += 1;
|
|
73
|
+
if (offset <= prefix) return offset;
|
|
74
|
+
if (offset >= before.length - suffix) return offset + (after.length - before.length);
|
|
75
|
+
return after.length - suffix;
|
|
76
|
+
}
|
|
77
|
+
|
|
44
78
|
/**
|
|
45
79
|
* The app sent `range` (or no selection): the range the editor moves to now,
|
|
46
80
|
* when it is focused; otherwise null, the range kept for `takePendingSelection`
|
|
47
81
|
* when the app focuses the editor. Nothing for the selection the editor
|
|
48
|
-
* itself reported, coming back
|
|
82
|
+
* itself reported, coming back, unless `force` (the text changed from
|
|
83
|
+
* outside under it, so the same numbers mean a move).
|
|
49
84
|
*/
|
|
50
|
-
export function selectionToApply(el: HTMLElement, range: TextSelectionRange | undefined): TextSelectionRange | null {
|
|
51
|
-
if (!range || reported.get(el) === key(range)) return null;
|
|
85
|
+
export function selectionToApply(el: HTMLElement, range: TextSelectionRange | undefined, force = false): TextSelectionRange | null {
|
|
86
|
+
if (!range || (!force && reported.get(el) === key(range))) return null;
|
|
52
87
|
reported.set(el, key(range));
|
|
53
88
|
if (el.ownerDocument.activeElement === el) return range;
|
|
54
89
|
pending.set(el, range);
|
package/src/files.ts
CHANGED
|
@@ -48,8 +48,19 @@ export interface FilesHost {
|
|
|
48
48
|
readonly directories: FileDirectories;
|
|
49
49
|
/** The file's bytes. */
|
|
50
50
|
read(url: string): Promise<Uint8Array>;
|
|
51
|
-
/**
|
|
52
|
-
|
|
51
|
+
/**
|
|
52
|
+
* The file's bytes from `start` up to `end` (exclusive, clamped to the
|
|
53
|
+
* file's size): what `read` would answer, sliced, without the rest of the
|
|
54
|
+
* file in memory where the host can avoid it. Hosts before 0.20 lack it;
|
|
55
|
+
* `fileManager` then reads the whole file and slices.
|
|
56
|
+
*/
|
|
57
|
+
readRange?(url: string, start: number, end: number): Promise<Uint8Array>;
|
|
58
|
+
/**
|
|
59
|
+
* Writes the bytes as the file, replacing one there; its directory must
|
|
60
|
+
* exist. A `Blob` (a `File` the viewer picked or dropped) is kept as it
|
|
61
|
+
* is where the host can, so a large file never passes through memory.
|
|
62
|
+
*/
|
|
63
|
+
write(url: string, data: Uint8Array | Blob): Promise<void>;
|
|
53
64
|
/** Removes a file, or a directory and everything in it. */
|
|
54
65
|
remove(url: string): Promise<void>;
|
|
55
66
|
/** Makes the directory and the directories above it; one already there is fine. */
|
|
@@ -136,13 +147,47 @@ function parentOf(path: string): string {
|
|
|
136
147
|
// Rows: a home's files by path, in IndexedDB or memory
|
|
137
148
|
// ---------------------------------------------------------------------------
|
|
138
149
|
|
|
150
|
+
/**
|
|
151
|
+
* A file's contents: bytes, or a `Blob` kept as the viewer handed it over
|
|
152
|
+
* (IndexedDB stores one without reading it into memory).
|
|
153
|
+
*/
|
|
154
|
+
export type FileData = Uint8Array | Blob;
|
|
155
|
+
|
|
139
156
|
/** A file's bytes, or null for a directory, and when it was written and made. */
|
|
140
157
|
export interface FileRow {
|
|
141
|
-
data:
|
|
158
|
+
data: FileData | null;
|
|
142
159
|
modified: number | null;
|
|
143
160
|
created: number | null;
|
|
144
161
|
}
|
|
145
162
|
|
|
163
|
+
function isBlob(data: FileData): data is Blob {
|
|
164
|
+
return typeof Blob !== 'undefined' && data instanceof Blob;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** How many bytes a file's data holds. */
|
|
168
|
+
export function dataSize(data: FileData): number {
|
|
169
|
+
return isBlob(data) ? data.size : data.byteLength;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A file's data as bytes. */
|
|
173
|
+
export async function dataBytes(data: FileData): Promise<Uint8Array> {
|
|
174
|
+
return isBlob(data) ? new Uint8Array(await data.arrayBuffer()) : data;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The bytes of a file's data from `start` up to `end`, clamped. */
|
|
178
|
+
export async function dataSlice(data: FileData, start: number, end: number): Promise<Uint8Array> {
|
|
179
|
+
const size = dataSize(data);
|
|
180
|
+
const from = Math.min(Math.max(0, Math.floor(start)), size);
|
|
181
|
+
const to = Math.min(Math.max(from, Math.floor(end)), size);
|
|
182
|
+
if (isBlob(data)) return new Uint8Array(await data.slice(from, to).arrayBuffer());
|
|
183
|
+
return data.subarray(from, to);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A copy of a file's data a row can keep: a Blob is immutable and kept as it is. */
|
|
187
|
+
function ownData(data: FileData): FileData {
|
|
188
|
+
return isBlob(data) ? data : data.slice();
|
|
189
|
+
}
|
|
190
|
+
|
|
146
191
|
/** The rows of one home, by path relative to it (`Documents/notes.txt`). */
|
|
147
192
|
export interface FileRows {
|
|
148
193
|
get(path: string): Promise<FileRow | undefined>;
|
|
@@ -157,7 +202,7 @@ export function memoryRows(): FileRows {
|
|
|
157
202
|
const rows = new Map<string, FileRow>();
|
|
158
203
|
return {
|
|
159
204
|
get: async (path) => rows.get(path),
|
|
160
|
-
put: async (path, row) => void rows.set(path, { data: row.data === null ? null : row.data
|
|
205
|
+
put: async (path, row) => void rows.set(path, { data: row.data === null ? null : ownData(row.data), modified: row.modified, created: row.created }),
|
|
161
206
|
remove: async (paths) => paths.forEach((path) => rows.delete(path)),
|
|
162
207
|
keys: async () => [...rows.keys()],
|
|
163
208
|
};
|
|
@@ -173,14 +218,15 @@ export function indexedDBRows(app: string, factory: IDBFactory = indexedDB): Fil
|
|
|
173
218
|
const row = (await request((await store('readonly')).get(path))) as EntryRow | undefined;
|
|
174
219
|
if (!row) return undefined;
|
|
175
220
|
return {
|
|
176
|
-
|
|
221
|
+
// A Blob row stays a Blob: the browser hands it over without reading it.
|
|
222
|
+
data: row.directory ? null : row.data !== undefined && isBlob(row.data) ? row.data : new Uint8Array(row.data ?? new Uint8Array()),
|
|
177
223
|
modified: typeof row.modified === 'number' ? row.modified : null,
|
|
178
224
|
created: typeof row.created === 'number' ? row.created : null,
|
|
179
225
|
};
|
|
180
226
|
},
|
|
181
227
|
async put(path, row) {
|
|
182
228
|
const entries = await store('readwrite');
|
|
183
|
-
const stored: EntryRow = row.data === null ? { path, directory: true } : { path, data: row.data
|
|
229
|
+
const stored: EntryRow = row.data === null ? { path, directory: true } : { path, data: ownData(row.data) };
|
|
184
230
|
if (row.modified !== null) stored.modified = row.modified;
|
|
185
231
|
if (row.created !== null) stored.created = row.created;
|
|
186
232
|
entries.put(stored);
|
|
@@ -350,15 +396,24 @@ export class BrowserFiles implements FilesHost {
|
|
|
350
396
|
this.objectURLs?.revoke(url);
|
|
351
397
|
}
|
|
352
398
|
|
|
353
|
-
|
|
399
|
+
/** The row of a file (not a directory) at the URL. */
|
|
400
|
+
private async fileRow(url: string): Promise<FileRow & { data: FileData }> {
|
|
354
401
|
const { mount, path } = this.locate(url);
|
|
355
402
|
const row = await this.rowAt(mount, path);
|
|
356
403
|
if (!row) throw new FilesFailure('notFound', `no file at ${url}`);
|
|
357
404
|
if (row.data === null) throw new FilesFailure('isDirectory', `${url} is a directory`);
|
|
358
|
-
return row
|
|
405
|
+
return row as FileRow & { data: FileData };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async read(url: string): Promise<Uint8Array> {
|
|
409
|
+
return dataBytes((await this.fileRow(url)).data);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async readRange(url: string, start: number, end: number): Promise<Uint8Array> {
|
|
413
|
+
return dataSlice((await this.fileRow(url)).data, start, end);
|
|
359
414
|
}
|
|
360
415
|
|
|
361
|
-
async write(url: string, data:
|
|
416
|
+
async write(url: string, data: FileData): Promise<void> {
|
|
362
417
|
const { mount, path } = this.locate(url);
|
|
363
418
|
if (path === '') throw new FilesFailure('isDirectory', `${url} is a directory`);
|
|
364
419
|
await this.requireDirectory(mount, parentOf(path), url);
|
|
@@ -413,7 +468,7 @@ export class BrowserFiles implements FilesHost {
|
|
|
413
468
|
const row = await this.rowAt(mount, path);
|
|
414
469
|
if (!row) return null;
|
|
415
470
|
const directory = row.data === null;
|
|
416
|
-
return { size: row.data
|
|
471
|
+
return { size: row.data === null ? 0 : dataSize(row.data), modified: row.modified, created: row.created, directory, type: directory ? '' : mediaType(path) };
|
|
417
472
|
}
|
|
418
473
|
|
|
419
474
|
/** Every row at or below `path`, by path. */
|
|
@@ -464,10 +519,10 @@ export class BrowserFiles implements FilesHost {
|
|
|
464
519
|
const key = `${mount.url}/${path}`;
|
|
465
520
|
const known = this.loaded.get(key);
|
|
466
521
|
if (known !== undefined) return known;
|
|
467
|
-
const data = await this.
|
|
522
|
+
const { data } = await this.fileRow(url);
|
|
468
523
|
const type = mediaType(path);
|
|
469
|
-
if (!this.objectURLs) return `data:${type};base64,${base64Of(data)}`;
|
|
470
|
-
const made = this.objectURLs.create(new Blob([data.slice()], { type }));
|
|
524
|
+
if (!this.objectURLs) return `data:${type};base64,${base64Of(await dataBytes(data))}`;
|
|
525
|
+
const made = this.objectURLs.create(isBlob(data) ? data.slice(0, data.size, type) : new Blob([data.slice()], { type }));
|
|
471
526
|
this.loaded.set(key, made);
|
|
472
527
|
return made;
|
|
473
528
|
}
|