Kaynağa Gözat

修复浏览器复制功能兼容性

LSamien 2 ay önce
ebeveyn
işleme
77854c4878

+ 13 - 4
src/client/components/messages/MarkdownContent.vue

@@ -4,6 +4,7 @@ import hljs from 'highlight.js'
 import MarkdownIt from 'markdown-it'
 import { computed, onMounted, onUpdated, ref } from 'vue'
 import AppIcon from '@/components/common/AppIcon.vue'
+import { copyTextToClipboard } from '@/utils/clipboard'
 import { normalizeAssistantMediaMarkdown } from '@/utils/mediaMarkdown'
 import { repairMarkdownForRender } from '@/utils/markdownRepair'
 
@@ -79,7 +80,9 @@ const rendered = computed(() => {
   const html = md.render(markdownSource.value)
   return DOMPurify.sanitize(highlightMentions(html), {
     USE_PROFILES: { html: true },
-    FORBID_TAGS: ['style', 'svg', 'math', 'form', 'input', 'button', 'iframe', 'object', 'embed'],
+    // MarkdownIt generates the inert code-copy button above. User-provided HTML
+    // remains disabled, and DOMPurify still strips event-handler attributes.
+    FORBID_TAGS: ['style', 'svg', 'math', 'form', 'input', 'iframe', 'object', 'embed'],
     FORBID_ATTR: ['style', 'onerror', 'onclick', 'onload'],
     ALLOW_UNKNOWN_PROTOCOLS: false,
     ADD_ATTR: ['target'],
@@ -93,11 +96,17 @@ function decorateCopyButtons() {
     button.dataset.bound = 'true'
     button.addEventListener('click', async () => {
       const code = button.closest('pre')?.querySelector('code')?.textContent ?? ''
-      await navigator.clipboard.writeText(code)
       const key = `${index}:${code.length}`
       copied.value = key
-      button.textContent = '已复制'
-      window.setTimeout(() => { if (copied.value === key) { copied.value = ''; button.textContent = '复制' } }, 1400)
+      const copiedSuccessfully = await copyTextToClipboard(code)
+      button.textContent = copiedSuccessfully ? '已复制' : '复制失败'
+      button.setAttribute('aria-label', copiedSuccessfully ? '代码已复制' : '复制代码失败')
+      window.setTimeout(() => {
+        if (copied.value !== key || !button.isConnected) return
+        copied.value = ''
+        button.textContent = '复制'
+        button.setAttribute('aria-label', '复制代码')
+      }, 1400)
     })
   })
 }

+ 18 - 25
src/client/components/messages/MessageTimeline.vue

@@ -9,6 +9,7 @@ import ToolTrace from './ToolTrace.vue'
 import TurnTrace from './TurnTrace.vue'
 import type { UiInteraction, UiLocalFileLink, UiMessage } from './types'
 import { buildMessageTimelineRows } from '@/utils/turnTrace'
+import { copyTextToClipboard } from '@/utils/clipboard'
 import { displayContentForMessage } from '@/utils/messageDisplay'
 import { formatMessageTime } from '@/utils/messageTime'
 
@@ -67,6 +68,7 @@ const scroller = ref<HTMLElement | null>(null)
 const pinnedToBottom = ref(true)
 const showJump = ref(false)
 const copiedMessageId = ref('')
+const copyFailedMessageId = ref('')
 const thinkingElapsedMs = ref(0)
 const timelineRows = computed(() => buildMessageTimelineRows(props.messages))
 const showThinkingIndicator = computed(() => {
@@ -151,28 +153,13 @@ function scrollToAnchor(messageId: string, anchorId: string): boolean {
 
 async function copyMessage(message: UiMessage) {
   const text = displayContentForMessage(message.role, message.content)
-  let copied = false
-  try {
-    if (navigator.clipboard?.writeText && window.isSecureContext) {
-      await navigator.clipboard.writeText(text)
-      copied = true
-    }
-  } catch { /* Fall through to the HTTP-compatible browser fallback. */ }
-  if (!copied) {
-    const textarea = document.createElement('textarea')
-    textarea.value = text
-    textarea.setAttribute('readonly', '')
-    textarea.style.cssText = 'position:fixed;left:-9999px;top:0;opacity:0'
-    document.body.appendChild(textarea)
-    textarea.select()
-    copied = document.execCommand('copy')
-    textarea.remove()
-  }
-  if (!copied) throw new Error('浏览器拒绝访问剪贴板')
-  copiedMessageId.value = message.id
+  const copied = await copyTextToClipboard(text)
+  copiedMessageId.value = copied ? message.id : ''
+  copyFailedMessageId.value = copied ? '' : message.id
   if (copyResetTimer !== undefined) window.clearTimeout(copyResetTimer)
   copyResetTimer = window.setTimeout(() => {
     copiedMessageId.value = ''
+    copyFailedMessageId.value = ''
     copyResetTimer = undefined
   }, 1400)
 }
@@ -221,7 +208,10 @@ watch(showThinkingIndicator, visible => {
 }, { immediate: true })
 
 onMounted(() => nextTick(() => scrollToBottom('auto')))
-onBeforeUnmount(() => { if (thinkingTimer !== undefined) window.clearInterval(thinkingTimer) })
+onBeforeUnmount(() => {
+  if (copyResetTimer !== undefined) window.clearTimeout(copyResetTimer)
+  if (thinkingTimer !== undefined) window.clearInterval(thinkingTimer)
+})
 
 defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom })
 </script>
@@ -358,11 +348,14 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom })
               <time v-if="message.role === 'assistant' && !showAssistantIdentity && message.createdAt" class="message__action-time">{{ formatTime(message.createdAt) }}</time>
               <button
                 type="button"
-                :class="{ 'message-action--copied': copiedMessageId === message.id }"
-                :title="copiedMessageId === message.id ? '已复制' : '复制'"
-                :aria-label="copiedMessageId === message.id ? '已复制' : '复制消息'"
+                :class="{
+                  'message-action--copied': copiedMessageId === message.id,
+                  'message-action--copy-failed': copyFailedMessageId === message.id,
+                }"
+                :title="copiedMessageId === message.id ? '已复制' : copyFailedMessageId === message.id ? '复制失败' : '复制'"
+                :aria-label="copiedMessageId === message.id ? '已复制' : copyFailedMessageId === message.id ? '复制失败' : '复制消息'"
                 @click="copyMessage(message)"
