zudo-text

検索したい単語を入力

いつでも検索バーを開ける

Media uploader — drop & attach any file

The editor handles every file type, not just images. The same paths exist on desktop, mobile, and the web build target — they only differ in where the asset is stored at the end.

Entry points

There are four ways to land a file in the workspace and get a Markdown reference inserted at the cursor:

  1. Drag & drop — drop one or more files onto the editor pane. Images become ![](../assets/<file>). Anything else becomes [<filename>](../assets/<file>). Text files (anything classifyFile() reports as "text" and, on desktop, that passes the content probe) are not attached automatically: on desktop they open the File Drop Intent dialog (see Text-file drops below), and in the browser/mock build their contents are inserted at the cursor. Folders are never imported.

  2. Insert asset… — the editor context-menu item, the command-palette entry "Insert Asset…", or the edit key bar's Asset key opens the Assets dialog in pick mode. Its Upload tab opens the OS native file picker (multi-select, inserted in selection order); its From Assets tab inserts files that are already in the workspace. The former header paper-clip button is gone. See Edit key bar for the captured-target insertion contract.

  3. Clipboard paste — copy a file from the OS file manager (Finder / Explorer / Files) and paste into the editor. See the engine caveat below.

  4. Keyboard shortcut — Mod+Shift+U (Cmd+Shift+U on macOS, Ctrl+Shift+U elsewhere) opens the same pick-mode dialog as "Insert asset…". It is a hidden shortcut (insertAsset) with no row in Settings → Shortcuts.

Text-file drops (File Drop Intent)

A native (Tauri) drop on a Note Tray editor asks what to do with the text files in it instead of guessing. Pieces:

PieceLocation
Drop zone + path partitiontauri-app/renderer/components/editor-pane/index.tsx (registerFileDropZone, targetKind: "editor"), lib/editor-drop-partition.ts
Content probe (bounded pool, PROBE_CONCURRENCY)tauri-app/renderer/lib/drop-text-probe.ts (probeTextPaths)
Orchestrationtauri-app/renderer/hooks/use-file-drop-intent.tsx, wired by the inbox provider
Dialogtauri-app/renderer/components/file-drop-intent-dialog/
Pure rules (default, remember, toast copy)tauri-app/renderer/lib/file-drop-intent.ts
Open External adaptertauri-app/renderer/lib/open-external-files.ts
Remembered-choice writestauri-app/renderer/lib/text-file-drop-overrides.ts

Routing

  1. The zone module delivers a drop only to the pane under the drop point, so split frames never both receive it. partitionDroppedPaths() sorts the paths into text, asset (images and BINARY_EXTENSIONS), and directory buckets using classifyFile() and files.isDirectory. BINARY_EXTENSIONS covers archives, executables, fonts, audio/video, office/design files, databases, compression formats and disk images, and compiled artifacts.

  2. Every extension-classified text candidate is then probed through probeTextPaths() (lib/drop-text-probe.ts), which calls files.probeText for each path — at most PROBE_CONCURRENCY (8) in flight. The Rust probe (probe_text_file in tauri-app/core/src/read_text.rs) fails a file that is over the 1 MB readText cap, or whose first 8 KiB (PROBE_HEAD_BYTES) contain a NUL byte or invalid UTF-8. A failed path is demoted to the asset bucket, so it is never offered the text actions and is attached instead. A probe that throws keeps the path as text (the extension classification stands), and the later readText reports the real error. The probe is conservative: it only inspects the head, so a NUL after the first 8 KiB still passes and fails later at readText, exactly as before.

  3. Each directory is reported through onDropError with "<name>/" is a folder. Drop it on the External File Editor's file tree to browse it. and is never imported.

  4. The pane calls its onFilesDropped({ textPaths, assetPaths }) prop (also when the note is read-only). The hook resolves false for an asset-only drop, which falls through to the legacy attach loop, and true whenever there is at least one text file. An EditorPane host without the prop keeps the legacy behaviour: text is inserted at the cursor, everything else is attached.

Dialog and actions

The dialog title is "What do you want to do with this file?" (or "…these N files?"). It offers four cards in a 2x2 grid, selectable by click, 1–4, or arrow keys plus Enter:

ChoiceBehaviour
insert — Insert into this notefiles.readText each path and insert at the cursor, in drop order
new-note — Create a new note / Create N new notescreateDraftsForPane() makes one note per file and switches to the first
open-external — Open in External File EditoropenExternalFilesBeside(handle, frameId, paths) splits beside the dropped-on frame (never the globally active one), or replaces it in place when it is core.empty
attach — Attach as asset(s)Imports the text files as assets and inserts links
  • Default card: one text file → insert, two or more → new-note. When that card is disabled, the default moves to the first enabled card.

  • Disabled cards: a read-only note disables insert and attach. Where no FramesetHandle is reachable (pop-out windows, archives), open-external is disabled.

  • Non-text files in the same drop are listed in a footnote ("… will be attached to this note as an asset either way.") and are imported after the text action, whatever the choice. They are skipped on a read-only note.

  • Cancel (button, close X, Escape, backdrop) aborts the whole drop, including the non-text files.

  • Per-file failures raise an error toast naming the file; the remaining files still run.

  • A second drop while the dialog is open is ignored.

Setting and remembered choices

Two AppSettings.editor fields in @takazudo/app-defaults:

  • textFileDropAction: "ask" | "insert" | "new-note" | "open-external" | "attach" — default "ask". Settings → Editor → "When a text file is dropped on a note".

  • textFileDropActionByExtension: Record<string, choice> — per-extension overrides keyed by lowercase extension with the leading dot (".md"); "ask" is not a valid value. Cleared by the "Clear remembered file-type choices" button in the same section.

