Code refactor to start working on viewer.
This commit is contained in:
parent
429e993009
commit
585c4abb25
2 changed files with 267 additions and 248 deletions
|
|
@ -10,8 +10,10 @@ document.addEventListener('keydown', (e) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
function initGridWidget(root, opts = {}) {
|
function initGridWidget(root, opts = {}) {
|
||||||
|
const mode = opts.mode || 'editor';
|
||||||
|
const storageKey = opts.storageKey ?? 'gridDoc';
|
||||||
|
|
||||||
const DEFAULT_DOC = { version: 1, cellSize: 25, shapes: [] };
|
const DEFAULT_DOC = { version: 1, cellSize: 25, shapes: [] };
|
||||||
const MAX_HISTORY = 100;
|
|
||||||
const SHAPE_DEFAULTS = {
|
const SHAPE_DEFAULTS = {
|
||||||
strokeWidth: 0.12,
|
strokeWidth: 0.12,
|
||||||
strokeOpacity: 1,
|
strokeOpacity: 1,
|
||||||
|
|
@ -19,17 +21,226 @@ function initGridWidget(root, opts = {}) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const canvasEl = root.querySelector('[data-canvas]');
|
const canvasEl = root.querySelector('[data-canvas]');
|
||||||
|
const gridEl = root.querySelector('[data-grid]');
|
||||||
|
const gridWrapEl = root.querySelector('[data-grid-wrap]');
|
||||||
|
|
||||||
|
if (!canvasEl || !gridEl || !gridWrapEl) {
|
||||||
|
throw new Error("Grid widget: missing required viewer elements.");
|
||||||
|
}
|
||||||
|
|
||||||
|
let doc = loadDoc();
|
||||||
|
let shapes = sanitizeShapes(Array.isArray(doc.shapes) ? doc.shapes : []);
|
||||||
|
let cellSize = Number(doc.cellSize) || 25;
|
||||||
|
|
||||||
|
let ctx;
|
||||||
|
let dpr = 1;
|
||||||
|
|
||||||
|
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', 'path']);
|
||||||
|
|
||||||
|
const normStroke = (v, fallback = 0.12) => {
|
||||||
|
const n = Number(v);
|
||||||
|
if (!Number.isFinite(n)) return fallback;
|
||||||
|
return Math.max(0, n);
|
||||||
|
};
|
||||||
|
|
||||||
|
return list.flatMap((s) => {
|
||||||
|
if (!s || typeof s !== 'object' || !allowed.has(s.type)) return [];
|
||||||
|
|
||||||
|
const color = typeof s.color === 'string' ? s.color : '#000000';
|
||||||
|
const fillOpacity = clamp01(s.fillOpacity, SHAPE_DEFAULTS.fillOpacity);
|
||||||
|
const strokeOpacity = clamp01(s.strokeOpacity, SHAPE_DEFAULTS.strokeOpacity);
|
||||||
|
|
||||||
|
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,
|
||||||
|
strokeWidth: normStroke(s.strokeWidth, SHAPE_DEFAULTS.strokeWidth),
|
||||||
|
strokeOpacity
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [];
|
||||||
|
|
||||||
|
return [{
|
||||||
|
type: 'path',
|
||||||
|
points,
|
||||||
|
color,
|
||||||
|
strokeWidth: normStroke(s.strokeWidth, SHAPE_DEFAULTS.strokeWidth),
|
||||||
|
strokeOpacity
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
fillOpacity,
|
||||||
|
strokeOpacity,
|
||||||
|
strokeWidth: normStroke(s.strokeWidth, SHAPE_DEFAULTS.strokeWidth)
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDoc() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(storageKey)) || structuredClone(DEFAULT_DOC); }
|
||||||
|
catch { return structuredClone(DEFAULT_DOC); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveDoc(nextDoc = doc) {
|
||||||
|
doc = nextDoc;
|
||||||
|
try { localStorage.setItem(storageKey, JSON.stringify(nextDoc)); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
redrawAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCanvas() {
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.clearRect(0, 0, canvasEl.width / dpr, canvasEl.height / dpr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawShape(shape) {
|
||||||
|
if (!ctx) return;
|
||||||
|
const toPx = (v) => v * cellSize;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = shape.color || '#000000';
|
||||||
|
ctx.lineWidth = Math.max(1, toPx(shape.strokeWidth ?? SHAPE_DEFAULTS.strokeWidth));
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
ctx.globalAlpha = clamp01(shape.strokeOpacity, SHAPE_DEFAULTS.strokeOpacity);
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
|
||||||
|
if (shape.fill) {
|
||||||
|
ctx.globalAlpha = clamp01(shape.fillOpacity, SHAPE_DEFAULTS.fillOpacity);
|
||||||
|
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.globalAlpha = clamp01(shape.strokeOpacity, SHAPE_DEFAULTS.strokeOpacity);
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x1, y1);
|
||||||
|
ctx.lineTo(x2, y2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
} else if (shape.type === 'path') {
|
||||||
|
const toPx = (v) => v * cellSize;
|
||||||
|
|
||||||
|
ctx.globalAlpha = clamp01(shape.strokeOpacity, SHAPE_DEFAULTS.strokeOpacity);
|
||||||
|
|
||||||
|
ctx.lineWidth = Math.max(1, toPx(shape.strokeWidth ?? SHAPE_DEFAULTS.strokeWidth));
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
function redrawAll() {
|
||||||
|
if (!ctx || !shapes) return;
|
||||||
|
|
||||||
|
clearCanvas();
|
||||||
|
shapes.forEach(drawShape);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDoc(nextDoc) {
|
||||||
|
const d = nextDoc && typeof nextDoc === 'object' ? nextDoc : DEFAULT_DOC;
|
||||||
|
cellSize = Number(d.cellSize) || 25;
|
||||||
|
shapes = sanitizeShapes(Array.isArray(d.shapes) ? d.shapes : []);
|
||||||
|
saveDoc({ version: Number(d.version) || 1, cellSize, shapes });
|
||||||
|
resizeAndSetupCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeAndSetupCanvas();
|
||||||
|
redrawAll();
|
||||||
|
|
||||||
|
if (mode !== 'editor') {
|
||||||
|
return { setDoc, redraw: redrawAll };
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_HISTORY = 100;
|
||||||
|
|
||||||
const clearEl = root.querySelector('[data-clear]');
|
const clearEl = root.querySelector('[data-clear]');
|
||||||
const colorEl = root.querySelector('[data-color]');
|
const colorEl = root.querySelector('[data-color]');
|
||||||
const coordsEl = root.querySelector('[data-coords]');
|
const coordsEl = root.querySelector('[data-coords]');
|
||||||
const dotEl = root.querySelector('[data-dot]');
|
const dotEl = root.querySelector('[data-dot]');
|
||||||
const dotSVGEl = root.querySelector('[data-dot-svg]');
|
const dotSVGEl = root.querySelector('[data-dot-svg]');
|
||||||
const exportEl = root.querySelector('[data-export]');
|
const exportEl = root.querySelector('[data-export]');
|
||||||
const gridEl = root.querySelector('[data-grid]');
|
|
||||||
const importButtonEl = root.querySelector('[data-import-button]');
|
const importButtonEl = root.querySelector('[data-import-button]');
|
||||||
const importEl = root.querySelector('[data-import]');
|
const importEl = root.querySelector('[data-import]');
|
||||||
const cellSizeEl = root.querySelector('[data-cell-size]');
|
const cellSizeEl = root.querySelector('[data-cell-size]');
|
||||||
const gridWrapEl = root.querySelector('[data-grid-wrap]');
|
|
||||||
const toolBarEl = root.querySelector('[data-toolbar]');
|
const toolBarEl = root.querySelector('[data-toolbar]');
|
||||||
const fillOpacityEl = root.querySelector('[data-fill-opacity]');
|
const fillOpacityEl = root.querySelector('[data-fill-opacity]');
|
||||||
const strokeOpacityEl = root.querySelector('[data-stroke-opacity]');
|
const strokeOpacityEl = root.querySelector('[data-stroke-opacity]');
|
||||||
|
|
@ -44,10 +255,6 @@ function initGridWidget(root, opts = {}) {
|
||||||
if (strokeOpacityEl && strokeValEl) bindRangeWithLabel(strokeOpacityEl, strokeValEl, v => `${parseInt(Number(v) * 100)}%`);
|
if (strokeOpacityEl && strokeValEl) bindRangeWithLabel(strokeOpacityEl, strokeValEl, v => `${parseInt(Number(v) * 100)}%`);
|
||||||
if (strokeWidthEl && widthValEl) bindRangeWithLabel(strokeWidthEl, widthValEl, v => `${Math.round(Number(v) * Number(cellSizeEl.value || 0))}px`);
|
if (strokeWidthEl && widthValEl) bindRangeWithLabel(strokeWidthEl, widthValEl, v => `${Math.round(Number(v) * Number(cellSizeEl.value || 0))}px`);
|
||||||
|
|
||||||
const storageKey = opts.storageKey ?? 'gridDoc';
|
|
||||||
|
|
||||||
let doc = loadDoc();
|
|
||||||
let shapes = sanitizeShapes(Array.isArray(doc.shapes) ? doc.shapes : []);
|
|
||||||
saveDoc({ ...doc, shapes });
|
saveDoc({ ...doc, shapes });
|
||||||
|
|
||||||
const savedTool = localStorage.getItem(`${storageKey}:tool`);
|
const savedTool = localStorage.getItem(`${storageKey}:tool`);
|
||||||
|
|
@ -56,12 +263,9 @@ function initGridWidget(root, opts = {}) {
|
||||||
const savedType = localStorage.getItem(`${storageKey}:gridType`);
|
const savedType = localStorage.getItem(`${storageKey}:gridType`);
|
||||||
if (savedType) setActiveType(savedType);
|
if (savedType) setActiveType(savedType);
|
||||||
|
|
||||||
let cellSize = Number(doc.cellSize) || 25;
|
|
||||||
cellSizeEl.value = cellSize;
|
cellSizeEl.value = cellSize;
|
||||||
let dotSize = Math.floor(Math.max(cellSize * 1.25, 32));
|
let dotSize = Math.floor(Math.max(cellSize * 1.25, 32));
|
||||||
|
|
||||||
let ctx;
|
|
||||||
let dpr = 1;
|
|
||||||
let selectedColor;
|
let selectedColor;
|
||||||
let currentFillOpacity = clamp01(fillOpacityEl?.value ?? 1, 1);
|
let currentFillOpacity = clamp01(fillOpacityEl?.value ?? 1, 1);
|
||||||
let currentStrokeOpacity = clamp01(strokeOpacityEl?.value ?? 1, 1);
|
let currentStrokeOpacity = clamp01(strokeOpacityEl?.value ?? 1, 1);
|
||||||
|
|
@ -77,8 +281,6 @@ function initGridWidget(root, opts = {}) {
|
||||||
const ro = new ResizeObserver(scheduleSnappedCellSize);
|
const ro = new ResizeObserver(scheduleSnappedCellSize);
|
||||||
ro.observe(gridWrapEl);
|
ro.observe(gridWrapEl);
|
||||||
|
|
||||||
resizeAndSetupCanvas();
|
|
||||||
|
|
||||||
setGrid();
|
setGrid();
|
||||||
scheduleSnappedCellSize();
|
scheduleSnappedCellSize();
|
||||||
|
|
||||||
|
|
@ -143,6 +345,53 @@ function initGridWidget(root, opts = {}) {
|
||||||
activeGridWidget = api;
|
activeGridWidget = api;
|
||||||
}, { capture: true });
|
}, { capture: true });
|
||||||
|
|
||||||
|
function setGrid() {
|
||||||
|
const type = getActiveType();
|
||||||
|
|
||||||
|
gridEl.style.backgroundImage = "";
|
||||||
|
gridEl.style.backgroundSize = "";
|
||||||
|
gridEl.style.backgroundPosition = "";
|
||||||
|
gridEl.style.boxShadow = "none";
|
||||||
|
dotEl.classList.add('d-none');
|
||||||
|
|
||||||
|
// Minor dots
|
||||||
|
const dotPx = Math.max(1, Math.round(cellSize * 0.08));
|
||||||
|
const minorColor = '#ddd';
|
||||||
|
|
||||||
|
// Major dots (every 5 cells)
|
||||||
|
const majorStep = cellSize * 5;
|
||||||
|
const majorDotPx = Math.max(dotPx + 1, Math.round(cellSize * 0.12));
|
||||||
|
const majorColor = '#c4c4c4';
|
||||||
|
|
||||||
|
const minorLayer = `radial-gradient(circle, ${minorColor} ${dotPx}px, transparent ${dotPx}px)`;
|
||||||
|
const majorLayer = `radial-gradient(circle, ${majorColor} ${majorDotPx}px, transparent ${majorDotPx}px)`;
|
||||||
|
|
||||||
|
if (type === 'fullGrid') {
|
||||||
|
gridEl.style.backgroundImage = `${majorLayer}, ${minorLayer}`;
|
||||||
|
gridEl.style.backgroundSize = `${majorStep}px ${majorStep}px, ${cellSize}px ${cellSize}px`;
|
||||||
|
gridEl.style.backgroundPosition =
|
||||||
|
`${majorStep / 2}px ${majorStep / 2}px, ${cellSize / 2}px ${cellSize / 2}px`;
|
||||||
|
gridEl.style.boxShadow = "inset 0 0 0 1px #ccc";
|
||||||
|
|
||||||
|
} else if (type === 'verticalGrid') {
|
||||||
|
gridEl.style.backgroundImage = `${majorLayer}, ${minorLayer}`;
|
||||||
|
gridEl.style.backgroundSize = `${majorStep}px 100%, ${cellSize}px 100%`;
|
||||||
|
gridEl.style.backgroundPosition =
|
||||||
|
`${majorStep / 2}px 0px, ${cellSize / 2}px 0px`;
|
||||||
|
gridEl.style.boxShadow = "inset 0 1px 0 0 #ccc, inset 0 -1px 0 0 #ccc";
|
||||||
|
|
||||||
|
} else if (type === 'horizontalGrid') {
|
||||||
|
gridEl.style.backgroundImage = `${majorLayer}, ${minorLayer}`;
|
||||||
|
gridEl.style.backgroundSize = `100% ${majorStep}px, 100% ${cellSize}px`;
|
||||||
|
gridEl.style.backgroundPosition =
|
||||||
|
`0px ${majorStep / 2}px, 0px ${cellSize / 2}px`;
|
||||||
|
gridEl.style.boxShadow = "inset 1px 0 0 0 #ccc, inset -1px 0 0 0 #ccc";
|
||||||
|
|
||||||
|
} else { // noGrid
|
||||||
|
gridEl.style.boxShadow = "inset 0 0 0 1px #ccc";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isInsideRect(clientX, clientY, rect) {
|
function isInsideRect(clientX, clientY, rect) {
|
||||||
return clientX >= rect.left && clientX <= rect.right &&
|
return clientX >= rect.left && clientX <= rect.right &&
|
||||||
clientY >= rect.top && clientY <= rect.bottom;
|
clientY >= rect.top && clientY <= rect.bottom;
|
||||||
|
|
@ -285,80 +534,6 @@ function initGridWidget(root, opts = {}) {
|
||||||
redrawAll();
|
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', 'path']);
|
|
||||||
|
|
||||||
const normStroke = (v, fallback = 0.12) => {
|
|
||||||
const n = Number(v);
|
|
||||||
if (!Number.isFinite(n)) return fallback;
|
|
||||||
return Math.max(0, n);
|
|
||||||
};
|
|
||||||
|
|
||||||
return list.flatMap((s) => {
|
|
||||||
if (!s || typeof s !== 'object' || !allowed.has(s.type)) return [];
|
|
||||||
|
|
||||||
const color = typeof s.color === 'string' ? s.color : '#000000';
|
|
||||||
const fillOpacity = clamp01(s.fillOpacity, SHAPE_DEFAULTS.fillOpacity);
|
|
||||||
const strokeOpacity = clamp01(s.strokeOpacity, SHAPE_DEFAULTS.strokeOpacity);
|
|
||||||
|
|
||||||
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,
|
|
||||||
strokeWidth: normStroke(s.strokeWidth, SHAPE_DEFAULTS.strokeWidth),
|
|
||||||
strokeOpacity
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
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 [];
|
|
||||||
|
|
||||||
return [{
|
|
||||||
type: 'path',
|
|
||||||
points,
|
|
||||||
color,
|
|
||||||
strokeWidth: normStroke(s.strokeWidth, SHAPE_DEFAULTS.strokeWidth),
|
|
||||||
strokeOpacity
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
fillOpacity,
|
|
||||||
strokeOpacity,
|
|
||||||
strokeWidth: normStroke(s.strokeWidth, SHAPE_DEFAULTS.strokeWidth)
|
|
||||||
}];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadDoc() {
|
|
||||||
try { return JSON.parse(localStorage.getItem(storageKey)) || structuredClone(DEFAULT_DOC); }
|
|
||||||
catch { return structuredClone(DEFAULT_DOC); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveDoc(nextDoc = doc) {
|
|
||||||
doc = nextDoc;
|
|
||||||
try { localStorage.setItem(storageKey, JSON.stringify(nextDoc)); } catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapDown(n, step) {
|
function snapDown(n, step) {
|
||||||
return Math.floor(n / step) * step;
|
return Math.floor(n / step) * step;
|
||||||
}
|
}
|
||||||
|
|
@ -528,165 +703,6 @@ function initGridWidget(root, opts = {}) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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';
|
|
||||||
ctx.lineWidth = Math.max(1, toPx(shape.strokeWidth ?? SHAPE_DEFAULTS.strokeWidth));
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
ctx.globalAlpha = clamp01(shape.strokeOpacity, SHAPE_DEFAULTS.strokeOpacity);
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
|
|
||||||
if (shape.fill) {
|
|
||||||
ctx.globalAlpha = clamp01(shape.fillOpacity, SHAPE_DEFAULTS.fillOpacity);
|
|
||||||
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.globalAlpha = clamp01(shape.strokeOpacity, SHAPE_DEFAULTS.strokeOpacity);
|
|
||||||
ctx.lineJoin = 'round';
|
|
||||||
ctx.lineCap = 'round';
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x1, y1);
|
|
||||||
ctx.lineTo(x2, y2);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
} else if (shape.type === 'path') {
|
|
||||||
const toPx = (v) => v * cellSize;
|
|
||||||
|
|
||||||
ctx.globalAlpha = clamp01(shape.strokeOpacity, SHAPE_DEFAULTS.strokeOpacity);
|
|
||||||
|
|
||||||
ctx.lineWidth = Math.max(1, toPx(shape.strokeWidth ?? SHAPE_DEFAULTS.strokeWidth));
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
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.backgroundPosition = "";
|
|
||||||
gridEl.style.boxShadow = "none";
|
|
||||||
dotEl.classList.add('d-none');
|
|
||||||
|
|
||||||
// Minor dots
|
|
||||||
const dotPx = Math.max(1, Math.round(cellSize * 0.08));
|
|
||||||
const minorColor = '#ddd';
|
|
||||||
|
|
||||||
// Major dots (every 5 cells)
|
|
||||||
const majorStep = cellSize * 5;
|
|
||||||
const majorDotPx = Math.max(dotPx + 1, Math.round(cellSize * 0.12));
|
|
||||||
const majorColor = '#c4c4c4';
|
|
||||||
|
|
||||||
const minorLayer = `radial-gradient(circle, ${minorColor} ${dotPx}px, transparent ${dotPx}px)`;
|
|
||||||
const majorLayer = `radial-gradient(circle, ${majorColor} ${majorDotPx}px, transparent ${majorDotPx}px)`;
|
|
||||||
|
|
||||||
if (type === 'fullGrid') {
|
|
||||||
gridEl.style.backgroundImage = `${majorLayer}, ${minorLayer}`;
|
|
||||||
gridEl.style.backgroundSize = `${majorStep}px ${majorStep}px, ${cellSize}px ${cellSize}px`;
|
|
||||||
gridEl.style.backgroundPosition =
|
|
||||||
`${majorStep / 2}px ${majorStep / 2}px, ${cellSize / 2}px ${cellSize / 2}px`;
|
|
||||||
gridEl.style.boxShadow = "inset 0 0 0 1px #ccc";
|
|
||||||
|
|
||||||
} else if (type === 'verticalGrid') {
|
|
||||||
gridEl.style.backgroundImage = `${majorLayer}, ${minorLayer}`;
|
|
||||||
gridEl.style.backgroundSize = `${majorStep}px 100%, ${cellSize}px 100%`;
|
|
||||||
gridEl.style.backgroundPosition =
|
|
||||||
`${majorStep / 2}px 0px, ${cellSize / 2}px 0px`;
|
|
||||||
gridEl.style.boxShadow = "inset 0 1px 0 0 #ccc, inset 0 -1px 0 0 #ccc";
|
|
||||||
|
|
||||||
} else if (type === 'horizontalGrid') {
|
|
||||||
gridEl.style.backgroundImage = `${majorLayer}, ${minorLayer}`;
|
|
||||||
gridEl.style.backgroundSize = `100% ${majorStep}px, 100% ${cellSize}px`;
|
|
||||||
gridEl.style.backgroundPosition =
|
|
||||||
`0px ${majorStep / 2}px, 0px ${cellSize / 2}px`;
|
|
||||||
gridEl.style.boxShadow = "inset 1px 0 0 0 #ccc, inset -1px 0 0 0 #ccc";
|
|
||||||
|
|
||||||
} else { // noGrid
|
|
||||||
gridEl.style.boxShadow = "inset 0 0 0 1px #ccc";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPointerUp(e) {
|
function onPointerUp(e) {
|
||||||
if (!currentShape) return;
|
if (!currentShape) return;
|
||||||
|
|
||||||
|
|
@ -915,7 +931,7 @@ function initGridWidget(root, opts = {}) {
|
||||||
// PEN: mutate points and preview the same shape object
|
// PEN: mutate points and preview the same shape object
|
||||||
if (currentShape.tool === 'pen') {
|
if (currentShape.tool === 'pen') {
|
||||||
penAddPoint(currentShape, e.clientX, e.clientY, 0.02);
|
penAddPoint(currentShape, e.clientX, e.clientY, 0.02);
|
||||||
renderAllWithPreview(currentShape, true);
|
renderAllWithPreview(currentShape, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -938,7 +954,7 @@ function initGridWidget(root, opts = {}) {
|
||||||
preview = normalizeEllipse({ ...currentShape, x2: snapX, y2: snapY });
|
preview = normalizeEllipse({ ...currentShape, x2: snapX, y2: snapY });
|
||||||
}
|
}
|
||||||
|
|
||||||
renderAllWithPreview(preview, true);
|
renderAllWithPreview(preview, currentShape.tool !== 'pen');
|
||||||
});
|
});
|
||||||
|
|
||||||
gridEl.addEventListener('pointerleave', (e) => {
|
gridEl.addEventListener('pointerleave', (e) => {
|
||||||
|
|
|
||||||
|
|
@ -241,4 +241,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro viewWidget(uid, json) %}
|
||||||
|
{% endmacro %}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue