diff --git a/editors/editoria/src/config/config.js b/editors/editoria/src/config/config.js index 7072aeab13c9c70d5f17b45059cd5bd9f521dec8..a10a5e399d5f0babefb052f069c8a1a7db5b723f 100644 --- a/editors/editoria/src/config/config.js +++ b/editors/editoria/src/config/config.js @@ -25,6 +25,7 @@ import { CodeBlockToolGroupService, TrackChangeToolGroupService, DisplayTextToolGroupService, + MathService, } from 'wax-prosemirror-services'; import { WaxSelectionPlugin } from 'wax-prosemirror-plugins'; @@ -97,5 +98,6 @@ export default { new CodeBlockToolGroupService(), new TrackChangeToolGroupService(), new DisplayTextToolGroupService(), + new MathService(), ], }; diff --git a/wax-prosemirror-plugins/index.js b/wax-prosemirror-plugins/index.js index 72d283b67322f401b18d557fa3cd3d42652009e0..819d258b5e5fcd2f7faa176493b63799d35f2056 100644 --- a/wax-prosemirror-plugins/index.js +++ b/wax-prosemirror-plugins/index.js @@ -3,3 +3,6 @@ export { default as TrackChangePlugin } from './src/trackChanges/TrackChangePlug export { default as CommentPlugin } from './src/comments/CommentPlugin'; export { default as WaxSelectionPlugin } from './src/WaxSelectionPlugin'; export { default as highlightPlugin } from './src/highlightPlugin'; + +export { default as mathPlugin } from './src/math/math-plugin'; +export { default as mathSelectPlugin } from './src/math/math-select'; diff --git a/wax-prosemirror-plugins/package.json b/wax-prosemirror-plugins/package.json index 3e3f5adebdaf1202b61631efcc525c78f3007c60..86635e611941ae990749878a9fb698a1895eec38 100644 --- a/wax-prosemirror-plugins/package.json +++ b/wax-prosemirror-plugins/package.json @@ -18,6 +18,9 @@ "prosemirror-highlightjs": "^0.2.0", "prosemirror-state": "1.3.3", "prosemirror-view": "1.15.2", + "prosemirror-transform": "1.2.6", + "prosemirror-keymap": "1.1.4", + "prosemirror-commands": "1.1.4", "wax-prosemirror-components": "^0.0.20", "wax-prosemirror-core": "^0.0.20", "wax-prosemirror-utilities": "^0.0.20", diff --git a/wax-prosemirror-plugins/src/math/math-nodeview.js b/wax-prosemirror-plugins/src/math/math-nodeview.js new file mode 100644 index 0000000000000000000000000000000000000000..81bcd0d3373d512159aa0b84271644d7bfea65a0 --- /dev/null +++ b/wax-prosemirror-plugins/src/math/math-nodeview.js @@ -0,0 +1,281 @@ +/* eslint-disable */ + +import { EditorState, TextSelection } from 'prosemirror-state'; +import { EditorView } from 'prosemirror-view'; +import { StepMap } from 'prosemirror-transform'; +import { keymap } from 'prosemirror-keymap'; +import { + newlineInCode, + chainCommands, + deleteSelection, +} from 'prosemirror-commands'; +// katex +import katex, { ParseError } from 'katex'; +export class MathView { + // == Lifecycle ===================================== // + /** + * @param onDestroy Callback for when this NodeView is destroyed. + * This NodeView should unregister itself from the list of ICursorPosObservers. + * + * Math Views support the following options: + * @option displayMode If TRUE, will render math in display mode, otherwise in inline mode. + * @option tagName HTML tag name to use for this NodeView. If none is provided, + * will use the node name with underscores converted to hyphens. + */ + constructor(node, view, getPos, options = {}, onDestroy) { + // store arguments + this._node = node; + this._outerView = view; + this._getPos = getPos; + this._onDestroy = onDestroy && onDestroy.bind(this); + // editing state + this.cursorSide = 'start'; + this._isEditing = false; + // options + this._katexOptions = Object.assign( + { globalGroup: true, throwOnError: false }, + options.katexOptions, + ); + this._tagName = options.tagName || this._node.type.name.replace('_', '-'); + // create dom representation of nodeview + this.dom = document.createElement(this._tagName); + this.dom.classList.add('math-node'); + this._mathRenderElt = document.createElement('span'); + this._mathRenderElt.textContent = ''; + this._mathRenderElt.classList.add('math-render'); + this.dom.appendChild(this._mathRenderElt); + this._mathSrcElt = document.createElement('span'); + this._mathSrcElt.classList.add('math-src'); + this.dom.appendChild(this._mathSrcElt); + // ensure + this.dom.addEventListener('click', () => this.ensureFocus()); + // render initial content + this.renderMath(); + } + destroy() { + // close the inner editor without rendering + this.closeEditor(false); + // clean up dom elements + if (this._mathRenderElt) { + this._mathRenderElt.remove(); + delete this._mathRenderElt; + } + if (this._mathSrcElt) { + this._mathSrcElt.remove(); + delete this._mathSrcElt; + } + delete this.dom; + } + /** + * Ensure focus on the inner editor whenever this node has focus. + * This helps to prevent accidental deletions of math blocks. + */ + ensureFocus() { + if (this._innerView && this._outerView.hasFocus()) { + this._innerView.focus(); + } + } + // == Updates ======================================= // + update(node, decorations) { + if (!node.sameMarkup(this._node)) return false; + this._node = node; + if (this._innerView) { + let state = this._innerView.state; + let start = node.content.findDiffStart(state.doc.content); + if (start != null) { + let diff = node.content.findDiffEnd(state.doc.content); + if (diff) { + let { a: endA, b: endB } = diff; + let overlap = start - Math.min(endA, endB); + if (overlap > 0) { + endA += overlap; + endB += overlap; + } + this._innerView.dispatch( + state.tr + .replace(start, endB, node.slice(start, endA)) + .setMeta('fromOutside', true), + ); + } + } + } + if (!this._isEditing) { + this.renderMath(); + } + return true; + } + updateCursorPos(state) { + const pos = this._getPos(); + const size = this._node.nodeSize; + const inPmSelection = + state.selection.from < pos + size && pos < state.selection.to; + if (!inPmSelection) { + this.cursorSide = pos < state.selection.from ? 'end' : 'start'; + } + } + // == Events ===================================== // + selectNode() { + this.dom.classList.add('ProseMirror-selectednode'); + if (!this._isEditing) { + this.openEditor(); + } + } + deselectNode() { + this.dom.classList.remove('ProseMirror-selectednode'); + if (this._isEditing) { + this.closeEditor(); + } + } + stopEvent(event) { + return ( + this._innerView !== undefined && + event.target !== undefined && + this._innerView.dom.contains(event.target) + ); + } + ignoreMutation() { + return true; + } + // == Rendering ===================================== // + renderMath() { + if (!this._mathRenderElt) { + return; + } + // get tex string to render + let content = this._node.content.content; + let texString = ''; + if (content.length > 0 && content[0].textContent !== null) { + texString = content[0].textContent.trim(); + } + // empty math? + if (texString.length < 1) { + this.dom.classList.add('empty-math'); + // clear rendered math, since this node is in an invalid state + while (this._mathRenderElt.firstChild) { + this._mathRenderElt.firstChild.remove(); + } + // do not render empty math + return; + } else { + this.dom.classList.remove('empty-math'); + } + // render katex, but fail gracefully + try { + katex.render(texString, this._mathRenderElt, this._katexOptions); + this._mathRenderElt.classList.remove('parse-error'); + this.dom.setAttribute('title', ''); + } catch (err) { + if (err instanceof ParseError) { + console.error(err); + this._mathRenderElt.classList.add('parse-error'); + this.dom.setAttribute('title', err.toString()); + } else { + throw err; + } + } + } + // == Inner Editor ================================== // + dispatchInner(tr) { + if (!this._innerView) { + return; + } + let { state, transactions } = this._innerView.state.applyTransaction(tr); + this._innerView.updateState(state); + if (!tr.getMeta('fromOutside')) { + let outerTr = this._outerView.state.tr, + offsetMap = StepMap.offset(this._getPos() + 1); + for (let i = 0; i < transactions.length; i++) { + let steps = transactions[i].steps; + for (let j = 0; j < steps.length; j++) { + let mapped = steps[j].map(offsetMap); + if (!mapped) { + throw Error('step discarded!'); + } + outerTr.step(mapped); + } + } + if (outerTr.docChanged) this._outerView.dispatch(outerTr); + } + } + openEditor() { + if (this._innerView) { + throw Error('inner view should not exist!'); + } + // create a nested ProseMirror view + this._innerView = new EditorView(this._mathSrcElt, { + state: EditorState.create({ + doc: this._node, + plugins: [ + keymap({ + Tab: (state, dispatch) => { + if (dispatch) { + dispatch(state.tr.insertText('\t')); + } + return true; + }, + Backspace: chainCommands( + deleteSelection, + (state, dispatch, tr_inner) => { + // default backspace behavior for non-empty selections + if (!state.selection.empty) { + return false; + } + // default backspace behavior when math node is non-empty + if (this._node.textContent.length > 0) { + return false; + } + // otherwise, we want to delete the empty math node and focus the outer view + this._outerView.dispatch( + this._outerView.state.tr.insertText(''), + ); + this._outerView.focus(); + return true; + }, + ), + Enter: newlineInCode, + 'Ctrl-Enter': (state, dispatch) => { + let { to } = this._outerView.state.selection; + let outerState = this._outerView.state; + // place cursor outside of math node + this._outerView.dispatch( + outerState.tr.setSelection( + TextSelection.create(outerState.doc, to), + ), + ); + // must return focus to the outer view, + // otherwise no cursor will appear + this._outerView.focus(); + return true; + }, + }), + ], + }), + dispatchTransaction: this.dispatchInner.bind(this), + }); + // focus element + let innerState = this._innerView.state; + this._innerView.focus(); + // determine cursor position + let pos = this.cursorSide == 'start' ? 0 : this._node.nodeSize - 2; + this._innerView.dispatch( + innerState.tr.setSelection(TextSelection.create(innerState.doc, pos)), + ); + this._isEditing = true; + } + /** + * Called when the inner ProseMirror editor should close. + * + * @param render Optionally update the rendered math after closing. (which + * is generally what we want to do, since the user is done editing!) + */ + closeEditor(render = true) { + if (this._innerView) { + this._innerView.destroy(); + this._innerView = undefined; + } + if (render) { + this.renderMath(); + } + this._isEditing = false; + } +} diff --git a/wax-prosemirror-plugins/src/math/math-plugin.js b/wax-prosemirror-plugins/src/math/math-plugin.js new file mode 100644 index 0000000000000000000000000000000000000000..e9569035f1632a221b5f1e473df721acaf1eee06 --- /dev/null +++ b/wax-prosemirror-plugins/src/math/math-plugin.js @@ -0,0 +1,67 @@ +import { Plugin as ProsePlugin, PluginKey } from 'prosemirror-state'; +import { MathView } from './math-nodeview'; +/** + * Returns a function suitable for passing as a field in `EditorProps.nodeViews`. + * @param displayMode TRUE for block math, FALSE for inline math. + * @see https://prosemirror.net/docs/ref/#view.EditorProps.nodeViews + */ +function createMathView(displayMode) { + return (node, view, getPos) => { + /** @todo is this necessary? + * Docs says that for any function proprs, the current plugin instance + * will be bound to `this`. However, the typings don't reflect this. + */ + const pluginState = mathPluginKey.getState(view.state); + if (!pluginState) { + throw new Error('no math plugin!'); + } + const nodeViews = pluginState.activeNodeViews; + // set up NodeView + const nodeView = new MathView( + node, + view, + getPos, + { katexOptions: { displayMode, macros: pluginState.macros } }, + () => { + nodeViews.splice(nodeViews.indexOf(nodeView)); + }, + ); + nodeViews.push(nodeView); + return nodeView; + }; +} +const mathPluginKey = new PluginKey('prosemirror-math'); +const mathPluginSpec = { + key: mathPluginKey, + state: { + init(config, instance) { + return { + macros: {}, + activeNodeViews: [], + }; + }, + apply(tr, value, oldState, newState) { + /** @todo (8/21/20) + * since new state has not been fully applied yet, we don't yet have + * information about any new MathViews that were created by this transaction. + * As a result, the cursor position may be wrong for any newly created math blocks. + */ + const pluginState = mathPluginKey.getState(oldState); + if (pluginState) { + for (let mathView of pluginState.activeNodeViews) { + mathView.updateCursorPos(newState); + } + } + return value; + }, + }, + props: { + nodeViews: { + math_inline: createMathView(false), + math_display: createMathView(true), + }, + }, +}; +const mathPlugin = new ProsePlugin(mathPluginSpec); + +export default mathPlugin; diff --git a/wax-prosemirror-plugins/src/math/math-select.js b/wax-prosemirror-plugins/src/math/math-select.js new file mode 100644 index 0000000000000000000000000000000000000000..9d9e874541f69e01299c2ed65e99af4f6a99a20c --- /dev/null +++ b/wax-prosemirror-plugins/src/math/math-select.js @@ -0,0 +1,65 @@ +// prosemirror imports +import { Plugin as ProsePlugin } from 'prosemirror-state'; +import { DecorationSet, Decoration } from 'prosemirror-view'; + +/** + * Uses the selection to determine which math_select decorations + * should be applied to the given document. + * @param arg Should be either a Transaction or an EditorState, + * although any object with `selection` and `doc` will work. + */ + +const checkSelection = arg => { + const { from, to } = arg; + const { content } = arg.selection.content(); + const result = []; + content.descendants((node, pos, parent) => { + if (node.type.name === 'text') { + return false; + } + if (node.type.name.startsWith('math_')) { + result.push({ + start: Math.max(from + pos - 1, 0), + end: from + pos + node.nodeSize - 1, + }); + return false; + } + return true; + }); + return DecorationSet.create( + arg.doc, + result.map(({ start, end }) => + Decoration.node(start, end, { class: 'math-select' }), + ), + ); +}; +/** + * Due to the internals of KaTeX, by default, selecting rendered + * math will put a box around each individual character of a + * math expression. This plugin attempts to make math selections + * slightly prettier by instead setting a background color on the node. + * + * (remember to use the included math.css!) + * + * @todo (6/13/20) math selection rectangles are not quite even with text + */ +const mathSelectPlugin = new ProsePlugin({ + state: { + init(config, partialState) { + return checkSelection(partialState); + }, + apply(tr, oldState) { + if (!tr.selection || !tr.selectionSet) { + return oldState; + } + const sel = checkSelection(tr); + return sel; + }, + }, + props: { + decorations: state => { + return mathSelectPlugin.getState(state); + }, + }, +}); +export default mathSelectPlugin; diff --git a/wax-prosemirror-services/src/MathService/MathService.js b/wax-prosemirror-services/src/MathService/MathService.js index e194427dc9f64bdd5ff35a21f6e8a65ce5cf8168..267aa147ee98f46d00a64d1b69adb91e0c2aed3a 100644 --- a/wax-prosemirror-services/src/MathService/MathService.js +++ b/wax-prosemirror-services/src/MathService/MathService.js @@ -1,9 +1,14 @@ +import { mathPlugin, mathSelectPlugin } from 'wax-prosemirror-plugins'; import Service from '../Service'; class MathService extends Service { name = 'MathService'; - boot() {} + boot() { + this.app.PmPlugins.add('mathplugin', mathPlugin); + this.app.PmPlugins.add('mathselectplugin', mathSelectPlugin); + console.log(this.app); + } register() {} }