reviw 0.18.0 → 0.19.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.
package/README.ja.md CHANGED
@@ -177,7 +177,7 @@ plugin/
177
177
 
178
178
  | 種類 | 名前 | 説明 |
179
179
  |------|------|------|
180
- | **コマンド** | `/reviw:do` | タスク開始 - gwqでworktree作成、計画、todo登録 |
180
+ | **コマンド** | `/reviw:do` | タスク開始 - git wtでworktree作成、計画、todo登録 |
181
181
  | **コマンド** | `/reviw:done` | 完了チェックリスト - 7レビューエージェント実行、エビデンス収集、レビュー開始 |
182
182
  | **エージェント** | `report-builder` | ユーザーレビュー用レポート準備 |
183
183
  | **エージェント** | `review-code-quality` | コード品質: 可読性、DRY、型安全性、エラーハンドリング |
@@ -201,14 +201,14 @@ plugin/
201
201
  適切な環境セットアップで新しいタスクを開始します。
202
202
 
203
203
  **処理内容:**
204
- 1. gwqを使用して分離開発用のgit worktreeを作成(`feature/<name>`、`fix/<name>`など)
204
+ 1. git wtを使用して分離開発用のgit worktreeを作成(`feature/<name>`、`fix/<name>`など)
205
205
  2. エビデンス用の`.artifacts/<feature>/`ディレクトリをセットアップ
206
206
  3. 計画とTODOチェックリスト付きの`REPORT.md`を作成
207
207
  4. 進捗追跡用にTodoWriteにtodoを登録
208
208
 
209
209
  **作成されるディレクトリ構成:**
210
210
  ```
211
- <worktree>/ # 例: ~/src/github.com/owner/myrepo-feature-auth/
211
+ <worktree>/ # 例: .worktree/feature-auth/
212
212
  └── .artifacts/
213
213
  └── <feature>/ # 例: auth(feature/authから)
214
214
  ├── REPORT.md # 計画、進捗、エビデンスリンク
@@ -216,7 +216,7 @@ plugin/
216
216
  └── videos/ # 動画録画
217
217
  ```
218
218
 
219
- **タスク再開:** セッション開始時またはコンテキスト圧縮後、コマンドは既存のworktreeを確認(`gwq list`)し、`REPORT.md`から再開します。
219
+ **タスク再開:** セッション開始時またはコンテキスト圧縮後、コマンドは既存のworktreeを確認(`git wt`)し、`REPORT.md`から再開します。
220
220
 
221
221
  #### `/reviw:done`
222
222
 
package/README.md CHANGED
@@ -180,7 +180,7 @@ plugin/
180
180
 
181
181
  | Type | Name | Description |
182
182
  |------|------|-------------|
183
- | **Command** | `/reviw:do` | Start a task - create worktree with gwq, plan, register todos |
183
+ | **Command** | `/reviw:do` | Start a task - create worktree with git wt, plan, register todos |
184
184
  | **Command** | `/reviw:done` | Complete checklist - run 7 review agents, collect evidence, start review |
185
185
  | **Agent** | `report-builder` | Prepare reports and evidence for user review |
186
186
  | **Agent** | `review-code-quality` | Code quality: readability, DRY, type safety, error handling |
@@ -204,14 +204,14 @@ plugin/
204
204
  Starts a new task with proper environment setup.
205
205
 
206
206
  **What it does:**
207
- 1. Creates a git worktree using gwq for isolated development (`feature/<name>`, `fix/<name>`, etc.)
207
+ 1. Creates a git worktree using git wt for isolated development (`feature/<name>`, `fix/<name>`, etc.)
208
208
  2. Sets up `.artifacts/<feature>/` directory for evidence
209
209
  3. Creates `REPORT.md` with plan and TODO checklist
210
210
  4. Registers todos in TodoWrite for progress tracking
211
211
 
212
212
  **Directory structure created:**
213
213
  ```
214
- <worktree>/ # e.g., ~/src/github.com/owner/myrepo-feature-auth/
214
+ <worktree>/ # e.g., .worktree/feature-auth/
215
215
  └── .artifacts/
216
216
  └── <feature>/ # e.g., auth (from feature/auth)
217
217
  ├── REPORT.md # Plan, progress, evidence links
@@ -219,7 +219,7 @@ Starts a new task with proper environment setup.
219
219
  └── videos/ # Video recordings
220
220
  ```
221
221
 
222
- **Task resumption:** When a session starts or after context compaction, the command checks for existing worktrees (via `gwq list`) and resumes from `REPORT.md`.
222
+ **Task resumption:** When a session starts or after context compaction, the command checks for existing worktrees (via `git wt`) and resumes from `REPORT.md`.
223
223
 
224
224
  #### `/reviw:done`
225
225
 
package/cli.cjs CHANGED
@@ -37,9 +37,10 @@ const timelineTempDirs = new Set();
37
37
 
38
38
  // Extract video timeline thumbnails using ffmpeg scene detection
39
39
  // Uses "stabilization filter" - groups consecutive similar frames and takes the last frame of each group
