@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Contember Limited
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# @fabrika/annotations
|
|
2
|
+
|
|
3
|
+
React annotation state, text/block/image-region locators, editors, popovers and
|
|
4
|
+
markdown serialization. No Bun runtime, canvas compiler, revision store or
|
|
5
|
+
question/response state is required. Preact hosts can map React imports to
|
|
6
|
+
`preact/compat`.
|
|
7
|
+
|
|
8
|
+
## State and persistence
|
|
9
|
+
|
|
10
|
+
```tsx
|
|
11
|
+
import {
|
|
12
|
+
AnnotationHostContext,
|
|
13
|
+
AnnotationProvider,
|
|
14
|
+
useAnnotations,
|
|
15
|
+
} from '@fabrika/annotations'
|
|
16
|
+
import type { AnnotationState } from '@fabrika/annotations'
|
|
17
|
+
|
|
18
|
+
const drafts = new Map<string, AnnotationState>()
|
|
19
|
+
const loadState = async (scope: string) => drafts.get(scope) ?? null
|
|
20
|
+
const saveState = (scope: string, state: AnnotationState) => {
|
|
21
|
+
drafts.set(scope, state)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function Notes() {
|
|
25
|
+
const { generalNote, setGeneralNote } = useAnnotations()
|
|
26
|
+
return (
|
|
27
|
+
<textarea
|
|
28
|
+
value={generalNote}
|
|
29
|
+
onChange={(event) => setGeneralNote(event.currentTarget.value)}
|
|
30
|
+
/>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function Review() {
|
|
35
|
+
return (
|
|
36
|
+
<AnnotationHostContext.Provider
|
|
37
|
+
value={{
|
|
38
|
+
sessionId: 'review',
|
|
39
|
+
isShared: false,
|
|
40
|
+
fsAvailable: false,
|
|
41
|
+
uploadUrl: null,
|
|
42
|
+
}}
|
|
43
|
+
>
|
|
44
|
+
<AnnotationProvider
|
|
45
|
+
scope="review:image-1"
|
|
46
|
+
loadState={loadState}
|
|
47
|
+
saveState={saveState}
|
|
48
|
+
>
|
|
49
|
+
<Notes />
|
|
50
|
+
</AnnotationProvider>
|
|
51
|
+
</AnnotationHostContext.Provider>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`scope` identifies one draft. Changing it resets the draft and cancels stale loads.
|
|
57
|
+
The host supplies stable `loadState` and `saveState` callbacks. Persisted state
|
|
58
|
+
contains only `annotations` and `generalNote`; remote annotations are merged for
|
|
59
|
+
display but are not saved as local annotations.
|
|
60
|
+
|
|
61
|
+
Hosts with their own persistence coordinator can use `useAnnotationState` with
|
|
62
|
+
controlled state and provide its result through `AnnotationCtx.Provider`.
|
|
63
|
+
|
|
64
|
+
`AnnotationHost` declares upload and file capabilities. It does not load canvas
|
|
65
|
+
modules. `findAnnotationElement(annotation, root)` accepts a host-selected DOM
|
|
66
|
+
root. The optional `canvasFile` annotation metadata is retained for existing
|
|
67
|
+
serialized records; this package does not interpret canvas navigation or revisions.
|
|
68
|
+
|
|
69
|
+
## Bundling and styles
|
|
70
|
+
|
|
71
|
+
An annotation locator implements `AnnotationTarget<L>`: `parse`, `format` and
|
|
72
|
+
`find`, with optional `restore` and `describe`. Text, document blocks and image
|
|
73
|
+
regions are included. Creation and lookup must use the same selector.
|
|
74
|
+
|
|
75
|
+
For one browser bundle, import the package normally. For multiple independently
|
|
76
|
+
built bundles, externalize `@fabrika/annotations/runtime` in **all** of them and
|
|
77
|
+
map it to one shared runtime bundle. That module owns annotation, session and host
|
|
78
|
+
contexts. Never inline a second copy into the components bundle.
|
|
79
|
+
|
|
80
|
+
Import `@fabrika/annotations/styles.css` into a Tailwind v4 stylesheet to scan the
|
|
81
|
+
surface classes. The host supplies theme tokens; the canvas kernel provides its
|
|
82
|
+
own theme through `@fabrika/canvas-kernel/styles.css`.
|
package/index.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export { AnnotationCtx, AnnotationHostContext, SessionContext, useAnnotationHost, useAnnotations } from '@fabrika/annotations/runtime'
|
|
2
|
+
export { extractContext, formatSnippetInContext } from './src/annotationContext'
|
|
3
|
+
export {
|
|
4
|
+
ANNOTATABLE_SELECTOR,
|
|
5
|
+
ANNOTATION_TARGETS,
|
|
6
|
+
BLOCK_SELECTOR,
|
|
7
|
+
describeSnippet,
|
|
8
|
+
findAnnotationElement,
|
|
9
|
+
findSnippetElement,
|
|
10
|
+
getBlockSnippet,
|
|
11
|
+
restoreAnnotationTargets,
|
|
12
|
+
} from './src/annotationDom'
|
|
13
|
+
export { AnnotationDraftFooter } from './src/AnnotationDraftFooter'
|
|
14
|
+
export type { AnnotationDraftFooterProps } from './src/AnnotationDraftFooter'
|
|
15
|
+
export { AnnotationEditor, AttachButton, ImageThumbnails, imageToUrl } from './src/AnnotationEditor'
|
|
16
|
+
export { AnnotationList } from './src/AnnotationList'
|
|
17
|
+
export type { AnnotationListProps } from './src/AnnotationList'
|
|
18
|
+
export { renderAnnotation } from './src/annotationMarkdown'
|
|
19
|
+
export type { RenderableAnnotation } from './src/annotationMarkdown'
|
|
20
|
+
export { AnnotationProvider } from './src/AnnotationProvider'
|
|
21
|
+
export type { AnnotationProviderProps } from './src/AnnotationProvider'
|
|
22
|
+
export { sealTarget } from './src/annotationTarget'
|
|
23
|
+
export type { AnnotationTarget, SealedAnnotationTarget, TargetAnnotation } from './src/annotationTarget'
|
|
24
|
+
export { blockTarget } from './src/blockTarget'
|
|
25
|
+
export type { BlockLocator } from './src/blockTarget'
|
|
26
|
+
export { renameMarkId, restoreMarks, setMarkActive, unwrapMarks, updateAllMarkStates, wrapRangeWithMark } from './src/highlightRange'
|
|
27
|
+
export { AnnotationCreatePopover, AnnotationEditPopover, AnnotationPopover, Popover } from './src/Popover'
|
|
28
|
+
export {
|
|
29
|
+
createRegionOverlay,
|
|
30
|
+
describeRegion,
|
|
31
|
+
findRegionHost,
|
|
32
|
+
isDrawableRegion,
|
|
33
|
+
REGION_HOST_ATTR,
|
|
34
|
+
REGION_UNITS,
|
|
35
|
+
regionBetween,
|
|
36
|
+
regionPointIn,
|
|
37
|
+
regionStyle,
|
|
38
|
+
regionTarget,
|
|
39
|
+
} from './src/regionTarget'
|
|
40
|
+
export type { RegionBox, RegionLocator, RegionPoint, RegionShape } from './src/regionTarget'
|
|
41
|
+
export type {
|
|
42
|
+
Annotation,
|
|
43
|
+
AnnotationAttachment,
|
|
44
|
+
AnnotationAuthor,
|
|
45
|
+
AnnotationContext,
|
|
46
|
+
AnnotationContextValue,
|
|
47
|
+
AnnotationHost,
|
|
48
|
+
AnnotationState,
|
|
49
|
+
} from './src/runtime'
|
|
50
|
+
export { textTarget } from './src/textTarget'
|
|
51
|
+
export type { TextLocator } from './src/textTarget'
|
|
52
|
+
export { useAnnotationState } from './src/useAnnotationState'
|
|
53
|
+
export { useRegionAnnotation } from './src/useRegionAnnotation'
|
|
54
|
+
export { useTextAnnotation } from './src/useTextAnnotation'
|
|
55
|
+
export { autoResizeTextarea, fileAnnotationPath, generateAnnotationId, hostAcceptsUploads, RESPONSE_ANNOTATION_PATH } from './src/utils'
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fabrika/annotations",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "React annotation state, text/block/region locators, editor and feedback surface.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/contember/fabrika-platform.git",
|
|
10
|
+
"directory": "packages/annotations"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"index.ts",
|
|
17
|
+
"src",
|
|
18
|
+
"!src/**/*.test.ts",
|
|
19
|
+
"!src/**/*.test.tsx"
|
|
20
|
+
],
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"bun": "./index.ts",
|
|
24
|
+
"types": "./index.ts",
|
|
25
|
+
"default": "./index.ts"
|
|
26
|
+
},
|
|
27
|
+
"./runtime": {
|
|
28
|
+
"bun": "./src/runtime.ts",
|
|
29
|
+
"types": "./src/runtime.ts",
|
|
30
|
+
"default": "./src/runtime.ts"
|
|
31
|
+
},
|
|
32
|
+
"./styles.css": "./src/styles.css",
|
|
33
|
+
"./testing/dom.ts": {
|
|
34
|
+
"bun": "./src/testing/dom.ts",
|
|
35
|
+
"types": "./src/testing/dom.ts",
|
|
36
|
+
"default": "./src/testing/dom.ts"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"test": "bun test"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@happy-dom/global-registrator": "^20.11.2",
|
|
45
|
+
"react": ">=18",
|
|
46
|
+
"react-dom": ">=18"
|
|
47
|
+
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"@happy-dom/global-registrator": {
|
|
50
|
+
"optional": true
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type React from 'react'
|
|
2
|
+
import { useAnnotations } from './AnnotationProvider'
|
|
3
|
+
import { autoResizeTextarea } from './utils'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The foot of a draft: the note that belongs to no one annotation, and the
|
|
7
|
+
* action that sends the draft off.
|
|
8
|
+
*
|
|
9
|
+
* What counts as content, and what the payload looks like, is the host's —
|
|
10
|
+
* hence `hasContent` and a bare `onSubmit`. The two slots are where a host
|
|
11
|
+
* hangs whatever else its submit step needs (a preview button, a validation
|
|
12
|
+
* notice) without this component learning about it.
|
|
13
|
+
*/
|
|
14
|
+
export interface AnnotationDraftFooterProps {
|
|
15
|
+
/** Whether the draft says anything. Hosts count more than annotations and
|
|
16
|
+
* the general note, so they decide. */
|
|
17
|
+
hasContent: boolean
|
|
18
|
+
onSubmit: () => void
|
|
19
|
+
/** Submitting an empty draft — an explicit "nothing to say", not a no-op. */
|
|
20
|
+
onSubmitEmpty: () => void
|
|
21
|
+
/** Sits between the note and the actions: validation, status, a warning. */
|
|
22
|
+
notice?: React.ReactNode
|
|
23
|
+
/** Sits left of the submit button, sharing the row. */
|
|
24
|
+
secondaryAction?: React.ReactNode
|
|
25
|
+
placeholder?: string
|
|
26
|
+
submitLabel?: string
|
|
27
|
+
emptyLabel?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function AnnotationDraftFooter({
|
|
31
|
+
hasContent,
|
|
32
|
+
onSubmit,
|
|
33
|
+
onSubmitEmpty,
|
|
34
|
+
notice,
|
|
35
|
+
secondaryAction,
|
|
36
|
+
placeholder = 'General notes...',
|
|
37
|
+
submitLabel = 'Submit',
|
|
38
|
+
emptyLabel = 'Submit without feedback',
|
|
39
|
+
}: AnnotationDraftFooterProps) {
|
|
40
|
+
const { generalNote, setGeneralNote } = useAnnotations()
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<>
|
|
44
|
+
{/* General note — seamless */}
|
|
45
|
+
<div className="border-t border-border-subtle px-4 py-3 flex-shrink-0">
|
|
46
|
+
<textarea
|
|
47
|
+
value={generalNote}
|
|
48
|
+
onChange={(e) => setGeneralNote(e.currentTarget.value)}
|
|
49
|
+
className="w-full bg-transparent text-[13px] font-body text-text-primary resize-none leading-relaxed p-0 border-none ring-0 shadow-none outline-none focus:outline-none focus:ring-0 focus:border-none placeholder:text-text-disabled min-h-[40px]"
|
|
50
|
+
placeholder={placeholder}
|
|
51
|
+
onInput={(e) => autoResizeTextarea(e.currentTarget, 40)}
|
|
52
|
+
ref={(el) => {
|
|
53
|
+
if (el) autoResizeTextarea(el, 40)
|
|
54
|
+
}}
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
{notice}
|
|
59
|
+
|
|
60
|
+
<div className="px-4 py-3 border-t border-border-subtle flex-shrink-0">
|
|
61
|
+
{hasContent
|
|
62
|
+
? (
|
|
63
|
+
<div className="flex gap-2">
|
|
64
|
+
{secondaryAction}
|
|
65
|
+
<button
|
|
66
|
+
onClick={onSubmit}
|
|
67
|
+
className="flex-1 py-2 rounded-lg font-body text-[13px] font-medium transition-all bg-btn-primary text-btn-primary-text hover:opacity-90 hover:-translate-y-px shadow-sm"
|
|
68
|
+
>
|
|
69
|
+
{submitLabel}
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
)
|
|
73
|
+
: (
|
|
74
|
+
<button
|
|
75
|
+
onClick={onSubmitEmpty}
|
|
76
|
+
className="w-full py-2 rounded-lg font-body text-[13px] font-medium border border-border-medium text-text-secondary hover:text-text-primary hover:border-border-hover hover:bg-bg-input transition-all"
|
|
77
|
+
>
|
|
78
|
+
{emptyLabel}
|
|
79
|
+
</button>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
</>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { type AnnotationHost, useAnnotationHost } from '@fabrika/annotations/runtime'
|
|
2
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
3
|
+
import { autoResizeTextarea, hostAcceptsUploads } from './utils'
|
|
4
|
+
|
|
5
|
+
interface AnnotationEditorProps {
|
|
6
|
+
note: string
|
|
7
|
+
onNoteChange: (note: string) => void
|
|
8
|
+
images: string[]
|
|
9
|
+
onAddImage: (path: string) => void
|
|
10
|
+
onRemoveImage: (path: string) => void
|
|
11
|
+
sessionId: string
|
|
12
|
+
|
|
13
|
+
placeholder?: string
|
|
14
|
+
autoFocus?: boolean
|
|
15
|
+
autoResize?: boolean
|
|
16
|
+
readOnly?: boolean
|
|
17
|
+
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
|
|
18
|
+
onTextareaClick?: (e: React.MouseEvent<HTMLTextAreaElement>) => void
|
|
19
|
+
textareaRef?: React.MutableRefObject<HTMLTextAreaElement | null>
|
|
20
|
+
textareaClassName?: string
|
|
21
|
+
textareaStyle?: React.CSSProperties
|
|
22
|
+
minHeight?: number
|
|
23
|
+
/** "always" (default), "on-focus" (sidebar), or "none" (popover — use openFilePickerRef) */
|
|
24
|
+
attachButton?: 'always' | 'on-focus' | 'none'
|
|
25
|
+
/** Parent can store the open-file-picker function for external trigger */
|
|
26
|
+
openFilePickerRef?: React.MutableRefObject<(() => void) | null>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function imageToUrl(path: string): string {
|
|
30
|
+
// Shared-mode uploads come back from the worker as absolute URLs under
|
|
31
|
+
// /s/:shareId/uploads/... — return them as-is. Local daemon uploads are
|
|
32
|
+
// stored as absolute filesystem paths; translate to the served URL.
|
|
33
|
+
if (path.startsWith('/s/') || path.startsWith('http://') || path.startsWith('https://')) {
|
|
34
|
+
return path
|
|
35
|
+
}
|
|
36
|
+
const filename = path.split('/').pop()
|
|
37
|
+
return `/api/uploads/${filename}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function uploadImage(host: AnnotationHost, file: File): Promise<string | null> {
|
|
41
|
+
const endpoint = host.uploadUrl
|
|
42
|
+
if (!endpoint) return null
|
|
43
|
+
const formData = new FormData()
|
|
44
|
+
formData.append('image', file)
|
|
45
|
+
try {
|
|
46
|
+
const resp = await fetch(endpoint(), { method: 'POST', body: formData })
|
|
47
|
+
const data = await resp.json()
|
|
48
|
+
// Local daemon returns { path }; worker returns { url }. Normalise to
|
|
49
|
+
// a single string that `imageToUrl` can resolve.
|
|
50
|
+
if (host.isShared) return data.url || null
|
|
51
|
+
return data.path || null
|
|
52
|
+
} catch {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function AnnotationEditor({
|
|
58
|
+
note,
|
|
59
|
+
onNoteChange,
|
|
60
|
+
images,
|
|
61
|
+
onAddImage,
|
|
62
|
+
onRemoveImage,
|
|
63
|
+
sessionId,
|
|
64
|
+
placeholder = 'Add your note...',
|
|
65
|
+
autoFocus = false,
|
|
66
|
+
autoResize = false,
|
|
67
|
+
readOnly = false,
|
|
68
|
+
onKeyDown,
|
|
69
|
+
onTextareaClick,
|
|
70
|
+
textareaRef: externalRef,
|
|
71
|
+
textareaClassName,
|
|
72
|
+
textareaStyle,
|
|
73
|
+
minHeight,
|
|
74
|
+
attachButton = 'always',
|
|
75
|
+
openFilePickerRef,
|
|
76
|
+
}: AnnotationEditorProps) {
|
|
77
|
+
const host = useAnnotationHost()
|
|
78
|
+
// Button, paste and drop all end in the same upload; a host with no endpoint
|
|
79
|
+
// for it gets none of them rather than three ways to fail silently.
|
|
80
|
+
const canAttach = !readOnly && hostAcceptsUploads(host)
|
|
81
|
+
const [isDragging, setIsDragging] = useState(false)
|
|
82
|
+
const [isUploading, setIsUploading] = useState(false)
|
|
83
|
+
const [isFocused, setIsFocused] = useState(false)
|
|
84
|
+
const dragCounter = useRef(0)
|
|
85
|
+
const internalRef = useRef<HTMLTextAreaElement | null>(null)
|
|
86
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
|
87
|
+
|
|
88
|
+
const openFilePicker = useCallback(() => fileInputRef.current?.click(), [])
|
|
89
|
+
|
|
90
|
+
// Expose file picker to parent
|
|
91
|
+
if (openFilePickerRef) openFilePickerRef.current = openFilePicker
|
|
92
|
+
|
|
93
|
+
const setRef = useCallback((el: HTMLTextAreaElement | null) => {
|
|
94
|
+
internalRef.current = el
|
|
95
|
+
if (externalRef) externalRef.current = el
|
|
96
|
+
if (el && autoResize) autoResizeTextarea(el, minHeight)
|
|
97
|
+
}, [externalRef, autoResize, minHeight])
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (autoFocus && internalRef.current) {
|
|
101
|
+
const ta = internalRef.current
|
|
102
|
+
ta.focus()
|
|
103
|
+
if (ta.value) ta.setSelectionRange(ta.value.length, ta.value.length)
|
|
104
|
+
}
|
|
105
|
+
}, [autoFocus])
|
|
106
|
+
|
|
107
|
+
const doUpload = useCallback(async (file: File) => {
|
|
108
|
+
if (!file.type.startsWith('image/')) return
|
|
109
|
+
setIsUploading(true)
|
|
110
|
+
try {
|
|
111
|
+
const path = await uploadImage(host, file)
|
|
112
|
+
if (path) onAddImage(path)
|
|
113
|
+
} finally {
|
|
114
|
+
setIsUploading(false)
|
|
115
|
+
}
|
|
116
|
+
}, [host, onAddImage])
|
|
117
|
+
|
|
118
|
+
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
|
119
|
+
for (const item of e.clipboardData?.items ?? []) {
|
|
120
|
+
if (item.type.startsWith('image/')) {
|
|
121
|
+
e.preventDefault()
|
|
122
|
+
const file = item.getAsFile()
|
|
123
|
+
if (file) doUpload(file)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}, [doUpload])
|
|
128
|
+
|
|
129
|
+
const handleDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
|
130
|
+
e.preventDefault()
|
|
131
|
+
dragCounter.current++
|
|
132
|
+
if (dragCounter.current === 1) setIsDragging(true)
|
|
133
|
+
}, [])
|
|
134
|
+
|
|
135
|
+
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
|
136
|
+
e.preventDefault()
|
|
137
|
+
}, [])
|
|
138
|
+
|
|
139
|
+
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
|
140
|
+
e.preventDefault()
|
|
141
|
+
dragCounter.current--
|
|
142
|
+
if (dragCounter.current === 0) setIsDragging(false)
|
|
143
|
+
}, [])
|
|
144
|
+
|
|
145
|
+
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
|
146
|
+
e.preventDefault()
|
|
147
|
+
dragCounter.current = 0
|
|
148
|
+
setIsDragging(false)
|
|
149
|
+
for (const file of e.dataTransfer?.files ?? []) {
|
|
150
|
+
if (file.type.startsWith('image/')) doUpload(file)
|
|
151
|
+
}
|
|
152
|
+
}, [doUpload])
|
|
153
|
+
|
|
154
|
+
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
155
|
+
onNoteChange(e.currentTarget.value)
|
|
156
|
+
if (autoResize) {
|
|
157
|
+
e.currentTarget.style.height = 'auto'
|
|
158
|
+
e.currentTarget.style.height = (minHeight ? Math.max(minHeight, e.currentTarget.scrollHeight) : e.currentTarget.scrollHeight) + 'px'
|
|
159
|
+
}
|
|
160
|
+
}, [onNoteChange, autoResize, minHeight])
|
|
161
|
+
|
|
162
|
+
const defaultClassName =
|
|
163
|
+
'w-full bg-transparent text-[13px] font-body text-text-primary resize-none focus:outline-none leading-relaxed p-0 border-none'
|
|
164
|
+
const defaultStyle: React.CSSProperties = {
|
|
165
|
+
width: '100%',
|
|
166
|
+
background: 'transparent',
|
|
167
|
+
border: 'none',
|
|
168
|
+
color: 'var(--color-text-primary)',
|
|
169
|
+
fontFamily: "'Inter', sans-serif",
|
|
170
|
+
fontSize: '13px',
|
|
171
|
+
lineHeight: '1.5',
|
|
172
|
+
outline: 'none',
|
|
173
|
+
...(autoResize ? { resize: 'none' as const, overflow: 'hidden' } : { resize: 'vertical' as const }),
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const showButton = canAttach && (
|
|
177
|
+
attachButton === 'always'
|
|
178
|
+
|| (attachButton === 'on-focus' && isFocused)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div
|
|
183
|
+
style={{ position: 'relative' }}
|
|
184
|
+
onDragEnter={canAttach ? handleDragEnter : undefined}
|
|
185
|
+
onDragOver={canAttach ? handleDragOver : undefined}
|
|
186
|
+
onDragLeave={canAttach ? handleDragLeave : undefined}
|
|
187
|
+
onDrop={canAttach ? handleDrop : undefined}
|
|
188
|
+
>
|
|
189
|
+
<textarea
|
|
190
|
+
ref={setRef}
|
|
191
|
+
value={note}
|
|
192
|
+
onChange={readOnly ? undefined : handleChange}
|
|
193
|
+
onPaste={canAttach ? handlePaste : undefined}
|
|
194
|
+
onKeyDown={onKeyDown}
|
|
195
|
+
onClick={onTextareaClick}
|
|
196
|
+
onFocus={() => setIsFocused(true)}
|
|
197
|
+
onBlur={() => {
|
|
198
|
+
// Delay so click on attach button registers before hiding
|
|
199
|
+
setTimeout(() => setIsFocused(false), 150)
|
|
200
|
+
}}
|
|
201
|
+
readOnly={readOnly}
|
|
202
|
+
placeholder={placeholder}
|
|
203
|
+
className={textareaClassName ?? defaultClassName}
|
|
204
|
+
style={textareaStyle ?? defaultStyle}
|
|
205
|
+
rows={autoResize ? 1 : undefined}
|
|
206
|
+
/>
|
|
207
|
+
|
|
208
|
+
{/* Hidden file input — always rendered for paste/drop + external trigger */}
|
|
209
|
+
{canAttach && (
|
|
210
|
+
<input
|
|
211
|
+
ref={fileInputRef}
|
|
212
|
+
type="file"
|
|
213
|
+
accept="image/*"
|
|
214
|
+
multiple
|
|
215
|
+
style={{ display: 'none' }}
|
|
216
|
+
onChange={(e) => {
|
|
217
|
+
const files = e.currentTarget.files
|
|
218
|
+
if (files) { for (const f of files) doUpload(f) }
|
|
219
|
+
e.currentTarget.value = ''
|
|
220
|
+
}}
|
|
221
|
+
/>
|
|
222
|
+
)}
|
|
223
|
+
|
|
224
|
+
{/* Image thumbnails + inline attach button */}
|
|
225
|
+
{(images.length > 0 || showButton) && (
|
|
226
|
+
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', marginTop: '6px', alignItems: 'center' }}>
|
|
227
|
+
{images.map((img) => (
|
|
228
|
+
<div
|
|
229
|
+
key={img}
|
|
230
|
+
style={{
|
|
231
|
+
position: 'relative',
|
|
232
|
+
width: '56px',
|
|
233
|
+
height: '56px',
|
|
234
|
+
borderRadius: '6px',
|
|
235
|
+
overflow: 'hidden',
|
|
236
|
+
border: '1px solid var(--color-border-subtle)',
|
|
237
|
+
flexShrink: 0,
|
|
238
|
+
}}
|
|
239
|
+
>
|
|
240
|
+
<img
|
|
241
|
+
src={imageToUrl(img)}
|
|
242
|
+
alt=""
|
|
243
|
+
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
|
244
|
+
/>
|
|
245
|
+
{!readOnly && (
|
|
246
|
+
<button
|
|
247
|
+
onClick={(e) => {
|
|
248
|
+
e.stopPropagation()
|
|
249
|
+
onRemoveImage(img)
|
|
250
|
+
}}
|
|
251
|
+
style={{
|
|
252
|
+
position: 'absolute',
|
|
253
|
+
top: '2px',
|
|
254
|
+
right: '2px',
|
|
255
|
+
width: '16px',
|
|
256
|
+
height: '16px',
|
|
257
|
+
borderRadius: '50%',
|
|
258
|
+
background: 'rgba(0,0,0,0.6)',
|
|
259
|
+
color: 'white',
|
|
260
|
+
border: 'none',
|
|
261
|
+
cursor: 'pointer',
|
|
262
|
+
fontSize: '11px',
|
|
263
|
+
display: 'flex',
|
|
264
|
+
alignItems: 'center',
|
|
265
|
+
justifyContent: 'center',
|
|
266
|
+
lineHeight: 1,
|
|
267
|
+
padding: 0,
|
|
268
|
+
}}
|
|
269
|
+
title="Remove image"
|
|
270
|
+
>
|
|
271
|
+
×
|
|
272
|
+
</button>
|
|
273
|
+
)}
|
|
274
|
+
</div>
|
|
275
|
+
))}
|
|
276
|
+
{showButton && <AttachButton onClick={openFilePicker} />}
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
|
|
280
|
+
{isUploading && (
|
|
281
|
+
<div
|
|
282
|
+
style={{
|
|
283
|
+
fontSize: '11px',
|
|
284
|
+
color: 'var(--color-text-tertiary)',
|
|
285
|
+
fontFamily: "'Inter', sans-serif",
|
|
286
|
+
marginTop: '4px',
|
|
287
|
+
}}
|
|
288
|
+
>
|
|
289
|
+
Uploading...
|
|
290
|
+
</div>
|
|
291
|
+
)}
|
|
292
|
+
|
|
293
|
+
{isDragging && (
|
|
294
|
+
<div
|
|
295
|
+
style={{
|
|
296
|
+
position: 'absolute',
|
|
297
|
+
inset: 0,
|
|
298
|
+
display: 'flex',
|
|
299
|
+
alignItems: 'center',
|
|
300
|
+
justifyContent: 'center',
|
|
301
|
+
background: 'var(--color-highlight-bg)',
|
|
302
|
+
opacity: 0.9,
|
|
303
|
+
border: '2px dashed var(--color-highlight-border)',
|
|
304
|
+
borderRadius: '6px',
|
|
305
|
+
zIndex: 10,
|
|
306
|
+
pointerEvents: 'none',
|
|
307
|
+
fontSize: '12px',
|
|
308
|
+
fontFamily: "'Inter', sans-serif",
|
|
309
|
+
color: 'var(--color-text-secondary)',
|
|
310
|
+
}}
|
|
311
|
+
>
|
|
312
|
+
Drop image
|
|
313
|
+
</div>
|
|
314
|
+
)}
|
|
315
|
+
</div>
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Small attach-image button — reusable in editor and popover button bar */
|
|
320
|
+
export function AttachButton({ onClick, size = 16 }: { onClick: () => void; size?: number }) {
|
|
321
|
+
return (
|
|
322
|
+
<button
|
|
323
|
+
onClick={(e) => {
|
|
324
|
+
e.stopPropagation()
|
|
325
|
+
onClick()
|
|
326
|
+
}}
|
|
327
|
+
style={{
|
|
328
|
+
width: `${size * 2}px`,
|
|
329
|
+
height: `${size * 2}px`,
|
|
330
|
+
borderRadius: '6px',
|
|
331
|
+
border: 'none',
|
|
332
|
+
background: 'transparent',
|
|
333
|
+
cursor: 'pointer',
|
|
334
|
+
display: 'flex',
|
|
335
|
+
alignItems: 'center',
|
|
336
|
+
justifyContent: 'center',
|
|
337
|
+
color: 'var(--color-text-tertiary)',
|
|
338
|
+
flexShrink: 0,
|
|
339
|
+
transition: 'color 0.15s',
|
|
340
|
+
}}
|
|
341
|
+
title="Attach image"
|
|
342
|
+
onMouseEnter={(e) => {
|
|
343
|
+
e.currentTarget.style.color = 'var(--color-text-secondary)'
|
|
344
|
+
}}
|
|
345
|
+
onMouseLeave={(e) => {
|
|
346
|
+
e.currentTarget.style.color = 'var(--color-text-tertiary)'
|
|
347
|
+
}}
|
|
348
|
+
>
|
|
349
|
+
<svg
|
|
350
|
+
width={size}
|
|
351
|
+
height={size}
|
|
352
|
+
viewBox="0 0 24 24"
|
|
353
|
+
fill="none"
|
|
354
|
+
stroke="currentColor"
|
|
355
|
+
strokeWidth="1.5"
|
|
356
|
+
strokeLinecap="round"
|
|
357
|
+
strokeLinejoin="round"
|
|
358
|
+
>
|
|
359
|
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
|
360
|
+
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
361
|
+
<polyline points="21 15 16 10 5 21" />
|
|
362
|
+
</svg>
|
|
363
|
+
</button>
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Read-only image thumbnails for display in read-only annotations */
|
|
368
|
+
export function ImageThumbnails({ images }: { images: string[] }) {
|
|
369
|
+
if (!images.length) return null
|
|
370
|
+
return (
|
|
371
|
+
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', marginTop: '6px' }}>
|
|
372
|
+
{images.map((img) => (
|
|
373
|
+
<div
|
|
374
|
+
key={img}
|
|
375
|
+
style={{
|
|
376
|
+
width: '56px',
|
|
377
|
+
height: '56px',
|
|
378
|
+
borderRadius: '6px',
|
|
379
|
+
overflow: 'hidden',
|
|
380
|
+
border: '1px solid var(--color-border-subtle)',
|
|
381
|
+
flexShrink: 0,
|
|
382
|
+
}}
|
|
383
|
+
>
|
|
384
|
+
<img
|
|
385
|
+
src={imageToUrl(img)}
|
|
386
|
+
alt=""
|
|
387
|
+
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
|
388
|
+
/>
|
|
389
|
+
</div>
|
|
390
|
+
))}
|
|
391
|
+
</div>
|
|
392
|
+
)
|
|
393
|
+
}
|