fix(chat): preserve native copying of selected text (#540)

- Allow native text selection in AI chat messages.\n- Leave active DOM text ranges to the platform clipboard.\n- Preserve canvas clipboard routing when no document text is selected.
This commit is contained in:
Danila Poyarkov 2026-08-17 11:54:24 +03:00 committed by GitHub
parent 54c1373d3e
commit d112bc9044
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 51 additions and 3 deletions

View file

@ -55,6 +55,7 @@
### Fixed
- Let desktop users select and copy AI chat text without replacing it with the selected canvas layers. (#538)
- Restore visible above, below, and child drop feedback while dragging layers in the Layers panel.
- Place editor-created instances beside nested source components in world space, including transformed source and destination parents.
- Harden collaboration node synchronization against malformed remote source metadata and geometry while excluding derived text-renderer caches.

View file

@ -7,7 +7,7 @@ import {
copySelectionToTauriClipboard,
pasteFromTauriClipboard
} from '@/app/editor/clipboard/system'
import { isEditing } from '@/app/shell/keyboard/focus'
import { hasDocumentTextSelection, isEditing } from '@/app/shell/keyboard/focus'
import { isTauri } from '@/app/tauri/env'
function cursorPosition(store: EditorStore) {
@ -17,7 +17,7 @@ function cursorPosition(store: EditorStore) {
export function bindEditorClipboard(store: EditorStore) {
useEventListener(window, 'copy', (e: ClipboardEvent) => {
if (isEditing(e)) return
if (isEditing(e) || hasDocumentTextSelection()) return
e.preventDefault()
if (isTauri()) {
void copySelectionToTauriClipboard(store)

View file

@ -10,6 +10,11 @@ export function isEditing(event: Event) {
return event.composedPath().some(isEditableTarget)
}
export function hasDocumentTextSelection(): boolean {
const selection = window.getSelection()
return selection !== null && !selection.isCollapsed && selection.toString().length > 0
}
export function isInputElement(element: EventTarget | null | undefined): boolean {
return (
element instanceof HTMLInputElement ||

View file

@ -52,7 +52,10 @@ function partKey(part: UIMessagePart<UIDataTypes, UITools>, index: number): stri
v-test-id="`chat-message-${message.role}`"
:class="message.role === 'user' ? 'flex justify-end' : ''"
>
<div class="min-w-0 space-y-2" :class="message.role === 'user' ? 'max-w-[85%]' : ''">
<div
class="min-w-0 space-y-2 select-text"
:class="message.role === 'user' ? 'max-w-[85%]' : ''"
>
<template v-if="message.role === 'assistant'">
<template v-for="(part, i) in message.parts" :key="partKey(part, i)">
<!-- Tool call -->

View file

@ -36,6 +36,18 @@ function selectedCount(page: Page): Promise<number> {
})
}
async function selectProbeText(page: Page, selector: string) {
await page.evaluate((probeSelector) => {
const probe = document.querySelector(probeSelector)
const selection = window.getSelection()
if (!probe || !selection) throw new Error(`Text selection probe not found: ${probeSelector}`)
const range = document.createRange()
range.selectNodeContents(probe)
selection.removeAllRanges()
selection.addRange(range)
}, selector)
}
async function dispatchClipboardEvent(
page: Page,
type: 'copy' | 'cut' | 'paste',
@ -147,6 +159,33 @@ test('Tauri clipboard events from editable fields keep native text behavior', as
expect(await pageChildren(page)).toHaveLength(1)
})
test('Tauri copy preserves selected document text instead of copying canvas layers', async ({
page
}) => {
const { canvas, clipboard } = await createTauriEditorPage(page)
await canvas.drawRect(160, 160, 96, 72)
const before = clipboard.snapshot()
await page.evaluate(() => {
const probe = document.createElement('div')
probe.className = 'select-text'
probe.dataset.clipboardProbe = 'document-text'
probe.textContent = 'Assistant response'
document.body.append(probe)
})
await selectProbeText(page, '[data-clipboard-probe="document-text"]')
expect(
await page.evaluate(() => ({
collapsed: window.getSelection()?.isCollapsed,
text: window.getSelection()?.toString()
}))
).toEqual({ collapsed: false, text: 'Assistant response' })
await dispatchClipboardEvent(page, 'copy', '[data-clipboard-probe="document-text"]')
expect(clipboard.snapshot()).toEqual(before)
expect(await selectedCount(page)).toBe(1)
})
test('Tauri context-menu copy uses plugin clipboard fallback instead of blocked browser command', async ({
page
}) => {