react-x11 2.40.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 +2 -2
- package/src/cocoa/app.js +19 -8
- package/src/cocoa/keymap.js +69 -12
- package/src/cocoa/panehost.js +44 -12
- package/src/cocoa/paneplayer.js +101 -24
- package/src/cocoa/panewindow.js +325 -44
- package/src/cocoa/window.js +9 -1
- package/src/events.js +12 -0
- package/src/frame/index.js +63 -17
- package/src/nodes/textarea.js +85 -15
- package/src/nodes/textinput.js +14 -3
- package/src/nodes/textkeys.js +322 -0
- package/src/types/events.d.ts +6 -2
- package/src/win32/fonts.js +521 -44
- package/src/win32/panewindow.js +25 -2
package/src/nodes/textarea.js
CHANGED
|
@@ -17,8 +17,78 @@ import {
|
|
|
17
17
|
scrollbarHit,
|
|
18
18
|
paintScrollbarThumb,
|
|
19
19
|
} from './scrollbars.js';
|
|
20
|
+
import { installMethods } from './install.js';
|
|
20
21
|
import { rangeBands } from './text.js';
|
|
21
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
|
+
}
|
|
22
92
|
|
|
23
93
|
/**
|
|
24
94
|
* <textarea>: multi-line editable text on the same editing core as
|
|
@@ -27,6 +97,8 @@ import { CARET_WIDTH, CARET_RESERVE, TextInputNode } from './textinput.js';
|
|
|
27
97
|
* between visual lines keeping a goal column, Home/End are wrap-aware,
|
|
28
98
|
* selection spans lines, and the view scrolls vertically to follow the
|
|
29
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.
|
|
30
102
|
*/
|
|
31
103
|
export class TextAreaNode extends TextInputNode {
|
|
32
104
|
/**
|
|
@@ -141,11 +213,13 @@ export class TextAreaNode extends TextInputNode {
|
|
|
141
213
|
const key = `${width}|${color}|${shown}|${s.family}|${s.size}|${s.weight}|${s.style}|${direction}|${align}|${s.letterSpacing}|${JSON.stringify(s.features ?? null)}`;
|
|
142
214
|
if (this._valueLayoutKey !== key) {
|
|
143
215
|
this._valueLayoutKey = key;
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
+
);
|
|
149
223
|
}
|
|
150
224
|
return this._valueLayoutCache;
|
|
151
225
|
}
|
|
@@ -265,17 +339,11 @@ export class TextAreaNode extends TextInputNode {
|
|
|
265
339
|
return true;
|
|
266
340
|
}
|
|
267
341
|
if ((k === XK_HOME || k === XK_END) && layout && this.value.length > 0) {
|
|
268
|
-
const pos = layout.caretPosition(this._caret);
|
|
269
|
-
const line = layout.lines[pos.line];
|
|
270
|
-
const y = line.y + (line.ascent + line.descent) / 2;
|
|
271
|
-
// indexAt clamps into the line: far left = line start; just past the
|
|
272
|
-
// right edge = end of visible content (before the newline)
|
|
273
|
-
const i =
|
|
274
|
-
k === XK_HOME
|
|
275
|
-
? layout.indexAt(-1e6, y)
|
|
276
|
-
: layout.indexAt(line.x + line.width + 0.01, y);
|
|
277
342
|
this._goalX = null;
|
|
278
|
-
this._moveCaret(
|
|
343
|
+
this._moveCaret(
|
|
344
|
+
this._lineEdge(this._caret, k === XK_HOME ? -1 : 1),
|
|
345
|
+
ev.shiftKey,
|
|
346
|
+
);
|
|
279
347
|
return true;
|
|
280
348
|
}
|
|
281
349
|
this._goalX = null;
|
|
@@ -391,3 +459,5 @@ export class TextAreaNode extends TextInputNode {
|
|
|
391
459
|
ctx.restore();
|
|
392
460
|
}
|
|
393
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';
|
|
@@ -32,6 +33,7 @@ import { installMethods } from './install.js';
|
|
|
32
33
|
import { Node } from './node.js';
|
|
33
34
|
import { TextInputPreedit } from './preedit.js';
|
|
34
35
|
import { rangeBands } from './text.js';
|
|
36
|
+
import { TextInputKeys } from './textkeys.js';
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
39
|
* How long a caret stays in each of its two states, in milliseconds, on a
|
|
@@ -487,7 +489,11 @@ export class TextInputNode extends Node {
|
|
|
487
489
|
const previous = this._keyNative;
|
|
488
490
|
this._keyNative = ev.nativeEvent ?? null;
|
|
489
491
|
try {
|
|
490
|
-
|
|
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();
|
|
491
497
|
} finally {
|
|
492
498
|
this._keyNative = previous;
|
|
493
499
|
}
|
|
@@ -1152,4 +1158,9 @@ export class TextInputNode extends Node {
|
|
|
1152
1158
|
}
|
|
1153
1159
|
|
|
1154
1160
|
// Undo/redo and IME composition live in their own files (see install.js).
|
|
1155
|
-
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/types/events.d.ts
CHANGED
|
@@ -140,10 +140,14 @@ export interface KeyboardEvent<T = DrawnNode> extends SyntheticEvent<T> {
|
|
|
140
140
|
* group and sends no other notice. */
|
|
141
141
|
group: number;
|
|
142
142
|
/** Unicode code point, undefined when the key produces no character —
|
|
143
|
-
* which includes every key a composition took (see `composing`)
|
|
143
|
+
* which includes every key a composition took (see `composing`), a dead
|
|
144
|
+
* key on the Cocoa backend, and a key that typed more than one character
|
|
145
|
+
* there, whose text arrives as a composition that starts and ends on it. */
|
|
144
146
|
codepoint?: number;
|
|
145
147
|
/** The character the key produced, undefined for non-printing keys and
|
|
146
|
-
* for keys
|
|
148
|
+
* for the keys `codepoint` is undefined for. On the Cocoa backend it is
|
|
149
|
+
* what the layout typed with ⌥ applied — `ß` for ⌥S — while `keysym` is
|
|
150
|
+
* still the key. */
|
|
147
151
|
key?: string;
|
|
148
152
|
/** Whether this key belongs to an open composition — a dead key, or a key
|
|
149
153
|
* of a Compose sequence. Its text arrives on the composition events
|