Jelajahi Sumber

refac: rich text input

Timothy Jaeryang Baek 5 bulan lalu
induk
melakukan
e30c5e628c

File diff ditekan karena terlalu besar
+ 881 - 20
package-lock.json


+ 7 - 0
package.json

@@ -37,6 +37,7 @@
 		"postcss": "^8.4.31",
 		"prettier": "^3.3.3",
 		"prettier-plugin-svelte": "^3.2.6",
+		"sass-embedded": "^1.81.0",
 		"svelte": "^4.2.18",
 		"svelte-check": "^3.8.5",
 		"svelte-confetti": "^1.3.2",
@@ -56,6 +57,12 @@
 		"@mediapipe/tasks-vision": "^0.10.17",
 		"@pyscript/core": "^0.4.32",
 		"@sveltejs/adapter-node": "^2.0.0",
+		"@tiptap/core": "^2.10.0",
+		"@tiptap/extension-highlight": "^2.10.0",
+		"@tiptap/extension-placeholder": "^2.10.0",
+		"@tiptap/extension-typography": "^2.10.0",
+		"@tiptap/pm": "^2.10.0",
+		"@tiptap/starter-kit": "^2.10.0",
 		"@xyflow/svelte": "^0.1.19",
 		"async": "^3.2.5",
 		"bits-ui": "^0.19.7",

+ 20 - 5
src/app.css

@@ -199,19 +199,34 @@ input[type='number'] {
 }
 
 .ProseMirror {
-	@apply h-full  min-h-fit max-h-full whitespace-pre-wrap;
+	@apply h-full min-h-fit max-h-full whitespace-pre-wrap;
 }
 
 .ProseMirror:focus {
 	outline: none;
 }
 
-.placeholder::after {
+.ProseMirror p.is-editor-empty:first-child::before {
 	content: attr(data-placeholder);
-	cursor: text;
+	float: left;
+	color: #adb5bd;
 	pointer-events: none;
+	height: 0;
+}
 
-	float: left;
+.tiptap > pre > code {
+	border-radius: 0.4rem;
+	font-size: 0.85rem;
+	padding: 0.25em 0.3em;
+
+	@apply dark:bg-gray-800 bg-gray-100;
+}
+
+.tiptap > pre {
+	border-radius: 0.5rem;
+	font-family: 'JetBrainsMono', monospace;
+	margin: 1.5rem 0;
+	padding: 0.75rem 1rem;
 
-	@apply absolute inset-0 z-0 text-gray-500;
+	@apply dark:bg-gray-800 bg-gray-100;
 }

+ 20 - 51
src/lib/components/chat/MessageInput.svelte

@@ -75,14 +75,6 @@
 		(model) => $models.find((m) => m.id === model)?.info?.meta?.capabilities?.vision ?? true
 	);
 
