bannzai / bannzai/nikki

エディタを Obsidian 準拠の単一テキストビュー (Live Preview) 方式に作り替える

Open
#111 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Swift
Stars
0
Forks
0
Avg merge
10h 28m
Merged PRs (30d)
30

Description

# Obsidian 準拠エディタへの再設計プラン

## Context

現状のエディタは「1ブロック = 1つの SwiftUI TextField」の集合で構成されている。この構造が、ユーザーが挙げた書きづらさの根本原因になっている:

1. **Backspace で前の行 (ブロック) に移動できない** — TextField はブロックごとに独立した first responder で、行頭での Backspace はそのフィールド内で完結する。前のブロックと結合する経路が存在しない
2. **行の途中に「- [ ] 」を打ってもチェックボックスにならない** — `Block.converted(paragraphText:)` が「段落の本文全体が記法と完全一致」の時だけ変換する設計 (`Block.swift:504`)。既存の文の行頭に記法を書き足すケースを扱えない
3. **Cmd+Enter (Cmd+L) でチェックボックスをトグルできない** — 段落⇔チェックリストを切り替えるコマンド自体が存在しない
4. その他: 行の途中の Return でカーソル以降が次ブロックへ移らない (macOS、QA.md 既知の制限)、ソフトウェアキーボードの Backspace でチェックボックスを外せない (iOS、QA.md 既知の制限)、完了項目のテキストが編集できない、img/details ブロックを削除できない — いずれも複数 TextField 構造の制約

個別にパッチを重ねても TextField の限界 (キャレット位置が取れない・フィールド間のキーイベント連携ができない) は超えられない。Obsidian と同等の編集体験には、**本文全体を1つのテキストビュー (UITextView / NSTextView) で編集し、記法を装飾表示する Live Preview 方式**への作り替えが必要。

## Obsidian の挙動 (完コピ対象の仕様。実アプリ 1.13.7 / macOS で実測済み)

Obsidian の Live Preview は「ソース (markdown 文字列) は常に1つのドキュメント。表示だけ装飾」というモデル。ローカルに Obsidian 1.13.7 をインストールし、テスト vault で AppleScript キー入力 + スクリーンショット + 保存された .md の中身で確認した挙動:

| 操作 | Obsidian の実測挙動 |
|---|---|
| 行頭で Backspace | 前の行と結合する (通常のテキスト編集)。空行も同様に消えて詰まる |
| 空のチェックリスト項目で Backspace | 特別扱いなし。記法の文字 (`- [ ] `) を1文字ずつ普通に削除していく (カーソル行では記法が生表示されているため、見た目も自然) |
| `- [ ] ` を入力 | 入力した行がその場でチェックボックス表示になる。既存テキスト (例: `buy milk`) の行頭に打ち足しても即効く |
| チェックリスト項目で Enter | 次の行にも `- [ ] ` が自動継続される。完了項目 (`- [x]`) の後でも未完了 (`- [ ]`) で継続 |
| 空のチェックリスト項目で Enter | 記法が消えて平文の行になる (リスト脱出) |
| Cmd+L | カーソル行のチェックボックスをトグル (`[ ]` ⇔ `[x]`)。平文の行なら `- [ ] ` を付けてチェックボックス化 |
| Cmd+Enter | 既定では何も起きない (1.13.7 で無割り当てを実測。旧版の既定が Cmd+Enter だった) |
| `## ` を入力 | その行が見出し表示になる (文字サイズ・太さが変わる) |
| カーソル行の記法表示 | カーソルがある行は記法 (`##`・`- [ ]`) がグレーの生テキストで見え、行を離れると隠れて装飾だけになる (Live Preview) |
| 完了項目 | 打ち消し線 + 灰色。テキストはそのまま編集できる |
| ↑↓・行またぎの選択 | 1つのテキストビューなので自然に動く |

参照: https://obsidian.md/help/syntax / Toggle checkbox status の既定ホットキーの変遷: https://forum.obsidian.md/t/shortcut-for-checkbox/47335

## アーキテクチャ方針

### 採用: UITextView / NSTextView + TextKit 2 による単一テキストビュー方式

