Compare commits

...
Sign in to create a new pull request.

64 commits
stable ... main

Author SHA1 Message Date
Yaro Kasear
d6bb3d8780 All good. Now for more fun. 2026-01-21 13:17:43 -06:00
Yaro Kasear
ea1f43dcd3 Massive refactor. 2026-01-21 10:49:05 -06:00
Yaro Kasear
2561127221 Tweaking path smoothing and simplification. 2026-01-14 13:26:13 -06:00
Yaro Kasear
059d5ee9ba Make minStep jitter handling more dynamic. 2026-01-14 11:37:00 -06:00
Yaro Kasear
27a29d9c66 Limit the "edges" of the grid. 2026-01-14 11:20:01 -06:00
Yaro Kasear
41fafae501 Add further validation to encodeStates. 2026-01-14 11:12:38 -06:00
Yaro Kasear
82c3ea2b90 Various bug fixes. 2026-01-12 16:35:20 -06:00
Yaro Kasear
cc32b2214c Delta differential coding 2026-01-12 15:12:11 -06:00
Yaro Kasear
5a2f480ef7 Selection! 2026-01-12 10:59:26 -06:00
Yaro Kasear
4e2cd2b0e5 Blah 2026-01-09 16:23:09 -06:00
Yaro Kasear
760fe603c9 Minor fix in the encoder for quantizing state changes. 2026-01-09 14:26:12 -06:00
Yaro Kasear
296f29db0c New encoding format!!!! 2026-01-09 13:48:58 -06:00
Yaro Kasear
1ee3a05ab9 Apply a needed fix to notes. 2026-01-09 09:35:43 -06:00
Yaro Kasear
62629c6a3d Fixing encoding a bit for space. 2026-01-09 08:45:38 -06:00
Yaro Kasear
2993bc400a Changing test image. 2026-01-08 16:06:13 -06:00
Yaro Kasear
6d711474df Final fixes to CSS. 2026-01-08 11:58:53 -06:00
Yaro Kasear
c51ef38d99 Fix viewer translation. 2026-01-08 09:51:47 -06:00
Yaro Kasear
34d4a28622 Resizing viewer. 2026-01-08 09:36:09 -06:00
Yaro Kasear
6ab8ce4069 Viewer is working, in theory! 2026-01-07 10:03:17 -06:00
Yaro Kasear
e4b59b124e Catmull-Rom smoothing! 2026-01-07 09:36:35 -06:00
Yaro Kasear
63f3ad1394 Some fixes. 2026-01-06 15:12:40 -06:00
Yaro Kasear
ff2734fff6 More adjustments to js and HTML. 2026-01-06 15:04:02 -06:00
Yaro Kasear
585c4abb25 Code refactor to start working on viewer. 2026-01-06 14:39:26 -06:00
Yaro Kasear
429e993009 Line simplification!!! 2026-01-06 10:01:25 -06:00
Yaro Kasear
a1cf260072 More sizing improvements. 2025-12-31 13:56:06 -06:00
Yaro Kasear
86d24ccb43 More improvements on style of draw widget. 2025-12-31 13:45:06 -06:00
Yaro Kasear
3864f27cc6 More improvement for toolbar. 2025-12-31 13:06:41 -06:00
Yaro Kasear
a05218b985 Various improvements to make things happier. 2025-12-31 09:19:11 -06:00
Yaro Kasear
6f175c103e Fix dropdown behavior. 2025-12-31 08:48:35 -06:00
Yaro Kasear
d2061f5c1c More UI improvements. 2025-12-19 15:55:37 -06:00
Yaro Kasear
4c98de2eef Trying to make UX improvements. 2025-12-19 10:52:47 -06:00
Yaro Kasear
03804cc476 Fix undo/redo not firing when in a text box. 2025-12-18 11:22:57 -06:00
Yaro Kasear
65beb3509c Adding some opacity and stroke width control. 2025-12-18 09:59:01 -06:00
ce562d34de Multiple widget support? 2025-12-17 14:08:38 -06:00
5dfc2691e9 Fix to finalizing shape behavior. 2025-12-17 12:29:58 -06:00
b4c448d572 Fix naming. 2025-12-17 12:01:12 -06:00
24b74f78c0 Pen tool added. 2025-12-17 11:40:26 -06:00
c5cc368ef9 Move the dot outside the grid. 2025-12-17 10:36:42 -06:00
e8d9d1a330 Add undo/redo. 2025-12-17 09:59:32 -06:00
Yaro Kasear
641ae1470d Opacity added. 2025-12-16 15:22:23 -06:00
Yaro Kasear
802c3cd028 Fix sanitizeShapes. 2025-12-16 10:54:39 -06:00
Yaro Kasear
9d22c55aba Some more improvements. 2025-12-16 10:48:21 -06:00
Yaro Kasear
8d5ddca229 More work on drawing widget. 2025-12-15 14:25:37 -06:00
Yaro Kasear
624ebf09d3 Getting more resize and reset behavior. 2025-12-12 14:30:54 -06:00
Yaro Kasear
1926a3930d REmoving snapSizeToGrid 2025-12-12 12:13:25 -06:00
Yaro Kasear
0b85715c1e Better grid resizing behavior. 2025-12-12 12:10:14 -06:00
Yaro Kasear
3f4aee73a3 Add grid scaling. 2025-12-12 11:21:18 -06:00
Yaro Kasear
0992cf97eb More minor tweaks. 2025-12-12 10:17:38 -06:00
Yaro Kasear
47942cc6b6 Fixing more annoyign bugs. 2025-12-12 10:03:43 -06:00
Yaro Kasear
4fe3dfb8b4 Bug fixes! 2025-12-12 09:40:05 -06:00
Yaro Kasear
8abf9bdcdf Fix coordinate printing. 2025-12-09 14:47:52 -06:00
Yaro Kasear
0fb1991b5a Remove dot in no-grid mode. 2025-12-09 11:31:47 -06:00
Yaro Kasear
292ca0798c Fix some pointer behavior. 2025-12-09 11:26:39 -06:00
Yaro Kasear
9ddbacb4de Update coords box to behave based on grid mode. 2025-12-09 11:19:16 -06:00
Yaro Kasear
5cc47c4a81 More grid support. 2025-12-09 11:06:57 -06:00
Yaro Kasear
87ec637ac1 Adding grid settings buttons. 2025-12-08 16:32:53 -06:00
Yaro Kasear
fc95c87e84 More features added. 2025-12-05 09:57:54 -06:00
Yaro Kasear
d207f1da2c More features added to our draw widget. 2025-12-05 09:29:50 -06:00
Yaro Kasear
55f18b1cbe Hey, I added lines! 2025-12-04 16:15:58 -06:00
Yaro Kasear
285db679d9 Some nice refinements here. 2025-12-04 15:31:42 -06:00
Yaro Kasear
5b8f14c99b Experimental drawing widget? 2025-12-04 14:06:14 -06:00
Yaro Kasear
7d4b76d19f Latest updates. 2025-12-02 09:36:33 -06:00
Yaro Kasear
d1f00cd9d5 Image handling fixed properly. 2025-11-18 14:14:54 -06:00
Yaro Kasear
d151d68ce9 Adding image functionality. 2025-11-17 10:05:34 -06:00
26 changed files with 3280 additions and 159 deletions

