diff --git a/wax-prosemirror-services/src/MatchingService/MatchingOptionNodeView.js b/wax-prosemirror-services/src/MatchingService/MatchingOptionNodeView.js new file mode 100644 index 0000000000000000000000000000000000000000..f5a9656cbef57d6f32dc2a190cf89c46c5735a11 --- /dev/null +++ b/wax-prosemirror-services/src/MatchingService/MatchingOptionNodeView.js @@ -0,0 +1,32 @@ +import QuestionsNodeView from '../lib/helpers/QuestionsNodeView'; + +export default class MatchingOptionNodeView extends QuestionsNodeView { + constructor( + node, + view, + getPos, + decorations, + createPortal, + Component, + context, + ) { + super(node, view, getPos, decorations, createPortal, Component, context); + + this.node = node; + this.outerView = view; + this.getPos = getPos; + this.context = context; + } + + static name() { + return 'matching_option'; + } + + stopEvent(event) { + if (event.target.type === 'text') { + return true; + } + const innerView = this.context.pmViews[this.node.attrs.id]; + return innerView && innerView.dom.contains(event.target); + } +} diff --git a/wax-prosemirror-services/src/MatchingService/MatchingService.js b/wax-prosemirror-services/src/MatchingService/MatchingService.js index c95696a11b9f41ef9c8726ba96fe461f7fbe91b5..77df0ee9bf0aac232c004432965edb3e974c41cf 100644 --- a/wax-prosemirror-services/src/MatchingService/MatchingService.js +++ b/wax-prosemirror-services/src/MatchingService/MatchingService.js @@ -1,8 +1,11 @@ import Service from '../Service'; import MatchingQuestion from './MatchingQuestion'; import matchingContainerNode from './schema/matchingContainerNode'; +import matchingOptionNode from './schema/matchingOptionNode'; import MatchingContainerNodeView from './MatchingContainerNodeView'; +import MatchingOptionNodeView from './MatchingOptionNodeView'; import MatchingContainerComponent from './components/MatchingContainerComponent'; +import MatchingOptionComponent from './MatchingOptionComponent'; class MatchingService extends Service { name = 'MatchingService'; @@ -16,11 +19,21 @@ class MatchingService extends Service { matching_container: matchingContainerNode, }); + createNode({ + matching_option: matchingOptionNode, + }); + addPortal({ nodeView: MatchingContainerNodeView, component: MatchingContainerComponent, context: this.app, }); + + addPortal({ + nodeView: MatchingOptionNodeView, + component: MatchingOptionComponent, + context: this.app, + }); } } diff --git a/wax-prosemirror-services/src/MatchingService/components/EditorComponent.js b/wax-prosemirror-services/src/MatchingService/components/EditorComponent.js new file mode 100644 index 0000000000000000000000000000000000000000..9f4cc72aab0d59e19ef7bc41cf4962ed2b128e2f --- /dev/null +++ b/wax-prosemirror-services/src/MatchingService/components/EditorComponent.js @@ -0,0 +1,180 @@ +/* eslint-disable react/prop-types */ + +import React, { useContext, useRef, useEffect } from 'react'; +import styled from 'styled-components'; +import { EditorView } from 'prosemirror-view'; +import { EditorState, TextSelection } from 'prosemirror-state'; +import { StepMap } from 'prosemirror-transform'; +import { keymap } from 'prosemirror-keymap'; +import { baseKeymap } from 'prosemirror-commands'; +import { undo, redo } from 'prosemirror-history'; +import { WaxContext } from 'wax-prosemirror-core'; +import Placeholder from '../../MultipleChoiceQuestionService/plugins/placeholder'; + +const EditorWrapper = styled.div` + border: none; + display: flex; + flex: 2 1 auto; + justify-content: left; + + .ProseMirror { + white-space: break-spaces; + width: 100%; + word-wrap: break-word; + + &:focus { + outline: none; + } + + :empty::before { + content: 'Type your answer'; + color: #aaa; + float: left; + font-style: italic; + pointer-events: none; + } + + p:first-child { + margin: 0; + } + + p.empty-node:first-child::before { + content: attr(data-content); + } + + .empty-node::before { + color: rgb(170, 170, 170); + float: left; + font-style: italic; + height: 0px; + pointer-events: none; + } + } +`; + +const EditorComponent = ({ node, view, getPos }) => { + const editorRef = useRef(); + + const context = useContext(WaxContext); + const { + app, + pmViews: { main }, + } = context; + let questionView; + const questionId = node.attrs.id; + const isEditable = main.props.editable(editable => { + return editable; + }); + + let finalPlugins = []; + + const createKeyBindings = () => { + const keys = getKeys(); + Object.keys(baseKeymap).forEach(key => { + keys[key] = baseKeymap[key]; + }); + return keys; + }; + + const getKeys = () => { + return { + 'Mod-z': () => undo(view.state, view.dispatch), + 'Mod-y': () => redo(view.state, view.dispatch), + }; + }; + + const plugins = [keymap(createKeyBindings()), ...app.getPlugins()]; + + // eslint-disable-next-line no-shadow + const createPlaceholder = placeholder => { + return Placeholder({ + content: placeholder, + }); + }; + + finalPlugins = finalPlugins.concat([ + createPlaceholder('Type your answer'), + ...plugins, + ]); + + useEffect(() => { + questionView = new EditorView( + { + mount: editorRef.current, + }, + { + editable: () => isEditable, + state: EditorState.create({ + doc: node, + plugins: finalPlugins, + }), + dispatchTransaction, + disallowedTools: ['Images', 'Lists', 'lift', 'MultipleChoice'], + handleDOMEvents: { + mousedown: () => { + main.dispatch( + main.state.tr + .setMeta('outsideView', questionId) + .setSelection( + new TextSelection( + main.state.tr.doc.resolve( + getPos() + + 2 + + context.pmViews[questionId].state.selection.to, + ), + ), + ), + ); + + context.updateView({}, questionId); + if (questionView.hasFocus()) questionView.focus(); + }, + blur: (editorView, event) => { + if (questionView && event.relatedTarget === null) { + questionView.focus(); + } + }, + }, + + attributes: { + spellcheck: 'false', + }, + }, + ); + + // Set Each note into Wax's Context + context.updateView( + { + [questionId]: questionView, + }, + questionId, + ); + if (questionView.hasFocus()) questionView.focus(); + }, []); + + const dispatchTransaction = tr => { + const { state, transactions } = questionView.state.applyTransaction(tr); + questionView.updateState(state); + context.updateView({}, questionId); + + if (!tr.getMeta('fromOutside')) { + const outerTr = view.state.tr; + const offsetMap = StepMap.offset(getPos() + 1); + for (let i = 0; i < transactions.length; i++) { + const { steps } = transactions[i]; + for (let j = 0; j < steps.length; j++) + outerTr.step(steps[j].map(offsetMap)); + } + if (outerTr.docChanged) + view.dispatch(outerTr.setMeta('outsideView', questionId)); + } + }; + + return ( + <EditorWrapper> + <div ref={editorRef} /> + </EditorWrapper> + ); +}; + +export default EditorComponent; diff --git a/wax-prosemirror-services/src/MatchingService/components/MatchingOptionComponent.js b/wax-prosemirror-services/src/MatchingService/components/MatchingOptionComponent.js new file mode 100644 index 0000000000000000000000000000000000000000..efc17d00e85d22f08872143943820c9a7851260f --- /dev/null +++ b/wax-prosemirror-services/src/MatchingService/components/MatchingOptionComponent.js @@ -0,0 +1,7 @@ +/* eslint-disable react/prop-types */ +import React from 'react'; +import EditorComponent from './EditorComponent'; + +export default ({ node, view, getPos }) => { + return <EditorComponent getPos={getPos} node={node} view={view} />; +}; diff --git a/wax-prosemirror-services/src/MatchingService/schema/matchingOptionNode.js b/wax-prosemirror-services/src/MatchingService/schema/matchingOptionNode.js new file mode 100644 index 0000000000000000000000000000000000000000..e07647b7cc0a5f7f9c05d3f3508de4ad297f9384 --- /dev/null +++ b/wax-prosemirror-services/src/MatchingService/schema/matchingOptionNode.js @@ -0,0 +1,29 @@ +const matchingOptionNode = { + attrs: { + class: { default: 'matching-option' }, + id: { default: '' }, + correct: { default: false }, + answer: { default: false }, + feedback: { default: '' }, + }, + group: 'block questions', + content: 'block*', + defining: true, + parseDOM: [ + { + tag: 'div.matching-option', + getAttrs(dom) { + return { + id: dom.getAttribute('id'), + class: dom.getAttribute('class'), + correct: JSON.parse(dom.getAttribute('correct').toLowerCase()), + answer: JSON.parse(dom.getAttribute('answer').toLowerCase()), + feedback: dom.getAttribute('feedback'), + }; + }, + }, + ], + toDOM: node => ['div', node.attrs, 0], +}; + +export default matchingOptionNode;