@fabrika/annotations 0.2.0 → 0.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/annotations",
3
- "version": "0.2.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "description": "React annotation state, text/block/region locators, editor and feedback surface.",
6
6
  "license": "MIT",
@@ -29,6 +29,11 @@
29
29
  "types": "./src/runtime.ts",
30
30
  "default": "./src/runtime.ts"
31
31
  },
32
+ "./schemas": {
33
+ "bun": "./src/schemas.ts",
34
+ "types": "./src/schemas.ts",
35
+ "default": "./src/schemas.ts"
36
+ },
32
37
  "./styles.css": "./src/styles.css",
33
38
  "./testing/dom.ts": {
34
39
  "bun": "./src/testing/dom.ts",
@@ -49,5 +54,8 @@
49
54
  "@happy-dom/global-registrator": {
50
55
  "optional": true
51
56
  }
57
+ },
58
+ "dependencies": {
59
+ "zod": "^3.25.76"
52
60
  }
53
61
  }
@@ -1,5 +1,6 @@
1
1
  import { type AnnotationHost, useAnnotationHost } from '@fabrika/annotations/runtime'
2
2
  import React, { useCallback, useEffect, useRef, useState } from 'react'
3
+ import { localUploadResponseSchema, sharedUploadResponseSchema } from './schemas'
3
4
  import { autoResizeTextarea, hostAcceptsUploads } from './utils'
4
5
 
5
6
  interface AnnotationEditorProps {
@@ -44,11 +45,11 @@ async function uploadImage(host: AnnotationHost, file: File): Promise<string | n
44
45
  formData.append('image', file)
45
46
  try {
46
47
  const resp = await fetch(endpoint(), { method: 'POST', body: formData })
47
- const data = await resp.json()
48
+ const data: unknown = await resp.json()
48
49
  // Local daemon returns { path }; worker returns { url }. Normalise to
49
50
  // a single string that `imageToUrl` can resolve.
50
- if (host.isShared) return data.url || null
51
- return data.path || null
51
+ if (host.isShared) return sharedUploadResponseSchema.parse(data).url || null
52
+ return localUploadResponseSchema.parse(data).path || null
52
53
  } catch {
53
54
  return null
54
55
  }
@@ -1,6 +1,7 @@
1
1
  import { AnnotationCtx, SessionContext, useAnnotationHost } from '@fabrika/annotations/runtime'
2
- import React, { useEffect, useState } from 'react'
2
+ import React, { useEffect, useRef, useState } from 'react'
3
3
  import type { Annotation, AnnotationState } from './runtime'
4
+ import { annotationStateSchema } from './schemas'
4
5
  import { useAnnotationState } from './useAnnotationState'
5
6
 
6
7
  export { useAnnotations } from '@fabrika/annotations/runtime'
@@ -24,6 +25,7 @@ function AnnotationDraft({ scope, isReadOnly = false, remoteAnnotations, loadSta
24
25
  const [annotations, setAnnotations] = useState<Annotation[]>([])
25
26
  const [generalNote, setGeneralNote] = useState('')
26
27
  const [hydrated, setHydrated] = useState(false)
28
+ const savedState = useRef<AnnotationState | null>(null)
27
29
  const value = useAnnotationState({
28
30
  annotations,
29
31
  setAnnotations,
@@ -39,8 +41,10 @@ function AnnotationDraft({ scope, isReadOnly = false, remoteAnnotations, loadSta
39
41
  setHydrated(false)
40
42
  loadState(scope).then((saved) => {
41
43
  if (cancelled) return
42
- setAnnotations(saved?.annotations ?? [])
43
- setGeneralNote(saved?.generalNote ?? '')
44
+ const result = annotationStateSchema.safeParse(saved)
45
+ savedState.current = result.success ? result.data : null
46
+ setAnnotations(result.success ? result.data.annotations : [])
47
+ setGeneralNote(result.success ? result.data.generalNote : '')
44
48
  setHydrated(true)
45
49
  })
46
50
  return () => {
@@ -50,7 +54,7 @@ function AnnotationDraft({ scope, isReadOnly = false, remoteAnnotations, loadSta
50
54
 
51
55
  useEffect(() => {
52
56
  if (!hydrated || isReadOnly) return
53
- const timer = setTimeout(() => saveState(scope, { annotations, generalNote }), 300)
57
+ const timer = setTimeout(() => saveState(scope, { ...savedState.current, annotations, generalNote }), 300)
54
58
  return () => clearTimeout(timer)
55
59
  }, [scope, annotations, generalNote, hydrated, isReadOnly, saveState])
56
60
 
package/src/schemas.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { z } from 'zod'
2
+
3
+ export const annotationContextSchema = z.object({
4
+ before: z.string(),
5
+ after: z.string(),
6
+ hierarchy: z.array(z.string()),
7
+ lineStart: z.number().optional(),
8
+ lineEnd: z.number().optional(),
9
+ }).passthrough()
10
+
11
+ export const annotationAuthorSchema = z.object({ id: z.string(), name: z.string() }).passthrough()
12
+ export const annotationAttachmentSchema = z.object({ url: z.string(), mime: z.string().optional() }).passthrough()
13
+
14
+ export const annotationSchema = z.object({
15
+ id: z.string(),
16
+ snippet: z.string(),
17
+ note: z.string(),
18
+ createdAt: z.string(),
19
+ filePath: z.string().optional(),
20
+ canvasFile: z.string().optional(),
21
+ context: annotationContextSchema.optional(),
22
+ images: z.array(z.string()).optional(),
23
+ attachments: z.array(annotationAttachmentSchema).optional(),
24
+ source: z.enum(['local', 'remote']).optional(),
25
+ author: annotationAuthorSchema.optional(),
26
+ }).passthrough()
27
+
28
+ // A damaged entry must not discard the other annotations in a saved draft.
29
+ export const recoveredAnnotationsSchema = z.array(annotationSchema.optional().catch(undefined)).transform(entries =>
30
+ entries.filter(entry => entry !== undefined)
31
+ ).catch([])
32
+
33
+ export const annotationStateSchema = z.object({
34
+ annotations: recoveredAnnotationsSchema,
35
+ generalNote: z.string().catch(''),
36
+ }).passthrough()
37
+
38
+ export const localUploadResponseSchema = z.object({ path: z.string() })
39
+ export const sharedUploadResponseSchema = z.object({ url: z.string() })