Skip to content

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

Open
mattakamatsu wants to merge 3 commits into
mainfrom
eng-2149-drag-multiple-images-into-tldraw-canvas
Open

[ENG-2149] Support dropping multiple images onto the canvas#1312
mattakamatsu wants to merge 3 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 26, 2026 12:36am

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 ↗︎.

* store, so this is the single place assets are counted. The "file-drop" source
* covers drops and pastes alike, matching what this event has always reported.
*/
export const captureCanvasAssetUploaded = ({ file }: { file: File }): void => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the onUpload callback? Both call sites pass this same function. The repo pattern is to call posthog.capture inline at the action site (QueryDrawer, QueryBuilder, DiscourseNodeSearchMenu all do this), and src/utils already imports posthog in 7 files, so there's no layering rule keeping it out of the asset store. Let's move the capture into createRoamAssetStore's upload and delete this file, the onUpload param, the try/catch guard, and its test. About 30 fewer lines and one less indirection to follow.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 1b648ad. Checked the claim before changing it: src/utils has posthog in 7 files, and the capture is now inline in createRoamAssetStore's upload path.

The unstated reason for the callback was that I expected posthog-js to break the node-env vitest run. It doesn't — the suite passes with the import in place, so the indirection was defending against nothing. canvasAssetTelemetry.ts, the param, the try/catch and its two tests are gone.

One thing I kept: the helper takes the posthog source as a plain argument, defaulting to "file-drop". That's only so svg pastes keep reporting "svg-paste" — routing them through the shared upload (your other comment) would otherwise silently fold that dimension into "file-drop". If losing it is fine, that argument can go too and the signature drops to (file).


const url = await window.roamAlphaAPI.file.upload({ file });
const dataUrl = url.replace(/^!\[\]\(/, "").replace(/\)$/, "");
const dataUrl = parseRoamUploadResponse(url);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the same crash path the PR fixes for file drops. parseRoamUploadResponse falls back to the raw response when it can't find a url, and here that flows straight into createAssets and fails later in store.put, past any try/catch, so the whole canvas goes down with the error boundary. The asset store validates before returning for exactly this reason, and both stores now carry it, so editor.uploadAsset(asset, file) is available in this handler. Let's route this upload through the asset store, or at least reuse its url check. If it's out of scope for this PR, a ticket works.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 1b648ad, in scope rather than as a ticket.

You're right about the mechanism, and it's worse than pre-existing: my change to parseRoamUploadResponse added the raw-response fallback that this handler consumes, so this PR made the bad value more reachable rather than less.

Rather than editor.uploadAsset I pulled the upload out as uploadCanvasFileToRoam(file, source) and had both the asset store and this handler call it. Same validation and same telemetry for both, and it avoids constructing a placeholder asset just to satisfy uploadAsset's signature when our store ignores that argument anyway. The handler also catches now and shows an "Upload failed" toast instead of rejecting silently.

Verified against tldraw 2.4.6 by driving the svg-text handler directly: a good paste still creates one image shape and asset; an upload response with no url gives a toast, no shape, and no error boundary.

Separately, this handler is now the last hand-rolled upload path left. tldraw's own svg-text default would go through getAssetForExternalContent to the same asset store and let us delete ~60 lines. That felt like scope creep for this PR — happy to file it.

Review feedback from sid597, both points taken.

Capture posthog inline in the asset store rather than through an
onUpload callback. The callback was defending against nothing: both
call sites passed the same function, and posthog-js imports fine in
src/utils (7 files already do) and in the node test environment, which
was the unstated worry. Deletes canvasAssetTelemetry.ts, the param, the
try/catch guard, and its two tests.

Route the svg-text handler's upload through the same helper. It was
uploading to Roam directly and putting the parsed response into an
asset's src without checking it was a url, which is the exact crash the
rest of this PR fixes: a bad src only fails inside store.put, past any
try/catch, and takes the canvas down with an error boundary. It now
calls uploadCanvasFileToRoam like everything else, and catches to show
an "Upload failed" toast.

uploadCanvasFileToRoam takes the posthog `source` as a plain argument so
svg pastes keep reporting "svg-paste" rather than being folded into
"file-drop". Say the word if losing that dimension is fine and it can go.

Verified in a harness against tldraw 2.4.6, driving the svg-text handler
directly: a good paste still creates one image shape and asset, and an
upload response with no url in it now produces an "Upload failed" toast,
no shape, and no error boundary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mattakamatsu
mattakamatsu marked this pull request as ready for review August 26, 2026 01:55
@mattakamatsu mattakamatsu self-assigned this Aug 26, 2026
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.

2 participants