Add undo/redo functionality to text automator

This commit is contained in:
SpectralFlame 2023-04-18 22:58:37 -05:00 committed by cyip92
parent 5f48af2c87
commit 13673ae0c7
8 changed files with 104 additions and 7 deletions

View File

@ -29,6 +29,7 @@ export default {
// AutomatorBackend.deleteScript will create an empty script if necessary
player.reality.automator.state.editorScript = scriptList[0].id;
}
AutomatorData.clearUndoData();
EventHub.dispatch(GAME_EVENT.AUTOMATOR_SAVE_CHANGED);
},
},

View File

@ -169,6 +169,7 @@ export default {
if (storedScripts[this.currentScriptID] === undefined) {
this.currentScriptID = Number(Object.keys(storedScripts)[0]);
player.reality.automator.state.editorScript = this.currentScriptID;
AutomatorData.clearUndoData();
}
// This gets checked whenever the editor pane is foricibly changed to a different script, which may or may not

View File

@ -59,6 +59,7 @@ export default {
if (storedScripts[this.currentScriptID] === undefined) {
this.currentScriptID = Number(Object.keys(storedScripts)[0]);
player.reality.automator.state.editorScript = this.currentScriptID;
AutomatorData.clearUndoData();
}
// This may happen if the player has errored textmato scripts and switches to them while in blockmato mode
if (BlockAutomator.hasUnparsableCommands(this.currentScript) &&

View File

@ -53,6 +53,7 @@ export default {
if (storedScripts[this.currentScriptID] === undefined) {
this.currentScriptID = Object.keys(storedScripts)[0];
player.reality.automator.state.editorScript = this.currentScriptID;
AutomatorData.clearUndoData();
}
if (BlockAutomator.hasUnparsableCommands(this.currentScript) &&
player.reality.automator.type === AUTOMATOR_TYPE.BLOCK) {
@ -81,6 +82,7 @@ export default {
} else {
AutomatorBackend.changeModes(this.currentScriptID);
}
AutomatorData.clearUndoData();
}
}
};

View File

@ -67,6 +67,7 @@ export default {
}
if (this.isBlock) this.$nextTick(() => BlockAutomator.fromText(this.currentScript));
this.$parent.openRequest = false;
AutomatorData.clearUndoData();
},
dropdownLabel(script) {
const labels = [];

View File

@ -127,16 +127,33 @@ export const AutomatorTextUI = {
},
setUpEditor() {
this.editor = CodeMirror.fromTextArea(this.textArea, this.mode);
// CodeMirror has a built-in undo/redo functionality bound to ctrl-z/ctrl-y which doesn't have an
// easily-configured history buffer; we need to specifically cancel this event since we have our own undo
this.editor.on("beforeChange", (_, event) => {
if (event.origin === "undo") event.cancel();
});
this.editor.on("keydown", (editor, event) => {
if (editor.state.completionActive) return;
const key = event.key;
if (event.ctrlKey && ["z", "y"].includes(key)) {
if (key === "z") AutomatorData.undoScriptEdit();
if (key === "y") AutomatorData.redoScriptEdit();
return;
}
// This check is related to the drop-down command suggestion menu, but must come after the undo/redo check
// as it often evaluates to innocuous false positives which eat the keybinds
if (editor.state.completionActive) return;
if (event.ctrlKey || event.altKey || event.metaKey || !/^[a-zA-Z0-9 \t]$/u.test(key)) return;
CodeMirror.commands.autocomplete(editor, null, { completeSingle: false });
});
this.editor.on("change", editor => {
this.editor.on("change", (editor, event) => {
const scriptID = ui.view.tabs.reality.automator.editorScriptID;
const scriptText = editor.getDoc().getValue();
AutomatorBackend.saveScript(scriptID, scriptText);
// Undo/redo directly changes the editor contents, which also causes this event to be fired; we have a few
// things which we specifically only want to do on manual typing changes
if (event.origin !== "setValue") {
AutomatorBackend.saveScript(scriptID, scriptText);
AutomatorData.redoBuffer = [];
}
AutomatorData.recalculateErrors();
const errors = AutomatorData.currentErrors().length;

View File

@ -180,6 +180,9 @@ export const AutomatorData = {
cachedErrors: 0,
// This is to hold finished script templates as text in order to make the custom blocks for blockmato
blockTemplates: [],
undoBuffer: [],
redoBuffer: [],
charsSinceLastUndoState: 0,
MAX_ALLOWED_SCRIPT_CHARACTERS: 10000,
MAX_ALLOWED_TOTAL_CHARACTERS: 60000,
@ -189,6 +192,8 @@ export const AutomatorData = {
// Note that a study string with ALL studies in unshortened form without duplicated studies is ~230 characters
MAX_ALLOWED_CONSTANT_VALUE_LENGTH: 250,
MAX_ALLOWED_CONSTANT_COUNT: 30,
MIN_CHARS_BETWEEN_UNDOS: 10,
MAX_UNDO_ENTRIES: 30,
scriptIndex() {
return player.reality.automator.state.editorScript;
@ -204,6 +209,7 @@ export const AutomatorData = {
const newScript = AutomatorScript.create(name, content);
GameUI.notify.automator(`Imported Script "${name}"`);
player.reality.automator.state.editorScript = newScript.id;
AutomatorData.clearUndoData();
EventHub.dispatch(GAME_EVENT.AUTOMATOR_SAVE_CHANGED);
},
recalculateErrors() {
@ -255,6 +261,51 @@ export const AutomatorData = {
return this.singleScriptCharacters() <= this.MAX_ALLOWED_SCRIPT_CHARACTERS &&
this.totalScriptCharacters() <= this.MAX_ALLOWED_TOTAL_CHARACTERS;
},
// This must be called every time the current script or editor mode are changed
clearUndoData() {
this.undoBuffer = [];
this.redoBuffer = [];
this.charsSinceLastUndoState = 0;
},
// We only save an undo state every so often based on the number of characters that have been modified
// since the last state. This gets passed in as a parameter and gets called every time any typing is done,
// but only actually does something when that threshold is reached.
pushUndoData(data, newChars) {
this.charsSinceLastUndoState += newChars;
if (this.charsSinceLastUndoState <= this.MIN_CHARS_BETWEEN_UNDOS) return;
if (this.undoBuffer[this.undoBuffer.length - 1] !== data) this.undoBuffer.push(data);
if (this.undoBuffer.length > this.MAX_UNDO_ENTRIES) this.undoBuffer.shift();
this.charsSinceLastUndoState = 0;
},
pushRedoData(data) {
if (this.redoBuffer[this.redoBuffer.length - 1] !== data) this.redoBuffer.push(data);
},
// These following two methods pop the top entry off of the undo/redo stack and then push it
// onto the *other* stack before modifying all the relevant UI elements and player props. These
// could in principle be combined into one function to reduce boilerplace, but keeping them
// separate is probably more readable externally
undoScriptEdit() {
if (this.undoBuffer.length === 0) return;
const undoContent = this.undoBuffer.pop();
this.pushRedoData(this.currentScriptText());
player.reality.automator.scripts[this.scriptIndex()].content = undoContent;
if (AutomatorTextUI.editor) AutomatorTextUI.editor.setValue(undoContent);
AutomatorBackend.saveScript(this.scriptIndex(), undoContent);
},
redoScriptEdit() {
if (this.redoBuffer.length === 0) return;
const redoContent = this.redoBuffer.pop();
// We call this with a value which is always higher than said threshold, forcing the current text to be pushed
this.pushUndoData(this.currentScriptText(), 2 * this.MIN_CHARS_BETWEEN_UNDOS);
player.reality.automator.scripts[this.scriptIndex()].content = redoContent;
if (AutomatorTextUI.editor) AutomatorTextUI.editor.setValue(redoContent);
AutomatorBackend.saveScript(this.scriptIndex(), redoContent);
}
};
export const LineEnum = { Active: "active", Event: "event", Error: "error" };
@ -806,9 +857,20 @@ export const AutomatorBackend = {
}
},
// Note: This gets run every time any edit or mode conversion is done
saveScript(id, data) {
if (!this.findScript(id)) return;
this.findScript(id).save(data);
const script = this.findScript(id);
if (!script) return;
// Add the old data to the undo buffer; there are internal checks which prevent it from saving too often.
// For performance, the contents of the script aren't actually checked (this would be an unavoidable O(n) cost).
// Instead we naively assume length changes are pure insertions and deletions, which does mean we're ignoring
// a few edge cases when changes are really substitutions that massively change the content
const oldData = script.persistent.content;
const lenChange = Math.abs(oldData.length - data.length);
AutomatorData.pushUndoData(oldData, lenChange);
script.save(data);
if (id === this.state.topLevelScript) this.stop();
},

View File

@ -124,13 +124,25 @@ export const shortcuts = [
keys: ["u"],
type: "bindHotkey",
function: () => keyboardAutomatorToggle(),
visible: () => PlayerProgress.realityUnlocked()
visible: () => Player.automatorUnlocked
}, {
name: "Restart Automator",
keys: ["shift", "u"],
type: "bindHotkey",
function: () => keyboardAutomatorRestart(),
visible: () => PlayerProgress.realityUnlocked()
visible: () => Player.automatorUnlocked
}, {
name: "Undo Edit (Automator)",
keys: ["mod", "z"],
type: "bind",
function: () => AutomatorData.undoScriptEdit(),
visible: () => Player.automatorUnlocked
}, {
name: "Redo Edit (Automator)",
keys: ["mod", "y"],
type: "bind",
function: () => AutomatorData.redoScriptEdit(),
visible: () => Player.automatorUnlocked
}, {
name: "Toggle Black Hole",
keys: ["b"],