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.
Files changed (100) hide show
  1. package/README.md +278 -129
  2. package/package.json +12 -4
  3. package/src/Reconciler.js +19 -31
  4. package/src/a11y.js +2 -2
  5. package/src/anchor.js +7 -5
  6. package/src/appcontext.js +59 -30
  7. package/src/bootstrap.js +14 -0
  8. package/src/clientmessage.js +1 -1
  9. package/src/cocoa/app.js +303 -49
  10. package/src/cocoa/bezels.js +175 -30
  11. package/src/cocoa/dnd.js +27 -13
  12. package/src/cocoa/fonts.js +3 -3
  13. package/src/cocoa/glarea.js +24 -5
  14. package/src/cocoa/main.d.ts +8 -0
  15. package/src/cocoa/main.js +43 -0
  16. package/src/cocoa/overlay.js +159 -0
  17. package/src/cocoa/panehost.js +15 -5
  18. package/src/cocoa/presenter.js +13 -9
  19. package/src/cocoa/promotion.js +17 -7
  20. package/src/cocoa/relaunch.js +207 -0
  21. package/src/cocoa/threaded.js +246 -0
  22. package/src/cocoa/window.js +256 -42
  23. package/src/components/Select.js +2 -2
  24. package/src/components/anchor.js +3 -3
  25. package/src/components/native.js +12 -7
  26. package/src/components/theme.js +2 -2
  27. package/src/debug.js +1 -1
  28. package/src/decorations.js +1 -1
  29. package/src/editmenu.js +2 -2
  30. package/src/embedding.js +31 -0
  31. package/src/errors.js +46 -0
  32. package/src/events.js +78 -18
  33. package/src/foreignnodes.js +59 -5
  34. package/src/frames.js +2 -2
  35. package/src/glnodes.js +172 -41
  36. package/src/gloverlay.js +383 -0
  37. package/src/grid.js +1653 -0
  38. package/src/host.d.ts +230 -1
  39. package/src/host.js +11 -3
  40. package/src/imagesource.js +1 -1
  41. package/src/index.d.ts +34 -4
  42. package/src/index.js +9 -1
  43. package/src/layouts.js +721 -0
  44. package/src/node.d.ts +16 -3
  45. package/src/node.js +19 -21
  46. package/src/nodes/animation.js +644 -0
  47. package/src/nodes/box.js +21 -0
  48. package/src/nodes/boxpaint.js +473 -0
  49. package/src/nodes/canvas.js +269 -0
  50. package/src/nodes/cascade.js +600 -0
  51. package/src/nodes/damage.js +183 -0
  52. package/src/nodes/edithistory.js +124 -0
  53. package/src/nodes/editmenupopup.js +260 -0
  54. package/src/nodes/hittest.js +185 -0
  55. package/src/nodes/image.js +266 -0
  56. package/src/nodes/install.js +75 -0
  57. package/src/nodes/invalidate.js +465 -0
  58. package/src/nodes/kinds.js +31 -0
  59. package/src/nodes/layout.js +439 -0
  60. package/src/nodes/layouthost.js +949 -0
  61. package/src/nodes/node.js +868 -0
  62. package/src/nodes/paint.js +466 -0
  63. package/src/nodes/position.js +366 -0
  64. package/src/nodes/preedit.js +127 -0
  65. package/src/nodes/queries.js +330 -0
  66. package/src/nodes/rects.js +102 -0
  67. package/src/nodes/scrollable.js +891 -0
  68. package/src/nodes/scrollbars.js +138 -0
  69. package/src/nodes/scrollblit.js +1034 -0
  70. package/src/nodes/selectable.js +142 -0
  71. package/src/nodes/styling.js +225 -0
  72. package/src/nodes/text.js +649 -0
  73. package/src/nodes/textarea.js +391 -0
  74. package/src/nodes/textinput.js +1146 -0
  75. package/src/nodes/util.js +17 -0
  76. package/src/nodes/window/anchoring.js +161 -0
  77. package/src/nodes/window/capabilities.js +190 -0
  78. package/src/nodes/window/debugpaint.js +83 -0
  79. package/src/nodes/window/droptarget.js +145 -0
  80. package/src/nodes/window/floors.js +577 -0
  81. package/src/nodes/window/flush.js +369 -0
  82. package/src/nodes/window/hints.js +482 -0
  83. package/src/nodes/window/listeners.js +222 -0
  84. package/src/nodes/window/popup.js +71 -0
  85. package/src/nodes/window/size.js +591 -0
  86. package/src/nodes/window/window.js +954 -0
  87. package/src/palette.js +1 -1
  88. package/src/registry.js +7 -3
  89. package/src/styles.js +137 -15
  90. package/src/svgnodes.js +2 -1
  91. package/src/testing/harness.js +2 -2
  92. package/src/textselection.js +5 -3
  93. package/src/trace-registry.js +1 -1
  94. package/src/types/components.d.ts +38 -6
  95. package/src/types/elements.d.ts +26 -14
  96. package/src/types/nodes.d.ts +17 -2
  97. package/src/types/style.d.ts +94 -3
  98. package/src/windowstate.js +1 -1
  99. package/src/yoga.js +1 -1
  100. 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
+ }