RichTextInput.svelte 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152
  1. <script lang="ts">
  2. import { marked } from 'marked';
  3. marked.use({
  4. breaks: true,
  5. gfm: true,
  6. renderer: {
  7. list(body, ordered, start) {
  8. const isTaskList = body.includes('data-checked=');
  9. if (isTaskList) {
  10. return `<ul data-type="taskList">${body}</ul>`;
  11. }
  12. const type = ordered ? 'ol' : 'ul';
  13. const startatt = ordered && start !== 1 ? ` start="${start}"` : '';
  14. return `<${type}${startatt}>${body}</${type}>`;
  15. },
  16. listitem(text, task, checked) {
  17. if (task) {
  18. const checkedAttr = checked ? 'true' : 'false';
  19. return `<li data-type="taskItem" data-checked="${checkedAttr}">${text}</li>`;
  20. }
  21. return `<li>${text}</li>`;
  22. }
  23. }
  24. });
  25. import TurndownService from 'turndown';
  26. import { gfm } from 'turndown-plugin-gfm';
  27. const turndownService = new TurndownService({
  28. codeBlockStyle: 'fenced',
  29. headingStyle: 'atx'
  30. });
  31. turndownService.escape = (string) => string;
  32. // Use turndown-plugin-gfm for proper GFM table support
  33. turndownService.use(gfm);
  34. turndownService.addRule('taskListItems', {
  35. filter: (node) =>
  36. node.nodeName === 'LI' &&
  37. (node.getAttribute('data-checked') === 'true' ||
  38. node.getAttribute('data-checked') === 'false'),
  39. replacement: function (content, node) {
  40. const checked = node.getAttribute('data-checked') === 'true';
  41. content = content.replace(/^\s+/, '');
  42. return `- [${checked ? 'x' : ' '}] ${content}\n`;
  43. }
  44. });
  45. import { onMount, onDestroy, tick, getContext } from 'svelte';
  46. import { createEventDispatcher } from 'svelte';
  47. const i18n = getContext('i18n');
  48. const eventDispatch = createEventDispatcher();
  49. import { Fragment, DOMParser } from 'prosemirror-model';
  50. import { EditorState, Plugin, PluginKey, TextSelection, Selection } from 'prosemirror-state';
  51. import { Editor, Extension } from '@tiptap/core';
  52. // Yjs imports
  53. import * as Y from 'yjs';
  54. import {
  55. ySyncPlugin,
  56. yCursorPlugin,
  57. yUndoPlugin,
  58. undo,
  59. redo,
  60. prosemirrorJSONToYDoc,
  61. yDocToProsemirrorJSON
  62. } from 'y-prosemirror';
  63. import { keymap } from 'prosemirror-keymap';
  64. import { AIAutocompletion } from './RichTextInput/AutoCompletion.js';
  65. import Table from '@tiptap/extension-table';
  66. import TableRow from '@tiptap/extension-table-row';
  67. import TableHeader from '@tiptap/extension-table-header';
  68. import TableCell from '@tiptap/extension-table-cell';
  69. import Link from '@tiptap/extension-link';
  70. import Underline from '@tiptap/extension-underline';
  71. import TaskItem from '@tiptap/extension-task-item';
  72. import TaskList from '@tiptap/extension-task-list';
  73. import CharacterCount from '@tiptap/extension-character-count';
  74. import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
  75. import Placeholder from '@tiptap/extension-placeholder';
  76. import StarterKit from '@tiptap/starter-kit';
  77. import Highlight from '@tiptap/extension-highlight';
  78. import Typography from '@tiptap/extension-typography';
  79. import BubbleMenu from '@tiptap/extension-bubble-menu';
  80. import FloatingMenu from '@tiptap/extension-floating-menu';
  81. import { all, createLowlight } from 'lowlight';
  82. import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
  83. import FormattingButtons from './RichTextInput/FormattingButtons.svelte';
  84. export let oncompositionstart = (e) => {};
  85. export let oncompositionend = (e) => {};
  86. export let onChange = (e) => {};
  87. // create a lowlight instance with all languages loaded
  88. const lowlight = createLowlight(all);
  89. export let editor = null;
  90. export let socket = null;
  91. export let user = null;
  92. export let documentId = '';
  93. export let className = 'input-prose';
  94. export let placeholder = 'Type here...';
  95. export let link = false;
  96. export let id = '';
  97. export let value = '';
  98. export let html = '';
  99. export let json = false;
  100. export let raw = false;
  101. export let editable = true;
  102. export let collaboration = false;
  103. export let showFormattingButtons = true;
  104. export let preserveBreaks = false;
  105. export let generateAutoCompletion: Function = async () => null;
  106. export let autocomplete = false;
  107. export let messageInput = false;
  108. export let shiftEnter = false;
  109. export let largeTextAsFile = false;
  110. export let insertPromptAsRichText = false;
  111. let content = null;
  112. let htmlValue = '';
  113. let jsonValue = '';
  114. let mdValue = '';
  115. // Yjs setup
  116. let ydoc = null;
  117. let yXmlFragment = null;
  118. let awareness = null;
  119. // Custom Yjs Socket.IO provider
  120. class SocketIOProvider {
  121. constructor(doc, documentId, socket, user) {
  122. this.doc = doc;
  123. this.documentId = documentId;
  124. this.socket = socket;
  125. this.user = user;
  126. this.isConnected = false;
  127. this.synced = false;
  128. this.setupEventListeners();
  129. }
  130. generateUserColor() {
  131. const colors = [
  132. '#FF6B6B',
  133. '#4ECDC4',
  134. '#45B7D1',
  135. '#96CEB4',
  136. '#FFEAA7',
  137. '#DDA0DD',
  138. '#98D8C8',
  139. '#F7DC6F',
  140. '#BB8FCE',
  141. '#85C1E9'
  142. ];
  143. return colors[Math.floor(Math.random() * colors.length)];
  144. }
  145. joinDocument() {
  146. const userColor = this.generateUserColor();
  147. this.socket.emit('yjs:document:join', {
  148. document_id: this.documentId,
  149. user_id: this.user?.id,
  150. user_name: this.user?.name,
  151. user_color: userColor
  152. });
  153. // Set user awareness info
  154. if (awareness && this.user) {
  155. awareness.setLocalStateField('user', {
  156. name: `${this.user.name}`,
  157. color: userColor,
  158. id: this.socket.id
  159. });
  160. }
  161. }
  162. setupEventListeners() {
  163. // Listen for document updates from server
  164. this.socket.on('yjs:document:update', (data) => {
  165. if (data.document_id === this.documentId && data.socket_id !== this.socket.id) {
  166. try {
  167. const update = new Uint8Array(data.update);
  168. Y.applyUpdate(this.doc, update);
  169. } catch (error) {
  170. console.error('Error applying Yjs update:', error);
  171. }
  172. }
  173. });
  174. // Listen for document state from server
  175. this.socket.on('yjs:document:state', async (data) => {
  176. if (data.document_id === this.documentId) {
  177. try {
  178. if (data.state) {
  179. const state = new Uint8Array(data.state);
  180. if (state.length === 2 && state[0] === 0 && state[1] === 0) {
  181. // Empty state, check if we have content to initialize
  182. // check if editor empty as well
  183. const isEmptyEditor = !editor || editor.getText().trim() === '';
  184. if (content && isEmptyEditor) {
  185. const pydoc = prosemirrorJSONToYDoc(editor.schema, content);
  186. if (pydoc) {
  187. Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(pydoc));
  188. }
  189. }
  190. } else {
  191. Y.applyUpdate(this.doc, state);
  192. }
  193. }
  194. this.synced = true;
  195. } catch (error) {
  196. console.error('Error applying Yjs state:', error);
  197. }
  198. }
  199. });
  200. // Listen for awareness updates
  201. this.socket.on('yjs:awareness:update', (data) => {
  202. if (data.document_id === this.documentId && awareness) {
  203. try {
  204. const awarenessUpdate = new Uint8Array(data.update);
  205. awareness.applyUpdate(awarenessUpdate, 'server');
  206. } catch (error) {
  207. console.error('Error applying awareness update:', error);
  208. }
  209. }
  210. });
  211. // Handle connection events
  212. this.socket.on('connect', this.onConnect);
  213. this.socket.on('disconnect', this.onDisconnect);
  214. // Listen for document updates from Yjs
  215. this.doc.on('update', async (update, origin) => {
  216. if (origin !== 'server' && this.isConnected) {
  217. await tick(); // Ensure the DOM is updated before sending
  218. this.socket.emit('yjs:document:update', {
  219. document_id: this.documentId,
  220. user_id: this.user?.id,
  221. socket_id: this.socket.id,
  222. update: Array.from(update),
  223. data: {
  224. content: {
  225. md: mdValue,
  226. html: htmlValue,
  227. json: jsonValue
  228. }
  229. }
  230. });
  231. }
  232. });
  233. // Listen for awareness updates from Yjs
  234. if (awareness) {
  235. awareness.on('change', ({ added, updated, removed }, origin) => {
  236. if (origin !== 'server' && this.isConnected) {
  237. const changedClients = added.concat(updated).concat(removed);
  238. const awarenessUpdate = awareness.encodeUpdate(changedClients);
  239. this.socket.emit('yjs:awareness:update', {
  240. document_id: this.documentId,
  241. user_id: this.socket.id,
  242. update: Array.from(awarenessUpdate)
  243. });
  244. }
  245. });
  246. }
  247. if (this.socket.connected) {
  248. this.isConnected = true;
  249. this.joinDocument();
  250. }
  251. }
  252. onConnect = () => {
  253. this.isConnected = true;
  254. this.joinDocument();
  255. };
  256. onDisconnect = () => {
  257. this.isConnected = false;
  258. this.synced = false;
  259. };
  260. destroy() {
  261. this.socket.off('yjs:document:update');
  262. this.socket.off('yjs:document:state');
  263. this.socket.off('yjs:awareness:update');
  264. this.socket.off('connect', this.onConnect);
  265. this.socket.off('disconnect', this.onDisconnect);
  266. if (this.isConnected) {
  267. this.socket.emit('yjs:document:leave', {
  268. document_id: this.documentId,
  269. user_id: this.user?.id
  270. });
  271. }
  272. }
  273. }
  274. let provider = null;
  275. // Simple awareness implementation
  276. class SimpleAwareness {
  277. constructor(yDoc) {
  278. // Yjs awareness expects clientID (not clientId) property
  279. this.clientID = yDoc ? yDoc.clientID : Math.floor(Math.random() * 0xffffffff);
  280. // Map from clientID (number) to state (object)
  281. this._states = new Map(); // _states, not states; will make getStates() for compat
  282. this._updateHandlers = [];
  283. this._localState = {};
  284. // As in Yjs Awareness, add our local state to the states map from the start:
  285. this._states.set(this.clientID, this._localState);
  286. }
  287. on(event, handler) {
  288. if (event === 'change') this._updateHandlers.push(handler);
  289. }
  290. off(event, handler) {
  291. if (event === 'change') {
  292. const i = this._updateHandlers.indexOf(handler);
  293. if (i !== -1) this._updateHandlers.splice(i, 1);
  294. }
  295. }
  296. getLocalState() {
  297. return this._states.get(this.clientID) || null;
  298. }
  299. getStates() {
  300. // Yjs returns a Map (clientID->state)
  301. return this._states;
  302. }
  303. setLocalStateField(field, value) {
  304. let localState = this._states.get(this.clientID);
  305. if (!localState) {
  306. localState = {};
  307. this._states.set(this.clientID, localState);
  308. }
  309. localState[field] = value;
  310. // After updating, fire 'update' event to all handlers
  311. for (const cb of this._updateHandlers) {
  312. // Follows Yjs Awareness ({ added, updated, removed }, origin)
  313. cb({ added: [], updated: [this.clientID], removed: [] }, 'local');
  314. }
  315. }
  316. applyUpdate(update, origin) {
  317. // Very simple: Accepts a serialized JSON state for now as Uint8Array
  318. try {
  319. const str = new TextDecoder().decode(update);
  320. const obj = JSON.parse(str);
  321. // Should be a plain object: { clientID: state, ... }
  322. for (const [k, v] of Object.entries(obj)) {
  323. this._states.set(+k, v);
  324. }
  325. for (const cb of this._updateHandlers) {
  326. cb({ added: [], updated: Array.from(Object.keys(obj)).map(Number), removed: [] }, origin);
  327. }
  328. } catch (e) {
  329. console.warn('SimpleAwareness: Could not decode update:', e);
  330. }
  331. }
  332. encodeUpdate(clients) {
  333. // Encodes the states for the given clientIDs as Uint8Array (JSON)
  334. const obj = {};
  335. for (const id of clients || Array.from(this._states.keys())) {
  336. const st = this._states.get(id);
  337. if (st) obj[id] = st;
  338. }
  339. const json = JSON.stringify(obj);
  340. return new TextEncoder().encode(json);
  341. }
  342. }
  343. // Yjs collaboration extension
  344. const YjsCollaboration = Extension.create({
  345. name: 'yjsCollaboration',
  346. addProseMirrorPlugins() {
  347. if (!collaboration || !yXmlFragment) return [];
  348. const plugins = [
  349. ySyncPlugin(yXmlFragment),
  350. yUndoPlugin(),
  351. keymap({
  352. 'Mod-z': undo,
  353. 'Mod-y': redo,
  354. 'Mod-Shift-z': redo
  355. })
  356. ];
  357. if (awareness) {
  358. plugins.push(yCursorPlugin(awareness));
  359. }
  360. return plugins;
  361. }
  362. });
  363. function initializeCollaboration() {
  364. if (!collaboration) return;
  365. // Create Yjs document
  366. ydoc = new Y.Doc();
  367. yXmlFragment = ydoc.getXmlFragment('prosemirror');
  368. awareness = new SimpleAwareness(ydoc);
  369. // Create custom Socket.IO provider
  370. provider = new SocketIOProvider(ydoc, documentId, socket, user);
  371. }
  372. let floatingMenuElement = null;
  373. let bubbleMenuElement = null;
  374. let element;
  375. const options = {
  376. throwOnError: false
  377. };
  378. $: if (editor) {
  379. editor.setOptions({
  380. editable: editable
  381. });
  382. }
  383. $: if (value === null && html !== null && editor) {
  384. editor.commands.setContent(html);
  385. }
  386. export const getWordAtDocPos = () => {
  387. if (!editor) return '';
  388. const { state } = editor.view;
  389. const pos = state.selection.from;
  390. const doc = state.doc;
  391. const resolvedPos = doc.resolve(pos);
  392. const textBlock = resolvedPos.parent;
  393. const paraStart = resolvedPos.start();
  394. const text = textBlock.textContent;
  395. const offset = resolvedPos.parentOffset;
  396. let wordStart = offset,
  397. wordEnd = offset;
  398. while (wordStart > 0 && !/\s/.test(text[wordStart - 1])) wordStart--;
  399. while (wordEnd < text.length && !/\s/.test(text[wordEnd])) wordEnd++;
  400. const word = text.slice(wordStart, wordEnd);
  401. return word;
  402. };
  403. // Returns {start, end} of the word at pos
  404. function getWordBoundsAtPos(doc, pos) {
  405. const resolvedPos = doc.resolve(pos);
  406. const textBlock = resolvedPos.parent;
  407. const paraStart = resolvedPos.start();
  408. const text = textBlock.textContent;
  409. const offset = resolvedPos.parentOffset;
  410. let wordStart = offset,
  411. wordEnd = offset;
  412. while (wordStart > 0 && !/\s/.test(text[wordStart - 1])) wordStart--;
  413. while (wordEnd < text.length && !/\s/.test(text[wordEnd])) wordEnd++;
  414. return {
  415. start: paraStart + wordStart,
  416. end: paraStart + wordEnd
  417. };
  418. }
  419. export const replaceCommandWithText = async (text) => {
  420. const { state, dispatch } = editor.view;
  421. const { selection } = state;
  422. const pos = selection.from;
  423. // Get the plain text of this document
  424. // const docText = state.doc.textBetween(0, state.doc.content.size, '\n', '\n');
  425. // Find the word boundaries at cursor
  426. const { start, end } = getWordBoundsAtPos(state.doc, pos);
  427. let tr = state.tr;
  428. if (insertPromptAsRichText) {
  429. const htmlContent = marked
  430. .parse(text, {
  431. breaks: true,
  432. gfm: true
  433. })
  434. .trim();
  435. // Create a temporary div to parse HTML
  436. const tempDiv = document.createElement('div');
  437. tempDiv.innerHTML = htmlContent;
  438. // Convert HTML to ProseMirror nodes
  439. const fragment = DOMParser.fromSchema(state.schema).parse(tempDiv);
  440. // Extract just the content, not the wrapper paragraphs
  441. const content = fragment.content;
  442. let nodesToInsert = [];
  443. content.forEach((node) => {
  444. if (node.type.name === 'paragraph') {
  445. // If it's a paragraph, extract its content
  446. nodesToInsert.push(...node.content.content);
  447. } else {
  448. nodesToInsert.push(node);
  449. }
  450. });
  451. tr = tr.replaceWith(start, end, nodesToInsert);
  452. // Calculate new position
  453. const newPos = start + nodesToInsert.reduce((sum, node) => sum + node.nodeSize, 0);
  454. tr = tr.setSelection(Selection.near(tr.doc.resolve(newPos)));
  455. } else {
  456. if (text.includes('\n')) {
  457. // Split the text into lines and create a <p> node for each line
  458. const lines = text.split('\n');
  459. const nodes = lines.map(
  460. (line, index) =>
  461. index === 0
  462. ? state.schema.text(line ? line : []) // First line is plain text
  463. : state.schema.nodes.paragraph.create({}, line ? state.schema.text(line) : undefined) // Subsequent lines are paragraphs
  464. );
  465. // Build and dispatch the transaction to replace the word at cursor
  466. tr = tr.replaceWith(start, end, nodes);
  467. let newSelectionPos;
  468. // +1 because the insert happens at start, so last para starts at (start + sum of all previous nodes' sizes)
  469. let lastPos = start;
  470. for (let i = 0; i < nodes.length; i++) {
  471. lastPos += nodes[i].nodeSize;
  472. }
  473. // Place cursor inside the last paragraph at its end
  474. newSelectionPos = lastPos;
  475. tr = tr.setSelection(TextSelection.near(tr.doc.resolve(newSelectionPos)));
  476. } else {
  477. tr = tr.replaceWith(
  478. start,
  479. end, // replace this range
  480. text !== '' ? state.schema.text(text) : []
  481. );
  482. tr = tr.setSelection(
  483. state.selection.constructor.near(tr.doc.resolve(start + text.length + 1))
  484. );
  485. }
  486. }
  487. dispatch(tr);
  488. await tick();
  489. // selectNextTemplate(state, dispatch);
  490. };
  491. export const setText = (text: string) => {
  492. if (!editor) return;
  493. text = text.replaceAll('\n\n', '\n');
  494. const { state, view } = editor;
  495. const { schema, tr } = state;
  496. if (text.includes('\n')) {
  497. // Multiple lines: make paragraphs
  498. const lines = text.split('\n');
  499. // Map each line to a paragraph node (empty lines -> empty paragraph)
  500. const nodes = lines.map((line) =>
  501. schema.nodes.paragraph.create({}, line ? schema.text(line) : undefined)
  502. );
  503. // Create a document fragment containing all parsed paragraphs
  504. const fragment = Fragment.fromArray(nodes);
  505. // Replace current selection with these paragraphs
  506. tr.replaceSelectionWith(fragment, false /* don't select new */);
  507. view.dispatch(tr);
  508. } else if (text === '') {
  509. // Empty: replace with empty paragraph using tr
  510. editor.commands.clearContent();
  511. } else {
  512. // Single line: create paragraph with text
  513. const paragraph = schema.nodes.paragraph.create({}, schema.text(text));
  514. tr.replaceSelectionWith(paragraph, false);
  515. view.dispatch(tr);
  516. }
  517. selectNextTemplate(editor.view.state, editor.view.dispatch);
  518. focus();
  519. };
  520. export const insertContent = (content) => {
  521. if (!editor) return;
  522. const { state, view } = editor;
  523. const { schema, tr } = state;
  524. // If content is a string, convert it to a ProseMirror node
  525. const htmlContent = marked.parse(content);
  526. // insert the HTML content at the current selection
  527. editor.commands.insertContent(htmlContent);
  528. focus();
  529. };
  530. export const replaceVariables = (variables) => {
  531. if (!editor) return;
  532. const { state, view } = editor;
  533. const { doc } = state;
  534. // Create a transaction to replace variables
  535. let tr = state.tr;
  536. let offset = 0; // Track position changes due to text length differences
  537. // Collect all replacements first to avoid position conflicts
  538. const replacements = [];
  539. doc.descendants((node, pos) => {
  540. if (node.isText && node.text) {
  541. const text = node.text;
  542. const replacedText = text.replace(/{{\s*([^|}]+)(?:\|[^}]*)?\s*}}/g, (match, varName) => {
  543. const trimmedVarName = varName.trim();
  544. return variables.hasOwnProperty(trimmedVarName)
  545. ? String(variables[trimmedVarName])
  546. : match;
  547. });
  548. if (replacedText !== text) {
  549. replacements.push({
  550. from: pos,
  551. to: pos + text.length,
  552. text: replacedText
  553. });
  554. }
  555. }
  556. });
  557. // Apply replacements in reverse order to maintain correct positions
  558. replacements.reverse().forEach(({ from, to, text }) => {
  559. tr = tr.replaceWith(from, to, text !== '' ? state.schema.text(text) : []);
  560. });
  561. // Only dispatch if there are changes
  562. if (replacements.length > 0) {
  563. view.dispatch(tr);
  564. }
  565. };
  566. export const focus = () => {
  567. if (editor) {
  568. editor.view.focus();
  569. // Scroll to the current selection
  570. editor.view.dispatch(editor.view.state.tr.scrollIntoView());
  571. }
  572. };
  573. // Function to find the next template in the document
  574. function findNextTemplate(doc, from = 0) {
  575. const patterns = [{ start: '{{', end: '}}' }];
  576. let result = null;
  577. doc.nodesBetween(from, doc.content.size, (node, pos) => {
  578. if (result) return false; // Stop if we've found a match
  579. if (node.isText) {
  580. const text = node.text;
  581. let index = Math.max(0, from - pos);
  582. while (index < text.length) {
  583. for (const pattern of patterns) {
  584. if (text.startsWith(pattern.start, index)) {
  585. const endIndex = text.indexOf(pattern.end, index + pattern.start.length);
  586. if (endIndex !== -1) {
  587. result = {
  588. from: pos + index,
  589. to: pos + endIndex + pattern.end.length
  590. };
  591. return false; // Stop searching
  592. }
  593. }
  594. }
  595. index++;
  596. }
  597. }
  598. });
  599. return result;
  600. }
  601. // Function to select the next template in the document
  602. function selectNextTemplate(state, dispatch) {
  603. const { doc, selection } = state;
  604. const from = selection.to;
  605. let template = findNextTemplate(doc, from);
  606. if (!template) {
  607. // If not found, search from the beginning
  608. template = findNextTemplate(doc, 0);
  609. }
  610. if (template) {
  611. if (dispatch) {
  612. const tr = state.tr.setSelection(TextSelection.create(doc, template.from, template.to));
  613. dispatch(tr);
  614. // Scroll to the selected template
  615. dispatch(
  616. tr.scrollIntoView().setMeta('preventScroll', true) // Prevent default scrolling behavior
  617. );
  618. }
  619. return true;
  620. }
  621. return false;
  622. }
  623. export const setContent = (content) => {
  624. editor.commands.setContent(content);
  625. };
  626. const selectTemplate = () => {
  627. if (value !== '') {
  628. // After updating the state, try to find and select the next template
  629. setTimeout(() => {
  630. const templateFound = selectNextTemplate(editor.view.state, editor.view.dispatch);
  631. if (!templateFound) {
  632. editor.commands.focus('end');
  633. }
  634. }, 0);
  635. }
  636. };
  637. onMount(async () => {
  638. content = value;
  639. if (json) {
  640. if (!content) {
  641. content = html ? html : null;
  642. }
  643. } else {
  644. if (preserveBreaks) {
  645. turndownService.addRule('preserveBreaks', {
  646. filter: 'br', // Target <br> elements
  647. replacement: function (content) {
  648. return '<br/>';
  649. }
  650. });
  651. }
  652. if (!raw) {
  653. async function tryParse(value, attempts = 3, interval = 100) {
  654. try {
  655. // Try parsing the value
  656. return marked.parse(value.replaceAll(`\n<br/>`, `<br/>`), {
  657. breaks: false
  658. });
  659. } catch (error) {
  660. // If no attempts remain, fallback to plain text
  661. if (attempts <= 1) {
  662. return value;
  663. }
  664. // Wait for the interval, then retry
  665. await new Promise((resolve) => setTimeout(resolve, interval));
  666. return tryParse(value, attempts - 1, interval); // Recursive call
  667. }
  668. }
  669. // Usage example
  670. content = await tryParse(value);
  671. }
  672. }
  673. console.log('content', content);
  674. if (collaboration) {
  675. initializeCollaboration();
  676. }
  677. editor = new Editor({
  678. element: element,
  679. extensions: [
  680. StarterKit,
  681. CodeBlockLowlight.configure({
  682. lowlight
  683. }),
  684. Highlight,
  685. Typography,
  686. Underline,
  687. Placeholder.configure({ placeholder }),
  688. Table.configure({ resizable: true }),
  689. TableRow,
  690. TableHeader,
  691. TableCell,
  692. TaskList,
  693. TaskItem.configure({
  694. nested: true
  695. }),
  696. CharacterCount.configure({}),
  697. ...(link
  698. ? [
  699. Link.configure({
  700. openOnClick: true,
  701. linkOnPaste: true
  702. })
  703. ]
  704. : []),
  705. ...(autocomplete
  706. ? [
  707. AIAutocompletion.configure({
  708. generateCompletion: async (text) => {
  709. if (text.trim().length === 0) {
  710. return null;
  711. }
  712. const suggestion = await generateAutoCompletion(text).catch(() => null);
  713. if (!suggestion || suggestion.trim().length === 0) {
  714. return null;
  715. }
  716. return suggestion;
  717. }
  718. })
  719. ]
  720. : []),
  721. ...(showFormattingButtons
  722. ? [
  723. BubbleMenu.configure({
  724. element: bubbleMenuElement,
  725. tippyOptions: {
  726. duration: 100,
  727. arrow: false,
  728. placement: 'top',
  729. theme: 'transparent',
  730. offset: [0, 2]
  731. }
  732. }),
  733. FloatingMenu.configure({
  734. element: floatingMenuElement,
  735. tippyOptions: {
  736. duration: 100,
  737. arrow: false,
  738. placement: 'bottom-start',
  739. theme: 'transparent',
  740. offset: [-12, 4]
  741. }
  742. })
  743. ]
  744. : []),
  745. ...(collaboration ? [YjsCollaboration] : [])
  746. ],
  747. content: collaboration ? undefined : content,
  748. autofocus: messageInput ? true : false,
  749. onTransaction: () => {
  750. // force re-render so `editor.isActive` works as expected
  751. editor = editor;
  752. htmlValue = editor.getHTML();
  753. jsonValue = editor.getJSON();
  754. mdValue = turndownService
  755. .turndown(
  756. htmlValue
  757. .replace(/<p><\/p>/g, '<br/>')
  758. .replace(/ {2,}/g, (m) => m.replace(/ /g, '\u00a0'))
  759. )
  760. .replace(/\u00a0/g, ' ');
  761. onChange({
  762. html: htmlValue,
  763. json: jsonValue,
  764. md: mdValue
  765. });
  766. if (json) {
  767. value = jsonValue;
  768. } else {
  769. if (raw) {
  770. value = htmlValue;
  771. } else {
  772. if (!preserveBreaks) {
  773. mdValue = mdValue.replace(/<br\/>/g, '');
  774. }
  775. if (value !== mdValue) {
  776. value = mdValue;
  777. // check if the node is paragraph as well
  778. if (editor.isActive('paragraph')) {
  779. if (value === '') {
  780. editor.commands.clearContent();
  781. }
  782. }
  783. }
  784. }
  785. }
  786. },
  787. editorProps: {
  788. attributes: { id },
  789. handleDOMEvents: {
  790. compositionstart: (view, event) => {
  791. oncompositionstart(event);
  792. return false;
  793. },
  794. compositionend: (view, event) => {
  795. oncompositionend(event);
  796. return false;
  797. },
  798. focus: (view, event) => {
  799. eventDispatch('focus', { event });
  800. return false;
  801. },
  802. keyup: (view, event) => {
  803. eventDispatch('keyup', { event });
  804. return false;
  805. },
  806. keydown: (view, event) => {
  807. if (messageInput) {
  808. // Check if the current selection is inside a structured block (like codeBlock or list)
  809. const { state } = view;
  810. const { $head } = state.selection;
  811. // Recursive function to check ancestors for specific node types
  812. function isInside(nodeTypes: string[]): boolean {
  813. let currentNode = $head;
  814. while (currentNode) {
  815. if (nodeTypes.includes(currentNode.parent.type.name)) {
  816. return true;
  817. }
  818. if (!currentNode.depth) break; // Stop if we reach the top
  819. currentNode = state.doc.resolve(currentNode.before()); // Move to the parent node
  820. }
  821. return false;
  822. }
  823. // Handle Tab Key
  824. if (event.key === 'Tab') {
  825. const isInCodeBlock = isInside(['codeBlock']);
  826. if (isInCodeBlock) {
  827. // Handle tab in code block - insert tab character or spaces
  828. const tabChar = '\t'; // or ' ' for 4 spaces
  829. editor.commands.insertContent(tabChar);
  830. event.preventDefault();
  831. return true; // Prevent further propagation
  832. } else {
  833. const handled = selectNextTemplate(view.state, view.dispatch);
  834. if (handled) {
  835. event.preventDefault();
  836. return true;
  837. }
  838. }
  839. }
  840. if (event.key === 'Enter') {
  841. const isCtrlPressed = event.ctrlKey || event.metaKey; // metaKey is for Cmd key on Mac
  842. if (event.shiftKey && !isCtrlPressed) {
  843. editor.commands.enter(); // Insert a new line
  844. view.dispatch(view.state.tr.scrollIntoView()); // Move viewport to the cursor
  845. event.preventDefault();
  846. return true;
  847. } else {
  848. const isInCodeBlock = isInside(['codeBlock']);
  849. const isInList = isInside(['listItem', 'bulletList', 'orderedList', 'taskList']);
  850. const isInHeading = isInside(['heading']);
  851. if (isInCodeBlock || isInList || isInHeading) {
  852. // Let ProseMirror handle the normal Enter behavior
  853. return false;
  854. }
  855. }
  856. }
  857. // Handle shift + Enter for a line break
  858. if (shiftEnter) {
  859. if (event.key === 'Enter' && event.shiftKey && !event.ctrlKey && !event.metaKey) {
  860. editor.commands.setHardBreak(); // Insert a hard break
  861. view.dispatch(view.state.tr.scrollIntoView()); // Move viewport to the cursor
  862. event.preventDefault();
  863. return true;
  864. }
  865. }
  866. }
  867. eventDispatch('keydown', { event });
  868. return false;
  869. },
  870. paste: (view, event) => {
  871. if (event.clipboardData) {
  872. const plainText = event.clipboardData.getData('text/plain');
  873. if (plainText) {
  874. if (largeTextAsFile && plainText.length > PASTED_TEXT_CHARACTER_LIMIT) {
  875. // Delegate handling of large text pastes to the parent component.
  876. eventDispatch('paste', { event });
  877. event.preventDefault();
  878. return true;
  879. }
  880. // Workaround for mobile WebViews that strip line breaks when pasting from
  881. // clipboard suggestions (e.g., Gboard clipboard history).
  882. const isMobile = /Android|iPhone|iPad|iPod|Windows Phone/i.test(
  883. navigator.userAgent
  884. );
  885. const isWebView =
  886. typeof window !== 'undefined' &&
  887. (/wv/i.test(navigator.userAgent) || // Standard Android WebView flag
  888. (navigator.userAgent.includes('Android') &&
  889. !navigator.userAgent.includes('Chrome')) || // Other generic Android WebViews
  890. (navigator.userAgent.includes('Safari') &&
  891. !navigator.userAgent.includes('Version'))); // iOS WebView (in-app browsers)
  892. if (isMobile && isWebView && plainText.includes('\n')) {
  893. // Manually deconstruct the pasted text and insert it with hard breaks
  894. // to preserve the multi-line formatting.
  895. const { state, dispatch } = view;
  896. const { from, to } = state.selection;
  897. const lines = plainText.split('\n');
  898. const nodes = [];
  899. lines.forEach((line, index) => {
  900. if (index > 0) {
  901. nodes.push(state.schema.nodes.hardBreak.create());
  902. }
  903. if (line.length > 0) {
  904. nodes.push(state.schema.text(line));
  905. }
  906. });
  907. const fragment = Fragment.fromArray(nodes);
  908. const tr = state.tr.replaceWith(from, to, fragment);
  909. dispatch(tr.scrollIntoView());
  910. event.preventDefault();
  911. return true;
  912. }
  913. // Let ProseMirror handle normal text paste in non-problematic environments.
  914. return false;
  915. }
  916. // Delegate image paste handling to the parent component.
  917. const hasImageFile = Array.from(event.clipboardData.files).some((file) =>
  918. file.type.startsWith('image/')
  919. );
  920. // Fallback for cases where an image is in dataTransfer.items but not clipboardData.files.
  921. const hasImageItem = Array.from(event.clipboardData.items).some((item) =>
  922. item.type.startsWith('image/')
  923. );
  924. if (hasImageFile || hasImageItem) {
  925. eventDispatch('paste', { event });
  926. event.preventDefault();
  927. return true;
  928. }
  929. }
  930. // For all other cases, let ProseMirror perform its default paste behavior.
  931. view.dispatch(view.state.tr.scrollIntoView());
  932. return false;
  933. }
  934. }
  935. }
  936. });
  937. if (messageInput) {
  938. selectTemplate();
  939. }
  940. });
  941. onDestroy(() => {
  942. if (provider) {
  943. provider.destroy();
  944. }
  945. if (editor) {
  946. editor.destroy();
  947. }
  948. });
  949. $: if (value !== null && editor && !collaboration) {
  950. onValueChange();
  951. }
  952. const onValueChange = () => {
  953. if (!editor) return;
  954. const jsonValue = editor.getJSON();
  955. const htmlValue = editor.getHTML();
  956. let mdValue = turndownService
  957. .turndown(
  958. (preserveBreaks ? htmlValue.replace(/<p><\/p>/g, '<br/>') : htmlValue).replace(
  959. / {2,}/g,
  960. (m) => m.replace(/ /g, '\u00a0')
  961. )
  962. )
  963. .replace(/\u00a0/g, ' ');
  964. if (value === '') {
  965. editor.commands.clearContent(); // Clear content if value is empty
  966. selectTemplate();
  967. return;
  968. }
  969. if (json) {
  970. if (JSON.stringify(value) !== JSON.stringify(jsonValue)) {
  971. editor.commands.setContent(value);
  972. selectTemplate();
  973. }
  974. } else {
  975. if (raw) {
  976. if (value !== htmlValue) {
  977. editor.commands.setContent(value);
  978. selectTemplate();
  979. }
  980. } else {
  981. if (value !== mdValue) {
  982. editor.commands.setContent(
  983. preserveBreaks
  984. ? value
  985. : marked.parse(value.replaceAll(`\n<br/>`, `<br/>`), {
  986. breaks: false
  987. })
  988. );
  989. selectTemplate();
  990. }
  991. }
  992. }
  993. };
  994. </script>
  995. {#if showFormattingButtons}
  996. <div bind:this={bubbleMenuElement} class="p-0">
  997. <FormattingButtons {editor} />
  998. </div>
  999. <div bind:this={floatingMenuElement} class="p-0">
  1000. <FormattingButtons {editor} />
  1001. </div>
  1002. {/if}
  1003. <div bind:this={element} class="relative w-full min-w-full h-full min-h-fit {className}" />