Some more improvements.

This commit is contained in:
Yaro Kasear 2025-12-16 10:48:21 -06:00
parent 8d5ddca229
commit 9d22c55aba

View file

@ -17,13 +17,6 @@
margin: 0 auto;
}
#toolBar {
top: 10px;
transform: translateX(-50%);
z-index: 10000;
max-width: calc(100% - 20px);
}
#toolBar::-webkit-scrollbar {
height: 8px;
}
@ -39,17 +32,24 @@
pointer-events: none;
inset: 0;
}
#toolBar {
margin: 0 auto;
}
{% endblock %}
{% block main %}
<div class="container">
<div id="gridWrap">
<div id="grid" class="position-relative overflow-hidden">
<div id="toolBar"
class="btn-toolbar bg-light position-absolute border border-secondary-subtle rounded start-50 p-1 align-items-center flex-nowrap overflow-auto">
<div class="input-group input-group-sm w-auto">
class="btn-toolbar bg-light border border-bottom-0 rounded-bottom-0 border-secondary-subtle rounded p-1 align-items-center flex-nowrap overflow-auto">
<div class="input-group input-group-sm w-auto flex-nowrap">
<span class="input-group-text">Grid Size:</span>
<input type="number" min="1" value="25" name="gridSize" id="gridSize" class="form-control form-control-sm">
<span class="input-group-text">Fill Opacity:</span>
<input type="number" min="0" max="1" step=".01" name="fillOpacity" id="fillOpacity"
class="form-control form-control-sm">
</div>
<div class="vr mx-1"></div>
<input type="color" class="form-control form-control-sm form-control-color" id="color">
@ -58,8 +58,8 @@
<input type="radio" class="btn-check" id="outline" name="tool" checked>
<label for="outline"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-square" viewBox="0 0 16 16">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-square"
viewBox="0 0 16 16">
<path
d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z" />
</svg>
@ -77,8 +77,8 @@
<input type="radio" class="btn-check" id="outlineEllipse" name="tool">
<label for="outlineEllipse"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-circle" viewBox="0 0 16 16">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-circle"
viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16" />
</svg>
</label>
@ -103,8 +103,8 @@
<input type="radio" class="btn-check" name="gridType" id="noGrid" checked>
<label for="noGrid"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-border" viewBox="0 0 16 16">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-border"
viewBox="0 0 16 16">
<path
d="M0 0h.969v.5H1v.469H.969V1H.5V.969H0zm2.844 1h-.938V0h.938zm1.875 0H3.78V0h.938v1zm1.875 0h-.938V0h.938zm.937 0V.969H7.5V.5h.031V0h.938v.5H8.5v.469h-.031V1zm2.813 0h-.938V0h.938zm1.875 0h-.938V0h.938zm1.875 0h-.938V0h.938zM15.5 1h-.469V.969H15V.5h.031V0H16v.969h-.5zM1 1.906v.938H0v-.938zm6.5.938v-.938h1v.938zm7.5 0v-.938h1v.938zM1 3.78v.938H0V3.78zm6.5.938V3.78h1v.938zm7.5 0V3.78h1v.938zM1 5.656v.938H0v-.938zm6.5.938v-.938h1v.938zm7.5 0v-.938h1v.938zM.969 8.5H.5v-.031H0V7.53h.5V7.5h.469v.031H1v.938H.969zm1.875 0h-.938v-1h.938zm1.875 0H3.78v-1h.938v1zm1.875 0h-.938v-1h.938zm1.875-.031V8.5H7.53v-.031H7.5V7.53h.031V7.5h.938v.031H8.5v.938zm1.875.031h-.938v-1h.938zm1.875 0h-.938v-1h.938zm1.875 0h-.938v-1h.938zm1.406 0h-.469v-.031H15V7.53h.031V7.5h.469v.031h.5v.938h-.5zM0 10.344v-.938h1v.938zm7.5 0v-.938h1v.938zm8.5-.938v.938h-1v-.938zM0 12.22v-.938h1v.938zm7.5 0v-.938h1v.938zm8.5-.938v.938h-1v-.938zM0 14.094v-.938h1v.938zm7.5 0v-.938h1v.938zm8.5-.938v.938h-1v-.938zM.969 16H0v-.969h.5V15h.469v.031H1v.469H.969zm1.875 0h-.938v-1h.938zm1.875 0H3.78v-1h.938v1zm1.875 0h-.938v-1h.938zm.937 0v-.5H7.5v-.469h.031V15h.938v.031H8.5v.469h-.031v.5zm2.813 0h-.938v-1h.938zm1.875 0h-.938v-1h.938zm1.875 0h-.938v-1h.938zm.937 0v-.5H15v-.469h.031V15h.469v.031h.5V16z" />
</svg>
@ -139,8 +139,7 @@
<div class="vr mx-1"></div>
<div class="btn-group">
<button type="button"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center"
id="export">
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center" id="export">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-download" viewBox="0 0 16 16">
<path
@ -153,8 +152,8 @@
<button type="button"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center"
id="importButton">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-upload" viewBox="0 0 16 16">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-upload"
viewBox="0 0 16 16">
<path
d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5" />
<path
@ -162,19 +161,20 @@
</svg>
</button>
<button type="button"
class="btn btn-sm btn-danger border d-inline-flex align-items-center justify-content-center"
id="clear">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-x-lg" viewBox="0 0 16 16">
class="btn btn-sm btn-danger border d-inline-flex align-items-center justify-content-center" id="clear">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-x-lg"
viewBox="0 0 16 16">
<path
d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8z" />
</svg>
</button>
</div>
</div>
<div id="gridWrap">
<div id="grid" class="position-relative overflow-hidden">
<span id="dot" class="position-absolute p-0 m-0 d-none">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"
id="dotSVG">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" id="dotSVG">
<circle cx="16" cy="16" r="4" fill="black" />
</svg>
</span>
@ -201,6 +201,7 @@ const importButtonEl = document.getElementById('importButton');
const importEl = document.getElementById('import');
const gridSizeEl = document.getElementById('gridSize');
const gridWrapEl = document.getElementById('gridWrap');
const toolBarEl = document.getElementById('toolBar');
let doc = loadDoc();
let gridSize = Number(doc.gridSize) || 25;
@ -234,10 +235,27 @@ resizeAndSetupCanvas();
window.addEventListener('resize', resizeAndSetupCanvas);
setGrid();
scheduleSnappedGridSize();
function isFiniteNum(n) { return Number.isFinite(Number(n)); }
function sanitizeShapes(list) {
return list.filter(s => {
if (!s || typeof s !== 'object') return false;
if (!['rect','ellipse','line'].include(s.type)) return false;
if (!s.color) s.color = '#000000';
if (s.type === 'line') {
return ['x1','y1','x2','y2'].every(k => isFiniteNum(s[k]));
} else {
return ['x','y','w','h'].every(k => isFiniteNum(s[k]));
}
});
}
function loadDoc() {
try { return JSON.parse(localStorage.getItem("gridDoc")) || DEFAULT_DOC; }
catch { return DEFAULT_DOC; }
try { return JSON.parse(localStorage.getItem("gridDoc")) || structuredClone(DEFAULT_DOC); }
catch { return structuredClone(DEFAULT_DOC); }
}
function saveDoc(nextDoc = doc) {
@ -267,7 +285,10 @@ function applySnappedGridSize() {
gridEl.style.width = `${snappedW}px`;
gridEl.style.height = `${snappedH}px`;
requestAnimationFrame(resizeAndSetupCanvas);
toolBarEl.style.width = `${snappedW}px`;
gridEl.getBoundingClientRect();
resizeAndSetupCanvas();
}
function scheduleSnappedGridSize() {
@ -280,8 +301,7 @@ function applyGridSize(newSize) {
if (!Number.isFinite(n) || n < 1) return;
gridSize = n;
doc.gridSize = gridSize;
saveDoc({ ...doc, shapes });
saveDoc({ ...doc, shapes, gridSize });
dotSize = Math.floor(Math.max(gridSize * 1.25, 32));
@ -324,11 +344,9 @@ function setActiveType(typeId) {
function snapToGrid(x, y) {
/*
For portability, we do not allow pixel coordinates in the data model
and only use pixels for rendering. We display both spaces on the screen
to ensure no matter the mode, the user is reasoning in the same two
coordinate spaces as they need. Thus, snapping will happen even if
the tool doesn't use it.
Shapes are stored in grid units (document units), not pixels.
1 unit renders as gridSize pixels, so changing gridSize rescales (zooms) the whole drawing.
Grid modes only affect snapping/visuals; storage is always in document units for portability.
*/
const rect = gridEl.getBoundingClientRect();
@ -362,7 +380,9 @@ function snapToGrid(x, y) {
ix,
iy,
x: snapX,
y: snapY
y: snapY,
localX,
localY
};
}
@ -401,10 +421,15 @@ function normalizeLine(shape) {
function resizeAndSetupCanvas() {
dpr = window.devicePixelRatio || 1;
const rect = canvasEl.getBoundingClientRect();
canvasEl.width = rect.width * dpr;
canvasEl.height = rect.height * dpr;
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);
@ -538,6 +563,7 @@ document.querySelectorAll('input[name="gridType"]').forEach(input => {
localStorage.setItem('gridType', input.id);
}
setGrid();
redrawAll();
});
});
@ -563,7 +589,7 @@ importEl.addEventListener('change', (e) => {
const loadedShapes = Array.isArray(data) ? data : data.shapes;
if (!Array.isArray(loadedShapes)) return;
shapes = loadedShapes;
shapes = sanitizeShapes(loadedShapes);
doc = {
version: Number(data?.version) || 1,
@ -571,7 +597,7 @@ importEl.addEventListener('change', (e) => {
shapes
};
saveDoc(data);
saveDoc(doc);
redrawAll();
} catch {
toastMessage('Failed to load data from JSON file.', 'danger');
@ -598,9 +624,7 @@ exportEl.addEventListener('click', () => {
clearEl.addEventListener('click', () => {
gridSize = 25;
shapes = [];
doc.gridSize = gridSize;
doc.shapes = shapes;
saveDoc(doc);
saveDoc({...doc, shapes, gridSize});
gridSizeEl.value = 25;
applyGridSize(25);
redrawAll();
@ -621,7 +645,7 @@ document.addEventListener('keydown', (e) => {
e.preventDefault();
if (shapes.length > 0) {
shapes.pop();
saveShapes();
saveDoc({ ...doc, shapes, gridSize });
redrawAll();
}
}
@ -632,10 +656,20 @@ document.addEventListener('keydown', (e) => {
}
});
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 } = snapToGrid(e.clientX, e.clientY);
const { ix, iy, x: snapX, y: snapY, localX, localY } = snapToGrid(e.clientX, e.clientY);
const renderX = snapX - dotSize / 2;
@ -649,7 +683,11 @@ gridEl.addEventListener('pointermove', (e) => {
dotEl.style.left = `${renderX}px`;
}
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) {
const tool = currentShape.tool;
@ -725,7 +763,7 @@ gridEl.addEventListener('pointerdown', (e) => {
x2: snapX,
y2: snapY,
color: selectedColor,
fill: document.getElementById('filled').checked
fill: (tool === 'filled' || tool === 'filledEllipse')
};
} else if (tool === 'outlineEllipse' || tool === 'filledEllipse') {
currentShape = {
@ -773,8 +811,7 @@ window.addEventListener('pointerup', (e) => {
if (finalShape) {
shapes.push(finalShape);
doc.shapes = shapes;
saveDoc(doc);
saveDoc({...doc, shapes, gridSize});
}
clearCanvas();