|
<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; |
|
} |
|
|
|
|
|
function handleTabIndentation(text: string): string { |
|
// Replace each tab character with four spaces |
|
return text.replace(/\t/g, ' '); |
|
} |
|
|
|
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) { |
|
// Extract plain text from clipboard and paste it without formatting |
|
const plainText = event.clipboardData.getData('text/plain'); |
|
if (plainText) { |
|
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; |
|
} |
|
|
|
|
|
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/') |
|
); |
|
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> |
|
|