@swiftbrowser/web 0.19.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/index.html +11 -7
- package/package.json +1 -1
- package/src/appFiles.ts +12 -3
- package/src/audioEngine.ts +29 -5
- package/src/deviceFrame.ts +188 -0
- package/src/dragDrop.ts +66 -10
- package/src/editorSelection.ts +38 -3
- package/src/files.ts +68 -13
- package/src/hostRequests.ts +43 -3
- package/src/interaction.ts +6 -1
- package/src/layout/engine.ts +2 -0
- package/src/main.ts +12 -1
- package/src/page.css +65 -0
- package/src/pinnedViews.ts +38 -1
- package/src/plainEditor.ts +24 -3
- package/src/protocol.ts +41 -1
- package/src/renderer.ts +146 -14
- package/src/scrollTransition.ts +2 -1
- package/src/styles.css +167 -7
- package/src/styles.ts +9 -6
- package/src/viewRegistry.ts +58 -0
package/src/renderer.ts
CHANGED
|
@@ -62,7 +62,7 @@ import { LayoutReporter } from './layoutEvents';
|
|
|
62
62
|
import { createGaugeRing, isCircularGauge, updateGaugeRing } from './gaugeRing';
|
|
63
63
|
import { bitmapStore, showBitmap } from './bitmaps';
|
|
64
64
|
import { clearRichText, runTemplates } from './richText';
|
|
65
|
-
import { markDeleteBackward, wantsDeleteBackward } from './editorSelection';
|
|
65
|
+
import { markDeleteBackward, noteReportedText, textArrived, wantsDeleteBackward } from './editorSelection';
|
|
66
66
|
import { applyPlainEditorSelection, focusPlainEditor, isPlainEditor, listenToPlainEditors } from './plainEditor';
|
|
67
67
|
import { applyRichEditor, createRichEditor, focusRichEditor, isRichEditor, listenToRichEditors, richEditorText } from './richEditor';
|
|
68
68
|
import { applyLayout, setTabHidden, type BoxChange } from './layoutDom';
|
|
@@ -126,6 +126,7 @@ import {
|
|
|
126
126
|
type TabProps,
|
|
127
127
|
type TabViewProps,
|
|
128
128
|
type HostProps,
|
|
129
|
+
type RegisteredProps,
|
|
129
130
|
type TextFieldProps,
|
|
130
131
|
type TextProps,
|
|
131
132
|
type TextRun,
|
|
@@ -171,8 +172,9 @@ import { TouchPaging } from './touchPaging';
|
|
|
171
172
|
import { isScrollVisible } from './scrollVisibility';
|
|
172
173
|
import { applyDefaultScrollAnchor, keepScrollAnchors } from './scrollAnchor';
|
|
173
174
|
import { EMPTY_CATALOG, assetColorVar, type AssetCatalog } from './assets';
|
|
174
|
-
import { HIERARCHY_OPACITY, colorToCSS, effectsCSS, styleToCSS, type PaintCSS } from './styles';
|
|
175
|
+
import { HIERARCHY_OPACITY, colorToCSS, effectsCSS, shadowCSS, styleToCSS, type PaintCSS } from './styles';
|
|
175
176
|
import { hostContent, onHostContentChange } from './hostContent';
|
|
177
|
+
import { type RegisteredViewInstance, ViewRegistry } from './viewRegistry';
|
|
176
178
|
import { HoverTracker, applyPointerAndEffects, safeAreaOverlap } from './pointerEffects';
|
|
177
179
|
import { colorMultiplyFilter } from './colorMultiply';
|
|
178
180
|
import { largeTitleOf, largeTitleTextOf } from './largeTitle';
|
|
@@ -193,6 +195,14 @@ export class RendererError extends Error {
|
|
|
193
195
|
}
|
|
194
196
|
|
|
195
197
|
const KIND_ATTR = 'sbKind';
|
|
198
|
+
/**
|
|
199
|
+
* The kinds that draw what they show themselves: a shadow of a box around one falls on it as a whole (castShadows,
|
|
200
|
+
* styles.css), where a container hands it on to the views inside.
|
|
201
|
+
*/
|
|
202
|
+
const DRAWN_KINDS: ReadonlySet<string> = new Set([
|
|
203
|
+
'text', 'image', 'divider', 'color', 'shape', 'textfield', 'toggle', 'picker', 'gauge', 'searchfield', 'registered', 'chart',
|
|
204
|
+
'stepper', 'slider', 'datepicker', 'texteditor', 'video', 'canvas',
|
|
205
|
+
]);
|
|
196
206
|
const ID_ATTR = 'sbId';
|
|
197
207
|
/** `animationToken` of a `styled` box, kept across `update` to detect changes. */
|
|
198
208
|
const ANIMATION_TOKEN = 'sbAnimationToken';
|
|
@@ -252,6 +262,7 @@ export { SHEET_DISMISS_DRAG } from './interaction';
|
|
|
252
262
|
|
|
253
263
|
const ALL_KINDS: ReadonlySet<string> = new Set<ElementKind>([
|
|
254
264
|
'host',
|
|
265
|
+
'registered',
|
|
255
266
|
'text',
|
|
256
267
|
'vstack',
|
|
257
268
|
'hstack',
|
|
@@ -422,6 +433,8 @@ export class Renderer {
|
|
|
422
433
|
private readonly hostContents = new Map<ElementId, HTMLElement>();
|
|
423
434
|
/** The `host` elements, adopted or still waiting for their content. */
|
|
424
435
|
private readonly hosts = new Set<ElementId>();
|
|
436
|
+
/** The registered views (src/viewRegistry.ts) built for `registered` elements, by element: the name each was built for. */
|
|
437
|
+
private readonly registeredViews = new Map<ElementId, { name: string; instance: RegisteredViewInstance | undefined }>();
|
|
425
438
|
private readonly hostSizes = new WeakMap<HTMLElement, { width: number; height: number }>();
|
|
426
439
|
private readonly hostMeasures = new Map<string, { width: number; height: number }>();
|
|
427
440
|
private readonly stopHostChanges: () => void;
|
|
@@ -440,6 +453,12 @@ export class Renderer {
|
|
|
440
453
|
private readonly pressEffects: PressEffects;
|
|
441
454
|
/** Text, links and files dragged in from outside the page (src/dragDrop.ts). */
|
|
442
455
|
private readonly externalDrops: ExternalDrops;
|
|
456
|
+
/**
|
|
457
|
+
* How a dropped file reaches the app: base64 `data` in the event (a Swift
|
|
458
|
+
* app) or the `File` by reference (a React app in the same page sets
|
|
459
|
+
* `reference`; a dropped folder then comes as its files by path).
|
|
460
|
+
*/
|
|
461
|
+
fileDrops: 'bytes' | 'reference' = 'bytes';
|
|
443
462
|
private readonly pinches: PinchController;
|
|
444
463
|
private readonly touches: TouchesController;
|
|
445
464
|
private readonly touchPaging: TouchPaging;
|
|
@@ -880,6 +899,7 @@ export class Renderer {
|
|
|
880
899
|
const id = Number(owner?.dataset[ID_ATTR]);
|
|
881
900
|
if (!Number.isInteger(id)) return;
|
|
882
901
|
const value = isRichEditor(input) ? richEditorText(input) : input.value;
|
|
902
|
+
noteReportedText(input, value);
|
|
883
903
|
this.onEvent({ type: 'text', id, value });
|
|
884
904
|
};
|
|
885
905
|
|
|
@@ -1025,6 +1045,7 @@ export class Renderer {
|
|
|
1025
1045
|
root,
|
|
1026
1046
|
send: (event) => this.onEvent(event),
|
|
1027
1047
|
nodeOf: (id) => this.nodes.get(id),
|
|
1048
|
+
fileDrops: () => this.fileDrops,
|
|
1028
1049
|
openContextMenu: (anchor, id) => this.openContextMenu(anchor, id),
|
|
1029
1050
|
revealScreen: (screen) => this.revealScreen(screen),
|
|
1030
1051
|
isMenuOpen: (id) => this.menu?.anchorId === id,
|
|
@@ -1258,6 +1279,7 @@ export class Renderer {
|
|
|
1258
1279
|
this.syncFocus();
|
|
1259
1280
|
// The focus the app moved shows or hides a screen's keyboard bar.
|
|
1260
1281
|
if (this.focusedFieldId() !== focusedBefore && this.hasKeyboardToolbars()) this.layout();
|
|
1282
|
+
this.castShadows();
|
|
1261
1283
|
this.performScrolls(op.animation != null);
|
|
1262
1284
|
this.performOffsetScrolls(op.animation != null);
|
|
1263
1285
|
this.checkLazySpacers();
|
|
@@ -1275,6 +1297,55 @@ export class Renderer {
|
|
|
1275
1297
|
}
|
|
1276
1298
|
}
|
|
1277
1299
|
|
|
1300
|
+
/**
|
|
1301
|
+
* SwiftUI's `.shadow` on a container falls on each view it draws (its texts, shapes, images, controls and
|
|
1302
|
+
* backgrounds), on their own shadows too, and not on the container as a whole unless a `.compositingGroup()` in
|
|
1303
|
+
* between draws the views as one. Each box with a shadow (`data-sb-shadow`) hands its views the shadows to cast:
|
|
1304
|
+
* its own, then those of the boxes around it, up to a compositing group (`--sb-shadow-filter`, which styles.css
|
|
1305
|
+
* gives each drawn view's `filter`, and which a box that clips, masks or composites takes for itself). A box's own
|
|
1306
|
+
* background, painted with its children inside, casts them as `box-shadow` instead.
|
|
1307
|
+
*/
|
|
1308
|
+
private castShadows(): void {
|
|
1309
|
+
const sources = this.root.querySelectorAll<HTMLElement>('[data-sb-shadow]');
|
|
1310
|
+
const cast = new Set(this.root.querySelectorAll<HTMLElement>('[data-sb-shadow-cast]'));
|
|
1311
|
+
if (sources.length === 0 && cast.size === 0) return;
|
|
1312
|
+
const filters = new Map<HTMLElement, { filter: string; box: string }>();
|
|
1313
|
+
// The shadows that reach the views inside `el` from the boxes around it, up to a compositing group or a drawn
|
|
1314
|
+
// view, which takes those around it as one and hands none on.
|
|
1315
|
+
const above = (el: HTMLElement): { filter: string; box: string } => {
|
|
1316
|
+
for (let up = el.parentElement; up && up !== this.root; up = up.parentElement) {
|
|
1317
|
+
if (up.dataset['sbComposite'] !== undefined || DRAWN_KINDS.has(up.dataset[KIND_ATTR] ?? '')) break;
|
|
1318
|
+
const found = filters.get(up);
|
|
1319
|
+
if (found) return found;
|
|
1320
|
+
}
|
|
1321
|
+
return { filter: '', box: '' };
|
|
1322
|
+
};
|
|
1323
|
+
// Document order: a box comes before the boxes inside it.
|
|
1324
|
+
for (const el of sources) {
|
|
1325
|
+
const outer = above(el);
|
|
1326
|
+
const filter = `${el.dataset['sbShadow']} ${outer.filter}`.trim();
|
|
1327
|
+
const box = [el.dataset['sbShadowBox'], outer.box].filter(Boolean).join(', ');
|
|
1328
|
+
filters.set(el, { filter, box });
|
|
1329
|
+
if (el.style.getPropertyValue('--sb-shadow-filter') !== filter) el.style.setProperty('--sb-shadow-filter', filter);
|
|
1330
|
+
}
|
|
1331
|
+
// A background painted on a box (its children inside) casts them as a box-shadow.
|
|
1332
|
+
if (sources.length > 0) {
|
|
1333
|
+
// A box that clips, masks or composites casts them for its background already, with what it holds.
|
|
1334
|
+
for (const el of this.root.querySelectorAll<HTMLElement>('.sb-styled[data-sb-background]:not([data-sb-as-one])')) {
|
|
1335
|
+
const { box } = filters.get(el) ?? above(el);
|
|
1336
|
+
if (!box) continue;
|
|
1337
|
+
if (el.style.boxShadow !== box) el.style.boxShadow = box;
|
|
1338
|
+
el.dataset['sbShadowCast'] = '';
|
|
1339
|
+
cast.delete(el);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
// Those no shadow reaches any more.
|
|
1343
|
+
for (const el of cast) {
|
|
1344
|
+
el.style.boxShadow = '';
|
|
1345
|
+
delete el.dataset['sbShadowCast'];
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1278
1349
|
/**
|
|
1279
1350
|
* `withAnimation(_:completionCriteria:_:completion:)`: the pass's `commit`
|
|
1280
1351
|
* carried a completion serial; once the animations its commit started have
|
|
@@ -2057,6 +2128,34 @@ export class Renderer {
|
|
|
2057
2128
|
this.hosts.add(id);
|
|
2058
2129
|
this.adoptHostContent(id, el, props as HostProps);
|
|
2059
2130
|
}
|
|
2131
|
+
if (kind === 'registered') this.adoptRegistered(id, el, props as RegisteredProps);
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
/**
|
|
2135
|
+
* A `registered` element's view (src/viewRegistry.ts): built for its name,
|
|
2136
|
+
* given the new props after; a name nothing registered shows itself, as an
|
|
2137
|
+
* unknown kind does. The element's own children are the native view's: on
|
|
2138
|
+
* a page the stylesheet hides them.
|
|
2139
|
+
*/
|
|
2140
|
+
private adoptRegistered(id: ElementId, el: HTMLElement, props: RegisteredProps): void {
|
|
2141
|
+
const values = asRecord(props.props);
|
|
2142
|
+
const current = this.registeredViews.get(id);
|
|
2143
|
+
if (current && current.name === props.name) {
|
|
2144
|
+
current.instance?.update?.(values);
|
|
2145
|
+
return;
|
|
2146
|
+
}
|
|
2147
|
+
current?.instance?.dispose?.();
|
|
2148
|
+
const name = props.name;
|
|
2149
|
+
const instance = ViewRegistry.make(name, { id, props: values, send: (value) => this.onEvent({ type: 'registered', id, name, value }) });
|
|
2150
|
+
this.registeredViews.set(id, { name, instance });
|
|
2151
|
+
let content = instance?.element;
|
|
2152
|
+
if (!content) {
|
|
2153
|
+
content = div('sb-registered-missing');
|
|
2154
|
+
content.textContent = name;
|
|
2155
|
+
}
|
|
2156
|
+
content.classList.add('sb-registered-content');
|
|
2157
|
+
el.querySelector(':scope > .sb-registered-content')?.remove();
|
|
2158
|
+
el.prepend(content);
|
|
2060
2159
|
}
|
|
2061
2160
|
|
|
2062
2161
|
/** A toolbar whose placement changed to or from `largeTitle` moves between its screen's content slot and large title. */
|
|
@@ -2469,6 +2568,7 @@ export class Renderer {
|
|
|
2469
2568
|
this.noteScroll(id, kind, props);
|
|
2470
2569
|
this.noteVisibility(id, kind, props);
|
|
2471
2570
|
if (kind === 'host') this.adoptHostContent(id, el, props as HostProps);
|
|
2571
|
+
if (kind === 'registered') this.adoptRegistered(id, el, props as RegisteredProps);
|
|
2472
2572
|
if (kind === 'toolbar') this.rehomeToolbar(id, el, op);
|
|
2473
2573
|
const newToken = el.dataset[ANIMATION_TOKEN];
|
|
2474
2574
|
const tokenChanged = (previous?.tokenChanged ?? false) || (newToken !== undefined && newToken !== oldToken);
|
|
@@ -2704,6 +2804,8 @@ export class Renderer {
|
|
|
2704
2804
|
this.hostContents.delete(id);
|
|
2705
2805
|
}
|
|
2706
2806
|
this.hosts.delete(id);
|
|
2807
|
+
this.registeredViews.get(id)?.instance?.dispose?.();
|
|
2808
|
+
this.registeredViews.delete(id);
|
|
2707
2809
|
this.elements.delete(id);
|
|
2708
2810
|
this.boxes.delete(id);
|
|
2709
2811
|
this.dirty.delete(id);
|
|
@@ -2979,6 +3081,9 @@ function createShell(kind: ElementKind): HTMLElement {
|
|
|
2979
3081
|
case 'host':
|
|
2980
3082
|
// The page's content element is adopted by the renderer (`adoptHostContent`).
|
|
2981
3083
|
return div('sb-host');
|
|
3084
|
+
case 'registered':
|
|
3085
|
+
// Its view is built by the renderer (`adoptRegistered`).
|
|
3086
|
+
return div('sb-registered');
|
|
2982
3087
|
case 'button': {
|
|
2983
3088
|
const el = document.createElement('button');
|
|
2984
3089
|
el.type = 'button';
|
|
@@ -3821,16 +3926,25 @@ function setDatePickerValue(el: HTMLElement, seconds: number): void {
|
|
|
3821
3926
|
}
|
|
3822
3927
|
}
|
|
3823
3928
|
|
|
3824
|
-
/**
|
|
3929
|
+
/**
|
|
3930
|
+
* Like `applyTextField`: the caret survives an echo of the text the editor
|
|
3931
|
+
* shows. A text from outside (not what the editor reported) would put the
|
|
3932
|
+
* caret at the end: the editor's value and caret from before are kept for
|
|
3933
|
+
* `applyPlainEditorSelection` to move it past the change.
|
|
3934
|
+
*/
|
|
3825
3935
|
function applyTextEditor(area: HTMLTextAreaElement, props: TextEditorProps): void {
|
|
3826
3936
|
const text = props.text ?? '';
|
|
3827
|
-
|
|
3937
|
+
const before = area.value;
|
|
3938
|
+
const outside = before !== text && textArrived(area, text) === 'remote';
|
|
3939
|
+
const focused = area.ownerDocument.activeElement === area;
|
|
3940
|
+
const caret = outside && focused ? { start: area.selectionStart, end: area.selectionEnd } : null;
|
|
3941
|
+
if (before !== text) area.value = text;
|
|
3828
3942
|
// Bound focus, as for a `textfield` (moved in `syncFocus` after the commit).
|
|
3829
3943
|
if (typeof props.focused === 'boolean') area.dataset['sbFocus'] = String(props.focused);
|
|
3830
3944
|
else delete area.dataset['sbFocus'];
|
|
3831
3945
|
markDeleteBackward(area, props.deleteBackward === true);
|
|
3832
3946
|
// A bound selection, under the rich editor's rules (src/plainEditor.ts).
|
|
3833
|
-
applyPlainEditorSelection(area, props);
|
|
3947
|
+
applyPlainEditorSelection(area, props, outside ? { before, caret } : null);
|
|
3834
3948
|
}
|
|
3835
3949
|
|
|
3836
3950
|
// ---------------------------------------------------------------------------
|
|
@@ -4136,6 +4250,9 @@ function applyKindProps(el: HTMLElement, kind: ElementKind, props: ElementProps,
|
|
|
4136
4250
|
case 'lazyspacer':
|
|
4137
4251
|
el.dataset['axis'] = (props as LazySpacerProps).axis === 'horizontal' ? 'horizontal' : 'vertical';
|
|
4138
4252
|
break;
|
|
4253
|
+
case 'registered':
|
|
4254
|
+
// The renderer builds its view (`adoptRegistered`).
|
|
4255
|
+
break;
|
|
4139
4256
|
default: {
|
|
4140
4257
|
const unknown: never = kind;
|
|
4141
4258
|
throw new RendererError(`Unknown element kind ${String(unknown)}`);
|
|
@@ -4282,9 +4399,10 @@ function applyShape(el: HTMLElement, props: ShapeProps): void {
|
|
|
4282
4399
|
s.border = `${width}px ${stroke.dash?.length ? 'dashed' : 'solid'} ${color}`;
|
|
4283
4400
|
el.dataset['sbStroke'] = stroke.inset ? 'inset' : 'center';
|
|
4284
4401
|
}
|
|
4285
|
-
// A fill with a drop shadow (`.shadow(.drop(…))` on the style)
|
|
4402
|
+
// A fill with a drop shadow (`.shadow(.drop(…))` on the style), then the shadows of the boxes around the shape
|
|
4403
|
+
// (castShadows), which styles.css gives a shape without one of its own.
|
|
4286
4404
|
const shadow = (props.fill as { shadow?: ShapeShadow } | null)?.shadow;
|
|
4287
|
-
s.filter = shadow && typeof shadow === 'object' ? shapeShadowCSS(shadow) : '';
|
|
4405
|
+
s.filter = shadow && typeof shadow === 'object' ? `${shapeShadowCSS(shadow)} var(--sb-shadow-filter, )` : '';
|
|
4288
4406
|
const mask = shapeTrimMask(props.trim);
|
|
4289
4407
|
if (mask) {
|
|
4290
4408
|
s.setProperty('mask-image', mask);
|
|
@@ -4893,15 +5011,29 @@ export function applyStyle(el: HTMLElement, style: Style): void {
|
|
|
4893
5011
|
}
|
|
4894
5012
|
|
|
4895
5013
|
// Phase 6 visual effects: CSS only, the layout engine never sees them.
|
|
4896
|
-
|
|
5014
|
+
// A shadow is not the box's own filter: as SwiftUI's `.shadow` on a container, it falls on each view the box draws
|
|
5015
|
+
// (castShadows). A box that clips, masks or composites what it holds draws it as one, so the shadows around it are
|
|
5016
|
+
// cast by it too (`--sb-shadow-filter`, which castShadows sets).
|
|
5017
|
+
const effects = effectsCSS(style, { shadow: false });
|
|
4897
5018
|
if (effects.transform) s.transform = effects.transform;
|
|
4898
5019
|
if (effects.transformOrigin) s.transformOrigin = effects.transformOrigin;
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
|
|
4902
|
-
|
|
4903
|
-
|
|
4904
|
-
|
|
5020
|
+
const multiply = style.colorMultiply ? colorMultiplyFilter(style.colorMultiply, el) : '';
|
|
5021
|
+
const asOne = style.compositingGroup === true || style.cornerRadius != null || style.clipped === true || (style.mask != null && typeof style.mask === 'object');
|
|
5022
|
+
const filter = [multiply, effects.filter ?? '', asOne ? 'var(--sb-shadow-filter, )' : ''].filter(Boolean).join(' ');
|
|
5023
|
+
if (filter) s.filter = filter;
|
|
5024
|
+
if (asOne) el.dataset['sbAsOne'] = '';
|
|
5025
|
+
else delete el.dataset['sbAsOne'];
|
|
5026
|
+
if (style.shadow) {
|
|
5027
|
+
el.dataset['sbShadow'] = `drop-shadow(${shadowCSS(style.shadow)})`;
|
|
5028
|
+
el.dataset['sbShadowBox'] = shadowCSS(style.shadow);
|
|
5029
|
+
} else if (el.dataset['sbShadow'] !== undefined) {
|
|
5030
|
+
delete el.dataset['sbShadow'];
|
|
5031
|
+
delete el.dataset['sbShadowBox'];
|
|
5032
|
+
s.removeProperty('--sb-shadow-filter');
|
|
5033
|
+
s.removeProperty('--sb-shadow-box');
|
|
5034
|
+
}
|
|
5035
|
+
if (style.compositingGroup) el.dataset['sbComposite'] = '';
|
|
5036
|
+
else delete el.dataset['sbComposite'];
|
|
4905
5037
|
// Phase 7: `.hidden()` keeps the box (the engine lays it out) but draws
|
|
4906
5038
|
// nothing and takes no input; `.mask { }` clips the box to a style's alpha.
|
|
4907
5039
|
if (effects.hidden) {
|
package/src/scrollTransition.ts
CHANGED
|
@@ -35,7 +35,8 @@ export function setScrollTransition(el: HTMLElement, style: Style): void {
|
|
|
35
35
|
}
|
|
36
36
|
return;
|
|
37
37
|
}
|
|
38
|
-
|
|
38
|
+
// Its shadow is cast by the views it draws (renderer.ts castShadows), not by it.
|
|
39
|
+
const own = effectsCSS(style, { shadow: false });
|
|
39
40
|
registered.set(el, {
|
|
40
41
|
spec,
|
|
41
42
|
base: { transform: own.transform ?? '', filter: own.filter ?? '', opacity: style.opacity ?? 1 },
|
package/src/styles.css
CHANGED
|
@@ -326,12 +326,6 @@
|
|
|
326
326
|
fill: currentColor;
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
-
.glyph-wifi .wifi-band {
|
|
330
|
-
fill: none;
|
|
331
|
-
stroke: currentColor;
|
|
332
|
-
stroke-linecap: round;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
329
|
.glyph-battery .battery-shell {
|
|
336
330
|
fill: none;
|
|
337
331
|
stroke: currentColor;
|
|
@@ -386,6 +380,86 @@
|
|
|
386
380
|
}
|
|
387
381
|
}
|
|
388
382
|
|
|
383
|
+
/* ---------------------------------------------------------------------------
|
|
384
|
+
The preview's device (html[data-sb-frame], src/deviceFrame.ts), frame mode
|
|
385
|
+
only: the iPhone above is the default; an iPad in landscape (an 11-inch
|
|
386
|
+
iPad's 1180 x 820 points, its status bar the time and date at the leading
|
|
387
|
+
edge, no island); or no device, the app filling the page under the toolbar
|
|
388
|
+
(page.css sizes the stage).
|
|
389
|
+
------------------------------------------------------------------------- */
|
|
390
|
+
|
|
391
|
+
html[data-sb-frame="ipad"]:not([data-sb-mode="device"]) {
|
|
392
|
+
--device-screen-width: 1180px;
|
|
393
|
+
--device-screen-height: 820px;
|
|
394
|
+
--device-bezel: 20px;
|
|
395
|
+
--device-radius: 38px;
|
|
396
|
+
--device-safe-top: 24px;
|
|
397
|
+
--device-safe-bottom: 20px;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
html[data-sb-frame="ipad"]:not([data-sb-mode="device"]) .dynamic-island,
|
|
401
|
+
html[data-sb-frame="ipad"]:not([data-sb-mode="device"]) .device::before,
|
|
402
|
+
html[data-sb-frame="ipad"]:not([data-sb-mode="device"]) .device::after {
|
|
403
|
+
display: none;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
html[data-sb-frame="ipad"]:not([data-sb-mode="device"]) .status-bar {
|
|
407
|
+
padding: 0 18px 0 22px;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
html[data-sb-frame="ipad"]:not([data-sb-mode="device"]) .status-time {
|
|
411
|
+
min-width: 0;
|
|
412
|
+
top: 0;
|
|
413
|
+
font-size: 13px;
|
|
414
|
+
letter-spacing: 0;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
.status-date {
|
|
418
|
+
display: none;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
html[data-sb-frame="ipad"]:not([data-sb-mode="device"]) .status-date {
|
|
422
|
+
display: inline;
|
|
423
|
+
margin-left: 6px;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
html[data-sb-frame="ipad"]:not([data-sb-mode="device"]) .status-glyphs {
|
|
427
|
+
gap: 6px;
|
|
428
|
+
transform: scale(0.82);
|
|
429
|
+
transform-origin: right center;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
html[data-sb-frame="ipad"]:not([data-sb-mode="device"]) .home-indicator {
|
|
433
|
+
bottom: 7px;
|
|
434
|
+
width: 315px;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
html[data-sb-frame="none"]:not([data-sb-mode="device"]) .device:not(.sb-island) {
|
|
438
|
+
flex: 1 1 auto;
|
|
439
|
+
align-self: stretch;
|
|
440
|
+
width: auto;
|
|
441
|
+
height: auto;
|
|
442
|
+
min-height: 0;
|
|
443
|
+
padding: 0;
|
|
444
|
+
border-radius: 0;
|
|
445
|
+
background: var(--sb-color-system-background);
|
|
446
|
+
box-shadow: none;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
html[data-sb-frame="none"]:not([data-sb-mode="device"]) .device-screen {
|
|
450
|
+
width: 100%;
|
|
451
|
+
height: 100%;
|
|
452
|
+
border-radius: 0;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
html[data-sb-frame="none"]:not([data-sb-mode="device"]) .device::before,
|
|
456
|
+
html[data-sb-frame="none"]:not([data-sb-mode="device"]) .device::after,
|
|
457
|
+
html[data-sb-frame="none"]:not([data-sb-mode="device"]) .dynamic-island,
|
|
458
|
+
html[data-sb-frame="none"]:not([data-sb-mode="device"]) .status-bar,
|
|
459
|
+
html[data-sb-frame="none"]:not([data-sb-mode="device"]) .home-indicator {
|
|
460
|
+
display: none;
|
|
461
|
+
}
|
|
462
|
+
|
|
389
463
|
/* ---------------------------------------------------------------------------
|
|
390
464
|
Device mode (html[data-sb-mode="device"], see src/deviceMode.ts)
|
|
391
465
|
|
|
@@ -761,6 +835,24 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
761
835
|
display: none;
|
|
762
836
|
}
|
|
763
837
|
|
|
838
|
+
/* Pinned section headers (src/pinnedViews.ts): each scroll view names its
|
|
839
|
+
scroll timelines, and a header moves along its nearest one, from where it
|
|
840
|
+
starts to pin (its `animation-range`) by as far as its section lets it
|
|
841
|
+
(`--sb-pinned-to`), in step with the scroll. */
|
|
842
|
+
.sb-scrollview,
|
|
843
|
+
.sb-list {
|
|
844
|
+
scroll-timeline: --sb-pin-y block, --sb-pin-x inline;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
@keyframes sb-pinned {
|
|
848
|
+
from {
|
|
849
|
+
translate: 0 0;
|
|
850
|
+
}
|
|
851
|
+
to {
|
|
852
|
+
translate: var(--sb-pinned-to);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
764
856
|
.sb-scrollview[data-sb-axes="vertical"] {
|
|
765
857
|
overflow-y: auto;
|
|
766
858
|
}
|
|
@@ -1050,6 +1142,24 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
1050
1142
|
overflow: visible;
|
|
1051
1143
|
}
|
|
1052
1144
|
|
|
1145
|
+
/* A section outside a list (a lazy stack's, its header pinned) is a plain
|
|
1146
|
+
container, as in SwiftUI: its rows draw in no card, so nothing clips them,
|
|
1147
|
+
paints under them or draws lines between them (src/layoutDom.ts leaves the
|
|
1148
|
+
slot unsized there). */
|
|
1149
|
+
:not(.sb-list-content) > .sb-section > .sb-section-rows {
|
|
1150
|
+
overflow: visible;
|
|
1151
|
+
background: none;
|
|
1152
|
+
border-radius: 0;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
:not(.sb-list-content) > .sb-section > .sb-section-rows > :is([data-sb-id], [data-sb-ghost]) {
|
|
1156
|
+
background: none;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
:not(.sb-list-content) > .sb-section > .sb-section-rows > *::after {
|
|
1160
|
+
content: none;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1053
1163
|
.sb-list-content > :not(.sb-section):first-child,
|
|
1054
1164
|
.sb-list-content > .sb-section + :not(.sb-section) {
|
|
1055
1165
|
border-top-left-radius: 26px;
|
|
@@ -3513,9 +3623,16 @@ svg[data-sb-symbol="progress.indicator"] > line:nth-child(8) { animation-delay:
|
|
|
3513
3623
|
opacity: 0.4;
|
|
3514
3624
|
}
|
|
3515
3625
|
|
|
3626
|
+
/* The copy lets the pointer through to the destination under it, its own
|
|
3627
|
+
boxes too: a background or a content shape makes a box a target
|
|
3628
|
+
(pointer-events: auto) wherever it is, and the copy keeps those. */
|
|
3629
|
+
.sb-drag-preview,
|
|
3630
|
+
.sb-drag-preview * {
|
|
3631
|
+
pointer-events: none !important;
|
|
3632
|
+
}
|
|
3633
|
+
|
|
3516
3634
|
.sb-drag-preview {
|
|
3517
3635
|
z-index: 60;
|
|
3518
|
-
pointer-events: none;
|
|
3519
3636
|
opacity: 0.9;
|
|
3520
3637
|
filter: drop-shadow(0 8px 20px rgba(0, 0, 0, 0.25));
|
|
3521
3638
|
will-change: transform;
|
|
@@ -4385,3 +4502,46 @@ svg.sb-path {
|
|
|
4385
4502
|
.sb-list[data-sb-list-style='sidebar'] .sb-section[data-sb-collapsible][data-sb-expanded] .sb-sectionheader::after {
|
|
4386
4503
|
transform: rotate(45deg);
|
|
4387
4504
|
}
|
|
4505
|
+
|
|
4506
|
+
/* A registered view (src/viewRegistry.ts): the package's own element fills
|
|
4507
|
+
the box; the element's children are a native view's to place. */
|
|
4508
|
+
.sb-registered {
|
|
4509
|
+
overflow: hidden;
|
|
4510
|
+
}
|
|
4511
|
+
.sb-registered > .sb-registered-content {
|
|
4512
|
+
display: block;
|
|
4513
|
+
width: 100%;
|
|
4514
|
+
height: 100%;
|
|
4515
|
+
}
|
|
4516
|
+
.sb-registered > :not(.sb-registered-content) {
|
|
4517
|
+
display: none;
|
|
4518
|
+
}
|
|
4519
|
+
.sb-registered-missing {
|
|
4520
|
+
box-sizing: border-box;
|
|
4521
|
+
border: 1px dashed currentColor;
|
|
4522
|
+
font: 11px/1.2 ui-monospace, monospace;
|
|
4523
|
+
opacity: 0.6;
|
|
4524
|
+
padding: 2px;
|
|
4525
|
+
}
|
|
4526
|
+
|
|
4527
|
+
/* SwiftUI's `.shadow` on a container falls on each view it draws, not on the
|
|
4528
|
+
container as a whole (renderer.ts castShadows): a view that draws itself
|
|
4529
|
+
casts the shadows of the boxes around it, set on them as
|
|
4530
|
+
`--sb-shadow-filter`, and hands none to what is inside it; nor does a
|
|
4531
|
+
`.compositingGroup()`, which casts them itself, as one. */
|
|
4532
|
+
:is([data-sb-kind="text"], [data-sb-kind="image"], [data-sb-kind="divider"], [data-sb-kind="color"], [data-sb-kind="shape"],
|
|
4533
|
+
[data-sb-kind="textfield"], [data-sb-kind="toggle"], [data-sb-kind="picker"], [data-sb-kind="gauge"],
|
|
4534
|
+
[data-sb-kind="searchfield"], [data-sb-kind="registered"], [data-sb-kind="chart"], [data-sb-kind="stepper"],
|
|
4535
|
+
[data-sb-kind="slider"], [data-sb-kind="datepicker"], [data-sb-kind="texteditor"], [data-sb-kind="video"],
|
|
4536
|
+
[data-sb-kind="canvas"]) {
|
|
4537
|
+
filter: var(--sb-shadow-filter, none);
|
|
4538
|
+
}
|
|
4539
|
+
|
|
4540
|
+
:is([data-sb-kind="text"], [data-sb-kind="image"], [data-sb-kind="divider"], [data-sb-kind="color"], [data-sb-kind="shape"],
|
|
4541
|
+
[data-sb-kind="textfield"], [data-sb-kind="toggle"], [data-sb-kind="picker"], [data-sb-kind="gauge"],
|
|
4542
|
+
[data-sb-kind="searchfield"], [data-sb-kind="registered"], [data-sb-kind="chart"], [data-sb-kind="stepper"],
|
|
4543
|
+
[data-sb-kind="slider"], [data-sb-kind="datepicker"], [data-sb-kind="texteditor"], [data-sb-kind="video"],
|
|
4544
|
+
[data-sb-kind="canvas"]) *,
|
|
4545
|
+
[data-sb-composite] > * {
|
|
4546
|
+
--sb-shadow-filter: initial;
|
|
4547
|
+
}
|
package/src/styles.ts
CHANGED
|
@@ -352,7 +352,7 @@ export const PERSPECTIVE_BASE_PX = 500;
|
|
|
352
352
|
* `500px / perspective` (a smaller SwiftUI perspective is a flatter
|
|
353
353
|
* projection, as `perspective: 0.5` is in SwiftUI).
|
|
354
354
|
*/
|
|
355
|
-
export function effectsCSS(style: Style): EffectsCSS {
|
|
355
|
+
export function effectsCSS(style: Style, options: { shadow?: boolean } = {}): EffectsCSS {
|
|
356
356
|
const out: EffectsCSS = {};
|
|
357
357
|
const transforms: string[] = [];
|
|
358
358
|
if (style.offset) {
|
|
@@ -408,11 +408,7 @@ export function effectsCSS(style: Style): EffectsCSS {
|
|
|
408
408
|
filters.push(`hue-rotate(${round(style.hueRotation)}deg)`);
|
|
409
409
|
}
|
|
410
410
|
if (style.colorInvert === true) filters.push('invert(1)');
|
|
411
|
-
if (style.shadow) {
|
|
412
|
-
const { color, radius, x, y } = style.shadow;
|
|
413
|
-
const css = color ? colorToCSS(color) : 'rgba(0, 0, 0, 0.33)';
|
|
414
|
-
filters.push(`drop-shadow(${finite(x)}px ${finite(y)}px ${Math.max(0, finite(radius))}px ${css})`);
|
|
415
|
-
}
|
|
411
|
+
if (style.shadow && options.shadow !== false) filters.push(`drop-shadow(${shadowCSS(style.shadow)})`);
|
|
416
412
|
if (filters.length) out.filter = filters.join(' ');
|
|
417
413
|
// Phase 7.
|
|
418
414
|
if (style.hidden === true) out.hidden = true;
|
|
@@ -420,6 +416,13 @@ export function effectsCSS(style: Style): EffectsCSS {
|
|
|
420
416
|
return out;
|
|
421
417
|
}
|
|
422
418
|
|
|
419
|
+
/** A shadow's `x y radius color`, as `drop-shadow()` and `box-shadow` both take it. */
|
|
420
|
+
export function shadowCSS(shadow: NonNullable<Style['shadow']>): string {
|
|
421
|
+
const { color, radius, x, y } = shadow;
|
|
422
|
+
const css = color ? colorToCSS(color) : 'rgba(0, 0, 0, 0.33)';
|
|
423
|
+
return `${finite(x)}px ${finite(y)}px ${Math.max(0, finite(radius))}px ${css}`;
|
|
424
|
+
}
|
|
425
|
+
|
|
423
426
|
/**
|
|
424
427
|
* Phase 7 `.mask { }` with a ShapeStyle: a CSS `mask-image` whose alpha is
|
|
425
428
|
* the style's. A `linearGradient` / `radialGradient` becomes the matching CSS
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page's half of `ViewRegistry` (docs/ops-protocol.md, "Registered
|
|
3
|
+
* views"): a view a package draws itself, by name, which the `registered`
|
|
4
|
+
* element kind names. A native app registers a SwiftUI view under the same
|
|
5
|
+
* name (SwiftBrowserNative's `ViewRegistry`), so one component
|
|
6
|
+
* (`RegisteredView` in @swiftbrowser/react) is a SwiftUI view in the app and
|
|
7
|
+
* the package's own DOM on a page, drawing what never crosses the bridge
|
|
8
|
+
* (an audio node's frames, a map's tiles).
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* ViewRegistry.register('Meter', (context) => {
|
|
12
|
+
* const element = document.createElement('canvas');
|
|
13
|
+
* return { element, update: (props) => …, dispose: () => … };
|
|
14
|
+
* });
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** What a builder gets: the element's id, its props as the app wrote them, and a way to answer (`onEvent`). */
|
|
19
|
+
export interface RegisteredViewContext {
|
|
20
|
+
readonly id: number;
|
|
21
|
+
readonly props: Record<string, unknown>;
|
|
22
|
+
/** Sends a value to the element's `onEvent` in the app. */
|
|
23
|
+
send(value: unknown): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A built view: its element, which fills the box the layout gives it, and what to do as props change or it goes. */
|
|
27
|
+
export interface RegisteredViewInstance {
|
|
28
|
+
readonly element: HTMLElement;
|
|
29
|
+
update?(props: Record<string, unknown>): void;
|
|
30
|
+
dispose?(): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type RegisteredViewBuilder = (context: RegisteredViewContext) => RegisteredViewInstance | HTMLElement;
|
|
34
|
+
|
|
35
|
+
const builders = new Map<string, RegisteredViewBuilder>();
|
|
36
|
+
|
|
37
|
+
export const ViewRegistry = {
|
|
38
|
+
/** Registers `builder` under `name`, replacing one registered before; elements made after use it. */
|
|
39
|
+
register(name: string, builder: RegisteredViewBuilder): void {
|
|
40
|
+
builders.set(name, builder);
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
unregister(name: string): void {
|
|
44
|
+
builders.delete(name);
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
isRegistered(name: string): boolean {
|
|
48
|
+
return builders.has(name);
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
/** The view registered under `name`, built; undefined when there is none. */
|
|
52
|
+
make(name: string, context: RegisteredViewContext): RegisteredViewInstance | undefined {
|
|
53
|
+
const builder = builders.get(name);
|
|
54
|
+
if (!builder) return undefined;
|
|
55
|
+
const made = builder(context);
|
|
56
|
+
return made instanceof HTMLElement ? { element: made } : made;
|
|
57
|
+
},
|
|
58
|
+
};
|