fix: сохранить identity карточек Masonry после reload (#561)

Сводная панель теперь использует канонический порядок карточек Home Assistant и не переносит локальные настройки между визуальными колонками после remount.

Issue: #561
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-13 02:04:45 +03:00
parent d6b33b115b
commit bb35af993a
54 changed files with 744 additions and 425 deletions
+84 -6
View File
@@ -1,15 +1,16 @@
/**
* Stable placement identity for per-card summary preferences.
*
* Home Assistant may move a native card wrapper between visual Masonry
* columns without changing its logical position in the dashboard. Cache the
* wrapper's initial structural path so that later DOM reflow and remounting
* the custom card inside that wrapper cannot move its local preferences.
* Home Assistant may move a card element between visual Masonry columns
* without changing its logical position in the dashboard. Native Masonry
* therefore uses the canonical `cards` array; other hosts retain the
* structural fallback introduced with the summary panel.
*/
type PlacementNode = {
localName?: unknown;
parentNode?: unknown;
children?: ArrayLike<unknown>;
cards?: ArrayLike<unknown>;
host?: unknown;
getRootNode?: () => unknown;
};
@@ -67,8 +68,85 @@ function structuralPath(start: object): string {
return parts.join('/') || 'root';
}
/** Return one stable slot for the nearest HA card wrapper. */
export function stableSummaryPlacementSlot(start: object): string {
function ancestorChain(start: object): object[] {
const chain: object[] = [];
const seen = new Set<object>();
let node: object | null = start;
for (let depth = 0; node && depth < 32 && !seen.has(node); depth++) {
chain.push(node);
seen.add(node);
node = parentOf(node);
}
return chain;
}
function childToken(node: object, parent: object): string | null {
const children = shapeOf(parent).children;
if (children) {
const index = Array.from(children).indexOf(node);
if (index >= 0) return `${localNameOf(node)}:${index}`;
}
return objectNode(shapeOf(node).host) === parent ? 'shadow-root' : null;
}
/** A deterministic composed path below one canonical top-level HA card. */
function descendantPath(start: object, ancestor: object): string | null {
if (start === ancestor) return '';
const parts: string[] = [];
let node: object | null = start;
const seen = new Set<object>();
for (let depth = 0; node && depth < 32 && !seen.has(node); depth++) {
if (node === ancestor) return parts.join('/');
seen.add(node);
const parent = parentOf(node);
if (!parent) return null;
const token = childToken(node, parent);
if (!token) return null;
parts.unshift(token);
node = parent;
}
return node === ancestor ? parts.join('/') : null;
}
type MasonryPlacement =
| { kind: 'not-masonry' }
| { kind: 'unresolved' }
| { kind: 'resolved'; slot: string };
/** Resolve against HA's canonical cards array, never its visual columns. */
function masonryPlacement(start: object): MasonryPlacement {
const chain = ancestorChain(start);
const masonryAt = chain.findIndex((node) => localNameOf(node) === 'hui-masonry-view');
if (masonryAt < 0) return { kind: 'not-masonry' };
const masonry = chain[masonryAt];
const source = shapeOf(masonry).cards;
if (!source) return { kind: 'unresolved' };
const cards = Array.from(source);
const ancestors = new Set(chain.slice(0, masonryAt));
const cardIndex = cards.findIndex((candidate) => {
const card = objectNode(candidate);
return !!card && ancestors.has(card);
});
if (cardIndex < 0) return { kind: 'unresolved' };
const topCard = objectNode(cards[cardIndex]);
if (!topCard) return { kind: 'unresolved' };
const suffix = descendantPath(start, topCard);
if (suffix === null) return { kind: 'unresolved' };
const slot = `masonry-v2:${cardIndex}${suffix ? `/${suffix}` : ''}`;
placementSlots.set(start, slot);
return { kind: 'resolved', slot };
}
/** Return one stable slot, or null until a native Masonry identity is safe. */
export function stableSummaryPlacementSlot(start: object): string | null {
const cached = placementSlots.get(start);
if (cached) return cached;
const masonry = masonryPlacement(start);
if (masonry.kind === 'resolved') return masonry.slot;
if (masonry.kind === 'unresolved') return null;
const anchor = enclosingNativeCard(start);
const existing = placementSlots.get(anchor);
if (existing) return existing;
+1 -1
View File
@@ -332,7 +332,7 @@ export class LoadedSummaryPanelRuntime {
this.styledDialogRoots.add(root);
}
private placementSlot(): string {
private placementSlot(): string | null {
return stableSummaryPlacementSlot(this.host);
}
+1 -1
View File
@@ -275,7 +275,7 @@ export function effectiveSummaryVisible(input: {
}
export function summaryLocalKey(parts: {
userId?: string | null; path?: string; host?: string; slot?: string;
userId?: string | null; path?: string; host?: string; slot?: string | null;
}): string | null {
const user = cleanText(parts.userId);
const slot = cleanText(parts.slot);