Multiple widget support?

This commit is contained in:
Conrad Nelson 2025-12-17 14:08:38 -06:00
parent 5dfc2691e9
commit ce562d34de

View file

@ -1,14 +1,13 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block style %} {% block style %}
.grid-widget .grid-wrap {
#gridWrap {
width: 100%; width: 100%;
height: 80vh; height: 80vh;
position: relative; position: relative;
} }
#grid { .grid-widget [data-grid] {
position: relative; position: relative;
cursor: crosshair; cursor: crosshair;
width: 100%; width: 100%;
@ -17,27 +16,27 @@
margin: 0 auto; margin: 0 auto;
} }
#toolBar::-webkit-scrollbar { .grid-widget [data-toolbar]::-webkit-scrollbar {
height: 8px; height: 8px;
} }
#coords { .grid-widget [data-coords] {
bottom: 10px; bottom: 10px;
pointer-events: none; pointer-events: none;
left: 10px; left: 10px;
} }
#overlay { .grid-widget [data-canvas] {
z-index: 9999; z-index: 9999;
pointer-events: none; pointer-events: none;
inset: 0; inset: 0;
} }
#toolBar { .grid-widget [data-toolbar] {
margin: 0 auto; margin: 0 auto;
} }
#dot { .grid-widget [data-dot] {
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
z-index: 10000; z-index: 10000;
pointer-events: none; pointer-events: none;
@ -45,24 +44,25 @@
{% endblock %} {% endblock %}
{% block main %} {% block main %}
{% macro drawWidget(uid) %}
<div class="container"> <div class="container">
<div id="toolBar" <div class="grid-widget" data-grid-widget>
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"> <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="input-group input-group-sm w-auto flex-nowrap"> <div class="input-group input-group-sm w-auto flex-nowrap">
<span class="input-group-text">Cell Size:</span> <span class="input-group-text">Cell Size:</span>
<input type="number" min="1" value="25" name="cellSize" id="cellSize" class="form-control form-control-sm"> <input type="number" min="1" value="25" class="form-control form-control-sm" data-cell-size>
<span class="input-group-text">Fill Opacity:</span> <span class="input-group-text">Fill Opacity:</span>
<input type="number" min="0" max="1" step=".01" value="1" name="fillOpacity" id="fillOpacity" <input type="number" min="0" max="1" step=".01" value="1" name="fillOpacity" data-fill-opacity
class="form-control form-control-sm"> class="form-control form-control-sm">
</div> </div>
<div class="vr mx-1"></div> <div class="vr mx-1"></div>
<input type="color" class="form-control form-control-sm form-control-color" id="color"> <input type="color" class="form-control form-control-sm form-control-color" data-color>
<div class="vr mx-1"></div> <div class="vr mx-1"></div>
<div class="btn-group"> <div class="btn-group">
<input type="radio" class="btn-check" id="pen" name="tool" checked> <input type="radio" class="btn-check" value="pen" name="tool-{{ uid }}" id="tool-pen-{{ uid }}" data-tool checked>
<label for="pen" <label for="tool-pen-{{ uid }}" class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
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" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pen"
viewBox="0 0 16 16"> viewBox="0 0 16 16">
<path <path
@ -70,9 +70,8 @@
</svg> </svg>
</label> </label>
<input type="radio" class="btn-check" id="outline" name="tool"> <input type="radio" class="btn-check" value="outline" name="tool-{{ uid }}" id="tool-outline-{{ uid }}" data-tool>
<label for="outline" <label for="tool-outline-{{ uid }}" class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
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" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-square"
viewBox="0 0 16 16"> viewBox="0 0 16 16">
<path <path
@ -80,71 +79,63 @@
</svg> </svg>
</label> </label>
<input type="radio" class="btn-check" id="filled" name="tool"> <input type="radio" class="btn-check" value="filled" name="tool-{{ uid }}" id="tool-filled-{{ uid }}" data-tool>
<label for="filled" <label for="tool-filled-{{ uid }}" class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
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" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-square-fill" viewBox="0 0 16 16"> 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" /> <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> </svg>
</label> </label>
<input type="radio" class="btn-check" id="outlineEllipse" name="tool"> <input type="radio" class="btn-check" value="outlineEllipse" name="tool-{{ uid }}" id="tool-outline-ellipse-{{ uid }}" data-tool>
<label for="outlineEllipse" <label for="tool-outline-ellipse-{{ uid }}" class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
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" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-circle"
viewBox="0 0 16 16"> 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" /> <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> </svg>
</label> </label>
<input type="radio" class="btn-check" id="filledEllipse" name="tool"> <input type="radio" class="btn-check" value="filledEllipse" name="tool-{{ uid }}" id="tool-filled-ellipse-{{ uid }}" data-tool>
<label for="filledEllipse" <label for="tool-filled-ellipse-{{ uid }}" class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
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" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-circle-fill" viewBox="0 0 16 16"> class="bi bi-circle-fill" viewBox="0 0 16 16">
<circle cx="8" cy="8" r="8" /> <circle cx="8" cy="8" r="8" />
</svg> </svg>
</label> </label>
<input type="radio" class="btn-check" id="line" name="tool"> <input type="radio" class="btn-check" value="line" name="tool-{{ uid }}" id="tool-line-{{ uid }}" data-tool>
<label for="line" <label for="tool-line-{{ uid }}" class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
</label> </label>
</div> </div>
<div class="vr mx-1"></div> <div class="vr mx-1"></div>
<div class="btn-group"> <div class="btn-group">
<input type="radio" class="btn-check" name="gridType" id="noGrid" checked> <input type="radio" class="btn-check" name="gridType-{{ uid }}" value="noGrid" id="type-no-grid-{{ uid }}" data-gridtype checked>
<label for="noGrid" <label for="type-no-grid-{{ uid }}" class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
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" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-border"
viewBox="0 0 16 16"> viewBox="0 0 16 16">
<path <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" /> 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> </svg>
</label> </label>
<input type="radio" class="btn-check" name="gridType" id="horizontalGrid"> <input type="radio" class="btn-check" name="gridType-{{ uid }}" value="horizontalGrid" id="type-horizontal-{{ uid }}" data-gridtype>
<label for="horizontalGrid" <label for="type-horizontal-{{ uid }}" class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
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" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-border-center" viewBox="0 0 16 16"> class="bi bi-border-center" viewBox="0 0 16 16">
<path <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" /> 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> </svg>
</label> </label>
<input type="radio" class="btn-check" name="gridType" id="verticalGrid"> <input type="radio" class="btn-check" name="gridType-{{ uid }}" value="verticalGrid" id="type-vertical-{{ uid }}" data-gridtype>
<label for="verticalGrid" <label for="type-vertical-{{ uid }}" class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
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" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-border-middle" viewBox="0 0 16 16"> class="bi bi-border-middle" viewBox="0 0 16 16">
<path <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" /> 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> </svg>
</label> </label>
<input type="radio" class="btn-check" name="gridType" id="fullGrid"> <input type="radio" class="btn-check" name="gridType-{{ uid }}" value="fullGrid" id="type-full-{{ uid }}" data-gridtype>
<label for="fullGrid" <label for="type-full-{{ uid }}" class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center">
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" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-border-all" viewBox="0 0 16 16"> 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" /> <path d="M0 0h16v16H0zm1 1v6.5h6.5V1zm7.5 0v6.5H15V1zM15 8.5H8.5V15H15zM7.5 15V8.5H1V15z" />
@ -154,7 +145,7 @@
<div class="vr mx-1"></div> <div class="vr mx-1"></div>
<div class="btn-group"> <div class="btn-group">
<button type="button" <button type="button"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center" id="export"> 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" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-download" viewBox="0 0 16 16"> class="bi bi-download" viewBox="0 0 16 16">
<path <path
@ -163,10 +154,10 @@
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" /> 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> </svg>
</button> </button>
<input type="file" id="import" accept="application/json" class="d-none"> <input type="file" data-import accept="application/json" class="d-none">
<button type="button" <button type="button"
class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center" class="btn btn-sm btn-light border d-inline-flex align-items-center justify-content-center"
id="importButton"> data-import-button>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-upload" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-upload"
viewBox="0 0 16 16"> viewBox="0 0 16 16">
<path <path
@ -176,7 +167,7 @@
</svg> </svg>
</button> </button>
<button type="button" <button type="button"
class="btn btn-sm btn-danger border d-inline-flex align-items-center justify-content-center" id="clear"> 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" <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-x-lg"
viewBox="0 0 16 16"> viewBox="0 0 16 16">
<path <path
@ -185,78 +176,129 @@
</button> </button>
</div> </div>
</div> </div>
<div id="gridWrap"> <div class="grid-wrap" data-grid-wrap>
<span id="dot" class="position-absolute p-0 m-0 d-none"> <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" id="dotSVG"> <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" /> <circle cx="16" cy="16" r="4" fill="black" />
</svg> </svg>
</span> </span>
<div id="grid" class="position-relative overflow-hidden"> <div class="position-relative overflow-hidden grid" data-grid>
<div id="coords" <div class="border border-black position-absolute d-none bg-warning-subtle px-1 py-0 user-select-none coords" data-coords></div>
class="border border-black position-absolute d-none bg-warning-subtle px-1 py-0 user-select-none"></div> <canvas class="position-absolute w-100 h-100" data-canvas></canvas>
<canvas id="overlay" class="position-absolute w-100 h-100"></canvas> </div>
</div> </div>
</div> </div>
</div> </div>
{% endmacro %}
{{ drawWidget('test') }}
{% endblock %} {% endblock %}
{% block script %} {% block script %}
const DEFAULT_DOC = { version: 1, cellSize: 25, shapes: [] };
const canvasEl = document.getElementById('overlay'); document.querySelectorAll('[data-grid-widget]').forEach((root, index) => {
const clearEl = document.getElementById('clear'); initGridWidget(root, { storageKey: `gridDoc:${index}` });
const colorEl = document.getElementById('color'); });
const coordsEl = document.getElementById('coords');
const dotEl = document.getElementById('dot');
const dotSVGEl = document.getElementById('dotSVG');
const exportEl = document.getElementById('export');
const gridEl = document.getElementById('grid');
const importButtonEl = document.getElementById('importButton');
const importEl = document.getElementById('import');
const cellSizeEl = document.getElementById('cellSize');
const gridWrapEl = document.getElementById('gridWrap');
const toolBarEl = document.getElementById('toolBar');
const fillOpacityEl = document.getElementById('fillOpacity');
let doc = loadDoc();
let shapes = sanitizeShapes(Array.isArray(doc.shapes) ? doc.shapes : []);
saveDoc({ ...doc, shapes });
let cellSize = Number(doc.cellSize) || 25; let activeGridWidget = null;
cellSizeEl.value = cellSize;
let dotSize = Math.floor(Math.max(cellSize * 1.25, 32));
let ctx; document.addEventListener('keydown', (e) => {
let dpr = 1; if (!activeGridWidget) return;
let selectedColor; activeGridWidget.handleKeyDown(e);
let currentOpacity = clamp01(fillOpacityEl?.value ?? 1, 1); });
let currentShape = null;
const history = [structuredClone(shapes)];
let historyIndex = 0
let sizingRAF = 0; function initGridWidget(root, opts = {}) {
let lastApplied = { w: 0, h: 0 }; const DEFAULT_DOC = { version: 1, cellSize: 25, shapes: [] };
const ro = new ResizeObserver(scheduleSnappedCellSize); const canvasEl = root.querySelector('[data-canvas]');
ro.observe(gridWrapEl); 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 gridEl = root.querySelector('[data-grid]');
const importButtonEl = root.querySelector('[data-import-button]');
const importEl = root.querySelector('[data-import]');
const cellSizeEl = root.querySelector('[data-cell-size]');
const gridWrapEl = root.querySelector('[data-grid-wrap]');
const toolBarEl = root.querySelector('[data-toolbar]');
const fillOpacityEl = root.querySelector('[data-fill-opacity]');
const savedTool = localStorage.getItem('gridTool'); const storageKey = opts.storageKey ?? 'gridDoc';
if (savedTool) {
setActiveTool(savedTool);
}
const savedType = localStorage.getItem('gridType'); let doc = loadDoc();
if (savedType) { let shapes = sanitizeShapes(Array.isArray(doc.shapes) ? doc.shapes : []);
setActiveType(savedType); saveDoc({ ...doc, shapes });
}
resizeAndSetupCanvas(); const savedTool = localStorage.getItem(`${storageKey}:tool`);
if (savedTool) setActiveTool(savedTool);
setGrid(); const savedType = localStorage.getItem(`${storageKey}:gridType`);
scheduleSnappedCellSize(); if (savedType) setActiveType(savedType);
function renderAllWithPreview(previewShape = null, dashed = true) { let cellSize = Number(doc.cellSize) || 25;
cellSizeEl.value = cellSize;
let dotSize = Math.floor(Math.max(cellSize * 1.25, 32));
let ctx;
let dpr = 1;
let selectedColor;
let currentOpacity = clamp01(fillOpacityEl?.value ?? 1, 1);
let currentShape = null;
const history = [structuredClone(shapes)];
let historyIndex = 0
let sizingRAF = 0;
let lastApplied = { w: 0, h: 0 };
const ro = new ResizeObserver(scheduleSnappedCellSize);
ro.observe(gridWrapEl);
resizeAndSetupCanvas();
setGrid();
scheduleSnappedCellSize();
const api = {
handleKeyDown(e) {
const key = e.key.toLowerCase();
// Only act if the focus isn't in an input inside this widget.
// (Otherwise Ctrl+Z in a number field becomes a drawing undo, which is… rude.)
const t = e.target;
if (t && root.contains(t) && (t.matches('input, textarea, select') || t.isContentEditable)) 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;
redrawAll();
}
}
};
root.addEventListener('focusin', () => { activeGridWidget = api; });
root.addEventListener('pointerdown', () => {
activeGridWidget = api;
}, { capture: true });
function renderAllWithPreview(previewShape = null, dashed = true) {
if (!ctx) return; if (!ctx) return;
clearCanvas(); clearCanvas();
shapes.forEach(drawShape); shapes.forEach(drawShape);
@ -267,44 +309,44 @@ function renderAllWithPreview(previewShape = null, dashed = true) {
if (dashed) ctx.setLineDash([5, 3]); if (dashed) ctx.setLineDash([5, 3]);
drawShape(previewShape); drawShape(previewShape);
ctx.restore(); ctx.restore();
} }
function penAddPoint(shape, clientX, clientY, minStep = 0.02) { function penAddPoint(shape, clientX, clientY, minStep = 0.02) {
const p = pxToDocPoint(clientX, clientY); const p = pxToDocPoint(clientX, clientY);
const pts = shape.points; const pts = shape.points;
const last = pts[pts.length - 1]; const last = pts[pts.length - 1];
if (!last || dist2(p, last) >= minStep * minStep) pts.push(p); if (!last || dist2(p, last) >= minStep * minStep) pts.push(p);
} }
function pxToDocPoint(clientX, clientY) { function pxToDocPoint(clientX, clientY) {
const rect = gridEl.getBoundingClientRect(); const rect = gridEl.getBoundingClientRect();
const x = Math.min(Math.max(clientX, rect.left), rect.right) - rect.left; 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; const y = Math.min(Math.max(clientY, rect.top), rect.bottom) - rect.top;
return { x: pxToGrid(x), y: pxToGrid(y) }; return { x: pxToGrid(x), y: pxToGrid(y) };
} }
function dist2(a, b) { function dist2(a, b) {
const dx = a.x - b.x, dy = a.y - b.y; const dx = a.x - b.x, dy = a.y - b.y;
return dx*dx + dy*dy return dx * dx + dy * dy
} }
function undo() { function undo() {
if (historyIndex <= 0) return; if (historyIndex <= 0) return;
historyIndex--; historyIndex--;
shapes = structuredClone(history[historyIndex]); shapes = structuredClone(history[historyIndex]);
saveDoc({ ...doc, shapes, cellSize }); saveDoc({ ...doc, shapes, cellSize });
redrawAll(); redrawAll();
} }
function redo() { function redo() {
if (historyIndex >= history.length - 1) return; if (historyIndex >= history.length - 1) return;
historyIndex++; historyIndex++;
shapes = structuredClone(history[historyIndex]); shapes = structuredClone(history[historyIndex]);
saveDoc({ ...doc, shapes, cellSize }); saveDoc({ ...doc, shapes, cellSize });
redrawAll(); redrawAll();
} }
function commit(nextShapes) { function commit(nextShapes) {
history.splice(historyIndex + 1); history.splice(historyIndex + 1);
history.push(structuredClone(nextShapes)); history.push(structuredClone(nextShapes));
historyIndex++; historyIndex++;
@ -312,17 +354,17 @@ function commit(nextShapes) {
saveDoc({ ...doc, shapes, cellSize }); saveDoc({ ...doc, shapes, cellSize });
redrawAll(); redrawAll();
} }
function clamp01(n, fallback = 1) { function clamp01(n, fallback = 1) {
const x = Number(n); const x = Number(n);
return Number.isFinite(x) ? Math.min(1, Math.max(0, x)) : fallback; return Number.isFinite(x) ? Math.min(1, Math.max(0, x)) : fallback;
} }
function isFiniteNum(n) { return Number.isFinite(Number(n)); } function isFiniteNum(n) { return Number.isFinite(Number(n)); }
function sanitizeShapes(list) { function sanitizeShapes(list) {
const allowed = new Set(['rect','ellipse','line','path']); const allowed = new Set(['rect', 'ellipse', 'line', 'path']);
return list.flatMap((s) => { return list.flatMap((s) => {
if (!s || typeof s !== 'object' || !allowed.has(s.type)) return []; if (!s || typeof s !== 'object' || !allowed.has(s.type)) return [];
@ -330,8 +372,8 @@ function sanitizeShapes(list) {
const opacity = clamp01(s.opacity, 1); const opacity = clamp01(s.opacity, 1);
if (s.type === 'line') { if (s.type === 'line') {
if (!['x1','y1','x2','y2'].every(k => isFiniteNum(s[k]))) return []; 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 }]; return [{ type: 'line', x1: +s.x1, y1: +s.y1, x2: +s.x2, y2: +s.y2, color }];
} }
if (s.type === 'path') { if (s.type === 'path') {
@ -354,32 +396,32 @@ function sanitizeShapes(list) {
}]; }];
} }
if (!['x','y','w','h'].every(k => isFiniteNum(s[k]))) return []; if (!['x', 'y', 'w', 'h'].every(k => isFiniteNum(s[k]))) return [];
return [{ return [{
type: s.type, type: s.type,
x:+s.x, y:+s.y, w:+s.w, h:+s.h, x: +s.x, y: +s.y, w: +s.w, h: +s.h,
color, color,
fill: !!s.fill, fill: !!s.fill,
opacity opacity
}]; }];
}); });
} }
function loadDoc() { function loadDoc() {
try { return JSON.parse(localStorage.getItem("gridDoc")) || structuredClone(DEFAULT_DOC); } try { return JSON.parse(localStorage.getItem(storageKey)) || structuredClone(DEFAULT_DOC); }
catch { return structuredClone(DEFAULT_DOC); } catch { return structuredClone(DEFAULT_DOC); }
} }
function saveDoc(nextDoc = doc) { function saveDoc(nextDoc = doc) {
doc = nextDoc; doc = nextDoc;
try { localStorage.setItem("gridDoc", JSON.stringify(nextDoc)); } catch {} try { localStorage.setItem(storageKey, JSON.stringify(nextDoc)); } catch { }
} }
function snapDown(n, step) { function snapDown(n, step) {
return Math.floor(n / step) * step; return Math.floor(n / step) * step;
} }
function applySnappedCellSize() { function applySnappedCellSize() {
sizingRAF = 0; sizingRAF = 0;
const grid = cellSize; const grid = cellSize;
@ -401,14 +443,14 @@ function applySnappedCellSize() {
gridEl.getBoundingClientRect(); gridEl.getBoundingClientRect();
resizeAndSetupCanvas(); resizeAndSetupCanvas();
} }
function scheduleSnappedCellSize() { function scheduleSnappedCellSize() {
if (sizingRAF) return; if (sizingRAF) return;
sizingRAF = requestAnimationFrame(applySnappedCellSize); sizingRAF = requestAnimationFrame(applySnappedCellSize);
} }
function applyCellSize(newSize) { function applyCellSize(newSize) {
const n = Number(newSize); const n = Number(newSize);
if (!Number.isFinite(n) || n < 1) return; if (!Number.isFinite(n) || n < 1) return;
@ -422,39 +464,33 @@ function applyCellSize(newSize) {
setGrid(); setGrid();
scheduleSnappedCellSize(); scheduleSnappedCellSize();
} }
function pxToGrid(v) { function pxToGrid(v) {
return v / cellSize; return v / cellSize;
}
function getActiveTool() {
const checked = document.querySelector('input[name="tool"]:checked');
return checked ? checked.id : 'outline';
}
function setActiveTool(toolId) {
const el = document.getElementById(toolId);
if (el) {
el.checked = true;
localStorage.setItem('gridTool', toolId);
} }
}
function getActiveType() { function getActiveTool() {
const checked = document.querySelector('input[name="gridType"]:checked'); const checked = root.querySelector('input[data-tool]:checked');
return checked ? checked.id : 'noGrid'; return checked ? checked.value : 'pen';
}
function setActiveType(typeId) {
const el = document.getElementById(typeId);
if (el) {
el.checked = true;
localStorage.setItem('gridType', typeId);
} }
}
function snapToGrid(x, y) { 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;
}
function snapToGrid(x, y) {
/* /*
Shapes are stored in grid units (document units), not pixels. Shapes are stored in grid units (document units), not pixels.
1 unit renders as cellSize pixels, so changing cellSize rescales (zooms) the whole drawing. 1 unit renders as cellSize pixels, so changing cellSize rescales (zooms) the whole drawing.
@ -496,9 +532,9 @@ function snapToGrid(x, y) {
localX, localX,
localY localY
}; };
} }
function normalizeRect(shape) { function normalizeRect(shape) {
const x1 = pxToGrid(shape.x1); const x1 = pxToGrid(shape.x1);
const y1 = pxToGrid(shape.y1); const y1 = pxToGrid(shape.y1);
const x2 = pxToGrid(shape.x2); const x2 = pxToGrid(shape.x2);
@ -514,14 +550,14 @@ function normalizeRect(shape) {
fill: shape.fill, fill: shape.fill,
opacity: clamp01(shape.opacity, 1) opacity: clamp01(shape.opacity, 1)
}; };
} }
function normalizeEllipse(shape) { function normalizeEllipse(shape) {
const r = normalizeRect(shape); const r = normalizeRect(shape);
return { ...r, type: 'ellipse' }; return { ...r, type: 'ellipse' };
} }
function normalizeLine(shape) { function normalizeLine(shape) {
return { return {
type: 'line', type: 'line',
x1: pxToGrid(shape.x1), x1: pxToGrid(shape.x1),
@ -530,9 +566,9 @@ function normalizeLine(shape) {
y2: pxToGrid(shape.y2), y2: pxToGrid(shape.y2),
color: shape.color color: shape.color
}; };
} }
function resizeAndSetupCanvas() { function resizeAndSetupCanvas() {
dpr = window.devicePixelRatio || 1; dpr = window.devicePixelRatio || 1;
const w = gridEl.clientWidth; const w = gridEl.clientWidth;
@ -554,16 +590,16 @@ function resizeAndSetupCanvas() {
} }
redrawAll(); redrawAll();
} }
function redrawAll() { function redrawAll() {
if (!ctx || !shapes) return; if (!ctx || !shapes) return;
clearCanvas(); clearCanvas();
shapes.forEach(drawShape); shapes.forEach(drawShape);
} }
function drawShape(shape) { function drawShape(shape) {
if (!ctx) return; if (!ctx) return;
const toPx = (v) => v * cellSize; const toPx = (v) => v * cellSize;
@ -630,14 +666,14 @@ function drawShape(shape) {
} }
ctx.restore(); ctx.restore();
} }
function clearCanvas() { function clearCanvas() {
if (!ctx) return; if (!ctx) return;
ctx.clearRect(0, 0, canvasEl.width / dpr, canvasEl.height / dpr); ctx.clearRect(0, 0, canvasEl.width / dpr, canvasEl.height / dpr);
} }
function setGrid() { function setGrid() {
const type = getActiveType(); const type = getActiveType();
gridEl.style.backgroundImage = ""; gridEl.style.backgroundImage = "";
@ -682,9 +718,9 @@ function setGrid() {
} else { // noGrid } else { // noGrid
gridEl.style.boxShadow = "inset 0 0 0 1px #ccc"; gridEl.style.boxShadow = "inset 0 0 0 1px #ccc";
} }
} }
function onPointerUp(e) { function onPointerUp(e) {
if (!currentShape) return; if (!currentShape) return;
// Only finalize if this pointer is the captured one (or we failed to capture, sigh) // Only finalize if this pointer is the captured one (or we failed to capture, sigh)
@ -731,35 +767,35 @@ function onPointerUp(e) {
currentShape = null; currentShape = null;
renderAllWithPreview(null); // clean final render renderAllWithPreview(null); // clean final render
} }
gridEl.addEventListener('pointerup', onPointerUp); gridEl.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointerup', onPointerUp, { capture: true });
document.querySelectorAll('input[name="tool"]').forEach(input => { root.querySelectorAll('input[data-tool]').forEach((input) => {
input.addEventListener('change', () => { input.addEventListener('change', () => {
if (input.checked) { if (input.checked) {
localStorage.setItem('gridTool', input.id); localStorage.setItem(`${storageKey}:tool`, input.value);
} }
}); });
}); });
document.querySelectorAll('input[name="gridType"]').forEach(input => { root.querySelectorAll('input[data-gridtype]').forEach((input) => {
input.addEventListener('change', () => { input.addEventListener('change', () => {
if (input.checked) { if (input.checked) {
localStorage.setItem('gridType', input.id); localStorage.setItem(`${storageKey}:gridType`, input.value);
} }
setGrid(); setGrid();
redrawAll(); redrawAll();
}); });
}); });
cellSizeEl.addEventListener('input', () => applyCellSize(cellSizeEl.value));
cellSizeEl.addEventListener('change', () => applyCellSize(cellSizeEl.value));
importButtonEl.addEventListener('click', () => importEl.click()); cellSizeEl.addEventListener('input', () => applyCellSize(cellSizeEl.value));
cellSizeEl.addEventListener('change', () => applyCellSize(cellSizeEl.value));
importEl.addEventListener('change', (e) => { importButtonEl.addEventListener('click', () => importEl.click());
importEl.addEventListener('change', (e) => {
const file = e.target.files[0]; const file = e.target.files[0];
if (!file) return; if (!file) return;
@ -791,9 +827,9 @@ importEl.addEventListener('change', (e) => {
} }
}; };
reader.readAsText(file); reader.readAsText(file);
}); });
exportEl.addEventListener('click', () => { exportEl.addEventListener('click', () => {
const payload = { const payload = {
version: 1, version: 1,
cellSize: cellSize, cellSize: cellSize,
@ -806,60 +842,42 @@ exportEl.addEventListener('click', () => {
a.download = 'grid-shapes.json'; a.download = 'grid-shapes.json';
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}); });
clearEl.addEventListener('click', () => { clearEl.addEventListener('click', () => {
cellSize = 25; cellSize = 25;
cellSizeEl.value = 25; cellSizeEl.value = 25;
applyCellSize(25); applyCellSize(25);
commit([]); commit([]);
}); });
colorEl.addEventListener('input', () => { colorEl.addEventListener('input', () => {
selectedColor = document.getElementById('color').value; selectedColor = colorEl.value || '#000000';
const circle = dotSVGEl.querySelector('circle'); const circle = dotSVGEl.querySelector('circle');
if (circle) { if (circle) {
circle.setAttribute('fill', selectedColor); circle.setAttribute('fill', selectedColor);
} }
}); });
document.addEventListener('keydown', (e) => { fillOpacityEl?.addEventListener('input', () => {
const key = e.key.toLowerCase();
if ((e.ctrlKey || e.metaKey) && key === 'z') {
e.preventDefault();
undo();
}
if ((e.ctrlKey || e.metaKey) && (key === 'y' || (e.shiftKey && key === 'z'))) {
e.preventDefault(); redo();
}
if (key === 'escape' && currentShape) {
currentShape = null;
redrawAll();
}
});
fillOpacityEl?.addEventListener('input', () => {
currentOpacity = clamp01(fillOpacityEl.value, 0); currentOpacity = clamp01(fillOpacityEl.value, 0);
}); });
fillOpacityEl?.addEventListener('change', () => { fillOpacityEl?.addEventListener('change', () => {
currentOpacity = clamp01(fillOpacityEl.value, 0); currentOpacity = clamp01(fillOpacityEl.value, 0);
}); });
gridEl.addEventListener('pointercancel', () => { gridEl.addEventListener('pointercancel', () => {
currentShape = null; currentShape = null;
redrawAll(); redrawAll();
}); });
gridEl.addEventListener('lostpointercapture', () => { gridEl.addEventListener('lostpointercapture', () => {
currentShape = null; currentShape = null;
redrawAll(); redrawAll();
}); });
gridEl.addEventListener('pointermove', (e) => { gridEl.addEventListener('pointermove', (e) => {
if (!ctx) return; if (!ctx) return;
const { ix, iy, x: snapX, y: snapY, localX, localY } = snapToGrid(e.clientX, e.clientY); const { ix, iy, x: snapX, y: snapY, localX, localY } = snapToGrid(e.clientX, e.clientY);
@ -915,17 +933,17 @@ gridEl.addEventListener('pointermove', (e) => {
} }
renderAllWithPreview(preview, true); renderAllWithPreview(preview, true);
}); });
gridEl.addEventListener('pointerleave', (e) => { gridEl.addEventListener('pointerleave', (e) => {
coordsEl.classList.add('d-none'); coordsEl.classList.add('d-none');
dotEl.classList.add('d-none'); dotEl.classList.add('d-none');
}); });
gridEl.addEventListener('pointerdown', (e) => { gridEl.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return; if (e.button !== 0) return;
if (e.target.closest('#toolBar')) return; if (e.target.closest('[data-toolbar]')) return;
e.preventDefault(); e.preventDefault();
gridEl.setPointerCapture(e.pointerId); gridEl.setPointerCapture(e.pointerId);
@ -976,61 +994,6 @@ gridEl.addEventListener('pointerdown', (e) => {
opacity: 1 opacity: 1
}; };
} }
}); });
}
window.addEventListener('pointerup', (e) => {
if (!currentShape) return;
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 simplified = [pts[0]];
const minStep = 0.03;
for (let i = 1; i < pts.length; i++) {
if (dist2(pts[i], simplified[simplified.length - 1]) >= minStep * minStep) {
simplified.push(pts[i])
}
}
if (simplified.length >= 2) {
finalShape = { ...currentShape, points: simplified };
}
}
}
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) commit([...shapes, finalShape]);
clearCanvas();
shapes.forEach(drawShape);
currentShape = null;
});
{% endblock %} {% endblock %}