12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361 |
- <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 '@joplin/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);
- // Add custom table header rule before using GFM plugin
- turndownService.addRule('tableHeaders', {
- filter: 'th',
- replacement: function (content, node) {
- return content;
- }
- });
- // Add custom table rule to handle headers properly
- turndownService.addRule('tables', {
- filter: 'table',
- replacement: function (content, node) {
- // Extract rows
- const rows = Array.from(node.querySelectorAll('tr'));
- if (rows.length === 0) return content;
- let markdown = '\n';
- rows.forEach((row, rowIndex) => {
- const cells = Array.from(row.querySelectorAll('th, td'));
- const cellContents = cells.map((cell) => {
- // Get the text content and clean it up
- let cellContent = turndownService.turndown(cell.innerHTML).trim();
- // Remove extra paragraph tags that might be added
- cellContent = cellContent.replace(/^\n+|\n+$/g, '');
- return cellContent;
- });
- // Add the row
- markdown += '| ' + cellContents.join(' | ') + ' |\n';
- // Add separator after first row (which should be headers)
- if (rowIndex === 0) {
- const separator = cells.map(() => '---').join(' | ');
- markdown += '| ' + separator + ' |\n';
- }
- });
- return markdown + '\n';
- }
- });
- 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 { Decoration, DecorationSet } from 'prosemirror-view';
- 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 StarterKit from '@tiptap/starter-kit';
- // Bubble and Floating menus are currently fixed to v2 due to styling issues in v3
- // TODO: Update to v3 when styling issues are resolved
- import BubbleMenu from '@tiptap/extension-bubble-menu';
- import FloatingMenu from '@tiptap/extension-floating-menu';
- import { TableKit } from '@tiptap/extension-table';
- import { ListKit } from '@tiptap/extension-list';
- import { Placeholder, CharacterCount } from '@tiptap/extensions';
- import Image from './RichTextInput/Image/index.js';
- // import TiptapImage from '@tiptap/extension-image';
- import FileHandler from '@tiptap/extension-file-handler';
- import Typography from '@tiptap/extension-typography';
- import Highlight from '@tiptap/extension-highlight';
- import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
- import Mention from '@tiptap/extension-mention';
- import FormattingButtons from './RichTextInput/FormattingButtons.svelte';
- import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
- import { all, createLowlight } from 'lowlight';
- import MentionList from './RichTextInput/MentionList.svelte';
- import { getSuggestionRenderer } from './RichTextInput/suggestions.js';
- 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 files = [];
- export let documentId = '';
- export let className = 'input-prose';
- export let placeholder = 'Type here...';
- export let link = false;
- export let image = false;
- export let fileHandler = false;
- export let suggestions = null;
- export let onFileDrop = (currentEditor, files, pos) => {
- files.forEach((file) => {
- const fileReader = new FileReader();
- fileReader.readAsDataURL(file);
- fileReader.onload = () => {
- currentEditor
- .chain()
- .insertContentAt(pos, {
- type: 'image',
- attrs: {
- src: fileReader.result
- }
- })
- .focus()
- .run();
- };
- });
- };
- export let onFilePaste = (currentEditor, files, htmlContent) => {
- files.forEach((file) => {
- if (htmlContent) {
- // if there is htmlContent, stop manual insertion & let other extensions handle insertion via inputRule
- // you could extract the pasted file from this url string and upload it to a server for example
- console.log(htmlContent); // eslint-disable-line no-console
- return false;
- }
- const fileReader = new FileReader();
- fileReader.readAsDataURL(file);
- fileReader.onload = () => {
- currentEditor
- .chain()
- .insertContentAt(currentEditor.state.selection.anchor, {
- type: 'image',
- attrs: {
- src: fileReader.result
- }
- })
- .focus()
- .run();
- };
- });
- };
- export let onSelectionUpdate = (e) => {};
- 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 showFormattingToolbar = 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;
- export let floatingMenuPlacement = 'bottom-start';
- let content = null;
- let htmlValue = '';
- let jsonValue = '';
- let mdValue = '';
- let lastSelectionBookmark = null;
- // Yjs setup
- let ydoc = null;
- let yXmlFragment = null;
- let awareness = null;
- const getEditorInstance = async () => {
- return new Promise((resolve) => {
- setTimeout(() => {
- resolve(editor);
- }, 0);
- });
- };
- // 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('ydoc: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('ydoc: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('ydoc: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 editor = await getEditorInstance();
- const isEmptyEditor = !editor || editor.getText().trim() === '';
- if (isEmptyEditor) {
- if (content && (data?.sessions ?? ['']).length === 1) {
- const editorYdoc = prosemirrorJSONToYDoc(editor.schema, content);
- if (editorYdoc) {
- Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(editorYdoc));
- }
- }
- } else {
- // If the editor already has content, we don't need to send an empty state
- if (this.doc.getXmlFragment('prosemirror').length > 0) {
- this.socket.emit('ydoc:document:update', {
- document_id: this.documentId,
- user_id: this.user?.id,
- socket_id: this.socket.id,
- update: Y.encodeStateAsUpdate(this.doc)
- });
- } else {
- console.warn('Yjs document is empty, not sending state.');
- }
- }
- } else {
- Y.applyUpdate(this.doc, state, 'server');
- }
- }
- this.synced = true;
- } catch (error) {
- console.error('Error applying Yjs state:', error);
- this.synced = false;
- this.socket.emit('ydoc:document:state', {
- document_id: this.documentId
- });
- }
- }
- });
- // Listen for awareness updates
- this.socket.on('ydoc: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('ydoc: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('ydoc: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('ydoc:document:update');
- this.socket.off('ydoc:document:state');
- this.socket.off('ydoc:awareness:update');
- this.socket.off('connect', this.onConnect);
- this.socket.off('disconnect', this.onDisconnect);
- if (this.isConnected) {
- this.socket.emit('ydoc: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');
- // reset the editor content
- editor.commands.clearContent();
- 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);
- }
- };
- const SelectionDecoration = Extension.create({
- name: 'selectionDecoration',
- addProseMirrorPlugins() {
- return [
- new Plugin({
- key: new PluginKey('selection'),
- props: {
- decorations: (state) => {
- const { selection } = state;
- const { focused } = this.editor;
- if (focused || selection.empty) {
- return null;
- }
- return DecorationSet.create(state.doc, [
- Decoration.inline(selection.from, selection.to, {
- class: 'editor-selection'
- })
- ]);
- }
- }
- })
- ];
- }
- });
- 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();
- }
- console.log(bubbleMenuElement, floatingMenuElement);
- console.log(suggestions);
- editor = new Editor({
- element: element,
- extensions: [
- StarterKit.configure({
- link: link
- }),
- Placeholder.configure({ placeholder }),
- SelectionDecoration,
- CodeBlockLowlight.configure({
- lowlight
- }),
- Highlight,
- Typography,
- ...(suggestions
- ? [
- Mention.configure({
- HTMLAttributes: { class: 'mention' },
- suggestions: suggestions
- })
- ]
- : []),
- TableKit.configure({
- table: { resizable: true }
- }),
- ListKit.configure({
- taskItem: {
- nested: true
- }
- }),
- CharacterCount.configure({}),
- ...(image ? [Image] : []),
- ...(fileHandler
- ? [
- FileHandler.configure({
- onDrop: onFileDrop,
- onPaste: onFilePaste
- })
- ]
- : []),
- ...(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;
- }
- })
- ]
- : []),
- ...(showFormattingToolbar
- ? [
- 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: floatingMenuPlacement,
- 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;
- if (!editor) return;
- 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
- const { state } = view;
- const { $from } = state.selection;
- const lineStart = $from.before($from.depth);
- const lineEnd = $from.after($from.depth);
- const lineText = state.doc.textBetween(lineStart, lineEnd, '\n', '\0').trim();
- if (event.shiftKey && !isCtrlPressed) {
- if (lineText.startsWith('```')) {
- // Fix GitHub issue #16337: prevent backtick removal for lines starting with ```
- return false; // Let ProseMirror handle the Enter key normally
- }
- 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']);
- console.log({ isInCodeBlock, isInList, isInHeading });
- if (isInCodeBlock || isInList || isInHeading) {
- // Let ProseMirror handle the normal Enter behavior
- return false;
- }
- const suggestionsElement = document.getElementById('suggestions-container');
- if (lineText.startsWith('#') && suggestionsElement) {
- console.log('Letting heading suggestion handle Enter key');
- return true;
- }
- }
- }
- // 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/')
- );
- const hasFile = Array.from(event.clipboardData.files).length > 0;
- if (hasImageFile || hasImageItem || hasFile) {
- 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;
- }
- }
- },
- onBeforeCreate: ({ editor }) => {
- if (files) {
- editor.storage.files = files;
- }
- },
- onSelectionUpdate: onSelectionUpdate
- });
- 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 showFormattingToolbar}
- <div bind:this={bubbleMenuElement} id="bubble-menu" class="p-0">
- <FormattingButtons {editor} />
- </div>
- <div bind:this={floatingMenuElement} id="floating-menu" class="p-0">
- <FormattingButtons {editor} />
- </div>
- {/if}
- <div bind:this={element} class="relative w-full min-w-full h-full min-h-fit {className}" />
|