feat: add paste without formatting command
This commit is contained in:
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user