@fabrika/canvas-kernel 0.1.45 → 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.
Files changed (66) hide show
  1. package/README.md +33 -67
  2. package/index.ts +19 -83
  3. package/package.json +68 -72
  4. package/src/client/AnnotationProvider.tsx +227 -268
  5. package/src/client/AnnotationSidebar.tsx +392 -338
  6. package/src/client/FileIcon.tsx +106 -65
  7. package/src/client/PlanRenderer.tsx +607 -551
  8. package/src/client/RenderErrorContext.ts +7 -7
  9. package/src/client/ResponsePreview.tsx +192 -173
  10. package/src/client/annotationDom.ts +43 -97
  11. package/src/client/annotationDraft.ts +98 -92
  12. package/src/client/appContext.tsx +40 -40
  13. package/src/client/components/Callout.tsx +20 -20
  14. package/src/client/components/Checklist.tsx +28 -28
  15. package/src/client/components/Choice.tsx +178 -159
  16. package/src/client/components/CodeBlock.tsx +26 -26
  17. package/src/client/components/Diff.tsx +17 -17
  18. package/src/client/components/FilePreview.tsx +99 -74
  19. package/src/client/components/Image.tsx +41 -39
  20. package/src/client/components/Markdown.tsx +33 -32
  21. package/src/client/components/Mermaid.tsx +592 -542
  22. package/src/client/components/Note.tsx +8 -8
  23. package/src/client/components/Priority.tsx +11 -11
  24. package/src/client/components/ResponseNote.tsx +17 -15
  25. package/src/client/components/Section.tsx +28 -26
  26. package/src/client/components/Table.tsx +34 -29
  27. package/src/client/components/Task.tsx +58 -60
  28. package/src/client/components/UserInput.tsx +169 -152
  29. package/src/client/components/index.ts +16 -16
  30. package/src/client/components/renderMarkdownChildren.tsx +86 -68
  31. package/src/client/diffLines.ts +51 -39
  32. package/src/client/generateMarkdown.ts +197 -191
  33. package/src/client/hostContext.tsx +34 -34
  34. package/src/client/index.ts +43 -125
  35. package/src/client/preact-all.ts +4 -4
  36. package/src/client/runtime.ts +58 -111
  37. package/src/client/styles.css +1 -74
  38. package/src/engine/compiler.ts +316 -313
  39. package/src/engine/paths.ts +44 -44
  40. package/src/engine/session.ts +623 -618
  41. package/src/engine/watcher.ts +63 -60
  42. package/src/engine/websocket.ts +123 -121
  43. package/annotate.ts +0 -94
  44. package/daemon.ts +0 -46
  45. package/src/client/AnnotationDraftFooter.tsx +0 -76
  46. package/src/client/AnnotationEditor.tsx +0 -320
  47. package/src/client/AnnotationList.tsx +0 -190
  48. package/src/client/Popover.tsx +0 -361
  49. package/src/client/SessionContext.tsx +0 -3
  50. package/src/client/annotationContext.ts +0 -130
  51. package/src/client/annotationMarkdown.ts +0 -102
  52. package/src/client/annotationTarget.ts +0 -73
  53. package/src/client/blockTarget.ts +0 -127
  54. package/src/client/highlightRange.ts +0 -262
  55. package/src/client/popoverPosition.ts +0 -46
  56. package/src/client/regionTarget.ts +0 -180
  57. package/src/client/testing/dom.ts +0 -74
  58. package/src/client/textTarget.ts +0 -31
  59. package/src/client/useRegionAnnotation.tsx +0 -188
  60. package/src/client/useTextAnnotation.tsx +0 -194
  61. package/src/client/utils.ts +0 -33
  62. package/src/engine/channel.ts +0 -278
  63. package/src/engine/http-utils.ts +0 -14
  64. package/src/engine/lifecycle.ts +0 -302
  65. package/src/engine/router.ts +0 -16
  66. 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,74 +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
- // happy-dom replaces the whole fetch stack with a Window implementation over
19
- // node:http. That is not what this harness is for, and because every test
20
- // file shares one process it reaches code that has nothing to do with the
21
- // DOM — the daemon lifecycle probes `/health` through the global `fetch` and
22
- // fails on responses Bun parses fine. Which tests broke depended on file
23
- // order, so it passed locally and failed in CI. Keep Bun's stack.
24
- const runtimeFetch = globalThis.fetch;
25
- const runtimeResponse = globalThis.Response;
26
- const runtimeRequest = globalThis.Request;
27
- const runtimeHeaders = globalThis.Headers;
28
-
29
- GlobalRegistrator.register();
30
-
31
- globalThis.fetch = runtimeFetch;
32
- globalThis.Response = runtimeResponse;
33
- globalThis.Request = runtimeRequest;
34
- globalThis.Headers = runtimeHeaders;
35
- }
36
-
37
- /**
38
- * Replace the document body with `html` inside a fresh container and return it.
39
- * Call it at the start of each test — the document is shared across every test
40
- * in the process, so whatever the previous one left behind is still there.
41
- */
42
- export function mountContainer(html: string): HTMLElement {
43
- document.body.innerHTML = `<div data-testid="container">${html}</div>`;
44
- const container = document.body.firstElementChild;
45
- if (!(container instanceof HTMLElement)) throw new Error("container did not mount");
46
- return container;
47
- }
48
-
49
- /**
50
- * A Range over the first occurrence of `text` within a single text node of
51
- * `root`, which is how a reader's selection arrives in the annotation code.
52
- * Throws rather than returning null so a typo in the fixture fails the test at
53
- * its cause instead of somewhere downstream.
54
- */
55
- export function selectText(root: HTMLElement, text: string): Range {
56
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
57
- let node: Node | null;
58
- while ((node = walker.nextNode())) {
59
- const content = node.textContent ?? "";
60
- const index = content.indexOf(text);
61
- if (index === -1) continue;
62
- const range = document.createRange();
63
- range.setStart(node, index);
64
- range.setEnd(node, index + text.length);
65
- return range;
66
- }
67
- throw new Error(`no text node in the fixture contains ${JSON.stringify(text)}`);
68
- }
69
-
70
- /** Ids of the marks currently wrapping annotated text, in document order. */
71
- export function markIds(root: HTMLElement): string[] {
72
- return [...root.querySelectorAll("[data-annotation-id]")]
73
- .map((el) => el.getAttribute("data-annotation-id") ?? "");
74
- }