react-x11 2.11.0 → 2.13.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 +12 -4
- package/src/Reconciler.js +19 -31
- package/src/a11y.js +2 -2
- package/src/anchor.js +7 -5
- package/src/appcontext.js +59 -30
- package/src/bootstrap.js +14 -0
- package/src/clientmessage.js +1 -1
- package/src/cocoa/app.js +303 -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 +24 -5
- package/src/cocoa/main.d.ts +8 -0
- package/src/cocoa/main.js +43 -0
- package/src/cocoa/overlay.js +159 -0
- package/src/cocoa/panehost.js +15 -5
- package/src/cocoa/presenter.js +13 -9
- package/src/cocoa/promotion.js +17 -7
- package/src/cocoa/relaunch.js +207 -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/embedding.js +31 -0
- package/src/errors.js +46 -0
- package/src/events.js +78 -18
- package/src/foreignnodes.js +59 -5
- package/src/frames.js +2 -2
- package/src/glnodes.js +172 -41
- package/src/gloverlay.js +383 -0
- package/src/grid.js +1653 -0
- package/src/host.d.ts +230 -1
- package/src/host.js +11 -3
- package/src/imagesource.js +1 -1
- package/src/index.d.ts +34 -4
- package/src/index.js +9 -1
- package/src/layouts.js +721 -0
- package/src/node.d.ts +16 -3
- 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 +369 -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 +954 -0
- package/src/palette.js +1 -1
- package/src/registry.js +7 -3
- 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 +26 -14
- package/src/types/nodes.d.ts +17 -2
- 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,183 @@
|
|
|
1
|
+
// The damage model's vocabulary: the whole-window sentinel, the bounded list
|
|
2
|
+
// of rects a frame repaints and how it is merged, the reasons an
|
|
3
|
+
// invalidate() may give, and the diff a layout pass reports into. Data and
|
|
4
|
+
// pure functions only; the methods that feed it are in invalidate.js.
|
|
5
|
+
|
|
6
|
+
import { unionRect, rectArea, rectsBounds, rectsOverlap } from './rects.js';
|
|
7
|
+
|
|
8
|
+
// --- damage -------------------------------------------------------------
|
|
9
|
+
//
|
|
10
|
+
// A frame either repaints the whole window or a bounded region of it. The
|
|
11
|
+
// sentinel is deliberately not a rect: "the whole window" has to stay
|
|
12
|
+
// distinguishable from "a rect that happens to cover it", because the
|
|
13
|
+
// window can be resized between the invalidation and the paint.
|
|
14
|
+
export const FULL_DAMAGE = Symbol('full-damage');
|
|
15
|
+
|
|
16
|
+
// "I need a frame, but nothing I own changed appearance." Distinct from
|
|
17
|
+
// passing no node, which means "something changed and I cannot say where" —
|
|
18
|
+
// the safe reading, and the one that repaints everything.
|
|
19
|
+
export const NO_DAMAGE = Symbol('no-damage');
|
|
20
|
+
|
|
21
|
+
// Painting is not perfectly bounded by a node's rect — an antialiased
|
|
22
|
+
// rounded corner or glyph edge puts coverage a fraction of a pixel outside
|
|
23
|
+
// it — so damage grows by a pixel on each side before anything is culled
|
|
24
|
+
// against it.
|
|
25
|
+
export const DAMAGE_SLOP = 1;
|
|
26
|
+
|
|
27
|
+
/** Stale bounds are undebuggable, and every other cache here has an escape
|
|
28
|
+
* hatch — see NO_SCROLL_BLIT and the paint cache's DISABLED. */
|
|
29
|
+
export const NO_BOUNDS_CACHE = process.env.REACT_X11_NO_BOUNDS_CACHE === '1';
|
|
30
|
+
|
|
31
|
+
// The two pieces of state a bounded frame's layout pass reports through.
|
|
32
|
+
// One object rather than two `let`s: the pass runs through more than one
|
|
33
|
+
// module, and a module cannot assign a binding it imported.
|
|
34
|
+
export const layoutDiff = {
|
|
35
|
+
// While a bounded frame's layout pass runs, every node whose absolute rect
|
|
36
|
+
// comes out different reports its old and new rects here (each already
|
|
37
|
+
// inflated by that node's own paint reach) — which is what lets a layout
|
|
38
|
+
// change stay a handful of rects instead of degrading the frame to
|
|
39
|
+
// FULL_DAMAGE. Module state rather than a parameter because absolutize is
|
|
40
|
+
// a hot recursive walk with overrides in three classes; null outside the
|
|
41
|
+
// pass, and always restored through `finally`.
|
|
42
|
+
sink: null,
|
|
43
|
+
|
|
44
|
+
// The uniform translation the subtree currently being walked is riding: set
|
|
45
|
+
// while a scroll container whose blit is armed lays its children out, so the
|
|
46
|
+
// diff can tell "moved" from "scrolled" (issue #398). Every child of such a
|
|
47
|
+
// container lands at its old rect plus this shift, which is precisely what
|
|
48
|
+
// the blit is about to do to those pixels — so it is not a change, and the
|
|
49
|
+
// diff reports only the children that landed somewhere else. Null everywhere
|
|
50
|
+
// else, and restored through `finally` like the sink beside it.
|
|
51
|
+
shift: null,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// What an invalidate() may name as its reason — a small closed set, so the
|
|
55
|
+
// frame log, the tracer and the full-repaint warning can print "why" next
|
|
56
|
+
// to "where". A typo'd reason would silently vanish from every report, so
|
|
57
|
+
// DEV validates against this list.
|
|
58
|
+
export const INVALIDATE_REASONS = new Set([
|
|
59
|
+
'props', // a React commit changed what a node draws
|
|
60
|
+
'style-state', // :hover/:focus/:active/:disabled restyle
|
|
61
|
+
'shadow', // a boxShadow got smaller: where it *was* still owes a repaint
|
|
62
|
+
'outline', // …and the same for an outline a style swap took away
|
|
63
|
+
'theme', // a theme/token change restyled a subtree
|
|
64
|
+
'direction', // the reading direction moved: sides, glyph order, bar edge
|
|
65
|
+
'scale', // a `scale` prop zoomed a subtree: every length in it moved
|
|
66
|
+
'animation', // a transition frame
|
|
67
|
+
'scroll', // scrollTo/scrollBy/scrollIntoView, textarea/textinput panning
|
|
68
|
+
'text', // text content, input value or caret editing
|
|
69
|
+
'selection', // the document selection lit or unlit a range of text
|
|
70
|
+
'content', // async content arrived (image decode, rich-content reflow)
|
|
71
|
+
'measure', // an element said its own size changed (invalidateMeasure)
|
|
72
|
+
'child-list', // children were added, removed or reordered
|
|
73
|
+
'focus', // focus moved: ring/caret handover between nodes
|
|
74
|
+
'caret', // the caret blink timer
|
|
75
|
+
'resize', // the window changed size
|
|
76
|
+
'mount', // the window was just realized; its first frame
|
|
77
|
+
'expose', // ntk asked for a redraw (backing store invalidated)
|
|
78
|
+
'highlight', // DevTools hover highlight
|
|
79
|
+
'trace-updates', // DevTools' outline of what just re-rendered
|
|
80
|
+
'capabilities', // a compositor started or stopped: what the window may paint
|
|
81
|
+
'layout', // a layout algorithm arrived, left, or has something new to read
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
// A frame with no recorded reasons shares one frozen empty list, so the
|
|
85
|
+
// per-frame cost of the reason machinery when nothing is reading it is a
|
|
86
|
+
// property write.
|
|
87
|
+
export const EMPTY_REASONS = Object.freeze([]);
|
|
88
|
+
|
|
89
|
+
// How many rectangles a frame's damage is allowed to hold.
|
|
90
|
+
//
|
|
91
|
+
// Kept much smaller than the equivalent cap in ntk, because a rectangle costs
|
|
92
|
+
// more here: ntk pays one extra CopyArea per rectangle, while this pays a whole
|
|
93
|
+
// extra pass over the tree — `paintOrder()` allocates and sorts per node — plus
|
|
94
|
+
// a clip. Four is enough for the shapes that actually occur (a ticker and a
|
|
95
|
+
// table row; a control and the status line it updates) and cheap enough that
|
|
96
|
+
// the worst case is not worth avoiding.
|
|
97
|
+
export const MAX_DAMAGE_RECTS = 4;
|
|
98
|
+
|
|
99
|
+
// How much of the box around them several rectangles have to save to be worth
|
|
100
|
+
// painting separately. Two adjacent tab headers describe nearly the same area
|
|
101
|
+
// either way and are better off as one pass; two corners of the window are not.
|
|
102
|
+
const SPLIT_SAVING = 0.75;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Merge overlapping rects until none of them overlap.
|
|
106
|
+
*
|
|
107
|
+
* Disjointness is not tidiness here, it is correctness. Each rect gets its own
|
|
108
|
+
* pass over the tree, and a node inside two of them would be painted twice —
|
|
109
|
+
* harmless for opaque drawing, wrong for anything translucent, which would
|
|
110
|
+
* blend over itself. Merging removes the question and saves the duplicated
|
|
111
|
+
* pass at the same time.
|
|
112
|
+
*/
|
|
113
|
+
function coalesceRects(rects) {
|
|
114
|
+
const out = [];
|
|
115
|
+
for (const rect of rects) {
|
|
116
|
+
let merged = rect;
|
|
117
|
+
for (let i = out.length - 1; i >= 0; i--) {
|
|
118
|
+
if (!rectsOverlap(out[i], merged)) continue;
|
|
119
|
+
merged = unionRect(merged, out[i]);
|
|
120
|
+
out.splice(i, 1);
|
|
121
|
+
// start the scan again: having grown, the rect can now reach ones
|
|
122
|
+
// already passed over
|
|
123
|
+
i = out.length;
|
|
124
|
+
}
|
|
125
|
+
out.push(merged);
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Add one rect to a capped, disjoint damage list, returning a new list.
|
|
132
|
+
*
|
|
133
|
+
* Over the cap, the pair whose merge wastes the least area is merged — waste
|
|
134
|
+
* being the area the merged rect covers that neither of the two did, so
|
|
135
|
+
* neighbours go first and far-apart rects last. That merge can itself overlap a
|
|
136
|
+
* third rect, so the result is coalesced again.
|
|
137
|
+
*/
|
|
138
|
+
export function addDamageRect(rects, add, cap = MAX_DAMAGE_RECTS) {
|
|
139
|
+
let out = coalesceRects(
|
|
140
|
+
(rects ?? []).concat([
|
|
141
|
+
{ x: add.x, y: add.y, width: add.width, height: add.height },
|
|
142
|
+
]),
|
|
143
|
+
);
|
|
144
|
+
while (out.length > cap) {
|
|
145
|
+
let bestI = 0;
|
|
146
|
+
let bestJ = 1;
|
|
147
|
+
let bestWaste = Infinity;
|
|
148
|
+
for (let i = 0; i < out.length; i++) {
|
|
149
|
+
for (let j = i + 1; j < out.length; j++) {
|
|
150
|
+
const waste =
|
|
151
|
+
rectArea(unionRect(out[i], out[j])) -
|
|
152
|
+
rectArea(out[i]) -
|
|
153
|
+
rectArea(out[j]);
|
|
154
|
+
if (waste < bestWaste) {
|
|
155
|
+
bestWaste = waste;
|
|
156
|
+
bestI = i;
|
|
157
|
+
bestJ = j;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const merged = unionRect(out[bestI], out[bestJ]);
|
|
162
|
+
out = coalesceRects(
|
|
163
|
+
out.filter((_, k) => k !== bestI && k !== bestJ).concat([merged]),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The rects a frame actually paints, given the ones that were claimed.
|
|
171
|
+
*
|
|
172
|
+
* Each one costs a pass over the tree and a clip, so a list whose pieces nearly
|
|
173
|
+
* fill the box around them is better served by one pass over that box. Both
|
|
174
|
+
* answers cover every claimed pixel, so this is a cost decision and never a
|
|
175
|
+
* correctness one.
|
|
176
|
+
*/
|
|
177
|
+
export function damageToPaint(rects) {
|
|
178
|
+
if (rects.length < 2) return rects;
|
|
179
|
+
const box = rectsBounds(rects);
|
|
180
|
+
let sum = 0;
|
|
181
|
+
for (const r of rects) sum += rectArea(r);
|
|
182
|
+
return sum > rectArea(box) * SPLIT_SAVING ? [box] : rects;
|
|
183
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Undo and redo for <textinput> and <textarea>: the history an edit is
|
|
2
|
+
// recorded into, run by run, capped at UNDO_LIMIT entries.
|
|
3
|
+
|
|
4
|
+
/** Undo entries kept per input. Snapshots of a single field are small; the
|
|
5
|
+
* cap is what stops a long-lived form from growing without bound. */
|
|
6
|
+
const UNDO_LIMIT = 200;
|
|
7
|
+
|
|
8
|
+
/** Undo and redo, installed onto `TextInputNode.prototype` by textinput.js. */
|
|
9
|
+
export class TextInputHistory {
|
|
10
|
+
// --- undo/redo ------------------------------------------------------
|
|
11
|
+
//
|
|
12
|
+
// Full-value snapshots rather than a diff log: a single field is small,
|
|
13
|
+
// and a snapshot is the only representation that stays right when the
|
|
14
|
+
// value is controlled and the parent rewrites what we send it. Each
|
|
15
|
+
// entry also carries the caret from *before* the edit that produced it,
|
|
16
|
+
// so undoing puts the caret back where the typing happened rather than
|
|
17
|
+
// where the run ended.
|
|
18
|
+
|
|
19
|
+
/** True while there is an earlier state to go back to. */
|
|
20
|
+
get canUndo() {
|
|
21
|
+
return this._historyIndex > 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** True while an undone state is still ahead. */
|
|
25
|
+
get canRedo() {
|
|
26
|
+
return this._historyIndex < this._history.length - 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Step back one edit. Returns false when there is nothing to undo. */
|
|
30
|
+
undo() {
|
|
31
|
+
if (!this.canUndo) return false;
|
|
32
|
+
const undone = this._history[this._historyIndex];
|
|
33
|
+
const target = this._history[--this._historyIndex];
|
|
34
|
+
// the caret goes where the undone edit started, not where it ended
|
|
35
|
+
this._applyHistory(target.value, undone.beforeCaret, undone.beforeAnchor);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Step forward one undone edit. False when there is nothing to redo. */
|
|
40
|
+
redo() {
|
|
41
|
+
if (!this.canRedo) return false;
|
|
42
|
+
const target = this._history[++this._historyIndex];
|
|
43
|
+
this._applyHistory(target.value, target.caret, target.anchor);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** End the coalescing run: the next edit starts a fresh undo entry. */
|
|
48
|
+
_breakUndoRun() {
|
|
49
|
+
this._undoRun = null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
_applyHistory(value, caret, anchor) {
|
|
53
|
+
this._breakUndoRun();
|
|
54
|
+
const previous = this.value;
|
|
55
|
+
if (this.props.value == null) this._value = value;
|
|
56
|
+
this._historyValue = value;
|
|
57
|
+
const len = Array.from(value).length;
|
|
58
|
+
this._caret = Math.min(Math.max(0, caret), len);
|
|
59
|
+
this._anchor = Math.min(Math.max(0, anchor), len);
|
|
60
|
+
if (value !== previous) this._fireValueEvent('onChange', value);
|
|
61
|
+
this._repaint();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Fold the edit into the open run, or start a new entry. A run continues
|
|
66
|
+
* while the same kind of edit keeps happening at the caret it left off
|
|
67
|
+
* at, so a word of typing — or a run of backspaces — undoes as one step.
|
|
68
|
+
*/
|
|
69
|
+
_recordEdit(kind, entry) {
|
|
70
|
+
const top = this._history[this._historyIndex];
|
|
71
|
+
const continues =
|
|
72
|
+
kind != null &&
|
|
73
|
+
kind === this._undoRun &&
|
|
74
|
+
// a replaced selection is a distinct edit, however it was typed
|
|
75
|
+
entry.beforeCaret === entry.beforeAnchor &&
|
|
76
|
+
top?.caret === entry.beforeCaret;
|
|
77
|
+
if (continues) {
|
|
78
|
+
top.value = entry.value;
|
|
79
|
+
top.caret = entry.caret;
|
|
80
|
+
top.anchor = entry.anchor;
|
|
81
|
+
} else {
|
|
82
|
+
this._pushHistory(kind, entry);
|
|
83
|
+
}
|
|
84
|
+
this._historyValue = entry.value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
_pushHistory(kind, entry) {
|
|
88
|
+
// a fresh edit after an undo drops whatever was ahead
|
|
89
|
+
this._history.length = this._historyIndex + 1;
|
|
90
|
+
this._history.push(entry);
|
|
91
|
+
if (this._history.length > UNDO_LIMIT) this._history.shift();
|
|
92
|
+
this._historyIndex = this._history.length - 1;
|
|
93
|
+
this._undoRun = kind;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A controlled `value` that changed to something we did not commit was
|
|
98
|
+
* edited outside the control — a form reset, or an onChange that filters
|
|
99
|
+
* what it is given. It becomes its own history entry, so undo walks back
|
|
100
|
+
* through states that really existed. The neighbour checks catch the
|
|
101
|
+
* parent echoing an undo back at us, or refusing one: that moves through
|
|
102
|
+
* the history instead of appending to it, which is what keeps a filtering
|
|
103
|
+
* onChange from growing the stack on every keystroke.
|
|
104
|
+
*/
|
|
105
|
+
_noteExternalValue() {
|
|
106
|
+
const value = this.value;
|
|
107
|
+
if (value === this._historyValue) return;
|
|
108
|
+
this._breakUndoRun();
|
|
109
|
+
if (this._history[this._historyIndex + 1]?.value === value) {
|
|
110
|
+
this._historyIndex++;
|
|
111
|
+
} else if (this._history[this._historyIndex - 1]?.value === value) {
|
|
112
|
+
this._historyIndex--;
|
|
113
|
+
} else {
|
|
114
|
+
this._pushHistory(null, {
|
|
115
|
+
value,
|
|
116
|
+
caret: this._caret,
|
|
117
|
+
anchor: this._anchor,
|
|
118
|
+
beforeCaret: this._caret,
|
|
119
|
+
beforeAnchor: this._anchor,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
this._historyValue = value;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// The built-in edit menu's popup: a <popup> with a canvas in it, opened at
|
|
2
|
+
// the caret or the pointer. The menu's items, geometry and painting are in
|
|
3
|
+
// src/editmenu.js; this is the node half that shows them.
|
|
4
|
+
|
|
5
|
+
import { deviceAnchorArea, windowOrigin } from '../anchor.js';
|
|
6
|
+
import { armPasteState, canPaste } from '../pastestate.js';
|
|
7
|
+
import {
|
|
8
|
+
XK_RETURN,
|
|
9
|
+
XK_KP_ENTER,
|
|
10
|
+
XK_UP,
|
|
11
|
+
XK_DOWN,
|
|
12
|
+
XK_ESCAPE,
|
|
13
|
+
} from '../keysyms.js';
|
|
14
|
+
import {
|
|
15
|
+
editMenuColors,
|
|
16
|
+
editMenuGeometry,
|
|
17
|
+
editMenuIndexAt,
|
|
18
|
+
editMenuItems,
|
|
19
|
+
editMenuStep,
|
|
20
|
+
paintEditMenu,
|
|
21
|
+
} from '../editmenu.js';
|
|
22
|
+
import { CanvasNode } from './canvas.js';
|
|
23
|
+
import { PopupNode } from './window/popup.js';
|
|
24
|
+
|
|
25
|
+
// --- the standard edit menu ------------------------------------------------
|
|
26
|
+
//
|
|
27
|
+
// Right-click gets Undo/Cut/Copy/Paste with no wiring, the way a browser
|
|
28
|
+
// gives `<input>` one. The rows cannot be `Menu` components — those are
|
|
29
|
+
// React over the nodes, and a node cannot mount one — so the menu is a
|
|
30
|
+
// `<popup>` built here with a `<canvas>` child that paints the rows
|
|
31
|
+
// (src/editmenu.js) and handles its own pointer and key events. That reuses
|
|
32
|
+
// the popup's pointer grab, dismissal and focus rather than reinventing
|
|
33
|
+
// them.
|
|
34
|
+
//
|
|
35
|
+
// `<textinput>` is a *caller* of this, not its owner (issue #256): the
|
|
36
|
+
// enablement rules, the PRIMARY/CLIPBOARD subtleties and the menu's keyboard
|
|
37
|
+
// handling are the parts a second editable element would otherwise have to
|
|
38
|
+
// re-debug, so they live here, once, behind a verb interface anything can
|
|
39
|
+
// speak.
|
|
40
|
+
|
|
41
|
+
/** Where the popup goes: at `at`, which is in the owner window's coordinates
|
|
42
|
+
* the way a synthetic event's `x`/`y` are, pulled back inside the monitor
|
|
43
|
+
* when the menu would hang off an edge of it. */
|
|
44
|
+
function editMenuOrigin(node, at, size) {
|
|
45
|
+
const origin = windowOrigin(node);
|
|
46
|
+
let x = origin.x + (Number.isFinite(at?.x) ? at.x : (node.abs?.x ?? 0));
|
|
47
|
+
let y = origin.y + (Number.isFinite(at?.y) ? at.y : (node.abs?.y ?? 0));
|
|
48
|
+
// the monitor's work area rather than the whole virtual desktop, so a menu
|
|
49
|
+
// near a seam flips back onto the screen it was opened on — the same
|
|
50
|
+
// answer `<ContextMenu>` clamps a pointer-anchored menu into. Clamped, not
|
|
51
|
+
// flipped: there is no anchor rect to flip around.
|
|
52
|
+
const area = deviceAnchorArea(node);
|
|
53
|
+
if (area) {
|
|
54
|
+
x = Math.max(area.x, Math.min(x, area.x + area.width - size.width));
|
|
55
|
+
y = Math.max(area.y, Math.min(y, area.y + area.height - size.height));
|
|
56
|
+
}
|
|
57
|
+
return { x, y };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Open the standard edit menu on `node`, for a target that speaks a small
|
|
62
|
+
* verb interface.
|
|
63
|
+
*
|
|
64
|
+
* This is what `<textinput>`'s own right-click menu is, and the reason it is
|
|
65
|
+
* exported is that everything about it except the verbs is worth having
|
|
66
|
+
* once: which rows are enabled, Paste watching selection ownership rather
|
|
67
|
+
* than asking the server on the way to opening a menu, the arrow keys and
|
|
68
|
+
* Escape, the pointer grab that dismisses it, and handing the keyboard back
|
|
69
|
+
* where it came from afterwards.
|
|
70
|
+
*
|
|
71
|
+
* ```js
|
|
72
|
+
* openEditMenu(node, { x: ev.x, y: ev.y }, {
|
|
73
|
+
* canUndo: this.canUndo, undo: () => this.undo(),
|
|
74
|
+
* canRedo: this.canRedo, redo: () => this.redo(),
|
|
75
|
+
* hasSelection: this.hasSelection(),
|
|
76
|
+
* cut: () => this.cut(),
|
|
77
|
+
* copy: () => this.copy(),
|
|
78
|
+
* paste: () => this.paste(),
|
|
79
|
+
* selectAll: () => this.selectAll(),
|
|
80
|
+
* });
|
|
81
|
+
* ```
|
|
82
|
+
*
|
|
83
|
+
* **A verb you leave out is a row that is not there**, rather than a greyed
|
|
84
|
+
* one — see `editMenuItems`. A read-only surface passes `hasSelection`,
|
|
85
|
+
* `copy` and `selectAll` and gets a two-row menu; a password field passes no
|
|
86
|
+
* `copy` and no `cut` and gets a menu that offers neither. Leave out every
|
|
87
|
+
* verb and nothing opens at all.
|
|
88
|
+
*
|
|
89
|
+
* @param {Node} node the element the menu belongs to. The popup hangs off it
|
|
90
|
+
* in the tree, so it goes away with the element and counts as inside it
|
|
91
|
+
* for `:focus-within`.
|
|
92
|
+
* @param {{x: number, y: number}} at where the pointer was, in the owner
|
|
93
|
+
* window's coordinates — `ev.x`/`ev.y` from the event that asked for the
|
|
94
|
+
* menu. A surface with no caret has nothing else to offer, and this is
|
|
95
|
+
* what it already has.
|
|
96
|
+
* @param {object} actions the verbs, and what each is worth right now:
|
|
97
|
+
* `hasSelection` (Cut and Copy follow it), `canUndo`, `canRedo`,
|
|
98
|
+
* `canSelectAll` (defaults to true), and the functions `undo`, `redo`,
|
|
99
|
+
* `cut`, `copy`, `paste`, `selectAll`.
|
|
100
|
+
*/
|
|
101
|
+
export function openEditMenu(node, at, actions = {}) {
|
|
102
|
+
closeEditMenu(node);
|
|
103
|
+
if (!node?.root || node.destroyed) return;
|
|
104
|
+
const app = node.app;
|
|
105
|
+
const clipboard = app?.clipboard ?? null;
|
|
106
|
+
// From here on the menu knows whether there is anything to paste. This
|
|
107
|
+
// first open still shows the row enabled — the answer arrives after it
|
|
108
|
+
// is drawn — which is the pre-tracking behaviour, and correct far more
|
|
109
|
+
// often than not.
|
|
110
|
+
if (typeof actions.paste === 'function') armPasteState(app, clipboard);
|
|
111
|
+
const items = editMenuItems(actions, {
|
|
112
|
+
// greyed only when the server has told us the selection is unowned
|
|
113
|
+
// (pastestate.js). Never a round trip on the way to opening a menu.
|
|
114
|
+
canPaste: Boolean(clipboard) && canPaste(app),
|
|
115
|
+
});
|
|
116
|
+
if (items.length === 0) return;
|
|
117
|
+
|
|
118
|
+
const style = node.resolvedTextStyle();
|
|
119
|
+
// `at` is `{x: ev.x, y: ev.y}` per the doc above — logical, like every
|
|
120
|
+
// coordinate a handler reads — and everything below is device: the
|
|
121
|
+
// geometry takes the scale so its chrome lands on the same grid as the
|
|
122
|
+
// device-sized text it measures.
|
|
123
|
+
const s = node.scale;
|
|
124
|
+
const geometry = editMenuGeometry(
|
|
125
|
+
items,
|
|
126
|
+
(text) => app?.fonts?.layout(text, style)?.width,
|
|
127
|
+
s,
|
|
128
|
+
);
|
|
129
|
+
const deviceAt = at && {
|
|
130
|
+
...at,
|
|
131
|
+
...(Number.isFinite(at.x) && { x: at.x * s }),
|
|
132
|
+
...(Number.isFinite(at.y) && { y: at.y * s }),
|
|
133
|
+
};
|
|
134
|
+
const { x, y } = editMenuOrigin(node, deviceAt, geometry);
|
|
135
|
+
const colors = editMenuColors(node.theme);
|
|
136
|
+
const state = { active: -1 };
|
|
137
|
+
const choose = (id) => {
|
|
138
|
+
closeEditMenu(node);
|
|
139
|
+
// the target's own entry point, so the row can never drift from what
|
|
140
|
+
// the equivalent shortcut does
|
|
141
|
+
if (id) actions[id]?.();
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const canvas = new CanvasNode(
|
|
145
|
+
{
|
|
146
|
+
focusable: true,
|
|
147
|
+
style: { flexGrow: 1 },
|
|
148
|
+
onDraw: (ctx) =>
|
|
149
|
+
paintEditMenu(ctx, {
|
|
150
|
+
geometry,
|
|
151
|
+
active: state.active,
|
|
152
|
+
colors,
|
|
153
|
+
radius: node.theme?.radius ?? 4,
|
|
154
|
+
layoutOf: (text, color) =>
|
|
155
|
+
app?.fonts?.layout([{ text, ...style, color }], style),
|
|
156
|
+
}),
|
|
157
|
+
onMouseMove: (mv) => {
|
|
158
|
+
const next = editMenuIndexAt(geometry, mv.nativeEvent?.y ?? mv.y * s);
|
|
159
|
+
if (next === state.active) return;
|
|
160
|
+
state.active = next;
|
|
161
|
+
popup.invalidate(false, null, 'style-state');
|
|
162
|
+
},
|
|
163
|
+
onMouseUp: (mv) => {
|
|
164
|
+
const i = editMenuIndexAt(geometry, mv.nativeEvent?.y ?? mv.y * s);
|
|
165
|
+
if (i !== -1) choose(geometry.rows[i].id);
|
|
166
|
+
else closeEditMenu(node);
|
|
167
|
+
},
|
|
168
|
+
onKeyDown: (k) => {
|
|
169
|
+
if (k.keysym === XK_ESCAPE) return closeEditMenu(node);
|
|
170
|
+
if (k.keysym === XK_UP || k.keysym === XK_DOWN) {
|
|
171
|
+
state.active = editMenuStep(
|
|
172
|
+
geometry,
|
|
173
|
+
state.active,
|
|
174
|
+
k.keysym === XK_DOWN ? 1 : -1,
|
|
175
|
+
);
|
|
176
|
+
popup.invalidate(false, null, 'style-state');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (k.keysym === XK_RETURN || k.keysym === XK_KP_ENTER) {
|
|
180
|
+
const row = geometry.rows[state.active];
|
|
181
|
+
if (row && !row.separator) choose(row.id);
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
app,
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const popup = new PopupNode(
|
|
189
|
+
app,
|
|
190
|
+
{
|
|
191
|
+
x,
|
|
192
|
+
y,
|
|
193
|
+
width: geometry.width,
|
|
194
|
+
height: geometry.height,
|
|
195
|
+
windowType: 'popup_menu',
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
// **The size goes in the props, not only in the attributes.** A
|
|
199
|
+
// `<window>`/`<popup>` size is `'auto'` when the props do not name one,
|
|
200
|
+
// and `realize()` then *measures* the content and overwrites whatever
|
|
201
|
+
// the attributes said (issue #248) — which for a canvas that only
|
|
202
|
+
// `flexGrow`s is nothing at all, so this popup opened 1x1 and the menu
|
|
203
|
+
// was invisible. Every other popup in the tree comes from React, where
|
|
204
|
+
// one props object is both, so nothing else could reach it.
|
|
205
|
+
// Props are the logical contract (`_measure` multiplies them back);
|
|
206
|
+
// the attributes above carry the same size already in device pixels.
|
|
207
|
+
width: geometry.width / s,
|
|
208
|
+
height: geometry.height / s,
|
|
209
|
+
grab: true,
|
|
210
|
+
// a press outside the menu closes it and goes no further, which is
|
|
211
|
+
// what the grab is for
|
|
212
|
+
onDismiss: () => closeEditMenu(node),
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
popup.insertBefore(canvas, null);
|
|
216
|
+
node.insertBefore(popup, null);
|
|
217
|
+
popup.realize(null);
|
|
218
|
+
// the rows as they were built, for a test to read: they are painted into a
|
|
219
|
+
// canvas, so there is no tree for `screen` to query them out of
|
|
220
|
+
popup._editMenuRows = geometry.rows;
|
|
221
|
+
node._editMenu = popup;
|
|
222
|
+
// read *before* the menu takes the keyboard, and handed back on close
|
|
223
|
+
node._editMenuRestore = node._focusManager()?.focused ?? null;
|
|
224
|
+
// the menu takes the keyboard so arrows and Escape reach it rather than
|
|
225
|
+
// the element behind it
|
|
226
|
+
popup.events?.focus?.(canvas);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Whether `node` has the standard edit menu open. An element that paints a
|
|
230
|
+
* selection asks: the popup holds the keyboard, so the element is not
|
|
231
|
+
* focused, and the text the menu is about to act on has to stay visibly
|
|
232
|
+
* selected. */
|
|
233
|
+
export function editMenuOpen(node) {
|
|
234
|
+
return Boolean(node?._editMenu);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Close it, if it is open. The menu closes itself on a choice, a press
|
|
238
|
+
* outside and Escape; this is for an element that has decided the menu no
|
|
239
|
+
* longer applies — its content changed underneath it, or it scrolled. */
|
|
240
|
+
export function closeEditMenu(node) {
|
|
241
|
+
const popup = node?._editMenu;
|
|
242
|
+
if (!popup) return;
|
|
243
|
+
node._editMenu = null;
|
|
244
|
+
const restore = node._editMenuRestore;
|
|
245
|
+
node._editMenuRestore = null;
|
|
246
|
+
// the popup is a child, so a node destroyed while its menu was up took the
|
|
247
|
+
// menu with it: there is nothing left to remove and nowhere to hand the
|
|
248
|
+
// keyboard back to
|
|
249
|
+
if (node.destroyed) return;
|
|
250
|
+
node.removeChild(popup);
|
|
251
|
+
const events = node._focusManager();
|
|
252
|
+
if (!events) return;
|
|
253
|
+
// focus goes back where the menu took it from, so typing carries on where
|
|
254
|
+
// it left off — as a pointer focus, since a right-click is what opened the
|
|
255
|
+
// menu and a ring appearing on the way back would be news to nobody. A
|
|
256
|
+
// surface that was not focusable in the first place, or that stopped being
|
|
257
|
+
// on screen while the menu was up, gets nothing back rather than the
|
|
258
|
+
// destroyed menu canvas keeping the keyboard.
|
|
259
|
+
events.focus(events._canRestoreTo(restore) ? restore : null, 'pointer');
|
|
260
|
+
}
|