RichTextInput.svelte 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  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 } from 'svelte';
  13. import { createEventDispatcher } from 'svelte';
  14. const eventDispatch = createEventDispatcher();
  15. import { EditorState, Plugin, PluginKey, TextSelection } from 'prosemirror-state';
  16. import { Decoration, DecorationSet } from 'prosemirror-view';
  17. import { Editor } from '@tiptap/core';
  18. import { AIAutocompletion } from './RichTextInput/AutoCompletion.js';
  19. import Table from '@tiptap/extension-table';
  20. import TableRow from '@tiptap/extension-table-row';
  21. import TableHeader from '@tiptap/extension-table-header';
  22. import TableCell from '@tiptap/extension-table-cell';
  23. import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
  24. import Placeholder from '@tiptap/extension-placeholder';
  25. import { all, createLowlight } from 'lowlight';
  26. import StarterKit from '@tiptap/starter-kit';
  27. import Highlight from '@tiptap/extension-highlight';
  28. import Typography from '@tiptap/extension-typography';
  29. import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
  30. export let oncompositionstart = (e) => {};
  31. export let oncompositionend = (e) => {};
  32. export let onChange = (e) => {};
  33. // create a lowlight instance with all languages loaded
  34. const lowlight = createLowlight(all);
  35. export let className = 'input-prose';
  36. export let placeholder = 'Type here...';
  37. export let id = '';
  38. export let value = '';
  39. export let html = '';
  40. export let json = false;
  41. export let raw = false;
  42. export let editable = true;
  43. export let preserveBreaks = false;
  44. export let generateAutoCompletion: Function = async () => null;
  45. export let autocomplete = false;
  46. export let messageInput = false;
  47. export let shiftEnter = false;
  48. export let largeTextAsFile = false;
  49. let element;
  50. let editor;
  51. const options = {
  52. throwOnError: false
  53. };
  54. $: if (editor) {
  55. editor.setOptions({
  56. editable: editable
  57. });
  58. }
  59. $: if (value === null && html !== null && editor) {
  60. editor.commands.setContent(html);
  61. }
  62. // Function to find the next template in the document
  63. function findNextTemplate(doc, from = 0) {
  64. const patterns = [{ start: '{{', end: '}}' }];
  65. let result = null;
  66. doc.nodesBetween(from, doc.content.size, (node, pos) => {
  67. if (result) return false; // Stop if we've found a match
  68. if (node.isText) {
  69. const text = node.text;
  70. let index = Math.max(0, from - pos);
  71. while (index < text.length) {
  72. for (const pattern of patterns) {
  73. if (text.startsWith(pattern.start, index)) {
  74. const endIndex = text.indexOf(pattern.end, index + pattern.start.length);
  75. if (endIndex !== -1) {
  76. result = {
  77. from: pos + index,
  78. to: pos + endIndex + pattern.end.length
  79. };
  80. return false; // Stop searching
  81. }
  82. }
  83. }
  84. index++;
  85. }
  86. }
  87. });
  88. return result;
  89. }
  90. // Function to select the next template in the document
  91. function selectNextTemplate(state, dispatch) {
  92. const { doc, selection } = state;
  93. const from = selection.to;
  94. let template = findNextTemplate(doc, from);
  95. if (!template) {
  96. // If not found, search from the beginning
  97. template = findNextTemplate(doc, 0);
  98. }
  99. if (template) {
  100. if (dispatch) {
  101. const tr = state.tr.setSelection(TextSelection.create(doc, template.from, template.to));
  102. dispatch(tr);
  103. }
  104. return true;
  105. }
  106. return false;
  107. }
  108. export const setContent = (content) => {
  109. editor.commands.setContent(content);
  110. };
  111. const selectTemplate = () => {
  112. if (value !== '') {
  113. // After updating the state, try to find and select the next template
  114. setTimeout(() => {
  115. const templateFound = selectNextTemplate(editor.view.state, editor.view.dispatch);
  116. if (!templateFound) {
  117. // If no template found, set cursor at the end
  118. const endPos = editor.view.state.doc.content.size;
  119. editor.view.dispatch(
  120. editor.view.state.tr.setSelection(TextSelection.create(editor.view.state.doc, endPos))
  121. );
  122. }
  123. }, 0);
  124. }
  125. };
  126. onMount(async () => {
  127. let content = value;
  128. if (!json) {
  129. if (preserveBreaks) {
  130. turndownService.addRule('preserveBreaks', {
  131. filter: 'br', // Target <br> elements
  132. replacement: function (content) {
  133. return '<br/>';
  134. }
  135. });
  136. }
  137. if (!raw) {
  138. async function tryParse(value, attempts = 3, interval = 100) {
  139. try {
  140. // Try parsing the value
  141. return marked.parse(value.replaceAll(`\n<br/>`, `<br/>`), {
  142. breaks: false
  143. });
  144. } catch (error) {
  145. // If no attempts remain, fallback to plain text
  146. if (attempts <= 1) {
  147. return value;
  148. }
  149. // Wait for the interval, then retry
  150. await new Promise((resolve) => setTimeout(resolve, interval));
  151. return tryParse(value, attempts - 1, interval); // Recursive call
  152. }
  153. }
  154. // Usage example
  155. content = await tryParse(value);
  156. }
  157. } else {
  158. if (html && !content) {
  159. content = html;
  160. }
  161. }
  162. console.log('content', content);
  163. editor = new Editor({
  164. element: element,
  165. extensions: [
  166. StarterKit,
  167. CodeBlockLowlight.configure({
  168. lowlight
  169. }),
  170. Highlight,
  171. Typography,
  172. Placeholder.configure({ placeholder }),
  173. Table.configure({ resizable: true }),
  174. TableRow,
  175. TableHeader,
  176. TableCell,
  177. ...(autocomplete
  178. ? [
  179. AIAutocompletion.configure({
  180. generateCompletion: async (text) => {
  181. if (text.trim().length === 0) {
  182. return null;
  183. }
  184. const suggestion = await generateAutoCompletion(text).catch(() => null);
  185. if (!suggestion || suggestion.trim().length === 0) {
  186. return null;
  187. }
  188. return suggestion;
  189. }
  190. })
  191. ]
  192. : [])
  193. ],
  194. content: content,
  195. autofocus: messageInput ? true : false,
  196. onTransaction: () => {
  197. // force re-render so `editor.isActive` works as expected
  198. editor = editor;
  199. html = editor.getHTML();
  200. onChange({
  201. html: editor.getHTML(),
  202. json: editor.getJSON(),
  203. md: turndownService.turndown(editor.getHTML())
  204. });
  205. if (json) {
  206. value = editor.getJSON();
  207. } else {
  208. if (!raw) {
  209. let newValue = turndownService
  210. .turndown(
  211. editor
  212. .getHTML()
  213. .replace(/<p><\/p>/g, '<br/>')
  214. .replace(/ {2,}/g, (m) => m.replace(/ /g, '\u00a0'))
  215. )
  216. .replace(/\u00a0/g, ' ');
  217. if (!preserveBreaks) {
  218. newValue = newValue.replace(/<br\/>/g, '');
  219. }
  220. if (value !== newValue) {
  221. value = newValue;
  222. // check if the node is paragraph as well
  223. if (editor.isActive('paragraph')) {
  224. if (value === '') {
  225. editor.commands.clearContent();
  226. }
  227. }
  228. }
  229. } else {
  230. value = editor.getHTML();
  231. }
  232. }
  233. },
  234. editorProps: {
  235. attributes: { id },
  236. handleDOMEvents: {
  237. compositionstart: (view, event) => {
  238. oncompositionstart(event);
  239. return false;
  240. },
  241. compositionend: (view, event) => {
  242. oncompositionend(event);
  243. return false;
  244. },
  245. focus: (view, event) => {
  246. eventDispatch('focus', { event });
  247. return false;
  248. },
  249. keyup: (view, event) => {
  250. eventDispatch('keyup', { event });
  251. return false;
  252. },
  253. keydown: (view, event) => {
  254. if (messageInput) {
  255. // Handle Tab Key
  256. if (event.key === 'Tab') {
  257. const handled = selectNextTemplate(view.state, view.dispatch);
  258. if (handled) {
  259. event.preventDefault();
  260. return true;
  261. }
  262. }
  263. if (event.key === 'Enter') {
  264. // Check if the current selection is inside a structured block (like codeBlock or list)
  265. const { state } = view;
  266. const { $head } = state.selection;
  267. // Recursive function to check ancestors for specific node types
  268. function isInside(nodeTypes: string[]): boolean {
  269. let currentNode = $head;
  270. while (currentNode) {
  271. if (nodeTypes.includes(currentNode.parent.type.name)) {
  272. return true;
  273. }
  274. if (!currentNode.depth) break; // Stop if we reach the top
  275. currentNode = state.doc.resolve(currentNode.before()); // Move to the parent node
  276. }
  277. return false;
  278. }
  279. const isInCodeBlock = isInside(['codeBlock']);
  280. const isInList = isInside(['listItem', 'bulletList', 'orderedList']);
  281. const isInHeading = isInside(['heading']);
  282. if (isInCodeBlock || isInList || isInHeading) {
  283. // Let ProseMirror handle the normal Enter behavior
  284. return false;
  285. }
  286. }
  287. // Handle shift + Enter for a line break
  288. if (shiftEnter) {
  289. if (event.key === 'Enter' && event.shiftKey && !event.ctrlKey && !event.metaKey) {
  290. editor.commands.setHardBreak(); // Insert a hard break
  291. view.dispatch(view.state.tr.scrollIntoView()); // Move viewport to the cursor
  292. event.preventDefault();
  293. return true;
  294. }
  295. }
  296. }
  297. eventDispatch('keydown', { event });
  298. return false;
  299. },
  300. paste: (view, event) => {
  301. if (event.clipboardData) {
  302. // Extract plain text from clipboard and paste it without formatting
  303. const plainText = event.clipboardData.getData('text/plain');
  304. if (plainText) {
  305. if (largeTextAsFile) {
  306. if (plainText.length > PASTED_TEXT_CHARACTER_LIMIT) {
  307. // Dispatch paste event to parent component
  308. eventDispatch('paste', { event });
  309. event.preventDefault();
  310. return true;
  311. }
  312. }
  313. return false;
  314. }
  315. // Check if the pasted content contains image files
  316. const hasImageFile = Array.from(event.clipboardData.files).some((file) =>
  317. file.type.startsWith('image/')
  318. );
  319. // Check for image in dataTransfer items (for cases where files are not available)
  320. const hasImageItem = Array.from(event.clipboardData.items).some((item) =>
  321. item.type.startsWith('image/')
  322. );
  323. if (hasImageFile) {
  324. // If there's an image, dispatch the event to the parent
  325. eventDispatch('paste', { event });
  326. event.preventDefault();
  327. return true;
  328. }
  329. if (hasImageItem) {
  330. // If there's an image item, dispatch the event to the parent
  331. eventDispatch('paste', { event });
  332. event.preventDefault();
  333. return true;
  334. }
  335. }
  336. // For all other cases (text, formatted text, etc.), let ProseMirror handle it
  337. view.dispatch(view.state.tr.scrollIntoView()); // Move viewport to the cursor after pasting
  338. return false;
  339. }
  340. }
  341. }
  342. });
  343. if (messageInput) {
  344. selectTemplate();
  345. }
  346. });
  347. onDestroy(() => {
  348. if (editor) {
  349. editor.destroy();
  350. }
  351. });
  352. $: if (value !== null && editor) {
  353. onValueChange();
  354. }
  355. const onValueChange = () => {
  356. if (!editor) return;
  357. if (json) {
  358. if (JSON.stringify(value) !== JSON.stringify(editor.getJSON())) {
  359. editor.commands.setContent(value);
  360. selectTemplate();
  361. }
  362. } else {
  363. if (raw) {
  364. if (value !== editor.getHTML()) {
  365. editor.commands.setContent(value);
  366. selectTemplate();
  367. }
  368. } else {
  369. if (
  370. value !==
  371. turndownService
  372. .turndown(
  373. (preserveBreaks
  374. ? editor.getHTML().replace(/<p><\/p>/g, '<br/>')
  375. : editor.getHTML()
  376. ).replace(/ {2,}/g, (m) => m.replace(/ /g, '\u00a0'))
  377. )
  378. .replace(/\u00a0/g, ' ')
  379. ) {
  380. preserveBreaks
  381. ? editor.commands.setContent(value)
  382. : editor.commands.setContent(
  383. marked.parse(value.replaceAll(`\n<br/>`, `<br/>`), {
  384. breaks: false
  385. })
  386. ); // Update editor content
  387. selectTemplate();
  388. }
  389. }
  390. }
  391. };
  392. </script>
  393. <div bind:this={element} class="relative w-full min-w-full h-full min-h-fit {className}" />