Add UI web components and adjust .gitignore

This commit is contained in:
2026-01-13 00:40:05 -05:00
parent 6f508ed194
commit b67235b93d
6 changed files with 594 additions and 1 deletions

4
.gitignore vendored
View File

@@ -39,7 +39,9 @@ go.work.sum
# But not these files... # But not these files...
!/.gitignore !/.gitignore
!*.js
!*.css
!*.mjt
!*.go !*.go
!go.sum !go.sum
!go.mod !go.mod

View 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 backgroundcolor and gridsettings
// 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);

View 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);

View 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);

View 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 kebabcase convention used elsewhere
customElements.define("side-menu-component", SideMenu);

View 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 toolbarspecific event
_onColorChange(e) {
this.dispatchEvent(
new CustomEvent('bgcolorchange', {
detail: e.detail,
bubbles: true,
composed: true,
})
);
}
// Forward grid setting changes as a toolbarspecific event
_onGridChange(e) {
this.dispatchEvent(
new CustomEvent('gridsettingschange', {
detail: e.detail,
bubbles: true,
composed: true,
})
);
}
}
// Register the custom element; the tag name follows kebabcase convention
customElements.define('toolbar-component', Toolbar);