@fabrika/canvas-kernel 0.1.45 → 0.3.1

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 (70) hide show
  1. package/README.md +33 -67
  2. package/index.ts +20 -83
  3. package/package.json +69 -72
  4. package/src/client/AnnotationProvider.tsx +218 -255
  5. package/src/client/AnnotationSidebar.tsx +393 -338
  6. package/src/client/FileIcon.tsx +105 -66
  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 +106 -96
  12. package/src/client/appContext.tsx +40 -40
  13. package/src/client/components/Callout.tsx +20 -20
  14. package/src/client/components/Checklist.tsx +27 -29
  15. package/src/client/components/Choice.tsx +178 -159
  16. package/src/client/components/CodeBlock.tsx +27 -26
  17. package/src/client/components/Diff.tsx +16 -17
  18. package/src/client/components/FilePreview.tsx +102 -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 +593 -542
  22. package/src/client/components/Note.tsx +8 -8
  23. package/src/client/components/Priority.tsx +10 -12
  24. package/src/client/components/ResponseNote.tsx +16 -15
  25. package/src/client/components/Section.tsx +28 -26
  26. package/src/client/components/Table.tsx +33 -30
  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 +39 -34
  34. package/src/client/index.ts +44 -125
  35. package/src/client/pageGlobals.ts +25 -0
  36. package/src/client/persistenceSchemas.ts +41 -0
  37. package/src/client/preact-all.ts +4 -4
  38. package/src/client/runtime.ts +58 -111
  39. package/src/client/styles.css +1 -74
  40. package/src/engine/compiler.ts +411 -323
  41. package/src/engine/file-admission.ts +25 -0
  42. package/src/engine/paths.ts +44 -44
  43. package/src/engine/session.ts +515 -626
  44. package/src/engine/sessionSchemas.ts +71 -0
  45. package/src/engine/watcher.ts +70 -60
  46. package/src/engine/websocket.ts +134 -132
  47. package/annotate.ts +0 -94
  48. package/daemon.ts +0 -46
  49. package/src/client/AnnotationDraftFooter.tsx +0 -76
  50. package/src/client/AnnotationEditor.tsx +0 -320
  51. package/src/client/AnnotationList.tsx +0 -190
  52. package/src/client/Popover.tsx +0 -361
  53. package/src/client/SessionContext.tsx +0 -3
  54. package/src/client/annotationContext.ts +0 -130
  55. package/src/client/annotationMarkdown.ts +0 -102
  56. package/src/client/annotationTarget.ts +0 -73
  57. package/src/client/blockTarget.ts +0 -127
  58. package/src/client/highlightRange.ts +0 -262
  59. package/src/client/popoverPosition.ts +0 -46
  60. package/src/client/regionTarget.ts +0 -180
  61. package/src/client/testing/dom.ts +0 -74
  62. package/src/client/textTarget.ts +0 -31
  63. package/src/client/useRegionAnnotation.tsx +0 -188
  64. package/src/client/useTextAnnotation.tsx +0 -194
  65. package/src/client/utils.ts +0 -33
  66. package/src/engine/channel.ts +0 -278
  67. package/src/engine/http-utils.ts +0 -14
  68. package/src/engine/lifecycle.ts +0 -302
  69. package/src/engine/router.ts +0 -16
  70. package/src/engine/waiter.ts +0 -188
@@ -1,55 +1,58 @@
1
- import React, { useState, useCallback, useEffect, useRef, useMemo } from "react";
2
- import { AnnotationCtx } from "#canvas/runtime";
3
- import type { Annotation, AnnotationContext, PlanResponse, FeedbackEntry, AnnotationContextValue } from "#canvas/runtime";
4
- import { annotationDraftKey, clearPersistedDraft, type AnnotationDraftPhase } from "./annotationDraft";
5
- import { canPruneResponses, pruneStaleResponses } from "./generateMarkdown";
6
- import { generateAnnotationId } from "./utils";
1
+ import { AnnotationCtx } from '#canvas/runtime'
2
+ import type { Annotation, AnnotationContext, AnnotationContextValue, FeedbackEntry, PlanResponse } from '#canvas/runtime'
3
+ import { useAnnotationState } from '@fabrika/annotations'
4
+ import { AnnotationCtx as SurfaceAnnotationCtx, AnnotationHostContext } from '@fabrika/annotations/runtime'
5
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
6
+ import { annotationDraftKey, type AnnotationDraftPhase, clearPersistedDraft } from './annotationDraft'
7
+ import { canPruneResponses, pruneStaleResponses } from './generateMarkdown'
8
+ import { useCanvasHost } from './hostContext'
9
+ import { parsePersistedState } from './persistenceSchemas'
7
10
 
