Skip to content

feat: MaskEditor UI component (#532)#550

Draft
shaoster wants to merge 11 commits into
mainfrom
issue/532-mask-editor
Draft

feat: MaskEditor UI component (#532)#550
shaoster wants to merge 11 commits into
mainfrom
issue/532-mask-editor

Conversation

@shaoster
Copy link
Copy Markdown
Owner

Summary

  • Full-screen mask editor Dialog with 5 tools: Pre-fill, Polygon, Flood fill, GrabCut (stub), Contour snap (stub)
  • Dark warm-graphite theme with oklch design tokens; Manrope/JetBrains Mono/Instrument Serif fonts
  • Two-canvas architecture: mask canvas (alpha = foreground) + overlay canvas (polygon verts, grabcut rect, cursor)
  • Undo/redo via ImageData refs (counts in reducer, snapshots in component refs); max 20 steps
  • onCommit outputs RGBA PNG blob with RGB zeroed, alpha = foreground mask

Polygon tool — click to add vertices, drag to move, right-click to delete, double-click / Enter / Close path button to fill and commit; Insert here (I), Smooth ×3, Apply simplify (Douglas-Peucker)

Flood fill — scanline BFS seeded from click position, RGB Euclidean tolerance against source image; 4-way/8-way connectivity; add/subtract modes

GrabCut — drag rect on overlay canvas; hint mode switching (R/F/G keys); Run GrabCut stubbed pending cv.grabCut() wiring

Contour snap — inspector controls wired; snap execution stubbed pending cv.Canny() wiring

Keyboard shortcuts — P/G/F/C/S switch tools; Enter finishes polygon; Backspace/Delete removes selected vertex; I inserts midpoint vertex; Cmd+Z/Shift+Cmd+Z undo/redo; Cmd+Enter runs GrabCut; [ ] adjust snap radius; R resets grabcut rect; F/G toggle grabcut hint mode when grabcut is active

Storybook — 7 stories (Gallery + 6 per-image launchers) pre-seeded with real pottery images stored in web/public/stories/

Out of scope (follow-up issues)

Test plan

  • bazel build --config=lint //web/... passes (32/32 tests green)
  • gz_story -> Components/MaskEditor -> open any image, verify polygon draw/commit, flood fill, undo/redo
  • Keyboard: G (switch tool), click 4+ vertices, Enter (commits polygon), Cmd+Z (undo)
  • GrabCut: drag rect, R to reset, F/G to switch hint mode
  • Commit mask button emits PNG blob (logged in story console)

Closes #532

Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com

🤖 Generated with Claude Code

shaoster and others added 3 commits May 19, 2026 01:57
…r panels, and Storybook stories

Complete UI skeleton for #532 — all 6 tools (prefill, brush, polygon, flood, grabcut, snap)
render with correct layout and inspector panels; canvas functionality and ML wiring are stubs
pending next iteration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Brush/eraser paint to alpha channel via pointer events. Flood fill uses scanline BFS seeded
from source image pixel color. Polygon tool tracks vertices on overlay canvas and fills on
double-click. GrabCut rect dragged on overlay canvas. Undo/redo wired with ImageData refs
(counts in reducer, snapshots in component refs). Mask canvas shows at 0.55 opacity over
source image; commit strips RGB channels to output RGBA PNG.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…sh tool

- Brush tool removed from toolbar and state (scope deferred per user request)
- Polygon: Close path button + Enter key commit, Insert here + I key, Smooth ×3, Apply simplify
  (Douglas-Peucker), Delete vertex + Backspace/Delete; all use polygon_vertices_set action
- Flood: Commit fill button wires to tool_applied dispatch
- GrabCut: R resets rect; F/G switch hint mode when grabcut is active; Cmd+Enter runs refine
- Snap: S snaps selected vertex, Shift+S snaps all, [ ] adjust radius
- Global: P/G/F/C/S switch tools; Cmd+Z undo, Cmd+Shift+Z / Cmd+Y redo
- Canvas ops (fillPolygon, floodFill, smoothPolygon, simplifyPolygon) extracted to
  maskEditorCanvasOps.ts so MaskEditor.tsx can call them directly for keyboard/button handlers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 19, 2026

shaoster and others added 8 commits May 19, 2026 09:13
… WASM

GrabCut: loadOpenCV() singleton, builds RGB cv.Mat from source image, GrabCut mask from
existing mask canvas alpha channel, runs cv.grabCut() with user rect + iterations, writes
GC_FGD/GC_PR_FGD pixels back to mask canvas. Uses GC_INIT_WITH_RECT on first run,
GC_EVAL when mask already has foreground pixels painted.

Contour snap: Canny/Sobel/Scharr edge map computed from grayscale source, then each target
vertex searches within snapRadius for the strongest edge pixel above the threshold.
Snap vertex (S) operates on selected vertex only; Snap all (Shift+S) operates on all
polygon vertices.

srcCanvasRef lifted to MaskEditor so both GrabCut and snap can access source pixels without
going through React state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add hintsCanvasRef (third canvas) for GC FG/BG scribble painting
- Pass hintsCanvas to runGrabCut so hint strokes are respected as GC_FGD/GC_BGD pins
- Fix grabcut hint mode routing: overlay receives pointer events for rect drag, hints canvas receives events in FG/BG modes
- Fix snap tool: redraw polygon overlay in snap mode so snapped positions are visible
- Add click-to-select vertex in snap mode on overlay canvas
- Add ←/→ arrow key cycling through vertices in snap mode
- Show "draw a polygon first" empty state in snap inspector when no vertices
- Update snap inspector Run section: show vertex count, disable buttons when no polygon

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Close path (⏎ / button) now sets polygonClosed=true, keeping all vertices
intact and switching from "add vertex on click" to "select vertex on click".
A separate "Apply to mask · ⏎" button (shown when closed) fills the polygon
and clears vertices. "Reopen path" restores add-vertex mode.

- Add polygonClosed: boolean to state with polygon_closed / polygon_reopened actions
- polygon_vertices_set always resets polygonClosed to false
- Canvas blocks vertex adds when polygonClosed; click-on-empty-space is a no-op
- Inspector shows Close/Reopen toggle + Apply button; ⏎ shortcut label updates accordingly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace three separate overlay effects (polygon, grabcut, clear-on-switch)
with a single composite effect that always draws polygon vertices on top of
any grabcut rect. Vertices are now visible regardless of active tool.

- Rename drawGrabCutOverlay → drawGrabCutRect (no longer owns clearRect)
- Composite effect: clear → draw polygon (if any) → draw grabcut rect (if grabcut tool)
- GrabCut live-drag handler composites the same way during pointer move
- drawPolygonOverlay gains `closed` param: squares when open, circles when closed
- Preview edge-to-cursor suppressed when path is closed (no new vertices)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add Spinner component to MaskEditorShared (CSS animation, no deps)
- Add loading prop to ActionButton: swaps icon for spinner, disables click
- GrabCut "Refine mask" and snap "Snap v / Snap all" buttons now show spinner while assistStatus=loading
- Fix polygon onLeave: redraw overlay without hover preview line so the dashed edge-to-cursor doesn't stick after cursor exits

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Polygon overlay visibility:
- Two-pass edge drawing: 3.5px black shadow + 1.5px accent, readable on any background
- Vertex handles: white fill + accent ring + dark shadow drop — visible on both dark and orange backgrounds
- Preview line to cursor also two-pass

Enter key race (Scenario B):
- Enter in polygon mode ONLY closes path (polygonClosed=false → polygon_closed)
- Pressing Enter when already closed is a no-op; apply requires the button
- Remove "· ⏎" label from "Apply to mask" button and keyboard hint

OpenCV WASM loading:
- isCvReady() exported from maskEditorCv.ts; WASM prefetch starts on module load
- Canvas shows "opencv.js loading…" pill with spinner when grabcut/snap tool is active and WASM hasn't resolved yet
- Once loaded, indicator disappears automatically

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Each mutating operation (polygon vertex add/move/delete, flood fill, hints
stroke, grabcut run, snap) now snapshots mask canvas + hints canvas + polygon
vertices + polygonClosed into a single UndoEntry before acting, so a single
Ctrl-Z reverses the whole operation.

Also separates Close path (Enter / button) from Apply to mask — Enter only
closes the polygon, never fills it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Paints alpha=0 circles on the mask canvas to erase foreground. Each
stroke start snapshots the mask into undo history, so individual strokes
are reversible. Radius adjustable via inspector slider or [ / ] keys.

Also fixes: unused `closed` param in drawPolygonOverlay now correctly
controls whether the last edge closes back to the first vertex.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@shaoster
Copy link
Copy Markdown
Owner Author

Checkpoint — session summary

What landed in this session (both commits pushed)

feat: unified undo history covering mask, hints, and polygon state

  • New UndoEntry = { mask: ImageData; hints: ImageData | null; vertices: Point[]; polygonClosed: boolean } type captures all mutable canvas + React state in one snapshot
  • Stable-ref pattern: pushUndoRef is synced via useEffect (no-dep-array) so event handlers always see fresh state without re-registering
  • Every mutating operation snapshots before acting: polygon vertex add/move/delete, flood fill click, hints stroke (pointerdown), GrabCut run, snap, insert/smooth/simplify
  • Undo/redo restores mask canvas pixels + hints canvas pixels + polygon vertex array + polygonClosed atomically
  • polygon_closed and polygon_reopened actions added; Enter key only closes the path, never applies it — applying requires the explicit "Apply to mask" button
  • polygon_state_restored action allows undo to restore vertex + closed state together

feat: add eraser tool

  • New eraser ToolName entry throughout (state, toolbar, canvas, inspector, footer)
  • Paints alpha=0 circles on mask canvas with destination-out composite op
  • Each pointerdown snapshots into undo history before stroke begins — per-stroke undoable
  • Inspector: radius slider; keyboard [ / ] adjust radius; E activates tool
  • Also fixes: closed param in drawPolygonOverlay now actually gates ctx.closePath() (was always closing before)

Known state / what to continue next

  • GrabCut "Refine mask" button disabled until rect is drawn — this is correct behavior; user must drag a bounding rect first in "Bounding rect" hint mode. The flow is: drag rect → optionally paint FG (orange) / BG (slate-blue) scribbles → click Refine mask.
  • Polygon vertices persist across tool switches — overlay composite effect redraws them whenever state.polygonVertices changes, regardless of active tool. Snap and GrabCut tools both read the shared vertex array.
  • OpenCV WASM loading indicator — shown when on grabcut/snap and WASM not yet initialized. Eagerly prefetched on module load.
  • react-hooks/refs lint rule — the pushUndoRef.current assignment was moved from render-time into a no-dep useEffect to satisfy the rule.

Files changed

web/src/components/MaskEditor.tsx
web/src/components/MaskEditorCanvas.tsx
web/src/components/MaskEditorFooter.tsx
web/src/components/MaskEditorIcons.tsx
web/src/components/MaskEditorInspector.tsx
web/src/components/MaskEditorToolbar.tsx
web/src/components/maskEditorState.ts

What remains (out of scope for #532 or follow-up)

  • Tests (MaskEditor.test.tsx) — the plan lists test cases; none written yet
  • Brush tool (paint foreground) — eraser added, brush not yet added; could be symmetric implementation
  • Zoom / pan on the canvas — toolbar buttons wired to icons only, no implementation
  • Actual IoU / pixel-count stats in footer/inspector are placeholder values
  • handleFloodCommit ("Commit fill" button) is a no-op — flood fill is already live on canvas the moment the user clicks; the button can be removed or repurposed

@shaoster shaoster marked this pull request as draft May 19, 2026 19:34
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.

feat: reusable MaskEditor UI component with browser-ML assists

2 participants