reviw 0.12.0 → 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 (2) hide show
  1. package/cli.cjs +376 -89
  2. package/package.json +1 -1
package/cli.cjs CHANGED
@@ -3566,7 +3566,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3566
3566
  }, 1500);
3567
3567
  }
3568
3568
 
3569
- function openCardForSelection() {
3569
+ function openCardForSelection(previewElement) {
3570
3570
  if (!selection) return;
3571
3571
  // Don't open card while image/video modal is visible
3572
3572
  const imageOverlay = document.getElementById('image-fullscreen');
@@ -3604,10 +3604,50 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3604
3604
  commentInput.value = existingComment?.text || '';
3605
3605
 
3606
3606
  card.style.display = 'block';
3607
+ // 常にソーステーブルの選択セル位置を基準にカードを配置
3608
+ // これにより、プレビューからクリックしてもソースからクリックしても
3609
+ // 同じ行に対しては同じ位置にダイアログが表示される
3607
3610
  positionCardForSelection(startRow, endRow, startCol, endCol);
3608
3611
  commentInput.focus();
3609
3612
  }
3610
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
+
3611
3651
  function positionCardForSelection(startRow, endRow, startCol, endCol) {
3612
3652
  const cardWidth = card.offsetWidth || 380;
3613
3653
  const cardHeight = card.offsetHeight || 220;
@@ -3632,6 +3672,9 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3632
3672
  const sx = window.scrollX;
3633
3673
  const sy = window.scrollY;
3634
3674
 
3675
+ // Check if the selection is within viewport
3676
+ const isInViewport = rect.top >= 0 && rect.top < vh && rect.bottom > 0;
3677
+
3635
3678
  const spaceRight = vw - rect.right - margin;
3636
3679
  const spaceLeft = rect.left - margin - ROW_HEADER_WIDTH; // Account for row header
3637
3680
  const spaceBelow = vh - rect.bottom - margin;
@@ -3643,28 +3686,38 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3643
3686
  let left = sx + rect.right + margin;
3644
3687
  let top = sy + rect.top;
3645
3688
 
3646
- // Priority: right > below > above > left > fallback right
3647
- if (spaceRight >= cardWidth) {
3648
- // Prefer right side of selection
3649
- left = sx + rect.right + margin;
3650
- top = sy + clamp(rect.top, margin, vh - cardHeight - margin);
3651
- } else if (spaceBelow >= cardHeight) {
3652
- left = sx + clamp(rect.left, minLeft, vw - cardWidth - margin);
3653
- top = sy + rect.bottom + margin;
3654
- } else if (spaceAbove >= cardHeight) {
3655
- left = sx + clamp(rect.left, minLeft, vw - cardWidth - margin);
3656
- top = sy + rect.top - cardHeight - margin;
3657
- } else if (spaceLeft >= cardWidth) {
3658
- left = sx + rect.left - cardWidth - margin;
3659
- 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);
3660
3693
  } else {
3661
- // Fallback: place to right side even if it means going off screen
3662
- // Position card at right edge of selection, clamped to viewport
3663
- left = sx + Math.max(rect.right + margin, minLeft);
3664
- left = Math.min(left, sx + vw - cardWidth - margin);
3665
- 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
+ }
3666
3715
  }
3667
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
+
3668
3721
  card.style.left = left + 'px';
3669
3722
  card.style.top = top + 'px';
3670
3723
  }
@@ -3673,6 +3726,8 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3673
3726
  card.style.display = 'none';
3674
3727
  currentKey = null;
3675
3728
  clearSelection();
3729
+ // Re-enable scroll sync when card is closed
3730
+ window._disableScrollSync = false;
3676
3731
  }
3677
3732
 
3678
3733
  function setDot(row, col, on) {
@@ -4277,20 +4332,56 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4277
4332
  })();
4278
4333
 
