RichTextInput.svelte 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506
  1. <script lang="ts">
  2. import { onDestroy, onMount } from 'svelte';
  3. import { createEventDispatcher } from 'svelte';
  4. const eventDispatch = createEventDispatcher();
  5. import { EditorState, Plugin, TextSelection } from 'prosemirror-state';
  6. import { EditorView, Decoration, DecorationSet } from 'prosemirror-view';
  7. import { undo, redo, history } from 'prosemirror-history';
  8. import {
  9. schema,
  10. defaultMarkdownParser,
  11. MarkdownParser,
  12. defaultMarkdownSerializer
  13. } from 'prosemirror-markdown';
  14. import {
  15. inputRules,
  16. wrappingInputRule,
  17. textblockTypeInputRule,
  18. InputRule
  19. } from 'prosemirror-inputrules'; // Import input rules
  20. import { splitListItem, liftListItem, sinkListItem } from 'prosemirror-schema-list'; // Import from prosemirror-schema-list
  21. import { keymap } from 'prosemirror-keymap';
  22. import { baseKeymap, chainCommands } from 'prosemirror-commands';
  23. import { DOMParser, DOMSerializer, Schema, Fragment } from 'prosemirror-model';
  24. import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
  25. export let className = 'input-prose';
  26. export let shiftEnter = false;
  27. export let id = '';
  28. export let value = '';
  29. export let placeholder = 'Type here...';
  30. export let trim = false;
  31. let element: HTMLElement; // Element where ProseMirror will attach
  32. let state;
  33. let view;
  34. // Plugin to add placeholder when the content is empty
  35. function placeholderPlugin(placeholder: string) {
  36. return new Plugin({
  37. props: {
  38. decorations(state) {
  39. const doc = state.doc;
  40. if (
  41. doc.childCount === 1 &&
  42. doc.firstChild.isTextblock &&
  43. doc.firstChild?.textContent === ''
  44. ) {
  45. // If there's nothing in the editor, show the placeholder decoration
  46. const decoration = Decoration.node(0, doc.content.size, {
  47. 'data-placeholder': placeholder,
  48. class: 'placeholder'
  49. });
  50. return DecorationSet.create(doc, [decoration]);
  51. }
  52. return DecorationSet.empty;
  53. }
  54. }
  55. });
  56. }
  57. function unescapeMarkdown(text: string): string {
  58. return text
  59. .replace(/\\([\\`*{}[\]()#+\-.!_>])/g, '$1') // unescape backslashed characters
  60. .replace(/&amp;/g, '&')
  61. .replace(/</g, '<')
  62. .replace(/>/g, '>')
  63. .replace(/&quot;/g, '"')
  64. .replace(/&#39;/g, "'");
  65. }
  66. // Custom parsing rule that creates proper paragraphs for newlines and empty lines
  67. function markdownToProseMirrorDoc(markdown: string) {
  68. // Split the markdown into lines
  69. const lines = markdown.split('\n\n');
  70. // Create an array to hold our paragraph nodes
  71. const paragraphs = [];
  72. // Process each line
  73. lines.forEach((line) => {
  74. if (line.trim() === '') {
  75. // For empty lines, create an empty paragraph
  76. paragraphs.push(schema.nodes.paragraph.create());
  77. } else {
  78. // For non-empty lines, parse as usual
  79. const doc = defaultMarkdownParser.parse(line);
  80. // Extract the content of the parsed document
  81. doc.content.forEach((node) => {
  82. paragraphs.push(node);
  83. });
  84. }
  85. });
  86. // Create a new document with these paragraphs
  87. return schema.node('doc', null, paragraphs);
  88. }
  89. // Create a custom serializer for paragraphs
  90. // Custom paragraph serializer to preserve newlines for empty paragraphs (empty block).
  91. function serializeParagraph(state, node: Node) {
  92. const content = node.textContent.trim();
  93. // If the paragraph is empty, just add an empty line.
  94. if (content === '') {
  95. state.write('\n\n');
  96. } else {
  97. state.renderInline(node);
  98. state.closeBlock(node);
  99. }
  100. }
  101. const customMarkdownSerializer = new defaultMarkdownSerializer.constructor(
  102. {
  103. ...defaultMarkdownSerializer.nodes,
  104. paragraph: (state, node) => {
  105. serializeParagraph(state, node); // Use custom paragraph serialization
  106. }
  107. // Customize other block formats if needed
  108. },
  109. // Copy marks directly from the original serializer (or customize them if necessary)
  110. defaultMarkdownSerializer.marks
  111. );
  112. // Utility function to convert ProseMirror content back to markdown text
  113. function serializeEditorContent(doc) {
  114. const markdown = customMarkdownSerializer.serialize(doc);
  115. if (trim) {
  116. return unescapeMarkdown(markdown).trim();
  117. } else {
  118. return unescapeMarkdown(markdown);
  119. }
  120. }
  121. // ---- Input Rules ----
  122. // Input rule for heading (e.g., # Headings)
  123. function headingRule(schema) {
  124. return textblockTypeInputRule(/^(#{1,6})\s$/, schema.nodes.heading, (match) => ({
  125. level: match[1].length
  126. }));
  127. }
  128. // Input rule for bullet list (e.g., `- item`)
  129. function bulletListRule(schema) {
  130. return wrappingInputRule(/^\s*([-+*])\s$/, schema.nodes.bullet_list);
  131. }
  132. // Input rule for ordered list (e.g., `1. item`)
  133. function orderedListRule(schema) {
  134. return wrappingInputRule(/^(\d+)\.\s$/, schema.nodes.ordered_list, (match) => ({
  135. order: +match[1]
  136. }));
  137. }
  138. // Custom input rules for Bold/Italic (using * or _)
  139. function markInputRule(regexp: RegExp, markType: any) {
  140. return new InputRule(regexp, (state, match, start, end) => {
  141. const { tr } = state;
  142. if (match) {
  143. tr.replaceWith(start, end, schema.text(match[1], [markType.create()]));
  144. }
  145. return tr;
  146. });
  147. }
  148. function boldRule(schema) {
  149. return markInputRule(/(?<=^|\s)\*([^*]+)\*(?=\s|$)/, schema.marks.strong);
  150. }
  151. function italicRule(schema) {
  152. // Using lookbehind and lookahead to prevent the space from being consumed
  153. return markInputRule(/(?<=^|\s)_([^*_]+)_(?=\s|$)/, schema.marks.em);
  154. }
  155. // Initialize Editor State and View
  156. function afterSpacePress(state, dispatch) {
  157. // Get the position right after the space was naturally inserted by the browser.
  158. let { from, to, empty } = state.selection;
  159. if (dispatch && empty) {
  160. let tr = state.tr;
  161. // Check for any active marks at `from - 1` (the space we just inserted)
  162. const storedMarks = state.storedMarks || state.selection.$from.marks();
  163. const hasBold = storedMarks.some((mark) => mark.type === state.schema.marks.strong);
  164. const hasItalic = storedMarks.some((mark) => mark.type === state.schema.marks.em);
  165. // Remove marks from the space character (marks applied to the space character will be marked as false)
  166. if (hasBold) {
  167. tr = tr.removeMark(from - 1, from, state.schema.marks.strong);
  168. }
  169. if (hasItalic) {
  170. tr = tr.removeMark(from - 1, from, state.schema.marks.em);
  171. }
  172. // Dispatch the resulting transaction to update the editor state
  173. dispatch(tr);
  174. }
  175. return true;
  176. }
  177. function toggleMark(markType) {
  178. return (state, dispatch) => {
  179. const { from, to } = state.selection;
  180. if (state.doc.rangeHasMark(from, to, markType)) {
  181. if (dispatch) dispatch(state.tr.removeMark(from, to, markType));
  182. return true;
  183. } else {
  184. if (dispatch) dispatch(state.tr.addMark(from, to, markType.create()));
  185. return true;
  186. }
  187. };
  188. }
  189. function isInList(state) {
  190. const { $from } = state.selection;
  191. return (
  192. $from.parent.type === schema.nodes.paragraph && $from.node(-1).type === schema.nodes.list_item
  193. );
  194. }
  195. function isEmptyListItem(state) {
  196. const { $from } = state.selection;
  197. return isInList(state) && $from.parent.content.size === 0 && $from.node(-1).childCount === 1;
  198. }
  199. function exitList(state, dispatch) {
  200. return liftListItem(schema.nodes.list_item)(state, dispatch);
  201. }
  202. function findNextTemplate(doc, from = 0) {
  203. const patterns = [
  204. { start: '[', end: ']' },
  205. { start: '{{', end: '}}' }
  206. ];
  207. let result = null;
  208. doc.nodesBetween(from, doc.content.size, (node, pos) => {
  209. if (result) return false; // Stop if we've found a match
  210. if (node.isText) {
  211. const text = node.text;
  212. let index = Math.max(0, from - pos);
  213. while (index < text.length) {
  214. for (const pattern of patterns) {
  215. if (text.startsWith(pattern.start, index)) {
  216. const endIndex = text.indexOf(pattern.end, index + pattern.start.length);
  217. if (endIndex !== -1) {
  218. result = {
  219. from: pos + index,
  220. to: pos + endIndex + pattern.end.length
  221. };
  222. return false; // Stop searching
  223. }
  224. }
  225. }
  226. index++;
  227. }
  228. }
  229. });
  230. return result;
  231. }
  232. function selectNextTemplate(state, dispatch) {
  233. const { doc, selection } = state;
  234. const from = selection.to;
  235. let template = findNextTemplate(doc, from);
  236. if (!template) {
  237. // If not found, search from the beginning
  238. template = findNextTemplate(doc, 0);
  239. }
  240. if (template) {
  241. if (dispatch) {
  242. const tr = state.tr.setSelection(TextSelection.create(doc, template.from, template.to));
  243. dispatch(tr);
  244. }
  245. return true;
  246. }
  247. return false;
  248. }
  249. // Replace tabs with four spaces
  250. function handleTabIndentation(text: string): string {
  251. // Replace each tab character with four spaces
  252. return text.replace(/\t/g, ' ');
  253. }
  254. onMount(() => {
  255. const initialDoc = markdownToProseMirrorDoc(value || ''); // Convert the initial content
  256. state = EditorState.create({
  257. doc: initialDoc,
  258. schema,
  259. plugins: [
  260. history(),
  261. placeholderPlugin(placeholder),
  262. inputRules({
  263. rules: [
  264. headingRule(schema), // Handle markdown-style headings (# H1, ## H2, etc.)
  265. bulletListRule(schema), // Handle `-` or `*` input to start bullet list
  266. orderedListRule(schema), // Handle `1.` input to start ordered list
  267. boldRule(schema), // Bold input rule
  268. italicRule(schema) // Italic input rule
  269. ]
  270. }),
  271. keymap({
  272. ...baseKeymap,
  273. 'Mod-z': undo,
  274. 'Mod-y': redo,
  275. Enter: (state, dispatch, view) => {
  276. if (shiftEnter) {
  277. eventDispatch('enter');
  278. return true;
  279. }
  280. return chainCommands(
  281. (state, dispatch, view) => {
  282. if (isEmptyListItem(state)) {
  283. return exitList(state, dispatch);
  284. }
  285. return false;
  286. },
  287. (state, dispatch, view) => {
  288. if (isInList(state)) {
  289. return splitListItem(schema.nodes.list_item)(state, dispatch);
  290. }
  291. return false;
  292. },
  293. baseKeymap.Enter
  294. )(state, dispatch, view);
  295. },
  296. 'Shift-Enter': (state, dispatch, view) => {
  297. if (shiftEnter) {
  298. return chainCommands(
  299. (state, dispatch, view) => {
  300. if (isEmptyListItem(state)) {
  301. return exitList(state, dispatch);
  302. }
  303. return false;
  304. },
  305. (state, dispatch, view) => {
  306. if (isInList(state)) {
  307. return splitListItem(schema.nodes.list_item)(state, dispatch);
  308. }
  309. return false;
  310. },
  311. baseKeymap.Enter
  312. )(state, dispatch, view);
  313. } else {
  314. return baseKeymap.Enter(state, dispatch, view);
  315. }
  316. return false;
  317. },
  318. // Prevent default tab navigation and provide indent/outdent behavior inside lists:
  319. Tab: chainCommands((state, dispatch, view) => {
  320. const { $from } = state.selection;
  321. if (isInList(state)) {
  322. return sinkListItem(schema.nodes.list_item)(state, dispatch);
  323. } else {
  324. return selectNextTemplate(state, dispatch);
  325. }
  326. return true; // Prevent Tab from moving the focus
  327. }),
  328. 'Shift-Tab': (state, dispatch, view) => {
  329. const { $from } = state.selection;
  330. if (isInList(state)) {
  331. return liftListItem(schema.nodes.list_item)(state, dispatch);
  332. }
  333. return true; // Prevent Shift-Tab from moving the focus
  334. },
  335. 'Mod-b': toggleMark(schema.marks.strong),
  336. 'Mod-i': toggleMark(schema.marks.em)
  337. })
  338. ]
  339. });
  340. view = new EditorView(element, {
  341. state,
  342. dispatchTransaction(transaction) {
  343. // Update editor state
  344. let newState = view.state.apply(transaction);
  345. view.updateState(newState);
  346. value = serializeEditorContent(newState.doc); // Convert ProseMirror content to markdown text
  347. eventDispatch('input', { value });
  348. },
  349. handleDOMEvents: {
  350. focus: (view, event) => {
  351. eventDispatch('focus', { event });
  352. return false;
  353. },
  354. keypress: (view, event) => {
  355. eventDispatch('keypress', { event });
  356. return false;
  357. },
  358. keydown: (view, event) => {
  359. eventDispatch('keydown', { event });
  360. return false;
  361. },
  362. paste: (view, event) => {
  363. if (event.clipboardData) {
  364. // Extract plain text from clipboard and paste it without formatting
  365. const plainText = event.clipboardData.getData('text/plain');
  366. if (plainText) {
  367. if (plainText.length > PASTED_TEXT_CHARACTER_LIMIT) {
  368. // Dispatch paste event to parent component
  369. eventDispatch('paste', { event });
  370. event.preventDefault();
  371. return true;
  372. }
  373. const modifiedText = handleTabIndentation(plainText);
  374. console.log(modifiedText);
  375. // Replace the current selection with the plain text content
  376. const tr = view.state.tr.replaceSelectionWith(
  377. view.state.schema.text(modifiedText),
  378. false
  379. );
  380. view.dispatch(tr.scrollIntoView());
  381. event.preventDefault(); // Prevent the default paste behavior
  382. return true;
  383. }
  384. // Check if the pasted content contains image files
  385. const hasImageFile = Array.from(event.clipboardData.files).some((file) =>
  386. file.type.startsWith('image/')
  387. );
  388. // Check for image in dataTransfer items (for cases where files are not available)
  389. const hasImageItem = Array.from(event.clipboardData.items).some((item) =>
  390. item.type.startsWith('image/')
  391. );
  392. if (hasImageFile) {
  393. // If there's an image, dispatch the event to the parent
  394. eventDispatch('paste', { event });
  395. event.preventDefault();
  396. return true;
  397. }
  398. if (hasImageItem) {
  399. // If there's an image item, dispatch the event to the parent
  400. eventDispatch('paste', { event });
  401. event.preventDefault();
  402. return true;
  403. }
  404. }
  405. // For all other cases (text, formatted text, etc.), let ProseMirror handle it
  406. return false;
  407. },
  408. // Handle space input after browser has completed it
  409. keyup: (view, event) => {
  410. if (event.key === ' ' && event.code === 'Space') {
  411. afterSpacePress(view.state, view.dispatch);
  412. }
  413. return false;
  414. }
  415. },
  416. attributes: { id }
  417. });
  418. });
  419. // Reinitialize the editor if the value is externally changed (i.e. when `value` is updated)
  420. $: if (view && value !== serializeEditorContent(view.state.doc)) {
  421. const newDoc = markdownToProseMirrorDoc(value || '');
  422. const newState = EditorState.create({
  423. doc: newDoc,
  424. schema,
  425. plugins: view.state.plugins,
  426. selection: TextSelection.atEnd(newDoc) // This sets the cursor at the end
  427. });
  428. view.updateState(newState);
  429. if (value !== '') {
  430. // After updating the state, try to find and select the next template
  431. setTimeout(() => {
  432. const templateFound = selectNextTemplate(view.state, view.dispatch);
  433. if (!templateFound) {
  434. // If no template found, set cursor at the end
  435. const endPos = view.state.doc.content.size;
  436. view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, endPos)));
  437. }
  438. }, 0);
  439. }
  440. }
  441. // Destroy ProseMirror instance on unmount
  442. onDestroy(() => {
  443. view?.destroy();
  444. });
  445. </script>
  446. <div bind:this={element} class="relative w-full min-w-full h-full min-h-fit {className}"></div>