openOutpaint/js/lib/util.js
Victor Seiji Hariki 42967f0a9a make cursors less finicky
Signed-off-by: Victor Seiji Hariki <victorseijih@gmail.com>
2022-12-21 18:29:11 -03:00

436 lines
12 KiB
JavaScript

/**
* Some type definitions before the actual code
*/
/**
* Simple Point Coordinate
*
* @typedef Point
* @property {number} x - x coordinate
* @property {number} y - y coordinate
*/
/**
* Represents a size
*/
class Size {
w = 0;
h = 0;
constructor({w, h} = {w: 0, h: 0}) {
this.w = w;
this.h = h;
}
}
/**
* Represents a simple bouding box
*/
class BoundingBox {
x = 0;
y = 0;
w = 0;
h = 0;
constructor({x, y, w, h} = {x: 0, y: 0, w: 0, h: 0}) {
this.x = x;
this.y = y;
this.w = w;
this.h = h;
}
contains(x, y) {
return (
this.x < x && this.y < y && x < this.x + this.w && y < this.y + this.h
);
}
/**
* Gets bounding box from two points
*
* @param {Point} start Coordinate
* @param {Point} end
*/
static fromStartEnd(start, end) {
const minx = Math.min(start.x, end.x);
const miny = Math.min(start.y, end.y);
const maxx = Math.max(start.x, end.x);
const maxy = Math.max(start.y, end.y);
return new BoundingBox({
x: minx,
y: miny,
w: maxx - minx,
h: maxy - miny,
});
}
}
/**
* A simple implementation of the Observer programming pattern
* @template [T=any] Message type
*/
class Observer {
/**
* List of handlers
* @type {Array<{handler: (msg: T) => void | Promise<void>, priority: number}>}
*/
_handlers = [];
/**
* Adds a observer to the events
*
* @param {(msg: T, state?: any) => void | Promise<void>} callback The function to run when receiving a message
* @param {number} priority The priority level of the observer
* @param {boolean} wait If the handler must be waited for before continuing
* @returns {(msg:T, state?: any) => void | Promise<void>} The callback we received
*/
on(callback, priority = 0, wait = false) {
this._handlers.push({handler: callback, priority, wait});
this._handlers.sort((a, b) => b.priority - a.priority);
return callback;
}
/**
* Removes a observer
*
* @param {(msg: T, state?: any) => void | Promise<void>} callback The function used to register the callback
* @returns {boolean} Whether the handler existed
*/
clear(callback) {
const index = this._handlers.findIndex((v) => v.handler === callback);
if (index === -1) return false;
this._handlers.splice(index, 1);
return true;
}
/**
* Sends a message to all observers
*
* @param {T} msg The message to send to the observers
* @param {any} state The initial state
*/
async emit(msg, state = {}) {
const promises = [];
for (const {handler, wait} of this._handlers) {
const run = async () => {
try {
await handler(msg, state);
} catch (e) {
console.warn("Observer failed to run handler");
console.warn(e);
}
};
if (wait) await run();
else promises.push(run());
}
return Promise.all(promises);
}
}
/**
* Static DOM utility functions
*/
class DOM {
static inputTags = new Set(["input", "textarea"]);
/**
* Checks if there is an active input
*
* @returns Whether there is currently an active input
*/
static hasActiveInput() {
return (
document.activeElement &&
this.inputTags.has(document.activeElement.tagName.toLowerCase())
);
}
}
/**
* Generates a simple UID in the format xxxx-xxxx-...-xxxx, with x being [0-9a-f]
*
* @param {number} [size] Number of quartets of characters to generate
* @returns {string} The new UID
*/
const guid = (size = 3) => {
const s4 = () => {
return Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
};
// returns id of format 'aaaa'-'aaaa'-'aaaa' by default
let id = "";
for (var i = 0; i < size - 1; i++) id += s4() + "-";
id += s4();
return id;
};
/**
* Returns a hash code from a string
*
* From https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript
*
* @param {String} str The string to hash
* @return {Number} A 32bit integer
*/
const hashCode = (str, seed = 0) => {
let h1 = 0xdeadbeef ^ seed,
h2 = 0x41c6ce57 ^ seed;
for (let i = 0, ch; i < str.length; i++) {
ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 =
Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^
Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 =
Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^
Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return 4294967296 * (2097151 & h2) + (h1 >>> 0);
};
/**
* Assigns defaults to an option object passed to the function.
*
* @template T Object Type
*
* @param {T} options Original options object
* @param {T} defaults Default values to assign
*/
function defaultOpt(options, defaults) {
Object.keys(defaults).forEach((key) => {
if (options[key] === undefined) options[key] = defaults[key];
});
}
/** Custom error for attempt to set read-only objects */
class ProxyReadOnlySetError extends Error {}
/**
* Makes a given object read-only; throws a ProxyReadOnlySetError exception if modification is attempted
*
* @template T Object Type
*
* @param {T} obj Object to be proxied
* @param {string} name Name for logging purposes
* @param {string[]} exceptions Parameters excepted from this restriction
* @returns {T} Proxied object, intercepting write attempts
*/
function makeReadOnly(obj, name = "read-only object", exceptions = []) {
return new Proxy(obj, {
set: (obj, prop, value) => {
if (!exceptions.some((v) => v === prop))
throw new ProxyReadOnlySetError(
`Tried setting the '${prop}' property on '${name}'`
);
obj[prop] = value;
},
});
}
/** Custom error for attempt to set write-once objects a second time */
class ProxyWriteOnceSetError extends Error {}
/**
* Makes a given object write-once; Attempts to overwrite an existing prop in the object will throw a ProxyWriteOnceSetError exception
*
* @template T Object Type
* @param {T} obj Object to be proxied
* @param {string} [name] Name for logging purposes
* @param {string[]} [exceptions] Parameters excepted from this restriction
* @returns {T} Proxied object, intercepting write attempts
*/
function makeWriteOnce(obj, name = "write-once object", exceptions = []) {
return new Proxy(obj, {
set: (obj, prop, value) => {
if (obj[prop] !== undefined && !exceptions.some((v) => v === prop))
throw new ProxyWriteOnceSetError(
`Tried setting the '${prop}' property on '${name}' after it was already set`
);
obj[prop] = value;
},
});
}
/**
* Snaps a single value to an infinite grid
*
* @param {number} i Original value to be snapped
* @param {number} [offset=0] Value to offset the grid. Should be in the rande [0, gridSize[
* @param {number} [gridSize=64] Size of the grid
* @returns an offset, in which [i + offset = (a location snapped to the grid)]
*/
function snap(i, offset = 0, gridSize = 64) {
let diff = i - offset;
if (diff < 0) {
diff += gridSize * Math.ceil(Math.abs(diff / gridSize));
}
const modulus = diff % gridSize;
var snapOffset = modulus;
if (modulus > gridSize / 2) snapOffset = modulus - gridSize;
if (snapOffset == 0) {
return snapOffset;
}
return -snapOffset;
}
/**
* Gets a bounding box centered on a given set of coordinates. Supports grid snapping
*
* @param {number} cx - x-coordinate of the center of the box
* @param {number} cy - y-coordinate of the center of the box
* @param {number} w - the width of the box
* @param {height} h - the height of the box
* @param {?number} gridSnap - The size of the grid to snap to
* @param {number} [offset=0] - How much to offset the grid by
* @returns {BoundingBox} - A bounding box object centered at (cx, cy)
*/
function getBoundingBox(cx, cy, w, h, gridSnap = null, offset = 0) {
const offs = {x: 0, y: 0};
const box = {x: 0, y: 0};
if (gridSnap) {
offs.x = snap(cx, offset, gridSnap);
offs.y = snap(cy, offset, gridSnap);
}
box.x = Math.round(offs.x + cx);
box.y = Math.round(offs.y + cy);
return new BoundingBox({
x: Math.floor(box.x - w / 2),
y: Math.floor(box.y - h / 2),
w: Math.round(w),
h: Math.round(h),
});
}
class NoContentError extends Error {}
/**
* Crops a given canvas to content, returning a new canvas object with the content in it.
*
* @param {HTMLCanvasElement} sourceCanvas Canvas to get a content crop from
* @param {object} options Extra options
* @param {number} [options.border=0] Extra border around the content
* @returns {{canvas: HTMLCanvasElement, bb: BoundingBox}} A new canvas with the cropped part of the image
*/
function cropCanvas(sourceCanvas, options = {}) {
defaultOpt(options, {border: 0});
const w = sourceCanvas.width;
const h = sourceCanvas.height;
const srcCtx = sourceCanvas.getContext("2d");
const offset = {
x: (srcCtx.origin && -srcCtx.origin.x) || 0,
y: (srcCtx.origin && -srcCtx.origin.y) || 0,
};
var imageData = srcCtx.getImageDataRoot(0, 0, w, h);
/** @type {BoundingBox} */
const bb = new BoundingBox();
let minx = Infinity;
let maxx = -Infinity;
let miny = Infinity;
let maxy = -Infinity;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
// lol i need to learn what this part does
const index = (y * w + x) * 4; // OHHH OK this is setting the imagedata.data uint8clampeddataarray index for the specified x/y coords
//this part i get, this is checking that 4th RGBA byte for opacity
if (imageData.data[index + 3] > 0) {
minx = Math.min(minx, x + offset.x);
maxx = Math.max(maxx, x + offset.x);
miny = Math.min(miny, y + offset.y);
maxy = Math.max(maxy, y + offset.y);
}
}
}
bb.x = minx - options.border;
bb.y = miny - options.border;
bb.w = maxx - minx + 1 + 2 * options.border;
bb.h = maxy - miny + 1 + 2 * options.border;
if (!Number.isFinite(maxx))
throw new NoContentError("Canvas has no content to crop");
var cutCanvas = document.createElement("canvas");
cutCanvas.width = bb.w;
cutCanvas.height = bb.h;
cutCanvas
.getContext("2d")
.drawImage(sourceCanvas, bb.x, bb.y, bb.w, bb.h, 0, 0, bb.w, bb.h);
return {canvas: cutCanvas, bb};
}
/**
* Downloads the content of a canvas to the disk, or opens it
*
* @param {Object} options - Optional Information
* @param {boolean} [options.cropToContent] - If we wish to crop to content first (default: true)
* @param {HTMLCanvasElement} [options.canvas] - The source canvas (default: visible)
* @param {string} [options.filename] - The filename to save as (default: '[ISO date] [Hours] [Minutes] [Seconds] openOutpaint image.png').\
* If null, opens image in new tab.
*/
function downloadCanvas(options = {}) {
defaultOpt(options, {
cropToContent: true,
canvas: uil.getVisible(imageCollection.bb),
filename:
new Date()
.toISOString()
.slice(0, 19)
.replace("T", " ")
.replace(":", " ") + " openOutpaint image.png",
});
var link = document.createElement("a");
link.target = "_blank";
if (options.filename) link.download = options.filename;
var croppedCanvas = options.cropToContent
? cropCanvas(options.canvas).canvas
: options.canvas;
if (croppedCanvas != null) {
croppedCanvas.toBlob((blob) => {
link.href = URL.createObjectURL(blob);
link.click();
});
}
}
/**
* Makes an element in a location
* @param {string} type Element Tag
* @param {number} x X coordinate of the element
* @param {number} y Y coordinate of the element
* @param {{x: number y: offset}} offset Offset to apply to the element
* @returns
*/
const makeElement = (
type,
x,
y,
offset = {
x: -imageCollection.inputOffset.x,
y: -imageCollection.inputOffset.y,
}
) => {
const el = document.createElement(type);
el.style.position = "absolute";
el.style.left = `${x + offset.x}px`;
el.style.top = `${y + offset.y}px`;
// We can use the input element to add interactible html elements in the world
imageCollection.inputElement.appendChild(el);
return el;
};