File size: 14,585 Bytes
b5ba7a5 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 |
/**
* 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;
/** @type {Point} */
get tl() {
return {x: this.x, y: this.y};
}
/** @type {Point} */
get tr() {
return {x: this.x + this.w, y: this.y};
}
/** @type {Point} */
get bl() {
return {x: this.x, y: this.y + this.h};
}
/** @type {Point} */
get br() {
return {x: this.x + this.w, y: this.y + this.h};
}
/** @type {Point} */
get center() {
return {x: this.x + this.w / 2, y: this.y + this.h / 2};
}
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,
});
}
/**
* Returns a transformed bounding box (using top-left, bottom-right points)
*
* @param {DOMMatrix} transform Transformation matrix to transform points
*/
transform(transform) {
return BoundingBox.fromStartEnd(
transform.transformPoint({x: this.x, y: this.y}),
transform.transformPoint({x: this.x + this.w, y: this.y + this.h})
);
}
}
/**
* 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() {
const active = document.activeElement;
const tag = active.tagName.toLowerCase();
const checkTag = this.inputTags.has(tag);
if (!checkTag) return false;
return tag !== "input" || active.type === "text";
}
}
/**
* 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 = config.gridSize) {
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;
};
/**
* Subtracts identical (or damn close) pixels from new dreams
* @param {HTMLCanvasElement} canvas
* @param {BoundingBox} bb
* @param {HTMLImageElement} bgImg
* @param {number}} blur
* @returns {HTMLCanvasElement}
*/
const subtractBackground = (canvas, bb, bgImg, blur = 0, threshold = 10) => {
// set up temp canvases
const bgCanvas = document.createElement("canvas");
const fgCanvas = document.createElement("canvas");
const returnCanvas = document.createElement("canvas");
bgCanvas.width = fgCanvas.width = returnCanvas.width = bb.w;
bgCanvas.height = fgCanvas.height = returnCanvas.height = bb.h;
const bgCtx = bgCanvas.getContext("2d");
const fgCtx = fgCanvas.getContext("2d");
const returnCtx = returnCanvas.getContext("2d");
returnCtx.rect(0, 0, bb.w, bb.h);
returnCtx.fill();
// draw previous "background" image
bgCtx.drawImage(bgImg, 0, 0, bb.w, bb.h);
bgCtx.filter = "blur(" + blur + "px)";
// ... turn that into base64
const bgImgData = bgCtx.getImageData(0, 0, bb.w, bb.h);
// draw new image
fgCtx.drawImage(canvas, 0, 0);
const fgImgData = fgCtx.getImageData(0, 0, bb.w, bb.h);
for (var i = 0; i < bgImgData.data.length; i += 4) {
// one of these days i'm gonna learn how to use map reduce or whatever and stop iterating in for loops :(
// a la https://adamwathan.me/refactoring-to-collections/
// background rgb
var bgr = bgImgData.data[i];
var bgg = bgImgData.data[i + 1];
var bgb = bgImgData.data[i + 2];
// foreground rgb
var fgr = fgImgData.data[i];
var fgb = fgImgData.data[i + 1];
var fgd = fgImgData.data[i + 2];
// delta rgb
const dr = Math.abs(bgr - fgr) > threshold ? fgr : 0;
const dg = Math.abs(bgg - fgb) > threshold ? fgb : 0;
const db = Math.abs(bgb - fgd) > threshold ? fgd : 0;
const pxChanged = dr > 0 && dg > 0 && db > 0;
fgImgData.data[i + 3] = pxChanged ? 255 : 0;
}
returnCtx.putImageData(fgImgData, 0, 0);
return returnCanvas;
};
|