Browse Source

refac/enh: rich text input

Timothy Jaeryang Baek 2 months ago
parent
commit
c1c589d609

+ 49 - 47
src/lib/components/common/RichTextInput.svelte

@@ -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
 							})
 						]
 					: []),

+ 2 - 2
src/lib/components/common/RichTextInput/Image/image.ts

@@ -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');
 					}
 				}
 			});

+ 156 - 98
src/lib/components/notes/NoteEditor.svelte

@@ -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 (
-				($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 neither settings nor config wants compression, return original URL.
+		if (!settingsCompression && !configWidth && !configHeight) {
+			return imageUrl;
+		}
 
-			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;
+		// Default to null (no compression unless set)
+		let width = null;
+		let height = null;
 
-					// If neither settings nor config wants compression, return original URL.
-					if (!settingsCompression && !configWidth && !configHeight) {
-						return imageUrl;
-					}
+		// If user/settings want compression, pick their preferred size.
+		if (settingsCompression) {
+			width = settings?.imageCompressionSize?.width ?? null;
+			height = settings?.imageCompressionSize?.height ?? null;
+		}
 
-					// Default to null (no compression unless set)
-					let width = null;
-					let 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;
+		}
 
-					// If user/settings want compression, pick their preferred size.
-					if (settingsCompression) {
-						width = settings?.imageCompressionSize?.width ?? null;
-						height = settings?.imageCompressionSize?.height ?? null;
-					}
+		// Do the compression if required
+		if (width || height) {
+			return await compressImage(imageUrl, width, height);
+		}
+		return imageUrl;
+	};
 
-					// 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;
-					}
+	const inputFileHandler = async (file) => {
+		console.log('Processing file:', {
+			name: file.name,
+			type: file.type,
+			size: file.size,
+			extension: file.name.split('.').at(-1)
+		});
 
-					// Do the compression if required
-					if (width || height) {
-						return await compressImage(imageUrl, width, height);
-					}
-					return imageUrl;
-				};
+		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 uploadImagePromise = new Promise(async (resolve, reject) => {
 				let reader = new FileReader();
 				reader.onload = async (event) => {
-					let imageUrl = event.target.result;
-
-					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) {
+					try {
+						let imageUrl = event.target.result;
+						imageUrl = await compressImageHandler(imageUrl, $settings, $config);
+
+						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