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_SIZE_KEY = "kml_map_tool.popup_size";
|
||||||
const POPUP_ENABLED_KEY = "kml_map_tool.popup_enabled";
|
const POPUP_ENABLED_KEY = "kml_map_tool.popup_enabled";
|
||||||
const BBOX_MODE_KEY = "kml_map_tool.bbox_mode";
|
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 POPUP_ASPECT_KEY = "kml_map_tool.popup_aspect";
|
||||||
const QUALITY_OFF_TRACK_KEY = "kml_map_tool.quality_off_track_m";
|
const QUALITY_OFF_TRACK_KEY = "kml_map_tool.quality_off_track_m";
|
||||||
const PERF_MODE_KEY = "kml_map_tool.perf_mode";
|
const PERF_MODE_KEY = "kml_map_tool.perf_mode";
|
||||||
@@ -311,6 +312,16 @@ function AppShell({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.localStorage.setItem(BBOX_MODE_KEY, bboxMode ? "1" : "0");
|
window.localStorage.setItem(BBOX_MODE_KEY, bboxMode ? "1" : "0");
|
||||||
}, [bboxMode]);
|
}, [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>(() => {
|
const [popupAspect, setPopupAspect] = useState<PopupAspect>(() => {
|
||||||
if (typeof window === "undefined") return "4:3";
|
if (typeof window === "undefined") return "4:3";
|
||||||
const v = window.localStorage.getItem(POPUP_ASPECT_KEY) as PopupAspect | null;
|
const v = window.localStorage.getItem(POPUP_ASPECT_KEY) as PopupAspect | null;
|
||||||
@@ -573,6 +584,21 @@ function AppShell({
|
|||||||
null | { ids: number[]; current: string }
|
null | { ids: number[]; current: string }
|
||||||
>(null);
|
>(null);
|
||||||
const [renameInput, setRenameInput] = useState<string>("");
|
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 [osmToolsOpen, setOsmToolsOpen] = useState<boolean>(false);
|
||||||
const [importsOpen, setImportsOpen] = useState<boolean>(false);
|
const [importsOpen, setImportsOpen] = useState<boolean>(false);
|
||||||
const [osmToolPrune, setOsmToolPrune] = useState<boolean>(true);
|
const [osmToolPrune, setOsmToolPrune] = useState<boolean>(true);
|
||||||
@@ -994,9 +1020,11 @@ function AppShell({
|
|||||||
try {
|
try {
|
||||||
await invoke("update_asset_position", { id, vertex, lat, lng });
|
await invoke("update_asset_position", { id, vertex, lat, lng });
|
||||||
setStatus(`Moved ${vertex} to ${lat.toFixed(6)}, ${lng.toFixed(6)}`);
|
setStatus(`Moved ${vertex} to ${lat.toFixed(6)}, ${lng.toFixed(6)}`);
|
||||||
|
showToast(`Moved ${vertex} point`);
|
||||||
await refreshAfterMutation();
|
await refreshAfterMutation();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus(`Move failed: ${e}`);
|
setStatus(`Move failed: ${e}`);
|
||||||
|
showToast(`Move failed`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1123,6 +1151,63 @@ function AppShell({
|
|||||||
toggleOsmEditModeRef.current();
|
toggleOsmEditModeRef.current();
|
||||||
return;
|
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.
|
// 'B' / 'b' toggles bbox editing. No-op when popups are disabled.
|
||||||
if (e.key === "b" || e.key === "B") {
|
if (e.key === "b" || e.key === "B") {
|
||||||
const target = e.target as HTMLElement | null;
|
const target = e.target as HTMLElement | null;
|
||||||
@@ -1209,7 +1294,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]);
|
}, [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
|
// 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.
|
||||||
@@ -1309,15 +1394,39 @@ function AppShell({
|
|||||||
return () => window.removeEventListener("keydown", handler);
|
return () => window.removeEventListener("keydown", handler);
|
||||||
}, [selectedAsset, viewport.center]);
|
}, [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() {
|
async function handleDeleteSelected() {
|
||||||
if (!selectedAsset) return;
|
if (!selectedAsset) return;
|
||||||
|
const label = `${selectedAsset.asset_name} · ${selectedAsset.row_id}`;
|
||||||
try {
|
try {
|
||||||
await invoke("delete_asset", { id: selectedAsset.id });
|
await invoke("delete_asset", { id: selectedAsset.id });
|
||||||
setStatus(`Deleted ${selectedAsset.asset_name} · ${selectedAsset.row_id}`);
|
setStatus(`Deleted ${label}`);
|
||||||
|
showToast(`Deleted ${label}`);
|
||||||
setSelectedAssetId(null);
|
setSelectedAssetId(null);
|
||||||
await refreshAfterMutation();
|
await refreshAfterMutation();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus(`Delete failed: ${e}`);
|
setStatus(`Delete failed: ${e}`);
|
||||||
|
showToast(`Delete failed: ${e}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1345,13 +1454,16 @@ function AppShell({
|
|||||||
});
|
});
|
||||||
if (count === 0) {
|
if (count === 0) {
|
||||||
setStatus(`No live assets matched video "${name}"`);
|
setStatus(`No live assets matched video "${name}"`);
|
||||||
|
showToast(`No matches for "${name}"`);
|
||||||
} else {
|
} else {
|
||||||
setStatus(`Deleted ${count} assets from video "${name}"`);
|
setStatus(`Deleted ${count} assets from video "${name}"`);
|
||||||
|
showToast(`Deleted ${count} assets in "${name}"`);
|
||||||
setBulkVideoName("");
|
setBulkVideoName("");
|
||||||
}
|
}
|
||||||
await refreshAfterMutation();
|
await refreshAfterMutation();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus(`Bulk delete failed: ${e}`);
|
setStatus(`Bulk delete failed: ${e}`);
|
||||||
|
showToast(`Bulk delete failed`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1406,8 +1518,10 @@ function AppShell({
|
|||||||
refreshAssetNames().catch(() => {}),
|
refreshAssetNames().catch(() => {}),
|
||||||
]);
|
]);
|
||||||
setStatus("Database reset.");
|
setStatus("Database reset.");
|
||||||
|
showToast("Database reset");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus(`Reset failed: ${e}`);
|
setStatus(`Reset failed: ${e}`);
|
||||||
|
showToast(`Reset failed`);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -1417,9 +1531,11 @@ function AppShell({
|
|||||||
try {
|
try {
|
||||||
await invoke("undo_action", { actionId });
|
await invoke("undo_action", { actionId });
|
||||||
setStatus(`Undone action #${actionId}`);
|
setStatus(`Undone action #${actionId}`);
|
||||||
|
showToast(`Undone #${actionId}`);
|
||||||
await refreshAfterMutation();
|
await refreshAfterMutation();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus(`Undo failed: ${e}`);
|
setStatus(`Undo failed: ${e}`);
|
||||||
|
showToast(`Undo failed`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1427,9 +1543,11 @@ function AppShell({
|
|||||||
try {
|
try {
|
||||||
await invoke("redo_action", { actionId });
|
await invoke("redo_action", { actionId });
|
||||||
setStatus(`Redone action #${actionId}`);
|
setStatus(`Redone action #${actionId}`);
|
||||||
|
showToast(`Redone #${actionId}`);
|
||||||
await refreshAfterMutation();
|
await refreshAfterMutation();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus(`Redo failed: ${e}`);
|
setStatus(`Redo failed: ${e}`);
|
||||||
|
showToast(`Redo failed`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1460,12 +1578,16 @@ function AppShell({
|
|||||||
setStatus(
|
setStatus(
|
||||||
`Merged ${mergeSecondary.row_id} into ${mergePrimary.row_id}`,
|
`Merged ${mergeSecondary.row_id} into ${mergePrimary.row_id}`,
|
||||||
);
|
);
|
||||||
|
showToast(
|
||||||
|
`Merged ${mergeSecondary.row_id} → ${mergePrimary.row_id}`,
|
||||||
|
);
|
||||||
setMergePrimary(null);
|
setMergePrimary(null);
|
||||||
setMergeSecondary(null);
|
setMergeSecondary(null);
|
||||||
setSelectedAssetId(null);
|
setSelectedAssetId(null);
|
||||||
await refreshAfterMutation();
|
await refreshAfterMutation();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus(`Merge failed: ${e}`);
|
setStatus(`Merge failed: ${e}`);
|
||||||
|
showToast(`Merge failed`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1917,8 +2039,10 @@ function AppShell({
|
|||||||
setStatus(
|
setStatus(
|
||||||
`Created road #${newId} with ${drawRoadCoords.length} vertices.`,
|
`Created road #${newId} with ${drawRoadCoords.length} vertices.`,
|
||||||
);
|
);
|
||||||
|
showToast(`Created road #${newId}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus(`Create road failed: ${e}`);
|
setStatus(`Create road failed: ${e}`);
|
||||||
|
showToast(`Create road failed`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1926,14 +2050,17 @@ function AppShell({
|
|||||||
if (editingRoadId === null) return;
|
if (editingRoadId === null) return;
|
||||||
const ok = window.confirm(`Delete road #${editingRoadId}?`);
|
const ok = window.confirm(`Delete road #${editingRoadId}?`);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
const roadId = editingRoadId;
|
||||||
try {
|
try {
|
||||||
await invoke("delete_osm_road", { id: editingRoadId });
|
await invoke("delete_osm_road", { id: roadId });
|
||||||
await refreshOsmRoads();
|
await refreshOsmRoads();
|
||||||
await refreshDataCounts();
|
await refreshDataCounts();
|
||||||
setEditingRoadId(null);
|
setEditingRoadId(null);
|
||||||
setStatus(`Deleted road #${editingRoadId}.`);
|
setStatus(`Deleted road #${roadId}.`);
|
||||||
|
showToast(`Deleted road #${roadId}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus(`Delete failed: ${e}`);
|
setStatus(`Delete failed: ${e}`);
|
||||||
|
showToast(`Delete road failed`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2021,9 +2148,11 @@ function AppShell({
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
setStatus(`Loaded ${count} road segment(s)`);
|
setStatus(`Loaded ${count} road segment(s)`);
|
||||||
|
showToast(`Loaded ${count} road segment(s)`);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus(`OSM import failed: ${e}`);
|
setStatus(`OSM import failed: ${e}`);
|
||||||
|
showToast(`OSM import failed`);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -3057,8 +3186,10 @@ function AppShell({
|
|||||||
await fetchBboxAssets(boundsRef.current);
|
await fetchBboxAssets(boundsRef.current);
|
||||||
}
|
}
|
||||||
setStatus(`Loaded ${count} scope polygon(s)`);
|
setStatus(`Loaded ${count} scope polygon(s)`);
|
||||||
|
showToast(`Loaded ${count} scope polygon(s)`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus(`KML import failed: ${e}`);
|
setStatus(`KML import failed: ${e}`);
|
||||||
|
showToast(`KML import failed`);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -3146,6 +3277,7 @@ function AppShell({
|
|||||||
{ path: picked },
|
{ path: picked },
|
||||||
);
|
);
|
||||||
setStatus(`Imported ${r.fixed} fixed + ${r.range} range = ${r.fixed + r.range} asset(s)`);
|
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([
|
await Promise.all([
|
||||||
refreshVideoNames(),
|
refreshVideoNames(),
|
||||||
refreshVideoEntries(),
|
refreshVideoEntries(),
|
||||||
@@ -3189,6 +3321,7 @@ function AppShell({
|
|||||||
kind === "fixed" ? "import_fixed_assets" : "import_range_assets";
|
kind === "fixed" ? "import_fixed_assets" : "import_range_assets";
|
||||||
const count = await invoke<number>(cmd, { path: picked });
|
const count = await invoke<number>(cmd, { path: picked });
|
||||||
setStatus(`Imported ${count} ${kind}`);
|
setStatus(`Imported ${count} ${kind}`);
|
||||||
|
showToast(`Imported ${count} ${kind} asset(s)`);
|
||||||
|
|
||||||
// Pull in the new video / class names so the filter auto-includes them.
|
// Pull in the new video / class names so the filter auto-includes them.
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -3428,6 +3561,8 @@ function AppShell({
|
|||||||
imageFolder={imageFolder}
|
imageFolder={imageFolder}
|
||||||
popupEnabled={popupEnabled}
|
popupEnabled={popupEnabled}
|
||||||
bboxMode={bboxMode}
|
bboxMode={bboxMode}
|
||||||
|
bboxOverlay={bboxOverlay}
|
||||||
|
onSideChange={handleSideChange}
|
||||||
perfMode={perfMode}
|
perfMode={perfMode}
|
||||||
anchorIds={Array.from(anchors.keys())}
|
anchorIds={Array.from(anchors.keys())}
|
||||||
colorBy={colorBy}
|
colorBy={colorBy}
|
||||||
@@ -3551,6 +3686,8 @@ function AppShell({
|
|||||||
imageFolder={imageFolder}
|
imageFolder={imageFolder}
|
||||||
popupEnabled={popupEnabled}
|
popupEnabled={popupEnabled}
|
||||||
bboxMode={bboxMode}
|
bboxMode={bboxMode}
|
||||||
|
bboxOverlay={bboxOverlay}
|
||||||
|
onSideChange={handleSideChange}
|
||||||
perfMode={perfMode}
|
perfMode={perfMode}
|
||||||
anchorIds={Array.from(anchors.keys())}
|
anchorIds={Array.from(anchors.keys())}
|
||||||
colorBy={
|
colorBy={
|
||||||
@@ -3653,6 +3790,31 @@ function AppShell({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<button
|
||||||
className="panel-toggle"
|
className="panel-toggle"
|
||||||
onClick={() => setPanelOpen((v) => !v)}
|
onClick={() => setPanelOpen((v) => !v)}
|
||||||
@@ -4152,7 +4314,8 @@ function AppShell({
|
|||||||
const name = renameInput.trim();
|
const name = renameInput.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
try {
|
try {
|
||||||
if (!classNames.includes(name)) {
|
const isNewClass = !classNames.includes(name);
|
||||||
|
if (isNewClass) {
|
||||||
await invoke("add_class", { name });
|
await invoke("add_class", { name });
|
||||||
}
|
}
|
||||||
await invoke<number>("rename_assets", {
|
await invoke<number>("rename_assets", {
|
||||||
@@ -4162,9 +4325,35 @@ function AppShell({
|
|||||||
setStatus(
|
setStatus(
|
||||||
`Renamed ${renameOpen.ids.length} asset(s) → ${name}`,
|
`Renamed ${renameOpen.ids.length} asset(s) → ${name}`,
|
||||||
);
|
);
|
||||||
|
showToast(
|
||||||
|
renameOpen.ids.length === 1
|
||||||
|
? `Renamed → ${name}`
|
||||||
|
: `Renamed ${renameOpen.ids.length} → ${name}`,
|
||||||
|
);
|
||||||
setRenameOpen(null);
|
setRenameOpen(null);
|
||||||
setRenameInput("");
|
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) {
|
} catch (e) {
|
||||||
setStatus(`Rename failed: ${e}`);
|
setStatus(`Rename failed: ${e}`);
|
||||||
}
|
}
|
||||||
@@ -4423,6 +4612,23 @@ function AppShell({
|
|||||||
/>
|
/>
|
||||||
Show image popup when an asset is selected
|
Show image popup when an asset is selected
|
||||||
</label>
|
</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
|
<label
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -4436,7 +4642,7 @@ function AppShell({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={bboxMode}
|
checked={bboxMode}
|
||||||
onChange={(e) => setBboxMode(e.target.checked)}
|
onChange={(e) => setBboxMode(e.target.checked)}
|
||||||
disabled={!popupEnabled}
|
disabled={!popupEnabled || !bboxOverlay}
|
||||||
/>
|
/>
|
||||||
BBox mode (edit bounding boxes on images)
|
BBox mode (edit bounding boxes on images)
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
186
src/MapView.tsx
186
src/MapView.tsx
@@ -74,6 +74,8 @@ type Props = {
|
|||||||
onMetadataLineClick?: (videoName: string) => void;
|
onMetadataLineClick?: (videoName: string) => void;
|
||||||
popupEnabled?: boolean;
|
popupEnabled?: boolean;
|
||||||
bboxMode?: boolean;
|
bboxMode?: boolean;
|
||||||
|
bboxOverlay?: boolean;
|
||||||
|
onSideChange?: (assetId: number, side: "Left" | "Right") => void;
|
||||||
perfMode?: boolean;
|
perfMode?: boolean;
|
||||||
onPositionChange?: (id: number, vertex: Vertex, lat: number, lng: number) => void;
|
onPositionChange?: (id: number, vertex: Vertex, lat: number, lng: number) => void;
|
||||||
placeMode?: boolean;
|
placeMode?: boolean;
|
||||||
@@ -147,6 +149,8 @@ export function MapView({
|
|||||||
onMetadataLineClick,
|
onMetadataLineClick,
|
||||||
popupEnabled = true,
|
popupEnabled = true,
|
||||||
bboxMode = false,
|
bboxMode = false,
|
||||||
|
bboxOverlay = true,
|
||||||
|
onSideChange,
|
||||||
perfMode = false,
|
perfMode = false,
|
||||||
onPositionChange,
|
onPositionChange,
|
||||||
placeMode = false,
|
placeMode = false,
|
||||||
@@ -202,6 +206,8 @@ export function MapView({
|
|||||||
osmEditModeRef.current = osmEditMode;
|
osmEditModeRef.current = osmEditMode;
|
||||||
const onPickRoadRef = useRef(onPickRoad);
|
const onPickRoadRef = useRef(onPickRoad);
|
||||||
onPickRoadRef.current = onPickRoad;
|
onPickRoadRef.current = onPickRoad;
|
||||||
|
const onSideChangeRef = useRef(onSideChange);
|
||||||
|
onSideChangeRef.current = onSideChange;
|
||||||
const onUpdateRoadRef = useRef(onUpdateRoad);
|
const onUpdateRoadRef = useRef(onUpdateRoad);
|
||||||
onUpdateRoadRef.current = onUpdateRoad;
|
onUpdateRoadRef.current = onUpdateRoad;
|
||||||
const onRoadRightClickRef = useRef(onRoadRightClick);
|
const onRoadRightClickRef = useRef(onRoadRightClick);
|
||||||
@@ -1648,6 +1654,7 @@ export function MapView({
|
|||||||
img: HTMLImageElement,
|
img: HTMLImageElement,
|
||||||
asset: Asset,
|
asset: Asset,
|
||||||
slot: BboxSlot,
|
slot: BboxSlot,
|
||||||
|
editable: boolean,
|
||||||
) {
|
) {
|
||||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||||
let svg: SVGSVGElement | null = null;
|
let svg: SVGSVGElement | null = null;
|
||||||
@@ -1670,9 +1677,13 @@ export function MapView({
|
|||||||
if (!natW || !natH) return;
|
if (!natW || !natH) return;
|
||||||
|
|
||||||
// Pull the popup's fixed image size off the CSS variable so the
|
// Pull the popup's fixed image size off the CSS variable so the
|
||||||
// wrapper becomes a fixed-size scrollable viewport. The image is
|
// wrapper becomes a fixed-size scrollable viewport. Inside, both the
|
||||||
// resized below to its "contain" fit at zoom = 1; wheel zoom grows it
|
// image and the SVG render at the same CSS box; the image stays
|
||||||
// beyond the wrapper, triggering scrollbars.
|
// contain-fit so it never distorts, and the SVG's viewBox +
|
||||||
|
// preserveAspectRatio="xMidYMid meet" makes its coordinate space line
|
||||||
|
// up with the displayed image automatically — so a bbox at native
|
||||||
|
// 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 css = getComputedStyle(document.documentElement);
|
||||||
const baseW =
|
const baseW =
|
||||||
parseInt(css.getPropertyValue("--popup-img-w") || "440", 10) || 440;
|
parseInt(css.getPropertyValue("--popup-img-w") || "440", 10) || 440;
|
||||||
@@ -1683,38 +1694,38 @@ export function MapView({
|
|||||||
wrapper.style.maxWidth = `${baseW}px`;
|
wrapper.style.maxWidth = `${baseW}px`;
|
||||||
wrapper.style.maxHeight = `${baseH}px`;
|
wrapper.style.maxHeight = `${baseH}px`;
|
||||||
wrapper.style.overflow = "auto";
|
wrapper.style.overflow = "auto";
|
||||||
// Compute the contain-fit size at zoom = 1.
|
|
||||||
const aspect = natW / natH;
|
|
||||||
const fitW =
|
|
||||||
baseW / baseH > aspect ? baseH * aspect : baseW;
|
|
||||||
const fitH =
|
|
||||||
baseW / baseH > aspect ? baseH : baseW / aspect;
|
|
||||||
|
|
||||||
// The image and overlay need to scale together; both are sized in CSS
|
img.style.objectFit = "contain";
|
||||||
// pixels off the same zoom factor.
|
img.style.objectPosition = "left top";
|
||||||
img.style.objectFit = "fill";
|
img.style.width = `${baseW}px`;
|
||||||
img.style.width = `${fitW}px`;
|
img.style.height = `${baseH}px`;
|
||||||
img.style.height = `${fitH}px`;
|
|
||||||
img.style.display = "block";
|
img.style.display = "block";
|
||||||
|
|
||||||
svg = document.createElementNS(SVG_NS, "svg") as SVGSVGElement;
|
svg = document.createElementNS(SVG_NS, "svg") as SVGSVGElement;
|
||||||
svg.setAttribute("viewBox", `0 0 ${natW} ${natH}`);
|
svg.setAttribute("viewBox", `0 0 ${natW} ${natH}`);
|
||||||
svg.setAttribute("preserveAspectRatio", "none");
|
svg.setAttribute("preserveAspectRatio", "xMinYMin meet");
|
||||||
svg.style.position = "absolute";
|
svg.style.position = "absolute";
|
||||||
svg.style.left = "0";
|
svg.style.left = "0";
|
||||||
svg.style.top = "0";
|
svg.style.top = "0";
|
||||||
svg.style.width = `${fitW}px`;
|
svg.style.width = `${baseW}px`;
|
||||||
svg.style.height = `${fitH}px`;
|
svg.style.height = `${baseH}px`;
|
||||||
svg.style.cursor = "crosshair";
|
svg.style.cursor = editable ? "crosshair" : "default";
|
||||||
svg.style.touchAction = "none";
|
svg.style.touchAction = "none";
|
||||||
|
// In read-only mode the overlay must not intercept clicks — the map
|
||||||
|
// popup needs to stay closeable via its X button and clicks on the
|
||||||
|
// image should not start a draw.
|
||||||
|
if (!editable) {
|
||||||
|
svg.style.pointerEvents = "none";
|
||||||
|
}
|
||||||
wrapper.appendChild(svg);
|
wrapper.appendChild(svg);
|
||||||
|
|
||||||
// Zoom state — wheel anchored on the cursor. Bounded so the user
|
// Zoom state — wheel anchored on the cursor. Bounded so the user
|
||||||
// can't accidentally zoom to a useless extreme.
|
// can't accidentally zoom to a useless extreme. Both img and svg
|
||||||
|
// grow uniformly so the contain-fit overlay stays aligned.
|
||||||
let zoom = 1;
|
let zoom = 1;
|
||||||
const applyZoom = () => {
|
const applyZoom = () => {
|
||||||
const w = fitW * zoom;
|
const w = baseW * zoom;
|
||||||
const h = fitH * zoom;
|
const h = baseH * zoom;
|
||||||
img.style.width = `${w}px`;
|
img.style.width = `${w}px`;
|
||||||
img.style.height = `${h}px`;
|
img.style.height = `${h}px`;
|
||||||
if (svg) {
|
if (svg) {
|
||||||
@@ -1797,15 +1808,22 @@ export function MapView({
|
|||||||
labelText = null;
|
labelText = null;
|
||||||
handles = [];
|
handles = [];
|
||||||
if (!bbox) {
|
if (!bbox) {
|
||||||
// Hint that the user can drag to draw a bbox.
|
// Either editable (drag to draw) or read-only (nothing on this
|
||||||
|
// image yet). Surface state either way so the user can tell the
|
||||||
|
// difference between "not yet drawn" and "didn't load".
|
||||||
const hint = document.createElementNS(SVG_NS, "text");
|
const hint = document.createElementNS(SVG_NS, "text");
|
||||||
hint.setAttribute("x", "10");
|
hint.setAttribute("x", "10");
|
||||||
hint.setAttribute("y", "30");
|
// Font size scales with the natural image so it stays legible at
|
||||||
hint.setAttribute("fill", "rgba(255,255,255,0.7)");
|
// any aspect / popup size.
|
||||||
|
const fontPx = Math.max(20, Math.round(img.naturalWidth / 60));
|
||||||
|
hint.setAttribute("y", String(fontPx + 4));
|
||||||
|
hint.setAttribute("fill", "rgba(255,255,255,0.85)");
|
||||||
hint.setAttribute("stroke", "rgba(0,0,0,0.6)");
|
hint.setAttribute("stroke", "rgba(0,0,0,0.6)");
|
||||||
hint.setAttribute("stroke-width", "0.5");
|
hint.setAttribute("stroke-width", "0.5");
|
||||||
hint.setAttribute("font-size", "22");
|
hint.setAttribute("font-size", String(fontPx));
|
||||||
hint.textContent = "drag to draw a bbox";
|
hint.textContent = editable
|
||||||
|
? "drag to draw a bbox"
|
||||||
|
: "no bbox saved for this image";
|
||||||
svg.appendChild(hint);
|
svg.appendChild(hint);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1824,8 +1842,8 @@ export function MapView({
|
|||||||
rect.setAttribute("fill", "none");
|
rect.setAttribute("fill", "none");
|
||||||
rect.setAttribute("stroke", "#27ae60");
|
rect.setAttribute("stroke", "#27ae60");
|
||||||
rect.setAttribute("stroke-width", String(strokeW));
|
rect.setAttribute("stroke-width", String(strokeW));
|
||||||
rect.style.pointerEvents = "all";
|
rect.style.pointerEvents = editable ? "all" : "none";
|
||||||
rect.style.cursor = "move";
|
rect.style.cursor = editable ? "move" : "default";
|
||||||
svg.appendChild(rect);
|
svg.appendChild(rect);
|
||||||
|
|
||||||
// Class label: place above the bbox if there's room; otherwise inside
|
// Class label: place above the bbox if there's room; otherwise inside
|
||||||
@@ -1864,12 +1882,10 @@ export function MapView({
|
|||||||
labelG.appendChild(labelText);
|
labelG.appendChild(labelText);
|
||||||
svg.appendChild(labelG);
|
svg.appendChild(labelG);
|
||||||
|
|
||||||
// X delete badge at the top-right corner of the bbox. Always visible
|
// X delete badge at the top-right corner of the bbox. Editable mode
|
||||||
// while a bbox exists so the user can drop it in one click without
|
// only — read-only overlay just shows the saved annotation without
|
||||||
// hunting for the caption button. Drops inside the bbox if there's
|
// letting the user clear it.
|
||||||
// no room above; pulls left if it would overflow the image's right
|
if (editable) {
|
||||||
// edge.
|
|
||||||
{
|
|
||||||
const badgeR = Math.max(10, Math.round(natW / 90));
|
const badgeR = Math.max(10, Math.round(natW / 90));
|
||||||
let bx = x2;
|
let bx = x2;
|
||||||
let by = y1;
|
let by = y1;
|
||||||
@@ -2002,7 +2018,9 @@ export function MapView({
|
|||||||
// Persist current bbox to the DB. Skipped if too small (covered by
|
// Persist current bbox to the DB. Skipped if too small (covered by
|
||||||
// backend validation but checked here too so we don't spam errors).
|
// backend validation but checked here too so we don't spam errors).
|
||||||
// Surface success/failure via the SVG toast so the user has a clear
|
// Surface success/failure via the SVG toast so the user has a clear
|
||||||
// "saved" signal — the export sidecar reads the same DB rows.
|
// "saved" signal — the export sidecar reads the same DB rows. After a
|
||||||
|
// successful save, auto-flip the asset's side based on which half of
|
||||||
|
// the image the bbox center lives in.
|
||||||
const persist = async (): Promise<boolean> => {
|
const persist = async (): Promise<boolean> => {
|
||||||
if (!bbox) return false;
|
if (!bbox) return false;
|
||||||
const { x1, y1, x2, y2 } = bbox;
|
const { x1, y1, x2, y2 } = bbox;
|
||||||
@@ -2017,6 +2035,20 @@ export function MapView({
|
|||||||
y2,
|
y2,
|
||||||
});
|
});
|
||||||
showToast("✓ Saved", true);
|
showToast("✓ Saved", true);
|
||||||
|
const natW = img.naturalWidth;
|
||||||
|
const centerX = (x1 + x2) / 2;
|
||||||
|
const inferred: "Left" | "Right" =
|
||||||
|
centerX < natW / 2 ? "Left" : "Right";
|
||||||
|
const currentSide = (asset.side ?? "").toLowerCase();
|
||||||
|
const currentNormalized =
|
||||||
|
currentSide === "left" || currentSide === "lhs"
|
||||||
|
? "Left"
|
||||||
|
: currentSide === "right" || currentSide === "rhs"
|
||||||
|
? "Right"
|
||||||
|
: null;
|
||||||
|
if (currentNormalized !== inferred) {
|
||||||
|
onSideChangeRef.current?.(asset.id, inferred);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("set_asset_bbox failed", e);
|
console.warn("set_asset_bbox failed", e);
|
||||||
@@ -2209,21 +2241,24 @@ export function MapView({
|
|||||||
} else {
|
} else {
|
||||||
img.addEventListener("load", () => setup(), { once: true });
|
img.addEventListener("load", () => setup(), { once: true });
|
||||||
}
|
}
|
||||||
// Attach pointer listeners on wrapper after svg exists.
|
// Edit pointer wiring only when the user is actually allowed to edit.
|
||||||
// We attach them right away on wrapper-level so the events still fire
|
// In read-only overlay mode the SVG itself has pointer-events:none, so
|
||||||
// even before setup() runs (no-ops while svg is null).
|
// these listeners would never fire — but skip attaching them anyway to
|
||||||
wrapper.addEventListener("pointerdown", (ev) => {
|
// keep the intent obvious.
|
||||||
if (svg) onDown(ev as PointerEvent);
|
if (editable) {
|
||||||
});
|
wrapper.addEventListener("pointerdown", (ev) => {
|
||||||
wrapper.addEventListener("pointermove", (ev) => {
|
if (svg) onDown(ev as PointerEvent);
|
||||||
if (svg) onMove(ev as PointerEvent);
|
});
|
||||||
});
|
wrapper.addEventListener("pointermove", (ev) => {
|
||||||
wrapper.addEventListener("pointerup", (ev) => {
|
if (svg) onMove(ev as PointerEvent);
|
||||||
if (svg) onUp(ev as PointerEvent);
|
});
|
||||||
});
|
wrapper.addEventListener("pointerup", (ev) => {
|
||||||
wrapper.addEventListener("pointercancel", (ev) => {
|
if (svg) onUp(ev as PointerEvent);
|
||||||
if (svg) onUp(ev as PointerEvent);
|
});
|
||||||
});
|
wrapper.addEventListener("pointercancel", (ev) => {
|
||||||
|
if (svg) onUp(ev as PointerEvent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Expose a delete handle by attaching a property on the wrapper so the
|
// Expose a delete handle by attaching a property on the wrapper so the
|
||||||
// caller (showVertexPopup) can render a "Delete bbox" button that
|
// caller (showVertexPopup) can render a "Delete bbox" button that
|
||||||
@@ -2261,10 +2296,12 @@ export function MapView({
|
|||||||
root.className = "asset-popup";
|
root.className = "asset-popup";
|
||||||
const grid = document.createElement("div");
|
const grid = document.createElement("div");
|
||||||
grid.className = "popup-imgs";
|
grid.className = "popup-imgs";
|
||||||
// BBox mode wraps the image so the SVG overlay can sit on top using
|
// BBox overlay wraps the image so the SVG can sit on top using
|
||||||
// absolute positioning. Without bbox mode, image lives directly in the
|
// absolute positioning. The wrapper is created whenever the overlay is
|
||||||
// grid (original layout).
|
// turned on; edit interactions are further gated on bboxMode inside
|
||||||
const wrapper = bboxMode
|
// installBboxOverlay.
|
||||||
|
const overlayActive = bboxOverlay || bboxMode;
|
||||||
|
const wrapper = overlayActive
|
||||||
? (() => {
|
? (() => {
|
||||||
const w = document.createElement("div");
|
const w = document.createElement("div");
|
||||||
w.style.position = "relative";
|
w.style.position = "relative";
|
||||||
@@ -2280,11 +2317,13 @@ export function MapView({
|
|||||||
// fetchPriority hint (Chrome / WebKit) — prioritise the visible popup image.
|
// fetchPriority hint (Chrome / WebKit) — prioritise the visible popup image.
|
||||||
(img as HTMLImageElement & { fetchPriority?: string }).fetchPriority =
|
(img as HTMLImageElement & { fetchPriority?: string }).fetchPriority =
|
||||||
"high";
|
"high";
|
||||||
if (bboxMode) {
|
if (overlayActive) {
|
||||||
// BBox editing needs the FULL image visible; switch to contain so the
|
// Overlay mode (either editable bbox or read-only render): the image
|
||||||
// SVG overlay maps to known pixel space, and skip the pan-drag UX.
|
// must stay contain-fit so the SVG viewBox lines up. setup() inside
|
||||||
|
// installBboxOverlay completes the layout. Skip the cover-mode
|
||||||
|
// pan-drag handlers entirely so they can't fight the contain layout.
|
||||||
img.style.objectFit = "contain";
|
img.style.objectFit = "contain";
|
||||||
img.style.objectPosition = "center center";
|
img.style.objectPosition = "left top";
|
||||||
} else {
|
} else {
|
||||||
// The image uses object-fit:cover so the container's aspect is fixed.
|
// The image uses object-fit:cover so the container's aspect is fixed.
|
||||||
// Bias the visible crop toward the side the asset is on so the user
|
// Bias the visible crop toward the side the asset is on so the user
|
||||||
@@ -2337,7 +2376,7 @@ export function MapView({
|
|||||||
}
|
}
|
||||||
if (wrapper) {
|
if (wrapper) {
|
||||||
wrapper.appendChild(img);
|
wrapper.appendChild(img);
|
||||||
installBboxOverlay(wrapper, img, selectedAsset, slot);
|
installBboxOverlay(wrapper, img, selectedAsset, slot, bboxMode);
|
||||||
grid.appendChild(wrapper);
|
grid.appendChild(wrapper);
|
||||||
} else {
|
} else {
|
||||||
grid.appendChild(img);
|
grid.appendChild(img);
|
||||||
@@ -2351,13 +2390,36 @@ export function MapView({
|
|||||||
? `${selectedAsset.asset_name} · ${kindLabel}`
|
? `${selectedAsset.asset_name} · ${kindLabel}`
|
||||||
: selectedAsset.asset_name;
|
: selectedAsset.asset_name;
|
||||||
cap.appendChild(strong);
|
cap.appendChild(strong);
|
||||||
|
// Side tag + flip button. The tag is shown when a side exists; the
|
||||||
|
// flip button is always shown so a no-side asset can still be set.
|
||||||
|
cap.appendChild(document.createTextNode(" "));
|
||||||
if (selectedAsset.side) {
|
if (selectedAsset.side) {
|
||||||
cap.appendChild(document.createTextNode(" "));
|
|
||||||
const tag = document.createElement("span");
|
const tag = document.createElement("span");
|
||||||
tag.className = `side-tag side-${selectedAsset.side.toLowerCase()}`;
|
tag.className = `side-tag side-${selectedAsset.side.toLowerCase()}`;
|
||||||
tag.textContent = selectedAsset.side;
|
tag.textContent = selectedAsset.side;
|
||||||
cap.appendChild(tag);
|
cap.appendChild(tag);
|
||||||
}
|
}
|
||||||
|
const flipBtn = document.createElement("button");
|
||||||
|
flipBtn.type = "button";
|
||||||
|
const currentSide = (selectedAsset.side ?? "").toLowerCase();
|
||||||
|
const nextSide: "Left" | "Right" =
|
||||||
|
currentSide === "left" || currentSide === "lhs" ? "Right" : "Left";
|
||||||
|
flipBtn.textContent = `→ ${nextSide}`;
|
||||||
|
flipBtn.title = `Set side to ${nextSide}`;
|
||||||
|
flipBtn.style.marginLeft = "6px";
|
||||||
|
flipBtn.style.fontSize = "10px";
|
||||||
|
flipBtn.style.padding = "1px 6px";
|
||||||
|
flipBtn.style.border = "1px solid #7f8c8d";
|
||||||
|
flipBtn.style.background = "#fff";
|
||||||
|
flipBtn.style.color = "#34495e";
|
||||||
|
flipBtn.style.borderRadius = "10px";
|
||||||
|
flipBtn.style.cursor = "pointer";
|
||||||
|
flipBtn.addEventListener("click", (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
onSideChangeRef.current?.(selectedAsset.id, nextSide);
|
||||||
|
});
|
||||||
|
cap.appendChild(flipBtn);
|
||||||
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
|
||||||
@@ -2377,7 +2439,7 @@ export function MapView({
|
|||||||
small.appendChild(vidSpan);
|
small.appendChild(vidSpan);
|
||||||
}
|
}
|
||||||
cap.appendChild(small);
|
cap.appendChild(small);
|
||||||
if (bboxMode && wrapper) {
|
if (bboxMode && wrapper && bboxOverlay) {
|
||||||
cap.appendChild(document.createElement("br"));
|
cap.appendChild(document.createElement("br"));
|
||||||
const badge = document.createElement("span");
|
const badge = document.createElement("span");
|
||||||
badge.textContent = "BBox mode";
|
badge.textContent = "BBox mode";
|
||||||
@@ -2534,7 +2596,7 @@ export function MapView({
|
|||||||
markers.forEach((m) => m.remove());
|
markers.forEach((m) => m.remove());
|
||||||
if (activePopup) activePopup.remove();
|
if (activePopup) activePopup.remove();
|
||||||
};
|
};
|
||||||
}, [selectedAsset, imageFolder, popupEnabled, bboxMode]);
|
}, [selectedAsset, imageFolder, popupEnabled, bboxMode, bboxOverlay]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
|
|||||||
Reference in New Issue
Block a user