Skip to content

feat(ui-core): the class picker gets a second anchor, on the shape itself (#380) - #397

Merged
JArmandoAnaya merged 3 commits into
mainfrom
feat/canvas-reassign
Aug 7, 2026
Merged

feat(ui-core): the class picker gets a second anchor, on the shape itself (#380)#397
JArmandoAnaya merged 3 commits into
mainfrom
feat/canvas-reassign

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

The Annotations panel has offered class reassignment per row since #378 — every class the
pinned schema declares, the incompatible ones disabled and carrying the reason, applied
through replaceAnnotationCommand so it lands in the undo history. #368's decision 9
deferred the second anchor: the same picker on the canvas, over the shape.

This is that anchor, and the whole of it is that it is the same picker.

Closes #380

Same component, second mount point

ReassignMenu.tsx is new and holds everything the menu decides — the class list, the
declared.geometry === annotation.geometry.type predicate, the disabled-with-reason
rendering, the hotkey and the call that applies. AnnotatorPanel's private ReassignMenu
was moved into it, not copied: what is left in the panel is RowReassign, a trigger
and a row number, which are the only two things a list legitimately has that a shape does
not.

So there is no second class-list derivation, no second legality rendering and no second
apply path — and that claim is mutation-verified rather than asserted (below).

Panel row Canvas
Trigger 24px ghost tag button in the row 24px secondary tag button on the shape
Anchor the row, align="end" the shape's top-right, align="start"
Subject the row's annotation the single selected annotation
Everything else ReassignMenu ReassignMenu

What is new for both anchors

The brief asks for class hotkeys shown, and a hotkey while the picker is open
reassigns
. That lives in the shared component, so the panel's row menu gained it too —
a deliberate consequence of "same component", not scope creep, and panel.test.tsx now
pins it at that anchor as well.

Two decisions inside it:

  • The hotkey is shown only where it works. A disabled row spends its right-hand slot
    on the reason instead. A key chip on a row that refuses the key is a lie, and the reason
    is the actionable half — the same argument the disabled rows exist for, one column over.
  • The digit is claimed whether or not the class fits, so it cannot fall through to
    Radix's typeahead and move the highlight to whichever class name happens to start with
    that character. digitFromCode(event.code) ?? event.key, the spelling AnnotatorCanvas
    uses, because the digit row is a row of positions and on AZERTY 1 arrives as &.

Both mounts now hold open themselves rather than leaving it to Radix: a hotkey is not an
item selection, so nothing would dismiss the menu after a digit reassigns.

The canvas mount

CanvasReassign.tsx, a sibling of the canvas inside the stage — ToolPalette's placement,
for its reason: the stage is relative and the annotator ships no chrome.

  • The button rides the shape, above its top-right corner. imageToScreen over the
    geometry's bbox, so the position is derived from the same transform the stage's own
    translate/scale is built from and cannot disagree with what was drawn — no DOM read,
    no ResizeObserver. Above rather than on the corner because a selected bbox draws a
    resize grip exactly there, and a button centred on it would be the one thing standing
    between somebody and resizing what they had just selected.
  • Right-click opens the same menu, because both ways in share one anchor: the press
    sets openFor and Radix positions the content against the button, with the flipping,
    the collision handling and the portal (which is what keeps the content out of the
    stage's overflow: hidden) all the primitive's own.
  • openFor is an id, not a boolean. Deleting the selected shape while the menu is
    open, or selecting a different one from the panel, would otherwise leave open true
    over a subject that had changed underneath it. An id is false by construction the moment
    the subject is not the one that was opened — no effect, no cleanup.
  • Absent, never disabled, in four cases: read-only, nothing selected, more than one
    selected, and a classification tag. The last two are the same rule from two sides — the
    menu changes one annotation's class and anchors to one position, and a tag is the
    only annotation the canvas never renders, so there is no position on the stage that
    means it. Tags stay reachable from their panel row, which is a list and needs no anchor.

Read-only is enforced in CanvasReassign and nowhere else. The page still hands
AnnotatorCanvas its onAnnotationMenu when the frame cannot be written, deliberately: a
second guard up there would keep the behaviour correct with this one deleted, which is a
test that cannot fail (#378's finding).

The one annotator change, and why it is in the adapter

AnnotatorCanvas gains one optional prop, onAnnotationMenu?: (annotationId: string) => void. It is a report, not an action — what a right-click should open is entirely the
host's business, but which annotation is under a client position is not something a host
can answer without re-deriving the transform and the hit test that already live here. The
brief's own constraint ("no new global listeners; pointer handling stays in the adapter
layer") is what puts it there.

It rides the browser's contextmenu rather than a secondary pointer-down, and that is
deliberate on both sides:

  • A secondary press is a pan, and consuming it would take the gesture away from the two
    interaction-table rows that still have no browser spelling — cf. #129, which stays
    exactly as it was.
  • contextmenu arrives after the press has already started its pan, so a
    click-with-no-travel pans by zero and nothing about the existing grammar moves. The
    onContextMenu handler that was already there for preventDefault() is the same one.

It resolves through topmostAnnotationAt over withoutHidden(...) at
tolerances.shape — the same three inputs resolveTarget's body branch uses, so what a
right-click hits and what a left-click would have selected are one rule. A hidden shape
does not answer, for visibility.ts's reason: a shape you cannot see must not swallow a
press, and a menu is a press. readOnly does not gate it — the rule that component
enforces is that input may not change the document, and reporting a hit changes nothing.

Tests

frontend/ui-core/src/annotator/canvasReassign.test.tsx — 13, against a real
AnnotatorStore, no mock and no provider (panel.test.tsx's shape). Four new in
frontend/app/e2e/annotate.spec.ts, chromium.

Claim Test
opens from the canvas trigger on a selected shape canvasReassignrides the one selected shape, and names what it will reassign
right-click opens it annotate.spec.tsright-clicking a shape opens its class picker, and the class lands through the command
applies through the command; undo works canvasReassignapplies through the command, so undo takes the class back · the e2e above (mod+z back to vehicle)
incompatible class disabled-with-reason canvasReassignlists every class, disabling the ones this shape cannot become and saying why · the e2e above (needs a polygon)
hotkey-while-open reassigns canvasReassignreassigns on a class hotkey and closes itself… · panelreassigns on a class hotkey, at this anchor as at the other one
a hotkey for a refused class does nothing canvasReassignchanges nothing on a digit for a class this shape cannot become, and stays open
read-only shows no trigger canvasReassignis absent entirely when the document cannot be written
anchored to the shape annotate.spec.tsthe picker's button rides the shape, above the corner its resize grip owns
the hit test is real annotate.spec.tsa right-click on empty canvas opens nothing, because the hit test is real
Esc closes with no effect canvasReassigncloses on Escape with the document untouched · annotate.spec.tsEscape closes the canvas picker and leaves the object alone
no subject → no picker is absent with nothing selected · is absent with two selected, rather than silently acting on the first · is absent for a classification tag, which the canvas draws nowhere
panel unchanged (regression pin) the 26 pre-existing panel.test.tsx cases and e2e/panel.spec.ts, untouched and green

The split follows #47's precedent: jsdom's getBoundingClientRect answers all zeros, so
the transform — the riskiest part — is exactly what a unit test could never verify. The
two claims that need a real browser are in chromium; everything the menu decides is a
component claim.

Mutation-verified

Seven, each reverted by its exact recorded diff (git apply -R), anchor asserted present
exactly once before and the replacement after.

Mutation Red
the shared component forked into CanvasReassign, diverging by filtering incompatible classes out instead of disabling them 4 named canvas tests, led by lists every class, disabling the ones this shape cannot become and saying why
the adapter's body tolerance → Number.POSITIVE_INFINITY annotate.spec.tsa right-click on empty canvas opens nothing, because the hit test is real (chromium)
the anchor reads the shape's top-left corner annotate.spec.tsthe picker's button rides the shape… (chromium)
readOnly dropped from the guard is absent entirely when the document cannot be written
selected.length === 1selected[0] is absent with two selected…
anchorFor returns an origin instead of null is absent for a classification tag…
the apply bypasses store.execute applies through the command… + the hotkey test

The first is the brief's own required mutation, and it is the one that makes
"single spelling" a claim rather than a hope.

The e2e fixture gained a class

annotate.spec.ts's pinned schema declared one bbox class (vehicle) and one polygon
class, so a bbox had nowhere to be reassigned to and the apply could not be proved in a
browser at all. It now also declares pedestrian (bbox). It adds no tool — the palette
is per geometry — and one hotkey row, which the shortcut-sheet scenario counts and now
counts as three.

Gate

Full run, staged around the ~10-minute harness ceiling, exit codes verbatim:

Stage Exit
scripts/check.sh frontend generated 0 (82s)
scripts/check.sh python 0 (459s)
scripts/check.sh browser 0 — annotator + app e2e 146s, real-server cycle 30s

206 app/annotator e2e scenarios and 687 ui-core unit tests, all green. No kernel change,
no wire change, no migration: openapi.json, the generated client and FORMAT_VERSION
are all byte-still.

Found, not fixed

  • The panel row's align="end" and the canvas's align="start" are the only two props
    that differ between the mounts, and they are passed rather than derived. If a third
    anchor ever appears it should carry its own placement the same way — there is no rule
    here to generalise yet.
  • CanvasReassign is not exported from @visionset/ui-core's public entry, so the
    showcase (frontend/app/src/demo/) still shows the panel anchor only. Mounting it there
    would put a floating button over the demo canvas that ~40 existing showcase scenarios
    drag through, and that is a decision worth taking on its own rather than as a side
    effect of this one.

@JArmandoAnaya
JArmandoAnaya enabled auto-merge (squash) August 7, 2026 07:24
@JArmandoAnaya
JArmandoAnaya merged commit 08433b7 into main Aug 7, 2026
14 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the feat/canvas-reassign branch August 7, 2026 07:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Canvas-anchored class reassignment picker

1 participant