Compare commits

..

No commits in common. "main" and "stable" have entirely different histories.
main ... stable

26 changed files with 160 additions and 3281 deletions

View file

@ -18,13 +18,11 @@ from crudkit.integrations.flask import init_app
from .debug_pretty import init_pretty
from .routes.entry import init_entry_routes
from .routes.image import init_image_routes
from .routes.index import init_index_routes
from .routes.listing import init_listing_routes
from .routes.search import init_search_routes
from .routes.settings import init_settings_routes
from .routes.reports import init_reports_routes
from .routes.testing import init_testing_routes
def create_app(config_cls=crudkit.DevConfig) -> Flask:
app = Flask(__name__)
@ -100,13 +98,11 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask:
])
init_entry_routes(app)
init_image_routes(app)
init_index_routes(app)
init_listing_routes(app)
init_search_routes(app)
init_settings_routes(app)
init_reports_routes(app)
init_testing_routes(app)
@app.teardown_appcontext
def _remove_session(_exc):

View file

@ -1,6 +1,6 @@
from typing import List, Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Unicode, UnicodeText, case, cast, func
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Unicode, case, cast, func
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import Mapped, mapped_column, relationship, synonym
from sqlalchemy.sql import expression as sql
@ -21,7 +21,7 @@ class Inventory(Base, CRUDMixin):
condition_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey('status.id'), nullable=True, index=True)
model: Mapped[Optional[str]] = mapped_column(Unicode(255))
notes: Mapped[Optional[str]] = mapped_column(UnicodeText)
notes: Mapped[Optional[str]] = mapped_column(Unicode(255))
shared: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=sql.false())
timestamp: Mapped[DateTime] = mapped_column(DateTime, default=func.now(), nullable=False)

View file

