@plannotator/ui 0.28.0 → 0.29.1

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 (105) hide show
  1. package/README.md +6 -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/MenuVersionSection.tsx +4 -4
  19. package/components/MermaidBlock.tsx +1 -1
  20. package/components/ModeToggle.tsx +7 -6
  21. package/components/OpenInAppButton.tsx +2 -5
  22. package/components/PinpointOverlay.tsx +9 -7
  23. package/components/PlanHeaderMenu.tsx +8 -8
  24. package/components/PopoutDialog.tsx +6 -1
  25. package/components/ResizeHandle.tsx +1 -0
  26. package/components/Settings.tsx +172 -12
  27. package/components/SkillReferenceMenu.tsx +260 -0
  28. package/components/StickyHeaderLane.tsx +7 -0
  29. package/components/ThemeProvider.tsx +131 -32
  30. package/components/ThemeTab.tsx +123 -77
  31. package/components/ToolbarButtons.tsx +29 -8
  32. package/components/Viewer.tsx +396 -130
  33. package/components/VimKeyHud.tsx +695 -0
  34. package/components/VimModeAnnouncementDialog.tsx +557 -0
  35. package/components/VimModeOverlay.tsx +235 -0
  36. package/components/VimTargetReticle.tsx +284 -0
  37. package/components/ai/DocumentAIChatPanel.tsx +1 -1
  38. package/components/blocks/CodeBlock.tsx +18 -18
  39. package/components/blocks/TablePopout.tsx +7 -8
  40. package/components/blocks/TableToolbar.tsx +7 -8
  41. package/components/goal-setup/GoalSetupSurface.tsx +16 -3
  42. package/components/html-viewer/HtmlViewer.tsx +450 -47
  43. package/components/html-viewer/annotationNumbering.ts +37 -0
  44. package/components/html-viewer/bridge-script.ts +4051 -298
  45. package/components/html-viewer/composerYield.ts +51 -0
  46. package/components/html-viewer/srcdoc.ts +18 -3
  47. package/components/html-viewer/useHtmlAnnotation.ts +457 -32
  48. package/components/icons/themeIcons.tsx +1 -1
  49. package/components/plan-diff/PlanCleanDiffView.tsx +9 -9
  50. package/components/plan-diff/PlanDiffBadge.tsx +22 -1
  51. package/components/settings/HooksTab.tsx +12 -8
  52. package/components/sidebar/FileBrowser.tsx +4 -1
  53. package/components/themeModes.tsx +28 -0
  54. package/config/configStore.ts +76 -1
  55. package/config/settings.ts +152 -0
  56. package/configure.ts +9 -0
  57. package/globals.d.ts +7 -1
  58. package/hooks/useAIChat.ts +5 -2
  59. package/hooks/useAIProviderActivation.ts +47 -0
  60. package/hooks/useAIProviderConfig.ts +5 -1
  61. package/hooks/useAgentSettings.ts +64 -23
  62. package/hooks/useAgents.ts +4 -4
  63. package/hooks/useAnnotationHighlighter.ts +100 -3
  64. package/hooks/useArchive.ts +2 -1
  65. package/hooks/useFenceTheme.ts +17 -0
  66. package/hooks/useLinkedDoc.ts +68 -1
  67. package/hooks/usePinpoint.ts +76 -75
  68. package/hooks/usePlanDiff.ts +73 -2
  69. package/hooks/useSkillReferenceAutocomplete.ts +239 -0
  70. package/hooks/useUpdateCheck.ts +1 -2
  71. package/hooks/useVimDocumentFocus.ts +116 -0
  72. package/hooks/useVimSelection.ts +1063 -0
  73. package/package.json +4 -4
  74. package/print.css +14 -13
  75. package/shortcuts/core.ts +38 -13
  76. package/shortcuts/index.ts +10 -0
  77. package/shortcuts/plan-review/commentPopover.shortcuts.ts +7 -0
  78. package/shortcuts/plan-review/vimSelection.shortcuts.ts +251 -0
  79. package/shortcuts/runtime.ts +111 -12
  80. package/styles.css +1 -1
  81. package/theme.css +504 -0
  82. package/themes/colorblind.css +89 -0
  83. package/themes/plannotator.css +2 -2
  84. package/types.ts +93 -10
  85. package/utils/agentSwitch.ts +33 -7
  86. package/utils/blockTargeting.ts +462 -178
  87. package/utils/clipboard.ts +110 -0
  88. package/utils/codeBlockMark.ts +50 -0
  89. package/utils/codeHighlight.ts +293 -0
  90. package/utils/codexModels.ts +79 -0
  91. package/utils/domSelection.ts +84 -0
  92. package/utils/htmlChrome.ts +73 -0
  93. package/utils/inputMethod.ts +79 -6
  94. package/utils/parser.ts +517 -21
  95. package/utils/preferenceTtl.ts +15 -0
  96. package/utils/sharing.ts +0 -1
  97. package/utils/skillCatalog.ts +269 -0
  98. package/utils/skillReferences.ts +475 -0
  99. package/utils/syntaxTheme.ts +83 -0
  100. package/utils/themeRegistry.ts +154 -0
  101. package/utils/vimHud.ts +263 -0
  102. package/utils/vimModeAnnouncement.ts +23 -0
  103. package/utils/vimNavigation.ts +417 -0
  104. package/utils/vimReticle.ts +88 -0
  105. package/utils/vimScroll.ts +162 -0
