From aa8db40376a6afe53b3419de738cd908ff5bd0e1 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Mon, 14 Apr 2025 01:56:15 -0700 Subject: [PATCH] enh: copy formatted option Co-Authored-By: Sebastian Whincop <123417897+macjedi42@users.noreply.github.com> --- .../chat/Messages/ResponseMessage.svelte | 3 +- .../components/chat/Settings/Interface.svelte | 29 ++++ src/lib/utils/index.ts | 150 ++++++++++++++---- 3 files changed, 150 insertions(+), 32 deletions(-) diff --git a/src/lib/components/chat/Messages/ResponseMessage.svelte b/src/lib/components/chat/Messages/ResponseMessage.svelte index c66e8143b..72459cd3e 100644 --- a/src/lib/components/chat/Messages/ResponseMessage.svelte +++ b/src/lib/components/chat/Messages/ResponseMessage.svelte @@ -156,7 +156,8 @@ const copyToClipboard = async (text) => { text = removeAllDetails(text); - const res = await _copyToClipboard(text); + + const res = await _copyToClipboard(text, $settings?.copyFormatted ?? false); if (res) { toast.success($i18n.t('Copying to clipboard was successful!')); } diff --git a/src/lib/components/chat/Settings/Interface.svelte b/src/lib/components/chat/Settings/Interface.svelte index 192b8d39d..c6298173a 100644 --- a/src/lib/components/chat/Settings/Interface.svelte +++ b/src/lib/components/chat/Settings/Interface.svelte @@ -43,6 +43,7 @@ let chatBubble = true; let chatDirection: 'LTR' | 'RTL' | 'auto' = 'auto'; let ctrlEnterToSend = false; + let copyFormatted = false; let collapseCodeBlocks = false; let expandDetails = false; @@ -220,6 +221,11 @@ } }; + const toggleCopyFormatted = async () => { + copyFormatted = !copyFormatted; + saveSettings({ copyFormatted }); + }; + const toggleChangeChatDirection = async () => { if (chatDirection === 'auto') { chatDirection = 'LTR'; @@ -275,6 +281,7 @@ richTextInput = $settings.richTextInput ?? true; promptAutocomplete = $settings.promptAutocomplete ?? false; largeTextAsFile = $settings.largeTextAsFile ?? false; + copyFormatted = $settings.copyFormatted ?? false; collapseCodeBlocks = $settings.collapseCodeBlocks ?? false; expandDetails = $settings.expandDetails ?? false; @@ -670,6 +677,28 @@ +
+
+
+ {$i18n.t('Copy Formatted Text')} +
+ + +
+
+
{$i18n.t('Always Collapse Code Blocks')}
diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index ffcd6e27a..022a901c1 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -15,6 +15,11 @@ dayjs.extend(localizedFormat); import { WEBUI_BASE_URL } from '$lib/constants'; import { TTS_RESPONSE_SPLIT } from '$lib/types'; +import { marked } from 'marked'; +import markedExtension from '$lib/utils/marked/extension'; +import markedKatexExtension from '$lib/utils/marked/katex-extension'; +import hljs from 'highlight.js'; + ////////////////////////// // Helper functions ////////////////////////// @@ -309,46 +314,129 @@ export const formatDate = (inputDate) => { } }; -export const copyToClipboard = async (text) => { - let result = false; - if (!navigator.clipboard) { - const textArea = document.createElement('textarea'); - textArea.value = text; +export const copyToClipboard = async (text, formatted = false) => { + if (formatted) { + const options = { + throwOnError: false, + highlight: function (code, lang) { + const language = hljs.getLanguage(lang) ? lang : 'plaintext'; + return hljs.highlight(code, { language }).value; + } + }; + marked.use(markedKatexExtension(options)); + marked.use(markedExtension(options)); - // Avoid scrolling to bottom - textArea.style.top = '0'; - textArea.style.left = '0'; - textArea.style.position = 'fixed'; + const htmlContent = marked.parse(text); - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); + // Add basic styling to make the content look better when pasted + const styledHtml = ` +
+ + ${htmlContent} +
+ `; + + // Create a blob with HTML content + const blob = new Blob([styledHtml], { type: 'text/html' }); try { - const successful = document.execCommand('copy'); - const msg = successful ? 'successful' : 'unsuccessful'; - console.log('Fallback: Copying text command was ' + msg); - result = true; + // Create a ClipboardItem with HTML content + const data = new ClipboardItem({ + 'text/html': blob, + 'text/plain': new Blob([text], { type: 'text/plain' }) + }); + + // Write to clipboard + await navigator.clipboard.write([data]); + return true; } catch (err) { - console.error('Fallback: Oops, unable to copy', err); + console.error('Error copying formatted content:', err); + // Fallback to plain text + return await copyToClipboard(text); + } + } else { + let result = false; + if (!navigator.clipboard) { + const textArea = document.createElement('textarea'); + textArea.value = text; + + // Avoid scrolling to bottom + textArea.style.top = '0'; + textArea.style.left = '0'; + textArea.style.position = 'fixed'; + + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + try { + const successful = document.execCommand('copy'); + const msg = successful ? 'successful' : 'unsuccessful'; + console.log('Fallback: Copying text command was ' + msg); + result = true; + } catch (err) { + console.error('Fallback: Oops, unable to copy', err); + } + + document.body.removeChild(textArea); + return result; } - document.body.removeChild(textArea); + result = await navigator.clipboard + .writeText(text) + .then(() => { + console.log('Async: Copying to clipboard was successful!'); + return true; + }) + .catch((error) => { + console.error('Async: Could not copy text: ', error); + return false; + }); + return result; } - - result = await navigator.clipboard - .writeText(text) - .then(() => { - console.log('Async: Copying to clipboard was successful!'); - return true; - }) - .catch((error) => { - console.error('Async: Could not copy text: ', error); - return false; - }); - - return result; }; export const compareVersion = (latest, current) => {