006aa608e8
Signed-off-by: Victor Seiji Hariki <victorseijih@gmail.com>
716 lines
16 KiB
JavaScript
716 lines
16 KiB
JavaScript
/**
|
|
* The layering UI window
|
|
*/
|
|
|
|
const uil = {
|
|
/** @type {Observer<{uilayer: UILayer}>} */
|
|
onactive: new Observer(),
|
|
|
|
_ui_layer_list: document.getElementById("layer-list"),
|
|
layers: [],
|
|
layerIndex: {},
|
|
_active: null,
|
|
set active(v) {
|
|
this.onactive.emit({
|
|
uilayer: v,
|
|
});
|
|
|
|
Array.from(this._ui_layer_list.children).forEach((child) => {
|
|
child.classList.remove("active");
|
|
});
|
|
|
|
v.entry.classList.add("active");
|
|
|
|
this._active = v;
|
|
},
|
|
get active() {
|
|
return this._active;
|
|
},
|
|
|
|
/** @type {Layer} */
|
|
get layer() {
|
|
return this.active && this.active.layer;
|
|
},
|
|
|
|
get canvas() {
|
|
return this.layer && this.active.layer.canvas;
|
|
},
|
|
|
|
get ctx() {
|
|
return this.layer && this.active.layer.ctx;
|
|
},
|
|
|
|
get w() {
|
|
return imageCollection.size.w;
|
|
},
|
|
get h() {
|
|
return imageCollection.size.h;
|
|
},
|
|
|
|
/**
|
|
* Synchronizes layer array to DOM
|
|
*/
|
|
_syncLayers() {
|
|
const layersEl = document.getElementById("layer-list");
|
|
|
|
const copy = this.layers.map((i) => i);
|
|
copy.reverse();
|
|
|
|
copy.forEach((uiLayer, index) => {
|
|
// If we have the correct layer here, then do nothing
|
|
if (
|
|
layersEl.children[index] &&
|
|
layersEl.children[index].id === `ui-layer-${uiLayer.id}`
|
|
)
|
|
return;
|
|
|
|
// If the layer we are processing does not exist, then create it and add before current element
|
|
if (!uiLayer.entry) {
|
|
uiLayer.entry = document.createElement("div");
|
|
uiLayer.entry.id = `ui-layer-${uiLayer.id}`;
|
|
uiLayer.entry.classList.add("ui-layer");
|
|
uiLayer.entry.addEventListener("click", () => {
|
|
this.active = uiLayer;
|
|
});
|
|
|
|
// Title Element
|
|
const titleEl = document.createElement("input");
|
|
titleEl.classList.add("title");
|
|
titleEl.value = uiLayer.name;
|
|
titleEl.style.pointerEvents = "none";
|
|
|
|
const deselect = () => {
|
|
titleEl.style.pointerEvents = "none";
|
|
titleEl.setSelectionRange(0, 0);
|
|
};
|
|
|
|
titleEl.addEventListener("blur", deselect);
|
|
uiLayer.entry.appendChild(titleEl);
|
|
|
|
uiLayer.entry.addEventListener("change", () => {
|
|
const name = titleEl.value.trim();
|
|
titleEl.value = name;
|
|
uiLayer.entry.title = name;
|
|
|
|
uiLayer.name = name;
|
|
|
|
this._syncLayers();
|
|
|
|
titleEl.blur();
|
|
});
|
|
uiLayer.entry.addEventListener("dblclick", () => {
|
|
titleEl.style.pointerEvents = "auto";
|
|
titleEl.focus();
|
|
titleEl.select();
|
|
});
|
|
|
|
// Add action buttons
|
|
const actionArray = document.createElement("div");
|
|
actionArray.classList.add("actions");
|
|
|
|
if (uiLayer.deletable) {
|
|
const deleteButton = document.createElement("button");
|
|
deleteButton.addEventListener(
|
|
"click",
|
|
(evn) => {
|
|
evn.stopPropagation();
|
|
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",
|
|
(evn) => {
|
|
evn.stopPropagation();
|
|
uiLayer.hidden = !uiLayer.hidden;
|
|
},
|
|
{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");
|
|
|
|
actionArray.appendChild(hideButton);
|
|
uiLayer.entry.appendChild(actionArray);
|
|
|
|
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
|
|
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);
|
|
else uiLayer.layer.moveAfter(copy[index - 1].layer);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* 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) {
|
|
const layer = imageCollection.registerLayer(null, {
|
|
name,
|
|
category: "user",
|
|
after:
|
|
(this.layers.length > 0 && this.layers[this.layers.length - 1].layer) ||
|
|
bgLayer,
|
|
});
|
|
|
|
const uiLayer = {
|
|
id: layer.id,
|
|
group,
|
|
name,
|
|
_hidden: false,
|
|
set hidden(v) {
|
|
if (v) {
|
|
this._hidden = true;
|
|
this.layer.hide(v);
|
|
this.entry && this.entry.classList.add("hidden");
|
|
} else {
|
|
this._hidden = false;
|
|
this.layer.unhide(v);
|
|
this.entry && this.entry.classList.remove("hidden");
|
|
}
|
|
},
|
|
get hidden() {
|
|
return this._hidden;
|
|
},
|
|
entry: null,
|
|
layer,
|
|
};
|
|
this.layers.push(uiLayer);
|
|
|
|
this._syncLayers();
|
|
|
|
this.active = uiLayer;
|
|
|
|
return uiLayer;
|
|
},
|
|
|
|
/**
|
|
* 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) {
|
|
if (position < 0 || position >= this.layers.length)
|
|
throw new RangeError("Position out of bounds");
|
|
|
|
const index = this.layers.indexOf(layer);
|
|
if (index !== -1) {
|
|
if (this.layers.length < 2) return; // Do nothing if moving a layer doesn't make sense
|
|
|
|
this.layers.splice(index, 1);
|
|
this.layers.splice(position, 0, layer);
|
|
|
|
this._syncLayers();
|
|
|
|
return;
|
|
}
|
|
throw new ReferenceError("Layer could not be found");
|
|
},
|
|
/**
|
|
* 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) {
|
|
const index = this.layers.indexOf(layer);
|
|
if (index === -1) throw new ReferenceError("Layer could not be found");
|
|
try {
|
|
this._moveLayerTo(layer, index + 1);
|
|
} catch (e) {}
|
|
},
|
|
/**
|
|
* 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) {
|
|
const index = this.layers.indexOf(layer);
|
|
if (index === -1) throw new ReferenceError("Layer could not be found");
|
|
try {
|
|
this._moveLayerTo(layer, index - 1);
|
|
} catch (e) {}
|
|
},
|
|
/**
|
|
* Function that returns a canvas with full visible information of a certain bounding box.
|
|
*
|
|
* For now, only the img is used.
|
|
*
|
|
* @param {BoundingBox} bb The bouding box to get visible data from
|
|
* @param {object} [options] Options
|
|
* @param {boolean} [options.includeBg=false] Whether to include the background
|
|
* @param {string[]} [options.categories] Categories of layers to consider visible
|
|
* @returns {HTMLCanvasElement} The canvas element containing visible image data
|
|
*/
|
|
getVisible(bb, options = {}) {
|
|
defaultOpt(options, {
|
|
includeBg: false,
|
|
categories: ["user", "image"],
|
|
});
|
|
|
|
const canvas = document.createElement("canvas");
|
|
const ctx = canvas.getContext("2d");
|
|
|
|
canvas.width = bb.w;
|
|
canvas.height = bb.h;
|
|
|
|
const categories = new Set(options.categories);
|
|
if (options.includeBg) categories.add("background");
|
|
const layers = imageCollection._layers;
|
|
|
|
layers.reduceRight((_, layer) => {
|
|
if (categories.has(layer.category) && !layer.hidden)
|
|
ctx.drawImage(layer.canvas, bb.x, bb.y, bb.w, bb.h, 0, 0, bb.w, bb.h);
|
|
});
|
|
|
|
return canvas;
|
|
},
|
|
};
|
|
|
|
class UILayer {
|
|
/** @type {string} Layer ID */
|
|
id;
|
|
|
|
/** @type {string} Display name of the layer */
|
|
name;
|
|
|
|
/** @type {Layer} Associated real layer */
|
|
layer;
|
|
|
|
/** @type {string} Custom key to access this layer */
|
|
key;
|
|
|
|
/** @type {string} The group the UI layer is on (for some categorization) */
|
|
group;
|
|
|
|
/** @type {boolean} If the layer displays the delete button */
|
|
deletable;
|
|
|
|
/** @type {HTMLElement} The entry element on the UI */
|
|
entry;
|
|
|
|
/** @type {boolean} [internal] Whether the layer is actually hidden right now */
|
|
_hidden;
|
|
|
|
/** @type {boolean} Whether the layer is hidden or not */
|
|
set hidden(v) {
|
|
if (v) {
|
|
this._hidden = true;
|
|
this.layer.hide(v);
|
|
this.entry && this.entry.classList.add("hidden");
|
|
} else {
|
|
this._hidden = false;
|
|
this.layer.unhide(v);
|
|
this.entry && this.entry.classList.remove("hidden");
|
|
}
|
|
}
|
|
get hidden() {
|
|
return this._hidden;
|
|
}
|
|
|
|
/** @type {CanvasRenderingContext2D} */
|
|
get ctx() {
|
|
return this.layer.ctx;
|
|
}
|
|
|
|
/** @type {HTMLCanvasElement} */
|
|
get canvas() {
|
|
return this.layer.canvas;
|
|
}
|
|
|
|
/**
|
|
* Creates a new UI Layer
|
|
*
|
|
* @param {string} name Display name of the layer
|
|
* @param {object} extra
|
|
* @param {string} extra.id The id of the layer to create
|
|
* @param {string} extra.group The group the layer is on (for some categorization)
|
|
* @param {string} extra.key Custom key to access this layer
|
|
* @param {string} extra.deletable If the layer displays the delete button
|
|
*/
|
|
constructor(name, extra = {}) {
|
|
defaultOpt(extra, {
|
|
id: null,
|
|
group: null,
|
|
key: null,
|
|
deletable: true,
|
|
});
|
|
|
|
this.layer = imageCollection.registerLayer(extra.key, {
|
|
id: extra.id,
|
|
name,
|
|
category: "user",
|
|
after:
|
|
(uil.layers.length > 0 && uil.layers[uil.layers.length - 1].layer) ||
|
|
bgLayer,
|
|
});
|
|
|
|
this.name = name;
|
|
this.id = this.layer.id;
|
|
this.key = extra.key;
|
|
this.group = extra.group;
|
|
this.deletable = extra.deletable;
|
|
|
|
this.hidden = false;
|
|
}
|
|
|
|
/**
|
|
* Register layer in uil
|
|
*/
|
|
register() {
|
|
uil.layers.push(this);
|
|
uil.layerIndex[this.id] = this;
|
|
uil.layerIndex[this.key] = this;
|
|
}
|
|
|
|
/**
|
|
* Removes layer registration from uil
|
|
*/
|
|
unregister() {
|
|
const index = uil.layers.findIndex((v) => v === this);
|
|
|
|
if (index === -1) throw new ReferenceError("Layer could not be found");
|
|
|
|
if (uil.active === this)
|
|
uil.active = uil.layers[index + 1] || uil.layers[index - 1];
|
|
uil.layers.splice(index, 1);
|
|
uil.layerIndex[this.id] = undefined;
|
|
uil.layerIndex[this.key] = undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Command for creating a new layer
|
|
*/
|
|
commands.createCommand(
|
|
"addLayer",
|
|
(title, opt, state) => {
|
|
const options = Object.assign({}, opt) || {};
|
|
defaultOpt(options, {
|
|
id: guid(),
|
|
group: null,
|
|
name: "New Layer",
|
|
key: null,
|
|
deletable: true,
|
|
});
|
|
|
|
if (!state.layer) {
|
|
let {id, name, group, key, deletable} = state;
|
|
|
|
if (!state.imported) {
|
|
id = options.id;
|
|
name = options.name;
|
|
group = options.group;
|
|
key = options.key;
|
|
deletable = options.deletable;
|
|
|
|
state.name = name;
|
|
state.group = group;
|
|
state.key = key;
|
|
state.deletable = deletable;
|
|
}
|
|
|
|
state.layer = new UILayer(name, {
|
|
id,
|
|
group,
|
|
key: key,
|
|
deletable: deletable,
|
|
});
|
|
|
|
if (state.hidden !== undefined) state.layer.hidden = state.hidden;
|
|
|
|
state.id = state.layer.id;
|
|
}
|
|
|
|
state.layer.register();
|
|
|
|
uil._syncLayers();
|
|
|
|
uil.active = state.layer;
|
|
},
|
|
(title, state) => {
|
|
state.layer.unregister();
|
|
|
|
uil._syncLayers();
|
|
},
|
|
{
|
|
exportfn(state) {
|
|
return {
|
|
id: state.layer.id,
|
|
hidden: state.layer.hidden,
|
|
|
|
name: state.layer.name,
|
|
group: state.group,
|
|
key: state.key,
|
|
deletable: state.deletable,
|
|
};
|
|
},
|
|
importfn(value, state) {
|
|
state.id = value.id;
|
|
state.hidden = value.hidden;
|
|
|
|
state.name = value.name;
|
|
state.group = value.group;
|
|
state.key = value.key;
|
|
state.deletable = value.deletable;
|
|
},
|
|
}
|
|
);
|
|
|
|
/**
|
|
* 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);
|
|
},
|
|
{
|
|
exportfn(state) {
|
|
return {
|
|
layer: state.layer.id,
|
|
position: state.position,
|
|
oldposition: state.oldposition,
|
|
};
|
|
},
|
|
importfn(value, state) {
|
|
state.layer = uil.layerIndex[value.layer];
|
|
state.position = value.position;
|
|
state.oldposition = value.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;
|
|
}
|
|
|
|
if (uil.active === state.layer)
|
|
uil.active =
|
|
uil.layers[state.position - 1] || uil.layers[state.position + 1];
|
|
uil.layers.splice(state.position, 1);
|
|
|
|
uil._syncLayers();
|
|
|
|
state.layer.hidden = true;
|
|
},
|
|
(title, state) => {
|
|
uil.layers.splice(state.position, 0, state.layer);
|
|
uil.active = state.layer;
|
|
|
|
uil._syncLayers();
|
|
|
|
state.layer.hidden = false;
|
|
},
|
|
{
|
|
exportfn(state) {
|
|
return {
|
|
layer: state.layer.id,
|
|
position: state.position,
|
|
};
|
|
},
|
|
importfn(value, state) {
|
|
state.layer = uil.layerIndex[value.layer];
|
|
state.position = value.position;
|
|
},
|
|
}
|
|
);
|
|
|
|
/**
|
|
* 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,
|
|
});
|
|
|
|
if (state.imported) {
|
|
state.layerS = uil.layerIndex[state.layerSID];
|
|
state.layerD = uil.layerIndex[state.layerDID];
|
|
}
|
|
|
|
if (!state.layerS) {
|
|
const layerS = options.layer || uil.active;
|
|
|
|
if (!layerS.deletable)
|
|
throw new TypeError(
|
|
"[layer.mergeLayer] Layer is a undeletable 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,
|
|
layer: state.layerD.layer,
|
|
},
|
|
{recordHistory: false}
|
|
);
|
|
state.delCommand = await commands.runCommand(
|
|
"deleteLayer",
|
|
"Merge Layer Delete",
|
|
{layer: state.layerS},
|
|
{recordHistory: false}
|
|
);
|
|
},
|
|
(title, state) => {
|
|
state.drawCommand.undo();
|
|
state.delCommand.undo();
|
|
},
|
|
{
|
|
redo: (title, options, state) => {
|
|
state.drawCommand.redo();
|
|
state.delCommand.redo();
|
|
},
|
|
exportfn(state) {
|
|
return {
|
|
layerS: state.layerS.id,
|
|
layerD: state.layerD.id,
|
|
};
|
|
},
|
|
importfn(value, state) {
|
|
state.layerSID = value.layerS;
|
|
state.layerDID = value.layerD;
|
|
},
|
|
}
|
|
);
|
|
|
|
commands.runCommand(
|
|
"addLayer",
|
|
"Initial Layer Creation",
|
|
{name: "Default Image Layer", key: "default", deletable: false},
|
|
{recordHistory: false}
|
|
);
|