View file

@ -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):

View file

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

View file

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

89
inventory/routes/image.py Normal file
View file

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

View file

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

View file

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

View file

@ -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 <img>)
========================================================= */
.grid-widget[data-mode="viewer"]{
/* explicitly undo any containment */
container-type: normal; /* <-- the money line */
contain: none;
display: inline-block;
vertical-align: middle;
width: auto;
height: auto;
min-width: 0;
flex: none;
}
/* wrap is the sized box (JS sets px) */
.grid-widget[data-mode="viewer"] [data-grid-wrap]{
display: inline-block;
position: relative;
overflow: hidden;
line-height: 0; /* remove inline baseline gap */
}
/* grid must be in-flow and fill wrap */
.grid-widget[data-mode="viewer"] [data-grid]{
display: block;
width: 100%;
height: 100%;
cursor: default;
overflow: hidden;
}
/* viewer hides editor-only overlays */
.grid-widget[data-mode="viewer"] [data-coords],
.grid-widget[data-mode="viewer"] [data-dot]{ display: none !important; }

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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
};
}

View file

@ -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;
}

View file

@ -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 });
})();

View file

@ -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 });
})();

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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,
};
}

View file

@ -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()
};
}
});
}

View file

@ -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;
}

View file

@ -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
};
}

View file

