RichTextInput.svelte 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671
  1. <script lang="ts">
  2. import { marked } from 'marked';
  3. import TurndownService from 'turndown';
  4. import { gfm } from 'turndown-plugin-gfm';
  5. const turndownService = new TurndownService({
  6. codeBlockStyle: 'fenced',
  7. headingStyle: 'atx'
  8. });
  9. turndownService.escape = (string) => string;
  10. // Use turndown-plugin-gfm for proper GFM table support
  11. turndownService.use(gfm);
  12. import { onMount, onDestroy, tick } from 'svelte';
  13. import { createEventDispatcher } from 'svelte';
  14. const eventDispatch = createEventDispatcher();
  15. import { Fragment } from 'prosemirror-model';
  16. import { EditorState, Plugin, PluginKey, TextSelection } from 'prosemirror-state';
  17. import { Decoration, DecorationSet } from 'prosemirror-view';
  18. import { Editor } from '@tiptap/core';
  19. import { AIAutocompletion } from './RichTextInput/AutoCompletion.js';
  20. import Table from '@tiptap/extension-table';
  21. import TableRow from '@tiptap/extension-table-row';
  22. import TableHeader from '@tiptap/extension-table-header';
  23. import TableCell from '@tiptap/extension-table-cell';
  24. import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
  25. import Placeholder from '@tiptap/extension-placeholder';
  26. import { all, createLowlight } from 'lowlight';
  27. import StarterKit from '@tiptap/starter-kit';
  28. import Highlight from '@tiptap/extension-highlight';
  29. import Typography from '@tiptap/extension-typography';
  30. import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
  31. export let oncompositionstart = (e) => {};
  32. export let oncompositionend = (e) => {};
  33. export let onChange = (e) => {};
  34. // create a lowlight instance with all languages loaded
  35. const lowlight = createLowlight(all);
  36. export let className = 'input-prose';
  37. export let placeholder = 'Type here...';
  38. export let id = '';
  39. export let value = '';
  40. export let html = '';
  41. export let json = false;
  42. export let raw = false;
  43. export let editable = true;
  44. export let preserveBreaks = false;
  45. export let generateAutoCompletion: Function = async () => null;
  46. export let autocomplete = false;
  47. export let messageInput = false;
  48. export let shiftEnter = false;
  49. export let largeTextAsFile = false;
  50. let element;
  51. let editor;
  52. const options = {
  53. throwOnError: false
  54. };
  55. $: if (editor) {
  56. editor.setOptions({
  57. editable: editable
  58. });
  59. }
  60. $: if (value === null && html !== null && editor) {
  61. editor.commands.setContent(html);
  62. }
  63. export const getWordAtDocPos = () => {
  64. if (!editor) return '';
  65. const { state } = editor.view;
  66. const pos = state.selection.from;
  67. const doc = state.doc;
  68. const resolvedPos = doc.resolve(pos);
  69. const textBlock = resolvedPos.parent;
  70. const paraStart = resolvedPos.start();
  71. const text = textBlock.textContent;
  72. const offset = resolvedPos.parentOffset;
  73. let wordStart = offset,
  74. wordEnd = offset;
  75. while (wordStart > 0 && !/\s/.test(text[wordStart - 1])) wordStart--;
  76. while (wordEnd < text.length && !/\s/.test(text[wordEnd])) wordEnd++;
  77. const word = text.slice(wordStart, wordEnd);
  78. return word;
  79. };
  80. // Returns {start, end} of the word at pos
  81. function getWordBoundsAtPos(doc, pos) {
  82. const resolvedPos = doc.resolve(pos);
  83. const textBlock = resolvedPos.parent;
  84. const paraStart = resolvedPos.start();
  85. const text = textBlock.textContent;
  86. const offset = resolvedPos.parentOffset;
  87. let wordStart = offset,
  88. wordEnd = offset;
  89. while (wordStart > 0 && !/\s/.test(text[wordStart - 1])) wordStart--;
  90. while (wordEnd < text.length && !/\s/.test(text[wordEnd])) wordEnd++;
  91. return {
  92. start: paraStart + wordStart,
  93. end: paraStart + wordEnd
  94. };
  95. }
  96. export const replaceCommandWithText = async (text) => {
  97. const { state, dispatch } = editor.view;
  98. const { selection } = state;
  99. const pos = selection.from;
  100. // Get the plain text of this document
  101. // const docText = state.doc.textBetween(0, state.doc.content.size, '\n', '\n');
  102. // Find the word boundaries at cursor
  103. const { start, end } = getWordBoundsAtPos(state.doc, pos);
  104. let tr = state.tr;
  105. if (text.includes('\n')) {
  106. // Split the text into lines and create a <p> node for each line
  107. const lines = text.split('\n');
  108. const nodes = lines.map(
  109. (line, index) =>
  110. index === 0
  111. ? state.schema.text(line ? line : []) // First line is plain text
  112. : state.schema.nodes.paragraph.create({}, line ? state.schema.text(line) : undefined) // Subsequent lines are paragraphs
  113. );
  114. // Build and dispatch the transaction to replace the word at cursor
  115. tr = tr.replaceWith(start, end, nodes);
  116. let newSelectionPos;
  117. // +1 because the insert happens at start, so last para starts at (start + sum of all previous nodes' sizes)
  118. let lastPos = start;
  119. for (let i = 0; i < nodes.length; i++) {
  120. lastPos += nodes[i].nodeSize;
  121. }
  122. // Place cursor inside the last paragraph at its end
  123. newSelectionPos = lastPos;
  124. tr = tr.setSelection(TextSelection.near(tr.doc.resolve(newSelectionPos)));
  125. } else {
  126. tr = tr.replaceWith(
  127. start,
  128. end, // replace this range
  129. text !== '' ? state.schema.text(text) : []
  130. );
  131. tr = tr.setSelection(
  132. state.selection.constructor.near(tr.doc.resolve(start + text.length + 1))
  133. );
  134. }
  135. dispatch(tr);
  136. await tick();
  137. // selectNextTemplate(state, dispatch);
  138. };
  139. export const setText = (text: string) => {
  140. if (!editor) return;
  141. text = text.replaceAll('\n\n', '\n');
  142. const { state, view } = editor;
  143. const { schema, tr } = state;
  144. if (text.includes('\n')) {
  145. // Multiple lines: make paragraphs
  146. const lines = text.split('\n');
  147. // Map each line to a paragraph node (empty lines -> empty paragraph)
  148. const nodes = lines.map((line) =>
  149. schema.nodes.paragraph.create({}, line ? schema.text(line) : undefined)
  150. );
  151. // Create a document fragment containing all parsed paragraphs
  152. const fragment = Fragment.fromArray(nodes);
  153. // Replace current selection with these paragraphs
  154. tr.replaceSelectionWith(fragment, false /* don't select new */);
  155. view.dispatch(tr);
  156. } else if (text === '') {
  157. // Empty: replace with empty paragraph using tr
  158. const emptyParagraph = schema.nodes.paragraph.create();
  159. tr.replaceSelectionWith(emptyParagraph, false);
  160. view.dispatch(tr);
  161. } else {
  162. // Single line: create paragraph with text
  163. const paragraph = schema.nodes.paragraph.create({}, schema.text(text));
  164. tr.replaceSelectionWith(paragraph, false);
  165. view.dispatch(tr);
  166. }
  167. selectNextTemplate(editor.view.state, editor.view.dispatch);
  168. focus();
  169. };
  170. export const replaceVariables = (variables) => {
  171. if (!editor) return;
  172. const { state, view } = editor;
  173. const { doc } = state;
  174. // Create a transaction to replace variables
  175. let tr = state.tr;
  176. let offset = 0; // Track position changes due to text length differences
  177. // Collect all replacements first to avoid position conflicts
  178. const replacements = [];
  179. doc.descendants((node, pos) => {
  180. if (node.isText && node.text) {
  181. const text = node.text;
  182. const replacedText = text.replace(/{{\s*([^|}]+)(?:\|[^}]*)?\s*}}/g, (match, varName) => {
  183. const trimmedVarName = varName.trim();
  184. return variables.hasOwnProperty(trimmedVarName)
  185. ? String(variables[trimmedVarName])
  186. : match;
  187. });
  188. if (replacedText !== text) {
  189. replacements.push({
  190. from: pos,
  191. to: pos + text.length,
  192. text: replacedText
  193. });
  194. }
  195. }
  196. });
  197. // Apply replacements in reverse order to maintain correct positions
  198. replacements.reverse().forEach(({ from, to, text }) => {
  199. tr = tr.replaceWith(from, to, text !== '' ? state.schema.text(text) : []);
  200. });
  201. // Only dispatch if there are changes
  202. if (replacements.length > 0) {
  203. view.dispatch(tr);
  204. }
  205. };
  206. export const focus = () => {
  207. if (editor) {
  208. editor.view.focus();
  209. // Scroll to the current selection
  210. editor.view.dispatch(editor.view.state.tr.scrollIntoView());
  211. }
  212. };
  213. // Function to find the next template in the document
  214. function findNextTemplate(doc, from = 0) {
  215. const patterns = [{ start: '{{', end: '}}' }];
  216. let result = null;
  217. doc.nodesBetween(from, doc.content.size, (node, pos) => {
  218. if (result) return false; // Stop if we've found a match
  219. if (node.isText) {
  220. const text = node.text;
  221. let index = Math.max(0, from - pos);
  222. while (index < text.length) {
  223. for (const pattern of patterns) {
  224. if (text.startsWith(pattern.start, index)) {
  225. const endIndex = text.indexOf(pattern.end, index + pattern.start.length);
  226. if (endIndex !== -1) {
  227. result = {
  228. from: pos + index,
  229. to: pos + endIndex + pattern.end.length
  230. };
  231. return false; // Stop searching
  232. }
  233. }
  234. }
  235. index++;
  236. }
  237. }
  238. });
  239. return result;
  240. }
  241. // Function to select the next template in the document
  242. function selectNextTemplate(state, dispatch) {
  243. const { doc, selection } = state;
  244. const from = selection.to;
  245. let template = findNextTemplate(doc, from);
  246. if (!template) {
  247. // If not found, search from the beginning
  248. template = findNextTemplate(doc, 0);
  249. }
  250. if (template) {
  251. if (dispatch) {
  252. const tr = state.tr.setSelection(TextSelection.create(doc, template.from, template.to));
  253. dispatch(tr);
  254. // Scroll to the selected template
  255. dispatch(
  256. tr.scrollIntoView().setMeta('preventScroll', true) // Prevent default scrolling behavior
  257. );
  258. }
  259. return true;
  260. }
  261. return false;
  262. }
  263. export const setContent = (content) => {
  264. editor.commands.setContent(content);
  265. };
  266. const selectTemplate = () => {
  267. if (value !== '') {
  268. // After updating the state, try to find and select the next template
  269. setTimeout(() => {
  270. const templateFound = selectNextTemplate(editor.view.state, editor.view.dispatch);
  271. if (!templateFound) {
  272. editor.commands.focus('end');
  273. }
  274. }, 0);
  275. }
  276. };
  277. onMount(async () => {
  278. let content = value;
  279. if (json) {
  280. if (!content) {
  281. content = html ? html : null;
  282. }
  283. } else {
  284. if (preserveBreaks) {
  285. turndownService.addRule('preserveBreaks', {
  286. filter: 'br', // Target <br> elements
  287. replacement: function (content) {
  288. return '<br/>';
  289. }
  290. });
  291. }
  292. if (!raw) {
  293. async function tryParse(value, attempts = 3, interval = 100) {
  294. try {
  295. // Try parsing the value
  296. return marked.parse(value.replaceAll(`\n<br/>`, `<br/>`), {
  297. breaks: false
  298. });
  299. } catch (error) {
  300. // If no attempts remain, fallback to plain text
  301. if (attempts <= 1) {
  302. return value;
  303. }
  304. // Wait for the interval, then retry
  305. await new Promise((resolve) => setTimeout(resolve, interval));
  306. return tryParse(value, attempts - 1, interval); // Recursive call
  307. }
  308. }
  309. // Usage example
  310. content = await tryParse(value);
  311. }
  312. }
  313. console.log('content', content);
  314. editor = new Editor({
  315. element: element,
  316. extensions: [
  317. StarterKit,
  318. CodeBlockLowlight.configure({
  319. lowlight
  320. }),
  321. Highlight,
  322. Typography,
  323. Placeholder.configure({ placeholder }),
  324. Table.configure({ resizable: true }),
  325. TableRow,
  326. TableHeader,
  327. TableCell,
  328. ...(autocomplete
  329. ? [
  330. AIAutocompletion.configure({
  331. generateCompletion: async (text) => {
  332. if (text.trim().length === 0) {
  333. return null;
  334. }
  335. const suggestion = await generateAutoCompletion(text).catch(() => null);
  336. if (!suggestion || suggestion.trim().length === 0) {
  337. return null;
  338. }
  339. return suggestion;
  340. }
  341. })
  342. ]
  343. : [])
  344. ],
  345. content: content,
  346. autofocus: messageInput ? true : false,
  347. onTransaction: () => {
  348. // force re-render so `editor.isActive` works as expected
  349. editor = editor;
  350. const htmlValue = editor.getHTML();
  351. const jsonValue = editor.getJSON();
  352. let mdValue = turndownService
  353. .turndown(
  354. htmlValue
  355. .replace(/<p><\/p>/g, '<br/>')
  356. .replace(/ {2,}/g, (m) => m.replace(/ /g, '\u00a0'))
  357. )
  358. .replace(/\u00a0/g, ' ');
  359. onChange({
  360. html: htmlValue,
  361. json: jsonValue,
  362. md: mdValue
  363. });
  364. if (json) {
  365. value = jsonValue;
  366. } else {
  367. if (raw) {
  368. value = htmlValue;
  369. } else {
  370. if (!preserveBreaks) {
  371. mdValue = mdValue.replace(/<br\/>/g, '');
  372. }
  373. if (value !== mdValue) {
  374. value = mdValue;
  375. // check if the node is paragraph as well
  376. if (editor.isActive('paragraph')) {
  377. if (value === '') {
  378. editor.commands.clearContent();
  379. }
  380. }
  381. }
  382. }
  383. }
  384. },
  385. editorProps: {
  386. attributes: { id },
  387. handleDOMEvents: {
  388. compositionstart: (view, event) => {
  389. oncompositionstart(event);
  390. return false;
  391. },
  392. compositionend: (view, event) => {
  393. oncompositionend(event);
  394. return false;
  395. },
  396. focus: (view, event) => {
  397. eventDispatch('focus', { event });
  398. return false;
  399. },
  400. keyup: (view, event) => {
  401. eventDispatch('keyup', { event });
  402. return false;
  403. },
  404. keydown: (view, event) => {
  405. if (messageInput) {
  406. // Handle Tab Key
  407. if (event.key === 'Tab') {
  408. const handled = selectNextTemplate(view.state, view.dispatch);
  409. if (handled) {
  410. event.preventDefault();
  411. return true;
  412. }
  413. }
  414. if (event.key === 'Enter') {
  415. const isCtrlPressed = event.ctrlKey || event.metaKey; // metaKey is for Cmd key on Mac
  416. if (event.shiftKey && !isCtrlPressed) {
  417. editor.commands.enter(); // Insert a new line
  418. view.dispatch(view.state.tr.scrollIntoView()); // Move viewport to the cursor
  419. event.preventDefault();
  420. return true;
  421. } else {
  422. // Check if the current selection is inside a structured block (like codeBlock or list)
  423. const { state } = view;
  424. const { $head } = state.selection;
  425. // Recursive function to check ancestors for specific node types
  426. function isInside(nodeTypes: string[]): boolean {
  427. let currentNode = $head;
  428. while (currentNode) {
  429. if (nodeTypes.includes(currentNode.parent.type.name)) {
  430. return true;
  431. }
  432. if (!currentNode.depth) break; // Stop if we reach the top
  433. currentNode = state.doc.resolve(currentNode.before()); // Move to the parent node
  434. }
  435. return false;
  436. }
  437. const isInCodeBlock = isInside(['codeBlock']);
  438. const isInList = isInside(['listItem', 'bulletList', 'orderedList']);
  439. const isInHeading = isInside(['heading']);
  440. if (isInCodeBlock || isInList || isInHeading) {
  441. // Let ProseMirror handle the normal Enter behavior
  442. return false;
  443. }
  444. }
  445. }
  446. // Handle shift + Enter for a line break
  447. if (shiftEnter) {
  448. if (event.key === 'Enter' && event.shiftKey && !event.ctrlKey && !event.metaKey) {
  449. editor.commands.setHardBreak(); // Insert a hard break
  450. view.dispatch(view.state.tr.scrollIntoView()); // Move viewport to the cursor
  451. event.preventDefault();
  452. return true;
  453. }
  454. }
  455. }
  456. eventDispatch('keydown', { event });
  457. return false;
  458. },
  459. paste: (view, event) => {
  460. if (event.clipboardData) {
  461. const plainText = event.clipboardData.getData('text/plain');
  462. if (plainText) {
  463. if (largeTextAsFile && plainText.length > PASTED_TEXT_CHARACTER_LIMIT) {
  464. // Delegate handling of large text pastes to the parent component.
  465. eventDispatch('paste', { event });
  466. event.preventDefault();
  467. return true;
  468. }
  469. // Workaround for mobile WebViews that strip line breaks when pasting from
  470. // clipboard suggestions (e.g., Gboard clipboard history).
  471. const isMobile = /Android|iPhone|iPad|iPod|Windows Phone/i.test(
  472. navigator.userAgent
  473. );
  474. const isWebView =
  475. typeof window !== 'undefined' &&
  476. (/wv/i.test(navigator.userAgent) || // Standard Android WebView flag
  477. (navigator.userAgent.includes('Android') &&
  478. !navigator.userAgent.includes('Chrome')) || // Other generic Android WebViews
  479. (navigator.userAgent.includes('Safari') &&
  480. !navigator.userAgent.includes('Version'))); // iOS WebView (in-app browsers)
  481. if (isMobile && isWebView && plainText.includes('\n')) {
  482. // Manually deconstruct the pasted text and insert it with hard breaks
  483. // to preserve the multi-line formatting.
  484. const { state, dispatch } = view;
  485. const { from, to } = state.selection;
  486. const lines = plainText.split('\n');
  487. const nodes = [];
  488. lines.forEach((line, index) => {
  489. if (index > 0) {
  490. nodes.push(state.schema.nodes.hardBreak.create());
  491. }
  492. if (line.length > 0) {
  493. nodes.push(state.schema.text(line));
  494. }
  495. });
  496. const fragment = Fragment.fromArray(nodes);
  497. const tr = state.tr.replaceWith(from, to, fragment);
  498. dispatch(tr.scrollIntoView());
  499. event.preventDefault();
  500. return true;
  501. }
  502. // Let ProseMirror handle normal text paste in non-problematic environments.
  503. return false;
  504. }
  505. // Delegate image paste handling to the parent component.
  506. const hasImageFile = Array.from(event.clipboardData.files).some((file) =>
  507. file.type.startsWith('image/')
  508. );
  509. // Fallback for cases where an image is in dataTransfer.items but not clipboardData.files.
  510. const hasImageItem = Array.from(event.clipboardData.items).some((item) =>
  511. item.type.startsWith('image/')
  512. );
  513. if (hasImageFile || hasImageItem) {
  514. eventDispatch('paste', { event });
  515. event.preventDefault();
  516. return true;
  517. }
  518. }
  519. // For all other cases, let ProseMirror perform its default paste behavior.
  520. view.dispatch(view.state.tr.scrollIntoView());
  521. return false;
  522. }
  523. }
  524. }
  525. });
  526. if (messageInput) {
  527. selectTemplate();
  528. }
  529. });
  530. onDestroy(() => {
  531. if (editor) {
  532. editor.destroy();
  533. }
  534. });
  535. $: if (value !== null && editor) {
  536. onValueChange();
  537. }
  538. const onValueChange = () => {
  539. if (!editor) return;
  540. const jsonValue = editor.getJSON();
  541. const htmlValue = editor.getHTML();
  542. let mdValue = turndownService
  543. .turndown(
  544. (preserveBreaks ? htmlValue.replace(/<p><\/p>/g, '<br/>') : htmlValue).replace(
  545. / {2,}/g,
  546. (m) => m.replace(/ /g, '\u00a0')
  547. )
  548. )
  549. .replace(/\u00a0/g, ' ');
  550. if (value === '') {
  551. editor.commands.clearContent(); // Clear content if value is empty
  552. selectTemplate();
  553. return;
  554. }
  555. if (json) {
  556. if (JSON.stringify(value) !== JSON.stringify(jsonValue)) {
  557. editor.commands.setContent(value);
  558. selectTemplate();
  559. }
  560. } else {
  561. if (raw) {
  562. if (value !== htmlValue) {
  563. editor.commands.setContent(value);
  564. selectTemplate();
  565. }
  566. } else {
  567. if (value !== mdValue) {
  568. editor.commands.setContent(
  569. preserveBreaks
  570. ? value
  571. : marked.parse(value.replaceAll(`\n<br/>`, `<br/>`), {
  572. breaks: false
  573. })
  574. );
  575. selectTemplate();
  576. }
  577. }
  578. }
  579. };
  580. </script>
  581. <div bind:this={element} class="relative w-full min-w-full h-full min-h-fit {className}" />