From d4d45d3c0f17b944187dd046a5e1cc09dc3d84c8 Mon Sep 17 00:00:00 2001 From: chris <kokosias@yahoo.gr> Date: Mon, 11 Oct 2021 21:32:46 +0300 Subject: [PATCH] add gap component --- editors/demo/src/Editors.js | 2 +- .../CreateGapService/CreateGap.js | 30 +++- .../FillTheGapNodeView.js | 28 +++ .../FillTheGapQuestionService.js | 12 ++ .../components/EditorComponent.js | 161 ++++++++++++++++++ .../components/GapComponent.js | 12 ++ .../plugins/placeholder.js | 32 ++++ .../schema/fillTheGapNode.js | 25 ++- .../components/EditorComponent.js | 10 +- .../components/QuestionComponent.js | 9 +- .../src/NoteService/Note.js | 2 +- 11 files changed, 306 insertions(+), 17 deletions(-) create mode 100644 editors/demo/src/HHMI/FillTheGapQuestionService/FillTheGapNodeView.js create mode 100644 editors/demo/src/HHMI/FillTheGapQuestionService/components/EditorComponent.js create mode 100644 editors/demo/src/HHMI/FillTheGapQuestionService/components/GapComponent.js create mode 100644 editors/demo/src/HHMI/FillTheGapQuestionService/plugins/placeholder.js diff --git a/editors/demo/src/Editors.js b/editors/demo/src/Editors.js index abd0628fb..d19991a2e 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/FillTheGapQuestionService/CreateGapService/CreateGap.js b/editors/demo/src/HHMI/FillTheGapQuestionService/CreateGapService/CreateGap.js index 35d40e082..74b6d44a9 100644 --- a/editors/demo/src/HHMI/FillTheGapQuestionService/CreateGapService/CreateGap.js +++ b/editors/demo/src/HHMI/FillTheGapQuestionService/CreateGapService/CreateGap.js @@ -1,4 +1,6 @@ import { injectable } from 'inversify'; +import { Fragment } from 'prosemirror-model'; +import { v4 as uuidv4 } from 'uuid'; import { Tools } from 'wax-prosemirror-services'; @injectable() @@ -8,15 +10,37 @@ class CreateGap extends Tools { name = 'Create Gap'; get run() { - return (state, dispatch) => {}; + return (state, dispatch) => { + const { empty, $from, $to } = state.selection; + let content = Fragment.empty; + if (!empty && $from.sameParent($to) && $from.parent.inlineContent) + content = $from.parent.content.cut( + $from.parentOffset, + $to.parentOffset, + ); + const createGap = state.config.schema.nodes.fill_the_gap.create( + { id: uuidv4() }, + content, + ); + dispatch(state.tr.replaceSelectionWith(createGap)); + }; } + select = (state, activeViewId) => { + let status = false; + const { from, to } = state.selection; + state.doc.nodesBetween(from, to, (node, pos) => { + if (node.type.name === 'fill_the_gap_container') { + status = true; + } + }); + return status; + }; + get active() { return state => {}; } - select = (state, activeViewId) => {}; - get enable() { return state => {}; } diff --git a/editors/demo/src/HHMI/FillTheGapQuestionService/FillTheGapNodeView.js b/editors/demo/src/HHMI/FillTheGapQuestionService/FillTheGapNodeView.js new file mode 100644 index 000000000..5b61c652d --- /dev/null +++ b/editors/demo/src/HHMI/FillTheGapQuestionService/FillTheGapNodeView.js @@ -0,0 +1,28 @@ +import { AbstractNodeView } from 'wax-prosemirror-services'; + +export default class MultipleChoiceNodeView extends AbstractNodeView { + 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 'fill_the_gap'; + } + + update(node) { + return true; + } +} diff --git a/editors/demo/src/HHMI/FillTheGapQuestionService/FillTheGapQuestionService.js b/editors/demo/src/HHMI/FillTheGapQuestionService/FillTheGapQuestionService.js index 60612d55d..8863042c8 100644 --- a/editors/demo/src/HHMI/FillTheGapQuestionService/FillTheGapQuestionService.js +++ b/editors/demo/src/HHMI/FillTheGapQuestionService/FillTheGapQuestionService.js @@ -3,6 +3,8 @@ import FillTheGapQuestion from './FillTheGapQuestion'; import fillTheGapContainerNode from './schema/fillTheGapContainerNode'; import fillTheGapNode from './schema/fillTheGapNode'; import CreateGapService from './CreateGapService/CreateGapService'; +import FillTheGapNodeView from './FillTheGapNodeView'; +import GapComponent from './components/GapComponent'; class FillTheGapQuestionService extends Service { register() { @@ -13,6 +15,16 @@ class FillTheGapQuestionService extends Service { createNode({ fill_the_gap_container: fillTheGapContainerNode, }); + + createNode({ + fill_the_gap: fillTheGapNode, + }); + + addPortal({ + nodeView: FillTheGapNodeView, + component: GapComponent, + context: this.app, + }); } dependencies = [new CreateGapService()]; } diff --git a/editors/demo/src/HHMI/FillTheGapQuestionService/components/EditorComponent.js b/editors/demo/src/HHMI/FillTheGapQuestionService/components/EditorComponent.js new file mode 100644 index 000000000..68a2ee3f7 --- /dev/null +++ b/editors/demo/src/HHMI/FillTheGapQuestionService/components/EditorComponent.js @@ -0,0 +1,161 @@ +/* eslint-disable react/destructuring-assignment */ +/* eslint-disable react/prop-types */ +/* eslint-disable react-hooks/exhaustive-deps */ +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 '../plugins/placeholder'; + +const EditorWrapper = styled.div` + border: none; + display: flex; + flex: 2 1 auto; + justify-content: left; + margin-right: 15px; + + .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 answer'), + ...plugins, + ]); + + const { activeViewId } = context; + + 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'], + handleDOMEvents: { + mousedown: () => { + 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 => { + let { state, transactions } = questionView.state.applyTransaction(tr); + questionView.updateState(state); + context.updateView({}, questionId); + + if (!tr.getMeta('fromOutside')) { + let outerTr = view.state.tr, + offsetMap = StepMap.offset(getPos() + 1); + for (let i = 0; i < transactions.length; i++) { + let steps = transactions[i].steps; + 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/editors/demo/src/HHMI/FillTheGapQuestionService/components/GapComponent.js b/editors/demo/src/HHMI/FillTheGapQuestionService/components/GapComponent.js new file mode 100644 index 000000000..91c6cca11 --- /dev/null +++ b/editors/demo/src/HHMI/FillTheGapQuestionService/components/GapComponent.js @@ -0,0 +1,12 @@ +import React from 'react'; +import styled from 'styled-components'; +import EditorComponent from './EditorComponent'; + +const Gap = styled.span` + color: red; + text-decoration: underline; +`; + +export default ({ node, view, getPos }) => { + return <Gap> Gap</Gap>; +}; diff --git a/editors/demo/src/HHMI/FillTheGapQuestionService/plugins/placeholder.js b/editors/demo/src/HHMI/FillTheGapQuestionService/plugins/placeholder.js new file mode 100644 index 000000000..de3fd8058 --- /dev/null +++ b/editors/demo/src/HHMI/FillTheGapQuestionService/plugins/placeholder.js @@ -0,0 +1,32 @@ +import { Plugin, PluginKey } from 'prosemirror-state'; +import { Decoration, DecorationSet } from 'prosemirror-view'; + +const placeHolderText = new PluginKey('placeHolderText'); + +export default props => { + return new Plugin({ + key: placeHolderText, + props: { + decorations: state => { + const decorations = []; + const decorate = (node, pos) => { + if ( + node.type.isBlock && + node.childCount === 0 && + state.doc.content.childCount === 1 + ) { + decorations.push( + Decoration.node(pos, pos + node.nodeSize, { + class: 'empty-node', + 'data-content': props.content, + }), + ); + } + }; + state.doc.descendants(decorate); + + return DecorationSet.create(state.doc, decorations); + }, + }, + }); +}; diff --git a/editors/demo/src/HHMI/FillTheGapQuestionService/schema/fillTheGapNode.js b/editors/demo/src/HHMI/FillTheGapQuestionService/schema/fillTheGapNode.js index 5443c358e..26de347d1 100644 --- a/editors/demo/src/HHMI/FillTheGapQuestionService/schema/fillTheGapNode.js +++ b/editors/demo/src/HHMI/FillTheGapQuestionService/schema/fillTheGapNode.js @@ -1,3 +1,26 @@ -const fillTheGapNode = {}; +const fillTheGapNode = { + group: 'inline', + content: 'inline*', + inline: true, + atom: true, + attrs: { + id: { default: '' }, + class: { default: 'fill-the-gap' }, + }, + parseDOM: [ + { + tag: 'span', + getAttrs(dom) { + return { + id: dom.getAttribute('id'), + class: dom.getAttribute('class'), + }; + }, + }, + ], + toDOM: node => { + return ['span', node.attrs, 0]; + }, +}; export default fillTheGapNode; diff --git a/wax-prosemirror-services/src/MultipleChoiceQuestionService/components/EditorComponent.js b/wax-prosemirror-services/src/MultipleChoiceQuestionService/components/EditorComponent.js index 4f6f72187..68a2ee3f7 100644 --- a/wax-prosemirror-services/src/MultipleChoiceQuestionService/components/EditorComponent.js +++ b/wax-prosemirror-services/src/MultipleChoiceQuestionService/components/EditorComponent.js @@ -1,3 +1,5 @@ +/* eslint-disable react/destructuring-assignment */ +/* eslint-disable react/prop-types */ /* eslint-disable react-hooks/exhaustive-deps */ import React, { useContext, useRef, useEffect } from 'react'; import styled from 'styled-components'; @@ -81,13 +83,7 @@ const EditorComponent = ({ node, view, getPos }) => { ...plugins, ]); - const { - view: { main }, - activeViewId, - } = context; - - if (activeViewId === node.attrs.id && context.view[activeViewId].focused) { - } + const { activeViewId } = context; useEffect(() => { questionView = new EditorView( diff --git a/wax-prosemirror-services/src/MultipleChoiceQuestionService/components/QuestionComponent.js b/wax-prosemirror-services/src/MultipleChoiceQuestionService/components/QuestionComponent.js index 1171dac4e..0cfa34aec 100644 --- a/wax-prosemirror-services/src/MultipleChoiceQuestionService/components/QuestionComponent.js +++ b/wax-prosemirror-services/src/MultipleChoiceQuestionService/components/QuestionComponent.js @@ -1,3 +1,4 @@ +/* eslint-disable react/prop-types */ import React, { useContext } from 'react'; import styled from 'styled-components'; import { TextSelection } from 'prosemirror-state'; @@ -124,16 +125,16 @@ export default ({ node, view, getPos }) => { return ( <Wrapper> <InfoRow> - <QuestionNunber></QuestionNunber> + <QuestionNunber /> </InfoRow> <QuestionControlsWrapper> <QuestionWrapper> <QuestionData> - <EditorComponent node={node} view={view} getPos={getPos} /> + <EditorComponent getPos={getPos} node={node} view={view} /> - <SwitchComponent node={node} getPos={getPos} /> + <SwitchComponent getPos={getPos} node={node} /> </QuestionData> - <FeedbackComponent node={node} view={view} getPos={getPos} /> + <FeedbackComponent getPos={getPos} node={node} view={view} /> </QuestionWrapper> <IconsWrapper> {showAddIcon && !readOnly && ( diff --git a/wax-prosemirror-services/src/NoteService/Note.js b/wax-prosemirror-services/src/NoteService/Note.js index 17e97dc21..76395e692 100644 --- a/wax-prosemirror-services/src/NoteService/Note.js +++ b/wax-prosemirror-services/src/NoteService/Note.js @@ -1,7 +1,7 @@ -import Tools from '../lib/Tools'; import { injectable } from 'inversify'; import { Fragment } from 'prosemirror-model'; import { v4 as uuidv4 } from 'uuid'; +import Tools from '../lib/Tools'; export default @injectable() -- GitLab