export const BRUSH_MODE_NORMAL = "normal"; export const BRUSH_MODE_PIXEL = "pixel"; export const CANVAS_VIEW_MIN_SCALE = 1; export const CANVAS_VIEW_MAX_SCALE = 8; export const CANVAS_UNDO_LIMIT = 50; const MODE_SPECS = Object.freeze({ [BRUSH_MODE_NORMAL]: Object.freeze({ min: 1, max: 16, presets: Object.freeze([1, 2, 4, 8]) }), [BRUSH_MODE_PIXEL]: Object.freeze({ min: 1, max: 4, presets: Object.freeze([1, 2, 3, 4]) }), }); function record(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : null; } export function brushModeSpec(mode) { return MODE_SPECS[mode === BRUSH_MODE_PIXEL ? BRUSH_MODE_PIXEL : BRUSH_MODE_NORMAL]; } export function normalizeBrushSize(value, mode) { const spec = brushModeSpec(mode); const parsed = Number.parseInt(value, 10); return Math.max(spec.min, Math.min(spec.max, Number.isFinite(parsed) ? parsed : spec.min)); } export function restoreCanvasToolState(savedV2, savedV1 = null) { const current = record(savedV2); const legacy = record(savedV1); const source = current || legacy || {}; return { paintColor: typeof source.paintColor === "string" ? source.paintColor : null, backgroundColor: typeof source.backgroundColor === "string" ? source.backgroundColor : null, tool: source.tool === "eraser" ? "eraser" : "brush", pixelEditEnabled: Boolean(current?.pixelEditEnabled), normalBrushSize: normalizeBrushSize( current?.normalBrushSize ?? source.brushSize, BRUSH_MODE_NORMAL, ), pixelBrushSize: normalizeBrushSize(current?.pixelBrushSize, BRUSH_MODE_PIXEL), }; } export function linePoints(from, to) { const points = []; let x = from.x; let y = from.y; const dx = Math.abs(to.x - from.x); const sx = from.x < to.x ? 1 : -1; const dy = -Math.abs(to.y - from.y); const sy = from.y < to.y ? 1 : -1; let error = dx + dy; while (true) { points.push({ x, y }); if (x === to.x && y === to.y) break; const doubled = 2 * error; if (doubled >= dy) { error += dy; x += sx; } if (doubled <= dx) { error += dx; y += sy; } } return points; } export function brushStampPoints(centerX, centerY, size, mode, width = 64, height = 64) { const normalizedMode = mode === BRUSH_MODE_PIXEL ? BRUSH_MODE_PIXEL : BRUSH_MODE_NORMAL; const diameter = normalizeBrushSize(size, normalizedMode); const start = -Math.floor((diameter - 1) / 2); const radius = Math.max(0.25, (diameter - 0.5) / 2); const shapeCenter = (diameter - 1) / 2; const points = []; for (let localY = 0; localY < diameter; localY += 1) { for (let localX = 0; localX < diameter; localX += 1) { if (normalizedMode === BRUSH_MODE_NORMAL) { const normalizedX = (localX - shapeCenter) / radius; const normalizedY = (localY - shapeCenter) / radius; if ((normalizedX * normalizedX) + (normalizedY * normalizedY) > 1) continue; } const x = centerX + start + localX; const y = centerY + start + localY; if (x >= 0 && x < width && y >= 0 && y < height) points.push({ x, y }); } } return points; } export function pixelBuffersEqual(left, right) { if (!left || !right || left.length !== right.length) return false; for (let index = 0; index < left.length; index += 1) { if (left[index] !== right[index]) return false; } return true; } export class PixelUndoHistory { constructor(limit = CANVAS_UNDO_LIMIT) { this.limit = Math.max(1, Number.isInteger(limit) ? limit : CANVAS_UNDO_LIMIT); this.entries = []; } get size() { return this.entries.length; } record(before, after) { if (pixelBuffersEqual(before, after)) return false; this.entries.push(Uint8Array.from(before)); if (this.entries.length > this.limit) this.entries.splice(0, this.entries.length - this.limit); return true; } undo() { return this.entries.pop() || null; } clear() { this.entries.length = 0; } } export function normalizeCanvasScale(value) { const scale = Number(value); if (!Number.isFinite(scale)) return CANVAS_VIEW_MIN_SCALE; return Math.max(CANVAS_VIEW_MIN_SCALE, Math.min(CANVAS_VIEW_MAX_SCALE, scale)); } export function clampCanvasView( view, { viewportWidth, viewportHeight, baseSize, minVisible = 48 }, ) { const scale = normalizeCanvasScale(view?.scale); const width = Math.max(1, Number(baseSize) || 1) * scale; const height = width; const visibleX = Math.min(Math.max(1, Number(minVisible) || 1), width, viewportWidth); const visibleY = Math.min(Math.max(1, Number(minVisible) || 1), height, viewportHeight); const limitX = Math.max(0, (viewportWidth + width) / 2 - visibleX); const limitY = Math.max(0, (viewportHeight + height) / 2 - visibleY); return { scale, x: Math.max(-limitX, Math.min(limitX, Number(view?.x) || 0)), y: Math.max(-limitY, Math.min(limitY, Number(view?.y) || 0)), }; } export function zoomCanvasView(view, nextScale, anchor, bounds) { const scale = normalizeCanvasScale(nextScale); const previousScale = normalizeCanvasScale(view?.scale); const centerX = bounds.viewportWidth / 2; const centerY = bounds.viewportHeight / 2; const ratio = scale / previousScale; const x = (anchor.x - centerX) - ratio * (anchor.x - centerX - (Number(view?.x) || 0)); const y = (anchor.y - centerY) - ratio * (anchor.y - centerY - (Number(view?.y) || 0)); return clampCanvasView({ scale, x, y }, bounds); } export function clampFloatingButton(position, { viewportWidth, viewportHeight, size = 56, inset = 8, }) { const half = size / 2; const minX = inset + half; const maxX = Math.max(minX, viewportWidth - inset - half); const minY = inset + half; const maxY = Math.max(minY, viewportHeight - inset - half); return { x: Math.max(minX, Math.min(maxX, Number(position?.x) || minX)), y: Math.max(minY, Math.min(maxY, Number(position?.y) || minY)), }; }