reviw 0.18.0 → 0.20.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,10 +37,23 @@ 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
43
- const MIN_INTERVAL = 1.0; // Minimum interval for additional keyframes (seconds)
40
+ function extractVideoTimeline(videoPath, tmpDir, res, onComplete, options = {}) {
41
+ // Use provided thresholds or defaults
42
+ const STABILIZATION_THRESHOLD = options.stabilizationThreshold || 0.1; // seconds - consecutive changes within this are grouped
43
+ const SCENE_THRESHOLD = options.sceneThreshold || 0.01; // Default matches "標準" button
44
+ // Determine target thumbnail count based on BOTH scene sensitivity and stabilization
45
+ // Scene: lower threshold = more detail = more thumbnails
46
+ const SCENE_BASE = SCENE_THRESHOLD >= 0.1 ? 3
47
+ : SCENE_THRESHOLD >= 0.03 ? 5
48
+ : SCENE_THRESHOLD >= 0.01 ? 8
49
+ : SCENE_THRESHOLD >= 0.003 ? 12
50
+ : 20;
51
+ // Stab: lower threshold = finer granularity = scale up target
52
+ const STAB_FACTOR = STABILIZATION_THRESHOLD >= 0.3 ? 0.6
53
+ : STABILIZATION_THRESHOLD >= 0.1 ? 1.0
54
+ : STABILIZATION_THRESHOLD >= 0.05 ? 1.4
55
+ : 1.8;
56
+ const TARGET_THUMBNAILS = Math.round(SCENE_BASE * STAB_FACTOR);
44
57
 
45
58
  // First, get video duration using ffprobe
46
59
  let videoDuration = 0;
@@ -150,14 +163,16 @@ function extractVideoTimeline(videoPath, tmpDir, res, onComplete) {
150
163
  }
151
164
  }
152
165
 
