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

+13 -7
View File
@@ -355,13 +355,19 @@ sequentially and merges, without examples it is the unchanged full-set request.
per-class overrides table also carries a **Container** checkbox (REQ-184) that toggles
`project_classes.container` through `PATCH /api/projects/{id} { containers: … }`.
**Review sidebar class rows (REQ-180, REQ-183).** Each class row's frame-clear `×` (REQ-180)
gains an eye toggle in front of it: session-only `Set` state in `ReviewPage`, so it survives
frame changes, resets when the review page is left, and never touches stored data. Hiding a
class filters it out of the `annotations` array handed to `AnnotationCanvas` and to the
"Shapes on this frame" list, and a purge effect drops hidden shapes from the marked and
selected ids — so hidden shapes are not selectable, not marquee-selectable, not deletable —
while the row keeps its real per-frame count.
**Review sidebar class rows (REQ-180, REQ-183, REQ-185).** Each class row's frame-clear `×`
(REQ-180) gains an eye toggle in front of it: session-only `Set` state in `ReviewPage`, so it
survives frame changes, resets when the review page is left, and never touches stored data.
Hidden shapes are never handed to `AnnotationCanvas` — not drawn, not clickable, not
marquee-selectable — but they stay listed, dimmed, in "Shapes on this frame". Two session
Sets drive the hide half: `hiddenShapeIds` (the `H` key, REQ-185, which toggles the single
selection or all marked shapes) and `overriddenShapeIds` (the row-eye restore and the
auto-override below); a shape is visible when it is not `H`-hidden and (its class is visible
or it is overridden). Dimmed rows stay selectable, reclassable and deletable from the list —
the old "not deletable while hidden" purge invariant is superseded — while the class row
keeps its real per-frame count. Creating a shape while its class is hidden (draw, assist,
copy) auto-overrides it so the user sees what they just made; reclassing into a hidden class
dims it unless it already carries an override; toggling a class eye clears that class's overrides.
**Exemplar-driven labeling in review (REQ-173, REQ-174, REQ-175).** In `draw` mode a drag on
`AnnotationCanvas` is an exemplar, not a rectangle: `onExemplar(box, positive)` where
+16 -5
View File
@@ -186,11 +186,12 @@ changes.
the **current frame only** (REQ-046 stays batch-wide). One click, no confirmation dialog;
the next frame is untouched.
- **REQ-183** — In the manual review editor, each class row has a **hide toggle**. A hidden
class's shapes disappear from both the annotation canvas and the "Shapes on this frame"
list — not selectable, not marquee-selectable, not deletable while hidden — while the class
row keeps its real per-frame count and its eye state, so what is hidden stays visible as
state. The choice is session state: it survives frame changes, resets when the review page
is left, and never touches stored data.
class's shapes are never drawn on the annotation canvas — not clickable there, not
marquee-selectable — but they stay listed in "Shapes on this frame", dimmed, each dimmed
row carrying an eye that restores it individually (REQ-185). The class row keeps its real
per-frame count and its eye state, so what is hidden stays visible as state. The choice is
session state: it survives frame changes, resets when the review page is left, and never
touches stored data.
- **REQ-184** — A class can be marked as **container** (persisted on
`project_classes.container`, edited next to the per-class overrides in the auto-annotate
modals). When the cross-class NMS of REQ-031 compares another class's box against a
@@ -198,6 +199,16 @@ changes.
the smaller box's area lies inside the container's box, both survive. The flag changes
nothing else — unmarked classes are governed by IoU alone — and preview and batch job
read the same stored flag.
- **REQ-185** — In the manual review editor, the `H` key toggles hide on every currently
selected shape (the single selection, or all marked shapes in select mode). Hiding is per
shape: a shape hidden by `H`, and a shape hidden only because its class is hidden, both
stay listed in "Shapes on this frame" **dimmed**, where an eye button restores it — the
eye first un-hides an `H`-hidden shape, otherwise it overrides the class hide. Dimmed rows
remain selectable, reclassable and deletable from the list; they are simply never drawn
on the canvas. Shapes created while their class is hidden (draw, assist, copy) are
auto-overridden so the user sees what they just made. Like the class eye, this is session
state: it survives frame changes, resets when the review page is left, and never touches
stored data.
## E4. Live counting preview
+21 -5
View File
@@ -1285,14 +1285,16 @@ failure modes to the counting path. The saving was always on the browser side.
## Task — Per-class hide toggle in review editor (REQ-183) `[DONE]`
1. Eye button on each sidebar class row — a hidden class's shapes leave the canvas, the
"Shapes on this frame" list and every selection path while the row keeps its real
per-frame count and eye state; the choice is session-only (survives frame changes, resets
1. Eye button on each sidebar class row — a hidden class's shapes leave the canvas and every
canvas selection path but stay in the "Shapes on this frame" list, dimmed, each with its
own restore eye (amended by REQ-185; the old leave-the-list and "not deletable" semantics
are superseded — see the REQ-185 entry below), while the row keeps its real per-frame count
and eye state; the choice is session-only (survives frame changes, resets
when the review page is left, stored data untouched) → verify: **[DONE]** `cd frontend &&
npm run build` passes (✓ 535 ms, 69 modules); `wc -l` caps hold —
`frontend/src/components/Icons.jsx` (172) and
`frontend/src/components/ReviewSidebar.jsx` (129) ≤ 400,
`frontend/src/pages/ReviewPage.jsx` (666) exempt (pre-existing over the 400 cap, not
`frontend/src/components/ReviewSidebar.jsx` (145) ≤ 400,
`frontend/src/pages/ReviewPage.jsx` (725) exempt (pre-existing over the 400 cap, not
split by this task); rebuilt Docker frontend serves the new bundle (HTTP 200 on :9000,
`aria-pressed` present in the shipped JS); browser click-test of the eye toggle is NOT
automated — manual click-test pending.
@@ -1321,6 +1323,20 @@ The REQ-031 amendment rides on this entry: cross-class greedy NMS with the per-c
override and the containment carve-out is only real once the flag exists and both live sites
read it.
## Task — Per-shape hide + dimmed shape list (REQ-183 amended, REQ-185) `[DONE]`
1. `H` hides/shows the selected or marked shapes; hidden shapes (by `H` or by their class)
stay listed dimmed in "Shapes on this frame", each dimmed row with a restore eye that
un-hides or overrides; shapes created into a hidden class (draw, assist, copy)
auto-override; the class-eye toggle clears that class's overrides; the old purge effect
and its "not deletable" invariant are gone → verify: **[DONE]** `cd frontend && npm run
build` passes (✓ 545 ms, 69 modules); `wc -l` — `frontend/src/components/ReviewSidebar.jsx`
≤ 400 (145), `frontend/src/components/ShortcutsPanel.jsx` ≤ 400 (69),
`frontend/src/pages/ReviewPage.jsx` exempt (725, pre-existing over the 400 cap, not
split); `grep` proofs that `'h', 'H'` is in `isShortcutKey` (`ReviewPage.jsx:407`) and
that the removed purge effect (`while a class is hidden`) has zero hits left in
`ReviewPage.jsx`; browser `H`/eye click-test is NOT automated — manual click-test pending.
## Known open points
- *Not closed by any task, by choice:* **any rebuild kills the running job.** Task 14's resume
+18 -12
View File
@@ -1099,6 +1099,7 @@ Handlers are attached in the **capture** phase on `document`, and:
| `T` | track selected shape forward 5 frames | same |
| `1`–`9` | set active class; reclass the selected shape if any | reclass **all marked** shapes |
| `Del` / `Backspace` | delete the selected shape | delete **all marked** shapes (confirmed) |
| `H` | hide/show the selected shape(s); no-op with nothing selected | same |
| `Esc` | cancel the gesture in flight | clear the marked set |
| hold `S` | SAM3 assist mode while held | — |
| `Enter` | apply the exemplar preview (panel open) | — |
@@ -1125,18 +1126,23 @@ network.
- **Eye button (REQ-183)** — first control in the class row, before the frame-clear `×`: a
ghost icon button (`cursor: pointer`, `aria-pressed` bound to the hidden state, title
`Hide "name"` / `Show "name"`), drawing the eye icon while the class is visible and the
slashed eye at 55 % opacity while it is hidden. Hiding drops that class's shapes from the
annotation canvas **and** from *Shapes on this frame* — not selectable, not
marquee-selectable, not deletable while hidden, and any selection or mark on them is
cleared — while the row keeps its real per-frame count (the `×` and the trash still count
every shape). The choice is **session state**: it survives frame changes, resets when the
review page is left, and never touches stored data.
slashed eye at 55 % opacity while it is hidden. Hiding stops drawing that class's shapes on
the annotation canvas — not clickable there, not marquee-selectable — but the shapes stay
listed in *Shapes on this frame*, dimmed (≈45 % opacity), each dimmed row carrying its own
show control (REQ-185), while the class row keeps its real per-frame count (the `×` and
the trash still count every shape). The choice is **session state**: it survives frame
changes, resets when the review page is left, and never touches stored data.
- **Shapes on this frame (N)** — one row per annotation: swatch, class name, and either the
score to 2 dp (auto) or the word `manual`; click selects, trash deletes. When empty:
*"None on this frame — drag to draw a shape."* plus, if the batch has shapes elsewhere, a
*Jump to Frame with Shapes [N]* button.
- **Shortcuts** — a static `<dl>` of the keyboard map. Keep it visible; this is a keyboard-first
screen and the panel is the discovery mechanism.
score to 2 dp (auto) or the word `manual`; click selects, trash deletes. The header count
and the rows cover **every** annotation on the frame, visible or not. A row is dimmed
(≈45 % opacity) when its shape is `H`-hidden or hidden by its class without an override;
a dimmed row keeps its swatch, name and score and gains an eye button (*Show shape*, the
REQ-185 eye) that first un-hides an `H`-hidden shape and otherwise overrides the class
hide; click still selects it and the trash still deletes it. Empty means the frame has
**zero shapes**, not zero visible ones: *"None on this frame — drag to draw a shape."*
plus, if the batch has shapes elsewhere, a *Jump to Frame with Shapes [N]* button.
- **Shortcuts** — a static `<dl>` of the keyboard map (it also lists `H`). Keep it visible;
this is a keyboard-first screen and the panel is the discovery mechanism.
---
@@ -1516,7 +1522,7 @@ Shortcuts).
Overlap/NMS, Min box size, Max shapes), lalu baru **Apply** yang menulis. Shift-drag = contoh
negatif ("bukan ini"). Panel filter duduk di **sidebar**, bukan menutupi frame.
- Tahan `S` = SAM3 click-assist (satu box → satu shape).
- Keyboard-first: `V ← → A X U N C T 1–9 Del Esc Enter`. Semua aksi punya shortcut; panel
- Keyboard-first: `V ← → A X U N C T 1–9 Del H Esc Enter`. Semua aksi punya shortcut; panel
Shortcuts selalu terlihat. Ctrl/Cmd/Alt **tidak pernah** di-intercept; mengetik di input tidak
memicu shortcut.
- Approve/Reject langsung pindah frame dan meng-update status lokal sebelum request selesai.
+24 -8
View File
@@ -12,6 +12,9 @@ export default function ReviewSidebar({
clearClassInFrame,
hiddenClasses,
onToggleHide,
hiddenShapeIds,
overriddenShapeIds,
onRestoreShape,
annotations,
selectedId,
setSelectedId,
@@ -20,9 +23,6 @@ export default function ReviewSidebar({
jumpToNextAnnotated,
batchAnnotationCount,
}) {
// REQ-183: the shapes panel lists visible shapes only; class rows keep
// counting every shape of the class (data truth).
const visibleAnnotations = annotations.filter((row) => !hiddenClasses.has(row.class_id))
return (
<aside className="review-side stack">
{exemplarPanel}
@@ -76,8 +76,8 @@ export default function ReviewSidebar({
</div>
<div className="panel side-panel">
<h2>Shapes on this frame ({visibleAnnotations.length})</h2>
{visibleAnnotations.length === 0 ? (
<h2>Shapes on this frame ({annotations.length})</h2>
{annotations.length === 0 ? (
<div>
<p className="hint" style={{ marginBottom: 8 }}>
None on this frame — drag to draw a shape.
@@ -101,8 +101,12 @@ export default function ReviewSidebar({
</div>
) : (
<ul className="shape-list">
{visibleAnnotations.map((item) => (
<li key={item.id} className={item.id === selectedId ? 'selected' : ''}>
{annotations.map((item) => {
const dimmed =
hiddenShapeIds.has(item.id) ||
(hiddenClasses.has(item.class_id) && !overriddenShapeIds.has(item.id))
return (
<li key={item.id} className={item.id === selectedId ? 'selected' : ''} style={dimmed ? { opacity: 0.45 } : undefined}>
<button className="shape-pick" onClick={() => setSelectedId(item.id)}>
<span className="swatch" style={{ background: classColor(item.class_id) }} />
{project?.classes?.find?.((c) => c.class_id === item.class_id)?.name ?? project?.classes?.[item.class_id]?.name ?? `class ${item.class_id}`}
@@ -110,6 +114,17 @@ export default function ReviewSidebar({
{item.source === 'auto' ? item.score.toFixed(2) : 'manual'}
</span>
</button>
{dimmed && (
<button
className="btn btn-ghost"
style={{ padding: '4px 6px', cursor: 'pointer' }}
title="Show shape"
aria-label="Show shape"
onClick={() => onRestoreShape(item.id)}
>
<EyeIcon size={12} />
</button>
)}
<button
className="btn btn-danger"
aria-label="Delete shape"
@@ -118,7 +133,8 @@ export default function ReviewSidebar({
<TrashIcon size={13} />
</button>
</li>
))}
)
})}
</ul>
)}
</div>
@@ -43,6 +43,10 @@ export default function ShortcutsPanel() {
<dt><kbd>Del</kbd></dt>
<dd>Remove selected</dd>
</div>
<div>
<dt><kbd>H</kbd></dt>
<dd>Hide / show selected shapes</dd>
</div>
<div>
<dt><kbd>←</kbd> <kbd>→</kbd></dt>
<dd>Prev / next frame</dd>
+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}