feat: Modernize the visual card editor. (#2586)

This commit is contained in:
Dermot Duffy
2026-07-20 20:44:16 -07:00
committed by GitHub
parent e204ec183f
commit b6e1de5999
146 changed files with 8487 additions and 5992 deletions
+64
View File
@@ -0,0 +1,64 @@
// Registers the card's custom icons as a Home Assistant custom iconset, so
// any `ha-icon` (and the icon picker) can render `advanced-camera-card:<name>`
// icons -- inside or outside the card. The iconset format supports a single
// monochrome SVG path per icon, so each asset is a single-path silhouette,
// with its path data extracted at build time.
import frigateIcon from '../images/icons/frigate.svg';
import irisIcon from '../images/icons/iris.svg';
import motioneyeIcon from '../images/icons/motioneye.svg';
import reolinkIcon from '../images/icons/reolink.svg';
import tplinkIcon from '../images/icons/tplink.svg';
interface CustomIconsetIcon {
path: string;
viewBox?: string;
}
interface CustomIconsetListItem {
name: string;
}
interface CustomIconsetHelpers {
getIcon: (name: string) => Promise<CustomIconsetIcon>;
getIconList: () => Promise<CustomIconsetListItem[]>;
}
declare global {
interface Window {
customIcons?: Record<string, CustomIconsetHelpers>;
}
}
export const CUSTOM_ICONSET_PREFIX = 'advanced-camera-card';
const ICONS: Record<string, CustomIconsetIcon> = {
frigate: frigateIcon,
iris: irisIcon,
motioneye: motioneyeIcon,
reolink: reolinkIcon,
tplink: tplinkIcon,
};
export const CUSTOM_ICON_NAMES = Object.keys(ICONS);
const getIcon = async (name: string): Promise<CustomIconsetIcon> => {
const icon: CustomIconsetIcon | undefined = ICONS[name];
if (!icon) {
throw new Error(`Unknown icon: ${CUSTOM_ICONSET_PREFIX}:${name}`);
}
return icon;
};
const getIconList = async (): Promise<CustomIconsetListItem[]> =>
CUSTOM_ICON_NAMES.map((name) => ({ name }));
/**
* Register the card's custom iconset with Home Assistant. Safe to call more
* than once; an existing registration (e.g. from a second loaded copy of the
* card) is left in place.
*/
export const registerCustomIconset = (): void => {
window.customIcons ??= {};
window.customIcons[CUSTOM_ICONSET_PREFIX] ??= { getIcon, getIconList };
};
+7
View File
@@ -18,17 +18,24 @@ class HomeAssistantElementsLoadError extends AdvancedCameraCardError {
*/
export const sideLoadHomeAssistantElements = async (): Promise<void> => {
const neededElements = [
'ha-alert',
'ha-button',
'ha-camera-stream',
'ha-card',
'ha-combo-box',
'ha-dropdown-item',
'ha-dropdown',
'ha-expansion-panel',
'ha-form',
'ha-hls-player',
'ha-icon-button-prev',
'ha-icon-button',
'ha-icon',
'ha-md-list-item',
'ha-md-list',
'ha-menu-button',
'ha-selector',
'ha-sortable',
'ha-spinner',
'ha-state-icon',
'ha-web-rtc-player',
+108
View File
@@ -254,3 +254,111 @@ export const resolvedMediaSchema = z.object({
mime_type: z.string(),
});
export type ResolvedMedia = z.infer<typeof resolvedMediaSchema>;
// *************************************************************************
// Home Assistant form types.
// *************************************************************************
// Home Assistant does not publish frontend types as a package, so the subset
// of the `ha-form` schema and `ha-selector` configuration shapes used by this
// card are declared here. They must remain structurally compatible with:
// https://github.com/home-assistant/frontend/blob/dev/src/components/ha-form/types.ts
// https://github.com/home-assistant/frontend/blob/dev/src/data/selector.ts
interface HAEntitySelector {
entity: {
domain?: string;
multiple?: boolean;
};
}
export interface HASelectSelectorOption {
value: unknown;
label: string;
}
export interface HASelectSelector {
select: {
mode?: 'dropdown' | 'list';
multiple?: boolean;
custom_value?: boolean;
options: string[] | HASelectSelectorOption[];
};
}
interface HAStringSelector {
text: {
type?: string;
};
}
export interface HANumberSelector {
number: {
min?: number;
max?: number;
mode?: 'box' | 'slider';
step?: number;
};
}
interface HABooleanSelector {
boolean: Record<string, never>;
}
interface HAIconSelector {
icon: Record<string, never>;
}
interface HAObjectSelector {
object: Record<string, never>;
}
type HASelector =
| HABooleanSelector
| HAEntitySelector
| HAIconSelector
| HANumberSelector
| HAObjectSelector
| HASelectSelector
| HAStringSelector;
interface StockHAFormBaseSchema {
name: string;
}
interface StockHAFormSelectorSchema extends StockHAFormBaseSchema {
selector: HASelector;
}
interface StockHAFormExpandableSchema extends StockHAFormBaseSchema {
type: 'expandable';
title?: string;
icon?: string;
schema: readonly HAFormSchema[];
}
// Extensions layered onto the stock shapes; not part of `ha-form` itself.
interface CardHAFormSchemaExtensions {
// Override the standard path-derived label, e.g. to point shared fields at
// `config.common.*` rather than duplicating keys under each section.
label?: string;
}
export interface HAFormSelectorSchema
extends StockHAFormSelectorSchema,
CardHAFormSchemaExtensions {}
export interface HAFormExpandableSchema
extends Omit<StockHAFormExpandableSchema, 'name'>,
CardHAFormSchemaExtensions {
// hass-frontend requires `name`, but `ha-form`'s runtime treats a missing name
// as a flattened, visual-only group (the `!item.name` paths in ha-form.ts).
// Relaxed to optional here for nameless grouping, e.g. an "Engine" group over
// the top-level `frigate`/`motioneye` camera keys.
name?: string;
// Explicit documentation-link key, for a nameless group whose link cannot be
// derived from a configuration path.
docPath?: string;
}
export type HAFormSchema = HAFormSelectorSchema | HAFormExpandableSchema;