@@ -0,0 +1,417 @@
1
+ import { getAnnotatableTextNodes } from './domSelection';
2
+
3
+ /** A durable cursor location measured within one rendered Markdown block. */
4
+ export interface VimTextPosition {
5
+ readonly blockId: string;
6
+ readonly textOffset: number;
7
+ /**
8
+ * Resolve an offset shared by adjacent text nodes toward the next or previous
9
+ * node. Omitted positions preserve the historical backward affinity.
10
+ */
11
+ readonly affinity?: 'forward' | 'backward';
12
+ }
13
+
14
+ /** Text movement commands understood by the Markdown Vim adapter. */
15
+ export type VimTextMotion =
16
+ | 'left'
17
+ | 'right'
18
+ | 'word-forward'
19
+ | 'word-backward'
20
+ | 'word-end'
21
+ | 'line-start'
22
+ | 'line-end'
23
+ | 'block-backward'
24
+ | 'block-forward'
25
+ | 'document-start'
26
+ | 'document-end';
27
+
28
+ /** Block-level keyboard navigation with no browser text selection. */
29
+ export interface VimBlockState {
30
+ readonly phase: 'block';
31
+ readonly targetKey: string;
32
+ }
33
+
34
+ /** A refined semantic child such as inline code, a table row, or a cell. */
35
+ export interface VimInlineState {
36
+ readonly phase: 'inline';
37
+ readonly targetKey: string;
38
+ }
39
+
40
+ /** A collapsed text cursor inside the current semantic target. */
41
+ export interface VimTextState {
42
+ readonly phase: 'text';
43
+ readonly targetKey: string;
44
+ readonly cursor: VimTextPosition;
45
+ }
46
+
47
+ /** A characterwise browser selection anchored inside rendered text. */
48
+ export interface VimVisualState {
49
+ readonly phase: 'visual';
50
+ readonly targetKey: string;
51
+ readonly cursor: VimTextPosition;
52
+ readonly anchor: VimTextPosition;
53
+ }
54
+
55
+ /** A whole-block selection extending through block-navigation order. */
56
+ export interface VimVisualBlockState {
57
+ readonly phase: 'visual-block';
58
+ readonly targetKey: string;
59
+ readonly anchorTargetKey: string;
60
+ }
61
+
62
+ /** Any Vim state that can be restored after an annotation UI closes. */
63
+ export type VimRestorableState =
64
+ | VimBlockState
65
+ | VimInlineState
66
+ | VimTextState
67
+ | VimVisualState
68
+ | VimVisualBlockState;
69
+
70
+ /** Annotation UI owns the keyboard while this state is active. */
71
+ export interface VimActionState {
72
+ readonly phase: 'action';
73
+ readonly returnTo: VimRestorableState;
74
+ }
75
+
76
+ /**
77
+ * Complete Vim navigation state.
78
+ *
79
+ * The discriminated union prevents semantic focus, text cursors, and visual
80
+ * anchors from existing in contradictory combinations.
81
+ */
82
+ export type VimSelectionState =
83
+ | { readonly phase: 'inactive' }
84
+ | VimRestorableState
85
+ | VimActionState;
86
+
87
+ /** Return a fresh state with no semantic or text target. */
88
+ export function createInitialVimSelectionState(): { readonly phase: 'inactive' } {
89
+ return { phase: 'inactive' };
90
+ }
91
+
92
+ function getBlockElements(container: HTMLElement): HTMLElement[] {
93
+ const seen = new Set<string>();
94
+ const result: HTMLElement[] = [];
95
+
96
+ container.querySelectorAll<HTMLElement>('[data-block-id]').forEach((element) => {
97
+ const blockId = element.dataset.blockId;
98
+ if (!blockId || seen.has(blockId) || element.tagName === 'HR') return;
99
+ seen.add(blockId);
100
+ result.push(element);
101
+ });
102
+
103
+ return result;
104
+ }
105
+
106
+ function getBlockText(block: HTMLElement): string {
107
+ return getAnnotatableTextNodes(block).map((node) => node.data).join('');
108
+ }
109
+
110
+ function getPositionAtBlockBoundary(
111
+ block: HTMLElement,
112
+ boundary: 'start' | 'end',
113
+ ): VimTextPosition | null {
114
+ const blockId = block.dataset.blockId;
115
+ if (!blockId) return null;
116
+ const textLength = getBlockText(block).length;
117
+ if (textLength === 0) return null;
118
+ return {
119
+ blockId,
120
+ textOffset: boundary === 'start' ? 0 : textLength,
121
+ };
122
+ }
123
+
124
+ function findBlock(container: HTMLElement, blockId: string): HTMLElement | null {
125
+ for (const element of getBlockElements(container)) {
126
+ if (element.dataset.blockId === blockId) return element;
127
+ }
128
+ return null;
129
+ }
130
+
131
+ function getViewportCenterY(
132
+ container: HTMLElement,
133
+ scrollViewport?: HTMLElement | null,
134
+ ): number {
135
+ const rect = (scrollViewport ?? container).getBoundingClientRect();
136
+ return rect.top + rect.height / 2;
137
+ }
138
+
139
+ /** Pick the first text cursor nearest the visible center of the document. */
140
+ export function findInitialTextPosition(
141
+ container: HTMLElement,
142
+ scrollViewport?: HTMLElement | null,
143
+ ): VimTextPosition | null {
144
+ const centerY = getViewportCenterY(container, scrollViewport);
145
+ const candidates = getBlockElements(container)
146
+ .map((block) => ({ block, position: getPositionAtBlockBoundary(block, 'start') }))
147
+ .filter((candidate): candidate is { block: HTMLElement; position: VimTextPosition } =>
148
+ candidate.position !== null,
149
+ );
150
+
151
+ candidates.sort((left, right) => {
152
+ const leftRect = left.block.getBoundingClientRect();
153
+ const rightRect = right.block.getBoundingClientRect();
154
+ const leftDistance = Math.abs((leftRect.top + leftRect.bottom) / 2 - centerY);
155
+ const rightDistance = Math.abs((rightRect.top + rightRect.bottom) / 2 - centerY);
156
+ return leftDistance - rightDistance;
157
+ });
158
+
159
+ return candidates[0]?.position ?? null;
160
+ }
161
+
162
+ /** Resolve a logical Vim position into a live text-node DOM point. */
163
+ export function resolveTextPosition(
164
+ container: HTMLElement,
165
+ position: VimTextPosition,
166
+ ): { node: Text; offset: number } | null {
167
+ const block = findBlock(container, position.blockId);
168
+ if (!block) return null;
169
+
170
+ const nodes = getAnnotatableTextNodes(block);
171
+ if (nodes.length === 0) return null;
172
+
173
+ let remaining = Math.max(0, position.textOffset);
174
+ for (const [index, node] of nodes.entries()) {
175
+ if (remaining < node.length) {
176
+ return { node, offset: remaining };
177
+ }
178
+ if (remaining === node.length) {
179
+ if (position.affinity === 'forward' && index < nodes.length - 1) {
180
+ remaining = 0;
181
+ continue;
182
+ }
183
+ return { node, offset: remaining };
184
+ }
185
+ remaining -= node.length;
186
+ }
187
+
188
+ const lastNode = nodes[nodes.length - 1];
189
+ return { node: lastNode, offset: lastNode.length };
190
+ }
191
+
192
+ function normalizeDomPoint(
193
+ node: Node,
194
+ offset: number,
195
+ ): { node: Text; offset: number } | null {
196
+ if (node instanceof Text) {
197
+ return { node, offset: Math.max(0, Math.min(offset, node.length)) };
198
+ }
199
+ if (!(node instanceof Element)) return null;
200
+
201
+ // A DOM point whose offset equals `childNodes.length` sits after the final
202
+ // child. Treating it as the start of that child moves a line/document-end
203
+ // cursor backward when Selection.modify() returns an element endpoint.
204
+ const childAtOffset = offset < node.childNodes.length
205
+ ? node.childNodes[Math.max(0, offset)]
206
+ : undefined;
207
+ if (childAtOffset) {
208
+ const nextWalker = document.createTreeWalker(childAtOffset, NodeFilter.SHOW_TEXT);
209
+ const nextCandidate = childAtOffset instanceof Text
210
+ ? childAtOffset
211
+ : nextWalker.nextNode();
212
+ const nextText = nextCandidate instanceof Text ? nextCandidate : null;
213
+ if (nextText) return { node: nextText, offset: 0 };
214
+ }
215
+
216
+ const previousChild = node.childNodes[Math.max(0, offset - 1)];
217
+ if (!previousChild) return null;
218
+ const previousTexts = previousChild instanceof Text
219
+ ? [previousChild]
220
+ : previousChild instanceof Element
221
+ ? getAnnotatableTextNodes(previousChild)
222
+ : [];
223
+ const previousText = previousTexts[previousTexts.length - 1];
224
+ return previousText ? { node: previousText, offset: previousText.length } : null;
225
+ }
226
+
227
+ /** Serialize a live DOM selection point back into a durable block-relative position. */
228
+ export function serializeTextPosition(
229
+ container: HTMLElement,
230
+ node: Node,
231
+ offset: number,
232
+ ): VimTextPosition | null {
233
+ const point = normalizeDomPoint(node, offset);
234
+ if (!point) return null;
235
+
236
+ const block = point.node.parentElement?.closest<HTMLElement>('[data-block-id]');
237
+ const blockId = block?.dataset.blockId;
238
+ if (!block || !blockId || !container.contains(block)) return null;
239
+
240
+ let textOffset = 0;
241
+ for (const textNode of getAnnotatableTextNodes(block)) {
242
+ if (textNode === point.node) {
243
+ return {
244
+ blockId,
245
+ textOffset: textOffset + Math.max(0, Math.min(point.offset, textNode.length)),
246
+ };
247
+ }
248
+ textOffset += textNode.length;
249
+ }
250
+ return null;
251
+ }
252
+
253
+ function compareTextPositions(
254
+ container: HTMLElement,
255
+ left: VimTextPosition,
256
+ right: VimTextPosition,
257
+ ): number {
258
+ if (left.blockId === right.blockId) return left.textOffset - right.textOffset;
259
+ const blocks = getBlockElements(container);
260
+ const leftIndex = blocks.findIndex((block) => block.dataset.blockId === left.blockId);
261
+ const rightIndex = blocks.findIndex((block) => block.dataset.blockId === right.blockId);
262
+ return leftIndex - rightIndex;
263
+ }
264
+
265
+ /** Build a live range between two logical positions regardless of selection direction. */
266
+ export function createRangeBetweenTextPositions(
267
+ container: HTMLElement,
268
+ anchor: VimTextPosition,
269
+ cursor: VimTextPosition,
270
+ ): Range | null {
271
+ const anchorPoint = resolveTextPosition(container, anchor);
272
+ const cursorPoint = resolveTextPosition(container, cursor);
273
+ if (!anchorPoint || !cursorPoint) return null;
274
+
275
+ const anchorFirst = compareTextPositions(container, anchor, cursor) <= 0;
276
+ const start = anchorFirst ? anchorPoint : cursorPoint;
277
+ const end = anchorFirst ? cursorPoint : anchorPoint;
278
+ const range = document.createRange();
279
+ range.setStart(start.node, start.offset);
280
+ range.setEnd(end.node, end.offset);
281
+ return range;
282
+ }
283
+
284
+ /**
285
+ * Return durable text bounds for a semantic target element.
286
+ *
287
+ * Bounds may span multiple Markdown blocks for group targets.
288
+ */
289
+ export function getTextElementBounds(
290
+ container: HTMLElement,
291
+ element: HTMLElement,
292
+ ): { start: VimTextPosition; end: VimTextPosition } | null {
293
+ const nodes = getAnnotatableTextNodes(element);
294
+ const first = nodes[0];
295
+ const last = nodes.at(-1);
296
+ if (!first || !last) return null;
297
+ const start = serializeTextPosition(container, first, 0);
298
+ const end = serializeTextPosition(container, last, last.length);
299
+ return start && end
300
+ ? {
301
+ start: { ...start, affinity: 'forward' },
302
+ end: { ...end, affinity: 'backward' },
303
+ }
304
+ : null;
305
+ }
306
+
307
+ /** Show a logical cursor or visual range through the browser Selection API. */
308
+ export function applyNativeTextSelection(
309
+ container: HTMLElement,
310
+ cursor: VimTextPosition,
311
+ anchor: VimTextPosition | null,
312
+ ): Selection | null {
313
+ const cursorPoint = resolveTextPosition(container, cursor);
314
+ if (!cursorPoint) return null;
315
+
316
+ const selection = window.getSelection();
317
+ selection?.removeAllRanges();
318
+ if (!selection) return null;
319
+
320
+ if (!anchor) {
321
+ const range = document.createRange();
322
+ range.setStart(cursorPoint.node, cursorPoint.offset);
323
+ range.collapse(true);
324
+ selection.addRange(range);
325
+ return selection;
326
+ }
327
+
328
+ const anchorPoint = resolveTextPosition(container, anchor);
329
+ if (!anchorPoint) return null;
330
+ selection.setBaseAndExtent(
331
+ anchorPoint.node,
332
+ anchorPoint.offset,
333
+ cursorPoint.node,
334
+ cursorPoint.offset,
335
+ );
336
+ return selection;
337
+ }
338
+
339
+ function segmentBoundaries(text: string, granularity: 'grapheme' | 'word'): number[] {
340
+ const segmenter = new Intl.Segmenter(undefined, { granularity });
341
+ return Array.from(segmenter.segment(text), (segment) => segment.index);
342
+ }
343
+
344
+ function moveWithinBlock(
345
+ text: string,
346
+ offset: number,
347
+ motion: VimTextMotion,
348
+ ): number | null {
349
+ const safeOffset = Math.max(0, Math.min(offset, text.length));
350
+
351
+ if (motion === 'left' || motion === 'right') {
352
+ const boundaries = [...segmentBoundaries(text, 'grapheme'), text.length];
353
+ if (motion === 'left') {
354
+ return boundaries.filter((boundary) => boundary < safeOffset).at(-1) ?? null;
355
+ }
356
+ return boundaries.find((boundary) => boundary > safeOffset) ?? null;
357
+ }
358
+
359
+ if (
360
+ motion === 'word-forward'
361
+ || motion === 'word-backward'
362
+ || motion === 'word-end'
363
+ ) {
364
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
365
+ const words = Array.from(segmenter.segment(text)).filter((segment) => segment.isWordLike);
366
+ if (motion === 'word-backward') {
367
+ return words.filter((word) => word.index < safeOffset).at(-1)?.index ?? null;
368
+ }
369
+ if (motion === 'word-end') {
370
+ const word = words.find((candidate) => candidate.index + candidate.segment.length > safeOffset);
371
+ return word ? word.index + word.segment.length : null;
372
+ }
373
+ return words.find((word) => word.index > safeOffset)?.index ?? null;
374
+ }
375
+
376
+ if (motion === 'line-start') return 0;
377
+ if (motion === 'line-end') return text.length;
378
+ return null;
379
+ }
380
+
381
+ /** Move a logical text cursor without retaining stale DOM-node references. */
382
+ export function moveTextPosition(
383
+ container: HTMLElement,
384
+ position: VimTextPosition,
385
+ motion: VimTextMotion,
386
+ scrollViewport?: HTMLElement | null,
387
+ ): VimTextPosition {
388
+ const currentBlock = findBlock(container, position.blockId);
389
+ if (!currentBlock) return findInitialTextPosition(container, scrollViewport) ?? position;
390
+ const currentText = getBlockText(currentBlock);
391
+ const nextOffset = moveWithinBlock(currentText, position.textOffset, motion);
392
+ if (nextOffset !== null) {
393
+ return { blockId: position.blockId, textOffset: nextOffset };
394
+ }
395
+
396
+ const blocks = getBlockElements(container).filter((block) => getBlockText(block).length > 0);
397
+ if (blocks.length === 0) return position;
398
+ if (motion === 'document-start') {
399
+ return getPositionAtBlockBoundary(blocks[0], 'start') ?? position;
400
+ }
401
+ if (motion === 'document-end') {
402
+ return getPositionAtBlockBoundary(blocks[blocks.length - 1], 'end') ?? position;
403
+ }
404
+
405
+ const blockIndex = blocks.indexOf(currentBlock);
406
+ if (blockIndex < 0) return findInitialTextPosition(container, scrollViewport) ?? position;
407
+ if (motion === 'block-backward' || motion === 'block-forward') {
408
+ const delta = motion === 'block-backward' ? -1 : 1;
409
+ const nextBlock = blocks[Math.max(0, Math.min(blocks.length - 1, blockIndex + delta))];
410
+ return getPositionAtBlockBoundary(nextBlock, 'start') ?? position;
411
+ }
412
+
413
+ const direction = motion === 'left' || motion === 'word-backward' ? -1 : 1;
414
+ const adjacent = blocks[blockIndex + direction];
415
+ if (!adjacent) return position;
416
+ return getPositionAtBlockBoundary(adjacent, direction < 0 ? 'end' : 'start') ?? position;
417
+ }
@@ -0,0 +1,88 @@
1
+ import type { SemanticTarget } from './blockTargeting';
2
+ import type { VimRestorableState } from './vimNavigation';
3
+ import type { VimHudCommand } from './vimHud';
4
+
5
+ const CURSOR_DESCRIPTORS: Partial<
6
+ Record<VimHudCommand['actionId'], string>
7
+ > = {
8
+ moveDown: 'NEXT LINE',
9
+ moveUp: 'PREVIOUS LINE',
10
+ lineStart: 'LINE START',
11
+ lineEnd: 'LINE END',
12
+ wordForward: 'NEXT WORD',
13
+ wordBackward: 'PREVIOUS WORD',
14
+ wordEnd: 'WORD END',
15
+ previousTextBlock: 'PREVIOUS TEXT',
16
+ nextTextBlock: 'NEXT TEXT',
17
+ documentStart: 'DOCUMENT START',
18
+ documentEnd: 'DOCUMENT END',
19
+ };
20
+
21
+ const VISUAL_DESCRIPTORS: Partial<
22
+ Record<VimHudCommand['actionId'], string>
23
+ > = {
24
+ visual: 'RANGE START',
25
+ visualBlock: 'BLOCK RANGE',
26
+ wordForward: 'NEXT WORD',
27
+ wordBackward: 'PREVIOUS WORD',
28
+ wordEnd: 'EXACT TOKEN',
29
+ lineStart: 'TO LINE START',
30
+ lineEnd: 'TO LINE END',
31
+ moveDown: 'NEXT LINE',
32
+ moveUp: 'PREVIOUS LINE',
33
+ previousTextBlock: 'PREVIOUS BLOCK',
34
+ nextTextBlock: 'NEXT BLOCK',
35
+ swapSelectionEnds: 'SWAPPED ENDS',
36
+ };
37
+
38
+ type VimReticleSemanticTarget = Pick<SemanticTarget, 'kind' | 'label'>;
39
+
40
+ function semanticDescriptor(target: VimReticleSemanticTarget): string {
41
+ switch (target.kind) {
42
+ case 'code':
43
+ return 'CODE';
44
+ case 'math':
45
+ return 'FORMULA';
46
+ case 'table':
47
+ return 'TABLE';
48
+ case 'row':
49
+ return 'ROW';
50
+ case 'cell':
51
+ return 'CELL';
52
+ case 'group':
53
+ return target.label.toUpperCase();
54
+ case 'inline':
55
+ case 'block':
56
+ return (target.label.split(':')[0] || target.kind).toUpperCase();
57
+ }
58
+ }
59
+
60
+ function cursorDescriptor(command: VimHudCommand | null): string {
61
+ if (!command) return 'TEXT';
62
+ if (command.actionId === 'moveOut' || command.actionId === 'refine') {
63
+ const characterMotion = command.context === 'text' || command.context === 'visual';
64
+ if (command.actionId === 'moveOut') {
65
+ return characterMotion ? 'PREVIOUS CHARACTER' : 'TEXT';
66
+ }
67
+ return characterMotion ? 'NEXT CHARACTER' : 'INLINE TEXT';
68
+ }
69
+ return CURSOR_DESCRIPTORS[command.actionId] ?? 'TEXT';
70
+ }
71
+
72
+ /** Build the concise target label shared with the approved Vim demo. */
73
+ export function getVimReticleLabel(
74
+ state: VimRestorableState,
75
+ target: VimReticleSemanticTarget | null,
76
+ command: VimHudCommand | null,
77
+ ): string {
78
+ if (state.phase === 'text') return `CURSOR · ${cursorDescriptor(command)}`;
79
+ if (state.phase === 'visual') {
80
+ return `VISUAL · ${
81
+ command ? VISUAL_DESCRIPTORS[command.actionId] ?? 'RANGE' : 'RANGE'
82
+ }`;
83
+ }
84
+ if (state.phase === 'visual-block') return 'VISUAL · BLOCK RANGE';
85
+ return `${state.phase === 'inline' ? 'INLINE' : 'BLOCK'} · ${
86
+ target ? semanticDescriptor(target) : 'TARGET'
87
+ }`;
88
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Keep the Vim cursor clear of the HUD bands that hug the viewport edges.
3
+ *
4
+ * `Element.scrollIntoView({ block: 'nearest' })` parks a target flush against
5
+ * the nearest viewport edge — exactly where the sticky action bar (top) and the
6
+ * key HUD / status pill (bottom) float. Keyboard motion then lands the caret
7
+ * behind an overlay, while a mouse wheel (which the browser lets overshoot)
8
+ * keeps the same text nearer the centre. These helpers reproduce that mouse
9
+ * feel: a target inside the safe band never scrolls, and one that strays into a
10
+ * HUD band is revealed with a margin instead of being pinned to the edge.
11
+ */
12
+
13
+ /** A viewport's vertical geometry, relative to the page. */
14
+ export interface VimScrollViewportRect {
15
+ readonly top: number;
16
+ readonly height: number;
17
+ }
18
+
19
+ /** A target's vertical extent, relative to the page. */
20
+ export interface VimScrollTargetRect {
21
+ readonly top: number;
22
+ readonly bottom: number;
23
+ }
24
+
25
+ /** The occluded strips to keep the caret out of, measured from each edge. */
26
+ export interface VimScrollBand {
27
+ readonly topMargin: number;
28
+ readonly bottomMargin: number;
29
+ }
30
+
31
+ /** Fraction of the viewport height reserved as a HUD band at each edge. */
32
+ export const VIM_SCROLL_MARGIN_RATIO = 0.2;
33
+ /** Lower clamp so short viewports still leave a usable margin. */
34
+ export const VIM_SCROLL_MARGIN_MIN = 24;
35
+ /** Upper clamp so tall viewports do not reserve most of the screen. */
36
+ export const VIM_SCROLL_MARGIN_MAX = 160;
37
+
38
+ /**
39
+ * How far to move `scrollTop` so `target` clears the HUD bands.
40
+ *
41
+ * Returns a signed delta (negative scrolls up, positive scrolls down) or `0`
42
+ * when the target already sits inside the safe band. A target taller than the
43
+ * band is aligned to its top edge — reading order wins, so the start of the
44
+ * block is never pushed above the top margin to chase its bottom.
45
+ */
46
+ export function computeVimScrollDelta(
47
+ viewport: VimScrollViewportRect,
48
+ target: VimScrollTargetRect,
49
+ band: VimScrollBand,
50
+ ): number {
51
+ const relativeTop = target.top - viewport.top;
52
+ const relativeBottom = target.bottom - viewport.top;
53
+ const safeTop = band.topMargin;
54
+ const safeBottom = viewport.height - band.bottomMargin;
55
+
56
+ // Behind the top HUD → scroll up just enough to reach the top margin.
57
+ if (relativeTop < safeTop) return relativeTop - safeTop;
58
+
59
+ // Behind the bottom HUD → scroll down, but never past the point where the
60
+ // target's top would slip under the top margin.
61
+ if (relativeBottom > safeBottom) {
62
+ const bottomDelta = relativeBottom - safeBottom;
63
+ const topRoom = relativeTop - safeTop;
64
+ return Math.min(bottomDelta, Math.max(0, topRoom));
65
+ }
66
+
67
+ return 0;
68
+ }
69
+
70
+ /** Resolve the ratio-based HUD margin, clamped for very short or tall viewports. */
71
+ export function resolveVimScrollMargin(viewportHeight: number): number {
72
+ return Math.min(
73
+ Math.max(viewportHeight * VIM_SCROLL_MARGIN_RATIO, VIM_SCROLL_MARGIN_MIN),
74
+ VIM_SCROLL_MARGIN_MAX,
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Top edge of the lowest floating Vim HUD, or `undefined` when none is shown.
80
+ *
81
+ * The key HUD and the mode badge are portaled to `document.body`, outside the
82
+ * scroll viewport's subtree, so the query is necessarily document-wide. It is
83
+ * still scoped to `element.ownerDocument` (never the global `document`) so a
84
+ * host mounted inside another document measures its own HUD, and because both
85
+ * widgets are fixed-position singletons, a host mounting two viewers in one
86
+ * document gets the same band geometry from either instance.
87
+ *
88
+ * The expanded key HUD is deliberately skipped: it is a modal state that can
89
+ * stand taller than the viewport, so no band could clear it — scrolling keeps
90
+ * the ratio margin until the user collapses it.
91
+ */
92
+ function vimHudBandTop(element: HTMLElement): number | undefined {
93
+ const doc = element.ownerDocument;
94
+ const keyHud = doc.querySelector<HTMLElement>('[data-vim-key-hud]');
95
+ const badge = doc.querySelector<HTMLElement>('[data-vim-mode-badge]');
96
+ const tops: number[] = [];
97
+ if (keyHud && keyHud.getAttribute('data-expanded') !== 'true') {
98
+ const rect = keyHud.getBoundingClientRect();
99
+ if (rect.height > 0) tops.push(rect.top);
100
+ }
101
+ if (badge) {
102
+ const rect = badge.getBoundingClientRect();
103
+ if (rect.height > 0) tops.push(rect.top);
104
+ }
105
+ return tops.length > 0 ? Math.min(...tops) : undefined;
106
+ }
107
+
108
+ /**
109
+ * Scroll `element` into view while keeping it clear of the Vim HUD bands.
110
+ *
111
+ * `scrollViewport` is the element that actually scrolls — the caller passes the
112
+ * value it already holds from ScrollViewportContext (the same node the reticle
113
+ * measures against), because the native-scroll host carries no attribute that
114
+ * would rediscover it. When it is absent the helper falls back to the
115
+ * historical `scrollIntoView({ block: 'nearest' })`, so behaviour never
116
+ * regresses.
117
+ *
118
+ * Both bands are measured from live geometry rather than guessed constants:
119
+ * the top band clears the sticky action bar when present, and the bottom band
120
+ * clears the floating key HUD / mode pill, so the caret is never parked behind
121
+ * either overlay — and the default pill configuration no longer reserves the
122
+ * full ratio band for a 25px widget.
123
+ */
124
+ export function scrollVimTargetIntoView(
125
+ element: HTMLElement,
126
+ scrollViewport?: HTMLElement | null,
127
+ ): void {
128
+ const viewport = scrollViewport ?? null;
129
+ if (!viewport) {
130
+ element.scrollIntoView({ block: 'nearest' });
131
+ return;
132
+ }
133
+
134
+ const viewportRect = viewport.getBoundingClientRect();
135
+ const targetRect = element.getBoundingClientRect();
136
+ if (targetRect.height === 0 && targetRect.width === 0) return;
137
+
138
+ const margin = resolveVimScrollMargin(viewport.clientHeight);
139
+ const stickyBottom = viewport
140
+ .querySelector<HTMLElement>('[data-sticky-actions]')
141
+ ?.getBoundingClientRect().bottom;
142
+ const topMargin = stickyBottom !== undefined
143
+ ? Math.max(margin, stickyBottom - viewportRect.top + 8)
144
+ : margin;
145
+
146
+ // Mirror of the top band: keep the caret above the floating HUD by the same
147
+ // 8px gap. The HUD rect wins over the ratio band whenever it is larger (the
148
+ // key HUD needs ~238px, well past the 160px clamp) and is allowed to shrink
149
+ // past it when only the small mode pill floats (floor: the minimum margin).
150
+ const hudTop = vimHudBandTop(element);
151
+ const bottomMargin = hudTop !== undefined
152
+ ? Math.max(VIM_SCROLL_MARGIN_MIN, viewportRect.bottom - hudTop + 8)
153
+ : margin;
154
+
155
+ const delta = computeVimScrollDelta(
156
+ { top: viewportRect.top, height: viewport.clientHeight },
157
+ { top: targetRect.top, bottom: targetRect.bottom },
158
+ { topMargin, bottomMargin },
159
+ );
160
+ if (delta === 0) return;
161
+ viewport.scrollTop += delta;
162
+ }