Skip to content

[ENG-2149] Support dropping multiple images onto the canvas - #1312

Draft
mattakamatsu wants to merge 2 commits into
mainfrom
eng-2149-drag-multiple-images-into-tldraw-canvas
Draft

[ENG-2149] Support dropping multiple images onto the canvas#1312
mattakamatsu wants to merge 2 commits into
mainfrom
eng-2149-drag-multiple-images-into-tldraw-canvas

Conversation

@mattakamatsu

Copy link
Copy Markdown
Contributor

Closes ENG-2149.

What was wrong

tldraw splits a file drop across two layers. A content handler for "files" owns iterating the dropped files, enforcing size and type limits, and placing the shapes. An asset store owns turning one file into one stored asset, meaning where the bytes go.

The canvas overrode the content handler, and that override read content.files[0] and nothing else. Every file after the first was discarded. It also ignored content.point, so the one image it did create landed at the centre of the viewport instead of where you dropped it.

The override existed for a real reason: canvas media has to go to Roam's file store, not be inlined as base64 into the page's block props. But that is an asset-store concern implemented one layer too high, and it took multi-file handling down with it.

What changed

The Roam upload moves down to the asset store, and the content-handler override is deleted.

  • apps/roam/src/utils/roamCanvasAssetStore.ts is the shared asset store.
  • The Cloudflare sync adapter already had this exact asset store, so it now shares the module.
  • The local block-props store had no asset store at all, which is why the override was written in the first place. It gets one now, so both canvas paths behave identically.

With the override gone, tldraw's own handler takes back over and the canvas matches tldraw.com.

Where images live: unchanged

Before and after, media is uploaded through window.roamAlphaAPI.file.upload into Roam's Firebase-backed file store at imgs/app/<graph>/<id>.<ext>. The canvas stores only the URL, in the tldraw asset record's props.src. Image bytes never enter block props. Same API call, same destination, same field. Only the layer that makes the call changed.

Existing canvases are unaffected and no migration is involved. Only new uploads take the new code path.

Reviewer notes

Asset IDs now hash file content rather than the upload URL. This is tldraw's default and the one behavioural change worth a second opinion. Identical images now collapse to one asset record. Measured: dropping the same image three times gives 3 uploads, 1 asset record, 3 correctly-rendering shapes, and therefore 2 orphaned files in Roam. Context: the plugin never calls file.delete anywhere, so deleting an image shape already orphaned its upload. Reclaiming unreferenced uploads is separate work and deliberately not in here.

file.upload returns Roam markup, not a URL, and the wrapper depends on file type. ![](url) for images, {{[[video]]: url}} for video, {{[[audio]]: …}}, {{[[pdf]]: …}}, [name](url) otherwise. The parser inherited from the sync adapter stripped the image wrapper specifically, so the first real video dropped crashed the canvas with a schema error. It now extracts the URL from whatever wrapper came back. This was already latent in the sync adapter before this PR; it was simply unreachable, because the deleted handler rejected video before the asset store ever saw it.

A bad src used to take down the whole canvas. It fails inside store.put, which is past the try/catch in tldraw's file handler, so the error boundary caught it and offered "Reset data" for the entire canvas. The asset store now validates before returning, which turns it into an "Upload failed" toast for that one file while the rest of the drop still lands.

Smaller consequences of using tldraw's defaults: accepted image types widen to include apng and avif; failures raise a toast instead of a silent console.error; the toolbar's Media insert button was broken the same way and is fixed by the same change, since its picker already sets multiple = true; and Canvas: Asset Added now fires once per file instead of once per drop, with the same event name and source value.

Test plan

Automated:

  • 11 unit tests on the asset store, covering each upload wrapper form and both validation branches.
  • Full Roam suite passes (107 tests, 18 files). tsc --noEmit clean, eslint 0 errors, extension builds.

Against real tldraw 2.4.6 in a harness driving genuine drop events:

  • Three-file drop: 3 uploads, 3 image shapes tiled in a row, centred on the drop point, all selected.
  • Control with the old files[0] handler re-registered: 1 upload, 1 shape. Confirms the deleted code was the cause.
  • Real 320×240 mp4 dropped alongside a png: both upload, video wrapper parses, both shapes created at correct dimensions.
  • Three files where one upload returns prose instead of a URL: one toast, that file skipped, the other two imported, no error boundary.

In a live Roam graph:

  • Multi-image drop confirmed working by @mattakamatsu on plugin-testing-akamatsulab2. The mp4 crash above was found this way and is fixed.

