diff --git a/inventory/__init__.py b/inventory/__init__.py index f19a417..9975841 100644 --- a/inventory/__init__.py +++ b/inventory/__init__.py @@ -18,11 +18,13 @@ 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__) @@ -98,11 +100,13 @@ 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 6c9db32..65a445a 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, case, cast, func +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Unicode, UnicodeText, 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(Unicode(255)) + notes: Mapped[Optional[str]] = mapped_column(UnicodeText) 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 4e02903..c9dbcf3 100644 --- a/inventory/routes/entry.py +++ b/inventory/routes/entry.py @@ -27,6 +27,7 @@ def _fields_for_model(model: str): "notes", "owner.id", "image.filename", + "image.caption", ] fields_spec = [ {"name": "label", "type": "display", "label": "", "row": "label", @@ -55,8 +56,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"}, - "wrap": {"class": "h-100 w-100"}}, + "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;"}}, {"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"}, @@ -349,6 +350,11 @@ 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 @@ -402,6 +408,8 @@ 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 [] @@ -460,6 +468,13 @@ 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 new file mode 100644 index 0000000..487552c --- /dev/null +++ b/inventory/routes/image.py @@ -0,0 +1,89 @@ +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 7a605ae..8bc4950 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|owner.label__icontains': q, + 'notes|label|model|serial|barcode|name|owner.label__icontains': q, 'fields': [ "label", "name", diff --git a/inventory/routes/testing.py b/inventory/routes/testing.py new file mode 100644 index 0000000..2510f71 --- /dev/null +++ b/inventory/routes/testing.py @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..dd9927b --- /dev/null +++ b/inventory/static/css/components/draw.css @@ -0,0 +1,222 @@ +: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 new file mode 100644 index 0000000..c2c582d --- /dev/null +++ b/inventory/static/css/components/image_display.css @@ -0,0 +1,10 @@ +.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 72bc8e8..90ae355 100644 --- a/inventory/static/js/components/dropdown.js +++ b/inventory/static/js/components/dropdown.js @@ -64,7 +64,44 @@ DropDown.utilities = { } function onShow(e) { - setMenuMaxHeight(e.target); + // 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); } function onResize() { @@ -78,7 +115,7 @@ DropDown.utilities = { function init(root = document) { // Delegate so dynamically-added dropdowns work too - root.addEventListener('show.bs.dropdown', onShow); + root.addEventListener('shown.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 new file mode 100644 index 0000000..88ffdbb --- /dev/null +++ b/inventory/static/js/components/grid/encode-decode.js @@ -0,0 +1,438 @@ +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 new file mode 100644 index 0000000..0f06f6d --- /dev/null +++ b/inventory/static/js/components/grid/geometry.js @@ -0,0 +1,117 @@ +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 new file mode 100644 index 0000000..1ead43d --- /dev/null +++ b/inventory/static/js/components/grid/global-bindings.js @@ -0,0 +1,23 @@ +(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 new file mode 100644 index 0000000..ad50c6a --- /dev/null +++ b/inventory/static/js/components/grid/index.js @@ -0,0 +1,46 @@ +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 new file mode 100644 index 0000000..9ead5a6 --- /dev/null +++ b/inventory/static/js/components/grid/simplify.js @@ -0,0 +1,42 @@ +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 new file mode 100644 index 0000000..573cefc --- /dev/null +++ b/inventory/static/js/components/grid/spline.js @@ -0,0 +1,90 @@ +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 new file mode 100644 index 0000000..21cd761 --- /dev/null +++ b/inventory/static/js/components/grid/widget-core.js @@ -0,0 +1,472 @@ +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 new file mode 100644 index 0000000..8e2c025 --- /dev/null +++ b/inventory/static/js/components/grid/widget-editor.js @@ -0,0 +1,831 @@ +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 new file mode 100644 index 0000000..69218ba --- /dev/null +++ b/inventory/static/js/components/grid/widget-init.js @@ -0,0 +1,136 @@ +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 new file mode 100644 index 0000000..b6de808 --- /dev/null +++ b/inventory/static/js/components/grid/widget-viewer.js @@ -0,0 +1,66 @@ +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 new file mode 100644 index 0000000..b174250 --- /dev/null +++ b/inventory/static/js/components/image_display.js @@ -0,0 +1,105 @@ +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 new file mode 100644 index 0000000..f6b8f19 --- /dev/null +++ b/inventory/templates/components/draw.html @@ -0,0 +1,266 @@ +{% 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 aa33953..bf6f749 100644 --- a/inventory/templates/crudkit/field.html +++ b/inventory/templates/crudkit/field.html @@ -35,10 +35,8 @@ {% else %} {% set sel_label = "-- Select --" %} {% endif %} - + id="{{ field_name }}-button" data-bs-toggle="dropdown" data-value="{{ value }}" data-field="{{ field_name }}" value="{{ sel_label }}"> \ No newline at end of file diff --git a/inventory/templates/image_display.html b/inventory/templates/image_display.html index ca32078..5e2c496 100644 --- a/inventory/templates/image_display.html +++ b/inventory/templates/image_display.html @@ -1,6 +1,43 @@ +{% 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 %} + style="min-width: 200px; min-height: 200px;" id="imageDisplay" data-placeholder="{{ url_for('static', filename='images/noimage.svg') }}"> {% else %} - -{% endif %} \ No newline at end of file + +{% endif %} + + + + + diff --git a/inventory/templates/testing.html b/inventory/templates/testing.html new file mode 100644 index 0000000..cdf3e90 --- /dev/null +++ b/inventory/templates/testing.html @@ -0,0 +1,42 @@ +{% 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