openOutpaint/js/lib/commands.js
Victor Seiji Hariki c4ef6ccce4 Workspaces now fully functional (using indexedDB)
Signed-off-by: Victor Seiji Hariki <victorseijih@gmail.com>
2023-01-27 01:40:27 -03:00

531 lines
13 KiB
JavaScript

/**
* Command pattern to allow for editing history
*/
const _commands_events = new Observer();
/** Global Commands Object */
const commands = makeReadOnly(
{
/** Current History Index Reader */
get current() {
return this._current;
},
/** Current History Index (private) */
_current: -1,
/**
* Command History (private)
*
* @type {CommandEntry[]}
*/
_history: [],
/** The types of commands we can run (private) */
_types: {},
/**
* Undoes the last commands in the history
*
* @param {number} [n] Number of actions to undo
*/
async undo(n = 1) {
for (var i = 0; i < n && this.current > -1; i++) {
try {
await this._history[this._current--].undo();
} catch (e) {
console.warn("[commands] Failed to undo command");
console.warn(e);
this._current++;
break;
}
}
},
/**
* Redoes the next commands in the history
*
* @param {number} [n] Number of actions to redo
*/
async redo(n = 1) {
for (var i = 0; i < n && this.current + 1 < this._history.length; i++) {
try {
await this._history[++this._current].redo();
} catch (e) {
console.warn("[commands] Failed to redo command");
console.warn(e);
this._current--;
break;
}
}
},
/**
* Clears the history
*/
async clear() {
await this.undo(this._history.length);
this._history.splice(0, this._history.length);
_commands_events.emit({
action: "clear",
state: {},
current: commands._current,
});
},
/**
* Imports an exported command and runs it
*
* @param {{name: string, title: string, data: any}} exported Exported command
*/
async import(exported) {
await this.runCommand(
exported.command,
exported.title,
{},
{importData: exported.data}
);
},
/**
* Exports all commands in the history
*/
async export() {
return Promise.all(
this._history.map(async (command) => command.export())
);
},
/**
* Creates a basic command, that can be done and undone
*
* They must contain a 'run' method that performs the action for the first time,
* a 'undo' method that undoes that action and a 'redo' method that does the
* action again, but without requiring parameters. 'redo' is by default the
* same as 'run'.
*
* The 'run' and 'redo' functions will receive a 'options' parameter which will be
* forwarded directly to the operation, and a 'state' parameter that
* can be used to store state for undoing things.
*
* The 'state' object will be passed to the 'undo' function as well.
*
* @param {string} name Command identifier (name)
* @param {CommandDoCallback} run A method that performs the action for the first time
* @param {CommandUndoCallback} undo A method that reverses what the run method did
* @param {object} opt Extra options
* @param {CommandDoCallback} opt.redo A method that redoes the action after undone (default: run)
* @param {(state: any) => any} opt.exportfn A method that exports a serializeable object
* @param {(value: any, state: any) => any} opt.importfn A method that imports a serializeable object
* @returns {Command}
*/
createCommand(name, run, undo, opt = {}) {
defaultOpt(opt, {
redo: run,
exportfn: null,
importfn: null,
});
const command = async function runWrapper(title, options, extra = {}) {
// Create copy of options and state object
const copy = {};
Object.assign(copy, options);
const state = {};
defaultOpt(extra, {
recordHistory: true,
importData: null,
});
const exportfn =
opt.exportfn ?? ((state) => Object.assign({}, state.serializeable));
const importfn =
opt.importfn ??
((value, state) => (state.serializeable = Object.assign({}, value)));
const redo = opt.redo;
/** @type {CommandEntry} */
const entry = {
id: guid(),
title,
state,
async export() {
return {
command: name,
title,
data: await exportfn(state),
};
},
extra: extra.extra,
};
if (extra.importData) {
await importfn(extra.importData, state);
state.imported = extra.importData;
}
// Attempt to run command
try {
console.debug(`[commands] Running '${title}'[${name}]`);
await run(title, copy, state);
} catch (e) {
console.warn(
`[commands] Error while running command '${name}' with options:`
);
console.warn(copy);
console.warn(e);
return;
}
const undoWrapper = async () => {
console.debug(
`[commands] Undoing '${title}'[${name}], currently ${this._current}`
);
await undo(title, state);
_commands_events.emit({
id: entry.id,
name,
action: "undo",
state,
current: this._current,
});
};
const redoWrapper = async () => {
console.debug(
`[commands] Redoing '${title}'[${name}], currently ${this._current}`
);
await redo(title, copy, state);
_commands_events.emit({
id: entry.id,
name,
action: "redo",
state,
current: this._current,
});
};
entry.undo = undoWrapper;
entry.redo = redoWrapper;
if (!extra.recordHistory) return entry;
// Add to history
if (commands._history.length > commands._current + 1) {
commands._history.forEach((entry, index) => {
if (index >= commands._current + 1)
_commands_events.emit({
id: entry.id,
name,
action: "deleted",
state,
current: this._current,
});
});
commands._history.splice(commands._current + 1);
}
commands._history.push(entry);
commands._current++;
_commands_events.emit({
id: entry.id,
name,
action: "run",
state,
current: commands._current,
});
return entry;
};
this._types[name] = command;
return command;
},
/**
* Runs a command
*
* @param {string} name The name of the command to run
* @param {string} title The display name of the command on the history panel view
* @param {any} options The options to be sent to the command to be run
* @param {CommandExtraParams} extra Extra running options
* @return {Promise<{undo: () => void, redo: () => void}>} The command's return value
*/
async runCommand(name, title, options = null, extra = {}) {
defaultOpt(extra, {
recordHistory: true,
extra: {},
});
if (!this._types[name])
throw new ReferenceError(`[commands] Command '${name}' does not exist`);
return this._types[name](title, options, extra);
},
},
"commands",
["_current"]
);
/**
* Draw Image Command, used to draw a Image to a context
*/
commands.createCommand(
"drawImage",
(title, options, state) => {
if (
!state.imported &&
(!options ||
options.image === undefined ||
options.x === undefined ||
options.y === undefined)
)
throw "Command drawImage requires options in the format: {image, x, y, w?, h?, layer?}";
// Check if we have state
if (!state.layer) {
/** @type {Layer} */
let layer = options.layer;
if (!options.layer && state.layerId)
layer = imageCollection.layers[state.layerId];
if (!options.layer && !state.layerId) layer = uil.layer;
state.layer = layer;
state.context = layer.ctx;
if (!state.imported) {
const canvas = document.createElement("canvas");
canvas.width = options.image.width;
canvas.height = options.image.height;
canvas.getContext("2d").drawImage(options.image, 0, 0);
state.image = canvas;
// Saving what was in the canvas before the command
const imgData = state.context.getImageData(
options.x,
options.y,
options.w || options.image.width,
options.h || options.image.height
);
state.box = {
x: options.x,
y: options.y,
w: options.w || options.image.width,
h: options.h || options.image.height,
};
// Create Image
const cutout = document.createElement("canvas");
cutout.width = state.box.w;
cutout.height = state.box.h;
cutout.getContext("2d").putImageData(imgData, 0, 0);
state.original = cutout;
}
}
// Apply command
state.context.drawImage(
state.image,
0,
0,
state.image.width,
state.image.height,
state.box.x,
state.box.y,
state.box.w,
state.box.h
);
},
(title, state) => {
// Clear destination area
state.context.clearRect(state.box.x, state.box.y, state.box.w, state.box.h);
// Undo
state.context.drawImage(state.original, state.box.x, state.box.y);
},
{
exportfn: (state) => {
const canvas = document.createElement("canvas");
canvas.width = state.image.width;
canvas.height = state.image.height;
canvas.getContext("2d").drawImage(state.image, 0, 0);
const originalc = document.createElement("canvas");
originalc.width = state.original.width;
originalc.height = state.original.height;
originalc.getContext("2d").drawImage(state.original, 0, 0);
return {
image: canvas.toDataURL(),
original: originalc.toDataURL(),
box: state.box,
layer: state.layer.id,
};
},
importfn: async (value, state) => {
state.box = value.box;
state.layerId = value.layer;
const img = document.createElement("img");
img.src = value.image;
await img.decode();
const imagec = document.createElement("canvas");
imagec.width = state.box.w;
imagec.height = state.box.h;
imagec.getContext("2d").drawImage(img, 0, 0);
const orig = document.createElement("img");
orig.src = value.original;
await orig.decode();
const originalc = document.createElement("canvas");
originalc.width = state.box.w;
originalc.height = state.box.h;
originalc.getContext("2d").drawImage(orig, 0, 0);
state.image = imagec;
state.original = originalc;
},
}
);
commands.createCommand(
"eraseImage",
(title, options, state) => {
if (
!state.imported &&
(!options ||
options.x === undefined ||
options.y === undefined ||
options.w === undefined ||
options.h === undefined)
)
throw "Command eraseImage requires options in the format: {x, y, w, h, ctx?}";
if (state.imported) {
state.layer = imageCollection.layers[state.layerId];
state.context = state.layer.ctx;
}
// Check if we have state
if (!state.layer) {
const layer = (options.layer || state.layerId) ?? uil.layer;
state.layer = layer;
state.mask = options.mask;
state.context = layer.ctx;
// Saving what was in the canvas before the command
state.box = {
x: options.x,
y: options.y,
w: options.w,
h: options.h,
};
// Create Image
const cutout = document.createElement("canvas");
cutout.width = state.box.w;
cutout.height = state.box.h;
cutout
.getContext("2d")
.drawImage(
state.context.canvas,
options.x,
options.y,
options.w,
options.h,
0,
0,
options.w,
options.h
);
state.original = new Image();
state.original.src = cutout.toDataURL();
}
// Apply command
const style = state.context.fillStyle;
state.context.fillStyle = "black";
const op = state.context.globalCompositeOperation;
state.context.globalCompositeOperation = "destination-out";
if (state.mask)
state.context.drawImage(
state.mask,
state.box.x,
state.box.y,
state.box.w,
state.box.h
);
else
state.context.fillRect(
state.box.x,
state.box.y,
state.box.w,
state.box.h
);
state.context.fillStyle = style;
state.context.globalCompositeOperation = op;
},
(title, state) => {
// Clear destination area
state.context.clearRect(state.box.x, state.box.y, state.box.w, state.box.h);
// Undo
state.context.drawImage(state.original, state.box.x, state.box.y);
},
{
exportfn: (state) => {
let mask = null;
if (state.mask) {
const maskc = document.createElement("canvas");
maskc.width = state.mask.width;
maskc.height = state.mask.height;
maskc.getContext("2d").drawImage(state.mask, 0, 0);
mask = maskc.toDataURL();
}
const originalc = document.createElement("canvas");
originalc.width = state.original.width;
originalc.height = state.original.height;
originalc.getContext("2d").drawImage(state.original, 0, 0);
return {
original: originalc.toDataURL(),
mask,
box: state.box,
layer: state.layer.id,
};
},
importfn: async (value, state) => {
state.box = value.box;
state.layerId = value.layer;
if (value.mask) {
const mask = document.createElement("img");
mask.src = value.mask;
await mask.decode();
const maskc = document.createElement("canvas");
maskc.width = state.box.w;
maskc.height = state.box.h;
maskc.getContext("2d").drawImage(mask, 0, 0);
state.mask = maskc;
}
const orig = document.createElement("img");
orig.src = value.original;
await orig.decode();
const originalc = document.createElement("canvas");
originalc.width = state.box.w;
originalc.height = state.box.h;
originalc.getContext("2d").drawImage(orig, 0, 0);
state.original = originalc;
},
}
);