@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/layout/text.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Greedy word wrapping with character fallback and `lineLimit` truncation.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { ResolvedFont, TextMeasurer } from './types';
|
|
5
|
+
import type { ResolvedFont, Size, TextFragment, TextMeasurer } from './types';
|
|
6
6
|
|
|
7
7
|
export const ELLIPSIS = '…';
|
|
8
8
|
/**
|
|
@@ -122,3 +122,219 @@ function truncateLine(
|
|
|
122
122
|
}
|
|
123
123
|
return chars.join('') + ELLIPSIS;
|
|
124
124
|
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Rich text: runs
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/** A run of rich text as the wrapper reads it: its text or image, and its font. */
|
|
131
|
+
export interface TextRunInput {
|
|
132
|
+
text?: string;
|
|
133
|
+
/** An inline image's size (the run has no text). */
|
|
134
|
+
image?: Size;
|
|
135
|
+
font: ResolvedFont;
|
|
136
|
+
/** Extra space after every character, in px. */
|
|
137
|
+
kerning: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface WrappedRuns {
|
|
141
|
+
lines: TextFragment[][];
|
|
142
|
+
/** Each line's height: its tallest font's line height or image. */
|
|
143
|
+
heights: number[];
|
|
144
|
+
/** Width of the widest line. */
|
|
145
|
+
width: number;
|
|
146
|
+
truncated: boolean;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
type Piece = TextFragment;
|
|
150
|
+
type Token = { kind: 'word'; pieces: Piece[] } | { kind: 'space'; piece: Piece } | { kind: 'break' };
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* `wrapText` for runs of differently styled text and inline images: lines
|
|
154
|
+
* break at spaces (and around images), a word may span runs, a word wider
|
|
155
|
+
* than the line breaks by characters, and `lineLimit` truncates the last
|
|
156
|
+
* kept line with an ellipsis in its last run's font. `base` sets the height
|
|
157
|
+
* of an empty line.
|
|
158
|
+
*/
|
|
159
|
+
export function wrapRuns(
|
|
160
|
+
runs: TextRunInput[],
|
|
161
|
+
base: ResolvedFont,
|
|
162
|
+
maxWidth: number | null,
|
|
163
|
+
lineLimit: number | null,
|
|
164
|
+
measurer: TextMeasurer,
|
|
165
|
+
): WrappedRuns {
|
|
166
|
+
const measure = (run: number, text: string): number => {
|
|
167
|
+
if (text.length === 0) return 0;
|
|
168
|
+
const input = runs[run] as TextRunInput;
|
|
169
|
+
return measurer.measureWidth(text, input.font) + input.kerning * Array.from(text).length;
|
|
170
|
+
};
|
|
171
|
+
const constrained = maxWidth !== null && Number.isFinite(maxWidth);
|
|
172
|
+
const limit = constrained ? maxWidth + EPSILON : Infinity;
|
|
173
|
+
|
|
174
|
+
// Tokens: words (which may span runs), spaces and hard breaks.
|
|
175
|
+
const tokens: Token[] = [];
|
|
176
|
+
let word: Piece[] | null = null;
|
|
177
|
+
runs.forEach((input, run) => {
|
|
178
|
+
if (input.image) {
|
|
179
|
+
word = null;
|
|
180
|
+
tokens.push({ kind: 'word', pieces: [{ run, width: input.image.width, height: input.image.height }] });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
for (const part of (input.text ?? '').split(/(\n| +)/)) {
|
|
184
|
+
if (part.length === 0) continue;
|
|
185
|
+
if (part === '\n') {
|
|
186
|
+
word = null;
|
|
187
|
+
tokens.push({ kind: 'break' });
|
|
188
|
+
} else if (part.startsWith(' ')) {
|
|
189
|
+
word = null;
|
|
190
|
+
tokens.push({ kind: 'space', piece: { run, text: part, width: measure(run, part) } });
|
|
191
|
+
} else {
|
|
192
|
+
const piece = { run, text: part, width: measure(run, part) };
|
|
193
|
+
if (word) word.push(piece);
|
|
194
|
+
else {
|
|
195
|
+
word = [piece];
|
|
196
|
+
tokens.push({ kind: 'word', pieces: word });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
let lines: Piece[][] = [];
|
|
203
|
+
let current: Piece[] = [];
|
|
204
|
+
let width = 0;
|
|
205
|
+
let spaces: Piece[] = [];
|
|
206
|
+
const sum = (pieces: Piece[]): number => pieces.reduce((total, piece) => total + piece.width, 0);
|
|
207
|
+
const endLine = (): void => {
|
|
208
|
+
lines.push(current);
|
|
209
|
+
current = [];
|
|
210
|
+
width = 0;
|
|
211
|
+
spaces = [];
|
|
212
|
+
};
|
|
213
|
+
for (const token of tokens) {
|
|
214
|
+
if (token.kind === 'break') {
|
|
215
|
+
endLine();
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (token.kind === 'space') {
|
|
219
|
+
if (current.length > 0) spaces.push(token.piece);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const wordWidth = sum(token.pieces);
|
|
223
|
+
const spaceWidth = sum(spaces);
|
|
224
|
+
if (current.length === 0 ? wordWidth <= limit : width + spaceWidth + wordWidth <= limit) {
|
|
225
|
+
current.push(...spaces, ...token.pieces);
|
|
226
|
+
width += spaceWidth + wordWidth;
|
|
227
|
+
spaces = [];
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (current.length > 0) endLine();
|
|
231
|
+
spaces = [];
|
|
232
|
+
if (wordWidth <= limit) {
|
|
233
|
+
current = [...token.pieces];
|
|
234
|
+
width = wordWidth;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
// The word alone is wider than the line: break it by characters.
|
|
238
|
+
for (const piece of token.pieces) {
|
|
239
|
+
const units = piece.text === undefined ? [piece] : Array.from(piece.text).map((ch) => ({ run: piece.run, text: ch, width: measure(piece.run, ch) }));
|
|
240
|
+
for (const unit of units) {
|
|
241
|
+
if (current.length > 0 && width + unit.width > limit) endLine();
|
|
242
|
+
current.push(unit);
|
|
243
|
+
width += unit.width;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
lines.push(current);
|
|
248
|
+
|
|
249
|
+
let truncated = false;
|
|
250
|
+
if (lineLimit !== null && lineLimit >= 1 && lines.length > lineLimit) {
|
|
251
|
+
const kept = lines.slice(0, lineLimit - 1);
|
|
252
|
+
const rest: Piece[] = [];
|
|
253
|
+
for (const line of lines.slice(lineLimit - 1)) {
|
|
254
|
+
if (line.length === 0) continue;
|
|
255
|
+
const first = line[0] as Piece;
|
|
256
|
+
if (rest.length > 0) rest.push({ run: first.run, text: ' ', width: measure(first.run, ' ') });
|
|
257
|
+
rest.push(...line);
|
|
258
|
+
}
|
|
259
|
+
kept.push(truncatePieces(rest, constrained ? maxWidth : null, measure, runs.length));
|
|
260
|
+
lines = kept;
|
|
261
|
+
truncated = true;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const merged = lines.map(mergePieces);
|
|
265
|
+
const heights = merged.map((line) => {
|
|
266
|
+
if (line.length === 0) return base.lineHeight;
|
|
267
|
+
let height = 0;
|
|
268
|
+
for (const piece of line) {
|
|
269
|
+
const font = (runs[piece.run] as TextRunInput).font;
|
|
270
|
+
height = Math.max(height, piece.height ?? font.lineHeight, font.lineHeight);
|
|
271
|
+
}
|
|
272
|
+
return height;
|
|
273
|
+
});
|
|
274
|
+
let widest = 0;
|
|
275
|
+
for (const line of merged) widest = Math.max(widest, sum(line));
|
|
276
|
+
return { lines: merged, heights, width: widest, truncated };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The pieces cut to fit `maxWidth` with an ellipsis (in the last run's font) after them. */
|
|
280
|
+
function truncatePieces(
|
|
281
|
+
pieces: Piece[],
|
|
282
|
+
maxWidth: number | null,
|
|
283
|
+
measure: (run: number, text: string) => number,
|
|
284
|
+
runCount: number,
|
|
285
|
+
): Piece[] {
|
|
286
|
+
const out = pieces.map((piece) => ({ ...piece }));
|
|
287
|
+
const lastRun = (): number => (out.length > 0 ? (out[out.length - 1] as Piece).run : Math.max(0, runCount - 1));
|
|
288
|
+
const ellipsis = (): number => measure(lastRun(), ELLIPSIS);
|
|
289
|
+
const total = (): number => out.reduce((sum, piece) => sum + piece.width, 0);
|
|
290
|
+
const trimEnd = (): void => {
|
|
291
|
+
for (;;) {
|
|
292
|
+
const last = out[out.length - 1];
|
|
293
|
+
if (!last) return;
|
|
294
|
+
if (last.text !== undefined && last.text.length === 0) {
|
|
295
|
+
out.pop();
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (last.text === undefined || !last.text.endsWith(' ')) return;
|
|
299
|
+
last.text = last.text.replace(/ +$/, '');
|
|
300
|
+
last.width = measure(last.run, last.text);
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
if (maxWidth !== null) {
|
|
304
|
+
while (out.length > 0 && total() + ellipsis() > maxWidth + EPSILON) {
|
|
305
|
+
const last = out[out.length - 1] as Piece;
|
|
306
|
+
if (last.text === undefined || last.text.length <= 1) out.pop();
|
|
307
|
+
else {
|
|
308
|
+
const chars = Array.from(last.text);
|
|
309
|
+
chars.pop();
|
|
310
|
+
last.text = chars.join('');
|
|
311
|
+
last.width = measure(last.run, last.text);
|
|
312
|
+
}
|
|
313
|
+
trimEnd();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const run = lastRun();
|
|
317
|
+
const last = out[out.length - 1];
|
|
318
|
+
if (last && last.text !== undefined) {
|
|
319
|
+
last.text += ELLIPSIS;
|
|
320
|
+
last.width = measure(last.run, last.text);
|
|
321
|
+
} else {
|
|
322
|
+
out.push({ run, text: ELLIPSIS, width: measure(run, ELLIPSIS) });
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Adjacent text pieces of the same run as one fragment. */
|
|
328
|
+
function mergePieces(line: Piece[]): Piece[] {
|
|
329
|
+
const out: Piece[] = [];
|
|
330
|
+
for (const piece of line) {
|
|
331
|
+
const last = out[out.length - 1];
|
|
332
|
+
if (last && last.run === piece.run && last.text !== undefined && piece.text !== undefined) {
|
|
333
|
+
last.text += piece.text;
|
|
334
|
+
last.width += piece.width;
|
|
335
|
+
} else {
|
|
336
|
+
out.push({ ...piece });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
340
|
+
}
|
package/src/layout/types.ts
CHANGED
|
@@ -66,6 +66,14 @@ export interface ResolvedFont {
|
|
|
66
66
|
lineHeight: number;
|
|
67
67
|
/** A CSS `font` shorthand (`weight size/lineHeight family`) usable verbatim. */
|
|
68
68
|
css: string;
|
|
69
|
+
/**
|
|
70
|
+
* A weight or design set by a view modifier (`.fontWeight`, `.bold()`,
|
|
71
|
+
* `.fontDesign`): SwiftUI applies it to whatever font the text inside ends
|
|
72
|
+
* up with, so a `.font(...)` set further in keeps it unless that font
|
|
73
|
+
* names its own (typography.ts, `resolveFont`).
|
|
74
|
+
*/
|
|
75
|
+
weightModifier?: number;
|
|
76
|
+
familyModifier?: FontFamily;
|
|
69
77
|
}
|
|
70
78
|
|
|
71
79
|
export interface TextMeasurer {
|
|
@@ -96,6 +104,29 @@ export interface TextLayout {
|
|
|
96
104
|
alignment: TextAlignment;
|
|
97
105
|
/** True when `lineLimit` cut text off (the last line ends with an ellipsis). */
|
|
98
106
|
truncated: boolean;
|
|
107
|
+
/**
|
|
108
|
+
* Rich text (a `text` with `runs`): each line as fragments of the runs, the
|
|
109
|
+
* height of each line, and each run's font. `lines` then holds the same
|
|
110
|
+
* lines as plain strings (an inline image as U+FFFC).
|
|
111
|
+
*/
|
|
112
|
+
rich?: RichTextLayout;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** One piece of a line of rich text: some of a run's text, or its image. */
|
|
116
|
+
export interface TextFragment {
|
|
117
|
+
/** Index into the text's `runs`. */
|
|
118
|
+
run: number;
|
|
119
|
+
/** The text drawn; absent for an image run. */
|
|
120
|
+
text?: string;
|
|
121
|
+
width: number;
|
|
122
|
+
/** An image run's height (its width is `width`). */
|
|
123
|
+
height?: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface RichTextLayout {
|
|
127
|
+
lines: TextFragment[][];
|
|
128
|
+
heights: number[];
|
|
129
|
+
fonts: ResolvedFont[];
|
|
99
130
|
}
|
|
100
131
|
|
|
101
132
|
export interface LayoutResult {
|
package/src/layout/typography.ts
CHANGED
|
@@ -131,10 +131,18 @@ function formatPx(value: number): string {
|
|
|
131
131
|
return `${Math.round(value * 100) / 100}px`;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
export function makeFont(
|
|
134
|
+
export function makeFont(
|
|
135
|
+
family: FontFamily,
|
|
136
|
+
size: number,
|
|
137
|
+
weight: number,
|
|
138
|
+
modifiers: { weightModifier?: number | undefined; familyModifier?: FontFamily | undefined } = {},
|
|
139
|
+
): ResolvedFont {
|
|
135
140
|
const lineHeight = lineHeightFor(size);
|
|
136
141
|
const css = `${weight} ${formatPx(size)}/${lineHeight}px ${FONT_FAMILIES[family]}`;
|
|
137
|
-
|
|
142
|
+
const font: ResolvedFont = { family, size, weight, lineHeight, css };
|
|
143
|
+
if (modifiers.weightModifier !== undefined) font.weightModifier = modifiers.weightModifier;
|
|
144
|
+
if (modifiers.familyModifier !== undefined) font.familyModifier = modifiers.familyModifier;
|
|
145
|
+
return font;
|
|
138
146
|
}
|
|
139
147
|
|
|
140
148
|
/** Body text for the current `dynamicTypeSize` (17/22 at Large). */
|
|
@@ -146,7 +154,11 @@ export function defaultFont(env: LayoutEnvironment): ResolvedFont {
|
|
|
146
154
|
* Apply a Font JSON object (`textStyle` | `size`, `weight`, `design`,
|
|
147
155
|
* `relativeTo`) to an inherited font. A `textStyle` or `size` defines a new
|
|
148
156
|
* font (weight defaults to regular, or semibold for `headline`; design to the
|
|
149
|
-
* system family); without either, only the given `weight`/`design` change
|
|
157
|
+
* system family); without either, only the given `weight`/`design` change,
|
|
158
|
+
* as a view modifier (`.fontWeight(.semibold)`, `.bold()`, `.fontDesign`)
|
|
159
|
+
* that also applies to a new font set further in which names no weight or
|
|
160
|
+
* design of its own: `.font(.system(size: 15)).fontWeight(.semibold)` is a
|
|
161
|
+
* semibold 15.
|
|
150
162
|
*/
|
|
151
163
|
export function resolveFont(
|
|
152
164
|
fontProps: unknown,
|
|
@@ -181,8 +193,13 @@ export function resolveFont(
|
|
|
181
193
|
size = inherited.size;
|
|
182
194
|
}
|
|
183
195
|
|
|
184
|
-
const weight = explicitWeight ?? (isNewFont ? defaultWeight : inherited.weight);
|
|
185
|
-
const family = explicitFamily ?? (isNewFont ? 'system' : inherited.family);
|
|
186
|
-
|
|
187
|
-
|
|
196
|
+
const weight = explicitWeight ?? (isNewFont ? (inherited.weightModifier ?? defaultWeight) : inherited.weight);
|
|
197
|
+
const family = explicitFamily ?? (isNewFont ? (inherited.familyModifier ?? 'system') : inherited.family);
|
|
198
|
+
// A weight or design without a new font is a modifier the text further in keeps.
|
|
199
|
+
const weightModifier = !isNewFont && explicitWeight !== undefined ? explicitWeight : inherited.weightModifier;
|
|
200
|
+
const familyModifier = !isNewFont && explicitFamily !== undefined ? explicitFamily : inherited.familyModifier;
|
|
201
|
+
if (!isNewFont && weight === inherited.weight && family === inherited.family && weightModifier === inherited.weightModifier && familyModifier === inherited.familyModifier) {
|
|
202
|
+
return inherited;
|
|
203
|
+
}
|
|
204
|
+
return makeFont(family, size, weight, { weightModifier, familyModifier });
|
|
188
205
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Measuring a `video` element (docs/ops-protocol.md, "Video and audio"). A
|
|
3
|
+
* player view fills what it is offered, like a resizable image: a finite
|
|
4
|
+
* proposal is taken as is; an axis left open follows the video's aspect
|
|
5
|
+
* ratio (its `width` / `height` props once the page has read the metadata,
|
|
6
|
+
* 16:9 before); with both open it is the video's own size, or 320 × 180.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Proposal, Size } from './types';
|
|
10
|
+
|
|
11
|
+
export const VIDEO_DEFAULT_SIZE: Size = { width: 320, height: 180 };
|
|
12
|
+
|
|
13
|
+
export function videoSize(props: Record<string, unknown>, p: Proposal): Size {
|
|
14
|
+
const width = typeof props['width'] === 'number' && props['width'] > 0 ? props['width'] : null;
|
|
15
|
+
const height = typeof props['height'] === 'number' && props['height'] > 0 ? props['height'] : null;
|
|
16
|
+
const aspect = width !== null && height !== null ? width / height : VIDEO_DEFAULT_SIZE.width / VIDEO_DEFAULT_SIZE.height;
|
|
17
|
+
const pw = p.width !== null && Number.isFinite(p.width) ? p.width : null;
|
|
18
|
+
const ph = p.height !== null && Number.isFinite(p.height) ? p.height : null;
|
|
19
|
+
if (pw !== null && ph !== null) return { width: pw, height: ph };
|
|
20
|
+
if (pw !== null) return { width: pw, height: pw / aspect };
|
|
21
|
+
if (ph !== null) return { width: ph * aspect, height: ph };
|
|
22
|
+
if (width !== null && height !== null) return { width, height };
|
|
23
|
+
return { ...VIDEO_DEFAULT_SIZE };
|
|
24
|
+
}
|
package/src/layoutDom.ts
CHANGED
|
@@ -35,6 +35,8 @@
|
|
|
35
35
|
|
|
36
36
|
import type { LayoutEnvironment, LayoutNode, LayoutResult, Rect, ResolvedFont, TextLayout } from './layout';
|
|
37
37
|
import { applyChartChrome } from './chartDom';
|
|
38
|
+
import { bitmapLaidOut } from './bitmaps';
|
|
39
|
+
import { drawRichText } from './richText';
|
|
38
40
|
|
|
39
41
|
export interface LayoutDomContext {
|
|
40
42
|
root: HTMLElement;
|
|
@@ -95,8 +97,8 @@ export function applyLayout(ctx: LayoutDomContext, result: LayoutResult): BoxCha
|
|
|
95
97
|
boxes.delete(id);
|
|
96
98
|
continue;
|
|
97
99
|
}
|
|
98
|
-
const box = boxOf(ctx, result, node, frame);
|
|
99
|
-
const origin = node.kind === 'sheet' ? { x: 0, y: 0 } : originOf(ctx, result, ctx.parents.get(id) ?? ROOT_ID);
|
|
100
|
+
const box = drawnBox(node, boxOf(ctx, result, node, frame));
|
|
101
|
+
const origin = node.kind === 'sheet' ? { x: 0, y: 0 } : isSectionSupplement(node) ? supplementOrigin(ctx, result, id) : originOf(ctx, result, ctx.parents.get(id) ?? ROOT_ID);
|
|
100
102
|
const relative = { x: box.x - origin.x, y: box.y - origin.y, width: box.width, height: box.height };
|
|
101
103
|
const previous = ctx.boxes.get(id) ?? null;
|
|
102
104
|
boxes.set(id, relative);
|
|
@@ -146,7 +148,7 @@ export function setTabHidden(tab: HTMLElement, hidden: boolean): void {
|
|
|
146
148
|
export function boxOf(ctx: LayoutDomContext, result: LayoutResult, node: LayoutNode, frame: Rect): Rect {
|
|
147
149
|
const row = result.chrome.get(node.id)?.['row'];
|
|
148
150
|
if (row) return row;
|
|
149
|
-
if (node.kind === 'section') return frame;
|
|
151
|
+
if (node.kind === 'section' || isSectionSupplement(node)) return frame;
|
|
150
152
|
const parentId = ctx.parents.get(node.id);
|
|
151
153
|
const parent = parentId !== undefined ? ctx.nodes.get(parentId) : undefined;
|
|
152
154
|
if (!parent || (parent.kind !== 'section' && parent.kind !== 'list')) return frame;
|
|
@@ -156,6 +158,32 @@ export function boxOf(ctx: LayoutDomContext, result: LayoutResult, node: LayoutN
|
|
|
156
158
|
return { x: card.x, y: frame.y - (height - frame.height) / 2, width: card.width, height };
|
|
157
159
|
}
|
|
158
160
|
|
|
161
|
+
/**
|
|
162
|
+
* The box an element is drawn in: its frame, except a circle's, which is the
|
|
163
|
+
* square centered in it (SwiftUI draws the largest circle that fits, so
|
|
164
|
+
* `Circle().frame(width: 110)` in a taller space stays round). The engine's
|
|
165
|
+
* frame, which the siblings were laid out with, is unchanged.
|
|
166
|
+
*/
|
|
167
|
+
function drawnBox(node: LayoutNode, box: Rect): Rect {
|
|
168
|
+
if (node.kind !== 'shape' || node.props['shape'] !== 'circle' || box.width === box.height) return box;
|
|
169
|
+
const side = Math.min(box.width, box.height);
|
|
170
|
+
return { x: box.x + (box.width - side) / 2, y: box.y + (box.height - side) / 2, width: side, height: side };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** A section's view header or footer: not a row, and a DOM child of the section itself. */
|
|
174
|
+
function isSectionSupplement(node: LayoutNode): boolean {
|
|
175
|
+
return node.kind === 'sectionheader' || node.kind === 'sectionfooter';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A view header / footer sits in the section element, beside the rows' card: the section's box is its origin. */
|
|
179
|
+
function supplementOrigin(ctx: LayoutDomContext, result: LayoutResult, id: number): { x: number; y: number } {
|
|
180
|
+
const parentId = ctx.parents.get(id);
|
|
181
|
+
const parent = parentId === undefined ? undefined : ctx.nodes.get(parentId);
|
|
182
|
+
const frame = parentId === undefined ? undefined : result.frames.get(parentId);
|
|
183
|
+
if (!parent || !frame || parent.kind !== 'section') return originOf(ctx, result, parentId ?? ROOT_ID);
|
|
184
|
+
return boxOf(ctx, result, parent, frame);
|
|
185
|
+
}
|
|
186
|
+
|
|
159
187
|
/** Screen-coordinate origin that the children of `parentId` are positioned against. */
|
|
160
188
|
export function originOf(ctx: LayoutDomContext, result: LayoutResult, parentId: number): { x: number; y: number } {
|
|
161
189
|
if (parentId === ROOT_ID) return { x: 0, y: 0 };
|
|
@@ -244,6 +272,10 @@ function setTextLayout(el: HTMLElement, layout: TextLayout): void {
|
|
|
244
272
|
setFont(el, layout.font);
|
|
245
273
|
const align = TEXT_ALIGN[layout.alignment] ?? 'left';
|
|
246
274
|
if (el.style.textAlign !== align) el.style.textAlign = align;
|
|
275
|
+
if (layout.rich && el.dataset['sbRich'] !== undefined) {
|
|
276
|
+
drawRichText(el, layout.rich);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
247
279
|
const content = layout.lines.join('\n');
|
|
248
280
|
if (el.textContent !== content) el.textContent = content;
|
|
249
281
|
}
|
|
@@ -275,6 +307,14 @@ function applyBadge(result: LayoutResult, node: LayoutNode, el: HTMLElement, box
|
|
|
275
307
|
}
|
|
276
308
|
}
|
|
277
309
|
|
|
310
|
+
function setFlag(el: HTMLElement, key: string, value: string | null): void {
|
|
311
|
+
if (value === null) {
|
|
312
|
+
if (key in el.dataset) delete el.dataset[key];
|
|
313
|
+
} else if (el.dataset[key] !== value) {
|
|
314
|
+
el.dataset[key] = value;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
278
318
|
/** For leaf rows: pad the content frame back into place inside the row box. */
|
|
279
319
|
function padIntoFrame(el: HTMLElement, box: Rect, frame: Rect): void {
|
|
280
320
|
const s = el.style;
|
|
@@ -298,6 +338,10 @@ function applyChrome(
|
|
|
298
338
|
bottomInset: number,
|
|
299
339
|
): void {
|
|
300
340
|
const chrome = result.chrome.get(node.id);
|
|
341
|
+
// `.listRowSeparator(.hidden)`: a list row leaves out the separator below
|
|
342
|
+
// it, or above it (the previous row's line; styles.css).
|
|
343
|
+
setFlag(el, 'sbSeparatorBottom', chrome?.['separatorBottomHidden'] ? 'hidden' : null);
|
|
344
|
+
setFlag(el, 'sbSeparatorTop', chrome?.['separatorTopHidden'] ? 'hidden' : null);
|
|
301
345
|
switch (node.kind) {
|
|
302
346
|
case 'styled':
|
|
303
347
|
applyBadge(result, node, el, box);
|
|
@@ -315,10 +359,14 @@ function applyChrome(
|
|
|
315
359
|
if (el.style.fontSize !== size) el.style.fontSize = size;
|
|
316
360
|
}
|
|
317
361
|
padIntoFrame(el, box, frame);
|
|
362
|
+
// A bitmap is drawn for the size it is shown at.
|
|
363
|
+
if (node.props['bitmap']) bitmapLaidOut(el, frame.width, frame.height);
|
|
318
364
|
return;
|
|
319
365
|
}
|
|
320
366
|
case 'textfield':
|
|
321
367
|
case 'texteditor':
|
|
368
|
+
// A vertical text field draws in the font its lines were measured in.
|
|
369
|
+
setFont(el, result.fonts.get(node.id));
|
|
322
370
|
padIntoFrame(el, box, frame);
|
|
323
371
|
return;
|
|
324
372
|
// Phase 8 controls: their chrome sits at the engine's rects inside the element's box.
|
|
@@ -514,6 +562,8 @@ function applyChrome(
|
|
|
514
562
|
// The track and fill are chrome, positioned from the element's own box.
|
|
515
563
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-gauge-track'), chrome?.['track'], box);
|
|
516
564
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-gauge-fill'), chrome?.['fill'], box);
|
|
565
|
+
// A circular style: the ring over the gauge's square (gaugeRing.ts).
|
|
566
|
+
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-gauge-ring'), chrome?.['ring'], box);
|
|
517
567
|
return;
|
|
518
568
|
}
|
|
519
569
|
case 'chart':
|
package/src/main.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/// <reference path="./vite-env.d.ts" />
|
|
2
2
|
import { easingFor, springEasing, timingFor } from './animation';
|
|
3
3
|
import type { BuildEndPayload, BuildErrorPayload, BuildStartPayload } from './devEvents';
|
|
4
|
+
import { ChromeColor } from './chromeColor';
|
|
4
5
|
import { applyPageMode, decidePageMode, readViewportInsets, watchViewport, type PageMode } from './deviceMode';
|
|
5
6
|
import { EMPTY_CATALOG, catalogPath, isAssetCatalog, type AssetCatalog } from './assets';
|
|
6
7
|
import { createMockApp } from './mock';
|
|
@@ -14,9 +15,16 @@ import {
|
|
|
14
15
|
type ScenePhase,
|
|
15
16
|
} from './protocol';
|
|
16
17
|
import { Renderer, RendererError } from './renderer';
|
|
18
|
+
import { withBlockedHosts } from './blockedHosts';
|
|
19
|
+
import { withCORSProxy } from './corsProxy';
|
|
20
|
+
import { browserEnvironment } from './hostRequests';
|
|
21
|
+
import { callbackPageURL } from './webAuth';
|
|
22
|
+
import { bitmapStore } from './bitmaps';
|
|
17
23
|
import { DEFAULT_TYPE_SIZE, applyTypeSizeVariables } from './typography';
|
|
18
24
|
import { JobScheduler, loadApp, type AppHandle, type JobTimers } from './wasm';
|
|
19
25
|
import { chooseLocale, currentTimeZone } from './locale';
|
|
26
|
+
import { indexedDBPersistence, launchValue, type SwiftDataPersistence, type SwiftDataStores } from './swiftData';
|
|
27
|
+
import { applyHomeChanges, diffHome, homeDirectory, homeEntries, indexedDBFiles, type FilesPersistence, type HomeEntries } from './appFiles';
|
|
20
28
|
import type { BundleManifest } from '../plugins/bundle-resources';
|
|
21
29
|
|
|
22
30
|
const THEME_KEY = 'sb-theme';
|
|
@@ -70,7 +78,7 @@ function dispatch(event: RendererEvent): void {
|
|
|
70
78
|
return;
|
|
71
79
|
}
|
|
72
80
|
try {
|
|
73
|
-
if (event.type === 'tap') {
|
|
81
|
+
if (event.type === 'tap' && event.x === undefined) {
|
|
74
82
|
app.sendEvent(event.id);
|
|
75
83
|
} else if (event.type === 'batch' && !app.supportsBatchEvents) {
|
|
76
84
|
// The mock apps handle events one at a time.
|
|
@@ -87,6 +95,11 @@ function dispatch(event: RendererEvent): void {
|
|
|
87
95
|
|
|
88
96
|
const renderer = new Renderer(screen, dispatch);
|
|
89
97
|
|
|
98
|
+
// On a phone the browser's status bar strip and safe areas take the color the
|
|
99
|
+
// app draws at its top edge (chromeColor.ts).
|
|
100
|
+
const chromeColor = deviceMode ? new ChromeColor(device, screen) : null;
|
|
101
|
+
if (chromeColor) renderer.afterLayout = () => chromeColor.schedule();
|
|
102
|
+
|
|
90
103
|
// Phase 12: `.preferredColorScheme` from the app overrides the viewer's theme
|
|
91
104
|
// for this page (not persisted); `null` goes back to the toggle / OS setting.
|
|
92
105
|
screen.addEventListener('sb:color-scheme', (event) => {
|
|
@@ -122,6 +135,7 @@ function applyTheme(theme: Theme, persist: boolean): void {
|
|
|
122
135
|
themeToggle.setAttribute('aria-pressed', String(theme === 'dark'));
|
|
123
136
|
themeToggle.textContent = theme === 'dark' ? 'Light mode' : 'Dark mode';
|
|
124
137
|
renderer.setEnvironment({ colorScheme: theme });
|
|
138
|
+
chromeColor?.schedule();
|
|
125
139
|
if (persist) {
|
|
126
140
|
try {
|
|
127
141
|
localStorage.setItem(THEME_KEY, theme);
|
|
@@ -210,8 +224,8 @@ function currentSizeClass(): HorizontalSizeClass {
|
|
|
210
224
|
let sentSizeClass: HorizontalSizeClass | null = null;
|
|
211
225
|
|
|
212
226
|
if (deviceMode) {
|
|
213
|
-
// `#screen` fills the viewport (styles.css); the
|
|
214
|
-
//
|
|
227
|
+
// `#screen` fills the viewport (styles.css); the renderer re-reads its size
|
|
228
|
+
// when it resizes. The safe areas come from env(safe-area-inset-*): non-zero
|
|
215
229
|
// only in standalone (home screen) mode or landscape, which is what the
|
|
216
230
|
// real app would get.
|
|
217
231
|
renderer.setSafeArea(readViewportInsets());
|
|
@@ -357,9 +371,78 @@ function launchEnvironment(): string[] {
|
|
|
357
371
|
if (zone) env.push(`TZ=${zone}`);
|
|
358
372
|
const defaults = readStoredDefaults();
|
|
359
373
|
if (defaults !== null) env.push(`SB_DEFAULTS=${defaults}`);
|
|
374
|
+
// Signing in: the app's URL schemes (its Info.plist's CFBundleURLTypes) and
|
|
375
|
+
// the callback page its OAuth redirects become (webAuth.ts).
|
|
376
|
+
const schemes = registeredApp()?.urlSchemes;
|
|
377
|
+
if (schemes?.length) env.push(`SB_URL_SCHEMES=${schemes.join(',')}`);
|
|
378
|
+
env.push(`SB_WEB_AUTH_CALLBACK=${callbackPageURL(new URL(import.meta.env.BASE_URL, location.href).href)}`);
|
|
360
379
|
return env;
|
|
361
380
|
}
|
|
362
381
|
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
// Phase 18: SwiftData's stores, kept in IndexedDB
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
/** The app's database, or null where the browser has no IndexedDB (or forbids it). */
|
|
387
|
+
const swiftData: SwiftDataPersistence | null = (() => {
|
|
388
|
+
try {
|
|
389
|
+
return typeof indexedDB === 'undefined' ? null : indexedDBPersistence(requestedApp);
|
|
390
|
+
} catch {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
})();
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Every SwiftData store the app saved, for `SB_SWIFTDATA`. `?swiftdata=reset`
|
|
397
|
+
* deletes them first (a model changed in a way the saved records cannot
|
|
398
|
+
* follow). A database that cannot be read starts the app empty.
|
|
399
|
+
*/
|
|
400
|
+
async function loadSwiftData(): Promise<SwiftDataStores> {
|
|
401
|
+
if (!swiftData) return {};
|
|
402
|
+
try {
|
|
403
|
+
if (params.get('swiftdata') === 'reset') await swiftData.clear();
|
|
404
|
+
return await swiftData.load();
|
|
405
|
+
} catch (error) {
|
|
406
|
+
console.warn('[sb] could not read the SwiftData stores; starting empty', error);
|
|
407
|
+
return {};
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// Phase 19: the app's home directory, kept in IndexedDB
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
const files: FilesPersistence | null = (() => {
|
|
416
|
+
try {
|
|
417
|
+
return typeof indexedDB === 'undefined' ? null : indexedDBFiles(requestedApp);
|
|
418
|
+
} catch {
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
})();
|
|
422
|
+
|
|
423
|
+
/** The home as last written to IndexedDB, to diff the live tree against. */
|
|
424
|
+
let savedHome: HomeEntries = new Map();
|
|
425
|
+
|
|
426
|
+
/** The saved home (`?files=reset` deletes it first); empty when it cannot be read. */
|
|
427
|
+
async function loadHome(): Promise<HomeEntries> {
|
|
428
|
+
if (!files) return new Map();
|
|
429
|
+
try {
|
|
430
|
+
if (params.get('files') === 'reset') await files.clear();
|
|
431
|
+
return await files.load();
|
|
432
|
+
} catch (error) {
|
|
433
|
+
console.warn('[sb] could not read the app\'s files; starting empty', error);
|
|
434
|
+
return new Map();
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Writes what the app changed in its home since the last write. */
|
|
439
|
+
function saveHome(home: ReturnType<typeof homeDirectory>): void {
|
|
440
|
+
const changes = diffHome(savedHome, homeEntries(home));
|
|
441
|
+
if (changes.put.length === 0 && changes.remove.length === 0) return;
|
|
442
|
+
savedHome = applyHomeChanges(savedHome, changes);
|
|
443
|
+
files?.apply(changes).catch((error: unknown) => console.warn('[sb] could not save the app\'s files to IndexedDB', error));
|
|
444
|
+
}
|
|
445
|
+
|
|
363
446
|
/** The app's resources (`/bundle/<App>.json` and its files), for the WASI `/bundle` mount; empty without a manifest. */
|
|
364
447
|
async function loadBundle(app: string): Promise<Map<string, Uint8Array>> {
|
|
365
448
|
const files = new Map<string, Uint8Array>();
|
|
@@ -489,7 +572,35 @@ async function boot(): Promise<void> {
|
|
|
489
572
|
console.info(`[sb] restoring state for ${requestedApp}: ${restored}`);
|
|
490
573
|
}
|
|
491
574
|
const bundle = await loadBundle(requestedApp);
|
|
492
|
-
|
|
575
|
+
const stores = launchValue(await loadSwiftData());
|
|
576
|
+
if (stores !== null) env.push(`SB_SWIFTDATA=${stores}`);
|
|
577
|
+
savedHome = await loadHome();
|
|
578
|
+
const home = homeDirectory(savedHome);
|
|
579
|
+
// The standard directories a new home gets are saved with its first write.
|
|
580
|
+
const corsProxy = registeredApp()?.corsProxy;
|
|
581
|
+
app = await loadApp(url, onOps, {
|
|
582
|
+
args: [requestedApp],
|
|
583
|
+
env,
|
|
584
|
+
bundle,
|
|
585
|
+
home,
|
|
586
|
+
onFilesWritten: () => saveHome(home),
|
|
587
|
+
onDefaults: storeDefaults,
|
|
588
|
+
// `ShareLink` shares asset-catalog images as their files. An app built
|
|
589
|
+
// with a CORS proxy retries a refused cross-origin read through it
|
|
590
|
+
// (corsProxy.ts); one built with blocked hosts never reaches them (blockedHosts.ts).
|
|
591
|
+
requests: {
|
|
592
|
+
...browserEnvironment,
|
|
593
|
+
fetch: withBlockedHosts(
|
|
594
|
+
corsProxy ? withCORSProxy(browserEnvironment.fetch, corsProxy, location.origin) : browserEnvironment.fetch,
|
|
595
|
+
registeredApp()?.blockedHosts ?? [],
|
|
596
|
+
),
|
|
597
|
+
assetURL: (name) => assets.images[name]?.src,
|
|
598
|
+
bitmapFile: (recipe) => bitmapStore.png(recipe),
|
|
599
|
+
},
|
|
600
|
+
onSwiftData: (op) => {
|
|
601
|
+
swiftData?.apply(op).catch((error: unknown) => console.warn('[sb] could not save to IndexedDB', error));
|
|
602
|
+
},
|
|
603
|
+
});
|
|
493
604
|
setLoaded(requestedApp);
|
|
494
605
|
sendEnvironment();
|
|
495
606
|
flushQueuedEvents();
|
|
@@ -527,6 +638,10 @@ declare global {
|
|
|
527
638
|
* module (or mock) has no executor.
|
|
528
639
|
*/
|
|
529
640
|
runJobs: () => number | null;
|
|
641
|
+
/** Phase 19: the app's home as IndexedDB holds it now: path → text of each file (`null` for a directory). */
|
|
642
|
+
files: () => Promise<Record<string, string | null>>;
|
|
643
|
+
/** Phase 18: the app's SwiftData stores as IndexedDB holds them now (all saves written). */
|
|
644
|
+
swiftData: () => Promise<SwiftDataStores>;
|
|
530
645
|
/** The executor driver class, so tests can run it against a stub. */
|
|
531
646
|
JobScheduler: new (
|
|
532
647
|
run: (nowMs: number) => number,
|
|
@@ -550,5 +665,10 @@ window.__sb = {
|
|
|
550
665
|
assets: () => assets,
|
|
551
666
|
animation: { easingFor, timingFor, springEasing },
|
|
552
667
|
runJobs: () => app?.runJobs() ?? null,
|
|
668
|
+
swiftData: async () => (swiftData ? swiftData.load() : {}),
|
|
669
|
+
files: async () => {
|
|
670
|
+
const entries = files ? await files.load() : new Map<string, Uint8Array | null>();
|
|
671
|
+
return Object.fromEntries([...entries].map(([path, data]) => [path, data === null ? null : new TextDecoder().decode(data)]));
|
|
672
|
+
},
|
|
553
673
|
JobScheduler,
|
|
554
674
|
};
|