shortcuts
This commit is contained in:
220
src/App.tsx
220
src/App.tsx
@@ -6,6 +6,7 @@ const IMAGE_FOLDER_KEY = "kml_map_tool.image_folder";
|
||||
const POPUP_SIZE_KEY = "kml_map_tool.popup_size";
|
||||
const POPUP_ENABLED_KEY = "kml_map_tool.popup_enabled";
|
||||
const BBOX_MODE_KEY = "kml_map_tool.bbox_mode";
|
||||
const BBOX_OVERLAY_KEY = "kml_map_tool.bbox_overlay";
|
||||
const POPUP_ASPECT_KEY = "kml_map_tool.popup_aspect";
|
||||
const QUALITY_OFF_TRACK_KEY = "kml_map_tool.quality_off_track_m";
|
||||
const PERF_MODE_KEY = "kml_map_tool.perf_mode";
|
||||
@@ -311,6 +312,16 @@ function AppShell({
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(BBOX_MODE_KEY, bboxMode ? "1" : "0");
|
||||
}, [bboxMode]);
|
||||
// Independent of edit mode — whether the bbox rectangle/label is shown on
|
||||
// the popup image at all. Default ON so users see annotations immediately.
|
||||
const [bboxOverlay, setBboxOverlay] = useState<boolean>(() => {
|
||||
if (typeof window === "undefined") return true;
|
||||
const v = window.localStorage.getItem(BBOX_OVERLAY_KEY);
|
||||
return v === null ? true : v === "1";
|
||||
});
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(BBOX_OVERLAY_KEY, bboxOverlay ? "1" : "0");
|
||||
}, [bboxOverlay]);
|
||||
const [popupAspect, setPopupAspect] = useState<PopupAspect>(() => {
|
||||
if (typeof window === "undefined") return "4:3";
|
||||
const v = window.localStorage.getItem(POPUP_ASPECT_KEY) as PopupAspect | null;
|
||||
@@ -573,6 +584,21 @@ function AppShell({
|
||||
null | { ids: number[]; current: string }
|
||||
>(null);
|
||||
const [renameInput, setRenameInput] = useState<string>("");
|
||||
// Auto-dismissing toast for transient confirmations (delete, rename, side
|
||||
// flip). Separate from the persistent status bar at the top — toasts
|
||||
// float at the bottom and disappear after ~2s.
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
const toastTimerRef = useRef<number | null>(null);
|
||||
const showToast = (text: string) => {
|
||||
setToast(text);
|
||||
if (toastTimerRef.current !== null) {
|
||||
window.clearTimeout(toastTimerRef.current);
|
||||
}
|
||||
toastTimerRef.current = window.setTimeout(() => {
|
||||
setToast(null);
|
||||
toastTimerRef.current = null;
|
||||
}, 2200);
|
||||
};
|
||||
const [osmToolsOpen, setOsmToolsOpen] = useState<boolean>(false);
|
||||
const [importsOpen, setImportsOpen] = useState<boolean>(false);
|
||||
const [osmToolPrune, setOsmToolPrune] = useState<boolean>(true);
|
||||
@@ -994,9 +1020,11 @@ function AppShell({
|
||||
try {
|
||||
await invoke("update_asset_position", { id, vertex, lat, lng });
|
||||
setStatus(`Moved ${vertex} to ${lat.toFixed(6)}, ${lng.toFixed(6)}`);
|
||||
showToast(`Moved ${vertex} point`);
|
||||
await refreshAfterMutation();
|
||||
} catch (e) {
|
||||
setStatus(`Move failed: ${e}`);
|
||||
showToast(`Move failed`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1123,6 +1151,63 @@ function AppShell({
|
||||
toggleOsmEditModeRef.current();
|
||||
return;
|
||||
}
|
||||
// 'R' / 'r' opens the rename modal for the currently selected asset.
|
||||
// Input-guard pattern matches the other shortcuts.
|
||||
if (e.key === "r" || e.key === "R") {
|
||||
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;
|
||||
if (!selectedAsset) return;
|
||||
e.preventDefault();
|
||||
setRenameOpen({
|
||||
ids: [selectedAsset.id],
|
||||
current: selectedAsset.asset_name ?? "",
|
||||
});
|
||||
setRenameInput(selectedAsset.asset_name ?? "");
|
||||
return;
|
||||
}
|
||||
// Delete / Backspace: when an asset is selected and we're not in any
|
||||
// edit mode that already claims those keys (OSM vertex/road delete,
|
||||
// link deletion handled above), delete the asset and toast.
|
||||
if (
|
||||
(e.key === "Delete" || e.key === "Backspace") &&
|
||||
selectedAsset &&
|
||||
!osmEditMode &&
|
||||
!selectedLink
|
||||
) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const tag = target?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
const label = `${selectedAsset.asset_name} · ${selectedAsset.row_id}`;
|
||||
(async () => {
|
||||
try {
|
||||
await invoke("delete_asset", { id: selectedAsset.id });
|
||||
setSelectedAssetId(null);
|
||||
// Surgical local update — drop the row instead of refetching
|
||||
// everything, so the rest of the UI stays put.
|
||||
const deletedId = selectedAsset.id;
|
||||
setAssets((prev) =>
|
||||
prev.map((a) =>
|
||||
a.id === deletedId ? { ...a, deleted: 1 } : a,
|
||||
),
|
||||
);
|
||||
showToast(`Deleted ${label}`);
|
||||
void Promise.all([
|
||||
refreshActions().catch(() => {}),
|
||||
refreshDataCounts().catch(() => {}),
|
||||
]);
|
||||
} catch (err) {
|
||||
showToast(`Delete failed: ${err}`);
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
// 'B' / 'b' toggles bbox editing. No-op when popups are disabled.
|
||||
if (e.key === "b" || e.key === "B") {
|
||||
const target = e.target as HTMLElement | null;
|
||||
@@ -1209,7 +1294,7 @@ function AppShell({
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [drawRoadMode, editingRoadId, linkPickMode, rightClickFirstId, lassoMode, selectedLink, selectedMetadataVideo, gotoPin, osmEditMode, markedVertices, focusedVideo, selectedRoadIds, lensActive]);
|
||||
}, [drawRoadMode, editingRoadId, linkPickMode, rightClickFirstId, lassoMode, selectedLink, selectedMetadataVideo, gotoPin, osmEditMode, markedVertices, focusedVideo, selectedRoadIds, lensActive, selectedAsset, popupEnabled]);
|
||||
|
||||
// Image prefetch: when an asset is selected, warm the browser cache for its
|
||||
// immediate neighbours so [/] navigation feels instant.
|
||||
@@ -1309,15 +1394,39 @@ function AppShell({
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [selectedAsset, viewport.center]);
|
||||
|
||||
// Set an asset's side. Used both by the popup flip button and the auto-flip
|
||||
// from bbox editing (when the bbox center is in the left vs right half of
|
||||
// the image). Updates the local assets array in place so the popup tag
|
||||
// and any downstream UI refresh without a full re-fetch.
|
||||
async function handleSideChange(assetId: number, side: "Left" | "Right") {
|
||||
const cur = assets.find((a) => a.id === assetId);
|
||||
if (cur && cur.side === side) return;
|
||||
try {
|
||||
await invoke<number>("change_assets_side", {
|
||||
ids: [assetId],
|
||||
side,
|
||||
});
|
||||
setAssets((prev) =>
|
||||
prev.map((a) => (a.id === assetId ? { ...a, side } : a)),
|
||||
);
|
||||
showToast(`Side → ${side}`);
|
||||
} catch (e) {
|
||||
showToast(`Side change failed: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteSelected() {
|
||||
if (!selectedAsset) return;
|
||||
const label = `${selectedAsset.asset_name} · ${selectedAsset.row_id}`;
|
||||
try {
|
||||
await invoke("delete_asset", { id: selectedAsset.id });
|
||||
setStatus(`Deleted ${selectedAsset.asset_name} · ${selectedAsset.row_id}`);
|
||||
setStatus(`Deleted ${label}`);
|
||||
showToast(`Deleted ${label}`);
|
||||
setSelectedAssetId(null);
|
||||
await refreshAfterMutation();
|
||||
} catch (e) {
|
||||
setStatus(`Delete failed: ${e}`);
|
||||
showToast(`Delete failed: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1345,13 +1454,16 @@ function AppShell({
|
||||
});
|
||||
if (count === 0) {
|
||||
setStatus(`No live assets matched video "${name}"`);
|
||||
showToast(`No matches for "${name}"`);
|
||||
} else {
|
||||
setStatus(`Deleted ${count} assets from video "${name}"`);
|
||||
showToast(`Deleted ${count} assets in "${name}"`);
|
||||
setBulkVideoName("");
|
||||
}
|
||||
await refreshAfterMutation();
|
||||
} catch (e) {
|
||||
setStatus(`Bulk delete failed: ${e}`);
|
||||
showToast(`Bulk delete failed`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1406,8 +1518,10 @@ function AppShell({
|
||||
refreshAssetNames().catch(() => {}),
|
||||
]);
|
||||
setStatus("Database reset.");
|
||||
showToast("Database reset");
|
||||
} catch (e) {
|
||||
setStatus(`Reset failed: ${e}`);
|
||||
showToast(`Reset failed`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -1417,9 +1531,11 @@ function AppShell({
|
||||
try {
|
||||
await invoke("undo_action", { actionId });
|
||||
setStatus(`Undone action #${actionId}`);
|
||||
showToast(`Undone #${actionId}`);
|
||||
await refreshAfterMutation();
|
||||
} catch (e) {
|
||||
setStatus(`Undo failed: ${e}`);
|
||||
showToast(`Undo failed`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1427,9 +1543,11 @@ function AppShell({
|
||||
try {
|
||||
await invoke("redo_action", { actionId });
|
||||
setStatus(`Redone action #${actionId}`);
|
||||
showToast(`Redone #${actionId}`);
|
||||
await refreshAfterMutation();
|
||||
} catch (e) {
|
||||
setStatus(`Redo failed: ${e}`);
|
||||
showToast(`Redo failed`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1460,12 +1578,16 @@ function AppShell({
|
||||
setStatus(
|
||||
`Merged ${mergeSecondary.row_id} into ${mergePrimary.row_id}`,
|
||||
);
|
||||
showToast(
|
||||
`Merged ${mergeSecondary.row_id} → ${mergePrimary.row_id}`,
|
||||
);
|
||||
setMergePrimary(null);
|
||||
setMergeSecondary(null);
|
||||
setSelectedAssetId(null);
|
||||
await refreshAfterMutation();
|
||||
} catch (e) {
|
||||
setStatus(`Merge failed: ${e}`);
|
||||
showToast(`Merge failed`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1917,8 +2039,10 @@ function AppShell({
|
||||
setStatus(
|
||||
`Created road #${newId} with ${drawRoadCoords.length} vertices.`,
|
||||
);
|
||||
showToast(`Created road #${newId}`);
|
||||
} catch (e) {
|
||||
setStatus(`Create road failed: ${e}`);
|
||||
showToast(`Create road failed`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1926,14 +2050,17 @@ function AppShell({
|
||||
if (editingRoadId === null) return;
|
||||
const ok = window.confirm(`Delete road #${editingRoadId}?`);
|
||||
if (!ok) return;
|
||||
const roadId = editingRoadId;
|
||||
try {
|
||||
await invoke("delete_osm_road", { id: editingRoadId });
|
||||
await invoke("delete_osm_road", { id: roadId });
|
||||
await refreshOsmRoads();
|
||||
await refreshDataCounts();
|
||||
setEditingRoadId(null);
|
||||
setStatus(`Deleted road #${editingRoadId}.`);
|
||||
setStatus(`Deleted road #${roadId}.`);
|
||||
showToast(`Deleted road #${roadId}`);
|
||||
} catch (e) {
|
||||
setStatus(`Delete failed: ${e}`);
|
||||
showToast(`Delete road failed`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2021,9 +2148,11 @@ function AppShell({
|
||||
);
|
||||
} else {
|
||||
setStatus(`Loaded ${count} road segment(s)`);
|
||||
showToast(`Loaded ${count} road segment(s)`);
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus(`OSM import failed: ${e}`);
|
||||
showToast(`OSM import failed`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -3057,8 +3186,10 @@ function AppShell({
|
||||
await fetchBboxAssets(boundsRef.current);
|
||||
}
|
||||
setStatus(`Loaded ${count} scope polygon(s)`);
|
||||
showToast(`Loaded ${count} scope polygon(s)`);
|
||||
} catch (e) {
|
||||
setStatus(`KML import failed: ${e}`);
|
||||
showToast(`KML import failed`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -3146,6 +3277,7 @@ function AppShell({
|
||||
{ path: picked },
|
||||
);
|
||||
setStatus(`Imported ${r.fixed} fixed + ${r.range} range = ${r.fixed + r.range} asset(s)`);
|
||||
showToast(`Imported ${r.fixed + r.range} asset(s)`);
|
||||
await Promise.all([
|
||||
refreshVideoNames(),
|
||||
refreshVideoEntries(),
|
||||
@@ -3189,6 +3321,7 @@ function AppShell({
|
||||
kind === "fixed" ? "import_fixed_assets" : "import_range_assets";
|
||||
const count = await invoke<number>(cmd, { path: picked });
|
||||
setStatus(`Imported ${count} ${kind}`);
|
||||
showToast(`Imported ${count} ${kind} asset(s)`);
|
||||
|
||||
// Pull in the new video / class names so the filter auto-includes them.
|
||||
await Promise.all([
|
||||
@@ -3428,6 +3561,8 @@ function AppShell({
|
||||
imageFolder={imageFolder}
|
||||
popupEnabled={popupEnabled}
|
||||
bboxMode={bboxMode}
|
||||
bboxOverlay={bboxOverlay}
|
||||
onSideChange={handleSideChange}
|
||||
perfMode={perfMode}
|
||||
anchorIds={Array.from(anchors.keys())}
|
||||
colorBy={colorBy}
|
||||
@@ -3551,6 +3686,8 @@ function AppShell({
|
||||
imageFolder={imageFolder}
|
||||
popupEnabled={popupEnabled}
|
||||
bboxMode={bboxMode}
|
||||
bboxOverlay={bboxOverlay}
|
||||
onSideChange={handleSideChange}
|
||||
perfMode={perfMode}
|
||||
anchorIds={Array.from(anchors.keys())}
|
||||
colorBy={
|
||||
@@ -3653,6 +3790,31 @@ function AppShell({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{toast !== null && (
|
||||
<div
|
||||
// Bottom-center transient confirmation. Fixed position so it
|
||||
// overlays everything; pointerEvents:none so it never blocks
|
||||
// clicks on whatever's behind it.
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: "50%",
|
||||
bottom: 24,
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 9999,
|
||||
background: "rgba(44, 62, 80, 0.95)",
|
||||
color: "#fff",
|
||||
padding: "8px 16px",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.35)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="panel-toggle"
|
||||
onClick={() => setPanelOpen((v) => !v)}
|
||||
@@ -4152,7 +4314,8 @@ function AppShell({
|
||||
const name = renameInput.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
if (!classNames.includes(name)) {
|
||||
const isNewClass = !classNames.includes(name);
|
||||
if (isNewClass) {
|
||||
await invoke("add_class", { name });
|
||||
}
|
||||
await invoke<number>("rename_assets", {
|
||||
@@ -4162,9 +4325,35 @@ function AppShell({
|
||||
setStatus(
|
||||
`Renamed ${renameOpen.ids.length} asset(s) → ${name}`,
|
||||
);
|
||||
showToast(
|
||||
renameOpen.ids.length === 1
|
||||
? `Renamed → ${name}`
|
||||
: `Renamed ${renameOpen.ids.length} → ${name}`,
|
||||
);
|
||||
setRenameOpen(null);
|
||||
setRenameInput("");
|
||||
await refreshAfterMutation();
|
||||
// Surgical update so scroll position / selection / list
|
||||
// panels don't churn: just patch asset_name on the renamed
|
||||
// rows in the existing array, and append the new class
|
||||
// locally if there is one. The audit log / counts / video
|
||||
// panels are refreshed in the background — they don't
|
||||
// affect the user's place in the UI.
|
||||
const renamedIds = new Set(renameOpen.ids);
|
||||
setAssets((prev) =>
|
||||
prev.map((a) =>
|
||||
renamedIds.has(a.id) ? { ...a, asset_name: name } : a,
|
||||
),
|
||||
);
|
||||
if (isNewClass) {
|
||||
setClassNames((prev) =>
|
||||
prev.includes(name) ? prev : [...prev, name].sort(),
|
||||
);
|
||||
}
|
||||
void Promise.all([
|
||||
refreshActions().catch(() => {}),
|
||||
refreshAssetNames().catch(() => {}),
|
||||
refreshDataCounts().catch(() => {}),
|
||||
]);
|
||||
} catch (e) {
|
||||
setStatus(`Rename failed: ${e}`);
|
||||
}
|
||||
@@ -4423,6 +4612,23 @@ function AppShell({
|
||||
/>
|
||||
Show image popup when an asset is selected
|
||||
</label>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
marginTop: 6,
|
||||
}}
|
||||
title="Render the saved bounding box on top of the image. Read-only by itself — flip BBox mode below to edit."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bboxOverlay}
|
||||
onChange={(e) => setBboxOverlay(e.target.checked)}
|
||||
disabled={!popupEnabled}
|
||||
/>
|
||||
Show bbox overlay on images
|
||||
</label>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -4436,7 +4642,7 @@ function AppShell({
|
||||
type="checkbox"
|
||||
checked={bboxMode}
|
||||
onChange={(e) => setBboxMode(e.target.checked)}
|
||||
disabled={!popupEnabled}
|
||||
disabled={!popupEnabled || !bboxOverlay}
|
||||
/>
|
||||
BBox mode (edit bounding boxes on images)
|
||||
</label>
|
||||
|
||||
Reference in New Issue
Block a user