@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
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Block Targeting — resolves which element to annotate in pinpoint mode.
2
+ * Semantic document targeting shared by pointer Pinpoint and Vim navigation.
3
3
  *
4
- * Walks from the element under the cursor upward through the block tree
5
- * to find the most specific targetable element (inline, cell, or block).
4
+ * The graph is rebuilt from the live rendered document whenever a consumer
5
+ * needs it. Callers persist stable keys, never DOM nodes, across renders.
6
6
  */
7
+ import { createTextRange } from './domSelection';
7
8
 
8
- /** Elements that should never be targeted */
9
+ /** Elements that never participate in document targeting. */
9
10
  const SKIP_SELECTORS = [
10
11
  '.annotation-toolbar',
11
12
  '.annotation-highlight',
@@ -14,227 +15,510 @@ const SKIP_SELECTORS = [
14
15
  '[data-pinpoint-ignore]',
15
16
  ].join(',');
16
17
 
17
- /** Inline elements that are individually targetable within a block */
18
- const INLINE_TARGETS = new Set(['STRONG', 'EM', 'A']);
18
+ const INLINE_TARGET_SELECTOR = 'strong,em,a,code:not(.pn-code)';
19
+ const TABLE_EDGE_ZONE = 22;
19
20
 
20
- /** Table cell elements */
21
- const CELL_TARGETS = new Set(['TD', 'TH']);
21
+ /** The semantic kind of a document target. */
22
+ export type SemanticTargetKind =
23
+ | 'group'
24
+ | 'block'
25
+ | 'inline'
26
+ | 'table'
27
+ | 'row'
28
+ | 'cell'
29
+ | 'code'
30
+ | 'math';
22
31
 
23
- export interface PinpointTarget {
24
- /** The DOM element to highlight and select */
25
- element: HTMLElement;
26
- /** The data-block-id of the parent block */
27
- blockId: string;
28
- /** Human-readable label for the hover tooltip */
29
- label: string;
30
- /** Whether this is a code block (needs special annotation path) */
31
- isCodeBlock: boolean;
32
- }
33
-
34
- /** Edge-zone threshold for table hover (px). Covers the outermost cell padding
35
- * area (cells have px-3/py-2 = 12px/8px padding) so you need to aim at actual
36
- * text content to target a specific cell. */
37
- const TABLE_EDGE_ZONE = 22;
32
+ /** A stable semantic target resolved to its current live DOM element. */
33
+ export interface SemanticTarget {
34
+ readonly key: string;
35
+ readonly blockId: string;
36
+ readonly element: HTMLElement;
37
+ readonly label: string;
38
+ readonly kind: SemanticTargetKind;
39
+ readonly parentKey: string | null;
40
+ readonly rowIndex?: number;
41
+ readonly columnIndex?: number;
42
+ }
38
43
 
39
44
  /**
40
- * Given a mousemove/click target element, find the best annotation target
41
- * within the viewer container. Optionally accepts mouse coordinates for
42
- * edge-zone detection (tables).
45
+ * One projection of the rendered document used by pointer hit-testing,
46
+ * keyboard traversal, hierarchy refinement, overlays, and annotation actions.
43
47
  */
44
- export function resolvePinpointTarget(
45
- target: HTMLElement,
46
- container: HTMLElement,
47
- mousePos?: { clientX: number; clientY: number },
48
- ): PinpointTarget | null {
49
- // Skip toolbar, buttons, existing annotations
50
- if (target.closest(SKIP_SELECTORS)) return null;
51
- if (!container.contains(target)) return null;
52
-
53
- // Group detection: cursor is in the gap/gutter of a list group wrapper
54
- const groupEl = target.closest('[data-pinpoint-group]') as HTMLElement | null;
55
- if (groupEl && container.contains(groupEl) && !target.closest('[data-block-id]')) {
56
- const groupType = groupEl.getAttribute('data-pinpoint-group');
57
- const label = groupType === 'list' ? 'list' : groupType === 'blockquote' ? 'blockquote group' : 'group';
58
- return { element: groupEl, blockId: '', label, isCodeBlock: false };
48
+ export interface SemanticTargetGraph {
49
+ readonly container: HTMLElement;
50
+ readonly targets: readonly SemanticTarget[];
51
+ readonly byKey: ReadonlyMap<string, SemanticTarget>;
52
+ readonly byElement: ReadonlyMap<HTMLElement, SemanticTarget>;
53
+ /** One entry per rendered Markdown block, in document order. */
54
+ readonly blockKeys: readonly string[];
55
+ }
56
+
57
+ /** Motions available while navigating the semantic target graph. */
58
+ export type SemanticTargetMotion =
59
+ | 'previous-block'
60
+ | 'next-block'
61
+ | 'previous-sibling'
62
+ | 'next-sibling'
63
+ | 'parent'
64
+ | 'child'
65
+ | 'first-block'
66
+ | 'last-block';
67
+
68
+ /** Pointer coordinates used for table edge-zone targeting. */
69
+ export interface SemanticPointerPosition {
70
+ readonly clientX: number;
71
+ readonly clientY: number;
72
+ }
73
+
74
+ function getBlockElements(container: HTMLElement): HTMLElement[] {
75
+ const seen = new Set<string>();
76
+ const result: HTMLElement[] = [];
77
+
78
+ container.querySelectorAll<HTMLElement>('[data-block-id]').forEach((element) => {
79
+ const blockId = element.dataset.blockId;
80
+ if (!blockId || seen.has(blockId) || element.tagName === 'HR') return;
81
+ seen.add(blockId);
82
+ result.push(element);
83
+ });
84
+
85
+ return result;
86
+ }
87
+
88
+ function truncate(text: string, max: number): string {
89
+ return text.length > max ? `${text.slice(0, max)}...` : text;
90
+ }
91
+
92
+ function inlineLabel(element: HTMLElement): string {
93
+ const text = element.textContent?.trim() ?? '';
94
+ const excerpt = truncate(text, 30);
95
+ if (element.tagName === 'STRONG') return `bold: "${excerpt}"`;
96
+ if (element.tagName === 'EM') return `italic: "${excerpt}"`;
97
+ if (element.tagName === 'A') return `link: "${truncate(text, 25)}"`;
98
+ return element.tagName === 'CODE' ? `code: \`${excerpt}\`` : excerpt;
99
+ }
100
+
101
+ function blockLabel(element: HTMLElement, listItem: boolean): string {
102
+ const text = element.textContent?.trim() ?? '';
103
+ const tag = element.tagName.toLowerCase();
104
+ if (listItem) {
105
+ return text ? `list item: "${truncate(text, 30)}"` : 'list item';
106
+ }
107
+ if (element.dataset.blockType === 'heading' || /^h[1-6]$/.test(tag)) {
108
+ return `heading: "${truncate(text, 35)}"`;
59
109
  }
110
+ if (tag === 'blockquote') return `blockquote: "${truncate(text, 30)}"`;
111
+ return text ? `paragraph: "${truncate(text, 35)}"` : tag;
112
+ }
60
113
 
61
- // Find the parent block
62
- const blockEl = target.closest('[data-block-id]') as HTMLElement | null;
63
- if (!blockEl || !container.contains(blockEl)) return null;
114
+ function codeBlockLabel(block: HTMLElement): string {
115
+ const code = block.querySelector('code');
116
+ const language = code?.className.match(/language-(\S+)/)?.[1];
117
+ return language ? `code block (${language})` : 'code block';
118
+ }
64
119
 
65
- const blockId = blockEl.getAttribute('data-block-id')!;
120
+ function groupKey(group: HTMLElement): string {
121
+ const type = group.dataset.pinpointGroup ?? 'group';
122
+ const ids = Array.from(group.querySelectorAll<HTMLElement>('[data-block-id]'))
123
+ .map((element) => element.dataset.blockId)
124
+ .filter((id): id is string => Boolean(id));
125
+ return `group:${type}:${ids[0] ?? 'empty'}:${ids.at(-1) ?? 'empty'}`;
126
+ }
127
+
128
+ function groupLabel(group: HTMLElement): string {
129
+ if (group.dataset.pinpointGroup === 'list') return 'list';
130
+ if (group.dataset.pinpointGroup === 'blockquote') return 'blockquote group';
131
+ return 'group';
132
+ }
66
133
 
67
- // Skip hr (no text content)
68
- if (blockEl.tagName === 'HR') return null;
134
+ function listContentElement(block: HTMLElement): HTMLElement | null {
135
+ if (!block.querySelector('.select-none')) return null;
136
+ return block.children[1] instanceof HTMLElement ? block.children[1] : null;
137
+ }
69
138
 
70
- // Code block detection: pre > code.hljs
71
- const codeEl = blockEl.querySelector('pre > code.hljs');
72
- if (codeEl && (target === codeEl || codeEl.contains(target) || target.closest('pre'))) {
73
- return {
74
- element: blockEl,
139
+ function addInlineTargets(
140
+ targets: SemanticTarget[],
141
+ byElement: Map<HTMLElement, SemanticTarget>,
142
+ blockId: string,
143
+ parent: SemanticTarget,
144
+ root: HTMLElement,
145
+ keyPrefix: string,
146
+ ): void {
147
+ const elements = Array.from(root.querySelectorAll<HTMLElement>(INLINE_TARGET_SELECTOR))
148
+ .filter((element) => element.textContent?.trim() && !element.closest(SKIP_SELECTORS));
149
+
150
+ elements.forEach((element, index) => {
151
+ const ancestorElement = element.parentElement?.closest<HTMLElement>(INLINE_TARGET_SELECTOR);
152
+ const semanticParent = ancestorElement && root.contains(ancestorElement)
153
+ ? byElement.get(ancestorElement) ?? parent
154
+ : parent;
155
+ const target: SemanticTarget = {
156
+ key: `${keyPrefix}:inline:${index}`,
75
157
  blockId,
76
- label: getCodeBlockLabel(blockEl),
77
- isCodeBlock: true,
158
+ element,
159
+ label: inlineLabel(element),
160
+ kind: 'inline',
161
+ parentKey: semanticParent.key,
78
162
  };
79
- }
163
+ targets.push(target);
164
+ byElement.set(element, target);
165
+ });
166
+ }
80
167
 
81
- // Table edge-zone detection: edges target whole table or row
82
- const tableEl = blockEl.querySelector('table');
83
- if (tableEl && mousePos) {
84
- const tableRect = tableEl.getBoundingClientRect();
85
- const nearLeft = mousePos.clientX - tableRect.left < TABLE_EDGE_ZONE;
86
- const nearRight = tableRect.right - mousePos.clientX < TABLE_EDGE_ZONE;
87
- const nearTop = mousePos.clientY - tableRect.top < TABLE_EDGE_ZONE;
88
- const nearBottom = tableRect.bottom - mousePos.clientY < TABLE_EDGE_ZONE;
89
-
90
- // Top/bottom edge → whole table
91
- if (nearTop || nearBottom) {
92
- return { element: blockEl, blockId, label: 'table', isCodeBlock: false };
93
- }
168
+ /**
169
+ * Build the canonical semantic target graph for a rendered Markdown document.
170
+ *
171
+ * Each `[data-block-id]` contributes exactly one block-navigation entry.
172
+ * Groups, table rows/cells, and inline formatting become hierarchy nodes.
173
+ */
174
+ export function buildSemanticTargetGraph(container: HTMLElement): SemanticTargetGraph {
175
+ const targets: SemanticTarget[] = [];
176
+ const byElement = new Map<HTMLElement, SemanticTarget>();
177
+ const blockKeys: string[] = [];
178
+ const groupTargets = new Map<HTMLElement, SemanticTarget>();
94
179
 
95
- // Left/right edge → the row at this Y position
96
- if (nearLeft || nearRight) {
97
- const row = findRowAtY(tableEl, mousePos.clientY);
98
- if (row) {
99
- return { element: row, blockId, label: getRowLabel(row), isCodeBlock: false };
100
- }
101
- return { element: blockEl, blockId, label: 'table', isCodeBlock: false };
102
- }
103
- }
180
+ container.querySelectorAll<HTMLElement>('[data-pinpoint-group]').forEach((group) => {
181
+ const firstBlockId = group.querySelector<HTMLElement>('[data-block-id]')?.dataset.blockId;
182
+ const target: SemanticTarget = {
183
+ key: groupKey(group),
184
+ blockId: firstBlockId ?? '',
185
+ element: group,
186
+ label: groupLabel(group),
187
+ kind: 'group',
188
+ parentKey: null,
189
+ };
190
+ targets.push(target);
191
+ byElement.set(group, target);
192
+ groupTargets.set(group, target);
193
+ });
104
194
 
105
- // Inline code (not inside a code block) — target the <code> element
106
- if (target.tagName === 'CODE' && !target.classList.contains('hljs')) {
107
- const text = target.textContent?.trim() || '';
108
- if (text) {
109
- return {
110
- element: target,
111
- blockId,
112
- label: `code: \`${truncate(text, 30)}\``,
113
- isCodeBlock: false,
114
- };
115
- }
116
- }
195
+ for (const block of getBlockElements(container)) {
196
+ const blockId = block.dataset.blockId;
197
+ if (!blockId) continue;
198
+
199
+ const group = block.closest<HTMLElement>('[data-pinpoint-group]');
200
+ const parentKey = group ? groupTargets.get(group)?.key ?? null : null;
201
+ const codeElement = block.querySelector<HTMLElement>('pre > code.pn-code');
202
+ const mathElement = block.matches('.math-annotatable,[data-math-tex]')
203
+ ? block
204
+ : block.querySelector<HTMLElement>('.math-annotatable,[data-math-tex]');
205
+ const table = block.querySelector<HTMLTableElement>('table');
117
206
 
118
- // Inline elements: strong, em, a
119
- if (INLINE_TARGETS.has(target.tagName)) {
120
- const text = target.textContent?.trim() || '';
121
- if (text) {
122
- return {
123
- element: target,
207
+ if (codeElement) {
208
+ const target: SemanticTarget = {
209
+ key: `${blockId}:code`,
124
210
  blockId,
125
- label: getInlineLabel(target, text),
126
- isCodeBlock: false,
211
+ element: block,
212
+ label: codeBlockLabel(block),
213
+ kind: 'code',
214
+ parentKey,
127
215
  };
216
+ targets.push(target);
217
+ byElement.set(block, target);
218
+ blockKeys.push(target.key);
219
+ continue;
128
220
  }
129
- }
130
221
 
131
- // Table cells (only reached when cursor is deep inside, not near edge)
132
- if (CELL_TARGETS.has(target.tagName)) {
133
- return {
134
- element: target,
135
- blockId,
136
- label: 'table cell',
137
- isCodeBlock: false,
138
- };
139
- }
140
- // Check if inside a table cell
141
- const cell = target.closest('td, th') as HTMLElement | null;
142
- if (cell && blockEl.contains(cell)) {
143
- return {
144
- element: cell,
145
- blockId,
146
- label: 'table cell',
147
- isCodeBlock: false,
148
- };
149
- }
150
-
151
- // List item — target the content span (second child), not the bullet
152
- if (blockEl.querySelector('.select-none')) {
153
- // This is a list item with a bullet. Find the content span.
154
- const contentSpan = blockEl.children[1] as HTMLElement | undefined;
155
- if (contentSpan && (contentSpan === target || contentSpan.contains(target))) {
156
- return {
157
- element: contentSpan,
222
+ if (mathElement) {
223
+ const target: SemanticTarget = {
224
+ key: `${blockId}:math`,
158
225
  blockId,
159
- label: getListItemLabel(contentSpan),
160
- isCodeBlock: false,
226
+ element: mathElement,
227
+ label: 'formula',
228
+ kind: 'math',
229
+ parentKey,
161
230
  };
231
+ targets.push(target);
232
+ byElement.set(mathElement, target);
233
+ blockKeys.push(target.key);
234
+ continue;
162
235
  }
163
- // Clicked on the bullet area — still target the content span
164
- if (contentSpan) {
165
- return {
166
- element: contentSpan,
236
+
237
+ if (table) {
238
+ const tableTarget: SemanticTarget = {
239
+ key: `${blockId}:table`,
167
240
  blockId,
168
- label: getListItemLabel(contentSpan),
169
- isCodeBlock: false,
241
+ element: block,
242
+ label: 'table',
243
+ kind: 'table',
244
+ parentKey,
170
245
  };
246
+ targets.push(tableTarget);
247
+ byElement.set(block, tableTarget);
248
+ blockKeys.push(tableTarget.key);
249
+
250
+ Array.from(table.rows).forEach((row, rowIndex) => {
251
+ const rowTarget: SemanticTarget = {
252
+ key: `${blockId}:row:${rowIndex}`,
253
+ blockId,
254
+ element: row,
255
+ label: rowIndex === 0 ? 'table header row' : `table row ${rowIndex}`,
256
+ kind: 'row',
257
+ parentKey: tableTarget.key,
258
+ rowIndex,
259
+ };
260
+ targets.push(rowTarget);
261
+ byElement.set(row, rowTarget);
262
+
263
+ Array.from(row.cells).forEach((cell, columnIndex) => {
264
+ const cellTarget: SemanticTarget = {
265
+ key: `${blockId}:cell:${rowIndex}:${columnIndex}`,
266
+ blockId,
267
+ element: cell,
268
+ label: `table cell ${rowIndex + 1}, ${columnIndex + 1}`,
269
+ kind: 'cell',
270
+ parentKey: rowTarget.key,
271
+ rowIndex,
272
+ columnIndex,
273
+ };
274
+ targets.push(cellTarget);
275
+ byElement.set(cell, cellTarget);
276
+ addInlineTargets(
277
+ targets,
278
+ byElement,
279
+ blockId,
280
+ cellTarget,
281
+ cell,
282
+ cellTarget.key,
283
+ );
284
+ });
285
+ });
286
+ continue;
171
287
  }
288
+
289
+ const listContent = listContentElement(block);
290
+ const primaryElement = listContent ?? block;
291
+ const blockTarget: SemanticTarget = {
292
+ key: `${blockId}:block`,
293
+ blockId,
294
+ element: primaryElement,
295
+ label: blockLabel(primaryElement, listContent !== null),
296
+ kind: 'block',
297
+ parentKey,
298
+ };
299
+ targets.push(blockTarget);
300
+ byElement.set(primaryElement, blockTarget);
301
+ blockKeys.push(blockTarget.key);
302
+ addInlineTargets(targets, byElement, blockId, blockTarget, primaryElement, blockId);
172
303
  }
173
304
 
174
- // Fall back to the full block
175
305
  return {
176
- element: blockEl,
177
- blockId,
178
- label: getBlockLabel(blockEl),
179
- isCodeBlock: false,
306
+ container,
307
+ targets,
308
+ byKey: new Map(targets.map((target) => [target.key, target])),
309
+ byElement,
310
+ blockKeys,
180
311
  };
181
312
  }
182
313
 
183
- function getInlineLabel(el: HTMLElement, text: string): string {
184
- switch (el.tagName) {
185
- case 'STRONG': return `bold: "${truncate(text, 30)}"`;
186
- case 'EM': return `italic: "${truncate(text, 30)}"`;
187
- case 'A': return `link: "${truncate(text, 25)}"`;
188
- default: return truncate(text, 30);
189
- }
314
+ /** Resolve a stable target key against a freshly built graph. */
315
+ export function resolveSemanticTarget(
316
+ graph: SemanticTargetGraph,
317
+ key: string | null,
318
+ ): SemanticTarget | null {
319
+ return key ? graph.byKey.get(key) ?? null : null;
320
+ }
321
+
322
+ /**
323
+ * Create the annotation range owned by a semantic target.
324
+ *
325
+ * Code and math targets use their existing specialized annotation paths;
326
+ * every text-bearing graph node resolves through this one range seam.
327
+ */
328
+ export function createSemanticTargetRange(target: SemanticTarget): Range | null {
329
+ return target.kind === 'code' || target.kind === 'math'
330
+ ? null
331
+ : createTextRange(target.element);
190
332
  }
191
333
 
192
- function getBlockLabel(el: HTMLElement): string {
193
- const tag = el.tagName.toLowerCase();
194
- const text = el.textContent?.trim() || '';
334
+ /** Return the direct semantic children of a target in document order. */
335
+ export function getSemanticTargetChildren(
336
+ graph: SemanticTargetGraph,
337
+ target: SemanticTarget,
338
+ ): readonly SemanticTarget[] {
339
+ return graph.targets.filter((candidate) => candidate.parentKey === target.key);
340
+ }
195
341
 
196
- if (el.querySelector('table')) return 'table';
197
- if (el.dataset.blockType === 'heading' || /^h[1-6]$/.test(tag)) {
198
- return `heading: "${truncate(text, 35)}"`;
342
+ /** Return the block-navigation target that owns a nested semantic target. */
343
+ export function getOwningBlockTarget(
344
+ graph: SemanticTargetGraph,
345
+ target: SemanticTarget,
346
+ ): SemanticTarget {
347
+ let current = target;
348
+ while (!graph.blockKeys.includes(current.key) && current.parentKey) {
349
+ const parent = resolveSemanticTarget(graph, current.parentKey);
350
+ if (!parent) break;
351
+ current = parent;
199
352
  }
200
- if (tag === 'blockquote') return `blockquote: "${truncate(text, 30)}"`;
201
- if (tag === 'p') return text ? `paragraph: "${truncate(text, 35)}"` : 'paragraph';
202
- return truncate(text, 35) || tag;
353
+ if (graph.blockKeys.includes(current.key)) return current;
354
+ return graph.blockKeys
355
+ .map((key) => resolveSemanticTarget(graph, key))
356
+ .find((candidate) => candidate?.blockId === target.blockId)
357
+ ?? target;
203
358
  }
204
359
 
205
- function getListItemLabel(contentSpan: HTMLElement): string {
206
- const text = contentSpan.textContent?.trim() || '';
207
- return text ? `list item: "${truncate(text, 30)}"` : 'list item';
360
+ /** Pick the block nearest the visible center of the document viewport. */
361
+ export function findInitialSemanticTarget(
362
+ graph: SemanticTargetGraph,
363
+ scrollViewport?: HTMLElement | null,
364
+ ): SemanticTarget | null {
365
+ const viewportRect = (scrollViewport ?? graph.container).getBoundingClientRect();
366
+ const centerY = viewportRect.top + viewportRect.height / 2;
367
+ return graph.blockKeys
368
+ .map((key) => resolveSemanticTarget(graph, key))
369
+ .filter((target): target is SemanticTarget => target !== null)
370
+ .sort((left, right) => {
371
+ const leftRect = left.element.getBoundingClientRect();
372
+ const rightRect = right.element.getBoundingClientRect();
373
+ return Math.abs((leftRect.top + leftRect.bottom) / 2 - centerY)
374
+ - Math.abs((rightRect.top + rightRect.bottom) / 2 - centerY);
375
+ })[0] ?? null;
376
+ }
377
+
378
+ /**
379
+ * Move through block order, sibling order, or one hierarchy level.
380
+ */
381
+ export function moveSemanticTarget(
382
+ graph: SemanticTargetGraph,
383
+ current: SemanticTarget,
384
+ motion: SemanticTargetMotion,
385
+ ): SemanticTarget {
386
+ if (motion === 'parent') {
387
+ return resolveSemanticTarget(graph, current.parentKey) ?? current;
388
+ }
389
+ if (motion === 'child') {
390
+ return getSemanticTargetChildren(graph, current)[0] ?? current;
391
+ }
392
+ if (motion === 'first-block') {
393
+ return resolveSemanticTarget(graph, graph.blockKeys[0] ?? null) ?? current;
394
+ }
395
+ if (motion === 'last-block') {
396
+ return resolveSemanticTarget(graph, graph.blockKeys.at(-1) ?? null) ?? current;
397
+ }
398
+ if (motion === 'previous-sibling' || motion === 'next-sibling') {
399
+ if (!current.parentKey) return current;
400
+ const parent = resolveSemanticTarget(graph, current.parentKey);
401
+ if (!parent) return current;
402
+ const siblings = getSemanticTargetChildren(graph, parent);
403
+ const index = siblings.findIndex((candidate) => candidate.key === current.key);
404
+ if (index < 0) return current;
405
+ const delta = motion === 'previous-sibling' ? -1 : 1;
406
+ const nextIndex = Math.max(0, Math.min(siblings.length - 1, index + delta));
407
+ return siblings[nextIndex] ?? current;
408
+ }
409
+
410
+ const delta: -1 | 1 = motion === 'previous-block' ? -1 : 1;
411
+ const block = getOwningBlockTarget(graph, current);
412
+ const index = graph.blockKeys.indexOf(block.key);
413
+ if (index < 0) return current;
414
+ const nextIndex = Math.max(0, Math.min(graph.blockKeys.length - 1, index + delta));
415
+ return resolveSemanticTarget(graph, graph.blockKeys[nextIndex] ?? null) ?? current;
208
416
  }
209
417
 
210
- function getCodeBlockLabel(blockEl: HTMLElement): string {
211
- const codeEl = blockEl.querySelector('code');
212
- const lang = codeEl?.className?.match(/language-(\S+)/)?.[1];
213
- return lang ? `code block (${lang})` : 'code block';
418
+ function targetForBlock(graph: SemanticTargetGraph, block: HTMLElement): SemanticTarget | null {
419
+ const blockId = block.dataset.blockId;
420
+ if (!blockId) return null;
421
+ return graph.blockKeys
422
+ .map((key) => resolveSemanticTarget(graph, key))
423
+ .find((target) => target?.blockId === blockId)
424
+ ?? null;
214
425
  }
215
426
 
216
- /** Find the table row whose bounding box contains the given Y coordinate */
217
- function findRowAtY(tableEl: HTMLTableElement, clientY: number): HTMLTableRowElement | null {
218
- const rows = tableEl.querySelectorAll('tr');
219
- for (const row of rows) {
427
+ function rowAtY(table: HTMLTableElement, clientY: number): HTMLTableRowElement | null {
428
+ return Array.from(table.rows).find((row) => {
220
429
  const rect = row.getBoundingClientRect();
221
- if (clientY >= rect.top && clientY <= rect.bottom) {
222
- return row;
430
+ return clientY >= rect.top && clientY <= rect.bottom;
431
+ }) ?? null;
432
+ }
433
+
434
+ /**
435
+ * Resolve the pointer's semantic target from the same graph used by keyboard
436
+ * navigation. Table edge zones select table/row scope; content selects cells.
437
+ */
438
+ export function resolveSemanticTargetAtPoint(
439
+ graph: SemanticTargetGraph,
440
+ pointerTarget: HTMLElement,
441
+ pointer?: SemanticPointerPosition,
442
+ ): SemanticTarget | null {
443
+ if (pointerTarget.closest(SKIP_SELECTORS)) return null;
444
+ if (!graph.container.contains(pointerTarget)) return null;
445
+
446
+ const group = pointerTarget.closest<HTMLElement>('[data-pinpoint-group]');
447
+ if (group && !pointerTarget.closest('[data-block-id]')) {
448
+ return graph.byElement.get(group) ?? null;
449
+ }
450
+
451
+ const block = pointerTarget.closest<HTMLElement>('[data-block-id]');
452
+ if (!block || !graph.container.contains(block) || block.tagName === 'HR') return null;
453
+ const blockTarget = targetForBlock(graph, block);
454
+ if (!blockTarget) return null;
455
+
456
+ const code = block.querySelector<HTMLElement>('pre > code.pn-code');
457
+ if (
458
+ code
459
+ && (pointerTarget === code || code.contains(pointerTarget) || pointerTarget.closest('pre'))
460
+ ) {
461
+ return blockTarget;
462
+ }
463
+
464
+ const table = block.querySelector<HTMLTableElement>('table');
465
+ if (table && pointer) {
466
+ const rect = table.getBoundingClientRect();
467
+ const nearHorizontalEdge = pointer.clientX - rect.left < TABLE_EDGE_ZONE
468
+ || rect.right - pointer.clientX < TABLE_EDGE_ZONE;
469
+ const nearVerticalEdge = pointer.clientY - rect.top < TABLE_EDGE_ZONE
470
+ || rect.bottom - pointer.clientY < TABLE_EDGE_ZONE;
471
+ if (nearVerticalEdge) return blockTarget;
472
+ if (nearHorizontalEdge) {
473
+ const row = rowAtY(table, pointer.clientY);
474
+ return row ? graph.byElement.get(row) ?? blockTarget : blockTarget;
223
475
  }
224
476
  }
225
- return null;
477
+
478
+ const inline = pointerTarget.closest<HTMLElement>(INLINE_TARGET_SELECTOR);
479
+ if (inline && block.contains(inline)) {
480
+ const inlineTarget = graph.byElement.get(inline);
481
+ if (inlineTarget) return inlineTarget;
482
+ }
483
+
484
+ const cell = pointerTarget.closest<HTMLTableCellElement>('td,th');
485
+ if (cell && block.contains(cell)) {
486
+ return graph.byElement.get(cell) ?? blockTarget;
487
+ }
488
+
489
+ return blockTarget;
226
490
  }
227
491
 
228
- /** Human-readable label for a table row */
229
- function getRowLabel(row: HTMLTableRowElement): string {
230
- // Header row
231
- if (row.querySelector('th')) return 'table header row';
232
- // Body row — use first cell text as hint
233
- const firstCell = row.querySelector('td');
234
- const text = firstCell?.textContent?.trim() || '';
235
- return text ? `row: "${truncate(text, 25)}"` : 'table row';
492
+ /**
493
+ * Backward-compatible pointer result used by existing Pinpoint consumers.
494
+ *
495
+ * New code should retain the semantic target itself so pointer and keyboard
496
+ * paths share its stable key and hierarchy.
497
+ */
498
+ export interface PinpointTarget {
499
+ readonly element: HTMLElement;
500
+ readonly blockId: string;
501
+ readonly label: string;
502
+ readonly isCodeBlock: boolean;
236
503
  }
237
504
 
238
- function truncate(text: string, max: number): string {
239
- return text.length > max ? text.slice(0, max) + '...' : text;
505
+ /** Resolve a pointer target through the canonical semantic graph. */
506
+ export function resolvePinpointTarget(
507
+ target: HTMLElement,
508
+ container: HTMLElement,
509
+ pointer?: SemanticPointerPosition,
510
+ ): PinpointTarget | null {
511
+ const semantic = resolveSemanticTargetAtPoint(
512
+ buildSemanticTargetGraph(container),
513
+ target,
514
+ pointer,
515
+ );
516
+ return semantic
517
+ ? {
518
+ element: semantic.element,
519
+ blockId: semantic.blockId,
520
+ label: semantic.label,
521
+ isCodeBlock: semantic.kind === 'code',
522
+ }
523
+ : null;
240
524
  }