RichTextInput.svelte 13 KB

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