From b67235b93d901482a9848299b1d26260f31a922b Mon Sep 17 00:00:00 2001 From: Thomas Nilles Date: Tue, 13 Jan 2026 00:40:05 -0500 Subject: [PATCH] Add UI web components and adjust .gitignore --- .gitignore | 4 +- .../canvas-display/canvas-display.js | 211 ++++++++++++++++++ .../components/color-picker/color-picker.js | 94 ++++++++ .../components/grid-selector/grid-selector.js | 127 +++++++++++ static/components/side-menu/side-menu.js | 102 +++++++++ static/components/toolbar/toolbar.js | 57 +++++ 6 files changed, 594 insertions(+), 1 deletion(-) create mode 100644 static/components/canvas-display/canvas-display.js create mode 100644 static/components/color-picker/color-picker.js create mode 100644 static/components/grid-selector/grid-selector.js create mode 100644 static/components/side-menu/side-menu.js create mode 100644 static/components/toolbar/toolbar.js diff --git a/.gitignore b/.gitignore index 715f934..3ef4dc6 100644 --- a/.gitignore +++ b/.gitignore @@ -39,7 +39,9 @@ go.work.sum # But not these files... !/.gitignore - +!*.js +!*.css +!*.mjt !*.go !go.sum !go.mod diff --git a/static/components/canvas-display/canvas-display.js b/static/components/canvas-display/canvas-display.js new file mode 100644 index 0000000..82a864d --- /dev/null +++ b/static/components/canvas-display/canvas-display.js @@ -0,0 +1,211 @@ +// CanvasDisplay component logic +// This file defines the custom element 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); diff --git a/static/components/color-picker/color-picker.js b/static/components/color-picker/color-picker.js new file mode 100644 index 0000000..900e2b1 --- /dev/null +++ b/static/components/color-picker/color-picker.js @@ -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 = ` + +
+ + +
+ `; + + // 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); diff --git a/static/components/grid-selector/grid-selector.js b/static/components/grid-selector/grid-selector.js new file mode 100644 index 0000000..9ba66e6 --- /dev/null +++ b/static/components/grid-selector/grid-selector.js @@ -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); diff --git a/static/components/side-menu/side-menu.js b/static/components/side-menu/side-menu.js new file mode 100644 index 0000000..c0d572a --- /dev/null +++ b/static/components/side-menu/side-menu.js @@ -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); diff --git a/static/components/toolbar/toolbar.js b/static/components/toolbar/toolbar.js new file mode 100644 index 0000000..4387003 --- /dev/null +++ b/static/components/toolbar/toolbar.js @@ -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);