diff --git a/editors/demo/src/Editors.js b/editors/demo/src/Editors.js index abd0628fb70695fdd171c22a2956784070ef3345..d19991a2ed8613a7f85ed0fed95a7dc6ef294736 100644 --- a/editors/demo/src/Editors.js +++ b/editors/demo/src/Editors.js @@ -70,7 +70,7 @@ const Editors = () => { case 'ncbi': return <NCBI />; default: - return <Editoria />; + return <HHMI />; } }; diff --git a/editors/demo/src/HHMI/config/config.js b/editors/demo/src/HHMI/config/config.js index 13c0157864a5c3ffdc1800f7e6822cecfb8e3857..dbebc284085adde767865b1b53bdc49e20fd7a57 100644 --- a/editors/demo/src/HHMI/config/config.js +++ b/editors/demo/src/HHMI/config/config.js @@ -25,6 +25,7 @@ import { FillTheGapQuestionService, FillTheGapToolGroupService, MultipleDropDownToolGroupService, + EssayService, } from 'wax-prosemirror-services'; import { DefaultSchema } from 'wax-prosemirror-utilities'; @@ -51,6 +52,7 @@ export default { 'Images', 'Tables', 'MultipleDropDown', + 'MultipleChoice', 'FillTheGap', 'FullScreen', ], @@ -73,6 +75,7 @@ export default { new MultipleChoiceQuestionService(), new MultipleChoiceToolGroupService(), new MultipleDropDownToolGroupService(), + new EssayService(), new ListsService(), new LinkService(), new InlineAnnotationsService(), diff --git a/editors/demo/src/HHMI/layout/EditorElements.js b/editors/demo/src/HHMI/layout/EditorElements.js index 65f8294b55907923cda2f6c2d467c62f43518158..6385a12476a44c7cdefc72f1350e695c4ff63823 100644 --- a/editors/demo/src/HHMI/layout/EditorElements.js +++ b/editors/demo/src/HHMI/layout/EditorElements.js @@ -392,4 +392,24 @@ export default css` width: 30px; } } + + /* -- Essay ---------------------------------- */ + + .essay { + border: 3px solid #f5f5f7; + margin-bottom: 30px; + margin-top: 30px; + padding: 3px; + + &:before { + background-color: #fff; + bottom: 22px; + color: #535e76; + content: 'Essay'; + height: 10px; + left: -1px; + position: relative; + width: 30px; + } + } `; diff --git a/wax-prosemirror-services/index.js b/wax-prosemirror-services/index.js index 9273ee11149d370e5e3e5b60e02b665b8d3769d7..17d4efd65b1a668fd7b4f7dae3f1f0fce2d245c4 100644 --- a/wax-prosemirror-services/index.js +++ b/wax-prosemirror-services/index.js @@ -48,7 +48,7 @@ export { default as MultipleChoiceQuestionService } from './src/MultipleChoiceQu export { default as MultipleChoiceSingleCorrectQuestionService } from './src/MultipleChoiceQuestionService/MultipleChoiceSingleCorrectQuestionService/MultipleChoiceSingleCorrectQuestionService'; export { default as TrueFalseQuestionService } from './src/MultipleChoiceQuestionService/TrueFalseQuestionService/TrueFalseQuestionService'; export { default as FillTheGapQuestionService } from './src/FillTheGapQuestionService/FillTheGapQuestionService'; - +export { default as EssayService } from './src/EssayService/EssayService'; /* ToolGroups */ diff --git a/wax-prosemirror-services/src/EssayService/EssayQuestion.js b/wax-prosemirror-services/src/EssayService/EssayQuestion.js index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..a02f7931a60feec6c230fe8eac0a5fd24c5b1dee 100644 --- a/wax-prosemirror-services/src/EssayService/EssayQuestion.js +++ b/wax-prosemirror-services/src/EssayService/EssayQuestion.js @@ -0,0 +1,41 @@ +import { injectable } from 'inversify'; +import { wrapIn } from 'prosemirror-commands'; +import Tools from '../lib/Tools'; + +@injectable() +class EssayQuestion extends Tools { + title = 'Add Essay Question'; + icon = ''; + name = 'Essay'; + label = 'Essay'; + + get run() { + return (state, dispatch) => { + wrapIn(state.config.schema.nodes.essay)(state, dispatch); + }; + } + + get active() { + return state => {}; + } + + select = (state, activeView) => { + let status = true; + const { from, to } = state.selection; + + if (from === null) return false; + + state.doc.nodesBetween(from, to, (node, pos) => { + if (node.type.groups.includes('questions')) { + status = false; + } + }); + return status; + }; + + get enable() { + return state => {}; + } +} + +export default EssayQuestion; diff --git a/wax-prosemirror-services/src/EssayService/components/EditorComponent.js b/wax-prosemirror-services/src/EssayService/components/EditorComponent.js new file mode 100644 index 0000000000000000000000000000000000000000..6a3fbc010aa60770e48b2bc4624845620c29dd34 --- /dev/null +++ b/wax-prosemirror-services/src/EssayService/components/EditorComponent.js @@ -0,0 +1,165 @@ +/* eslint-disable react/destructuring-assignment */ +/* 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; + } + + 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); + let questionView; + const questionId = node.attrs.id; + const isEditable = context.view.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()), ...context.app.getPlugins()]; + + // eslint-disable-next-line no-shadow + const createPlaceholder = placeholder => { + return Placeholder({ + content: placeholder, + }); + }; + + finalPlugins = finalPlugins.concat([ + createPlaceholder('Type your essay'), + ...plugins, + ]); + + useEffect(() => { + questionView = new EditorView( + { + mount: editorRef.current, + }, + { + editable: () => isEditable, + state: EditorState.create({ + doc: node, + plugins: finalPlugins, + }), + // This is the magic part + dispatchTransaction, + disallowedTools: ['Images', 'Lists', 'lift', 'MultipleChoice'], + handleDOMEvents: { + mousedown: () => { + context.view.main.dispatch( + context.view.main.state.tr.setSelection( + new TextSelection( + context.view.main.state.tr.doc.resolve(getPos() + 2), + ), + ), + ); + // context.view[activeViewId].dispatch( + // context.view[activeViewId].state.tr.setSelection( + // TextSelection.between( + // context.view[activeViewId].state.selection.$anchor, + // context.view[activeViewId].state.selection.$head, + // ), + // ), + // ); + context.updateView({}, questionId); + // Kludge to prevent issues due to the fact that the whole + // footnote is node-selected (and thus DOM-selected) when + // the parent editor is focused. + if (questionView.hasFocus()) 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/EssayService/components/EssayComponent.js b/wax-prosemirror-services/src/EssayService/components/EssayComponent.js index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..ace6f2e9102c7c4a199ffca60051ea4866828b2a 100644 --- a/wax-prosemirror-services/src/EssayService/components/EssayComponent.js +++ b/wax-prosemirror-services/src/EssayService/components/EssayComponent.js @@ -0,0 +1,5 @@ +import React from 'react'; + +export default ({ node, view, getPos }) => { + return <span>Essay</span>; +}; diff --git a/wax-prosemirror-services/src/EssayService/schema/essayNode.js b/wax-prosemirror-services/src/EssayService/schema/essayNode.js index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..e87a69822768f84d6e8778651db9e39c62e85482 100644 --- a/wax-prosemirror-services/src/EssayService/schema/essayNode.js +++ b/wax-prosemirror-services/src/EssayService/schema/essayNode.js @@ -0,0 +1,26 @@ +const essayNode = { + attrs: { + class: { default: 'essay' }, + }, + group: 'block questions', + atom: true, + selectable: true, + draggable: true, + content: 'block+', + parseDOM: [ + { + tag: 'div.essay', + getAttrs(dom) { + return { + id: dom.dataset.id, + class: dom.getAttribute('class'), + }; + }, + }, + ], + toDOM(node) { + return ['div', node.attrs, 0]; + }, +}; + +export default essayNode; diff --git a/wax-prosemirror-services/src/WaxToolGroups/MultipleChoiceToolGroupService/MultipleChoice.js b/wax-prosemirror-services/src/WaxToolGroups/MultipleChoiceToolGroupService/MultipleChoice.js index cffe0cf6b7b7ce23bb7ab21a05f0c70e1198a0c6..3855e6de78f120a60aba28cde9efff462c8df031 100644 --- a/wax-prosemirror-services/src/WaxToolGroups/MultipleChoiceToolGroupService/MultipleChoice.js +++ b/wax-prosemirror-services/src/WaxToolGroups/MultipleChoiceToolGroupService/MultipleChoice.js @@ -8,9 +8,11 @@ class MultipleChoice extends ToolGroup { @inject('MultipleChoiceQuestion') multipleChoiceQuestion, @inject('MultipleChoiceSingleCorrectQuestion') multipleChoiceSingleCorrectQuestion, + @inject('EssayQuestion') + essayQuestion, ) { super(); - this.tools = [multipleChoiceQuestion, multipleChoiceSingleCorrectQuestion]; + this.tools = [multipleChoiceQuestion, essayQuestion]; } } diff --git a/wax-prosemirror-services/src/WaxToolGroups/MultipleDropDownToolGroupService/DropComponent.js b/wax-prosemirror-services/src/WaxToolGroups/MultipleDropDownToolGroupService/DropComponent.js new file mode 100644 index 0000000000000000000000000000000000000000..9ba491eefa43bda05708111e3241ceec5c23cc8a --- /dev/null +++ b/wax-prosemirror-services/src/WaxToolGroups/MultipleDropDownToolGroupService/DropComponent.js @@ -0,0 +1,108 @@ +/* eslint-disable no-underscore-dangle */ +import React, { useContext, useMemo, useEffect, useState } from 'react'; +import styled from 'styled-components'; +import { WaxContext } from 'wax-prosemirror-core'; +import { ReactDropDownStyles } from 'wax-prosemirror-components'; +import Dropdown from 'react-dropdown'; +import { v4 as uuidv4 } from 'uuid'; + +const Wrapper = styled.div` + ${ReactDropDownStyles}; +`; +const DropdownStyled = styled(Dropdown)` + display: inline-flex; + cursor: not-allowed; + opacity: ${props => (props.select ? 1 : 0.4)}; + pointer-events: ${props => (props.select ? 'default' : 'none')}; + .Dropdown-control { + border: none; + padding-top: 12px; + + &:hover { + box-shadow: none; + } + } + + .Dropdown-arrow { + top: 17px; + } + + .Dropdown-menu { + width: 102%; + display: flex; + flex-direction: column; + align-items: flex-start; + .Dropdown-option { + width: 100%; + } + } +`; + +const DropComponent = ({ title, view, tools }) => { + const context = useContext(WaxContext); + const { + activeView, + activeViewId, + view: { main }, + } = context; + const { state } = view; + + const [label, setLabel] = useState(null); + + const dropDownOptions = [ + { + label: 'Multiple Choice', + value: '0', + item: tools[0], + }, + { + label: 'Multiple Choice (single correct)', + value: '1', + item: tools[1], + }, + { + label: 'True/False', + value: '2', + item: tools[2], + }, + { + label: 'True/False (single correct)', + value: '3', + item: tools[3], + }, + ]; + + useEffect(() => { + dropDownOptions.forEach((option, i) => { + if (option.item.active(main.state)) { + setLabel(option.label); + } + }); + }, [activeViewId]); + + const isDisabled = tools[0].select(state, activeView); + + const onChange = option => { + tools[option.value].run(main, context); + }; + + const MultipleDropDown = useMemo( + () => ( + <Wrapper key={uuidv4()}> + <DropdownStyled + key={uuidv4()} + onChange={option => onChange(option)} + options={dropDownOptions} + placeholder="Multiple Question Types" + select={isDisabled} + value={label} + /> + </Wrapper> + ), + [isDisabled, label], + ); + + return MultipleDropDown; +}; + +export default DropComponent; diff --git a/wax-prosemirror-services/src/WaxToolGroups/MultipleDropDownToolGroupService/MultipleDropDown.js b/wax-prosemirror-services/src/WaxToolGroups/MultipleDropDownToolGroupService/MultipleDropDown.js index a0c9e89d7b73e003fb583bb7dc4a3ad35346eee2..0ca3f6496e71cc97962d871014bdd9fe08b021d6 100644 --- a/wax-prosemirror-services/src/WaxToolGroups/MultipleDropDownToolGroupService/MultipleDropDown.js +++ b/wax-prosemirror-services/src/WaxToolGroups/MultipleDropDownToolGroupService/MultipleDropDown.js @@ -1,12 +1,9 @@ -import React, { useContext, useMemo, useEffect, useState } from 'react'; +import React from 'react'; import { injectable, inject } from 'inversify'; import { isEmpty } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; -import styled from 'styled-components'; -import { WaxContext } from 'wax-prosemirror-core'; -import { ReactDropDownStyles } from 'wax-prosemirror-components'; -import Dropdown from 'react-dropdown'; import ToolGroup from '../../lib/ToolGroup'; +import DropComponent from './DropComponent'; @injectable() class MultipleDropDown extends ToolGroup { @@ -29,104 +26,9 @@ class MultipleDropDown extends ToolGroup { renderTools(view) { if (isEmpty(view)) return null; - - const Wrapper = styled.div` - ${ReactDropDownStyles}; - `; - const DropdownStyled = styled(Dropdown)` - display: inline-flex; - cursor: not-allowed; - opacity: ${props => (props.select ? 1 : 0.4)}; - pointer-events: ${props => (props.select ? 'default' : 'none')}; - .Dropdown-control { - border: none; - padding-top: 12px; - - &:hover { - box-shadow: none; - } - } - - .Dropdown-arrow { - top: 17px; - } - - .Dropdown-menu { - width: 102%; - display: flex; - flex-direction: column; - align-items: flex-start; - .Dropdown-option { - width: 100%; - } - } - `; - - const context = useContext(WaxContext); - - const { - activeView, - activeViewId, - view: { main }, - } = context; - const { state } = view; - - const [label, setLabel] = useState(null); - - const dropDownOptions = [ - { - label: 'Multiple Choice', - value: '0', - item: this._tools[0], - }, - { - label: 'Multiple Choice (single correct)', - value: '1', - item: this._tools[1], - }, - { - label: 'True/False', - value: '2', - item: this._tools[2], - }, - { - label: 'True/False (single correct)', - value: '3', - item: this._tools[3], - }, - ]; - - useEffect(() => { - dropDownOptions.forEach((option, i) => { - if (option.item.active(main.state)) { - setLabel(option.label); - } - }); - }, [activeViewId]); - - const isDisabled = this._tools[0].select(state, activeView); - - const onChange = option => { - this._tools[option.value].run(main, context); - }; - - const MultipleDropDown = useMemo( - () => ( - <Wrapper key={uuidv4()}> - <DropdownStyled - value={label} - key={uuidv4()} - options={dropDownOptions} - onChange={option => onChange(option)} - placeholder="Multiple Question Types" - select={isDisabled} - /> - </Wrapper> - ), - [isDisabled, label], + return ( + <DropComponent key="Multipe Drop Down" view={view} tools={this._tools} /> ); - - return MultipleDropDown; } }