@swiftbrowser/web 0.1.0 → 0.3.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 +141 -63
- package/app/index.html +21 -10
- 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 +12 -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 +68 -3
- package/src/landing.ts +2 -2
- package/src/largeTitle.ts +79 -0
- package/src/layout/chart.ts +9 -3
- package/src/layout/engine.ts +681 -174
- 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 +66 -6
- package/src/main.ts +127 -4
- package/src/media.ts +132 -0
- package/src/mock.ts +4 -3
- package/src/notifications.ts +109 -0
- package/src/pathShape.ts +156 -0
- package/src/protocol.ts +782 -13
- package/src/renderer.ts +603 -44
- package/src/richText.ts +65 -0
- package/src/scanner.ts +234 -0
- package/src/scrollTo.ts +123 -0
- package/src/styles.css +521 -100
- package/src/styles.ts +61 -0
- package/src/swiftData.ts +128 -0
- package/src/symbols.ts +658 -28
- 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/index.ts
CHANGED
|
@@ -61,6 +61,8 @@ export type {
|
|
|
61
61
|
Size,
|
|
62
62
|
TextAlignment,
|
|
63
63
|
TextLayout,
|
|
64
|
+
TextFragment,
|
|
65
|
+
RichTextLayout,
|
|
64
66
|
TextMeasurer,
|
|
65
67
|
} from './types';
|
|
66
68
|
export { layoutTree, LayoutPass, type Context as LayoutContext } from './engine';
|
|
@@ -88,4 +90,4 @@ export {
|
|
|
88
90
|
snapSliderValue,
|
|
89
91
|
timeInputValue,
|
|
90
92
|
} from './controls';
|
|
91
|
-
export { wrapText, ELLIPSIS, type WrappedText } from './text';
|
|
93
|
+
export { wrapText, wrapRuns, ELLIPSIS, type WrappedText, type WrappedRuns, type TextRunInput } from './text';
|
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,9 @@
|
|
|
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';
|
|
40
|
+
import { bindLargeTitle } from './largeTitle';
|
|
38
41
|
|
|
39
42
|
export interface LayoutDomContext {
|
|
40
43
|
root: HTMLElement;
|
|
@@ -95,8 +98,8 @@ export function applyLayout(ctx: LayoutDomContext, result: LayoutResult): BoxCha
|
|
|
95
98
|
boxes.delete(id);
|
|
96
99
|
continue;
|
|
97
100
|
}
|
|
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);
|
|
101
|
+
const box = drawnBox(node, boxOf(ctx, result, node, frame));
|
|
102
|
+
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
103
|
const relative = { x: box.x - origin.x, y: box.y - origin.y, width: box.width, height: box.height };
|
|
101
104
|
const previous = ctx.boxes.get(id) ?? null;
|
|
102
105
|
boxes.set(id, relative);
|
|
@@ -146,7 +149,7 @@ export function setTabHidden(tab: HTMLElement, hidden: boolean): void {
|
|
|
146
149
|
export function boxOf(ctx: LayoutDomContext, result: LayoutResult, node: LayoutNode, frame: Rect): Rect {
|
|
147
150
|
const row = result.chrome.get(node.id)?.['row'];
|
|
148
151
|
if (row) return row;
|
|
149
|
-
if (node.kind === 'section') return frame;
|
|
152
|
+
if (node.kind === 'section' || isSectionSupplement(node)) return frame;
|
|
150
153
|
const parentId = ctx.parents.get(node.id);
|
|
151
154
|
const parent = parentId !== undefined ? ctx.nodes.get(parentId) : undefined;
|
|
152
155
|
if (!parent || (parent.kind !== 'section' && parent.kind !== 'list')) return frame;
|
|
@@ -156,6 +159,32 @@ export function boxOf(ctx: LayoutDomContext, result: LayoutResult, node: LayoutN
|
|
|
156
159
|
return { x: card.x, y: frame.y - (height - frame.height) / 2, width: card.width, height };
|
|
157
160
|
}
|
|
158
161
|
|
|
162
|
+
/**
|
|
163
|
+
* The box an element is drawn in: its frame, except a circle's, which is the
|
|
164
|
+
* square centered in it (SwiftUI draws the largest circle that fits, so
|
|
165
|
+
* `Circle().frame(width: 110)` in a taller space stays round). The engine's
|
|
166
|
+
* frame, which the siblings were laid out with, is unchanged.
|
|
167
|
+
*/
|
|
168
|
+
function drawnBox(node: LayoutNode, box: Rect): Rect {
|
|
169
|
+
if (node.kind !== 'shape' || node.props['shape'] !== 'circle' || box.width === box.height) return box;
|
|
170
|
+
const side = Math.min(box.width, box.height);
|
|
171
|
+
return { x: box.x + (box.width - side) / 2, y: box.y + (box.height - side) / 2, width: side, height: side };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A section's view header or footer: not a row, and a DOM child of the section itself. */
|
|
175
|
+
function isSectionSupplement(node: LayoutNode): boolean {
|
|
176
|
+
return node.kind === 'sectionheader' || node.kind === 'sectionfooter';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A view header / footer sits in the section element, beside the rows' card: the section's box is its origin. */
|
|
180
|
+
function supplementOrigin(ctx: LayoutDomContext, result: LayoutResult, id: number): { x: number; y: number } {
|
|
181
|
+
const parentId = ctx.parents.get(id);
|
|
182
|
+
const parent = parentId === undefined ? undefined : ctx.nodes.get(parentId);
|
|
183
|
+
const frame = parentId === undefined ? undefined : result.frames.get(parentId);
|
|
184
|
+
if (!parent || !frame || parent.kind !== 'section') return originOf(ctx, result, parentId ?? ROOT_ID);
|
|
185
|
+
return boxOf(ctx, result, parent, frame);
|
|
186
|
+
}
|
|
187
|
+
|
|
159
188
|
/** Screen-coordinate origin that the children of `parentId` are positioned against. */
|
|
160
189
|
export function originOf(ctx: LayoutDomContext, result: LayoutResult, parentId: number): { x: number; y: number } {
|
|
161
190
|
if (parentId === ROOT_ID) return { x: 0, y: 0 };
|
|
@@ -244,6 +273,10 @@ function setTextLayout(el: HTMLElement, layout: TextLayout): void {
|
|
|
244
273
|
setFont(el, layout.font);
|
|
245
274
|
const align = TEXT_ALIGN[layout.alignment] ?? 'left';
|
|
246
275
|
if (el.style.textAlign !== align) el.style.textAlign = align;
|
|
276
|
+
if (layout.rich && el.dataset['sbRich'] !== undefined) {
|
|
277
|
+
drawRichText(el, layout.rich);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
247
280
|
const content = layout.lines.join('\n');
|
|
248
281
|
if (el.textContent !== content) el.textContent = content;
|
|
249
282
|
}
|
|
@@ -275,6 +308,14 @@ function applyBadge(result: LayoutResult, node: LayoutNode, el: HTMLElement, box
|
|
|
275
308
|
}
|
|
276
309
|
}
|
|
277
310
|
|
|
311
|
+
function setFlag(el: HTMLElement, key: string, value: string | null): void {
|
|
312
|
+
if (value === null) {
|
|
313
|
+
if (key in el.dataset) delete el.dataset[key];
|
|
314
|
+
} else if (el.dataset[key] !== value) {
|
|
315
|
+
el.dataset[key] = value;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
278
319
|
/** For leaf rows: pad the content frame back into place inside the row box. */
|
|
279
320
|
function padIntoFrame(el: HTMLElement, box: Rect, frame: Rect): void {
|
|
280
321
|
const s = el.style;
|
|
@@ -298,6 +339,17 @@ function applyChrome(
|
|
|
298
339
|
bottomInset: number,
|
|
299
340
|
): void {
|
|
300
341
|
const chrome = result.chrome.get(node.id);
|
|
342
|
+
// `.listRowSeparator(.hidden)`: a list row leaves out the separator below
|
|
343
|
+
// it, or above it (the previous row's line; styles.css).
|
|
344
|
+
setFlag(el, 'sbSeparatorBottom', chrome?.['separatorBottomHidden'] ? 'hidden' : null);
|
|
345
|
+
setFlag(el, 'sbSeparatorTop', chrome?.['separatorTopHidden'] ? 'hidden' : null);
|
|
346
|
+
// iOS 26: the separator under a list row starts at its first text (styles.css).
|
|
347
|
+
const separator = chrome?.['separator'];
|
|
348
|
+
const leading = separator ? `${separator.x}px` : '';
|
|
349
|
+
if (el.style.getPropertyValue('--sb-separator-leading') !== leading) {
|
|
350
|
+
if (leading) el.style.setProperty('--sb-separator-leading', leading);
|
|
351
|
+
else el.style.removeProperty('--sb-separator-leading');
|
|
352
|
+
}
|
|
301
353
|
switch (node.kind) {
|
|
302
354
|
case 'styled':
|
|
303
355
|
applyBadge(result, node, el, box);
|
|
@@ -315,10 +367,14 @@ function applyChrome(
|
|
|
315
367
|
if (el.style.fontSize !== size) el.style.fontSize = size;
|
|
316
368
|
}
|
|
317
369
|
padIntoFrame(el, box, frame);
|
|
370
|
+
// A bitmap is drawn for the size it is shown at.
|
|
371
|
+
if (node.props['bitmap']) bitmapLaidOut(el, frame.width, frame.height);
|
|
318
372
|
return;
|
|
319
373
|
}
|
|
320
374
|
case 'textfield':
|
|
321
375
|
case 'texteditor':
|
|
376
|
+
// A vertical text field draws in the font its lines were measured in.
|
|
377
|
+
setFont(el, result.fonts.get(node.id));
|
|
322
378
|
padIntoFrame(el, box, frame);
|
|
323
379
|
return;
|
|
324
380
|
// Phase 8 controls: their chrome sits at the engine's rects inside the element's box.
|
|
@@ -406,9 +462,7 @@ function applyChrome(
|
|
|
406
462
|
placeChrome(footer, chrome?.['footer'], frame);
|
|
407
463
|
if (header && texts?.['header']) setTextLayout(header, texts['header']);
|
|
408
464
|
if (footer && texts?.['footer']) {
|
|
409
|
-
// The
|
|
410
|
-
// keep their case, so draw the prop text in the engine's font and
|
|
411
|
-
// rect and let it wrap there.
|
|
465
|
+
// The prop text in the engine's font and rect, wrapped there.
|
|
412
466
|
setFont(footer, texts['footer'].font);
|
|
413
467
|
footer.style.textAlign = 'left';
|
|
414
468
|
footer.style.whiteSpace = 'pre-wrap';
|
|
@@ -425,6 +479,7 @@ function applyChrome(
|
|
|
425
479
|
// Extra scrollable space at the end (the engine's frames are unchanged).
|
|
426
480
|
content.style.height = `${size.height + bottomInset}px`;
|
|
427
481
|
}
|
|
482
|
+
bindLargeTitle(el, chrome?.['largeTitle'] !== undefined);
|
|
428
483
|
return;
|
|
429
484
|
}
|
|
430
485
|
case 'scrollview': {
|
|
@@ -439,6 +494,7 @@ function applyChrome(
|
|
|
439
494
|
s.marginLeft = `${rect ? rect.x - frame.x : 0}px`;
|
|
440
495
|
s.marginTop = `${rect ? rect.y - frame.y : 0}px`;
|
|
441
496
|
}
|
|
497
|
+
bindLargeTitle(el, chrome?.['largeTitle'] !== undefined);
|
|
442
498
|
return;
|
|
443
499
|
}
|
|
444
500
|
case 'navscreen': {
|
|
@@ -493,6 +549,8 @@ function applyChrome(
|
|
|
493
549
|
const card = el.querySelector<HTMLElement>(':scope > .sb-sheet-card');
|
|
494
550
|
const grabber = card?.querySelector<HTMLElement>(':scope > .sb-sheet-grabber') ?? null;
|
|
495
551
|
placeChrome(grabber, chrome?.['grabber'], frame);
|
|
552
|
+
// iOS 26: a sheet at a partial detent floats as a rounded card (styles.css).
|
|
553
|
+
setFlag(el, 'sbFloating', chrome?.['floating'] ? '' : null);
|
|
496
554
|
return;
|
|
497
555
|
}
|
|
498
556
|
case 'tabview': {
|
|
@@ -514,6 +572,8 @@ function applyChrome(
|
|
|
514
572
|
// The track and fill are chrome, positioned from the element's own box.
|
|
515
573
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-gauge-track'), chrome?.['track'], box);
|
|
516
574
|
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-gauge-fill'), chrome?.['fill'], box);
|
|
575
|
+
// A circular style: the ring over the gauge's square (gaugeRing.ts).
|
|
576
|
+
placeChrome(el.querySelector<HTMLElement>(':scope > .sb-gauge-ring'), chrome?.['ring'], box);
|
|
517
577
|
return;
|
|
518
578
|
}
|
|
519
579
|
case 'chart':
|