T-0BFS-md-engine-attachments
State: closed/done · Impact: high · Complexity: medium
Pasting or dropping an image into the editor does nothing today; the provider contract has no
write path for binary attachments. This task adds provider.saveAttachment, the attachments
plugin with paste/drop handlers and pending markers, and insertAttachments for the chrome
toolbar.
| Location | Role today |
|---|---|
packages/ts/obsidian-editor/src/types.ts#EditorIndexProvider | searchNotes, searchTags, resolveLink, assetUrl; read-only — no attachment write path |
packages/ts/obsidian-editor/src/extensions.ts#obsidianEditorExtensions | No paste or drop handling, no dropCursor() |
solutions/determined/experiences/notes/app/composables/useEditorProvider.ts#useEditorProvider | The notes provider; implements only the read contract |
packages/ts/obsidian-editor/test/helpers.ts#stubProvider | Stub provider the plugin tests extend with a saveAttachment spy |
The plugin roster comes from T-4TE2-md-engine-plugin-contract; the catalogue and command payloads from T-EXQ7-md-engine-command-catalogue.
Proposed
Section titled “Proposed”EditorIndexProvider gains saveAttachment?(file: File, notePath?: string): Promise<string>,
resolving to the embed target name to write (e.g. pasted-2026-09-01.png).
packages/ts/obsidian-editor/src/plugins/attachments.ts, a defineEditorPlugin with
extensions: dropCursor():
- Paste and drop handlers claim the event only when the payload carries files and the provider
has
saveAttachment; otherwise the event falls through and text paste behaves exactly as today. - Each accepted file inserts a pending marker — a zero-length widget (
cm-attachment-pending) whose position lives in aStateFieldand maps through every change. WhensaveAttachmentresolves, the marker is replaced bynameat the mapped position; when it rejects, the marker is removed and the document is untouched. insertAttachments(view, files: File[]): Promise<void>runs the same path at the cursor — the export the chrome image button calls.- Catalogue addition
insert.attachment(groupinsert,slash: false):runtakes aFile[]payload and callsinsertAttachments;isEnabledisfalsewhen the provider lackssaveAttachment.
useObsidianEditor returns insertAttachments(files) bound to the view. The notes provider
implements saveAttachment over the vault asset write path so wave 1 exercises the seam.
Approach
Section titled “Approach”- Write
packages/ts/obsidian-editor/test/plugins/attachments.plugin.test.tsfirst (happy-dom paste/drop events,saveAttachmentspy with a controllable promise): accepted paste inserts a pending marker at the cursor; resolution with'img.png'replaces it withimg.pngand no other byte changes; rejection restores the document byte-identically; typing between paste and resolution still lands the embed at the mapped position; withoutsaveAttachmentthe handler does not claim the event. - Add the
insertAttachmentscase: two files insert two embeds in call order. - Implement the plugin: handlers, marker
StateField, resolution dispatch,dropCursor(). - Add
insert.attachmentto the catalogue with the payload run andisEnabled; extend the catalogue test. - Add
saveAttachmentto the types; extenduseObsidianEditorand the Vue test. - Implement
saveAttachmentin the notes provider (write the blob through the vault asset path, return the vault-relative name);moon run product-workbench:check. bun test,tsc --noEmit,moon run obsidian-editor:bench.
Files to touch
Section titled “Files to touch”| Location | Kind | Change |
|---|---|---|
packages/ts/obsidian-editor/src/plugins/attachments.ts | new | attachments plugin: handlers, pending markers, insertAttachments |
packages/ts/obsidian-editor/src/plugins/index.ts | new | Created by T-4TE2-md-engine-plugin-contract; attachments joins BUILTIN_PLUGINS |
packages/ts/obsidian-editor/src/commands/catalogue.ts | new | Created by T-EXQ7-md-engine-command-catalogue; gains insert.attachment |
packages/ts/obsidian-editor/src/types.ts | modify | saveAttachment? on the provider |
packages/ts/obsidian-editor/src/vue/index.ts | modify | insertAttachments(files) in the return |
packages/ts/obsidian-editor/src/index.ts | modify | Export insertAttachments |
packages/ts/obsidian-editor/test/plugins/attachments.plugin.test.ts | new | Paste, drop, marker mapping, rejection and fall-through tests |
packages/ts/obsidian-editor/test/vue.test.ts | modify | insertAttachments returned |
solutions/determined/experiences/notes/app/composables/useEditorProvider.ts | modify | Implement saveAttachment over the vault asset write path |
Acceptance criteria
Section titled “Acceptance criteria”- AC-1:
bun test packages/ts/obsidian-editorpasses, including the new suite. - AC-2: a paste whose
saveAttachmentresolves'img.png'yieldsimg.pngat the paste position with every other byte of the document unchanged. - AC-3: a rejected
saveAttachmentleaves the document byte-identical to before the paste and no pending marker remains. - AC-4: an insert typed before the promise resolves shifts the landing position — the embed appears at the mapped position, proven by the interleaved-edit test.
- AC-5: without
saveAttachmenton the provider, the paste handler returnsfalseand a text paste behaves as before this task (existing paste behavior asserted unchanged). - AC-6:
insert.attachmentexists in the catalogue;commandStatesreports itenabled: falseunder the stub provider withoutsaveAttachmentandtruewith it. - AC-7:
moon run product-workbench:checkpasses with the notes provider implementingsaveAttachment. - AC-8:
moon run obsidian-editor:benchstays within 1.5× of the recorded baseline.
Out of scope
Section titled “Out of scope”- The chrome “Attachment…” menu item and file input (T-98EU-md-ui-toolbar-insert-frontmatter).
- Rendering the pasted image; the existing image-embed widget picks
nameup throughprovider.assetUrlunchanged. - Naming policy and collision handling for saved files; both live in the host’s
saveAttachment.
Dependencies
Section titled “Dependencies”- T-EXQ7-md-engine-command-catalogue for command payloads and
commandStates. - T-4TE2-md-engine-plugin-contract for the plugin contract and the
extensionsseamdropCursor()rides. - D-7VMX: the plugin writes only the
nametext; it never rewrites surrounding content.
Discovery context
Section titled “Discovery context”Member of milestone M-931L; engine step E11 of decision D-K3EM-markdown-editor-chrome-and-plugin-environment.
Depends on
Section titled “Depends on”T-EXQ7-md-engine-command-catalogue