@ -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,
};
},
};

View file

@ -0,0 +1,266 @@
{% macro drawWidget(uid) %}
<div class="grid-widget" data-grid-widget data-mode="editor" data-storage-key="gridDoc:{{ uid }}">
<div data-toolbar
class="btn-toolbar bg-light border border-bottom-0 rounded-bottom-0 border-secondary-subtle rounded p-1 align-items-center flex-nowrap overflow-auto toolbar">
<div class="toolbar-row toolbar-row--primary">
<div class="toolbar-group">
<div class="btn-group">
<input type="radio" class="btn-check" value="pen" name="tool-{{ uid }}" id="tool-pen-{{ uid }}"
data-tool checked>
<label for="tool-pen-{{ uid }}"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-pen" viewBox="0 0 16 16">
<path
d="m13.498.795.149-.149a1.207 1.207 0 1 1 1.707 1.708l-.149.148a1.5 1.5 0 0 1-.059 2.059L4.854 14.854a.5.5 0 0 1-.233.131l-4 1a.5.5 0 0 1-.606-.606l1-4a.5.5 0 0 1 .131-.232l9.642-9.642a.5.5 0 0 0-.642.056L6.854 4.854a.5.5 0 1 1-.708-.708L9.44.854A1.5 1.5 0 0 1 11.5.796a1.5 1.5 0 0 1 1.998-.001m-.644.766a.5.5 0 0 0-.707 0L1.95 11.756l-.764 3.057 3.057-.764L14.44 3.854a.5.5 0 0 0 0-.708z" />
</svg>
</label>
<input type="radio" class="btn-check" value="line" name="tool-{{ uid }}" id="tool-line-{{ uid }}"
data-tool>
<label for="tool-line-{{ uid }}"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"
aria-hidden="true">
<path d="M4 12 L12 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<circle cx="4" cy="12" r="1.5" fill="currentColor" />
<circle cx="12" cy="4" r="1.5" fill="currentColor" />
</svg>
</label>
<input type="radio" class="btn-check" value="outline" name="tool-{{ uid }}"
id="tool-outline-{{ uid }}" data-tool>
<label for="tool-outline-{{ uid }}"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-square" viewBox="0 0 16 16">
<path
d="M14 1a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1zM2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2z" />
</svg>
</label>
<input type="radio" class="btn-check" value="filled" name="tool-{{ uid }}"
id="tool-filled-{{ uid }}" data-tool>
<label for="tool-filled-{{ uid }}"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-square-fill" viewBox="0 0 16 16">
<path d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2z" />
</svg>
</label>
<input type="radio" class="btn-check" value="outlineEllipse" name="tool-{{ uid }}"
id="tool-outline-ellipse-{{ uid }}" data-tool>
<label for="tool-outline-ellipse-{{ uid }}"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-circle" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16" />
</svg>
</label>
<input type="radio" class="btn-check" value="filledEllipse" name="tool-{{ uid }}"
id="tool-filled-ellipse-{{ uid }}" data-tool>
<label for="tool-filled-ellipse-{{ uid }}"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-circle-fill" viewBox="0 0 16 16">
<circle cx="8" cy="8" r="8" />
</svg>
</label>
</div>
<input type="color" class="form-control form-control-sm form-control-color" data-color>
</div>
<div class="toolbar-group">
<div class="btn-group">
<button type="button"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center"
data-export>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-download" viewBox="0 0 16 16">
<path
d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5" />
<path
d="M7.646 11.854a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V1.5a.5.5 0 0 0-1 0v8.793L5.354 8.146a.5.5 0 1 0-.708.708z" />
</svg>
</button>
<input type="file" data-import accept="application/json" class="d-none">
<button type="button"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center"
data-import-button>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-upload" viewBox="0 0 16 16">
<path
d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5" />
<path
d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708z" />
</svg>
</button>
<button type="button"
class="btn btn-sm btn-danger border d-inline-flex align-items-center justify-content-center"
data-clear>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-x-lg" viewBox="0 0 16 16">
<path
d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8z" />
</svg>
</button>
</div>
</div>
</div>
<div class="toolbar-row toolbar-row--secondary">
<div class="toolbar-group">
<div class="btn-group">
<input type="radio" class="btn-check" name="gridType-{{ uid }}" value="noGrid"
id="type-no-grid-{{ uid }}" data-gridtype checked>
<label for="type-no-grid-{{ uid }}"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-border" viewBox="0 0 16 16">
<path
d="M0 0h.969v.5H1v.469H.969V1H.5V.969H0zm2.844 1h-.938V0h.938zm1.875 0H3.78V0h.938v1zm1.875 0h-.938V0h.938zm.937 0V.969H7.5V.5h.031V0h.938v.5H8.5v.469h-.031V1zm2.813 0h-.938V0h.938zm1.875 0h-.938V0h.938zm1.875 0h-.938V0h.938zM15.5 1h-.469V.969H15V.5h.031V0H16v.969h-.5zM1 1.906v.938H0v-.938zm6.5.938v-.938h1v.938zm7.5 0v-.938h1v.938zM1 3.78v.938H0V3.78zm6.5.938V3.78h1v.938zm7.5 0V3.78h1v.938zM1 5.656v.938H0v-.938zm6.5.938v-.938h1v.938zm7.5 0v-.938h1v.938zM.969 8.5H.5v-.031H0V7.53h.5V7.5h.469v.031H1v.938H.969zm1.875 0h-.938v-1h.938zm1.875 0H3.78v-1h.938v1zm1.875 0h-.938v-1h.938zm1.875-.031V8.5H7.53v-.031H7.5V7.53h.031V7.5h.938v.031H8.5v.938zm1.875.031h-.938v-1h.938zm1.875 0h-.938v-1h.938zm1.875 0h-.938v-1h.938zm1.406 0h-.469v-.031H15V7.53h.031V7.5h.469v.031h.5v.938h-.5zM0 10.344v-.938h1v.938zm7.5 0v-.938h1v.938zm8.5-.938v.938h-1v-.938zM0 12.22v-.938h1v.938zm7.5 0v-.938h1v.938zm8.5-.938v.938h-1v-.938zM0 14.094v-.938h1v.938zm7.5 0v-.938h1v.938zm8.5-.938v.938h-1v-.938zM.969 16H0v-.969h.5V15h.469v.031H1v.469H.969zm1.875 0h-.938v-1h.938zm1.875 0H3.78v-1h.938v1zm1.875 0h-.938v-1h.938zm.937 0v-.5H7.5v-.469h.031V15h.938v.031H8.5v.469h-.031v.5zm2.813 0h-.938v-1h.938zm1.875 0h-.938v-1h.938zm1.875 0h-.938v-1h.938zm.937 0v-.5H15v-.469h.031V15h.469v.031h.5V16z" />
</svg>
</label>
<input type="radio" class="btn-check" name="gridType-{{ uid }}" value="horizontalGrid"
id="type-horizontal-{{ uid }}" data-gridtype>
<label for="type-horizontal-{{ uid }}"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-border-center" viewBox="0 0 16 16">
<path
d="M.969 0H0v.969h.5V1h.469V.969H1V.5H.969zm.937 1h.938V0h-.938zm1.875 0h.938V0H3.78v1zm1.875 0h.938V0h-.938zM7.531.969V1h.938V.969H8.5V.5h-.031V0H7.53v.5H7.5v.469zM9.406 1h.938V0h-.938zm1.875 0h.938V0h-.938zm1.875 0h.938V0h-.938zm1.875 0h.469V.969h.5V0h-.969v.5H15v.469h.031zM1 2.844v-.938H0v.938zm6.5-.938v.938h1v-.938zm7.5 0v.938h1v-.938zM1 4.719V3.78H0v.938h1zm6.5-.938v.938h1V3.78h-1zm7.5 0v.938h1V3.78h-1zM1 6.594v-.938H0v.938zm6.5-.938v.938h1v-.938zm7.5 0v.938h1v-.938zM0 8.5v-1h16v1zm0 .906v.938h1v-.938zm7.5 0v.938h1v-.938zm8.5.938v-.938h-1v.938zm-16 .937v.938h1v-.938zm7.5 0v.938h1v-.938zm8.5.938v-.938h-1v.938zm-16 .937v.938h1v-.938zm7.5 0v.938h1v-.938zm8.5.938v-.938h-1v.938zM0 16h.969v-.5H1v-.469H.969V15H.5v.031H0zm1.906 0h.938v-1h-.938zm1.875 0h.938v-1H3.78v1zm1.875 0h.938v-1h-.938zm1.875-.5v.5h.938v-.5H8.5v-.469h-.031V15H7.53v.031H7.5v.469zm1.875.5h.938v-1h-.938zm1.875 0h.938v-1h-.938zm1.875 0h.938v-1h-.938zm1.875-.5v.5H16v-.969h-.5V15h-.469v.031H15v.469z" />
</svg>
</label>
<input type="radio" class="btn-check" name="gridType-{{ uid }}" value="verticalGrid"
id="type-vertical-{{ uid }}" data-gridtype>
<label for="type-vertical-{{ uid }}"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-border-middle" viewBox="0 0 16 16">
<path
d="M.969 0H0v.969h.5V1h.469V.969H1V.5H.969zm.937 1h.938V0h-.938zm1.875 0h.938V0H3.78v1zm1.875 0h.938V0h-.938zM8.5 16h-1V0h1zm.906-15h.938V0h-.938zm1.875 0h.938V0h-.938zm1.875 0h.938V0h-.938zm1.875 0h.469V.969h.5V0h-.969v.5H15v.469h.031zM1 2.844v-.938H0v.938zm14-.938v.938h1v-.938zM1 4.719V3.78H0v.938h1zm14-.938v.938h1V3.78h-1zM1 6.594v-.938H0v.938zm14-.938v.938h1v-.938zM.5 8.5h.469v-.031H1V7.53H.969V7.5H.5v.031H0v.938h.5zm1.406 0h.938v-1h-.938zm1.875 0h.938v-1H3.78v1zm1.875 0h.938v-1h-.938zm3.75 0h.938v-1h-.938zm1.875 0h.938v-1h-.938zm1.875 0h.938v-1h-.938zm1.875 0h.469v-.031h.5V7.53h-.5V7.5h-.469v.031H15v.938h.031zM0 9.406v.938h1v-.938zm16 .938v-.938h-1v.938zm-16 .937v.938h1v-.938zm16 .938v-.938h-1v.938zm-16 .937v.938h1v-.938zm16 .938v-.938h-1v.938zM0 16h.969v-.5H1v-.469H.969V15H.5v.031H0zm1.906 0h.938v-1h-.938zm1.875 0h.938v-1H3.78v1zm1.875 0h.938v-1h-.938zm3.75 0h.938v-1h-.938zm1.875 0h.938v-1h-.938zm1.875 0h.938v-1h-.938zm1.875-.5v.5H16v-.969h-.5V15h-.469v.031H15v.469z" />
</svg>
</label>
<input type="radio" class="btn-check" name="gridType-{{ uid }}" value="fullGrid"
id="type-full-{{ uid }}" data-gridtype>
<label for="type-full-{{ uid }}"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-border-all" viewBox="0 0 16 16">
<path d="M0 0h16v16H0zm1 1v6.5h6.5V1zm7.5 0v6.5H15V1zM15 8.5H8.5V15H15zM7.5 15V8.5H1V15z" />
</svg>
</label>
</div>
</div>
<div class="toolbar-group">
<div class="dropdown">
<button type="button" class="btn tb-btn btn-light dropdown-toggle border" data-bs-toggle="dropdown">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-grid-3x3-gap" viewBox="0 0 16 16">
<path
d="M4 2v2H2V2zm1 12v-2a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1m0-5V7a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1m0-5V2a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1m5 10v-2a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1m0-5V7a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1m0-5V2a1 1 0 0 0-1-1H7a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1M9 2v2H7V2zm5 0v2h-2V2zM4 7v2H2V7zm5 0v2H7V7zm5 0h-2v2h2zM4 12v2H2v-2zm5 0v2H7v-2zm5 0v2h-2v-2zM12 1a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zm-1 6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1zm1 4a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1z" />
</svg>
<small data-cell-size-val>25px</small>
</button>
<ul class="dropdown-menu p-2">
<li>
<div class="small text-secondary mb-1">Cell Size</div>
<input type="range" min="1" max="100" step="1" value="25" data-cell-size
class="form-range w-100">
</li>
</ul>
</div>
<div class="dropdown">
<button type="button" class="btn tb-btn btn-light dropdown-toggle border" data-bs-toggle="dropdown">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-droplet-fill" viewBox="0 0 16 16">
<path
d="M8 16a6 6 0 0 0 6-6c0-1.655-1.122-2.904-2.432-4.362C10.254 4.176 8.75 2.503 8 0c0 0-6 5.686-6 10a6 6 0 0 0 6 6M6.646 4.646l.708.708c-.29.29-1.128 1.311-1.907 2.87l-.894-.448c.82-1.641 1.717-2.753 2.093-3.13" />
</svg>
<small data-fill-opacity-val>100%</small>
</button>
<ul class="dropdown-menu p-2">
<li>
<div class="small text-secondary mb-1">Fill Opacity</div>
<input type="range" min="0" max="1" step="0.01" value="1" data-fill-opacity
class="form-range w-100">
</li>
</ul>
</div>
<div class="dropdown">
<button type="button" class="btn tb-btn btn-light dropdown-toggle border" data-bs-toggle="dropdown">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-dash-square-dotted" viewBox="0 0 16 16">
<path
d="M2.5 0q-.25 0-.487.048l.194.98A1.5 1.5 0 0 1 2.5 1h.458V0zm2.292 0h-.917v1h.917zm1.833 0h-.917v1h.917zm1.833 0h-.916v1h.916zm1.834 0h-.917v1h.917zm1.833 0h-.917v1h.917zM13.5 0h-.458v1h.458q.151 0 .293.029l.194-.981A2.5 2.5 0 0 0 13.5 0m2.079 1.11a2.5 2.5 0 0 0-.69-.689l-.556.831q.248.167.415.415l.83-.556zM1.11.421a2.5 2.5 0 0 0-.689.69l.831.556c.11-.164.251-.305.415-.415zM16 2.5q0-.25-.048-.487l-.98.194q.027.141.028.293v.458h1zM.048 2.013A2.5 2.5 0 0 0 0 2.5v.458h1V2.5q0-.151.029-.293zM0 3.875v.917h1v-.917zm16 .917v-.917h-1v.917zM0 5.708v.917h1v-.917zm16 .917v-.917h-1v.917zM0 7.542v.916h1v-.916zm15 .916h1v-.916h-1zM0 9.375v.917h1v-.917zm16 .917v-.917h-1v.917zm-16 .916v.917h1v-.917zm16 .917v-.917h-1v.917zm-16 .917v.458q0 .25.048.487l.98-.194A1.5 1.5 0 0 1 1 13.5v-.458zm16 .458v-.458h-1v.458q0 .151-.029.293l.981.194Q16 13.75 16 13.5M.421 14.89c.183.272.417.506.69.689l.556-.831a1.5 1.5 0 0 1-.415-.415zm14.469.689c.272-.183.506-.417.689-.69l-.831-.556c-.11.164-.251.305-.415.415l.556.83zm-12.877.373Q2.25 16 2.5 16h.458v-1H2.5q-.151 0-.293-.029zM13.5 16q.25 0 .487-.048l-.194-.98A1.5 1.5 0 0 1 13.5 15h-.458v1zm-9.625 0h.917v-1h-.917zm1.833 0h.917v-1h-.917zm1.834 0h.916v-1h-.916zm1.833 0h.917v-1h-.917zm1.833 0h.917v-1h-.917zM4.5 7.5a.5.5 0 0 0 0 1h7a.5.5 0 0 0 0-1z" />
</svg>
<small data-stroke-opacity-val>100%</small>
</button>
<ul class="dropdown-menu p-2">
<li>
<div class="small text-secondary mb-1">Stroke Opacity</div>
<input type="range" min="0" max="1" step="0.01" value="1" data-stroke-opacity
class="form-range w-100">
</li>
</ul>
</div>
<div class="dropdown">
<button type="button" class="btn tb-btn btn-light dropdown-toggle border" data-bs-toggle="dropdown">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"
fill="currentColor" class="me-1" aria-hidden="true">
<rect x="2" y="3" width="12" height="1" rx=".5"></rect>
<rect x="2" y="7" width="12" height="2" rx="1"></rect>
<rect x="2" y="12" width="12" height="3" rx="1.5"></rect>
</svg>
<small data-stroke-width-val>12%</small>
</button>
<ul class="dropdown-menu p-2">
<li>
<div class="small text-secondary mb-1">Stroke Width</div>
<input type="range" min="0" max="1" step="0.01" value="0.12" data-stroke-width
class="form-range w-100">
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="grid-wrap" data-grid-wrap>
<span class="position-absolute p-0 m-0 d-none dot" data-dot>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" data-dot-svg>
<circle cx="16" cy="16" r="4" fill="black" />
</svg>
</span>
<div class="position-relative overflow-hidden grid" data-grid>
<div class="border border-black position-absolute d-none bg-warning-subtle px-1 py-0 user-select-none coords"
data-coords></div>
<canvas class="position-absolute w-100 h-100" data-canvas></canvas>
</div>
</div>
</div>
{% endmacro %}
{% macro viewWidget(uid, json) %}
<span class="grid-widget" data-grid-widget data-mode="viewer" data-storage-key="gridDoc:{{ uid }}">
<span class="grid-wrap" data-grid-wrap>
<span class="position-absolute p-0 m-0 d-none dot" data-dot>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" data-dot-svg>
<circle cx="16" cy="16" r="4" fill="black" />
</svg>
</span>
<span class="position-relative overflow-hidden grid" data-grid>
<span
class="border border-black position-absolute d-none bg-warning-subtle px-1 py-0 user-select-none coords"
data-coords></span>
<canvas class="position-absolute" data-canvas></canvas>
</span>
</span>
<script type="application/json" data-grid-doc>
{{ json | safe }}
</script>
</span>
{% endmacro %}