- 本文全体を **1つの `UITextView` (iOS) / `NSTextView` (macOS)** で編集する。SwiftUI からは `UIViewRepresentable` / `NSViewRepresentable` で包む
- ソース・オブ・トゥルースは **markdown 文字列そのもの** (`JournalEntry.bodyMarkdown` と同じ形式)。`[Block]` への双方向変換で編集状態を持つ現行方式をやめ、「編集中のテキスト = markdown」にする
- 装飾は `NSTextStorage` のカスタムサブクラス (または TextKit 2 の `NSTextContentStorage` delegate) で、行単位の記法を `NSAttributedString` の属性として適用する:
- 見出し行 → フォントを `EditorHeadingFont` 相当に
- チェックリスト行 → 完了項目は打ち消し線 + `inkTextTertiary`
- img / details 行 → 現行の EditorImageBlock / EditorDetailsBlock 相当の attachment ビュー (PR 3)
- **重要な設計原則: NSTextStorage の文字列は常に markdown そのものを保つ**。記法の文字を attachment 文字などに「置換」すると `textView.text` が markdown でなくなり、text Binding の書き戻しがソースを壊す。装飾は文字を変えない属性適用と、描画レイヤ (TextKit 2 のレイアウトフラグメントのカスタマイズ + 記法グリフの非表示 + チェックボックスのオーバーレイ描画) だけで行う
- チェックボックスのタップは記法範囲のレイアウト位置へのヒットテストで拾い、storage 内の `[ ]` ⇔ `[x]` の文字列置換で反映する
- **記法の表示は Obsidian と同じ「カーソルのある行では記法を生で見せ、離れた行では隠す」を PR 1 から実装する**。記法の文字が storage に残る以上、常時隠すとキャレットが見えない `# ` や `- [ ] ` の中に入り、行頭 Backspace が「見た目上何も起きない」— 今回直したい不満の再生産になるため。選択変更 (`textViewDidChangeSelection`) でカーソル行の記法グリフの表示/非表示を切り替える

### 理由