40
- function extractVideoTimeline(videoPath, tmpDir, res, onComplete) {
41
- const STABILIZATION_THRESHOLD = 0.5; // seconds - consecutive changes within this are grouped
42
- const SCENE_THRESHOLD = 0.01; // Very low threshold to catch subtle color changes
40
+ function extractVideoTimeline(videoPath, tmpDir, res, onComplete, options = {}) {
41
+ // Use provided thresholds or defaults
42
+ const STABILIZATION_THRESHOLD = options.stabilizationThreshold || 0.5; // seconds - consecutive changes within this are grouped
43
+ const SCENE_THRESHOLD = options.sceneThreshold || 0.005; // Lower default threshold (was 0.01) to catch more subtle changes
43
44
  const MIN_INTERVAL = 1.0; // Minimum interval for additional keyframes (seconds)
44
45
 
45
46
  // First, get video duration using ffprobe
@@ -373,6 +374,13 @@ function sanitizeHtml(html) {
373
374
  });
374
375
  }
375
376
 
377
+ // [TDD Fix] 重複見出しキーを防ぐためのカウンター
378
+ // 同じテキストの見出しが複数ある場合でも、ユニークなキーを生成する
379
+ var headingKeyCounter = {};
380
+ function resetHeadingKeyCounter() {
381
+ headingKeyCounter = {};
382
+ }
383
+
376
384
  marked.use({
377
385
  hooks: {
378
386
  // テーブルをスクロールラッパーで囲む(後処理)
@@ -382,6 +390,21 @@ marked.use({
382
390
  }
383
391
  },
384
392
  renderer: {
393
+ // 見出しをトグル可能なセクションとしてレンダリング
394
+ // markedのrenderer.headingは (text, level, raw) を引数に取る
395
+ heading: function(text, level, raw) {
396
+ // トグル用のdata属性を追加(見出しテキスト + 出現順でユニークキーを生成)
397
+ var baseKey = (text || '').replace(/[^a-zA-Z0-9\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF\u3400-\u4DBF]/g, '-').toLowerCase();
398
+ // 同じbaseKeyが出現した回数をカウントしてユニーク化
399
+ if (headingKeyCounter[baseKey] === undefined) {
400
+ headingKeyCounter[baseKey] = 0;
401
+ }
402
+ var headingKey = baseKey + '-' + headingKeyCounter[baseKey]++;
403
+ return '<h' + level + ' class="md-heading-toggle" data-heading-key="' + escapeHtmlForXss(headingKey) + '" data-heading-level="' + level + '">' +
404
+ '<span class="heading-toggle-icon">▼</span>' +
405
+ text +
406
+ '</h' + level + '>';
407
+ },
385
408
  // 生HTMLブロックをサニタイズ
386
409
  html: function(token) {
387
410
  var text = token.raw || token.text || token;
@@ -862,6 +885,9 @@ function loadText(filePath) {
862
885
  }
863
886
 
864
887
  function loadMarkdown(filePath) {
888
+ // [TDD Fix] カウンターをリセットして新しいMarkdownファイルの見出しキーを初期化
889
+ resetHeadingKeyCounter();
890
+
865
891
  const raw = fs.readFileSync(filePath);
866
892
  const text = decodeBuffer(raw);
867
893
  const lines = text.split(/\r?\n/);
@@ -3020,6 +3046,35 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3020
3046
  .md-preview h1, .md-preview h2, .md-preview h3, .md-preview h4 {
3021
3047
  margin: 0.4em 0 0.2em;
3022
3048
  }
3049
+ /* Heading toggle feature */
3050
+ .md-preview .md-heading-toggle {
3051
+ display: flex;
3052
+ align-items: center;
3053
+ gap: 6px;
3054
+ }
3055
+ .md-preview .heading-toggle-icon {
3056
+ font-size: 0.6em;
3057
+ transition: transform 150ms ease;
3058
+ color: var(--muted);
3059
+ flex-shrink: 0;
3060
+ cursor: pointer;
3061
+ padding: 4px 8px;
3062
+ margin: -4px 0 -4px -8px;
3063
+ border-radius: 4px;
3064
+ }
3065
+ .md-preview .heading-toggle-icon:hover {
3066
+ background: var(--hover-bg);
3067
+ color: var(--accent);
3068
+ }
3069
+ .md-preview .md-heading-toggle.collapsed .heading-toggle-icon {
3070
+ transform: rotate(-90deg);
3071
+ }
3072
+ .md-preview .heading-section-content {
3073
+ /* Content wrapper for collapsible sections */
3074
+ }
3075
+ .md-preview .heading-section-content.hidden {
3076
+ display: none;
3077
+ }
3023
3078
  .md-preview p { margin: 0.3em 0; line-height: 1.5; }
3024
3079
  .md-preview img { max-width: 100%; height: auto; border-radius: 8px; }
3025
3080
  .md-preview video.video-preview { max-width: 100%; height: auto; border-radius: 8px; background: #000; }
@@ -3425,6 +3480,111 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3425
3480
  position: relative;
3426
3481
  flex-shrink: 0;
3427
3482
  }
3483
+ /* Video threshold settings panel */
3484
+ .video-settings-btn {
3485
+ position: absolute;
3486
+ top: 14px;
3487
+ right: 64px;
3488
+ width: 40px;
3489
+ height: 40px;
3490
+ display: flex;
3491
+ align-items: center;
3492
+ justify-content: center;
3493
+ background: rgba(0, 0, 0, 0.55);
3494
+ border: 1px solid rgba(255, 255, 255, 0.25);
3495
+ border-radius: 50%;
3496
+ cursor: pointer;
3497
+ color: #fff;
3498
+ font-size: 18px;
3499
+ z-index: 10;
3500
+ backdrop-filter: blur(4px);
3501
+ transition: background 120ms ease, transform 120ms ease;
3502
+ }
3503
+ .video-settings-btn:hover {
3504
+ background: rgba(0, 0, 0, 0.75);
3505
+ transform: scale(1.04);
3506
+ }
3507
+ .video-settings-panel {
3508
+ position: absolute;
3509
+ top: 60px;
3510
+ right: 14px;
3511
+ background: rgba(0, 0, 0, 0.9);
3512
+ border: 1px solid rgba(255, 255, 255, 0.2);
3513
+ border-radius: 12px;
3514
+ padding: 16px;
3515
+ z-index: 15;
3516
+ min-width: 280px;
3517
+ display: none;
3518
+ backdrop-filter: blur(8px);
3519
+ }
3520
+ .video-settings-panel.visible {
3521
+ display: block;
3522
+ }
3523
+ .video-settings-panel h4 {
3524
+ margin: 0 0 8px;
3525
+ color: #fff;
3526
+ font-size: 14px;
3527
+ font-weight: 500;
3528
+ }
3529
+ .video-settings-desc {
3530
+ margin: 0 0 12px;
3531
+ color: rgba(255, 255, 255, 0.6);
3532
+ font-size: 11px;
3533
+ line-height: 1.4;
3534
+ }
3535
+ .video-settings-buttons {
3536
+ display: flex;
3537
+ gap: 6px;
3538
+ margin-bottom: 12px;
3539
+ }
3540
+ .video-settings-buttons button {
3541
+ flex: 1;
3542
+ padding: 8px 4px;
3543
+ border: 1px solid rgba(255, 255, 255, 0.2);
3544
+ border-radius: 6px;
3545
+ background: rgba(255, 255, 255, 0.1);
3546
+ color: rgba(255, 255, 255, 0.8);
3547
+ font-size: 11px;
3548
+ cursor: pointer;
3549
+ transition: all 120ms ease;
3550
+ }
3551
+ .video-settings-buttons button:hover {
3552
+ background: rgba(255, 255, 255, 0.2);
3553
+ border-color: rgba(255, 255, 255, 0.3);
3554
+ }
3555
+ .video-settings-buttons button.selected {
3556
+ background: #3b82f6;
3557
+ border-color: #3b82f6;
3558
+ color: #fff;
3559
+ }
3560
+ .video-settings-actions {
3561
+ display: flex;
3562
+ gap: 8px;
3563
+ margin-top: 8px;
3564
+ }
3565
+ .video-settings-actions button {
3566
+ flex: 1;
3567
+ padding: 8px 12px;
3568
+ border: none;
3569
+ border-radius: 6px;
3570
+ font-size: 12px;
3571
+ cursor: pointer;
3572
+ transition: background 120ms ease;
3573
+ }
3574
+ .video-settings-actions .regenerate-btn {
3575
+ background: #3b82f6;
3576
+ color: #fff;
3577
+ }
3578
+ .video-settings-actions .regenerate-btn:hover {
3579
+ background: #2563eb;
3580
+ }
3581
+ .video-settings-actions .reset-btn {
3582
+ background: rgba(255, 255, 255, 0.15);
3583
+ color: #fff;
3584
+ }
3585
+ .video-settings-actions .reset-btn:hover {
3586
+ background: rgba(255, 255, 255, 0.25);
3587
+ }
3428
3588
  /* Video Shortcuts Help */
3429
3589
  .video-shortcuts-help {
3430
3590
  opacity: 0.85;
@@ -4358,6 +4518,21 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4358
4518
  </div>
4359
4519
  <div class="video-fullscreen-overlay" id="video-fullscreen">
4360
4520
  <button class="video-close-btn" id="video-close" aria-label="Close video" title="Close (ESC)">✕</button>
4521
+ <button class="video-settings-btn" id="video-settings-btn" aria-label="Timeline settings" title="Timeline settings">⚙</button>
4522
+ <div class="video-settings-panel" id="video-settings-panel">
4523
+ <h4>サムネイル数の調整</h4>
4524
+ <p class="video-settings-desc">重要シーンの見逃しを防ぐため、サムネイル数を調整できます</p>
4525
+ <div class="video-settings-buttons" id="scene-buttons">
4526
+ <button data-scene="0.05" data-stab="1.0">少なめ</button>
4527
+ <button data-scene="0.02" data-stab="0.8">やや少</button>
4528
+ <button data-scene="0.005" data-stab="0.5" class="selected">標準</button>
4529
+ <button data-scene="0.002" data-stab="0.3">やや多</button>
4530
+ <button data-scene="0.001" data-stab="0.2">多め</button>
4531
+ </div>
4532
+ <div class="video-settings-actions">
4533
+ <button class="regenerate-btn" id="video-settings-regenerate">この設定で再生成</button>
4534
+ </div>
4535
+ </div>
4361
4536
  <div class="video-container" id="video-container"></div>
4362
4537
  </div>
4363
4538
 
@@ -6083,8 +6258,17 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
6083
6258
  let startX, startY;
6084
6259
  let svgNaturalWidth = 0, svgNaturalHeight = 0;
6085
6260
  let minimapScale = 1;
6261
+ let currentMermaidContainer = null; // Store the container for selection after close
6262
+ let skipNextFullscreenOpen = false; // Flag to skip opening fullscreen after close
6086
6263
 
6087
6264
  function openFullscreen(mermaidEl) {
6265
+ // Skip if this is triggered by the post-close click
6266
+ if (skipNextFullscreenOpen) {
6267
+ skipNextFullscreenOpen = false;
6268
+ return;
6269
+ }
6270
+ // Store the container for selection when fullscreen closes
6271
+ currentMermaidContainer = mermaidEl.closest('.mermaid-container');
6088
6272
  const svg = mermaidEl.querySelector('svg');
6089
6273
  if (!svg) return;
6090
6274
  fsWrapper.innerHTML = '';
@@ -6146,6 +6330,17 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
6146
6330
 
6147
6331
  function closeFullscreen() {
6148
6332
  fsOverlay.classList.remove('visible');
6333
+ // Trigger selection of the mermaid container after closing fullscreen
6334
+ if (currentMermaidContainer) {
6335
+ const containerToSelect = currentMermaidContainer;
6336
+ currentMermaidContainer = null;
6337
+ // Set flags to skip reopening fullscreen and trigger selection
6338
+ setTimeout(() => {
6339
+ skipNextFullscreenOpen = true;
6340
+ window._mermaidSelectAfterClose = containerToSelect;
6341
+ containerToSelect.click();
6342
+ }, 100);
6343
+ }
6149
6344
  }
6150
6345
 
6151
6346
  function updateTransform() {
@@ -6375,6 +6570,82 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
6375
6570
  }
6376
6571
  })();
6377
6572
 
6573
+ // --- Heading Toggle ---
6574
+ (function initHeadingToggle() {
6575
+ const preview = document.querySelector('.md-preview');
6576
+ if (!preview) return;
6577
+
6578
+ const STORAGE_PREFIX = 'reviw-heading-toggle-';
6579
+
6580
+ // Get all headings
6581
+ const headings = Array.from(preview.querySelectorAll('.md-heading-toggle'));
6582
+ if (headings.length === 0) return;
6583
+
6584
+ // Wrap content between headings into sections
6585
+ headings.forEach((heading, idx) => {
6586
+ const level = parseInt(heading.dataset.headingLevel) || 1;
6587
+ const key = heading.dataset.headingKey;
6588
+
6589
+ // Collect all siblings until next heading of same or higher level
6590
+ const content = [];
6591
+ let sibling = heading.nextElementSibling;
6592
+
6593
+ while (sibling) {
6594
+ // Check if it's a heading of same or higher level
6595
+ if (sibling.classList.contains('md-heading-toggle')) {
6596
+ const siblingLevel = parseInt(sibling.dataset.headingLevel) || 1;
6597
+ if (siblingLevel <= level) break;
6598
+ }
6599
+ content.push(sibling);
6600
+ sibling = sibling.nextElementSibling;
6601
+ }
6602
+
6603
+ // Create wrapper for content
6604
+ if (content.length > 0) {
6605
+ const wrapper = document.createElement('div');
6606
+ wrapper.className = 'heading-section-content';
6607
+ wrapper.dataset.forHeading = key;
6608
+
6609
+ // Move content into wrapper
6610
+ const firstContent = content[0];
6611
+ heading.parentNode.insertBefore(wrapper, firstContent);
6612
+ content.forEach(el => wrapper.appendChild(el));
6613
+
6614
+ // Restore state from localStorage
6615
+ const savedState = localStorage.getItem(STORAGE_PREFIX + key);
6616
+ if (savedState === 'collapsed') {
6617
+ heading.classList.add('collapsed');
6618
+ wrapper.classList.add('hidden');
6619
+ }
6620
+ }
6621
+
6622
+ // Add click handler to toggle icon only (not the whole heading)
6623
+ // This allows the heading text to remain selectable for comments
6624
+ const toggleIcon = heading.querySelector('.heading-toggle-icon');
6625
+ if (toggleIcon) {
6626
+ toggleIcon.addEventListener('click', (e) => {
6627
+ e.stopPropagation(); // Prevent triggering comment selection
6628
+ const wrapper = preview.querySelector(\`.heading-section-content[data-for-heading="\${key}"]\`);
6629
+ if (!wrapper) return;
6630
+
6631
+ const isCollapsed = heading.classList.toggle('collapsed');
6632
+ wrapper.classList.toggle('hidden', isCollapsed);
6633
+
6634
+ // Save state to localStorage
6635
+ localStorage.setItem(STORAGE_PREFIX + key, isCollapsed ? 'collapsed' : 'expanded');
6636
+
6637
+ // Re-render Mermaid diagrams when expanding (they may not render while hidden)
6638
+ if (!isCollapsed && typeof mermaid !== 'undefined') {
6639
+ const mermaidDivs = wrapper.querySelectorAll('.mermaid');
6640
+ if (mermaidDivs.length > 0) {
6641
+ mermaid.run({ nodes: Array.from(mermaidDivs) }).catch(() => {});
6642
+ }
6643
+ }
6644
+ });
6645
+ }
6646
+ });
6647
+ })();
6648
+
6378
6649
  // --- Image Fullscreen ---
6379
6650
  (function initImageFullscreen() {
6380
6651
  const preview = document.querySelector('.md-preview');
@@ -6526,8 +6797,23 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
6526
6797
  return mins + ':' + (secs < 10 ? '0' : '') + secs;
6527
6798
  }
6528
6799
 
6800
+ // Default threshold values
6801
+ const DEFAULT_SCENE_THRESHOLD = 0.005;
6802
+ const DEFAULT_STABILIZATION_THRESHOLD = 0.5;
6803
+ let currentSceneThreshold = DEFAULT_SCENE_THRESHOLD;
6804
+ let currentStabilizationThreshold = DEFAULT_STABILIZATION_THRESHOLD;
6805
+ let currentVideoPath = null;
6806
+ let currentVideo = null;
6807
+
6529
6808
  // Load video timeline via SSE
6530
- function loadVideoTimeline(videoPath, video) {
6809
+ function loadVideoTimeline(videoPath, video, options = {}) {
6810
+ const sceneThreshold = options.sceneThreshold || currentSceneThreshold;
6811
+ const stabilizationThreshold = options.stabilizationThreshold || currentStabilizationThreshold;
6812
+
6813
+ // Store for regeneration
6814
+ currentVideoPath = videoPath;
6815
+ currentVideo = video;
6816
+
6531
6817
  // Close existing connection
6532
6818
  if (currentTimelineEventSource) {
6533
6819
  currentTimelineEventSource.close();
@@ -6552,9 +6838,9 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
6552
6838
 
6553
6839
  videoContainer.appendChild(timeline);
6554
6840
 
6555
- // Start SSE connection
6841
+ // Start SSE connection with threshold parameters
6556
6842
  const encodedPath = encodeURIComponent(videoPath);
6557
- const es = new EventSource('/video-timeline?path=' + encodedPath);
6843
+ const es = new EventSource(\`/video-timeline?path=\${encodedPath}&scene=\${sceneThreshold}&stabilization=\${stabilizationThreshold}\`);
6558
6844
  currentTimelineEventSource = es;
6559
6845
 
6560
6846
  es.onmessage = function(e) {
@@ -6648,6 +6934,11 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
6648
6934
  }
6649
6935
 
6650
6936
  function showVideo(index) {
6937
+ // Skip if this is triggered by the post-close click
6938
+ if (window._skipNextVideoFullscreen) {
6939
+ window._skipNextVideoFullscreen = false;
6940
+ return;
6941
+ }
6651
6942
  if (index < 0 || index >= allVideoSources.length) return;
6652
6943
  currentVideoIndex = index;
6653
6944
  const source = allVideoSources[index];
@@ -6730,6 +7021,12 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
6730
7021
 
6731
7022
  function closeVideoOverlay() {
6732
7023
  videoOverlay.classList.remove('visible');
7024
+
7025
+ // Save source element before resetting index (for triggering selection)
7026
+ const closedSource = currentVideoIndex >= 0 && currentVideoIndex < allVideoSources.length
7027
+ ? allVideoSources[currentVideoIndex]
7028
+ : null;
7029
+
6733
7030
  currentVideoIndex = -1;
6734
7031
 
6735
7032
  // Close timeline SSE connection
@@ -6747,6 +7044,18 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
6747
7044
  video.remove();
6748
7045
  }
6749
7046
 
7047
+ // Trigger selection on the source element's parent table cell (if any)
7048
+ if (closedSource && closedSource.element) {
7049
+ const parentCell = closedSource.element.closest('td, th');
7050
+ if (parentCell) {
7051
+ // Set global flag to skip reopening fullscreen, then trigger click for selection
7052
+ setTimeout(() => {
7053
+ window._skipNextVideoFullscreen = true;
7054
+ parentCell.click();
7055
+ }, 100);
7056
+ }
7057
+ }
7058
+
6750
7059
  // Remove timeline
6751
7060
  const timeline = videoContainer.querySelector('.video-timeline');
6752
7061
  if (timeline) timeline.remove();
@@ -6931,6 +7240,63 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
6931
7240
  });
6932
7241
  }
6933
7242
  });
7243
+
7244
+ // --- Video Settings Panel ---
7245
+ const settingsBtn = document.getElementById('video-settings-btn');
7246
+ const settingsPanel = document.getElementById('video-settings-panel');
7247
+ const sceneButtons = document.getElementById('scene-buttons');
7248
+ const regenerateBtn = document.getElementById('video-settings-regenerate');
7249
+
7250
+ if (settingsBtn && settingsPanel) {
7251
+ // Toggle settings panel
7252
+ settingsBtn.addEventListener('click', (e) => {
7253
+ e.stopPropagation();
7254
+ settingsPanel.classList.toggle('visible');
7255
+ });
7256
+
7257
+ // Prevent panel clicks from closing overlay
7258
+ settingsPanel.addEventListener('click', (e) => e.stopPropagation());
7259
+
7260
+ // Handle 5-level button clicks
7261
+ if (sceneButtons) {
7262
+ const buttons = sceneButtons.querySelectorAll('button');
7263
+ buttons.forEach(btn => {
7264
+ btn.addEventListener('click', () => {
7265
+ buttons.forEach(b => b.classList.remove('selected'));
7266
+ btn.classList.add('selected');
7267
+ });
7268
+ });
7269
+ }
7270
+
7271
+ // Regenerate timeline with selected settings
7272
+ if (regenerateBtn) {
7273
+ regenerateBtn.addEventListener('click', () => {
7274
+ if (!currentVideoPath || !currentVideo) return;
7275
+
7276
+ // Get selected button's values
7277
+ const selectedBtn = sceneButtons?.querySelector('button.selected');
7278
+ if (selectedBtn) {
7279
+ currentSceneThreshold = parseFloat(selectedBtn.dataset.scene) || DEFAULT_SCENE_THRESHOLD;
7280
+ currentStabilizationThreshold = parseFloat(selectedBtn.dataset.stab) || DEFAULT_STABILIZATION_THRESHOLD;
7281
+ }
7282
+
7283
+ loadVideoTimeline(currentVideoPath, currentVideo, {
7284
+ sceneThreshold: currentSceneThreshold,
7285
+ stabilizationThreshold: currentStabilizationThreshold
7286
+ });
7287
+
7288
+ // Hide panel after regenerating
7289
+ settingsPanel.classList.remove('visible');
7290
+ });
7291
+ }
7292
+
7293
+ // Close panel when overlay closes
7294
+ const originalCloseVideoOverlay = closeVideoOverlay;
7295
+ closeVideoOverlay = function() {
7296
+ settingsPanel.classList.remove('visible');
7297
+ originalCloseVideoOverlay();
7298
+ };
7299
+ }
6934
7300
  })();
6935
7301
 
6936
7302
  // --- Preview Commenting ---
@@ -6946,11 +7312,16 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
6946
7312
  .md-preview > p:hover, .md-preview > h1:hover, .md-preview > h2:hover,
6947
7313
  .md-preview > h3:hover, .md-preview > h4:hover, .md-preview > h5:hover,
6948
7314
  .md-preview > h6:hover, .md-preview > ul > li:hover, .md-preview > ol > li:hover,
6949
- .md-preview > pre:hover, .md-preview > blockquote:hover {
7315
+ .md-preview > pre:hover, .md-preview > blockquote:hover,
7316
+ .md-preview details summary:hover {
6950
7317
  background: rgba(99, 102, 241, 0.08);
6951
7318
  cursor: pointer;
6952
7319
  border-radius: 4px;
6953
7320
  }
7321
+ .md-preview td:hover, .md-preview th:hover {
7322
+ background: rgba(99, 102, 241, 0.12);
7323
+ cursor: pointer;
7324
+ }
6954
7325
  .md-preview img:hover {
6955
7326
  outline: 2px solid var(--accent);
6956
7327
  cursor: pointer;
@@ -7013,7 +7384,9 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
7013
7384
  }
7014
7385
 
7015
7386
  if (!text) return -1;
7016
- const normalized = text.trim().replace(/\\s+/g, ' ').slice(0, 100);
7387
+ // Remove toggle icon characters (▼, ▶) that may be included from heading toggles
7388
+ const cleanText = text.replace(/[▼▶]/g, '').trim();
7389
+ const normalized = cleanText.replace(/\\s+/g, ' ').slice(0, 100);
7017
7390
  if (!normalized) return -1;
7018
7391
 
7019
7392
  for (let i = 0; i < DATA.length; i++) {
@@ -7033,6 +7406,19 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
7033
7406
  }
7034
7407
  }
7035
7408
 
7409
+ // Check for HTML summary tags: <summary>text</summary>
7410
+ const summaryMatch = lineText.match(/<summary>([^<]*)<\\/summary>/i);
7411
+ if (summaryMatch) {
7412
+ const summaryText = summaryMatch[1].trim();
7413
+ if (summaryText === normalized || summaryText.toLowerCase() === normalized.toLowerCase()) {
7414
+ return i + 1;
7415
+ }
7416
+ // Partial match for long summary text
7417
+ if (summaryText.includes(normalized.slice(0, 30)) && normalized.length > 5) {
7418
+ return i + 1;
7419
+ }
7420
+ }
7421
+
7036
7422
  // Try stripping all markdown formatting (links, bold, italic, etc.)
7037
7423
  const strippedLine = stripMarkdown(lineText).replace(/\\s+/g, ' ').slice(0, 100);
7038
7424
  if (strippedLine === normalized) return i + 1;
@@ -7045,10 +7431,31 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
7045
7431
  // Helper: find matching source line for table cell (prioritizes table rows)
7046
7432
  function findTableSourceLine(text) {
7047
7433
  if (!text) return -1;
7048
- const normalized = text.trim().replace(/\\s+/g, ' ').slice(0, 100);
7434
+ // Remove toggle icon characters (▼, ▶) that may be included from heading toggles
7435
+ const cleanText = text.replace(/[▼▶]/g, '').trim();
7436
+ const normalized = cleanText.replace(/\\s+/g, ' ').slice(0, 100);
7049
7437
  if (!normalized) return -1;
7050
7438
 
7051
- // First pass: look for table rows (lines starting with |) containing the text
7439
+ // First pass: look for EXACT cell text match (not inside markdown syntax)
7440
+ for (let i = 0; i < DATA.length; i++) {
7441
+ const lineText = (DATA[i][0] || '').trim();
7442
+ if (!lineText || !lineText.startsWith('|')) continue;
7443
+
7444
+ // Split into cells and check for exact match (excluding markdown syntax)
7445
+ const cells = lineText.split('|').map(c => c.trim());
7446
+ for (const cell of cells) {
7447
+ // Skip cells that are markdown images/links (start with ![, contain []())
7448
+ if (cell.match(/^!?\\[.*\\]\\(.*\\)$/)) continue;
7449
+
7450
+ // Check for exact cell text match
7451
+ if (cell === normalized) return i + 1;
7452
+
7453
+ // For short text (like header cells), require exact word match
7454
+ if (normalized.length <= 5 && cell === normalized) return i + 1;
7455
+ }
7456
+ }
7457
+
7458
+ // Second pass: look for partial matches (including inside markdown syntax)
7052
7459
  for (let i = 0; i < DATA.length; i++) {
7053
7460
  const lineText = (DATA[i][0] || '').trim();
7054
7461
  if (!lineText || !lineText.startsWith('|')) continue;
@@ -7137,6 +7544,51 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
7137
7544
  return { startLine: -1, endLine: -1 };
7138
7545
  }
7139
7546
 
7547
+ // Helper: find source line range for mermaid diagram by its container
7548
+ function findMermaidSourceLine(mermaidContainer) {
7549
+ if (!mermaidContainer) return { startLine: -1, endLine: -1 };
7550
+
7551
+ // Find the index of this mermaid container among all mermaid containers
7552
+ const allMermaidContainers = document.querySelectorAll('.mermaid-container');
7553
+ let containerIndex = -1;
7554
+ for (let i = 0; i < allMermaidContainers.length; i++) {
7555
+ if (allMermaidContainers[i] === mermaidContainer) {
7556
+ containerIndex = i;
7557
+ break;
7558
+ }
7559
+ }
7560
+ if (containerIndex < 0) return { startLine: -1, endLine: -1 };
7561
+
7562
+ // Find all mermaid code blocks in the source
7563
+ const mermaidBlocks = [];
7564
+ let currentBlock = null;
7565
+
7566
+ for (let i = 0; i < DATA.length; i++) {
7567
+ const lineText = (DATA[i][0] || '').trim();
7568
+
7569
+ if (lineText.match(/^\`\`\`mermaid/i) && !currentBlock) {
7570
+ // Start of a mermaid code block
7571
+ currentBlock = { startLine: i + 1, lines: [] };
7572
+ } else if (lineText === '\`\`\`' && currentBlock) {
7573
+ // End of the code block
7574
+ currentBlock.endLine = i + 1;
7575
+ mermaidBlocks.push(currentBlock);
7576
+ currentBlock = null;
7577
+ } else if (currentBlock) {
7578
+ // Inside the mermaid block
7579
+ currentBlock.lines.push(DATA[i][0] || '');
7580
+ }
7581
+ }
7582
+
7583
+ // Return the block at the same index as the container
7584
+ if (containerIndex < mermaidBlocks.length) {
7585
+ const block = mermaidBlocks[containerIndex];
7586
+ return { startLine: block.startLine, endLine: block.endLine };
7587
+ }
7588
+
7589
+ return { startLine: -1, endLine: -1 };
7590
+ }
7591
+
7140
7592
  // Helper: find source line for image by src
7141
7593
  function findImageSourceLine(src) {
7142
7594
  if (!src) return -1;
@@ -7217,6 +7669,44 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
7217
7669
  return;
7218
7670
  }
7219
7671
 
7672
+ // Handle video/video-button clicks - select source line first
7673
+ // This includes video elements, fullscreen buttons, video wrapper clicks,
7674
+ // AND clicks on table cells containing videos (for post-fullscreen selection)
7675
+ const videoElement = e.target.closest('video.video-preview');
7676
+ const videoButton = e.target.closest('.video-fullscreen-btn');
7677
+ const videoWrapper = e.target.closest('.video-fullscreen-wrapper');
7678
+
7679
+ // Also check if clicking on a td that contains a video
7680
+ const targetCell = e.target.closest('td, th');
7681
+ const cellContainsVideo = targetCell && targetCell.querySelector('video.video-preview');
7682
+
7683
+ if (videoElement || videoButton || videoWrapper || cellContainsVideo) {
7684
+ // Check if this is a post-fullscreen click (for selection only, skip fullscreen)
7685
+ const isPostFullscreenClick = window._skipNextVideoFullscreen;
7686
+ if (isPostFullscreenClick) {
7687
+ window._skipNextVideoFullscreen = false;
7688
+ }
7689
+
7690
+ // Find the parent table cell
7691
+ const refElement = videoElement || videoButton || videoWrapper || targetCell;
7692
+ const parentCell = refElement.closest('td, th') || targetCell;
7693
+ if (parentCell) {
7694
+ // Use video src to find the source line
7695
+ const video = videoElement || parentCell.querySelector('video');
7696
+ if (video && video.src) {
7697
+ const line = findImageSourceLine(video.src);
7698
+ if (line > 0) {
7699
+ selectSourceRange(line, null, parentCell);
7700
+ // Return if this is a post-fullscreen click (for selection only)
7701
+ if (isPostFullscreenClick) {
7702
+ return;
7703
+ }
7704
+ }
7705
+ }
7706
+ }
7707
+ // Don't return for normal clicks - let fullscreen handler work too
7708
+ }
7709
+
7220
7710
  // Handle links - sync to source but let link work normally
7221
7711
  const link = e.target.closest('a');
7222
7712
  if (link) {
@@ -7233,8 +7723,23 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
7233
7723
  return;
7234
7724
  }
7235
7725
 
7236
- // Ignore clicks on mermaid, video overlay
7237
- if (e.target.closest('.mermaid-container, .video-fullscreen-overlay')) return;
7726
+ // Handle mermaid container clicks for post-fullscreen selection
7727
+ const mermaidContainer = e.target.closest('.mermaid-container');
7728
+ if (mermaidContainer) {
7729
+ // Check if this is a post-fullscreen click for selection
7730
+ if (window._mermaidSelectAfterClose === mermaidContainer) {
7731
+ window._mermaidSelectAfterClose = null;
7732
+ const { startLine, endLine } = findMermaidSourceLine(mermaidContainer);
7733
+ if (startLine > 0) {
7734
+ selectSourceRange(startLine, endLine, mermaidContainer);
7735
+ }
7736
+ }
7737
+ // Always return to prevent fullscreen from reopening or other handling
7738
+ return;
7739
+ }
7740
+
7741
+ // Ignore clicks on video overlay
7742
+ if (e.target.closest('.video-fullscreen-overlay')) return;
7238
7743
 
7239
7744
  // Handle code blocks - select entire block
7240
7745
  const pre = e.target.closest('pre');
@@ -7248,15 +7753,31 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
7248
7753
  return;
7249
7754
  }
7250
7755
 
7251
- const target = e.target.closest('p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th');
7756
+ const target = e.target.closest('p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th, summary');
7252
7757
  if (!target) return;
7253
7758
 
7759
+ // Get text content, excluding toggle icon if present (for heading toggles)
7760
+ let searchText = target.textContent;
7761
+
7762
+ // For summary elements, clean up the text (may have disclosure triangle)
7763
+ if (target.tagName === 'SUMMARY') {
7764
+ searchText = searchText.replace(/^[▶▼◀►◆◇]?\\s*/, '').trim();
7765
+ }
7766
+ const toggleIcon = target.querySelector('.heading-toggle-icon');
7767
+ if (toggleIcon) {
7768
+ // Exclude the toggle icon text from search
7769
+ searchText = searchText.replace(toggleIcon.textContent, '').trim();
7770
+ }
7771
+
7254
7772
  // Use table-specific search for table cells, otherwise use element-aware search
7255
7773
  const isTableCell = target.tagName === 'TD' || target.tagName === 'TH';
7256
- const line = isTableCell ? findTableSourceLine(target.textContent) : findSourceLine(target.textContent, target);
7774
+ const line = isTableCell ? findTableSourceLine(searchText) : findSourceLine(searchText, target);
7257
7775
  if (line <= 0) return;
7258
7776
 
7259
- e.preventDefault();
7777
+ // Don't prevent default for summary elements - let native <details> toggle work
7778
+ if (target.tagName !== 'SUMMARY') {
7779
+ e.preventDefault();
7780
+ }
7260
7781
  selectSourceRange(line, null, target);
7261
7782
  });
7262
7783
 
@@ -8090,6 +8611,8 @@ function createFileServer(filePath, fileIndex = 0) {
8090
8611
  if (req.method === "GET" && req.url.startsWith("/video-timeline?")) {
8091
8612
  const urlParams = new URL(req.url, `http://localhost`);
8092
8613
  const videoPath = urlParams.searchParams.get("path");
8614
+ const sceneThreshold = parseFloat(urlParams.searchParams.get("scene")) || 0.005;
8615
+ const stabilizationThreshold = parseFloat(urlParams.searchParams.get("stabilization")) || 0.5;
8093
8616
 
8094
8617
  if (!videoPath) {
8095
8618
  res.writeHead(400, { "Content-Type": "text/plain" });
@@ -8148,7 +8671,7 @@ function createFileServer(filePath, fileIndex = 0) {
8148
8671
  // Ignore cleanup errors
8149
8672
  }
8150
8673
  });
8151
- });
8674
+ }, { sceneThreshold, stabilizationThreshold });
8152
8675
  return;
8153
8676
  }
8154
8677
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reviw",
3
- "version": "0.18.0",
3
+ "version": "0.19.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": {