agent-canvas 0.1.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/bin/agent-canvas.js +14 -0
- package/bin/agent-canvas.ts +377 -0
- package/daemon/build.ts +185 -0
- package/daemon/client/AnnotationProvider.tsx +185 -0
- package/daemon/client/AnnotationSidebar.tsx +314 -0
- package/daemon/client/App.tsx +611 -0
- package/daemon/client/CompareView.tsx +333 -0
- package/daemon/client/FileBrowser.tsx +188 -0
- package/daemon/client/FileIcon.tsx +76 -0
- package/daemon/client/FileViewer.tsx +207 -0
- package/daemon/client/PlanRenderer.tsx +665 -0
- package/daemon/client/Popover.tsx +285 -0
- package/daemon/client/ResponsePreview.tsx +260 -0
- package/daemon/client/RevisionSelect.tsx +97 -0
- package/daemon/client/SessionContext.tsx +3 -0
- package/daemon/client/SessionSwitcher.tsx +72 -0
- package/daemon/client/annotationContext.ts +114 -0
- package/daemon/client/components/Callout.tsx +25 -0
- package/daemon/client/components/Checklist.tsx +37 -0
- package/daemon/client/components/Choice.tsx +164 -0
- package/daemon/client/components/CodeBlock.tsx +35 -0
- package/daemon/client/components/Diff.tsx +65 -0
- package/daemon/client/components/FilePreview.tsx +88 -0
- package/daemon/client/components/Image.tsx +45 -0
- package/daemon/client/components/Mermaid.tsx +277 -0
- package/daemon/client/components/Note.tsx +13 -0
- package/daemon/client/components/Priority.tsx +19 -0
- package/daemon/client/components/ResponseNote.tsx +24 -0
- package/daemon/client/components/Section.tsx +32 -0
- package/daemon/client/components/Table.tsx +35 -0
- package/daemon/client/components/Task.tsx +75 -0
- package/daemon/client/components/UserInput.tsx +180 -0
- package/daemon/client/components/index.ts +15 -0
- package/daemon/client/domDiff.ts +336 -0
- package/daemon/client/exportMarkdown.ts +267 -0
- package/daemon/client/generateMarkdown.ts +194 -0
- package/daemon/client/highlightRange.ts +233 -0
- package/daemon/client/index.html +26 -0
- package/daemon/client/lcs.ts +19 -0
- package/daemon/client/popoverPosition.ts +31 -0
- package/daemon/client/runtime.ts +89 -0
- package/daemon/client/styles.css +306 -0
- package/daemon/client/theme.css +66 -0
- package/daemon/client/unifiedDiff.ts +604 -0
- package/daemon/dist/client.css +2 -0
- package/daemon/dist/client.js +68 -0
- package/daemon/dist/components.js +5 -0
- package/daemon/dist/index.html +108 -0
- package/daemon/dist/jsx-dev-runtime-shim.js +17 -0
- package/daemon/dist/jsx-runtime-shim.js +17 -0
- package/daemon/dist/react-dom-client-shim.js +4 -0
- package/daemon/dist/react-dom-shim.js +4 -0
- package/daemon/dist/react-shim.js +4 -0
- package/daemon/dist/runtime.js +1 -0
- package/daemon/dist/theme.css +66 -0
- package/daemon/src/compiler.ts +92 -0
- package/daemon/src/paths.ts +22 -0
- package/daemon/src/server.ts +448 -0
- package/daemon/src/session.ts +274 -0
- package/daemon/src/watcher.ts +59 -0
- package/hooks/session-start.sh +11 -0
- package/package.json +39 -0
- package/skills/canvas/SKILL.md +174 -0
- package/skills/canvas/components.md +349 -0
- package/skills/canvas/flows.md +406 -0
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
import React, { useEffect, useState, useRef, useContext, useCallback, useMemo } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { SessionContext } from "#canvas/runtime";
|
|
4
|
+
import { useAnnotations } from "./AnnotationProvider";
|
|
5
|
+
import { wrapRangeWithMark, updateAllMarkStates, renameMarkId, unwrapMarks, restoreMarks } from "./highlightRange";
|
|
6
|
+
import { extractContext } from "./annotationContext";
|
|
7
|
+
import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
|
|
8
|
+
|
|
9
|
+
/** All navigable blocks (keyboard arrows) */
|
|
10
|
+
const BLOCK_SELECTOR = "[data-md='item'], [data-md='section'], [data-md='table'] tbody tr, [data-md='callout'], [data-md='note'], [data-md='checklist-item'], [data-md='choice-option'], [data-md='multichoice-option'], [data-md='userinput'], [data-md='rangeinput'], [data-md='image']";
|
|
11
|
+
/** Blocks eligible for annotation comment icons (excludes interactive controls) */
|
|
12
|
+
const ANNOTATABLE_SELECTOR = "[data-md='item'], [data-md='section'], [data-md='table'] tbody tr, [data-md='callout'], [data-md='note'], [data-md='checklist-item'], [data-md='image']";
|
|
13
|
+
|
|
14
|
+
interface PlanRendererProps {
|
|
15
|
+
revision: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function PlanRenderer({ revision }: PlanRendererProps) {
|
|
19
|
+
const sessionId = useContext(SessionContext);
|
|
20
|
+
const [PlanComponent, setPlanComponent] = useState<React.ComponentType | null>(null);
|
|
21
|
+
const [error, setError] = useState<string | null>(null);
|
|
22
|
+
const [loading, setLoading] = useState(true);
|
|
23
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
24
|
+
const { annotations, addAnnotationWithId, removeAnnotation, updateAnnotation, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
|
|
25
|
+
const [editingAnn, setEditingAnn] = useState<{ id: string; note: string } | null>(null);
|
|
26
|
+
|
|
27
|
+
// Popover state
|
|
28
|
+
const [editPopover, setEditPopover] = useState<{ anchorEl: HTMLElement; annId: string } | null>(null);
|
|
29
|
+
const [createPopover, setCreatePopover] = useState<{ anchorEl: HTMLElement; tempId: string; snippet: string; ctx: any } | null>(null);
|
|
30
|
+
|
|
31
|
+
// Block annotation hover state
|
|
32
|
+
const [hoveredBlock, setHoveredBlock] = useState<HTMLElement | null>(null);
|
|
33
|
+
const [blockPopover, setBlockPopover] = useState<{ anchorEl: HTMLElement; snippet: string; annId?: string } | null>(null);
|
|
34
|
+
|
|
35
|
+
// Keyboard navigation state
|
|
36
|
+
const [focusedBlockIndex, setFocusedBlockIndex] = useState<number | null>(null);
|
|
37
|
+
const [keyboardNav, setKeyboardNav] = useState(false);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
setLoading(true);
|
|
41
|
+
setError(null);
|
|
42
|
+
setFocusedBlockIndex(null);
|
|
43
|
+
import(`/api/session/${sessionId}/plan.js?rev=${revision}&t=${Date.now()}`)
|
|
44
|
+
.then((mod) => { setPlanComponent(() => mod.default); setLoading(false); })
|
|
45
|
+
.catch((e) => { setError(e.message); setLoading(false); });
|
|
46
|
+
}, [sessionId, revision]);
|
|
47
|
+
|
|
48
|
+
// Restore persisted annotation marks after plan renders
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!PlanComponent || !containerRef.current) return;
|
|
51
|
+
// Small delay to ensure the plan DOM is fully rendered
|
|
52
|
+
const timer = setTimeout(() => {
|
|
53
|
+
if (containerRef.current) {
|
|
54
|
+
restoreMarks(containerRef.current, annotations);
|
|
55
|
+
}
|
|
56
|
+
}, 50);
|
|
57
|
+
return () => clearTimeout(timer);
|
|
58
|
+
}, [PlanComponent]);
|
|
59
|
+
|
|
60
|
+
// Update mark active states when activeAnnotationId changes
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
updateAllMarkStates(activeAnnotationId);
|
|
63
|
+
}, [activeAnnotationId]);
|
|
64
|
+
|
|
65
|
+
// Click handler for marks in plan
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const container = containerRef.current;
|
|
68
|
+
if (!container) return;
|
|
69
|
+
|
|
70
|
+
const handleClick = (e: MouseEvent) => {
|
|
71
|
+
const mark = (e.target as HTMLElement).closest("[data-annotation-id]") as HTMLElement | null;
|
|
72
|
+
if (!mark) return;
|
|
73
|
+
e.stopPropagation();
|
|
74
|
+
const annId = mark.getAttribute("data-annotation-id")!;
|
|
75
|
+
if (annId === activeAnnotationId) {
|
|
76
|
+
setEditPopover({ anchorEl: mark, annId });
|
|
77
|
+
} else {
|
|
78
|
+
setActiveAnnotationId(annId);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Hover on inline marks → highlight sidebar card
|
|
83
|
+
const handleMouseOver = (e: MouseEvent) => {
|
|
84
|
+
const mark = (e.target as HTMLElement).closest("[data-annotation-id]") as HTMLElement | null;
|
|
85
|
+
if (mark) {
|
|
86
|
+
const annId = mark.getAttribute("data-annotation-id")!;
|
|
87
|
+
setActiveAnnotationId(annId);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const handleMouseOut = (e: MouseEvent) => {
|
|
92
|
+
const mark = (e.target as HTMLElement).closest("[data-annotation-id]");
|
|
93
|
+
const relatedMark = (e.relatedTarget as HTMLElement | null)?.closest?.("[data-annotation-id]");
|
|
94
|
+
if (mark && !relatedMark) {
|
|
95
|
+
// Left a mark without entering another one — only deactivate if popover isn't open
|
|
96
|
+
if (!document.getElementById("ann-inline-popover")) {
|
|
97
|
+
setActiveAnnotationId(null);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
container.addEventListener("click", handleClick);
|
|
103
|
+
container.addEventListener("mouseover", handleMouseOver);
|
|
104
|
+
container.addEventListener("mouseout", handleMouseOut);
|
|
105
|
+
return () => {
|
|
106
|
+
container.removeEventListener("click", handleClick);
|
|
107
|
+
container.removeEventListener("mouseover", handleMouseOver);
|
|
108
|
+
container.removeEventListener("mouseout", handleMouseOut);
|
|
109
|
+
};
|
|
110
|
+
}, [PlanComponent, annotations, activeAnnotationId]);
|
|
111
|
+
|
|
112
|
+
// Build a map of block snippets to annotation IDs for quick lookup
|
|
113
|
+
const blockAnnotationMap = useMemo(() => {
|
|
114
|
+
const map = new Map<string, string>();
|
|
115
|
+
for (const ann of annotations) {
|
|
116
|
+
if (ann.snippet.startsWith("[")) {
|
|
117
|
+
map.set(ann.snippet, ann.id);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return map;
|
|
121
|
+
}, [annotations]);
|
|
122
|
+
|
|
123
|
+
// Block annotation: hover detection
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
const container = containerRef.current;
|
|
126
|
+
if (!container) return;
|
|
127
|
+
|
|
128
|
+
const handleMove = (e: MouseEvent) => {
|
|
129
|
+
// Don't change hover when a popover is open
|
|
130
|
+
if (blockPopover || createPopover || editPopover) return;
|
|
131
|
+
const target = e.target as HTMLElement;
|
|
132
|
+
// Ignore events on the block comment button itself
|
|
133
|
+
if (target.closest("[data-block-comment-btn]")) return;
|
|
134
|
+
setKeyboardNav(false);
|
|
135
|
+
// Track keyboard nav position on any navigable block
|
|
136
|
+
const navBlock = target.closest(BLOCK_SELECTOR) as HTMLElement | null;
|
|
137
|
+
if (navBlock && container.contains(navBlock)) {
|
|
138
|
+
const blocks = getOrderedBlocks(container);
|
|
139
|
+
const idx = blocks.indexOf(navBlock);
|
|
140
|
+
setFocusedBlockIndex(idx >= 0 ? idx : null);
|
|
141
|
+
} else {
|
|
142
|
+
setFocusedBlockIndex(null);
|
|
143
|
+
}
|
|
144
|
+
// Only show comment icons on annotatable blocks
|
|
145
|
+
const block = target.closest(ANNOTATABLE_SELECTOR) as HTMLElement | null;
|
|
146
|
+
if (block && container.contains(block)) {
|
|
147
|
+
setHoveredBlock(block);
|
|
148
|
+
const snippet = getBlockSnippet(block);
|
|
149
|
+
const annId = snippet ? blockAnnotationMap.get(snippet) : undefined;
|
|
150
|
+
if (annId && annId !== activeAnnotationId) {
|
|
151
|
+
setActiveAnnotationId(annId);
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
setHoveredBlock(null);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const handleLeave = (e: MouseEvent) => {
|
|
159
|
+
if (blockPopover || createPopover || editPopover) return;
|
|
160
|
+
// Don't clear if moving onto the block comment button
|
|
161
|
+
const related = e.relatedTarget as HTMLElement | null;
|
|
162
|
+
if (related?.closest("[data-block-comment-btn]")) return;
|
|
163
|
+
setHoveredBlock(null);
|
|
164
|
+
setActiveAnnotationId(null);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
container.addEventListener("mousemove", handleMove);
|
|
168
|
+
container.addEventListener("mouseleave", handleLeave);
|
|
169
|
+
return () => {
|
|
170
|
+
container.removeEventListener("mousemove", handleMove);
|
|
171
|
+
container.removeEventListener("mouseleave", handleLeave);
|
|
172
|
+
};
|
|
173
|
+
}, [PlanComponent, blockPopover, createPopover, editPopover, blockAnnotationMap, activeAnnotationId]);
|
|
174
|
+
|
|
175
|
+
// Keyboard navigation: document-level key listener
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
178
|
+
// Guard: skip if input/textarea focused or popover open
|
|
179
|
+
const tag = (e.target as HTMLElement).tagName;
|
|
180
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement).isContentEditable) return;
|
|
181
|
+
if (blockPopover || createPopover || editPopover) return;
|
|
182
|
+
|
|
183
|
+
const container = containerRef.current;
|
|
184
|
+
if (!container) return;
|
|
185
|
+
|
|
186
|
+
if (e.key === "ArrowDown") {
|
|
187
|
+
e.preventDefault();
|
|
188
|
+
setKeyboardNav(true);
|
|
189
|
+
const blocks = getOrderedBlocks(container);
|
|
190
|
+
if (blocks.length === 0) return;
|
|
191
|
+
setFocusedBlockIndex((prev) => {
|
|
192
|
+
if (prev === null) return 0;
|
|
193
|
+
return Math.min(prev + 1, blocks.length - 1);
|
|
194
|
+
});
|
|
195
|
+
} else if (e.key === "ArrowUp") {
|
|
196
|
+
e.preventDefault();
|
|
197
|
+
setKeyboardNav(true);
|
|
198
|
+
const blocks = getOrderedBlocks(container);
|
|
199
|
+
if (blocks.length === 0) return;
|
|
200
|
+
setFocusedBlockIndex((prev) => {
|
|
201
|
+
if (prev === null) return blocks.length - 1;
|
|
202
|
+
return Math.max(prev - 1, 0);
|
|
203
|
+
});
|
|
204
|
+
} else if (e.key === "a" || e.key === "A") {
|
|
205
|
+
if (focusedBlockIndex === null || !container) return;
|
|
206
|
+
e.preventDefault();
|
|
207
|
+
const blocks = getOrderedBlocks(container);
|
|
208
|
+
const block = blocks[focusedBlockIndex];
|
|
209
|
+
if (!block) return;
|
|
210
|
+
const snippet = getBlockSnippet(block);
|
|
211
|
+
if (!snippet) return;
|
|
212
|
+
const existingAnnId = blockAnnotationMap.get(snippet);
|
|
213
|
+
if (existingAnnId) {
|
|
214
|
+
setActiveAnnotationId(existingAnnId);
|
|
215
|
+
setBlockPopover({ anchorEl: block, snippet, annId: existingAnnId });
|
|
216
|
+
} else {
|
|
217
|
+
setBlockPopover({ anchorEl: block, snippet });
|
|
218
|
+
}
|
|
219
|
+
} else if (e.key === " " || e.key === "Enter") {
|
|
220
|
+
if (focusedBlockIndex === null) return;
|
|
221
|
+
const blocks = getOrderedBlocks(container);
|
|
222
|
+
const block = blocks[focusedBlockIndex];
|
|
223
|
+
if (!block) return;
|
|
224
|
+
const md = block.getAttribute("data-md");
|
|
225
|
+
if (md === "choice-option" || md === "multichoice-option") {
|
|
226
|
+
e.preventDefault();
|
|
227
|
+
block.dispatchEvent(new CustomEvent("kb-select", { bubbles: true }));
|
|
228
|
+
} else if (md === "userinput") {
|
|
229
|
+
e.preventDefault();
|
|
230
|
+
const textarea = block.querySelector("textarea");
|
|
231
|
+
if (textarea) {
|
|
232
|
+
textarea.focus();
|
|
233
|
+
const exitHandler = (te: KeyboardEvent) => {
|
|
234
|
+
if (te.key === "Escape") {
|
|
235
|
+
te.preventDefault();
|
|
236
|
+
textarea.blur();
|
|
237
|
+
setKeyboardNav(true);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
textarea.addEventListener("keydown", exitHandler);
|
|
241
|
+
textarea.addEventListener("blur", () => textarea.removeEventListener("keydown", exitHandler), { once: true });
|
|
242
|
+
}
|
|
243
|
+
} else if (md === "rangeinput") {
|
|
244
|
+
e.preventDefault();
|
|
245
|
+
const input = block.querySelector("input[type='range']") as HTMLInputElement | null;
|
|
246
|
+
if (input) {
|
|
247
|
+
input.focus();
|
|
248
|
+
const exitHandler = (re: KeyboardEvent) => {
|
|
249
|
+
if (re.key === "Escape" || re.key === "ArrowUp" || re.key === "ArrowDown") {
|
|
250
|
+
re.preventDefault();
|
|
251
|
+
input.blur();
|
|
252
|
+
setKeyboardNav(true);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
input.addEventListener("keydown", exitHandler);
|
|
256
|
+
input.addEventListener("blur", () => input.removeEventListener("keydown", exitHandler), { once: true });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
} else if (e.key === "Escape") {
|
|
260
|
+
setFocusedBlockIndex(null);
|
|
261
|
+
setKeyboardNav(false);
|
|
262
|
+
setActiveAnnotationId(null);
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
267
|
+
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
268
|
+
}, [focusedBlockIndex, blockPopover, createPopover, editPopover, blockAnnotationMap]);
|
|
269
|
+
|
|
270
|
+
// Keyboard navigation: visual focus outline + scroll into view + sidebar sync
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
const container = containerRef.current;
|
|
273
|
+
if (!container || focusedBlockIndex === null || !keyboardNav) return;
|
|
274
|
+
const blocks = getOrderedBlocks(container);
|
|
275
|
+
const block = blocks[focusedBlockIndex];
|
|
276
|
+
if (!block) return;
|
|
277
|
+
|
|
278
|
+
block.style.outline = "2px solid var(--color-accent-blue)";
|
|
279
|
+
block.style.outlineOffset = "-2px";
|
|
280
|
+
block.style.borderRadius = "8px";
|
|
281
|
+
block.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
282
|
+
|
|
283
|
+
// Sync sidebar if block has an annotation
|
|
284
|
+
const snippet = getBlockSnippet(block);
|
|
285
|
+
const annId = snippet ? blockAnnotationMap.get(snippet) : undefined;
|
|
286
|
+
if (annId) setActiveAnnotationId(annId);
|
|
287
|
+
|
|
288
|
+
return () => {
|
|
289
|
+
block.style.outline = "";
|
|
290
|
+
block.style.outlineOffset = "";
|
|
291
|
+
block.style.borderRadius = "";
|
|
292
|
+
};
|
|
293
|
+
}, [focusedBlockIndex, keyboardNav, blockAnnotationMap]);
|
|
294
|
+
|
|
295
|
+
// Use document-level mouseup so selections that end outside the container still work
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
const handler = () => handleMouseUp();
|
|
298
|
+
document.addEventListener("mouseup", handler);
|
|
299
|
+
return () => document.removeEventListener("mouseup", handler);
|
|
300
|
+
}, []);
|
|
301
|
+
|
|
302
|
+
const handleMouseUp = useCallback(() => {
|
|
303
|
+
const sel = window.getSelection();
|
|
304
|
+
if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
|
|
305
|
+
if (!containerRef.current) return;
|
|
306
|
+
const range = sel.getRangeAt(0);
|
|
307
|
+
// Selection must have started inside the plan container
|
|
308
|
+
if (!containerRef.current.contains(range.startContainer)) return;
|
|
309
|
+
// Don't create new annotation if clicking inside existing mark
|
|
310
|
+
if ((range.startContainer.parentElement as HTMLElement)?.closest?.("[data-annotation-id]")) return;
|
|
311
|
+
const snippet = sel.toString().trim();
|
|
312
|
+
if (snippet.length < 2) return;
|
|
313
|
+
|
|
314
|
+
// Capture context before popover disturbs the DOM
|
|
315
|
+
const savedRange = range.cloneRange();
|
|
316
|
+
const ctx = extractContext(range, containerRef.current);
|
|
317
|
+
const tempId = `__pending_${Date.now()}`;
|
|
318
|
+
try { wrapRangeWithMark(savedRange, tempId); } catch {}
|
|
319
|
+
window.getSelection()?.removeAllRanges();
|
|
320
|
+
|
|
321
|
+
const marks = document.querySelectorAll(`[data-annotation-id="${tempId}"]`);
|
|
322
|
+
const lastMark = marks[marks.length - 1] as HTMLElement | undefined;
|
|
323
|
+
if (!lastMark) return;
|
|
324
|
+
|
|
325
|
+
setCreatePopover({ anchorEl: lastMark, tempId, snippet, ctx });
|
|
326
|
+
}, [addAnnotationWithId]);
|
|
327
|
+
|
|
328
|
+
// Memoize so context-triggered re-renders of PlanRenderer don't re-render the plan tree.
|
|
329
|
+
// This prevents remounting custom components defined inside the Plan function.
|
|
330
|
+
const planElement = useMemo(() => PlanComponent ? <PlanComponent /> : null, [PlanComponent]);
|
|
331
|
+
|
|
332
|
+
if (loading) {
|
|
333
|
+
return <div className="flex items-center justify-center h-64 text-text-tertiary font-body text-body">Loading plan...</div>;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (error) {
|
|
337
|
+
return (
|
|
338
|
+
<div className="bg-accent-red-muted rounded-lg p-5">
|
|
339
|
+
<h3 className="font-body font-semibold text-accent-red mb-2">Compilation Error</h3>
|
|
340
|
+
<pre className="text-code font-mono text-text-secondary whitespace-pre-wrap">{error}</pre>
|
|
341
|
+
</div>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (!PlanComponent) {
|
|
346
|
+
return <div className="text-text-tertiary text-center py-8 font-body text-body">No plan loaded</div>;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const scrollContainer = document.getElementById("plan-scroll-container");
|
|
350
|
+
|
|
351
|
+
return (
|
|
352
|
+
<>
|
|
353
|
+
<div ref={containerRef} className="plan-content plan-updated">
|
|
354
|
+
{planElement}
|
|
355
|
+
</div>
|
|
356
|
+
{editingAnn && (
|
|
357
|
+
<EditAnnotationModal
|
|
358
|
+
note={editingAnn.note}
|
|
359
|
+
onSave={(note) => { updateAnnotation(editingAnn.id, note); setEditingAnn(null); }}
|
|
360
|
+
onCancel={() => setEditingAnn(null)}
|
|
361
|
+
/>
|
|
362
|
+
)}
|
|
363
|
+
|
|
364
|
+
{editPopover && (() => {
|
|
365
|
+
const ann = annotations.find((a) => a.id === editPopover.annId);
|
|
366
|
+
if (!ann) return null;
|
|
367
|
+
return (
|
|
368
|
+
<AnnotationEditPopover
|
|
369
|
+
anchorEl={editPopover.anchorEl}
|
|
370
|
+
scrollContainer={scrollContainer}
|
|
371
|
+
initialNote={ann.note}
|
|
372
|
+
onUpdate={(note) => updateAnnotation(editPopover.annId, note)}
|
|
373
|
+
onDelete={() => { removeAnnotation(editPopover.annId); setActiveAnnotationId(null); }}
|
|
374
|
+
onClose={() => setEditPopover(null)}
|
|
375
|
+
/>
|
|
376
|
+
);
|
|
377
|
+
})()}
|
|
378
|
+
|
|
379
|
+
{createPopover && (
|
|
380
|
+
<AnnotationCreatePopover
|
|
381
|
+
anchorEl={createPopover.anchorEl}
|
|
382
|
+
scrollContainer={scrollContainer}
|
|
383
|
+
snippet={createPopover.snippet}
|
|
384
|
+
truncateAt={80}
|
|
385
|
+
onAdd={(note) => {
|
|
386
|
+
const id = `ann-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
387
|
+
renameMarkId(createPopover.tempId, id);
|
|
388
|
+
addAnnotationWithId(id, createPopover.snippet, note, undefined, createPopover.ctx);
|
|
389
|
+
setCreatePopover(null);
|
|
390
|
+
}}
|
|
391
|
+
onCancel={() => {
|
|
392
|
+
unwrapMarks(createPopover.tempId);
|
|
393
|
+
setCreatePopover(null);
|
|
394
|
+
}}
|
|
395
|
+
/>
|
|
396
|
+
)}
|
|
397
|
+
|
|
398
|
+
{/* Block annotation floating buttons */}
|
|
399
|
+
<BlockCommentButtons
|
|
400
|
+
containerRef={containerRef}
|
|
401
|
+
hoveredBlock={hoveredBlock}
|
|
402
|
+
focusedBlockIndex={focusedBlockIndex}
|
|
403
|
+
blockPopover={blockPopover}
|
|
404
|
+
blockAnnotationMap={blockAnnotationMap}
|
|
405
|
+
activeAnnotationId={activeAnnotationId}
|
|
406
|
+
onOpen={(anchorEl, snippet, annId) => {
|
|
407
|
+
if (annId) {
|
|
408
|
+
setActiveAnnotationId(annId);
|
|
409
|
+
setBlockPopover({ anchorEl, snippet, annId });
|
|
410
|
+
} else {
|
|
411
|
+
setBlockPopover({ anchorEl, snippet });
|
|
412
|
+
}
|
|
413
|
+
}}
|
|
414
|
+
/>
|
|
415
|
+
|
|
416
|
+
{/* Block annotation popover */}
|
|
417
|
+
{blockPopover && !blockPopover.annId && (
|
|
418
|
+
<AnnotationCreatePopover
|
|
419
|
+
anchorEl={blockPopover.anchorEl}
|
|
420
|
+
scrollContainer={scrollContainer}
|
|
421
|
+
snippet={blockPopover.snippet}
|
|
422
|
+
truncateAt={80}
|
|
423
|
+
onAdd={(note) => {
|
|
424
|
+
const id = `ann-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
425
|
+
addAnnotationWithId(id, blockPopover.snippet, note);
|
|
426
|
+
setBlockPopover(null);
|
|
427
|
+
}}
|
|
428
|
+
onCancel={() => setBlockPopover(null)}
|
|
429
|
+
/>
|
|
430
|
+
)}
|
|
431
|
+
{blockPopover?.annId && (() => {
|
|
432
|
+
const ann = annotations.find((a) => a.id === blockPopover.annId);
|
|
433
|
+
if (!ann) return null;
|
|
434
|
+
return (
|
|
435
|
+
<AnnotationEditPopover
|
|
436
|
+
anchorEl={blockPopover.anchorEl}
|
|
437
|
+
scrollContainer={scrollContainer}
|
|
438
|
+
initialNote={ann.note}
|
|
439
|
+
onUpdate={(note) => updateAnnotation(blockPopover.annId!, note)}
|
|
440
|
+
onDelete={() => { removeAnnotation(blockPopover.annId!); setActiveAnnotationId(null); setBlockPopover(null); }}
|
|
441
|
+
onClose={() => setBlockPopover(null)}
|
|
442
|
+
/>
|
|
443
|
+
);
|
|
444
|
+
})()}
|
|
445
|
+
</>
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Get all navigable blocks in DOM order */
|
|
450
|
+
function getOrderedBlocks(container: HTMLElement): HTMLElement[] {
|
|
451
|
+
return Array.from(container.querySelectorAll(BLOCK_SELECTOR)) as HTMLElement[];
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Extract a snippet identifier for a block element */
|
|
455
|
+
function getBlockSnippet(block: HTMLElement): string | null {
|
|
456
|
+
const md = block.getAttribute("data-md");
|
|
457
|
+
if (md === "item") {
|
|
458
|
+
const label = block.getAttribute("data-md-label");
|
|
459
|
+
return label ? `[Item] ${label}` : null;
|
|
460
|
+
}
|
|
461
|
+
if (md === "section") {
|
|
462
|
+
const title = block.getAttribute("data-md-title");
|
|
463
|
+
return title ? `[Section] ${title}` : null;
|
|
464
|
+
}
|
|
465
|
+
if (md === "callout") {
|
|
466
|
+
const type = block.getAttribute("data-md-type") || "info";
|
|
467
|
+
const text = block.textContent?.trim().slice(0, 60) || "Callout";
|
|
468
|
+
return `[Callout:${type}] ${text}`;
|
|
469
|
+
}
|
|
470
|
+
if (md === "note") {
|
|
471
|
+
const text = block.textContent?.trim().slice(0, 60) || "Note";
|
|
472
|
+
return `[Note] ${text}`;
|
|
473
|
+
}
|
|
474
|
+
// Table row
|
|
475
|
+
if (block.tagName === "TR") {
|
|
476
|
+
const cells = Array.from(block.querySelectorAll("td")).map((td) => td.textContent?.trim()).filter(Boolean);
|
|
477
|
+
return cells.length ? `[Row] ${cells.join(" | ")}` : null;
|
|
478
|
+
}
|
|
479
|
+
if (md === "checklist-item") {
|
|
480
|
+
const label = block.getAttribute("data-md-label");
|
|
481
|
+
return label ? `[Checklist] ${label}` : null;
|
|
482
|
+
}
|
|
483
|
+
if (md === "choice-option" || md === "multichoice-option") {
|
|
484
|
+
const label = block.getAttribute("data-md-label");
|
|
485
|
+
return label ? `[Option] ${label}` : null;
|
|
486
|
+
}
|
|
487
|
+
if (md === "userinput") {
|
|
488
|
+
const label = block.getAttribute("data-md-label");
|
|
489
|
+
return label ? `[Input] ${label}` : null;
|
|
490
|
+
}
|
|
491
|
+
if (md === "rangeinput") {
|
|
492
|
+
const label = block.getAttribute("data-md-label");
|
|
493
|
+
return label ? `[Range] ${label}` : null;
|
|
494
|
+
}
|
|
495
|
+
if (md === "image") {
|
|
496
|
+
const src = block.getAttribute("data-md-src");
|
|
497
|
+
return src ? `[Image] ${src}` : null;
|
|
498
|
+
}
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Renders comment icons: always visible on annotated blocks, on hover for others */
|
|
503
|
+
function BlockCommentButtons({
|
|
504
|
+
containerRef,
|
|
505
|
+
hoveredBlock,
|
|
506
|
+
focusedBlockIndex,
|
|
507
|
+
blockPopover,
|
|
508
|
+
blockAnnotationMap,
|
|
509
|
+
activeAnnotationId,
|
|
510
|
+
onOpen,
|
|
511
|
+
}: {
|
|
512
|
+
containerRef: React.RefObject<HTMLDivElement | null>;
|
|
513
|
+
hoveredBlock: HTMLElement | null;
|
|
514
|
+
focusedBlockIndex: number | null;
|
|
515
|
+
blockPopover: { anchorEl: HTMLElement; snippet: string; annId?: string } | null;
|
|
516
|
+
blockAnnotationMap: Map<string, string>;
|
|
517
|
+
activeAnnotationId: string | null;
|
|
518
|
+
onOpen: (anchorEl: HTMLElement, snippet: string, annId?: string) => void;
|
|
519
|
+
}) {
|
|
520
|
+
// Find all blocks that have annotations
|
|
521
|
+
const [annotatedBlocks, setAnnotatedBlocks] = useState<HTMLElement[]>([]);
|
|
522
|
+
|
|
523
|
+
useEffect(() => {
|
|
524
|
+
const container = containerRef.current;
|
|
525
|
+
if (!container || blockAnnotationMap.size === 0) { setAnnotatedBlocks([]); return; }
|
|
526
|
+
const blocks: HTMLElement[] = [];
|
|
527
|
+
for (const el of container.querySelectorAll(ANNOTATABLE_SELECTOR)) {
|
|
528
|
+
const snippet = getBlockSnippet(el as HTMLElement);
|
|
529
|
+
if (snippet && blockAnnotationMap.has(snippet)) {
|
|
530
|
+
blocks.push(el as HTMLElement);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
setAnnotatedBlocks(blocks);
|
|
534
|
+
}, [blockAnnotationMap, containerRef.current]);
|
|
535
|
+
|
|
536
|
+
// Also highlight block when its annotation is active from sidebar hover
|
|
537
|
+
const [activeBlock, setActiveBlock] = useState<HTMLElement | null>(null);
|
|
538
|
+
useEffect(() => {
|
|
539
|
+
if (!activeAnnotationId || !containerRef.current) { setActiveBlock(null); return; }
|
|
540
|
+
// Find block matching active annotation
|
|
541
|
+
for (const el of containerRef.current.querySelectorAll(BLOCK_SELECTOR)) {
|
|
542
|
+
const snippet = getBlockSnippet(el as HTMLElement);
|
|
543
|
+
if (snippet && blockAnnotationMap.get(snippet) === activeAnnotationId) {
|
|
544
|
+
setActiveBlock(el as HTMLElement);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
setActiveBlock(null);
|
|
549
|
+
}, [activeAnnotationId, blockAnnotationMap]);
|
|
550
|
+
|
|
551
|
+
// Apply highlight style to active block
|
|
552
|
+
useEffect(() => {
|
|
553
|
+
if (!activeBlock) return;
|
|
554
|
+
activeBlock.style.boxShadow = "inset 0 0 0 1.5px var(--color-highlight-bg)";
|
|
555
|
+
activeBlock.style.borderRadius = "8px";
|
|
556
|
+
return () => {
|
|
557
|
+
activeBlock.style.boxShadow = "";
|
|
558
|
+
activeBlock.style.borderRadius = "";
|
|
559
|
+
};
|
|
560
|
+
}, [activeBlock]);
|
|
561
|
+
|
|
562
|
+
// Resolve keyboard-focused block element (only annotatable blocks get comment icons)
|
|
563
|
+
const focusedBlock = useMemo(() => {
|
|
564
|
+
if (focusedBlockIndex === null || !containerRef.current) return null;
|
|
565
|
+
const block = getOrderedBlocks(containerRef.current)[focusedBlockIndex];
|
|
566
|
+
if (!block) return null;
|
|
567
|
+
// Only show comment icon on annotatable blocks, not interactive controls
|
|
568
|
+
return block.matches(ANNOTATABLE_SELECTOR) ? block : null;
|
|
569
|
+
}, [focusedBlockIndex, containerRef.current]);
|
|
570
|
+
|
|
571
|
+
// Collect all blocks that need a button: annotated + hovered + focused + popover target
|
|
572
|
+
const targets = useMemo(() => {
|
|
573
|
+
const set = new Set<HTMLElement>(annotatedBlocks);
|
|
574
|
+
if (hoveredBlock) set.add(hoveredBlock);
|
|
575
|
+
if (focusedBlock) set.add(focusedBlock);
|
|
576
|
+
if (blockPopover?.anchorEl) set.add(blockPopover.anchorEl);
|
|
577
|
+
return Array.from(set);
|
|
578
|
+
}, [annotatedBlocks, hoveredBlock, focusedBlock, blockPopover]);
|
|
579
|
+
|
|
580
|
+
return (
|
|
581
|
+
<>
|
|
582
|
+
{targets.map((block) => (
|
|
583
|
+
<BlockCommentIcon
|
|
584
|
+
key={block.getAttribute("data-task-id") || block.getAttribute("data-md-title") || block.textContent?.slice(0, 30) || "block"}
|
|
585
|
+
block={block}
|
|
586
|
+
isHovered={block === hoveredBlock || block === focusedBlock || block === blockPopover?.anchorEl}
|
|
587
|
+
blockAnnotationMap={blockAnnotationMap}
|
|
588
|
+
activeAnnotationId={activeAnnotationId}
|
|
589
|
+
onOpen={onOpen}
|
|
590
|
+
/>
|
|
591
|
+
))}
|
|
592
|
+
</>
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function BlockCommentIcon({
|
|
597
|
+
block,
|
|
598
|
+
isHovered,
|
|
599
|
+
blockAnnotationMap,
|
|
600
|
+
activeAnnotationId,
|
|
601
|
+
onOpen,
|
|
602
|
+
}: {
|
|
603
|
+
block: HTMLElement;
|
|
604
|
+
isHovered: boolean;
|
|
605
|
+
blockAnnotationMap: Map<string, string>;
|
|
606
|
+
activeAnnotationId: string | null;
|
|
607
|
+
onOpen: (anchorEl: HTMLElement, snippet: string, annId?: string) => void;
|
|
608
|
+
}) {
|
|
609
|
+
const snippet = getBlockSnippet(block);
|
|
610
|
+
if (!snippet) return null;
|
|
611
|
+
|
|
612
|
+
const existingAnnId = blockAnnotationMap.get(snippet);
|
|
613
|
+
const hasAnnotation = !!existingAnnId;
|
|
614
|
+
const isActive = existingAnnId === activeAnnotationId;
|
|
615
|
+
|
|
616
|
+
return createPortal(
|
|
617
|
+
<button
|
|
618
|
+
data-block-comment-btn
|
|
619
|
+
onClick={(e) => {
|
|
620
|
+
e.stopPropagation();
|
|
621
|
+
onOpen(e.currentTarget as HTMLElement, snippet, existingAnnId);
|
|
622
|
+
}}
|
|
623
|
+
className={`absolute top-1 right-1 z-10 w-6 h-6 flex items-center justify-center rounded transition-all duration-150 ${
|
|
624
|
+
hasAnnotation
|
|
625
|
+
? isActive
|
|
626
|
+
? "text-accent-amber bg-accent-amber-muted"
|
|
627
|
+
: "text-accent-amber opacity-60 hover:opacity-100"
|
|
628
|
+
: isHovered
|
|
629
|
+
? "text-text-disabled hover:text-text-tertiary opacity-50 hover:opacity-100"
|
|
630
|
+
: "text-text-disabled opacity-0 group-hover/section:opacity-20 hover:!opacity-80"
|
|
631
|
+
}`}
|
|
632
|
+
title={hasAnnotation ? "Edit annotation" : "Add annotation"}
|
|
633
|
+
>
|
|
634
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill={hasAnnotation ? "currentColor" : "none"} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
635
|
+
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
|
636
|
+
</svg>
|
|
637
|
+
</button>,
|
|
638
|
+
block,
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function EditAnnotationModal({ note, onSave, onCancel }: { note: string; onSave: (n: string) => void; onCancel: () => void }) {
|
|
643
|
+
const [text, setText] = React.useState(note);
|
|
644
|
+
const ref = React.useRef<HTMLTextAreaElement>(null);
|
|
645
|
+
React.useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []);
|
|
646
|
+
return (
|
|
647
|
+
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center" onClick={onCancel}>
|
|
648
|
+
<div className="bg-bg-elevated border border-border-hover rounded-lg shadow-md p-4 w-80" onClick={(e) => e.stopPropagation()}>
|
|
649
|
+
<div className="text-tiny uppercase tracking-widest text-text-tertiary font-body mb-2">Edit annotation</div>
|
|
650
|
+
<textarea ref={ref} value={text} onChange={(e) => setText(e.target.value)}
|
|
651
|
+
className="w-full bg-transparent border-none text-body font-body text-text-primary resize-vertical focus:outline-none min-h-[60px]"
|
|
652
|
+
onKeyDown={(e) => {
|
|
653
|
+
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) onSave(text.trim());
|
|
654
|
+
if (e.key === "Escape") onCancel();
|
|
655
|
+
}}
|
|
656
|
+
/>
|
|
657
|
+
<div className="flex justify-end gap-2 mt-2">
|
|
658
|
+
<button onClick={onCancel} className="text-meta text-text-tertiary font-body px-3 py-1">Cancel</button>
|
|
659
|
+
<button onClick={() => onSave(text.trim())} className="text-meta font-medium font-body px-3 py-1 rounded-md bg-highlight-bg text-text-primary border border-highlight-border">Save</button>
|
|
660
|
+
</div>
|
|
661
|
+
</div>
|
|
662
|
+
</div>
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
|