react-x11 2.32.0 → 2.33.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.
@@ -0,0 +1,641 @@
1
+ // <video>: frames an application decodes (`frames`, a VideoFrames sink), or a
2
+ // file or URL the platform plays (`src`, where there is a player), fitted
3
+ // into the node's box (docs/architecture/video.md §6–7).
4
+ //
5
+ // A drawn node like `<image>`: it joins yoga, it is in the paint order and it
6
+ // is hit like anything else — which is the whole point. A video in a form
7
+ // sits under the form's popups and tooltips and inside its scroll panes, and
8
+ // a child window or a `<glarea>`'s layer can do neither.
9
+ //
10
+ // It is presented two ways, decided per frame:
11
+ //
12
+ // - **lifted** — it offers its picture as its one sprite part (`sprites()`),
13
+ // with the sink as the part's `contents`; a presenter that lifts sprites
14
+ // (the Cocoa surface presenter, over a bridge with video surfaces) puts
15
+ // the frames on a layer of their own, written by `push` itself, and this
16
+ // node leaves a hole where the picture is. No react-x11 frame per video
17
+ // frame.
18
+ // - **drawn** — everywhere else, and in every frame the presenter declines
19
+ // the part (something is painted over it, it fades, a rounded card does
20
+ // not hold it): the newest frame is copied into a surface of the node's
21
+ // own at paint, natively where the backend can (`app.writeVideoFrame`) and
22
+ // through `frameToRGBA` where it cannot, and composited at the picture's
23
+ // rect like an `<image>` whose pixels changed. A push claims the picture.
24
+ //
25
+ // `src` needs a platform player, which this backend has when
26
+ // `useSupports('mediaPlayback')` says so — the app's `createPlayer`, on macOS
27
+ // AVFoundation's (src/cocoa/player.js). The player is the part's `contents`
28
+ // the way a sink is: lifted, its own layer shows the picture; drawn, the
29
+ // frame showing is copied into the node's surface (`copyFrame`) for as long
30
+ // as it plays. Where there is no player the element says so once, through
31
+ // `onError`, and shows its poster — never a black box that looks like a
32
+ // video that has not started (#531's rule for `<foreign>`).
33
+
34
+ import { loadImageFile } from '../imagedecode.js';
35
+ import {
36
+ decodeImageSource,
37
+ freeImage,
38
+ isDirectImageSource,
39
+ isPathImageSource,
40
+ toLoadablePath,
41
+ } from '../imagesource.js';
42
+ import { canPlayMedia, NoMediaPlaybackError } from '../mediaplayback.js';
43
+ import { Surface } from '../offscreen.js';
44
+ import { frameToRGBA, isVideoFrames } from '../videoframes.js';
45
+ import { fitRect, objectFitOf } from './fit.js';
46
+ import { intrinsicSize } from './layout.js';
47
+ import { Node } from './node.js';
48
+ import { DEV } from './util.js';
49
+
50
+ // HTML's size for a video that has nothing to say about its own
51
+ const DEFAULT_WIDTH = 300;
52
+ const DEFAULT_HEIGHT = 150;
53
+
54
+ // How often a drawn player's frame is asked for while it plays: a display
55
+ // frame, since the copy answers null for one that has not changed and the
56
+ // claim follows only a new one.
57
+ const PULL_MS = 16;
58
+
59
+ // The player's settings among a `<video>`'s props, as the player takes them.
60
+ const PLAYER_PROPS = ['paused', 'muted', 'volume', 'loop', 'playbackRate'];
61
+
62
+ // Apps already told, by a <video src> with no `onError`, that this backend
63
+ // plays nothing: the answer is the backend's, so the second element to ask
64
+ // carries no news.
65
+ const warnedNoPlayback = new WeakSet();
66
+
67
+ /** Throws for a combination no `<video>` can be. */
68
+ export function validateVideoProps(props) {
69
+ if (props.frames != null && props.src != null) {
70
+ throw new Error(
71
+ 'react-x11: <video> takes frames or src, not both — frames are ' +
72
+ 'decoded by the application, src by the platform',
73
+ );
74
+ }
75
+ if (props.frames != null && !isVideoFrames(props.frames)) {
76
+ throw new Error(
77
+ 'react-x11: <video frames> must be a VideoFrames sink — from ' +
78
+ 'useVideoFrames({ width, height, format }) or createVideoFrames(app, …)',
79
+ );
80
+ }
81
+ }
82
+
83
+ export class VideoNode extends Node {
84
+ constructor(props, app) {
85
+ super('video', props, app);
86
+ validateVideoProps(props);
87
+ this.focusableByDefault = false;
88
+ /** the sink being shown, subscribed to from the first layout on */
89
+ this._sink = null;
90
+ this._unsubscribe = null;
91
+ /** whether the presenter has the picture on a layer (`spritesLifted`) */
92
+ this._lifted = false;
93
+ /** the drawn presentation: a surface at the frame's size, and which
94
+ * frame it holds */
95
+ this._surface = null;
96
+ this._drawn = -1;
97
+ this._rgba = null;
98
+ /** the poster, once loaded or decoded */
99
+ this._poster = null;
100
+ this._ownedPoster = null;
101
+ this._posterToken = 0;
102
+ this._refused = false;
103
+ /** `<video src>`'s player, from the first layout on, and what the drawn
104
+ * presentation has of it: whether a frame is in the surface, and the
105
+ * timer that asks for the next one while it plays */
106
+ this._player = null;
107
+ this._playerOff = null;
108
+ this._playerFrame = false;
109
+ this._pull = null;
110
+ // Like `<image>`, nothing is resolved in the constructor: it runs in the
111
+ // render phase, which React may discard, and a subscription made there
112
+ // would never be undone.
113
+ this._dirty = true;
114
+ }
115
+
116
+ // --- the source -------------------------------------------------------------
117
+
118
+ /** First layout or paint after a mount or a change: both after commit. */
119
+ _ensure() {
120
+ if (!this._dirty || this.destroyed) return;
121
+ this._dirty = false;
122
+ this._attach();
123
+ }
124
+
125
+ _attach() {
126
+ const { frames, src, poster } = this.props;
127
+ if (frames != null) {
128
+ this._sink = frames;
129
+ this._unsubscribe = frames._subscribe((frame) => this._onFrame(frame));
130
+ this._metadata();
131
+ } else if (src != null) {
132
+ if (canPlayMedia(this.app)) this._play(src);
133
+ else this._refuse();
134
+ }
135
+ if (poster != null) this._loadPoster(poster);
136
+ }
137
+
138
+ _detach() {
139
+ this._unsubscribe?.();
140
+ this._unsubscribe = null;
141
+ this._sink = null;
142
+ this._playerOff?.();
143
+ this._playerOff = null;
144
+ this._player?.release();
145
+ this._player = null;
146
+ this._playerFrame = false;
147
+ this._stopPull();
148
+ this._posterToken++;
149
+ if (this._ownedPoster) {
150
+ freeImage(this.app, this._ownedPoster);
151
+ this._ownedPoster = null;
152
+ }
153
+ this._poster = null;
154
+ this._refused = false;
155
+ }
156
+
157
+ /**
158
+ * A push, or a close. Lifted, the layer shows the frame and the window
159
+ * paints nothing; otherwise the picture is claimed, and the frame that
160
+ * claim brings asks for the parts again — which is how the first frame
161
+ * goes up onto a layer and a close takes the picture back down.
162
+ */
163
+ _onFrame(frame) {
164
+ if (this.destroyed) return;
165
+ if (frame === null || !this._lifted) {
166
+ this.root?.invalidate(false, this, 'content');
167
+ }
168
+ }
169
+
170
+ _metadata() {
171
+ const sink = this._sink;
172
+ const handler = this.props.onLoadedMetadata;
173
+ if (!handler || !sink) return;
174
+ // after the commit that mounted it: the ordinary answer to the news is
175
+ // to set state, which React refuses in the middle of a commit
176
+ queueMicrotask(() => {
177
+ if (this.destroyed || this._sink !== sink) return;
178
+ handler({
179
+ width: sink.width,
180
+ height: sink.height,
181
+ // a sink is a live source: HTML's duration for one
182
+ duration: Infinity,
183
+ node: this,
184
+ });
185
+ });
186
+ }
187
+
188
+ /** `src` on the platform's player, playing from the start if the props
189
+ * say so: a controlled `paused={false}`, or `autoPlay`. */
190
+ _play(src) {
191
+ const p = this.props;
192
+ const player = this.app.createPlayer(src, {
193
+ autoPlay:
194
+ p.paused !== undefined ? p.paused === false : Boolean(p.autoPlay),
195
+ loop: p.loop,
196
+ muted: p.muted,
197
+ volume: p.volume,
198
+ playbackRate: p.playbackRate,
199
+ });
200
+ this._player = player;
201
+ this._playerOff = player._subscribe((ev) => this._onPlayer(ev));
202
+ }
203
+
204
+ /** The player's news, as HTML's media events — after the frame they
205
+ * change has been asked for. */
206
+ _onPlayer(ev) {
207
+ if (this.destroyed) return;
208
+ const player = this._player;
209
+ const props = this.props;
210
+ switch (ev.type) {
211
+ case 'player-metadata':
212
+ // a size at last, or a new one: laid out again, and a part to offer
213
+ this.invalidateMeasure('content');
214
+ this.spritesChanged();
215
+ props.onLoadedMetadata?.({
216
+ width: player.width,
217
+ height: player.height,
218
+ duration: player.duration,
219
+ node: this,
220
+ });
221
+ return;
222
+ case 'player-state':
223
+ this._syncPull();
224
+ if (player.playing) props.onPlay?.();
225
+ else props.onPause?.();
226
+ return;
227
+ case 'player-time':
228
+ // a seek while paused shows another frame, which nothing else pulls
229
+ if (!player.playing) this._pullFrame();
230
+ props.onTimeUpdate?.({ currentTime: player.currentTime, node: this });
231
+ return;
232
+ case 'player-ended':
233
+ this._syncPull();
234
+ props.onEnded?.();
235
+ return;
236
+ case 'player-error':
237
+ this.error = player.error;
238
+ if (props.onError) props.onError(player.error);
239
+ else console.warn(player.error.message);
240
+ return;
241
+ }
242
+ }
243
+
244
+ /** While the picture is drawn and playing, the frame showing is asked for
245
+ * every display frame; lifted or paused, nothing is. */
246
+ _syncPull() {
247
+ const want =
248
+ this._player !== null &&
249
+ !this._lifted &&
250
+ this._player.playing &&
251
+ !this.destroyed;
252
+ if (want && this._pull === null) {
253
+ this._pull = setInterval(() => this._pullFrame(), PULL_MS);
254
+ this._pull.unref?.();
255
+ } else if (!want) {
256
+ this._stopPull();
257
+ }
258
+ }
259
+
260
+ _stopPull() {
261
+ if (this._pull !== null) clearInterval(this._pull);
262
+ this._pull = null;
263
+ }
264
+
265
+ /** The frame showing into the surface, and the picture claimed if it was
266
+ * a new one. */
267
+ _pullFrame() {
268
+ if (this._lifted || this.destroyed) return;
269
+ if (this._copyPlayerFrame()) this.root?.invalidate(false, this, 'content');
270
+ }
271
+
272
+ /** True when a new frame went into the surface. */
273
+ _copyPlayerFrame() {
274
+ const player = this._player;
275
+ if (!player || !(player.width > 0)) return false;
276
+ const surface = this._surfaceFor(player.width, player.height);
277
+ if (!surface) return false;
278
+ const copied = player.copyFrame(surface);
279
+ // another size than the surface: the metadata that says so remakes it
280
+ if (!copied?.written) return false;
281
+ this._playerFrame = true;
282
+ return true;
283
+ }
284
+
285
+ _refuse() {
286
+ if (canPlayMedia(this.app)) return;
287
+ this._refused = true;
288
+ const err = new NoMediaPlaybackError();
289
+ this.error = err;
290
+ queueMicrotask(() => {
291
+ if (this.destroyed || !this._refused) return;
292
+ if (this.props.onError) this.props.onError(err);
293
+ else if (this.app && !warnedNoPlayback.has(this.app)) {
294
+ warnedNoPlayback.add(this.app);
295
+ console.warn(err.message);
296
+ }
297
+ });
298
+ }
299
+
300
+ /** The poster through `<image>`'s own ladder: a file read, bytes decoded
301
+ * now or — WebP, or anything under Bun — later, raw RGBA or an Image as
302
+ * it is. A failure is a content failure: said once, and no poster. */
303
+ _loadPoster(src) {
304
+ if (isDirectImageSource(src)) {
305
+ this._poster = src;
306
+ return;
307
+ }
308
+ let decoded;
309
+ try {
310
+ decoded = isPathImageSource(src)
311
+ ? loadImageFile(toLoadablePath(src))
312
+ : decodeImageSource(src);
313
+ } catch (err) {
314
+ console.error('react-x11: <video poster> did not decode:', err.message);
315
+ return;
316
+ }
317
+ if (typeof decoded?.then !== 'function') {
318
+ this._ownedPoster = decoded;
319
+ this._poster = decoded;
320
+ return;
321
+ }
322
+ const token = ++this._posterToken;
323
+ decoded.then(
324
+ (image) => {
325
+ if (!image || token !== this._posterToken || this.destroyed) return;
326
+ this._ownedPoster = image;
327
+ this._poster = image;
328
+ if (!this._sink) this.invalidateMeasure('content');
329
+ else this.root?.invalidate(false, this, 'content');
330
+ },
331
+ (err) => {
332
+ if (token !== this._posterToken || this.destroyed) return;
333
+ console.error(
334
+ `react-x11: <video poster> ${isPathImageSource(src) ? src : 'bytes'} did not load:`,
335
+ err.message,
336
+ );
337
+ },
338
+ );
339
+ }
340
+
341
+ // --- layout -------------------------------------------------------------------
342
+
343
+ /** The picture's size in source pixels, which are logical pixels — the
344
+ * browser's rule, and `<image>`'s: a 1280x720 stream is 1280 logical px
345
+ * wide at any scale. The stream's size once it is known, then the
346
+ * poster's, then HTML's 300x150. */
347
+ _natural() {
348
+ const sink = this._sink;
349
+ if (sink) return { width: sink.width, height: sink.height };
350
+ const player = this._player;
351
+ if (player?.width > 0 && player?.height > 0) {
352
+ return { width: player.width, height: player.height };
353
+ }
354
+ const poster = this._poster;
355
+ if (poster?.width > 0 && poster?.height > 0) {
356
+ return { width: poster.width, height: poster.height };
357
+ }
358
+ return { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT };
359
+ }
360
+
361
+ /**
362
+ * HTML's video sizing: a style that names one axis gets the other from
363
+ * the picture's ratio — larger than the stream as well as smaller, which
364
+ * is where a video parts from `<image>` — and a style that names neither
365
+ * gets the stream's size, shrunk to fit what is on offer. Whole pixels:
366
+ * a fractional answer is an input yoga's line arithmetic cannot take.
367
+ */
368
+ measureContent(constraints) {
369
+ this._ensure();
370
+ const s = this.scale;
371
+ const n = this._natural();
372
+ const natural = { width: n.width * s, height: n.height * s };
373
+ const { width, height, widthMode, heightMode } = constraints;
374
+ if (widthMode === 'exactly' && heightMode !== 'exactly') {
375
+ return { width, height: Math.round((width * n.height) / n.width) };
376
+ }
377
+ if (heightMode === 'exactly' && widthMode !== 'exactly') {
378
+ return { width: Math.round((height * n.width) / n.height), height };
379
+ }
380
+ const size = intrinsicSize(natural, constraints);
381
+ return { width: Math.round(size.width), height: Math.round(size.height) };
382
+ }
383
+
384
+ /** Where the picture goes in the content box, device pixels, snapped to
385
+ * whole ones so the hole a lifted picture leaves and the layer that
386
+ * fills it meet without a seam. */
387
+ _pictureRect(natural) {
388
+ const s = this.scale;
389
+ return fitRect(
390
+ this.contentBox(),
391
+ natural.width * s,
392
+ natural.height * s,
393
+ objectFitOf(this.style, 'contain'),
394
+ );
395
+ }
396
+
397
+ // --- props --------------------------------------------------------------------
398
+
399
+ applyProps(newProps, oldProps) {
400
+ const before = oldProps ?? this.props;
401
+ const changed =
402
+ newProps.frames !== before.frames ||
403
+ newProps.src !== before.src ||
404
+ newProps.poster !== before.poster;
405
+ if (changed) validateVideoProps(newProps);
406
+ super.applyProps(newProps, oldProps);
407
+ if (!changed) {
408
+ // the same player, told what changed of how it plays
409
+ if (
410
+ this._player &&
411
+ PLAYER_PROPS.some((name) => newProps[name] !== before[name])
412
+ ) {
413
+ const settings = {};
414
+ for (const name of PLAYER_PROPS) {
415
+ if (newProps[name] !== before[name]) settings[name] = newProps[name];
416
+ }
417
+ this._player.set(settings);
418
+ }
419
+ return;
420
+ }
421
+ const was = this._natural();
422
+ this._detach();
423
+ this._dirty = false;
424
+ this._attach();
425
+ this._releaseSurface();
426
+ this.spritesChanged();
427
+ const now = this._natural();
428
+ if (was.width !== now.width || was.height !== now.height) {
429
+ this.invalidateMeasure('content');
430
+ }
431
+ }
432
+
433
+ destroySubtree() {
434
+ this.destroyed = true;
435
+ this._detach();
436
+ this._releaseSurface();
437
+ super.destroySubtree();
438
+ }
439
+
440
+ // --- sprites --------------------------------------------------------------------
441
+
442
+ /**
443
+ * The picture, as one part whose content is the sink: offered once there
444
+ * is a frame to show, and cut to the content box where `objectFit:
445
+ * 'cover'` makes it larger.
446
+ */
447
+ sprites() {
448
+ if (this.hidden) return null;
449
+ const sink = this._sink;
450
+ const player = this._player;
451
+ // a sink with a frame, or a player that knows its size and works
452
+ const contents = sink?.frame
453
+ ? sink
454
+ : player?.width > 0 && !player.error
455
+ ? player
456
+ : null;
457
+ if (contents === null) return null;
458
+ const rect = this._pictureRect(this._natural());
459
+ if (!(rect.width > 0 && rect.height > 0)) return null;
460
+ const part = { key: 'video', rect, contents };
461
+ const box = this.contentBox();
462
+ if (
463
+ rect.x < box.x ||
464
+ rect.y < box.y ||
465
+ rect.x + rect.width > box.x + box.width ||
466
+ rect.y + rect.height > box.y + box.height
467
+ ) {
468
+ part.clip = box;
469
+ }
470
+ return [part];
471
+ }
472
+
473
+ spritesLifted(keys) {
474
+ this._lifted = keys.has('video');
475
+ if (this._player) {
476
+ this._syncPull();
477
+ // given back: the frame showing, for the paint this frame owes it
478
+ if (!this._lifted) this._copyPlayerFrame();
479
+ }
480
+ }
481
+
482
+ // --- the imperative half: what a `ref` can do, as HTMLMediaElement can ----------
483
+
484
+ /** Play `src` — from its start if it has ended. */
485
+ play() {
486
+ this._ensure();
487
+ this._player?.play();
488
+ }
489
+
490
+ pause() {
491
+ this._ensure();
492
+ this._player?.pause();
493
+ }
494
+
495
+ /** To `seconds` into `src`, exactly. */
496
+ seek(seconds) {
497
+ this._ensure();
498
+ this._player?.seek(seconds);
499
+ }
500
+
501
+ /** Seconds into `src`; a sink's newest frame's time, or 0. */
502
+ get currentTime() {
503
+ if (this._player) return this._player.currentTime;
504
+ return this._sink?.frame?.time ?? 0;
505
+ }
506
+
507
+ /** Seconds; `Infinity` for a live stream and for a sink, NaN before it is
508
+ * known. */
509
+ get duration() {
510
+ if (this._player) return this._player.duration;
511
+ return this._sink ? Infinity : NaN;
512
+ }
513
+
514
+ get paused() {
515
+ return this._player ? !this._player.playing : true;
516
+ }
517
+
518
+ // --- paint ----------------------------------------------------------------------
519
+
520
+ paintContent(ctx) {
521
+ this._ensure();
522
+ const sink = this._sink;
523
+ if (sink?.frame) {
524
+ // the layer is showing it, and the bitmap under it is a hole
525
+ if (this._lifted) return;
526
+ if (this._drawFrame(ctx, sink)) return;
527
+ }
528
+ const player = this._player;
529
+ if (player?.width > 0 && !player.error) {
530
+ if (this._lifted) return;
531
+ if (this._playerFrame || this._copyPlayerFrame()) {
532
+ this._drawFitted(ctx, this._surface, {
533
+ width: player.width,
534
+ height: player.height,
535
+ });
536
+ return;
537
+ }
538
+ }
539
+ const poster = this._poster;
540
+ if (poster?.width > 0 && poster?.height > 0) {
541
+ this._drawFitted(ctx, poster, {
542
+ width: poster.width,
543
+ height: poster.height,
544
+ });
545
+ }
546
+ }
547
+
548
+ _drawFitted(ctx, image, natural) {
549
+ const rect = this._pictureRect(natural);
550
+ if (!(rect.width > 0 && rect.height > 0)) return;
551
+ const box = this.contentBox();
552
+ const overflows =
553
+ rect.x < box.x ||
554
+ rect.y < box.y ||
555
+ rect.x + rect.width > box.x + box.width ||
556
+ rect.y + rect.height > box.y + box.height;
557
+ if (overflows) {
558
+ ctx.save();
559
+ ctx.beginPath();
560
+ ctx.rect(box.x, box.y, box.width, box.height);
561
+ ctx.clip();
562
+ }
563
+ try {
564
+ ctx.drawImage(image, rect.x, rect.y, rect.width, rect.height);
565
+ } finally {
566
+ if (overflows) ctx.restore();
567
+ }
568
+ }
569
+
570
+ /** The newest frame into the node's surface, if it is not there yet, and
571
+ * the surface composited. False where there is no surface to be had —
572
+ * the headless mock — and the poster stands in. */
573
+ _drawFrame(ctx, sink) {
574
+ const frame = sink.frame;
575
+ const surface = this._surfaceFor(sink.width, sink.height);
576
+ if (!surface) return false;
577
+ if (this._drawn !== frame.version) {
578
+ try {
579
+ this._upload(surface, sink, frame);
580
+ } catch (err) {
581
+ if (DEV) {
582
+ console.warn(
583
+ `react-x11: <video> could not draw a frame: ${err?.message ?? err}`,
584
+ );
585
+ }
586
+ return false;
587
+ }
588
+ this._drawn = frame.version;
589
+ }
590
+ this._drawFitted(ctx, surface, { width: sink.width, height: sink.height });
591
+ return true;
592
+ }
593
+
594
+ _upload(surface, sink, frame) {
595
+ // natively where the backend converts in its own code (the Cocoa
596
+ // bridge's writeVideoSurface: a copy for BGRA, VideoToolbox for YCbCr,
597
+ // colour-matched to what a layer shows)
598
+ if (this.app?.writeVideoFrame?.(surface, sink, frame)) return;
599
+ const w = sink.width;
600
+ const h = sink.height;
601
+ if (this._rgba?.width !== w || this._rgba?.height !== h) {
602
+ this._rgba = {
603
+ width: w,
604
+ height: h,
605
+ data: new Uint8ClampedArray(w * h * 4),
606
+ };
607
+ }
608
+ frameToRGBA(sink, frame, this._rgba.data);
609
+ surface.render((sctx) => sctx.putImageData(this._rgba, 0, 0));
610
+ }
611
+
612
+ _surfaceFor(width, height) {
613
+ const kept = this._surface;
614
+ if (kept && kept.width === width && kept.height === height) return kept;
615
+ this._releaseSurface();
616
+ this._playerFrame = false;
617
+ const app = this.app;
618
+ if (!app?.display?.Render && typeof app?.createSurface !== 'function') {
619
+ return null;
620
+ }
621
+ try {
622
+ this._surface = new Surface(app, { width, height });
623
+ } catch {
624
+ this._surface = null;
625
+ }
626
+ this._drawn = -1;
627
+ return this._surface;
628
+ }
629
+
630
+ _releaseSurface() {
631
+ const surface = this._surface;
632
+ this._surface = null;
633
+ this._rgba = null;
634
+ this._drawn = -1;
635
+ try {
636
+ surface?.destroy();
637
+ } catch {
638
+ // gone with its connection
639
+ }
640
+ }
641
+ }
package/src/styles.js CHANGED
@@ -6,6 +6,7 @@
6
6
  import { cssColorStraight } from 'ntk/color';
