Skip to content

feat(uploads) add image annotation tools to composer uploads#1488

Open
tellaho wants to merge 8 commits into
mainfrom
tho/uploaded-image-canvas
Open

feat(uploads) add image annotation tools to composer uploads#1488
tellaho wants to merge 8 commits into
mainfrom
tho/uploaded-image-canvas

Conversation

@tellaho

@tellaho tellaho commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator
Screen.Recording.2026-07-02.at.11.19.20.PM.mov

Category: new-feature
User Impact: Users can now draw on uploaded images before sending them from the desktop composer.
Problem: Uploaded images could only be previewed or removed, so any quick markup required leaving Buzz, editing elsewhere, and re-uploading. That made lightweight annotation feel heavier than the message it was supporting.
Solution: This adds an in-composer image editor with draw, undo/redo, save, and revert flows, while preserving originals across drafts, spoiler toggles, sends, and cancels. Export now fetches the source media bytes over Tauri IPC and wraps them in a same-origin blob URL, so canvas editing works without broadening the media proxy's CORS/origin policy.

File changes

desktop/playwright.config.ts
Adds the project configuration needed for the new image-editor browser coverage.

desktop/src-tauri/src/commands/media_download.rs
Adds a validated fetch_media_bytes command for the composer image editor. It reuses the same relay URL validation, size cap, MIME checks, and Rust-side fetch path as media downloads so canvas export can avoid webview CORS entirely.

desktop/src-tauri/src/lib.rs
Registers the new media-byte fetch command with Tauri so the desktop webview can request source image bytes for editor export.

desktop/src/features/forum/ui/ForumComposerMediaStatus.tsx
Routes forum image attachments through the shared edited-upload path so replacement uploads report progress consistently with the message composer.

desktop/src/features/messages/lib/useAttachmentEditing.ts
Introduces the composer glue for saving edited attachments, reverting to originals, and migrating spoiler state across URL swaps.

desktop/src/features/messages/lib/useMediaUpload.ts
Adds the edited-attachment upload path and in-memory original/revert bookkeeping while preserving attachment order. It also prunes edit state when attachments leave the composer through send, remove, cancel, or draft changes.

desktop/src/features/messages/ui/ComposerAttachments.tsx
Extends the attachment lightbox with draw entry points, save/revert controls, dialog behavior, and image/video-specific affordances.

desktop/src/features/messages/ui/ComposerImageEditor.tsx
Adds the canvas drawing experience, including brush colors and width, brush preview, undo/redo, keyboard shortcuts, save disabled states, and PNG export from source bytes.

desktop/src/features/messages/ui/MessageComposer.tsx
Connects message send, cancel, and draft transitions to the edited-attachment lifecycle so temporary edit state is cleared or restored at the right time.

desktop/src/shared/api/tauriMedia.ts
Adds the frontend wrapper for fetching relay media bytes over Tauri IPC and returning them as a Uint8Array for same-origin blob export.

desktop/src/shared/hooks/useWebviewScrollBoundaryLock.ts
Refines scroll-boundary locking for modal/lightbox interactions so drawing and overscroll behavior do not fight each other.

desktop/src/shared/styles/globals/theme.css
Removes global scrollbar styling that conflicted with the updated modal and scroll-boundary behavior.

desktop/src/testing/e2eBridge.ts
Adds an E2E mock for fetch_media_bytes so image-editor tests can exercise the IPC export path in browser automation.

desktop/tests/e2e/composer-image-draw.spec.ts
Adds end-to-end coverage for drawing, saving, upload replacement, reverting, Escape behavior, and spoiler URL migration.

desktop/tests/e2e/overscroll-boundary.spec.ts
Updates overscroll-boundary coverage to match the shared hook behavior after the lightbox/editor changes.

Reproduction Steps

  1. Open the desktop composer and upload an image attachment.
  2. Open the attachment lightbox and click the draw control; the image should enter canvas edit mode with pen controls.
  3. Draw a stroke, use undo/redo as needed, and save; the save button should stay disabled until there is a stroke, then upload an annotated PNG replacement and close the lightbox.
  4. Reopen the annotated attachment and use Revert; the original attachment should be restored in the same slot.
  5. Mark an attachment as a spoiler, draw on it, and save; the spoiler treatment should follow the edited attachment URL.
  6. Cancel, remove, send, and switch drafts with edited attachments; edited state should be pruned or restored consistently.
  7. Confirm image export works without any media-proxy CORS changes by saving an edit from a proxied relay image.

Screenshots/Demos

Draft PR — demo or screen recording to be added before marking ready.

tellaho added 7 commits July 2, 2026 20:38
Add a freehand annotation mode to composer image attachments: a pencil
button in the attachment lightbox enters canvas mode, saving composites
the strokes into a PNG at natural resolution and re-uploads it as an
in-place replacement, and a revert button restores the pre-edit
original — both without closing the dialog.

