Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/renderer/components/MessageItemArtifactBlock.vue
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ import ContextMenuTrigger from './ContextMenuTrigger.vue'
import MessageItemBody from './MessageItemBody.vue'

const content = () => extractCodeBlockContent(props.content)
const { copying, onCopy } = useArtifactCopy(content)
const { copying, onCopy } = useArtifactCopy({
plainText: content,
html: () => window.api.markdown.render(content()),
})

const downloadButton = ref<HTMLElement>(null)
const panelBody = ref<HTMLElement>(null)
Expand Down Expand Up @@ -144,6 +147,8 @@ const onDownloadFormat = async (action: string) => {
})
}

defineExpose({ onCopy })

</script>

<style scoped>
Expand Down
12 changes: 11 additions & 1 deletion src/renderer/components/MessageItemHtmlBlock.vue
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ import MessageItemBody from './MessageItemBody.vue'
const content = () => extractCodeBlockContent(props.content)

const { onDomEvent } = useEventListener()
const { copying, onCopy } = useArtifactCopy(content)
const { copying, onCopy } = useArtifactCopy({
plainText: () => extractPlainText(extractHtmlContent()),
html: () => extractHtmlContent(),
})

const previewHtml = ref(false)
const htmlRenderingDelayPassed = ref(false)
Expand Down Expand Up @@ -87,6 +90,11 @@ const extractBodyContent = (htmlContent: string) => {
return htmlContent.slice(bodyTagEnd + 1, bodyEnd)
}

const extractPlainText = (htmlContent: string) => {
const doc = new DOMParser().parseFromString(htmlContent, 'text/html')
return doc.body?.innerText?.trim() || doc.body?.textContent?.trim() || ''
}

