From 11bace3c1bf5d84318ff5adef3208b0a07b95874 Mon Sep 17 00:00:00 2001 From: Nico Date: Mon, 15 Jun 2026 23:55:32 -0700 Subject: [PATCH] fix: support map undo shortcuts --- .../src/hooks/useLocationMapViewControls.js | 113 +++++++++++++----- frontend/src/pages/LocationMapManager.jsx | 3 + frontend/tests/location-map-manager.spec.ts | 17 +++ 3 files changed, 106 insertions(+), 27 deletions(-) diff --git a/frontend/src/hooks/useLocationMapViewControls.js b/frontend/src/hooks/useLocationMapViewControls.js index 60321ad..0d8c244 100644 --- a/frontend/src/hooks/useLocationMapViewControls.js +++ b/frontend/src/hooks/useLocationMapViewControls.js @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useRef } from "react"; import useMobileMapAutoFit from "./useMobileMapAutoFit"; 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({ filters, + future = [], hasUnsavedChanges, + history = [], mapSize, mapState, mode, objects, previewDraft, + saving, selectedObjectKey, setFuture, setHasUnsavedChanges, @@ -49,6 +56,13 @@ export default function useLocationMapViewControls({ syncMapDraftFromState, zoom, }) { + const shortcutControlsRef = useRef({ + handleRedo: () => {}, + handleUndo: () => {}, + mode: "view", + saving: false, + }); + useEffect(() => { if (!selectedObjectKey) return; @@ -68,41 +82,86 @@ export default function useLocationMapViewControls({ }); const handleUndo = useCallback(() => { - setHistory((previous) => { - if (previous.length === 0) return previous; - const priorSnapshot = getHistorySnapshot(previous[previous.length - 1], selectedObjectKey); - setFuture((nextFuture) => [ - createHistorySnapshot(objects, selectedObjectKey), - ...nextFuture.slice(0, 19), - ]); - setObjects(priorSnapshot.objects); - setSelectedObjectKey(priorSnapshot.selectedObjectKey); - setHasUnsavedChanges(previous.length > 1); - return previous.slice(0, -1); - }); + if (history.length === 0) return; + + const priorSnapshot = getHistorySnapshot(history[history.length - 1], selectedObjectKey); + setFuture([ + createHistorySnapshot(objects, selectedObjectKey), + ...future.slice(0, 19), + ]); + setObjects(priorSnapshot.objects); + setSelectedObjectKey(priorSnapshot.selectedObjectKey); + setHasUnsavedChanges(history.length > 1); + setHistory(history.slice(0, -1)); }, [ - objects, selectedObjectKey, setFuture, setHasUnsavedChanges, setHistory, + future, history, objects, selectedObjectKey, setFuture, setHasUnsavedChanges, setHistory, setObjects, setSelectedObjectKey, ]); const handleRedo = useCallback(() => { - setFuture((previous) => { - if (previous.length === 0) return previous; - const nextSnapshot = getHistorySnapshot(previous[0], selectedObjectKey); - setHistory((nextHistory) => [ - ...nextHistory.slice(-19), - createHistorySnapshot(objects, selectedObjectKey), - ]); - setObjects(nextSnapshot.objects); - setSelectedObjectKey(nextSnapshot.selectedObjectKey); - setHasUnsavedChanges(true); - return previous.slice(1); - }); + if (future.length === 0) return; + + const nextSnapshot = getHistorySnapshot(future[0], selectedObjectKey); + setHistory([ + ...history.slice(-19), + createHistorySnapshot(objects, selectedObjectKey), + ]); + setObjects(nextSnapshot.objects); + setSelectedObjectKey(nextSnapshot.selectedObjectKey); + setHasUnsavedChanges(true); + setFuture(future.slice(1)); }, [ - objects, selectedObjectKey, setFuture, setHasUnsavedChanges, setHistory, + future, history, objects, selectedObjectKey, setFuture, setHasUnsavedChanges, setHistory, 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(() => { setMode("view"); setLayersOpen(false); diff --git a/frontend/src/pages/LocationMapManager.jsx b/frontend/src/pages/LocationMapManager.jsx index c3a2cd8..f968988 100644 --- a/frontend/src/pages/LocationMapManager.jsx +++ b/frontend/src/pages/LocationMapManager.jsx @@ -185,12 +185,15 @@ export default function LocationMapManager() { handleViewMode, } = useLocationMapViewControls({ filters, + future, hasUnsavedChanges, + history, mapSize, mapState, mode, objects, previewDraft, + saving, selectedObjectKey, setFuture, setHasUnsavedChanges, diff --git a/frontend/tests/location-map-manager.spec.ts b/frontend/tests/location-map-manager.spec.ts index 1bf4257..e9ec1ff 100644 --- a/frontend/tests/location-map-manager.spec.ts +++ b/frontend/tests/location-map-manager.spec.ts @@ -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.getByRole("button", { name: "Save Draft" })).toBeEnabled(); 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 }) => {