diff --git a/src/panels/lovelace/cards/hui-entity-button-card.ts b/src/panels/lovelace/cards/hui-entity-button-card.ts index 633a048679..0aa86b7762 100644 --- a/src/panels/lovelace/cards/hui-entity-button-card.ts +++ b/src/panels/lovelace/cards/hui-entity-button-card.ts @@ -13,6 +13,7 @@ import { styleMap } from "lit-html/directives/styleMap.js"; import { HomeAssistant } from "../../../types.js"; import { hassLocalizeLitMixin } from "../../../mixins/lit-localize-mixin"; import { LovelaceCard, LovelaceConfig } from "../types.js"; +import { longPress } from "../common/directives/long-press-directive"; interface Config extends LovelaceConfig { entity: string; @@ -20,6 +21,7 @@ interface Config extends LovelaceConfig { icon?: string; theme?: string; tap_action?: "toggle" | "call-service" | "more-info"; + hold_action?: "toggle" | "call-service" | "more-info"; service?: string; service_data?: object; } @@ -62,7 +64,11 @@ class HuiEntityButtonCard extends hassLocalizeLitMixin(LitElement) return html` ${this.renderStyle()} - + ${ !stateObj ? html`
Entity not available: ${ @@ -157,7 +163,7 @@ class HuiEntityButtonCard extends hassLocalizeLitMixin(LitElement) return `hsl(${hue}, 100%, ${100 - sat / 2}%)`; } - private handleClick() { + private handleClick(hold) { const config = this.config; if (!config) { return; @@ -167,11 +173,12 @@ class HuiEntityButtonCard extends hassLocalizeLitMixin(LitElement) return; } const entityId = stateObj.entity_id; - switch (config.tap_action) { + const action = hold ? config.hold_action : config.tap_action || "more-info"; + switch (action) { case "toggle": toggleEntity(this.hass, entityId); break; - case "call-service": { + case "call-service": if (!config.service) { return; } @@ -179,9 +186,10 @@ class HuiEntityButtonCard extends hassLocalizeLitMixin(LitElement) const serviceData = { entity_id: entityId, ...config.service_data }; this.hass!.callService(domain, service, serviceData); break; - } - default: + case "more-info": fireEvent(this, "hass-more-info", { entityId }); + break; + default: } } } diff --git a/src/panels/lovelace/cards/hui-glance-card.ts b/src/panels/lovelace/cards/hui-glance-card.ts index ca43c901f2..a2eb7b6703 100644 --- a/src/panels/lovelace/cards/hui-glance-card.ts +++ b/src/panels/lovelace/cards/hui-glance-card.ts @@ -16,12 +16,14 @@ import { fireEvent } from "../../../common/dom/fire_event.js"; import { hassLocalizeLitMixin } from "../../../mixins/lit-localize-mixin"; import { HomeAssistant } from "../../../types.js"; import { LovelaceCard, LovelaceConfig } from "../types.js"; +import { longPress } from "../common/directives/long-press-directive"; interface EntityConfig { name: string; icon: string; entity: string; tap_action: "toggle" | "call-service" | "more-info"; + hold_action?: "toggle" | "call-service" | "more-info"; service?: string; service_data?: object; } @@ -59,9 +61,13 @@ export class HuiGlanceCard extends hassLocalizeLitMixin(LitElement) const entities = processConfigEntities(config.entities); for (const entity of entities) { - if (entity.tap_action === "call-service" && !entity.service) { + if ( + (entity.tap_action === "call-service" || + entity.hold_action === "call-service") && + !entity.service + ) { throw new Error( - 'Missing required property "service" when tap_action is call-service' + 'Missing required property "service" when tap_action or hold_action is call-service' ); } } @@ -151,7 +157,9 @@ export class HuiGlanceCard extends hassLocalizeLitMixin(LitElement)
${ this.config!.show_name !== false @@ -175,21 +183,23 @@ export class HuiGlanceCard extends hassLocalizeLitMixin(LitElement) `; } - private handleClick(ev: MouseEvent) { + private handleClick(ev: MouseEvent, hold) { const config = (ev.currentTarget as any).entityConf as EntityConfig; const entityId = config.entity; - switch (config.tap_action) { + const action = hold ? config.hold_action : config.tap_action || "more-info"; + switch (action) { case "toggle": toggleEntity(this.hass, entityId); break; - case "call-service": { + case "call-service": const [domain, service] = config.service!.split(".", 2); const serviceData = { entity_id: entityId, ...config.service_data }; this.hass!.callService(domain, service, serviceData); break; - } - default: + case "more-info": fireEvent(this, "hass-more-info", { entityId }); + break; + default: } } } diff --git a/src/panels/lovelace/cards/hui-picture-entity-card.js b/src/panels/lovelace/cards/hui-picture-entity-card.js index a1b565dfa1..cc1a432b96 100644 --- a/src/panels/lovelace/cards/hui-picture-entity-card.js +++ b/src/panels/lovelace/cards/hui-picture-entity-card.js @@ -11,6 +11,7 @@ import toggleEntity from "../common/entity/toggle-entity.js"; import EventsMixin from "../../../mixins/events-mixin.js"; import LocalizeMixin from "../../../mixins/localize-mixin.js"; +import { longPressBind } from "../common/directives/long-press-directive"; const UNAVAILABLE = "Unavailable"; @@ -51,7 +52,7 @@ class HuiPictureEntityCard extends EventsMixin(LocalizeMixin(PolymerElement)) { } - + this._cardClicked(false)); + card.addEventListener("ha-hold", () => this._cardClicked(true)); + } + _hassChanged(hass) { const config = this._config; const entityId = config.entity; @@ -163,16 +172,22 @@ class HuiPictureEntityCard extends EventsMixin(LocalizeMixin(PolymerElement)) { return config.show_name === false && config.show_state !== false; } - _cardClicked() { + _cardClicked(hold) { const config = this._config; const entityId = config.entity; if (!(entityId in this.hass.states)) return; - if (config.tap_action === "toggle") { - toggleEntity(this.hass, entityId); - } else { - this.fire("hass-more-info", { entityId }); + const action = hold ? config.hold_action : config.tap_action || "more-info"; + + switch (action) { + case "toggle": + toggleEntity(this.hass, entityId); + break; + case "more-info": + this.fire("hass-more-info", { entityId }); + break; + default: } } diff --git a/src/panels/lovelace/common/directives/long-press-directive.ts b/src/panels/lovelace/common/directives/long-press-directive.ts new file mode 100644 index 0000000000..70b0b98d64 --- /dev/null +++ b/src/panels/lovelace/common/directives/long-press-directive.ts @@ -0,0 +1,140 @@ +import { directive, PropertyPart } from "lit-html"; +import "@material/mwc-ripple"; + +const isTouch = + "ontouchstart" in window || + navigator.maxTouchPoints > 0 || + navigator.msMaxTouchPoints > 0; + +interface LongPress extends HTMLElement { + holdTime: number; + bind(element: Element): void; +} +interface LongPressElement extends Element { + longPress?: boolean; +} + +class LongPress extends HTMLElement implements LongPress { + public holdTime: number; + protected ripple: any; + protected timer: number | undefined; + protected held: boolean; + + constructor() { + super(); + this.holdTime = 500; + this.ripple = document.createElement("mwc-ripple"); + this.timer = undefined; + this.held = false; + } + + public connectedCallback() { + Object.assign(this.style, { + position: "absolute", + width: isTouch ? "100px" : "50px", + height: isTouch ? "100px" : "50px", + transform: "translate(-50%, -50%)", + pointerEvents: "none", + }); + + this.appendChild(this.ripple); + this.ripple.primary = true; + + [ + isTouch ? "touchcancel" : "mouseout", + "mouseup", + "touchmove", + "mousewheel", + "wheel", + "scroll", + ].forEach((ev) => { + document.addEventListener( + ev, + () => { + clearTimeout(this.timer); + this.stopAnimation(); + }, + { passive: true } + ); + }); + } + + public bind(element: LongPressElement) { + if (element.longPress) { + return; + } + element.longPress = true; + element.addEventListener( + isTouch ? "touchstart" : "mousedown", + (ev: Event) => { + this.held = false; + let x; + let y; + if ((ev as TouchEvent).touches) { + x = (ev as TouchEvent).touches[0].pageX; + y = (ev as TouchEvent).touches[0].pageY; + } else { + x = (ev as MouseEvent).pageX; + y = (ev as MouseEvent).pageY; + } + this.timer = window.setTimeout(() => { + this.startAnimation(x, y); + this.held = true; + }, this.holdTime); + }, + { passive: true } + ); + element.addEventListener("click", () => { + this.stopAnimation(); + if (this.held) { + element.dispatchEvent(new Event("ha-hold")); + } else { + element.dispatchEvent(new Event("ha-click")); + } + }); + } + + private startAnimation(x: number, y: number) { + Object.assign(this.style, { + left: `${x}px`, + top: `${y}px`, + display: null, + }); + this.ripple.disabled = false; + this.ripple.active = true; + this.ripple.unbounded = true; + } + + private stopAnimation() { + this.ripple.active = false; + this.ripple.disabled = true; + this.style.display = "none"; + } +} + +customElements.define("long-press", LongPress); + +const getLongPress = (): LongPress => { + const body = document.body; + if (body.querySelector("long-press")) { + return body.querySelector("long-press") as LongPress; + } + + const longpress = document.createElement("long-press"); + body.appendChild(longpress); + + return longpress as LongPress; +}; + +export const longPressBind = (element: LongPressElement) => { + const longpress: LongPress = getLongPress(); + if (!longpress) { + return; + } + longpress.bind(element); +}; + +export const longPress = () => + directive((part: PropertyPart) => { + longPressBind(part.committer.element); + }); diff --git a/src/panels/lovelace/elements/hui-icon-element.js b/src/panels/lovelace/elements/hui-icon-element.js index 2606bcb184..5eb2bb2631 100644 --- a/src/panels/lovelace/elements/hui-icon-element.js +++ b/src/panels/lovelace/elements/hui-icon-element.js @@ -4,6 +4,7 @@ import { PolymerElement } from "@polymer/polymer/polymer-element.js"; import "../../../components/ha-icon.js"; import ElementClickMixin from "../mixins/element-click-mixin.js"; +import { longPressBind } from "../common/directives/long-press-directive"; /* * @appliesMixin ElementClickMixin @@ -32,7 +33,13 @@ class HuiIconElement extends ElementClickMixin(PolymerElement) { ready() { super.ready(); - this.registerMouse(this._config); + longPressBind(this); + this.addEventListener("ha-click", () => + this.handleClick(this.hass, this._config, false) + ); + this.addEventListener("ha-hold", () => + this.handleClick(this.hass, this._config, true) + ); } setConfig(config) { diff --git a/src/panels/lovelace/elements/hui-image-element.js b/src/panels/lovelace/elements/hui-image-element.js index 450512de56..67b67ba60f 100644 --- a/src/panels/lovelace/elements/hui-image-element.js +++ b/src/panels/lovelace/elements/hui-image-element.js @@ -4,6 +4,7 @@ import { PolymerElement } from "@polymer/polymer/polymer-element.js"; import "../components/hui-image.js"; import ElementClickMixin from "../mixins/element-click-mixin.js"; +import { longPressBind } from "../common/directives/long-press-directive"; /* * @appliesMixin ElementClickMixin @@ -44,7 +45,13 @@ class HuiImageElement extends ElementClickMixin(PolymerElement) { ready() { super.ready(); - this.registerMouse(this._config); + longPressBind(this); + this.addEventListener("ha-click", () => + this.handleClick(this.hass, this._config, false) + ); + this.addEventListener("ha-hold", () => + this.handleClick(this.hass, this._config, true) + ); } setConfig(config) { diff --git a/src/panels/lovelace/elements/hui-state-icon-element.js b/src/panels/lovelace/elements/hui-state-icon-element.js index 398d98f868..1ad1a3b4a7 100644 --- a/src/panels/lovelace/elements/hui-state-icon-element.js +++ b/src/panels/lovelace/elements/hui-state-icon-element.js @@ -4,6 +4,7 @@ import { PolymerElement } from "@polymer/polymer/polymer-element.js"; import "../../../components/entity/state-badge.js"; import ElementClickMixin from "../mixins/element-click-mixin.js"; +import { longPressBind } from "../common/directives/long-press-directive"; /* * @appliesMixin ElementClickMixin @@ -36,7 +37,13 @@ class HuiStateIconElement extends ElementClickMixin(PolymerElement) { ready() { super.ready(); - this.registerMouse(this._config); + longPressBind(this); + this.addEventListener("ha-click", () => + this.handleClick(this.hass, this._config, false) + ); + this.addEventListener("ha-hold", () => + this.handleClick(this.hass, this._config, true) + ); } setConfig(config) { diff --git a/src/panels/lovelace/elements/hui-state-label-element.js b/src/panels/lovelace/elements/hui-state-label-element.js index f66fa128c1..e22ab9ac9c 100644 --- a/src/panels/lovelace/elements/hui-state-label-element.js +++ b/src/panels/lovelace/elements/hui-state-label-element.js @@ -7,6 +7,7 @@ import "../../../components/entity/ha-state-label-badge.js"; import LocalizeMixin from "../../../mixins/localize-mixin.js"; import ElementClickMixin from "../mixins/element-click-mixin.js"; +import { longPressBind } from "../common/directives/long-press-directive"; /* * @appliesMixin ElementClickMixin @@ -45,7 +46,13 @@ class HuiStateLabelElement extends LocalizeMixin( ready() { super.ready(); - this.registerMouse(this._config); + longPressBind(this); + this.addEventListener("ha-click", () => + this.handleClick(this.hass, this._config, false) + ); + this.addEventListener("ha-hold", () => + this.handleClick(this.hass, this._config, true) + ); } setConfig(config) { diff --git a/src/panels/lovelace/mixins/element-click-mixin.js b/src/panels/lovelace/mixins/element-click-mixin.js index 3a2b16ad88..02a7cdb9fd 100644 --- a/src/panels/lovelace/mixins/element-click-mixin.js +++ b/src/panels/lovelace/mixins/element-click-mixin.js @@ -3,7 +3,6 @@ import toggleEntity from "../common/entity/toggle-entity.js"; import NavigateMixin from "../../../mixins/navigate-mixin"; import EventsMixin from "../../../mixins/events-mixin.js"; import computeStateName from "../../../common/entity/compute_state_name"; -import "@material/mwc-ripple"; /* * @polymerMixin @@ -13,92 +12,14 @@ import "@material/mwc-ripple"; export default dedupingMixin( (superClass) => class extends NavigateMixin(EventsMixin(superClass)) { - registerMouse(config) { - var isTouch = - "ontouchstart" in window || - navigator.MaxTouchPoints > 0 || - navigator.msMaxTouchPoints > 0; - - let ripple = null; - const rippleWrapper = document.createElement("div"); - this.parentElement.appendChild(rippleWrapper); - Object.assign(rippleWrapper.style, { - position: this.style.position || "absolute", - width: isTouch ? "100px" : "50px", - height: isTouch ? "100px" : "50px", - top: this.style.top, - left: this.style.left, - bottom: this.style.bottom, - right: this.style.right, - transform: "translate(-50%, -50%)", - pointerEvents: "none", - }); - - const loadRipple = () => { - if (ripple) return; - ripple = document.createElement("mwc-ripple"); - rippleWrapper.appendChild(ripple); - ripple.unbounded = true; - ripple.primary = true; - }; - const startAnimation = () => { - ripple.style.visibility = "visible"; - ripple.disabled = false; - ripple.active = true; - }; - const stopAnimation = () => { - if (ripple) { - ripple.active = false; - ripple.disabled = true; - ripple.style.visibility = "hidden"; - } - }; - - var mouseDown = isTouch ? "touchstart" : "mousedown"; - var mouseOut = isTouch ? "touchcancel" : "mouseout"; - var click = isTouch ? "touchend" : "click"; - - var timer = null; - var held = false; - var holdTime = config.hold_time || 500; - - this.addEventListener(mouseDown, () => { - held = false; - loadRipple(); - timer = setTimeout(() => { - startAnimation(); - held = true; - }, holdTime); - }); - - this.addEventListener(click, () => { - stopAnimation(); - this.handleClick(this.hass, config, held); - }); - - [ - mouseOut, - "mouseup", - "touchmove", - "mousewheel", - "wheel", - "scroll", - ].forEach((ev) => { - document.addEventListener(ev, () => { - clearTimeout(timer); - stopAnimation(); - }); - }); - } - - handleClick(hass, config, held = false) { - let tapAction = config.tap_action || "more-info"; - if (held) { - tapAction = config.hold_action || "none"; + handleClick(hass, config, hold) { + let action = config.tap_action || "more-info"; + if (hold) { + action = config.hold_action; } - if (tapAction === "none") return; + if (action === "none") return; - switch (tapAction) { + switch (action) { case "more-info": this.fire("hass-more-info", { entityId: config.entity }); break;