-	$: if (prompt) {
-		if (chatInputContainerElement) {
-			chatInputContainerElement.style.height = '';
-			chatInputContainerElement.style.height =
-				Math.min(chatInputContainerElement.scrollHeight, 200) + 'px';
-		}
-	}
-
 	const scrollToBottom = () => {
 		const element = document.getElementById('messages-container');
 		element.scrollTo({
@@ -585,54 +577,47 @@
 
 									{#if $settings?.richTextInput ?? true}
 										<div
-											bind:this={chatInputContainerElement}
-											id="chat-input-container"
-											class="scrollbar-hidden text-left bg-gray-50 dark:bg-gray-850 dark:text-gray-100 outline-none w-full py-2.5 px-1 rounded-xl resize-none h-[48px] overflow-auto"
+											class="scrollbar-hidden text-left bg-gray-50 dark:bg-gray-850 dark:text-gray-100 outline-none w-full py-2.5 px-1 rounded-xl resize-none h-fit max-h-60 overflow-auto"
 										>
 											<RichTextInput
 												bind:this={chatInputElement}
 												id="chat-input"
-												trim={true}
-												placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
-												largeTextAsFile={$settings?.largeTextAsFile ?? false}
-												bind:value={prompt}
+												messageInput={true}
 												shiftEnter={!$mobile ||
 													!(
 														'ontouchstart' in window ||
 														navigator.maxTouchPoints > 0 ||
 														navigator.msMaxTouchPoints > 0
 													)}
+												placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
+												largeTextAsFile={$settings?.largeTextAsFile ?? false}
+												bind:value={prompt}
 												on:enter={async (e) => {
+													const commandsContainerElement =
+														document.getElementById('commands-container');
+													if (commandsContainerElement) {
+														e.preventDefault();
+
+														const commandOptionButton = [
+															...document.getElementsByClassName('selected-command-option-button')
+														]?.at(-1);
+
+														if (commandOptionButton) {
+															commandOptionButton?.click();
+															return;
+														}
+													}
+
 													if (prompt !== '') {
 														dispatch('submit', prompt);
 													}
 												}}
-												on:input={async (e) => {
-													if (chatInputContainerElement) {
-														chatInputContainerElement.style.height = '';
-														chatInputContainerElement.style.height =
-															Math.min(chatInputContainerElement.scrollHeight, 200) + 'px';
-													}
-												}}
-												on:focus={async (e) => {
-													if (chatInputContainerElement) {
-														chatInputContainerElement.style.height = '';
-														chatInputContainerElement.style.height =
-															Math.min(chatInputContainerElement.scrollHeight, 200) + 'px';
-													}
-												}}
 												on:keypress={(e) => {
 													e = e.detail.event;
 												}}
 												on:keydown={async (e) => {
 													e = e.detail.event;
 
-													if (chatInputContainerElement) {
-														chatInputContainerElement.style.height = '';
-														chatInputContainerElement.style.height =
-															Math.min(chatInputContainerElement.scrollHeight, 200) + 'px';
-													}
-
 													const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
 													const commandsContainerElement =
 														document.getElementById('commands-container');
@@ -692,22 +677,6 @@
 														commandOptionButton.scrollIntoView({ block: 'center' });
 													}
 
-													if (commandsContainerElement && e.key === 'Enter') {
-														e.preventDefault();
-
-														const commandOptionButton = [
-															...document.getElementsByClassName('selected-command-option-button')
-														]?.at(-1);
-
-														if (e.shiftKey) {
-															prompt = `${prompt}\n`;
-														} else if (commandOptionButton) {
-															commandOptionButton?.click();
-														} else {
-															document.getElementById('send-message-button')?.click();
-														}
-													}
-
 													if (commandsContainerElement && e.key === 'Tab') {
 														e.preventDefault();
 

+ 140 - 414
src/lib/components/common/RichTextInput.svelte

@@ -1,241 +1,36 @@
 <script lang="ts">
-	import { onDestroy, onMount } from 'svelte';
+	import { marked } from 'marked';
+	import TurndownService from 'turndown';
+	const turndownService = new TurndownService();
+
+	import { onMount, onDestroy } from 'svelte';
 	import { createEventDispatcher } from 'svelte';
 	const eventDispatch = createEventDispatcher();
 
 	import { EditorState, Plugin, TextSelection } from 'prosemirror-state';
-	import { EditorView, Decoration, DecorationSet } from 'prosemirror-view';
-	import { undo, redo, history } from 'prosemirror-history';
-	import {
-		schema,
-		defaultMarkdownParser,
-		MarkdownParser,
-		defaultMarkdownSerializer
-	} from 'prosemirror-markdown';
 
-	import {
-		inputRules,
-		wrappingInputRule,
-		textblockTypeInputRule,
-		InputRule
-	} from 'prosemirror-inputrules'; // Import input rules
-	import { splitListItem, liftListItem, sinkListItem } from 'prosemirror-schema-list'; // Import from prosemirror-schema-list
-	import { keymap } from 'prosemirror-keymap';
-	import { baseKeymap, chainCommands } from 'prosemirror-commands';
-	import { DOMParser, DOMSerializer, Schema, Fragment } from 'prosemirror-model';
+	import { Editor } from '@tiptap/core';
+
+	import Placeholder from '@tiptap/extension-placeholder';
+	import Highlight from '@tiptap/extension-highlight';
+	import Typography from '@tiptap/extension-typography';
+	import StarterKit from '@tiptap/starter-kit';
+
 	import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
 
 	export let className = 'input-prose';
-	export let shiftEnter = false;
-	export let largeTextAsFile = false;
-
-	export let id = '';
-	export let value = '';
 	export let placeholder = 'Type here...';
-	export let trim = false;
-
-	let element: HTMLElement; // Element where ProseMirror will attach
-	let state;
-	let view;
-
-	// Plugin to add placeholder when the content is empty
-	function placeholderPlugin(placeholder: string) {
-		return new Plugin({
-			props: {
-				decorations(state) {
-					const doc = state.doc;
-					if (
-						doc.childCount === 1 &&
-						doc.firstChild.isTextblock &&
-						doc.firstChild?.textContent === ''
-					) {
-						// If there's nothing in the editor, show the placeholder decoration
-						const decoration = Decoration.node(0, doc.content.size, {
-							'data-placeholder': placeholder,
-							class: 'placeholder'
-						});
-						return DecorationSet.create(doc, [decoration]);
-					}
-					return DecorationSet.empty;
-				}
-			}
-		});
-	}
-
-	function unescapeMarkdown(text: string): string {
-		return text
-			.replace(/\\([\\`*{}[\]()#+\-.!_>])/g, '$1') // unescape backslashed characters
-			.replace(/&amp;/g, '&')
-			.replace(/</g, '<')
-			.replace(/>/g, '>')
-			.replace(/&quot;/g, '"')
-			.replace(/&#39;/g, "'");
-	}
-
-	// Custom parsing rule that creates proper paragraphs for newlines and empty lines
-	function markdownToProseMirrorDoc(markdown: string) {
-		// Split the markdown into lines
-		const lines = markdown.split('\n\n');
-
-		// Create an array to hold our paragraph nodes
-		const paragraphs = [];
-
-		// Process each line
-		lines.forEach((line) => {
-			if (line.trim() === '') {
-				// For empty lines, create an empty paragraph
-				paragraphs.push(schema.nodes.paragraph.create());
-			} else {
-				// For non-empty lines, parse as usual
-				const doc = defaultMarkdownParser.parse(line);
-				// Extract the content of the parsed document
-				doc.content.forEach((node) => {
-					paragraphs.push(node);
-				});
-			}
-		});
-
-		// Create a new document with these paragraphs
-		return schema.node('doc', null, paragraphs);
-	}
-
-	// Create a custom serializer for paragraphs
-	// Custom paragraph serializer to preserve newlines for empty paragraphs (empty block).
-	function serializeParagraph(state, node: Node) {
-		const content = node.textContent.trim();
-
-		// If the paragraph is empty, just add an empty line.
-		if (content === '') {
-			state.write('\n\n');
-		} else {
-			state.renderInline(node);
-			state.closeBlock(node);
-		}
-	}
-
-	const customMarkdownSerializer = new defaultMarkdownSerializer.constructor(
-		{
-			...defaultMarkdownSerializer.nodes,
-
-			paragraph: (state, node) => {
-				serializeParagraph(state, node); // Use custom paragraph serialization
-			}
-
-			// Customize other block formats if needed
-		},
-
-		// Copy marks directly from the original serializer (or customize them if necessary)
-		defaultMarkdownSerializer.marks
-	);
-
-	// Utility function to convert ProseMirror content back to markdown text
-	function serializeEditorContent(doc) {
-		const markdown = customMarkdownSerializer.serialize(doc);
-		if (trim) {
-			return unescapeMarkdown(markdown).trim();
-		} else {
-			return unescapeMarkdown(markdown);
-		}
-	}
-
-	// ---- Input Rules ----
-	// Input rule for heading (e.g., # Headings)
-	function headingRule(schema) {
-		return textblockTypeInputRule(/^(#{1,6})\s$/, schema.nodes.heading, (match) => ({
-			level: match[1].length
-		}));
-	}
-
-	// Input rule for bullet list (e.g., `- item`)
-	function bulletListRule(schema) {
-		return wrappingInputRule(/^\s*([-+*])\s$/, schema.nodes.bullet_list);
-	}
-
-	// Input rule for ordered list (e.g., `1. item`)
-	function orderedListRule(schema) {
-		return wrappingInputRule(/^(\d+)\.\s$/, schema.nodes.ordered_list, (match) => ({
-			order: +match[1]
-		}));
-	}
-
-	// Custom input rules for Bold/Italic (using * or _)
-	function markInputRule(regexp: RegExp, markType: any) {
-		return new InputRule(regexp, (state, match, start, end) => {
-			const { tr } = state;
-			if (match) {
-				tr.replaceWith(start, end, schema.text(match[1], [markType.create()]));
-			}
-			return tr;
-		});
-	}
-
-	function boldRule(schema) {
-		return markInputRule(/(?<=^|\s)\*([^*]+)\*(?=\s|$)/, schema.marks.strong);
-	}
-
-	function italicRule(schema) {
-		// Using lookbehind and lookahead to prevent the space from being consumed
-		return markInputRule(/(?<=^|\s)_([^*_]+)_(?=\s|$)/, schema.marks.em);
-	}
-
-	// Initialize Editor State and View
-	function afterSpacePress(state, dispatch) {
-		// Get the position right after the space was naturally inserted by the browser.
-		let { from, to, empty } = state.selection;
-
-		if (dispatch && empty) {
-			let tr = state.tr;
-
-			// Check for any active marks at `from - 1` (the space we just inserted)
-			const storedMarks = state.storedMarks || state.selection.$from.marks();
-
-			const hasBold = storedMarks.some((mark) => mark.type === state.schema.marks.strong);
-			const hasItalic = storedMarks.some((mark) => mark.type === state.schema.marks.em);
-
-			// Remove marks from the space character (marks applied to the space character will be marked as false)
-			if (hasBold) {
-				tr = tr.removeMark(from - 1, from, state.schema.marks.strong);
-			}
-			if (hasItalic) {
-				tr = tr.removeMark(from - 1, from, state.schema.marks.em);
-			}
-
-			// Dispatch the resulting transaction to update the editor state
-			dispatch(tr);
-		}
-
-		return true;
-	}
-
-	function toggleMark(markType) {
-		return (state, dispatch) => {
-			const { from, to } = state.selection;
-			if (state.doc.rangeHasMark(from, to, markType)) {
-				if (dispatch) dispatch(state.tr.removeMark(from, to, markType));
-				return true;
-			} else {
-				if (dispatch) dispatch(state.tr.addMark(from, to, markType.create()));
-				return true;
-			}
-		};
-	}
-
-	function isInList(state) {
-		const { $from } = state.selection;
-		return (
-			$from.parent.type === schema.nodes.paragraph && $from.node(-1).type === schema.nodes.list_item
-		);
-	}
+	export let value = '';
+	export let id = '';
 
-	function isEmptyListItem(state) {
-		const { $from } = state.selection;
-		return isInList(state) && $from.parent.content.size === 0 && $from.node(-1).childCount === 1;
-	}
+	export let messageInput = false;
+	export let shiftEnter = false;
+	export let largeTextAsFile = false;
 
-	function exitList(state, dispatch) {
-		return liftListItem(schema.nodes.list_item)(state, dispatch);
-	}
+	let element;
+	let editor;
 
+	// Function to find the next template in the document
 	function findNextTemplate(doc, from = 0) {
 		const patterns = [
 			{ start: '[', end: ']' },
@@ -270,6 +65,7 @@
 		return result;
 	}
 
+	// Function to select the next template in the document
 	function selectNextTemplate(state, dispatch) {
 		const { doc, selection } = state;
 		const from = selection.to;
@@ -290,220 +86,150 @@
 		return false;
 	}
 
-	// Replace tabs with four spaces
-	function handleTabIndentation(text: string): string {
-		// Replace each tab character with four spaces
-		return text.replace(/\t/g, '    ');
-	}
+	export const setContent = (content) => {
+		editor.commands.setContent(content);
+	};
 
-	onMount(() => {
-		const initialDoc = markdownToProseMirrorDoc(value || ''); // Convert the initial content
+	const selectTemplate = () => {
+		if (value !== '') {
+			// After updating the state, try to find and select the next template
+			setTimeout(() => {
+				const templateFound = selectNextTemplate(editor.view.state, editor.view.dispatch);
+				if (!templateFound) {
+					// If no template found, set cursor at the 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);
+		}
+	};
 
-		state = EditorState.create({
-			doc: initialDoc,
-			schema,
-			plugins: [
-				history(),
-				placeholderPlugin(placeholder),
-				inputRules({
-					rules: [
-						headingRule(schema), // Handle markdown-style headings (# H1, ## H2, etc.)
-						bulletListRule(schema), // Handle `-` or `*` input to start bullet list
-						orderedListRule(schema), // Handle `1.` input to start ordered list
-						boldRule(schema), // Bold input rule
-						italicRule(schema) // Italic input rule
-					]
-				}),
-				keymap({
-					...baseKeymap,
-					'Mod-z': undo,
-					'Mod-y': redo,
-					Enter: (state, dispatch, view) => {
-						if (shiftEnter) {
-							eventDispatch('enter');
-							return true;
-						}
-						return chainCommands(
-							(state, dispatch, view) => {
-								if (isEmptyListItem(state)) {
-									return exitList(state, dispatch);
-								}
-								return false;
-							},
-							(state, dispatch, view) => {
-								if (isInList(state)) {
-									return splitListItem(schema.nodes.list_item)(state, dispatch);
-								}
-								return false;
-							},
-							baseKeymap.Enter
-						)(state, dispatch, view);
+	onMount(() => {
+		editor = new Editor({
+			element: element,
+			extensions: [StarterKit, Highlight, Typography, Placeholder.configure({ placeholder })],
+			content: marked.parse(value),
+			autofocus: true,
+			onTransaction: () => {
+				// force re-render so `editor.isActive` works as expected
+				editor = editor;
+
+				const newValue = turndownService.turndown(editor.getHTML());
+				if (value !== newValue) {
+					value = newValue; // Trigger parent updates
+				}
+			},
+			editorProps: {
+				attributes: { id },
+				handleDOMEvents: {
+					focus: (view, event) => {
+						eventDispatch('focus', { event });
+						return false;
 					},
-
-					'Shift-Enter': (state, dispatch, view) => {
-						if (shiftEnter) {
-							return chainCommands(
-								(state, dispatch, view) => {
-									if (isEmptyListItem(state)) {
-										return exitList(state, dispatch);
-									}
-									return false;
-								},
-								(state, dispatch, view) => {
-									if (isInList(state)) {
-										return splitListItem(schema.nodes.list_item)(state, dispatch);
-									}
-									return false;
-								},
-								baseKeymap.Enter
-							)(state, dispatch, view);
-						} else {
-							return baseKeymap.Enter(state, dispatch, view);
-						}
+					keypress: (view, event) => {
+						eventDispatch('keypress', { event });
 						return false;
 					},
 
-					// Prevent default tab navigation and provide indent/outdent behavior inside lists:
-					Tab: chainCommands((state, dispatch, view) => {
-						const { $from } = state.selection;
-						if (isInList(state)) {
-							return sinkListItem(schema.nodes.list_item)(state, dispatch);
-						} else {
-							return selectNextTemplate(state, dispatch);
-						}
-						return true; // Prevent Tab from moving the focus
-					}),
-					'Shift-Tab': (state, dispatch, view) => {
-						const { $from } = state.selection;
-						if (isInList(state)) {
-							return liftListItem(schema.nodes.list_item)(state, dispatch);
+					keydown: (view, event) => {
+						// Handle Tab Key
+						if (event.key === 'Tab') {
+							const handled = selectNextTemplate(view.state, view.dispatch);
+							if (handled) {
+								event.preventDefault();
+								return true;
+							}
 						}
-						return true; // Prevent Shift-Tab from moving the focus
-					},
-					'Mod-b': toggleMark(schema.marks.strong),
-					'Mod-i': toggleMark(schema.marks.em)
-				})
-			]
-		});
 
-		view = new EditorView(element, {
-			state,
-			dispatchTransaction(transaction) {
-				// Update editor state
-				let newState = view.state.apply(transaction);
-				view.updateState(newState);
-
-				value = serializeEditorContent(newState.doc); // Convert ProseMirror content to markdown text
-				eventDispatch('input', { value });
-			},
-			handleDOMEvents: {
-				focus: (view, event) => {
-					eventDispatch('focus', { event });
-					return false;
-				},
-				keypress: (view, event) => {
-					eventDispatch('keypress', { event });
-					return false;
-				},
-				keydown: (view, event) => {
-					eventDispatch('keydown', { event });
-					return false;
-				},
-				paste: (view, event) => {
-					if (event.clipboardData) {
-						// Extract plain text from clipboard and paste it without formatting
-						const plainText = event.clipboardData.getData('text/plain');
-						if (plainText) {
-							if (largeTextAsFile) {
-								if (plainText.length > PASTED_TEXT_CHARACTER_LIMIT) {
-									// Dispatch paste event to parent component
-									eventDispatch('paste', { event });
+						if (messageInput) {
+							// Handle shift + Enter for a line break
+							if (shiftEnter) {
+								if (event.key === 'Enter' && event.shiftKey) {
+									editor.commands.setHardBreak(); // Insert a hard break
+									event.preventDefault();
+									return true;
+								}
+								if (event.key === 'Enter') {
+									eventDispatch('enter', { event });
 									event.preventDefault();
 									return true;
 								}
 							}
 
-							const modifiedText = handleTabIndentation(plainText);
-							console.log(modifiedText);
-
-							// Replace the current selection with the plain text content
-							const tr = view.state.tr.replaceSelectionWith(
-								view.state.schema.text(modifiedText),
-								false
-							);
-							view.dispatch(tr.scrollIntoView());
-							event.preventDefault(); // Prevent the default paste behavior
-							return true;
+							if (event.key === 'Enter') {
+								eventDispatch('enter', { event });
+								event.preventDefault();
+								return true;
+							}
 						}
 
-						// Check if the pasted content contains image files
-						const hasImageFile = Array.from(event.clipboardData.files).some((file) =>
-							file.type.startsWith('image/')
-						);
+						eventDispatch('keydown', { event });
+						return false;
+					},
+					paste: (view, event) => {
+						if (event.clipboardData) {
+							// Extract plain text from clipboard and paste it without formatting
+							const plainText = event.clipboardData.getData('text/plain');
+							if (plainText) {
+								if (largeTextAsFile) {
+									if (plainText.length > PASTED_TEXT_CHARACTER_LIMIT) {
+										// Dispatch paste event to parent component
+										eventDispatch('paste', { event });
+										event.preventDefault();
+										return true;
+									}
+								}
+								return false;
+							}
 
-						// Check for image in dataTransfer items (for cases where files are not available)
-						const hasImageItem = Array.from(event.clipboardData.items).some((item) =>
-							item.type.startsWith('image/')
-						);
-						if (hasImageFile) {
-							// If there's an image, dispatch the event to the parent
-							eventDispatch('paste', { event });
-							event.preventDefault();
-							return true;
-						}
+							// Check if the pasted content contains image files
+							const hasImageFile = Array.from(event.clipboardData.files).some((file) =>
+								file.type.startsWith('image/')
+							);
 
-						if (hasImageItem) {
-							// If there's an image item, dispatch the event to the parent
-							eventDispatch('paste', { event });
-							event.preventDefault();
-							return true;
+							// Check for image in dataTransfer items (for cases where files are not available)
+							const hasImageItem = Array.from(event.clipboardData.items).some((item) =>
+								item.type.startsWith('image/')
+							);
+							if (hasImageFile) {
+								// If there's an image, dispatch the event to the parent
+								eventDispatch('paste', { event });
+								event.preventDefault();
+								return true;
+							}
+
+							if (hasImageItem) {
+								// If there's an image item, dispatch the event to the parent
+								eventDispatch('paste', { event });
+								event.preventDefault();
+								return true;
+							}
 						}
-					}
 
-					// For all other cases (text, formatted text, etc.), let ProseMirror handle it
-					return false;
-				},
-				// Handle space input after browser has completed it
-				keyup: (view, event) => {
-					if (event.key === ' ' && event.code === 'Space') {
-						afterSpacePress(view.state, view.dispatch);
+						// For all other cases (text, formatted text, etc.), let ProseMirror handle it
+						view.dispatch(view.state.tr.scrollIntoView()); // Move viewport to the cursor after pasting
+						return false;
 					}
-					return false;
 				}
-			},
-			attributes: { id }
-		});
-	});
-
-	// Reinitialize the editor if the value is externally changed (i.e. when `value` is updated)
-	$: if (view && value !== serializeEditorContent(view.state.doc)) {
-		const newDoc = markdownToProseMirrorDoc(value || '');
-
-		const newState = EditorState.create({
-			doc: newDoc,
-			schema,
-			plugins: view.state.plugins,
-			selection: TextSelection.atEnd(newDoc) // This sets the cursor at the end
+			}
 		});
-		view.updateState(newState);
 
-		if (value !== '') {
-			// After updating the state, try to find and select the next template
-			setTimeout(() => {
-				const templateFound = selectNextTemplate(view.state, view.dispatch);
-				if (!templateFound) {
-					// If no template found, set cursor at the end
-					const endPos = view.state.doc.content.size;
-					view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, endPos)));
-				}
-			}, 0);
-		}
-	}
+		selectTemplate();
+	});
 
-	// Destroy ProseMirror instance on unmount
 	onDestroy(() => {
-		view?.destroy();
+		if (editor) {
+			editor.destroy();
+		}
 	});
+
+	// Update the editor content if the external `value` changes
+	$: if (editor && value !== turndownService.turndown(editor.getHTML())) {
+		editor.commands.setContent(marked.parse(value)); // Update editor content
+		selectTemplate();
+	}
 </script>
 
-<div bind:this={element} class="relative w-full min-w-full h-full min-h-fit {className}"></div>
+<div bind:this={element} class="relative w-full min-w-full h-full min-h-fit {className}" />

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini