reviw 0.9.2 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/cli.cjs +412 -0
  2. package/package.json +6 -6
package/cli.cjs CHANGED
@@ -839,6 +839,18 @@ function diffHtmlTemplate(diffData) {
839
839
  z-index: 100;
840
840
  }
841
841
  .modal-overlay.visible { display: flex; }
842
+ /* Submit modal: top-right position, no blocking overlay */
843
+ #submit-modal {
844
+ background: transparent;
845
+ pointer-events: none;
846
+ align-items: flex-start;
847
+ justify-content: flex-end;
848
+ }
849
+ #submit-modal.visible { display: flex; }
850
+ #submit-modal .modal-dialog {
851
+ pointer-events: auto;
852
+ margin: 60px 20px 20px 20px; /* top margin avoids header button overlap */
853
+ }
842
854
  .modal-dialog {
843
855
  background: var(--panel);
844
856
  border: 1px solid var(--border);
@@ -1991,6 +2003,55 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
1991
2003
  border-radius: 8px;
1992
2004
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
1993
2005
  }
2006
+ /* Video fullscreen overlay */
2007
+ .video-fullscreen-overlay {
2008
+ position: fixed;
2009
+ inset: 0;
2010
+ background: rgba(0, 0, 0, 0.95);
2011
+ z-index: 1001;
2012
+ display: none;
2013
+ justify-content: center;
2014
+ align-items: center;
2015
+ }
2016
+ .video-fullscreen-overlay.visible {
2017
+ display: flex;
2018
+ }
2019
+ .video-close-btn {
2020
+ position: absolute;
2021
+ top: 14px;
2022
+ right: 14px;
2023
+ width: 40px;
2024
+ height: 40px;
2025
+ display: flex;
2026
+ align-items: center;
2027
+ justify-content: center;
2028
+ background: rgba(0, 0, 0, 0.55);
2029
+ border: 1px solid rgba(255, 255, 255, 0.25);
2030
+ border-radius: 50%;
2031
+ cursor: pointer;
2032
+ color: #fff;
2033
+ font-size: 18px;
2034
+ z-index: 10;
2035
+ backdrop-filter: blur(4px);
2036
+ transition: background 120ms ease, transform 120ms ease;
2037
+ }
2038
+ .video-close-btn:hover {
2039
+ background: rgba(0, 0, 0, 0.75);
2040
+ transform: scale(1.04);
2041
+ }
2042
+ .video-container {
2043
+ width: 90vw;
2044
+ height: 90vh;
2045
+ display: flex;
2046
+ justify-content: center;
2047
+ align-items: center;
2048
+ }
2049
+ .video-container video {
2050
+ max-width: 100%;
2051
+ max-height: 100%;
2052
+ border-radius: 8px;
2053
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
2054
+ }
1994
2055
  /* Copy notification toast */
1995
2056
  .copy-toast {
1996
2057
  position: fixed;
@@ -2051,6 +2112,18 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
2051
2112
  z-index: 100;
2052
2113
  }
2053
2114
  .modal-overlay.visible { display: flex; }
2115
+ /* Submit modal: top-right position, no blocking overlay */
2116
+ #submit-modal {
2117
+ background: transparent;
2118
+ pointer-events: none;
2119
+ align-items: flex-start;
2120
+ justify-content: flex-end;
2121
+ }
2122
+ #submit-modal.visible { display: flex; }
2123
+ #submit-modal .modal-dialog {
2124
+ pointer-events: auto;
2125
+ margin: 60px 20px 20px 20px; /* top margin avoids header button overlap */
2126
+ }
2054
2127
  .modal-dialog {
2055
2128
  background: var(--panel-solid);
2056
2129
  border: 1px solid var(--border);
@@ -2352,6 +2425,10 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
2352
2425
  <button class="image-close-btn" id="image-close" aria-label="Close image" title="Close (ESC)">✕</button>
2353
2426
  <div class="image-container" id="image-container"></div>
2354
2427
  </div>
2428
+ <div class="video-fullscreen-overlay" id="video-fullscreen">
2429
+ <button class="video-close-btn" id="video-close" aria-label="Close video" title="Close (ESC)">✕</button>
2430
+ <div class="video-container" id="video-container"></div>
2431
+ </div>
2355
2432
 
2356
2433
  <script>
2357
2434
  const DATA = ${serialized};
@@ -3652,6 +3729,321 @@ function htmlTemplate(dataRows, cols, title, mode, previewHtml) {
3652
3729
  });
