react-x11 2.30.0 → 2.31.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 +1 -1
- package/package.json +2 -1
- package/src/imagedecode.js +255 -0
- package/src/imagesource.js +20 -15
- package/src/nodes/image.js +44 -45
- package/src/nodes/window/floors.js +76 -15
- package/src/types/elements.d.ts +10 -6
package/README.md
CHANGED
|
@@ -256,7 +256,7 @@ drawn tree. See [docs/elements.md](docs/elements.md) for the full reference.
|
|
|
256
256
|
| `<text>` | shaped, wrapped text (bidi, ligatures, font fallback); nested `<text>` elements are style spans |
|
|
257
257
|
| `<textinput>` | single-line editor: caret/selection, clipboard, word select, undo/redo, right-click menu |
|
|
258
258
|
| `<textarea>` | the same editor, wrapped and multi-line, scrolling with its content |
|
|
259
|
-
| `<image>` | PNG/JPEG from `src
|
|
259
|
+
| `<image>` | PNG/JPEG/WebP from `src` (more formats under Bun), natural-size aware; also pixels the server already has |
|
|
260
260
|
| `<canvas>` | escape hatch: `onDraw={(ctx, {width, height}) => …}` with a canvas-like 2d context (XRender-backed on X11, CoreGraphics on Cocoa) |
|
|
261
261
|
| `<svg>` | static SVG (paths, shapes, gradients, transforms), sized like `<image>` |
|
|
262
262
|
| `<glarea>` | an OpenGL surface in the layout — see [3D](#3d) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-x11",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.31.0",
|
|
4
4
|
"description": "react renderer with X11 as a target",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"files": [
|
|
@@ -102,6 +102,7 @@
|
|
|
102
102
|
},
|
|
103
103
|
"dependencies": {
|
|
104
104
|
"ical.js": "^2.2.1",
|
|
105
|
+
"image-in-browser": "^3.6.0",
|
|
105
106
|
"linebreak": "^1.1.0",
|
|
106
107
|
"ntk": "^8.17.8",
|
|
107
108
|
"react-reconciler": "^0.34.0",
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// Encoded image bytes → an ntk `Image` of straight RGBA, through the best
|
|
2
|
+
// decoder this runtime has. A ladder, tried in order:
|
|
3
|
+
//
|
|
4
|
+
// 1. The runtime's own: `Bun.Image` under Bun. libjpeg-turbo, spng and
|
|
5
|
+
// libwebp linked into the runtime, GIF and BMP everywhere, and TIFF,
|
|
6
|
+
// HEIC and AVIF through the system's codecs on macOS and Windows — all
|
|
7
|
+
// run on a worker thread. That is the reason to prefer it even for
|
|
8
|
+
// the formats the next rung can do: a 1080p JPEG costs the JavaScript
|
|
9
|
+
// thread ~7ms here against ~110ms inside jpeg-js (a 1080p PNG, ~5ms
|
|
10
|
+
// against ~150ms in pngjs), and a decode on the JavaScript thread is
|
|
11
|
+
// frames nobody gets. Found by its constructor, never by asking which
|
|
12
|
+
// runtime this is.
|
|
13
|
+
// 2. JavaScript. ntk's PNG and JPEG decoders (pngjs, jpeg-js), which are
|
|
14
|
+
// synchronous, and WebP through image-in-browser's decoder — imported
|
|
15
|
+
// the first time a WebP arrives, because evaluating it takes ~140ms
|
|
16
|
+
// that an app showing no WebP should never pay at startup.
|
|
17
|
+
//
|
|
18
|
+
// Two things here are easy to undo:
|
|
19
|
+
//
|
|
20
|
+
// - `Bun.Image` has no raw-pixel output: every terminal re-encodes. The
|
|
21
|
+
// cheapest round trip is a PNG at compression level 0 — stored deflate
|
|
22
|
+
// blocks, every row filtered None — which `storedPngImage` unwraps with
|
|
23
|
+
// one native inflate and a row copy. Reading that PNG with pngjs instead
|
|
24
|
+
// costs 80-130ms of the JavaScript thread at 1080p — all of what moving
|
|
25
|
+
// the decode off it saved.
|
|
26
|
+
// - Bun's WebP decode is libwebp's still-image decoder, which refuses an
|
|
27
|
+
// animation outright (Bun 1.4.0). An animated WebP that Bun turns down
|
|
28
|
+
// goes to the JavaScript decoder for its first frame, which is what an
|
|
29
|
+
// `<image>` shows of any animation; asking Bun first keeps a later Bun that
|
|
30
|
+
// can do it on the fast rung.
|
|
31
|
+
import { Image, decodeImage } from 'ntk/image';
|
|
32
|
+
|
|
33
|
+
/** `Bun.Image`, where the runtime has one. Read per call: it is one
|
|
34
|
+
* property lookup, and a cached answer would hide a test's stand-in. */
|
|
35
|
+
function runtimeDecoder() {
|
|
36
|
+
const decoder = globalThis.Bun?.Image;
|
|
37
|
+
return typeof decoder === 'function' ? decoder : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ascii = (bytes, at, text) => {
|
|
41
|
+
for (let i = 0; i < text.length; i++) {
|
|
42
|
+
if (bytes[at + i] !== text.charCodeAt(i)) return false;
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** ISO-BMFF major brands of a HEIF still image; AVIF is told apart first. */
|
|
48
|
+
const HEIF_BRANDS = new Set(['heic', 'heix', 'heim', 'heis', 'mif1', 'msf1']);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The format a buffer's first bytes name, or null. The bytes, never a file
|
|
52
|
+
* extension, say what an image is — which is how Bun and ntk tell too.
|
|
53
|
+
*/
|
|
54
|
+
export function imageFormat(bytes) {
|
|
55
|
+
if (bytes.length >= 8 && ascii(bytes, 1, 'PNG') && bytes[0] === 0x89) {
|
|
56
|
+
return 'png';
|
|
57
|
+
}
|
|
58
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8) {
|
|
59
|
+
return 'jpeg';
|
|
60
|
+
}
|
|
61
|
+
if (
|
|
62
|
+
bytes.length >= 12 &&
|
|
63
|
+
ascii(bytes, 0, 'RIFF') &&
|
|
64
|
+
ascii(bytes, 8, 'WEBP')
|
|
65
|
+
) {
|
|
66
|
+
return 'webp';
|
|
67
|
+
}
|
|
68
|
+
if (bytes.length >= 6 && ascii(bytes, 0, 'GIF8')) return 'gif';
|
|
69
|
+
if (bytes.length >= 14 && ascii(bytes, 0, 'BM')) return 'bmp';
|
|
70
|
+
if (
|
|
71
|
+
bytes.length >= 4 &&
|
|
72
|
+
((ascii(bytes, 0, 'II') && bytes[2] === 42 && bytes[3] === 0) ||
|
|
73
|
+
(ascii(bytes, 0, 'MM') && bytes[2] === 0 && bytes[3] === 42))
|
|
74
|
+
) {
|
|
75
|
+
return 'tiff';
|
|
76
|
+
}
|
|
77
|
+
if (bytes.length >= 12 && ascii(bytes, 4, 'ftyp')) {
|
|
78
|
+
if (ascii(bytes, 8, 'avif') || ascii(bytes, 8, 'avis')) return 'avif';
|
|
79
|
+
const brand = String.fromCharCode(...bytes.subarray(8, 12));
|
|
80
|
+
if (HEIF_BRANDS.has(brand)) return 'heic';
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** An extended-format WebP whose VP8X chunk says it is animated. */
|
|
86
|
+
export function isAnimatedWebP(bytes) {
|
|
87
|
+
return (
|
|
88
|
+
bytes.length >= 21 &&
|
|
89
|
+
ascii(bytes, 0, 'RIFF') &&
|
|
90
|
+
ascii(bytes, 8, 'WEBP') &&
|
|
91
|
+
ascii(bytes, 12, 'VP8X') &&
|
|
92
|
+
(bytes[20] & 0x02) !== 0
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const NAMES = {
|
|
97
|
+
png: 'PNG',
|
|
98
|
+
jpeg: 'JPEG',
|
|
99
|
+
webp: 'WebP',
|
|
100
|
+
gif: 'GIF',
|
|
101
|
+
bmp: 'BMP',
|
|
102
|
+
tiff: 'TIFF',
|
|
103
|
+
heic: 'HEIC',
|
|
104
|
+
avif: 'AVIF',
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const hex = (bytes) =>
|
|
108
|
+
Array.from(bytes.subarray(0, 8), (b) => b.toString(16).padStart(2, '0'))
|
|
109
|
+
.join(' ')
|
|
110
|
+
.trim();
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Decode encoded bytes: an `Image`, or a promise of one where the decode
|
|
114
|
+
* runs off the JavaScript thread (Bun) or its decoder loads on demand (WebP
|
|
115
|
+
* elsewhere). Throws — or rejects — on bytes that are not an image this
|
|
116
|
+
* runtime can decode, with what to do about it.
|
|
117
|
+
*
|
|
118
|
+
* The bytes are read, never written, and under Bun they are read on another
|
|
119
|
+
* thread after this returns: the caller must not change them meanwhile,
|
|
120
|
+
* which `<image>`'s contract (a source is immutable content) already says.
|
|
121
|
+
*/
|
|
122
|
+
export function decodeImageBytes(bytes) {
|
|
123
|
+
if (bytes.length === 0) {
|
|
124
|
+
throw new Error('react-x11: an empty buffer is not an image.');
|
|
125
|
+
}
|
|
126
|
+
const decoder = runtimeDecoder();
|
|
127
|
+
if (decoder) return decodeWithRuntime(decoder, bytes);
|
|
128
|
+
const format = imageFormat(bytes);
|
|
129
|
+
if (format === 'png' || format === 'jpeg') return decodeImage(bytes);
|
|
130
|
+
if (format === 'webp') return decodeWebP(bytes);
|
|
131
|
+
if (format) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`react-x11: ${NAMES[format]} needs a decoder this runtime does not ` +
|
|
134
|
+
'have. Under Node, <image> decodes PNG, JPEG and WebP; under Bun it ' +
|
|
135
|
+
'decodes GIF and BMP too, and TIFF, HEIC and AVIF on macOS and ' +
|
|
136
|
+
'Windows. Convert the image, or run the app with Bun.',
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
throw new Error(
|
|
140
|
+
'react-x11: these bytes are not an image format react-x11 recognizes ' +
|
|
141
|
+
`(PNG, JPEG or WebP; more under Bun) — they begin ${hex(bytes)}.`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Read a file path or file URL and decode it. */
|
|
146
|
+
export async function loadImageFile(path) {
|
|
147
|
+
const { readFile } = await import('node:fs/promises');
|
|
148
|
+
return decodeImageBytes(await readFile(path));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function decodeWithRuntime(BunImage, bytes) {
|
|
152
|
+
// Bun borrows the bytes on its worker thread and refuses a shared or a
|
|
153
|
+
// resizable buffer; those, and only those, it is handed a copy of
|
|
154
|
+
const input =
|
|
155
|
+
bytes.buffer instanceof ArrayBuffer && !bytes.buffer.resizable
|
|
156
|
+
? bytes
|
|
157
|
+
: bytes.slice();
|
|
158
|
+
let png;
|
|
159
|
+
try {
|
|
160
|
+
png = await new BunImage(input).png({ compressionLevel: 0 }).bytes();
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (err?.code === 'ERR_IMAGE_DECODE_FAILED' && isAnimatedWebP(bytes)) {
|
|
163
|
+
return decodeWebP(bytes);
|
|
164
|
+
}
|
|
165
|
+
if (err?.code === 'ERR_IMAGE_FORMAT_UNSUPPORTED') {
|
|
166
|
+
const name = NAMES[imageFormat(bytes)] ?? 'This format';
|
|
167
|
+
throw new Error(
|
|
168
|
+
`react-x11: ${name} has no decoder on this machine. Bun decodes ` +
|
|
169
|
+
"TIFF, HEIC and AVIF through the system's codecs — ImageIO on " +
|
|
170
|
+
'macOS, WIC on Windows (HEIC and AVIF need the HEIF and AV1 ' +
|
|
171
|
+
'extensions from the Microsoft Store) — and Linux has none. ' +
|
|
172
|
+
'Convert the image to PNG, JPEG or WebP.',
|
|
173
|
+
{ cause: err },
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
return storedPngImage(png) ?? decodeImage(png);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const IHDR = 0x49484452;
|
|
182
|
+
const IDAT = 0x49444154;
|
|
183
|
+
const IEND = 0x49454e44;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The pixels of the PNG `Bun.Image` re-encodes into, without pngjs: 8-bit
|
|
187
|
+
* RGBA, not interlaced, every row filtered None — what
|
|
188
|
+
* `png({ compressionLevel: 0 })` writes — read with one native inflate and a
|
|
189
|
+
* row copy. Null for any other PNG, which pngjs reads instead: correct, and
|
|
190
|
+
* slower, so a Bun that changes what it writes degrades rather than breaks.
|
|
191
|
+
*/
|
|
192
|
+
export function storedPngImage(png) {
|
|
193
|
+
const view = new DataView(png.buffer, png.byteOffset, png.byteLength);
|
|
194
|
+
let width = 0;
|
|
195
|
+
let height = 0;
|
|
196
|
+
const chunks = [];
|
|
197
|
+
let total = 0;
|
|
198
|
+
for (let at = 8; at + 12 <= png.length;) {
|
|
199
|
+
const length = view.getUint32(at);
|
|
200
|
+
const type = view.getUint32(at + 4);
|
|
201
|
+
const body = png.subarray(at + 8, at + 8 + length);
|
|
202
|
+
if (type === IHDR) {
|
|
203
|
+
width = view.getUint32(at + 8);
|
|
204
|
+
height = view.getUint32(at + 12);
|
|
205
|
+
// bit depth 8, colour type 6 (RGBA), no interlace
|
|
206
|
+
if (body[8] !== 8 || body[9] !== 6 || body[12] !== 0) return null;
|
|
207
|
+
} else if (type === IDAT) {
|
|
208
|
+
chunks.push(body);
|
|
209
|
+
total += length;
|
|
210
|
+
} else if (type === IEND) {
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
at += 12 + length;
|
|
214
|
+
}
|
|
215
|
+
if (!width || !height || !chunks.length) return null;
|
|
216
|
+
let stream = chunks[0];
|
|
217
|
+
if (chunks.length > 1) {
|
|
218
|
+
stream = new Uint8Array(total);
|
|
219
|
+
let at = 0;
|
|
220
|
+
for (const chunk of chunks) {
|
|
221
|
+
stream.set(chunk, at);
|
|
222
|
+
at += chunk.length;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// windowBits 15: the zlib wrapper PNG puts around its deflate stream
|
|
226
|
+
const rows = globalThis.Bun.inflateSync(stream, { windowBits: 15 });
|
|
227
|
+
const stride = width * 4;
|
|
228
|
+
if (rows.length !== height * (stride + 1)) return null;
|
|
229
|
+
const data = new Uint8Array(height * stride);
|
|
230
|
+
for (let y = 0, from = 0; y < height; y++, from += stride + 1) {
|
|
231
|
+
if (rows[from] !== 0) return null;
|
|
232
|
+
data.set(rows.subarray(from + 1, from + 1 + stride), y * stride);
|
|
233
|
+
}
|
|
234
|
+
return new Image({ width, height, data });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
let webpModule = null;
|
|
238
|
+
|
|
239
|
+
/** A WebP through image-in-browser's decoder; an animation's first frame. */
|
|
240
|
+
async function decodeWebP(bytes) {
|
|
241
|
+
webpModule ??= import('image-in-browser/lib/src/formats/webp-decoder.js');
|
|
242
|
+
const { WebPDecoder } = await webpModule;
|
|
243
|
+
// frame 0 alone: without an index it decodes every frame of an animation
|
|
244
|
+
// and still answers the first
|
|
245
|
+
const decoded = new WebPDecoder().decode({ bytes, frameIndex: 0 });
|
|
246
|
+
if (!decoded) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
'react-x11: these WebP bytes did not decode — the file is damaged or ' +
|
|
249
|
+
'cut short.',
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
// order 0 is ChannelOrder.rgba; a WebP always decodes to four channels
|
|
253
|
+
const data = decoded.getBytes({ order: 0 });
|
|
254
|
+
return new Image({ width: decoded.width, height: decoded.height, data });
|
|
255
|
+
}
|
package/src/imagesource.js
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
// and the `cacheKey` cache live here, where a test needs no server; the node
|
|
7
7
|
// half in nodes/image.js is only lifecycle — when to resolve, when to claim
|
|
8
8
|
// damage, when to let go.
|
|
9
|
-
import { Image
|
|
9
|
+
import { Image } from 'ntk/image';
|
|
10
10
|
|
|
11
|
+
import { decodeImageBytes } from './imagedecode.js';
|
|
11
12
|
import { x11Ntk } from './ntkroot.js';
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -42,7 +43,7 @@ export function isPathImageSource(src) {
|
|
|
42
43
|
);
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
/** …normalized for
|
|
46
|
+
/** …normalized for `readFile`, which wants a string or a real URL. */
|
|
46
47
|
export const toLoadablePath = (src) =>
|
|
47
48
|
typeof src === 'string' || src instanceof URL ? src : new URL(src.href);
|
|
48
49
|
|
|
@@ -139,9 +140,9 @@ const describe = (value) =>
|
|
|
139
140
|
|
|
140
141
|
/** Stated once, so every error lists the same set of accepted forms. */
|
|
141
142
|
const SRC_FORMS =
|
|
142
|
-
'a file path or file URL (PNG/JPEG), encoded PNG/JPEG bytes
|
|
143
|
-
'Uint8Array), raw RGBA ({ width, height, data }), an ntk
|
|
144
|
-
"or a symbol by name ({ symbol: 'speaker.wave.3.fill' })";
|
|
143
|
+
'a file path or file URL (PNG/JPEG/WebP), encoded PNG/JPEG/WebP bytes ' +
|
|
144
|
+
'(Buffer or Uint8Array), raw RGBA ({ width, height, data }), an ntk ' +
|
|
145
|
+
"Image/Surface, or a symbol by name ({ symbol: 'speaker.wave.3.fill' })";
|
|
145
146
|
|
|
146
147
|
function validateServerSource(kind, desc) {
|
|
147
148
|
const shape =
|
|
@@ -198,7 +199,7 @@ function validateRawSource(src) {
|
|
|
198
199
|
'react-x11: <image src={{ width, height, data }}> wants straight ' +
|
|
199
200
|
'(non-premultiplied) RGBA bytes — data.length must be ' +
|
|
200
201
|
`width × height × 4 = ${want}, got ${bytes ?? describe(data)}. ` +
|
|
201
|
-
'For encoded PNG/JPEG bytes pass the buffer itself as `src`.',
|
|
202
|
+
'For encoded PNG/JPEG/WebP bytes pass the buffer itself as `src`.',
|
|
202
203
|
);
|
|
203
204
|
}
|
|
204
205
|
}
|
|
@@ -254,14 +255,17 @@ export function validateImageProps(props) {
|
|
|
254
255
|
}
|
|
255
256
|
|
|
256
257
|
/**
|
|
257
|
-
* Decode
|
|
258
|
-
*
|
|
259
|
-
* as immutable content from here on).
|
|
260
|
-
*
|
|
261
|
-
*
|
|
258
|
+
* Decode an in-memory source into an ntk `Image`: encoded bytes through the
|
|
259
|
+
* decoder ladder in imagedecode.js, raw RGBA wrapped as-is (no copy — the
|
|
260
|
+
* object is treated as immutable content from here on). Returns the `Image`,
|
|
261
|
+
* or a promise of one where the bytes decode off the JavaScript thread
|
|
262
|
+
* (under Bun) or their decoder loads on demand (WebP elsewhere). File paths
|
|
263
|
+
* do not come here. May throw or reject on bytes that do not decode; the
|
|
264
|
+
* caller treats that as a content failure, because encoded bytes usually
|
|
265
|
+
* arrive from outside the program.
|
|
262
266
|
*/
|
|
263
267
|
export function decodeImageSource(src) {
|
|
264
|
-
if (src instanceof Uint8Array) return
|
|
268
|
+
if (src instanceof Uint8Array) return decodeImageBytes(src);
|
|
265
269
|
return new Image({ width: src.width, height: src.height, data: src.data });
|
|
266
270
|
}
|
|
267
271
|
|
|
@@ -317,9 +321,10 @@ const sourceCaches = new WeakMap();
|
|
|
317
321
|
/**
|
|
318
322
|
* Take a hold on the entry for `key`, creating it with `load()` on first
|
|
319
323
|
* use. `load` returns `{ image }` for a synchronous source or `{ promise }`
|
|
320
|
-
* for a file read — the promise resolves an
|
|
321
|
-
* `null` (never rejects; the loader reports
|
|
322
|
-
* holder). Every acquire is paired with a
|
|
324
|
+
* for a file read or a decode that lands later — the promise resolves an
|
|
325
|
+
* `Image` or, on content failure, `null` (never rejects; the loader reports
|
|
326
|
+
* the failure once, not per holder). Every acquire is paired with a
|
|
327
|
+
* `releaseImageSource`.
|
|
323
328
|
*/
|
|
324
329
|
export function acquireImageSource(app, key, load) {
|
|
325
330
|
let cache = sourceCaches.get(app);
|
package/src/nodes/image.js
CHANGED
|
@@ -16,11 +16,7 @@ import {
|
|
|
16
16
|
toLoadablePath,
|
|
17
17
|
validateImageProps,
|
|
18
18
|
} from '../imagesource.js';
|
|
19
|
-
|
|
20
|
-
// `paintcache.js`: a named import of something an older ntk does not export
|
|
21
|
-
// is a *load-time* SyntaxError, which would take the renderer down rather
|
|
22
|
-
// than the one feature that needs it.
|
|
23
|
-
import { loadImage } from 'ntk/image';
|
|
19
|
+
import { loadImageFile } from '../imagedecode.js';
|
|
24
20
|
import { symbolWeight, symbolsFor, warnOnce } from '../symbols.js';
|
|
25
21
|
import { intrinsicSize } from './layout.js';
|
|
26
22
|
import { Node } from './node.js';
|
|
@@ -32,6 +28,18 @@ import { DEV } from './util.js';
|
|
|
32
28
|
* both "nothing changed". */
|
|
33
29
|
const IMAGE_SOURCE_PROPS = new Set(['src', 'picture', 'drawable', 'cacheKey']);
|
|
34
30
|
|
|
31
|
+
const BYTES_FAILED = 'react-x11: <image src> bytes did not decode:';
|
|
32
|
+
|
|
33
|
+
/** A load that never rejects: a failure is logged and resolves null. */
|
|
34
|
+
const reported = (promise, prefix) =>
|
|
35
|
+
promise.then(
|
|
36
|
+
(image) => image,
|
|
37
|
+
(err) => {
|
|
38
|
+
console.error(prefix, err.message);
|
|
39
|
+
return null;
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
|
|
35
43
|
export class ImageNode extends Node {
|
|
36
44
|
constructor(props, app) {
|
|
37
45
|
super('image', props, app);
|
|
@@ -133,7 +141,7 @@ export class ImageNode extends Node {
|
|
|
133
141
|
}
|
|
134
142
|
if (cacheKey != null) {
|
|
135
143
|
const entry = acquireImageSource(this.app, cacheKey, () =>
|
|
136
|
-
this.
|
|
144
|
+
this._load(src),
|
|
137
145
|
);
|
|
138
146
|
this._hold = entry;
|
|
139
147
|
if (entry.image) {
|
|
@@ -148,63 +156,54 @@ export class ImageNode extends Node {
|
|
|
148
156
|
}
|
|
149
157
|
return;
|
|
150
158
|
}
|
|
151
|
-
|
|
152
|
-
this._loadFile(src);
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
const image = this._decode(src);
|
|
159
|
+
const { image, promise } = this._load(src);
|
|
156
160
|
if (image) {
|
|
157
161
|
this._ownedImage = image;
|
|
158
162
|
this.image = image;
|
|
163
|
+
} else if (promise) {
|
|
164
|
+
this._adopt(promise);
|
|
159
165
|
}
|
|
160
166
|
}
|
|
161
167
|
|
|
162
|
-
/**
|
|
163
|
-
*
|
|
164
|
-
|
|
168
|
+
/**
|
|
169
|
+
* `{ image }` for a source decoded on the spot, `{ promise }` for a file
|
|
170
|
+
* read or a decode that lands later — off the JavaScript thread under
|
|
171
|
+
* Bun, or behind a decoder loaded on demand. The promise resolves an
|
|
172
|
+
* Image, or null on a content failure, which is reported once, here: not
|
|
173
|
+
* per node holding the key.
|
|
174
|
+
*/
|
|
175
|
+
_load(src) {
|
|
165
176
|
if (isPathImageSource(src)) {
|
|
166
177
|
return {
|
|
167
|
-
promise:
|
|
168
|
-
(
|
|
169
|
-
|
|
170
|
-
console.error(
|
|
171
|
-
`react-x11: failed to load image ${src}:`,
|
|
172
|
-
err.message,
|
|
173
|
-
);
|
|
174
|
-
return null;
|
|
175
|
-
},
|
|
178
|
+
promise: reported(
|
|
179
|
+
loadImageFile(toLoadablePath(src)),
|
|
180
|
+
`react-x11: failed to load image ${src}:`,
|
|
176
181
|
),
|
|
177
182
|
};
|
|
178
183
|
}
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
_decode(src) {
|
|
184
|
+
let decoded;
|
|
183
185
|
try {
|
|
184
|
-
|
|
186
|
+
decoded = decodeImageSource(src);
|
|
185
187
|
} catch (err) {
|
|
186
188
|
// corrupt or unrecognized bytes are a content failure, not a
|
|
187
189
|
// programming error: log and show nothing, like a missing file
|
|
188
|
-
console.error(
|
|
189
|
-
|
|
190
|
-
err.message,
|
|
191
|
-
);
|
|
192
|
-
return null;
|
|
190
|
+
console.error(BYTES_FAILED, err.message);
|
|
191
|
+
return { image: null };
|
|
193
192
|
}
|
|
193
|
+
return typeof decoded?.then === 'function'
|
|
194
|
+
? { promise: reported(decoded, BYTES_FAILED) }
|
|
195
|
+
: { image: decoded };
|
|
194
196
|
}
|
|
195
197
|
|
|
196
|
-
|
|
198
|
+
/** Take an Image that lands after the frame that asked for it — unless
|
|
199
|
+
* the source changed or the node went first, in which case it was never
|
|
200
|
+
* painted, so nothing was uploaded and there is nothing to free. */
|
|
201
|
+
async _adopt(promise) {
|
|
197
202
|
const token = ++this._loadToken;
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
this._setImage(image);
|
|
203
|
-
} catch (err) {
|
|
204
|
-
if (token === this._loadToken && !this.destroyed) {
|
|
205
|
-
console.error(`react-x11: failed to load image ${src}:`, err.message);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
203
|
+
const image = await promise;
|
|
204
|
+
if (!image || token !== this._loadToken || this.destroyed) return;
|
|
205
|
+
this._ownedImage = image;
|
|
206
|
+
this._setImage(image);
|
|
208
207
|
}
|
|
209
208
|
|
|
210
209
|
/** Adopt pixels that arrived after the frame that asked for them. A size
|
|
@@ -238,7 +237,7 @@ export class ImageNode extends Node {
|
|
|
238
237
|
}
|
|
239
238
|
|
|
240
239
|
_releaseSource() {
|
|
241
|
-
this._loadToken++; // orphan any in-flight
|
|
240
|
+
this._loadToken++; // orphan any in-flight read or decode
|
|
242
241
|
if (this._hold) {
|
|
243
242
|
releaseImageSource(this.app, this._hold);
|
|
244
243
|
this._hold = null;
|
|
@@ -136,11 +136,14 @@ export function receivesFloor(child, axis) {
|
|
|
136
136
|
* A differential run over random trees, laid out with and without the
|
|
137
137
|
* rule, found why each clause is there. An auto basis is yoga's own
|
|
138
138
|
* measurement of the content, which can come out short of what the content
|
|
139
|
-
* needs, and the floor is what holds the item open. A named size that
|
|
140
|
-
* grow
|
|
141
|
-
* floor then
|
|
142
|
-
*
|
|
143
|
-
*
|
|
139
|
+
* needs, and the floor is what holds the item open. A named size that could
|
|
140
|
+
* grow was written as the size it grew to in the measuring pass, which the
|
|
141
|
+
* floor then kept; nothing grows in that pass any more
|
|
142
|
+
* (`setMeasuringShrink`), so its floor is the size itself, but the clause
|
|
143
|
+
* stays until the random trees are run against it again. And a `flexBasis`
|
|
144
|
+
* of its own is where the basis comes from instead of the size, and a
|
|
145
|
+
* percentage one is a different length in a measuring pass than in the real
|
|
146
|
+
* one.
|
|
144
147
|
*/
|
|
145
148
|
function floorIsNamedSize(child, axis) {
|
|
146
149
|
const style = child.style;
|
|
@@ -473,6 +476,17 @@ function markHeightStale(node, root, hit, stop = root) {
|
|
|
473
476
|
* pass has to be told, or every node would shrink to nothing and answer that
|
|
474
477
|
* the content needs no room — which is true of no content anywhere.
|
|
475
478
|
*
|
|
479
|
+
* And nothing grows. A min-content measurement hands out no free space, but
|
|
480
|
+
* a box with a size of its own has some in this pass all the same — a
|
|
481
|
+
* `width: 400` row is 400 wide whatever is offered at the root — and an item
|
|
482
|
+
* that grew into it was read back at the size it grew to: an empty spacer as
|
|
483
|
+
* its share of the row, a box that clips as its share. That share is a fact
|
|
484
|
+
* about the item's siblings, not its content, and the extent is cached as
|
|
485
|
+
* content (`contentSpan`), so it outlived the siblings it came from. A
|
|
486
|
+
* `<Select>`'s trigger is a caption, a growing spacer and a chevron in a row
|
|
487
|
+
* of its own width: the spacer's floor was the room the first caption left
|
|
488
|
+
* over, and a longer caption picked later was elided at the old one's width.
|
|
489
|
+
*
|
|
476
490
|
* Every child of a node being measured is told, so that the node's own
|
|
477
491
|
* layout is the one it always was; below a child whose extent is still
|
|
478
492
|
* good nothing is, since nothing in there is read. `out` collects what was
|
|
@@ -485,8 +499,10 @@ export function setMeasuringShrink(node, axis, out) {
|
|
|
485
499
|
// not read and the floors are not written back through
|
|
486
500
|
if (child.style.display === 'none') continue;
|
|
487
501
|
const shrink = namesOwnFloor(child, axis) ? 1 : 0;
|
|
488
|
-
|
|
502
|
+
const grows = child.style.flexGrow > 0;
|
|
503
|
+
if ((child.style.flexShrink ?? 1) !== shrink || grows) {
|
|
489
504
|
child.yoga.setFlexShrink(shrink);
|
|
505
|
+
if (grows) child.yoga.setFlexGrow(0);
|
|
490
506
|
out.push(child);
|
|
491
507
|
}
|
|
492
508
|
// not into a layout host's children: they are not flex items, and
|
|
@@ -585,22 +601,55 @@ const ACROSS_EDGES = [
|
|
|
585
601
|
Yoga.EDGE_ALL,
|
|
586
602
|
];
|
|
587
603
|
|
|
604
|
+
/** …and a vertical one. */
|
|
605
|
+
const DOWN_EDGES = [
|
|
606
|
+
Yoga.EDGE_TOP,
|
|
607
|
+
Yoga.EDGE_BOTTOM,
|
|
608
|
+
Yoga.EDGE_VERTICAL,
|
|
609
|
+
Yoga.EDGE_ALL,
|
|
610
|
+
];
|
|
611
|
+
|
|
588
612
|
/** Whether an in-flow child of a column is as wide as something other than
|
|
589
613
|
* its content: a width it names, or the column's, which it stretches to
|
|
590
614
|
* unless an alignment or an `auto` margin says otherwise. */
|
|
591
615
|
function widthNotItsOwn(yoga, items) {
|
|
592
616
|
const width = yoga.getWidth().unit;
|
|
593
617
|
if (width === Yoga.UNIT_POINT || width === Yoga.UNIT_PERCENT) return true;
|
|
618
|
+
return stretches(yoga, items, ACROSS_EDGES);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Whether an item is stretched across its container, whose alignment is
|
|
622
|
+
* `items`: unless its own alignment or an `auto` margin on one of `edges`
|
|
623
|
+
* — the ones across the container — says otherwise. A size of its own on
|
|
624
|
+
* that axis is the caller's to rule out. */
|
|
625
|
+
function stretches(yoga, items, edges) {
|
|
594
626
|
const self = yoga.getAlignSelf();
|
|
595
627
|
if ((self === Yoga.ALIGN_AUTO ? items : self) !== Yoga.ALIGN_STRETCH) {
|
|
596
628
|
return false;
|
|
597
629
|
}
|
|
598
|
-
for (const edge of
|
|
630
|
+
for (const edge of edges) {
|
|
599
631
|
if (yoga.getMargin(edge).unit === Yoga.UNIT_AUTO) return false;
|
|
600
632
|
}
|
|
601
633
|
return true;
|
|
602
634
|
}
|
|
603
635
|
|
|
636
|
+
/** Whether `node`'s size along `axis` is its container's cross size rather
|
|
637
|
+
* than anything of its own: an item in flow, stretched, naming no size on
|
|
638
|
+
* that axis. */
|
|
639
|
+
function stretchedAlong(node, axis) {
|
|
640
|
+
const parent = node.parent;
|
|
641
|
+
if (!parent || parent._host !== null || !inFlow(node)) return false;
|
|
642
|
+
if (mainAxisOf(parent) === axis) return false;
|
|
643
|
+
const yoga = node.yoga;
|
|
644
|
+
const size = (axis === 'width' ? yoga.getWidth() : yoga.getHeight()).unit;
|
|
645
|
+
if (size === Yoga.UNIT_POINT || size === Yoga.UNIT_PERCENT) return false;
|
|
646
|
+
return stretches(
|
|
647
|
+
yoga,
|
|
648
|
+
parent.yoga.getAlignItems(),
|
|
649
|
+
axis === 'width' ? ACROSS_EDGES : DOWN_EDGES,
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
604
653
|
/**
|
|
605
654
|
* Where a column's height comes from, down a pass with none on offer: its
|
|
606
655
|
* `'content'`, when it names nothing that bounds it; its `'own'`, when all it
|
|
@@ -637,6 +686,7 @@ function heightFollowsParent(yoga) {
|
|
|
637
686
|
export function restoreShrink(shrunk) {
|
|
638
687
|
for (const child of shrunk) {
|
|
639
688
|
child.yoga.setFlexShrink(child.style.flexShrink ?? 1);
|
|
689
|
+
if (child.style.flexGrow > 0) child.yoga.setFlexGrow(child.style.flexGrow);
|
|
640
690
|
}
|
|
641
691
|
}
|
|
642
692
|
|
|
@@ -804,6 +854,11 @@ export function contentSpan(node, axis, intrinsic, root, read = true) {
|
|
|
804
854
|
}
|
|
805
855
|
// a span nobody reads, and — for a leaf — a measurement nobody needs
|
|
806
856
|
if (!read) return 0;
|
|
857
|
+
const edges =
|
|
858
|
+
yoga.getComputedPadding(startEdge) +
|
|
859
|
+
yoga.getComputedPadding(endEdge) +
|
|
860
|
+
yoga.getComputedBorder(startEdge) +
|
|
861
|
+
yoga.getComputedBorder(endEdge);
|
|
807
862
|
if (start === Infinity) {
|
|
808
863
|
// A leaf: nothing inside to look at, and what the pass did to it may
|
|
809
864
|
// have been a stretch rather than a measurement. So it is asked again.
|
|
@@ -814,7 +869,7 @@ export function contentSpan(node, axis, intrinsic, root, read = true) {
|
|
|
814
869
|
// rather than joining it in a `max`, because a measured leaf's base size
|
|
815
870
|
// is its max-content width — the whole line, unwrapped — and taking the
|
|
816
871
|
// larger of the two would floor every label at the width it would like
|
|
817
|
-
// to be.
|
|
872
|
+
// to be.
|
|
818
873
|
//
|
|
819
874
|
// **Down**, it is what the leaf measured before the collapse squashed
|
|
820
875
|
// it: a height at a settled width is not a leaf's to give.
|
|
@@ -825,15 +880,21 @@ export function contentSpan(node, axis, intrinsic, root, read = true) {
|
|
|
825
880
|
undefined,
|
|
826
881
|
Yoga.MEASURE_MODE_UNDEFINED,
|
|
827
882
|
)?.width;
|
|
828
|
-
|
|
883
|
+
if (measured !== undefined) return measured;
|
|
884
|
+
} else if (node._measureFn) {
|
|
885
|
+
return Math.max(own, intrinsic?.get(node) ?? 0);
|
|
829
886
|
}
|
|
830
|
-
|
|
887
|
+
// A leaf that measures nothing has only its own box to report: padding
|
|
888
|
+
// and border, a basis, a ratio — what the pass laid it out at, now that
|
|
889
|
+
// nothing grows in it (`setMeasuringShrink`) — except where the box was
|
|
890
|
+
// its container's, stretched across it. Then it is the leaf's edges and
|
|
891
|
+
// nothing more: the stretch is as wide as its siblings were, and the
|
|
892
|
+
// extent outlives them. So is what it was laid out at before the
|
|
893
|
+
// collapse, which grew: a spacer in a column of a height of its own was
|
|
894
|
+
// floored at the room the column had left over, whatever later came to
|
|
895
|
+
// need it.
|
|
896
|
+
return stretchedAlong(node, axis) ? edges : own;
|
|
831
897
|
}
|
|
832
|
-
const edges =
|
|
833
|
-
yoga.getComputedPadding(startEdge) +
|
|
834
|
-
yoga.getComputedPadding(endEdge) +
|
|
835
|
-
yoga.getComputedBorder(startEdge) +
|
|
836
|
-
yoga.getComputedBorder(endEdge);
|
|
837
898
|
return end - start + edges;
|
|
838
899
|
}
|
|
839
900
|
|
package/src/types/elements.d.ts
CHANGED
|
@@ -805,9 +805,9 @@ export interface FileUrl {
|
|
|
805
805
|
}
|
|
806
806
|
|
|
807
807
|
/**
|
|
808
|
-
* What `src` accepts: a file path or file URL (PNG
|
|
809
|
-
*
|
|
810
|
-
* symbol by name.
|
|
808
|
+
* What `src` accepts: a file path or file URL, encoded bytes (PNG, JPEG or
|
|
809
|
+
* WebP; GIF, BMP, TIFF, HEIC and AVIF too under Bun), raw RGBA pixels, an
|
|
810
|
+
* ntk `Image`/`Surface`, or a symbol by name.
|
|
811
811
|
*/
|
|
812
812
|
export type ImageSource =
|
|
813
813
|
| string
|
|
@@ -862,9 +862,13 @@ export interface ImageDrawableSource {
|
|
|
862
862
|
|
|
863
863
|
export interface ImageProps extends DrawnProps<DrawnNode> {
|
|
864
864
|
/**
|
|
865
|
-
* Client-side pixels: a file path or file URL
|
|
866
|
-
*
|
|
867
|
-
*
|
|
865
|
+
* Client-side pixels: a file path or file URL, encoded bytes (no temp
|
|
866
|
+
* file), raw RGBA (`{ width, height, data }`), or an ntk
|
|
867
|
+
* `Image`/`Surface` used as-is. Files and bytes are PNG, JPEG or WebP —
|
|
868
|
+
* and under Bun, which decodes them off the JavaScript thread, GIF and BMP
|
|
869
|
+
* too, with TIFF, HEIC and AVIF on macOS and Windows. A file, any format
|
|
870
|
+
* under Bun, and a WebP elsewhere arrive a moment after the first frame:
|
|
871
|
+
* the element takes no room until then, unless styled a size.
|
|
868
872
|
*
|
|
869
873
|
* One source per element: `src`, `picture` and `drawable` are mutually
|
|
870
874
|
* exclusive, and passing two throws.
|