resolveRememberedAction() applies the precedence: a per-extension override (only when every text file shares one extension) → the global scalar → ask. The dialog's "Remember for .md files" checkbox appears only when rememberExtensionFor() finds that single shared extension, and the override is written through setTextFileDropOverride() only after the action succeeded for at least one file.

When a remembered action skips the dialog, an info toast describes what happened (memo.md inserted into the note (remembered for .md)). For an extension override it carries an action that deletes that override: "Ask again" when the global setting is "ask", "Clear override" otherwise. A remembered action that is disabled at the drop site (for example Insert on a read-only note) opens the dialog instead of failing silently.

Browser and mock builds

Browser drops deliver File objects without absolute paths, so there is no dialog and no Open External: onFilesDropped is never called. Text files are inserted at the cursor (1 MiB cap), everything else is attached. Nothing happens on a read-only note.

Other drop targets

The empty frame opens dropped text files or a folder in the External File Editor without the dialog — see Frame Components → Empty. The External File Editor, Mind Map, and Kanban frames keep their own drop zones.

The empty-frame and External File Editor drop routes run the same probeTextPaths() probe on their extension-text candidates. The empty frame opens only the paths that pass (and shows the "Drop images and files onto a note to attach them." notice when none do); the External File Editor skips a failed path with its Skipped unsupported file(s): … notice instead of loading it. Because the External File Editor's drop handling is now async, it carries a stale-drop guard (dropGenerationRef, plus mountedRef): a probe that settles after a newer drop or after unmount is discarded.

Supported file types and size cap

Every file type is allowed. The 25 MiB cap (MAX_ASSET_SIZE) applies uniformly to every entry point. Files over the cap are rejected with a toast that mentions both the filename and the limit.

There is no magic-byte MIME validation — the type field is set from the extension. Same-name uploads in the same workspace get a numeric suffix (screenshot.png → screenshot-1.png) so existing files are never silently overwritten.

Where assets live

Build targetStorage
Desktop (Tauri)Encrypted cloud asset surface once the workspace is armed; native import first reads the selected local source file
Web (cloud)Encrypted R2 object at assets-e2ee/<workspaceId>/<random-object-id> plus an opaque D1 name token

The asset path written into your Markdown is always ../assets/<filename> — the same shape regardless of build target. The bridge layer (@takazudo/backend-bridge) hides where the bytes actually go.

How non-image assets render

The Markdown preview pane renders <a> tags whose href starts with ../assets/ as a small file chip: a document icon, the filename, and a download cursor. Clicking the chip downloads the file via assets.readFile → blob URL → programmatic <a download> click → blob URL revoke. If the read fails the chip falls back to a plain styled link.

The Assets manager dialog (open from the toolbar) shows images with thumbnails and non-images with a document icon in the same square frame. Click an image to enlarge in the gallery overlay; click a non-image to download it. Non-image entries are never read at list time — the icon renders from kind alone — so opening the dialog in cloud mode does not download every R2 object.

Cloud-mode encryption contract

The bridge.assets.* API deliberately remains a plaintext application contract: callers pass normal filenames and base64 file data and receive normal filenames, metadata, and base64 file data. In a cloud workspace, the shared backend bridge encrypts both pieces before the request. The filename uses deterministic AES-256-GCM so lookup and collision handling can use one stable opaque token; the bytes use randomized AES-256-GCM plus HMAC authentication. The server stores only the token, encrypted envelope size, upload time, and a random per-upload R2 key. It neither receives nor derives the MIME type; the client derives kind and MIME from the decrypted extension.

This is zero-knowledge for contents and names, but it is not traffic-analysis resistant. Within a workspace, deterministic name tokens expose filename equality and their encoded length tracks the plaintext filename's UTF-8 byte length. The server also sees encrypted object size (and therefore plaintext size plus fixed envelope overhead), upload timing, and list/read/delete access patterns. The deterministic 96-bit IV inherited from encrypted path handling has a theoretical nonce-collision risk at extreme per-workspace namespace sizes; the bounded filename namespace makes that risk negligible, not impossible.

Every request also requires authentication, workspace ownership/binding, and the method's asset scope when a PAT is used. Asset mutations do not require an active subscription; creation remains subject to storage quota. Base64 remains the transport encoding for compatibility with the unchanged bridge API; replacing its roughly 33% wire expansion with a streaming/binary transport is a follow-up, not part of the encryption contract.

Clipboard paste — engine caveat

Pasting a non-image file from the OS file manager works on macOS / WebKit (both the real Tauri runtime and Safari). Coverage is patchier on Linux Chrome and varies by source application. The handler is defensive: if the clipboard item reports kind === "file" but getAsFile() returns null, the item is skipped silently. Image clipboard paste is unaffected and works everywhere the platform delivers an image item.

Mobile (iOS)

On iOS the pick-mode dialog's Upload tab (asset-upload-tab.tsx) shows two picker buttons instead of the native multi-file input: Photo or camera (accept="image/*") and Document (accept="*/*"). The photo / camera entry stays separate because the iOS WebView does not guarantee that accept="*/*" surfaces the camera and photo library the same way. Both routes round-trip through bridge.assets.saveFile via uploadFilesAndInsert — picked files are never inlined as data: URLs in the Markdown body. At ≤ 640 px the Assets dialog renders full-bleed.