Compare commits

...
6 Commits
Author SHA1 Message Date
Maarten Lakerveld fcbf5c1d22 Drop the cut toast when a paste finished first
A cut whose save was still pending could show its Undo toast after a
later paste. Saves apply optimistically, so Undo would then remove the
pasted card instead of restoring the cut one. The cut still updates the
clipboard when its save succeeds, but skips the toast once a newer
clipboard action has started.
2026-10-23 11:29:47 +02:00
Maarten Lakerveld 672d144f43 Use the generic undo for the replace toast
Binding Undo to the replaced card captured the pre-paste dashboard, so
clicking it discarded every edit made while the toast was visible, and a
failed restore went unhandled. The toast now fires undo-change like the
cut and delete toasts, sharing their behavior and limitations.
2026-10-23 11:20:57 +02:00
Maarten Lakerveld fd4a26d3c8 Make paste generic like copy and cut
performPaste now takes the clipboard item type like performCopy and
performCut, so badges can reuse it. The sidebar rule moved out of the
helper: the view handler adopts the target card's view_layout through
adoptViewLayout before pasting, because only it knows the view type.
2026-10-23 11:07:56 +02:00
Maarten Lakerveld 57a42b420b Bind the replace toast's Undo to the replaced card
The toast after replacing a panel card used the generic undo-change event,
which undoes the latest change. An edit made before clicking Undo was
reverted instead of the replacement. The action now puts the replaced card
back at its path.
2026-10-23 10:50:26 +02:00
Maarten Lakerveld 41adc02408 Rerender storage-backed state that changed while disconnected
The storage decorator unsubscribes while an element is disconnected and
subscribes again on reconnect, but never asked for a render when the
value changed in between. Cached dashboard views kept stale card menus:
copy a card in another view and the Paste item was missing on return.

The decorator now remembers the value at disconnect and requests an
update on reconnect when it differs.
2026-10-23 10:50:26 +02:00
Maarten Lakerveld 46291451d7 Add Paste to the dashboard card menu
The card menu now offers Paste when a card is on the clipboard, inserting a
copy right after the card the menu was opened on, like Paste for sections.
In panel views, which show a single card, Paste replaces that card and
offers Undo in a toast. In sidebar views the pasted card takes the target
card's view_layout so it lands in the same column.

Paste closes the edit toast before saving, because the Cut toast's Undo
undoes the latest change and would otherwise undo the paste.

Paste is hidden in the view header and footer slots and the import-only
section, where adding a card makes no sense.
2026-10-22 17:11:16 +02:00
10 changed files with 386 additions and 6 deletions

No files matched your search