4279
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
+
4280
4340
  if (MODE === 'markdown') {
4281
4341
  const mdLeft = document.querySelector('.md-left');
4282
4342
  const mdRight = document.querySelector('.md-right');
4283
4343
  if (mdLeft && mdRight) {
4284
4344
  let activePane = null;
4285
- 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
+ }
4286
4368
 
4287
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
+
4288
4375
  // Only sync if this pane initiated the scroll
4289
4376
  if (activePane && activePane !== sourceName) return;
4290
4377
  activePane = sourceName;
4291
4378
 
4292
- if (rafId) cancelAnimationFrame(rafId);
4293
- 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
+
4294
4385
  const sourceMax = source.scrollHeight - source.clientHeight;
4295
4386
  const targetMax = target.scrollHeight - target.clientHeight;
4296
4387
 
@@ -4299,20 +4390,81 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4299
4390
  // Snap to edges for precision
4300
4391
  if (source.scrollTop <= 1) {
4301
4392
  target.scrollTop = 0;
4302
- } else if (source.scrollTop >= sourceMax - 1) {
4393
+ setTimeout(() => { activePane = null; }, 100);
4394
+ return;
4395
+ }
4396
+ if (source.scrollTop >= sourceMax - 1) {
4303
4397
  target.scrollTop = targetMax;
4304
- } else {
4305
- const ratio = source.scrollTop / sourceMax;
4306
- 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
+ }
4307
4444
  }
4308
4445
 
4446
+ // Fallback to ratio-based sync
4447
+ const ratio = source.scrollTop / sourceMax;
4448
+ target.scrollTop = Math.round(ratio * targetMax);
4449
+
4309
4450
  // Release lock after scroll settles
4310
4451
  setTimeout(() => { activePane = null; }, 100);
4311
4452
  });
4312
4453
  }
4313
4454
 
4314
- mdLeft.addEventListener('scroll', () => syncScroll(mdLeft, mdRight, 'left'), { passive: true });
4315
- 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
+ };
4316
4468
  }
4317
4469
  }
4318
4470
 
@@ -4632,9 +4784,47 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4632
4784
  const imageClose = document.getElementById('image-close');
4633
4785
  if (!imageOverlay || !imageContainer) return;
4634
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
+
4635
4816
  function closeImageOverlay() {
4636
4817
  imageOverlay.classList.remove('visible');
4637
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
+ }
4638
4828
  }
4639
4829
 
4640
4830
  if (imageClose) {
@@ -4649,30 +4839,33 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4649
4839
  }
4650
4840
 
4651
4841
  document.addEventListener('keydown', (e) => {
4652
- if (e.key === 'Escape' && imageOverlay.classList.contains('visible')) {
4653
- 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;
4654
4858
  }
4655
4859
  });
4656
4860
 
4657
- preview.querySelectorAll('img').forEach(img => {
4861
+ allImages.forEach((img, index) => {
4658
4862
  img.style.cursor = 'pointer';
4659
- img.title = 'Click to view fullscreen';
4863
+ img.title = 'Click to view fullscreen (← → to navigate)';
4660
4864
 
4661
4865
  img.addEventListener('click', (e) => {
4662
4866
  // Don't stop propagation - allow select to work
4663
4867
  e.preventDefault();
4664
-
4665
- imageContainer.innerHTML = '';
4666
- const clonedImg = img.cloneNode(true);
4667
- // CSSで制御するためインラインスタイルはリセット
4668
- clonedImg.style.width = '';
4669
- clonedImg.style.height = '';
4670
- clonedImg.style.maxWidth = '';
4671
- clonedImg.style.maxHeight = '';
4672
- clonedImg.style.cursor = 'default';
4673
- imageContainer.appendChild(clonedImg);
4674
-
4675
- imageOverlay.classList.add('visible');
4868
+ showImage(index);
4676
4869
  });
4677
4870
  });
4678
4871
  })();
@@ -4689,8 +4882,56 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4689
4882
 
4690
4883
  const videoExtensions = /\\.(mp4|mov|webm|avi|mkv|m4v|ogv)$/i;
4691
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
+
4692
4932
  function closeVideoOverlay() {
4693
4933
  videoOverlay.classList.remove('visible');
4934
+ currentVideoIndex = -1;
4694
4935
  // Stop and remove video
4695
4936
  const video = videoContainer.querySelector('video');
4696
4937
  if (video) {
@@ -4700,6 +4941,14 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4700
4941
  }
4701
4942
  }
4702
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
+
4703
4952
  if (videoClose) {
4704
4953
  videoClose.addEventListener('click', closeVideoOverlay);
4705
4954
  }
@@ -4712,41 +4961,37 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4712
4961
  }
4713
4962
 
4714
4963
  document.addEventListener('keydown', (e) => {
4715
- if (e.key === 'Escape' && videoOverlay.classList.contains('visible')) {
4716
- 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;
4717
4980
  }
4718
4981
  });
4719
4982
 
4720
4983
  // Intercept video link clicks
