shithub: scrax

ref: 81d1e4e01d7001e6a0107792e6167136787295d6
dir: /paint.js/

View raw version
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)