@swiftbrowser/web 0.1.0 → 0.2.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/README.md +43 -6
- package/app/index.html +11 -0
- package/node/dist/node/apps.d.ts +16 -0
- package/node/dist/node/apps.d.ts.map +1 -1
- package/node/dist/node/apps.js +9 -0
- package/node/dist/node/apps.js.map +1 -1
- package/node/dist/node/config.d.ts +14 -1
- package/node/dist/node/config.d.ts.map +1 -1
- package/node/dist/node/config.js +20 -3
- package/node/dist/node/config.js.map +1 -1
- package/node/dist/node/examples.d.ts +2 -1
- package/node/dist/node/examples.d.ts.map +1 -1
- package/node/dist/node/examples.js +11 -2
- package/node/dist/node/examples.js.map +1 -1
- package/node/dist/plugins/app-manifests.d.ts +4 -1
- package/node/dist/plugins/app-manifests.d.ts.map +1 -1
- package/node/dist/plugins/app-manifests.js +3 -2
- package/node/dist/plugins/app-manifests.js.map +1 -1
- package/node/dist/plugins/asset-catalogs.d.ts +15 -3
- package/node/dist/plugins/asset-catalogs.d.ts.map +1 -1
- package/node/dist/plugins/asset-catalogs.js +111 -13
- package/node/dist/plugins/asset-catalogs.js.map +1 -1
- package/node/dist/plugins/included-sites.d.ts +26 -0
- package/node/dist/plugins/included-sites.d.ts.map +1 -0
- package/node/dist/plugins/included-sites.js +70 -0
- package/node/dist/plugins/included-sites.js.map +1 -0
- package/node/dist/plugins/pdfImages.d.ts +27 -0
- package/node/dist/plugins/pdfImages.d.ts.map +1 -0
- package/node/dist/plugins/pdfImages.js +145 -0
- package/node/dist/plugins/pdfImages.js.map +1 -0
- package/node/dist/plugins/swift-wasm.d.ts +19 -1
- package/node/dist/plugins/swift-wasm.d.ts.map +1 -1
- package/node/dist/plugins/swift-wasm.js +45 -7
- package/node/dist/plugins/swift-wasm.js.map +1 -1
- package/node/dist/plugins/symbolTemplates.d.ts +22 -0
- package/node/dist/plugins/symbolTemplates.d.ts.map +1 -0
- package/node/dist/plugins/symbolTemplates.js +83 -0
- package/node/dist/plugins/symbolTemplates.js.map +1 -0
- package/package.json +4 -2
- package/plugins/app-manifests.ts +3 -2
- package/plugins/asset-catalogs.ts +119 -14
- package/plugins/included-sites.ts +74 -0
- package/plugins/pdfImages.ts +163 -0
- package/plugins/swift-wasm.ts +52 -6
- package/plugins/symbolTemplates.ts +88 -0
- package/public/_headers +7 -0
- package/public/app/oauth-callback.html +62 -0
- package/src/animation.ts +235 -8
- package/src/appFiles.ts +201 -0
- package/src/base64.ts +43 -0
- package/src/bitmapPlan.ts +143 -0
- package/src/bitmapStore.ts +206 -0
- package/src/bitmapWorker.ts +41 -0
- package/src/bitmapWorkerProtocol.ts +15 -0
- package/src/bitmaps.ts +283 -0
- package/src/blockedHosts.ts +29 -0
- package/src/canvasDrawing.ts +361 -0
- package/src/chartDom.ts +2 -2
- package/src/chromeColor.ts +179 -0
- package/src/clipboard.ts +78 -0
- package/src/colorMultiply.ts +93 -0
- package/src/colorPicker.ts +44 -0
- package/src/corsProxy.ts +45 -0
- package/src/gaugeRing.ts +74 -0
- package/src/gif.ts +297 -0
- package/src/hostRequests.ts +426 -0
- package/src/imageOps.ts +592 -0
- package/src/interaction.ts +18 -1
- package/src/landing.ts +2 -2
- package/src/layout/chart.ts +9 -3
- package/src/layout/engine.ts +279 -25
- package/src/layout/index.ts +3 -1
- package/src/layout/text.ts +217 -1
- package/src/layout/types.ts +31 -0
- package/src/layout/typography.ts +24 -7
- package/src/layout/video.ts +24 -0
- package/src/layoutDom.ts +53 -3
- package/src/main.ts +124 -4
- package/src/media.ts +132 -0
- package/src/mock.ts +2 -1
- package/src/notifications.ts +109 -0
- package/src/pathShape.ts +156 -0
- package/src/protocol.ts +761 -12
- package/src/renderer.ts +584 -36
- package/src/richText.ts +65 -0
- package/src/scanner.ts +234 -0
- package/src/scrollTo.ts +123 -0
- package/src/styles.css +212 -7
- package/src/styles.ts +31 -0
- package/src/swiftData.ts +128 -0
- package/src/symbols.ts +564 -21
- package/src/translation.ts +126 -0
- package/src/video.ts +299 -0
- package/src/vite-env.d.ts +6 -0
- package/src/wasm.ts +227 -32
- package/src/webAuth.ts +267 -0
- package/src/webSocket.ts +148 -0
- package/src/zoneinfo.ts +269 -0
package/src/renderer.ts
CHANGED
|
@@ -51,6 +51,9 @@ import {
|
|
|
51
51
|
type Rect,
|
|
52
52
|
} from './layout';
|
|
53
53
|
import { LayoutReporter } from './layoutEvents';
|
|
54
|
+
import { createGaugeRing, isCircularGauge, updateGaugeRing } from './gaugeRing';
|
|
55
|
+
import { bitmapStore, showBitmap } from './bitmaps';
|
|
56
|
+
import { clearRichText, runTemplates } from './richText';
|
|
54
57
|
import { applyLayout, setTabHidden, type BoxChange } from './layoutDom';
|
|
55
58
|
import { ghostClone } from './ghost';
|
|
56
59
|
import { InteractionController, type InteractionHost } from './interaction';
|
|
@@ -59,6 +62,8 @@ import {
|
|
|
59
62
|
INTERACTION_KINDS,
|
|
60
63
|
ROOT_ID,
|
|
61
64
|
isAssetImage,
|
|
65
|
+
isBitmapImage,
|
|
66
|
+
isUrlImage,
|
|
62
67
|
type Animation,
|
|
63
68
|
type ButtonProps,
|
|
64
69
|
type ChartItemProps,
|
|
@@ -84,6 +89,7 @@ import {
|
|
|
84
89
|
type ListProps,
|
|
85
90
|
type ListRowProps,
|
|
86
91
|
type NavLinkProps,
|
|
92
|
+
type TapGestureProps,
|
|
87
93
|
type NavScreenProps,
|
|
88
94
|
type Op,
|
|
89
95
|
type PickerProps,
|
|
@@ -101,6 +107,8 @@ import {
|
|
|
101
107
|
type TabViewProps,
|
|
102
108
|
type TextFieldProps,
|
|
103
109
|
type TextProps,
|
|
110
|
+
type TextRun,
|
|
111
|
+
type ShapeStyle,
|
|
104
112
|
type ToggleProps,
|
|
105
113
|
type ToolbarProps,
|
|
106
114
|
type ViewThatFitsProps,
|
|
@@ -113,6 +121,11 @@ import {
|
|
|
113
121
|
type StepperProps,
|
|
114
122
|
type TextEditorProps,
|
|
115
123
|
type RootProps,
|
|
124
|
+
type ScrollToCommand,
|
|
125
|
+
type VideoProps,
|
|
126
|
+
type CanvasProps,
|
|
127
|
+
type Color,
|
|
128
|
+
type LazySpacerProps,
|
|
116
129
|
} from './protocol';
|
|
117
130
|
import {
|
|
118
131
|
combineDateSeconds,
|
|
@@ -125,8 +138,13 @@ import {
|
|
|
125
138
|
timeInputValue,
|
|
126
139
|
} from './layout';
|
|
127
140
|
import { renderSymbol } from './symbols';
|
|
141
|
+
import { applyPathShape, hasPathFrames, playPathFrames } from './pathShape';
|
|
142
|
+
import { VideoEvents, applyVideo, stopVideos } from './video';
|
|
143
|
+
import { applyCanvas, createCanvasElement } from './canvasDrawing';
|
|
144
|
+
import { scrollToTarget } from './scrollTo';
|
|
128
145
|
import { EMPTY_CATALOG, assetColorVar, type AssetCatalog } from './assets';
|
|
129
146
|
import { colorToCSS, effectsCSS, styleToCSS, type PaintCSS } from './styles';
|
|
147
|
+
import { colorMultiplyFilter } from './colorMultiply';
|
|
130
148
|
import { DEFAULT_TYPE_SIZE, TEXT_STYLE_WEIGHT, resolveTextStyle, textStyleVar } from './typography';
|
|
131
149
|
|
|
132
150
|
export type EventCallback = (event: RendererEvent) => void;
|
|
@@ -198,6 +216,14 @@ const ALL_KINDS: ReadonlySet<string> = new Set<ElementKind>([
|
|
|
198
216
|
'slider',
|
|
199
217
|
'datepicker',
|
|
200
218
|
'texteditor',
|
|
219
|
+
// A `Section`'s view header / footer
|
|
220
|
+
'sectionheader',
|
|
221
|
+
'sectionfooter',
|
|
222
|
+
// An `AVPlayer` shown by a view (src/video.ts)
|
|
223
|
+
'video',
|
|
224
|
+
'lazyspacer',
|
|
225
|
+
// A `Canvas`'s drawing (src/canvasDrawing.ts)
|
|
226
|
+
'canvas',
|
|
201
227
|
]);
|
|
202
228
|
|
|
203
229
|
/** Duration of the alert / dialog fade (Phase 8); keep in sync with styles.css. */
|
|
@@ -253,11 +279,31 @@ export class Renderer {
|
|
|
253
279
|
layoutResult: LayoutResult | null = null;
|
|
254
280
|
/** What the current render pass did so far (handed to the Animator at `commit`). */
|
|
255
281
|
private pass: PassRecord = emptyPass();
|
|
282
|
+
/**
|
|
283
|
+
* Whether the current pass can animate (see `passCanAnimate`), decided at
|
|
284
|
+
* its first op; null between passes. A pass that cannot skips the style
|
|
285
|
+
* snapshots and rectangles the Animator would only need to animate, each
|
|
286
|
+
* of which forces a style recalculation.
|
|
287
|
+
*/
|
|
288
|
+
private passAnimates: boolean | null = null;
|
|
289
|
+
/** The animation root's rectangle, read once per pass that animates. */
|
|
290
|
+
private passRootRect: DOMRect | null = null;
|
|
291
|
+
/**
|
|
292
|
+
* The root's size as the engine last read it; null when it must be read
|
|
293
|
+
* again. Reading it after a pass changed the DOM forces a style and layout
|
|
294
|
+
* recalculation, so it is read once and then only after the root resized
|
|
295
|
+
* (`resizeObserver`, `viewportChanged`). Without a ResizeObserver it is
|
|
296
|
+
* read on every pass.
|
|
297
|
+
*/
|
|
298
|
+
private rootSize: { width: number; height: number } | null = null;
|
|
299
|
+
private readonly resizeObserver: ResizeObserver | null = null;
|
|
256
300
|
/**
|
|
257
301
|
* Phase 8: owns every pointer gesture (drags, long presses, swipe rows,
|
|
258
302
|
* swipe back, the sheet drag, pull to refresh) and arbitrates between them.
|
|
259
303
|
*/
|
|
260
304
|
readonly interaction: InteractionController;
|
|
305
|
+
/** `video` elements' media events, as `video` events (src/video.ts). */
|
|
306
|
+
private readonly videoEvents: VideoEvents;
|
|
261
307
|
private readonly measurer = new CanvasTextMeasurer();
|
|
262
308
|
/**
|
|
263
309
|
* Phase 9: the layout pass lives across commits and is told what changed
|
|
@@ -280,6 +326,8 @@ export class Renderer {
|
|
|
280
326
|
private readonly dirty = new Set<ElementId>();
|
|
281
327
|
/** The open popover (of a `menu` picker or a `menu` element), if any. */
|
|
282
328
|
private menu: OpenMenu | null = null;
|
|
329
|
+
/** Lazy menus: a menu the viewer opened whose items Swift is building. */
|
|
330
|
+
private pendingMenu: { anchor: HTMLElement; id: ElementId; kind: 'menu' | 'contextmenu' } | null = null;
|
|
283
331
|
/** Size last reported for every `geometry` element (whole px). */
|
|
284
332
|
private readonly reportedGeometry = new Map<ElementId, { width: number; height: number }>();
|
|
285
333
|
/** `geometry` and `layout` events collected by layout passes, sent once the ops are applied. */
|
|
@@ -288,6 +336,14 @@ export class Renderer {
|
|
|
288
336
|
private readonly layoutReporter = new LayoutReporter();
|
|
289
337
|
/** Nesting depth of `applyOps` (events are flushed when it returns to 0). */
|
|
290
338
|
private applying = 0;
|
|
339
|
+
/** `styled` elements whose props in this pass carry a new `scrollTo` command (src/scrollTo.ts). */
|
|
340
|
+
private pendingScrolls: ElementId[] = [];
|
|
341
|
+
/** Lazy rows: the `lazyspacer`s on the page, and those already reported near the visible area. */
|
|
342
|
+
private lazySpacers = new Set<ElementId>();
|
|
343
|
+
private lazyReported = new Set<ElementId>();
|
|
344
|
+
private lazyCheckQueued = false;
|
|
345
|
+
/** The highest `scrollTo` serial seen: a command keeps its serial until the reader re-runs, and runs once. */
|
|
346
|
+
private scrollSerial = 0;
|
|
291
347
|
|
|
292
348
|
private readonly onClick = (event: MouseEvent): void => {
|
|
293
349
|
// `closest` picks the innermost control, so a button inside a
|
|
@@ -297,9 +353,20 @@ export class Renderer {
|
|
|
297
353
|
'.sb-tabbar-item, .sb-segment, .sb-picker-option, .sb-picker-menu-item, .sb-picker[data-sb-style="menu"], ' +
|
|
298
354
|
'.sb-menu, .sb-tapgesture, .sb-stepper-button, .sb-alert-scrim, .sb-alert-ok, ' +
|
|
299
355
|
// Phase 8: swipe action buttons, the edit-mode row control and selectable rows (the row sends `tap` itself)
|
|
300
|
-
'.sb-swipe-action, .sb-listrow-control, .sb-listrow[data-sb-selectable]'
|
|
356
|
+
'.sb-swipe-action, .sb-listrow-control, .sb-listrow[data-sb-selectable], ' +
|
|
357
|
+
// A link in a rich text: innermost, so it wins over a tappable row around the text
|
|
358
|
+
'.sb-link',
|
|
301
359
|
);
|
|
302
360
|
if (!target || !this.root.contains(target)) return;
|
|
361
|
+
if (target.classList.contains('sb-link')) {
|
|
362
|
+
const text = target.closest<HTMLElement>('.sb-text');
|
|
363
|
+
const id = Number(text?.dataset[ID_ATTR]);
|
|
364
|
+
const url = target.dataset['sbLink'];
|
|
365
|
+
if (!text || !Number.isInteger(id) || url === undefined) return;
|
|
366
|
+
event.preventDefault();
|
|
367
|
+
this.onEvent({ type: 'link', id, value: url });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
303
370
|
if (this.interaction.handleClick(target)) {
|
|
304
371
|
event.preventDefault();
|
|
305
372
|
return;
|
|
@@ -370,7 +437,7 @@ export class Renderer {
|
|
|
370
437
|
if (!Number.isInteger(id)) return;
|
|
371
438
|
event.preventDefault();
|
|
372
439
|
if (this.menu?.anchorId === id) this.closeMenu();
|
|
373
|
-
else if (target.classList.contains('sb-menu')) this.
|
|
440
|
+
else if (target.classList.contains('sb-menu')) this.openMenuWhenBuilt(target, id, 'menu');
|
|
374
441
|
else this.openMenu(target, id, this.pickerRows(id));
|
|
375
442
|
return;
|
|
376
443
|
}
|
|
@@ -405,6 +472,16 @@ export class Renderer {
|
|
|
405
472
|
const id = Number(target.dataset[ID_ATTR]);
|
|
406
473
|
if (!Number.isInteger(id)) return;
|
|
407
474
|
event.preventDefault();
|
|
475
|
+
if (target.hasAttribute('data-sb-tap-location')) {
|
|
476
|
+
// `onTapGesture { point in }`: the point in the element's own
|
|
477
|
+
// coordinates (points; the device frame may be scaled).
|
|
478
|
+
const rect = target.getBoundingClientRect();
|
|
479
|
+
const scale = target.offsetWidth > 0 ? rect.width / target.offsetWidth : 1;
|
|
480
|
+
const x = Math.round(((event.clientX - rect.left) / (scale || 1)) * 100) / 100;
|
|
481
|
+
const y = Math.round(((event.clientY - rect.top) / (scale || 1)) * 100) / 100;
|
|
482
|
+
this.onEvent({ type: 'tap', id, x, y });
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
408
485
|
this.onEvent({ type: 'tap', id });
|
|
409
486
|
};
|
|
410
487
|
|
|
@@ -415,17 +492,33 @@ export class Renderer {
|
|
|
415
492
|
*/
|
|
416
493
|
private syncFocus(): void {
|
|
417
494
|
const active = document.activeElement;
|
|
418
|
-
for (const input of this.root.querySelectorAll<
|
|
495
|
+
for (const input of this.root.querySelectorAll<HTMLElement>('.sb-textfield[data-sb-focus], .sb-texteditor[data-sb-focus]')) {
|
|
419
496
|
const wanted = input.dataset['sbFocus'] === 'true';
|
|
420
497
|
if (wanted && active !== input) input.focus();
|
|
421
498
|
else if (!wanted && active === input) input.blur();
|
|
422
499
|
}
|
|
423
500
|
}
|
|
424
501
|
|
|
502
|
+
/**
|
|
503
|
+
* `.scrollDismissesKeyboard`: scrolling a list or scroll view marked
|
|
504
|
+
* `data-sb-dismisses-keyboard` takes the focus from the text input being
|
|
505
|
+
* edited inside it (a bound field then reports the blur as usual).
|
|
506
|
+
*/
|
|
507
|
+
private readonly onScrollDismissKeyboard = (event: Event): void => {
|
|
508
|
+
const scroller = event.target;
|
|
509
|
+
if (!(scroller instanceof HTMLElement) || scroller.dataset['sbDismissesKeyboard'] !== 'true') return;
|
|
510
|
+
const active = document.activeElement;
|
|
511
|
+
if ((active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) && scroller.contains(active)) active.blur();
|
|
512
|
+
};
|
|
513
|
+
|
|
425
514
|
/** Phase 12: a bound field (`data-sb-focus`) reports focus changes. */
|
|
426
515
|
private readonly onFocusChange = (event: FocusEvent): void => {
|
|
427
|
-
|
|
428
|
-
|
|
516
|
+
// A bound `texteditor` (UIKit's text views) reports focus like a field.
|
|
517
|
+
const input = event.target as HTMLElement | null;
|
|
518
|
+
const isField =
|
|
519
|
+
(input instanceof HTMLInputElement || input instanceof HTMLTextAreaElement) && input.classList.contains('sb-textfield');
|
|
520
|
+
const isEditor = input instanceof HTMLTextAreaElement && input.classList.contains('sb-texteditor');
|
|
521
|
+
if (!input || (!isField && !isEditor)) return;
|
|
429
522
|
if (input.dataset['sbFocus'] === undefined || !this.root.contains(input)) return;
|
|
430
523
|
const id = Number(input.dataset[ID_ATTR]);
|
|
431
524
|
if (!Number.isInteger(id)) return;
|
|
@@ -445,7 +538,12 @@ export class Renderer {
|
|
|
445
538
|
const target = event.target as HTMLElement | null;
|
|
446
539
|
if (!target) return;
|
|
447
540
|
// Phase 12: Enter in a text field under `.onSubmit`.
|
|
448
|
-
if (
|
|
541
|
+
if (
|
|
542
|
+
event.key === 'Enter' &&
|
|
543
|
+
target.classList.contains('sb-textfield') &&
|
|
544
|
+
target.dataset['sbSubmit'] === 'true' &&
|
|
545
|
+
!(event.shiftKey && target instanceof HTMLTextAreaElement)
|
|
546
|
+
) {
|
|
449
547
|
const id = Number(target.dataset[ID_ATTR]);
|
|
450
548
|
if (Number.isInteger(id)) {
|
|
451
549
|
event.preventDefault();
|
|
@@ -606,7 +704,10 @@ export class Renderer {
|
|
|
606
704
|
root.dataset[KIND_ATTR] = 'root';
|
|
607
705
|
this.elements.set(ROOT_ID, root);
|
|
608
706
|
this.nodes.set(ROOT_ID, { id: ROOT_ID, kind: 'root', props: {}, children: [] });
|
|
609
|
-
this.animator = new Animator(root, {
|
|
707
|
+
this.animator = new Animator(root, {
|
|
708
|
+
selfAnimatingKinds: SELF_ANIMATING,
|
|
709
|
+
playOwnFrames: (el, animation) => hasPathFrames(el) && playPathFrames(el, animation),
|
|
710
|
+
});
|
|
610
711
|
// Delegated listeners instead of one per control: elements can be
|
|
611
712
|
// created detached and moved around freely.
|
|
612
713
|
root.addEventListener('click', this.onClick);
|
|
@@ -614,6 +715,9 @@ export class Renderer {
|
|
|
614
715
|
root.addEventListener('input', this.onInput);
|
|
615
716
|
root.addEventListener('focusin', this.onFocusChange);
|
|
616
717
|
root.addEventListener('focusout', this.onFocusChange);
|
|
718
|
+
root.addEventListener('scroll', this.onScrollDismissKeyboard, true);
|
|
719
|
+
root.addEventListener('scroll', this.queueLazyCheck, { capture: true, passive: true });
|
|
720
|
+
this.videoEvents = new VideoEvents(root, (event) => this.onEvent(event));
|
|
617
721
|
// Phase 8: pointer gestures (the sheet drag among them) are delegated too.
|
|
618
722
|
const host: InteractionHost = {
|
|
619
723
|
root,
|
|
@@ -626,6 +730,16 @@ export class Renderer {
|
|
|
626
730
|
this.interaction = new InteractionController(host);
|
|
627
731
|
root.addEventListener('change', this.onChange);
|
|
628
732
|
root.addEventListener('pointerdown', this.onSliderPointerDown);
|
|
733
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
734
|
+
this.resizeObserver = new ResizeObserver(() => {
|
|
735
|
+
const before = this.rootSize;
|
|
736
|
+
this.rootSize = null;
|
|
737
|
+
if (!before || this.commits === 0) return;
|
|
738
|
+
const now = this.readRootSize();
|
|
739
|
+
if (now.width !== before.width || now.height !== before.height) this.layout();
|
|
740
|
+
});
|
|
741
|
+
this.resizeObserver.observe(root);
|
|
742
|
+
}
|
|
629
743
|
}
|
|
630
744
|
|
|
631
745
|
/** Apply a batch of ops in order. Throws `RendererError` on an invalid op. */
|
|
@@ -642,7 +756,9 @@ export class Renderer {
|
|
|
642
756
|
}
|
|
643
757
|
|
|
644
758
|
private applyAll(ops: readonly Op[]): void {
|
|
645
|
-
for (
|
|
759
|
+
for (let index = 0; index < ops.length; index++) {
|
|
760
|
+
const op = ops[index]!;
|
|
761
|
+
if (this.passAnimates === null) this.passAnimates = this.passCanAnimate(ops, index);
|
|
646
762
|
switch (op.op) {
|
|
647
763
|
case 'create':
|
|
648
764
|
this.create(op.id, op.kind, op.props, op);
|
|
@@ -657,15 +773,26 @@ export class Renderer {
|
|
|
657
773
|
this.remove(op.id, op);
|
|
658
774
|
break;
|
|
659
775
|
case 'defaults':
|
|
776
|
+
case 'request':
|
|
777
|
+
case 'swiftdata':
|
|
660
778
|
// Consumed by the loader (wasm.ts); nothing to render.
|
|
661
779
|
break;
|
|
780
|
+
case 'imagedata':
|
|
781
|
+
// A bitmap's source, kept for the `image` elements that draw it (src/bitmaps.ts).
|
|
782
|
+
bitmapStore.add(op);
|
|
783
|
+
break;
|
|
662
784
|
case 'commit': {
|
|
663
785
|
const pass = this.pass;
|
|
664
786
|
this.pass = emptyPass();
|
|
787
|
+
this.passAnimates = null;
|
|
788
|
+
this.passRootRect = null;
|
|
665
789
|
const changes = this.layout();
|
|
666
790
|
this.animator.commit(pass, op.animation, changes);
|
|
667
791
|
this.commits += 1;
|
|
668
792
|
this.syncFocus();
|
|
793
|
+
this.performScrolls(op.animation != null);
|
|
794
|
+
this.checkLazySpacers();
|
|
795
|
+
this.openPendingMenu();
|
|
669
796
|
this.root.dispatchEvent(
|
|
670
797
|
new CustomEvent('sb:commit', { detail: { commits: this.commits, animation: op.animation ?? null } }),
|
|
671
798
|
);
|
|
@@ -679,8 +806,29 @@ export class Renderer {
|
|
|
679
806
|
}
|
|
680
807
|
}
|
|
681
808
|
|
|
809
|
+
/**
|
|
810
|
+
* Whether the pass starting at `ops[start]` can animate: its `commit` has
|
|
811
|
+
* an animation, or one of its updates changes an `animationToken`
|
|
812
|
+
* (`.animation(_:value:)`). Otherwise the Animator only retargets running
|
|
813
|
+
* frame tweens, which needs nothing recorded before the changes. A pass
|
|
814
|
+
* whose `commit` is not in this batch is assumed to animate.
|
|
815
|
+
*/
|
|
816
|
+
private passCanAnimate(ops: readonly Op[], start: number): boolean {
|
|
817
|
+
for (let index = start; index < ops.length; index++) {
|
|
818
|
+
const op = ops[index]!;
|
|
819
|
+
if (op.op === 'commit') return op.animation != null;
|
|
820
|
+
if (op.op !== 'update') continue;
|
|
821
|
+
const token = (op.props as { style?: { animationToken?: number | null } } | null)?.style?.animationToken;
|
|
822
|
+
if (token == null) continue;
|
|
823
|
+
const el = this.elements.get(op.id);
|
|
824
|
+
if (!el || el.dataset[ANIMATION_TOKEN] !== String(token)) return true;
|
|
825
|
+
}
|
|
826
|
+
return true;
|
|
827
|
+
}
|
|
828
|
+
|
|
682
829
|
/** Detach everything under the root and forget all non-root elements. */
|
|
683
830
|
reset(): void {
|
|
831
|
+
stopVideos(this.root);
|
|
684
832
|
this.root.replaceChildren();
|
|
685
833
|
this.elements.clear();
|
|
686
834
|
this.elements.set(ROOT_ID, this.root);
|
|
@@ -692,6 +840,8 @@ export class Renderer {
|
|
|
692
840
|
this.layoutResult = null;
|
|
693
841
|
this.engine = null;
|
|
694
842
|
this.pass = emptyPass();
|
|
843
|
+
this.passAnimates = null;
|
|
844
|
+
this.passRootRect = null;
|
|
695
845
|
this.closeMenu();
|
|
696
846
|
this.reportedGeometry.clear();
|
|
697
847
|
this.layoutReporter.clear();
|
|
@@ -777,20 +927,20 @@ export class Renderer {
|
|
|
777
927
|
}
|
|
778
928
|
|
|
779
929
|
/**
|
|
780
|
-
* The root's size changed (device mode: a resize or rotation)
|
|
781
|
-
*
|
|
930
|
+
* The root's size changed (device mode: a resize or rotation): re-read it
|
|
931
|
+
* and re-lay out now rather than when the ResizeObserver reports it.
|
|
782
932
|
*/
|
|
783
933
|
viewportChanged(): void {
|
|
934
|
+
this.rootSize = null;
|
|
784
935
|
if (this.commits > 0) this.layout();
|
|
785
936
|
}
|
|
786
937
|
|
|
787
938
|
/** The environment the engine lays out for. */
|
|
788
939
|
layoutEnvironment(): LayoutEnvironment {
|
|
789
|
-
const
|
|
790
|
-
const height = this.root.clientHeight || DEFAULT_SCREEN.height;
|
|
940
|
+
const screen = this.resizeObserver ? (this.rootSize ??= this.readRootSize()) : this.readRootSize();
|
|
791
941
|
if (!this.safeArea) this.safeArea = readSafeArea(this.root);
|
|
792
942
|
return {
|
|
793
|
-
screen: {
|
|
943
|
+
screen: { ...screen },
|
|
794
944
|
safeArea: this.safeArea,
|
|
795
945
|
dynamicTypeSize: this.environment.dynamicTypeSize,
|
|
796
946
|
devicePixelRatio: typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1,
|
|
@@ -799,6 +949,16 @@ export class Renderer {
|
|
|
799
949
|
};
|
|
800
950
|
}
|
|
801
951
|
|
|
952
|
+
private readRootSize(): { width: number; height: number } {
|
|
953
|
+
return {
|
|
954
|
+
width: this.root.clientWidth || DEFAULT_SCREEN.width,
|
|
955
|
+
height: this.root.clientHeight || DEFAULT_SCREEN.height,
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** Called after each layout pass (main.ts keeps the browser chrome's color in step). */
|
|
960
|
+
afterLayout: (() => void) | null = null;
|
|
961
|
+
|
|
802
962
|
/**
|
|
803
963
|
* Run the layout engine over the current tree and position every element.
|
|
804
964
|
* Returns the elements whose box changed. Runs at every `commit`; call it
|
|
@@ -834,17 +994,19 @@ export class Renderer {
|
|
|
834
994
|
this.collectGeometry(result);
|
|
835
995
|
this.collectLayouts(result);
|
|
836
996
|
if (this.applying === 0) this.flushGeometry();
|
|
997
|
+
this.afterLayout?.();
|
|
837
998
|
return changes;
|
|
838
999
|
}
|
|
839
1000
|
|
|
840
1001
|
/**
|
|
841
|
-
* Queue a `geometry` event for every `geometry` element
|
|
1002
|
+
* Queue a `geometry` event for every `geometry` element (and every element
|
|
1003
|
+
* whose props set `reportsSize`, an anchor preference's) whose laid-out size
|
|
842
1004
|
* (whole px) differs from the one last reported. Nothing is queued for an
|
|
843
1005
|
* unchanged size, which is what lets the Swift re-render loop converge.
|
|
844
1006
|
*/
|
|
845
1007
|
private collectGeometry(result: LayoutResult): void {
|
|
846
1008
|
for (const [id, node] of this.nodes) {
|
|
847
|
-
if (node.kind !== 'geometry') continue;
|
|
1009
|
+
if (node.kind !== 'geometry' && node.props['reportsSize'] !== true) continue;
|
|
848
1010
|
const frame = result.frames.get(id);
|
|
849
1011
|
if (!frame) continue; // e.g. inside a hidden tab
|
|
850
1012
|
const width = Math.round(frame.width);
|
|
@@ -947,9 +1109,47 @@ export class Renderer {
|
|
|
947
1109
|
*/
|
|
948
1110
|
openContextMenu(anchor: HTMLElement, id: ElementId): void {
|
|
949
1111
|
if (this.menu?.anchorId === id) return;
|
|
1112
|
+
if (!this.nodes.has(id) || !this.root.contains(anchor)) return;
|
|
1113
|
+
this.openMenuWhenBuilt(anchor, id, 'contextmenu');
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Lazy menus: a `deferred` menu asks Swift for its items (`menu` event,
|
|
1118
|
+
* `open: true`) and opens once they are there, which is usually as soon as
|
|
1119
|
+
* the event returns (the app answers with the ops at once), else at the
|
|
1120
|
+
* commit that brings them.
|
|
1121
|
+
*/
|
|
1122
|
+
private openMenuWhenBuilt(anchor: HTMLElement, id: ElementId, kind: 'menu' | 'contextmenu'): void {
|
|
1123
|
+
const node = this.nodes.get(id);
|
|
1124
|
+
if (!node) return;
|
|
1125
|
+
if (node.props['deferred'] !== true) {
|
|
1126
|
+
this.showMenu(anchor, id, kind);
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
this.closeMenu();
|
|
1130
|
+
this.pendingMenu = { anchor, id, kind };
|
|
1131
|
+
this.onEvent({ type: 'menu', id, open: true });
|
|
1132
|
+
this.openPendingMenu();
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
private showMenu(anchor: HTMLElement, id: ElementId, kind: 'menu' | 'contextmenu'): void {
|
|
950
1136
|
const node = this.nodes.get(id);
|
|
951
|
-
if (!node
|
|
952
|
-
this.openMenu(anchor, id, collectMenuRows(node.children.slice(1)));
|
|
1137
|
+
if (!node) return;
|
|
1138
|
+
this.openMenu(anchor, id, kind === 'menu' ? this.menuRows(id) : collectMenuRows(node.children.slice(1)));
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/** Opens the menu waiting for its items once they arrived (dropped when its element left). */
|
|
1142
|
+
private openPendingMenu(): void {
|
|
1143
|
+
const pending = this.pendingMenu;
|
|
1144
|
+
if (!pending) return;
|
|
1145
|
+
const node = this.nodes.get(pending.id);
|
|
1146
|
+
if (!node || !pending.anchor.isConnected) {
|
|
1147
|
+
this.pendingMenu = null;
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
if (node.props['deferred'] === true) return;
|
|
1151
|
+
this.pendingMenu = null;
|
|
1152
|
+
this.showMenu(pending.anchor, pending.id, pending.kind);
|
|
953
1153
|
}
|
|
954
1154
|
|
|
955
1155
|
/** Close the open popover (no-op when none is open). */
|
|
@@ -960,6 +1160,16 @@ export class Renderer {
|
|
|
960
1160
|
document.removeEventListener('click', this.onDocumentClick, true);
|
|
961
1161
|
menu.el.remove();
|
|
962
1162
|
menu.anchor.setAttribute('aria-expanded', 'false');
|
|
1163
|
+
// Lazy menus: Swift drops the items. After the current task, so that the
|
|
1164
|
+
// tap of the item chosen (sent right after closing) reaches its button.
|
|
1165
|
+
const id = menu.anchorId;
|
|
1166
|
+
if (this.nodes.get(id)?.props['deferred'] === false) {
|
|
1167
|
+
queueMicrotask(() => {
|
|
1168
|
+
if (this.nodes.get(id)?.props['deferred'] === false && this.menu?.anchorId !== id) {
|
|
1169
|
+
this.onEvent({ type: 'menu', id, open: false });
|
|
1170
|
+
}
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
963
1173
|
}
|
|
964
1174
|
|
|
965
1175
|
/** The open popover element (for tests), or null. */
|
|
@@ -981,7 +1191,10 @@ export class Renderer {
|
|
|
981
1191
|
this.root.removeEventListener('input', this.onInput);
|
|
982
1192
|
this.root.removeEventListener('focusin', this.onFocusChange);
|
|
983
1193
|
this.root.removeEventListener('focusout', this.onFocusChange);
|
|
1194
|
+
this.root.removeEventListener('scroll', this.onScrollDismissKeyboard, true);
|
|
984
1195
|
this.interaction.dispose();
|
|
1196
|
+
this.videoEvents.dispose();
|
|
1197
|
+
this.resizeObserver?.disconnect();
|
|
985
1198
|
this.root.removeEventListener('change', this.onChange);
|
|
986
1199
|
this.root.removeEventListener('pointerdown', this.onSliderPointerDown);
|
|
987
1200
|
this.reset();
|
|
@@ -995,13 +1208,72 @@ export class Renderer {
|
|
|
995
1208
|
if (id === ROOT_ID) throw new RendererError('Element 0 is the root and cannot be created', op);
|
|
996
1209
|
if (this.elements.has(id)) throw new RendererError(`Element ${id} already exists`, op);
|
|
997
1210
|
if (!ALL_KINDS.has(kind)) throw new RendererError(`Unknown element kind ${String(kind)}`, op);
|
|
998
|
-
|
|
1211
|
+
// A vertical `textfield` is a `<textarea>` (its axis is fixed at creation).
|
|
1212
|
+
const el = kind === 'textfield' && (props as TextFieldProps).axis === 'vertical' ? createMultilineField() : createShell(kind);
|
|
999
1213
|
el.className = `sb-${kind}`;
|
|
1000
1214
|
el.dataset[ID_ATTR] = String(id);
|
|
1001
1215
|
el.dataset[KIND_ATTR] = kind;
|
|
1002
1216
|
this.elements.set(id, el);
|
|
1003
1217
|
this.nodes.set(id, { id, kind, props: asRecord(props), children: [] });
|
|
1004
1218
|
applyProps(el, kind, props, this.assets);
|
|
1219
|
+
this.noteScroll(id, kind, props);
|
|
1220
|
+
if (kind === 'lazyspacer') this.lazySpacers.add(id);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/** After a scroll, the lazy spacers are checked on the next frame (once per frame). */
|
|
1224
|
+
private readonly queueLazyCheck = (): void => {
|
|
1225
|
+
if (this.lazyCheckQueued || this.lazySpacers.size === 0) return;
|
|
1226
|
+
this.lazyCheckQueued = true;
|
|
1227
|
+
requestAnimationFrame(() => {
|
|
1228
|
+
this.lazyCheckQueued = false;
|
|
1229
|
+
this.checkLazySpacers();
|
|
1230
|
+
});
|
|
1231
|
+
};
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* Lazy rows: a `lazyspacer` within a screen of its scroll view's visible
|
|
1235
|
+
* area asks Swift for the next rows (a `lazy` event), once; the next batch
|
|
1236
|
+
* comes with a new spacer, which is checked after that pass's layout.
|
|
1237
|
+
*/
|
|
1238
|
+
private checkLazySpacers(): void {
|
|
1239
|
+
for (const id of this.lazySpacers) {
|
|
1240
|
+
const el = this.elements.get(id);
|
|
1241
|
+
if (!el || !el.isConnected) {
|
|
1242
|
+
this.lazySpacers.delete(id);
|
|
1243
|
+
this.lazyReported.delete(id);
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
if (this.lazyReported.has(id) || !lazySpacerIsNear(el)) continue;
|
|
1247
|
+
this.lazyReported.add(id);
|
|
1248
|
+
this.onEvent({ type: 'lazy', id });
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/** A `styled` whose style carries a `scrollTo` not seen before is scrolled to after this pass's layout. */
|
|
1253
|
+
private noteScroll(id: ElementId, kind: ElementKind, props: ElementProps): void {
|
|
1254
|
+
if (kind !== 'styled') return;
|
|
1255
|
+
const serial = (props as StyledProps)?.style?.scrollTo?.serial;
|
|
1256
|
+
if (typeof serial !== 'number' || serial <= this.scrollSerial) return;
|
|
1257
|
+
this.pendingScrolls.push(id);
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/** `ScrollViewProxy.scrollTo`: scroll to the targets this pass marked (src/scrollTo.ts). */
|
|
1261
|
+
private performScrolls(animated: boolean): void {
|
|
1262
|
+
if (this.pendingScrolls.length === 0) return;
|
|
1263
|
+
const ids = this.pendingScrolls;
|
|
1264
|
+
this.pendingScrolls = [];
|
|
1265
|
+
const result = this.layoutResult;
|
|
1266
|
+
if (!result) return;
|
|
1267
|
+
// Several commands in one pass: the latest wins (it scrolls last).
|
|
1268
|
+
const commands = ids
|
|
1269
|
+
.map((id) => ({ id, command: (this.nodes.get(id)?.props as Partial<StyledProps> | undefined)?.style?.scrollTo }))
|
|
1270
|
+
.filter((entry): entry is { id: ElementId; command: ScrollToCommand } => entry.command !== undefined)
|
|
1271
|
+
.sort((a, b) => a.command.serial - b.command.serial);
|
|
1272
|
+
for (const { id, command } of commands) {
|
|
1273
|
+
if (command.serial <= this.scrollSerial) continue;
|
|
1274
|
+
scrollToTarget(this, result, id, command, animated);
|
|
1275
|
+
}
|
|
1276
|
+
for (const { command } of commands) this.scrollSerial = Math.max(this.scrollSerial, command.serial);
|
|
1005
1277
|
}
|
|
1006
1278
|
|
|
1007
1279
|
private update(id: ElementId, props: ElementProps, op: Op): void {
|
|
@@ -1032,7 +1304,7 @@ export class Renderer {
|
|
|
1032
1304
|
}
|
|
1033
1305
|
// Remember how the element looked before, in case the pass is animated.
|
|
1034
1306
|
const previous = this.pass.updated.get(el);
|
|
1035
|
-
const before = previous ? previous.before : this.animator.snapshot(el);
|
|
1307
|
+
const before = previous ? previous.before : this.passAnimates !== false ? this.animator.snapshot(el) : null;
|
|
1036
1308
|
const oldToken = el.dataset[ANIMATION_TOKEN];
|
|
1037
1309
|
// `update` replaces the props entirely, so drop whatever the previous
|
|
1038
1310
|
// props produced before applying the new ones. The layout box is kept:
|
|
@@ -1051,6 +1323,7 @@ export class Renderer {
|
|
|
1051
1323
|
setTransition(el, undefined);
|
|
1052
1324
|
if (kind === 'text') el.textContent = '';
|
|
1053
1325
|
applyProps(el, kind, props, this.assets);
|
|
1326
|
+
this.noteScroll(id, kind, props);
|
|
1054
1327
|
const newToken = el.dataset[ANIMATION_TOKEN];
|
|
1055
1328
|
const tokenChanged = (previous?.tokenChanged ?? false) || (newToken !== undefined && newToken !== oldToken);
|
|
1056
1329
|
const record: UpdatedRecord = { el, before, tokenChanged };
|
|
@@ -1085,14 +1358,24 @@ export class Renderer {
|
|
|
1085
1358
|
if (!Number.isInteger(index) || index < 0) {
|
|
1086
1359
|
throw new RendererError(`Invalid child index ${index}`, op);
|
|
1087
1360
|
}
|
|
1088
|
-
|
|
1361
|
+
// A section's view header / footer lives beside its rows' card, in the
|
|
1362
|
+
// section element itself; the rows after a header are one DOM slot lower.
|
|
1363
|
+
const supplement = isSectionSupplement(el);
|
|
1364
|
+
const container = supplement && parent.classList.contains('sb-section') ? parent : slotOf(parent);
|
|
1089
1365
|
// Moves: `index` is the element's position in the parent's child list
|
|
1090
1366
|
// *after* the op is applied. Detaching first makes the index arithmetic
|
|
1091
1367
|
// identical for fresh inserts and for moves within the same parent.
|
|
1092
1368
|
const previousParent = el.parentElement;
|
|
1093
1369
|
const wasAttached = el.isConnected;
|
|
1094
1370
|
el.remove();
|
|
1095
|
-
|
|
1371
|
+
let slotIndex = index;
|
|
1372
|
+
if (!supplement && index > 0 && parent.classList.contains('sb-section')) {
|
|
1373
|
+
const first = this.nodes.get(parentId)?.children.find((child) => child.id !== id);
|
|
1374
|
+
if (first?.kind === 'sectionheader') slotIndex -= 1;
|
|
1375
|
+
}
|
|
1376
|
+
const reference = supplement
|
|
1377
|
+
? el.classList.contains('sb-sectionheader') ? container.firstChild : null
|
|
1378
|
+
: (container.children[slotIndex] ?? null);
|
|
1096
1379
|
container.insertBefore(el, reference);
|
|
1097
1380
|
if (!wasAttached) this.pass.inserted.add(el);
|
|
1098
1381
|
// Mirror the move in the layout tree.
|
|
@@ -1166,10 +1449,17 @@ export class Renderer {
|
|
|
1166
1449
|
// `commit` whether the pass is animated.
|
|
1167
1450
|
const ancestors: HTMLElement[] = [];
|
|
1168
1451
|
for (let a = parent; a && a !== this.root; a = a.parentElement!) ancestors.push(a);
|
|
1169
|
-
|
|
1452
|
+
let rect: DOMRect | null = null;
|
|
1453
|
+
if (this.passAnimates !== false) {
|
|
1454
|
+
this.passRootRect ??= this.animator.rootRect();
|
|
1455
|
+
rect = this.animator.rectOf(el, this.passRootRect);
|
|
1456
|
+
}
|
|
1457
|
+
this.pass.removed.push({ el, ancestors, rect, transition: transitionOf(el) });
|
|
1170
1458
|
}
|
|
1171
1459
|
this.pass.inserted.delete(el);
|
|
1172
1460
|
this.pass.updated.delete(el);
|
|
1461
|
+
// A detached <video> would keep playing its sound.
|
|
1462
|
+
stopVideos(el);
|
|
1173
1463
|
el.remove();
|
|
1174
1464
|
// Phase 9: the layout pass drops the subtree's output; the parent re-measures.
|
|
1175
1465
|
const removedNode = this.nodes.get(id);
|
|
@@ -1282,6 +1572,14 @@ function collectMenuRows(items: readonly LayoutNode[], rows: MenuRow[] = []): Me
|
|
|
1282
1572
|
collectMenuRows(base.children, rows);
|
|
1283
1573
|
break;
|
|
1284
1574
|
}
|
|
1575
|
+
case 'sectionheader': {
|
|
1576
|
+
// A view header: its text as the caption.
|
|
1577
|
+
const text = textOf(base);
|
|
1578
|
+
if (text.length > 0) rows.push({ kind: 'caption', text });
|
|
1579
|
+
break;
|
|
1580
|
+
}
|
|
1581
|
+
case 'sectionfooter':
|
|
1582
|
+
break;
|
|
1285
1583
|
case 'text':
|
|
1286
1584
|
rows.push({ kind: 'text', text: textOf(base) });
|
|
1287
1585
|
break;
|
|
@@ -1561,7 +1859,8 @@ function createShell(kind: ElementKind): HTMLElement {
|
|
|
1561
1859
|
track.setAttribute('aria-hidden', 'true');
|
|
1562
1860
|
const fill = div('sb-gauge-fill');
|
|
1563
1861
|
fill.setAttribute('aria-hidden', 'true');
|
|
1564
|
-
|
|
1862
|
+
// A circular style's ring (gaugeRing.ts), placed from the `ring` rect.
|
|
1863
|
+
el.append(slot, track, fill, createGaugeRing());
|
|
1565
1864
|
SLOTS.set(el, slot);
|
|
1566
1865
|
return el;
|
|
1567
1866
|
}
|
|
@@ -1739,6 +2038,8 @@ function createShell(kind: ElementKind): HTMLElement {
|
|
|
1739
2038
|
SLOTS.set(el, slot);
|
|
1740
2039
|
return el;
|
|
1741
2040
|
}
|
|
2041
|
+
case 'canvas':
|
|
2042
|
+
return createCanvasElement();
|
|
1742
2043
|
case 'texteditor': {
|
|
1743
2044
|
const el = document.createElement('textarea');
|
|
1744
2045
|
el.autocomplete = 'off';
|
|
@@ -1750,6 +2051,19 @@ function createShell(kind: ElementKind): HTMLElement {
|
|
|
1750
2051
|
}
|
|
1751
2052
|
}
|
|
1752
2053
|
|
|
2054
|
+
/** A vertical `textfield`: a `<textarea>` the layout sizes to its lines. */
|
|
2055
|
+
function createMultilineField(): HTMLTextAreaElement {
|
|
2056
|
+
const el = document.createElement('textarea');
|
|
2057
|
+
el.autocomplete = 'off';
|
|
2058
|
+
el.spellcheck = false;
|
|
2059
|
+
el.rows = 1;
|
|
2060
|
+
return el;
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
function isSectionSupplement(el: HTMLElement): boolean {
|
|
2064
|
+
return el.classList.contains('sb-sectionheader') || el.classList.contains('sb-sectionfooter');
|
|
2065
|
+
}
|
|
2066
|
+
|
|
1753
2067
|
function span(className: string): HTMLSpanElement {
|
|
1754
2068
|
const el = document.createElement('span');
|
|
1755
2069
|
el.className = className;
|
|
@@ -1873,7 +2187,7 @@ function refreshTabBar(tabview: HTMLElement): void {
|
|
|
1873
2187
|
item.type = 'button';
|
|
1874
2188
|
item.className = 'sb-tabbar-item';
|
|
1875
2189
|
item.setAttribute('role', 'tab');
|
|
1876
|
-
item.append(span('sb-tabbar-icon'), span('sb-tabbar-title'));
|
|
2190
|
+
item.append(span('sb-tabbar-icon'), span('sb-tabbar-title'), span('sb-tabbar-badge'));
|
|
1877
2191
|
bar.append(item);
|
|
1878
2192
|
}
|
|
1879
2193
|
tabs.forEach((tab, i) => {
|
|
@@ -1896,6 +2210,14 @@ function refreshTabBar(tabview: HTMLElement): void {
|
|
|
1896
2210
|
}
|
|
1897
2211
|
const label = item.querySelector<HTMLElement>(':scope > .sb-tabbar-title');
|
|
1898
2212
|
if (label && label.textContent !== title) label.textContent = title;
|
|
2213
|
+
// `Tab(…).badge(…)` and `Tab(role: .search)` (the iOS 18 tab API).
|
|
2214
|
+
const badge = item.querySelector<HTMLElement>(':scope > .sb-tabbar-badge');
|
|
2215
|
+
const badgeText = tab.dataset['sbBadge'] ?? '';
|
|
2216
|
+
if (badge && badge.textContent !== badgeText) badge.textContent = badgeText;
|
|
2217
|
+
if (badge) badge.hidden = badgeText === '';
|
|
2218
|
+
const role = tab.dataset['sbRole'];
|
|
2219
|
+
if (role) item.dataset['sbRole'] = role;
|
|
2220
|
+
else delete item.dataset['sbRole'];
|
|
1899
2221
|
setTabHidden(tab, !isSelected);
|
|
1900
2222
|
});
|
|
1901
2223
|
}
|
|
@@ -1954,7 +2276,9 @@ function animateSheetOut(container: HTMLElement, sheet: HTMLElement): void {
|
|
|
1954
2276
|
function applySheet(el: HTMLElement, props: SheetProps): void {
|
|
1955
2277
|
const detents = Array.isArray(props.detents) && props.detents.length ? props.detents : ['large'];
|
|
1956
2278
|
// Phase 8: `full` is `.fullScreenCover` (no radius, grabber or drag; the presenting tree keeps its scale).
|
|
1957
|
-
|
|
2279
|
+
// A custom height (`height:` / `fraction:`) leaves the presenting tree unscaled, like `medium`.
|
|
2280
|
+
const custom = typeof detents[0] === 'string' && /^(height|fraction):/.test(detents[0]);
|
|
2281
|
+
const first = detents[0] === 'medium' || custom ? 'medium' : detents[0] === 'full' ? 'full' : 'large';
|
|
1958
2282
|
el.dataset['sbDetent'] = first;
|
|
1959
2283
|
el.dataset['sbDetents'] = detents.join(' ');
|
|
1960
2284
|
}
|
|
@@ -2077,6 +2401,9 @@ function setDatePickerValue(el: HTMLElement, seconds: number): void {
|
|
|
2077
2401
|
function applyTextEditor(area: HTMLTextAreaElement, props: TextEditorProps): void {
|
|
2078
2402
|
const text = props.text ?? '';
|
|
2079
2403
|
if (area.value !== text) area.value = text;
|
|
2404
|
+
// Bound focus, as for a `textfield` (moved in `syncFocus` after the commit).
|
|
2405
|
+
if (typeof props.focused === 'boolean') area.dataset['sbFocus'] = String(props.focused);
|
|
2406
|
+
else delete area.dataset['sbFocus'];
|
|
2080
2407
|
}
|
|
2081
2408
|
|
|
2082
2409
|
// ---------------------------------------------------------------------------
|
|
@@ -2086,7 +2413,7 @@ function applyTextEditor(area: HTMLTextAreaElement, props: TextEditorProps): voi
|
|
|
2086
2413
|
function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, assets: AssetCatalog): void {
|
|
2087
2414
|
switch (kind) {
|
|
2088
2415
|
case 'text':
|
|
2089
|
-
applyText(el, props as TextProps);
|
|
2416
|
+
applyText(el, props as TextProps, assets);
|
|
2090
2417
|
break;
|
|
2091
2418
|
case 'vstack':
|
|
2092
2419
|
applyVStack(el, props as VStackProps);
|
|
@@ -2111,11 +2438,14 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
|
|
|
2111
2438
|
case 'image':
|
|
2112
2439
|
applyImage(el, props as ImageProps, assets);
|
|
2113
2440
|
break;
|
|
2441
|
+
case 'tapgesture':
|
|
2442
|
+
// `location`: the tap is sent with its point (`onTapGesture { point in }`).
|
|
2443
|
+
el.toggleAttribute('data-sb-tap-location', (props as TapGestureProps | undefined)?.location === true);
|
|
2444
|
+
break;
|
|
2114
2445
|
case 'divider':
|
|
2115
2446
|
case 'navstack':
|
|
2116
2447
|
case 'geometry':
|
|
2117
2448
|
case 'menu':
|
|
2118
|
-
case 'tapgesture':
|
|
2119
2449
|
case 'navsplit':
|
|
2120
2450
|
// No props.
|
|
2121
2451
|
break;
|
|
@@ -2143,6 +2473,9 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
|
|
|
2143
2473
|
// Phase 8: edit mode (the engine shifts `listrow` content) and pull to refresh.
|
|
2144
2474
|
el.toggleAttribute('data-sb-editing', list.editing === true);
|
|
2145
2475
|
applyRefreshable(el, list.refreshable === true, list.refreshing === true);
|
|
2476
|
+
// `.scrollContentBackground(.hidden)`: no grouped background (styles.css).
|
|
2477
|
+
if (list.background === 'hidden') el.dataset['sbBackground'] = 'hidden';
|
|
2478
|
+
if (list.dismissesKeyboard === true) el.dataset['sbDismissesKeyboard'] = 'true';
|
|
2146
2479
|
break;
|
|
2147
2480
|
}
|
|
2148
2481
|
case 'tabview': {
|
|
@@ -2151,9 +2484,13 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
|
|
|
2151
2484
|
break;
|
|
2152
2485
|
}
|
|
2153
2486
|
case 'tab': {
|
|
2154
|
-
const { title, systemImage } = props as TabProps;
|
|
2487
|
+
const { title, systemImage, badge, role } = props as TabProps;
|
|
2155
2488
|
el.dataset['sbTitle'] = title ?? '';
|
|
2156
2489
|
el.dataset['sbSymbol'] = systemImage ?? '';
|
|
2490
|
+
if (badge) el.dataset['sbBadge'] = badge;
|
|
2491
|
+
else delete el.dataset['sbBadge'];
|
|
2492
|
+
if (role) el.dataset['sbRole'] = role;
|
|
2493
|
+
else delete el.dataset['sbRole'];
|
|
2157
2494
|
el.setAttribute('aria-label', title ?? '');
|
|
2158
2495
|
break;
|
|
2159
2496
|
}
|
|
@@ -2166,13 +2503,14 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
|
|
|
2166
2503
|
applyRefreshable(el, scroll.refreshable === true, scroll.refreshing === true);
|
|
2167
2504
|
if (scroll.snap === 'viewAligned' || scroll.snap === 'paging') el.dataset['sbSnap'] = scroll.snap;
|
|
2168
2505
|
else delete el.dataset['sbSnap'];
|
|
2506
|
+
if (scroll.dismissesKeyboard === true) el.dataset['sbDismissesKeyboard'] = 'true';
|
|
2169
2507
|
break;
|
|
2170
2508
|
}
|
|
2171
2509
|
case 'section':
|
|
2172
2510
|
applySection(el, props as SectionProps);
|
|
2173
2511
|
break;
|
|
2174
2512
|
case 'textfield':
|
|
2175
|
-
applyTextField(el as HTMLInputElement, props as TextFieldProps);
|
|
2513
|
+
applyTextField(el as HTMLInputElement | HTMLTextAreaElement, props as TextFieldProps);
|
|
2176
2514
|
break;
|
|
2177
2515
|
case 'toggle':
|
|
2178
2516
|
setSwitchState(el, Boolean((props as ToggleProps).isOn));
|
|
@@ -2275,6 +2613,19 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
|
|
|
2275
2613
|
case 'texteditor':
|
|
2276
2614
|
applyTextEditor(el as HTMLTextAreaElement, (props as TextEditorProps) ?? {});
|
|
2277
2615
|
break;
|
|
2616
|
+
case 'sectionheader':
|
|
2617
|
+
case 'sectionfooter':
|
|
2618
|
+
// Pure containers: the list layout positions them.
|
|
2619
|
+
break;
|
|
2620
|
+
case 'video':
|
|
2621
|
+
applyVideo(el, props as VideoProps);
|
|
2622
|
+
break;
|
|
2623
|
+
case 'canvas':
|
|
2624
|
+
applyCanvas(el, props as CanvasProps, assets);
|
|
2625
|
+
break;
|
|
2626
|
+
case 'lazyspacer':
|
|
2627
|
+
el.dataset['axis'] = (props as LazySpacerProps).axis === 'horizontal' ? 'horizontal' : 'vertical';
|
|
2628
|
+
break;
|
|
2278
2629
|
default: {
|
|
2279
2630
|
const unknown: never = kind;
|
|
2280
2631
|
throw new RendererError(`Unknown element kind ${String(unknown)}`);
|
|
@@ -2282,8 +2633,59 @@ function applyProps(el: HTMLElement, kind: ElementKind, props: ElementProps, ass
|
|
|
2282
2633
|
}
|
|
2283
2634
|
}
|
|
2284
2635
|
|
|
2285
|
-
function applyText(el: HTMLElement, props: TextProps): void {
|
|
2286
|
-
|
|
2636
|
+
function applyText(el: HTMLElement, props: TextProps, assets: AssetCatalog): void {
|
|
2637
|
+
if (!Array.isArray(props.runs)) {
|
|
2638
|
+
runTemplates.delete(el);
|
|
2639
|
+
delete el.dataset['sbRich'];
|
|
2640
|
+
clearRichText(el);
|
|
2641
|
+
el.textContent = props.text ?? '';
|
|
2642
|
+
return;
|
|
2643
|
+
}
|
|
2644
|
+
// Rich text: a template per run; the layout pass draws the lines (src/richText.ts).
|
|
2645
|
+
el.dataset['sbRich'] = '';
|
|
2646
|
+
runTemplates.set(el, props.runs.map((run) => runTemplate(run, assets)));
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
/** A rich text run's span (or inline image element) with its style; the layout pass sets the font. */
|
|
2650
|
+
function runTemplate(run: TextRun, assets: AssetCatalog): HTMLElement {
|
|
2651
|
+
let el: HTMLElement;
|
|
2652
|
+
if (run.image) {
|
|
2653
|
+
el = createShell('image');
|
|
2654
|
+
el.className = 'sb-image sb-text-image';
|
|
2655
|
+
applyImage(el, run.image, assets);
|
|
2656
|
+
// A symbol is a glyph: centered on the line like text, not stood on the baseline.
|
|
2657
|
+
if (!isBitmapImage(run.image) && !isUrlImage(run.image)) el.classList.add('sb-text-symbol');
|
|
2658
|
+
} else {
|
|
2659
|
+
el = document.createElement('span');
|
|
2660
|
+
el.className = 'sb-text-run';
|
|
2661
|
+
}
|
|
2662
|
+
const s = el.style;
|
|
2663
|
+
if (run.foreground !== undefined) s.color = styleToCSS(run.foreground as ShapeStyle, 'text').solid;
|
|
2664
|
+
const lines: string[] = [];
|
|
2665
|
+
let lineColor: unknown;
|
|
2666
|
+
if (run.underline) {
|
|
2667
|
+
lines.push('underline');
|
|
2668
|
+
lineColor = run.underline.color;
|
|
2669
|
+
}
|
|
2670
|
+
if (run.strikethrough) {
|
|
2671
|
+
lines.push('line-through');
|
|
2672
|
+
lineColor ??= run.strikethrough.color;
|
|
2673
|
+
}
|
|
2674
|
+
if (lines.length > 0) {
|
|
2675
|
+
s.textDecorationLine = lines.join(' ');
|
|
2676
|
+
if (lineColor !== undefined) s.textDecorationColor = styleToCSS(lineColor as ShapeStyle, 'text').solid;
|
|
2677
|
+
}
|
|
2678
|
+
if (typeof run.kerning === 'number' && run.kerning !== 0) s.letterSpacing = `${run.kerning}px`;
|
|
2679
|
+
if (typeof run.baselineOffset === 'number' && run.baselineOffset !== 0) {
|
|
2680
|
+
s.position = 'relative';
|
|
2681
|
+
s.top = `${-run.baselineOffset}px`;
|
|
2682
|
+
}
|
|
2683
|
+
if (typeof run.link === 'string') {
|
|
2684
|
+
el.classList.add('sb-link');
|
|
2685
|
+
el.dataset['sbLink'] = run.link;
|
|
2686
|
+
el.setAttribute('role', 'link');
|
|
2687
|
+
}
|
|
2688
|
+
return el;
|
|
2287
2689
|
}
|
|
2288
2690
|
|
|
2289
2691
|
/**
|
|
@@ -2313,6 +2715,8 @@ function applyGauge(el: HTMLElement, props: GaugeProps): void {
|
|
|
2313
2715
|
el.dataset['sbStyle'] = props.style ?? 'automatic';
|
|
2314
2716
|
el.dataset['sbValue'] = String(value);
|
|
2315
2717
|
el.setAttribute('aria-valuenow', String(value));
|
|
2718
|
+
const ring = el.querySelector<HTMLElement>(':scope > .sb-gauge-ring');
|
|
2719
|
+
if (ring && isCircularGauge(props.style)) updateGaugeRing(ring, props.style, value);
|
|
2316
2720
|
}
|
|
2317
2721
|
|
|
2318
2722
|
function applyColor(el: HTMLElement, props: ColorProps): void {
|
|
@@ -2320,7 +2724,7 @@ function applyColor(el: HTMLElement, props: ColorProps): void {
|
|
|
2320
2724
|
}
|
|
2321
2725
|
|
|
2322
2726
|
/** Radius that turns a box into each shape (the layout engine sizes it). */
|
|
2323
|
-
const SHAPE_RADIUS: Record<ShapeProps['shape'], (cornerRadius: number) => string> = {
|
|
2727
|
+
const SHAPE_RADIUS: Record<Exclude<ShapeProps['shape'], 'path'>, (cornerRadius: number) => string> = {
|
|
2324
2728
|
rectangle: () => '0px',
|
|
2325
2729
|
roundedRectangle: (r) => `${Math.max(0, r)}px`,
|
|
2326
2730
|
circle: () => '50%',
|
|
@@ -2340,7 +2744,13 @@ const SHAPE_RADIUS: Record<ShapeProps['shape'], (cornerRadius: number) => string
|
|
|
2340
2744
|
* and `data-sb-stroke` records which one it was.
|
|
2341
2745
|
*/
|
|
2342
2746
|
function applyShape(el: HTMLElement, props: ShapeProps): void {
|
|
2343
|
-
|
|
2747
|
+
if (props.shape === 'path') {
|
|
2748
|
+
// A custom shape: an SVG of its path (src/pathShape.ts).
|
|
2749
|
+
el.dataset['sbShape'] = 'path';
|
|
2750
|
+
applyPathShape(el, props);
|
|
2751
|
+
return;
|
|
2752
|
+
}
|
|
2753
|
+
const shape = props.shape in SHAPE_RADIUS ? (props.shape as keyof typeof SHAPE_RADIUS) : 'rectangle';
|
|
2344
2754
|
const s = el.style;
|
|
2345
2755
|
el.dataset['sbShape'] = shape;
|
|
2346
2756
|
s.borderRadius = SHAPE_RADIUS[shape](Number(props.cornerRadius) || 0);
|
|
@@ -2354,6 +2764,48 @@ function applyShape(el: HTMLElement, props: ShapeProps): void {
|
|
|
2354
2764
|
s.border = `${width}px solid ${color}`;
|
|
2355
2765
|
el.dataset['sbStroke'] = stroke.inset ? 'inset' : 'center';
|
|
2356
2766
|
}
|
|
2767
|
+
// A fill with a drop shadow (`.shadow(.drop(…))` on the style).
|
|
2768
|
+
const shadow = (props.fill as { shadow?: ShapeShadow } | null)?.shadow;
|
|
2769
|
+
s.filter = shadow && typeof shadow === 'object' ? shapeShadowCSS(shadow) : '';
|
|
2770
|
+
const mask = shapeTrimMask(props.trim);
|
|
2771
|
+
if (mask) {
|
|
2772
|
+
s.setProperty('mask-image', mask);
|
|
2773
|
+
s.setProperty('-webkit-mask-image', mask);
|
|
2774
|
+
} else if (el.dataset['sbTrim']) {
|
|
2775
|
+
s.removeProperty('mask-image');
|
|
2776
|
+
s.removeProperty('-webkit-mask-image');
|
|
2777
|
+
}
|
|
2778
|
+
if (mask) el.dataset['sbTrim'] = `${props.trim!.from},${props.trim!.to}`;
|
|
2779
|
+
else delete el.dataset['sbTrim'];
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
/** A style's drop shadow (`ShadowStyle.drop`). */
|
|
2783
|
+
interface ShapeShadow {
|
|
2784
|
+
color?: Color;
|
|
2785
|
+
radius?: number;
|
|
2786
|
+
x?: number;
|
|
2787
|
+
y?: number;
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
function shapeShadowCSS(shadow: ShapeShadow): string {
|
|
2791
|
+
const n = (v: number | undefined) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
2792
|
+
const color = shadow.color ? colorToCSS(shadow.color) : 'rgba(0, 0, 0, 0.33)';
|
|
2793
|
+
return `drop-shadow(${n(shadow.x)}px ${n(shadow.y)}px ${Math.max(0, n(shadow.radius))}px ${color})`;
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
/**
|
|
2797
|
+
* `.trim(from:to:)`: a conic gradient opaque between the fractions, starting
|
|
2798
|
+
* at 3 o'clock (CSS's 90deg) and turning clockwise like SwiftUI's circle path.
|
|
2799
|
+
* Undefined for an untrimmed (or fully kept) shape.
|
|
2800
|
+
*/
|
|
2801
|
+
export function shapeTrimMask(trim: ShapeProps['trim']): string | undefined {
|
|
2802
|
+
if (!trim) return undefined;
|
|
2803
|
+
const from = Math.min(1, Math.max(0, Number(trim.from) || 0));
|
|
2804
|
+
const to = Math.min(1, Math.max(0, Number(trim.to) || 0));
|
|
2805
|
+
if (from <= 0 && to >= 1) return undefined;
|
|
2806
|
+
const a = Math.round(from * 36000) / 100;
|
|
2807
|
+
const b = Math.round(Math.max(from, to) * 36000) / 100;
|
|
2808
|
+
return `conic-gradient(from 90deg, transparent ${a}deg, #000 ${a}deg, #000 ${b}deg, transparent ${b}deg)`;
|
|
2357
2809
|
}
|
|
2358
2810
|
|
|
2359
2811
|
/** Paint an area with a resolved ShapeStyle: a color, a gradient image or a material over a blurred backdrop. */
|
|
@@ -2417,6 +2869,50 @@ function applyImage(el: HTMLElement, props: ImageProps, assets: AssetCatalog): v
|
|
|
2417
2869
|
el.setAttribute('aria-label', name);
|
|
2418
2870
|
delete el.dataset['sbMissing'];
|
|
2419
2871
|
};
|
|
2872
|
+
// `.interpolation(.none)`: scaled without smoothing (styles.css).
|
|
2873
|
+
if ((props as { interpolation?: string }).interpolation === 'none') el.dataset['sbInterpolation'] = 'none';
|
|
2874
|
+
else delete el.dataset['sbInterpolation'];
|
|
2875
|
+
if (isBitmapImage(props)) {
|
|
2876
|
+
// A `UIImage` bitmap: a canvas the recipe is drawn on once the layout
|
|
2877
|
+
// engine has sized the box (src/bitmaps.ts).
|
|
2878
|
+
delete el.dataset['sbSymbol'];
|
|
2879
|
+
delete el.dataset['sbAsset'];
|
|
2880
|
+
delete el.dataset['sbUrl'];
|
|
2881
|
+
if (props.resizable) el.dataset['sbResizable'] = '';
|
|
2882
|
+
else delete el.dataset['sbResizable'];
|
|
2883
|
+
let canvas = el.firstElementChild;
|
|
2884
|
+
if (!(canvas instanceof HTMLCanvasElement)) {
|
|
2885
|
+
canvas = document.createElement('canvas');
|
|
2886
|
+
found('', canvas);
|
|
2887
|
+
el.removeAttribute('aria-label');
|
|
2888
|
+
}
|
|
2889
|
+
showBitmap(canvas as HTMLCanvasElement, props.bitmap);
|
|
2890
|
+
return;
|
|
2891
|
+
}
|
|
2892
|
+
if (isUrlImage(props)) {
|
|
2893
|
+
// Phase 13: the page decoded this URL for `AsyncImage` already, so the
|
|
2894
|
+
// <img> shows at once; the layout engine sized the box from the props.
|
|
2895
|
+
delete el.dataset['sbSymbol'];
|
|
2896
|
+
delete el.dataset['sbAsset'];
|
|
2897
|
+
el.dataset['sbUrl'] = props.url;
|
|
2898
|
+
if (props.resizable) el.dataset['sbResizable'] = '';
|
|
2899
|
+
else delete el.dataset['sbResizable'];
|
|
2900
|
+
const existing = el.firstElementChild;
|
|
2901
|
+
if (existing instanceof HTMLImageElement) {
|
|
2902
|
+
if (existing.getAttribute('src') !== props.url && existing.dataset['sbPending'] !== props.url) {
|
|
2903
|
+
swapImageSource(existing, props.url);
|
|
2904
|
+
}
|
|
2905
|
+
return;
|
|
2906
|
+
}
|
|
2907
|
+
const img = document.createElement('img');
|
|
2908
|
+
img.src = props.url;
|
|
2909
|
+
img.alt = '';
|
|
2910
|
+
img.draggable = false;
|
|
2911
|
+
img.decoding = 'async';
|
|
2912
|
+
found(props.url, img);
|
|
2913
|
+
return;
|
|
2914
|
+
}
|
|
2915
|
+
delete el.dataset['sbUrl'];
|
|
2420
2916
|
if (isAssetImage(props)) {
|
|
2421
2917
|
const name = props.name ?? '';
|
|
2422
2918
|
delete el.dataset['sbSymbol'];
|
|
@@ -2499,8 +2995,10 @@ function applySection(el: HTMLElement, props: SectionProps): void {
|
|
|
2499
2995
|
}
|
|
2500
2996
|
}
|
|
2501
2997
|
|
|
2502
|
-
function applyTextField(input: HTMLInputElement, props: TextFieldProps): void {
|
|
2503
|
-
|
|
2998
|
+
function applyTextField(input: HTMLInputElement | HTMLTextAreaElement, props: TextFieldProps): void {
|
|
2999
|
+
// A vertical field is a `<textarea>`, which has no type.
|
|
3000
|
+
if (input instanceof HTMLInputElement) input.type = props.secure ? 'password' : 'text';
|
|
3001
|
+
else input.dataset['sbAxis'] = 'vertical';
|
|
2504
3002
|
input.placeholder = props.placeholder ?? '';
|
|
2505
3003
|
input.dataset['sbStyle'] = props.style ?? 'plain';
|
|
2506
3004
|
const text = props.text ?? '';
|
|
@@ -2736,6 +3234,15 @@ export function applyStyle(el: HTMLElement, style: Style): void {
|
|
|
2736
3234
|
}
|
|
2737
3235
|
}
|
|
2738
3236
|
|
|
3237
|
+
// View `.underline()` / `.strikethrough()` / `.italic()`: inherited by the
|
|
3238
|
+
// texts below (`.sb-text` reads the decoration properties; `initial` turns
|
|
3239
|
+
// an ancestor's off).
|
|
3240
|
+
if (style.underline != null) s.setProperty('--sb-text-underline', style.underline ? 'underline' : 'initial');
|
|
3241
|
+
if (style.strikethrough != null) {
|
|
3242
|
+
s.setProperty('--sb-text-strikethrough', style.strikethrough ? 'line-through' : 'initial');
|
|
3243
|
+
}
|
|
3244
|
+
if (style.italic != null) s.fontStyle = style.italic ? 'italic' : 'normal';
|
|
3245
|
+
|
|
2739
3246
|
if (style.background) {
|
|
2740
3247
|
applyPaint(el, styleToCSS(style.background, 'paint'));
|
|
2741
3248
|
// A painted background is a view of its own: hittable (styles.css).
|
|
@@ -2760,6 +3267,9 @@ export function applyStyle(el: HTMLElement, style: Style): void {
|
|
|
2760
3267
|
if (style.cornerRadius != null) {
|
|
2761
3268
|
s.borderRadius = `${style.cornerRadius}px`;
|
|
2762
3269
|
s.overflow = 'hidden';
|
|
3270
|
+
} else if (style.backgroundCornerRadius != null) {
|
|
3271
|
+
// The background in its container's shape, without clipping the content.
|
|
3272
|
+
s.borderRadius = `${style.backgroundCornerRadius}px`;
|
|
2763
3273
|
}
|
|
2764
3274
|
|
|
2765
3275
|
// Phase 6 visual effects: CSS only, the layout engine never sees them.
|
|
@@ -2767,6 +3277,11 @@ export function applyStyle(el: HTMLElement, style: Style): void {
|
|
|
2767
3277
|
if (effects.transform) s.transform = effects.transform;
|
|
2768
3278
|
if (effects.transformOrigin) s.transformOrigin = effects.transformOrigin;
|
|
2769
3279
|
if (effects.filter) s.filter = effects.filter;
|
|
3280
|
+
// `.colorMultiply`: an SVG filter, first so the other filters see its colors.
|
|
3281
|
+
if (style.colorMultiply) {
|
|
3282
|
+
const multiply = colorMultiplyFilter(style.colorMultiply, el);
|
|
3283
|
+
s.filter = effects.filter ? `${multiply} ${effects.filter}` : multiply;
|
|
3284
|
+
}
|
|
2770
3285
|
// Phase 7: `.hidden()` keeps the box (the engine lays it out) but draws
|
|
2771
3286
|
// nothing and takes no input; `.mask { }` clips the box to a style's alpha.
|
|
2772
3287
|
if (effects.hidden) {
|
|
@@ -2804,6 +3319,13 @@ export function applyStyle(el: HTMLElement, style: Style): void {
|
|
|
2804
3319
|
if (opacity != null) {
|
|
2805
3320
|
s.opacity = String(opacity);
|
|
2806
3321
|
}
|
|
3322
|
+
// `.blendMode`: blends the box with what is behind it.
|
|
3323
|
+
if (style.blendMode) s.mixBlendMode = style.blendMode;
|
|
3324
|
+
else if (s.mixBlendMode) s.mixBlendMode = '';
|
|
3325
|
+
// `.zIndex`: in front of siblings with a lower index (boxes are positioned).
|
|
3326
|
+
// CSS takes integers; ×1000 keeps fractional indices (0.5) apart.
|
|
3327
|
+
if (style.zIndex != null && Number.isFinite(style.zIndex)) s.zIndex = String(Math.round(style.zIndex * 1000));
|
|
3328
|
+
else if (s.zIndex) s.zIndex = '';
|
|
2807
3329
|
|
|
2808
3330
|
// Phase 3 text and layout hints (the engine reads them from the node tree).
|
|
2809
3331
|
if (style.layoutPriority != null && Number.isFinite(style.layoutPriority)) {
|
|
@@ -2825,6 +3347,8 @@ export function applyStyle(el: HTMLElement, style: Style): void {
|
|
|
2825
3347
|
el.dataset['sbAspectRatio'] = `${ratio ?? 'auto'} ${contentMode ?? 'fit'}`;
|
|
2826
3348
|
}
|
|
2827
3349
|
if (style.animationToken != null) el.dataset[ANIMATION_TOKEN] = String(style.animationToken);
|
|
3350
|
+
// A `Label`'s icon: tinted in list rows (styles.css).
|
|
3351
|
+
if (style.labelIcon) el.dataset['sbLabelIcon'] = '';
|
|
2828
3352
|
if (style.animation) ANIMATIONS.set(el, style.animation);
|
|
2829
3353
|
if (style.transition) setTransition(el, style.transition);
|
|
2830
3354
|
|
|
@@ -2858,6 +3382,7 @@ export { colorToCSS, styleToCSS } from './styles';
|
|
|
2858
3382
|
export function assetColorsCSS(catalog: AssetCatalog): string {
|
|
2859
3383
|
const names = Object.keys(catalog.colors).sort();
|
|
2860
3384
|
if (names.length === 0) return '';
|
|
3385
|
+
const accent = catalog.colors[ACCENT_COLOR_NAME];
|
|
2861
3386
|
const declarations = (scheme: 'light' | 'dark'): string =>
|
|
2862
3387
|
names
|
|
2863
3388
|
.map((name) => {
|
|
@@ -2866,13 +3391,19 @@ export function assetColorsCSS(catalog: AssetCatalog): string {
|
|
|
2866
3391
|
const value = scheme === 'dark' ? (color.dark ?? color.light) : color.light;
|
|
2867
3392
|
return ` ${assetColorVar(name)}: ${sanitizeColor(value)};\n`;
|
|
2868
3393
|
})
|
|
2869
|
-
.join('')
|
|
3394
|
+
.join('') +
|
|
3395
|
+
// The app's accent color, as Xcode's global accent setting (`AccentColor`
|
|
3396
|
+
// in its templates) makes it on iOS: buttons, links, toggles, selection.
|
|
3397
|
+
(accent ? ` --sb-color-accent: var(${assetColorVar(ACCENT_COLOR_NAME)});\n` : '');
|
|
2870
3398
|
return (
|
|
2871
3399
|
`.device[data-theme="light"] {\n${declarations('light')}}\n` +
|
|
2872
3400
|
`.device[data-theme="dark"] {\n${declarations('dark')}}\n`
|
|
2873
3401
|
);
|
|
2874
3402
|
}
|
|
2875
3403
|
|
|
3404
|
+
/** The catalog color that is the app's accent (Xcode's `ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME` default). */
|
|
3405
|
+
const ACCENT_COLOR_NAME = 'AccentColor';
|
|
3406
|
+
|
|
2876
3407
|
/** A manifest color value is injected into a stylesheet: keep only CSS color syntax characters. */
|
|
2877
3408
|
function sanitizeColor(value: string): string {
|
|
2878
3409
|
return /^[A-Za-z0-9#(),.%\s/-]+$/.test(value) ? value : 'magenta';
|
|
@@ -2880,3 +3411,20 @@ function sanitizeColor(value: string): string {
|
|
|
2880
3411
|
|
|
2881
3412
|
// Re-exported so callers can build typed fixtures without importing protocol.ts.
|
|
2882
3413
|
export type { Op, ElementKind, ElementPropsByKind, RendererEvent };
|
|
3414
|
+
|
|
3415
|
+
/**
|
|
3416
|
+
* Whether a `lazyspacer` is within one screen of the visible area of the
|
|
3417
|
+
* scroll view (or list) it is in, along its axis; the page's viewport when it
|
|
3418
|
+
* is in none. Spacers in hidden tabs, menus or candidates are not.
|
|
3419
|
+
*/
|
|
3420
|
+
function lazySpacerIsNear(el: HTMLElement): boolean {
|
|
3421
|
+
if (el.closest('[data-sb-tab-hidden], [hidden]')) return false;
|
|
3422
|
+
const rect = el.getBoundingClientRect();
|
|
3423
|
+
const scroller = el.closest<HTMLElement>('.sb-scrollview, .sb-list');
|
|
3424
|
+
const view = scroller ? scroller.getBoundingClientRect() : new DOMRect(0, 0, window.innerWidth, window.innerHeight);
|
|
3425
|
+
if (view.width <= 0 || view.height <= 0) return false;
|
|
3426
|
+
if (el.dataset['axis'] === 'horizontal') {
|
|
3427
|
+
return rect.left < view.right + view.width && rect.right > view.left - view.width;
|
|
3428
|
+
}
|
|
3429
|
+
return rect.top < view.bottom + view.height && rect.bottom > view.top - view.height;
|
|
3430
|
+
}
|