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.
- package/package.json +3 -3
- package/src/acceleratorhooks.js +24 -8
- package/src/accelerators.js +26 -9
- package/src/backend/context2d.js +292 -35
- package/src/backend/filter.js +285 -0
- package/src/cocoa/app.js +25 -7
- package/src/cocoa/globalmenu.js +32 -1
- package/src/cocoa/keymap.js +69 -12
- package/src/cocoa/panehost.js +161 -13
- package/src/cocoa/paneplayer.js +101 -24
- package/src/cocoa/panewindow.js +325 -44
- package/src/cocoa/window.js +9 -1
- package/src/components/Menu.js +57 -11
- package/src/components/PasswordInput.js +11 -6
- package/src/editmenu.js +9 -2
- package/src/events.js +36 -0
- package/src/frame/index.js +64 -18
- package/src/globalmenu.js +21 -3
- package/src/index.d.ts +4 -1
- package/src/keysyms.d.ts +30 -0
- package/src/keysyms.js +29 -0
- package/src/menuitem.js +63 -17
- package/src/node.d.ts +23 -0
- package/src/nodes/editmenupopup.js +21 -2
- package/src/nodes/textarea.js +88 -16
- package/src/nodes/textinput.js +27 -6
- package/src/nodes/textkeys.js +322 -0
- package/src/textselection.js +7 -7
- package/src/types/components.d.ts +8 -2
- package/src/types/elements.d.ts +1 -1
- package/src/types/events.d.ts +27 -5
- package/src/types/nodes.d.ts +6 -0
- package/src/win32/fonts.js +521 -44
- package/src/win32/panewindow.js +25 -2
package/src/nodes/textarea.js
CHANGED
|
@@ -10,14 +10,85 @@ import {
|
|
|
10
10
|
XK_PAGE_UP,
|
|
11
11
|
XK_PAGE_DOWN,
|
|
12
12
|
XK_END,
|
|
13
|
+
primaryModifierHeld,
|
|
13
14
|
} from '../keysyms.js';
|
|
14
15
|
import {
|
|
15
16
|
scrollbarGeometry,
|
|
16
17
|
scrollbarHit,
|
|
17
18
|
paintScrollbarThumb,
|
|
18
19
|
} from './scrollbars.js';
|
|
20
|
+
import { installMethods } from './install.js';
|
|
19
21
|
import { rangeBands } from './text.js';
|
|
20
22
|
import { CARET_WIDTH, CARET_RESERVE, TextInputNode } from './textinput.js';
|
|
23
|
+
import { TextAreaKeys } from './textkeys.js';
|
|
24
|
+
|
|
25
|
+
/** A hard line break at the end of the value — the breaks ntk's TextLayout
|
|
26
|
+
* and CoreText both end a line at. */
|
|
27
|
+
const FINAL_BREAK = /[\n\r\u2028\u2029]$/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The layout of a value that ends in a line break, with the empty line the
|
|
31
|
+
* break opens: the line Return at the end of a textarea puts the caret on.
|
|
32
|
+
*
|
|
33
|
+
* Neither engine sets it. ntk's TextLayout and CoreText both end a paragraph
|
|
34
|
+
* at its last break, which is right for a `<text>` — CSS draws no line after
|
|
35
|
+
* a final newline either — and wrong for an editor, where that line is
|
|
36
|
+
* where the next letter goes. Without it the caret after Return was drawn at
|
|
37
|
+
* the end of the line above, Up from there skipped a line, Down never
|
|
38
|
+
* reached it, and the scroll that follows the caret stopped a line short.
|
|
39
|
+
*
|
|
40
|
+
* The line is the one the engine sets for an empty paragraph with a break
|
|
41
|
+
* after it — `lineBreak()`, a lone `\n` laid out in the value's style and
|
|
42
|
+
* options — moved down under the last line, so its height, baseline and
|
|
43
|
+
* place across the box (`textAlign`, the base direction), and the caret
|
|
44
|
+
* drawn on it, are that engine's: typing the next letter moves the caret
|
|
45
|
+
* along the line and nothing else. A lone break rather than no text, because
|
|
46
|
+
* ntk resolves an empty string's direction from paragraphs it does not have,
|
|
47
|
+
* and puts an RTL one against the left edge. An engine that already sets the
|
|
48
|
+
* line — its last line starts where the text ends — is left alone.
|
|
49
|
+
*/
|
|
50
|
+
export function withFinalLine(layout, text, lineBreak) {
|
|
51
|
+
const lines = layout.lines;
|
|
52
|
+
const last = lines?.[lines.length - 1];
|
|
53
|
+
if (!FINAL_BREAK.test(text) || !last || last.start >= text.length) {
|
|
54
|
+
return layout;
|
|
55
|
+
}
|
|
56
|
+
const lone = lineBreak();
|
|
57
|
+
const model = lone.lines?.[0];
|
|
58
|
+
if (!model) return layout;
|
|
59
|
+
const dy = last.y + last.height - model.y;
|
|
60
|
+
const line = {
|
|
61
|
+
x: model.x,
|
|
62
|
+
y: model.y + dy,
|
|
63
|
+
width: 0,
|
|
64
|
+
height: model.height,
|
|
65
|
+
baseline: model.baseline + dy,
|
|
66
|
+
ascent: model.ascent,
|
|
67
|
+
descent: model.descent,
|
|
68
|
+
start: text.length,
|
|
69
|
+
end: text.length,
|
|
70
|
+
runs: [],
|
|
71
|
+
};
|
|
72
|
+
const index = lines.length;
|
|
73
|
+
const end = Array.from(text).length;
|
|
74
|
+
const caret = lone.caretPosition(0);
|
|
75
|
+
return Object.create(layout, {
|
|
76
|
+
lines: { value: [...lines, line] },
|
|
77
|
+
height: { value: Math.max(layout.height, line.y + line.height) },
|
|
78
|
+
caretPosition: {
|
|
79
|
+
value: (cp) =>
|
|
80
|
+
cp >= end
|
|
81
|
+
? { x: caret.x, y: caret.y + dy, height: caret.height, line: index }
|
|
82
|
+
: layout.caretPosition(cp),
|
|
83
|
+
},
|
|
84
|
+
indexAt: {
|
|
85
|
+
value: (x, y) => (y >= line.y ? end : layout.indexAt(x, y)),
|
|
86
|
+
},
|
|
87
|
+
// the engine draws, and measures the coverage of, the text it laid out
|
|
88
|
+
draw: { value: (ctx, x, y) => layout.draw(ctx, x, y) },
|
|
89
|
+
coverage: { value: (options) => layout.coverage?.(options) ?? null },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
21
92
|
|
|
22
93
|
/**
|
|
23
94
|
* <textarea>: multi-line editable text on the same editing core as
|
|
@@ -26,6 +97,8 @@ import { CARET_WIDTH, CARET_RESERVE, TextInputNode } from './textinput.js';
|
|
|
26
97
|
* between visual lines keeping a goal column, Home/End are wrap-aware,
|
|
27
98
|
* selection spans lines, and the view scrolls vertically to follow the
|
|
28
99
|
* caret (wheel scrolls too). `rows` (default 3) sets the preferred height.
|
|
100
|
+
* Where the app's shortcuts are ⌘, the keys are the Mac's (textkeys.js):
|
|
101
|
+
* ⌘↑/⌘↓ for the ends of the value, ⌥↑/⌥↓ for a paragraph's.
|
|
29
102
|
*/
|
|
30
103
|
export class TextAreaNode extends TextInputNode {
|
|
31
104
|
/**
|
|
@@ -140,11 +213,13 @@ export class TextAreaNode extends TextInputNode {
|
|
|
140
213
|
const key = `${width}|${color}|${shown}|${s.family}|${s.size}|${s.weight}|${s.style}|${direction}|${align}|${s.letterSpacing}|${JSON.stringify(s.features ?? null)}`;
|
|
141
214
|
if (this._valueLayoutKey !== key) {
|
|
142
215
|
this._valueLayoutKey = key;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
216
|
+
const options = { maxWidth: container, align, direction };
|
|
217
|
+
const layout = fonts.layout([{ text: shown, ...s, color }], s, options);
|
|
218
|
+
this._valueLayoutCache = isEmpty
|
|
219
|
+
? layout
|
|
220
|
+
: withFinalLine(layout, shown, () =>
|
|
221
|
+
fonts.layout([{ text: '\n', ...s, color }], s, options),
|
|
222
|
+
);
|
|
148
223
|
}
|
|
149
224
|
return this._valueLayoutCache;
|
|
150
225
|
}
|
|
@@ -235,7 +310,8 @@ export class TextAreaNode extends TextInputNode {
|
|
|
235
310
|
const layout = this._valueLayout();
|
|
236
311
|
|
|
237
312
|
if (k === XK_RETURN || k === XK_KP_ENTER) {
|
|
238
|
-
|
|
313
|
+
// Ctrl+Enter, ⌘↩ on the Cocoa backend
|
|
314
|
+
if (primaryModifierHeld(ev, this.app)) {
|
|
239
315
|
this._fireValueEvent('onSubmit', this.value, ev.nativeEvent);
|
|
240
316
|
return true;
|
|
241
317
|
}
|
|
@@ -263,17 +339,11 @@ export class TextAreaNode extends TextInputNode {
|
|
|
263
339
|
return true;
|
|
264
340
|
}
|
|
265
341
|
if ((k === XK_HOME || k === XK_END) && layout && this.value.length > 0) {
|
|
266
|
-
const pos = layout.caretPosition(this._caret);
|
|
267
|
-
const line = layout.lines[pos.line];
|
|
268
|
-
const y = line.y + (line.ascent + line.descent) / 2;
|
|
269
|
-
// indexAt clamps into the line: far left = line start; just past the
|
|
270
|
-
// right edge = end of visible content (before the newline)
|
|
271
|
-
const i =
|
|
272
|
-
k === XK_HOME
|
|
273
|
-
? layout.indexAt(-1e6, y)
|
|
274
|
-
: layout.indexAt(line.x + line.width + 0.01, y);
|
|
275
342
|
this._goalX = null;
|
|
276
|
-
this._moveCaret(
|
|
343
|
+
this._moveCaret(
|
|
344
|
+
this._lineEdge(this._caret, k === XK_HOME ? -1 : 1),
|
|
345
|
+
ev.shiftKey,
|
|
346
|
+
);
|
|
277
347
|
return true;
|
|
278
348
|
}
|
|
279
349
|
this._goalX = null;
|
|
@@ -389,3 +459,5 @@ export class TextAreaNode extends TextInputNode {
|
|
|
389
459
|
ctx.restore();
|
|
390
460
|
}
|
|
391
461
|
}
|
|
462
|
+
|
|
463
|
+
installMethods(TextAreaNode, TextAreaKeys);
|
package/src/nodes/textinput.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// <textinput>: a single-line editable field — value, caret and selection,
|
|
2
2
|
// the keys, the mouse, the clipboard, the edit menu and painting. Undo/redo
|
|
3
|
-
// is in edithistory.js
|
|
3
|
+
// is in edithistory.js, IME composition in preedit.js and the Mac's editing
|
|
4
|
+
// keys in textkeys.js, installed below.
|
|
4
5
|
|
|
5
6
|
import { localTextStyleChanged } from '../styles.js';
|
|
6
7
|
import { Yoga } from '../yoga.js';
|
|
@@ -14,6 +15,7 @@ import { lastInputTime } from '../inputtime.js';
|
|
|
14
15
|
import {
|
|
15
16
|
ctrlChordLetter,
|
|
16
17
|
MOD,
|
|
18
|
+
primaryModifierHeld,
|
|
17
19
|
XK_BACKSPACE,
|
|
18
20
|
XK_RETURN,
|
|
19
21
|
XK_KP_ENTER,
|
|
@@ -31,6 +33,7 @@ import { installMethods } from './install.js';
|
|
|
31
33
|
import { Node } from './node.js';
|
|
32
34
|
import { TextInputPreedit } from './preedit.js';
|
|
33
35
|
import { rangeBands } from './text.js';
|
|
36
|
+
import { TextInputKeys } from './textkeys.js';
|
|
34
37
|
|
|
35
38
|
/**
|
|
36
39
|
* How long a caret stays in each of its two states, in milliseconds, on a
|
|
@@ -486,7 +489,11 @@ export class TextInputNode extends Node {
|
|
|
486
489
|
const previous = this._keyNative;
|
|
487
490
|
this._keyNative = ev.nativeEvent ?? null;
|
|
488
491
|
try {
|
|
489
|
-
|
|
492
|
+
// Where the app's shortcuts are ⌘ the keys are the Mac's (textkeys.js),
|
|
493
|
+
// and they come first: ⌃← and ⌃A mean something else to them than
|
|
494
|
+
// to the bindings in `_editKeyDown`.
|
|
495
|
+
const answered = this._macKeyDown(ev) ?? this._editKeyDown(ev);
|
|
496
|
+
if (answered) ev.preventDefault();
|
|
490
497
|
} finally {
|
|
491
498
|
this._keyNative = previous;
|
|
492
499
|
}
|
|
@@ -550,7 +557,9 @@ export class TextInputNode extends Node {
|
|
|
550
557
|
this._moveCaret(this._chars().length, ev.shiftKey);
|
|
551
558
|
return true;
|
|
552
559
|
}
|
|
553
|
-
|
|
560
|
+
// Ctrl, or ⌘ on the Cocoa backend (`primaryModifierHeld`): the keys the
|
|
561
|
+
// edit menu prints beside its rows are the keys that do what they say
|
|
562
|
+
if (primaryModifierHeld(ev, this.app)) {
|
|
554
563
|
const letter = ctrlChordLetter(ev);
|
|
555
564
|
if (letter === 0x61 /* a */) {
|
|
556
565
|
this._selectAll();
|
|
@@ -562,10 +571,12 @@ export class TextInputNode extends Node {
|
|
|
562
571
|
} else if (letter === 0x76 /* v */) {
|
|
563
572
|
this._pasteFrom();
|
|
564
573
|
} else if (letter === 0x7a /* z */) {
|
|
565
|
-
// Ctrl+Shift+Z redoes, the way it does in GTK and Qt
|
|
574
|
+
// Ctrl+Shift+Z redoes, the way it does in GTK and Qt — and ⇧⌘Z is
|
|
575
|
+
// the Mac's own redo
|
|
566
576
|
if (ev.shiftKey) this.redo();
|
|
567
577
|
else this.undo();
|
|
568
|
-
} else if (letter === 0x79 /* y */) {
|
|
578
|
+
} else if (letter === 0x79 /* y */ && !ev.metaKey) {
|
|
579
|
+
// Ctrl+Y is Windows' redo; ⌘Y is nobody's, so it is not taken
|
|
569
580
|
this.redo();
|
|
570
581
|
} else {
|
|
571
582
|
// A chord this field has no answer for is not the field's: Ctrl+S
|
|
@@ -576,6 +587,11 @@ export class TextInputNode extends Node {
|
|
|
576
587
|
}
|
|
577
588
|
return true;
|
|
578
589
|
}
|
|
590
|
+
// …and a chord on the other one is not text either. AppKit hands over
|
|
591
|
+
// the key's bare character with ⌘ or ⌃ held, so ⌘S in a focused field
|
|
592
|
+
// typed an `s` before this, and ⌃A typed an `a` once ⌘ became the
|
|
593
|
+
// primary — Ctrl and Super chords are commands on every backend.
|
|
594
|
+
if (ev.ctrlKey || ev.metaKey) return false;
|
|
579
595
|
if (ev.codepoint != null && ev.codepoint >= 0x20 && ev.codepoint !== 0x7f) {
|
|
580
596
|
const ch = String.fromCodePoint(ev.codepoint);
|
|
581
597
|
this._insert(ch, 'type');
|
|
@@ -1142,4 +1158,9 @@ export class TextInputNode extends Node {
|
|
|
1142
1158
|
}
|
|
1143
1159
|
|
|
1144
1160
|
// Undo/redo and IME composition live in their own files (see install.js).
|
|
1145
|
-
installMethods(
|
|
1161
|
+
installMethods(
|
|
1162
|
+
TextInputNode,
|
|
1163
|
+
TextInputHistory,
|
|
1164
|
+
TextInputPreedit,
|
|
1165
|
+
TextInputKeys,
|
|
1166
|
+
);
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// The Mac's text-editing keys for <textinput> and <textarea>, and the caret
|
|
2
|
+
// geometry they move by: the ends of a line, of a paragraph and of the
|
|
3
|
+
// value, and a line up or down.
|
|
4
|
+
//
|
|
5
|
+
// The bindings are AppKit's own (StandardKeyBinding.dict, the table every
|
|
6
|
+
// Cocoa field reads): ⌥ moves and deletes by word, ⌘ goes to the ends of the
|
|
7
|
+
// line and of the document, ⌘⌫ deletes to the start of the line, and ⌃ is
|
|
8
|
+
// the Emacs set — ⌃A and ⌃E to the ends of the paragraph, ⌃K kills to its
|
|
9
|
+
// end and ⌃Y yanks it back. They apply where the app's shortcuts are pressed
|
|
10
|
+
// with ⌘ (`app.primaryModifier`, the Cocoa app), which is a fact about the
|
|
11
|
+
// backend rather than the platform: an X11 app under XQuartz is an X client
|
|
12
|
+
// and keeps the bindings in textinput.js's `_editKeyDown` — GTK's, Qt's and
|
|
13
|
+
// Windows', where Ctrl moves by word. The two sets disagree about Ctrl
|
|
14
|
+
// outright, which is why the Mac's are not a layer over the others: ⌃← is
|
|
15
|
+
// the start of the line here and a word there, and ⌃A is the start of the
|
|
16
|
+
// paragraph here and Select All there.
|
|
17
|
+
//
|
|
18
|
+
// Directions are logical, as the rest of the field's keys are: ← is towards
|
|
19
|
+
// the start of the value, also in a right-to-left field.
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
ctrlChordLetter,
|
|
23
|
+
primaryModifier,
|
|
24
|
+
XK_BACKSPACE,
|
|
25
|
+
XK_DELETE,
|
|
26
|
+
XK_DOWN,
|
|
27
|
+
XK_LEFT,
|
|
28
|
+
XK_RIGHT,
|
|
29
|
+
XK_UP,
|
|
30
|
+
} from '../keysyms.js';
|
|
31
|
+
|
|
32
|
+
/** A chord a Mac field answers by doing nothing — and typing nothing. */
|
|
33
|
+
const NOTHING = 'noop';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The AppKit command a key is bound to in a Mac text field — the selector's
|
|
37
|
+
* name without the colon or `AndModifySelection`, since Shift is the
|
|
38
|
+
* `extend` the command runs with — `NOTHING` for a chord the field types
|
|
39
|
+
* nothing for, or null for a key the field's own handling covers: typing, a
|
|
40
|
+
* plain arrow, Return, Tab, a ⌘ chord (Undo, Copy and the rest), and every
|
|
41
|
+
* ⌃ chord the Mac does not bind, which `_editKeyDown` types nothing for.
|
|
42
|
+
*/
|
|
43
|
+
function macBinding(ev) {
|
|
44
|
+
const cmd = ev.metaKey;
|
|
45
|
+
const ctrl = ev.ctrlKey;
|
|
46
|
+
const opt = ev.altKey;
|
|
47
|
+
switch (ev.keysym) {
|
|
48
|
+
case XK_LEFT:
|
|
49
|
+
case XK_RIGHT: {
|
|
50
|
+
const left = ev.keysym === XK_LEFT;
|
|
51
|
+
// ⌘⌃← sets the paragraph's writing direction, which a field leaves
|
|
52
|
+
// to its `direction` style
|
|
53
|
+
if (cmd && ctrl) return NOTHING;
|
|
54
|
+
// ⌃← is Mission Control's before it is the field's, and when the
|
|
55
|
+
// system lets it through AppKit binds it beside ⌘←, not to a word
|
|
56
|
+
if (cmd || ctrl) {
|
|
57
|
+
return left ? 'moveToLeftEndOfLine' : 'moveToRightEndOfLine';
|
|
58
|
+
}
|
|
59
|
+
if (opt) return left ? 'moveWordLeft' : 'moveWordRight';
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
case XK_UP:
|
|
63
|
+
case XK_DOWN: {
|
|
64
|
+
const up = ev.keysym === XK_UP;
|
|
65
|
+
if (cmd) return up ? 'moveToBeginningOfDocument' : 'moveToEndOfDocument';
|
|
66
|
+
// ⌃↑ scrolls a page without moving the caret, when Mission Control
|
|
67
|
+
// has not taken it
|
|
68
|
+
if (ctrl) return NOTHING;
|
|
69
|
+
if (opt) return up ? 'moveParagraphBackward' : 'moveParagraphForward';
|
|
70
|
+
return up ? 'moveUp' : 'moveDown';
|
|
71
|
+
}
|
|
72
|
+
case XK_BACKSPACE:
|
|
73
|
+
if (opt) return 'deleteWordBackward'; // ⌥⌫, and ⌥⌃⌫
|
|
74
|
+
if (cmd) return 'deleteToBeginningOfLine';
|
|
75
|
+
if (ctrl) return 'deleteBackwardByDecomposingPreviousCharacter';
|
|
76
|
+
return null;
|
|
77
|
+
case XK_DELETE:
|
|
78
|
+
if (opt) return 'deleteWordForward';
|
|
79
|
+
if (ctrl) return 'deleteForward';
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
// ⌃ and a letter: the Emacs set
|
|
83
|
+
if (!ctrl || cmd || opt) return null;
|
|
84
|
+
switch (ctrlChordLetter(ev)) {
|
|
85
|
+
case 0x61 /* a */:
|
|
86
|
+
return 'moveToBeginningOfParagraph';
|
|
87
|
+
case 0x65 /* e */:
|
|
88
|
+
return 'moveToEndOfParagraph';
|
|
89
|
+
case 0x62 /* b */:
|
|
90
|
+
return 'moveBackward';
|
|
91
|
+
case 0x66 /* f */:
|
|
92
|
+
return 'moveForward';
|
|
93
|
+
case 0x70 /* p */:
|
|
94
|
+
return 'moveUp';
|
|
95
|
+
case 0x6e /* n */:
|
|
96
|
+
return 'moveDown';
|
|
97
|
+
case 0x68 /* h */:
|
|
98
|
+
return 'deleteBackward';
|
|
99
|
+
case 0x64 /* d */:
|
|
100
|
+
return 'deleteForward';
|
|
101
|
+
case 0x6b /* k */:
|
|
102
|
+
return 'deleteToEndOfParagraph';
|
|
103
|
+
case 0x79 /* y */:
|
|
104
|
+
return 'yank';
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* What ⌃K killed, per application: AppKit's kill buffer, which is its own
|
|
111
|
+
* and not the pasteboard — a kill does not replace what ⌘C copied, and ⌃Y
|
|
112
|
+
* in one field yanks what ⌃K killed in another.
|
|
113
|
+
*/
|
|
114
|
+
const killBuffers = new WeakMap();
|
|
115
|
+
const NO_APP = {};
|
|
116
|
+
|
|
117
|
+
/** Mac text keys for `TextInputNode`, installed by textinput.js. */
|
|
118
|
+
export class TextInputKeys {
|
|
119
|
+
/**
|
|
120
|
+
* The Mac binding for this key, run, ahead of `_editKeyDown`. Answers
|
|
121
|
+
* whether the field took the key, as `_editKeyDown` does — false for a
|
|
122
|
+
* chord the field types nothing for — and undefined where the key is not
|
|
123
|
+
* a Mac binding, or this app does not edit with them.
|
|
124
|
+
*/
|
|
125
|
+
_macKeyDown(ev) {
|
|
126
|
+
// the Mac's keys go with the Mac's shortcuts, read off the app on every
|
|
127
|
+
// key rather than cached, like every capability an app object carries
|
|
128
|
+
if (primaryModifier(this.app) !== 'Super') return undefined;
|
|
129
|
+
const command = macBinding(ev);
|
|
130
|
+
if (command == null) return undefined;
|
|
131
|
+
if (command === NOTHING) return false;
|
|
132
|
+
this._runTextCommand(command, Boolean(ev.shiftKey));
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One AppKit editing command; `extend` keeps the anchor where it is. */
|
|
137
|
+
_runTextCommand(command, extend) {
|
|
138
|
+
const [a, b] = this._selection();
|
|
139
|
+
const hasSelection = a !== b;
|
|
140
|
+
const caret = this._caret;
|
|
141
|
+
const length = this._chars().length;
|
|
142
|
+
const move = (index) => this._moveCaret(index, extend);
|
|
143
|
+
switch (command) {
|
|
144
|
+
case 'moveBackward':
|
|
145
|
+
return move(!extend && hasSelection ? a : caret - 1);
|
|
146
|
+
case 'moveForward':
|
|
147
|
+
return move(!extend && hasSelection ? b : caret + 1);
|
|
148
|
+
case 'moveWordLeft':
|
|
149
|
+
return move(this._wordBoundary(caret, -1));
|
|
150
|
+
case 'moveWordRight':
|
|
151
|
+
return move(this._wordBoundary(caret, 1));
|
|
152
|
+
case 'moveToLeftEndOfLine':
|
|
153
|
+
return move(this._lineEdge(caret, -1));
|
|
154
|
+
case 'moveToRightEndOfLine':
|
|
155
|
+
return move(this._lineEdge(caret, 1));
|
|
156
|
+
case 'moveToBeginningOfParagraph':
|
|
157
|
+
return move(this._paragraphEdge(caret, -1));
|
|
158
|
+
case 'moveToEndOfParagraph':
|
|
159
|
+
return move(this._paragraphEdge(caret, 1));
|
|
160
|
+
// ⌥↑ is AppKit's (moveBackward:, moveToBeginningOfParagraph:): a caret
|
|
161
|
+
// already at the start of a paragraph goes on to the previous one's
|
|
162
|
+
case 'moveParagraphBackward':
|
|
163
|
+
return move(
|
|
164
|
+
this._paragraphEdge(!extend && hasSelection ? a : caret - 1, -1),
|
|
165
|
+
);
|
|
166
|
+
case 'moveParagraphForward':
|
|
167
|
+
return move(
|
|
168
|
+
this._paragraphEdge(!extend && hasSelection ? b : caret + 1, 1),
|
|
169
|
+
);
|
|
170
|
+
case 'moveToBeginningOfDocument':
|
|
171
|
+
return move(0);
|
|
172
|
+
case 'moveToEndOfDocument':
|
|
173
|
+
return move(length);
|
|
174
|
+
case 'moveUp':
|
|
175
|
+
return move(this._verticalStep(-1));
|
|
176
|
+
case 'moveDown':
|
|
177
|
+
return move(this._verticalStep(1));
|
|
178
|
+
}
|
|
179
|
+
if (hasSelection && command !== 'yank') {
|
|
180
|
+
if (command === 'deleteToEndOfParagraph') this._kill(a, b);
|
|
181
|
+
else this._deleteRange(a, b);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
switch (command) {
|
|
185
|
+
case 'deleteBackward':
|
|
186
|
+
if (caret > 0) this._deleteRange(caret - 1, caret, 'delete-back');
|
|
187
|
+
return;
|
|
188
|
+
case 'deleteForward':
|
|
189
|
+
if (caret < length) {
|
|
190
|
+
this._deleteRange(caret, caret + 1, 'delete-forward');
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
case 'deleteWordBackward':
|
|
194
|
+
return this._deleteRange(this._wordBoundary(caret, -1), caret);
|
|
195
|
+
case 'deleteWordForward':
|
|
196
|
+
return this._deleteRange(caret, this._wordBoundary(caret, 1));
|
|
197
|
+
case 'deleteToBeginningOfLine':
|
|
198
|
+
return this._deleteRange(this._lineEdge(caret, -1), caret);
|
|
199
|
+
case 'deleteToEndOfParagraph': {
|
|
200
|
+
// at the end of a paragraph, the kill takes the line break, which
|
|
201
|
+
// joins the next paragraph to this one — Emacs's ⌃K, and AppKit's
|
|
202
|
+
const end = this._paragraphEdge(caret, 1);
|
|
203
|
+
this._kill(caret, end > caret ? end : Math.min(caret + 1, length));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
case 'deleteBackwardByDecomposingPreviousCharacter':
|
|
207
|
+
return this._deleteDecomposing(caret);
|
|
208
|
+
case 'yank': {
|
|
209
|
+
const text = killBuffers.get(this.app ?? NO_APP)?.text;
|
|
210
|
+
if (text) this._insert(text);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Delete `[from, to)` into the kill buffer. Kills one after another — ⌃K,
|
|
218
|
+
* ⌃K, with nothing in between — add up, so the two that take a line and
|
|
219
|
+
* then its break yank back as the line.
|
|
220
|
+
*
|
|
221
|
+
* A `sensitive` field deletes and keeps nothing, for the reason it offers
|
|
222
|
+
* no Copy: the text would outlive the field.
|
|
223
|
+
*/
|
|
224
|
+
_kill(from, to) {
|
|
225
|
+
if (to <= from) return;
|
|
226
|
+
const chars = this._chars();
|
|
227
|
+
const killed = chars.slice(from, to).join('');
|
|
228
|
+
// What the kill leaves, worked out rather than read back: a controlled
|
|
229
|
+
// field's `value` is the parent's until it re-renders.
|
|
230
|
+
const left = [...chars.slice(0, from), ...chars.slice(to)].join('');
|
|
231
|
+
const key = this.app ?? NO_APP;
|
|
232
|
+
const buffer = killBuffers.get(key);
|
|
233
|
+
const last = this._lastKill;
|
|
234
|
+
const appends =
|
|
235
|
+
buffer != null &&
|
|
236
|
+
last?.buffer === buffer &&
|
|
237
|
+
last.value === this.value &&
|
|
238
|
+
last.caret === from;
|
|
239
|
+
this._deleteRange(from, to);
|
|
240
|
+
if (this.props.sensitive) return;
|
|
241
|
+
const next = { text: appends ? buffer.text + killed : killed };
|
|
242
|
+
killBuffers.set(key, next);
|
|
243
|
+
this._lastKill = { buffer: next, value: left, caret: from };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* ⌃⌫: take the last mark off the character before the caret — `é` back to
|
|
248
|
+
* `e` — and delete the character only when it has none.
|
|
249
|
+
*/
|
|
250
|
+
_deleteDecomposing(caret) {
|
|
251
|
+
if (caret === 0) return;
|
|
252
|
+
const chars = this._chars();
|
|
253
|
+
const parts = Array.from(chars[caret - 1].normalize('NFD'));
|
|
254
|
+
if (parts.length < 2) {
|
|
255
|
+
this._deleteRange(caret - 1, caret, 'delete-back');
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const left = Array.from(parts.slice(0, -1).join('').normalize('NFC'));
|
|
259
|
+
this._commit(
|
|
260
|
+
[...chars.slice(0, caret - 1), ...left, ...chars.slice(caret)],
|
|
261
|
+
caret - 1 + left.length,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The start (`dir` < 0) or end of the paragraph `index` is in: the
|
|
266
|
+
* value's ends in a single line, a line break's two sides in a textarea. */
|
|
267
|
+
_paragraphEdge(index, dir) {
|
|
268
|
+
const chars = this._chars();
|
|
269
|
+
let i = Math.min(Math.max(0, index), chars.length);
|
|
270
|
+
if (dir < 0) {
|
|
271
|
+
while (i > 0 && chars[i - 1] !== '\n') i--;
|
|
272
|
+
} else {
|
|
273
|
+
while (i < chars.length && chars[i] !== '\n') i++;
|
|
274
|
+
}
|
|
275
|
+
return i;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The start or end of the line `index` is on as it is drawn. One line
|
|
279
|
+
* here; a textarea's wraps. */
|
|
280
|
+
_lineEdge(index, dir) {
|
|
281
|
+
return this._paragraphEdge(index, dir);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Where ↑ and ↓ take the caret: in a single line, as in an NSTextField,
|
|
285
|
+
* to its start and its end. */
|
|
286
|
+
_verticalStep(dir) {
|
|
287
|
+
return dir < 0 ? 0 : this._chars().length;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Mac text keys for `TextAreaNode`, installed by textarea.js. */
|
|
292
|
+
export class TextAreaKeys {
|
|
293
|
+
/** ↑ and ↓ keep the goal column they set; every other command is a fresh
|
|
294
|
+
* start, as every other key is in `_editKeyDown`. */
|
|
295
|
+
_runTextCommand(command, extend) {
|
|
296
|
+
if (command !== 'moveUp' && command !== 'moveDown') this._goalX = null;
|
|
297
|
+
return super._runTextCommand(command, extend);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The wrapped line's ends, as Home and End find them. */
|
|
301
|
+
_lineEdge(index, dir) {
|
|
302
|
+
const layout = this._valueLayout();
|
|
303
|
+
if (!layout || this.value.length === 0) {
|
|
304
|
+
return super._lineEdge(index, dir);
|
|
305
|
+
}
|
|
306
|
+
const pos = layout.caretPosition(index);
|
|
307
|
+
const line = layout.lines[pos.line];
|
|
308
|
+
const y = line.y + (line.ascent + line.descent) / 2;
|
|
309
|
+
// indexAt clamps into the line: far left = line start; just past the
|
|
310
|
+
// right edge = end of visible content (before the newline)
|
|
311
|
+
return dir < 0
|
|
312
|
+
? layout.indexAt(-1e6, y)
|
|
313
|
+
: layout.indexAt(line.x + line.width + 0.01, y);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** A visual line up or down, keeping the goal column. */
|
|
317
|
+
_verticalStep(dir) {
|
|
318
|
+
const layout = this._valueLayout();
|
|
319
|
+
if (!layout || this.value.length === 0) return this._caret;
|
|
320
|
+
return this._verticalMove(layout, dir);
|
|
321
|
+
}
|
|
322
|
+
}
|
package/src/textselection.js
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
import { callHandler } from './errors.js';
|
|
32
32
|
import { lastInputTime } from './inputtime.js';
|
|
33
|
-
import { ctrlChordLetter } from './keysyms.js';
|
|
33
|
+
import { ctrlChordLetter, primaryModifierHeld } from './keysyms.js';
|
|
34
34
|
import { codePoints, wordRangeAt } from './textrange.js';
|
|
35
35
|
// --- who is showing a selection ------------------------------------------
|
|
36
36
|
//
|
|
@@ -201,14 +201,14 @@ export class TextSelection {
|
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
/**
|
|
204
|
-
* Ctrl+A and Ctrl+C
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
* default.
|
|
204
|
+
* Ctrl+A and Ctrl+C — ⌘A and ⌘C on the Cocoa backend — run as this
|
|
205
|
+
* element's default action, which is why a surface is a focus target
|
|
206
|
+
* (a11y.js): the copy has to have somewhere to arrive. Nothing else is
|
|
207
|
+
* bound. A read-only document has no caret, so shift+arrows would be caret
|
|
208
|
+
* browsing, which is a mode rather than a default.
|
|
209
209
|
*/
|
|
210
210
|
keyDown(ev) {
|
|
211
|
-
if (!ev.
|
|
211
|
+
if (!primaryModifierHeld(ev, this.node.app)) return;
|
|
212
212
|
const letter = ctrlChordLetter(ev);
|
|
213
213
|
if (letter === 0x61 /* a */) {
|
|
214
214
|
this.selectAll();
|
|
@@ -579,6 +579,10 @@ export interface MenuItem {
|
|
|
579
579
|
* the menu is mounted, without the menu being opened (#351). `enabled`
|
|
580
580
|
* and `visible` gate the binding with the row. `accelerators={false}` on
|
|
581
581
|
* the menu turns that off for an app with a dispatcher of its own.
|
|
582
|
+
*
|
|
583
|
+
* `Control` is the primary modifier: on the Cocoa backend `[['Control',
|
|
584
|
+
* 'S']]` is drawn `⌘S`, bound to ⌘S, and installed in the macOS menu bar
|
|
585
|
+
* as ⌘S — which then answers it by itself (docs/events.md).
|
|
582
586
|
*/
|
|
583
587
|
shortcut?: MenuShortcut;
|
|
584
588
|
/** With `toggleState`, draws a check mark or a radio dot in the gutter. */
|
|
@@ -654,8 +658,10 @@ export interface MenuBarProps extends WidgetProps {
|
|
|
654
658
|
/**
|
|
655
659
|
* Honour the items' own `shortcut`s while this bar is mounted — including
|
|
656
660
|
* when the desktop's panel has taken the menu over, since the panel draws
|
|
657
|
-
* the rows but the key is pressed in this window.
|
|
658
|
-
*
|
|
661
|
+
* the rows but the key is pressed in this window. The macOS menu bar is the
|
|
662
|
+
* exception: it answers the ⌘ chords it holds as key equivalents itself,
|
|
663
|
+
* and the window leaves those alone. On by default; `false` leaves the
|
|
664
|
+
* chords to an app that dispatches its own.
|
|
659
665
|
*/
|
|
660
666
|
accelerators?: boolean;
|
|
661
667
|
fontSize?: number;
|
package/src/types/elements.d.ts
CHANGED
|
@@ -739,7 +739,7 @@ export interface TextInputProps extends DrawnProps<TextInputNode> {
|
|
|
739
739
|
* both read naturally.
|
|
740
740
|
*/
|
|
741
741
|
onChange?: (ev: ChangeEvent<TextInputNode>) => void;
|
|
742
|
-
/** Enter — or Ctrl+Enter in a `<textarea
|
|
742
|
+
/** Enter — or Ctrl+Enter in a `<textarea>` (⌘↩ on the Cocoa backend). */
|
|
743
743
|
onSubmit?: (ev: SubmitEvent<TextInputNode>) => void;
|
|
744
744
|
placeholder?: string;
|
|
745
745
|
placeholderColor?: Color;
|