osm undo option

This commit is contained in:
2026-05-14 16:11:09 +05:30
parent 0336dacf4b
commit 05187dd35a
2 changed files with 180 additions and 33 deletions

View File

@@ -381,16 +381,19 @@ function AppShell({
}, [editingRoadId, osmEditMode]);
useEffect(() => {
if (!osmEditMode) {
// Clear transient visual selection. Hidden roads and the undo ring
// buffer survive exit/re-entry so the user can pop into the layer to
// sanity-check elsewhere and come back without losing their work.
setSelectedRoadIds(new Set());
setHiddenRoadIds(new Set());
setOsmUndoStack([]);
}
}, [osmEditMode]);
// Per-session OSM edit undo: ring buffer (last 3) covering flip / simplify
// / vertex-delete / drag-vertex / snap-ignore toggle, plus client-only hide
// and restore. Cleared on exit OSM edit mode. Merges aren't undoable here
// (they delete source rows; this ring buffer doesn't recreate them).
// and restore. Persists across OSM-mode toggles within a session so users
// can exit/re-enter without losing their last few edits. Merges aren't
// undoable here (they delete source rows; this ring buffer doesn't
// recreate them).
type OsmRoadSnap = {
id: number;
name: string | null;
@@ -594,6 +597,14 @@ function AppShell({
useEffect(() => {
window.localStorage.setItem(ROAD_OFFSET_KEY, String(roadOffsetMeters));
}, [roadOffsetMeters]);
// Per-road lane-offset overrides. The slider above sets the default; when
// one or more roads are selected, the slider writes an override into this
// map so each road's parallels can sit at its own width without dragging
// the rest of the network with it. In-memory only — overrides reset across
// reloads, but the default persists.
const [roadOffsetById, setRoadOffsetById] = useState<Map<number, number>>(
() => new Map(),
);
useEffect(() => {
const w = POPUP_WIDTH_PX[popupSize];
@@ -1070,6 +1081,18 @@ function AppShell({
.catch((err) => setStatus(`Unlink failed: ${err}`));
return;
}
// 'O' / 'o' toggles OSM edit mode. Ignored while typing in inputs.
if (e.key === "o" || e.key === "O") {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) {
return;
}
if (e.metaKey || e.ctrlKey || e.altKey) return;
e.preventDefault();
toggleOsmEditModeRef.current();
return;
}
if (e.key !== "Escape") return;
if (lensActive) {
e.preventDefault();
@@ -1420,7 +1443,13 @@ function AppShell({
async function refreshOsmRoads() {
const list = await invoke<
Array<{ id: number; name: string | null; geojson: string; snap_ignored?: boolean }>
Array<{
id: number;
name: string | null;
geojson: string;
snap_ignored?: boolean;
modified?: boolean;
}>
>("get_osm_roads");
let mnLat = Infinity;
let mxLat = -Infinity;
@@ -1455,6 +1484,7 @@ function AppShell({
id: r.id,
name: r.name,
snap_ignored: r.snap_ignored === true,
modified: r.modified === true,
},
};
});
@@ -1701,6 +1731,11 @@ function AppShell({
setStatus("OSM edit mode: click a road to edit its vertices.");
}
}
// Ref so the keyboard handler always sees the latest closure (otherwise
// toggling via 'O' would capture stale layerVisibility / osmEditMode from
// whichever render the keydown effect last re-ran on).
const toggleOsmEditModeRef = useRef(toggleOsmEditMode);
toggleOsmEditModeRef.current = toggleOsmEditMode;
function startDrawRoad() {
setDrawRoadCoords([]);
@@ -2150,7 +2185,7 @@ function AppShell({
const ok = window.confirm(
`Bulk-delete OSM road sections inside this lasso?\n\n` +
`Roads fully inside are removed; roads partially inside are clipped at the lasso boundary — the inside portion is dropped, the outside portion(s) survive as separate roads. ` +
`This is NOT covered by the OSM Undo (the ring buffer doesn't recreate deleted rows).`,
`Covered by OSM Undo: the original rows are restored and the split-out rows are deleted on undo.`,
);
if (!ok) {
setLassoMode(false);
@@ -2162,11 +2197,23 @@ function AppShell({
roads_deleted: number;
roads_split: number;
new_road_ids: number[];
before_snapshots: OsmRoadSnap[];
}>("delete_roads_in_lasso", { polygon })
.then(async (r) => {
setStatus(
`Lasso roads: examined ${r.roads_examined}, fully deleted ${r.roads_deleted}, split ${r.roads_split} (→ ${r.new_road_ids.length} new sub-road(s)).`,
);
// Push to the OSM undo ring: restore_road_state upserts the
// snapshots (re-inserts deleted rows by id) and the createdIds
// path drops the split-out children before that runs.
if (r.before_snapshots.length > 0 || r.new_road_ids.length > 0) {
pushOsmUndo({
kind: "restore-roads",
snapshots: r.before_snapshots,
createdIds: r.new_road_ids,
label: "lasso road delete",
});
}
// The currently-edited / selected / marked road(s) may have been
// deleted or split — drop those UI references so the next click
// doesn't act on a stale id.
@@ -3276,6 +3323,7 @@ function AppShell({
}}
onUpdateRoad={handleUpdateRoad}
roadOffsetMeters={roadOffsetMeters}
roadOffsetById={roadOffsetById}
drawRoadMode={drawRoadMode}
drawRoadCoords={drawRoadCoords}
onDrawRoadAddPoint={handleDrawRoadAddPoint}
@@ -4903,23 +4951,70 @@ function AppShell({
)}
</>
)}
{osmEditMode && (
<label
className="field"
style={{ marginTop: 6, display: "block" }}
>
<span>Lane offset (m): {roadOffsetMeters.toFixed(1)}</span>
<input
type="range"
min={0.1}
max={30}
step={0.1}
value={roadOffsetMeters}
onChange={(e) => setRoadOffsetMeters(parseFloat(e.target.value))}
style={{ width: "100%" }}
/>
</label>
)}
{osmEditMode && (() => {
// Roads the slider should affect: editing road + multi-selected
// set. Empty → slider edits the default for all roads.
const sliderIds = new Set<number>(selectedRoadIds);
if (editingRoadId !== null) sliderIds.add(editingRoadId);
const hasSelection = sliderIds.size > 0;
// Effective value to display. With a selection, prefer the first
// selected road's override (falling back to default). Without one,
// show the default.
const firstId = hasSelection
? (Array.from(sliderIds)[0] as number)
: null;
const effective =
firstId !== null
? (roadOffsetById.get(firstId) ?? roadOffsetMeters)
: roadOffsetMeters;
return (
<label
className="field"
style={{ marginTop: 6, display: "block" }}
>
<span>
Lane offset (m){hasSelection ? ` [${sliderIds.size} selected]` : " [default]"}
: {effective.toFixed(1)}
</span>
<input
type="range"
min={0.1}
max={30}
step={0.1}
value={effective}
onChange={(e) => {
const v = parseFloat(e.target.value);
if (!hasSelection) {
setRoadOffsetMeters(v);
return;
}
setRoadOffsetById((prev) => {
const next = new Map(prev);
for (const id of sliderIds) next.set(id, v);
return next;
});
}}
style={{ width: "100%" }}
/>
{hasSelection && (
<button
className="primary-btn"
style={{ marginTop: 4, background: "#7f8c8d", fontSize: 11 }}
onClick={() => {
setRoadOffsetById((prev) => {
const next = new Map(prev);
for (const id of sliderIds) next.delete(id);
return next;
});
}}
title="Drop the per-road override on the selected road(s) so they follow the default again."
>
Reset selected to default ({roadOffsetMeters.toFixed(1)} m)
</button>
)}
</label>
);
})()}
<button
className="primary-btn"
style={{ marginTop: 6, background: "#1abc9c" }}