openOutpaint/js/lib/layers.js

684 lines
17 KiB
JavaScript
Raw Normal View History

/**
* This is a manager for the many canvas and content layers that compose the application
*
* It manages canvases and their locations and sizes according to current viewport views
*/
/**
* Here is where the old magic is created.
*
* This is probably not recommended, but it works and
* is probably the most reliable way to not break everything.
*/
(() => {
const original = {
drawImage: CanvasRenderingContext2D.prototype.drawImage,
getImageData: CanvasRenderingContext2D.prototype.getImageData,
putImageData: CanvasRenderingContext2D.prototype.putImageData,
// Drawing methods
moveTo: CanvasRenderingContext2D.prototype.moveTo,
lineTo: CanvasRenderingContext2D.prototype.lineTo,
arc: CanvasRenderingContext2D.prototype.arc,
fillRect: CanvasRenderingContext2D.prototype.fillRect,
clearRect: CanvasRenderingContext2D.prototype.clearRect,
};
// Backing up original functions to <key>Root
Object.keys(original).forEach((key) => {
CanvasRenderingContext2D.prototype[key + "Root"] = function (...args) {
return original[key].call(this, ...args);
};
});
// Add basic get bounding box support (canvas coordinates)
Reflect.defineProperty(CanvasRenderingContext2D.prototype, "bb", {
get: function () {
return new BoundingBox({
x: -this.origin.x,
y: -this.origin.y,
w: this.canvas.width,
h: this.canvas.height,
});
},
});
// Modifying drawImage
Reflect.defineProperty(CanvasRenderingContext2D.prototype, "drawImage", {
value: function (...args) {
switch (args.length) {
case 3:
case 5:
if (this.origin !== undefined) {
args[1] += this.origin.x;
args[2] += this.origin.y;
}
break;
case 9:
// Check for origin on source
const sctx = args[0].getContext && args[0].getContext("2d");
if (sctx && sctx.origin !== undefined) {
args[1] += sctx.origin.x;
args[2] += sctx.origin.y;
}
// Check for origin on destination
if (this.origin !== undefined) {
args[5] += this.origin.x;
args[6] += this.origin.y;
}
break;
}
// Pass arguments through
return original.drawImage.call(this, ...args);
},
});
// Modifying getImageData method
Reflect.defineProperty(CanvasRenderingContext2D.prototype, "getImageData", {
value: function (...args) {
if (this.origin) {
args[0] += this.origin.x;
args[1] += this.origin.y;
}
// Pass arguments through
return original.getImageData.call(this, ...args);
},
});
// Modifying putImageData method
Reflect.defineProperty(CanvasRenderingContext2D.prototype, "putImageData", {
value: function (...args) {
if (this.origin) {
args[0] += this.origin.x;
args[1] += this.origin.y;
}
// Pass arguments through
return original.putImageData.call(this, ...args);
},
});
// Modifying moveTo method
Reflect.defineProperty(CanvasRenderingContext2D.prototype, "moveTo", {
value: function (...args) {
if (this.origin) {
args[0] += this.origin.x;
args[1] += this.origin.y;
}
// Pass arguments through
return original.moveTo.call(this, ...args);
},
});
// Modifying lineTo method
Reflect.defineProperty(CanvasRenderingContext2D.prototype, "lineTo", {
value: function (...args) {
if (this.origin) {
args[0] += this.origin.x;
args[1] += this.origin.y;
}
// Pass arguments through
return original.lineTo.call(this, ...args);
},
});
// Modifying arc
Reflect.defineProperty(CanvasRenderingContext2D.prototype, "arc", {
value: function (...args) {
if (this.origin) {
args[0] += this.origin.x;
args[1] += this.origin.y;
}
// Pass arguments through
return original.arc.call(this, ...args);
},
});
// Modifying fillRect
Reflect.defineProperty(CanvasRenderingContext2D.prototype, "fillRect", {
value: function (...args) {
if (this.origin) {
args[0] += this.origin.x;
args[1] += this.origin.y;
}
// Pass arguments through
return original.fillRect.call(this, ...args);
},
});
// Modifying clearRect
Reflect.defineProperty(CanvasRenderingContext2D.prototype, "clearRect", {
value: function (...args) {
if (this.origin) {
args[0] += this.origin.x;
args[1] += this.origin.y;
}
// Pass arguments through
return original.clearRect.call(this, ...args);
},
});
})();
// End of black magic
const layers = {
_collections: [],
collections: makeWriteOnce({}, "layers.collections"),
listen: {
oncollectioncreate: new Observer(),
oncollectiondelete: new Observer(),
onlayercreate: new Observer(),
onlayerdelete: new Observer(),
},
// Registers a new collection
// Layer collections are a group of layers (canvases) that are rendered in tandem. (same width, height, position, transform, etc)
/**
*
* @param {string} key A key used to identify the collection
* @param {Size} size The initial size of the collection in pixels (CSS size)
* @param {object} options Extra options for the collection
* @param {string} [options.name=key] The display name of the collection
* @param {{key: string, options: object}} [options.initLayer] The configuration for the initial layer to be created
* @param {number} [options.inputSizeMultiplier=9] Size of the input area element, in pixels
* @param {HTMLElement} [options.targetElement] Element the collection will be inserted into
* @param {Size} [options.resolution=size] The resolution of the collection (canvas size). Not sure it works.
* @returns {LayerCollection} The newly created layer collection
*/
registerCollection: (key, size, options = {}) => {
defaultOpt(options, {
// Display name for the collection
name: key,
// Initial layer
initLayer: {
key: "default",
options: {},
},
// Input multiplier (Size of the input element div)
inputSizeMultiplier: 9,
// Target
targetElement: document.getElementById("layer-render"),
// Resolution of the image
resolution: size,
});
if (options.inputSizeMultiplier % 2 === 0) options.inputSizeMultiplier++;
// Path used for logging purposes
const _logpath = "layers.collections." + key;
// Collection ID
const id = guid();
// Collection element
const element = document.createElement("div");
element.id = `collection-${id}`;
element.style.width = `${size.w}px`;
element.style.height = `${size.h}px`;
element.classList.add("collection");
// Input element (overlay element for input handling)
const inputel = document.createElement("div");
inputel.id = `collection-input-${id}`;
inputel.classList.add("collection-input-overlay");
element.appendChild(inputel);
options.targetElement.appendChild(element);
/** @type {LayerCollection} */
const collection = makeWriteOnce(
{
id,
_logpath,
_layers: [],
layers: {},
key,
name: options.name,
element,
inputElement: inputel,
_inputOffset: null,
get inputOffset() {
return this._inputOffset;
},
_origin: {x: 0, y: 0},
get origin() {
return {...this._origin};
},
get bb() {
return new BoundingBox({
x: -this.origin.x,
y: -this.origin.y,
w: this.size.w,
h: this.size.h,
});
},
_resizeInputDiv() {
// Set offset
const oldOffset = {...this._inputOffset};
this._inputOffset = {
x:
-Math.floor(options.inputSizeMultiplier / 2) * size.w -
this._origin.x,
y:
-Math.floor(options.inputSizeMultiplier / 2) * size.h -
this._origin.y,
};
// Resize the input element
this.inputElement.style.left = `${this.inputOffset.x}px`;
this.inputElement.style.top = `${this.inputOffset.y}px`;
this.inputElement.style.width = `${
size.w * options.inputSizeMultiplier
}px`;
this.inputElement.style.height = `${
size.h * options.inputSizeMultiplier
}px`;
// Move elements inside to new offset
for (const child of this.inputElement.children) {
if (child.style.position === "absolute") {
child.style.left = `${
parseInt(child.style.left, 10) +
oldOffset.x -
this._inputOffset.x
}px`;
child.style.top = `${
parseInt(child.style.top, 10) +
oldOffset.y -
this._inputOffset.y
}px`;
}
}
},
/**
* Expands the collection and its full layers by the specified amounts
*
* @param {number} left Pixels to expand left
* @param {number} top Pixels to expand top
* @param {number} right Pixels to expand right
* @param {number} bottom Pixels to expand bottom
*/
expand(left, top, right, bottom) {
this._layers.forEach((layer) => {
if (layer.full) layer._expand(left, top, right, bottom);
});
this._origin.x += left;
this._origin.y += top;
this.size.w += left + right;
this.size.h += top + bottom;
this._resizeInputDiv();
for (const layer of this._layers) {
layer.moveTo(layer.x, layer.y);
}
},
size,
resolution: options.resolution,
/**
* Registers a new layer
*
* @param {string | null} key Name and key to use to access layer. If null, it is a temporary layer.
* @param {object} options
* @param {string} options.id
* @param {string} options.name
* @param {?BoundingBox} options.bb
* @param {string} [options.category]
* @param {{w: number, h: number}} options.resolution
* @param {?string} options.group
* @param {object} options.after
* @param {object} options.ctxOptions
* @returns {Layer} The newly created layer
*/
registerLayer(key = null, options = {}) {
// Make ID
const id = options.id ?? guid();
defaultOpt(options, {
// ID of the layer
id: null,
// Display name for the layer
name: key || `Temporary ${id}`,
// Bounding box for layer
bb: {
x: -collection.origin.x,
y: -collection.origin.y,
w: collection.size.w,
h: collection.size.h,
},
// Category of the layer
category: null,
// Resolution for layer
resolution: null,
// Group for the layer ("group/subgroup/subsubgroup")
group: null,
// If set, will insert the layer after the given one
after: null,
// Context creation options
ctxOptions: {},
});
// Check if the layer is full
let full = false;
if (
options.bb.x === -collection.origin.x &&
options.bb.y === -collection.origin.y &&
options.bb.w === collection.size.w &&
options.bb.h === collection.size.h
)
full = true;
if (!options.resolution)
// Calculate resolution
options.resolution = {
w: Math.round(
(collection.resolution.w / collection.size.w) * options.bb.w
),
h: Math.round(
(collection.resolution.h / collection.size.h) * options.bb.h
),
};
// This layer's canvas
// This is where black magic will take place in the future
/**
* @todo Use the canvas black arts to auto-scale canvas
*/
const canvas = document.createElement("canvas");
canvas.id = `layer-${id}`;
canvas.style.left = `${options.bb.x}px`;
canvas.style.top = `${options.bb.y}px`;
canvas.style.width = `${options.bb.w}px`;
canvas.style.height = `${options.bb.h}px`;
canvas.width = options.resolution.w;
canvas.height = options.resolution.h;
if (!options.after) collection.element.appendChild(canvas);
else {
options.after.canvas.after(canvas);
}
/**
* Here we set the context origin for using the black magic.
*/
const ctx = canvas.getContext("2d", options.ctxOptions);
if (full) {
// Modify context to add origin information
ctx.origin = {
get x() {
return collection.origin.x;
},
get y() {
return collection.origin.y;
},
};
}
// Path used for logging purposes
const _layerlogpath = key
? _logpath + ".layers." + key
: _logpath + ".layers[" + id + "]";
const layer = makeWriteOnce(
{
_logpath: _layerlogpath,
_collection: collection,
_bb: new BoundingBox(options.bb),
get bb() {
return new BoundingBox(this._bb);
},
resolution: new Size(options.resolution),
id,
key,
name: options.name,
full,
category: options.category,
state: new Proxy(
{visible: true},
{
set(obj, opt, val) {
switch (opt) {
case "visible":
layer.canvas.style.display = val ? "block" : "none";
break;
}
obj[opt] = val;
},
}
),
get x() {
return this._bb.x;
},
get y() {
return this._bb.y;
},
get width() {
return this._bb.w;
},
get height() {
return this._bb.h;
},
get w() {
return this._bb.w;
},
get h() {
return this._bb.h;
},
get origin() {
return this._collection.origin;
},
get hidden() {
return !this.state.visible;
},
/** Our canvas */
canvas,
ctx,
/**
* This is called by the collection when the layer must be expanded.
*
* Should NOT be called directly
*
* @param {number} left Pixels to expand left
* @param {number} top Pixels to expand top
* @param {number} right Pixels to expand right
* @param {number} bottom Pixels to expand bottom
*/
_expand(left, top, right, bottom) {
const tmpCanvas = document.createElement("canvas");
tmpCanvas.width = this.w;
tmpCanvas.height = this.h;
tmpCanvas.getContext("2d").drawImage(this.canvas, 0, 0);
this.resize(this.w + left + right, this.h + top + bottom);
this.clear();
this.ctx.drawImageRoot(tmpCanvas, left, top);
this.moveTo(this.x - left, this.y - top);
},
/**
* Clears the layer contents
*/
clear() {
this.ctx.clearRectRoot(
0,
0,
this.canvas.width,
this.canvas.height
);
},
/**
* Recalculates DOM positioning
*/
syncDOM() {
this.moveTo(this.x, this.y);
this.resize(this.w, this.h);
},
/**
* Moves this layer to another level (after given layer)
*
* @param {Layer} layer Will move layer to after this one
*/
moveAfter(layer) {
layer.canvas.after(this.canvas);
},
/**
* Moves this layer to another level (before given layer)
*
* @param {Layer} layer Will move layer to before this one
*/
moveBefore(layer) {
layer.canvas.before(this.canvas);
},
/**
* Moves this layer to another location
*
* @param {number} x X coordinate of the top left of the canvas
* @param {number} y Y coordinate of the top left of the canvas
*/
moveTo(x, y) {
this._bb.x = x;
this._bb.y = y;
this.canvas.style.left = `${x}px`;
this.canvas.style.top = `${y}px`;
},
/**
* Resizes layer in place
*
* @param {number} w New width
* @param {number} h New height
*/
resize(w, h) {
canvas.width = Math.round(
options.resolution.w * (w / options.bb.w)
);
canvas.height = Math.round(
options.resolution.h * (h / options.bb.h)
);
this._bb.w = w;
this._bb.h = h;
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
},
// Hides this layer (don't draw)
hide() {
2023-02-18 12:56:53 -06:00
this.state.visible = false;
},
// Hides this layer (don't draw)
unhide() {
2023-02-18 12:56:53 -06:00
this.state.visible = true;
},
},
_layerlogpath
);
// Add to indexers
if (!options.after) collection._layers.push(layer);
else {
const index = collection._layers.findIndex(
(l) => l === options.after
);
collection._layers.splice(index, 0, layer);
}
if (key) collection.layers[key] = layer;
collection.layers[id] = layer;
if (key === null)
console.debug(
`[layers] Anonymous layer '${layer.name}' registered`
);
else
console.info(
`[layers] Layer '${layer.name}' at ${layer._logpath} registered`
);
layers.listen.onlayercreate.emit({
layer,
});
return layer;
},
/**
* Deletes a layer from the collection
*
* @param {Layer} layer Layer to delete
*/
deleteLayer: (layer) => {
const lobj = collection._layers.splice(
collection._layers.findIndex(
(l) => l.id === layer || l.id === layer.id
),
1
)[0];
if (!lobj) return;
layers.listen.onlayerdelete.emit({
layer: lobj,
});
if (lobj.key) collection.layers[lobj.key] = undefined;
collection.layers[lobj.id] = undefined;
collection.element.removeChild(lobj.canvas);
if (lobj.key) console.info(`[layers] Layer '${lobj.key}' deleted`);
else console.debug(`[layers] Anonymous layer '${lobj.id}' deleted`);
},
},
_logpath,
["_inputOffset"]
);
collection._resizeInputDiv();
layers._collections.push(collection);
layers.collections[key] = collection;
console.info(
`[layers] Collection '${options.name}' at ${_logpath} registered`
);
return collection;
},
};