diff --git a/wax-questions-service/src/NumericalAnswerService/components/ContainerEditor.js b/wax-questions-service/src/NumericalAnswerService/components/ContainerEditor.js new file mode 100644 index 0000000000000000000000000000000000000000..3e62759ffbf5973756795cc31b680339f9cf1101 --- /dev/null +++ b/wax-questions-service/src/NumericalAnswerService/components/ContainerEditor.js @@ -0,0 +1,150 @@ +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'; + +const EditorWrapper = styled.div` + > .ProseMirror { + padding: 5px; + &: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 ContainerEditor = ({ node, view, getPos }) => { + const editorRef = useRef(); + + const context = useContext(WaxContext); + const { + app, + pmViews: { main }, + } = context; + + let gapContainerView; + 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()]; + + finalPlugins = finalPlugins.concat([...plugins]); + + useEffect(() => { + gapContainerView = new EditorView( + { + mount: editorRef.current, + }, + { + editable: () => isEditable, + state: EditorState.create({ + doc: node, + plugins: finalPlugins, + }), + dispatchTransaction, + disallowedTools: [ + 'Images', + 'Lists', + 'lift', + 'Tables', + 'FillTheGap', + 'MultipleChoice', + ], + type: 'filltheGapContaier', + 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 (gapContainerView.hasFocus()) gapContainerView.focus(); + }, + }, + + attributes: { + spellcheck: 'false', + }, + }, + ); + + // Set Each note into Wax's Context + context.updateView( + { + [questionId]: gapContainerView, + }, + questionId, + ); + gapContainerView.focus(); + }, []); + + const dispatchTransaction = tr => { + const { state, transactions } = gapContainerView.state.applyTransaction(tr); + gapContainerView.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 ContainerEditor; diff --git a/wax-questions-service/src/NumericalAnswerService/components/EditorComponent.js b/wax-questions-service/src/NumericalAnswerService/components/EditorComponent.js new file mode 100644 index 0000000000000000000000000000000000000000..34d695e04776b56b059445793f9e90ba6acc7809 --- /dev/null +++ b/wax-questions-service/src/NumericalAnswerService/components/EditorComponent.js @@ -0,0 +1,226 @@ +/* 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, 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, ComponentPlugin } from 'wax-prosemirror-core'; +import { + splitListItem, + liftListItem, + sinkListItem, +} from 'prosemirror-schema-list'; +import Placeholder from '../../MultipleChoiceQuestionService/plugins/placeholder'; +import FakeCursorPlugin from '../../MultipleDropDownService/plugins/FakeCursorPlugin'; + +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 question'; + 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; + } + } +`; + +let WaxOverlays = () => true; + +const EditorComponent = ({ + node, + view, + getPos, + placeholderText = 'Type your question', +}) => { + 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 = [FakeCursorPlugin()]; + + 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: () => + // splitListItem(questionView.state.schema.nodes.list_item)( + // questionView.state, + // questionView.dispatch, + // ), + Enter: pressEnter, + }; + }; + + const plugins = [keymap(createKeyBindings()), ...app.getPlugins()]; + + const createPlaceholder = placeholder => { + return Placeholder({ + content: placeholder, + }); + }; + + finalPlugins = finalPlugins.concat([ + createPlaceholder(placeholderText), + ...plugins, + ]); + + useEffect(() => { + WaxOverlays = ComponentPlugin('waxOverlays'); + questionView = new EditorView( + { + mount: editorRef.current, + }, + { + editable: () => isEditable, + 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() + + 1 + + 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} /> + <WaxOverlays activeViewId={questionId} /> + </EditorWrapper> + ); +}; + +export default EditorComponent; diff --git a/wax-questions-service/src/NumericalAnswerService/components/NumericalAnswerContainerComponent.js b/wax-questions-service/src/NumericalAnswerService/components/NumericalAnswerContainerComponent.js new file mode 100644 index 0000000000000000000000000000000000000000..da2cf7632a93b045e01d19e0c0c81f46c7499fb9 --- /dev/null +++ b/wax-questions-service/src/NumericalAnswerService/components/NumericalAnswerContainerComponent.js @@ -0,0 +1,122 @@ +import React, { useContext } from 'react'; +import { + WaxContext, + ComponentPlugin, + DocumentHelpers, + Icon, +} from 'wax-prosemirror-core'; +import styled from 'styled-components'; +import ContainerEditor from './ContainerEditor'; +import FeedbackComponent from '../../MultipleChoiceQuestionService/components/FeedbackComponent'; + +const NumericalAnswerWrapper = styled.div` + margin: 0px 38px 15px 38px; + margin-top: 10px; +`; + +const NumericalAnswerContainer = styled.div` + border: 3px solid #f5f5f7; + margin-bottom: 30px; +`; + +const NumericalAnswerContainerTool = styled.div` + border: 3px solid #f5f5f7; + border-bottom: none; + + span:first-of-type { + position: relative; + top: 3px; + } +`; + +const ActionButton = styled.button` + background: transparent; + cursor: pointer; + margin-top: 16px; + border: none; + position: relative; + bottom: 14px; + left: -11px; + float: right; +`; + +const StyledIconActionRemove = styled(Icon)` + height: 24px; + width: 24px; +`; + +export default ({ node, view, getPos }) => { + const context = useContext(WaxContext); + const { + pmViews: { main }, + } = context; + + const FillTheGapTool = ComponentPlugin('fillTheGap'); + + const customProps = main.props.customValues; + const { testMode } = customProps; + + const isEditable = main.props.editable(editable => { + return editable; + }); + + const readOnly = !isEditable; + const { feedback } = node.attrs; + + const removeQuestion = () => { + const allNodes = getNodes(context.pmViews.main); + + allNodes.forEach(singleNode => { + if (singleNode.node.attrs.id === node.attrs.id) { + context.pmViews.main.dispatch( + context.pmViews.main.state.tr.delete( + singleNode.pos, + singleNode.pos + singleNode.node.nodeSize, + ), + ); + } + }); + }; + + return ( + <NumericalAnswerWrapper> + <div> + {!testMode && !readOnly && ( + <NumericalAnswerContainerTool> + <FillTheGapTool /> + <ActionButton + aria-label="delete this question" + onClick={removeQuestion} + type="button" + > + <StyledIconActionRemove name="deleteOutlinedQuestion" /> + </ActionButton> + </NumericalAnswerContainerTool> + )} + </div> + <NumericalAnswerContainer className="numerical-answer"> + <ContainerEditor getPos={getPos} node={node} view={view} /> + + {!testMode && !(readOnly && feedback === '') && ( + <FeedbackComponent + getPos={getPos} + node={node} + readOnly={readOnly} + view={view} + /> + )} + </NumericalAnswerContainer> + </NumericalAnswerWrapper> + ); +}; + +const getNodes = view => { + const allNodes = DocumentHelpers.findBlockNodes(view.state.doc); + const numericalAnswerpContainerNodes = []; + allNodes.forEach(node => { + if (node.node.type.name === 'numerical_answer_container') { + numericalAnswerpContainerNodes.push(node); + } + }); + return numericalAnswerpContainerNodes; +};