Dashboard editor: copy and paste widgets across dashboards

Copy widget in the widget panel or mod+c keeps one widget in localStorage;
a paste button in the dock and mod+v insert it below the other widgets with
a fresh id. Placement is shared with adding a widget.
This commit is contained in:
2026-10-16 09:20:23 +02:00
parent bfab6e1d2e
commit 85539741a1
5 changed files with 239 additions and 30 deletions
+5
View File
@@ -18,6 +18,11 @@ or the Glass look's drifting colours) and dragging is disabled, but settings
remain editable.
In edit mode, ⌘Z / Ctrl-Z undoes an edit and ⌘⇧Z / Ctrl-Shift-Z redoes it.
**Copy widget** in a widget's settings, or ⌘C / Ctrl-C with it selected, keeps
a copy in the browser until the next copy replaces it. Paste it on this or any
other dashboard with the paste button in the bar or ⌘V / Ctrl-V. It lands below
the other widgets with the same title, bindings and size.
A widget can end up below the canvas, for example after the canvas was made
smaller. In edit mode the canvas then grows to reach it, under a dashed line
where the panel ends. Scroll down and drag it back, or use **Reflow**.
@@ -1,6 +1,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useNavigate } from "@tanstack/react-router"
import { Check, ExternalLink, Pencil, Plus, Settings2 } from "lucide-react"
import {
Check,
ClipboardPaste,
ExternalLink,
Pencil,
Plus,
Settings2,
} from "lucide-react"
import { motion, Reorder } from "motion/react"
import { type CSSProperties, useEffect, useRef, useState } from "react"
import {
@@ -38,6 +45,7 @@ import { appPath } from "@/lib/portal"
import { useShortcuts } from "@/lib/shortcuts"
import { cn } from "@/lib/utils"
import { handleError } from "@/utils"
import { copyWidget, useCopiedWidget } from "./clipboard"
import { DashboardPalette } from "./DashboardPalette"
import {
areaOf,
@@ -233,6 +241,29 @@ function nextId(dashboard: DashboardDef_Output, type: string): string {
return candidate
}
/**
* A widget as it joins the dashboard, added or pasted: a fresh id, at the left
* edge below everything already there, and no wider than the columns.
*/
export function appended(
dashboard: Dashboard,
widget: Omit<WidgetDef, "id" | "layout">,
size: { w: number; h: number },
): WidgetDef {
const columns = columnsOf(dashboard)
const bottom = layoutOf(widgetsOf(dashboard), columns).reduce(
(lowest, item) => Math.max(lowest, item.y + item.h),
0,
)
return {
...widget,
id: nextId(dashboard, widget.type),
layout: {
lg: { x: 0, y: bottom, w: Math.min(columns, size.w), h: size.h },
},
}
}
/**
* Positions for a dashboard nobody has arranged yet.
*
@@ -376,7 +407,8 @@ export function DashboardEditor({
const publish = usePublishDashboard(dashboard.name)
const discard = useDiscardDashboardDraft(dashboard.name)
const [discardOpen, setDiscardOpen] = useState(false)
const { showErrorToast } = useCustomToast()
const { showSuccessToast, showErrorToast } = useCustomToast()
const copied = useCopiedWidget()
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
// The save that is already on its way, so a publish waits for it instead of
// going out with the version it is about to replace.
@@ -470,24 +502,42 @@ export function DashboardEditor({
commit({ ...draft, widgets: next })
}
const addWidget = (type: WidgetKind) => {
const id = nextId(draft, type)
const bottom = layoutOf(widgets, columns).reduce(
(lowest, item) => Math.max(lowest, item.y + item.h),
0,
)
updateWidgets([
...widgets,
{
id,
type,
title: WIDGET_LABELS[type],
layout: { lg: { x: 0, y: bottom, ...WIDGET_SIZES[type] } },
config: type === "chart" ? { series: [{}] } : {},
},
])
/** Put a widget on the dashboard and open its settings. */
const place = (widget: WidgetDef) => {
updateWidgets([...widgets, widget])
setSettingsOpen(false)
setSelected(id)
setSelected(widget.id)
}
const addWidget = (type: WidgetKind) =>
place(
appended(
draft,
{
type,
title: WIDGET_LABELS[type],
config: type === "chart" ? { series: [{}] } : {},
},
WIDGET_SIZES[type],
),
)
const copy = (widget: WidgetDef) => {
if (copyWidget(widget)) showSuccessToast("Widget copied")
else showErrorToast("The browser would not store the copy.")
}
/** The copied widget again, at the size it had where it was copied. */
const paste = () => {
if (!copied) return
const { w = 3, h = 2 } = placement(copied)
place(
appended(
draft,
{ type: copied.type, title: copied.title, config: copied.config },
{ w, h },
),
)
}
const openSettings = () => {
@@ -503,6 +553,15 @@ export function DashboardEditor({
"mod+z": () => (edit ? step(true) : false),
"mod+shift+z": () => (edit ? step(false) : false),
"mod+p": () => (edit ? setPaletteOpen((open) => !open) : false),
// Selected text wins, so a native copy still works on the canvas.
"mod+c": () => {
const selection = document.getSelection()
if (selection && !selection.isCollapsed) return false
const widget = widgets.find((other) => other.id === selected)
if (!edit || !widget) return false
copy(widget)
},
"mod+v": () => (edit && copied ? paste() : false),
// An edit still waiting for its autosave counts: publishing flushes it.
"mod+s": () => {
if (!edit) return false
@@ -850,6 +909,26 @@ export function DashboardEditor({
<TooltipContent>Add a widget (⌘P)</TooltipContent>
</Tooltip>
{copied ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-touch"
className="text-muted-foreground"
onClick={paste}
aria-label={`Paste ${copied.title || WIDGET_LABELS[copied.type]}`}
data-testid="paste-widget"
>
<ClipboardPaste />
</Button>
</TooltipTrigger>
<TooltipContent>
Paste {copied.title || WIDGET_LABELS[copied.type]} (⌘V)
</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -946,6 +1025,7 @@ export function DashboardEditor({
widget={active}
round={round}
onChange={(changes) => active && patch(active.id, changes)}
onCopy={() => active && copy(active)}
onDelete={() => {
if (!active) return
updateWidgets(widgets.filter((other) => other.id !== active.id))
@@ -0,0 +1,59 @@
/**
* The widget clipboard and where a paste lands, checked.
*
* ponytail: a script rather than a suite, as with the other `.check.ts` files:
*
* cd frontend && bun run src/components/Dashboard/clipboard.check.ts
*
* Whatever another tab or an older build left in the slot is read as nothing
* unless it is a widget this app draws, and a paste never collides with an id
* or overlaps what is already arranged.
*/
import assert from "node:assert/strict"
import type { WidgetDef } from "@/client"
import { parseWidget } from "./clipboard"
import { appended } from "./DashboardEditor"
import type { Dashboard } from "./DashboardView"
const gauge: WidgetDef = {
id: "gauge",
type: "gauge",
title: "Boiler",
layout: { lg: { x: 4, y: 0, w: 8, h: 3 } },
config: { message: "heating.boiler" },
}
assert.deepEqual(parseWidget(JSON.stringify(gauge)), gauge)
for (const stored of [null, "", "{", "null", "42", '{"type":"sofa"}', "{}"]) {
assert.equal(parseWidget(stored), null, String(stored))
}
// Pasted onto a narrower dashboard that already holds a gauge: a fresh id,
// below the lowest widget, as wide as the columns allow, and its own height.
const dashboard = {
name: "kitchen",
columns: 6,
widgets: [
{ ...gauge, layout: { lg: { x: 0, y: 0, w: 3, h: 2 } } },
{ id: "stat", type: "stat", layout: { lg: { x: 3, y: 1, w: 3, h: 4 } } },
],
} as Dashboard
const { w = 3, h = 2 } = gauge.layout?.lg ?? {}
assert.deepEqual(
appended(
dashboard,
{ type: gauge.type, title: gauge.title, config: gauge.config },
{ w, h },
),
{
id: "gauge2",
type: "gauge",
title: "Boiler",
config: { message: "heating.boiler" },
layout: { lg: { x: 0, y: 5, w: 6, h: 3 } },
},
)
console.log("clipboard: ok")
@@ -0,0 +1,53 @@
import { useMemo, useSyncExternalStore } from "react"
import type { WidgetDef } from "@/client"
import { safeStorage } from "@/lib/safeStorage"
import { WIDGET_LABELS } from "./widgets"
/**
* One copied widget, waiting to be pasted.
*
* ponytail: localStorage, as with the flow editor's node clipboard, so a copy
* survives a reload and reaches every dashboard and tab of this instance. Its
* message references are flow-qualified, so it pastes anywhere here as-is.
*/
const CLIPBOARD_KEY = "fluksio.widgetClipboard"
// The `storage` event only reaches the other tabs, so this one is told here.
const listeners = new Set<() => void>()
/** What was stored, if it is still a widget this app draws. */
export function parseWidget(stored: string | null): WidgetDef | null {
if (!stored) return null
try {
const widget = JSON.parse(stored)
return Object.keys(WIDGET_LABELS).includes(widget?.type) ? widget : null
} catch {
return null
}
}
/** Replace the copy. False when the browser would not store it. */
export function copyWidget(widget: WidgetDef): boolean {
const stored = safeStorage.set(CLIPBOARD_KEY, JSON.stringify(widget))
for (const listener of listeners) listener()
return stored
}
function subscribe(listener: () => void) {
listeners.add(listener)
window.addEventListener("storage", listener)
return () => {
listeners.delete(listener)
window.removeEventListener("storage", listener)
}
}
/** The copied widget, kept current as any tab copies another. */
export function useCopiedWidget(): WidgetDef | null {
// The raw string is the snapshot: it compares by value, a parse would not.
const stored = useSyncExternalStore(subscribe, () =>
safeStorage.get(CLIPBOARD_KEY),
)
return useMemo(() => parseWidget(stored), [stored])
}
+23 -11
View File
@@ -495,6 +495,7 @@ export function WidgetPanel({
widget,
round,
onChange,
onCopy,
onDelete,
onClose,
}: {
@@ -502,6 +503,7 @@ export function WidgetPanel({
/** The dashboard is round: what only a round face draws is set here too. */
round?: boolean
onChange: (changes: Partial<WidgetDef>) => void
onCopy: () => void
onDelete: () => void
onClose: () => void
}) {
@@ -584,15 +586,25 @@ export function WidgetPanel({
/>
}
footer={
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={onDelete}
data-testid="delete-widget"
>
Remove widget
</Button>
<div className="flex items-center justify-between gap-2">
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={onDelete}
data-testid="delete-widget"
>
Remove widget
</Button>
<Button
variant="ghost"
size="sm"
onClick={onCopy}
data-testid="copy-widget"
>
Copy widget
</Button>
</div>
}
>
<div className="grid gap-5 p-4" data-testid="widget-settings">
@@ -1343,8 +1355,8 @@ export function WidgetPanel({
<InfoTip label="Counters">
Coarsest first, each a label and a step, then the range on the
first and a size on the others: how many of it make one of the
counter before it. Together they are one number in the last
one's unit, so hours and minutes send 07:30 as 450.
counter before it. Together they are one number in the last one's
unit, so hours and minutes send 07:30 as 450.
</InfoTip>
{counters.map((counter, index) => (
<div