From 2e2a63c2018e8b5780b961a4c1b351de1c206209 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Fri, 4 Jul 2025 20:26:01 +0400 Subject: [PATCH] fix: various rich text input issues #15140 --- src/lib/components/chat/Chat.svelte | 102 +++--- src/lib/components/chat/MessageInput.svelte | 290 ++++++++++++++++-- .../chat/MessageInput/Commands.svelte | 101 +++--- .../MessageInput/Commands/Knowledge.svelte | 68 ++-- .../chat/MessageInput/Commands/Models.svelte | 6 +- .../chat/MessageInput/Commands/Prompts.svelte | 144 +-------- src/lib/components/chat/Placeholder.svelte | 2 + .../components/common/RichTextInput.svelte | 146 ++++++++- 8 files changed, 555 insertions(+), 304 deletions(-) diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 0848a19dd5..dd8006b895 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -96,6 +96,8 @@ let controlPane; let controlPaneComponent; + let messageInput; + let autoScroll = true; let processing = ''; let messagesContainerElement: HTMLDivElement; @@ -140,24 +142,39 @@ let params = {}; $: if (chatIdProp) { - (async () => { - loading = true; + navigateHandler(); + } - prompt = ''; - files = []; - selectedToolIds = []; - selectedFilterIds = []; - webSearchEnabled = false; - imageGenerationEnabled = false; + const navigateHandler = async () => { + loading = true; - if (sessionStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)) { + prompt = ''; + messageInput?.setText(''); + + files = []; + selectedToolIds = []; + selectedFilterIds = []; + webSearchEnabled = false; + imageGenerationEnabled = false; + + const storageChatInput = sessionStorage.getItem( + `chat-input${chatIdProp ? `-${chatIdProp}` : ''}` + ); + + if (chatIdProp && (await loadChat())) { + await tick(); + loading = false; + window.setTimeout(() => scrollToBottom(), 0); + + await tick(); + + if (storageChatInput) { try { - const input = JSON.parse( - sessionStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`) - ); + const input = JSON.parse(storageChatInput); + console.log(input); if (!$temporaryChatEnabled) { - prompt = input.prompt; + messageInput?.setText(input.prompt); files = input.files; selectedToolIds = input.selectedToolIds; selectedFilterIds = input.selectedFilterIds; @@ -168,17 +185,12 @@ } catch (e) {} } - if (chatIdProp && (await loadChat())) { - await tick(); - loading = false; - window.setTimeout(() => scrollToBottom(), 0); - const chatInput = document.getElementById('chat-input'); - chatInput?.focus(); - } else { - await goto('/'); - } - })(); - } + const chatInput = document.getElementById('chat-input'); + chatInput?.focus(); + } else { + await goto('/'); + } + }; $: if (selectedModels && chatIdProp !== '') { saveSessionSelectedModels(); @@ -405,7 +417,7 @@ const inputElement = document.getElementById('chat-input'); if (inputElement) { - prompt = event.data.text; + messageInput?.setText(event.data.text); inputElement.focus(); } } @@ -443,8 +455,19 @@ } }); - if (sessionStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`)) { + const storageChatInput = sessionStorage.getItem( + `chat-input${chatIdProp ? `-${chatIdProp}` : ''}` + ); + + if (!chatIdProp) { + loading = false; + await tick(); + } + + if (storageChatInput) { prompt = ''; + messageInput?.setText(''); + files = []; selectedToolIds = []; selectedFilterIds = []; @@ -453,12 +476,11 @@ codeInterpreterEnabled = false; try { - const input = JSON.parse( - sessionStorage.getItem(`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`) - ); + const input = JSON.parse(storageChatInput); + console.log(input); if (!$temporaryChatEnabled) { - prompt = input.prompt; + messageInput?.setText(input.prompt); files = input.files; selectedToolIds = input.selectedToolIds; selectedFilterIds = input.selectedFilterIds; @@ -469,11 +491,6 @@ } catch (e) {} } - if (!chatIdProp) { - loading = false; - await tick(); - } - showControls.subscribe(async (value) => { if (controlPane && !$mobile) { try { @@ -833,12 +850,13 @@ } if ($page.url.searchParams.get('q')) { - prompt = $page.url.searchParams.get('q') ?? ''; + const q = $page.url.searchParams.get('q') ?? ''; + messageInput?.setText(q); - if (prompt) { + if (q) { if (($page.url.searchParams.get('submit') ?? 'true') === 'true') { await tick(); - submitPrompt(prompt); + submitPrompt(q); } } } @@ -1071,7 +1089,7 @@ }; const createMessagePair = async (userPrompt) => { - prompt = ''; + messageInput?.setText(''); if (selectedModels.length === 0) { toast.error($i18n.t('Model not selected')); } else { @@ -1392,7 +1410,7 @@ return; } - prompt = ''; + messageInput?.setText(''); // Reset chat input textarea if (!($settings?.richTextInput ?? true)) { @@ -1413,7 +1431,7 @@ ); files = []; - prompt = ''; + messageInput?.setText(''); // Create user message let userMessageId = uuidv4(); @@ -2104,6 +2122,7 @@
{ + const chatInput = document.getElementById('chat-input'); + + if (chatInput) { + if ($settings?.richTextInput ?? true) { + chatInputElement.setText(text); + } else { + // chatInput.value = text; + prompt = text; + } + } + }; + + function getWordAtCursor(text, cursor) { + if (typeof text !== 'string' || cursor == null) return ''; + const left = text.slice(0, cursor); + const right = text.slice(cursor); + const leftWord = left.match(/(?:^|\s)([^\s]*)$/)?.[1] || ''; + + const rightWord = right.match(/^([^\s]*)/)?.[1] || ''; + return leftWord + rightWord; + } + + const getCommand = () => { + const chatInput = document.getElementById('chat-input'); + let word = ''; + + if (chatInput) { + if ($settings?.richTextInput ?? true) { + word = chatInputElement?.getWordAtDocPos(); + } else { + const cursor = chatInput ? chatInput.selectionStart : prompt.length; + word = getWordAtCursor(prompt, cursor); + } + } + + return word; + }; + + function getWordBoundsAtCursor(text, cursor) { + let start = cursor, + end = cursor; + while (start > 0 && !/\s/.test(text[start - 1])) --start; + while (end < text.length && !/\s/.test(text[end])) ++end; + return { start, end }; + } + + function replaceCommandWithText(text) { + const chatInput = document.getElementById('chat-input'); + if (!chatInput) return; + + if ($settings?.richTextInput ?? true) { + chatInputElement?.replaceCommandWithText(text); + } else { + const cursor = chatInput.selectionStart; + const { start, end } = getWordBoundsAtCursor(prompt, cursor); + prompt = prompt.slice(0, start) + text + prompt.slice(end); + chatInput.focus(); + chatInput.setSelectionRange(start + text.length, start + text.length); + } + } + + const inputVariableHandler = async (text: string) => { + return text; + }; + + const textVariableHandler = async (text: string) => { + if (text.includes('{{CLIPBOARD}}')) { + const clipboardText = await navigator.clipboard.readText().catch((err) => { + toast.error($i18n.t('Failed to read clipboard contents')); + return '{{CLIPBOARD}}'; + }); + + const clipboardItems = await navigator.clipboard.read(); + + let imageUrl = null; + for (const item of clipboardItems) { + // Check for known image types + for (const type of item.types) { + if (type.startsWith('image/')) { + const blob = await item.getType(type); + imageUrl = URL.createObjectURL(blob); + } + } + } + + if (imageUrl) { + files = [ + ...files, + { + type: 'image', + url: imageUrl + } + ]; + } + + text = text.replaceAll('{{CLIPBOARD}}', clipboardText); + } + + if (text.includes('{{USER_LOCATION}}')) { + let location; + try { + location = await getUserPosition(); + } catch (error) { + toast.error($i18n.t('Location access not allowed')); + location = 'LOCATION_UNKNOWN'; + } + text = text.replaceAll('{{USER_LOCATION}}', String(location)); + } + + if (text.includes('{{USER_NAME}}')) { + const name = $_user?.name || 'User'; + text = text.replaceAll('{{USER_NAME}}', name); + } + + if (text.includes('{{USER_LANGUAGE}}')) { + const language = localStorage.getItem('locale') || 'en-US'; + text = text.replaceAll('{{USER_LANGUAGE}}', language); + } + + if (text.includes('{{CURRENT_DATE}}')) { + const date = getFormattedDate(); + text = text.replaceAll('{{CURRENT_DATE}}', date); + } + + if (text.includes('{{CURRENT_TIME}}')) { + const time = getFormattedTime(); + text = text.replaceAll('{{CURRENT_TIME}}', time); + } + + if (text.includes('{{CURRENT_DATETIME}}')) { + const dateTime = getCurrentDateTime(); + text = text.replaceAll('{{CURRENT_DATETIME}}', dateTime); + } + + if (text.includes('{{CURRENT_TIMEZONE}}')) { + const timezone = getUserTimezone(); + text = text.replaceAll('{{CURRENT_TIMEZONE}}', timezone); + } + + if (text.includes('{{CURRENT_WEEKDAY}}')) { + const weekday = getWeekday(); + text = text.replaceAll('{{CURRENT_WEEKDAY}}', weekday); + } + + text = await inputVariableHandler(text); + return text; + }; + + const insertTextAtCursor = async (text: string) => { + const chatInput = document.getElementById('chat-input'); + if (!chatInput) return; + + text = await textVariableHandler(text); + if (command) { + replaceCommandWithText(text); + } else { + if ($settings?.richTextInput ?? true) { + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + range.insertNode(document.createTextNode(text)); + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); + } + } else { + const cursor = chatInput.selectionStart; + prompt = prompt.slice(0, cursor) + text + prompt.slice(cursor); + chatInput.focus(); + chatInput.setSelectionRange(cursor + text.length, cursor + text.length); + } + } + + await tick(); + const chatInputContainer = document.getElementById('chat-input-container'); + if (chatInputContainer) { + chatInputContainer.scrollTop = chatInputContainer.scrollHeight; + } + + await tick(); + if (chatInput) { + chatInput.focus(); + chatInput.dispatchEvent(new Event('input')); + + const words = extractCurlyBraceWords(prompt); + + if (words.length > 0) { + const word = words.at(0); + await tick(); + + if (!($settings?.richTextInput ?? true)) { + // Move scroll to the first word + chatInput.setSelectionRange(word.startIndex, word.endIndex + 1); + chatInput.focus(); + + const selectionRow = + (word?.startIndex - (word?.startIndex % chatInput.cols)) / chatInput.cols; + const lineHeight = chatInput.clientHeight / chatInput.rows; + + chatInput.scrollTop = lineHeight * selectionRow; + } + } else { + chatInput.scrollTop = chatInput.scrollHeight; + } + } + }; + + let command = ''; + + let showCommands = false; + $: showCommands = ['/', '#', '@'].includes(command?.charAt(0)) || '\\#' === command?.slice(0, 2); + let showTools = false; let loaded = false; @@ -583,20 +802,36 @@ { - dispatch('upload', e.detail); - }} - on:select={(e) => { - const data = e.detail; + show={showCommands} + {command} + insertTextHandler={insertTextAtCursor} + onUpload={(e) => { + const { type, data } = e; - if (data?.type === 'model') { - atSelectedModel = data.data; + if (type === 'file') { + if (files.find((f) => f.id === data.id)) { + return; + } + files = [ + ...files, + { + ...data, + status: 'processed' + } + ]; + } else { + dispatch('upload', e); + } + }} + onSelect={(e) => { + const { type, data } = e; + + if (type === 'model') { + atSelectedModel = data; } - const chatInputElement = document.getElementById('chat-input'); - chatInputElement?.focus(); + document.getElementById('chat-input')?.focus(); }} />
@@ -770,8 +1005,12 @@ > { + prompt = e.md; + command = getCommand(); + }} + json={true} messageInput={true} shiftEnter={!($settings?.ctrlEnterToSend ?? false) && (!$mobile || @@ -990,6 +1229,12 @@ class="scrollbar-hidden bg-transparent dark:text-gray-200 outline-hidden w-full pt-3 px-1 resize-none" placeholder={placeholder ? placeholder : $i18n.t('Send a Message')} bind:value={prompt} + on:input={() => { + command = getCommand(); + }} + on:click={() => { + command = getCommand(); + }} on:compositionstart={() => (isComposing = true)} on:compositionend={() => (isComposing = false)} on:keydown={async (e) => { @@ -1137,17 +1382,20 @@ if (words.length > 0) { const word = words.at(0); - const fullPrompt = prompt; - prompt = prompt.substring(0, word?.endIndex + 1); - await tick(); + if (word && e.target instanceof HTMLTextAreaElement) { + // Prevent default tab behavior + e.preventDefault(); + e.target.setSelectionRange(word?.startIndex, word.endIndex + 1); + e.target.focus(); - e.target.scrollTop = e.target.scrollHeight; - prompt = fullPrompt; - await tick(); + const selectionRow = + (word?.startIndex - (word?.startIndex % e.target.cols)) / + e.target.cols; + const lineHeight = e.target.clientHeight / e.target.rows; - e.preventDefault(); - e.target.setSelectionRange(word?.startIndex, word.endIndex + 1); + e.target.scrollTop = lineHeight * selectionRow; + } } e.target.style.height = ''; diff --git a/src/lib/components/chat/MessageInput/Commands.svelte b/src/lib/components/chat/MessageInput/Commands.svelte index bcea28d1e7..af71458522 100644 --- a/src/lib/components/chat/MessageInput/Commands.svelte +++ b/src/lib/components/chat/MessageInput/Commands.svelte @@ -1,9 +1,4 @@ -{#if filteredItems.length > 0 || prompt.split(' ')?.at(0)?.substring(1).startsWith('http')} +{#if filteredItems.length > 0 || command?.substring(1).startsWith('http')}
{ console.log(item); - confirmSelect(item); + confirmSelect('knowledge', item); }} on:mousemove={() => { selectedIdx = idx; @@ -298,18 +272,15 @@
--> {/each} - {#if prompt - .split(' ') - .some((s) => s.substring(1).startsWith('https://www.youtube.com') || s - .substring(1) - .startsWith('https://youtu.be'))} + {#if command.substring(1).startsWith('https://www.youtube.com') || command + .substring(1) + .startsWith('https://youtu.be')} - {:else if prompt.split(' ')?.at(0)?.substring(1).startsWith('http')} + {:else if command.substring(1).startsWith('http')} {/each} diff --git a/src/lib/components/chat/Placeholder.svelte b/src/lib/components/chat/Placeholder.svelte index 70081e1ac2..aa8abbf6c7 100644 --- a/src/lib/components/chat/Placeholder.svelte +++ b/src/lib/components/chat/Placeholder.svelte @@ -32,6 +32,7 @@ export let prompt = ''; export let files = []; + export let messageInput = null; export let selectedToolIds = []; export let selectedFilterIds = []; @@ -207,6 +208,7 @@
{ + if (!editor) return ''; + const { state } = editor.view; + const pos = state.selection.from; + const doc = state.doc; + const resolvedPos = doc.resolve(pos); + const textBlock = resolvedPos.parent; + const paraStart = resolvedPos.start(); + const text = textBlock.textContent; + const offset = resolvedPos.parentOffset; + + let wordStart = offset, + wordEnd = offset; + while (wordStart > 0 && !/\s/.test(text[wordStart - 1])) wordStart--; + while (wordEnd < text.length && !/\s/.test(text[wordEnd])) wordEnd++; + + const word = text.slice(wordStart, wordEnd); + + return word; + }; + + // Returns {start, end} of the word at pos + function getWordBoundsAtPos(doc, pos) { + const resolvedPos = doc.resolve(pos); + const textBlock = resolvedPos.parent; + const paraStart = resolvedPos.start(); + const text = textBlock.textContent; + + const offset = resolvedPos.parentOffset; + let wordStart = offset, + wordEnd = offset; + while (wordStart > 0 && !/\s/.test(text[wordStart - 1])) wordStart--; + while (wordEnd < text.length && !/\s/.test(text[wordEnd])) wordEnd++; + return { + start: paraStart + wordStart, + end: paraStart + wordEnd + }; + } + + export const replaceCommandWithText = async (text) => { + const { state, dispatch } = editor.view; + const { selection } = state; + const pos = selection.from; + + // Get the plain text of this document + // const docText = state.doc.textBetween(0, state.doc.content.size, '\n', '\n'); + + // Find the word boundaries at cursor + const { start, end } = getWordBoundsAtPos(state.doc, pos); + + let tr = state.tr; + + if (text.includes('\n')) { + // Split the text into lines and create a

node for each line + const lines = text.split('\n'); + const nodes = lines.map( + (line, index) => + index === 0 + ? state.schema.text(line) // First line is plain text + : state.schema.nodes.paragraph.create({}, line ? state.schema.text(line) : undefined) // Subsequent lines are paragraphs + ); + + // Build and dispatch the transaction to replace the word at cursor + tr = tr.replaceWith(start, end, nodes); + + let newSelectionPos; + + // +1 because the insert happens at start, so last para starts at (start + sum of all previous nodes' sizes) + let lastPos = start; + for (let i = 0; i < nodes.length; i++) { + lastPos += nodes[i].nodeSize; + } + // Place cursor inside the last paragraph at its end + newSelectionPos = lastPos; + + tr = tr.setSelection(TextSelection.near(tr.doc.resolve(newSelectionPos))); + } else { + tr = tr.replaceWith( + start, + end, // replace this range + text !== '' ? state.schema.text(text) : [] + ); + + tr = tr.setSelection( + state.selection.constructor.near(tr.doc.resolve(start + text.length + 1)) + ); + } + + dispatch(tr); + + await tick(); + // selectNextTemplate(state, dispatch); + }; + + export const setText = (text: string) => { + if (!editor) return; + text = text.replaceAll('\n\n', '\n'); + const { state, view } = editor; + + if (text.includes('\n')) { + // Multiple lines: make paragraphs + const { schema, tr } = state; + const lines = text.split('\n'); + + // Map each line to a paragraph node (empty lines -> empty paragraph) + const nodes = lines.map((line) => + schema.nodes.paragraph.create({}, line ? schema.text(line) : undefined) + ); + + // Create a document fragment containing all parsed paragraphs + const fragment = Fragment.fromArray(nodes); + + // Replace current selection with these paragraphs + tr.replaceSelectionWith(fragment, false /* don't select new */); + + // You probably want to move the cursor after the inserted content + // tr.setSelection(Selection.near(tr.doc.resolve(tr.selection.to))); + + view.dispatch(tr); + } else if (text === '') { + // Empty: delete selection or paragraph + editor.commands.clearContent(); + } else { + editor.commands.setContent(editor.state.schema.text(text)); + } + + selectNextTemplate(editor.view.state, editor.view.dispatch); + }; + // Function to find the next template in the document function findNextTemplate(doc, from = 0) { const patterns = [{ start: '{{', end: '}}' }]; @@ -240,9 +371,18 @@ onChange({ html: editor.getHTML(), json: editor.getJSON(), - md: turndownService.turndown(editor.getHTML()) + md: turndownService + .turndown( + editor + .getHTML() + .replace(/

<\/p>/g, '
') + .replace(/ {2,}/g, (m) => m.replace(/ /g, '\u00a0')) + ) + .replace(/\u00a0/g, ' ') }); + console.log(html); + if (json) { value = editor.getJSON(); } else { @@ -308,7 +448,7 @@ if (event.key === 'Enter') { const isCtrlPressed = event.ctrlKey || event.metaKey; // metaKey is for Cmd key on Mac if (event.shiftKey && !isCtrlPressed) { - editor.commands.setHardBreak(); // Insert a hard break + editor.commands.enter(); // Insert a new line view.dispatch(view.state.tr.scrollIntoView()); // Move viewport to the cursor event.preventDefault(); return true;