3653
3730
  });
3654
3731
  })();
3732
+
3733
+ // --- Video Fullscreen ---
3734
+ (function initVideoFullscreen() {
3735
+ const preview = document.querySelector('.md-preview');
3736
+ if (!preview) return;
3737
+
3738
+ const videoOverlay = document.getElementById('video-fullscreen');
3739
+ const videoContainer = document.getElementById('video-container');
3740
+ const videoClose = document.getElementById('video-close');
3741
+ if (!videoOverlay || !videoContainer) return;
3742
+
3743
+ const videoExtensions = /\\.(mp4|mov|webm|avi|mkv|m4v|ogv)$/i;
3744
+
3745
+ function closeVideoOverlay() {
3746
+ videoOverlay.classList.remove('visible');
3747
+ // Stop and remove video
3748
+ const video = videoContainer.querySelector('video');
3749
+ if (video) {
3750
+ video.pause();
3751
+ video.src = '';
3752
+ video.remove();
3753
+ }
3754
+ }
3755
+
3756
+ if (videoClose) {
3757
+ videoClose.addEventListener('click', closeVideoOverlay);
3758
+ }
3759
+
3760
+ if (videoOverlay) {
3761
+ videoOverlay.addEventListener('click', (e) => {
3762
+ if (e.target === videoOverlay) closeVideoOverlay();
3763
+ });
3764
+ }
3765
+
3766
+ document.addEventListener('keydown', (e) => {
3767
+ if (e.key === 'Escape' && videoOverlay.classList.contains('visible')) {
3768
+ closeVideoOverlay();
3769
+ }
3770
+ });
3771
+
3772
+ // Intercept video link clicks
3773
+ preview.querySelectorAll('a').forEach(link => {
3774
+ const href = link.getAttribute('href');
3775
+ if (href && videoExtensions.test(href)) {
3776
+ link.style.cursor = 'pointer';
3777
+ link.title = 'Click to play video fullscreen';
3778
+
3779
+ link.addEventListener('click', (e) => {
3780
+ e.preventDefault();
3781
+ e.stopPropagation();
3782
+
3783
+ // Remove existing video if any
3784
+ const existingVideo = videoContainer.querySelector('video');
3785
+ if (existingVideo) {
3786
+ existingVideo.pause();
3787
+ existingVideo.src = '';
3788
+ existingVideo.remove();
3789
+ }
3790
+
3791
+ const video = document.createElement('video');
3792
+ video.src = href;
3793
+ video.controls = true;
3794
+ video.autoplay = true;
3795
+ video.style.maxWidth = '100%';
3796
+ video.style.maxHeight = '100%';
3797
+ videoContainer.appendChild(video);
3798
+
3799
+ videoOverlay.classList.add('visible');
3800
+ });
3801
+ }
3802
+ });
3803
+ })();
3804
+
3805
+ // --- Preview Commenting ---
3806
+ (function initPreviewCommenting() {
3807
+ if (MODE !== 'markdown') return;
3808
+
3809
+ const preview = document.querySelector('.md-preview');
3810
+ if (!preview) return;
3811
+
3812
+ // Add visual hint for clickable elements
3813
+ const style = document.createElement('style');
3814
+ style.textContent = \`
3815
+ .md-preview > p:hover, .md-preview > h1:hover, .md-preview > h2:hover,
3816
+ .md-preview > h3:hover, .md-preview > h4:hover, .md-preview > h5:hover,
3817
+ .md-preview > h6:hover, .md-preview > ul > li:hover, .md-preview > ol > li:hover,
3818
+ .md-preview > pre:hover, .md-preview > blockquote:hover {
3819
+ background: rgba(99, 102, 241, 0.08);
3820
+ cursor: pointer;
3821
+ border-radius: 4px;
3822
+ }
3823
+ .md-preview img:hover {
3824
+ outline: 2px solid var(--accent);
3825
+ cursor: pointer;
3826
+ }
3827
+ \`;
3828
+ document.head.appendChild(style);
3829
+
3830
+ // Helper: find matching source line for text
3831
+ function findSourceLine(text) {
3832
+ if (!text) return -1;
3833
+ const normalized = text.trim().replace(/\\s+/g, ' ').slice(0, 100);
3834
+ if (!normalized) return -1;
3835
+
3836
+ for (let i = 0; i < DATA.length; i++) {
3837
+ const lineText = (DATA[i][0] || '').trim();
3838
+ if (!lineText) continue;
3839
+
3840
+ const lineNorm = lineText.replace(/\\s+/g, ' ').slice(0, 100);
3841
+ if (lineNorm === normalized) return i + 1;
3842
+ if (lineNorm.includes(normalized.slice(0, 30)) && normalized.length > 5) return i + 1;
3843
+ if (normalized.includes(lineNorm.slice(0, 30)) && lineNorm.length > 5) return i + 1;
3844
+
3845
+ // Check for markdown headings: strip # from source and compare
3846
+ if (lineText.match(/^#+\\s/)) {
3847
+ const headingText = lineText.replace(/^#+\\s*/, '').trim();
3848
+ if (headingText === normalized || headingText.toLowerCase() === normalized.toLowerCase()) {
3849
+ return i + 1;
3850
+ }
3851
+ }
3852
+ }
3853
+ return -1;
3854
+ }
3855
+
3856
+ // Helper: find matching source line for table cell (prioritizes table rows)
3857
+ function findTableSourceLine(text) {
3858
+ if (!text) return -1;
3859
+ const normalized = text.trim().replace(/\\s+/g, ' ').slice(0, 100);
3860
+ if (!normalized) return -1;
3861
+
3862
+ // First pass: look for table rows (lines starting with |) containing the text
3863
+ for (let i = 0; i < DATA.length; i++) {
3864
+ const lineText = (DATA[i][0] || '').trim();
3865
+ if (!lineText || !lineText.startsWith('|')) continue;
3866
+
3867
+ const lineNorm = lineText.replace(/\\s+/g, ' ').slice(0, 100);
3868
+ if (lineNorm.includes(normalized.slice(0, 30)) && normalized.length > 5) return i + 1;
3869
+ }
3870
+
3871
+ // Fallback to normal search
3872
+ return findSourceLine(text);
3873
+ }
3874
+
3875
+ // Helper: find code block range in source (fenced code blocks)
3876
+ function findCodeBlockRange(codeText) {
3877
+ const clickedLines = codeText.split('\\n').map(l => l.trim()).filter(l => l);
3878
+ const clickedContent = clickedLines.join('\\n');
3879
+
3880
+ // Extract all code blocks from DATA
3881
+ const codeBlocks = [];
3882
+ let currentBlock = null;
3883
+
3884
+ for (let i = 0; i < DATA.length; i++) {
3885
+ const lineText = (DATA[i][0] || '').trim();
3886
+
3887
+ if (lineText.startsWith('\`\`\`') && !currentBlock) {
3888
+ // Start of a code block
3889
+ currentBlock = { startLine: i + 1, lines: [] };
3890
+ } else if (lineText === '\`\`\`' && currentBlock) {
3891
+ // End of a code block
3892
+ currentBlock.endLine = i + 1;
3893
+ currentBlock.content = currentBlock.lines.map(l => l.trim()).filter(l => l).join('\\n');
3894
+ codeBlocks.push(currentBlock);
3895
+ currentBlock = null;
3896
+ } else if (currentBlock) {
3897
+ // Inside a code block
3898
+ currentBlock.lines.push(DATA[i][0] || '');
3899
+ }
3900
+ }
3901
+
3902
+ // Find the best matching code block by content similarity
3903
+ let bestMatch = null;
3904
+ let bestScore = 0;
3905
+
3906
+ for (const block of codeBlocks) {
3907
+ // Calculate similarity score
3908
+ let score = 0;
3909
+
3910
+ // Exact match
3911
+ if (block.content === clickedContent) {
3912
+ score = 1000;
3913
+ } else {
3914
+ // Check line-by-line matches
3915
+ const blockLines = block.content.split('\\n');
3916
+ for (const clickedLine of clickedLines) {
3917
+ if (clickedLine.length > 3) {
3918
+ for (const blockLine of blockLines) {
3919
+ if (blockLine.includes(clickedLine) || clickedLine.includes(blockLine)) {
3920
+ score += clickedLine.length;
3921
+ }
3922
+ }
3923
+ }
3924
+ }
3925
+ }
3926
+
3927
+ if (score > bestScore) {
3928
+ bestScore = score;
3929
+ bestMatch = block;
3930
+ }
3931
+ }
3932
+
3933
+ if (bestMatch) {
3934
+ return { startLine: bestMatch.startLine, endLine: bestMatch.endLine };
3935
+ }
3936
+
3937
+ // Fallback: find by first line content matching
3938
+ const firstCodeLine = clickedLines[0];
3939
+ if (firstCodeLine && firstCodeLine.length > 3) {
3940
+ for (let i = 0; i < DATA.length; i++) {
3941
+ const lineText = (DATA[i][0] || '').trim();
3942
+ if (lineText.includes(firstCodeLine.slice(0, 30))) {
3943
+ return { startLine: i + 1, endLine: i + 1 };
3944
+ }
3945
+ }
3946
+ }
3947
+
3948
+ return { startLine: -1, endLine: -1 };
3949
+ }
3950
+
3951
+ // Helper: find source line for image by src
3952
+ function findImageSourceLine(src) {
3953
+ if (!src) return -1;
3954
+ const filename = src.split('/').pop().split('?')[0];
3955
+ for (let i = 0; i < DATA.length; i++) {
3956
+ const lineText = DATA[i][0] || '';
3957
+ if (lineText.includes(filename) || lineText.includes(src)) {
3958
+ return i + 1;
3959
+ }
3960
+ }
3961
+ return -1;
3962
+ }
3963
+
3964
+ // Trigger source cell selection (reuse existing comment flow)
3965
+ function selectSourceRange(startRow, endRow) {
3966
+ selection = { startRow, endRow: endRow || startRow, startCol: 1, endCol: 1 };
3967
+ updateSelectionVisual();
3968
+
3969
+ // Clear header selection
3970
+ document.querySelectorAll('thead th.selected').forEach(el => el.classList.remove('selected'));
3971
+
3972
+ // Scroll source table to show the selected row, then open card
3973
+ const sourceTd = tbody.querySelector('td[data-row="' + startRow + '"][data-col="1"]');
3974
+ if (sourceTd) {
3975
+ sourceTd.scrollIntoView({ behavior: 'smooth', block: 'center' });
3976
+ // Wait for scroll to complete before positioning card
3977
+ setTimeout(() => openCardForSelection(), 350);
3978
+ } else {
3979
+ openCardForSelection();
3980
+ }
3981
+ }
3982
+
3983
+ // Click on block elements
3984
+ preview.addEventListener('click', (e) => {
3985
+ // Handle image clicks
3986
+ if (e.target.tagName === 'IMG') {
3987
+ if (!e.defaultPrevented) {
3988
+ const line = findImageSourceLine(e.target.src);
3989
+ if (line > 0) {
3990
+ e.preventDefault();
3991
+ e.stopPropagation();
3992
+ selectSourceRange(line);
3993
+ }
3994
+ }
3995
+ return;
3996
+ }
3997
+
3998
+ // Ignore clicks on links, mermaid, video overlay
3999
+ if (e.target.closest('a, .mermaid-container, .video-fullscreen-overlay')) return;
4000
+
4001
+ // Handle code blocks - select entire block
4002
+ const pre = e.target.closest('pre');
4003
+ if (pre) {
4004
+ const code = pre.querySelector('code') || pre;
4005
+ const { startLine, endLine } = findCodeBlockRange(code.textContent);
4006
+ if (startLine > 0) {
4007
+ e.preventDefault();
4008
+ selectSourceRange(startLine, endLine);
4009
+ }
4010
+ return;
4011
+ }
4012
+
4013
+ const target = e.target.closest('p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th');
4014
+ if (!target) return;
4015
+
4016
+ // Use table-specific search for table cells
4017
+ const isTableCell = target.tagName === 'TD' || target.tagName === 'TH';
4018
+ const line = isTableCell ? findTableSourceLine(target.textContent) : findSourceLine(target.textContent);
4019
+ if (line <= 0) return;
4020
+
4021
+ e.preventDefault();
4022
+ selectSourceRange(line);
4023
+ });
4024
+
4025
+ // Text selection to open comment for range
4026
+ preview.addEventListener('mouseup', (e) => {
4027
+ setTimeout(() => {
4028
+ const sel = window.getSelection();
4029
+ if (!sel || sel.isCollapsed) return;
4030
+
4031
+ const text = sel.toString().trim();
4032
+ if (!text || text.length < 5) return;
4033
+
4034
+ const lines = text.split('\\n').filter(l => l.trim());
4035
+ if (lines.length === 0) return;
4036
+
4037
+ const startLine = findSourceLine(lines[0]);
4038
+ const endLine = lines.length > 1 ? findSourceLine(lines[lines.length - 1]) : startLine;
4039
+
4040
+ if (startLine <= 0) return;
4041
+
4042
+ sel.removeAllRanges();
4043
+ selectSourceRange(startLine, endLine > 0 ? endLine : startLine);
4044
+ }, 10);
4045
+ });
4046
+ })();
3655
4047
  </script>
3656
4048
  </body>
3657
4049
  </html>`;
@@ -3890,7 +4282,10 @@ function createFileServer(filePath) {
3890
4282
  res.end("not found");
3891
4283
  });
3892
4284
 
4285
+ let serverStarted = false;
4286
+
3893
4287
  function tryListen(attemptPort, attempts = 0) {
4288
+ if (serverStarted) return; // Prevent double-start race condition
3894
4289
  if (attempts >= MAX_PORT_ATTEMPTS) {
3895
4290
  console.error(
3896
4291
  `Could not find an available port for ${baseName} after ${MAX_PORT_ATTEMPTS} attempts.`,
@@ -3901,6 +4296,7 @@ function createFileServer(filePath) {
3901
4296
  }
3902
4297
 
3903
4298
  ctx.server.once("error", (err) => {
4299
+ if (serverStarted) return; // Already started on another port
3904
4300
  if (err.code === "EADDRINUSE") {
3905
4301
  tryListen(attemptPort + 1, attempts + 1);
3906
4302
  } else {
@@ -3911,6 +4307,12 @@ function createFileServer(filePath) {
3911
4307
  });
3912
4308
 
3913
4309
  ctx.server.listen(attemptPort, () => {
4310
+ if (serverStarted) {
4311
+ // Race condition: server started on multiple ports, close this one
4312
+ try { ctx.server.close(); } catch (_) {}
4313
+ return;
4314
+ }
4315
+ serverStarted = true;
3914
4316
  ctx.port = attemptPort;
3915
4317
  nextPort = attemptPort + 1;
3916
4318
  activeServers.set(filePath, ctx);
@@ -4044,7 +4446,10 @@ function createDiffServer(diffContent) {
4044
4446
  res.end("not found");
4045
4447
  });
4046
4448
 
4449
+ let serverStarted = false;
4450
+
4047
4451
  function tryListen(attemptPort, attempts = 0) {
4452
+ if (serverStarted) return; // Prevent double-start race condition
4048
4453
  if (attempts >= MAX_PORT_ATTEMPTS) {
4049
4454
  console.error(
4050
4455
  `Could not find an available port for diff viewer after ${MAX_PORT_ATTEMPTS} attempts.`,
@@ -4055,6 +4460,7 @@ function createDiffServer(diffContent) {
4055
4460
  }
4056
4461
 
4057
4462
  ctx.server.once("error", (err) => {
4463
+ if (serverStarted) return; // Already started on another port
4058
4464
  if (err.code === "EADDRINUSE") {
4059
4465
  tryListen(attemptPort + 1, attempts + 1);
4060
4466
  } else {
@@ -4065,6 +4471,12 @@ function createDiffServer(diffContent) {
4065
4471
  });
4066
4472
 
4067
4473
  ctx.server.listen(attemptPort, () => {
4474
+ if (serverStarted) {
4475
+ // Race condition: server started on multiple ports, close this one
4476
+ try { ctx.server.close(); } catch (_) {}
4477
+ return;
4478
+ }
4479
+ serverStarted = true;
4068
4480
  ctx.port = attemptPort;
4069
4481
  ctx.heartbeat = setInterval(() => broadcast("ping"), 25000);
4070
4482
  console.log(`Diff viewer started: http://localhost:${attemptPort}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reviw",
3
- "version": "0.9.2",
3
+ "version": "0.10.1",
4
4
  "description": "Lightweight file reviewer with in-browser comments for CSV, TSV, Markdown, and Git diffs.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,10 +9,6 @@
9
9
  "files": [
10
10
  "cli.cjs"
11
11
  ],
12
- "scripts": {
13
- "test": "vitest run",
14
- "test:watch": "vitest"
15
- },
16
12
  "license": "MIT",
17
13
  "author": "kazuph",
18
14
  "publishConfig": {
@@ -31,5 +27,9 @@
31
27
  "@playwright/test": "^1.57.0",
32
28
  "playwright": "^1.57.0",
33
29
  "vitest": "^4.0.14"
30
+ },
31
+ "scripts": {
32
+ "test": "vitest run",
33
+ "test:watch": "vitest"
34
34
  }
35
- }
35
+ }