@mrsf/markdown-it-mrsf 0.2.0 → 0.4.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.
@@ -1,22 +1,859 @@
1
+ // src/html.ts
2
+ function escapeHtml(str) {
3
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4
+ }
5
+ function formatTime(iso) {
6
+ if (!iso) return "";
7
+ try {
8
+ const d = new Date(iso);
9
+ return d.toLocaleDateString(void 0, {
10
+ year: "numeric",
11
+ month: "short",
12
+ day: "numeric"
13
+ });
14
+ } catch {
15
+ return "";
16
+ }
17
+ }
18
+ function renderCommentHtml(comment, isReply, interactive) {
19
+ const resolvedClass = comment.resolved ? " mrsf-resolved" : "";
20
+ const replyClass = isReply ? " mrsf-reply" : "";
21
+ let html = `<span class="mrsf-comment${resolvedClass}${replyClass}" data-mrsf-comment-id="${escapeHtml(comment.id)}">`;
22
+ html += `<span class="mrsf-comment-header">`;
23
+ html += `<span class="mrsf-author">${escapeHtml(comment.author)}</span>`;
24
+ if (comment.timestamp) {
25
+ html += `<span class="mrsf-date">${escapeHtml(formatTime(comment.timestamp))}</span>`;
26
+ }
27
+ if (comment.severity) {
28
+ html += `<span class="mrsf-severity mrsf-severity-${escapeHtml(comment.severity)}">${escapeHtml(comment.severity)}</span>`;
29
+ }
30
+ if (comment.type) {
31
+ html += `<span class="mrsf-type">${escapeHtml(comment.type)}</span>`;
32
+ }
33
+ if (comment.resolved) {
34
+ html += `<span class="mrsf-resolved-badge">\u2713 resolved</span>`;
35
+ }
36
+ html += `</span>`;
37
+ if (comment.selected_text) {
38
+ html += `<details class="mrsf-selected-text"><summary class="mrsf-selected-text-summary">${escapeHtml(comment.selected_text)}</summary><span class="mrsf-selected-text-full">${escapeHtml(comment.selected_text)}</span></details>`;
39
+ }
40
+ html += `<span class="mrsf-comment-body">${escapeHtml(comment.text)}</span>`;
41
+ if (interactive) {
42
+ const line = comment.line != null ? String(comment.line) : "";
43
+ html += `<span class="mrsf-actions">`;
44
+ if (comment.resolved) {
45
+ html += `<button class="mrsf-action-btn" data-mrsf-action="unresolve" data-mrsf-comment-id="${escapeHtml(comment.id)}" data-mrsf-line="${line}">Unresolve</button>`;
46
+ } else {
47
+ html += `<button class="mrsf-action-btn" data-mrsf-action="resolve" data-mrsf-comment-id="${escapeHtml(comment.id)}" data-mrsf-line="${line}">Resolve</button>`;
48
+ }
49
+ html += `<button class="mrsf-action-btn" data-mrsf-action="reply" data-mrsf-comment-id="${escapeHtml(comment.id)}" data-mrsf-line="${line}">Reply</button>`;
50
+ html += `<button class="mrsf-action-btn" data-mrsf-action="edit" data-mrsf-comment-id="${escapeHtml(comment.id)}" data-mrsf-line="${line}">Edit</button>`;
51
+ html += `<button class="mrsf-action-btn mrsf-action-danger" data-mrsf-action="delete" data-mrsf-comment-id="${escapeHtml(comment.id)}" data-mrsf-line="${line}">Delete</button>`;
52
+ html += `</span>`;
53
+ }
54
+ html += `</span>`;
55
+ return html;
56
+ }
57
+ function renderThreadHtml(thread, interactive) {
58
+ let html = `<span class="mrsf-thread">`;
59
+ html += renderCommentHtml(thread.comment, false, interactive);
60
+ if (thread.replies.length > 0) {
61
+ html += `<span class="mrsf-replies">`;
62
+ for (const reply of thread.replies) {
63
+ html += renderCommentHtml(reply, true, interactive);
64
+ }
65
+ html += `</span>`;
66
+ }
67
+ html += `</span>`;
68
+ return html;
69
+ }
70
+
1
71
  // src/controller.ts
