Code refactor to start working on viewer.

This commit is contained in:
Yaro Kasear 2026-01-06 14:39:26 -06:00
parent 429e993009
commit 585c4abb25
2 changed files with 267 additions and 248 deletions

View file

@ -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) => {

View file

@ -242,3 +242,6 @@
</div> </div>
</div> </div>
{% endmacro %} {% endmacro %}
{% macro viewWidget(uid, json) %}
{% endmacro %}