diff --git a/.eslintrc.js b/.eslintrc.js index 387043b..6597ca8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -22,5 +22,6 @@ module.exports = { rules: { quotes: ["error", "double"], "no-shadow": ["error", { hoist: "functions", allow: [] }], + "no-unused-vars": ["error", { argsIgnorePattern: "^_" }], }, }; diff --git a/css/panel.css b/css/panel.css index 01b24d3..a0b274e 100644 --- a/css/panel.css +++ b/css/panel.css @@ -37,21 +37,25 @@ justify-content: space-between; } -.panel-row button { +.panel-row ui-button, +.panel-row color-button, +.panel-row tool-button, +.panel-row variant-button, +.panel-row variant-stamp-button { margin-right: var(--button-space); - width: var(--button-size); - height: var(--button-size); - border: 2px solid black; - border-radius: 6px; } -.panel-row button:last-child { +.panel-row ui-button:last-child, +.panel-row color-button:last-child, +.panel-row tool-button:last-child, +.panel-row variant-button:last-child, +.panel-row variant-stamp-button:last-child { margin-right: 0px; } -.panel-row button.active { - border: 2px solid white; - box-shadow: 0px 0px 0px 4px orange; +#tools { + display: flex; + flex: 1; } #actions { diff --git a/index.html b/index.html index 6e101c9..9756504 100644 --- a/index.html +++ b/index.html @@ -76,9 +76,9 @@

New version available

- - - + + +
diff --git a/js/boot.mjs b/js/boot.mjs index ef6951a..8bfc0ed 100644 --- a/js/boot.mjs +++ b/js/boot.mjs @@ -22,6 +22,19 @@ import { attachKeyboardListeners } from "./controls/keyboard.mjs"; import { attachGamepadBlockListeners } from "./controls/general.mjs"; import { attachGamepadListeners } from "./controls/gamepad.mjs"; import { initializeCursor } from "./cursor.mjs"; +import { ColorButton } from "./ui/color.mjs"; +import { ToolButton } from "./ui/tool.mjs"; +import { VariantButton } from "./ui/variant.mjs"; +import { VariantStampButton } from "./ui/variant-stamp.mjs"; +import { UiButton } from "./ui/button.mjs"; + +function registerComponents() { + customElements.define("ui-button", UiButton); + customElements.define("color-button", ColorButton); + customElements.define("tool-button", ToolButton); + customElements.define("variant-button", VariantButton); + customElements.define("variant-stamp-button", VariantStampButton); +} function attachResizeListeners() { const canvas = getCanvas(); @@ -59,6 +72,7 @@ export function boot() { const state = createState(); const canvas = getCanvas(); + registerComponents(); restorePreviousCanvas(canvas); attachCanvasSaveListener(canvas); diff --git a/js/controls/keyboard.mjs b/js/controls/keyboard.mjs index d36be2d..5776326 100644 --- a/js/controls/keyboard.mjs +++ b/js/controls/keyboard.mjs @@ -39,15 +39,7 @@ export function attachKeyboardListeners(state) { let accelerationTimer = null; let keysPressed = NO_KEYS_PRESSED; - function shouldBlockInteractions() { - return state.get((prevState) => prevState.blockedInteractions); - } - window.addEventListener("keyup", (event) => { - if (shouldBlockInteractions()) { - return; - } - switch (event.key) { case "ArrowUp": keysPressed = produceMovementKeysPressed(keysPressed, "up", false); @@ -67,10 +59,6 @@ export function attachKeyboardListeners(state) { }); window.addEventListener("keydown", (event) => { - if (shouldBlockInteractions()) { - return; - } - if (event.key === "p") { setTool(TOOLS.PEN, { state }); } else if (event.key === "f") { diff --git a/js/cursor.mjs b/js/cursor.mjs index 7e43c71..d379970 100644 --- a/js/cursor.mjs +++ b/js/cursor.mjs @@ -23,19 +23,11 @@ export function initializeCursor({ state }) { const y = cursor.y; let setCursorTimer = null; - function shouldBlockInteractions() { - return state.get((prevState) => prevState.blockedInteractions); - } - function drawCursorOnMouseMove(event) { if (setCursorTimer) { window.clearTimeout(setCursorTimer); } - if (shouldBlockInteractions()) { - return; - } - const rect = canvas.getBoundingClientRect(); const { clientX, clientY } = event; diff --git a/js/dom.mjs b/js/dom.mjs index 7adb358..e772319 100644 --- a/js/dom.mjs +++ b/js/dom.mjs @@ -12,10 +12,28 @@ export function getPanelTools() { return document.getElementById("tools"); } +export function getToolButtons() { + return getPanelTools().querySelectorAll("tool-button"); +} + +export function getToolButtonById(id) { + return Array.from(getToolButtons()).find((button) => button.id === id); +} + export function getPanelToolVariants() { return document.getElementById("variants"); } +export function getVariantButtons() { + return getPanelToolVariants().querySelectorAll( + "variant-button,variant-stamp-button", + ); +} + +export function getVariantButtonById(id) { + return Array.from(getVariantButtons()).find((button) => button.id === id); +} + export function getPanelToolActions() { return document.getElementById("actions"); } @@ -24,6 +42,14 @@ export function getPanelColors() { return document.getElementById("colors"); } +export function getColorButtons() { + return getPanelColors().querySelectorAll("color-button"); +} + +export function getColorButtonByColor(color) { + return Array.from(getColorButtons()).find((button) => button.color === color); +} + export function getPanel() { return document.getElementById("panel"); } diff --git a/js/state/actions/cam.mjs b/js/state/actions/cam.mjs index 20e7321..ad36044 100644 --- a/js/state/actions/cam.mjs +++ b/js/state/actions/cam.mjs @@ -6,7 +6,6 @@ import { getCountdownAnimationLengthInSeconds, getFlashAnimationLengthInSeconds, } from "../../dom.mjs"; -import { blockInteractions, unblockInteractions } from "../actions/ui.mjs"; function memorizePhoto({ state }) { state.set(() => ({ @@ -29,7 +28,6 @@ export function takePhoto({ state }) { const flashAnimationLength = getFlashAnimationLengthInSeconds() * 1000; insertCountdown(); - blockInteractions({ state }); const videoSettings = cam.srcObject.getVideoTracks()[0]?.getSettings(); const height = canvas.height; @@ -39,7 +37,6 @@ export function takePhoto({ state }) { ctx.drawImage(cam, canvas.width / 2 - width / 2, 0, width, height); memorizePhoto({ state }); removeCountdown(); - unblockInteractions({ state }); }, countdownAnimationLength + flashAnimationLength); } diff --git a/js/state/actions/ui.mjs b/js/state/actions/ui.mjs index 3c39d1a..3fb3d54 100644 --- a/js/state/actions/ui.mjs +++ b/js/state/actions/ui.mjs @@ -1,17 +1,5 @@ import { getInfoDialog } from "../../dom.mjs"; -export function blockInteractions({ state }) { - state.set(() => ({ - blockedInteractions: true, - })); -} - -export function unblockInteractions({ state }) { - state.set(() => ({ - blockedInteractions: false, - })); -} - export function showInfo() { const dialog = getInfoDialog(); const closeButton = dialog.querySelector("#close-info"); diff --git a/js/state/state.mjs b/js/state/state.mjs index f23e31e..5b877e3 100644 --- a/js/state/state.mjs +++ b/js/state/state.mjs @@ -17,7 +17,6 @@ export function createState() { color: loadColor(), gamepad: null, photoMemorized: false, - blockedInteractions: false, gamepadBlocked: false, }; diff --git a/js/tools/fill.mjs b/js/tools/fill.mjs index 72f1411..6a81746 100644 --- a/js/tools/fill.mjs +++ b/js/tools/fill.mjs @@ -4,10 +4,6 @@ import { isPrimaryGamepadButtonPressed, } from "../controls/gamepad.mjs"; import { isWithinCanvasBounds } from "../canvas.mjs"; -import { - blockInteractions, - unblockInteractions, -} from "../state/actions/ui.mjs"; function hexToRGB(h) { let r = 0, @@ -53,9 +49,8 @@ function colorsMatch(a, b) { } // taken from SO: https://stackoverflow.com/a/56221940/3056783 -function floodFill(ctx, x, y, fillColor, { state }) { +function floodFill(ctx, x, y, fillColor) { showLoader(); - blockInteractions({ state }); // read the pixels in the canvas const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height); @@ -83,7 +78,6 @@ function floodFill(ctx, x, y, fillColor, { state }) { ctx.putImageData(imageData, 0, 0); } - unblockInteractions({ state }); hideLoader(); } @@ -131,18 +125,6 @@ export function activateFill({ state }) { window.removeEventListener("keydown", keyDown); } - function onBlockInteractionsChange(nextState, prevState) { - if (nextState.blockedInteractions === prevState.blockedInteractions) { - return; - } - - if (nextState.blockedInteractions) { - deactivateListeners(); - } else { - activateListeners(); - } - } - let wasPressed = false; function activateFillOnGamepadButtonPress() { const gamepad = getGamepad(state); @@ -187,20 +169,9 @@ export function activateFill({ state }) { frame = null; } - const blockedInteractions = state.get( - (prevState) => prevState.blockedInteractions, - ); - - if (blockedInteractions) { - deactivateListeners(); - } else { - activateListeners(); - } - - state.addListener(onBlockInteractionsChange); + activateListeners(); return function dispose() { deactivateListeners(); - state.removeListener(onBlockInteractionsChange); }; } diff --git a/js/tools/pen.mjs b/js/tools/pen.mjs index 2d50405..8431f34 100644 --- a/js/tools/pen.mjs +++ b/js/tools/pen.mjs @@ -201,27 +201,7 @@ export function activatePen({ state, variant }) { cancelGamepadAnimationFrame(); } - function onBlockInteractionsChange(nextState, prevState) { - if (nextState.blockedInteractions === prevState.blockedInteractions) { - return; - } - - if (nextState.blockedInteractions) { - deactivateListeners(); - } else { - activateListeners(); - } - } - - const blockInteractions = state.get( - (prevState) => prevState.blockedInteractions, - ); - - if (blockInteractions) { - deactivateListeners(); - } else { - activateListeners(); - } + activateListeners(); function updateColor(nextState, prevState) { if (nextState.color === prevState.color) { @@ -243,13 +223,11 @@ export function activatePen({ state, variant }) { draw(nextState.cursor.x, nextState.cursor.y); } - state.addListener(onBlockInteractionsChange); state.addListener(updateColor); state.addListener(updatePath); return function dispose() { deactivateListeners(); - state.removeListener(onBlockInteractionsChange); state.removeListener(updateColor); state.removeListener(updatePath); }; diff --git a/js/ui/actions.mjs b/js/ui/actions.mjs index 7d06672..8e80e47 100644 --- a/js/ui/actions.mjs +++ b/js/ui/actions.mjs @@ -1,15 +1,11 @@ import { takePhoto, removePhoto } from "../state/actions/cam.mjs"; import { getPanelToolActions } from "../dom.mjs"; -import { disposeCallback, ensureCallbacksRemoved, loadIcon } from "./utils.mjs"; +import { UiButton } from "./button.mjs"; -export function buildToolActions(tool, state) { +export function buildToolActions(tool, { state, signal }) { const actionsContainer = getPanelToolActions(); - const listeners = {}; - tool.actions.forEach((action) => { - const button = document.createElement("button"); - function onCamTakePhotoClick() { takePhoto({ state }); } @@ -18,45 +14,48 @@ export function buildToolActions(tool, state) { removePhoto({ state }); } + let button = null; + switch (action.id.description) { case "cam-take-photo": { - listeners[action.id.description] = onCamTakePhotoClick; - button.addEventListener("click", onCamTakePhotoClick); + button = new UiButton({ + ariaLabel: action.id.description, + dataset: { + value: action.id.description, + }, + iconUrl: action.iconUrl, + onClick: onCamTakePhotoClick, + signal, + }); + break; } case "cam-cancel": { - listeners[action.id.description] = onCamCancelClick; - button.addEventListener("click", onCamCancelClick); + button = new UiButton({ + ariaLabel: action.id.description, + dataset: { + value: action.id.description, + }, + iconUrl: action.iconUrl, + onClick: onCamCancelClick, + signal, + }); break; } } - button.dataset.value = action.id.description; - button.innerText = action.id.description; - - loadIcon(action.iconUrl) - .then((icon) => { - button.innerHTML = icon; - }) - .catch((error) => { - console.error(error); - button.innerText = action.id.description; - }); - actionsContainer.appendChild(button); }); - return function dispose() { + function dispose() { Array.from(actionsContainer.querySelectorAll("button")).forEach( (button) => { - disposeCallback(button, listeners); - button.remove(); }, ); - ensureCallbacksRemoved(listeners); - actionsContainer.innerHTML = ""; - }; + } + + signal.addEventListener("abort", dispose, { once: true }); } diff --git a/js/ui/button.mjs b/js/ui/button.mjs new file mode 100644 index 0000000..fd88cf8 --- /dev/null +++ b/js/ui/button.mjs @@ -0,0 +1,151 @@ +import { loadIcon } from "./utils.mjs"; +import { serializeSvg, deserializeSvgFromDataURI } from "../svg-utils.mjs"; +import { isDataUri } from "../state/utils.mjs"; + +/** + * This is a base UI button with square shape. + */ +export class UiButton extends HTMLElement { + // Can be built also declaratively with HTML. + constructor(options = {}) { + const { + isActive, + id, + ariaLabel, + dataset, + iconUrl, + backgroundColor, + onClick, + signal, + } = options; + + super(); + + this.#isActive = isActive ?? false; + this.#id = id; + this.#onClick = onClick; + this.#signal = signal; + this.#ariaLabel = ariaLabel; + this.#dataset = dataset; + this.#iconUrl = iconUrl; + this.#backgroundColor = backgroundColor; + + this.attachShadow({ mode: "open" }); + } + + #isActive = false; + #id = ""; + #iconUrl = null; + #ariaLabel = ""; + #dataset = {}; + #backgroundColor = "#000000"; + #onClick = () => {}; + #signal = null; + + connectedCallback() { + this.shadowRoot.innerHTML = ` + + + `; + + if (this.#onClick) { + this.addClickListener(this.#onClick); + } + this.iconUrl = this.#iconUrl || this.getAttribute("icon-url"); + this.isActive = false; + } + + attributeChangedCallback(name, oldValue, newValue) { + switch (name) { + case "icon-url": + if (oldValue === newValue) { + break; + } + this.iconUrl = newValue; + break; + } + } + + click(e) { + this.button.dispatchEvent(e); + } + + addClickListener(listener) { + this.button.addEventListener("click", listener, { + signal: this.#signal, + }); + } + + removeClickListener(listener) { + this.button.removeEventListener("click", listener); + } + + get button() { + return this.shadowRoot.querySelector("button"); + } + + set isActive(value) { + this.#isActive = value; + this.button.setAttribute("aria-pressed", value ? "true" : "false"); + } + + get isActive() { + return this.#isActive; + } + + set iconUrl(value) { + if (!value) { + return; + } + + const dataset = this.#dataset ?? this.dataset; + + if (isDataUri(value)) { + this.button.innerHTML = serializeSvg(deserializeSvgFromDataURI(value)); + } else { + loadIcon(value) + .then((icon) => { + this.button.innerHTML = icon; + }) + .catch((error) => { + console.error(error); + this.button.innerText = UiButton.#getContentFallback(dataset); + }); + } + } + + static observedAttributes = ["icon-url"]; + + static #createDataSetAttributesString(dataset) { + return Object.entries(dataset ?? {}) + .map(([key, value]) => `data-${key}="${value}"`) + .join(" "); + } + + static #getContentFallback(dataset) { + return dataset?.id?.description ?? ""; + } +} diff --git a/js/ui/color.mjs b/js/ui/color.mjs new file mode 100644 index 0000000..9483f1f --- /dev/null +++ b/js/ui/color.mjs @@ -0,0 +1,54 @@ +import { COLOR } from "../state/constants.mjs"; +import { UiButton } from "./button.mjs"; + +/** + * Represents a button for selecting colors + */ +export class ColorButton extends UiButton { + constructor({ onClick, color, signal, isActive }) { + super(); + + this.color = color; + this.#isActive = isActive; + this.#onClick = onClick; + this.#signal = signal; + } + + color = "#000000"; + #onClick = () => {}; + #signal = null; + #isActive = false; + + connectedCallback() { + const button = new UiButton({ + ariaLabel: `${Object.entries(COLOR) + .find(([_, value]) => value === this.color)?.[0] + ?.toLocaleLowerCase()} color`, + dataset: { + value: this.color, + }, + backgroundColor: this.color, + onClick: this.#onClick, + signal: this.#signal, + }); + + this.shadowRoot.appendChild(button); + this.isActive = this.#isActive; + } + + click(e) { + this.#button.click(e); + } + + set isActive(value) { + this.shadowRoot.querySelector("ui-button").isActive = value; + } + + get isActive() { + return this.shadowRoot.querySelector("ui-button").isActive; + } + + get #button() { + return this.shadowRoot.querySelector("ui-button").button; + } +} diff --git a/js/ui/colors.mjs b/js/ui/colors.mjs index 1eac578..30d61cf 100644 --- a/js/ui/colors.mjs +++ b/js/ui/colors.mjs @@ -9,12 +9,16 @@ import { isLeftShoulderGamepadButtonPressed, getGamepad, } from "../controls/gamepad.mjs"; -import { getPanelColors } from "../dom.mjs"; -import { updateActivatedButton } from "./utils.mjs"; +import { + getPanelColors, + getColorButtons, + getColorButtonByColor, +} from "../dom.mjs"; +import { ColorButton } from "./color.mjs"; const GAMEPAD_BUTTON_ACTIVATION_DELAY_IN_MS = 300; -function attachKeyboardListeners(state) { +function attachKeyboardListeners({ state, signal }) { function onKeyDown(event) { switch (event.key) { case "a": @@ -28,11 +32,7 @@ function attachKeyboardListeners(state) { } } - window.addEventListener("keydown", onKeyDown); - - return function dispose() { - window.removeEventListener("keydown", onKeyDown); - }; + window.addEventListener("keydown", onKeyDown, { signal }); } function attachGamepadListeners(state) { @@ -75,72 +75,43 @@ function attachGamepadListeners(state) { frame = requestAnimationFrame(activateColorCycleOnShoulderButtonPresses); } - function cancelGamepadAnimationFrame() { - cancelAnimationFrame(frame); - frame = null; - } - requestGamepadAnimationFrame(); - - return function dispose() { - cancelGamepadAnimationFrame(); - }; } export function createColorPanel({ state }) { const colors = getPanelColors(); - let disposeKeyboardListenersCallback = null; - let disposeGamepadListenersCallback = null; + let controller = new AbortController(); state.addListener((nextState, prevState) => { if (nextState.color === prevState.color) { return; } - updateActivatedButton(colors, nextState.color); - }); + const prevActiveButton = Array.from(getColorButtons()).find( + (b) => b.isActive, + ); + prevActiveButton.isActive = false; - state.addListener((nextState, prevState) => { - if (nextState.blockedInteractions === prevState.blockedInteractions) { - return; - } + const nextActiveButton = getColorButtonByColor(nextState.color); - if (nextState.blockedInteractions) { - if (disposeKeyboardListenersCallback) { - disposeKeyboardListenersCallback(); - disposeKeyboardListenersCallback = null; - } - - if (disposeGamepadListenersCallback) { - disposeGamepadListenersCallback(); - disposeGamepadListenersCallback = null; - } - } else { - disposeKeyboardListenersCallback = attachKeyboardListeners(state); - disposeGamepadListenersCallback = attachGamepadListeners(state); + if (!nextActiveButton) { + throw new Error(`Color button not found for color: ${nextState.color}`); } + + nextActiveButton.isActive = true; }); COLOR_LIST.forEach((color) => { - const button = document.createElement("button"); - button.style.backgroundColor = color; - - button.addEventListener("click", () => { - setColor(color, { state }); + const colorButton = new ColorButton({ + color, + onClick: () => setColor(color, { state }), + signal: controller.signal, + isActive: state.get((prevState) => prevState.color) === color, }); - button.dataset.value = color; - button.style.backgroundColor = color; - - colors.appendChild(button); + colors.appendChild(colorButton); }); - const selectedColor = state.get((prevState) => prevState.color); - - if (selectedColor) { - updateActivatedButton(colors, selectedColor); - } - - disposeKeyboardListenersCallback = attachKeyboardListeners(state); - disposeGamepadListenersCallback = attachGamepadListeners(state); + attachKeyboardListeners({ state, signal: controller.signal }); + attachGamepadListeners(state); } diff --git a/js/ui/global.mjs b/js/ui/global.mjs index fe51935..58207bb 100644 --- a/js/ui/global.mjs +++ b/js/ui/global.mjs @@ -3,6 +3,7 @@ import { resetCanvas, exportImage } from "../state/actions/canvas.mjs"; import { showInfo } from "../state/actions/ui.mjs"; export function createGlobalActionsPanel() { + const controller = new AbortController(); const clearButton = getClearButton(); const saveButton = getSaveButton(); const infoButton = getInfoButton(); @@ -27,13 +28,20 @@ export function createGlobalActionsPanel() { showInfo(); } - clearButton.addEventListener("click", handleClearClick); - saveButton.addEventListener("click", handleSaveClick); - infoButton.addEventListener("click", handleInfoClick); + clearButton.addClickListener(handleClearClick, { + once: true, + signal: controller.signal, + }); + saveButton.addClickListener(handleSaveClick, { + once: true, + signal: controller.signal, + }); + infoButton.addClickListener(handleInfoClick, { + once: true, + signal: controller.signal, + }); return function dispose() { - clearButton.removeEventListener("click", handleClearClick); - saveButton.removeEventListener("click", handleSaveClick); - infoButton.removeEventListener("click", handleInfoClick); + // TODO: in case these need to be disposed call `controller.abort()` }; } diff --git a/js/ui/panel.mjs b/js/ui/panel.mjs index 316c0e6..2364c69 100644 --- a/js/ui/panel.mjs +++ b/js/ui/panel.mjs @@ -4,9 +4,12 @@ import { } from "../controls/gamepad.mjs"; import { getPanel } from "../dom.mjs"; import { isCursorWithinPanelBounds } from "./utils.mjs"; +import { UiButton } from "./button.mjs"; function getPanelButtonByCoordinates(x, y, panel) { - const buttons = panel.querySelectorAll("button"); + const buttons = panel.querySelectorAll( + "ui-button,color-button,tool-button,variant-button,variant-stamp-button", + ); for (let i = 0; i < buttons.length; i++) { const button = buttons[i]; @@ -43,6 +46,12 @@ function activatePanelButtonOnCoordinates(x, y) { bubbles: false, }); + if (button instanceof UiButton) { + button.click(clickEvent); + + return; + } + button.dispatchEvent(clickEvent); } @@ -152,36 +161,5 @@ export function attachPanelListeners({ state }) { disposeGamepadListenersCallback = attachGamepadListeners(state); } - function deactivateListeners() { - if (disposeMouseListenersCallback) { - disposeMouseListenersCallback(); - disposeMouseListenersCallback = null; - } - - if (disposeKeyboardListenersCallback) { - disposeKeyboardListenersCallback(); - disposeKeyboardListenersCallback = null; - } - - if (disposeGamepadListenersCallback) { - disposeGamepadListenersCallback(); - disposeGamepadListenersCallback = null; - } - } - - function onBlockedInteractionsChange(nextState, prevState) { - if (nextState.blockedInteractions === prevState.blockedInteractions) { - return; - } - - if (nextState.blockedInteractions) { - deactivateListeners(); - } else { - activateListeners(); - } - } - - state.addListener(onBlockedInteractionsChange); - activateListeners(); } diff --git a/js/ui/tool.mjs b/js/ui/tool.mjs new file mode 100644 index 0000000..3266f7c --- /dev/null +++ b/js/ui/tool.mjs @@ -0,0 +1,56 @@ +import { UiButton } from "./button.mjs"; +import { setTool } from "../state/actions/tool.mjs"; + +/** + * Represents tool/action button. + */ +export class ToolButton extends UiButton { + constructor({ tool, state, isActive }) { + super(); + + this.id = tool.id; + this.#isActive = isActive; + this.#tool = tool; + this.#state = state; + } + + #isActive = false; + id = ""; + #state = null; + #tool = null; + + connectedCallback() { + const button = new UiButton({ + ariaLabel: this.#tool.id.description, + dataset: { + id: this.id.description, + value: this.#tool.id.description, + }, + iconUrl: this.#tool.iconUrl, + onClick: this.#onClick, + }); + + this.shadowRoot.appendChild(button); + button.isActive = this.#isActive; + } + + click(e) { + this.#button.click(e); + } + + set isActive(value) { + this.shadowRoot.querySelector("ui-button").isActive = value; + } + + get isActive() { + return this.shadowRoot.querySelector("ui-button").isActive; + } + + get #button() { + return this.shadowRoot.querySelector("ui-button").button; + } + + #onClick = () => { + setTool(this.#tool, { state: this.#state }); + }; +} diff --git a/js/ui/tools.mjs b/js/ui/tools.mjs index 48903a1..2cb175d 100644 --- a/js/ui/tools.mjs +++ b/js/ui/tools.mjs @@ -1,77 +1,84 @@ -import { getPanelTools } from "../dom.mjs"; -import { setTool } from "../state/actions/tool.mjs"; +import { getPanelTools, getToolButtons, getToolButtonById } from "../dom.mjs"; import { TOOL_LIST } from "../state/constants.mjs"; -import { loadIcon, updateActivatedButton } from "./utils.mjs"; import { buildToolActions } from "./actions.mjs"; import { buildToolVariants } from "./variants.mjs"; +import { ToolButton } from "./tool.mjs"; export function createToolPanel({ state }) { const tools = getPanelTools(); - let disposeVariantsCallback = null; - let disposeActionsCallback = null; + let actionsController = new AbortController(); + let variantsController = new AbortController(); state.addListener((updatedState, prevState) => { if (updatedState.tool === prevState.tool) { return; } - if (disposeVariantsCallback) { - disposeVariantsCallback(); - disposeVariantsCallback = null; + if (!variantsController.signal.aborted) { + variantsController.abort(); } - if (disposeActionsCallback) { - disposeActionsCallback(); - disposeActionsCallback = null; + if (!actionsController.signal.aborted) { + actionsController.abort(); } - updateActivatedButton(tools, updatedState.tool.id.description); + variantsController = new AbortController(); + actionsController = new AbortController(); + + const prevActiveButton = Array.from(getToolButtons()).find( + (b) => b.isActive, + ); + prevActiveButton.isActive = false; + + const toolId = updatedState.tool.id; + const nextActiveButton = getToolButtonById(toolId); + + if (!nextActiveButton) { + throw new Error(`Tool button not found for tool: ${toolId}`); + } + + nextActiveButton.isActive = true; if (updatedState.tool.variants) { - disposeVariantsCallback = buildToolVariants(updatedState.tool, state); + buildToolVariants(updatedState.tool, { + state, + signal: variantsController.signal, + }); } if (updatedState.tool.actions) { - disposeActionsCallback = buildToolActions(updatedState.tool, state); + buildToolActions(updatedState.tool, { + state, + signal: actionsController.signal, + }); } }); - TOOL_LIST.forEach((tool) => { - const button = document.createElement("button"); - - button.addEventListener( - "click", - () => { - setTool(tool, { state }); - }, - true, - ); - - button.dataset.value = tool.id.description; + const selectedTool = state.get((prevState) => prevState.tool); - loadIcon(tool.iconUrl) - .then((icon) => { - button.innerHTML = icon; - }) - .catch((error) => { - console.error(error); - button.innerText = tool.id.description; - }); + TOOL_LIST.forEach((tool) => { + const button = new ToolButton({ + tool, + state, + isActive: selectedTool.id === tool.id, + }); tools.appendChild(button); }); - const selectedTool = state.get((prevState) => prevState.tool); - if (selectedTool) { - updateActivatedButton(tools, selectedTool.id.description); - if (selectedTool.variants) { - disposeVariantsCallback = buildToolVariants(selectedTool, state); + buildToolVariants(selectedTool, { + state, + signal: variantsController.signal, + }); } if (selectedTool.actions) { - disposeActionsCallback = buildToolActions(selectedTool, state); + buildToolActions(selectedTool, { + state, + signal: actionsController.signal, + }); } } } diff --git a/js/ui/utils.mjs b/js/ui/utils.mjs index 89b5704..5ab69ad 100644 --- a/js/ui/utils.mjs +++ b/js/ui/utils.mjs @@ -27,18 +27,6 @@ export function ensureCallbacksRemoved(listeners) { throw new Error("Not all listeners were removed!"); } -export function updateActivatedButton(buttonContainer, value) { - const buttons = buttonContainer.querySelectorAll("button"); - - Array.from(buttons).forEach((button) => { - if (button.dataset.value === value) { - button.classList.add("active"); - } else { - button.classList.remove("active"); - } - }); -} - /** * Is cursor located on UI panel? * diff --git a/js/ui/variant-stamp.mjs b/js/ui/variant-stamp.mjs new file mode 100644 index 0000000..2179d64 --- /dev/null +++ b/js/ui/variant-stamp.mjs @@ -0,0 +1,100 @@ +import { setCustomVariant, setTool } from "../state/actions/tool.mjs"; +import { VariantButton } from "./variant.mjs"; + +import { + createSvgDataUri, + serializeSvg, + deserializeSvgFromDataURI, + normalizeSvgSize, +} from "../svg-utils.mjs"; + +export class VariantStampButton extends VariantButton { + constructor(options) { + super(options); + + const { variant, tool, state } = options; + this.#variant = variant; + this.#tool = tool; + this.#state = state; + } + + #variant = null; + #tool = null; + #state = null; + + connectedCallback() { + super.connectedCallback(); + } + + click = () => { + if (this.#variant.value) { + setTool(this.#tool, { state: this.#state, variant: this.#variant }); + return; + } + + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = "image/svg+xml"; + fileInput.style.display = "none"; + + const handleFileUpload = (event) => { + this.#readUploadedSVG(event, fileInput); + }; + + fileInput.addEventListener("stamp-custom-slot-success", (event) => { + fileInput.removeEventListener("change", handleFileUpload); + fileInput.remove(); + + const updatedVariant = { + ...this.#variant, + iconUrl: event.detail.iconDataUri, + value: event.detail.dataUri, + }; + setTool(this.#tool, { state: this.#state, variant: updatedVariant }); + setCustomVariant(this.#tool, updatedVariant, { state: this.#state }); + this.#variant = updatedVariant; + super.uiButton.setAttribute("icon-url", event.detail.iconDataUri); + }); + fileInput.addEventListener("stamp-custom-slot-failure", () => { + alert("Something went wrong with uploading the image!"); + }); + fileInput.addEventListener("change", handleFileUpload); + + fileInput.click(); + }; + + #readUploadedSVG = (event, fileInput) => { + const file = event.target.files[0]; + const failureEvent = new CustomEvent("stamp-custom-slot-failure"); + + if (!file) { + fileInput.dispatchEvent(failureEvent); + throw new Error("No file selected!"); + } + + const fileReader = new FileReader(); + fileReader.addEventListener("load", (fileEvent) => { + const parsedSvgElement = deserializeSvgFromDataURI( + fileEvent.srcElement.result, + ); + const iconSvgDocument = parsedSvgElement.documentElement.cloneNode(true); + const stampSvgDocument = parsedSvgElement.documentElement.cloneNode(true); + const iconSvgElement = normalizeSvgSize(iconSvgDocument); + const stampSvgElement = normalizeSvgSize(stampSvgDocument, 50); + + fileInput.dispatchEvent( + new CustomEvent("stamp-custom-slot-success", { + detail: { + iconDataUri: createSvgDataUri(serializeSvg(iconSvgElement)), + dataUri: createSvgDataUri(serializeSvg(stampSvgElement)), + }, + }), + ); + }); + fileReader.addEventListener("error", () => { + fileInput.dispatchEvent(failureEvent); + }); + + fileReader.readAsDataURL(file); + }; +} diff --git a/js/ui/variant.mjs b/js/ui/variant.mjs new file mode 100644 index 0000000..734ab8b --- /dev/null +++ b/js/ui/variant.mjs @@ -0,0 +1,59 @@ +import { UiButton } from "./button.mjs"; +import { setTool } from "../state/actions/tool.mjs"; + +export class VariantButton extends UiButton { + constructor({ id, iconUrl, signal, isActive, tool, state, variant }) { + super(); + + this.#isActive = isActive; + this.id = id; + this.#tool = tool; + this.#state = state; + this.#variant = variant; + this.#iconUrl = iconUrl; + this.#signal = signal; + } + + #isActive = false; + id = ""; + #iconUrl = ""; + #signal = null; + #tool = null; + #state = null; + #variant = null; + + connectedCallback() { + const button = new UiButton({ + ariaLabel: `${this.id.description.toLocaleLowerCase()} variant}`, + dataset: { + value: this.id.description, + }, + iconUrl: this.#iconUrl, + onClick: this.click, + signal: this.#signal, + }); + + this.shadowRoot.appendChild(button); + this.isActive = this.#isActive; + } + + click = () => { + setTool(this.#tool, { state: this.#state, variant: this.#variant }); + }; + + get button() { + return this.shadowRoot.querySelector("ui-button").button; + } + + get uiButton() { + return this.shadowRoot.querySelector("ui-button"); + } + + set isActive(value) { + this.shadowRoot.querySelector("ui-button").isActive = value; + } + + get isActive() { + return this.shadowRoot.querySelector("ui-button").isActive; + } +} diff --git a/js/ui/variants.mjs b/js/ui/variants.mjs index 1889418..62f782c 100644 --- a/js/ui/variants.mjs +++ b/js/ui/variants.mjs @@ -1,111 +1,19 @@ -import { setTool, setCustomVariant } from "../state/actions/tool.mjs"; import { TOOLS } from "../state/constants.mjs"; -import { isDataUri } from "../state/utils.mjs"; -import { getPanelToolVariants } from "../dom.mjs"; +import { + getPanelToolVariants, + getVariantButtons, + getVariantButtonById, +} from "../dom.mjs"; import { isLeftTriggerGamepadButtonPressed, isRightTriggerGamepadButtonPressed, getGamepad, } from "../controls/gamepad.mjs"; -import { - loadIcon, - disposeCallback, - ensureCallbacksRemoved, - updateActivatedButton, -} from "./utils.mjs"; -import { - createSvgDataUri, - serializeSvg, - deserializeSvgFromDataURI, - normalizeSvgSize, -} from "../svg-utils.mjs"; +import { VariantButton } from "./variant.mjs"; +import { VariantStampButton } from "./variant-stamp.mjs"; const GAMEPAD_BUTTON_ACTIVATION_DELAY_IN_MS = 300; -function readUploadedSVG(event, fileInput) { - const file = event.target.files[0]; - const failureEvent = new CustomEvent("stamp-custom-slot-failure"); - - if (!file) { - fileInput.dispatchEvent(failureEvent); - throw new Error("No file selected!"); - } - - const fileReader = new FileReader(); - fileReader.addEventListener("load", (fileEvent) => { - const parsedSvgElement = deserializeSvgFromDataURI( - fileEvent.srcElement.result, - ); - const iconSvgDocument = parsedSvgElement.documentElement.cloneNode(true); - const stampSvgDocument = parsedSvgElement.documentElement.cloneNode(true); - const iconSvgElement = normalizeSvgSize(iconSvgDocument); - const stampSvgElement = normalizeSvgSize(stampSvgDocument, 50); - - fileInput.dispatchEvent( - new CustomEvent("stamp-custom-slot-success", { - detail: { - iconDataUri: createSvgDataUri(serializeSvg(iconSvgElement)), - dataUri: createSvgDataUri(serializeSvg(stampSvgElement)), - }, - }), - ); - }); - fileReader.addEventListener("error", () => { - fileInput.dispatchEvent(failureEvent); - }); - - fileReader.readAsDataURL(file); -} - -function createFileInputForUpload() { - const fileInput = document.createElement("input"); - fileInput.type = "file"; - fileInput.accept = "image/svg+xml"; - fileInput.style.display = "none"; - - return fileInput; -} - -function customStampOnClick({ tool, variant, state }) { - const fileInput = createFileInputForUpload(); - - function handleFileUpload(event) { - readUploadedSVG(event, fileInput); - } - - fileInput.addEventListener("stamp-custom-slot-success", async (event) => { - fileInput.removeEventListener("change", handleFileUpload); - fileInput.remove(); - - const updatedVariant = { - ...variant, - iconUrl: event.detail.iconDataUri, - value: event.detail.dataUri, - }; - updateActivatedButton( - getPanelToolVariants(), - updatedVariant.id.description, - ); - setTool(tool, { state, variant: updatedVariant }); - setCustomVariant(tool, updatedVariant, { state }); - }); - fileInput.addEventListener("stamp-custom-slot-failure", () => { - alert("Something went wrong with uploading the image!"); - }); - fileInput.addEventListener("change", handleFileUpload); - - fileInput.click(); -} - -function defaultOnClick({ tool, variant, state }) { - setTool(tool, { state, variant }); - updateActivatedButton(getPanelToolVariants(), variant.id.description); -} - -function getVariantButtons(container) { - return Array.from(container.querySelectorAll("button")); -} - // do not bubble event to avoid clicks on canvas function dispatchButtonClick(element) { element.dispatchEvent( @@ -117,26 +25,33 @@ function dispatchButtonClick(element) { } function getNextButton(buttons, index) { - return index + 1 < buttons.length ? buttons[index + 1] : buttons[0]; + const variantButtons = Array.from(buttons); + return index + 1 < buttons.length + ? variantButtons[index + 1] + : variantButtons[0]; } function getPreviousButton(buttons, index) { - return index - 1 >= 0 ? buttons[index - 1] : buttons[buttons.length - 1]; + const variantButtons = Array.from(buttons); + return index - 1 >= 0 + ? variantButtons[index - 1] + : variantButtons[buttons.length - 1]; } -function getButtonIndex(buttons, { state, tool }) { +function getButtonIndex(variantButtons, { state, tool }) { const activatedVariant = state.get((prevState) => prevState.activatedVariants.get(tool.id), ); - const index = buttons.findIndex( - (button) => button.dataset.value === activatedVariant.id.description, + const index = Array.from(variantButtons).findIndex( + (variantButton) => + variantButton.button.dataset.value === activatedVariant.id.description, ); return index; } -function attachGamepadListeners(container, tool, { state }) { +function attachGamepadListeners(tool, { state }) { let frame = null; let buttonPressedTimestamp = null; @@ -150,7 +65,7 @@ function attachGamepadListeners(container, tool, { state }) { const buttonPressedDelta = timestamp - buttonPressedTimestamp; - const index = getButtonIndex(getVariantButtons(container), { + const index = getButtonIndex(getVariantButtons(), { state, tool, }); @@ -163,14 +78,14 @@ function attachGamepadListeners(container, tool, { state }) { isLeftTriggerGamepadButtonPressed(gamepad) && buttonPressedDelta > GAMEPAD_BUTTON_ACTIVATION_DELAY_IN_MS ) { - const prevButton = getPreviousButton(getVariantButtons(container), index); + const prevButton = getPreviousButton(getVariantButtons(), index); dispatchButtonClick(prevButton); buttonPressedTimestamp = timestamp; } else if ( isRightTriggerGamepadButtonPressed(gamepad) && buttonPressedDelta > GAMEPAD_BUTTON_ACTIVATION_DELAY_IN_MS ) { - const nextButton = getNextButton(getVariantButtons(container), index); + const nextButton = getNextButton(getVariantButtons(), index); dispatchButtonClick(nextButton); buttonPressedTimestamp = timestamp; } @@ -187,20 +102,11 @@ function attachGamepadListeners(container, tool, { state }) { frame = requestAnimationFrame(activateVariantCycleOnShoulderButtonPresses); } - function cancelGamepadAnimationFrame() { - cancelAnimationFrame(frame); - frame = null; - } - requestGamepadAnimationFrame(); - - return function dispose() { - cancelGamepadAnimationFrame(); - }; } -function attachKeyboardListeners(container, tool, { state }) { - const buttons = getVariantButtons(container); +function attachKeyboardListeners(tool, { state, signal }) { + const buttons = getVariantButtons(); function onKeyDown(event) { const index = getButtonIndex(buttons, { state, tool }); @@ -212,12 +118,12 @@ function attachKeyboardListeners(container, tool, { state }) { switch (event.key) { case ".": { const nextButton = getNextButton(buttons, index); - dispatchButtonClick(nextButton); + dispatchButtonClick(nextButton.button); break; } case ",": { const prevButton = getPreviousButton(buttons, index); - dispatchButtonClick(prevButton); + dispatchButtonClick(prevButton.button); break; } default: @@ -225,128 +131,83 @@ function attachKeyboardListeners(container, tool, { state }) { } } - window.addEventListener("keydown", onKeyDown); - - return function dispose() { - window.removeEventListener("keydown", onKeyDown); - }; + window.addEventListener("keydown", onKeyDown, { + signal, + }); } -function renderToolVariants(tool, state) { +export function buildToolVariants(tool, { state, signal }) { const variantsContainer = getPanelToolVariants(); - const listeners = {}; - - const activatedVariant = state.get((prevState) => - prevState.activatedVariants.get(tool.id), - ); - - const customVariants = state.get( - (prevState) => prevState.customVariants.get(tool.id) ?? new Set(), - ); - - const allVariants = [...tool.variants, ...customVariants]; - - allVariants.forEach((variant) => { - const button = document.createElement("button"); - - function onClick() { - switch (tool.id) { - case TOOLS.STAMP.id: - { - if (!variant.value) { - customStampOnClick({ tool, variant, state }); - } else { - defaultOnClick({ tool, variant, state }); - } - } - break; - default: - defaultOnClick({ tool, variant, state }); - break; - } + state.addListener((updatedState, prevState) => { + if ( + prevState.activatedVariants.get(tool.id) === + updatedState.activatedVariants.get(tool.id) + ) { + return; } - button.addEventListener("click", onClick); - - button.dataset.value = variant.id.description; + const variantButtons = Array.from(getVariantButtons()); + const prevActiveVariantButton = variantButtons.find((b) => b.isActive); - if (isDataUri(variant.iconUrl)) { - button.innerHTML = serializeSvg( - deserializeSvgFromDataURI(variant.iconUrl), - ); - } else { - loadIcon(variant.iconUrl) - .then((icon) => { - button.innerHTML = icon; - }) - .catch((error) => { - console.error(error); - button.innerText = variant.id.description; - }); + if (prevActiveVariantButton) { + prevActiveVariantButton.isActive = false; } - variantsContainer.appendChild(button); + const nextActiveVariantButton = + getVariantButtonById(updatedState.activatedVariants.get(tool.id)?.id) || + variantButtons?.[0]; - if (variant.id === activatedVariant.id) { - updateActivatedButton(variantsContainer, variant.id.description); + if (!nextActiveVariantButton) { + throw new Error( + `Variant button not found for variant: ${updatedState.tool.activatedVariant.id}`, + ); } - listeners[button.dataset.value] = onClick; + nextActiveVariantButton.isActive = true; }); - const keyboardListenersDisposeCallback = attachKeyboardListeners( - variantsContainer, - tool, - { state }, - ); - const gamepadListenersDisposeCallback = attachGamepadListeners( - variantsContainer, - tool, - { state }, + const customVariants = state.get( + (prevState) => prevState.customVariants.get(tool.id) ?? new Set(), ); - return function dispose() { - keyboardListenersDisposeCallback(); - gamepadListenersDisposeCallback(); - - getVariantButtons(variantsContainer).forEach((button) => { - disposeCallback(button, listeners); - button.remove(); - }); - - ensureCallbacksRemoved(listeners); - - variantsContainer.innerHTML = ""; - }; -} + const allVariants = [...tool.variants, ...customVariants]; -export function buildToolVariants(tool, state) { - let disposeVariantsCallback = null; + for (const variant of allVariants) { + const activatedVariant = state.get((prevState) => + prevState.activatedVariants.get(tool.id), + ); - function onToolChange(nextState, prevState) { - if (nextState.customVariants === prevState.customVariants) { - return; - } + const options = { + ...variant, + isActive: activatedVariant?.id.description === variant.id.description, + signal, + tool, + state, + variant, + }; - if (disposeVariantsCallback) { - disposeVariantsCallback(); - disposeVariantsCallback = null; + switch (tool.id) { + case TOOLS.STAMP.id: + variantsContainer.appendChild(new VariantStampButton(options)); + break; + default: + variantsContainer.appendChild(new VariantButton(options)); + break; } - - disposeVariantsCallback = renderToolVariants(tool, state); } - disposeVariantsCallback = renderToolVariants(tool, state); + attachKeyboardListeners(tool, { state, signal }); - state.addListener(onToolChange); + attachGamepadListeners(tool, { state }); - return function dispose() { - state.removeListener(onToolChange); + function dispose() { + Array.from(getVariantButtons()).forEach((variantButton) => { + variantButton.remove(); + }); - if (disposeVariantsCallback) { - disposeVariantsCallback(); - disposeVariantsCallback = null; - } - }; + variantsContainer.innerHTML = ""; + } + + signal.addEventListener("abort", dispose, { once: true }); } diff --git a/service-worker.js b/service-worker.js index 1e98fda..059e081 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,5 +1,5 @@ const STATIC_CACHE_NAME = "static"; -const STATIC_CACHE_VERSION = "v42"; +const STATIC_CACHE_VERSION = "v44"; const STATIC_CACHE_ID = `${STATIC_CACHE_NAME}-${STATIC_CACHE_VERSION}`; // All the files need to be added here manually. I want to avoid @@ -39,12 +39,17 @@ const STATIC_ASSETS = [ "js/tools/pen.mjs", "js/tools/stamp.mjs", "js/ui/actions.mjs", + "js/ui/button.mjs", "js/ui/colors.mjs", + "js/ui/color.mjs", "js/ui/global.mjs", "js/ui/panel.mjs", "js/ui/tools.mjs", + "js/ui/tool.mjs", "js/ui/toast.mjs", "js/ui/utils.mjs", + "js/ui/variant.mjs", + "js/ui/variant-stamp.mjs", "js/ui/variants.mjs", /* CSS */