Dashboard editor undo: ⌘Z / ⇧⌘Z over a 50-entry history of commits, folding keystrokes and the grid's own correction as the flow editor does
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WgBfkm4rCmtd1T4vHWjEPx
This commit is contained in:
@@ -288,6 +288,41 @@ const scaledStrategy = (scale: number) => ({
|
||||
calcStyle: setTransform as (pos: Position) => CSSProperties,
|
||||
})
|
||||
|
||||
/** Dashboards to step back and forward through, newest last. */
|
||||
type History = {
|
||||
past: Dashboard[]
|
||||
future: Dashboard[]
|
||||
/** When the last entry was recorded, so a burst of typing stays one edit. */
|
||||
at: number
|
||||
}
|
||||
|
||||
const HISTORY_LIMIT = 50
|
||||
const TYPING_WINDOW = 500
|
||||
|
||||
/**
|
||||
* Remember the dashboard as it was before a change.
|
||||
*
|
||||
* Fields commit on every keystroke, and the grid writes back its own
|
||||
* correction a moment after a widget comes or goes, so consecutive edits that
|
||||
* leave the same widgets in place fold into the entry already on the stack.
|
||||
* Adding, removing or reordering a widget is a finished action and starts its
|
||||
* own.
|
||||
*/
|
||||
function record(history: History, previous: Dashboard, next: Dashboard) {
|
||||
const before = widgetsOf(previous)
|
||||
const after = widgetsOf(next)
|
||||
const sameWidgets =
|
||||
before.length === after.length &&
|
||||
before.every((widget, index) => widget.id === after[index].id)
|
||||
if (!sameWidgets || Date.now() - history.at >= TYPING_WINDOW) {
|
||||
history.past.push(previous)
|
||||
if (history.past.length > HISTORY_LIMIT) history.past.shift()
|
||||
}
|
||||
history.at = Date.now()
|
||||
// A new change is a new branch: what was undone is not coming back.
|
||||
history.future = []
|
||||
}
|
||||
|
||||
const same = (a: Layout, b: Layout) =>
|
||||
a.length === b.length &&
|
||||
a.every((item, index) => {
|
||||
@@ -349,6 +384,7 @@ export function DashboardEditor({
|
||||
// The saved version is what the next save is based on; without following it
|
||||
// the second save of a session is always a conflict.
|
||||
const version = useRef(dashboard.version)
|
||||
const history = useRef<History>({ past: [], future: [], at: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
version.current = dashboard.version
|
||||
@@ -369,7 +405,8 @@ export function DashboardEditor({
|
||||
return request
|
||||
}
|
||||
|
||||
const commit = (next: Dashboard) => {
|
||||
/** Put a dashboard on the canvas and on its way to the server. */
|
||||
const apply = (next: Dashboard) => {
|
||||
setDraft(next)
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
timer.current = setTimeout(() => {
|
||||
@@ -378,6 +415,25 @@ export function DashboardEditor({
|
||||
}, AUTOSAVE_MS)
|
||||
}
|
||||
|
||||
/** Apply a change and make it undoable. */
|
||||
const commit = (next: Dashboard) => {
|
||||
record(history.current, draft, next)
|
||||
apply(next)
|
||||
}
|
||||
|
||||
/**
|
||||
* Step through the history. Undo and redo save like any other edit, so the
|
||||
* autosave debounce collapses a run of them into one write.
|
||||
*/
|
||||
const step = (back: boolean) => {
|
||||
const { past, future } = history.current
|
||||
const remembered = (back ? past : future).pop()
|
||||
if (!remembered) return
|
||||
;(back ? future : past).push(draft)
|
||||
history.current.at = 0
|
||||
apply(remembered)
|
||||
}
|
||||
|
||||
/** Send what is queued and wait for the server to have it. */
|
||||
const flush = async () => {
|
||||
if (timer.current) {
|
||||
@@ -439,10 +495,13 @@ export function DashboardEditor({
|
||||
setSettingsOpen(true)
|
||||
}
|
||||
|
||||
// As on the flow canvas, and reachable while typing for the same reasons.
|
||||
// Outside edit mode both keys stay the browser's.
|
||||
// As on the flow canvas: undo and redo step aside while typing, since a
|
||||
// field keeps its own, and ⌘P and ⌘S stay reachable for the same reasons.
|
||||
// Outside edit mode every key stays the browser's.
|
||||
useShortcuts(
|
||||
{
|
||||
"mod+z": () => (edit ? step(true) : false),
|
||||
"mod+shift+z": () => (edit ? step(false) : false),
|
||||
"mod+p": () => (edit ? setPaletteOpen((open) => !open) : false),
|
||||
// An edit still waiting for its autosave counts: publishing flushes it.
|
||||
"mod+s": () => {
|
||||
|
||||
Reference in New Issue
Block a user