-              ><AppIcon :name="copiedMessageId === message.id ? 'check' : 'copy'" :size="13" /></button>
+              ><AppIcon :name="copiedMessageId === message.id ? 'check' : copyFailedMessageId === message.id ? 'alert' : 'copy'" :size="13" /></button>
               <button type="button" title="引用" aria-label="引用消息" @click="emit('quote', message)"><AppIcon name="quote" :size="13" /></button>
               <button v-if="message.role === 'assistant'" type="button" title="从这里分支" aria-label="从这里分支" @click="emit('branch', message)"><AppIcon name="branch" :size="13" /></button>
             </div>
@@ -427,7 +420,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom })
 .message__attachments { display: flex; margin-top: 8px; flex-wrap: wrap; gap: 7px; }.message__attachments button { display: flex; min-width: 90px; max-width: 210px; min-height: 42px; align-items: center; gap: 7px; padding: 5px 8px; overflow: hidden; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); color: var(--text-secondary); cursor: pointer; }.message__attachments button:hover { border-color: var(--line-strong); }.message__attachments img { width: 48px; height: 48px; margin: -5px 0 -5px -8px; object-fit: cover; }.message__attachments button > span { display: grid; place-items: center; width: 25px; height: 25px; border-radius: 7px; background: var(--surface-soft); }.message__attachments strong { min-width: 0; overflow: hidden; font-size: 10px; font-weight: 520; text-overflow: ellipsis; white-space: nowrap; }
 .message--user .message__attachments { margin: 0 0 9px; }.message--user .message__attachments button { width: 100%; max-width: none; min-height: 50px; padding: 7px 9px; border: 0; border-radius: 11px; background: rgba(255,255,255,.48); color: var(--text-primary); }.message--user .message__attachments button > span { width: 28px; height: 28px; background: rgba(255,255,255,.65); }.message--user .message__attachments strong { font-size: 11px; font-weight: 560; }.dark .message--user .message__attachments button { background: rgba(255,255,255,.08); }.message--user .message__attachments button.message__attachment--image { display: block; width: auto; min-width: 0; max-width: min(360px, 100%); min-height: 0; padding: 0; border: 0; border-radius: 8px; background: transparent; }.message--user .message__attachments button.message__attachment--image img { display: block; width: auto; height: auto; max-width: min(360px, 100%); max-height: 420px; margin: 0; border-radius: inherit; object-fit: contain; }
 .message__tools { margin-top: 9px; }.message__error { display: flex; align-items: center; gap: 5px; margin: 7px 0 0; color: var(--danger); font-size: 9px; }
