Refactor undo/redo to be a controller instead (#27279)

This commit is contained in:
Jan-Philipp Benecke
2025-10-06 16:04:42 +02:00
committed by GitHub
parent e66724ca9e
commit 04bc5fba63
6 changed files with 203 additions and 115 deletions

View File

@@ -0,0 +1,141 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
const UNDO_REDO_STACK_LIMIT = 75;
/**
* Configuration options for the UndoRedoController.
*
* @template ConfigType The type of configuration to manage.
*/
export interface UndoRedoControllerConfig<ConfigType> {
stackLimit?: number;
currentConfig: () => ConfigType;
apply: (config: ConfigType) => void;
}
/**
* A controller to manage undo and redo operations for a given configuration type.
*
* @template ConfigType The type of configuration to manage.
*/
export class UndoRedoController<ConfigType> implements ReactiveController {
private _host: ReactiveControllerHost;
private _undoStack: ConfigType[] = [];
private _redoStack: ConfigType[] = [];
private readonly _stackLimit: number = UNDO_REDO_STACK_LIMIT;
private readonly _apply: (config: ConfigType) => void = () => {
throw new Error("No apply function provided");
};
private readonly _currentConfig: () => ConfigType = () => {
throw new Error("No currentConfig function provided");
};
constructor(
host: ReactiveControllerHost,
options: UndoRedoControllerConfig<ConfigType>
) {
if (options.stackLimit !== undefined) {
this._stackLimit = options.stackLimit;
}
this._apply = options.apply;
this._currentConfig = options.currentConfig;
this._host = host;
host.addController(this);
}
hostConnected() {
window.addEventListener("undo-change", this._onUndoChange);
}
hostDisconnected() {
window.removeEventListener("undo-change", this._onUndoChange);
}
private _onUndoChange = (ev: Event) => {
ev.stopPropagation();
this.undo();
this._host.requestUpdate();
};
/**
* Indicates whether there are actions available to undo.
*
* @returns `true` if there are actions to undo, `false` otherwise.
*/
public get canUndo(): boolean {
return this._undoStack.length > 0;
}
/**
* Indicates whether there are actions available to redo.
*
* @returns `true` if there are actions to redo, `false` otherwise.
*/
public get canRedo(): boolean {
return this._redoStack.length > 0;
}
/**
* Commits the current configuration to the undo stack and clears the redo stack.
*
* @param config The current configuration to commit.
*/
public commit(config: ConfigType) {
if (this._undoStack.length >= this._stackLimit) {
this._undoStack.shift();
}
this._undoStack.push({ ...config });
this._redoStack = [];
}
/**
* Undoes the last action and applies the previous configuration
* while saving the current configuration to the redo stack.
*/
public undo() {
if (this._undoStack.length === 0) {
return;
}
this._redoStack.push({ ...this._currentConfig() });
const config = this._undoStack.pop()!;
this._apply(config);
this._host.requestUpdate();
}
/**
* Redoes the last undone action and reapplies the configuration
* while saving the current configuration to the undo stack.
*/
public redo() {
if (this._redoStack.length === 0) {
return;
}
this._undoStack.push({ ...this._currentConfig() });
const config = this._redoStack.pop()!;
this._apply(config);
this._host.requestUpdate();
}
/**
* Resets the undo and redo stacks, clearing all history.
*/
public reset() {
this._undoStack = [];
this._redoStack = [];
}
}
declare global {
interface HASSDomEvents {
"undo-change": undefined;
}
}

View File

@@ -1,60 +0,0 @@
import type { LitElement } from "lit";
import type { Constructor } from "../types";
export const UndoRedoMixin = <T extends Constructor<LitElement>, ConfigType>(
superClass: T
) => {
class UndoRedoClass extends superClass {
private _undoStack: ConfigType[] = [];
private _redoStack: ConfigType[] = [];
protected _undoStackLimit = 75;
protected pushToUndo(config: ConfigType) {
if (this._undoStack.length >= this._undoStackLimit) {
this._undoStack.shift();
}
this._undoStack.push({ ...config });
this._redoStack = [];
}
public undo() {
const currentConfig = this.currentConfig;
if (this._undoStack.length === 0 || !currentConfig) {
return;
}
this._redoStack.push({ ...currentConfig });
const config = this._undoStack.pop()!;
this.applyUndoRedo(config);
}
public redo() {
const currentConfig = this.currentConfig;
if (this._redoStack.length === 0 || !currentConfig) {
return;
}
this._undoStack.push({ ...currentConfig });
const config = this._redoStack.pop()!;
this.applyUndoRedo(config);
}
public get canUndo(): boolean {
return this._undoStack.length > 0;
}
public get canRedo(): boolean {
return this._redoStack.length > 0;
}
protected get currentConfig(): ConfigType | undefined {
return undefined;
}
protected applyUndoRedo(_: ConfigType) {
throw new Error("applyUndoRedo not implemented");
}
}
return UndoRedoClass;
};

View File

@@ -74,7 +74,7 @@ import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info
import "../../../layouts/hass-subpage"; import "../../../layouts/hass-subpage";
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin"; import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin"; import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin";
import { UndoRedoMixin } from "../../../mixins/undo-redo-mixin"; import { UndoRedoController } from "../../../common/controllers/undo-redo-controller";
import { haStyle } from "../../../resources/styles"; import { haStyle } from "../../../resources/styles";
import type { Entries, HomeAssistant, Route } from "../../../types"; import type { Entries, HomeAssistant, Route } from "../../../types";
import { isMac } from "../../../util/is_mac"; import { isMac } from "../../../util/is_mac";
@@ -111,12 +111,9 @@ declare global {
} }
} }
const baseEditorMixins = PreventUnsavedMixin(KeyboardShortcutMixin(LitElement)); export class HaAutomationEditor extends PreventUnsavedMixin(
KeyboardShortcutMixin(LitElement)
export class HaAutomationEditor extends UndoRedoMixin< ) {
typeof baseEditorMixins,
AutomationConfig
>(baseEditorMixins) {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public automationId: string | null = null; @property({ attribute: false }) public automationId: string | null = null;
@@ -183,6 +180,11 @@ export class HaAutomationEditor extends UndoRedoMixin<
value: PromiseLike<EntityRegistryEntry> | EntityRegistryEntry value: PromiseLike<EntityRegistryEntry> | EntityRegistryEntry
) => void; ) => void;
private _undoRedoController = new UndoRedoController<AutomationConfig>(this, {
apply: (config) => this._applyUndoRedo(config),
currentConfig: () => this._config!,
});
protected willUpdate(changedProps) { protected willUpdate(changedProps) {
super.willUpdate(changedProps); super.willUpdate(changedProps);
@@ -235,8 +237,8 @@ export class HaAutomationEditor extends UndoRedoMixin<
slot="toolbar-icon" slot="toolbar-icon"
.label=${this.hass.localize("ui.common.undo")} .label=${this.hass.localize("ui.common.undo")}
.path=${mdiUndo} .path=${mdiUndo}
@click=${this.undo} @click=${this._undo}
.disabled=${!this.canUndo} .disabled=${!this._undoRedoController.canUndo}
id="button-undo" id="button-undo"
> >
</ha-icon-button> </ha-icon-button>
@@ -253,8 +255,8 @@ export class HaAutomationEditor extends UndoRedoMixin<
slot="toolbar-icon" slot="toolbar-icon"
.label=${this.hass.localize("ui.common.redo")} .label=${this.hass.localize("ui.common.redo")}
.path=${mdiRedo} .path=${mdiRedo}
@click=${this.redo} @click=${this._redo}
.disabled=${!this.canRedo} .disabled=${!this._undoRedoController.canRedo}
id="button-redo" id="button-redo"
> >
</ha-icon-button> </ha-icon-button>
@@ -298,16 +300,16 @@ export class HaAutomationEditor extends UndoRedoMixin<
${this._mode === "gui" && this.narrow ${this._mode === "gui" && this.narrow
? html`<ha-list-item ? html`<ha-list-item
graphic="icon" graphic="icon"
@click=${this.undo} @click=${this._undo}
.disabled=${!this.canUndo} .disabled=${!this._undoRedoController.canUndo}
> >
${this.hass.localize("ui.common.undo")} ${this.hass.localize("ui.common.undo")}
<ha-svg-icon slot="graphic" .path=${mdiUndo}></ha-svg-icon> <ha-svg-icon slot="graphic" .path=${mdiUndo}></ha-svg-icon>
</ha-list-item> </ha-list-item>
<ha-list-item <ha-list-item
graphic="icon" graphic="icon"
@click=${this.redo} @click=${this._redo}
.disabled=${!this.canRedo} .disabled=${!this._undoRedoController.canRedo}
> >
${this.hass.localize("ui.common.redo")} ${this.hass.localize("ui.common.redo")}
<ha-svg-icon slot="graphic" .path=${mdiRedo}></ha-svg-icon> <ha-svg-icon slot="graphic" .path=${mdiRedo}></ha-svg-icon>
@@ -518,7 +520,6 @@ export class HaAutomationEditor extends UndoRedoMixin<
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
@save-automation=${this._handleSaveAutomation} @save-automation=${this._handleSaveAutomation}
@editor-save=${this._handleSaveAutomation} @editor-save=${this._handleSaveAutomation}
@undo-paste=${this.undo}
> >
<div class="alert-wrapper" slot="alerts"> <div class="alert-wrapper" slot="alerts">
${this._errors || stateObj?.state === UNAVAILABLE ${this._errors || stateObj?.state === UNAVAILABLE
@@ -791,7 +792,7 @@ export class HaAutomationEditor extends UndoRedoMixin<
ev.stopPropagation(); ev.stopPropagation();
if (this._config) { if (this._config) {
this.pushToUndo(this._config); this._undoRedoController.commit(this._config);
} }
this._config = ev.detail.value; this._config = ev.detail.value;
@@ -1202,9 +1203,9 @@ export class HaAutomationEditor extends UndoRedoMixin<
x: () => this._cutSelectedRow(), x: () => this._cutSelectedRow(),
Delete: () => this._deleteSelectedRow(), Delete: () => this._deleteSelectedRow(),
Backspace: () => this._deleteSelectedRow(), Backspace: () => this._deleteSelectedRow(),
z: () => this.undo(), z: () => this._undo(),
Z: () => this.redo(), Z: () => this._redo(),
y: () => this.redo(), y: () => this._redo(),
}; };
} }
@@ -1238,16 +1239,20 @@ export class HaAutomationEditor extends UndoRedoMixin<
this._manualEditor?.deleteSelectedRow(); this._manualEditor?.deleteSelectedRow();
} }
protected get currentConfig() { private _applyUndoRedo(config: AutomationConfig) {
return this._config;
}
protected applyUndoRedo(config: AutomationConfig) {
this._manualEditor?.triggerCloseSidebar(); this._manualEditor?.triggerCloseSidebar();
this._config = config; this._config = config;
this._dirty = true; this._dirty = true;
} }
private _undo() {
this._undoRedoController.undo();
}
private _redo() {
this._undoRedoController.redo();
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyle, haStyle,

View File

@@ -616,7 +616,7 @@ export class HaManualAutomationEditor extends LitElement {
action: { action: {
text: this.hass.localize("ui.common.undo"), text: this.hass.localize("ui.common.undo"),
action: () => { action: () => {
fireEvent(this, "undo-paste"); fireEvent(this, "undo-change");
this._pastedConfig = undefined; this._pastedConfig = undefined;
}, },
@@ -742,6 +742,5 @@ declare global {
"open-sidebar": SidebarConfig; "open-sidebar": SidebarConfig;
"request-close-sidebar": undefined; "request-close-sidebar": undefined;
"close-sidebar": undefined; "close-sidebar": undefined;
"undo-paste": undefined;
} }
} }

View File

@@ -65,7 +65,7 @@ import "../../../layouts/hass-subpage";
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin"; import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin"; import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { UndoRedoMixin } from "../../../mixins/undo-redo-mixin"; import { UndoRedoController } from "../../../common/controllers/undo-redo-controller";
import { haStyle } from "../../../resources/styles"; import { haStyle } from "../../../resources/styles";
import type { Entries, HomeAssistant, Route } from "../../../types"; import type { Entries, HomeAssistant, Route } from "../../../types";
import { isMac } from "../../../util/is_mac"; import { isMac } from "../../../util/is_mac";
@@ -78,14 +78,9 @@ import "./blueprint-script-editor";
import "./manual-script-editor"; import "./manual-script-editor";
import type { HaManualScriptEditor } from "./manual-script-editor"; import type { HaManualScriptEditor } from "./manual-script-editor";
const baseEditorMixins = SubscribeMixin( export class HaScriptEditor extends SubscribeMixin(
PreventUnsavedMixin(KeyboardShortcutMixin(LitElement)) PreventUnsavedMixin(KeyboardShortcutMixin(LitElement))
); ) {
export class HaScriptEditor extends UndoRedoMixin<
typeof baseEditorMixins,
ScriptConfig
>(baseEditorMixins) {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public scriptId: string | null = null; @property({ attribute: false }) public scriptId: string | null = null;
@@ -141,6 +136,11 @@ export class HaScriptEditor extends UndoRedoMixin<
value: PromiseLike<EntityRegistryEntry> | EntityRegistryEntry value: PromiseLike<EntityRegistryEntry> | EntityRegistryEntry
) => void; ) => void;
private _undoRedoController = new UndoRedoController<ScriptConfig>(this, {
apply: (config) => this._applyUndoRedo(config),
currentConfig: () => this._config!,
});
protected willUpdate(changedProps) { protected willUpdate(changedProps) {
super.willUpdate(changedProps); super.willUpdate(changedProps);
@@ -188,8 +188,8 @@ export class HaScriptEditor extends UndoRedoMixin<
slot="toolbar-icon" slot="toolbar-icon"
.label=${this.hass.localize("ui.common.undo")} .label=${this.hass.localize("ui.common.undo")}
.path=${mdiUndo} .path=${mdiUndo}
@click=${this.undo} @click=${this._undo}
.disabled=${!this.canUndo} .disabled=${!this._undoRedoController.canUndo}
id="button-undo" id="button-undo"
> >
</ha-icon-button> </ha-icon-button>
@@ -205,8 +205,8 @@ export class HaScriptEditor extends UndoRedoMixin<
slot="toolbar-icon" slot="toolbar-icon"
.label=${this.hass.localize("ui.common.redo")} .label=${this.hass.localize("ui.common.redo")}
.path=${mdiRedo} .path=${mdiRedo}
@click=${this.redo} @click=${this._redo}
.disabled=${!this.canRedo} .disabled=${!this._undoRedoController.canRedo}
id="button-redo" id="button-redo"
> >
</ha-icon-button> </ha-icon-button>
@@ -249,16 +249,16 @@ export class HaScriptEditor extends UndoRedoMixin<
${this._mode === "gui" && this.narrow ${this._mode === "gui" && this.narrow
? html`<ha-list-item ? html`<ha-list-item
graphic="icon" graphic="icon"
@click=${this.undo} @click=${this._undo}
.disabled=${!this.canUndo} .disabled=${!this._undoRedoController.canUndo}
> >
${this.hass.localize("ui.common.undo")} ${this.hass.localize("ui.common.undo")}
<ha-svg-icon slot="graphic" .path=${mdiUndo}></ha-svg-icon> <ha-svg-icon slot="graphic" .path=${mdiUndo}></ha-svg-icon>
</ha-list-item> </ha-list-item>
<ha-list-item <ha-list-item
graphic="icon" graphic="icon"
@click=${this.redo} @click=${this._redo}
.disabled=${!this.canRedo} .disabled=${!this._undoRedoController.canRedo}
> >
${this.hass.localize("ui.common.redo")} ${this.hass.localize("ui.common.redo")}
<ha-svg-icon slot="graphic" .path=${mdiRedo}></ha-svg-icon> <ha-svg-icon slot="graphic" .path=${mdiRedo}></ha-svg-icon>
@@ -463,7 +463,6 @@ export class HaScriptEditor extends UndoRedoMixin<
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
@editor-save=${this._handleSaveScript} @editor-save=${this._handleSaveScript}
@save-script=${this._handleSaveScript} @save-script=${this._handleSaveScript}
@undo-paste=${this.undo}
> >
<div class="alert-wrapper" slot="alerts"> <div class="alert-wrapper" slot="alerts">
${this._errors || stateObj?.state === UNAVAILABLE ${this._errors || stateObj?.state === UNAVAILABLE
@@ -679,7 +678,7 @@ export class HaScriptEditor extends UndoRedoMixin<
private _valueChanged(ev) { private _valueChanged(ev) {
if (this._config) { if (this._config) {
this.pushToUndo(this._config); this._undoRedoController.commit(this._config);
} }
this._config = ev.detail.value; this._config = ev.detail.value;
@@ -776,7 +775,7 @@ export class HaScriptEditor extends UndoRedoMixin<
} }
if (this._config) { if (this._config) {
this.pushToUndo(this._config); this._undoRedoController.commit(this._config);
} }
this._manualEditor?.addFields(); this._manualEditor?.addFields();
@@ -1110,9 +1109,9 @@ export class HaScriptEditor extends UndoRedoMixin<
x: () => this._cutSelectedRow(), x: () => this._cutSelectedRow(),
Delete: () => this._deleteSelectedRow(), Delete: () => this._deleteSelectedRow(),
Backspace: () => this._deleteSelectedRow(), Backspace: () => this._deleteSelectedRow(),
z: () => this.undo(), z: () => this._undo(),
Z: () => this.redo(), Z: () => this._redo(),
y: () => this.redo(), y: () => this._redo(),
}; };
} }
@@ -1146,16 +1145,20 @@ export class HaScriptEditor extends UndoRedoMixin<
this._manualEditor?.deleteSelectedRow(); this._manualEditor?.deleteSelectedRow();
} }
protected get currentConfig() { private _applyUndoRedo(config: ScriptConfig) {
return this._config;
}
protected applyUndoRedo(config: ScriptConfig) {
this._manualEditor?.triggerCloseSidebar(); this._manualEditor?.triggerCloseSidebar();
this._config = config; this._config = config;
this._dirty = true; this._dirty = true;
} }
private _undo() {
this._undoRedoController.undo();
}
private _redo() {
this._undoRedoController.redo();
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyle, haStyle,

View File

@@ -491,7 +491,7 @@ export class HaManualScriptEditor extends LitElement {
action: { action: {
text: this.hass.localize("ui.common.undo"), text: this.hass.localize("ui.common.undo"),
action: () => { action: () => {
fireEvent(this, "undo-paste"); fireEvent(this, "undo-change");
this._pastedConfig = undefined; this._pastedConfig = undefined;
}, },