@swiftbrowser/web 0.19.0 → 0.21.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/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/protocol.ts +41 -1
- package/src/renderer.ts +132 -10
- 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/src/hostRequests.ts
CHANGED
|
@@ -202,6 +202,12 @@ export interface HostRequestEnvironment {
|
|
|
202
202
|
languageModel?: LanguageModelHost;
|
|
203
203
|
/** The app's files (files.ts: its IndexedDB home on a page, its sandbox natively); `FileManager` in @swiftbrowser/react calls it. */
|
|
204
204
|
files?: FilesHost;
|
|
205
|
+
/**
|
|
206
|
+
* A dropped file the host left on disk (a `DropItem` with `url` and `size`,
|
|
207
|
+
* natively) as a `File` read on demand; a page never needs it, its drops
|
|
208
|
+
* carry the `File` itself.
|
|
209
|
+
*/
|
|
210
|
+
fileFromReference?: (item: { type: string; name?: string; path?: string; url: string; size?: number }) => File;
|
|
205
211
|
/** The browser's built-in translator (`translate`, translation.ts), where it has one. */
|
|
206
212
|
translation?: TranslationHost;
|
|
207
213
|
/** The browser's color chooser (`pickColor`, colorPicker.ts); it must open before it returns. */
|
|
@@ -210,18 +216,49 @@ export interface HostRequestEnvironment {
|
|
|
210
216
|
undoManager?: UndoHost;
|
|
211
217
|
}
|
|
212
218
|
|
|
219
|
+
/**
|
|
220
|
+
* The type that, among a chooser's `accept`, asks for a folder rather than files (SwiftUI's `UTType.folder`).
|
|
221
|
+
* The chooser then answers with the files in the folder, at any depth, that the rest of `accept` allows.
|
|
222
|
+
*/
|
|
223
|
+
export const FOLDER_TYPE = 'public.folder';
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* A chosen folder's files, as a `pickFiles` request answers them: those the
|
|
227
|
+
* rest of `accept` allows (an extension, a MIME type or a `type/*` pattern;
|
|
228
|
+
* any when it names none), each named by its path from the folder, the
|
|
229
|
+
* folder's own name first (`Bank/Leads/Saw.fxp`).
|
|
230
|
+
*/
|
|
231
|
+
export function folderFiles(files: File[], accept: string[]): File[] {
|
|
232
|
+
const allowed = accept.filter((part) => part !== FOLDER_TYPE).map((part) => part.toLowerCase());
|
|
233
|
+
const allows = (file: File): boolean =>
|
|
234
|
+
allowed.length === 0 ||
|
|
235
|
+
allowed.some((part) =>
|
|
236
|
+
part.startsWith('.') ? file.name.toLowerCase().endsWith(part)
|
|
237
|
+
: part.endsWith('/*') ? file.type.toLowerCase().startsWith(part.slice(0, -1))
|
|
238
|
+
: file.type.toLowerCase() === part);
|
|
239
|
+
return files.filter(allows).map((file) => {
|
|
240
|
+
const path = file.webkitRelativePath;
|
|
241
|
+
return path && path !== file.name ? new File([file], path, { type: file.type, lastModified: file.lastModified }) : file;
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
213
245
|
/**
|
|
214
246
|
* The browser's file chooser: a hidden `<input type="file">` clicked at once,
|
|
215
247
|
* inside the tap that sent the request (the loader performs the request while
|
|
216
248
|
* the tap's click handler is still running; see wasm.ts). Resolves on
|
|
217
249
|
* `change` with the files, or on `cancel` (the viewer closed the chooser)
|
|
218
250
|
* with none. A browser without the `cancel` event never answers a dismissal;
|
|
219
|
-
* the app's selection then just stays as it was.
|
|
251
|
+
* the app's selection then just stays as it was. With `FOLDER_TYPE` in
|
|
252
|
+
* `accept`, it is the folder chooser, answered as `folderFiles` says.
|
|
220
253
|
*/
|
|
221
254
|
function chooseFiles(options: { accept: string; multiple: boolean }): Promise<File[]> {
|
|
255
|
+
const accept = options.accept.split(',').map((part) => part.trim()).filter((part) => part !== '');
|
|
256
|
+
const folder = accept.includes(FOLDER_TYPE);
|
|
222
257
|
const input = document.createElement('input');
|
|
223
258
|
input.type = 'file';
|
|
224
|
-
|
|
259
|
+
// A folder: the browser's folder chooser, which takes no `accept`; its files are filtered after.
|
|
260
|
+
if (folder) input.webkitdirectory = true;
|
|
261
|
+
else if (options.accept) input.accept = options.accept;
|
|
225
262
|
input.multiple = options.multiple;
|
|
226
263
|
// In the document (some browsers only open a chooser for an attached
|
|
227
264
|
// input), but invisible and out of the way.
|
|
@@ -233,7 +270,10 @@ function chooseFiles(options: { accept: string; multiple: boolean }): Promise<Fi
|
|
|
233
270
|
input.remove();
|
|
234
271
|
resolve(files);
|
|
235
272
|
};
|
|
236
|
-
input.addEventListener('change', () =>
|
|
273
|
+
input.addEventListener('change', () => {
|
|
274
|
+
const files = Array.from(input.files ?? []);
|
|
275
|
+
finish(folder ? folderFiles(files, accept) : files);
|
|
276
|
+
}, { once: true });
|
|
237
277
|
input.addEventListener('cancel', () => finish([]), { once: true });
|
|
238
278
|
});
|
|
239
279
|
input.click();
|
package/src/interaction.ts
CHANGED
|
@@ -102,6 +102,8 @@ export interface InteractionHost {
|
|
|
102
102
|
readonly root: HTMLElement;
|
|
103
103
|
send(event: RendererEvent): void;
|
|
104
104
|
nodeOf(id: ElementId): LayoutNode | undefined;
|
|
105
|
+
/** How a dropped file reaches the app (src/dragDrop.ts `DragDropHost`). */
|
|
106
|
+
fileDrops?(): 'bytes' | 'reference';
|
|
105
107
|
/** Open the popover of a `contextmenu` element anchored to it. */
|
|
106
108
|
openContextMenu(anchor: HTMLElement, id: ElementId): void;
|
|
107
109
|
/** A covered screen is about to show (a back swipe starts): lay it out if it is stale. */
|
|
@@ -318,7 +320,10 @@ export class InteractionController {
|
|
|
318
320
|
// -------------------------------------------------------------------------
|
|
319
321
|
|
|
320
322
|
private readonly onPointerDown = (event: PointerEvent): void => {
|
|
321
|
-
|
|
323
|
+
// One pointer at a time, but not only the primary one: a finger on a
|
|
324
|
+
// knob while another holds a key (a `touches` element, src/touches.ts,
|
|
325
|
+
// owns that one) drags, as in SwiftUI.
|
|
326
|
+
if (this.session || (event.pointerType === 'mouse' && event.button !== 0)) return;
|
|
322
327
|
const target = event.target as HTMLElement | null;
|
|
323
328
|
if (!target) return;
|
|
324
329
|
// Pointer downs in a text field select text, never gesture; a slider
|
package/src/layout/engine.ts
CHANGED
|
@@ -1819,6 +1819,7 @@ export class LayoutPass {
|
|
|
1819
1819
|
return this.pickerPlan(node, ctx, p).size;
|
|
1820
1820
|
case 'host':
|
|
1821
1821
|
return this.measureHost(node, p);
|
|
1822
|
+
case 'registered':
|
|
1822
1823
|
case 'geometry':
|
|
1823
1824
|
// GeometryReader: the proposal, 10 on unconstrained axes.
|
|
1824
1825
|
return { width: p.width ?? SHAPE_DEFAULT, height: p.height ?? SHAPE_DEFAULT };
|
|
@@ -3102,6 +3103,7 @@ export class LayoutPass {
|
|
|
3102
3103
|
case 'canvas':
|
|
3103
3104
|
case 'video':
|
|
3104
3105
|
case 'host':
|
|
3106
|
+
case 'registered':
|
|
3105
3107
|
return;
|
|
3106
3108
|
case 'vstack':
|
|
3107
3109
|
case 'disclosuregroup':
|
package/src/main.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { easingFor, springEasing, timingFor } from './animation';
|
|
3
3
|
import type { BuildEndPayload, BuildErrorPayload, BuildStartPayload } from './devEvents';
|
|
4
4
|
import { ChromeColor } from './chromeColor';
|
|
5
|
+
import { installDeviceFrame } from './deviceFrame';
|
|
5
6
|
import { applyPageMode, decidePageMode, installViewportReadout, readViewportInsets, watchKeyboard, watchViewport, type PageMode } from './deviceMode';
|
|
6
7
|
import { EMPTY_CATALOG, catalogPath, isAssetCatalog, type AssetCatalog } from './assets';
|
|
7
8
|
import { createMockApp } from './mock';
|
|
@@ -228,7 +229,8 @@ typeSizeSelect.addEventListener('change', () => {
|
|
|
228
229
|
|
|
229
230
|
/**
|
|
230
231
|
* Phase 7: the horizontal size class Swift sees (`regular` from 700 CSS px of
|
|
231
|
-
* screen width). The frame is
|
|
232
|
+
* screen width). The iPhone frame is compact, the iPad frame regular, and
|
|
233
|
+
* without a frame (or in device mode) it follows the window.
|
|
232
234
|
*/
|
|
233
235
|
function currentSizeClass(): HorizontalSizeClass {
|
|
234
236
|
return horizontalSizeClassFor(screen.clientWidth);
|
|
@@ -260,6 +262,15 @@ if (deviceMode) {
|
|
|
260
262
|
// `?debug=viewport` shows the numbers the measurement reads, over the app.
|
|
261
263
|
const readout = new URLSearchParams(location.search).get('debug') === 'viewport' ? installViewportReadout() : undefined;
|
|
262
264
|
watchKeyboard((height) => renderer.setKeyboard(height), { root: device, onRead: readout });
|
|
265
|
+
} else if (!document.documentElement.hasAttribute('data-sb-embed')) {
|
|
266
|
+
// A desktop preview: the toolbar's Device picker (deviceFrame.ts) draws the
|
|
267
|
+
// app in an iPhone, an iPad or no device; the renderer re-reads the
|
|
268
|
+
// screen's size when it changes, and Swift hears a new size class.
|
|
269
|
+
const sizeClassMayHaveChanged = (): void => {
|
|
270
|
+
if (sentSizeClass !== null && currentSizeClass() !== sentSizeClass) sendEnvironment();
|
|
271
|
+
};
|
|
272
|
+
installDeviceFrame(renderer, { onChange: sizeClassMayHaveChanged });
|
|
273
|
+
watchViewport(sizeClassMayHaveChanged);
|
|
263
274
|
}
|
|
264
275
|
|
|
265
276
|
// ---------------------------------------------------------------------------
|
package/src/page.css
CHANGED
|
@@ -141,6 +141,43 @@ body {
|
|
|
141
141
|
border-color: transparent;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
.toolbar-close {
|
|
145
|
+
flex: 0 0 auto;
|
|
146
|
+
display: grid;
|
|
147
|
+
place-items: center;
|
|
148
|
+
width: 28px;
|
|
149
|
+
height: 28px;
|
|
150
|
+
margin-right: -4px;
|
|
151
|
+
padding: 0;
|
|
152
|
+
border: 0;
|
|
153
|
+
border-radius: 50%;
|
|
154
|
+
background: transparent;
|
|
155
|
+
color: var(--page-fg);
|
|
156
|
+
cursor: pointer;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.toolbar-close svg circle {
|
|
160
|
+
fill: currentColor;
|
|
161
|
+
opacity: 0.12;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.toolbar-close svg path {
|
|
165
|
+
fill: none;
|
|
166
|
+
stroke: currentColor;
|
|
167
|
+
stroke-width: 1.8;
|
|
168
|
+
stroke-linecap: round;
|
|
169
|
+
opacity: 0.7;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.toolbar-close:hover svg circle {
|
|
173
|
+
opacity: 0.2;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/* ?toolbar=0 (the toolbar's close button, src/deviceFrame.ts): the app alone. */
|
|
177
|
+
html[data-sb-toolbar="hidden"] .toolbar {
|
|
178
|
+
display: none;
|
|
179
|
+
}
|
|
180
|
+
|
|
144
181
|
.toolbar-select {
|
|
145
182
|
font: inherit;
|
|
146
183
|
padding: 5px 8px;
|
|
@@ -172,6 +209,34 @@ body {
|
|
|
172
209
|
word-break: break-word;
|
|
173
210
|
}
|
|
174
211
|
|
|
212
|
+
/* No device (html[data-sb-frame="none"], src/deviceFrame.ts): the page is
|
|
213
|
+
the toolbar over the app, which fills the rest of the window and lays out
|
|
214
|
+
for its size as the window is resized. */
|
|
215
|
+
html[data-sb-frame="none"]:not([data-sb-mode="device"]) body {
|
|
216
|
+
display: flex;
|
|
217
|
+
flex-direction: column;
|
|
218
|
+
height: 100dvh;
|
|
219
|
+
min-height: 0;
|
|
220
|
+
overflow: hidden;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
html[data-sb-frame="none"]:not([data-sb-mode="device"]) .stage {
|
|
224
|
+
flex: 1 1 auto;
|
|
225
|
+
min-height: 0;
|
|
226
|
+
align-items: stretch;
|
|
227
|
+
padding: 0;
|
|
228
|
+
gap: 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
html[data-sb-frame="none"]:not([data-sb-mode="device"]) .status {
|
|
232
|
+
position: fixed;
|
|
233
|
+
left: 16px;
|
|
234
|
+
right: 16px;
|
|
235
|
+
bottom: 16px;
|
|
236
|
+
z-index: 10;
|
|
237
|
+
max-width: none;
|
|
238
|
+
}
|
|
239
|
+
|
|
175
240
|
/* ---------------------------------------------------------------------------
|
|
176
241
|
Device mode (html[data-sb-mode="device"], see src/deviceMode.ts): no
|
|
177
242
|
toolbar, no stage padding, the page behind the app in the OS appearance's
|
package/src/pinnedViews.ts
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
* scroll event each header is moved by a transform along the axis. The
|
|
11
11
|
* laid-out positions are left alone (offsets, not client rects), so the
|
|
12
12
|
* transform never feeds back into where the header is reckoned to be.
|
|
13
|
+
*
|
|
14
|
+
* Where the browser has scroll-driven animations, a header is instead moved
|
|
15
|
+
* by one (styles.css `sb-pinned`) along its scroll view's timeline, from the
|
|
16
|
+
* scroll offset where it starts to pin to the one where its section's end
|
|
17
|
+
* stops it. The browser then moves it in step with the scroll itself, where a
|
|
18
|
+
* scroll event, a frame late on iOS, would leave it jittering.
|
|
13
19
|
*/
|
|
14
20
|
|
|
15
21
|
import type { PinnedView } from './protocol';
|
|
@@ -43,6 +49,9 @@ export function pinnedFooterOffset(viewEnd: number, footer: Span, sectionStart:
|
|
|
43
49
|
|
|
44
50
|
const registered = new Map<HTMLElement, Set<PinnedView>>();
|
|
45
51
|
|
|
52
|
+
/** Whether headers pin by a scroll-driven animation (styles.css), not by script on each scroll event. */
|
|
53
|
+
const scrollDriven = typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('animation-timeline', '--sb-pin-y');
|
|
54
|
+
|
|
46
55
|
/** Called by the renderer for every stack and grid: registers, updates or forgets its pinned views. */
|
|
47
56
|
export function setPinnedViews(el: HTMLElement, pinned: PinnedView[] | undefined): void {
|
|
48
57
|
const views = new Set<PinnedView>(Array.isArray(pinned) ? pinned.filter((view) => view === 'sectionHeaders' || view === 'sectionFooters') : []);
|
|
@@ -55,6 +64,7 @@ export function setPinnedViews(el: HTMLElement, pinned: PinnedView[] | undefined
|
|
|
55
64
|
if (view) {
|
|
56
65
|
view.style.transform = '';
|
|
57
66
|
view.style.zIndex = '';
|
|
67
|
+
unpin(view);
|
|
58
68
|
}
|
|
59
69
|
}
|
|
60
70
|
}
|
|
@@ -98,7 +108,8 @@ function draw(stack: HTMLElement, views: Set<PinnedView>): void {
|
|
|
98
108
|
if (header) {
|
|
99
109
|
const at = layoutOrigin(header, scroller);
|
|
100
110
|
const span: Span = { start: horizontal ? (at?.x ?? sectionStart) : (at?.y ?? sectionStart), length: horizontal ? header.offsetWidth : header.offsetHeight };
|
|
101
|
-
|
|
111
|
+
if (scrollDriven) pin(header, horizontal, span.start - (horizontal ? 0 : topInset(scroller)), pinnedHeaderOffset(Infinity, span, sectionEnd));
|
|
112
|
+
else move(header, horizontal, pinnedHeaderOffset(viewStart, span, sectionEnd));
|
|
102
113
|
}
|
|
103
114
|
}
|
|
104
115
|
if (views.has('sectionFooters')) {
|
|
@@ -119,6 +130,32 @@ function move(view: HTMLElement, horizontal: boolean, offset: number): void {
|
|
|
119
130
|
view.style.zIndex = '1';
|
|
120
131
|
}
|
|
121
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Pins a header by a scroll-driven animation: from the scroll offset `from`, where its view's start reaches it, it
|
|
135
|
+
* moves with the scroll for `room` and stays there. The scroll view names its timelines (styles.css).
|
|
136
|
+
*/
|
|
137
|
+
function pin(view: HTMLElement, horizontal: boolean, from: number, room: number): void {
|
|
138
|
+
view.style.transform = '';
|
|
139
|
+
view.style.zIndex = '1';
|
|
140
|
+
if (room <= 0) {
|
|
141
|
+
unpin(view);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const start = Math.round(from * 100) / 100;
|
|
145
|
+
const end = Math.round((from + room) * 100) / 100;
|
|
146
|
+
view.style.animation = 'sb-pinned linear both';
|
|
147
|
+
view.style.setProperty('animation-timeline', horizontal ? '--sb-pin-x' : '--sb-pin-y');
|
|
148
|
+
view.style.setProperty('animation-range', `${start}px ${end}px`);
|
|
149
|
+
view.style.setProperty('--sb-pinned-to', horizontal ? `${room}px 0` : `0 ${room}px`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function unpin(view: HTMLElement): void {
|
|
153
|
+
view.style.animation = '';
|
|
154
|
+
view.style.removeProperty('animation-timeline');
|
|
155
|
+
view.style.removeProperty('animation-range');
|
|
156
|
+
view.style.removeProperty('--sb-pinned-to');
|
|
157
|
+
}
|
|
158
|
+
|
|
122
159
|
/** Moves every pinned view to its place: after a layout, and on every scroll event. */
|
|
123
160
|
export function drawPinnedViews(): void {
|
|
124
161
|
for (const [stack, views] of registered) {
|
package/src/protocol.ts
CHANGED
|
@@ -378,6 +378,11 @@ export interface Style {
|
|
|
378
378
|
/** Unit point the scale grows from (default 0.5/0.5). */
|
|
379
379
|
scaleAnchor?: UnitPoint;
|
|
380
380
|
shadow?: Shadow;
|
|
381
|
+
/**
|
|
382
|
+
* `.compositingGroup()`: the box is drawn as one layer before the effects around it apply, so a `shadow` (here or
|
|
383
|
+
* on a box around it) is cast by the box as a whole rather than by each view it draws.
|
|
384
|
+
*/
|
|
385
|
+
compositingGroup?: boolean;
|
|
381
386
|
/** `.clipped()`: `overflow: hidden` on the box. */
|
|
382
387
|
clipped?: boolean;
|
|
383
388
|
|
|
@@ -1062,6 +1067,19 @@ export interface ElementPropsByKind {
|
|
|
1062
1067
|
searchfield: SearchFieldProps;
|
|
1063
1068
|
tapgesture: TapGestureProps;
|
|
1064
1069
|
host: HostProps;
|
|
1070
|
+
registered: RegisteredProps;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* A view a package registered under `name` with `ViewRegistry`: on a page
|
|
1075
|
+
* its own DOM (src/viewRegistry.ts), in a native app a SwiftUI view. It
|
|
1076
|
+
* takes the proposal, 10 on an unconstrained axis, as `GeometryReader`
|
|
1077
|
+
* does; its children, if any, are the native view's to place.
|
|
1078
|
+
*/
|
|
1079
|
+
export interface RegisteredProps {
|
|
1080
|
+
name: string;
|
|
1081
|
+
/** JSON, as the app wrote it, for the view. */
|
|
1082
|
+
props: Record<string, unknown>;
|
|
1065
1083
|
}
|
|
1066
1084
|
|
|
1067
1085
|
export type ElementKind = keyof ElementPropsByKind;
|
|
@@ -1552,7 +1570,17 @@ export type RendererEvent =
|
|
|
1552
1570
|
| ScrollGeometryEvent
|
|
1553
1571
|
| AnimationCompleteEvent
|
|
1554
1572
|
// The page's undo and redo keys, for the app's undo manager (src/undo.ts)
|
|
1555
|
-
| UndoEvent
|
|
1573
|
+
| UndoEvent
|
|
1574
|
+
// A registered view's own answer (src/viewRegistry.ts)
|
|
1575
|
+
| RegisteredEvent;
|
|
1576
|
+
|
|
1577
|
+
/** What a registered view sent (`context.send(value)`), for the element's `onEvent`. */
|
|
1578
|
+
export interface RegisteredEvent {
|
|
1579
|
+
type: 'registered';
|
|
1580
|
+
id: ElementId;
|
|
1581
|
+
name: string;
|
|
1582
|
+
value: unknown;
|
|
1583
|
+
}
|
|
1556
1584
|
|
|
1557
1585
|
/**
|
|
1558
1586
|
* Phase 13: the page's answer to the `request` op with this `id`: `value`
|
|
@@ -2251,7 +2279,19 @@ export interface DropItem {
|
|
|
2251
2279
|
type: string;
|
|
2252
2280
|
text?: string;
|
|
2253
2281
|
name?: string;
|
|
2282
|
+
/** A file's bytes as base64 (a Swift app's destination; `file` for a React one). */
|
|
2254
2283
|
data?: string;
|
|
2284
|
+
/**
|
|
2285
|
+
* The file as the browser holds it, by reference (a React host asks for
|
|
2286
|
+
* `reference` drops, src/dragDrop.ts): never serialized, so a dropped
|
|
2287
|
+
* folder of large files costs no memory until a file is read.
|
|
2288
|
+
*/
|
|
2289
|
+
file?: File;
|
|
2290
|
+
/** The file's path inside a dropped folder, the folder's name first (`Photos/2025/a.jpg`); absent for a file dropped on its own. */
|
|
2291
|
+
path?: string;
|
|
2292
|
+
/** Natively: a dropped file still on disk, its `file:` URL and size, read on demand through the files bridge. */
|
|
2293
|
+
url?: string;
|
|
2294
|
+
size?: number;
|
|
2255
2295
|
}
|
|
2256
2296
|
/**
|
|
2257
2297
|
* A drag over a `dropdestination`: it `entered`, `exited`, or `performed`
|
package/src/renderer.ts
CHANGED
|
@@ -126,6 +126,7 @@ import {
|
|
|
126
126
|
type TabProps,
|
|
127
127
|
type TabViewProps,
|
|
128
128
|
type HostProps,
|
|
129
|
+
type RegisteredProps,
|
|
129
130
|
type TextFieldProps,
|
|
130
131
|
type TextProps,
|
|
131
132
|
type TextRun,
|
|
@@ -171,8 +172,9 @@ import { TouchPaging } from './touchPaging';
|
|
|
171
172
|
import { isScrollVisible } from './scrollVisibility';
|
|
172
173
|
import { applyDefaultScrollAnchor, keepScrollAnchors } from './scrollAnchor';
|
|
173
174
|
import { EMPTY_CATALOG, assetColorVar, type AssetCatalog } from './assets';
|
|
174
|
-
import { HIERARCHY_OPACITY, colorToCSS, effectsCSS, styleToCSS, type PaintCSS } from './styles';
|
|
175
|
+
import { HIERARCHY_OPACITY, colorToCSS, effectsCSS, shadowCSS, styleToCSS, type PaintCSS } from './styles';
|
|
175
176
|
import { hostContent, onHostContentChange } from './hostContent';
|
|
177
|
+
import { type RegisteredViewInstance, ViewRegistry } from './viewRegistry';
|
|
176
178
|
import { HoverTracker, applyPointerAndEffects, safeAreaOverlap } from './pointerEffects';
|
|
177
179
|
import { colorMultiplyFilter } from './colorMultiply';
|
|
178
180
|
import { largeTitleOf, largeTitleTextOf } from './largeTitle';
|
|
@@ -193,6 +195,14 @@ export class RendererError extends Error {
|
|
|
193
195
|
}
|
|
194
196
|
|
|
195
197
|
const KIND_ATTR = 'sbKind';
|
|
198
|
+
/**
|
|
199
|
+
* The kinds that draw what they show themselves: a shadow of a box around one falls on it as a whole (castShadows,
|
|
200
|
+
* styles.css), where a container hands it on to the views inside.
|
|
201
|
+
*/
|
|
202
|
+
const DRAWN_KINDS: ReadonlySet<string> = new Set([
|
|
203
|
+
'text', 'image', 'divider', 'color', 'shape', 'textfield', 'toggle', 'picker', 'gauge', 'searchfield', 'registered', 'chart',
|
|
204
|
+
'stepper', 'slider', 'datepicker', 'texteditor', 'video', 'canvas',
|
|
205
|
+
]);
|
|
196
206
|
const ID_ATTR = 'sbId';
|
|
197
207
|
/** `animationToken` of a `styled` box, kept across `update` to detect changes. */
|
|
198
208
|
const ANIMATION_TOKEN = 'sbAnimationToken';
|
|
@@ -252,6 +262,7 @@ export { SHEET_DISMISS_DRAG } from './interaction';
|
|
|
252
262
|
|
|
253
263
|
const ALL_KINDS: ReadonlySet<string> = new Set<ElementKind>([
|
|
254
264
|
'host',
|
|
265
|
+
'registered',
|
|
255
266
|
'text',
|
|
256
267
|
'vstack',
|
|
257
268
|
'hstack',
|
|
@@ -422,6 +433,8 @@ export class Renderer {
|
|
|
422
433
|
private readonly hostContents = new Map<ElementId, HTMLElement>();
|
|
423
434
|
/** The `host` elements, adopted or still waiting for their content. */
|
|
424
435
|
private readonly hosts = new Set<ElementId>();
|
|
436
|
+
/** The registered views (src/viewRegistry.ts) built for `registered` elements, by element: the name each was built for. */
|
|
437
|
+
private readonly registeredViews = new Map<ElementId, { name: string; instance: RegisteredViewInstance | undefined }>();
|
|
425
438
|
private readonly hostSizes = new WeakMap<HTMLElement, { width: number; height: number }>();
|
|
426
439
|
private readonly hostMeasures = new Map<string, { width: number; height: number }>();
|
|
427
440
|
private readonly stopHostChanges: () => void;
|
|
@@ -440,6 +453,12 @@ export class Renderer {
|
|
|
440
453
|
private readonly pressEffects: PressEffects;
|
|
441
454
|
/** Text, links and files dragged in from outside the page (src/dragDrop.ts). */
|
|
442
455
|
private readonly externalDrops: ExternalDrops;
|
|
456
|
+
/**
|
|
457
|
+
* How a dropped file reaches the app: base64 `data` in the event (a Swift
|
|
458
|
+
* app) or the `File` by reference (a React app in the same page sets
|
|
459
|
+
* `reference`; a dropped folder then comes as its files by path).
|
|
460
|
+
*/
|
|
461
|
+
fileDrops: 'bytes' | 'reference' = 'bytes';
|
|
443
462
|
private readonly pinches: PinchController;
|
|
444
463
|
private readonly touches: TouchesController;
|
|
445
464
|
private readonly touchPaging: TouchPaging;
|
|
@@ -1025,6 +1044,7 @@ export class Renderer {
|
|
|
1025
1044
|
root,
|
|
1026
1045
|
send: (event) => this.onEvent(event),
|
|
1027
1046
|
nodeOf: (id) => this.nodes.get(id),
|
|
1047
|
+
fileDrops: () => this.fileDrops,
|
|
1028
1048
|
openContextMenu: (anchor, id) => this.openContextMenu(anchor, id),
|
|
1029
1049
|
revealScreen: (screen) => this.revealScreen(screen),
|
|
1030
1050
|
isMenuOpen: (id) => this.menu?.anchorId === id,
|
|
@@ -1258,6 +1278,7 @@ export class Renderer {
|
|
|
1258
1278
|
this.syncFocus();
|
|
1259
1279
|
// The focus the app moved shows or hides a screen's keyboard bar.
|
|
1260
1280
|
if (this.focusedFieldId() !== focusedBefore && this.hasKeyboardToolbars()) this.layout();
|
|
1281
|
+
this.castShadows();
|
|
1261
1282
|
this.performScrolls(op.animation != null);
|
|
1262
1283
|
this.performOffsetScrolls(op.animation != null);
|
|
1263
1284
|
this.checkLazySpacers();
|
|
@@ -1275,6 +1296,55 @@ export class Renderer {
|
|
|
1275
1296
|
}
|
|
1276
1297
|
}
|
|
1277
1298
|
|
|
1299
|
+
/**
|
|
1300
|
+
* SwiftUI's `.shadow` on a container falls on each view it draws (its texts, shapes, images, controls and
|
|
1301
|
+
* backgrounds), on their own shadows too, and not on the container as a whole unless a `.compositingGroup()` in
|
|
1302
|
+
* between draws the views as one. Each box with a shadow (`data-sb-shadow`) hands its views the shadows to cast:
|
|
1303
|
+
* its own, then those of the boxes around it, up to a compositing group (`--sb-shadow-filter`, which styles.css
|
|
1304
|
+
* gives each drawn view's `filter`, and which a box that clips, masks or composites takes for itself). A box's own
|
|
1305
|
+
* background, painted with its children inside, casts them as `box-shadow` instead.
|
|
1306
|
+
*/
|
|
1307
|
+
private castShadows(): void {
|
|
1308
|
+
const sources = this.root.querySelectorAll<HTMLElement>('[data-sb-shadow]');
|
|
1309
|
+
const cast = new Set(this.root.querySelectorAll<HTMLElement>('[data-sb-shadow-cast]'));
|
|
1310
|
+
if (sources.length === 0 && cast.size === 0) return;
|
|
1311
|
+
const filters = new Map<HTMLElement, { filter: string; box: string }>();
|
|
1312
|
+
// The shadows that reach the views inside `el` from the boxes around it, up to a compositing group or a drawn
|
|
1313
|
+
// view, which takes those around it as one and hands none on.
|
|
1314
|
+
const above = (el: HTMLElement): { filter: string; box: string } => {
|
|
1315
|
+
for (let up = el.parentElement; up && up !== this.root; up = up.parentElement) {
|
|
1316
|
+
if (up.dataset['sbComposite'] !== undefined || DRAWN_KINDS.has(up.dataset[KIND_ATTR] ?? '')) break;
|
|
1317
|
+
const found = filters.get(up);
|
|
1318
|
+
if (found) return found;
|
|
1319
|
+
}
|
|
1320
|
+
return { filter: '', box: '' };
|
|
1321
|
+
};
|
|
1322
|
+
// Document order: a box comes before the boxes inside it.
|
|
1323
|
+
for (const el of sources) {
|
|
1324
|
+
const outer = above(el);
|
|
1325
|
+
const filter = `${el.dataset['sbShadow']} ${outer.filter}`.trim();
|
|
1326
|
+
const box = [el.dataset['sbShadowBox'], outer.box].filter(Boolean).join(', ');
|
|
1327
|
+
filters.set(el, { filter, box });
|
|
1328
|
+
if (el.style.getPropertyValue('--sb-shadow-filter') !== filter) el.style.setProperty('--sb-shadow-filter', filter);
|
|
1329
|
+
}
|
|
1330
|
+
// A background painted on a box (its children inside) casts them as a box-shadow.
|
|
1331
|
+
if (sources.length > 0) {
|
|
1332
|
+
// A box that clips, masks or composites casts them for its background already, with what it holds.
|
|
1333
|
+
for (const el of this.root.querySelectorAll<HTMLElement>('.sb-styled[data-sb-background]:not([data-sb-as-one])')) {
|
|
1334
|
+
const { box } = filters.get(el) ?? above(el);
|
|
1335
|
+
if (!box) continue;
|
|
1336
|
+
if (el.style.boxShadow !== box) el.style.boxShadow = box;
|
|
1337
|
+
el.dataset['sbShadowCast'] = '';
|
|
1338
|
+
cast.delete(el);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
// Those no shadow reaches any more.
|
|
1342
|
+
for (const el of cast) {
|
|
1343
|
+
el.style.boxShadow = '';
|
|
1344
|
+
delete el.dataset['sbShadowCast'];
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1278
1348
|
/**
|
|
1279
1349
|
* `withAnimation(_:completionCriteria:_:completion:)`: the pass's `commit`
|
|
1280
1350
|
* carried a completion serial; once the animations its commit started have
|
|
@@ -2057,6 +2127,34 @@ export class Renderer {
|
|
|
2057
2127
|
this.hosts.add(id);
|
|
2058
2128
|
this.adoptHostContent(id, el, props as HostProps);
|
|
2059
2129
|
}
|
|
2130
|
+
if (kind === 'registered') this.adoptRegistered(id, el, props as RegisteredProps);
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
/**
|
|
2134
|
+
* A `registered` element's view (src/viewRegistry.ts): built for its name,
|
|
2135
|
+
* given the new props after; a name nothing registered shows itself, as an
|
|
2136
|
+
* unknown kind does. The element's own children are the native view's: on
|
|
2137
|
+
* a page the stylesheet hides them.
|
|
2138
|
+
*/
|
|
2139
|
+
private adoptRegistered(id: ElementId, el: HTMLElement, props: RegisteredProps): void {
|
|
2140
|
+
const values = asRecord(props.props);
|
|
2141
|
+
const current = this.registeredViews.get(id);
|
|
2142
|
+
if (current && current.name === props.name) {
|
|
2143
|
+
current.instance?.update?.(values);
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
2146
|
+
current?.instance?.dispose?.();
|
|
2147
|
+
const name = props.name;
|
|
2148
|
+
const instance = ViewRegistry.make(name, { id, props: values, send: (value) => this.onEvent({ type: 'registered', id, name, value }) });
|
|
2149
|
+
this.registeredViews.set(id, { name, instance });
|
|
2150
|
+
let content = instance?.element;
|
|
2151
|
+
if (!content) {
|
|
2152
|
+
content = div('sb-registered-missing');
|
|
2153
|
+
content.textContent = name;
|
|
2154
|
+
}
|
|
2155
|
+
content.classList.add('sb-registered-content');
|
|
2156
|
+
el.querySelector(':scope > .sb-registered-content')?.remove();
|
|
2157
|
+
el.prepend(content);
|
|
2060
2158
|
}
|
|
2061
2159
|
|
|
2062
2160
|
/** A toolbar whose placement changed to or from `largeTitle` moves between its screen's content slot and large title. */
|
|
@@ -2469,6 +2567,7 @@ export class Renderer {
|
|
|
2469
2567
|
this.noteScroll(id, kind, props);
|
|
2470
2568
|
this.noteVisibility(id, kind, props);
|
|
2471
2569
|
if (kind === 'host') this.adoptHostContent(id, el, props as HostProps);
|
|
2570
|
+
if (kind === 'registered') this.adoptRegistered(id, el, props as RegisteredProps);
|
|
2472
2571
|
if (kind === 'toolbar') this.rehomeToolbar(id, el, op);
|
|
2473
2572
|
const newToken = el.dataset[ANIMATION_TOKEN];
|
|
2474
2573
|
const tokenChanged = (previous?.tokenChanged ?? false) || (newToken !== undefined && newToken !== oldToken);
|
|
@@ -2704,6 +2803,8 @@ export class Renderer {
|
|
|
2704
2803
|
this.hostContents.delete(id);
|
|
2705
2804
|
}
|
|
2706
2805
|
this.hosts.delete(id);
|
|
2806
|
+
this.registeredViews.get(id)?.instance?.dispose?.();
|
|
2807
|
+
this.registeredViews.delete(id);
|
|
2707
2808
|
this.elements.delete(id);
|
|
2708
2809
|
this.boxes.delete(id);
|
|
2709
2810
|
this.dirty.delete(id);
|
|
@@ -2979,6 +3080,9 @@ function createShell(kind: ElementKind): HTMLElement {
|
|
|
2979
3080
|
case 'host':
|
|
2980
3081
|
// The page's content element is adopted by the renderer (`adoptHostContent`).
|
|
2981
3082
|
return div('sb-host');
|
|
3083
|
+
case 'registered':
|
|
3084
|
+
// Its view is built by the renderer (`adoptRegistered`).
|
|
3085
|
+
return div('sb-registered');
|
|
2982
3086
|
case 'button': {
|
|
2983
3087
|
const el = document.createElement('button');
|
|
2984
3088
|
el.type = 'button';
|
|
@@ -4136,6 +4240,9 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
|
|
|
4136
4240
|
case 'lazyspacer':
|
|
4137
4241
|
el.dataset['axis'] = (props as LazySpacerProps).axis === 'horizontal' ? 'horizontal' : 'vertical';
|
|
4138
4242
|
break;
|
|
4243
|
+
case 'registered':
|
|
4244
|
+
// The renderer builds its view (`adoptRegistered`).
|
|
4245
|
+
break;
|
|
4139
4246
|
default: {
|
|
4140
4247
|
const unknown: never = kind;
|
|
4141
4248
|
throw new RendererError(`Unknown element kind ${String(unknown)}`);
|
|
@@ -4282,9 +4389,10 @@ function applyShape(el: HTMLElement, props: ShapeProps): void {
|
|
|
4282
4389
|
s.border = `${width}px ${stroke.dash?.length ? 'dashed' : 'solid'} ${color}`;
|
|
4283
4390
|
el.dataset['sbStroke'] = stroke.inset ? 'inset' : 'center';
|
|
4284
4391
|
}
|
|
4285
|
-
// A fill with a drop shadow (`.shadow(.drop(…))` on the style)
|
|
4392
|
+
// A fill with a drop shadow (`.shadow(.drop(…))` on the style), then the shadows of the boxes around the shape
|
|
4393
|
+
// (castShadows), which styles.css gives a shape without one of its own.
|
|
4286
4394
|
const shadow = (props.fill as { shadow?: ShapeShadow } | null)?.shadow;
|
|
4287
|
-
s.filter = shadow && typeof shadow === 'object' ? shapeShadowCSS(shadow) : '';
|
|
4395
|
+
s.filter = shadow && typeof shadow === 'object' ? `${shapeShadowCSS(shadow)} var(--sb-shadow-filter, )` : '';
|
|
4288
4396
|
const mask = shapeTrimMask(props.trim);
|
|
4289
4397
|
if (mask) {
|
|
4290
4398
|
s.setProperty('mask-image', mask);
|
|
@@ -4893,15 +5001,29 @@ export function applyStyle(el: HTMLElement, style: Style): void {
|
|
|
4893
5001
|
}
|
|
4894
5002
|
|
|
4895
5003
|
// Phase 6 visual effects: CSS only, the layout engine never sees them.
|
|
4896
|
-
|
|
5004
|
+
// A shadow is not the box's own filter: as SwiftUI's `.shadow` on a container, it falls on each view the box draws
|
|
5005
|
+
// (castShadows). A box that clips, masks or composites what it holds draws it as one, so the shadows around it are
|
|
5006
|
+
// cast by it too (`--sb-shadow-filter`, which castShadows sets).
|
|
5007
|
+
const effects = effectsCSS(style, { shadow: false });
|
|
4897
5008
|
if (effects.transform) s.transform = effects.transform;
|
|
4898
5009
|
if (effects.transformOrigin) s.transformOrigin = effects.transformOrigin;
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
|
|
4902
|
-
|
|
4903
|
-
|
|
4904
|
-
|
|
5010
|
+
const multiply = style.colorMultiply ? colorMultiplyFilter(style.colorMultiply, el) : '';
|
|
5011
|
+
const asOne = style.compositingGroup === true || style.cornerRadius != null || style.clipped === true || (style.mask != null && typeof style.mask === 'object');
|
|
5012
|
+
const filter = [multiply, effects.filter ?? '', asOne ? 'var(--sb-shadow-filter, )' : ''].filter(Boolean).join(' ');
|
|
5013
|
+
if (filter) s.filter = filter;
|
|
5014
|
+
if (asOne) el.dataset['sbAsOne'] = '';
|
|
5015
|
+
else delete el.dataset['sbAsOne'];
|
|
5016
|
+
if (style.shadow) {
|
|
5017
|
+
el.dataset['sbShadow'] = `drop-shadow(${shadowCSS(style.shadow)})`;
|
|
5018
|
+
el.dataset['sbShadowBox'] = shadowCSS(style.shadow);
|
|
5019
|
+
} else if (el.dataset['sbShadow'] !== undefined) {
|
|
5020
|
+
delete el.dataset['sbShadow'];
|
|
5021
|
+
delete el.dataset['sbShadowBox'];
|
|
5022
|
+
s.removeProperty('--sb-shadow-filter');
|
|
5023
|
+
s.removeProperty('--sb-shadow-box');
|
|
5024
|
+
}
|
|
5025
|
+
if (style.compositingGroup) el.dataset['sbComposite'] = '';
|
|
5026
|
+
else delete el.dataset['sbComposite'];
|
|
4905
5027
|
// Phase 7: `.hidden()` keeps the box (the engine lays it out) but draws
|
|
4906
5028
|
// nothing and takes no input; `.mask { }` clips the box to a style's alpha.
|
|
4907
5029
|
if (effects.hidden) {
|
package/src/scrollTransition.ts
CHANGED
|
@@ -35,7 +35,8 @@ export function setScrollTransition(el: HTMLElement, style: Style): void {
|
|
|
35
35
|
}
|
|
36
36
|
return;
|
|
37
37
|
}
|
|
38
|
-
|
|
38
|
+
// Its shadow is cast by the views it draws (renderer.ts castShadows), not by it.
|
|
39
|
+
const own = effectsCSS(style, { shadow: false });
|
|
39
40
|
registered.set(el, {
|
|
40
41
|
spec,
|
|
41
42
|
base: { transform: own.transform ?? '', filter: own.filter ?? '', opacity: style.opacity ?? 1 },
|