From f83724951fb3d99fb0c20673777e5881b09b11dd Mon Sep 17 00:00:00 2001 From: ravi Date: Tue, 26 May 2026 15:34:06 +0530 Subject: [PATCH] multi slct del --- CLAUDE.md | 7 ++ src-tauri/src/commands.rs | 61 +++++++++++++++-- src/App.tsx | 104 ++++++++++++++++++++++++---- src/MapView.tsx | 140 +++++++++++++++++++++++++++++++------- 4 files changed, 269 insertions(+), 43 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..094034c --- /dev/null +++ b/CLAUDE.md @@ -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 | diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index eeea7f3..1e72e93 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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, + > = 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 = vec![]; let mut range_arr: Vec = 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); } } } diff --git a/src/App.tsx b/src/App.tsx index b88f709..06dcbc0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -221,6 +221,12 @@ function AppShell({ const [status, setStatus] = useState(""); const [busy, setBusy] = useState(false); const [selectedAssetId, setSelectedAssetId] = useState(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>( + () => new Set(), + ); const [bulkVideoName, setBulkVideoName] = useState(""); const [mergePrimary, setMergePrimary] = useState(null); const [mergeSecondary, setMergeSecondary] = useState(null); @@ -551,6 +557,10 @@ function AppShell({ }, [autoLinkMaxM]); const [lassoListOpen, setLassoListOpen] = useState(false); const [showDeleted, setShowDeleted] = useState(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(false); const [showOutOfScope, setShowOutOfScope] = useState(true); const [showInScope, setShowInScope] = useState(true); const [visibilityOpen, setVisibilityOpen] = useState(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("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({ setShowDeleted(e.target.checked)} /> Show deleted + )} diff --git a/src/MapView.tsx b/src/MapView.tsx index f9a4684..9f44ead 100644 --- a/src/MapView.tsx +++ b/src/MapView.tsx @@ -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(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({ @@ -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; + }; + 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; - }; - 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);