UNPKG

@onjmin/oekaki

Version:

レイヤー概念があるお絵描きパッケージ

632 lines (626 loc) 15.8 kB
"use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { Config: () => Config, LayeredCanvas: () => LayeredCanvas, LinkedList: () => LinkedList, brushSize: () => brushSize, color: () => color, eraserSize: () => eraserSize, flipped: () => flipped, floodFill: () => floodFill, getDotSize: () => getDotSize, getLayers: () => getLayers, getXY: () => getXY, init: () => init, lerp: () => lerp, lowerLayer: () => lowerLayer, onClick: () => onClick, onDraw: () => onDraw, onDrawn: () => onDrawn, penSize: () => penSize, refresh: () => refresh, render: () => render, setDotSize: () => setDotSize, setLayers: () => setLayers, upperLayer: () => upperLayer }); module.exports = __toCommonJS(index_exports); // src/flood-fill.ts var floodFill = (data, width, height, startX, startY, fillColor) => { const getPixel = (x, y) => { const index = (y * width + x) * 4; return [data[index], data[index + 1], data[index + 2], data[index + 3]]; }; const setPixel = (x, y, color2) => { const index = (y * width + x) * 4; [data[index], data[index + 1], data[index + 2], data[index + 3]] = color2; }; const colorsMatch = (a, b) => a.every((value, i) => value === b[i]); const targetColor = getPixel(startX, startY); if (colorsMatch(targetColor, fillColor)) return null; const queue = [[startX, startY]]; while (queue.length > 0) { const item = queue.pop(); if (!item) break; const [x, y] = item; if (x < 0 || y < 0 || x >= width || y >= height) continue; const currentColor = getPixel(x, y); if (!colorsMatch(currentColor, targetColor)) continue; setPixel(x, y, fillColor); queue.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]); } return data; }; // src/linked-list.ts var LinkedList = class { #cursor; constructor() { const node = { value: null, prev: null, next: null }; this.#cursor = node; } /** * 履歴を1つ追加 */ add(value) { const node = { value, prev: this.#cursor, next: null }; this.#cursor.next = node; this.#cursor = node; } /** * 履歴を1つ戻す */ undo() { const { prev } = this.#cursor; if (prev === null || prev.value === null) return null; this.#cursor = prev; return this.#cursor.value; } /** * 履歴を1つ進める */ redo() { const { next } = this.#cursor; if (next === null || next.value === null) return null; this.#cursor = next; return this.#cursor.value; } }; // src/layered-canvas.ts var g_layer_container = null; var g_width; var g_height; var g_dot_size; var g_lower; var g_upper; var g_serial_number = 0; var g_layers = []; var Config = class { #value; #reactive; constructor(defaultValue, reactive) { this.#value = defaultValue; this.#reactive = reactive ?? null; } get value() { return this.#value; } set value(next) { this.#value = next; this.#reactive?.(); } }; var color = new Config("#222222"); var brushSize = new Config(2); var penSize = new Config(16); var eraserSize = new Config(32); var flipped = new Config(false, () => { if (g_layer_container) g_layer_container.style.transform = `scaleX(${flipped.value ? -1 : 1})`; }); var getDotSize = () => g_dot_size; var setDotSize = (dotPenScale = 1, maxDotCount = 64, canvasLength = g_height) => { g_dot_size = Math.floor(Math.floor(canvasLength / maxDotCount) * dotPenScale); resetTranslation(); }; var accDx = 0; var accDy = 0; var snappedX = 0; var snappedY = 0; var offsetX = 0; var offsetY = 0; var translating = null; var resetTranslation = () => { accDx = 0; accDy = 0; snappedX = 0; snappedY = 0; offsetX = 0; offsetY = 0; translating = null; }; var getLayers = () => { const layers = []; for (const v of g_layers) { if (v) layers.push(v); } return layers; }; var insertAfter = (sp1, sp2) => g_layer_container?.insertBefore(sp1, sp2.nextSibling); var setLayers = (layers) => { for (const layer of g_layers) { layer?.canvas.remove(); } g_layers = layers; refresh(); let el = g_lower.canvas; for (const layer of g_layers) { if (layer) { insertAfter(layer.canvas, el); el = layer.canvas; } } }; var lowerLayer = new Config(null); var upperLayer = new Config(null); var init = (mountTarget, width = 640, height = 360) => { const layerContainer = document.createElement("div"); mountTarget.innerHTML = ""; mountTarget.append(layerContainer); g_layer_container = layerContainer; g_width = Math.floor(width); g_height = Math.floor(height); layerContainer.innerHTML = ""; layerContainer.style.position = "relative"; layerContainer.style.zIndex = "0"; layerContainer.style.display = "inline-block"; layerContainer.style.width = `${width}px`; layerContainer.style.height = `${height}px`; g_serial_number = 0; g_lower = new LayeredCanvas(""); g_upper = new LayeredCanvas(""); g_upper.canvas.style.zIndex = String(2 ** 16 + 3); lowerLayer.value = g_lower; upperLayer.value = g_upper; g_layers = []; }; var getXY = (e) => { const { clientX, clientY } = e; const rect = g_upper.canvas.getBoundingClientRect(); let x = Math.floor(clientX - rect.left); const y = Math.floor(clientY - rect.top); if (flipped.value) x = g_width - x; return [x, y, e.buttons]; }; var onClick = (callback) => { g_upper.canvas.addEventListener( "click", (e) => requestAnimationFrame(() => callback(...getXY(e))), { passive: true } ); g_upper.canvas.addEventListener("contextmenu", (e) => e.preventDefault()); g_upper.canvas.addEventListener("auxclick", (e) => { e.preventDefault(); requestAnimationFrame(() => callback(...getXY(e))); }); }; var onDraw = (callback) => { g_upper.canvas.addEventListener( "pointerdown", (e) => { resetTranslation(); g_upper.canvas.setPointerCapture(e.pointerId); drawing = true; requestAnimationFrame(() => callback(...getXY(e))); }, { passive: true } ); g_upper.canvas.addEventListener( "pointermove", (e) => { if (drawing) { for (const ev of e.getCoalescedEvents()) { requestAnimationFrame(() => callback(...getXY(ev))); } requestAnimationFrame(() => callback(...getXY(e))); } }, { passive: true } ); g_upper.canvas.addEventListener("touchstart", (e) => e.preventDefault()); g_upper.canvas.addEventListener("touchmove", (e) => e.preventDefault()); }; var drawing = false; var onDrawn = (callback) => { g_upper.canvas.addEventListener( "pointerup", (e) => { g_upper.canvas.releasePointerCapture(e.pointerId); drawing = false; requestAnimationFrame(() => callback(...getXY(e))); }, { passive: true } ); }; var LayeredCanvas = class { canvas; ctx; /** * レイヤー名 */ name; /** * 内部レイヤーリストの添え字 */ index; /** * レイヤーの描画履歴 */ history = new LinkedList(); /** * 差分検出用ハッシュ */ hash = 0; /** * レイヤーの可視性 */ #visible = true; /** * レイヤーの不透明度[%] */ #opacity = 100; /** * レイヤーロック */ locked = false; /** * 使用済みレイヤー */ used = false; /** * レイヤーの一意なid */ uuid; constructor(name = "", uuid = "") { this.name = name; this.uuid = uuid || crypto.randomUUID(); const canvas = document.createElement("canvas"); g_layer_container?.append(canvas); canvas.width = g_width; canvas.height = g_height; canvas.style.position = "absolute"; canvas.style.zIndex = String(++g_serial_number); canvas.style.left = "0"; canvas.style.top = "0"; this.canvas = canvas; const ctx = canvas.getContext("2d", { willReadFrequently: true }); if (!ctx) throw new Error("Failed to get 2D rendering context"); this.ctx = ctx; g_layers.push(this); this.index = g_layers.length - 1; this.trace(); } /** * ストレージなどに一時保存可能なレイヤー情報 */ get meta() { const { name, index, hash, visible, opacity, locked, used, uuid } = this; return { name, index, hash, visible, opacity, locked, used, uuid }; } /** * ストレージなどに一時保存可能なレイヤー情報 */ set meta(meta) { this.name = meta.name; this.index = meta.index; this.hash = meta.hash; this.visible = meta.visible; this.opacity = meta.opacity; this.locked = meta.locked; this.used = meta.used; this.uuid = meta.uuid; } /** * レイヤーの削除 */ delete() { g_layers[this.index] = null; this.canvas.remove(); } /** * 1つ背面のレイヤー */ get prev() { const prev = g_layers.slice(0, this.index).findLast((v) => v); return prev ? prev : null; } /** * 1つ前面のレイヤー */ get next() { const next = g_layers.slice(this.index + 1).find((v) => v); return next ? next : null; } /** * レイヤーの入れ替え */ swap(to) { const from = this.index; if (to === from) return; const that = g_layers[to]; if (!that) return; [g_layers[from], g_layers[to]] = [g_layers[to], g_layers[from]]; [this.index, that.index] = [that.index, this.index]; [this.canvas.style.zIndex, that.canvas.style.zIndex] = [ that.canvas.style.zIndex, this.canvas.style.zIndex ]; } /** * レイヤーの可視性 */ get visible() { return this.#visible; } /** * レイヤーの可視性 */ set visible(visible) { this.#visible = visible; this.canvas.style.visibility = this.#visible ? "visible" : "hidden"; } /** * レイヤーの不透明度[%] */ get opacity() { return this.#opacity; } /** * レイヤーの不透明度[%] */ set opacity(opacity) { this.#opacity = opacity; this.canvas.style.opacity = `${opacity}%`; } /** * レイヤーのUint8ClampedArray */ get data() { return this.ctx.getImageData(0, 0, g_width, g_height).data; } /** * レイヤーのUint8ClampedArray */ set data(data) { const imageData = new ImageData(data, g_width, g_height); this.ctx.putImageData(imageData, 0, 0); } /** * 差分検出 */ modified() { const hash = calcHash(this.data); if (this.hash !== hash) { this.hash = hash; this.used = true; return true; } return false; } /** * レイヤーの描画履歴の保存 */ trace() { this.history.add(this.data); } /** * レイヤーの描画履歴を1つ戻す */ undo() { if (this.locked) return; const data = this.history.undo(); if (!data) return; this.data = data; } /** * レイヤーの描画履歴を1つ進める */ redo() { if (this.locked) return; const data = this.history.redo(); if (!data) return; this.data = data; } /** * 全消し */ clear() { if (this.locked) return; this.ctx.clearRect(0, 0, g_width, g_height); } /** * ドット基準で平行移動 * * @param dx x差分 * @param dy y差分 */ translateByDot(dx, dy) { if (this.locked) return; const size = g_dot_size; accDx += dx; accDy += dy; const newSnappedX = Math.round(accDx / size) * size; const newSnappedY = Math.round(accDy / size) * size; if (newSnappedX !== snappedX || newSnappedY !== snappedY) { if (!translating) translating = this.ctx.getImageData(0, 0, g_width, g_height); this.clear(); this.ctx.putImageData(translating, newSnappedX, newSnappedY); snappedX = newSnappedX; snappedY = newSnappedY; } } /** * 平行移動 * * @param dx x差分 * @param dy y差分 */ translate(dx, dy) { if (this.locked) return; if (!translating) translating = this.ctx.getImageData(0, 0, g_width, g_height); offsetX += dx; offsetY += dy; this.clear(); this.ctx.putImageData( translating, Math.floor(offsetX), Math.floor(offsetY) ); } /** * ドット基準の消しゴム */ eraseByDot(x, y) { if (this.locked) return; const size = g_dot_size; const _x = Math.floor(x / size) * size; const _y = Math.floor(y / size) * size; this.ctx.clearRect(_x, _y, size, size); } /** * ドット基準のペン */ drawByDot(x, y) { if (this.locked) return; this.ctx.fillStyle = color.value; const size = g_dot_size; const _x = Math.floor(x / size) * size; const _y = Math.floor(y / size) * size; this.ctx.fillRect(_x, _y, size, size); } /** * 消しゴム */ erase(x, y) { if (this.locked) return; this.ctx.globalCompositeOperation = "destination-out"; this.ctx.beginPath(); this.ctx.arc(x, y, eraserSize.value >> 1, 0, Math.PI * 2); this.ctx.fill(); this.ctx.globalCompositeOperation = "source-over"; } /** * ペン */ draw(x, y) { if (this.locked) return; this.ctx.fillStyle = color.value; const size = penSize.value; const radius = size >> 1; this.ctx.fillRect(x - radius, y - radius, size, size); } /** * ブラシ */ drawLine(fromX, fromY, toX, toY) { if (this.locked) return; this.ctx.strokeStyle = color.value; this.ctx.lineWidth = brushSize.value; this.ctx.lineCap = "round"; this.ctx.beginPath(); this.ctx.moveTo(fromX, fromY); this.ctx.lineTo(toX, toY); this.ctx.stroke(); } }; var render = () => { const canvas = document.createElement("canvas"); canvas.width = g_width; canvas.height = g_height; const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Failed to get 2D rendering context"); for (const layer of g_layers) { if (!layer || !layer.visible) continue; ctx.globalAlpha = layer.opacity / 100; ctx.drawImage(layer.canvas, 0, 0); } return canvas; }; var refresh = () => { g_serial_number = 2; const layers = getLayers(); for (const [i, layer] of layers.entries()) { layer.index = i; layer.canvas.style.zIndex = String(++g_serial_number); } g_layers = layers; }; var calcHash = (data) => { let hash = 2166136261; for (let i = 0; i < data.length; i++) { hash ^= data[i]; hash = Math.imul(hash, 16777619); } return hash >>> 0; }; // src/lerp.ts var lerp = (x, y, _x, _y) => { const a = _x - x; const b = _y - y; const len = Math.max(...[a, b].map(Math.abs)) || 1; const _a = a / len; const _b = b / len; return [...new Array(len).keys()].map( (i) => [i * _a + x, i * _b + y].map(Math.round) ); }; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { Config, LayeredCanvas, LinkedList, brushSize, color, eraserSize, flipped, floodFill, getDotSize, getLayers, getXY, init, lerp, lowerLayer, onClick, onDraw, onDrawn, penSize, refresh, render, setDotSize, setLayers, upperLayer });