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)