Repository navigation
Expand file tree
/
Copy pathcommands.ts
More file actions
1074 lines (955 loc) · 50.2 KB
/
Copy pathcommands.ts
File metadata and controls
1074 lines (955 loc) · 50.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/* eslint-disable import/prefer-default-export */
/** Defines global keyboard shortcuts and gestures. */
import { GestureResponderEvent } from 'react-native'
import { Store } from 'redux'
import { ArrowKey } from './@types/ArrowKey'
import Command from './@types/Command'
import CommandId from './@types/CommandId'
import CommandSuccess from './@types/CommandSuccess'
import CommandType from './@types/CommandType'
import Direction from './@types/Direction'
import Gesture from './@types/Gesture'
import Index from './@types/IndexType'
import Key from './@types/Key'
import { CommandPatchMetadata } from './@types/Patch'
import Path from './@types/Path'
import State from './@types/State'
import { addMulticursorActionCreator as addMulticursor } from './actions/addMulticursor'
import { alertActionCreator as alert } from './actions/alert'
import { clearMulticursorsActionCreator as clearMulticursors } from './actions/clearMulticursors'
import { cursorClearedActionCreator as cursorCleared } from './actions/cursorCleared'
import { gestureMenuActionCreator as gestureMenu } from './actions/gestureMenu'
import { indentActionCreator as indent } from './actions/indent'
import { pinnedCommandAddRepActionCreator as pinnedCommandAddRep } from './actions/pinnedCommandAddRep'
import { redoActionCreator as redo } from './actions/redo'
import { setCursorActionCreator as setCursor } from './actions/setCursor'
import { setIsMulticursorExecutingActionCreator as setIsMulticursorExecuting } from './actions/setIsMulticursorExecuting'
import { showLatestCommandsActionCreator as showLatestCommands } from './actions/showLatestCommands'
import { suppressExpansionActionCreator as suppressExpansion } from './actions/suppressExpansion'
import { undoActionCreator as undo } from './actions/undo'
import { isCapacitor, isMac } from './browser'
import * as commandsObject from './commands/index'
import openMobileCommandUniverseCommand from './commands/openMobileCommandUniverse'
import FavoritesIcon from './components/icons/FavoritesIcon'
import { AlertType, COMMAND_PALETTE_TIMEOUT, HOME_PATH, HOME_TOKEN, LongPressState, Settings, noop } from './constants'
import * as selection from './device/selection'
import testFlags from './e2e/testFlags'
import documentSort from './selectors/documentSort'
import filterCursors from './selectors/filterCursors'
import getChildren from './selectors/getChildren'
import getThoughtById from './selectors/getThoughtById'
import getUserSetting from './selectors/getUserSetting'
import hasMulticursor from './selectors/hasMulticursor'
import isAllSelected from './selectors/isAllSelected'
import isRedoEnabled from './selectors/isRedoEnabled'
import isUndoEnabled from './selectors/isUndoEnabled'
import splitChain from './selectors/splitChain'
import thoughtToPath from './selectors/thoughtToPath'
import store from './stores/app'
import commandEmitter from './stores/commandEmitter'
import editingValueStore from './stores/editingValueStore'
import gestureStore from './stores/gestureStore'
import heldKeysStore from './stores/heldKeysStore'
import ministore from './stores/ministore'
import commandTransaction from './util/commandTransaction'
import createId from './util/createId'
import debugLog from './util/debugLog'
import equalPath from './util/equalPath'
import haptics from './util/haptics'
import head from './util/head'
import isAttribute from './util/isAttribute'
import isCommandKey from './util/isCommandKey'
import keyValueBy from './util/keyValueBy'
import parentOf from './util/parentOf'
import saveDebugLog from './util/saveDebugLog'
/** The commands registered in src/commands/index.ts. */
export const registeredCommands: Command[] = Object.values(commandsObject)
/** The commands that the app lists and matches against keyboard shortcuts and gestures: the registered commands, or the commands that a test preloaded into testFlags.stubCommands so that a snapshot of a command list does not change whenever a command is added, removed, or renamed. A stub given as an id keeps that registered command; any other stub becomes an inert command, drawn with the Favorites icon if it has an icon. */
export const globalCommands: Command[] =
testFlags.stubCommands?.map(stub =>
typeof stub === 'string'
? registeredCommands.find(command => command.id === stub)!
: {
...stub,
id: stub.label as CommandId,
svg: stub.icon ? FavoritesIcon : undefined,
multicursor: false,
exec: () => {},
},
) ?? registeredCommands
export { commandEmitter }
/** The id of the command matched by the key that is down, read by beforeInput. Set on keyDown and cleared on keyUp. A ministore rather than a module variable so that resetStores clears it between tests, including after a keydown that no keyup followed. Nothing subscribes, so a keystroke never renders. */
const keyCommandIdStore = ministore<{ id: string | null }>({ id: null })
/* A mapping of key codes to uppercase letters.
* {
* 65: 'A',
* 66: 'B',
* 67: 'C',
* ...
* }
*/
const letters = keyValueBy(Array(26).fill(0), (n, i) => ({
[65 + i]: String.fromCharCode(65 + i).toUpperCase(),
}))
/* A mapping of key codes to digits.
* {
* 48: '0',
* 49: '1',
* 50: '2',
* ...
* }
*/
const digits = keyValueBy(Array(58 - 48).fill(0), (n, i) => ({
[48 + i]: i.toString(),
}))
/**
* Hash a keyboard shortcut into a string that can be compared with the result of hashKeyDown.
* This function only handles a single keyboard shortcut, not arrays.
*/
export const hashCommand = (keyboard: string | Key): string => {
const key = typeof keyboard === 'string' ? { key: keyboard } : keyboard
return (
(key.meta ? 'META_' : '') +
(key.alt ? 'ALT_' : '') +
// On non-Mac platforms Ctrl is already the meta modifier, so control falls back to Shift (see Key).
(isMac && key.control ? 'CONTROL_' : '') +
(key.shift || (!isMac && key.control) ? 'SHIFT_' : '') +
key.key?.toUpperCase()
)
}
/**
* The letter or digit of the physical key an Option chord was struck on, e.g. 'KeyN' -> 'N'. Option + N, E, I, U, and `
* compose as dead keys on macOS, which mangles e.key ('Dead' in Chrome, '~' in Safari) and e.keyCode (192, the tilde
* key) alike, leaving e.code as the only faithful source. Restricted to Option so that unmodified typing — including
* IME composition, where keyCode is the 229 sentinel and e.code is still the physical key — resolves as it otherwise
* would.
*/
const optionChordKey = (e: KeyboardEvent): string | undefined =>
e.altKey ? /^(?:Key|Digit)(.)$/.exec(e.code)?.[1] : undefined
/** Hash all the properties of a keydown event into a string that can be compared with the result of hashCommand. */
export const hashKeyDown = (e: KeyboardEvent): string =>
(e.metaKey || e.ctrlKey ? 'META_' : '') +
(e.altKey ? 'ALT_' : '') +
(isMac && e.ctrlKey ? 'CONTROL_' : '') +
(e.shiftKey ? 'SHIFT_' : '') +
// keyCode is tried before e.code so that a layout which reports a letter's keyCode faithfully keeps resolving through
// it, e.g. AZERTY reports keyCode 65 for the physical KeyQ.
(letters[e.keyCode] || digits[e.keyCode] || optionChordKey(e) || e.key || '').toUpperCase()
/* A map of typed modifier tokens to the corresponding Key modifier property.
* Command and Ctrl are the same modifier on their respective platforms, so they both map to meta. Literal Control is
* a distinct modifier on Mac only; on other platforms Ctrl is already meta, so a typed Ctrl maps to meta there too.
* See docs/commands.md.
*/
const SHORTCUT_MODIFIERS: Index<'meta' | 'alt' | 'shift' | 'control'> = {
cmd: 'meta',
command: 'meta',
meta: 'meta',
ctrl: isMac ? 'control' : 'meta',
control: isMac ? 'control' : 'meta',
'⌘': 'meta',
'⌃': isMac ? 'control' : 'meta',
opt: 'alt',
option: 'alt',
alt: 'alt',
'⌥': 'alt',
shift: 'shift',
'⇧': 'shift',
}
/* A map of typed named keys to their canonical key name. */
const SHORTCUT_NAMED_KEYS: Index<string> = {
enter: 'Enter',
return: 'Enter',
esc: 'Escape',
escape: 'Escape',
space: 'Space',
backspace: 'Backspace',
delete: 'Delete',
del: 'Delete',
tab: 'Tab',
up: 'ArrowUp',
down: 'ArrowDown',
left: 'ArrowLeft',
right: 'ArrowRight',
}
/**
* Parses a search query that looks like a keyboard shortcut (e.g. "cmd option k", "ctrl+option+k") into a hash string
* that can be compared directly against hashCommand. Returns null if the query is not a recognized shortcut, in which
* case the query should be treated as a normal label search.
*
* Tokens are case-insensitive and order-independent, separated by whitespace and/or "+". A query is recognized as a
* shortcut iff it contains at least one modifier token and exactly one valid key token (a single character or a known
* named key). Modifier tokens map to the same Key properties that em matches keypresses against at runtime, so typing
* a command's displayed shortcut (e.g. "Command + Control + e") always resolves to that command's hash.
*/
export const parseCommandShortcut = (query: string): string | null => {
const tokens = query
.toLowerCase()
.split(/[\s+]+/)
.filter(token => token.length > 0)
if (tokens.length === 0) return null
const modifiers = new Set<'meta' | 'alt' | 'shift' | 'control'>()
const keys: string[] = []
tokens.forEach(token => {
const modifier = SHORTCUT_MODIFIERS[token]
if (modifier) {
modifiers.add(modifier)
} else {
// a valid key is a known named key or a single character
const key = SHORTCUT_NAMED_KEYS[token] || (token.length === 1 ? token : null)
if (key) keys.push(key)
// an unrecognized multi-character token means this is not a shortcut
else keys.push('')
}
})
// recognized as a shortcut iff at least one modifier and exactly one valid key
if (modifiers.size === 0 || keys.length !== 1 || keys[0] === '') return null
return hashCommand({
key: keys[0],
meta: modifiers.has('meta'),
alt: modifiers.has('alt'),
control: modifiers.has('control'),
shift: modifiers.has('shift'),
})
}
const ARROW_KEYS_TO_CHARACTER: Record<ArrowKey, string> = {
ArrowLeft: '←',
ArrowRight: '→',
ArrowUp: '↑',
ArrowDown: '↓',
}
/** Returns true if key is an arrow key. */
export const isArrowKey = (key: string): key is ArrowKey => {
return key in ARROW_KEYS_TO_CHARACTER
}
/** Converts a gesture letter or event key of an arrow key to an arrow utf8 character. Defaults to input. */
export const arrowTextToArrowCharacter = (s: ArrowKey) => ARROW_KEYS_TO_CHARACTER[s]
/** Formats a keyboard shortcut to display to the user. */
export const formatKeyboardShortcut = (keyboardOrString: Key | Key[] | string): string => {
// If it's an array, format only the first shortcut for display
if (Array.isArray(keyboardOrString)) {
return formatKeyboardShortcut(keyboardOrString[0])
}
const keyboard = typeof keyboardOrString === 'string' ? { key: keyboardOrString } : keyboardOrString
const text = keyboard.shift && keyboard.key.length === 1 ? keyboard.key.toUpperCase() : keyboard.key
return (
(keyboard.meta ? (isMac ? 'Command' : 'Ctrl') + ' + ' : '') +
(keyboard.alt ? (isMac ? 'Option' : 'Alt') + ' + ' : '') +
(keyboard.control ? (isMac ? 'Control' : 'Shift') + ' + ' : '') +
(keyboard.shift ? 'Shift + ' : '') +
(isArrowKey(text) ? arrowTextToArrowCharacter(text) : text)
)
}
/** Initializes command indices and logs keyboard shortcut conflicts. */
const index = (): {
commandKeyIndex: Index<Command>
commandIdIndex: Index<Command>
commandGestureIndex: Index<Command>
} => {
// index commands for O(1) lookup by keyboard
const commandKeyIndex: Index<Command> = keyValueBy(globalCommands, (command, i, accum) => {
if (!command.keyboard) return null
// Handle both single keyboard shortcut and arrays of shortcuts
const keyboardShortcuts = Array.isArray(command.keyboard) ? command.keyboard : [command.keyboard]
// Process each keyboard shortcut and create entries in the index
return keyboardShortcuts.reduce((result: Record<string, Command>, keyboardShortcut) => {
const hash = hashCommand(keyboardShortcut)
// check if the same shortcut is used by multiple commands
if (accum[hash]) {
console.error(
`"${command.id}" uses the same shortcut as "${accum[hash].id}": ${formatKeyboardShortcut(keyboardShortcut)}`,
)
}
return { ...result, [hash]: command }
}, {})
})
// index command for O(1) lookup by id
// Look up ids among the registered commands even when a test has stubbed the global commands, since commandById is only called with ids of registered commands.
const commandIdIndex: Index<Command> = keyValueBy(registeredCommands, command =>
command.id ? { [command.id]: command } : null,
)
// index command for O(1) lookup by gesture
const commandGestureIndex: Index<Command> = keyValueBy(globalCommands, command =>
command.gesture
? {
// command.gesture may be a string or array of strings
// normalize intro array of strings
...keyValueBy(Array.prototype.concat([], command.gesture), gesture => ({
[gesture]: command,
})),
}
: null,
)
return {
commandKeyIndex,
commandIdIndex,
commandGestureIndex,
}
}
/** The pending gesture menu, which handleGestureSegment shows after COMMAND_PALETTE_TIMEOUT. A ministore whose dispose clears the timer, so that resetStores cancels it between tests rather than letting it dispatch into the next test's store. */
const gestureMenuTimerStore = ministore<{ timer: number | null }>(
{ timer: null },
{ dispose: ({ timer }) => clearTimeout(timer ?? undefined) },
)
/** Cancels the pending gesture menu. */
const clearGestureMenuTimer = () => {
clearTimeout(gestureMenuTimerStore.getState().timer ?? undefined)
gestureMenuTimerStore.update({ timer: null })
}
const { commandKeyIndex, commandIdIndex, commandGestureIndex } = index()
/** Gets the canonical gesture of the command as a string, ignoring aliases. Returns an empty string if the command does not have a gesture. */
export const gestureString = (command: Command): Gesture =>
typeof command.gesture === 'string' ? command.gesture : command.gesture?.[0] || ''
/** Get a command by its id. Only use this for dynamic ids that are only known at runtime. If you know the id of the command at compile time, use a static import. */
export const commandById = (id: CommandId): Command => commandIdIndex[id]
/** Generates a synthetic Command object that is the result of chaining two commands together. Prefixes gesture and label. */
export const chainCommand = (command1: Command, command2: Command): Command => {
const command1GestureString = gestureString(command1)
const command2GestureString = gestureString(command2)
// collapse duplicate swipes when the command starts with the same character that the first gesture ends with
const chainedGesture =
command1GestureString +
command2GestureString.slice(command1GestureString.endsWith(command2GestureString[0]) ? 1 : 0)
const chainedCommand: Command = {
...command2,
gesture: chainedGesture,
label: `${command1.label} + ${command2.label}`,
}
return chainedCommand
}
const eventNoop = { preventDefault: noop } as Event
/** Recomputes a path after a command has executed, in case the thought was moved. Returns null if the thought no longer exists. Paths that cross a context view are returned as-is, since they do not follow the parent chain and therefore cannot be reconstructed by thoughtToPath. */
const recomputePath = (state: State, path: Path): Path | null => {
// e.g. a/m~/a does not follow the parent chain (the trailing a is a context of the Lexeme m, whose real parent is the root), so thoughtToPath would collapse it to a.
if (splitChain(state, path).length > 1) return getThoughtById(state, head(path)) ? path : null
const recomputed = thoughtToPath(state, head(path))
return recomputed && equalPath(recomputed, HOME_PATH) ? null : recomputed
}
/**
* Truncates a path to its nearest ancestor that is not within a metaprogramming attribute. If a command moves the cursor or a multicursor into a metaprogramming attribute (e.g. swapNote moving a thought into =note), the selection should be set to the nearest non-attribute ancestor instead. Returns the path unchanged if it contains no attribute, or null if truncation would leave an empty path.
*/
const nearestNonAttributeAncestor = (state: State, path: Path): Path | null => {
const attributeIndex = path.findIndex(id => {
const thought = getThoughtById(state, id)
return !!thought && isAttribute(thought.value)
})
if (attributeIndex === -1) return path
const truncated = path.slice(0, attributeIndex) as Path
return truncated.length > 0 ? truncated : null
}
/**
* The last command that was executed, tracked so that it can be executed again by the repeat command. A ministore so that resetStores clears it between tests. Nothing subscribes to it, so recording a command never renders.
*
* Repeat has no behavior of its own. Both executeCommand and executeCommandWithMulticursor swap it out for lastCommand before executing, rather than executing from within its exec, so that the repeated command runs through the same path as any other command and gets its own canExecute and multicursor handling. Since repeat is repeatable: false, it is never recorded here, so the swap never resolves to repeat itself and cannot recurse.
*
* The keyboardIndex that triggered the command is recorded alongside it, since it cannot be recovered from the repeat keypress. Without it, a command bound to an array of shortcuts (applyColor) would have no shortcut to repeat.
*/
const lastCommandStore = ministore<{ lastCommand: { command: Command; keyboardIndex?: number } | null }>({
lastCommand: null,
})
/** Returns the index of the command's keyboard shortcut that was pressed, so that it can be read in exec (e.g. to select a color based on the pressed shortcut). Returns undefined if the command was not activated by one of its own keyboard shortcuts. */
const keyboardIndexOf = (
command: Command,
type: CommandType | undefined,
event: Event | GestureResponderEvent | KeyboardEvent | React.MouseEvent | React.TouchEvent,
): number | undefined => {
if (type !== 'keyboard' || !(event instanceof KeyboardEvent) || !command.keyboard) return undefined
const keyboardShortcuts = Array.isArray(command.keyboard) ? command.keyboard : [command.keyboard]
const index = keyboardShortcuts.findIndex(keyboard => hashCommand(keyboard) === hashKeyDown(event))
return index === -1 ? undefined : index
}
/**
* Records the last command so that it can be executed again by the repeat command, but only if it made an undoable, non-navigational change to the thoughtspace. Otherwise repeat would repeat cursor movements and commands that dispatch no undoable actions (e.g. Cursor Down, Export) rather than the last edit, no matter how many of them occurred since.
*
* The newest patch records the command directly, so no patch-identity comparison or action inference is needed.
*/
const recordLastCommand = (command: Command, metadata: CommandPatchMetadata, stateAfter: State) => {
const latest = [...stateAfter.undoPatches].reverse().find(patch => !patch.metadata.isNavigation)
if (
command.repeatable !== false &&
latest?.metadata.source === 'command' &&
latest.metadata.invocationId === metadata.invocationId &&
!latest.metadata.isNavigation
) {
lastCommandStore.update({ lastCommand: { command, keyboardIndex: metadata.keyboardIndex } })
}
}
/** Records the resolved command and input once per invocation, including the shortcut used by Repeat. */
const createCommandMetadata = (
command: Command,
{
type,
event,
keyboardIndex,
}: {
type?: CommandType
event: Event | GestureResponderEvent | KeyboardEvent | React.MouseEvent | React.TouchEvent
keyboardIndex?: number
},
): CommandPatchMetadata => ({
source: 'command',
invocationId: createId(),
commandId: command.id,
label: command.label,
type,
keyboardIndex: keyboardIndex ?? keyboardIndexOf(command, type, event),
})
/** Execute a single command. Defaults to global store and keyboard shortcuts. Use `executeCommandWithMulticursor` to execute a command with multicursor mode. Returns false if there is nothing to repeat or the command cannot execute. */
export const executeCommand = (
commandArg: Command,
{
store: storeArg,
type,
event,
keyboardIndex: keyboardIndexArg,
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
store?: Store<State, any>
type?: CommandType
event?: Event | GestureResponderEvent | KeyboardEvent | React.MouseEvent | React.TouchEvent
/** The index of the keyboard shortcut that triggered the command, when it cannot be derived from the event. Set by executeCommandWithMulticursor, which resolves repeat before delegating here and so must carry the recorded index with it. */
keyboardIndex?: number
} = {},
) => {
const commandStore = storeArg ?? store
const inputMethod = type
const commandType = type ?? 'keyboard'
event = event ?? eventNoop
// resolve repeat to the last command that was executed and the keyboardIndex it was triggered with, and exit early if there is none
const resolved = commandArg.id === 'repeat' ? lastCommandStore.getState().lastCommand : { command: commandArg }
if (!resolved) return false
const command = resolved.command
const canExecute = !command.canExecute || command.canExecute(commandStore.getState())
// Exit early if the command cannot execute
if (!canExecute) return false
// A repeated command takes the keyboardIndex that was recorded with it, since the repeat keypress matches none of its own keyboard shortcuts. Otherwise it is derived from the event.
const keyboardIndex = keyboardIndexArg ?? resolved.keyboardIndex
const commandMetadata = createCommandMetadata(command, { type: inputMethod, event, keyboardIndex })
debugLog.log('command', { id: command.id, commandType })
return commandStore.dispatch(
commandTransaction(commandMetadata, (dispatch, metadata) => {
const result = command.exec(dispatch, commandStore.getState, event, {
type: commandType,
keyboardIndex: commandMetadata.keyboardIndex,
})
if (result instanceof Promise) {
return result.then(() => recordLastCommand(command, metadata, commandStore.getState()))
}
recordLastCommand(command, metadata, commandStore.getState())
}),
)
}
/** Execute command. Defaults to global store and keyboard shortcuts. */
export const executeCommandWithMulticursor = (
commandArg: Command,
{
store: storeArg,
type,
event,
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
store?: Store<State, any>
type?: CommandType
event?: Event | GestureResponderEvent | KeyboardEvent | React.MouseEvent | React.TouchEvent
} = {},
) => {
const commandStore = storeArg ?? store
const inputMethod = type
const commandType = type ?? 'keyboard'
event = event ?? eventNoop
// resolve repeat to the last command that was executed and the keyboardIndex it was triggered with, and exit early if there is none
const resolved = commandArg.id === 'repeat' ? lastCommandStore.getState().lastCommand : { command: commandArg }
if (!resolved) return
const command = resolved.command
// Every executeCommand call below is given the already resolved command, so it cannot resolve repeat itself. Forward the recorded keyboardIndex explicitly, otherwise it would be derived from the repeat keypress and lost.
const keyboardIndex = resolved.keyboardIndex
// Editable dispatches editThought on a throttle with leading: false, so a command that runs inside that window would
// read the pre-edit value and the trailing edit would then commit over the command's own result (#4774). Keyboard and
// gesture already flushed in keyDown and handleGestureEnd; every other entry point (toolbar, Command Center, Command
// Universe) flushes here, before the state read below.
if (commandType !== 'keyboard' && commandType !== 'gesture') {
commandEmitter.trigger('command', command)
}
const state = commandStore.getState()
/** Reports a successful invocation once exec has returned, unless it raised an app error, and adds a rep to the pinned command when the user performed it by keyboard or gesture. Asynchronous work is not awaited: the user has completed the command once it starts, whatever a server or the clipboard later returns. */
const reportSuccess = () => {
if (commandStore.getState().error !== state.error) return
commandEmitter.trigger('commandSucceeded', {
commandId: commandArg.id,
source: type ?? 'internal',
} satisfies CommandSuccess)
if (type === 'keyboard' || type === 'gesture')
commandStore.dispatch(pinnedCommandAddRep({ commandId: commandArg.id }))
}
// If we don't have active multicursors or the command ignores multicursors, execute the command normally.
if (!command.multicursor || !hasMulticursor(state)) {
const result = executeCommand(command, {
store: commandStore,
type: inputMethod,
event,
keyboardIndex,
})
if (result === false) return
reportSuccess()
return result
}
/** The value of Command['multicursor'] resolved to an object. That is, bare false has already short circuited, and bare true resolves to an empty object so that we don't need to make existential checks everywhere. */
const multicursor = typeof command.multicursor === 'boolean' ? {} : command.multicursor
const paths = documentSort(state, Object.values(state.multicursors))
// For each multicursor, place the cursor on the path and execute the command by calling executeCommand.
const filteredPaths = filterCursors(state, paths, multicursor.filter)
// Exit early if the command cannot execute on any of the filtered paths
const canExecute = filteredPaths.every(path => !command.canExecute || command.canExecute({ ...state, cursor: path }))
if (!canExecute) return
/** Whether the command is active on the thought alone, i.e. in the state that exec will see after setCursor clears the multicursors. */
const isActiveOnPath = (path: Path) => !!command.isActive?.({ ...state, cursor: path, multicursors: {} })
// Toggle all selected thoughts in the same direction by skipping the ones that are already in the target state.
const execPaths =
multicursor.toggle && !filteredPaths.every(isActiveOnPath)
? filteredPaths.filter(path => !isActiveOnPath(path))
: filteredPaths
const commandMetadata = createCommandMetadata(command, { type: inputMethod, event, keyboardIndex })
// Whether exec ran on at least one cursor, i.e. the invocation counts as a success.
let didExecute = false
const result = commandStore.dispatch(
commandTransaction(commandMetadata, (dispatch, metadata) => {
// Pass the attributed dispatch through the existing executor API so nested asynchronous work retains its parent.
const scopedStore = { ...commandStore, dispatch }
// Reverse the order of the cursors if the command has reverse multicursor mode enabled.
if (multicursor.reverse) {
execPaths.reverse()
}
// Keep direct multicursor action-creators grouped while command metadata identifies this transaction.
dispatch(setIsMulticursorExecuting({ value: true }))
// The thoughts created by the executions, collected for selectNewCursors.
const newCursors: Path[] = []
/** Restores selection state and closes the synchronous multicursor bracket. */
const completeMulticursorExecution = () => {
// Restore the cursor to its original value if not prevented.
// Note that state.cursor is the old cursor, before any commands were executed.
// If the cursor thought was moved into a metaprogramming attribute (e.g. swapNote moves it into =note),
// restore it to the nearest non-attribute ancestor instead.
if (!multicursor.preventSetCursor && state.cursor) {
const restoreState = commandStore.getState()
const recomputedPath = recomputePath(restoreState, state.cursor)
dispatch(setCursor({ path: recomputedPath && nearestNonAttributeAncestor(restoreState, recomputedPath) }))
}
// Restore multicursors, or select the thoughts created by commands that opt into selectNewCursors.
if (multicursor.selectNewCursors) {
// Setting the cursor to each selected thought emptied the multicursors, so the thoughts that were created can
// simply be selected. A single new thought is not a selection, so clear it and end as the command does without a
// multiselect, with the caret in the new thought.
dispatch(
newCursors.length < 2
? [clearMulticursors()]
: [
...newCursors.map(path => addMulticursor({ path })),
// state.expanded is recalculated on setCursor, so set the cursor to the last new thought to expand the
// ancestors of the new selection. The cursor is already there, so this does not move it.
// The new thoughts are selected rather than edited — there is no typing into several of them at once — so
// close the keyboard that each exec opened. Otherwise multicursorAlertMiddleware reads the selection as a
// multiselection being edited (Clear Thought) and leaves the Command Center closed over it on mobile.
setCursor({
path: newCursors[newCursors.length - 1],
isKeyboardOpen: false,
preserveMulticursor: true,
}),
],
)
} else if (!multicursor.clearMulticursor) {
dispatch(
paths.map(path => (dispatch, getState) => {
const state = getState()
const recomputedPath = recomputePath(state, path)
// If a multicursor thought was moved into a metaprogramming attribute (e.g. swapNote moves it into
// =note), restore it to the nearest non-attribute ancestor instead.
const restoredPath = recomputedPath && nearestNonAttributeAncestor(state, recomputedPath)
if (!restoredPath) return
dispatch(addMulticursor({ path: restoredPath }))
}),
)
}
// A command tapped in the Command Center that ends with an empty selection (e.g. delete, whose thoughts no
// longer exist to be restored above) would dismiss the Command Center, since multicursorAlertMiddleware
// closes it when nothing is selected. Select a thought instead, the same way the Command Center is opened in
// the first place, so that it stays open and can be used again. If the command removed thoughts, stay open
// only while a sibling of a removed thought is left: select the cursor if it is such a sibling, otherwise the
// first one. When no siblings are left anywhere, the cursor has fallen back to a parent (or to nothing at the
// root) that the user did not select, so leave the selection empty and let the Command Center close.
if (commandType === 'commandCenter') {
const state = commandStore.getState()
if (!hasMulticursor(state)) {
const removedPaths = execPaths.filter(path => !equalPath(recomputePath(state, path), path))
if (removedPaths.length === 0) {
if (state.cursor) dispatch(addMulticursor({ path: state.cursor }))
} else {
/** True if the path is a sibling of a removed thought that is still there. */
const isRemainingSibling = (path: Path) =>
removedPaths.some(removed => equalPath(parentOf(removed), parentOf(path)) && !equalPath(removed, path))
let sibling = state.cursor && isRemainingSibling(state.cursor) ? state.cursor : null
for (const removed of removedPaths) {
if (sibling) break
const parentId = removed.length > 1 ? removed[removed.length - 2] : HOME_TOKEN
const firstChild = getChildren(state, parentId)[0]
if (firstChild) sibling = [...parentOf(removed), firstChild.id] as Path
}
if (sibling) dispatch(addMulticursor({ path: sibling }))
}
}
}
multicursor.onComplete?.(execPaths, dispatch, commandStore.getState)
// The cleared state is preserved while the cursor is set to each selected thought (see setCursor), so reset it now
// that the command has completed, just as setCursor resets it when a command moves the cursor off a single cleared
// thought. Only reset it if it was set before the command, otherwise clearThought's own multiselect clear is undone.
if (state.cursorCleared) {
dispatch(cursorCleared({ value: false }))
}
dispatch(setIsMulticursorExecuting({ value: false }))
}
// If there is a custom execMulticursor function, call it with the filtered multicursors.
// Otherwise, execute the command once for each of the filtered multicursors.
if (multicursor.execMulticursor) {
// Custom execution may settle asynchronously; record Repeat once its attributed work is complete.
let result: void | Promise<void>
try {
result = multicursor.execMulticursor(execPaths, dispatch, commandStore.getState)
didExecute = true
} catch (error) {
completeMulticursorExecution()
throw error
}
if (result instanceof Promise) {
// Restore the selection and close the synchronous command bracket now. The custom command owns any asynchronous
// multicursor bracket; its supplied dispatch automatically attributes the completed edits.
completeMulticursorExecution()
return result.then(() => recordLastCommand(command, metadata, commandStore.getState()))
}
} else {
try {
for (const path of execPaths) {
// Make sure we have the correct path to the thought in case it was moved during execution.
const recomputedPath = recomputePath(commandStore.getState(), path)
if (!recomputedPath) continue
dispatch(setCursor({ path: recomputedPath }))
if (
executeCommand(command, {
store: scopedStore,
type: inputMethod,
event,
keyboardIndex,
}) !== false
) {
didExecute = true
}
// The command sets the cursor to the thought it created, so a cursor on a different thought than the one that was
// just set is the new thought. A command that could not act on the selected thought leaves the cursor where it
// was and contributes nothing (e.g. newUncle on a thought at the root).
const cursorAfter = commandStore.getState().cursor
if (multicursor.selectNewCursors && cursorAfter && !equalPath(cursorAfter, recomputedPath)) {
newCursors.push(cursorAfter)
}
}
} catch (error) {
completeMulticursorExecution()
throw error
}
}
completeMulticursorExecution()
recordLastCommand(command, metadata, commandStore.getState())
}),
)
if (didExecute) reportSuccess()
return result
}
/**
* Handles gesture hints when a valid segment is entered.
*
* There are two alert types for gesture hints:
* - GestureHint - The basic gesture hint that is shown immediately on swipe.
* - Gesture menu - The gesture menu that shows all possible gestures from the current sequence after a delay.
*
* There is no automated test coverage since timers are so messed up in the current Jest version. It may be possible to write tests if Jest is upgraded. Manual test cases.
* - Basic gesture hint.
* - Preserve gesture hint for valid command.
* - Only show "Cancel gesture" if gesture hint is already activated.
* - Dismiss gesture hint after release for invalid command.
* - gesture menu on hold.
* - gesture menu from invalid gesture (e.g. ←↓, hold, ←↓←).
* - Change gesture menu to basic gesture hint on gesture end.
*/
export const handleGestureSegment = ({ sequence }: { gesture: Direction | null; sequence: Gesture }) => {
const state = store.getState()
// Over the Mobile Command Universe the only gesture is a dry run of the pinned command, traced over its tooltip.
const isDryRun = state.showMobileCommandUniverse && state.showPinnedCommandTooltip
if (
state.showModal ||
state.longPress === LongPressState.DragInProgress ||
(state.showMobileCommandUniverse && !isDryRun)
)
return
// Stop gesture segment haptics when there are no more possible commands that can be completed from the current sequence.
// useFilteredCommands updates the possibleCommands in a back channel for efficiency.
// Always allow haptics for the first swipe, as possibleCommands may not be populated yet.
if (sequence.length === 1 || gestureStore.getState().possibleCommands.length > 2) {
haptics.light()
}
// While the pinned command tooltip is open the user is practicing the gesture it shows, so the gesture menu would
// only cover it. Haptics still confirm each segment.
if (state.showPinnedCommandTooltip) return
// gesture menu
// alert after a delay of COMMAND_PALETTE_TIMEOUT
clearGestureMenuTimer()
const timer = window.setTimeout(
() => {
gestureMenuTimerStore.update({ timer: null })
store.dispatch((dispatch, getState) => {
// do not show "Cancel gesture" if already being shown by basic gesture hint
const state = getState()
if (state.showGestureMenu) return
dispatch(gestureMenu())
})
},
// if the hint is already being shown, do not wait to change the value
COMMAND_PALETTE_TIMEOUT,
)
gestureMenuTimerStore.update({ timer })
}
/** Dismiss gesture hint that is shown by alert. */
export const handleGestureCancel = () => {
clearGestureMenuTimer()
store.dispatch((dispatch, getState) => {
const state = getState()
if (state.showGestureMenu) {
dispatch(gestureMenu())
}
if (state.alert?.alertType === AlertType.GestureHint || state.showGestureMenu) {
dispatch(alert(null))
}
})
}
/** A secret gesture (→↓←↓↑) that downloads the debug log, or shares it in the native apps, while Debug Logging is on. A debugging backdoor rather than a command, so that it appears nowhere a command does: the Command Universe, the gesture menu, help, or the tutorial. */
const DOWNLOAD_DEBUG_LOG_GESTURE: Gesture = 'rdldu'
/** Executes a valid gesture and closes the gesture hint. Special handling for chainable commands. */
export const handleGestureEnd = ({ sequence, e }: { sequence: Gesture | null; e: GestureResponderEvent }) => {
const state = store.getState()
// secret gesture
if (
sequence === DOWNLOAD_DEBUG_LOG_GESTURE &&
debugLog.isEnabled() &&
!state.showModal &&
!state.showMobileCommandUniverse &&
state.longPress !== LongPressState.DragInProgress
) {
handleGestureCancel()
const entryCount = saveDebugLog(state)
store.dispatch(alert(`${isCapacitor() ? 'Shared' : 'Downloaded'} ${entryCount} debug log entries`))
return
}
// Get the command from the command gesture index.
// When the gesture menu is displayed, disable gesture aliases (i.e. gestures hidden from instructions). This is because the gesture hints are meant only as an aid when entering gestures quickly.
const openMobileCommandUniverseGesture = gestureString(openMobileCommandUniverseCommand)
// If sequence ends with help gesture, use help command.
// If sequence starts with a chainable command gesture and has additional swipes, use the chained command with the longest matching gesture.
// Otherwise use the normal command lookup.
let command: Command | null = null
// mobile command universe
if (sequence?.toString().endsWith(openMobileCommandUniverseGesture)) {
command = openMobileCommandUniverseCommand
}
// normal command
else {
command =
!state.showDesktopCommandUniverse || !commandGestureIndex[sequence as string]?.hideFromHelp
? commandGestureIndex[sequence as string]
: null
}
// The chainable command that is in progress (only if there is at least one additional swipe). Otherwise null.
const chainableCommandInProgressExclusive: Command | undefined = command
? undefined
: globalCommands.find(
command =>
command.isChainable &&
sequence?.toString().startsWith(gestureString(command)) &&
sequence?.toString()?.length > gestureString(command).length,
)
// chained command
// only if there is no exact match command
if (!command && chainableCommandInProgressExclusive) {
const chainedGesture1 = gestureString(chainableCommandInProgressExclusive)
const chainedGestureCollapsed = sequence!.toString().slice(chainedGesture1.length - 1)
const chainedGesture = sequence!.toString().slice(chainedGesture1.length)
const commandMatch = commandGestureIndex[chainedGestureCollapsed] ?? commandGestureIndex[chainedGesture]
// only chain a command that the first command allows to follow it, as the gesture menu does
if (commandMatch && chainableCommandInProgressExclusive.isChainable?.(commandMatch)) {
command = chainCommand(chainableCommandInProgressExclusive, commandMatch)
}
}
// Whether the user drew one of the pinned command's own gestures, exactly.
const isPinnedGesture = !!sequence && commandGestureIndex[sequence]?.id === state.learning.pinnedCommandId
// Over the Mobile Command Universe a trace over the pinned command's tooltip is a dry run: nothing executes and no
// rep is earned, but tracing the pinned command's own gesture plays the ring's burst.
if (state.showMobileCommandUniverse && state.showPinnedCommandTooltip) {
if (isPinnedGesture) commandEmitter.trigger('pinnedGestureDryRun')
return
}
// In training mode, show the command's label as a gesture hint.
// Dispatch it before the command executes so that an alert raised by the command itself (e.g. an error explaining why it cannot be executed) replaces the label rather than being replaced by it.
const showLabel = !getUserSetting(state, Settings.experienceMode) && !!command && !command.hideAlert
if (command && showLabel) {
store.dispatch(alert(command.label, { alertType: AlertType.GestureHint }))
}
// execute command
// do not execute when modal is displayed or a drag is in progress
if (
command &&
!state.showModal &&
!state.showMobileCommandUniverse &&
state.longPress !== LongPressState.DragInProgress
) {
commandEmitter.trigger('command', command)
if (chainableCommandInProgressExclusive) {
const commandMetadata = createCommandMetadata(command, { type: 'gesture', event: e })
store.dispatch(
commandTransaction(commandMetadata, dispatch => {
const scopedStore = { ...store, dispatch }
if (!isAllSelected(state)) {
executeCommandWithMulticursor(chainableCommandInProgressExclusive, {
event: e,
type: 'gesture',
store: scopedStore,
})
}
executeCommandWithMulticursor(command, {
event: e,
type: 'gesture',
store: scopedStore,
})
if (chainableCommandInProgressExclusive.id === 'selectAll') dispatch(clearMulticursors())
}),
)
recordLastCommand(command, commandMetadata, store.getState())
} else {
executeCommandWithMulticursor(command, { event: e, type: 'gesture', store })
}
if (store.getState().enableLatestCommandsDiagram) store.dispatch(showLatestCommands(command))
}
// if no command was found, execute the cancel command
// clear gesture hint
clearGestureMenuTimer()
// In experience mode, clear any existing gesture hint
setTimeout(() => {
store.dispatch((dispatch, getState) => {
const state = getState()
const alertType = state.alert?.alertType
const experienceMode = getUserSetting(state, Settings.experienceMode)
if (state.showGestureMenu) {
dispatch(gestureMenu())
}
// Do not clear the label shown above, nor the alert that the command raised in its place
if (
!showLabel &&
// Clear alert if gesture is cancelled (no command)
(!command ||
// Clear alert if back/forward
command?.id === 'cursorForward' ||
command?.id === 'cursorBack' ||
// In experience mode, clear any existing gesture hint
(experienceMode && alertType === AlertType.GestureHint))
) {
dispatch(alert(null))
}
})
})
}
/** Performs a native undo/redo gesture (iOS shake-to-undo, three-finger swipe, or the Edit menu) as em's own undo/redo, so that Redux remains the single source of truth. Called by `device/nativeHistory.ts` from every route a native gesture can arrive by. */
export const handleNativeHistory = (type: 'undo' | 'redo') => {
// Flush any pending throttled edit before reading the state, mirroring keyDown. Editing dispatches editThought on a
// throttle, so a native undo triggered mid-edit (e.g. immediately after an autocorrect) would otherwise undo the
// previous step and let the pending edit commit afterwards, duplicating text (#4477).
commandEmitter.trigger('command', commandById(type))
// cursorAtEnd places the caret at the end of the restored thought rather than at the cursorOffset captured before
// the undone action, which is the position the thought was entered at and leaves the caret away from the restored
// word, typically at the beginning of the thought.
const state = store.getState()
if (type === 'undo') {
if (isUndoEnabled(state)) store.dispatch(undo({ cursorAtEnd: true }))
} else if (isRedoEnabled(state)) {
store.dispatch(redo({ cursorAtEnd: true }))
}
}
/** In the specific case of the newThought and indent commands, prevent default in beforeinput event instead of keydown to preserve default iOS auto-capitalization behavior. The Enter and space characters needs to be prevented so that it doesn't get inserted into the thought (#3707).
*
* Android soft keyboards report the space keydown as keyCode 229 ('Unidentified'), so the space-to-indent
* command is never matched in keyDown and keyCommandId is never set. The second branch catches that case:
* a `beforeinput` insertText of a single space over an empty thought indents it instead of inserting the
* space, mirroring the keyDown-matched path on desktop/iOS (#4178). Neither branch applies while the caret is in
* the thought's note, where the space belongs to the note (#5969). */
export const beforeInput = (e: InputEvent) => {
const keyCommandId = keyCommandIdStore.getState().id
// editingValueStore holds the cursor thought's value even when the caret is in its note, so space-to-indent is excluded under noteFocus and the space is inserted into the note (#5969).
const isSpaceToIndent = editingValueStore.getState() === '' && !store.getState().noteFocus
if (keyCommandId === 'newThought' || (keyCommandId === 'indent' && isSpaceToIndent)) {
e.preventDefault()
return
}
// On Android, the soft keyboard reports the space keydown with keyCode 229 ('Unidentified'), so the
// space-to-indent command is never matched in keyDown and keyCommandId is not set. Catch the space here