View file

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

View file

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

View file

@ -7,29 +7,29 @@
</div>
<script>
window.newDrafts = window.newDrafts || [];
window.deletedIds = window.deletedIds || [];
const LIST_URL = {{ url_for('listing.show_list', model = field['attrs']['data-model']) | tojson }};
window.newDrafts = window.newDrafts || [];
window.deletedIds = window.deletedIds || [];
const LIST_URL = {{ url_for('listing.show_list', model = field['attrs']['data-model']) | tojson }};
// Build delete URL only if we have an id, or leave it empty string
{% set model = field['attrs']['data-model'] %}
{% set obj_id = field['template_ctx']['values'].get('id') %}
{% set delete_url = obj_id and url_for('crudkit.' ~model ~ '.rest_delete', obj_id = obj_id) %}
const DELETE_URL = {{ (delete_url or '') | tojson }};
// Build delete URL only if we have an id, or leave it empty string
{% set model = field['attrs']['data-model'] %}
{% set obj_id = field['template_ctx']['values'].get('id') %}
{% set delete_url = obj_id and url_for('crudkit.' ~model ~ '.rest_delete', obj_id = obj_id) %}
const DELETE_URL = {{ (delete_url or '') | tojson }};
// Form metadata
const formEl = document.getElementById({{ (field['attrs']['data-model'] ~ '_form') | tojson }});
const model = {{ field['attrs']['data-model'] | tojson }};
const idVal = {{ field['template_ctx']['values'].get('id') | tojson }};
const hasId = idVal !== null && idVal !== undefined;
// Form metadata
const formEl = document.getElementById({{ (field['attrs']['data-model'] ~ '_form') | tojson }});
const model = {{ field['attrs']['data-model'] | tojson }};
const idVal = {{ field['template_ctx']['values'].get('id') | tojson }};
const hasId = idVal !== null && idVal !== undefined;
if (!hasId) {
if (!hasId) {
const delBtn = document.getElementById('delete');
delBtn.disabled = true;
delBtn.classList.add('disabled');
}
}
async function deleteEntry() {
async function deleteEntry() {
const delBtn = document.getElementById('delete');
if (!DELETE_URL) return;
if (!window.confirm('Delete this entry?')) return;
@ -74,18 +74,18 @@
} finally {
delBtn.disabled = false;
}
}
}
{% if field['attrs']['data-model'] == 'worklog' %}
function collectExistingUpdateIds() {
{% if field['attrs']['data-model'] == 'worklog' %}
function collectExistingUpdateIds() {
return Array.from(document.querySelectorAll('script[type="application/json"][id^="md-"]'))
.map(el => Number(el.id.slice(3)))
.filter(Number.isFinite);
}
}
function collectDeletedIds() { return (window.deletedIds || []).filter(Number.isFinite); }
function collectDeletedIds() { return (window.deletedIds || []).filter(Number.isFinite); }
function collectEditedUpdates() {
function collectEditedUpdates() {
const updates = [];
const deleted = new Set(collectDeletedIds());
for (const id of collectExistingUpdateIds()) {
@ -94,10 +94,10 @@
}
for (const md of (window.newDrafts || [])) if ((md ?? '').trim()) updates.push({ content: md });
return updates;
}
{% endif %}
}
{% endif %}
function formToJson(form) {
function formToJson(form) {
const fd = new FormData(form);
const out = {};
fd.forEach((value, key) => {
@ -124,20 +124,37 @@
}
});
return out;
}
}
// URLs for create/update
const createUrl = {{ url_for('entry.create_entry', model = field['attrs']['data-model']) | tojson }};
const updateUrl = hasId ? `/entry/${model}/${idVal}` : null;
// URLs for create/update
const createUrl = {{ url_for('entry.create_entry', model = field['attrs']['data-model']) | tojson }};
const updateUrl = hasId ? `/entry/${model}/${idVal}` : null;
formEl.addEventListener('submit', async e => {
formEl.addEventListener('submit', async e => {
e.preventDefault();
const submitBtn = document.getElementById('submit');
submitBtn.disabled = true;
try {
const json = formToJson(formEl);
if (model === 'inventory') {
// the file input 'image' must NOT go into the JSON at all
delete json.image;
}
// Handle image for inventory
if (model === 'inventory' && globalThis.ImageDisplay?.utilities) {
const imgResult = await ImageDisplay.utilities.uploadIfChanged();
if (imgResult?.remove) {
json.image_id = null;
} else if (imgResult && imgResult.id) {
json.image_id = imgResult.id; // ✅ this, and ONLY this
}
}
if (model === 'inventory' && typeof getMarkdown === 'function') {
const md = getMarkdown();
json.notes = (typeof md === 'string') ? md.trim() : '';
@ -150,23 +167,27 @@
const url = hasId ? updateUrl : createUrl;
try {
console.log('Submitting JSON:', json);
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(json),
credentials: 'same-origin'
});
const reply = await res.json();
if (reply.status === 'success') {
window.newDrafts = [];
window.deletedIds = [];
if (!hasId && reply.id) {
queueToast('Created successfully.', 'success');
location.assign(`/entry/${model}/${reply.id}`);
return;
}
queueToast('Updated successfully.', 'success');
if (model === 'worklog') {
@ -184,5 +205,5 @@
} finally {
submitBtn.disabled = false;
}
});
});
</script>

View file

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

View file

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