Snapshot Undo/Redo with Advanced Abstraction Design
Advanced Abstraction-Based Snapshot Undo/Redo
Snapshot-Based Undo/Redo through Advanced Abstraction
注意点:この記事の内容はかなり上級者向けで、設計レベルのちょっと高度なプログラミング知識が要求されます。
作れるようになると、空を自由に飛べる巫女さん程度の能力なCoderに一歩近づくでしょう。たぶん。
いいねされない記事は消されます。
Git Hub Sponsersで寄付を頂けた場合、Gitリポジトリを追加します。 (制作に2か月ほどかかったため)
たった10ストックで
- DataGrid戦略などのアルゴリズム公開
- MVVM化とT Valueの実装など(出来たらだけど)
をやります。お得。
前回の記事 【WPF】DataGridでコンスセルなUndoRedo のつづき。
特に言及がない限り、.net9.0 VisualStudio 2022が前提です。
前回と変わらずデータの入れ物としてConsCellを使用してます。
他の「入れ物」としてはImmutableStack<T>やStack<T>、Queue<T>等が使えます(ImmutableStack<T>が一番ConsCellに近い)。
解説:https://qiita.com/hysui/items/1fa7146f36fb52c5ea50
つまり現状ではわざわざConsCellを使わないといけない理由はないのですが、後に非線形で履歴を遡れる実装を作るのに便利なので。興味があればImmutableStack<T>等で実装を試みるのが良いでしょう。.net純正なのでメモリ効率が若干変わりそうです。
参考記事
参考記事は非常に重要です。羊にとっての草、美女にとっての化粧品のようなものです。
多分一番、実装的に感銘を受けたサンプルでした。
スマートなんだけど、Binding使ってるからあんまり直観的な実装じゃないなと思い今回は採用していません。
T Valueを使っている、
DataGridが未実装。
The Memento Design Pattern in C#, Practically With Examples [2024]
初級-中級者向け。WPF対応。
オブジェクトの状態をカプセル化して保存(スナップショット)し、Undo/Redoを可能にする実装例。
ImmutableList<T>を使用
完成度が高い。
リポジトリへのLinkもあるので安心。
記事内容よりもGit内のCodeの方が重要。
Commandパターン: 各操作をCommandオブジェクトとしてカプセル化。Do(実行)とUndo(取り消し)のアクションをデリゲート(RecordableAction)で保持。
- 状態管理: 連鎖リスト(Stateクラス)で履歴を管理。PrevState/NextStateで双方向リンク、PrevCommand/NextCommandでコマンドを紐付け。
- トランザクションサポート: TransactionCommandで複数コマンドを1つの単位として扱い、Undo/Redoを原子的に実行(例: 複合操作のバッチ処理)。
- スレッドセーフ: lock(Lock)で全操作を同期。
- 状態全体保存せず差分操作専用
強み
- 連鎖リスト+Commandのハイブリッドで、Redo時の分岐防止(EliminateStates)が洗練。トランザクションの逆順Undoが実用的。
初心者〜中級者向け。
※以下はGit内Codeの評価。
- SnapshotManagerはキー(例: タイムスタンプ)でスナップショットを管理し、シーケンシャルでない履歴を扱う。一方、UndoRedoManagerはスタックベースで直線的なUndo/Redo専用。
- SnapshotManagerの辞書をUndoRedoManagerのスタックに置き換える、またはキーとして操作順序を管理する設計が可能
- ディープコピー実装がコードにない
-
強み
SnapshotManagerのキー付き辞書管理は、タイムスタンプや操作IDでスナップショットを扱う独自の視点。UndoRedoManagerの再帰防止フラグは実務的。
動画(sample)
この記事の内容で以下の動作を得ることができます。
- DataGridの動作デモ
① 個別Capture(SnapShot)/ 複合Capture(SnapShot)
② 個別Undo/Redo
各コントロール毎にスタックが積まれます
③ 複合Undo/Redo
全てのコントロールを対象にスタックが積まれます。
④ Add Data
羊Personデータを入れます
⑤ 羊の品種名です。女性にも栗毛、葦毛、◎法〇リなど居ますね。
⑥ 羊の生育段階を示す名称です。人間の女性にも応用できるでしょう。
⑦ システムカラーを弄ります。変更するとバグってもとに戻らなくなります ※1。
説明に入れてないけどTextBoxもUndo/Redoの対象になります。
サンプルバイナリ
こんなんで動くのかって疑い深い人向け。ちなみに解析禁止です(笑)。頑張って解析しちゃおう。
あまりにも警戒心がつよい人には〇ンサムウェアが発動する可能性が1%ぐらいあります(※1)。
めんどいのでGoogle Dribeにさせていただきました。
仕様
コントロールの状態を保存したSnapShotを撮る。
→ 10-15回のSnapで230MB程度までメモリを食います。
個別CaotureのStackと複合CaptureのStackは独立していて、Undo/Redoの際の挙動が異なります。比べてみてください。わざわざDebeg.WriteLineも入れてるので。
(ちなみにコンストラクタのStrategy登録の際に個別・っ複合のUndoスタックを統合することも出来る)
-
使用するコントロール
Button(Button厨なので)、DataGrid、TextBox、CheckBox、RadioButton,Slider
構成するクラス
-
Core(フォルダ)
ConsCell<T>: 前回を参照。Undo/RedoStackの入れ物。
SnapshotController: ISnapshotStrategy を使い、スナップショットの操作を統一的に管理するコンテキストクラス。
ConsCell<T>型のUndoStack、RedoStackを持つ。 -
Model
Person:DataGridに渡すデータのモデル。DeepCopy用のCloneメソッドを持つ。
PersonCreater: RanDam Dataの生成 -
Strategy
- BaseStrategy
-
ISnapshotStrategy: SnapSHhotのメソッド規約を示すだけでなく、Strategy毎のメソッド動作の橋渡しも行う。 -
SnapshotStrategyBase<T>: UndoRedo実装の共通処理基盤を提供する。各StrategyにおけるUndoRedo処理・UndoStackの共通化を担い、非常に重要。 abstract修飾子のメソッドで個別のStrategy戦略として派生する。
-
- BaseStrategy
- 個別コントロールのStrategy
対象コントロール毎に実装が異なるため、SnapshotStrategyBase<T>を継承して派生している。
SnapshotStrategyBase、ISnapshotStrategyを継承した共通戦略クラス。Stackはこのクラスが持つ。
- CompositeSnapshot : 複合SnapShotを提供するクラス。
- CompositeStrategy :
SnapshotStrategyBase<CompositeSnapshot>を継承した複合SnapShotの具象クラス。各コントロールのStackをまとめて積むためのもの。
- Utility
- ChangeRDBBase : Template Methodによる共通化実装。これにより保守性、可読性を損なわない。
- ChangeRDBHelper : 内部に
ChangeRDBBaseメンバを持ち、呼び出し時に切り換えるStaticなCall用実装。 - EnumAllControls : 全コントロールを列挙するやつ。便利なのでよく使う。
実装内容
呼び出し順と呼び出し構造
1.List<Strategy> _stratesyListにStrategyを登録する
2. SnapshotController _controllerを通じて、Caputure機構・Undo/Redo機構を呼び出す。
3.登録したStrategyListからForeachでCaptureメソッドまたはUndo/Redoを呼び出す。
4.どのようなヒツジが存在しているのかが分かる。
この実装におけるInterFaceの役割
Snapshotを行うメソッドとして下記が登録されている。
public interface ISnapshotStrategy
{
public abstract void CaptureSnapshot();
public virtual void Undo() { }
public virtual void Redo() { }
bool CanUndo { get; }
bool CanRedo { get; }
}
abstract修飾子を持たせた CaptureSnapshot();により、具象クラスにおいてoverrideが行われる。
尚、
Button Click → ISnapshotStrategyで登録メソッドのコンパイラへの伝達 → abstract class SnapshotStrategyBase<T>→ 各戦略クラスでoverride
という流れで呼び出される。
Interfaceにおいて規約メソッドのコンパイラへの伝達は重要な概念であり、例えばIlist<ISnapShotStrategy>のようにジェネリクス型Class/Methodの型引数にInterface型を渡すことで、柔軟にメソッドの紐づけを行うことを可能にしている。
これはジェネリック型制約<ClassName> Where T : <Interface> キーワードに置いても使われる(今回は使っていないが)。
SnapshotController
このクラスを介して、CaptureSnapshot、Undo/Redoロジックを動作させます。
using SnapShotUndo.Strategy.BaseStaretegt;
namespace SnapShotUndo.Core
{
/// <summary>
/// ISnapshotStrategy を使い、スナップショットの操作を統一的に管理するコンテキストクラス。
/// Strategy Pattern の「Context」に相当する。
/// </summary>
public class SnapshotController
{
+ private ISnapshotStrategy? _strategy;
+ private List<ISnapshotStrategy> _stratesyList = new();
public bool CanUndo => throw new NotImplementedException();
public bool CanRedo => throw new NotImplementedException();
/// <summary>
/// 利用する戦略を設定する。
/// TextBox用やDataGrid用など、状況に応じて差し替え可能。
/// </summary>
public void SetStrategy(ISnapshotStrategy strategy)
{
+ _stratesyList = new List<ISnapshotStrategy> { strategy };
+ //Interface経由でメソッドを伝播させる(Dependency Injection)
}
public void SetStrategy(List<ISnapshotStrategy> strategyList)
{
+ //StrategyをListに登録する(複合SnapShot用)
_stratesyList.Clear();
_stratesyList.AddRange(strategyList);
}
/// <summary>
/// 現在の状態をスナップショットとして保存。
/// </summary>
public void IndividualCapture()
{
foreach (var s in _stratesyList)
s.CaptureSnapshot();
}
+ Strategyが一つでも複数でも統一してforeachで回す
/// <summary>
/// Undo操作を実行。
/// </summary>
public void Undo()
{
foreach (var s in _stratesyList)
s.Undo();
}
/// <summary>
/// Redo操作を実行。
/// </summary>
public void Redo()
{
foreach (var s in _stratesyList)
s.Redo();
}
internal void CompositeCapture()
{
foreach (var s in _stratesyList)
s.CaptureSnapshot();
}
}
}
Strategy(戦略) クラス
DataGrid以外のものを挙げます。
一番苦労させられたのがDataGridのため、ストックが10に達しない限り公開されません。 こんなときはAIに頼むのが良いでしょう。
差分Undo/Redoをするために、直前のUndoStackと比較して==の場合はUndoStackに積みません。
SnapshotStrategyBase<T>
public abstract class SnapshotStrategyBase<T> : ISnapshotStrategy
{
protected ConsCell<T> _undoStack = new();
protected ConsCell<T> _redoStack = new();
public virtual bool CanUndo => !_undoStack.IsEmpty;
public virtual bool CanRedo => !_redoStack.IsEmpty;
+ //わざわざ分かりやすく``UndoCore`に`名前を変えてるので実際にはVirtualではない
// 共通のUndoロジック
protected virtual void UndoCore(Action<T> apply)
{
if (CanUndo)
{
var current = _undoStack.Head;
_undoStack = _undoStack.Tail;
_redoStack = _redoStack.Push(current);
if (!_undoStack.IsEmpty)
apply(_undoStack.Head);
}
CountManage.StackCounter(_undoStack);
}
// 共通のRedoロジック
protected virtual void RedoCore(Action<T> apply)
{
if (CanRedo)
{
var redoValue = _redoStack.Head;
_redoStack = _redoStack.Tail;
_undoStack = _undoStack.Push(redoValue);
apply(redoValue);
}
}
//InterFaceにより実装の強制
// 派生クラスで具体化
public abstract void CaptureSnapshot();
//InterFaceにより実装の強制
+ public abstract void Undo();
+ public abstract void Redo();
}
TextBoxSnapshotStrategy
これが最もシンプルな戦略アルゴリズムでしょう。
public class TextBoxSnapshotStrategy : SnapshotStrategyBase<string>
{
private readonly TextBox _target;
+ public string GetCurrentValue()
=> _target.Text;
public TextBoxSnapshotStrategy(TextBox target)
{
_target = target;
_undoStack = _undoStack.Push(target.Text);
}
public void Apply(string text)
{
_target.Text = text;
}
public override void CaptureSnapshot()
{
var current = _target.Text;
if (_undoStack.IsEmpty)
{
_undoStack = _undoStack.Push(current);
_redoStack.Clear();
return;
}
+ if (current == _undoStack.Head)
return;
else
+ // 差分キャプチャ: 「前の状態」をUndoに積む
_undoStack = _undoStack.Push(current);
Debug.WriteLine(this.GetType().Name + _undoStack.Count);
_redoStack.Clear();
}
+ public override void Undo() => base.UndoCore(v => _target.Text = v);
+ public override void Redo() => base.RedoCore(v => _target.Text = v);
}
CheckBoxStrategy
やや複雑。
using SnapShotUndo.Strategy.BaseStaretegt;
using System.Diagnostics;
using System.Windows.Controls;
namespace SnapShotUndo.Strategy
{
internal class CheckBoxStrategy : SnapshotStrategyBase<IEnumerable<bool>>, ISnapshotStrategy
{
private readonly IEnumerable<CheckBox> _checkBoxes;
public CheckBoxStrategy(IEnumerable<CheckBox> checkBoxes)
{
_checkBoxes = checkBoxes;
// 初期スナップショット
_undoStack = _undoStack.Push(GetCurrentState());
}
+ private List<bool> GetCurrentState()
{
+ var state = new List<bool>();
+ foreach (var cb in _checkBoxes)
+ state.Add(cb.IsChecked == true);
+ return state;
}
public override void CaptureSnapshot()
{
var current = GetCurrentState();
+ // 直前と同じなら無視
+ if (_undoStack.Head.SequenceEqual(current))
+ return;
else
_undoStack = _undoStack.Push(current);
Debug.WriteLine(this.GetType().Name + _undoStack.Count);
_redoStack.Clear();
}
+ //細部の実装が異なるのでoverride(上書き)する必要がある
+ public override void Undo() => base.UndoCore(values =>
{
foreach (var (checkBox, isChecked) in _checkBoxes.Zip(values))
checkBox.IsChecked = isChecked;
});
+ public override void Redo() => base.RedoCore(values =>
{
+ foreach (var (checkBox, isChecked) in _checkBoxes.Zip(values))
+ checkBox.IsChecked = isChecked;
///CheckBox の順番通りに状態を復元
+ ////Enumerable.Zip は 2 つのシーケンスを同時に走査するメソッド。
+ ///要素数が異なる場合は 短い方に合わせて自動で終了 します。
});
public IEnumerable<bool> GetCurrentValue()
=> _checkBoxes.Select(c => c.IsChecked == true);
public void Apply(IEnumerable<bool>? value)
{
if (value is null)
return;
foreach (var (cb, v) in _checkBoxes.Zip(value))
cb.IsChecked = v;
}
}
}
RadioGroupStrategy
CheckBoxと異なり、Nameプロパティで判定している。
internal class RadioGroupStrategy : SnapshotStrategyBase<string>
{
//この場合は``IEnumerable<T>``よりも``List<T>``の方が
//遅延実行がなく、高速。
private readonly List<RadioButton> _radioButtons;
internal string GetCurrentValue()
{
return _radioButtons.FirstOrDefault(r => r.IsChecked == true)?.Name ?? string.Empty;
}
public RadioGroupStrategy(List<RadioButton> buttons)
{
_radioButtons = buttons;
_undoStack = _undoStack.Push(GetCheckedName());
}
private string GetCheckedName()
=> _radioButtons.FirstOrDefault(b => b.IsChecked == true)?.Name ?? string.Empty;
public override void CaptureSnapshot()
{
var current = GetCheckedName();
if (_undoStack.IsEmpty)
{
_undoStack = _undoStack.Push(current);
_redoStack.Clear();
return;
}
if (current == _undoStack.Head)
return;
else
_undoStack = _undoStack.Push(current);
Debug.WriteLine(this.GetType().Name + _undoStack.Count);
_redoStack = new ConsCell<string>();
}
public override void Undo()
=> base.UndoCore(name => SetChecked(name));
public override void Redo()
=> base.RedoCore(name => SetChecked(name));
private void SetChecked(string name)
{
foreach (var btn in _radioButtons)
btn.IsChecked = (btn.Name == name);
}
internal void Apply(string checkedName)
{
foreach (var radio in _radioButtons)
{
radio.IsChecked = (radio.Name == checkedName);
}
}
}
SliderStrategy
using SnapShotUndo.Strategy.BaseStaretegt;
using System.Diagnostics;
using System.Windows.Controls;
namespace SnapShotUndo.Strategy
{
internal class SliderStrategy : SnapshotStrategyBase<IEnumerable<object>>
{
List<Slider> _Sliders;
public SliderStrategy(List<Slider> sliders)
{
_Sliders = sliders;
foreach (Slider slider in _Sliders)
slider.Value = 255;
// 初期スナップショット
_undoStack = _undoStack.Push(GetCurrentState());
}
private IList<object> GetCurrentState()
{
var state = new List<object>();
foreach (var cb in _Sliders)
state.Add(cb.Value);
return state;
}
public override void CaptureSnapshot()
{
var current = GetCurrentState().ToList();
if (_undoStack.IsEmpty)
{
_undoStack = _undoStack.Push(current);
_redoStack.Clear();
return;
}
// 直前と同じなら無視
if (_undoStack.Head.SequenceEqual(current))
return;
else
_undoStack = _undoStack.Push(current);
Debug.WriteLine(this.GetType().Name + _undoStack.Count);
_redoStack.Clear();
}
public override void Redo() => base.RedoCore(values =>
{
foreach (var (tokenSlider, tokenValue) in _Sliders.Zip(values))
tokenSlider.Value = (double)tokenValue;
});
public override void Undo() => base.UndoCore(values =>
{
foreach (var (tokenSlider, tokenValue) in _Sliders.Zip(values))
tokenSlider.Value = (double)tokenValue;
});
internal IEnumerable<double> GetCurrentValue()
{
return _Sliders.Select(s => s.Value);
}
internal void Apply(IEnumerable<double> sliderValue)
{
if (sliderValue is null)
return;
////すべての CheckBox の状態が value の最後の要素として反映される
//foreach (var token in _Sliders)
// foreach(var coslide in sliderValue)
// token.Value = coslide;
foreach (var (cb, value) in _Sliders.Zip(sliderValue))
cb.Value = value;
}
}
}
CompositeStrategy
複合Snapshotアルゴリズムを提供するクラス。
内部に全てのValueオブジェクトを持つ。
等価性判定のためIEquatable<CompositeSnapshot>を継承している。
using SnapShotUndo.Model;
using System.Collections.ObjectModel;
namespace SnapShotUndo.Strategy
{
internal class CompositeSnapshot : IEquatable<CompositeSnapshot>
{
public string TextBoxValue { get; set; } = string.Empty;
public string RadioNames { get; set; } = string.Empty;
public IEnumerable<bool> CheckBoxValue { get; set; } = new List<bool>();
public IEnumerable<double> SliderValue { get; set; } = new List<double>();
public ObservableCollection<Person> PersonValue { get; set; } = new();
public bool Equals(CompositeSnapshot? other)
{
if (other is null)
return false;
return TextBoxValue == other.TextBoxValue
&& RadioNames == other.RadioNames
&& CheckBoxValue.SequenceEqual(other.CheckBoxValue)
&& SliderValue.SequenceEqual(other.SliderValue)
&& PersonValue.SequenceEqual(other.PersonValue);
}
}
}
DataGridSnapshotStrategy
Cloneメソッドを持つ。
Replaceメソッドを持つので、セル単位編集でもそのままUndo/Redo出来る。
10ストックまで非公開です。
呼び出し
一見すると冗長な実装に思えますが、利用者側の呼び出しが驚くほどシンプルになります。
例えばAdaptorパターンなど使うとコンストラクタで煩雑な初期化が必要になってしまうので、このような管理クラスを介する設計は有用です。これこそ隠蔽的な設計ですね。
- コンストラクタ
無駄に長い。
private readonly SnapshotController _controller = new();
private readonly SnapshotController _CompoSiteController = new();
private readonly ObservableCollection<Person> _people = new ObservableCollection<Person>();
List<ISnapshotStrategy> _snapStaratesyList = new();
List<CheckBox> checkBoxes;
List<RadioButton> radioButtons;
List<Slider> Sliders = new();
public MainWindow()
{
InitializeComponent();
// 初期戦略は DataGrid 用
var gridStrategy = new DataGridSnapshotStrategy(dataGrid1, _people);
var textStrategy = new TextBoxSnapshotStrategy(textBox1);
dataGrid1.ItemsSource = _people;
checkBoxes = new List<CheckBox>();
radioButtons = new List<RadioButton>();
this.WalkInChildren(child =>
{
if (child is CheckBox checkBox)
{
checkBoxes.Add(checkBox);
}
});
var checkStratesy = new CheckBoxStrategy(checkBoxes);
this.WalkInChildren(child =>
{
if (child is RadioButton radios)
{
radioButtons.Add(radios);
}
});
this.WalkInChildren(child =>
{
if (child is Slider slider)
{
Sliders.Add(slider);
}
});
var sliderStratesy = new SliderStrategy(Sliders);
var radioStaratesy = new RadioGroupStrategy(radioButtons);
_snapStaratesyList.Add(gridStrategy);
_snapStaratesyList.Add(textStrategy);
_snapStaratesyList.Add(checkStratesy);
_snapStaratesyList.Add(radioStaratesy);
_snapStaratesyList.Add(sliderStratesy);
//複合SnapShot用
+ var compoStrategy = new CompositeStrategy(textStrategy, radioStaratesy, checkStratesy, sliderStratesy, gridStrategy);
_CompoSiteController.SetStrategy(compoStrategy);
_controller.SetStrategy(_snapStaratesyList);
//ここでcompoStrategyを_snapStaratesyListに積んでおくと
//全てのUndo/RedoStackを統合できます。
SheepSlider.Value = 255;
SheepSlider2.Value = 255;
SheepSlider3.Value = 255;
}
- 各Burron呼び出し
非MVVM。手抜き。
private void CaptureButton_Click(object sender, RoutedEventArgs e)
{
_controller.IndividualCapture();
}
private void UndoButton_Click(object sender, RoutedEventArgs e)
{
_controller.Undo();
}
private void RedoButton_Click(object sender, RoutedEventArgs e)
{
_controller.Redo();
}
private void AddDatadButton_Click(object sender, RoutedEventArgs e)
{
var newPerson = PersonCreater.RandomPerson();
_people.Add(newPerson);
}
private void SheepSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
{
// Windowの背景ブラシから現在の色を取得
var currentBrush = (SolidColorBrush)this.Background;
Color currentColor = currentBrush.Color;
// どのスライダーかに応じて戦略を選択
var strategy = ChangeRDBHelper.Red; // 例:赤スライダーの場合
Brush newBrush = strategy.Change(e, currentColor);
// 背景更新
this.Background = newBrush;
}
private void SheepSlider2_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
{
// Windowの背景ブラシから現在の色を取得
var currentBrush = (SolidColorBrush)this.Background;
Color currentColor = currentBrush.Color;
// どのスライダーかに応じて戦略を選択
var strategy = ChangeRDBHelper.Green; // 例:緑スライダーの場合
Brush newBrush = strategy.Change(e, currentColor);
// 背景更新
this.Background = newBrush;
}
private void SheepSlider3_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
{
// Windowの背景ブラシから現在の色を取得
var currentBrush = (SolidColorBrush)this.Background;
Color currentColor = currentBrush.Color;
// どのスライダーかに応じて戦略を選択
var strategy = ChangeRDBHelper.Blue; // 例:青スライダーの場合
Brush newBrush = strategy.Change(e, currentColor);
// 背景更新
this.Background = newBrush;
}
private void CompositeCaptureButton_Click(object sender, RoutedEventArgs e)
{
_CompoSiteController.CompositeCapture();
}
private void CompoSiteUndoButton_Click(object sender, RoutedEventArgs e)
{
_CompoSiteController.Undo();
}
private void CompoSiteRedoButton_Click(object sender, RoutedEventArgs e)
{
_CompoSiteController.Redo();
}
あとがき
なんでこんなに苦労させられたのか?
DataGridのスタックのインデックスがどうしても合わなくて、そこの辻褄合わせるのにすげー苦労したためです。無駄に凝り性なのが問題とも言えるが。
ぶっちゃけもう諦めようかとも思いました。あんま覚えてないけど、その部分の解決は自力でやったと思う。
IndexやLingバッファも良いのですが、あまり条件式を使うのも美しいとは思えないので、その点ではこの実装はまあまあ美しいと思っています。
$\color{lightblue}{\tiny \textsf{※1. 嘘です。}}$
面白かったらいいねボタン、なんかいい加減だなと思ったらBad Button、〇ンサムウェア(といい加減な内容)が気に要らない場合は通報をお願いします。



