react-x11 2.39.0 → 2.41.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.
@@ -4,7 +4,7 @@
4
4
 
5
5
  import React, { useEffect, useRef, useState } from 'react';
6
6
  import { createStyles } from '../styles.js';
7
- import { useClipboard } from '../appcontext.js';
7
+ import { useAppOrNull, useClipboard } from '../appcontext.js';
8
8
  import { windowIdOf } from '../windowid.js';
9
9
  import { useTheme } from './theme.js';
10
10
  import { Icon } from './Icon.js';
@@ -19,6 +19,7 @@ import {
19
19
  XK_KP_ENTER,
20
20
  XK_RETURN,
21
21
  ctrlChordLetter,
22
+ primaryModifierHeld,
22
23
  } from '../keysyms.js';
23
24
 
24
25
  const h = React.createElement;
@@ -169,6 +170,7 @@ export function PasswordInput({
169
170
  const inputRef = useRef(null);
170
171
  const hideTimer = useRef(null);
171
172
  const clipboard = useClipboard();
173
+ const app = useAppOrNull();
172
174
  const [ownValue, setOwnValue] = useState(defaultValue ?? '');
173
175
  const [ownRevealed, setOwnRevealed] = useState(false);
174
176
  const [focused, setFocused] = useState(false);
@@ -282,13 +284,16 @@ export function PasswordInput({
282
284
  if (ev.shiftKey) paste('CLIPBOARD');
283
285
  return;
284
286
  }
285
- if (ev.ctrlKey) {
287
+ if (ev.ctrlKey || ev.metaKey) {
286
288
  const letter = ctrlChordLetter(ev);
287
289
  // Ctrl+U clears the line, as readline and every pinentry on the desktop
288
- // do; Ctrl+V pastes. Nothing for C, X or A — there is no selection to
289
- // copy, and the secret does not leave by the clipboard.
290
- if (letter === CTRL_U) commit('');
291
- else if (letter === CTRL_V) paste('CLIPBOARD');
290
+ // do; Ctrl+V pastes — ⌘V on the Cocoa backend, where ⌃U still clears,
291
+ // as it does in a Mac terminal. Nothing for C, X or A — there is no
292
+ // selection to copy, and the secret does not leave by the clipboard.
293
+ // Any other chord is a command and never a character of the secret.
294
+ if (letter === CTRL_U && ev.ctrlKey) commit('');
295
+ else if (letter === CTRL_V && primaryModifierHeld(ev, app))
296
+ paste('CLIPBOARD');
292
297
  return;
293
298
  }
294
299
  // A printable character and only that: a control code is not text, and
package/src/editmenu.js CHANGED
@@ -86,6 +86,9 @@ export function editMenuColors(theme) {
86
86
  export function editMenuItems(actions = {}, { canPaste = true } = {}) {
87
87
  const has = (verb) => typeof actions[verb] === 'function';
88
88
  const selected = Boolean(actions.hasSelection);
89
+ // `Control` is the primary modifier here, as it is in a menu bar's chord:
90
+ // ⌘ on the Cocoa backend, which is what the editors answer there and what
91
+ // `editMenuGeometry` prints when it is told so
89
92
  const groups = [
90
93
  [
91
94
  has('undo') && {
@@ -147,8 +150,12 @@ export function editMenuItems(actions = {}, { canPaste = true } = {}) {
147
150
  * @param {Array<{id?, label?, shortcut?, type?, enabled?}>} items
148
151
  * @param {(text: string) => number|null} [measure] text width, or null when
149
152
  * nothing can be measured yet
153
+ * @param {number} [scale]
154
+ * @param {{primary?: 'Control'|'Super'}} [options] the app's primary
155
+ * modifier (keysyms.js): `'Super'` prints the shortcuts the Mac way —
156
+ * `⌘Z`, `⇧⌘Z` — since ⌘ is what the text controls answer there
150
157
  */
151
- export function editMenuGeometry(items, measure, scale = 1) {
158
+ export function editMenuGeometry(items, measure, scale = 1, { primary } = {}) {
152
159
  const widthOf = (text) => {
153
160
  if (!text) return 0;
154
161
  const w = measure?.(text);
@@ -161,7 +168,7 @@ export function editMenuGeometry(items, measure, scale = 1) {
161
168
  let widest = 0;
162
169
  for (const item of items) {
163
170
  const separator = isSeparator(item);
164
- const shortcut = formatShortcut(item.shortcut);
171
+ const shortcut = formatShortcut(item.shortcut, { primary });
165
172
  const height = (separator ? SEPARATOR_HEIGHT : ROW_HEIGHT) * scale;
166
173
  rows.push({ ...item, separator, shortcut, y, height });
167
174
  y += height;
package/src/events.js CHANGED
@@ -1672,6 +1672,18 @@ export class EventManager {
1672
1672
  // its ordinary turn.
1673
1673
  if (step.consumed) return;
1674
1674
  }
1675
+ // A key that typed more than one character has no code point to type
1676
+ // them with: a backend whose keyboard layout composes on its own says
1677
+ // so with `text` — the accent and a letter it does not combine with,
1678
+ // `´s` (src/cocoa/keymap.js). Committed the way an input method's text
1679
+ // is, a composition that opens and closes on this key, which is where
1680
+ // an element that types inserts it — and like a composed key, it is
1681
+ // the key's whole answer.
1682
+ if (native.text && target.composes !== false) {
1683
+ this._composition('Start', target, '', native);
1684
+ this._composition('End', target, String(native.text), native);
1685
+ return;
1686
+ }
1675
1687
  // The element's own behaviour first, focus traversal after it — Tab is
1676
1688
  // an ordinary defaultable key rather than one the focus manager eats on
1677
1689
  // the way past. Cycling first meant an editor could only keep Tab as an
@@ -1705,6 +1717,11 @@ export class EventManager {
1705
1717
  *
1706
1718
  * `entry.handle(ev)` answers whether it took the key.
1707
1719
  *
1720
+ * `entry.reserves(ev)`, optional, answers whether something *outside* the
1721
+ * window is going to answer this key whatever we do — the macOS menu
1722
+ * bar's key equivalents (`MenuBar`, delegated). Such a key is spent
1723
+ * before any binding is offered it (`_runAccelerators`).
1724
+ *
1708
1725
  * Registrations live on the **top-level** manager, which is the one thing
1709
1726
  * a `<popup>`, a nested `<window>` and the window itself all share — the
1710
1727
  * same grouping `_keyManager` resolves keys through. So a chord reaches
@@ -1740,11 +1757,30 @@ export class EventManager {
1740
1757
  * A menu that is *open* suppresses them by another route entirely: it
1741
1758
  * `preventDefault()`s the keys it is being driven with, one layer above
1742
1759
  * this (`components/Menu.js`).
1760
+ *
1761
+ * And a key one of them *reserves* reaches none of them. The Cocoa bridge
1762
+ * shows us every key before `[NSApp sendEvent:]`, where the menu bar's
1763
+ * key equivalent then runs its item regardless — so a binding here that
1764
+ * answered ⌘S too would be a second command on one key, whichever of the
1765
+ * two mounted last. Neither gate applies to that: the menu bar is not in
1766
+ * the window, and a modal `<popup>` does not take keys from it.
1743
1767
  */
1744
1768
  _runAccelerators(ev) {
1745
1769
  const owner = this.topLevelManager;
1746
1770
  const entries = owner._accelerators;
1747
1771
  if (!entries?.size) return;
1772
+ for (const entry of entries) {
1773
+ let reserved = false;
1774
+ try {
1775
+ reserved = Boolean(entry.reserves?.(ev));
1776
+ } catch (error) {
1777
+ reportHandlerError(entry.anchor(), 'an accelerator', error);
1778
+ }
1779
+ if (reserved) {
1780
+ ev.preventDefault();
1781
+ return;
1782
+ }
1783
+ }
1748
1784
  const scopeRoot = owner._scopeRoot();
1749
1785
  const trapped = scopeRoot !== owner.node;
1750
1786
  for (const entry of [...entries].reverse()) {
@@ -192,6 +192,11 @@ function shallowEqual(a, b) {
192
192
 
193
193
  const EMPTY_PROPS = {};
194
194
 
195
+ /** How long a rect sent to a pane that acknowledges sizes waits for its
196
+ * `pane-sized` before the newest is sent anyway: a frame of a slow page,
197
+ * several times over, so a lost answer costs a pause and not the size. */
198
+ export const PANE_SIZED_WAIT_MS = 500;
199
+
195
200
  /**
196
201
  * A module of this application, mounted in its own process, its window
197
202
  * embedded here.
@@ -552,7 +557,54 @@ function PaneHostView({
552
557
  // moves when the box does and when the window does, so it is sent on
553
558
  // layout, on a scroll that shifts the box, and on every anchor change
554
559
  // of the window, which a move of the window is.
560
+ //
561
+ // **The newest rect wins.** A drag sends the host a size a frame, and a
562
+ // pane whose page takes longer than that to lay out was sent them
563
+ // faster than it could take them: a pipe hands a pane process one
564
+ // message a turn of its loop, with a frame in between, so the pane laid
565
+ // out and painted every size the drag passed through, one a frame, and
566
+ // went on resizing seconds after the edge stopped. A pane that says it
567
+ // has painted a size (`pane-sized`) is sent one rect at a time: what
568
+ // the host comes to while one is out replaces what was waiting, and goes
569
+ // when the pane says it has painted the last. A pane that never says so
570
+ // is sent every rect, as before, and one that goes quiet is sent the
571
+ // newest anyway after `PANE_SIZED_WAIT_MS`.
555
572
  let sent = null;
573
+ let pending = null;
574
+ let acks = false;
575
+ let inFlight = false;
576
+ let waiting = null;
577
+ const transmit = (rect) => {
578
+ sent = rect;
579
+ s.trySend?.({
580
+ type: 'pane-rect',
581
+ width: rect.width,
582
+ height: rect.height,
583
+ scale: rect.scale,
584
+ ...(rect.screen && { screen: rect.screen }),
585
+ });
586
+ if (!acks) return;
587
+ inFlight = true;
588
+ if (waiting) clearTimeout(waiting);
589
+ waiting = setTimeout(sized, PANE_SIZED_WAIT_MS);
590
+ };
591
+ const sameRect = (a, b) =>
592
+ !!a &&
593
+ !!b &&
594
+ a.width === b.width &&
595
+ a.height === b.height &&
596
+ a.scale === b.scale &&
597
+ a.screen?.x === b.screen?.x &&
598
+ a.screen?.y === b.screen?.y;
599
+ const sized = () => {
600
+ if (waiting) clearTimeout(waiting);
601
+ waiting = null;
602
+ inFlight = false;
603
+ const next = pending;
604
+ pending = null;
605
+ // a drag that came back to where it was sent from waits for nothing
606
+ if (next && !sameRect(next, sent)) transmit(next);
607
+ };
556
608
  const syncSize = () => {
557
609
  host.setRect(node.abs);
558
610
  const sc = node.scale ?? 1;
@@ -565,23 +617,10 @@ function PaneHostView({
565
617
  y: Math.round(origin.y + node.abs.y),
566
618
  }
567
619
  : null;
568
- if (
569
- sent &&
570
- sent.width === width &&
571
- sent.height === height &&
572
- sent.screen?.x === screen?.x &&
573
- sent.screen?.y === screen?.y
574
- ) {
575
- return;
576
- }
577
- sent = { width, height, screen };
578
- s.trySend?.({
579
- type: 'pane-rect',
580
- width,
581
- height,
582
- scale: sc,
583
- ...(screen && { screen }),
584
- });
620
+ const rect = { width, height, scale: sc, screen };
621
+ if (sameRect(rect, pending ?? sent)) return;
622
+ if (inFlight) pending = rect;
623
+ else transmit(rect);
585
624
  };
586
625
  const origAbsolutize = node.absolutize.bind(node);
587
626
  node.absolutize = (ox, oy) => {
@@ -624,7 +663,12 @@ function PaneHostView({
624
663
  host.windowCursor?.(msg);
625
664
  } else if (msg?.type === 'pane-present') {
626
665
  host.setRect(node.abs);
627
- host.present(msg.id);
666
+ host.present(msg.id, msg);
667
+ } else if (msg?.type === 'pane-sized') {
668
+ // the pane has painted the rect it was last sent: the newest one
669
+ // waiting goes now, and from here on one is out at a time
670
+ acks = true;
671
+ sized();
628
672
  } else if (msg?.type === 'pane-cursor') {
629
673
  // The pane names its cursor as an element that draws names one for
630
674
  // a point: this box is the node the host's pointer is over, so the
@@ -637,6 +681,8 @@ function PaneHostView({
637
681
  });
638
682
  return () => {
639
683
  off();
684
+ if (waiting) clearTimeout(waiting);
685
+ waiting = null;
640
686
  offAnchor?.();
641
687
  offFocus?.();
642
688
  node.defaultCursor = undefined;
package/src/globalmenu.js CHANGED
@@ -54,7 +54,7 @@
54
54
  // third-party menus consume dbusmenu. A second exporter is roughly double the
55
55
  // work for no consumer that the first one does not already reach.
56
56
 
57
- import { useEffect, useRef, useState } from 'react';
57
+ import { useCallback, useEffect, useRef, useState } from 'react';
58
58
 
59
59
  import { loadTransport, sessionBus } from './bus.js';
60
60
  import { desktopIntegrationEnabled } from './desktopintegration.js';
@@ -539,7 +539,20 @@ export function _resetGlobalMenuState() {
539
539
  * it. It is exported for a component that draws its own bar and wants the
540
540
  * same behaviour.
541
541
  */
542
- export function useGlobalMenu(
542
+ export function useGlobalMenu(menus, options) {
543
+ return useGlobalMenuExport(menus, options).delegated;
544
+ }
545
+
546
+ /**
547
+ * `useGlobalMenu`, with the one more thing `MenuBar` needs to know about the
548
+ * delegate: which shortcuts it answers by itself. A panel answers none — it
549
+ * draws the rows, and the key is pressed in our window — but the macOS menu
550
+ * bar answers the ⌘ chords it holds as key equivalents, and a bar that
551
+ * matched them as well would run the item twice
552
+ * (`CocoaGlobalMenuExport.answersShortcut`). Read at key time, so it is
553
+ * whatever delegate is live then. Not public.
554
+ */
555
+ export function useGlobalMenuExport(
543
556
  menus,
544
557
  { onSelect, onAboutToShow, enabled = true } = {},
545
558
  ) {
@@ -586,5 +599,10 @@ export function useGlobalMenu(
586
599
  exportRef.current?.update(menus ?? []);
587
600
  }, [menus]);
588
601
 
589
- return enabled && exported;
602
+ const answersShortcut = useCallback(
603
+ (shortcut) => Boolean(exportRef.current?.answersShortcut?.(shortcut)),
604
+ [],
605
+ );
606
+
607
+ return { delegated: enabled && exported, answersShortcut };
590
608
  }
package/src/index.d.ts CHANGED
@@ -290,7 +290,10 @@ export interface RootOptions {
290
290
  * `resizeWait` is how long, in ms, AppKit may hold a live-resize tick for
291
291
  * the app's frame at the new size under `react-x11/cocoa-main`, where
292
292
  * the frame is painted on another thread (50 by default; 0 lets the edge
293
- * move without waiting).
293
+ * move without waiting). A `<Frame>` pane's frames of a new size are held
294
+ * to it as well: within it, the pane's last frame stays at its size while
295
+ * the next comes, and past it, it is stretched to the pane's new size, as
296
+ * every one is at 0.
294
297
  * `screenPoll` is how often the screen layout is re-read while a
295
298
  * {@link useScreens} subscriber is mounted, in ms — 500 by default, 0 for
296
299
  * never. macOS has no event for a display plugged in, unplugged or
package/src/keysyms.d.ts CHANGED
@@ -50,6 +50,36 @@ export function ctrlChordLetter(ev: {
50
50
  codepoint?: number | null;
51
51
  }): number | null;
52
52
 
53
+ /** The modifier token a shortcut's primary means, in a menu `shortcut`'s
54
+ * vocabulary. */
55
+ export type PrimaryModifier = 'Control' | 'Super';
56
+
57
+ /**
58
+ * The modifier this app's shortcuts are pressed with: `'Super'` — ⌘ — where
59
+ * the backend says so (the Cocoa app does, as `app.primaryModifier`), and
60
+ * `'Control'` everywhere else. A backend fact, not a platform one: an X11
61
+ * app under XQuartz keeps Ctrl, like every other X client on that display.
62
+ */
63
+ export function primaryModifier(
64
+ app?: { primaryModifier?: PrimaryModifier } | null,
65
+ ): PrimaryModifier;
66
+
67
+ /**
68
+ * Is the primary modifier down for this event — ⌘ on the Cocoa backend, Ctrl
69
+ * everywhere else? What the built-in editors test before their
70
+ * Undo/Cut/Copy/Paste/Select All chords, so the keys the edit menu prints
71
+ * are the keys that do it.
72
+ *
73
+ * ```js
74
+ * if (primaryModifierHeld(ev, node.app) && ctrlChordLetter(ev) === keysymOf('d'))
75
+ * duplicateLine();
76
+ * ```
77
+ */
78
+ export function primaryModifierHeld(
79
+ ev: { ctrlKey?: boolean; metaKey?: boolean } | null | undefined,
80
+ app?: { primaryModifier?: PrimaryModifier } | null,
81
+ ): boolean;
82
+
53
83
  /**
54
84
  * The keysym an X11 key *name* stands for — `'Return'`, `'plus'`, `'F5'`,
55
85
  * `'s'` — or `undefined` for a name nothing here knows.
package/src/keysyms.js CHANGED
@@ -247,6 +247,35 @@ export function ctrlChordLetter(ev) {
247
247
  return code >= 0x41 && code <= 0x5a ? code + 0x20 : code;
248
248
  }
249
249
 
250
+ /**
251
+ * The modifier this app's shortcuts are pressed with: `'Super'` — ⌘ — where
252
+ * the backend says so (the Cocoa app does, as `app.primaryModifier`), and
253
+ * `'Control'` everywhere else.
254
+ *
255
+ * A backend fact, not a platform one: an X11 app under XQuartz keeps Ctrl,
256
+ * which is what every other X client on that display answers to.
257
+ */
258
+ export function primaryModifier(app) {
259
+ return app?.primaryModifier === 'Super' ? 'Super' : 'Control';
260
+ }
261
+
262
+ /**
263
+ * Is the primary modifier down for this event — ⌘ on the Cocoa backend,
264
+ * Ctrl everywhere else? The test the built-in editors put in front of their
265
+ * Undo/Cut/Copy/Paste/Select All chords, so the keys the edit menu prints
266
+ * beside each row are the keys that do it.
267
+ *
268
+ * ```js
269
+ * if (primaryModifierHeld(ev, node.app) && ctrlChordLetter(ev) === keysymOf('d'))
270
+ * duplicateLine();
271
+ * ```
272
+ */
273
+ export function primaryModifierHeld(ev, app) {
274
+ return primaryModifier(app) === 'Super'
275
+ ? Boolean(ev?.metaKey)
276
+ : Boolean(ev?.ctrlKey);
277
+ }
278
+
250
279
  /**
251
280
  * The X11 modifier mask bits, as they arrive on `ev.nativeEvent.buttons`
252
281
  * and as `fireEvent` takes them. Bit 3 (Mod1) is Alt and bit 6 (Mod4) is
package/src/menuitem.js CHANGED
@@ -112,21 +112,62 @@ const KEY_LABELS = {
112
112
  Next: 'PgDn',
113
113
  };
114
114
 
115
+ // The Mac prints a chord as glyphs, in one fixed order whatever order the
116
+ // chord was written in — ⌃⌥⇧⌘ — and with nothing between them: `⇧⌘Z`, never
117
+ // `Cmd+Shift+Z`. `Control` and `Super` are both ⌘ there, for the reason
118
+ // `primaryModifier` gives below: a cross-platform chord is written in the
119
+ // primary modifier, and the Mac's is Command.
120
+ const MAC_MODIFIER_ORDER = ['Alt', 'Shift', 'Command'];
121
+ const MAC_MODIFIER_GLYPHS = { Alt: '⌥', Shift: '⇧', Command: '⌘' };
122
+
123
+ // The keys a Mac menu draws as a glyph rather than a word. The rest are
124
+ // `KEY_LABELS`' answer, which is already the Mac's for punctuation.
125
+ const MAC_KEY_LABELS = {
126
+ Return: '↩',
127
+ KP_Enter: '⌤',
128
+ Tab: '⇥',
129
+ BackSpace: '⌫',
130
+ Delete: '⌦',
131
+ Escape: '⎋',
132
+ Left: '←',
133
+ Right: '→',
134
+ Up: '↑',
135
+ Down: '↓',
136
+ Prior: '⇞',
137
+ Next: '⇟',
138
+ Home: '↖',
139
+ End: '↘',
140
+ };
141
+
142
+ const keyLabel = (key, labels) =>
143
+ labels[key] ??
144
+ KEY_LABELS[key] ??
145
+ (key.length === 1 ? key.toUpperCase() : key);
146
+
115
147
  /**
116
148
  * One `aas` alternative — `['Control', 'S']` — as the string a menu row
117
- * shows: `Ctrl+S`.
149
+ * shows: `Ctrl+S`, or `⌘S` where the primary modifier is ⌘.
118
150
  *
119
151
  * A single-character key is upper-cased because that is how every toolkit
120
152
  * prints one, and because dbusmenu carries the *key*, not the character it
121
153
  * types: `['Control', 's']` and `['Control', 'S']` are the same binding and
122
154
  * must not render differently.
123
155
  */
124
- function formatChord(chord) {
156
+ function formatChord(chord, primary) {
125
157
  const { modifiers, key } = splitChord(chord);
126
- const parts = [...modifiers].map((token) => MODIFIER_LABELS[token]);
127
- if (key != null) {
128
- parts.push(KEY_LABELS[key] ?? (key.length === 1 ? key.toUpperCase() : key));
158
+ if (primary === 'Super') {
159
+ const held = new Set(
160
+ [...modifiers].map((token) =>
161
+ token === 'Control' || token === 'Super' ? 'Command' : token,
162
+ ),
163
+ );
164
+ const glyphs = MAC_MODIFIER_ORDER.filter((token) => held.has(token))
165
+ .map((token) => MAC_MODIFIER_GLYPHS[token])
166
+ .join('');
167
+ return glyphs + (key == null ? '' : keyLabel(key, MAC_KEY_LABELS));
129
168
  }
169
+ const parts = [...modifiers].map((token) => MODIFIER_LABELS[token]);
170
+ if (key != null) parts.push(keyLabel(key, {}));
130
171
  return parts.join('+');
131
172
  }
132
173
 
@@ -137,11 +178,14 @@ function formatChord(chord) {
137
178
  * modifier tokens ending in the key. Only the first alternative is drawn —
138
179
  * a row has one shortcut column, and a menu that printed `Ctrl+S / F2` in it
139
180
  * would be wider than the menu for the benefit of the rarer binding.
181
+ *
182
+ * `primary` is the app's `primaryModifier` (keysyms.js): `'Super'` draws the
183
+ * chord the way a Mac menu does, `⇧⌘Z` for `['Control', 'Shift', 'Z']`.
140
184
  */
141
- export function formatShortcut(shortcut) {
185
+ export function formatShortcut(shortcut, { primary = 'Control' } = {}) {
142
186
  const first = shortcut?.[0];
143
187
  if (!Array.isArray(first) || first.length === 0) return '';
144
- return formatChord(first);
188
+ return formatChord(first, primary);
145
189
  }
146
190
 
147
191
  // What UI Events calls the modifiers, which is what `aria-keyshortcuts` is
@@ -160,19 +204,21 @@ const ARIA_MODIFIERS = {
160
204
  * dbusmenu's `aas` already has — so unlike the drawn menu, **every**
161
205
  * alternative is announced rather than only the first: a screen reader is
162
206
  * reading them out, not fitting them in a column.
207
+ *
208
+ * `primary` is `formatShortcut`'s: under `'Super'` a `Control` is ⌘, which
209
+ * UI Events calls `Meta`, so what is announced is what is drawn and bound.
163
210
  */
164
- export function ariaKeyShortcuts(shortcut) {
211
+ export function ariaKeyShortcuts(shortcut, { primary = 'Control' } = {}) {
165
212
  if (!isValidShortcut(shortcut)) return undefined;
213
+ const name = (token) => {
214
+ if (!MODIFIER_TOKENS.has(token)) return KEY_LABELS[token] ?? token;
215
+ return primary === 'Super' && token === 'Control'
216
+ ? 'Meta'
217
+ : ARIA_MODIFIERS[token];
218
+ };
219
+ // a chord naming both `Control` and `Super` is one ⌘ there, said once
166
220
  return shortcut
167
- .map((chord) =>
168
- chord
169
- .map((token) =>
170
- MODIFIER_TOKENS.has(token)
171
- ? ARIA_MODIFIERS[token]
172
- : (KEY_LABELS[token] ?? token),
173
- )
174
- .join('+'),
175
- )
221
+ .map((chord) => [...new Set(chord.map(name))].join('+'))
176
222
  .join(' ');
177
223
  }
178
224
 
package/src/node.d.ts CHANGED
@@ -73,6 +73,29 @@ export interface Context2D {
73
73
  * `'globalCompositeOperation' in ctx`.
74
74
  */
75
75
  globalCompositeOperation?: string;
76
+ /**
77
+ * Canvas's: whether an image drawn scaled or turned is smoothed, or
78
+ * drawn as its nearest pixels. Native contexts only (macOS, Windows), and
79
+ * only where the bridge can set it: assign and read back to know.
80
+ */
81
+ imageSmoothingEnabled?: boolean;
82
+ /**
83
+ * Canvas's: how it is smoothed, `'low'` being bilinear. A native context
84
+ * starts at `'medium'`, which on macOS resamples the whole source for a
85
+ * draw through a matrix whatever the clip — so a surface drawn a tile at
86
+ * a time is far cheaper at `'low'`.
87
+ */
88
+ imageSmoothingQuality?: 'low' | 'medium' | 'high';
89
+ /**
90
+ * Canvas's: a CSS filter list applied to what is drawn after it. Native
91
+ * contexts only (macOS, Windows), and only the colour functions —
92
+ * `grayscale()`, `sepia()`, `saturate()`, `hue-rotate()`, `invert()`,
93
+ * `brightness()`, `contrast()` and `opacity()` — over a bridge that reads
94
+ * a surface's pixels back. Anything else does not stick: assign and read
95
+ * back to know. Absent on X11, whose server runs no colour matrix and
96
+ * answers a read a round trip later.
97
+ */
98
+ filter?: string;
76
99
  save(): void;
77
100
  restore(): void;
78
101
  translate(x: number, y: number): void;
@@ -5,6 +5,7 @@
5
5
  import { deviceAnchorArea, windowOrigin } from '../anchor.js';
6
6
  import { armPasteState, canPaste } from '../pastestate.js';
7
7
  import {
8
+ primaryModifier,
8
9
  XK_RETURN,
9
10
  XK_KP_ENTER,
10
11
  XK_UP,
@@ -125,6 +126,7 @@ export function openEditMenu(node, at, actions = {}) {
125
126
  items,
126
127
  (text) => app?.fonts?.layout(text, style)?.width,
127
128
  s,
129
+ { primary: primaryModifier(app) },
128
130
  );
129
131
  const deviceAt = at && {
130
132
  ...at,
@@ -221,6 +223,17 @@ export function openEditMenu(node, at, actions = {}) {
221
223
  node._editMenu = popup;
222
224
  // read *before* the menu takes the keyboard, and handed back on close
223
225
  node._editMenuRestore = node._focusManager()?.focused ?? null;
226
+ // The window losing focus closes it — the user went to another
227
+ // application, ⌘-Tab or a click on its window. Neither reaches us as a
228
+ // press: the grab redirects presses only between windows of ours on Cocoa,
229
+ // and a popup panel never takes the keyboard there, so this is the one
230
+ // event that says so. The node keeps its focus across the blur, so nothing
231
+ // in the tree hears it either; the same answer `Menu` and `Select` give
232
+ // (`useDismissOnWindowBlur`, components/anchor.js).
233
+ node._editMenuOffBlur =
234
+ node.root?.onWindowFocusChange?.((focused) => {
235
+ if (!focused) closeEditMenu(node);
236
+ }) ?? null;
224
237
  // the menu takes the keyboard so arrows and Escape reach it rather than
225
238
  // the element behind it
226
239
  popup.events?.focus?.(canvas);
@@ -241,6 +254,8 @@ export function closeEditMenu(node) {
241
254
  const popup = node?._editMenu;
242
255
  if (!popup) return;
243
256
  node._editMenu = null;
257
+ node._editMenuOffBlur?.();
258
+ node._editMenuOffBlur = null;
244
259
  const restore = node._editMenuRestore;
245
260
  node._editMenuRestore = null;
246
261
  // the popup is a child, so a node destroyed while its menu was up took the
@@ -255,6 +270,10 @@ export function closeEditMenu(node) {
255
270
  // menu and a ring appearing on the way back would be news to nobody. A
256
271
  // surface that was not focusable in the first place, or that stopped being
257
272
  // 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');
273
+ // destroyed menu canvas keeping the keyboard. And a restore, which takes
274
+ // the window no keyboard: a menu closed because the user went to another
275
+ // application must not drag them back to this one.
276
+ events.focus(events._canRestoreTo(restore) ? restore : null, 'pointer', {
277
+ restoring: true,
278
+ });
260
279
  }