diff --git a/src/rendering/QuestEntityControls.ts b/src/rendering/QuestEntityControls.ts index 0511128e..12e28386 100644 --- a/src/rendering/QuestEntityControls.ts +++ b/src/rendering/QuestEntityControls.ts @@ -276,7 +276,7 @@ export class QuestEntityControls { const entity_type = entity instanceof QuestNpc ? entity.type.name : (entity as QuestObject).type.name; - quest_editor_store.undo_stack.push_action( + quest_editor_store.undo.push_action( `Move ${entity_type}`, () => { entity.position = initial_position; diff --git a/src/stores/ApplicationStore.ts b/src/stores/ApplicationStore.ts index 5cf849fd..6a474e2f 100644 --- a/src/stores/ApplicationStore.ts +++ b/src/stores/ApplicationStore.ts @@ -1,6 +1,6 @@ import { observable } from "mobx"; import { Server } from "../domain"; -import { UndoStack } from "../undo"; +import { undo_manager } from "../undo"; class ApplicationStore { @observable current_server: Server = Server.Ephinea; @@ -23,10 +23,10 @@ class ApplicationStore { switch (binding) { case "Ctrl-Z": - UndoStack.current && UndoStack.current.undo(); + undo_manager.undo(); break; case "Ctrl-Shift-Z": - UndoStack.current && UndoStack.current.redo(); + undo_manager.redo(); break; default: { diff --git a/src/stores/QuestEditorStore.ts b/src/stores/QuestEditorStore.ts index 54397720..266c44ac 100644 --- a/src/stores/QuestEditorStore.ts +++ b/src/stores/QuestEditorStore.ts @@ -6,7 +6,7 @@ import { parse_quest, write_quest_qst } from "../data_formats/parsing/quest"; import { Vec3 } from "../data_formats/vector"; import { Area, Episode, Quest, QuestEntity, Section } from "../domain"; import { read_file } from "../read_file"; -import { UndoStack } from "../undo"; +import { UndoStack, SimpleUndo } from "../undo"; import { application_store } from "./ApplicationStore"; import { area_store } from "./AreaStore"; import { create_new_quest } from "./quest_creation"; @@ -16,8 +16,8 @@ const logger = Logger.get("stores/QuestEditorStore"); class QuestEditorStore { @observable debug = false; - readonly undo_stack = new UndoStack(); - readonly script_undo_stack = new UndoStack(); + readonly undo = new UndoStack(); + readonly script_undo = new SimpleUndo("Text edits", () => {}, () => {}); @observable current_quest_filename?: string; @observable current_quest?: Quest; @@ -121,7 +121,8 @@ class QuestEditorStore { @action private set_quest = flow(function* set_quest(this: QuestEditorStore, quest?: Quest) { if (quest !== this.current_quest) { - this.undo_stack.clear(); + this.undo.reset(); + this.script_undo.reset(); this.selected_entity = undefined; this.current_quest = quest; diff --git a/src/ui/quest_editor/QuestEditorComponent.tsx b/src/ui/quest_editor/QuestEditorComponent.tsx index 8452e3c8..fb0c4549 100644 --- a/src/ui/quest_editor/QuestEditorComponent.tsx +++ b/src/ui/quest_editor/QuestEditorComponent.tsx @@ -73,7 +73,7 @@ export class QuestEditorComponent extends Component { private layout?: GoldenLayout; componentDidMount(): void { - quest_editor_store.undo_stack.make_current(); + quest_editor_store.undo.make_current(); window.addEventListener("resize", this.resize); @@ -116,7 +116,7 @@ export class QuestEditorComponent extends Component { } componentWillUnmount(): void { - quest_editor_store.undo_stack.ensure_not_current(); + quest_editor_store.undo.ensure_not_current(); window.removeEventListener("resize", this.resize); @@ -147,10 +147,9 @@ export class QuestEditorComponent extends Component { scrip_editor_element.compareDocumentPosition(e.target) & Node.DOCUMENT_POSITION_CONTAINED_BY ) { - // quest_editor_store.script_undo_stack.make_current(); - quest_editor_store.undo_stack.ensure_not_current(); + quest_editor_store.script_undo.make_current(); } else { - quest_editor_store.undo_stack.make_current(); + quest_editor_store.undo.make_current(); } }; diff --git a/src/ui/quest_editor/ScriptEditorComponent.tsx b/src/ui/quest_editor/ScriptEditorComponent.tsx index e2e8e88a..ee856fdd 100644 --- a/src/ui/quest_editor/ScriptEditorComponent.tsx +++ b/src/ui/quest_editor/ScriptEditorComponent.tsx @@ -6,6 +6,7 @@ import { OPCODES } from "../../data_formats/parsing/quest/bin"; import { Assembler } from "../../scripting/Assembler"; import { quest_editor_store } from "../../stores/QuestEditorStore"; import "./ScriptEditorComponent.less"; +import { Action } from "../../undo"; const ASM_SYNTAX: languages.IMonarchLanguage = { defaultToken: "invalid", @@ -182,7 +183,53 @@ class MonacoComponent extends Component { const assembly = this.assembler.disassemble(quest.instructions, quest.labels); const model = editor.createModel(assembly.join("\n"), "psoasm"); + quest_editor_store.script_undo.action = new Action( + "Text edits", + () => { + if (this.editor) { + this.editor.trigger("undo stack", "undo", undefined); + } + }, + () => { + if (this.editor) { + this.editor.trigger("redo stack", "redo", undefined); + } + } + ); + + let initial_version = model.getAlternativeVersionId(); + let current_version = initial_version; + let last_version = initial_version; + const disposable = model.onDidChangeContent(e => { + const version = model.getAlternativeVersionId(); + + if (version < current_version) { + // Undoing. + quest_editor_store.script_undo.can_redo = true; + + if (version === initial_version) { + quest_editor_store.script_undo.can_undo = false; + } + } else { + // Redoing. + if (version <= last_version) { + if (version === last_version) { + quest_editor_store.script_undo.can_redo = false; + } + } else { + quest_editor_store.script_undo.can_redo = false; + + if (current_version > last_version) { + last_version = current_version; + } + } + + quest_editor_store.script_undo.can_undo = true; + } + + current_version = version; + if (!this.assembler) return; this.assembler.update_assembly(e.changes); }); diff --git a/src/ui/quest_editor/Toolbar.tsx b/src/ui/quest_editor/Toolbar.tsx index 845df75d..c05e8490 100644 --- a/src/ui/quest_editor/Toolbar.tsx +++ b/src/ui/quest_editor/Toolbar.tsx @@ -5,13 +5,12 @@ import { observer } from "mobx-react"; import React, { ChangeEvent, Component, ReactNode } from "react"; import { Episode } from "../../domain"; import { quest_editor_store } from "../../stores/QuestEditorStore"; +import { undo_manager } from "../../undo"; import "./Toolbar.less"; -import { UndoStack } from "../../undo"; @observer export class Toolbar extends Component { render(): ReactNode { - const undo = UndoStack.current; const quest = quest_editor_store.current_quest; const areas = quest ? Array.from(quest.area_variants).map(a => a.area) : []; const area = quest_editor_store.current_area; @@ -49,10 +48,11 @@ export class Toolbar extends Component { icon="undo" onClick={this.undo} title={ - "Undo" + - (undo && undo.first_undo ? ` "${undo.first_undo.description}"` : "") + undo_manager.first_undo + ? `Undo "${undo_manager.first_undo.description}"` + : "Nothing to undo" } - disabled={!(undo && undo.can_undo)} + disabled={!undo_manager.can_undo} > Undo @@ -60,10 +60,11 @@ export class Toolbar extends Component { icon="redo" onClick={this.redo} title={ - "Redo" + - (undo && undo.first_redo ? ` "${undo.first_redo.description}"` : "") + undo_manager.first_redo + ? `Redo "${undo_manager.first_redo.description}"` + : "Nothing to redo" } - disabled={!(undo && undo.can_redo)} + disabled={!undo_manager.can_redo} > Redo @@ -95,11 +96,11 @@ export class Toolbar extends Component { } private undo(): void { - UndoStack.current && UndoStack.current.undo(); + undo_manager.undo(); } private redo(): void { - UndoStack.current && UndoStack.current.redo(); + undo_manager.redo(); } } diff --git a/src/undo.ts b/src/undo.ts index 5fb4171e..993d5e75 100644 --- a/src/undo.ts +++ b/src/undo.ts @@ -8,9 +8,147 @@ export class Action { ) {} } -export class UndoStack { - @observable static current?: UndoStack; +class UndoManager { + @observable current?: Undo; + @computed + get can_undo(): boolean { + return this.current ? this.current.can_undo : false; + } + + @computed + get can_redo(): boolean { + return this.current ? this.current.can_redo : false; + } + + @computed + get first_undo(): Action | undefined { + return this.current && this.current.first_undo; + } + + @computed + get first_redo(): Action | undefined { + return this.current && this.current.first_redo; + } + + undo(): boolean { + return this.current ? this.current.undo() : false; + } + + redo(): boolean { + return this.current ? this.current.redo() : false; + } +} + +export const undo_manager = new UndoManager(); + +interface Undo { + make_current(): void; + + ensure_not_current(): void; + + readonly can_undo: boolean; + + readonly can_redo: boolean; + + /** + * The first action that will be undone when calling undo(). + */ + readonly first_undo: Action | undefined; + + /** + * The first action that will be redone when calling redo(). + */ + readonly first_redo: Action | undefined; + + undo(): boolean; + + redo(): boolean; + + reset(): void; +} + +/** + * Simply contains a single action. `can_undo` and `can_redo` must be managed manually. + */ +export class SimpleUndo implements Undo { + @observable.ref action: Action; + + constructor(description: string, undo: () => void, redo: () => void) { + this.action = new Action(description, undo, redo); + } + + @action + make_current(): void { + undo_manager.current = this; + } + + @action + ensure_not_current(): void { + if (undo_manager.current === this) { + undo_manager.current = undefined; + } + } + + @observable _can_undo = false; + + get can_undo(): boolean { + return this._can_undo; + } + + set can_undo(can_undo: boolean) { + this._can_undo = can_undo; + } + + @observable _can_redo = false; + + get can_redo(): boolean { + return this._can_redo; + } + + set can_redo(can_redo: boolean) { + this._can_redo = can_redo; + } + + @computed get first_undo(): Action | undefined { + return this.can_undo ? this.action : undefined; + } + + @computed get first_redo(): Action | undefined { + return this.can_redo ? this.action : undefined; + } + + @action + undo(): boolean { + if (this.can_undo) { + this.action.undo(); + return true; + } else { + return false; + } + } + + @action + redo(): boolean { + if (this.can_redo) { + this.action.redo(); + return true; + } else { + return false; + } + } + + @action + reset(): void { + this._can_undo = false; + this._can_redo = false; + } +} + +/** + * Full-fledged linear undo/redo implementation. + */ +export class UndoStack implements Undo { @observable private readonly stack: IObservableArray = observable.array([], { deep: false, }); @@ -19,13 +157,15 @@ export class UndoStack { */ @observable private index = 0; + @action make_current(): void { - UndoStack.current = this; + undo_manager.current = this; } + @action ensure_not_current(): void { - if (UndoStack.current === this) { - UndoStack.current = undefined; + if (undo_manager.current === this) { + undo_manager.current = undefined; } } @@ -62,6 +202,14 @@ export class UndoStack { this.index++; } + /** + * Pop an action off the stack without undoing. + */ + @action + pop(): Action | undefined { + return this.stack.splice(--this.index, 1)[0]; + } + @action undo(): boolean { if (this.can_undo) { @@ -83,7 +231,7 @@ export class UndoStack { } @action - clear(): void { + reset(): void { this.stack.clear(); this.index = 0; }