From 62ddc38f0101a52fe22d1e1eccf7641dda91355c Mon Sep 17 00:00:00 2001 From: Victor Seiji Hariki Date: Mon, 5 Dec 2022 23:22:19 -0300 Subject: [PATCH] layer history and finally layer delete/merge Signed-off-by: Victor Seiji Hariki --- css/icons.css | 15 ++ index.html | 22 ++- js/lib/commands.js | 52 ++++--- js/ui/floating/layers.js | 294 ++++++++++++++++++++++++++++++++++-- res/icons/chevron-first.svg | 5 + res/icons/file-x.svg | 7 + 6 files changed, 361 insertions(+), 34 deletions(-) create mode 100644 res/icons/chevron-first.svg create mode 100644 res/icons/file-x.svg diff --git a/css/icons.css b/css/icons.css index 0309e12..ee50b80 100644 --- a/css/icons.css +++ b/css/icons.css @@ -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); +} diff --git a/index.html b/index.html index 62ccff4..f827cfd 100644 --- a/index.html +++ b/index.html @@ -216,7 +216,7 @@ @@ -224,7 +224,7 @@ @@ -232,10 +232,26 @@ + + + + diff --git a/js/lib/commands.js b/js/lib/commands.js index 42c5c6f..f665d1e 100644 --- a/js/lib/commands.js +++ b/js/lib/commands.js @@ -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++) { - await this._history[this._current--].undo(); + 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++) { - await this._history[++this._current].redo(); + 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", diff --git a/js/ui/floating/layers.js b/js/ui/floating/layers.js index 0bee020..52a15e6 100644 --- a/js/ui/floating/layers.js +++ b/js/ui/floating/layers.js @@ -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); - } - // If the layer already exists, just move it here - else { + } 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 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} +); diff --git a/res/icons/chevron-first.svg b/res/icons/chevron-first.svg new file mode 100644 index 0000000..36cfa87 --- /dev/null +++ b/res/icons/chevron-first.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/res/icons/file-x.svg b/res/icons/file-x.svg new file mode 100644 index 0000000..f2339af --- /dev/null +++ b/res/icons/file-x.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file