<script lang="ts"> import { marked } from 'marked'; marked.use({ breaks: true, gfm: true, renderer: { list(body, ordered, start) { const isTaskList = body.includes('data-checked='); if (isTaskList) { return `<ul data-type="taskList">${body}</ul>`; } const type = ordered ? 'ol' : 'ul'; const startatt = ordered && start !== 1 ? ` start="${start}"` : ''; return `<${type}${startatt}>${body}</${type}>`; }, listitem(text, task, checked) { if (task) { const checkedAttr = checked ? 'true' : 'false'; return `<li data-type="taskItem" data-checked="${checkedAttr}">${text}</li>`; } return `<li>${text}</li>`; } } }); import TurndownService from 'turndown'; import { gfm } from 'turndown-plugin-gfm'; const turndownService = new TurndownService({ codeBlockStyle: 'fenced', headingStyle: 'atx' }); turndownService.escape = (string) => string; // Use turndown-plugin-gfm for proper GFM table support turndownService.use(gfm); turndownService.addRule('taskListItems', { filter: (node) => node.nodeName === 'LI' && (node.getAttribute('data-checked') === 'true' || node.getAttribute('data-checked') === 'false'), replacement: function (content, node) { const checked = node.getAttribute('data-checked') === 'true'; content = content.replace(/^\s+/, ''); return `- [${checked ? 'x' : ' '}] ${content}\n`; } }); import { onMount, onDestroy, tick, getContext } from 'svelte'; import { createEventDispatcher } from 'svelte'; const i18n = getContext('i18n'); const eventDispatch = createEventDispatcher(); import { Fragment, DOMParser } from 'prosemirror-model'; import { EditorState, Plugin, PluginKey, TextSelection, Selection } from 'prosemirror-state'; import { Editor, Extension } from '@tiptap/core'; // Yjs imports import * as Y from 'yjs'; import { ySyncPlugin, yCursorPlugin, yUndoPlugin, undo, redo, prosemirrorJSONToYDoc, yDocToProsemirrorJSON } from 'y-prosemirror'; import { keymap } from 'prosemirror-keymap'; import { AIAutocompletion } from './RichTextInput/AutoCompletion.js'; import Table from '@tiptap/extension-table'; import TableRow from '@tiptap/extension-table-row'; import TableHeader from '@tiptap/extension-table-header'; import TableCell from '@tiptap/extension-table-cell'; import Link from '@tiptap/extension-link'; import Underline from '@tiptap/extension-underline'; import TaskItem from '@tiptap/extension-task-item'; import TaskList from '@tiptap/extension-task-list'; import CharacterCount from '@tiptap/extension-character-count'; import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; import Placeholder from '@tiptap/extension-placeholder'; import StarterKit from '@tiptap/starter-kit'; import Highlight from '@tiptap/extension-highlight'; import Typography from '@tiptap/extension-typography'; import BubbleMenu from '@tiptap/extension-bubble-menu'; import FloatingMenu from '@tiptap/extension-floating-menu'; import { all, createLowlight } from 'lowlight'; import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants'; import FormattingButtons from './RichTextInput/FormattingButtons.svelte'; export let oncompositionstart = (e) => {}; export let oncompositionend = (e) => {}; export let onChange = (e) => {}; // create a lowlight instance with all languages loaded const lowlight = createLowlight(all); export let editor = null; export let socket = null; export let user = null; export let documentId = ''; export let className = 'input-prose'; export let placeholder = 'Type here...'; export let link = false; export let id = ''; export let value = ''; export let html = ''; export let json = false; export let raw = false; export let editable = true; export let collaboration = false; export let showFormattingButtons = true; export let preserveBreaks = false; export let generateAutoCompletion: Function = async () => null; export let autocomplete = false; export let messageInput = false; export let shiftEnter = false; export let largeTextAsFile = false; export let insertPromptAsRichText = false; export let floatingMenuPlacement = 'bottom-start'; let content = null; let htmlValue = ''; let jsonValue = ''; let mdValue = ''; // Yjs setup let ydoc = null; let yXmlFragment = null; let awareness = null; // Custom Yjs Socket.IO provider class SocketIOProvider { constructor(doc, documentId, socket, user) { this.doc = doc; this.documentId = documentId; this.socket = socket; this.user = user; this.isConnected = false; this.synced = false; this.setupEventListeners(); } generateUserColor() { const colors = [ '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9' ]; return colors[Math.floor(Math.random() * colors.length)]; } joinDocument() { const userColor = this.generateUserColor(); this.socket.emit('ydoc:document:join', { document_id: this.documentId, user_id: this.user?.id, user_name: this.user?.name, user_color: userColor }); // Set user awareness info if (awareness && this.user) { awareness.setLocalStateField('user', { name: `${this.user.name}`, color: userColor, id: this.socket.id }); } } setupEventListeners() { // Listen for document updates from server this.socket.on('ydoc:document:update', (data) => { if (data.document_id === this.documentId && data.socket_id !== this.socket.id) { try { const update = new Uint8Array(data.update); Y.applyUpdate(this.doc, update); } catch (error) { console.error('Error applying Yjs update:', error); } } }); // Listen for document state from server this.socket.on('ydoc:document:state', async (data) => { if (data.document_id === this.documentId) { try { if (data.state) { const state = new Uint8Array(data.state); if (state.length === 2 && state[0] === 0 && state[1] === 0) { // Empty state, check if we have content to initialize // check if editor empty as well const isEmptyEditor = !editor || editor.getText().trim() === ''; if (content && isEmptyEditor && (data?.sessions ?? ['']).length === 1) { const editorYdoc = prosemirrorJSONToYDoc(editor.schema, content); if (editorYdoc) { Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(editorYdoc)); } } } else { Y.applyUpdate(this.doc, state, 'server'); } } this.synced = true; } catch (error) { console.error('Error applying Yjs state:', error); this.synced = false; this.socket.emit('ydoc:document:state', { document_id: this.documentId }); } } }); // Listen for awareness updates this.socket.on('ydoc:awareness:update', (data) => { if (data.document_id === this.documentId && awareness) { try { const awarenessUpdate = new Uint8Array(data.update); awareness.applyUpdate(awarenessUpdate, 'server'); } catch (error) { console.error('Error applying awareness update:', error); } } }); // Handle connection events this.socket.on('connect', this.onConnect); this.socket.on('disconnect', this.onDisconnect); // Listen for document updates from Yjs this.doc.on('update', async (update, origin) => { if (origin !== 'server' && this.isConnected) { await tick(); // Ensure the DOM is updated before sending this.socket.emit('ydoc:document:update', { document_id: this.documentId, user_id: this.user?.id, socket_id: this.socket.id, update: Array.from(update), data: { content: { md: mdValue, html: htmlValue, json: jsonValue } } }); } }); // Listen for awareness updates from Yjs if (awareness) { awareness.on('change', ({ added, updated, removed }, origin) => { if (origin !== 'server' && this.isConnected) { const changedClients = added.concat(updated).concat(removed); const awarenessUpdate = awareness.encodeUpdate(changedClients); this.socket.emit('ydoc:awareness:update', { document_id: this.documentId, user_id: this.socket.id, update: Array.from(awarenessUpdate) }); } }); } if (this.socket.connected) { this.isConnected = true; this.joinDocument(); } } onConnect = () => { this.isConnected = true; this.joinDocument(); }; onDisconnect = () => { this.isConnected = false; this.synced = false; }; destroy() { this.socket.off('ydoc:document:update'); this.socket.off('ydoc:document:state'); this.socket.off('ydoc:awareness:update'); this.socket.off('connect', this.onConnect); this.socket.off('disconnect', this.onDisconnect); if (this.isConnected) { this.socket.emit('ydoc:document:leave', { document_id: this.documentId, user_id: this.user?.id }); } } } let provider = null; // Simple awareness implementation class SimpleAwareness { constructor(yDoc) { // Yjs awareness expects clientID (not clientId) property this.clientID = yDoc ? yDoc.clientID : Math.floor(Math.random() * 0xffffffff); // Map from clientID (number) to state (object) this._states = new Map(); // _states, not states; will make getStates() for compat this._updateHandlers = []; this._localState = {}; // As in Yjs Awareness, add our local state to the states map from the start: this._states.set(this.clientID, this._localState); } on(event, handler) { if (event === 'change') this._updateHandlers.push(handler); } off(event, handler) { if (event === 'change') { const i = this._updateHandlers.indexOf(handler); if (i !== -1) this._updateHandlers.splice(i, 1); } } getLocalState() { return this._states.get(this.clientID) || null; } getStates() { // Yjs returns a Map (clientID->state) return this._states; } setLocalStateField(field, value) { let localState = this._states.get(this.clientID); if (!localState) { localState = {}; this._states.set(this.clientID, localState); } localState[field] = value; // After updating, fire 'update' event to all handlers for (const cb of this._updateHandlers) { // Follows Yjs Awareness ({ added, updated, removed }, origin) cb({ added: [], updated: [this.clientID], removed: [] }, 'local'); } } applyUpdate(update, origin) { // Very simple: Accepts a serialized JSON state for now as Uint8Array try { const str = new TextDecoder().decode(update); const obj = JSON.parse(str); // Should be a plain object: { clientID: state, ... } for (const [k, v] of Object.entries(obj)) { this._states.set(+k, v); } for (const cb of this._updateHandlers) { cb({ added: [], updated: Array.from(Object.keys(obj)).map(Number), removed: [] }, origin); } } catch (e) { console.warn('SimpleAwareness: Could not decode update:', e); } } encodeUpdate(clients) { // Encodes the states for the given clientIDs as Uint8Array (JSON) const obj = {}; for (const id of clients || Array.from(this._states.keys())) { const st = this._states.get(id); if (st) obj[id] = st; } const json = JSON.stringify(obj); return new TextEncoder().encode(json); } } // Yjs collaboration extension const YjsCollaboration = Extension.create({ name: 'yjsCollaboration', addProseMirrorPlugins() { if (!collaboration || !yXmlFragment) return []; const plugins = [ ySyncPlugin(yXmlFragment), yUndoPlugin(), keymap({ 'Mod-z': undo, 'Mod-y': redo, 'Mod-Shift-z': redo }) ]; if (awareness) { plugins.push(yCursorPlugin(awareness)); } return plugins; } }); function initializeCollaboration() { if (!collaboration) return; // Create Yjs document ydoc = new Y.Doc(); yXmlFragment = ydoc.getXmlFragment('prosemirror'); awareness = new SimpleAwareness(ydoc); // Create custom Socket.IO provider provider = new SocketIOProvider(ydoc, documentId, socket, user); } let floatingMenuElement = null; let bubbleMenuElement = null; let element; const options = { throwOnError: false }; $: if (editor) { editor.setOptions({ editable: editable }); } $: if (value === null && html !== null && editor) { editor.commands.setContent(html); } export const getWordAtDocPos = () => { if (!editor) return ''; const { state } = editor.view; const pos = state.selection.from; const doc = state.doc; const resolvedPos = doc.resolve(pos); const textBlock = resolvedPos.parent; const paraStart = resolvedPos.start(); const text = textBlock.textContent; const offset = resolvedPos.parentOffset; let wordStart = offset, wordEnd = offset; while (wordStart > 0 && !/\s/.test(text[wordStart - 1])) wordStart--; while (wordEnd < text.length && !/\s/.test(text[wordEnd])) wordEnd++; const word = text.slice(wordStart, wordEnd); return word; }; // Returns {start, end} of the word at pos function getWordBoundsAtPos(doc, pos) { const resolvedPos = doc.resolve(pos); const textBlock = resolvedPos.parent; const paraStart = resolvedPos.start(); const text = textBlock.textContent; const offset = resolvedPos.parentOffset; let wordStart = offset, wordEnd = offset; while (wordStart > 0 && !/\s/.test(text[wordStart - 1])) wordStart--; while (wordEnd < text.length && !/\s/.test(text[wordEnd])) wordEnd++; return { start: paraStart + wordStart, end: paraStart + wordEnd }; } export const replaceCommandWithText = async (text) => { const { state, dispatch } = editor.view; const { selection } = state; const pos = selection.from; // Get the plain text of this document // const docText = state.doc.textBetween(0, state.doc.content.size, '\n', '\n'); // Find the word boundaries at cursor const { start, end } = getWordBoundsAtPos(state.doc, pos); let tr = state.tr; if (insertPromptAsRichText) { const htmlContent = marked .parse(text, { breaks: true, gfm: true }) .trim(); // Create a temporary div to parse HTML const tempDiv = document.createElement('div'); tempDiv.innerHTML = htmlContent; // Convert HTML to ProseMirror nodes const fragment = DOMParser.fromSchema(state.schema).parse(tempDiv); // Extract just the content, not the wrapper paragraphs const content = fragment.content; let nodesToInsert = []; content.forEach((node) => { if (node.type.name === 'paragraph') { // If it's a paragraph, extract its content nodesToInsert.push(...node.content.content); } else { nodesToInsert.push(node); } }); tr = tr.replaceWith(start, end, nodesToInsert); // Calculate new position const newPos = start + nodesToInsert.reduce((sum, node) => sum + node.nodeSize, 0); tr = tr.setSelection(Selection.near(tr.doc.resolve(newPos))); } else { if (text.includes('\n')) { // Split the text into lines and create a <p> node for each line const lines = text.split('\n'); const nodes = lines.map( (line, index) => index === 0 ? state.schema.text(line ? line : []) // First line is plain text : state.schema.nodes.paragraph.create({}, line ? state.schema.text(line) : undefined) // Subsequent lines are paragraphs ); // Build and dispatch the transaction to replace the word at cursor tr = tr.replaceWith(start, end, nodes); let newSelectionPos; // +1 because the insert happens at start, so last para starts at (start + sum of all previous nodes' sizes) let lastPos = start; for (let i = 0; i < nodes.length; i++) { lastPos += nodes[i].nodeSize; } // Place cursor inside the last paragraph at its end newSelectionPos = lastPos; tr = tr.setSelection(TextSelection.near(tr.doc.resolve(newSelectionPos))); } else { tr = tr.replaceWith( start, end, // replace this range text !== '' ? state.schema.text(text) : [] ); tr = tr.setSelection( state.selection.constructor.near(tr.doc.resolve(start + text.length + 1)) ); } } dispatch(tr); await tick(); // selectNextTemplate(state, dispatch); }; export const setText = (text: string) => { if (!editor) return; text = text.replaceAll('\n\n', '\n'); const { state, view } = editor; const { schema, tr } = state; if (text.includes('\n')) { // Multiple lines: make paragraphs const lines = text.split('\n'); // Map each line to a paragraph node (empty lines -> empty paragraph) const nodes = lines.map((line) => schema.nodes.paragraph.create({}, line ? schema.text(line) : undefined) ); // Create a document fragment containing all parsed paragraphs const fragment = Fragment.fromArray(nodes); // Replace current selection with these paragraphs tr.replaceSelectionWith(fragment, false /* don't select new */); view.dispatch(tr); } else if (text === '') { // Empty: replace with empty paragraph using tr editor.commands.clearContent(); } else { // 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); focus(); }; export const insertContent = (content) => { if (!editor) return; const { state, view } = editor; const { schema, tr } = state; // If content is a string, convert it to a ProseMirror node const htmlContent = marked.parse(content); // insert the HTML content at the current selection editor.commands.insertContent(htmlContent); focus(); }; export const replaceVariables = (variables) => { if (!editor) return; const { state, view } = editor; const { doc } = state; // Create a transaction to replace variables let tr = state.tr; let offset = 0; // Track position changes due to text length differences // Collect all replacements first to avoid position conflicts const replacements = []; doc.descendants((node, pos) => { if (node.isText && node.text) { const text = node.text; const replacedText = text.replace(/{{\s*([^|}]+)(?:\|[^}]*)?\s*}}/g, (match, varName) => { const trimmedVarName = varName.trim(); return variables.hasOwnProperty(trimmedVarName) ? String(variables[trimmedVarName]) : match; }); if (replacedText !== text) { replacements.push({ from: pos, to: pos + text.length, text: replacedText }); } } }); // Apply replacements in reverse order to maintain correct positions replacements.reverse().forEach(({ from, to, text }) => { tr = tr.replaceWith(from, to, text !== '' ? state.schema.text(text) : []); }); // Only dispatch if there are changes if (replacements.length > 0) { view.dispatch(tr); } }; export const focus = () => { if (editor) { editor.view.focus(); // Scroll to the current selection editor.view.dispatch(editor.view.state.tr.scrollIntoView()); } }; // Function to find the next template in the document function findNextTemplate(doc, from = 0) { const patterns = [{ start: '{{', end: '}}' }]; let result = null; doc.nodesBetween(from, doc.content.size, (node, pos) => { if (result) return false; // Stop if we've found a match if (node.isText) { const text = node.text; let index = Math.max(0, from - pos); while (index < text.length) { for (const pattern of patterns) { if (text.startsWith(pattern.start, index)) { const endIndex = text.indexOf(pattern.end, index + pattern.start.length); if (endIndex !== -1) { result = { from: pos + index, to: pos + endIndex + pattern.end.length }; return false; // Stop searching } } } index++; } } }); return result; } // Function to select the next template in the document function selectNextTemplate(state, dispatch) { const { doc, selection } = state; const from = selection.to; let template = findNextTemplate(doc, from); if (!template) { // If not found, search from the beginning template = findNextTemplate(doc, 0); } if (template) { if (dispatch) { const tr = state.tr.setSelection(TextSelection.create(doc, template.from, template.to)); dispatch(tr); // Scroll to the selected template dispatch( tr.scrollIntoView().setMeta('preventScroll', true) // Prevent default scrolling behavior ); } return true; } return false; } export const setContent = (content) => { editor.commands.setContent(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) { editor.commands.focus('end'); } }, 0); } }; onMount(async () => { content = value; if (json) { if (!content) { content = html ? html : null; } } else { if (preserveBreaks) { turndownService.addRule('preserveBreaks', { filter: 'br', // Target <br> elements replacement: function (content) { return '<br/>'; } }); } if (!raw) { async function tryParse(value, attempts = 3, interval = 100) { try { // Try parsing the value return marked.parse(value.replaceAll(`\n<br/>`, `<br/>`), { breaks: false }); } catch (error) { // If no attempts remain, fallback to plain text if (attempts <= 1) { return value; } // Wait for the interval, then retry await new Promise((resolve) => setTimeout(resolve, interval)); return tryParse(value, attempts - 1, interval); // Recursive call } } // Usage example content = await tryParse(value); } } console.log('content', content); if (collaboration) { initializeCollaboration(); } editor = new Editor({ element: element, extensions: [ StarterKit, CodeBlockLowlight.configure({ lowlight }), Highlight, Typography, Underline, Placeholder.configure({ placeholder }), Table.configure({ resizable: true }), TableRow, TableHeader, TableCell, TaskList, TaskItem.configure({ nested: true }), CharacterCount.configure({}), ...(link ? [ Link.configure({ openOnClick: true, linkOnPaste: true }) ] : []), ...(autocomplete ? [ AIAutocompletion.configure({ generateCompletion: async (text) => { if (text.trim().length === 0) { return null; } const suggestion = await generateAutoCompletion(text).catch(() => null); if (!suggestion || suggestion.trim().length === 0) { return null; } return suggestion; } }) ] : []), ...(showFormattingButtons ? [ BubbleMenu.configure({ element: bubbleMenuElement, tippyOptions: { duration: 100, arrow: false, placement: 'top', theme: 'transparent', offset: [0, 2] } }), FloatingMenu.configure({ element: floatingMenuElement, tippyOptions: { duration: 100, arrow: false, placement: floatingMenuPlacement, theme: 'transparent', offset: [-12, 4] } }) ] : []), ...(collaboration ? [YjsCollaboration] : []) ], content: collaboration ? undefined : content, autofocus: messageInput ? true : false, onTransaction: () => { // force re-render so `editor.isActive` works as expected editor = editor; htmlValue = editor.getHTML(); jsonValue = editor.getJSON(); mdValue = turndownService .turndown( htmlValue .replace(/<p><\/p>/g, '<br/>') .replace(/ {2,}/g, (m) => m.replace(/ /g, '\u00a0')) ) .replace(/\u00a0/g, ' '); onChange({ html: htmlValue, json: jsonValue, md: mdValue }); if (json) { value = jsonValue; } else { if (raw) { value = htmlValue; } else { if (!preserveBreaks) { mdValue = mdValue.replace(/<br\/>/g, ''); } if (value !== mdValue) { value = mdValue; // check if the node is paragraph as well if (editor.isActive('paragraph')) { if (value === '') { editor.commands.clearContent(); } } } } } }, editorProps: { attributes: { id }, handleDOMEvents: { compositionstart: (view, event) => { oncompositionstart(event); return false; }, compositionend: (view, event) => { oncompositionend(event); return false; }, focus: (view, event) => { eventDispatch('focus', { event }); return false; }, keyup: (view, event) => { eventDispatch('keyup', { event }); return false; }, keydown: (view, event) => { if (messageInput) { // Check if the current selection is inside a structured block (like codeBlock or list) const { state } = view; const { $head } = state.selection; // Recursive function to check ancestors for specific node types function isInside(nodeTypes: string[]): boolean { let currentNode = $head; while (currentNode) { if (nodeTypes.includes(currentNode.parent.type.name)) { return true; } if (!currentNode.depth) break; // Stop if we reach the top currentNode = state.doc.resolve(currentNode.before()); // Move to the parent node } return false; } // Handle Tab Key if (event.key === 'Tab') { const isInCodeBlock = isInside(['codeBlock']); if (isInCodeBlock) { // Handle tab in code block - insert tab character or spaces const tabChar = '\t'; // or ' ' for 4 spaces editor.commands.insertContent(tabChar); event.preventDefault(); return true; // Prevent further propagation } else { const handled = selectNextTemplate(view.state, view.dispatch); if (handled) { event.preventDefault(); return true; } } } if (event.key === 'Enter') { const isCtrlPressed = event.ctrlKey || event.metaKey; // metaKey is for Cmd key on Mac if (event.shiftKey && !isCtrlPressed) { editor.commands.enter(); // Insert a new line view.dispatch(view.state.tr.scrollIntoView()); // Move viewport to the cursor event.preventDefault(); return true; } else { const isInCodeBlock = isInside(['codeBlock']); const isInList = isInside(['listItem', 'bulletList', 'orderedList', 'taskList']); const isInHeading = isInside(['heading']); if (isInCodeBlock || isInList || isInHeading) { // Let ProseMirror handle the normal Enter behavior return false; } } } // Handle shift + Enter for a line break if (shiftEnter) { if (event.key === 'Enter' && event.shiftKey && !event.ctrlKey && !event.metaKey) { editor.commands.setHardBreak(); // Insert a hard break view.dispatch(view.state.tr.scrollIntoView()); // Move viewport to the cursor event.preventDefault(); return true; } } } eventDispatch('keydown', { event }); return false; }, paste: (view, event) => { if (event.clipboardData) { const plainText = event.clipboardData.getData('text/plain'); if (plainText) { if (largeTextAsFile && plainText.length > PASTED_TEXT_CHARACTER_LIMIT) { // Delegate handling of large text pastes to the parent component. eventDispatch('paste', { event }); event.preventDefault(); return true; } // Workaround for mobile WebViews that strip line breaks when pasting from // clipboard suggestions (e.g., Gboard clipboard history). const isMobile = /Android|iPhone|iPad|iPod|Windows Phone/i.test( navigator.userAgent ); const isWebView = typeof window !== 'undefined' && (/wv/i.test(navigator.userAgent) || // Standard Android WebView flag (navigator.userAgent.includes('Android') && !navigator.userAgent.includes('Chrome')) || // Other generic Android WebViews (navigator.userAgent.includes('Safari') && !navigator.userAgent.includes('Version'))); // iOS WebView (in-app browsers) if (isMobile && isWebView && plainText.includes('\n')) { // Manually deconstruct the pasted text and insert it with hard breaks // to preserve the multi-line formatting. const { state, dispatch } = view; const { from, to } = state.selection; const lines = plainText.split('\n'); const nodes = []; lines.forEach((line, index) => { if (index > 0) { nodes.push(state.schema.nodes.hardBreak.create()); } if (line.length > 0) { nodes.push(state.schema.text(line)); } }); const fragment = Fragment.fromArray(nodes); const tr = state.tr.replaceWith(from, to, fragment); dispatch(tr.scrollIntoView()); event.preventDefault(); return true; } // Let ProseMirror handle normal text paste in non-problematic environments. return false; } // Delegate image paste handling to the parent component. const hasImageFile = Array.from(event.clipboardData.files).some((file) => file.type.startsWith('image/') ); // Fallback for cases where an image is in dataTransfer.items but not clipboardData.files. const hasImageItem = Array.from(event.clipboardData.items).some((item) => item.type.startsWith('image/') ); if (hasImageFile || hasImageItem) { eventDispatch('paste', { event }); event.preventDefault(); return true; } } // For all other cases, let ProseMirror perform its default paste behavior. view.dispatch(view.state.tr.scrollIntoView()); return false; } } } }); if (messageInput) { selectTemplate(); } }); onDestroy(() => { if (provider) { provider.destroy(); } if (editor) { editor.destroy(); } }); $: if (value !== null && editor && !collaboration) { onValueChange(); } const onValueChange = () => { 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.stringify(value) !== JSON.stringify(jsonValue)) { editor.commands.setContent(value); selectTemplate(); } } else { if (raw) { if (value !== htmlValue) { editor.commands.setContent(value); selectTemplate(); } } else { if (value !== mdValue) { editor.commands.setContent( preserveBreaks ? value : marked.parse(value.replaceAll(`\n<br/>`, `<br/>`), { breaks: false }) ); selectTemplate(); } } } }; </script> {#if showFormattingButtons} <div bind:this={bubbleMenuElement} class="p-0"> <FormattingButtons {editor} /> </div> <div bind:this={floatingMenuElement} class="p-0"> <FormattingButtons {editor} /> </div> {/if} <div bind:this={element} class="relative w-full min-w-full h-full min-h-fit {className}" />