react-x11 2.10.2 → 2.12.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 +278 -129
- package/package.json +10 -3
- package/src/Reconciler.js +15 -17
- package/src/a11y.js +2 -2
- package/src/anchor.js +7 -5
- package/src/bootstrap.js +14 -0
- package/src/clientmessage.js +1 -1
- package/src/cocoa/app.js +304 -49
- package/src/cocoa/bezels.js +175 -30
- package/src/cocoa/dnd.js +27 -13
- package/src/cocoa/fonts.js +3 -3
- package/src/cocoa/glarea.js +20 -3
- package/src/cocoa/main.d.ts +8 -0
- package/src/cocoa/main.js +43 -0
- package/src/cocoa/panehost.js +15 -5
- package/src/cocoa/presenter.js +13 -9
- package/src/cocoa/promotion.js +4 -7
- package/src/cocoa/relaunch.js +207 -0
- package/src/cocoa/screencolor.js +62 -0
- package/src/cocoa/threaded.js +246 -0
- package/src/cocoa/window.js +256 -42
- package/src/components/Select.js +2 -2
- package/src/components/anchor.js +3 -3
- package/src/components/native.js +12 -7
- package/src/components/theme.js +2 -2
- package/src/debug.js +1 -1
- package/src/decorations.js +1 -1
- package/src/editmenu.js +2 -2
- package/src/errors.js +46 -0
- package/src/events.js +6 -6
- package/src/foreignnodes.js +3 -2
- package/src/frames.js +2 -2
- package/src/glnodes.js +1 -1
- package/src/grid.js +1653 -0
- package/src/host.d.ts +230 -0
- package/src/host.js +11 -3
- package/src/imagesource.js +1 -1
- package/src/index.d.ts +21 -4
- package/src/index.js +9 -1
- package/src/layouts.js +721 -0
- package/src/node.d.ts +4 -2
- package/src/node.js +19 -21
- package/src/nodes/animation.js +644 -0
- package/src/nodes/box.js +21 -0
- package/src/nodes/boxpaint.js +473 -0
- package/src/nodes/canvas.js +269 -0
- package/src/nodes/cascade.js +600 -0
- package/src/nodes/damage.js +183 -0
- package/src/nodes/edithistory.js +124 -0
- package/src/nodes/editmenupopup.js +260 -0
- package/src/nodes/hittest.js +185 -0
- package/src/nodes/image.js +266 -0
- package/src/nodes/install.js +75 -0
- package/src/nodes/invalidate.js +465 -0
- package/src/nodes/kinds.js +31 -0
- package/src/nodes/layout.js +439 -0
- package/src/nodes/layouthost.js +949 -0
- package/src/nodes/node.js +868 -0
- package/src/nodes/paint.js +466 -0
- package/src/nodes/position.js +366 -0
- package/src/nodes/preedit.js +127 -0
- package/src/nodes/queries.js +330 -0
- package/src/nodes/rects.js +102 -0
- package/src/nodes/scrollable.js +891 -0
- package/src/nodes/scrollbars.js +138 -0
- package/src/nodes/scrollblit.js +1034 -0
- package/src/nodes/selectable.js +142 -0
- package/src/nodes/styling.js +225 -0
- package/src/nodes/text.js +649 -0
- package/src/nodes/textarea.js +391 -0
- package/src/nodes/textinput.js +1146 -0
- package/src/nodes/util.js +17 -0
- package/src/nodes/window/anchoring.js +161 -0
- package/src/nodes/window/capabilities.js +190 -0
- package/src/nodes/window/debugpaint.js +83 -0
- package/src/nodes/window/droptarget.js +145 -0
- package/src/nodes/window/floors.js +577 -0
- package/src/nodes/window/flush.js +334 -0
- package/src/nodes/window/hints.js +482 -0
- package/src/nodes/window/listeners.js +222 -0
- package/src/nodes/window/popup.js +71 -0
- package/src/nodes/window/size.js +591 -0
- package/src/nodes/window/window.js +945 -0
- package/src/palette.js +1 -1
- package/src/registry.js +7 -3
- package/src/screencolor.js +212 -38
- package/src/screencolorhooks.js +6 -2
- package/src/styles.js +137 -15
- package/src/svgnodes.js +2 -1
- package/src/testing/harness.js +2 -2
- package/src/textselection.js +5 -3
- package/src/trace-registry.js +1 -1
- package/src/types/components.d.ts +38 -6
- package/src/types/elements.d.ts +11 -1
- package/src/types/nodes.d.ts +33 -5
- package/src/types/screencolor.d.ts +20 -14
- package/src/types/style.d.ts +94 -3
- package/src/windowstate.js +1 -1
- package/src/yoga.js +1 -1
- package/src/nodes.js +0 -13120
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
// The Scrollable mixin: `overflow: 'scroll'` with the wheel, the keys, the
|
|
2
|
+
// bars and the a11y role wired. <box> and <window> use it, and a registered
|
|
3
|
+
// element can too — `class MyPane extends Scrollable(Node)`.
|
|
4
|
+
|
|
5
|
+
import { Yoga } from '../yoga.js';
|
|
6
|
+
import { WHEEL_NOTCH_PX } from '../events.js';
|
|
7
|
+
import { callHandler } from '../errors.js';
|
|
8
|
+
import {
|
|
9
|
+
XK_HOME,
|
|
10
|
+
XK_LEFT,
|
|
11
|
+
XK_UP,
|
|
12
|
+
XK_RIGHT,
|
|
13
|
+
XK_DOWN,
|
|
14
|
+
XK_PAGE_UP,
|
|
15
|
+
XK_PAGE_DOWN,
|
|
16
|
+
XK_END,
|
|
17
|
+
XK_SPACE,
|
|
18
|
+
} from '../keysyms.js';
|
|
19
|
+
import { DAMAGE_SLOP, layoutDiff } from './damage.js';
|
|
20
|
+
import { describeSize, offsetInParent } from './layout.js';
|
|
21
|
+
import { insetRect, intersectRects, rectsOverlap } from './rects.js';
|
|
22
|
+
import {
|
|
23
|
+
SCROLLBAR_WIDTH,
|
|
24
|
+
scrollbarGeometry,
|
|
25
|
+
along,
|
|
26
|
+
scrollbarHit,
|
|
27
|
+
paintScrollbarThumb,
|
|
28
|
+
} from './scrollbars.js';
|
|
29
|
+
import { BLIT_POISONED } from './scrollblit.js';
|
|
30
|
+
|
|
31
|
+
const clampScroll = (v, max) => Math.min(Math.max(0, v), max);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One axis of a scroll request held for a pass
|
|
35
|
+
* (`Scrollable._holdScrollTo`): `{ base, steps }`, the request the extent in
|
|
36
|
+
* hand could not answer and the relative ones made after it. `to` is the new
|
|
37
|
+
* request on this axis (null leaves the axis alone), `step` its delta when
|
|
38
|
+
* it was relative.
|
|
39
|
+
*/
|
|
40
|
+
function holdAxis(held, to, step, owed, max) {
|
|
41
|
+
if (to == null) return held ?? null;
|
|
42
|
+
if (held && step != null) {
|
|
43
|
+
return { base: held.base, steps: [...held.steps, step] };
|
|
44
|
+
}
|
|
45
|
+
return owed && to > max ? { base: to, steps: [] } : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Where a held axis lands against the extent a pass measured: the base,
|
|
49
|
+
* clamped, then each step from there, clamped in turn — the answers a pane
|
|
50
|
+
* that already had this extent would have given one request at a time. */
|
|
51
|
+
function replayHeld({ base, steps }, max) {
|
|
52
|
+
let at = clampScroll(base, max);
|
|
53
|
+
for (const step of steps) at = clampScroll(at + step, max);
|
|
54
|
+
return at;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Every box and every window now answers `_scrollbars()`, and almost none of
|
|
58
|
+
// them has any: the shared empty keeps that answer allocation-free on a path
|
|
59
|
+
// walked per node per hit test.
|
|
60
|
+
const EMPTY_SCROLLBARS = Object.freeze([]);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Scrolling, as a style rather than as a species of node.
|
|
64
|
+
*
|
|
65
|
+
* `overflow: 'scroll'` turns a `<box>` — or a `<window>` — into a clipped
|
|
66
|
+
* viewport over its own overflowing content: the offset is applied during
|
|
67
|
+
* absolutize, so painting and hit testing see already-shifted rects. Wheel
|
|
68
|
+
* events scroll the nearest one by default (see EventManager), and
|
|
69
|
+
* `scrollTo`/`scrollBy`/`scrollIntoView` are on the ref.
|
|
70
|
+
*
|
|
71
|
+
* Everything here is **inert until the style says scroll**, which is what
|
|
72
|
+
* lets it live on the ordinary container instead of behind an element of its
|
|
73
|
+
* own: `_maxScroll` answers 0, so `scrollTo` is a no-op, no bar has geometry,
|
|
74
|
+
* nothing is a tab stop, and `absolutize` takes the plain path. A second gate
|
|
75
|
+
* sits behind the first — most of the visible behaviour also asks whether
|
|
76
|
+
* there is anything to scroll *right now* — so a viewport whose content fits
|
|
77
|
+
* really is an ordinary clipped box, and grows a thumb and a tab stop the
|
|
78
|
+
* moment its content outgrows it.
|
|
79
|
+
*
|
|
80
|
+
* A mixin rather than a base class because the two elements that scroll do
|
|
81
|
+
* not share one: `<box>` extends Node directly and `<window>` is its own
|
|
82
|
+
* world. Deliberately not on Node itself — `<textinput>`/`<textarea>` carry
|
|
83
|
+
* their own `_scrollbar` and `focusableByDefault` with different meanings,
|
|
84
|
+
* and inheriting these would collide with both.
|
|
85
|
+
*/
|
|
86
|
+
export const Scrollable = (Base) =>
|
|
87
|
+
class extends Base {
|
|
88
|
+
constructor(...args) {
|
|
89
|
+
super(...args);
|
|
90
|
+
this.scrollY = 0;
|
|
91
|
+
this.scrollX = 0;
|
|
92
|
+
this.contentHeight = 0;
|
|
93
|
+
this.contentWidth = 0;
|
|
94
|
+
// `measureScrollContent` owed a fresh answer — true until the first
|
|
95
|
+
// layout pass measures, and re-raised by any change yoga cannot see
|
|
96
|
+
// (`_markScrollMeasureDirty`, issue #405)
|
|
97
|
+
this._scrollMeasureDirty = true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Does this node scroll what overflows it? The one gate everything below
|
|
102
|
+
* reads, and the reason `overflow: 'scroll'` and `overflow: 'hidden'`
|
|
103
|
+
* are now genuinely different things: both clip, only this one scrolls.
|
|
104
|
+
*
|
|
105
|
+
* The layout defaults that go with it — `flex-basis: 0`, `min-width: 0`,
|
|
106
|
+
* `min-height: 0` — are folded into the resolved style by
|
|
107
|
+
* `resolveComputedStyle` (styles.js), so they travel through the same
|
|
108
|
+
* diff as any other style and come back off when the overflow does.
|
|
109
|
+
*/
|
|
110
|
+
isScroller() {
|
|
111
|
+
return this.style.overflow === 'scroll';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Stopped being a scroll container: an offset nothing will clamp again
|
|
116
|
+
* would otherwise keep the content shifted forever. CSS loses the scroll
|
|
117
|
+
* position the same way when a box stops scrolling.
|
|
118
|
+
*/
|
|
119
|
+
_overflowChanged() {
|
|
120
|
+
// whichever way the style flipped, the next scrolling pass starts
|
|
121
|
+
// from a fresh measurement
|
|
122
|
+
this._scrollMeasureDirty = true;
|
|
123
|
+
if (this.isScroller()) return;
|
|
124
|
+
this._scrollIntoViewTarget = null;
|
|
125
|
+
this._scrollToTarget = null;
|
|
126
|
+
this._childOrigin = null;
|
|
127
|
+
if (this.scrollX === 0 && this.scrollY === 0) return;
|
|
128
|
+
this.scrollX = 0;
|
|
129
|
+
this.scrollY = 0;
|
|
130
|
+
this._invalidateLayout('scroll');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
absolutize(originX, originY) {
|
|
134
|
+
this._placed = true;
|
|
135
|
+
if (!this.yoga) return;
|
|
136
|
+
this._assignAbs(
|
|
137
|
+
originX + this.yoga.getComputedLeft(),
|
|
138
|
+
originY + this.yoga.getComputedTop(),
|
|
139
|
+
this.yoga.getComputedWidth(),
|
|
140
|
+
this.yoga.getComputedHeight(),
|
|
141
|
+
);
|
|
142
|
+
if (this.props.onLayout) this._reportLayout();
|
|
143
|
+
this._absolutizeChildren(this.abs.x, this.abs.y);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Place the children, shifted by the scroll offset when there is one.
|
|
148
|
+
* Split out of `absolutize` because a `<window>` writes its own `abs`
|
|
149
|
+
* during flush and then walks its children from (0, 0) — the same walk,
|
|
150
|
+
* reached by a different route.
|
|
151
|
+
*/
|
|
152
|
+
_absolutizeChildren(originX, originY) {
|
|
153
|
+
if (!this.isScroller()) {
|
|
154
|
+
// a layout host's children go where its algorithm put them
|
|
155
|
+
if (this._host !== null) {
|
|
156
|
+
this._absolutizeHostChildren();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
for (const child of this.children) {
|
|
160
|
+
if (!child.isWindow) child.absolutize(originX, originY);
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const rtl = this.direction === 'rtl';
|
|
165
|
+
// A pure-scroll pass re-learns nothing by walking (issue #405): the
|
|
166
|
+
// content reach and every child's place *inside* the pane only change
|
|
167
|
+
// when layout inside the pane changes. Yoga's own has-new-layout flag
|
|
168
|
+
// is the witness — consumed here and nowhere else — set by any pass
|
|
169
|
+
// that laid this node or anything under it out again, and left clear
|
|
170
|
+
// by one that merely scrolled. `_scrollMeasureDirty` covers the one
|
|
171
|
+
// route yoga cannot see: an element that paints its own content
|
|
172
|
+
// growing its extent (docs/extending.md), announced through
|
|
173
|
+
// `invalidate(true, this, 'scroll')`. The root's yoga node re-flags
|
|
174
|
+
// on every pass, so a `<window overflow='scroll'>` always takes the
|
|
175
|
+
// full walk — the pane that holds an app's long list is a box.
|
|
176
|
+
const clean =
|
|
177
|
+
this._childOrigin != null &&
|
|
178
|
+
!this._scrollMeasureDirty &&
|
|
179
|
+
!this.yoga.hasNewLayout();
|
|
180
|
+
if (!clean) {
|
|
181
|
+
const size = this.measureScrollContent();
|
|
182
|
+
if (!Number.isFinite(size?.width) || !Number.isFinite(size?.height)) {
|
|
183
|
+
// A NaN here does not throw on its own: it becomes a NaN max
|
|
184
|
+
// scroll, a NaN offset, and every child laid out at NaN — a whole
|
|
185
|
+
// tree gone with nothing naming the element that did it.
|
|
186
|
+
throw new Error(
|
|
187
|
+
`react-x11: <${this.kind}>.measureScrollContent() must return ` +
|
|
188
|
+
'{ width, height } as finite numbers; it returned ' +
|
|
189
|
+
`${describeSize(size)}. Return { width: 0, height: 0 } for ` +
|
|
190
|
+
'content that has not arrived yet.',
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
this.contentWidth = size.width;
|
|
194
|
+
this.contentHeight = size.height;
|
|
195
|
+
this._scrollMeasureDirty = false;
|
|
196
|
+
this.yoga.markLayoutSeen();
|
|
197
|
+
}
|
|
198
|
+
// The moves layout makes on its own, with nobody's call to report them
|
|
199
|
+
// from: a `scrollTo` held for this pane's first pass landing, a
|
|
200
|
+
// `scrollIntoView` resolving against the geometry this pass produced,
|
|
201
|
+
// and the clamp pulling the offset back when the content shrank, or
|
|
202
|
+
// the viewport grew, under it. A browser fires `scroll` for each, so
|
|
203
|
+
// `onScroll` hears of them too, once the pass is over.
|
|
204
|
+
const from = { x: this.scrollX, y: this.scrollY };
|
|
205
|
+
// A scrollTo held for this pass lands first, so a node asked into view
|
|
206
|
+
// in the same frame is brought in from where that scroll put the pane:
|
|
207
|
+
// the order the two have on a laid-out pane, where scrollTo applies at
|
|
208
|
+
// once and scrollIntoView waits for the pass.
|
|
209
|
+
this._resolveScrollTo();
|
|
210
|
+
this._resolveScrollIntoView();
|
|
211
|
+
this.scrollY = clampScroll(this.scrollY, this._maxScroll('y'));
|
|
212
|
+
this.scrollX = clampScroll(this.scrollX, this._maxScroll('x'));
|
|
213
|
+
this._reportViewport();
|
|
214
|
+
this._reportScrollTo(from);
|
|
215
|
+
// `scrollX` is how far the content has moved **from its start**, which
|
|
216
|
+
// is the right-hand edge in RTL — so scrolling shifts the children the
|
|
217
|
+
// other way. Keeping it a distance rather than a coordinate is what
|
|
218
|
+
// makes `scrollTo({x: 0})` mean "back to the beginning" in both
|
|
219
|
+
// directions, and keeps every clamp and every max in one sign. What
|
|
220
|
+
// moves pixels with the content does not share it: the scroll blit
|
|
221
|
+
// asks `_blitShift`, which asks the direction.
|
|
222
|
+
const ox = rtl ? originX + this.scrollX : originX - this.scrollX;
|
|
223
|
+
const oy = originY - this.scrollY;
|
|
224
|
+
// The layout diff and a scroll would double-report each other: a scroll
|
|
225
|
+
// is a uniform shift of everything below this viewport, already claimed
|
|
226
|
+
// as the viewport itself (or narrowed to the exposed strip by the blit),
|
|
227
|
+
// and per-child old/new claims would re-widen the very frame the blit
|
|
228
|
+
// narrows. So when the children's origin moved, the walk below runs
|
|
229
|
+
// with the diff off. When it did not move, a child that moved did so by
|
|
230
|
+
// real layout — claim it, but clipped to the viewport: ink below the
|
|
231
|
+
// fold never reaches the surface, and an unclipped claim would repaint
|
|
232
|
+
// whatever unrelated UI sits under this node's off-viewport extent.
|
|
233
|
+
const wasOrigin = this._childOrigin;
|
|
234
|
+
const shifted = wasOrigin && (wasOrigin.x !== ox || wasOrigin.y !== oy);
|
|
235
|
+
this._childOrigin = { x: ox, y: oy };
|
|
236
|
+
if (clean) {
|
|
237
|
+
// The fast path (issue #405): nothing inside was laid out, so every
|
|
238
|
+
// child sits exactly where the last pass put it, shifted by however
|
|
239
|
+
// far the origin moved — one uniform translation instead of a
|
|
240
|
+
// per-node yoga re-derivation. The layout diff is owed nothing by
|
|
241
|
+
// construction: under a blit ledger the shifted diff's claims are
|
|
242
|
+
// the deviations from this very translation, and a clean pane has
|
|
243
|
+
// none — the walk below lands every node where the diff would have
|
|
244
|
+
// reported silence.
|
|
245
|
+
if (!shifted) return;
|
|
246
|
+
const dx = ox - wasOrigin.x;
|
|
247
|
+
const dy = oy - wasOrigin.y;
|
|
248
|
+
for (const child of this.children) {
|
|
249
|
+
if (!child.isWindow) child._shiftAbs(dx, dy);
|
|
250
|
+
}
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const outer = layoutDiff.sink;
|
|
254
|
+
const outerShift = layoutDiff.shift;
|
|
255
|
+
const ledger = shifted && this._blitLedgerOpen();
|
|
256
|
+
if (outer) {
|
|
257
|
+
if (ledger) {
|
|
258
|
+
// The blit's own ledger takes this walk (issue #398). The shift
|
|
259
|
+
// below is what makes the diff worth running under a scroll at
|
|
260
|
+
// all: without it every child reports the move the blit is about
|
|
261
|
+
// to make for them, and the claims add up to the viewport. What
|
|
262
|
+
// is left is the virtualized list's real frame — the rows that
|
|
263
|
+
// entered, the ones that left, a spacer that resized — and it
|
|
264
|
+
// goes to the ledger rather than to `outer`, whose claims are
|
|
265
|
+
// what `layoutMoved` reads as "this frame is not a pure scroll".
|
|
266
|
+
const vp = insetRect(this.abs, -DAMAGE_SLOP);
|
|
267
|
+
layoutDiff.sink = (rect) => {
|
|
268
|
+
const clipped = intersectRects(rect, vp);
|
|
269
|
+
if (clipped && !this._recordBlitClaim(clipped)) {
|
|
270
|
+
this._pendingBlitFrom = BLIT_POISONED;
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
layoutDiff.shift = { x: ox - wasOrigin.x, y: oy - wasOrigin.y };
|
|
274
|
+
} else if (shifted) {
|
|
275
|
+
layoutDiff.sink = null;
|
|
276
|
+
} else {
|
|
277
|
+
const vp = insetRect(this.abs, -DAMAGE_SLOP);
|
|
278
|
+
layoutDiff.sink = (rect) => {
|
|
279
|
+
const clipped = intersectRects(rect, vp);
|
|
280
|
+
if (clipped) outer(clipped);
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
for (const child of this.children) {
|
|
286
|
+
if (!child.isWindow) {
|
|
287
|
+
child.absolutize(ox, oy);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
} finally {
|
|
291
|
+
layoutDiff.sink = outer;
|
|
292
|
+
layoutDiff.shift = outerShift;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* A scroller inside a shifting subtree does not ride the translation
|
|
298
|
+
* blindly: its box moves rigidly, but its children's origin also
|
|
299
|
+
* carries the scroll offsets, which may have changed again this very
|
|
300
|
+
* frame — a wheel on a nested pane while an outer one scrolls.
|
|
301
|
+
* Re-entering `_absolutizeChildren` folds both into one delta, and
|
|
302
|
+
* re-runs the gate, so a nested pane that is not clean still walks
|
|
303
|
+
* properly. (Reached only under an outer pane's fast path, which
|
|
304
|
+
* proved nothing in here was laid out — the nested gate can only
|
|
305
|
+
* decline over its own `_scrollMeasureDirty`.)
|
|
306
|
+
*/
|
|
307
|
+
_shiftChildren(dx, dy) {
|
|
308
|
+
if (!this.isScroller()) return super._shiftChildren(dx, dy);
|
|
309
|
+
this._absolutizeChildren(this.abs.x, this.abs.y);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Tell the owner how big the viewport and the content turned out, when
|
|
314
|
+
* either changes. Layout happens on the frame clock, *after* the commit
|
|
315
|
+
* that mounted the node, so an effect cannot read this off the ref —
|
|
316
|
+
* which is exactly what a list needs before it can decide how many rows
|
|
317
|
+
* are worth building. Fired from layout rather than from scrolling, so
|
|
318
|
+
* it also arrives for a list nobody has scrolled yet.
|
|
319
|
+
*/
|
|
320
|
+
_reportViewport() {
|
|
321
|
+
// logical, like onScroll's payload: finder.jsx divides this height by
|
|
322
|
+
// a row height it wrote in a style
|
|
323
|
+
const s = this.scale;
|
|
324
|
+
const next = {
|
|
325
|
+
width: this.abs.width / s,
|
|
326
|
+
height: this.abs.height / s,
|
|
327
|
+
contentWidth: this.contentWidth / s,
|
|
328
|
+
contentHeight: this.contentHeight / s,
|
|
329
|
+
};
|
|
330
|
+
const last = this._lastViewport;
|
|
331
|
+
if (
|
|
332
|
+
last &&
|
|
333
|
+
last.width === next.width &&
|
|
334
|
+
last.height === next.height &&
|
|
335
|
+
last.contentWidth === next.contentWidth &&
|
|
336
|
+
last.contentHeight === next.contentHeight
|
|
337
|
+
) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
this._lastViewport = next;
|
|
341
|
+
// during layout: defer, or a setState from the handler would re-enter
|
|
342
|
+
// the pass that is still running
|
|
343
|
+
const notify = this.props.onViewport;
|
|
344
|
+
if (notify) setImmediate(() => !this.destroyed && notify(next));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* How far the content reaches — `scrollWidth`/`scrollHeight`, and what
|
|
349
|
+
* everything below scrolls against: the maxima, the bars, the keys.
|
|
350
|
+
*
|
|
351
|
+
* The default measures the **children**, through the subtree rather than
|
|
352
|
+
* off the direct ones. A row that stretches to the viewport while its own
|
|
353
|
+
* cells overflow it — a table, in other words — reports the viewport
|
|
354
|
+
* width at the top level and says nothing about the cells, so a shallow
|
|
355
|
+
* measurement would find nothing to scroll. Anything that clips its own
|
|
356
|
+
* children ends the walk: their overflow is that node's business.
|
|
357
|
+
*
|
|
358
|
+
* `width` is how far the content reaches from the edge it *starts* at,
|
|
359
|
+
* which is the right-hand one under `direction: 'rtl'` — yoga lays an
|
|
360
|
+
* overflowing RTL row out at negative offsets, so the reach that matters
|
|
361
|
+
* there is how far left of zero it got, not how far right. An element
|
|
362
|
+
* measuring its own drawing answers the same question and never has to
|
|
363
|
+
* ask which direction it is in.
|
|
364
|
+
*
|
|
365
|
+
* **Override it when the content is pixels rather than nodes.** An
|
|
366
|
+
* element that paints its own content — an editor drawing lines of text,
|
|
367
|
+
* a terminal, a canvas-backed table — has no children to walk, so the
|
|
368
|
+
* default measures 0 and the viewport clamps to nothing however far the
|
|
369
|
+
* drawing actually goes. Answering here is the whole of joining in: the
|
|
370
|
+
* wheel, the scrollbars, the scroll keys and the AT-SPI scroll pane all
|
|
371
|
+
* read the numbers this returns (docs/extending.md).
|
|
372
|
+
*
|
|
373
|
+
* Called at most once per layout pass, from `absolutize`, so it may
|
|
374
|
+
* read yoga geometry but must not invalidate or paint — and cached
|
|
375
|
+
* across passes that laid nothing inside the pane out again (issue
|
|
376
|
+
* #405): a pass that merely scrolled reuses the last answer, since a
|
|
377
|
+
* scroll cannot change how far the content reaches. An element whose
|
|
378
|
+
* extent changed by a route layout never saw — rows arrived, a line
|
|
379
|
+
* was typed — announces it with `invalidate(true, this, 'scroll')`,
|
|
380
|
+
* and the next pass asks again.
|
|
381
|
+
*/
|
|
382
|
+
measureScrollContent() {
|
|
383
|
+
const rtl = this.direction === 'rtl';
|
|
384
|
+
const width = this.yoga.getComputedWidth();
|
|
385
|
+
let start = 0;
|
|
386
|
+
let bottom = 0;
|
|
387
|
+
const walk = (node, dx, dy) => {
|
|
388
|
+
for (const child of node.children) {
|
|
389
|
+
if (child.isWindow || !child.yoga || child.hidden) continue;
|
|
390
|
+
const offset = offsetInParent(child);
|
|
391
|
+
const x = dx + offset.x;
|
|
392
|
+
const y = dy + offset.y;
|
|
393
|
+
const w = child.yoga.getComputedWidth();
|
|
394
|
+
start = Math.max(start, rtl ? width - x : x + w);
|
|
395
|
+
bottom = Math.max(bottom, y + child.yoga.getComputedHeight());
|
|
396
|
+
if (!child.clipsChildren()) walk(child, x, y);
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
walk(this, 0, 0);
|
|
400
|
+
// the end padding is part of the content box a browser scrolls to, and
|
|
401
|
+
// it is the one part of it yoga has already resolved for us — on the
|
|
402
|
+
// left in RTL, since that is the end there
|
|
403
|
+
return {
|
|
404
|
+
width:
|
|
405
|
+
start +
|
|
406
|
+
this.yoga.getComputedPadding(rtl ? Yoga.EDGE_LEFT : Yoga.EDGE_RIGHT),
|
|
407
|
+
height: bottom + this.yoga.getComputedPadding(Yoga.EDGE_BOTTOM),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* How far this axis can scroll — 0 for a node the style does not make a
|
|
413
|
+
* scroll container, which is the gate the whole public surface rests on:
|
|
414
|
+
* `scrollTo` clamps to nothing, no bar has geometry, and nothing is a
|
|
415
|
+
* tab stop, without any of them testing the style themselves.
|
|
416
|
+
*/
|
|
417
|
+
_maxScroll(axis) {
|
|
418
|
+
if (!this.isScroller()) return 0;
|
|
419
|
+
return axis === 'x'
|
|
420
|
+
? Math.max(0, this.contentWidth - this.abs.width)
|
|
421
|
+
: Math.max(0, this.contentHeight - this.abs.height);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* `scrollTo(y)` scrolls vertically, as it always has; `scrollTo({x, y})`
|
|
426
|
+
* moves either axis, leaving out whichever is omitted.
|
|
427
|
+
*/
|
|
428
|
+
/** Public entry, logical pixels — application code writes `scrollTo(120)`
|
|
429
|
+
* in the same unit as its styles. Internal callers hold device offsets
|
|
430
|
+
* and use `_scrollToDevice`/`_scrollByDevice` instead (src/scale.js). */
|
|
431
|
+
scrollTo(to) {
|
|
432
|
+
const s = this.scale;
|
|
433
|
+
this._scrollToDevice(
|
|
434
|
+
typeof to === 'number'
|
|
435
|
+
? { y: to * s }
|
|
436
|
+
: {
|
|
437
|
+
x: to?.x == null ? undefined : to.x * s,
|
|
438
|
+
y: to?.y == null ? undefined : to.y * s,
|
|
439
|
+
},
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
_scrollToDevice(want, by = null) {
|
|
444
|
+
const maxX = this._maxScroll('x');
|
|
445
|
+
const maxY = this._maxScroll('y');
|
|
446
|
+
const next = {
|
|
447
|
+
x: want.x == null ? this.scrollX : clampScroll(want.x, maxX),
|
|
448
|
+
y: want.y == null ? this.scrollY : clampScroll(want.y, maxY),
|
|
449
|
+
};
|
|
450
|
+
const moved = next.x !== this.scrollX || next.y !== this.scrollY;
|
|
451
|
+
const holding = this._holdScrollTo(want, by, maxX, maxY);
|
|
452
|
+
if (!moved && !holding) return;
|
|
453
|
+
const root = this.root;
|
|
454
|
+
// A pane no pass has placed arms nothing: its first pass is a layout
|
|
455
|
+
// change rather than a pure scroll.
|
|
456
|
+
if (root && this._childOrigin != null) {
|
|
457
|
+
// Arming is the one moment the evidence still exists: the viewport
|
|
458
|
+
// claim recorded below coalesces earlier claims into itself
|
|
459
|
+
// (addDamageRect keeps the list disjoint), after which a change
|
|
460
|
+
// inside the viewport is indistinguishable from the scroll's own
|
|
461
|
+
// claim — the blind spot the claim-time cancel in
|
|
462
|
+
// WindowNode.invalidate cannot cover (react-x11#295). The scroll
|
|
463
|
+
// has not claimed yet, so damage already overlapping this viewport
|
|
464
|
+
// is foreign by construction: poison the frame instead of arming,
|
|
465
|
+
// and the full-viewport repaint below stays in force.
|
|
466
|
+
const arming = this._pendingBlitFrom == null;
|
|
467
|
+
// The ledger this frame's changes inside the viewport are written
|
|
468
|
+
// to (issue #398). Opened with the blit and read by
|
|
469
|
+
// _applyScrollBlits, which clears it beside the origin.
|
|
470
|
+
if (arming) this._blitLedger = [];
|
|
471
|
+
if (arming && Array.isArray(root._damage)) {
|
|
472
|
+
const zone = insetRect(this.abs, -(DAMAGE_SLOP * 2 + 1));
|
|
473
|
+
for (const rect of root._damage) {
|
|
474
|
+
// Already coalesced, so these rects are as coarse as the frame
|
|
475
|
+
// has made them — which the ledger reads conservatively: a blob
|
|
476
|
+
// that swallowed the viewport says so and poisons, exactly as
|
|
477
|
+
// this gate used to for every claim it saw.
|
|
478
|
+
if (rectsOverlap(rect, zone) && !this._recordBlitClaim(rect)) {
|
|
479
|
+
this._pendingBlitFrom = BLIT_POISONED;
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
// An element that also shifted its own drawing this frame
|
|
485
|
+
// (`scrollContents`, issue #303) is two shifts of the same pixels,
|
|
486
|
+
// and a frame can only have one.
|
|
487
|
+
if (this._pendingBlitContents) this._pendingBlitFrom = BLIT_POISONED;
|
|
488
|
+
// The offsets whose pixels are on screen, captured before the first
|
|
489
|
+
// change of the frame: the frame's blit fast path (issue #138) shifts
|
|
490
|
+
// from *these* to wherever layout settles, however many scrollTo
|
|
491
|
+
// calls land in between.
|
|
492
|
+
this._pendingBlitFrom ??= { x: this.scrollX, y: this.scrollY };
|
|
493
|
+
(root._pendingScrolls ??= new Set()).add(this);
|
|
494
|
+
// ... and the claim about to be recorded is the scroll itself, not a
|
|
495
|
+
// reason to un-blit it
|
|
496
|
+
root._scrollClaim = this;
|
|
497
|
+
}
|
|
498
|
+
this.scrollX = next.x;
|
|
499
|
+
this.scrollY = next.y;
|
|
500
|
+
if (moved) this.props.onScroll?.(this._scrollEvent());
|
|
501
|
+
// A scroll reflows this viewport's contents and nothing else, and the
|
|
502
|
+
// viewport clips them, so the damage is this node's own rect. It is a
|
|
503
|
+
// layout change all the same — children's absolute positions move — hence
|
|
504
|
+
// both arguments. Unbounded, every wheel notch repainted the whole window,
|
|
505
|
+
// which is the whole cost of scrolling: the client work is negligible next
|
|
506
|
+
// to what the server then has to redraw. (When the frame turns out to be
|
|
507
|
+
// a *pure* scroll, _applyScrollBlits later narrows this claim to the
|
|
508
|
+
// exposed strip and blits the rest — see WindowNode.)
|
|
509
|
+
this.root?.invalidate(true, this, 'scroll');
|
|
510
|
+
if (root) root._scrollClaim = null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Keep the part of a request the extent in hand cannot answer, for the
|
|
515
|
+
* pass that will measure one that can. `_scrollToDevice` clamps against
|
|
516
|
+
* `contentWidth`/`contentHeight` and `abs` as the last pass left them,
|
|
517
|
+
* and two kinds of pane have nothing better there yet:
|
|
518
|
+
*
|
|
519
|
+
* - one no pass has placed as a scroller (`_childOrigin` is the
|
|
520
|
+
* witness): on mount those are still the zeros they were built with,
|
|
521
|
+
* and a box that starts scrolling in the same commit never measured
|
|
522
|
+
* them. Restoring a list's position from a mount-time effect is the
|
|
523
|
+
* case.
|
|
524
|
+
* - one whose next pass is already owed (`needsLayout`): rows mounted in
|
|
525
|
+
* the commit the request comes from are not in the extent yet, so a
|
|
526
|
+
* follow to the end from a layout effect stopped at the old end.
|
|
527
|
+
*
|
|
528
|
+
* What the extent in hand can answer still lands at once, with its
|
|
529
|
+
* `onScroll` and its blit origin. The request is kept as well, and
|
|
530
|
+
* `_resolveScrollTo` answers it again against what the pass measures,
|
|
531
|
+
* before anything is placed.
|
|
532
|
+
*
|
|
533
|
+
* Per axis, a request replaces whatever was held on that axis, and a
|
|
534
|
+
* relative one (`by`) made while something is held is kept as a step
|
|
535
|
+
* after it, so the pass replays the frame's requests the way a pane with
|
|
536
|
+
* a fresh extent would have taken them. Returns whether anything is
|
|
537
|
+
* held.
|
|
538
|
+
*/
|
|
539
|
+
_holdScrollTo(want, by, maxX, maxY) {
|
|
540
|
+
const root = this.root;
|
|
541
|
+
const owed =
|
|
542
|
+
this.isScroller() &&
|
|
543
|
+
(this._childOrigin == null ||
|
|
544
|
+
(root != null && root.needsLayout && !root._inFlush));
|
|
545
|
+
const was = this._scrollToTarget;
|
|
546
|
+
const x = holdAxis(was?.x, want.x, by?.x, owed, maxX);
|
|
547
|
+
const y = holdAxis(was?.y, want.y, by?.y, owed, maxY);
|
|
548
|
+
this._scrollToTarget = x || y ? { x, y } : null;
|
|
549
|
+
if (!this._scrollToTarget) return false;
|
|
550
|
+
if (root) (root._heldScrolls ??= new Set()).add(this);
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Answer a held request (`_holdScrollTo`) against the extent this pass
|
|
556
|
+
* has just measured: each held axis's base, clamped, then each step
|
|
557
|
+
* after it, clamped in turn. Its `onScroll` is the pass's, like every
|
|
558
|
+
* move layout makes (see `_absolutizeChildren`).
|
|
559
|
+
*/
|
|
560
|
+
_resolveScrollTo() {
|
|
561
|
+
const held = this._scrollToTarget;
|
|
562
|
+
if (!held) return;
|
|
563
|
+
this._scrollToTarget = null;
|
|
564
|
+
this.root?._heldScrolls?.delete(this);
|
|
565
|
+
if (held.x) this.scrollX = replayHeld(held.x, this._maxScroll('x'));
|
|
566
|
+
if (held.y) this.scrollY = replayHeld(held.y, this._maxScroll('y'));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* `onScroll` for an offset a layout pass moved — a held `scrollTo`
|
|
571
|
+
* landing, a `scrollIntoView` resolving, or the clamp when the content
|
|
572
|
+
* shrank or the viewport grew (see `_absolutizeChildren`) — when the
|
|
573
|
+
* pass left the pane somewhere other than `from`. Deferred like
|
|
574
|
+
* `onViewport`, since a setState from the handler would re-enter the
|
|
575
|
+
* pass, so it arrives after the frame that first shows the new offset.
|
|
576
|
+
* The payload is read on delivery, not now: a wheel landing in between
|
|
577
|
+
* has already reported where it went, and a payload from before it
|
|
578
|
+
* would leave the handler behind the pane.
|
|
579
|
+
*/
|
|
580
|
+
_reportScrollTo(from) {
|
|
581
|
+
if (from.x === this.scrollX && from.y === this.scrollY) return;
|
|
582
|
+
setImmediate(() => {
|
|
583
|
+
const notify = this.props.onScroll;
|
|
584
|
+
if (this.destroyed || !notify) return;
|
|
585
|
+
callHandler(this, 'onScroll', notify, this._scrollEvent());
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** `onScroll`'s payload, for the offsets in force. The handler is
|
|
590
|
+
* application code, so it is logical like every payload: finder.jsx's
|
|
591
|
+
* row virtualisation divides scrollY by a row height it wrote in a
|
|
592
|
+
* style, and those must be the same unit. */
|
|
593
|
+
_scrollEvent() {
|
|
594
|
+
const s = this.scale;
|
|
595
|
+
return {
|
|
596
|
+
scrollX: this.scrollX / s,
|
|
597
|
+
scrollY: this.scrollY / s,
|
|
598
|
+
contentWidth: this.contentWidth / s,
|
|
599
|
+
contentHeight: this.contentHeight / s,
|
|
600
|
+
viewportWidth: this.abs.width / s,
|
|
601
|
+
viewportHeight: this.abs.height / s,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Is there room to move on the axis this delta names? The first half of
|
|
607
|
+
* the wheel's chain protocol (`canScroll` then `scrollBy`, see
|
|
608
|
+
* docs/extending.md): a scroll container that fits its content answers
|
|
609
|
+
* no and hands the gesture to the next one out, the way a browser does.
|
|
610
|
+
*
|
|
611
|
+
* Position is deliberately not part of the answer — a viewport scrolled
|
|
612
|
+
* to its bottom still owns the wheel, rather than passing the rest of a
|
|
613
|
+
* flick to whatever is behind it.
|
|
614
|
+
*/
|
|
615
|
+
canScroll(dx, dy) {
|
|
616
|
+
if (dx && this._maxScroll('x') > 0) return true;
|
|
617
|
+
if (dy && this._maxScroll('y') > 0) return true;
|
|
618
|
+
return false;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** `scrollBy(dy)`, or `scrollBy({x, y})` for either axis. Logical, like
|
|
622
|
+
* `scrollTo`. */
|
|
623
|
+
scrollBy(by) {
|
|
624
|
+
const s = this.scale;
|
|
625
|
+
const step =
|
|
626
|
+
typeof by === 'number'
|
|
627
|
+
? { y: by * s }
|
|
628
|
+
: {
|
|
629
|
+
x: by?.x == null ? undefined : by.x * s,
|
|
630
|
+
y: by?.y == null ? undefined : by.y * s,
|
|
631
|
+
};
|
|
632
|
+
this._scrollToDevice(
|
|
633
|
+
{
|
|
634
|
+
x: step.x == null ? undefined : this.scrollX + step.x,
|
|
635
|
+
y: step.y == null ? undefined : this.scrollY + step.y,
|
|
636
|
+
},
|
|
637
|
+
step,
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** The wheel's and the key handler's entry: whole device pixels, which
|
|
642
|
+
* is what keeps the scroll blit on the pixel grid at any scale. */
|
|
643
|
+
_scrollByDevice(dx, dy) {
|
|
644
|
+
this._scrollToDevice(
|
|
645
|
+
{
|
|
646
|
+
x: dx ? this.scrollX + dx : undefined,
|
|
647
|
+
y: dy ? this.scrollY + dy : undefined,
|
|
648
|
+
},
|
|
649
|
+
{ x: dx || undefined, y: dy || undefined },
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* A box with something to scroll is a tab stop, so a pane of
|
|
655
|
+
* *unfocusable* content — a log, a long `<text>`, a rendered document —
|
|
656
|
+
* can be read without a pointer. Before this the only way to scroll one was the
|
|
657
|
+
* wheel, which is a WCAG 2.1.1 failure on the most ordinary layout the
|
|
658
|
+
* library has.
|
|
659
|
+
*
|
|
660
|
+
* Conditional on purpose: a scroll box that fits its content is an
|
|
661
|
+
* ordinary clipped box, and stopping Tab on it would be a tab stop that
|
|
662
|
+
* does nothing. It is answered from the current layout, so a pane that
|
|
663
|
+
* grows past its viewport becomes reachable the moment it does.
|
|
664
|
+
*/
|
|
665
|
+
get focusableByDefault() {
|
|
666
|
+
return this._scrollsWithKeys();
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Is there anything here for the scroll keys to move? Separate from
|
|
670
|
+
* `focusableByDefault` because a box can now be a focus target for
|
|
671
|
+
* another reason — a `selectable` document is one (a11y.js) — and a
|
|
672
|
+
* document that does not scroll must still leave the arrows alone. */
|
|
673
|
+
_scrollsWithKeys() {
|
|
674
|
+
return this._maxScroll('y') > 0 || this._maxScroll('x') > 0;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* The keys a scroll pane answers, matching what every desktop toolkit
|
|
679
|
+
* does: arrows by a wheel notch, PageUp/PageDown by a viewport, Home/End
|
|
680
|
+
* to the ends, Space and Shift+Space as a second pair of page keys
|
|
681
|
+
* because that is what a reader's hand is already on.
|
|
682
|
+
*
|
|
683
|
+
* Runs after the application's own `onKeyDown`, and not at all if that
|
|
684
|
+
* called `preventDefault` — the same contract `<textinput>` editing has.
|
|
685
|
+
*/
|
|
686
|
+
defaultKeyDown(ev) {
|
|
687
|
+
// nothing to scroll, nothing to swallow: a plain box must leave the
|
|
688
|
+
// arrows and Page keys to whatever else would answer them
|
|
689
|
+
if (!this._scrollsWithKeys()) return super.defaultKeyDown(ev);
|
|
690
|
+
const step = SCROLL_KEY_STEP * this.scale;
|
|
691
|
+
const page = Math.max(
|
|
692
|
+
1,
|
|
693
|
+
this.abs.height - SCROLL_KEY_PAGE_OVERLAP * this.scale,
|
|
694
|
+
);
|
|
695
|
+
// Left and Right are the directions on the *screen*, and `scrollX` runs
|
|
696
|
+
// from the start of the content — so which of them moves it forward
|
|
697
|
+
// depends on which way the content runs. Home/End and the Page keys
|
|
698
|
+
// need no such rule: they already name the logical ends.
|
|
699
|
+
const forward = this.direction === 'rtl' ? -step : step;
|
|
700
|
+
switch (ev.keysym) {
|
|
701
|
+
case XK_DOWN:
|
|
702
|
+
return this._scrollByDevice(0, step);
|
|
703
|
+
case XK_UP:
|
|
704
|
+
return this._scrollByDevice(0, -step);
|
|
705
|
+
case XK_RIGHT:
|
|
706
|
+
return this._scrollByDevice(forward, 0);
|
|
707
|
+
case XK_LEFT:
|
|
708
|
+
return this._scrollByDevice(-forward, 0);
|
|
709
|
+
case XK_PAGE_DOWN:
|
|
710
|
+
return this._scrollByDevice(0, page);
|
|
711
|
+
case XK_PAGE_UP:
|
|
712
|
+
return this._scrollByDevice(0, -page);
|
|
713
|
+
case XK_HOME:
|
|
714
|
+
return this._scrollToDevice({ y: 0 });
|
|
715
|
+
case XK_END:
|
|
716
|
+
// the end the pass measures, if one is owed (`_holdScrollTo`)
|
|
717
|
+
return this._scrollToDevice({ y: Infinity });
|
|
718
|
+
case XK_SPACE:
|
|
719
|
+
return this._scrollByDevice(0, ev.shiftKey ? -page : page);
|
|
720
|
+
default:
|
|
721
|
+
return super.defaultKeyDown(ev);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Scroll the minimum amount that brings a descendant fully into view.
|
|
727
|
+
* The request is queued rather than applied immediately: absolute rects
|
|
728
|
+
* only exist after a layout pass, so a caller reacting to a mount (a
|
|
729
|
+
* list widget moving its selection, say) would otherwise measure a node
|
|
730
|
+
* that has no geometry yet. `absolutize` resolves it against freshly
|
|
731
|
+
* computed yoga positions, and `onScroll` reports the move once that
|
|
732
|
+
* pass is over (`_reportScrollTo`).
|
|
733
|
+
*/
|
|
734
|
+
scrollIntoView(node) {
|
|
735
|
+
if (!node || !this.isScroller()) return;
|
|
736
|
+
this._scrollIntoViewTarget = node;
|
|
737
|
+
// whatever the resolved scroll moves is inside this clipped viewport,
|
|
738
|
+
// so the viewport's own before/after rects bound the frame
|
|
739
|
+
this._invalidateLayout('scroll');
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
_resolveScrollIntoView() {
|
|
743
|
+
const target = this._scrollIntoViewTarget;
|
|
744
|
+
if (!target) return;
|
|
745
|
+
this._scrollIntoViewTarget = null;
|
|
746
|
+
if (target.destroyed || !target.yoga) return;
|
|
747
|
+
// offset of the target within our content box, summed up the chain so
|
|
748
|
+
// targets nested below a direct child work too
|
|
749
|
+
let top = 0;
|
|
750
|
+
let left = 0;
|
|
751
|
+
for (let n = target; n && n !== this; n = n.parent) {
|
|
752
|
+
if (!n.yoga) return; // not (or no longer) inside this viewport
|
|
753
|
+
const offset = offsetInParent(n);
|
|
754
|
+
top += offset.y;
|
|
755
|
+
left += offset.x;
|
|
756
|
+
if (!n.parent) return;
|
|
757
|
+
}
|
|
758
|
+
const bottom = top + target.yoga.getComputedHeight();
|
|
759
|
+
// Horizontally the two edges are measured from the content's **start**,
|
|
760
|
+
// the same units `scrollX` is in — so under RTL the target's near edge
|
|
761
|
+
// is its right one and both are counted back from the viewport's width.
|
|
762
|
+
const w = target.yoga.getComputedWidth();
|
|
763
|
+
const near =
|
|
764
|
+
this.direction === 'rtl'
|
|
765
|
+
? this.yoga.getComputedWidth() - left - w
|
|
766
|
+
: left;
|
|
767
|
+
const far = near + w;
|
|
768
|
+
if (bottom > this.scrollY + this.abs.height) {
|
|
769
|
+
this.scrollY = bottom - this.abs.height;
|
|
770
|
+
}
|
|
771
|
+
if (top < this.scrollY) this.scrollY = top;
|
|
772
|
+
if (far > this.scrollX + this.abs.width) {
|
|
773
|
+
this.scrollX = far - this.abs.width;
|
|
774
|
+
}
|
|
775
|
+
if (near < this.scrollX) this.scrollX = near;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
paint(ctx) {
|
|
779
|
+
super.paint(ctx);
|
|
780
|
+
this._paintScrollbars(ctx);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** Over the content and outside the clip — a `<window>` reaches this by
|
|
784
|
+
* its own route, since it paints through `_paintRegion` and never
|
|
785
|
+
* through `Node.paint`. */
|
|
786
|
+
_paintScrollbars(ctx) {
|
|
787
|
+
for (const bar of this._scrollbars()) {
|
|
788
|
+
paintScrollbarThumb(ctx, bar, this.props.scrollbarColor);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** null when this is not a scroll container, when the bar is switched
|
|
793
|
+
* off, or when there is nothing to scroll on that axis. */
|
|
794
|
+
_scrollbar(axis = 'y') {
|
|
795
|
+
if (!this.isScroller() || this.props.scrollbar === false) return null;
|
|
796
|
+
const horizontal = axis === 'x';
|
|
797
|
+
// when both bars show, each stops short of the other's corner
|
|
798
|
+
const other = horizontal
|
|
799
|
+
? this.contentHeight > this.abs.height
|
|
800
|
+
: this.contentWidth > this.abs.width;
|
|
801
|
+
return scrollbarGeometry({
|
|
802
|
+
axis,
|
|
803
|
+
start: horizontal ? this.abs.x : this.abs.y,
|
|
804
|
+
viewport: horizontal ? this.abs.width : this.abs.height,
|
|
805
|
+
content: horizontal ? this.contentWidth : this.contentHeight,
|
|
806
|
+
across: horizontal ? this.abs.y : this.abs.x,
|
|
807
|
+
crossSize: horizontal ? this.abs.height : this.abs.width,
|
|
808
|
+
scroll: horizontal ? this.scrollX : this.scrollY,
|
|
809
|
+
inset: 2 * this.scale,
|
|
810
|
+
shorten: other ? (SCROLLBAR_WIDTH + 2) * this.scale : 0,
|
|
811
|
+
direction: this.direction,
|
|
812
|
+
scale: this.scale,
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
_scrollbars() {
|
|
817
|
+
if (!this.isScroller()) return EMPTY_SCROLLBARS;
|
|
818
|
+
return [this._scrollbar('y'), this._scrollbar('x')].filter(Boolean);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* The bar belongs to the scroller, not to the content under it — the same
|
|
823
|
+
* rule a browser applies. Without this a press on the thumb would be
|
|
824
|
+
* delivered to whatever child happens to be painted beneath it.
|
|
825
|
+
*/
|
|
826
|
+
hitTest(x, y) {
|
|
827
|
+
if (this.isScroller()) {
|
|
828
|
+
for (const bar of this._scrollbars()) {
|
|
829
|
+
if (scrollbarHit(bar, x, y)) return this;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
return super.hitTest(x, y);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
defaultMouseDown(ev) {
|
|
836
|
+
// bar geometry is device pixels; the synthetic event is logical, so
|
|
837
|
+
// the hit tests here read the native coordinates
|
|
838
|
+
const nx = ev.nativeEvent?.x ?? ev.x * this.scale;
|
|
839
|
+
const ny = ev.nativeEvent?.y ?? ev.y * this.scale;
|
|
840
|
+
for (const bar of this._scrollbars()) {
|
|
841
|
+
const hit = scrollbarHit(bar, nx, ny);
|
|
842
|
+
if (!hit) continue;
|
|
843
|
+
const at = along(bar, nx, ny);
|
|
844
|
+
if (hit === 'thumb') {
|
|
845
|
+
// remember where in the thumb it was grabbed, so it does not jump
|
|
846
|
+
this._barGrab = { axis: bar.axis, offset: at - bar.thumbStart };
|
|
847
|
+
ev.capturePointer();
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
// a press on the track pages towards it, like PageUp/PageDown — and
|
|
851
|
+
// "towards it" is a visual direction, so it flips with the bar
|
|
852
|
+
const page = bar.axis === 'x' ? this.abs.width : this.abs.height;
|
|
853
|
+
const back = at < bar.thumbStart ? !bar.reversed : bar.reversed;
|
|
854
|
+
const delta = back ? -page : page;
|
|
855
|
+
if (bar.axis === 'x') this._scrollByDevice(delta, 0);
|
|
856
|
+
else this._scrollByDevice(0, delta);
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
// no bar under the press: it belongs to whatever is behind the bars,
|
|
860
|
+
// which for a `selectable` pane is the selection (issue #259)
|
|
861
|
+
super.defaultMouseDown(ev);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
defaultMouseDrag(ev) {
|
|
865
|
+
if (this._barGrab == null) return super.defaultMouseDrag(ev);
|
|
866
|
+
const bar = this._scrollbar(this._barGrab.axis);
|
|
867
|
+
if (!bar || bar.travel <= 0) return;
|
|
868
|
+
const nx = ev.nativeEvent?.x ?? ev.x * this.scale;
|
|
869
|
+
const ny = ev.nativeEvent?.y ?? ev.y * this.scale;
|
|
870
|
+
const at = along(bar, nx, ny) - this._barGrab.offset - bar.trackStart;
|
|
871
|
+
const from = bar.reversed ? bar.travel - at : at;
|
|
872
|
+
const to = (from / bar.travel) * bar.range;
|
|
873
|
+
this._scrollToDevice(bar.axis === 'x' ? { x: to } : { y: to });
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
defaultMouseUp(ev) {
|
|
877
|
+
if (this._barGrab != null) {
|
|
878
|
+
this._barGrab = null;
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
super.defaultMouseUp(ev);
|
|
882
|
+
}
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
// An arrow key scrolls by a wheel notch — literally the one events.js
|
|
886
|
+
// converts a notch into, so the two input routes agree about what one step
|
|
887
|
+
// is however far a notch turns out to be.
|
|
888
|
+
const SCROLL_KEY_STEP = WHEEL_NOTCH_PX;
|
|
889
|
+
// A page keeps a sliver of the previous one on screen, so the eye has
|
|
890
|
+
// somewhere to land. Toolkits all keep a line or two; this is about that.
|
|
891
|
+
const SCROLL_KEY_PAGE_OVERLAP = 24;
|