diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index 8fb9fb3..dad99a5 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -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**. diff --git a/frontend/src/components/Dashboard/DashboardEditor.tsx b/frontend/src/components/Dashboard/DashboardEditor.tsx index 33fac94..39a5796 100644 --- a/frontend/src/components/Dashboard/DashboardEditor.tsx +++ b/frontend/src/components/Dashboard/DashboardEditor.tsx @@ -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, + 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 | 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({ Add a widget (⌘P) + {copied ? ( + + + + + + Paste {copied.title || WIDGET_LABELS[copied.type]} (⌘V) + + + ) : null} + +
+ + +
} >
@@ -1343,8 +1355,8 @@ export function WidgetPanel({ 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. {counters.map((counter, index) => (