@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.
@@ -0,0 +1,299 @@
1
+ // Pure-ish helpers extracted from main.js for direct test coverage.
2
+ // These functions take their DOM dependencies as arguments — they don't read
3
+ // from globals — so the same code runs in the browser bundle and in happy-dom
4
+ // tests without a separate harness.
5
+
6
+ export type ClientComment = {
7
+ id: string;
8
+ quote: string;
9
+ context_before?: string;
10
+ context_after?: string;
11
+ resolved: boolean;
12
+ thread: ThreadEntry[];
13
+ };
14
+
15
+ export type ThreadEntry = {
16
+ role?: "human" | "agent";
17
+ name?: string;
18
+ message: string;
19
+ requires_revision?: boolean;
20
+ revision_reason?: string;
21
+ };
22
+
23
+ export function escapeHtml(s: string): string {
24
+ return s
25
+ .replace(/&/g, "&")
26
+ .replace(/</g, "&lt;")
27
+ .replace(/>/g, "&gt;")
28
+ .replace(/"/g, "&quot;");
29
+ }
30
+
31
+ // Latest agent verdict on a comment thread. Mirrors latestVerdict() in sidecar.ts.
32
+ export function latestVerdict(comment: ClientComment): "revise" | "accept" | null {
33
+ const t = comment.thread || [];
34
+ for (let i = t.length - 1; i >= 0; i--) {
35
+ const e = t[i]!;
36
+ if (e.role !== "agent") continue;
37
+ if (typeof e.requires_revision !== "boolean") continue;
38
+ return e.requires_revision ? "revise" : "accept";
39
+ }
40
+ return null;
41
+ }
42
+
43
+ export function nearestCell(node: Node): HTMLElement | null {
44
+ const el = node.nodeType === Node.TEXT_NODE ? node.parentNode : node;
45
+ return el && (el as Element).closest ? ((el as Element).closest("td, th") as HTMLElement | null) : null;
46
+ }
47
+
48
+ // Clamp a Range so both endpoints land inside the same cell. Returns a new
49
+ // Range, or null if no meaningful clamp is possible. Called when the user
50
+ // dragged across a cell boundary by accident.
51
+ export function clampRangeToCell(
52
+ range: Range,
53
+ startCell: HTMLElement | null,
54
+ endCell: HTMLElement | null,
55
+ doc: Document = document,
56
+ ): Range | null {
57
+ const cell = startCell || endCell;
58
+ if (!cell) return null;
59
+ const newRange = doc.createRange();
60
+ try {
61
+ if (startCell === cell) {
62
+ newRange.setStart(range.startContainer, range.startOffset);
63
+ newRange.setEnd(cell, cell.childNodes.length);
64
+ } else {
65
+ newRange.setStart(cell, 0);
66
+ newRange.setEnd(range.endContainer, range.endOffset);
67
+ }
68
+ return newRange;
69
+ } catch {
70
+ try {
71
+ newRange.selectNodeContents(cell);
72
+ return newRange;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+ }
78
+
79
+ export type Captured = {
80
+ quote: string;
81
+ context_before: string;
82
+ context_after: string;
83
+ };
84
+
85
+ // Walk the prose container's text nodes, locate the selection's start, and
86
+ // return the quote with surrounding context. Returns null when the selection
87
+ // can't be relocated against flat text (e.g. crossed an <img>).
88
+ export function captureSelection(prose: Element, sel: Selection, text: string): Captured | null {
89
+ const range = sel.getRangeAt(0);
90
+
91
+ const walker = (prose.ownerDocument || document).createTreeWalker(prose, NodeFilter.SHOW_TEXT);
92
+ const segments: { node: Text; start: number }[] = [];
93
+ let flat = "";
94
+ let node: Node | null;
95
+ while ((node = walker.nextNode())) {
96
+ const tn = node as Text;
97
+ segments.push({ node: tn, start: flat.length });
98
+ flat += tn.nodeValue ?? "";
99
+ }
100
+
101
+ let quoteStart = -1;
102
+ for (const seg of segments) {
103
+ if (seg.node === range.startContainer) {
104
+ quoteStart = seg.start + range.startOffset;
105
+ break;
106
+ }
107
+ }
108
+
109
+ if (quoteStart === -1) {
110
+ return { quote: text, context_before: "", context_after: "" };
111
+ }
112
+
113
+ const slice = flat.slice(quoteStart, quoteStart + text.length);
114
+ if (slice !== text) {
115
+ // Trimming in the caller, or block-boundary newlines that sel.toString()
116
+ // inserts but our flat-text walker doesn't, can shift the true start a
117
+ // few chars away from range.startOffset. Search a small window around
118
+ // quoteStart for the text before giving up.
119
+ const windowStart = Math.max(0, quoteStart - 64);
120
+ const windowEnd = Math.min(flat.length, quoteStart + text.length + 64);
121
+ const found = flat.indexOf(text, windowStart);
122
+ if (found === -1 || found >= windowEnd) return null;
123
+ quoteStart = found;
124
+ }
125
+
126
+ return {
127
+ quote: text,
128
+ context_before: flat.slice(Math.max(0, quoteStart - 32), quoteStart),
129
+ context_after: flat.slice(quoteStart + text.length, quoteStart + text.length + 32),
130
+ };
131
+ }
132
+
133
+ // Wrap occurrences of `text` inside `container` with <mark> elements. Uses
134
+ // `contextBefore` to disambiguate when a quote appears multiple times.
135
+ // Image quotes (`[image: alt]`) wrap the matching <img> instead.
136
+ // Returns the marks created (caller can attach event listeners).
137
+ export function highlightText(
138
+ container: Element,
139
+ text: string,
140
+ id: string,
141
+ resolved: boolean,
142
+ contextBefore: string,
143
+ ): HTMLElement[] {
144
+ const doc = container.ownerDocument || document;
145
+ const marks: HTMLElement[] = [];
146
+
147
+ const imgMatch = text.match(/^\[image:\s*(.*)\]$/);
148
+ if (imgMatch) {
149
+ const alt = imgMatch[1];
150
+ const imgs = container.querySelectorAll("img");
151
+ for (const img of imgs) {
152
+ if ((img.alt || "") === alt) {
153
+ const mark = doc.createElement("mark");
154
+ mark.className = "rl-highlight rl-img" + (resolved ? " resolved" : "");
155
+ mark.dataset.commentId = id;
156
+ img.parentNode!.insertBefore(mark, img);
157
+ mark.appendChild(img);
158
+ marks.push(mark);
159
+ return marks;
160
+ }
161
+ }
162
+ return marks;
163
+ }
164
+
165
+ (container as HTMLElement).normalize();
166
+
167
+ const walker = doc.createTreeWalker(container, NodeFilter.SHOW_TEXT);
168
+ const segments: { node: Text; start: number }[] = [];
169
+ let flat = "";
170
+ let node: Node | null;
171
+ while ((node = walker.nextNode())) {
172
+ const tn = node as Text;
173
+ segments.push({ node: tn, start: flat.length });
174
+ flat += tn.nodeValue ?? "";
175
+ }
176
+
177
+ let quoteStart = -1;
178
+ if (contextBefore) {
179
+ const ctxIdx = flat.indexOf(contextBefore + text);
180
+ if (ctxIdx !== -1) quoteStart = ctxIdx + contextBefore.length;
181
+ }
182
+ if (quoteStart === -1) quoteStart = flat.indexOf(text);
183
+ if (quoteStart === -1) return marks;
184
+
185
+ const quoteEnd = quoteStart + text.length;
186
+
187
+ const toWrap: { node: Text; localStart: number; localEnd: number }[] = [];
188
+ for (const seg of segments) {
189
+ const segEnd = seg.start + (seg.node.nodeValue?.length ?? 0);
190
+ if (segEnd <= quoteStart || seg.start >= quoteEnd) continue;
191
+ toWrap.push({
192
+ node: seg.node,
193
+ localStart: Math.max(0, quoteStart - seg.start),
194
+ localEnd: Math.min(seg.node.nodeValue?.length ?? 0, quoteEnd - seg.start),
195
+ });
196
+ }
197
+
198
+ for (const { node: tn, localStart, localEnd } of toWrap) {
199
+ const mark = doc.createElement("mark");
200
+ mark.className = "rl-highlight" + (resolved ? " resolved" : "");
201
+ mark.dataset.commentId = id;
202
+ const mid = tn.splitText(localStart);
203
+ mid.splitText(localEnd - localStart);
204
+ mid.parentNode!.insertBefore(mark, mid);
205
+ mark.appendChild(mid);
206
+ marks.push(mark);
207
+ }
208
+ return marks;
209
+ }
210
+
211
+ export type NavState = {
212
+ visible: boolean;
213
+ countText: string;
214
+ navIdx: number;
215
+ showPrev: boolean;
216
+ prevDisabled: boolean;
217
+ nextLabel: string;
218
+ nextDisabled: boolean;
219
+ };
220
+
221
+ // Pure state computation for the prev/next nav above the prose. Given the
222
+ // comments, the active card's id (if any), and the previous navIdx, return
223
+ // what the buttons should display. Mirrors the body of updateNav() in main.js.
224
+ export function computeNavState(
225
+ comments: ClientComment[],
226
+ activeId: string | null,
227
+ prevNavIdx: number,
228
+ ): NavState {
229
+ const open = comments.filter((c) => !c.resolved);
230
+ if (open.length === 0) {
231
+ return {
232
+ visible: false,
233
+ countText: "",
234
+ navIdx: prevNavIdx,
235
+ showPrev: false,
236
+ prevDisabled: true,
237
+ nextLabel: "",
238
+ nextDisabled: true,
239
+ };
240
+ }
241
+ const matchIdx = activeId ? open.findIndex((c) => c.id === activeId) : -1;
242
+ const navIdx = matchIdx >= 0 ? matchIdx : Math.min(prevNavIdx, open.length - 1);
243
+ if (open.length === 1) {
244
+ return {
245
+ visible: true,
246
+ countText: "1 / 1",
247
+ navIdx,
248
+ showPrev: false,
249
+ prevDisabled: true,
250
+ nextLabel: "Jump to comment ↓",
251
+ nextDisabled: false,
252
+ };
253
+ }
254
+ return {
255
+ visible: true,
256
+ countText: navIdx + 1 + " / " + open.length,
257
+ navIdx,
258
+ showPrev: true,
259
+ prevDisabled: navIdx === 0,
260
+ nextLabel: "Next ↓",
261
+ nextDisabled: navIdx === open.length - 1,
262
+ };
263
+ }
264
+
265
+ // Pin `window.scrollY` across a DOM mutation that may shift focus. Blurs the
266
+ // active element first (focus-loss scrolls fire a frame later), runs `fn`,
267
+ // then restores scroll synchronously and again across two rAF callbacks. The
268
+ // triple restore is not paranoia — focus-related scroll-into-view lands one
269
+ // frame after the call returns. `protectFocusSelector` opt-out is for the
270
+ // new-comment-form case where blurring would eat keystrokes.
271
+ export function preserveScroll(
272
+ fn: () => void,
273
+ opts: {
274
+ win?: Window;
275
+ doc?: Document;
276
+ protectFocusSelector?: string;
277
+ skip?: boolean;
278
+ } = {},
279
+ ): void {
280
+ const win = opts.win ?? window;
281
+ const doc = opts.doc ?? win.document;
282
+ if (opts.skip) {
283
+ fn();
284
+ return;
285
+ }
286
+ const top = win.scrollY;
287
+ const active = doc.activeElement as HTMLElement | null;
288
+ const protect =
289
+ opts.protectFocusSelector && active && active.closest && active.closest(opts.protectFocusSelector);
290
+ if (!protect && active && active !== doc.body && typeof active.blur === "function") active.blur();
291
+ fn();
292
+ doc.documentElement.scrollTop = top;
293
+ win.requestAnimationFrame(() => {
294
+ doc.documentElement.scrollTop = top;
295
+ win.requestAnimationFrame(() => {
296
+ doc.documentElement.scrollTop = top;
297
+ });
298
+ });
299
+ }
@@ -0,0 +1,119 @@
1
+ import { setCardCallbacks } from "./cards";
2
+ import {
3
+ renderComments,
4
+ applyHighlights,
5
+ applyRoundState,
6
+ focusComment,
7
+ clearFocus,
8
+ updateNav,
9
+ navigateTo,
10
+ positionCards,
11
+ resolveComment,
12
+ reopenComment,
13
+ submitReply,
14
+ toggleReplyForm,
15
+ } from "./render";
16
+ import { state } from "./state";
17
+ import { initSelectionHandlers } from "./selection";
18
+ import { initSSE } from "./sse";
19
+ import { showRevisionBanner, initDiffHandlers } from "./diff";
20
+ import { observeCardSizes } from "./cards";
21
+
22
+ // Wire card callbacks (breaks circular dependency between cards and render)
23
+ setCardCallbacks({
24
+ focusComment,
25
+ updateNav,
26
+ positionCards,
27
+ resolveComment,
28
+ reopenComment,
29
+ toggleReplyForm,
30
+ submitReply,
31
+ });
32
+
33
+ // Click-to-clear-focus
34
+ document.addEventListener("click", (e) => {
35
+ const inCard = (e.target as HTMLElement).closest(".comment-card, .new-comment-form");
36
+ const inMark = (e.target as HTMLElement).closest("mark.rl-highlight");
37
+ const inNav = (e.target as HTMLElement).closest("#comment-nav");
38
+ if (!inCard && !inMark && !inNav) clearFocus();
39
+ });
40
+
41
+ // Nav buttons
42
+ document.getElementById("nav-prev")!.addEventListener("click", () => {
43
+ const open = state.comments.filter((c) => !c.resolved);
44
+ if (state.navIdx > 0) {
45
+ state.navIdx--;
46
+ navigateTo(open[state.navIdx]!.id);
47
+ updateNav();
48
+ }
49
+ });
50
+ document.getElementById("nav-next")!.addEventListener("click", () => {
51
+ const open = state.comments.filter((c) => !c.resolved);
52
+ if (open.length === 1) {
53
+ navigateTo(open[0]!.id);
54
+ } else if (state.navIdx < open.length - 1) {
55
+ state.navIdx++;
56
+ navigateTo(open[state.navIdx]!.id);
57
+ updateNav();
58
+ }
59
+ });
60
+
61
+ // Broken images
62
+ function swapBrokenImg(img: HTMLImageElement): void {
63
+ const placeholder = document.createElement("div");
64
+ placeholder.className = "broken-img";
65
+ placeholder.textContent = "Image failed to load" + (img.alt ? ": " + img.alt : "");
66
+ img.replaceWith(placeholder);
67
+ }
68
+ document.querySelectorAll("#prose img").forEach((img) => {
69
+ const htmlImg = img as HTMLImageElement;
70
+ htmlImg.addEventListener("error", () => swapBrokenImg(htmlImg));
71
+ if (htmlImg.complete && htmlImg.naturalWidth === 0) swapBrokenImg(htmlImg);
72
+ });
73
+
74
+ // Syntax highlighting
75
+ if (window.hljs) {
76
+ document.querySelectorAll('#prose pre code[class*="language-"]').forEach((el) => {
77
+ try {
78
+ window.hljs!.highlightElement(el as HTMLElement);
79
+ } catch {
80
+ /* unknown language */
81
+ }
82
+ });
83
+ }
84
+
85
+ // Initial render
86
+ renderComments();
87
+ applyHighlights();
88
+ positionCards();
89
+ updateNav();
90
+ applyRoundState();
91
+
92
+ if (sessionStorage.getItem("just-revised")) {
93
+ sessionStorage.removeItem("just-revised");
94
+ showRevisionBanner();
95
+ }
96
+ if (sessionStorage.getItem("rl-no-changes")) {
97
+ sessionStorage.removeItem("rl-no-changes");
98
+ const banner = document.getElementById("sidebar-status-banner");
99
+ if (banner) {
100
+ banner.classList.remove("revising");
101
+ banner.classList.remove("error");
102
+ banner.textContent = "No changes \u2014 the document is unchanged.";
103
+ banner.style.display = "block";
104
+ setTimeout(() => {
105
+ banner.style.display = "none";
106
+ }, 5000);
107
+ }
108
+ }
109
+
110
+ window.addEventListener("scroll", positionCards, { passive: true });
111
+ window.addEventListener("resize", positionCards, { passive: true });
112
+
113
+ import { initFirstRunBanner } from "./firstRunBanner";
114
+ initFirstRunBanner(document, window);
115
+
116
+ // Init subsystems
117
+ initSelectionHandlers();
118
+ initDiffHandlers();
119
+ initSSE();