react-x11 1.2.0 → 2.0.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 +330 -81
- package/package.json +132 -14
- package/src/ClickToComponent.js +9 -2
- package/src/DevToolsIntegration.js +440 -32
- package/src/Reconciler.js +463 -165
- package/src/a11y.js +1299 -0
- package/src/acceleratorhooks.js +86 -0
- package/src/accelerators.js +128 -0
- package/src/activate.js +168 -0
- package/src/anchor.js +360 -0
- package/src/appcontext.js +161 -0
- package/src/appearance.js +748 -0
- package/src/appearancehooks.js +96 -0
- package/src/apphooks.js +73 -0
- package/src/application.js +748 -0
- package/src/atspi.js +1953 -0
- package/src/bus.js +545 -0
- package/src/bushooks.js +136 -0
- package/src/clientmessage.js +140 -0
- package/src/clipboard.js +121 -0
- package/src/components/Button.js +148 -33
- package/src/components/Checkbox.js +66 -32
- package/src/components/Dialog.js +96 -29
- package/src/components/FileDialog.js +491 -0
- package/src/components/Icon.js +329 -0
- package/src/components/Menu.js +797 -133
- package/src/components/PasswordInput.js +434 -0
- package/src/components/ProgressBar.js +91 -12
- package/src/components/Radio.js +78 -35
- package/src/components/Select.js +255 -65
- package/src/components/Slider.js +102 -41
- package/src/components/SplitPane.js +206 -0
- package/src/components/Switch.js +100 -22
- package/src/components/Table.js +529 -0
- package/src/components/Tabs.js +211 -0
- package/src/components/Tooltip.js +528 -54
- package/src/components/anchor.js +154 -107
- package/src/components/change.js +34 -0
- package/src/components/dnd.js +103 -0
- package/src/components/index.js +16 -3
- package/src/components/keys.js +17 -20
- package/src/components/scribble.js +247 -0
- package/src/components/theme.js +350 -42
- package/src/components/typeahead.js +12 -2
- package/src/compose.js +868 -0
- package/src/compositing.js +224 -0
- package/src/dbusmenu.js +384 -0
- package/src/debug.d.ts +63 -0
- package/src/debug.js +642 -0
- package/src/decorations.js +486 -0
- package/src/desktopsettings.js +209 -0
- package/src/desktopsettingshooks.js +62 -0
- package/src/dnd.js +1725 -0
- package/src/editmenu.js +272 -0
- package/src/errors.js +98 -0
- package/src/events.js +1406 -162
- package/src/extensions.js +45 -0
- package/src/filedialog.js +375 -0
- package/src/filedialoghooks.js +132 -0
- package/src/fonthooks.js +64 -0
- package/src/fonts.js +301 -0
- package/src/foreignnodes.js +519 -0
- package/src/frame/child.js +33 -0
- package/src/frame/childmain.js +274 -0
- package/src/frame/env.js +140 -0
- package/src/frame/index.js +444 -0
- package/src/frame/lifecycle.js +67 -0
- package/src/frame/protocol.js +179 -0
- package/src/frames.js +73 -0
- package/src/glbackend.js +114 -0
- package/src/glnodes.js +125 -67
- package/src/globalmenu.js +683 -0
- package/src/host.d.ts +88 -0
- package/src/host.js +42 -0
- package/src/idle.js +490 -0
- package/src/idlehooks.js +100 -0
- package/src/imagesource.js +349 -0
- package/src/index.d.ts +475 -0
- package/src/index.js +83 -9
- package/src/inputtime.js +206 -0
- package/src/jsx-dev-runtime.d.ts +21 -0
- package/src/jsx-dev-runtime.js +2 -0
- package/src/jsx-runtime.d.ts +39 -0
- package/src/jsx-runtime.js +6 -0
- package/src/keyboard.js +256 -0
- package/src/keyboardstate.js +278 -0
- package/src/keyboardstatehooks.js +58 -0
- package/src/keysyms.d.ts +149 -0
- package/src/keysyms.js +270 -0
- package/src/locale.js +170 -0
- package/src/localehooks.js +47 -0
- package/src/menuitem.js +223 -0
- package/src/node.d.ts +597 -0
- package/src/node.js +44 -0
- package/src/nodes.js +9546 -690
- package/src/ntk.d.ts +44 -0
- package/src/ntk.js +25 -0
- package/src/paintcache.js +366 -0
- package/src/palette.js +380 -0
- package/src/pastestate.js +66 -0
- package/src/portal.js +461 -0
- package/src/priority.js +26 -0
- package/src/refresh/index.d.ts +40 -0
- package/src/refresh/index.js +122 -0
- package/src/refresh/loader.d.ts +37 -0
- package/src/refresh/loader.js +401 -0
- package/src/refresh/register.d.ts +5 -0
- package/src/refresh/register.js +13 -0
- package/src/registry.js +232 -0
- package/src/scale.js +626 -0
- package/src/scalehooks.js +27 -0
- package/src/screencolor.js +640 -0
- package/src/screencolorhooks.js +101 -0
- package/src/screens.js +754 -0
- package/src/screenshooks.js +137 -0
- package/src/startup.js +302 -0
- package/src/style.d.ts +126 -0
- package/src/style.js +33 -0
- package/src/styles.js +1482 -6
- package/src/svgnodes.js +306 -0
- package/src/testing/a11y.js +484 -0
- package/src/testing/components.js +414 -0
- package/src/testing/events.js +407 -0
- package/src/testing/harness.js +455 -0
- package/src/testing/index.d.ts +558 -0
- package/src/testing/index.js +85 -0
- package/src/testing/mock-app.js +463 -0
- package/src/testing/pixels.js +152 -0
- package/src/testing/queries.js +224 -0
- package/src/textrange.js +83 -0
- package/src/textselection.js +439 -0
- package/src/trace-registry.js +63 -0
- package/src/transfer.js +93 -0
- package/src/types/appearance.d.ts +84 -0
- package/src/types/application.d.ts +173 -0
- package/src/types/components.d.ts +820 -0
- package/src/types/dbus.d.ts +177 -0
- package/src/types/elements.d.ts +897 -0
- package/src/types/events.d.ts +549 -0
- package/src/types/filedialog.d.ts +260 -0
- package/src/types/fonts.d.ts +124 -0
- package/src/types/frame.d.ts +146 -0
- package/src/types/globalmenu.d.ts +42 -0
- package/src/types/nodes.d.ts +199 -0
- package/src/types/screencolor.d.ts +84 -0
- package/src/types/style.d.ts +434 -0
- package/src/types/system.d.ts +287 -0
- package/src/windowid.js +151 -0
- package/src/windowstate.js +393 -0
- package/src/xsettings.js +336 -0
- package/src/yoga.d.ts +55 -0
- package/src/yoga.js +135 -0
- package/src/components/Canvas3D.js +0 -28
- package/src/geometry3d.js +0 -223
- package/src/pointer3d.js +0 -158
- package/src/raycast3d.js +0 -146
- package/src/richnodes.js +0 -436
- package/src/scene3d.js +0 -683
package/src/dnd.js
ADDED
|
@@ -0,0 +1,1725 @@
|
|
|
1
|
+
// XDND drag and drop, the drop-target half: react-x11 windows accept drags
|
|
2
|
+
// from other X11 applications (file managers, editors, browsers).
|
|
3
|
+
//
|
|
4
|
+
// Design: docs/architecture/drag-and-drop.md; protocol: XDND version 5.
|
|
5
|
+
// The advertisement (`XdndAware`) is a 4-byte property written
|
|
6
|
+
// unconditionally on every top-level window at realize time — it creates no
|
|
7
|
+
// X windows, so it is orthogonal to the windowed/windowless split.
|
|
8
|
+
// Laziness lives in the protocol's own channel instead: a window with no
|
|
9
|
+
// registered drop target answers the first XdndPosition with "not
|
|
10
|
+
// accepting" plus a suppression rectangle covering the whole window, which
|
|
11
|
+
// silences the source until the pointer leaves. De-advertising on unmount
|
|
12
|
+
// would race sources that cache the window list at drag start (GTK does),
|
|
13
|
+
// and would churn a property per commit for nothing a user can see.
|
|
14
|
+
//
|
|
15
|
+
// Drop handling is ordinary props on ordinary nodes — `dropAccept`,
|
|
16
|
+
// `onDragEnter/Over/Leave`, `onDrop` — dispatched through the same
|
|
17
|
+
// hit-test → capture/bubble machinery as every pointer event. `dropAccept`
|
|
18
|
+
// is data, not code, so XdndStatus is answered in the ClientMessage handler
|
|
19
|
+
// without waiting on React: an advertised window that stalls on a render
|
|
20
|
+
// stalls the *source application's* drag cursor.
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
runWithPriority,
|
|
24
|
+
DiscreteEventPriority,
|
|
25
|
+
ContinuousEventPriority,
|
|
26
|
+
} from './priority.js';
|
|
27
|
+
import { callHandler, reportHandlerError } from './errors.js';
|
|
28
|
+
import { desktopSettings } from './desktopsettings.js';
|
|
29
|
+
import { discrete } from './events.js';
|
|
30
|
+
import {
|
|
31
|
+
TEXT_TARGETS,
|
|
32
|
+
TYPE_GROUPS,
|
|
33
|
+
decodeData,
|
|
34
|
+
parseUriList,
|
|
35
|
+
resolveType,
|
|
36
|
+
typeMatches,
|
|
37
|
+
} from './transfer.js';
|
|
38
|
+
|
|
39
|
+
export const XDND_VERSION = 5;
|
|
40
|
+
|
|
41
|
+
/** How long an async onDrop may hold the source's XdndFinished. The reply
|
|
42
|
+
* must always go out — a forgotten await in one app must not hang another
|
|
43
|
+
* application's drag gesture. */
|
|
44
|
+
const DROP_TIMEOUT = 10_000;
|
|
45
|
+
|
|
46
|
+
// Edge auto-scroll. How near a viewport's edge the pointer has to be for
|
|
47
|
+
// the drag to start scrolling it, how often a step goes out, and the
|
|
48
|
+
// fastest one step can be.
|
|
49
|
+
const AUTOSCROLL_EDGE = 24;
|
|
50
|
+
const AUTOSCROLL_INTERVAL = 30;
|
|
51
|
+
const AUTOSCROLL_STEP = 14;
|
|
52
|
+
|
|
53
|
+
const ATOM_NAMES = [
|
|
54
|
+
'XdndAware',
|
|
55
|
+
'XdndSelection',
|
|
56
|
+
'XdndTypeList',
|
|
57
|
+
'XdndProxy',
|
|
58
|
+
'XdndEnter',
|
|
59
|
+
'XdndPosition',
|
|
60
|
+
'XdndStatus',
|
|
61
|
+
'XdndLeave',
|
|
62
|
+
'XdndDrop',
|
|
63
|
+
'XdndFinished',
|
|
64
|
+
'XdndActionCopy',
|
|
65
|
+
'XdndActionMove',
|
|
66
|
+
'XdndActionLink',
|
|
67
|
+
'XdndActionAsk',
|
|
68
|
+
'XdndActionPrivate',
|
|
69
|
+
// read off the source's window when it asks the target to choose
|
|
70
|
+
'XdndActionList',
|
|
71
|
+
'XdndActionDescription',
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
// per-connection atom table: X -> { atoms|null, promise, names: Map }
|
|
75
|
+
const tables = new WeakMap();
|
|
76
|
+
|
|
77
|
+
/** Intern the XDND atom vocabulary once per connection (one batched write;
|
|
78
|
+
* node-x11 caches interned atoms, so re-entry costs nothing). Resolves to
|
|
79
|
+
* `{ <name>: atom, byAtom: Map<atom, name> }`, also readable synchronously
|
|
80
|
+
* via `dndAtomsOf` once resolved. */
|
|
81
|
+
export function dndAtoms(X) {
|
|
82
|
+
let entry = tables.get(X);
|
|
83
|
+
if (!entry) {
|
|
84
|
+
entry = { atoms: null, promise: null, names: new Map() };
|
|
85
|
+
entry.promise = Promise.all(
|
|
86
|
+
ATOM_NAMES.map(
|
|
87
|
+
(name) =>
|
|
88
|
+
new Promise((resolve, reject) =>
|
|
89
|
+
X.InternAtom(false, name, (err, atom) =>
|
|
90
|
+
err ? reject(err) : resolve(atom),
|
|
91
|
+
),
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
).then((ids) => {
|
|
95
|
+
const atoms = { byAtom: new Map() };
|
|
96
|
+
ATOM_NAMES.forEach((name, i) => {
|
|
97
|
+
atoms[name] = ids[i];
|
|
98
|
+
atoms.byAtom.set(ids[i], name);
|
|
99
|
+
});
|
|
100
|
+
entry.atoms = atoms;
|
|
101
|
+
return atoms;
|
|
102
|
+
});
|
|
103
|
+
tables.set(X, entry);
|
|
104
|
+
}
|
|
105
|
+
return entry.promise;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The resolved atom table, or null while the interning is in flight. */
|
|
109
|
+
export function dndAtomsOf(X) {
|
|
110
|
+
return tables.get(X)?.atoms ?? null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Atom → name, cached per connection. Type-list atoms are arbitrary MIME
|
|
114
|
+
* strings interned by the source, so unknowns go through GetAtomName. */
|
|
115
|
+
function atomName(X, atom) {
|
|
116
|
+
const entry = tables.get(X);
|
|
117
|
+
const known = entry?.atoms?.byAtom.get(atom);
|
|
118
|
+
if (known) return Promise.resolve(known);
|
|
119
|
+
const cache = entry?.names;
|
|
120
|
+
const hit = cache?.get(atom);
|
|
121
|
+
if (hit) return hit;
|
|
122
|
+
const p = new Promise((resolve) =>
|
|
123
|
+
X.GetAtomName(atom, (err, name) => resolve(err ? null : name)),
|
|
124
|
+
);
|
|
125
|
+
cache?.set(atom, p);
|
|
126
|
+
return p;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export { parseUriList, typeMatches };
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Does a node's `dropAccept` accept this offer? `dropAccept` is a type
|
|
133
|
+
* name, a semantic group, an array of either, or a predicate over the
|
|
134
|
+
* offered names. Absent means "accepts anything" — a bare `onDrop` is a
|
|
135
|
+
* valid dropzone.
|
|
136
|
+
*/
|
|
137
|
+
export function matchAccept(accept, offered) {
|
|
138
|
+
if (accept == null) return true;
|
|
139
|
+
if (typeof accept === 'function') return Boolean(accept(offered));
|
|
140
|
+
const list = Array.isArray(accept) ? accept : [accept];
|
|
141
|
+
return list.some((want) => typeMatches(offered, want));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The props that make a node a drop target (and register it, see
|
|
145
|
+
* Reconciler commitMount / Node.applyProps). `dropAccept` is deliberately
|
|
146
|
+
* namespaced — a generic name like `accept` could ride in on a prop spread
|
|
147
|
+
* and silently turn a box into a dropzone. */
|
|
148
|
+
const DROP_PROPS = [
|
|
149
|
+
'dropAccept',
|
|
150
|
+
'onDrop',
|
|
151
|
+
'onDropCapture',
|
|
152
|
+
'onDragEnter',
|
|
153
|
+
'onDragLeave',
|
|
154
|
+
'onDragOver',
|
|
155
|
+
'onDragOverCapture',
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
export function hasDropProps(props) {
|
|
159
|
+
if (!props) return false;
|
|
160
|
+
for (const key of DROP_PROPS) {
|
|
161
|
+
if (props[key] != null) return true;
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const ACTION_NAMES = {
|
|
167
|
+
XdndActionCopy: 'copy',
|
|
168
|
+
XdndActionMove: 'move',
|
|
169
|
+
XdndActionLink: 'link',
|
|
170
|
+
XdndActionAsk: 'ask',
|
|
171
|
+
XdndActionPrivate: 'private',
|
|
172
|
+
};
|
|
173
|
+
const ACTION_ATOMS = {
|
|
174
|
+
copy: 'XdndActionCopy',
|
|
175
|
+
move: 'XdndActionMove',
|
|
176
|
+
link: 'XdndActionLink',
|
|
177
|
+
ask: 'XdndActionAsk',
|
|
178
|
+
private: 'XdndActionPrivate',
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
function actionName(atoms, atom) {
|
|
182
|
+
return ACTION_NAMES[atoms.byAtom.get(atom)] ?? 'copy';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function actionAtom(atoms, name) {
|
|
186
|
+
return atoms[ACTION_ATOMS[name] ?? 'XdndActionCopy'];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Decode selection bytes for a text-ish target; anything else stays a
|
|
190
|
+
* Buffer. STRING is defined as latin-1; everything textual else is UTF-8. */
|
|
191
|
+
/**
|
|
192
|
+
* The viewport a drag over `path` scrolls: the nearest enclosing scroll
|
|
193
|
+
* container with somewhere to go, found by walking out from the hit node the
|
|
194
|
+
* same way the wheel's default action does ([events.js](./events.js)) — and
|
|
195
|
+
* skipping the same ones, so the two gestures agree about which viewport a
|
|
196
|
+
* point belongs to.
|
|
197
|
+
*
|
|
198
|
+
* A scroll *container* — `isScroller()` — rather than anything the wheel
|
|
199
|
+
* would scroll: `<textarea>` and its kind answer `canScroll` because they
|
|
200
|
+
* pan their own pixels, but nothing can be dropped into one, so scrolling
|
|
201
|
+
* it here would move text the drag cannot reach.
|
|
202
|
+
*/
|
|
203
|
+
function scrollerIn(path) {
|
|
204
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
205
|
+
const n = path[i];
|
|
206
|
+
if (n.isScroller?.() && n.canScroll(1, 1)) return n;
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Pixels to scroll per step for a pointer at `x, y` over `rect`, or null
|
|
213
|
+
* when it is not near enough to an edge to scroll at all.
|
|
214
|
+
*
|
|
215
|
+
* The rate ramps with depth into the band rather than being constant: a
|
|
216
|
+
* drag crossing a long list crawls at a constant slow rate, and one
|
|
217
|
+
* nudging the next row into view overshoots at a constant fast one. Each
|
|
218
|
+
* axis takes the nearer of its two edges, so a viewport shorter than two
|
|
219
|
+
* bands still scrolls one way rather than fighting itself.
|
|
220
|
+
*/
|
|
221
|
+
function edgeVelocity(rect, x, y, scale = 1) {
|
|
222
|
+
const inside =
|
|
223
|
+
x >= rect.x &&
|
|
224
|
+
x <= rect.x + rect.width &&
|
|
225
|
+
y >= rect.y &&
|
|
226
|
+
y <= rect.y + rect.height;
|
|
227
|
+
if (!inside) return null;
|
|
228
|
+
const edge = AUTOSCROLL_EDGE * scale;
|
|
229
|
+
const ramp = (distance) =>
|
|
230
|
+
distance >= edge
|
|
231
|
+
? 0
|
|
232
|
+
: Math.ceil((1 - Math.max(0, distance) / edge) * AUTOSCROLL_STEP * scale);
|
|
233
|
+
const top = y - rect.y;
|
|
234
|
+
const bottom = rect.y + rect.height - y;
|
|
235
|
+
const left = x - rect.x;
|
|
236
|
+
const right = rect.x + rect.width - x;
|
|
237
|
+
const dy = top < bottom ? -ramp(top) : ramp(bottom);
|
|
238
|
+
const dx = left < right ? -ramp(left) : ramp(right);
|
|
239
|
+
return dx === 0 && dy === 0 ? null : { dx, dy };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// The per-top-level session.
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* One per realized top-level window (`<window>` at the root, `<popup>`),
|
|
248
|
+
* owned by the WindowNode as `_dnd`. Handles the incoming half of XDND:
|
|
249
|
+
* Enter/Position/Leave/Drop in, Status/Finished out. Messages for drags
|
|
250
|
+
* over nested `<window>` children also arrive here — since XDND v3 only
|
|
251
|
+
* top-levels advertise — and are routed down in JS via each child's
|
|
252
|
+
* `_screenOrigin`.
|
|
253
|
+
*/
|
|
254
|
+
export class DropSession {
|
|
255
|
+
constructor(windowNode) {
|
|
256
|
+
this.node = windowNode;
|
|
257
|
+
this.X = windowNode.app.X;
|
|
258
|
+
// FIFO gate: messages queue behind atom interning and XdndEnter's
|
|
259
|
+
// async type-list resolution, so a Position never overtakes its Enter.
|
|
260
|
+
this._chain = Promise.resolve();
|
|
261
|
+
this._reset();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
_reset() {
|
|
265
|
+
this._stopAutoScroll();
|
|
266
|
+
this.sourceWid = 0;
|
|
267
|
+
this.version = 0;
|
|
268
|
+
this.types = [];
|
|
269
|
+
this.path = [];
|
|
270
|
+
// what an `ask` source offered, read once per drag (see _readAskOffer)
|
|
271
|
+
this._askOffer = null;
|
|
272
|
+
this.accepted = null;
|
|
273
|
+
this.acceptedAction = 'copy';
|
|
274
|
+
this.requestedAction = 'copy';
|
|
275
|
+
this.lastPoint = null;
|
|
276
|
+
// which transport is feeding this session: 'external' (XDND wire) or
|
|
277
|
+
// 'internal' (a DragSession in this process). Carried onto every drag
|
|
278
|
+
// event as `e.source`.
|
|
279
|
+
this._source = 'external';
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Entry point, from the window's 'message' listener. */
|
|
283
|
+
handleMessage(ev) {
|
|
284
|
+
if (ev.format !== 32 || !this.node.window) return;
|
|
285
|
+
this._chain = this._chain
|
|
286
|
+
.then(() => dndAtomsOf(this.X) ?? dndAtoms(this.X))
|
|
287
|
+
.then((atoms) => this._route(ev, atoms))
|
|
288
|
+
.catch(() => {}); // connection teardown mid-drag; nothing to do
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
_route(ev, atoms) {
|
|
292
|
+
switch (ev.message_type) {
|
|
293
|
+
case atoms.XdndEnter:
|
|
294
|
+
return this._onEnter(ev);
|
|
295
|
+
case atoms.XdndPosition:
|
|
296
|
+
return this._onPosition(ev, atoms);
|
|
297
|
+
case atoms.XdndLeave:
|
|
298
|
+
return this._onLeave(ev);
|
|
299
|
+
case atoms.XdndDrop:
|
|
300
|
+
return this._onDrop(ev, atoms);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** A node is leaving the tree: no stale references (mirrors
|
|
305
|
+
* EventManager.forget). */
|
|
306
|
+
forget(node) {
|
|
307
|
+
this.path = this.path.filter((n) => n !== node);
|
|
308
|
+
if (this.accepted === node) this.accepted = null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async _onEnter(ev) {
|
|
312
|
+
this._clearPath(); // a fresh enter ends any stale session
|
|
313
|
+
this._reset();
|
|
314
|
+
this.sourceWid = ev.data[0] >>> 0;
|
|
315
|
+
this.version = Math.min(XDND_VERSION, ev.data[1] >>> 24);
|
|
316
|
+
const typeAtoms =
|
|
317
|
+
ev.data[1] & 1
|
|
318
|
+
? await this._readTypeList(this.sourceWid)
|
|
319
|
+
: [ev.data[2], ev.data[3], ev.data[4]].filter(Boolean);
|
|
320
|
+
// returned before positions are processed — the chain awaits this
|
|
321
|
+
this.types = (
|
|
322
|
+
await Promise.all(typeAtoms.map((a) => atomName(this.X, a)))
|
|
323
|
+
).filter(Boolean);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The source offers more than three types: the full list is on its
|
|
327
|
+
* window as XdndTypeList (type ATOM). */
|
|
328
|
+
_readTypeList(sourceWid) {
|
|
329
|
+
return new Promise((resolve) => {
|
|
330
|
+
this.X.GetProperty(
|
|
331
|
+
0,
|
|
332
|
+
sourceWid,
|
|
333
|
+
dndAtomsOf(this.X).XdndTypeList,
|
|
334
|
+
0,
|
|
335
|
+
0,
|
|
336
|
+
0x1000,
|
|
337
|
+
(err, prop) => {
|
|
338
|
+
if (err || !prop?.data?.length) return resolve([]);
|
|
339
|
+
const atoms = [];
|
|
340
|
+
for (let o = 0; o + 4 <= prop.data.length; o += 4) {
|
|
341
|
+
atoms.push(prop.data.readUInt32LE(o));
|
|
342
|
+
}
|
|
343
|
+
resolve(atoms);
|
|
344
|
+
},
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* What a source offering `ask` is offering, read from its window: the
|
|
351
|
+
* actions as `XdndActionList` (type ATOM) and, beside it, the source's
|
|
352
|
+
* own words for them as `XdndActionDescription` — a run of NUL-separated
|
|
353
|
+
* strings, one per action, positionally matched.
|
|
354
|
+
*
|
|
355
|
+
* Read once per drag, and only when a position actually asks, because
|
|
356
|
+
* the two round trips buy nothing for the copy/move/link sources that
|
|
357
|
+
* are the overwhelming majority.
|
|
358
|
+
*/
|
|
359
|
+
async _readAskOffer(sourceWid) {
|
|
360
|
+
if (this._askOffer) return this._askOffer;
|
|
361
|
+
const dnd = dndAtomsOf(this.X);
|
|
362
|
+
const [list, describedBytes] = await Promise.all([
|
|
363
|
+
this._readAtomList(sourceWid, dnd.XdndActionList),
|
|
364
|
+
this._readBytes(sourceWid, dnd.XdndActionDescription),
|
|
365
|
+
]);
|
|
366
|
+
const actions = (await Promise.all(list.map((a) => atomName(this.X, a))))
|
|
367
|
+
.map((name) => ACTION_NAMES[name])
|
|
368
|
+
.filter(Boolean);
|
|
369
|
+
// trailing NUL is conventional, so drop the empty tail it leaves
|
|
370
|
+
const described = describedBytes.toString('utf8').split('\0');
|
|
371
|
+
if (described.at(-1) === '') described.pop();
|
|
372
|
+
this._askOffer = {
|
|
373
|
+
actions,
|
|
374
|
+
// positional: a source that describes none, or describes fewer than
|
|
375
|
+
// it offers, leaves the rest to us to name
|
|
376
|
+
descriptions: actions.map((name, i) => described[i] ?? null),
|
|
377
|
+
};
|
|
378
|
+
return this._askOffer;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
_readAtomList(wid, atom) {
|
|
382
|
+
return new Promise((resolve) => {
|
|
383
|
+
this.X.GetProperty(0, wid, atom, 0, 0, 0x1000, (err, prop) => {
|
|
384
|
+
if (err || !prop?.data?.length) return resolve([]);
|
|
385
|
+
const atoms = [];
|
|
386
|
+
for (let o = 0; o + 4 <= prop.data.length; o += 4) {
|
|
387
|
+
atoms.push(prop.data.readUInt32LE(o));
|
|
388
|
+
}
|
|
389
|
+
resolve(atoms);
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
_readBytes(wid, atom) {
|
|
395
|
+
return new Promise((resolve) => {
|
|
396
|
+
this.X.GetProperty(0, wid, atom, 0, 0, 0x1000, (err, prop) =>
|
|
397
|
+
resolve(err || !prop?.data ? Buffer.alloc(0) : prop.data),
|
|
398
|
+
);
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async _onPosition(ev, atoms) {
|
|
403
|
+
if (ev.data[0] >>> 0 !== this.sourceWid) return;
|
|
404
|
+
const packed = ev.data[2] >>> 0;
|
|
405
|
+
const rootX = packed >>> 16;
|
|
406
|
+
const rootY = packed & 0xffff;
|
|
407
|
+
const time = ev.data[3] >>> 0;
|
|
408
|
+
this.requestedAction = actionName(atoms, ev.data[4] >>> 0);
|
|
409
|
+
|
|
410
|
+
// Zero registered targets anywhere under this top-level: one refusal
|
|
411
|
+
// with a rectangle covering the whole window, and a well-behaved
|
|
412
|
+
// source goes quiet until the pointer leaves. This is the lazy channel
|
|
413
|
+
// — no node work, no React, one message per window-entry.
|
|
414
|
+
if (this.node._dndTargetCount() === 0) {
|
|
415
|
+
this._sendStatus(atoms, { accept: false, rect: this._windowRect() });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// `ask` means the source wants the user asked which action to perform,
|
|
420
|
+
// so the choices have to be in hand before a handler is called. Only
|
|
421
|
+
// this branch pays for them, and only once per drag.
|
|
422
|
+
if (this.requestedAction === 'ask' && !this._askOffer) {
|
|
423
|
+
await this._readAskOffer(this.sourceWid);
|
|
424
|
+
// a leave or a new drag may have overtaken the round trips
|
|
425
|
+
if (ev.data[0] >>> 0 !== this.sourceWid) return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const answer = this._overAt(rootX, rootY, time);
|
|
429
|
+
this._sendStatus(atoms, {
|
|
430
|
+
accept: answer.accept,
|
|
431
|
+
action: answer.action,
|
|
432
|
+
rect:
|
|
433
|
+
answer.freeze && this.accepted ? this._nodeRect(this.accepted) : null,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* One position update, whichever transport delivered it: route the root
|
|
439
|
+
* point, hit-test, find the accepting node from `dropAccept` data, diff
|
|
440
|
+
* the drag path (enter/leave + `:drag-over`), and give `onDragOver` its
|
|
441
|
+
* synchronous chance to override. Returns the answer — XDND sends it as
|
|
442
|
+
* XdndStatus, an internal DragSession reads it directly.
|
|
443
|
+
*/
|
|
444
|
+
_overAt(rootX, rootY, time) {
|
|
445
|
+
const { windowNode, x, y } = this._routePoint(rootX, rootY);
|
|
446
|
+
const target = windowNode.hitTest(x, y) ?? windowNode;
|
|
447
|
+
const path = windowNode.events._path(target);
|
|
448
|
+
const native = { x, y, rootx: rootX, rooty: rootY, time, buttons: 0 };
|
|
449
|
+
|
|
450
|
+
// deepest node that declares drop handling and matches the offer —
|
|
451
|
+
// answered from `dropAccept` data, no React in the loop
|
|
452
|
+
let accepted = null;
|
|
453
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
454
|
+
const n = path[i];
|
|
455
|
+
if (n.props.disabled || !hasDropProps(n.props)) continue;
|
|
456
|
+
if (matchAccept(n.props.dropAccept, this.types)) {
|
|
457
|
+
accepted = n;
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
this.accepted = accepted;
|
|
462
|
+
this.lastPoint = { windowNode, x, y, rootX, rootY, time };
|
|
463
|
+
this._updateAutoScroll(path, x, y);
|
|
464
|
+
|
|
465
|
+
const answer = {
|
|
466
|
+
accept: Boolean(accepted),
|
|
467
|
+
action: this.requestedAction,
|
|
468
|
+
freeze: false,
|
|
469
|
+
};
|
|
470
|
+
runWithPriority(ContinuousEventPriority, () => {
|
|
471
|
+
this._updateDragPath(path, native);
|
|
472
|
+
// onDragOver may override the declarative answer, synchronously —
|
|
473
|
+
// same latency budget as any event handler, no render awaited
|
|
474
|
+
const over = this._makeDragEvent(
|
|
475
|
+
windowNode,
|
|
476
|
+
'dragOver',
|
|
477
|
+
native,
|
|
478
|
+
target,
|
|
479
|
+
answer,
|
|
480
|
+
);
|
|
481
|
+
this._dispatchOver(windowNode, path, over);
|
|
482
|
+
});
|
|
483
|
+
this.acceptedAction = answer.action;
|
|
484
|
+
return answer;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// -- the internal transport (a DragSession in this process) --------------
|
|
488
|
+
//
|
|
489
|
+
// Same session, same path diffing, same handlers — no wire. `e.source`
|
|
490
|
+
// says 'internal', `e.items` carries live values, and the answer goes
|
|
491
|
+
// back as a return value instead of an XdndStatus.
|
|
492
|
+
|
|
493
|
+
localOver(rootX, rootY, offer, time) {
|
|
494
|
+
this._source = 'internal';
|
|
495
|
+
this.sourceWid = 0;
|
|
496
|
+
this.types = offer.types;
|
|
497
|
+
this.requestedAction = offer.action;
|
|
498
|
+
const answer = this._overAt(rootX, rootY, time);
|
|
499
|
+
return { accepted: answer.accept, action: answer.action };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
localLeave() {
|
|
503
|
+
if (this._source !== 'internal') return;
|
|
504
|
+
discrete(() => this._clearPath())();
|
|
505
|
+
this.accepted = null;
|
|
506
|
+
this.lastPoint = null;
|
|
507
|
+
this._source = 'external';
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** The internal drop: dispatch with the live extras a DragSession built
|
|
511
|
+
* (`items`, prefetched `files`/`text`, a local `getData`). Synchronous
|
|
512
|
+
* from the caller's point of view — `onDragEnd` follows immediately,
|
|
513
|
+
* like the DOM's dragend after drop. */
|
|
514
|
+
localDrop(offer, extras, time) {
|
|
515
|
+
this._source = 'internal';
|
|
516
|
+
this.types = offer.types;
|
|
517
|
+
const point = this.lastPoint;
|
|
518
|
+
const accepted = this.accepted;
|
|
519
|
+
const action = this.acceptedAction;
|
|
520
|
+
if (!accepted || accepted.destroyed || !point) {
|
|
521
|
+
this.localLeave();
|
|
522
|
+
return { handled: false, action: null };
|
|
523
|
+
}
|
|
524
|
+
// the same outcome object the wire transport uses, so `e.accept('move')`
|
|
525
|
+
// in a drop handler means one thing across both. In-app handlers are
|
|
526
|
+
// dispatched synchronously and not awaited, so only a synchronous
|
|
527
|
+
// answer reaches `onDragEnd` — an async one is too late, exactly as it
|
|
528
|
+
// is for the rest of the in-app drop path. Not awaited is not the same
|
|
529
|
+
// as not observed, though: `_dispatchDrop` attaches to whatever a
|
|
530
|
+
// handler returns even with no `pending` array to collect it, because
|
|
531
|
+
// an unobserved rejection here is a dead process rather than a late
|
|
532
|
+
// answer (#203).
|
|
533
|
+
const outcome = { accept: true, action, freeze: false };
|
|
534
|
+
discrete(() => {
|
|
535
|
+
runWithPriority(DiscreteEventPriority, () => {
|
|
536
|
+
const targetNode = this._dropTarget(point) ?? accepted;
|
|
537
|
+
const drop = this._makeDragEvent(
|
|
538
|
+
point.windowNode,
|
|
539
|
+
'drop',
|
|
540
|
+
{
|
|
541
|
+
x: point.x,
|
|
542
|
+
y: point.y,
|
|
543
|
+
rootx: point.rootX,
|
|
544
|
+
rooty: point.rootY,
|
|
545
|
+
time,
|
|
546
|
+
buttons: 0,
|
|
547
|
+
},
|
|
548
|
+
targetNode,
|
|
549
|
+
outcome,
|
|
550
|
+
);
|
|
551
|
+
Object.assign(drop, extras);
|
|
552
|
+
this._dispatchDrop(point.windowNode, targetNode, drop);
|
|
553
|
+
this._clearPath();
|
|
554
|
+
});
|
|
555
|
+
})();
|
|
556
|
+
this.accepted = null;
|
|
557
|
+
this.lastPoint = null;
|
|
558
|
+
this._source = 'external';
|
|
559
|
+
return {
|
|
560
|
+
handled: outcome.accept,
|
|
561
|
+
action: outcome.accept ? outcome.action : null,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
_onLeave(ev) {
|
|
566
|
+
if (ev.data[0] >>> 0 !== this.sourceWid) return;
|
|
567
|
+
discrete(() => this._clearPath())();
|
|
568
|
+
this.accepted = null;
|
|
569
|
+
this.sourceWid = 0;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async _onDrop(ev, atoms) {
|
|
573
|
+
if (ev.data[0] >>> 0 !== this.sourceWid) return;
|
|
574
|
+
// captured now: a new drag may Enter while an async onDrop is still
|
|
575
|
+
// settling, and the late XdndFinished must go to the *dropped* source
|
|
576
|
+
const sourceWid = this.sourceWid;
|
|
577
|
+
const version = this.version;
|
|
578
|
+
const action = this.acceptedAction;
|
|
579
|
+
const accepted = this.accepted;
|
|
580
|
+
const point = this.lastPoint;
|
|
581
|
+
if (!accepted || accepted.destroyed || !point) {
|
|
582
|
+
this._sendFinished(atoms, sourceWid, version, { accepted: false });
|
|
583
|
+
discrete(() => this._clearPath())();
|
|
584
|
+
this._reset();
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
const time = ev.data[2] >>> 0;
|
|
588
|
+
|
|
589
|
+
// Prefetch the common payloads so `e.files` / `e.text` read
|
|
590
|
+
// synchronously in the handler; anything else stays behind
|
|
591
|
+
// `await e.getData(type)`.
|
|
592
|
+
let files = [];
|
|
593
|
+
let text;
|
|
594
|
+
try {
|
|
595
|
+
if (typeMatches(this.types, 'files')) {
|
|
596
|
+
const raw = await this._getData('text/uri-list', time);
|
|
597
|
+
text = String(raw);
|
|
598
|
+
files = parseUriList(raw);
|
|
599
|
+
} else {
|
|
600
|
+
const best = TEXT_TARGETS.find((t) => this.types.includes(t));
|
|
601
|
+
if (best) text = await this._getData(best, time);
|
|
602
|
+
}
|
|
603
|
+
} catch {
|
|
604
|
+
// conversion refused or timed out; the handler still runs with
|
|
605
|
+
// getData available for retries of other types
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// What XdndFinished waits for. `_dispatchDrop` fills it with promises
|
|
609
|
+
// whose rejections it has already turned into a reported, refused drop,
|
|
610
|
+
// so nothing here rejects and `finish` is reached either way.
|
|
611
|
+
const pending = [];
|
|
612
|
+
// What the handler settles on. `accept`/`reject` mean something
|
|
613
|
+
// different at drop time than during a hover — "I took it, doing this"
|
|
614
|
+
// rather than "I would take it" — and this is what XdndFinished
|
|
615
|
+
// reports. An `ask` source is the reason it can be changed at all: the
|
|
616
|
+
// point of asking is that the answer is not known until now, and a
|
|
617
|
+
// handler that puts a menu up settles it later still.
|
|
618
|
+
const outcome = { accept: true, action, freeze: false };
|
|
619
|
+
discrete(() => {
|
|
620
|
+
runWithPriority(DiscreteEventPriority, () => {
|
|
621
|
+
const targetNode = this._dropTarget(point) ?? accepted;
|
|
622
|
+
const drop = this._makeDragEvent(
|
|
623
|
+
point.windowNode,
|
|
624
|
+
'drop',
|
|
625
|
+
{
|
|
626
|
+
x: point.x,
|
|
627
|
+
y: point.y,
|
|
628
|
+
rootx: point.rootX,
|
|
629
|
+
rooty: point.rootY,
|
|
630
|
+
time,
|
|
631
|
+
buttons: 0,
|
|
632
|
+
},
|
|
633
|
+
targetNode,
|
|
634
|
+
outcome,
|
|
635
|
+
);
|
|
636
|
+
drop.files = files;
|
|
637
|
+
drop.text = text;
|
|
638
|
+
// `time` is captured, not read off the session: an async onDrop can
|
|
639
|
+
// still be holding getData after a later drag has reset us
|
|
640
|
+
drop.getData = (type) => this._getData(this._resolveType(type), time);
|
|
641
|
+
this._dispatchDrop(point.windowNode, targetNode, drop, pending);
|
|
642
|
+
this._clearPath();
|
|
643
|
+
});
|
|
644
|
+
})();
|
|
645
|
+
|
|
646
|
+
// XdndFinished goes out when every handler promise settles, or when
|
|
647
|
+
// the watchdog fires — whichever is first. Always.
|
|
648
|
+
let finished = false;
|
|
649
|
+
const finish = () => {
|
|
650
|
+
if (finished) return;
|
|
651
|
+
finished = true;
|
|
652
|
+
clearTimeout(timer);
|
|
653
|
+
// read now, not captured: an async handler settles the outcome
|
|
654
|
+
// between the dispatch above and this call
|
|
655
|
+
this._sendFinished(atoms, sourceWid, version, {
|
|
656
|
+
accepted: outcome.accept,
|
|
657
|
+
action: outcome.action,
|
|
658
|
+
});
|
|
659
|
+
// only clear session state if no new drag has entered meanwhile
|
|
660
|
+
if (this.sourceWid === sourceWid) this._reset();
|
|
661
|
+
};
|
|
662
|
+
const timer = setTimeout(() => {
|
|
663
|
+
if (process.env.NODE_ENV !== 'production' && !finished) {
|
|
664
|
+
console.warn(
|
|
665
|
+
'react-x11: onDrop has not settled after ' +
|
|
666
|
+
`${DROP_TIMEOUT}ms; sending XdndFinished anyway so the drag ` +
|
|
667
|
+
'source is not left hanging.',
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
finish();
|
|
671
|
+
}, DROP_TIMEOUT);
|
|
672
|
+
timer.unref?.();
|
|
673
|
+
Promise.allSettled(pending).then(finish);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** The node under the last position, re-hit-tested at drop time — the
|
|
677
|
+
* tree may have re-rendered between the last motion and the button
|
|
678
|
+
* release. */
|
|
679
|
+
_dropTarget(point) {
|
|
680
|
+
const node = point.windowNode;
|
|
681
|
+
if (node.destroyed) return null;
|
|
682
|
+
return node.hitTest(point.x, point.y) ?? node;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// -- dispatch ------------------------------------------------------------
|
|
686
|
+
|
|
687
|
+
/** Drag events ride the same synthetic-event shape as pointer events,
|
|
688
|
+
* with the XDND extras flattened on. `answer` wires accept/reject/freeze
|
|
689
|
+
* for dragOver; enter/leave/drop pass null and get inert versions. */
|
|
690
|
+
_makeDragEvent(windowNode, type, native, target, answer) {
|
|
691
|
+
const types = this.types;
|
|
692
|
+
return windowNode.events._makeEvent(type, native, target, {
|
|
693
|
+
types,
|
|
694
|
+
has: (want) => typeMatches(types, want),
|
|
695
|
+
action: this.requestedAction,
|
|
696
|
+
// Only an `ask` source fills these: the actions it will accept, and
|
|
697
|
+
// its own words for them, positionally matched (null where it gave
|
|
698
|
+
// none). Empty otherwise, so a handler can read them unguarded.
|
|
699
|
+
actions: this._askOffer?.actions ?? [],
|
|
700
|
+
actionDescriptions: this._askOffer?.descriptions ?? [],
|
|
701
|
+
source: this._source,
|
|
702
|
+
// root-window coordinates, divided to logical like every coordinate
|
|
703
|
+
// a handler reads (src/scale.js)
|
|
704
|
+
screenX: native.rootx / windowNode.events.scale,
|
|
705
|
+
screenY: native.rooty / windowNode.events.scale,
|
|
706
|
+
accept: (action) => {
|
|
707
|
+
if (!answer) return;
|
|
708
|
+
answer.accept = true;
|
|
709
|
+
if (action) answer.action = action;
|
|
710
|
+
},
|
|
711
|
+
reject: () => {
|
|
712
|
+
if (answer) answer.accept = false;
|
|
713
|
+
},
|
|
714
|
+
freeze: () => {
|
|
715
|
+
if (answer) answer.freeze = true;
|
|
716
|
+
},
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Capture → target → bubble for onDragOver, sharing the path already
|
|
721
|
+
* computed for the position. */
|
|
722
|
+
_dispatchOver(windowNode, path, ev) {
|
|
723
|
+
const events = windowNode.events;
|
|
724
|
+
for (const n of path) {
|
|
725
|
+
const handler = n.props.onDragOverCapture;
|
|
726
|
+
if (handler) {
|
|
727
|
+
ev.currentTarget = events._public(n);
|
|
728
|
+
callHandler(n, 'onDragOverCapture', handler, ev);
|
|
729
|
+
if (ev.propagationStopped) return;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
733
|
+
const handler = path[i].props.onDragOver;
|
|
734
|
+
if (handler) {
|
|
735
|
+
ev.currentTarget = events._public(path[i]);
|
|
736
|
+
callHandler(path[i], 'onDragOver', handler, ev);
|
|
737
|
+
if (ev.propagationStopped) return;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** onDrop dispatch that observes the handlers' returned promises, and
|
|
743
|
+
* hands them to a caller that has to *wait* for them —
|
|
744
|
+
* `events.dispatch` does neither. Same capture → target → bubble walk.
|
|
745
|
+
*
|
|
746
|
+
* Observing and waiting are separate, and only the waiting is optional:
|
|
747
|
+
* `_onDrop` passes `pending` because XdndFinished cannot go out until the
|
|
748
|
+
* handlers settle, `localDrop` passes nothing because nothing in-app
|
|
749
|
+
* waits (its `onDragEnd` follows immediately, by design). Observing is
|
|
750
|
+
* unconditional — an `async onDrop` that throws is a failed drop, and a
|
|
751
|
+
* failed drop nobody attached to is an unhandled rejection, which under
|
|
752
|
+
* node's default `--unhandled-rejections=throw` kills the application
|
|
753
|
+
* (#203). */
|
|
754
|
+
_dispatchDrop(windowNode, targetNode, ev, pending) {
|
|
755
|
+
const events = windowNode.events;
|
|
756
|
+
const nodePath = events._path(targetNode);
|
|
757
|
+
const call = (n, name, handler) => {
|
|
758
|
+
ev.currentTarget = events._public(n);
|
|
759
|
+
try {
|
|
760
|
+
const result = handler(ev);
|
|
761
|
+
if (result && typeof result.then === 'function') {
|
|
762
|
+
// settled first, pushed second: `pending?.push(result.then(…))`
|
|
763
|
+
// would short-circuit the whole expression when there is no
|
|
764
|
+
// `pending`, which is the bug this is here to prevent.
|
|
765
|
+
const settled = result.then(
|
|
766
|
+
() => {},
|
|
767
|
+
(error) => this._dropFailed(ev, n, name, error),
|
|
768
|
+
);
|
|
769
|
+
pending?.push(settled);
|
|
770
|
+
}
|
|
771
|
+
} catch (error) {
|
|
772
|
+
this._dropFailed(ev, n, name, error);
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
for (const n of nodePath) {
|
|
776
|
+
if (n.props.onDropCapture) {
|
|
777
|
+
call(n, 'onDropCapture', n.props.onDropCapture);
|
|
778
|
+
if (ev.propagationStopped) return;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
for (let i = nodePath.length - 1; i >= 0; i--) {
|
|
782
|
+
if (nodePath[i].props.onDrop) {
|
|
783
|
+
call(nodePath[i], 'onDrop', nodePath[i].props.onDrop);
|
|
784
|
+
if (ev.propagationStopped) return;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** A drop handler that failed, whichever way it failed.
|
|
790
|
+
*
|
|
791
|
+
* Both halves matter. It is **reported** on the same channel as every
|
|
792
|
+
* other handler throw, so an `async onDrop` that rejects is as loud as a
|
|
793
|
+
* synchronous one rather than silent (or, before #203, fatal). And it
|
|
794
|
+
* **refuses the drop**: the handler did not store what it was given, and
|
|
795
|
+
* XdndFinished's accepted bit is what a `move` source deletes its own
|
|
796
|
+
* copy on — reporting success after the handler threw is how a failed
|
|
797
|
+
* drop turns into lost data. A later handler that succeeds may still
|
|
798
|
+
* accept, exactly as one may today. */
|
|
799
|
+
_dropFailed(ev, node, name, error) {
|
|
800
|
+
ev.reject();
|
|
801
|
+
reportHandlerError(node, name, error);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// -- edge auto-scroll ----------------------------------------------------
|
|
805
|
+
//
|
|
806
|
+
// A drop target below the fold is otherwise unreachable: the pointer is
|
|
807
|
+
// already at the bottom of the list, and a source that sees no motion
|
|
808
|
+
// sends no more positions. So the timer, not the next position, is what
|
|
809
|
+
// advances the drag — which is also why the suppression rectangle
|
|
810
|
+
// (`e.freeze()`) is opt-in and off by default.
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Aim the auto-scroll at whatever the pointer is over now, or stop it.
|
|
814
|
+
* Called from every position update, so the velocity tracks the pointer
|
|
815
|
+
* without the timer having to re-measure anything.
|
|
816
|
+
*/
|
|
817
|
+
_updateAutoScroll(path, x, y) {
|
|
818
|
+
const node = scrollerIn(path);
|
|
819
|
+
const velocity = node?.abs
|
|
820
|
+
? edgeVelocity(node.abs, x, y, node.scale)
|
|
821
|
+
: null;
|
|
822
|
+
if (!velocity) return this._stopAutoScroll();
|
|
823
|
+
this._autoScroll = { node, ...velocity };
|
|
824
|
+
// already stepping: the line above is the whole update
|
|
825
|
+
if (this._autoScrollTimer) return;
|
|
826
|
+
this._autoScrollTimer = setInterval(
|
|
827
|
+
() => this._autoScrollStep(),
|
|
828
|
+
AUTOSCROLL_INTERVAL,
|
|
829
|
+
);
|
|
830
|
+
// a drag must never be the reason a process cannot exit
|
|
831
|
+
this._autoScrollTimer.unref?.();
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
_stopAutoScroll() {
|
|
835
|
+
if (this._autoScrollTimer) clearInterval(this._autoScrollTimer);
|
|
836
|
+
this._autoScrollTimer = null;
|
|
837
|
+
this._autoScroll = null;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* One step. Re-routing after it is the point of the whole feature: the
|
|
842
|
+
* content moved under a stationary pointer, so which node is hovered —
|
|
843
|
+
* and whether it accepts — can differ from one step to the next.
|
|
844
|
+
*
|
|
845
|
+
* Nothing is sent to the source. An `XdndStatus` is the answer to an
|
|
846
|
+
* `XdndPosition`, and an unsolicited one is not in the protocol; the next
|
|
847
|
+
* real position carries the fresh answer instead.
|
|
848
|
+
*/
|
|
849
|
+
_autoScrollStep() {
|
|
850
|
+
const state = this._autoScroll;
|
|
851
|
+
const point = this.lastPoint;
|
|
852
|
+
if (!state || !point || state.node.destroyed) return this._stopAutoScroll();
|
|
853
|
+
const { node, dx, dy } = state;
|
|
854
|
+
// Nothing may escape a timer callback: there is no caller to catch it,
|
|
855
|
+
// so a throw here would take the process down rather than end a drag.
|
|
856
|
+
// The tree can be torn down between steps — a handler on the way past
|
|
857
|
+
// is free to unmount whatever this was scrolling.
|
|
858
|
+
try {
|
|
859
|
+
const before = `${node.scrollX},${node.scrollY}`;
|
|
860
|
+
if (node._scrollByDevice) node._scrollByDevice(dx, dy);
|
|
861
|
+
else node.scrollBy({ x: dx / node.scale, y: dy / node.scale });
|
|
862
|
+
// at the limit: no movement, so nothing under the pointer changed and
|
|
863
|
+
// there is nothing to re-route. The timer stays armed — the content
|
|
864
|
+
// can still grow, and the pointer is still in the band.
|
|
865
|
+
if (`${node.scrollX},${node.scrollY}` === before) return;
|
|
866
|
+
this._overAt(point.rootX, point.rootY, point.time);
|
|
867
|
+
} catch {
|
|
868
|
+
this._stopAutoScroll();
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** Enter/leave diffing over the drag path — the hover algorithm, with
|
|
873
|
+
* `:drag-over` in place of `:hover`. Non-bubbling, like the DOM's. */
|
|
874
|
+
_updateDragPath(newPath, native) {
|
|
875
|
+
const oldPath = this.path;
|
|
876
|
+
let common = 0;
|
|
877
|
+
while (
|
|
878
|
+
common < oldPath.length &&
|
|
879
|
+
common < newPath.length &&
|
|
880
|
+
oldPath[common] === newPath[common]
|
|
881
|
+
) {
|
|
882
|
+
common++;
|
|
883
|
+
}
|
|
884
|
+
for (let i = oldPath.length - 1; i >= common; i--) {
|
|
885
|
+
const n = oldPath[i];
|
|
886
|
+
if (n.destroyed) continue;
|
|
887
|
+
n.setStyleState(':drag-over', false);
|
|
888
|
+
if (n.props.onDragLeave) {
|
|
889
|
+
callHandler(
|
|
890
|
+
n,
|
|
891
|
+
'onDragLeave',
|
|
892
|
+
n.props.onDragLeave,
|
|
893
|
+
this._makeDragEvent(n.root, 'dragLeave', native, n, null),
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
for (let i = common; i < newPath.length; i++) {
|
|
898
|
+
const n = newPath[i];
|
|
899
|
+
n.setStyleState(':drag-over', true);
|
|
900
|
+
if (n.props.onDragEnter) {
|
|
901
|
+
callHandler(
|
|
902
|
+
n,
|
|
903
|
+
'onDragEnter',
|
|
904
|
+
n.props.onDragEnter,
|
|
905
|
+
this._makeDragEvent(n.root, 'dragEnter', native, n, null),
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
this.path = newPath;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
_clearPath() {
|
|
913
|
+
// every way a drag ends — leave, drop, reset — comes through here
|
|
914
|
+
this._stopAutoScroll();
|
|
915
|
+
const native = this.lastPoint
|
|
916
|
+
? {
|
|
917
|
+
x: this.lastPoint.x,
|
|
918
|
+
y: this.lastPoint.y,
|
|
919
|
+
rootx: this.lastPoint.rootX,
|
|
920
|
+
rooty: this.lastPoint.rootY,
|
|
921
|
+
buttons: 0,
|
|
922
|
+
}
|
|
923
|
+
: { x: 0, y: 0, rootx: 0, rooty: 0, buttons: 0 };
|
|
924
|
+
this._updateDragPath([], native);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// -- data ----------------------------------------------------------------
|
|
928
|
+
|
|
929
|
+
/** One selection conversion, decoded for text-ish targets. ntk's
|
|
930
|
+
* clipboard drives ConvertSelection + INCR reassembly.
|
|
931
|
+
*
|
|
932
|
+
* `time` is the timestamp from `XdndDrop`'s `l[2]`, which the protocol
|
|
933
|
+
* says to convert with — never CurrentTime. Mainstream sources serve
|
|
934
|
+
* whatever they currently offer and would not notice, but one that
|
|
935
|
+
* validates the timestamp fails mysteriously, which is the worst failure
|
|
936
|
+
* profile there is. */
|
|
937
|
+
async _getData(target, time) {
|
|
938
|
+
const clipboard = this.node.app.clipboard;
|
|
939
|
+
if (!clipboard) throw new Error('react-x11: no clipboard on this app');
|
|
940
|
+
const data = await clipboard.read({
|
|
941
|
+
selection: 'XdndSelection',
|
|
942
|
+
target,
|
|
943
|
+
time,
|
|
944
|
+
});
|
|
945
|
+
return typeof data === 'string' ? data : decodeData(data, target);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** `getData('files')` and friends: resolve a semantic group to the first
|
|
949
|
+
* concretely offered member. */
|
|
950
|
+
_resolveType(type) {
|
|
951
|
+
return resolveType(type, this.types);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// -- geometry ------------------------------------------------------------
|
|
955
|
+
|
|
956
|
+
/** Root point → the deepest realized `<window>` under it (nested child
|
|
957
|
+
* windows never advertise; the message lands here), in that window's
|
|
958
|
+
* coordinates. */
|
|
959
|
+
_routePoint(rootX, rootY) {
|
|
960
|
+
const local = (wn) => {
|
|
961
|
+
const origin = wn.window?._screenOrigin ?? {
|
|
962
|
+
x: wn.window?.x ?? 0,
|
|
963
|
+
y: wn.window?.y ?? 0,
|
|
964
|
+
};
|
|
965
|
+
return { x: rootX - origin.x, y: rootY - origin.y };
|
|
966
|
+
};
|
|
967
|
+
const descend = (wn) => {
|
|
968
|
+
const kids = wn.children.filter(
|
|
969
|
+
(c) => c.isWindow && !c.isPopup && c.window && !c.hidden,
|
|
970
|
+
);
|
|
971
|
+
// _xStack is bottom-to-top server order; hit the topmost first
|
|
972
|
+
const order = [...kids].sort(
|
|
973
|
+
(a, b) =>
|
|
974
|
+
wn._xStack.indexOf(b.window.id) - wn._xStack.indexOf(a.window.id),
|
|
975
|
+
);
|
|
976
|
+
for (const child of order) {
|
|
977
|
+
const p = local(child);
|
|
978
|
+
if (
|
|
979
|
+
p.x >= 0 &&
|
|
980
|
+
p.y >= 0 &&
|
|
981
|
+
p.x < (child.window.width ?? 0) &&
|
|
982
|
+
p.y < (child.window.height ?? 0)
|
|
983
|
+
) {
|
|
984
|
+
return descend(child);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
return wn;
|
|
988
|
+
};
|
|
989
|
+
const windowNode = descend(this.node);
|
|
990
|
+
return { windowNode, ...local(windowNode) };
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
_windowRect() {
|
|
994
|
+
const wnd = this.node.window;
|
|
995
|
+
const origin = wnd?._screenOrigin ?? { x: wnd?.x ?? 0, y: wnd?.y ?? 0 };
|
|
996
|
+
return {
|
|
997
|
+
x: origin.x,
|
|
998
|
+
y: origin.y,
|
|
999
|
+
width: wnd?.width ?? 0,
|
|
1000
|
+
height: wnd?.height ?? 0,
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
_nodeRect(node) {
|
|
1005
|
+
const wnd = node.root?.window;
|
|
1006
|
+
const origin = wnd?._screenOrigin ?? { x: wnd?.x ?? 0, y: wnd?.y ?? 0 };
|
|
1007
|
+
const abs = node.abs ?? { x: 0, y: 0, width: 0, height: 0 };
|
|
1008
|
+
return {
|
|
1009
|
+
x: origin.x + abs.x,
|
|
1010
|
+
y: origin.y + abs.y,
|
|
1011
|
+
width: abs.width,
|
|
1012
|
+
height: abs.height,
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// -- replies -------------------------------------------------------------
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* XdndStatus. `rect` null means "keep the positions coming" (flag bit 1
|
|
1020
|
+
* set, empty rectangle) — suppression silently breaks insertion carets
|
|
1021
|
+
* and edge auto-scroll, so it is opt-in via `e.freeze()`, plus the
|
|
1022
|
+
* automatic whole-window refusal when nothing is registered.
|
|
1023
|
+
*/
|
|
1024
|
+
_sendStatus(atoms, { accept, action = 'copy', rect = null }) {
|
|
1025
|
+
const wid = this.node.window?.id;
|
|
1026
|
+
if (!wid || !this.sourceWid) return;
|
|
1027
|
+
const r = rect ?? { x: 0, y: 0, width: 0, height: 0 };
|
|
1028
|
+
this._send(this.sourceWid, atoms.XdndStatus, [
|
|
1029
|
+
wid,
|
|
1030
|
+
(accept ? 1 : 0) | (rect ? 0 : 2),
|
|
1031
|
+
(((r.x & 0xffff) << 16) | (r.y & 0xffff)) >>> 0,
|
|
1032
|
+
(((r.width & 0xffff) << 16) | (r.height & 0xffff)) >>> 0,
|
|
1033
|
+
accept ? actionAtom(atoms, action) : 0,
|
|
1034
|
+
]);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
_sendFinished(atoms, sourceWid, version, { accepted, action = 'copy' }) {
|
|
1038
|
+
const wid = this.node.window?.id;
|
|
1039
|
+
if (!wid || !sourceWid || version < 2) return;
|
|
1040
|
+
const v5 = version >= 5;
|
|
1041
|
+
this._send(sourceWid, atoms.XdndFinished, [
|
|
1042
|
+
wid,
|
|
1043
|
+
v5 && accepted ? 1 : 0,
|
|
1044
|
+
v5 && accepted ? actionAtom(atoms, action) : 0,
|
|
1045
|
+
0,
|
|
1046
|
+
0,
|
|
1047
|
+
]);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/** Replies address the source window with event mask 0 — delivered to
|
|
1051
|
+
* its owner client, per ICCCM SendEvent semantics for client-to-client
|
|
1052
|
+
* messages. */
|
|
1053
|
+
_send(destWid, typeAtom, data) {
|
|
1054
|
+
try {
|
|
1055
|
+
this.X.SendClientMessage(destWid, destWid, typeAtom, 32, data, 0);
|
|
1056
|
+
} catch {
|
|
1057
|
+
// the source died mid-drag; its ClientMessages stop arriving too
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// ---------------------------------------------------------------------------
|
|
1063
|
+
// The drag source.
|
|
1064
|
+
//
|
|
1065
|
+
// One drag session, two transports (docs/architecture/drag-and-drop.md §6.3):
|
|
1066
|
+
// while the pointer is over this process's own windows the drag is local —
|
|
1067
|
+
// no wire, live JS payloads, the DropSession above delivering to the same
|
|
1068
|
+
// handlers with `e.source === 'internal'`. The moment it leaves them, the
|
|
1069
|
+
// session promotes to XDND: the payload is published on XdndSelection
|
|
1070
|
+
// through ntk's clipboard, the foreign target is found by descending from
|
|
1071
|
+
// the root, and Enter/Position/Leave/Drop go out. Handlers cannot tell the
|
|
1072
|
+
// transports apart except by asking.
|
|
1073
|
+
// ---------------------------------------------------------------------------
|
|
1074
|
+
|
|
1075
|
+
// How far a press moves before it is a drag rather than a click lives in
|
|
1076
|
+
// `desktopsettings.js`: it is `Net/DndDragThreshold` where the desktop
|
|
1077
|
+
// published one, and this renderer's own 4px where it did not. A drag that
|
|
1078
|
+
// starts sooner here than in every other application is a drag people begin
|
|
1079
|
+
// by accident.
|
|
1080
|
+
|
|
1081
|
+
/** How long to wait for a target's XdndFinished before ending the drag
|
|
1082
|
+
* anyway — the mirror of the drop side's watchdog. */
|
|
1083
|
+
const FINISH_TIMEOUT = 5_000;
|
|
1084
|
+
/** ButtonPress | ButtonRelease | PointerMotion: what the active grab must
|
|
1085
|
+
* keep selecting when the cursor is swapped mid-drag. */
|
|
1086
|
+
const GRAB_EVENT_MASK = 4 | 8 | 64;
|
|
1087
|
+
|
|
1088
|
+
export function hasDragProps(props) {
|
|
1089
|
+
return Boolean(props?.draggable);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// Realized top-level WindowNodes per app: "is this root point over one of
|
|
1093
|
+
// our windows" is the internal/external transport switch, and the same set
|
|
1094
|
+
// answers internal cross-window routing.
|
|
1095
|
+
const appTopLevels = new WeakMap();
|
|
1096
|
+
|
|
1097
|
+
export function registerTopLevel(windowNode) {
|
|
1098
|
+
const app = windowNode.app;
|
|
1099
|
+
let set = appTopLevels.get(app);
|
|
1100
|
+
if (!set) appTopLevels.set(app, (set = new Set()));
|
|
1101
|
+
set.add(windowNode);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
export function forgetTopLevel(windowNode) {
|
|
1105
|
+
appTopLevels.get(windowNode.app)?.delete(windowNode);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/** The top-level under a root point. Popups sit above plain windows and a
|
|
1109
|
+
* later realization above an earlier one — a heuristic, since the server
|
|
1110
|
+
* owns top-level stacking; good enough for drags. A `<popup dragPreview>`
|
|
1111
|
+
* follows the pointer by design and must never be the thing under it. */
|
|
1112
|
+
function topLevelAt(app, rootX, rootY) {
|
|
1113
|
+
const set = appTopLevels.get(app);
|
|
1114
|
+
if (!set) return null;
|
|
1115
|
+
let hit = null;
|
|
1116
|
+
for (const node of set) {
|
|
1117
|
+
if (node.destroyed || !node.window || node.props.dragPreview) continue;
|
|
1118
|
+
const wnd = node.window;
|
|
1119
|
+
const origin = wnd._screenOrigin ?? { x: wnd.x ?? 0, y: wnd.y ?? 0 };
|
|
1120
|
+
if (
|
|
1121
|
+
rootX >= origin.x &&
|
|
1122
|
+
rootY >= origin.y &&
|
|
1123
|
+
rootX < origin.x + (wnd.width ?? 0) &&
|
|
1124
|
+
rootY < origin.y + (wnd.height ?? 0)
|
|
1125
|
+
) {
|
|
1126
|
+
if (!hit || node.isPopup || !hit.isPopup) hit = node;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
return hit;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/** Called on every button-1 press (events.js): the nearest draggable
|
|
1133
|
+
* ancestor of the hit node arms the window's DragSession, or nothing
|
|
1134
|
+
* happens at all. */
|
|
1135
|
+
export function armDrag(windowNode, target, native) {
|
|
1136
|
+
let source = null;
|
|
1137
|
+
for (
|
|
1138
|
+
let n = target;
|
|
1139
|
+
n;
|
|
1140
|
+
n = n === windowNode ? null : (n.parent ?? windowNode)
|
|
1141
|
+
) {
|
|
1142
|
+
if (hasDragProps(n.props) && !n.props.disabled) {
|
|
1143
|
+
source = n;
|
|
1144
|
+
break;
|
|
1145
|
+
}
|
|
1146
|
+
if (n === windowNode) break;
|
|
1147
|
+
}
|
|
1148
|
+
if (!source) return null;
|
|
1149
|
+
const session = (windowNode._dragSession ??= new DragSession(windowNode));
|
|
1150
|
+
session.arm(source, native);
|
|
1151
|
+
return session;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
const isBinaryData = (v) =>
|
|
1155
|
+
Buffer.isBuffer(v) || ArrayBuffer.isView(v) || v instanceof ArrayBuffer;
|
|
1156
|
+
|
|
1157
|
+
function internAtom(X, name) {
|
|
1158
|
+
return new Promise((resolve, reject) =>
|
|
1159
|
+
X.InternAtom(false, name, (err, atom) =>
|
|
1160
|
+
err ? reject(err) : resolve(atom),
|
|
1161
|
+
),
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
export class DragSession {
|
|
1166
|
+
constructor(windowNode) {
|
|
1167
|
+
this.node = windowNode;
|
|
1168
|
+
this.app = windowNode.app;
|
|
1169
|
+
this.X = windowNode.app.X;
|
|
1170
|
+
this._reset();
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
_reset() {
|
|
1174
|
+
this.phase = 'idle'; // 'idle' | 'armed' | 'dragging'
|
|
1175
|
+
this.source = null;
|
|
1176
|
+
this.press = null;
|
|
1177
|
+
this.types = [];
|
|
1178
|
+
this.actions = ['copy'];
|
|
1179
|
+
this.currentAction = 'copy';
|
|
1180
|
+
this.accepted = false;
|
|
1181
|
+
this.localSession = null;
|
|
1182
|
+
this._data = null;
|
|
1183
|
+
this._resolved = null;
|
|
1184
|
+
this._published = false;
|
|
1185
|
+
this._typeAtoms = null;
|
|
1186
|
+
this._atoms = null;
|
|
1187
|
+
this._cursor = null;
|
|
1188
|
+
this.ext = null;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/** The pressed node (or an ancestor) is draggable: remember where, and
|
|
1192
|
+
* wait for the threshold. Below it, the gesture is still a click. */
|
|
1193
|
+
arm(source, native) {
|
|
1194
|
+
this._reset();
|
|
1195
|
+
this.phase = 'armed';
|
|
1196
|
+
this.source = source;
|
|
1197
|
+
this.press = {
|
|
1198
|
+
x: native.x,
|
|
1199
|
+
y: native.y,
|
|
1200
|
+
rootx: native.rootx ?? native.x,
|
|
1201
|
+
rooty: native.rooty ?? native.y,
|
|
1202
|
+
time: (native.time ?? 0) >>> 0,
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
cancel() {
|
|
1207
|
+
if (this.phase === 'dragging') {
|
|
1208
|
+
this.source?.setStyleState?.(':dragging', false);
|
|
1209
|
+
this.localSession?.localLeave();
|
|
1210
|
+
}
|
|
1211
|
+
this._reset();
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/** A node leaving the tree mid-gesture: abort rather than dangle. */
|
|
1215
|
+
forget(node) {
|
|
1216
|
+
if (this.source === node) this.cancel();
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
/** Every mousemove while armed or dragging. Returns true when the drag
|
|
1220
|
+
* consumed the motion — the caller then skips hover and mousemove. */
|
|
1221
|
+
motion(native) {
|
|
1222
|
+
if (this.phase === 'armed') {
|
|
1223
|
+
const moved =
|
|
1224
|
+
Math.abs(native.x - this.press.x) + Math.abs(native.y - this.press.y);
|
|
1225
|
+
// the desktop's threshold is logical pixels; the motion is device
|
|
1226
|
+
if (moved < desktopSettings(this.app).dragThreshold * this.node.scale)
|
|
1227
|
+
return false;
|
|
1228
|
+
if (!this._start(native)) return false;
|
|
1229
|
+
}
|
|
1230
|
+
if (this.phase !== 'dragging') return false;
|
|
1231
|
+
this._feed(native);
|
|
1232
|
+
return true;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
_start(native) {
|
|
1236
|
+
const source = this.source;
|
|
1237
|
+
if (!source || source.destroyed) {
|
|
1238
|
+
this._reset();
|
|
1239
|
+
return false;
|
|
1240
|
+
}
|
|
1241
|
+
this._data = source.props.dragData ?? {};
|
|
1242
|
+
this.types = Object.keys(this._data);
|
|
1243
|
+
const actions = source.props.dragActions;
|
|
1244
|
+
this.actions =
|
|
1245
|
+
Array.isArray(actions) && actions.length > 0 ? actions : ['copy'];
|
|
1246
|
+
this.currentAction = this.actions[0];
|
|
1247
|
+
this._resolved = new Map();
|
|
1248
|
+
const ev = this.node.events.dispatch('DragStart', source, native, {
|
|
1249
|
+
types: this.types,
|
|
1250
|
+
action: this.currentAction,
|
|
1251
|
+
source: 'internal',
|
|
1252
|
+
screenX: (native.rootx ?? native.x) / this.node.events.scale,
|
|
1253
|
+
screenY: (native.rooty ?? native.y) / this.node.events.scale,
|
|
1254
|
+
});
|
|
1255
|
+
if (ev.defaultPrevented) {
|
|
1256
|
+
this._reset();
|
|
1257
|
+
return false;
|
|
1258
|
+
}
|
|
1259
|
+
this.phase = 'dragging';
|
|
1260
|
+
source.setStyleState(':dragging', true);
|
|
1261
|
+
this._setCursor('grab');
|
|
1262
|
+
return true;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/** dragData values resolve once per drag: thunks are called on first
|
|
1266
|
+
* use — at delivery for an internal drop, at promotion for an external
|
|
1267
|
+
* one — and never at mousedown. */
|
|
1268
|
+
_resolve(type) {
|
|
1269
|
+
if (!this._resolved.has(type)) {
|
|
1270
|
+
const value = this._data[type];
|
|
1271
|
+
this._resolved.set(type, typeof value === 'function' ? value() : value);
|
|
1272
|
+
}
|
|
1273
|
+
return this._resolved.get(type);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
_offer() {
|
|
1277
|
+
return {
|
|
1278
|
+
types: this.types,
|
|
1279
|
+
action: this.actions[0],
|
|
1280
|
+
source: 'internal',
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/** What an internal drop event carries beyond the external one: the live
|
|
1285
|
+
* values themselves, plus the same prefetched `files`/`text` shape so a
|
|
1286
|
+
* handler cannot tell the transports apart unless it asks. */
|
|
1287
|
+
_dropExtras() {
|
|
1288
|
+
const items = {};
|
|
1289
|
+
for (const t of this.types) items[t] = this._resolve(t);
|
|
1290
|
+
let files = [];
|
|
1291
|
+
const uriType = this.types.find((t) => TYPE_GROUPS.files.includes(t));
|
|
1292
|
+
if (uriType) {
|
|
1293
|
+
const v = this._resolve(uriType);
|
|
1294
|
+
if (typeof v === 'string') files = parseUriList(v);
|
|
1295
|
+
}
|
|
1296
|
+
let text;
|
|
1297
|
+
const best = TEXT_TARGETS.find((t) => this.types.includes(t));
|
|
1298
|
+
if (best) {
|
|
1299
|
+
const v = this._resolve(best);
|
|
1300
|
+
if (typeof v === 'string') text = v;
|
|
1301
|
+
}
|
|
1302
|
+
const getData = (type) =>
|
|
1303
|
+
Promise.resolve(this._resolve(resolveType(type, this.types)));
|
|
1304
|
+
return { items, files, text, getData };
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
_feed(native) {
|
|
1308
|
+
const rootX = native.rootx ?? native.x;
|
|
1309
|
+
const rootY = native.rooty ?? native.y;
|
|
1310
|
+
const over = topLevelAt(this.app, rootX, rootY);
|
|
1311
|
+
if (over?._dnd) {
|
|
1312
|
+
// internal: deliver through the window's own DropSession
|
|
1313
|
+
if (this.ext?.current) this._extLeave();
|
|
1314
|
+
if (this.localSession && this.localSession !== over._dnd) {
|
|
1315
|
+
this.localSession.localLeave();
|
|
1316
|
+
}
|
|
1317
|
+
this.localSession = over._dnd;
|
|
1318
|
+
const result = this.localSession.localOver(
|
|
1319
|
+
rootX,
|
|
1320
|
+
rootY,
|
|
1321
|
+
this._offer(),
|
|
1322
|
+
(native.time ?? 0) >>> 0,
|
|
1323
|
+
);
|
|
1324
|
+
this.accepted = result.accepted;
|
|
1325
|
+
if (result.accepted) this.currentAction = result.action;
|
|
1326
|
+
this._setCursor(result.accepted ? 'grab' : 'not-allowed');
|
|
1327
|
+
} else {
|
|
1328
|
+
// external: over a foreign window (or nothing)
|
|
1329
|
+
if (this.localSession) {
|
|
1330
|
+
this.localSession.localLeave();
|
|
1331
|
+
this.localSession = null;
|
|
1332
|
+
this.accepted = false;
|
|
1333
|
+
}
|
|
1334
|
+
this._extMotion(rootX, rootY, (native.time ?? 0) >>> 0);
|
|
1335
|
+
}
|
|
1336
|
+
const source = this.source;
|
|
1337
|
+
if (source && !source.destroyed && source.props.onDrag) {
|
|
1338
|
+
callHandler(
|
|
1339
|
+
source,
|
|
1340
|
+
'onDrag',
|
|
1341
|
+
source.props.onDrag,
|
|
1342
|
+
this.node.events._makeEvent('drag', native, source, {
|
|
1343
|
+
types: this.types,
|
|
1344
|
+
action: this.currentAction,
|
|
1345
|
+
source: this.localSession ? 'internal' : 'external',
|
|
1346
|
+
accepted: this.accepted,
|
|
1347
|
+
screenX: rootX / this.node.events.scale,
|
|
1348
|
+
screenY: rootY / this.node.events.scale,
|
|
1349
|
+
}),
|
|
1350
|
+
);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/** Button release. Returns true when a drag ran (the caller suppresses
|
|
1355
|
+
* mouseup/click, like the DOM after a drag gesture). */
|
|
1356
|
+
release(native) {
|
|
1357
|
+
if (this.phase !== 'dragging') {
|
|
1358
|
+
this._reset();
|
|
1359
|
+
return false;
|
|
1360
|
+
}
|
|
1361
|
+
const source = this.source;
|
|
1362
|
+
source?.setStyleState?.(':dragging', false);
|
|
1363
|
+
if (this.localSession) {
|
|
1364
|
+
let outcome = { handled: false, action: null };
|
|
1365
|
+
if (this.accepted) {
|
|
1366
|
+
outcome = this.localSession.localDrop(
|
|
1367
|
+
this._offer(),
|
|
1368
|
+
this._dropExtras(),
|
|
1369
|
+
(native.time ?? 0) >>> 0,
|
|
1370
|
+
);
|
|
1371
|
+
} else {
|
|
1372
|
+
this.localSession.localLeave();
|
|
1373
|
+
}
|
|
1374
|
+
this._end(
|
|
1375
|
+
native,
|
|
1376
|
+
outcome.handled ? outcome.action : null,
|
|
1377
|
+
outcome.handled,
|
|
1378
|
+
);
|
|
1379
|
+
} else if (this.ext?.current && this.accepted) {
|
|
1380
|
+
this._extDrop(native); // async: onDragEnd follows XdndFinished
|
|
1381
|
+
} else {
|
|
1382
|
+
if (this.ext?.current) this._extLeave();
|
|
1383
|
+
this._end(native, null, false);
|
|
1384
|
+
}
|
|
1385
|
+
return true;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
_end(native, action, dropped) {
|
|
1389
|
+
const source = this.source;
|
|
1390
|
+
if (this._published) this._disown();
|
|
1391
|
+
if (source && !source.destroyed) {
|
|
1392
|
+
this.node.events.dispatch('DragEnd', source, native, {
|
|
1393
|
+
types: this.types,
|
|
1394
|
+
action,
|
|
1395
|
+
dropped,
|
|
1396
|
+
source: 'internal',
|
|
1397
|
+
screenX: (native.rootx ?? native.x ?? 0) / this.node.events.scale,
|
|
1398
|
+
screenY: (native.rooty ?? native.y ?? 0) / this.node.events.scale,
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
this._reset();
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/** Swap the active grab's cursor (X ChangeActivePointerGrab works on the
|
|
1405
|
+
* implicit button grab too). Core-font approximations — see the doc's
|
|
1406
|
+
* XCursor caveat; the mask must keep selecting motion and release or the
|
|
1407
|
+
* gesture goes silent. */
|
|
1408
|
+
_setCursor(name) {
|
|
1409
|
+
if (name === this._cursor) return;
|
|
1410
|
+
const X = this.X;
|
|
1411
|
+
if (typeof X.ChangeActivePointerGrab !== 'function') return;
|
|
1412
|
+
const cursors = this.app.cursors;
|
|
1413
|
+
if (!cursors?.get) return;
|
|
1414
|
+
try {
|
|
1415
|
+
X.ChangeActivePointerGrab(cursors.get(name), 0, GRAB_EVENT_MASK);
|
|
1416
|
+
this._cursor = name;
|
|
1417
|
+
} catch {
|
|
1418
|
+
// no cursor feedback is a cosmetic failure, never a functional one
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// -- the external transport (XDND out) -----------------------------------
|
|
1423
|
+
|
|
1424
|
+
/** First motion outside our windows: publish the payload. Thunks resolve
|
|
1425
|
+
* now; live objects are JSON-serialised for the wire (internal drops got
|
|
1426
|
+
* them by reference). ntk's clipboard owns XdndSelection and serves the
|
|
1427
|
+
* conversions, INCR included. */
|
|
1428
|
+
async _publish() {
|
|
1429
|
+
if (this._published) return;
|
|
1430
|
+
this._published = true;
|
|
1431
|
+
this._atoms = await dndAtoms(this.X);
|
|
1432
|
+
this._typeAtoms = await Promise.all(
|
|
1433
|
+
this.types.map((t) => internAtom(this.X, t)),
|
|
1434
|
+
);
|
|
1435
|
+
const map = {};
|
|
1436
|
+
for (const t of this.types) {
|
|
1437
|
+
let v = this._resolve(t);
|
|
1438
|
+
if (typeof v !== 'string' && !isBinaryData(v)) v = JSON.stringify(v);
|
|
1439
|
+
map[t] = v;
|
|
1440
|
+
}
|
|
1441
|
+
const clipboard = this.app.clipboard;
|
|
1442
|
+
if (clipboard && this.types.length > 0) {
|
|
1443
|
+
await clipboard.write(map, {
|
|
1444
|
+
selection: 'XdndSelection',
|
|
1445
|
+
...(this.press.time ? { time: this.press.time } : {}),
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
if (
|
|
1449
|
+
this._typeAtoms.length > 3 &&
|
|
1450
|
+
typeof this.node.window?.setProperty === 'function'
|
|
1451
|
+
) {
|
|
1452
|
+
await this.node.window.setProperty('XdndTypeList', this._typeAtoms, {
|
|
1453
|
+
type: 'ATOM',
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/** Positions are serialized behind the publish and the async target
|
|
1459
|
+
* descent; only the newest queued point is ever resolved, so a motion
|
|
1460
|
+
* burst costs one descent, not one per event. */
|
|
1461
|
+
_extMotion(rootX, rootY, time) {
|
|
1462
|
+
const ext = (this.ext ??= {
|
|
1463
|
+
current: null,
|
|
1464
|
+
queue: null,
|
|
1465
|
+
busy: false,
|
|
1466
|
+
suppress: null,
|
|
1467
|
+
finish: null,
|
|
1468
|
+
});
|
|
1469
|
+
ext.queue = { rootX, rootY, time };
|
|
1470
|
+
if (ext.busy) return;
|
|
1471
|
+
ext.busy = true;
|
|
1472
|
+
(async () => {
|
|
1473
|
+
try {
|
|
1474
|
+
await this._publish();
|
|
1475
|
+
while (this.ext?.queue && this.phase === 'dragging') {
|
|
1476
|
+
const point = this.ext.queue;
|
|
1477
|
+
this.ext.queue = null;
|
|
1478
|
+
await this._extStep(point);
|
|
1479
|
+
}
|
|
1480
|
+
} catch {
|
|
1481
|
+
// connection teardown mid-drag
|
|
1482
|
+
} finally {
|
|
1483
|
+
if (this.ext) this.ext.busy = false;
|
|
1484
|
+
}
|
|
1485
|
+
})();
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
async _extStep({ rootX, rootY, time }) {
|
|
1489
|
+
const atoms = this._atoms;
|
|
1490
|
+
const found = await this._findTarget(rootX, rootY);
|
|
1491
|
+
const ext = this.ext;
|
|
1492
|
+
if (!ext || this.phase !== 'dragging') return;
|
|
1493
|
+
const current = ext.current;
|
|
1494
|
+
if ((found?.wid ?? 0) !== (current?.wid ?? 0)) {
|
|
1495
|
+
if (current) {
|
|
1496
|
+
this._sendTo(current.sendWid, atoms.XdndLeave, [
|
|
1497
|
+
this.node.window.id,
|
|
1498
|
+
0,
|
|
1499
|
+
0,
|
|
1500
|
+
0,
|
|
1501
|
+
0,
|
|
1502
|
+
]);
|
|
1503
|
+
}
|
|
1504
|
+
ext.current = found;
|
|
1505
|
+
ext.suppress = null;
|
|
1506
|
+
this.accepted = false;
|
|
1507
|
+
this._setCursor('not-allowed');
|
|
1508
|
+
if (found) {
|
|
1509
|
+
const flags =
|
|
1510
|
+
((XDND_VERSION << 24) | (this.types.length > 3 ? 1 : 0)) >>> 0;
|
|
1511
|
+
const t = this._typeAtoms ?? [];
|
|
1512
|
+
this._sendTo(found.sendWid, atoms.XdndEnter, [
|
|
1513
|
+
this.node.window.id,
|
|
1514
|
+
flags,
|
|
1515
|
+
t[0] ?? 0,
|
|
1516
|
+
t[1] ?? 0,
|
|
1517
|
+
t[2] ?? 0,
|
|
1518
|
+
]);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
if (!ext.current) return;
|
|
1522
|
+
const s = ext.suppress;
|
|
1523
|
+
if (
|
|
1524
|
+
s &&
|
|
1525
|
+
rootX >= s.x &&
|
|
1526
|
+
rootY >= s.y &&
|
|
1527
|
+
rootX < s.x + s.width &&
|
|
1528
|
+
rootY < s.y + s.height
|
|
1529
|
+
) {
|
|
1530
|
+
return; // the target asked for silence inside this rectangle
|
|
1531
|
+
}
|
|
1532
|
+
this._sendTo(ext.current.sendWid, atoms.XdndPosition, [
|
|
1533
|
+
this.node.window.id,
|
|
1534
|
+
0,
|
|
1535
|
+
(((rootX & 0xffff) << 16) | (rootY & 0xffff)) >>> 0,
|
|
1536
|
+
time >>> 0,
|
|
1537
|
+
actionAtom(atoms, this.actions[0]),
|
|
1538
|
+
]);
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/**
|
|
1542
|
+
* The XDND target under a root point: descend the window tree with
|
|
1543
|
+
* TranslateCoordinates, checking XdndAware at each level (since v3 it is
|
|
1544
|
+
* on the top-level client window, which under a reparenting WM sits a
|
|
1545
|
+
* level or two below the frame). XdndProxy redirects the sends. Per
|
|
1546
|
+
* motion and uncached — 2–4 round trips, fine locally; the drag-start
|
|
1547
|
+
* window cache from the design doc is the remote-X follow-up.
|
|
1548
|
+
*/
|
|
1549
|
+
async _findTarget(rootX, rootY) {
|
|
1550
|
+
const root = this.X.display?.screen?.[0]?.root;
|
|
1551
|
+
if (root == null) return null;
|
|
1552
|
+
let wid = root;
|
|
1553
|
+
for (let depth = 0; depth < 16; depth++) {
|
|
1554
|
+
const child = await this._translate(root, wid, rootX, rootY);
|
|
1555
|
+
if (!child) return null;
|
|
1556
|
+
if (this._isOurs(child)) return null;
|
|
1557
|
+
const aware = await this._readAware(child);
|
|
1558
|
+
if (aware) {
|
|
1559
|
+
return {
|
|
1560
|
+
wid: child,
|
|
1561
|
+
sendWid: aware.proxy || child,
|
|
1562
|
+
version: Math.min(XDND_VERSION, aware.version),
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
wid = child;
|
|
1566
|
+
}
|
|
1567
|
+
return null;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
_translate(srcWid, dstWid, x, y) {
|
|
1571
|
+
return new Promise((resolve) => {
|
|
1572
|
+
this.X.TranslateCoordinates(srcWid, dstWid, x, y, (err, res) =>
|
|
1573
|
+
resolve(err ? 0 : (res?.child ?? 0)),
|
|
1574
|
+
);
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
_isOurs(wid) {
|
|
1579
|
+
const set = appTopLevels.get(this.app);
|
|
1580
|
+
if (!set) return false;
|
|
1581
|
+
for (const node of set) {
|
|
1582
|
+
if (node.window?.id === wid) return true;
|
|
1583
|
+
}
|
|
1584
|
+
return false;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
_readAware(wid) {
|
|
1588
|
+
const atoms = this._atoms;
|
|
1589
|
+
const read = (atom) =>
|
|
1590
|
+
new Promise((resolve) => {
|
|
1591
|
+
this.X.GetProperty(0, wid, atom, 0, 0, 4, (err, prop) =>
|
|
1592
|
+
resolve(
|
|
1593
|
+
err || !prop?.data || prop.data.length < 4 ? null : prop.data,
|
|
1594
|
+
),
|
|
1595
|
+
);
|
|
1596
|
+
});
|
|
1597
|
+
return Promise.all([read(atoms.XdndAware), read(atoms.XdndProxy)]).then(
|
|
1598
|
+
([aware, proxy]) =>
|
|
1599
|
+
aware
|
|
1600
|
+
? {
|
|
1601
|
+
version: aware.readUInt32LE(0),
|
|
1602
|
+
proxy: proxy ? proxy.readUInt32LE(0) : 0,
|
|
1603
|
+
}
|
|
1604
|
+
: null,
|
|
1605
|
+
);
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
/** XdndStatus / XdndFinished sent back by the foreign target, routed here
|
|
1609
|
+
* from the source window's 'message' listener. */
|
|
1610
|
+
handleMessage(ev) {
|
|
1611
|
+
if (ev.format !== 32) return;
|
|
1612
|
+
const atoms = dndAtomsOf(this.X);
|
|
1613
|
+
if (!atoms) return; // cannot be ours: we intern before we publish
|
|
1614
|
+
if (ev.message_type === atoms.XdndStatus) this._onStatus(ev, atoms);
|
|
1615
|
+
else if (ev.message_type === atoms.XdndFinished) this._onFinished(ev);
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
_onStatus(ev, atoms) {
|
|
1619
|
+
const ext = this.ext;
|
|
1620
|
+
if (!ext?.current || ev.data[0] >>> 0 !== ext.current.wid) return;
|
|
1621
|
+
const accept = Boolean(ev.data[1] & 1);
|
|
1622
|
+
this.accepted = accept;
|
|
1623
|
+
if (accept) this.currentAction = actionName(atoms, ev.data[4] >>> 0);
|
|
1624
|
+
const wantInside = Boolean(ev.data[1] & 2);
|
|
1625
|
+
const rw = ev.data[3] >>> 16;
|
|
1626
|
+
const rh = ev.data[3] & 0xffff;
|
|
1627
|
+
ext.suppress =
|
|
1628
|
+
wantInside || (rw === 0 && rh === 0)
|
|
1629
|
+
? null
|
|
1630
|
+
: {
|
|
1631
|
+
x: ev.data[2] >>> 16,
|
|
1632
|
+
y: ev.data[2] & 0xffff,
|
|
1633
|
+
width: rw,
|
|
1634
|
+
height: rh,
|
|
1635
|
+
};
|
|
1636
|
+
this._setCursor(accept ? 'grab' : 'not-allowed');
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
_onFinished(ev) {
|
|
1640
|
+
const ext = this.ext;
|
|
1641
|
+
if (ext?.finish) {
|
|
1642
|
+
const finish = ext.finish;
|
|
1643
|
+
ext.finish = null;
|
|
1644
|
+
finish({
|
|
1645
|
+
accepted: Boolean(ev.data[1] & 1),
|
|
1646
|
+
actionAtom: ev.data[2] >>> 0,
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
_extLeave() {
|
|
1652
|
+
const ext = this.ext;
|
|
1653
|
+
if (!ext?.current || !this._atoms) return;
|
|
1654
|
+
this._sendTo(ext.current.sendWid, this._atoms.XdndLeave, [
|
|
1655
|
+
this.node.window.id,
|
|
1656
|
+
0,
|
|
1657
|
+
0,
|
|
1658
|
+
0,
|
|
1659
|
+
0,
|
|
1660
|
+
]);
|
|
1661
|
+
ext.current = null;
|
|
1662
|
+
ext.suppress = null;
|
|
1663
|
+
this.accepted = false;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
_extDrop(native) {
|
|
1667
|
+
const ext = this.ext;
|
|
1668
|
+
const atoms = this._atoms;
|
|
1669
|
+
const target = ext.current;
|
|
1670
|
+
this._sendTo(target.sendWid, atoms.XdndDrop, [
|
|
1671
|
+
this.node.window.id,
|
|
1672
|
+
0,
|
|
1673
|
+
(native.time ?? 0) >>> 0,
|
|
1674
|
+
0,
|
|
1675
|
+
0,
|
|
1676
|
+
]);
|
|
1677
|
+
let done = false;
|
|
1678
|
+
const finish = (result) => {
|
|
1679
|
+
if (done) return;
|
|
1680
|
+
done = true;
|
|
1681
|
+
clearTimeout(timer);
|
|
1682
|
+
if (this.ext) this.ext.finish = null;
|
|
1683
|
+
// pre-v5 XdndFinished carries no verdict: treat silence and old
|
|
1684
|
+
// versions as "done, with the action the status negotiated"
|
|
1685
|
+
const rejected = result != null && result.accepted === false;
|
|
1686
|
+
const action =
|
|
1687
|
+
result?.accepted && target.version >= 5 && result.actionAtom
|
|
1688
|
+
? actionName(atoms, result.actionAtom)
|
|
1689
|
+
: this.currentAction;
|
|
1690
|
+
this._end(native, rejected ? null : action, !rejected);
|
|
1691
|
+
};
|
|
1692
|
+
const timer = setTimeout(() => finish(null), FINISH_TIMEOUT);
|
|
1693
|
+
timer.unref?.();
|
|
1694
|
+
if (target.version >= 2) ext.finish = finish;
|
|
1695
|
+
else finish(null);
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/** Give XdndSelection up at drag end. Ownership is otherwise held until
|
|
1699
|
+
* someone else drags — harmless, except that a stale offer answered long
|
|
1700
|
+
* after XdndFinished is exactly what the spec warns about.
|
|
1701
|
+
*
|
|
1702
|
+
* `clipboard.clear()` rather than a raw `SetSelectionOwner` (ntk >= 5.4.0):
|
|
1703
|
+
* it releases with the timestamp the selection was acquired with, as ICCCM
|
|
1704
|
+
* 2.3.1 requires, so a drag that has already lost XdndSelection to another
|
|
1705
|
+
* client leaves it alone — where CurrentTime would have taken it away from
|
|
1706
|
+
* them. It also keeps ntk's own record of what it owns in step, instead of
|
|
1707
|
+
* waiting for the SelectionClear to come back and say so. */
|
|
1708
|
+
_disown() {
|
|
1709
|
+
// Nothing here may throw: `_end` calls this before dispatching DragEnd,
|
|
1710
|
+
// so a throw would cost the app the end of its own drag.
|
|
1711
|
+
try {
|
|
1712
|
+
this.app?.clipboard?.clear('XdndSelection')?.catch(() => {});
|
|
1713
|
+
} catch {
|
|
1714
|
+
// a connection already closing
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
_sendTo(destWid, typeAtom, data) {
|
|
1719
|
+
try {
|
|
1720
|
+
this.X.SendClientMessage(destWid, destWid, typeAtom, 32, data, 0);
|
|
1721
|
+
} catch {
|
|
1722
|
+
// the target died mid-drag
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
}
|