- Backspace での行結合・行途中 Return での分割・↑↓ 移動・行またぎ選択・undo/redo が **テキストビュー標準の挙動としてタダで手に入る**。現行方式ではこれら全てを個別実装しても TextField の制約 (キャレット位置が取れない等) で完成しない
- 日本語 IME の変換中テキスト (issue #86) は、単一テキストビューでは OS 標準の marked text 管理に乗るため、ブロック差し替えによる変換中テキスト破棄の問題自体が消える
- 既存の `Block` パース (`Block.swift`) は保存形式・ホーム抜粋・コピー機能 (`EditorBlockCopy`) でそのまま使い続ける。捨てるのは「編集 UI としての `[Block]` 双方向同期」だけ

### 検討して不採用にした代替案

- **現行 TextField 方式へのパッチ継続**: Backspace 行結合は onKeyPress + NSEvent 監視で擬似実装できるが、キャレット位置が取れないため「行頭でだけ結合」が判定できない。行途中 Return も同じ理由で不可能。限界が明確なので不採用
- **WKWebView + CodeMirror (Obsidian と同じエンジン)**: 挙動は完全一致するが、ネイティブ IME・スクロール・アクセシビリティ・フォント描画の品質が下がり、依存も重い。日記アプリの本文には過剰

## 実装フェーズ

3 PR に分割する。各 PR でビルド + ユニットテスト + 両プラットフォーム QA を通す。

### PR 1: 単一テキストビューの土台 (最重要・最大)

markdown 文字列を編集する `EditorTextView` (UIViewRepresentable / NSViewRepresentable) を新設し、EditorPage の本文を置き換える。装飾は見出し・チェックリスト・完了打ち消し線まで。

- Backspace 行結合・行途中 Return・↑↓ はテキストビュー標準挙動で解決 (問題 1)
- チェックボックスは記法グリフの位置に重ねて描画し (storage の文字は `- [ ] ` のまま)、タップでトグル
- カーソル行では記法を生で見せる Live Preview 切り替えもここで入れる
- img / details は当面「生の HTML 行がモノスペース書体で見える」表示に一時後退させる (attachment 化は PR 3)

### PR 2: 記法の入力支援 (Obsidian 挙動)

- 行頭の `- [ ] ` / `- [x] ` / `# `〜`### ` 入力でその行が即座に装飾表示になる (問題 2。単一ビューでは装飾が行単位の再計算なので、完全一致条件が不要になり自然に解決。Obsidian 実測と同じ)
- チェックリスト行の Enter で次行に `- [ ] ` を自動継続 (完了項目の後でも未完了で継続。実測どおり)、空項目の Enter で記法を消して平文化
- 空項目の Backspace は特別扱い不要 (Obsidian 実測: 記法文字を普通に1文字ずつ消すだけ)。storage が markdown を保ち、カーソル行では記法が生表示される本設計では、同じ挙動が標準のテキスト削除として成立する
- **Cmd+Enter** (ユーザーの期待どおり。Obsidian の旧既定。現行 1.13.7 では無割り当てを実測) でカーソル行 / 選択行のチェックボックスをトグル。平文行はチェックボックス化 (問題 3)。Obsidian の現既定 Cmd+L も併せて割り当てる。iOS はハードウェアキーボードの keyCommands で対応
- Tab / Shift+Tab のインデントは今回のスコープ外 (Nikki はネストリスト未サポートのため)

### PR 3: img / details の attachment 表示の復元

- img → 現行 EditorImageBlock 相当のプレースホルダ attachment
- details → 現行 EditorDetailsBlock 相当のカード attachment、タップで開閉 (open 属性の書き換え)
- 行として選択・削除できるようになるため、既知の制限「img/details を削除できない」も解消

## 変更ファイル一覧 (PR 1 の詳細)

| ファイル | 変更 |
|---|---|
| `Nikki/Features/Editor/Components/EditorTextView.swift` (新規) | UITextView/NSTextView を包む representable。markdown Binding・装飾適用・チェックボックスタップ |
| `Nikki/Features/Editor/EditorMarkdownStyler.swift` (新規) | 行単位の記法判定 → NSAttributedString 属性適用の純粋ロジック (ユニットテスト対象) |
| `Nikki/Features/Editor/EditorPage.swift` | draftBlocks: [Block] → draftMarkdown: String へ。ForEach + EditorBlockRow を EditorTextView に置換。checklistBackspaceMonitor 削除 |
| `Nikki/Features/Editor/Components/EditorTextBlockField.swift` ほか | 段階的に削除 (EditorChecklistField / EditorBlockRow / EditorCheckboxToggleStyle は attachment 描画に流用) |
| `NikkiTests/EditorMarkdownStylerTests.swift` (新規) | 装飾適用ロジックのテスト |
| `Nikki/Features/Editor/QA.md` | 項目の全面見直し (Backspace 結合・行途中 Return を追加、既知の制限を削除) |

### 実装コード提案 (抜粋)

`EditorMarkdownStyler.swift` — 行単位のスタイル決定 (純粋関数):

```swift
/// markdown の1行に適用する装飾。EditorTextView が NSAttributedString の属性に変換する。
enum EditorLineStyle {
/// 見出し行。level は 1〜3。記法「# 」の範囲は表示上隠す。
case heading(level: Int, syntaxRange: NSRange)
/// チェックリスト行。記法「- [ ] 」の範囲をチェックボックス attachment に置き換える。
case checklistItem(done: Bool, syntaxRange: NSRange)
/// 通常の段落行。
case paragraph
}

/// 行のテキストから装飾を決める。行頭の記法のみ解釈する (Block.blocks(fromMarkdown:) と同じ規則)。
func editorLineStyle(lineText: String) -> EditorLineStyle {
for (index, prefix) in Block.headingPrefixes.enumerated() {
if lineText.hasPrefix(prefix) {
return .heading(level: index + 1, syntaxRange: NSRange(location: 0, length: prefix.utf16.count))
}
}
if lineText.hasPrefix(Block.uncheckedPrefix) {
return .checklistItem(done: false, syntaxRange: NSRange(location: 0, length: Block.uncheckedPrefix.utf16.count))
}
if lineText.lowercased().hasPrefix(Block.checkedPrefix) {
return .checklistItem(done: true, syntaxRange: NSRange(location: 0, length: Block.checkedPrefix.utf16.count))
}
return .paragraph
}
```

`EditorTextView.swift` — representable の骨格 (iOS 側。macOS は NSViewRepresentable で対に):

```swift
/// 本文全体を1つの UITextView で編集する。表示は markdown の記法を行単位で装飾し、
/// ソースは常に markdown 文字列 (text Binding) のまま持つ。
struct EditorTextView: UIViewRepresentable {
@Binding var text: String
let bodyFontSize: CGFloat

func makeUIView(context: Context) -> UITextView {
// TextKit 2 スタックで生成し、行断片ごとの装飾を textStorage delegate で適用する。
let textView = UITextView(usingTextLayoutManager: true)
textView.delegate = context.coordinator
textView.textStorage.delegate = context.coordinator
textView.backgroundColor = .clear
return textView
}

func updateUIView(_ textView: UITextView, context: Context) {
// IME 変換中 (markedTextRange != nil) は外部からの text 差し替えをしない (issue #86 と同じ理由)。
if textView.markedTextRange == nil, textView.text != text {
textView.text = text
}
}

final class Coordinator: NSObject, UITextViewDelegate, NSTextStorageDelegate {
// textStorage(_:didProcessEditing:) で編集された行範囲だけ editorLineStyle を引き直して属性を貼る。
// textViewDidChange で text Binding へ書き戻す。
}
}
```

チェックリスト行の Enter 継続 / 空項目の脱出 (PR 2、`shouldChangeTextIn` で介入):

```swift
// UITextViewDelegate
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
if text == "\n", let line = currentLine(of: textView, at: range.location) {
if line.text == Block.uncheckedPrefix || line.text.lowercased() == Block.checkedPrefix {
// 空のチェックリスト項目で Return: 記法を消して平文の行にする (Obsidian のリスト脱出)。
replace(lineRange: line.range, with: "", in: textView)
return false
}
if line.text.hasPrefix(Block.uncheckedPrefix) || line.text.lowercased().hasPrefix(Block.checkedPrefix) {
// 項目に本文がある Return: 次の行に「- [ ] 」を自動継続する。
insert("\n" + Block.uncheckedPrefix, at: range, in: textView)
return false
}
}
return true
}
```

## データ互換性

- 保存形式は `bodyMarkdown` のままで変更なし。マイグレーション不要
- `Block` パーサ・`withoutEmptyText`・コピー機能・ホーム抜粋・テンプレートはそのまま動く
- 書き戻し時の「空ブロック除去」は現行どおり `Block.blocks(fromMarkdown:) → withoutEmptyText → markdown` を commit 時に通して維持する

## リスク

- **最大リスクは日本語 IME**: 単一テキストビュー化で原理的には改善するが、textStorage への属性適用が変換中テキストと衝突しないか (marked text 範囲は再スタイルしない等) を macOS/iOS 両方で必ず実機系 QA する
- TextKit 2 の attachment タップ判定は iOS/macOS で API が違う。PR 1 でチェックボックスだけ先に確立する
- EditorWritingPage (オンボーディング用) など EditorBlockRow に依存する画面の追随が必要 (影響範囲は PR 1 実装時に grep で確定)

---

## チェックリスト

### 実装内容
- [ ] 変更対象ファイルごとに具体的なコード提案をコードブロックで記載している
- [ ] 既存コードのパターン・構成 (`Nikki/Features/**`, `Nikki/DesignSystem/**`, `Nikki/Models/**`) を確認し、同じパターンで実装している
- [ ] 変更範囲が必要最小限であること (PR 分割で段階的に置換する)

### ビルド
- [ ] `xcodebuild build -project Nikki.xcodeproj -scheme Nikki -destination 'platform=iOS Simulator,name=<起動中のシミュレータ>'` が成功する (ログ全文を `./tmp` に保存し warning / error を grep で検査)
- [ ] macOS ビルドも成功する (プラットフォーム分岐が多いため両方検査)

### UI (画面変更がある場合)
- [ ] `/ios-simulator` (simtunnel 優先) でシミュレータを起動し、`/verify-ui-mobile-mcp` 等で実機挙動を目視確認 (スクリーンショット取得)
- [ ] macOS でも動作確認 (CLAUDE.md「プラットフォーム別の動作確認」に従い両 OS のスクリーンショットを PR に添付)
- [ ] 日本語 IME の変換中テキスト保持 (issue #86 の再発確認) を両 OS で実施
- [ ] Editor/QA.md の再実行・更新 (run-qa)

### 共通
- [ ] エラーメッセージはそのまま表示 (加工・プレフィックス除去なし)

## セッション再開

```sh
cd /Users/bannzai/ghq/github.com/bannzai/nikki
claude --resume 254f63c4-ee10-4a52-b3e2-5680480bc0fc
```

Contributor guide

No contributing guide indexed for this repository

Research direction

Start by reading Nikki/Features/Editor/EditorPage.swift, the existing editor block components, Block.swift, and Editor/QA.md; then inspect the proposed EditorTextView.swift and EditorMarkdownStyler.swift boundaries. The work is complete only when the phased single-text-view redesign, markdown-preserving editing, unit tests, builds, and iOS/macOS QA described in the issue are covered.

Written by the indexing model from the issue text.

Assessment

Tech stack
swift
Domain
desktop, frontend, mobile
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.