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; margin: 0 auto;
} }
#toolBar {
top: 10px;
transform: translateX(-50%);
z-index: 10000;
max-width: calc(100% - 20px);
}
#toolBar::-webkit-scrollbar { #toolBar::-webkit-scrollbar {
height: 8px; height: 8px;
} }
@ -39,17 +32,24 @@
pointer-events: none; pointer-events: none;
inset: 0; inset: 0;
} }
#toolBar {
margin: 0 auto;
}
{% endblock %} {% endblock %}
{% block main %} {% block main %}
<div class="container"> <div class="container">
<div id="gridWrap">
<div id="grid" class="position-relative overflow-hidden">
<div id="toolBar" <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"> 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"> <div class="input-group input-group-sm w-auto flex-nowrap">
<span class="input-group-text">Grid Size:</span> <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"> <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>
<div class="vr mx-1"></div> <div class="vr mx-1"></div>
<input type="color" class="form-control form-control-sm form-control-color" id="color"> <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> <input type="radio" class="btn-check" id="outline" name="tool" checked>
<label for="outline" <label for="outline"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center"> 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" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-square"
class="bi bi-square" viewBox="0 0 16 16"> viewBox="0 0 16 16">
<path <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" /> 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> </svg>
@ -77,8 +77,8 @@
<input type="radio" class="btn-check" id="outlineEllipse" name="tool"> <input type="radio" class="btn-check" id="outlineEllipse" name="tool">
<label for="outlineEllipse" <label for="outlineEllipse"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center"> 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" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-circle"
class="bi bi-circle" viewBox="0 0 16 16"> 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" /> <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> </svg>
</label> </label>
@ -103,8 +103,8 @@
<input type="radio" class="btn-check" name="gridType" id="noGrid" checked> <input type="radio" class="btn-check" name="gridType" id="noGrid" checked>
<label for="noGrid" <label for="noGrid"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center"> 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" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-border"
class="bi bi-border" viewBox="0 0 16 16"> viewBox="0 0 16 16">
<path <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" /> 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> </svg>
@ -139,8 +139,7 @@
<div class="vr mx-1"></div> <div class="vr mx-1"></div>
<div class="btn-group"> <div class="btn-group">
<button type="button" <button type="button"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center" class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center" id="export">
id="export">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-download" viewBox="0 0 16 16"> class="bi bi-download" viewBox="0 0 16 16">
<path <path
@ -153,8 +152,8 @@
<button type="button" <button type="button"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center" class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center"
id="importButton"> id="importButton">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-upload"
class="bi bi-upload" viewBox="0 0 16 16"> viewBox="0 0 16 16">
<path <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" /> 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 <path
@ -162,19 +161,20 @@
</svg> </svg>
</button> </button>
<button type="button" <button type="button"
class="btn btn-sm btn-danger border d-inline-flex align-items-center justify-content-center" class="btn btn-sm btn-danger border d-inline-flex align-items-center justify-content-center" id="clear">
id="clear"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-x-lg"
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
class="bi bi-x-lg" viewBox="0 0 16 16">
<path <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" /> 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> </svg>
</button> </button>
</div> </div>
</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"> <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" <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" id="dotSVG">
id="dotSVG">
<circle cx="16" cy="16" r="4" fill="black" /> <circle cx="16" cy="16" r="4" fill="black" />
</svg> </svg>
</span> </span>
@ -201,6 +201,7 @@ const importButtonEl = document.getElementById('importButton');
const importEl = document.getElementById('import'); const importEl = document.getElementById('import');
const gridSizeEl = document.getElementById('gridSize'); const gridSizeEl = document.getElementById('gridSize');
const gridWrapEl = document.getElementById('gridWrap'); const gridWrapEl = document.getElementById('gridWrap');
const toolBarEl = document.getElementById('toolBar');
let doc = loadDoc(); let doc = loadDoc();
let gridSize = Number(doc.gridSize) || 25; let gridSize = Number(doc.gridSize) || 25;
@ -234,10 +235,27 @@ resizeAndSetupCanvas();
window.addEventListener('resize', resizeAndSetupCanvas); window.addEventListener('resize', resizeAndSetupCanvas);
setGrid(); 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() { function loadDoc() {
try { return JSON.parse(localStorage.getItem("gridDoc")) || DEFAULT_DOC; } try { return JSON.parse(localStorage.getItem("gridDoc")) || structuredClone(DEFAULT_DOC); }
catch { return DEFAULT_DOC; } catch { return structuredClone(DEFAULT_DOC); }
} }
function saveDoc(nextDoc = doc) { function saveDoc(nextDoc = doc) {
@ -267,7 +285,10 @@ function applySnappedGridSize() {
gridEl.style.width = `${snappedW}px`; gridEl.style.width = `${snappedW}px`;
gridEl.style.height = `${snappedH}px`; gridEl.style.height = `${snappedH}px`;
requestAnimationFrame(resizeAndSetupCanvas); toolBarEl.style.width = `${snappedW}px`;
gridEl.getBoundingClientRect();
resizeAndSetupCanvas();
} }
function scheduleSnappedGridSize() { function scheduleSnappedGridSize() {
@ -280,8 +301,7 @@ function applyGridSize(newSize) {
if (!Number.isFinite(n) || n < 1) return; if (!Number.isFinite(n) || n < 1) return;
gridSize = n; gridSize = n;
doc.gridSize = gridSize; saveDoc({ ...doc, shapes, gridSize });
saveDoc({ ...doc, shapes });
dotSize = Math.floor(Math.max(gridSize * 1.25, 32)); dotSize = Math.floor(Math.max(gridSize * 1.25, 32));
@ -324,11 +344,9 @@ function setActiveType(typeId) {
function snapToGrid(x, y) { function snapToGrid(x, y) {
/* /*
For portability, we do not allow pixel coordinates in the data model Shapes are stored in grid units (document units), not pixels.
and only use pixels for rendering. We display both spaces on the screen 1 unit renders as gridSize pixels, so changing gridSize rescales (zooms) the whole drawing.
to ensure no matter the mode, the user is reasoning in the same two Grid modes only affect snapping/visuals; storage is always in document units for portability.
coordinate spaces as they need. Thus, snapping will happen even if
the tool doesn't use it.
*/ */
const rect = gridEl.getBoundingClientRect(); const rect = gridEl.getBoundingClientRect();
@ -362,7 +380,9 @@ function snapToGrid(x, y) {
ix, ix,
iy, iy,
x: snapX, x: snapX,
y: snapY y: snapY,
localX,
localY
}; };
} }
@ -401,10 +421,15 @@ function normalizeLine(shape) {
function resizeAndSetupCanvas() { function resizeAndSetupCanvas() {
dpr = window.devicePixelRatio || 1; dpr = window.devicePixelRatio || 1;
const rect = canvasEl.getBoundingClientRect();
canvasEl.width = rect.width * dpr; const w = gridEl.clientWidth;
canvasEl.height = rect.height * dpr; 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 = canvasEl.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
@ -538,6 +563,7 @@ document.querySelectorAll('input[name="gridType"]').forEach(input => {
localStorage.setItem('gridType', input.id); localStorage.setItem('gridType', input.id);
} }
setGrid(); setGrid();
redrawAll();
}); });
}); });
@ -563,7 +589,7 @@ importEl.addEventListener('change', (e) => {
const loadedShapes = Array.isArray(data) ? data : data.shapes; const loadedShapes = Array.isArray(data) ? data : data.shapes;
if (!Array.isArray(loadedShapes)) return; if (!Array.isArray(loadedShapes)) return;
shapes = loadedShapes; shapes = sanitizeShapes(loadedShapes);
doc = { doc = {
version: Number(data?.version) || 1, version: Number(data?.version) || 1,
@ -571,7 +597,7 @@ importEl.addEventListener('change', (e) => {
shapes shapes
}; };
saveDoc(data); saveDoc(doc);
redrawAll(); redrawAll();
} catch { } catch {
toastMessage('Failed to load data from JSON file.', 'danger'); toastMessage('Failed to load data from JSON file.', 'danger');
@ -598,9 +624,7 @@ exportEl.addEventListener('click', () => {
clearEl.addEventListener('click', () => { clearEl.addEventListener('click', () => {
gridSize = 25; gridSize = 25;
shapes = []; shapes = [];
doc.gridSize = gridSize; saveDoc({...doc, shapes, gridSize});
doc.shapes = shapes;
saveDoc(doc);
gridSizeEl.value = 25; gridSizeEl.value = 25;
applyGridSize(25); applyGridSize(25);
redrawAll(); redrawAll();
@ -621,7 +645,7 @@ document.addEventListener('keydown', (e) => {
e.preventDefault(); e.preventDefault();
if (shapes.length > 0) { if (shapes.length > 0) {
shapes.pop(); shapes.pop();
saveShapes(); saveDoc({ ...doc, shapes, gridSize });
redrawAll(); 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) => { gridEl.addEventListener('pointermove', (e) => {
if (!ctx) return; 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; const renderX = snapX - dotSize / 2;
@ -649,7 +683,11 @@ gridEl.addEventListener('pointermove', (e) => {
dotEl.style.left = `${renderX}px`; dotEl.style.left = `${renderX}px`;
} }
coordsEl.innerText = `(x=${ix} (${snapX}px) y=${iy} (${snapY}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) { if (currentShape) {
const tool = currentShape.tool; const tool = currentShape.tool;
@ -725,7 +763,7 @@ gridEl.addEventListener('pointerdown', (e) => {
x2: snapX, x2: snapX,
y2: snapY, y2: snapY,
color: selectedColor, color: selectedColor,
fill: document.getElementById('filled').checked fill: (tool === 'filled' || tool === 'filledEllipse')
}; };
} else if (tool === 'outlineEllipse' || tool === 'filledEllipse') { } else if (tool === 'outlineEllipse' || tool === 'filledEllipse') {
currentShape = { currentShape = {
@ -773,8 +811,7 @@ window.addEventListener('pointerup', (e) => {
if (finalShape) { if (finalShape) {
shapes.push(finalShape); shapes.push(finalShape);
doc.shapes = shapes; saveDoc({...doc, shapes, gridSize});
saveDoc(doc);
} }
clearCanvas(); clearCanvas();