-.message__actions { display: flex; position: absolute; left: -3px; top: 100%; gap: 2px; padding-top: 2px; opacity: 0; transition: opacity 120ms ease; }.message:hover .message__actions, .message:focus-within .message__actions { opacity: 1; }.message--user .message__actions { right: 0; left: auto; }.message__actions button { display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted); cursor: pointer; }.message__actions button:hover { background: var(--surface-soft); color: var(--text-primary); }.message__actions .message-action--copied { color: var(--success); }
+.message__actions { display: flex; position: absolute; left: -3px; top: 100%; gap: 2px; padding-top: 2px; opacity: 0; transition: opacity 120ms ease; }.message:hover .message__actions, .message:focus-within .message__actions { opacity: 1; }.message--user .message__actions { right: 0; left: auto; }.message__actions button { display: grid; place-items: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted); cursor: pointer; }.message__actions button:hover { background: var(--surface-soft); color: var(--text-primary); }.message__actions .message-action--copied { color: var(--success); }.message__actions .message-action--copy-failed { color: var(--danger); }
 .message--assistant-anonymous .message__actions { align-items: center; }.message__action-time { min-width: 36px; padding: 0 4px 0 3px; color: var(--text-muted); font-size: 9px; font-variant-numeric: tabular-nums; }
 .message--failed .message__body { border-color: color-mix(in srgb, var(--danger) 35%, var(--line)); }
 .message--revealed .message__body { animation: reveal-message 1.8s ease; }

+ 36 - 0
src/client/utils/clipboard.ts

@@ -0,0 +1,36 @@
+function legacyCopyText(text: string): boolean {
+  const textarea = document.createElement('textarea')
+  const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : null
+  textarea.value = text
+  textarea.setAttribute('readonly', '')
+  textarea.setAttribute('aria-hidden', 'true')
+  textarea.style.cssText = 'position:fixed;left:-9999px;top:0;width:1px;height:1px;opacity:0'
+  document.body.appendChild(textarea)
+  textarea.focus({ preventScroll: true })
+  textarea.select()
+  textarea.setSelectionRange(0, text.length)
+
+  let copied = false
+  try {
+    copied = document.execCommand('copy')
+  } catch {
+    copied = false
+  } finally {
+    textarea.remove()
+    activeElement?.focus({ preventScroll: true })
+  }
+  return copied
+}
+
+/** Copy text in secure browsers and in embedded/HTTP browsers with a legacy fallback. */
+export async function copyTextToClipboard(text: string): Promise<boolean> {
+  try {
+    if (window.isSecureContext && navigator.clipboard?.writeText) {
+      await navigator.clipboard.writeText(text)
+      return true
+    }
+  } catch {
+    // Permission-restricted embedded browsers can expose the API but reject it.
+  }
+  return legacyCopyText(text)
+}

