@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
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A bitmap recipe made only of geometry (`place`, `crop`, `render`,
|
|
3
|
+
* `roundedClip`) and `tint`, as one canvas drawing: where the source image
|
|
4
|
+
* lands on the output, which rounded rectangles clip it, and the tint color.
|
|
5
|
+
* The browser then draws it in one `drawImage`, at the output size, instead
|
|
6
|
+
* of src/imageOps.ts running each step over a float raster in JavaScript,
|
|
7
|
+
* which was a quarter of Ice Cubes' main thread at startup (Nuke resizes
|
|
8
|
+
* every avatar with `place` + `render`).
|
|
9
|
+
*
|
|
10
|
+
* The output has the size the raster pipeline gives the same recipe (so the
|
|
11
|
+
* element shows the same resolution); within it, the image is drawn smoothly
|
|
12
|
+
* where the pipeline samples the nearest pixel. Recipes with a filter, or a
|
|
13
|
+
* `clamp` (its edge pixels repeat), are not planned: `planBitmap` answers
|
|
14
|
+
* null and they take the raster pipeline.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { BitmapStep } from './protocol';
|
|
18
|
+
|
|
19
|
+
/** A rectangle in output pixels, rows from the top. */
|
|
20
|
+
export interface PixelRect {
|
|
21
|
+
x: number;
|
|
22
|
+
y: number;
|
|
23
|
+
width: number;
|
|
24
|
+
height: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface BitmapPlan {
|
|
28
|
+
/** Output size in pixels; 0 × 0 is an empty image. */
|
|
29
|
+
width: number;
|
|
30
|
+
height: number;
|
|
31
|
+
/** Where the whole source image is drawn. */
|
|
32
|
+
image: PixelRect;
|
|
33
|
+
/** Rounded rectangles the drawing is clipped to (corner radii in pixels). */
|
|
34
|
+
clips: (PixelRect & { rx: number; ry: number })[];
|
|
35
|
+
/** The image's shape in one color (each pixel's alpha times `a`), when tinted. */
|
|
36
|
+
tint: { r: number; g: number; b: number; a: number } | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A rectangle in Core Image's space: units of source pixels, origin at the bottom left, y up. */
|
|
40
|
+
interface UnitRect {
|
|
41
|
+
x: number;
|
|
42
|
+
y: number;
|
|
43
|
+
w: number;
|
|
44
|
+
h: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The drawing for `steps` over a `sourceWidth` × `sourceHeight` source at
|
|
49
|
+
* `scale` output pixels per source pixel, or null when a step needs pixels
|
|
50
|
+
* (a filter, `clamp`, an op this module does not know).
|
|
51
|
+
*/
|
|
52
|
+
export function planBitmap(steps: readonly BitmapStep[], sourceWidth: number, sourceHeight: number, scale: number): BitmapPlan | null {
|
|
53
|
+
// What the raster pipeline tracks: the rectangle the raster covers, its
|
|
54
|
+
// size in pixels and pixels per unit; and where the image and clips are.
|
|
55
|
+
let extent: UnitRect = { x: 0, y: 0, w: sourceWidth, h: sourceHeight };
|
|
56
|
+
let s = scale;
|
|
57
|
+
let width = Math.max(0, Math.round(sourceWidth * s));
|
|
58
|
+
let height = Math.max(0, Math.round(sourceHeight * s));
|
|
59
|
+
let image: UnitRect = { ...extent };
|
|
60
|
+
let clips: (UnitRect & { rx: number; ry: number })[] = [];
|
|
61
|
+
let tint = null as BitmapPlan['tint'];
|
|
62
|
+
const empty: BitmapPlan = { width: 0, height: 0, image: { x: 0, y: 0, width: 0, height: 0 }, clips: [], tint: null };
|
|
63
|
+
|
|
64
|
+
for (const step of steps) {
|
|
65
|
+
switch (step.op) {
|
|
66
|
+
case 'place': {
|
|
67
|
+
const { sx, sy, tx, ty } = step;
|
|
68
|
+
if (!(sx > 0) || !(sy > 0)) return empty;
|
|
69
|
+
const move = (r: UnitRect): UnitRect => ({ x: r.x * sx + tx, y: r.y * sy + ty, w: r.w * sx, h: r.h * sy });
|
|
70
|
+
extent = move(extent);
|
|
71
|
+
image = move(image);
|
|
72
|
+
clips = clips.map((c) => ({ ...move(c), rx: c.rx * sx, ry: c.ry * sy }));
|
|
73
|
+
if (Math.abs(sx - sy) <= 1e-9 * Math.max(sx, sy)) {
|
|
74
|
+
// A uniform scale relabels the raster: the same pixels.
|
|
75
|
+
s /= sx;
|
|
76
|
+
} else {
|
|
77
|
+
s /= Math.min(sx, sy);
|
|
78
|
+
width = Math.max(0, Math.round(extent.w * s));
|
|
79
|
+
height = Math.max(0, Math.round(extent.h * s));
|
|
80
|
+
}
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
case 'crop': {
|
|
84
|
+
extent = intersect({ x: step.x, y: step.y, w: step.width, h: step.height }, extent);
|
|
85
|
+
width = Math.max(0, Math.round(extent.w * s));
|
|
86
|
+
height = Math.max(0, Math.round(extent.h * s));
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
case 'render': {
|
|
90
|
+
// The raster covers the rectangle (clear outside the image), then its
|
|
91
|
+
// origin moves to 0, 0: everything shifts by the rectangle's origin.
|
|
92
|
+
const dx = -step.x;
|
|
93
|
+
const dy = -step.y;
|
|
94
|
+
extent = { x: 0, y: 0, w: step.width, h: step.height };
|
|
95
|
+
width = Math.max(0, Math.round(step.width * s));
|
|
96
|
+
height = Math.max(0, Math.round(step.height * s));
|
|
97
|
+
image = { ...image, x: image.x + dx, y: image.y + dy };
|
|
98
|
+
clips = clips.map((c) => ({ ...c, x: c.x + dx, y: c.y + dy }));
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
case 'roundedClip': {
|
|
102
|
+
const rx = Math.max(0, Math.min(step.rx, step.width / 2));
|
|
103
|
+
const ry = Math.max(0, Math.min(step.ry, step.height / 2));
|
|
104
|
+
clips.push({ x: step.x, y: step.y, w: step.width, h: step.height, rx, ry });
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
case 'tint': {
|
|
108
|
+
const c = (v: number) => Math.min(1, Math.max(0, Number.isFinite(v) ? v : 0));
|
|
109
|
+
// A second tint recolors the first's shape: its color, both alphas.
|
|
110
|
+
tint = { r: c(step.r), g: c(step.g), b: c(step.b), a: c(step.a) * (tint?.a ?? 1) };
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
default:
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (width === 0 || height === 0 || !(extent.w > 0) || !(extent.h > 0)) return empty;
|
|
118
|
+
|
|
119
|
+
// Units to output pixels: x from the extent's left, y down from its top.
|
|
120
|
+
const kx = width / extent.w;
|
|
121
|
+
const ky = height / extent.h;
|
|
122
|
+
const toPixels = (r: UnitRect): PixelRect => ({
|
|
123
|
+
x: (r.x - extent.x) * kx,
|
|
124
|
+
y: (extent.y + extent.h - (r.y + r.h)) * ky,
|
|
125
|
+
width: r.w * kx,
|
|
126
|
+
height: r.h * ky,
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
width,
|
|
130
|
+
height,
|
|
131
|
+
image: toPixels(image),
|
|
132
|
+
clips: clips.map((c) => ({ ...toPixels(c), rx: c.rx * kx, ry: c.ry * ky })),
|
|
133
|
+
tint,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function intersect(a: UnitRect, b: UnitRect): UnitRect {
|
|
138
|
+
const x0 = Math.max(a.x, b.x);
|
|
139
|
+
const y0 = Math.max(a.y, b.y);
|
|
140
|
+
const x1 = Math.min(a.x + a.w, b.x + b.w);
|
|
141
|
+
const y1 = Math.min(a.y + a.h, b.y + b.h);
|
|
142
|
+
return { x: x0, y: y0, w: Math.max(0, x1 - x0), h: Math.max(0, y1 - y0) };
|
|
143
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bitmap sources Swift sent and how to draw a recipe over one: decoded by
|
|
3
|
+
* the browser, geometry and tints drawn on a canvas (src/bitmapPlan.ts),
|
|
4
|
+
* filters run by the raster pipeline (src/imageOps.ts), a GIF's frames
|
|
5
|
+
* composed by src/gif.ts. No DOM beyond canvases and `ImageData`, so the
|
|
6
|
+
* same store runs in the bitmap worker (src/bitmapWorker.ts) or, where the
|
|
7
|
+
* browser has no worker canvases, on the page (src/bitmaps.ts).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { base64Decode } from './base64';
|
|
11
|
+
import { type BitmapPlan, planBitmap } from './bitmapPlan';
|
|
12
|
+
import { GifDecoder } from './gif';
|
|
13
|
+
import { applySteps, rasterFromRGBA, rasterToRGBA, workingScale } from './imageOps';
|
|
14
|
+
import type { BitmapRecipe, ImageDataOp } from './protocol';
|
|
15
|
+
|
|
16
|
+
/** What src/bitmaps.ts draws with: this store, or the worker running one. */
|
|
17
|
+
export interface Bitmaps {
|
|
18
|
+
/** Keeps an `imagedata` op's source for the rest of the run. */
|
|
19
|
+
add(op: ImageDataOp): void;
|
|
20
|
+
has(key: string): boolean;
|
|
21
|
+
sizeOf(key: string): { width: number; height: number } | undefined;
|
|
22
|
+
/** `recipe` drawn at `scale` raster pixels per source pixel; null for an empty image. */
|
|
23
|
+
render(recipe: BitmapRecipe, scale: number): Promise<RenderedBitmap | null>;
|
|
24
|
+
/** The recipe at full resolution as a PNG file. */
|
|
25
|
+
png(recipe: BitmapRecipe): Promise<Blob | null>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface StoredSource {
|
|
29
|
+
width: number;
|
|
30
|
+
height: number;
|
|
31
|
+
/** An encoded file, decoded on first use. */
|
|
32
|
+
blob?: Blob;
|
|
33
|
+
/** Straight RGBA, for pixel buffers. */
|
|
34
|
+
rgba?: Uint8ClampedArray<ArrayBuffer>;
|
|
35
|
+
decoded?: Promise<CanvasImageSource>;
|
|
36
|
+
/** A GIF file's bytes, for drawing its frames. */
|
|
37
|
+
gifBytes?: Uint8Array;
|
|
38
|
+
/** Its frames, parsed on first use (null: not a GIF the decoder reads). */
|
|
39
|
+
gif?: GifDecoder | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A drawing surface: an `OffscreenCanvas` where the browser has one, else a
|
|
44
|
+
* detached `<canvas>`. `readBack`: its pixels are read with `getImageData`
|
|
45
|
+
* (the raster pipeline), which keeps the canvas in memory instead of on the GPU.
|
|
46
|
+
*/
|
|
47
|
+
function surface(width: number, height: number, readBack = true): { canvas: HTMLCanvasElement | OffscreenCanvas; context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D } {
|
|
48
|
+
const canvas = typeof OffscreenCanvas === 'function' ? new OffscreenCanvas(width, height) : Object.assign(document.createElement('canvas'), { width, height });
|
|
49
|
+
const context = canvas.getContext('2d', { willReadFrequently: readBack }) as CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null;
|
|
50
|
+
if (!context) throw new Error('no 2D canvas context');
|
|
51
|
+
return { canvas, context };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A recipe this store drew: pixels from the raster pipeline, or a canvas the browser drew. */
|
|
55
|
+
export type DrawnBitmap = ImageData | { canvas: HTMLCanvasElement | OffscreenCanvas; width: number; height: number };
|
|
56
|
+
|
|
57
|
+
/** A drawn recipe as src/bitmaps.ts gets it: from this store, or as the worker sends it. */
|
|
58
|
+
export type RenderedBitmap = DrawnBitmap | ImageBitmap;
|
|
59
|
+
|
|
60
|
+
/** Whether `roundRect` takes elliptical corners here (every current browser); else rounded clips take the raster pipeline. */
|
|
61
|
+
const canRoundRect =
|
|
62
|
+
typeof OffscreenCanvasRenderingContext2D === 'function'
|
|
63
|
+
? typeof OffscreenCanvasRenderingContext2D.prototype.roundRect === 'function'
|
|
64
|
+
: typeof CanvasRenderingContext2D === 'function' && typeof CanvasRenderingContext2D.prototype.roundRect === 'function';
|
|
65
|
+
|
|
66
|
+
/** The sources Swift has sent, by key, and how to draw recipes over them. */
|
|
67
|
+
export class BitmapStore implements Bitmaps {
|
|
68
|
+
private sources = new Map<string, StoredSource>();
|
|
69
|
+
|
|
70
|
+
add(op: ImageDataOp): void {
|
|
71
|
+
if (this.sources.has(op.key)) return;
|
|
72
|
+
const width = Math.max(0, op.width | 0);
|
|
73
|
+
const height = Math.max(0, op.height | 0);
|
|
74
|
+
if (op.data !== undefined) {
|
|
75
|
+
const bytes = typeof op.data === 'string' ? base64Decode(op.data) : op.data;
|
|
76
|
+
const source: StoredSource = { width, height, blob: new Blob([bytes as BlobPart], { type: op.mime ?? '' }) };
|
|
77
|
+
if (op.mime === 'image/gif') source.gifBytes = bytes;
|
|
78
|
+
this.sources.set(op.key, source);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const pixels = typeof op.pixels === 'string' || op.pixels === undefined ? base64Decode(op.pixels ?? '') : op.pixels;
|
|
82
|
+
const rgba = new Uint8ClampedArray(width * height * 4);
|
|
83
|
+
if (op.format === 'gray') {
|
|
84
|
+
for (let i = 0; i < width * height; i++) {
|
|
85
|
+
const v = pixels[i] ?? 0;
|
|
86
|
+
rgba[i * 4] = v;
|
|
87
|
+
rgba[i * 4 + 1] = v;
|
|
88
|
+
rgba[i * 4 + 2] = v;
|
|
89
|
+
rgba[i * 4 + 3] = 255;
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
rgba.set(pixels.subarray(0, rgba.length));
|
|
93
|
+
}
|
|
94
|
+
this.sources.set(op.key, { width, height, rgba });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
has(key: string): boolean {
|
|
98
|
+
return this.sources.has(key);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
sizeOf(key: string): { width: number; height: number } | undefined {
|
|
102
|
+
const source = this.sources.get(key);
|
|
103
|
+
return source && { width: source.width, height: source.height };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Frame `frame` of a GIF source, composed; null when the source is not a GIF or has no such frame. */
|
|
107
|
+
private gifFrame(source: StoredSource, frame: number): CanvasImageSource | null {
|
|
108
|
+
if (!source.gifBytes) return null;
|
|
109
|
+
if (source.gif === undefined) source.gif = GifDecoder.parse(source.gifBytes);
|
|
110
|
+
const gif = source.gif;
|
|
111
|
+
const rgba = gif?.frame(frame);
|
|
112
|
+
if (!gif || !rgba) return null;
|
|
113
|
+
const { canvas, context } = surface(gif.width, gif.height);
|
|
114
|
+
context.putImageData(new ImageData(rgba, gif.width, gif.height), 0, 0);
|
|
115
|
+
return canvas;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private decode(source: StoredSource, frame?: number): Promise<CanvasImageSource> {
|
|
119
|
+
if (frame !== undefined) {
|
|
120
|
+
// A GIF's frame; the file as the browser decodes it when the decoder cannot read it.
|
|
121
|
+
const image = this.gifFrame(source, frame);
|
|
122
|
+
if (image) return Promise.resolve(image);
|
|
123
|
+
}
|
|
124
|
+
if (!source.decoded) {
|
|
125
|
+
if (source.blob) {
|
|
126
|
+
// `createImageBitmap` applies a JPEG's EXIF rotation ("from-image"),
|
|
127
|
+
// which the size Swift read from the header already accounts for.
|
|
128
|
+
source.decoded = createImageBitmap(source.blob);
|
|
129
|
+
} else {
|
|
130
|
+
const { canvas, context } = surface(Math.max(1, source.width), Math.max(1, source.height));
|
|
131
|
+
context.putImageData(new ImageData(source.rgba!, Math.max(1, source.width), Math.max(1, source.height)), 0, 0);
|
|
132
|
+
source.decoded = Promise.resolve(canvas);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return source.decoded;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Draws `recipe` at `scale` raster pixels per source pixel: straight RGBA
|
|
140
|
+
* of the result and its size. Null for an empty image; rejects when the
|
|
141
|
+
* source is unknown or the browser cannot decode it (HEIC in Chromium).
|
|
142
|
+
*/
|
|
143
|
+
async render(recipe: BitmapRecipe, scale: number): Promise<DrawnBitmap | null> {
|
|
144
|
+
if (recipe.source === null) return null;
|
|
145
|
+
const source = this.sources.get(recipe.source);
|
|
146
|
+
if (!source) throw new Error(`no image data for ${recipe.source}`);
|
|
147
|
+
const image = await this.decode(source, recipe.frame);
|
|
148
|
+
// Geometry and tints (an image as it is, or resized as Nuke does): one
|
|
149
|
+
// drawing by the browser (src/bitmapPlan.ts). Filters: the raster pipeline.
|
|
150
|
+
const plan = planBitmap(recipe.steps, source.width, source.height, scale);
|
|
151
|
+
if (plan && (plan.clips.length === 0 || canRoundRect)) return drawPlan(plan, image, !source.rgba);
|
|
152
|
+
const width = Math.max(1, Math.round(source.width * scale));
|
|
153
|
+
const height = Math.max(1, Math.round(source.height * scale));
|
|
154
|
+
const { context } = surface(width, height);
|
|
155
|
+
// A pixel buffer (a QR code's modules) is scaled without smoothing.
|
|
156
|
+
context.imageSmoothingEnabled = !source.rgba;
|
|
157
|
+
context.imageSmoothingQuality = 'high';
|
|
158
|
+
context.drawImage(image, 0, 0, width, height);
|
|
159
|
+
const pixels = context.getImageData(0, 0, width, height).data;
|
|
160
|
+
const raster = rasterFromRGBA(pixels, width, height, { x: 0, y: 0, width: source.width, height: source.height }, scale);
|
|
161
|
+
const result = applySteps(raster, recipe.steps);
|
|
162
|
+
if (result.width === 0 || result.height === 0) return null;
|
|
163
|
+
return new ImageData(rasterToRGBA(result), result.width, result.height);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The recipe at full resolution (as far as `MAX_WORKING_PIXELS` allows) as a PNG file. */
|
|
167
|
+
async png(recipe: BitmapRecipe): Promise<Blob | null> {
|
|
168
|
+
if (recipe.source === null || !this.sources.has(recipe.source)) return null;
|
|
169
|
+
const size = this.sizeOf(recipe.source)!;
|
|
170
|
+
const scale = workingScale(recipe.width, recipe.height, recipe.width, recipe.height, size.width * size.height);
|
|
171
|
+
const image = await this.render(recipe, scale);
|
|
172
|
+
if (!image) return null;
|
|
173
|
+
let canvas: HTMLCanvasElement | OffscreenCanvas;
|
|
174
|
+
if (image instanceof ImageData) {
|
|
175
|
+
const drawn = surface(image.width, image.height);
|
|
176
|
+
drawn.context.putImageData(image, 0, 0);
|
|
177
|
+
canvas = drawn.canvas;
|
|
178
|
+
} else {
|
|
179
|
+
canvas = image.canvas;
|
|
180
|
+
}
|
|
181
|
+
if ('convertToBlob' in canvas) return canvas.convertToBlob({ type: 'image/png' });
|
|
182
|
+
return new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Draws a plan: clipped to its rounded rectangles, the image smoothed (unless it is a pixel buffer), then tinted. */
|
|
187
|
+
function drawPlan(plan: BitmapPlan, image: CanvasImageSource, smooth: boolean): DrawnBitmap | null {
|
|
188
|
+
if (plan.width === 0 || plan.height === 0) return null;
|
|
189
|
+
const { canvas, context } = surface(plan.width, plan.height, false);
|
|
190
|
+
for (const clip of plan.clips) {
|
|
191
|
+
// Each clip narrows the last, as the raster pipeline's coverages multiply.
|
|
192
|
+
context.beginPath();
|
|
193
|
+
context.roundRect(clip.x, clip.y, clip.width, clip.height, [{ x: clip.rx, y: clip.ry }]);
|
|
194
|
+
context.clip();
|
|
195
|
+
}
|
|
196
|
+
context.imageSmoothingEnabled = smooth;
|
|
197
|
+
context.imageSmoothingQuality = 'high';
|
|
198
|
+
context.drawImage(image, plan.image.x, plan.image.y, plan.image.width, plan.image.height);
|
|
199
|
+
if (plan.tint) {
|
|
200
|
+
const { r, g, b, a } = plan.tint;
|
|
201
|
+
context.globalCompositeOperation = 'source-in';
|
|
202
|
+
context.fillStyle = `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${a})`;
|
|
203
|
+
context.fillRect(0, 0, plan.width, plan.height);
|
|
204
|
+
}
|
|
205
|
+
return { canvas, width: plan.width, height: plan.height };
|
|
206
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bitmap worker: a src/bitmapStore.ts store off the page's thread. The
|
|
3
|
+
* page sends each `imagedata` op and each recipe to draw (src/bitmaps.ts);
|
|
4
|
+
* the worker decodes the source, draws the recipe and answers with an
|
|
5
|
+
* `ImageBitmap` the page's canvas takes without copying it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { BitmapStore, type DrawnBitmap } from './bitmapStore';
|
|
9
|
+
import type { BitmapWorkerReply, BitmapWorkerRequest } from './bitmapWorkerProtocol';
|
|
10
|
+
|
|
11
|
+
const store = new BitmapStore();
|
|
12
|
+
const scope = self as unknown as {
|
|
13
|
+
onmessage: ((event: MessageEvent<BitmapWorkerRequest>) => void) | null;
|
|
14
|
+
postMessage(message: BitmapWorkerReply, transfer?: Transferable[]): void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** A drawing as a transferable bitmap. */
|
|
18
|
+
async function toImageBitmap(drawn: DrawnBitmap): Promise<ImageBitmap> {
|
|
19
|
+
if (drawn instanceof ImageData) return createImageBitmap(drawn);
|
|
20
|
+
const canvas = drawn.canvas as OffscreenCanvas;
|
|
21
|
+
return canvas.transferToImageBitmap();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
scope.onmessage = (event) => {
|
|
25
|
+
const request = event.data;
|
|
26
|
+
if (request.type === 'add') {
|
|
27
|
+
store.add(request.op);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const answer = async (): Promise<ImageBitmap | Blob | null> => {
|
|
31
|
+
if (request.type === 'png') return store.png(request.recipe);
|
|
32
|
+
const drawn = await store.render(request.recipe, request.scale);
|
|
33
|
+
return drawn && toImageBitmap(drawn);
|
|
34
|
+
};
|
|
35
|
+
answer().then(
|
|
36
|
+
(value) => scope.postMessage({ id: request.id, ok: true, value }, value instanceof ImageBitmap ? [value] : []),
|
|
37
|
+
(error: unknown) => scope.postMessage({ id: request.id, ok: false, error: error instanceof Error ? error.message : String(error) }),
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
scope.postMessage({ ready: true });
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Messages between the page (src/bitmaps.ts) and the bitmap worker (src/bitmapWorker.ts). */
|
|
2
|
+
|
|
3
|
+
import type { BitmapRecipe, ImageDataOp } from './protocol';
|
|
4
|
+
|
|
5
|
+
export type BitmapWorkerRequest =
|
|
6
|
+
| { type: 'add'; op: ImageDataOp }
|
|
7
|
+
| { type: 'render'; id: number; recipe: BitmapRecipe; scale: number }
|
|
8
|
+
| { type: 'png'; id: number; recipe: BitmapRecipe };
|
|
9
|
+
|
|
10
|
+
/** The answer to a `render` (an `ImageBitmap`, transferred) or a `png` (a `Blob`); null for an empty image. */
|
|
11
|
+
export type BitmapWorkerReply =
|
|
12
|
+
| { id: number; ok: true; value: ImageBitmap | Blob | null }
|
|
13
|
+
| { id: number; ok: false; error: string }
|
|
14
|
+
/** Sent once, when the worker has started. */
|
|
15
|
+
| { ready: true };
|
package/src/bitmaps.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `UIImage` / `CGImage` bitmaps (`Image(uiImage:)`) on the page. Swift sends
|
|
3
|
+
* each bitmap's source once (an `imagedata` op: an image file, or a pixel
|
|
4
|
+
* buffer such as a QR code) and every `image` element that shows a bitmap
|
|
5
|
+
* carries its recipe: the source's key and the Core Image steps to run
|
|
6
|
+
* (docs/ops-protocol.md, "Bitmaps and Core Image"). A store keeps the
|
|
7
|
+
* sources and draws recipes (src/bitmapStore.ts); this module puts each
|
|
8
|
+
* drawing, at the size the image is shown, on the element's `<canvas>`.
|
|
9
|
+
*
|
|
10
|
+
* The store runs in a worker (src/bitmapWorker.ts) where the browser has
|
|
11
|
+
* canvases there (`OffscreenCanvas`), so decoding, resizing and filters
|
|
12
|
+
* leave the page's thread free for Swift; the drawing comes back as an
|
|
13
|
+
* `ImageBitmap` the canvas shows without a copy. Elsewhere, or with
|
|
14
|
+
* `?bitmaps=main` in the page's URL, the store runs on the page.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { BitmapStore, type Bitmaps, type RenderedBitmap } from './bitmapStore';
|
|
18
|
+
import type { BitmapWorkerReply, BitmapWorkerRequest } from './bitmapWorkerProtocol';
|
|
19
|
+
import { workingScale } from './imageOps';
|
|
20
|
+
import type { BitmapRecipe, ImageDataOp } from './protocol';
|
|
21
|
+
|
|
22
|
+
export { BitmapStore } from './bitmapStore';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The store in the worker. Sources are sent as they arrive; until the worker
|
|
26
|
+
* says it is running they are also kept here, so that a worker that cannot
|
|
27
|
+
* start (a blocked script, no canvases there after all) hands everything to
|
|
28
|
+
* a store on the page instead.
|
|
29
|
+
*/
|
|
30
|
+
class WorkerBitmaps implements Bitmaps {
|
|
31
|
+
private sizes = new Map<string, { width: number; height: number }>();
|
|
32
|
+
private pending = new Map<number, { resolve: (value: ImageBitmap | Blob | null) => void; reject: (error: Error) => void }>();
|
|
33
|
+
private nextId = 1;
|
|
34
|
+
private worker: Worker | null;
|
|
35
|
+
/** Sources sent before the worker started (null once it has). */
|
|
36
|
+
private early: ImageDataOp[] | null = [];
|
|
37
|
+
/** The store on the page, when the worker failed to start. */
|
|
38
|
+
private fallback: BitmapStore | null = null;
|
|
39
|
+
|
|
40
|
+
constructor(worker: Worker) {
|
|
41
|
+
this.worker = worker;
|
|
42
|
+
worker.onmessage = (event: MessageEvent<BitmapWorkerReply>) => {
|
|
43
|
+
const reply = event.data;
|
|
44
|
+
if ('ready' in reply) {
|
|
45
|
+
this.early = null;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const request = this.pending.get(reply.id);
|
|
49
|
+
if (!request) return;
|
|
50
|
+
this.pending.delete(reply.id);
|
|
51
|
+
if (reply.ok) request.resolve(reply.value);
|
|
52
|
+
else request.reject(new Error(reply.error));
|
|
53
|
+
};
|
|
54
|
+
worker.onerror = (event) => {
|
|
55
|
+
event.preventDefault();
|
|
56
|
+
if (this.early === null) return; // a running worker reports its errors per request
|
|
57
|
+
console.warn(`[swiftbrowser] the bitmap worker did not start (${event.message || 'error'}); drawing bitmaps on the page`);
|
|
58
|
+
this.useFallback();
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private useFallback(): void {
|
|
63
|
+
const store = new BitmapStore();
|
|
64
|
+
for (const op of this.early ?? []) store.add(op);
|
|
65
|
+
this.early = null;
|
|
66
|
+
this.fallback = store;
|
|
67
|
+
this.worker?.terminate();
|
|
68
|
+
this.worker = null;
|
|
69
|
+
const failed = [...this.pending.values()];
|
|
70
|
+
this.pending.clear();
|
|
71
|
+
for (const request of failed) request.reject(new Error('the bitmap worker stopped'));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private send(request: BitmapWorkerRequest): void {
|
|
75
|
+
this.worker!.postMessage(request);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private ask(request: { type: 'render'; recipe: BitmapRecipe; scale: number } | { type: 'png'; recipe: BitmapRecipe }): Promise<ImageBitmap | Blob | null> {
|
|
79
|
+
const id = this.nextId++;
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
this.pending.set(id, { resolve, reject });
|
|
82
|
+
this.send({ ...request, id });
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
add(op: ImageDataOp): void {
|
|
87
|
+
if (this.sizes.has(op.key)) return;
|
|
88
|
+
this.sizes.set(op.key, { width: Math.max(0, op.width | 0), height: Math.max(0, op.height | 0) });
|
|
89
|
+
if (this.fallback) {
|
|
90
|
+
this.fallback.add(op);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (this.early) {
|
|
94
|
+
this.early.push(op);
|
|
95
|
+
this.send({ type: 'add', op });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// The bytes from linear memory are this op's own copy: moved, not copied.
|
|
99
|
+
const bytes = op.data ?? op.pixels;
|
|
100
|
+
this.worker!.postMessage({ type: 'add', op } satisfies BitmapWorkerRequest, typeof bytes === 'object' ? [bytes.buffer] : []);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
has(key: string): boolean {
|
|
104
|
+
return this.sizes.has(key);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
sizeOf(key: string): { width: number; height: number } | undefined {
|
|
108
|
+
return this.sizes.get(key);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async render(recipe: BitmapRecipe, scale: number): Promise<RenderedBitmap | null> {
|
|
112
|
+
if (recipe.source === null) return null;
|
|
113
|
+
if (this.fallback) return this.fallback.render(recipe, scale);
|
|
114
|
+
if (!this.sizes.has(recipe.source)) throw new Error(`no image data for ${recipe.source}`);
|
|
115
|
+
try {
|
|
116
|
+
return (await this.ask({ type: 'render', recipe, scale })) as ImageBitmap | null;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
// The worker failed to start while drawing: draw on the page.
|
|
119
|
+
const fallback = this.fallback as BitmapStore | null;
|
|
120
|
+
if (fallback) return fallback.render(recipe, scale);
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async png(recipe: BitmapRecipe): Promise<Blob | null> {
|
|
126
|
+
if (recipe.source === null || !this.sizes.has(recipe.source)) return null;
|
|
127
|
+
if (this.fallback) return this.fallback.png(recipe);
|
|
128
|
+
return (await this.ask({ type: 'png', recipe })) as Blob | null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Whether to draw in a worker: the browser has worker canvases, and the page did not ask otherwise. */
|
|
133
|
+
function wantsWorker(): boolean {
|
|
134
|
+
if (typeof Worker !== 'function' || typeof OffscreenCanvas !== 'function' || typeof createImageBitmap !== 'function') return false;
|
|
135
|
+
try {
|
|
136
|
+
return new URLSearchParams(location.search).get('bitmaps') !== 'main';
|
|
137
|
+
} catch {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function makeBitmaps(): Bitmaps {
|
|
143
|
+
if (!wantsWorker()) return new BitmapStore();
|
|
144
|
+
try {
|
|
145
|
+
return new WorkerBitmaps(new Worker(new URL('./bitmapWorker.ts', import.meta.url), { type: 'module', name: 'bitmaps' }));
|
|
146
|
+
} catch {
|
|
147
|
+
return new BitmapStore();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The page's bitmaps: one store, as every source is named by its content. */
|
|
152
|
+
export const bitmapStore: Bitmaps = makeBitmaps();
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* One `<canvas>` showing a recipe. It draws once both the recipe and the
|
|
156
|
+
* laid-out size are known, again when either changes enough to matter, and
|
|
157
|
+
* never two drawings at once: a request that arrives during a drawing waits
|
|
158
|
+
* for it and takes the newest values. Until a drawing finishes the canvas
|
|
159
|
+
* keeps the previous picture, so a slider dragging a filter never blanks it.
|
|
160
|
+
*/
|
|
161
|
+
class BitmapView {
|
|
162
|
+
private recipe: BitmapRecipe | null = null;
|
|
163
|
+
private recipeKey = '';
|
|
164
|
+
private boxWidth = 0;
|
|
165
|
+
private boxHeight = 0;
|
|
166
|
+
private drawn = '';
|
|
167
|
+
private busy = false;
|
|
168
|
+
private again = false;
|
|
169
|
+
|
|
170
|
+
constructor(
|
|
171
|
+
private readonly canvas: HTMLCanvasElement,
|
|
172
|
+
private readonly store: Bitmaps,
|
|
173
|
+
) {}
|
|
174
|
+
|
|
175
|
+
setRecipe(recipe: BitmapRecipe): void {
|
|
176
|
+
const key = JSON.stringify(recipe);
|
|
177
|
+
if (key === this.recipeKey) return;
|
|
178
|
+
this.recipe = recipe;
|
|
179
|
+
this.recipeKey = key;
|
|
180
|
+
this.update();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
setBox(width: number, height: number): void {
|
|
184
|
+
if (width === this.boxWidth && height === this.boxHeight) return;
|
|
185
|
+
this.boxWidth = width;
|
|
186
|
+
this.boxHeight = height;
|
|
187
|
+
this.update();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private update(): void {
|
|
191
|
+
const recipe = this.recipe;
|
|
192
|
+
if (!recipe) return;
|
|
193
|
+
if (this.busy) {
|
|
194
|
+
this.again = true;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const source = recipe.source === null ? undefined : this.store.sizeOf(recipe.source);
|
|
198
|
+
// Before layout, draw for the image's own size; layout refines it.
|
|
199
|
+
const ratio = typeof devicePixelRatio === 'number' && devicePixelRatio > 0 ? devicePixelRatio : 1;
|
|
200
|
+
const width = this.boxWidth > 0 ? this.boxWidth * ratio : recipe.width;
|
|
201
|
+
const height = this.boxHeight > 0 ? this.boxHeight * ratio : recipe.height;
|
|
202
|
+
const scale = workingScale(recipe.width, recipe.height, width, height, source ? source.width * source.height : 0);
|
|
203
|
+
const key = `${this.recipeKey}@${scale}`;
|
|
204
|
+
if (key === this.drawn) return;
|
|
205
|
+
this.busy = true;
|
|
206
|
+
this.store
|
|
207
|
+
.render(recipe, scale)
|
|
208
|
+
.then(
|
|
209
|
+
(image) => {
|
|
210
|
+
this.drawn = key;
|
|
211
|
+
this.paint(image);
|
|
212
|
+
this.canvas.dataset['sbBitmap'] = 'ready';
|
|
213
|
+
},
|
|
214
|
+
(error: unknown) => {
|
|
215
|
+
this.drawn = key;
|
|
216
|
+
this.paint(null);
|
|
217
|
+
this.canvas.dataset['sbBitmap'] = 'error';
|
|
218
|
+
console.warn(`[swiftbrowser] could not draw a bitmap: ${error instanceof Error ? error.message : String(error)}`);
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
.finally(() => {
|
|
222
|
+
this.busy = false;
|
|
223
|
+
this.canvas.dispatchEvent(new CustomEvent('sb:bitmap', { bubbles: true }));
|
|
224
|
+
if (this.again) {
|
|
225
|
+
this.again = false;
|
|
226
|
+
this.update();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private paint(image: RenderedBitmap | null): void {
|
|
232
|
+
const canvas = this.canvas;
|
|
233
|
+
if (!image) {
|
|
234
|
+
canvas.width = 0;
|
|
235
|
+
canvas.height = 0;
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (canvas.width !== image.width) canvas.width = image.width;
|
|
239
|
+
if (canvas.height !== image.height) canvas.height = image.height;
|
|
240
|
+
if (isImageBitmap(image)) {
|
|
241
|
+
// From the worker: handed to the canvas as it is, without a copy.
|
|
242
|
+
const renderer = canvas.getContext('bitmaprenderer');
|
|
243
|
+
if (renderer) {
|
|
244
|
+
renderer.transferFromImageBitmap(image);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
canvas.getContext('2d')?.drawImage(image, 0, 0);
|
|
248
|
+
image.close();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const context = canvas.getContext('2d');
|
|
252
|
+
if (!context) return;
|
|
253
|
+
if (image instanceof ImageData) {
|
|
254
|
+
context.putImageData(image, 0, 0);
|
|
255
|
+
} else {
|
|
256
|
+
context.clearRect(0, 0, image.width, image.height);
|
|
257
|
+
context.drawImage(image.canvas, 0, 0);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function isImageBitmap(image: RenderedBitmap): image is ImageBitmap {
|
|
263
|
+
return typeof ImageBitmap === 'function' && image instanceof ImageBitmap;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const views = new WeakMap<HTMLCanvasElement, BitmapView>();
|
|
267
|
+
|
|
268
|
+
/** Shows `recipe` on `canvas` (drawn once the canvas is laid out). */
|
|
269
|
+
export function showBitmap(canvas: HTMLCanvasElement, recipe: BitmapRecipe, store: Bitmaps = bitmapStore): void {
|
|
270
|
+
let view = views.get(canvas);
|
|
271
|
+
if (!view) {
|
|
272
|
+
view = new BitmapView(canvas, store);
|
|
273
|
+
views.set(canvas, view);
|
|
274
|
+
}
|
|
275
|
+
view.setRecipe(recipe);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The layout engine placed the `image` element `el` at `width` × `height` points. */
|
|
279
|
+
export function bitmapLaidOut(el: HTMLElement, width: number, height: number): void {
|
|
280
|
+
const canvas = el.firstElementChild;
|
|
281
|
+
if (!(canvas instanceof HTMLCanvasElement)) return;
|
|
282
|
+
views.get(canvas)?.setBox(width, height);
|
|
283
|
+
}
|