123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152 |
- <script lang="ts">
- import { marked } from 'marked';
- 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 'turndown-plugin-gfm';
- const turndownService = new TurndownService({
- codeBlockStyle: 'fenced',
- headingStyle: 'atx'
- });
- turndownService.escape = (string) => string;
- // Use turndown-plugin-gfm for proper GFM table support
- turndownService.use(gfm);
- 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`;
- }
- });
- 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 { Editor, Extension } from '@tiptap/core';
- // Yjs imports
- import * as Y from 'yjs';
- import {
- ySyncPlugin,
- yCursorPlugin,
- yUndoPlugin,
- undo,
- redo,
- prosemirrorJSONToYDoc,
- yDocToProsemirrorJSON
- } from 'y-prosemirror';
- import { keymap } from 'prosemirror-keymap';
- import { AIAutocompletion } from './RichTextInput/AutoCompletion.js';
- import Table from '@tiptap/extension-table';
- import TableRow from '@tiptap/extension-table-row';
- import TableHeader from '@tiptap/extension-table-header';
- import TableCell from '@tiptap/extension-table-cell';
- import Link from '@tiptap/extension-link';
- import Underline from '@tiptap/extension-underline';
- import TaskItem from '@tiptap/extension-task-item';
- import TaskList from '@tiptap/extension-task-list';
- import CharacterCount from '@tiptap/extension-character-count';
- import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
- import Placeholder from '@tiptap/extension-placeholder';
- import StarterKit from '@tiptap/starter-kit';
- import Highlight from '@tiptap/extension-highlight';
- import Typography from '@tiptap/extension-typography';
- import BubbleMenu from '@tiptap/extension-bubble-menu';
- import FloatingMenu from '@tiptap/extension-floating-menu';
- import { all, createLowlight } from 'lowlight';
- import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
- import FormattingButtons from './RichTextInput/FormattingButtons.svelte';
- export let oncompositionstart = (e) => {};
- export let oncompositionend = (e) => {};
- export let onChange = (e) => {};
- // create a lowlight instance with all languages loaded
- const lowlight = createLowlight(all);
- export let editor = null;
- export let socket = null;
- export let user = null;
- export let documentId = '';
- export let className = 'input-prose';
- export let placeholder = 'Type here...';
- export let link = false;
- 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 showFormattingButtons = 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;
- let content = null;
- let htmlValue = '';
- let jsonValue = '';
- let mdValue = '';
- // Yjs setup
- let ydoc = null;
- let yXmlFragment = null;
- let awareness = null;
- // Custom Yjs Socket.IO provider
- class SocketIOProvider {
- constructor(doc, documentId, socket, user) {
- this.doc = doc;
- this.documentId = documentId;
- this.socket = socket;
- this.user = user;
- this.isConnected = false;
- this.synced = false;
- this.setupEventListeners();
- }
- generateUserColor() {
- const colors = [
- '#FF6B6B',
- '#4ECDC4',
- '#45B7D1',
- '#96CEB4',
- '#FFEAA7',
- '#DDA0DD',
- '#98D8C8',
- '#F7DC6F',
- '#BB8FCE',
- '#85C1E9'
- ];
- return colors[Math.floor(Math.random() * colors.length)];
- }
- joinDocument() {
- const userColor = this.generateUserColor();
- this.socket.emit('yjs:document:join', {
- document_id: this.documentId,
- user_id: this.user?.id,
- user_name: this.user?.name,
- user_color: userColor
- });
- // Set user awareness info
- if (awareness && this.user) {
- awareness.setLocalStateField('user', {
- name: `${this.user.name}`,
- color: userColor,
- id: this.socket.id
- });
- }
- }
- setupEventListeners() {
- // Listen for document updates from server
- this.socket.on('yjs:document:update', (data) => {
- if (data.document_id === this.documentId && data.socket_id !== this.socket.id) {
- try {
- const update = new Uint8Array(data.update);
- Y.applyUpdate(this.doc, update);
- } catch (error) {
- console.error('Error applying Yjs update:', error);
- }
- }
- });
- // Listen for document state from server
- this.socket.on('yjs:document:state', async (data) => {
- if (data.document_id === this.documentId) {
- try {
- if (data.state) {
- const state = new Uint8Array(data.state);
- if (state.length === 2 && state[0] === 0 && state[1] === 0) {
- // Empty state, check if we have content to initialize
- // check if editor empty as well
- const isEmptyEditor = !editor || editor.getText().trim() === '';
- if (content && isEmptyEditor) {
- const pydoc = prosemirrorJSONToYDoc(editor.schema, content);
- if (pydoc) {
- Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(pydoc));
- }
- }
- } else {
- Y.applyUpdate(this.doc, state);
- }
- }
- this.synced = true;
- } catch (error) {
- console.error('Error applying Yjs state:', error);
- }
- }
- });
- // Listen for awareness updates
- this.socket.on('yjs:awareness:update', (data) => {
- if (data.document_id === this.documentId && awareness) {
- try {
- const awarenessUpdate = new Uint8Array(data.update);
- awareness.applyUpdate(awarenessUpdate, 'server');
- } catch (error) {
- console.error('Error applying awareness update:', error);
- }
- }
- });
- // Handle connection events
- this.socket.on('connect', this.onConnect);
- this.socket.on('disconnect', this.onDisconnect);
- // Listen for document updates from Yjs
- this.doc.on('update', async (update, origin) => {
- if (origin !== 'server' && this.isConnected) {
- await tick(); // Ensure the DOM is updated before sending
- this.socket.emit('yjs:document:update', {
- document_id: this.documentId,
- user_id: this.user?.id,
- socket_id: this.socket.id,
- update: Array.from(update),
- data: {
- content: {
- md: mdValue,
- html: htmlValue,
- json: jsonValue
- }
- }
- });
- }
- });
- // Listen for awareness updates from Yjs
- if (awareness) {
- awareness.on('change', ({ added, updated, removed }, origin) => {
- if (origin !== 'server' && this.isConnected) {
- const changedClients = added.concat(updated).concat(removed);
- const awarenessUpdate = awareness.encodeUpdate(changedClients);
- this.socket.emit('yjs:awareness:update', {
- document_id: this.documentId,
- user_id: this.socket.id,
- update: Array.from(awarenessUpdate)
- });
- }
- });
- }
- if (this.socket.connected) {
- this.isConnected = true;
- this.joinDocument();
- }
- }
- onConnect = () => {
- this.isConnected = true;
- this.joinDocument();
- };
- onDisconnect = () => {
- this.isConnected = false;
- this.synced = false;
- };
- destroy() {
- this.socket.off('yjs:document:update');
- this.socket.off('yjs:document:state');
- this.socket.off('yjs:awareness:update');
- this.socket.off('connect', this.onConnect);
- this.socket.off('disconnect', this.onDisconnect);
- if (this.isConnected) {
- this.socket.emit('yjs:document:leave', {
- document_id: this.documentId,
- user_id: this.user?.id
- });
- }
- }
- }
- let provider = null;
- // Simple awareness implementation
- class SimpleAwareness {
- constructor(yDoc) {
- // Yjs awareness expects clientID (not clientId) property
- this.clientID = yDoc ? yDoc.clientID : Math.floor(Math.random() * 0xffffffff);
- // Map from clientID (number) to state (object)
- this._states = new Map(); // _states, not states; will make getStates() for compat
- this._updateHandlers = [];
- this._localState = {};
- // As in Yjs Awareness, add our local state to the states map from the start:
- this._states.set(this.clientID, this._localState);
- }
- on(event, handler) {
- if (event === 'change') this._updateHandlers.push(handler);
- }
- off(event, handler) {
- if (event === 'change') {
- const i = this._updateHandlers.indexOf(handler);
- if (i !== -1) this._updateHandlers.splice(i, 1);
- }
- }
- getLocalState() {
- return this._states.get(this.clientID) || null;
- }
- getStates() {
- // Yjs returns a Map (clientID->state)
- return this._states;
- }
- setLocalStateField(field, value) {
- let localState = this._states.get(this.clientID);
- if (!localState) {
- localState = {};
- this._states.set(this.clientID, localState);
- }
- localState[field] = value;
- // After updating, fire 'update' event to all handlers
- for (const cb of this._updateHandlers) {
- // Follows Yjs Awareness ({ added, updated, removed }, origin)
- cb({ added: [], updated: [this.clientID], removed: [] }, 'local');
- }
- }
- applyUpdate(update, origin) {
- // Very simple: Accepts a serialized JSON state for now as Uint8Array
- try {
- const str = new TextDecoder().decode(update);
- const obj = JSON.parse(str);
- // Should be a plain object: { clientID: state, ... }
- for (const [k, v] of Object.entries(obj)) {
- this._states.set(+k, v);
- }
- for (const cb of this._updateHandlers) {
- cb({ added: [], updated: Array.from(Object.keys(obj)).map(Number), removed: [] }, origin);
- }
- } catch (e) {
- console.warn('SimpleAwareness: Could not decode update:', e);
- }
- }
- encodeUpdate(clients) {
- // Encodes the states for the given clientIDs as Uint8Array (JSON)
- const obj = {};
- for (const id of clients || Array.from(this._states.keys())) {
- const st = this._states.get(id);
- if (st) obj[id] = st;
- }
- const json = JSON.stringify(obj);
- return new TextEncoder().encode(json);
- }
- }
- // Yjs collaboration extension
- const YjsCollaboration = Extension.create({
- name: 'yjsCollaboration',
- addProseMirrorPlugins() {
- if (!collaboration || !yXmlFragment) return [];
- const plugins = [
- ySyncPlugin(yXmlFragment),
- yUndoPlugin(),
- keymap({
- 'Mod-z': undo,
- 'Mod-y': redo,
- 'Mod-Shift-z': redo
- })
- ];
- if (awareness) {
- plugins.push(yCursorPlugin(awareness));
- }
- return plugins;
- }
- });
- function initializeCollaboration() {
- if (!collaboration) return;
- // Create Yjs document
- ydoc = new Y.Doc();
- yXmlFragment = ydoc.getXmlFragment('prosemirror');
- awareness = new SimpleAwareness(ydoc);
- // Create custom Socket.IO provider
- provider = new SocketIOProvider(ydoc, documentId, socket, user);
- }
- let floatingMenuElement = null;
- let bubbleMenuElement = null;
- let element;
- 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;
- };
- // Returns {start, end} of the word at pos
- 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;
- // Get the plain text of this document
- // const docText = state.doc.textBetween(0, state.doc.content.size, '\n', '\n');
- // Find the word boundaries at cursor
- const { start, end } = getWordBoundsAtPos(state.doc, pos);
- let tr = state.tr;
- if (insertPromptAsRichText) {
- const htmlContent = marked
- .parse(text, {
- breaks: true,
- gfm: true
- })
- .trim();
- // Create a temporary div to parse HTML
- const tempDiv = document.createElement('div');
- tempDiv.innerHTML = htmlContent;
- // Convert HTML to ProseMirror nodes
- const fragment = DOMParser.fromSchema(state.schema).parse(tempDiv);
- // Extract just the content, not the wrapper paragraphs
- const content = fragment.content;
- let nodesToInsert = [];
- content.forEach((node) => {
- if (node.type.name === 'paragraph') {
- // If it's a paragraph, extract its content
- nodesToInsert.push(...node.content.content);
- } else {
- nodesToInsert.push(node);
- }
- });
- tr = tr.replaceWith(start, end, nodesToInsert);
- // Calculate new position
- 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')) {
- // Split the text into lines and create a <p> node for each line
- const lines = text.split('\n');
- const nodes = lines.map(
- (line, index) =>
- index === 0
- ? state.schema.text(line ? line : []) // First line is plain text
- : state.schema.nodes.paragraph.create({}, line ? state.schema.text(line) : undefined) // Subsequent lines are paragraphs
- );
- // Build and dispatch the transaction to replace the word at cursor
- tr = tr.replaceWith(start, end, nodes);
- let newSelectionPos;
- // +1 because the insert happens at start, so last para starts at (start + sum of all previous nodes' sizes)
- let lastPos = start;
- for (let i = 0; i < nodes.length; i++) {
- lastPos += nodes[i].nodeSize;
- }
- // Place cursor inside the last paragraph at its end
- newSelectionPos = lastPos;
- tr = tr.setSelection(TextSelection.near(tr.doc.resolve(newSelectionPos)));
- } else {
- tr = tr.replaceWith(
- start,
- end, // replace this range
- text !== '' ? state.schema.text(text) : []
- );
- tr = tr.setSelection(
- state.selection.constructor.near(tr.doc.resolve(start + text.length + 1))
- );
- }
- }
- dispatch(tr);
- await tick();
- // selectNextTemplate(state, dispatch);
- };
- export const setText = (text: string) => {
- if (!editor) return;
- text = text.replaceAll('\n\n', '\n');
- const { state, view } = editor;
- const { schema, tr } = state;
- if (text.includes('\n')) {
- // Multiple lines: make paragraphs
- const lines = text.split('\n');
- // Map each line to a paragraph node (empty lines -> empty paragraph)
- const nodes = lines.map((line) =>
- schema.nodes.paragraph.create({}, line ? schema.text(line) : undefined)
- );
- // Create a document fragment containing all parsed paragraphs
- const fragment = Fragment.fromArray(nodes);
- // Replace current selection with these paragraphs
- tr.replaceSelectionWith(fragment, false /* don't select new */);
- view.dispatch(tr);
- } else if (text === '') {
- // Empty: replace with empty paragraph using tr
- editor.commands.clearContent();
- } else {
- // Single line: create paragraph with text
- const paragraph = schema.nodes.paragraph.create({}, schema.text(text));
- tr.replaceSelectionWith(paragraph, false);
- view.dispatch(tr);
- }
- selectNextTemplate(editor.view.state, editor.view.dispatch);
- focus();
- };
- export const insertContent = (content) => {
- if (!editor) return;
- const { state, view } = editor;
- const { schema, tr } = state;
- // If content is a string, convert it to a ProseMirror node
- const htmlContent = marked.parse(content);
- // insert the HTML content at the current selection
- editor.commands.insertContent(htmlContent);
- focus();
- };
- export const replaceVariables = (variables) => {
- if (!editor) return;
- const { state, view } = editor;
- const { doc } = state;
- // Create a transaction to replace variables
- let tr = state.tr;
- let offset = 0; // Track position changes due to text length differences
- // Collect all replacements first to avoid position conflicts
- 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
- });
- }
- }
- });
- // Apply replacements in reverse order to maintain correct positions
- replacements.reverse().forEach(({ from, to, text }) => {
- tr = tr.replaceWith(from, to, text !== '' ? state.schema.text(text) : []);
- });
- // Only dispatch if there are changes
- if (replacements.length > 0) {
- view.dispatch(tr);
- }
- };
- export const focus = () => {
- if (editor) {
- editor.view.focus();
- // Scroll to the current selection
- editor.view.dispatch(editor.view.state.tr.scrollIntoView());
- }
- };
- // Function to find the next template in the document
- function findNextTemplate(doc, from = 0) {
- const patterns = [{ 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; // Stop searching
- }
- }
- }
- index++;
- }
- }
- });
- return result;
- }
- // Function to select the next template in the document
- 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);
- // Scroll to the selected template
- dispatch(
- tr.scrollIntoView().setMeta('preventScroll', true) // Prevent default scrolling behavior
- );
- }
- 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) {
- editor.commands.focus('end');
- }
- }, 0);
- }
- };
- onMount(async () => {
- content = value;
- if (json) {
- if (!content) {
- content = html ? html : null;
- }
- } else {
- if (preserveBreaks) {
- turndownService.addRule('preserveBreaks', {
- filter: 'br', // Target <br> elements
- replacement: function (content) {
- return '<br/>';
- }
- });
- }
- if (!raw) {
- async function tryParse(value, attempts = 3, interval = 100) {
- try {
- // Try parsing the value
- return marked.parse(value.replaceAll(`\n<br/>`, `<br/>`), {
- breaks: false
- });
- } catch (error) {
- // If no attempts remain, fallback to plain text
- if (attempts <= 1) {
- return value;
- }
- // Wait for the interval, then retry
- await new Promise((resolve) => setTimeout(resolve, interval));
- return tryParse(value, attempts - 1, interval); // Recursive call
- }
- }
- // Usage example
- content = await tryParse(value);
- }
- }
- console.log('content', content);
- if (collaboration) {
- initializeCollaboration();
- }
- editor = new Editor({
- element: element,
- extensions: [
- StarterKit,
- CodeBlockLowlight.configure({
- lowlight
- }),
- Highlight,
- Typography,
- Underline,
- Placeholder.configure({ placeholder }),
- Table.configure({ resizable: true }),
- TableRow,
- TableHeader,
- TableCell,
- TaskList,
- TaskItem.configure({
- nested: true
- }),
- CharacterCount.configure({}),
- ...(link
- ? [
- Link.configure({
- openOnClick: true,
- linkOnPaste: true
- })
- ]
- : []),
- ...(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;
- }
- })
- ]
- : []),
- ...(showFormattingButtons
- ? [
- BubbleMenu.configure({
- element: bubbleMenuElement,
- tippyOptions: {
- duration: 100,
- arrow: false,
- placement: 'top',
- theme: 'transparent',
- offset: [0, 2]
- }
- }),
- FloatingMenu.configure({
- element: floatingMenuElement,
- tippyOptions: {
- duration: 100,
- arrow: false,
- placement: 'bottom-start',
- theme: 'transparent',
- offset: [-12, 4]
- }
- })
- ]
- : []),
- ...(collaboration ? [YjsCollaboration] : [])
- ],
- content: collaboration ? undefined : content,
- autofocus: messageInput ? true : false,
- onTransaction: () => {
- // force re-render so `editor.isActive` works as expected
- editor = editor;
- htmlValue = editor.getHTML();
- jsonValue = editor.getJSON();
- mdValue = turndownService
- .turndown(
- htmlValue
- .replace(/<p><\/p>/g, '<br/>')
- .replace(/ {2,}/g, (m) => m.replace(/ /g, '\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;
- // check if the node is paragraph as well
- if (editor.isActive('paragraph')) {
- if (value === '') {
- editor.commands.clearContent();
- }
- }
- }
- }
- }
- },
- editorProps: {
- attributes: { id },
- handleDOMEvents: {
- compositionstart: (view, event) => {
- oncompositionstart(event);
- return false;
- },
- compositionend: (view, event) => {
- oncompositionend(event);
- return false;
- },
- focus: (view, event) => {
- eventDispatch('focus', { event });
- return false;
- },
- keyup: (view, event) => {
- eventDispatch('keyup', { event });
- return false;
- },
- keydown: (view, event) => {
- if (messageInput) {
- // Check if the current selection is inside a structured block (like codeBlock or list)
- const { state } = view;
- const { $head } = state.selection;
- // Recursive function to check ancestors for specific node types
- function isInside(nodeTypes: string[]): boolean {
- let currentNode = $head;
- while (currentNode) {
- if (nodeTypes.includes(currentNode.parent.type.name)) {
- return true;
- }
- if (!currentNode.depth) break; // Stop if we reach the top
- currentNode = state.doc.resolve(currentNode.before()); // Move to the parent node
- }
- return false;
- }
- // Handle Tab Key
- if (event.key === 'Tab') {
- const isInCodeBlock = isInside(['codeBlock']);
- if (isInCodeBlock) {
- // Handle tab in code block - insert tab character or spaces
- const tabChar = '\t'; // or ' ' for 4 spaces
- editor.commands.insertContent(tabChar);
- event.preventDefault();
- return true; // Prevent further propagation
- } else {
- const handled = selectNextTemplate(view.state, view.dispatch);
- if (handled) {
- event.preventDefault();
- return true;
- }
- }
- }
- if (event.key === 'Enter') {
- const isCtrlPressed = event.ctrlKey || event.metaKey; // metaKey is for Cmd key on Mac
- if (event.shiftKey && !isCtrlPressed) {
- editor.commands.enter(); // Insert a new line
- view.dispatch(view.state.tr.scrollIntoView()); // Move viewport to the cursor
- event.preventDefault();
- return true;
- } else {
- const isInCodeBlock = isInside(['codeBlock']);
- const isInList = isInside(['listItem', 'bulletList', 'orderedList', 'taskList']);
- const isInHeading = isInside(['heading']);
- if (isInCodeBlock || isInList || isInHeading) {
- // Let ProseMirror handle the normal Enter behavior
- return false;
- }
- }
- }
- // Handle shift + Enter for a line break
- 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) {
- const plainText = event.clipboardData.getData('text/plain');
- if (plainText) {
- if (largeTextAsFile && plainText.length > PASTED_TEXT_CHARACTER_LIMIT) {
- // Delegate handling of large text pastes to the parent component.
- eventDispatch('paste', { event });
- event.preventDefault();
- return true;
- }
- // Workaround for mobile WebViews that strip line breaks when pasting from
- // clipboard suggestions (e.g., Gboard clipboard history).
- const isMobile = /Android|iPhone|iPad|iPod|Windows Phone/i.test(
- navigator.userAgent
- );
- const isWebView =
- typeof window !== 'undefined' &&
- (/wv/i.test(navigator.userAgent) || // Standard Android WebView flag
- (navigator.userAgent.includes('Android') &&
- !navigator.userAgent.includes('Chrome')) || // Other generic Android WebViews
- (navigator.userAgent.includes('Safari') &&
- !navigator.userAgent.includes('Version'))); // iOS WebView (in-app browsers)
- if (isMobile && isWebView && plainText.includes('\n')) {
- // Manually deconstruct the pasted text and insert it with hard breaks
- // to preserve the multi-line formatting.
- 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;
- }
- // Let ProseMirror handle normal text paste in non-problematic environments.
- return false;
- }
- // Delegate image paste handling to the parent component.
- const hasImageFile = Array.from(event.clipboardData.files).some((file) =>
- file.type.startsWith('image/')
- );
- // Fallback for cases where an image is in dataTransfer.items but not clipboardData.files.
- const hasImageItem = Array.from(event.clipboardData.items).some((item) =>
- item.type.startsWith('image/')
- );
- if (hasImageFile || hasImageItem) {
- eventDispatch('paste', { event });
- event.preventDefault();
- return true;
- }
- }
- // For all other cases, let ProseMirror perform its default paste behavior.
- view.dispatch(view.state.tr.scrollIntoView());
- return false;
- }
- }
- }
- });
- if (messageInput) {
- selectTemplate();
- }
- });
- onDestroy(() => {
- 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(); // Clear content if value is empty
- selectTemplate();
- return;
- }
- if (json) {
- if (JSON.stringify(value) !== JSON.stringify(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 showFormattingButtons}
- <div bind:this={bubbleMenuElement} class="p-0">
- <FormattingButtons {editor} />
- </div>
- <div bind:this={floatingMenuElement} class="p-0">
- <FormattingButtons {editor} />
- </div>
- {/if}
- <div bind:this={element} class="relative w-full min-w-full h-full min-h-fit {className}" />
|