reviw 0.11.3 → 0.13.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 (3) hide show
  1. package/README.md +3 -0
  2. package/cli.cjs +382 -91
  3. package/package.json +6 -6
package/README.md CHANGED
@@ -90,6 +90,9 @@ reviw changes.diff
90
90
  ### Mermaid Fullscreen
91
91
  ![Mermaid Fullscreen](https://raw.githubusercontent.com/kazuph/reviw/main/assets/screenshot-mermaid.png)
92
92
 
93
+ ### Submit Review Dialog
94
+ ![Submit Review Dialog](https://raw.githubusercontent.com/kazuph/reviw/main/assets/screenshot-submit-dialog.png)
95
+
93
96
  ## Output Example
94
97
 
95
98
  ```yaml
package/cli.cjs CHANGED
@@ -1254,6 +1254,7 @@ function diffHtmlTemplate(diffData) {
1254
1254
  <label><input type="checkbox" id="prompt-reviw" checked /> Open in REVIW next time.</label>
1255
1255
  <label><input type="checkbox" id="prompt-screenshots" checked /> Update all screenshots and videos.</label>
1256
1256
  <label><input type="checkbox" id="prompt-user-feedback-todo" checked /> Add the user's feedback to the Todo list, and do not check it off without the user's approval.</label>
1257
+ <label><input type="checkbox" id="prompt-deep-dive" checked /> Before implementing, deeply probe the user's request. If using Claude Code, start with AskUserQuestion and EnterPlanMode; otherwise achieve the same depth through interactive questions and planning, even if the UI differs.</label>
1257
1258
  </div>
1258
1259
  <div class="modal-actions">
1259
1260
  <button id="modal-cancel">Cancel</button>
@@ -1628,7 +1629,8 @@ function diffHtmlTemplate(diffData) {
1628
1629
  { id: 'prompt-subagents', text: 'All implementation, verification, and report creation will be done by the sub-agents.' },
1629
1630
  { id: 'prompt-reviw', text: 'Open in REVIW next time.' },
1630
1631
  { id: 'prompt-screenshots', text: 'Update all screenshots and videos.' },
1631
- { id: 'prompt-user-feedback-todo', text: "Add the user's feedback to the Todo list, and do not check it off without the user's approval." }
1632
+ { id: 'prompt-user-feedback-todo', text: "Add the user's feedback to the Todo list, and do not check it off without the user's approval." },
1633
+ { id: 'prompt-deep-dive', text: "Before implementing, deeply probe the user's request. If using Claude Code, start with AskUserQuestion and EnterPlanMode; otherwise achieve the same depth through interactive questions and planning, even if the UI differs." }
1632
1634
  ];
1633
1635
  const PROMPT_STORAGE_KEY = 'reviw-prompt-prefs';
1634
1636
 
@@ -3143,6 +3145,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3143
3145
  <label><input type="checkbox" id="prompt-reviw" checked /> Open in REVIW next time.</label>
3144
3146
  <label><input type="checkbox" id="prompt-screenshots" checked /> Update all screenshots and videos.</label>
3145
3147
  <label><input type="checkbox" id="prompt-user-feedback-todo" checked /> Add the user's feedback to the Todo list, and do not check it off without the user's approval.</label>
3148
+ <label><input type="checkbox" id="prompt-deep-dive" checked /> Before implementing, deeply probe the user's request. If using Claude Code, start with AskUserQuestion and EnterPlanMode; otherwise achieve the same depth through interactive questions and planning, even if the UI differs.</label>
3146
3149
  </div>
3147
3150
  <div class="modal-actions">
3148
3151
  <button id="modal-cancel">Cancel</button>
@@ -3563,7 +3566,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3563
3566
  }, 1500);
3564
3567
  }
3565
3568
 
3566
- function openCardForSelection() {
3569
+ function openCardForSelection(previewElement) {
3567
3570
  if (!selection) return;
3568
3571
  // Don't open card while image/video modal is visible
3569
3572
  const imageOverlay = document.getElementById('image-fullscreen');
@@ -3601,10 +3604,50 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3601
3604
  commentInput.value = existingComment?.text || '';
3602
3605
 
3603
3606
  card.style.display = 'block';
3607
+ // 常にソーステーブルの選択セル位置を基準にカードを配置
3608
+ // これにより、プレビューからクリックしてもソースからクリックしても
3609
+ // 同じ行に対しては同じ位置にダイアログが表示される
3604
3610
  positionCardForSelection(startRow, endRow, startCol, endCol);
3605
3611
  commentInput.focus();
3606
3612
  }
3607
3613
 
3614
+ // Position card near a clicked preview element (used when clicking from preview pane)
3615
+ // Note: Uses viewport-relative coordinates directly since .md-left/.md-right containers scroll,
3616
+ // not the document. The card uses position:absolute but with no positioned ancestor,
3617
+ // so it's positioned relative to the initial containing block.
3618
+ function positionCardNearElement(element) {
3619
+ const cardWidth = card.offsetWidth || 380;
3620
+ const cardHeight = card.offsetHeight || 220;
3621
+ const rect = element.getBoundingClientRect();
3622
+ const margin = 12;
3623
+ const vw = window.innerWidth;
3624
+ const vh = window.innerHeight;
3625
+
3626
+ // Use viewport-relative coordinates directly from getBoundingClientRect()
3627
+ // No need to add window.scrollX/Y since the containers scroll, not the document
3628
+ let left = rect.right + margin;
3629
+ let top = rect.top;
3630
+
3631
+ // If card would go off the right edge, position it below the element
3632
+ if (left + cardWidth > vw - margin) {
3633
+ left = Math.max(rect.left, margin);
3634
+ left = Math.min(left, vw - cardWidth - margin);
3635
+ top = rect.bottom + margin;
3636
+ }
3637
+
3638
+ // If card would go off the bottom, position it above the element
3639
+ if (top + cardHeight > vh - margin) {
3640
+ top = rect.top - cardHeight - margin;
3641
+ }
3642
+
3643
+ // Ensure card stays within viewport
3644
+ top = Math.max(margin, Math.min(top, vh - cardHeight - margin));
3645
+ left = Math.max(margin, Math.min(left, vw - cardWidth - margin));
3646
+
3647
+ card.style.left = left + 'px';
3648
+ card.style.top = top + 'px';
3649
+ }
3650
+
3608
3651
  function positionCardForSelection(startRow, endRow, startCol, endCol) {
3609
3652
  const cardWidth = card.offsetWidth || 380;
3610
3653
  const cardHeight = card.offsetHeight || 220;
@@ -3629,6 +3672,9 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3629
3672
  const sx = window.scrollX;
3630
3673
  const sy = window.scrollY;
3631
3674
 
3675
+ // Check if the selection is within viewport
3676
+ const isInViewport = rect.top >= 0 && rect.top < vh && rect.bottom > 0;
3677
+
3632
3678
  const spaceRight = vw - rect.right - margin;
3633
3679
  const spaceLeft = rect.left - margin - ROW_HEADER_WIDTH; // Account for row header
3634
3680
  const spaceBelow = vh - rect.bottom - margin;
@@ -3640,28 +3686,38 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3640
3686
  let left = sx + rect.right + margin;
3641
3687
  let top = sy + rect.top;
3642
3688
 
3643
- // Priority: right > below > above > left > fallback right
3644
- if (spaceRight >= cardWidth) {
3645
- // Prefer right side of selection
3646
- left = sx + rect.right + margin;
3647
- top = sy + clamp(rect.top, margin, vh - cardHeight - margin);
3648
- } else if (spaceBelow >= cardHeight) {
3649
- left = sx + clamp(rect.left, minLeft, vw - cardWidth - margin);
3650
- top = sy + rect.bottom + margin;
3651
- } else if (spaceAbove >= cardHeight) {
3652
- left = sx + clamp(rect.left, minLeft, vw - cardWidth - margin);
3653
- top = sy + rect.top - cardHeight - margin;
3654
- } else if (spaceLeft >= cardWidth) {
3655
- left = sx + rect.left - cardWidth - margin;
3656
- top = sy + clamp(rect.top, margin, vh - cardHeight - margin);
3689
+ // If selection is outside viewport, position card in center of viewport
3690
+ if (!isInViewport) {
3691
+ left = sx + Math.max(vw / 2 - cardWidth / 2, minLeft);
3692
+ top = sy + Math.max(vh / 2 - cardHeight / 2, margin);
3657
3693
  } else {
3658
- // Fallback: place to right side even if it means going off screen
3659
- // Position card at right edge of selection, clamped to viewport
3660
- left = sx + Math.max(rect.right + margin, minLeft);
3661
- left = Math.min(left, sx + vw - cardWidth - margin);
3662
- top = sy + clamp(rect.top, margin, vh - cardHeight - margin);
3694
+ // Priority: right > below > above > left > fallback right
3695
+ if (spaceRight >= cardWidth) {
3696
+ // Prefer right side of selection
3697
+ left = sx + rect.right + margin;
3698
+ top = sy + clamp(rect.top, margin, vh - cardHeight - margin);
3699
+ } else if (spaceBelow >= cardHeight) {
3700
+ left = sx + clamp(rect.left, minLeft, vw - cardWidth - margin);
3701
+ top = sy + rect.bottom + margin;
3702
+ } else if (spaceAbove >= cardHeight) {
3703
+ left = sx + clamp(rect.left, minLeft, vw - cardWidth - margin);
3704
+ top = sy + rect.top - cardHeight - margin;
3705
+ } else if (spaceLeft >= cardWidth) {
3706
+ left = sx + rect.left - cardWidth - margin;
3707
+ top = sy + clamp(rect.top, margin, vh - cardHeight - margin);
3708
+ } else {
3709
+ // Fallback: place to right side even if it means going off screen
3710
+ // Position card at right edge of selection, clamped to viewport
3711
+ left = sx + Math.max(rect.right + margin, minLeft);
3712
+ left = Math.min(left, sx + vw - cardWidth - margin);
3713
+ top = sy + clamp(rect.top, margin, vh - cardHeight - margin);
3714
+ }
3663
3715
  }
3664
3716
 
3717
+ // Final clamp to ensure card stays within viewport
3718
+ left = clamp(left, margin, sx + vw - cardWidth - margin);
3719
+ top = clamp(top, sy + margin, sy + vh - cardHeight - margin);
3720
+
3665
3721
  card.style.left = left + 'px';
3666
3722
  card.style.top = top + 'px';
3667
3723
  }
@@ -3670,6 +3726,8 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3670
3726
  card.style.display = 'none';
3671
3727
  currentKey = null;
3672
3728
  clearSelection();
3729
+ // Re-enable scroll sync when card is closed
3730
+ window._disableScrollSync = false;
3673
3731
  }
3674
3732
 
3675
3733
  function setDot(row, col, on) {
@@ -4081,7 +4139,8 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4081
4139
  { id: 'prompt-subagents', text: 'All implementation, verification, and report creation will be done by the sub-agents.' },
4082
4140
  { id: 'prompt-reviw', text: 'Open in REVIW next time.' },
4083
4141
  { id: 'prompt-screenshots', text: 'Update all screenshots and videos.' },
4084
- { id: 'prompt-user-feedback-todo', text: "Add the user's feedback to the Todo list, and do not check it off without the user's approval." }
4142
+ { id: 'prompt-user-feedback-todo', text: "Add the user's feedback to the Todo list, and do not check it off without the user's approval." },
4143
+ { id: 'prompt-deep-dive', text: "Before implementing, deeply probe the user's request. If using Claude Code, start with AskUserQuestion and EnterPlanMode; otherwise achieve the same depth through interactive questions and planning, even if the UI differs." }
4085
4144
  ];
4086
4145
  const PROMPT_STORAGE_KEY = 'reviw-prompt-prefs';
4087
4146
 
@@ -4273,20 +4332,56 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4273
4332
  })();
4274
4333
 
4275
4334
  // --- Scroll Sync for Markdown Mode ---
4335
+ // Global flag to temporarily disable scroll sync (used by selectSourceRange)
4336
+ window._disableScrollSync = false;
4337
+ // Global RAF ID so we can cancel pending scroll syncs from selectSourceRange
4338
+ window._scrollSyncRafId = null;
4339
+
4276
4340
  if (MODE === 'markdown') {
4277
4341
  const mdLeft = document.querySelector('.md-left');
4278
4342
  const mdRight = document.querySelector('.md-right');
4279
4343
  if (mdLeft && mdRight) {
4280
4344
  let activePane = null;
4281
- let rafId = null;
4345
+
4346
+ // Build anchor map for section-based sync
4347
+ // Maps line numbers to heading elements in preview
4348
+ const headingAnchors = [];
4349
+ const preview = document.querySelector('.md-preview');
4350
+ if (preview) {
4351
+ const headings = preview.querySelectorAll('h1, h2, h3, h4, h5, h6');
4352
+ headings.forEach(h => {
4353
+ const text = h.textContent.trim();
4354
+ // Find corresponding line in source
4355
+ for (let i = 0; i < DATA.length; i++) {
4356
+ const lineText = (DATA[i][0] || '').trim();
4357
+ if (lineText.match(/^#+\\s/) && lineText.replace(/^#+\\s*/, '').trim() === text) {
4358
+ headingAnchors.push({
4359
+ line: i + 1,
4360
+ sourceEl: mdLeft.querySelector('td[data-row="' + (i + 1) + '"]'),
4361
+ previewEl: h
4362
+ });
4363
+ break;
4364
+ }
4365
+ }
4366
+ });
4367
+ }
4282
4368
 
4283
4369
  function syncScroll(source, target, sourceName) {
4370
+ // Skip if scroll sync is temporarily disabled
4371
+ if (window._disableScrollSync) return;
4372
+ // Skip if scroll sync is disabled until a certain time (for preview click scroll)
4373
+ if (window._scrollSyncDisableUntil && Date.now() < window._scrollSyncDisableUntil) return;
4374
+
4284
4375
  // Only sync if this pane initiated the scroll
4285
4376
  if (activePane && activePane !== sourceName) return;
4286
4377
  activePane = sourceName;
4287
4378
 
4288
- if (rafId) cancelAnimationFrame(rafId);
4289
- rafId = requestAnimationFrame(() => {
4379
+ if (window._scrollSyncRafId) cancelAnimationFrame(window._scrollSyncRafId);
4380
+ window._scrollSyncRafId = requestAnimationFrame(() => {
4381
+ // Check again inside RAF in case _disableScrollSync was set after RAF was scheduled
4382
+ if (window._disableScrollSync) return;
4383
+ if (window._scrollSyncDisableUntil && Date.now() < window._scrollSyncDisableUntil) return;
4384
+
4290
4385
  const sourceMax = source.scrollHeight - source.clientHeight;
4291
4386
  const targetMax = target.scrollHeight - target.clientHeight;
4292
4387
 
@@ -4295,20 +4390,81 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4295
4390
  // Snap to edges for precision
4296
4391
  if (source.scrollTop <= 1) {
4297
4392
  target.scrollTop = 0;
4298
- } else if (source.scrollTop >= sourceMax - 1) {
4393
+ setTimeout(() => { activePane = null; }, 100);
4394
+ return;
4395
+ }
4396
+ if (source.scrollTop >= sourceMax - 1) {
4299
4397
  target.scrollTop = targetMax;
4300
- } else {
4301
- const ratio = source.scrollTop / sourceMax;
4302
- target.scrollTop = Math.round(ratio * targetMax);
4398
+ setTimeout(() => { activePane = null; }, 100);
4399
+ return;
4400
+ }
4401
+
4402
+ // Try section-based sync if anchors exist
4403
+ if (headingAnchors.length > 0) {
4404
+ const sourceRect = source.getBoundingClientRect();
4405
+ const viewportTop = sourceRect.top;
4406
+ const viewportMid = viewportTop + sourceRect.height / 3;
4407
+
4408
+ // Find the heading closest to viewport top in source
4409
+ let closestAnchor = null;
4410
+ let closestDistance = Infinity;
4411
+
4412
+ for (const anchor of headingAnchors) {
4413
+ const el = sourceName === 'left' ? anchor.sourceEl : anchor.previewEl;
4414
+ if (!el) continue;
4415
+ const rect = el.getBoundingClientRect();
4416
+ const distance = Math.abs(rect.top - viewportMid);
4417
+ if (distance < closestDistance) {
4418
+ closestDistance = distance;
4419
+ closestAnchor = anchor;
4420
+ }
4421
+ }
4422
+
4423
+ if (closestAnchor) {
4424
+ const sourceEl = sourceName === 'left' ? closestAnchor.sourceEl : closestAnchor.previewEl;
4425
+ const targetEl = sourceName === 'left' ? closestAnchor.previewEl : closestAnchor.sourceEl;
4426
+
4427
+ if (sourceEl && targetEl) {
4428
+ const sourceElRect = sourceEl.getBoundingClientRect();
4429
+ const sourceOffset = sourceElRect.top - sourceRect.top;
4430
+
4431
+ // Calculate where target element should be
4432
+ const targetRect = target.getBoundingClientRect();
4433
+ const targetElRect = targetEl.getBoundingClientRect();
4434
+ const currentTargetOffset = targetElRect.top - targetRect.top;
4435
+
4436
+ // Adjust target scroll to align the anchor
4437
+ const adjustment = currentTargetOffset - sourceOffset;
4438
+ target.scrollTop = target.scrollTop + adjustment;
4439
+
4440
+ setTimeout(() => { activePane = null; }, 100);
4441
+ return;
4442
+ }
4443
+ }
4303
4444
  }
4304
4445
 
4446
+ // Fallback to ratio-based sync
4447
+ const ratio = source.scrollTop / sourceMax;
4448
+ target.scrollTop = Math.round(ratio * targetMax);
4449
+
4305
4450
  // Release lock after scroll settles
4306
4451
  setTimeout(() => { activePane = null; }, 100);
4307
4452
  });
4308
4453
  }
4309
4454
 
4310
- mdLeft.addEventListener('scroll', () => syncScroll(mdLeft, mdRight, 'left'), { passive: true });
4311
- mdRight.addEventListener('scroll', () => syncScroll(mdRight, mdLeft, 'right'), { passive: true });
4455
+ // Store scroll handlers for temporary removal
4456
+ const leftScrollHandler = () => syncScroll(mdLeft, mdRight, 'left');
4457
+ const rightScrollHandler = () => syncScroll(mdRight, mdLeft, 'right');
4458
+ mdLeft.addEventListener('scroll', leftScrollHandler, { passive: true });
4459
+ mdRight.addEventListener('scroll', rightScrollHandler, { passive: true });
4460
+
4461
+ // Expose handlers for temporary removal during preview click
4462
+ window._scrollHandlers = {
4463
+ left: leftScrollHandler,
4464
+ right: rightScrollHandler,
4465
+ mdLeft: mdLeft,
4466
+ mdRight: mdRight
4467
+ };
4312
4468
  }
4313
4469
  }
4314
4470
 
@@ -4628,9 +4784,47 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4628
4784
  const imageClose = document.getElementById('image-close');
4629
4785
  if (!imageOverlay || !imageContainer) return;
4630
4786
 
4787
+ // Collect all images for navigation
4788
+ const allImages = Array.from(preview.querySelectorAll('img'));
4789
+ let currentImageIndex = -1;
4790
+
4791
+ function showImage(index) {
4792
+ if (index < 0 || index >= allImages.length) return;
4793
+ currentImageIndex = index;
4794
+ const img = allImages[index];
4795
+
4796
+ imageContainer.innerHTML = '';
4797
+ const clonedImg = img.cloneNode(true);
4798
+ // CSSで制御するためインラインスタイルはリセット
4799
+ clonedImg.style.width = '';
4800
+ clonedImg.style.height = '';
4801
+ clonedImg.style.maxWidth = '';
4802
+ clonedImg.style.maxHeight = '';
4803
+ clonedImg.style.cursor = 'default';
4804
+ imageContainer.appendChild(clonedImg);
4805
+
4806
+ // Show navigation hint
4807
+ const counter = document.createElement('div');
4808
+ counter.className = 'fullscreen-counter';
4809
+ counter.textContent = \`\${index + 1} / \${allImages.length}\`;
4810
+ counter.style.cssText = 'position:absolute;bottom:20px;left:50%;transform:translateX(-50%);color:#fff;background:rgba(0,0,0,0.6);padding:8px 16px;border-radius:20px;font-size:14px;';
4811
+ imageContainer.appendChild(counter);
4812
+
4813
+ imageOverlay.classList.add('visible');
4814
+ }
4815
+
4631
4816
  function closeImageOverlay() {
4632
4817
  imageOverlay.classList.remove('visible');
4633
4818
  imageContainer.innerHTML = '';
4819
+ currentImageIndex = -1;
4820
+ }
4821
+
4822
+ function navigateImage(direction) {
4823
+ if (!imageOverlay.classList.contains('visible')) return;
4824
+ const newIndex = currentImageIndex + direction;
4825
+ if (newIndex >= 0 && newIndex < allImages.length) {
4826
+ showImage(newIndex);
4827
+ }
4634
4828
  }
4635
4829
 
4636
4830
  if (imageClose) {
@@ -4645,30 +4839,33 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4645
4839
  }
4646
4840
 
4647
4841
  document.addEventListener('keydown', (e) => {
4648
- if (e.key === 'Escape' && imageOverlay.classList.contains('visible')) {
4649
- closeImageOverlay();
4842
+ if (!imageOverlay.classList.contains('visible')) return;
4843
+
4844
+ switch (e.key) {
4845
+ case 'Escape':
4846
+ closeImageOverlay();
4847
+ break;
4848
+ case 'ArrowLeft':
4849
+ case 'ArrowUp':
4850
+ e.preventDefault();
4851
+ navigateImage(-1);
4852
+ break;
4853
+ case 'ArrowRight':
4854
+ case 'ArrowDown':
4855
+ e.preventDefault();
4856
+ navigateImage(1);
4857
+ break;
4650
4858
  }
4651
4859
  });
4652
4860
 
4653
- preview.querySelectorAll('img').forEach(img => {
4861
+ allImages.forEach((img, index) => {
4654
4862
  img.style.cursor = 'pointer';
4655
- img.title = 'Click to view fullscreen';
4863
+ img.title = 'Click to view fullscreen (← → to navigate)';
4656
4864
 
4657
4865
  img.addEventListener('click', (e) => {
4658
4866
  // Don't stop propagation - allow select to work
4659
4867
  e.preventDefault();
4660
-
4661
- imageContainer.innerHTML = '';
4662
- const clonedImg = img.cloneNode(true);
4663
- // CSSで制御するためインラインスタイルはリセット
4664
- clonedImg.style.width = '';
4665
- clonedImg.style.height = '';
4666
- clonedImg.style.maxWidth = '';
4667
- clonedImg.style.maxHeight = '';
4668
- clonedImg.style.cursor = 'default';
4669
- imageContainer.appendChild(clonedImg);
4670
-
4671
- imageOverlay.classList.add('visible');
4868
+ showImage(index);
4672
4869
  });
4673
4870
  });
4674
4871
  })();
@@ -4685,8 +4882,56 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4685
4882
 
4686
4883
  const videoExtensions = /\\.(mp4|mov|webm|avi|mkv|m4v|ogv)$/i;
4687
4884
 
4885
+ // Collect all video links for navigation
4886
+ const allVideoLinks = Array.from(preview.querySelectorAll('a')).filter(link => {
4887
+ const href = link.getAttribute('href');
4888
+ return href && videoExtensions.test(href);
4889
+ });
4890
+ let currentVideoIndex = -1;
4891
+
4892
+ function showVideo(index) {
4893
+ if (index < 0 || index >= allVideoLinks.length) return;
4894
+ currentVideoIndex = index;
4895
+ const link = allVideoLinks[index];
4896
+ const href = link.getAttribute('href');
4897
+
4898
+ // Remove existing video if any
4899
+ const existingVideo = videoContainer.querySelector('video');
4900
+ if (existingVideo) {
4901
+ existingVideo.pause();
4902
+ existingVideo.src = '';
4903
+ existingVideo.remove();
4904
+ }
4905
+
4906
+ // Remove existing counter
4907
+ const existingCounter = videoContainer.querySelector('.fullscreen-counter');
4908
+ if (existingCounter) existingCounter.remove();
4909
+
4910
+ const video = document.createElement('video');
4911
+ video.src = href;
4912
+ video.controls = true;
4913
+ video.autoplay = true;
4914
+ video.style.maxWidth = '100%';
4915
+ video.style.maxHeight = '100%';
4916
+ // Prevent click on video from closing overlay
4917
+ video.addEventListener('click', (e) => e.stopPropagation());
4918
+ videoContainer.appendChild(video);
4919
+
4920
+ // Show navigation hint
4921
+ if (allVideoLinks.length > 1) {
4922
+ const counter = document.createElement('div');
4923
+ counter.className = 'fullscreen-counter';
4924
+ counter.textContent = \`\${index + 1} / \${allVideoLinks.length}\`;
4925
+ counter.style.cssText = 'position:absolute;bottom:20px;left:50%;transform:translateX(-50%);color:#fff;background:rgba(0,0,0,0.6);padding:8px 16px;border-radius:20px;font-size:14px;';
4926
+ videoContainer.appendChild(counter);
4927
+ }
4928
+
4929
+ videoOverlay.classList.add('visible');
4930
+ }
4931
+
4688
4932
  function closeVideoOverlay() {
4689
4933
  videoOverlay.classList.remove('visible');
4934
+ currentVideoIndex = -1;
4690
4935
  // Stop and remove video
4691
4936
  const video = videoContainer.querySelector('video');
4692
4937
  if (video) {
@@ -4696,6 +4941,14 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4696
4941
  }
4697
4942
  }
4698
4943
 
4944
+ function navigateVideo(direction) {
4945
+ if (!videoOverlay.classList.contains('visible')) return;
4946
+ const newIndex = currentVideoIndex + direction;
4947
+ if (newIndex >= 0 && newIndex < allVideoLinks.length) {
4948
+ showVideo(newIndex);
4949
+ }
4950
+ }
4951
+
4699
4952
  if (videoClose) {
4700
4953
  videoClose.addEventListener('click', closeVideoOverlay);
4701
4954
  }
@@ -4708,41 +4961,37 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4708
4961
  }
4709
4962
 
4710
4963
  document.addEventListener('keydown', (e) => {
4711
- if (e.key === 'Escape' && videoOverlay.classList.contains('visible')) {
4712
- closeVideoOverlay();
4964
+ if (!videoOverlay.classList.contains('visible')) return;
4965
+
4966
+ switch (e.key) {
4967
+ case 'Escape':
4968
+ closeVideoOverlay();
4969
+ break;
4970
+ case 'ArrowLeft':
4971
+ case 'ArrowUp':
4972
+ e.preventDefault();
4973
+ navigateVideo(-1);
4974
+ break;
4975
+ case 'ArrowRight':
4976
+ case 'ArrowDown':
4977
+ e.preventDefault();
4978
+ navigateVideo(1);
4979
+ break;
4713
4980
  }
4714
4981
  });
4715
4982
 
4716
4983
  // Intercept video link clicks
4717
- preview.querySelectorAll('a').forEach(link => {
4718
- const href = link.getAttribute('href');
4719
- if (href && videoExtensions.test(href)) {
4720
- link.style.cursor = 'pointer';
4721
- link.title = 'Click to play video fullscreen';
4722
-
4723
- link.addEventListener('click', (e) => {
4724
- e.preventDefault();
4725
- // Don't stop propagation - allow select to work
4726
-
4727
- // Remove existing video if any
4728
- const existingVideo = videoContainer.querySelector('video');
4729
- if (existingVideo) {
4730
- existingVideo.pause();
4731
- existingVideo.src = '';
4732
- existingVideo.remove();
4733
- }
4984
+ allVideoLinks.forEach((link, index) => {
4985
+ link.style.cursor = 'pointer';
4986
+ link.title = allVideoLinks.length > 1
4987
+ ? 'Click to play video fullscreen (← → to navigate)'
4988
+ : 'Click to play video fullscreen';
4734
4989
 
4735
- const video = document.createElement('video');
4736
- video.src = href;
4737
- video.controls = true;
4738
- video.autoplay = true;
4739
- video.style.maxWidth = '100%';
4740
- video.style.maxHeight = '100%';
4741
- videoContainer.appendChild(video);
4742
-
4743
- videoOverlay.classList.add('visible');
4744
- });
4745
- }
4990
+ link.addEventListener('click', (e) => {
4991
+ e.preventDefault();
4992
+ // Don't stop propagation - allow select to work
4993
+ showVideo(index);
4994
+ });
4746
4995
  });
4747
4996
  })();
4748
4997
 
@@ -4926,22 +5175,59 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4926
5175
  }
4927
5176
 
4928
5177
  // Trigger source cell selection (reuse existing comment flow)
4929
- function selectSourceRange(startRow, endRow) {
5178
+ // When clickedPreviewElement is provided (from preview click), position card near that element
5179
+ function selectSourceRange(startRow, endRow, clickedPreviewElement) {
5180
+ // IMMEDIATELY disable scroll sync at the very start
5181
+ window._disableScrollSync = true;
5182
+ window._scrollSyncDisableUntil = Date.now() + 2000;
5183
+
5184
+ // Cancel any pending scroll sync RAF
5185
+ if (window._scrollSyncRafId) {
5186
+ cancelAnimationFrame(window._scrollSyncRafId);
5187
+ window._scrollSyncRafId = null;
5188
+ }
5189
+
5190
+ // TEMPORARILY REMOVE scroll event listeners to prevent any interference
5191
+ const handlers = window._scrollHandlers;
5192
+ if (handlers) {
5193
+ handlers.mdLeft.removeEventListener('scroll', handlers.left);
5194
+ handlers.mdRight.removeEventListener('scroll', handlers.right);
5195
+ }
5196
+
4930
5197
  selection = { startRow, endRow: endRow || startRow, startCol: 1, endCol: 1 };
4931
5198
  updateSelectionVisual();
4932
5199
 
4933
5200
  // Clear header selection
4934
5201
  document.querySelectorAll('thead th.selected').forEach(el => el.classList.remove('selected'));
4935
5202
 
4936
- // Scroll source table to show the selected row, then open card
4937
- const sourceTd = tbody.querySelector('td[data-row="' + startRow + '"][data-col="1"]');
4938
- if (sourceTd) {
4939
- sourceTd.scrollIntoView({ behavior: 'smooth', block: 'center' });
4940
- // Wait for scroll to complete before positioning card
4941
- setTimeout(() => openCardForSelection(), 350);
4942
- } else {
4943
- openCardForSelection();
5203
+ // Scroll source pane FIRST (before opening card) to ensure target is visible
5204
+ const targetRow = startRow;
5205
+ const mdRight = document.querySelector('.md-right');
5206
+ const sourceTd = document.querySelector('td[data-row="' + targetRow + '"][data-col="1"]');
5207
+ if (mdRight && sourceTd) {
5208
+ const tdOffsetTop = sourceTd.offsetTop;
5209
+ const containerHeight = mdRight.clientHeight;
5210
+ const tdHeight = sourceTd.offsetHeight;
5211
+ const scrollTarget = tdOffsetTop - (containerHeight / 2) + (tdHeight / 2);
5212
+ // Use scrollTo with instant behavior to ensure immediate scroll
5213
+ mdRight.scrollTo({
5214
+ top: Math.max(0, scrollTarget),
5215
+ behavior: 'instant'
5216
+ });
4944
5217
  }
5218
+
5219
+ // Open the card (synchronously) - now target cell should be visible for positioning
5220
+ openCardForSelection();
5221
+
5222
+ // Re-add scroll handlers after a delay to allow scroll to settle
5223
+ setTimeout(() => {
5224
+ if (handlers) {
5225
+ handlers.mdLeft.addEventListener('scroll', handlers.left, { passive: true });
5226
+ handlers.mdRight.addEventListener('scroll', handlers.right, { passive: true });
5227
+ }
5228
+ window._disableScrollSync = false;
5229
+ window._scrollSyncDisableUntil = 0;
5230
+ }, 500);
4945
5231
  }
4946
5232
 
4947
5233
  // Click on block elements
@@ -4950,7 +5236,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4950
5236
  if (e.target.tagName === 'IMG') {
4951
5237
  const line = findImageSourceLine(e.target.src);
4952
5238
  if (line > 0) {
4953
- selectSourceRange(line);
5239
+ selectSourceRange(line, null, e.target);
4954
5240
  }
4955
5241
  return;
4956
5242
  }
@@ -4964,7 +5250,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4964
5250
  const isTableCell = parentBlock.tagName === 'TD' || parentBlock.tagName === 'TH';
4965
5251
  const line = isTableCell ? findTableSourceLine(parentBlock.textContent) : findSourceLine(parentBlock.textContent);
4966
5252
  if (line > 0) {
4967
- selectSourceRange(line);
5253
+ selectSourceRange(line, null, parentBlock);
4968
5254
  }
4969
5255
  }
4970
5256
  // Let the link open naturally (target="_blank" is set by marked)
@@ -4981,7 +5267,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4981
5267
  const { startLine, endLine } = findCodeBlockRange(code.textContent);
4982
5268
  if (startLine > 0) {
4983
5269
  e.preventDefault();
4984
- selectSourceRange(startLine, endLine);
5270
+ selectSourceRange(startLine, endLine, pre);
4985
5271
  }
4986
5272
  return;
4987
5273
  }
@@ -4995,7 +5281,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4995
5281
  if (line <= 0) return;
4996
5282
 
4997
5283
  e.preventDefault();
4998
- selectSourceRange(line);
5284
+ selectSourceRange(line, null, target);
4999
5285
  });
5000
5286
 
5001
5287
  // Text selection to open comment for range
@@ -5015,8 +5301,13 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
5015
5301
 
5016
5302
  if (startLine <= 0) return;
5017
5303
 
5304
+ // Get the element containing the selection for positioning
5305
+ const range = sel.getRangeAt(0);
5306
+ const container = range.commonAncestorContainer;
5307
+ const element = container.nodeType === Node.TEXT_NODE ? container.parentElement : container;
5308
+
5018
5309
  sel.removeAllRanges();
5019
- selectSourceRange(startLine, endLine > 0 ? endLine : startLine);
5310
+ selectSourceRange(startLine, endLine > 0 ? endLine : startLine, element);
5020
5311
  }, 10);
5021
5312
  });
5022
5313
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reviw",
3
- "version": "0.11.3",
3
+ "version": "0.13.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": {
@@ -9,6 +9,10 @@
9
9
  "files": [
10
10
  "cli.cjs"
11
11
  ],
12
+ "scripts": {
13
+ "test": "vitest run",
14
+ "test:watch": "vitest"
15
+ },
12
16
  "license": "MIT",
13
17
  "author": "kazuph",
14
18
  "publishConfig": {
@@ -27,9 +31,5 @@
27
31
  "@playwright/test": "^1.57.0",
28
32
  "playwright": "^1.57.0",
29
33
  "vitest": "^4.0.14"
30
- },
31
- "scripts": {
32
- "test": "vitest run",
33
- "test:watch": "vitest"
34
34
  }
35
- }
35
+ }