211 lines
7.1 KiB
JavaScript
211 lines
7.1 KiB
JavaScript
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;
|
|
export const COORDINATE_GUIDE_STEPS = Object.freeze([1, 2, 4, 8, 16, 32]);
|
|
export const COORDINATE_GUIDE_TARGET_SPACING = 48;
|
|
|
|
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(savedV3, savedV2 = null) {
|
|
const current = record(savedV3);
|
|
const legacy = record(savedV2);
|
|
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(source.pixelEditEnabled),
|
|
normalBrushSize: normalizeBrushSize(
|
|
source.normalBrushSize ?? source.brushSize,
|
|
BRUSH_MODE_NORMAL,
|
|
),
|
|
pixelBrushSize: normalizeBrushSize(source.pixelBrushSize, BRUSH_MODE_PIXEL),
|
|
coordinateGuideEnabled: Boolean(current?.coordinateGuideEnabled),
|
|
};
|
|
}
|
|
|
|
export function chooseCoordinateGuideStep(
|
|
logicalPixelCssSize,
|
|
targetSpacing = COORDINATE_GUIDE_TARGET_SPACING,
|
|
) {
|
|
const pixelSize = Math.max(0, Number(logicalPixelCssSize) || 0);
|
|
const target = Math.max(1, Number(targetSpacing) || COORDINATE_GUIDE_TARGET_SPACING);
|
|
return COORDINATE_GUIDE_STEPS.reduce((best, candidate) => (
|
|
Math.abs(candidate * pixelSize - target) < Math.abs(best * pixelSize - target)
|
|
? candidate
|
|
: best
|
|
), COORDINATE_GUIDE_STEPS[0]);
|
|
}
|
|
|
|
export function coordinateGuideTicks({
|
|
boardStart,
|
|
boardSize,
|
|
visibleStart = Number.NEGATIVE_INFINITY,
|
|
visibleEnd = Number.POSITIVE_INFINITY,
|
|
step,
|
|
count = 64,
|
|
origin = 0,
|
|
}) {
|
|
const start = Number(boardStart) || 0;
|
|
const size = Math.max(0, Number(boardSize) || 0);
|
|
const interval = COORDINATE_GUIDE_STEPS.includes(step) ? step : COORDINATE_GUIDE_STEPS[0];
|
|
const ticks = [];
|
|
for (let value = origin; value < origin + count; value += interval) {
|
|
const position = start + ((value-origin) / count) * size;
|
|
if (position >= visibleStart && position <= visibleEnd) ticks.push({ value, position });
|
|
}
|
|
return ticks;
|
|
}
|
|
|
|
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)),
|
|
};
|
|
}
|