From ee71a00c6f2ea98f32cff179ac2330bf2036533f Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 1 May 2026 00:31:14 +0200 Subject: [PATCH] feat: add paste without formatting command --- docs/ABSTRACTIONS.md | 3 +- docs/ARCHITECTURE.md | 3 + docs/GETTING-STARTED.md | 5 +- lara.lock | 1 + src-tauri/src/commands/system.rs | 49 +++++ src-tauri/src/lib.rs | 1 + src-tauri/src/menu.rs | 9 + src/App.tsx | 7 + src/components/CommandPalette.tsx | 1 + src/components/LinuxMenuButton.test.tsx | 5 + src/components/LinuxMenuButton.tsx | 1 + .../RawEditorView.behavior.test.tsx | 31 +++ src/components/RawEditorView.tsx | 66 +++++- src/components/SingleEditorView.test.tsx | 13 ++ src/components/SingleEditorView.tsx | 65 +++++- src/hooks/appCommandCatalog.ts | 8 + src/hooks/appCommandDispatcher.test.ts | 153 ++++---------- src/hooks/appCommandDispatcher.ts | 3 + .../appKeyboardShortcuts.editorFind.test.ts | 1 + src/hooks/appKeyboardShortcuts.ts | 1 + src/hooks/commands/localizeCommands.ts | 1 + src/hooks/commands/noteCommands.ts | 9 + src/hooks/useAppCommands.ts | 6 + src/hooks/useAppKeyboard.test.ts | 12 ++ src/hooks/useCommandRegistry.test.ts | 17 ++ src/hooks/useCommandRegistry.ts | 7 +- src/hooks/useMenuEvents.editorFind.test.ts | 1 + .../useMenuEvents.noteListSearch.test.ts | 1 + src/hooks/useMenuEvents.test.ts | 7 + src/lib/locales/de-DE.json | 1 + src/lib/locales/en.json | 1 + src/lib/locales/es-419.json | 1 + src/lib/locales/es-ES.json | 1 + src/lib/locales/fr-FR.json | 1 + src/lib/locales/it-IT.json | 1 + src/lib/locales/ja-JP.json | 1 + src/lib/locales/ko-KR.json | 1 + src/lib/locales/pt-BR.json | 1 + src/lib/locales/pt-PT.json | 1 + src/lib/locales/ru-RU.json | 1 + src/lib/locales/zh-CN.json | 1 + src/lib/locales/zh-TW.json | 1 + src/mock-tauri/mock-handlers.more.test.ts | 1 + src/mock-tauri/mock-handlers.ts | 1 + src/utils/plainTextPaste.test.ts | 95 +++++++++ src/utils/plainTextPaste.ts | 195 ++++++++++++++++++ 46 files changed, 673 insertions(+), 119 deletions(-) create mode 100644 src/utils/plainTextPaste.test.ts create mode 100644 src/utils/plainTextPaste.ts diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 2234975d..2deeb8e7 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -362,7 +362,7 @@ A `vault_health_check` command detects stray files in non-protected subfolders a Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. Image attachment commands add the current vault root to the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately. -UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder routes through the Tauri opener plugin, external-open routes through the `open_vault_file_external` command and active-vault boundary before invoking the native opener, and copy-path uses the browser clipboard API. None of those actions mutate vault contents or bypass the backend write boundary. +UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder routes through the Tauri opener plugin, external-open routes through the `open_vault_file_external` command and active-vault boundary before invoking the native opener, and copy-path uses the browser clipboard API. Plain-text paste reads the desktop clipboard through `read_text_from_clipboard` in Tauri so macOS WKWebView clipboard permissions do not block the command; browser/mock mode falls back to the Web Clipboard API or mock handlers. None of those actions mutate vault contents or bypass the backend write boundary. The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. App exit uses the same child cleanup path and waits for the bridge process after killing it. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`. Manual MCP config export uses the same generated stdio entry as registration, so the copied snippet remains scoped to the active vault without writing third-party config files. Desktop snippet copy goes through the native `copy_text_to_clipboard` command, while browser/mock mode keeps using the Web Clipboard API. External-client stdio MCP processes also exit when stdin closes; their UI-bridge reconnect timers and WebSocket are canceled during shutdown so disconnected clients do not leave extra Node processes behind. @@ -557,6 +557,7 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola - The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, and list blocks. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model. - The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface. - `useNoteWikilinkDrop()` is the shared editor-drop abstraction for dragging note rows into either editor mode. It reads the existing note-retargeting drag payload, resolves the vault-relative stem, and inserts a canonical `[[wikilink]]` without hijacking unrelated plain-text drags. +- `plainTextPaste.ts` is the shared plain-text paste target registry. Rich BlockNote and raw CodeMirror surfaces register focused insertion targets, while ordinary focused text controls use DOM selection replacement, so the `Cmd+Shift+V` command can preserve caret/selection behavior without each surface inventing its own clipboard reader. - `useTauriDragDropEvent()` owns the shared Tauri window drag/drop subscription and duplicate-unlisten cleanup used by native drop features. - `useNativePathDrop()` is the shared Tauri file/folder-drop abstraction for text inputs that need filesystem paths instead of attachment import. It consumes native window drag/drop events, gates them to the target element bounds or focused text selection, and lets AI composer / command-palette inputs insert formatted paths at the current cursor. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 33466938..bad0094f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -754,6 +754,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | `check_mcp_status` | Check whether the active vault is explicitly registered in Claude/Gemini/Cursor/generic config | | `get_mcp_config_snippet` | Return the exact manual MCP JSON snippet for the active vault | | `copy_text_to_clipboard` | Copy setup snippets through the native desktop clipboard command path | +| `read_text_from_clipboard` | Read current desktop clipboard text for command-driven plain-text paste | | `sync_mcp_bridge_vault` | Sync the desktop WebSocket bridge process to the selected vault, or stop it when no vault is selected | The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bridge.js` binds both bridge ports to loopback, rejects non-loopback clients, accepts browser/Tauri origins only on the UI bridge, and rejects browser-origin requests on the tool bridge so remote pages cannot drive vault tools directly. @@ -834,6 +835,7 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c | Cmd+S | Save current note | | Cmd+F | Find in current note when the editor is focused; otherwise note-list search can claim it | | Cmd+Shift+F | Find in vault | +| Cmd+Shift+V | Paste without Formatting into the active supported editing surface | | Cmd+[ / Cmd+] | Navigate back / forward (replaces tabs) | | Cmd+Z / Cmd+Shift+Z | Undo / Redo | | Cmd+1–9 | Switch to tab N | @@ -848,6 +850,7 @@ Shortcut routing is explicit: - `formatShortcutDisplay()` derives platform-accurate visible shortcut labels (`⌘` on macOS, `Ctrl` on Windows/Linux) from that same manifest so menus, tooltips, and command-palette copy stay aligned with real accelerators - `useAppKeyboard` is the primary execution path for real shortcut keypresses, including Tauri runs - macOS browser-reserved chords such as `Cmd+O`, `Cmd+F`, and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path +- `Cmd+Shift+V` uses the same command path for "Paste without Formatting"; `plainTextPaste.ts` reads text through the native clipboard command in Tauri and inserts it through the active rich/raw editor target or the focused browser text control - `Cmd+F` is surface-aware: editor focus opens current-note find/replace in raw CodeMirror, note-list focus preserves note-list search, and native menu enablement follows focus availability events so only one `Cmd+F` menu item is active - `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same command IDs for native menu clicks, accelerators, and custom titlebar menu actions - `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index fe2f9615..7638382c 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -157,6 +157,7 @@ tolaria/ │ ├── utils/ # Pure utility functions (~48 files) │ │ ├── wikilinks.ts # Wikilink preprocessing pipeline │ │ ├── frontmatter.ts # TypeScript YAML parser +│ │ ├── plainTextPaste.ts # Shared Paste without Formatting command target registry │ │ ├── platform.ts # Runtime platform + Linux chrome gating helpers │ │ ├── ai-agent.ts # Agent stream utilities │ │ ├── ai-chat.ts # Token estimation utilities @@ -361,7 +362,7 @@ type SidebarSelection = ### Command Registry -`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the light/dark theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command. +`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the light/dark theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. Plain-text paste follows this same path: the command owns `Cmd+Shift+V`, the menu and palette expose the same action, and `plainTextPaste.ts` resolves the active rich/raw editor target or focused text control before reading clipboard text. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command. Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active. @@ -406,7 +407,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/.spec.ts 1. Write the Rust function in the appropriate module (`vault/`, `git/`, etc.) 2. Add a command handler in `commands/` 3. Register it in the `generate_handler![]` macro in `lib.rs` -4. Call it from the frontend via `invoke()` in the appropriate hook +4. Call it from the frontend via `invoke()` in the appropriate hook or utility, keeping native-only permission work behind the Tauri command boundary 5. Add a mock handler in `mock-tauri.ts` ### Add a new component diff --git a/lara.lock b/lara.lock index 2ccd4b3b..d581202a 100644 --- a/lara.lock +++ b/lara.lock @@ -39,6 +39,7 @@ files: command.note.newType: adce5108f18945cc502a06c02445e32d command.note.newTypedNote: 1493eda772c2179cb6247169d00723b2 command.note.saveNote: 2a309cda46e95b00d2a5a8afb3cc0047 + command.note.pastePlainText: 09e8075dbd91e11878f8f4a138df760c command.note.findInNote: f72f8f93d8e6119d5d08b60eb3a7b3bc command.note.replaceInNote: b008e2e20c5e4cd202f4386271d6bed1 command.note.deleteNote: 56f727a2ee11d15159f1aa6373314cb6 diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index 5605fe61..d329555e 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -147,11 +147,23 @@ fn clipboard_command() -> Command { crate::hidden_command("pbcopy") } +#[cfg(target_os = "macos")] +fn clipboard_read_command() -> Command { + crate::hidden_command("pbpaste") +} + #[cfg(target_os = "windows")] fn clipboard_command() -> Command { crate::hidden_command("clip.exe") } +#[cfg(target_os = "windows")] +fn clipboard_read_command() -> Command { + let mut command = crate::hidden_command("powershell.exe"); + command.args(["-NoProfile", "-Command", "Get-Clipboard -Raw"]); + command +} + #[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))] fn clipboard_command() -> Command { let mut command = crate::hidden_command("sh"); @@ -162,6 +174,16 @@ fn clipboard_command() -> Command { command } +#[cfg(all(desktop, not(any(target_os = "macos", target_os = "windows"))))] +fn clipboard_read_command() -> Command { + let mut command = crate::hidden_command("sh"); + command.args([ + "-c", + "if command -v wl-paste >/dev/null 2>&1; then wl-paste; elif command -v xclip >/dev/null 2>&1; then xclip -selection clipboard -out; elif command -v xsel >/dev/null 2>&1; then xsel --clipboard --output; else exit 127; fi", + ]); + command +} + #[cfg(desktop)] fn clipboard_failure_message(stderr: &[u8]) -> String { let message = String::from_utf8_lossy(stderr).trim().to_string(); @@ -200,12 +222,33 @@ fn write_native_clipboard(mut command: Command, text: &str) -> Result<(), String } } +#[cfg(desktop)] +fn read_native_clipboard(mut command: Command) -> Result { + let output = command + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(|e| format!("Failed to read native clipboard text: {e}"))?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + Err(clipboard_failure_message(&output.stderr)) + } +} + #[cfg(desktop)] #[tauri::command] pub fn copy_text_to_clipboard(text: String) -> Result<(), String> { write_native_clipboard(clipboard_command(), &text) } +#[cfg(desktop)] +#[tauri::command] +pub fn read_text_from_clipboard() -> Result { + read_native_clipboard(clipboard_read_command()) +} + #[cfg(desktop)] #[tauri::command] pub async fn sync_mcp_bridge_vault( @@ -254,6 +297,12 @@ pub fn copy_text_to_clipboard(_text: String) -> Result<(), String> { Err("Clipboard is not available on mobile".into()) } +#[cfg(mobile)] +#[tauri::command] +pub fn read_text_from_clipboard() -> Result { + Err("Clipboard is not available on mobile".into()) +} + #[cfg(mobile)] #[tauri::command] pub async fn sync_mcp_bridge_vault(_vault_path: Option) -> Result { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 03712479..738c206a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -579,6 +579,7 @@ macro_rules! app_invoke_handler { commands::check_mcp_status, commands::get_mcp_config_snippet, commands::copy_text_to_clipboard, + commands::read_text_from_clipboard, commands::sync_mcp_bridge_vault, commands::repair_vault, commands::reinit_telemetry, diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index cd4fc27b..d8fc5499 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -16,6 +16,7 @@ const FILE_SAVE: &str = "file-save"; const EDIT_FIND_IN_NOTE: &str = "edit-find-in-note"; const EDIT_REPLACE_IN_NOTE: &str = "edit-replace-in-note"; const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault"; +const EDIT_PASTE_PLAIN_TEXT: &str = "edit-paste-plain-text"; const EDIT_TOGGLE_NOTE_LIST_SEARCH: &str = "edit-toggle-note-list-search"; const EDIT_TOGGLE_RAW_EDITOR: &str = "edit-toggle-raw-editor"; const EDIT_TOGGLE_DIFF: &str = "edit-toggle-diff"; @@ -67,6 +68,7 @@ const CUSTOM_IDS: &[&str] = &[ EDIT_FIND_IN_NOTE, EDIT_REPLACE_IN_NOTE, EDIT_FIND_IN_VAULT, + EDIT_PASTE_PLAIN_TEXT, EDIT_TOGGLE_NOTE_LIST_SEARCH, EDIT_TOGGLE_RAW_EDITOR, EDIT_TOGGLE_DIFF, @@ -227,6 +229,10 @@ fn build_edit_menu(app: &App) -> MenuResult { let toggle_diff = MenuItemBuilder::new("Toggle Diff Mode") .id(EDIT_TOGGLE_DIFF) .build(app)?; + let paste_plain_text = MenuItemBuilder::new("Paste without Formatting") + .id(EDIT_PASTE_PLAIN_TEXT) + .accelerator("CmdOrCtrl+Shift+V") + .build(app)?; Ok(SubmenuBuilder::new(app, "Edit") .undo() @@ -235,6 +241,7 @@ fn build_edit_menu(app: &App) -> MenuResult { .cut() .copy() .paste() + .item(&paste_plain_text) .separator() .select_all() .separator() @@ -540,6 +547,7 @@ mod tests { EDIT_FIND_IN_NOTE, EDIT_REPLACE_IN_NOTE, EDIT_FIND_IN_VAULT, + EDIT_PASTE_PLAIN_TEXT, EDIT_TOGGLE_NOTE_LIST_SEARCH, EDIT_TOGGLE_RAW_EDITOR, EDIT_TOGGLE_DIFF, @@ -571,6 +579,7 @@ mod tests { VAULT_VIEW_CHANGES, VAULT_INSTALL_MCP, VAULT_RELOAD, + VAULT_REPAIR, ]; for id in &expected { assert!(CUSTOM_IDS.contains(id), "missing custom ID: {id}"); diff --git a/src/App.tsx b/src/App.tsx index 3c8dd180..08e3645d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -125,6 +125,7 @@ import { isExplicitOrganizationEnabled, sanitizeSelectionForOrganization, } from './utils/organizationWorkflow' +import { requestPlainTextPaste } from './utils/plainTextPaste' import './App.css' // Type declarations for mock content storage and test overrides @@ -1403,6 +1404,11 @@ function App() { const replaceInNoteCommand = useCallback(() => { findInNoteRef.current?.({ replace: true }) }, []) + const pastePlainTextCommand = useCallback(() => { + void requestPlainTextPaste().catch((error) => { + console.warn('[paste] Failed to paste plain text:', error) + }) + }, []) const removeActiveVaultCommand = useCallback(() => { vaultSwitcher.removeVault(vaultSwitcher.vaultPath) }, [vaultSwitcher]) @@ -1492,6 +1498,7 @@ function App() { onSearch: dialogs.openSearch, onFindInNote: findInNoteCommand, onReplaceInNote: activeDeletedFile ? undefined : replaceInNoteCommand, + onPastePlainText: pastePlainTextCommand, onCreateNote: notes.handleCreateNoteImmediate, onCreateNoteOfType: notes.handleCreateNoteImmediate, onSave: appSave.handleSave, diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index f2a3c510..ad07469e 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -399,6 +399,7 @@ function OpenCommandPalette({ return (
diff --git a/src/components/LinuxMenuButton.test.tsx b/src/components/LinuxMenuButton.test.tsx index ae061220..f252342d 100644 --- a/src/components/LinuxMenuButton.test.tsx +++ b/src/components/LinuxMenuButton.test.tsx @@ -32,6 +32,11 @@ describe('LinuxMenuButton', () => { it('dispatches shared menu commands from the Linux menu', async () => { render() + await openSubmenu('Edit') + expect(screen.getByText('Ctrl+Shift+V')).toBeInTheDocument() + fireEvent.click(await screen.findByText('Paste without Formatting')) + expect(invoke).toHaveBeenCalledWith('trigger_menu_command', { id: 'edit-paste-plain-text' }) + await openSubmenu('Note') expect(screen.getByText('Ctrl+Shift+L')).toBeInTheDocument() fireEvent.click(await screen.findByText('Toggle AI Panel')) diff --git a/src/components/LinuxMenuButton.tsx b/src/components/LinuxMenuButton.tsx index b939be11..afa4f825 100644 --- a/src/components/LinuxMenuButton.tsx +++ b/src/components/LinuxMenuButton.tsx @@ -44,6 +44,7 @@ const MENU_SECTIONS: ReadonlyArray = [ { kind: 'command', label: 'Find in Note', commandId: APP_COMMAND_IDS.editFindInNote }, { kind: 'command', label: 'Replace in Note', commandId: APP_COMMAND_IDS.editReplaceInNote }, { kind: 'command', label: 'Find in Vault', commandId: APP_COMMAND_IDS.editFindInVault }, + { kind: 'command', label: 'Paste without Formatting', commandId: APP_COMMAND_IDS.editPastePlainText }, { kind: 'command', label: 'Toggle Note List Search', commandId: 'edit-toggle-note-list-search' }, { kind: 'command', label: 'Toggle Diff Mode', commandId: APP_COMMAND_IDS.editToggleDiff }, ], diff --git a/src/components/RawEditorView.behavior.test.tsx b/src/components/RawEditorView.behavior.test.tsx index 07df9de8..e4138de8 100644 --- a/src/components/RawEditorView.behavior.test.tsx +++ b/src/components/RawEditorView.behavior.test.tsx @@ -76,6 +76,7 @@ vi.mock('./NoteSearchList', () => ({ })) import { RawEditorView } from './RawEditorView' +import { insertPlainTextFromClipboardText } from '../utils/plainTextPaste' function entry(title: string, path = `/vault/note/${title}.md`) { return { @@ -112,6 +113,10 @@ function createMockView(docText = '[[Target') { state: { doc: { toString: () => docText }, selection: { main: { head: docText.length } }, + replaceSelection: vi.fn((text: string) => ({ + changes: { from: 2, to: 5, insert: text }, + selection: { anchor: 2 + text.length }, + })), }, dispatch: vi.fn(), focus: vi.fn(), @@ -313,4 +318,30 @@ describe('RawEditorView behavior coverage', () => { expect(callbacks.onEscape()).toBe(true) }) + it('handles registered plain-text paste requests with CodeMirror selection replacement', () => { + const mockView = createMockView('Alpha Beta') + viewRefState.current = mockView + + render( + , + ) + + fireEvent.focus(screen.getByTestId('raw-editor-codemirror')) + + expect(insertPlainTextFromClipboardText('Plain\nText')).toBe(true) + expect(mockView.state.replaceSelection).toHaveBeenCalledWith('Plain\nText') + expect(mockView.dispatch).toHaveBeenCalledWith({ + changes: { from: 2, to: 5, insert: 'Plain\nText' }, + selection: { anchor: 12 }, + userEvent: 'input.paste', + }) + expect(mockView.focus).toHaveBeenCalledOnce() + }) + }) diff --git a/src/components/RawEditorView.tsx b/src/components/RawEditorView.tsx index 12a65a52..da796de1 100644 --- a/src/components/RawEditorView.tsx +++ b/src/components/RawEditorView.tsx @@ -17,6 +17,11 @@ import { useCodeMirror } from '../hooks/useCodeMirror' import type { VaultEntry } from '../types' import { translate, type AppLocale } from '../lib/i18n' import { RawEditorFindBar, type RawEditorFindRequest } from './RawEditorFindBar' +import { + activatePlainTextPasteTarget, + registerPlainTextPasteTarget, + type PlainTextPasteTarget, +} from '../utils/plainTextPaste' export interface RawEditorViewProps { content: string @@ -334,6 +339,53 @@ function useRawEditorWikilinkInsertion({ useEffect(() => { insertWikilinkRef.current = insertAutocompleteWikilink }, [insertAutocompleteWikilink, insertWikilinkRef]) } +function useRawEditorPlainTextPasteTarget({ + containerRef, + setAutocomplete, + viewRef, +}: { + containerRef: React.RefObject + setAutocomplete: RawEditorSetAutocomplete + viewRef: React.MutableRefObject +}) { + const targetRef = useRef(null) + + useEffect(() => { + const target: PlainTextPasteTarget = { + surface: 'raw_editor', + contains: (element) => Boolean(element && containerRef.current?.contains(element)), + isConnected: () => containerRef.current?.isConnected === true, + insert: (text) => { + const view = viewRef.current + if (!view) return false + + view.dispatch({ + ...view.state.replaceSelection(text), + userEvent: 'input.paste', + }) + setAutocomplete(null) + view.focus() + return true + }, + } + targetRef.current = target + const unregister = registerPlainTextPasteTarget(target) + + return () => { + unregister() + if (targetRef.current === target) { + targetRef.current = null + } + } + }, [containerRef, setAutocomplete, viewRef]) + + return useCallback(() => { + if (targetRef.current) { + activatePlainTextPasteTarget(targetRef.current) + } + }, []) +} + export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath, locale = 'en', findRequest }: RawEditorViewProps) { const containerRef = useRef(null) const [rawDoc, setRawDoc] = useState(content) @@ -366,6 +418,11 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave, onSave: pendingChanges.handleSave, onEscape: handleEscape, }) + const activatePlainTextPaste = useRawEditorPlainTextPasteTarget({ + containerRef, + setAutocomplete, + viewRef, + }) useRawEditorWikilinkInsertion({ debounceRef: pendingChanges.debounceRef, @@ -391,7 +448,14 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave, const dropdownPosition = getRawEditorDropdownPosition(autocomplete, DROPDOWN_MAX_HEIGHT, window) return ( -
+
({ capturedLinkToolbarProps: null as null | Record, @@ -603,6 +604,18 @@ describe('SingleEditorView', () => { expect(clipboardData.setData).not.toHaveBeenCalled() }) + it('handles registered plain-text paste requests through BlockNote insertion', () => { + const { container, editor } = renderEditorHarness() + + fireEvent.focus(container) + + expect(insertPlainTextFromClipboardText('Plain\nText')).toBe(true) + expect(editor.focus).toHaveBeenCalled() + expect(editor.insertInlineContent).toHaveBeenCalledWith('Plain\nText', { + updateSelection: true, + }) + }) + it('routes clicks on the empty title wrapper back into the H1 block', async () => { const editor = createEditor() diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx index 95fb0d54..42ddf8b7 100644 --- a/src/components/SingleEditorView.tsx +++ b/src/components/SingleEditorView.tsx @@ -42,6 +42,11 @@ import { TolariaSideMenu } from './tolariaBlockNoteSideMenu' import { useEditorLinkActivation } from './useEditorLinkActivation' import { findNearestTextCursorBlock } from './blockNoteCursorTarget' import { ImageLightbox } from './ImageLightbox' +import { + activatePlainTextPasteTarget, + registerPlainTextPasteTarget, + type PlainTextPasteTarget, +} from '../utils/plainTextPaste' const TEST_TABLE_MARKDOWN = `| Head 1 | Head 2 | Head 3 | | --- | --- | --- | @@ -575,6 +580,50 @@ function useInsertImageCallback(editor: ReturnType) { }, []) } +function useRichEditorPlainTextPasteTarget(options: { + containerRef: React.RefObject + editable: boolean + editor: ReturnType + runEditorAction: (action: SuggestionAction) => void +}) { + const { containerRef, editable, editor, runEditorAction } = options + const targetRef = useRef(null) + + useEffect(() => { + const target: PlainTextPasteTarget = { + surface: 'rich_editor', + contains: (element) => Boolean(element && containerRef.current?.contains(element)), + isConnected: () => containerRef.current?.isConnected === true, + insert: (text) => { + if (!editable) return false + + let inserted = false + runEditorAction(() => { + editor.focus() + editor.insertInlineContent(text, { updateSelection: true }) + inserted = true + }) + return inserted + }, + } + targetRef.current = target + const unregister = registerPlainTextPasteTarget(target) + + return () => { + unregister() + if (targetRef.current === target) { + targetRef.current = null + } + } + }, [containerRef, editable, editor, runEditorAction]) + + return useCallback(() => { + if (targetRef.current) { + activatePlainTextPasteTarget(targetRef.current) + } + }, []) +} + /** Single BlockNote editor view — content is swapped via replaceBlocks */ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath, editable = true, locale = 'en' }: { editor: ReturnType @@ -617,6 +666,12 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange editor, }) }, [editor]) + const activatePlainTextPaste = useRichEditorPlainTextPasteTarget({ + containerRef, + editable, + editor, + runEditorAction, + }) const insertWikilink = useInsertWikilink(editor, runEditorAction) const { getWikilinkItems, @@ -632,7 +687,15 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }) return ( -
+
{isDragOver && (
Drop image here
diff --git a/src/hooks/appCommandCatalog.ts b/src/hooks/appCommandCatalog.ts index 481df84c..fe3f0205 100644 --- a/src/hooks/appCommandCatalog.ts +++ b/src/hooks/appCommandCatalog.ts @@ -12,6 +12,7 @@ export const APP_COMMAND_IDS = { editFindInNote: 'edit-find-in-note', editReplaceInNote: 'edit-replace-in-note', editFindInVault: 'edit-find-in-vault', + editPastePlainText: 'edit-paste-plain-text', editToggleRawEditor: 'edit-toggle-raw-editor', editToggleDiff: 'edit-toggle-diff', viewEditorOnly: 'view-editor-only', @@ -84,6 +85,7 @@ type SimpleHandlerKey = | 'onSave' | 'onFindInNote' | 'onReplaceInNote' + | 'onPastePlainText' | 'onSearch' | 'onToggleRawEditor' | 'onToggleDiff' @@ -186,6 +188,11 @@ export const APP_COMMAND_DEFINITIONS: Record menuOwned: true, shortcut: { combo: 'command-or-ctrl-shift', key: 'f', code: 'KeyF', display: '⌘⇧F' }, }, + [APP_COMMAND_IDS.editPastePlainText]: { + route: { kind: 'handler', handler: 'onPastePlainText' }, + menuOwned: true, + shortcut: { combo: 'command-or-ctrl-shift', key: 'v', code: 'KeyV', display: '⌘⇧V' }, + }, [APP_COMMAND_IDS.editToggleRawEditor]: { route: { kind: 'handler', handler: 'onToggleRawEditor' }, menuOwned: true, @@ -360,6 +367,7 @@ const MANUAL_NATIVE_ACCELERATOR_QA_COMMAND_SET = new Set([ APP_COMMAND_IDS.fileSave, APP_COMMAND_IDS.editFindInNote, APP_COMMAND_IDS.editFindInVault, + APP_COMMAND_IDS.editPastePlainText, APP_COMMAND_IDS.viewToggleAiChat, APP_COMMAND_IDS.viewCommandPalette, APP_COMMAND_IDS.noteToggleOrganized, diff --git a/src/hooks/appCommandDispatcher.test.ts b/src/hooks/appCommandDispatcher.test.ts index 950c9e51..8e466704 100644 --- a/src/hooks/appCommandDispatcher.test.ts +++ b/src/hooks/appCommandDispatcher.test.ts @@ -46,6 +46,7 @@ function makeHandlers(): AppCommandHandlers { onToggleRawEditor: vi.fn(), onToggleDiff: vi.fn(), onToggleAIChat: vi.fn(), + onPastePlainText: vi.fn(), onGoBack: vi.fn(), onGoForward: vi.fn(), onCheckForUpdates: vi.fn(), @@ -67,6 +68,21 @@ function makeHandlers(): AppCommandHandlers { } } +function expectShortcutEventCommand( + event: Partial & Pick, + commandId: string | null, +) { + expect( + findShortcutCommandIdForEvent({ + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + ...event, + }), + ).toBe(commandId) +} + describe('appCommandDispatcher', () => { afterEach(() => { setUserAgent(originalUserAgent) @@ -87,10 +103,11 @@ describe('appCommandDispatcher', () => { expect(isNativeMenuCommandId(APP_COMMAND_IDS.noteToggleFavorite)).toBe(false) }) - it('finds raw editor and AI shortcuts from the shared catalog', () => { + it('finds raw editor, AI, and plain-text paste shortcuts from the shared catalog', () => { expect(findShortcutCommandId('command-or-ctrl', 'o', 'KeyO')).toBe(APP_COMMAND_IDS.fileQuickOpen) expect(findShortcutCommandId('command-or-ctrl', '\\')).toBe(APP_COMMAND_IDS.editToggleRawEditor) expect(findShortcutCommandId('command-or-ctrl-shift', '¬', 'KeyL')).toBe(APP_COMMAND_IDS.viewToggleAiChat) + expect(findShortcutCommandId('command-or-ctrl-shift', 'v', 'KeyV')).toBe(APP_COMMAND_IDS.editPastePlainText) }) it('gives every shortcut command an explicit deterministic QA strategy', () => { @@ -112,6 +129,12 @@ describe('appCommandDispatcher', () => { supportsNativeMenuCommand: false, requiresManualNativeAcceleratorQa: true, }) + expect(getDeterministicShortcutQaDefinition(APP_COMMAND_IDS.editPastePlainText)).toMatchObject({ + preferredMode: 'native-menu-command', + supportsRendererShortcutEvent: true, + supportsNativeMenuCommand: true, + requiresManualNativeAcceleratorQa: true, + }) }) it('builds deterministic keyboard events from the shared shortcut manifest', () => { @@ -140,121 +163,23 @@ describe('appCommandDispatcher', () => { }) it('resolves event modifiers through the shared shortcut catalog', () => { - expect( - findShortcutCommandIdForEvent({ - key: 'o', - code: 'KeyO', - altKey: false, - ctrlKey: false, - metaKey: true, - shiftKey: false, - }), - ).toBe(APP_COMMAND_IDS.fileQuickOpen) - expect( - findShortcutCommandIdForEvent({ - key: '¬', - code: 'KeyL', - altKey: false, - ctrlKey: false, - metaKey: true, - shiftKey: true, - }), - ).toBe(APP_COMMAND_IDS.viewToggleAiChat) - expect( - findShortcutCommandIdForEvent({ - key: 'I', - code: 'KeyI', - altKey: false, - ctrlKey: false, - metaKey: true, - shiftKey: true, - }), - ).toBe(APP_COMMAND_IDS.viewToggleProperties) - expect( - findShortcutCommandIdForEvent({ - key: 'ArrowLeft', - code: 'ArrowLeft', - altKey: false, - ctrlKey: false, - metaKey: true, - shiftKey: false, - }), - ).toBe(APP_COMMAND_IDS.viewGoBack) - expect( - findShortcutCommandIdForEvent({ - key: 'ArrowRight', - code: 'ArrowRight', - altKey: false, - ctrlKey: false, - metaKey: true, - shiftKey: false, - }), - ).toBe(APP_COMMAND_IDS.viewGoForward) - expect( - findShortcutCommandIdForEvent({ - key: 'l', - code: 'KeyL', - altKey: false, - ctrlKey: true, - metaKey: false, - shiftKey: true, - }), - ).toBe(APP_COMMAND_IDS.viewToggleAiChat) + expectShortcutEventCommand({ key: 'o', code: 'KeyO', metaKey: true }, APP_COMMAND_IDS.fileQuickOpen) + expectShortcutEventCommand({ key: '¬', code: 'KeyL', metaKey: true, shiftKey: true }, APP_COMMAND_IDS.viewToggleAiChat) + expectShortcutEventCommand({ key: 'I', code: 'KeyI', metaKey: true, shiftKey: true }, APP_COMMAND_IDS.viewToggleProperties) + expectShortcutEventCommand({ key: 'ArrowLeft', code: 'ArrowLeft', metaKey: true }, APP_COMMAND_IDS.viewGoBack) + expectShortcutEventCommand({ key: 'ArrowRight', code: 'ArrowRight', metaKey: true }, APP_COMMAND_IDS.viewGoForward) + expectShortcutEventCommand({ key: 'l', code: 'KeyL', ctrlKey: true, shiftKey: true }, APP_COMMAND_IDS.viewToggleAiChat) + expectShortcutEventCommand({ key: 'V', code: 'KeyV', metaKey: true, shiftKey: true }, APP_COMMAND_IDS.editPastePlainText) }) it('ignores macOS Control-only shortcuts so native text editing bindings pass through', () => { setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)') - expect( - findShortcutCommandIdForEvent({ - key: 'e', - code: 'KeyE', - altKey: false, - ctrlKey: true, - metaKey: false, - shiftKey: false, - }), - ).toBeNull() - expect( - findShortcutCommandIdForEvent({ - key: 'n', - code: 'KeyN', - altKey: false, - ctrlKey: true, - metaKey: false, - shiftKey: false, - }), - ).toBeNull() - expect( - findShortcutCommandIdForEvent({ - key: 'p', - code: 'KeyP', - altKey: false, - ctrlKey: true, - metaKey: false, - shiftKey: false, - }), - ).toBeNull() - expect( - findShortcutCommandIdForEvent({ - key: 'd', - code: 'KeyD', - altKey: false, - ctrlKey: true, - metaKey: false, - shiftKey: false, - }), - ).toBeNull() - expect( - findShortcutCommandIdForEvent({ - key: 'e', - code: 'KeyE', - altKey: false, - ctrlKey: false, - metaKey: true, - shiftKey: false, - }), - ).toBe(APP_COMMAND_IDS.noteToggleOrganized) + expectShortcutEventCommand({ key: 'e', code: 'KeyE', ctrlKey: true }, null) + expectShortcutEventCommand({ key: 'n', code: 'KeyN', ctrlKey: true }, null) + expectShortcutEventCommand({ key: 'p', code: 'KeyP', ctrlKey: true }, null) + expectShortcutEventCommand({ key: 'd', code: 'KeyD', ctrlKey: true }, null) + expectShortcutEventCommand({ key: 'e', code: 'KeyE', metaKey: true }, APP_COMMAND_IDS.noteToggleOrganized) }) it('dispatches create note through the shared command path', () => { @@ -275,6 +200,12 @@ describe('appCommandDispatcher', () => { expect(handlers.onToggleAIChat).toHaveBeenCalled() }) + it('dispatches plain-text paste through the shared command path', () => { + const handlers = makeHandlers() + expect(dispatchAppCommand(APP_COMMAND_IDS.editPastePlainText, handlers)).toBe(true) + expect(handlers.onPastePlainText).toHaveBeenCalled() + }) + it('uses the active note for note-scoped commands', () => { const handlers = makeHandlers() expect(dispatchAppCommand(APP_COMMAND_IDS.noteToggleFavorite, handlers)).toBe(true) diff --git a/src/hooks/appCommandDispatcher.ts b/src/hooks/appCommandDispatcher.ts index d59c43da..38dfc0fb 100644 --- a/src/hooks/appCommandDispatcher.ts +++ b/src/hooks/appCommandDispatcher.ts @@ -45,6 +45,7 @@ export interface AppCommandHandlers { onDeleteNote: (path: string) => void onFindInNote?: () => void onReplaceInNote?: () => void + onPastePlainText: () => void onSearch: () => void onToggleRawEditor?: () => void onToggleDiff?: () => void @@ -80,6 +81,7 @@ type SimpleHandlerKey = keyof Pick< | 'onSave' | 'onFindInNote' | 'onReplaceInNote' + | 'onPastePlainText' | 'onSearch' | 'onToggleRawEditor' | 'onToggleDiff' @@ -120,6 +122,7 @@ const SIMPLE_HANDLER_EXECUTORS: Record handlers.onSave(), onFindInNote: (handlers) => handlers.onFindInNote?.(), onReplaceInNote: (handlers) => handlers.onReplaceInNote?.(), + onPastePlainText: (handlers) => handlers.onPastePlainText(), onSearch: (handlers) => handlers.onSearch(), onToggleRawEditor: (handlers) => handlers.onToggleRawEditor?.(), onToggleDiff: (handlers) => handlers.onToggleDiff?.(), diff --git a/src/hooks/appKeyboardShortcuts.editorFind.test.ts b/src/hooks/appKeyboardShortcuts.editorFind.test.ts index fb2c8567..b4b10b9e 100644 --- a/src/hooks/appKeyboardShortcuts.editorFind.test.ts +++ b/src/hooks/appKeyboardShortcuts.editorFind.test.ts @@ -14,6 +14,7 @@ function makeActions(): KeyboardActions { onGoForward: vi.fn(), onOpenInNewWindow: vi.fn(), onOpenSettings: vi.fn(), + onPastePlainText: vi.fn(), onQuickOpen: vi.fn(), onReplaceInNote: vi.fn(), onSave: vi.fn(), diff --git a/src/hooks/appKeyboardShortcuts.ts b/src/hooks/appKeyboardShortcuts.ts index 2acb663d..4290fbfa 100644 --- a/src/hooks/appKeyboardShortcuts.ts +++ b/src/hooks/appKeyboardShortcuts.ts @@ -17,6 +17,7 @@ export type KeyboardActions = Pick< | 'onSave' | 'onFindInNote' | 'onReplaceInNote' + | 'onPastePlainText' | 'onOpenSettings' | 'onDeleteNote' | 'onArchiveNote' diff --git a/src/hooks/commands/localizeCommands.ts b/src/hooks/commands/localizeCommands.ts index d7b1c08b..4494f070 100644 --- a/src/hooks/commands/localizeCommands.ts +++ b/src/hooks/commands/localizeCommands.ts @@ -28,6 +28,7 @@ const STATIC_LABEL_KEYS: Partial> = { 'create-note': 'command.note.newNote', 'create-type': 'command.note.newType', 'save-note': 'command.note.saveNote', + 'paste-plain-text': 'command.note.pastePlainText', 'find-in-note': 'command.note.findInNote', 'replace-in-note': 'command.note.replaceInNote', 'delete-note': 'command.note.deleteNote', diff --git a/src/hooks/commands/noteCommands.ts b/src/hooks/commands/noteCommands.ts index 15e9d6d1..d5261f00 100644 --- a/src/hooks/commands/noteCommands.ts +++ b/src/hooks/commands/noteCommands.ts @@ -13,6 +13,7 @@ interface NoteCommandsConfig { onSave: () => void onFindInNote?: () => void onReplaceInNote?: () => void + onPastePlainText: () => void onDeleteNote: (path: string) => void onArchiveNote: (path: string) => void onUnarchiveNote: (path: string) => void @@ -87,6 +88,14 @@ function buildCoreNoteCommands(config: NoteCommandsConfig): CommandAction[] { enabled: config.hasActiveNote, execute: config.onSave, }), + createNoteCommand({ + id: 'paste-plain-text', + label: 'Paste without formatting', + shortcut: getAppCommandShortcutDisplay(APP_COMMAND_IDS.editPastePlainText), + keywords: ['paste', 'plain', 'formatting', 'clipboard', 'match style'], + enabled: true, + execute: config.onPastePlainText, + }), ...buildEditorFindCommands(config), ] } diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index e0a2b483..4f89d731 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -27,6 +27,7 @@ interface AppCommandsConfig { onSearch: () => void onFindInNote?: () => void onReplaceInNote?: () => void + onPastePlainText: () => void onCreateNote: () => void onCreateNoteOfType: (type: string) => void onSave: () => void @@ -152,6 +153,7 @@ type CommandRegistryCoreActions = Pick< | 'onSave' | 'onFindInNote' | 'onReplaceInNote' + | 'onPastePlainText' | 'onOpenSettings' | 'onOpenFeedback' | 'onDeleteNote' @@ -242,6 +244,7 @@ function createKeyboardActions( onSearch: config.onSearch, onFindInNote: config.onFindInNote, onReplaceInNote: config.onReplaceInNote, + onPastePlainText: config.onPastePlainText, onCreateNote: config.onCreateNote, onSave: config.onSave, onOpenSettings: config.onOpenSettings, @@ -294,6 +297,7 @@ function createMenuEventActionHandlers( | 'onDeleteNote' | 'onFindInNote' | 'onReplaceInNote' + | 'onPastePlainText' | 'onSearch' | 'onToggleRawEditor' | 'onToggleDiff' @@ -319,6 +323,7 @@ function createMenuEventActionHandlers( onDeleteNote: config.onDeleteNote, onFindInNote: config.onFindInNote, onReplaceInNote: config.onReplaceInNote, + onPastePlainText: config.onPastePlainText, onSearch: config.onSearch, onToggleRawEditor: config.onToggleRawEditor, onToggleDiff: config.onToggleDiff, @@ -441,6 +446,7 @@ function createCommandRegistryCoreConfig( canMoveSelectedViewDown: config.canMoveSelectedViewDown, onFindInNote: config.onFindInNote, onReplaceInNote: config.onReplaceInNote, + onPastePlainText: config.onPastePlainText, noteWidth: config.noteWidth, defaultNoteWidth: config.defaultNoteWidth, onSetNoteWidth: config.onSetNoteWidth, diff --git a/src/hooks/useAppKeyboard.test.ts b/src/hooks/useAppKeyboard.test.ts index 03b982c6..28353326 100644 --- a/src/hooks/useAppKeyboard.test.ts +++ b/src/hooks/useAppKeyboard.test.ts @@ -44,6 +44,7 @@ function makeActions() { onZoomIn: vi.fn(), onZoomOut: vi.fn(), onZoomReset: vi.fn(), + onPastePlainText: vi.fn(), onGoBack: vi.fn(), onGoForward: vi.fn(), activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject, @@ -291,6 +292,17 @@ describe('useAppKeyboard', () => { expect(actions.onCreateNote).not.toHaveBeenCalled() }) + it('Cmd+Shift+V triggers plain-text paste in focused text editors', () => { + const actions = makeActions() + renderHook(() => useAppKeyboard(actions)) + withFocusedContentEditable((editable) => { + const event = fireKeyOnTarget(editable, 'v', { metaKey: true, shiftKey: true, code: 'KeyV' }) + + expect(event.defaultPrevented).toBe(true) + expect(actions.onPastePlainText).toHaveBeenCalledOnce() + }) + }) + function withFocusedInput(fn: () => void) { const input = document.createElement('input') document.body.appendChild(input) diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index cb2dad05..9e91e2e3 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -14,6 +14,7 @@ function makeConfig(overrides: Record = {}) { onCreateNote: vi.fn(), onCreateNoteOfType: vi.fn(), onSave: vi.fn(), + onPastePlainText: vi.fn(), onOpenSettings: vi.fn(), onDeleteNote: vi.fn(), onArchiveNote: vi.fn(), @@ -588,6 +589,22 @@ describe('useCommandRegistry', () => { }) }) + it('exposes paste without formatting in the command palette', () => { + const onPastePlainText = vi.fn() + const { result } = renderHook(() => useCommandRegistry(makeConfig({ onPastePlainText }))) + const command = findCommand(result.current, 'paste-plain-text') + + expect(command).toMatchObject({ + label: 'Paste without formatting', + group: 'Note', + shortcut: formatShortcutDisplay({ display: '⌘⇧V' }), + enabled: true, + }) + + command!.execute() + expect(onPastePlainText).toHaveBeenCalledOnce() + }) + it('keeps a single canonical New Type command when the Type definition exists', () => { const onCreateType = vi.fn() const onCreateNoteOfType = vi.fn() diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index be5b5377..0c592fd3 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -66,6 +66,7 @@ interface CommandRegistryConfig { onCreateNote: () => void onCreateNoteOfType: (type: string) => void onSave: () => void + onPastePlainText: () => void onOpenSettings: () => void onOpenFeedback?: () => void onOpenVault?: () => void @@ -125,7 +126,7 @@ interface CommandRegistryConfig { export function useCommandRegistry(config: CommandRegistryConfig): import('./commands/types').CommandAction[] { const { activeTabPath, entries, modifiedCount, - onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings, onOpenFeedback, + onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onPastePlainText, onOpenSettings, onOpenFeedback, onDeleteNote, onArchiveNote, onUnarchiveNote, onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onFindInNote, onReplaceInNote, noteWidth, defaultNoteWidth, onSetNoteWidth, onSetDefaultNoteWidth, onToggleAIChat, onOpenVault, onCreateEmptyVault, @@ -188,7 +189,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com const noteCommands = useMemo(() => buildNoteCommands({ hasActiveNote, activeTabPath, activeFileKind: activeEntry?.fileKind ?? 'markdown', isArchived, onCreateNote, onCreateType, onSave, - onFindInNote, onReplaceInNote, + onFindInNote, onReplaceInNote, onPastePlainText, onDeleteNote, onArchiveNote, onUnarchiveNote, onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, @@ -198,7 +199,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com onRestoreDeletedNote, canRestoreDeletedNote, }), [ hasActiveNote, activeTabPath, activeEntry?.fileKind, isArchived, - onCreateNote, onCreateType, onSave, onFindInNote, onReplaceInNote, onDeleteNote, onArchiveNote, onUnarchiveNote, + onCreateNote, onCreateType, onSave, onFindInNote, onReplaceInNote, onPastePlainText, onDeleteNote, onArchiveNote, onUnarchiveNote, onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, onRevealActiveFile, onCopyActiveFilePath, onOpenActiveFileExternal, diff --git a/src/hooks/useMenuEvents.editorFind.test.ts b/src/hooks/useMenuEvents.editorFind.test.ts index a7ed2caa..88b19860 100644 --- a/src/hooks/useMenuEvents.editorFind.test.ts +++ b/src/hooks/useMenuEvents.editorFind.test.ts @@ -31,6 +31,7 @@ function makeHandlers(): MenuEventHandlers { onDeleteNote: vi.fn(), onFindInNote: vi.fn(), onOpenSettings: vi.fn(), + onPastePlainText: vi.fn(), onQuickOpen: vi.fn(), onReplaceInNote: vi.fn(), onSave: vi.fn(), diff --git a/src/hooks/useMenuEvents.noteListSearch.test.ts b/src/hooks/useMenuEvents.noteListSearch.test.ts index 05da8139..50e119d0 100644 --- a/src/hooks/useMenuEvents.noteListSearch.test.ts +++ b/src/hooks/useMenuEvents.noteListSearch.test.ts @@ -38,6 +38,7 @@ function makeHandlers(): MenuEventHandlers { onToggleRawEditor: vi.fn(), onToggleDiff: vi.fn(), onToggleAIChat: vi.fn(), + onPastePlainText: vi.fn(), onGoBack: vi.fn(), onGoForward: vi.fn(), onCheckForUpdates: vi.fn(), diff --git a/src/hooks/useMenuEvents.test.ts b/src/hooks/useMenuEvents.test.ts index 385639c6..4b77cc7b 100644 --- a/src/hooks/useMenuEvents.test.ts +++ b/src/hooks/useMenuEvents.test.ts @@ -38,6 +38,7 @@ function makeHandlers(): MenuEventHandlers { onToggleRawEditor: vi.fn(), onToggleDiff: vi.fn(), onToggleAIChat: vi.fn(), + onPastePlainText: vi.fn(), onGoBack: vi.fn(), onGoForward: vi.fn(), onCheckForUpdates: vi.fn(), @@ -310,6 +311,12 @@ describe('dispatchMenuEvent', () => { expect(h.onToggleDiff).toHaveBeenCalled() }) + it('edit-paste-plain-text triggers plain-text paste', () => { + const h = makeHandlers() + dispatchMenuEvent('edit-paste-plain-text', h) + expect(h.onPastePlainText).toHaveBeenCalled() + }) + it('view-toggle-ai-chat triggers toggle AI chat', () => { const h = makeHandlers() dispatchMenuEvent('view-toggle-ai-chat', h) diff --git a/src/lib/locales/de-DE.json b/src/lib/locales/de-DE.json index 39435340..8c052cce 100644 --- a/src/lib/locales/de-DE.json +++ b/src/lib/locales/de-DE.json @@ -37,6 +37,7 @@ "command.note.newType": "Neuer Typ", "command.note.newTypedNote": "Neuer {type}", "command.note.saveNote": "Notiz speichern", + "command.note.pastePlainText": "Ohne Formatierung einfügen", "command.note.findInNote": "In Notiz suchen", "command.note.replaceInNote": "In Notiz ersetzen", "command.note.deleteNote": "Notiz löschen", diff --git a/src/lib/locales/en.json b/src/lib/locales/en.json index ee1f5c36..e44112fe 100644 --- a/src/lib/locales/en.json +++ b/src/lib/locales/en.json @@ -37,6 +37,7 @@ "command.note.newType": "New Type", "command.note.newTypedNote": "New {type}", "command.note.saveNote": "Save Note", + "command.note.pastePlainText": "Paste without formatting", "command.note.findInNote": "Find in Note", "command.note.replaceInNote": "Replace in Note", "command.note.deleteNote": "Delete Note", diff --git a/src/lib/locales/es-419.json b/src/lib/locales/es-419.json index f12c541f..814a7404 100644 --- a/src/lib/locales/es-419.json +++ b/src/lib/locales/es-419.json @@ -37,6 +37,7 @@ "command.note.newType": "Nuevo tipo", "command.note.newTypedNote": "Nuevo {type}", "command.note.saveNote": "Guardar nota", + "command.note.pastePlainText": "Pegar sin formato", "command.note.findInNote": "Buscar en la nota", "command.note.replaceInNote": "Reemplazar en la nota", "command.note.deleteNote": "Eliminar nota", diff --git a/src/lib/locales/es-ES.json b/src/lib/locales/es-ES.json index 45a9a45b..b348b647 100644 --- a/src/lib/locales/es-ES.json +++ b/src/lib/locales/es-ES.json @@ -37,6 +37,7 @@ "command.note.newType": "Nuevo tipo", "command.note.newTypedNote": "Nuevo {type}", "command.note.saveNote": "Guardar nota", + "command.note.pastePlainText": "Pegar sin formato", "command.note.findInNote": "Buscar en la nota", "command.note.replaceInNote": "Reemplazar en la nota", "command.note.deleteNote": "Eliminar nota", diff --git a/src/lib/locales/fr-FR.json b/src/lib/locales/fr-FR.json index 4c34d7c1..f991d4d6 100644 --- a/src/lib/locales/fr-FR.json +++ b/src/lib/locales/fr-FR.json @@ -37,6 +37,7 @@ "command.note.newType": "Nouveau type", "command.note.newTypedNote": "Nouveau {type}", "command.note.saveNote": "Enregistrer la note", + "command.note.pastePlainText": "Coller sans mise en forme", "command.note.findInNote": "Rechercher dans la note", "command.note.replaceInNote": "Remplacer dans la note", "command.note.deleteNote": "Supprimer la note", diff --git a/src/lib/locales/it-IT.json b/src/lib/locales/it-IT.json index ed4bcce8..fd5d7695 100644 --- a/src/lib/locales/it-IT.json +++ b/src/lib/locales/it-IT.json @@ -37,6 +37,7 @@ "command.note.newType": "Nuovo tipo", "command.note.newTypedNote": "Nuovo {type}", "command.note.saveNote": "Salva nota", + "command.note.pastePlainText": "Incolla senza formattazione", "command.note.findInNote": "Trova nella nota", "command.note.replaceInNote": "Sostituisci nella nota", "command.note.deleteNote": "Elimina nota", diff --git a/src/lib/locales/ja-JP.json b/src/lib/locales/ja-JP.json index 844b66c8..dd1fb358 100644 --- a/src/lib/locales/ja-JP.json +++ b/src/lib/locales/ja-JP.json @@ -37,6 +37,7 @@ "command.note.newType": "新しいタイプ", "command.note.newTypedNote": "新規{type}", "command.note.saveNote": "メモを保存", + "command.note.pastePlainText": "書式設定せずに貼り付け", "command.note.findInNote": "ノート内で検索", "command.note.replaceInNote": "ノート内で置き換える", "command.note.deleteNote": "ノートを削除", diff --git a/src/lib/locales/ko-KR.json b/src/lib/locales/ko-KR.json index e6c1bab4..bc03838d 100644 --- a/src/lib/locales/ko-KR.json +++ b/src/lib/locales/ko-KR.json @@ -37,6 +37,7 @@ "command.note.newType": "새 유형", "command.note.newTypedNote": "새 {type}", "command.note.saveNote": "메모 저장", + "command.note.pastePlainText": "서식 없이 붙여넣기", "command.note.findInNote": "노트에서 찾기", "command.note.replaceInNote": "노트에서 바꾸기", "command.note.deleteNote": "메모 삭제", diff --git a/src/lib/locales/pt-BR.json b/src/lib/locales/pt-BR.json index de46c7fb..87c44e32 100644 --- a/src/lib/locales/pt-BR.json +++ b/src/lib/locales/pt-BR.json @@ -37,6 +37,7 @@ "command.note.newType": "Novo tipo", "command.note.newTypedNote": "Novo {type}", "command.note.saveNote": "Salvar nota", + "command.note.pastePlainText": "Colar sem formatação", "command.note.findInNote": "Localizar na nota", "command.note.replaceInNote": "Substituir na nota", "command.note.deleteNote": "Excluir nota", diff --git a/src/lib/locales/pt-PT.json b/src/lib/locales/pt-PT.json index 4bb38c8b..ed5bca77 100644 --- a/src/lib/locales/pt-PT.json +++ b/src/lib/locales/pt-PT.json @@ -37,6 +37,7 @@ "command.note.newType": "Novo tipo", "command.note.newTypedNote": "Novo {type}", "command.note.saveNote": "Guardar nota", + "command.note.pastePlainText": "Colar sem formatação", "command.note.findInNote": "Procurar na nota", "command.note.replaceInNote": "Substituir na nota", "command.note.deleteNote": "Eliminar nota", diff --git a/src/lib/locales/ru-RU.json b/src/lib/locales/ru-RU.json index ea22979b..35c88131 100644 --- a/src/lib/locales/ru-RU.json +++ b/src/lib/locales/ru-RU.json @@ -37,6 +37,7 @@ "command.note.newType": "Новый тип", "command.note.newTypedNote": "Новый {type}", "command.note.saveNote": "Сохранить заметку", + "command.note.pastePlainText": "Вставить без форматирования", "command.note.findInNote": "Найти в заметке", "command.note.replaceInNote": "Заменить в заметке", "command.note.deleteNote": "Удалить заметку", diff --git a/src/lib/locales/zh-CN.json b/src/lib/locales/zh-CN.json index 95882a4c..c090ffa0 100644 --- a/src/lib/locales/zh-CN.json +++ b/src/lib/locales/zh-CN.json @@ -37,6 +37,7 @@ "command.note.newType": "新建类型", "command.note.newTypedNote": "新建{type}", "command.note.saveNote": "保存笔记", + "command.note.pastePlainText": "粘贴时不保留格式", "command.note.findInNote": "在笔记中查找", "command.note.replaceInNote": "在笔记中替换", "command.note.deleteNote": "删除笔记", diff --git a/src/lib/locales/zh-TW.json b/src/lib/locales/zh-TW.json index 7a8e09bc..e1345e24 100644 --- a/src/lib/locales/zh-TW.json +++ b/src/lib/locales/zh-TW.json @@ -37,6 +37,7 @@ "command.note.newType": "新建型別", "command.note.newTypedNote": "新建{type}", "command.note.saveNote": "儲存筆記", + "command.note.pastePlainText": "貼上內容(不保留格式)", "command.note.findInNote": "在筆記中查詢", "command.note.replaceInNote": "在筆記中替換", "command.note.deleteNote": "刪除筆記", diff --git a/src/mock-tauri/mock-handlers.more.test.ts b/src/mock-tauri/mock-handlers.more.test.ts index 6f07b983..910b16a9 100644 --- a/src/mock-tauri/mock-handlers.more.test.ts +++ b/src/mock-tauri/mock-handlers.more.test.ts @@ -195,6 +195,7 @@ describe('mockHandlers additional coverage', () => { expect(mockHandlers.register_mcp_tools()).toBe('registered') expect(mockHandlers.check_mcp_status()).toBe('installed') expect(mockHandlers.copy_text_to_clipboard()).toBeNull() + expect(mockHandlers.read_text_from_clipboard()).toBe('') expect(mockHandlers.reinit_telemetry()).toBeNull() expect(mockHandlers.stream_claude_chat()).toBe('mock-session') expect(mockHandlers.stream_ai_agent()).toBeNull() diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 5862b995..a331688b 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -504,6 +504,7 @@ export const mockHandlers: Record any> = { }, }, null, 2), copy_text_to_clipboard: () => null, + read_text_from_clipboard: () => '', sync_mcp_bridge_vault: (args: { vaultPath?: string | null }) => args.vaultPath ? 'started' : 'stopped', repair_vault: (): string => { mockVaultAiGuidanceStatus = { diff --git a/src/utils/plainTextPaste.test.ts b/src/utils/plainTextPaste.test.ts new file mode 100644 index 00000000..3e76d0dc --- /dev/null +++ b/src/utils/plainTextPaste.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + activatePlainTextPasteTarget, + insertPlainTextFromClipboardText, + registerPlainTextPasteTarget, +} from './plainTextPaste' + +const { trackEvent } = vi.hoisted(() => ({ + trackEvent: vi.fn(), +})) + +vi.mock('../lib/telemetry', () => ({ + trackEvent, +})) + +describe('plainTextPaste', () => { + afterEach(() => { + vi.clearAllMocks() + document.body.innerHTML = '' + }) + + it('replaces the selected text in a focused textarea', () => { + const input = document.createElement('textarea') + input.value = 'Alpha Beta' + document.body.appendChild(input) + input.focus() + input.setSelectionRange(6, 10) + + expect(insertPlainTextFromClipboardText('Plain\nText')).toBe(true) + + expect(input.value).toBe('Alpha Plain\nText') + expect(input.selectionStart).toBe(16) + expect(trackEvent).toHaveBeenCalledWith('plain_text_paste_used', { + surface: 'focused_input', + }) + }) + + it('keeps command palette focus from swallowing the paste target', () => { + const palette = document.createElement('div') + palette.setAttribute('data-command-palette', 'true') + const input = document.createElement('input') + palette.appendChild(input) + document.body.appendChild(palette) + + const target = { + contains: () => false, + insert: vi.fn(() => true), + isConnected: () => true, + surface: 'raw_editor', + } + + const unregister = registerPlainTextPasteTarget(target) + activatePlainTextPasteTarget(target) + input.focus() + + expect(insertPlainTextFromClipboardText('Plain')).toBe(true) + expect(target.insert).toHaveBeenCalledWith('Plain') + expect(input.value).toBe('') + expect(trackEvent).toHaveBeenCalledWith('plain_text_paste_used', { + surface: 'raw_editor', + }) + + unregister() + }) + + it('uses the latest registered editor target when focus is outside text controls', () => { + const target = { + contains: () => false, + insert: vi.fn(() => true), + isConnected: () => true, + surface: 'rich_editor', + } + + const unregister = registerPlainTextPasteTarget(target) + document.body.focus() + + expect(insertPlainTextFromClipboardText('Plain')).toBe(true) + expect(target.insert).toHaveBeenCalledWith('Plain') + expect(trackEvent).toHaveBeenCalledWith('plain_text_paste_used', { + surface: 'rich_editor', + }) + + unregister() + }) + + it('does not treat non-text inputs as editable paste surfaces', () => { + const input = document.createElement('input') + input.type = 'checkbox' + document.body.appendChild(input) + input.focus() + + expect(insertPlainTextFromClipboardText('Plain')).toBe(false) + expect(trackEvent).not.toHaveBeenCalled() + }) +}) diff --git a/src/utils/plainTextPaste.ts b/src/utils/plainTextPaste.ts new file mode 100644 index 00000000..77fdf724 --- /dev/null +++ b/src/utils/plainTextPaste.ts @@ -0,0 +1,195 @@ +import { invoke } from '@tauri-apps/api/core' +import { trackEvent } from '../lib/telemetry' +import { isTauri, mockInvoke } from '../mock-tauri' + +export type PlainTextPasteSurface = + | 'focused_contenteditable' + | 'focused_input' + | 'raw_editor' + | 'rich_editor' + +export interface PlainTextPasteTarget { + surface: PlainTextPasteSurface + contains: (element: Element | null) => boolean + insert: (text: string) => boolean + isConnected: () => boolean +} + +let activePasteTarget: PlainTextPasteTarget | null = null + +function activeElement(): HTMLElement | null { + const active = document.activeElement + return active instanceof HTMLElement ? active : null +} + +function isCommandPaletteElement(element: Element | null): boolean { + return Boolean(element?.closest('[data-command-palette="true"]')) +} + +function isUsableTarget(target: PlainTextPasteTarget | null): target is PlainTextPasteTarget { + return Boolean(target?.isConnected()) +} + +function recordPlainTextPaste(surface: PlainTextPasteSurface): void { + trackEvent('plain_text_paste_used', { surface }) +} + +function inputEvent(text: string): Event { + if (typeof InputEvent === 'function') { + return new InputEvent('input', { + bubbles: true, + cancelable: false, + data: text, + inputType: 'insertText', + }) + } + + return new Event('input', { bubbles: true }) +} + +function isPlainTextInput(element: HTMLInputElement): boolean { + const plainTextTypes = new Set([ + '', + 'email', + 'password', + 'search', + 'tel', + 'text', + 'url', + ]) + + return plainTextTypes.has(element.type) +} + +function insertIntoTextControl(element: HTMLInputElement | HTMLTextAreaElement, text: string): boolean { + if (element.readOnly || element.disabled) return false + if (element instanceof HTMLInputElement && !isPlainTextInput(element)) return false + + const start = element.selectionStart ?? element.value.length + const end = element.selectionEnd ?? start + element.setRangeText(text, start, end, 'end') + element.dispatchEvent(inputEvent(text)) + return true +} + +function insertIntoContentEditable(element: HTMLElement, text: string): boolean { + if (!element.isContentEditable && !element.closest('[contenteditable="true"]')) return false + + if (document.queryCommandSupported?.('insertText') && document.execCommand('insertText', false, text)) { + return true + } + + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return false + + const range = selection.getRangeAt(0) + range.deleteContents() + range.insertNode(document.createTextNode(text)) + range.collapse(false) + selection.removeAllRanges() + selection.addRange(range) + element.dispatchEvent(inputEvent(text)) + return true +} + +function insertIntoFocusedEditable(text: string, element: HTMLElement | null): PlainTextPasteSurface | null { + if (!element) return null + if (isCommandPaletteElement(element)) return null + + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { + return insertIntoTextControl(element, text) ? 'focused_input' : null + } + + return insertIntoContentEditable(element, text) ? 'focused_contenteditable' : null +} + +function isEditableElementForPlainTextPaste(element: HTMLElement | null): boolean { + if (!element || isCommandPaletteElement(element)) return false + return (element instanceof HTMLInputElement && isPlainTextInput(element)) + || element instanceof HTMLTextAreaElement + || element.isContentEditable + || element.closest('[contenteditable="true"]') !== null +} + +function insertIntoTarget(target: PlainTextPasteTarget, text: string): boolean { + if (!target.insert(text)) return false + recordPlainTextPaste(target.surface) + return true +} + +function currentPasteTarget(): PlainTextPasteTarget | null { + return isUsableTarget(activePasteTarget) ? activePasteTarget : null +} + +function insertIntoContainedTarget( + target: PlainTextPasteTarget | null, + element: HTMLElement | null, + text: string, +): boolean { + return Boolean(target?.contains(element) && insertIntoTarget(target, text)) +} + +function insertIntoFocusedSurface(text: string, element: HTMLElement | null): boolean { + const focusedSurface = insertIntoFocusedEditable(text, element) + if (!focusedSurface) return false + + recordPlainTextPaste(focusedSurface) + return true +} + +function shouldUseLastPasteTarget(element: HTMLElement | null): boolean { + if (!element) return true + if (isCommandPaletteElement(element)) return true + return !isEditableElementForPlainTextPaste(element) +} + +function insertIntoLastTarget( + target: PlainTextPasteTarget | null, + element: HTMLElement | null, + text: string, +): boolean { + if (!target || !shouldUseLastPasteTarget(element)) return false + return insertIntoTarget(target, text) +} + +export function registerPlainTextPasteTarget(target: PlainTextPasteTarget): () => void { + activePasteTarget = target + + return () => { + if (activePasteTarget === target) { + activePasteTarget = null + } + } +} + +export function activatePlainTextPasteTarget(target: PlainTextPasteTarget): void { + activePasteTarget = target +} + +export function insertPlainTextFromClipboardText(text: string): boolean { + if (text.length === 0) return false + + const currentElement = activeElement() + const target = currentPasteTarget() + + return insertIntoContainedTarget(target, currentElement, text) + || insertIntoFocusedSurface(text, currentElement) + || insertIntoLastTarget(target, currentElement, text) +} + +async function readClipboardText(): Promise { + if (isTauri()) { + return invoke('read_text_from_clipboard') + } + + if (navigator.clipboard?.readText) { + return navigator.clipboard.readText() + } + + return mockInvoke('read_text_from_clipboard') +} + +export async function requestPlainTextPaste(): Promise { + const text = await readClipboardText() + return insertPlainTextFromClipboardText(text) +}