| <script lang="ts"> |
| import { onDestroy, onMount } 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 { splitListItem, liftListItem, sinkListItem } from 'prosemirror-schema-list'; |
| import { keymap } from 'prosemirror-keymap'; |
| import { baseKeymap, chainCommands } from 'prosemirror-commands'; |
| import { DOMParser, DOMSerializer, Schema, Fragment } from 'prosemirror-model'; |
| |
| export let className = 'input-prose'; |
| export let shiftEnter = false; |
| |
| export let id = ''; |
| export let value = ''; |
| export let placeholder = 'Type here...'; |
| export let trim = false; |
| |
| let element: HTMLElement; |
| let state; |
| let view; |
| |
| |
| 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(/&/g, '&') |
| .replace(/</g, '<') |
| .replace(/>/g, '>') |
| .replace(/"/g, '"') |
| .replace(/'/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); |
| }); |
| } |
| }); |
| |
| |
| return schema.node('doc', null, paragraphs); |
| } |
| |
| |
| |
| 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 |
| } |
| |
| |
| }, |
| |
| |
| defaultMarkdownSerializer.marks |
| ); |
| |
| |
| function serializeEditorContent(doc) { |
| const markdown = customMarkdownSerializer.serialize(doc); |
| if (trim) { |
| return unescapeMarkdown(markdown).trim(); |
| } else { |
| return unescapeMarkdown(markdown); |
| } |
| } |
| |
| |
| |
| function headingRule(schema) { |
| return textblockTypeInputRule(/^(#{1,6})\s$/, schema.nodes.heading, (match) => ({ |
| level: match[1].length |
| })); |
| } |
| |
| |
| function bulletListRule(schema) { |
| return wrappingInputRule(/^\s*([-+*])\s$/, schema.nodes.bullet_list); |
| } |
| |
| |
| function orderedListRule(schema) { |
| return wrappingInputRule(/^(\d+)\.\s$/, schema.nodes.ordered_list, (match) => ({ |
| order: +match[1] |
| })); |
| } |
| |
| |
| 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); |
| } |
| |
| |
| 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(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 |
| ); |
| } |
| |
| function isEmptyListItem(state) { |
| const { $from } = state.selection; |
| return isInList(state) && $from.parent.content.size === 0 && $from.node(-1).childCount === 1; |
| } |
| |
| function exitList(state, dispatch) { |
| return liftListItem(schema.nodes.list_item)(state, dispatch); |
| } |
| |
| function findNextTemplate(doc, from = 0) { |
| const patterns = [ |
| { start: '[', end: ']' }, |
| { 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; |
| } |
| } |
| } |
| index++; |
| } |
| } |
| }); |
| |
| return result; |
| } |
| |
| 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); |
| } |
| return true; |
| } |
| return false; |
| } |
| |
| onMount(() => { |
| const initialDoc = markdownToProseMirrorDoc(value || ''); // Convert the initial content |
| |
| 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); |
| }, |
| |
| '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); |
| } |
| return false; |
| }, |
| |
| |
| 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; |
| }), |
| 'Shift-Tab': (state, dispatch, view) => { |
| const { $from } = state.selection; |
| if (isInList(state)) { |
| return liftListItem(schema.nodes.list_item)(state, dispatch); |
| } |
| return true; |
| }, |
| '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) { |
| // Check if the pasted content contains image files |
| const hasImageFile = Array.from(event.clipboardData.files).some((file) => |
| file.type.startsWith('image/') |
| ); |
| |
| // 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; |
| } |
| } |
| |
| |
| return false; |
| }, |
| |
| keyup: (view, event) => { |
| if (event.key === ' ' && event.code === 'Space') { |
| afterSpacePress(view.state, view.dispatch); |
| } |
| return false; |
| } |
| }, |
| attributes: { id } |
| }); |
| }); |
| |
| |
| $: 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); |
| } |
| } |
| |
| |
| onDestroy(() => { |
| view?.destroy(); |
| }); |
| </script> |
|
|
| <div bind:this={element} class="relative w-full min-w-full h-full min-h-fit {className}"></div> |
| |