| <script lang="ts"> |
| import { marked } from 'marked'; |
| import DOMPurify from 'dompurify'; |
| import equal from 'fast-deep-equal'; |
| |
| 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 '@joplin/turndown-plugin-gfm'; |
| const turndownService = new TurndownService({ |
| codeBlockStyle: 'fenced', |
| headingStyle: 'atx' |
| }); |
| turndownService.escape = (string) => string; |
| |
| |
| |
| |
| |
| |
| turndownService.addRule('singleNewlineParagraphs', { |
| filter: 'p', |
| replacement: function (content) { |
| return '\n' + content + '\n'; |
| } |
| }); |
| |
| |
| turndownService.use(gfm); |
| |
| |
| turndownService.addRule('tableHeaders', { |
| filter: 'th', |
| replacement: function (content, node) { |
| return content; |
| } |
| }); |
| |
| |
| turndownService.addRule('tables', { |
| filter: 'table', |
| replacement: function (content, node) { |
| |
| const rows = Array.from(node.querySelectorAll('tr')); |
| if (rows.length === 0) return content; |
| |
| let markdown = '\n'; |
| |
| rows.forEach((row, rowIndex) => { |
| const cells = Array.from(row.querySelectorAll('th, td')); |
| const cellContents = cells.map((cell) => { |
| |
| let cellContent = turndownService.turndown(cell.innerHTML).trim(); |
| |
| cellContent = cellContent.replace(/^\n+|\n+$/g, ''); |
| return cellContent; |
| }); |
| |
| |
| markdown += '| ' + cellContents.join(' | ') + ' |\n'; |
| |
| |
| if (rowIndex === 0) { |
| const separator = cells.map(() => '---').join(' | '); |
| markdown += '| ' + separator + ' |\n'; |
| } |
| }); |
| |
| return markdown + '\n'; |
| } |
| }); |
| |
| 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`; |
| } |
| }); |
| |
| |
| turndownService.addRule('mentions', { |
| filter: (node) => node.nodeName === 'SPAN' && node.getAttribute('data-type') === 'mention', |
| replacement: (_content, node: HTMLElement) => { |
| const id = node.getAttribute('data-id') || ''; |
| |
| const ch = node.getAttribute('data-mention-suggestion-char') || '@'; |
| |
| return `<${ch}${id}>`; |
| } |
| }); |
| |
| 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 { Decoration, DecorationSet } from 'prosemirror-view'; |
| import { Editor, Extension, markInputRule, mergeAttributes } from '@tiptap/core'; |
| |
| import { AIAutocompletion } from './RichTextInput/AutoCompletion.js'; |
| |
| import StarterKit from '@tiptap/starter-kit'; |
| |
| |
| |
| import BubbleMenu from '@tiptap/extension-bubble-menu'; |
| import FloatingMenu from '@tiptap/extension-floating-menu'; |
| |
| import { TableKit } from '@tiptap/extension-table'; |
| import { ListKit } from '@tiptap/extension-list'; |
| import { Placeholder, CharacterCount } from '@tiptap/extensions'; |
| |
| import Image from './RichTextInput/Image/index.js'; |
| |
| |
| import FileHandler from '@tiptap/extension-file-handler'; |
| import Typography from '@tiptap/extension-typography'; |
| import Highlight from '@tiptap/extension-highlight'; |
| import Code from '@tiptap/extension-code'; |
| import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; |
| |
| |
| |
| |
| |
| |
| const backtickInputRegex = /(?<=\s|^)`([^`]+)`(?!`)$/; |
| const FixedCode = Code.extend({ |
| addInputRules() { |
| return [ |
| markInputRule({ |
| find: backtickInputRegex, |
| type: this.type |
| }) |
| ]; |
| } |
| }); |
| |
| import Mention from '@tiptap/extension-mention'; |
| import FormattingButtons from './RichTextInput/FormattingButtons.svelte'; |
| |
| import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants'; |
| import { createLowlight } from 'lowlight'; |
| import hljs from 'highlight.js'; |
| |
| import type { SocketIOCollaborationProvider } from './RichTextInput/Collaboration'; |
| |
| export let oncompositionstart = (e) => {}; |
| export let oncompositionend = (e) => {}; |
| export let onChange = (e) => {}; |
| |
| |
| const lowlight = createLowlight( |
| hljs.listLanguages().reduce( |
| (obj, lang) => { |
| obj[lang] = () => hljs.getLanguage(lang); |
| return obj; |
| }, |
| {} as Record<string, any> |
| ) |
| ); |
| |
| export let editor: Editor | null = null; |
| |
| export let socket = null; |
| export let user = null; |
| export let files = []; |
| |
| export let documentId = ''; |
| |
| export let className = 'input-prose min-h-fit h-full'; |
| export let placeholder = $i18n.t('Type here...'); |
| let _placeholder = placeholder; |
| |
| $: if (placeholder !== _placeholder) { |
| setPlaceholder(); |
| } |
| |
| const setPlaceholder = () => { |
| _placeholder = placeholder; |
| if (editor) { |
| editor?.view.dispatch(editor.state.tr); |
| } |
| }; |
| |
| export let richText = true; |
| export let dragHandle = false; |
| export let link = false; |
| export let image = false; |
| export let fileHandler = false; |
| export let suggestions = null; |
| |
| 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) { |
| |
| |
| console.log(htmlContent); |
| 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 onSelectionUpdate = (e) => {}; |
| |
| 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 showFormattingToolbar = 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 = ''; |
| |
| let provider: SocketIOCollaborationProvider | null = null; |
| |
| let floatingMenuElement: Element | null = null; |
| let bubbleMenuElement: Element | null = null; |
| let element: Element | null = null; |
| |
| let pendingUpdate = null; |
| |
| 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; |
| }; |
| |
| |
| 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; |
| |
| |
| |
| |
| |
| const { start, end } = getWordBoundsAtPos(state.doc, pos); |
| |
| let tr = state.tr; |
| |
| if (insertPromptAsRichText) { |
| const htmlContent = DOMPurify.sanitize( |
| marked |
| .parse(text, { |
| breaks: true, |
| gfm: true |
| }) |
| .trim() |
| ); |
| |
| |
| const tempDiv = document.createElement('div'); |
| tempDiv.innerHTML = htmlContent; |
| |
| |
| const fragment = DOMParser.fromSchema(state.schema).parse(tempDiv); |
| |
| |
| const content = fragment.content; |
| let nodesToInsert = []; |
| |
| content.forEach((node) => { |
| if (node.type.name === 'paragraph') { |
| |
| nodesToInsert.push(...node.content.content); |
| } else { |
| nodesToInsert.push(node); |
| } |
| }); |
| |
| tr = tr.replaceWith(start, end, nodesToInsert); |
| |
| 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')) { |
| |
| const lines = text.split('\n'); |
| const nodes = lines.map( |
| (line, index) => |
| index === 0 |
| ? state.schema.text(line ? line : []) |
| : state.schema.nodes.paragraph.create({}, line ? state.schema.text(line) : undefined) |
| ); |
| |
| |
| tr = tr.replaceWith(start, end, nodes); |
| |
| let newSelectionPos; |
| |
| |
| let lastPos = start; |
| for (let i = 0; i < nodes.length; i++) { |
| lastPos += nodes[i].nodeSize; |
| } |
| |
| newSelectionPos = lastPos; |
| |
| tr = tr.setSelection(TextSelection.near(tr.doc.resolve(newSelectionPos))); |
| } else { |
| tr = tr.replaceWith( |
| start, |
| end, |
| text !== '' ? state.schema.text(text) : [] |
| ); |
| |
| tr = tr.setSelection( |
| state.selection.constructor.near(tr.doc.resolve(start + text.length + 1)) |
| ); |
| } |
| } |
| |
| dispatch(tr); |
| |
| await tick(); |
| |
| }; |
| |
| export const setText = (text: string) => { |
| if (!editor || !editor.view) return; |
| |
| if (text === '') { |
| editor.commands.clearContent(); |
| } else { |
| |
| const mentionReG = /<([@#$])([\w.\-:/]+)(?:\|([^>]*))?>/g; |
| |
| |
| |
| const lines = text.split('\n'); |
| const htmlContent = lines |
| .map((line) => { |
| if (!line) return '<p></p>'; |
| |
| |
| |
| const escaped = line.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); |
| |
| const withMentions = escaped.replace( |
| /<([@#$])([\w.\-:/]+)(?:\|([^&]*?))?>/g, |
| (_, ch, id, label) => { |
| const display = label?.length ? label : id; |
| return `<span class="mention" data-type="mention" data-id="${id}" data-label="${display}" data-mention-suggestion-char="${ch}">${ch}${display}</span>`; |
| } |
| ); |
| return `<p>${withMentions}</p>`; |
| }) |
| .join(''); |
| |
| editor.commands.setContent(htmlContent); |
| } |
| |
| selectNextTemplate(editor.view.state, editor.view.dispatch); |
| |
| |
| focus(); |
| }; |
| |
| export const insertContent = (content) => { |
| if (!editor || !editor.view) return; |
| const { state, view } = editor; |
| const { schema, tr } = state; |
| |
| |
| const htmlContent = marked.parse(content); |
| |
| |
| editor.commands.insertContent(htmlContent); |
| |
| focus(); |
| }; |
| |
| |
| const textToNodes = (state, text) => { |
| if (!text.includes('\n')) return state.schema.text(text); |
| const nodes = []; |
| text.split('\n').forEach((line, i) => { |
| if (i > 0) nodes.push(state.schema.nodes.hardBreak.create()); |
| if (line) nodes.push(state.schema.text(line)); |
| }); |
| return nodes; |
| }; |
| |
| export const replaceVariables = (variables) => { |
| if (!editor || !editor.view) return; |
| const { state, view } = editor; |
| const { doc } = state; |
| |
| |
| let tr = state.tr; |
| let offset = 0; |
| |
| |
| 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 |
| }); |
| } |
| } |
| }); |
| |
| |
| replacements.reverse().forEach(({ from, to, text }) => { |
| tr = tr.replaceWith(from, to, text !== '' ? textToNodes(state, text) : []); |
| }); |
| |
| |
| if (replacements.length > 0) { |
| view.dispatch(tr); |
| } |
| }; |
| |
| export const focus = () => { |
| if (editor && editor.view) { |
| |
| if (editor.isDestroyed) { |
| return; |
| } |
| |
| try { |
| editor.view.focus(); |
| |
| editor.view.dispatch(editor.view.state.tr.scrollIntoView()); |
| } catch (e) { |
| |
| console.warn('Error focusing editor', e); |
| } |
| } |
| }; |
| |
| |
| function findNextTemplate(doc, from = 0) { |
| const patterns = [{ start: '{{', end: '}}' }]; |
| |
| let result = null; |
| |
| doc.nodesBetween(from, doc.content.size, (node, pos) => { |
| if (result) return false; |
| 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; |
| } |
| } |
| } |
| index++; |
| } |
| } |
| }); |
| |
| return result; |
| } |
| |
| |
| function selectNextTemplate(state, dispatch) { |
| const { doc, selection } = state; |
| const from = selection.to; |
| let template = findNextTemplate(doc, from); |
| |
| if (!template) { |
| |
| template = findNextTemplate(doc, 0); |
| } |
| |
| if (template) { |
| if (dispatch) { |
| const tr = state.tr.setSelection(TextSelection.create(doc, template.from, template.to)); |
| dispatch(tr); |
| |
| |
| dispatch( |
| tr.scrollIntoView().setMeta('preventScroll', true) |
| ); |
| } |
| return true; |
| } |
| return false; |
| } |
| |
| export const setContent = (content) => { |
| editor.commands.setContent(content); |
| }; |
| |
| const selectTemplate = () => { |
| if (value !== '') { |
| |
| setTimeout(() => { |
| const templateFound = selectNextTemplate(editor.view.state, editor.view.dispatch); |
| if (!templateFound) { |
| editor.commands.focus('end'); |
| } |
| }, 0); |
| } |
| }; |
| |
| const SelectionDecoration = Extension.create({ |
| name: 'selectionDecoration', |
| addProseMirrorPlugins() { |
| return [ |
| new Plugin({ |
| key: new PluginKey('selection'), |
| props: { |
| decorations: (state) => { |
| const { selection } = state; |
| const { focused } = this.editor; |
| |
| if (focused || selection.empty) { |
| return null; |
| } |
| |
| return DecorationSet.create(state.doc, [ |
| Decoration.inline(selection.from, selection.to, { |
| class: 'editor-selection' |
| }) |
| ]); |
| } |
| } |
| }) |
| ]; |
| } |
| }); |
| |
| import { listDragHandlePlugin } from './RichTextInput/listDragHandlePlugin.js'; |
| |
| const ListItemDragHandle = Extension.create({ |
| name: 'listItemDragHandle', |
| addProseMirrorPlugins() { |
| return [ |
| listDragHandlePlugin({ |
| itemTypeNames: ['listItem', 'taskItem'], |
| getEditor: () => this.editor |
| }) |
| ]; |
| } |
| }); |
| |
| onMount(async () => { |
| content = value; |
| |
| if (json) { |
| if (!content) { |
| content = html ? html : null; |
| } |
| } else { |
| if (preserveBreaks) { |
| turndownService.addRule('preserveBreaks', { |
| filter: 'br', |
| replacement: function (content) { |
| return '<br/>'; |
| } |
| }); |
| } |
| |
| if (!raw) { |
| async function tryParse(value, attempts = 3, interval = 100) { |
| try { |
| |
| return marked.parse(value.replaceAll(`\n<br/>`, `<br/>`), { |
| breaks: false |
| }); |
| } catch (error) { |
| |
| if (attempts <= 1) { |
| return value; |
| } |
| |
| await new Promise((resolve) => setTimeout(resolve, interval)); |
| return tryParse(value, attempts - 1, interval); |
| } |
| } |
| |
| |
| content = await tryParse(value); |
| } |
| } |
| |
| if (collaboration && documentId && socket && user) { |
| const { SocketIOCollaborationProvider } = await import('./RichTextInput/Collaboration'); |
| provider = new SocketIOCollaborationProvider(documentId, socket, user, content); |
| } |
| editor = new Editor({ |
| element: element, |
| extensions: [ |
| StarterKit.configure({ |
| link: link, |
| code: false, |
| |
| |
| ...(richText |
| ? { |
| codeBlock: false, |
| bulletList: false, |
| orderedList: false, |
| listItem: false, |
| listKeymap: false |
| } |
| : {}), |
| |
| |
| |
| |
| ...(richText ? {} : { strike: false }) |
| }), |
| FixedCode, |
| ...(dragHandle ? [ListItemDragHandle] : []), |
| Placeholder.configure({ placeholder: () => _placeholder, showOnlyWhenEditable: false }), |
| SelectionDecoration, |
| |
| ...(richText |
| ? [ |
| CodeBlockLowlight.configure({ |
| lowlight |
| }), |
| Typography, |
| TableKit.configure({ |
| table: { resizable: true } |
| }), |
| ListKit.configure({ |
| taskItem: { |
| nested: true |
| } |
| }) |
| ] |
| : []), |
| ...(suggestions |
| ? [ |
| Mention.configure({ |
| HTMLAttributes: { class: 'mention' }, |
| suggestions: suggestions |
| }) |
| ] |
| : []), |
| |
| CharacterCount.configure({}), |
| ...(image ? [Image] : []), |
| ...(fileHandler |
| ? [ |
| FileHandler.configure({ |
| onDrop: onFileDrop, |
| onPaste: onFilePaste |
| }) |
| ] |
| : []), |
| ...(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; |
| } |
| }) |
| ] |
| : []), |
| ...(richText && showFormattingToolbar |
| ? [ |
| BubbleMenu.configure({ |
| element: bubbleMenuElement, |
| appendTo: () => document.body, |
| options: { |
| strategy: 'fixed', |
| placement: 'top', |
| offset: 2 |
| }, |
| shouldShow: ({ editor, view, state, oldState, from, to }) => { |
| |
| if (!editor || !editor.view || editor.isDestroyed) { |
| return false; |
| } |
| |
| return view.hasFocus() && from !== to; |
| } |
| }), |
| FloatingMenu.configure({ |
| element: floatingMenuElement, |
| appendTo: () => document.body, |
| options: { |
| strategy: 'fixed', |
| placement: floatingMenuPlacement, |
| offset: 4 |
| }, |
| shouldShow: ({ editor, view, state, oldState }) => { |
| |
| if (!editor || !editor.view || editor.isDestroyed) { |
| return false; |
| } |
| const { selection } = state; |
| const { $anchor, empty } = selection; |
| const isRootDepth = $anchor.depth === 1; |
| const isEmptyTextBlock = |
| $anchor.parent.isTextblock && |
| !$anchor.parent.type.spec.code && |
| !$anchor.parent.textContent && |
| $anchor.parent.childCount === 0; |
| |
| |
| return ( |
| view.hasFocus() && empty && isRootDepth && isEmptyTextBlock && editor.isEditable |
| ); |
| } |
| }) |
| ] |
| : []), |
| ...(collaboration && provider ? [provider.getEditorExtension()] : []) |
| ], |
| content: collaboration ? undefined : content, |
| autofocus: messageInput ? true : false, |
| onTransaction: () => { |
| if (!editor) return; |
| |
| |
| |
| if (!pendingUpdate) { |
| pendingUpdate = requestAnimationFrame(() => { |
| pendingUpdate = null; |
| if (editor && !editor.isDestroyed) { |
| editor = editor; |
| } |
| }); |
| } |
| |
| htmlValue = editor.getHTML(); |
| jsonValue = editor.getJSON(); |
| |
| if (richText) { |
| mdValue = turndownService |
| .turndown( |
| htmlValue |
| .replace(/<p><\/p>/g, '<br/>') |
| .replace(/ {2,}/g, (m) => m.replace(/ /g, '\u00a0')) |
| ) |
| .replace(/\u00a0/g, ' '); |
| } else { |
| mdValue = turndownService |
| .turndown( |
| htmlValue |
| |
| .replace(/<p><\/p>/g, '<br/>') |
| |
| .replace(/ {2,}/g, (m) => m.replace(/ /g, '\u00a0')) |
| |
| .replace(/\t/g, '\u00a0\u00a0\u00a0\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; |
| |
| |
| if (editor.isActive('paragraph')) { |
| if (value === '') { |
| editor.commands.clearContent(); |
| } |
| } |
| } |
| } |
| } |
| }, |
| editorProps: { |
| attributes: { id }, |
| handleDrop: (view, event) => { |
| |
| |
| |
| const textData = event.dataTransfer?.getData('text/plain'); |
| if (textData) { |
| try { |
| const data = JSON.parse(textData); |
| if (data.type === 'chat' && data.id) { |
| |
| return true; |
| } |
| } catch (_) { |
| |
| } |
| } |
| return false; |
| }, |
| handlePaste: (view, event) => { |
| |
| if (!richText) { |
| |
| event.preventDefault(); |
| const { state, dispatch } = view; |
| |
| const plainText = (event.clipboardData?.getData('text/plain') ?? '').replace( |
| /\r\n/g, |
| '\n' |
| ); |
| |
| 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); |
| dispatch(state.tr.replaceSelectionWith(fragment, false).scrollIntoView()); |
| |
| return true; |
| } |
| |
| return false; |
| }, |
| handleDOMEvents: { |
| compositionstart: (view, event) => { |
| oncompositionstart(event); |
| return false; |
| }, |
| compositionend: (view, event) => { |
| oncompositionend(event); |
| return false; |
| }, |
| beforeinput: (view, event) => { |
| |
| |
| |
| const isAndroid = /Android/i.test(navigator.userAgent); |
| if (isAndroid && event.inputType === 'insertText' && event.data?.includes('\n')) { |
| event.preventDefault(); |
| |
| const { state, dispatch } = view; |
| const { from, to } = state.selection; |
| const lines = event.data.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); |
| dispatch(state.tr.replaceWith(from, to, fragment).scrollIntoView()); |
| return true; |
| } |
| return false; |
| }, |
| focus: (view, event) => { |
| eventDispatch('focus', { event }); |
| return false; |
| }, |
| keyup: (view, event) => { |
| eventDispatch('keyup', { event }); |
| return false; |
| }, |
| keydown: (view, event) => { |
| if (messageInput) { |
| |
| const { state } = view; |
| const { $head } = state.selection; |
| |
| |
| function isInside(nodeTypes: string[]): boolean { |
| let currentNode = $head; |
| while (currentNode) { |
| if (nodeTypes.includes(currentNode.parent.type.name)) { |
| return true; |
| } |
| if (!currentNode.depth) break; |
| currentNode = state.doc.resolve(currentNode.before()); |
| } |
| return false; |
| } |
| |
| |
| if (event.key === 'Tab') { |
| const isInCodeBlock = isInside(['codeBlock']); |
| |
| if (isInCodeBlock) { |
| |
| const tabChar = '\t'; |
| editor.commands.insertContent(tabChar); |
| event.preventDefault(); |
| return true; |
| } else { |
| const handled = selectNextTemplate(view.state, view.dispatch); |
| if (handled) { |
| event.preventDefault(); |
| return true; |
| } |
| } |
| } |
| |
| if (event.key === 'Enter') { |
| const isCtrlPressed = event.ctrlKey || event.metaKey; |
| |
| const { state } = view; |
| const { $from } = state.selection; |
| const lineStart = $from.before($from.depth); |
| const lineEnd = $from.after($from.depth); |
| const lineText = state.doc.textBetween(lineStart, lineEnd, '\n', '\0').trim(); |
| if (event.shiftKey && !isCtrlPressed) { |
| if (lineText.startsWith('```')) { |
| |
| return false; |
| } |
| |
| editor.commands.enter(); |
| view.dispatch(view.state.tr.scrollIntoView()); |
| event.preventDefault(); |
| return true; |
| } else { |
| const isInCodeBlock = isInside(['codeBlock']); |
| const isInList = isInside(['listItem', 'bulletList', 'orderedList', 'taskList']); |
| const isInHeading = isInside(['heading']); |
| |
| console.log({ isInCodeBlock, isInList, isInHeading }); |
| |
| if (isInCodeBlock || isInList || isInHeading) { |
| |
| return false; |
| } |
| |
| const suggestionsElement = document.getElementById('suggestions-container'); |
| if (lineText.startsWith('#') && suggestionsElement) { |
| console.log('Letting heading suggestion handle Enter key'); |
| return true; |
| } |
| } |
| } |
| |
| |
| if (shiftEnter) { |
| if (event.key === 'Enter' && event.shiftKey && !event.ctrlKey && !event.metaKey) { |
| editor.commands.setHardBreak(); |
| view.dispatch(view.state.tr.scrollIntoView()); |
| 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) { |
| |
| eventDispatch('paste', { event }); |
| event.preventDefault(); |
| return true; |
| } |
| |
| |
| |
| const isMobile = /Android|iPhone|iPad|iPod|Windows Phone/i.test( |
| navigator.userAgent |
| ); |
| const isWebView = |
| typeof window !== 'undefined' && |
| (/wv/i.test(navigator.userAgent) || |
| (navigator.userAgent.includes('Android') && |
| !navigator.userAgent.includes('Chrome')) || |
| (navigator.userAgent.includes('Safari') && |
| !navigator.userAgent.includes('Version'))); |
| |
| if (isMobile && isWebView && plainText.includes('\n')) { |
| |
| |
| 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; |
| } |
| |
| return false; |
| } |
| |
| |
| const hasImageFile = Array.from(event.clipboardData.files).some((file) => |
| file.type.startsWith('image/') |
| ); |
| |
| const hasImageItem = Array.from(event.clipboardData.items).some((item) => |
| item.type.startsWith('image/') |
| ); |
| |
| const hasFile = Array.from(event.clipboardData.files).length > 0; |
| |
| if (hasImageFile || hasImageItem || hasFile) { |
| eventDispatch('paste', { event }); |
| event.preventDefault(); |
| return true; |
| } |
| } |
| |
| view.dispatch(view.state.tr.scrollIntoView()); |
| return false; |
| }, |
| copy: (view, event: ClipboardEvent) => { |
| if (!event.clipboardData) return false; |
| if (richText) return false; |
| |
| const { state } = view; |
| const { from, to } = state.selection; |
| |
| |
| const plain = state.doc.textBetween(from, to, '\n'); |
| const slice = state.doc.cut(from, to); |
| const html = editor.schema ? editor.getHTML(slice) : editor.getHTML(); |
| |
| event.clipboardData.setData('text/plain', plain); |
| event.clipboardData.setData('text/html', html); |
| |
| event.preventDefault(); |
| return true; |
| } |
| } |
| }, |
| onBeforeCreate: ({ editor }) => { |
| if (files) { |
| editor.storage.files = files; |
| } |
| }, |
| onSelectionUpdate: onSelectionUpdate, |
| onBlur: () => { |
| |
| |
| if (bubbleMenuElement) { |
| bubbleMenuElement.style.visibility = 'hidden'; |
| bubbleMenuElement.style.opacity = '0'; |
| } |
| if (floatingMenuElement) { |
| floatingMenuElement.style.visibility = 'hidden'; |
| floatingMenuElement.style.opacity = '0'; |
| } |
| }, |
| enableInputRules: richText, |
| enablePasteRules: richText |
| }); |
| |
| provider?.setEditor(editor, () => ({ md: mdValue, html: htmlValue, json: jsonValue })); |
| |
| if (messageInput) { |
| selectTemplate(); |
| } |
| }); |
| |
| onDestroy(() => { |
| if (pendingUpdate) { |
| cancelAnimationFrame(pendingUpdate); |
| } |
| |
| 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(); |
| selectTemplate(); |
| |
| return; |
| } |
| |
| if (json) { |
| if (!equal(value, 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 richText && showFormattingToolbar} |
| <div |
| bind:this={bubbleMenuElement} |
| id="bubble-menu" |
| class="p-0" |
| style="visibility: hidden; opacity: 0; position: absolute; z-index: 9999;" |
| > |
| <FormattingButtons {editor} /> |
| </div> |
|
|
| <div |
| bind:this={floatingMenuElement} |
| id="floating-menu" |
| class="p-0" |
| style="visibility: hidden; opacity: 0; position: absolute; z-index: 9999;" |
| > |
| <FormattingButtons {editor} /> |
| </div> |
| {/if} |
|
|
| <div |
| bind:this={element} |
| dir="auto" |
| class="relative w-full min-w-full {className} {!editable ? 'cursor-not-allowed' : ''}" |
| /> |
|
|