fix: support map undo shortcuts

This commit is contained in:
Nico 2026-06-15 23:55:32 -07:00
parent ff68413e62
commit 11bace3c1b
3 changed files with 106 additions and 27 deletions

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect, useRef } from "react";
import useMobileMapAutoFit from "./useMobileMapAutoFit"; import useMobileMapAutoFit from "./useMobileMapAutoFit";
import { getObjectKey } from "../lib/locationMapUtils"; import { getObjectKey } from "../lib/locationMapUtils";
@ -27,14 +27,21 @@ function createHistorySnapshot(objects, selectedObjectKey) {
}; };
} }
function isTextEditingTarget(target) {
return Boolean(target?.closest?.("input, textarea, select, [contenteditable]"));
}
export default function useLocationMapViewControls({ export default function useLocationMapViewControls({
filters, filters,
future = [],
hasUnsavedChanges, hasUnsavedChanges,
history = [],
mapSize, mapSize,
mapState, mapState,
mode, mode,
objects, objects,
previewDraft, previewDraft,
saving,
selectedObjectKey, selectedObjectKey,
setFuture, setFuture,
setHasUnsavedChanges, setHasUnsavedChanges,
@ -49,6 +56,13 @@ export default function useLocationMapViewControls({
syncMapDraftFromState, syncMapDraftFromState,
zoom, zoom,
}) { }) {
const shortcutControlsRef = useRef({
handleRedo: () => {},
handleUndo: () => {},
mode: "view",
saving: false,
});
useEffect(() => { useEffect(() => {
if (!selectedObjectKey) return; if (!selectedObjectKey) return;
@ -68,41 +82,86 @@ export default function useLocationMapViewControls({
}); });
const handleUndo = useCallback(() => { const handleUndo = useCallback(() => {
setHistory((previous) => { if (history.length === 0) return;
if (previous.length === 0) return previous;
const priorSnapshot = getHistorySnapshot(previous[previous.length - 1], selectedObjectKey); const priorSnapshot = getHistorySnapshot(history[history.length - 1], selectedObjectKey);
setFuture((nextFuture) => [ setFuture([
createHistorySnapshot(objects, selectedObjectKey), createHistorySnapshot(objects, selectedObjectKey),
...nextFuture.slice(0, 19), ...future.slice(0, 19),
]); ]);
setObjects(priorSnapshot.objects); setObjects(priorSnapshot.objects);
setSelectedObjectKey(priorSnapshot.selectedObjectKey); setSelectedObjectKey(priorSnapshot.selectedObjectKey);
setHasUnsavedChanges(previous.length > 1); setHasUnsavedChanges(history.length > 1);
return previous.slice(0, -1); setHistory(history.slice(0, -1));
});
}, [ }, [
objects, selectedObjectKey, setFuture, setHasUnsavedChanges, setHistory, future, history, objects, selectedObjectKey, setFuture, setHasUnsavedChanges, setHistory,
setObjects, setSelectedObjectKey, setObjects, setSelectedObjectKey,
]); ]);
const handleRedo = useCallback(() => { const handleRedo = useCallback(() => {
setFuture((previous) => { if (future.length === 0) return;
if (previous.length === 0) return previous;
const nextSnapshot = getHistorySnapshot(previous[0], selectedObjectKey); const nextSnapshot = getHistorySnapshot(future[0], selectedObjectKey);
setHistory((nextHistory) => [ setHistory([
...nextHistory.slice(-19), ...history.slice(-19),
createHistorySnapshot(objects, selectedObjectKey), createHistorySnapshot(objects, selectedObjectKey),
]); ]);
setObjects(nextSnapshot.objects); setObjects(nextSnapshot.objects);
setSelectedObjectKey(nextSnapshot.selectedObjectKey); setSelectedObjectKey(nextSnapshot.selectedObjectKey);
setHasUnsavedChanges(true); setHasUnsavedChanges(true);
return previous.slice(1); setFuture(future.slice(1));
});
}, [ }, [
objects, selectedObjectKey, setFuture, setHasUnsavedChanges, setHistory, future, history, objects, selectedObjectKey, setFuture, setHasUnsavedChanges, setHistory,
setObjects, setSelectedObjectKey, setObjects, setSelectedObjectKey,
]); ]);
shortcutControlsRef.current = {
handleRedo,
handleUndo,
mode,
saving,
};
useEffect(() => {
if (typeof window === "undefined") {
return undefined;
}
const handleKeyDown = (event) => {
if (
event.defaultPrevented ||
event.altKey ||
!(event.ctrlKey || event.metaKey) ||
isTextEditingTarget(event.target)
) {
return;
}
const controls = shortcutControlsRef.current;
if (controls.mode !== "edit" || controls.saving) {
return;
}
const key = event.key.toLowerCase();
const wantsUndo = key === "z" && !event.shiftKey;
const wantsRedo = key === "y" || (key === "z" && event.shiftKey);
if (wantsUndo) {
event.preventDefault();
controls.handleUndo();
return;
}
if (wantsRedo) {
event.preventDefault();
controls.handleRedo();
}
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, []);
const handlePreviewDraft = useCallback(() => { const handlePreviewDraft = useCallback(() => {
setMode("view"); setMode("view");
setLayersOpen(false); setLayersOpen(false);

View File

@ -185,12 +185,15 @@ export default function LocationMapManager() {
handleViewMode, handleViewMode,
} = useLocationMapViewControls({ } = useLocationMapViewControls({
filters, filters,
future,
hasUnsavedChanges, hasUnsavedChanges,
history,
mapSize, mapSize,
mapState, mapState,
mode, mode,
objects, objects,
previewDraft, previewDraft,
saving,
selectedObjectKey, selectedObjectKey,
setFuture, setFuture,
setHasUnsavedChanges, setHasUnsavedChanges,

View File

@ -1806,6 +1806,23 @@ test("admin undoing the only draft change clears unsaved state", async ({ page }
await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft"); await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
await expect(page.getByRole("button", { name: "Save Draft" })).toBeEnabled(); await expect(page.getByRole("button", { name: "Save Draft" })).toBeEnabled();
await expect(page.getByLabel("Linked zone")).toHaveValue(""); await expect(page.getByLabel("Linked zone")).toHaveValue("");
await page.getByRole("button", { name: "Undo" }).focus();
await page.keyboard.down("Control");
await page.keyboard.press("KeyZ");
await page.keyboard.up("Control");
await expect(page.locator(".location-map-status")).toHaveText("Draft");
await expect(page.getByRole("button", { name: "Save Draft" })).toHaveCount(0);
await expect(page.getByLabel("Linked zone")).toHaveValue("501");
await expect(page.getByRole("button", { name: "Redo" })).toBeEnabled();
await page.getByRole("button", { name: "Redo" }).focus();
await page.keyboard.down("Control");
await page.keyboard.press("KeyY");
await page.keyboard.up("Control");
await expect(page.locator(".location-map-status")).toHaveText("Unsaved Draft");
await expect(page.getByRole("button", { name: "Save Draft" })).toBeEnabled();
await expect(page.getByLabel("Linked zone")).toHaveValue("");
}); });
test("admin can recover hidden map areas from the empty canvas", async ({ page }) => { test("admin can recover hidden map areas from the empty canvas", async ({ page }) => {