@levistudio/redline 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/CHANGELOG.md +37 -0
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/ROADMAP.md +39 -0
- package/SECURITY.md +33 -0
- package/bin/redline.cjs +61 -0
- package/package.json +61 -0
- package/scripts/install-skill.sh +78 -0
- package/skills/redline-review/SKILL.md +102 -0
- package/src/agent.ts +283 -0
- package/src/cli.ts +332 -0
- package/src/client/cards.ts +385 -0
- package/src/client/diff.ts +100 -0
- package/src/client/firstRunBanner.ts +26 -0
- package/src/client/lib.ts +299 -0
- package/src/client/main.ts +119 -0
- package/src/client/render.ts +413 -0
- package/src/client/selection.ts +253 -0
- package/src/client/sse.ts +179 -0
- package/src/client/state.ts +56 -0
- package/src/client/styles.css +994 -0
- package/src/contextBlock.ts +16 -0
- package/src/diff.ts +166 -0
- package/src/parseReply.ts +115 -0
- package/src/pickModel.ts +38 -0
- package/src/promptEnvelope.ts +58 -0
- package/src/render.ts +83 -0
- package/src/resolve.ts +290 -0
- package/src/server-page.ts +119 -0
- package/src/server.ts +634 -0
- package/src/sidecar.ts +190 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computeNavState,
|
|
3
|
+
highlightText as _highlightText,
|
|
4
|
+
preserveScroll as _preserveScroll,
|
|
5
|
+
latestVerdict,
|
|
6
|
+
} from "./lib";
|
|
7
|
+
import { state, apiFetch, showError } from "./state";
|
|
8
|
+
import {
|
|
9
|
+
buildCommentCard,
|
|
10
|
+
captureTypingState,
|
|
11
|
+
restoreTypingState,
|
|
12
|
+
positionCards,
|
|
13
|
+
observeCardSizes,
|
|
14
|
+
toggleReplyForm,
|
|
15
|
+
} from "./cards";
|
|
16
|
+
|
|
17
|
+
function preserveScroll(fn: () => void): void {
|
|
18
|
+
_preserveScroll(fn, {
|
|
19
|
+
skip: Date.now() < state.deliberateScrollUntil,
|
|
20
|
+
protectFocusSelector: "#new-comment-form",
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function applyHighlights(): void {
|
|
25
|
+
preserveScroll(() => {
|
|
26
|
+
const prose = document.getElementById("prose")!;
|
|
27
|
+
prose.querySelectorAll("mark.rl-highlight").forEach((m) => {
|
|
28
|
+
const parent = m.parentNode!;
|
|
29
|
+
while (m.firstChild) parent.insertBefore(m.firstChild, m);
|
|
30
|
+
parent.removeChild(m);
|
|
31
|
+
});
|
|
32
|
+
prose.normalize();
|
|
33
|
+
state.comments.forEach((comment) => {
|
|
34
|
+
highlightText(prose, comment.quote, comment.id, comment.resolved, comment.context_before || "");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function highlightText(
|
|
40
|
+
container: Element,
|
|
41
|
+
text: string,
|
|
42
|
+
id: string,
|
|
43
|
+
resolved: boolean,
|
|
44
|
+
contextBefore: string,
|
|
45
|
+
): void {
|
|
46
|
+
const marks = _highlightText(container, text, id, resolved, contextBefore);
|
|
47
|
+
marks.forEach((m) => {
|
|
48
|
+
if (m.classList.contains("rl-img")) {
|
|
49
|
+
m.addEventListener("click", (e) => {
|
|
50
|
+
e.stopPropagation();
|
|
51
|
+
focusComment(id);
|
|
52
|
+
});
|
|
53
|
+
} else {
|
|
54
|
+
m.addEventListener("click", (e) => {
|
|
55
|
+
e.stopPropagation();
|
|
56
|
+
focusComment(id);
|
|
57
|
+
updateNav();
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function focusComment(id: string): void {
|
|
64
|
+
document.querySelectorAll(".comment-card").forEach((el) => el.classList.remove("active"));
|
|
65
|
+
document.querySelectorAll("mark.rl-highlight").forEach((el) => el.classList.remove("active"));
|
|
66
|
+
const card = document.getElementById("card-" + id);
|
|
67
|
+
if (card) card.classList.add("active");
|
|
68
|
+
document.querySelectorAll('[data-comment-id="' + id + '"]').forEach((el) => el.classList.add("active"));
|
|
69
|
+
positionCards();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function clearFocus(): void {
|
|
73
|
+
document.querySelectorAll(".comment-card").forEach((el) => el.classList.remove("active"));
|
|
74
|
+
document.querySelectorAll("mark.rl-highlight").forEach((el) => el.classList.remove("active"));
|
|
75
|
+
positionCards();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function navigateTo(id: string): void {
|
|
79
|
+
focusComment(id);
|
|
80
|
+
positionCards();
|
|
81
|
+
const mark = document.querySelector('[data-comment-id="' + id + '"]');
|
|
82
|
+
if (mark) mark.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function updateNav(): void {
|
|
86
|
+
const nav = document.getElementById("comment-nav")!;
|
|
87
|
+
const countEl = document.getElementById("nav-count")!;
|
|
88
|
+
const prevBtn = document.getElementById("nav-prev") as HTMLButtonElement;
|
|
89
|
+
const nextBtn = document.getElementById("nav-next") as HTMLButtonElement;
|
|
90
|
+
const activeCard = document.querySelector(".comment-card.active");
|
|
91
|
+
const activeId = activeCard ? activeCard.id.replace(/^card-/, "") : null;
|
|
92
|
+
const navState = computeNavState(state.comments, activeId, state.navIdx);
|
|
93
|
+
state.navIdx = navState.navIdx;
|
|
94
|
+
if (!navState.visible) {
|
|
95
|
+
nav.style.display = "none";
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
nav.style.display = "flex";
|
|
99
|
+
countEl.textContent = navState.countText;
|
|
100
|
+
prevBtn.style.display = navState.showPrev ? "" : "none";
|
|
101
|
+
prevBtn.textContent = "\u2191 Prev";
|
|
102
|
+
prevBtn.disabled = navState.prevDisabled;
|
|
103
|
+
nextBtn.style.display = "";
|
|
104
|
+
nextBtn.textContent = navState.nextLabel;
|
|
105
|
+
nextBtn.disabled = navState.nextDisabled;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function renderComments(): void {
|
|
109
|
+
const typing = captureTypingState();
|
|
110
|
+
preserveScroll(() => {
|
|
111
|
+
const sidebar = document.querySelector(".sidebar-col")!;
|
|
112
|
+
|
|
113
|
+
const activeCard = sidebar.querySelector(".comment-card.active");
|
|
114
|
+
const activeId = activeCard ? activeCard.id.replace(/^card-/, "") : null;
|
|
115
|
+
|
|
116
|
+
sidebar.querySelectorAll(".comment-card").forEach((el) => el.remove());
|
|
117
|
+
|
|
118
|
+
state.comments.forEach((comment) => {
|
|
119
|
+
sidebar.appendChild(buildCommentCard(comment));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (activeId) {
|
|
123
|
+
const restored = document.getElementById("card-" + activeId);
|
|
124
|
+
if (restored) restored.classList.add("active");
|
|
125
|
+
document
|
|
126
|
+
.querySelectorAll('mark.rl-highlight[data-comment-id="' + activeId + '"]')
|
|
127
|
+
.forEach((el) => el.classList.add("active"));
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
observeCardSizes();
|
|
131
|
+
restoreTypingState(typing);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function saveComment(
|
|
135
|
+
form: HTMLElement,
|
|
136
|
+
textarea: HTMLTextAreaElement,
|
|
137
|
+
selection: Record<string, unknown>,
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
const message = textarea.value.trim();
|
|
140
|
+
if (!message) {
|
|
141
|
+
textarea.focus();
|
|
142
|
+
textarea.style.borderColor = "var(--accent)";
|
|
143
|
+
textarea.placeholder = "Type a comment first\u2026";
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const res = await apiFetch("/api/comment", {
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers: { "Content-Type": "application/json" },
|
|
151
|
+
body: JSON.stringify({ ...selection, message }),
|
|
152
|
+
});
|
|
153
|
+
const data = await res.json();
|
|
154
|
+
if (data.ok) {
|
|
155
|
+
form.remove();
|
|
156
|
+
removePendingHighlight();
|
|
157
|
+
if (!state.comments.some((c) => c.id === data.comment.id)) {
|
|
158
|
+
state.comments.push(data.comment);
|
|
159
|
+
}
|
|
160
|
+
state.thinkingCommentIds.add(data.comment.id);
|
|
161
|
+
state.pendingSelection = null;
|
|
162
|
+
window.getSelection()?.removeAllRanges();
|
|
163
|
+
renderComments();
|
|
164
|
+
applyHighlights();
|
|
165
|
+
applyRoundState();
|
|
166
|
+
focusComment(data.comment.id);
|
|
167
|
+
updateNav();
|
|
168
|
+
const newCard = document.getElementById("card-" + data.comment.id);
|
|
169
|
+
if (newCard) newCard.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
170
|
+
} else {
|
|
171
|
+
showError(data.error || "Failed to save comment");
|
|
172
|
+
}
|
|
173
|
+
} catch (err: unknown) {
|
|
174
|
+
showError("Failed to save comment: " + (err as Error).message);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function submitReply(id: string, message: string): Promise<void> {
|
|
179
|
+
if (!message) return;
|
|
180
|
+
try {
|
|
181
|
+
const res = await apiFetch("/api/comment/" + id + "/reply", {
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers: { "Content-Type": "application/json" },
|
|
184
|
+
body: JSON.stringify({ role: "human", message }),
|
|
185
|
+
});
|
|
186
|
+
const data = await res.json();
|
|
187
|
+
if (data.ok) {
|
|
188
|
+
const idx = state.comments.findIndex((c) => c.id === id);
|
|
189
|
+
if (idx !== -1) state.comments[idx] = data.comment;
|
|
190
|
+
state.thinkingCommentIds.add(id);
|
|
191
|
+
renderComments();
|
|
192
|
+
positionCards();
|
|
193
|
+
updateNav();
|
|
194
|
+
} else {
|
|
195
|
+
showError(data.error || "Failed to save reply");
|
|
196
|
+
}
|
|
197
|
+
} catch (err: unknown) {
|
|
198
|
+
showError("Failed to save reply: " + (err as Error).message);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function resolveComment(id: string): Promise<void> {
|
|
203
|
+
try {
|
|
204
|
+
const res = await apiFetch("/api/comment/" + id + "/resolve", { method: "POST" });
|
|
205
|
+
const data = await res.json();
|
|
206
|
+
if (data.ok) {
|
|
207
|
+
const c = state.comments.find((c) => c.id === id);
|
|
208
|
+
if (c) c.resolved = true;
|
|
209
|
+
state.navIdx = 0;
|
|
210
|
+
renderComments();
|
|
211
|
+
applyHighlights();
|
|
212
|
+
positionCards();
|
|
213
|
+
updateNav();
|
|
214
|
+
applyRoundState();
|
|
215
|
+
if (data.allResolved) {
|
|
216
|
+
state.deliberateScrollUntil = Date.now() + 1200;
|
|
217
|
+
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
showError(data.error || "Failed to resolve comment");
|
|
221
|
+
}
|
|
222
|
+
} catch (err: unknown) {
|
|
223
|
+
showError("Failed to resolve: " + (err as Error).message);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function reopenComment(id: string): Promise<void> {
|
|
228
|
+
try {
|
|
229
|
+
const res = await apiFetch("/api/comment/" + id + "/reopen", { method: "POST" });
|
|
230
|
+
const data = await res.json();
|
|
231
|
+
if (data.ok) {
|
|
232
|
+
const idx = state.comments.findIndex((c) => c.id === id);
|
|
233
|
+
if (idx !== -1) state.comments[idx] = data.comment;
|
|
234
|
+
renderComments();
|
|
235
|
+
applyHighlights();
|
|
236
|
+
positionCards();
|
|
237
|
+
updateNav();
|
|
238
|
+
applyRoundState();
|
|
239
|
+
} else {
|
|
240
|
+
showError(data.error || "Failed to reopen comment");
|
|
241
|
+
}
|
|
242
|
+
} catch (err: unknown) {
|
|
243
|
+
showError("Failed to reopen: " + (err as Error).message);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function removePendingHighlight(): void {
|
|
248
|
+
const prose = document.getElementById("prose")!;
|
|
249
|
+
prose.querySelectorAll('[data-comment-id="pending"]').forEach((m) => {
|
|
250
|
+
const parent = m.parentNode!;
|
|
251
|
+
while (m.firstChild) parent.insertBefore(m.firstChild, m);
|
|
252
|
+
parent.removeChild(m);
|
|
253
|
+
});
|
|
254
|
+
prose.normalize();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function applyRoundState(): void {
|
|
258
|
+
const btnAccept = document.getElementById("btn-accept") as HTMLButtonElement | null;
|
|
259
|
+
const banner = document.getElementById("sidebar-status-banner");
|
|
260
|
+
if (!btnAccept) return;
|
|
261
|
+
|
|
262
|
+
const errorShowing = !!banner?.classList.contains("error");
|
|
263
|
+
|
|
264
|
+
if (state.roundResolved) {
|
|
265
|
+
document.getElementById("empty-rail-hint")?.remove();
|
|
266
|
+
document.getElementById("round-secondary")?.remove();
|
|
267
|
+
btnAccept.disabled = true;
|
|
268
|
+
btnAccept.textContent = "\u2713 Accepted";
|
|
269
|
+
if (banner && !errorShowing) {
|
|
270
|
+
banner.innerHTML =
|
|
271
|
+
'<div class="revising-header"><span class="revising-spinner"></span><span>Revising the document<span class="revising-dots"><span>.</span><span>.</span><span>.</span></span></span></div><div id="revision-stream"></div>';
|
|
272
|
+
banner.classList.add("revising");
|
|
273
|
+
banner.style.display = "flex";
|
|
274
|
+
}
|
|
275
|
+
renderComments();
|
|
276
|
+
positionCards();
|
|
277
|
+
updateNav();
|
|
278
|
+
} else if (state.comments.length === 0) {
|
|
279
|
+
document.getElementById("round-secondary")?.remove();
|
|
280
|
+
btnAccept.disabled = false;
|
|
281
|
+
btnAccept.textContent = "Accept doc";
|
|
282
|
+
btnAccept.dataset.mode = "finish";
|
|
283
|
+
if (banner && !errorShowing) {
|
|
284
|
+
banner.classList.remove("revising");
|
|
285
|
+
banner.style.display = "none";
|
|
286
|
+
}
|
|
287
|
+
const hint = document.getElementById("empty-rail-hint");
|
|
288
|
+
const isColdOpen = state.totalRounds <= 1;
|
|
289
|
+
if (isColdOpen) {
|
|
290
|
+
if (!hint) {
|
|
291
|
+
const rail = document.querySelector(".sidebar-col");
|
|
292
|
+
if (rail) {
|
|
293
|
+
const el = document.createElement("div");
|
|
294
|
+
el.id = "empty-rail-hint";
|
|
295
|
+
el.className = "empty-rail-hint";
|
|
296
|
+
el.textContent = "Select text to leave a comment.";
|
|
297
|
+
rail.appendChild(el);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
} else if (hint) {
|
|
301
|
+
hint.remove();
|
|
302
|
+
}
|
|
303
|
+
} else {
|
|
304
|
+
document.getElementById("empty-rail-hint")?.remove();
|
|
305
|
+
const hasOpen = state.comments.some((c) => !c.resolved);
|
|
306
|
+
btnAccept.disabled = hasOpen;
|
|
307
|
+
document.getElementById("round-secondary")?.remove();
|
|
308
|
+
if (hasOpen) {
|
|
309
|
+
// In manual (--no-agent) mode there's no agent to run a revision pass,
|
|
310
|
+
// so the round-level button stays in finish mode regardless of how
|
|
311
|
+
// many comments are open. The "Revise document" affordance would
|
|
312
|
+
// dead-end on the watchdog if clicked.
|
|
313
|
+
const mode = state.noAgent ? "finish" : "accept";
|
|
314
|
+
const label = state.noAgent ? "Finish review" : "Revise document";
|
|
315
|
+
btnAccept.dataset.mode = mode;
|
|
316
|
+
btnAccept.classList.remove("revise-tinted");
|
|
317
|
+
btnAccept.textContent = label;
|
|
318
|
+
if (banner && !errorShowing) {
|
|
319
|
+
banner.classList.remove("revising");
|
|
320
|
+
banner.style.display = "none";
|
|
321
|
+
}
|
|
322
|
+
} else {
|
|
323
|
+
const verdicts = state.comments.map(latestVerdict);
|
|
324
|
+
const reviseCount = verdicts.filter((v) => v === "revise").length;
|
|
325
|
+
const total = verdicts.length;
|
|
326
|
+
const anyRevise = reviseCount > 0;
|
|
327
|
+
|
|
328
|
+
btnAccept.dataset.mode = anyRevise ? "accept" : "finish";
|
|
329
|
+
btnAccept.textContent = anyRevise ? "Revise document" : "Accept as-is";
|
|
330
|
+
|
|
331
|
+
if (banner && !errorShowing) {
|
|
332
|
+
banner.classList.remove("revising");
|
|
333
|
+
let bannerText: string;
|
|
334
|
+
if (reviseCount === 0) {
|
|
335
|
+
bannerText =
|
|
336
|
+
total === 1
|
|
337
|
+
? "Comment answered in thread \u2014 no revision needed."
|
|
338
|
+
: `All ${total} comments answered in thread \u2014 no revision needed.`;
|
|
339
|
+
} else if (reviseCount === total) {
|
|
340
|
+
bannerText =
|
|
341
|
+
total === 1
|
|
342
|
+
? "Comment implies an edit \u2014 ready to revise."
|
|
343
|
+
: `All ${total} comments imply edits \u2014 ready to revise.`;
|
|
344
|
+
} else {
|
|
345
|
+
bannerText = `${reviseCount} of ${total} comments imply edits.`;
|
|
346
|
+
}
|
|
347
|
+
banner.textContent = bannerText;
|
|
348
|
+
banner.style.display = "block";
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Manual mode skips the secondary action: there's no agent to run the
|
|
352
|
+
// revision pass, so "revise the document anyway" would dead-end, and
|
|
353
|
+
// "accept as-is without revising" can't appear because every comment
|
|
354
|
+
// is treated as accept by the verdict-fallback path.
|
|
355
|
+
const sidebar = state.noAgent ? null : document.querySelector(".sidebar-col");
|
|
356
|
+
if (sidebar) {
|
|
357
|
+
const sec = document.createElement("div");
|
|
358
|
+
sec.id = "round-secondary";
|
|
359
|
+
sec.className = "round-secondary";
|
|
360
|
+
const altLabel = anyRevise ? "accept as-is without revising" : "revise the document anyway";
|
|
361
|
+
sec.innerHTML = `or <button id="round-secondary-btn">${altLabel}</button>`;
|
|
362
|
+
banner?.insertAdjacentElement("afterend", sec) ?? sidebar.appendChild(sec);
|
|
363
|
+
sec.querySelector("#round-secondary-btn")!.addEventListener("click", () => {
|
|
364
|
+
if (anyRevise) {
|
|
365
|
+
const msg =
|
|
366
|
+
reviseCount === 1
|
|
367
|
+
? "1 comment suggested an edit. Accept anyway?"
|
|
368
|
+
: `${reviseCount} comments suggested edits. Accept anyway?`;
|
|
369
|
+
if (!confirm(msg)) return;
|
|
370
|
+
triggerRoundAction("finish");
|
|
371
|
+
} else {
|
|
372
|
+
triggerRoundAction("accept");
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export async function triggerRoundAction(mode: string): Promise<void> {
|
|
381
|
+
const btnAccept = document.getElementById("btn-accept") as HTMLButtonElement | null;
|
|
382
|
+
const banner = document.getElementById("sidebar-status-banner");
|
|
383
|
+
if (banner) {
|
|
384
|
+
banner.classList.remove("error");
|
|
385
|
+
banner.style.display = "none";
|
|
386
|
+
banner.textContent = "";
|
|
387
|
+
}
|
|
388
|
+
const endpoint = mode === "finish" ? "/api/finish" : "/api/accept";
|
|
389
|
+
const res = await apiFetch(endpoint, { method: "POST" });
|
|
390
|
+
const data = await res.json();
|
|
391
|
+
if (data.ok) {
|
|
392
|
+
state.roundResolved = true;
|
|
393
|
+
document.getElementById("round-secondary")?.remove();
|
|
394
|
+
if (mode === "finish") {
|
|
395
|
+
if (btnAccept) {
|
|
396
|
+
btnAccept.disabled = true;
|
|
397
|
+
btnAccept.textContent = "\u2713 Done";
|
|
398
|
+
}
|
|
399
|
+
if (banner) {
|
|
400
|
+
banner.classList.remove("revising");
|
|
401
|
+
banner.classList.remove("error");
|
|
402
|
+
banner.textContent = "Review complete. Document is ready.";
|
|
403
|
+
banner.style.display = "block";
|
|
404
|
+
}
|
|
405
|
+
} else {
|
|
406
|
+
applyRoundState();
|
|
407
|
+
}
|
|
408
|
+
} else {
|
|
409
|
+
alert(data.error || "Could not complete.");
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export { toggleReplyForm, positionCards };
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import {
|
|
2
|
+
nearestCell,
|
|
3
|
+
clampRangeToCell,
|
|
4
|
+
captureSelection as _captureSelection,
|
|
5
|
+
} from "./lib";
|
|
6
|
+
import { state, showError } from "./state";
|
|
7
|
+
import { positionCards, observeCardSizes } from "./cards";
|
|
8
|
+
import {
|
|
9
|
+
saveComment,
|
|
10
|
+
applyHighlights,
|
|
11
|
+
applyRoundState,
|
|
12
|
+
focusComment,
|
|
13
|
+
updateNav,
|
|
14
|
+
renderComments,
|
|
15
|
+
} from "./render";
|
|
16
|
+
|
|
17
|
+
let selectionTimer: ReturnType<typeof setTimeout> | null = null;
|
|
18
|
+
|
|
19
|
+
function captureSelection(sel: Selection, text: string) {
|
|
20
|
+
return _captureSelection(document.getElementById("prose")!, sel, text);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isFormEmpty(): boolean {
|
|
24
|
+
const ta = document.querySelector("#new-comment-form textarea") as HTMLTextAreaElement | null;
|
|
25
|
+
return !ta || ta.value.trim() === "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function nudgeOpenForm(): void {
|
|
29
|
+
const form = document.getElementById("new-comment-form");
|
|
30
|
+
if (!form) return;
|
|
31
|
+
form.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
32
|
+
const ta = form.querySelector("textarea") as HTMLTextAreaElement | null;
|
|
33
|
+
if (ta) {
|
|
34
|
+
ta.focus();
|
|
35
|
+
ta.style.borderColor = "var(--accent)";
|
|
36
|
+
ta.style.boxShadow = "0 0 0 3px rgba(217,119,6,0.25)";
|
|
37
|
+
setTimeout(() => {
|
|
38
|
+
ta.style.boxShadow = "";
|
|
39
|
+
}, 600);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function showNewCommentForm(
|
|
44
|
+
selection: typeof state.pendingSelection,
|
|
45
|
+
formTop: number,
|
|
46
|
+
): void {
|
|
47
|
+
document.getElementById("new-comment-form")?.remove();
|
|
48
|
+
removePendingHighlight();
|
|
49
|
+
window.getSelection()?.removeAllRanges();
|
|
50
|
+
|
|
51
|
+
if (selection?._range) applyPendingHighlight(selection._range);
|
|
52
|
+
else if (selection?._img) applyPendingImgHighlight(selection._img);
|
|
53
|
+
|
|
54
|
+
const form = document.createElement("div");
|
|
55
|
+
form.id = "new-comment-form";
|
|
56
|
+
form.className = "new-comment-form";
|
|
57
|
+
form.style.top = Math.max(0, formTop) + "px";
|
|
58
|
+
|
|
59
|
+
const body = document.createElement("div");
|
|
60
|
+
body.className = "new-comment-body";
|
|
61
|
+
|
|
62
|
+
const textarea = document.createElement("textarea");
|
|
63
|
+
textarea.className = "reply-input";
|
|
64
|
+
textarea.placeholder = "Leave a comment\u2026";
|
|
65
|
+
body.appendChild(textarea);
|
|
66
|
+
|
|
67
|
+
const actions = document.createElement("div");
|
|
68
|
+
actions.className = "new-comment-actions";
|
|
69
|
+
|
|
70
|
+
const cancel = document.createElement("button");
|
|
71
|
+
cancel.className = "btn-cancel-inline";
|
|
72
|
+
cancel.textContent = "Cancel";
|
|
73
|
+
cancel.addEventListener("click", () => {
|
|
74
|
+
dismissNewCommentForm();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const save = document.createElement("button");
|
|
78
|
+
save.className = "reply-submit";
|
|
79
|
+
save.innerHTML = "Save <kbd>\u2318\u21B5</kbd>";
|
|
80
|
+
save.addEventListener("click", () => saveComment(form, textarea, selection!));
|
|
81
|
+
|
|
82
|
+
textarea.addEventListener("keydown", (e) => {
|
|
83
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) saveComment(form, textarea, selection!);
|
|
84
|
+
if (e.key === "Escape") {
|
|
85
|
+
dismissNewCommentForm();
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
actions.appendChild(cancel);
|
|
90
|
+
actions.appendChild(save);
|
|
91
|
+
body.appendChild(actions);
|
|
92
|
+
form.appendChild(body);
|
|
93
|
+
|
|
94
|
+
const sidebar = document.querySelector(".sidebar-col")!;
|
|
95
|
+
sidebar.appendChild(form);
|
|
96
|
+
textarea.focus();
|
|
97
|
+
positionCards();
|
|
98
|
+
observeCardSizes();
|
|
99
|
+
|
|
100
|
+
requestAnimationFrame(() => {
|
|
101
|
+
const sidebarHeight = sidebar.getBoundingClientRect().height;
|
|
102
|
+
const formHeight = form.offsetHeight;
|
|
103
|
+
const desiredTop = parseFloat(form.style.top) || 0;
|
|
104
|
+
const maxTop = Math.max(0, sidebarHeight - formHeight - 8);
|
|
105
|
+
if (desiredTop > maxTop) form.style.top = maxTop + "px";
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function dismissNewCommentForm(): void {
|
|
110
|
+
const form = document.getElementById("new-comment-form");
|
|
111
|
+
if (form) form.remove();
|
|
112
|
+
removePendingHighlight();
|
|
113
|
+
state.pendingSelection = null;
|
|
114
|
+
positionCards();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function applyPendingHighlight(range: Range): void {
|
|
118
|
+
const ancestor = range.commonAncestorContainer;
|
|
119
|
+
const root = ancestor.nodeType === Node.TEXT_NODE ? ancestor.parentNode! : ancestor;
|
|
120
|
+
|
|
121
|
+
const textNodes: Text[] = [];
|
|
122
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
|
123
|
+
let node: Node | null;
|
|
124
|
+
while ((node = walker.nextNode())) {
|
|
125
|
+
if (range.intersectsNode(node)) textNodes.push(node as Text);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
for (const tn of textNodes) {
|
|
129
|
+
const start = tn === range.startContainer ? range.startOffset : 0;
|
|
130
|
+
const end = tn === range.endContainer ? range.endOffset : tn.nodeValue!.length;
|
|
131
|
+
if (start >= end) continue;
|
|
132
|
+
|
|
133
|
+
const mark = document.createElement("mark");
|
|
134
|
+
mark.className = "rl-highlight rl-pending";
|
|
135
|
+
mark.dataset.commentId = "pending";
|
|
136
|
+
|
|
137
|
+
const mid = tn.splitText(start);
|
|
138
|
+
mid.splitText(end - start);
|
|
139
|
+
mid.parentNode!.insertBefore(mark, mid);
|
|
140
|
+
mark.appendChild(mid);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function applyPendingImgHighlight(img: HTMLImageElement): void {
|
|
145
|
+
const mark = document.createElement("mark");
|
|
146
|
+
mark.className = "rl-highlight rl-pending rl-img";
|
|
147
|
+
mark.dataset.commentId = "pending";
|
|
148
|
+
img.parentNode!.insertBefore(mark, img);
|
|
149
|
+
mark.appendChild(img);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function removePendingHighlight(): void {
|
|
153
|
+
const prose = document.getElementById("prose")!;
|
|
154
|
+
prose.querySelectorAll('[data-comment-id="pending"]').forEach((m) => {
|
|
155
|
+
const parent = m.parentNode!;
|
|
156
|
+
while (m.firstChild) parent.insertBefore(m.firstChild, m);
|
|
157
|
+
parent.removeChild(m);
|
|
158
|
+
});
|
|
159
|
+
prose.normalize();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function initSelectionHandlers(): void {
|
|
163
|
+
// Text selection -> comment form (debounced)
|
|
164
|
+
document.addEventListener("mouseup", () => {
|
|
165
|
+
if (selectionTimer) {
|
|
166
|
+
clearTimeout(selectionTimer);
|
|
167
|
+
selectionTimer = null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
selectionTimer = setTimeout(() => {
|
|
171
|
+
selectionTimer = null;
|
|
172
|
+
if (document.getElementById("new-comment-form")) {
|
|
173
|
+
if ((window.getSelection()?.toString().trim().length ?? 0) >= 2) nudgeOpenForm();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const sel = window.getSelection();
|
|
178
|
+
if (!sel || sel.isCollapsed) return;
|
|
179
|
+
const prose = document.getElementById("prose");
|
|
180
|
+
if (!prose?.contains(sel.anchorNode)) return;
|
|
181
|
+
|
|
182
|
+
let range = sel.getRangeAt(0);
|
|
183
|
+
|
|
184
|
+
const startCell = nearestCell(range.startContainer);
|
|
185
|
+
const endCell = nearestCell(range.endContainer);
|
|
186
|
+
if ((startCell || endCell) && startCell !== endCell) {
|
|
187
|
+
const clamped = clampRangeToCell(range, startCell, endCell);
|
|
188
|
+
if (clamped) {
|
|
189
|
+
range = clamped;
|
|
190
|
+
sel.removeAllRanges();
|
|
191
|
+
sel.addRange(range);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const text = sel.toString().trim();
|
|
196
|
+
if (!text || text.length < 2) return;
|
|
197
|
+
|
|
198
|
+
const captured = captureSelection(sel, text);
|
|
199
|
+
if (!captured) {
|
|
200
|
+
showError("Highlight a single passage \u2014 selections that cross images or sections can't be anchored.");
|
|
201
|
+
sel.removeAllRanges();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const rect = range.getBoundingClientRect();
|
|
206
|
+
state.pendingSelection = {
|
|
207
|
+
...captured,
|
|
208
|
+
_rectTop: rect.top,
|
|
209
|
+
_range: range.cloneRange(),
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const sidebarRect = document.querySelector(".sidebar-col")!.getBoundingClientRect();
|
|
213
|
+
showNewCommentForm(state.pendingSelection, rect.top - sidebarRect.top);
|
|
214
|
+
}, 250);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Image click -> comment form
|
|
218
|
+
document.addEventListener(
|
|
219
|
+
"click",
|
|
220
|
+
(e) => {
|
|
221
|
+
if ((e.target as HTMLElement).tagName !== "IMG") return;
|
|
222
|
+
const prose = document.getElementById("prose");
|
|
223
|
+
if (!prose?.contains(e.target as Node)) return;
|
|
224
|
+
if (document.getElementById("new-comment-form")) {
|
|
225
|
+
e.preventDefault();
|
|
226
|
+
nudgeOpenForm();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
e.preventDefault();
|
|
230
|
+
const img = e.target as HTMLImageElement;
|
|
231
|
+
const alt = img.alt || "";
|
|
232
|
+
const quote = "[image: " + alt + "]";
|
|
233
|
+
|
|
234
|
+
const rect = img.getBoundingClientRect();
|
|
235
|
+
const sidebarRect = document.querySelector(".sidebar-col")!.getBoundingClientRect();
|
|
236
|
+
state.pendingSelection = { quote, context_before: "", context_after: "", _rectTop: rect.top, _img: img };
|
|
237
|
+
showNewCommentForm(state.pendingSelection, rect.top - sidebarRect.top);
|
|
238
|
+
},
|
|
239
|
+
true,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// Close empty draft on outside click
|
|
243
|
+
document.addEventListener("mousedown", (e) => {
|
|
244
|
+
if (selectionTimer) {
|
|
245
|
+
clearTimeout(selectionTimer);
|
|
246
|
+
selectionTimer = null;
|
|
247
|
+
}
|
|
248
|
+
const form = document.getElementById("new-comment-form");
|
|
249
|
+
if (form && !form.contains(e.target as Node) && isFormEmpty()) {
|
|
250
|
+
dismissNewCommentForm();
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
}
|