@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/node/dist/node/apps.d.ts +5 -0
- package/node/dist/node/apps.d.ts.map +1 -1
- package/node/dist/node/apps.js +3 -0
- package/node/dist/node/apps.js.map +1 -1
- package/node/dist/node/config.d.ts +2 -0
- package/node/dist/node/config.d.ts.map +1 -1
- package/node/dist/node/config.js +2 -0
- package/node/dist/node/config.js.map +1 -1
- package/package.json +1 -1
- package/public/_headers +5 -0
- package/src/animation.ts +60 -27
- package/src/appFiles.ts +21 -16
- package/src/audioRecorder.ts +392 -0
- package/src/editorSelection.ts +80 -0
- package/src/files.ts +442 -0
- package/src/gestureComposition.ts +92 -0
- package/src/hostRequests.ts +50 -5
- package/src/interaction.ts +108 -27
- package/src/layout/engine.ts +295 -19
- package/src/layout/types.ts +6 -0
- package/src/layoutDom.ts +13 -5
- package/src/main.ts +10 -1
- package/src/notifications.ts +134 -1
- package/src/pinch.ts +9 -1
- package/src/pinnedViews.ts +131 -0
- package/src/plainEditor.ts +79 -0
- package/src/protocol.ts +307 -14
- package/src/renderer.ts +354 -43
- package/src/richEditor.ts +330 -0
- package/src/scrollGeometry.ts +79 -0
- package/src/scrollTransition.ts +145 -0
- package/src/styles.css +142 -11
- package/src/symbols.ts +22 -0
- package/src/touchPaging.ts +23 -2
- package/src/undo.ts +126 -0
- package/src/video.ts +59 -8
- package/src/vite-env.d.ts +2 -0
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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),
|
package/src/touchPaging.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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 (
|
|
114
|
+
if (state.source !== url) {
|
|
115
|
+
state.source = url;
|
|
89
116
|
if (url) {
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
}
|