@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
|
@@ -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
|
+
}
|