RichTextInput.svelte 37 KB

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