@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.
@@ -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
- if (options.accept) input.accept = options.accept;
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', () => finish(Array.from(input.files ?? [])), { once: true });
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();
@@ -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
- if (this.session || !event.isPrimary || (event.pointerType === 'mouse' && event.button !== 0)) return;
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
@@ -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 always compact; device mode follows the viewport.
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
@@ -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
- move(header, horizontal, pinnedHeaderOffset(viewStart, span, sectionEnd));
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) {
@@ -9,15 +9,36 @@
9
9
  */
10
10
 
11
11
  import type { TextEditorProps, TextSelectionRange } from './protocol';
12
- import { editorBlurred, isSelectionBound, markSelectionBound, noteViewerSelection, selectionToApply, takePendingSelection, viewerFocused } from './editorSelection';
12
+ import { editorBlurred, isSelectionBound, markSelectionBound, noteViewerSelection, selectionToApply, shiftOffset, takePendingSelection, viewerFocused } from './editorSelection';
13
+
14
+ /** A `text` that came from outside, with the editor's value and caret before it was applied (src/editorSelection.ts, `textArrived`). */
15
+ export interface OutsideChange {
16
+ before: string;
17
+ caret: TextSelectionRange | null;
18
+ }
13
19
 
14
20
  export function isPlainEditor(el: unknown): el is HTMLTextAreaElement {
15
21
  return el instanceof HTMLTextAreaElement && el.classList.contains('sb-texteditor');
16
22
  }
17
23
 
18
- /** After the text: a selection the app set (not the viewer's coming back), moved to now, or when the app focuses the editor. */
19
- export function applyPlainEditorSelection(area: HTMLTextAreaElement, props: TextEditorProps): void {
24
+ /**
25
+ * After the text: a selection the app set (not the viewer's coming back),
26
+ * moved to now, or when the app focuses the editor. After a text from
27
+ * outside (`outside`): the app's selection even when its numbers did not
28
+ * change, else the viewer's caret moved past the change (setting a
29
+ * textarea's value puts it at the end otherwise).
30
+ */
31
+ export function applyPlainEditorSelection(area: HTMLTextAreaElement, props: TextEditorProps, outside: OutsideChange | null = null): void {
20
32
  markSelectionBound(area, props.selection !== undefined);
33
+ if (outside) {
34
+ if (props.selection !== undefined) {
35
+ const range = selectionToApply(area, props.selection, true);
36
+ if (range) area.setSelectionRange(range.start, range.end);
37
+ } else if (outside.caret) {
38
+ area.setSelectionRange(shiftOffset(outside.before, area.value, outside.caret.start), shiftOffset(outside.before, area.value, outside.caret.end));
39
+ }
40
+ return;
41
+ }
21
42
  const range = selectionToApply(area, props.selection);
22
43
  if (range) area.setSelectionRange(range.start, range.end);
23
44
  }
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`