4721
- preview.querySelectorAll('a').forEach(link => {
4722
- const href = link.getAttribute('href');
4723
- if (href && videoExtensions.test(href)) {
4724
- link.style.cursor = 'pointer';
4725
- link.title = 'Click to play video fullscreen';
4726
-
4727
- link.addEventListener('click', (e) => {
4728
- e.preventDefault();
4729
- // Don't stop propagation - allow select to work
4730
-
4731
- // Remove existing video if any
4732
- const existingVideo = videoContainer.querySelector('video');
4733
- if (existingVideo) {
4734
- existingVideo.pause();
4735
- existingVideo.src = '';
4736
- existingVideo.remove();
4737
- }
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';
4738
4989
 
4739
- const video = document.createElement('video');
4740
- video.src = href;
4741
- video.controls = true;
4742
- video.autoplay = true;
4743
- video.style.maxWidth = '100%';
4744
- video.style.maxHeight = '100%';
4745
- videoContainer.appendChild(video);
4746
-
4747
- videoOverlay.classList.add('visible');
4748
- });
4749
- }
4990
+ link.addEventListener('click', (e) => {
4991
+ e.preventDefault();
4992
+ // Don't stop propagation - allow select to work
4993
+ showVideo(index);
4994
+ });
4750
4995
  });
4751
4996
  })();
4752
4997
 
@@ -4930,22 +5175,59 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4930
5175
  }
4931
5176
 
4932
5177
  // Trigger source cell selection (reuse existing comment flow)
4933
- 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
+
4934
5197
  selection = { startRow, endRow: endRow || startRow, startCol: 1, endCol: 1 };
4935
5198
  updateSelectionVisual();
4936
5199
 
4937
5200
  // Clear header selection
4938
5201
  document.querySelectorAll('thead th.selected').forEach(el => el.classList.remove('selected'));
4939
5202
 
4940
- // Scroll source table to show the selected row, then open card
4941
- const sourceTd = tbody.querySelector('td[data-row="' + startRow + '"][data-col="1"]');
4942
- if (sourceTd) {
4943
- sourceTd.scrollIntoView({ behavior: 'smooth', block: 'center' });
4944
- // Wait for scroll to complete before positioning card
4945
- setTimeout(() => openCardForSelection(), 350);
4946
- } else {
4947
- 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
+ });
4948
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);
4949
5231
  }
4950
5232
 
4951
5233
  // Click on block elements
@@ -4954,7 +5236,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4954
5236
  if (e.target.tagName === 'IMG') {
4955
5237
  const line = findImageSourceLine(e.target.src);
4956
5238
  if (line > 0) {
4957
- selectSourceRange(line);
5239
+ selectSourceRange(line, null, e.target);
4958
5240
  }
4959
5241
  return;
4960
5242
  }
@@ -4968,7 +5250,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4968
5250
  const isTableCell = parentBlock.tagName === 'TD' || parentBlock.tagName === 'TH';
4969
5251
  const line = isTableCell ? findTableSourceLine(parentBlock.textContent) : findSourceLine(parentBlock.textContent);
4970
5252
  if (line > 0) {
4971
- selectSourceRange(line);
5253
+ selectSourceRange(line, null, parentBlock);
4972
5254
  }
4973
5255
  }
4974
5256
  // Let the link open naturally (target="_blank" is set by marked)
@@ -4985,7 +5267,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4985
5267
  const { startLine, endLine } = findCodeBlockRange(code.textContent);
4986
5268
  if (startLine > 0) {
4987
5269
  e.preventDefault();
4988
- selectSourceRange(startLine, endLine);
5270
+ selectSourceRange(startLine, endLine, pre);
4989
5271
  }
4990
5272
  return;
4991
5273
  }
@@ -4999,7 +5281,7 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4999
5281
  if (line <= 0) return;
5000
5282
 
5001
5283
  e.preventDefault();
5002
- selectSourceRange(line);
5284
+ selectSourceRange(line, null, target);
5003
5285
  });
5004
5286
 
5005
5287
  // Text selection to open comment for range
@@ -5019,8 +5301,13 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
5019
5301
 
5020
5302
  if (startLine <= 0) return;
5021
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
+
5022
5309
  sel.removeAllRanges();
5023
- selectSourceRange(startLine, endLine > 0 ? endLine : startLine);
5310
+ selectSourceRange(startLine, endLine > 0 ? endLine : startLine, element);
5024
5311
  }, 10);
5025
5312
  });
5026
5313
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reviw",
3
- "version": "0.12.0",
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": {