@swiftbrowser/web 0.17.7 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/styles.css CHANGED
@@ -37,6 +37,9 @@
37
37
  device frame for the selected size (typography.ts); the fallbacks are the
38
38
  Large values. Unstyled text is `body` and scales with it. */
39
39
  --sb-font-size-body: var(--sb-ts-body-size, 17px);
40
+ /* The app's accent as resolved here (its AccentColor or the system's),
41
+ for what keeps it inside bars, which draw in the label color. */
42
+ --sb-color-app-accent: var(--sb-color-accent);
40
43
  }
41
44
 
42
45
  .device,
@@ -476,6 +479,15 @@ html[data-sb-mode="device"] .device-screen {
476
479
  display: block;
477
480
  }
478
481
 
482
+ /* `.zIndex` orders a stack's children only: a stack with one under it
483
+ stacks its children on its own, so a negative index stays in front of
484
+ what is behind the stack (and takes its taps) rather than slipping under
485
+ it. The modifiers' boxes between (an `.id`, a `.shadow`) pass the index
486
+ through to the stack, as SwiftUI's modifiers pass the trait. */
487
+ :is(.sb-vstack, .sb-hstack, .sb-zstack):has([data-sb-z]) {
488
+ isolation: isolate;
489
+ }
490
+
479
491
  .sb-button {
480
492
  appearance: none;
481
493
  display: block;
@@ -793,7 +805,9 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
793
805
  padding: 0;
794
806
  /* `.multilineTextAlignment` reaches fields as it reaches text (inputs reset it) */
795
807
  text-align: inherit;
796
- color: var(--sb-color-primary);
808
+ /* `.foregroundStyle` colors a field's text as it does a text's (the
809
+ screen's color is the primary label's) */
810
+ color: inherit;
797
811
  background: transparent;
798
812
  border: 0;
799
813
  outline: none;
@@ -871,6 +885,67 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
871
885
  transform: translateX(22px);
872
886
  }
873
887
 