+ 54 - 0
tests/client/clipboard.test.ts

@@ -0,0 +1,54 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { copyTextToClipboard } from '@/utils/clipboard'
+
+function secureContext(value: boolean) {
+  Object.defineProperty(window, 'isSecureContext', { configurable: true, value })
+}
+
+function clipboard(writeText?: (text: string) => Promise<void>) {
+  Object.defineProperty(navigator, 'clipboard', {
+    configurable: true,
+    value: writeText ? { writeText } : undefined,
+  })
+}
+
+afterEach(() => {
+  vi.restoreAllMocks()
+  Reflect.deleteProperty(navigator, 'clipboard')
+  Object.defineProperty(window, 'isSecureContext', { configurable: true, value: false })
+  Object.defineProperty(document, 'execCommand', { configurable: true, value: undefined })
+})
+
+describe('copyTextToClipboard', () => {
+  it('uses the asynchronous clipboard API in a secure context', async () => {
+    secureContext(true)
+    const writeText = vi.fn(async () => undefined)
+    clipboard(writeText)
+
+    await expect(copyTextToClipboard('安全复制')).resolves.toBe(true)
+    expect(writeText).toHaveBeenCalledWith('安全复制')
+  })
+
+  it('falls back when an embedded browser rejects clipboard permission', async () => {
+    secureContext(true)
+    clipboard(vi.fn(async () => { throw new Error('NotAllowedError') }))
+    const execCommand = vi.fn(() => true)
+    Object.defineProperty(document, 'execCommand', { configurable: true, value: execCommand })
+
+    await expect(copyTextToClipboard('兼容复制')).resolves.toBe(true)
+    expect(execCommand).toHaveBeenCalledWith('copy')
+    expect(document.querySelector('textarea')).toBeNull()
+  })
+
+  it('reports failure and cleans up when both clipboard paths are unavailable', async () => {
+    secureContext(false)
+    clipboard()
+    Object.defineProperty(document, 'execCommand', {
+      configurable: true,
+      value: vi.fn(() => { throw new Error('copy denied') }),
+    })
+
+    await expect(copyTextToClipboard('无法复制')).resolves.toBe(false)
+    expect(document.querySelector('textarea')).toBeNull()
+  })
+})

+ 43 - 0
tests/client/markdownContent.test.ts

@@ -0,0 +1,43 @@
+import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import MarkdownContent from '@/components/messages/MarkdownContent.vue'
+
+afterEach(() => {
+  vi.restoreAllMocks()
+  Reflect.deleteProperty(navigator, 'clipboard')
+  Object.defineProperty(window, 'isSecureContext', { configurable: true, value: false })
+  Object.defineProperty(document, 'execCommand', { configurable: true, value: undefined })
+})
+
+describe('MarkdownContent code copy', () => {
+  it('copies code through the compatibility fallback and confirms success', async () => {
+    Object.defineProperty(window, 'isSecureContext', { configurable: true, value: true })
+    Object.defineProperty(navigator, 'clipboard', {
+      configurable: true,
+      value: { writeText: vi.fn(async () => { throw new Error('NotAllowedError') }) },
+    })
+    let selectedText = ''
+    Object.defineProperty(document, 'execCommand', {
+      configurable: true,
+      value: vi.fn(() => {
+        selectedText = (document.activeElement as HTMLTextAreaElement | null)?.value ?? ''
+        return true
+      }),
+    })
+    const wrapper = mount(MarkdownContent, {
+      attachTo: document.body,
+      props: { content: '```bash\necho "复制成功"\n```' },
+    })
+    await nextTick()
+
+    const button = wrapper.find<HTMLButtonElement>('.code-copy')
+    expect(button.exists()).toBe(true)
+    await button.trigger('click')
+    await nextTick()
+
+    expect(selectedText).toBe('echo "复制成功"\n')
+    expect(button.text()).toBe('已复制')
+    wrapper.unmount()
+  })
+})