multi slct del

This commit is contained in:
2026-05-26 15:34:06 +05:30
parent b99d02e29a
commit f83724951f
4 changed files with 269 additions and 43 deletions

7
CLAUDE.md Normal file
View File

@@ -0,0 +1,7 @@
| Principle | Addresses |
|-----------|-----------|
| **Think Before Coding** | Wrong assumptions, hidden confusion, missing tradeoffs |
| **Simplicity First** | Overcomplication, bloated abstractions |
| **Surgical Changes** | Orthogonal edits, touching code you shouldn't |
| **Goal-Driven Execution** | Leverage through tests-first, verifiable success criteria |

View File

@@ -2109,14 +2109,45 @@ pub async fn export_assets(
"source" => {
// Round-trippable JSON in the same shape import_*_assets accept.
// Fixed and range objects are written into separate top-level keys
// so the user can re-feed each via the matching importer. Includes
// deleted=true rows so the audit state survives a round-trip.
// so the user can re-feed each via the matching importer. BBox
// fields are merged inline using the same prefixed-key convention
// the importer parses (`bbox_*` for fixed, `start_bbox_*` /
// `mid_bbox_*` / `end_bbox_*` for range) so a re-import preserves
// every bbox the user drew.
let bbox_rows: Vec<(i64, String, f64, f64, f64, f64)> =
sqlx::query_as(
"SELECT b.asset_id, b.slot, b.x1, b.y1, b.x2, b.y2
FROM asset_bboxes b
JOIN assets a ON a.id = b.asset_id
WHERE a.deleted = 0",
)
.fetch_all(&state.pool)
.await
.map_err(|e| e.to_string())?;
let mut bbox_by_asset: std::collections::HashMap<
i64,
std::collections::HashMap<String, (f64, f64, f64, f64)>,
> = std::collections::HashMap::new();
for (aid, slot, x1, y1, x2, y2) in bbox_rows {
bbox_by_asset
.entry(aid)
.or_default()
.insert(slot, (x1, y1, x2, y2));
}
let inject_bbox = |obj: &mut serde_json::Value, prefix: &str, bb: &(f64, f64, f64, f64)| {
if let Some(map) = obj.as_object_mut() {
map.insert(format!("{prefix}bbox_x1"), serde_json::json!(bb.0));
map.insert(format!("{prefix}bbox_y1"), serde_json::json!(bb.1));
map.insert(format!("{prefix}bbox_x2"), serde_json::json!(bb.2));
map.insert(format!("{prefix}bbox_y2"), serde_json::json!(bb.3));
}
};
let mut fixed_arr: Vec<serde_json::Value> = vec![];
let mut range_arr: Vec<serde_json::Value> = vec![];
for a in &assets {
if a.asset_type == "fixed" {
if let (Some(la), Some(ln)) = (a.lat, a.lng) {
fixed_arr.push(serde_json::json!({
let mut obj = serde_json::json!({
"row_id": a.row_id,
"asset_name": a.asset_name,
"video_name": a.video_name,
@@ -2124,13 +2155,19 @@ pub async fn export_assets(
"coord": [la, ln],
"deleted": a.deleted != 0,
"side": a.side,
}));
});
if let Some(slots) = bbox_by_asset.get(&a.id) {
if let Some(bb) = slots.get("fixed") {
inject_bbox(&mut obj, "", bb);
}
}
fixed_arr.push(obj);
}
} else if a.asset_type == "range" {
if let (Some(la), Some(ln), Some(sla), Some(sln), Some(ela), Some(eln)) =
(a.lat, a.lng, a.start_lat, a.start_lng, a.end_lat, a.end_lng)
{
range_arr.push(serde_json::json!({
let mut obj = serde_json::json!({
"row_id": a.row_id,
"asset_name": a.asset_name,
"video_name": a.video_name,
@@ -2145,7 +2182,19 @@ pub async fn export_assets(
"image_path3": a.image_path3,
"deleted": a.deleted != 0,
"side": a.side,
}));
});
if let Some(slots) = bbox_by_asset.get(&a.id) {
if let Some(bb) = slots.get("start") {
inject_bbox(&mut obj, "start_", bb);
}
if let Some(bb) = slots.get("mid") {
inject_bbox(&mut obj, "mid_", bb);
}
if let Some(bb) = slots.get("end") {
inject_bbox(&mut obj, "end_", bb);
}
}
range_arr.push(obj);
}
}
}

View File

@@ -221,6 +221,12 @@ function AppShell({
const [status, setStatus] = useState<string>("");
const [busy, setBusy] = useState<boolean>(false);
const [selectedAssetId, setSelectedAssetId] = useState<number | null>(null);
// Ctrl/Cmd+click extends an additive selection that the Delete shortcut acts
// on in bulk. Plain click (single-select) clears it so the two paths don't
// mix and surprise the user with stale halos.
const [multiSelectedIds, setMultiSelectedIds] = useState<Set<number>>(
() => new Set(),
);
const [bulkVideoName, setBulkVideoName] = useState<string>("");
const [mergePrimary, setMergePrimary] = useState<Asset | null>(null);
const [mergeSecondary, setMergeSecondary] = useState<Asset | null>(null);
@@ -551,6 +557,10 @@ function AppShell({
}, [autoLinkMaxM]);
const [lassoListOpen, setLassoListOpen] = useState<boolean>(false);
const [showDeleted, setShowDeleted] = useState<boolean>(false);
// "Only deleted" inverts the deleted-asset filter: live rows are hidden and
// the bbox fetch implicitly includes deleted rows. Useful for triaging the
// trash without scrolling through everything else.
const [onlyDeleted, setOnlyDeleted] = useState<boolean>(false);
const [showOutOfScope, setShowOutOfScope] = useState<boolean>(true);
const [showInScope, setShowInScope] = useState<boolean>(true);
const [visibilityOpen, setVisibilityOpen] = useState<boolean>(false);
@@ -701,12 +711,15 @@ function AppShell({
!filters.videos.has(displayVideo(a.video_name))
)
return false;
if (onlyDeleted) {
if (a.deleted === 0) return false;
}
const inScope = a.in_scope !== 0;
if (!showOutOfScope && !inScope) return false;
if (!showInScope && inScope) return false;
return true;
});
}, [assets, filters, showOutOfScope, showInScope, allVideoNames]);
}, [assets, filters, showOutOfScope, showInScope, onlyDeleted, allVideoNames]);
// Per-asset_name counts within the current video/side/type filter context
// (but ignoring the names filter itself, so toggling a name doesn't make the
@@ -981,7 +994,7 @@ function AppShell({
maxLat: b.north,
minLng: b.west,
maxLng: b.east,
includeDeleted: showDeleted,
includeDeleted: showDeleted || onlyDeleted,
});
if (seq === fetchSeqRef.current) {
setAssets(list);
@@ -1003,13 +1016,14 @@ function AppShell({
fetchBboxAssets(bounds).catch((e) => setStatus(`Fetch failed: ${e}`));
}, BBOX_FETCH_DEBOUNCE_MS);
void showDeleted;
void onlyDeleted;
return () => {
if (debounceRef.current !== null) {
window.clearTimeout(debounceRef.current);
debounceRef.current = null;
}
};
}, [bounds, showDeleted, osmEditMode, lassoMode, drawRoadMode]);
}, [bounds, showDeleted, onlyDeleted, osmEditMode, lassoMode, drawRoadMode]);
async function handlePositionChange(
id: number,
@@ -1169,12 +1183,14 @@ function AppShell({
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.
// Delete / Backspace: when an asset (or multi-selection) is active and
// we're not in any edit mode that already claims those keys (OSM
// vertex/road delete, link deletion handled above), delete and toast.
// Multi-selection (Ctrl/Cmd+click) wins over single-select so a stray
// single-selected asset isn't accidentally deleted alone.
if (
(e.key === "Delete" || e.key === "Backspace") &&
selectedAsset &&
(multiSelectedIds.size > 0 || selectedAsset) &&
!osmEditMode &&
!selectedLink
) {
@@ -1184,6 +1200,33 @@ function AppShell({
return;
}
e.preventDefault();
if (multiSelectedIds.size > 0) {
const ids = Array.from(multiSelectedIds);
(async () => {
try {
await invoke<number>("delete_assets_bulk", { ids });
setMultiSelectedIds(new Set());
const deletedSet = new Set(ids);
setAssets((prev) =>
prev.map((a) =>
deletedSet.has(a.id) ? { ...a, deleted: 1 } : a,
),
);
if (selectedAssetId !== null && deletedSet.has(selectedAssetId)) {
setSelectedAssetId(null);
}
showToast(`Deleted ${ids.length}`);
void Promise.all([
refreshActions().catch(() => {}),
refreshDataCounts().catch(() => {}),
]);
} catch (err) {
showToast(`Delete failed: ${err}`);
}
})();
return;
}
if (!selectedAsset) return;
const label = `${selectedAsset.asset_name} · ${selectedAsset.row_id}`;
(async () => {
try {
@@ -1224,14 +1267,17 @@ function AppShell({
setBboxMode((v) => !v);
return;
}
// 'H' / 'h' toggles the asset image popup. Same input-guard rules as 'O'.
// 'H' / 'h' toggles the asset image popup. Ctrl/Cmd+H is intentionally
// allowed (and preventDefault'd) so the user can hide the popup while
// multi-selecting — they're already holding Ctrl to extend the
// selection. Alt+H stays browser/menu-bound (no preventDefault).
if (e.key === "h" || e.key === "H") {
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 (e.altKey) return;
e.preventDefault();
setPopupEnabled((v) => !v);
return;
@@ -1294,7 +1340,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, selectedAsset, popupEnabled]);
}, [drawRoadMode, editingRoadId, linkPickMode, rightClickFirstId, lassoMode, selectedLink, selectedMetadataVideo, gotoPin, osmEditMode, markedVertices, focusedVideo, selectedRoadIds, lensActive, selectedAsset, selectedAssetId, popupEnabled, multiSelectedIds]);
// Image prefetch: when an asset is selected, warm the browser cache for its
// immediate neighbours so [/] navigation feels instant.
@@ -3506,11 +3552,24 @@ function AppShell({
}}
layerVisibility={layerVisibility}
selectedAsset={selectedAsset}
onSelect={(a) => {
multiSelectedIds={Array.from(multiSelectedIds)}
onSelect={(a, additive) => {
if (additive) {
if (!a) return;
setMultiSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(a.id)) next.delete(a.id);
else next.add(a.id);
return next;
});
setStatus(`Multi-select: ${a.asset_name} · ${a.row_id}`);
return;
}
if (linkPickMode && a && selectedAsset && a.id !== selectedAsset.id) {
handleSetLink(a.id);
return;
}
setMultiSelectedIds(new Set());
setSelectedAssetId(a?.id ?? null);
setSelectedLink(null);
setStatus(a ? `Selected ${a.asset_name} · ${a.row_id}` : "");
@@ -3678,7 +3737,19 @@ function AppShell({
}}
layerVisibility={layerVisibility}
selectedAsset={selectedAsset}
onSelect={(a) => {
multiSelectedIds={Array.from(multiSelectedIds)}
onSelect={(a, additive) => {
if (additive) {
if (!a) return;
setMultiSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(a.id)) next.delete(a.id);
else next.add(a.id);
return next;
});
return;
}
setMultiSelectedIds(new Set());
setSelectedAssetId(a?.id ?? null);
setStatus(a ? `Selected ${a.asset_name} · ${a.row_id}` : "");
}}
@@ -3906,10 +3977,19 @@ function AppShell({
<input
type="checkbox"
checked={showDeleted}
disabled={onlyDeleted}
onChange={(e) => setShowDeleted(e.target.checked)}
/>
<span className="label">Show deleted</span>
</label>
<label className="filter-row">
<input
type="checkbox"
checked={onlyDeleted}
onChange={(e) => setOnlyDeleted(e.target.checked)}
/>
<span className="label">Only deleted</span>
</label>
</>
)}

View File

@@ -68,7 +68,8 @@ type Props = {
onVertexShiftClick?: (idx: number) => void;
layerVisibility?: LayerVisibility;
selectedAsset?: Asset | null;
onSelect?: (asset: Asset | null) => void;
onSelect?: (asset: Asset | null, additive?: boolean) => void;
multiSelectedIds?: number[];
onAssetRightClick?: (asset: Asset) => void;
onLinkClick?: (idA: number, idB: number) => void;
onMetadataLineClick?: (videoName: string) => void;
@@ -144,6 +145,7 @@ export function MapView({
layerVisibility = DEFAULT_VIS,
selectedAsset,
onSelect,
multiSelectedIds = [],
onAssetRightClick,
onLinkClick,
onMetadataLineClick,
@@ -204,6 +206,27 @@ export function MapView({
placeModeRef.current = placeMode;
const osmEditModeRef = useRef(osmEditMode);
osmEditModeRef.current = osmEditMode;
// Document-level Ctrl/Cmd tracker. The deck.gl onClick event's srcEvent
// doesn't always carry modifier flags through MapboxOverlay, so we read
// them from a ref kept up-to-date by global keydown/keyup. This is the
// fallback used by the additive (multi-select) flag.
const ctrlHeldRef = useRef<boolean>(false);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
ctrlHeldRef.current = e.ctrlKey || e.metaKey;
};
const onBlur = () => {
ctrlHeldRef.current = false;
};
document.addEventListener("keydown", onKey);
document.addEventListener("keyup", onKey);
window.addEventListener("blur", onBlur);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("keyup", onKey);
window.removeEventListener("blur", onBlur);
};
}, []);
const onPickRoadRef = useRef(onPickRoad);
onPickRoadRef.current = onPickRoad;
const onSideChangeRef = useRef(onSideChange);
@@ -324,7 +347,13 @@ export function MapView({
coordinate?: number[];
layer?: { id: string };
},
event?: { srcEvent?: { shiftKey?: boolean } },
event?: {
srcEvent?: {
shiftKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
};
},
) => {
if (drawRoadModeRef.current && info.coordinate) {
const [lng, lat] = info.coordinate;
@@ -417,8 +446,12 @@ export function MapView({
}
return;
}
if (info.object) onSelectRef.current?.(info.object as Asset);
else onSelectRef.current?.(null);
const additive =
event?.srcEvent?.ctrlKey === true ||
event?.srcEvent?.metaKey === true ||
ctrlHeldRef.current === true;
if (info.object) onSelectRef.current?.(info.object as Asset, additive);
else if (!additive) onSelectRef.current?.(null);
},
});
map.addControl(overlay as unknown as maplibregl.IControl);
@@ -1526,6 +1559,32 @@ export function MapView({
);
}
}
if (multiSelectedIds.length > 0) {
const idSet = new Set(multiSelectedIds);
const halos: Array<[number, number]> = [];
for (const a of assets) {
if (a.deleted !== 0) continue;
if (!idSet.has(a.id)) continue;
if (a.lat !== null && a.lng !== null) halos.push([a.lng, a.lat]);
}
if (halos.length > 0) {
layers.push(
new ScatterplotLayer<[number, number]>({
id: "multi-select-halo",
data: halos,
getPosition: (d) => d,
getRadius: 16,
radiusUnits: "pixels",
getFillColor: [156, 39, 176, 70],
getLineColor: [156, 39, 176, 255],
stroked: true,
getLineWidth: 3,
lineWidthUnits: "pixels",
pickable: false,
}),
);
}
}
if (layerVisibility.fixed) {
layers.push(
new ScatterplotLayer<Asset>({
@@ -1593,6 +1652,7 @@ export function MapView({
drawRoadCoords,
assets,
highlightedIds,
multiSelectedIds,
fixedDeleted,
rangeDeleted,
rangePoints,
@@ -1616,6 +1676,9 @@ export function MapView({
if (!map || !selectedAsset) return;
const markers: maplibregl.Marker[] = [];
let activePopup: maplibregl.Popup | null = null;
// Track which vertex the active popup is anchored to so we can move it
// in lock-step when the user drags that vertex's marker.
let activePopupSlot: "fixed" | "start" | "mid" | "end" | null = null;
const labelFor = (kind: Vertex) =>
kind === "start"
@@ -1685,10 +1748,19 @@ export function MapView({
// pixel (138, 1725) renders exactly on top of pixel (138, 1725) in
// the image, including any letterbox padding when aspects differ.
const css = getComputedStyle(document.documentElement);
const baseW =
const boxW =
parseInt(css.getPropertyValue("--popup-img-w") || "440", 10) || 440;
const baseH =
const boxH =
parseInt(css.getPropertyValue("--popup-img-h") || "320", 10) || 320;
// Hug the contain-fitted image so the popup doesn't trail empty
// padding below a 16:9 frame inside a taller box. Whichever axis the
// image saturates first becomes the box's actual dimension.
const ratioImg = natW / Math.max(1, natH);
const ratioBox = boxW / Math.max(1, boxH);
const baseW =
ratioImg >= ratioBox ? boxW : Math.round(boxH * ratioImg);
const baseH =
ratioImg >= ratioBox ? Math.round(boxW / ratioImg) : boxH;
wrapper.style.width = `${baseW}px`;
wrapper.style.height = `${baseH}px`;
wrapper.style.maxWidth = `${baseW}px`;
@@ -2420,6 +2492,32 @@ export function MapView({
onSideChangeRef.current?.(selectedAsset.id, nextSide);
});
cap.appendChild(flipBtn);
// Delete bbox sits next to the side-toggle to keep the popup short.
// It's only meaningful when the user is editing bboxes, so it only
// shows in bbox mode with the overlay visible.
if (bboxMode && wrapper && bboxOverlay) {
const delBtnInline = document.createElement("button");
delBtnInline.type = "button";
delBtnInline.textContent = "Delete bbox";
delBtnInline.title = "Delete saved bbox for this image";
delBtnInline.style.marginLeft = "6px";
delBtnInline.style.fontSize = "10px";
delBtnInline.style.padding = "1px 6px";
delBtnInline.style.border = "1px solid #c0392b";
delBtnInline.style.background = "#fff";
delBtnInline.style.color = "#c0392b";
delBtnInline.style.borderRadius = "10px";
delBtnInline.style.cursor = "pointer";
delBtnInline.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
const w = wrapper as HTMLDivElement & {
_deleteBbox?: () => Promise<void>;
};
w._deleteBbox?.();
});
cap.appendChild(delBtnInline);
}
cap.appendChild(document.createElement("br"));
const small = document.createElement("small");
// row_id · video_name on a single line so users can read both
@@ -2457,25 +2555,6 @@ export function MapView({
hint.style.fontSize = "10px";
hint.style.color = "#888";
cap.appendChild(hint);
cap.appendChild(document.createElement("br"));
const delBtn = document.createElement("button");
delBtn.type = "button";
delBtn.textContent = "Delete bbox";
delBtn.style.marginTop = "4px";
delBtn.style.fontSize = "11px";
delBtn.style.padding = "2px 8px";
delBtn.style.border = "1px solid #c0392b";
delBtn.style.background = "#fff";
delBtn.style.color = "#c0392b";
delBtn.style.borderRadius = "3px";
delBtn.style.cursor = "pointer";
delBtn.addEventListener("click", () => {
const w = wrapper as HTMLDivElement & {
_deleteBbox?: () => Promise<void>;
};
w._deleteBbox?.();
});
cap.appendChild(delBtn);
}
root.appendChild(cap);
@@ -2500,6 +2579,10 @@ export function MapView({
.setLngLat([lng, lat])
.setDOMContent(root)
.addTo(map);
activePopupSlot = slot;
activePopup.on("close", () => {
activePopupSlot = null;
});
};
type V = {
@@ -2568,6 +2651,13 @@ export function MapView({
const m = new maplibregl.Marker({ element: el, draggable: true })
.setLngLat([v.lng, v.lat])
.addTo(map);
// Keep the open popup glued to the marker while it's being dragged so
// the user can see exactly where the asset will land.
m.on("drag", () => {
if (activePopup && activePopupSlot === v.kind) {
activePopup.setLngLat(m.getLngLat());
}
});
m.on("dragend", () => {
const ll = m.getLngLat();
onPositionChangeRef.current?.(id, v.kind, ll.lat, ll.lng);