@ -27,7 +27,6 @@ def _fields_for_model(model: str):
"notes",
"owner.id",
"image.filename",
"image.caption",
]
fields_spec = [
{"name": "label", "type": "display", "label": "", "row": "label",
@ -56,8 +55,8 @@ def _fields_for_model(model: str):
{"name": "condition", "label": "Condition", "row": "status", "wrap": {"class": "col form-floating"},
"label_attrs": {"class": "ms-2"}, "label_spec": "{description}"},
{"name": "image", "label": "", "row": "image", "type": "template", "label_spec": "{filename}",
"template": "image_display.html", "attrs": {"class": "img-fluid img-thumbnail h-auto", "data-model": "inventory"},
"wrap": {"class": "d-inline-block position-relative image-wrapper", "style": "min-width: 200px; min-height: 200px;"}},
"template": "image_display.html", "attrs": {"class": "img-fluid img-thumbnail h-auto"},
"wrap": {"class": "h-100 w-100"}},
{"name": "notes", "type": "template", "label": "Notes", "row": "notes", "wrap": {"class": "col"},
"template": "inventory_note.html"},
{"name": "work_logs", "type": "template", "template_ctx": {}, "row": "notes", "wrap": {"class": "col"},
@ -350,11 +349,6 @@ def init_entry_routes(app):
payload = normalize_payload(request.get_json(force=True) or {}, cls)
# Strip caption for inventory so it doesn't hit Inventory(**payload)
image_caption = None
if model == "inventory":
image_caption = payload.pop("caption", None)
# Child mutations and friendly-to-FK mapping
updates = payload.pop("updates", []) or []
payload.pop("delete_update_ids", None) # irrelevant on create
@ -408,8 +402,6 @@ def init_entry_routes(app):
cls = crudkit.crud.get_model(model)
payload = normalize_payload(request.get_json(), cls)
image_caption = payload.pop("caption", None)
updates = payload.pop("updates", None) or []
delete_ids = payload.pop("delete_update_ids", None) or []
@ -468,13 +460,6 @@ def init_entry_routes(app):
obj = service.update(id, data=payload, actor="update_entry", commit=False)
if model == "inventory" and image_caption is not None:
image_id = payload.get("image_id") or getattr(obj, "image_id", None)
if image_id:
image_cls = crudkit.crud.get_model("image")
image_svc = crudkit.crud.get_service(image_cls)
image_svc.update(image_id, {"caption": image_caption})
if model == "worklog" and (updates or delete_ids):
_apply_worklog_updates(obj, updates, delete_ids)

View file

@ -1,89 +0,0 @@
from pathlib import Path
from hashlib import md5
from werkzeug.utils import secure_filename
from flask import current_app, request, abort, jsonify, url_for, Blueprint
import crudkit
bp_image = Blueprint('image', __name__, url_prefix='/api/image')
def init_image_routes(app):
@bp_image.post('/upload')
def upload_image():
"""
Accepts multipart/form-data:
- image: file
- model: optional model name (e.g "inventory")
- caption: optional caption
Saves to static/uploads/images/<model>/<hash>_filename
Creates Image row via CRUD service and returns it as JSON.
"""
file = request.files.get("image")
if not file or not file.filename:
abort(400, "missing image file")
# Optional, useful to namespace by owner model
model_name = (request.form.get("model") or "generic").lower()
# Normalize filename
orig_name = secure_filename(file.filename)
# Read bytes once so we can hash + save
raw = file.read()
if not raw:
abort(400, "empty file")
# Hash for stable-ish unique prefix
h = md5(raw).hexdigest()[:16]
stored_name = f"{h}_{orig_name}"
# Build path: static/uploads/images/<model_name>/<hash>_filename
static_root = Path(current_app.root_path) / "static"
rel_dir = Path("uploads") / "images" / model_name
abs_dir = static_root / rel_dir
abs_dir.mkdir(parents=True, exist_ok=True)
abs_path = abs_dir / stored_name
abs_path.write_bytes(raw)
# What goes in the DB: path relative to /static
rel_path = str(rel_dir / stored_name).replace("\\", "/")
caption = request.form.get("caption", "") or ""
image_id = request.form.get("image_id")
image_model = crudkit.crud.get_model('image')
image_svc = crudkit.crud.get_service(image_model)
if image_id:
# Reuse existing row instead of creating a new one
image_id_int = int(image_id)
# Make sure it exists
existing = image_svc.get(image_id_int, {})
if existing is not None:
image = image_svc.update(image_id_int, {
'filename': rel_path,
'caption': caption,
})
else:
# Fallback to create if somehow missing
image = image_svc.create({
'filename': rel_path,
'caption': caption,
})
else:
# First time: create new row
image = image_svc.create({
'filename': rel_path,
'caption': caption
})
return jsonify({
'status': 'success',
'id': image.id,
'filename': image.filename,
'caption': image.caption,
'url': url_for('static', filename=image.filename, _external=False)
}), 201
app.register_blueprint(bp_image)

View file

@ -32,7 +32,7 @@ def init_search_routes(app):
{"field": "location.label", "label": "Location"},
]
inventory_results = inventory_service.list({
'notes|label|model|serial|barcode|name|owner.label__icontains': q,
'notes|label|model|owner.label__icontains': q,
'fields': [
"label",
"name",

View file

@ -1,12 +0,0 @@
from flask import Blueprint, render_template
import crudkit
bp_testing = Blueprint("testing", __name__)
def init_testing_routes(app):
@bp_testing.get('/testing')
def test_page():
return render_template('testing.html')
app.register_blueprint(bp_testing)

View file

@ -1,222 +0,0 @@
:root { --tb-h: 34px; }
/* =========================================================
GRID WIDGET (editor uses container queries, viewer does not)
========================================================= */
/* -------------------------
Shared basics (both modes)
------------------------- */
/* drawing stack */
.grid-widget [data-grid] {
position: relative;
margin-inline: auto;
}
/* Overlay elements */
.grid-widget [data-canvas],
.grid-widget [data-dot],
.grid-widget [data-coords] { position: absolute; }
.grid-widget [data-canvas]{
inset: 0;
width: 100%;
height: 100%;
display: block;
z-index: 1;
pointer-events: none;
}
.grid-widget [data-dot]{
transform: translate(-50%, -50%);
z-index: 2;
pointer-events: none;
}
.grid-widget [data-coords]{
bottom: 10px;
left: 10px;
pointer-events: none;
}
/* -------------------------
Toolbar styling
------------------------- */
.grid-widget [data-toolbar].toolbar{
display: grid !important;
grid-template-rows: auto auto;
align-content: start;
gap: 0.5rem;
overflow: visible;
}
.grid-widget [data-toolbar] .toolbar-row{
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
flex-wrap: nowrap;
}
.grid-widget [data-toolbar] .toolbar-row--primary,
.grid-widget [data-toolbar] .toolbar-row--secondary{
overflow-x: auto;
overflow-y: hidden;
}
.grid-widget [data-toolbar] .toolbar-row--secondary{ opacity: 0.95; }
/* container query only matters in editor (set below) */
@container (min-width: 750px){
.grid-widget [data-toolbar].toolbar{
display: flex !important;
flex-wrap: nowrap;
align-items: center;
gap: 0.5rem;
overflow-x: auto;
overflow-y: hidden;
}
.grid-widget [data-toolbar] .toolbar-row{ display: contents; }
.grid-widget [data-toolbar] .toolbar-row--primary,
.grid-widget [data-toolbar] .toolbar-row--secondary{ overflow: visible; }
}
.grid-widget [data-toolbar]::-webkit-scrollbar{ height: 8px; }
.grid-widget .toolbar-group{
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem;
border: 1px solid rgba(0,0,0,0.08);
border-radius: 0.5rem;
background: rgba(0,0,0,0.02);
}
.grid-widget .btn,
.grid-widget .form-control,
.grid-widget .badge{ height: var(--tb-h); }
.grid-widget [data-toolbar] .badge,
.grid-widget [data-toolbar] .input-group-text{ white-space: nowrap; }
.grid-widget .toolbar .btn{
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 0.5rem;
}
.grid-widget .toolbar .form-control-color{ width: var(--tb-h); padding: 0; }
.grid-widget .tb-btn{ flex-direction: column; gap: 2px; line-height: 1; }
.grid-widget .tb-btn small{ font-size: 11px; opacity: 0.75; }
.grid-widget .dropdown-toggle::after{ display: none; }
.grid-widget .toolbar .btn-group .btn{ border-radius: 0; }
.grid-widget .toolbar .btn-group .btn:first-child{
border-top-left-radius: 0.5rem;
border-bottom-left-radius: 0.5rem;
}
.grid-widget .toolbar .btn-group .btn:last-child{
border-top-right-radius: 0.5rem;
border-bottom-right-radius: 0.5rem;
}
.grid-widget .btn-check:checked + .btn{
background: rgba(0,0,0,0.08);
border-color: rgba(0,0,0,0.18);
}
.grid-widget .dropdown-menu{
min-width: 200px;
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.12);
position: absolute;
z-index: 1000;
pointer-events: auto;
}
.grid-widget .dropdown-menu .form-range{ width: 100%; margin: 0; }
/* =========================================================
EDITOR MODE (needs container queries)
========================================================= */
.grid-widget[data-mode="editor"]{
container-type: inline-size; /* ONLY here */
min-width: 375px;
height: 100%;
display: flex;
flex-direction: column;
}
.grid-widget[data-mode="editor"] [data-grid-wrap]{
flex: 1 1 auto;
width: 100%;
min-height: 375px;
position: relative;
overflow: hidden;
}
.grid-widget[data-mode="editor"] [data-grid]{
position: absolute;
inset: 0;
width: 100%;
height: 100%;
cursor: crosshair;
touch-action: none;
z-index: 0;
}
/* Editor: toolbar should match snapped grid width */
.grid-widget[data-mode="editor"] [data-toolbar]{
width: var(--grid-maxw, 100%);
margin-inline: auto; /* center it to match the centered grid */
max-width: 100%;
align-self: center; /* don't stretch full parent width */
}
/* =========================================================
VIEWER MODE (must shrink-wrap like an <img>)
========================================================= */
.grid-widget[data-mode="viewer"]{
/* explicitly undo any containment */
container-type: normal; /* <-- the money line */
contain: none;
display: inline-block;
vertical-align: middle;
width: auto;
height: auto;
min-width: 0;
flex: none;
}
/* wrap is the sized box (JS sets px) */
.grid-widget[data-mode="viewer"] [data-grid-wrap]{
display: inline-block;
position: relative;
overflow: hidden;
line-height: 0; /* remove inline baseline gap */
}
/* grid must be in-flow and fill wrap */
.grid-widget[data-mode="viewer"] [data-grid]{
display: block;
width: 100%;
height: 100%;
cursor: default;
overflow: hidden;
}
/* viewer hides editor-only overlays */
.grid-widget[data-mode="viewer"] [data-coords],
.grid-widget[data-mode="viewer"] [data-dot]{ display: none !important; }

View file

@ -1,10 +0,0 @@
.image-buttons {
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease;
}
.image-wrapper:hover .image-buttons {
opacity: 1;
pointer-events: auto;
}

View file

@ -64,44 +64,7 @@ DropDown.utilities = {
}
function onShow(e) {
// Bootstrap delegated events: currentTarget is document, useless here.
const source = e.target;
// Sanity check: make sure this is an Element before using .closest
if (!(source instanceof Element)) {
console.warn('Event target is not an Element:', source);
return;
}
// Whatever you were doing before
setMenuMaxHeight(source);
// Walk up to the element with data-field
const fieldElement = source.closest('[data-field]');
if (!fieldElement) {
console.warn('No [data-field] ancestor found for', source);
return;
}
const fieldName = fieldElement.dataset.field;
if (!fieldName) {
console.warn('Element has no data-field value:', fieldElement);
return;
}
const input = document.getElementById(`${fieldName}-filter`);
if (!input) {
console.warn(`No element found with id "${fieldName}-filter"`);
return;
}
// Let Bootstrap finish its show animation / DOM fiddling
setTimeout(() => {
input.focus();
if (typeof input.select === 'function') {
input.select();
}
}, 0);
setMenuMaxHeight(e.target);
}
function onResize() {
@ -115,7 +78,7 @@ DropDown.utilities = {
function init(root = document) {
// Delegate so dynamically-added dropdowns work too
root.addEventListener('shown.bs.dropdown', onShow);
root.addEventListener('show.bs.dropdown', onShow);
window.addEventListener('resize', onResize);
}

View file

@ -1,438 +0,0 @@
import { SHAPE_DEFAULTS } from "./widget-core.js";
function shortenKeys(shapes) {
const keyMap = {
type: 't',
points: 'p',
color: 'cl', // avoid collision with x2
strokeWidth: 'sw',
strokeOpacity: 'so',
fillOpacity: 'fo',
fill: 'f',
x: 'x',
y: 'y',
w: 'w',
h: 'h',
x1: 'a',
y1: 'b',
x2: 'c',
y2: 'd'
};
return shapes.map((shape) => {
const out = {};
for (const key of Object.keys(shape)) {
const newKey = keyMap[key] || key;
out[newKey] = shape[key];
}
return out;
});
}
function shortenShapes(shapes) {
const shapeMap = { path: 'p', line: 'l', rect: 'r', ellipse: 'e', stateChange: 's' };
return shapes.map(shape => ({
...shape,
type: shapeMap[shape.type] || shape.type
}));
}
function collapseStateChanges(shapes) {
const out = [];
let pending = null;
const flush = () => {
if (pending) out.push(pending);
pending = null;
};
for (const shape of shapes) {
if (shape.type === "stateChange") {
if (!pending) pending = { ...shape };
else {
for (const [k, v] of Object.entries(shape)) {
if (k !== "type") pending[k] = v;
}
}
continue;
}
flush();
out.push(shape);
}
flush();
return out;
}
function stateCode(shapes, SHAPE_DEFAULTS) {
const state = {
...SHAPE_DEFAULTS,
color: "#000000",
fill: false,
fillOpacity: 1
};
const styleKeys = Object.keys(state);
const out = [];
for (const shape of shapes) {
const s = { ...shape };
const stateChange = {};
for (const key of styleKeys) {
if (!(key in s)) continue;
if (s[key] !== state[key]) {
stateChange[key] = s[key];
state[key] = s[key];
}
delete s[key];
}
if (Object.keys(stateChange).length > 0) {
out.push({ type: "stateChange", ...stateChange });
}
out.push(s);
}
return out;
}
function computeDeltas(shapes) {
const q = 100;
const out = [];
let prevKind = null;
let prevBR = null;
let prevLineEnd = null;
const MAX_DOC_COORD = 1_000_000;
const MAX_INT = MAX_DOC_COORD * q;
const clampInt = (v) => {
if (!Number.isFinite(v)) return 0;
if (v > MAX_INT) return MAX_INT;
if (v < -MAX_INT) return -MAX_INT;
return v;
};
const toInt = (n) => clampInt(Math.round(Number(n) * q));
const resetRun = () => {
prevKind = null;
prevBR = null;
prevLineEnd = null;
};
for (const shape of shapes) {
if (shape.type === "stateChange") {
out.push(shape);
resetRun();
continue;
}
if (shape.type === "path") {
const s = { ...shape };
if (!Array.isArray(s.points) || s.points.length === 0) {
out.push(s);
resetRun();
continue;
}
const pts = [toInt(s.points[0].x), toInt(s.points[0].y)];
let prev = s.points[0];
for (let i = 1; i < s.points.length; i++) {
const cur = s.points[i];
pts.push(toInt(cur.x - prev.x), toInt(cur.y - prev.y));
prev = cur;
}
s.points = pts;
out.push(s);
resetRun();
continue;
}
if (shape.type === "line") {
const s = { ...shape };
const x1 = toInt(s.x1), y1 = toInt(s.y1);
const x2 = toInt(s.x2), y2 = toInt(s.y2);
let arr;
if (prevKind !== "line" || !prevLineEnd) {
arr = [x1, y1, x2 - x1, y2 - y1];
} else {
arr = [x1 - prevLineEnd.x2, y1 - prevLineEnd.y2, x2 - x1, y2 - y1];
}
prevKind = "line";
prevLineEnd = { x2, y2 };
delete s.x1; delete s.y1; delete s.x2; delete s.y2;
s.points = arr;
out.push(s);
continue;
}
if (shape.type === "rect" || shape.type === "ellipse") {
const s = { ...shape };
const x = toInt(s.x), y = toInt(s.y);
const w = toInt(s.w), h = toInt(s.h);
let arr;
if (prevKind !== s.type || !prevBR) {
arr = [x, y, w, h];
} else {
arr = [x - prevBR.x, y - prevBR.y, w, h];
}
prevKind = s.type;
prevBR = { x: x + w, y: y + h };
delete s.x; delete s.y; delete s.w; delete s.h;
s.points = arr;
out.push(s);
continue;
}
out.push(shape);
resetRun();
}
return out;
}
function encodeRuns(shapes) {
const out = [];
let run = null;
const flush = () => {
if (!run) return;
out.push(run);
run = null;
};
for (const shape of shapes) {
if (shape.type === 'path' || shape.type === 'stateChange') {
flush();
out.push(shape);
continue;
}
if (!run) {
run = { ...shape, points: [...shape.points] };
continue;
}
if (shape.type === run.type) {
run.points.push(...shape.points);
} else {
flush();
run = { ...shape, points: [...shape.points] };
}
}
flush();
return out;
}
function encodeStates(shapes) {
return shapes.map(shape => {
if (shape.type !== 'stateChange') return shape;
const re = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
let newShape = {};
Object.keys(shape).forEach(key => {
if (key === 'strokeOpacity' || key === 'strokeWidth' || key === 'fillOpacity') {
const v = Number(shape[key]);
if (Number.isFinite(v))
newShape[key] = Math.round(v * 100);
} else if (key === 'color') {
newShape[key] = re.test(shape[key]) ? shape[key] : '#000000';
} else if (key === 'fill') {
newShape[key] = !!shape[key];
}
});
return { ...shape, ...newShape };
});
}
export function encode({ cellSize, shapes, stripCaches, SHAPE_DEFAULTS }) {
if (!SHAPE_DEFAULTS) SHAPE_DEFAULTS = { strokeWidth: 0.12, strokeOpacity: 1, fillOpacity: 1 };
const cs = Number(cellSize);
const safeCellSize = Number.isFinite(cs) && cs >= 1 ? cs : 25;
const safeShapes = Array.isArray(shapes) ? shapes : [];
const stripped = (typeof stripCaches === "function") ? stripCaches(safeShapes) : safeShapes;
const payload = {
v: 1,
cs: safeCellSize,
q: 100,
d: {
cl: "#000000",
f: false,
sw: 12,
so: 100,
fo: 100
},
s: shortenKeys(
shortenShapes(
encodeStates(
encodeRuns(
computeDeltas(
collapseStateChanges(
stateCode(stripped, SHAPE_DEFAULTS)
)
)
)
)
)
)
};
return payload;
}
function decodePath(arr, q) {
let x = arr[0], y = arr[1];
const pts = [{ x: x / q, y: y / q }];
for (let i = 2; i < arr.length; i += 2) {
x += arr[i];
y += arr[i + 1];
pts.push({ x: x / q, y: y / q });
}
return pts;
}
export function decode(doc) {
const q = Number(doc?.q) || 100;
const cs = Number(doc?.cs) || 25;
const defaults = doc?.d || {};
const state = {
color: defaults.cl ?? "#000000",
fill: !!defaults.f,
strokeWidth: (Number(defaults.sw) ?? 12) / 100,
strokeOpacity: (Number(defaults.so) ?? 100) / 100,
fillOpacity: (Number(defaults.fo) ?? 100) / 100
};
const outShapes = [];
const num01 = (v, fallback) => {};
const applyStateChange = (op) => {
if ("cl" in op) state.color = op.cl;
if ("f" in op) state.fill = !!op.f;
if ("sw" in op) state.strokeWidth = num01(op.sw, state.strokeWidth * 100) / 100;
if ("so" in op) state.strokeOpacity = num01(op.so, state.strokeOpacity * 100) / 100;
if ("fo" in op) state.fillOpacity = num01(op.fo, state.fillOpacity * 100) / 100;
};
const ops = Array.isArray(doc?.s) ? doc.s : [];
for (const op of ops) {
if (!op || typeof op !== "object") continue;
const t = op.t;
if (t === "s") {
applyStateChange(op);
continue;
}
const arr = op.p;
if (!Array.isArray(arr) || arr.length === 0) continue;
if (t === "p") {
if (arr.length < 2 || (arr.length % 2) !== 0) continue;
outShapes.push({
type: "path",
points: decodePath(arr, q),
color: state.color,
strokeWidth: state.strokeWidth,
strokeOpacity: state.strokeOpacity
});
continue;
}
if ((arr.length % 4) !== 0) continue;
if (t === "l") {
let prevX2 = null, prevY2 = null;
for (let i = 0; i < arr.length; i += 4) {
const a = arr[i], b = arr[i + 1], c = arr[i + 2], d = arr[i + 3];
let x1, y1;
if (i === 0) {
x1 = a; y1 = b;
} else {
x1 = prevX2 + a;
y1 = prevY2 + b;
}
const x2 = x1 + c;
const y2 = y1 + d;
outShapes.push({
type: "line",
x1: x1 / q, y1: y1 / q, x2: x2 / q, y2: y2 / q,
color: state.color,
strokeWidth: state.strokeWidth,
strokeOpacity: state.strokeOpacity
});
prevX2 = x2; prevY2 = y2;
}
continue;
}
if (t === "r" || t === "e") {
let prevBRx = null, prevBRy = null;
for (let i = 0; i < arr.length; i += 4) {
const a = arr[i], b = arr[i + 1], c = arr[i + 2], d = arr[i + 3];
let x, y;
if (i === 0) {
x = a; y = b;
} else {
x = prevBRx + a;
y = prevBRy + b;
}
const w = c, h = d;
outShapes.push({
type: (t === "r") ? "rect" : "ellipse",
x: x / q, y: y / q, w: w / q, h: h / q,
color: state.color,
fill: state.fill,
fillOpacity: state.fillOpacity,
strokeWidth: state.strokeWidth,
strokeOpacity: state.strokeOpacity
});
prevBRx = x + w;
prevBRy = y + h;
}
continue;
}
}
return {
version: Number(doc?.v) || 1,
cellSize: cs,
shapes: outShapes
};
}

View file

@ -1,117 +0,0 @@
export function dist2(a, b) {
const dx = a.x - b.x, dy = a.y - b.y;
return dx * dx + dy * dy;
}
export function pointToSegmentDist2(p, a, b) {
const vx = b.x - a.x, vy = b.y - a.y;
const wx = p.x - a.x, wy = p.y - a.y;
const c1 = vx * wx + vy * wy;
if (c1 <= 0) return dist2(p, a);
const c2 = vx * vx + vy * vy;
if (c2 <= c1) return dist2(p, b);
const t = c1 / c2;
const proj = { x: a.x + t * vx, y: a.y + t * vy };
return dist2(p, proj);
}
function hitShape(p, s, tol) {
if (s.type === 'line') {
const a = { x: s.x1, y: s.y1 };
const b = { x: s.x2, y: s.y2 };
const sw = Math.max(0, Number(s.strokeWidth) || 0) / 2;
const t = tol + sw;
return pointToSegmentDist2(p, a, b) <= (t * t);
}
if (s.type === 'path') {
const pts = (s.renderPoints?.length >= 2) ? s.renderPoints : s.points;
if (!pts || pts.length < 2) return false;
const sw = Math.max(0, Number(s.strokeWidth) || 0) / 2;
const t = tol + sw;
for (let i = 0; i < pts.length - 1; i++) {
if (pointToSegmentDist2(p, pts[i], pts[i + 1]) <= (t * t)) return true;
}
return false;
}
if (s.type === 'rect') {
return hitRect(p, s, tol);
}
if (s.type === 'ellipse') {
return hitEllipse(p, s, tol);
}
return false;
}
function hitRect(p, r, tol) {
const x1 = r.x, y1 = r.y, x2 = r.x + r.w, y2 = r.y + r.h;
const minX = Math.min(x1, x2), maxX = Math.max(x1, x2);
const minY = Math.min(y1, y2), maxY = Math.max(y1, y2);
const inside = (p.x >= minX && p.x <= maxX && p.y >= minY && p.y <= maxY);
if (r.fill) {
return (p.x >= minX - tol && p.x <= maxX + tol && p.y >= minY - tol && p.y <= maxY + tol);
}
if (!inside) {
if (p.x < minX - tol || p.x > maxX + tol || p.y < minY - tol || p.y > maxY + tol) return false;
}
const nearLeft = Math.abs(p.x - minX) <= tol && p.y >= minY - tol && p.y <= maxY + tol;
const nearRight = Math.abs(p.x - maxX) <= tol && p.y >= minY - tol && p.y <= maxY + tol;
const nearTop = Math.abs(p.y - minY) <= tol && p.x >= minX - tol && p.x <= maxX + tol;
const nearBottom = Math.abs(p.y - maxY) <= tol && p.x >= minX - tol && p.x <= maxX + tol;
return nearLeft || nearRight || nearTop || nearBottom;
}
function hitEllipse(p, e, tol) {
const cx = e.x + e.w / 2;
const cy = e.y + e.h / 2;
const rx = Math.abs(e.w / 2);
const ry = Math.abs(e.h / 2);
if (rx <= 0 || ry <= 0) return false;
const nx = (p.x - cx) / rx;
const ny = (p.y - cy) / ry;
const d = nx * nx + ny * ny;
if (e.fill) {
const rx2 = (rx + tol);
const ry2 = (ry + tol);
const nnx = (p.x - cx) / rx2;
const nny = (p.y - cy) / ry2;
return (nnx * nnx + nny * nny) <= 1;
}
const minR = Math.max(1e-6, Math.min(rx, ry));
const band = tol / minR;
return Math.abs(d - 1) <= Math.max(0.02, band);
}
export function pickShapeAt(docPt, shapes, cellSize, opts = {}) {
const pxTol = opts.pxTol ?? 6;
const cs = Number(cellSize);
const safeCellSize = (Number.isFinite(cs) && cs > 0) ? cs : 25;
const tol = pxTol / safeCellSize;
for (let i = shapes.length - 1; i >= 0; i--) {
const s = shapes[i];
if (!s) continue;
if (hitShape(docPt, s, tol)) {
return { index: i, shape: s };
}
}
return null;
}

View file

@ -1,23 +0,0 @@
(function bindGridGlobalOnce() {
if (window.__gridGlobalBound) return;
window.__gridGlobalBound = true;
window.activeGridWidget = null;
// Keydown (undo/redo, escape)
document.addEventListener('keydown', (e) => {
const w = window.activeGridWidget;
if (!w || typeof w.handleKeyDown !== 'function') return;
w.handleKeyDown(e);
});
// Pointer finalize (for drawing finishing outside the element)
const forwardPointer = (e) => {
const w = window.__gridPointerOwner || window.activeGridWidget;
if (!w || typeof w.handleGlobalPointerUp !== 'function') return;
w.handleGlobalPointerUp(e);
};
window.addEventListener('pointerup', forwardPointer, { capture: true });
window.addEventListener('pointercancel', forwardPointer, { capture: true });
})();

View file

@ -1,46 +0,0 @@
import './global-bindings.js';
import { initGridWidget } from './widget-init.js';
const GRID_BOOT = window.__gridBootMap || (window.__gridBootMap = new WeakMap());
(function autoBootGridWidgets() {
function bootRoot(root) {
if (GRID_BOOT.has(root)) return;
GRID_BOOT.set(root, true);
const mode = root.dataset.mode || 'editor';
const storageKey = root.dataset.storageKey || root.dataset.key || 'gridDoc';
const api = initGridWidget(root, { mode, storageKey });
root.__gridApi = api;
}
document.querySelectorAll('[data-grid-widget]').forEach(bootRoot);
const mo = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.removedNodes) {
if (!(node instanceof Element)) continue;
const roots = [];
if (node.matches?.('[data-grid-widget]')) roots.push(node);
node.querySelectorAll?.('[data-grid-widget]').forEach(r => roots.push(r));
for (const r of roots) {
r.__gridApi?.destroy?.();
r.__gridApi = null;
GRID_BOOT.delete(r);
}
}
for (const node of m.addedNodes) {
if (!(node instanceof Element)) continue;
if (node.matches?.('[data-grid-widget]')) bootRoot(node);
node.querySelectorAll?.('[data-grid-widget]').forEach(bootRoot);
}
}
});
mo.observe(document.documentElement, { childList: true, subtree: true });
})();

View file

@ -1,42 +0,0 @@
import { pointToSegmentDist2 } from './geometry.js';
export function simplifyRDP(points, epsilon) {
if (!Array.isArray(points) || points.length < 3) return points || [];
const e = Number(epsilon);
const eps2 = Number.isFinite(e) ? e * e : 0;
function rdp(first, last, out) {
let maxD2 = 0;
let idx = -1;
const a = points[first];
const b = points[last];
for (let i = first + 1; i < last; ++i) {
const d2 = pointToSegmentDist2(points[i], a, b);
if (d2 > maxD2) {
maxD2 = d2;
idx = i;
}
}
if (maxD2 > eps2 && idx !== -1) {
rdp(first, idx, out);
out.pop();
rdp(idx, last, out);
} else {
out.push(a, b);
}
}
const out = [];
rdp(0, points.length - 1, out);
const deduped = [out[0]];
for (let i = 1; i < out.length; i++) {
const prev = deduped[deduped.length - 1];
const cur = out[i];
if (prev.x !== cur.x || prev.y !== cur.y) deduped.push(cur);
}
return deduped;
}

View file

@ -1,90 +0,0 @@
export function catmullRomResample(points, {
alpha = 0.5,
samplesPerSeg = 8,
maxSamplesPerSeg = 32,
minSamplesPerSeg = 4,
closed = false,
maxOutputPoints = 5000
} = {}) {
if (!Array.isArray(points) || points.length < 2) return points || [];
const dist = (a, b) => {
const dx = b.x - a.x, dy = b.y - a.y;
return Math.hypot(dx, dy);
};
const tj = (ti, pi, pj) => ti + Math.pow(dist(pi, pj), alpha);
const lerp2 = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
function evalSegment(p0, p1, p2, p3, t) {
let t0 = 0;
let t1 = tj(t0, p0, p1);
let t2 = tj(t1, p1, p2);
let t3 = tj(t2, p2, p3);
const eps = 1e-6;
if (t1 - t0 < eps) t1 = t0 + eps;
if (t2 - t1 < eps) t2 = t1 + eps;
if (t3 - t2 < eps) t3 = t2 + eps;
const u = t1 + (t2 - t1) * t;
const A1 = lerp2(p0, p1, (u - t0) / (t1 - t0));
const A2 = lerp2(p1, p2, (u - t1) / (t2 - t1));
const A3 = lerp2(p2, p3, (u - t2) / (t3 - t2));
const B1 = lerp2(A1, A2, (u - t0) / (t2 - t0));
const B2 = lerp2(A2, A3, (u - t1) / (t3 - t1));
const C = lerp2(B1, B2, (u - t1) / (t2 - t1));
return C;
}
const src = (points || []).filter(p =>
p && Number.isFinite(p.x) && Number.isFinite(p.y)
);
if (src.length < 2) return src;
const n = src.length;
const get = (i) => {
if (closed) {
const k = (i % n + n) % n;
return src[k];
}
if (i < 0) return src[0];
if (i >= n) return src[n - 1];
return src[i];
};
const out = [];
const pushPoint = (p) => {
if (out.length >= maxOutputPoints) return false;
const prev = out[out.length - 1];
if (!prev || prev.x !== p.x || prev.y !== p.y) out.push(p);
return true;
};
pushPoint({ x: src[0].x, y: src[0].y });
const segCount = closed ? n : (n - 1);
for (let i = 0; i < segCount; i++) {
const p0 = get(i - 1);
const p1 = get(i);
const p2 = get(i + 1);
const p3 = get(i + 2);
const segLen = dist(p1, p2);
const adaptive = Math.round(samplesPerSeg * Math.max(1, segLen * 0.75));
const steps = Math.max(minSamplesPerSeg, Math.min(maxSamplesPerSeg, adaptive));
for (let s = 1; s <= steps; s++) {
const t = s / steps;
const p = evalSegment(p0, p1, p2, p3, t);
if (!pushPoint(p)) return out;
}
}
return out;
}

View file

@ -1,472 +0,0 @@
import { catmullRomResample } from './spline.js';
export const DEFAULT_DOC = { version: 1, cellSize: 25, shapes: [] };
export const SHAPE_DEFAULTS = {
strokeWidth: 0.12,
strokeOpacity: 1,
fillOpacity: 1
};
export function createWidgetCore(env) {
let {
root, mode, storageKey,
gridEl, canvasEl,
viewerOffset = { x: 0, y: 0 },
} = env;
let doc = env.doc || structuredClone(DEFAULT_DOC);
let cellSize = Number(env.cellSize) || 25;
let shapes = Array.isArray(env.shapes) ? env.shapes : [];
let selectedShape = env.selectedShape || null;
let ctx = null;
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)); }
// Document and shape lifecycle
function loadDoc() {
try {
const raw = env.loadRaw
? env.loadRaw()
: localStorage.getItem(storageKey);
return raw ? JSON.parse(raw) : structuredClone(DEFAULT_DOC);
} catch {
return structuredClone(DEFAULT_DOC);
}
}
function saveDoc(nextDoc = doc) {
const safeDoc = {
...nextDoc,
shapes: stripCaches(Array.isArray(nextDoc.shapes) ? nextDoc.shapes : [])
};
doc = safeDoc;
const raw = JSON.stringify(safeDoc);
try {
if (env.saveRaw) env.saveRaw(raw);
else localStorage.setItem(storageKey, raw);
} catch { }
}
function setDoc(nextDoc) {
const d = nextDoc && typeof nextDoc === 'object' ? nextDoc : DEFAULT_DOC;
cellSize = Number(d.cellSize) || 25;
shapes = rebuildPathCaches(
sanitizeShapes(Array.isArray(d.shapes) ? d.shapes : [])
);
doc = { version: Number(d.version) || 1, cellSize, shapes };
if (mode === 'editor') {
saveDoc(doc);
}
requestAnimationFrame(() => resizeAndSetupCanvas());
}
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 stripCaches(shapes) {
return shapes.map(s => {
if (s.type === 'path') {
return {
type: 'path',
points: s.points,
color: s.color,
strokeWidth: s.strokeWidth,
strokeOpacity: s.strokeOpacity
};
}
if (s.type === 'line') {
return {
type: 'line',
x1: s.x1, y1: s.y1, x2: s.x2, y2: s.y2,
color: s.color,
strokeWidth: s.strokeWidth,
strokeOpacity: s.strokeOpacity
};
}
if (s.type === 'rect' || s.type === 'ellipse') {
return {
type: s.type,
x: s.x, y: s.y, w: s.w, h: s.h,
color: s.color,
fill: !!s.fill,
fillOpacity: s.fillOpacity,
strokeOpacity: s.strokeOpacity,
strokeWidth: s.strokeWidth
};
}
return s; // shouldn't happen
});
}
function rebuildPathCaches(list) {
const MIN_PTS_FOR_SMOOTH = 4;
const MIN_LEN = 2;
const MIN_TURN = 0.15;
return list.map(s => {
if (s.type !== 'path') return s;
const pts = s.points;
if (!Array.isArray(s.points) || pts.length < 2) return s;
if (!pts.every(p => p && Number.isFinite(p.x) && Number.isFinite(p.y))) return s;
if (pathLength(pts) < MIN_LEN) return s;
if (pts.length < MIN_PTS_FOR_SMOOTH) return s;
if (MIN_TURN != null && totalTurning(pts) < MIN_TURN) return s;
const renderPoints = catmullRomResample(s.points, {
alpha: 0.5,
samplesPerSeg: 10,
maxSamplesPerSeg: 40,
minSamplesPerSeg: 6,
closed: false,
maxOutputPoints: 4000
});
return {
...s,
...(renderPoints?.length >= 2 ? { renderPoints } : {})
};
});
}
function totalTurning(points) {
let sum = 0;
for (let i = 1; i < points.length - 1; i++) {
const p0 = points[i - 1];
const p1 = points[i];
const p2 = points[i + 1];
const v1x = p1.x - p0.x;
const v1y = p1.y - p0.y;
const v2x = p2.x - p1.x;
const v2y = p2.y - p1.y;
const len1 = Math.hypot(v1x, v1y);
const len2 = Math.hypot(v2x, v2y);
if (len1 === 0 || len2 === 0) continue;
const cross = Math.abs(v1x * v2y - v1y * v2x);
sum += cross / (len1 * len2);
}
return sum;
}
function pathLength(pts) {
let L = 0;
for (let i = 1; i < pts.length; i++) {
const dx = pts[i].x - pts[i - 1].x;
const dy = pts[i].y - pts[i - 1].y;
L += Math.hypot(dx, dy);
}
return L;
}
function getShapesBounds(shapes) {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const expand = (x1, y1, x2, y2) => {
minX = Math.min(minX, x1);
minY = Math.min(minY, y1);
maxX = Math.max(maxX, x2);
maxY = Math.max(maxY, y2);
};
for (const s of shapes || []) {
if (!s) continue;
if (s.type === 'rect' || s.type === 'ellipse') {
expand(s.x, s.y, s.x + s.w, s.y + s.h);
} else if (s.type === 'line') {
expand(
Math.min(s.x1, s.x2), Math.min(s.y1, s.y2),
Math.max(s.x1, s.x2), Math.max(s.y1, s.y2)
);
} else if (s.type === 'path') {
const pts = (s.renderPoints?.length >= 2) ? s.renderPoints : s.points;
if (!pts?.length) continue;
for (const p of pts) expand(p.x, p.y, p.x, p.y);
}
}
if (!Number.isFinite(minX)) return null;
return { minX, minY, maxX, maxY };
}
// Canvas pipeline
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') {
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.renderPoints && shape.renderPoints.length >= 2)
? shape.renderPoints
: 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();
ctx.save();
if (mode !== 'editor') {
ctx.translate(viewerOffset.x, viewerOffset.y);
}
shapes.forEach(drawShape);
if (mode === 'editor' && selectedShape) {
ctx.save();
ctx.globalAlpha = 1;
ctx.setLineDash([6, 4]);
drawShape({
...selectedShape,
fill: false,
strokeWidth: Math.max(selectedShape.strokeWidth ?? 0.12, 0.12) + (2 / cellSize),
strokeOpacity: 1
});
ctx.restore();
}
ctx.restore();
}
function renderAllWithPreview(previewShape = null, dashed = true) {
if (!ctx) return;
clearCanvas();
shapes.forEach(drawShape);
if (!previewShape) return;
ctx.save();
if (dashed) ctx.setLineDash([5, 3]);
drawShape(previewShape);
ctx.restore();
}
// Coordinate conversion
function pxToGrid(v) {
return v / cellSize;
}
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) };
}
// Tool state helpers
function getActiveTool() {
const checked = root.querySelector('input[data-tool]:checked');
return checked ? checked.value : 'pen';
}
function setActiveTool(toolValue) {
const el = root.querySelector(`input[data-tool][value="${CSS.escape(toolValue)}"]`);
if (el) el.checked = true;
}
function getActiveType() {
const checked = root.querySelector('input[data-gridtype]:checked');
return checked ? checked.value : 'noGrid';
}
function setActiveType(typeValue) {
const el = root.querySelector(`input[data-gridtype][value="${CSS.escape(typeValue)}"]`);
if (el) el.checked = true;
}
return {
DEFAULT_DOC,
SHAPE_DEFAULTS,
get doc() { return doc; },
get cellSize() { return cellSize; },
get shapes() { return shapes; },
get ctx() { return ctx; },
get selectedShape() { return selectedShape; },
set selectedShape(v) { selectedShape = v; },
set viewerOffset(v) { viewerOffset = v; },
loadDoc, saveDoc, setDoc,
sanitizeShapes, stripCaches, rebuildPathCaches,
getShapesBounds,
resizeAndSetupCanvas,
redrawAll,
renderAllWithPreview,
pxToDocPoint,
getActiveTool, setActiveTool,
getActiveType, setActiveType,
clamp01, pxToGrid, isFiniteNum,
};
}