153
- // If we have very few thumbnails for a long video, add interval-based ones
154
- if (videoDuration > 3 && allThumbnails.length < 3) {
155
- // Extract frames at regular intervals
156
- const intervalCount = Math.min(5, Math.floor(videoDuration / MIN_INTERVAL));
157
- for (let i = 1; i < intervalCount; i++) {
166
+ // Fill up to target thumbnail count with interval-based extraction
167
+ // This ensures scene sensitivity actually affects output even for gradient videos
168
+ if (videoDuration > 1 && allThumbnails.length < TARGET_THUMBNAILS) {
169
+ const needed = TARGET_THUMBNAILS - allThumbnails.length;
170
+ const intervalCount = needed + 1;
171
+ for (let i = 1; i <= needed; i++) {
158
172
  const time = (videoDuration / intervalCount) * i;
159
173
  // Check if we already have a thumbnail close to this time
160
- const hasNearby = allThumbnails.some(t => Math.abs(t.time - time) < STABILIZATION_THRESHOLD);
174
+ const dedupThreshold = Math.min(STABILIZATION_THRESHOLD, 0.1);
175
+ const hasNearby = allThumbnails.some(t => Math.abs(t.time - time) < dedupThreshold);
161
176
  if (!hasNearby) {
162
177
  const intervalPath = `${tmpDir}/interval_${i}.jpg`;
163
178
  const result = spawnSync('ffmpeg', [
@@ -181,7 +196,7 @@ function extractVideoTimeline(videoPath, tmpDir, res, onComplete) {
181
196
  // Remove duplicates (same time within threshold)
182
197
  const uniqueThumbnails = [];
183
198
  for (const thumb of allThumbnails) {
184
- const isDuplicate = uniqueThumbnails.some(t => Math.abs(t.time - thumb.time) < 0.1);
199
+ const isDuplicate = uniqueThumbnails.some(t => Math.abs(t.time - thumb.time) < Math.min(STABILIZATION_THRESHOLD, 0.1));
185
200
  if (!isDuplicate) {
186
201
  uniqueThumbnails.push(thumb);
187
202
  }
@@ -373,6 +388,13 @@ function sanitizeHtml(html) {
373
388
  });
374
389
  }
375
390
 
391
+ // [TDD Fix] 重複見出しキーを防ぐためのカウンター
392
+ // 同じテキストの見出しが複数ある場合でも、ユニークなキーを生成する
393
+ var headingKeyCounter = {};
394
+ function resetHeadingKeyCounter() {
395
+ headingKeyCounter = {};
396
+ }
397
+
376
398
  marked.use({
377
399
  hooks: {
378
400
  // テーブルをスクロールラッパーで囲む(後処理)
@@ -382,6 +404,21 @@ marked.use({
382
404
  }
383
405
  },
384
406
  renderer: {
407
+ // 見出しをトグル可能なセクションとしてレンダリング
408
+ // markedのrenderer.headingは (text, level, raw) を引数に取る
409
+ heading: function(text, level, raw) {
410
+ // トグル用のdata属性を追加(見出しテキスト + 出現順でユニークキーを生成)
411
+ var baseKey = (text || '').replace(/[^a-zA-Z0-9\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FAF\u3400-\u4DBF]/g, '-').toLowerCase();
412
+ // 同じbaseKeyが出現した回数をカウントしてユニーク化
413
+ if (headingKeyCounter[baseKey] === undefined) {
414
+ headingKeyCounter[baseKey] = 0;
415
+ }
416
+ var headingKey = baseKey + '-' + headingKeyCounter[baseKey]++;
417
+ return '<h' + level + ' class="md-heading-toggle" data-heading-key="' + escapeHtmlForXss(headingKey) + '" data-heading-level="' + level + '">' +
418
+ '<span class="heading-toggle-icon">▼</span>' +
419
+ text +
420
+ '</h' + level + '>';
421
+ },
385
422
  // 生HTMLブロックをサニタイズ
386
423
  html: function(token) {
387
424
  var text = token.raw || token.text || token;
@@ -516,6 +553,24 @@ function parseCliArgs(argv) {
516
553
  }
517
554
 
518
555
  // ===== ファイルパス検証・解決関数(require.main時のみ呼ばれる) =====
556
+ // バイナリファイル拡張子(レビュー対象外)
557
+ const BINARY_EXTENSIONS = new Set([
558
+ // 動画
559
+ '.mp4', '.mov', '.webm', '.avi', '.mkv', '.m4v', '.ogv', '.wmv', '.flv',
560
+ // 画像
561
+ '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.svg', '.webp', '.tiff', '.tif',
562
+ // 音声
563
+ '.mp3', '.wav', '.ogg', '.flac', '.aac', '.m4a', '.wma',
564
+ // アーカイブ
565
+ '.zip', '.tar', '.gz', '.bz2', '.xz', '.7z', '.rar',
566
+ // バイナリ実行
567
+ '.exe', '.dll', '.so', '.dylib', '.bin', '.o', '.a',
568
+ // その他バイナリ
569
+ '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
570
+ '.woff', '.woff2', '.ttf', '.eot', '.otf',
571
+ '.sqlite', '.db'
572
+ ]);
573
+
519
574
  function validateAndResolvePaths(filePaths) {
520
575
  const resolved = [];
521
576
  for (const fp of filePaths) {
@@ -531,6 +586,13 @@ function validateAndResolvePaths(filePaths) {
531
586
  console.error(`Please specify a file, not a directory.`);
532
587
  process.exit(1);
533
588
  }
589
+ const ext = path.extname(resolvedPath).toLowerCase();
590
+ if (BINARY_EXTENSIONS.has(ext)) {
591
+ console.error(`Error: Binary file cannot be reviewed: ${path.basename(resolvedPath)} (${ext})`);
592
+ console.error(`reviw supports: .csv, .tsv, .md, .diff, .patch, and text files.`);
593
+ console.error(`Tip: To review videos/images, embed them in a Markdown file using ![](path/to/file)`);
594
+ process.exit(1);
595
+ }
534
596
  resolved.push(resolvedPath);
535
597
  }
536
598
  return resolved;
@@ -862,6 +924,9 @@ function loadText(filePath) {
862
924
  }
863
925
 
864
926
  function loadMarkdown(filePath) {
927
+ // [TDD Fix] カウンターをリセットして新しいMarkdownファイルの見出しキーを初期化
928
+ resetHeadingKeyCounter();
929
+
865
930
  const raw = fs.readFileSync(filePath);
866
931
  const text = decodeBuffer(raw);
867
932
  const lines = text.split(/\r?\n/);
@@ -1050,6 +1115,16 @@ function loadData(filePath) {
1050
1115
  );
1051
1116
  }
1052
1117
  const ext = path.extname(filePath).toLowerCase();
1118
+
1119
+ // バイナリファイルはレビュー対象外 - エラーで拒否(validateAndResolvePathsでも弾くが二重チェック)
1120
+ if (BINARY_EXTENSIONS.has(ext)) {
1121
+ throw new Error(
1122
+ `Binary file cannot be reviewed: ${path.basename(filePath)} (${ext})\n` +
1123
+ `reviw supports: .csv, .tsv, .md, .diff, .patch, and text files.\n` +
1124
+ `Tip: To review videos/images, embed them in a Markdown file using ![](path/to/file)`
1125
+ );
1126
+ }
1127
+
1053
1128
  if (ext === ".csv" || ext === ".tsv") {
1054
1129
  const data = loadCsv(filePath);
1055
1130
  return { ...data, mode: "csv" };
@@ -3020,6 +3095,35 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3020
3095
  .md-preview h1, .md-preview h2, .md-preview h3, .md-preview h4 {
3021
3096
  margin: 0.4em 0 0.2em;
3022
3097
  }
3098
+ /* Heading toggle feature */
3099
+ .md-preview .md-heading-toggle {
3100
+ display: flex;
3101
+ align-items: center;
3102
+ gap: 6px;
3103
+ }
3104
+ .md-preview .heading-toggle-icon {
3105
+ font-size: 0.6em;
3106
+ transition: transform 150ms ease;
3107
+ color: var(--muted);
3108
+ flex-shrink: 0;
3109
+ cursor: pointer;
3110
+ padding: 4px 8px;
3111
+ margin: -4px 0 -4px -8px;
3112
+ border-radius: 4px;
3113
+ }
3114
+ .md-preview .heading-toggle-icon:hover {
3115
+ background: var(--hover-bg);
3116
+ color: var(--accent);
3117
+ }
3118
+ .md-preview .md-heading-toggle.collapsed .heading-toggle-icon {
3119
+ transform: rotate(-90deg);
3120
+ }
3121
+ .md-preview .heading-section-content {
3122
+ /* Content wrapper for collapsible sections */
3123
+ }
3124
+ .md-preview .heading-section-content.hidden {
3125
+ display: none;
3126
+ }
3023
3127
  .md-preview p { margin: 0.3em 0; line-height: 1.5; }
3024
3128
  .md-preview img { max-width: 100%; height: auto; border-radius: 8px; }
3025
3129
  .md-preview video.video-preview { max-width: 100%; height: auto; border-radius: 8px; background: #000; }
@@ -3425,6 +3529,104 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
3425
3529
  position: relative;
3426
3530
  flex-shrink: 0;
3427
3531
  }
3532
+ /* Video threshold settings panel */
3533
+ .video-settings-btn {
3534
+ position: absolute;
3535
+ top: 14px;
3536
+ right: 64px;
3537
+ width: 40px;
3538
+ height: 40px;
3539
+ display: flex;
3540
+ align-items: center;
3541
+ justify-content: center;
3542
+ background: rgba(0, 0, 0, 0.55);
3543
+ border: 1px solid rgba(255, 255, 255, 0.25);
3544
+ border-radius: 50%;
3545
+ cursor: pointer;
3546
+ color: #fff;
3547
+ font-size: 18px;
3548
+ z-index: 10;
3549
+ backdrop-filter: blur(4px);
3550
+ transition: background 120ms ease, transform 120ms ease;
3551
+ }
3552
+ .video-settings-btn:hover {
3553
+ background: rgba(0, 0, 0, 0.75);
3554
+ transform: scale(1.04);
3555
+ }
3556
+ .video-settings-panel {
3557
+ position: absolute;
3558
+ top: 60px;
3559
+ right: 14px;
3560
+ background: rgba(0, 0, 0, 0.9);
3561
+ border: 1px solid rgba(255, 255, 255, 0.2);
3562
+ border-radius: 12px;
3563
+ padding: 16px;
3564
+ z-index: 15;
3565
+ min-width: 280px;
3566
+ display: none;
3567
+ backdrop-filter: blur(8px);
3568
+ }
3569
+ .video-settings-panel.visible {
3570
+ display: block;
3571
+ }
3572
+ .video-settings-panel h4 {
3573
+ margin: 0 0 8px;
3574
+ color: #fff;
3575
+ font-size: 14px;
3576
+ font-weight: 500;
3577
+ }
3578
+ .video-settings-desc {
3579
+ margin: 0 0 12px;
3580
+ color: rgba(255, 255, 255, 0.6);
3581
+ font-size: 11px;
3582
+ line-height: 1.4;
3583
+ }
3584
+ .video-settings-buttons {
3585
+ display: flex;
3586
+ gap: 6px;
3587
+ margin-bottom: 12px;
3588
+ }
3589
+ .video-settings-buttons button {
3590
+ flex: 1;
3591
+ padding: 8px 4px;
3592
+ border: 1px solid rgba(255, 255, 255, 0.2);
3593
+ border-radius: 6px;
3594
+ background: rgba(255, 255, 255, 0.1);
3595
+ color: rgba(255, 255, 255, 0.8);
3596
+ font-size: 11px;
3597
+ cursor: pointer;
3598
+ transition: all 120ms ease;
3599
+ }
3600
+ .video-settings-buttons button:hover {
3601
+ background: rgba(255, 255, 255, 0.2);
3602
+ border-color: rgba(255, 255, 255, 0.3);
3603
+ }
3604
+ .video-settings-buttons button.selected {
3605
+ background: #3b82f6;
3606
+ border-color: #3b82f6;
3607
+ color: #fff;
3608
+ }
3609
+ .video-settings-actions {
3610
+ display: flex;
3611
+ gap: 8px;
3612
+ margin-top: 8px;
3613
+ }
3614
+ .video-settings-actions button {
3615
+ flex: 1;
3616
+ padding: 8px 12px;
3617
+ border: none;
3618
+ border-radius: 6px;
3619
+ font-size: 12px;
3620
+ cursor: pointer;
3621
+ transition: background 120ms ease;
3622
+ }
3623
+ .video-settings-actions .reset-btn {
3624
+ background: rgba(255, 255, 255, 0.15);
3625
+ color: #fff;
3626
+ }
3627
+ .video-settings-actions .reset-btn:hover {
3628
+ background: rgba(255, 255, 255, 0.25);
3629
+ }
3428
3630
  /* Video Shortcuts Help */
3429
3631
  .video-shortcuts-help {
3430
3632
  opacity: 0.85;
@@ -4358,6 +4560,26 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
4358
4560
  </div>
4359
4561
  <div class="video-fullscreen-overlay" id="video-fullscreen">
4360
4562
  <button class="video-close-btn" id="video-close" aria-label="Close video" title="Close (ESC)">✕</button>
4563
+ <button class="video-settings-btn" id="video-settings-btn" aria-label="Timeline settings" title="Timeline settings">⚙</button>
4564
+ <div class="video-settings-panel" id="video-settings-panel">
4565
+ <h4>タイムライン設定</h4>
4566
+ <p class="video-settings-desc">シーン感度: 映像変化の検出しきい値</p>
4567
+ <div class="video-settings-buttons" id="scene-buttons">
4568
+ <button data-scene="0.3">少なめ</button>
4569
+ <button data-scene="0.1">やや少</button>
4570
+ <button data-scene="0.01" class="selected">標準</button>
4571
+ <button data-scene="0.003">やや多</button>
4572
+ <button data-scene="0.001">多め</button>
4573
+ </div>
4574
+ <p class="video-settings-desc">グルーピング: 連続する変化をまとめる秒数</p>
4575
+ <div class="video-settings-buttons" id="stab-buttons">
4576
+ <button data-stab="0.5">0.5s</button>
4577
+ <button data-stab="0.3">0.3s</button>
4578
+ <button data-stab="0.1" class="selected">0.1s</button>
4579
+ <button data-stab="0.05">0.05s</button>
4580
+ <button data-stab="0.02">0.02s</button>
4581
+ </div>
4582
+ </div>
4361
4583
  <div class="video-container" id="video-container"></div>
4362
4584
  </div>
4363
4585
 
@@ -6083,8 +6305,17 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
6083
6305
  let startX, startY;
6084
6306
  let svgNaturalWidth = 0, svgNaturalHeight = 0;
6085
6307
  let minimapScale = 1;
6308
+ let currentMermaidContainer = null; // Store the container for selection after close
6309
+ let skipNextFullscreenOpen = false; // Flag to skip opening fullscreen after close
6086
6310
 
6087
6311
  function openFullscreen(mermaidEl) {
6312
+ // Skip if this is triggered by the post-close click
6313
+ if (skipNextFullscreenOpen) {
6314
+ skipNextFullscreenOpen = false;
6315
+ return;
6316
+ }
6317
+ // Store the container for selection when fullscreen closes
6318
+ currentMermaidContainer = mermaidEl.closest('.mermaid-container');
6088
6319
  const svg = mermaidEl.querySelector('svg');
6089
6320
  if (!svg) return;
6090
6321
  fsWrapper.innerHTML = '';
@@ -6146,6 +6377,17 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
6146
6377
 
6147
6378
  function closeFullscreen() {
6148
6379
  fsOverlay.classList.remove('visible');
6380
+ // Trigger selection of the mermaid container after closing fullscreen
6381
+ if (currentMermaidContainer) {
6382
+ const containerToSelect = currentMermaidContainer;
6383
+ currentMermaidContainer = null;
6384
+ // Set flags to skip reopening fullscreen and trigger selection
6385
+ setTimeout(() => {
6386
+ skipNextFullscreenOpen = true;
6387
+ window._mermaidSelectAfterClose = containerToSelect;
6388
+ containerToSelect.click();
6389
+ }, 100);
6390
+ }
6149
6391
  }
6150
6392
 
6151
6393
  function updateTransform() {
@@ -6375,6 +6617,82 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
6375
6617
  }
6376
6618
  })();
6377
6619
 
6620
+ // --- Heading Toggle ---
6621
+ (function initHeadingToggle() {
6622
+ const preview = document.querySelector('.md-preview');
6623
+ if (!preview) return;
6624
+
6625
+ const STORAGE_PREFIX = 'reviw-heading-toggle-';
6626
+
6627
+ // Get all headings
6628
+ const headings = Array.from(preview.querySelectorAll('.md-heading-toggle'));
6629
+ if (headings.length === 0) return;
6630
+
6631
+ // Wrap content between headings into sections
6632
+ headings.forEach((heading, idx) => {
6633
+ const level = parseInt(heading.dataset.headingLevel) || 1;
6634
+ const key = heading.dataset.headingKey;
6635
+
6636
+ // Collect all siblings until next heading of same or higher level
6637
+ const content = [];
6638
+ let sibling = heading.nextElementSibling;
6639
+
6640
+ while (sibling) {
6641
+ // Check if it's a heading of same or higher level
6642
+ if (sibling.classList.contains('md-heading-toggle')) {
6643
+ const siblingLevel = parseInt(sibling.dataset.headingLevel) || 1;
6644
+ if (siblingLevel <= level) break;
6645
+ }
6646
+ content.push(sibling);
6647
+ sibling = sibling.nextElementSibling;
6648
+ }
6649
+
6650
+ // Create wrapper for content
6651
+ if (content.length > 0) {
6652
+ const wrapper = document.createElement('div');
6653
+ wrapper.className = 'heading-section-content';
6654
+ wrapper.dataset.forHeading = key;
6655
+
6656
+ // Move content into wrapper
6657
+ const firstContent = content[0];
6658
+ heading.parentNode.insertBefore(wrapper, firstContent);
6659
+ content.forEach(el => wrapper.appendChild(el));
6660
+
6661
+ // Restore state from localStorage
6662
+ const savedState = localStorage.getItem(STORAGE_PREFIX + key);
6663
+ if (savedState === 'collapsed') {
6664
+ heading.classList.add('collapsed');
6665
+ wrapper.classList.add('hidden');
6666
+ }
6667
+ }
6668
+
6669
+ // Add click handler to toggle icon only (not the whole heading)
6670
+ // This allows the heading text to remain selectable for comments
6671
+ const toggleIcon = heading.querySelector('.heading-toggle-icon');
6672
+ if (toggleIcon) {
6673
+ toggleIcon.addEventListener('click', (e) => {
6674
+ e.stopPropagation(); // Prevent triggering comment selection
6675
+ const wrapper = preview.querySelector(\`.heading-section-content[data-for-heading="\${key}"]\`);
6676
+ if (!wrapper) return;
6677
+
6678
+ const isCollapsed = heading.classList.toggle('collapsed');
6679
+ wrapper.classList.toggle('hidden', isCollapsed);
6680
+
6681
+ // Save state to localStorage
6682
+ localStorage.setItem(STORAGE_PREFIX + key, isCollapsed ? 'collapsed' : 'expanded');
6683
+
6684
+ // Re-render Mermaid diagrams when expanding (they may not render while hidden)
6685
+ if (!isCollapsed && typeof mermaid !== 'undefined') {
6686
+ const mermaidDivs = wrapper.querySelectorAll('.mermaid');
6687
+ if (mermaidDivs.length > 0) {
6688
+ mermaid.run({ nodes: Array.from(mermaidDivs) }).catch(() => {});
6689
+ }
6690
+ }
6691
+ });
6692
+ }
6693
+ });
6694
+ })();
6695
+
6378
6696
  // --- Image Fullscreen ---
6379
6697
  (function initImageFullscreen() {
6380
6698
  const preview = document.querySelector('.md-preview');
@@ -6526,8 +6844,23 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
6526
6844
  return mins + ':' + (secs < 10 ? '0' : '') + secs;
6527
6845
  }
6528
6846
 
6847
+ // Default threshold values
6848
+ const DEFAULT_SCENE_THRESHOLD = 0.01;
6849
+ const DEFAULT_STABILIZATION_THRESHOLD = 0.1;
6850
+ let currentSceneThreshold = DEFAULT_SCENE_THRESHOLD;
6851
+ let currentStabilizationThreshold = DEFAULT_STABILIZATION_THRESHOLD;
6852
+ let currentVideoPath = null;
6853
+ let currentVideo = null;
6854
+
6529
6855
  // Load video timeline via SSE
6530
- function loadVideoTimeline(videoPath, video) {
6856
+ function loadVideoTimeline(videoPath, video, options = {}) {
6857
+ const sceneThreshold = options.sceneThreshold || currentSceneThreshold;
6858
+ const stabilizationThreshold = options.stabilizationThreshold || currentStabilizationThreshold;
6859
+
6860
+ // Store for regeneration
6861
+ currentVideoPath = videoPath;
6862
+ currentVideo = video;
6863
+
6531
6864
  // Close existing connection
6532
6865
  if (currentTimelineEventSource) {
6533
6866
  currentTimelineEventSource.close();
@@ -6552,9 +6885,9 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
6552
6885
 
6553
6886
  videoContainer.appendChild(timeline);
6554
6887
 
6555
- // Start SSE connection
6888
+ // Start SSE connection with threshold parameters
6556
6889
  const encodedPath = encodeURIComponent(videoPath);
6557
- const es = new EventSource('/video-timeline?path=' + encodedPath);
6890
+ const es = new EventSource(\`/video-timeline?path=\${encodedPath}&scene=\${sceneThreshold}&stabilization=\${stabilizationThreshold}\`);
6558
6891
  currentTimelineEventSource = es;
6559
6892
 
6560
6893
  es.onmessage = function(e) {
@@ -6648,6 +6981,11 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
6648
6981
  }
6649
6982
 
6650
6983
  function showVideo(index) {
6984
+ // Skip if this is triggered by the post-close click
6985
+ if (window._skipNextVideoFullscreen) {
6986
+ window._skipNextVideoFullscreen = false;
6987
+ return;
6988
+ }
6651
6989
  if (index < 0 || index >= allVideoSources.length) return;
6652
6990
  currentVideoIndex = index;
6653
6991
  const source = allVideoSources[index];
@@ -6730,6 +7068,12 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
6730
7068
 
6731
7069
  function closeVideoOverlay() {
6732
7070
  videoOverlay.classList.remove('visible');
7071
+
7072
+ // Save source element before resetting index (for triggering selection)
7073
+ const closedSource = currentVideoIndex >= 0 && currentVideoIndex < allVideoSources.length
7074
+ ? allVideoSources[currentVideoIndex]
7075
+ : null;
7076
+
6733
7077
  currentVideoIndex = -1;
6734
7078
 
6735
7079
  // Close timeline SSE connection
@@ -6747,6 +7091,18 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
6747
7091
  video.remove();
6748
7092
  }
6749
7093
 
7094
+ // Trigger selection on the source element's parent table cell (if any)
7095
+ if (closedSource && closedSource.element) {
7096
+ const parentCell = closedSource.element.closest('td, th');
7097
+ if (parentCell) {
7098
+ // Set global flag to skip reopening fullscreen, then trigger click for selection
7099
+ setTimeout(() => {
7100
+ window._skipNextVideoFullscreen = true;
7101
+ parentCell.click();
7102
+ }, 100);
7103
+ }
7104
+ }
7105
+
6750
7106
  // Remove timeline
6751
7107
  const timeline = videoContainer.querySelector('.video-timeline');
6752
7108
  if (timeline) timeline.remove();
@@ -6931,6 +7287,76 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
6931
7287
  });
6932
7288
  }
6933
7289
  });
7290
+
7291
+ // --- Video Settings Panel ---
7292
+ const settingsBtn = document.getElementById('video-settings-btn');
7293
+ const settingsPanel = document.getElementById('video-settings-panel');
7294
+ const sceneButtons = document.getElementById('scene-buttons');
7295
+ const stabButtons = document.getElementById('stab-buttons');
7296
+
7297
+ // Auto-regenerate function triggered by button clicks
7298
+ function triggerRegenerate() {
7299
+ if (!currentVideoPath || !currentVideo) return;
7300
+
7301
+ const selectedScene = sceneButtons?.querySelector('button.selected');
7302
+ if (selectedScene) {
7303
+ currentSceneThreshold = parseFloat(selectedScene.dataset.scene) || DEFAULT_SCENE_THRESHOLD;
7304
+ }
7305
+
7306
+ const selectedStab = stabButtons?.querySelector('button.selected');
7307
+ if (selectedStab) {
7308
+ currentStabilizationThreshold = parseFloat(selectedStab.dataset.stab) || DEFAULT_STABILIZATION_THRESHOLD;
7309
+ }
7310
+
7311
+ loadVideoTimeline(currentVideoPath, currentVideo, {
7312
+ sceneThreshold: currentSceneThreshold,
7313
+ stabilizationThreshold: currentStabilizationThreshold
7314
+ });
7315
+ }
7316
+
7317
+ if (settingsBtn && settingsPanel) {
7318
+ // Toggle settings panel
7319
+ settingsBtn.addEventListener('click', (e) => {
7320
+ e.stopPropagation();
7321
+ settingsPanel.classList.toggle('visible');
7322
+ });
7323
+
7324
+ // Prevent panel clicks from closing overlay
7325
+ settingsPanel.addEventListener('click', (e) => e.stopPropagation());
7326
+
7327
+ // Handle scene button clicks (auto-regenerate)
7328
+ if (sceneButtons) {
7329
+ const buttons = sceneButtons.querySelectorAll('button');
7330
+ buttons.forEach(btn => {
7331
+ btn.addEventListener('click', () => {
7332
+ buttons.forEach(b => b.classList.remove('selected'));
7333
+ btn.classList.add('selected');
7334
+ // Auto-regenerate on click
7335
+ triggerRegenerate();
7336
+ });
7337
+ });
7338
+ }
7339
+
7340
+ // Handle stab button clicks (auto-regenerate)
7341
+ if (stabButtons) {
7342
+ const buttons = stabButtons.querySelectorAll('button');
7343
+ buttons.forEach(btn => {
7344
+ btn.addEventListener('click', () => {
7345
+ buttons.forEach(b => b.classList.remove('selected'));
7346
+ btn.classList.add('selected');
7347
+ // Auto-regenerate on click
7348
+ triggerRegenerate();
7349
+ });
7350
+ });
7351
+ }
7352
+
7353
+ // Close panel when overlay closes
7354
+ const originalCloseVideoOverlay = closeVideoOverlay;
7355
+ closeVideoOverlay = function() {
7356
+ settingsPanel.classList.remove('visible');
7357
+ originalCloseVideoOverlay();
7358
+ };
7359
+ }
6934
7360
  })();
6935
7361
 
6936
7362
  // --- Preview Commenting ---
@@ -6946,11 +7372,16 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
6946
7372
  .md-preview > p:hover, .md-preview > h1:hover, .md-preview > h2:hover,
6947
7373
  .md-preview > h3:hover, .md-preview > h4:hover, .md-preview > h5:hover,
6948
7374
  .md-preview > h6:hover, .md-preview > ul > li:hover, .md-preview > ol > li:hover,
6949
- .md-preview > pre:hover, .md-preview > blockquote:hover {
7375
+ .md-preview > pre:hover, .md-preview > blockquote:hover,
7376
+ .md-preview details summary:hover {
6950
7377
  background: rgba(99, 102, 241, 0.08);
6951
7378
  cursor: pointer;
6952
7379
  border-radius: 4px;
6953
7380
  }
7381
+ .md-preview td:hover, .md-preview th:hover {
7382
+ background: rgba(99, 102, 241, 0.12);
7383
+ cursor: pointer;
7384
+ }
6954
7385
  .md-preview img:hover {
6955
7386
  outline: 2px solid var(--accent);
6956
7387
  cursor: pointer;
@@ -7013,7 +7444,9 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
7013
7444
  }
7014
7445
 
7015
7446
  if (!text) return -1;
7016
- const normalized = text.trim().replace(/\\s+/g, ' ').slice(0, 100);
7447
+ // Remove toggle icon characters (▼, ▶) that may be included from heading toggles
7448
+ const cleanText = text.replace(/[▼▶]/g, '').trim();
7449
+ const normalized = cleanText.replace(/\\s+/g, ' ').slice(0, 100);
7017
7450
  if (!normalized) return -1;
7018
7451
 
7019
7452
  for (let i = 0; i < DATA.length; i++) {
@@ -7033,6 +7466,19 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
7033
7466
  }
7034
7467
  }
7035
7468
 
7469
+ // Check for HTML summary tags: <summary>text</summary>
7470
+ const summaryMatch = lineText.match(/<summary>([^<]*)<\\/summary>/i);
7471
+ if (summaryMatch) {
7472
+ const summaryText = summaryMatch[1].trim();
7473
+ if (summaryText === normalized || summaryText.toLowerCase() === normalized.toLowerCase()) {
7474
+ return i + 1;
7475
+ }
7476
+ // Partial match for long summary text
7477
+ if (summaryText.includes(normalized.slice(0, 30)) && normalized.length > 5) {
7478
+ return i + 1;
7479
+ }
7480
+ }
7481
+
7036
7482
  // Try stripping all markdown formatting (links, bold, italic, etc.)
7037
7483
  const strippedLine = stripMarkdown(lineText).replace(/\\s+/g, ' ').slice(0, 100);
7038
7484
  if (strippedLine === normalized) return i + 1;
@@ -7045,10 +7491,31 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
7045
7491
  // Helper: find matching source line for table cell (prioritizes table rows)
7046
7492
  function findTableSourceLine(text) {
7047
7493
  if (!text) return -1;
7048
- const normalized = text.trim().replace(/\\s+/g, ' ').slice(0, 100);
7494
+ // Remove toggle icon characters (▼, ▶) that may be included from heading toggles
7495
+ const cleanText = text.replace(/[▼▶]/g, '').trim();
7496
+ const normalized = cleanText.replace(/\\s+/g, ' ').slice(0, 100);
7049
7497
  if (!normalized) return -1;
7050
7498
 
7051
- // First pass: look for table rows (lines starting with |) containing the text
7499
+ // First pass: look for EXACT cell text match (not inside markdown syntax)
7500
+ for (let i = 0; i < DATA.length; i++) {
7501
+ const lineText = (DATA[i][0] || '').trim();
7502
+ if (!lineText || !lineText.startsWith('|')) continue;
7503
+
7504
+ // Split into cells and check for exact match (excluding markdown syntax)
7505
+ const cells = lineText.split('|').map(c => c.trim());
7506
+ for (const cell of cells) {
7507
+ // Skip cells that are markdown images/links (start with ![, contain []())
7508
+ if (cell.match(/^!?\\[.*\\]\\(.*\\)$/)) continue;
7509
+
7510
+ // Check for exact cell text match
7511
+ if (cell === normalized) return i + 1;
7512
+
7513
+ // For short text (like header cells), require exact word match
7514
+ if (normalized.length <= 5 && cell === normalized) return i + 1;
7515
+ }
7516
+ }
7517
+
7518
+ // Second pass: look for partial matches (including inside markdown syntax)
7052
7519
  for (let i = 0; i < DATA.length; i++) {
7053
7520
  const lineText = (DATA[i][0] || '').trim();
7054
7521
  if (!lineText || !lineText.startsWith('|')) continue;
@@ -7137,6 +7604,51 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
7137
7604
  return { startLine: -1, endLine: -1 };
7138
7605
  }
7139
7606
 
7607
+ // Helper: find source line range for mermaid diagram by its container
7608
+ function findMermaidSourceLine(mermaidContainer) {
7609
+ if (!mermaidContainer) return { startLine: -1, endLine: -1 };
7610
+
7611
+ // Find the index of this mermaid container among all mermaid containers
7612
+ const allMermaidContainers = document.querySelectorAll('.mermaid-container');
7613
+ let containerIndex = -1;
7614
+ for (let i = 0; i < allMermaidContainers.length; i++) {
7615
+ if (allMermaidContainers[i] === mermaidContainer) {
7616
+ containerIndex = i;
7617
+ break;
7618
+ }
7619
+ }
7620
+ if (containerIndex < 0) return { startLine: -1, endLine: -1 };
7621
+
7622
+ // Find all mermaid code blocks in the source
7623
+ const mermaidBlocks = [];
7624
+ let currentBlock = null;
7625
+
7626
+ for (let i = 0; i < DATA.length; i++) {
7627
+ const lineText = (DATA[i][0] || '').trim();
7628
+
7629
+ if (lineText.match(/^\`\`\`mermaid/i) && !currentBlock) {
7630
+ // Start of a mermaid code block
7631
+ currentBlock = { startLine: i + 1, lines: [] };
7632
+ } else if (lineText === '\`\`\`' && currentBlock) {
7633
+ // End of the code block
7634
+ currentBlock.endLine = i + 1;
7635
+ mermaidBlocks.push(currentBlock);
7636
+ currentBlock = null;
7637
+ } else if (currentBlock) {
7638
+ // Inside the mermaid block
7639
+ currentBlock.lines.push(DATA[i][0] || '');
7640
+ }
7641
+ }
7642
+
7643
+ // Return the block at the same index as the container
7644
+ if (containerIndex < mermaidBlocks.length) {
7645
+ const block = mermaidBlocks[containerIndex];
7646
+ return { startLine: block.startLine, endLine: block.endLine };
7647
+ }
7648
+
7649
+ return { startLine: -1, endLine: -1 };
7650
+ }
7651
+
7140
7652
  // Helper: find source line for image by src
7141
7653
  function findImageSourceLine(src) {
7142
7654
  if (!src) return -1;
@@ -7217,6 +7729,44 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
7217
7729
  return;
7218
7730
  }
7219
7731
 
7732
+ // Handle video/video-button clicks - select source line first
7733
+ // This includes video elements, fullscreen buttons, video wrapper clicks,
7734
+ // AND clicks on table cells containing videos (for post-fullscreen selection)
7735
+ const videoElement = e.target.closest('video.video-preview');
7736
+ const videoButton = e.target.closest('.video-fullscreen-btn');
7737
+ const videoWrapper = e.target.closest('.video-fullscreen-wrapper');
7738
+
7739
+ // Also check if clicking on a td that contains a video
7740
+ const targetCell = e.target.closest('td, th');
7741
+ const cellContainsVideo = targetCell && targetCell.querySelector('video.video-preview');
7742
+
7743
+ if (videoElement || videoButton || videoWrapper || cellContainsVideo) {
7744
+ // Check if this is a post-fullscreen click (for selection only, skip fullscreen)
7745
+ const isPostFullscreenClick = window._skipNextVideoFullscreen;
7746
+ if (isPostFullscreenClick) {
7747
+ window._skipNextVideoFullscreen = false;
7748
+ }
7749
+
7750
+ // Find the parent table cell
7751
+ const refElement = videoElement || videoButton || videoWrapper || targetCell;
7752
+ const parentCell = refElement.closest('td, th') || targetCell;
7753
+ if (parentCell) {
7754
+ // Use video src to find the source line
7755
+ const video = videoElement || parentCell.querySelector('video');
7756
+ if (video && video.src) {
7757
+ const line = findImageSourceLine(video.src);
7758
+ if (line > 0) {
7759
+ selectSourceRange(line, null, parentCell);
7760
+ // Return if this is a post-fullscreen click (for selection only)
7761
+ if (isPostFullscreenClick) {
7762
+ return;
7763
+ }
7764
+ }
7765
+ }
7766
+ }
7767
+ // Don't return for normal clicks - let fullscreen handler work too
7768
+ }
7769
+
7220
7770
  // Handle links - sync to source but let link work normally
7221
7771
  const link = e.target.closest('a');
7222
7772
  if (link) {
@@ -7233,8 +7783,23 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
7233
7783
  return;
7234
7784
  }
7235
7785
 
7236
- // Ignore clicks on mermaid, video overlay
7237
- if (e.target.closest('.mermaid-container, .video-fullscreen-overlay')) return;
7786
+ // Handle mermaid container clicks for post-fullscreen selection
7787
+ const mermaidContainer = e.target.closest('.mermaid-container');
7788
+ if (mermaidContainer) {
7789
+ // Check if this is a post-fullscreen click for selection
7790
+ if (window._mermaidSelectAfterClose === mermaidContainer) {
7791
+ window._mermaidSelectAfterClose = null;
7792
+ const { startLine, endLine } = findMermaidSourceLine(mermaidContainer);
7793
+ if (startLine > 0) {
7794
+ selectSourceRange(startLine, endLine, mermaidContainer);
7795
+ }
7796
+ }
7797
+ // Always return to prevent fullscreen from reopening or other handling
7798
+ return;
7799
+ }
7800
+
7801
+ // Ignore clicks on video overlay
7802
+ if (e.target.closest('.video-fullscreen-overlay')) return;
7238
7803
 
7239
7804
  // Handle code blocks - select entire block
7240
7805
  const pre = e.target.closest('pre');
@@ -7248,15 +7813,31 @@ function htmlTemplate(dataRows, cols, projectRoot, relativePath, mode, previewHt
7248
7813
  return;
7249
7814
  }
7250
7815
 
7251
- const target = e.target.closest('p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th');
7816
+ const target = e.target.closest('p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th, summary');
7252
7817
  if (!target) return;
7253
7818
 
7819
+ // Get text content, excluding toggle icon if present (for heading toggles)
7820
+ let searchText = target.textContent;
7821
+
7822
+ // For summary elements, clean up the text (may have disclosure triangle)
7823
+ if (target.tagName === 'SUMMARY') {
7824
+ searchText = searchText.replace(/^[▶▼◀►◆◇]?\\s*/, '').trim();
7825
+ }
7826
+ const toggleIcon = target.querySelector('.heading-toggle-icon');
7827
+ if (toggleIcon) {
7828
+ // Exclude the toggle icon text from search
7829
+ searchText = searchText.replace(toggleIcon.textContent, '').trim();
7830
+ }
7831
+
7254
7832
  // Use table-specific search for table cells, otherwise use element-aware search
7255
7833
  const isTableCell = target.tagName === 'TD' || target.tagName === 'TH';
7256
- const line = isTableCell ? findTableSourceLine(target.textContent) : findSourceLine(target.textContent, target);
7834
+ const line = isTableCell ? findTableSourceLine(searchText) : findSourceLine(searchText, target);
7257
7835
  if (line <= 0) return;
7258
7836
 
7259
- e.preventDefault();
7837
+ // Don't prevent default for summary elements - let native <details> toggle work
7838
+ if (target.tagName !== 'SUMMARY') {
7839
+ e.preventDefault();
7840
+ }
7260
7841
  selectSourceRange(line, null, target);
7261
7842
  });
7262
7843
 
@@ -8090,6 +8671,8 @@ function createFileServer(filePath, fileIndex = 0) {
8090
8671
  if (req.method === "GET" && req.url.startsWith("/video-timeline?")) {
8091
8672
  const urlParams = new URL(req.url, `http://localhost`);
8092
8673
  const videoPath = urlParams.searchParams.get("path");
8674
+ const sceneThreshold = parseFloat(urlParams.searchParams.get("scene")) || 0.01;
8675
+ const stabilizationThreshold = parseFloat(urlParams.searchParams.get("stabilization")) || 0.1;
8093
8676
 
8094
8677
  if (!videoPath) {
8095
8678
  res.writeHead(400, { "Content-Type": "text/plain" });
@@ -8148,7 +8731,7 @@ function createFileServer(filePath, fileIndex = 0) {
8148
8731
  // Ignore cleanup errors
8149
8732
  }
8150
8733
  });
8151
- });
8734
+ }, { sceneThreshold, stabilizationThreshold });
8152
8735
  return;
8153
8736
  }
8154
8737
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reviw",
3
- "version": "0.18.0",
3
+ "version": "0.20.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": {