diff --git a/core/target.js b/core/target.js index aee5a84ad..f66854ffe 100644 --- a/core/target.js +++ b/core/target.js @@ -112,13 +112,39 @@ exports.Target = class Target extends Montage { * https://developer.mozilla.org/en-US/docs/Web/API/Event/composedPath * @property {Array} */ - composedPath: {value: undefined} + composedPath: { value: undefined }, + /** + * Map storing the last timestamp when each interaction type was processed + * @type {Map} + * @private + */ + __debouncedInteractionTimestamps: { value: null }, + + /** + * Map of threshold durations (in milliseconds) for each interaction type + * @type {Map} + * @private + */ + __debouncedInteractionThresholds: { value: null }, }); + } + get _debouncedInteractionTimestamps() { + if (!this.__debouncedInteractionTimestamps) { + this.__debouncedInteractionTimestamps = new Map(); + } + + return this.__debouncedInteractionTimestamps; } + get _debouncedInteractionThresholds() { + if (!this.__debouncedInteractionThresholds) { + this.__debouncedInteractionThresholds = new Map(); + } + return this.__debouncedInteractionThresholds; + } /** * Whether or not this is the activeTarget @@ -133,7 +159,31 @@ exports.Target = class Target extends Montage { return this === defaultEventManager.activeTarget; } + registerInteractionForDebounce(interactionKey, threshold) { + this._debouncedInteractionThresholds.set(interactionKey, threshold); + } + + unregisterInteractionForDebounce(interactionKey) { + this._debouncedInteractionThresholds.delete(interactionKey); + this._debouncedInteractionTimestamps.delete(interactionKey); + } + + shouldPreventInteraction(interactionKey) { + if (!this._debouncedInteractionThresholds.has(interactionKey)) return false; + const currentTime = Date.now(); + const lastTime = this._debouncedInteractionTimestamps.get(interactionKey) || 0; + const threshold = this._debouncedInteractionThresholds.get(interactionKey) || 0; + const delta = currentTime - lastTime; + + // Check if we need to debounce + if (lastTime > 0 && delta < threshold) return true; + + // Update timestamp for this interaction type + this._debouncedInteractionTimestamps.set(interactionKey, currentTime); + + return false; + } /** * Ask this target to surrender its activeTarget status. diff --git a/ui/button.mod/button.js b/ui/button.mod/button.js index 7efd1fd16..f7fd2ed40 100644 --- a/ui/button.mod/button.js +++ b/ui/button.mod/button.js @@ -54,6 +54,50 @@ const Button = (exports.Button = class Button extends Control { static VisualPosition = VisualPosition; + /** + * How long to wait (in milliseconds) between allowing press events + * @type {number} + */ + _debounceThreshold = 300; + + /** + * Whether debouncing is enabled for press events + * @type {boolean} + */ + _debounceEnabled = false; + + get debounceEnabled() { + return this._debounceEnabled; + } + + set debounceEnabled(enabled) { + enabled = !!enabled; + + if (enabled !== this._debounceEnabled) { + this._debounceEnabled = enabled; + this._updateActionDebounceRegistration(); + } + } + + get debounceThreshold() { + return this._debounceThreshold; + } + + set debounceThreshold(threshold) { + if (typeof threshold === "number" && threshold >= 0 && this._debounceThreshold !== threshold) { + this._debounceThreshold = threshold; + this._updateActionDebounceRegistration(); + } + } + + _updateActionDebounceRegistration() { + if (this.debounceEnabled ) { + this.registerInteractionForDebounce("action", this.debounceThreshold); + } else { + this.unregisterInteractionForDebounce("action"); + } + } + // <---- Properties ----> _visualPosition = VisualPosition.start; @@ -275,7 +319,10 @@ const Button = (exports.Button = class Button extends Control { if (identifier === "space" || identifier === "enter") { this.active = false; - this.dispatchActionEvent(); + + if (!this.shouldPreventInteraction("action")) { + this.dispatchActionEvent(); + } } } @@ -300,7 +347,11 @@ const Button = (exports.Button = class Button extends Control { handlePress(mutableEvent) { if (!this._promise) { this.active = false; - this.dispatchActionEvent(event.details); + + if (!this.shouldPreventInteraction("action")) { + this.dispatchActionEvent(event.details); + } + this._removeEventListeners(); } } diff --git a/ui/button.mod/teach/ui/main.mod/main.html b/ui/button.mod/teach/ui/main.mod/main.html index 5307291b5..5ffa72cc1 100644 --- a/ui/button.mod/teach/ui/main.mod/main.html +++ b/ui/button.mod/teach/ui/main.mod/main.html @@ -62,6 +62,22 @@ "element": {"#": "text"}, "value": {"<-": "@owner.message || 'please hit a button'"} } + }, + "debounceButton": { + "prototype": "mod/ui/button.mod", + "values": { + "element": {"#": "debounceButton"}, + "debounceEnabled": true, + "label": "Debounced Button", + "debounceThreshold": 1000 + } + }, + "debounceText": { + "prototype": "mod/ui/text.mod", + "values": { + "element": {"#": "debounceText"}, + "value": {"<-": "@owner.debounceMessage"} + } } } @@ -84,6 +100,8 @@
+ + diff --git a/ui/button.mod/teach/ui/main.mod/main.js b/ui/button.mod/teach/ui/main.mod/main.js index 4648a540d..cdf9bbd3c 100644 --- a/ui/button.mod/teach/ui/main.mod/main.js +++ b/ui/button.mod/teach/ui/main.mod/main.js @@ -1,6 +1,8 @@ const { Component } = require("mod/ui/component"); exports.Main = class Main extends Component { + debounceCounter = 0; + message = null; handleAction(event) { @@ -11,6 +13,12 @@ exports.Main = class Main extends Component { this.message = `${event.target.identifier} button has been clicked (long action)`; } + handleDebounceButtonAction(event) { + this.debounceCounter++; + const id = event.target.identifier; + this.debounceMessage = `${id} button has been clicked (debounce) ${this.debounceCounter} times`; + } + async handlePromiseButtonAction(_) { this.message = "First Promise is pending resolution. Wait 2 seconds...";