Skip to content

fix(expo): Android annotation gestures + drag-to-move placed shapes - #27

Merged
Ripwords merged 3 commits into
mainfrom
fix/expo-android-annotation-gestures
Sep 21, 2026
Merged

Ripwords merged 3 commits into
mainfrom
fix/expo-android-annotation-gestures

Conversation

@Ripwords

Copy link
Copy Markdown
Owner

Why

Reported from the field: in the Expo wizard's Annotate step nothing could be drawn on the screenshot. It reproduced in two different host apps, so it was ours, not a local setup. iOS drew fine — but on iOS a placed text label could not be moved.

1. Android: gestures were dead inside the wizard modal

The wizard is a react-native Modal. On Android that is a separate native window (ReactModalHostView.DialogRootViewGroup) sitting outside the host app's GestureHandlerRootView, so gesture-handler receives no touches inside it and the canvas' pan gesture never fires. The toolbar kept working (plain Pressable), which is why it read as a broken canvas rather than a dead gesture. Gesture-handler's own root view detects this case and activates a nested root, so the wizard now carries its own.

Evidence it was never the host app's setup: the launcher opens the wizard via an RNGH Gesture.Tap() in the app's own tree — if gesture-handler were missing or unrooted, the bug button would not have opened anything.

2. Annotations are now movable

The toolbar gains a cursor tool: tap a shape to select it (dashed outline), drag to move it, tap empty canvas to deselect. Works for text, arrows, rectangles, highlights and pen strokes.

  • A whole drag is one undo step — undo restores the position instead of deleting the shape, which needed the store to move from append-only to snapshot history.
  • Shapes are grabbed by their ink: arrows/pen by distance to the line, boxes/text anywhere inside.
  • Drags are clamped to the canvas, so nothing can be dragged out of reach.
  • The selection outline is drawn by the canvas alone, so it can never bake into the submitted PNG.
  • Geometry (shapeBounds, hitTest, translateShape, clampTranslation) is pure and lives in @reprojs/sdk-utils; packages/ui is untouched, and select deliberately stays out of the shared Tool union.

Not included: resize, rotate, multi-select, web parity.

Testing

  • 23 new pure geometry tests in sdk-utils.
  • New component-test harness for the Expo package (src/test-support/native-mocks.ts) under react-test-renderer: asserts the gesture root wraps the canvas, and drives real tap/drag gestures to check selection, the committed move, single-step undo and clamping.
  • scripts/test-sdk.sh now discovers *.test.tsx — its find only matched *.test.ts, so component tests would have been skipped in CI silently.
  • system-info.test.ts takes react-native from the shared stub; bun test shares one module registry, and its partial stub decided what other files could import.
  • bun run check, bun run test:sdk, bun run expo:build, tsc --noEmit all green.

🤖 Generated with Claude Code

Ripwords and others added 3 commits September 21, 2026 18:03
Nothing could be drawn in the Annotate step on Android: strokes were ignored
while the toolbar kept working, so it read as a broken canvas. iOS was fine.

A react-native Modal on Android is a separate native window
(ReactModalHostView.DialogRootViewGroup) that sits outside the host app's
GestureHandlerRootView, so gesture-handler receives no touches inside it and
the pan gesture never fires. Gesture-handler's own root view detects this case
and activates a nested root for it, so the wizard has to carry its own.
The launcher is unaffected because it renders in the app's own tree, which is
why tapping the bug button still opened the wizard.

Tests need react-native mounted under bun, which means stubbing the native
modules; that harness lands in src/test-support/native-mocks.ts. bun test
shares one module registry across files, so system-info.test.ts now takes
react-native from the same stub instead of registering a partial one that
decided what the component tests could import. test-sdk.sh grew *.test.tsx
discovery — its find only matched *.test.ts, so component tests would never
have run in CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Annotations were write-once: a text label that landed in the wrong spot could
only be undone and retyped, and the same went for arrows and boxes. The
toolbar gains a cursor tool — tap a shape to select it, drag to move it, tap
empty canvas to deselect.

The geometry is pure and lives in @reprojs/sdk-utils (shapeBounds, hitTest,
translateShape, clampTranslation) so the web widget can adopt the same
behaviour later; nothing in packages/ui changes here. Shapes are grabbed by
their ink — arrows and pen strokes by distance to the line, boxes and text
anywhere inside — because a 2px border is not a finger-sized target. Text
needs an estimated box: react-native offers no synchronous measurement, and
SVG text is positioned by its baseline.

"select" stays out of sdk-utils' Tool union, which means "a tool that produces
a shape" and backs an exhaustive Record<Tool, ToolHandler> in the web canvas;
the expo canvas takes a local CanvasMode instead.

The store moves from append-only to snapshot history, because undo now has to
reverse edits as well as additions — popping the last shape would delete the
label you just nudged instead of putting it back. A whole drag commits once,
on release, so it costs one history entry rather than one per frame. Drags are
clamped to the canvas: a shape dragged past the edge would leave nothing to tap
to get it back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cursor tool makes nine buttons in the first toolbar row. At 44pt each plus
gaps that is ~416pt, which does not fit a 375pt phone, and Yoga defaults
flexShrink to 0 — the row would have run off the edge and taken the trash
button with it. The buttons now shrink to no less than 34pt, so they stay at
44pt wherever there is room.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Ripwords
Ripwords merged commit 5e25e20 into main Sep 21, 2026
8 checks passed
@Ripwords
Ripwords deleted the fix/expo-android-annotation-gestures branch September 21, 2026 10:11
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.

1 participant