diff --git a/wax-prosemirror-services/src/EssayService/EssayPromptNodeView.js b/wax-prosemirror-services/src/EssayService/EssayPromptNodeView.js new file mode 100644 index 0000000000000000000000000000000000000000..f85fc5eb7a6a73ae4ccac8340aedbd5a8e19f3d5 --- /dev/null +++ b/wax-prosemirror-services/src/EssayService/EssayPromptNodeView.js @@ -0,0 +1,29 @@ +import { QuestionsNodeView } from 'wax-prosemirror-core'; + +export default class EssayPromptNodeView 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 'essay_prompt'; + } + + stopEvent(event) { + const innerView = this.context.pmViews[this.node.attrs.id]; + return innerView && innerView.dom.contains(event.target); + } +} diff --git a/wax-prosemirror-services/src/EssayService/EssayQuestion.js b/wax-prosemirror-services/src/EssayService/EssayQuestion.js index 88b4717da05acfa251f7ad1c27ffcafee3328172..9a84ed40e0b4f2109dab0412a292398ba4d5bb07 100644 --- a/wax-prosemirror-services/src/EssayService/EssayQuestion.js +++ b/wax-prosemirror-services/src/EssayService/EssayQuestion.js @@ -76,11 +76,11 @@ class EssayQuestion extends Tools { if (!wrapping) return false; tr.wrap(range, wrapping); - // const map = tr.mapping.maps[0]; - // let newPos = 0; - // map.forEach((_from, _to, _newFrom, newTo) => { - // newPos = newTo; - // }); + const map = tr.mapping.maps[0]; + let newPos = 0; + map.forEach((_from, _to, _newFrom, newTo) => { + newPos = newTo; + }); tr.setSelection(TextSelection.create(tr.doc, range.$to.pos)); @@ -88,17 +88,26 @@ class EssayQuestion extends Tools { { id: uuidv4() }, Fragment.empty, ); + const essayPrompt = main.state.config.schema.nodes.essay_prompt.create( + { id: uuidv4() }, + Fragment.empty, + ); + const essayAnswer = main.state.config.schema.nodes.essay_answer.create( { id: uuidv4() }, Fragment.empty, ); tr.replaceSelectionWith(essayQuestion); + tr.setSelection(TextSelection.create(tr.doc, newPos)); + tr.replaceSelectionWith(essayPrompt); + tr.setSelection(TextSelection.create(tr.doc, newPos + 1)); tr.replaceSelectionWith(essayAnswer); dispatch(tr); setTimeout(() => { createEmptyParagraph(context, essayAnswer.attrs.id); + createEmptyParagraph(context, essayPrompt.attrs.id); createEmptyParagraph(context, essayQuestion.attrs.id); }, 50); }; diff --git a/wax-prosemirror-services/src/EssayService/EssayService.js b/wax-prosemirror-services/src/EssayService/EssayService.js index fc0f91d64e2ec8529a95203fbf18f28d914c11f9..583ce1dd0056086a173c706aa8895a22db087a94 100644 --- a/wax-prosemirror-services/src/EssayService/EssayService.js +++ b/wax-prosemirror-services/src/EssayService/EssayService.js @@ -1,11 +1,14 @@ import { Service } from 'wax-prosemirror-core'; import EssayQuestion from './EssayQuestion'; import essayContainerNode from './schema/essayContainerNode'; +import essayPromptNode from './schema/essayPromptNode'; import essayQuestionNode from './schema/essayQuestionNode'; import essayAnswerNode from './schema/essayAnswerNode'; import EssayQuestionComponent from './components/EssayQuestionComponent'; +import EssayPromptComponent from './components/EssayPromptComponent'; import EssayAnswerComponent from './components/EssayAnswerComponent'; import EssayQuestionNodeView from './EssayQuestionNodeView'; +import EssayPromptNodeView from './EssayPromptNodeView'; import EssayAnswerNodeView from './EssayAnswerNodeView'; import './essay.css'; @@ -23,6 +26,10 @@ class EssayService extends Service { essay_question: essayQuestionNode, }); + createNode({ + essay_prompt: essayPromptNode, + }); + createNode({ essay_answer: essayAnswerNode, }); @@ -33,6 +40,12 @@ class EssayService extends Service { context: this.app, }); + addPortal({ + nodeView: EssayPromptNodeView, + component: EssayPromptComponent, + context: this.app, + }); + addPortal({ nodeView: EssayAnswerNodeView, component: EssayAnswerComponent, diff --git a/wax-prosemirror-services/src/EssayService/components/EssayPromptComponent.js b/wax-prosemirror-services/src/EssayService/components/EssayPromptComponent.js new file mode 100644 index 0000000000000000000000000000000000000000..f74b6e14aacf414c08ff50de286f0c98301bb6bc --- /dev/null +++ b/wax-prosemirror-services/src/EssayService/components/EssayPromptComponent.js @@ -0,0 +1,200 @@ +import React, { useContext, useRef, useEffect } from 'react'; +import styled from 'styled-components'; +import { EditorView } from 'prosemirror-view'; +import { EditorState, TextSelection, NodeSelection } from 'prosemirror-state'; +import { StepMap } from 'prosemirror-transform'; +import { keymap } from 'prosemirror-keymap'; +import { baseKeymap, chainCommands } from 'prosemirror-commands'; +import { undo, redo } from 'prosemirror-history'; +import { WaxContext } from 'wax-prosemirror-core'; +import { + splitListItem, + liftListItem, + sinkListItem, +} from 'prosemirror-schema-list'; +import Placeholder from '../../MultipleChoiceQuestionService/plugins/placeholder'; + +const EditorWrapper = styled.div` + border: none; + display: flex; + flex: 2 1 auto; + justify-content: left; + opacity: ${props => (props.editable ? 1 : 0.4)}; + cursor: ${props => (props.editable ? 'default' : 'not-allowed')}; + + .ProseMirror { + white-space: break-spaces; + width: 100%; + word-wrap: break-word; + + &:focus { + outline: none; + } + + 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 EssayPromptComponent = ({ node, view, getPos }) => { + const editorRef = useRef(); + + const context = useContext(WaxContext); + const { + app, + pmViews: { main }, + } = context; + let essayPromptView; + const questionId = node.attrs.id; + + const customProps = main.props.customValues; + + const { testMode } = customProps; + + let finalPlugins = []; + + const createKeyBindings = () => { + const keys = getKeys(); + Object.keys(baseKeymap).forEach(key => { + if (keys[key]) { + keys[key] = chainCommands(keys[key], baseKeymap[key]); + } else { + keys[key] = baseKeymap[key]; + } + }); + return keys; + }; + + const pressEnter = (state, dispatch) => { + if (state.selection.node && state.selection.node.type.name === 'image') { + const { $from, to } = state.selection; + + const same = $from.sharedDepth(to); + + const pos = $from.before(same); + dispatch(state.tr.setSelection(NodeSelection.create(state.doc, pos))); + return true; + } + // LISTS + if (splitListItem(state.schema.nodes.list_item)(state)) { + splitListItem(state.schema.nodes.list_item)(state, dispatch); + return true; + } + + return false; + }; + + const getKeys = () => { + return { + 'Mod-z': () => undo(view.state, view.dispatch), + 'Mod-y': () => redo(view.state, view.dispatch), + 'Mod-[': liftListItem(view.state.schema.nodes.list_item), + 'Mod-]': sinkListItem(view.state.schema.nodes.list_item), + Enter: pressEnter, + }; + }; + + const plugins = [keymap(createKeyBindings()), ...app.getPlugins()]; + + const createPlaceholder = placeholder => { + return Placeholder({ + content: placeholder, + }); + }; + + finalPlugins = finalPlugins.concat([ + createPlaceholder('Type your essay prompt'), + ...plugins, + ]); + + useEffect(() => { + essayPromptView = new EditorView( + { + mount: editorRef.current, + }, + { + editable: () => testMode, + state: EditorState.create({ + doc: node, + plugins: finalPlugins, + }), + + dispatchTransaction, + disallowedTools: ['MultipleChoice'], + handleDOMEvents: { + mousedown: () => { + context.updateView({}, questionId); + 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 (essayPromptView.hasFocus()) essayPromptView.focus(); + }, + blur: (editorView, event) => { + if (essayPromptView && event.relatedTarget === null) { + essayPromptView.focus(); + } + }, + }, + + attributes: { + spellcheck: 'false', + }, + }, + ); + + // Set Each note into Wax's Context + context.updateView( + { + [questionId]: essayPromptView, + }, + questionId, + ); + if (essayPromptView.hasFocus()) essayPromptView.focus(); + }, []); + + const dispatchTransaction = tr => { + const outerTr = main.state.tr; + main.dispatch(outerTr.setMeta('outsideView', questionId)); + const { state, transactions } = essayPromptView.state.applyTransaction(tr); + context.updateView({}, questionId); + essayPromptView.updateState(state); + if (!tr.getMeta('fromOutside')) { + 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) + main.dispatch(outerTr.setMeta('outsideView', questionId)); + } + }; + + return ( + <EditorWrapper editable={testMode}> + <div ref={editorRef} /> + </EditorWrapper> + ); +}; + +export default EssayPromptComponent; diff --git a/wax-prosemirror-services/src/EssayService/schema/essayPromptNode.js b/wax-prosemirror-services/src/EssayService/schema/essayPromptNode.js new file mode 100644 index 0000000000000000000000000000000000000000..410d2bf70eaa0e50614a9b56c076dbf966bb41f1 --- /dev/null +++ b/wax-prosemirror-services/src/EssayService/schema/essayPromptNode.js @@ -0,0 +1,26 @@ +import { v4 as uuidv4 } from 'uuid'; + +const essayPromptNode = { + attrs: { + class: { default: 'essay-prompt' }, + id: { default: uuidv4() }, + }, + group: 'block questions', + content: 'block*', + defining: true, + + parseDOM: [ + { + tag: 'div.essay-prompt', + getAttrs(dom) { + return { + id: dom.getAttribute('id'), + class: dom.getAttribute('class'), + }; + }, + }, + ], + toDOM: node => ['div', node.attrs, 0], +}; + +export default essayPromptNode;