ligarb 0.4.0 → 0.6.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.
data/assets/review.js ADDED
@@ -0,0 +1,684 @@
1
+ // ligarb serve — review/comment system (SSE-driven)
2
+ (function() {
3
+ 'use strict';
4
+
5
+ var API = window._ligarbAPI || '/_ligarb';
6
+ var panel = null;
7
+ var listPanel = null;
8
+ var currentReviewId = null;
9
+
10
+ // ── Utility ──
11
+
12
+ function fetchJSON(url, opts) {
13
+ opts = opts || {};
14
+ opts.headers = opts.headers || {};
15
+ if (opts.body && typeof opts.body === 'object') {
16
+ opts.body = JSON.stringify(opts.body);
17
+ opts.headers['Content-Type'] = 'application/json';
18
+ }
19
+ return fetch(url, opts).then(function(r) { return r.json(); });
20
+ }
21
+
22
+ function escapeHTML(str) {
23
+ var div = document.createElement('div');
24
+ div.textContent = str;
25
+ return div.innerHTML;
26
+ }
27
+
28
+ function formatTime(iso) {
29
+ if (!iso) return '';
30
+ var d = new Date(iso);
31
+ return d.toLocaleString();
32
+ }
33
+
34
+ // ── SSE: listen for review updates ──
35
+
36
+ function waitForSSE() {
37
+ if (window._ligarbEvents) {
38
+ window._ligarbEvents.addEventListener('review_updated', function(e) {
39
+ var data = JSON.parse(e.data);
40
+ // Update open panel if it matches
41
+ if (currentReviewId && data.id === currentReviewId) {
42
+ loadReview(currentReviewId);
43
+ }
44
+ // Update badge
45
+ updateBadge();
46
+ });
47
+ } else {
48
+ setTimeout(waitForSSE, 100);
49
+ }
50
+ }
51
+ waitForSSE();
52
+
53
+ // ── Comment Button on Text Selection ──
54
+
55
+ var commentBtn = document.createElement('button');
56
+ commentBtn.id = 'ligarb-comment-btn';
57
+ commentBtn.textContent = 'Comment';
58
+ commentBtn.style.display = 'none';
59
+ document.body.appendChild(commentBtn);
60
+
61
+ var selectionData = null;
62
+
63
+ document.addEventListener('mouseup', function(e) {
64
+ if (e.target.closest('#ligarb-panel, #ligarb-list-panel, #ligarb-comment-btn')) return;
65
+
66
+ var sel = window.getSelection();
67
+ if (!sel || sel.isCollapsed || !sel.toString().trim()) {
68
+ commentBtn.style.display = 'none';
69
+ selectionData = null;
70
+ return;
71
+ }
72
+
73
+ var anchor = sel.anchorNode;
74
+ var chapter = anchor ? anchor.parentElement.closest('.chapter') : null;
75
+ if (!chapter) {
76
+ commentBtn.style.display = 'none';
77
+ selectionData = null;
78
+ return;
79
+ }
80
+
81
+ var chapterSlug = chapter.id.replace('chapter-', '');
82
+ var selectedText = sel.toString().trim();
83
+
84
+ // Find nearest heading before selection
85
+ var headingId = '';
86
+ var headings = chapter.querySelectorAll('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]');
87
+ var range = sel.getRangeAt(0);
88
+ for (var i = headings.length - 1; i >= 0; i--) {
89
+ var headingRange = document.createRange();
90
+ headingRange.selectNode(headings[i]);
91
+ if (range.compareBoundaryPoints(Range.START_TO_START, headingRange) >= 0) {
92
+ headingId = headings[i].id;
93
+ break;
94
+ }
95
+ }
96
+
97
+ selectionData = {
98
+ chapter_slug: chapterSlug,
99
+ heading_id: headingId,
100
+ selected_text: selectedText.substring(0, 500)
101
+ };
102
+
103
+ var rect = sel.getRangeAt(0).getBoundingClientRect();
104
+ commentBtn.style.display = 'block';
105
+ commentBtn.style.top = (window.scrollY + rect.bottom + 5) + 'px';
106
+ commentBtn.style.left = (window.scrollX + rect.left) + 'px';
107
+ });
108
+
109
+ commentBtn.addEventListener('click', function(e) {
110
+ e.preventDefault();
111
+ e.stopPropagation();
112
+ if (!selectionData) return;
113
+ commentBtn.style.display = 'none';
114
+ openNewCommentPanel(selectionData);
115
+ selectionData = null;
116
+ window.getSelection().removeAllRanges();
117
+ });
118
+
119
+ // ── Review Panel ──
120
+
121
+ function createPanel() {
122
+ if (panel) return;
123
+ panel = document.createElement('div');
124
+ panel.id = 'ligarb-panel';
125
+ panel.innerHTML =
126
+ '<div class="ligarb-panel-header">' +
127
+ '<span class="ligarb-panel-title">Review</span>' +
128
+ '<button class="ligarb-panel-close">&times;</button>' +
129
+ '</div>' +
130
+ '<div class="ligarb-panel-body">' +
131
+ '<div class="ligarb-context"></div>' +
132
+ '<div class="ligarb-messages"></div>' +
133
+ '<div class="ligarb-input-area">' +
134
+ '<textarea class="ligarb-input" placeholder="Type a message..." rows="3"></textarea>' +
135
+ '<div class="ligarb-file-area">' +
136
+ '<input type="file" class="ligarb-file-input" multiple style="display:none">' +
137
+ '<button class="ligarb-file-btn" title="Attach files">&#128206;</button>' +
138
+ '<span class="ligarb-file-names"></span>' +
139
+ '</div>' +
140
+ '<div class="ligarb-actions">' +
141
+ '<button class="ligarb-btn ligarb-btn-send">Send</button>' +
142
+ '<button class="ligarb-btn ligarb-btn-approve">Approve</button>' +
143
+ '<button class="ligarb-btn ligarb-btn-close-thread">Dismiss</button>' +
144
+ '</div>' +
145
+ '</div>' +
146
+ '</div>';
147
+ document.body.appendChild(panel);
148
+
149
+ panel.querySelector('.ligarb-panel-close').addEventListener('click', closePanel);
150
+ panel.querySelector('.ligarb-btn-send').addEventListener('click', sendMessage);
151
+ panel.querySelector('.ligarb-btn-approve').addEventListener('click', approveReview);
152
+ panel.querySelector('.ligarb-btn-close-thread').addEventListener('click', closeThread);
153
+
154
+ panel.querySelector('.ligarb-input').addEventListener('keydown', function(e) {
155
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
156
+ e.preventDefault();
157
+ sendMessage();
158
+ }
159
+ });
160
+
161
+ // File attachment
162
+ var fileBtn = panel.querySelector('.ligarb-file-btn');
163
+ var fileInput = panel.querySelector('.ligarb-file-input');
164
+ var fileNames = panel.querySelector('.ligarb-file-names');
165
+ panel._pendingFiles = [];
166
+
167
+ fileBtn.addEventListener('click', function() { fileInput.click(); });
168
+ fileInput.addEventListener('change', function() {
169
+ addFiles(fileInput.files);
170
+ fileInput.value = '';
171
+ });
172
+
173
+ // Drag & drop on textarea
174
+ var textarea = panel.querySelector('.ligarb-input');
175
+ textarea.addEventListener('dragover', function(e) {
176
+ e.preventDefault();
177
+ textarea.classList.add('dragover');
178
+ });
179
+ textarea.addEventListener('dragleave', function() {
180
+ textarea.classList.remove('dragover');
181
+ });
182
+ textarea.addEventListener('drop', function(e) {
183
+ e.preventDefault();
184
+ textarea.classList.remove('dragover');
185
+ if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
186
+ });
187
+
188
+ function addFiles(fileList) {
189
+ for (var i = 0; i < fileList.length; i++) panel._pendingFiles.push(fileList[i]);
190
+ renderPendingFiles();
191
+ }
192
+
193
+ function renderPendingFiles() {
194
+ fileNames.innerHTML = '';
195
+ panel._pendingFiles.forEach(function(f, i) {
196
+ var tag = document.createElement('span');
197
+ tag.className = 'ligarb-file-tag';
198
+ tag.innerHTML = escapeHTML(f.name) + ' <a href="#" data-idx="' + i + '">&times;</a>';
199
+ tag.querySelector('a').addEventListener('click', function(e) {
200
+ e.preventDefault();
201
+ panel._pendingFiles.splice(parseInt(this.dataset.idx), 1);
202
+ renderPendingFiles();
203
+ });
204
+ fileNames.appendChild(tag);
205
+ });
206
+ }
207
+
208
+ panel._renderPendingFiles = renderPendingFiles;
209
+ }
210
+
211
+ function closePanel() {
212
+ if (panel) {
213
+ panel.classList.remove('open');
214
+ currentReviewId = null;
215
+ }
216
+ }
217
+
218
+ function openNewCommentPanel(context) {
219
+ createPanel();
220
+ currentReviewId = null;
221
+ panel._pendingFiles = [];
222
+ if (panel._renderPendingFiles) panel._renderPendingFiles();
223
+
224
+ panel.querySelector('.ligarb-panel-title').textContent = 'New Comment';
225
+ panel.querySelector('.ligarb-context').innerHTML =
226
+ '<div class="ligarb-selected-text">"' + escapeHTML(context.selected_text) + '"</div>';
227
+ panel.querySelector('.ligarb-messages').innerHTML = '';
228
+ panel.querySelector('.ligarb-input').value = '';
229
+ panel.querySelector('.ligarb-input').placeholder = 'Write your comment...';
230
+ panel.querySelector('.ligarb-input').disabled = false;
231
+ panel.querySelector('.ligarb-btn-send').disabled = false;
232
+ panel.querySelector('.ligarb-btn-approve').style.display = 'none';
233
+ panel.querySelector('.ligarb-btn-close-thread').style.display = 'none';
234
+ panel.querySelector('.ligarb-btn-send').textContent = 'Comment';
235
+
236
+ panel._createContext = context;
237
+
238
+ panel.classList.add('open');
239
+ panel.addEventListener('transitionend', function onEnd() {
240
+ panel.removeEventListener('transitionend', onEnd);
241
+ panel.querySelector('.ligarb-input').focus();
242
+ });
243
+ }
244
+
245
+ function openReviewPanel(reviewId) {
246
+ createPanel();
247
+ currentReviewId = reviewId;
248
+ panel._createContext = null;
249
+ panel._pendingFiles = [];
250
+ if (panel._renderPendingFiles) panel._renderPendingFiles();
251
+
252
+ panel.querySelector('.ligarb-panel-title').textContent = 'Review';
253
+ panel.querySelector('.ligarb-messages').innerHTML = '<div class="ligarb-loading">Loading...</div>';
254
+ panel.querySelector('.ligarb-input').value = '';
255
+ panel.querySelector('.ligarb-btn-send').textContent = 'Reply';
256
+ panel.querySelector('.ligarb-btn-approve').style.display = '';
257
+ panel.querySelector('.ligarb-btn-close-thread').style.display = '';
258
+
259
+ panel.classList.add('open');
260
+ loadReview(reviewId);
261
+ }
262
+
263
+ function loadReview(id) {
264
+ fetchJSON(API + '/reviews/' + id).then(function(review) {
265
+ if (currentReviewId !== id) return;
266
+ renderReview(review);
267
+ });
268
+ }
269
+
270
+ function renderReview(review) {
271
+ var ctx = review.context || {};
272
+ var filePath = review.file_path ? '<details class="ligarb-file-path"><summary>debug</summary><code>' + escapeHTML(review.file_path) + '</code></details>' : '';
273
+ panel.querySelector('.ligarb-context').innerHTML =
274
+ '<div class="ligarb-selected-text">"' + escapeHTML(ctx.selected_text || '') + '"</div>' +
275
+ '<div class="ligarb-meta">Chapter: ' + escapeHTML(ctx.chapter_slug || '') + '</div>' +
276
+ filePath;
277
+
278
+ var msgsEl = panel.querySelector('.ligarb-messages');
279
+ msgsEl.innerHTML = '';
280
+
281
+ (review.messages || []).forEach(function(msg) {
282
+ var div = document.createElement('div');
283
+ div.className = 'ligarb-message ligarb-message-' + msg.role;
284
+ div.innerHTML =
285
+ '<div class="ligarb-message-role">' + (msg.role === 'user' ? 'You' : 'Claude') + '</div>' +
286
+ '<div class="ligarb-message-content">' + formatMessageContent(msg.content) + '</div>' +
287
+ '<div class="ligarb-message-time">' + formatTime(msg.timestamp) + '</div>';
288
+ msgsEl.appendChild(div);
289
+ });
290
+
291
+ // Show processing indicator when waiting for Claude
292
+ var lastMsg = review.messages && review.messages[review.messages.length - 1];
293
+ var isApplying = review.status === 'applying';
294
+ var waitingForClaude = lastMsg && lastMsg.role === 'user' && review.status === 'open';
295
+
296
+ if (isApplying) {
297
+ msgsEl.appendChild(makeThinkingBubble('Applying changes...'));
298
+ } else if (waitingForClaude) {
299
+ msgsEl.appendChild(makeThinkingBubble('Claude is thinking...'));
300
+ }
301
+
302
+ msgsEl.scrollTop = msgsEl.scrollHeight;
303
+
304
+ var isOpen = review.status === 'open';
305
+ panel.querySelector('.ligarb-input').disabled = !isOpen;
306
+ panel.querySelector('.ligarb-btn-send').disabled = !isOpen;
307
+ panel.querySelector('.ligarb-btn-approve').disabled = !isOpen;
308
+ panel.querySelector('.ligarb-btn-close-thread').disabled = isApplying;
309
+
310
+ if (review.status === 'applied') {
311
+ panel.querySelector('.ligarb-panel-title').textContent = 'Review (Applied)';
312
+ } else if (review.status === 'closed') {
313
+ panel.querySelector('.ligarb-panel-title').textContent = 'Review (Closed)';
314
+ } else if (isApplying) {
315
+ panel.querySelector('.ligarb-panel-title').textContent = 'Review (Applying...)';
316
+ } else {
317
+ panel.querySelector('.ligarb-panel-title').textContent = 'Review';
318
+ }
319
+ }
320
+
321
+ function formatMessageContent(content) {
322
+ if (!content) return '';
323
+
324
+ // Highlight error messages
325
+ if (/^Error:\s/.test(content)) {
326
+ return '<div class="ligarb-error">' + escapeHTML(content) + '</div>';
327
+ }
328
+
329
+ // Split on <patch> blocks
330
+ var parts = content.split(/(<patch(?:\s+file="[^"]*")?>[\s\S]*?<\/patch>)/g);
331
+ var hasPatch = false;
332
+ var html = '';
333
+ var patches = '';
334
+
335
+ parts.forEach(function(part) {
336
+ var m = part.match(/<patch(?:\s+file="([^"]*)")?>\s*<<<[ \t]*\r?\n([\s\S]*?)\r?\n===[ \t]*\r?\n([\s\S]*?)\r?\n>>>[ \t]*\s*<\/patch>/);
337
+ if (m) {
338
+ hasPatch = true;
339
+ var fileLabel = m[1] ? '<div class="ligarb-patch-file">' + escapeHTML(m[1]) + '</div>' : '';
340
+ patches +=
341
+ '<div class="ligarb-patch">' +
342
+ fileLabel +
343
+ '<div class="ligarb-patch-del">' + escapeHTML(m[2]) + '</div>' +
344
+ '<div class="ligarb-patch-add">' + escapeHTML(m[3]) + '</div>' +
345
+ '</div>';
346
+ } else {
347
+ html += escapeHTML(part)
348
+ .replace(/\n/g, '<br>')
349
+ .replace(/`([^`]+)`/g, '<code>$1</code>');
350
+ }
351
+ });
352
+
353
+ if (hasPatch) {
354
+ html += '<button class="ligarb-patch-toggle" onclick="this.nextElementSibling.classList.toggle(\'open\'); this.textContent = this.nextElementSibling.classList.contains(\'open\') ? \'Hide patch\' : \'Show patch\'">Show patch</button>';
355
+ html += '<div class="ligarb-patch-container">' + patches + '</div>';
356
+ }
357
+
358
+ return html;
359
+ }
360
+
361
+ function makeThinkingBubble(text) {
362
+ var div = document.createElement('div');
363
+ div.className = 'ligarb-message ligarb-message-assistant ligarb-thinking';
364
+ div.innerHTML =
365
+ '<div class="ligarb-message-role">Claude</div>' +
366
+ '<div class="ligarb-message-content"><span class="ligarb-dots"></span> ' + escapeHTML(text) + '</div>';
367
+ return div;
368
+ }
369
+
370
+ function readFilesAsBase64(files) {
371
+ if (!files || files.length === 0) return Promise.resolve([]);
372
+ var promises = files.map(function(f) {
373
+ return new Promise(function(resolve) {
374
+ var reader = new FileReader();
375
+ reader.onload = function() {
376
+ var base64 = reader.result.split(',')[1] || '';
377
+ resolve({ name: f.name, data: base64 });
378
+ };
379
+ reader.readAsDataURL(f);
380
+ });
381
+ });
382
+ return Promise.all(promises);
383
+ }
384
+
385
+ function sendMessage() {
386
+ var input = panel.querySelector('.ligarb-input');
387
+ var message = input.value.trim();
388
+ if (!message) return;
389
+
390
+ input.value = '';
391
+ var pending = panel._pendingFiles || [];
392
+ panel._pendingFiles = [];
393
+ if (panel._renderPendingFiles) panel._renderPendingFiles();
394
+
395
+ readFilesAsBase64(pending).then(function(filesData) {
396
+ if (panel._createContext) {
397
+ var ctx = panel._createContext;
398
+ panel._createContext = null;
399
+ panel.querySelector('.ligarb-btn-send').textContent = 'Reply';
400
+
401
+ var body = { context: ctx, message: message };
402
+ if (filesData.length > 0) body.files = filesData;
403
+
404
+ fetchJSON(API + '/reviews', {
405
+ method: 'POST',
406
+ body: body
407
+ }).then(function(review) {
408
+ currentReviewId = review.id;
409
+ panel.querySelector('.ligarb-btn-approve').style.display = '';
410
+ panel.querySelector('.ligarb-btn-close-thread').style.display = '';
411
+ renderReview(review);
412
+ updateBadge();
413
+ });
414
+ return;
415
+ }
416
+
417
+ if (!currentReviewId) return;
418
+
419
+ var body = { message: message };
420
+ if (filesData.length > 0) body.files = filesData;
421
+
422
+ fetchJSON(API + '/reviews/' + currentReviewId + '/messages', {
423
+ method: 'POST',
424
+ body: body
425
+ }).then(function(review) {
426
+ renderReview(review);
427
+ });
428
+ });
429
+ }
430
+
431
+ function approveReview() {
432
+ if (!currentReviewId) return;
433
+ if (!confirm('Apply the discussed changes to the source file?')) return;
434
+
435
+ fetchJSON(API + '/reviews/' + currentReviewId + '/approve', {
436
+ method: 'POST'
437
+ }).then(function(review) {
438
+ renderReview(review);
439
+ if (review.status === 'applied') {
440
+ closePanel();
441
+ }
442
+ updateBadge();
443
+ });
444
+ }
445
+
446
+ function closeThread() {
447
+ if (!currentReviewId) return;
448
+
449
+ fetchJSON(API + '/reviews/' + currentReviewId + '/close', {
450
+ method: 'POST'
451
+ }).then(function(review) {
452
+ renderReview(review);
453
+ updateBadge();
454
+ });
455
+ }
456
+
457
+ // ── Review List Panel ──
458
+
459
+ var listBtn = document.createElement('button');
460
+ listBtn.id = 'ligarb-list-btn';
461
+ listBtn.innerHTML = '&#9993;';
462
+ listBtn.title = 'Review threads';
463
+ document.body.appendChild(listBtn);
464
+
465
+ var badge = document.createElement('span');
466
+ badge.id = 'ligarb-badge';
467
+ badge.style.display = 'none';
468
+ listBtn.appendChild(badge);
469
+
470
+ listBtn.addEventListener('click', function() {
471
+ toggleListPanel();
472
+ });
473
+
474
+ function toggleListPanel() {
475
+ if (listPanel && listPanel.classList.contains('open')) {
476
+ listPanel.classList.remove('open');
477
+ return;
478
+ }
479
+ createListPanel();
480
+ listPanel.classList.add('open');
481
+ loadReviewList();
482
+ }
483
+
484
+ function createListPanel() {
485
+ if (listPanel) return;
486
+ listPanel = document.createElement('div');
487
+ listPanel.id = 'ligarb-list-panel';
488
+ listPanel.innerHTML =
489
+ '<div class="ligarb-panel-header">' +
490
+ '<span class="ligarb-panel-title">Reviews</span>' +
491
+ '<button class="ligarb-panel-close">&times;</button>' +
492
+ '</div>' +
493
+ '<div class="ligarb-list-body"></div>';
494
+ document.body.appendChild(listPanel);
495
+
496
+ listPanel.querySelector('.ligarb-panel-close').addEventListener('click', function() {
497
+ listPanel.classList.remove('open');
498
+ });
499
+ }
500
+
501
+ function loadReviewList() {
502
+ fetchJSON(API + '/reviews').then(function(reviews) {
503
+ var body = listPanel.querySelector('.ligarb-list-body');
504
+ if (!reviews || reviews.length === 0) {
505
+ body.innerHTML = '<div class="ligarb-list-empty">No reviews yet.</div>';
506
+ return;
507
+ }
508
+
509
+ body.innerHTML = '';
510
+ reviews.reverse();
511
+ reviews.forEach(function(r) {
512
+ var item = document.createElement('div');
513
+ item.className = 'ligarb-list-item ligarb-list-' + r.status;
514
+ var statusIcon = r.status === 'open' ? '&#9679;' : r.status === 'applied' ? '&#10003;' : '&#10005;';
515
+ item.innerHTML =
516
+ '<div class="ligarb-list-item-header">' +
517
+ '<span class="ligarb-list-status">' + statusIcon + '</span>' +
518
+ '<span class="ligarb-list-text">"' + escapeHTML((r.context && r.context.selected_text || '').substring(0, 60)) + '"</span>' +
519
+ '</div>' +
520
+ '<div class="ligarb-list-item-meta">' +
521
+ escapeHTML(r.context && r.context.chapter_slug || '') +
522
+ ' &middot; ' + r.message_count + ' messages' +
523
+ ' &middot; ' + formatTime(r.created_at) +
524
+ '</div>';
525
+ item.addEventListener('click', function() {
526
+ listPanel.classList.remove('open');
527
+ openReviewPanel(r.id);
528
+ });
529
+ body.appendChild(item);
530
+ });
531
+ });
532
+ }
533
+
534
+ function updateBadge() {
535
+ fetchJSON(API + '/reviews').then(function(reviews) {
536
+ var open = (reviews || []).filter(function(r) { return r.status === 'open' || r.status === 'applying'; }).length;
537
+ if (open > 0) {
538
+ badge.textContent = open;
539
+ badge.style.display = 'inline-block';
540
+ listBtn.classList.add('has-open');
541
+ } else {
542
+ badge.style.display = 'none';
543
+ listBtn.classList.remove('has-open');
544
+ }
545
+ });
546
+ }
547
+
548
+ // ── Highlight open reviews in the document ──
549
+
550
+ function updateHighlights() {
551
+ // Remove existing highlights
552
+ var existing = document.querySelectorAll('mark.ligarb-highlight');
553
+ existing.forEach(function(el) {
554
+ var parent = el.parentNode;
555
+ while (el.firstChild) parent.insertBefore(el.firstChild, el);
556
+ parent.removeChild(el);
557
+ parent.normalize();
558
+ });
559
+
560
+ fetchJSON(API + '/reviews').then(function(reviews) {
561
+ if (!reviews) return;
562
+
563
+ reviews.forEach(function(r) {
564
+ if (r.status !== 'open') return;
565
+ var ctx = r.context;
566
+ if (!ctx || !ctx.selected_text) return;
567
+
568
+ var chapter = document.getElementById('chapter-' + ctx.chapter_slug);
569
+ if (!chapter) return;
570
+
571
+ // Narrow scope using heading_id if available
572
+ var scope = chapter;
573
+ if (ctx.heading_id) {
574
+ var heading = document.getElementById(ctx.heading_id);
575
+ if (heading) {
576
+ // Use the heading's parent section or next sibling content
577
+ scope = heading.parentElement || chapter;
578
+ }
579
+ }
580
+
581
+ highlightText(scope, ctx.selected_text, r.id);
582
+ });
583
+ });
584
+ }
585
+
586
+ function highlightText(root, text, reviewId) {
587
+ // Use TreeWalker to find text nodes containing the target text
588
+ var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
589
+ var nodes = [];
590
+ var node;
591
+ while ((node = walker.nextNode())) {
592
+ // Skip nodes inside review UI elements
593
+ if (node.parentElement.closest('#ligarb-panel, #ligarb-list-panel, #ligarb-comment-btn, #ligarb-list-btn')) continue;
594
+ nodes.push(node);
595
+ }
596
+
597
+ // Try to find a contiguous range of text nodes that contain the selected text
598
+ // First, try single-node match
599
+ for (var i = 0; i < nodes.length; i++) {
600
+ var content = nodes[i].textContent;
601
+ var idx = content.indexOf(text);
602
+ if (idx !== -1) {
603
+ wrapRange(nodes[i], idx, idx + text.length, reviewId);
604
+ return;
605
+ }
606
+ }
607
+
608
+ // Multi-node match: concatenate adjacent text and find the match
609
+ for (var start = 0; start < nodes.length; start++) {
610
+ var combined = '';
611
+ var segments = []; // {node, startInCombined, length}
612
+ for (var end = start; end < nodes.length && combined.length < text.length + 500; end++) {
613
+ segments.push({ node: nodes[end], startInCombined: combined.length, length: nodes[end].textContent.length });
614
+ combined += nodes[end].textContent;
615
+ var matchIdx = combined.indexOf(text);
616
+ if (matchIdx !== -1) {
617
+ wrapMultiNodeRange(segments, matchIdx, matchIdx + text.length, reviewId);
618
+ return;
619
+ }
620
+ }
621
+ }
622
+ }
623
+
624
+ function wrapRange(textNode, startOffset, endOffset, reviewId) {
625
+ var range = document.createRange();
626
+ range.setStart(textNode, startOffset);
627
+ range.setEnd(textNode, endOffset);
628
+ var mark = createMark(reviewId);
629
+ range.surroundContents(mark);
630
+ }
631
+
632
+ function wrapMultiNodeRange(segments, matchStart, matchEnd, reviewId) {
633
+ // Collect the text node ranges that overlap with [matchStart, matchEnd)
634
+ var parts = [];
635
+ for (var i = 0; i < segments.length; i++) {
636
+ var seg = segments[i];
637
+ var segStart = seg.startInCombined;
638
+ var segEnd = segStart + seg.length;
639
+ // Check overlap with [matchStart, matchEnd)
640
+ var overlapStart = Math.max(segStart, matchStart);
641
+ var overlapEnd = Math.min(segEnd, matchEnd);
642
+ if (overlapStart < overlapEnd) {
643
+ parts.push({
644
+ node: seg.node,
645
+ start: overlapStart - segStart,
646
+ end: overlapEnd - segStart
647
+ });
648
+ }
649
+ }
650
+
651
+ // Wrap each part in reverse order to preserve node offsets
652
+ for (var j = parts.length - 1; j >= 0; j--) {
653
+ var p = parts[j];
654
+ var range = document.createRange();
655
+ range.setStart(p.node, p.start);
656
+ range.setEnd(p.node, p.end);
657
+ var mark = createMark(reviewId);
658
+ range.surroundContents(mark);
659
+ }
660
+ }
661
+
662
+ function createMark(reviewId) {
663
+ var mark = document.createElement('mark');
664
+ mark.className = 'ligarb-highlight';
665
+ mark.dataset.reviewId = reviewId;
666
+ mark.addEventListener('click', function(e) {
667
+ e.preventDefault();
668
+ e.stopPropagation();
669
+ openReviewPanel(reviewId);
670
+ });
671
+ return mark;
672
+ }
673
+
674
+ // ── Update highlights on review changes ──
675
+
676
+ var origUpdateBadge = updateBadge;
677
+ updateBadge = function() {
678
+ origUpdateBadge();
679
+ updateHighlights();
680
+ };
681
+
682
+ // Initial badge + highlights
683
+ updateBadge();
684
+ })();