Pen tool added.

This commit is contained in:
Conrad Nelson 2025-12-17 11:40:26 -06:00
parent c5cc368ef9
commit 24b74f78c0

View file

@ -60,7 +60,17 @@
<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">
<div class="vr mx-1"></div> <div class="vr mx-1"></div>
<div class="btn-group"> <div class="btn-group">
<input type="radio" class="btn-check" id="outline" name="tool" checked> <input type="radio" class="btn-check" id="pen" name="tool" checked>
<label for="pen"
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-pen"
viewBox="0 0 16 16">
<path
d="m13.498.795.149-.149a1.207 1.207 0 1 1 1.707 1.708l-.149.148a1.5 1.5 0 0 1-.059 2.059L4.854 14.854a.5.5 0 0 1-.233.131l-4 1a.5.5 0 0 1-.606-.606l1-4a.5.5 0 0 1 .131-.232l9.642-9.642a.5.5 0 0 0-.642.056L6.854 4.854a.5.5 0 1 1-.708-.708L9.44.854A1.5 1.5 0 0 1 11.5.796a1.5 1.5 0 0 1 1.998-.001m-.644.766a.5.5 0 0 0-.707 0L1.95 11.756l-.764 3.057 3.057-.764L14.44 3.854a.5.5 0 0 0 0-.708z" />
</svg>
</label>
<input type="radio" class="btn-check" id="outline" name="tool">
<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" class="bi bi-square" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-square"
@ -246,6 +256,18 @@ resizeAndSetupCanvas();
setGrid(); setGrid();
scheduleSnappedcellSize(); scheduleSnappedcellSize();
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() { function undo() {
if (historyIndex <= 0) return; if (historyIndex <= 0) return;
historyIndex--; historyIndex--;
@ -280,7 +302,7 @@ function clamp01(n, fallback = 1) {
function isFiniteNum(n) { return Number.isFinite(Number(n)); } function isFiniteNum(n) { return Number.isFinite(Number(n)); }
function sanitizeShapes(list) { function sanitizeShapes(list) {
const allowed = new Set(['rect','ellipse','line']); const allowed = new Set(['rect','ellipse','line','path']);
return list.flatMap((s) => { return list.flatMap((s) => {
if (!s || typeof s !== 'object' || !allowed.has(s.type)) return []; if (!s || typeof s !== 'object' || !allowed.has(s.type)) return [];
@ -292,6 +314,26 @@ function sanitizeShapes(list) {
return [{ type:'line', x1:+s.x1, y1:+s.y1, x2:+s.x2, y2:+s.y2, color }]; return [{ type:'line', x1:+s.x1, y1:+s.y1, x2:+s.x2, y2:+s.y2, color }];
} }
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 [];
const width = isFiniteNum(s.width) ? Math.max(0.05, +s.width) : 0.12;
const opacity = clamp01(s.opacity, 1);
return [{
type: 'path',
points,
color,
width,
opacity
}];
}
if (!['x','y','w','h'].every(k => isFiniteNum(s[k]))) return []; if (!['x','y','w','h'].every(k => isFiniteNum(s[k]))) return [];
return [{ return [{
type: s.type, type: s.type,
@ -550,6 +592,24 @@ function drawShape(shape) {
ctx.moveTo(x1, y1); ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2); ctx.lineTo(x2, y2);
ctx.stroke(); ctx.stroke();
} else if (shape.type === 'path') {
const toPx = (v) => v * cellSize;
ctx.save();
ctx.strokeStyle = shape.color || '#000000';
ctx.globalAlpha = clamp01(shape.opacity, 1);
ctx.lineWidth = Math.max(1, toPx(shape.width ?? 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(); ctx.restore();
@ -727,10 +787,11 @@ gridEl.addEventListener('pointermove', (e) => {
if (!ctx) return; if (!ctx) return;
const { ix, iy, x: snapX, y: snapY, localX, localY } = snapToGrid(e.clientX, e.clientY); const { ix, iy, x: snapX, y: snapY, localX, localY } = snapToGrid(e.clientX, e.clientY);
const tool = getActiveTool();
coordsEl.classList.remove('d-none'); coordsEl.classList.remove('d-none');
if (getActiveType() !== 'noGrid') { if (getActiveType() !== 'noGrid' && tool !== 'pen') {
dotEl.classList.remove('d-none'); dotEl.classList.remove('d-none');
const gridRect = gridEl.getBoundingClientRect(); const gridRect = gridEl.getBoundingClientRect();
@ -758,6 +819,28 @@ gridEl.addEventListener('pointermove', (e) => {
ctx.save(); ctx.save();
ctx.setLineDash([5, 3]); ctx.setLineDash([5, 3]);
if (currentShape.tool === 'pen') {
const p = pxToDocPoint(e.clientX, e.clientY);
const pts = currentShape.points;
const last = pts[pts.length - 1];
const minStep = 0.02;
if (!last || dist2(p, last) >= minStep * minStep) {
pts.push(p);
}
clearCanvas();
shapes.forEach(drawShape);
ctx.save();
ctx.setLineDash([5, 3]);
drawShape(currentShape);
ctx.setLineDash([]);
ctx.restore();
return;
}
if (tool === 'line') { if (tool === 'line') {
const previewLine = normalizeLine({ const previewLine = normalizeLine({
type: 'line', type: 'line',
@ -837,6 +920,16 @@ gridEl.addEventListener('pointerdown', (e) => {
fill: (tool === 'filledEllipse'), fill: (tool === 'filledEllipse'),
opacity: currentOpacity opacity: currentOpacity
}; };
} else if (tool === 'pen') {
const p = pxToDocPoint(e.clientX, e.clientY);
currentShape = {
tool,
type: 'path',
points: [p],
color: selectedColor,
width: 0.12,
opacity: 1
};
} }
}); });
@ -854,6 +947,23 @@ window.addEventListener('pointerup', (e) => {
let finalShape = null; 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 };
}
}
}
if (currentShape.tool === 'line') { if (currentShape.tool === 'line') {
const line = normalizeLine(currentShape); const line = normalizeLine(currentShape);