|
<script lang="ts"> |
|
import { marked } from 'marked'; |
|
import TurndownService from 'turndown'; |
|
const turndownService = new TurndownService({ |
|
codeBlockStyle: 'fenced' |
|
}); |
|
turndownService.escape = (string) => string; |
|
|
|
import { onMount, onDestroy } from 'svelte'; |
|
import { createEventDispatcher } from 'svelte'; |
|
const eventDispatch = createEventDispatcher(); |
|
|
|
import { EditorState, Plugin, TextSelection } from 'prosemirror-state'; |
|
|
|
import { Editor } from '@tiptap/core'; |
|
|
|
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; |
|
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 { all, createLowlight } from 'lowlight'; |
|
|
|
import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants'; |
|
|
|
|
|
const lowlight = createLowlight(all); |
|
|
|
export let className = 'input-prose'; |
|
export let placeholder = 'Type here...'; |
|
export let value = ''; |
|
export let id = ''; |
|
|
|
export let messageInput = false; |
|
export let shiftEnter = false; |
|
export let largeTextAsFile = false; |
|
|
|
let element; |
|
let editor; |
|
|
|
const options = { |
|
throwOnError: false |
|
}; |
|
|
|
|
|
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; |
|
} |
|
|
|
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) { |
|
// 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); |
|
} |
|
}; |
|
|
|
onMount(async () => { |
|
async function tryParse(value, attempts = 3, interval = 100) { |
|
try { |
|
// Try parsing the value |
|
return marked.parse(value); |
|
} catch (error) { |
|
// If no attempts remain, fallback to plain text |
|
if (attempts <= 1) { |
|
return value; |
|
} |
|
|
|
await new Promise((resolve) => setTimeout(resolve, interval)); |
|
return tryParse(value, attempts - 1, interval); |
|
} |
|
} |
|
|
|
|
|
let content = await tryParse(value); |
|
|
|
editor = new Editor({ |
|
element: element, |
|
extensions: [ |
|
StarterKit, |
|
CodeBlockLowlight.configure({ |
|
lowlight |
|
}), |
|
Highlight, |
|
Typography, |
|
Placeholder.configure({ placeholder }) |
|
], |
|
content: content, |
|
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; |
|
|
|
if (value === '') { |
|
editor.commands.clearContent(); |
|
} |
|
} |
|
}, |
|
editorProps: { |
|
attributes: { id }, |
|
handleDOMEvents: { |
|
focus: (view, event) => { |
|
eventDispatch('focus', { event }); |
|
return false; |
|
}, |
|
keyup: (view, event) => { |
|
eventDispatch('keyup', { event }); |
|
return false; |
|
}, |
|
keydown: (view, event) => { |
|
// Handle Tab Key |
|
if (event.key === 'Tab') { |
|
const handled = selectNextTemplate(view.state, view.dispatch); |
|
if (handled) { |
|
event.preventDefault(); |
|
return true; |
|
} |
|
} |
|
|
|
if (messageInput) { |
|
if (event.key === 'Enter') { |
|
// Check if the current selection is inside a structured block (like codeBlock or list) |
|
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; |
|
} |
|
|
|
const isInCodeBlock = isInside(['codeBlock']); |
|
const isInList = isInside(['listItem', 'bulletList', 'orderedList']); |
|
const isInHeading = isInside(['heading']); |
|
|
|
if (isInCodeBlock || isInList || isInHeading) { |
|
// Let ProseMirror handle the normal Enter behavior |
|
return false; |
|
} |
|
} |
|
|
|
|
|
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) { |
|
// 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; |
|
} |
|
|
|
|
|
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; |
|
} |
|
} |
|
|
|
|
|
view.dispatch(view.state.tr.scrollIntoView()); |
|
return false; |
|
} |
|
} |
|
} |
|
}); |
|
|
|
selectTemplate(); |
|
}); |
|
|
|
onDestroy(() => { |
|
if (editor) { |
|
editor.destroy(); |
|
} |
|
}); |
|
|
|
|
|
$: 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}" /> |
|
|