diff --git a/editors/demo/src/Editoria/Editoria.js b/editors/demo/src/Editoria/Editoria.js index 3a72be1e77c3730d05315f894139f42e27619975..9bda4b1134b3737b5871a9376d44bbe0f478a44d 100644 --- a/editors/demo/src/Editoria/Editoria.js +++ b/editors/demo/src/Editoria/Editoria.js @@ -53,9 +53,9 @@ const Editoria = () => { value={demo} // readonly layout={layout} - // onChange={debounce(source => { - // console.log(JSON.stringify(source)); - // }, 200)} + onChange={debounce(source => { + console.log(JSON.stringify(source)); + }, 200)} user={user} scrollMargin={200} scrollThreshold={200} diff --git a/editors/demo/src/Editoria/config/config.js b/editors/demo/src/Editoria/config/config.js index aa5d9607b8066939535cbbd0b45742c3f1847362..ba03fd62792a602fc1a5726076ca33a4ea11c153 100644 --- a/editors/demo/src/Editoria/config/config.js +++ b/editors/demo/src/Editoria/config/config.js @@ -75,7 +75,7 @@ async function DummyPromise(userInput) { 'He made significant contributions to theoretical physics, including achievements in quantum mechanics', ); } - }, 4150); + }, 3150); }); } diff --git a/wax-prosemirror-core/src/config/defaultServices/OverlayService/OverlayComponent.js b/wax-prosemirror-core/src/config/defaultServices/OverlayService/OverlayComponent.js index d324297306e166a9579f57124d9bc346ad96587b..5a5f1cf0b3ffa948bf1d2f02cb2724e12937ff11 100644 --- a/wax-prosemirror-core/src/config/defaultServices/OverlayService/OverlayComponent.js +++ b/wax-prosemirror-core/src/config/defaultServices/OverlayService/OverlayComponent.js @@ -7,6 +7,7 @@ import usePosition from './usePosition'; export default (Component, markType) => props => { const context = useContext(WaxContext); const [position, setPosition, mark] = usePosition(markType); + const component = useMemo( () => ( <Component @@ -18,8 +19,7 @@ export default (Component, markType) => props => { ), [JSON.stringify(mark), position, context.activeViewId], ); - const visible = !!position.left; - + const visible = position.left !== null; return ( <Overlay position={position}> {props.activeViewId === context.activeViewId && visible && component} diff --git a/wax-prosemirror-services/src/AiService/AskAiContentService.js b/wax-prosemirror-services/src/AiService/AskAiContentService.js index 5d8ec89bb34060d632b891dc37ea05fd30b25687..86f3e555317f609e61656a1fe9506a00b76bf6f5 100644 --- a/wax-prosemirror-services/src/AiService/AskAiContentService.js +++ b/wax-prosemirror-services/src/AiService/AskAiContentService.js @@ -1,5 +1,4 @@ import { Service } from 'wax-prosemirror-core'; -import AskAiContentTool from './AskAiContentTool'; import AskAIOverlay from './components/AskAIOverlay'; import AskAiSelectionPlugin from './plugins/AskAiSelectionPlugin'; import './AskAiContent.css'; @@ -14,7 +13,7 @@ class AskAiContentService extends Service { ); const createOverlay = this.container.get('CreateOverlay'); - const config = this.config; + const { config } = this; // Create the overlay createOverlay( @@ -29,9 +28,7 @@ class AskAiContentService extends Service { ); } - register() { - this.container.bind('AskAiContentTool').to(AskAiContentTool); - } + register() {} } export default AskAiContentService; diff --git a/wax-prosemirror-services/src/AiService/AskAiContentTool.js b/wax-prosemirror-services/src/AiService/AskAiContentTool.js deleted file mode 100644 index 1a6bb73d61cadf0e951443dc45af1aad9cb8b460..0000000000000000000000000000000000000000 --- a/wax-prosemirror-services/src/AiService/AskAiContentTool.js +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; -import { v4 as uuidv4 } from 'uuid'; -import { injectable } from 'inversify'; -import { Commands, Tools } from 'wax-prosemirror-core'; -import AskAiComponent from './components/AskAiComponent'; - -@injectable() -class AskAiContentTool extends Tools { - title = 'ChatGPT'; - name = 'ChatGPT'; - label = ''; - - get run() { - return true; - } - - get enable() { - return state => { - return Commands.isOnSameTextBlock(state); - }; - } - - select = state => { - return Commands.isOnSameTextBlock(state); - }; - - renderTool(view) { - return ( - <AskAiComponent - config={this.config} - displayed={this.isDisplayed()} - item={this} - key={uuidv4()} - pmplugins={this.pmplugins} - view={view} - /> - ); - } -} - -export default AskAiContentTool; diff --git a/wax-prosemirror-services/src/AiService/InsertTextBelowSelection.js b/wax-prosemirror-services/src/AiService/InsertTextBelowSelection.js deleted file mode 100644 index ac3e907c7805abf7afdfdcadea98971a36d6be95..0000000000000000000000000000000000000000 --- a/wax-prosemirror-services/src/AiService/InsertTextBelowSelection.js +++ /dev/null @@ -1,39 +0,0 @@ -import { TextSelection } from 'prosemirror-state'; -export const insertTextBelowSelection = (view, transformedText) => { - let state = view.state; - let tr = state.tr; - - const { to } = tr.selection; - - // Check if 'to' is within the document size - if (to > state.doc.content.size) { - console.error("Position out of range"); - return; - } - - // Fetch the most recent state again - state = view.state; - - // Create a new paragraph node with the transformed text - const paragraph = state.schema.nodes.paragraph.create( - {}, - state.schema.text(transformedText), - ); - - // Insert the new paragraph node below the selection - tr = tr.insert(to + 1, paragraph); - - // Dispatch the transaction to update the state - view.dispatch(tr); - - // Fetch the most recent state again - state = view.state; - - // Update the selection to the end of the new text - const newTo = to + transformedText.length + 1; // +1 for the paragraph node - const newSelection = TextSelection.create(state.doc, newTo, newTo); - tr = state.tr.setSelection(newSelection); - - // Dispatch the final transaction to update the state - view.dispatch(tr); -}; diff --git a/wax-prosemirror-services/src/AiService/ReplaceSelectedText.js b/wax-prosemirror-services/src/AiService/ReplaceSelectedText.js index b0b5bb5eebf5d30359f8d64d6cd20ba2429d6d1d..e4a55dba4ec927e4761511248f8ba546cea4e25c 100644 --- a/wax-prosemirror-services/src/AiService/ReplaceSelectedText.js +++ b/wax-prosemirror-services/src/AiService/ReplaceSelectedText.js @@ -1,41 +1,73 @@ +import { DOMParser } from 'prosemirror-model'; import { TextSelection } from 'prosemirror-state'; -export const replaceSelectedText = (view, transformedText) => { - let state = view.state; - let tr = state.tr; +const elementFromString = string => { + const wrappedValue = `<body>${string}</body>`; + + return new window.DOMParser().parseFromString(wrappedValue, 'text/html').body; +}; + +const replaceSelectedText = (view, responseText, replace = false) => { + let { state } = view; + let { tr } = state; const { from, to } = tr.selection; + const paragraphNodes = []; + const parser = DOMParser.fromSchema(state.config.schema); - // Check if 'from' and 'to' are within the document size if (from > state.doc.content.size || to > state.doc.content.size) { - console.error("Position out of range"); return; } - // Delete the selected text if any - if (from !== to) { - tr = tr.delete(from, to); + let transformedText = state.schema.text(responseText); + + if (responseText.includes('<ul>') || responseText.includes('ol')) { + transformedText = parser.parse( + elementFromString(responseText.replace(/^\s+|\s+$/g, '')), + {}, + ); } - // Fetch the most recent state again - state = view.state; + if (responseText.includes('\n\n')) { + responseText.split('\n\n').forEach(element => { + paragraphNodes.push( + parser.parse(elementFromString(element.replace(/\n/g, '<br />')), { + preserveWhitespace: true, + }), + ); + }); + } - // Create a new text node with the transformed text - const newText = state.schema.text(transformedText); + const finalReplacementText = + paragraphNodes.length !== 0 ? paragraphNodes : transformedText; - // Replace the selected text with the new text - tr = tr.replaceWith(from, from, newText); // Note: 'to' is replaced with 'from' + if (replace) { + if (from !== to) { + tr = tr.delete(from, to); + } + tr = tr.replaceWith(from, from, finalReplacementText); + } else { + tr = tr.insert(to, finalReplacementText); + } - // Dispatch the transaction to update the state view.dispatch(tr); // Fetch the most recent state again state = view.state; // Update the selection to the end of the new text - const newTo = from + transformedText.length; - const newSelection = TextSelection.create(state.doc, newTo, newTo); + const newFrom = replace ? from : to; + const newTo = newFrom + responseText.length; + const cursorPosition = paragraphNodes.length !== 0 ? newTo + 2 : newTo; + const newSelection = TextSelection.create( + state.doc, + cursorPosition, + cursorPosition, + ); tr = state.tr.setSelection(newSelection); // Dispatch the final transaction to update the state view.dispatch(tr); + view.focus(); }; + +export default replaceSelectedText; diff --git a/wax-prosemirror-services/src/AiService/components/AskAIOverlay.js b/wax-prosemirror-services/src/AiService/components/AskAIOverlay.js index d32cb53970c8a5d9e6b96a2027266605ad71ab21..0c33e7f0f6b4b73c8a19ad7381a3b0df4b668c6c 100644 --- a/wax-prosemirror-services/src/AiService/components/AskAIOverlay.js +++ b/wax-prosemirror-services/src/AiService/components/AskAIOverlay.js @@ -1,15 +1,25 @@ /* eslint-disable react/prop-types */ -import React, { - useRef, - useEffect, - useLayoutEffect, - useContext, - useState, -} from 'react'; +import React, { useRef, useLayoutEffect, useContext, useState } from 'react'; import styled from 'styled-components'; import { WaxContext, icons } from 'wax-prosemirror-core'; -import { replaceSelectedText } from '../ReplaceSelectedText'; -import { insertTextBelowSelection } from '../InsertTextBelowSelection'; +import replaceSelectedText from '../ReplaceSelectedText'; + +const Wrapper = styled.div` + display: flex; + flex-direction: column; +`; + +const AskAIForm = styled.div` + background: #fafafa; + border: 0.5px #dcdcdc solid; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.08); + display: inline-flex; + justify-content: space-between; + padding: 8px 12px; + width: 458px; +`; const ActionButton = styled.button` align-items: center; @@ -41,20 +51,6 @@ const ActionText = styled.div` word-wrap: break-word; `; -const AskAIForm = styled.div` - align-items: center; - background: #fafafa; - border: 0.5px #dcdcdc solid; - border-top-left-radius: 4px; - border-top-right-radius: 4px; - box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.08); - display: inline-flex; - gap: 10px; - justify-content: space-between; - padding: 8px 12px; - width: 458px; -`; - const AskAIFormInput = styled.input` background: transparent; border: none; @@ -90,7 +86,8 @@ const ResultText = styled.div` font-family: Roboto, sans-serif; font-size: 14px; font-weight: 400; - line-height: 22px; + line-height: 19px; + white-space: pre-line; word-wrap: break-word; `; @@ -109,41 +106,30 @@ const AskAIOverlay = ({ setPosition, position, config }) => { const [isLoading, setIsLoading] = useState(false); const { AskAiContentTransformation } = config; const inputRef = useRef(null); - const [isScrollable, setIsScrollable] = useState(false); - const resultDivRef = useRef(null); - const [isVisible, setIsVisible] = useState(false); useLayoutEffect(() => { const WaxSurface = activeView.dom.getBoundingClientRect(); const { selection } = activeView.state; - const { from, to } = selection; - const end = activeView.coordsAtPos(to); + const { to } = selection; + // const start = activeView.coordsAtPos(from); + const end = activeView.coordsAtPos(to - 1); const overLayComponent = document.getElementById('ai-overlay'); - let overLayComponentCoords; - if (overLayComponent) - overLayComponentCoords = overLayComponent.getBoundingClientRect(); + + const overLayComponentCoords = overLayComponent.getBoundingClientRect(); const top = end.top - WaxSurface.top + 20; - // const left = end.left - WaxSurface.left - overLayComponentCoords.width / 2; - const left = end.left - WaxSurface.left - 50; - setPosition({ ...position, left, top }); - }, [position.left]); + let left = end.left - WaxSurface.left - overLayComponentCoords.width / 2; - useEffect(() => { - if (resultDivRef.current) { - setIsScrollable( - resultDivRef.current.scrollHeight > resultDivRef.current.clientHeight, - ); + if (end.left - overLayComponentCoords.width / 2 < WaxSurface.left) { + left += WaxSurface.left - (end.left - overLayComponentCoords.width / 2); } - }, [result]); - useEffect(() => { - // Add a delay of 2 seconds before showing the overlay - const timer = setTimeout(() => { - setIsVisible(true); - }, 1216); + // Don't get out of right boundary of the surface + if (end.left + overLayComponentCoords.width / 2 > WaxSurface.right) { + left -= end.left + overLayComponentCoords.width / 2 - WaxSurface.right; + } - return () => clearTimeout(timer); - }, []); + setPosition({ ...position, left, top }); + }, [position.left]); const tryAgain = () => { // Reset the state to initial values @@ -156,12 +142,16 @@ const AskAIOverlay = ({ setPosition, position, config }) => { }; const handleInsertTextBelow = () => { - insertTextBelowSelection(activeView, result); + replaceSelectedText(activeView, result); }; const handleSubmit = async () => { - setIsLoading(true); const inputValue = inputRef.current.value; + if (inputValue === '') { + inputRef.current.focus(); + return; + } + setIsLoading(true); // Get the highlighted text from the editor const { from, to } = activeView.state.selection; @@ -183,7 +173,7 @@ const AskAIOverlay = ({ setPosition, position, config }) => { }; const handleReplaceText = () => { - replaceSelectedText(activeView, result); + replaceSelectedText(activeView, result, true); }; const discardResults = () => { @@ -202,11 +192,8 @@ const AskAIOverlay = ({ setPosition, position, config }) => { }; return ( - <> - <AskAIForm - className={`fade-in ${isVisible ? 'show' : ''}`} - id="ai-overlay" - > + <Wrapper id="ai-overlay"> + <AskAIForm> <AskAIFormInput id="askAiInput" onKeyPress={handleKeyDown} @@ -220,8 +207,8 @@ const AskAIOverlay = ({ setPosition, position, config }) => { </AskAIForm> {isSubmitted && ( <> - <ResultDiv ref={resultDivRef} isScrollable={isScrollable}> - <ResultText>{result}</ResultText> + <ResultDiv> + <ResultText dangerouslySetInnerHTML={{ __html: result }} /> </ResultDiv> <ActionSection> <ActionButton onClick={handleReplaceText}> @@ -247,7 +234,7 @@ const AskAIOverlay = ({ setPosition, position, config }) => { </ActionSection> </> )} - </> + </Wrapper> ); }; diff --git a/wax-prosemirror-services/src/AiService/components/AskAiButton.js b/wax-prosemirror-services/src/AiService/components/AskAiButton.js deleted file mode 100644 index dfdb32275eef54d4f7dbd107e3f2aa13bb861b4d..0000000000000000000000000000000000000000 --- a/wax-prosemirror-services/src/AiService/components/AskAiButton.js +++ /dev/null @@ -1,57 +0,0 @@ -/* eslint react/prop-types: 0 */ -import React, { useContext, useMemo, useEffect } from 'react'; -import { WaxContext, DocumentHelpers, MenuButton } from 'wax-prosemirror-core'; -import { TextSelection } from 'prosemirror-state'; - -const AskAiButton = ({ view = {}, item, AskAiContent }) => { - const { active, icon, label, run, select, title } = item; - - const { - app, - pmViews: { main }, - activeViewId, - activeView, - } = useContext(WaxContext); - - const { state } = view; - - const handleMouseDown = (e, editorState) => { - e.preventDefault(); - const { - selection: { $from, $to }, - } = editorState; - const textSelection = new TextSelection($from, $to); - - const content = textSelection.content(); - - AskAiContent(content.content.content[0].textContent); - }; - - useEffect(() => {}, []); - - const isActive = !!active(state, activeViewId); - let isDisabled = !select(state, activeViewId, activeView); - - const isEditable = main.props.editable(editable => { - return editable; - }); - if (!isEditable) isDisabled = true; - - const AskAiButtonComponent = useMemo( - () => ( - <MenuButton - active={isActive || false} - disabled={isDisabled} - iconName={icon} - label={label} - onMouseDown={e => handleMouseDown(e, view.state, view.dispatch)} - title={title} - /> - ), - [isActive, isDisabled], - ); - - return AskAiButtonComponent; -}; - -export default AskAiButton; diff --git a/wax-prosemirror-services/src/AiService/components/AskAiComponent.js b/wax-prosemirror-services/src/AiService/components/AskAiComponent.js deleted file mode 100644 index f41275dbc3b762acfa604ada3cfa1acf368579fe..0000000000000000000000000000000000000000 --- a/wax-prosemirror-services/src/AiService/components/AskAiComponent.js +++ /dev/null @@ -1,31 +0,0 @@ -/* eslint-disable react/prop-types */ -import React, { useContext } from 'react'; -import { v4 as uuidv4 } from 'uuid'; -import { WaxContext } from 'wax-prosemirror-core'; -import { isEmpty } from 'lodash'; -import replaceText from '../replaceText'; -import AskAiButton from './AskAiButton'; - -const AskAiComponent = ({ view, displayed, config, pmplugins, item }) => { - const context = useContext(WaxContext); - if (isEmpty(view)) return null; - - const AskAiContent = replaceText( - view, - config.get('config.AskAiContentService') - .AskAiContentTransformation, - pmplugins.get('AskAiContentPlaceHolder'), - context, - ); - - return displayed ? ( - <AskAiButton - AskAiContent={AskAiContent} - item={item.toJSON()} - key={uuidv4()} - view={view} - /> - ) : null; -}; - -export default AskAiComponent; diff --git a/wax-prosemirror-services/src/AiService/plugins/AskAiSelectionPlugin.js b/wax-prosemirror-services/src/AiService/plugins/AskAiSelectionPlugin.js index 41672bdf6104e2361b0adbfcb3be9a20101f3799..121981709ae853fdbba9bbb2cc1a7b1c088f3ae9 100644 --- a/wax-prosemirror-services/src/AiService/plugins/AskAiSelectionPlugin.js +++ b/wax-prosemirror-services/src/AiService/plugins/AskAiSelectionPlugin.js @@ -3,20 +3,28 @@ import { Decoration, DecorationSet } from 'prosemirror-view'; const key = new PluginKey('askAiSelectionPlugin'); -export default props => { +export default () => { return new Plugin({ key, state: { - init: (_, state) => { + init: () => { return {}; }, apply(tr, prev, prevState, newState) { let createDecoration; const askAiInput = document.getElementById('askAiInput'); - if (askAiInput) { + const selectionWhenBlured = tr.getMeta(key); + + const from = selectionWhenBlured + ? selectionWhenBlured.from + : newState.selection.from; + const to = selectionWhenBlured + ? selectionWhenBlured.to + : newState.selection.to; + createDecoration = DecorationSet.create(newState.doc, [ - Decoration.inline(newState.selection.from, newState.selection.to, { + Decoration.inline(from, to, { class: 'ask-ai-selection', }), ]); @@ -32,14 +40,14 @@ export default props => { const askAiSelectionPluginState = state && key.getState(state); return askAiSelectionPluginState.createDecoration; }, - // handleDOMEvents: { - // blur(view) { - // view.dispatch(view.state.tr.setMeta(key, false)); - // }, - // focus(view) { - // view.dispatch(view.state.tr.setMeta(key, true)); - // }, - // }, + handleDOMEvents: { + blur(view) { + view.dispatch(view.state.tr.setMeta(key, view.state.selection)); + }, + // focus(view) { + // view.dispatch(view.state.tr.setMeta(key, true)); + // }, + }, }, }); }; diff --git a/wax-prosemirror-services/src/AiService/replaceText.js b/wax-prosemirror-services/src/AiService/replaceText.js deleted file mode 100644 index ca63d257453870580ce524d99a31054f4f928233..0000000000000000000000000000000000000000 --- a/wax-prosemirror-services/src/AiService/replaceText.js +++ /dev/null @@ -1,97 +0,0 @@ -import { DOMParser } from 'prosemirror-model'; -import { ReplaceStep, ReplaceAroundStep } from 'prosemirror-transform'; -import { Selection } from 'prosemirror-state'; - -const findPlaceholder = (state, id, placeholderPlugin) => { - const decos = placeholderPlugin.getState(state); - const found = decos.find(null, null, spec => spec.id === id); - return found.length ? found[0].from : null; -}; - -const elementFromString = string => { - const wrappedValue = `<body>${string}</body>`; - - return new window.DOMParser().parseFromString(wrappedValue, 'text/html').body; -}; - -const selectionToInsertionEnd = (tr, startLen, bias) => { - const last = tr.steps.length - 1; - - if (last < startLen) { - return; - } - - const step = tr.steps[last]; - - if (!(step instanceof ReplaceStep || step instanceof ReplaceAroundStep)) { - return; - } - - const map = tr.mapping.maps[last]; - let end = 0; - - map.forEach((_from, _to, _newFrom, newTo) => { - if (end === 0) { - end = newTo; - } - }); - tr.setSelection(Selection.near(tr.doc.resolve(end), bias)); -}; - -export default ( - view, - AskAiContentTransformation, - placeholderPlugin, - context, -) => data => { - const { state } = view; - // A fresh object to act as the ID for this upload - const id = {}; - - // Replace the selection with a placeholder - const { tr } = state; - if (!tr.selection.empty) tr.deleteSelection(); - - tr.setMeta(placeholderPlugin, { - add: { id, pos: tr.selection.from }, - }); - - view.dispatch(tr); - - AskAiContentTransformation(data).then( - text => { - const pos = findPlaceholder(view.state, id, placeholderPlugin); - - if (pos == null) { - return; - } - const parser = DOMParser.fromSchema( - context.pmViews.main.state.config.schema, - ); - const options = - text.includes('<ul>') || text.includes('ol') - ? {} - : { - preserveWhitespace: 'full', - }; - const parsedContent = parser.parse( - elementFromString(text.replace(/^\s+|\s+$/g, '')), - options, - ); - - const newTr = context.pmViews.main.state.tr; - - newTr - .replaceWith(pos - 1, pos - 1, parsedContent) - .setMeta(placeholderPlugin, { remove: { id } }); - - selectionToInsertionEnd(newTr, newTr.steps.length - 1, 1); - context.pmViews.main.dispatch(newTr); - }, - - () => { - // On failure, just clean up the placeholder - view.dispatch(tr.setMeta(placeholderPlugin, { remove: { id } })); - }, - ); -};