2
- function init() {
3
- document.addEventListener("click", (e) => {
4
- const target = e.target.closest(
5
- "[data-mrsf-action]"
6
- );
72
+ var MrsfController = class _MrsfController {
73
+ container;
74
+ opts;
75
+ threads = /* @__PURE__ */ new Map();
76
+ gutterLeft = null;
77
+ gutterRight = null;
78
+ activeTooltip = null;
79
+ floatingAddButton = null;
80
+ overlayEl = null;
81
+ lastSelectionText = null;
82
+ resizeObserver = null;
83
+ styleInjected = false;
84
+ inlineMarks = [];
85
+ inlineTooltipEl = null;
86
+ orphanedSection = null;
87
+ handleResizeBound = this.positionGutterItems.bind(this);
88
+ handleClickBound = this.handleClick.bind(this);
89
+ handleSelectionBound = this.handleSelectionChange.bind(this);
90
+ constructor(container, options = {}) {
91
+ this.container = container;
92
+ this.opts = {
93
+ gutterPosition: options.gutterPosition ?? "right",
94
+ interactive: options.interactive ?? false,
95
+ comments: options.comments ?? [],
96
+ inlineHighlights: options.inlineHighlights ?? true
97
+ };
98
+ this.loadCommentData();
99
+ this.setupOverlayStructure();
100
+ this.renderGutterItems();
101
+ this.positionGutterItems();
102
+ this.renderInlineHighlights();
103
+ this.renderOrphanedSection();
104
+ this.resizeObserver = new ResizeObserver(this.handleResizeBound);
105
+ this.resizeObserver.observe(this.container);
106
+ document.addEventListener("click", this.handleClickBound);
107
+ if (this.opts.interactive) {
108
+ document.addEventListener("selectionchange", this.handleSelectionBound);
109
+ }
110
+ }
111
+ /** Remove all controller DOM and listeners. */
112
+ destroy() {
113
+ this.resizeObserver?.disconnect();
114
+ document.removeEventListener("click", this.handleClickBound);
115
+ document.removeEventListener("selectionchange", this.handleSelectionBound);
116
+ this.removeInlineHighlights();
117
+ this.orphanedSection?.remove();
118
+ this.gutterLeft?.remove();
119
+ this.gutterRight?.remove();
120
+ this.floatingAddButton?.remove();
121
+ this.closeOverlay();
122
+ this.container.classList.remove("mrsf-overlay-root");
123
+ }
124
+ // ── Data loading ────────────────────────────────────────
125
+ loadCommentData() {
126
+ if (this.opts.comments.length > 0) {
127
+ this.buildThreadMap(this.opts.comments);
128
+ return;
129
+ }
130
+ const script = this.container.querySelector('script[type="application/mrsf+json"]');
131
+ if (!script?.textContent) return;
132
+ try {
133
+ const data = JSON.parse(script.textContent);
134
+ if (data.threads) {
135
+ this.buildThreadMap(data.threads);
136
+ }
137
+ } catch {
138
+ }
139
+ }
140
+ buildThreadMap(threads) {
141
+ this.threads.clear();
142
+ for (const t of threads) {
143
+ const line = t.comment.line;
144
+ if (line == null) continue;
145
+ const arr = this.threads.get(line) ?? [];
146
+ arr.push(t);
147
+ this.threads.set(line, arr);
148
+ }
149
+ }
150
+ // ── Overlay structure ───────────────────────────────────
151
+ setupOverlayStructure() {
152
+ this.container.classList.add("mrsf-overlay-root");
153
+ const pos = this.opts.gutterPosition;
154
+ if (pos === "left") {
155
+ this.gutterLeft = this.createGutter("mrsf-gutter-left");
156
+ } else {
157
+ this.gutterRight = this.createGutter("mrsf-gutter-right");
158
+ }
159
+ }
160
+ createGutter(cls) {
161
+ const gutter = document.createElement("div");
162
+ gutter.className = `mrsf-gutter ${cls}`;
163
+ this.container.appendChild(gutter);
164
+ return gutter;
165
+ }
166
+ // ── Gutter rendering ───────────────────────────────────
167
+ /** Build badge/add-button elements for each line in the gutter(s). */
168
+ renderGutterItems() {
169
+ const lines = this.collectLines();
170
+ const gutter = this.primaryGutter();
171
+ if (!gutter) return;
172
+ for (const line of lines) {
173
+ const threads = this.threads.get(line);
174
+ if (threads && threads.length > 0) {
175
+ const item = this.createBadgeItem(line, threads);
176
+ gutter.appendChild(item);
177
+ } else if (this.opts.interactive) {
178
+ const item = this.createAddItem(line);
179
+ gutter.appendChild(item);
180
+ }
181
+ }
182
+ }
183
+ primaryGutter() {
184
+ return this.gutterLeft ?? this.gutterRight;
185
+ }
186
+ /** Collect all unique line numbers from data-mrsf-line elements, expanding multi-line ranges. */
187
+ collectLines() {
188
+ const els = this.container.querySelectorAll("[data-mrsf-line]");
189
+ const seen = /* @__PURE__ */ new Set();
190
+ for (const el of els) {
191
+ if (el.tagName === "SCRIPT") continue;
192
+ const line = parseInt(el.dataset.mrsfLine, 10);
193
+ if (isNaN(line)) continue;
194
+ const startLine = parseInt(el.dataset.mrsfStartLine ?? "", 10);
195
+ const endLine = parseInt(el.dataset.mrsfEndLine ?? "", 10);
196
+ if (!isNaN(startLine) && !isNaN(endLine) && endLine > startLine) {
197
+ for (let l = startLine; l <= endLine; l++) seen.add(l);
198
+ } else {
199
+ seen.add(line);
200
+ }
201
+ }
202
+ return [...seen].sort((a, b) => a - b);
203
+ }
204
+ createBadgeItem(line, threads) {
205
+ const item = document.createElement("div");
206
+ item.className = "mrsf-gutter-item";
207
+ item.dataset.mrsfGutterLine = String(line);
208
+ const total = threads.reduce((n, t) => n + 1 + t.replies.length, 0);
209
+ const allResolved = threads.every((t) => t.comment.resolved);
210
+ const highestSeverity = threads.reduce((sev, t) => {
211
+ if (t.comment.severity === "high" || sev === "high") return "high";
212
+ if (t.comment.severity === "medium" || sev === "medium") return "medium";
213
+ if (t.comment.severity === "low" || sev === "low") return "low";
214
+ return sev;
215
+ }, null);
216
+ const classes = ["mrsf-badge"];
217
+ if (allResolved) classes.push("mrsf-badge-resolved");
218
+ if (highestSeverity === "high" || highestSeverity === "medium") {
219
+ classes.push(`mrsf-badge-severity-${highestSeverity}`);
220
+ }
221
+ const icon = allResolved ? "\u2713" : "\u{1F4AC}";
222
+ const badge = document.createElement("span");
223
+ badge.className = classes.join(" ");
224
+ badge.dataset.mrsfLine = String(line);
225
+ badge.dataset.mrsfAction = "navigate";
226
+ badge.dataset.mrsfCommentId = threads[0].comment.id;
227
+ badge.tabIndex = 0;
228
+ badge.textContent = `${icon} ${total}`;
229
+ badge.addEventListener("click", (e) => {
230
+ e.stopPropagation();
231
+ this.toggleTooltip(item, line, threads);
232
+ });
233
+ item.appendChild(badge);
234
+ if (this.opts.interactive) {
235
+ const addBtn = this.createAddButton(line);
236
+ item.appendChild(addBtn);
237
+ }
238
+ return item;
239
+ }
240
+ createAddItem(line) {
241
+ const item = document.createElement("div");
242
+ item.className = "mrsf-gutter-item";
243
+ item.dataset.mrsfGutterLine = String(line);
244
+ const addBtn = this.createAddButton(line);
245
+ item.appendChild(addBtn);
246
+ return item;
247
+ }
248
+ createAddButton(line) {
249
+ const btn = document.createElement("button");
250
+ btn.className = "mrsf-gutter-add";
251
+ btn.dataset.mrsfAction = "add";
252
+ btn.dataset.mrsfLine = String(line);
253
+ btn.dataset.mrsfStartLine = String(line);
254
+ btn.dataset.mrsfEndLine = String(line);
255
+ btn.setAttribute("aria-label", "Add comment");
256
+ btn.textContent = "+";
257
+ return btn;
258
+ }
259
+ // ── Positioning ─────────────────────────────────────────
260
+ /** Measure [data-mrsf-line] elements and set gutter item Y offsets. */
261
+ positionGutterItems() {
262
+ const containerRect = this.container.getBoundingClientRect();
263
+ const gutters = [this.gutterLeft, this.gutterRight].filter(Boolean);
264
+ for (const gutter of gutters) {
265
+ const items = gutter.querySelectorAll(".mrsf-gutter-item");
266
+ for (const item of items) {
267
+ const line = parseInt(item.dataset.mrsfGutterLine, 10);
268
+ let target = this.container.querySelector(
269
+ `[data-mrsf-line="${line}"]:not(script):not(.mrsf-gutter-item)`
270
+ );
271
+ if (!target) {
272
+ target = this.findElementForLine(line);
273
+ }
274
+ if (!target) {
275
+ item.style.display = "none";
276
+ continue;
277
+ }
278
+ const targetRect = target.getBoundingClientRect();
279
+ const top = targetRect.top - containerRect.top;
280
+ item.style.top = `${top}px`;
281
+ item.style.display = "";
282
+ }
283
+ }
284
+ }
285
+ /**
286
+ * Find the element whose start-line/end-line range contains the given line.
287
+ * Used for positioning gutter items on expanded multi-line elements.
288
+ */
289
+ findElementForLine(line) {
290
+ const els = this.container.querySelectorAll("[data-mrsf-start-line][data-mrsf-end-line]");
291
+ for (const el of els) {
292
+ if (el.tagName === "SCRIPT") continue;
293
+ const start = parseInt(el.dataset.mrsfStartLine, 10);
294
+ const end = parseInt(el.dataset.mrsfEndLine, 10);
295
+ if (!isNaN(start) && !isNaN(end) && line >= start && line <= end) {
296
+ return el;
297
+ }
298
+ }
299
+ return null;
300
+ }
301
+ // ── Orphaned comments section ────────────────────────────
302
+ /**
303
+ * Render orphaned comments (whose line doesn't match any DOM element)
304
+ * in a dedicated section at the bottom of the container.
305
+ */
306
+ renderOrphanedSection() {
307
+ const lines = this.collectLines();
308
+ const lineSet = new Set(lines);
309
+ const orphanedThreads = [];
310
+ for (const [line, threads] of this.threads) {
311
+ if (!lineSet.has(line)) {
312
+ orphanedThreads.push(...threads);
313
+ }
314
+ }
315
+ if (orphanedThreads.length === 0) return;
316
+ const section = document.createElement("div");
317
+ section.className = "mrsf-orphaned-section";
318
+ const heading = document.createElement("div");
319
+ heading.className = "mrsf-orphaned-heading";
320
+ heading.textContent = `Orphaned Comments (${orphanedThreads.length})`;
321
+ section.appendChild(heading);
322
+ const interactive = this.opts.interactive;
323
+ for (const thread of orphanedThreads) {
324
+ const wrapper = document.createElement("div");
325
+ wrapper.className = interactive ? "mrsf-orphaned-thread mrsf-interactive" : "mrsf-orphaned-thread";
326
+ wrapper.innerHTML = renderThreadHtml(thread, interactive);
327
+ section.appendChild(wrapper);
328
+ }
329
+ this.container.appendChild(section);
330
+ this.orphanedSection = section;
331
+ }
332
+ // ── Inline text highlights ──────────────────────────────
333
+ /**
334
+ * For comments with `selected_text`, find the matching text in the DOM
335
+ * and wrap it in a `<mark class="mrsf-inline-highlight">` element with
336
+ * hover/click behaviour to show the comment tooltip.
337
+ */
338
+ renderInlineHighlights() {
339
+ if (!this.opts.inlineHighlights) return;
340
+ for (const [line, threads] of this.threads) {
341
+ for (const thread of threads) {
342
+ const comment = thread.comment;
343
+ if (!comment.selected_text) continue;
344
+ const el = this.container.querySelector(
345
+ `[data-mrsf-line="${line}"]:not(script):not(.mrsf-gutter):not(.mrsf-gutter-item)`
346
+ );
347
+ if (!el) continue;
348
+ this.wrapSelectedText(el, comment.selected_text, thread);
349
+ }
350
+ }
351
+ }
352
+ /**
353
+ * Strip common inline markdown syntax so `selected_text` from source
354
+ * can be matched against rendered text content.
355
+ */
356
+ static stripInlineMarkdown(text) {
357
+ let s = text;
358
+ s = s.replace(/`([^`]+)`/g, "$1");
359
+ s = s.replace(/\*\*(.+?)\*\*/g, "$1");
360
+ s = s.replace(/__(.+?)__/g, "$1");
361
+ s = s.replace(/\*(.+?)\*/g, "$1");
362
+ s = s.replace(/_(.+?)_/g, "$1");
363
+ s = s.replace(/~~(.+?)~~/g, "$1");
364
+ return s;
365
+ }
366
+ /**
367
+ * Walk text nodes inside `root` to find `text`, then wrap the matching
368
+ * range in a `<mark>` element. Falls back to markdown-stripped matching.
369
+ */
370
+ wrapSelectedText(root, selectedText, thread) {
371
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
372
+ let accumulated = "";
373
+ const textNodes = [];
374
+ let node;
375
+ while (node = walker.nextNode()) {
376
+ const start = accumulated.length;
377
+ accumulated += node.textContent || "";
378
+ textNodes.push({ node, start, end: accumulated.length });
379
+ }
380
+ let matchStart = accumulated.indexOf(selectedText);
381
+ let matchLen = selectedText.length;
382
+ if (matchStart === -1) {
383
+ const stripped = _MrsfController.stripInlineMarkdown(selectedText);
384
+ if (stripped !== selectedText) {
385
+ matchStart = accumulated.indexOf(stripped);
386
+ matchLen = stripped.length;
387
+ }
388
+ }
389
+ if (matchStart === -1) return;
390
+ const matchEnd = matchStart + matchLen;
391
+ const range = document.createRange();
392
+ let startSet = false;
393
+ for (const tn of textNodes) {
394
+ if (!startSet && tn.end > matchStart) {
395
+ range.setStart(tn.node, matchStart - tn.start);
396
+ startSet = true;
397
+ }
398
+ if (startSet && tn.end >= matchEnd) {
399
+ range.setEnd(tn.node, matchEnd - tn.start);
400
+ break;
401
+ }
402
+ }
403
+ if (!startSet) return;
404
+ const mark = document.createElement("mark");
405
+ mark.className = "mrsf-inline-highlight";
406
+ mark.dataset.mrsfCommentId = thread.comment.id;
407
+ mark.dataset.mrsfLine = String(thread.comment.line);
408
+ try {
409
+ range.surroundContents(mark);
410
+ } catch {
411
+ const fragment = range.extractContents();
412
+ mark.appendChild(fragment);
413
+ range.insertNode(mark);
414
+ }
415
+ this.inlineMarks.push(mark);
416
+ mark.addEventListener("mouseenter", () => {
417
+ this.showInlineTooltip(mark, thread);
418
+ });
419
+ mark.addEventListener("mouseleave", (e) => {
420
+ const related = e.relatedTarget;
421
+ if (related && this.inlineTooltipEl?.contains(related)) return;
422
+ this.scheduleHideInlineTooltip();
423
+ });
424
+ mark.addEventListener("click", (e) => {
425
+ e.stopPropagation();
426
+ if (this.inlineTooltipEl && this.inlineTooltipEl.dataset.mrsfForMark === thread.comment.id) {
427
+ this.hideInlineTooltip();
428
+ } else {
429
+ this.showInlineTooltip(mark, thread);
430
+ }
431
+ });
432
+ }
433
+ showInlineTooltip(mark, thread) {
434
+ this.hideInlineTooltip();
435
+ const tooltip = document.createElement("div");
436
+ tooltip.className = this.opts.interactive ? "mrsf-inline-tooltip mrsf-interactive mrsf-tooltip-visible" : "mrsf-inline-tooltip mrsf-tooltip-visible";
437
+ tooltip.dataset.mrsfForMark = thread.comment.id;
438
+ tooltip.innerHTML = renderThreadHtml(thread, this.opts.interactive);
439
+ tooltip.addEventListener("mouseenter", () => {
440
+ this.cancelHideInlineTooltip();
441
+ });
442
+ tooltip.addEventListener("mouseleave", () => {
443
+ this.hideInlineTooltip();
444
+ });
445
+ document.body.appendChild(tooltip);
446
+ this.inlineTooltipEl = tooltip;
447
+ const rect = mark.getBoundingClientRect();
448
+ const margin = 4;
449
+ const tooltipH = tooltip.offsetHeight;
450
+ if (rect.bottom + margin + tooltipH > window.innerHeight) {
451
+ tooltip.style.top = `${rect.top - tooltipH - margin}px`;
452
+ } else {
453
+ tooltip.style.top = `${rect.bottom + margin}px`;
454
+ }
455
+ tooltip.style.left = `${Math.max(0, rect.left)}px`;
456
+ }
457
+ hideInlineTimeout = null;
458
+ scheduleHideInlineTooltip() {
459
+ this.hideInlineTimeout = setTimeout(() => this.hideInlineTooltip(), 120);
460
+ }
461
+ cancelHideInlineTooltip() {
462
+ if (this.hideInlineTimeout) {
463
+ clearTimeout(this.hideInlineTimeout);
464
+ this.hideInlineTimeout = null;
465
+ }
466
+ }
467
+ hideInlineTooltip() {
468
+ this.cancelHideInlineTooltip();
469
+ if (this.inlineTooltipEl) {
470
+ this.inlineTooltipEl.remove();
471
+ this.inlineTooltipEl = null;
472
+ }
473
+ }
474
+ /** Remove all inline marks, unwrapping their contents back to text. */
475
+ removeInlineHighlights() {
476
+ this.hideInlineTooltip();
477
+ for (const mark of this.inlineMarks) {
478
+ const parent = mark.parentNode;
479
+ if (!parent) continue;
480
+ while (mark.firstChild) {
481
+ parent.insertBefore(mark.firstChild, mark);
482
+ }
483
+ parent.removeChild(mark);
484
+ }
485
+ this.inlineMarks = [];
486
+ }
487
+ // ── Tooltip ─────────────────────────────────────────────
488
+ toggleTooltip(anchor, line, threads) {
489
+ if (this.activeTooltip && this.activeTooltip.parentElement === anchor) {
490
+ this.hideTooltip();
491
+ return;
492
+ }
493
+ this.hideTooltip();
494
+ this.showTooltip(anchor, line, threads);
495
+ }
496
+ showTooltip(anchor, line, threads) {
497
+ const tooltip = document.createElement("div");
498
+ const interactive = this.opts.interactive;
499
+ tooltip.className = interactive ? "mrsf-tooltip mrsf-interactive mrsf-tooltip-visible" : "mrsf-tooltip mrsf-tooltip-visible";
500
+ tooltip.dataset.mrsfLine = String(line);
501
+ let html = "";
502
+ for (const thread of threads) {
503
+ html += renderThreadHtml(thread, interactive);
504
+ }
505
+ if (interactive) {
506
+ html += `<div class="mrsf-tooltip-actions"><button class="mrsf-action-btn" data-mrsf-action="add" data-mrsf-line="${line}" data-mrsf-start-line="${line}" data-mrsf-end-line="${line}">Add comment</button></div>`;
507
+ }
508
+ tooltip.innerHTML = html;
509
+ anchor.appendChild(tooltip);
510
+ this.activeTooltip = tooltip;
511
+ }
512
+ hideTooltip() {
513
+ if (this.activeTooltip) {
514
+ this.activeTooltip.remove();
515
+ this.activeTooltip = null;
516
+ }
517
+ }
518
+ // ── Click handling ──────────────────────────────────────
519
+ handleClick(e) {
520
+ const target = e.target.closest("[data-mrsf-action]");
521
+ if (!target && this.activeTooltip) {
522
+ const tooltipClick = e.target.closest(".mrsf-tooltip");
523
+ if (!tooltipClick) {
524
+ this.hideTooltip();
525
+ }
526
+ return;
527
+ }
7
528
  if (!target) return;
8
529
  const action = target.dataset.mrsfAction;
9
- const commentId = target.dataset.mrsfCommentId;
10
- if (!action || !commentId) return;
530
+ if (!action) return;
531
+ const commentId = target.dataset.mrsfCommentId ?? null;
11
532
  const lineStr = target.dataset.mrsfLine;
12
533
  const line = lineStr ? parseInt(lineStr, 10) : null;
534
+ const selectionText = target.dataset.mrsfSelection ?? this.lastSelectionText ?? null;
535
+ const startLineStr = target.dataset.mrsfStartLine;
536
+ const endLineStr = target.dataset.mrsfEndLine;
537
+ const startColStr = target.dataset.mrsfStartColumn;
538
+ const endColStr = target.dataset.mrsfEndColumn;
539
+ const startLine = startLineStr ? parseInt(startLineStr, 10) : line ?? null;
540
+ const endLine = endLineStr ? parseInt(endLineStr, 10) : line ?? null;
541
+ const startColumn = startColStr ? parseInt(startColStr, 10) : null;
542
+ const endColumn = endColStr ? parseInt(endColStr, 10) : null;
13
543
  e.preventDefault();
14
544
  e.stopPropagation();
15
- const detail = { commentId, line, action };
545
+ const detail = {
546
+ commentId,
547
+ line,
548
+ action,
549
+ selectionText,
550
+ start_line: startLine,
551
+ end_line: endLine,
552
+ start_column: startColumn,
553
+ end_column: endColumn
554
+ };
555
+ if (action === "add" || action === "edit" || action === "reply") {
556
+ if (action === "add") {
557
+ this.hideFloatingAddButton();
558
+ }
559
+ this.openForm(action, detail);
560
+ return;
561
+ }
562
+ if (action === "resolve" || action === "unresolve" || action === "delete") {
563
+ this.openConfirm(action, detail);
564
+ return;
565
+ }
16
566
  document.dispatchEvent(
17
567
  new CustomEvent(`mrsf:${action}`, { detail, bubbles: true })
18
568
  );
19
- });
569
+ }
570
+ // ── Selection handling ──────────────────────────────────
571
+ handleSelectionChange() {
572
+ const sel = document.getSelection();
573
+ if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
574
+ this.hideFloatingAddButton();
575
+ return;
576
+ }
577
+ const text = sel.toString().trim();
578
+ if (!text) {
579
+ this.hideFloatingAddButton();
580
+ return;
581
+ }
582
+ const range = sel.getRangeAt(0);
583
+ const rect = range.getBoundingClientRect();
584
+ if (!rect || rect.width === 0 && rect.height === 0) {
585
+ this.hideFloatingAddButton();
586
+ return;
587
+ }
588
+ const startAnchor = range.startContainer.parentElement?.closest(
589
+ "[data-mrsf-line]"
590
+ );
591
+ const endAnchor = range.endContainer.parentElement?.closest(
592
+ "[data-mrsf-line]"
593
+ );
594
+ const startLineStr = startAnchor?.dataset.mrsfStartLine ?? startAnchor?.dataset.mrsfLine;
595
+ const endLineStr = endAnchor?.dataset.mrsfEndLine ?? endAnchor?.dataset.mrsfLine ?? startLineStr;
596
+ const startLine = startLineStr ? parseInt(startLineStr, 10) : null;
597
+ const endLine = endLineStr ? parseInt(endLineStr, 10) : startLine;
598
+ const startColumn = range.startContainer.nodeType === Node.TEXT_NODE ? range.startOffset : null;
599
+ const endColumn = range.endContainer.nodeType === Node.TEXT_NODE ? range.endOffset : null;
600
+ this.showFloatingAddButton(startLine, endLine, startColumn, endColumn, rect, text);
601
+ }
602
+ ensureFloatingAddButton() {
603
+ if (this.floatingAddButton) return this.floatingAddButton;
604
+ const btn = document.createElement("button");
605
+ btn.textContent = "Add comment";
606
+ btn.className = "mrsf-add-inline-button";
607
+ btn.dataset.mrsfAction = "add";
608
+ btn.style.display = "none";
609
+ btn.style.position = "absolute";
610
+ btn.style.zIndex = "1200";
611
+ document.body.appendChild(btn);
612
+ this.floatingAddButton = btn;
613
+ return btn;
614
+ }
615
+ hideFloatingAddButton() {
616
+ if (!this.floatingAddButton) return;
617
+ this.floatingAddButton.style.display = "none";
618
+ this.floatingAddButton.dataset.mrsfLine = "";
619
+ this.floatingAddButton.dataset.mrsfStartLine = "";
620
+ this.floatingAddButton.dataset.mrsfEndLine = "";
621
+ this.lastSelectionText = null;
622
+ }
623
+ showFloatingAddButton(startLine, endLine, startColumn, endColumn, rect, selectionText) {
624
+ const btn = this.ensureFloatingAddButton();
625
+ if (startLine != null) {
626
+ btn.dataset.mrsfLine = String(startLine);
627
+ btn.dataset.mrsfStartLine = String(startLine);
628
+ btn.dataset.mrsfEndLine = String(endLine ?? startLine);
629
+ } else {
630
+ delete btn.dataset.mrsfLine;
631
+ delete btn.dataset.mrsfStartLine;
632
+ delete btn.dataset.mrsfEndLine;
633
+ }
634
+ if (startColumn != null) {
635
+ btn.dataset.mrsfStartColumn = String(startColumn);
636
+ } else {
637
+ delete btn.dataset.mrsfStartColumn;
638
+ }
639
+ if (endColumn != null) {
640
+ btn.dataset.mrsfEndColumn = String(endColumn);
641
+ } else {
642
+ delete btn.dataset.mrsfEndColumn;
643
+ }
644
+ this.lastSelectionText = selectionText;
645
+ const margin = 6;
646
+ btn.style.visibility = "hidden";
647
+ btn.style.display = "block";
648
+ const height = btn.offsetHeight || 0;
649
+ const top = window.scrollY + rect.top - height - margin;
650
+ const left = window.scrollX + rect.left;
651
+ btn.style.top = `${Math.max(0, top)}px`;
652
+ btn.style.left = `${Math.max(0, left)}px`;
653
+ btn.style.visibility = "visible";
654
+ }
655
+ // ── Dialog: form (add/edit/reply) ───────────────────────
656
+ injectStyles() {
657
+ if (this.styleInjected) return;
658
+ const css = `
659
+ .mrsf-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 2000; display: flex; align-items: center; justify-content: center; }
660
+ .mrsf-dialog { background: var(--mrsf-dialog-bg, var(--vp-c-bg, #1e1e1e)); color: var(--mrsf-dialog-fg, var(--vp-c-text-1, #f3f3f3)); border: 1px solid var(--mrsf-dialog-border, var(--vp-c-divider, #444)); border-radius: 8px; width: min(520px, 90vw); box-shadow: 0 10px 40px rgba(0,0,0,0.35); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
661
+ .mrsf-dialog header { padding: 12px 16px; font-weight: 600; border-bottom: 1px solid var(--mrsf-dialog-border, var(--vp-c-divider, #333)); }
662
+ .mrsf-dialog form { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
663
+ .mrsf-field { display: flex; flex-direction: column; gap: 6px; }
664
+ .mrsf-field label { font-size: 13px; color: var(--mrsf-dialog-muted, var(--vp-c-text-2, #ccc)); }
665
+ .mrsf-field input, .mrsf-field select, .mrsf-field textarea { background: var(--mrsf-field-bg, var(--vp-c-bg-soft, #252526)); color: var(--mrsf-dialog-fg, var(--vp-c-text-1, #f3f3f3)); border: 1px solid var(--mrsf-dialog-border, var(--vp-c-divider, #3c3c3c)); border-radius: 4px; padding: 6px 8px; font-size: 13px; }
666
+ .mrsf-field textarea { min-height: 100px; resize: vertical; }
667
+ .mrsf-actions-row { display: flex; justify-content: flex-end; gap: 8px; margin-top: 0; padding: 12px 16px 16px; border-top: 1px solid var(--mrsf-dialog-border, var(--vp-c-divider, #333)); }
668
+ .mrsf-btn { padding: 6px 12px; border-radius: 4px; border: 1px solid var(--mrsf-dialog-border, var(--vp-c-divider, #3c3c3c)); background: var(--mrsf-button-bg, var(--vp-c-bg-soft, #2d2d30)); color: var(--mrsf-dialog-fg, var(--vp-c-text-1, #f3f3f3)); cursor: pointer; }
669
+ .mrsf-btn-primary { background: var(--mrsf-button-primary-bg, #0e639c); border-color: var(--mrsf-button-primary-bg, #0e639c); color: #fff; }
670
+ .mrsf-helper { font-size: 12px; color: var(--mrsf-dialog-muted, var(--vp-c-text-2, #aaa)); }
671
+ `;
672
+ const style = document.createElement("style");
673
+ style.textContent = css;
674
+ document.head.appendChild(style);
675
+ this.styleInjected = true;
676
+ }
677
+ closeOverlay() {
678
+ if (this.overlayEl?.parentElement) {
679
+ this.overlayEl.parentElement.removeChild(this.overlayEl);
680
+ }
681
+ this.overlayEl = null;
682
+ }
683
+ openForm(action, detail) {
684
+ if (window.mrsfDisableBuiltinUi) return;
685
+ this.injectStyles();
686
+ this.closeOverlay();
687
+ const selText = detail.selectionText ?? "";
688
+ const line = detail.line ?? detail.start_line ?? null;
689
+ const endLine = detail.end_line ?? detail.line ?? null;
690
+ const startCol = detail.start_column ?? null;
691
+ const endCol = detail.end_column ?? null;
692
+ const overlay = document.createElement("div");
693
+ overlay.className = "mrsf-overlay";
694
+ const dialog = document.createElement("div");
695
+ dialog.className = "mrsf-dialog";
696
+ const header = document.createElement("header");
697
+ header.textContent = action === "add" ? "Add comment" : action === "edit" ? "Edit comment" : "Reply";
698
+ dialog.appendChild(header);
699
+ const form = document.createElement("form");
700
+ const field = (labelText, inputEl, helper) => {
701
+ const wrap = document.createElement("div");
702
+ wrap.className = "mrsf-field";
703
+ const label = document.createElement("label");
704
+ label.textContent = labelText;
705
+ wrap.appendChild(label);
706
+ wrap.appendChild(inputEl);
707
+ if (helper) {
708
+ const h = document.createElement("div");
709
+ h.className = "mrsf-helper";
710
+ h.textContent = helper;
711
+ wrap.appendChild(h);
712
+ }
713
+ form.appendChild(wrap);
714
+ };
715
+ const textArea = document.createElement("textarea");
716
+ textArea.name = "text";
717
+ textArea.required = true;
718
+ field("Comment text", textArea);
719
+ const typeSelect = document.createElement("select");
720
+ typeSelect.name = "type";
721
+ ["", "suggestion", "issue", "question", "accuracy", "style", "clarity"].forEach((t) => {
722
+ const opt = document.createElement("option");
723
+ opt.value = t;
724
+ opt.textContent = t || "(none)";
725
+ typeSelect.appendChild(opt);
726
+ });
727
+ field("Type", typeSelect, "Optional");
728
+ const severitySelect = document.createElement("select");
729
+ severitySelect.name = "severity";
730
+ ["", "low", "medium", "high"].forEach((s) => {
731
+ const opt = document.createElement("option");
732
+ opt.value = s;
733
+ opt.textContent = s || "(none)";
734
+ severitySelect.appendChild(opt);
735
+ });
736
+ field("Severity", severitySelect, "Optional");
737
+ if (selText) {
738
+ const pre = document.createElement("pre");
739
+ pre.textContent = selText;
740
+ pre.style.margin = "0";
741
+ pre.style.whiteSpace = "pre-wrap";
742
+ field("Selected text", pre, "Captured automatically");
743
+ }
744
+ const actions = document.createElement("div");
745
+ actions.className = "mrsf-actions-row";
746
+ const cancelBtn = document.createElement("button");
747
+ cancelBtn.type = "button";
748
+ cancelBtn.className = "mrsf-btn";
749
+ cancelBtn.textContent = "Cancel";
750
+ cancelBtn.addEventListener("click", () => this.closeOverlay());
751
+ actions.appendChild(cancelBtn);
752
+ const submitBtn = document.createElement("button");
753
+ submitBtn.type = "submit";
754
+ submitBtn.className = "mrsf-btn mrsf-btn-primary";
755
+ submitBtn.textContent = action === "add" ? "Add" : action === "reply" ? "Reply" : "Save";
756
+ actions.appendChild(submitBtn);
757
+ form.appendChild(actions);
758
+ form.addEventListener("submit", (ev) => {
759
+ ev.preventDefault();
760
+ const detailOut = {
761
+ action,
762
+ commentId: detail.commentId,
763
+ text: textArea.value.trim(),
764
+ type: typeSelect.value || null,
765
+ severity: severitySelect.value || null,
766
+ line,
767
+ end_line: endLine,
768
+ start_column: startCol,
769
+ end_column: endCol,
770
+ selection_text: selText || null
771
+ };
772
+ document.dispatchEvent(new CustomEvent("mrsf:submit", { detail: detailOut, bubbles: true }));
773
+ this.closeOverlay();
774
+ });
775
+ dialog.appendChild(form);
776
+ overlay.appendChild(dialog);
777
+ overlay.addEventListener("click", (e) => {
778
+ if (e.target === overlay) this.closeOverlay();
779
+ });
780
+ document.body.appendChild(overlay);
781
+ this.overlayEl = overlay;
782
+ }
783
+ // ── Dialog: confirm (resolve / unresolve / delete) ──────
784
+ openConfirm(action, detail) {
785
+ if (window.mrsfDisableBuiltinUi) return;
786
+ this.injectStyles();
787
+ this.closeOverlay();
788
+ const overlay = document.createElement("div");
789
+ overlay.className = "mrsf-overlay";
790
+ const dialog = document.createElement("div");
791
+ dialog.className = "mrsf-dialog";
792
+ const header = document.createElement("header");
793
+ header.textContent = action === "delete" ? "Delete comment" : "Change status";
794
+ dialog.appendChild(header);
795
+ const body = document.createElement("div");
796
+ body.style.padding = "16px";
797
+ body.textContent = action === "delete" ? "Delete this comment?" : action === "resolve" ? "Mark this comment as resolved?" : "Mark this comment as unresolved?";
798
+ dialog.appendChild(body);
799
+ const actions = document.createElement("div");
800
+ actions.className = "mrsf-actions-row";
801
+ const cancelBtn = document.createElement("button");
802
+ cancelBtn.type = "button";
803
+ cancelBtn.className = "mrsf-btn";
804
+ cancelBtn.textContent = "Cancel";
805
+ cancelBtn.addEventListener("click", () => this.closeOverlay());
806
+ actions.appendChild(cancelBtn);
807
+ const confirmBtn = document.createElement("button");
808
+ confirmBtn.type = "button";
809
+ confirmBtn.className = "mrsf-btn mrsf-btn-primary";
810
+ confirmBtn.textContent = action === "delete" ? "Delete" : "Confirm";
811
+ confirmBtn.addEventListener("click", () => {
812
+ const detailOut = {
813
+ action,
814
+ commentId: detail.commentId,
815
+ text: "",
816
+ type: null,
817
+ severity: null,
818
+ line: detail.line,
819
+ end_line: detail.end_line ?? detail.line ?? null,
820
+ start_column: detail.start_column ?? null,
821
+ end_column: detail.end_column ?? null,
822
+ selection_text: detail.selectionText ?? null
823
+ };
824
+ document.dispatchEvent(new CustomEvent("mrsf:submit", { detail: detailOut, bubbles: true }));
825
+ this.closeOverlay();
826
+ });
827
+ actions.appendChild(confirmBtn);
828
+ dialog.appendChild(actions);
829
+ overlay.appendChild(dialog);
830
+ overlay.addEventListener("click", (e) => {
831
+ if (e.target === overlay) this.closeOverlay();
832
+ });
833
+ document.body.appendChild(overlay);
834
+ this.overlayEl = overlay;
835
+ }
836
+ };
837
+ var autoInitDone = false;
838
+ function autoInit() {
839
+ if (autoInitDone) return;
840
+ autoInitDone = true;
841
+ const containers = document.querySelectorAll("[data-mrsf-controller]");
842
+ for (const container of containers) {
843
+ const pos = container.dataset.mrsfGutterPosition;
844
+ const interactive = container.dataset.mrsfInteractive === "true";
845
+ new MrsfController(container, { gutterPosition: pos ?? "right", interactive });
846
+ }
847
+ }
848
+ if (typeof document !== "undefined") {
849
+ if (document.readyState === "loading") {
850
+ document.addEventListener("DOMContentLoaded", autoInit);
851
+ } else {
852
+ autoInit();
853
+ }
20
854
  }
21
- init();
855
+ export {
856
+ MrsfController,
857
+ autoInit
858
+ };
22
859
  //# sourceMappingURL=controller.js.map