Add UI web components and adjust .gitignore
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -39,7 +39,9 @@ go.work.sum
|
||||
|
||||
# But not these files...
|
||||
!/.gitignore
|
||||
|
||||
!*.js
|
||||
!*.css
|
||||
!*.mjt
|
||||
!*.go
|
||||
!go.sum
|
||||
!go.mod
|
||||
|
||||
211
static/components/canvas-display/canvas-display.js
Normal file
211
static/components/canvas-display/canvas-display.js
Normal file
@@ -0,0 +1,211 @@
|
||||
// CanvasDisplay component logic
|
||||
// This file defines the custom element <canvas-display> which renders a canvas
|
||||
// and an SVG grid overlay. It reacts to background‑color and grid‑settings
|
||||
// events from the toolbar or directly via attributes.
|
||||
|
||||
class CanvasDisplay extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ["bg-color", "grid-color", "grid-size", "grid-numbering"];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// Attach shadow root
|
||||
this.attachShadow({ mode: "open" });
|
||||
|
||||
// Grab the template defined in canvas-display.html
|
||||
const tmpl = document.getElementById("canvas-display-template");
|
||||
if (!tmpl) {
|
||||
console.error("CanvasDisplay: template not found");
|
||||
return;
|
||||
}
|
||||
this.shadowRoot.appendChild(tmpl.content.cloneNode(true));
|
||||
|
||||
// Elements inside the shadow DOM
|
||||
this._canvas = this.shadowRoot.querySelector("#draw-canvas");
|
||||
this._gridOverlay = this.shadowRoot.querySelector("#grid-overlay");
|
||||
|
||||
// Canvas drawing context
|
||||
this._ctx = this._canvas.getContext("2d");
|
||||
|
||||
// Bind handlers
|
||||
this._onBgColorChange = this._onBgColorChange.bind(this);
|
||||
this._onGridSettingsChange = this._onGridSettingsChange.bind(this);
|
||||
this._onGridToggle = this._onGridToggle.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Initialise size – a default that can be overridden by CSS
|
||||
this._canvas.width = this.clientWidth || 300;
|
||||
this._canvas.height = this.clientHeight || 300;
|
||||
|
||||
// Apply default attribute values if none are set
|
||||
if (!this.hasAttribute("bg-color"))
|
||||
this.setAttribute("bg-color", "#ffffff");
|
||||
if (!this.hasAttribute("grid-color"))
|
||||
this.setAttribute("grid-color", "#000000");
|
||||
if (!this.hasAttribute("grid-size")) this.setAttribute("grid-size", "25");
|
||||
if (!this.hasAttribute("grid-numbering"))
|
||||
this.setAttribute("grid-numbering", "true");
|
||||
|
||||
// Initial draw
|
||||
this._updateCanvasBackground();
|
||||
this._drawGrid();
|
||||
|
||||
// Listen for events bubbled up from slotted child components
|
||||
this.addEventListener("bgcolorchange", this._onBgColorChange);
|
||||
this.addEventListener("gridsettingschange", this._onGridSettingsChange);
|
||||
this.addEventListener("gridtoggle", this._onGridToggle);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.removeEventListener("bgcolorchange", this._onBgColorChange);
|
||||
this.removeEventListener("gridsettingschange", this._onGridSettingsChange);
|
||||
this.removeEventListener("gridtoggle", this._onGridToggle);
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, oldVal, newVal) {
|
||||
if (oldVal === newVal) return;
|
||||
switch (name) {
|
||||
case "bg-color":
|
||||
this._updateCanvasBackground();
|
||||
break;
|
||||
case "grid-color":
|
||||
case "grid-size":
|
||||
case "grid-numbering":
|
||||
this._drawGrid();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Property getters/setters -----
|
||||
get bgColor() {
|
||||
return this.getAttribute("bg-color");
|
||||
}
|
||||
set bgColor(v) {
|
||||
this.setAttribute("bg-color", v);
|
||||
}
|
||||
|
||||
get gridColor() {
|
||||
return this.getAttribute("grid-color");
|
||||
}
|
||||
set gridColor(v) {
|
||||
this.setAttribute("grid-color", v);
|
||||
}
|
||||
|
||||
get gridSize() {
|
||||
return Number(this.getAttribute("grid-size"));
|
||||
}
|
||||
set gridSize(v) {
|
||||
this.setAttribute("grid-size", v);
|
||||
}
|
||||
|
||||
get gridNumbering() {
|
||||
return this.getAttribute("grid-numbering") === "true";
|
||||
}
|
||||
set gridNumbering(v) {
|
||||
this.setAttribute("grid-numbering", v);
|
||||
}
|
||||
|
||||
// ----- Event handlers -----
|
||||
_onBgColorChange(e) {
|
||||
const { color } = e.detail;
|
||||
this.bgColor = color;
|
||||
}
|
||||
|
||||
_onGridSettingsChange(e) {
|
||||
const { color, size, numbering } = e.detail;
|
||||
this.gridColor = color;
|
||||
this.gridSize = size;
|
||||
this.gridNumbering = numbering;
|
||||
}
|
||||
|
||||
_onGridToggle(e) {
|
||||
const { enabled } = e.detail;
|
||||
this.gridNumbering = enabled;
|
||||
// Reflect the change to the attribute so CSS/DOM stays in sync
|
||||
this.setAttribute("grid-numbering", enabled);
|
||||
}
|
||||
|
||||
// ----- Rendering helpers -----
|
||||
_updateCanvasBackground() {
|
||||
const { _ctx, _canvas, bgColor } = this;
|
||||
_ctx.save();
|
||||
_ctx.fillStyle = bgColor;
|
||||
_ctx.fillRect(0, 0, _canvas.width, _canvas.height);
|
||||
_ctx.restore();
|
||||
}
|
||||
|
||||
_drawGrid() {
|
||||
const { _gridOverlay, _canvas, gridColor, gridSize, gridNumbering } = this;
|
||||
|
||||
// Clear any previous grid
|
||||
while (_gridOverlay.firstChild)
|
||||
_gridOverlay.removeChild(_gridOverlay.firstChild);
|
||||
|
||||
const width = _canvas.width;
|
||||
const height = _canvas.height;
|
||||
const spacing = gridSize;
|
||||
|
||||
// Set SVG size and viewBox to match canvas dimensions
|
||||
_gridOverlay.setAttribute("width", width);
|
||||
_gridOverlay.setAttribute("height", height);
|
||||
_gridOverlay.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||
|
||||
// Helper to create a line
|
||||
const makeLine = (x1, y1, x2, y2, thick) => {
|
||||
const line = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"line",
|
||||
);
|
||||
line.setAttribute("x1", x1);
|
||||
line.setAttribute("y1", y1);
|
||||
line.setAttribute("x2", x2);
|
||||
line.setAttribute("y2", y2);
|
||||
line.setAttribute("stroke", gridColor);
|
||||
line.setAttribute("stroke-width", thick ? 2 : 1);
|
||||
return line;
|
||||
};
|
||||
|
||||
// Draw vertical lines
|
||||
for (let x = 0; x <= width; x += spacing) {
|
||||
const thick = (x / spacing) % 5 === 0;
|
||||
_gridOverlay.appendChild(makeLine(x, 0, x, height, thick));
|
||||
}
|
||||
|
||||
// Draw horizontal lines
|
||||
for (let y = 0; y <= height; y += spacing) {
|
||||
const thick = (y / spacing) % 5 === 0;
|
||||
_gridOverlay.appendChild(makeLine(0, y, width, y, thick));
|
||||
}
|
||||
|
||||
// Optional numbering
|
||||
if (gridNumbering) {
|
||||
const makeText = (x, y, txt) => {
|
||||
const t = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"text",
|
||||
);
|
||||
t.setAttribute("x", x + 2);
|
||||
t.setAttribute("y", y + 10);
|
||||
t.setAttribute("fill", gridColor);
|
||||
t.setAttribute("font-size", "8");
|
||||
t.textContent = txt;
|
||||
return t;
|
||||
};
|
||||
// Number columns
|
||||
for (let x = spacing; x <= width; x += spacing) {
|
||||
const col = x / spacing;
|
||||
if (col % 5 === 0) _gridOverlay.appendChild(makeText(x, 12, col));
|
||||
}
|
||||
// Number rows
|
||||
for (let y = spacing; y <= height; y += spacing) {
|
||||
const row = y / spacing;
|
||||
if (row % 5 === 0) _gridOverlay.appendChild(makeText(2, y, row));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the custom element
|
||||
customElements.define("canvas-display", CanvasDisplay);
|
||||
94
static/components/color-picker/color-picker.js
Normal file
94
static/components/color-picker/color-picker.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// ColorPicker component – template is embedded directly in the JS file.
|
||||
|
||||
class ColorPicker extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ["value"];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Create a template element and embed the markup.
|
||||
const tmpl = document.createElement("template");
|
||||
tmpl.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
display: inline-block;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
.wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
label {
|
||||
font-size: 0.9rem;
|
||||
user-select: none;
|
||||
}
|
||||
input[type="color"] {
|
||||
border: none;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
</style>
|
||||
<div class="wrapper">
|
||||
<label for="color-input">Background:</label>
|
||||
<input type="color" id="color-input" value="#ffffff">
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Attach a shadow root and clone the template into it.
|
||||
this.attachShadow({ mode: "open" });
|
||||
this.shadowRoot.appendChild(tmpl.content.cloneNode(true));
|
||||
|
||||
// Reference the input element for later use.
|
||||
this._input = this.shadowRoot.querySelector("#color-input");
|
||||
|
||||
// Bind event handler.
|
||||
this._onInput = this._onInput.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Initialise the input value from the attribute (or default).
|
||||
const init = this.getAttribute("value") || "#ffffff";
|
||||
this._input.value = init;
|
||||
this._input.addEventListener("input", this._onInput);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this._input.removeEventListener("input", this._onInput);
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, oldVal, newVal) {
|
||||
if (name === "value" && this._input && oldVal !== newVal) {
|
||||
this._input.value = newVal;
|
||||
}
|
||||
}
|
||||
|
||||
// Property getter/setter for easy JS access.
|
||||
get value() {
|
||||
return this._input ? this._input.value : this.getAttribute("value");
|
||||
}
|
||||
|
||||
set value(v) {
|
||||
this.setAttribute("value", v);
|
||||
}
|
||||
|
||||
// Dispatch a custom event when the user picks a color.
|
||||
_onInput(e) {
|
||||
const color = e.target.value;
|
||||
this.setAttribute("value", color);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("colorchange", {
|
||||
detail: { color },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Register the custom element.
|
||||
customElements.define("color-picker", ColorPicker);
|
||||
127
static/components/grid-selector/grid-selector.js
Normal file
127
static/components/grid-selector/grid-selector.js
Normal file
@@ -0,0 +1,127 @@
|
||||
// GridSelector component definition
|
||||
// This file should be loaded as a module (type="module") after the
|
||||
// corresponding HTML template (grid-selector.html) is present in the DOM.
|
||||
|
||||
class GridSelector extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
return ['grid-color', 'grid-size', 'grid-numbering'];
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// Attach a shadow root
|
||||
this.attachShadow({ mode: 'open' });
|
||||
|
||||
// Load the template defined in grid-selector.html
|
||||
const tmpl = document.getElementById('grid-selector-template');
|
||||
if (!tmpl) {
|
||||
console.error('GridSelector: template not found in DOM');
|
||||
return;
|
||||
}
|
||||
this.shadowRoot.appendChild(tmpl.content.cloneNode(true));
|
||||
|
||||
// Grab elements from the template
|
||||
this._colorInput = this.shadowRoot.querySelector('#grid-color');
|
||||
this._sizeInput = this.shadowRoot.querySelector('#grid-size');
|
||||
this._numberingInput = this.shadowRoot.querySelector('#grid-numbering');
|
||||
|
||||
// Bind event handlers
|
||||
this._onColorChange = this._onColorChange.bind(this);
|
||||
this._onSizeChange = this._onSizeChange.bind(this);
|
||||
this._onNumberingChange = this._onNumberingChange.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Initialise values from attributes or defaults
|
||||
this._colorInput.value = this.getAttribute('grid-color') || '#000000';
|
||||
this._sizeInput.value = this.getAttribute('grid-size') || '25';
|
||||
const numbering = this.hasAttribute('grid-numbering')
|
||||
? this.getAttribute('grid-numbering') === 'true'
|
||||
: true;
|
||||
this._numberingInput.checked = numbering;
|
||||
|
||||
// Register listeners
|
||||
this._colorInput.addEventListener('input', this._onColorChange);
|
||||
this._sizeInput.addEventListener('input', this._onSizeChange);
|
||||
this._numberingInput.addEventListener('change', this._onNumberingChange);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this._colorInput.removeEventListener('input', this._onColorChange);
|
||||
this._sizeInput.removeEventListener('input', this._onSizeChange);
|
||||
this._numberingInput.removeEventListener('change', this._onNumberingChange);
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
if (oldValue === newValue) return;
|
||||
switch (name) {
|
||||
case 'grid-color':
|
||||
if (this._colorInput) this._colorInput.value = newValue;
|
||||
break;
|
||||
case 'grid-size':
|
||||
if (this._sizeInput) this._sizeInput.value = newValue;
|
||||
break;
|
||||
case 'grid-numbering':
|
||||
if (this._numberingInput) this._numberingInput.checked = newValue === 'true';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Property getters / setters -----
|
||||
get gridColor() {
|
||||
return this._colorInput ? this._colorInput.value : this.getAttribute('grid-color');
|
||||
}
|
||||
set gridColor(v) {
|
||||
this.setAttribute('grid-color', v);
|
||||
}
|
||||
|
||||
get gridSize() {
|
||||
return this._sizeInput ? Number(this._sizeInput.value) : Number(this.getAttribute('grid-size'));
|
||||
}
|
||||
set gridSize(v) {
|
||||
this.setAttribute('grid-size', v);
|
||||
}
|
||||
|
||||
get gridNumbering() {
|
||||
return this._numberingInput ? this._numberingInput.checked : this.hasAttribute('grid-numbering') && this.getAttribute('grid-numbering') === 'true';
|
||||
}
|
||||
set gridNumbering(v) {
|
||||
this.setAttribute('grid-numbering', v);
|
||||
}
|
||||
|
||||
// ----- Internal event handlers -----
|
||||
_onColorChange(e) {
|
||||
const color = e.target.value;
|
||||
this.setAttribute('grid-color', color);
|
||||
this._dispatchChange();
|
||||
}
|
||||
|
||||
_onSizeChange(e) {
|
||||
const size = e.target.value;
|
||||
this.setAttribute('grid-size', size);
|
||||
this._dispatchChange();
|
||||
}
|
||||
|
||||
_onNumberingChange(e) {
|
||||
const checked = e.target.checked;
|
||||
this.setAttribute('grid-numbering', checked);
|
||||
this._dispatchChange();
|
||||
}
|
||||
|
||||
_dispatchChange() {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('gridchange', {
|
||||
detail: {
|
||||
color: this.gridColor,
|
||||
size: this.gridSize,
|
||||
numbering: this.gridNumbering,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Register the custom element
|
||||
customElements.define('grid-selector', GridSelector);
|
||||
102
static/components/side-menu/side-menu.js
Normal file
102
static/components/side-menu/side-menu.js
Normal file
@@ -0,0 +1,102 @@
|
||||
// Side menu component logic
|
||||
|
||||
class SideMenu extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Attach a shadow root and clone the template defined in side-menu.html
|
||||
this.attachShadow({ mode: "open" });
|
||||
const tmpl = document.getElementById("side-menu-template");
|
||||
if (!tmpl) {
|
||||
console.error("SideMenu: template not found");
|
||||
return;
|
||||
}
|
||||
this.shadowRoot.appendChild(tmpl.content.cloneNode(true));
|
||||
|
||||
// Reference the toggle button and the menu container
|
||||
this._toggleBtn = this.shadowRoot.querySelector(".toggle");
|
||||
this._menu = this.shadowRoot.querySelector(".menu");
|
||||
this._colorPicker = this.shadowRoot.querySelector("color-picker");
|
||||
this._gridToggle = this.shadowRoot.querySelector("#grid-toggle");
|
||||
|
||||
// Bind event handlers
|
||||
this._onToggle = this._onToggle.bind(this);
|
||||
this._onColorChange = this._onColorChange.bind(this);
|
||||
this._onGridToggle = this._onGridToggle.bind(this);
|
||||
}
|
||||
|
||||
static get observedAttributes() {
|
||||
return ["collapsed"];
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Register click listener for the toggle button
|
||||
this._toggleBtn?.addEventListener("click", this._onToggle);
|
||||
this._colorPicker?.addEventListener("colorchange", this._onColorChange);
|
||||
this._gridToggle?.addEventListener("change", this._onGridToggle);
|
||||
|
||||
// Initialise collapsed state from the attribute (if present)
|
||||
if (this.hasAttribute("collapsed")) {
|
||||
this._applyCollapsed(true);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
// Clean up listeners when the element is removed
|
||||
this._toggleBtn?.removeEventListener("click", this._onToggle);
|
||||
this._colorPicker?.removeEventListener("colorchange", this._onColorChange);
|
||||
this._gridToggle?.removeEventListener("change", this._onGridToggle);
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, oldVal, newVal) {
|
||||
if (name === "collapsed") {
|
||||
const isCollapsed = this.hasAttribute("collapsed");
|
||||
this._applyCollapsed(isCollapsed);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply or remove the CSS class that slides the menu
|
||||
_applyCollapsed(collapsed) {
|
||||
const host = this.shadowRoot.host;
|
||||
if (collapsed) {
|
||||
host.classList.add("collapsed");
|
||||
} else {
|
||||
host.classList.remove("collapsed");
|
||||
}
|
||||
}
|
||||
// Forward color picker change as bgcolorchange event
|
||||
_onColorChange(e) {
|
||||
const { color } = e.detail;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("bgcolorchange", {
|
||||
detail: { color },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Forward grid toggle change as gridtoggle event
|
||||
_onGridToggle(e) {
|
||||
const enabled = e.target.checked;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("gridtoggle", {
|
||||
detail: { enabled },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Toggle the collapsed attribute when the button is clicked
|
||||
_onToggle() {
|
||||
if (this.hasAttribute("collapsed")) {
|
||||
this.removeAttribute("collapsed");
|
||||
} else {
|
||||
this.setAttribute("collapsed", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the custom element; we follow the kebab‑case convention used elsewhere
|
||||
customElements.define("side-menu-component", SideMenu);
|
||||
57
static/components/toolbar/toolbar.js
Normal file
57
static/components/toolbar/toolbar.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// Toolbar component logic
|
||||
|
||||
class Toolbar extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Attach a shadow root and clone the template defined in toolbar.html
|
||||
this.attachShadow({ mode: 'open' });
|
||||
const tmpl = document.getElementById('toolbar-template');
|
||||
if (!tmpl) {
|
||||
console.error('Toolbar: template not found in DOM');
|
||||
return;
|
||||
}
|
||||
this.shadowRoot.appendChild(tmpl.content.cloneNode(true));
|
||||
|
||||
// Bind internal handlers
|
||||
this._onColorChange = this._onColorChange.bind(this);
|
||||
this._onGridChange = this._onGridChange.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Listen for events bubbled up from slotted child components
|
||||
this.addEventListener('colorchange', this._onColorChange);
|
||||
this.addEventListener('gridchange', this._onGridChange);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
// Clean up listeners when the element is removed
|
||||
this.removeEventListener('colorchange', this._onColorChange);
|
||||
this.removeEventListener('gridchange', this._onGridChange);
|
||||
}
|
||||
|
||||
// Forward background color changes as a toolbar‑specific event
|
||||
_onColorChange(e) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('bgcolorchange', {
|
||||
detail: e.detail,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Forward grid setting changes as a toolbar‑specific event
|
||||
_onGridChange(e) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('gridsettingschange', {
|
||||
detail: e.detail,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Register the custom element; the tag name follows kebab‑case convention
|
||||
customElements.define('toolbar-component', Toolbar);
|
||||
Reference in New Issue
Block a user