reviw 0.16.3 → 0.17.1

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.
Files changed (2) hide show
  1. package/cli.cjs +382 -60
  2. package/package.json +1 -1
package/cli.cjs CHANGED
@@ -159,7 +159,8 @@ marked.use({
159
159
  if (videoExtensions.test(href)) {
160
160
  // For videos, render as video element with controls and thumbnail preview
161
161
  var displayText = text || href.split('/').pop();
162
- return '<video src="' + escapeHtmlForXss(href) + '" controls preload="metadata" class="video-preview"' + titleAttr + '>' +
162
+ var dataAlt = text ? ' data-alt="' + escapeHtmlForXss(text) + '"' : "";
163
+ return '<video src="' + escapeHtmlForXss(href) + '" controls preload="metadata" class="video-preview"' + titleAttr + dataAlt + '>' +
163
164
  '<a href="' + escapeHtmlForXss(href) + '">📹' + escapeHtmlForXss(displayText) + '</a></video>';
164
165
  }
165
166
  return '<img src="' + escapeHtmlForXss(href) + '"' + altAttr + titleAttr + '>';
@@ -946,7 +947,7 @@ function diffHtmlTemplate(diffData, history = []) {
946
947
  }
947
948
  .theme-toggle:hover { background: var(--border); }
948
949
 
949
- .wrap { padding: 16px 20px 60px; max-width: 1200px; margin: 0 auto; }
950
+ .wrap { padding: 16px 20px 16px; max-width: 1200px; margin: 0 auto; }
950
951
  .diff-container {
951
952
  background: var(--panel);
952
953
  border: 1px solid var(--border);
@@ -1140,19 +1141,8 @@ function diffHtmlTemplate(diffData, history = []) {
1140
1141
  .comment-list li:hover { color: var(--accent); }
1141
1142
  .comment-list .hint { color: var(--muted); font-size: 11px; margin-top: 8px; }
1142
1143
  .comment-list.collapsed { opacity: 0; pointer-events: none; transform: translateY(8px); }
1143
- .comment-toggle {
1144
- position: fixed;
1145
- right: 16px;
1146
- bottom: 16px;
1147
- padding: 8px 12px;
1148
- border-radius: 6px;
1149
- border: 1px solid var(--border);
1150
- background: var(--panel-alpha);
1151
- color: var(--text);
1152
- cursor: pointer;
1153
- font-size: 12px;
1154
- }
1155
- .pill { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border-radius: 999px; background: var(--selected-bg); border: 1px solid var(--border); font-size: 12px; }
1144
+ .pill { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border-radius: 999px; background: var(--selected-bg); border: 1px solid var(--border); font-size: 12px; cursor: pointer; transition: background 150ms ease, border-color 150ms ease; }
1145
+ .pill:hover { background: var(--hover-bg); border-color: var(--accent); }
1156
1146
  .pill strong { font-weight: 700; }
1157
1147
 
1158
1148
  .modal-overlay {
@@ -1213,6 +1203,15 @@ function diffHtmlTemplate(diffData, history = []) {
1213
1203
  }
1214
1204
  .modal-actions button:hover { background: var(--border); }
1215
1205
  .modal-actions button.primary { background: var(--accent); color: var(--text-inverse); border-color: var(--accent); }
1206
+ .image-attach-area { margin: 12px 0; }
1207
+ .image-attach-area label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
1208
+ .image-attach-area.image-attach-small { margin: 8px 0; }
1209
+ .image-attach-area.image-attach-small label { font-size: 11px; }
1210
+ .image-preview-list { display: flex; flex-wrap: wrap; gap: 8px; min-height: 24px; }
1211
+ .image-preview-item { position: relative; }
1212
+ .image-preview-item img { max-width: 80px; max-height: 60px; border-radius: 4px; border: 1px solid var(--border); object-fit: cover; }
1213
+ .image-preview-item .remove-image { position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; border-radius: 50%; background: var(--error, #ef4444); color: #fff; border: none; cursor: pointer; font-size: 12px; line-height: 1; display: flex; align-items: center; justify-content: center; }
1214
+ .image-preview-item .remove-image:hover { background: #dc2626; }
1216
1215
 
1217
1216
  .modal-checkboxes { margin: 12px 0; }
1218
1217
  .modal-checkboxes label {
@@ -1458,7 +1457,7 @@ function diffHtmlTemplate(diffData, history = []) {
1458
1457
  <div class="meta">
1459
1458
  <h1>${projectRoot ? `<span class="title-path">${projectRoot}</span>` : ""}<span class="title-file">${relativePath}</span></h1>
1460
1459
  <span class="badge">${fileCount} file${fileCount !== 1 ? "s" : ""} changed</span>
1461
- <span class="pill">Comments <strong id="comment-count">0</strong></span>
1460
+ <button class="pill" id="pill-comments" title="Toggle comment panel">Comments <strong id="comment-count">0</strong></button>
1462
1461
  </div>
1463
1462
  <div class="actions">
1464
1463
  <button class="history-toggle" id="history-toggle" title="Review History">☰</button>
@@ -1492,17 +1491,20 @@ function diffHtmlTemplate(diffData, history = []) {
1492
1491
  </header>
1493
1492
  <div id="cell-preview" style="font-size:11px; color: var(--muted); margin-bottom:8px; white-space: pre-wrap; max-height: 60px; overflow: hidden;"></div>
1494
1493
  <textarea id="comment-input" placeholder="Enter your comment"></textarea>
1494
+ <div class="image-attach-area image-attach-small" id="comment-image-area">
1495
+ <label>📎 Image (⌘V, max 1)</label>
1496
+ <div class="image-preview-list" id="comment-image-preview"></div>
1497
+ </div>
1495
1498
  <div class="actions">
1496
1499
  <button class="primary" id="save-comment">Save</button>
1497
1500
  </div>
1498
1501
  </div>
1499
1502
 
1500
- <aside class="comment-list">
1503
+ <aside class="comment-list collapsed">
1501
1504
  <h3>Comments</h3>
1502
1505
  <ol id="comment-list"></ol>
1503
1506
  <p class="hint">Click "Submit & Exit" to finish review.</p>
1504
1507
  </aside>
1505
- <button class="comment-toggle" id="comment-toggle">Comments (0)</button>
1506
1508
 
1507
1509
  <div class="modal-overlay" id="submit-modal">
1508
1510
  <div class="modal-dialog">
@@ -1510,6 +1512,10 @@ function diffHtmlTemplate(diffData, history = []) {
1510
1512
  <p class="modal-summary" id="modal-summary"></p>
1511
1513
  <label for="global-comment">Overall comment (optional)</label>
1512
1514
  <textarea id="global-comment" placeholder="Add a summary or overall feedback..."></textarea>
1515
+ <div class="image-attach-area" id="submit-image-area">
1516
+ <label>📎 Attach images (⌘V to paste, max 5)</label>
1517
+ <div class="image-preview-list" id="submit-image-preview"></div>
1518
+ </div>
1513
1519
  <div class="modal-checkboxes">
1514
1520
  <label><input type="checkbox" id="prompt-subagents" checked /> 🤖 Delegate to sub-agents (implement, verify, report)</label>
1515
1521
  <label><input type="checkbox" id="prompt-reviw" checked /> 👁️ Open in REVIW next time</label>
@@ -1716,7 +1722,7 @@ function diffHtmlTemplate(diffData, history = []) {
1716
1722
  const commentList = document.getElementById('comment-list');
1717
1723
  const commentCount = document.getElementById('comment-count');
1718
1724
  const commentPanel = document.querySelector('.comment-list');
1719
- const commentToggle = document.getElementById('comment-toggle');
1725
+ const pillComments = document.getElementById('pill-comments');
1720
1726
 
1721
1727
  const comments = {};
1722
1728
  let currentKey = null;
@@ -1726,6 +1732,81 @@ function diffHtmlTemplate(diffData, history = []) {
1726
1732
  let dragEnd = null;
1727
1733
  let selection = null;
1728
1734
 
1735
+ // Image attachment state
1736
+ const submitImages = []; // base64 images for submit modal (max 5)
1737
+ let currentCommentImage = null; // base64 image for current comment (max 1)
1738
+
1739
+ // Image attachment handlers
1740
+ const submitImagePreview = document.getElementById('submit-image-preview');
1741
+ const commentImagePreview = document.getElementById('comment-image-preview');
1742
+
1743
+ function addImageToPreview(container, images, maxCount, base64) {
1744
+ if (images.length >= maxCount) return;
1745
+ images.push(base64);
1746
+ renderImagePreviews(container, images);
1747
+ }
1748
+
1749
+ function renderImagePreviews(container, images) {
1750
+ container.innerHTML = '';
1751
+ images.forEach((base64, idx) => {
1752
+ const item = document.createElement('div');
1753
+ item.className = 'image-preview-item';
1754
+ item.innerHTML = \`<img src="\${base64}" alt="attached image"><button class="remove-image" data-idx="\${idx}">×</button>\`;
1755
+ item.querySelector('.remove-image').addEventListener('click', () => {
1756
+ images.splice(idx, 1);
1757
+ renderImagePreviews(container, images);
1758
+ });
1759
+ container.appendChild(item);
1760
+ });
1761
+ }
1762
+
1763
+ function renderCommentImagePreview() {
1764
+ commentImagePreview.innerHTML = '';
1765
+ if (currentCommentImage) {
1766
+ const item = document.createElement('div');
1767
+ item.className = 'image-preview-item';
1768
+ item.innerHTML = \`<img src="\${currentCommentImage}" alt="attached image"><button class="remove-image">×</button>\`;
1769
+ item.querySelector('.remove-image').addEventListener('click', () => {
1770
+ currentCommentImage = null;
1771
+ renderCommentImagePreview();
1772
+ });
1773
+ commentImagePreview.appendChild(item);
1774
+ }
1775
+ }
1776
+
1777
+ // Global paste handler
1778
+ document.addEventListener('paste', (e) => {
1779
+ const items = e.clipboardData?.items;
1780
+ if (!items) return;
1781
+ for (const item of items) {
1782
+ if (item.type.startsWith('image/')) {
1783
+ e.preventDefault();
1784
+ const file = item.getAsFile();
1785
+ const reader = new FileReader();
1786
+ reader.onload = () => {
1787
+ const base64 = reader.result;
1788
+ const activeEl = document.activeElement;
1789
+ // Prioritize comment card if its textarea has focus
1790
+ if (card.style.display !== 'none' && activeEl === commentInput) {
1791
+ if (!currentCommentImage) {
1792
+ currentCommentImage = base64;
1793
+ renderCommentImagePreview();
1794
+ }
1795
+ } else if (submitModal.classList.contains('visible')) {
1796
+ addImageToPreview(submitImagePreview, submitImages, 5, base64);
1797
+ } else if (card.style.display !== 'none') {
1798
+ if (!currentCommentImage) {
1799
+ currentCommentImage = base64;
1800
+ renderCommentImagePreview();
1801
+ }
1802
+ }
1803
+ };
1804
+ reader.readAsDataURL(file);
1805
+ break;
1806
+ }
1807
+ }
1808
+ });
1809
+
1729
1810
  function makeKey(start, end) {
1730
1811
  return start === end ? String(start) : (start + '-' + end);
1731
1812
  }
@@ -1947,7 +2028,6 @@ function diffHtmlTemplate(diffData, history = []) {
1947
2028
  commentList.innerHTML = '';
1948
2029
  const items = Object.values(comments).sort((a, b) => (a.startRow ?? a.row) - (b.startRow ?? b.row));
1949
2030
  commentCount.textContent = items.length;
1950
- commentToggle.textContent = 'Comments (' + items.length + ')';
1951
2031
  if (items.length === 0) panelOpen = false;
1952
2032
  commentPanel.classList.toggle('collapsed', !panelOpen || items.length === 0);
1953
2033
  if (!items.length) {
@@ -1968,7 +2048,7 @@ function diffHtmlTemplate(diffData, history = []) {
1968
2048
  });
1969
2049
  }
1970
2050
 
1971
- commentToggle.addEventListener('click', () => {
2051
+ pillComments.addEventListener('click', () => {
1972
2052
  panelOpen = !panelOpen;
1973
2053
  if (panelOpen && Object.keys(comments).length === 0) panelOpen = false;
1974
2054
  commentPanel.classList.toggle('collapsed', !panelOpen);
@@ -1980,7 +2060,7 @@ function diffHtmlTemplate(diffData, history = []) {
1980
2060
  const range = keyToRange(currentKey);
1981
2061
  if (!range) return;
1982
2062
  const rowIdx = range.start;
1983
- if (text) {
2063
+ if (text || currentCommentImage) {
1984
2064
  if (range.start === range.end) {
1985
2065
  comments[currentKey] = { row: rowIdx, text, content: DATA[rowIdx]?.content || '' };
1986
2066
  } else {
@@ -1992,11 +2072,16 @@ function diffHtmlTemplate(diffData, history = []) {
1992
2072
  content: DATA.slice(range.start, range.end + 1).map(r => r?.content || '').join('\\n')
1993
2073
  };
1994
2074
  }
2075
+ if (currentCommentImage) {
2076
+ comments[currentKey].image = currentCommentImage;
2077
+ }
1995
2078
  setDotRange(range.start, range.end, true);
1996
2079
  } else {
1997
2080
  delete comments[currentKey];
1998
2081
  setDotRange(range.start, range.end, false);
1999
2082
  }
2083
+ currentCommentImage = null;
2084
+ renderCommentImagePreview();
2000
2085
  refreshList();
2001
2086
  closeCard();
2002
2087
  saveToStorage();
@@ -2112,17 +2197,28 @@ function diffHtmlTemplate(diffData, history = []) {
2112
2197
  function payload(reason) {
2113
2198
  const data = { file: FILE_NAME, mode: MODE, submittedBy: reason, submittedAt: new Date().toISOString(), comments: Object.values(comments) };
2114
2199
  if (globalComment.trim()) data.summary = globalComment.trim();
2200
+ if (submitImages.length > 0) data.summaryImages = submitImages;
2115
2201
  const prompts = getSelectedPrompts();
2116
2202
  if (prompts.length > 0) data.prompts = prompts;
2117
2203
  return data;
2118
2204
  }
2119
- function sendAndExit(reason = 'button') {
2205
+ async function sendAndExit(reason = 'button') {
2120
2206
  if (sent) return;
2121
2207
  sent = true;
2122
2208
  clearStorage();
2123
2209
  const p = payload(reason);
2124
2210
  saveToHistory(p);
2125
- navigator.sendBeacon('/exit', new Blob([JSON.stringify(p)], { type: 'application/json' }));
2211
+ try {
2212
+ // Use fetch with keepalive to handle large payloads (images)
2213
+ await fetch('/exit', {
2214
+ method: 'POST',
2215
+ headers: { 'Content-Type': 'application/json' },
2216
+ body: JSON.stringify(p),
2217
+ // Note: keepalive has 64KB limit like sendBeacon, so we don't use it for large payloads
2218
+ });
2219
+ } catch (err) {
2220
+ console.error('Failed to send exit request:', err);
2221
+ }
2126
2222
  }
2127
2223
  function showSubmitModal() {
2128
2224
  const count = Object.keys(comments).length;
@@ -2134,11 +2230,11 @@ function diffHtmlTemplate(diffData, history = []) {
2134
2230
  function hideSubmitModal() { submitModal.classList.remove('visible'); }
2135
2231
  document.getElementById('send-and-exit').addEventListener('click', showSubmitModal);
2136
2232
  document.getElementById('modal-cancel').addEventListener('click', hideSubmitModal);
2137
- function doSubmit() {
2233
+ async function doSubmit() {
2138
2234
  globalComment = globalCommentInput.value;
2139
2235
  savePromptPrefs();
2140
2236
  hideSubmitModal();
2141
- sendAndExit('button');
2237
+ await sendAndExit('button');
2142
2238
  // Try to close window; if it fails (browser security), show completion message
2143
2239
  setTimeout(() => {
2144
2240
  window.close();
@@ -2343,7 +2439,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
2343
2439
  }
2344
2440
  .theme-toggle:hover { background: var(--hover-bg); transform: scale(1.05); }
2345
2441
 
2346
- .wrap { padding: 12px 16px 40px; }
2442
+ .wrap { padding: 12px 16px 12px; }
2347
2443
  .toolbar {
2348
2444
  display: flex;
2349
2445
  gap: 12px;
@@ -2607,27 +2703,14 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
2607
2703
  }
2608
2704
  .comment-list li { margin-bottom: 6px; }
2609
2705
  .comment-list .hint { color: var(--muted); font-size: 12px; }
2610
- .pill { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border-radius: 999px; background: var(--selected-bg); border: 1px solid var(--border); font-size: 12px; color: var(--text); }
2706
+ .pill { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border-radius: 999px; background: var(--selected-bg); border: 1px solid var(--border); font-size: 12px; color: var(--text); cursor: pointer; transition: background 150ms ease, border-color 150ms ease; }
2707
+ .pill:hover { background: var(--hover-bg); border-color: var(--accent); }
2611
2708
  .pill strong { color: var(--text); font-weight: 700; }
2612
2709
  .comment-list.collapsed {
2613
2710
  opacity: 0;
2614
2711
  pointer-events: none;
2615
2712
  transform: translateY(8px) scale(0.98);
2616
2713
  }
2617
- .comment-toggle {
2618
- position: fixed;
2619
- right: 14px;
2620
- bottom: 14px;
2621
- padding: 10px 12px;
2622
- border-radius: 10px;
2623
- border: 1px solid var(--border);
2624
- background: var(--selected-bg);
2625
- color: var(--text);
2626
- cursor: pointer;
2627
- box-shadow: 0 10px 24px var(--shadow-color);
2628
- font-size: 13px;
2629
- transition: background 200ms ease, border-color 200ms ease;
2630
- }
2631
2714
  .md-preview {
2632
2715
  background: var(--input-bg);
2633
2716
  border: 1px solid var(--border);
@@ -2641,13 +2724,14 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
2641
2724
  gap: 16px;
2642
2725
  align-items: stretch;
2643
2726
  margin-top: 8px;
2644
- height: calc(100vh - 140px);
2727
+ height: calc(100vh - 80px);
2645
2728
  }
2646
2729
  .md-left {
2647
2730
  flex: 1;
2648
2731
  min-width: 0;
2649
2732
  overflow-y: auto;
2650
2733
  overflow-x: hidden;
2734
+ overscroll-behavior: contain;
2651
2735
  }
2652
2736
  .md-left .md-preview {
2653
2737
  max-height: none;
@@ -2657,6 +2741,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
2657
2741
  min-width: 0;
2658
2742
  overflow-y: auto;
2659
2743
  overflow-x: auto;
2744
+ overscroll-behavior: contain;
2660
2745
  }
2661
2746
  .md-right .table-box {
2662
2747
  max-width: none;
@@ -3354,6 +3439,15 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3354
3439
  }
3355
3440
  .modal-actions button:hover { background: var(--hover-bg); }
3356
3441
  .modal-actions button.primary { background: var(--accent); color: var(--text-inverse); border-color: var(--accent); }
3442
+ .image-attach-area { margin: 12px 0; }
3443
+ .image-attach-area label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
3444
+ .image-attach-area.image-attach-small { margin: 8px 0; }
3445
+ .image-attach-area.image-attach-small label { font-size: 11px; }
3446
+ .image-preview-list { display: flex; flex-wrap: wrap; gap: 8px; min-height: 24px; }
3447
+ .image-preview-item { position: relative; }
3448
+ .image-preview-item img { max-width: 80px; max-height: 60px; border-radius: 4px; border: 1px solid var(--border); object-fit: cover; }
3449
+ .image-preview-item .remove-image { position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; border-radius: 50%; background: var(--error, #ef4444); color: #fff; border: none; cursor: pointer; font-size: 12px; line-height: 1; display: flex; align-items: center; justify-content: center; }
3450
+ .image-preview-item .remove-image:hover { background: #dc2626; }
3357
3451
  .modal-actions button.primary:hover { background: #7dd3fc; }
3358
3452
 
3359
3453
  .modal-checkboxes { margin: 12px 0; }
@@ -3686,7 +3780,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3686
3780
  <div class="meta">
3687
3781
  <h1><span class="title-path">${projectRoot}</span><span class="title-file">${relativePath}</span></h1>
3688
3782
  <span class="badge">Click to comment / ESC to cancel</span>
3689
- <span class="pill">Comments <strong id="comment-count">0</strong></span>
3783
+ <button class="pill" id="pill-comments" title="Toggle comment panel">Comments <strong id="comment-count">0</strong></button>
3690
3784
  </div>
3691
3785
  <div class="actions">
3692
3786
  <button class="history-toggle" id="history-toggle" title="Review History">☰</button>
@@ -3772,17 +3866,20 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3772
3866
  </header>
3773
3867
  <div id="cell-preview" style="font-size:12px; color: var(--muted); margin-bottom:8px;"></div>
3774
3868
  <textarea id="comment-input" placeholder="Enter your comment or note"></textarea>
3869
+ <div class="image-attach-area image-attach-small" id="comment-image-area">
3870
+ <label>📎 Image (⌘V, max 1)</label>
3871
+ <div class="image-preview-list" id="comment-image-preview"></div>
3872
+ </div>
3775
3873
  <div class="actions">
3776
3874
  <button class="primary" id="save-comment">Save</button>
3777
3875
  </div>
3778
3876
  </div>
3779
3877
 
3780
- <aside class="comment-list">
3878
+ <aside class="comment-list collapsed">
3781
3879
  <h3>Comments</h3>
3782
3880
  <ol id="comment-list"></ol>
3783
3881
  <p class="hint">Close the tab or click "Submit & Exit" to send comments and stop the server.</p>
3784
3882
  </aside>
3785
- <button class="comment-toggle" id="comment-toggle">Comments (0)</button>
3786
3883
  <div class="filter-menu" id="filter-menu">
3787
3884
  <label class="menu-check"><input type="checkbox" id="freeze-col-check" /> Freeze up to this column</label>
3788
3885
  <button data-action="not-empty">Rows where not empty</button>
@@ -3812,6 +3909,10 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3812
3909
  <p class="modal-summary" id="modal-summary"></p>
3813
3910
  <label for="global-comment">Overall comment (optional)</label>
3814
3911
  <textarea id="global-comment" placeholder="Add a summary or overall feedback..."></textarea>
3912
+ <div class="image-attach-area" id="submit-image-area">
3913
+ <label>📎 Attach images (⌘V to paste, max 5)</label>
3914
+ <div class="image-preview-list" id="submit-image-preview"></div>
3915
+ </div>
3815
3916
  <div class="modal-checkboxes">
3816
3917
  <label><input type="checkbox" id="prompt-subagents" checked /> 🤖 Delegate to sub-agents (implement, verify, report)</label>
3817
3918
  <label><input type="checkbox" id="prompt-reviw" checked /> 👁️ Open in REVIW next time</label>
@@ -4074,7 +4175,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4074
4175
  const commentCount = document.getElementById('comment-count');
4075
4176
  const fitBtn = document.getElementById('fit-width');
4076
4177
  const commentPanel = document.querySelector('.comment-list');
4077
- const commentToggle = document.getElementById('comment-toggle');
4178
+ const pillComments = document.getElementById('pill-comments');
4078
4179
  const filterMenu = document.getElementById('filter-menu');
4079
4180
  const rowMenu = document.getElementById('row-menu');
4080
4181
  const freezeColCheck = document.getElementById('freeze-col-check');
@@ -4144,6 +4245,85 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4144
4245
  let dragEnd = null; // {row, col}
4145
4246
  let selection = null; // {startRow, endRow, startCol, endCol}
4146
4247
 
4248
+ // Image attachment state
4249
+ const submitImages = []; // base64 images for submit modal (max 5)
4250
+ let currentCommentImage = null; // base64 image for current comment (max 1)
4251
+
4252
+ // Image attachment handlers
4253
+ const submitImagePreview = document.getElementById('submit-image-preview');
4254
+ const commentImagePreview = document.getElementById('comment-image-preview');
4255
+
4256
+ function addImageToPreview(container, images, maxCount, base64) {
4257
+ if (images.length >= maxCount) return;
4258
+ images.push(base64);
4259
+ renderImagePreviews(container, images);
4260
+ }
4261
+
4262
+ function renderImagePreviews(container, images) {
4263
+ container.innerHTML = '';
4264
+ images.forEach((base64, idx) => {
4265
+ const item = document.createElement('div');
4266
+ item.className = 'image-preview-item';
4267
+ item.innerHTML = \`<img src="\${base64}" alt="attached image"><button class="remove-image" data-idx="\${idx}">×</button>\`;
4268
+ item.querySelector('.remove-image').addEventListener('click', () => {
4269
+ images.splice(idx, 1);
4270
+ renderImagePreviews(container, images);
4271
+ });
4272
+ container.appendChild(item);
4273
+ });
4274
+ }
4275
+
4276
+ function renderCommentImagePreview() {
4277
+ commentImagePreview.innerHTML = '';
4278
+ if (currentCommentImage) {
4279
+ const item = document.createElement('div');
4280
+ item.className = 'image-preview-item';
4281
+ item.innerHTML = \`<img src="\${currentCommentImage}" alt="attached image"><button class="remove-image">×</button>\`;
4282
+ item.querySelector('.remove-image').addEventListener('click', () => {
4283
+ currentCommentImage = null;
4284
+ renderCommentImagePreview();
4285
+ });
4286
+ commentImagePreview.appendChild(item);
4287
+ }
4288
+ }
4289
+
4290
+ // Global paste handler for images
4291
+ document.addEventListener('paste', (e) => {
4292
+ const items = e.clipboardData?.items;
4293
+ if (!items) return;
4294
+ for (const item of items) {
4295
+ if (item.type.startsWith('image/')) {
4296
+ e.preventDefault();
4297
+ const file = item.getAsFile();
4298
+ const reader = new FileReader();
4299
+ reader.onload = () => {
4300
+ const base64 = reader.result;
4301
+ const modal = document.getElementById('submit-modal');
4302
+ const commentCard = document.getElementById('comment-card');
4303
+ const activeEl = document.activeElement;
4304
+ const commentInput = document.getElementById('comment-input');
4305
+
4306
+ // Prioritize comment card if its textarea has focus
4307
+ if (commentCard?.style.display !== 'none' && activeEl === commentInput) {
4308
+ if (!currentCommentImage) {
4309
+ currentCommentImage = base64;
4310
+ renderCommentImagePreview();
4311
+ }
4312
+ } else if (modal?.classList.contains('visible')) {
4313
+ addImageToPreview(submitImagePreview, submitImages, 5, base64);
4314
+ } else if (commentCard?.style.display !== 'none') {
4315
+ if (!currentCommentImage) {
4316
+ currentCommentImage = base64;
4317
+ renderCommentImagePreview();
4318
+ }
4319
+ }
4320
+ };
4321
+ reader.readAsDataURL(file);
4322
+ break;
4323
+ }
4324
+ }
4325
+ });
4326
+
4147
4327
  // --- localStorage Comment Persistence ---
4148
4328
  const STORAGE_KEY = 'reviw:comments:' + FILE_NAME;
4149
4329
  const STORAGE_TTL = 3 * 60 * 60 * 1000; // 3 hours in milliseconds
@@ -4566,7 +4746,6 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4566
4746
  return aRow === bRow ? aCol - bCol : aRow - bRow;
4567
4747
  });
4568
4748
  commentCount.textContent = items.length;
4569
- commentToggle.textContent = 'Comments (' + items.length + ')';
4570
4749
  if (items.length === 0) {
4571
4750
  panelOpen = false;
4572
4751
  }
@@ -4608,7 +4787,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4608
4787
  });
4609
4788
  }
4610
4789
 
4611
- commentToggle.addEventListener('click', () => {
4790
+ pillComments.addEventListener('click', () => {
4612
4791
  panelOpen = !panelOpen;
4613
4792
  if (panelOpen && Object.keys(comments).length === 0) {
4614
4793
  panelOpen = false; // keep hidden if no comments
@@ -4745,8 +4924,11 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4745
4924
  if (isRangeKey(currentKey)) {
4746
4925
  // Range (rectangular) comment
4747
4926
  const { startRow, startCol, endRow, endCol } = parseRangeKey(currentKey);
4748
- if (text) {
4927
+ if (text || currentCommentImage) {
4749
4928
  comments[currentKey] = { startRow, startCol, endRow, endCol, text, isRange: true };
4929
+ if (currentCommentImage) {
4930
+ comments[currentKey].image = currentCommentImage;
4931
+ }
4750
4932
  for (let r = startRow; r <= endRow; r++) {
4751
4933
  for (let c = startCol; c <= endCol; c++) {
4752
4934
  setDot(r, c, true);
@@ -4768,14 +4950,19 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4768
4950
  const [row, col] = currentKey.split('-').map(Number);
4769
4951
  const td = tbody.querySelector('td[data-row="' + row + '"][data-col="' + col + '"]');
4770
4952
  const value = td ? td.textContent : '';
4771
- if (text) {
4953
+ if (text || currentCommentImage) {
4772
4954
  comments[currentKey] = { row, col, text, value };
4955
+ if (currentCommentImage) {
4956
+ comments[currentKey].image = currentCommentImage;
4957
+ }
4773
4958
  setDot(row, col, true);
4774
4959
  } else {
4775
4960
  delete comments[currentKey];
4776
4961
  setDot(row, col, false);
4777
4962
  }
4778
4963
  }
4964
+ currentCommentImage = null;
4965
+ renderCommentImagePreview();
4779
4966
  refreshList();
4780
4967
  closeCard();
4781
4968
  saveCommentsToStorage();
@@ -5059,6 +5246,10 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
5059
5246
  if (c.isRange) {
5060
5247
  transformed.lineEnd = (c.endRow || c.startRow) + 1;
5061
5248
  }
5249
+ // Preserve image attachment
5250
+ if (c.image) {
5251
+ transformed.image = c.image;
5252
+ }
5062
5253
  return transformed;
5063
5254
  });
5064
5255
  }
@@ -5079,6 +5270,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
5079
5270
  if (globalComment.trim()) {
5080
5271
  data.summary = globalComment.trim();
5081
5272
  }
5273
+ if (submitImages.length > 0) data.summaryImages = submitImages;
5082
5274
  const prompts = getSelectedPrompts();
5083
5275
  if (prompts.length > 0) data.prompts = prompts;
5084
5276
  // Include answered questions
@@ -5099,14 +5291,24 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
5099
5291
  }
5100
5292
  return data;
5101
5293
  }
5102
- function sendAndExit(reason = 'pagehide') {
5294
+ async function sendAndExit(reason = 'pagehide') {
5103
5295
  if (sent) return;
5104
5296
  sent = true;
5105
5297
  clearCommentsFromStorage();
5106
5298
  const p = payload(reason);
5107
5299
  saveToHistory(p);
5108
- const blob = new Blob([JSON.stringify(p)], { type: 'application/json' });
5109
- navigator.sendBeacon('/exit', blob);
5300
+ try {
5301
+ // Use fetch with keepalive to handle large payloads (images)
5302
+ // keepalive allows the request to outlive the page
5303
+ await fetch('/exit', {
5304
+ method: 'POST',
5305
+ headers: { 'Content-Type': 'application/json' },
5306
+ body: JSON.stringify(p),
5307
+ // Note: keepalive has 64KB limit like sendBeacon, so we don't use it for large payloads
5308
+ });
5309
+ } catch (err) {
5310
+ console.error('Failed to send exit request:', err);
5311
+ }
5110
5312
  }
5111
5313
  function showSubmitModal() {
5112
5314
  const count = Object.keys(comments).length;
@@ -5122,11 +5324,11 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
5122
5324
  }
5123
5325
  document.getElementById('send-and-exit').addEventListener('click', showSubmitModal);
5124
5326
  modalCancel.addEventListener('click', hideSubmitModal);
5125
- function doSubmit() {
5327
+ async function doSubmit() {
5126
5328
  globalComment = globalCommentInput.value;
5127
5329
  savePromptPrefs();
5128
5330
  hideSubmitModal();
5129
- sendAndExit('button');
5331
+ await sendAndExit('button');
5130
5332
  // Try to close window; if it fails (browser security), show completion message
5131
5333
  setTimeout(() => {
5132
5334
  window.close();
@@ -5918,8 +6120,46 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
5918
6120
  .trim();
5919
6121
  }
5920
6122
 
5921
- // Helper: find matching source line for text
5922
- function findSourceLine(text) {
6123
+ // Helper: find matching source line for text or element
6124
+ // If element is provided, also searches by media src attributes
6125
+ function findSourceLine(text, element = null) {
6126
+ // First, try to find by media src (images, videos) in the element
6127
+ if (element) {
6128
+ const mediaElements = element.querySelectorAll('img, video');
6129
+ for (const m of mediaElements) {
6130
+ const src = m.getAttribute('src');
6131
+ if (!src) continue;
6132
+
6133
+ const fileName = src.split('/').pop();
6134
+ const alt = m.getAttribute('alt') || m.getAttribute('data-alt') || m.getAttribute('title') || '';
6135
+
6136
+ // Search for lines containing this media file (![...](path) syntax)
6137
+ // Prioritize exact match with alt text
6138
+ let bestMatch = -1;
6139
+ for (let i = 0; i < DATA.length; i++) {
6140
+ const lineText = (DATA[i][0] || '');
6141
+ if (!lineText.includes(fileName)) continue;
6142
+
6143
+ // Check if it's an image/video markdown syntax
6144
+ const match = lineText.match(/!\\[([^\\]]*)\\]\\(([^)]+)\\)/);
6145
+ if (!match) continue;
6146
+
6147
+ const [, mdAlt, mdPath] = match;
6148
+
6149
+ // Exact path match
6150
+ if (mdPath.includes(fileName)) {
6151
+ // If alt text matches exactly, this is definitely the right one
6152
+ if (alt && mdAlt && mdAlt === alt) {
6153
+ return i + 1;
6154
+ }
6155
+ // Otherwise, remember as fallback (prefer first match)
6156
+ if (bestMatch === -1) bestMatch = i + 1;
6157
+ }
6158
+ }
6159
+ if (bestMatch !== -1) return bestMatch;
6160
+ }
6161
+ }
6162
+
5923
6163
  if (!text) return -1;
5924
6164
  const normalized = text.trim().replace(/\\s+/g, ' ').slice(0, 100);
5925
6165
  if (!normalized) return -1;
@@ -6159,9 +6399,9 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
6159
6399
  const target = e.target.closest('p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th');
6160
6400
  if (!target) return;
6161
6401
 
6162
- // Use table-specific search for table cells
6402
+ // Use table-specific search for table cells, otherwise use element-aware search
6163
6403
  const isTableCell = target.tagName === 'TD' || target.tagName === 'TH';
6164
- const line = isTableCell ? findTableSourceLine(target.textContent) : findSourceLine(target.textContent);
6404
+ const line = isTableCell ? findTableSourceLine(target.textContent) : findSourceLine(target.textContent, target);
6165
6405
  if (line <= 0) return;
6166
6406
 
6167
6407
  e.preventDefault();
@@ -6496,6 +6736,79 @@ function removeLockFile(filePath) {
6496
6736
  }
6497
6737
  }
6498
6738
 
6739
+ // --- Image Saving Helper ---
6740
+ function saveBase64Image(base64Data, baseDir) {
6741
+ try {
6742
+ // Parse data URL: data:image/png;base64,iVBORw0K...
6743
+ const match = base64Data.match(/^data:image\/(\w+);base64,(.+)$/);
6744
+ if (!match) return null;
6745
+
6746
+ const ext = match[1] === 'jpeg' ? 'jpg' : match[1];
6747
+ const data = match[2];
6748
+
6749
+ // Create ./tmp/ directory if it doesn't exist
6750
+ const tmpDir = path.join(baseDir, 'tmp');
6751
+ if (!fs.existsSync(tmpDir)) {
6752
+ fs.mkdirSync(tmpDir, { recursive: true });
6753
+ }
6754
+
6755
+ // Generate unique filename using timestamp and random string
6756
+ const timestamp = Date.now();
6757
+ const random = crypto.randomBytes(4).toString('hex');
6758
+ const filename = `reviw-${timestamp}-${random}.${ext}`;
6759
+ const filepath = path.join(tmpDir, filename);
6760
+
6761
+ // Decode base64 and save to file
6762
+ const buffer = Buffer.from(data, 'base64');
6763
+ fs.writeFileSync(filepath, buffer);
6764
+
6765
+ // Return relative path (./tmp/filename)
6766
+ return `./tmp/${filename}`;
6767
+ } catch (err) {
6768
+ console.error('Failed to save image:', err);
6769
+ return null;
6770
+ }
6771
+ }
6772
+
6773
+ // Process payload images: save to disk and replace base64 with paths
6774
+ function processPayloadImages(payload, baseDir) {
6775
+ // Process summaryImages (images attached to summary)
6776
+ if (payload.summaryImages && Array.isArray(payload.summaryImages)) {
6777
+ const imagePaths = [];
6778
+ for (const base64 of payload.summaryImages) {
6779
+ const savedPath = saveBase64Image(base64, baseDir);
6780
+ if (savedPath) {
6781
+ imagePaths.push(savedPath);
6782
+ }
6783
+ }
6784
+ // Replace base64 array with file paths (keeps same key position)
6785
+ payload.summaryImages = imagePaths.length > 0 ? imagePaths : undefined;
6786
+ if (!payload.summaryImages) delete payload.summaryImages;
6787
+ }
6788
+
6789
+ // Process comment images
6790
+ if (payload.comments && Array.isArray(payload.comments)) {
6791
+ for (const comment of payload.comments) {
6792
+ if (comment.image) {
6793
+ const savedPath = saveBase64Image(comment.image, baseDir);
6794
+ if (savedPath) {
6795
+ comment.imagePath = savedPath;
6796
+ }
6797
+ delete comment.image; // Remove base64 data from comment
6798
+ }
6799
+ }
6800
+ }
6801
+
6802
+ // Add image reading instruction if any images are attached
6803
+ const hasCommentImages = payload.comments?.some(c => c.imagePath);
6804
+ const hasSummaryImages = payload.summaryImages?.length > 0;
6805
+ if (hasCommentImages || hasSummaryImages) {
6806
+ payload._imageReadingNote = "MANDATORY: You MUST read ALL images (imagePath and summaryImages) using the Read tool. Skipping image reading is PROHIBITED.";
6807
+ }
6808
+
6809
+ return payload;
6810
+ }
6811
+
6499
6812
  function checkExistingServer(filePath) {
6500
6813
  try {
6501
6814
  const lockPath = getLockFilePath(filePath);
@@ -6871,6 +7184,10 @@ function createFileServer(filePath, fileIndex = 0) {
6871
7184
  if (raw && raw.trim()) {
6872
7185
  payload = JSON.parse(raw);
6873
7186
  }
7187
+ // Process images: save to ./tmp/ and replace base64 with paths
7188
+ if (payload) {
7189
+ payload = processPayloadImages(payload, ctx.baseDir);
7190
+ }
6874
7191
  // Save to file-based history (only if there are comments)
6875
7192
  if (payload && (payload.comments?.length > 0 || payload.submitComment)) {
6876
7193
  saveHistoryToFile(ctx.filePath, payload);
@@ -7137,6 +7454,11 @@ function createDiffServer(diffContent) {
7137
7454
  if (raw && raw.trim()) {
7138
7455
  payload = JSON.parse(raw);
7139
7456
  }
7457
+ // Process images: save to ./tmp/ and replace base64 with paths
7458
+ // For diff mode, use current working directory
7459
+ if (payload) {
7460
+ payload = processPayloadImages(payload, process.cwd());
7461
+ }
7140
7462
  // Save to file-based history (only if there are comments)
7141
7463
  // For diff mode, use relativePath as identifier
7142
7464
  if (payload && (payload.comments?.length > 0 || payload.submitComment)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reviw",
3
- "version": "0.16.3",
3
+ "version": "0.17.1",
4
4
  "description": "Lightweight file reviewer with in-browser comments for CSV, TSV, Markdown, and Git diffs.",
5
5
  "type": "module",
6
6
  "bin": {