8
11
  // Re-export types for convenience
9
- export type { Annotation, AnnotationContext, PlanResponse, FeedbackEntry, AnnotationContextValue };
10
- export { useAnnotations } from "#canvas/runtime";
12
+ export type { Annotation, AnnotationContext, AnnotationContextValue, FeedbackEntry, PlanResponse }
13
+ export { useAnnotations } from '#canvas/runtime'
11
14
 
12
15
  export interface PersistedState {
13
- annotations: Annotation[];
14
- generalNote: string;
15
- responses: [string, PlanResponse][];
16
- feedbackEntries?: [string, FeedbackEntry][];
16
+ annotations: Annotation[]
17
+ generalNote: string
18
+ responses: [string, PlanResponse][]
19
+ feedbackEntries?: [string, FeedbackEntry][]
17
20
  }
18
21
 
19
22
  interface AnnotationProviderProps {
20
- sessionId: string;
21
- revision: number;
22
- isReadOnly: boolean;
23
- draftPhase: AnnotationDraftPhase;
24
- /** Remote annotations fetched from shared views. Merged read-only into
25
- * the annotation list so they render alongside the author's own. */
26
- remoteAnnotations?: Annotation[];
27
- /** Canvas files this revision is made of. Supplying it enables pruning of
28
- * answers to questions the revision no longer asks; omit it and every
29
- * answer is submitted, which is what a host without canvas tabs wants. */
30
- canvasFiles?: readonly string[];
31
- /** Server-authoritative persistence. When supplied, the draft is loaded and
32
- * saved through these instead of localStorage, so it survives reloads and
33
- * follows the author across browsers. Omit for the localStorage default. */
34
- loadState?: (sessionId: string, revision: number, phase: AnnotationDraftPhase) => Promise<PersistedState | null>;
35
- saveState?: (sessionId: string, revision: number, state: PersistedState, phase: AnnotationDraftPhase) => void;
36
- children: React.ReactNode;
23
+ sessionId: string
24
+ revision: number
25
+ isReadOnly: boolean
26
+ draftPhase: AnnotationDraftPhase
27
+ /** Remote annotations fetched from shared views. Merged read-only into
28
+ * the annotation list so they render alongside the author's own. */
29
+ remoteAnnotations?: Annotation[]
30
+ /** Canvas files this revision is made of. Supplying it enables pruning of
31
+ * answers to questions the revision no longer asks; omit it and every
32
+ * answer is submitted, which is what a host without canvas tabs wants. */
33
+ canvasFiles?: readonly string[]
34
+ /** Server-authoritative persistence. When supplied, the draft is loaded and
35
+ * saved through these instead of localStorage, so it survives reloads and
36
+ * follows the author across browsers. Omit for the localStorage default. */
37
+ loadState?: (sessionId: string, revision: number, phase: AnnotationDraftPhase) => Promise<PersistedState | null>
38
+ saveState?: (sessionId: string, revision: number, state: PersistedState, phase: AnnotationDraftPhase) => void
39
+ children: React.ReactNode
37
40
  }
38
41
 
39
42
  function loadPersisted(sessionId: string, revision: number, phase: AnnotationDraftPhase): PersistedState | null {
40
- try {
41
- const raw = localStorage.getItem(annotationDraftKey(sessionId, revision, phase));
42
- if (!raw) return null;
43
- return JSON.parse(raw);
44
- } catch {
45
- return null;
46
- }
43
+ try {
44
+ const raw = localStorage.getItem(annotationDraftKey(sessionId, revision, phase))
45
+ if (!raw) return null
46
+ return parsePersistedState(JSON.parse(raw))
47
+ } catch {
48
+ return null
49
+ }
47
50
  }
48
51
 
