@plannotator/ui 0.28.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/components/AISettingsTab.tsx +5 -4
- package/components/ActionMenu.tsx +5 -1
- package/components/AgentsTab.tsx +10 -23
- package/components/AnnotationPanel.tsx +9 -5
- package/components/AnnotationToolbar.tsx +5 -13
- package/components/AnnotationToolstrip.tsx +2 -2
- package/components/ApproveDropdown.tsx +1 -1
- package/components/BlockRenderer.tsx +1 -1
- package/components/CodeFilePopout.tsx +5 -4
- package/components/CommentPopover.tsx +391 -65
- package/components/DocBadges.tsx +29 -11
- package/components/ExportModal.tsx +16 -8
- package/components/GraphvizBlock.tsx +1 -1
- package/components/InlineMarkdown.tsx +30 -13
- package/components/KeyboardShortcuts.tsx +37 -2
- package/components/Landing.tsx +7 -7
- package/components/MenuVersionSection.tsx +4 -4
- package/components/MermaidBlock.tsx +1 -1
- package/components/ModeToggle.tsx +7 -6
- package/components/OpenInAppButton.tsx +2 -5
- package/components/PinpointOverlay.tsx +9 -7
- package/components/PlanHeaderMenu.tsx +8 -8
- package/components/PopoutDialog.tsx +6 -1
- package/components/ResizeHandle.tsx +1 -0
- package/components/Settings.tsx +172 -12
- package/components/SkillReferenceMenu.tsx +260 -0
- package/components/StickyHeaderLane.tsx +7 -0
- package/components/ThemeProvider.tsx +131 -32
- package/components/ThemeTab.tsx +123 -77
- package/components/ToolbarButtons.tsx +29 -8
- package/components/Viewer.tsx +396 -130
- package/components/VimKeyHud.tsx +695 -0
- package/components/VimModeAnnouncementDialog.tsx +557 -0
- package/components/VimModeOverlay.tsx +235 -0
- package/components/VimTargetReticle.tsx +284 -0
- package/components/ai/DocumentAIChatPanel.tsx +1 -1
- package/components/blocks/CodeBlock.tsx +18 -18
- package/components/blocks/TablePopout.tsx +7 -8
- package/components/blocks/TableToolbar.tsx +7 -8
- package/components/goal-setup/GoalSetupSurface.tsx +16 -3
- package/components/html-viewer/HtmlViewer.tsx +450 -47
- package/components/html-viewer/annotationNumbering.ts +37 -0
- package/components/html-viewer/bridge-script.ts +4051 -298
- package/components/html-viewer/composerYield.ts +51 -0
- package/components/html-viewer/srcdoc.ts +18 -3
- package/components/html-viewer/useHtmlAnnotation.ts +457 -32
- package/components/icons/themeIcons.tsx +1 -1
- package/components/plan-diff/PlanCleanDiffView.tsx +9 -9
- package/components/plan-diff/PlanDiffBadge.tsx +22 -1
- package/components/settings/HooksTab.tsx +12 -8
- package/components/sidebar/FileBrowser.tsx +4 -1
- package/components/themeModes.tsx +28 -0
- package/config/configStore.ts +76 -1
- package/config/settings.ts +152 -0
- package/configure.ts +9 -0
- package/globals.d.ts +7 -1
- package/hooks/useAIChat.ts +5 -2
- package/hooks/useAIProviderActivation.ts +47 -0
- package/hooks/useAIProviderConfig.ts +5 -1
- package/hooks/useAgentSettings.ts +64 -23
- package/hooks/useAgents.ts +4 -4
- package/hooks/useAnnotationHighlighter.ts +100 -3
- package/hooks/useArchive.ts +2 -1
- package/hooks/useFenceTheme.ts +17 -0
- package/hooks/useLinkedDoc.ts +68 -1
- package/hooks/usePinpoint.ts +76 -75
- package/hooks/usePlanDiff.ts +73 -2
- package/hooks/useSkillReferenceAutocomplete.ts +239 -0
- package/hooks/useUpdateCheck.ts +1 -2
- package/hooks/useVimDocumentFocus.ts +116 -0
- package/hooks/useVimSelection.ts +1063 -0
- package/package.json +4 -4
- package/print.css +14 -13
- package/shortcuts/core.ts +38 -13
- package/shortcuts/index.ts +10 -0
- package/shortcuts/plan-review/commentPopover.shortcuts.ts +7 -0
- package/shortcuts/plan-review/vimSelection.shortcuts.ts +251 -0
- package/shortcuts/runtime.ts +111 -12
- package/styles.css +1 -1
- package/theme.css +504 -0
- package/themes/colorblind.css +89 -0
- package/themes/plannotator.css +2 -2
- package/types.ts +93 -10
- package/utils/agentSwitch.ts +33 -7
- package/utils/blockTargeting.ts +462 -178
- package/utils/clipboard.ts +110 -0
- package/utils/codeBlockMark.ts +50 -0
- package/utils/codeHighlight.ts +293 -0
- package/utils/codexModels.ts +79 -0
- package/utils/domSelection.ts +84 -0
- package/utils/htmlChrome.ts +73 -0
- package/utils/inputMethod.ts +79 -6
- package/utils/parser.ts +517 -21
- package/utils/preferenceTtl.ts +15 -0
- package/utils/sharing.ts +0 -1
- package/utils/skillCatalog.ts +269 -0
- package/utils/skillReferences.ts +475 -0
- package/utils/syntaxTheme.ts +83 -0
- package/utils/themeRegistry.ts +154 -0
- package/utils/vimHud.ts +263 -0
- package/utils/vimModeAnnouncement.ts +23 -0
- package/utils/vimNavigation.ts +417 -0
- package/utils/vimReticle.ts +88 -0
- package/utils/vimScroll.ts +162 -0
package/utils/blockTargeting.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Semantic document targeting shared by pointer Pinpoint and Vim navigation.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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
|
-
|
|
18
|
-
const
|
|
18
|
+
const INLINE_TARGET_SELECTOR = 'strong,em,a,code:not(.pn-code)';
|
|
19
|
+
const TABLE_EDGE_ZONE = 22;
|
|
19
20
|
|
|
20
|
-
/**
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
*
|
|
41
|
-
*
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
if (
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (text) {
|
|
122
|
-
return {
|
|
123
|
-
element: target,
|
|
207
|
+
if (codeElement) {
|
|
208
|
+
const target: SemanticTarget = {
|
|
209
|
+
key: `${blockId}:code`,
|
|
124
210
|
blockId,
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
164
|
-
if (
|
|
165
|
-
|
|
166
|
-
|
|
236
|
+
|
|
237
|
+
if (table) {
|
|
238
|
+
const tableTarget: SemanticTarget = {
|
|
239
|
+
key: `${blockId}:table`,
|
|
167
240
|
blockId,
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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 (
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
return
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
239
|
-
|
|
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
|
}
|