const isDarkMode = () => {
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
Expand Down Expand Up @@ -280,6 +288,8 @@ const toggleHtml = () => {
previewHtml.value = !previewHtml.value
}

defineExpose({ onCopy })

const onDownloadFormat = async (action: string) => {

let filename = props.title
Expand Down
44 changes: 38 additions & 6 deletions src/renderer/composables/artifact_copy.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,46 @@
import { ref } from 'vue'

export function useArtifactCopy(contentGetter: () => string) {
export interface ArtifactCopySource {
plainText: () => string
html?: () => string
}

export function useArtifactCopy(source: ArtifactCopySource | (() => string)) {
const copying = ref(false)

const onCopy = () => {
const resolve = (): ArtifactCopySource => {
return typeof source === 'function' ? { plainText: source } : source
}

const onCopy = async () => {
copying.value = true
navigator.clipboard.writeText(contentGetter())
setTimeout(() => {
copying.value = false
}, 1000)

const { plainText, html } = resolve()
const plainContent = plainText()
const htmlContent = html?.()

try {
if (htmlContent && window.ClipboardItem && navigator.clipboard?.write) {
await navigator.clipboard.write([
new ClipboardItem({
'text/html': new Blob([htmlContent], { type: 'text/html' }),
'text/plain': new Blob([plainContent], { type: 'text/plain' }),
}),
])
} else {
await navigator.clipboard.writeText(plainContent)
}
} catch {
try {
await navigator.clipboard.writeText(plainContent)
} catch {
// copying state is reset in finally
}
} finally {
setTimeout(() => {
copying.value = false
}, 1000)
}
}

return {
Expand Down
105 changes: 105 additions & 0 deletions tests/unit/renderer/components/message_item_artifact_block.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createI18nMock } from '@tests/mocks'
import { useWindowMock } from '@tests/mocks/window'
import { stubTeleport } from '@tests/mocks/stubs'
import MessageItemArtifactBlock from '@components/MessageItemArtifactBlock.vue'
import { store } from '@services/store'

vi.mock('@services/i18n', async () => {
return createI18nMock()
})

class ClipboardItemMock {
items: Record<string, Blob>
constructor(items: Record<string, Blob>) {
this.items = items
}
}

const blobText = (b: Blob & { __text?: string }): string => b.__text ?? ''

const installBlobCapture = () => {
const OriginalBlob = window.Blob
window.Blob = class CapturingBlob extends OriginalBlob {
__text: string
constructor(parts: BlobPart[], options?: BlobPropertyBag) {
super(parts, options)
this.__text = parts.map(p => typeof p === 'string' ? p : '').join('')
}
} as unknown as typeof Blob
return () => { window.Blob = OriginalBlob }
}

describe('MessageItemArtifactBlock copy', () => {

beforeAll(() => {
useWindowMock()
store.loadSettings()
})

beforeEach(() => {
vi.clearAllMocks()
})

afterEach(() => {
delete (window as Window & { ClipboardItem?: typeof ClipboardItem }).ClipboardItem
})

const mountArtifactBlock = (content: string) => {
return mount(MessageItemArtifactBlock, {
...stubTeleport,
props: {
title: 'Markdown Artifact',
content,
transient: false,
},
})
}

test('copies rendered html + raw markdown when ClipboardItem is available', async () => {
const restoreBlob = installBlobCapture()
try {
const write = vi.fn().mockResolvedValue(undefined)
const writeText = vi.fn().mockResolvedValue(undefined)
Object.assign(navigator, { clipboard: { write, writeText } })
window.ClipboardItem = ClipboardItemMock as unknown as typeof ClipboardItem

const wrapper = mountArtifactBlock('```\n# Heading\n\n**bold** text\n```')

await wrapper.vm.onCopy()

expect(write).toHaveBeenCalledTimes(1)
expect(writeText).not.toHaveBeenCalled()

const items = write.mock.calls[0][0]
const htmlBlob = items[0].items['text/html'] as Blob
const plainBlob = items[0].items['text/plain'] as Blob
expect(htmlBlob.type).toBe('text/html')
expect(plainBlob.type).toBe('text/plain')

const htmlText = blobText(htmlBlob)
const plainText = blobText(plainBlob)
expect(htmlText).toContain('<h1>')
expect(htmlText).toContain('<strong>bold</strong>')
expect(plainText).toContain('# Heading')
expect(plainText).toContain('**bold**')
} finally {
restoreBlob()
}
})

test('falls back to raw markdown as plain text when rich clipboard is unavailable', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.assign(navigator, { clipboard: { writeText } })

const wrapper = mountArtifactBlock('```\n# Heading\n\n**bold** text\n```')

await wrapper.vm.onCopy()

expect(writeText).toHaveBeenCalledTimes(1)
const copied = writeText.mock.calls[0][0]
expect(copied).toContain('# Heading')
expect(copied).toContain('**bold**')
})
})
86 changes: 86 additions & 0 deletions tests/unit/renderer/components/message_item_html_block.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createI18nMock } from '@tests/mocks'
import { useWindowMock } from '@tests/mocks/window'
import { stubTeleport } from '@tests/mocks/stubs'
import MessageItemHtmlBlock from '@components/MessageItemHtmlBlock.vue'
import { store } from '@services/store'

vi.mock('@services/i18n', async () => {
return createI18nMock()
})

class ClipboardItemMock {
items: Record<string, Blob>
constructor(items: Record<string, Blob>) {
this.items = items
}
}

describe('MessageItemHtmlBlock copy', () => {

beforeAll(() => {
useWindowMock()
store.loadSettings()
})

beforeEach(() => {
vi.clearAllMocks()
})

afterEach(() => {
delete (window as Window & { ClipboardItem?: typeof ClipboardItem }).ClipboardItem
})

const mountHtmlArtifactBlock = (content: string) => {
return mount(MessageItemHtmlBlock, {
...stubTeleport,
props: {
title: 'HTML Artifact',
content,
transient: false,
},
})
}

test('copies rich html when ClipboardItem support exists', async () => {
const write = vi.fn().mockResolvedValue(undefined)
const writeText = vi.fn().mockResolvedValue(undefined)
Object.assign(navigator, { clipboard: { write, writeText } })
window.ClipboardItem = ClipboardItemMock as unknown as typeof ClipboardItem

const wrapper = mountHtmlArtifactBlock('```html\n<!DOCTYPE html><html><body><h1>Hello</h1><p>World</p></body></html>\n```')

await wrapper.vm.onCopy()

expect(write).toHaveBeenCalledTimes(1)
expect(writeText).not.toHaveBeenCalled()

const clipboardItems = write.mock.calls[0][0]
expect(clipboardItems).toHaveLength(1)
expect(clipboardItems[0]).toBeInstanceOf(ClipboardItemMock)
expect(clipboardItems[0].items['text/html']).toBeInstanceOf(Blob)
expect(clipboardItems[0].items['text/plain']).toBeInstanceOf(Blob)
expect(clipboardItems[0].items['text/html'].type).toBe('text/html')
expect(clipboardItems[0].items['text/plain'].type).toBe('text/plain')
expect(clipboardItems[0].items['text/html'].size).toBeGreaterThan(0)
expect(clipboardItems[0].items['text/plain'].size).toBeGreaterThan(0)
})

test('falls back to plain text without raw html when rich clipboard is unavailable', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.assign(navigator, { clipboard: { writeText } })

const wrapper = mountHtmlArtifactBlock('```html\n<!DOCTYPE html><html><body><h1>Hello</h1><p>World</p></body></html>\n```')

await wrapper.vm.onCopy()

expect(writeText).toHaveBeenCalledTimes(1)

const copiedText = writeText.mock.calls[0][0]
expect(copiedText).toContain('Hello')
expect(copiedText).toContain('World')
expect(copiedText).not.toContain('<html')
expect(copiedText).not.toContain('<body')
})
})
Loading
Loading