fix: various rich text input issues

#15140
This commit is contained in:
Timothy Jaeryang Baek
2025-07-04 20:26:01 +04:00
parent 9b5da77ffc
commit 2e2a63c201
8 changed files with 555 additions and 304 deletions
@@ -1,9 +1,4 @@
<script>
import { createEventDispatcher, onMount } from 'svelte';
import { toast } from 'svelte-sonner';
const dispatch = createEventDispatcher();
import { knowledge, prompts } from '$lib/stores';
import { removeLastWordFromString } from '$lib/utils';
@@ -15,8 +10,15 @@
import Models from './Commands/Models.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
export let prompt = '';
export let show = false;
export let files = [];
export let command = '';
export let onSelect = (e) => {};
export let onUpload = (e) => {};
export let insertTextHandler = (text) => {};
let loading = false;
let commandElement = null;
@@ -29,12 +31,6 @@
commandElement?.selectDown();
};
let command = '';
$: command = prompt?.split('\n').pop()?.split(' ')?.pop() ?? '';
let show = false;
$: show = ['/', '#', '@'].includes(command?.charAt(0)) || '\\#' === command.slice(0, 2);
$: if (show) {
init();
}
@@ -56,54 +52,63 @@
{#if show}
{#if !loading}
{#if command?.charAt(0) === '/'}
<Prompts bind:this={commandElement} bind:prompt bind:files {command} />
<Prompts
bind:this={commandElement}
{command}
onSelect={(e) => {
const { type, data } = e;
if (type === 'prompt') {
insertTextHandler(data.content);
}
}}
/>
{:else if (command?.charAt(0) === '#' && command.startsWith('#') && !command.includes('# ')) || ('\\#' === command.slice(0, 2) && command.startsWith('#') && !command.includes('# '))}
<Knowledge
bind:this={commandElement}
bind:prompt
command={command.includes('\\#') ? command.slice(2) : command}
on:youtube={(e) => {
console.log(e);
dispatch('upload', {
type: 'youtube',
data: e.detail
});
}}
on:url={(e) => {
console.log(e);
dispatch('upload', {
type: 'web',
data: e.detail
});
}}
on:select={(e) => {
console.log(e);
if (files.find((f) => f.id === e.detail.id)) {
return;
onSelect={(e) => {
const { type, data } = e;
if (type === 'knowledge') {
insertTextHandler('');
onUpload({
type: 'file',
data: data
});
} else if (type === 'youtube') {
insertTextHandler('');
onUpload({
type: 'youtube',
data: data
});
} else if (type === 'web') {
insertTextHandler('');
onUpload({
type: 'web',
data: data
});
}
files = [
...files,
{
...e.detail,
status: 'processed'
}
];
dispatch('select');
}}
/>
{:else if command?.charAt(0) === '@'}
<Models
bind:this={commandElement}
{command}
on:select={(e) => {
prompt = removeLastWordFromString(prompt, command);
onSelect={(e) => {
const { type, data } = e;
dispatch('select', {
type: 'model',
data: e.detail
});
if (type === 'model') {
insertTextHandler('');
onSelect({
type: 'model',
data: data
});
}
}}
/>
{/if}
@@ -6,16 +6,15 @@
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
import { createEventDispatcher, tick, getContext, onMount, onDestroy } from 'svelte';
import { tick, getContext, onMount, onDestroy } from 'svelte';
import { removeLastWordFromString, isValidHttpUrl } from '$lib/utils';
import { knowledge } from '$lib/stores';
const i18n = getContext('i18n');
export let prompt = '';
export let command = '';
export let onSelect = (e) => {};
const dispatch = createEventDispatcher();
let selectedIdx = 0;
let items = [];
@@ -60,37 +59,12 @@
}, 100);
}
};
const confirmSelect = async (item) => {
dispatch('select', item);
prompt = removeLastWordFromString(prompt, command);
const chatInputElement = document.getElementById('chat-input');
await tick();
chatInputElement?.focus();
await tick();
};
const confirmSelectWeb = async (url) => {
dispatch('url', url);
prompt = removeLastWordFromString(prompt, command);
const chatInputElement = document.getElementById('chat-input');
await tick();
chatInputElement?.focus();
await tick();
};
const confirmSelectYoutube = async (url) => {
dispatch('youtube', url);
prompt = removeLastWordFromString(prompt, command);
const chatInputElement = document.getElementById('chat-input');
await tick();
chatInputElement?.focus();
await tick();
const confirmSelect = async (type, data) => {
onSelect({
type: type,
data: data
});
};
const decodeString = (str: string) => {
@@ -189,7 +163,7 @@
});
</script>
{#if filteredItems.length > 0 || prompt.split(' ')?.at(0)?.substring(1).startsWith('http')}
{#if filteredItems.length > 0 || command?.substring(1).startsWith('http')}
<div
id="commands-container"
class="px-2 mb-2 text-left w-full absolute bottom-0 left-0 right-0 z-10"
@@ -210,7 +184,7 @@
type="button"
on:click={() => {
console.log(item);
confirmSelect(item);
confirmSelect('knowledge', item);
}}
on:mousemove={() => {
selectedIdx = idx;
@@ -298,18 +272,15 @@
</div> -->
{/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')}
<button
class="px-3 py-1.5 rounded-xl w-full text-left bg-gray-50 dark:bg-gray-850 dark:text-gray-100 selected-command-option-button"
type="button"
on:click={() => {
const url = prompt.split(' ')?.at(0)?.substring(1);
if (isValidHttpUrl(url)) {
confirmSelectYoutube(url);
if (isValidHttpUrl(command.substring(1))) {
confirmSelect('youtube', command.substring(1));
} else {
toast.error(
$i18n.t(
@@ -320,19 +291,18 @@
}}
>
<div class=" font-medium text-black dark:text-gray-100 line-clamp-1">
{prompt.split(' ')?.at(0)?.substring(1)}
{command.substring(1)}
</div>
<div class=" text-xs text-gray-600 line-clamp-1">{$i18n.t('Youtube')}</div>
</button>
{:else if prompt.split(' ')?.at(0)?.substring(1).startsWith('http')}
{:else if command.substring(1).startsWith('http')}
<button
class="px-3 py-1.5 rounded-xl w-full text-left bg-gray-50 dark:bg-gray-850 dark:text-gray-100 selected-command-option-button"
type="button"
on:click={() => {
const url = prompt.split(' ')?.at(0)?.substring(1);
if (isValidHttpUrl(url)) {
confirmSelectWeb(url);
if (isValidHttpUrl(command.substring(1))) {
confirmSelect('web', command.substring(1));
} else {
toast.error(
$i18n.t(
@@ -343,7 +313,7 @@
}}
>
<div class=" font-medium text-black dark:text-gray-100 line-clamp-1">
{prompt.split(' ')?.at(0)?.substring(1)}
{command}
</div>
<div class=" text-xs text-gray-600 line-clamp-1">{$i18n.t('Web')}</div>
@@ -8,9 +8,8 @@
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let command = '';
export let onSelect = (e) => {};
let selectedIdx = 0;
let filteredItems = [];
@@ -71,8 +70,7 @@
};
const confirmSelect = async (model) => {
command = '';
dispatch('select', model);
onSelect({ type: 'model', data: model });
};
onMount(async () => {
@@ -14,10 +14,8 @@
const i18n = getContext('i18n');
export let files;
export let prompt = '';
export let command = '';
export let onSelect = (e) => {};
let selectedPromptIdx = 0;
let filteredPrompts = [];
@@ -58,137 +56,7 @@
};
const confirmPrompt = async (command) => {
let text = command.content;
if (command.content.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 (command.content.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 (command.content.includes('{{USER_NAME}}')) {
console.log($user);
const name = $user?.name || 'User';
text = text.replaceAll('{{USER_NAME}}', name);
}
if (command.content.includes('{{USER_LANGUAGE}}')) {
const language = localStorage.getItem('locale') || 'en-US';
text = text.replaceAll('{{USER_LANGUAGE}}', language);
}
if (command.content.includes('{{CURRENT_DATE}}')) {
const date = getFormattedDate();
text = text.replaceAll('{{CURRENT_DATE}}', date);
}
if (command.content.includes('{{CURRENT_TIME}}')) {
const time = getFormattedTime();
text = text.replaceAll('{{CURRENT_TIME}}', time);
}
if (command.content.includes('{{CURRENT_DATETIME}}')) {
const dateTime = getCurrentDateTime();
text = text.replaceAll('{{CURRENT_DATETIME}}', dateTime);
}
if (command.content.includes('{{CURRENT_TIMEZONE}}')) {
const timezone = getUserTimezone();
text = text.replaceAll('{{CURRENT_TIMEZONE}}', timezone);
}
if (command.content.includes('{{CURRENT_WEEKDAY}}')) {
const weekday = getWeekday();
text = text.replaceAll('{{CURRENT_WEEKDAY}}', weekday);
}
const lines = prompt.split('\n');
const lastLine = lines.pop();
const lastLineWords = lastLine.split(' ');
const lastWord = lastLineWords.pop();
if ($settings?.richTextInput ?? true) {
lastLineWords.push(
`${text.replace(/</g, '&lt;').replace(/>/g, '&gt;').replaceAll('\n', '<br/>')}`
);
lines.push(lastLineWords.join(' '));
prompt = lines.join('<br/>');
} else {
lastLineWords.push(text);
lines.push(lastLineWords.join(' '));
prompt = lines.join('\n');
}
const chatInputContainerElement = document.getElementById('chat-input-container');
const chatInputElement = document.getElementById('chat-input');
await tick();
if (chatInputContainerElement) {
chatInputContainerElement.scrollTop = chatInputContainerElement.scrollHeight;
}
await tick();
if (chatInputElement) {
chatInputElement.focus();
chatInputElement.dispatchEvent(new Event('input'));
const words = extractCurlyBraceWords(prompt);
if (words.length > 0) {
const word = words.at(0);
await tick();
if (!($settings?.richTextInput ?? true)) {
chatInputElement.setSelectionRange(word.startIndex, word.endIndex + 1);
chatInputElement.focus();
// This is a workaround to ensure the cursor is placed correctly
// after the text is inserted, especially for multiline inputs.
chatInputElement.setSelectionRange(word.startIndex, word.endIndex + 1);
}
} else {
chatInputElement.scrollTop = chatInputElement.scrollHeight;
}
}
onSelect({ type: 'prompt', data: command });
};
onMount(() => {
@@ -213,14 +81,14 @@
id="command-options-container"
bind:this={container}
>
{#each filteredPrompts as prompt, promptIdx}
{#each filteredPrompts as promptItem, promptIdx}
<button
class=" px-3 py-1.5 rounded-xl w-full text-left {promptIdx === selectedPromptIdx
? ' bg-gray-50 dark:bg-gray-850 selected-command-option-button'
: ''}"
type="button"
on:click={() => {
confirmPrompt(prompt);
confirmPrompt(promptItem);
}}
on:mousemove={() => {
selectedPromptIdx = promptIdx;
@@ -228,11 +96,11 @@
on:focus={() => {}}
>
<div class=" font-medium text-black dark:text-gray-100">
{prompt.command}
{promptItem.command}
</div>
<div class=" text-xs text-gray-600 dark:text-gray-100">
{prompt.title}
{promptItem.title}
</div>
</button>
{/each}