# 管理画面エディタに行番号ガター + 行レベルアクションを追加
## Context
管理画面の Markdown エディタ(`MarkdownEditor.tsx`)にツールバーは追加済みだが、行番号表示と行単位の操作(移動・削除・複製・見出し切替)がない。CodeMirror 等のリッチエディタは導入せず、**既存の textarea + カスタムガター**で実装する。
## 方針: オーバーレイ方式
textarea の左パディングを拡張し、空いた領域にガター div を `position: absolute` で重ねる。
- スクロールバーは 1 本(textarea のみ)。ガターは `overflow: hidden` + `scrollTop` 同期
- **textarea に `wrap="off"` を指定** — 論理行ベースでガター整列。長い行は水平スクロール
- モバイル(lg 未満)ではガターを非表示、パディングも通常に戻す
- テーマ対応: `var(--muted)` / `var(--ink)` で配色
## 変更ファイル
| # | パス | 変更内容 |
|---|------|---------|
| 1 | `src/components/admin/LineGutter.tsx` | **新規** ガター(行番号 + 行アクションボタン統合) |
| 2 | `src/lib/editor/lineOperations.ts` | **新規** 行操作の純粋関数(move/delete/duplicate/toggleHeading) |
| 3 | `src/components/admin/MarkdownEditor.tsx` | ラッパー追加、ガター統合、スクロール同期、アクティブ行追跡 |
| 4 | `src/app/globals.css` | ガター用スクロールバー非表示 CSS |
## 実装詳細
### 1. `LineGutter.tsx` — ガター + 行アクション統合コンポーネント
```
構造(1コンポーネントに統合):
<div ref={ref} className="absolute left-0 top-0 ... hidden lg:block"
aria-hidden="true" style={{ overflowY: "hidden" }}>
{lines.map(i => (
<div className="h-6 leading-6 font-mono text-xs text-right pr-2">
{i + 1}
</div>
))}
</div>
{/* アクティブ行のアクションボタン(ラッパー内に absolute 配置) */}
<div ref={actionsRef} className="absolute right-1 z-20 hidden lg:flex gap-0.5"
role="toolbar" aria-label="行アクション">
<button aria-label="行を上に移動">↑</button>
<button aria-label="行を下に移動">↓</button>
<button aria-label="行を複製">D</button>
<button aria-label="見出しレベル切替">H</button>
<button aria-label="行を削除">×</button>
</div>
```
Props:
- `lineCount`, `activeLine`, `gutterRef` (forwardRef)
- `actionsRef` (forwardRef) — RAF 内で直接 style.top を操作
- `onAction: (action, lineIndex) => void`
スタイル:
- `w-14`(56px)、`py-3`(textarea の上下パディングと一致)
- 行高: `h-6 leading-6`(textarea の `leading-6` = 24px と一致)
- 非アクティブ行: `color: var(--muted)`、アクティブ行: `color: var(--ink)` + `font-medium`
- `pointer-events-none`(コンテナ)+ `pointer-events-auto`(行番号セル)で textarea クリックを透過
### 2. 行アクションボタン
アクティブ行の右端に小さなフローティングツールバーを表示:
| ボタン | ラベル | 操作 |
|--------|--------|------|
| ↑ | 行を上に移動 | 前行と入れ替え |
| ↓ | 行を下に移動 | 次行と入れ替え |
| D | 行を複製 | 下に同じ行を挿入 |
| H | 見出し切替 | normal → `## ` → `### ` → normal |
| × | 行を削除 | 行を除去 |
配置: `position: absolute; right: 4px` + `top` を `activeLine * 24 + 12 - scrollTop` で算出。
スクロール時は RAF 内で DOM 直接操作(React state 経由しない)。
### 3. `lineOperations.ts` — 純粋関数
```ts
type LineOpResult = { newValue: string; cursorPos: number };
moveLineUp(text, lineIndex): LineOpResult | null
moveLineDown(text, lineIndex): LineOpResult | null
deleteLine(text, lineIndex): LineOpResult
duplicateLine(text, lineIndex): LineOpResult
toggleHeading(text, lineIndex): LineOpResult
// サイクル: normal → ## → ### → normal
```
**境界条件の前提:**
- 改行区切りは `\n` のみ(`\r\n` は事前に正規化しない。textarea は `\n` を返す)
- 空文書 (`""`) は `split("\n")` で `[""]` = 1行扱い
- 末尾改行ありの文書は最終要素が空文字列になる(正常動作)
- 最終行削除時は直前行にカーソル移動、1行しかない場合は `[""]` を返す
Undo/Redo 対応(`execCommand` + フォールバック):
```ts
function applyLineOp(ta: HTMLTextAreaElement, result: LineOpResult, onChange: (v: string) => void) {
ta.focus();
ta.selectionStart = 0;
ta.selectionEnd = ta.value.length;
// execCommand を試行(ネイティブ Undo スタックに記録される)
if (!document.execCommand("insertText", false, result.newValue)) {
// フォールバック: setRangeText + input イベント dispatch
ta.setRangeText(result.newValue, 0, ta.value.length, "end");
ta.dispatchEvent(new Event("input", { bubbles: true }));
}
ta.selectionStart = ta.selectionEnd = result.cursorPos;
onChange(ta.value);
}
```
### 4. `MarkdownEditor.tsx` の変更
Before:
```tsx
<textarea ref={textareaRef}
className="mt-2 h-[520px] w-full rounded border p-3 font-mono text-sm leading-6"
.../>
```
After:
```tsx
<div className="relative mt-2">
<LineGutter ref={gutterRef} lineCount={lineCount} activeLine={activeLine}
actionsRef={actionsRef} onAction={handleLineAction} />
<textarea ref={textareaRef}
wrap="off"
className="h-[520px] w-full rounded border py-3 pr-3 pl-3 lg:pl-14 font-mono text-sm leading-6"
onScroll={handleScroll} onClick={updateActiveLine} onKeyUp={updateActiveLine}
onSelect={updateActiveLine}
.../>
</div>
```
新規ステート/ロジック:
- `lineCount = useMemo(() => value.split("\n").length, [value])`
- `activeLine` (0-indexed) — click/keyup/**onSelect** で更新
- `handleScroll` — `requestAnimationFrame` で `gutterRef.scrollTop = ta.scrollTop` + アクションボタン位置更新
- `handleLineAction` — `applyLineOp()` で適用後、**`result.cursorPos` から `activeLine` を再計算して即時更新**
### 5. `globals.css` 追加
```css
.editor-gutter::-webkit-scrollbar { display: none; }
.editor-gutter { scrollbar-width: none; }
```
## パフォーマンス考慮
- ガター行は `React.memo` + key で最小再レンダリング
- スクロール同期は RAF(React state 経由しない DOM 操作)
- `activeLine` 更新は click/keyup/onSelect + 行アクション後の再計算のみ(onInput/onScroll では更新しない)
- 行数 < 500 なら仮想化不要(ブログ記事エディタ)
## 検証方法
1. `npx tsc --noEmit` — 型チェック
2. `npm run build` — ビルド確認(`NODE_OPTIONS="--max-old-space-size=4096"`)
3. 手動確認:
- 行番号がテキストと正確に揃うこと(`wrap="off"` で論理行ベース)
- スクロール時にガターが追従すること
- 各行アクション(↑↓ D H ×)が正しく動作すること
- **行アクション実行後に activeLine が正しい行を指していること**
- **execCommand フォールバック動作(Firefox 等)**
- アクション後に Ctrl+Z で Undo できること
- lg 未満でガターが非表示、パディングが通常に戻ること
- warm/light/night 各テーマで配色が正しいこと
- **空文書・1行文書・末尾改行ありの文書で正常動作すること**
## Codex レビュー対応履歴
### レビュー 1 回目(ok: false / blocking 3 件 + advisory 3 件)
| # | 重要度 | 指摘 | 対応 |
|---|--------|------|------|
| 1 | blocking | textarea 折り返しでガター行番号と本文がずれる | → `wrap="off"` を追加、論理行ベースに固定 |
| 2 | blocking | 行アクション実行後に `activeLine` が更新されない | → `handleLineAction` 内で再計算 + `onSelect` 追加 |
| 3 | blocking | `execCommand` フォールバック未記載 | → `setRangeText` + `input` dispatch フォールバックを追記 |
| 4 | advisory | `LineGutter` と `LineActions` の構成不一致 | → `LineGutter.tsx` に統合 |
| 5 | advisory | 境界条件(空文書・末尾改行等)未定義 | → `lineOperations` の前提条件を明文化 |
| 6 | advisory | `lineOperations` の単体テストなし | → 検証方法に記載(実装時に追加検討) |
### レビュー 2 回目(ok: true / advisory 2 件)
| # | 重要度 | 指摘 | 対応 |
|---|--------|------|------|
| 1 | advisory | `lineOperations` の自動テストが「検討」止まり | → 実装時に境界条件テストを優先的に追加 |
| 2 | advisory | 行アクションボタンに個別 `aria-label` が未記載 | → コード例に各ボタンの `aria-label` を追加 |