881 lines
No EOL
30 KiB
HTML
881 lines
No EOL
30 KiB
HTML
{% extends 'base.html' %}
|
|
|
|
{% block style %}
|
|
|
|
#gridWrap {
|
|
width: 100%;
|
|
height: 80vh;
|
|
position: relative;
|
|
}
|
|
|
|
#grid {
|
|
position: relative;
|
|
cursor: crosshair;
|
|
width: 100%;
|
|
height: 100%;
|
|
touch-action: none;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
#toolBar::-webkit-scrollbar {
|
|
height: 8px;
|
|
}
|
|
|
|
#coords {
|
|
bottom: 10px;
|
|
pointer-events: none;
|
|
left: 10px;
|
|
}
|
|
|
|
#overlay {
|
|
z-index: 9999;
|
|
pointer-events: none;
|
|
inset: 0;
|
|
}
|
|
|
|
#toolBar {
|
|
margin: 0 auto;
|
|
}
|
|
|
|
#dot {
|
|
transform: translate(-50%, -50%);
|
|
z-index: 10000;
|
|
pointer-events: none;
|
|
}
|
|
{% endblock %}
|
|
|
|
{% block main %}
|
|
<div class="container">
|
|
<div id="toolBar"
|
|
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">Cell Size:</span>
|
|
<input type="number" min="1" value="25" name="cellSize" id="cellSize" class="form-control form-control-sm">
|
|
|
|
<span class="input-group-text">Fill Opacity:</span>
|
|
<input type="number" min="0" max="1" step=".01" value="1" 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">
|
|
<div class="vr mx-1"></div>
|
|
<div class="btn-group">
|
|
<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">
|
|
<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>
|
|
</label>
|
|
|
|
<input type="radio" class="btn-check" id="filled" name="tool">
|
|
<label for="filled"
|
|
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-fill" viewBox="0 0 16 16">
|
|
<path d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2z" />
|
|
</svg>
|
|
</label>
|
|
|
|
<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">
|
|
<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>
|
|
|
|
<input type="radio" class="btn-check" id="filledEllipse" name="tool">
|
|
<label for="filledEllipse"
|
|
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-fill" viewBox="0 0 16 16">
|
|
<circle cx="8" cy="8" r="8" />
|
|
</svg>
|
|
</label>
|
|
|
|
<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>
|
|
</div>
|
|
<div class="vr mx-1"></div>
|
|
<div class="btn-group">
|
|
<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">
|
|
<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>
|
|
</label>
|
|
<input type="radio" class="btn-check" name="gridType" id="horizontalGrid">
|
|
<label for="horizontalGrid"
|
|
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-center" viewBox="0 0 16 16">
|
|
<path
|
|
d="M.969 0H0v.969h.5V1h.469V.969H1V.5H.969zm.937 1h.938V0h-.938zm1.875 0h.938V0H3.78v1zm1.875 0h.938V0h-.938zM7.531.969V1h.938V.969H8.5V.5h-.031V0H7.53v.5H7.5v.469zM9.406 1h.938V0h-.938zm1.875 0h.938V0h-.938zm1.875 0h.938V0h-.938zm1.875 0h.469V.969h.5V0h-.969v.5H15v.469h.031zM1 2.844v-.938H0v.938zm6.5-.938v.938h1v-.938zm7.5 0v.938h1v-.938zM1 4.719V3.78H0v.938h1zm6.5-.938v.938h1V3.78h-1zm7.5 0v.938h1V3.78h-1zM1 6.594v-.938H0v.938zm6.5-.938v.938h1v-.938zm7.5 0v.938h1v-.938zM0 8.5v-1h16v1zm0 .906v.938h1v-.938zm7.5 0v.938h1v-.938zm8.5.938v-.938h-1v.938zm-16 .937v.938h1v-.938zm7.5 0v.938h1v-.938zm8.5.938v-.938h-1v.938zm-16 .937v.938h1v-.938zm7.5 0v.938h1v-.938zm8.5.938v-.938h-1v.938zM0 16h.969v-.5H1v-.469H.969V15H.5v.031H0zm1.906 0h.938v-1h-.938zm1.875 0h.938v-1H3.78v1zm1.875 0h.938v-1h-.938zm1.875-.5v.5h.938v-.5H8.5v-.469h-.031V15H7.53v.031H7.5v.469zm1.875.5h.938v-1h-.938zm1.875 0h.938v-1h-.938zm1.875 0h.938v-1h-.938zm1.875-.5v.5H16v-.969h-.5V15h-.469v.031H15v.469z" />
|
|
</svg>
|
|
</label>
|
|
<input type="radio" class="btn-check" name="gridType" id="verticalGrid">
|
|
<label for="verticalGrid"
|
|
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-middle" viewBox="0 0 16 16">
|
|
<path
|
|
d="M.969 0H0v.969h.5V1h.469V.969H1V.5H.969zm.937 1h.938V0h-.938zm1.875 0h.938V0H3.78v1zm1.875 0h.938V0h-.938zM8.5 16h-1V0h1zm.906-15h.938V0h-.938zm1.875 0h.938V0h-.938zm1.875 0h.938V0h-.938zm1.875 0h.469V.969h.5V0h-.969v.5H15v.469h.031zM1 2.844v-.938H0v.938zm14-.938v.938h1v-.938zM1 4.719V3.78H0v.938h1zm14-.938v.938h1V3.78h-1zM1 6.594v-.938H0v.938zm14-.938v.938h1v-.938zM.5 8.5h.469v-.031H1V7.53H.969V7.5H.5v.031H0v.938h.5zm1.406 0h.938v-1h-.938zm1.875 0h.938v-1H3.78v1zm1.875 0h.938v-1h-.938zm3.75 0h.938v-1h-.938zm1.875 0h.938v-1h-.938zm1.875 0h.938v-1h-.938zm1.875 0h.469v-.031h.5V7.53h-.5V7.5h-.469v.031H15v.938h.031zM0 9.406v.938h1v-.938zm16 .938v-.938h-1v.938zm-16 .937v.938h1v-.938zm16 .938v-.938h-1v.938zm-16 .937v.938h1v-.938zm16 .938v-.938h-1v.938zM0 16h.969v-.5H1v-.469H.969V15H.5v.031H0zm1.906 0h.938v-1h-.938zm1.875 0h.938v-1H3.78v1zm1.875 0h.938v-1h-.938zm3.75 0h.938v-1h-.938zm1.875 0h.938v-1h-.938zm1.875 0h.938v-1h-.938zm1.875-.5v.5H16v-.969h-.5V15h-.469v.031H15v.469z" />
|
|
</svg>
|
|
</label>
|
|
<input type="radio" class="btn-check" name="gridType" id="fullGrid">
|
|
<label for="fullGrid"
|
|
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-all" viewBox="0 0 16 16">
|
|
<path d="M0 0h16v16H0zm1 1v6.5h6.5V1zm7.5 0v6.5H15V1zM15 8.5H8.5V15H15zM7.5 15V8.5H1V15z" />
|
|
</svg>
|
|
</label>
|
|
</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-download" 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
|
|
d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708z" />
|
|
</svg>
|
|
</button>
|
|
<input type="file" id="import" accept="application/json" class="d-none">
|
|
<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">
|
|
<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
|
|
d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708z" />
|
|
</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 id="gridWrap">
|
|
<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">
|
|
<circle cx="16" cy="16" r="4" fill="black" />
|
|
</svg>
|
|
</span>
|
|
<div id="grid" class="position-relative overflow-hidden">
|
|
<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="position-absolute w-100 h-100"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block script %}
|
|
const DEFAULT_DOC = { version: 1, cellSize: 25, shapes: [] };
|
|
|
|
const canvasEl = document.getElementById('overlay');
|
|
const clearEl = document.getElementById('clear');
|
|
const colorEl = document.getElementById('color');
|
|
const coordsEl = document.getElementById('coords');
|
|
const dotEl = document.getElementById('dot');
|
|
const dotSVGEl = document.getElementById('dotSVG');
|
|
const exportEl = document.getElementById('export');
|
|
const gridEl = document.getElementById('grid');
|
|
const importButtonEl = document.getElementById('importButton');
|
|
const importEl = document.getElementById('import');
|
|
const cellSizeEl = document.getElementById('cellSize');
|
|
const gridWrapEl = document.getElementById('gridWrap');
|
|
const toolBarEl = document.getElementById('toolBar');
|
|
const fillOpacityEl = document.getElementById('fillOpacity');
|
|
|
|
let doc = loadDoc();
|
|
let shapes = sanitizeShapes(Array.isArray(doc.shapes) ? doc.shapes : []);
|
|
saveDoc({ ...doc, shapes });
|
|
|
|
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 currentOpacity = clamp01(fillOpacityEl?.value ?? 1, 1);
|
|
|
|
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);
|
|
|
|
const savedTool = localStorage.getItem('gridTool');
|
|
if (savedTool) {
|
|
setActiveTool(savedTool);
|
|
}
|
|
|
|
const savedType = localStorage.getItem('gridType');
|
|
if (savedType) {
|
|
setActiveType(savedType);
|
|
}
|
|
|
|
resizeAndSetupCanvas();
|
|
|
|
setGrid();
|
|
scheduleSnappedcellSize();
|
|
|
|
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++;
|
|
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']);
|
|
|
|
return list.flatMap((s) => {
|
|
if (!s || typeof s !== 'object' || !allowed.has(s.type)) return [];
|
|
const color = typeof s.color === 'string' ? s.color : '#000000';
|
|
const opacity = clamp01(s.opacity, 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 }];
|
|
}
|
|
|
|
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,
|
|
opacity
|
|
}];
|
|
});
|
|
}
|
|
|
|
function loadDoc() {
|
|
try { return JSON.parse(localStorage.getItem("gridDoc")) || structuredClone(DEFAULT_DOC); }
|
|
catch { return structuredClone(DEFAULT_DOC); }
|
|
}
|
|
|
|
function saveDoc(nextDoc = doc) {
|
|
doc = nextDoc;
|
|
try { localStorage.setItem("gridDoc", 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.width = `${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 = document.querySelector('input[name="tool"]:checked');
|
|
return checked ? checked.id : 'outline';
|
|
}
|
|
|
|
function setActiveTool(toolId) {
|
|
const el = document.getElementById(toolId);
|
|
if (el) {
|
|
el.checked = true;
|
|
localStorage.setItem('gridTool', toolId);
|
|
}
|
|
}
|
|
|
|
function getActiveType() {
|
|
const checked = document.querySelector('input[name="gridType"]:checked');
|
|
return checked ? checked.id : 'noGrid';
|
|
}
|
|
|
|
function setActiveType(typeId) {
|
|
const el = document.getElementById(typeId);
|
|
if (el) {
|
|
el.checked = true;
|
|
localStorage.setItem('gridType', typeId);
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
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,
|
|
opacity: clamp01(shape.opacity, 1)
|
|
};
|
|
}
|
|
|
|
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
|
|
};
|
|
}
|
|
|
|
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';
|
|
|
|
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);
|
|
|
|
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();
|
|
}
|
|
|
|
if (shape.fill) {
|
|
ctx.globalAlpha = clamp01(shape.opacity, 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.beginPath();
|
|
ctx.moveTo(x1, y1);
|
|
ctx.lineTo(x2, y2);
|
|
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.boxShadow = "none";
|
|
dotEl.classList.add('d-none');
|
|
|
|
if (type === 'fullGrid') {
|
|
gridEl.style.backgroundImage =
|
|
"linear-gradient(to right, #ccc 1px, transparent 1px)," +
|
|
"linear-gradient(to bottom, #ccc 1px, transparent 1px)";
|
|
gridEl.style.backgroundSize = `${cellSize}px ${cellSize}px`;
|
|
gridEl.style.boxShadow = "inset 0 0 0 1px #ccc"; // full frame
|
|
|
|
} else if (type === 'horizontalGrid') {
|
|
gridEl.style.backgroundImage =
|
|
"linear-gradient(to bottom, #ccc 1px, transparent 1px)";
|
|
gridEl.style.backgroundSize = `100% ${cellSize}px`;
|
|
|
|
// left + right borders only
|
|
gridEl.style.boxShadow =
|
|
"inset 1px 0 0 0 #ccc, inset -1px 0 0 0 #ccc";
|
|
|
|
} else if (type === 'verticalGrid') {
|
|
gridEl.style.backgroundImage =
|
|
"linear-gradient(to right, #ccc 1px, transparent 1px)";
|
|
gridEl.style.backgroundSize = `${cellSize}px 100%`;
|
|
|
|
// top + bottom borders only
|
|
gridEl.style.boxShadow =
|
|
"inset 0 1px 0 0 #ccc, inset 0 -1px 0 0 #ccc";
|
|
|
|
} else { // noGrid
|
|
gridEl.style.boxShadow = "inset 0 0 0 1px #ccc";
|
|
dotEl.classList.add('d-none');
|
|
}
|
|
}
|
|
|
|
|
|
document.querySelectorAll('input[name="tool"]').forEach(input => {
|
|
input.addEventListener('change', () => {
|
|
if (input.checked) {
|
|
localStorage.setItem('gridTool', input.id);
|
|
}
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('input[name="gridType"]').forEach(input => {
|
|
input.addEventListener('change', () => {
|
|
if (input.checked) {
|
|
localStorage.setItem('gridType', input.id);
|
|
}
|
|
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);
|
|
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 = 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();
|
|
undo();
|
|
}
|
|
|
|
if ((e.ctrlKey || e.metaKey) && (key === 'y' || (e.shiftKey && key === 'z'))) {
|
|
e.preventDefault(); redo();
|
|
}
|
|
|
|
if (key === 'escape' && currentShape) {
|
|
currentShape = null;
|
|
redrawAll();
|
|
}
|
|
});
|
|
|
|
fillOpacityEl?.addEventListener('input', () => {
|
|
currentOpacity = clamp01(fillOpacityEl.value, 0);
|
|
});
|
|
|
|
fillOpacityEl?.addEventListener('change', () => {
|
|
currentOpacity = clamp01(fillOpacityEl.value, 0);
|
|
});
|
|
|
|
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);
|
|
|
|
coordsEl.classList.remove('d-none');
|
|
|
|
if (getActiveType() !== 'noGrid') {
|
|
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`;
|
|
}
|
|
|
|
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;
|
|
|
|
clearCanvas();
|
|
shapes.forEach(drawShape);
|
|
|
|
ctx.save();
|
|
ctx.setLineDash([5, 3]);
|
|
|
|
if (tool === 'line') {
|
|
const previewLine = normalizeLine({
|
|
type: 'line',
|
|
x1: currentShape.x1,
|
|
y1: currentShape.y1,
|
|
x2: snapX,
|
|
y2: snapY,
|
|
color: currentShape.color
|
|
});
|
|
drawShape(previewLine);
|
|
} else if (tool === 'filled' || tool === 'outline') {
|
|
const previewRect = normalizeRect({
|
|
...currentShape,
|
|
x2: snapX,
|
|
y2: snapY
|
|
});
|
|
drawShape(previewRect);
|
|
} else if (tool === 'filledEllipse' || tool === 'outlineEllipse') {
|
|
const previewEllipse = normalizeEllipse({
|
|
...currentShape,
|
|
x2: snapX,
|
|
y2: snapY
|
|
});
|
|
drawShape(previewEllipse);
|
|
}
|
|
|
|
ctx.setLineDash([]);
|
|
ctx.restore();
|
|
}
|
|
});
|
|
|
|
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('#toolBar')) return;
|
|
|
|
e.preventDefault();
|
|
gridEl.setPointerCapture(e.pointerId);
|
|
|
|
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 if (tool === 'outline' || tool === 'filled') {
|
|
currentShape = {
|
|
tool,
|
|
x1: snapX,
|
|
y1: snapY,
|
|
x2: snapX,
|
|
y2: snapY,
|
|
color: selectedColor,
|
|
fill: (tool === 'filled'),
|
|
opacity: currentOpacity
|
|
};
|
|
} else if (tool === 'outlineEllipse' || tool === 'filledEllipse') {
|
|
currentShape = {
|
|
tool,
|
|
x1: snapX,
|
|
y1: snapY,
|
|
x2: snapX,
|
|
y2: snapY,
|
|
color: selectedColor,
|
|
fill: (tool === 'filledEllipse'),
|
|
opacity: currentOpacity
|
|
};
|
|
}
|
|
});
|
|
|
|
window.addEventListener('pointerup', (e) => {
|
|
if (!currentShape) return;
|
|
|
|
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 === '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]);
|
|
|
|
clearCanvas();
|
|
shapes.forEach(drawShape);
|
|
|
|
currentShape = null;
|
|
});
|
|
{% endblock %} |