More features added to our draw widget.

This commit is contained in:
Yaro Kasear 2025-12-05 09:29:50 -06:00
parent 55f18b1cbe
commit d207f1da2c

View file

@ -8,10 +8,10 @@
#grid { #grid {
background-image: background-image:
linear-gradient(to right, #ccc 1px, transparent 1px), linear-gradient(to right, #ccc 1px, transparent 1px),
linear-gradient(to bottom, #ccc 1px, transparent 1px); linear-gradient(to bottom, #ccc 1px, transparent 1px);
background-size: var(--grid) var(--grid); background-size: var(--grid) var(--grid);
cursor: none; cursor: crosshair;
height: 80vh; height: 80vh;
width: 100%; width: 100%;
height: calc(round(nearest, 80vh, var(--grid)) + 1px); height: calc(round(nearest, 80vh, var(--grid)) + 1px);
@ -65,272 +65,334 @@
</label> </label>
<input type="radio" class="btn-check" id="line" name="tool"> <input type="radio" class="btn-check" id="line" name="tool">
<label for="line" class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center"> <label for="line"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
</label> </label>
</div> </div>
<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">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-floppy" viewBox="0 0 16 16">
<path d="M11 2H9v3h2z" />
<path
d="M1.5 0h11.586a1.5 1.5 0 0 1 1.06.44l1.415 1.414A1.5 1.5 0 0 1 16 2.914V14.5a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 0 14.5v-13A1.5 1.5 0 0 1 1.5 0M1 1.5v13a.5.5 0 0 0 .5.5H2v-4.5A1.5 1.5 0 0 1 3.5 9h9a1.5 1.5 0 0 1 1.5 1.5V15h.5a.5.5 0 0 0 .5-.5V2.914a.5.5 0 0 0-.146-.353l-1.415-1.415A.5.5 0 0 0 13.086 1H13v4.5A1.5 1.5 0 0 1 11.5 7h-7A1.5 1.5 0 0 1 3 5.5V1H1.5a.5.5 0 0 0-.5.5m3 4a.5.5 0 0 0 .5.5h7a.5.5 0 0 0 .5-.5V1H4zM3 15h10v-4.5a.5.5 0 0 0-.5-.5h-9a.5.5 0 0 0-.5.5z" />
</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">
<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>
<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="{{ dot_size }}" <svg xmlns="http://www.w3.org/2000/svg" width="{{ dot_size }}" height="{{ dot_size }}" viewBox="0 0 32 32"
height="{{ dot_size }}" 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>
<div id="coords" class="border border-black position-absolute d-none bg-warning-subtle px-1 py-0 user-select-none"></div> <div id="coords"
class="border border-black position-absolute d-none bg-warning-subtle px-1 py-0 user-select-none"></div>
<canvas id="overlay" class="w-100 h-100"></canvas> <canvas id="overlay" class="w-100 h-100"></canvas>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block script %} {% block script %}
const canvasEl = document.getElementById('overlay'); const canvasEl = document.getElementById('overlay');
const colorEl = document.getElementById('color'); const clearEl = document.getElementById('clear');
const coordsEl = document.getElementById('coords'); const colorEl = document.getElementById('color');
const dotEl = document.getElementById('dot'); const coordsEl = document.getElementById('coords');
const dotSVGEl = document.getElementById('dotSVG'); const dotEl = document.getElementById('dot');
const gridEl = document.getElementById('grid'); const dotSVGEl = document.getElementById('dotSVG');
const exportEl = document.getElementById('export');
const gridEl = document.getElementById('grid');
let ctx; let ctx;
let dpr = 1; let dpr = 1;
let selectedColor = '#000000'; let selectedColor;
let currentShape = null; let currentShape = null;
let shapes = []; let shapes = JSON.parse(localStorage.getItem('gridShapes')) || [];
resizeAndSetupCanvas(); resizeAndSetupCanvas();
window.addEventListener('resize', resizeAndSetupCanvas); window.addEventListener('resize', resizeAndSetupCanvas);
function getActiveTool() { function getActiveTool() {
const checked = document.querySelector('input[name="tool"]:checked'); const checked = document.querySelector('input[name="tool"]:checked');
return checked ? checked.id : 'outline'; return checked ? checked.id : 'outline';
}
function snapToGrid(x, y) {
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 maxIx = Math.floor(rect.width / {{ grid_size }});
const maxIy = Math.floor(rect.height / {{ grid_size }});
const ix = Math.min(Math.max(Math.round(localX / {{ grid_size }}), 0), maxIx);
const iy = Math.min(Math.max(Math.round(localY / {{ grid_size }}), 0), maxIy);
return {
ix,
iy,
x: ix * {{ grid_size }},
y: iy * {{ grid_size }}
};
}
function normalizeRect(shape) {
const x = Math.min(shape.x1, shape.x2);
const y = Math.min(shape.y1, shape.y2);
const w = Math.abs(shape.x2 - shape.x1);
const h = Math.abs(shape.y2 - shape.y1);
return {
type: 'rect',
x,
y,
w,
h,
color: shape.color,
fill: shape.fill
};
}
function normalizeLine(shape) {
return {
type: 'line',
x1: shape.x1,
y1: shape.y1,
x2: shape.x2,
y2: shape.y2,
color: shape.color
};
}
function resizeAndSetupCanvas() {
dpr = window.devicePixelRatio || 1;
const rect = canvasEl.getBoundingClientRect();
canvasEl.width = rect.width * dpr;
canvasEl.height = rect.height * dpr;
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);
} }
function snapToGrid(x, y) { redrawAll();
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; function redrawAll() {
const localY = clampedY - rect.top; if (!ctx) return;
const ix = Math.round(localX / {{ grid_size }}); clearCanvas();
const iy = Math.round(localY / {{ grid_size }}); shapes.forEach(drawShape);
return { }
ix,
iy, function drawShape(shape) {
x: ix * {{ grid_size }}, if (!ctx) return;
y: iy * {{ grid_size }}
}; ctx.save();
ctx.strokeStyle = shape.color || '#000000';
if (shape.type === 'rect') {
ctx.strokeRect(shape.x, shape.y, shape.w, shape.h);
if (shape.fill) {
ctx.globalAlpha = 0.15;
ctx.fillStyle = shape.color;
ctx.fillRect(shape.x, shape.y, shape.w, shape.h);
ctx.globalAlpha = 1;
}
} else if (shape.type === 'line') {
ctx.beginPath();
ctx.moveTo(shape.x1, shape.y1);
ctx.lineTo(shape.x2, shape.y2);
ctx.stroke();
} }
function normalizeRect(shape) { ctx.restore();
const x = Math.min(shape.x1, shape.x2); }
const y = Math.min(shape.y1, shape.y2);
const w = Math.abs(shape.x2 - shape.x1);
const h = Math.abs(shape.y2 - shape.y1);
return { function clearCanvas() {
type: 'rect', if (!ctx) return;
x, const rect = canvasEl.getBoundingClientRect();
y, ctx.clearRect(0, 0, rect.width, rect.height);
w, }
h,
color: shape.color, exportEl.addEventListener('click', () => {
fill: shape.fill const blob = new Blob([JSON.stringify(shapes, 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', () => {
shapes = [];
localStorage.removeItem('gridShapes');
redrawAll();
});
colorEl.addEventListener('input', () => {
selectedColor = document.getElementById('color').value;
const circle = dotSVGEl.querySelector('circle');
if (circle) {
circle.setAttribute('fill', selectedColor);
}
});
document.addEventListener('keydown', (e) => {
const key = e.key.toLowerCase();
if ((e.ctrlKey || e.metaKey) && key === 'z') {
e.preventDefault();
if (shapes.length > 0) {
shapes.pop();
localStorage.setItem('gridShapes', JSON.stringify(shapes));
redrawAll();
}
} }
function resizeAndSetupCanvas() { if (key === 'escape' && currentShape) {
dpr = window.devicePixelRatio || 1; currentShape = null;
const rect = canvasEl.getBoundingClientRect();
canvasEl.width = rect.width * dpr;
canvasEl.height = rect.height * dpr;
ctx = canvasEl.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
redrawAll(); redrawAll();
} }
});
function redrawAll() { gridEl.addEventListener('mousemove', (e) => {
if (!ctx) return; const { ix, iy, x: snapX, y: snapY } = snapToGrid(e.clientX, e.clientY);
coordsEl.innerText = `(${ix}, ${iy})`;
const renderX = snapX - {{ dot_size }} / 2;
const renderY = snapY - {{ dot_size }} / 2;
coordsEl.classList.remove('d-none');
dotEl.classList.remove('d-none');
dotEl.style.top = `${renderY}px`;
dotEl.style.left = `${renderX}px`;
if (currentShape) {
const tool = currentShape.tool;
clearCanvas(); clearCanvas();
shapes.forEach(drawShape); shapes.forEach(drawShape);
}
function drawShape(shape) {
if (!ctx) return;
ctx.save(); ctx.save();
ctx.strokeStyle = shape.color || '#000000'; ctx.setLineDash([5, 3]);
if (shape.type === 'rect') {
ctx.strokeRect(shape.x, shape.y, shape.w, shape.h);
if (shape.fill) {
ctx.globalAlpha = 0.15;
ctx.fillStyle = shape.color;
ctx.fillRect(shape.x, shape.y, shape.w, shape.h);
ctx.globalAlpha = 1;
}
} else if ( shape.type === 'line' ) {
ctx.beginPath();
ctx.moveTo(shape.x1, shape.y1);
ctx.lineTo(shape.x2, shape.y2);
ctx.stroke();
}
ctx.restore();
}
function clearCanvas() {
ctx.clearRect(0, 0, canvasEl.width / dpr, canvasEl.height / dpr);
}
colorEl.addEventListener('input', () => {
selectedColor = document.getElementById('color').value;
const circle = dotSVGEl.querySelector('circle');
if (circle) {
circle.setAttribute('fill', selectedColor);
}
});
document.addEventListener('keydown', (e) => {
const key = e.key.toLowerCase();
if ((e.ctrlKey || e.metaKey) && key === 'z') {
e.preventDefault();
shapes.pop();
redrawAll();
}
if (key === 'escape' && currentShape) {
currentShape = null;
redrawAll();
}
});
gridEl.addEventListener('mousemove', (e) => {
const { ix, iy, x: snapX, y: snapY } = snapToGrid(e.clientX, e.clientY);
coordsEl.innerText = `(${ix}, ${iy})`;
const renderX = snapX - {{ dot_size }} / 2;
const renderY = snapY - {{ dot_size }} / 2;
coordsEl.classList.remove('d-none');
dotEl.classList.remove('d-none');
dotEl.style.top = `${renderY}px`;
dotEl.style.left = `${renderX}px`;
if (currentShape) {
const tool = currentShape.tool;
clearCanvas();
shapes.forEach(drawShape);
ctx.save();
ctx.setLineDash([5, 3]);
if (tool === 'line') {
const previewLine = {
type: 'line',
x1: currentShape.x1,
y1: currentShape.y1,
x2: snapX,
y2: snapY,
color: currentShape.color
};
drawShape(previewLine);
} else {
const previewRect = normalizeRect({
...currentShape,
x2: snapX,
y2: snapY
});
drawShape(previewRect);
}
ctx.setLineDash([]);
ctx.restore();
}
});
gridEl.addEventListener('mouseleave', (e) => {
coordsEl.classList.add('d-none');
dotEl.classList.add('d-none');
});
gridEl.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
e.preventDefault();
if (e.target.closest('#toolBar')) return;
const {x: snapX, y: snapY} = snapToGrid(e.clientX, e.clientY);
const tool = getActiveTool();
if (tool === 'line') { if (tool === 'line') {
currentShape = { const previewLine = {
tool,
type: 'line',
x1: snapX,
y1: snapY,
x2: snapX,
y2: snapY,
color: selectedColor
};
} else {
currentShape = {
tool,
x1: snapX,
y1: snapY,
x2: snapX,
y2: snapY,
color: selectedColor,
fill: document.getElementById('filled').checked
};
}
});
window.addEventListener('mouseup', (e) => {
if (!currentShape) return;
const { x: snapX, y: snapY } = snapToGrid(e.clientX, e.clientY);
currentShape.x2 = snapX;
currentShape.y2 = snapY;
let finalShape = null;
if (currentShape.tool === 'line') {
finalShape = {
type: 'line', type: 'line',
x1: currentShape.x1, x1: currentShape.x1,
y1: currentShape.y1, y1: currentShape.y1,
x2: currentShape.x2, x2: snapX,
y2: currentShape.y2, y2: snapY,
color: currentShape.color color: currentShape.color
}; };
drawShape(previewLine);
} else { } else {
const rect = normalizeRect(currentShape); const previewRect = normalizeRect({
...currentShape,
if (rect.w > 0 && rect.h > 0) { x2: snapX,
finalShape = rect; y2: snapY
} });
drawShape(previewRect);
} }
if (finalShape) { ctx.setLineDash([]);
shapes.push(finalShape); ctx.restore();
}
});
gridEl.addEventListener('mouseleave', (e) => {
coordsEl.classList.add('d-none');
dotEl.classList.add('d-none');
});
gridEl.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
e.preventDefault();
if (e.target.closest('#toolBar')) return;
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
};
} else {
currentShape = {
tool,
x1: snapX,
y1: snapY,
x2: snapX,
y2: snapY,
color: selectedColor,
fill: document.getElementById('filled').checked
};
}
});
window.addEventListener('mouseup', (e) => {
if (!currentShape) return;
const { x: snapX, y: snapY } = snapToGrid(e.clientX, e.clientY);
currentShape.x2 = snapX;
currentShape.y2 = snapY;
let finalShape = null;
if (currentShape.tool === 'line') {
finalShape = normalizeLine(currentShape);
} else {
const rect = normalizeRect(currentShape);
if (rect.w > 0 && rect.h > 0) {
finalShape = rect;
} }
}
clearCanvas(); if (finalShape) {
shapes.forEach(drawShape); shapes.push(finalShape);
localStorage.setItem('gridShapes', JSON.stringify(shapes));
}
currentShape = null; clearCanvas();
}); shapes.forEach(drawShape);
currentShape = null;
});
{% endblock %} {% endblock %}