@fabrika/annotations 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.
@@ -0,0 +1,179 @@
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) => `[Region:${locator.shape}] ${locator.src} @${locator.x},${locator.y},${locator.w},${locator.h}`,
166
+
167
+ // The overlay is found by annotation id like any other decoration; this is
168
+ // the fallback for before it is drawn, and it lands on the right image.
169
+ find: (locator, root) => findRegionHost(locator.src, root),
170
+
171
+ restore: (locator, root, ann) => {
172
+ if (root.querySelector(`[data-annotation-id="${ann.id}"]`)) return
173
+ const host = findRegionHost(locator.src, root)
174
+ if (!host) return
175
+ host.appendChild(createRegionOverlay(locator, locator.shape, ann.id))
176
+ },
177
+
178
+ describe: describeRegion,
179
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,86 @@
1
+ import { createContext, useContext } from 'react'
2
+
3
+ export interface AnnotationContext {
4
+ before: string
5
+ after: string
6
+ hierarchy: string[]
7
+ lineStart?: number
8
+ lineEnd?: number
9
+ }
10
+
11
+ export interface AnnotationAuthor {
12
+ id: string
13
+ name: string
14
+ }
15
+
16
+ export interface AnnotationAttachment {
17
+ url: string
18
+ mime?: string
19
+ }
20
+
21
+ export interface Annotation {
22
+ id: string
23
+ snippet: string
24
+ note: string
25
+ createdAt: string
26
+ filePath?: string
27
+ canvasFile?: string
28
+ context?: AnnotationContext
29
+ images?: string[]
30
+ attachments?: AnnotationAttachment[]
31
+ source?: 'local' | 'remote'
32
+ author?: AnnotationAuthor
33
+ }
34
+
35
+ export interface AnnotationState {
36
+ annotations: Annotation[]
37
+ generalNote: string
38
+ }
39
+
40
+ export interface AnnotationContextValue extends AnnotationState {
41
+ addAnnotation: (snippet: string, note: string, filePath?: string) => void
42
+ addAnnotationWithId: (
43
+ id: string,
44
+ snippet: string,
45
+ note: string,
46
+ filePath?: string,
47
+ context?: AnnotationContext,
48
+ images?: string[],
49
+ canvasFile?: string,
50
+ ) => void
51
+ updateAnnotation: (id: string, note: string) => void
52
+ removeAnnotation: (id: string) => void
53
+ addAnnotationImage: (id: string, imagePath: string) => void
54
+ removeAnnotationImage: (id: string, imagePath: string) => void
55
+ setGeneralNote: (text: string) => void
56
+ clearAll: () => void
57
+ activeAnnotationId: string | null
58
+ setActiveAnnotationId: (id: string | null) => void
59
+ isReadOnly: boolean
60
+ }
61
+
62
+ export interface AnnotationHost {
63
+ sessionId: string
64
+ isShared: boolean
65
+ fsAvailable: boolean
66
+ uploadUrl: (() => string) | null
67
+ }
68
+
69
+ export const AnnotationCtx = createContext<AnnotationContextValue | null>(null)
70
+ export const SessionContext = createContext<string>('')
71
+ export const AnnotationHostContext = createContext<AnnotationHost>({
72
+ sessionId: '',
73
+ isShared: false,
74
+ fsAvailable: false,
75
+ uploadUrl: null,
76
+ })
77
+
78
+ export function useAnnotations(): AnnotationContextValue {
79
+ const value = useContext(AnnotationCtx)
80
+ if (!value) throw new Error('useAnnotations requires an AnnotationProvider')
81
+ return value
82
+ }
83
+
84
+ export function useAnnotationHost(): AnnotationHost {
85
+ return useContext(AnnotationHostContext)
86
+ }
package/src/styles.css ADDED
@@ -0,0 +1,70 @@
1
+ @source "./**/*.{tsx,ts,jsx,js}";
2
+
3
+ mark[data-annotation-id] {
4
+ background: var(--color-highlight-annotation);
5
+ border-bottom: 1.5px solid var(--color-highlight-bg);
6
+ border-radius: 2px;
7
+ cursor: pointer;
8
+ transition: background 150ms ease, box-shadow 150ms ease;
9
+ color: inherit;
10
+ padding: 1px 0;
11
+ }
12
+
13
+ mark[data-annotation-id].mark-active {
14
+ background: var(--color-highlight-active);
15
+ border-bottom: 2px solid var(--color-highlight-border);
16
+ box-shadow: 0 0 0 1px var(--color-highlight-border);
17
+ }
18
+
19
+ @keyframes ann-flash {
20
+ 0%, 100% { background: var(--color-highlight-annotation); }
21
+ 25%, 75% { background: var(--color-highlight-active); }
22
+ }
23
+
24
+ .ann-flash {
25
+ animation: ann-flash 0.6s ease 2;
26
+ }
27
+
28
+ [data-annotation-image] {
29
+ position: relative;
30
+ cursor: crosshair;
31
+ touch-action: none;
32
+ }
33
+
34
+ .ann-region {
35
+ position: absolute;
36
+ background: var(--color-highlight-annotation);
37
+ border: 2px solid var(--color-highlight-bg);
38
+ border-radius: 2px;
39
+ cursor: pointer;
40
+ transition: background 150ms ease, box-shadow 150ms ease;
41
+ }
42
+
43
+ .ann-region[data-annotation-region="ellipse"] {
44
+ border-radius: 50%;
45
+ }
46
+
47
+ .ann-region.mark-active {
48
+ background: var(--color-highlight-active);
49
+ border-color: var(--color-highlight-border);
50
+ box-shadow: 0 0 0 1px var(--color-highlight-border);
51
+ }
52
+
53
+ .ann-region-draft {
54
+ pointer-events: none;
55
+ border-style: dashed;
56
+ border-color: var(--color-highlight-border);
57
+ }
58
+
59
+ .annotation-highlight {
60
+ background: var(--color-highlight-bg);
61
+ border-bottom: 2px solid var(--color-highlight-border);
62
+ border-radius: 2px;
63
+ cursor: pointer;
64
+ transition: background 150ms ease;
65
+ }
66
+
67
+ .annotation-highlight:hover,
68
+ .annotation-highlight.active {
69
+ background: var(--color-highlight-active);
70
+ }
@@ -0,0 +1,74 @@
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
+ }
@@ -0,0 +1,31 @@
1
+ import type { AnnotationTarget } from './annotationTarget'
2
+ import { restoreMarks } from './highlightRange'
3
+
4
+ /** A run of text, identified by the text itself. */
5
+ export interface TextLocator {
6
+ text: string
7
+ }
8
+
9
+ /**
10
+ * Selected text, wrapped in inline `<mark>` elements.
11
+ *
12
+ * The fallback target: any snippet at all is text, so this one claims
13
+ * everything the others turned down and must stay last in the registry.
14
+ */
15
+ export const textTarget: AnnotationTarget<TextLocator> = {
16
+ kind: 'text',
17
+
18
+ parse: (snippet) => (snippet ? { text: snippet } : null),
19
+
20
+ format: (locator) => locator.text,
21
+
22
+ // A run of text has no element of its own until a mark wraps it, and the mark
23
+ // is found by annotation id, not by the text. Guessing the element back from
24
+ // page text would resolve an annotation onto whatever happens to read the
25
+ // same today, so a missing mark stays a miss.
26
+ find: () => null,
27
+
28
+ restore: (_locator, root, ann) => {
29
+ restoreMarks(root, [ann])
30
+ },
31
+ }
@@ -0,0 +1,114 @@
1
+ import { useCallback, useMemo, useState } from 'react'
2
+ import type { Dispatch, SetStateAction } from 'react'
3
+ import type { Annotation, AnnotationContext, AnnotationContextValue } from './runtime'
4
+ import { generateAnnotationId } from './utils'
5
+
6
+ interface AnnotationStateOptions {
7
+ annotations: Annotation[]
8
+ setAnnotations: Dispatch<SetStateAction<Annotation[]>>
9
+ generalNote: string
10
+ setGeneralNote: (note: string) => void
11
+ remoteAnnotations?: Annotation[]
12
+ isReadOnly: boolean
13
+ onClear?: () => void
14
+ }
15
+
16
+ function removeMarks(id?: string) {
17
+ const selector = id ? `[data-annotation-id="${CSS.escape(id)}"]` : '[data-annotation-id]'
18
+ for (const mark of document.querySelectorAll(selector)) {
19
+ if (mark.tagName === 'MARK') {
20
+ const parent = mark.parentNode
21
+ mark.replaceWith(document.createTextNode(mark.textContent || ''))
22
+ parent?.normalize()
23
+ } else {
24
+ mark.remove()
25
+ }
26
+ }
27
+ }
28
+
29
+ export function useAnnotationState({
30
+ annotations: localAnnotations,
31
+ setAnnotations,
32
+ generalNote,
33
+ setGeneralNote,
34
+ remoteAnnotations,
35
+ isReadOnly,
36
+ onClear,
37
+ }: AnnotationStateOptions): AnnotationContextValue {
38
+ const [activeAnnotationId, setActiveAnnotationId] = useState<string | null>(null)
39
+ const annotations = useMemo<Annotation[]>(() => {
40
+ if (!remoteAnnotations?.length) return localAnnotations
41
+ const ids = new Set(localAnnotations.map((annotation) => annotation.id))
42
+ const remote = remoteAnnotations.filter((annotation) => !ids.has(annotation.id))
43
+ return [...localAnnotations, ...remote.map((annotation): Annotation => ({ ...annotation, source: 'remote' }))]
44
+ }, [localAnnotations, remoteAnnotations])
45
+
46
+ const addAnnotationWithId = useCallback(
47
+ (id: string, snippet: string, note: string, filePath?: string, context?: AnnotationContext, images?: string[], canvasFile?: string) => {
48
+ setAnnotations((prev) => [...prev, {
49
+ id,
50
+ snippet,
51
+ note,
52
+ createdAt: new Date().toISOString(),
53
+ filePath,
54
+ context,
55
+ ...(images?.length ? { images } : {}),
56
+ ...(canvasFile ? { canvasFile } : {}),
57
+ }])
58
+ },
59
+ [setAnnotations],
60
+ )
61
+ const addAnnotation = useCallback((snippet: string, note: string, filePath?: string) => {
62
+ addAnnotationWithId(generateAnnotationId(), snippet, note, filePath)
63
+ }, [addAnnotationWithId])
64
+ const updateAnnotation = useCallback((id: string, note: string) => {
65
+ setAnnotations((prev) => prev.map((annotation) => annotation.id === id ? { ...annotation, note } : annotation))
66
+ }, [setAnnotations])
67
+ const removeAnnotation = useCallback((id: string) => {
68
+ if (!localAnnotations.some((annotation) => annotation.id === id)) return
69
+ removeMarks(id)
70
+ setAnnotations((prev) => prev.filter((annotation) => annotation.id !== id))
71
+ setActiveAnnotationId((prev) => prev === id ? null : prev)
72
+ }, [localAnnotations, setAnnotations])
73
+ const addAnnotationImage = useCallback((id: string, imagePath: string) => {
74
+ setAnnotations((prev) =>
75
+ prev.map((annotation) =>
76
+ annotation.id === id
77
+ ? { ...annotation, images: [...(annotation.images || []), imagePath] }
78
+ : annotation
79
+ )
80
+ )
81
+ }, [setAnnotations])
82
+ const removeAnnotationImage = useCallback((id: string, imagePath: string) => {
83
+ setAnnotations((prev) =>
84
+ prev.map((annotation) =>
85
+ annotation.id === id
86
+ ? { ...annotation, images: (annotation.images || []).filter((image) => image !== imagePath) }
87
+ : annotation
88
+ )
89
+ )
90
+ }, [setAnnotations])
91
+ const clearAll = useCallback(() => {
92
+ removeMarks()
93
+ setAnnotations([])
94
+ setGeneralNote('')
95
+ setActiveAnnotationId(null)
96
+ onClear?.()
97
+ }, [setAnnotations, setGeneralNote, onClear])
98
+
99
+ return {
100
+ annotations,
101
+ generalNote,
102
+ setGeneralNote,
103
+ activeAnnotationId,
104
+ setActiveAnnotationId,
105
+ addAnnotation,
106
+ addAnnotationWithId,
107
+ updateAnnotation,
108
+ removeAnnotation,
109
+ addAnnotationImage,
110
+ removeAnnotationImage,
111
+ clearAll,
112
+ isReadOnly,
113
+ }
114
+ }