Still worth covering before merge: persistence across a page reload, and the same drop on a sync-mode canvas.

Scope check

  • Ran $scope-check against ENG-2149 and the final diff.
  • Scope beyond Done When: The ticket has no Done When section, so the boundary was read from its one-line description, "drag multiple images into the canvas, as tldraw.com does". Beyond that: (1) video files now import as video shapes, where they were previously rejected with a silent console.error; (2) accepted image types widen to tldraw's defaults, adding apng and avif; (3) upload failures now surface as toasts.
  • Required now: All three fall out of delegating to tldraw's default "files" handler, which is the fix itself rather than an addition to it. Keeping the previous behaviour would mean re-implementing the handler's iteration and placement logic in order to narrow it again, which reintroduces the class of bug this PR removes. Video also forced the {{[[video]]: url}} parse fix and the crash-containment work, both of which are needed regardless of whether video stays.
  • Anyone affected or consulted: Yes. Raised with @mattakamatsu on the ticket before implementation was reviewed, and again after live testing surfaced the video crash. Explicitly left open for a product decision: if video should not be accepted, the change is to drop acceptedVideoMimeTypes from the handler config, and dropped videos get a clean "type not allowed" toast.
  • Decision: https://linear.app/discourse-graphs/issue/ENG-2149/drag-multiple-images-into-tldraw-canvas (see the two implementation comments) — video accept/reject still pending.

🤖 Generated with Claude Code

mattakamatsu and others added 2 commits August 18, 2026 18:10
Dropping several images at once only ever created one shape. The canvas
overrode tldraw's "files" external-content handler, which is the layer
that owns iterating a multi-file drop, and that override read only
content.files[0]. It also ignored content.point, so the one image it did
create landed at the viewport centre rather than where it was dropped.

The only thing the canvas actually needs to customize is where media is
stored: Roam's file store rather than base64 inlined into the page's
block props. That belongs in the store's asset store, one layer down, so
move it there and delete the content-handler override.

The Cloudflare sync adapter already had exactly this asset store, so
share it, and give the local block-props store one too - it had none,
which is why the override existed in the first place.

With the override gone, tldraw's own handler takes back over and the
canvas matches tldraw.com: every dropped file is uploaded, the shapes
are tiled in a row centred on the drop point and left selected, and
oversized or unsupported files raise a toast instead of a silent
console.error. Accepted image types widen slightly to tldraw's
defaults (apng and avif join the existing list).

Verified against tldraw 2.4.6 in a harness driving a real three-file
drop event: three uploads, three image shapes tiled and centred on the
drop point. Re-registering the old files[0] handler in the same harness
reproduces the bug - one upload, one shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…mage's

Dropping an .mp4 crashed the canvas with a schema ValidationError:

  At asset(type = video).props.src: Expected a valid url, got
  "{{[[video]]: https://firebasestorage.googleapis.com/...mp4?alt=media}}"

roamAlphaAPI.file.upload does not resolve to a bare url. It resolves to
the Roam markup that renders the file, and the wrapper depends on the
file type: `![](url)` for an image, `{{[[video]]: url}}` for a video,
and so on. We were stripping the image wrapper's punctuation
specifically, so a video kept "{{[[video]]: " glued to the front of its
src. Pull the url out of the wrapper instead of stripping any one
wrapper.

This was latent in the sync adapter's asset store before the previous
commit, which is where that parser came from - video simply never
reached it, because the handler it replaced rejected video outright.

Also fail early when the response has no url in it at all. A bad src
only fails later, inside store.put, which is past the point where
tldraw's file handler can catch it, so the whole canvas goes down with
an error boundary. Throwing in the asset store turns it into an
"Upload failed" toast for that one file and lets the rest of the drop
land.

Verified in a harness against tldraw 2.4.6 with a real mp4: the video
wrapper parses, and a 320x240 video shape and asset are created
alongside an image in the same drop. With one file's upload returning
prose instead of a url: one toast, no shape for that file, the other
two files still imported, no error boundary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Aug 19, 2026

Copy link
Copy Markdown

ENG-2149

@vercel

vercel Bot commented Aug 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
discourse-graph Skipped Skipped Aug 19, 2026 6:43am

Request Review

@supabase

supabase Bot commented Aug 19, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project zytfjzqyijgagqxrzbmz because there are no changes detected in packages/database/supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

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