// Copyright (C) 2017 University of Illinois Board of Trustees // Copyright (C) 2024 Marijn Haverbeke // Copyright (C) 2024 Stefan Goessner // Contains parts of // https://github.com/ProseMirror/prosemirror-markdown/blob/99b6f0a6c377a2c010320f4fdd883e4868aaf122/src/from_markdown.ts // Used under the MIT license. // Contains parts of // https://github.com/goessner/markdown-it-texmath/blob/5a1133b210cb6c69b07bc307ddb2c46491402b3f/texmath.js // Used under the MIT license. // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. import 'katex/dist/katex.min.css'; // prosemirror imports import { Schema, Node, Slice } from 'prosemirror-model'; import { EditorView } from 'prosemirror-view'; import { EditorState, Plugin, PluginKey } from 'prosemirror-state'; import { schema as basicSchema } from 'prosemirror-schema-basic'; import { addListNodes } from 'prosemirror-schema-list'; import { chainCommands, deleteSelection, selectNodeBackward, joinBackward, } from 'prosemirror-commands'; import { keymap } from 'prosemirror-keymap'; import { inputRules } from 'prosemirror-inputrules'; import 'prosemirror-view/style/prosemirror.css'; import 'prosemirror-menu/style/menu.css'; import { exampleSetup } from 'prosemirror-example-setup'; import 'prosemirror-example-setup/style/style.css'; import { MarkdownParser } from 'prosemirror-markdown'; import MarkdownIt from 'markdown-it'; import { mergeDelimiters, inline as texmathInline, block as texmathBlock } from 'markdown-it-texmath'; import { mathPlugin, mathBackspaceCmd, insertMathCmd, mathSerializer, makeBlockMathInputRule, makeInlineMathInputRule, REGEX_INLINE_MATH_DOLLARS, REGEX_BLOCK_MATH_DOLLARS, // I've no idea why eslint can't find the module; rollup can. // eslint-disable-next-line import/no-unresolved } from '@benrbray/prosemirror-math'; import '@benrbray/prosemirror-math/dist/prosemirror-math.css'; let anyEditorChangedFlag = false; export function anyEditorChanged() { return anyEditorChangedFlag; } export function resetAnyEditorChanged() { anyEditorChangedFlag = false; } const schema = new Schema({ nodes: addListNodes(basicSchema.spec.nodes, 'paragraph block*', 'block') .remove('image') .addToEnd('math_inline', { group: 'inline math', content: 'text*', // important! inline: true, // important! atom: true, // important! toDOM: () => ['math-inline', { class: 'math-node' }, 0], parseDOM: [{ tag: 'math-inline', // important! }], }) .addToEnd('math_display', { group: 'block math', content: 'text*', // important! atom: true, // important! code: true, // important! toDOM: () => ['math-display', { class: 'math-node' }, 0], parseDOM: [{ tag: 'math-display', // important! }], }), marks: basicSchema.spec.marks, }); const inlineMathInputRule = makeInlineMathInputRule( REGEX_INLINE_MATH_DOLLARS, schema.nodes.math_inline, ); const blockMathInputRule = makeBlockMathInputRule( REGEX_BLOCK_MATH_DOLLARS, schema.nodes.math_display, ); const readonlyPlugin = new Plugin({ key: new PluginKey('readonly'), // Allow selections but prevent any other changes filterTransaction: (transaction) => transaction.docChanged === false, }); const changeListenerPlugin = new Plugin({ key: new PluginKey('readonly'), filterTransaction: (transaction) => { if (transaction.docChanged) { anyEditorChangedFlag = true; } return true; }, }); // {{{ handle markdown paste function listIsTight(tokens, i) { // eslint-disable-next-line no-plusplus, no-param-reassign while (++i < tokens.length) { if (tokens[i].type !== 'list_item_open') return tokens[i].hidden; } return false; } const markdownItWithMath = (() => { const md = MarkdownIt('commonmark', { html: false }); const delimiters = mergeDelimiters(['dollars', 'beg_end']); // inject rules into markdown-it delimiters.inline.forEach((baseRule) => { const rule = { ...baseRule }; if ('outerSpace' in rule) rule.outerSpace = true; md.inline.ruler.before('escape', rule.name, texmathInline(rule)); }); delimiters.block.forEach((rule) => { md.block.ruler.before('fence', rule.name, texmathBlock(rule)); }); return md; })(); const markdownParser = new MarkdownParser(schema, markdownItWithMath, { blockquote: { block: 'blockquote' }, paragraph: { block: 'paragraph' }, list_item: { block: 'list_item' }, bullet_list: { block: 'bullet_list', getAttrs: (_, tokens, i) => ({ tight: listIsTight(tokens, i) }), }, ordered_list: { block: 'ordered_list', getAttrs: (tok, tokens, i) => ({ order: +tok.attrGet('start') || 1, tight: listIsTight(tokens, i), }), }, heading: { block: 'heading', getAttrs: (tok) => ({ level: +tok.tag.slice(1) }), }, code_block: { block: 'code_block', noCloseToken: true }, fence: { block: 'code_block', getAttrs: (tok) => ({ params: tok.info || '' }), noCloseToken: true, }, hr: { node: 'horizontal_rule' }, hardbreak: { node: 'hard_break' }, em: { mark: 'em' }, strong: { mark: 'strong' }, link: { mark: 'link', getAttrs: (tok) => ({ href: tok.attrGet('href'), title: tok.attrGet('title') || null, }), }, code_inline: { mark: 'code', noCloseToken: true }, math_inline: { block: 'math_inline', noCloseToken: true }, math_block: { block: 'math_display', noCloseToken: true }, }); const pasteMarkdownPlugin = new Plugin({ props: { handlePaste(view, event/* , slice */) { const clipboardText = event.clipboardData.getData('text/plain'); if (!clipboardText) return false; const doc = markdownParser.parse(clipboardText); const transaction = view.state.tr.replaceSelection(new Slice(doc.content, 0, 0)); view.dispatch(transaction); return true; }, }, }); // }}} // eslint-disable-next-line import/prefer-default-export export function editorFromTextArea(textarea, autofocus) { const plugins = [ ...exampleSetup({ schema }), mathPlugin, pasteMarkdownPlugin, keymap({ 'Mod-Space': insertMathCmd(schema.nodes.math_inline), Backspace: chainCommands( deleteSelection, mathBackspaceCmd, joinBackward, selectNodeBackward, ), }), inputRules({ rules: [inlineMathInputRule, blockMathInputRule] }), ]; if (textarea.disabled || textarea.readOnly) { plugins.push(readonlyPlugin); } // Change listener should be after readonly. plugins.push(changeListenerPlugin); let docJson = null; if (textarea.value) { docJson = JSON.parse(textarea.value); } let doc; if (docJson) { doc = Node.fromJSON(schema, docJson); } else { doc = schema.topNodeType.createAndFill(); } const state = EditorState.create({ schema, plugins, doc }); const editorElt = document.createElement('div'); editorElt.classList.add('rl-prosemirror-container'); const view = new EditorView(editorElt, { state, clipboardTextSerializer: mathSerializer.serializeSlice, }); if (autofocus) { document.addEventListener('DOMContentLoaded', () => { view.focus(); }); } textarea.parentNode.insertBefore(editorElt, textarea); // eslint-disable-next-line no-param-reassign textarea.style.display = 'none'; if (textarea.form) { textarea.form.addEventListener('submit', () => { // eslint-disable-next-line no-param-reassign textarea.value = JSON.stringify(view.state.doc.toJSON()); }); } textarea.classList.add('rl-managed-by-prosemirror'); return view; }