RichTextInput.svelte 34 KB

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