@fabrika/canvas-kernel 0.1.44 → 0.2.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 +33 -67
- package/index.ts +19 -83
- package/package.json +68 -72
- package/src/client/AnnotationProvider.tsx +227 -268
- package/src/client/AnnotationSidebar.tsx +392 -338
- package/src/client/FileIcon.tsx +106 -65
- package/src/client/PlanRenderer.tsx +607 -551
- package/src/client/RenderErrorContext.ts +7 -7
- package/src/client/ResponsePreview.tsx +192 -173
- package/src/client/annotationDom.ts +43 -97
- package/src/client/annotationDraft.ts +98 -92
- package/src/client/appContext.tsx +40 -40
- package/src/client/components/Callout.tsx +20 -20
- package/src/client/components/Checklist.tsx +28 -28
- package/src/client/components/Choice.tsx +178 -159
- package/src/client/components/CodeBlock.tsx +26 -26
- package/src/client/components/Diff.tsx +17 -17
- package/src/client/components/FilePreview.tsx +99 -74
- package/src/client/components/Image.tsx +41 -39
- package/src/client/components/Markdown.tsx +33 -32
- package/src/client/components/Mermaid.tsx +592 -542
- package/src/client/components/Note.tsx +8 -8
- package/src/client/components/Priority.tsx +11 -11
- package/src/client/components/ResponseNote.tsx +17 -15
- package/src/client/components/Section.tsx +28 -26
- package/src/client/components/Table.tsx +34 -29
- package/src/client/components/Task.tsx +58 -60
- package/src/client/components/UserInput.tsx +169 -152
- package/src/client/components/index.ts +16 -16
- package/src/client/components/renderMarkdownChildren.tsx +86 -68
- package/src/client/diffLines.ts +51 -39
- package/src/client/generateMarkdown.ts +197 -191
- package/src/client/hostContext.tsx +34 -34
- package/src/client/index.ts +43 -125
- package/src/client/preact-all.ts +4 -4
- package/src/client/runtime.ts +58 -111
- package/src/client/styles.css +1 -74
- package/src/engine/compiler.ts +316 -313
- package/src/engine/paths.ts +44 -44
- package/src/engine/session.ts +623 -618
- package/src/engine/watcher.ts +63 -60
- package/src/engine/websocket.ts +123 -121
- package/annotate.ts +0 -94
- package/daemon.ts +0 -46
- package/src/client/AnnotationDraftFooter.tsx +0 -76
- package/src/client/AnnotationEditor.tsx +0 -320
- package/src/client/AnnotationList.tsx +0 -190
- package/src/client/Popover.tsx +0 -361
- package/src/client/SessionContext.tsx +0 -3
- package/src/client/annotationContext.ts +0 -130
- package/src/client/annotationMarkdown.ts +0 -102
- package/src/client/annotationTarget.ts +0 -73
- package/src/client/blockTarget.ts +0 -127
- package/src/client/highlightRange.ts +0 -262
- package/src/client/popoverPosition.ts +0 -46
- package/src/client/regionTarget.ts +0 -180
- package/src/client/testing/dom.ts +0 -58
- package/src/client/textTarget.ts +0 -31
- package/src/client/useRegionAnnotation.tsx +0 -188
- package/src/client/useTextAnnotation.tsx +0 -194
- package/src/client/utils.ts +0 -33
- package/src/engine/channel.ts +0 -278
- package/src/engine/http-utils.ts +0 -14
- package/src/engine/lifecycle.ts +0 -302
- package/src/engine/router.ts +0 -16
- package/src/engine/waiter.ts +0 -188
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
import type { AnnotationTarget } from "./annotationTarget";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* A whole canvas block, identified by the text a reader sees in it.
|
|
5
|
-
*
|
|
6
|
-
* The hard-won invariant lives here: the selector that decides where an
|
|
7
|
-
* annotation may be minted and the selector the lookup scans must be one
|
|
8
|
-
* selector. An annotation minted on a block the lookup skips can never be
|
|
9
|
-
* resolved again. Both now come off one table, so they cannot drift: a kind is
|
|
10
|
-
* annotatable exactly when it can mint a snippet, and `getBlockSnippet` refuses
|
|
11
|
-
* every block outside that set.
|
|
12
|
-
*/
|
|
13
|
-
interface BlockKind {
|
|
14
|
-
/** CSS matching this kind, as the component emits it. */
|
|
15
|
-
selector: string;
|
|
16
|
-
/** The key a block of this kind is annotated under, or null when it is
|
|
17
|
-
* missing the attribute that identifies it. */
|
|
18
|
-
snippet(block: HTMLElement): string | null;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* A block's text as a reader sees it. Decorative markup — the callout icon,
|
|
23
|
-
* anything else marked aria-hidden — is skipped, so the glyph does not ride
|
|
24
|
-
* along into the sidebar label and into feedback.md.
|
|
25
|
-
*/
|
|
26
|
-
function readableText(block: HTMLElement): string {
|
|
27
|
-
const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
|
28
|
-
let text = "";
|
|
29
|
-
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
30
|
-
const hidden = node.parentElement?.closest("[aria-hidden='true']");
|
|
31
|
-
if (hidden && block.contains(hidden)) continue;
|
|
32
|
-
text += node.textContent ?? "";
|
|
33
|
-
}
|
|
34
|
-
return text.trim();
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** A block named by one of its attributes, e.g. `[Item] Ship the beta`. */
|
|
38
|
-
function byAttribute(selector: string, prefix: string, attribute: string): BlockKind {
|
|
39
|
-
return {
|
|
40
|
-
selector,
|
|
41
|
-
snippet: (block) => {
|
|
42
|
-
const value = block.getAttribute(attribute);
|
|
43
|
-
return value ? `${prefix} ${value}` : null;
|
|
44
|
-
},
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Every block an annotation can be minted on. */
|
|
49
|
-
const ANNOTATABLE_KINDS: BlockKind[] = [
|
|
50
|
-
byAttribute("[data-md='item']", "[Item]", "data-md-label"),
|
|
51
|
-
byAttribute("[data-md='section']", "[Section]", "data-md-title"),
|
|
52
|
-
byAttribute("[data-md='checklist-item']", "[Checklist]", "data-md-label"),
|
|
53
|
-
byAttribute("[data-md='image']", "[Image]", "data-md-src"),
|
|
54
|
-
{
|
|
55
|
-
selector: "[data-md='callout']",
|
|
56
|
-
snippet: (block) => {
|
|
57
|
-
const type = block.getAttribute("data-md-type") || "info";
|
|
58
|
-
return `[Callout:${type}] ${readableText(block).slice(0, 60) || "Callout"}`;
|
|
59
|
-
},
|
|
60
|
-
},
|
|
61
|
-
{
|
|
62
|
-
selector: "[data-md='note']",
|
|
63
|
-
snippet: (block) => `[Note] ${readableText(block).slice(0, 60) || "Note"}`,
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
// Only rows of a table the canvas emitted: a table inside a markdown block
|
|
67
|
-
// is not navigable, so a row annotated there could never be reached again.
|
|
68
|
-
selector: "[data-md='table'] tbody tr",
|
|
69
|
-
snippet: (block) => {
|
|
70
|
-
const cells = Array.from(block.querySelectorAll("td")).map((td) => td.textContent?.trim()).filter(Boolean);
|
|
71
|
-
return cells.length ? `[Row] ${cells.join(" | ")}` : null;
|
|
72
|
-
},
|
|
73
|
-
},
|
|
74
|
-
];
|
|
75
|
-
|
|
76
|
-
/** Blocks the arrows walk but no annotation can land on — interactive controls
|
|
77
|
-
* carry the reader's answer, not the author's text. */
|
|
78
|
-
const NAVIGABLE_ONLY_SELECTORS = [
|
|
79
|
-
"[data-md='choice-option']",
|
|
80
|
-
"[data-md='multichoice-option']",
|
|
81
|
-
"[data-md='userinput']",
|
|
82
|
-
"[data-md='rangeinput']",
|
|
83
|
-
];
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* The blocks that can carry a block annotation — a strict subset of
|
|
87
|
-
* BLOCK_SELECTOR, since interactive controls are navigable but not annotatable.
|
|
88
|
-
*/
|
|
89
|
-
export const ANNOTATABLE_SELECTOR = ANNOTATABLE_KINDS.map((kind) => kind.selector).join(", ");
|
|
90
|
-
|
|
91
|
-
/** Every block the keyboard arrows walk through. */
|
|
92
|
-
export const BLOCK_SELECTOR = [
|
|
93
|
-
...ANNOTATABLE_KINDS.map((kind) => kind.selector),
|
|
94
|
-
...NAVIGABLE_ONLY_SELECTORS,
|
|
95
|
-
].join(", ");
|
|
96
|
-
|
|
97
|
-
/** The snippet identifier for a block, or null if nothing can be annotated here. */
|
|
98
|
-
export function getBlockSnippet(block: HTMLElement): string | null {
|
|
99
|
-
for (const kind of ANNOTATABLE_KINDS) {
|
|
100
|
-
if (block.matches(kind.selector)) return kind.snippet(block);
|
|
101
|
-
}
|
|
102
|
-
return null;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** A block is its own locator: the snippet is the text that identifies it. */
|
|
106
|
-
export interface BlockLocator {
|
|
107
|
-
snippet: string;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export const blockTarget: AnnotationTarget<BlockLocator> = {
|
|
111
|
-
kind: "block",
|
|
112
|
-
|
|
113
|
-
// Every block snippet opens with its kind in brackets. Text that merely looks
|
|
114
|
-
// like one is not stranded by claiming it: `find` compares against snippets
|
|
115
|
-
// blocks actually mint, so a lookalike simply matches nothing.
|
|
116
|
-
parse: (snippet) => (snippet.startsWith("[") ? { snippet } : null),
|
|
117
|
-
|
|
118
|
-
format: (locator) => locator.snippet,
|
|
119
|
-
|
|
120
|
-
find: (locator, root) => {
|
|
121
|
-
for (const el of root.querySelectorAll(ANNOTATABLE_SELECTOR)) {
|
|
122
|
-
if (!(el instanceof HTMLElement)) continue;
|
|
123
|
-
if (getBlockSnippet(el) === locator.snippet) return el;
|
|
124
|
-
}
|
|
125
|
-
return null;
|
|
126
|
-
},
|
|
127
|
-
};
|
|
@@ -1,262 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Wraps the current browser Range in <mark> elements with the given annotation ID.
|
|
3
|
-
* Works for multiline / cross-element selections.
|
|
4
|
-
*/
|
|
5
|
-
export function wrapRangeWithMark(range: Range, annotationId: string): HTMLElement[] {
|
|
6
|
-
const marks: HTMLElement[] = [];
|
|
7
|
-
|
|
8
|
-
// Collect all text nodes within the range
|
|
9
|
-
const textNodes = getTextNodesInRange(range);
|
|
10
|
-
|
|
11
|
-
for (const tn of textNodes) {
|
|
12
|
-
const nodeRange = document.createRange();
|
|
13
|
-
nodeRange.selectNodeContents(tn);
|
|
14
|
-
|
|
15
|
-
// Clamp to the actual selection range
|
|
16
|
-
if (tn === range.startContainer) {
|
|
17
|
-
nodeRange.setStart(tn, range.startOffset);
|
|
18
|
-
}
|
|
19
|
-
if (tn === range.endContainer) {
|
|
20
|
-
nodeRange.setEnd(tn, range.endOffset);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const text = nodeRange.toString();
|
|
24
|
-
if (!text || !text.trim()) continue;
|
|
25
|
-
|
|
26
|
-
// Wrap this portion in a <mark>
|
|
27
|
-
const mark = document.createElement("mark");
|
|
28
|
-
mark.setAttribute("data-annotation-id", annotationId);
|
|
29
|
-
|
|
30
|
-
try {
|
|
31
|
-
nodeRange.surroundContents(mark);
|
|
32
|
-
} catch {
|
|
33
|
-
// surroundContents fails if range crosses element boundaries within a single text node
|
|
34
|
-
// (shouldn't happen for a single text node, but handle gracefully)
|
|
35
|
-
const fragment = nodeRange.extractContents();
|
|
36
|
-
mark.appendChild(fragment);
|
|
37
|
-
nodeRange.insertNode(mark);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
marks.push(mark);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
return marks;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Whether the annotation index carries this text. Line-number gutters and text
|
|
48
|
-
* that already belongs to another annotation are outside it. Everything that
|
|
49
|
-
* records a snippet or its context is matched back against that index later, so
|
|
50
|
-
* all of them have to skip exactly the same runs.
|
|
51
|
-
*/
|
|
52
|
-
export function isIndexedText(node: Text): boolean {
|
|
53
|
-
const parent = node.parentElement;
|
|
54
|
-
if (parent?.classList.contains("select-none")) return false;
|
|
55
|
-
if (parent?.closest("[data-annotation-id]")) return false;
|
|
56
|
-
return true;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function getTextNodesInRange(range: Range): Text[] {
|
|
60
|
-
const nodes: Text[] = [];
|
|
61
|
-
const ancestor = range.commonAncestorContainer;
|
|
62
|
-
|
|
63
|
-
if (ancestor.nodeType === Node.TEXT_NODE) {
|
|
64
|
-
nodes.push(ancestor as Text);
|
|
65
|
-
return nodes;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const walker = document.createTreeWalker(ancestor, NodeFilter.SHOW_TEXT);
|
|
69
|
-
let node: Text | null;
|
|
70
|
-
while ((node = walker.nextNode() as Text | null)) {
|
|
71
|
-
if (!isIndexedText(node)) continue;
|
|
72
|
-
if (range.intersectsNode(node)) {
|
|
73
|
-
nodes.push(node);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return nodes;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** The part of a text node that falls inside the range. */
|
|
81
|
-
function clampToRange(node: Text, range: Range): string {
|
|
82
|
-
const nodeRange = document.createRange();
|
|
83
|
-
nodeRange.selectNodeContents(node);
|
|
84
|
-
if (node === range.startContainer) nodeRange.setStart(node, range.startOffset);
|
|
85
|
-
if (node === range.endContainer) nodeRange.setEnd(node, range.endOffset);
|
|
86
|
-
return nodeRange.toString();
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* A selection's text as the annotation index will read it back. The raw
|
|
91
|
-
* selection is not that: it carries the gutter and any text already inside
|
|
92
|
-
* another annotation, none of which the index holds — so a snippet taken from
|
|
93
|
-
* it names something that cannot be found again.
|
|
94
|
-
*/
|
|
95
|
-
export function rangeIndexText(range: Range): string {
|
|
96
|
-
let text = "";
|
|
97
|
-
for (const node of getTextNodesInRange(range)) {
|
|
98
|
-
text += clampToRange(node, range);
|
|
99
|
-
}
|
|
100
|
-
return text;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Update the visual state of marks for a given annotation ID.
|
|
105
|
-
*/
|
|
106
|
-
export function setMarkActive(annotationId: string, active: boolean) {
|
|
107
|
-
const marks = document.querySelectorAll(`[data-annotation-id="${annotationId}"]`);
|
|
108
|
-
for (const mark of marks) {
|
|
109
|
-
(mark as HTMLElement).classList.toggle("mark-active", active);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Update active state for annotation marks.
|
|
115
|
-
* Only touches marks for the previous and current active IDs to avoid full DOM scans.
|
|
116
|
-
*/
|
|
117
|
-
export function updateAllMarkStates(activeId: string | null, prevActiveId?: string | null) {
|
|
118
|
-
if (prevActiveId != null && prevActiveId !== activeId) {
|
|
119
|
-
setMarkActive(prevActiveId, false);
|
|
120
|
-
}
|
|
121
|
-
if (activeId != null) {
|
|
122
|
-
setMarkActive(activeId, true);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Rename annotation ID on existing marks (e.g. temp → real ID).
|
|
128
|
-
*/
|
|
129
|
-
export function renameMarkId(oldId: string, newId: string) {
|
|
130
|
-
const marks = document.querySelectorAll(`[data-annotation-id="${oldId}"]`);
|
|
131
|
-
for (const mark of marks) {
|
|
132
|
-
mark.setAttribute("data-annotation-id", newId);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Restore marks for persisted annotations by finding their snippet text in the DOM.
|
|
138
|
-
* Uses before/after context for disambiguation when the same text appears multiple times.
|
|
139
|
-
*/
|
|
140
|
-
export function restoreMarks(
|
|
141
|
-
container: HTMLElement,
|
|
142
|
-
annotations: { id: string; snippet: string; filePath?: string; context?: { before: string; after: string; hierarchy: string[] } }[]
|
|
143
|
-
) {
|
|
144
|
-
for (const ann of annotations) {
|
|
145
|
-
// A canvas can be rendered more than once (overview, compare, hidden
|
|
146
|
-
// source panes), so restore decisions must be scoped to this container.
|
|
147
|
-
if (container.querySelector(`[data-annotation-id="${ann.id}"]`)) continue;
|
|
148
|
-
|
|
149
|
-
const snippet = ann.snippet;
|
|
150
|
-
if (!snippet) continue;
|
|
151
|
-
|
|
152
|
-
// Rebuild after every inserted mark. Wrapping text splits and moves text
|
|
153
|
-
// nodes, so a cached offset map can point at stale DOM after the first hit.
|
|
154
|
-
const { fullText, textNodes } = buildTextIndex(container);
|
|
155
|
-
|
|
156
|
-
// Find all occurrences of the snippet in the full text
|
|
157
|
-
const occurrences: number[] = [];
|
|
158
|
-
let searchFrom = 0;
|
|
159
|
-
while (true) {
|
|
160
|
-
const idx = fullText.indexOf(snippet, searchFrom);
|
|
161
|
-
if (idx === -1) break;
|
|
162
|
-
occurrences.push(idx);
|
|
163
|
-
searchFrom = idx + 1;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const [firstOccurrence] = occurrences;
|
|
167
|
-
if (firstOccurrence === undefined) continue;
|
|
168
|
-
|
|
169
|
-
// Pick the best occurrence using context
|
|
170
|
-
let bestIdx = firstOccurrence;
|
|
171
|
-
if (occurrences.length > 1 && ann.context) {
|
|
172
|
-
let bestScore = -1;
|
|
173
|
-
for (const idx of occurrences) {
|
|
174
|
-
let score = 0;
|
|
175
|
-
if (ann.context.before) {
|
|
176
|
-
const preceding = fullText.slice(Math.max(0, idx - 80), idx);
|
|
177
|
-
if (preceding.includes(ann.context.before)) score += 2;
|
|
178
|
-
else if (ann.context.before.length > 10 && preceding.includes(ann.context.before.slice(-10))) score += 1;
|
|
179
|
-
}
|
|
180
|
-
if (ann.context.after) {
|
|
181
|
-
const following = fullText.slice(idx + snippet.length, idx + snippet.length + 80);
|
|
182
|
-
if (following.includes(ann.context.after)) score += 2;
|
|
183
|
-
else if (ann.context.after.length > 10 && following.includes(ann.context.after.slice(0, 10))) score += 1;
|
|
184
|
-
}
|
|
185
|
-
if (score > bestScore) {
|
|
186
|
-
bestScore = score;
|
|
187
|
-
bestIdx = idx;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Map text offsets to DOM positions
|
|
193
|
-
const startOffset = bestIdx;
|
|
194
|
-
const endOffset = bestIdx + snippet.length;
|
|
195
|
-
|
|
196
|
-
const startPos = offsetToNode(textNodes, startOffset);
|
|
197
|
-
const endPos = offsetToNode(textNodes, endOffset);
|
|
198
|
-
if (!startPos || !endPos) continue;
|
|
199
|
-
|
|
200
|
-
try {
|
|
201
|
-
const range = document.createRange();
|
|
202
|
-
range.setStart(startPos.node, startPos.offset);
|
|
203
|
-
range.setEnd(endPos.node, endPos.offset);
|
|
204
|
-
wrapRangeWithMark(range, ann.id);
|
|
205
|
-
} catch {}
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function buildTextIndex(container: HTMLElement): {
|
|
210
|
-
fullText: string;
|
|
211
|
-
textNodes: { node: Text; start: number }[];
|
|
212
|
-
} {
|
|
213
|
-
const textNodes: { node: Text; start: number }[] = [];
|
|
214
|
-
let fullText = "";
|
|
215
|
-
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
|
216
|
-
let tn: Text | null;
|
|
217
|
-
while ((tn = walker.nextNode() as Text | null)) {
|
|
218
|
-
if (!isIndexedText(tn)) continue;
|
|
219
|
-
textNodes.push({ node: tn, start: fullText.length });
|
|
220
|
-
fullText += tn.textContent || "";
|
|
221
|
-
}
|
|
222
|
-
return { fullText, textNodes };
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function offsetToNode(
|
|
226
|
-
textNodes: { node: Text; start: number }[],
|
|
227
|
-
offset: number
|
|
228
|
-
): { node: Text; offset: number } | null {
|
|
229
|
-
for (let i = textNodes.length - 1; i >= 0; i--) {
|
|
230
|
-
const entry = textNodes[i];
|
|
231
|
-
if (!entry || entry.start > offset) continue;
|
|
232
|
-
const localOffset = offset - entry.start;
|
|
233
|
-
const nodeLen = entry.node.textContent?.length ?? 0;
|
|
234
|
-
if (localOffset <= nodeLen) {
|
|
235
|
-
return { node: entry.node, offset: localOffset };
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
return null;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/**
|
|
242
|
-
* Remove the decoration for a given annotation ID, restoring the original text
|
|
243
|
-
* nodes an inline mark wrapped.
|
|
244
|
-
*/
|
|
245
|
-
export function unwrapMarks(annotationId: string) {
|
|
246
|
-
const marks = document.querySelectorAll(`[data-annotation-id="${annotationId}"]`);
|
|
247
|
-
for (const mark of marks) {
|
|
248
|
-
const parent = mark.parentNode;
|
|
249
|
-
if (!parent) continue;
|
|
250
|
-
// Decoration that is not an inline mark — a region overlay — wraps no text
|
|
251
|
-
// and is simply taken out.
|
|
252
|
-
if (mark.tagName !== "MARK") {
|
|
253
|
-
parent.removeChild(mark);
|
|
254
|
-
continue;
|
|
255
|
-
}
|
|
256
|
-
while (mark.firstChild) {
|
|
257
|
-
parent.insertBefore(mark.firstChild, mark);
|
|
258
|
-
}
|
|
259
|
-
parent.removeChild(mark);
|
|
260
|
-
parent.normalize();
|
|
261
|
-
}
|
|
262
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
/** Width assumed when the caller does not say — the narrow popover. */
|
|
2
|
-
const DEFAULT_POPOVER_WIDTH = 280;
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Read a CSS width the popover will be styled with. Only px is a width this
|
|
6
|
-
* placement can reason about; anything else falls back to the default rather
|
|
7
|
-
* than clamping against a number that means something different.
|
|
8
|
-
*/
|
|
9
|
-
export function pxWidth(css: string): number | undefined {
|
|
10
|
-
const match = /^(\d+(?:\.\d+)?)px$/.exec(css.trim());
|
|
11
|
-
return match ? Number(match[1]) : undefined;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Calculate popover position relative to an anchor element.
|
|
16
|
-
* Returns absolute position within the scroll container (or body).
|
|
17
|
-
*/
|
|
18
|
-
export function getPopoverPosition(
|
|
19
|
-
anchor: HTMLElement,
|
|
20
|
-
scrollContainer?: HTMLElement | null,
|
|
21
|
-
popoverWidth: number = DEFAULT_POPOVER_WIDTH,
|
|
22
|
-
): { style: Record<string, string>; parent: HTMLElement } {
|
|
23
|
-
const gap = 8;
|
|
24
|
-
const parent = scrollContainer || document.body;
|
|
25
|
-
|
|
26
|
-
// Use getBoundingClientRect for both anchor and parent,
|
|
27
|
-
// then add scroll offsets to get absolute position within parent
|
|
28
|
-
const anchorRect = anchor.getBoundingClientRect();
|
|
29
|
-
const parentRect = parent.getBoundingClientRect();
|
|
30
|
-
|
|
31
|
-
// Both rects are viewport-relative, so the subtraction has already cancelled
|
|
32
|
-
// page scroll. Only a scrolling parent adds to that — the body is not what
|
|
33
|
-
// scrolls a document, and counting window.scrollY here counted it twice.
|
|
34
|
-
const scrollTop = parent === document.body ? 0 : parent.scrollTop;
|
|
35
|
-
const top = anchorRect.bottom - parentRect.top + scrollTop + gap;
|
|
36
|
-
|
|
37
|
-
// Horizontal: clamp to parent width
|
|
38
|
-
const left = Math.max(0, Math.min(anchorRect.left - parentRect.left, parentRect.width - popoverWidth - 12));
|
|
39
|
-
|
|
40
|
-
parent.style.position = "relative";
|
|
41
|
-
|
|
42
|
-
return {
|
|
43
|
-
style: { position: "absolute", top: `${top}px`, left: `${left}px` },
|
|
44
|
-
parent,
|
|
45
|
-
};
|
|
46
|
-
}
|
|
@@ -1,180 +0,0 @@
|
|
|
1
|
-
import type { AnnotationTarget } from "./annotationTarget";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* A rectangle or ellipse drawn inside an image block, so a reader can point at
|
|
5
|
-
* one corner of a screenshot instead of the whole picture.
|
|
6
|
-
*
|
|
7
|
-
* Coordinates are normalized against the image box, which is a uniform scale of
|
|
8
|
-
* the image's natural size — that is what lets a region survive a resize, a
|
|
9
|
-
* zoom or a different screen. They are stored on a fixed integer grid rather
|
|
10
|
-
* than as floats: a float printed into the snippet and read back is not
|
|
11
|
-
* guaranteed to be the same float, and a locator that shifts by one ulp names
|
|
12
|
-
* a region nobody drew.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
/** The grid a region snaps to: hundredths of a percent of the image box. */
|
|
16
|
-
export const REGION_UNITS = 10000;
|
|
17
|
-
|
|
18
|
-
/** Under this a drag is a stray click, not a region — 0.3% of the image. */
|
|
19
|
-
const MIN_REGION_SIZE = 30;
|
|
20
|
-
|
|
21
|
-
export type RegionShape = "rect" | "ellipse";
|
|
22
|
-
|
|
23
|
-
export interface RegionPoint {
|
|
24
|
-
x: number;
|
|
25
|
-
y: number;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Left, top, width and height, all in REGION_UNITS. */
|
|
29
|
-
export interface RegionBox extends RegionPoint {
|
|
30
|
-
w: number;
|
|
31
|
-
h: number;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface RegionLocator extends RegionBox {
|
|
35
|
-
/** The image block the region sits in — the same `src` `[Image]` is keyed by. */
|
|
36
|
-
src: string;
|
|
37
|
-
shape: RegionShape;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Marks the element a region is measured against: the image's own box, not the
|
|
41
|
-
* `<figure>`, which also holds the caption and would skew every percentage. */
|
|
42
|
-
export const REGION_HOST_ATTR = "data-annotation-image";
|
|
43
|
-
|
|
44
|
-
// The coordinates are anchored at the end and strictly numeric, so the greedy
|
|
45
|
-
// `src` group can hold anything at all — including a literal " @1,2,3,4" — and
|
|
46
|
-
// still hand the real coordinates to the tail.
|
|
47
|
-
const REGION_PATTERN = /^\[Region:(rect|ellipse)\] ([\s\S]+) @(0|[1-9]\d*),(0|[1-9]\d*),(0|[1-9]\d*),(0|[1-9]\d*)$/;
|
|
48
|
-
|
|
49
|
-
function readShape(value: string | undefined): RegionShape | null {
|
|
50
|
-
if (value === "rect") return "rect";
|
|
51
|
-
if (value === "ellipse") return "ellipse";
|
|
52
|
-
return null;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** A coordinate as `format` would have written it, or null. */
|
|
56
|
-
function readUnit(value: string | undefined): number | null {
|
|
57
|
-
if (value === undefined) return null;
|
|
58
|
-
const n = Number(value);
|
|
59
|
-
if (!Number.isInteger(n) || n < 0 || n > REGION_UNITS) return null;
|
|
60
|
-
return n;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function clampUnit(value: number): number {
|
|
64
|
-
return Math.min(REGION_UNITS, Math.max(0, value));
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Where a pointer landed inside a box, on the region grid. Null for a box with
|
|
68
|
-
* no extent — happy-dom and a not-yet-laid-out image both report zero. */
|
|
69
|
-
export function regionPointIn(box: { width: number; height: number }, offsetX: number, offsetY: number): RegionPoint | null {
|
|
70
|
-
if (!(box.width > 0) || !(box.height > 0)) return null;
|
|
71
|
-
return {
|
|
72
|
-
x: clampUnit(Math.round((offsetX / box.width) * REGION_UNITS)),
|
|
73
|
-
y: clampUnit(Math.round((offsetY / box.height) * REGION_UNITS)),
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** The box two drag corners span, kept inside the image. */
|
|
78
|
-
export function regionBetween(a: RegionPoint, b: RegionPoint): RegionBox {
|
|
79
|
-
const x = Math.min(a.x, b.x);
|
|
80
|
-
const y = Math.min(a.y, b.y);
|
|
81
|
-
return {
|
|
82
|
-
x,
|
|
83
|
-
y,
|
|
84
|
-
w: Math.min(REGION_UNITS - x, Math.abs(a.x - b.x)),
|
|
85
|
-
h: Math.min(REGION_UNITS - y, Math.abs(a.y - b.y)),
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function isDrawableRegion(box: RegionBox): boolean {
|
|
90
|
-
return box.w >= MIN_REGION_SIZE && box.h >= MIN_REGION_SIZE;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** CSS geometry for the overlay. REGION_UNITS is percent × 100, so every value
|
|
94
|
-
* the grid holds divides out exactly. */
|
|
95
|
-
export function regionStyle(box: RegionBox): { left: string; top: string; width: string; height: string } {
|
|
96
|
-
return {
|
|
97
|
-
left: `${box.x / 100}%`,
|
|
98
|
-
top: `${box.y / 100}%`,
|
|
99
|
-
width: `${box.w / 100}%`,
|
|
100
|
-
height: `${box.h / 100}%`,
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function pct(units: number): string {
|
|
105
|
-
return `${(units / 100).toFixed(1).replace(/\.0$/, "")}%`;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export function describeRegion(locator: RegionLocator): string {
|
|
109
|
-
const x = `${pct(locator.x)}-${pct(locator.x + locator.w)}`;
|
|
110
|
-
const y = `${pct(locator.y)}-${pct(locator.y + locator.h)}`;
|
|
111
|
-
return `[Region] ${locator.src} — ${locator.shape} x ${x}, y ${y} of the image`;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** The image box a region belongs to. Compared attribute by attribute rather
|
|
115
|
-
* than through a selector, because an image src is a path and may carry any
|
|
116
|
-
* character a selector would have to escape. */
|
|
117
|
-
export function findRegionHost(src: string, root: ParentNode): HTMLElement | null {
|
|
118
|
-
for (const figure of root.querySelectorAll("[data-md='image']")) {
|
|
119
|
-
if (!(figure instanceof HTMLElement)) continue;
|
|
120
|
-
if (figure.getAttribute("data-md-src") !== src) continue;
|
|
121
|
-
const host = figure.querySelector(`[${REGION_HOST_ATTR}]`);
|
|
122
|
-
if (host instanceof HTMLElement) return host;
|
|
123
|
-
}
|
|
124
|
-
return null;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export function applyRegionStyle(el: HTMLElement, box: RegionBox): void {
|
|
128
|
-
const style = regionStyle(box);
|
|
129
|
-
el.style.left = style.left;
|
|
130
|
-
el.style.top = style.top;
|
|
131
|
-
el.style.width = style.width;
|
|
132
|
-
el.style.height = style.height;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** The overlay box for one region. It carries `data-annotation-id`, so the
|
|
136
|
-
* hover, active-state and delete plumbing that already drives inline marks
|
|
137
|
-
* drives these too. */
|
|
138
|
-
export function createRegionOverlay(box: RegionBox, shape: RegionShape, annotationId: string): HTMLElement {
|
|
139
|
-
const el = document.createElement("div");
|
|
140
|
-
el.className = "ann-region";
|
|
141
|
-
el.setAttribute("data-annotation-id", annotationId);
|
|
142
|
-
el.setAttribute("data-annotation-region", shape);
|
|
143
|
-
applyRegionStyle(el, box);
|
|
144
|
-
return el;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
export const regionTarget: AnnotationTarget<RegionLocator> = {
|
|
148
|
-
kind: "region",
|
|
149
|
-
|
|
150
|
-
parse: (snippet) => {
|
|
151
|
-
const match = REGION_PATTERN.exec(snippet);
|
|
152
|
-
if (!match) return null;
|
|
153
|
-
const shape = readShape(match[1]);
|
|
154
|
-
const src = match[2];
|
|
155
|
-
const x = readUnit(match[3]);
|
|
156
|
-
const y = readUnit(match[4]);
|
|
157
|
-
const w = readUnit(match[5]);
|
|
158
|
-
const h = readUnit(match[6]);
|
|
159
|
-
if (shape === null || src === undefined) return null;
|
|
160
|
-
if (x === null || y === null || w === null || h === null) return null;
|
|
161
|
-
if (w < 1 || h < 1) return null;
|
|
162
|
-
return { src, shape, x, y, w, h };
|
|
163
|
-
},
|
|
164
|
-
|
|
165
|
-
format: (locator) =>
|
|
166
|
-
`[Region:${locator.shape}] ${locator.src} @${locator.x},${locator.y},${locator.w},${locator.h}`,
|
|
167
|
-
|
|
168
|
-
// The overlay is found by annotation id like any other decoration; this is
|
|
169
|
-
// the fallback for before it is drawn, and it lands on the right image.
|
|
170
|
-
find: (locator, root) => findRegionHost(locator.src, root),
|
|
171
|
-
|
|
172
|
-
restore: (locator, root, ann) => {
|
|
173
|
-
if (root.querySelector(`[data-annotation-id="${ann.id}"]`)) return;
|
|
174
|
-
const host = findRegionHost(locator.src, root);
|
|
175
|
-
if (!host) return;
|
|
176
|
-
host.appendChild(createRegionOverlay(locator, locator.shape, ann.id));
|
|
177
|
-
},
|
|
178
|
-
|
|
179
|
-
describe: describeRegion,
|
|
180
|
-
};
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DOM harness for the client tests.
|
|
3
|
-
*
|
|
4
|
-
* Import this **before** the module under test. ESM evaluates imports in order,
|
|
5
|
-
* so putting it first is what guarantees the globals exist by the time anything
|
|
6
|
-
* that reads `document` is evaluated.
|
|
7
|
-
*
|
|
8
|
-
* happy-dom is not a browser: it runs no layout. Everything geometric —
|
|
9
|
-
* `getBoundingClientRect`, `scrollHeight`, `offsetWidth` — reads zero unless the
|
|
10
|
-
* test stubs it, and `scrollIntoView` and CSS animations do nothing. Assert on
|
|
11
|
-
* structure and on the values a function computes, never on real measurements.
|
|
12
|
-
*/
|
|
13
|
-
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
|
14
|
-
|
|
15
|
-
// Bun runs every test file in one process, so the second file to import this
|
|
16
|
-
// must not register over the first.
|
|
17
|
-
if (!globalThis.document) {
|
|
18
|
-
GlobalRegistrator.register();
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Replace the document body with `html` inside a fresh container and return it.
|
|
23
|
-
* Call it at the start of each test — the document is shared across every test
|
|
24
|
-
* in the process, so whatever the previous one left behind is still there.
|
|
25
|
-
*/
|
|
26
|
-
export function mountContainer(html: string): HTMLElement {
|
|
27
|
-
document.body.innerHTML = `<div data-testid="container">${html}</div>`;
|
|
28
|
-
const container = document.body.firstElementChild;
|
|
29
|
-
if (!(container instanceof HTMLElement)) throw new Error("container did not mount");
|
|
30
|
-
return container;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* A Range over the first occurrence of `text` within a single text node of
|
|
35
|
-
* `root`, which is how a reader's selection arrives in the annotation code.
|
|
36
|
-
* Throws rather than returning null so a typo in the fixture fails the test at
|
|
37
|
-
* its cause instead of somewhere downstream.
|
|
38
|
-
*/
|
|
39
|
-
export function selectText(root: HTMLElement, text: string): Range {
|
|
40
|
-
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
41
|
-
let node: Node | null;
|
|
42
|
-
while ((node = walker.nextNode())) {
|
|
43
|
-
const content = node.textContent ?? "";
|
|
44
|
-
const index = content.indexOf(text);
|
|
45
|
-
if (index === -1) continue;
|
|
46
|
-
const range = document.createRange();
|
|
47
|
-
range.setStart(node, index);
|
|
48
|
-
range.setEnd(node, index + text.length);
|
|
49
|
-
return range;
|
|
50
|
-
}
|
|
51
|
-
throw new Error(`no text node in the fixture contains ${JSON.stringify(text)}`);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Ids of the marks currently wrapping annotated text, in document order. */
|
|
55
|
-
export function markIds(root: HTMLElement): string[] {
|
|
56
|
-
return [...root.querySelectorAll("[data-annotation-id]")]
|
|
57
|
-
.map((el) => el.getAttribute("data-annotation-id") ?? "");
|
|
58
|
-
}
|