ref: 81d1e4e01d7001e6a0107792e6167136787295d6
dir: /paint.js/
import {Stoppable} from "./areas.js" import "path-data-polyfill/path-data-polyfill.js" let svgns = "http://www.w3.org/2000/svg" class PaintArea { element = document.createElement("div") #element #changeListeners = [] #zoom = 1 #x = 0 #y = 0 constructor(element) { this.#element = element this.element.classList.add("area", "paint") this.element.append(this.#element) let x = 0 let y = 0 this.element.addEventListener("pointermove", event => { if (!event.isPrimary) return x = event.x y = event.y }) this.element.addEventListener("wheel", event => { event.preventDefault() if (event.ctrlKey || event.metaKey) { this.zoom(1 / Math.exp((event.deltaX + event.deltaY) / 256), x, y) } else { if (event.shiftKey) { this.pan(-event.deltaY, -event.deltaX) } else { this.pan(-event.deltaX, -event.deltaY) } } }) this.element.addEventListener("contextmenu", event => event.preventDefault()) this.element.addEventListener("pointerdown", event => { if (!(event.buttons & ~1)) return event.preventDefault() }) this.element.addEventListener("pointermove", event => { if (!(event.buttons & ~1)) return event.preventDefault() this.pan(event.movementX, event.movementY) }) this.#updateTransform() } show() { this.#x = 0 this.#y = 0 this.#zoom = 1 this.#updateTransform() } save() { for (let fn of this.#changeListeners) fn() } onChanged(fn) { this.#changeListeners.push(fn) } zoom(zoom = 1, x, y) { let rect = this.element.getBoundingClientRect() x ??= rect.x + rect.width / 2 y ??= rect.y + rect.height / 2 let [x0, y0] = this.fromClient(x, y) this.#zoom *= zoom this.#updateTransform() let [x1, y1] = this.fromClient(x, y) this.pan((x1 - x0) * this.#zoom, (y1 - y0) * this.#zoom) } pan(x = 0, y = 0) { this.#x += x this.#y += y this.#updateTransform() } #updateTransform() { let minZoom = 16 ** 2 / Math.min(this.#element.offsetWidth, this.#element.offsetHeight) let maxZoom = 16 ** 4 / Math.max(this.#element.offsetWidth, this.#element.offsetHeight) this.#zoom = Math.min(Math.max(this.#zoom, minZoom), maxZoom) let width = this.#element.offsetWidth * this.#zoom let height = this.#element.offsetHeight * this.#zoom this.#x = Math.min(Math.max(this.#x, -width), width) this.#y = Math.min(Math.max(this.#y, -height), height) this.#element.style.setProperty("transform", `translate(${this.#x}px, ${this.#y}px) scale(${this.#zoom})`) } } class RasterPaintArea extends PaintArea { #costume #canvas constructor(costume) { let canvas = document.createElement("canvas") super(canvas) this.#canvas = canvas this.#canvas.width = 960 this.#canvas.height = 720 this.ctx = canvas.getContext("2d") this.ctx.lineCap = "round" this.ctx.lineJoin = "round" this.ctx.lineWidth = 16 this.ctx.filter = "url('#alias')" this.ctx.strokeStyle = "#A4E" this.#costume = costume } fromClient(x, y) { let rect = this.#canvas.getBoundingClientRect() return [(x - rect.x) * this.#canvas.width / rect.width, (y - rect.y) * this.#canvas.height / rect.height] } show() { let x = this.#canvas.width / 2 - this.#costume.x let y = this.#canvas.height / 2 - this.#costume.y this.ctx.clearRect(0, 0, this.#canvas.width, this.#canvas.height) this.ctx.drawImage(this.#costume.image, x, y) super.show() this.zoom(0.5) } async save() { let x0 = 0 let y0 = 0 let x1 = this.#canvas.width let y1 = this.#canvas.height let w = this.#canvas.width let image = this.ctx.getImageData(0, 0, x1, y1) outer: while (x0 < x1) { for (let y = y0 ; y < y1 ; y++) { if (image.data[(y * w + x0) * 4 + 3]) break outer } x0++ } outer: while (x0 < x1) { for (let y = y0 ; y < y1 ; y++) { if (image.data[(y * w + x1 - 1) * 4 + 3]) break outer } x1-- } outer: while (y0 < y1) { for (let x = x0 ; x < x1 ; x++) { if (image.data[(y0 * w + x) * 4 + 3]) break outer } y0++ } outer: while (y0 < y1) { for (let x = x0 ; x < x1 ; x++) { if (image.data[((y1 - 1) * w + x) * 4 + 3]) break outer } y1-- } if (x0 === x1 || y0 === y1) { x0 = 0 y0 = 0 x1 = 1 y1 = 1 } let canvas = new OffscreenCanvas(x1 - x0, y1 - y0) let ctx = canvas.getContext("2d") ctx.putImageData(this.ctx.getImageData(x0, y0, x1 - x0, y1 - y0), 0, 0) let blob = await canvas.convertToBlob() this.#costume.blob = blob this.#costume.x = this.#canvas.width / 2 - x0 this.#costume.y = this.#canvas.height / 2 - y0 super.save() } } class VectorPaintArea extends PaintArea { #costume #element constructor(costume) { let element = document.createElement("div") super(element) this.#costume = costume this.#element = document.createElementNS(svgns, "svg") element.append(this.#element) this.#element.setAttribute("width", 480) this.#element.setAttribute("height", 360) } fromClient(x, y) { let rect = this.#element.getBoundingClientRect() return [(x - rect.x) * this.#element.width.baseVal.value / rect.width, (y - rect.y) * this.#element.height.baseVal.value / rect.height] } async show() { this.#element.textContent = "" super.show() let iframe = document.createElement("iframe") document.body.append(iframe) iframe.style.setProperty("opacity", "0") iframe.style.setProperty("position", "fixed") iframe.style.setProperty("pointer-events", "none") iframe.sandbox.add("allow-same-origin") iframe.src = this.#costume.url await new Promise(resolve => iframe.addEventListener("load", resolve)) let doc = iframe.contentDocument for (let element0 of doc.querySelectorAll("*")) { let data = element0.getPathData?.({normalize: true}) data ??= window[element0.constructor.name]?.prototype.getPathData?.call(element0, {normalize: true}) if (!data) continue if (data.length === 0) continue let matrix = element0.getScreenCTM() for (let {values} of data) { for (let i = 0 ; i < values.length ; i += 2) { let point = new DOMPoint(values[i], values[i + 1]).matrixTransform(matrix) values[i] = point.x + this.#element.width.baseVal.value / 2 - this.#costume.x values[i + 1] = point.y + this.#element.height.baseVal.value / 2 - this.#costume.y } } let data0 let data1 = [] for (let segment of data) { if (segment.type === "M") { data0 = [] data1.push(data0) } if (segment.type === "L") { let [x0, y0] = data0.at(-1).values.slice(-2) let [x3, y3] = segment.values let [x1, y1] = [2 * x0 / 3 + x3 / 3, 2 * y0 / 3 + y3 / 3] let [x2, y2] = [x0 / 3 + 2 * x3 / 3, y0 / 3 + 2 * y3 / 3] segment = {type: "C", values: [x1, y1, x2, y2, x3, y3]} } data0.push(segment) } for (let data of data1) { let element1 = document.createElementNS(svgns, "path") this.#element.append(element1) element1.setPathData(data) let style = getComputedStyle(element0) for (let name of ["fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin"]) { let value = style.getPropertyValue(name) if (value.match(/\burl\(/)) continue element1.style.setProperty(name, value) } } } iframe.remove() } save() { let x0 = Infinity let y0 = Infinity let x1 = -Infinity let y1 = -Infinity for (let path of this.#element.children) { let rect = path.getBBox() let style = getComputedStyle(path) let stroke = 0 if (style.getPropertyValue("stroke") !== "none") stroke = Number(style.getPropertyValue("stroke-width").slice(0, -2)) / 2 x0 = Math.min(x0, rect.x - stroke) y0 = Math.min(y0, rect.y - stroke) x1 = Math.max(x1, rect.x + rect.width + stroke) y1 = Math.max(y1, rect.y + rect.height + stroke) } if (x0 >= x1 || y0 >= y1) { x0 = 0 y0 = 0 x1 = 1 y1 = 1 } let element = this.#element.cloneNode(true) element.setAttribute("xmlns", svgns) for (let path of element.children) { let data = path.getPathData() for (let {values} of data) { for (let i = 0 ; i < values.length ; i += 2) { values[i] -= x0 values[i + 1] -= y0 } } path.setPathData(data) } element.setAttribute("width", x1 - x0) element.setAttribute("height", y1 - y0) this.#costume.blob = new Blob([element.outerHTML], {type: "image/svg+xml"}) this.#costume.x = this.#element.width.baseVal.value / 2 - x0 this.#costume.y = this.#element.height.baseVal.value / 2 - y0 super.save() } } class Tool { enabled = false changed = false constructor(area, stoppable) { this.area = area area.element.addEventListener("pointermove", event => { if (!this.enabled) return let x1 = event.x let y1 = event.y let x0 = x1 - event.movementX let y0 = y1 - event.movementY this.move?.(event, ...area.fromClient(x1, y1), ...area.fromClient(x0, y0)) }) area.element.addEventListener("pointerdown", event => { if (!this.enabled) return let [x, y] = area.fromClient(event.x, event.y) if (event.buttons & 1) this.click?.(event, x, y) }) stoppable.addEventListener("pointerup", event => { if (!this.enabled) return this.unclick?.(event) }) } async save() { if (!this.changed) return this.changed = false await this.area.save() } } class RasterBrush extends Tool { name = "brush" move(event, x1, y1, x0, y0) { if (!(event.buttons & 1)) return this.changed = true this.area.ctx.beginPath() this.area.ctx.moveTo(x0, y0) this.area.ctx.lineTo(x1, y1) this.area.ctx.stroke() } click(event, x, y) { this.move(event, x, y, x, y) } unclick() { this.save() } } class RasterEraser extends RasterBrush { name = "eraser" move(...args) { this.area.ctx.save() this.area.ctx.globalCompositeOperation = "destination-out" super.move(...args) this.area.ctx.restore() } } class VectorReshape extends Tool { name = "reshape" #element = document.createElementNS(svgns, "svg") #path = document.createElementNS(svgns, "g") #point = document.createElementNS(svgns, "g") #enabled = false #highlightedPath #highlightedPoint #lockedPath = false #lockedPoint = false constructor(...args) { super(...args) delete this.enabled this.#element.style.setProperty("pointer-events", "none") this.#element.append(this.#path, this.#point) } get enabled() { return this.#enabled } set enabled(enabled) { this.#enabled = enabled this.#path.textContent = "" this.#point.textContent = "" if (enabled) { let div = this.area.element.querySelector("div") let svg = div.querySelector("svg") div.append(this.#element) this.#element.setAttribute("width", svg.getAttribute("width")) this.#element.setAttribute("height", svg.getAttribute("height")) this.#element.setAttribute("fill", "#00F") this.#element.setAttribute("stroke", "#00F") this.#element.setAttribute("stroke-width", "0") } else { this.#element.remove() this.#highlightedPath = undefined this.#highlightedPoint = undefined } } move(event, x0, y0, x1, y1) { if (!this.#lockedPath) this.#highlightPath(event) if (!this.#lockedPoint) this.#highlightPoint(x0, y0) if (this.#lockedPoint && (event.buttons & 1)) this.#movePoint(x1, y1) } click(event, x, y) { this.#highlightPoint(x, y) this.#lockedPoint = Boolean(this.#highlightedPoint) this.#lockedPath = this.#lockedPoint if (this.#lockedPoint) return this.#highlightPath(event) this.#lockedPath = Boolean(this.#highlightedPath) } unclick() { this.save() } #highlightPath(event) { let path = event.target.closest("path") ?? undefined if (this.#highlightedPath === path) return this.#path.textContent = "" this.#highlightedPath = path if (!path) return let div = this.area.element.querySelector("div") let svg = div.querySelector("svg") if (!svg.contains(path)) return let data = path.getPathData() let path1 = document.createElementNS(svgns, "path") path1.setPathData(data) path1.setAttribute("fill", "none") path1.setAttribute("stroke-width", "0.5") this.#path.append(path1) for (let [i, {type, values}] of data.entries()) { if (type === "Z") continue let [x, y] = values.slice(-2) let circle = document.createElementNS(svgns, "circle") circle.setAttribute("r", "1") circle.setAttribute("cx", String(x)) circle.setAttribute("cy", String(y)) circle.dataset.index = String(i) this.#path.append(circle) } } #highlightPoint(x, y) { let point let distance = Infinity for (let point1 of this.#path.querySelectorAll("circle")) { let cx = Number(point1.getAttribute("cx")) let cy = Number(point1.getAttribute("cy")) let d = (x - cx) ** 2 + (y - cy) ** 2 if (d < distance) { point = point1 distance = d } } if (distance > 2) point = undefined if (this.#highlightedPoint === point) return this.#point.textContent = "" this.#highlightedPoint = point if (!point) return let data = this.#highlightedPath.getPathData() let index = Number(point.dataset.index) let index1 = index + 1 if (data[index].type === "C") { let circle1 = document.createElementNS(svgns, "circle") circle1.setAttribute("r", "1") circle1.dataset.index = String(index) circle1.dataset.offset = "2" this.#point.append(circle1) } if (data[index1]?.type === "C") { let circle1 = document.createElementNS(svgns, "circle") circle1.setAttribute("r", "1") circle1.dataset.index = String(index1) circle1.dataset.offset = "0" this.#point.append(circle1) } let [x1, y1] = data[index].values.slice(-2) for (let circle of [...this.#point.children]) { let index = Number(circle.dataset.index) let offset = Number(circle.dataset.offset) let [x2, y2] = data[index].values.slice(offset) circle.setAttribute("cx", String(x2)) circle.setAttribute("cy", String(y2)) circle.dataset.dx = String(x2 - x1) circle.dataset.dy = String(y2 - y1) let line = document.createElementNS(svgns, "line") line.setAttribute("x1", String(x1)) line.setAttribute("y1", String(y1)) line.setAttribute("x2", String(x2)) line.setAttribute("y2", String(y2)) line.setAttribute("stroke-width", "0.5") line.dataset.index = circle.dataset.index line.dataset.offset = circle.dataset.offset this.#point.append(line) } } #movePoint(x, y) { this.changed = true let index = Number(this.#highlightedPoint.dataset.index) let data = this.#highlightedPath.getPathData() let values = data[index].values values[values.length - 2] = x values[values.length - 1] = y for (let line of this.#point.querySelectorAll("line")) { let index = Number(line.dataset.index) let offset = Number(line.dataset.offset) let circle = this.#point.querySelector(`circle[data-index="${index}"][data-offset="${offset}"]`) let x2 = x + Number(circle.dataset.dx) let y2 = y + Number(circle.dataset.dy) line.setAttribute("x1", String(x)) line.setAttribute("y1", String(y)) line.setAttribute("x2", String(x2)) line.setAttribute("y2", String(y2)) circle.setAttribute("cx", String(x2)) circle.setAttribute("cy", String(y2)) let {values} = data[index] values[offset] = x2 values[offset + 1] = y2 } this.#highlightedPath.setPathData(data) this.#path.querySelector("path").setPathData(data) this.#highlightedPoint.setAttribute("cx", String(x)) this.#highlightedPoint.setAttribute("cy", String(y)) } } class ToolboxArea { element = document.createElement("div") tools = [] constructor(area, stoppable) { this.element.classList.add("area", "toolbox") let tools = area instanceof VectorPaintArea ? [VectorReshape] : [RasterBrush, RasterEraser] for (let Tool of tools) this.tools.push(new Tool(area, stoppable)) for (let tool of this.tools) { let button = document.createElement("button") button.append(getIcon(tool.name)) this.element.append(button) button.addEventListener("click", () => { for (let tool of this.tools) tool.enabled = false for (let button of this.element.querySelectorAll("button")) button.disabled =false tool.enabled = true button.disabled = true }) } this.element.append("note: costume editor is WIP") } show() { this.element.querySelector("button")?.click() } } export class CostumeAreas { #options #areas = new Map() #selecting = false costumeListElement = document.createElement("div") paintElement = document.createElement("div") toolboxElement = document.createElement("div") constructor(costumes, options = {}) { this.#options = options this.costumeListElement.classList.add("costume-list") this.paintElement.classList.add("paint-container") this.toolboxElement.classList.add("toolbox-container") for (let costume of costumes) this.#register(costume) } #register(costume) { let button = document.createElement("button") button.addEventListener("click", () => this.#select(costume)) this.costumeListElement.append(button) let image = document.createElement("img") image.src = costume.url button.append(image, costume.name) let stoppable = new Stoppable() let paint = costume.blob.type === "image/svg+xml" ? new VectorPaintArea(costume) : new RasterPaintArea(costume) let toolbox = new ToolboxArea(paint, stoppable) this.#areas.set(costume, {button, paint, toolbox, stoppable}) paint.onChanged(() => image.src = costume.url) } async #select(costume) { if (this.#selecting) return this.#selecting = true let areas = this.#areas.get(costume) for (let {button} of this.#areas.values()) button.disabled = false areas.button.disabled = true this.paintElement.textContent = "" this.toolboxElement.textContent = "" this.paintElement.append(areas.paint.element) this.toolboxElement.append(areas.toolbox.element) await areas.paint.show() areas.toolbox.show() this.#selecting = false this.#options.select?.(costume) } } function getIcon(name) { let span = document.createElement("span") span.insertAdjacentHTML("beforeend", icons[name]) span.classList.add("icon") let svg = span.querySelector("svg") svg.style.setProperty("height", "1.5em") svg.style.setProperty("vertical-align", "middle") return span } let icons = { brush: `<svg viewbox="0 0 24 24" fill="#48E" stroke="#444"><path d="M7 14c-1.66 0-3 1.34-3 3 0 1.31-1.16 2-2 2 .92 1.22 2.49 2 4 2 2.21 0 4-1.79 4-4 0-1.66-1.34-3-3-3zm13.71-9.37l-1.34-1.34c-.39-.39-1.02-.39-1.41 0L9 12.25 11.75 15l8.96-8.96c.39-.39.39-1.02 0-1.41z"/></svg>`, eraser: `<svg viewbox="0 0 24 24" fill="#48E" stroke="#444"><path d="M7 14c-1.66 0-3 1.34-3 3 0 1.31-1.16 2-2 2 .92 1.22 2.49 2 4 2 2.21 0 4-1.79 4-4 0-1.66-1.34-3-3-3zm13.71-9.37l-1.34-1.34c-.39-.39-1.02-.39-1.41 0L9 12.25 11.75 15l8.96-8.96c.39-.39.39-1.02 0-1.41z"/></svg>`, reshape: `<svg viewbox="0 0 24 24" fill="#48E" stroke="#444"><path d="M7 14c-1.66 0-3 1.34-3 3 0 1.31-1.16 2-2 2 .92 1.22 2.49 2 4 2 2.21 0 4-1.79 4-4 0-1.66-1.34-3-3-3zm13.71-9.37l-1.34-1.34c-.39-.39-1.02-.39-1.41 0L9 12.25 11.75 15l8.96-8.96c.39-.39.39-1.02 0-1.41z"/></svg>`, } document.body.insertAdjacentHTML("beforeend", ` <svg width="0" height="0" style="position: fixed; pointer-events: none;"> <filter id="alias"> <feComponentTransfer> <feFuncA type="discrete" tableValues="0 1" /> </feComponentTransfer> </filter> </svg>`) let css = ` .paint > * { display: grid; border-radius: 8px; background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2 2" stroke="none"><rect width="2" height="2" fill="%23FFF" /><path fill="%23EEF" d="M 0 0 0 1 2 1 2 2 1 2 1 0" /></svg>') center / 16px; } .paint > canvas { border-radius: 16px; image-rendering: crisp-edges; image-rendering: pixelated; background-size: 32px; } .paint > div > * { grid-area: 1 / 1; } .paint { filter: drop-shadow(0 0 6px #0002); } ` let style = document.createElement("style") style.textContent = css document.head.append(style)