49
52
  function savePersisted(sessionId: string, revision: number, phase: AnnotationDraftPhase, state: PersistedState) {
50
- try {
51
- localStorage.setItem(annotationDraftKey(sessionId, revision, phase), JSON.stringify(state));
52
- } catch {}
53
+ try {
54
+ localStorage.setItem(annotationDraftKey(sessionId, revision, phase), JSON.stringify(state))
55
+ } catch {}
53
56
  }
54
57
 
55
58
  /** What the reader has been shown for one draft. Stamped with the draft it
@@ -57,238 +60,198 @@ function savePersisted(sessionId: string, revision: number, phase: AnnotationDra
57
60
  * their own effects, which run before the provider's, so an effect-based reset
58
61
  * would wipe registrations that already happened. */
59
62
  interface SeenState {
60
- draft: string;
61
- responseIds: Set<string>;
62
- canvases: Set<string>;
63
+ draft: string
64
+ responseIds: Set<string>
65
+ canvases: Set<string>
63
66
  }
64
67
 
65
68
  function seenFor(draft: string): SeenState {
66
- return { draft, responseIds: new Set(), canvases: new Set() };
69
+ return { draft, responseIds: new Set(), canvases: new Set() }
67
70
  }
68
71
 
69
- export function AnnotationProvider({ sessionId, revision, isReadOnly, draftPhase, remoteAnnotations, canvasFiles, loadState, saveState, children }: AnnotationProviderProps) {
70
- // Server-authoritative persistence loads asynchronously, so that mode starts
71
- // empty and hydrates in an effect. localStorage mode seeds synchronously.
72
- const serverMode = !!loadState;
73
- const seed = serverMode ? null : loadPersisted(sessionId, revision, draftPhase);
72
+ export function AnnotationProvider(
73
+ { sessionId, revision, isReadOnly, draftPhase, remoteAnnotations, canvasFiles, loadState, saveState, children }: AnnotationProviderProps,
74
+ ) {
75
+ // Server-authoritative persistence loads asynchronously, so that mode starts
76
+ // empty and hydrates in an effect. localStorage mode seeds synchronously.
77
+ const serverMode = !!loadState
78
+ const seed = serverMode ? null : loadPersisted(sessionId, revision, draftPhase)
79
+ const savedState = useRef<PersistedState | null>(seed)
74
80
 
75
- const [localAnnotations, setAnnotations] = useState<Annotation[]>(() =>
76
- (seed?.annotations ?? []).map((a) => ({ ...a, source: a.source ?? "local" as const })),
77
- );
81
+ const [localAnnotations, setAnnotations] = useState<Annotation[]>(() =>
82
+ (seed?.annotations ?? []).map((a) => ({ ...a, source: a.source ?? 'local' as const }))
83
+ )
78
84
 
79
- // Merge local + remote annotations. Remote annotations are always
80
- // rendered read-only; mutation helpers below operate on localAnnotations
81
- // only so they silently no-op on remote ids.
82
- const annotations = useMemo<Annotation[]>(() => {
83
- if (!remoteAnnotations || remoteAnnotations.length === 0) return localAnnotations;
84
- const localIds = new Set(localAnnotations.map((a) => a.id));
85
- const filtered = remoteAnnotations.filter((a) => !localIds.has(a.id));
86
- return [...localAnnotations, ...filtered.map((a) => ({ ...a, source: "remote" as const }))];
87
- }, [localAnnotations, remoteAnnotations]);
88
- const [generalNote, setGeneralNote] = useState(() => seed?.generalNote ?? "");
89
- const [activeAnnotationId, setActiveAnnotationId] = useState<string | null>(null);
90
- const [responses, setResponses] = useState<Map<string, PlanResponse>>(() =>
91
- seed?.responses ? new Map(seed.responses) : new Map(),
92
- );
93
- const draftId = `${sessionId}:${revision}:${draftPhase}`;
94
- const draftIdRef = useRef(draftId);
95
- draftIdRef.current = draftId;
96
- const [seen, setSeen] = useState<SeenState>(() => seenFor(draftId));
97
- const [feedbackEntries, setFeedbackEntries] = useState<Map<string, FeedbackEntry>>(() =>
98
- seed?.feedbackEntries ? new Map(seed.feedbackEntries) : new Map(),
99
- );
85
+ const [generalNote, setGeneralNote] = useState(() => seed?.generalNote ?? '')
86
+ const [responses, setResponses] = useState<Map<string, PlanResponse>>(() => seed?.responses ? new Map(seed.responses) : new Map())
87
+ const draftId = `${sessionId}:${revision}:${draftPhase}`
88
+ const draftIdRef = useRef(draftId)
89
+ draftIdRef.current = draftId
90
+ const [seen, setSeen] = useState<SeenState>(() => seenFor(draftId))
91
+ const [feedbackEntries, setFeedbackEntries] = useState<Map<string, FeedbackEntry>>(() =>
92
+ seed?.feedbackEntries ? new Map(seed.feedbackEntries) : new Map()
93
+ )
100
94
 
101
- // Re-hydrate when the draft identity changes. Hosts that remount the provider
102
- // per revision (via a key) get this for free from the initializers, but hosts
103
- // that keep it mounted need it here — otherwise one revision's answers leak
104
- // into the next and the persist effect writes them over the new revision's
105
- // saved draft. `hydrated` gates that effect so an async server load cannot be
106
- // clobbered by the empty initial state.
107
- const [hydrated, setHydrated] = useState(!serverMode);
108
- useEffect(() => {
109
- const apply = (saved: PersistedState | null) => {
110
- setAnnotations((saved?.annotations ?? []).map((a) => ({ ...a, source: a.source ?? "local" as const })));
111
- setGeneralNote(saved?.generalNote ?? "");
112
- setResponses(saved?.responses ? new Map(saved.responses) : new Map());
113
- setFeedbackEntries(saved?.feedbackEntries ? new Map(saved.feedbackEntries) : new Map());
114
- setActiveAnnotationId(null);
115
- };
116
- if (serverMode) {
117
- let cancelled = false;
118
- setHydrated(false);
119
- loadState!(sessionId, revision, draftPhase).then((saved) => {
120
- if (cancelled) return;
121
- apply(saved);
122
- setHydrated(true);
123
- });
124
- return () => { cancelled = true; };
125
- }
126
- apply(loadPersisted(sessionId, revision, draftPhase));
127
- setHydrated(true);
128
- return undefined;
129
- }, [sessionId, revision, draftPhase, serverMode, loadState]);
95
+ // Re-hydrate when the draft identity changes. Hosts that remount the provider
96
+ // per revision (via a key) get this for free from the initializers, but hosts
97
+ // that keep it mounted need it here — otherwise one revision's answers leak
98
+ // into the next and the persist effect writes them over the new revision's
99
+ // saved draft. `hydrated` gates that effect so an async server load cannot be
100
+ // clobbered by the empty initial state.
101
+ const [hydrated, setHydrated] = useState(!serverMode)
102
+ useEffect(() => {
103
+ const apply = (saved: PersistedState | null) => {
104
+ savedState.current = saved
105
+ setAnnotations((saved?.annotations ?? []).map((a) => ({ ...a, source: a.source ?? 'local' as const })))
106
+ setGeneralNote(saved?.generalNote ?? '')
107
+ setResponses(saved?.responses ? new Map(saved.responses) : new Map())
108
+ setFeedbackEntries(saved?.feedbackEntries ? new Map(saved.feedbackEntries) : new Map())
109
+ setActiveAnnotationId(null)
110
+ }
111
+ if (serverMode) {
112
+ let cancelled = false
113
+ setHydrated(false)
114
+ loadState!(sessionId, revision, draftPhase).then((saved) => {
115
+ if (cancelled) return
116
+ apply(parsePersistedState(saved))
117
+ setHydrated(true)
118
+ })
119
+ return () => {
120
+ cancelled = true
121
+ }
122
+ }
123
+ apply(loadPersisted(sessionId, revision, draftPhase))
124
+ setHydrated(true)
125
+ return undefined
126
+ }, [sessionId, revision, draftPhase, serverMode, loadState])
130
127
 
131
- // Post-feedback drafts save immediately so a new revision can carry the latest edit.
132
- const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
133
- useEffect(() => {
134
- if (isReadOnly && draftPhase !== "next") return;
135
- if (!hydrated) return;
136
- if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
137
- const state: PersistedState = {
138
- // Persist only local annotations — remote ones are re-fetched on load.
139
- annotations: localAnnotations,
140
- generalNote,
141
- responses: Array.from(responses.entries()),
142
- feedbackEntries: Array.from(feedbackEntries.entries()),
143
- };
144
- const persist = () => {
145
- if (saveState) saveState(sessionId, revision, state, draftPhase);
146
- else savePersisted(sessionId, revision, draftPhase, state);
147
- };
148
- if (draftPhase === "next") {
149
- persist();
150
- return;
151
- }
152
- persistTimerRef.current = setTimeout(persist, 300);
153
- return () => { if (persistTimerRef.current) clearTimeout(persistTimerRef.current); };
154
- }, [localAnnotations, generalNote, responses, feedbackEntries, sessionId, revision, isReadOnly, draftPhase, hydrated, saveState]);
128
+ // Post-feedback drafts save immediately so a new revision can carry the latest edit.
129
+ const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
130
+ useEffect(() => {
131
+ if (isReadOnly && draftPhase !== 'next') return
132
+ if (!hydrated) return
133
+ if (persistTimerRef.current) clearTimeout(persistTimerRef.current)
134
+ const state: PersistedState = {
135
+ ...savedState.current,
136
+ // Persist only local annotations — remote ones are re-fetched on load.
137
+ annotations: localAnnotations,
138
+ generalNote,
139
+ responses: Array.from(responses.entries()),
140
+ feedbackEntries: Array.from(feedbackEntries.entries()),
141
+ }
142
+ const persist = () => {
143
+ if (saveState) saveState(sessionId, revision, state, draftPhase)
144
+ else savePersisted(sessionId, revision, draftPhase, state)
145
+ }
146
+ if (draftPhase === 'next') {
147
+ persist()
148
+ return
149
+ }
150
+ persistTimerRef.current = setTimeout(persist, 300)
151
+ return () => {
152
+ if (persistTimerRef.current) clearTimeout(persistTimerRef.current)
153
+ }
154
+ }, [localAnnotations, generalNote, responses, feedbackEntries, sessionId, revision, isReadOnly, draftPhase, hydrated, saveState])
155
155
 
156
- const addAnnotationWithId = useCallback((id: string, snippet: string, note: string, filePath?: string, context?: AnnotationContext, images?: string[], canvasFile?: string) => {
157
- setAnnotations((prev) => [...prev, { id, snippet, note, createdAt: new Date().toISOString(), filePath, context, ...(images?.length ? { images } : {}), ...(canvasFile ? { canvasFile } : {}) }]);
158
- }, []);
156
+ const setResponse = useCallback((id: string, response: PlanResponse) => {
157
+ setResponses((prev) => {
158
+ const next = new Map(prev)
159
+ next.set(id, response)
160
+ return next
161
+ })
162
+ }, [])
159
163
 
160
- const addAnnotation = useCallback((snippet: string, note: string, filePath?: string) => {
161
- addAnnotationWithId(generateAnnotationId(), snippet, note, filePath);
162
- }, [addAnnotationWithId]);
164
+ // Both registrations accumulate for the life of one draft and are idempotent,
165
+ // so re-running the caller's effect cannot loop.
166
+ const registerResponse = useCallback((id: string) => {
167
+ setSeen((prev) => {
168
+ const base = prev.draft === draftIdRef.current ? prev : seenFor(draftIdRef.current)
169
+ if (base.responseIds.has(id)) return base
170
+ return { ...base, responseIds: new Set(base.responseIds).add(id) }
171
+ })
172
+ }, [])
163
173
 
164
- const updateAnnotation = useCallback((id: string, note: string) => {
165
- setAnnotations((prev) => prev.map((a) => (a.id === id ? { ...a, note } : a)));
166
- }, []);
174
+ const registerCanvasRendered = useCallback((filename: string) => {
175
+ setSeen((prev) => {
176
+ const base = prev.draft === draftIdRef.current ? prev : seenFor(draftIdRef.current)
177
+ if (base.canvases.has(filename)) return base
178
+ return { ...base, canvases: new Set(base.canvases).add(filename) }
179
+ })
180
+ }, [])
167
181
 
168
- const removeAnnotation = useCallback((id: string) => {
169
- removeMarksFromDom(id);
170
- setAnnotations((prev) => prev.filter((a) => a.id !== id));
171
- setActiveAnnotationId((prev) => (prev === id ? null : prev));
172
- }, []);
182
+ // Answers survive until a canvas proves the question is gone. `seen` can lag
183
+ // the draft by a render when the host keeps the provider mounted across
184
+ // revisions, and stale evidence must never prune.
185
+ const current = seen.draft === draftId ? seen : null
186
+ const submittableResponses = useMemo(() => {
187
+ if (!current || !canPruneResponses(canvasFiles, current.canvases)) return responses
188
+ return pruneStaleResponses(responses, current.responseIds)
189
+ }, [current, canvasFiles, responses])
173
190
 
174
- const addAnnotationImage = useCallback((id: string, imagePath: string) => {
175
- setAnnotations((prev) => prev.map((a) =>
176
- a.id === id ? { ...a, images: [...(a.images || []), imagePath] } : a
177
- ));
178
- }, []);
191
+ const setFeedbackEntry = useCallback((id: string, entry: FeedbackEntry) => {
192
+ setFeedbackEntries((prev) => {
193
+ const existing = prev.get(id)
194
+ if (existing && existing.markdown === entry.markdown && existing.label === entry.label && existing.required === entry.required) {
195
+ return prev // same reference → no re-render
196
+ }
197
+ const next = new Map(prev)
198
+ next.set(id, entry)
199
+ return next
200
+ })
201
+ }, [])
179
202
 
180
- const removeAnnotationImage = useCallback((id: string, imagePath: string) => {
181
- setAnnotations((prev) => prev.map((a) =>
182
- a.id === id ? { ...a, images: (a.images || []).filter((p) => p !== imagePath) } : a
183
- ));
184
- }, []);
203
+ const removeFeedbackEntry = useCallback((id: string) => {
204
+ setFeedbackEntries((prev) => {
205
+ if (!prev.has(id)) return prev
206
+ const next = new Map(prev)
207
+ next.delete(id)
208
+ return next
209
+ })
210
+ }, [])
185
211
 
186
- const setResponse = useCallback((id: string, response: PlanResponse) => {
187
- setResponses((prev) => {
188
- const next = new Map(prev);
189
- next.set(id, response);
190
- return next;
191
- });
192
- }, []);
212
+ const clearCanvasState = useCallback(() => {
213
+ setResponses(new Map())
214
+ setFeedbackEntries(new Map())
215
+ if (saveState) {
216
+ saveState(sessionId, revision, { annotations: [], generalNote: '', responses: [], feedbackEntries: [] }, draftPhase)
217
+ } else if (draftPhase === 'next') {
218
+ localStorage.removeItem(annotationDraftKey(sessionId, revision, draftPhase))
219
+ } else {
220
+ clearPersistedDraft(localStorage, sessionId, revision)
221
+ }
222
+ }, [sessionId, revision, draftPhase, saveState])
193
223
 
194
- // Both registrations accumulate for the life of one draft and are idempotent,
195
- // so re-running the caller's effect cannot loop.
196
- const registerResponse = useCallback((id: string) => {
197
- setSeen((prev) => {
198
- const base = prev.draft === draftIdRef.current ? prev : seenFor(draftIdRef.current);
199
- if (base.responseIds.has(id)) return base;
200
- return { ...base, responseIds: new Set(base.responseIds).add(id) };
201
- });
202
- }, []);
224
+ const annotationState = useAnnotationState({
225
+ annotations: localAnnotations,
226
+ setAnnotations,
227
+ generalNote,
228
+ setGeneralNote,
229
+ remoteAnnotations,
230
+ isReadOnly,
231
+ onClear: clearCanvasState,
232
+ })
233
+ const { setActiveAnnotationId } = annotationState
234
+ const host = useCanvasHost()
203
235
 
204
- const registerCanvasRendered = useCallback((filename: string) => {
205
- setSeen((prev) => {
206
- const base = prev.draft === draftIdRef.current ? prev : seenFor(draftIdRef.current);
207
- if (base.canvases.has(filename)) return base;
208
- return { ...base, canvases: new Set(base.canvases).add(filename) };
209
- });
210
- }, []);
211
-
212
- // Answers survive until a canvas proves the question is gone. `seen` can lag
213
- // the draft by a render when the host keeps the provider mounted across
214
- // revisions, and stale evidence must never prune.
215
- const current = seen.draft === draftId ? seen : null;
216
- const submittableResponses = useMemo(() => {
217
- if (!current || !canPruneResponses(canvasFiles, current.canvases)) return responses;
218
- return pruneStaleResponses(responses, current.responseIds);
219
- }, [current, canvasFiles, responses]);
220
-
221
- const setFeedbackEntry = useCallback((id: string, entry: FeedbackEntry) => {
222
- setFeedbackEntries((prev) => {
223
- const existing = prev.get(id);
224
- if (existing && existing.markdown === entry.markdown && existing.label === entry.label && existing.required === entry.required) {
225
- return prev; // same reference → no re-render
226
- }
227
- const next = new Map(prev);
228
- next.set(id, entry);
229
- return next;
230
- });
231
- }, []);
232
-
233
- const removeFeedbackEntry = useCallback((id: string) => {
234
- setFeedbackEntries((prev) => {
235
- if (!prev.has(id)) return prev;
236
- const next = new Map(prev);
237
- next.delete(id);
238
- return next;
239
- });
240
- }, []);
241
-
242
- const clearAll = useCallback(() => {
243
- for (const mark of document.querySelectorAll("[data-annotation-id]")) {
244
- unwrapMark(mark as HTMLElement);
245
- }
246
- setAnnotations([]);
247
- setGeneralNote("");
248
- setActiveAnnotationId(null);
249
- setResponses(new Map());
250
- setFeedbackEntries(new Map());
251
- if (saveState) {
252
- saveState(sessionId, revision, { annotations: [], generalNote: "", responses: [], feedbackEntries: [] }, draftPhase);
253
- } else if (draftPhase === "next") {
254
- localStorage.removeItem(annotationDraftKey(sessionId, revision, draftPhase));
255
- } else {
256
- clearPersistedDraft(localStorage, sessionId, revision);
257
- }
258
- }, [sessionId, revision, draftPhase, saveState]);
259
-
260
- return (
261
- <AnnotationCtx.Provider
262
- value={{
263
- annotations, addAnnotation, addAnnotationWithId, updateAnnotation, removeAnnotation, addAnnotationImage, removeAnnotationImage,
264
- generalNote, setGeneralNote, clearAll,
265
- activeAnnotationId, setActiveAnnotationId,
266
- responses, setResponse, submittableResponses,
267
- registerResponse, registerCanvasRendered,
268
- feedbackEntries, setFeedbackEntry, removeFeedbackEntry,
269
- isReadOnly,
270
- }}
271
- >
272
- {children}
273
- </AnnotationCtx.Provider>
274
- );
275
- }
276
-
277
- function removeMarksFromDom(id: string) {
278
- const marks = document.querySelectorAll(`[data-annotation-id="${id}"]`);
279
- for (const mark of marks) unwrapMark(mark as HTMLElement);
280
- }
281
-
282
- function unwrapMark(mark: HTMLElement) {
283
- const parent = mark.parentNode;
284
- if (!parent) return;
285
- // A region overlay wraps no text; swapping it for its text content would
286
- // leave a stray empty node inside the image.
287
- if (mark.tagName !== "MARK") {
288
- parent.removeChild(mark);
289
- return;
290
- }
291
- const text = document.createTextNode(mark.textContent || "");
292
- parent.replaceChild(text, mark);
293
- parent.normalize();
236
+ return (
237
+ <AnnotationHostContext.Provider value={host}>
238
+ <SurfaceAnnotationCtx.Provider value={annotationState}>
239
+ <AnnotationCtx.Provider
240
+ value={{
241
+ ...annotationState,
242
+ responses,
243
+ setResponse,
244
+ submittableResponses,
245
+ registerResponse,
246
+ registerCanvasRendered,
247
+ feedbackEntries,
248
+ setFeedbackEntry,
249
+ removeFeedbackEntry,
250
+ }}
251
+ >
252
+ {children}
253
+ </AnnotationCtx.Provider>
254
+ </SurfaceAnnotationCtx.Provider>
255
+ </AnnotationHostContext.Provider>
256
+ )
294
257
  }