feat: add paste without formatting command

This commit is contained in:
lucaronin
2026-05-01 00:31:14 +02:00
parent adcaa8a387
commit ee71a00c6f
46 changed files with 673 additions and 119 deletions

View File

@@ -399,6 +399,7 @@ function OpenCommandPalette({
return (
<div
data-command-palette="true"
className="fixed inset-0 z-[1000] flex justify-center bg-[var(--shadow-dialog)] pt-[15vh]"
onClick={onClose}
>

View File

@@ -32,6 +32,11 @@ describe('LinuxMenuButton', () => {
it('dispatches shared menu commands from the Linux menu', async () => {
render(<LinuxMenuButton />)
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'))

View File

@@ -44,6 +44,7 @@ const MENU_SECTIONS: ReadonlyArray<MenuSection> = [
{ 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 },
],

View File

@@ -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(
<RawEditorView
content="Alpha Beta"
path="/vault/a.md"
entries={[entry('Alpha')]}
onContentChange={vi.fn()}
onSave={vi.fn()}
/>,
)
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()
})
})

View File

@@ -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<HTMLDivElement | null>
setAutocomplete: RawEditorSetAutocomplete
viewRef: React.MutableRefObject<EditorView | null>
}) {
const targetRef = useRef<PlainTextPasteTarget | null>(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<HTMLDivElement>(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 (
<div className="flex flex-1 flex-col min-h-0 relative" style={{ background: 'var(--background)' }} onKeyDown={handleAutocompleteKey} role="presentation">
<div
className="flex flex-1 flex-col min-h-0 relative"
style={{ background: 'var(--background)' }}
onFocusCapture={activatePlainTextPaste}
onKeyDown={handleAutocompleteKey}
onMouseDownCapture={activatePlainTextPaste}
role="presentation"
>
<RawEditorYamlErrorBanner error={pendingChanges.yamlError} />
<RawEditorFindBar
doc={rawDoc}

View File

@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ReactNode } from 'react'
import type { VaultEntry } from '../types'
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
import { insertPlainTextFromClipboardText } from '../utils/plainTextPaste'
const state = vi.hoisted(() => ({
capturedLinkToolbarProps: null as null | Record<string, unknown>,
@@ -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()

View File

@@ -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<typeof useCreateBlockNote>) {
}, [])
}
function useRichEditorPlainTextPasteTarget(options: {
containerRef: React.RefObject<HTMLDivElement | null>
editable: boolean
editor: ReturnType<typeof useCreateBlockNote>
runEditorAction: (action: SuggestionAction) => void
}) {
const { containerRef, editable, editor, runEditorAction } = options
const targetRef = useRef<PlainTextPasteTarget | null>(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<typeof useCreateBlockNote>
@@ -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 (
<div ref={containerRef} className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`} style={cssVars as React.CSSProperties} onClick={handleContainerClick} onCopyCapture={handleCodeBlockCopy}>
<div
ref={containerRef}
className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`}
style={cssVars as React.CSSProperties}
onClick={handleContainerClick}
onCopyCapture={handleCodeBlockCopy}
onFocusCapture={activatePlainTextPaste}
onMouseDownCapture={activatePlainTextPaste}
>
{isDragOver && (
<div className="editor__drop-overlay">
<div className="editor__drop-overlay-label">Drop image here</div>