agent-canvas 0.1.9 → 0.1.11
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/.github/dark.png +0 -0
- package/.github/light.png +0 -0
- package/README.md +10 -0
- package/daemon/build.ts +1 -1
- package/daemon/client/AnnotationEditor.tsx +307 -0
- package/daemon/client/AnnotationProvider.tsx +15 -3
- package/daemon/client/AnnotationSidebar.tsx +54 -28
- package/daemon/client/App.tsx +64 -24
- package/daemon/client/CompareView.tsx +53 -22
- package/daemon/client/FileBrowser.tsx +2 -1
- package/daemon/client/FileViewer.tsx +41 -161
- package/daemon/client/PlanRenderer.tsx +30 -147
- package/daemon/client/Popover.tsx +168 -108
- package/daemon/client/ResponsePreview.tsx +7 -1
- package/daemon/client/RevisionSelect.tsx +8 -1
- package/daemon/client/annotationDom.ts +104 -0
- package/daemon/client/components/Mermaid.tsx +2 -2
- package/daemon/client/generateMarkdown.ts +8 -0
- package/daemon/client/highlightRange.ts +1 -22
- package/daemon/client/index.html +1 -1
- package/daemon/client/runtime.ts +4 -1
- package/daemon/client/styles.css +28 -2
- package/daemon/client/theme.css +1 -1
- package/daemon/client/useTextAnnotation.tsx +183 -0
- package/daemon/dist/client.css +1 -1
- package/daemon/dist/client.js +63 -87
- package/daemon/dist/components.js +54 -54
- package/daemon/dist/index.html +2 -2
- package/daemon/src/handlers/uploads.ts +62 -0
- package/daemon/src/paths.ts +4 -0
- package/daemon/src/server.ts +2 -0
- package/daemon/src/session.ts +39 -1
- package/package.json +1 -1
- package/skills/canvas/SKILL.md +15 -7
- package/skills/canvas/flows.md +1 -1
- package/hooks/session-start.sh +0 -13
|
@@ -85,7 +85,14 @@ export function RevisionSelect({ value, onChange, accent = "default", className
|
|
|
85
85
|
<>Round {r.revision}</>
|
|
86
86
|
)}
|
|
87
87
|
</span>
|
|
88
|
-
<span className="text-[10px] text-text-tertiary flex-shrink-0">
|
|
88
|
+
<span className="text-[10px] text-text-tertiary flex-shrink-0 flex items-center gap-1.5">
|
|
89
|
+
{r.diffStats && (
|
|
90
|
+
<span className="font-mono">
|
|
91
|
+
<span className="text-accent-green">+{r.diffStats.added}</span>
|
|
92
|
+
{" "}
|
|
93
|
+
<span className="text-accent-red">-{r.diffStats.removed}</span>
|
|
94
|
+
</span>
|
|
95
|
+
)}
|
|
89
96
|
{r.revision === currentRevision ? "current" : r.hasFeedback ? "sent" : relativeTime(r.createdAt)}
|
|
90
97
|
</span>
|
|
91
98
|
</button>
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Annotation } from "./AnnotationProvider";
|
|
2
|
+
import type { ActiveView } from "./App";
|
|
3
|
+
|
|
4
|
+
/** All blocks that can be annotated via block-level comments */
|
|
5
|
+
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']";
|
|
6
|
+
|
|
7
|
+
/** Extract snippet identifier for a block element (mirrors PlanRenderer's getBlockSnippet) */
|
|
8
|
+
function getBlockSnippet(block: HTMLElement): string | null {
|
|
9
|
+
const md = block.getAttribute("data-md");
|
|
10
|
+
if (md === "item") {
|
|
11
|
+
const label = block.getAttribute("data-md-label");
|
|
12
|
+
return label ? `[Item] ${label}` : null;
|
|
13
|
+
}
|
|
14
|
+
if (md === "section") {
|
|
15
|
+
const title = block.getAttribute("data-md-title");
|
|
16
|
+
return title ? `[Section] ${title}` : null;
|
|
17
|
+
}
|
|
18
|
+
if (md === "callout") {
|
|
19
|
+
const type = block.getAttribute("data-md-type") || "info";
|
|
20
|
+
const text = block.textContent?.trim().slice(0, 60) || "Callout";
|
|
21
|
+
return `[Callout:${type}] ${text}`;
|
|
22
|
+
}
|
|
23
|
+
if (md === "note") {
|
|
24
|
+
const text = block.textContent?.trim().slice(0, 60) || "Note";
|
|
25
|
+
return `[Note] ${text}`;
|
|
26
|
+
}
|
|
27
|
+
if (block.tagName === "TR") {
|
|
28
|
+
const cells = Array.from(block.querySelectorAll("td")).map((td) => td.textContent?.trim()).filter(Boolean);
|
|
29
|
+
return cells.length ? `[Row] ${cells.join(" | ")}` : null;
|
|
30
|
+
}
|
|
31
|
+
if (md === "checklist-item") {
|
|
32
|
+
const label = block.getAttribute("data-md-label");
|
|
33
|
+
return label ? `[Checklist] ${label}` : null;
|
|
34
|
+
}
|
|
35
|
+
if (md === "choice-option" || md === "multichoice-option") {
|
|
36
|
+
const label = block.getAttribute("data-md-label");
|
|
37
|
+
return label ? `[Option] ${label}` : null;
|
|
38
|
+
}
|
|
39
|
+
if (md === "userinput") {
|
|
40
|
+
const label = block.getAttribute("data-md-label");
|
|
41
|
+
return label ? `[Input] ${label}` : null;
|
|
42
|
+
}
|
|
43
|
+
if (md === "rangeinput") {
|
|
44
|
+
const label = block.getAttribute("data-md-label");
|
|
45
|
+
return label ? `[Range] ${label}` : null;
|
|
46
|
+
}
|
|
47
|
+
if (md === "image") {
|
|
48
|
+
const src = block.getAttribute("data-md-src");
|
|
49
|
+
return src ? `[Image] ${src}` : null;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Find the DOM element for an annotation — either an inline mark (data-annotation-id)
|
|
56
|
+
* or a block element matched by snippet.
|
|
57
|
+
*/
|
|
58
|
+
export function findAnnotationElement(ann: Annotation): HTMLElement | null {
|
|
59
|
+
// Try inline mark first
|
|
60
|
+
const mark = document.querySelector(`[data-annotation-id="${ann.id}"]`) as HTMLElement | null;
|
|
61
|
+
if (mark) return mark;
|
|
62
|
+
|
|
63
|
+
// For block annotations (snippet starts with "["), find by matching snippet against blocks
|
|
64
|
+
if (ann.snippet.startsWith("[")) {
|
|
65
|
+
for (const el of document.querySelectorAll(ANNOTATABLE_SELECTOR)) {
|
|
66
|
+
if (getBlockSnippet(el as HTMLElement) === ann.snippet) {
|
|
67
|
+
return el as HTMLElement;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Flash/pulse the element briefly — blinks bg via CSS animation */
|
|
76
|
+
function flashElement(el: HTMLElement) {
|
|
77
|
+
el.classList.remove("ann-flash");
|
|
78
|
+
void el.offsetWidth; // force reflow to restart
|
|
79
|
+
el.classList.add("ann-flash");
|
|
80
|
+
el.addEventListener("animationend", () => { el.classList.remove("ann-flash"); }, { once: true });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Scroll the canvas/file view to an annotation element and flash it.
|
|
85
|
+
* Handles file annotations by switching view first.
|
|
86
|
+
*/
|
|
87
|
+
export function scrollToAnnotation(ann: Annotation, setActiveView: (view: ActiveView) => void) {
|
|
88
|
+
if (ann.filePath) {
|
|
89
|
+
setActiveView({ type: "file", path: ann.filePath });
|
|
90
|
+
setTimeout(() => {
|
|
91
|
+
const el = findAnnotationElement(ann);
|
|
92
|
+
if (el) {
|
|
93
|
+
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
94
|
+
flashElement(el);
|
|
95
|
+
}
|
|
96
|
+
}, 150);
|
|
97
|
+
} else {
|
|
98
|
+
const el = findAnnotationElement(ann);
|
|
99
|
+
if (el) {
|
|
100
|
+
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
101
|
+
flashElement(el);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -276,9 +276,9 @@ export function Mermaid({ children }: MermaidProps) {
|
|
|
276
276
|
{nodePopover.snippet}
|
|
277
277
|
</div>
|
|
278
278
|
}
|
|
279
|
-
onAdd={(note) => {
|
|
279
|
+
onAdd={(note, images) => {
|
|
280
280
|
const id = `ann-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
281
|
-
addAnnotationWithId(id, `[Diagram ${nodePopover.prefix.toLowerCase()}] ${nodePopover.snippet}`, note);
|
|
281
|
+
addAnnotationWithId(id, `[Diagram ${nodePopover.prefix.toLowerCase()}] ${nodePopover.snippet}`, note, undefined, undefined, images);
|
|
282
282
|
setNodePopover(null);
|
|
283
283
|
}}
|
|
284
284
|
onCancel={() => setNodePopover(null)}
|
|
@@ -201,6 +201,14 @@ function renderAnnotation(ann: Annotation): string {
|
|
|
201
201
|
lines.push(ann.note.trim());
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
// Attached images
|
|
205
|
+
if (ann.images?.length) {
|
|
206
|
+
lines.push("");
|
|
207
|
+
for (const img of ann.images) {
|
|
208
|
+
lines.push(``);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
204
212
|
lines.push("");
|
|
205
213
|
return lines.join("\n");
|
|
206
214
|
}
|
|
@@ -1,23 +1,3 @@
|
|
|
1
|
-
const MARK_STYLE_INACTIVE = `
|
|
2
|
-
background: var(--color-highlight-annotation);
|
|
3
|
-
border-bottom: 1.5px solid var(--color-highlight-bg);
|
|
4
|
-
border-radius: 2px;
|
|
5
|
-
cursor: pointer;
|
|
6
|
-
transition: background 150ms ease;
|
|
7
|
-
color: inherit;
|
|
8
|
-
padding: 1px 0;
|
|
9
|
-
`;
|
|
10
|
-
|
|
11
|
-
const MARK_STYLE_ACTIVE = `
|
|
12
|
-
background: var(--color-highlight-bg);
|
|
13
|
-
border-bottom: 1.5px solid var(--color-highlight-border);
|
|
14
|
-
border-radius: 2px;
|
|
15
|
-
cursor: pointer;
|
|
16
|
-
transition: background 150ms ease;
|
|
17
|
-
color: inherit;
|
|
18
|
-
padding: 1px 0;
|
|
19
|
-
`;
|
|
20
|
-
|
|
21
1
|
/**
|
|
22
2
|
* Wraps the current browser Range in <mark> elements with the given annotation ID.
|
|
23
3
|
* Works for multiline / cross-element selections.
|
|
@@ -46,7 +26,6 @@ export function wrapRangeWithMark(range: Range, annotationId: string): HTMLEleme
|
|
|
46
26
|
// Wrap this portion in a <mark>
|
|
47
27
|
const mark = document.createElement("mark");
|
|
48
28
|
mark.setAttribute("data-annotation-id", annotationId);
|
|
49
|
-
mark.style.cssText = MARK_STYLE_INACTIVE;
|
|
50
29
|
|
|
51
30
|
try {
|
|
52
31
|
nodeRange.surroundContents(mark);
|
|
@@ -94,7 +73,7 @@ function getTextNodesInRange(range: Range): Text[] {
|
|
|
94
73
|
export function setMarkActive(annotationId: string, active: boolean) {
|
|
95
74
|
const marks = document.querySelectorAll(`[data-annotation-id="${annotationId}"]`);
|
|
96
75
|
for (const mark of marks) {
|
|
97
|
-
(mark as HTMLElement).
|
|
76
|
+
(mark as HTMLElement).classList.toggle("mark-active", active);
|
|
98
77
|
}
|
|
99
78
|
}
|
|
100
79
|
|
package/daemon/client/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>Agent Canvas</title>
|
|
7
|
-
<script
|
|
7
|
+
<script>!function(){var p=localStorage.getItem('canvas-theme')||'auto';document.documentElement.dataset.theme=p==='auto'?(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'):p}()</script>
|
|
8
8
|
<link rel="stylesheet" href="/assets/client.css" />
|
|
9
9
|
</head>
|
|
10
10
|
<body class="min-h-screen">
|
package/daemon/client/runtime.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface Annotation {
|
|
|
19
19
|
createdAt: string;
|
|
20
20
|
filePath?: string;
|
|
21
21
|
context?: AnnotationContext;
|
|
22
|
+
images?: string[];
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
export interface PlanResponse {
|
|
@@ -41,9 +42,11 @@ export interface FeedbackEntry {
|
|
|
41
42
|
export interface AnnotationContextValue {
|
|
42
43
|
annotations: Annotation[];
|
|
43
44
|
addAnnotation: (snippet: string, note: string, filePath?: string) => void;
|
|
44
|
-
addAnnotationWithId: (id: string, snippet: string, note: string, filePath?: string, context?: AnnotationContext) => void;
|
|
45
|
+
addAnnotationWithId: (id: string, snippet: string, note: string, filePath?: string, context?: AnnotationContext, images?: string[]) => void;
|
|
45
46
|
updateAnnotation: (id: string, note: string) => void;
|
|
46
47
|
removeAnnotation: (id: string) => void;
|
|
48
|
+
addAnnotationImage: (id: string, imagePath: string) => void;
|
|
49
|
+
removeAnnotationImage: (id: string, imagePath: string) => void;
|
|
47
50
|
generalNote: string;
|
|
48
51
|
setGeneralNote: (text: string) => void;
|
|
49
52
|
clearAll: () => void;
|
package/daemon/client/styles.css
CHANGED
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
--color-highlight-bg: rgba(200, 160, 30, 0.15);
|
|
57
57
|
--color-highlight-border: rgba(200, 160, 30, 0.40);
|
|
58
58
|
--color-highlight-annotation: rgba(200, 160, 30, 0.18);
|
|
59
|
-
--color-highlight-active: rgba(200, 160, 30, 0.
|
|
59
|
+
--color-highlight-active: rgba(200, 160, 30, 0.35);
|
|
60
60
|
--color-highlight-selected: rgba(200, 160, 30, 0.10);
|
|
61
61
|
|
|
62
62
|
--color-badge-bg: rgba(160, 120, 32, 0.15);
|
|
@@ -96,7 +96,33 @@ code, pre, .font-mono {
|
|
|
96
96
|
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
/* Annotation
|
|
99
|
+
/* Annotation inline marks */
|
|
100
|
+
mark[data-annotation-id] {
|
|
101
|
+
background: var(--color-highlight-annotation);
|
|
102
|
+
border-bottom: 1.5px solid var(--color-highlight-bg);
|
|
103
|
+
border-radius: 2px;
|
|
104
|
+
cursor: pointer;
|
|
105
|
+
transition: background 150ms ease, box-shadow 150ms ease;
|
|
106
|
+
color: inherit;
|
|
107
|
+
padding: 1px 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
mark[data-annotation-id].mark-active {
|
|
111
|
+
background: var(--color-highlight-active);
|
|
112
|
+
border-bottom: 2px solid var(--color-highlight-border);
|
|
113
|
+
box-shadow: 0 0 0 1px var(--color-highlight-border);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@keyframes ann-flash {
|
|
117
|
+
0%, 100% { background: var(--color-highlight-annotation); }
|
|
118
|
+
25%, 75% { background: var(--color-highlight-active); }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.ann-flash {
|
|
122
|
+
animation: ann-flash 0.6s ease 2;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* Annotation highlight (legacy) */
|
|
100
126
|
.annotation-highlight {
|
|
101
127
|
background: var(--color-highlight-bg);
|
|
102
128
|
border-bottom: 2px solid var(--color-highlight-border);
|
package/daemon/client/theme.css
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
--color-highlight-bg: rgba(255, 220, 100, 0.15);
|
|
32
32
|
--color-highlight-border: rgba(255, 220, 100, 0.35);
|
|
33
33
|
--color-highlight-annotation: rgba(255, 220, 100, 0.18);
|
|
34
|
-
--color-highlight-active: rgba(255, 220, 100, 0.
|
|
34
|
+
--color-highlight-active: rgba(255, 220, 100, 0.35);
|
|
35
35
|
--color-highlight-selected: rgba(255, 220, 100, 0.10);
|
|
36
36
|
|
|
37
37
|
--color-badge-bg: rgba(196, 154, 58, 0.20);
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import React, { useEffect, useState, useRef, useCallback } from "react";
|
|
2
|
+
import type { AnnotationContext, Annotation } from "./AnnotationProvider";
|
|
3
|
+
import { useAnnotations } from "./AnnotationProvider";
|
|
4
|
+
import { wrapRangeWithMark, updateAllMarkStates, renameMarkId, unwrapMarks, restoreMarks } from "./highlightRange";
|
|
5
|
+
import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
|
|
6
|
+
import { generateAnnotationId } from "./utils";
|
|
7
|
+
|
|
8
|
+
interface UseTextAnnotationOptions {
|
|
9
|
+
/** Ref to the container element where text can be selected and marks are placed */
|
|
10
|
+
containerRef: React.RefObject<HTMLElement | null>;
|
|
11
|
+
/** Trigger for mark restoration — when this value changes, marks are restored */
|
|
12
|
+
restoreKey: unknown;
|
|
13
|
+
/** Subset of annotations to restore in this container */
|
|
14
|
+
restoreAnnotations: Annotation[];
|
|
15
|
+
/** Build AnnotationContext from a selection Range */
|
|
16
|
+
extractContext: (range: Range) => AnnotationContext | undefined;
|
|
17
|
+
/** Optional filePath to associate with created annotations */
|
|
18
|
+
filePath?: string;
|
|
19
|
+
/** Scroll container for popover positioning */
|
|
20
|
+
scrollContainer?: HTMLElement | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useTextAnnotation(options: UseTextAnnotationOptions) {
|
|
24
|
+
const { containerRef, restoreKey, restoreAnnotations, extractContext, filePath, scrollContainer } = options;
|
|
25
|
+
const { annotations: allAnnotations, addAnnotationWithId, removeAnnotation, updateAnnotation, addAnnotationImage, removeAnnotationImage, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
|
|
26
|
+
|
|
27
|
+
const [editPopover, setEditPopover] = useState<{ anchorEl: HTMLElement; annId: string } | null>(null);
|
|
28
|
+
const [createPopover, setCreatePopover] = useState<{ anchorEl: HTMLElement; tempId: string; snippet: string; ctx?: AnnotationContext } | null>(null);
|
|
29
|
+
|
|
30
|
+
// Refs for stable handlers (registered with empty or minimal deps)
|
|
31
|
+
const extractContextRef = useRef(extractContext);
|
|
32
|
+
extractContextRef.current = extractContext;
|
|
33
|
+
const filePathRef = useRef(filePath);
|
|
34
|
+
filePathRef.current = filePath;
|
|
35
|
+
const addAnnotationRef = useRef(addAnnotationWithId);
|
|
36
|
+
addAnnotationRef.current = addAnnotationWithId;
|
|
37
|
+
const activeAnnotationIdRef = useRef(activeAnnotationId);
|
|
38
|
+
activeAnnotationIdRef.current = activeAnnotationId;
|
|
39
|
+
const setActiveAnnotationIdRef = useRef(setActiveAnnotationId);
|
|
40
|
+
setActiveAnnotationIdRef.current = setActiveAnnotationId;
|
|
41
|
+
|
|
42
|
+
// Mark restoration after content renders
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!containerRef.current) return;
|
|
45
|
+
const timer = setTimeout(() => {
|
|
46
|
+
if (containerRef.current) {
|
|
47
|
+
restoreMarks(containerRef.current, restoreAnnotations);
|
|
48
|
+
}
|
|
49
|
+
}, 50);
|
|
50
|
+
return () => clearTimeout(timer);
|
|
51
|
+
}, [restoreKey]);
|
|
52
|
+
|
|
53
|
+
// Mark active state tracking
|
|
54
|
+
const prevActiveRef = useRef<string | null>(null);
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
updateAllMarkStates(activeAnnotationId, prevActiveRef.current);
|
|
57
|
+
prevActiveRef.current = activeAnnotationId;
|
|
58
|
+
}, [activeAnnotationId]);
|
|
59
|
+
|
|
60
|
+
// Mark click/hover/out handlers on the container (uses refs to avoid re-registration on every hover)
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const container = containerRef.current;
|
|
63
|
+
if (!container) return;
|
|
64
|
+
|
|
65
|
+
const handleClick = (e: MouseEvent) => {
|
|
66
|
+
const mark = (e.target as HTMLElement).closest("[data-annotation-id]") as HTMLElement | null;
|
|
67
|
+
if (!mark) return;
|
|
68
|
+
e.stopPropagation();
|
|
69
|
+
const annId = mark.getAttribute("data-annotation-id")!;
|
|
70
|
+
if (annId === activeAnnotationIdRef.current) {
|
|
71
|
+
setEditPopover({ anchorEl: mark, annId });
|
|
72
|
+
} else {
|
|
73
|
+
setActiveAnnotationIdRef.current(annId);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const handleMouseOver = (e: MouseEvent) => {
|
|
78
|
+
const mark = (e.target as HTMLElement).closest("[data-annotation-id]") as HTMLElement | null;
|
|
79
|
+
if (mark) setActiveAnnotationIdRef.current(mark.getAttribute("data-annotation-id")!);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const handleMouseOut = (e: MouseEvent) => {
|
|
83
|
+
const mark = (e.target as HTMLElement).closest("[data-annotation-id]");
|
|
84
|
+
const related = (e.relatedTarget as HTMLElement | null)?.closest?.("[data-annotation-id]");
|
|
85
|
+
if (mark && !related && !document.getElementById("ann-inline-popover")) setActiveAnnotationIdRef.current(null);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
container.addEventListener("click", handleClick);
|
|
89
|
+
container.addEventListener("mouseover", handleMouseOver);
|
|
90
|
+
container.addEventListener("mouseout", handleMouseOut);
|
|
91
|
+
return () => {
|
|
92
|
+
container.removeEventListener("click", handleClick);
|
|
93
|
+
container.removeEventListener("mouseover", handleMouseOver);
|
|
94
|
+
container.removeEventListener("mouseout", handleMouseOut);
|
|
95
|
+
};
|
|
96
|
+
}, [restoreKey]);
|
|
97
|
+
|
|
98
|
+
// Document-level mouseup for text selection → mark → popover
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
const handler = () => handleMouseUp();
|
|
101
|
+
document.addEventListener("mouseup", handler);
|
|
102
|
+
return () => document.removeEventListener("mouseup", handler);
|
|
103
|
+
}, []);
|
|
104
|
+
|
|
105
|
+
const handleMouseUp = useCallback(() => {
|
|
106
|
+
const sel = window.getSelection();
|
|
107
|
+
if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
|
|
108
|
+
if (!containerRef.current) return;
|
|
109
|
+
const range = sel.getRangeAt(0);
|
|
110
|
+
if (!containerRef.current.contains(range.startContainer)) return;
|
|
111
|
+
if ((range.startContainer.parentElement as HTMLElement)?.closest?.("[data-annotation-id]")) return;
|
|
112
|
+
const snippet = sel.toString().trim();
|
|
113
|
+
if (snippet.length < 2) return;
|
|
114
|
+
|
|
115
|
+
// Extract context before wrapping disturbs the DOM
|
|
116
|
+
const ctx = extractContextRef.current(range);
|
|
117
|
+
const savedRange = range.cloneRange();
|
|
118
|
+
const tempId = `__pending_${Date.now()}`;
|
|
119
|
+
try { wrapRangeWithMark(savedRange, tempId); } catch {}
|
|
120
|
+
window.getSelection()?.removeAllRanges();
|
|
121
|
+
|
|
122
|
+
const marks = document.querySelectorAll(`[data-annotation-id="${tempId}"]`);
|
|
123
|
+
const lastMark = marks[marks.length - 1] as HTMLElement | undefined;
|
|
124
|
+
if (!lastMark) return;
|
|
125
|
+
|
|
126
|
+
setCreatePopover({ anchorEl: lastMark, tempId, snippet, ctx });
|
|
127
|
+
}, []);
|
|
128
|
+
|
|
129
|
+
const openCreatePopover = useCallback((anchorEl: HTMLElement, tempId: string, snippet: string, ctx?: AnnotationContext) => {
|
|
130
|
+
setCreatePopover({ anchorEl, tempId, snippet, ctx });
|
|
131
|
+
}, []);
|
|
132
|
+
|
|
133
|
+
const popovers = (
|
|
134
|
+
<>
|
|
135
|
+
{editPopover && (() => {
|
|
136
|
+
const ann = allAnnotations.find((a) => a.id === editPopover.annId);
|
|
137
|
+
if (!ann) return null;
|
|
138
|
+
return (
|
|
139
|
+
<AnnotationEditPopover
|
|
140
|
+
anchorEl={editPopover.anchorEl}
|
|
141
|
+
scrollContainer={scrollContainer}
|
|
142
|
+
initialNote={ann.note}
|
|
143
|
+
initialImages={ann.images}
|
|
144
|
+
onUpdate={(note, images) => {
|
|
145
|
+
updateAnnotation(editPopover.annId, note);
|
|
146
|
+
// Sync images
|
|
147
|
+
const current = ann.images || [];
|
|
148
|
+
for (const img of images) {
|
|
149
|
+
if (!current.includes(img)) addAnnotationImage(editPopover.annId, img);
|
|
150
|
+
}
|
|
151
|
+
for (const img of current) {
|
|
152
|
+
if (!images.includes(img)) removeAnnotationImage(editPopover.annId, img);
|
|
153
|
+
}
|
|
154
|
+
}}
|
|
155
|
+
onDelete={() => { removeAnnotation(editPopover.annId); setActiveAnnotationId(null); }}
|
|
156
|
+
onClose={() => setEditPopover(null)}
|
|
157
|
+
/>
|
|
158
|
+
);
|
|
159
|
+
})()}
|
|
160
|
+
|
|
161
|
+
{createPopover && (
|
|
162
|
+
<AnnotationCreatePopover
|
|
163
|
+
anchorEl={createPopover.anchorEl}
|
|
164
|
+
scrollContainer={scrollContainer}
|
|
165
|
+
snippet={createPopover.snippet}
|
|
166
|
+
truncateAt={80}
|
|
167
|
+
onAdd={(note, images) => {
|
|
168
|
+
const id = generateAnnotationId();
|
|
169
|
+
renameMarkId(createPopover.tempId, id);
|
|
170
|
+
addAnnotationRef.current(id, createPopover.snippet, note, filePathRef.current, createPopover.ctx, images);
|
|
171
|
+
setCreatePopover(null);
|
|
172
|
+
}}
|
|
173
|
+
onCancel={() => {
|
|
174
|
+
unwrapMarks(createPopover.tempId);
|
|
175
|
+
setCreatePopover(null);
|
|
176
|
+
}}
|
|
177
|
+
/>
|
|
178
|
+
)}
|
|
179
|
+
</>
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
return { popovers, openCreatePopover, isPopoverOpen: !!(editPopover || createPopover) };
|
|
183
|
+
}
|