refac/enh: rich text input

This commit is contained in:
Timothy Jaeryang Baek 2025-07-18 15:58:06 +04:00
parent 032fa52190
commit c1c589d609
4 changed files with 211 additions and 151 deletions

View file

@ -120,6 +120,53 @@
export let image = false; export let image = false;
export let fileHandler = false; export let fileHandler = false;
export let onFileDrop = (currentEditor, files, pos) => {
files.forEach((file) => {
const fileReader = new FileReader();
fileReader.readAsDataURL(file);
fileReader.onload = () => {
currentEditor
.chain()
.insertContentAt(pos, {
type: 'image',
attrs: {
src: fileReader.result
}
})
.focus()
.run();
};
});
};
export let onFilePaste = (currentEditor, files, htmlContent) => {
files.forEach((file) => {
if (htmlContent) {
// if there is htmlContent, stop manual insertion & let other extensions handle insertion via inputRule
// you could extract the pasted file from this url string and upload it to a server for example
console.log(htmlContent); // eslint-disable-line no-console
return false;
}
const fileReader = new FileReader();
fileReader.readAsDataURL(file);
fileReader.onload = () => {
currentEditor
.chain()
.insertContentAt(currentEditor.state.selection.anchor, {
type: 'image',
attrs: {
src: fileReader.result
}
})
.focus()
.run();
};
});
};
export let id = ''; export let id = '';
export let value = ''; export let value = '';
export let html = ''; export let html = '';
@ -847,57 +894,12 @@
} }
}), }),
CharacterCount.configure({}), CharacterCount.configure({}),
...(image ? [Image] : []), ...(image ? [Image] : []),
...(fileHandler ...(fileHandler
? [ ? [
FileHandler.configure({ FileHandler.configure({
allowedMimeTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'], onDrop: onFileDrop,
onDrop: (currentEditor, files, pos) => { onPaste: onFilePaste
files.forEach((file) => {
const fileReader = new FileReader();
fileReader.readAsDataURL(file);
fileReader.onload = () => {
currentEditor
.chain()
.insertContentAt(pos, {
type: 'image',
attrs: {
src: fileReader.result
}
})
.focus()
.run();
};
});
},
onPaste: (currentEditor, files, htmlContent) => {
files.forEach((file) => {
if (htmlContent) {
// if there is htmlContent, stop manual insertion & let other extensions handle insertion via inputRule
// you could extract the pasted file from this url string and upload it to a server for example
console.log(htmlContent); // eslint-disable-line no-console
return false;
}
const fileReader = new FileReader();
fileReader.readAsDataURL(file);
fileReader.onload = () => {
currentEditor
.chain()
.insertContentAt(currentEditor.state.selection.anchor, {
type: 'image',
attrs: {
src: fileReader.result
}
})
.focus()
.run();
};
});
}
}) })
] ]
: []), : []),

View file

@ -139,7 +139,7 @@ export const Image = Node.create<ImageOptions>({
if (file) { if (file) {
img.setAttribute('src', file.url || ''); img.setAttribute('src', file.url || '');
} else { } else {
img.setAttribute('src', node.attrs.src || ''); img.setAttribute('src', '/no-image.png');
} }
} else { } else {
img.setAttribute('src', node.attrs.src || ''); img.setAttribute('src', node.attrs.src || '');
@ -155,7 +155,7 @@ export const Image = Node.create<ImageOptions>({
if (file) { if (file) {
img.setAttribute('src', file.url || ''); img.setAttribute('src', file.url || '');
} else { } else {
img.setAttribute('src', node.attrs.src || ''); img.setAttribute('src', '/no-image.png');
} }
} }
}); });

View file

