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:
asus committed 2026-10-02 13:22:50 +07:00
1 parent 96a00267d9
commit 6a36f102c9
7 files changed
+175 -57

No files matched your search

+79 -20
View File
@@ -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}