mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 20:29:00 +00:00
fix: сохранить identity карточек Masonry после reload (#561)
Сводная панель теперь использует канонический порядок карточек Home Assistant и не переносит локальные настройки между визуальными колонками после remount. Issue: #561 User-Visible: yes
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -332,7 +332,7 @@ export class LoadedSummaryPanelRuntime {
|
||||
this.styledDialogRoots.add(root);
|
||||
}
|
||||
|
||||
private placementSlot(): string {
|
||||
private placementSlot(): string | null {
|
||||
return stableSummaryPlacementSlot(this.host);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user