diff --git a/inventory/__init__.py b/inventory/__init__.py index 9975841..f19a417 100644 --- a/inventory/__init__.py +++ b/inventory/__init__.py @@ -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): diff --git a/inventory/models/inventory.py b/inventory/models/inventory.py index 65a445a..6c9db32 100644 --- a/inventory/models/inventory.py +++ b/inventory/models/inventory.py @@ -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) diff --git a/inventory/routes/entry.py b/inventory/routes/entry.py index c9dbcf3..4e02903 100644 --- a/inventory/routes/entry.py +++ b/inventory/routes/entry.py @@ -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) diff --git a/inventory/routes/image.py b/inventory/routes/image.py deleted file mode 100644 index 487552c..0000000 --- a/inventory/routes/image.py +++ /dev/null @@ -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//_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//_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) diff --git a/inventory/routes/search.py b/inventory/routes/search.py index 8bc4950..7a605ae 100644 --- a/inventory/routes/search.py +++ b/inventory/routes/search.py @@ -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", diff --git a/inventory/routes/testing.py b/inventory/routes/testing.py deleted file mode 100644 index 2510f71..0000000 --- a/inventory/routes/testing.py +++ /dev/null @@ -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) \ No newline at end of file diff --git a/inventory/static/css/components/draw.css b/inventory/static/css/components/draw.css deleted file mode 100644 index dd9927b..0000000 --- a/inventory/static/css/components/draw.css +++ /dev/null @@ -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 ) - ========================================================= */ - -.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; } diff --git a/inventory/static/css/components/image_display.css b/inventory/static/css/components/image_display.css deleted file mode 100644 index c2c582d..0000000 --- a/inventory/static/css/components/image_display.css +++ /dev/null @@ -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; -} diff --git a/inventory/static/js/components/dropdown.js b/inventory/static/js/components/dropdown.js index 90ae355..72bc8e8 100644 --- a/inventory/static/js/components/dropdown.js +++ b/inventory/static/js/components/dropdown.js @@ -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); } diff --git a/inventory/static/js/components/grid/encode-decode.js b/inventory/static/js/components/grid/encode-decode.js deleted file mode 100644 index 88ffdbb..0000000 --- a/inventory/static/js/components/grid/encode-decode.js +++ /dev/null @@ -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 - }; -} \ No newline at end of file diff --git a/inventory/static/js/components/grid/geometry.js b/inventory/static/js/components/grid/geometry.js deleted file mode 100644 index 0f06f6d..0000000 --- a/inventory/static/js/components/grid/geometry.js +++ /dev/null @@ -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; -} diff --git a/inventory/static/js/components/grid/global-bindings.js b/inventory/static/js/components/grid/global-bindings.js deleted file mode 100644 index 1ead43d..0000000 --- a/inventory/static/js/components/grid/global-bindings.js +++ /dev/null @@ -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 }); -})(); diff --git a/inventory/static/js/components/grid/index.js b/inventory/static/js/components/grid/index.js deleted file mode 100644 index ad50c6a..0000000 --- a/inventory/static/js/components/grid/index.js +++ /dev/null @@ -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 }); -})(); diff --git a/inventory/static/js/components/grid/simplify.js b/inventory/static/js/components/grid/simplify.js deleted file mode 100644 index 9ead5a6..0000000 --- a/inventory/static/js/components/grid/simplify.js +++ /dev/null @@ -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; -} diff --git a/inventory/static/js/components/grid/spline.js b/inventory/static/js/components/grid/spline.js deleted file mode 100644 index 573cefc..0000000 --- a/inventory/static/js/components/grid/spline.js +++ /dev/null @@ -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; -} diff --git a/inventory/static/js/components/grid/widget-core.js b/inventory/static/js/components/grid/widget-core.js deleted file mode 100644 index 21cd761..0000000 --- a/inventory/static/js/components/grid/widget-core.js +++ /dev/null @@ -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, - }; -} \ No newline at end of file diff --git a/inventory/static/js/components/grid/widget-editor.js b/inventory/static/js/components/grid/widget-editor.js deleted file mode 100644 index 8e2c025..0000000 --- a/inventory/static/js/components/grid/widget-editor.js +++ /dev/null @@ -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() - }; - } - }); -} \ No newline at end of file diff --git a/inventory/static/js/components/grid/widget-init.js b/inventory/static/js/components/grid/widget-init.js deleted file mode 100644 index 69218ba..0000000 --- a/inventory/static/js/components/grid/widget-init.js +++ /dev/null @@ -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; -} diff --git a/inventory/static/js/components/grid/widget-viewer.js b/inventory/static/js/components/grid/widget-viewer.js deleted file mode 100644 index b6de808..0000000 --- a/inventory/static/js/components/grid/widget-viewer.js +++ /dev/null @@ -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 - }; -} \ No newline at end of file diff --git a/inventory/static/js/components/image_display.js b/inventory/static/js/components/image_display.js deleted file mode 100644 index b174250..0000000 --- a/inventory/static/js/components/image_display.js +++ /dev/null @@ -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, - }; - }, -}; diff --git a/inventory/templates/components/draw.html b/inventory/templates/components/draw.html deleted file mode 100644 index f6b8f19..0000000 --- a/inventory/templates/components/draw.html +++ /dev/null @@ -1,266 +0,0 @@ -{% macro drawWidget(uid) %} -
-
-
-
-
- - - - - - - - - - - - - - - - - -
- -
-
-
- - - - -
-
-
-
-
-
- - - - - - - - -
-
-
- - - - - - - -
-
-
-
- - - - - -
-
- -
-
-
-{% endmacro %} - -{% macro viewWidget(uid, json) %} - - - - - - - - - - - - - - - -{% endmacro %} \ No newline at end of file diff --git a/inventory/templates/crudkit/field.html b/inventory/templates/crudkit/field.html index bf6f749..aa33953 100644 --- a/inventory/templates/crudkit/field.html +++ b/inventory/templates/crudkit/field.html @@ -35,8 +35,10 @@ {% else %} {% set sel_label = "-- Select --" %} {% endif %} + + id="{{ field_name }}-button" data-bs-toggle="dropdown" data-value="{{ value }}" value="{{ sel_label }}"> \ No newline at end of file diff --git a/inventory/templates/image_display.html b/inventory/templates/image_display.html index 5e2c496..ca32078 100644 --- a/inventory/templates/image_display.html +++ b/inventory/templates/image_display.html @@ -1,43 +1,6 @@ -{% set model = field['attrs']['data-model'] %} -{% set image_id = field['template_ctx']['values'].get('image_id') %} - -{% set buttons %} -
- - -
-{% endset %} -{{ buttons }} {% if value %} {{ value }} + field['attrs'].items() %} {{k}}{% if v is not sameas true %}="{{ v }}" {% endif %} {% endfor %}{% endif %}> {% else %} - -{% endif %} - - - - - + +{% endif %} \ No newline at end of file diff --git a/inventory/templates/testing.html b/inventory/templates/testing.html deleted file mode 100644 index cdf3e90..0000000 --- a/inventory/templates/testing.html +++ /dev/null @@ -1,42 +0,0 @@ -{% extends 'base.html' %} - -{% import 'components/draw.html' as draw %} - -{% block styleincludes %} - -{% 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 %} -
-
- {{ draw.drawWidget('test1') }} -
- -
- I am testing a thing. - {{ draw.viewWidget('test2', jsonImage) }} - {{ draw.viewWidget('test3', jsonImage2) }} - The thing has been tested. -
-
-{% endblock %} - -{% block scriptincludes %} - -{% endblock %} \ No newline at end of file