Per-shape hide + dimmed shape list in review editor (REQ-185, amended REQ-183)
- ReviewPage: hiddenShapeIds/overriddenShapeIds session Sets, H toggles selected/marked shapes, purge effect removed (dimmed rows selectable from list), auto-override on draw/assist/copy into hidden class, class-eye toggle clears that class's overrides - ReviewSidebar: all rows listed, dimmed rows get restore eye, header count = total shapes - ShortcutsPanel: H entry - docs: REQ-183 amended + REQ-185 added; design/ui-spec/tasks synced
This commit is contained in:
1 parent
96a00267d9
commit
6a36f102c9
7 files changed
+175
-57
No files matched your search
@@ -24,6 +24,8 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
const [error, setError] = useState('')
|
||||
const [activeJob, setActiveJob] = useState(null)
|
||||
const [hiddenClasses, setHiddenClasses] = useState(() => new Set())
|
||||
const [hiddenShapeIds, setHiddenShapeIds] = useState(() => new Set())
|
||||
const [overriddenShapeIds, setOverriddenShapeIds] = useState(() => new Set())
|
||||
|
||||
const batchId = batch?.id || rawBatchId
|
||||
const stripRef = useRef(null)
|
||||
@@ -111,6 +113,7 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
const created = await api.addAnnotation(frame.id, { class_id: activeClass, geometry })
|
||||
setAnnotations((rows) => [...rows, created])
|
||||
setSelectedId(created.id)
|
||||
if (hiddenClasses.has(created.class_id)) setOverriddenShapeIds((current) => new Set(current).add(created.id))
|
||||
patchFrameLocally(frame.id, { annotation_count: (frame.annotation_count ?? 0) + 1 })
|
||||
} catch (exc) { setError(exc.message) }
|
||||
}
|
||||
@@ -132,6 +135,7 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
const created = await api.assist(frame.id, { box, class_id: activeClass })
|
||||
setAnnotations((rows) => [...rows, created])
|
||||
setSelectedId(created.id)
|
||||
if (hiddenClasses.has(created.class_id)) setOverriddenShapeIds((current) => new Set(current).add(created.id))
|
||||
} catch (exc) { setError(exc.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
@@ -192,24 +196,69 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
else next.add(classId)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// REQ-183: while a class is hidden, no shape of it may be selected or
|
||||
// marked — however it got there (draw, reclass, copy) — so Delete can
|
||||
// never hit an invisible shape.
|
||||
useEffect(() => {
|
||||
if (!hiddenClasses.size) return
|
||||
setSelectedId((currentId) => {
|
||||
const row = annotations.find((a) => a.id === currentId)
|
||||
return row && hiddenClasses.has(row.class_id) ? null : currentId
|
||||
// REQ-185: toggling a class drops its per-shape overrides — re-hiding a
|
||||
// class starts from a clean slate.
|
||||
const classShapeIds = annotations.filter((row) => row.class_id === classId).map((row) => row.id)
|
||||
setOverriddenShapeIds((current) => {
|
||||
if (!classShapeIds.some((id) => current.has(id))) return current
|
||||
const next = new Set(current)
|
||||
classShapeIds.forEach((id) => next.delete(id))
|
||||
return next
|
||||
})
|
||||
setMarkedIds((rows) => rows.filter((id) => {
|
||||
const row = annotations.find((a) => a.id === id)
|
||||
return !row || !hiddenClasses.has(row.class_id)
|
||||
}))
|
||||
}, [hiddenClasses, annotations])
|
||||
}, [annotations])
|
||||
|
||||
const visibleAnnotations = annotations.filter((row) => !hiddenClasses.has(row.class_id))
|
||||
// REQ-185: the eye first un-hides an H-hidden shape, otherwise it overrides
|
||||
// the class hide — one rule, in one place.
|
||||
function restoreShape(id) {
|
||||
if (hiddenShapeIds.has(id)) {
|
||||
setHiddenShapeIds((current) => {
|
||||
const next = new Set(current)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
setOverriddenShapeIds((current) => (current.has(id) ? current : new Set(current).add(id)))
|
||||
}
|
||||
}
|
||||
|
||||
// REQ-185: neither the shape's own hide nor its class's hide (without an
|
||||
// override) may reach the canvas. Session-only, never persisted.
|
||||
const isShapeVisible = (row) =>
|
||||
!hiddenShapeIds.has(row.id) &&
|
||||
(!hiddenClasses.has(row.class_id) || overriddenShapeIds.has(row.id))
|
||||
|
||||
const visibleAnnotations = annotations.filter(isShapeVisible)
|
||||
|
||||
// REQ-185: H toggles hide on the single selection and every marked shape.
|
||||
function toggleHideSelected() {
|
||||
const ids = [...new Set([selectedId, ...markedIds].filter((id) => id != null))]
|
||||
const rows = ids.map((id) => annotations.find((row) => row.id === id)).filter(Boolean)
|
||||
if (!rows.length) return
|
||||
const toHide = rows.filter((row) => isShapeVisible(row)).map((row) => row.id)
|
||||
const toUnhide = rows.filter((row) => !isShapeVisible(row)).map((row) => row.id)
|
||||
if (toHide.length) {
|
||||
setHiddenShapeIds((current) => {
|
||||
const next = new Set(current)
|
||||
toHide.forEach((id) => next.add(id))
|
||||
return next
|
||||
})
|
||||
// Flags must not contradict: hiding drops any class-hide override.
|
||||
setOverriddenShapeIds((current) => {
|
||||
if (!toHide.some((id) => current.has(id))) return current
|
||||
const next = new Set(current)
|
||||
toHide.forEach((id) => next.delete(id))
|
||||
return next
|
||||
})
|
||||
}
|
||||
if (toUnhide.length) {
|
||||
setHiddenShapeIds((current) => {
|
||||
if (!toUnhide.some((id) => current.has(id))) return current
|
||||
const next = new Set(current)
|
||||
toUnhide.forEach((id) => next.delete(id))
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const markAll = useCallback(() => setMarkedIds(visibleAnnotations.map((row) => row.id)), [visibleAnnotations])
|
||||
|
||||
@@ -294,10 +343,16 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
setAnnotations(rows => [...rows, ...created])
|
||||
patchFrameLocally(frame.id, { annotation_count: (frame.annotation_count ?? 0) + created.length })
|
||||
if (created.length > 0) setSelectedId(created[0].id)
|
||||
const overridden = created.filter((row) => hiddenClasses.has(row.class_id)).map((row) => row.id)
|
||||
if (overridden.length) setOverriddenShapeIds((current) => {
|
||||
const next = new Set(current)
|
||||
overridden.forEach((id) => next.add(id))
|
||||
return next
|
||||
})
|
||||
} catch (exc) {
|
||||
setError(exc.message)
|
||||
}
|
||||
}, [frames, index, frame])
|
||||
}, [frames, index, frame, hiddenClasses])
|
||||
|
||||
const trackForward = useCallback(async () => {
|
||||
if (selectedId == null || !frames?.length) return
|
||||
@@ -339,7 +394,7 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
}, [annotations, selectedId, index, frames])
|
||||
|
||||
const stateRef = useRef({})
|
||||
stateRef.current = { frames, index, project, selectedId, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode, copyPrevious, trackForward, mode, setMode, markedIds, removeMarked, reclassMarked, setMarkedIds }
|
||||
stateRef.current = { frames, index, project, selectedId, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode, copyPrevious, trackForward, mode, setMode, markedIds, removeMarked, reclassMarked, setMarkedIds, toggleHideSelected }
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event) {
|
||||
@@ -347,9 +402,9 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
// Ctrl/Cmd/Alt combos belong to the browser and the OS: without this,
|
||||
// Ctrl+S approves the frame and Ctrl+A/C/X/N/T all fire review actions.
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return
|
||||
const { frames, project, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode, copyPrevious, trackForward, mode, setMode, markedIds, removeMarked, reclassMarked, setMarkedIds } = stateRef.current
|
||||
const { frames, project, setStatus, removeSelected, reclass, jumpToPending, jumpToNextAnnotated, setAssistMode, copyPrevious, trackForward, mode, setMode, markedIds, removeMarked, reclassMarked, setMarkedIds, toggleHideSelected } = stateRef.current
|
||||
const key = event.key
|
||||
const isShortcutKey = /^[1-9]$/.test(key) || ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Delete', 'Backspace', 'a', 'A', 'x', 'X', 'u', 'U', 's', 'S', 'n', 'N', 'c', 'C', 't', 'T', 'v', 'V', 'Escape'].includes(key)
|
||||
const isShortcutKey = /^[1-9]$/.test(key) || ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Delete', 'Backspace', 'a', 'A', 'x', 'X', 'u', 'U', 's', 'S', 'n', 'N', 'c', 'C', 't', 'T', 'v', 'V', 'h', 'H', 'Escape'].includes(key)
|
||||
if (isShortcutKey) { event.preventDefault(); event.stopPropagation() }
|
||||
|
||||
// In select mode the marquee owns Delete and the digits, otherwise a
|
||||
@@ -374,6 +429,7 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
else if (key === 'n' || key === 'N') jumpToNextAnnotated?.()
|
||||
else if (key === 'c' || key === 'C') copyPrevious?.()
|
||||
else if (key === 't' || key === 'T') trackForward?.()
|
||||
else if (key === 'h' || key === 'H') toggleHideSelected?.()
|
||||
else if (key === 'Delete' || key === 'Backspace') removeSelected?.()
|
||||
else if (/^[1-9]$/.test(key)) {
|
||||
const classId = Number(key) - 1
|
||||
@@ -652,6 +708,9 @@ export default function ReviewPage({ batchId: rawBatchId, projectId, onProject }
|
||||
clearClassInFrame={clearClassInFrame}
|
||||
hiddenClasses={hiddenClasses}
|
||||
onToggleHide={toggleHide}
|
||||
hiddenShapeIds={hiddenShapeIds}
|
||||
overriddenShapeIds={overriddenShapeIds}
|
||||
onRestoreShape={restoreShape}
|
||||
annotations={annotations}
|
||||
selectedId={selectedId}
|
||||
setSelectedId={setSelectedId}
|
||||
|
||||
Reference in new issue
Block a user