@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.
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/index.ts +55 -0
- package/package.json +53 -0
- package/src/AnnotationDraftFooter.tsx +84 -0
- package/src/AnnotationEditor.tsx +393 -0
- package/src/AnnotationList.tsx +214 -0
- package/src/AnnotationProvider.tsx +62 -0
- package/src/Popover.tsx +496 -0
- package/src/annotationContext.ts +130 -0
- package/src/annotationDom.ts +42 -0
- package/src/annotationMarkdown.ts +102 -0
- package/src/annotationTarget.ts +73 -0
- package/src/blockTarget.ts +127 -0
- package/src/highlightRange.ts +262 -0
- package/src/popoverPosition.ts +46 -0
- package/src/regionTarget.ts +179 -0
- package/src/runtime.ts +86 -0
- package/src/styles.css +70 -0
- package/src/testing/dom.ts +74 -0
- package/src/textTarget.ts +31 -0
- package/src/useAnnotationState.ts +114 -0
- package/src/useRegionAnnotation.tsx +190 -0
- package/src/useTextAnnotation.tsx +208 -0
- package/src/utils.ts +33 -0
|
@@ -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
|
+
}
|