559 lines
13 KiB
JavaScript
559 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: {},
|
|
|
|
/** @type {Observer<{n: int, cancel: function}>} */
|
|
get onundo() {
|
|
return this._onundo;
|
|
},
|
|
_onundo: new Observer(),
|
|
|
|
/** @type {Observer<{n: int, cancel: function}>} */
|
|
get onredo() {
|
|
return this._onredo;
|
|
},
|
|
_onredo: new Observer(),
|
|
|
|
/**
|
|
* Undoes the last commands in the history
|
|
*
|
|
* @param {number} [n] Number of actions to undo
|
|
*/
|
|
async undo(n = 1) {
|
|
var cancelled = false;
|
|
await this._onundo.emit({
|
|
n: n,
|
|
cancel: () => {
|
|
cancelled = true;
|
|
},
|
|
});
|
|
if (cancelled) return;
|
|
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) {
|
|
let cancelled = false;
|
|
await this._onredo.emit({
|
|
n: n,
|
|
cancel: () => {
|
|
cancelled = true;
|
|
},
|
|
});
|
|
if (cancelled) return;
|
|
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;
|
|
},
|
|
}
|
|
);
|