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" => {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
104
src/App.tsx
104
src/App.tsx
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
140
src/MapView.tsx
140
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<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);
|
||||
|
||||
Reference in New Issue
Block a user