react-x11 2.18.2 → 2.18.4

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.
@@ -18,6 +18,20 @@ import { BLIT_POISONED } from './scrollblit.js';
18
18
  import { DEV } from './util.js';
19
19
  import { debugPaint } from './window/debugpaint.js';
20
20
 
21
+ // Which damage lists a claim belongs in (`WindowInvalidate._paneReach`).
22
+ const WINDOW_ONLY = 0;
23
+ const BOTH = 1;
24
+ const PANES_ONLY = 2;
25
+
26
+ /** Is `node` inside a `<glarea>` below `root` — drawn on its panes, and
27
+ * never by the window's own paint walk? */
28
+ function insideSurface(node, root) {
29
+ for (let n = node.parent; n && n !== root; n = n.parent) {
30
+ if (n.isGlArea) return true;
31
+ }
32
+ return false;
33
+ }
34
+
21
35
  /** Node's half of invalidation, installed onto `Node.prototype` by node.js. */
22
36
  export class NodeInvalidate {
23
37
  /**
@@ -40,7 +54,10 @@ export class NodeInvalidate {
40
54
  // reaches — a shadow, an outline, a scene element's ink — so its cached
41
55
  // paint reach goes with the claim
42
56
  if (damage === this || damage === null) this._clearPaintBounds();
43
- this.root?.invalidate(layoutChanged, damage, reason);
57
+ // …and names itself, so a rect it claims goes to the damage lists it can
58
+ // reach — the window's, a `<glarea>`'s panes', or both
59
+ // (`WindowNode._paneReach`)
60
+ this.root?.invalidate(layoutChanged, damage, reason, this);
44
61
  }
45
62
 
46
63
  /**
@@ -112,7 +129,7 @@ export class NodeInvalidate {
112
129
  // Same walk, same frame, same answer — see `_childListBefore`, whose
113
130
  // record this shares so that a reflow and a child-list change on one
114
131
  // node in one frame walk the subtree once between them.
115
- root.invalidate(true, this._childListBefore(), reason);
132
+ root.invalidate(true, this._childListBefore(), reason, this);
116
133
  root._reflowed.add(this);
117
134
  }
118
135
 
@@ -235,8 +252,14 @@ export class WindowInvalidate {
235
252
  * diagnostic, collected per frame into `_lastReasons` so the frame log,
236
253
  * REACT_X11_DEBUG_PAINT=full and the tracer can attribute a repaint.
237
254
  * Omitting it costs nothing but attribution.
255
+ *
256
+ * `source` is the node making a claim that names a rect rather than a
257
+ * node — `Node.invalidate` passes itself — and is what decides which of
258
+ * the frame's two lists the claim goes to: the window's, the one the
259
+ * panes over its `<glarea>`s paint from, or both (`_paneReach`). Left
260
+ * out, both.
238
261
  */
239
- invalidate(layoutChanged, damage = null, reason = null) {
262
+ invalidate(layoutChanged, damage = null, reason = null, source = null) {
240
263
  if (this.destroyed || !this.window) return;
241
264
  if (!layoutChanged && damage === NO_DAMAGE) {
242
265
  // Nothing this node draws changed, so it contributes no region — and
@@ -274,6 +297,16 @@ export class WindowInvalidate {
274
297
  this._scheduleFrame();
275
298
  return;
276
299
  }
300
+ // The panes over this window's `<glarea>`s paint from a list of their
301
+ // own (`_paneDamage`): a claim goes to the window's list, the panes',
302
+ // or both, by what its source can reach (`_paneReach`). One `size`
303
+ // read for a window with no surface children.
304
+ const reach =
305
+ this._overlaid.size !== 0
306
+ ? this._paneReach(damage?.kind ? damage : source)
307
+ : WINDOW_ONLY;
308
+ const panes = reach !== WINDOW_ONLY;
309
+ const windowToo = reach !== PANES_ONLY;
277
310
  if (layoutChanged) {
278
311
  this.needsLayout = true;
279
312
  // The content floors are measured from the tree, so anything that
@@ -314,8 +347,11 @@ export class WindowInvalidate {
314
347
  // where nothing can be seen, so it owes no pixels), and null on a frame
315
348
  // that is already unbounded, which owes neither a rect nor the subtree
316
349
  // walk that measures one — a blit cannot fire there either.
350
+ const bounded =
351
+ (windowToo && this._damage !== FULL_DAMAGE) ||
352
+ (panes && this._paneDamage !== FULL_DAMAGE);
317
353
  const bounds =
318
- damage && damage !== NO_DAMAGE && this._damage !== FULL_DAMAGE
354
+ damage && damage !== NO_DAMAGE && bounded
319
355
  ? damage._claimBounds
320
356
  ? damage._claimBounds()
321
357
  : damage
@@ -324,6 +360,9 @@ export class WindowInvalidate {
324
360
  if (pendingScrolls?.size && bounds && this._scrollClaim !== damage) {
325
361
  const rect = bounds;
326
362
  for (const sv of pendingScrolls) {
363
+ // A claim the window's list never sees is no change to the pixels
364
+ // a blit of the window's moves: a surface's children are on panes
365
+ if (!windowToo && !insideSurface(sv, this)) continue;
327
366
  // An element blitting a region of its own drawing (issue #303) is
328
367
  // waiting on that region, not on the whole node it lives in — and
329
368
  // it is waiting on it *exactly* (issue #309). Its claim is the rect
@@ -363,25 +402,102 @@ export class WindowInvalidate {
363
402
  }
364
403
  }
365
404
  }
366
- if (layoutChanged && !damage) this._damage = FULL_DAMAGE;
367
- else if (!layoutChanged && !damage) this._damage = FULL_DAMAGE;
368
- else if (!bounds) {
405
+ if (!damage) {
406
+ // "something, somewhere" — in the panes alone, for a node whose
407
+ // pixels are only ever on one; everywhere, for anything else
408
+ if (windowToo) this._damage = FULL_DAMAGE;
409
+ else this._damage ??= [];
410
+ if (panes) this._paneDamage = FULL_DAMAGE;
411
+ } else if (!bounds) {
369
412
  // A layout change that names no region: either NO_DAMAGE, from a
370
413
  // caller with a finer claim already in flight, or a node whose reach
371
414
  // a clipping ancestor left nothing of (issue #398). Unlike `!damage`
372
- // neither is "something, somewhere", so neither costs a full repaint.
373
- } else if (this._damage !== FULL_DAMAGE) {
415
+ // neither is "something, somewhere", so neither costs a full repaint
416
+ // — which a frame with no list at all would be, however little its
417
+ // layout pass goes on to claim (`_takeDamage`). A child of a
418
+ // `<glarea>` that only moved is this claim (issue #644), and a frame
419
+ // whose move is settled on a pane claims only the strips it uncovered.
420
+ this._damage ??= [];
421
+ } else {
374
422
  // a node, or a bare rect for a caller that has a region rather than a
375
423
  // node — a subtree that is about to be removed, say. Claims accumulate
376
424
  // as a list of rects rather than one box around them all, so two changes
377
425
  // at opposite corners of the window no longer repaint everything
378
426
  // between them.
379
- this._damage = addDamageRect(this._damage, bounds, this._damageRectCap());
427
+ if (!windowToo) {
428
+ // the window owes nothing, and a frame with no list is unbounded
429
+ this._damage ??= [];
430
+ } else if (this._damage !== FULL_DAMAGE) {
431
+ this._damage = addDamageRect(
432
+ this._damage,
433
+ bounds,
434
+ this._damageRectCap(),
435
+ );
436
+ }
437
+ if (panes) this._addPaneDamage(bounds);
380
438
  }
381
439
  this.needsPaint = true;
382
440
  this._scheduleFrame();
383
441
  }
384
442
 
443
+ /**
444
+ * Which damage lists a claim from `node` belongs in: the window's, the
445
+ * panes' over its `<glarea>`s, or both. The panes hold the surfaces'
446
+ * children and nothing else (src/gloverlay.js), and the window paints
447
+ * none of those, so:
448
+ *
449
+ * - a node inside a surface is the panes' alone — the window's pass under
450
+ * the surface would repaint pixels no child of it ever had;
451
+ * - a node above a surface is both: its colour or font is what the
452
+ * children below it paint with. So is a surface itself, whose
453
+ * `clearColor` an X11 pane's ground is, and so is a claim with no
454
+ * source to go by;
455
+ * - anything else is the window's alone. It draws only into the window,
456
+ * where the surface covers it: a graph pane under the surface that its
457
+ * own pan claims whole every frame (issue #644) is the claim this keeps
458
+ * out of the panes' repaint.
459
+ */
460
+ _paneReach(node) {
461
+ if (!node || node === this) return BOTH;
462
+ if (insideSurface(node, this)) return PANES_ONLY;
463
+ if (node.isGlArea) return BOTH;
464
+ for (const area of this._overlaid) {
465
+ for (let n = area.parent; n && n !== this; n = n.parent) {
466
+ if (n === node) return BOTH;
467
+ }
468
+ }
469
+ return WINDOW_ONLY;
470
+ }
471
+
472
+ /** One rect more for the panes, unless they repaint whole already. */
473
+ _addPaneDamage(rect) {
474
+ if (this._paneDamage === FULL_DAMAGE) return;
475
+ this._paneDamage = addDamageRect(
476
+ this._paneDamage,
477
+ rect,
478
+ this._damageRectCap(),
479
+ );
480
+ }
481
+
482
+ /**
483
+ * What the panes repaint this frame: null for all of them, and — unlike
484
+ * the window's list — an empty list when nothing reached them, which is
485
+ * most frames of a window whose surface children hold still.
486
+ */
487
+ _takePaneDamage(width, height) {
488
+ const damage = this._paneDamage;
489
+ this._paneDamage = null;
490
+ if (damage === FULL_DAMAGE) return null;
491
+ if (!damage || damage.length === 0) return [];
492
+ const rects = [];
493
+ for (const claimed of damage) {
494
+ const clamped = this._clampDamage(claimed, width, height);
495
+ if (clamped === FULL_DAMAGE) return null;
496
+ if (clamped) rects.push(clamped);
497
+ }
498
+ return damageToPaint(rects);
499
+ }
500
+
385
501
  /**
386
502
  * The rects this frame will repaint, or null for the whole window.
387
503
  *
package/src/nodes/node.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  applyLayoutStyle,
14
14
  applyLayoutDefaults,
15
15
  createLayoutNode,
16
+ onlyInsetsChanged,
16
17
  paintPropsChanged,
17
18
  isEventProp,
18
19
  } from '../styles.js';
@@ -473,10 +474,10 @@ export class Node {
473
474
  // `_reflowed` would undo both — its post-layout claim is this node's
474
475
  // box, the whole band the blit is about to move.
475
476
  if (this._blitLedgerOpen()) {
476
- root.invalidate(true, before ?? NO_DAMAGE, 'child-list');
477
+ root.invalidate(true, before ?? NO_DAMAGE, 'child-list', this);
477
478
  return;
478
479
  }
479
- root.invalidate(true, before, 'child-list');
480
+ root.invalidate(true, before, 'child-list', this);
480
481
  root._reflowed.add(this);
481
482
  }
482
483
 
@@ -604,7 +605,21 @@ export class Node {
604
605
  // which is what keeps a commit from widening the region to every node
605
606
  // it touched.
606
607
  if (layoutChanged) {
607
- this._invalidateLayout('props');
608
+ // A child of a `<glarea>` that only moved (issue #644): its pixels are
609
+ // on a pane, one shift away, and the layout pass reports the move to
610
+ // the overlay rather than claiming it (src/glnodes.js). A claim of the
611
+ // subtree here, before and after, would be the repaint the overlay
612
+ // exists to skip — and the pass claims both ends of the move itself
613
+ // whenever the overlay cannot move the pixels.
614
+ if (
615
+ this.parent?.isGlArea &&
616
+ onlyInsetsChanged(style, prevStyle) &&
617
+ !this.paintChanged(newProps, prev)
618
+ ) {
619
+ this.invalidate(true, NO_DAMAGE, 'props');
620
+ } else {
621
+ this._invalidateLayout('props');
622
+ }
608
623
  } else {
609
624
  // The style half is asked here rather than inside `paintChanged`, and
610
625
  // stays core's answer: what a style change moves is the background,
@@ -632,12 +647,16 @@ export class Node {
632
647
  * safe without knowing about subclasses: `<image src>`, `<canvas onDraw>`,
633
648
  * a `value`, a `placeholder`, a `caretColor` — any prop a subclass paints
634
649
  * from is a prop, so a change to it lands here as an inequality and damages
635
- * the node. Three kinds are skipped because they cannot affect this node's
650
+ * the node. Four kinds are skipped because they cannot affect this node's
636
651
  * own drawing:
637
652
  *
638
653
  * - `children`, which the reconciler mutates through appendChild /
639
654
  * removeChild / commitTextUpdate, each of which invalidates on its own;
640
655
  * - event handlers, rebuilt every render and never painted;
656
+ * - `ref`, which is React's rather than the element's. Since React 19 it
657
+ * arrives as an ordinary prop, so an inline callback is a new function
658
+ * every render, like a handler. `key` would be the same if React ever
659
+ * passed it on; today React strips it;
641
660
  * - `style`, compared by value by the caller — so a style object React
642
661
  * rebuilt with the same contents costs nothing, which is the whole
643
662
  * point, since React rebuilds sibling styles on every render and a
@@ -669,6 +688,7 @@ export class Node {
669
688
  const keys = new Set([...Object.keys(newProps), ...Object.keys(prev)]);
670
689
  for (const key of keys) {
671
690
  if (key === 'children' || key === 'style' || isEventProp(key)) continue;
691
+ if (key === 'ref' || key === 'key') continue;
672
692
  if (claimed.has(key)) continue;
673
693
  if (newProps[key] !== prev[key]) return true;
674
694
  }
@@ -317,6 +317,7 @@ export class WindowPosition {
317
317
  nodes.sort((a, b) => depth.get(a) - depth.get(b));
318
318
  }
319
319
  const cap = this._damageRectCap();
320
+ const panes = this._overlaid.size !== 0;
320
321
  const t = now();
321
322
  let claimed = false;
322
323
  for (const node of nodes) {
@@ -330,7 +331,12 @@ export class WindowPosition {
330
331
  ? node.paintBounds()
331
332
  : null;
332
333
  node._placedShown = after;
333
- if (this._damage === FULL_DAMAGE) continue;
334
+ if (
335
+ this._damage === FULL_DAMAGE &&
336
+ (!panes || this._paneDamage === FULL_DAMAGE)
337
+ ) {
338
+ continue;
339
+ }
334
340
  const sv = node._blitViewport();
335
341
  let was = before;
336
342
  if (was && sv) {
@@ -354,7 +360,10 @@ export class WindowPosition {
354
360
  if (sv && !sv._recordBlitClaim(claim)) {
355
361
  sv._pendingBlitFrom = BLIT_POISONED;
356
362
  }
357
- this._damage = addDamageRect(this._damage, claim, cap);
363
+ if (this._damage !== FULL_DAMAGE) {
364
+ this._damage = addDamageRect(this._damage, claim, cap);
365
+ }
366
+ if (panes) this._addPaneDamage(claim);
358
367
  claimed = true;
359
368
  }
360
369
  }
@@ -297,8 +297,11 @@ export const Scrollable = (Base) =>
297
297
  // …which holds for a scroll's shift and not for the clamp's: the
298
298
  // content shrank, or the viewport grew, under the offset — a row
299
299
  // collapsing at the end of a list scrolled to its end — and
300
- // nothing claimed the viewport every child just moved in.
301
- if (clamped) outer(vp);
300
+ // nothing claimed the viewport every child just moved in. Nor
301
+ // for a `<glarea>` child's move that this pane rides (issue
302
+ // #644): its box claimed nothing, and a pane laid out again may
303
+ // have moved rows the move's copy carries where they were.
304
+ if (clamped || layoutDiff.ride) outer(vp);
302
305
  } else {
303
306
  layoutDiff.sink = (rect) => {
304
307
  const clipped = intersectRects(rect, vp);
@@ -409,7 +409,7 @@ export class NodeScrollBlit {
409
409
  // reason to un-blit it. `state.rect` by identity, so a second call this
410
410
  // frame is recognised as the same claim rather than as foreign damage.
411
411
  root._scrollClaim = state.rect;
412
- root.invalidate(false, state.rect, 'scroll');
412
+ root.invalidate(false, state.rect, 'scroll', this);
413
413
  root._scrollClaim = null;
414
414
  return this._pendingBlitFrom !== BLIT_POISONED;
415
415
  }
@@ -485,8 +485,10 @@ export class WindowScrollBlit {
485
485
  // claim the scroll recorded, like every other declined gate here
486
486
  if (from === BLIT_POISONED) return;
487
487
  if (NO_SCROLL_BLIT || !from || node.destroyed || node.root !== this) return;
488
- const wnd = this.window;
489
- if (typeof wnd?.scrollRegion !== 'function') return; // ntk without #139
488
+ // the window's backing store, or the pane over a `<glarea>` that holds
489
+ // the pixels of a node inside it — none where neither can move them
490
+ const target = this._blitTarget(node);
491
+ if (!target) return;
490
492
  // the debug overlays and the DevTools highlight draw over the whole
491
493
  // window; a blit would drag shifted copies of them along
492
494
  if (
@@ -497,7 +499,8 @@ export class WindowScrollBlit {
497
499
  ) {
498
500
  return;
499
501
  }
500
- if (!Array.isArray(this._damage)) return; // unbounded frame already
502
+ // unbounded already, on the surface the blit would move
503
+ if (!Array.isArray(target.damage)) return;
501
504
  // the layout diff claimed real movement this pass — the frame is not a
502
505
  // pure scroll, and a blit under rearranged content would shift stale
503
506
  // pixels into place the repaint no longer covers
@@ -506,7 +509,7 @@ export class WindowScrollBlit {
506
509
  // region and the shift itself (issue #303), where a scroll container is
507
510
  // its whole viewport shifted by the change in its offsets.
508
511
  if (contents) {
509
- this._applyContentsBlit(node, contents, width, height);
512
+ this._applyContentsBlit(node, contents, width, height, target);
510
513
  return;
511
514
  }
512
515
  // Back where the frame started: a burst of scrolls that went one way
@@ -518,14 +521,14 @@ export class WindowScrollBlit {
518
521
  // (Every pixel-shift gate below is moot for a shift of nothing.)
519
522
  if (from.x === node.scrollX && from.y === node.scrollY) {
520
523
  const box = node.abs;
521
- const kept = this._blitKeptDamage(box);
524
+ const kept = this._blitKeptDamage(box, false, target.damage);
522
525
  if (!kept) return;
523
526
  let rects = kept;
524
527
  for (const claim of ledger ?? []) {
525
528
  const inside = intersectRects(claim, box);
526
529
  if (inside) rects = addDamageRect(rects, inside);
527
530
  }
528
- this._damage = rects;
531
+ target.damage = rects;
529
532
  return;
530
533
  }
531
534
  // children clip to the border box, so a border ring or rounded corner
@@ -575,7 +578,7 @@ export class WindowScrollBlit {
575
578
  ) {
576
579
  return;
577
580
  }
578
- const keep = this._blitKeptDamage(vp);
581
+ const keep = this._blitKeptDamage(vp, false, target.damage);
579
582
  if (!keep) return;
580
583
  // What changed inside the viewport while the blit was armed, in the
581
584
  // coordinates the frame is about to paint in (issue #398). A claim made
@@ -635,7 +638,7 @@ export class WindowScrollBlit {
635
638
  ? unionArea([strip, ...repairs]) - rectArea(strip)
636
639
  : 0;
637
640
  if (repairArea > area * BLIT_MAX_CLAIM_AREA) return;
638
- if (!this._scrollBlitSafe(node, vp)) return;
641
+ if (!this._scrollBlitSafe(node, vp, target.top)) return;
639
642
  // The band the scrolled bar's thumb travels in is repaired on its own.
640
643
  // The thumb's rects are thin and run along the viewport's edge, and a
641
644
  // rect reaching into the band from inside — a column held down the
@@ -730,8 +733,8 @@ export class WindowScrollBlit {
730
733
  if (inside) painted += inside.width * inside.height;
731
734
  }
732
735
  if (painted > area * SCROLL_BLIT_MAX_REPAINT) return;
733
- if (!wnd.scrollRegion({ ...vp }, dx, dy)) return;
734
- this._damage = rects;
736
+ if (!target.blit({ ...vp }, dx, dy)) return;
737
+ target.damage = rects;
735
738
  }
736
739
 
737
740
  /**
@@ -751,12 +754,15 @@ export class WindowScrollBlit {
751
754
  * damage cap merges neighbours on its own, without a claim-time gate to
752
755
  * poison first. Requiring the rect back unchanged is what lets the
753
756
  * claim-time gate narrow to `vp` itself and let furniture beside it live.
757
+ *
758
+ * `damage` is the list the blit's surface paints from: the window's, or
759
+ * its panes' for a node inside a `<glarea>` (`_blitTarget`).
754
760
  */
755
- _blitKeptDamage(vp, exact = false) {
761
+ _blitKeptDamage(vp, exact = false, damage = this._damage) {
756
762
  const keep = [];
757
763
  let sawClaim = false;
758
764
  const slopped = insetRect(vp, -(DAMAGE_SLOP + 1));
759
- for (const rect of this._damage) {
765
+ for (const rect of damage) {
760
766
  if (!rectsOverlap(rect, vp)) {
761
767
  keep.push(rect);
762
768
  continue;
@@ -795,7 +801,7 @@ export class WindowScrollBlit {
795
801
  * balloon back towards the whole viewport. With no bars the L is two
796
802
  * disjoint rects and stays two.
797
803
  */
798
- _applyContentsBlit(node, { rect: vp, dx, dy }, width, height) {
804
+ _applyContentsBlit(node, { rect: vp, dx, dy }, width, height, target) {
799
805
  // only a whole-pixel shift of a whole-pixel region is a copy
800
806
  if (!isIntegerRect(vp)) return;
801
807
  if (!Number.isInteger(dx) || !Number.isInteger(dy)) return;
@@ -840,14 +846,14 @@ export class WindowScrollBlit {
840
846
  if (child.style?.display === 'none') continue;
841
847
  if (rectsOverlap(child._subtreeBounds(), vp)) return;
842
848
  }
843
- const keep = this._blitKeptDamage(vp, true);
849
+ const keep = this._blitKeptDamage(vp, true, target.damage);
844
850
  if (!keep) return;
845
851
  // An ancestor's rounded corners reach into the top and bottom rows of
846
852
  // the region (a graph pane inside a rounded card is the common shape):
847
853
  // those rows do not translate, so they leave the blit and get repainted
848
854
  // as bands — the same carve the element does for its own furniture —
849
855
  // and the band that shifts is what is left between them.
850
- const bands = this._cornerBands(node, vp);
856
+ const bands = this._cornerBands(node, vp, target.top);
851
857
  const shifted =
852
858
  bands.top || bands.bottom
853
859
  ? {
@@ -864,11 +870,11 @@ export class WindowScrollBlit {
864
870
  ) {
865
871
  return;
866
872
  }
867
- if (!this._scrollBlitSafe(node, shifted)) return;
873
+ if (!this._scrollBlitSafe(node, shifted, target.top)) return;
868
874
  // the element's deltas are already how far the pixels moved, the sense
869
875
  // scrollRegion takes (0 + x rather than x: a caller's -0 would survive
870
876
  // into request buffers and test comparisons)
871
- if (!this.window.scrollRegion({ ...shifted }, 0 + dx, 0 + dy)) return;
877
+ if (!target.blit({ ...shifted }, 0 + dx, 0 + dy)) return;
872
878
  let rects = keep;
873
879
  if (bands.top) {
874
880
  rects = addDamageRect(rects, {
@@ -908,7 +914,7 @@ export class WindowScrollBlit {
908
914
  height: vp.height - Math.abs(dy),
909
915
  });
910
916
  }
911
- this._damage = rects;
917
+ target.damage = rects;
912
918
  }
913
919
 
914
920
  /**
@@ -916,11 +922,13 @@ export class WindowScrollBlit {
916
922
  * corners reach into — the rows an element blit has to leave behind and
917
923
  * repaint, so that what shifts stays clear of every corner square
918
924
  * (`_scrollBlitSafe`). Whole pixels, and zero when no corner reaches in.
925
+ * Ancestors below `upTo`, the node the blit's surface belongs to
926
+ * (`_blitTarget`): a pane is a rectangle no corner above it clips.
919
927
  */
920
- _cornerBands(node, vp) {
928
+ _cornerBands(node, vp, upTo = this) {
921
929
  let top = 0;
922
930
  let bottom = 0;
923
- for (let n = node.parent; n && n !== this; n = n.parent) {
931
+ for (let n = node.parent; n && n !== upTo; n = n.parent) {
924
932
  const radius = n.style?.borderRadius ?? 0;
925
933
  if (!(radius > 0) || !n.abs) continue;
926
934
  if (!cornerSquaresOverlap(n.abs, radius, vp)) continue;
@@ -940,13 +948,18 @@ export class WindowScrollBlit {
940
948
  * the viewport — an overlapping sibling, an ancestor's border ring or
941
949
  * rounded corner, an enclosing viewport's scrollbar — would have its
942
950
  * pixels dragged along by the blit, so any of them is a no.
951
+ *
952
+ * "Outside" is below `top`, the node whose subtree the blit's surface
953
+ * holds (`_blitTarget`): the window, or a `<glarea>` whose pane holds
954
+ * only its own children — what the window draws under the surface was
955
+ * never in the pane's pixels.
943
956
  */
944
- _scrollBlitSafe(scroller, vp) {
957
+ _scrollBlitSafe(scroller, vp, top = this) {
945
958
  // the window itself scrolls: everything inside it *is* the scrolled
946
959
  // content, so there is nothing that could be dragged along
947
960
  if (scroller === this) return true;
948
961
  const ancestors = new Set();
949
- for (let n = scroller.parent; n && n !== this; n = n.parent) {
962
+ for (let n = scroller.parent; n && n !== top; n = n.parent) {
950
963
  ancestors.add(n);
951
964
  }
952
965
  const check = (parent) => {
@@ -993,7 +1006,60 @@ export class WindowScrollBlit {
993
1006
  }
994
1007
  return true;
995
1008
  };
996
- return check(this);
1009
+ return check(top);
1010
+ }
1011
+
1012
+ /**
1013
+ * Where a blit for `node` moves pixels, as `{ top, blit(rect, dx, dy),
1014
+ * damage }` — `blit` with `Window.scrollRegion`'s contract, `top` the node
1015
+ * whose subtree that surface holds, `damage` the list it paints from and
1016
+ * the blit narrows — or null when nothing can.
1017
+ *
1018
+ * The window's backing store, for a node the window paints. A node inside
1019
+ * a `<glarea>` is painted on the pane over the surface instead
1020
+ * (src/gloverlay.js), and the window's pixels under the surface are none
1021
+ * of its: shifting them moved nothing on screen while the strip-only
1022
+ * repaint left the pane holding the scroll's old frame (issue #644). So
1023
+ * the pane that holds the rect moves it, or the frame repaints — a pane
1024
+ * owed a paint whole, or none holding the whole rect, is a refusal like
1025
+ * any other here.
1026
+ */
1027
+ _blitTarget(node) {
1028
+ for (let n = node.parent; n && n !== this; n = n.parent) {
1029
+ if (!n.isGlArea) continue;
1030
+ const overlay = n._overlay;
1031
+ if (!overlay) return null;
1032
+ const root = this;
1033
+ return {
1034
+ top: n,
1035
+ blit: (rect, dx, dy) => {
1036
+ const pane = overlay.panes.find((p) => p.canBlit(rect));
1037
+ return pane ? pane.blit(rect, dx, dy) : false;
1038
+ },
1039
+ // the panes' list: a scroll inside a surface claims nothing in the
1040
+ // window's, which paints none of its children (`_paneReach`)
1041
+ get damage() {
1042
+ return root._paneDamage;
1043
+ },
1044
+ set damage(rects) {
1045
+ root._paneDamage = rects;
1046
+ },
1047
+ };
1048
+ }
1049
+ const wnd = this.window;
1050
+ // an ntk without #139, or a presenter with no bitmap to move
1051
+ if (typeof wnd?.scrollRegion !== 'function') return null;
1052
+ const root = this;
1053
+ return {
1054
+ top: this,
1055
+ blit: (rect, dx, dy) => wnd.scrollRegion(rect, dx, dy),
1056
+ get damage() {
1057
+ return root._damage;
1058
+ },
1059
+ set damage(rects) {
1060
+ root._damage = rects;
1061
+ },
1062
+ };
997
1063
  }
998
1064
 
999
1065
  /**