Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
6.3 kB · 138 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139// src/editor/extensions/image-paste-handler.ts/** * CM6 extension that intercepts paste events containing image files, * stores the image as a binary file in the Vault, and inserts an * Obsidian-style embed (![[filename]]) at the cursor. * * Read-only safe (with one TOCTOU caveat, below): the handler * short-circuits if EditorState.readOnly is set when the paste event * fires, so we never start the async vault write for a paste targeting a * read-only editor. * * KNOWN TOCTOU LEAK: The vault write is async. If the editor transitions * into read-only between `vault.createBinary` resolving and the * subsequent `view.dispatch`, we have already written the binary file * to the vault. The post-write `view.state.readOnly` check below * suppresses the document insertion in that case, but the binary file * remains in the vault as an orphan (no `![[...]]` references it). * * Why not block earlier? Because the only way to truly avoid the orphan * would be to cancel the in-flight `createBinary` (no public API for * that) or to undo the write after the fact (Vault has no transactional * undo). The race window is very small (single-digit milliseconds in * practice — the time between createBinary resolve and the subsequent * dispatch) and the user-visible cost is one stray binary file, which * the user can later delete from the file tree. We accept this leak. * * Selection is captured at the time of paste, before the async vault * write. The dispatched transaction uses that snapshot — if the user * moves the cursor between paste and resolution, the embed lands at the * paste site, not the new cursor position. */import { EditorView } from '@codemirror/view'import { Transaction } from '@codemirror/state'import type { Extension } from '@codemirror/state'import type { App } from '../../plugin/app'import { generateImageFilename, getImageFile, hasClipboardImage, readFileAsUint8Array } from '../image-paste-utils'import { loadAttachmentSettings, resolveAttachmentFolder } from '../attachment-settings'import { editorInfoField } from '../../plugin/obsidian-cm6-stubs'import { lineAttachmentBindingToken } from '../line-attachment-extension'
function boundAttachmentContext(app: App, view: EditorView): { activeFilePath: string | null folder: string bindingToken: object | undefined boundFile: object | null | undefined} { const info = view.state.field(editorInfoField, false) const boundFile = info ? info.file : undefined const activeFilePath = info ? (boundFile?.path ?? null) : (app.workspace.getActiveFile()?.path ?? null) const settings = loadAttachmentSettings() if (settings.relativeToCurrentFile && !activeFilePath) { console.warn( 'Image paste: "Same folder as current file" is enabled but no file is active; ' + 'saving to vault root (or the configured absolute folder, if any).', ) } return { activeFilePath, folder: resolveAttachmentFolder({ settings, activeFilePath }), bindingToken: lineAttachmentBindingToken(view.state), boundFile, }}
function imageBindingIsLive(view: EditorView, context: ReturnType<typeof boundAttachmentContext>): boolean { if ((view as unknown as { destroyed: boolean }).destroyed || view.state.readOnly) return false if (context.bindingToken !== undefined && lineAttachmentBindingToken(view.state) !== context.bindingToken) return false if (context.boundFile !== undefined) { const info = view.state.field(editorInfoField, false) if (!info || info.file !== context.boundFile) return false } return true}
export function imagePasteHandler(app: App): Extension { return EditorView.domEventHandlers({ paste(event, view) { // Allow workspace event listeners to intercept before our handler if (event.defaultPrevented) return false if (view.state.readOnly) return false const dt = event.clipboardData if (!dt || !hasClipboardImage(dt)) return false const file = getImageFile(dt) if (!file) return false
// Capture the document boundary and attachment folder before async work. const attachmentContext = boundAttachmentContext(app, view) const sel = view.state.selection.main const from = sel.from const to = sel.to
event.preventDefault()
void (async () => { try { const data = await readFileAsUint8Array(file) // Match the toolbar action's MIME fallback so paste-by-clipboard // and paste-by-file-picker produce identical vault entries. const mimeType = file.type || 'image/png' const filename = generateImageFilename(mimeType)
const fullPath = attachmentContext.folder ? `${attachmentContext.folder}/${filename}` : filename
if (!imageBindingIsLive(view, attachmentContext)) { console.warn(`Image paste aborted before saving: editor binding changed before writing ${fullPath}.`) return } await app.vault.createBinary(fullPath, data, mimeType) // Re-check readOnly: state may have changed between paste and // resolve. The binary is already on disk at this point — if // readOnly flipped, we orphan the file but at least we don't // mutate a document the user has marked read-only. See the // KNOWN TOCTOU LEAK note in the file header. Surface a warning // so the orphan is at least visible in the console; a future // GC sweep over Vault binaries can use this signal. if (!imageBindingIsLive(view, attachmentContext)) { console.warn( `Image paste produced an orphan binary "${fullPath}": editor became unavailable or read-only ` + `between vault write and document insert. The binary remains in the vault.`, ) return } const insert = `![[${fullPath}]]` view.dispatch({ changes: { from, to, insert }, selection: { anchor: from + insert.length }, scrollIntoView: true, annotations: Transaction.userEvent.of('input.paste'), }) } catch (err) { console.error('Failed to paste image:', err) } })()
return true }, })}