@alreadyso/annotate 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/README.md +69 -0
- package/dist/AlreadyAnnotate.d.ts +33 -0
- package/dist/AlreadyAnnotate.js +468 -0
- package/dist/AlreadyAnnotateOverlay.d.ts +7 -0
- package/dist/AlreadyAnnotateOverlay.js +39 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# @alreadyso/annotate
|
|
2
|
+
|
|
3
|
+
A lightweight annotation overlay for React apps that creates Already sessions and posts notes + timeline events.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import { AlreadyAnnotate } from "@alreadyso/annotate";
|
|
9
|
+
|
|
10
|
+
export function App() {
|
|
11
|
+
return (
|
|
12
|
+
<>
|
|
13
|
+
<YourApp />
|
|
14
|
+
<AlreadyAnnotate
|
|
15
|
+
teamId="YOUR_TEAM_ID"
|
|
16
|
+
teamSlug="your-team-slug"
|
|
17
|
+
webAppUrl="http://localhost:3000"
|
|
18
|
+
authToken="SUPABASE_ACCESS_TOKEN"
|
|
19
|
+
openOnSave
|
|
20
|
+
/>
|
|
21
|
+
</>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Overlay Helper
|
|
27
|
+
|
|
28
|
+
If you want the component to fetch an auth token on mount, use `AlreadyAnnotateOverlay`:
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
import { AlreadyAnnotateOverlay } from "@alreadyso/annotate";
|
|
32
|
+
|
|
33
|
+
async function getAuthToken() {
|
|
34
|
+
// Replace with your auth/session lookup.
|
|
35
|
+
return localStorage.getItem("access_token");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function App() {
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
<YourApp />
|
|
42
|
+
<AlreadyAnnotateOverlay
|
|
43
|
+
teamId="YOUR_TEAM_ID"
|
|
44
|
+
teamSlug="your-team-slug"
|
|
45
|
+
getAuthToken={getAuthToken}
|
|
46
|
+
/>
|
|
47
|
+
</>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Props
|
|
53
|
+
|
|
54
|
+
- `teamId` (required): Team UUID for the session.
|
|
55
|
+
- `teamSlug` (optional): Used to open the session in the web app after save.
|
|
56
|
+
- `webAppUrl` (optional): Defaults to `http://localhost:3000`.
|
|
57
|
+
- `authToken` (optional): Supabase access token for user auth.
|
|
58
|
+
- `apiKey` (optional): Agent API key (alternative to `authToken`).
|
|
59
|
+
- `useCookies` (optional): Send cookies with requests (for same-origin use).
|
|
60
|
+
- `defaultStatus` (optional): Session status to set after saving. Defaults to `todo`.
|
|
61
|
+
- `openOnSave` (optional): Open the session after save if `teamSlug` is set. Defaults to `true`.
|
|
62
|
+
- `blockInteractions` (optional): Prevent underlying clicks while annotating. Defaults to `true`.
|
|
63
|
+
- `accentColor` (optional): Accent color for the overlay UI.
|
|
64
|
+
- `sessionTitle` (optional): Override the session title.
|
|
65
|
+
|
|
66
|
+
## Notes
|
|
67
|
+
|
|
68
|
+
- This component writes notes to `session_notes` and annotation events to `session_timeline`.
|
|
69
|
+
- The `Save to Already` action uses `/api/sessions/start`, `/api/sessions/:id/data`, and `/api/sessions/:id`.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export type SessionStatus = "in_session" | "uploading" | "todo" | "in_progress" | "needs_feedback" | "done" | "pending" | "completed" | "cancelled";
|
|
3
|
+
export type Annotation = {
|
|
4
|
+
id: string;
|
|
5
|
+
selector: string;
|
|
6
|
+
label: string;
|
|
7
|
+
text?: string;
|
|
8
|
+
selectedText?: string;
|
|
9
|
+
rect: {
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
};
|
|
15
|
+
comment: string;
|
|
16
|
+
url: string;
|
|
17
|
+
createdAt: string;
|
|
18
|
+
};
|
|
19
|
+
export type AlreadyAnnotateProps = {
|
|
20
|
+
teamId: string;
|
|
21
|
+
teamSlug?: string;
|
|
22
|
+
webAppUrl?: string;
|
|
23
|
+
authToken?: string;
|
|
24
|
+
apiKey?: string;
|
|
25
|
+
useCookies?: boolean;
|
|
26
|
+
defaultStatus?: SessionStatus;
|
|
27
|
+
openOnSave?: boolean;
|
|
28
|
+
blockInteractions?: boolean;
|
|
29
|
+
accentColor?: string;
|
|
30
|
+
sessionTitle?: string;
|
|
31
|
+
onSaveComplete?: (sessionId: string) => void;
|
|
32
|
+
};
|
|
33
|
+
export declare function AlreadyAnnotate({ teamId, teamSlug, webAppUrl, authToken, apiKey, useCookies, defaultStatus, openOnSave, blockInteractions, accentColor, sessionTitle, onSaveComplete, }: AlreadyAnnotateProps): React.ReactPortal | null;
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { createPortal } from "react-dom";
|
|
4
|
+
const DEFAULT_WEB_URL = "http://localhost:3000";
|
|
5
|
+
const DEFAULT_ACCENT = "#2563eb";
|
|
6
|
+
function nowIso() {
|
|
7
|
+
return new Date().toISOString();
|
|
8
|
+
}
|
|
9
|
+
function safeText(input, max = 140) {
|
|
10
|
+
if (!input)
|
|
11
|
+
return "";
|
|
12
|
+
const cleaned = input.replace(/\s+/g, " ").trim();
|
|
13
|
+
if (!cleaned)
|
|
14
|
+
return "";
|
|
15
|
+
return cleaned.length > max ? cleaned.slice(0, max - 1) + "…" : cleaned;
|
|
16
|
+
}
|
|
17
|
+
function getSelectedTextForElement(element) {
|
|
18
|
+
const selection = window.getSelection();
|
|
19
|
+
if (!selection || selection.isCollapsed)
|
|
20
|
+
return undefined;
|
|
21
|
+
const text = selection.toString().trim();
|
|
22
|
+
if (!text)
|
|
23
|
+
return undefined;
|
|
24
|
+
if (selection.rangeCount === 0)
|
|
25
|
+
return text;
|
|
26
|
+
const range = selection.getRangeAt(0);
|
|
27
|
+
const container = range.commonAncestorContainer;
|
|
28
|
+
if (container instanceof Node && element.contains(container)) {
|
|
29
|
+
return text;
|
|
30
|
+
}
|
|
31
|
+
return text;
|
|
32
|
+
}
|
|
33
|
+
function cssEscape(value) {
|
|
34
|
+
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
|
35
|
+
return CSS.escape(value);
|
|
36
|
+
}
|
|
37
|
+
return value.replace(/[^a-zA-Z0-9_-]/g, "\\$");
|
|
38
|
+
}
|
|
39
|
+
function buildSelector(element) {
|
|
40
|
+
const testId = element.getAttribute("data-testid") || element.getAttribute("data-test") || element.getAttribute("data-qa");
|
|
41
|
+
if (testId)
|
|
42
|
+
return `[data-testid=\"${cssEscape(testId)}\"]`;
|
|
43
|
+
if (element.id)
|
|
44
|
+
return `#${cssEscape(element.id)}`;
|
|
45
|
+
const parts = [];
|
|
46
|
+
let current = element;
|
|
47
|
+
while (current && current !== document.body) {
|
|
48
|
+
const tag = current.tagName.toLowerCase();
|
|
49
|
+
let part = tag;
|
|
50
|
+
const classList = Array.from(current.classList).filter(Boolean).slice(0, 2);
|
|
51
|
+
if (classList.length > 0) {
|
|
52
|
+
part += "." + classList.map((cls) => cssEscape(cls)).join(".");
|
|
53
|
+
}
|
|
54
|
+
const siblings = Array.from(current.parentElement?.children || []).filter((node) => node.tagName.toLowerCase() === tag);
|
|
55
|
+
if (siblings.length > 1) {
|
|
56
|
+
const index = siblings.indexOf(current) + 1;
|
|
57
|
+
part += `:nth-of-type(${index})`;
|
|
58
|
+
}
|
|
59
|
+
parts.unshift(part);
|
|
60
|
+
if (current.id)
|
|
61
|
+
break;
|
|
62
|
+
current = current.parentElement;
|
|
63
|
+
}
|
|
64
|
+
return parts.join(" > ");
|
|
65
|
+
}
|
|
66
|
+
function buildLabel(element) {
|
|
67
|
+
const text = safeText(element.textContent, 60);
|
|
68
|
+
if (text)
|
|
69
|
+
return text;
|
|
70
|
+
if (element.id)
|
|
71
|
+
return `#${element.id}`;
|
|
72
|
+
const classes = Array.from(element.classList).slice(0, 2).join(".");
|
|
73
|
+
if (classes)
|
|
74
|
+
return `${element.tagName.toLowerCase()}.${classes}`;
|
|
75
|
+
return element.tagName.toLowerCase();
|
|
76
|
+
}
|
|
77
|
+
function buildNotesDocument(annotations, title, url) {
|
|
78
|
+
const content = [];
|
|
79
|
+
const header = `Annotations: ${title}`;
|
|
80
|
+
content.push({ type: "paragraph", content: [{ type: "text", text: header }] });
|
|
81
|
+
content.push({ type: "paragraph", content: [{ type: "text", text: `URL: ${url}` }] });
|
|
82
|
+
content.push({ type: "paragraph", content: [{ type: "text", text: `Captured: ${nowIso()}` }] });
|
|
83
|
+
annotations.forEach((annotation, index) => {
|
|
84
|
+
const number = index + 1;
|
|
85
|
+
const comment = annotation.comment || "(no comment)";
|
|
86
|
+
content.push({
|
|
87
|
+
type: "paragraph",
|
|
88
|
+
content: [{ type: "text", text: `${number}. ${comment}` }],
|
|
89
|
+
});
|
|
90
|
+
content.push({
|
|
91
|
+
type: "paragraph",
|
|
92
|
+
content: [{ type: "text", text: `Selector: ${annotation.selector}` }],
|
|
93
|
+
});
|
|
94
|
+
content.push({
|
|
95
|
+
type: "paragraph",
|
|
96
|
+
content: [{ type: "text", text: `Element: ${annotation.label}` }],
|
|
97
|
+
});
|
|
98
|
+
if (annotation.selectedText) {
|
|
99
|
+
content.push({
|
|
100
|
+
type: "paragraph",
|
|
101
|
+
content: [{ type: "text", text: `Selected: \"${annotation.selectedText}\"` }],
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const rect = annotation.rect;
|
|
105
|
+
content.push({
|
|
106
|
+
type: "paragraph",
|
|
107
|
+
content: [{ type: "text", text: `Bounds: x=${Math.round(rect.x)}, y=${Math.round(rect.y)}, w=${Math.round(rect.width)}, h=${Math.round(rect.height)}` }],
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
return { type: "doc", content };
|
|
111
|
+
}
|
|
112
|
+
function buildTimeline(annotations) {
|
|
113
|
+
return annotations.map((annotation, index) => ({
|
|
114
|
+
t: index,
|
|
115
|
+
type: "annotation",
|
|
116
|
+
data: {
|
|
117
|
+
filename: `annotation-${index + 1}`,
|
|
118
|
+
selector: annotation.selector,
|
|
119
|
+
label: annotation.label,
|
|
120
|
+
comment: annotation.comment,
|
|
121
|
+
url: annotation.url,
|
|
122
|
+
rect: annotation.rect,
|
|
123
|
+
selectedText: annotation.selectedText || null,
|
|
124
|
+
},
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
function isEventInsideOverlay(target) {
|
|
128
|
+
if (!(target instanceof HTMLElement))
|
|
129
|
+
return false;
|
|
130
|
+
return Boolean(target.closest("[data-already-annotate-ui]"));
|
|
131
|
+
}
|
|
132
|
+
function isIgnoredTarget(target) {
|
|
133
|
+
if (!(target instanceof HTMLElement))
|
|
134
|
+
return true;
|
|
135
|
+
if (target.closest("[data-already-annotate-ignore]"))
|
|
136
|
+
return true;
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
export function AlreadyAnnotate({ teamId, teamSlug, webAppUrl = DEFAULT_WEB_URL, authToken, apiKey, useCookies = false, defaultStatus = "todo", openOnSave = true, blockInteractions = true, accentColor = DEFAULT_ACCENT, sessionTitle, onSaveComplete, }) {
|
|
140
|
+
const [portalRoot, setPortalRoot] = useState(null);
|
|
141
|
+
const [isActive, setIsActive] = useState(false);
|
|
142
|
+
const [hoverRect, setHoverRect] = useState(null);
|
|
143
|
+
const hoveredElementRef = useRef(null);
|
|
144
|
+
const animationFrameRef = useRef(null);
|
|
145
|
+
const previousCursorRef = useRef("");
|
|
146
|
+
const [annotations, setAnnotations] = useState([]);
|
|
147
|
+
const [draft, setDraft] = useState(null);
|
|
148
|
+
const [saving, setSaving] = useState(false);
|
|
149
|
+
const [error, setError] = useState(null);
|
|
150
|
+
const [lastSessionId, setLastSessionId] = useState(null);
|
|
151
|
+
const pageTitle = useMemo(() => {
|
|
152
|
+
if (typeof document === "undefined")
|
|
153
|
+
return "Annotations";
|
|
154
|
+
return sessionTitle || document.title || window.location.pathname;
|
|
155
|
+
}, [sessionTitle]);
|
|
156
|
+
const pageUrl = useMemo(() => {
|
|
157
|
+
if (typeof window === "undefined")
|
|
158
|
+
return "";
|
|
159
|
+
return window.location.href;
|
|
160
|
+
}, []);
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (typeof document === "undefined")
|
|
163
|
+
return;
|
|
164
|
+
const root = document.createElement("div");
|
|
165
|
+
root.setAttribute("data-already-annotate-root", "true");
|
|
166
|
+
document.body.appendChild(root);
|
|
167
|
+
setPortalRoot(root);
|
|
168
|
+
return () => {
|
|
169
|
+
document.body.removeChild(root);
|
|
170
|
+
};
|
|
171
|
+
}, []);
|
|
172
|
+
const resetHover = useCallback(() => {
|
|
173
|
+
hoveredElementRef.current = null;
|
|
174
|
+
setHoverRect(null);
|
|
175
|
+
}, []);
|
|
176
|
+
const updateHoverRect = useCallback((element) => {
|
|
177
|
+
if (!element) {
|
|
178
|
+
resetHover();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const rect = element.getBoundingClientRect();
|
|
182
|
+
setHoverRect(rect);
|
|
183
|
+
}, [resetHover]);
|
|
184
|
+
const handleMouseMove = useCallback((event) => {
|
|
185
|
+
if (!isActive)
|
|
186
|
+
return;
|
|
187
|
+
if (isEventInsideOverlay(event.target) || isIgnoredTarget(event.target)) {
|
|
188
|
+
resetHover();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const target = event.target;
|
|
192
|
+
if (!target || target === document.body || target === document.documentElement) {
|
|
193
|
+
resetHover();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
hoveredElementRef.current = target;
|
|
197
|
+
if (animationFrameRef.current)
|
|
198
|
+
return;
|
|
199
|
+
animationFrameRef.current = window.requestAnimationFrame(() => {
|
|
200
|
+
animationFrameRef.current = null;
|
|
201
|
+
updateHoverRect(target);
|
|
202
|
+
});
|
|
203
|
+
}, [isActive, resetHover, updateHoverRect]);
|
|
204
|
+
const handleClick = useCallback((event) => {
|
|
205
|
+
if (!isActive)
|
|
206
|
+
return;
|
|
207
|
+
if (event.button !== 0)
|
|
208
|
+
return;
|
|
209
|
+
if (isEventInsideOverlay(event.target) || isIgnoredTarget(event.target))
|
|
210
|
+
return;
|
|
211
|
+
if (blockInteractions) {
|
|
212
|
+
event.preventDefault();
|
|
213
|
+
event.stopPropagation();
|
|
214
|
+
}
|
|
215
|
+
const target = event.target;
|
|
216
|
+
if (!target || target === document.body || target === document.documentElement)
|
|
217
|
+
return;
|
|
218
|
+
const rect = target.getBoundingClientRect();
|
|
219
|
+
const selector = buildSelector(target);
|
|
220
|
+
const label = buildLabel(target);
|
|
221
|
+
const text = safeText(target.textContent, 120);
|
|
222
|
+
const selectedText = getSelectedTextForElement(target);
|
|
223
|
+
setDraft({
|
|
224
|
+
selector,
|
|
225
|
+
label,
|
|
226
|
+
text,
|
|
227
|
+
selectedText,
|
|
228
|
+
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
229
|
+
comment: "",
|
|
230
|
+
url: pageUrl,
|
|
231
|
+
});
|
|
232
|
+
}, [blockInteractions, isActive, pageUrl]);
|
|
233
|
+
const handleKeyDown = useCallback((event) => {
|
|
234
|
+
if (!isActive)
|
|
235
|
+
return;
|
|
236
|
+
if (event.key !== "Escape")
|
|
237
|
+
return;
|
|
238
|
+
if (draft) {
|
|
239
|
+
setDraft(null);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
setIsActive(false);
|
|
243
|
+
}, [draft, isActive]);
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (!isActive) {
|
|
246
|
+
resetHover();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
previousCursorRef.current = document.body.style.cursor;
|
|
250
|
+
document.body.style.cursor = "crosshair";
|
|
251
|
+
window.addEventListener("mousemove", handleMouseMove, true);
|
|
252
|
+
window.addEventListener("click", handleClick, true);
|
|
253
|
+
window.addEventListener("keydown", handleKeyDown, true);
|
|
254
|
+
const handleScroll = () => {
|
|
255
|
+
if (!hoveredElementRef.current)
|
|
256
|
+
return;
|
|
257
|
+
updateHoverRect(hoveredElementRef.current);
|
|
258
|
+
};
|
|
259
|
+
window.addEventListener("scroll", handleScroll, true);
|
|
260
|
+
window.addEventListener("resize", handleScroll, true);
|
|
261
|
+
return () => {
|
|
262
|
+
document.body.style.cursor = previousCursorRef.current;
|
|
263
|
+
window.removeEventListener("mousemove", handleMouseMove, true);
|
|
264
|
+
window.removeEventListener("click", handleClick, true);
|
|
265
|
+
window.removeEventListener("keydown", handleKeyDown, true);
|
|
266
|
+
window.removeEventListener("scroll", handleScroll, true);
|
|
267
|
+
window.removeEventListener("resize", handleScroll, true);
|
|
268
|
+
};
|
|
269
|
+
}, [handleClick, handleKeyDown, handleMouseMove, isActive, resetHover, updateHoverRect]);
|
|
270
|
+
const handleSaveDraft = useCallback(() => {
|
|
271
|
+
if (!draft)
|
|
272
|
+
return;
|
|
273
|
+
const id = typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
274
|
+
? crypto.randomUUID()
|
|
275
|
+
: String(Date.now());
|
|
276
|
+
const newAnnotation = {
|
|
277
|
+
...draft,
|
|
278
|
+
id,
|
|
279
|
+
createdAt: nowIso(),
|
|
280
|
+
};
|
|
281
|
+
setAnnotations((prev) => [newAnnotation, ...prev]);
|
|
282
|
+
setDraft(null);
|
|
283
|
+
}, [draft]);
|
|
284
|
+
const handleDeleteAnnotation = useCallback((id) => {
|
|
285
|
+
setAnnotations((prev) => prev.filter((item) => item.id !== id));
|
|
286
|
+
}, []);
|
|
287
|
+
const buildHeaders = useCallback(() => {
|
|
288
|
+
const headers = {
|
|
289
|
+
"Content-Type": "application/json",
|
|
290
|
+
"X-Team-Id": teamId,
|
|
291
|
+
};
|
|
292
|
+
if (apiKey) {
|
|
293
|
+
headers["X-API-Key"] = apiKey;
|
|
294
|
+
}
|
|
295
|
+
else if (authToken) {
|
|
296
|
+
headers["Authorization"] = `Bearer ${authToken}`;
|
|
297
|
+
}
|
|
298
|
+
return headers;
|
|
299
|
+
}, [apiKey, authToken, teamId]);
|
|
300
|
+
const createSession = useCallback(async () => {
|
|
301
|
+
const response = await fetch(`${webAppUrl}/api/sessions/start`, {
|
|
302
|
+
method: "POST",
|
|
303
|
+
headers: buildHeaders(),
|
|
304
|
+
body: JSON.stringify({ title: pageTitle, source: "web" }),
|
|
305
|
+
credentials: useCookies ? "include" : "omit",
|
|
306
|
+
});
|
|
307
|
+
const payload = await response.json();
|
|
308
|
+
if (!response.ok) {
|
|
309
|
+
throw new Error(payload.error || payload.message || `Failed to create session (${response.status})`);
|
|
310
|
+
}
|
|
311
|
+
if (!payload.session_id) {
|
|
312
|
+
throw new Error("Session creation succeeded but no session_id returned");
|
|
313
|
+
}
|
|
314
|
+
return payload.session_id;
|
|
315
|
+
}, [buildHeaders, pageTitle, useCookies, webAppUrl]);
|
|
316
|
+
const updateSessionData = useCallback(async (sessionId) => {
|
|
317
|
+
const notes = buildNotesDocument(annotations, pageTitle, pageUrl);
|
|
318
|
+
const timeline = buildTimeline(annotations);
|
|
319
|
+
const response = await fetch(`${webAppUrl}/api/sessions/${sessionId}/data`, {
|
|
320
|
+
method: "PATCH",
|
|
321
|
+
headers: buildHeaders(),
|
|
322
|
+
body: JSON.stringify({
|
|
323
|
+
title: pageTitle,
|
|
324
|
+
duration_seconds: 0,
|
|
325
|
+
notes,
|
|
326
|
+
timeline,
|
|
327
|
+
}),
|
|
328
|
+
credentials: useCookies ? "include" : "omit",
|
|
329
|
+
});
|
|
330
|
+
const payload = await response.json();
|
|
331
|
+
if (!response.ok) {
|
|
332
|
+
throw new Error(payload.error || payload.message || `Failed to save session data (${response.status})`);
|
|
333
|
+
}
|
|
334
|
+
}, [annotations, buildHeaders, pageTitle, pageUrl, useCookies, webAppUrl]);
|
|
335
|
+
const updateSessionStatus = useCallback(async (sessionId) => {
|
|
336
|
+
if (!defaultStatus || defaultStatus === "in_session" || defaultStatus === "uploading") {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const response = await fetch(`${webAppUrl}/api/sessions/${sessionId}`, {
|
|
340
|
+
method: "PATCH",
|
|
341
|
+
headers: buildHeaders(),
|
|
342
|
+
body: JSON.stringify({ status: defaultStatus }),
|
|
343
|
+
credentials: useCookies ? "include" : "omit",
|
|
344
|
+
});
|
|
345
|
+
const payload = await response.json();
|
|
346
|
+
if (!response.ok) {
|
|
347
|
+
throw new Error(payload.error || payload.message || `Failed to update session status (${response.status})`);
|
|
348
|
+
}
|
|
349
|
+
}, [buildHeaders, defaultStatus, useCookies, webAppUrl]);
|
|
350
|
+
const handleSaveToAlready = useCallback(async () => {
|
|
351
|
+
if (annotations.length === 0) {
|
|
352
|
+
setError("Add at least one annotation before saving.");
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
setError(null);
|
|
356
|
+
setSaving(true);
|
|
357
|
+
try {
|
|
358
|
+
const sessionId = await createSession();
|
|
359
|
+
await updateSessionData(sessionId);
|
|
360
|
+
await updateSessionStatus(sessionId);
|
|
361
|
+
setLastSessionId(sessionId);
|
|
362
|
+
onSaveComplete?.(sessionId);
|
|
363
|
+
if (openOnSave && teamSlug) {
|
|
364
|
+
const url = `${webAppUrl}/t/${teamSlug}/sessions/${sessionId}`;
|
|
365
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
const message = err instanceof Error ? err.message : "Failed to save session";
|
|
370
|
+
setError(message);
|
|
371
|
+
}
|
|
372
|
+
finally {
|
|
373
|
+
setSaving(false);
|
|
374
|
+
}
|
|
375
|
+
}, [annotations.length, createSession, onSaveComplete, openOnSave, teamSlug, updateSessionData, updateSessionStatus, webAppUrl]);
|
|
376
|
+
const panel = (_jsxs("div", { "data-already-annotate-ui": true, style: {
|
|
377
|
+
position: "fixed",
|
|
378
|
+
right: 16,
|
|
379
|
+
bottom: 16,
|
|
380
|
+
width: 320,
|
|
381
|
+
background: "#0f172a",
|
|
382
|
+
color: "#f8fafc",
|
|
383
|
+
borderRadius: 12,
|
|
384
|
+
boxShadow: "0 12px 30px rgba(15, 23, 42, 0.35)",
|
|
385
|
+
fontFamily: "ui-sans-serif, system-ui, -apple-system, sans-serif",
|
|
386
|
+
zIndex: 2147483647,
|
|
387
|
+
}, children: [_jsxs("div", { style: { padding: "12px 12px 8px" }, children: [_jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between" }, children: [_jsx("div", { style: { fontSize: 14, fontWeight: 600 }, children: "Already Annotate" }), _jsx("button", { type: "button", onClick: () => setIsActive((prev) => !prev), style: {
|
|
388
|
+
background: isActive ? accentColor : "#1e293b",
|
|
389
|
+
color: "#fff",
|
|
390
|
+
border: "none",
|
|
391
|
+
borderRadius: 999,
|
|
392
|
+
padding: "6px 12px",
|
|
393
|
+
fontSize: 12,
|
|
394
|
+
cursor: "pointer",
|
|
395
|
+
}, children: isActive ? "Stop" : "Annotate" })] }), _jsx("div", { style: { marginTop: 6, fontSize: 12, color: "#94a3b8" }, children: isActive ? "Click elements to add notes." : "Toggle to start capturing." })] }), draft && (_jsxs("div", { style: { borderTop: "1px solid #1e293b", padding: 12 }, children: [_jsx("div", { style: { fontSize: 12, color: "#cbd5f5", marginBottom: 6 }, children: "New annotation" }), _jsx("div", { style: { fontSize: 12, color: "#e2e8f0", marginBottom: 6 }, children: draft.label }), _jsx("textarea", { rows: 3, value: draft.comment, onChange: (event) => setDraft({ ...draft, comment: event.target.value }), placeholder: "Describe the issue or request...", style: {
|
|
396
|
+
width: "100%",
|
|
397
|
+
borderRadius: 8,
|
|
398
|
+
border: "1px solid #1e293b",
|
|
399
|
+
background: "#0b1220",
|
|
400
|
+
color: "#f8fafc",
|
|
401
|
+
padding: 8,
|
|
402
|
+
fontSize: 12,
|
|
403
|
+
resize: "vertical",
|
|
404
|
+
} }), _jsxs("div", { style: { display: "flex", gap: 8, marginTop: 8 }, children: [_jsx("button", { type: "button", onClick: handleSaveDraft, style: {
|
|
405
|
+
flex: 1,
|
|
406
|
+
background: accentColor,
|
|
407
|
+
border: "none",
|
|
408
|
+
borderRadius: 8,
|
|
409
|
+
padding: "6px 10px",
|
|
410
|
+
color: "#fff",
|
|
411
|
+
fontSize: 12,
|
|
412
|
+
cursor: "pointer",
|
|
413
|
+
}, children: "Add" }), _jsx("button", { type: "button", onClick: () => setDraft(null), style: {
|
|
414
|
+
flex: 1,
|
|
415
|
+
background: "#1e293b",
|
|
416
|
+
border: "none",
|
|
417
|
+
borderRadius: 8,
|
|
418
|
+
padding: "6px 10px",
|
|
419
|
+
color: "#e2e8f0",
|
|
420
|
+
fontSize: 12,
|
|
421
|
+
cursor: "pointer",
|
|
422
|
+
}, children: "Cancel" })] })] })), _jsxs("div", { style: { borderTop: "1px solid #1e293b", padding: 12 }, children: [_jsxs("div", { style: { fontSize: 12, color: "#94a3b8", marginBottom: 6 }, children: [annotations.length, " annotations"] }), _jsxs("div", { style: { maxHeight: 160, overflowY: "auto", display: "grid", gap: 8 }, children: [annotations.map((annotation) => (_jsxs("div", { style: {
|
|
423
|
+
borderRadius: 8,
|
|
424
|
+
background: "#0b1220",
|
|
425
|
+
padding: 8,
|
|
426
|
+
border: "1px solid #1e293b",
|
|
427
|
+
}, children: [_jsx("div", { style: { fontSize: 12, color: "#e2e8f0" }, children: annotation.comment || "(no comment)" }), _jsx("div", { style: { fontSize: 11, color: "#94a3b8", marginTop: 4 }, children: annotation.label }), _jsx("button", { type: "button", onClick: () => handleDeleteAnnotation(annotation.id), style: {
|
|
428
|
+
marginTop: 6,
|
|
429
|
+
background: "transparent",
|
|
430
|
+
border: "none",
|
|
431
|
+
color: "#f87171",
|
|
432
|
+
fontSize: 11,
|
|
433
|
+
cursor: "pointer",
|
|
434
|
+
padding: 0,
|
|
435
|
+
}, children: "Remove" })] }, annotation.id))), annotations.length === 0 && (_jsx("div", { style: { fontSize: 12, color: "#64748b" }, children: "No annotations yet." }))] }), error && (_jsx("div", { style: { marginTop: 8, fontSize: 12, color: "#fca5a5" }, children: error })), lastSessionId && (_jsxs("div", { style: { marginTop: 8, fontSize: 11, color: "#94a3b8" }, children: ["Last session: ", lastSessionId] })), _jsxs("div", { style: { display: "flex", gap: 8, marginTop: 10 }, children: [_jsx("button", { type: "button", onClick: handleSaveToAlready, disabled: saving, style: {
|
|
436
|
+
flex: 1,
|
|
437
|
+
background: accentColor,
|
|
438
|
+
border: "none",
|
|
439
|
+
borderRadius: 8,
|
|
440
|
+
padding: "8px 10px",
|
|
441
|
+
color: "#fff",
|
|
442
|
+
fontSize: 12,
|
|
443
|
+
cursor: saving ? "not-allowed" : "pointer",
|
|
444
|
+
opacity: saving ? 0.7 : 1,
|
|
445
|
+
}, children: saving ? "Saving..." : "Save to Already" }), _jsx("button", { type: "button", onClick: () => setAnnotations([]), style: {
|
|
446
|
+
background: "#1e293b",
|
|
447
|
+
border: "none",
|
|
448
|
+
borderRadius: 8,
|
|
449
|
+
padding: "8px 10px",
|
|
450
|
+
color: "#e2e8f0",
|
|
451
|
+
fontSize: 12,
|
|
452
|
+
cursor: "pointer",
|
|
453
|
+
}, children: "Clear" })] })] })] }));
|
|
454
|
+
const highlight = hoverRect && isActive && (_jsx("div", { "data-already-annotate-ui": true, style: {
|
|
455
|
+
position: "fixed",
|
|
456
|
+
left: hoverRect.x,
|
|
457
|
+
top: hoverRect.y,
|
|
458
|
+
width: hoverRect.width,
|
|
459
|
+
height: hoverRect.height,
|
|
460
|
+
border: `2px solid ${accentColor}`,
|
|
461
|
+
background: `${accentColor}22`,
|
|
462
|
+
pointerEvents: "none",
|
|
463
|
+
zIndex: 2147483646,
|
|
464
|
+
} }));
|
|
465
|
+
if (!portalRoot)
|
|
466
|
+
return null;
|
|
467
|
+
return createPortal(_jsxs(_Fragment, { children: [highlight, panel] }), portalRoot);
|
|
468
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type AlreadyAnnotateProps } from "./AlreadyAnnotate";
|
|
2
|
+
export type AlreadyAnnotateOverlayProps = Omit<AlreadyAnnotateProps, "authToken"> & {
|
|
3
|
+
authToken?: string | null;
|
|
4
|
+
getAuthToken?: () => Promise<string | null>;
|
|
5
|
+
enabled?: boolean;
|
|
6
|
+
};
|
|
7
|
+
export declare function AlreadyAnnotateOverlay({ authToken, getAuthToken, enabled, webAppUrl, apiKey, ...rest }: AlreadyAnnotateOverlayProps): import("react/jsx-runtime").JSX.Element | null;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { AlreadyAnnotate } from "./AlreadyAnnotate";
|
|
5
|
+
export function AlreadyAnnotateOverlay({ authToken, getAuthToken, enabled = true, webAppUrl, apiKey, ...rest }) {
|
|
6
|
+
const [resolvedToken, setResolvedToken] = useState(authToken ?? null);
|
|
7
|
+
const resolvedWebAppUrl = useMemo(() => {
|
|
8
|
+
if (webAppUrl)
|
|
9
|
+
return webAppUrl;
|
|
10
|
+
if (typeof window === "undefined")
|
|
11
|
+
return undefined;
|
|
12
|
+
return window.location.origin;
|
|
13
|
+
}, [webAppUrl]);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!enabled)
|
|
16
|
+
return;
|
|
17
|
+
if (getAuthToken && (authToken === undefined || authToken === null)) {
|
|
18
|
+
let active = true;
|
|
19
|
+
getAuthToken()
|
|
20
|
+
.then((token) => {
|
|
21
|
+
if (active)
|
|
22
|
+
setResolvedToken(token ?? null);
|
|
23
|
+
})
|
|
24
|
+
.catch(() => {
|
|
25
|
+
if (active)
|
|
26
|
+
setResolvedToken(null);
|
|
27
|
+
});
|
|
28
|
+
return () => {
|
|
29
|
+
active = false;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
setResolvedToken(authToken ?? null);
|
|
33
|
+
}, [authToken, enabled, getAuthToken]);
|
|
34
|
+
if (!enabled)
|
|
35
|
+
return null;
|
|
36
|
+
if (!apiKey && !resolvedToken)
|
|
37
|
+
return null;
|
|
38
|
+
return (_jsx(AlreadyAnnotate, { ...rest, apiKey: apiKey, authToken: resolvedToken ?? undefined, webAppUrl: resolvedWebAppUrl }));
|
|
39
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { AlreadyAnnotate } from "./AlreadyAnnotate";
|
|
2
|
+
export { AlreadyAnnotateOverlay } from "./AlreadyAnnotateOverlay";
|
|
3
|
+
export type { AlreadyAnnotateProps, Annotation, SessionStatus } from "./AlreadyAnnotate";
|
|
4
|
+
export type { AlreadyAnnotateOverlayProps } from "./AlreadyAnnotateOverlay";
|
package/dist/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alreadyso/annotate",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Annotation overlay for creating Already sessions from React apps.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"module": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"react": ">=18",
|
|
26
|
+
"react-dom": ">=18"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/react": "^19.0.0",
|
|
30
|
+
"@types/react-dom": "^19.0.0",
|
|
31
|
+
"react": "^19.0.0",
|
|
32
|
+
"react-dom": "^19.0.0",
|
|
33
|
+
"typescript": "^5.4.0"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"clean": "rm -rf dist",
|
|
37
|
+
"build": "tsc -p tsconfig.json",
|
|
38
|
+
"dev": "tsc -p tsconfig.json --watch"
|
|
39
|
+
}
|
|
40
|
+
}
|