layer history and finally layer delete/merge
Signed-off-by: Victor Seiji Hariki <victorseijih@gmail.com>
This commit is contained in:
parent
0cc6f7660a
commit
62ddc38f01
6 changed files with 361 additions and 34 deletions
|
@ -13,6 +13,11 @@
|
|||
mask-image: url("/res/icons/file-plus.svg");
|
||||
}
|
||||
|
||||
.ui.icon > .icon-file-x {
|
||||
-webkit-mask-image: url("/res/icons/file-x.svg");
|
||||
mask-image: url("/res/icons/file-x.svg");
|
||||
}
|
||||
|
||||
.ui.icon > .icon-chevron-down {
|
||||
-webkit-mask-image: url("/res/icons/chevron-down.svg");
|
||||
mask-image: url("/res/icons/chevron-down.svg");
|
||||
|
@ -22,3 +27,13 @@
|
|||
-webkit-mask-image: url("/res/icons/chevron-up.svg");
|
||||
mask-image: url("/res/icons/chevron-up.svg");
|
||||
}
|
||||
.ui.icon > .icon-chevron-first {
|
||||
-webkit-mask-image: url("/res/icons/chevron-first.svg");
|
||||
mask-image: url("/res/icons/chevron-first.svg");
|
||||
}
|
||||
|
||||
.ui.icon > .icon-chevron-flat-down {
|
||||
-webkit-mask-image: url("/res/icons/chevron-first.svg");
|
||||
mask-image: url("/res/icons/chevron-first.svg");
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
|
22
index.html
22
index.html
|
@ -216,7 +216,7 @@
|
|||
<button
|
||||
type="button"
|
||||
title="Add Layer"
|
||||
onclick="uil.addLayer(null, 'New Layer')"
|
||||
onclick="commands.runCommand('addLayer', 'Added Layer')"
|
||||
class="ui icon button">
|
||||
<div class="icon-file-plus"></div>
|
||||
</button>
|
||||
|
@ -224,7 +224,7 @@
|
|||
<button
|
||||
type="button"
|
||||
title="Move Layer Up"
|
||||
onclick="uil.moveLayerUp()"
|
||||
onclick="commands.runCommand('moveLayer', 'Moved Layer Up',{delta: 1})"
|
||||
class="ui icon button">
|
||||
<div class="icon-chevron-up"></div>
|
||||
</button>
|
||||
|
@ -232,10 +232,26 @@
|
|||
<button
|
||||
type="button"
|
||||
title="Move Layer Down"
|
||||
onclick="uil.moveLayerDown()"
|
||||
onclick="commands.runCommand('moveLayer', 'Moved Layer Down', {delta: -1})"
|
||||
class="ui icon button">
|
||||
<div class="icon-chevron-down"></div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title="Merge Layer Down"
|
||||
onclick="commands.runCommand('mergeLayer', 'Merged Layer Down')"
|
||||
class="ui icon button">
|
||||
<div class="icon-chevron-flat-down"></div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title="Move Layer Down"
|
||||
onclick="commands.runCommand('deleteLayer', 'Deleted Layer')"
|
||||
class="ui icon button">
|
||||
<div class="icon-file-x"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -4,9 +4,6 @@
|
|||
|
||||
const _commands_events = new Observer();
|
||||
|
||||
/** CommandNonExistentError */
|
||||
class CommandNonExistentError extends Error {}
|
||||
|
||||
/** Global Commands Object */
|
||||
const commands = makeReadOnly(
|
||||
{
|
||||
|
@ -32,7 +29,14 @@ const commands = makeReadOnly(
|
|||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
|
@ -42,7 +46,14 @@ const commands = makeReadOnly(
|
|||
*/
|
||||
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 {
|
||||
console.warn("[commands] Failed to redo command");
|
||||
console.warn(e);
|
||||
this._current--;
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -67,7 +78,7 @@ const commands = makeReadOnly(
|
|||
* @returns {Command}
|
||||
*/
|
||||
createCommand(name, run, undo, redo = run) {
|
||||
const command = async function runWrapper(title, options) {
|
||||
const command = async function runWrapper(title, options, extra) {
|
||||
// Create copy of options and state object
|
||||
const copy = {};
|
||||
Object.assign(copy, options);
|
||||
|
@ -93,11 +104,11 @@ const commands = makeReadOnly(
|
|||
return;
|
||||
}
|
||||
|
||||
const undoWrapper = () => {
|
||||
const undoWrapper = async () => {
|
||||
console.debug(
|
||||
`[commands] Undoing '${title}'[${name}], currently ${this._current}`
|
||||
);
|
||||
undo(title, state);
|
||||
await undo(title, state);
|
||||
_commands_events.emit({
|
||||
id: entry.id,
|
||||
name,
|
||||
|
@ -106,11 +117,11 @@ const commands = makeReadOnly(
|
|||
current: this._current,
|
||||
});
|
||||
};
|
||||
const redoWrapper = () => {
|
||||
const redoWrapper = async () => {
|
||||
console.debug(
|
||||
`[commands] Redoing '${title}'[${name}], currently ${this._current}`
|
||||
);
|
||||
redo(title, copy, state);
|
||||
await redo(title, copy, state);
|
||||
_commands_events.emit({
|
||||
id: entry.id,
|
||||
name,
|
||||
|
@ -120,6 +131,11 @@ const commands = makeReadOnly(
|
|||
});
|
||||
};
|
||||
|
||||
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) => {
|
||||
|
@ -139,9 +155,6 @@ const commands = makeReadOnly(
|
|||
commands._history.push(entry);
|
||||
commands._current++;
|
||||
|
||||
entry.undo = undoWrapper;
|
||||
entry.redo = redoWrapper;
|
||||
|
||||
_commands_events.emit({
|
||||
id: entry.id,
|
||||
name,
|
||||
|
@ -163,13 +176,16 @@ const commands = makeReadOnly(
|
|||
* @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
|
||||
* @return {Promise<{undo: () => void, redo: () => void}>} The command's return value
|
||||
*/
|
||||
runCommand(name, title, options = null) {
|
||||
async runCommand(name, title, options = null, extra = {}) {
|
||||
defaultOpt(extra, {
|
||||
recordHistory: true,
|
||||
});
|
||||
if (!this._types[name])
|
||||
throw new CommandNonExistentError(
|
||||
`[commands] Command '${name}' does not exist`
|
||||
);
|
||||
this._types[name](title, options);
|
||||
throw new ReferenceError(`[commands] Command '${name}' does not exist`);
|
||||
|
||||
return this._types[name](title, options, extra);
|
||||
},
|
||||
},
|
||||
"commands",
|
||||
|
|
|
@ -99,6 +99,32 @@ const uil = {
|
|||
const actionArray = document.createElement("div");
|
||||
actionArray.classList.add("actions");
|
||||
|
||||
if (uiLayer.deletable) {
|
||||
const deleteButton = document.createElement("button");
|
||||
deleteButton.addEventListener(
|
||||
"click",
|
||||
(evn) => {
|
||||
commands.runCommand("deleteLayer", "Deleted Layer", {
|
||||
layer: uiLayer,
|
||||
});
|
||||
},
|
||||
{passive: false}
|
||||
);
|
||||
|
||||
deleteButton.addEventListener(
|
||||
"dblclick",
|
||||
(evn) => {
|
||||
evn.stopPropagation();
|
||||
},
|
||||
{passive: false}
|
||||
);
|
||||
deleteButton.title = "Delete Layer";
|
||||
deleteButton.appendChild(document.createElement("div"));
|
||||
deleteButton.classList.add("delete-btn");
|
||||
|
||||
actionArray.appendChild(deleteButton);
|
||||
}
|
||||
|
||||
const hideButton = document.createElement("button");
|
||||
hideButton.addEventListener(
|
||||
"click",
|
||||
|
@ -111,6 +137,13 @@ const uil = {
|
|||
},
|
||||
{passive: false}
|
||||
);
|
||||
hideButton.addEventListener(
|
||||
"dblclick",
|
||||
(evn) => {
|
||||
evn.stopPropagation();
|
||||
},
|
||||
{passive: false}
|
||||
);
|
||||
hideButton.title = "Hide/Unhide Layer";
|
||||
hideButton.appendChild(document.createElement("div"));
|
||||
hideButton.classList.add("hide-btn");
|
||||
|
@ -121,13 +154,23 @@ const uil = {
|
|||
if (layersEl.children[index])
|
||||
layersEl.children[index].before(uiLayer.entry);
|
||||
else layersEl.appendChild(uiLayer.entry);
|
||||
}
|
||||
} else if (!layersEl.querySelector(`#ui-layer-${uiLayer.id}`)) {
|
||||
// If layer exists but is not on the DOM, add it back
|
||||
if (index === 0) layersEl.children[0].before(uiLayer.entry);
|
||||
else layersEl.children[index - 1].after(uiLayer.entry);
|
||||
} else {
|
||||
// If the layer already exists, just move it here
|
||||
else {
|
||||
layersEl.children[index].before(uiLayer.entry);
|
||||
}
|
||||
});
|
||||
|
||||
// Deletes layer if not in array
|
||||
for (var i = 0; i < layersEl.children.length; i++) {
|
||||
if (!copy.find((l) => layersEl.children[i].id === `ui-layer-${l.id}`)) {
|
||||
layersEl.children[i].remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronizes with the layer lib
|
||||
this.layers.forEach((uiLayer, index) => {
|
||||
if (index === 0) uiLayer.layer.moveAfter(bgLayer);
|
||||
|
@ -138,11 +181,13 @@ const uil = {
|
|||
/**
|
||||
* Adds a user-manageable layer for image editing.
|
||||
*
|
||||
* Should not be called directly. Use the command instead.
|
||||
*
|
||||
* @param {string} group The group the layer belongs to. [does nothing for now]
|
||||
* @param {string} name The name of the new layer.
|
||||
* @returns
|
||||
*/
|
||||
addLayer(group, name) {
|
||||
_addLayer(group, name) {
|
||||
const layer = imageCollection.registerLayer(null, {
|
||||
name,
|
||||
after:
|
||||
|
@ -180,12 +225,14 @@ const uil = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Moves a layer to a specified position
|
||||
* Moves a layer to a specified position.
|
||||
*
|
||||
* Should not be called directly. Use the command instead.
|
||||
*
|
||||
* @param {UserLayer} layer Layer to move
|
||||
* @param {number} position Position to move the layer to
|
||||
*/
|
||||
moveLayerTo(layer, position) {
|
||||
_moveLayerTo(layer, position) {
|
||||
if (position < 0 || position >= this.layers.length)
|
||||
throw new RangeError("Position out of bounds");
|
||||
|
||||
|
@ -203,27 +250,31 @@ const uil = {
|
|||
throw new ReferenceError("Layer could not be found");
|
||||
},
|
||||
/**
|
||||
* Moves a layer up a single position
|
||||
* Moves a layer up a single position.
|
||||
*
|
||||
* Should not be called directly. Use the command instead.
|
||||
*
|
||||
* @param {UserLayer} [layer=uil.active] Layer to move
|
||||
*/
|
||||
moveLayerUp(layer = uil.active) {
|
||||
_moveLayerUp(layer = uil.active) {
|
||||
const index = this.layers.indexOf(layer);
|
||||
if (index === -1) throw new ReferenceError("Layer could not be found");
|
||||
try {
|
||||
this.moveLayerTo(layer, index + 1);
|
||||
this._moveLayerTo(layer, index + 1);
|
||||
} catch (e) {}
|
||||
},
|
||||
/**
|
||||
* Moves a layer down a single position
|
||||
* Moves a layer down a single position.
|
||||
*
|
||||
* Should not be called directly. Use the command instead.
|
||||
*
|
||||
* @param {UserLayer} [layer=uil.active] Layer to move
|
||||
*/
|
||||
moveLayerDown(layer = uil.active) {
|
||||
_moveLayerDown(layer = uil.active) {
|
||||
const index = this.layers.indexOf(layer);
|
||||
if (index === -1) throw new ReferenceError("Layer could not be found");
|
||||
try {
|
||||
this.moveLayerTo(layer, index - 1);
|
||||
this._moveLayerTo(layer, index - 1);
|
||||
} catch (e) {}
|
||||
},
|
||||
/**
|
||||
|
@ -266,4 +317,221 @@ const uil = {
|
|||
return canvas;
|
||||
},
|
||||
};
|
||||
uil.addLayer(null, "Default Image Layer");
|
||||
|
||||
/**
|
||||
* Command for creating a new layer
|
||||
*/
|
||||
commands.createCommand(
|
||||
"addLayer",
|
||||
(title, opt, state) => {
|
||||
const options = Object.assign({}, opt) || {};
|
||||
defaultOpt(options, {
|
||||
group: null,
|
||||
name: "New Layer",
|
||||
deletable: true,
|
||||
});
|
||||
|
||||
if (!state.layer) {
|
||||
const {group, name} = options;
|
||||
|
||||
const layer = imageCollection.registerLayer(null, {
|
||||
name,
|
||||
after:
|
||||
(uil.layers.length > 0 && uil.layers[uil.layers.length - 1].layer) ||
|
||||
bgLayer,
|
||||
});
|
||||
|
||||
state.layer = {
|
||||
id: layer.id,
|
||||
group,
|
||||
name,
|
||||
deletable: options.deletable,
|
||||
_hidden: false,
|
||||
set hidden(v) {
|
||||
if (v) {
|
||||
uil._hidden = true;
|
||||
uil.layer.hide(v);
|
||||
} else {
|
||||
uil._hidden = false;
|
||||
uil.layer.unhide(v);
|
||||
}
|
||||
},
|
||||
get hidden() {
|
||||
return uil._hidden;
|
||||
},
|
||||
entry: null,
|
||||
layer,
|
||||
};
|
||||
}
|
||||
uil.layers.push(state.layer);
|
||||
|
||||
uil._syncLayers();
|
||||
|
||||
uil.active = state.layer;
|
||||
},
|
||||
(title, state) => {
|
||||
const index = uil.layers.findIndex((v) => v === state.layer);
|
||||
|
||||
if (index === -1) throw new ReferenceError("Layer could not be found");
|
||||
|
||||
if (uil.active === state.layer)
|
||||
uil.active = uil.layers[index + 1] || uil.layers[index - 1];
|
||||
uil.layers.splice(index, 1);
|
||||
uil._syncLayers();
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Command for moving a layer to a position
|
||||
*/
|
||||
commands.createCommand(
|
||||
"moveLayer",
|
||||
(title, opt, state) => {
|
||||
const options = opt || {};
|
||||
defaultOpt(options, {
|
||||
layer: null,
|
||||
to: null,
|
||||
delta: null,
|
||||
});
|
||||
|
||||
if (!state.layer) {
|
||||
if (options.to === null && options.delta === null)
|
||||
throw new Error(
|
||||
"[layers.moveLayer] Options must contain one of {to?, delta?}"
|
||||
);
|
||||
|
||||
const layer = options.layer || uil.active;
|
||||
|
||||
const index = uil.layers.indexOf(layer);
|
||||
if (index === -1) throw new ReferenceError("Layer could not be found");
|
||||
|
||||
let position = options.to;
|
||||
|
||||
if (position === null) position = index + options.delta;
|
||||
|
||||
state.layer = layer;
|
||||
state.oldposition = index;
|
||||
state.position = position;
|
||||
}
|
||||
|
||||
uil._moveLayerTo(state.layer, state.position);
|
||||
},
|
||||
(title, state) => {
|
||||
uil._moveLayerTo(state.layer, state.oldposition);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Command for deleting a layer
|
||||
*/
|
||||
commands.createCommand(
|
||||
"deleteLayer",
|
||||
(title, opt, state) => {
|
||||
const options = opt || {};
|
||||
defaultOpt(options, {
|
||||
layer: null,
|
||||
});
|
||||
|
||||
if (!state.layer) {
|
||||
const layer = options.layer || uil.active;
|
||||
|
||||
if (!layer.deletable)
|
||||
throw new TypeError("[layer.deleteLayer] Layer is not deletable");
|
||||
|
||||
const index = uil.layers.indexOf(layer);
|
||||
if (index === -1)
|
||||
throw new ReferenceError(
|
||||
"[layer.deleteLayer] Layer could not be found"
|
||||
);
|
||||
|
||||
state.layer = layer;
|
||||
state.position = index;
|
||||
}
|
||||
|
||||
uil.layers.splice(state.position, 1);
|
||||
uil.active = uil.layers[state.position - 1] || uil.layers[state.position];
|
||||
|
||||
uil._syncLayers();
|
||||
|
||||
state.layer.layer.hide();
|
||||
},
|
||||
(title, state) => {
|
||||
uil.layers.splice(state.position, 0, state.layer);
|
||||
uil.active = state.layer;
|
||||
|
||||
uil._syncLayers();
|
||||
|
||||
state.layer.layer.unhide();
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Command for merging a layer into the layer below it
|
||||
*/
|
||||
commands.createCommand(
|
||||
"mergeLayer",
|
||||
async (title, opt, state) => {
|
||||
const options = opt || {};
|
||||
defaultOpt(options, {
|
||||
layerS: null,
|
||||
layerD: null,
|
||||
});
|
||||
|
||||
const layerS = options.layer || uil.active;
|
||||
|
||||
if (!layerS.deletable)
|
||||
throw new TypeError(
|
||||
"[layer.mergeLayer] Layer is a root layer and cannot be merged"
|
||||
);
|
||||
|
||||
const index = uil.layers.indexOf(layerS);
|
||||
if (index === -1)
|
||||
throw new ReferenceError("[layer.mergeLayer] Layer could not be found");
|
||||
|
||||
if (index === 0 && !options.layerD)
|
||||
throw new ReferenceError(
|
||||
"[layer.mergeLayer] No layer below source layer exists"
|
||||
);
|
||||
|
||||
// Use layer under source layer to merge into if not given
|
||||
const layerD = options.layerD || uil.layers[index - 1];
|
||||
|
||||
state.layerS = layerS;
|
||||
state.layerD = layerD;
|
||||
|
||||
// REFERENCE: This is a great reference for metacommands (commands that use other commands)
|
||||
// These commands should NOT record history as we are already executing a command
|
||||
state.drawCommand = await commands.runCommand(
|
||||
"drawImage",
|
||||
"Merge Layer Draw",
|
||||
{
|
||||
image: state.layerS.layer.canvas,
|
||||
x: 0,
|
||||
y: 0,
|
||||
ctx: state.layerD.layer.ctx,
|
||||
},
|
||||
{recordHistory: false}
|
||||
);
|
||||
state.delCommand = await commands.runCommand(
|
||||
"deleteLayer",
|
||||
"Merge Layer Delete",
|
||||
{layer: state.layerS},
|
||||
{recordHistory: false}
|
||||
);
|
||||
},
|
||||
(title, state) => {
|
||||
state.drawCommand.undo();
|
||||
state.delCommand.undo();
|
||||
},
|
||||
(title, options, state) => {
|
||||
state.drawCommand.redo();
|
||||
state.delCommand.redo();
|
||||
}
|
||||
);
|
||||
|
||||
commands.runCommand(
|
||||
"addLayer",
|
||||
"Initial Layer Creation",
|
||||
{name: "Default Image Layer", deletable: false},
|
||||
{recordHistory: false}
|
||||
);
|
||||
|
|
5
res/icons/chevron-first.svg
Normal file
5
res/icons/chevron-first.svg
Normal file
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="17 18 11 12 17 6"></polyline>
|
||||
<path d="M7 6v12"></path>
|
||||
|
||||
</svg>
|
After Width: | Height: | Size: 267 B |
7
res/icons/file-x.svg
Normal file
7
res/icons/file-x.svg
Normal file
|
@ -0,0 +1,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path>
|
||||
<polyline points="14 2 14 8 20 8"></polyline>
|
||||
<line x1="9.5" y1="12.5" x2="14.5" y2="17.5"></line>
|
||||
<line x1="14.5" y1="12.5" x2="9.5" y2="17.5"></line>
|
||||
|
||||
</svg>
|
After Width: | Height: | Size: 437 B |
Loading…
Reference in a new issue