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:
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user