@swiftbrowser/web 0.17.8 → 0.18.1
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/package.json +1 -1
- package/src/animation.ts +60 -27
- package/src/appFiles.ts +21 -16
- package/src/audioRecorder.ts +392 -0
- package/src/blockedHosts.ts +3 -1
- package/src/corsProxy.ts +3 -1
- package/src/editorSelection.ts +80 -0
- package/src/files.ts +442 -0
- package/src/gestureComposition.ts +92 -0
- package/src/hostRequests.ts +47 -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 +4 -0
- package/src/pinch.ts +9 -1
- package/src/pinnedViews.ts +131 -0
- package/src/plainEditor.ts +79 -0
- package/src/protocol.ts +304 -13
- 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/renderer.ts
CHANGED
|
@@ -49,6 +49,10 @@ import {
|
|
|
49
49
|
import {
|
|
50
50
|
CanvasTextMeasurer,
|
|
51
51
|
LayoutPass,
|
|
52
|
+
defaultFont,
|
|
53
|
+
resolveFont,
|
|
54
|
+
textHeight,
|
|
55
|
+
wrapText,
|
|
52
56
|
type LayoutEnvironment,
|
|
53
57
|
type LayoutNode,
|
|
54
58
|
type LayoutResult,
|
|
@@ -58,11 +62,15 @@ import { LayoutReporter } from './layoutEvents';
|
|
|
58
62
|
import { createGaugeRing, isCircularGauge, updateGaugeRing } from './gaugeRing';
|
|
59
63
|
import { bitmapStore, showBitmap } from './bitmaps';
|
|
60
64
|
import { clearRichText, runTemplates } from './richText';
|
|
65
|
+
import { markDeleteBackward, wantsDeleteBackward } from './editorSelection';
|
|
66
|
+
import { applyPlainEditorSelection, focusPlainEditor, isPlainEditor, listenToPlainEditors } from './plainEditor';
|
|
67
|
+
import { applyRichEditor, createRichEditor, focusRichEditor, isRichEditor, listenToRichEditors, richEditorText } from './richEditor';
|
|
61
68
|
import { applyLayout, setTabHidden, type BoxChange } from './layoutDom';
|
|
62
69
|
import { BarMorph } from './barMorph';
|
|
63
70
|
import { ghostClone } from './ghost';
|
|
64
71
|
import { ExternalDrops } from './dragDrop';
|
|
65
72
|
import { InteractionController, type InteractionHost } from './interaction';
|
|
73
|
+
import { TapCounter } from './gestureComposition';
|
|
66
74
|
import { PressEffects } from './press';
|
|
67
75
|
import { symbolOf, textOf, unwrapStyledNode } from './nodeQueries';
|
|
68
76
|
import {
|
|
@@ -86,6 +94,7 @@ import {
|
|
|
86
94
|
type FontWeight,
|
|
87
95
|
type GaugeProps,
|
|
88
96
|
type GeometryEvent,
|
|
97
|
+
type ScrollGeometryEvent,
|
|
89
98
|
type GestureProps,
|
|
90
99
|
type GridProps,
|
|
91
100
|
type GridRowProps,
|
|
@@ -151,6 +160,7 @@ import {
|
|
|
151
160
|
import { filledSymbolName, renderSymbol } from './symbols';
|
|
152
161
|
import { applyPathShape, hasPathFrames, playPathFrames } from './pathShape';
|
|
153
162
|
import { VideoEvents, applyVideo, stopVideos } from './video';
|
|
163
|
+
import { loadableURL } from './hostRequests';
|
|
154
164
|
import { PinchController } from './pinch';
|
|
155
165
|
import { applyCanvas, createCanvasElement } from './canvasDrawing';
|
|
156
166
|
import { scrollToTarget } from './scrollTo';
|
|
@@ -166,6 +176,9 @@ import { colorMultiplyFilter } from './colorMultiply';
|
|
|
166
176
|
import { largeTitleOf, largeTitleTextOf } from './largeTitle';
|
|
167
177
|
import { applyEdgeEffect } from './scrollEdge';
|
|
168
178
|
import { discardScrollOffsets, readScrollOffsets, trackScroller } from './scrollSync';
|
|
179
|
+
import { drawScrollTransitions, setScrollTransition } from './scrollTransition';
|
|
180
|
+
import { ScrollGeometryReporter, readScrollGeometry } from './scrollGeometry';
|
|
181
|
+
import { drawPinnedViews, setPinnedViews } from './pinnedViews';
|
|
169
182
|
import { DEFAULT_TYPE_SIZE, TEXT_STYLE_WEIGHT, resolveTextStyle, textStyleVar } from './typography';
|
|
170
183
|
|
|
171
184
|
export type EventCallback = (event: RendererEvent) => void;
|
|
@@ -391,12 +404,21 @@ export class Renderer {
|
|
|
391
404
|
private readonly hover: HoverTracker;
|
|
392
405
|
/** A frame check of the `reportsFrame` readers is queued for the next animation frame (after a scroll). */
|
|
393
406
|
private frameCheckQueued = false;
|
|
407
|
+
/** `reportsGeometry` scroll views (src/scrollGeometry.ts): their ids, the last report of each, and whether a check waits for the next frame. */
|
|
408
|
+
private readonly scrollReporters = new Set<ElementId>();
|
|
409
|
+
private readonly scrollGeometry = new ScrollGeometryReporter();
|
|
410
|
+
private scrollGeometryQueued = false;
|
|
411
|
+
/** `scrollview`s whose `scrollOffset` command is new this pass: scrolled after its layout (`performOffsetScrolls`), and the serial each last carried out. */
|
|
412
|
+
private pendingOffsets: ElementId[] = [];
|
|
413
|
+
private readonly offsetSerials = new Map<ElementId, number>();
|
|
394
414
|
private readonly hostContents = new Map<ElementId, HTMLElement>();
|
|
395
415
|
/** The `host` elements, adopted or still waiting for their content. */
|
|
396
416
|
private readonly hosts = new Set<ElementId>();
|
|
397
417
|
private readonly hostSizes = new WeakMap<HTMLElement, { width: number; height: number }>();
|
|
398
418
|
private readonly hostMeasures = new Map<string, { width: number; height: number }>();
|
|
399
419
|
private readonly stopHostChanges: () => void;
|
|
420
|
+
private readonly stopRichEditors: () => void;
|
|
421
|
+
private readonly stopPlainEditors: () => void;
|
|
400
422
|
/**
|
|
401
423
|
* The root takes its content's height (`LayoutEnvironment.contentHeight`):
|
|
402
424
|
* a SwiftUI island in a page's own layout. Set before the first commit.
|
|
@@ -445,7 +467,7 @@ export class Renderer {
|
|
|
445
467
|
/** The elements whose size is reported (`geometry`, or `reportsSize` props): what `collectGeometry` visits. */
|
|
446
468
|
private readonly geometryReporters = new Set<ElementId>();
|
|
447
469
|
/** `geometry` and `layout` events collected by layout passes, sent once the ops are applied. */
|
|
448
|
-
private pendingGeometry: (GeometryEvent | LayoutEvent)[] = [];
|
|
470
|
+
private pendingGeometry: (GeometryEvent | LayoutEvent | ScrollGeometryEvent)[] = [];
|
|
449
471
|
/** Phase 6: dedupes `layout` events per element (src/layoutEvents.ts). */
|
|
450
472
|
private readonly layoutReporter = new LayoutReporter();
|
|
451
473
|
/** Nesting depth of `applyOps` (events are flushed when it returns to 0). */
|
|
@@ -493,6 +515,7 @@ export class Renderer {
|
|
|
493
515
|
const hit = event.target as HTMLElement | null;
|
|
494
516
|
const target = hit?.closest<HTMLElement>(
|
|
495
517
|
'.sb-button, .sb-navlink, .sb-navbar-back, .sb-navbar-more, .sb-navsplit-toggle, .sb-switch, .sb-sheet-scrim, ' +
|
|
518
|
+
'.sb-toggle[data-sb-toggle-style="button"], .sb-toolbar .sb-toggle:not([data-sb-toggle-style]), ' +
|
|
496
519
|
'.sb-tabbar-item, .sb-segment, .sb-picker-option, .sb-picker-menu-item, .sb-picker[data-sb-style="menu"], ' +
|
|
497
520
|
'.sb-menu, .sb-tapgesture, .sb-stepper-button, .sb-alert-scrim, .sb-alert-ok, ' +
|
|
498
521
|
// Phase 8: swipe action buttons, the edit-mode row control and selectable rows (the row sends `tap` itself)
|
|
@@ -618,12 +641,13 @@ export class Renderer {
|
|
|
618
641
|
this.onEvent({ type: 'tap', id });
|
|
619
642
|
return;
|
|
620
643
|
}
|
|
621
|
-
if (target.classList.contains('sb-switch')) {
|
|
644
|
+
if (target.classList.contains('sb-switch') || target.classList.contains('sb-toggle')) {
|
|
645
|
+
// The switch, or a toggle drawn as a button (the whole of it).
|
|
622
646
|
const toggle = target.closest<HTMLElement>('.sb-toggle');
|
|
623
647
|
const id = Number(toggle?.dataset[ID_ATTR]);
|
|
624
648
|
if (!toggle || !Number.isInteger(id)) return;
|
|
625
649
|
event.preventDefault();
|
|
626
|
-
const value =
|
|
650
|
+
const value = toggle.dataset['sbOn'] !== 'true';
|
|
627
651
|
// Flip optimistically so the switch animates right away; Swift's
|
|
628
652
|
// `update` confirms (or reverts) the state.
|
|
629
653
|
setSwitchState(toggle, value);
|
|
@@ -633,23 +657,38 @@ export class Renderer {
|
|
|
633
657
|
const id = Number(target.dataset[ID_ATTR]);
|
|
634
658
|
if (!Number.isInteger(id)) return;
|
|
635
659
|
event.preventDefault();
|
|
636
|
-
if (target.
|
|
637
|
-
// `onTapGesture
|
|
638
|
-
//
|
|
639
|
-
const
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
const y = Math.round(((event.clientY - rect.top) / (scale || 1)) * 100) / 100;
|
|
643
|
-
this.onEvent({ type: 'tap', id, x, y });
|
|
660
|
+
if (target.classList.contains('sb-tapgesture')) {
|
|
661
|
+
// `onTapGesture(count: 2)`: the innermost double tap on the way up
|
|
662
|
+
// counts the clicks; the innermost single tap waits for it.
|
|
663
|
+
const multi = target.closest<HTMLElement>('.sb-tapgesture[data-sb-tap-count]');
|
|
664
|
+
const single = target.closest<HTMLElement>('.sb-tapgesture:not([data-sb-tap-count])');
|
|
665
|
+
this.taps.click(multi, Number(multi?.dataset['sbTapCount'] ?? 1), single, { x: event.clientX, y: event.clientY });
|
|
644
666
|
return;
|
|
645
667
|
}
|
|
646
668
|
this.onEvent({ type: 'tap', id });
|
|
647
669
|
};
|
|
648
670
|
|
|
671
|
+
/** A `tapgesture` fires: with `location`, the click's point in the element's own coordinates (points; the device frame may be scaled). */
|
|
672
|
+
private readonly taps = new TapCounter<HTMLElement, { x: number; y: number }>((el, point) => {
|
|
673
|
+
const id = Number(el.dataset[ID_ATTR]);
|
|
674
|
+
if (!Number.isInteger(id)) return;
|
|
675
|
+
if (el.hasAttribute('data-sb-tap-location')) {
|
|
676
|
+
const rect = el.getBoundingClientRect();
|
|
677
|
+
const scale = el.offsetWidth > 0 ? rect.width / el.offsetWidth : 1;
|
|
678
|
+
const x = Math.round(((point.x - rect.left) / (scale || 1)) * 100) / 100;
|
|
679
|
+
const y = Math.round(((point.y - rect.top) / (scale || 1)) * 100) / 100;
|
|
680
|
+
this.onEvent({ type: 'tap', id, x, y });
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
this.onEvent({ type: 'tap', id });
|
|
684
|
+
});
|
|
685
|
+
|
|
649
686
|
/**
|
|
650
687
|
* Phase 12: after a commit, the bound text fields (`data-sb-focus`) take or
|
|
651
688
|
* give up focus to match what Swift asked for. The `focus` event the move
|
|
652
|
-
* raises is echoed back so the state and the DOM agree.
|
|
689
|
+
* raises is echoed back so the state and the DOM agree. A field the app
|
|
690
|
+
* focuses is scrolled into view (`revealFocused`), as SwiftUI shows a
|
|
691
|
+
* field its `@FocusState` focuses.
|
|
653
692
|
*/
|
|
654
693
|
private syncFocus(): void {
|
|
655
694
|
const active = document.activeElement;
|
|
@@ -658,11 +697,30 @@ export class Renderer {
|
|
|
658
697
|
// `preventScroll`: a field in a sheet still sliding in sits below the
|
|
659
698
|
// screen, and the browser would scroll the whole screen to reveal it,
|
|
660
699
|
// blanking the page until something scrolls it back.
|
|
661
|
-
if (wanted && active !== input)
|
|
662
|
-
|
|
700
|
+
if (wanted && active !== input) {
|
|
701
|
+
if (isRichEditor(input)) focusRichEditor(input);
|
|
702
|
+
else if (isPlainEditor(input)) focusPlainEditor(input);
|
|
703
|
+
else input.focus({ preventScroll: true });
|
|
704
|
+
this.revealFocused(input);
|
|
705
|
+
} else if (!wanted && active === input) input.blur();
|
|
663
706
|
}
|
|
664
707
|
}
|
|
665
708
|
|
|
709
|
+
/**
|
|
710
|
+
* Scrolls the scroll views and lists around a text input as little as
|
|
711
|
+
* shows it, from the layout's frames (src/scrollTo.ts), never the page:
|
|
712
|
+
* the frames are where the views rest, so a sheet still sliding in is no
|
|
713
|
+
* reason to scroll, and in device mode they already stop above the
|
|
714
|
+
* keyboard, which the focus raised before this ran (`watchKeyboard`
|
|
715
|
+
* reports its known height on `focusin`, and `setKeyboard` lays out).
|
|
716
|
+
*/
|
|
717
|
+
private revealFocused(input: HTMLElement): void {
|
|
718
|
+
const result = this.layoutResult;
|
|
719
|
+
const id = Number(input.dataset[ID_ATTR]);
|
|
720
|
+
if (!result || !Number.isInteger(id)) return;
|
|
721
|
+
scrollToTarget(this, result, id, undefined, false);
|
|
722
|
+
}
|
|
723
|
+
|
|
666
724
|
/**
|
|
667
725
|
* `.scrollDismissesKeyboard`: scrolling a list or scroll view marked
|
|
668
726
|
* `data-sb-dismisses-keyboard` takes the focus from the text input being
|
|
@@ -672,16 +730,34 @@ export class Renderer {
|
|
|
672
730
|
const scroller = event.target;
|
|
673
731
|
if (!(scroller instanceof HTMLElement) || scroller.dataset['sbDismissesKeyboard'] !== 'true') return;
|
|
674
732
|
const active = document.activeElement;
|
|
675
|
-
if ((active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) && scroller.contains(active)) active.blur();
|
|
733
|
+
if ((active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement || isRichEditor(active)) && scroller.contains(active)) (active as HTMLElement).blur();
|
|
734
|
+
};
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* A press anywhere but another text input leaves the text being edited
|
|
738
|
+
* focused, as on iOS, where only a text input takes the first responder
|
|
739
|
+
* (a Bold button acts on the selection still there; a stepper, a row or
|
|
740
|
+
* the space beside the editor leaves the keyboard up): the mouse down
|
|
741
|
+
* that would move the focus does nothing else.
|
|
742
|
+
*/
|
|
743
|
+
private readonly onControlMouseDown = (event: MouseEvent): void => {
|
|
744
|
+
const active = document.activeElement;
|
|
745
|
+
const editing = active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement || isRichEditor(active);
|
|
746
|
+
if (!editing || !this.root.contains(active)) return;
|
|
747
|
+
const target = event.target as HTMLElement | null;
|
|
748
|
+
if (target && !target.closest('input, textarea, [contenteditable]')) event.preventDefault();
|
|
676
749
|
};
|
|
677
750
|
|
|
678
751
|
/** Phase 12: a bound field (`data-sb-focus`) reports focus changes. */
|
|
679
752
|
private readonly onFocusChange = (event: FocusEvent): void => {
|
|
753
|
+
// A screen's `keyboard` toolbars show while one of its text inputs is
|
|
754
|
+
// focused: the focus moving is a layout change there.
|
|
755
|
+
if (this.commits > 0 && this.hasKeyboardToolbars()) this.layout();
|
|
680
756
|
// A bound `texteditor` (UIKit's text views) reports focus like a field.
|
|
681
757
|
const input = event.target as HTMLElement | null;
|
|
682
758
|
const isField =
|
|
683
759
|
(input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) && input.classList.contains('sb-textfield');
|
|
684
|
-
const isEditor = input instanceof HTMLTextAreaElement && input
|
|
760
|
+
const isEditor = (input instanceof HTMLTextAreaElement || isRichEditor(input)) && input!.classList.contains('sb-texteditor');
|
|
685
761
|
if (!input || (!isField && !isEditor)) return;
|
|
686
762
|
if (input.dataset['sbFocus'] === undefined || !this.root.contains(input)) return;
|
|
687
763
|
const id = Number(input.dataset[ID_ATTR]);
|
|
@@ -718,7 +794,8 @@ export class Renderer {
|
|
|
718
794
|
if (
|
|
719
795
|
!target.classList.contains('sb-navlink') &&
|
|
720
796
|
!target.classList.contains('sb-menu') &&
|
|
721
|
-
!(target.classList.contains('sb-picker') && target.dataset['sbStyle'] === 'menu')
|
|
797
|
+
!(target.classList.contains('sb-picker') && target.dataset['sbStyle'] === 'menu') &&
|
|
798
|
+
!(target.classList.contains('sb-toggle') && target.dataset['sbToggleStyle'] === 'button')
|
|
722
799
|
) {
|
|
723
800
|
return;
|
|
724
801
|
}
|
|
@@ -743,6 +820,23 @@ export class Renderer {
|
|
|
743
820
|
}
|
|
744
821
|
};
|
|
745
822
|
|
|
823
|
+
/**
|
|
824
|
+
* Backspace in a `textfield` or plain `texteditor` with `deleteBackward`,
|
|
825
|
+
* nothing selected and the caret at the start: the key deletes nothing
|
|
826
|
+
* and the app hears `deletebackward` (the rich editor's own `beforeinput`
|
|
827
|
+
* does the same, src/richEditor.ts).
|
|
828
|
+
*/
|
|
829
|
+
private readonly onBeforeInput = (event: InputEvent): void => {
|
|
830
|
+
if (event.inputType !== 'deleteContentBackward') return;
|
|
831
|
+
const input = event.target;
|
|
832
|
+
if (!(input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) || !this.root.contains(input) || !wantsDeleteBackward(input)) return;
|
|
833
|
+
if (input.selectionStart !== 0 || input.selectionEnd !== 0) return;
|
|
834
|
+
const id = Number(input.dataset[ID_ATTR]);
|
|
835
|
+
if (!Number.isInteger(id)) return;
|
|
836
|
+
event.preventDefault();
|
|
837
|
+
this.onEvent({ type: 'deletebackward', id });
|
|
838
|
+
};
|
|
839
|
+
|
|
746
840
|
private readonly onInput = (event: Event): void => {
|
|
747
841
|
const input = event.target as HTMLInputElement | null;
|
|
748
842
|
if (!input || !this.root.contains(input)) return;
|
|
@@ -754,7 +848,8 @@ export class Renderer {
|
|
|
754
848
|
const owner = isField ? input : input.closest<HTMLElement>('.sb-searchfield');
|
|
755
849
|
const id = Number(owner?.dataset[ID_ATTR]);
|
|
756
850
|
if (!Number.isInteger(id)) return;
|
|
757
|
-
|
|
851
|
+
const value = isRichEditor(input) ? richEditorText(input) : input.value;
|
|
852
|
+
this.onEvent({ type: 'text', id, value });
|
|
758
853
|
};
|
|
759
854
|
|
|
760
855
|
/**
|
|
@@ -879,7 +974,9 @@ export class Renderer {
|
|
|
879
974
|
// created detached and moved around freely.
|
|
880
975
|
root.addEventListener('click', this.onClick);
|
|
881
976
|
root.addEventListener('pointerdown', this.onRowPress);
|
|
977
|
+
root.addEventListener('mousedown', this.onControlMouseDown);
|
|
882
978
|
root.addEventListener('keydown', this.onKeyDown);
|
|
979
|
+
root.addEventListener('beforeinput', this.onBeforeInput);
|
|
883
980
|
root.addEventListener('input', this.onInput);
|
|
884
981
|
root.addEventListener('focusin', this.onFocusChange);
|
|
885
982
|
root.addEventListener('focusout', this.onFocusChange);
|
|
@@ -949,8 +1046,48 @@ export class Renderer {
|
|
|
949
1046
|
});
|
|
950
1047
|
}
|
|
951
1048
|
this.stopHostChanges = onHostContentChange(() => this.hostContentChanged());
|
|
1049
|
+
const editorId = (el: HTMLElement): number | null => {
|
|
1050
|
+
const id = Number(el.dataset[ID_ATTR]);
|
|
1051
|
+
return Number.isInteger(id) ? id : null;
|
|
1052
|
+
};
|
|
1053
|
+
this.stopRichEditors = listenToRichEditors(root, editorId, (event) => this.onEvent(event));
|
|
1054
|
+
// After `onInput` above, so a keystroke's `text` goes before its `selection`.
|
|
1055
|
+
this.stopPlainEditors = listenToPlainEditors(root, editorId, (event) => this.onEvent(event));
|
|
952
1056
|
this.hover = new HoverTracker(root, (id, value) => this.onEvent({ type: 'hover', id, value }));
|
|
953
1057
|
root.addEventListener('scroll', this.queueFrameCheck, { capture: true, passive: true });
|
|
1058
|
+
// Scroll transitions and pinned views follow the scroll in the frame it is drawn in.
|
|
1059
|
+
root.addEventListener('scroll', drawScrollTransitions, { capture: true, passive: true });
|
|
1060
|
+
root.addEventListener('scroll', drawPinnedViews, { capture: true, passive: true });
|
|
1061
|
+
root.addEventListener('scroll', this.queueScrollGeometryCheck, { capture: true, passive: true });
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* `reportsGeometry` scroll views report where they are once per animation
|
|
1066
|
+
* frame while anything scrolls (the offsets change without a layout pass).
|
|
1067
|
+
*/
|
|
1068
|
+
private readonly queueScrollGeometryCheck = (): void => {
|
|
1069
|
+
if (this.scrollGeometryQueued || this.commits === 0 || this.scrollReporters.size === 0) return;
|
|
1070
|
+
this.scrollGeometryQueued = true;
|
|
1071
|
+
requestAnimationFrame(() => {
|
|
1072
|
+
this.scrollGeometryQueued = false;
|
|
1073
|
+
if (!this.layoutResult) return;
|
|
1074
|
+
this.collectScrollGeometry(this.layoutResult);
|
|
1075
|
+
if (this.applying === 0) this.flushGeometry();
|
|
1076
|
+
});
|
|
1077
|
+
};
|
|
1078
|
+
|
|
1079
|
+
/** Queue a `scrollgeometry` event for every reporting scroll view whose geometry changed. */
|
|
1080
|
+
private collectScrollGeometry(result: LayoutResult): void {
|
|
1081
|
+
for (const id of this.scrollReporters) {
|
|
1082
|
+
const el = this.elements.get(id);
|
|
1083
|
+
if (!el || !el.isConnected) continue;
|
|
1084
|
+
const frame = result.frames.get(id);
|
|
1085
|
+
if (!frame) continue; // inside a hidden tab
|
|
1086
|
+
const covered = result.chrome.get(id)?.['bottomInset'];
|
|
1087
|
+
const bottomInset = covered ? Math.max(0, frame.y + frame.height - covered.y) : 0;
|
|
1088
|
+
const event = this.scrollGeometry.report(id, readScrollGeometry(el, frame, result.contentSizes.get(id), bottomInset));
|
|
1089
|
+
if (event) this.pendingGeometry.push(event);
|
|
1090
|
+
}
|
|
954
1091
|
}
|
|
955
1092
|
|
|
956
1093
|
/**
|
|
@@ -1080,11 +1217,16 @@ export class Renderer {
|
|
|
1080
1217
|
// from the page's last layout, without them.
|
|
1081
1218
|
this.root.dispatchEvent(new CustomEvent('sb:precommit', { detail: { commits: this.commits + 1 } }));
|
|
1082
1219
|
const changes = this.layout();
|
|
1083
|
-
|
|
1220
|
+
const completion = op.completion;
|
|
1221
|
+
this.animator.commitNextFrame(pass, op.animation, changes, typeof completion === 'number' ? (started) => this.completeAnimation(completion, started) : undefined);
|
|
1084
1222
|
this.beginBarMorphsNextFrame();
|
|
1085
1223
|
this.commits += 1;
|
|
1224
|
+
const focusedBefore = this.focusedFieldId();
|
|
1086
1225
|
this.syncFocus();
|
|
1226
|
+
// The focus the app moved shows or hides a screen's keyboard bar.
|
|
1227
|
+
if (this.focusedFieldId() !== focusedBefore && this.hasKeyboardToolbars()) this.layout();
|
|
1087
1228
|
this.performScrolls(op.animation != null);
|
|
1229
|
+
this.performOffsetScrolls(op.animation != null);
|
|
1088
1230
|
this.checkLazySpacers();
|
|
1089
1231
|
this.openPendingMenu();
|
|
1090
1232
|
this.root.dispatchEvent(
|
|
@@ -1100,6 +1242,26 @@ export class Renderer {
|
|
|
1100
1242
|
}
|
|
1101
1243
|
}
|
|
1102
1244
|
|
|
1245
|
+
/**
|
|
1246
|
+
* `withAnimation(_:completionCriteria:_:completion:)`: the pass's `commit`
|
|
1247
|
+
* carried a completion serial; once the animations its commit started have
|
|
1248
|
+
* ended (at once when it started none) the app hears `animationcomplete`.
|
|
1249
|
+
* An animation that never ends (`repeatForever`) counts as ended after one
|
|
1250
|
+
* run of its duration, as `.logicallyComplete` does.
|
|
1251
|
+
*/
|
|
1252
|
+
private completeAnimation(serial: number, started: readonly globalThis.Animation[]): void {
|
|
1253
|
+
const ended = started.map((animation) => {
|
|
1254
|
+
const timing = animation.effect?.getComputedTiming();
|
|
1255
|
+
const end = timing?.endTime;
|
|
1256
|
+
if (typeof end === 'number' && Number.isFinite(end)) return animation.finished.then(() => undefined, () => undefined);
|
|
1257
|
+
const run = (Number(timing?.delay) || 0) + (Number(timing?.duration) || 0);
|
|
1258
|
+
return new Promise<void>((resolve) => window.setTimeout(resolve, run));
|
|
1259
|
+
});
|
|
1260
|
+
const send = (): void => this.onEvent({ type: 'animationcomplete', serial });
|
|
1261
|
+
if (ended.length === 0) send();
|
|
1262
|
+
else void Promise.all(ended).then(send, send);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1103
1265
|
/**
|
|
1104
1266
|
* Whether the pass starting at `ops[start]` can animate: its `commit` has
|
|
1105
1267
|
* an animation, or one of its updates changes an `animationToken`
|
|
@@ -1276,8 +1438,28 @@ export class Renderer {
|
|
|
1276
1438
|
setKeyboard(height: number): void {
|
|
1277
1439
|
const next = Number.isFinite(height) && height > 0 ? Math.round(height) : 0;
|
|
1278
1440
|
if (next === this.keyboard) return;
|
|
1441
|
+
const rose = next > this.keyboard;
|
|
1279
1442
|
this.keyboard = next;
|
|
1280
|
-
if (this.commits
|
|
1443
|
+
if (this.commits === 0) return;
|
|
1444
|
+
this.layout();
|
|
1445
|
+
// The keyboard covers more: the text input being edited comes out from under it, as iOS keeps it above the keyboard.
|
|
1446
|
+
const active = document.activeElement;
|
|
1447
|
+
if (rose && active instanceof HTMLElement && this.root.contains(active) && (active.classList.contains('sb-textfield') || active.classList.contains('sb-texteditor'))) {
|
|
1448
|
+
this.revealFocused(active);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* The size `text` in `fontProps` (a Font JSON object over the body font)
|
|
1454
|
+
* takes wrapped to `width` (one line when null), with the typography the
|
|
1455
|
+
* layout engine lays `text` elements out with: for an app that paginates
|
|
1456
|
+
* or sizes from its text (@swiftbrowser/react's `measureText`).
|
|
1457
|
+
*/
|
|
1458
|
+
measureText(text: string, fontProps: unknown, width: number | null): { width: number; height: number; lines: number } {
|
|
1459
|
+
const env = this.layoutEnvironment();
|
|
1460
|
+
const font = resolveFont(fontProps, defaultFont(env), env);
|
|
1461
|
+
const wrapped = wrapText(text, font, width, null, this.measurer);
|
|
1462
|
+
return { width: wrapped.width, height: textHeight(wrapped.lines.length, font), lines: wrapped.lines.length };
|
|
1281
1463
|
}
|
|
1282
1464
|
|
|
1283
1465
|
/**
|
|
@@ -1293,6 +1475,7 @@ export class Renderer {
|
|
|
1293
1475
|
layoutEnvironment(): LayoutEnvironment {
|
|
1294
1476
|
const screen = this.resizeObserver ? (this.rootSize ??= this.readRootSize()) : this.readRootSize();
|
|
1295
1477
|
if (!this.safeArea) this.safeArea = readSafeArea(this.root);
|
|
1478
|
+
const focused = this.focusedFieldId();
|
|
1296
1479
|
return {
|
|
1297
1480
|
screen: { ...screen },
|
|
1298
1481
|
safeArea: this.safeArea,
|
|
@@ -1302,9 +1485,25 @@ export class Renderer {
|
|
|
1302
1485
|
assets: this.assets,
|
|
1303
1486
|
...(this.contentHeight ? { contentHeight: true } : {}),
|
|
1304
1487
|
...(this.keyboard > 0 ? { keyboard: this.keyboard } : {}),
|
|
1488
|
+
...(focused !== null ? { focusedField: focused } : {}),
|
|
1305
1489
|
};
|
|
1306
1490
|
}
|
|
1307
1491
|
|
|
1492
|
+
/** The element id of the text input being edited in the root (a field, an editor or a search field's input), or null. */
|
|
1493
|
+
private focusedFieldId(): number | null {
|
|
1494
|
+
const active = typeof document === 'undefined' ? null : document.activeElement;
|
|
1495
|
+
if (!(active instanceof HTMLElement) || !this.root.contains(active)) return null;
|
|
1496
|
+
const owner = active.classList.contains('sb-searchfield-input') ? active.closest<HTMLElement>('.sb-searchfield') : active;
|
|
1497
|
+
if (!owner || !(owner.classList.contains('sb-textfield') || owner.classList.contains('sb-texteditor') || owner.classList.contains('sb-searchfield'))) return null;
|
|
1498
|
+
const id = Number(owner.dataset[ID_ATTR]);
|
|
1499
|
+
return Number.isInteger(id) ? id : null;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
/** Whether a screen has `keyboard` toolbars: their bar follows the focus, so a focus change lays out again. */
|
|
1503
|
+
private hasKeyboardToolbars(): boolean {
|
|
1504
|
+
return this.root.querySelector('.sb-toolbar[data-sb-placement="keyboard"]') !== null;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1308
1507
|
private readRootSize(): { width: number; height: number } {
|
|
1309
1508
|
return {
|
|
1310
1509
|
width: this.root.clientWidth || DEFAULT_SCREEN.width,
|
|
@@ -1481,7 +1680,10 @@ export class Renderer {
|
|
|
1481
1680
|
}
|
|
1482
1681
|
this.root.style.height = `${bottom + env.safeArea.bottom}px`;
|
|
1483
1682
|
}
|
|
1683
|
+
drawScrollTransitions();
|
|
1684
|
+
drawPinnedViews();
|
|
1484
1685
|
this.collectGeometry(result);
|
|
1686
|
+
this.collectScrollGeometry(result);
|
|
1485
1687
|
this.collectLayouts(result);
|
|
1486
1688
|
if (this.applying === 0) this.flushGeometry();
|
|
1487
1689
|
this.queueVisibilityCheck();
|
|
@@ -1731,12 +1933,18 @@ export class Renderer {
|
|
|
1731
1933
|
this.closeMenu();
|
|
1732
1934
|
this.root.removeEventListener('click', this.onClick);
|
|
1733
1935
|
this.root.removeEventListener('pointerdown', this.onRowPress);
|
|
1936
|
+
this.root.removeEventListener('mousedown', this.onControlMouseDown);
|
|
1734
1937
|
this.root.removeEventListener('keydown', this.onKeyDown);
|
|
1938
|
+
this.root.removeEventListener('beforeinput', this.onBeforeInput);
|
|
1735
1939
|
this.root.removeEventListener('input', this.onInput);
|
|
1736
1940
|
this.root.removeEventListener('focusin', this.onFocusChange);
|
|
1737
1941
|
this.root.removeEventListener('focusout', this.onFocusChange);
|
|
1738
1942
|
this.root.removeEventListener('scroll', this.onScrollDismissKeyboard, true);
|
|
1739
1943
|
this.root.removeEventListener('scroll', this.queueVisibilityCheck, true);
|
|
1944
|
+
this.root.removeEventListener('scroll', this.queueFrameCheck, true);
|
|
1945
|
+
this.root.removeEventListener('scroll', drawScrollTransitions, true);
|
|
1946
|
+
this.root.removeEventListener('scroll', drawPinnedViews, true);
|
|
1947
|
+
this.root.removeEventListener('scroll', this.queueScrollGeometryCheck, true);
|
|
1740
1948
|
this.root.removeEventListener('scroll', this.onPositionScroll, true);
|
|
1741
1949
|
this.root.removeEventListener('scrollend', this.onPositionScrollEnd, true);
|
|
1742
1950
|
this.root.removeEventListener('touchstart', this.onPositionTouchStart, true);
|
|
@@ -1753,8 +1961,11 @@ export class Renderer {
|
|
|
1753
1961
|
this.resizeObserver?.disconnect();
|
|
1754
1962
|
this.hostObserver?.disconnect();
|
|
1755
1963
|
this.stopHostChanges();
|
|
1964
|
+
this.stopRichEditors();
|
|
1965
|
+
this.stopPlainEditors();
|
|
1756
1966
|
this.hover.dispose();
|
|
1757
1967
|
this.root.removeEventListener('scroll', this.queueFrameCheck, true);
|
|
1968
|
+
this.root.removeEventListener('scroll', drawScrollTransitions, true);
|
|
1758
1969
|
this.root.removeEventListener('change', this.onChange);
|
|
1759
1970
|
this.root.removeEventListener('pointerdown', this.onSliderPointerDown);
|
|
1760
1971
|
this.reset();
|
|
@@ -1768,8 +1979,14 @@ export class Renderer {
|
|
|
1768
1979
|
if (id === ROOT_ID) throw new RendererError('Element 0 is the root and cannot be created', op);
|
|
1769
1980
|
if (this.elements.has(id)) throw new RendererError(`Element ${id} already exists`, op);
|
|
1770
1981
|
if (!ALL_KINDS.has(kind)) throw new RendererError(`Unknown element kind ${String(kind)}`, op);
|
|
1771
|
-
// A vertical `textfield` is a `<textarea>` (its axis is fixed at creation)
|
|
1772
|
-
|
|
1982
|
+
// A vertical `textfield` is a `<textarea>` (its axis is fixed at creation),
|
|
1983
|
+
// a `texteditor` with runs the rich editor (src/richEditor.ts).
|
|
1984
|
+
const el =
|
|
1985
|
+
kind === 'textfield' && (props as TextFieldProps).axis === 'vertical'
|
|
1986
|
+
? createMultilineField()
|
|
1987
|
+
: kind === 'texteditor' && Array.isArray((props as TextEditorProps).runs)
|
|
1988
|
+
? createRichEditor()
|
|
1989
|
+
: createShell(kind);
|
|
1773
1990
|
el.className = `sb-${kind}`;
|
|
1774
1991
|
el.dataset[ID_ATTR] = String(id);
|
|
1775
1992
|
el.dataset[KIND_ATTR] = kind;
|
|
@@ -2011,8 +2228,20 @@ export class Renderer {
|
|
|
2011
2228
|
this.onEvent({ type: 'scrollposition', id: target });
|
|
2012
2229
|
}
|
|
2013
2230
|
|
|
2014
|
-
/**
|
|
2231
|
+
/**
|
|
2232
|
+
* A `styled` whose style carries a `scrollTo` not seen before is scrolled
|
|
2233
|
+
* to after this pass's layout; so is a `scrollview` whose `scrollOffset`
|
|
2234
|
+
* command is new. A `scrollview` with `reportsGeometry` is noted to report.
|
|
2235
|
+
*/
|
|
2015
2236
|
private noteScroll(id: ElementId, kind: ElementKind, props: ElementProps): void {
|
|
2237
|
+
if (kind === 'scrollview') {
|
|
2238
|
+
const scroll = props as ScrollViewProps;
|
|
2239
|
+
if (scroll.reportsGeometry === true) this.scrollReporters.add(id);
|
|
2240
|
+
else this.scrollReporters.delete(id);
|
|
2241
|
+
const serial = scroll.scrollOffset?.serial;
|
|
2242
|
+
if (typeof serial === 'number' && serial > (this.offsetSerials.get(id) ?? 0)) this.pendingOffsets.push(id);
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
2016
2245
|
if (kind !== 'styled') return;
|
|
2017
2246
|
const serial = (props as StyledProps)?.style?.scrollTo?.serial;
|
|
2018
2247
|
if (typeof serial !== 'number' || serial <= this.scrollSerial) return;
|
|
@@ -2070,6 +2299,33 @@ export class Renderer {
|
|
|
2070
2299
|
for (const { command } of commands) this.scrollSerial = Math.max(this.scrollSerial, command.serial);
|
|
2071
2300
|
}
|
|
2072
2301
|
|
|
2302
|
+
/**
|
|
2303
|
+
* `scrollOffset` (a `.scrollPosition` bound to a point, `scrollTo(x:y:)`):
|
|
2304
|
+
* each scroll view with a command not carried out yet scrolls to it,
|
|
2305
|
+
* smoothly when the pass's commit is animated; an axis the command leaves
|
|
2306
|
+
* out keeps its offset. The view reports its position afresh when it next
|
|
2307
|
+
* settles, as after a `scrollTo` to a target.
|
|
2308
|
+
*/
|
|
2309
|
+
private performOffsetScrolls(animated: boolean): void {
|
|
2310
|
+
if (this.pendingOffsets.length === 0) return;
|
|
2311
|
+
const ids = this.pendingOffsets;
|
|
2312
|
+
this.pendingOffsets = [];
|
|
2313
|
+
for (const id of ids) {
|
|
2314
|
+
const el = this.elements.get(id);
|
|
2315
|
+
const command = (this.nodes.get(id)?.props as Partial<ScrollViewProps> | undefined)?.scrollOffset;
|
|
2316
|
+
if (!el || !command || !(command.serial > (this.offsetSerials.get(id) ?? 0))) continue;
|
|
2317
|
+
this.offsetSerials.set(id, command.serial);
|
|
2318
|
+
const left = typeof command.x === 'number' && Number.isFinite(command.x) ? Math.max(0, command.x) : el.scrollLeft;
|
|
2319
|
+
const top = typeof command.y === 'number' && Number.isFinite(command.y) ? Math.max(0, command.y) : el.scrollTop;
|
|
2320
|
+
if (animated && typeof el.scrollTo === 'function') el.scrollTo({ left, top, behavior: 'smooth' });
|
|
2321
|
+
else {
|
|
2322
|
+
el.scrollLeft = left;
|
|
2323
|
+
el.scrollTop = top;
|
|
2324
|
+
}
|
|
2325
|
+
this.reportedPositions.delete(el);
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2073
2329
|
private update(id: ElementId, props: ElementProps, op: Op): void {
|
|
2074
2330
|
if (id === ROOT_ID) {
|
|
2075
2331
|
// Phase 12: the root carries no element props; an update of it is the
|
|
@@ -2397,6 +2653,9 @@ export class Renderer {
|
|
|
2397
2653
|
this.boxes.delete(id);
|
|
2398
2654
|
this.dirty.delete(id);
|
|
2399
2655
|
this.reportedGeometry.delete(id);
|
|
2656
|
+
this.scrollReporters.delete(id);
|
|
2657
|
+
this.scrollGeometry.forget(id);
|
|
2658
|
+
this.offsetSerials.delete(id);
|
|
2400
2659
|
this.geometryReporters.delete(id);
|
|
2401
2660
|
this.layoutReporter.forget(id);
|
|
2402
2661
|
const node = this.nodes.get(id);
|
|
@@ -2689,7 +2948,10 @@ function createShell(kind: ElementKind): HTMLElement {
|
|
|
2689
2948
|
bar.append(back, inlineTitle, div('sb-navbar-trailing'), more);
|
|
2690
2949
|
const body = div('sb-navscreen-body');
|
|
2691
2950
|
const slot = div('sb-slot sb-navscreen-content');
|
|
2692
|
-
|
|
2951
|
+
// The `keyboard` toolbars' bar (chrome `keyboardBar`), under their items.
|
|
2952
|
+
const keyboardBar = div('sb-keyboard-bar');
|
|
2953
|
+
keyboardBar.hidden = true;
|
|
2954
|
+
body.append(div('sb-navbar-large-title'), slot, keyboardBar);
|
|
2693
2955
|
// Phase 8: a 24px strip along the leading edge of a pushed screen
|
|
2694
2956
|
// that, on touch devices, keeps the browser from scrolling so a swipe
|
|
2695
2957
|
// back can start there (styles.css; the controller forwards plain taps).
|
|
@@ -3015,15 +3277,14 @@ function listRowOf(el: HTMLElement, root: HTMLElement): HTMLElement | null {
|
|
|
3015
3277
|
|
|
3016
3278
|
/** Controls a row may hold besides its button for the row to stay the button's: none. */
|
|
3017
3279
|
const ROW_CONTROLS =
|
|
3018
|
-
'input, textarea, .sb-navlink, .sb-switch, .sb-picker, .sb-stepper, .sb-slider, .sb-menu, .sb-tapgesture, .sb-link, .sb-datepicker';
|
|
3280
|
+
'input, textarea, [contenteditable], .sb-navlink, .sb-switch, .sb-picker, .sb-stepper, .sb-slider, .sb-menu, .sb-tapgesture, .sb-link, .sb-datepicker';
|
|
3019
3281
|
|
|
3020
3282
|
/**
|
|
3021
3283
|
* A list row holding one button (with other views beside it, or under a
|
|
3022
3284
|
* modifier) taps the button wherever it is tapped, as a UITableView cell
|
|
3023
3285
|
* does on iOS; a row that is the button itself is that already. Not a
|
|
3024
|
-
* `.plain` or `.borderless` button
|
|
3025
|
-
*
|
|
3026
|
-
* controls.
|
|
3286
|
+
* `.plain` or `.borderless` button, which SwiftUI keeps to its own label in
|
|
3287
|
+
* a row, a disabled one, or a row with other controls.
|
|
3027
3288
|
*/
|
|
3028
3289
|
function rowAction(el: HTMLElement, root: HTMLElement): HTMLElement | null {
|
|
3029
3290
|
const row = listRowOf(el, root);
|
|
@@ -3031,7 +3292,7 @@ function rowAction(el: HTMLElement, root: HTMLElement): HTMLElement | null {
|
|
|
3031
3292
|
const buttons = row.querySelectorAll<HTMLElement>('.sb-button');
|
|
3032
3293
|
if (buttons.length !== 1) return null;
|
|
3033
3294
|
const button = buttons[0]!;
|
|
3034
|
-
if (button.dataset['sbStyle'] === 'plain' || button.closest('[data-sb-disabled]')) return null;
|
|
3295
|
+
if (button.dataset['sbStyle'] === 'plain' || button.dataset['sbStyle'] === 'borderless' || button.closest('[data-sb-disabled]')) return null;
|
|
3035
3296
|
if (row.querySelector(ROW_CONTROLS)) return null;
|
|
3036
3297
|
return button;
|
|
3037
3298
|
}
|
|
@@ -3449,6 +3710,9 @@ function applyTextEditor(area: HTMLTextAreaElement, props: TextEditorProps): voi
|
|
|
3449
3710
|
// Bound focus, as for a `textfield` (moved in `syncFocus` after the commit).
|
|
3450
3711
|
if (typeof props.focused === 'boolean') area.dataset['sbFocus'] = String(props.focused);
|
|
3451
3712
|
else delete area.dataset['sbFocus'];
|
|
3713
|
+
markDeleteBackward(area, props.deleteBackward === true);
|
|
3714
|
+
// A bound selection, under the rich editor's rules (src/plainEditor.ts).
|
|
3715
|
+
applyPlainEditorSelection(area, props);
|
|
3452
3716
|
}
|
|
3453
3717
|
|
|
3454
3718
|
// ---------------------------------------------------------------------------
|
|
@@ -3482,10 +3746,12 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
|
|
|
3482
3746
|
case 'vstack':
|
|
3483
3747
|
applyVStack(el, props as VStackProps);
|
|
3484
3748
|
applyScrollTarget(el, (props as VStackProps).scrollTarget === true);
|
|
3749
|
+
setPinnedViews(el, (props as VStackProps).pinned);
|
|
3485
3750
|
break;
|
|
3486
3751
|
case 'hstack':
|
|
3487
3752
|
applyHStack(el, props as HStackProps);
|
|
3488
3753
|
applyScrollTarget(el, (props as HStackProps).scrollTarget === true);
|
|
3754
|
+
setPinnedViews(el, (props as HStackProps).pinned);
|
|
3489
3755
|
break;
|
|
3490
3756
|
case 'spacer':
|
|
3491
3757
|
applySpacer(el, props as SpacerProps);
|
|
@@ -3502,10 +3768,15 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
|
|
|
3502
3768
|
case 'image':
|
|
3503
3769
|
applyImage(el, props as ImageProps, assets);
|
|
3504
3770
|
break;
|
|
3505
|
-
case 'tapgesture':
|
|
3506
|
-
// `location`: the tap is sent with its point (`onTapGesture { point in }`).
|
|
3507
|
-
|
|
3771
|
+
case 'tapgesture': {
|
|
3772
|
+
// `location`: the tap is sent with its point (`onTapGesture { point in }`); `count`: the taps it takes.
|
|
3773
|
+
const tap = props as TapGestureProps | undefined;
|
|
3774
|
+
el.toggleAttribute('data-sb-tap-location', tap?.location === true);
|
|
3775
|
+
const count = Math.floor(tap?.count ?? 1);
|
|
3776
|
+
if (count > 1) el.dataset['sbTapCount'] = String(count);
|
|
3777
|
+
else delete el.dataset['sbTapCount'];
|
|
3508
3778
|
break;
|
|
3779
|
+
}
|
|
3509
3780
|
case 'divider':
|
|
3510
3781
|
case 'navstack':
|
|
3511
3782
|
case 'geometry':
|
|
@@ -3535,7 +3806,7 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
|
|
|
3535
3806
|
const placement = (props as ToolbarProps).placement;
|
|
3536
3807
|
// `secondary` items shown in the bar look like the trailing ones (glass capsules).
|
|
3537
3808
|
el.dataset['sbPlacement'] =
|
|
3538
|
-
placement === 'leading' || placement === 'principal' || placement === 'bottom' || placement === 'largeTitle' ? placement : 'trailing';
|
|
3809
|
+
placement === 'leading' || placement === 'principal' || placement === 'bottom' || placement === 'largeTitle' || placement === 'keyboard' ? placement : 'trailing';
|
|
3539
3810
|
// A `ToolbarSpacer`: draws nothing.
|
|
3540
3811
|
el.toggleAttribute('data-sb-spacer', typeof (props as ToolbarProps).spacer === 'string');
|
|
3541
3812
|
break;
|
|
@@ -3595,9 +3866,22 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
|
|
|
3595
3866
|
case 'textfield':
|
|
3596
3867
|
applyTextField(el as HTMLInputElement | HTMLTextAreaElement, props as TextFieldProps);
|
|
3597
3868
|
break;
|
|
3598
|
-
case 'toggle':
|
|
3599
|
-
|
|
3869
|
+
case 'toggle': {
|
|
3870
|
+
const toggle = props as ToggleProps;
|
|
3871
|
+
// `switch` is marked too, so a bar draws it as a switch, not its own toggle.
|
|
3872
|
+
if (toggle.style === 'button' || toggle.style === 'switch') el.dataset['sbToggleStyle'] = toggle.style;
|
|
3873
|
+
else delete el.dataset['sbToggleStyle'];
|
|
3874
|
+
if (toggle.style === 'button') {
|
|
3875
|
+
el.setAttribute('role', 'button');
|
|
3876
|
+
el.tabIndex = 0;
|
|
3877
|
+
} else if (el.getAttribute('role') === 'button') {
|
|
3878
|
+
el.removeAttribute('role');
|
|
3879
|
+
el.removeAttribute('aria-pressed');
|
|
3880
|
+
el.removeAttribute('tabindex');
|
|
3881
|
+
}
|
|
3882
|
+
setSwitchState(el, Boolean(toggle.isOn));
|
|
3600
3883
|
break;
|
|
3884
|
+
}
|
|
3601
3885
|
case 'navscreen':
|
|
3602
3886
|
applyNavScreen(el, props as NavScreenProps);
|
|
3603
3887
|
break;
|
|
@@ -3627,12 +3911,14 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
|
|
|
3627
3911
|
const grid = props as LazyVGridProps;
|
|
3628
3912
|
el.dataset['sbAlignment'] = grid.alignment ?? 'center';
|
|
3629
3913
|
el.dataset['sbColumns'] = String(Array.isArray(grid.columns) ? grid.columns.length : 0);
|
|
3914
|
+
setPinnedViews(el, grid.pinned);
|
|
3630
3915
|
break;
|
|
3631
3916
|
}
|
|
3632
3917
|
case 'lazyhgrid': {
|
|
3633
3918
|
const grid = props as LazyHGridProps;
|
|
3634
3919
|
el.dataset['sbAlignment'] = grid.alignment ?? 'center';
|
|
3635
3920
|
el.dataset['sbRows'] = String(Array.isArray(grid.rows) ? grid.rows.length : 0);
|
|
3921
|
+
setPinnedViews(el, grid.pinned);
|
|
3636
3922
|
break;
|
|
3637
3923
|
}
|
|
3638
3924
|
case 'gauge':
|
|
@@ -3709,7 +3995,8 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
|
|
|
3709
3995
|
applyDatePicker(el, (props as DatePickerProps) ?? {});
|
|
3710
3996
|
break;
|
|
3711
3997
|
case 'texteditor':
|
|
3712
|
-
|
|
3998
|
+
if (isRichEditor(el)) applyRichEditor(el, (props as TextEditorProps) ?? {});
|
|
3999
|
+
else applyTextEditor(el as HTMLTextAreaElement, (props as TextEditorProps) ?? {});
|
|
3713
4000
|
break;
|
|
3714
4001
|
case 'sectionheader':
|
|
3715
4002
|
case 'sectionfooter':
|
|
@@ -3759,6 +4046,7 @@ function runTemplate(run: TextRun, assets: AssetCatalog): HTMLElement {
|
|
|
3759
4046
|
}
|
|
3760
4047
|
const s = el.style;
|
|
3761
4048
|
if (run.foreground !== undefined) s.color = styleToCSS(run.foreground as ShapeStyle, 'text').solid;
|
|
4049
|
+
if (run.background !== undefined) s.backgroundColor = styleToCSS(run.background as ShapeStyle, 'paint').solid ?? '';
|
|
3762
4050
|
const lines: string[] = [];
|
|
3763
4051
|
let lineColor: unknown;
|
|
3764
4052
|
if (run.underline) {
|
|
@@ -3999,28 +4287,36 @@ function applyImage(el: HTMLElement, props: ImageProps, assets: AssetCatalog): v
|
|
|
3999
4287
|
if (isUrlImage(props)) {
|
|
4000
4288
|
// Phase 13: the page decoded this URL for `AsyncImage` already, so the
|
|
4001
4289
|
// <img> shows at once; the layout engine sized the box from the props.
|
|
4290
|
+
// A `file:` URL of the app's files loads at the object URL the files
|
|
4291
|
+
// host makes of it (hostRequests.ts `loadableURL`), once it has.
|
|
4002
4292
|
delete el.dataset['sbSymbol'];
|
|
4003
4293
|
delete el.dataset['sbAsset'];
|
|
4294
|
+
const changed = el.dataset['sbUrl'] !== props.url;
|
|
4004
4295
|
el.dataset['sbUrl'] = props.url;
|
|
4005
4296
|
if (props.resizable) el.dataset['sbResizable'] = '';
|
|
4006
4297
|
else delete el.dataset['sbResizable'];
|
|
4007
4298
|
// React's opt-in to the browser's own image interactions (styles.css).
|
|
4008
4299
|
const web = props.webInteractions === true;
|
|
4009
4300
|
el.toggleAttribute('data-sb-web', web);
|
|
4301
|
+
const url = props.url;
|
|
4010
4302
|
const existing = el.firstElementChild;
|
|
4011
4303
|
if (existing instanceof HTMLImageElement) {
|
|
4012
4304
|
existing.draggable = web;
|
|
4013
|
-
if (
|
|
4014
|
-
|
|
4305
|
+
if (changed) {
|
|
4306
|
+
const loadable = loadableURL(url);
|
|
4307
|
+
if (typeof loadable === 'string') swapImageSource(existing, loadable);
|
|
4308
|
+
else void loadable.then((src) => el.dataset['sbUrl'] === url && swapImageSource(existing, src));
|
|
4015
4309
|
}
|
|
4016
4310
|
return;
|
|
4017
4311
|
}
|
|
4018
4312
|
const img = document.createElement('img');
|
|
4019
|
-
|
|
4313
|
+
const loadable = loadableURL(url);
|
|
4314
|
+
if (typeof loadable === 'string') img.src = loadable;
|
|
4315
|
+
else void loadable.then((src) => el.dataset['sbUrl'] === url && img.setAttribute('src', src));
|
|
4020
4316
|
img.alt = '';
|
|
4021
4317
|
img.draggable = web;
|
|
4022
4318
|
img.decoding = 'async';
|
|
4023
|
-
found(
|
|
4319
|
+
found(url, img);
|
|
4024
4320
|
return;
|
|
4025
4321
|
}
|
|
4026
4322
|
delete el.dataset['sbUrl'];
|
|
@@ -4131,6 +4427,7 @@ function applyTextField(input: HTMLInputElement | HTMLTextAreaElement, props: Te
|
|
|
4131
4427
|
}
|
|
4132
4428
|
if (props.submit === true) input.dataset['sbSubmit'] = 'true';
|
|
4133
4429
|
else delete input.dataset['sbSubmit'];
|
|
4430
|
+
markDeleteBackward(input, props.deleteBackward === true);
|
|
4134
4431
|
if (typeof props.submitLabel === 'string' && props.submitLabel.length > 0) input.enterKeyHint = props.submitLabel;
|
|
4135
4432
|
else input.removeAttribute('enterkeyhint');
|
|
4136
4433
|
applyTextInputHints(input, props);
|
|
@@ -4202,6 +4499,7 @@ function applySearchField(el: HTMLElement, props: SearchFieldProps): void {
|
|
|
4202
4499
|
|
|
4203
4500
|
function setSwitchState(toggle: HTMLElement, isOn: boolean): void {
|
|
4204
4501
|
toggle.dataset['sbOn'] = String(isOn);
|
|
4502
|
+
if (toggle.dataset['sbToggleStyle'] === 'button') toggle.setAttribute('aria-pressed', String(isOn));
|
|
4205
4503
|
const sw = toggle.querySelector<HTMLElement>(':scope > .sb-switch');
|
|
4206
4504
|
sw?.setAttribute('aria-checked', String(isOn));
|
|
4207
4505
|
}
|
|
@@ -4498,6 +4796,9 @@ export function applyStyle(el: HTMLElement, style: Style): void {
|
|
|
4498
4796
|
s.pointerEvents = 'none';
|
|
4499
4797
|
el.dataset['sbHitTesting'] = 'false';
|
|
4500
4798
|
}
|
|
4799
|
+
// `.contentShape(Rectangle())`: the box takes input across its area, which a box already does; marked for the stylesheet and tests.
|
|
4800
|
+
if (style.contentShape) el.dataset['sbContentShape'] = style.contentShape;
|
|
4801
|
+
else delete el.dataset['sbContentShape'];
|
|
4501
4802
|
// Hover, tooltips, cursors, symbol effects and matched geometry ids (pointerEffects.ts).
|
|
4502
4803
|
applyPointerAndEffects(el, style);
|
|
4503
4804
|
|
|
@@ -4522,9 +4823,17 @@ export function applyStyle(el: HTMLElement, style: Style): void {
|
|
|
4522
4823
|
if (style.blendMode) s.mixBlendMode = style.blendMode;
|
|
4523
4824
|
else if (s.mixBlendMode) s.mixBlendMode = '';
|
|
4524
4825
|
// `.zIndex`: in front of siblings with a lower index (boxes are positioned).
|
|
4525
|
-
// CSS takes integers; ×1000 keeps fractional indices (0.5) apart.
|
|
4526
|
-
|
|
4527
|
-
|
|
4826
|
+
// CSS takes integers; ×1000 keeps fractional indices (0.5) apart. The
|
|
4827
|
+
// stack it is in stacks its children on its own (styles.css,
|
|
4828
|
+
// `[data-sb-z]`), so a negative index orders the box among its siblings
|
|
4829
|
+
// only, as SwiftUI's does, and never sends it behind the views around.
|
|
4830
|
+
if (style.zIndex != null && Number.isFinite(style.zIndex)) {
|
|
4831
|
+
s.zIndex = String(Math.round(style.zIndex * 1000));
|
|
4832
|
+
el.dataset['sbZ'] = '';
|
|
4833
|
+
} else if (s.zIndex) {
|
|
4834
|
+
s.zIndex = '';
|
|
4835
|
+
delete el.dataset['sbZ'];
|
|
4836
|
+
}
|
|
4528
4837
|
|
|
4529
4838
|
// Phase 3 text and layout hints (the engine reads them from the node tree).
|
|
4530
4839
|
if (style.layoutPriority != null && Number.isFinite(style.layoutPriority)) {
|
|
@@ -4570,6 +4879,8 @@ export function applyStyle(el: HTMLElement, style: Style): void {
|
|
|
4570
4879
|
const { x, y } = style.gridCellAnchor;
|
|
4571
4880
|
el.dataset['sbGridCellAnchor'] = `${x} ${y}`;
|
|
4572
4881
|
}
|
|
4882
|
+
// `.scrollTransition`: drawn over the effects above as the box scrolls (scrollTransition.ts).
|
|
4883
|
+
setScrollTransition(el, style);
|
|
4573
4884
|
}
|
|
4574
4885
|
|
|
4575
4886
|
// Colors and ShapeStyles resolve in src/styles.ts; re-exported for callers of the renderer.
|