mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-11 20:05:19 +00:00
refac/enh: rich text input
This commit is contained in:
parent
032fa52190
commit
c1c589d609
4 changed files with 211 additions and 151 deletions
|
|
@ -120,6 +120,53 @@
|
|||
export let image = 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 value = '';
|
||||
export let html = '';
|
||||
|
|
@ -847,57 +894,12 @@
|
|||
}
|
||||
}),
|
||||
CharacterCount.configure({}),
|
||||
|
||||
...(image ? [Image] : []),
|
||||
...(fileHandler
|
||||
? [
|
||||
FileHandler.configure({
|
||||
allowedMimeTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
onDrop: (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();
|
||||
};
|
||||
});
|
||||
},
|
||||
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();
|
||||
};
|
||||
});
|
||||
}
|
||||
onDrop: onFileDrop,
|
||||
onPaste: onFilePaste
|
||||
})
|
||||
]
|
||||
: []),
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ export const Image = Node.create<ImageOptions>({
|
|||
if (file) {
|
||||
img.setAttribute('src', file.url || '');
|
||||
} else {
|
||||
img.setAttribute('src', node.attrs.src || '');
|
||||
img.setAttribute('src', '/no-image.png');
|
||||
}
|
||||
} else {
|
||||
img.setAttribute('src', node.attrs.src || '');
|
||||
|
|
@ -155,7 +155,7 @@ export const Image = Node.create<ImageOptions>({
|
|||
if (file) {
|
||||
img.setAttribute('src', file.url || '');
|
||||
} else {
|
||||
img.setAttribute('src', node.attrs.src || '');
|
||||
img.setAttribute('src', '/no-image.png');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,10 +34,10 @@
|
|||
import { config, models, settings, showSidebar, socket, user, WEBUI_NAME } from '$lib/stores';
|
||||
|
||||
import NotePanel from '$lib/components/notes/NotePanel.svelte';
|
||||
import MenuLines from '../icons/MenuLines.svelte';
|
||||
import ChatBubbleOval from '../icons/ChatBubbleOval.svelte';
|
||||
import Settings from './NoteEditor/Settings.svelte';
|
||||
|
||||
import Controls from './NoteEditor/Controls.svelte';
|
||||
import Chat from './NoteEditor/Chat.svelte';
|
||||
|
||||
import AccessControlModal from '$lib/components/workspace/common/AccessControlModal.svelte';
|
||||
|
||||
async function loadLocale(locales) {
|
||||
|
|
@ -61,6 +61,8 @@
|
|||
import MicSolid from '../icons/MicSolid.svelte';
|
||||
import VoiceRecording from '../chat/MessageInput/VoiceRecording.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 Users from '../icons/Users.svelte';
|
||||
|
|
@ -81,6 +83,7 @@
|
|||
import ArrowRight from '../icons/ArrowRight.svelte';
|
||||
import Cog6 from '../icons/Cog6.svelte';
|
||||
import AiMenu from './AIMenu.svelte';
|
||||
import AdjustmentsHorizontalOutline from '../icons/AdjustmentsHorizontalOutline.svelte';
|
||||
|
||||
export let id: null | string = null;
|
||||
|
||||
|
|
@ -441,113 +444,112 @@ ${content}
|
|||
}
|
||||
|
||||
changeDebounceHandler();
|
||||
|
||||
return fileItem;
|
||||
};
|
||||
|
||||
const inputFilesHandler = async (inputFiles) => {
|
||||
console.log('Input files handler called with:', inputFiles);
|
||||
inputFiles.forEach(async (file) => {
|
||||
console.log('Processing file:', {
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
extension: file.name.split('.').at(-1)
|
||||
const compressImageHandler = async (imageUrl, settings = {}, config = {}) => {
|
||||
// Quick shortcut so we don’t 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;
|
||||
};
|
||||
|
||||
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 (
|
||||
($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 (file['type'].startsWith('image/')) {
|
||||
const compressImageHandler = async (imageUrl, settings = {}, config = {}) => {
|
||||
// Quick shortcut so we don’t 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;
|
||||
};
|
||||
|
||||
if (file['type'].startsWith('image/')) {
|
||||
const uploadImagePromise = new Promise(async (resolve, reject) => {
|
||||
let reader = new FileReader();
|
||||
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 = {
|
||||
id: fileId,
|
||||
type: 'image',
|
||||
url: `${imageUrl}`
|
||||
};
|
||||
files = [...files, fileItem];
|
||||
note.data.files = files;
|
||||
|
||||
if (imageUrl && editor) {
|
||||
const fileId = uuidv4();
|
||||
const fileItem = {
|
||||
id: fileId,
|
||||
type: 'image',
|
||||
url: `${imageUrl}`
|
||||
};
|
||||
files = [...files, fileItem];
|
||||
note.data.files = files;
|
||||
editor.storage.files = files;
|
||||
editor
|
||||
?.chain()
|
||||
.insertContentAt(editor.state.selection.$anchor.pos, {
|
||||
type: 'image',
|
||||
attrs: {
|
||||
file: fileItem,
|
||||
src: `data://${fileId}`
|
||||
|
||||
// src: imageUrl
|
||||
}
|
||||
})
|
||||
.focus()
|
||||
.run();
|
||||
changeDebounceHandler();
|
||||
resolve(fileItem);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
|
||||
reader.readAsDataURL(
|
||||
file['type'] === 'image/heic'
|
||||
? await heic2any({ blob: file, toType: 'image/jpeg' })
|
||||
: file
|
||||
);
|
||||
});
|
||||
|
||||
changeDebounceHandler();
|
||||
} else {
|
||||
uploadFileHandler(file);
|
||||
}
|
||||
return await uploadImagePromise;
|
||||
} else {
|
||||
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');
|
||||
|
||||
dropzoneElement?.addEventListener('dragover', onDragOver);
|
||||
dropzoneElement?.addEventListener('drop', onDrop);
|
||||
dropzoneElement?.addEventListener('dragleave', onDragLeave);
|
||||
// dropzoneElement?.addEventListener('dragover', onDragOver);
|
||||
// dropzoneElement?.addEventListener('drop', onDrop);
|
||||
// dropzoneElement?.addEventListener('dragleave', onDragLeave);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
|
|
@ -878,9 +880,9 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
|||
const dropzoneElement = document.getElementById('note-editor');
|
||||
|
||||
if (dropzoneElement) {
|
||||
dropzoneElement?.removeEventListener('dragover', onDragOver);
|
||||
dropzoneElement?.removeEventListener('drop', onDrop);
|
||||
dropzoneElement?.removeEventListener('dragleave', onDragLeave);
|
||||
// dropzoneElement?.removeEventListener('dragover', onDragOver);
|
||||
// dropzoneElement?.removeEventListener('drop', onDrop);
|
||||
// dropzoneElement?.removeEventListener('dragleave', onDragLeave);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
@ -1044,7 +1046,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
|||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip placement="top" content={$i18n.t('Settings')} className="cursor-pointer">
|
||||
<Tooltip placement="top" content={$i18n.t('Controls')} className="cursor-pointer">
|
||||
<button
|
||||
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
|
||||
on:click={() => {
|
||||
|
|
@ -1058,7 +1060,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
|||
}
|
||||
}}
|
||||
>
|
||||
<Cog6 />
|
||||
<AdjustmentsHorizontalOutline />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
|
|
@ -1205,6 +1207,62 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
|||
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>
|
||||
|
|
@ -1349,7 +1407,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
|
|||
scrollToBottomHandler={scrollToBottom}
|
||||
/>
|
||||
{:else if selectedPanel === 'settings'}
|
||||
<Settings
|
||||
<Controls
|
||||
bind:show={showPanel}
|
||||
bind:selectedModelId
|
||||
bind:files
|
||||
|
|
|
|||
BIN
static/no-image.png
Normal file
BIN
static/no-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Loading…
Reference in a new issue