View file

@ -1,831 +0,0 @@
import { encode, decode } from './encode-decode.js';
import { dist2, pickShapeAt } from './geometry.js';
import { simplifyRDP } from './simplify.js';
import { SHAPE_DEFAULTS } from './widget-core.js';
export function initWidgetEditor(core, env) {
const { root, gridEl, gridWrapEl, toastMessage, storageKey } = env;
const MAX_HISTORY = 100;
const clearEl = root.querySelector('[data-clear]');
const colorEl = root.querySelector('[data-color]');
const coordsEl = root.querySelector('[data-coords]');
const dotEl = root.querySelector('[data-dot]');
const dotSVGEl = root.querySelector('[data-dot-svg]');
const exportEl = root.querySelector('[data-export]');
const importButtonEl = root.querySelector('[data-import-button]');
const importEl = root.querySelector('[data-import]');
const cellSizeEl = root.querySelector('[data-cell-size]');
const toolBarEl = root.querySelector('[data-toolbar]');
const fillOpacityEl = root.querySelector('[data-fill-opacity]');
const strokeOpacityEl = root.querySelector('[data-stroke-opacity]');
const strokeWidthEl = root.querySelector('[data-stroke-width]');
const cellSizeValEl = root.querySelector('[data-cell-size-val]');
const fillValEl = root.querySelector('[data-fill-opacity-val]');
const strokeValEl = root.querySelector('[data-stroke-opacity-val]');
const widthValEl = root.querySelector('[data-stroke-width-val]');
function bindRangeWithLabel(inputEl, labelEl, format = (v) => v) {
const sync = () => { labelEl.textContent = format(inputEl.value); };
inputEl.addEventListener('input', sync);
inputEl.addEventListener('change', sync);
sync();
}
if (cellSizeEl && cellSizeValEl) bindRangeWithLabel(cellSizeEl, cellSizeValEl, v => `${v}px`);
if (fillOpacityEl && fillValEl) bindRangeWithLabel(fillOpacityEl, fillValEl, 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`);
core.saveDoc({ ...core.doc, shapes: core.shapes });
const savedTool = localStorage.getItem(`${storageKey}:tool`);
if (savedTool) core.setActiveTool(savedTool);
const savedType = localStorage.getItem(`${storageKey}:gridType`);
if (savedType) core.setActiveType(savedType);
cellSizeEl.value = core.cellSize;
let dotSize = Math.floor(Math.max(core.cellSize * 1.25, 32));
let selectedColor;
let currentFillOpacity = core.clamp01(fillOpacityEl?.value ?? 1, 1);
let currentStrokeOpacity = core.clamp01(strokeOpacityEl?.value ?? 1, 1);
let currentStrokeWidth = Number(strokeWidthEl?.value ?? 0.12) || 0.12;
let selectedIndex = -1;
selectedColor = colorEl?.value || '#000000';
if (dotSVGEl) {
const circle = dotSVGEl.querySelector('circle');
circle?.setAttribute('fill', selectedColor);
}
let currentShape = null;
let suppressNextClick = false;
const history = [structuredClone(core.shapes)];
let historyIndex = 0
let sizingRAF = 0;
let lastApplied = { w: 0, h: 0 };
const ro = new ResizeObserver(scheduleSnappedCellSize);
ro.observe(gridWrapEl);
setGrid();
scheduleSnappedCellSize();
let activePointerId = null;
if (toolBarEl && window.bootstrap?.Dropdown) {
toolBarEl.querySelectorAll('[data-bs-toggle="dropdown"]').forEach((toggle) => {
window.bootstrap.Dropdown.getOrCreateInstance(toggle, {
popperConfig(defaultConfig) {
return {
...defaultConfig,
strategy: 'fixed',
modifiers: [
...(defaultConfig.modifiers || []),
{ name: 'preventOverflow', options: { boundary: 'viewport' } },
{ name: 'flip', options: { boundary: 'viewport', padding: 8 } },
],
};
},
});
});
}
requestAnimationFrame(() => requestAnimationFrame(scheduleSnappedCellSize));
const api = {
handleKeyDown(e) {
const key = e.key.toLowerCase();
const t = e.target;
const isTextField = t && root.contains(t) && (t.matches('input, textarea, select') || t.isContentEditable);
if (isTextField) {
const isUndo = (e.ctrlKey || e.metaKey) && key === 'z';
const isRedo = (e.ctrlKey || e.metaKey) && (key === 'y' || (key === 'z' && e.shiftKey));
if (!isUndo && !isRedo) return;
}
if ((e.ctrlKey || e.metaKey) && key === 'z') {
e.preventDefault();
if (e.shiftKey) redo();
else undo();
return;
}
if ((e.ctrlKey || e.metaKey) && key === 'y') {
e.preventDefault();
redo();
return;
}
if (key === 'escape' && currentShape) {
e.preventDefault();
currentShape = null;
core.redrawAll();
}
},
handleGlobalPointerUp(e) {
finishPointer(e);
},
cancelStroke() { cancelStroke(); }
};
function destroy() {
if (window.activeGridWidget === api) window.activeGridWidget = null;
currentShape = null;
activePointerId = null;
try {
if (window.__gridPointerId != null && gridEl.hasPointerCapture?.(window.__gridPointerId)) {
gridEl.releasePointerCapture(window.__gridPointerId);
}
} catch { }
if (window.__gridPointerOwner === api) {
window.__gridPointerOwner = null;
window.__gridPointerId = null;
}
ro.disconnect();
}
api.destroy = destroy;
root.addEventListener('focusin', () => { window.activeGridWidget = api; });
root.addEventListener('pointerdown', () => {
window.activeGridWidget = api;
}, { capture: true });
function setGrid() {
const type = core.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(core.cellSize * 0.08));
const minorColor = '#ddd';
// Major dots (every 5 cells)
const majorStep = core.cellSize * 5;
const majorDotPx = Math.max(dotPx + 1, Math.round(core.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, ${core.cellSize}px ${core.cellSize}px`;
gridEl.style.backgroundPosition =
`${majorStep / 2}px ${majorStep / 2}px, ${core.cellSize / 2}px ${core.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%, ${core.cellSize}px 100%`;
gridEl.style.backgroundPosition =
`${majorStep / 2}px 0px, ${core.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% ${core.cellSize}px`;
gridEl.style.backgroundPosition =
`0px ${majorStep / 2}px, 0px ${core.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) {
return clientX >= rect.left && clientX <= rect.right &&
clientY >= rect.top && clientY <= rect.bottom;
}
function finishPointer(e) {
if (window.__gridPointerOwner !== api) return;
if (!currentShape) return;
if (e.pointerId !== activePointerId) return;
onPointerUp(e);
activePointerId = null;
window.__gridPointerOwner = null;
window.__gridPointerId = null;
}
function penAddPoint(shape, clientX, clientY, minStep = 0.02, maxDtMs = 16) {
const p = core.pxToDocPoint(clientX, clientY);
if (!Array.isArray(shape.points)) shape.points = [];
if (shape._lastAddTime == null) shape._lastAddTime = performance.now();
const pts = shape.points;
const last = pts[pts.length - 1];
const now = performance.now();
const dt = now - shape._lastAddTime;
if (!last) {
pts.push(p);
shape._lastAddTime = now;
return;
}
const dx = p.x - last.x;
const dy = p.y - last.y;
const d2 = dx * dx + dy * dy;
if (d2 >= minStep * minStep || dt >= maxDtMs) {
pts.push(p);
shape._lastAddTime = now;
}
}
function undo() {
if (historyIndex <= 0) return;
historyIndex--;
const nextShapes = core.rebuildPathCaches(structuredClone(history[historyIndex]));
core.setDoc({ ...core.doc, cellSize: core.cellSize, shapes: nextShapes });
core.redrawAll();
}
function redo() {
if (historyIndex >= history.length - 1) return;
historyIndex++;
const nextShapes = core.rebuildPathCaches(structuredClone(history[historyIndex]));
core.setDoc({ ...core.doc, cellSize: core.cellSize, shapes: nextShapes });
core.redrawAll();
}
function commit(nextShapes) {
history.splice(historyIndex + 1);
history.push(structuredClone(nextShapes));
historyIndex++;
if (history.length > MAX_HISTORY) {
const overflow = history.length - MAX_HISTORY;
history.splice(0, overflow);
historyIndex -= overflow;
if (historyIndex < 0) historyIndex = 0;
}
const rebuilt = core.rebuildPathCaches(nextShapes);
core.setDoc({ ...core.doc, shapes: rebuilt, cellSize: core.cellSize });
core.redrawAll();
}
function snapDown(n, step) {
return Math.floor(n / step) * step;
}
function applySnappedCellSize() {
sizingRAF = 0;
const grid = core.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);
// Only touch width-related CSS if width changed
const wChanged = snappedW !== lastApplied.w;
const hChanged = snappedH !== lastApplied.h;
if (!wChanged && !hChanged) return;
lastApplied = { w: snappedW, h: snappedH };
// critical: don't let observer see our own updates as layout input
ro.disconnect();
gridEl.style.width = `${snappedW}px`;
gridEl.style.height = `${snappedH}px`;
if (wChanged) {
root.style.setProperty('--grid-maxw', `${snappedW}px`);
}
ro.observe(gridWrapEl);
core.resizeAndSetupCanvas();
}
function scheduleSnappedCellSize() {
if (sizingRAF) return;
sizingRAF = requestAnimationFrame(applySnappedCellSize);
}
function applyCellSize(newSize) {
const n = Number(newSize);
if (!Number.isFinite(n) || n < 1) return;
core.setDoc({ ...core.doc, cellSize: n, shapes: core.shapes });
dotSize = Math.floor(Math.max(core.cellSize * 1.25, 32));
dotSVGEl?.setAttribute('width', dotSize);
dotSVGEl?.setAttribute('height', dotSize);
setGrid();
scheduleSnappedCellSize();
}
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 grid = core.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 = core.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 = core.pxToGrid(shape.x1);
const y1 = core.pxToGrid(shape.y1);
const x2 = core.pxToGrid(shape.x2);
const y2 = core.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,
fillOpacity: core.clamp01(shape.fillOpacity, 1),
strokeOpacity: core.clamp01(shape.strokeOpacity, 1),
strokeWidth: core.isFiniteNum(shape.strokeWidth) ? Math.max(0, +shape.strokeWidth) : 0.12
};
}
function normalizeEllipse(shape) {
const r = normalizeRect(shape);
return { ...r, type: 'ellipse' };
}
function normalizeLine(shape) {
return {
type: 'line',
x1: core.pxToGrid(shape.x1),
y1: core.pxToGrid(shape.y1),
x2: core.pxToGrid(shape.x2),
y2: core.pxToGrid(shape.y2),
color: shape.color,
strokeWidth: core.isFiniteNum(shape.strokeWidth) ? Math.max(0, +shape.strokeWidth) : 0.12,
strokeOpacity: core.clamp01(shape.strokeOpacity)
};
}
function cancelStroke(e) {
const owns = (window.__gridPointerOwner === api) &&
(e ? window.__gridPointerId === e.pointerId : true);
if (!owns) return;
currentShape = null;
activePointerId = null;
window.__gridPointerOwner = null;
window.__gridPointerId = null;
core.redrawAll();
}
function onPointerUp(e) {
if (!currentShape) return;
// Only finalize if this pointer is the captured one (or we failed to capture, sigh)
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 === 'pen') {
const pts = currentShape.points;
if (pts.length >= 2) {
const coarse = [pts[0]];
const minStepPx = 0.75;
const minStep = minStepPx / core.cellSize;
for (let i = 1; i < pts.length; i++) {
if (dist2(pts[i], coarse[coarse.length - 1]) >= minStep * minStep) {
coarse.push(pts[i]);
}
}
if (coarse.length >= 2) {
const epsilon = Math.max(0.01, (currentShape.strokeWidth ?? 0.12) * 0.75);
const simplified = simplifyRDP(coarse, epsilon);
if (simplified.length >= 2) {
finalShape = {
type: 'path',
points: simplified,
color: currentShape.color || '#000000',
strokeWidth: core.isFiniteNum(currentShape.strokeWidth) ? Math.max(0, +currentShape.strokeWidth) : SHAPE_DEFAULTS.strokeWidth,
strokeOpacity: core.clamp01(currentShape.strokeOpacity, SHAPE_DEFAULTS.strokeOpacity)
};
}
}
}
} else 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) {
if (finalShape && ('_lastAddTime' in finalShape)) delete finalShape._lastAddTime;
commit([...core.shapes, finalShape]);
suppressNextClick = true;
setTimeout(() => { suppressNextClick = false; }, 0);
}
currentShape = null;
core.renderAllWithPreview(null);
}
gridEl.addEventListener('pointerup', finishPointer);
function setSelection(hit) {
if (!hit) {
selectedIndex = -1;
core.selectedShape = null;
core.redrawAll();
return;
}
selectedIndex = hit.index;
core.selectedShape = hit.shape;
core.redrawAll();
}
gridEl.addEventListener('click', (e) => {
if (suppressNextClick) {
suppressNextClick = false;
return;
}
if (currentShape) return;
if (e.target.closest('[data-toolbar]')) return;
const docPt = core.pxToDocPoint(e.clientX, e.clientY);
const hit = pickShapeAt(docPt, core.shapes, core.cellSize, { pxTol: 7 });
setSelection(hit);
if (hit) root.dispatchEvent(new CustomEvent('shape:click', { detail: hit }));
});
gridEl.addEventListener('contextmenu', (e) => {
e.preventDefault();
if (currentShape) return;
const docPt = core.pxToDocPoint(e.clientX, e.clientY);
const hit = pickShapeAt(docPt, core.shapes, core.cellSize, { pxTol: 7 });
setSelection(hit);
root.dispatchEvent(new CustomEvent('shape:contextmenu', {
detail: { hit, clientX: e.clientX, clientY: e.clientY }
}));
});
gridEl.addEventListener('dblclick', (e) => {
if (currentShape) return;
if (e.target.closest('[data-toolbar]')) return;
const docPt = core.pxToDocPoint(e.clientX, e.clientY);
const hit = pickShapeAt(docPt, core.shapes, core.cellSize, { pxTol: 7 });
setSelection(hit);
if (hit) root.dispatchEvent(new CustomEvent('shape:dblclick', { detail: hit }));
});
root.querySelectorAll('input[data-tool]').forEach((input) => {
input.addEventListener('change', () => {
if (input.checked) {
localStorage.setItem(`${storageKey}:tool`, input.value);
}
});
});
root.querySelectorAll('input[data-gridtype]').forEach((input) => {
input.addEventListener('change', () => {
if (input.checked) {
localStorage.setItem(`${storageKey}:gridType`, input.value);
}
setGrid();
core.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 = decode(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?.shapes) ? data.shapes : [];
const rebuilt = core.rebuildPathCaches(core.sanitizeShapes(loadedShapes));
core.setDoc({ version: Number(data?.version) || 1, cellSize: Number(data?.cellSize) || core.cellSize, shapes: rebuilt });
history.length = 0;
history.push(structuredClone(core.shapes));
historyIndex = 0;
core.redrawAll();
} catch {
toastMessage('Failed to load data from JSON file.', 'danger');
}
};
reader.readAsText(file);
});
exportEl.addEventListener('click', () => {
const payload = encode({ cellSize: core.cellSize, shapes: core.shapes, stripCaches: core.stripCaches, SHAPE_DEFAULTS });
const blob = new Blob([JSON.stringify(payload)], { 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', () => {
cellSizeEl.value = 25;
core.setDoc({ ...core.doc, cellSize: 25, shapes: [] });
history.length = 0;
history.push([]);
historyIndex = 0;
core.redrawAll();
});
colorEl.addEventListener('input', () => {
selectedColor = colorEl.value || '#000000';
const circle = dotSVGEl.querySelector('circle');
if (circle) {
circle.setAttribute('fill', selectedColor);
}
});
fillOpacityEl?.addEventListener('input', () => {
currentFillOpacity = core.clamp01(fillOpacityEl.value, 1);
});
fillOpacityEl?.addEventListener('change', () => {
currentFillOpacity = core.clamp01(fillOpacityEl.value, 1);
});
strokeOpacityEl?.addEventListener('input', () => {
currentStrokeOpacity = core.clamp01(strokeOpacityEl.value, 1);
});
strokeOpacityEl?.addEventListener('change', () => {
currentStrokeOpacity = core.clamp01(strokeOpacityEl.value, 1);
});
strokeWidthEl?.addEventListener('input', () => {
currentStrokeWidth = Math.max(0, Number(strokeWidthEl.value) || 0.12);
});
strokeWidthEl?.addEventListener('change', () => {
currentStrokeWidth = Math.max(0, Number(strokeWidthEl.value) || 0.12);
});
gridEl.addEventListener('pointercancel', (e) => cancelStroke(e));
gridEl.addEventListener('lostpointercapture', (e) => cancelStroke(e));
gridEl.addEventListener('pointermove', (e) => {
if (!core.ctx) return;
const rect = gridEl.getBoundingClientRect();
const inside = isInsideRect(e.clientX, e.clientY, rect);
const drawing = !!currentShape;
const { ix, iy, x: snapX, y: snapY, localX, localY } = snapToGrid(e.clientX, e.clientY);
const tool = core.getActiveTool();
if (!drawing && !inside) {
coordsEl.classList.add('d-none');
dotEl.classList.add('d-none');
} else {
coordsEl.classList.remove('d-none');
if (core.getActiveType() !== 'noGrid' && tool !== 'pen') {
dotEl.classList.remove('d-none');
const wrapRect = gridWrapEl.getBoundingClientRect();
const offsetX = rect.left - wrapRect.left;
const offsetY = rect.top - wrapRect.top;
dotEl.style.left = `${offsetX + snapX}px`;
dotEl.style.top = `${offsetY + snapY}px`;
} else {
dotEl.classList.add('d-none');
}
}
if (core.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) return;
// PEN: mutate points and preview the same shape object
if (currentShape.tool === 'pen') {
const minStepPx = 0.75;
const minStep = minStepPx / core.cellSize;
penAddPoint(currentShape, e.clientX, e.clientY, minStep, 16);
// realtime instrumentation
coordsEl.innerText += ` | pts=${currentShape.points?.length ?? 0}`;
core.renderAllWithPreview(currentShape, false);
return;
}
// Other tools: build a normalized preview shape
let preview = null;
if (currentShape.tool === 'line') {
preview = normalizeLine({
x1: currentShape.x1,
y1: currentShape.y1,
x2: snapX,
y2: snapY,
color: currentShape.color,
strokeWidth: currentShape.strokeWidth,
strokeOpacity: currentShape.strokeOpacity
});
} else if (currentShape.tool === 'filled' || currentShape.tool === 'outline') {
preview = normalizeRect({ ...currentShape, x2: snapX, y2: snapY });
} else if (currentShape.tool === 'filledEllipse' || currentShape.tool === 'outlineEllipse') {
preview = normalizeEllipse({ ...currentShape, x2: snapX, y2: snapY });
}
core.renderAllWithPreview(preview, currentShape.tool !== 'pen');
});
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('[data-toolbar]')) return;
e.preventDefault();
activePointerId = e.pointerId;
window.__gridPointerOwner = api;
window.__gridPointerId = e.pointerId;
try {
gridEl.setPointerCapture(e.pointerId);
} catch {
// ignore: some browsers / scenarios won't allow capture
}
const { x: snapX, y: snapY } = snapToGrid(e.clientX, e.clientY);
const tool = core.getActiveTool();
if (tool === 'line') {
currentShape = {
tool,
type: 'line',
x1: snapX,
y1: snapY,
x2: snapX,
y2: snapY,
color: selectedColor,
strokeWidth: currentStrokeWidth,
strokeOpacity: currentStrokeOpacity
};
} else if (tool === 'outline' || tool === 'filled') {
currentShape = {
tool,
x1: snapX,
y1: snapY,
x2: snapX,
y2: snapY,
color: selectedColor,
fill: (tool === 'filled'),
fillOpacity: currentFillOpacity,
strokeOpacity: currentStrokeOpacity,
strokeWidth: currentStrokeWidth
};
} else if (tool === 'outlineEllipse' || tool === 'filledEllipse') {
currentShape = {
tool,
x1: snapX,
y1: snapY,
x2: snapX,
y2: snapY,
color: selectedColor,
fill: (tool === 'filledEllipse'),
fillOpacity: currentFillOpacity,
strokeOpacity: currentStrokeOpacity,
strokeWidth: currentStrokeWidth
};
} else if (tool === 'pen') {
const p = core.pxToDocPoint(e.clientX, e.clientY);
currentShape = {
tool,
type: 'path',
points: [p],
color: selectedColor,
strokeWidth: currentStrokeWidth,
strokeOpacity: currentStrokeOpacity,
_lastAddTime: performance.now()
};
}
});
}

View file

@ -1,136 +0,0 @@
import { encode, decode } from './encode-decode.js';
import { createWidgetCore, DEFAULT_DOC } from "./widget-core.js";
import { initWidgetEditor } from "./widget-editor.js";
import { initWidgetViewer } from "./widget-viewer.js";
function readEmbeddedDoc(root, toastMessage) {
const el = root.querySelector('[data-grid-doc]');
if (!el) return null;
const raw = (el.textContent || '').trim();
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
return decode(parsed);
} catch (err) {
toastMessage?.(`Failed to parse embedded grid JSON: ${err?.message || err}`, 'danger');
return null;
}
}
export function initGridWidget(root, opts = {}) {
const mode = opts.mode || 'editor';
const storageKey = opts.storageKey ?? 'gridDoc';
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.");
}
const toastMessage = opts.toastMessage || (() => { });
let initialDoc = opts.doc ?? null;
if (!initialDoc && mode !== 'editor') {
initialDoc = readEmbeddedDoc(root, toastMessage);
}
const core = createWidgetCore({
root,
mode,
storageKey,
gridEl,
canvasEl,
viewerOffset: opts.viewerOffset || { x: 0, y: 0 },
doc: initialDoc,
cellSize: opts.cellSize,
shapes: opts.shapes,
loadRaw() {
if (mode !== 'editor') return null;
return localStorage.getItem(storageKey);
},
saveRaw(_rawInternalDoc) {
if (mode !== 'editor') return;
const payload = encode({
cellSize: core.cellSize,
shapes: core.shapes,
stripCaches: core.stripCaches,
SHAPE_DEFAULTS: core.SHAPE_DEFAULTS
});
localStorage.setItem(storageKey, JSON.stringify(payload));
}
});
const env = { root, gridEl, gridWrapEl, toastMessage, storageKey };
if (mode === 'editor') {
const raw = localStorage.getItem(storageKey);
if (raw) {
try {
const decoded = decode(JSON.parse(raw));
core.setDoc(decoded);
} catch {
core.setDoc(DEFAULT_DOC);
}
} else {
const raw = root.dataset.doc;
if (raw) {
try {
const decoded = decode(JSON.parse(raw));
core.setDoc(decoded);
} catch {
core.setDoc(DEFAULT_DOC);
}
} else {
core.setDoc(DEFAULT_DOC);
}
}
} else {
const embedded = initialDoc ?? readEmbeddedDoc(root, toastMessage);
if (embedded) core.setDoc(embedded);
}
let editorApi = null;
if (mode === 'editor') {
editorApi = initWidgetEditor(core, env);
}
let viewerApi = null;
if (mode !== 'editor') {
viewerApi = initWidgetViewer(core, { core, gridEl, gridWrapEl });
}
const api = {
core,
mode,
redraw() { core.redrawAll(); },
destroy() { editorApi?.destroy?.(); },
get doc() { return core.doc; },
get shapes() { return core.shapes; },
get cellSize() { return core.cellSize; },
};
if (editorApi) {
api.handleKeyDown = editorApi.handleKeyDown;
api.handleGlobalPointerUp = editorApi.handleGlobalPointerUp;
api.cancelStroke = editorApi.cancelStroke;
}
if (viewerApi) {
api.setDoc = viewerApi.setDoc;
api.redraw = viewerApi.redraw;
api.destroy = viewerApi.destroy;
api.decode = viewerApi.decode;
}
return api;
}

View file

@ -1,66 +0,0 @@
import { decode } from "./encode-decode.js";
export function initWidgetViewer(core, env) {
const { mode, gridEl, gridWrapEl } = env;
if (mode === 'editor') return null;
let resizeRAF = 0;
function applyViewerBoundsSizing() {
const b = core.getShapesBounds(core.shapes);
const padCells = 0.5;
const wCells = b ? (b.maxX - b.minX + padCells * 2) : 10;
const hCells = b ? (b.maxY - b.minY + padCells * 2) : 10;
const wPx = Math.max(1, Math.ceil(wCells * core.cellSize));
const hPx = Math.max(1, Math.ceil(hCells * core.cellSize));
gridEl.style.width = `${wPx}px`;
gridEl.style.height = `${hPx}px`;
gridWrapEl.style.width = `${wPx}px`;
gridWrapEl.style.height = `${hPx}px`;
if (b) {
core.viewerOffset = {
x: (-b.minX + padCells) * core.cellSize,
y: (-b.minY + padCells) * core.cellSize
};
} else {
core.viewerOffset = { x: 0, y: 0 };
}
}
const scheduleResize = () => {
if (resizeRAF) return;
resizeRAF = requestAnimationFrame(() => {
resizeRAF = 0;
applyViewerBoundsSizing();
core.resizeAndSetupCanvas();
});
};
const ro = new ResizeObserver(scheduleResize);
ro.observe(gridWrapEl);
window.addEventListener('resize', scheduleResize, { passive: true });
requestAnimationFrame(scheduleResize);
function setDoc(nextDoc) {
core.setDoc(nextDoc);
applyViewerBoundsSizing();
core.resizeAndSetupCanvas();
}
return {
setDoc,
redraw: () => core.redrawAll(),
destroy() {
ro.disconnect();
window.removeEventListener('resize', scheduleResize);
},
decode
};
}

View file

@ -1,105 +0,0 @@
const ImageDisplay = globalThis.ImageDisplay ?? (globalThis.ImageDisplay = {});
ImageDisplay.utilities = {
fileInput: document.getElementById('image'),
image: document.getElementById('imageDisplay'),
captionInput: document.getElementById('caption'),
removeButton: document.getElementById('remove-inventory-image'),
imageIdInput: document.getElementById('image_id'),
// set when user selects a new file
_dirty: false,
_removed: false,
onAddButtonClick() {
this.fileInput.click();
},
onRemoveButtonClick() {
// Clear preview back to placeholder
this.image.src = this.image.dataset.placeholder || this.image.src;
this.fileInput.value = '';
this._dirty = false;
this._removed = true;
this.imageIdInput.value = '';
this.removeButton.classList.add('d-none');
},
onFileChange() {
const [file] = this.fileInput.files;
if (!file) {
toastMessage('No file selected!', 'danger');
return;
}
if (!file.type.startsWith("image")) {
toastMessage('Unsupported file type!', 'danger')
this.fileInput.value = '';
return;
}
const url = URL.createObjectURL(file);
this.image.src = url;
if (this.removeButton) {
this.removeButton.classList.remove('d-none');
}
this._dirty = true;
this._removed = false;
},
async uploadIfChanged() {
// If no changes to image, do nothing
if (!this._dirty && !this._removed) return null;
// Removed but not replaced: tell backend to clear image_id
if (this._removed) {
return { remove: true };
}
const [file] = this.fileInput.files;
if (!file) return null;
if(!window.IMAGE_UPLOAD_URL) {
toastMessage('IMAGE_UPLOAD_URL not set', 'danger');
return null;
}
const fd = new FormData();
fd.append('image', file);
if (this.captionInput) {
fd.append('caption', this.captionInput.value || '');
}
if (window.IMAGE_OWNER_MODEL) {
fd.append('model', window.IMAGE_OWNER_MODEL);
}
if (this.imageIdInput && this.imageIdInput.value) {
fd.append('image_id', this.imageIdInput.value);
}
const res = await fetch(window.IMAGE_UPLOAD_URL, {
method: 'POST',
body: fd,
credentials: 'same-origin',
});
const data = await res.json();
if (!res.ok || data.status !== 'success') {
toastMessage(data.error || 'Image upload failed.', 'danger');
throw new Error(data.error || 'Image upload failed.');
}
// Update local state
this.imageIdInput.value = data.id;
this._dirty = false;
this._removed = false;
return {
id: data.id,
filename: data.filename,
url: data.url,
};
},
};

View file

@ -1,266 +0,0 @@
{% macro drawWidget(uid) %}
<div class="grid-widget" data-grid-widget data-mode="editor" data-storage-key="gridDoc:{{ uid }}">
<div data-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 toolbar">
<div class="toolbar-row toolbar-row--primary">
<div class="toolbar-group">
<div class="btn-group">
<input type="radio" class="btn-check" value="pen" name="tool-{{ uid }}" id="tool-pen-{{ uid }}"
data-tool checked>
<label for="tool-pen-{{ uid }}"
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" value="line" name="tool-{{ uid }}" id="tool-line-{{ uid }}"
data-tool>
<label for="tool-line-{{ uid }}"
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" viewBox="0 0 16 16" fill="none"
aria-hidden="true">
<path d="M4 12 L12 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<circle cx="4" cy="12" r="1.5" fill="currentColor" />
<circle cx="12" cy="4" r="1.5" fill="currentColor" />
</svg>
</label>
<input type="radio" class="btn-check" value="outline" name="tool-{{ uid }}"
id="tool-outline-{{ uid }}" data-tool>
<label for="tool-outline-{{ uid }}"
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" value="filled" name="tool-{{ uid }}"
id="tool-filled-{{ uid }}" data-tool>
<label for="tool-filled-{{ uid }}"
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" value="outlineEllipse" name="tool-{{ uid }}"
id="tool-outline-ellipse-{{ uid }}" data-tool>
<label for="tool-outline-ellipse-{{ uid }}"
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" value="filledEllipse" name="tool-{{ uid }}"
id="tool-filled-ellipse-{{ uid }}" data-tool>
<label for="tool-filled-ellipse-{{ uid }}"
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>
</div>
<input type="color" class="form-control form-control-sm form-control-color" data-color>
</div>
<div class="toolbar-group">
<div class="btn-group">
<button type="button"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center"
data-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" data-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"
data-import-button>
<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"
data-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>
<div class="toolbar-row toolbar-row--secondary">
<div class="toolbar-group">
<div class="btn-group">
<input type="radio" class="btn-check" name="gridType-{{ uid }}" value="noGrid"
id="type-no-grid-{{ uid }}" data-gridtype checked>
<label for="type-no-grid-{{ uid }}"
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-{{ uid }}" value="horizontalGrid"
id="type-horizontal-{{ uid }}" data-gridtype>
<label for="type-horizontal-{{ uid }}"
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-{{ uid }}" value="verticalGrid"
id="type-vertical-{{ uid }}" data-gridtype>
<label for="type-vertical-{{ uid }}"
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-{{ uid }}" value="fullGrid"
id="type-full-{{ uid }}" data-gridtype>
<label for="type-full-{{ uid }}"
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>
<div class="toolbar-group">
<div class="dropdown">
<button type="button" class="btn tb-btn btn-light dropdown-toggle border" data-bs-toggle="dropdown">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-grid-3x3-gap" viewBox="0 0 16 16">
<path
d="M4 2v2H2V2zm1 12v-2a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1m0-5V7a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1m0-5V2a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1m5 10v-2a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1m0-5V7a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1m0-5V2a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1M9 2v2H7V2zm5 0v2h-2V2zM4 7v2H2V7zm5 0v2H7V7zm5 0h-2v2h2zM4 12v2H2v-2zm5 0v2H7v-2zm5 0v2h-2v-2zM12 1a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zm-1 6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1zm1 4a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1z" />
</svg>
<small data-cell-size-val>25px</small>
</button>
<ul class="dropdown-menu p-2">
<li>
<div class="small text-secondary mb-1">Cell Size</div>
<input type="range" min="1" max="100" step="1" value="25" data-cell-size
class="form-range w-100">
</li>
</ul>
</div>
<div class="dropdown">
<button type="button" class="btn tb-btn btn-light dropdown-toggle border" data-bs-toggle="dropdown">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-droplet-fill" viewBox="0 0 16 16">
<path
d="M8 16a6 6 0 0 0 6-6c0-1.655-1.122-2.904-2.432-4.362C10.254 4.176 8.75 2.503 8 0c0 0-6 5.686-6 10a6 6 0 0 0 6 6M6.646 4.646l.708.708c-.29.29-1.128 1.311-1.907 2.87l-.894-.448c.82-1.641 1.717-2.753 2.093-3.13" />
</svg>
<small data-fill-opacity-val>100%</small>
</button>
<ul class="dropdown-menu p-2">
<li>
<div class="small text-secondary mb-1">Fill Opacity</div>
<input type="range" min="0" max="1" step="0.01" value="1" data-fill-opacity
class="form-range w-100">
</li>
</ul>
</div>
<div class="dropdown">
<button type="button" class="btn tb-btn btn-light dropdown-toggle border" data-bs-toggle="dropdown">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-dash-square-dotted" viewBox="0 0 16 16">
<path
d="M2.5 0q-.25 0-.487.048l.194.98A1.5 1.5 0 0 1 2.5 1h.458V0zm2.292 0h-.917v1h.917zm1.833 0h-.917v1h.917zm1.833 0h-.916v1h.916zm1.834 0h-.917v1h.917zm1.833 0h-.917v1h.917zM13.5 0h-.458v1h.458q.151 0 .293.029l.194-.981A2.5 2.5 0 0 0 13.5 0m2.079 1.11a2.5 2.5 0 0 0-.69-.689l-.556.831q.248.167.415.415l.83-.556zM1.11.421a2.5 2.5 0 0 0-.689.69l.831.556c.11-.164.251-.305.415-.415zM16 2.5q0-.25-.048-.487l-.98.194q.027.141.028.293v.458h1zM.048 2.013A2.5 2.5 0 0 0 0 2.5v.458h1V2.5q0-.151.029-.293zM0 3.875v.917h1v-.917zm16 .917v-.917h-1v.917zM0 5.708v.917h1v-.917zm16 .917v-.917h-1v.917zM0 7.542v.916h1v-.916zm15 .916h1v-.916h-1zM0 9.375v.917h1v-.917zm16 .917v-.917h-1v.917zm-16 .916v.917h1v-.917zm16 .917v-.917h-1v.917zm-16 .917v.458q0 .25.048.487l.98-.194A1.5 1.5 0 0 1 1 13.5v-.458zm16 .458v-.458h-1v.458q0 .151-.029.293l.981.194Q16 13.75 16 13.5M.421 14.89c.183.272.417.506.69.689l.556-.831a1.5 1.5 0 0 1-.415-.415zm14.469.689c.272-.183.506-.417.689-.69l-.831-.556c-.11.164-.251.305-.415.415l.556.83zm-12.877.373Q2.25 16 2.5 16h.458v-1H2.5q-.151 0-.293-.029zM13.5 16q.25 0 .487-.048l-.194-.98A1.5 1.5 0 0 1 13.5 15h-.458v1zm-9.625 0h.917v-1h-.917zm1.833 0h.917v-1h-.917zm1.834 0h.916v-1h-.916zm1.833 0h.917v-1h-.917zm1.833 0h.917v-1h-.917zM4.5 7.5a.5.5 0 0 0 0 1h7a.5.5 0 0 0 0-1z" />
</svg>
<small data-stroke-opacity-val>100%</small>
</button>
<ul class="dropdown-menu p-2">
<li>
<div class="small text-secondary mb-1">Stroke Opacity</div>
<input type="range" min="0" max="1" step="0.01" value="1" data-stroke-opacity
class="form-range w-100">
</li>
</ul>
</div>
<div class="dropdown">
<button type="button" class="btn tb-btn btn-light dropdown-toggle border" data-bs-toggle="dropdown">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"
fill="currentColor" class="me-1" aria-hidden="true">
<rect x="2" y="3" width="12" height="1" rx=".5"></rect>
<rect x="2" y="7" width="12" height="2" rx="1"></rect>
<rect x="2" y="12" width="12" height="3" rx="1.5"></rect>
</svg>
<small data-stroke-width-val>12%</small>
</button>
<ul class="dropdown-menu p-2">
<li>
<div class="small text-secondary mb-1">Stroke Width</div>
<input type="range" min="0" max="1" step="0.01" value="0.12" data-stroke-width
class="form-range w-100">
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="grid-wrap" data-grid-wrap>
<span class="position-absolute p-0 m-0 d-none dot" data-dot>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" data-dot-svg>
<circle cx="16" cy="16" r="4" fill="black" />
</svg>
</span>
<div class="position-relative overflow-hidden grid" data-grid>
<div class="border border-black position-absolute d-none bg-warning-subtle px-1 py-0 user-select-none coords"
data-coords></div>
<canvas class="position-absolute w-100 h-100" data-canvas></canvas>
</div>
</div>
</div>
{% endmacro %}
{% macro viewWidget(uid, json) %}
<span class="grid-widget" data-grid-widget data-mode="viewer" data-storage-key="gridDoc:{{ uid }}">
<span class="grid-wrap" data-grid-wrap>
<span class="position-absolute p-0 m-0 d-none dot" data-dot>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" data-dot-svg>
<circle cx="16" cy="16" r="4" fill="black" />
</svg>
</span>
<span class="position-relative overflow-hidden grid" data-grid>
<span
class="border border-black position-absolute d-none bg-warning-subtle px-1 py-0 user-select-none coords"
data-coords></span>
<canvas class="position-absolute" data-canvas></canvas>
</span>
</span>
<script type="application/json" data-grid-doc>
{{ json | safe }}
</script>
</span>
{% endmacro %}

View file

@ -35,8 +35,10 @@
{% else %}
{% set sel_label = "-- Select --" %}
{% endif %}
<!-- button type="button" class="btn btn-outline-dark d-block w-100 text-start dropdown-toggle inventory-dropdown"
id="{{ field_name }}-button" data-bs-toggle="dropdown" data-value="{{ value }}">{{ sel_label }}</button -->
<input type="button" class="form-control btn btn-outline-dark d-block w-100 text-start dropdown-toggle inventory-dropdown"
id="{{ field_name }}-button" data-bs-toggle="dropdown" data-value="{{ value }}" data-field="{{ field_name }}" value="{{ sel_label }}">
id="{{ field_name }}-button" data-bs-toggle="dropdown" data-value="{{ value }}" value="{{ sel_label }}">
<div class="dropdown-menu pt-0" id="{{ field_name }}-dropdown">
<input type="text" class="form-control mt-0 border-top-0 border-start-0 border-end-0 rounded-bottom-0"
id="{{ field_name }}-filter" placeholder="Filter..." oninput="DropDown.utilities.filterList('{{ field_name }}')">

View file

@ -2,7 +2,6 @@
{% block styleincludes %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/dropdown.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/image_display.css') }}">
{% endblock %}
{% block main %}
@ -13,6 +12,5 @@
{% endblock %}
{% block scriptincludes %}
<script src="{{ url_for('static', filename='js/components/image_display.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/components/dropdown.js') }}" defer></script>
{% endblock %}

View file

@ -136,25 +136,8 @@ formEl.addEventListener('submit', async e => {
const submitBtn = document.getElementById('submit');
submitBtn.disabled = true;
try {
const json = formToJson(formEl);
if (model === 'inventory') {
// the file input 'image' must NOT go into the JSON at all
delete json.image;
}
// Handle image for inventory
if (model === 'inventory' && globalThis.ImageDisplay?.utilities) {
const imgResult = await ImageDisplay.utilities.uploadIfChanged();
if (imgResult?.remove) {
json.image_id = null;
} else if (imgResult && imgResult.id) {
json.image_id = imgResult.id; // ✅ this, and ONLY this
}
}
if (model === 'inventory' && typeof getMarkdown === 'function') {
const md = getMarkdown();
json.notes = (typeof md === 'string') ? md.trim() : '';
@ -167,27 +150,23 @@ formEl.addEventListener('submit', async e => {
const url = hasId ? updateUrl : createUrl;
console.log('Submitting JSON:', json);
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(json),
credentials: 'same-origin'
});
const reply = await res.json();
if (reply.status === 'success') {
window.newDrafts = [];
window.deletedIds = [];
if (!hasId && reply.id) {
queueToast('Created successfully.', 'success');
location.assign(`/entry/${model}/${reply.id}`);
return;
}
queueToast('Updated successfully.', 'success');
if (model === 'worklog') {

View file

@ -1,43 +1,6 @@
{% set model = field['attrs']['data-model'] %}
{% set image_id = field['template_ctx']['values'].get('image_id') %}
{% set buttons %}
<div class="btn-group position-absolute end-0 top-0 mt-2 me-2 border image-buttons">
<button type="button" class="btn btn-light" id="add-inventory-image"
onclick="ImageDisplay.utilities.onAddButtonClick();"><svg xmlns="http://www.w3.org/2000/svg" width="16"
height="16" fill="currentColor" class="bi bi-plus-lg" viewBox="0 0 16 16">
<path fill-rule="evenodd"
d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5h-5a.5.5 0 0 1 0-1h5v-5A.5.5 0 0 1 8 2" />
</svg></button>
<button type="button" class="btn btn-danger{% if not value %} d-none{% endif %}" id="remove-inventory-image"
onclick="ImageDisplay.utilities.onRemoveButtonClick();">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash"
viewBox="0 0 16 16">
<path
d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0z" />
<path
d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4zM2.5 3h11V2h-11z" />
</svg>
</button>
</div>
{% endset %}
{{ buttons }}
{% if value %}
<img src="{{ url_for('static', filename=field['value_label']) }}" alt="{{ value }}" {% if field['attrs'] %}{% for k,v in
field['attrs'].items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}
style="min-width: 200px; min-height: 200px;" id="imageDisplay" data-placeholder="{{ url_for('static', filename='images/noimage.svg') }}">
field['attrs'].items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}>
{% else %}
<img src="{{ url_for('static', filename='images/noimage.svg') }}" class="img-fluid img-thumbnail h-100"
style="min-width: 200px; min-height: 200px;" id="imageDisplay" data-placeholder="{{ url_for('static', filename='images/noimage.svg') }}">
<img src="{{ url_for('static', filename='images/noimage.svg') }}" class="img-fluid img-thumbnail h-100">
{% endif %}
<input type="text" class="form-control" id="caption" name="caption"
value="{{ field['template_ctx']['values']['image.caption'] if value else '' }}">
<input type="hidden" id="image_id" name="image_id" value="{{ image_id if image_id is not none else '' }}">
<input type="file" class="d-none" name="image" id="image" accept="image/*"
onchange="ImageDisplay.utilities.onFileChange();">
<script>
// URL for image upload
window.IMAGE_UPLOAD_URL = {{ url_for('image.upload_image') | tojson }};
window.IMAGE_OWNER_MODEL = {{ model | tojson }};
</script>

View file

@ -1,42 +0,0 @@
{% extends 'base.html' %}
{% import 'components/draw.html' as draw %}
{% block styleincludes %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/draw.css') }}">
{% endblock %}
{% block main %}
{% set jsonImage %}
{"v":1,"cs":5,"q":100,"d":{"cl":"#000000","f":false,"sw":12,"so":100,"fo":100},"s":[{"t":"s","cl":"#ffff00","f":true},{"t":"e","p":[0,0,2500,2500]},{"t":"s","cl":"#ffffff"},{"t":"e","p":[500,600,600,600,300,-600,600,600]},{"t":"s","cl":"#000000"},{"t":"e","p":[700,800,200,200,700,-200,200,200,-800,300,500,1000]},{"t":"s","f":false},{"t":"e","p":[500,600,600,600,300,-600,600,600,-2000,-1200,2500,2500]},{"t":"l","p":[400,700,500,-300,1200,300,-500,-300]}]}
{% endset %}
{% set jsonImage2 %}
{"v":1,"cs":5,"q":100,"d":{"cl":"#000000","f":false,"sw":12,"so":100,"fo":100},"s":[{"t":"s","cl":"#ffe59e","f":true},{"t":"e","p":[0,0,2500,2500]},{"t":"s","cl":"#fdc8fe"},{"t":"e","p":[100,100,2300,2300]},{"t":"s","cl":"#fbe6a1"},{"t":"e","p":[600,600,1300,1300]},{"t":"s","cl":"#ffffff"},{"t":"e","p":[700,700,1100,1100]},{"t":"s","cl":"#000000","f":false},{"t":"e","p":[0,0,2500,2500,-2400,-2400,2300,2300,-1800,-1800,1300,1300,-1200,-1200,1100,1100]},{"t":"s","sw":37,"cl":"#ff0000"},{"t":"l","p":[600,500,100,-100,500,0,100,0,500,200,100,100,200,200,100,-100,-600,-400,0,-100,-800,300,-100,100,-400,800,0,-100,100,-400,-100,-100,200,1000,100,0,-100,-600,-100,-100,500,1000,100,-100,-200,-200,0,100,500,200,100,-100,200,-200,100,100,-500,0,-100,100,800,-400,100,100,0,-400,100,100,0,-300,100,0,-200,-100,0,-100,0,-400,0,100,-400,-200,100,0,-600,-200,100,-100,-300,100,0,100,-300,500,-100,-100,0,900,100,-100,1300,400,100,-100,200,-200,0,-100,100,-200,0,-100]},{"t":"s","cl":"#00ff00"},{"t":"l","p":[400,700,100,0,500,-200,0,-100,400,-200,0,100,100,200,100,100,200,-200,100,100,300,600,100,0,-400,-300,100,100,100,400,100,0,100,200,-100,0,-200,100,100,100,0,200,100,-100,-400,100,0,-100,-100,300,100,0,-200,200,0,-100,-100,-200,0,100,-200,200,100,0,-300,0,0,-100,200,-100,100,-100,-400,0,100,0,-300,100,100,0,-200,-300,0,100,-200,-100,100,0,-200,-100,0,-100,100,-100,0,-100,-300,-100,100,0,0,-200,100,0,100,-200,0,100,100,-400,100,0]},{"t":"s","cl":"#0000ff"},{"t":"l","p":[800,400,0,100,300,0,100,0,200,-100,100,-100,200,0,0,100,300,100,100,100,0,200,0,-100,0,300,100,0,-200,300,0,-100,100,200,100,0,-300,100,0,100,0,200,0,100,-200,-100,0,100,200,200,-100,-100,0,200,-100,0,-100,-100,0,-100,-100,200,-100,0,-200,100,0,-100,-300,100,100,-100,-100,-300,0,100,-300,100,100,0,-200,-100,100,0,-200,-100,-100,-100,0,-200,100,-100,0,-200,-100,-100,100,-400,-100,0,200,300,100,-100,100,-200,-100,-100,300,-100,100,0,-500,-100,-100,100,1300,100,100,100,-1200,900,100,0]}]}
{% endset %}
<div class="row">
<div class="col" style="height: 80vh">
{{ draw.drawWidget('test1') }}
</div>
<!-- div class="col">
{{ draw.drawWidget('test4') }}
</div>
</div>
<div class="row">
<div class="col">
{{ draw.drawWidget('test5') }}
</div>
<div class="col">
{{ draw.drawWidget('test6') }}
</div -->
<div class="col" style="height: 80vh;">
I am testing a thing.
{{ draw.viewWidget('test2', jsonImage) }}
{{ draw.viewWidget('test3', jsonImage2) }}
The thing has been tested.
</div>
</div>
{% endblock %}
{% block scriptincludes %}
<script type="module" src="{{ url_for('static', filename='js/components/grid/index.js') }}"></script>
{% endblock %}