@plannotator/ui 0.27.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.
Files changed (106) hide show
  1. package/README.md +22 -0
  2. package/components/AISettingsTab.tsx +5 -4
  3. package/components/ActionMenu.tsx +5 -1
  4. package/components/AgentsTab.tsx +10 -23
  5. package/components/AnnotationPanel.tsx +9 -5
  6. package/components/AnnotationToolbar.tsx +5 -13
  7. package/components/AnnotationToolstrip.tsx +2 -2
  8. package/components/ApproveDropdown.tsx +1 -1
  9. package/components/BlockRenderer.tsx +1 -1
  10. package/components/CodeFilePopout.tsx +5 -4
  11. package/components/CommentPopover.tsx +391 -65
  12. package/components/DocBadges.tsx +29 -11
  13. package/components/ExportModal.tsx +16 -8
  14. package/components/GraphvizBlock.tsx +1 -1
  15. package/components/InlineMarkdown.tsx +30 -13
  16. package/components/KeyboardShortcuts.tsx +37 -2
  17. package/components/Landing.tsx +7 -7
  18. package/components/MarkdownDiff.tsx +60 -0
  19. package/components/MenuVersionSection.tsx +4 -4
  20. package/components/MermaidBlock.tsx +1 -1
  21. package/components/ModeToggle.tsx +7 -6
  22. package/components/OpenInAppButton.tsx +2 -5
  23. package/components/PinpointOverlay.tsx +9 -7
  24. package/components/PlanHeaderMenu.tsx +8 -8
  25. package/components/PopoutDialog.tsx +6 -1
  26. package/components/ResizeHandle.tsx +1 -0
  27. package/components/Settings.tsx +172 -12
  28. package/components/SkillReferenceMenu.tsx +260 -0
  29. package/components/StickyHeaderLane.tsx +7 -0
  30. package/components/ThemeProvider.tsx +131 -32
  31. package/components/ThemeTab.tsx +123 -77
  32. package/components/ToolbarButtons.tsx +29 -8
  33. package/components/Viewer.tsx +396 -130
  34. package/components/VimKeyHud.tsx +695 -0
  35. package/components/VimModeAnnouncementDialog.tsx +557 -0
  36. package/components/VimModeOverlay.tsx +235 -0
  37. package/components/VimTargetReticle.tsx +284 -0
  38. package/components/ai/DocumentAIChatPanel.tsx +1 -1
  39. package/components/blocks/CodeBlock.tsx +18 -18
  40. package/components/blocks/TablePopout.tsx +7 -8
  41. package/components/blocks/TableToolbar.tsx +7 -8
  42. package/components/goal-setup/GoalSetupSurface.tsx +16 -3
  43. package/components/html-viewer/HtmlViewer.tsx +450 -47
  44. package/components/html-viewer/annotationNumbering.ts +37 -0
  45. package/components/html-viewer/bridge-script.ts +4051 -298
  46. package/components/html-viewer/composerYield.ts +51 -0
  47. package/components/html-viewer/srcdoc.ts +18 -3
  48. package/components/html-viewer/useHtmlAnnotation.ts +457 -32
  49. package/components/icons/themeIcons.tsx +1 -1
  50. package/components/plan-diff/PlanCleanDiffView.tsx +9 -9
  51. package/components/plan-diff/PlanDiffBadge.tsx +22 -1
  52. package/components/settings/HooksTab.tsx +12 -8
  53. package/components/sidebar/FileBrowser.tsx +4 -1
  54. package/components/themeModes.tsx +28 -0
  55. package/config/configStore.ts +76 -1
  56. package/config/settings.ts +152 -0
  57. package/configure.ts +9 -0
  58. package/globals.d.ts +7 -1
  59. package/hooks/useAIChat.ts +5 -2
  60. package/hooks/useAIProviderActivation.ts +47 -0
  61. package/hooks/useAIProviderConfig.ts +5 -1
  62. package/hooks/useAgentSettings.ts +64 -23
  63. package/hooks/useAgents.ts +4 -4
  64. package/hooks/useAnnotationHighlighter.ts +100 -3
  65. package/hooks/useArchive.ts +2 -1
  66. package/hooks/useFenceTheme.ts +17 -0
  67. package/hooks/useLinkedDoc.ts +68 -1
  68. package/hooks/usePinpoint.ts +76 -75
  69. package/hooks/usePlanDiff.ts +73 -2
  70. package/hooks/useSkillReferenceAutocomplete.ts +239 -0
  71. package/hooks/useUpdateCheck.ts +1 -2
  72. package/hooks/useVimDocumentFocus.ts +116 -0
  73. package/hooks/useVimSelection.ts +1063 -0
  74. package/package.json +7 -6
  75. package/print.css +14 -13
  76. package/shortcuts/core.ts +38 -13
  77. package/shortcuts/index.ts +10 -0
  78. package/shortcuts/plan-review/commentPopover.shortcuts.ts +7 -0
  79. package/shortcuts/plan-review/vimSelection.shortcuts.ts +251 -0
  80. package/shortcuts/runtime.ts +111 -12
  81. package/styles.css +1 -1
  82. package/theme.css +504 -0
  83. package/themes/colorblind.css +89 -0
  84. package/themes/plannotator.css +2 -2
  85. package/types.ts +93 -10
  86. package/utils/agentSwitch.ts +33 -7
  87. package/utils/blockTargeting.ts +462 -178
  88. package/utils/clipboard.ts +110 -0
  89. package/utils/codeBlockMark.ts +50 -0
  90. package/utils/codeHighlight.ts +293 -0
  91. package/utils/codexModels.ts +79 -0
  92. package/utils/domSelection.ts +84 -0
  93. package/utils/htmlChrome.ts +73 -0
  94. package/utils/inputMethod.ts +79 -6
  95. package/utils/parser.ts +517 -21
  96. package/utils/preferenceTtl.ts +15 -0
  97. package/utils/sharing.ts +0 -1
  98. package/utils/skillCatalog.ts +269 -0
  99. package/utils/skillReferences.ts +475 -0
  100. package/utils/syntaxTheme.ts +83 -0
  101. package/utils/themeRegistry.ts +154 -0
  102. package/utils/vimHud.ts +263 -0
  103. package/utils/vimModeAnnouncement.ts +23 -0
  104. package/utils/vimNavigation.ts +417 -0
  105. package/utils/vimReticle.ts +88 -0
  106. package/utils/vimScroll.ts +162 -0