- ComposerImageEditor.tsx (new): canvas overlay editor with 6 pen
  colors, 3 stroke widths, undo (button + Cmd+Z), clear, cancel, and
  save. Strokes are stored in natural-image coordinates so the
  on-screen preview matches the exported PNG exactly; export loads the
  image with crossOrigin="anonymous" plus a ?cors=1 cache-buster so
  year-long immutable cache entries from before the CORS fix can't
  poison the CORS check
- useMediaUpload.ts: add uploadEditedAttachment (uploads annotated
  bytes, swaps the descriptor into the same slot, disables send while
  in flight), revertAttachment, and an originalsByUrl map that
  remembers pre-edit originals; chained edits keep the earliest
  original and entries prune automatically when attachments leave the
  composer
- ComposerAttachments.tsx: extract MediaAttachmentItem with
  view/edit modes, pencil + revert + close toolbar in the lightbox,
  and Escape-in-edit-mode exiting canvas mode instead of closing the
  dialog; items are keyed by original URL so the URL swap on
  save/revert doesn't remount (and close) the open dialog
- media_proxy.rs: send Access-Control-Allow-Origin: * on proxied media
  responses — WKWebView omits the Origin header on CORS-mode image
  GETs while still enforcing the response header, so echoing the
  request origin can never work; the origin gate still 403s foreign
  origins first, and now also allows loopback dev origins
  (http://localhost:<port>) since tauri dev serves the webview from a
  per-worktree localhost port, with unit tests for the gate
- useAttachmentEditing.ts (new) + MessageComposer.tsx: wire save/revert
  and migrate spoiler marking to the replacement URL so an edited
  spoilered image stays spoilered; ForumComposerMediaStatus.tsx wires
  the same for forum composers
- composer-image-draw.spec.ts (new, registered in the smoke project):
  covers draw → save → in-place replace, Escape behavior, revert
  without dialog close, and spoiler survival across an edit

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
… bar

- Move Cancel/Save CTAs from the floating pen toolbar to a fixed bar in
  the top-right corner of the lightbox overlay, using the shared
  Button component (ghost variant for Cancel, default for Save)
- Move the drawing controls (width slider, color swatches, undo) into
  the same top bar, sliding in leftward from the CTAs via
  animate-in/slide-in-from-right on mount
- Strip the pill styling from the toolbar (background, padding, blur,
  separators) so controls sit directly on the overlay
- Replace the three Thin/Medium/Thick preset buttons with a compact
  range slider: 4-12px in 2px steps (five whole-pixel stops, default
  6px), slim custom track and thumb via ::-webkit-slider-thumb
- Color swatches now double as stroke-width previews: each fixed 20px
  button renders its color as an inner dot sized to the current width,
  animating as the slider moves; selection ring stays fixed-size
- Ring outline only on the black swatch (for contrast on the dark
  overlay), drawn outside the dot so it never overlaps the color
- Remove the clear-all (trashcan) control - Cancel covers discarding -
  and the hover scale effect on swatches

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…image

- Add redo: stroke history is now a single {strokes, undone} state
  object so undo/redo move strokes between stacks atomically
  (StrictMode-safe); new strokes clear the redo stack per editor
  convention. Redo button (Redo2 icon) next to Undo, plus Shift+Cmd+Z
  handling in the existing Cmd+Z keyboard listener
- Replace the crosshair cursor with a dynamic brush preview: the
  native cursor is hidden over the canvas (cursor-none) and a
  pointer-following DOM dot renders the active color at the exact
  on-screen stroke width, with a faint white ring for visibility on
  light image areas. Positioned imperatively via ref during
  pointermove (no per-move re-renders); fades out on pointer leave.
  A DOM element sized in CSS pixels is guaranteed accurate, unlike a
  cursor: url(...) image
- Make the lightbox image fully unselectable: pointer-events-none on
  the <img> alongside the existing select-none and draggable={false}

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…save

- ComposerAttachments: replace the undo-arrow icon button in the
  attachment lightbox view mode with the shared Button (default
  variant) labeled "Revert"; keeps the composer-attachment-revert
  test id and the "Revert to original" tooltip, drops the unused
  Undo2 import
- ComposerImageEditor: disable the Cancel CTA while a save is in
  flight so the editor cannot be dismissed mid-upload

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
The loopback media proxy's origin check accepted any http(s)
localhost/127.0.0.1 origin on any port, and the proxy is spawned
unconditionally (lib.rs) — so in packaged builds, any local web page
that discovered the proxy port could make JS-readable, CORS-approved
requests to relay media. The widening was never needed: the composer
canvas crossOrigin="anonymous" reload and <img> loads use the no-Origin
path, which the unconditional ACAO:* already covers.

- desktop/src-tauri/src/media_proxy.rs:
  - is_webview_origin now accepts only the packaged custom-scheme
    origins (tauri://localhost, http://tauri.localhost), plus the one
    exact dev origin gated behind cfg!(debug_assertions)
  - The dev origin is derived from the merged Tauri config's
    build.devUrl at proxy spawn (stored in ProxyState), so per-worktree
    dev ports from scripts/instance-env.sh keep working — no hardcoded
    localhost:1420
  - ACAO:* stays unconditional (required for no-Origin CORS image
    loads; foreign pages are rejected by the origin gate first)
  - Tests updated: exact-dev-origin-only in debug builds, dev origin
    rejected in release builds, remote/lookalike origins still rejected

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…ob churn

- ComposerAttachments.tsx: handleEditorSave now closes the lightbox dialog
  after a successful annotated-image upload instead of returning to view
  mode. Each save uploads a fresh blob to the relay while revert/re-edit
  only drops the client reference, so keeping the modal open made rapid
  save/redraw cycles (and their orphaned blobs) frictionless — closing it
  adds deliberate friction per reviewer feedback
- ComposerAttachments.tsx: update MediaAttachmentItem doc comment — save
  now closes the dialog while revert still keeps it open (parent keys the
  item by original URL so the URL swap doesn't remount it)
- ComposerImageEditor.tsx: update handleSave comment to reflect that the
  parent closes the lightbox on success
- composer-image-draw.spec.ts: main test now asserts the dialog closes on
  save and the annotated thumbnail appears in the composer, then reopens
  the lightbox to exercise the revert flow (revert behavior unchanged);
  spoiler test drops the now-unneeded Escape press after save

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…y changes

Replace the image editor's CORS-based canvas export with a Tauri IPC
byte fetch + same-origin blob: URL, eliminating the need for any media
proxy CORS headers or origin-gate changes. media_proxy.rs is restored
byte-for-byte to its pre-branch state (228122f), removing the CORS
surface the security review flagged entirely instead of narrowing it.

- desktop/src-tauri/src/media_proxy.rs: full revert — no ACAO headers,
  no is_webview_origin, original strict origin gate restored
- desktop/src-tauri/src/commands/media_download.rs: new
  fetch_media_bytes command reusing the existing download plumbing —
  validate_download_url SSRF check (relay origin + /media/ path only),
  50 MiB cap via fetch_blob_bytes, detect_and_validate_mime content
  policy; registered in lib.rs
- ComposerImageEditor.tsx: renderAnnotatedPng fetches original bytes
  over IPC, wraps them in a typed Blob, and decodes from a blob: URL
  (revoked in finally) — blob URLs are same-origin so the canvas stays
  un-tainted without crossOrigin="anonymous" or the ?cors=1
  cache-buster; takes new sourceUrl/sourceType props since the Rust
  command validates the raw relay URL, not the proxy-rewritten one
- ComposerAttachments.tsx: pass attachment.url and attachment.type to
  the editor alongside the proxy-rewritten display src
- tauriMedia.ts (new): fetchMediaBytes wrapper returning
  Uint8Array<ArrayBuffer> — its own module because tauri.ts is at the
  check-file-sizes.mjs line ceiling
- e2eBridge.ts: mock fetch_media_bytes with an in-page fetch (specs
  serve the URL via page.route)
- composer-image-draw.spec.ts: route comment updated — the ACAO header
  on the mock route now exists only for the bridge's in-page fetch,
  not the production path

Trade-offs: the export load skips WKWebView's HTTP cache (one extra
relay round-trip per save), and the avatar snapshot fetch in
selfProfileStorage.ts returns to its pre-branch silent-null fallback.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
@tellaho tellaho changed the title add image annotation tools to composer uploads feat(uploads) add image annotation tools to composer uploads Jul 3, 2026
@tellaho tellaho marked this pull request as ready for review July 3, 2026 06:26
@tellaho tellaho enabled auto-merge (squash) July 3, 2026 07:11
tellaho added a commit that referenced this pull request Jul 3, 2026
Move the text spoiler toggle into the expanded formatting toolbar and
make it text-only — it no longer mirrors onto pending media
attachments. Media spoilers are now toggled per image/video via a new
control in the attachment lightbox's top-right cluster, next to the
close button and backed by the existing per-URL spoilered set.

The lightbox toggle is placed left of where the upcoming image
edit/draw control (PR #1488) will sit, with a note to hide it while
edit mode is active once that feature lands.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
tellaho added a commit that referenced this pull request Jul 3, 2026
Move the text spoiler toggle into the expanded formatting toolbar and
make it text-only — it no longer mirrors onto pending media
attachments. Media spoilers are now toggled per image/video via a new
control in the attachment lightbox's top-right cluster, next to the
close button and backed by the existing per-URL spoilered set.

The lightbox toggle is placed left of where the upcoming image
edit/draw control (PR #1488) will sit, with a note to hide it while
edit mode is active once that feature lands.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Keep this PR's MediaAttachmentItem as the composer attachment render path:
annotation needs view/edit modes, ComposerImageEditor, revert, and the
key={originalUrl ?? attachment.url} guard so edit/revert URL swaps don't
remount and close the open dialog. Drop main's now-unused
AttachmentMediaLightbox (and its SimpleImageLightbox import) from this file
so we don't ship two lightboxes for the same thumbnails; the shared
SimpleImageLightbox component itself is untouched and still used by
ViewImageToolPreview. Trivial import hunks take the union.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
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