@ -34,10 +34,10 @@
import { config, models, settings, showSidebar, socket, user, WEBUI_NAME } from '$lib/stores'; import { config, models, settings, showSidebar, socket, user, WEBUI_NAME } from '$lib/stores';
import NotePanel from '$lib/components/notes/NotePanel.svelte'; import NotePanel from '$lib/components/notes/NotePanel.svelte';
import MenuLines from '../icons/MenuLines.svelte';
import ChatBubbleOval from '../icons/ChatBubbleOval.svelte'; import Controls from './NoteEditor/Controls.svelte';
import Settings from './NoteEditor/Settings.svelte';
import Chat from './NoteEditor/Chat.svelte'; import Chat from './NoteEditor/Chat.svelte';
import AccessControlModal from '$lib/components/workspace/common/AccessControlModal.svelte'; import AccessControlModal from '$lib/components/workspace/common/AccessControlModal.svelte';
async function loadLocale(locales) { async function loadLocale(locales) {
@ -61,6 +61,8 @@
import MicSolid from '../icons/MicSolid.svelte'; import MicSolid from '../icons/MicSolid.svelte';
import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte'; import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import MenuLines from '../icons/MenuLines.svelte';
import ChatBubbleOval from '../icons/ChatBubbleOval.svelte';
import Calendar from '../icons/Calendar.svelte'; import Calendar from '../icons/Calendar.svelte';
import Users from '../icons/Users.svelte'; import Users from '../icons/Users.svelte';
@ -81,6 +83,7 @@
import ArrowRight from '../icons/ArrowRight.svelte'; import ArrowRight from '../icons/ArrowRight.svelte';
import Cog6 from '../icons/Cog6.svelte'; import Cog6 from '../icons/Cog6.svelte';
import AiMenu from './AIMenu.svelte'; import AiMenu from './AIMenu.svelte';
import AdjustmentsHorizontalOutline from '../icons/AdjustmentsHorizontalOutline.svelte';
export let id: null | string = null; export let id: null | string = null;
@ -441,113 +444,112 @@ ${content}
} }
changeDebounceHandler(); changeDebounceHandler();
return fileItem;
}; };
const inputFilesHandler = async (inputFiles) => { const compressImageHandler = async (imageUrl, settings = {}, config = {}) => {
console.log('Input files handler called with:', inputFiles); // Quick shortcut so we dont do unnecessary work.
inputFiles.forEach(async (file) => { const settingsCompression = settings?.imageCompression ?? false;
console.log('Processing file:', { const configWidth = config?.file?.image_compression?.width ?? null;
name: file.name, const configHeight = config?.file?.image_compression?.height ?? null;
type: file.type,
size: file.size, // If neither settings nor config wants compression, return original URL.
extension: file.name.split('.').at(-1) if (!settingsCompression && !configWidth && !configHeight) {
return imageUrl;
}
// Default to null (no compression unless set)
let width = null;
let height = null;
// If user/settings want compression, pick their preferred size.
if (settingsCompression) {
width = settings?.imageCompressionSize?.width ?? null;
height = settings?.imageCompressionSize?.height ?? null;
}
// Apply config limits as an upper bound if any
if (configWidth && (width === null || width > configWidth)) {
width = configWidth;
}
if (configHeight && (height === null || height > configHeight)) {
height = configHeight;
}
// Do the compression if required
if (width || height) {
return await compressImage(imageUrl, width, height);
}
return imageUrl;
};
const inputFileHandler = async (file) => {
console.log('Processing file:', {
name: file.name,
type: file.type,
size: file.size,
extension: file.name.split('.').at(-1)
});
if (
($config?.file?.max_size ?? null) !== null &&
file.size > ($config?.file?.max_size ?? 0) * 1024 * 1024
) {
console.log('File exceeds max size limit:', {
fileSize: file.size,
maxSize: ($config?.file?.max_size ?? 0) * 1024 * 1024
}); });
toast.error(
$i18n.t(`File size should not exceed {{maxSize}} MB.`, {
maxSize: $config?.file?.max_size
})
);
return;
}
if ( if (file['type'].startsWith('image/')) {
($config?.file?.max_size ?? null) !== null && const uploadImagePromise = new Promise(async (resolve, reject) => {
file.size > ($config?.file?.max_size ?? 0) * 1024 * 1024
) {
console.log('File exceeds max size limit:', {
fileSize: file.size,
maxSize: ($config?.file?.max_size ?? 0) * 1024 * 1024
});
toast.error(
$i18n.t(`File size should not exceed {{maxSize}} MB.`, {
maxSize: $config?.file?.max_size
})
);
return;
}
if (file['type'].startsWith('image/')) {
const compressImageHandler = async (imageUrl, settings = {}, config = {}) => {
// Quick shortcut so we dont do unnecessary work.
const settingsCompression = settings?.imageCompression ?? false;
const configWidth = config?.file?.image_compression?.width ?? null;
const configHeight = config?.file?.image_compression?.height ?? null;
// If neither settings nor config wants compression, return original URL.
if (!settingsCompression && !configWidth && !configHeight) {
return imageUrl;
}
// Default to null (no compression unless set)
let width = null;
let height = null;
// If user/settings want compression, pick their preferred size.
if (settingsCompression) {
width = settings?.imageCompressionSize?.width ?? null;
height = settings?.imageCompressionSize?.height ?? null;
}
// Apply config limits as an upper bound if any
if (configWidth && (width === null || width > configWidth)) {
width = configWidth;
}
if (configHeight && (height === null || height > configHeight)) {
height = configHeight;
}
// Do the compression if required
if (width || height) {
return await compressImage(imageUrl, width, height);
}
return imageUrl;
};
let reader = new FileReader(); let reader = new FileReader();
reader.onload = async (event) => { reader.onload = async (event) => {
let imageUrl = event.target.result; try {
let imageUrl = event.target.result;
imageUrl = await compressImageHandler(imageUrl, $settings, $config);
imageUrl = await compressImageHandler(imageUrl, $settings, $config); const fileId = uuidv4();
const fileItem = {
const fileId = uuidv4(); id: fileId,
const fileItem = { type: 'image',
id: fileId, url: `${imageUrl}`
type: 'image', };
url: `${imageUrl}` files = [...files, fileItem];
}; note.data.files = files;
files = [...files, fileItem];
note.data.files = files;
if (imageUrl && editor) {
editor.storage.files = files; editor.storage.files = files;
editor
?.chain()
.insertContentAt(editor.state.selection.$anchor.pos, {
type: 'image',
attrs: {
file: fileItem,
src: `data://${fileId}`
// src: imageUrl changeDebounceHandler();
} resolve(fileItem);
}) } catch (err) {
.focus() reject(err);
.run();
} }
}; };
reader.readAsDataURL( reader.readAsDataURL(
file['type'] === 'image/heic' file['type'] === 'image/heic'
? await heic2any({ blob: file, toType: 'image/jpeg' }) ? await heic2any({ blob: file, toType: 'image/jpeg' })
: file : file
); );
});
changeDebounceHandler(); return await uploadImagePromise;
} else { } else {
uploadFileHandler(file); return await uploadFileHandler(file);
} }
};
const inputFilesHandler = async (inputFiles) => {
console.log('Input files handler called with:', inputFiles);
inputFiles.forEach(async (file) => {
await inputFileHandler(file);
}); });
}; };
@ -866,9 +868,9 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
const dropzoneElement = document.getElementById('note-editor'); const dropzoneElement = document.getElementById('note-editor');
dropzoneElement?.addEventListener('dragover', onDragOver); // dropzoneElement?.addEventListener('dragover', onDragOver);
dropzoneElement?.addEventListener('drop', onDrop); // dropzoneElement?.addEventListener('drop', onDrop);
dropzoneElement?.addEventListener('dragleave', onDragLeave); // dropzoneElement?.addEventListener('dragleave', onDragLeave);
}); });
onDestroy(() => { onDestroy(() => {
@ -878,9 +880,9 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
const dropzoneElement = document.getElementById('note-editor'); const dropzoneElement = document.getElementById('note-editor');
if (dropzoneElement) { if (dropzoneElement) {
dropzoneElement?.removeEventListener('dragover', onDragOver); // dropzoneElement?.removeEventListener('dragover', onDragOver);
dropzoneElement?.removeEventListener('drop', onDrop); // dropzoneElement?.removeEventListener('drop', onDrop);
dropzoneElement?.removeEventListener('dragleave', onDragLeave); // dropzoneElement?.removeEventListener('dragleave', onDragLeave);
} }
}); });
</script> </script>
@ -1044,7 +1046,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
</button> </button>
</Tooltip> </Tooltip>
<Tooltip placement="top" content={$i18n.t('Settings')} className="cursor-pointer"> <Tooltip placement="top" content={$i18n.t('Controls')} className="cursor-pointer">
<button <button
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg" class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
on:click={() => { on:click={() => {
@ -1058,7 +1060,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
} }
}} }}
> >
<Cog6 /> <AdjustmentsHorizontalOutline />
</button> </button>
</Tooltip> </Tooltip>
@ -1205,6 +1207,62 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
charCount = editor.storage.characterCount.characters(); charCount = editor.storage.characterCount.characters();
} }
}} }}
fileHandler={true}
onFileDrop={(currentEditor, files, pos) => {
files.forEach(async (file) => {
const fileItem = await inputFileHandler(file).catch((error) => {
return null;
});
if (fileItem.type === 'image') {
// If the file is an image, insert it directly
currentEditor
.chain()
.insertContentAt(pos, {
type: 'image',
attrs: {
src: `data://${fileItem.id}`
}
})
.focus()
.run();
}
});
}}
onFilePaste={() => {}}
on:paste={async (e) => {
e = e.detail.event || e;
const clipboardData = e.clipboardData || window.clipboardData;
console.log('Clipboard data:', clipboardData);
if (clipboardData && clipboardData.items) {
console.log('Clipboard data items:', clipboardData.items);
for (const item of clipboardData.items) {
console.log('Clipboard item:', item);
if (item.type.indexOf('image') !== -1) {
const blob = item.getAsFile();
const fileItem = await inputFileHandler(blob);
if (editor) {
editor
?.chain()
.insertContentAt(editor.state.selection.$anchor.pos, {
type: 'image',
attrs: {
src: `data://${fileItem.id}` // Use data URI for the image
}
})
.focus()
.run();
}
} else if (item?.kind === 'file') {
const file = item.getAsFile();
await inputFileHandler(file);
e.preventDefault();
}
}
}
}}
/> />
</div> </div>
</div> </div>
@ -1349,7 +1407,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
scrollToBottomHandler={scrollToBottom} scrollToBottomHandler={scrollToBottom}
/> />
{:else if selectedPanel === 'settings'} {:else if selectedPanel === 'settings'}
<Settings <Controls
bind:show={showPanel} bind:show={showPanel}
bind:selectedModelId bind:selectedModelId
bind:files bind:files

BIN
static/no-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB