reviw 0.16.3 → 0.17.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.
Files changed (2) hide show
  1. package/cli.cjs +336 -55
  2. package/package.json +1 -1
package/cli.cjs CHANGED
@@ -946,7 +946,7 @@ function diffHtmlTemplate(diffData, history = []) {
946
946
  }
947
947
  .theme-toggle:hover { background: var(--border); }
948
948
 
949
- .wrap { padding: 16px 20px 60px; max-width: 1200px; margin: 0 auto; }
949
+ .wrap { padding: 16px 20px 16px; max-width: 1200px; margin: 0 auto; }
950
950
  .diff-container {
951
951
  background: var(--panel);
952
952
  border: 1px solid var(--border);
@@ -1140,19 +1140,8 @@ function diffHtmlTemplate(diffData, history = []) {
1140
1140
  .comment-list li:hover { color: var(--accent); }
1141
1141
  .comment-list .hint { color: var(--muted); font-size: 11px; margin-top: 8px; }
1142
1142
  .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; }
1143
+ .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; }
1144
+ .pill:hover { background: var(--hover-bg); border-color: var(--accent); }
1156
1145
  .pill strong { font-weight: 700; }
1157
1146
 
1158
1147
  .modal-overlay {
@@ -1213,6 +1202,15 @@ function diffHtmlTemplate(diffData, history = []) {
1213
1202
  }
1214
1203
  .modal-actions button:hover { background: var(--border); }
1215
1204
  .modal-actions button.primary { background: var(--accent); color: var(--text-inverse); border-color: var(--accent); }
1205
+ .image-attach-area { margin: 12px 0; }
1206
+ .image-attach-area label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
1207
+ .image-attach-area.image-attach-small { margin: 8px 0; }
1208
+ .image-attach-area.image-attach-small label { font-size: 11px; }
1209
+ .image-preview-list { display: flex; flex-wrap: wrap; gap: 8px; min-height: 24px; }
1210
+ .image-preview-item { position: relative; }
1211
+ .image-preview-item img { max-width: 80px; max-height: 60px; border-radius: 4px; border: 1px solid var(--border); object-fit: cover; }
1212
+ .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; }
1213
+ .image-preview-item .remove-image:hover { background: #dc2626; }
1216
1214
 
1217
1215
  .modal-checkboxes { margin: 12px 0; }
1218
1216
  .modal-checkboxes label {
@@ -1458,7 +1456,7 @@ function diffHtmlTemplate(diffData, history = []) {
1458
1456
  <div class="meta">
1459
1457
  <h1>${projectRoot ? `<span class="title-path">${projectRoot}</span>` : ""}<span class="title-file">${relativePath}</span></h1>
1460
1458
  <span class="badge">${fileCount} file${fileCount !== 1 ? "s" : ""} changed</span>
1461
- <span class="pill">Comments <strong id="comment-count">0</strong></span>
1459
+ <button class="pill" id="pill-comments" title="Toggle comment panel">Comments <strong id="comment-count">0</strong></button>
1462
1460
  </div>
1463
1461
  <div class="actions">
1464
1462
  <button class="history-toggle" id="history-toggle" title="Review History">☰</button>
@@ -1492,17 +1490,20 @@ function diffHtmlTemplate(diffData, history = []) {
1492
1490
  </header>
1493
1491
  <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
1492
  <textarea id="comment-input" placeholder="Enter your comment"></textarea>
1493
+ <div class="image-attach-area image-attach-small" id="comment-image-area">
1494
+ <label>📎 Image (⌘V, max 1)</label>
1495
+ <div class="image-preview-list" id="comment-image-preview"></div>
1496
+ </div>
1495
1497
  <div class="actions">
1496
1498
  <button class="primary" id="save-comment">Save</button>
1497
1499
  </div>
1498
1500
  </div>
1499
1501
 
1500
- <aside class="comment-list">
1502
+ <aside class="comment-list collapsed">
1501
1503
  <h3>Comments</h3>
1502
1504
  <ol id="comment-list"></ol>
1503
1505
  <p class="hint">Click "Submit & Exit" to finish review.</p>
1504
1506
  </aside>
1505
- <button class="comment-toggle" id="comment-toggle">Comments (0)</button>
1506
1507
 
1507
1508
  <div class="modal-overlay" id="submit-modal">
1508
1509
  <div class="modal-dialog">
@@ -1510,6 +1511,10 @@ function diffHtmlTemplate(diffData, history = []) {
1510
1511
  <p class="modal-summary" id="modal-summary"></p>
1511
1512
  <label for="global-comment">Overall comment (optional)</label>
1512
1513
  <textarea id="global-comment" placeholder="Add a summary or overall feedback..."></textarea>
1514
+ <div class="image-attach-area" id="submit-image-area">
1515
+ <label>📎 Attach images (⌘V to paste, max 5)</label>
1516
+ <div class="image-preview-list" id="submit-image-preview"></div>
1517
+ </div>
1513
1518
  <div class="modal-checkboxes">
1514
1519
  <label><input type="checkbox" id="prompt-subagents" checked /> 🤖 Delegate to sub-agents (implement, verify, report)</label>
1515
1520
  <label><input type="checkbox" id="prompt-reviw" checked /> 👁️ Open in REVIW next time</label>
@@ -1716,7 +1721,7 @@ function diffHtmlTemplate(diffData, history = []) {
1716
1721
  const commentList = document.getElementById('comment-list');
1717
1722
  const commentCount = document.getElementById('comment-count');
1718
1723
  const commentPanel = document.querySelector('.comment-list');
1719
- const commentToggle = document.getElementById('comment-toggle');
1724
+ const pillComments = document.getElementById('pill-comments');
1720
1725
 
1721
1726
  const comments = {};
1722
1727
  let currentKey = null;
@@ -1726,6 +1731,81 @@ function diffHtmlTemplate(diffData, history = []) {
1726
1731
  let dragEnd = null;
1727
1732
  let selection = null;
1728
1733
 
1734
+ // Image attachment state
1735
+ const submitImages = []; // base64 images for submit modal (max 5)
1736
+ let currentCommentImage = null; // base64 image for current comment (max 1)
1737
+
1738
+ // Image attachment handlers
1739
+ const submitImagePreview = document.getElementById('submit-image-preview');
1740
+ const commentImagePreview = document.getElementById('comment-image-preview');
1741
+
1742
+ function addImageToPreview(container, images, maxCount, base64) {
1743
+ if (images.length >= maxCount) return;
1744
+ images.push(base64);
1745
+ renderImagePreviews(container, images);
1746
+ }
1747
+
1748
+ function renderImagePreviews(container, images) {
1749
+ container.innerHTML = '';
1750
+ images.forEach((base64, idx) => {
1751
+ const item = document.createElement('div');
1752
+ item.className = 'image-preview-item';
1753
+ item.innerHTML = \`<img src="\${base64}" alt="attached image"><button class="remove-image" data-idx="\${idx}">×</button>\`;
1754
+ item.querySelector('.remove-image').addEventListener('click', () => {
1755
+ images.splice(idx, 1);
1756
+ renderImagePreviews(container, images);
1757
+ });
1758
+ container.appendChild(item);
1759
+ });
1760
+ }
1761
+
1762
+ function renderCommentImagePreview() {
1763
+ commentImagePreview.innerHTML = '';
1764
+ if (currentCommentImage) {
1765
+ const item = document.createElement('div');
1766
+ item.className = 'image-preview-item';
1767
+ item.innerHTML = \`<img src="\${currentCommentImage}" alt="attached image"><button class="remove-image">×</button>\`;
1768
+ item.querySelector('.remove-image').addEventListener('click', () => {
1769
+ currentCommentImage = null;
1770
+ renderCommentImagePreview();
1771
+ });
1772
+ commentImagePreview.appendChild(item);
1773
+ }
1774
+ }
1775
+
1776
+ // Global paste handler
1777
+ document.addEventListener('paste', (e) => {
1778
+ const items = e.clipboardData?.items;
1779
+ if (!items) return;
1780
+ for (const item of items) {
1781
+ if (item.type.startsWith('image/')) {
1782
+ e.preventDefault();
1783
+ const file = item.getAsFile();
1784
+ const reader = new FileReader();
1785
+ reader.onload = () => {
1786
+ const base64 = reader.result;
1787
+ const activeEl = document.activeElement;
1788
+ // Prioritize comment card if its textarea has focus
1789
+ if (card.style.display !== 'none' && activeEl === commentInput) {
1790
+ if (!currentCommentImage) {
1791
+ currentCommentImage = base64;
1792
+ renderCommentImagePreview();
1793
+ }
1794
+ } else if (submitModal.classList.contains('visible')) {
1795
+ addImageToPreview(submitImagePreview, submitImages, 5, base64);
1796
+ } else if (card.style.display !== 'none') {
1797
+ if (!currentCommentImage) {
1798
+ currentCommentImage = base64;
1799
+ renderCommentImagePreview();
1800
+ }
1801
+ }
1802
+ };
1803
+ reader.readAsDataURL(file);
1804
+ break;
1805
+ }
1806
+ }
1807
+ });
1808
+
1729
1809
  function makeKey(start, end) {
1730
1810
  return start === end ? String(start) : (start + '-' + end);
1731
1811
  }
@@ -1947,7 +2027,6 @@ function diffHtmlTemplate(diffData, history = []) {
1947
2027
  commentList.innerHTML = '';
1948
2028
  const items = Object.values(comments).sort((a, b) => (a.startRow ?? a.row) - (b.startRow ?? b.row));
1949
2029
  commentCount.textContent = items.length;
1950
- commentToggle.textContent = 'Comments (' + items.length + ')';
1951
2030
  if (items.length === 0) panelOpen = false;
1952
2031
  commentPanel.classList.toggle('collapsed', !panelOpen || items.length === 0);
1953
2032
  if (!items.length) {
@@ -1968,7 +2047,7 @@ function diffHtmlTemplate(diffData, history = []) {
1968
2047
  });
1969
2048
  }
1970
2049
 
1971
- commentToggle.addEventListener('click', () => {
2050
+ pillComments.addEventListener('click', () => {
1972
2051
  panelOpen = !panelOpen;
1973
2052
  if (panelOpen && Object.keys(comments).length === 0) panelOpen = false;
1974
2053
  commentPanel.classList.toggle('collapsed', !panelOpen);
@@ -1980,7 +2059,7 @@ function diffHtmlTemplate(diffData, history = []) {
1980
2059
  const range = keyToRange(currentKey);
1981
2060
  if (!range) return;
1982
2061
  const rowIdx = range.start;
1983
- if (text) {
2062
+ if (text || currentCommentImage) {
1984
2063
  if (range.start === range.end) {
1985
2064
  comments[currentKey] = { row: rowIdx, text, content: DATA[rowIdx]?.content || '' };
1986
2065
  } else {
@@ -1992,11 +2071,16 @@ function diffHtmlTemplate(diffData, history = []) {
1992
2071
  content: DATA.slice(range.start, range.end + 1).map(r => r?.content || '').join('\\n')
1993
2072
  };
1994
2073
  }
2074
+ if (currentCommentImage) {
2075
+ comments[currentKey].image = currentCommentImage;
2076
+ }
1995
2077
  setDotRange(range.start, range.end, true);
1996
2078
  } else {
1997
2079
  delete comments[currentKey];
1998
2080
  setDotRange(range.start, range.end, false);
1999
2081
  }
2082
+ currentCommentImage = null;
2083
+ renderCommentImagePreview();
2000
2084
  refreshList();
2001
2085
  closeCard();
2002
2086
  saveToStorage();
@@ -2112,17 +2196,28 @@ function diffHtmlTemplate(diffData, history = []) {
2112
2196
  function payload(reason) {
2113
2197
  const data = { file: FILE_NAME, mode: MODE, submittedBy: reason, submittedAt: new Date().toISOString(), comments: Object.values(comments) };
2114
2198
  if (globalComment.trim()) data.summary = globalComment.trim();
2199
+ if (submitImages.length > 0) data.summaryImages = submitImages;
2115
2200
  const prompts = getSelectedPrompts();
2116
2201
  if (prompts.length > 0) data.prompts = prompts;
2117
2202
  return data;
2118
2203
  }
2119
- function sendAndExit(reason = 'button') {
2204
+ async function sendAndExit(reason = 'button') {
2120
2205
  if (sent) return;
2121
2206
  sent = true;
2122
2207
  clearStorage();
2123
2208
  const p = payload(reason);
2124
2209
  saveToHistory(p);
2125
- navigator.sendBeacon('/exit', new Blob([JSON.stringify(p)], { type: 'application/json' }));
2210
+ try {
2211
+ // Use fetch with keepalive to handle large payloads (images)
2212
+ await fetch('/exit', {
2213
+ method: 'POST',
2214
+ headers: { 'Content-Type': 'application/json' },
2215
+ body: JSON.stringify(p),
2216
+ // Note: keepalive has 64KB limit like sendBeacon, so we don't use it for large payloads
2217
+ });
2218
+ } catch (err) {
2219
+ console.error('Failed to send exit request:', err);
2220
+ }
2126
2221
  }
2127
2222
  function showSubmitModal() {
2128
2223
  const count = Object.keys(comments).length;
@@ -2134,11 +2229,11 @@ function diffHtmlTemplate(diffData, history = []) {
2134
2229
  function hideSubmitModal() { submitModal.classList.remove('visible'); }
2135
2230
  document.getElementById('send-and-exit').addEventListener('click', showSubmitModal);
2136
2231
  document.getElementById('modal-cancel').addEventListener('click', hideSubmitModal);
2137
- function doSubmit() {
2232
+ async function doSubmit() {
2138
2233
  globalComment = globalCommentInput.value;
2139
2234
  savePromptPrefs();
2140
2235
  hideSubmitModal();
2141
- sendAndExit('button');
2236
+ await sendAndExit('button');
2142
2237
  // Try to close window; if it fails (browser security), show completion message
2143
2238
  setTimeout(() => {
2144
2239
  window.close();
@@ -2343,7 +2438,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
2343
2438
  }
2344
2439
  .theme-toggle:hover { background: var(--hover-bg); transform: scale(1.05); }
2345
2440
 
2346
- .wrap { padding: 12px 16px 40px; }
2441
+ .wrap { padding: 12px 16px 12px; }
2347
2442
  .toolbar {
2348
2443
  display: flex;
2349
2444
  gap: 12px;
@@ -2607,27 +2702,14 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
2607
2702
  }
2608
2703
  .comment-list li { margin-bottom: 6px; }
2609
2704
  .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); }
2705
+ .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; }
2706
+ .pill:hover { background: var(--hover-bg); border-color: var(--accent); }
2611
2707
  .pill strong { color: var(--text); font-weight: 700; }
2612
2708
  .comment-list.collapsed {
2613
2709
  opacity: 0;
2614
2710
  pointer-events: none;
2615
2711
  transform: translateY(8px) scale(0.98);
2616
2712
  }
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
2713
  .md-preview {
2632
2714
  background: var(--input-bg);
2633
2715
  border: 1px solid var(--border);
@@ -2641,7 +2723,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
2641
2723
  gap: 16px;
2642
2724
  align-items: stretch;
2643
2725
  margin-top: 8px;
2644
- height: calc(100vh - 140px);
2726
+ height: calc(100vh - 80px);
2645
2727
  }
2646
2728
  .md-left {
2647
2729
  flex: 1;
@@ -3354,6 +3436,15 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3354
3436
  }
3355
3437
  .modal-actions button:hover { background: var(--hover-bg); }
3356
3438
  .modal-actions button.primary { background: var(--accent); color: var(--text-inverse); border-color: var(--accent); }
3439
+ .image-attach-area { margin: 12px 0; }
3440
+ .image-attach-area label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
3441
+ .image-attach-area.image-attach-small { margin: 8px 0; }
3442
+ .image-attach-area.image-attach-small label { font-size: 11px; }
3443
+ .image-preview-list { display: flex; flex-wrap: wrap; gap: 8px; min-height: 24px; }
3444
+ .image-preview-item { position: relative; }
3445
+ .image-preview-item img { max-width: 80px; max-height: 60px; border-radius: 4px; border: 1px solid var(--border); object-fit: cover; }
3446
+ .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; }
3447
+ .image-preview-item .remove-image:hover { background: #dc2626; }
3357
3448
  .modal-actions button.primary:hover { background: #7dd3fc; }
3358
3449
 
3359
3450
  .modal-checkboxes { margin: 12px 0; }
@@ -3686,7 +3777,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3686
3777
  <div class="meta">
3687
3778
  <h1><span class="title-path">${projectRoot}</span><span class="title-file">${relativePath}</span></h1>
3688
3779
  <span class="badge">Click to comment / ESC to cancel</span>
3689
- <span class="pill">Comments <strong id="comment-count">0</strong></span>
3780
+ <button class="pill" id="pill-comments" title="Toggle comment panel">Comments <strong id="comment-count">0</strong></button>
3690
3781
  </div>
3691
3782
  <div class="actions">
3692
3783
  <button class="history-toggle" id="history-toggle" title="Review History">☰</button>
@@ -3772,17 +3863,20 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3772
3863
  </header>
3773
3864
  <div id="cell-preview" style="font-size:12px; color: var(--muted); margin-bottom:8px;"></div>
3774
3865
  <textarea id="comment-input" placeholder="Enter your comment or note"></textarea>
3866
+ <div class="image-attach-area image-attach-small" id="comment-image-area">
3867
+ <label>📎 Image (⌘V, max 1)</label>
3868
+ <div class="image-preview-list" id="comment-image-preview"></div>
3869
+ </div>
3775
3870
  <div class="actions">
3776
3871
  <button class="primary" id="save-comment">Save</button>
3777
3872
  </div>
3778
3873
  </div>
3779
3874
 
3780
- <aside class="comment-list">
3875
+ <aside class="comment-list collapsed">
3781
3876
  <h3>Comments</h3>
3782
3877
  <ol id="comment-list"></ol>
3783
3878
  <p class="hint">Close the tab or click "Submit & Exit" to send comments and stop the server.</p>
3784
3879
  </aside>
3785
- <button class="comment-toggle" id="comment-toggle">Comments (0)</button>
3786
3880
  <div class="filter-menu" id="filter-menu">
3787
3881
  <label class="menu-check"><input type="checkbox" id="freeze-col-check" /> Freeze up to this column</label>
3788
3882
  <button data-action="not-empty">Rows where not empty</button>
@@ -3812,6 +3906,10 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3812
3906
  <p class="modal-summary" id="modal-summary"></p>
3813
3907
  <label for="global-comment">Overall comment (optional)</label>
3814
3908
  <textarea id="global-comment" placeholder="Add a summary or overall feedback..."></textarea>
3909
+ <div class="image-attach-area" id="submit-image-area">
3910
+ <label>📎 Attach images (⌘V to paste, max 5)</label>
3911
+ <div class="image-preview-list" id="submit-image-preview"></div>
3912
+ </div>
3815
3913
  <div class="modal-checkboxes">
3816
3914
  <label><input type="checkbox" id="prompt-subagents" checked /> 🤖 Delegate to sub-agents (implement, verify, report)</label>
3817
3915
  <label><input type="checkbox" id="prompt-reviw" checked /> 👁️ Open in REVIW next time</label>
@@ -4074,7 +4172,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4074
4172
  const commentCount = document.getElementById('comment-count');
4075
4173
  const fitBtn = document.getElementById('fit-width');
4076
4174
  const commentPanel = document.querySelector('.comment-list');
4077
- const commentToggle = document.getElementById('comment-toggle');
4175
+ const pillComments = document.getElementById('pill-comments');
4078
4176
  const filterMenu = document.getElementById('filter-menu');
4079
4177
  const rowMenu = document.getElementById('row-menu');
4080
4178
  const freezeColCheck = document.getElementById('freeze-col-check');
@@ -4144,6 +4242,85 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4144
4242
  let dragEnd = null; // {row, col}
4145
4243
  let selection = null; // {startRow, endRow, startCol, endCol}
4146
4244
 
4245
+ // Image attachment state
4246
+ const submitImages = []; // base64 images for submit modal (max 5)
4247
+ let currentCommentImage = null; // base64 image for current comment (max 1)
4248
+
4249
+ // Image attachment handlers
4250
+ const submitImagePreview = document.getElementById('submit-image-preview');
4251
+ const commentImagePreview = document.getElementById('comment-image-preview');
4252
+
4253
+ function addImageToPreview(container, images, maxCount, base64) {
4254
+ if (images.length >= maxCount) return;
4255
+ images.push(base64);
4256
+ renderImagePreviews(container, images);
4257
+ }
4258
+
4259
+ function renderImagePreviews(container, images) {
4260
+ container.innerHTML = '';
4261
+ images.forEach((base64, idx) => {
4262
+ const item = document.createElement('div');
4263
+ item.className = 'image-preview-item';
4264
+ item.innerHTML = \`<img src="\${base64}" alt="attached image"><button class="remove-image" data-idx="\${idx}">×</button>\`;
4265
+ item.querySelector('.remove-image').addEventListener('click', () => {
4266
+ images.splice(idx, 1);
4267
+ renderImagePreviews(container, images);
4268
+ });
4269
+ container.appendChild(item);
4270
+ });
4271
+ }
4272
+
4273
+ function renderCommentImagePreview() {
4274
+ commentImagePreview.innerHTML = '';
4275
+ if (currentCommentImage) {
4276
+ const item = document.createElement('div');
4277
+ item.className = 'image-preview-item';
4278
+ item.innerHTML = \`<img src="\${currentCommentImage}" alt="attached image"><button class="remove-image">×</button>\`;
4279
+ item.querySelector('.remove-image').addEventListener('click', () => {
4280
+ currentCommentImage = null;
4281
+ renderCommentImagePreview();
4282
+ });
4283
+ commentImagePreview.appendChild(item);
4284
+ }
4285
+ }
4286
+
4287
+ // Global paste handler for images
4288
+ document.addEventListener('paste', (e) => {
4289
+ const items = e.clipboardData?.items;
4290
+ if (!items) return;
4291
+ for (const item of items) {
4292
+ if (item.type.startsWith('image/')) {
4293
+ e.preventDefault();
4294
+ const file = item.getAsFile();
4295
+ const reader = new FileReader();
4296
+ reader.onload = () => {
4297
+ const base64 = reader.result;
4298
+ const modal = document.getElementById('submit-modal');
4299
+ const commentCard = document.getElementById('comment-card');
4300
+ const activeEl = document.activeElement;
4301
+ const commentInput = document.getElementById('comment-input');
4302
+
4303
+ // Prioritize comment card if its textarea has focus
4304
+ if (commentCard?.style.display !== 'none' && activeEl === commentInput) {
4305
+ if (!currentCommentImage) {
4306
+ currentCommentImage = base64;
4307
+ renderCommentImagePreview();
4308
+ }
4309
+ } else if (modal?.classList.contains('visible')) {
4310
+ addImageToPreview(submitImagePreview, submitImages, 5, base64);
4311
+ } else if (commentCard?.style.display !== 'none') {
4312
+ if (!currentCommentImage) {
4313
+ currentCommentImage = base64;
4314
+ renderCommentImagePreview();
4315
+ }
4316
+ }
4317
+ };
4318
+ reader.readAsDataURL(file);
4319
+ break;
4320
+ }
4321
+ }
4322
+ });
4323
+
4147
4324
  // --- localStorage Comment Persistence ---
4148
4325
  const STORAGE_KEY = 'reviw:comments:' + FILE_NAME;
4149
4326
  const STORAGE_TTL = 3 * 60 * 60 * 1000; // 3 hours in milliseconds
@@ -4566,7 +4743,6 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4566
4743
  return aRow === bRow ? aCol - bCol : aRow - bRow;
4567
4744
  });
4568
4745
  commentCount.textContent = items.length;
4569
- commentToggle.textContent = 'Comments (' + items.length + ')';
4570
4746
  if (items.length === 0) {
4571
4747
  panelOpen = false;
4572
4748
  }
@@ -4608,7 +4784,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4608
4784
  });
4609
4785
  }
4610
4786
 
4611
- commentToggle.addEventListener('click', () => {
4787
+ pillComments.addEventListener('click', () => {
4612
4788
  panelOpen = !panelOpen;
4613
4789
  if (panelOpen && Object.keys(comments).length === 0) {
4614
4790
  panelOpen = false; // keep hidden if no comments
@@ -4745,8 +4921,11 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4745
4921
  if (isRangeKey(currentKey)) {
4746
4922
  // Range (rectangular) comment
4747
4923
  const { startRow, startCol, endRow, endCol } = parseRangeKey(currentKey);
4748
- if (text) {
4924
+ if (text || currentCommentImage) {
4749
4925
  comments[currentKey] = { startRow, startCol, endRow, endCol, text, isRange: true };
4926
+ if (currentCommentImage) {
4927
+ comments[currentKey].image = currentCommentImage;
4928
+ }
4750
4929
  for (let r = startRow; r <= endRow; r++) {
4751
4930
  for (let c = startCol; c <= endCol; c++) {
4752
4931
  setDot(r, c, true);
@@ -4768,14 +4947,19 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4768
4947
  const [row, col] = currentKey.split('-').map(Number);
4769
4948
  const td = tbody.querySelector('td[data-row="' + row + '"][data-col="' + col + '"]');
4770
4949
  const value = td ? td.textContent : '';
4771
- if (text) {
4950
+ if (text || currentCommentImage) {
4772
4951
  comments[currentKey] = { row, col, text, value };
4952
+ if (currentCommentImage) {
4953
+ comments[currentKey].image = currentCommentImage;
4954
+ }
4773
4955
  setDot(row, col, true);
4774
4956
  } else {
4775
4957
  delete comments[currentKey];
4776
4958
  setDot(row, col, false);
4777
4959
  }
4778
4960
  }
4961
+ currentCommentImage = null;
4962
+ renderCommentImagePreview();
4779
4963
  refreshList();
4780
4964
  closeCard();
4781
4965
  saveCommentsToStorage();
@@ -5059,6 +5243,10 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
5059
5243
  if (c.isRange) {
5060
5244
  transformed.lineEnd = (c.endRow || c.startRow) + 1;
5061
5245
  }
5246
+ // Preserve image attachment
5247
+ if (c.image) {
5248
+ transformed.image = c.image;
5249
+ }
5062
5250
  return transformed;
5063
5251
  });
5064
5252
  }
@@ -5079,6 +5267,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
5079
5267
  if (globalComment.trim()) {
5080
5268
  data.summary = globalComment.trim();
5081
5269
  }
5270
+ if (submitImages.length > 0) data.summaryImages = submitImages;
5082
5271
  const prompts = getSelectedPrompts();
5083
5272
  if (prompts.length > 0) data.prompts = prompts;
5084
5273
  // Include answered questions
@@ -5099,14 +5288,24 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
5099
5288
  }
5100
5289
  return data;
5101
5290
  }
5102
- function sendAndExit(reason = 'pagehide') {
5291
+ async function sendAndExit(reason = 'pagehide') {
5103
5292
  if (sent) return;
5104
5293
  sent = true;
5105
5294
  clearCommentsFromStorage();
5106
5295
  const p = payload(reason);
5107
5296
  saveToHistory(p);
5108
- const blob = new Blob([JSON.stringify(p)], { type: 'application/json' });
5109
- navigator.sendBeacon('/exit', blob);
5297
+ try {
5298
+ // Use fetch with keepalive to handle large payloads (images)
5299
+ // keepalive allows the request to outlive the page
5300
+ await fetch('/exit', {
5301
+ method: 'POST',
5302
+ headers: { 'Content-Type': 'application/json' },
5303
+ body: JSON.stringify(p),
5304
+ // Note: keepalive has 64KB limit like sendBeacon, so we don't use it for large payloads
5305
+ });
5306
+ } catch (err) {
5307
+ console.error('Failed to send exit request:', err);
5308
+ }
5110
5309
  }
5111
5310
  function showSubmitModal() {
5112
5311
  const count = Object.keys(comments).length;
@@ -5122,11 +5321,11 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
5122
5321
  }
5123
5322
  document.getElementById('send-and-exit').addEventListener('click', showSubmitModal);
5124
5323
  modalCancel.addEventListener('click', hideSubmitModal);
5125
- function doSubmit() {
5324
+ async function doSubmit() {
5126
5325
  globalComment = globalCommentInput.value;
5127
5326
  savePromptPrefs();
5128
5327
  hideSubmitModal();
5129
- sendAndExit('button');
5328
+ await sendAndExit('button');
5130
5329
  // Try to close window; if it fails (browser security), show completion message
5131
5330
  setTimeout(() => {
5132
5331
  window.close();
@@ -6496,6 +6695,79 @@ function removeLockFile(filePath) {
6496
6695
  }
6497
6696
  }
6498
6697
 
6698
+ // --- Image Saving Helper ---
6699
+ function saveBase64Image(base64Data, baseDir) {
6700
+ try {
6701
+ // Parse data URL: data:image/png;base64,iVBORw0K...
6702
+ const match = base64Data.match(/^data:image\/(\w+);base64,(.+)$/);
6703
+ if (!match) return null;
6704
+
6705
+ const ext = match[1] === 'jpeg' ? 'jpg' : match[1];
6706
+ const data = match[2];
6707
+
6708
+ // Create ./tmp/ directory if it doesn't exist
6709
+ const tmpDir = path.join(baseDir, 'tmp');
6710
+ if (!fs.existsSync(tmpDir)) {
6711
+ fs.mkdirSync(tmpDir, { recursive: true });
6712
+ }
6713
+
6714
+ // Generate unique filename using timestamp and random string
6715
+ const timestamp = Date.now();
6716
+ const random = crypto.randomBytes(4).toString('hex');
6717
+ const filename = `reviw-${timestamp}-${random}.${ext}`;
6718
+ const filepath = path.join(tmpDir, filename);
6719
+
6720
+ // Decode base64 and save to file
6721
+ const buffer = Buffer.from(data, 'base64');
6722
+ fs.writeFileSync(filepath, buffer);
6723
+
6724
+ // Return relative path (./tmp/filename)
6725
+ return `./tmp/${filename}`;
6726
+ } catch (err) {
6727
+ console.error('Failed to save image:', err);
6728
+ return null;
6729
+ }
6730
+ }
6731
+
6732
+ // Process payload images: save to disk and replace base64 with paths
6733
+ function processPayloadImages(payload, baseDir) {
6734
+ // Process summaryImages (images attached to summary)
6735
+ if (payload.summaryImages && Array.isArray(payload.summaryImages)) {
6736
+ const imagePaths = [];
6737
+ for (const base64 of payload.summaryImages) {
6738
+ const savedPath = saveBase64Image(base64, baseDir);
6739
+ if (savedPath) {
6740
+ imagePaths.push(savedPath);
6741
+ }
6742
+ }
6743
+ // Replace base64 array with file paths (keeps same key position)
6744
+ payload.summaryImages = imagePaths.length > 0 ? imagePaths : undefined;
6745
+ if (!payload.summaryImages) delete payload.summaryImages;
6746
+ }
6747
+
6748
+ // Process comment images
6749
+ if (payload.comments && Array.isArray(payload.comments)) {
6750
+ for (const comment of payload.comments) {
6751
+ if (comment.image) {
6752
+ const savedPath = saveBase64Image(comment.image, baseDir);
6753
+ if (savedPath) {
6754
+ comment.imagePath = savedPath;
6755
+ }
6756
+ delete comment.image; // Remove base64 data from comment
6757
+ }
6758
+ }
6759
+ }
6760
+
6761
+ // Add image reading instruction if any images are attached
6762
+ const hasCommentImages = payload.comments?.some(c => c.imagePath);
6763
+ const hasSummaryImages = payload.summaryImages?.length > 0;
6764
+ if (hasCommentImages || hasSummaryImages) {
6765
+ payload._imageReadingNote = "MANDATORY: You MUST read ALL images (imagePath and summaryImages) using the Read tool. Skipping image reading is PROHIBITED.";
6766
+ }
6767
+
6768
+ return payload;
6769
+ }
6770
+
6499
6771
  function checkExistingServer(filePath) {
6500
6772
  try {
6501
6773
  const lockPath = getLockFilePath(filePath);
@@ -6871,6 +7143,10 @@ function createFileServer(filePath, fileIndex = 0) {
6871
7143
  if (raw && raw.trim()) {
6872
7144
  payload = JSON.parse(raw);
6873
7145
  }
7146
+ // Process images: save to ./tmp/ and replace base64 with paths
7147
+ if (payload) {
7148
+ payload = processPayloadImages(payload, ctx.baseDir);
7149
+ }
6874
7150
  // Save to file-based history (only if there are comments)
6875
7151
  if (payload && (payload.comments?.length > 0 || payload.submitComment)) {
6876
7152
  saveHistoryToFile(ctx.filePath, payload);
@@ -7137,6 +7413,11 @@ function createDiffServer(diffContent) {
7137
7413
  if (raw && raw.trim()) {
7138
7414
  payload = JSON.parse(raw);
7139
7415
  }
7416
+ // Process images: save to ./tmp/ and replace base64 with paths
7417
+ // For diff mode, use current working directory
7418
+ if (payload) {
7419
+ payload = processPayloadImages(payload, process.cwd());
7420
+ }
7140
7421
  // Save to file-based history (only if there are comments)
7141
7422
  // For diff mode, use relativePath as identifier
7142
7423
  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.0",
4
4
  "description": "Lightweight file reviewer with in-browser comments for CSV, TSV, Markdown, and Git diffs.",
5
5
  "type": "module",
6
6
  "bin": {