@@ -0,0 +1,1063 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useRef,
5
+ useState,
6
+ type FocusEvent as ReactFocusEvent,
7
+ type MouseEvent as ReactMouseEvent,
8
+ type RefObject,
9
+ } from 'react';
10
+ import {
11
+ useVimSelectionShortcuts,
12
+ type VimSelectionActionId,
13
+ } from '../shortcuts';
14
+ import type { EditorMode } from '../types';
15
+ import {
16
+ buildSemanticTargetGraph,
17
+ createSemanticTargetRange,
18
+ findInitialSemanticTarget,
19
+ getOwningBlockTarget,
20
+ getSemanticTargetChildren,
21
+ moveSemanticTarget,
22
+ resolveSemanticTarget,
23
+ type SemanticTarget,
24
+ type SemanticTargetGraph,
25
+ } from '../utils/blockTargeting';
26
+ import { copyTextPreservingFocus } from '../utils/clipboard';
27
+ import { isDocumentKeyboardControl } from '../utils/domSelection';
28
+ import {
29
+ createVimHudCommand,
30
+ type VimHudCommand,
31
+ } from '../utils/vimHud';
32
+ import {
33
+ applyNativeTextSelection,
34
+ createInitialVimSelectionState,
35
+ createRangeBetweenTextPositions,
36
+ getTextElementBounds,
37
+ moveTextPosition,
38
+ resolveTextPosition,
39
+ serializeTextPosition,
40
+ type VimBlockState,
41
+ type VimInlineState,
42
+ type VimRestorableState,
43
+ type VimSelectionState,
44
+ type VimTextMotion,
45
+ type VimTextPosition,
46
+ type VimTextState,
47
+ type VimVisualBlockState,
48
+ type VimVisualState,
49
+ } from '../utils/vimNavigation';
50
+ import { scrollVimTargetIntoView } from '../utils/vimScroll';
51
+ import { useVimDocumentFocus } from './useVimDocumentFocus';
52
+
53
+ /** Inputs required by the Markdown semantic Vim controller. */
54
+ export interface UseVimSelectionOptions {
55
+ readonly containerRef: RefObject<HTMLElement | null>;
56
+ /** The element that actually scrolls (ScrollViewportContext value). */
57
+ readonly scrollViewport?: HTMLElement | null;
58
+ readonly enabled: boolean;
59
+ readonly hudEnabled: boolean;
60
+ readonly blocked: boolean;
61
+ readonly activeMode: EditorMode;
62
+ /** Identity that changes when the rendered document is replaced. */
63
+ readonly contentVersion?: unknown;
64
+ readonly onHighlightRange: (range: Range, modeOverride?: EditorMode) => void;
65
+ readonly onCodeBlockAction: (
66
+ blockId: string,
67
+ element: HTMLElement,
68
+ modeOverride?: EditorMode,
69
+ ) => void;
70
+ readonly onMathAction: (element: HTMLElement, modeOverride?: EditorMode) => void;
71
+ /** Clear pointer-owned feedback after a handled keyboard command. */
72
+ readonly onHandledCommand?: () => void;
73
+ }
74
+
75
+ /** State and event handlers rendered by the Markdown Viewer. */
76
+ export interface UseVimSelectionReturn {
77
+ readonly state: VimSelectionState;
78
+ readonly focused: boolean;
79
+ readonly helpOpen: boolean;
80
+ readonly hudCommand: VimHudCommand | null;
81
+ readonly activeTarget: SemanticTarget | null;
82
+ readonly onFocus: (event: ReactFocusEvent<HTMLElement>) => void;
83
+ readonly onBlur: (event: ReactFocusEvent<HTMLElement>) => void;
84
+ readonly onMouseDown: (event: ReactMouseEvent<HTMLElement>) => void;
85
+ readonly onHelpOpenChange: (open: boolean) => void;
86
+ readonly onHudFocusLeave: () => void;
87
+ }
88
+
89
+ function selectionModeForAction(key: string): EditorMode | null {
90
+ if (key === 'c') return 'comment';
91
+ if (key === 'd') return 'redline';
92
+ if (key === 't') return 'quickLabel';
93
+ if (key === 'm' || key === ' ' || key === 'Space' || key === 'Spacebar') {
94
+ return 'selection';
95
+ }
96
+ return null;
97
+ }
98
+
99
+ function motionFromTextKey(key: string): VimTextMotion | null {
100
+ if (key === 'h') return 'left';
101
+ if (key === 'l') return 'right';
102
+ if (key === 'w') return 'word-forward';
103
+ if (key === 'b') return 'word-backward';
104
+ if (key === 'e') return 'word-end';
105
+ if (key === '{') return 'block-backward';
106
+ if (key === '}') return 'block-forward';
107
+ return null;
108
+ }
109
+
110
+ function semanticStateForTarget(target: SemanticTarget): VimBlockState | VimInlineState {
111
+ return target.kind === 'inline' || target.kind === 'row' || target.kind === 'cell'
112
+ ? { phase: 'inline', targetKey: target.key }
113
+ : { phase: 'block', targetKey: target.key };
114
+ }
115
+
116
+ function targetKeyForState(state: VimSelectionState): string | null {
117
+ switch (state.phase) {
118
+ case 'inactive':
119
+ return null;
120
+ case 'action':
121
+ return state.returnTo.targetKey;
122
+ case 'block':
123
+ case 'inline':
124
+ case 'text':
125
+ case 'visual':
126
+ case 'visual-block':
127
+ return state.targetKey;
128
+ }
129
+ }
130
+
131
+ function targetKeyForPosition(
132
+ graph: SemanticTargetGraph,
133
+ currentTargetKey: string,
134
+ position: VimTextPosition,
135
+ ): string {
136
+ const current = resolveSemanticTarget(graph, currentTargetKey);
137
+ if (current?.blockId === position.blockId) return currentTargetKey;
138
+ return graph.blockKeys
139
+ .map((key) => resolveSemanticTarget(graph, key))
140
+ .find((target) => target?.blockId === position.blockId)
141
+ ?.key ?? currentTargetKey;
142
+ }
143
+
144
+ function clampPositionToSemanticTarget(
145
+ graph: SemanticTargetGraph,
146
+ targetKey: string,
147
+ position: VimTextPosition,
148
+ direction: 'forward' | 'backward',
149
+ ): VimTextPosition {
150
+ const target = resolveSemanticTarget(graph, targetKey);
151
+ if (!target || (target.kind !== 'inline' && target.kind !== 'row' && target.kind !== 'cell')) {
152
+ return position;
153
+ }
154
+ const bounds = getTextElementBounds(graph.container, target.element);
155
+ if (!bounds) return position;
156
+ if (position.blockId !== bounds.start.blockId) {
157
+ return direction === 'forward' ? bounds.end : bounds.start;
158
+ }
159
+ if (position.textOffset <= bounds.start.textOffset) return bounds.start;
160
+ if (position.textOffset >= bounds.end.textOffset) return bounds.end;
161
+ return {
162
+ blockId: position.blockId,
163
+ textOffset: position.textOffset,
164
+ affinity: position.affinity,
165
+ };
166
+ }
167
+
168
+ function moveWithNativeSelection(
169
+ container: HTMLElement,
170
+ state: VimTextState | VimVisualState,
171
+ direction: 'forward' | 'backward',
172
+ granularity: 'line' | 'lineboundary',
173
+ ): VimTextPosition | null {
174
+ const anchor = state.phase === 'visual' ? state.anchor : null;
175
+ const selection = applyNativeTextSelection(container, state.cursor, anchor);
176
+ if (!selection || typeof selection.modify !== 'function') return null;
177
+ selection.modify(state.phase === 'text' ? 'move' : 'extend', direction, granularity);
178
+ if (!selection.focusNode) return null;
179
+ return serializeTextPosition(container, selection.focusNode, selection.focusOffset);
180
+ }
181
+
182
+ function selectedTextRange(
183
+ container: HTMLElement,
184
+ state: VimVisualState,
185
+ ): Range | null {
186
+ const range = createRangeBetweenTextPositions(container, state.anchor, state.cursor);
187
+ return range && !range.collapsed ? range : null;
188
+ }
189
+
190
+ function visualBlockPoints(
191
+ graph: SemanticTargetGraph,
192
+ state: VimVisualBlockState,
193
+ ): { anchor: VimTextPosition; cursor: VimTextPosition } | null {
194
+ const anchorTarget = resolveSemanticTarget(graph, state.anchorTargetKey);
195
+ const cursorTarget = resolveSemanticTarget(graph, state.targetKey);
196
+ if (!anchorTarget || !cursorTarget) return null;
197
+ const anchorBounds = getTextElementBounds(graph.container, anchorTarget.element);
198
+ const cursorBounds = getTextElementBounds(graph.container, cursorTarget.element);
199
+ if (!anchorBounds || !cursorBounds) return null;
200
+
201
+ const anchorIndex = graph.blockKeys.indexOf(anchorTarget.key);
202
+ const cursorIndex = graph.blockKeys.indexOf(cursorTarget.key);
203
+ return cursorIndex >= anchorIndex
204
+ ? { anchor: anchorBounds.start, cursor: cursorBounds.end }
205
+ : { anchor: anchorBounds.end, cursor: cursorBounds.start };
206
+ }
207
+
208
+ function visualBlockRange(
209
+ graph: SemanticTargetGraph,
210
+ state: VimVisualBlockState,
211
+ ): Range | null {
212
+ const points = visualBlockPoints(graph, state);
213
+ if (!points) return null;
214
+ const range = createRangeBetweenTextPositions(
215
+ graph.container,
216
+ points.anchor,
217
+ points.cursor,
218
+ );
219
+ return range && !range.collapsed ? range : null;
220
+ }
221
+
222
+ function applyVisualBlockSelection(
223
+ graph: SemanticTargetGraph,
224
+ state: VimVisualBlockState,
225
+ ): void {
226
+ const points = visualBlockPoints(graph, state);
227
+ if (points) applyNativeTextSelection(graph.container, points.cursor, points.anchor);
228
+ }
229
+
230
+ /**
231
+ * Own semantic block navigation and precise text selection while the rendered
232
+ * document has focus.
233
+ *
234
+ * The shortcut listener is attached only to the rendered document element.
235
+ * Disabling the opt-in setting makes it inert, and native controls inside the
236
+ * document retain their own keys.
237
+ */
238
+ export function useVimSelection({
239
+ containerRef,
240
+ scrollViewport,
241
+ enabled,
242
+ hudEnabled,
243
+ blocked,
244
+ activeMode,
245
+ contentVersion,
246
+ onHighlightRange,
247
+ onCodeBlockAction,
248
+ onMathAction,
249
+ onHandledCommand,
250
+ }: UseVimSelectionOptions): UseVimSelectionReturn {
251
+ const [state, setStateValue] = useState<VimSelectionState>(
252
+ createInitialVimSelectionState,
253
+ );
254
+ const stateRef = useRef(state);
255
+ const [focused, setFocused] = useState(false);
256
+ const [helpOpen, setHelpOpen] = useState(false);
257
+ const [hudCommand, setHudCommand] = useState<VimHudCommand | null>(null);
258
+ const hudSequenceRef = useRef(0);
259
+ const restoreFocusRef = useRef(false);
260
+ const wasBlockedRef = useRef(blocked);
261
+ const pointerFocusRef = useRef(false);
262
+ const restoringFocusRef = useRef(false);
263
+
264
+ // Read the live scroll viewport without adding a dependency to every
265
+ // navigation callback below.
266
+ const scrollViewportRef = useRef(scrollViewport);
267
+ scrollViewportRef.current = scrollViewport;
268
+
269
+ const setState = useCallback((next: VimSelectionState) => {
270
+ stateRef.current = next;
271
+ setStateValue(next);
272
+ }, []);
273
+
274
+ const needsLiveTarget = enabled && (
275
+ state.phase === 'block'
276
+ || state.phase === 'inline'
277
+ || state.phase === 'action'
278
+ );
279
+ const liveGraph = needsLiveTarget && containerRef.current
280
+ ? buildSemanticTargetGraph(containerRef.current)
281
+ : null;
282
+ const activeTarget = liveGraph
283
+ ? resolveSemanticTarget(liveGraph, targetKeyForState(state))
284
+ : null;
285
+
286
+ const initializeSemanticNavigation = useCallback((): VimBlockState | null => {
287
+ const container = containerRef.current;
288
+ if (!container) return null;
289
+ const graph = buildSemanticTargetGraph(container);
290
+ const initial = findInitialSemanticTarget(graph, scrollViewportRef.current);
291
+ if (!initial) return null;
292
+ const next: VimBlockState = { phase: 'block', targetKey: initial.key };
293
+ setState(next);
294
+ window.getSelection()?.removeAllRanges();
295
+ scrollVimTargetIntoView(initial.element, scrollViewportRef.current);
296
+ return next;
297
+ }, [containerRef, setState]);
298
+
299
+ useEffect(() => {
300
+ if (!enabled) {
301
+ setState(createInitialVimSelectionState());
302
+ setFocused(false);
303
+ setHelpOpen(false);
304
+ setHudCommand(null);
305
+ window.getSelection()?.removeAllRanges();
306
+ return;
307
+ }
308
+ if (!focused) return;
309
+ if (restoringFocusRef.current) {
310
+ restoringFocusRef.current = false;
311
+ return;
312
+ }
313
+ if (pointerFocusRef.current) {
314
+ pointerFocusRef.current = false;
315
+ return;
316
+ }
317
+ initializeSemanticNavigation();
318
+ }, [
319
+ contentVersion,
320
+ enabled,
321
+ focused,
322
+ initializeSemanticNavigation,
323
+ setState,
324
+ ]);
325
+
326
+ useEffect(() => {
327
+ setHudCommand(null);
328
+ }, [contentVersion, hudEnabled]);
329
+
330
+ useEffect(() => {
331
+ const wasBlocked = wasBlockedRef.current;
332
+ wasBlockedRef.current = blocked;
333
+ if (wasBlocked && !blocked && stateRef.current.phase === 'action') {
334
+ const returnTo = stateRef.current.returnTo;
335
+ setState(returnTo);
336
+ const container = containerRef.current;
337
+ if (container) {
338
+ const graph = buildSemanticTargetGraph(container);
339
+ if (returnTo.phase === 'text' || returnTo.phase === 'visual') {
340
+ applyNativeTextSelection(
341
+ graph.container,
342
+ returnTo.cursor,
343
+ returnTo.phase === 'visual' ? returnTo.anchor : null,
344
+ );
345
+ } else if (returnTo.phase === 'visual-block') {
346
+ applyVisualBlockSelection(graph, returnTo);
347
+ }
348
+ }
349
+ }
350
+ if (wasBlocked && !blocked) {
351
+ const shouldRestoreFocus = restoreFocusRef.current;
352
+ restoreFocusRef.current = false;
353
+ if (
354
+ shouldRestoreFocus
355
+ && enabled
356
+ && (document.activeElement === document.body || document.activeElement === null)
357
+ ) {
358
+ const container = containerRef.current;
359
+ if (container) {
360
+ restoringFocusRef.current = true;
361
+ container.focus({ preventScroll: true });
362
+ }
363
+ }
364
+ }
365
+ }, [blocked, containerRef, enabled, setState]);
366
+
367
+ const setSemanticTarget = useCallback((target: SemanticTarget) => {
368
+ setState(semanticStateForTarget(target));
369
+ window.getSelection()?.removeAllRanges();
370
+ scrollVimTargetIntoView(target.element, scrollViewportRef.current);
371
+ }, [setState]);
372
+
373
+ const updateTextState = useCallback((
374
+ graph: SemanticTargetGraph,
375
+ next: VimTextState | VimVisualState,
376
+ ) => {
377
+ const targetKey = targetKeyForPosition(graph, next.targetKey, next.cursor);
378
+ const normalized = { ...next, targetKey };
379
+ setState(normalized);
380
+ applyNativeTextSelection(
381
+ graph.container,
382
+ normalized.cursor,
383
+ normalized.phase === 'visual' ? normalized.anchor : null,
384
+ );
385
+ const cursorParent = resolveTextPosition(graph.container, normalized.cursor)
386
+ ?.node.parentElement;
387
+ if (cursorParent) scrollVimTargetIntoView(cursorParent, scrollViewportRef.current);
388
+ }, [setState]);
389
+
390
+ const enterTextAtTarget = useCallback((
391
+ graph: SemanticTargetGraph,
392
+ target: SemanticTarget,
393
+ ): boolean => {
394
+ const bounds = getTextElementBounds(graph.container, target.element);
395
+ if (!bounds) return false;
396
+ updateTextState(graph, {
397
+ phase: 'text',
398
+ targetKey: target.key,
399
+ cursor: bounds.start,
400
+ });
401
+ return true;
402
+ }, [updateTextState]);
403
+
404
+ const enterVisualAtTarget = useCallback((
405
+ graph: SemanticTargetGraph,
406
+ target: SemanticTarget,
407
+ ): boolean => {
408
+ const bounds = getTextElementBounds(graph.container, target.element);
409
+ if (!bounds) return false;
410
+ updateTextState(graph, {
411
+ phase: 'visual',
412
+ targetKey: target.key,
413
+ anchor: bounds.start,
414
+ cursor: bounds.start,
415
+ });
416
+ return true;
417
+ }, [updateTextState]);
418
+
419
+ const enterVisualBlock = useCallback((
420
+ graph: SemanticTargetGraph,
421
+ target: SemanticTarget,
422
+ ): boolean => {
423
+ const block = getOwningBlockTarget(graph, target);
424
+ const next: VimVisualBlockState = {
425
+ phase: 'visual-block',
426
+ anchorTargetKey: block.key,
427
+ targetKey: block.key,
428
+ };
429
+ if (!getTextElementBounds(graph.container, block.element)) return false;
430
+ setState(next);
431
+ applyVisualBlockSelection(graph, next);
432
+ scrollVimTargetIntoView(block.element, scrollViewportRef.current);
433
+ return true;
434
+ }, [setState]);
435
+
436
+ const runTargetAction = useCallback((
437
+ target: SemanticTarget,
438
+ returnTo: VimBlockState | VimInlineState,
439
+ modeOverride?: EditorMode,
440
+ ): boolean => {
441
+ const effectiveMode = modeOverride ?? activeMode;
442
+ const beginAction = () => {
443
+ restoreFocusRef.current = effectiveMode !== 'redline';
444
+ setState(
445
+ effectiveMode !== 'redline'
446
+ ? { phase: 'action', returnTo }
447
+ : returnTo,
448
+ );
449
+ };
450
+
451
+ if (target.kind === 'code') {
452
+ beginAction();
453
+ onCodeBlockAction(target.blockId, target.element, modeOverride);
454
+ return true;
455
+ }
456
+ if (target.kind === 'math') {
457
+ beginAction();
458
+ onMathAction(target.element, modeOverride);
459
+ return true;
460
+ }
461
+ const range = createSemanticTargetRange(target);
462
+ if (!range) return false;
463
+ beginAction();
464
+ onHighlightRange(range, modeOverride);
465
+ return true;
466
+ }, [
467
+ activeMode,
468
+ onCodeBlockAction,
469
+ onHighlightRange,
470
+ onMathAction,
471
+ setState,
472
+ ]);
473
+
474
+ const runRangeAction = useCallback((
475
+ graph: SemanticTargetGraph,
476
+ range: Range,
477
+ returnTo: VimVisualState | VimVisualBlockState,
478
+ modeOverride?: EditorMode,
479
+ ): boolean => {
480
+ const effectiveMode = modeOverride ?? activeMode;
481
+ const immediateReturn: VimRestorableState = returnTo.phase === 'visual'
482
+ ? {
483
+ phase: 'text' as const,
484
+ targetKey: returnTo.targetKey,
485
+ cursor: returnTo.cursor,
486
+ }
487
+ : (() => {
488
+ const target = resolveSemanticTarget(graph, returnTo.targetKey);
489
+ return target ? semanticStateForTarget(target) : returnTo;
490
+ })();
491
+ restoreFocusRef.current = effectiveMode !== 'redline';
492
+ setState(
493
+ effectiveMode !== 'redline'
494
+ ? { phase: 'action', returnTo }
495
+ : immediateReturn,
496
+ );
497
+ onHighlightRange(range, modeOverride);
498
+ return true;
499
+ }, [activeMode, onHighlightRange, setState]);
500
+
501
+ const actionForCurrentState = useCallback((
502
+ graph: SemanticTargetGraph,
503
+ current: VimRestorableState,
504
+ modeOverride?: EditorMode,
505
+ ): boolean => {
506
+ if (current.phase === 'visual') {
507
+ const range = selectedTextRange(graph.container, current);
508
+ return range
509
+ ? runRangeAction(graph, range, current, modeOverride)
510
+ : false;
511
+ }
512
+ if (current.phase === 'visual-block') {
513
+ const range = visualBlockRange(graph, current);
514
+ return range
515
+ ? runRangeAction(graph, range, current, modeOverride)
516
+ : false;
517
+ }
518
+
519
+ const target = resolveSemanticTarget(graph, current.targetKey);
520
+ if (!target) return false;
521
+ return runTargetAction(
522
+ target,
523
+ semanticStateForTarget(target),
524
+ modeOverride,
525
+ );
526
+ }, [runRangeAction, runTargetAction]);
527
+
528
+ const copyCurrentState = useCallback((
529
+ graph: SemanticTargetGraph,
530
+ current: VimRestorableState,
531
+ ): boolean => {
532
+ if (current.phase === 'visual') {
533
+ const range = selectedTextRange(graph.container, current);
534
+ if (!range) return false;
535
+ copyTextPreservingFocus(range.toString(), graph.container);
536
+ updateTextState(graph, {
537
+ phase: 'text',
538
+ targetKey: current.targetKey,
539
+ cursor: current.cursor,
540
+ });
541
+ return true;
542
+ }
543
+ if (current.phase === 'visual-block') {
544
+ const range = visualBlockRange(graph, current);
545
+ if (!range) return false;
546
+ copyTextPreservingFocus(range.toString(), graph.container);
547
+ const target = resolveSemanticTarget(graph, current.targetKey);
548
+ if (target) setSemanticTarget(target);
549
+ return true;
550
+ }
551
+ const target = resolveSemanticTarget(graph, current.targetKey);
552
+ if (!target) return false;
553
+ copyTextPreservingFocus(
554
+ target.element.textContent?.trim() ?? '',
555
+ graph.container,
556
+ );
557
+ return true;
558
+ }, [setSemanticTarget, updateTextState]);
559
+
560
+ const handleSemanticKey = useCallback((
561
+ graph: SemanticTargetGraph,
562
+ current: VimBlockState | VimInlineState,
563
+ key: string,
564
+ ): boolean => {
565
+ const target = resolveSemanticTarget(graph, current.targetKey);
566
+ if (!target) return false;
567
+
568
+ if (key === 'j' || key === 'k' || key === '{' || key === '}') {
569
+ const previous = key === 'k' || key === '{';
570
+ setSemanticTarget(moveSemanticTarget(
571
+ graph,
572
+ target,
573
+ current.phase === 'inline' && (key === 'j' || key === 'k')
574
+ ? previous
575
+ ? 'previous-sibling'
576
+ : 'next-sibling'
577
+ : previous
578
+ ? 'previous-block'
579
+ : 'next-block',
580
+ ));
581
+ return true;
582
+ }
583
+ if (key === 'h' || key === 'H') {
584
+ const parent = moveSemanticTarget(graph, target, 'parent');
585
+ if (parent.key !== target.key) setSemanticTarget(parent);
586
+ return true;
587
+ }
588
+ if (key === 'l') {
589
+ const child = getSemanticTargetChildren(graph, target)[0];
590
+ if (!child) return enterTextAtTarget(graph, target);
591
+ setSemanticTarget(child);
592
+ return true;
593
+ }
594
+ if (key === 'v') return enterVisualAtTarget(graph, target);
595
+ if (key === 'V') return enterVisualBlock(graph, target);
596
+ if (key === 'Enter') return actionForCurrentState(graph, current);
597
+ if (key === 'y') return copyCurrentState(graph, current);
598
+ const actionMode = selectionModeForAction(key);
599
+ return actionMode
600
+ ? actionForCurrentState(graph, current, actionMode)
601
+ : false;
602
+ }, [
603
+ actionForCurrentState,
604
+ copyCurrentState,
605
+ enterTextAtTarget,
606
+ enterVisualAtTarget,
607
+ enterVisualBlock,
608
+ setSemanticTarget,
609
+ ]);
610
+
611
+ const handleTextKey = useCallback((
612
+ graph: SemanticTargetGraph,
613
+ current: VimTextState | VimVisualState,
614
+ key: string,
615
+ ): boolean => {
616
+ if (key === 'v') {
617
+ if (current.phase === 'visual') {
618
+ updateTextState(graph, {
619
+ phase: 'text',
620
+ targetKey: current.targetKey,
621
+ cursor: current.cursor,
622
+ });
623
+ } else {
624
+ updateTextState(graph, {
625
+ phase: 'visual',
626
+ targetKey: current.targetKey,
627
+ cursor: current.cursor,
628
+ anchor: current.cursor,
629
+ });
630
+ }
631
+ return true;
632
+ }
633
+ if (key === 'V') {
634
+ const target = resolveSemanticTarget(graph, current.targetKey);
635
+ return target ? enterVisualBlock(graph, target) : false;
636
+ }
637
+ if (key === 'o' && current.phase === 'visual') {
638
+ updateTextState(graph, {
639
+ ...current,
640
+ anchor: current.cursor,
641
+ cursor: current.anchor,
642
+ });
643
+ return true;
644
+ }
645
+ if (key === 'Enter') {
646
+ return current.phase === 'visual'
647
+ ? actionForCurrentState(graph, current)
648
+ : false;
649
+ }
650
+ if (key === 'y') {
651
+ return current.phase === 'visual'
652
+ ? copyCurrentState(graph, current)
653
+ : false;
654
+ }
655
+ const actionMode = selectionModeForAction(key);
656
+ if (actionMode) {
657
+ return current.phase === 'visual'
658
+ ? actionForCurrentState(graph, current, actionMode)
659
+ : false;
660
+ }
661
+ if (key === 'G') {
662
+ updateTextState(graph, {
663
+ ...current,
664
+ cursor: moveTextPosition(
665
+ graph.container,
666
+ current.cursor,
667
+ 'document-end',
668
+ scrollViewportRef.current,
669
+ ),
670
+ });
671
+ return true;
672
+ }
673
+ if (key === 'j' || key === 'k' || key === '0' || key === '$') {
674
+ const direction = key === 'j' || key === '$' ? 'forward' : 'backward';
675
+ const granularity = key === 'j' || key === 'k' ? 'line' : 'lineboundary';
676
+ const nativePosition = moveWithNativeSelection(
677
+ graph.container,
678
+ current,
679
+ direction,
680
+ granularity,
681
+ );
682
+ const fallback: VimTextMotion = key === 'j'
683
+ ? 'block-forward'
684
+ : key === 'k'
685
+ ? 'block-backward'
686
+ : key === '0'
687
+ ? 'line-start'
688
+ : 'line-end';
689
+ updateTextState(graph, {
690
+ ...current,
691
+ cursor: clampPositionToSemanticTarget(
692
+ graph,
693
+ current.targetKey,
694
+ nativePosition
695
+ ?? moveTextPosition(
696
+ graph.container,
697
+ current.cursor,
698
+ fallback,
699
+ scrollViewportRef.current,
700
+ ),
701
+ direction,
702
+ ),
703
+ });
704
+ return true;
705
+ }
706
+ const motion = motionFromTextKey(key);
707
+ if (!motion) return false;
708
+ const nextPosition = moveTextPosition(
709
+ graph.container,
710
+ current.cursor,
711
+ motion,
712
+ scrollViewportRef.current,
713
+ );
714
+ updateTextState(graph, {
715
+ ...current,
716
+ cursor: motion === 'block-backward' || motion === 'block-forward'
717
+ ? nextPosition
718
+ : clampPositionToSemanticTarget(
719
+ graph,
720
+ current.targetKey,
721
+ nextPosition,
722
+ motion === 'left' || motion === 'word-backward' ? 'backward' : 'forward',
723
+ ),
724
+ });
725
+ return true;
726
+ }, [
727
+ actionForCurrentState,
728
+ copyCurrentState,
729
+ enterVisualBlock,
730
+ updateTextState,
731
+ ]);
732
+
733
+ const handleVisualBlockKey = useCallback((
734
+ graph: SemanticTargetGraph,
735
+ current: VimVisualBlockState,
736
+ key: string,
737
+ ): boolean => {
738
+ if (key === 'V') {
739
+ const target = resolveSemanticTarget(graph, current.targetKey);
740
+ if (target) setSemanticTarget(target);
741
+ return true;
742
+ }
743
+ if (key === 'j' || key === 'k') {
744
+ const target = resolveSemanticTarget(graph, current.targetKey);
745
+ if (!target) return false;
746
+ const next = moveSemanticTarget(
747
+ graph,
748
+ target,
749
+ key === 'j' ? 'next-block' : 'previous-block',
750
+ );
751
+ const nextState: VimVisualBlockState = {
752
+ ...current,
753
+ targetKey: getOwningBlockTarget(graph, next).key,
754
+ };
755
+ setState(nextState);
756
+ applyVisualBlockSelection(graph, nextState);
757
+ scrollVimTargetIntoView(next.element, scrollViewportRef.current);
758
+ return true;
759
+ }
760
+ if (key === 'o') {
761
+ const next: VimVisualBlockState = {
762
+ phase: 'visual-block',
763
+ anchorTargetKey: current.targetKey,
764
+ targetKey: current.anchorTargetKey,
765
+ };
766
+ setState(next);
767
+ applyVisualBlockSelection(graph, next);
768
+ return true;
769
+ }
770
+ if (key === 'Enter') return actionForCurrentState(graph, current);
771
+ if (key === 'y') return copyCurrentState(graph, current);
772
+ const actionMode = selectionModeForAction(key);
773
+ return actionMode
774
+ ? actionForCurrentState(graph, current, actionMode)
775
+ : false;
776
+ }, [
777
+ actionForCurrentState,
778
+ copyCurrentState,
779
+ setSemanticTarget,
780
+ setState,
781
+ ]);
782
+
783
+ const escapeCurrentState = useCallback((
784
+ graph: SemanticTargetGraph,
785
+ current: VimSelectionState,
786
+ ) => {
787
+ switch (current.phase) {
788
+ case 'inactive':
789
+ return;
790
+ case 'action':
791
+ setState(current.returnTo);
792
+ return;
793
+ case 'visual':
794
+ updateTextState(graph, {
795
+ phase: 'text',
796
+ targetKey: current.targetKey,
797
+ cursor: current.cursor,
798
+ });
799
+ return;
800
+ case 'text': {
801
+ const target = resolveSemanticTarget(graph, current.targetKey);
802
+ if (target) setSemanticTarget(target);
803
+ return;
804
+ }
805
+ case 'visual-block': {
806
+ const target = resolveSemanticTarget(graph, current.targetKey);
807
+ if (target) setSemanticTarget(target);
808
+ return;
809
+ }
810
+ case 'inline': {
811
+ const target = resolveSemanticTarget(graph, current.targetKey);
812
+ const parent = target
813
+ ? moveSemanticTarget(graph, target, 'parent')
814
+ : null;
815
+ if (parent && parent.key !== target?.key) {
816
+ setSemanticTarget(parent);
817
+ } else {
818
+ setState(createInitialVimSelectionState());
819
+ window.getSelection()?.removeAllRanges();
820
+ }
821
+ return;
822
+ }
823
+ case 'block':
824
+ setState(createInitialVimSelectionState());
825
+ window.getSelection()?.removeAllRanges();
826
+ }
827
+ }, [setSemanticTarget, setState, updateTextState]);
828
+
829
+ const jumpToDocumentBlock = useCallback((
830
+ graph: SemanticTargetGraph,
831
+ end: boolean,
832
+ ): boolean => {
833
+ const current = stateRef.current;
834
+ if (current.phase === 'text' || current.phase === 'visual') {
835
+ updateTextState(graph, {
836
+ ...current,
837
+ cursor: moveTextPosition(
838
+ graph.container,
839
+ current.cursor,
840
+ end ? 'document-end' : 'document-start',
841
+ scrollViewportRef.current,
842
+ ),
843
+ });
844
+ return true;
845
+ }
846
+ const currentTarget = resolveSemanticTarget(graph, targetKeyForState(current))
847
+ ?? findInitialSemanticTarget(graph, scrollViewportRef.current);
848
+ if (!currentTarget) return false;
849
+ setSemanticTarget(moveSemanticTarget(
850
+ graph,
851
+ currentTarget,
852
+ end ? 'last-block' : 'first-block',
853
+ ));
854
+ return true;
855
+ }, [setSemanticTarget, updateTextState]);
856
+
857
+ const canHandleShortcut = useCallback((event: KeyboardEvent) => (
858
+ enabled
859
+ && !blocked
860
+ && focused
861
+ && !event.isComposing
862
+ && !isDocumentKeyboardControl(event.target)
863
+ && !event.ctrlKey
864
+ && !event.metaKey
865
+ && !event.altKey
866
+ ), [blocked, enabled, focused]);
867
+
868
+ const recordHudCommand = useCallback((
869
+ actionId: VimSelectionActionId,
870
+ event: KeyboardEvent,
871
+ context: VimSelectionState['phase'],
872
+ ) => {
873
+ if (!hudEnabled) return;
874
+ hudSequenceRef.current += 1;
875
+ setHudCommand(createVimHudCommand(
876
+ hudSequenceRef.current,
877
+ actionId,
878
+ event.key,
879
+ context,
880
+ ));
881
+ }, [hudEnabled]);
882
+
883
+ const onKeyDown = useCallback((
884
+ event: KeyboardEvent,
885
+ actionId: VimSelectionActionId,
886
+ ) => {
887
+ if (!canHandleShortcut(event)) return;
888
+
889
+ if (stateRef.current.phase === 'action') {
890
+ return;
891
+ }
892
+
893
+ const container = containerRef.current;
894
+ if (!container) return;
895
+ const graph = buildSemanticTargetGraph(container);
896
+ const key = event.key;
897
+ const context = stateRef.current.phase;
898
+ let handled = false;
899
+
900
+ if (helpOpen) {
901
+ if (key === 'Escape' || key === '?') {
902
+ setHelpOpen(false);
903
+ handled = true;
904
+ }
905
+ } else if (key === '?') {
906
+ setHelpOpen(true);
907
+ handled = true;
908
+ } else if (key === 'Escape') {
909
+ if (stateRef.current.phase !== 'inactive') {
910
+ escapeCurrentState(graph, stateRef.current);
911
+ handled = true;
912
+ }
913
+ } else {
914
+ let current = stateRef.current;
915
+ if (current.phase === 'inactive') {
916
+ current = initializeSemanticNavigation()
917
+ ?? createInitialVimSelectionState();
918
+ }
919
+
920
+ if (key === 'G') {
921
+ handled = jumpToDocumentBlock(graph, true);
922
+ } else if (current.phase === 'block' || current.phase === 'inline') {
923
+ handled = handleSemanticKey(graph, current, key);
924
+ } else if (current.phase === 'text' || current.phase === 'visual') {
925
+ handled = handleTextKey(graph, current, key);
926
+ } else if (current.phase === 'visual-block') {
927
+ handled = handleVisualBlockKey(graph, current, key);
928
+ }
929
+ }
930
+
931
+ if (handled) {
932
+ onHandledCommand?.();
933
+ recordHudCommand(actionId, event, context);
934
+ event.preventDefault();
935
+ event.stopPropagation();
936
+ }
937
+ }, [
938
+ canHandleShortcut,
939
+ containerRef,
940
+ escapeCurrentState,
941
+ handleSemanticKey,
942
+ handleTextKey,
943
+ handleVisualBlockKey,
944
+ helpOpen,
945
+ initializeSemanticNavigation,
946
+ jumpToDocumentBlock,
947
+ onHandledCommand,
948
+ recordHudCommand,
949
+ ]);
950
+
951
+ const onDocumentStart = useCallback((event: KeyboardEvent) => {
952
+ if (!canHandleShortcut(event) || stateRef.current.phase === 'action') return;
953
+ const container = containerRef.current;
954
+ if (!container) return;
955
+ const context = stateRef.current.phase;
956
+ if (jumpToDocumentBlock(buildSemanticTargetGraph(container), false)) {
957
+ onHandledCommand?.();
958
+ recordHudCommand('documentStart', event, context);
959
+ event.preventDefault();
960
+ event.stopPropagation();
961
+ }
962
+ }, [
963
+ canHandleShortcut,
964
+ containerRef,
965
+ jumpToDocumentBlock,
966
+ onHandledCommand,
967
+ recordHudCommand,
968
+ ]);
969
+
970
+ const shortcutHandler = (actionId: VimSelectionActionId) => ({
971
+ when: canHandleShortcut,
972
+ handle: (event: KeyboardEvent) => onKeyDown(event, actionId),
973
+ });
974
+ useVimSelectionShortcuts({
975
+ target: containerRef.current,
976
+ handlers: {
977
+ moveDown: shortcutHandler('moveDown'),
978
+ moveUp: shortcutHandler('moveUp'),
979
+ documentStart: { when: canHandleShortcut, handle: onDocumentStart },
980
+ documentEnd: shortcutHandler('documentEnd'),
981
+ moveOut: shortcutHandler('moveOut'),
982
+ refine: shortcutHandler('refine'),
983
+ visual: shortcutHandler('visual'),
984
+ visualBlock: shortcutHandler('visualBlock'),
985
+ wordForward: shortcutHandler('wordForward'),
986
+ wordBackward: shortcutHandler('wordBackward'),
987
+ wordEnd: shortcutHandler('wordEnd'),
988
+ lineStart: shortcutHandler('lineStart'),
989
+ lineEnd: shortcutHandler('lineEnd'),
990
+ previousTextBlock: shortcutHandler('previousTextBlock'),
991
+ nextTextBlock: shortcutHandler('nextTextBlock'),
992
+ swapSelectionEnds: shortcutHandler('swapSelectionEnds'),
993
+ activeAnnotation: shortcutHandler('activeAnnotation'),
994
+ annotationMenu: shortcutHandler('annotationMenu'),
995
+ comment: shortcutHandler('comment'),
996
+ redline: shortcutHandler('redline'),
997
+ markup: shortcutHandler('markup'),
998
+ label: shortcutHandler('label'),
999
+ copy: shortcutHandler('copy'),
1000
+ cancel: shortcutHandler('cancel'),
1001
+ help: shortcutHandler('help'),
1002
+ },
1003
+ });
1004
+
1005
+ const onFocus = useCallback((event: ReactFocusEvent<HTMLElement>) => {
1006
+ if (!enabled || event.target !== event.currentTarget) return;
1007
+ setFocused(true);
1008
+ }, [enabled]);
1009
+
1010
+ const onBlur = useCallback((event: ReactFocusEvent<HTMLElement>) => {
1011
+ if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
1012
+ if (
1013
+ event.relatedTarget instanceof Element
1014
+ && event.relatedTarget.closest('[data-vim-key-hud]')
1015
+ ) {
1016
+ return;
1017
+ }
1018
+ setFocused(false);
1019
+ }, []);
1020
+
1021
+ const onHudFocusLeave = useCallback(() => {
1022
+ if (containerRef.current === document.activeElement) return;
1023
+ setFocused(false);
1024
+ }, [containerRef]);
1025
+
1026
+ const focusDocument = useCallback((): boolean => {
1027
+ const container = containerRef.current;
1028
+ if (!enabled || !container) return false;
1029
+ if (document.activeElement === container) return false;
1030
+ restoringFocusRef.current = stateRef.current.phase !== 'inactive';
1031
+ container.focus({ preventScroll: true });
1032
+ return document.activeElement === container;
1033
+ }, [containerRef, enabled]);
1034
+
1035
+ useVimDocumentFocus({
1036
+ enabled,
1037
+ blocked,
1038
+ focusDocument,
1039
+ });
1040
+
1041
+ const onMouseDown = useCallback((event: ReactMouseEvent<HTMLElement>) => {
1042
+ if (!enabled || isDocumentKeyboardControl(event.target)) return;
1043
+ setState(createInitialVimSelectionState());
1044
+ window.getSelection()?.removeAllRanges();
1045
+ if (document.activeElement !== event.currentTarget) {
1046
+ pointerFocusRef.current = true;
1047
+ event.currentTarget.focus({ preventScroll: true });
1048
+ }
1049
+ }, [enabled, setState]);
1050
+
1051
+ return {
1052
+ state,
1053
+ focused,
1054
+ helpOpen,
1055
+ hudCommand,
1056
+ activeTarget,
1057
+ onFocus,
1058
+ onBlur,
1059
+ onMouseDown,
1060
+ onHelpOpenChange: setHelpOpen,
1061
+ onHudFocusLeave,
1062
+ };
1063
+ }