Undo/Redoの仕組み
基本概念
NSUndoManagerは2つのスタックを持っています:
┌──────────────┐ ┌──────────────┐
│ Undoスタック │ │ Redoスタック │
├──────────────┤ ├──────────────┤
│ 操作3 │ │ │
│ 操作2 │ │ │
│ 操作1 │ │ │
└──────────────┘ └──────────────┘
ステップ1: 塗りつぶし実行
// ユーザーが塗りつぶしを実行
performFloodFill(at: point)
内部処理:
private func performFloodFill(at point: CGPoint) {
let oldImage = drawing_image // ← 現在の画像を保存
// 塗りつぶし処理
guard let filled = floodFill(...) else { return }
// Undo登録(重要!)
registerUndoForFloodFill(oldImage: oldImage, newImage: filled)
// 画像を更新
drawing_image = filled
drawing_layer.contents = filled
}
ステップ2: Undo登録
private func registerUndoForFloodFill(
oldImage: CGImage?,
newImage: CGImage?
) {
undoManager?.registerUndo(withTarget: self) { target in
target.restoreDrawingImage(oldImage)
}
undoManager?.setActionName("塗りつぶし")
}
この時点でのスタック:
Undoスタック:
┌─────────────────────────────────┐
│ 操作: restoreDrawingImage(旧画像) │ ← NEW!
└─────────────────────────────────┘
Redoスタック:
┌─────────────────────────────────┐
│ (空) │
└─────────────────────────────────┘
現在の状態: 新画像
ステップ3: Undoを実行(Cmd+Z)
ユーザーが Cmd+Z を押すと:
// システムが自動的に呼び出す
undoManager?.undo()
内部で起こること:
- Undoスタックから操作を取り出す
- その操作を実行:
restoreDrawingImage(oldImage)
private func restoreDrawingImage(_ image: CGImage?) {
let currentImage = drawing_image // ← 現在(新画像)を保存
// 画像を元に戻す
drawing_image = image // 旧画像に戻す
drawing_layer.contents = image
// ここがポイント!Redoのために新画像を登録
undoManager?.registerUndo(withTarget: self) { target in
target.restoreDrawingImage(currentImage) // 新画像
}
}
この時点でのスタック:
Undoスタック:
┌─────────────────────────────────┐
│ (空) │
└─────────────────────────────────┘
Redoスタック:
┌─────────────────────────────────┐
│ 操作: restoreDrawingImage(新画像) │ ← NEW!
└─────────────────────────────────┘
現在の状態: 旧画像(元に戻った)
ステップ4: Redoを実行(Cmd+Shift+Z)
ユーザーが Cmd+Shift+Z を押すと:
// システムが自動的に呼び出す
undoManager?.redo()
内部で起こること:
- Redoスタックから操作を取り出す
- その操作を実行:
restoreDrawingImage(newImage)
private func restoreDrawingImage(_ image: CGImage?) {
let currentImage = drawing_image // ← 現在(旧画像)を保存
// 画像を新しいものに
drawing_image = image // 新画像に戻す
drawing_layer.contents = image
// またUndoのために登録
undoManager?.registerUndo(withTarget: self) { target in
target.restoreDrawingImage(currentImage) // 旧画像
}
}
この時点でのスタック:
Undoスタック:
┌─────────────────────────────────┐
│ 操作: restoreDrawingImage(旧画像) │ ← NEW!
└─────────────────────────────────┘
Redoスタック:
┌─────────────────────────────────┐
│ (空) │
└─────────────────────────────────┘
現在の状態: 新画像(やり直した)
完全なフロー図
初期状態: 画像A
↓
塗りつぶし実行
↓
[登録] Undo: 画像Aに戻す
↓
現在の状態: 画像B
↓
Cmd+Z (Undo)
↓
[実行] 画像Aに戻す
[登録] Redo: 画像Bに進む
↓
現在の状態: 画像A
↓
Cmd+Shift+Z (Redo)
↓
[実行] 画像Bに進む
[登録] Undo: 画像Aに戻す
↓
現在の状態: 画像B
複数操作がある場合
3回塗りつぶした場合
操作1: 白 → 赤
操作2: 赤 → 青
操作3: 青 → 緑
Undoスタック:
┌──────────────┐
│ 青 → 緑 を戻す │ ← 最新
│ 赤 → 青 を戻す │
│ 白 → 赤 を戻す │ ← 最古
└──────────────┘
現在: 緑
Undoを1回実行:
Undoスタック:
┌──────────────┐
│ 赤 → 青 を戻す │
│ 白 → 赤 を戻す │
└──────────────┘
Redoスタック:
┌──────────────┐
│ 青 → 緑 を進む │
└──────────────┘
現在: 青
さらにUndoを実行:
Undoスタック:
┌──────────────┐
│ 白 → 赤 を戻す │
└──────────────┘
Redoスタック:
┌──────────────┐
│ 青 → 緑 を進む │
│ 赤 → 青 を進む │
└──────────────┘
現在: 赤
重要な仕組み
1. 新しい操作をするとRedoスタックがクリアされる
状態: 白 → 赤 → 青
Undoを2回実行
↓
状態: 白
Redoスタック: [赤に進む, 青に進む]
ここで新しい塗りつぶし(緑)を実行
↓
Redoスタックがクリア!
状態: 白 → 緑
Undoスタック: [白 → 緑 を戻す]
Redoスタック: [] ← 空になる
2. registerUndo内でregisterUndoする理由
private func restoreDrawingImage(_ image: CGImage?) {
let currentImage = drawing_image
drawing_image = image
drawing_layer.contents = image
// ここでまたregisterUndoする!
undoManager?.registerUndo(withTarget: self) { target in
target.restoreDrawingImage(currentImage)
}
}
理由: Undo/Redo を何度でも行き来できるようにするため
Undo実行時:
- 操作を実行する
- その逆操作をRedoスタックに登録
Redo実行時:
- 操作を実行する
- その逆操作をUndoスタックに登録
undoManagerはどこから来る?
// NSResponder(NSViewの親クラス)が持っている
override var undoManager: UndoManager? {
return window?.undoManager // ← ウィンドウが管理
}
自動的に接続される:
- メニューの「編集 > 取り消す」
- ショートカット Cmd+Z
- ショートカット Cmd+Shift+Z
デバッグ用コード
private func registerUndoForFloodFill(
oldImage: CGImage?,
newImage: CGImage?
) {
print("📝 Undo登録: 塗りつぶしを元に戻す")
undoManager?.registerUndo(withTarget: self) { target in
print("🔙 Undo実行: 画像を復元")
target.restoreDrawingImage(oldImage)
}
undoManager?.setActionName("塗りつぶし")
}
private func restoreDrawingImage(_ image: CGImage?) {
let currentImage = drawing_image
print("🔄 画像を変更")
drawing_image = image
drawing_layer.contents = image
print("📝 Undo登録: 逆操作")
undoManager?.registerUndo(withTarget: self) { target in
print("🔙 Undo実行: 逆の復元")
target.restoreDrawingImage(currentImage)
}
}
出力例:
[塗りつぶし実行]
📝 Undo登録: 塗りつぶしを元に戻す
[Cmd+Z]
🔙 Undo実行: 画像を復元
🔄 画像を変更
📝 Undo登録: 逆操作
[Cmd+Shift+Z]
🔙 Undo実行: 逆の復元
🔄 画像を変更
📝 Undo登録: 逆操作
まとめ
| タイミング | 何が起こる? | どこに登録? |
|---|---|---|
| 操作実行 | registerUndoを呼ぶ | Undoスタック |
| Undo実行 | Undoスタックから取り出して実行<br>→ 逆操作をRedoスタックに登録 | Redoスタック |
| Redo実行 | Redoスタックから取り出して実行<br>→ 逆操作をUndoスタックに登録 | Undoスタック |
| 新規操作 | Redoスタックをクリア | – |
キーポイント:
registerUndoの中でregisterUndoすることで無限にUndo/Redoできる- システムが自動的に
undo()/redo()を呼んでくれる - メニューやショートカットは自動で有効化される
setActionName()の役割
メニューに表示される操作名を設定するためのメソッドです。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
undoManager?.registerUndo(withTarget: self) { target in target.restoreDrawingImage(oldImage) } undoManager?.setActionName("塗りつぶし") // ← 操作に名前をつける ``` ### 効果 **メニューバーの表示が変わります:** ``` // setActionNameなしの場合 編集 > 取り消す Cmd+Z 編集 > やり直す Cmd+Shift+Z // setActionNameありの場合 編集 > "塗りつぶし"を取り消す Cmd+Z 編集 > "塗りつぶし"をやり直す Cmd+Shift+Z |
なぜ最後に書くのか?
直前に登録したregisterUndo操作に名前をつけるためです。
簡単なまとめ
setActionName()は直前のregisterUndoに名前をつけるメソッド
|
1 2 3 4 5 6 7 8 9 |
undoManager?.registerUndo(...) // ← この操作に undoManager?.setActionName("塗りつぶし") // ← 名前をつける ``` **効果:** ``` メニュー表示が変わる ❌ 取り消す ✅ "塗りつぶし"を取り消す ← ユーザーに優しい! |
なぜ最後?
- 直前の操作に名前をつける仕様だから
- 先に書いても効果がない
必須ではないが、UX向上のために推奨されています!
リセット
CanvasNSView にリセットメソッドを追加:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
final class CanvasNSView: NSView, NSTextViewDelegate { // ... existing code ... // Undo/Redo履歴をクリア func resetUndoHistory() { undoManager?.removeAllActions() } // キャンバス全体をリセット(オプション) func resetCanvas() { // Undo履歴をクリア undoManager?.removeAllActions() // 描画コマンドをクリア draw_commands.removeAll() // レイヤーをクリア clearLayer(add_images_root) clearLayer(shape_layer_root) clearLayer(text_layer_root) // 描画レイヤーをリセット if let size = drawing_image.flatMap({ CGSize(width: $0.width, height: $0.height) }) { let scale = NSScreen.main?.backingScaleFactor ?? 2.0 let emptyImage = makeEmptyBitmap(size: size, scale: scale) drawing_image = emptyImage drawing_layer_root.contents = emptyImage } } } |
コメント