RichTextInput.svelte 12 KB

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