diff --git a/src/App.tsx b/src/App.tsx index 86289e9..681cbf6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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>( + () => 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 && ( - - )} + {osmEditMode && (() => { + // Roads the slider should affect: editing road + multi-selected + // set. Empty → slider edits the default for all roads. + const sliderIds = new Set(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 ( + + ); + })()}