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 } } } |
構造体に状態を保存して処理をまとめる
|
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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
struct CellData: Identifiable { var id = UUID() var value: String } struct OperationBox: Identifiable { var id = UUID() var isSelected: Bool } struct TableSnapshot { let rows: [[CellData]] let operations: [OperationBox] let columnHeaders: [String] let maxColumnCount: Int let checkedColumns: Set<Int> let columnOrder: [Int] } /// テーブル全体の状態を管理する ObservableObject class TableStore: ObservableObject { @Published var rows: [[CellData]] = [] // rows[row][col] @Published var operations: [OperationBox] = [] @Published var columnHeaders: [String] = [] @Published var maxColumnCount: Int = 0 @Published var checkedRows: Set<Int> = [] @Published var saveAlert: Bool = false @Published var checkedColumns: Set<Int> = [] @Published var columnOrder: [Int] = [] func snapshot() -> TableSnapshot { TableSnapshot( rows: rows, operations: operations, columnHeaders: columnHeaders, maxColumnCount: maxColumnCount, checkedColumns: checkedColumns, columnOrder: columnOrder ) } func restore(from snap: TableSnapshot) { rows = snap.rows operations = snap.operations columnHeaders = snap.columnHeaders maxColumnCount = snap.maxColumnCount checkedColumns = snap.checkedColumns columnOrder = snap.columnOrder } |
|
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 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 |
struct tabletest: View { @StateObject private var store = TableStore() @State private var isOpen: Bool = false @Environment(\.undoManager) private var undoManager var body: some View { VStack(spacing: 0) { // ツールバー的なエリア HStack { Button("TSVを開く") { openFile() } Button { store.saveTSV() } label: { Label("TSVを保存", systemImage: "square.and.arrow.down") } .disabled(store.rows.isEmpty) .keyboardShortcut("s", modifiers: .command) // ⌘S Button(role: .destructive) { deleteCheckedRows() } label: { Label("チェック行を削除", systemImage: "trash") } .disabled(store.operations.allSatisfy{ !$0.isSelected }) Button(role: .destructive) { deleteCheckedColumns() } label: { Label("チェック列を削除", systemImage: "trash") } .disabled(store.checkedColumns.isEmpty) Spacer(minLength: 22) Text("\(store.rows.count) 行 × \(store.maxColumnCount) 列") .foregroundStyle(.secondary) .font(.caption) } .padding(8) Divider() HStack { if (store.rows.count > 0) { List { Section(header: Color.clear.frame(height: 20)) { ForEach($store.operations) { $operation in RowHeaderView(isSelected: $operation.isSelected) } .onMove { indices, newOffset in let fromIndex = indices.first! let moved = store.rows.remove(at: fromIndex) let target = fromIndex < newOffset ? newOffset - 1 : newOffset store.rows.insert(moved, at: target) store.operations.move(fromOffsets: indices, toOffset: newOffset) } } } .frame(width: 70) .listStyle(.plain) } TSVTableView(store: store) } } .alert("保存に失敗しました。", isPresented: $store.saveAlert) { Button("OK") { } } } private func openFile() { let panel = NSOpenPanel() panel.allowedContentTypes = [.plainText] panel.allowsMultipleSelection = false if panel.runModal() == .OK, let url = panel.url { store.loadTSV(from: url) } } private func deleteCheckedColumns() { applyWithUndo(actionName: "列を削除") { let sorted = store.checkedColumns.sorted() var newIndexMap: [Int: Int] = [:] var newIdx = 0 for oldIdx in 0..<store.maxColumnCount { if !store.checkedColumns.contains(oldIdx) { newIndexMap[oldIdx] = newIdx newIdx += 1 } } for colIdx in sorted.reversed() { for rowIdx in store.rows.indices where colIdx < store.rows[rowIdx].count { store.rows[rowIdx].remove(at: colIdx) } if colIdx < store.columnHeaders.count { store.columnHeaders.remove(at: colIdx) } } store.maxColumnCount -= store.checkedColumns.count store.columnOrder = store.columnOrder .filter { !store.checkedColumns.contains($0) } .compactMap { newIndexMap[$0] } store.checkedColumns.removeAll() } } private func deleteCheckedRows() { applyWithUndo(actionName: "行を削除") { let indexSet = IndexSet( store.operations.indices.filter { store.operations[$0].isSelected } ) for i in indexSet.sorted().reversed() { store.rows.remove(at: i) } store.operations.remove(atOffsets: indexSet) } } // ─── Undo/Redo 汎用ヘルパー ─────────────────────────────── /// 操作前後のスナップショットを対称的に登録する private func applyWithUndo(actionName: String, perform: () -> Void) { let before = store.snapshot() // 操作前を保存 perform() // 実際の削除を実行 let after = store.snapshot() // 操作後を保存 // Undo 登録(beforeへ戻す) undoManager?.registerUndo(withTarget: store) { [weak undoManager, before, after] store in store.restore(from: before) undoManager?.setActionName(actionName) // Redo 登録(afterへ進む) undoManager?.registerUndo(withTarget: store) { [weak undoManager, after] store in store.restore(from: after) undoManager?.setActionName(actionName) } } undoManager?.setActionName(actionName) } } |
コメント