multi slct del
This commit is contained in:
7
CLAUDE.md
Normal file
7
CLAUDE.md
Normal 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 |
|
||||||
@@ -2109,14 +2109,45 @@ pub async fn export_assets(
|
|||||||
"source" => {
|
"source" => {
|
||||||
// Round-trippable JSON in the same shape import_*_assets accept.
|
// Round-trippable JSON in the same shape import_*_assets accept.
|
||||||
// Fixed and range objects are written into separate top-level keys
|
// Fixed and range objects are written into separate top-level keys
|
||||||
// so the user can re-feed each via the matching importer. Includes
|
// so the user can re-feed each via the matching importer. BBox
|
||||||
// deleted=true rows so the audit state survives a round-trip.
|
// 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 fixed_arr: Vec<serde_json::Value> = vec![];
|
||||||
let mut range_arr: Vec<serde_json::Value> = vec![];
|
let mut range_arr: Vec<serde_json::Value> = vec![];
|
||||||
for a in &assets {
|
for a in &assets {
|
||||||
if a.asset_type == "fixed" {
|
if a.asset_type == "fixed" {
|
||||||
if let (Some(la), Some(ln)) = (a.lat, a.lng) {
|
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,
|
"row_id": a.row_id,
|
||||||
"asset_name": a.asset_name,
|
"asset_name": a.asset_name,
|
||||||
"video_name": a.video_name,
|
"video_name": a.video_name,
|
||||||
@@ -2124,13 +2155,19 @@ pub async fn export_assets(
|
|||||||
"coord": [la, ln],
|
"coord": [la, ln],
|
||||||
"deleted": a.deleted != 0,
|
"deleted": a.deleted != 0,
|
||||||
"side": a.side,
|
"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" {
|
} else if a.asset_type == "range" {
|
||||||
if let (Some(la), Some(ln), Some(sla), Some(sln), Some(ela), Some(eln)) =
|
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)
|
(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,
|
"row_id": a.row_id,
|
||||||
"asset_name": a.asset_name,
|
"asset_name": a.asset_name,
|
||||||
"video_name": a.video_name,
|
"video_name": a.video_name,
|
||||||
@@ -2145,7 +2182,19 @@ pub async fn export_assets(
|
|||||||
"image_path3": a.image_path3,
|
"image_path3": a.image_path3,
|
||||||
"deleted": a.deleted != 0,
|
"deleted": a.deleted != 0,
|
||||||
"side": a.side,
|
"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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
104
src/App.tsx
104
src/App.tsx
@@ -221,6 +221,12 @@ function AppShell({
|
|||||||
const [status, setStatus] = useState<string>("");
|
const [status, setStatus] = useState<string>("");
|
||||||
const [busy, setBusy] = useState<boolean>(false);
|
const [busy, setBusy] = useState<boolean>(false);
|
||||||
const [selectedAssetId, setSelectedAssetId] = useState<number | null>(null);
|
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 [bulkVideoName, setBulkVideoName] = useState<string>("");
|
||||||
const [mergePrimary, setMergePrimary] = useState<Asset | null>(null);
|
const [mergePrimary, setMergePrimary] = useState<Asset | null>(null);
|
||||||
const [mergeSecondary, setMergeSecondary] = useState<Asset | null>(null);
|
const [mergeSecondary, setMergeSecondary] = useState<Asset | null>(null);
|
||||||
@@ -551,6 +557,10 @@ function AppShell({
|
|||||||
}, [autoLinkMaxM]);
|
}, [autoLinkMaxM]);
|
||||||
const [lassoListOpen, setLassoListOpen] = useState<boolean>(false);
|
const [lassoListOpen, setLassoListOpen] = useState<boolean>(false);
|
||||||
const [showDeleted, setShowDeleted] = 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 [showOutOfScope, setShowOutOfScope] = useState<boolean>(true);
|
||||||
const [showInScope, setShowInScope] = useState<boolean>(true);
|
const [showInScope, setShowInScope] = useState<boolean>(true);
|
||||||
const [visibilityOpen, setVisibilityOpen] = useState<boolean>(false);
|
const [visibilityOpen, setVisibilityOpen] = useState<boolean>(false);
|
||||||
@@ -701,12 +711,15 @@ function AppShell({
|
|||||||
!filters.videos.has(displayVideo(a.video_name))
|
!filters.videos.has(displayVideo(a.video_name))
|
||||||
)
|
)
|
||||||
return false;
|
return false;
|
||||||
|
if (onlyDeleted) {
|
||||||
|
if (a.deleted === 0) return false;
|
||||||
|
}
|
||||||
const inScope = a.in_scope !== 0;
|
const inScope = a.in_scope !== 0;
|
||||||
if (!showOutOfScope && !inScope) return false;
|
if (!showOutOfScope && !inScope) return false;
|
||||||
if (!showInScope && inScope) return false;
|
if (!showInScope && inScope) return false;
|
||||||
return true;
|
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
|
// 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
|
// (but ignoring the names filter itself, so toggling a name doesn't make the
|
||||||
@@ -981,7 +994,7 @@ function AppShell({
|
|||||||
maxLat: b.north,
|
maxLat: b.north,
|
||||||
minLng: b.west,
|
minLng: b.west,
|
||||||
maxLng: b.east,
|
maxLng: b.east,
|
||||||
includeDeleted: showDeleted,
|
includeDeleted: showDeleted || onlyDeleted,
|
||||||
});
|
});
|
||||||
if (seq === fetchSeqRef.current) {
|
if (seq === fetchSeqRef.current) {
|
||||||
setAssets(list);
|
setAssets(list);
|
||||||
@@ -1003,13 +1016,14 @@ function AppShell({
|
|||||||
fetchBboxAssets(bounds).catch((e) => setStatus(`Fetch failed: ${e}`));
|
fetchBboxAssets(bounds).catch((e) => setStatus(`Fetch failed: ${e}`));
|
||||||
}, BBOX_FETCH_DEBOUNCE_MS);
|
}, BBOX_FETCH_DEBOUNCE_MS);
|
||||||
void showDeleted;
|
void showDeleted;
|
||||||
|
void onlyDeleted;
|
||||||
return () => {
|
return () => {
|
||||||
if (debounceRef.current !== null) {
|
if (debounceRef.current !== null) {
|
||||||
window.clearTimeout(debounceRef.current);
|
window.clearTimeout(debounceRef.current);
|
||||||
debounceRef.current = null;
|
debounceRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [bounds, showDeleted, osmEditMode, lassoMode, drawRoadMode]);
|
}, [bounds, showDeleted, onlyDeleted, osmEditMode, lassoMode, drawRoadMode]);
|
||||||
|
|
||||||
async function handlePositionChange(
|
async function handlePositionChange(
|
||||||
id: number,
|
id: number,
|
||||||
@@ -1169,12 +1183,14 @@ function AppShell({
|
|||||||
setRenameInput(selectedAsset.asset_name ?? "");
|
setRenameInput(selectedAsset.asset_name ?? "");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Delete / Backspace: when an asset is selected and we're not in any
|
// Delete / Backspace: when an asset (or multi-selection) is active and
|
||||||
// edit mode that already claims those keys (OSM vertex/road delete,
|
// we're not in any edit mode that already claims those keys (OSM
|
||||||
// link deletion handled above), delete the asset and toast.
|
// 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 (
|
if (
|
||||||
(e.key === "Delete" || e.key === "Backspace") &&
|
(e.key === "Delete" || e.key === "Backspace") &&
|
||||||
selectedAsset &&
|
(multiSelectedIds.size > 0 || selectedAsset) &&
|
||||||
!osmEditMode &&
|
!osmEditMode &&
|
||||||
!selectedLink
|
!selectedLink
|
||||||
) {
|
) {
|
||||||
@@ -1184,6 +1200,33 @@ function AppShell({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
e.preventDefault();
|
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}`;
|
const label = `${selectedAsset.asset_name} · ${selectedAsset.row_id}`;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1224,14 +1267,17 @@ function AppShell({
|
|||||||
setBboxMode((v) => !v);
|
setBboxMode((v) => !v);
|
||||||
return;
|
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") {
|
if (e.key === "h" || e.key === "H") {
|
||||||
const target = e.target as HTMLElement | null;
|
const target = e.target as HTMLElement | null;
|
||||||
const tag = target?.tagName;
|
const tag = target?.tagName;
|
||||||
if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) {
|
if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
if (e.altKey) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setPopupEnabled((v) => !v);
|
setPopupEnabled((v) => !v);
|
||||||
return;
|
return;
|
||||||
@@ -1294,7 +1340,7 @@ function AppShell({
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", handler);
|
window.addEventListener("keydown", handler);
|
||||||
return () => window.removeEventListener("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
|
// Image prefetch: when an asset is selected, warm the browser cache for its
|
||||||
// immediate neighbours so [/] navigation feels instant.
|
// immediate neighbours so [/] navigation feels instant.
|
||||||
@@ -3506,11 +3552,24 @@ function AppShell({
|
|||||||
}}
|
}}
|
||||||
layerVisibility={layerVisibility}
|
layerVisibility={layerVisibility}
|
||||||
selectedAsset={selectedAsset}
|
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) {
|
if (linkPickMode && a && selectedAsset && a.id !== selectedAsset.id) {
|
||||||
handleSetLink(a.id);
|
handleSetLink(a.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setMultiSelectedIds(new Set());
|
||||||
setSelectedAssetId(a?.id ?? null);
|
setSelectedAssetId(a?.id ?? null);
|
||||||
setSelectedLink(null);
|
setSelectedLink(null);
|
||||||
setStatus(a ? `Selected ${a.asset_name} · ${a.row_id}` : "");
|
setStatus(a ? `Selected ${a.asset_name} · ${a.row_id}` : "");
|
||||||
@@ -3678,7 +3737,19 @@ function AppShell({
|
|||||||
}}
|
}}
|
||||||
layerVisibility={layerVisibility}
|
layerVisibility={layerVisibility}
|
||||||
selectedAsset={selectedAsset}
|
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);
|
setSelectedAssetId(a?.id ?? null);
|
||||||
setStatus(a ? `Selected ${a.asset_name} · ${a.row_id}` : "");
|
setStatus(a ? `Selected ${a.asset_name} · ${a.row_id}` : "");
|
||||||
}}
|
}}
|
||||||
@@ -3906,10 +3977,19 @@ function AppShell({
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={showDeleted}
|
checked={showDeleted}
|
||||||
|
disabled={onlyDeleted}
|
||||||
onChange={(e) => setShowDeleted(e.target.checked)}
|
onChange={(e) => setShowDeleted(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
<span className="label">Show deleted</span>
|
<span className="label">Show deleted</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="filter-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={onlyDeleted}
|
||||||
|
onChange={(e) => setOnlyDeleted(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="label">Only deleted</span>
|
||||||
|
</label>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
140
src/MapView.tsx
140
src/MapView.tsx
@@ -68,7 +68,8 @@ type Props = {
|
|||||||
onVertexShiftClick?: (idx: number) => void;
|
onVertexShiftClick?: (idx: number) => void;
|
||||||
layerVisibility?: LayerVisibility;
|
layerVisibility?: LayerVisibility;
|
||||||
selectedAsset?: Asset | null;
|
selectedAsset?: Asset | null;
|
||||||
onSelect?: (asset: Asset | null) => void;
|
onSelect?: (asset: Asset | null, additive?: boolean) => void;
|
||||||
|
multiSelectedIds?: number[];
|
||||||
onAssetRightClick?: (asset: Asset) => void;
|
onAssetRightClick?: (asset: Asset) => void;
|
||||||
onLinkClick?: (idA: number, idB: number) => void;
|
onLinkClick?: (idA: number, idB: number) => void;
|
||||||
onMetadataLineClick?: (videoName: string) => void;
|
onMetadataLineClick?: (videoName: string) => void;
|
||||||
@@ -144,6 +145,7 @@ export function MapView({
|
|||||||
layerVisibility = DEFAULT_VIS,
|
layerVisibility = DEFAULT_VIS,
|
||||||
selectedAsset,
|
selectedAsset,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
multiSelectedIds = [],
|
||||||
onAssetRightClick,
|
onAssetRightClick,
|
||||||
onLinkClick,
|
onLinkClick,
|
||||||
onMetadataLineClick,
|
onMetadataLineClick,
|
||||||
@@ -204,6 +206,27 @@ export function MapView({
|
|||||||
placeModeRef.current = placeMode;
|
placeModeRef.current = placeMode;
|
||||||
const osmEditModeRef = useRef(osmEditMode);
|
const osmEditModeRef = useRef(osmEditMode);
|
||||||
osmEditModeRef.current = 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);
|
const onPickRoadRef = useRef(onPickRoad);
|
||||||
onPickRoadRef.current = onPickRoad;
|
onPickRoadRef.current = onPickRoad;
|
||||||
const onSideChangeRef = useRef(onSideChange);
|
const onSideChangeRef = useRef(onSideChange);
|
||||||
@@ -324,7 +347,13 @@ export function MapView({
|
|||||||
coordinate?: number[];
|
coordinate?: number[];
|
||||||
layer?: { id: string };
|
layer?: { id: string };
|
||||||
},
|
},
|
||||||
event?: { srcEvent?: { shiftKey?: boolean } },
|
event?: {
|
||||||
|
srcEvent?: {
|
||||||
|
shiftKey?: boolean;
|
||||||
|
ctrlKey?: boolean;
|
||||||
|
metaKey?: boolean;
|
||||||
|
};
|
||||||
|
},
|
||||||
) => {
|
) => {
|
||||||
if (drawRoadModeRef.current && info.coordinate) {
|
if (drawRoadModeRef.current && info.coordinate) {
|
||||||
const [lng, lat] = info.coordinate;
|
const [lng, lat] = info.coordinate;
|
||||||
@@ -417,8 +446,12 @@ export function MapView({
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (info.object) onSelectRef.current?.(info.object as Asset);
|
const additive =
|
||||||
else onSelectRef.current?.(null);
|
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);
|
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) {
|
if (layerVisibility.fixed) {
|
||||||
layers.push(
|
layers.push(
|
||||||
new ScatterplotLayer<Asset>({
|
new ScatterplotLayer<Asset>({
|
||||||
@@ -1593,6 +1652,7 @@ export function MapView({
|
|||||||
drawRoadCoords,
|
drawRoadCoords,
|
||||||
assets,
|
assets,
|
||||||
highlightedIds,
|
highlightedIds,
|
||||||
|
multiSelectedIds,
|
||||||
fixedDeleted,
|
fixedDeleted,
|
||||||
rangeDeleted,
|
rangeDeleted,
|
||||||
rangePoints,
|
rangePoints,
|
||||||
@@ -1616,6 +1676,9 @@ export function MapView({
|
|||||||
if (!map || !selectedAsset) return;
|
if (!map || !selectedAsset) return;
|
||||||
const markers: maplibregl.Marker[] = [];
|
const markers: maplibregl.Marker[] = [];
|
||||||
let activePopup: maplibregl.Popup | null = null;
|
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) =>
|
const labelFor = (kind: Vertex) =>
|
||||||
kind === "start"
|
kind === "start"
|
||||||
@@ -1685,10 +1748,19 @@ export function MapView({
|
|||||||
// pixel (138, 1725) renders exactly on top of pixel (138, 1725) in
|
// pixel (138, 1725) renders exactly on top of pixel (138, 1725) in
|
||||||
// the image, including any letterbox padding when aspects differ.
|
// the image, including any letterbox padding when aspects differ.
|
||||||
const css = getComputedStyle(document.documentElement);
|
const css = getComputedStyle(document.documentElement);
|
||||||
const baseW =
|
const boxW =
|
||||||
parseInt(css.getPropertyValue("--popup-img-w") || "440", 10) || 440;
|
parseInt(css.getPropertyValue("--popup-img-w") || "440", 10) || 440;
|
||||||
const baseH =
|
const boxH =
|
||||||
parseInt(css.getPropertyValue("--popup-img-h") || "320", 10) || 320;
|
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.width = `${baseW}px`;
|
||||||
wrapper.style.height = `${baseH}px`;
|
wrapper.style.height = `${baseH}px`;
|
||||||
wrapper.style.maxWidth = `${baseW}px`;
|
wrapper.style.maxWidth = `${baseW}px`;
|
||||||
@@ -2420,6 +2492,32 @@ export function MapView({
|
|||||||
onSideChangeRef.current?.(selectedAsset.id, nextSide);
|
onSideChangeRef.current?.(selectedAsset.id, nextSide);
|
||||||
});
|
});
|
||||||
cap.appendChild(flipBtn);
|
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"));
|
cap.appendChild(document.createElement("br"));
|
||||||
const small = document.createElement("small");
|
const small = document.createElement("small");
|
||||||
// row_id · video_name on a single line so users can read both
|
// 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.fontSize = "10px";
|
||||||
hint.style.color = "#888";
|
hint.style.color = "#888";
|
||||||
cap.appendChild(hint);
|
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);
|
root.appendChild(cap);
|
||||||
|
|
||||||
@@ -2500,6 +2579,10 @@ export function MapView({
|
|||||||
.setLngLat([lng, lat])
|
.setLngLat([lng, lat])
|
||||||
.setDOMContent(root)
|
.setDOMContent(root)
|
||||||
.addTo(map);
|
.addTo(map);
|
||||||
|
activePopupSlot = slot;
|
||||||
|
activePopup.on("close", () => {
|
||||||
|
activePopupSlot = null;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
type V = {
|
type V = {
|
||||||
@@ -2568,6 +2651,13 @@ export function MapView({
|
|||||||
const m = new maplibregl.Marker({ element: el, draggable: true })
|
const m = new maplibregl.Marker({ element: el, draggable: true })
|
||||||
.setLngLat([v.lng, v.lat])
|
.setLngLat([v.lng, v.lat])
|
||||||
.addTo(map);
|
.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", () => {
|
m.on("dragend", () => {
|
||||||
const ll = m.getLngLat();
|
const ll = m.getLngLat();
|
||||||
onPositionChangeRef.current?.(id, v.kind, ll.lat, ll.lng);
|
onPositionChangeRef.current?.(id, v.kind, ll.lat, ll.lng);
|
||||||
|
|||||||
Reference in New Issue
Block a user