+9
View File
@@ -185,6 +185,7 @@ export function storage(options: {
if (options.subscribe) {
const connectedCallback = proto.connectedCallback;
const disconnectedCallback = proto.disconnectedCallback;
const disconnectedValues = new WeakMap<ReactiveElement, unknown>();
proto.connectedCallback = function () {
connectedCallback.call(this);
@@ -192,12 +193,20 @@ export function storage(options: {
if (!el.__unbsubLocalStorage) {
el.__unbsubLocalStorage = subscribeChanges?.(this);
}
if (disconnectedValues.has(el)) {
const oldValue = disconnectedValues.get(el);
disconnectedValues.delete(el);
if (oldValue !== storageInstance.getValue(storageKey)) {
el.requestUpdate(propertyKey, oldValue);
}
}
};
proto.disconnectedCallback = function () {
disconnectedCallback.call(this);
const el = this as unknown as ReactiveStorageElement;
el.__unbsubLocalStorage?.();
el.__unbsubLocalStorage = undefined;
disconnectedValues.set(el, storageInstance.getValue(storageKey));
};
}
@@ -2,6 +2,7 @@ import "@home-assistant/webawesome/dist/components/divider/divider";
import {
mdiContentCopy,
mdiContentCut,
mdiContentPaste,
mdiCursorMove,
mdiDelete,
mdiDotsVertical,
@@ -13,6 +14,7 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { storage } from "../../../common/decorators/storage";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-dropdown";
@@ -20,6 +22,7 @@ import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-svg-icon";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { haStyle } from "../../../resources/styles";
import type { LovelacePath } from "../editor/lovelace-path";
import type { Lovelace } from "../types";
@@ -46,6 +49,15 @@ export class HuiCardEditMode extends LitElement {
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@storage({
key: "dashboardCardClipboard",
state: true,
subscribe: true,
storage: "sessionStorage",
})
private _clipboard?: LovelaceCardConfig;
@state()
public _hover = false;
@@ -175,6 +187,21 @@ export class HuiCardEditMode extends LitElement {
></ha-svg-icon>
${this._localize("ui.panel.lovelace.editor.edit_card.cut")}
</ha-dropdown-item>
${
this._clipboard && !this.noDuplicate
? html`
<ha-dropdown-item value="paste">
<ha-svg-icon
slot="icon"
.path=${mdiContentPaste}
></ha-svg-icon>
${this._localize(
"ui.panel.lovelace.editor.edit_card.paste"
)}
</ha-dropdown-item>
`
: nothing
}
`
}
${
@@ -227,6 +254,9 @@ export class HuiCardEditMode extends LitElement {
case "cut":
this._cutCard();
break;
case "paste":
this._pasteCard();
break;
case "delete":
this._deleteCard();
break;
@@ -249,6 +279,10 @@ export class HuiCardEditMode extends LitElement {
fireEvent(this, "ll-copy-card", { path: this.path! });
}
private _pasteCard(): void {
fireEvent(this, "ll-paste-card", { path: this.path! });
}
private _deleteCard(): void {
fireEvent(this, "ll-delete-card", { path: this.path!, silent: false });
}
@@ -2,6 +2,7 @@ import "@home-assistant/webawesome/dist/components/divider/divider";
import {
mdiContentCopy,
mdiContentCut,
mdiContentPaste,
mdiDelete,
mdiDotsVertical,
mdiFileMoveOutline,
@@ -11,7 +12,12 @@ import {
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, queryAssignedElements } from "lit/decorators";
import {
customElement,
property,
queryAssignedElements,
state,
} from "lit/decorators";
import { storage } from "../../../common/decorators/storage";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-button";
@@ -61,13 +67,14 @@ export class HuiCardOptions extends LitElement {
@property({ attribute: "hide-position", type: Boolean })
public hidePosition = false;
@state()
@storage({
key: "dashboardCardClipboard",
state: false,
subscribe: false,
state: true,
subscribe: true,
storage: "sessionStorage",
})
protected _clipboard?: LovelaceCardConfig;
private _clipboard?: LovelaceCardConfig;
public getCardSize() {
return this._assignedElements
@@ -189,6 +196,21 @@ export class HuiCardOptions extends LitElement {
<ha-svg-icon slot="icon" .path=${mdiContentCut}></ha-svg-icon>
${this.hass!.localize("ui.panel.lovelace.editor.edit_card.cut")}
</ha-dropdown-item>
${
this._clipboard
? html`
<ha-dropdown-item value="paste">
<ha-svg-icon
slot="icon"
.path=${mdiContentPaste}
></ha-svg-icon>
${this.hass!.localize(
"ui.panel.lovelace.editor.edit_card.paste"
)}
</ha-dropdown-item>
`
: nothing
}
<wa-divider></wa-divider>
<ha-dropdown-item value="delete" variant="danger">
<ha-svg-icon
@@ -283,6 +305,9 @@ export class HuiCardOptions extends LitElement {
case "cut":
this._cutCard();
break;
case "paste":
this._pasteCard();
break;
case "delete":
this._deleteCard();
break;
@@ -305,6 +330,10 @@ export class HuiCardOptions extends LitElement {
fireEvent(this, "ll-copy-card", { path: this.path! });
}
private _pasteCard(): void {
fireEvent(this, "ll-paste-card", { path: this.path! });
}
private _deleteCard(): void {
fireEvent(this, "ll-delete-card", { path: this.path!, silent: false });
}
@@ -8,6 +8,7 @@ import { LOVELACE_EDIT_TOAST_ID } from "../types";
import { performDeleteBadge } from "./delete-badge";
import { performDeleteCard } from "./delete-card";
import type { LovelacePath } from "./lovelace-path";
import { getParentPath, insertAtPath, setAtPath } from "./lovelace-path";
interface ClipboardItemConfigs {
card: LovelaceCardConfig;
@@ -53,6 +54,50 @@ export function performCopy<K extends ClipboardItemType>(
});
}
export interface PasteParams<K extends ClipboardItemType> {
type: K;
path: LovelacePath;
config: ClipboardItemConfigs[K];
replace?: boolean;
}
export async function performPaste<K extends ClipboardItemType>(
localize: LocalizeFunc,
lovelace: Lovelace,
{ type, path, config, replace }: PasteParams<K>
): Promise<void> {
++lastVersion;
lovelace.showToast({ id: LOVELACE_EDIT_TOAST_ID, message: "", duration: 0 });
try {
const index = path[path.length - 1];
if (typeof index !== "number") {
throw new Error("Paste target is not an item");
}
const pastedPath = replace ? path : [...getParentPath(path), index + 1];
const newConfig = replace
? setAtPath(lovelace.config, path, deepClone(config))
: insertAtPath(lovelace.config, pastedPath, deepClone(config));
await lovelace.saveConfig(newConfig);
} catch (_err: unknown) {
lovelace.showToast({
id: LOVELACE_EDIT_TOAST_ID,
message: localize(`ui.panel.lovelace.editor.edit_${type}.paste_error`),
});
return;
}
if (replace) {
lovelace.showToast({
id: LOVELACE_EDIT_TOAST_ID,
message: localize(`ui.panel.lovelace.editor.edit_${type}.replaced`),
duration: 8000,
action: {
action: () => fireEvent(window, "undo-change"),
text: localize("ui.common.undo"),
},
});
}
}
export async function performCut<K extends ClipboardItemType>(
localize: LocalizeFunc,
lovelace: Lovelace,
@@ -70,6 +115,9 @@ export async function performCut<K extends ClipboardItemType>(
}
clipboardVersions[type] = version;
setClipboard(deepClone(config));
if (lastVersion !== version) {
return;
}
lovelace.showToast({
id: LOVELACE_EDIT_TOAST_ID,
message: localize(`ui.panel.lovelace.editor.edit_${type}.cut_to_clipboard`),
+10
View File
@@ -75,6 +75,16 @@ export const moveCardToContainer = (
return deleteAtPath(newConfig, cardPath);
};
export const adoptViewLayout = (
card: LovelaceCardConfig,
target: LovelaceCardConfig
): LovelaceCardConfig => {
const { view_layout: _viewLayout, ...rest } = card;
return target.view_layout === undefined
? rest
: { ...rest, view_layout: target.view_layout };
};
export const getCardSectionConfig = (
config: LovelaceConfig,
path: LovelacePath
+37 -2
View File
@@ -32,9 +32,13 @@ import { showCreateBadgeDialog } from "../editor/badge-editor/show-create-badge-
import { showEditBadgeDialog } from "../editor/badge-editor/show-edit-badge-dialog";
import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog";
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
import { getCardSectionConfig } from "../editor/config-util";
import { adoptViewLayout, getCardSectionConfig } from "../editor/config-util";
import type { ClipboardActionParams } from "../editor/clipboard-actions";
import { performCopy, performCut } from "../editor/clipboard-actions";
import {
performCopy,
performCut,
performPaste,
} from "../editor/clipboard-actions";
import {
type DeleteBadgeParams,
performDeleteBadge,
@@ -47,8 +51,10 @@ import type { LovelacePath } from "../editor/lovelace-path";
import {
appendAtPath,
getAtPath,
getItemKind,
getParentPath,
getPathTarget,
getViewPath,
normalizeCardPath,
setAtPath,
} from "../editor/lovelace-path";
@@ -60,6 +66,7 @@ import {
generateLovelaceViewStrategy,
} from "../strategies/get-strategy";
import type { Lovelace } from "../types";
import { PANEL_VIEW_LAYOUT, SIDEBAR_VIEW_LAYOUT } from "./const";
import { getViewType } from "./get-view-type";
declare global {
@@ -71,6 +78,7 @@ declare global {
"ll-duplicate-card": { path: LovelacePath };
"ll-copy-card": { path: LovelacePath };
"ll-cut-card": { path: LovelacePath };
"ll-paste-card": { path: LovelacePath };
"ll-create-badge": { path: LovelacePath };
"ll-edit-badge": { path: LovelacePath };
"ll-delete-badge": DeleteBadgeParams;
@@ -84,6 +92,7 @@ declare global {
"ll-duplicate-card": HASSDomEvent<HASSDomEvents["ll-duplicate-card"]>;
"ll-copy-card": HASSDomEvent<HASSDomEvents["ll-copy-card"]>;
"ll-cut-card": HASSDomEvent<HASSDomEvents["ll-cut-card"]>;
"ll-paste-card": HASSDomEvent<HASSDomEvents["ll-paste-card"]>;
"ll-create-badge": HASSDomEvent<HASSDomEvents["ll-create-badge"]>;
"ll-edit-badge": HASSDomEvent<HASSDomEvents["ll-edit-badge"]>;
"ll-delete-badge": HASSDomEvent<HASSDomEvents["ll-delete-badge"]>;
@@ -238,6 +247,32 @@ export class HUIView extends ReactiveElement {
}
}
);
this.addEventListener(
"ll-paste-card",
(ev: HASSDomEvent<HASSDomEvents["ll-paste-card"]>) => {
const config = this._clipboard;
const path = normalizeCardPath(ev.detail.path);
const target =
getPathTarget(path) === "item" && getItemKind(path) === "card"
? this._getCardConfig(path)
: undefined;
if (!config || !target) {
return;
}
const viewType = getViewType(
getAtPath<LovelaceViewConfig>(this.lovelace.config, getViewPath(path))
);
performPaste(this.hass.localize, this.lovelace, {
type: "card",
path,
config:
viewType === SIDEBAR_VIEW_LAYOUT
? adoptViewLayout(config, target)
: config,
replace: viewType === PANEL_VIEW_LAYOUT,
});
}
);
this.addEventListener(
"ll-create-badge",
(ev: HASSDomEvent<HASSDomEvents["ll-create-badge"]>) => {
+5
View File
@@ -10293,6 +10293,9 @@
"cut": "Cut",
"copied_to_clipboard": "Card copied to clipboard",
"cut_to_clipboard": "Card cut to clipboard",
"paste": "Paste",
"paste_error": "Error while pasting card",
"replaced": "Card replaced",
"duplicate": "Duplicate",
"move": "Move to view",
"move_before": "Move card before",
@@ -10337,6 +10340,8 @@
"cut": "[%key:ui::panel::lovelace::editor::edit_card::cut%]",
"copied_to_clipboard": "Badge copied to clipboard",
"cut_to_clipboard": "Badge cut to clipboard",
"paste_error": "Error while pasting badge",
"replaced": "Badge replaced",
"duplicate": "[%key:ui::panel::lovelace::editor::edit_card::duplicate%]",
"tab_config": "[%key:ui::panel::lovelace::editor::edit_card::tab_config%]",
"tab_visibility": "[%key:ui::panel::lovelace::editor::edit_card::tab_visibility%]"
+56
View File
@@ -0,0 +1,56 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { afterEach, describe, expect, it } from "vitest";
import { getStorage, storage } from "../../../src/common/decorators/storage";
const KEY = "test-storage-subscriber-value";
@customElement("test-storage-subscriber")
class TestStorageSubscriber extends ReactiveElement {
@storage({
key: KEY,
state: true,
subscribe: true,
storage: "sessionStorage",
})
public value?: string;
public updates = 0;
protected update(changedProperties) {
super.update(changedProperties);
this.updates++;
}
}
declare global {
interface HTMLElementTagNameMap {
"test-storage-subscriber": TestStorageSubscriber;
}
}
describe("storage decorator", () => {
afterEach(() => {
document.body.replaceChildren();
getStorage("sessionStorage").setValue(KEY, undefined);
});
it("renders a value that changed while the element was disconnected", async () => {
const element = document.createElement("test-storage-subscriber");
document.body.append(element);
await element.updateComplete;
expect(element.updates).toBe(1);
element.remove();
document.body.append(element);
await element.updateComplete;
expect(element.updates).toBe(1);
element.remove();
getStorage("sessionStorage").setValue(KEY, "changed");
document.body.append(element);
await element.updateComplete;
expect(element.updates).toBe(2);
expect(element.value).toBe("changed");
});
});
@@ -5,7 +5,10 @@ import type { LovelaceConfig } from "../../../../src/data/lovelace/config/types"
import {
performCopy,
performCut,
performPaste,
} from "../../../../src/panels/lovelace/editor/clipboard-actions";
import type { LovelacePath } from "../../../../src/panels/lovelace/editor/lovelace-path";
import { getAtPath } from "../../../../src/panels/lovelace/editor/lovelace-path";
import type { Lovelace } from "../../../../src/panels/lovelace/types";
const CARD_A: LovelaceCardConfig = { type: "card-a" };
@@ -118,4 +121,134 @@ describe("performCut", () => {
expect(clipboard).toEqual(CARD_A);
});
it("keeps the clipboard but not the toast when a paste finished first", async () => {
let finishSave!: () => void;
const lovelace = createLovelace(
() =>
new Promise<void>((resolve) => {
finishSave = resolve;
})
);
let clipboard: LovelaceCardConfig | undefined;
const setClipboard = (config: LovelaceCardConfig) => {
clipboard = config;
};
const cut = performCut(
localize,
lovelace,
cardParams(CARD_A, 0, setClipboard)
);
await performPaste(
localize,
createLovelace(() => Promise.resolve()),
{
type: "card",
path: ["views", 0, "cards", 1],
config: CARD_B,
}
);
vi.mocked(lovelace.showToast).mockClear();
finishSave();
await cut;
expect(clipboard).toEqual(CARD_A);
expect(lovelace.showToast).not.toHaveBeenCalled();
});
});
describe("performPaste", () => {
const CLIPBOARD: LovelaceCardConfig = {
type: "clipboard",
entities: ["light.a"],
};
const pasteInto = async (
config: LovelaceConfig,
params: Omit<Parameters<typeof performPaste<"card">>[2], "type" | "config">
) => {
const saveConfig = vi.fn<Lovelace["saveConfig"]>(() => Promise.resolve());
const lovelace = {
config,
rawConfig: config,
saveConfig,
showToast: vi.fn(),
} as Partial<Lovelace> as Lovelace;
await performPaste(localize, lovelace, {
type: "card",
config: CLIPBOARD,
...params,
});
const saved = saveConfig.mock.calls[0][0] as LovelaceConfig;
return {
cardsAt: (cardsPath: LovelacePath) =>
getAtPath<LovelaceCardConfig[]>(saved, cardsPath)!,
showToast: lovelace.showToast,
};
};
it("inserts a copy of the clipboard card after the target card", async () => {
const { cardsAt } = await pasteInto(
{ views: [{ cards: [CARD_A, CARD_B] }] },
{ path: ["views", 0, "cards", 0] }
);
expect(cardsAt(["views", 0, "cards"])).toEqual([CARD_A, CLIPBOARD, CARD_B]);
const { cardsAt: nestedCardsAt } = await pasteInto(
{ views: [{ sections: [{ cards: [CARD_A, CARD_B] }] }] },
{ path: ["views", 0, "sections", 0, "cards", 1] }
);
expect(nestedCardsAt(["views", 0, "sections", 0, "cards"])).toEqual([
CARD_A,
CARD_B,
CLIPBOARD,
]);
});
it("pastes an independent copy", async () => {
const { cardsAt } = await pasteInto(
{ views: [{ cards: [CARD_A] }] },
{ path: ["views", 0, "cards", 0] }
);
const pasted = cardsAt(["views", 0, "cards"])[1];
expect(pasted).not.toBe(CLIPBOARD);
expect(pasted.entities).not.toBe(CLIPBOARD.entities);
});
it("replaces the target card in panel views", async () => {
const { cardsAt, showToast } = await pasteInto(
{ views: [{ panel: true, cards: [CARD_A] }] },
{ path: ["views", 0, "cards", 0], replace: true }
);
expect(cardsAt(["views", 0, "cards"])).toEqual([CLIPBOARD]);
expect(showToast).toHaveBeenLastCalledWith(
expect.objectContaining({
message: "ui.panel.lovelace.editor.edit_card.replaced",
action: expect.objectContaining({ text: "ui.common.undo" }),
})
);
});
it("shows an error toast when saving fails", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
const config: LovelaceConfig = { views: [{ cards: [CARD_A] }] };
const lovelace = {
config,
rawConfig: config,
saveConfig: () => Promise.reject(new Error("save failed")),
showToast: vi.fn(),
} as Partial<Lovelace> as Lovelace;
await performPaste(localize, lovelace, {
type: "card",
path: ["views", 0, "cards", 0],
config: CLIPBOARD,
});
expect(lovelace.showToast).toHaveBeenLastCalledWith(
expect.objectContaining({
message: "ui.panel.lovelace.editor.edit_card.paste_error",
})
);
});
});
@@ -4,6 +4,7 @@ import type { LovelaceConfig } from "../../../../src/data/lovelace/config/types"
import type { LovelaceSectionConfig } from "../../../../src/data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../../src/data/lovelace/config/view";
import {
adoptViewLayout,
duplicateSection,
moveCardToContainer,
swapView,
@@ -233,3 +234,23 @@ describe("duplicateSection", () => {
);
});
});
describe("adoptViewLayout", () => {
it("takes the target card's view layout", () => {
const card = { type: "card1", view_layout: { position: "sidebar" } };
const target = { type: "card2", view_layout: { position: "main" } };
assert.deepEqual(adoptViewLayout(card, target), {
type: "card1",
view_layout: { position: "main" },
});
});
it("drops the card's own view layout when the target has none", () => {
const card = { type: "card1", view_layout: { position: "sidebar" } };
assert.deepEqual(adoptViewLayout(card, { type: "card2" }), {
type: "card1",
});
});
});