@plannotator/ui 0.28.0 → 0.29.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/README.md +6 -0
- package/components/AISettingsTab.tsx +5 -4
- package/components/ActionMenu.tsx +5 -1
- package/components/AgentsTab.tsx +10 -23
- package/components/AnnotationPanel.tsx +9 -5
- package/components/AnnotationToolbar.tsx +5 -13
- package/components/AnnotationToolstrip.tsx +2 -2
- package/components/ApproveDropdown.tsx +1 -1
- package/components/BlockRenderer.tsx +1 -1
- package/components/CodeFilePopout.tsx +5 -4
- package/components/CommentPopover.tsx +391 -65
- package/components/DocBadges.tsx +29 -11
- package/components/ExportModal.tsx +16 -8
- package/components/GraphvizBlock.tsx +1 -1
- package/components/InlineMarkdown.tsx +30 -13
- package/components/KeyboardShortcuts.tsx +37 -2
- package/components/Landing.tsx +7 -7
- package/components/MenuVersionSection.tsx +4 -4
- package/components/MermaidBlock.tsx +1 -1
- package/components/ModeToggle.tsx +7 -6
- package/components/OpenInAppButton.tsx +2 -5
- package/components/PinpointOverlay.tsx +9 -7
- package/components/PlanHeaderMenu.tsx +8 -8
- package/components/PopoutDialog.tsx +6 -1
- package/components/ResizeHandle.tsx +1 -0
- package/components/Settings.tsx +172 -12
- package/components/SkillReferenceMenu.tsx +260 -0
- package/components/StickyHeaderLane.tsx +7 -0
- package/components/ThemeProvider.tsx +131 -32
- package/components/ThemeTab.tsx +123 -77
- package/components/ToolbarButtons.tsx +29 -8
- package/components/Viewer.tsx +396 -130
- package/components/VimKeyHud.tsx +695 -0
- package/components/VimModeAnnouncementDialog.tsx +557 -0
- package/components/VimModeOverlay.tsx +235 -0
- package/components/VimTargetReticle.tsx +284 -0
- package/components/ai/DocumentAIChatPanel.tsx +1 -1
- package/components/blocks/CodeBlock.tsx +18 -18
- package/components/blocks/TablePopout.tsx +7 -8
- package/components/blocks/TableToolbar.tsx +7 -8
- package/components/goal-setup/GoalSetupSurface.tsx +16 -3
- package/components/html-viewer/HtmlViewer.tsx +450 -47
- package/components/html-viewer/annotationNumbering.ts +37 -0
- package/components/html-viewer/bridge-script.ts +4051 -298
- package/components/html-viewer/composerYield.ts +51 -0
- package/components/html-viewer/srcdoc.ts +18 -3
- package/components/html-viewer/useHtmlAnnotation.ts +457 -32
- package/components/icons/themeIcons.tsx +1 -1
- package/components/plan-diff/PlanCleanDiffView.tsx +9 -9
- package/components/plan-diff/PlanDiffBadge.tsx +22 -1
- package/components/settings/HooksTab.tsx +12 -8
- package/components/sidebar/FileBrowser.tsx +4 -1
- package/components/themeModes.tsx +28 -0
- package/config/configStore.ts +76 -1
- package/config/settings.ts +152 -0
- package/configure.ts +9 -0
- package/globals.d.ts +7 -1
- package/hooks/useAIChat.ts +5 -2
- package/hooks/useAIProviderActivation.ts +47 -0
- package/hooks/useAIProviderConfig.ts +5 -1
- package/hooks/useAgentSettings.ts +64 -23
- package/hooks/useAgents.ts +4 -4
- package/hooks/useAnnotationHighlighter.ts +100 -3
- package/hooks/useArchive.ts +2 -1
- package/hooks/useFenceTheme.ts +17 -0
- package/hooks/useLinkedDoc.ts +68 -1
- package/hooks/usePinpoint.ts +76 -75
- package/hooks/usePlanDiff.ts +73 -2
- package/hooks/useSkillReferenceAutocomplete.ts +239 -0
- package/hooks/useUpdateCheck.ts +1 -2
- package/hooks/useVimDocumentFocus.ts +116 -0
- package/hooks/useVimSelection.ts +1063 -0
- package/package.json +4 -4
- package/print.css +14 -13
- package/shortcuts/core.ts +38 -13
- package/shortcuts/index.ts +10 -0
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +7 -0
- package/shortcuts/plan-review/vimSelection.shortcuts.ts +251 -0
- package/shortcuts/runtime.ts +111 -12
- package/styles.css +1 -1
- package/theme.css +504 -0
- package/themes/colorblind.css +89 -0
- package/themes/plannotator.css +2 -2
- package/types.ts +93 -10
- package/utils/agentSwitch.ts +33 -7
- package/utils/blockTargeting.ts +462 -178
- package/utils/clipboard.ts +110 -0
- package/utils/codeBlockMark.ts +50 -0
- package/utils/codeHighlight.ts +293 -0
- package/utils/codexModels.ts +79 -0
- package/utils/domSelection.ts +84 -0
- package/utils/htmlChrome.ts +73 -0
- package/utils/inputMethod.ts +79 -6
- package/utils/parser.ts +517 -21
- package/utils/preferenceTtl.ts +15 -0
- package/utils/sharing.ts +0 -1
- package/utils/skillCatalog.ts +269 -0
- package/utils/skillReferences.ts +475 -0
- package/utils/syntaxTheme.ts +83 -0
- package/utils/themeRegistry.ts +154 -0
- package/utils/vimHud.ts +263 -0
- package/utils/vimModeAnnouncement.ts +23 -0
- package/utils/vimNavigation.ts +417 -0
- package/utils/vimReticle.ts +88 -0
- package/utils/vimScroll.ts +162 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plannotator/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./components/*": "./components/*.tsx",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"./shortcuts": "./shortcuts/index.ts",
|
|
19
19
|
"./config": "./config/index.ts",
|
|
20
20
|
"./configure": "./configure.ts",
|
|
21
|
+
"./theme-modes": "./components/themeModes.tsx",
|
|
21
22
|
"./types": "./types.ts",
|
|
22
23
|
"./theme": "./theme.css",
|
|
23
24
|
"./styles.css": "./styles.css"
|
|
@@ -65,9 +66,9 @@
|
|
|
65
66
|
"@fontsource-variable/inter": "^5.2.8",
|
|
66
67
|
"@lezer/common": "^1.5.2",
|
|
67
68
|
"@lezer/highlight": "^1.2.3",
|
|
68
|
-
"@pierre/diffs": "1.2
|
|
69
|
+
"@pierre/diffs": "1.3.2",
|
|
69
70
|
"@plannotator/atomic-editor": "^0.8.0",
|
|
70
|
-
"@plannotator/core": "0.
|
|
71
|
+
"@plannotator/core": "0.23.0",
|
|
71
72
|
"@plannotator/markdown-editor": "^0.4.0",
|
|
72
73
|
"@plannotator/web-highlighter": "^0.8.1",
|
|
73
74
|
"@tanstack/react-table": "^8.21.3",
|
|
@@ -76,7 +77,6 @@
|
|
|
76
77
|
"clsx": "^2.1.1",
|
|
77
78
|
"diff": "^8.0.4",
|
|
78
79
|
"dompurify": "^3.3.3",
|
|
79
|
-
"highlight.js": "^11.11.1",
|
|
80
80
|
"katex": "^0.16.47",
|
|
81
81
|
"lucide-react": "^1.14.0",
|
|
82
82
|
"marked": "^17.0.6",
|
package/print.css
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* 1. @media print — standard print styles
|
|
5
5
|
* 2. .plannotator-print — class added via JS beforeprint/afterprint events
|
|
6
6
|
* to guarantee overrides that @media print alone cannot achieve
|
|
7
|
-
* (e.g. beating Tailwind layers
|
|
7
|
+
* (e.g. beating Tailwind layers, and the syntax theme's per-token inline
|
|
8
|
+
* colours — inline styles only lose to an !important author rule).
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
/* ============================================================
|
|
@@ -12,7 +13,7 @@
|
|
|
12
13
|
* These use .plannotator-print on <html> for maximum specificity.
|
|
13
14
|
* ============================================================ */
|
|
14
15
|
|
|
15
|
-
/* Code blocks:
|
|
16
|
+
/* Code blocks: flatten the syntax theme's dark block background to paper */
|
|
16
17
|
.plannotator-print pre,
|
|
17
18
|
.plannotator-print pre[class] {
|
|
18
19
|
background: #f5f5f5 !important;
|
|
@@ -23,18 +24,19 @@
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
.plannotator-print pre code,
|
|
26
|
-
.plannotator-print code.
|
|
27
|
-
.plannotator-print pre code.
|
|
28
|
-
.plannotator-print .
|
|
27
|
+
.plannotator-print code.pn-code,
|
|
28
|
+
.plannotator-print pre code.pn-code,
|
|
29
|
+
.plannotator-print .code-snippet-preview {
|
|
29
30
|
background: transparent !important;
|
|
30
31
|
background-color: transparent !important;
|
|
31
32
|
color: #1a1a1a !important;
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
/* The syntax theme colours every token with an inline `style`, so these
|
|
36
|
+
!important rules are what flattens code to black on paper. */
|
|
34
37
|
.plannotator-print pre span,
|
|
35
38
|
.plannotator-print pre code span,
|
|
36
|
-
.plannotator-print .
|
|
37
|
-
.plannotator-print [class*="hljs-"] {
|
|
39
|
+
.plannotator-print .code-snippet-preview span {
|
|
38
40
|
color: #1a1a1a !important;
|
|
39
41
|
background: transparent !important;
|
|
40
42
|
background-color: transparent !important;
|
|
@@ -284,9 +286,8 @@
|
|
|
284
286
|
|
|
285
287
|
pre code,
|
|
286
288
|
pre code[class],
|
|
287
|
-
pre code.
|
|
288
|
-
code.
|
|
289
|
-
code[data-highlighted] {
|
|
289
|
+
pre code.pn-code,
|
|
290
|
+
code.pn-code {
|
|
290
291
|
font-size: 9pt !important;
|
|
291
292
|
background: transparent !important;
|
|
292
293
|
background-color: transparent !important;
|
|
@@ -298,14 +299,14 @@
|
|
|
298
299
|
word-wrap: break-word !important;
|
|
299
300
|
}
|
|
300
301
|
|
|
301
|
-
.
|
|
302
|
+
.code-snippet-preview {
|
|
302
303
|
background: transparent !important;
|
|
303
304
|
background-color: transparent !important;
|
|
304
305
|
color: #1a1a1a !important;
|
|
305
306
|
}
|
|
306
307
|
|
|
307
|
-
pre span, pre code span,
|
|
308
|
-
|
|
308
|
+
pre span, pre code span, code span,
|
|
309
|
+
.code-snippet-preview span {
|
|
309
310
|
color: #1a1a1a !important;
|
|
310
311
|
background: transparent !important;
|
|
311
312
|
background-color: transparent !important;
|
package/shortcuts/core.ts
CHANGED
|
@@ -46,12 +46,6 @@ const NAMED_TOKENS = new Set([
|
|
|
46
46
|
'Enter',
|
|
47
47
|
'Escape',
|
|
48
48
|
'Tab',
|
|
49
|
-
// TODO(migration): `matchesKeyToken` does not currently match `Space` —
|
|
50
|
-
// pressing Spacebar produces `event.key === ' '` (length 1), which the
|
|
51
|
-
// matcher uppercases to `' '` and then compares to the literal `'Space'`,
|
|
52
|
-
// always failing. Add a special case in `matchesKeyToken` (e.g.
|
|
53
|
-
// `if (token === 'Space') return event.key === ' ' || event.code === 'Space'`)
|
|
54
|
-
// before any scope binds Space.
|
|
55
49
|
'Space',
|
|
56
50
|
'Backspace',
|
|
57
51
|
'Delete',
|
|
@@ -68,8 +62,13 @@ const NAMED_TOKENS = new Set([
|
|
|
68
62
|
// whitelist explicitly so typos like `Cmd` instead of `Mod` keep failing
|
|
69
63
|
// validation.
|
|
70
64
|
'.',
|
|
65
|
+
'/',
|
|
71
66
|
'[',
|
|
72
67
|
']',
|
|
68
|
+
'{',
|
|
69
|
+
'}',
|
|
70
|
+
'?',
|
|
71
|
+
'$',
|
|
73
72
|
]);
|
|
74
73
|
|
|
75
74
|
for (let n = 1; n <= 12; n += 1) {
|
|
@@ -77,6 +76,11 @@ for (let n = 1; n <= 12; n += 1) {
|
|
|
77
76
|
}
|
|
78
77
|
|
|
79
78
|
const MODIFIER_TOKENS = new Set(['Mod', 'Shift', 'Alt']);
|
|
79
|
+
const SHIFTED_LITERAL_TOKENS = new Set(['{', '}', '?', '$']);
|
|
80
|
+
type ShortcutKeyEvent = Pick<
|
|
81
|
+
KeyboardEvent,
|
|
82
|
+
'key' | 'code' | 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey'
|
|
83
|
+
>;
|
|
80
84
|
|
|
81
85
|
export function defineShortcutScope<TAction extends string>(scope: ShortcutScopeDefinition<TAction>): ShortcutScopeDefinition<TAction> {
|
|
82
86
|
return scope;
|
|
@@ -115,7 +119,7 @@ export function parseDoubleTapBinding(binding: string): string | null {
|
|
|
115
119
|
* Check if a KeyboardEvent matches a named key token (for sequential/stateful matching).
|
|
116
120
|
* Unlike `matchesShortcutBinding`, this matches a single key identity without modifier checks.
|
|
117
121
|
*/
|
|
118
|
-
export function matchesKeyName(event:
|
|
122
|
+
export function matchesKeyName(event: ShortcutKeyEvent, keyName: string): boolean {
|
|
119
123
|
if (keyName === 'Alt') return event.key === 'Alt';
|
|
120
124
|
if (keyName === 'Shift') return event.key === 'Shift';
|
|
121
125
|
if (keyName === 'Mod') return event.key === 'Meta' || event.key === 'Control';
|
|
@@ -333,13 +337,13 @@ export function formatShortcutBindingsText(
|
|
|
333
337
|
return bindings.map(binding => formatShortcutBindingText(binding, platform)).join(' or ');
|
|
334
338
|
}
|
|
335
339
|
|
|
336
|
-
function getDigitCode(event:
|
|
340
|
+
function getDigitCode(event: ShortcutKeyEvent): string | null {
|
|
337
341
|
const code = typeof event.code === 'string' ? event.code : '';
|
|
338
342
|
const match = code.match(/^Digit([0-9])$/);
|
|
339
343
|
return match ? match[1] : null;
|
|
340
344
|
}
|
|
341
345
|
|
|
342
|
-
export function getShortcutDigit(event:
|
|
346
|
+
export function getShortcutDigit(event: ShortcutKeyEvent): number | null {
|
|
343
347
|
const parsed = Number.parseInt(event.key, 10);
|
|
344
348
|
if (!Number.isNaN(parsed)) return parsed;
|
|
345
349
|
|
|
@@ -347,10 +351,14 @@ export function getShortcutDigit(event: KeyboardEvent): number | null {
|
|
|
347
351
|
return digitCode === null ? null : Number.parseInt(digitCode, 10);
|
|
348
352
|
}
|
|
349
353
|
|
|
350
|
-
function matchesKeyToken(event:
|
|
354
|
+
function matchesKeyToken(event: ShortcutKeyEvent, token: string): boolean {
|
|
351
355
|
const key = event.key.length === 1 ? event.key.toUpperCase() : event.key;
|
|
352
356
|
const shortcutDigit = getShortcutDigit(event);
|
|
353
357
|
|
|
358
|
+
if (token === 'Space') {
|
|
359
|
+
return event.key === ' ' || event.key === 'Spacebar' || event.code === 'Space';
|
|
360
|
+
}
|
|
361
|
+
|
|
354
362
|
if (token === 'A-Z') {
|
|
355
363
|
return /^[A-Z]$/.test(key);
|
|
356
364
|
}
|
|
@@ -370,7 +378,13 @@ function matchesKeyToken(event: KeyboardEvent, token: string): boolean {
|
|
|
370
378
|
return key === token;
|
|
371
379
|
}
|
|
372
380
|
|
|
373
|
-
|
|
381
|
+
/**
|
|
382
|
+
* Match a keyboard event against one normalized, single-press binding.
|
|
383
|
+
*
|
|
384
|
+
* Sequential and hold bindings deliberately return false; their timing
|
|
385
|
+
* semantics are handled by the shortcut runtime's dedicated paths.
|
|
386
|
+
*/
|
|
387
|
+
export function matchesShortcutBinding(event: ShortcutKeyEvent, binding: string): boolean {
|
|
374
388
|
if (binding.includes(' ') || binding.includes('hold')) {
|
|
375
389
|
return false;
|
|
376
390
|
}
|
|
@@ -385,7 +399,9 @@ export function matchesShortcutBinding(event: KeyboardEvent, binding: string): b
|
|
|
385
399
|
if (keyTokens.length !== 1) return false;
|
|
386
400
|
|
|
387
401
|
const keyToken = keyTokens[0];
|
|
388
|
-
const shiftMatches = requiresShift === event.shiftKey
|
|
402
|
+
const shiftMatches = requiresShift === event.shiftKey
|
|
403
|
+
|| (!requiresShift && keyToken === 'A-Z' && event.shiftKey)
|
|
404
|
+
|| (!requiresShift && SHIFTED_LITERAL_TOKENS.has(keyToken) && event.key === keyToken);
|
|
389
405
|
|
|
390
406
|
if (requiresMod !== (event.metaKey || event.ctrlKey)) return false;
|
|
391
407
|
if (!shiftMatches) return false;
|
|
@@ -394,6 +410,15 @@ export function matchesShortcutBinding(event: KeyboardEvent, binding: string): b
|
|
|
394
410
|
return matchesKeyToken(event, keyToken);
|
|
395
411
|
}
|
|
396
412
|
|
|
397
|
-
|
|
413
|
+
/**
|
|
414
|
+
* Match one key group from a sequential binding such as `G G`.
|
|
415
|
+
*
|
|
416
|
+
* The group uses the same normalized syntax as an ordinary one-press binding.
|
|
417
|
+
*/
|
|
418
|
+
export function matchesShortcutBindingGroup(event: ShortcutKeyEvent, group: string): boolean {
|
|
419
|
+
return matchesShortcutBinding(event, group);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function getMatchingShortcutBindingIndex(event: ShortcutKeyEvent, bindings: string[]): number {
|
|
398
423
|
return bindings.findIndex(binding => matchesShortcutBinding(event, binding));
|
|
399
424
|
}
|
package/shortcuts/index.ts
CHANGED
|
@@ -8,6 +8,16 @@ export { commentPopoverShortcuts } from './plan-review/commentPopover.shortcuts'
|
|
|
8
8
|
export { imageAnnotatorShortcuts, useImageAnnotatorShortcuts } from './plan-review/imageAnnotator.shortcuts';
|
|
9
9
|
export { inputMethodShortcuts } from './plan-review/inputMethod.shortcuts';
|
|
10
10
|
export { viewerShortcuts, useViewerShortcuts } from './plan-review/viewer.shortcuts';
|
|
11
|
+
export {
|
|
12
|
+
describeVimSelectionAction,
|
|
13
|
+
isVimSelectionActionId,
|
|
14
|
+
vimSelectionShortcuts,
|
|
15
|
+
useVimSelectionShortcuts,
|
|
16
|
+
} from './plan-review/vimSelection.shortcuts';
|
|
17
|
+
export type {
|
|
18
|
+
VimSelectionActionId,
|
|
19
|
+
VimSelectionHudContext,
|
|
20
|
+
} from './plan-review/vimSelection.shortcuts';
|
|
11
21
|
export { goalSetupShortcuts, useGoalSetupShortcuts } from './plan-review/goalSetup.shortcuts';
|
|
12
22
|
export { annotateSidebarShortcuts, useAnnotateSidebarShortcuts } from './plan-review/sidebar.shortcuts';
|
|
13
23
|
|
|
@@ -17,5 +17,12 @@ export const commentPopoverShortcuts = defineShortcutScope({
|
|
|
17
17
|
hint: 'Available while the comment editor is open.',
|
|
18
18
|
displayOrder: 40,
|
|
19
19
|
},
|
|
20
|
+
skillMenuOpen: {
|
|
21
|
+
description: 'Reference an agent skill (opens the skill menu)',
|
|
22
|
+
bindings: ['/', '$'],
|
|
23
|
+
section: 'Annotations',
|
|
24
|
+
hint: 'Type / or $ at the start of a word in the comment editor to open the skill menu; keep typing to filter. Nothing is preselected: Enter stays a newline until you pick a row with the arrow keys (or click one), then Enter or Tab inserts it. Escape dismisses the menu.',
|
|
25
|
+
displayOrder: 50,
|
|
26
|
+
},
|
|
20
27
|
},
|
|
21
28
|
});
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { defineShortcutScope } from '../core';
|
|
2
|
+
import { createShortcutScopeHook } from '../runtime';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Modal document-navigation commands shared by plan review and annotate mode.
|
|
6
|
+
*
|
|
7
|
+
* The bindings are active only while the opted-in document focus surface owns
|
|
8
|
+
* focus; native controls and annotation composers remain outside this scope.
|
|
9
|
+
*/
|
|
10
|
+
export const vimSelectionShortcuts = defineShortcutScope({
|
|
11
|
+
id: 'vim-selection',
|
|
12
|
+
title: 'Vim selection',
|
|
13
|
+
shortcuts: {
|
|
14
|
+
moveDown: {
|
|
15
|
+
description: 'Next block or semantic sibling',
|
|
16
|
+
bindings: ['J'],
|
|
17
|
+
section: 'Vim Document Navigation',
|
|
18
|
+
displayOrder: 10,
|
|
19
|
+
},
|
|
20
|
+
moveUp: {
|
|
21
|
+
description: 'Previous block or semantic sibling',
|
|
22
|
+
bindings: ['K'],
|
|
23
|
+
section: 'Vim Document Navigation',
|
|
24
|
+
displayOrder: 20,
|
|
25
|
+
},
|
|
26
|
+
documentStart: {
|
|
27
|
+
description: 'Start of document',
|
|
28
|
+
bindings: ['G G'],
|
|
29
|
+
section: 'Vim Document Navigation',
|
|
30
|
+
displayOrder: 30,
|
|
31
|
+
preventDefault: true,
|
|
32
|
+
},
|
|
33
|
+
documentEnd: {
|
|
34
|
+
description: 'End of document',
|
|
35
|
+
bindings: ['Shift+G'],
|
|
36
|
+
section: 'Vim Document Navigation',
|
|
37
|
+
displayOrder: 40,
|
|
38
|
+
},
|
|
39
|
+
moveOut: {
|
|
40
|
+
description: 'Move to containing target',
|
|
41
|
+
bindings: ['H'],
|
|
42
|
+
section: 'Vim Document Navigation',
|
|
43
|
+
displayOrder: 50,
|
|
44
|
+
},
|
|
45
|
+
refine: {
|
|
46
|
+
description: 'Refine into child or text',
|
|
47
|
+
bindings: ['L'],
|
|
48
|
+
section: 'Vim Document Navigation',
|
|
49
|
+
displayOrder: 60,
|
|
50
|
+
},
|
|
51
|
+
visual: {
|
|
52
|
+
description: 'Toggle precise Visual selection',
|
|
53
|
+
bindings: ['V'],
|
|
54
|
+
section: 'Vim Document Navigation',
|
|
55
|
+
displayOrder: 70,
|
|
56
|
+
},
|
|
57
|
+
visualBlock: {
|
|
58
|
+
description: 'Toggle whole-block Visual selection',
|
|
59
|
+
bindings: ['Shift+V'],
|
|
60
|
+
section: 'Vim Document Navigation',
|
|
61
|
+
hint: 'Use j / k to extend by whole blocks.',
|
|
62
|
+
displayOrder: 80,
|
|
63
|
+
},
|
|
64
|
+
wordForward: {
|
|
65
|
+
description: 'Move to next word',
|
|
66
|
+
bindings: ['W'],
|
|
67
|
+
section: 'Vim Text Navigation',
|
|
68
|
+
displayOrder: 90,
|
|
69
|
+
},
|
|
70
|
+
wordBackward: {
|
|
71
|
+
description: 'Move to previous word',
|
|
72
|
+
bindings: ['B'],
|
|
73
|
+
section: 'Vim Text Navigation',
|
|
74
|
+
displayOrder: 100,
|
|
75
|
+
},
|
|
76
|
+
wordEnd: {
|
|
77
|
+
description: 'Move to end of word',
|
|
78
|
+
bindings: ['E'],
|
|
79
|
+
section: 'Vim Text Navigation',
|
|
80
|
+
displayOrder: 110,
|
|
81
|
+
},
|
|
82
|
+
lineStart: {
|
|
83
|
+
description: 'Move to start of line',
|
|
84
|
+
bindings: ['0'],
|
|
85
|
+
section: 'Vim Text Navigation',
|
|
86
|
+
displayOrder: 120,
|
|
87
|
+
},
|
|
88
|
+
lineEnd: {
|
|
89
|
+
description: 'Move to end of line',
|
|
90
|
+
bindings: ['$'],
|
|
91
|
+
section: 'Vim Text Navigation',
|
|
92
|
+
displayOrder: 130,
|
|
93
|
+
},
|
|
94
|
+
previousTextBlock: {
|
|
95
|
+
description: 'Move to previous text block',
|
|
96
|
+
bindings: ['{'],
|
|
97
|
+
section: 'Vim Text Navigation',
|
|
98
|
+
displayOrder: 140,
|
|
99
|
+
},
|
|
100
|
+
nextTextBlock: {
|
|
101
|
+
description: 'Move to next text block',
|
|
102
|
+
bindings: ['}'],
|
|
103
|
+
section: 'Vim Text Navigation',
|
|
104
|
+
displayOrder: 150,
|
|
105
|
+
},
|
|
106
|
+
swapSelectionEnds: {
|
|
107
|
+
description: 'Swap selection ends',
|
|
108
|
+
bindings: ['O'],
|
|
109
|
+
section: 'Vim Text Navigation',
|
|
110
|
+
displayOrder: 160,
|
|
111
|
+
},
|
|
112
|
+
activeAnnotation: {
|
|
113
|
+
description: 'Use active annotation mode',
|
|
114
|
+
bindings: ['Enter'],
|
|
115
|
+
section: 'Vim Annotation Actions',
|
|
116
|
+
displayOrder: 170,
|
|
117
|
+
},
|
|
118
|
+
annotationMenu: {
|
|
119
|
+
description: 'Open annotation actions',
|
|
120
|
+
bindings: ['Space'],
|
|
121
|
+
section: 'Vim Annotation Actions',
|
|
122
|
+
displayOrder: 180,
|
|
123
|
+
},
|
|
124
|
+
comment: {
|
|
125
|
+
description: 'Comment selection or target',
|
|
126
|
+
bindings: ['C'],
|
|
127
|
+
section: 'Vim Annotation Actions',
|
|
128
|
+
displayOrder: 190,
|
|
129
|
+
},
|
|
130
|
+
redline: {
|
|
131
|
+
description: 'Redline selection or target',
|
|
132
|
+
bindings: ['D'],
|
|
133
|
+
section: 'Vim Annotation Actions',
|
|
134
|
+
displayOrder: 200,
|
|
135
|
+
},
|
|
136
|
+
markup: {
|
|
137
|
+
description: 'Markup selection or target',
|
|
138
|
+
bindings: ['M'],
|
|
139
|
+
section: 'Vim Annotation Actions',
|
|
140
|
+
displayOrder: 210,
|
|
141
|
+
},
|
|
142
|
+
label: {
|
|
143
|
+
description: 'Label selection or target',
|
|
144
|
+
bindings: ['T'],
|
|
145
|
+
section: 'Vim Annotation Actions',
|
|
146
|
+
displayOrder: 220,
|
|
147
|
+
},
|
|
148
|
+
copy: {
|
|
149
|
+
description: 'Copy selection or target',
|
|
150
|
+
bindings: ['Y'],
|
|
151
|
+
section: 'Vim Annotation Actions',
|
|
152
|
+
displayOrder: 230,
|
|
153
|
+
},
|
|
154
|
+
cancel: {
|
|
155
|
+
description: 'Cancel current Vim state',
|
|
156
|
+
bindings: ['Escape'],
|
|
157
|
+
section: 'Vim Annotation Actions',
|
|
158
|
+
displayOrder: 240,
|
|
159
|
+
},
|
|
160
|
+
help: {
|
|
161
|
+
description: 'Toggle key map',
|
|
162
|
+
bindings: ['?'],
|
|
163
|
+
section: 'Vim Annotation Actions',
|
|
164
|
+
displayOrder: 250,
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
/** Stable action identifiers emitted by the Vim selection shortcut scope. */
|
|
170
|
+
export type VimSelectionActionId = keyof typeof vimSelectionShortcuts.shortcuts;
|
|
171
|
+
|
|
172
|
+
/** Vim navigation state used to make HUD command descriptions contextual. */
|
|
173
|
+
export type VimSelectionHudContext =
|
|
174
|
+
| 'inactive'
|
|
175
|
+
| 'block'
|
|
176
|
+
| 'inline'
|
|
177
|
+
| 'text'
|
|
178
|
+
| 'visual'
|
|
179
|
+
| 'visual-block'
|
|
180
|
+
| 'action';
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Return the user-facing HUD description for a handled Vim action.
|
|
184
|
+
*
|
|
185
|
+
* Contextual movement keys keep one registered shortcut while accurately
|
|
186
|
+
* describing whether they moved by document structure, line, or character.
|
|
187
|
+
*/
|
|
188
|
+
export function describeVimSelectionAction(
|
|
189
|
+
actionId: VimSelectionActionId,
|
|
190
|
+
context: VimSelectionHudContext,
|
|
191
|
+
): string {
|
|
192
|
+
switch (actionId) {
|
|
193
|
+
case 'moveDown':
|
|
194
|
+
if (context === 'inline') return 'Next semantic sibling';
|
|
195
|
+
if (context === 'text' || context === 'visual') return 'Next line';
|
|
196
|
+
if (context === 'visual-block') return 'Extend to next block';
|
|
197
|
+
return 'Next block';
|
|
198
|
+
case 'moveUp':
|
|
199
|
+
if (context === 'inline') return 'Previous semantic sibling';
|
|
200
|
+
if (context === 'text' || context === 'visual') return 'Previous line';
|
|
201
|
+
if (context === 'visual-block') return 'Extend to previous block';
|
|
202
|
+
return 'Previous block';
|
|
203
|
+
case 'moveOut':
|
|
204
|
+
return context === 'text' || context === 'visual'
|
|
205
|
+
? 'Move left one character'
|
|
206
|
+
: 'Move to containing target';
|
|
207
|
+
case 'refine':
|
|
208
|
+
return context === 'text' || context === 'visual'
|
|
209
|
+
? 'Move right one character'
|
|
210
|
+
: 'Refine into child or text';
|
|
211
|
+
case 'visual':
|
|
212
|
+
return context === 'visual'
|
|
213
|
+
? 'Return to Normal mode'
|
|
214
|
+
: 'Start Visual selection';
|
|
215
|
+
case 'visualBlock':
|
|
216
|
+
return context === 'visual-block'
|
|
217
|
+
? 'Return to block navigation'
|
|
218
|
+
: 'Select the whole block';
|
|
219
|
+
case 'documentStart':
|
|
220
|
+
case 'documentEnd':
|
|
221
|
+
case 'wordForward':
|
|
222
|
+
case 'wordBackward':
|
|
223
|
+
case 'wordEnd':
|
|
224
|
+
case 'lineStart':
|
|
225
|
+
case 'lineEnd':
|
|
226
|
+
case 'previousTextBlock':
|
|
227
|
+
case 'nextTextBlock':
|
|
228
|
+
case 'swapSelectionEnds':
|
|
229
|
+
case 'activeAnnotation':
|
|
230
|
+
case 'annotationMenu':
|
|
231
|
+
case 'comment':
|
|
232
|
+
case 'redline':
|
|
233
|
+
case 'markup':
|
|
234
|
+
case 'label':
|
|
235
|
+
case 'copy':
|
|
236
|
+
case 'cancel':
|
|
237
|
+
case 'help':
|
|
238
|
+
return vimSelectionShortcuts.shortcuts[actionId].description;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Parse an unknown bridge value into a registered Vim action identifier. */
|
|
243
|
+
export function isVimSelectionActionId(
|
|
244
|
+
value: unknown,
|
|
245
|
+
): value is VimSelectionActionId {
|
|
246
|
+
return typeof value === 'string'
|
|
247
|
+
&& Object.prototype.hasOwnProperty.call(vimSelectionShortcuts.shortcuts, value);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Bind Vim selection handlers to the opted-in document focus surface. */
|
|
251
|
+
export const useVimSelectionShortcuts = createShortcutScopeHook(vimSelectionShortcuts);
|
package/shortcuts/runtime.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { useEffect, useRef } from 'react';
|
|
2
2
|
import type { ShortcutDefinition, ShortcutScopeDefinition } from './core';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
matchesKeyName,
|
|
5
|
+
matchesShortcutBinding,
|
|
6
|
+
matchesShortcutBindingGroup,
|
|
7
|
+
parseDoubleTapBinding,
|
|
8
|
+
} from './core';
|
|
4
9
|
|
|
5
10
|
type ShortcutActionId<TScope extends ShortcutScopeDefinition<any>> = keyof TScope['shortcuts'] & string;
|
|
6
11
|
|
|
@@ -24,6 +29,19 @@ export interface UseShortcutScopeOptions<TScope extends ShortcutScopeDefinition<
|
|
|
24
29
|
stopOnMatch?: boolean;
|
|
25
30
|
}
|
|
26
31
|
|
|
32
|
+
interface ShortcutSequenceCandidate {
|
|
33
|
+
readonly actionId: string;
|
|
34
|
+
readonly groups: readonly string[];
|
|
35
|
+
readonly nextIndex: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ShortcutSequenceState {
|
|
39
|
+
readonly candidates: readonly ShortcutSequenceCandidate[];
|
|
40
|
+
readonly expiresAt: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const SHORTCUT_SEQUENCE_WINDOW_MS = 500;
|
|
44
|
+
|
|
27
45
|
function normalizeShortcutHandler(handler: ShortcutHandler): ShortcutHandlerConfig {
|
|
28
46
|
if (typeof handler === 'function') {
|
|
29
47
|
return { handle: handler };
|
|
@@ -32,6 +50,17 @@ function normalizeShortcutHandler(handler: ShortcutHandler): ShortcutHandlerConf
|
|
|
32
50
|
return handler;
|
|
33
51
|
}
|
|
34
52
|
|
|
53
|
+
function getShortcutEntries<TScope extends ShortcutScopeDefinition<any>>(
|
|
54
|
+
scope: TScope,
|
|
55
|
+
): Array<[ShortcutActionId<TScope>, ShortcutDefinition]> {
|
|
56
|
+
// SAFETY: ShortcutScopeDefinition constrains every own value in `shortcuts`
|
|
57
|
+
// to ShortcutDefinition; Object.entries preserves those own keys and values.
|
|
58
|
+
return Object.entries(scope.shortcuts) as Array<[
|
|
59
|
+
ShortcutActionId<TScope>,
|
|
60
|
+
ShortcutDefinition,
|
|
61
|
+
]>;
|
|
62
|
+
}
|
|
63
|
+
|
|
35
64
|
// TODO(migration): no cross-scope arbitration. When two scopes bind the
|
|
36
65
|
// same key (e.g. `Escape` in both an outer editor scope and an inner
|
|
37
66
|
// dialog scope), both `useShortcutScope` listeners fire on a single
|
|
@@ -48,10 +77,7 @@ export function dispatchShortcutEvent<TScope extends ShortcutScopeDefinition<any
|
|
|
48
77
|
const stopOnMatch = options?.stopOnMatch ?? true;
|
|
49
78
|
let handled = false;
|
|
50
79
|
|
|
51
|
-
for (const [actionId, shortcut] of
|
|
52
|
-
ShortcutActionId<TScope>,
|
|
53
|
-
ShortcutDefinition,
|
|
54
|
-
]>) {
|
|
80
|
+
for (const [actionId, shortcut] of getShortcutEntries(scope)) {
|
|
55
81
|
const handler = handlers[actionId];
|
|
56
82
|
if (!handler) continue;
|
|
57
83
|
if (!shortcut.bindings.some(binding => matchesShortcutBinding(event, binding))) continue;
|
|
@@ -84,6 +110,14 @@ function getEventTarget(target: ShortcutEventTarget): EventTarget | null {
|
|
|
84
110
|
return target;
|
|
85
111
|
}
|
|
86
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Attach a registry scope to a keyboard event target.
|
|
115
|
+
*
|
|
116
|
+
* Ordinary bindings dispatch immediately. Non-modifier sequential bindings
|
|
117
|
+
* advance within a 500 ms window, suppress their prefix keystrokes, and reset
|
|
118
|
+
* after a match, timeout, or unrelated key. Handlers are read through a ref so
|
|
119
|
+
* listener lifetime follows only the scope, target, and dispatch options.
|
|
120
|
+
*/
|
|
87
121
|
export function useShortcutScope<TScope extends ShortcutScopeDefinition<any>>({
|
|
88
122
|
scope,
|
|
89
123
|
handlers,
|
|
@@ -91,6 +125,7 @@ export function useShortcutScope<TScope extends ShortcutScopeDefinition<any>>({
|
|
|
91
125
|
stopOnMatch = true,
|
|
92
126
|
}: UseShortcutScopeOptions<TScope>) {
|
|
93
127
|
const handlersRef = useRef(handlers);
|
|
128
|
+
const sequenceRef = useRef<ShortcutSequenceState | null>(null);
|
|
94
129
|
|
|
95
130
|
useEffect(() => {
|
|
96
131
|
handlersRef.current = handlers;
|
|
@@ -101,12 +136,79 @@ export function useShortcutScope<TScope extends ShortcutScopeDefinition<any>>({
|
|
|
101
136
|
if (!eventTarget || !('addEventListener' in eventTarget)) return;
|
|
102
137
|
|
|
103
138
|
const handleKeyDown = (event: Event) => {
|
|
104
|
-
|
|
139
|
+
if (!(event instanceof KeyboardEvent)) return;
|
|
140
|
+
const keyboardEvent = event;
|
|
141
|
+
if (dispatchShortcutEvent(scope, handlersRef.current, keyboardEvent, { stopOnMatch })) {
|
|
142
|
+
sequenceRef.current = null;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const now = Date.now();
|
|
147
|
+
const active = sequenceRef.current && sequenceRef.current.expiresAt >= now
|
|
148
|
+
? sequenceRef.current.candidates
|
|
149
|
+
: [];
|
|
150
|
+
const advanced: ShortcutSequenceCandidate[] = [];
|
|
151
|
+
|
|
152
|
+
for (const candidate of active) {
|
|
153
|
+
const group = candidate.groups[candidate.nextIndex];
|
|
154
|
+
if (!group || !matchesShortcutBindingGroup(keyboardEvent, group)) continue;
|
|
155
|
+
if (candidate.nextIndex === candidate.groups.length - 1) {
|
|
156
|
+
const shortcut = scope.shortcuts[candidate.actionId];
|
|
157
|
+
const handler = handlersRef.current[candidate.actionId];
|
|
158
|
+
if (!shortcut || !handler) continue;
|
|
159
|
+
const { when, handle } = normalizeShortcutHandler(handler);
|
|
160
|
+
if (when && !when(keyboardEvent)) continue;
|
|
161
|
+
if (shortcut.preventDefault) keyboardEvent.preventDefault();
|
|
162
|
+
handle(keyboardEvent);
|
|
163
|
+
sequenceRef.current = null;
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
advanced.push({ ...candidate, nextIndex: candidate.nextIndex + 1 });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (advanced.length > 0) {
|
|
170
|
+
sequenceRef.current = {
|
|
171
|
+
candidates: advanced,
|
|
172
|
+
expiresAt: now + SHORTCUT_SEQUENCE_WINDOW_MS,
|
|
173
|
+
};
|
|
174
|
+
keyboardEvent.preventDefault();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const started: ShortcutSequenceCandidate[] = [];
|
|
179
|
+
for (const [actionId, shortcut] of getShortcutEntries(scope)) {
|
|
180
|
+
const handler = handlersRef.current[actionId];
|
|
181
|
+
if (!handler) continue;
|
|
182
|
+
const { when } = normalizeShortcutHandler(handler);
|
|
183
|
+
if (when && !when(keyboardEvent)) continue;
|
|
184
|
+
|
|
185
|
+
for (const binding of shortcut.bindings) {
|
|
186
|
+
const groups = binding.trim().split(/\s+/).filter(Boolean);
|
|
187
|
+
const doubleTapKey = parseDoubleTapBinding(binding);
|
|
188
|
+
if (
|
|
189
|
+
groups.length < 2
|
|
190
|
+
|| groups.includes('hold')
|
|
191
|
+
|| (doubleTapKey !== null && ['Alt', 'Shift', 'Mod'].includes(doubleTapKey))
|
|
192
|
+
|| !matchesShortcutBindingGroup(keyboardEvent, groups[0] ?? '')
|
|
193
|
+
) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
started.push({ actionId, groups, nextIndex: 1 });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
sequenceRef.current = started.length > 0
|
|
201
|
+
? {
|
|
202
|
+
candidates: started,
|
|
203
|
+
expiresAt: now + SHORTCUT_SEQUENCE_WINDOW_MS,
|
|
204
|
+
}
|
|
205
|
+
: null;
|
|
206
|
+
if (started.length > 0) keyboardEvent.preventDefault();
|
|
105
207
|
};
|
|
106
208
|
|
|
107
|
-
eventTarget.addEventListener('keydown', handleKeyDown
|
|
209
|
+
eventTarget.addEventListener('keydown', handleKeyDown);
|
|
108
210
|
return () => {
|
|
109
|
-
eventTarget.removeEventListener('keydown', handleKeyDown
|
|
211
|
+
eventTarget.removeEventListener('keydown', handleKeyDown);
|
|
110
212
|
};
|
|
111
213
|
}, [scope, stopOnMatch, target]);
|
|
112
214
|
}
|
|
@@ -165,10 +267,7 @@ export function useDoubleTapShortcuts<TScope extends ShortcutScopeDefinition<any
|
|
|
165
267
|
useEffect(() => {
|
|
166
268
|
// Pre-parse which actions have double-tap bindings
|
|
167
269
|
const doubleTapActions: Array<{ actionId: ShortcutActionId<TScope>; keyName: string; preventDefault: boolean }> = [];
|
|
168
|
-
for (const [actionId, shortcut] of
|
|
169
|
-
ShortcutActionId<TScope>,
|
|
170
|
-
ShortcutDefinition,
|
|
171
|
-
]>) {
|
|
270
|
+
for (const [actionId, shortcut] of getShortcutEntries(scope)) {
|
|
172
271
|
for (const binding of shortcut.bindings) {
|
|
173
272
|
const keyName = parseDoubleTapBinding(binding);
|
|
174
273
|
if (keyName) {
|