888
+ /* `.toggleStyle(.button)`: no switch; the toggle is a button in the tint,
889
+ filled with it faintly while on (as a bordered button), the same size
890
+ either way. In a bar it draws in the bar's label color while off and
891
+ the app's accent while on (a 50-tall pill in a bottom bar). */
892
+ .sb-toggle[data-sb-toggle-style="button"] {
893
+ border-radius: 9999px;
894
+ color: var(--sb-tint, var(--sb-color-accent));
895
+ cursor: pointer;
896
+ touch-action: manipulation;
897
+ -webkit-tap-highlight-color: transparent;
898
+ transition: background-color 0.2s ease;
899
+ }
900
+
901
+ .sb-toggle[data-sb-toggle-style="button"] > .sb-switch {
902
+ display: none;
903
+ }
904
+
905
+ .sb-toggle[data-sb-toggle-style="button"][data-sb-on="true"] {
906
+ background: var(--sb-tint-fill, color-mix(in srgb, var(--sb-tint, var(--sb-color-accent)) var(--sb-tint-fill-amount), transparent));
907
+ }
908
+
909
+ .sb-toolbar .sb-toggle[data-sb-toggle-style="button"][data-sb-on="true"] {
910
+ color: var(--sb-tint, var(--sb-color-app-accent));
911
+ background: color-mix(in srgb, var(--sb-tint, var(--sb-color-app-accent)) var(--sb-tint-fill-amount), transparent);
912
+ }
913
+
914
+ /* A toggle in a bar (the automatic style) is no switch either: its switch
915
+ element is a pill under the label (the engine's `switch` rect, 10 wider
916
+ on each side and 36 tall), filled with the app's accent while on, the
917
+ label then in a light accent unless it has a color of its own. */
918
+ .sb-toolbar .sb-toggle:not([data-sb-toggle-style]) {
919
+ isolation: isolate;
920
+ cursor: pointer;
921
+ }
922
+
923
+ .sb-toolbar .sb-toggle:not([data-sb-toggle-style]) > .sb-switch {
924
+ z-index: -1;
925
+ background: transparent;
926
+ }
927
+
928
+ .sb-toolbar .sb-toggle:not([data-sb-toggle-style]) > .sb-switch > .sb-switch-knob {
929
+ display: none;
930
+ }
931
+
932
+ .sb-toolbar .sb-toggle:not([data-sb-toggle-style])[data-sb-on="true"] > .sb-switch {
933
+ background: var(--sb-tint, var(--sb-color-app-accent));
934
+ }
935
+
936
+ .sb-toolbar .sb-toggle:not([data-sb-toggle-style])[data-sb-on="true"] {
937
+ color: color-mix(in srgb, var(--sb-tint, var(--sb-color-app-accent)) 50%, #ffffff);
938
+ }
939
+
940
+ .sb-toggle[data-sb-toggle-style="button"]:active {
941
+ opacity: 0.3;
942
+ }
943
+
944
+ .sb-toggle[data-sb-toggle-style="button"]:focus-visible {
945
+ outline: 2px solid var(--sb-color-accent);
946
+ outline-offset: 2px;
947
+ }
948
+
874
949
  /* ---------------------------------------------------------------------------
875
950
  Phase 2: lists (iOS inset grouped). Geometry comes from the engine: the
876
951
  list is the scrolling viewport, sections carry the inset card (`rows`),
@@ -1717,11 +1792,15 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
1717
1792
  --sb-icon-knockout: var(--sb-color-system-background);
1718
1793
  }
1719
1794
 
1720
- .sb-list[data-sb-list-style="plain"] .sb-section-rows,
1795
+ .sb-list[data-sb-list-style="plain"] .sb-section-rows {
1796
+ background: var(--sb-color-system-background);
1797
+ }
1798
+
1799
+ /* A plain list's rows keep their `.listRowBackground(_:)` too. */
1721
1800
  .sb-list[data-sb-list-style="plain"] .sb-section-rows > [data-sb-id],
1722
1801
  .sb-list[data-sb-list-style="plain"] .sb-section-rows > [data-sb-ghost],
1723
1802
  .sb-list[data-sb-list-style="plain"] > .sb-list-content > :not(.sb-section) {
1724
- background: var(--sb-color-system-background);
1803
+ background: var(--sb-row-background, var(--sb-color-system-background));
1725
1804
  }
1726
1805
 
1727
1806
  .sb-navscreen:has(> .sb-navscreen-body > .sb-navscreen-content > .sb-list[data-sb-list-style="plain"]) {
@@ -2507,14 +2586,42 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2507
2586
  --sb-color-accent: var(--sb-color-primary);
2508
2587
  }
2509
2588
 
2589
+ /* A `keyboard` toolbar (the input accessory view): its items on the glass
2590
+ capsule the engine floats above the keyboard (chrome `keyboardBar`, 48
2591
+ tall, 10 in from the sides and 10 above the keyboard, as Notes' format
2592
+ bar on iOS 26; drawn by the screen's `.sb-keyboard-bar`), over the
2593
+ content, which stops above it. */
2594
+ .sb-toolbar[data-sb-placement="keyboard"] {
2595
+ z-index: 4;
2596
+ }
2597
+
2598
+ .sb-keyboard-bar {
2599
+ position: absolute;
2600
+ z-index: 3;
2601
+ box-sizing: border-box;
2602
+ border-radius: 9999px;
2603
+ background: var(--sb-glass-background);
2604
+ box-shadow: var(--sb-glass-shadow);
2605
+ -webkit-backdrop-filter: blur(12px) saturate(180%);
2606
+ backdrop-filter: blur(12px) saturate(180%);
2607
+ pointer-events: none;
2608
+ }
2609
+
2610
+ /* A prominent or glass button in a bar is drawn in the app's accent (its
2611
+ own 48 circle in a bottom bar), not the bar's label color. */
2612
+ .sb-toolbar .sb-button[data-sb-style="borderedProminent"] {
2613
+ background: var(--sb-tint, var(--sb-color-app-accent));
2614
+ }
2615
+
2510
2616
  /* The bar's Liquid Glass capsules (src/layoutDom.ts; engine `leadingGlass0`
2511
2617
  …, `trailingGlass0` …, `bottomGlass0` …): a 44-tall capsule in the
2512
- navigation bar, a 48-tall one in the bottom bar, under the items drawn on
2513
- them (`data-sb-toolbars`). */
2618
+ navigation bar, a 48-tall one in the bottom bar (60 around a button
2619
+ toggle), under the items drawn on them (`data-sb-toolbars`). Each is a
2620
+ capsule, its ends half its height, whatever the height. */
2514
2621
  .sb-bar-glass {
2515
2622
  position: absolute;
2516
2623
  z-index: 2;
2517
- border-radius: 22px;
2624
+ border-radius: 9999px;
2518
2625
  background: var(--sb-glass-background);
2519
2626
  box-shadow: var(--sb-glass-shadow);
2520
2627
  backdrop-filter: blur(12px) saturate(180%);
@@ -2522,10 +2629,6 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
2522
2629
  pointer-events: none;
2523
2630
  }
2524
2631
 
2525
- .sb-bar-glass[data-sb-placement="bottom"] {
2526
- border-radius: 24px;
2527
- }
2528
-
2529
2632
  /* While a sheet slides in or out, the glass in it does not blur. What is
2530
2633
  painted over a backdrop filter is composited on its own, and WebKit on
2531
2634
  iOS does not draw such a layer while the card it is in runs an
@@ -3052,6 +3155,12 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
3052
3155
  pointer-events: auto;
3053
3156
  }
3054
3157
 
3158
+ /* `.contentShape(Rectangle())`: the box is hit across its area, empty space
3159
+ and inert content (a stroke-only shape) included. */
3160
+ .sb-styled[data-sb-content-shape] {
3161
+ pointer-events: auto;
3162
+ }
3163
+
3055
3164
  /* A shape that is only stroked (`.stroke`, `.strokeBorder`) is hit along its
3056
3165
  line in SwiftUI, not across its box: an outline overlaid on a control must
3057
3166
  not swallow its taps. Its box takes none, except as the content of a
@@ -3653,6 +3762,16 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
3653
3762
  pointer-events: none;
3654
3763
  }
3655
3764
 
3765
+ /* An alert's text fields (`.alert { TextField(...) }`): a 36 row the engine
3766
+ gives them (chrome `field<i>`), a gray rounded field with the text 12 in. */
3767
+ .sb-alert .sb-alert-buttons > .sb-textfield {
3768
+ box-sizing: border-box;
3769
+ padding: 0 12px;
3770
+ border-radius: 10px;
3771
+ background: var(--sb-color-tertiary-fill);
3772
+ font-size: 15px;
3773
+ }
3774
+
3656
3775
  /* iOS 26: an alert's buttons are gray capsules, in the label color. */
3657
3776
  .sb-alert[data-sb-style="alert"] .sb-alert-buttons > .sb-button,
3658
3777
  .sb-alert[data-sb-style="alert"] .sb-alert-ok {
@@ -3919,12 +4038,24 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
3919
4038
  resize: none;
3920
4039
  overflow: auto;
3921
4040
  background: transparent;
3922
- color: var(--sb-color-primary);
4041
+ /* `.foregroundStyle` colors its text, as a field's (the screen's color is
4042
+ the primary label's) */
4043
+ color: inherit;
3923
4044
  font: inherit;
3924
4045
  font-size: var(--sb-font-size-body);
3925
4046
  line-height: 1.3;
3926
4047
  }
3927
4048
 
4049
+ /* The rich editor (src/richEditor.ts): a contenteditable box of a span per
4050
+ run, wrapping as the engine measured its lines (one per newline). */
4051
+ div.sb-texteditor {
4052
+ white-space: pre-wrap;
4053
+ overflow-wrap: break-word;
4054
+ cursor: text;
4055
+ -webkit-user-select: text;
4056
+ user-select: text;
4057
+ }
4058
+
3928
4059
  @media (prefers-reduced-motion: reduce) {
3929
4060
  .sb-alert-scrim,
3930
4061
  .sb-alert--in > .sb-alert-layer,
package/src/symbols.ts CHANGED
@@ -163,6 +163,7 @@ import {
163
163
  Fingerprint,
164
164
  Fish,
165
165
  Flag,
166
+ FlagOff,
166
167
  Flame,
167
168
  Folder,
168
169
  FolderMinus,
@@ -212,11 +213,13 @@ import {
212
213
  Leaf,
213
214
  Library,
214
215
  Lightbulb,
216
+ LineSquiggle,
215
217
  Link,
216
218
  Link2,
217
219
  List,
218
220
  ListChecks,
219
221
  ListFilter,
222
+ ListIndentDecrease,
220
223
  ListIndentIncrease,
221
224
  ListMusic,
222
225
  ListOrdered,
@@ -371,6 +374,7 @@ import {
371
374
  TextAlignEnd,
372
375
  TextAlignJustify,
373
376
  TextAlignStart,
377
+ TextCursorInput,
374
378
  Thermometer,
375
379
  ThumbsDown,
376
380
  ThumbsUp,
@@ -639,6 +643,14 @@ export const SF_SYMBOLS: Readonly<Record<string, SymbolGlyph>> = {
639
643
  'forward.fill': solid(FastForward),
640
644
  goforward: outline(RotateCw),
641
645
  gobackward: outline(RotateCcw),
646
+ // The skips' number is not drawn: the arrow alone.
647
+ 'gobackward.10': outline(RotateCcw),
648
+ 'gobackward.15': outline(RotateCcw),
649
+ 'gobackward.30': outline(RotateCcw),
650
+ 'goforward.10': outline(RotateCw),
651
+ 'goforward.15': outline(RotateCw),
652
+ 'goforward.30': outline(RotateCw),
653
+ 'record.circle': outline(CircleDot),
642
654
  cube: outline(Box),
643
655
  'cube.fill': outline(Box),
644
656
  shift: outline(ArrowBigUp),
@@ -684,6 +696,7 @@ export const SF_SYMBOLS: Readonly<Record<string, SymbolGlyph>> = {
684
696
  'square.3.stack.3d': outline(Layers),
685
697
  'hand.point.up.left': outline(Pointer),
686
698
  'pin.slash': outline(PinOff),
699
+ 'pin.slash.fill': outline(PinOff),
687
700
  'square.dotted': outline(SquareDashed),
688
701
  'rectangle.dashed': outline(SquareDashed),
689
702
  'circle.dashed': outline(CircleDashed),
@@ -784,6 +797,8 @@ export const SF_SYMBOLS: Readonly<Record<string, SymbolGlyph>> = {
784
797
  'bookmark.fill': solid(Bookmark),
785
798
  flag: outline(Flag),
786
799
  'flag.fill': solid(Flag),
800
+ 'flag.slash': outline(FlagOff),
801
+ 'flag.slash.fill': outline(FlagOff),
787
802
  tag: outline(Tag),
788
803
  'tag.fill': solid(Tag),
789
804
  textformat: outline(CaseSensitive),
@@ -1125,6 +1140,8 @@ export const SF_SYMBOLS: Readonly<Record<string, SymbolGlyph>> = {
1125
1140
  character: outline(Type),
1126
1141
  'list.dash': outline(List),
1127
1142
  'list.bullet.indent': outline(ListIndentIncrease),
1143
+ 'increase.indent': outline(ListIndentIncrease),
1144
+ 'decrease.indent': outline(ListIndentDecrease),
1128
1145
  'list.clipboard': outline(ClipboardList),
1129
1146
  'square.grid.3x3': outline(Grid3x3),
1130
1147
  'square.grid.2x2.fill': solid(LayoutGrid),
@@ -1249,6 +1266,11 @@ export const SF_SYMBOLS: Readonly<Record<string, SymbolGlyph>> = {
1249
1266
  paintbrush: outline(Paintbrush),
1250
1267
  paintpalette: outline(Palette),
1251
1268
  'pencil.tip': outline(PenTool),
1269
+ 'pencil.tip.crop.circle': outline(PenTool),
1270
+ 'pencil.tip.crop.circle.fill': knockout(PenTool),
1271
+ scribble: outline(LineSquiggle),
1272
+ 'scribble.variable': outline(LineSquiggle),
1273
+ 'character.textbox': outline(TextCursorInput),
1252
1274
  'pencil.slash': outline(PencilOff),
1253
1275
  highlighter: outline(Highlighter),
1254
1276
  scissors: outline(Scissors),
@@ -155,10 +155,30 @@ function pageAt(el: HTMLElement, horizontal: boolean, offset: number): Page | nu
155
155
  return pagesOf(el, horizontal).find((p) => p.start <= offset + 1 && offset < p.start + p.length) ?? null;
156
156
  }
157
157
 
158
- /** Pages of a paging scroll view in scroll coordinates along its axis: the children of its `scrollTargetLayout` stack, else of its content. */
158
+ /**
159
+ * Pages of the viewport's length over a scroll extent: what `.paging` turns
160
+ * by when nothing marks the pages, as SwiftUI's does (the last one may run
161
+ * past the end; the rest intervals clamp it).
162
+ */
163
+ export function viewportPages(extent: number, viewport: number): { start: number; length: number }[] {
164
+ if (viewport <= 0) return [];
165
+ const count = Math.max(1, Math.ceil(extent / viewport - 0.01));
166
+ return Array.from({ length: count }, (_, i) => ({ start: i * viewport, length: viewport }));
167
+ }
168
+
169
+ /**
170
+ * Pages of a paging scroll view in scroll coordinates along its axis: the
171
+ * children of its `scrollTargetLayout` stack, else of its content when it
172
+ * has several, else the viewport's length after length (the view itself
173
+ * their element).
174
+ */
159
175
  export function pagesOf(el: HTMLElement, horizontal: boolean): Page[] {
160
176
  const targets = [...el.querySelectorAll<HTMLElement>('[data-sb-scroll-target] > *')].filter((t) => t.parentElement?.closest('.sb-scrollview, .sb-list') === el);
161
177
  const children = targets.length > 0 ? targets : [...((el.firstElementChild?.children ?? []) as Iterable<Element>)];
178
+ if (children.length <= 1) {
179
+ const pages = viewportPages(horizontal ? el.scrollWidth : el.scrollHeight, horizontal ? el.clientWidth : el.clientHeight);
180
+ return pages.map((page) => ({ element: el, ...page }));
181
+ }
162
182
  const box = el.getBoundingClientRect();
163
183
  const computed = getComputedStyle(el);
164
184
  const padding = parseFloat(horizontal ? computed.scrollPaddingLeft : computed.scrollPaddingTop) || 0;
@@ -246,7 +266,8 @@ export class TouchPaging {
246
266
  const g = this.gesture;
247
267
  if (!held || !g?.ours || g.el !== held.el) return;
248
268
  const now = g.horizontal ? g.el.scrollLeft : g.el.scrollTop;
249
- const page = held.page ? pagesOf(g.el, g.horizontal).find((p) => p.element === held.page!.element) : undefined;
269
+ // A page of the viewport's length (the view itself its element) is no page to find again.
270
+ const page = held.page && held.page.element !== g.el ? pagesOf(g.el, g.horizontal).find((p) => p.element === held.page!.element) : undefined;
250
271
  const shift = page ? page.start - held.page!.start : now - held.offset;
251
272
  g.from += shift;
252
273
  if (page) {
package/src/undo.ts ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The system's undo for the app's undo manager (`useUndoManager` in
3
+ * @swiftbrowser/react, whose stack is the truth): where the system's undo
4
+ * and redo come from, and what it is told the stack can do. In a browser
5
+ * that is the keyboard: ⌘Z and ⇧⌘Z (Ctrl-Z, Ctrl-Shift-Z and Ctrl-Y off
6
+ * Apple platforms) while no text input has the focus go to the manager; a
7
+ * focused field or editor keeps the browser's own undo of what was typed in
8
+ * it. A page has no Edit menu, so `update` has nothing to show. The native
9
+ * app replaces this host with one over the window's `UndoManager`
10
+ * (packages/native/src/undo.ts), which the shake, the three-finger swipe,
11
+ * the keyboard and the Edit menu reach.
12
+ *
13
+ * A Swift app's manager is in Wasm (the shim's `UndoManager`, one per app):
14
+ * it reports what it can do through `undo` host requests (`performUndo`),
15
+ * and the host's commands go back to it as `undo` and `redo` events
16
+ * (`relayUndo`, which the page installs in the request environment).
17
+ */
18
+
19
+ import type { UndoRequestParams } from './protocol';
20
+
21
+ /** What the stack can do, for the system to enable and name (the Edit menu's "Undo Move"). */
22
+ export interface UndoState {
23
+ canUndo: boolean;
24
+ canRedo: boolean;
25
+ /** The name of what undo takes back, or '' for none. */
26
+ undoActionName: string;
27
+ redoActionName: string;
28
+ }
29
+
30
+ /** The manager's undo and redo, as the system calls them. */
31
+ export interface UndoCommands {
32
+ undo(): void;
33
+ redo(): void;
34
+ }
35
+
36
+ export interface UndoHost {
37
+ /** The stack after a change: what the system enables and names. */
38
+ update(state: UndoState): void;
39
+ /**
40
+ * Where the system's undo and redo go from now on, until the returned
41
+ * function is called. One manager at a time: the last to listen.
42
+ */
43
+ listen(commands: UndoCommands): () => void;
44
+ }
45
+
46
+ /** The part of a `Document` the host listens on; tests fake it. */
47
+ export interface UndoKeyTarget {
48
+ addEventListener(type: 'keydown', listener: (event: KeyboardEvent) => void): void;
49
+ removeEventListener(type: 'keydown', listener: (event: KeyboardEvent) => void): void;
50
+ }
51
+
52
+ /** Whether the keyboard's command key is ⌘ (Apple platforms) rather than Ctrl. */
53
+ export const isApplePlatform = (): boolean => typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
54
+
55
+ /** The undo or redo a key press asks for, or null for any other press. */
56
+ export function undoKey(event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>, apple: boolean): 'undo' | 'redo' | null {
57
+ if (event.altKey) return null;
58
+ const command = apple ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey;
59
+ if (!command) return null;
60
+ const key = event.key.toLowerCase();
61
+ if (key === 'z') return event.shiftKey ? 'redo' : 'undo';
62
+ if (key === 'y' && !apple && !event.shiftKey) return 'redo';
63
+ return null;
64
+ }
65
+
66
+ /** Whether `target` is in a text input, whose own undo the browser keeps. */
67
+ export function inTextInput(target: EventTarget | null): boolean {
68
+ const element = target as { closest?: (selector: string) => unknown } | null;
69
+ return typeof element?.closest === 'function' && element.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') != null;
70
+ }
71
+
72
+ /** The page's host: the keyboard, listened to while a manager listens. */
73
+ export function browserUndoHost(target: UndoKeyTarget, apple: boolean = isApplePlatform()): UndoHost {
74
+ let listener: ((event: KeyboardEvent) => void) | null = null;
75
+ return {
76
+ update: () => undefined,
77
+ listen(commands) {
78
+ if (listener) target.removeEventListener('keydown', listener);
79
+ const onKey = (event: KeyboardEvent): void => {
80
+ if (event.defaultPrevented || event.isComposing || inTextInput(event.target)) return;
81
+ const command = undoKey(event, apple);
82
+ if (!command) return;
83
+ event.preventDefault();
84
+ commands[command]();
85
+ };
86
+ listener = onKey;
87
+ target.addEventListener('keydown', onKey);
88
+ return () => {
89
+ if (listener !== onKey) return;
90
+ target.removeEventListener('keydown', onKey);
91
+ listener = null;
92
+ };
93
+ },
94
+ };
95
+ }
96
+
97
+ /**
98
+ * An `undo` host request: the Swift app's manager, after a turn that changed
99
+ * what it can do. The host shows nothing on a page, but learns the state;
100
+ * a page without a host (no document) has nothing to tell. Answered `{}`.
101
+ */
102
+ export function performUndo(params: UndoRequestParams, host: UndoHost | undefined): Record<string, never> {
103
+ host?.update({ canUndo: params.canUndo, canRedo: params.canRedo, undoActionName: params.undoActionName, redoActionName: params.redoActionName });
104
+ return {};
105
+ }
106
+
107
+ /**
108
+ * The host as the Swift app's request environment sees it: `update` passes
109
+ * through, and the first one (the app has a manager in use) makes the host
110
+ * listen, its undo and redo sent to the app as `undo` and `redo` events,
111
+ * which the runtime routes to the manager. Listening lasts: the stack the
112
+ * keys act on is the app's, which knows when there is nothing to undo.
113
+ */
114
+ export function relayUndo(host: UndoHost, send: (event: { type: 'undo' | 'redo' }) => void): UndoHost {
115
+ let listening = false;
116
+ return {
117
+ update(state) {
118
+ if (!listening) {
119
+ listening = true;
120
+ host.listen({ undo: () => send({ type: 'undo' }), redo: () => send({ type: 'redo' }) });
121
+ }
122
+ host.update(state);
123
+ },
124
+ listen: (commands) => host.listen(commands),
125
+ };
126
+ }
package/src/video.ts CHANGED
@@ -18,8 +18,11 @@
18
18
  */
19
19
 
20
20
  import type { ElementId, RendererEvent, VideoEvent, VideoProps } from './protocol';
21
+ import { loadableURL } from './hostRequests';
21
22
 
22
23
  interface VideoState {
24
+ /** The `url` prop as last applied ('' for none); the media's `src` is what `loadableURL` made of it. */
25
+ source: string;
23
26
  /** The props last applied. */
24
27
  props: VideoProps | null;
25
28
  /** The highest `seek` serial carried out (-1: none yet). */
@@ -30,8 +33,31 @@ interface VideoState {
30
33
  lastTime: number;
31
34
  /** The `requestAnimationFrame` loop reporting at `report`, if running. */
32
35
  ticker: number | null;
36
+ /** The position the frame loop last reported and when (`smoothTime`); null between runs of playback. */
37
+ smooth: { time: number; at: number } | null;
33
38
  }
34
39
 
40
+ /**
41
+ * The position a frame report gives: the media clock, smoothed. Safari
42
+ * runs the element's clock from `play()` and holds it, a tenth of a
43
+ * second or more, while the audio hardware catches up, the sound playing
44
+ * on meanwhile; a playhead on that clock stalls. Between the clock's
45
+ * updates the position advances with the page's clock at the playback
46
+ * rate and is pulled toward the media clock over about `PULL` seconds, so
47
+ * a hold passes unseen and the drift it leaves closes gently; a jump of
48
+ * more than `SNAP` (a seek, a stall of real length) is taken at once.
49
+ */
50
+ export function smoothTime(previous: { time: number; at: number } | null, clock: number, now: number, rate: number): number {
51
+ if (previous === null) return clock;
52
+ const elapsed = Math.max(0, (now - previous.at) / 1000);
53
+ const expected = previous.time + elapsed * rate;
54
+ const error = clock - expected;
55
+ if (Math.abs(error) > SNAP) return clock;
56
+ return expected + error * Math.min(1, elapsed / PULL);
57
+ }
58
+ const PULL = 0.3;
59
+ const SNAP = 0.5;
60
+
35
61
  const STATES = new WeakMap<HTMLVideoElement, VideoState>();
36
62
  /** `video` elements made so far; `stopVideos` has nothing to do while there are none. */
37
63
  let made = 0;
@@ -61,7 +87,7 @@ function mediaOf(el: HTMLElement): HTMLVideoElement {
61
87
  video.preload = 'metadata';
62
88
  video.style.cssText = 'display:block;width:100%;height:100%;background:#000;object-fit:contain';
63
89
  el.replaceChildren(video);
64
- STATES.set(video, { props: null, seekSerial: -1, pendingSeek: null, lastTime: 0, ticker: null });
90
+ STATES.set(video, { props: null, source: '', seekSerial: -1, pendingSeek: null, lastTime: 0, ticker: null, smooth: null });
65
91
  made += 1;
66
92
  return video;
67
93
  }
@@ -85,9 +111,20 @@ export function applyVideo(el: HTMLElement, props: VideoProps): void {
85
111
  el.dataset['sbGravity'] = props.gravity ?? 'resizeAspect';
86
112
 
87
113
  const url = typeof props.url === 'string' ? props.url : '';
88
- if ((video.getAttribute('src') ?? '') !== url) {
114
+ if (state.source !== url) {
115
+ state.source = url;
89
116
  if (url) {
90
- video.src = url;
117
+ const loadable = loadableURL(url);
118
+ if (typeof loadable === 'string') {
119
+ video.src = loadable;
120
+ } else {
121
+ // A file of the app's: its object URL comes later; play then if asked.
122
+ void loadable.then((src) => {
123
+ if (state.source !== url) return;
124
+ video.src = src;
125
+ if (state.props?.playing === true && video.paused) startPlaying(video);
126
+ });
127
+ }
91
128
  } else {
92
129
  video.removeAttribute('src');
93
130
  video.load();
@@ -119,7 +156,12 @@ export function applyVideo(el: HTMLElement, props: VideoProps): void {
119
156
  }
120
157
 
121
158
  const playing = props.playing === true && url !== '';
122
- video.preload = playing ? 'auto' : 'metadata';
159
+ // A recording the page holds (a `data:` or `blob:` URL, a voice memo) is
160
+ // decoded ahead, so play() starts the sound at once: on an iPhone the
161
+ // media clock runs from play() while the decode and the audio hardware
162
+ // catch up, and the position held a third of a second after it set off.
163
+ // A remote video is fetched ahead only once it plays.
164
+ video.preload = playing || /^(data|blob):/.test(url) ? 'auto' : 'metadata';
123
165
  if (playing) {
124
166
  // An ended video plays again only after a seek (play() would restart it).
125
167
  if (video.paused && (seeked || !video.ended)) startPlaying(video);
@@ -151,6 +193,7 @@ function updateTicker(video: HTMLVideoElement, state: VideoState): void {
151
193
  if (!wanted) {
152
194
  if (state.ticker !== null) cancelAnimationFrame(state.ticker);
153
195
  state.ticker = null;
196
+ state.smooth = null;
154
197
  return;
155
198
  }
156
199
  if (state.ticker !== null || typeof requestAnimationFrame !== 'function') return;
@@ -158,7 +201,8 @@ function updateTicker(video: HTMLVideoElement, state: VideoState): void {
158
201
  state.ticker = null;
159
202
  const report = state.props?.report;
160
203
  if (!video.isConnected || state.props?.playing !== true || typeof report !== 'number' || report <= 0) return;
161
- if (!video.paused && performance.now() - state.lastTime >= report * 1000) video.dispatchEvent(new CustomEvent(TICK));
204
+ // A millisecond under the interval counts (a `report` of a frame fires every frame, not every other).
205
+ if (!video.paused && performance.now() - state.lastTime >= report * 1000 - 1) video.dispatchEvent(new CustomEvent(TICK));
162
206
  state.ticker = requestAnimationFrame(frame);
163
207
  };
164
208
  state.ticker = requestAnimationFrame(frame);
@@ -229,6 +273,8 @@ export class VideoEvents {
229
273
  const props = state.props!;
230
274
  const time = round(video.currentTime);
231
275
  const url = video.getAttribute('src') ?? '';
276
+ // A seek, a pause, a new source or the end moves the clock on purpose: the next frame report starts from it.
277
+ if (event.type !== TICK && event.type !== 'timeupdate') state.smooth = null;
232
278
  switch (event.type) {
233
279
  case 'loadedmetadata':
234
280
  return {
@@ -244,9 +290,14 @@ export class VideoEvents {
244
290
  if (!video.paused && performance.now() - state.lastTime < TIME_EVENT_MIN_MS) return null;
245
291
  state.lastTime = performance.now();
246
292
  return { event: 'time', time };
247
- case TICK:
248
- state.lastTime = performance.now();
249
- return { event: 'time', time };
293
+ case TICK: {
294
+ const now = performance.now();
295
+ state.lastTime = now;
296
+ const rate = video.playbackRate > 0 ? video.playbackRate : 1;
297
+ const smoothed = round(Math.min(Number.isFinite(video.duration) ? video.duration : Infinity, smoothTime(state.smooth, video.currentTime, now, rate)));
298
+ state.smooth = { time: smoothed, at: now };
299
+ return { event: 'time', time: smoothed };
300
+ }
250
301
  case 'playing':
251
302
  return { event: 'status', status: 'playing', time };
252
303
  case 'waiting':
package/src/vite-env.d.ts CHANGED
@@ -18,6 +18,8 @@ interface SBLandingApp {
18
18
  urlSchemes?: string[];
19
19
  /** A CORS proxy (packages/cors-proxy) through which a refused cross-origin GET is retried (src/corsProxy.ts). */
20
20
  corsProxy?: string;
21
+ /** The Web Push application server key the app subscribes with (`SB_WEB_PUSH_KEY`, src/notifications.ts). */
22
+ webPushKey?: string;
21
23
  /** Hosts the app's requests may not reach (src/blockedHosts.ts). */
22
24
  blockedHosts?: string[];
23
25
  }