906 lines
No EOL
30 KiB
JavaScript
906 lines
No EOL
30 KiB
JavaScript
document.querySelectorAll('[data-grid-widget]').forEach((root, index) => {
|
|
initGridWidget(root, { storageKey: `gridDoc:${index}` });
|
|
});
|
|
|
|
|
|
let activeGridWidget = null;
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if (!activeGridWidget) return;
|
|
activeGridWidget.handleKeyDown(e);
|
|
});
|
|
|
|
|
|
function initGridWidget(root, opts = {}) {
|
|
const DEFAULT_DOC = { version: 1, cellSize: 25, shapes: [] };
|
|
const MAX_HISTORY = 100;
|
|
|
|
const canvasEl = root.querySelector('[data-canvas]');
|
|
const clearEl = root.querySelector('[data-clear]');
|
|
const colorEl = root.querySelector('[data-color]');
|
|
const coordsEl = root.querySelector('[data-coords]');
|
|
const dotEl = root.querySelector('[data-dot]');
|
|
const dotSVGEl = root.querySelector('[data-dot-svg]');
|
|
const exportEl = root.querySelector('[data-export]');
|
|
const gridEl = root.querySelector('[data-grid]');
|
|
const importButtonEl = root.querySelector('[data-import-button]');
|
|
const importEl = root.querySelector('[data-import]');
|
|
const cellSizeEl = root.querySelector('[data-cell-size]');
|
|
const gridWrapEl = root.querySelector('[data-grid-wrap]');
|
|
const toolBarEl = root.querySelector('[data-toolbar]');
|
|
const fillOpacityEl = root.querySelector('[data-fill-opacity]');
|
|
const strokeOpacityEl = root.querySelector('[data-stroke-opacity]');
|
|
const strokeWidthEl = root.querySelector('[data-stroke-width]');
|
|
const cellSizeValEl = root.querySelector('[data-cell-size-val]');
|
|
const fillValEl = root.querySelector('[data-fill-opacity-val]');
|
|
const strokeValEl = root.querySelector('[data-stroke-opacity-val]');
|
|
const widthValEl = root.querySelector('[data-stroke-width-val]');
|
|
|
|
if (cellSizeEl && cellSizeValEl) bindRangeWithLabel(cellSizeEl, cellSizeValEl, v => `${v}px`);
|
|
if (fillOpacityEl && fillValEl) bindRangeWithLabel(fillOpacityEl, fillValEl, v => `${Number(v) * 100}%`);
|
|
if (strokeOpacityEl && strokeValEl) bindRangeWithLabel(strokeOpacityEl, strokeValEl, v => `${Number(v) * 100}%`);
|
|
if (strokeWidthEl && widthValEl) bindRangeWithLabel(strokeWidthEl, widthValEl, v => `${Number(v) * 100}%`);
|
|
|
|
const storageKey = opts.storageKey ?? 'gridDoc';
|
|
|
|
let doc = loadDoc();
|
|
let shapes = sanitizeShapes(Array.isArray(doc.shapes) ? doc.shapes : []);
|
|
saveDoc({ ...doc, shapes });
|
|
|
|
const savedTool = localStorage.getItem(`${storageKey}:tool`);
|
|
if (savedTool) setActiveTool(savedTool);
|
|
|
|
const savedType = localStorage.getItem(`${storageKey}:gridType`);
|
|
if (savedType) setActiveType(savedType);
|
|
|
|
let cellSize = Number(doc.cellSize) || 25;
|
|
cellSizeEl.value = cellSize;
|
|
let dotSize = Math.floor(Math.max(cellSize * 1.25, 32));
|
|
|
|
let ctx;
|
|
let dpr = 1;
|
|
let selectedColor;
|
|
let currentFillOpacity = clamp01(fillOpacityEl?.value ?? 1, 1);
|
|
let currentStrokeOpacity = clamp01(strokeOpacityEl?.value ?? 1, 1);
|
|
let currentStrokeWidth = Number(strokeWidthEl?.value ?? 0.12) || 0.12;
|
|
|
|
let currentShape = null;
|
|
const history = [structuredClone(shapes)];
|
|
let historyIndex = 0
|
|
|
|
let sizingRAF = 0;
|
|
let lastApplied = { w: 0, h: 0 };
|
|
|
|
const ro = new ResizeObserver(scheduleSnappedCellSize);
|
|
ro.observe(gridWrapEl);
|
|
|
|
resizeAndSetupCanvas();
|
|
|
|
setGrid();
|
|
scheduleSnappedCellSize();
|
|
|
|
let activePointerId = null;
|
|
|
|
const api = {
|
|
handleKeyDown(e) {
|
|
const key = e.key.toLowerCase();
|
|
const t = e.target;
|
|
|
|
const isTextField = t && root.contains(t) && (t.matches('input, textarea, select') || t.isContentEditable);
|
|
|
|
if (isTextField) {
|
|
const isUndo = (e.ctrlKey || e.metaKey) && key === 'z';
|
|
const isRedo = (e.ctrlKey || e.metaKey) && (key === 'y' || (key === 'z' && e.shiftKey));
|
|
|
|
if (!isUndo && !isRedo) return;
|
|
}
|
|
|
|
if ((e.ctrlKey || e.metaKey) && key === 'z') {
|
|
e.preventDefault();
|
|
if (e.shiftKey) redo();
|
|
else undo();
|
|
return;
|
|
}
|
|
|
|
if ((e.ctrlKey || e.metaKey) && key === 'y') {
|
|
e.preventDefault();
|
|
redo();
|
|
return;
|
|
}
|
|
|
|
if (key === 'escape' && currentShape) {
|
|
e.preventDefault();
|
|
currentShape = null;
|
|
redrawAll();
|
|
}
|
|
}
|
|
};
|
|
|
|
root.addEventListener('focusin', () => { activeGridWidget = api; });
|
|
|
|
root.addEventListener('pointerdown', () => {
|
|
activeGridWidget = api;
|
|
}, { capture: true });
|
|
|
|
function bindRangeWithLabel(inputEl, labelEl, format = (v) => v) {
|
|
const sync = () => { labelEl.textContent = format(inputEl.value); };
|
|
inputEl.addEventListener('input', sync);
|
|
inputEl.addEventListener('change', sync);
|
|
sync();
|
|
}
|
|
|
|
function finishPointer(e) {
|
|
if (!currentShape) return;
|
|
if (activePointerId !== null && e.pointerId !== activePointerId) return;
|
|
|
|
onPointerUp(e);
|
|
activePointerId = null;
|
|
}
|
|
|
|
function renderAllWithPreview(previewShape = null, dashed = true) {
|
|
if (!ctx) return;
|
|
clearCanvas();
|
|
shapes.forEach(drawShape);
|
|
|
|
if (!previewShape) return;
|
|
|
|
ctx.save();
|
|
if (dashed) ctx.setLineDash([5, 3]);
|
|
drawShape(previewShape);
|
|
ctx.restore();
|
|
}
|
|
|
|
function penAddPoint(shape, clientX, clientY, minStep = 0.02) {
|
|
const p = pxToDocPoint(clientX, clientY);
|
|
const pts = shape.points;
|
|
const last = pts[pts.length - 1];
|
|
if (!last || dist2(p, last) >= minStep * minStep) pts.push(p);
|
|
}
|
|
|
|
function pxToDocPoint(clientX, clientY) {
|
|
const rect = gridEl.getBoundingClientRect();
|
|
const x = Math.min(Math.max(clientX, rect.left), rect.right) - rect.left;
|
|
const y = Math.min(Math.max(clientY, rect.top), rect.bottom) - rect.top;
|
|
return { x: pxToGrid(x), y: pxToGrid(y) };
|
|
}
|
|
|
|
function dist2(a, b) {
|
|
const dx = a.x - b.x, dy = a.y - b.y;
|
|
return dx * dx + dy * dy
|
|
}
|
|
|
|
function undo() {
|
|
if (historyIndex <= 0) return;
|
|
historyIndex--;
|
|
shapes = structuredClone(history[historyIndex]);
|
|
saveDoc({ ...doc, shapes, cellSize });
|
|
redrawAll();
|
|
}
|
|
|
|
function redo() {
|
|
if (historyIndex >= history.length - 1) return;
|
|
historyIndex++;
|
|
shapes = structuredClone(history[historyIndex]);
|
|
saveDoc({ ...doc, shapes, cellSize });
|
|
redrawAll();
|
|
}
|
|
|
|
function commit(nextShapes) {
|
|
history.splice(historyIndex + 1);
|
|
|
|
history.push(structuredClone(nextShapes));
|
|
historyIndex++;
|
|
|
|
if (history.length > MAX_HISTORY) {
|
|
const overflow = history.length - MAX_HISTORY;
|
|
history.splice(0, overflow);
|
|
historyIndex -= overflow;
|
|
if (historyIndex < 0) historyIndex = 0;
|
|
}
|
|
|
|
shapes = nextShapes;
|
|
|
|
saveDoc({ ...doc, shapes, cellSize });
|
|
redrawAll();
|
|
}
|
|
|
|
function clamp01(n, fallback = 1) {
|
|
const x = Number(n);
|
|
return Number.isFinite(x) ? Math.min(1, Math.max(0, x)) : fallback;
|
|
}
|
|
|
|
function isFiniteNum(n) { return Number.isFinite(Number(n)); }
|
|
|
|
function sanitizeShapes(list) {
|
|
const allowed = new Set(['rect', 'ellipse', 'line', 'path']);
|
|
|
|
const normStroke = (v, fallback = 0.12) => {
|
|
const n = Number(v);
|
|
if (!Number.isFinite(n)) return fallback;
|
|
return Math.max(0, n);
|
|
};
|
|
|
|
return list.flatMap((s) => {
|
|
if (!s || typeof s !== 'object' || !allowed.has(s.type)) return [];
|
|
|
|
const color = typeof s.color === 'string' ? s.color : '#000000';
|
|
const fillOpacity = clamp01(s.fillOpacity, 1);
|
|
const strokeOpacity = clamp01(s.strokeOpacity, 1);
|
|
|
|
if (s.type === 'line') {
|
|
if (!['x1', 'y1', 'x2', 'y2'].every(k => isFiniteNum(s[k]))) return [];
|
|
return [{
|
|
type: 'line',
|
|
x1: +s.x1, y1: +s.y1, x2: +s.x2, y2: +s.y2,
|
|
color,
|
|
strokeWidth: normStroke(s.strokeWidth),
|
|
strokeOpacity
|
|
}];
|
|
}
|
|
|
|
if (s.type === 'path') {
|
|
if (!Array.isArray(s.points) || s.points.length < 2) return [];
|
|
const points = s.points.flatMap(p => {
|
|
if (!p || !isFiniteNum(p.x) || !isFiniteNum(p.y)) return [];
|
|
return [{ x: +p.x, y: +p.y }];
|
|
});
|
|
if (points.length < 2) return [];
|
|
|
|
return [{
|
|
type: 'path',
|
|
points,
|
|
color,
|
|
strokeWidth: normStroke(s.strokeWidth, 0.12),
|
|
strokeOpacity
|
|
}];
|
|
}
|
|
|
|
if (!['x', 'y', 'w', 'h'].every(k => isFiniteNum(s[k]))) return [];
|
|
return [{
|
|
type: s.type,
|
|
x: +s.x, y: +s.y, w: +s.w, h: +s.h,
|
|
color,
|
|
fill: !!s.fill,
|
|
fillOpacity,
|
|
strokeOpacity,
|
|
strokeWidth: normStroke(s.strokeWidth)
|
|
}];
|
|
});
|
|
}
|
|
|
|
function loadDoc() {
|
|
try { return JSON.parse(localStorage.getItem(storageKey)) || structuredClone(DEFAULT_DOC); }
|
|
catch { return structuredClone(DEFAULT_DOC); }
|
|
}
|
|
|
|
function saveDoc(nextDoc = doc) {
|
|
doc = nextDoc;
|
|
try { localStorage.setItem(storageKey, JSON.stringify(nextDoc)); } catch { }
|
|
}
|
|
|
|
function snapDown(n, step) {
|
|
return Math.floor(n / step) * step;
|
|
}
|
|
|
|
function applySnappedCellSize() {
|
|
sizingRAF = 0;
|
|
|
|
const grid = cellSize;
|
|
if (!Number.isFinite(grid) || grid < 1) return;
|
|
|
|
const w = gridWrapEl.clientWidth;
|
|
const h = gridWrapEl.clientHeight;
|
|
|
|
const snappedW = snapDown(w, grid);
|
|
const snappedH = snapDown(h, grid);
|
|
|
|
if (snappedW === lastApplied.w && snappedH === lastApplied.h) return;
|
|
lastApplied = { w: snappedW, h: snappedH };
|
|
|
|
gridEl.style.width = `${snappedW}px`;
|
|
gridEl.style.height = `${snappedH}px`;
|
|
|
|
toolBarEl.style.maxWidth = `${snappedW}px`;
|
|
|
|
gridEl.getBoundingClientRect();
|
|
resizeAndSetupCanvas();
|
|
}
|
|
|
|
function scheduleSnappedCellSize() {
|
|
if (sizingRAF) return;
|
|
sizingRAF = requestAnimationFrame(applySnappedCellSize);
|
|
}
|
|
|
|
function applyCellSize(newSize) {
|
|
const n = Number(newSize);
|
|
if (!Number.isFinite(n) || n < 1) return;
|
|
|
|
cellSize = n;
|
|
saveDoc({ ...doc, shapes, cellSize });
|
|
|
|
dotSize = Math.floor(Math.max(cellSize * 1.25, 32));
|
|
|
|
dotSVGEl.setAttribute('width', dotSize);
|
|
dotSVGEl.setAttribute('height', dotSize);
|
|
|
|
setGrid();
|
|
scheduleSnappedCellSize();
|
|
}
|
|
|
|
function pxToGrid(v) {
|
|
return v / cellSize;
|
|
}
|
|
|
|
function getActiveTool() {
|
|
const checked = root.querySelector('input[data-tool]:checked');
|
|
return checked ? checked.value : 'pen';
|
|
}
|
|
|
|
function setActiveTool(toolValue) {
|
|
const el = root.querySelector(`input[data-tool][value="${CSS.escape(toolValue)}"]`);
|
|
if (el) el.checked = true;
|
|
}
|
|
|
|
function getActiveType() {
|
|
const checked = root.querySelector('input[data-gridtype]:checked');
|
|
return checked ? checked.value : 'noGrid';
|
|
}
|
|
|
|
function setActiveType(typeValue) {
|
|
const el = root.querySelector(`input[data-gridtype][value="${CSS.escape(typeValue)}"]`);
|
|
if (el) el.checked = true;
|
|
}
|
|
|
|
function snapToGrid(x, y) {
|
|
/*
|
|
Shapes are stored in grid units (document units), not pixels.
|
|
1 unit renders as cellSize pixels, so changing cellSize rescales (zooms) the whole drawing.
|
|
Grid modes only affect snapping/visuals; storage is always in document units for portability.
|
|
*/
|
|
|
|
const rect = gridEl.getBoundingClientRect();
|
|
const clampedX = Math.min(Math.max(x, rect.left), rect.right);
|
|
const clampedY = Math.min(Math.max(y, rect.top), rect.bottom);
|
|
|
|
const localX = clampedX - rect.left;
|
|
const localY = clampedY - rect.top;
|
|
|
|
const grid = cellSize;
|
|
const maxIx = Math.floor(rect.width / grid);
|
|
const maxIy = Math.floor(rect.height / grid);
|
|
|
|
// Math.round means we actually snap to the nearest dot. Math.floor
|
|
// makes for ugly snapping behavior where it only goes to the top left
|
|
// of the cell. Cells are not where we draw to, so floor is not good.
|
|
const ix = Math.min(Math.max(Math.round(localX / grid), 0), maxIx);
|
|
const iy = Math.min(Math.max(Math.round(localY / grid), 0), maxIy);
|
|
|
|
const type = getActiveType();
|
|
|
|
let snapX = localX;
|
|
let snapY = localY;
|
|
|
|
if (type === 'fullGrid' || type === 'verticalGrid') {
|
|
snapX = Math.min(ix * grid, rect.width);
|
|
}
|
|
|
|
if (type === 'fullGrid' || type === 'horizontalGrid') {
|
|
snapY = Math.min(iy * grid, rect.height);
|
|
}
|
|
|
|
return {
|
|
ix,
|
|
iy,
|
|
x: snapX,
|
|
y: snapY,
|
|
localX,
|
|
localY
|
|
};
|
|
}
|
|
|
|
function normalizeRect(shape) {
|
|
const x1 = pxToGrid(shape.x1);
|
|
const y1 = pxToGrid(shape.y1);
|
|
const x2 = pxToGrid(shape.x2);
|
|
const y2 = pxToGrid(shape.y2);
|
|
|
|
return {
|
|
type: 'rect',
|
|
x: Math.min(x1, x2),
|
|
y: Math.min(y1, y2),
|
|
w: Math.abs(x2 - x1),
|
|
h: Math.abs(y2 - y1),
|
|
color: shape.color,
|
|
fill: shape.fill,
|
|
fillOpacity: clamp01(shape.fillOpacity, 1),
|
|
strokeOpacity: clamp01(shape.strokeOpacity, 1),
|
|
strokeWidth: isFiniteNum(shape.strokeWidth) ? Math.max(0, +shape.strokeWidth) : 0.12
|
|
};
|
|
}
|
|
|
|
function normalizeEllipse(shape) {
|
|
const r = normalizeRect(shape);
|
|
return { ...r, type: 'ellipse' };
|
|
}
|
|
|
|
function normalizeLine(shape) {
|
|
return {
|
|
type: 'line',
|
|
x1: pxToGrid(shape.x1),
|
|
y1: pxToGrid(shape.y1),
|
|
x2: pxToGrid(shape.x2),
|
|
y2: pxToGrid(shape.y2),
|
|
color: shape.color,
|
|
strokeWidth: isFiniteNum(shape.strokeWidth) ? Math.max(0, +shape.strokeWidth) : 0.12,
|
|
strokeOpacity: clamp01(shape.strokeOpacity)
|
|
};
|
|
}
|
|
|
|
function resizeAndSetupCanvas() {
|
|
dpr = window.devicePixelRatio || 1;
|
|
|
|
const w = gridEl.clientWidth;
|
|
const h = gridEl.clientHeight;
|
|
|
|
canvasEl.width = Math.round(w * dpr);
|
|
canvasEl.height = Math.round(h * dpr);
|
|
|
|
canvasEl.style.width = `${w}px`;
|
|
canvasEl.style.height = `${h}px`;
|
|
|
|
ctx = canvasEl.getContext('2d');
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
|
|
selectedColor = colorEl.value || '#000000';
|
|
const circle = dotSVGEl.querySelector('circle');
|
|
if (circle) {
|
|
circle.setAttribute('fill', selectedColor);
|
|
}
|
|
|
|
redrawAll();
|
|
}
|
|
|
|
function redrawAll() {
|
|
if (!ctx || !shapes) return;
|
|
|
|
clearCanvas();
|
|
shapes.forEach(drawShape);
|
|
}
|
|
|
|
function drawShape(shape) {
|
|
if (!ctx) return;
|
|
const toPx = (v) => v * cellSize;
|
|
|
|
ctx.save();
|
|
ctx.strokeStyle = shape.color || '#000000';
|
|
ctx.lineWidth = Math.max(1, toPx(shape.strokeWidth ?? 0.12));
|
|
|
|
if (shape.type === 'rect' || shape.type === 'ellipse') {
|
|
const x = toPx(shape.x);
|
|
const y = toPx(shape.y);
|
|
const w = toPx(shape.w);
|
|
const h = toPx(shape.h);
|
|
|
|
ctx.globalAlpha = clamp01(shape.strokeOpacity, 1);
|
|
if (shape.type === 'rect') {
|
|
ctx.strokeRect(x, y, w, h);
|
|
} else {
|
|
const cx = x + w / 2;
|
|
const cy = y + h / 2;
|
|
ctx.beginPath();
|
|
ctx.ellipse(cx, cy, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
|
|
if (shape.fill) {
|
|
ctx.globalAlpha = clamp01(shape.fillOpacity, 1);
|
|
ctx.fillStyle = shape.color;
|
|
if (shape.type === 'rect') {
|
|
ctx.fillRect(x, y, w, h);
|
|
} else {
|
|
const cx = x + w / 2;
|
|
const cy = y + h / 2;
|
|
ctx.beginPath();
|
|
ctx.ellipse(cx, cy, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
|
|
ctx.fill()
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
} else if (shape.type === 'line') {
|
|
const x1 = toPx(shape.x1);
|
|
const y1 = toPx(shape.y1);
|
|
const x2 = toPx(shape.x2);
|
|
const y2 = toPx(shape.y2);
|
|
|
|
ctx.globalAlpha = clamp01(shape.strokeOpacity, 1);
|
|
ctx.beginPath();
|
|
ctx.moveTo(x1, y1);
|
|
ctx.lineTo(x2, y2);
|
|
ctx.stroke();
|
|
ctx.globalAlpha = 1;
|
|
} else if (shape.type === 'path') {
|
|
const toPx = (v) => v * cellSize;
|
|
|
|
ctx.globalAlpha = clamp01(shape.strokeOpacity, 1);
|
|
|
|
ctx.lineWidth = Math.max(1, toPx(shape.strokeWidth ?? 0.12));
|
|
ctx.lineJoin = 'round';
|
|
ctx.lineCap = 'round';
|
|
|
|
const pts = shape.points;
|
|
ctx.beginPath();
|
|
ctx.moveTo(toPx(pts[0].x), toPx(pts[0].y));
|
|
for (let i = 1; i < pts.length; i++) {
|
|
ctx.lineTo(toPx(pts[i].x), toPx(pts[i].y));
|
|
}
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
function clearCanvas() {
|
|
if (!ctx) return;
|
|
ctx.clearRect(0, 0, canvasEl.width / dpr, canvasEl.height / dpr);
|
|
}
|
|
|
|
function setGrid() {
|
|
const type = getActiveType();
|
|
|
|
gridEl.style.backgroundImage = "";
|
|
gridEl.style.backgroundSize = "";
|
|
gridEl.style.backgroundPosition = "";
|
|
gridEl.style.boxShadow = "none";
|
|
dotEl.classList.add('d-none');
|
|
|
|
// Minor dots
|
|
const dotPx = Math.max(1, Math.round(cellSize * 0.08));
|
|
const minorColor = '#ddd';
|
|
|
|
// Major dots (every 5 cells)
|
|
const majorStep = cellSize * 5;
|
|
const majorDotPx = Math.max(dotPx + 1, Math.round(cellSize * 0.12));
|
|
const majorColor = '#c4c4c4';
|
|
|
|
const minorLayer = `radial-gradient(circle, ${minorColor} ${dotPx}px, transparent ${dotPx}px)`;
|
|
const majorLayer = `radial-gradient(circle, ${majorColor} ${majorDotPx}px, transparent ${majorDotPx}px)`;
|
|
|
|
if (type === 'fullGrid') {
|
|
gridEl.style.backgroundImage = `${majorLayer}, ${minorLayer}`;
|
|
gridEl.style.backgroundSize = `${majorStep}px ${majorStep}px, ${cellSize}px ${cellSize}px`;
|
|
gridEl.style.backgroundPosition =
|
|
`${majorStep / 2}px ${majorStep / 2}px, ${cellSize / 2}px ${cellSize / 2}px`;
|
|
gridEl.style.boxShadow = "inset 0 0 0 1px #ccc";
|
|
|
|
} else if (type === 'verticalGrid') {
|
|
gridEl.style.backgroundImage = `${majorLayer}, ${minorLayer}`;
|
|
gridEl.style.backgroundSize = `${majorStep}px 100%, ${cellSize}px 100%`;
|
|
gridEl.style.backgroundPosition =
|
|
`${majorStep / 2}px 0px, ${cellSize / 2}px 0px`;
|
|
gridEl.style.boxShadow = "inset 0 1px 0 0 #ccc, inset 0 -1px 0 0 #ccc";
|
|
|
|
} else if (type === 'horizontalGrid') {
|
|
gridEl.style.backgroundImage = `${majorLayer}, ${minorLayer}`;
|
|
gridEl.style.backgroundSize = `100% ${majorStep}px, 100% ${cellSize}px`;
|
|
gridEl.style.backgroundPosition =
|
|
`0px ${majorStep / 2}px, 0px ${cellSize / 2}px`;
|
|
gridEl.style.boxShadow = "inset 1px 0 0 0 #ccc, inset -1px 0 0 0 #ccc";
|
|
|
|
} else { // noGrid
|
|
gridEl.style.boxShadow = "inset 0 0 0 1px #ccc";
|
|
}
|
|
}
|
|
|
|
function onPointerUp(e) {
|
|
if (!currentShape) return;
|
|
|
|
// Only finalize if this pointer is the captured one (or we failed to capture, sigh)
|
|
if (gridEl.hasPointerCapture?.(e.pointerId)) {
|
|
gridEl.releasePointerCapture(e.pointerId);
|
|
}
|
|
|
|
const { x: snapX, y: snapY } = snapToGrid(e.clientX, e.clientY);
|
|
|
|
currentShape.x2 = snapX;
|
|
currentShape.y2 = snapY;
|
|
|
|
let finalShape = null;
|
|
|
|
if (currentShape.tool === 'pen') {
|
|
const pts = currentShape.points;
|
|
|
|
if (pts.length >= 2) {
|
|
const simplified = [pts[0]];
|
|
const minStep = 0.03;
|
|
for (let i = 1; i < pts.length; i++) {
|
|
if (dist2(pts[i], simplified[simplified.length - 1]) >= minStep * minStep) {
|
|
simplified.push(pts[i]);
|
|
}
|
|
}
|
|
if (simplified.length >= 2) {
|
|
finalShape = { ...currentShape, points: simplified };
|
|
}
|
|
}
|
|
} else if (currentShape.tool === 'line') {
|
|
const line = normalizeLine(currentShape);
|
|
if (line.x1 !== line.x2 || line.y1 !== line.y2) finalShape = line;
|
|
|
|
} else if (currentShape.tool === 'filled' || currentShape.tool === 'outline') {
|
|
const rect = normalizeRect(currentShape);
|
|
if (rect.w > 0 && rect.h > 0) finalShape = rect;
|
|
|
|
} else if (currentShape.tool === 'filledEllipse' || currentShape.tool === 'outlineEllipse') {
|
|
const ellipse = normalizeEllipse(currentShape);
|
|
if (ellipse.w > 0 && ellipse.h > 0) finalShape = ellipse;
|
|
}
|
|
|
|
if (finalShape) commit([...shapes, finalShape]);
|
|
|
|
currentShape = null;
|
|
renderAllWithPreview(null); // clean final render
|
|
}
|
|
|
|
gridEl.addEventListener('pointerup', finishPointer);
|
|
window.addEventListener('pointerup', finishPointer);
|
|
window.addEventListener('pointercancel', finishPointer);
|
|
|
|
root.querySelectorAll('input[data-tool]').forEach((input) => {
|
|
input.addEventListener('change', () => {
|
|
if (input.checked) {
|
|
localStorage.setItem(`${storageKey}:tool`, input.value);
|
|
}
|
|
});
|
|
});
|
|
|
|
root.querySelectorAll('input[data-gridtype]').forEach((input) => {
|
|
input.addEventListener('change', () => {
|
|
if (input.checked) {
|
|
localStorage.setItem(`${storageKey}:gridType`, input.value);
|
|
}
|
|
setGrid();
|
|
redrawAll();
|
|
});
|
|
});
|
|
|
|
|
|
cellSizeEl.addEventListener('input', () => applyCellSize(cellSizeEl.value));
|
|
cellSizeEl.addEventListener('change', () => applyCellSize(cellSizeEl.value));
|
|
|
|
importButtonEl.addEventListener('click', () => importEl.click());
|
|
|
|
importEl.addEventListener('change', (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
try {
|
|
const data = JSON.parse(reader.result);
|
|
|
|
if (Number.isFinite(Number(data.cellSize)) && Number(data.cellSize) >= 1) {
|
|
cellSizeEl.value = data.cellSize;
|
|
applyCellSize(data.cellSize);
|
|
}
|
|
|
|
const loadedShapes = Array.isArray(data) ? data : data.shapes;
|
|
if (!Array.isArray(loadedShapes)) return;
|
|
|
|
shapes = sanitizeShapes(loadedShapes);
|
|
|
|
doc = {
|
|
version: Number(data?.version) || 1,
|
|
cellSize: Number(data?.cellSize) || cellSize,
|
|
shapes
|
|
};
|
|
|
|
saveDoc(doc);
|
|
|
|
history.length = 0;
|
|
history.push(structuredClone(shapes));
|
|
historyIndex = 0;
|
|
|
|
redrawAll();
|
|
} catch {
|
|
toastMessage('Failed to load data from JSON file.', 'danger');
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
});
|
|
|
|
exportEl.addEventListener('click', () => {
|
|
const payload = {
|
|
version: 1,
|
|
cellSize: cellSize,
|
|
shapes
|
|
};
|
|
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'grid-shapes.json';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
});
|
|
|
|
clearEl.addEventListener('click', () => {
|
|
cellSize = 25;
|
|
cellSizeEl.value = 25;
|
|
applyCellSize(25);
|
|
commit([]);
|
|
});
|
|
|
|
colorEl.addEventListener('input', () => {
|
|
selectedColor = colorEl.value || '#000000';
|
|
const circle = dotSVGEl.querySelector('circle');
|
|
if (circle) {
|
|
circle.setAttribute('fill', selectedColor);
|
|
}
|
|
});
|
|
|
|
fillOpacityEl?.addEventListener('input', () => {
|
|
currentFillOpacity = clamp01(fillOpacityEl.value, 1);
|
|
});
|
|
|
|
fillOpacityEl?.addEventListener('change', () => {
|
|
currentFillOpacity = clamp01(fillOpacityEl.value, 1);
|
|
});
|
|
|
|
strokeOpacityEl?.addEventListener('input', () => {
|
|
currentStrokeOpacity = clamp01(strokeOpacityEl.value, 1);
|
|
});
|
|
|
|
strokeOpacityEl?.addEventListener('change', () => {
|
|
currentStrokeOpacity = clamp01(strokeOpacityEl.value, 1);
|
|
});
|
|
|
|
strokeWidthEl?.addEventListener('input', () => {
|
|
currentStrokeWidth = Math.max(0, Number(strokeWidthEl.value) || 0.12);
|
|
});
|
|
|
|
strokeWidthEl?.addEventListener('change', () => {
|
|
currentStrokeWidth = Math.max(0, Number(strokeWidthEl.value) || 0.12);
|
|
});
|
|
|
|
gridEl.addEventListener('pointercancel', () => {
|
|
currentShape = null;
|
|
redrawAll();
|
|
});
|
|
|
|
gridEl.addEventListener('lostpointercapture', () => {
|
|
currentShape = null;
|
|
redrawAll();
|
|
});
|
|
|
|
gridEl.addEventListener('pointermove', (e) => {
|
|
if (!ctx) return;
|
|
|
|
const { ix, iy, x: snapX, y: snapY, localX, localY } = snapToGrid(e.clientX, e.clientY);
|
|
const tool = getActiveTool();
|
|
|
|
coordsEl.classList.remove('d-none');
|
|
|
|
if (getActiveType() !== 'noGrid' && tool !== 'pen') {
|
|
dotEl.classList.remove('d-none');
|
|
|
|
const gridRect = gridEl.getBoundingClientRect();
|
|
const wrapRect = gridWrapEl.getBoundingClientRect();
|
|
const offsetX = gridRect.left - wrapRect.left;
|
|
const offsetY = gridRect.top - wrapRect.top;
|
|
|
|
dotEl.style.left = `${offsetX + snapX}px`;
|
|
dotEl.style.top = `${offsetY + snapY}px`;
|
|
} else {
|
|
dotEl.classList.add('d-none');
|
|
}
|
|
|
|
if (getActiveType() == 'noGrid') {
|
|
coordsEl.innerText = `(px x=${Math.round(localX)} y=${Math.round(localY)})`;
|
|
} else {
|
|
coordsEl.innerText = `(x=${ix} (${snapX}px) y=${iy} (${snapY}px))`;
|
|
}
|
|
|
|
if (!currentShape) return;
|
|
|
|
// PEN: mutate points and preview the same shape object
|
|
if (currentShape.tool === 'pen') {
|
|
penAddPoint(currentShape, e.clientX, e.clientY, 0.02);
|
|
renderAllWithPreview(currentShape, true);
|
|
return;
|
|
}
|
|
|
|
// Other tools: build a normalized preview shape
|
|
let preview = null;
|
|
|
|
if (currentShape.tool === 'line') {
|
|
preview = normalizeLine({
|
|
x1: currentShape.x1,
|
|
y1: currentShape.y1,
|
|
x2: snapX,
|
|
y2: snapY,
|
|
color: currentShape.color,
|
|
strokeWidth: currentShape.strokeWidth,
|
|
strokeOpacity: currentShape.strokeOpacity
|
|
});
|
|
} else if (currentShape.tool === 'filled' || currentShape.tool === 'outline') {
|
|
preview = normalizeRect({ ...currentShape, x2: snapX, y2: snapY });
|
|
} else if (currentShape.tool === 'filledEllipse' || currentShape.tool === 'outlineEllipse') {
|
|
preview = normalizeEllipse({ ...currentShape, x2: snapX, y2: snapY });
|
|
}
|
|
|
|
renderAllWithPreview(preview, true);
|
|
});
|
|
|
|
gridEl.addEventListener('pointerleave', (e) => {
|
|
coordsEl.classList.add('d-none');
|
|
dotEl.classList.add('d-none');
|
|
});
|
|
|
|
gridEl.addEventListener('pointerdown', (e) => {
|
|
if (e.button !== 0) return;
|
|
|
|
if (e.target.closest('[data-toolbar]')) return;
|
|
|
|
e.preventDefault();
|
|
activePointerId = e.pointerId;
|
|
try {
|
|
gridEl.setPointerCapture(e.pointerId);
|
|
} catch {
|
|
// ignore: some browsers / scenarios won't allow capture
|
|
}
|
|
|
|
const { x: snapX, y: snapY } = snapToGrid(e.clientX, e.clientY);
|
|
const tool = getActiveTool();
|
|
|
|
if (tool === 'line') {
|
|
currentShape = {
|
|
tool,
|
|
type: 'line',
|
|
x1: snapX,
|
|
y1: snapY,
|
|
x2: snapX,
|
|
y2: snapY,
|
|
color: selectedColor,
|
|
strokeWidth: currentStrokeWidth,
|
|
strokeOpacity: currentStrokeOpacity
|
|
};
|
|
} else if (tool === 'outline' || tool === 'filled') {
|
|
currentShape = {
|
|
tool,
|
|
x1: snapX,
|
|
y1: snapY,
|
|
x2: snapX,
|
|
y2: snapY,
|
|
color: selectedColor,
|
|
fill: (tool === 'filled'),
|
|
fillOpacity: currentFillOpacity,
|
|
strokeOpacity: currentStrokeOpacity,
|
|
strokeWidth: currentStrokeWidth
|
|
};
|
|
} else if (tool === 'outlineEllipse' || tool === 'filledEllipse') {
|
|
currentShape = {
|
|
tool,
|
|
x1: snapX,
|
|
y1: snapY,
|
|
x2: snapX,
|
|
y2: snapY,
|
|
color: selectedColor,
|
|
fill: (tool === 'filledEllipse'),
|
|
fillOpacity: currentFillOpacity,
|
|
strokeOpacity: currentStrokeOpacity,
|
|
strokeWidth: currentStrokeWidth
|
|
};
|
|
} else if (tool === 'pen') {
|
|
const p = pxToDocPoint(e.clientX, e.clientY);
|
|
currentShape = {
|
|
tool,
|
|
type: 'path',
|
|
points: [p],
|
|
color: selectedColor,
|
|
strokeWidth: currentStrokeWidth,
|
|
strokeOpacity: currentStrokeOpacity
|
|
};
|
|
}
|
|
});
|
|
} |