mirror of
https://github.com/open-webui/open-webui
synced 2025-05-19 20:57:54 +00:00
refac: rich text input
This commit is contained in:
parent
9b03b1a453
commit
e30c5e628c
895
package-lock.json
generated
895
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -37,6 +37,7 @@
|
|||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.31",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"prettier-plugin-svelte": "^3.2.6",
|
"prettier-plugin-svelte": "^3.2.6",
|
||||||
|
"sass-embedded": "^1.81.0",
|
||||||
"svelte": "^4.2.18",
|
"svelte": "^4.2.18",
|
||||||
"svelte-check": "^3.8.5",
|
"svelte-check": "^3.8.5",
|
||||||
"svelte-confetti": "^1.3.2",
|
"svelte-confetti": "^1.3.2",
|
||||||
@ -56,6 +57,12 @@
|
|||||||
"@mediapipe/tasks-vision": "^0.10.17",
|
"@mediapipe/tasks-vision": "^0.10.17",
|
||||||
"@pyscript/core": "^0.4.32",
|
"@pyscript/core": "^0.4.32",
|
||||||
"@sveltejs/adapter-node": "^2.0.0",
|
"@sveltejs/adapter-node": "^2.0.0",
|
||||||
|
"@tiptap/core": "^2.10.0",
|
||||||
|
"@tiptap/extension-highlight": "^2.10.0",
|
||||||
|
"@tiptap/extension-placeholder": "^2.10.0",
|
||||||
|
"@tiptap/extension-typography": "^2.10.0",
|
||||||
|
"@tiptap/pm": "^2.10.0",
|
||||||
|
"@tiptap/starter-kit": "^2.10.0",
|
||||||
"@xyflow/svelte": "^0.1.19",
|
"@xyflow/svelte": "^0.1.19",
|
||||||
"async": "^3.2.5",
|
"async": "^3.2.5",
|
||||||
"bits-ui": "^0.19.7",
|
"bits-ui": "^0.19.7",
|
||||||
|
29
src/app.css
29
src/app.css
@ -199,19 +199,34 @@ input[type='number'] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror {
|
.ProseMirror {
|
||||||
@apply h-full min-h-fit max-h-full whitespace-pre-wrap;
|
@apply h-full min-h-fit max-h-full whitespace-pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror:focus {
|
.ProseMirror:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder::after {
|
.ProseMirror p.is-editor-empty:first-child::before {
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
cursor: text;
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
float: left;
|
float: left;
|
||||||
|
color: #adb5bd;
|
||||||
@apply absolute inset-0 z-0 text-gray-500;
|
pointer-events: none;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap > pre > code {
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.25em 0.3em;
|
||||||
|
|
||||||
|
@apply dark:bg-gray-800 bg-gray-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap > pre {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-family: 'JetBrainsMono', monospace;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
|
||||||
|
@apply dark:bg-gray-800 bg-gray-100;
|
||||||
}
|
}
|
||||||
|
@ -75,14 +75,6 @@
|
|||||||
(model) => $models.find((m) => m.id === model)?.info?.meta?.capabilities?.vision ?? true
|
(model) => $models.find((m) => m.id === model)?.info?.meta?.capabilities?.vision ?? true
|
||||||
);
|
);
|
||||||
|
|
||||||
$: if (prompt) {
|
|
||||||
if (chatInputContainerElement) {
|
|
||||||
chatInputContainerElement.style.height = '';
|
|
||||||
chatInputContainerElement.style.height =
|
|
||||||
Math.min(chatInputContainerElement.scrollHeight, 200) + 'px';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
const element = document.getElementById('messages-container');
|
const element = document.getElementById('messages-container');
|
||||||
element.scrollTo({
|
element.scrollTo({
|
||||||
@ -585,54 +577,47 @@
|
|||||||
|
|
||||||
{#if $settings?.richTextInput ?? true}
|
{#if $settings?.richTextInput ?? true}
|
||||||
<div
|
<div
|
||||||
bind:this={chatInputContainerElement}
|
class="scrollbar-hidden text-left bg-gray-50 dark:bg-gray-850 dark:text-gray-100 outline-none w-full py-2.5 px-1 rounded-xl resize-none h-fit max-h-60 overflow-auto"
|
||||||
id="chat-input-container"
|
|
||||||
class="scrollbar-hidden text-left bg-gray-50 dark:bg-gray-850 dark:text-gray-100 outline-none w-full py-2.5 px-1 rounded-xl resize-none h-[48px] overflow-auto"
|
|
||||||
>
|
>
|
||||||
<RichTextInput
|
<RichTextInput
|
||||||
bind:this={chatInputElement}
|
bind:this={chatInputElement}
|
||||||
id="chat-input"
|
id="chat-input"
|
||||||
trim={true}
|
messageInput={true}
|
||||||
placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
|
|
||||||
largeTextAsFile={$settings?.largeTextAsFile ?? false}
|
|
||||||
bind:value={prompt}
|
|
||||||
shiftEnter={!$mobile ||
|
shiftEnter={!$mobile ||
|
||||||
!(
|
!(
|
||||||
'ontouchstart' in window ||
|
'ontouchstart' in window ||
|
||||||
navigator.maxTouchPoints > 0 ||
|
navigator.maxTouchPoints > 0 ||
|
||||||
navigator.msMaxTouchPoints > 0
|
navigator.msMaxTouchPoints > 0
|
||||||
)}
|
)}
|
||||||
|
placeholder={placeholder ? placeholder : $i18n.t('Send a Message')}
|
||||||
|
largeTextAsFile={$settings?.largeTextAsFile ?? false}
|
||||||
|
bind:value={prompt}
|
||||||
on:enter={async (e) => {
|
on:enter={async (e) => {
|
||||||
|
const commandsContainerElement =
|
||||||
|
document.getElementById('commands-container');
|
||||||
|
if (commandsContainerElement) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const commandOptionButton = [
|
||||||
|
...document.getElementsByClassName('selected-command-option-button')
|
||||||
|
]?.at(-1);
|
||||||
|
|
||||||
|
if (commandOptionButton) {
|
||||||
|
commandOptionButton?.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (prompt !== '') {
|
if (prompt !== '') {
|
||||||
dispatch('submit', prompt);
|
dispatch('submit', prompt);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
on:input={async (e) => {
|
|
||||||
if (chatInputContainerElement) {
|
|
||||||
chatInputContainerElement.style.height = '';
|
|
||||||
chatInputContainerElement.style.height =
|
|
||||||
Math.min(chatInputContainerElement.scrollHeight, 200) + 'px';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
on:focus={async (e) => {
|
|
||||||
if (chatInputContainerElement) {
|
|
||||||
chatInputContainerElement.style.height = '';
|
|
||||||
chatInputContainerElement.style.height =
|
|
||||||
Math.min(chatInputContainerElement.scrollHeight, 200) + 'px';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
on:keypress={(e) => {
|
on:keypress={(e) => {
|
||||||
e = e.detail.event;
|
e = e.detail.event;
|
||||||
}}
|
}}
|
||||||
on:keydown={async (e) => {
|
on:keydown={async (e) => {
|
||||||
e = e.detail.event;
|
e = e.detail.event;
|
||||||
|
|
||||||
if (chatInputContainerElement) {
|
|
||||||
chatInputContainerElement.style.height = '';
|
|
||||||
chatInputContainerElement.style.height =
|
|
||||||
Math.min(chatInputContainerElement.scrollHeight, 200) + 'px';
|
|
||||||
}
|
|
||||||
|
|
||||||
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
|
const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
|
||||||
const commandsContainerElement =
|
const commandsContainerElement =
|
||||||
document.getElementById('commands-container');
|
document.getElementById('commands-container');
|
||||||
@ -692,22 +677,6 @@
|
|||||||
commandOptionButton.scrollIntoView({ block: 'center' });
|
commandOptionButton.scrollIntoView({ block: 'center' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (commandsContainerElement && e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const commandOptionButton = [
|
|
||||||
...document.getElementsByClassName('selected-command-option-button')
|
|
||||||
]?.at(-1);
|
|
||||||
|
|
||||||
if (e.shiftKey) {
|
|
||||||
prompt = `${prompt}\n`;
|
|
||||||
} else if (commandOptionButton) {
|
|
||||||
commandOptionButton?.click();
|
|
||||||
} else {
|
|
||||||
document.getElementById('send-message-button')?.click();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (commandsContainerElement && e.key === 'Tab') {
|
if (commandsContainerElement && e.key === 'Tab') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
@ -1,241 +1,36 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { marked } from 'marked';
|
||||||
|
import TurndownService from 'turndown';
|
||||||
|
const turndownService = new TurndownService();
|
||||||
|
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
const eventDispatch = createEventDispatcher();
|
const eventDispatch = createEventDispatcher();
|
||||||
|
|
||||||
import { EditorState, Plugin, TextSelection } from 'prosemirror-state';
|
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 {
|
import { Editor } from '@tiptap/core';
|
||||||
inputRules,
|
|
||||||
wrappingInputRule,
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
textblockTypeInputRule,
|
import Highlight from '@tiptap/extension-highlight';
|
||||||
InputRule
|
import Typography from '@tiptap/extension-typography';
|
||||||
} from 'prosemirror-inputrules'; // Import input rules
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
import { splitListItem, liftListItem, sinkListItem } from 'prosemirror-schema-list'; // Import from prosemirror-schema-list
|
|
||||||
import { keymap } from 'prosemirror-keymap';
|
|
||||||
import { baseKeymap, chainCommands } from 'prosemirror-commands';
|
|
||||||
import { DOMParser, DOMSerializer, Schema, Fragment } from 'prosemirror-model';
|
|
||||||
import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
|
import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
|
||||||
|
|
||||||
export let className = 'input-prose';
|
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 shiftEnter = false;
|
||||||
export let largeTextAsFile = false;
|
export let largeTextAsFile = false;
|
||||||
|
|
||||||
export let id = '';
|
let element;
|
||||||
export let value = '';
|
let editor;
|
||||||
export let placeholder = 'Type here...';
|
|
||||||
export let trim = false;
|
|
||||||
|
|
||||||
let element: HTMLElement; // Element where ProseMirror will attach
|
|
||||||
let state;
|
|
||||||
let view;
|
|
||||||
|
|
||||||
// Plugin to add placeholder when the content is empty
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a new document with these paragraphs
|
|
||||||
return schema.node('doc', null, paragraphs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a custom serializer for paragraphs
|
|
||||||
// Custom paragraph serializer to preserve newlines for empty paragraphs (empty block).
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Customize other block formats if needed
|
|
||||||
},
|
|
||||||
|
|
||||||
// Copy marks directly from the original serializer (or customize them if necessary)
|
|
||||||
defaultMarkdownSerializer.marks
|
|
||||||
);
|
|
||||||
|
|
||||||
// Utility function to convert ProseMirror content back to markdown text
|
|
||||||
function serializeEditorContent(doc) {
|
|
||||||
const markdown = customMarkdownSerializer.serialize(doc);
|
|
||||||
if (trim) {
|
|
||||||
return unescapeMarkdown(markdown).trim();
|
|
||||||
} else {
|
|
||||||
return unescapeMarkdown(markdown);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Input Rules ----
|
|
||||||
// Input rule for heading (e.g., # Headings)
|
|
||||||
function headingRule(schema) {
|
|
||||||
return textblockTypeInputRule(/^(#{1,6})\s$/, schema.nodes.heading, (match) => ({
|
|
||||||
level: match[1].length
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Input rule for bullet list (e.g., `- item`)
|
|
||||||
function bulletListRule(schema) {
|
|
||||||
return wrappingInputRule(/^\s*([-+*])\s$/, schema.nodes.bullet_list);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Input rule for ordered list (e.g., `1. item`)
|
|
||||||
function orderedListRule(schema) {
|
|
||||||
return wrappingInputRule(/^(\d+)\.\s$/, schema.nodes.ordered_list, (match) => ({
|
|
||||||
order: +match[1]
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom input rules for Bold/Italic (using * or _)
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize Editor State and View
|
|
||||||
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 the resulting transaction to update the editor state
|
|
||||||
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 to find the next template in the document
|
||||||
function findNextTemplate(doc, from = 0) {
|
function findNextTemplate(doc, from = 0) {
|
||||||
const patterns = [
|
const patterns = [
|
||||||
{ start: '[', end: ']' },
|
{ start: '[', end: ']' },
|
||||||
@ -270,6 +65,7 @@
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Function to select the next template in the document
|
||||||
function selectNextTemplate(state, dispatch) {
|
function selectNextTemplate(state, dispatch) {
|
||||||
const { doc, selection } = state;
|
const { doc, selection } = state;
|
||||||
const from = selection.to;
|
const from = selection.to;
|
||||||
@ -290,220 +86,150 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace tabs with four spaces
|
export const setContent = (content) => {
|
||||||
function handleTabIndentation(text: string): string {
|
editor.commands.setContent(content);
|
||||||
// Replace each tab character with four spaces
|
};
|
||||||
return text.replace(/\t/g, ' ');
|
|
||||||
}
|
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(() => {
|
onMount(() => {
|
||||||
const initialDoc = markdownToProseMirrorDoc(value || ''); // Convert the initial content
|
editor = new Editor({
|
||||||
|
element: element,
|
||||||
|
extensions: [StarterKit, Highlight, Typography, Placeholder.configure({ placeholder })],
|
||||||
|
content: marked.parse(value),
|
||||||
|
autofocus: true,
|
||||||
|
onTransaction: () => {
|
||||||
|
// force re-render so `editor.isActive` works as expected
|
||||||
|
editor = editor;
|
||||||
|
|
||||||
state = EditorState.create({
|
const newValue = turndownService.turndown(editor.getHTML());
|
||||||
doc: initialDoc,
|
if (value !== newValue) {
|
||||||
schema,
|
value = newValue; // Trigger parent updates
|
||||||
plugins: [
|
}
|
||||||
history(),
|
},
|
||||||
placeholderPlugin(placeholder),
|
editorProps: {
|
||||||
inputRules({
|
attributes: { id },
|
||||||
rules: [
|
handleDOMEvents: {
|
||||||
headingRule(schema), // Handle markdown-style headings (# H1, ## H2, etc.)
|
focus: (view, event) => {
|
||||||
bulletListRule(schema), // Handle `-` or `*` input to start bullet list
|
eventDispatch('focus', { event });
|
||||||
orderedListRule(schema), // Handle `1.` input to start ordered list
|
return false;
|
||||||
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);
|
|
||||||
},
|
},
|
||||||
|
keypress: (view, event) => {
|
||||||
'Shift-Enter': (state, dispatch, view) => {
|
eventDispatch('keypress', { event });
|
||||||
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;
|
return false;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Prevent default tab navigation and provide indent/outdent behavior inside lists:
|
keydown: (view, event) => {
|
||||||
Tab: chainCommands((state, dispatch, view) => {
|
// Handle Tab Key
|
||||||
const { $from } = state.selection;
|
if (event.key === 'Tab') {
|
||||||
if (isInList(state)) {
|
const handled = selectNextTemplate(view.state, view.dispatch);
|
||||||
return sinkListItem(schema.nodes.list_item)(state, dispatch);
|
if (handled) {
|
||||||
} else {
|
event.preventDefault();
|
||||||
return selectNextTemplate(state, dispatch);
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true; // Prevent Tab from moving the focus
|
|
||||||
}),
|
|
||||||
'Shift-Tab': (state, dispatch, view) => {
|
|
||||||
const { $from } = state.selection;
|
|
||||||
if (isInList(state)) {
|
|
||||||
return liftListItem(schema.nodes.list_item)(state, dispatch);
|
|
||||||
}
|
|
||||||
return true; // Prevent Shift-Tab from moving the focus
|
|
||||||
},
|
|
||||||
'Mod-b': toggleMark(schema.marks.strong),
|
|
||||||
'Mod-i': toggleMark(schema.marks.em)
|
|
||||||
})
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
view = new EditorView(element, {
|
if (messageInput) {
|
||||||
state,
|
// Handle shift + Enter for a line break
|
||||||
dispatchTransaction(transaction) {
|
if (shiftEnter) {
|
||||||
// Update editor state
|
if (event.key === 'Enter' && event.shiftKey) {
|
||||||
let newState = view.state.apply(transaction);
|
editor.commands.setHardBreak(); // Insert a hard break
|
||||||
view.updateState(newState);
|
event.preventDefault();
|
||||||
|
return true;
|
||||||
value = serializeEditorContent(newState.doc); // Convert ProseMirror content to markdown text
|
}
|
||||||
eventDispatch('input', { value });
|
if (event.key === 'Enter') {
|
||||||
},
|
eventDispatch('enter', { event });
|
||||||
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) {
|
|
||||||
if (largeTextAsFile) {
|
|
||||||
if (plainText.length > PASTED_TEXT_CHARACTER_LIMIT) {
|
|
||||||
// Dispatch paste event to parent component
|
|
||||||
eventDispatch('paste', { event });
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const modifiedText = handleTabIndentation(plainText);
|
if (event.key === 'Enter') {
|
||||||
console.log(modifiedText);
|
eventDispatch('enter', { event });
|
||||||
|
event.preventDefault();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Replace the current selection with the plain text content
|
eventDispatch('keydown', { event });
|
||||||
const tr = view.state.tr.replaceSelectionWith(
|
return false;
|
||||||
view.state.schema.text(modifiedText),
|
},
|
||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the pasted content contains image files
|
||||||
|
const hasImageFile = Array.from(event.clipboardData.files).some((file) =>
|
||||||
|
file.type.startsWith('image/')
|
||||||
);
|
);
|
||||||
view.dispatch(tr.scrollIntoView());
|
|
||||||
event.preventDefault(); // Prevent the default paste behavior
|
// Check for image in dataTransfer items (for cases where files are not available)
|
||||||
return true;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the pasted content contains image files
|
// For all other cases (text, formatted text, etc.), let ProseMirror handle it
|
||||||
const hasImageFile = Array.from(event.clipboardData.files).some((file) =>
|
view.dispatch(view.state.tr.scrollIntoView()); // Move viewport to the cursor after pasting
|
||||||
file.type.startsWith('image/')
|
return false;
|
||||||
);
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For all other cases (text, formatted text, etc.), let ProseMirror handle it
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
// Handle space input after browser has completed it
|
|
||||||
keyup: (view, event) => {
|
|
||||||
if (event.key === ' ' && event.code === 'Space') {
|
|
||||||
afterSpacePress(view.state, view.dispatch);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
attributes: { id }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
selectTemplate();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reinitialize the editor if the value is externally changed (i.e. when `value` is updated)
|
|
||||||
$: 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Destroy ProseMirror instance on unmount
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
view?.destroy();
|
if (editor) {
|
||||||
|
editor.destroy();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update the editor content if the external `value` changes
|
||||||
|
$: if (editor && value !== turndownService.turndown(editor.getHTML())) {
|
||||||
|
editor.commands.setContent(marked.parse(value)); // Update editor content
|
||||||
|
selectTemplate();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={element} class="relative w-full min-w-full h-full min-h-fit {className}"></div>
|
<div bind:this={element} class="relative w-full min-w-full h-full min-h-fit {className}" />
|
||||||
|
Loading…
Reference in New Issue
Block a user