7
7
 
8
8
  import { parseBoxShadow, parseLinearGradient } from './decorations.js';
9
+ import { OBJECT_FITS } from './nodes/fit.js';
9
10
  import {
10
11
  validateGridValue,
11
12
  GRID_CONTAINER_PROPS,
@@ -575,6 +576,9 @@ const STYLE_PROPS = new Set([
575
576
  ...TEXT_PAINT_PROPS,
576
577
  'color',
577
578
  'borderStyle',
579
+ // Where an `<image>`'s or a `<video>`'s picture goes in its box — CSS's
580
+ // `object-fit`. Paint: the box is laid out the same whichever it is.
581
+ 'objectFit',
578
582
  'transition',
579
583
  // a loop rather than a change with an end — see `animationsOf`
580
584
  'animation',
@@ -938,6 +942,13 @@ function validateValue(key, value, where) {
938
942
  }
939
943
  return;
940
944
  }
945
+ if (key === 'objectFit') {
946
+ if (value == null || OBJECT_FITS.includes(value) || isToken(value)) return;
947
+ throw new Error(
948
+ `react-x11: invalid objectFit ${JSON.stringify(value)} in ${where} ` +
949
+ `(expected ${OBJECT_FITS.map((v) => `'${v}'`).join(', ')})`,
950
+ );
951
+ }
941
952
  if (key === 'opacity') {
942
953
  // unset, the way a conditional writes it: `opacity: dim ? 0.5 : undefined`
943
954
  if (value == null) return;
@@ -1189,6 +1200,8 @@ const NOT_ANIMATABLE = new Set([
1189
1200
  'cursor',
1190
1201
  'pointerEvents',
1191
1202
  'borderStyle',
1203
+ // a keyword: there is no halfway between filling a box and covering it
1204
+ 'objectFit',
1192
1205
  'fontFamily',
1193
1206
  'fontWeight',
1194
1207
  'fontStyle',
@@ -2269,11 +2282,13 @@ export function paintPropsChanged(props, oldProps = {}) {
2269
2282
  for (const key of PAINT_PROPS) {
2270
2283
  if (props[key] !== oldProps[key]) return true;
2271
2284
  }
2272
- // `color` and `borderStyle` paint but are deliberately not in PAINT_PROPS:
2273
- // that set also decides what a state block is allowed to set, and widening
2274
- // it would change validation rather than just this comparison
2285
+ // `color`, `borderStyle` and `objectFit` paint but are deliberately not in
2286
+ // PAINT_PROPS: that set also decides what a state block is allowed to set,
2287
+ // and widening it would change validation rather than just this comparison
2275
2288
  return (
2276
- props.color !== oldProps.color || props.borderStyle !== oldProps.borderStyle
2289
+ props.color !== oldProps.color ||
2290
+ props.borderStyle !== oldProps.borderStyle ||
2291
+ props.objectFit !== oldProps.objectFit
2277
2292
  );
2278
2293
  }
2279
2294