580 lines
16 KiB
TypeScript
580 lines
16 KiB
TypeScript
import Fuse, { IFuseOptions } from "fuse.js";
|
|
import {
|
|
CSSResultGroup,
|
|
LitElement,
|
|
PropertyValues,
|
|
TemplateResult,
|
|
css,
|
|
html,
|
|
nothing,
|
|
} from "lit";
|
|
import { customElement, property, state } from "lit/decorators";
|
|
import { classMap } from "lit/directives/class-map";
|
|
import { styleMap } from "lit/directives/style-map";
|
|
import { until } from "lit/directives/until";
|
|
import memoizeOne from "memoize-one";
|
|
import { storage } from "../../../../common/decorators/storage";
|
|
import { fireEvent } from "../../../../common/dom/fire_event";
|
|
import { stringCompare } from "../../../../common/string/compare";
|
|
import "../../../../components/ha-circular-progress";
|
|
import "../../../../components/search-input";
|
|
import { isUnavailableState } from "../../../../data/entity";
|
|
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
|
|
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
|
|
import {
|
|
CUSTOM_TYPE_PREFIX,
|
|
CustomCardEntry,
|
|
customCards,
|
|
getCustomCardEntry,
|
|
} from "../../../../data/lovelace_custom_cards";
|
|
import type { HomeAssistant } from "../../../../types";
|
|
import {
|
|
calcUnusedEntities,
|
|
computeUsedEntities,
|
|
} from "../../common/compute-unused-entities";
|
|
import { tryCreateCardElement } from "../../create-element/create-card-element";
|
|
import type { LovelaceCard } from "../../types";
|
|
import { getCardStubConfig } from "../get-card-stub-config";
|
|
import { coreCards } from "../lovelace-cards";
|
|
import type { Card, CardPickTarget } from "../types";
|
|
|
|
interface CardElement {
|
|
card: Card;
|
|
element: TemplateResult;
|
|
}
|
|
|
|
@customElement("hui-card-picker")
|
|
export class HuiCardPicker extends LitElement {
|
|
@property({ attribute: false }) public hass?: HomeAssistant;
|
|
|
|
@property({ attribute: false }) public suggestedCards?: string[];
|
|
|
|
@storage({
|
|
key: "lovelaceClipboard",
|
|
state: true,
|
|
subscribe: true,
|
|
storage: "sessionStorage",
|
|
})
|
|
private _clipboard?: LovelaceCardConfig;
|
|
|
|
@state() private _cards: CardElement[] = [];
|
|
|
|
public lovelace?: LovelaceConfig;
|
|
|
|
public cardPicked?: (cardConf: LovelaceCardConfig) => void;
|
|
|
|
@state() private _filter = "";
|
|
|
|
@state() private _width?: number;
|
|
|
|
@state() private _height?: number;
|
|
|
|
private _unusedEntities?: string[];
|
|
|
|
private _usedEntities?: string[];
|
|
|
|
private _filterCards = memoizeOne(
|
|
(cardElements: CardElement[], filter?: string): CardElement[] => {
|
|
if (!filter) {
|
|
return cardElements;
|
|
}
|
|
let cards = cardElements.map(
|
|
(cardElement: CardElement) => cardElement.card
|
|
);
|
|
const options: IFuseOptions<Card> = {
|
|
keys: ["type", "name", "description"],
|
|
isCaseSensitive: false,
|
|
minMatchCharLength: Math.min(filter.length, 2),
|
|
threshold: 0.2,
|
|
};
|
|
const fuse = new Fuse(cards, options);
|
|
cards = fuse.search(filter).map((result) => result.item);
|
|
return cardElements.filter((cardElement: CardElement) =>
|
|
cards.includes(cardElement.card)
|
|
);
|
|
}
|
|
);
|
|
|
|
private _suggestedCards = memoizeOne(
|
|
(cardElements: CardElement[]): CardElement[] =>
|
|
cardElements.filter(
|
|
(cardElement: CardElement) => cardElement.card.isSuggested
|
|
)
|
|
);
|
|
|
|
private _customCards = memoizeOne(
|
|
(cardElements: CardElement[]): CardElement[] =>
|
|
cardElements.filter(
|
|
(cardElement: CardElement) =>
|
|
cardElement.card.isCustom && !cardElement.card.isSuggested
|
|
)
|
|
);
|
|
|
|
private _otherCards = memoizeOne(
|
|
(cardElements: CardElement[]): CardElement[] =>
|
|
cardElements.filter(
|
|
(cardElement: CardElement) =>
|
|
!cardElement.card.isSuggested && !cardElement.card.isCustom
|
|
)
|
|
);
|
|
|
|
protected render() {
|
|
if (
|
|
!this.hass ||
|
|
!this.lovelace ||
|
|
!this._unusedEntities ||
|
|
!this._usedEntities
|
|
) {
|
|
return nothing;
|
|
}
|
|
|
|
const suggestedCards = this._suggestedCards(this._cards);
|
|
const othersCards = this._otherCards(this._cards);
|
|
const customCardsItems = this._customCards(this._cards);
|
|
|
|
return html`
|
|
<search-input
|
|
.hass=${this.hass}
|
|
.filter=${this._filter}
|
|
@value-changed=${this._handleSearchChange}
|
|
.label=${this.hass.localize(
|
|
"ui.panel.lovelace.editor.edit_card.search_cards"
|
|
)}
|
|
></search-input>
|
|
<div
|
|
id="content"
|
|
style=${styleMap({
|
|
width: this._width ? `${this._width}px` : "auto",
|
|
height: this._height ? `${this._height}px` : "auto",
|
|
})}
|
|
>
|
|
<div class="cards-container">
|
|
${this._filter
|
|
? this._filterCards(this._cards, this._filter).map(
|
|
(cardElement: CardElement) => cardElement.element
|
|
)
|
|
: html`
|
|
${suggestedCards.length > 0
|
|
? html`
|
|
<div class="cards-container-header">
|
|
${this.hass!.localize(
|
|
`ui.panel.lovelace.editor.card.generic.suggested_cards`
|
|
)}
|
|
</div>
|
|
`
|
|
: nothing}
|
|
${this._renderClipboardCard()}
|
|
${suggestedCards.map(
|
|
(cardElement: CardElement) => cardElement.element
|
|
)}
|
|
${suggestedCards.length > 0
|
|
? html`
|
|
<div class="cards-container-header">
|
|
${this.hass!.localize(
|
|
`ui.panel.lovelace.editor.card.generic.other_cards`
|
|
)}
|
|
</div>
|
|
`
|
|
: nothing}
|
|
${othersCards.map(
|
|
(cardElement: CardElement) => cardElement.element
|
|
)}
|
|
${customCardsItems.length > 0
|
|
? html`
|
|
<div class="cards-container-header">
|
|
${this.hass!.localize(
|
|
`ui.panel.lovelace.editor.card.generic.custom_cards`
|
|
)}
|
|
</div>
|
|
`
|
|
: nothing}
|
|
${customCardsItems.map(
|
|
(cardElement: CardElement) => cardElement.element
|
|
)}
|
|
`}
|
|
</div>
|
|
<div class="cards-container">
|
|
<div
|
|
class="card manual"
|
|
@click=${this._cardPicked}
|
|
.config=${{ type: "" }}
|
|
>
|
|
<div class="card-header">
|
|
${this.hass!.localize(
|
|
`ui.panel.lovelace.editor.card.generic.manual`
|
|
)}
|
|
</div>
|
|
<div class="preview description">
|
|
${this.hass!.localize(
|
|
`ui.panel.lovelace.editor.card.generic.manual_description`
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
|
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
|
if (!oldHass) {
|
|
return true;
|
|
}
|
|
|
|
if (oldHass.locale !== this.hass!.locale) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
protected firstUpdated(): void {
|
|
if (!this.hass || !this.lovelace) {
|
|
return;
|
|
}
|
|
|
|
const usedEntities = computeUsedEntities(this.lovelace);
|
|
const unusedEntities = calcUnusedEntities(this.hass, usedEntities);
|
|
|
|
this._usedEntities = [...usedEntities].filter(
|
|
(eid) =>
|
|
this.hass!.states[eid] &&
|
|
!isUnavailableState(this.hass!.states[eid].state)
|
|
);
|
|
this._unusedEntities = [...unusedEntities].filter(
|
|
(eid) =>
|
|
this.hass!.states[eid] &&
|
|
!isUnavailableState(this.hass!.states[eid].state)
|
|
);
|
|
|
|
this._loadCards();
|
|
}
|
|
|
|
private _loadCards() {
|
|
let cards: Card[] = coreCards.map((card: Card) => ({
|
|
name: this.hass!.localize(
|
|
`ui.panel.lovelace.editor.card.${card.type}.name`
|
|
),
|
|
description: this.hass!.localize(
|
|
`ui.panel.lovelace.editor.card.${card.type}.description`
|
|
),
|
|
isSuggested: this.suggestedCards?.includes(card.type) || false,
|
|
...card,
|
|
}));
|
|
|
|
cards = cards.sort((a, b) => {
|
|
if (a.isSuggested && !b.isSuggested) {
|
|
return -1;
|
|
}
|
|
if (!a.isSuggested && b.isSuggested) {
|
|
return 1;
|
|
}
|
|
return stringCompare(
|
|
a.name || a.type,
|
|
b.name || b.type,
|
|
this.hass?.language
|
|
);
|
|
});
|
|
|
|
if (customCards.length > 0) {
|
|
cards = cards.concat(
|
|
customCards.map((ccard: CustomCardEntry) => ({
|
|
type: ccard.type,
|
|
name: ccard.name,
|
|
description: ccard.description,
|
|
showElement: ccard.preview,
|
|
isCustom: true,
|
|
}))
|
|
);
|
|
}
|
|
this._cards = cards.map((card: Card) => ({
|
|
card: card,
|
|
element: html`${until(
|
|
this._renderCardElement(card),
|
|
html`
|
|
<div class="card spinner">
|
|
<ha-circular-progress indeterminate></ha-circular-progress>
|
|
</div>
|
|
`
|
|
)}`,
|
|
}));
|
|
}
|
|
|
|
private _renderClipboardCard() {
|
|
if (!this._clipboard) {
|
|
return nothing;
|
|
}
|
|
|
|
return html` ${until(
|
|
this._renderCardElement(
|
|
{
|
|
type: this._clipboard.type,
|
|
showElement: true,
|
|
isCustom: false,
|
|
name: this.hass!.localize(
|
|
"ui.panel.lovelace.editor.card.generic.paste"
|
|
),
|
|
description: `${this.hass!.localize(
|
|
"ui.panel.lovelace.editor.card.generic.paste_description",
|
|
{
|
|
type: this._clipboard.type,
|
|
}
|
|
)}`,
|
|
},
|
|
this._clipboard
|
|
),
|
|
html`
|
|
<div class="card spinner">
|
|
<ha-circular-progress indeterminate></ha-circular-progress>
|
|
</div>
|
|
`
|
|
)}`;
|
|
}
|
|
|
|
private _handleSearchChange(ev: CustomEvent) {
|
|
const value = ev.detail.value;
|
|
|
|
if (!value) {
|
|
// Reset when we no longer filter
|
|
this._width = undefined;
|
|
this._height = undefined;
|
|
} else if (!this._width || !this._height) {
|
|
// Save height and width so the dialog doesn't jump while searching
|
|
const div = this.shadowRoot!.getElementById("content");
|
|
if (div && !this._width) {
|
|
const width = div.clientWidth;
|
|
if (width) {
|
|
this._width = width;
|
|
}
|
|
}
|
|
if (div && !this._height) {
|
|
const height = div.clientHeight;
|
|
if (height) {
|
|
this._height = height;
|
|
}
|
|
}
|
|
}
|
|
|
|
this._filter = value;
|
|
}
|
|
|
|
private _cardPicked(ev: Event): void {
|
|
const config: LovelaceCardConfig = (ev.currentTarget! as CardPickTarget)
|
|
.config;
|
|
|
|
fireEvent(this, "config-changed", { config });
|
|
}
|
|
|
|
private _tryCreateCardElement(cardConfig: LovelaceCardConfig) {
|
|
const element = tryCreateCardElement(cardConfig) as LovelaceCard;
|
|
element.hass = this.hass;
|
|
element.addEventListener(
|
|
"ll-rebuild",
|
|
(ev) => {
|
|
ev.stopPropagation();
|
|
this._rebuildCard(element, cardConfig);
|
|
},
|
|
{ once: true }
|
|
);
|
|
return element;
|
|
}
|
|
|
|
private _rebuildCard(
|
|
cardElToReplace: LovelaceCard,
|
|
config: LovelaceCardConfig
|
|
): void {
|
|
let newCardEl: LovelaceCard;
|
|
try {
|
|
newCardEl = this._tryCreateCardElement(config);
|
|
} catch (err: any) {
|
|
return;
|
|
}
|
|
if (cardElToReplace.parentElement) {
|
|
cardElToReplace.parentElement!.replaceChild(newCardEl, cardElToReplace);
|
|
}
|
|
}
|
|
|
|
private async _renderCardElement(
|
|
card: Card,
|
|
config?: LovelaceCardConfig
|
|
): Promise<TemplateResult> {
|
|
let { type } = card;
|
|
const { showElement, isCustom, name, description } = card;
|
|
const customCard = isCustom ? getCustomCardEntry(type) : undefined;
|
|
if (isCustom) {
|
|
type = `${CUSTOM_TYPE_PREFIX}${type}`;
|
|
}
|
|
|
|
let element: LovelaceCard | undefined;
|
|
let cardConfig: LovelaceCardConfig = config ?? { type };
|
|
|
|
if (this.hass && this.lovelace) {
|
|
if (!config) {
|
|
cardConfig = await getCardStubConfig(
|
|
this.hass,
|
|
type,
|
|
this._unusedEntities!,
|
|
this._usedEntities!
|
|
);
|
|
}
|
|
|
|
if (showElement) {
|
|
try {
|
|
element = this._tryCreateCardElement(cardConfig);
|
|
} catch (err: any) {
|
|
element = undefined;
|
|
}
|
|
}
|
|
}
|
|
|
|
return html`
|
|
<div class="card">
|
|
<div
|
|
class="overlay"
|
|
@click=${this._cardPicked}
|
|
.config=${cardConfig}
|
|
></div>
|
|
<div class="card-header">
|
|
${customCard
|
|
? `${this.hass!.localize(
|
|
"ui.panel.lovelace.editor.cardpicker.custom_card"
|
|
)}: ${customCard.name || customCard.type}`
|
|
: name}
|
|
</div>
|
|
<div
|
|
class="preview ${classMap({
|
|
description: !element || element.tagName === "HUI-ERROR-CARD",
|
|
})}"
|
|
>
|
|
${element && element.tagName !== "HUI-ERROR-CARD"
|
|
? element
|
|
: customCard
|
|
? customCard.description ||
|
|
this.hass!.localize(
|
|
`ui.panel.lovelace.editor.cardpicker.no_description`
|
|
)
|
|
: description}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
static get styles(): CSSResultGroup {
|
|
return [
|
|
css`
|
|
search-input {
|
|
display: block;
|
|
--mdc-shape-small: var(--card-picker-search-shape);
|
|
margin: var(--card-picker-search-margin);
|
|
}
|
|
|
|
.cards-container-header {
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
padding: 12px 8px 4px 8px;
|
|
margin: 0;
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.cards-container {
|
|
display: grid;
|
|
grid-gap: 8px 8px;
|
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.card {
|
|
height: 100%;
|
|
max-width: 500px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
border-radius: var(--ha-card-border-radius, 12px);
|
|
background: var(--primary-background-color, #fafafa);
|
|
cursor: pointer;
|
|
position: relative;
|
|
overflow: hidden;
|
|
border: var(--ha-card-border-width, 1px) solid
|
|
var(--ha-card-border-color, var(--divider-color));
|
|
}
|
|
|
|
.card-header {
|
|
color: var(--ha-card-header-color, --primary-text-color);
|
|
font-family: var(--ha-card-header-font-family, inherit);
|
|
font-size: 16px;
|
|
font-weight: bold;
|
|
letter-spacing: -0.012em;
|
|
line-height: 20px;
|
|
padding: 12px 16px;
|
|
display: block;
|
|
text-align: center;
|
|
background: var(
|
|
--ha-card-background,
|
|
var(--card-background-color, white)
|
|
);
|
|
border-bottom: 1px solid var(--divider-color);
|
|
}
|
|
|
|
.preview {
|
|
pointer-events: none;
|
|
margin: 20px;
|
|
flex-grow: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.preview > :first-child {
|
|
zoom: 0.6;
|
|
display: block;
|
|
width: 100%;
|
|
}
|
|
|
|
.description {
|
|
text-align: center;
|
|
}
|
|
|
|
.spinner {
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.overlay {
|
|
position: absolute;
|
|
width: 100%;
|
|
height: 100%;
|
|
z-index: 1;
|
|
box-sizing: border-box;
|
|
border-radius: var(--ha-card-border-radius, 12px);
|
|
}
|
|
|
|
.manual {
|
|
max-width: none;
|
|
}
|
|
|
|
.icon {
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 8px
|
|
inset-inline-start: 8px;
|
|
inset-inline-end: 8px;
|
|
border-radius: 50%;
|
|
--mdc-icon-size: 16px;
|
|
line-height: 16px;
|
|
box-sizing: border-box;
|
|
color: var(--text-primary-color);
|
|
padding: 4px;
|
|
}
|
|
.icon.custom {
|
|
background: var(--warning-color);
|
|
}
|
|
`,
|
|
];
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
"hui-card-picker": HuiCardPicker;
|
|
}
|
|
}
|