refac/fix: rich text input in ff

This commit is contained in:
Timothy Jaeryang Baek 2025-07-06 17:32:03 +04:00
parent be7166d6fc
commit e84c521177
3 changed files with 91 additions and 84 deletions

View file

@ -31,6 +31,7 @@
let content = ''; let content = '';
let files = []; let files = [];
let chatInputElement;
let filesInputElement; let filesInputElement;
let inputFiles; let inputFiles;
@ -287,8 +288,9 @@
await tick(); await tick();
const chatInputElement = document.getElementById(`chat-input-${id}`); if (chatInputElement) {
chatInputElement?.focus(); chatInputElement.focus();
}
}; };
$: if (content) { $: if (content) {
@ -297,9 +299,10 @@
onMount(async () => { onMount(async () => {
window.setTimeout(() => { window.setTimeout(() => {
const chatInput = document.getElementById(`chat-input-${id}`); if (chatInputElement) {
chatInput?.focus(); chatInputElement.focus();
}, 0); }
}, 100);
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
await tick(); await tick();
@ -402,7 +405,10 @@
recording = false; recording = false;
await tick(); await tick();
document.getElementById(`chat-input-${id}`)?.focus();
if (chatInputElement) {
chatInputElement.focus();
}
}} }}
onConfirm={async (data) => { onConfirm={async (data) => {
const { text, filename } = data; const { text, filename } = data;
@ -410,7 +416,10 @@
recording = false; recording = false;
await tick(); await tick();
document.getElementById(`chat-input-${id}`)?.focus();
if (chatInputElement) {
chatInputElement.focus();
}
}} }}
/> />
{:else} {:else}
@ -485,17 +494,21 @@
class="scrollbar-hidden font-primary text-left bg-transparent dark:text-gray-100 outline-hidden w-full pt-3 px-1 rounded-xl resize-none h-fit max-h-80 overflow-auto" class="scrollbar-hidden font-primary text-left bg-transparent dark:text-gray-100 outline-hidden w-full pt-3 px-1 rounded-xl resize-none h-fit max-h-80 overflow-auto"
> >
<RichTextInput <RichTextInput
bind:value={content} bind:this={chatInputElement}
id={`chat-input-${id}`} json={true}
messageInput={true} messageInput={true}
shiftEnter={!$mobile || shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
(!$mobile ||
!( !(
'ontouchstart' in window || 'ontouchstart' in window ||
navigator.maxTouchPoints > 0 || navigator.maxTouchPoints > 0 ||
navigator.msMaxTouchPoints > 0 navigator.msMaxTouchPoints > 0
)} ))}
{placeholder}
largeTextAsFile={$settings?.largeTextAsFile ?? false} largeTextAsFile={$settings?.largeTextAsFile ?? false}
onChange={(e) => {
const { md } = e;
content = md;
}}
on:keydown={async (e) => { on:keydown={async (e) => {
e = e.detail.event; e = e.detail.event;
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac

View file

@ -385,7 +385,6 @@
}; };
onMount(async () => { onMount(async () => {
console.log('codeblock', lang, code);
if (token) { if (token) {
onUpdate(token); onUpdate(token);
} }

View file

@ -176,35 +176,34 @@
if (!editor) return; if (!editor) return;
text = text.replaceAll('\n\n', '\n'); text = text.replaceAll('\n\n', '\n');
const { state, view } = editor; const { state, view } = editor;
const { schema, tr } = state;
if (text.includes('\n')) { if (text.includes('\n')) {
// Multiple lines: make paragraphs // Multiple lines: make paragraphs
const { schema, tr } = state;
const lines = text.split('\n'); const lines = text.split('\n');
// Map each line to a paragraph node (empty lines -> empty paragraph) // Map each line to a paragraph node (empty lines -> empty paragraph)
const nodes = lines.map((line) => const nodes = lines.map((line) =>
schema.nodes.paragraph.create({}, line ? schema.text(line) : undefined) schema.nodes.paragraph.create({}, line ? schema.text(line) : undefined)
); );
// Create a document fragment containing all parsed paragraphs // Create a document fragment containing all parsed paragraphs
const fragment = Fragment.fromArray(nodes); const fragment = Fragment.fromArray(nodes);
// Replace current selection with these paragraphs // Replace current selection with these paragraphs
tr.replaceSelectionWith(fragment, false /* don't select new */); 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); view.dispatch(tr);
} else if (text === '') { } else if (text === '') {
// Empty: delete selection or paragraph // Empty: replace with empty paragraph using tr
editor.commands.clearContent(); const emptyParagraph = schema.nodes.paragraph.create();
tr.replaceSelectionWith(emptyParagraph, false);
view.dispatch(tr);
} else { } else {
editor.commands.setContent(editor.state.schema.text(text)); // Single line: create paragraph with text
const paragraph = schema.nodes.paragraph.create({}, schema.text(text));
tr.replaceSelectionWith(paragraph, false);
view.dispatch(tr);
} }
selectNextTemplate(editor.view.state, editor.view.dispatch); selectNextTemplate(editor.view.state, editor.view.dispatch);
focus();
}; };
export const replaceVariables = (variables) => { export const replaceVariables = (variables) => {
@ -253,7 +252,7 @@
export const focus = () => { export const focus = () => {
if (editor) { if (editor) {
editor.view.focus(); editor.view.focus();
// Scroll to the top of the editor // Scroll to the current selection
editor.view.dispatch(editor.view.state.tr.scrollIntoView()); editor.view.dispatch(editor.view.state.tr.scrollIntoView());
} }
}; };
@ -326,11 +325,7 @@
setTimeout(() => { setTimeout(() => {
const templateFound = selectNextTemplate(editor.view.state, editor.view.dispatch); const templateFound = selectNextTemplate(editor.view.state, editor.view.dispatch);
if (!templateFound) { if (!templateFound) {
// If no template found, set cursor at the end editor.commands.focus('end');
const endPos = editor.view.state.doc.content.size;
editor.view.dispatch(
editor.view.state.tr.setSelection(TextSelection.create(editor.view.state.doc, endPos))
);
} }
}, 0); }, 0);
} }
@ -339,7 +334,11 @@
onMount(async () => { onMount(async () => {
let content = value; let content = value;
if (!json) { if (json) {
if (!content) {
content = html ? html : null;
}
} else {
if (preserveBreaks) { if (preserveBreaks) {
turndownService.addRule('preserveBreaks', { turndownService.addRule('preserveBreaks', {
filter: 'br', // Target <br> elements filter: 'br', // Target <br> elements
@ -370,10 +369,6 @@
// Usage example // Usage example
content = await tryParse(value); content = await tryParse(value);
} }
} else {
if (html && !content) {
content = html;
}
} }
console.log('content', content); console.log('content', content);
@ -417,40 +412,34 @@
// force re-render so `editor.isActive` works as expected // force re-render so `editor.isActive` works as expected
editor = editor; editor = editor;
html = editor.getHTML(); const htmlValue = editor.getHTML();
const jsonValue = editor.getJSON();
onChange({ let mdValue = turndownService
html: editor.getHTML(),
json: editor.getJSON(),
md: turndownService
.turndown( .turndown(
editor htmlValue
.getHTML()
.replace(/<p><\/p>/g, '<br/>')
.replace(/ {2,}/g, (m) => m.replace(/ /g, '\u00a0'))
)
.replace(/\u00a0/g, ' ')
});
if (json) {
value = editor.getJSON();
} else {
if (!raw) {
let newValue = turndownService
.turndown(
editor
.getHTML()
.replace(/<p><\/p>/g, '<br/>') .replace(/<p><\/p>/g, '<br/>')
.replace(/ {2,}/g, (m) => m.replace(/ /g, '\u00a0')) .replace(/ {2,}/g, (m) => m.replace(/ /g, '\u00a0'))
) )
.replace(/\u00a0/g, ' '); .replace(/\u00a0/g, ' ');
onChange({
html: htmlValue,
json: jsonValue,
md: mdValue
});
if (json) {
value = jsonValue;
} else {
if (raw) {
value = htmlValue;
} else {
if (!preserveBreaks) { if (!preserveBreaks) {
newValue = newValue.replace(/<br\/>/g, ''); mdValue = mdValue.replace(/<br\/>/g, '');
} }
if (value !== newValue) { if (value !== mdValue) {
value = newValue; value = mdValue;
// check if the node is paragraph as well // check if the node is paragraph as well
if (editor.isActive('paragraph')) { if (editor.isActive('paragraph')) {
@ -459,8 +448,6 @@
} }
} }
} }
} else {
value = editor.getHTML();
} }
} }
}, },
@ -609,36 +596,44 @@
const onValueChange = () => { const onValueChange = () => {
if (!editor) return; if (!editor) return;
const jsonValue = editor.getJSON();
const htmlValue = editor.getHTML();
let mdValue = turndownService
.turndown(
(preserveBreaks ? htmlValue.replace(/<p><\/p>/g, '<br/>') : htmlValue).replace(
/ {2,}/g,
(m) => m.replace(/ /g, '\u00a0')
)
)
.replace(/\u00a0/g, ' ');
if (value === '') {
editor.commands.clearContent(); // Clear content if value is empty
selectTemplate();
return;
}
if (json) { if (json) {
if (JSON.stringify(value) !== JSON.stringify(editor.getJSON())) { if (JSON.stringify(value) !== JSON.stringify(jsonValue)) {
editor.commands.setContent(value); editor.commands.setContent(value);
selectTemplate(); selectTemplate();
} }
} else { } else {
if (raw) { if (raw) {
if (value !== editor.getHTML()) { if (value !== htmlValue) {
editor.commands.setContent(value); editor.commands.setContent(value);
selectTemplate(); selectTemplate();
} }
} else { } else {
if ( if (value !== mdValue) {
value !== editor.commands.setContent(
turndownService
.turndown(
(preserveBreaks
? editor.getHTML().replace(/<p><\/p>/g, '<br/>')
: editor.getHTML()
).replace(/ {2,}/g, (m) => m.replace(/ /g, '\u00a0'))
)
.replace(/\u00a0/g, ' ')
) {
preserveBreaks preserveBreaks
? editor.commands.setContent(value) ? value
: editor.commands.setContent( : marked.parse(value.replaceAll(`\n<br/>`, `<br/>`), {
marked.parse(value.replaceAll(`\n<br/>`, `<br/>`), {
breaks: false breaks: false
}) })
); // Update editor content );
selectTemplate(); selectTemplate();
} }