feat: add paste without formatting command
This commit is contained in:
95
src/utils/plainTextPaste.test.ts
Normal file
95
src/utils/plainTextPaste.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
195
src/utils/plainTextPaste.ts
Normal file
195
src/utils/plainTextPaste.ts
Normal file
@@ -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<string> {
|
||||
if (isTauri()) {
|
||||
return invoke<string>('read_text_from_clipboard')
|
||||
}
|
||||
|
||||
if (navigator.clipboard?.readText) {
|
||||
return navigator.clipboard.readText()
|
||||
}
|
||||
|
||||
return mockInvoke<string>('read_text_from_clipboard')
|
||||
}
|
||||
|
||||
export async function requestPlainTextPaste(): Promise<boolean> {
|
||||
const text = await readClipboardText()
|
||||
return insertPlainTextFromClipboardText(text)
|
||||
}
|
||||
Reference in New Issue
Block a user