diff --git a/editors/demo/src/Editoria/config/config.js b/editors/demo/src/Editoria/config/config.js index a11306ffd7d8e5dd138a1816c8b6b3ef7d357c13..aa5d9607b8066939535cbbd0b45742c3f1847362 100644 --- a/editors/demo/src/Editoria/config/config.js +++ b/editors/demo/src/Editoria/config/config.js @@ -45,6 +45,7 @@ import { // YjsService, // BlockDropDownToolGroupService, // TitleToolGroupService, + AskAiContentService, } from 'wax-prosemirror-services'; import { TablesService, tableEditing, columnResizing } from 'wax-table-service'; @@ -63,6 +64,21 @@ import CharactersList from './CharactersList'; // console.log(title); // }; +async function DummyPromise(userInput) { + return new Promise((resolve, reject) => { + setTimeout(() => { + console.log('User input:', userInput); + if (userInput === 'reject') { + reject('Your request could not be processed for now'); + } else { + resolve( + 'He made significant contributions to theoretical physics, including achievements in quantum mechanics', + ); + } + }, 4150); + }); +} + const updateTitle = debounce(title => { console.log(title); }, 100); @@ -176,10 +192,15 @@ export default { // docIdentifier: 'prosemirror-demo', // }, + AskAiContentService: { + AskAiContentTransformation: DummyPromise, + }, + services: [ // new TitleToolGroupService(), // new YjsService(), // new BlockDropDownToolGroupService(), + new AskAiContentService(), new CustomTagService(), new DisplayBlockLevelService(), new DisplayToolGroupService(), diff --git a/wax-prosemirror-core/src/components/icons/icons.js b/wax-prosemirror-core/src/components/icons/icons.js index a95127cbfa2352cbde81f58cf61973aa9f7c9488..9a8cc58049144061b930a81c45e297fdb03e7002 100644 --- a/wax-prosemirror-core/src/components/icons/icons.js +++ b/wax-prosemirror-core/src/components/icons/icons.js @@ -224,12 +224,12 @@ export default { highlight: ({ className }) => ( <Svg className={className} viewBox="0 0 24 24"> <path - id="highlight" d="M14.837,2.538L7.587,9.617L6.826,11.846L5.707,12.94L8.752,15.912L9.869,14.818L12.153,14.075L19.402,6.995L14.837,2.538ZM21.685,6.252C22.105,6.662 22.105,7.328 21.685,7.738L13.315,15.912L11.033,16.655L9.511,18.141C9.091,18.551 8.41,18.551 7.989,18.141L3.424,13.682C3.004,13.272 3.004,12.607 3.424,12.197L4.945,10.711L5.706,8.482L14.076,0.308C14.497,-0.103 15.178,-0.103 15.598,0.308L21.685,6.252ZM14.837,5.509L16.359,6.995L10.46,12.769L8.939,11.283L14.837,5.509ZM6.468,18L4.566,20L0,18.514L3.424,15.027" + id="highlight" /> <path - id="trait" d="M7.447,18.31L7.64,18.499C8.254,19.097 9.248,19.097 9.86,18.499L10.053,18.31L22.506,18.31L22.506,21.417L1.876,21.417L1.876,19.65L4.411,20.475C4.594,20.535 4.796,20.484 4.928,20.345L6.83,18.345L6.794,18.31L7.447,18.31Z" + id="trait" /> </Svg> ), @@ -412,7 +412,7 @@ export default { </Svg> ), IconCross: ({ className }) => ( - <Svg className={className} viewBox="0 0 409.6 409.6" fill="none"> + <Svg className={className} fill="none" viewBox="0 0 409.6 409.6"> <path d="m405.332031 192h-170.664062v-170.667969c0-11.773437-9.558594-21.332031-21.335938-21.332031-11.773437 0-21.332031 9.558594-21.332031 21.332031v170.667969h-170.667969c-11.773437 0-21.332031 9.558594-21.332031 21.332031 0 11.777344 9.558594 21.335938 21.332031 21.335938h170.667969v170.664062c0 11.777344 9.558594 21.335938 21.332031 21.335938 11.777344 0 21.335938-9.558594 21.335938-21.335938v-170.664062h170.664062c11.777344 0 21.335938-9.558594 21.335938-21.335938 0-11.773437-9.558594-21.332031-21.335938-21.332031zm0 0" /> </Svg> ), @@ -503,4 +503,127 @@ export default { <path d="M478-240q21 0 35.5-14.5T528-290q0-21-14.5-35.5T478-340q-21 0-35.5 14.5T428-290q0 21 14.5 35.5T478-240Zm-36-154h74q0-33 7.5-52t42.5-52q26-26 41-49.5t15-56.5q0-56-41-86t-97-30q-57 0-92.5 30T342-618l66 26q5-18 22.5-39t53.5-21q32 0 48 17.5t16 38.5q0 20-12 37.5T506-526q-44 39-54 59t-10 73Zm38 314q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z" /> </Svg> ), + deleteIco: ({ className }) => ( + <svg + className={className} + fill="none" + height="13" + viewBox="0 0 20 20" + width="20" + xmlns="http://www.w3.org/2000/svg" + > + <title>Delete Icon</title> + <path + d="M1.65222 5.47827H18.3479" + stroke="#FF4E4E" + strokeLinecap="round" + /> + <path + d="M5.82666 5.47826V3C5.82666 2.44771 6.27438 2 6.82666 2H13.1745C13.7268 2 14.1745 2.44772 14.1745 3V5.47826" + stroke="#FF4E4E" + strokeLinecap="round" + strokeLinejoin="round" + /> + <path + d="M3.73889 5.47827L4.38207 17.0555C4.41151 17.5854 4.8498 18 5.38053 18H14.619C15.1497 18 15.588 17.5854 15.6174 17.0555L16.2606 5.47827" + stroke="#FF4E4E" + strokeLinecap="round" + strokeLinejoin="round" + /> + </svg> + ), + tryAgain: ({ className }) => ( + <svg + className={className} + fill="none" + height="13" + viewBox="0 0 20 20" + width="20" + xmlns="http://www.w3.org/2000/svg" + > + <title>Try Again Icon</title> + <path + d="M16.2963 13.0625C16.7471 12.1375 17 11.0983 17 10C17 6.13401 13.866 3 10 3C6.13401 3 3 6.13401 3 10C3 13.866 6.13401 17 10 17C10.8587 17 11.6812 16.8454 12.4414 16.5625" + stroke="#595959" + /> + <path d="M18 14L15 12V15L18 14Z" fill="#595959" stroke="#595959" /> + </svg> + ), + insertIco: ({ className }) => ( + <svg + className={className} + fill="none" + height="13" + viewBox="0 0 20 20" + width="20" + xmlns="http://www.w3.org/2000/svg" + > + <title>Insert Icon</title> + <path d="M4 4H16" stroke="#505050" /> + <path d="M4 7H16" stroke="#505050" /> + <path d="M7 10L16 10" stroke="#505050" /> + <path d="M10 13L16 13" stroke="#505050" /> + <path d="M4 9.5V13H7.5" stroke="#505050" /> + <path d="M6 11L8 13L6 15" stroke="#505050" /> + </svg> + ), + replaceIco: ({ className }) => ( + <svg + className={className} + fill="none" + height="13" + viewBox="0 0 20 20" + width="20" + xmlns="http://www.w3.org/2000/svg" + > + <title>Replace</title> + <path d="M18 8H2L6 4" stroke="#595959" strokeLinejoin="round" /> + <path d="M2 12H18L14 16" stroke="#595959" strokeLinejoin="round" /> + </svg> + ), + submitIco: ({ className }) => ( + <svg + className={className} + fill="none" + height="12" + viewBox="0 0 13 12" + width="13" + xmlns="http://www.w3.org/2000/svg" + > + <title>Submit</title> + <path d="M0 11H8V1" stroke="#434343" /> + <path d="M3.5 4L8 1L12 4" stroke="#434343" /> + </svg> + ), + loaderIco: ({ className }) => ( + <svg + className={className} + height="17" + viewBox="0 0 100 100" + width="17" + xmlns="http://www.w3.org/2000/svg" + > + <circle + cx="50" + cy="50" + fill="none" + r="40" + stroke="#434343" + strokeWidth="10" + > + <animate + attributeName="stroke-dasharray" + dur="1.5s" + repeatCount="indefinite" + values="0,150;120,150" + /> + <animate + attributeName="stroke-dashoffset" + dur="1.5s" + repeatCount="indefinite" + values="0;-120" + /> + </circle> + </svg> + ), }; diff --git a/wax-prosemirror-services/index.js b/wax-prosemirror-services/index.js index 47ca65e64e54e53b8e39e16f8cb81719d5f210d6..6eb076f571da213e395186ac9ea94eb56cdc17a6 100644 --- a/wax-prosemirror-services/index.js +++ b/wax-prosemirror-services/index.js @@ -31,6 +31,7 @@ export { default as EnterService } from './src/EnterService/EnterService'; export { default as OENContainersService } from './src/OENContainersService/OENContainersService'; export { default as YjsService } from './src/YjsService/YjsService'; export { default as ExternalAPIContentService } from './src/ExternalAPIContentService/ExternalAPIContentService'; +export { default as AskAiContentService } from './src/AiService/AskAiContentService'; /* ToolGroups */ diff --git a/wax-prosemirror-services/src/AiService/AskAiContent.css b/wax-prosemirror-services/src/AiService/AskAiContent.css new file mode 100644 index 0000000000000000000000000000000000000000..590ad236b1218302ad7651450850f6f5adcb0335 --- /dev/null +++ b/wax-prosemirror-services/src/AiService/AskAiContent.css @@ -0,0 +1,12 @@ +.fade-in { + opacity: 0; + transition: opacity 0.5s ease-in-out; +} + +.fade-in.show { + opacity: 1; +} + +.ask-ai-selection { + background-color: #C5D7FE; +} \ No newline at end of file diff --git a/wax-prosemirror-services/src/AiService/AskAiContentService.js b/wax-prosemirror-services/src/AiService/AskAiContentService.js new file mode 100644 index 0000000000000000000000000000000000000000..5d8ec89bb34060d632b891dc37ea05fd30b25687 --- /dev/null +++ b/wax-prosemirror-services/src/AiService/AskAiContentService.js @@ -0,0 +1,37 @@ +import { Service } from 'wax-prosemirror-core'; +import AskAiContentTool from './AskAiContentTool'; +import AskAIOverlay from './components/AskAIOverlay'; +import AskAiSelectionPlugin from './plugins/AskAiSelectionPlugin'; +import './AskAiContent.css'; + +class AskAiContentService extends Service { + name = 'AskAiContentService'; + + boot() { + this.app.PmPlugins.add( + 'askAiSelectionPlugin', + AskAiSelectionPlugin('askAiSelectionPlugin'), + ); + + const createOverlay = this.container.get('CreateOverlay'); + const config = this.config; + + // Create the overlay + createOverlay( + AskAIOverlay, + { config }, + { + nodeType: '', + markType: '', + followCursor: false, + selection: true, + }, + ); + } + + register() { + this.container.bind('AskAiContentTool').to(AskAiContentTool); + } +} + +export default AskAiContentService; diff --git a/wax-prosemirror-services/src/AiService/AskAiContentTool.js b/wax-prosemirror-services/src/AiService/AskAiContentTool.js new file mode 100644 index 0000000000000000000000000000000000000000..1a6bb73d61cadf0e951443dc45af1aad9cb8b460 --- /dev/null +++ b/wax-prosemirror-services/src/AiService/AskAiContentTool.js @@ -0,0 +1,41 @@ +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 new file mode 100644 index 0000000000000000000000000000000000000000..ac3e907c7805abf7afdfdcadea98971a36d6be95 --- /dev/null +++ b/wax-prosemirror-services/src/AiService/InsertTextBelowSelection.js @@ -0,0 +1,39 @@ +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 new file mode 100644 index 0000000000000000000000000000000000000000..b0b5bb5eebf5d30359f8d64d6cd20ba2429d6d1d --- /dev/null +++ b/wax-prosemirror-services/src/AiService/ReplaceSelectedText.js @@ -0,0 +1,41 @@ +import { TextSelection } from 'prosemirror-state'; +export const replaceSelectedText = (view, transformedText) => { + let state = view.state; + let tr = state.tr; + + const { from, to } = tr.selection; + + // 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); + } + + // Fetch the most recent state again + state = view.state; + + // Create a new text node with the transformed text + const newText = state.schema.text(transformedText); + + // Replace the selected text with the new text + tr = tr.replaceWith(from, from, newText); // Note: 'to' is replaced with 'from' + + // 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); + tr = state.tr.setSelection(newSelection); + + // Dispatch the final transaction to update the state + view.dispatch(tr); +}; diff --git a/wax-prosemirror-services/src/AiService/components/AskAIOverlay.js b/wax-prosemirror-services/src/AiService/components/AskAIOverlay.js new file mode 100644 index 0000000000000000000000000000000000000000..0f403323eca10c334c07906e2db136ed952baa9c --- /dev/null +++ b/wax-prosemirror-services/src/AiService/components/AskAIOverlay.js @@ -0,0 +1,252 @@ +import React, { + useRef, + useEffect, + 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'; + +const ActionButton = styled.button` + align-items: center; + align-self: stretch; + background: white; + border: 0.5px #f0f0f0 solid; + cursor: pointer; + display: inline-flex; + gap: 8px; + justify-content: flex-start; + padding: 8px 12px; +`; + +const ActionSection = styled.div` + align-items: flex-start; + box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.04); + display: flex; + flex-direction: column; + justify-content: flex-start; + width: 188px; +`; + +const ActionText = styled.div` + color: ${props => props.color || '#434343'}; + font-family: 'Helvetica Neue', sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 22px; + 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; + color: #000; + font-family: 'Helvetica Neue', sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 22px; + outline: none; + width: 100%; +`; + +const ResultDiv = styled.div` + align-items: center; + background: white; + 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; + flex-direction: column; + gap: 10px; + justify-content: flex-start; + max-height: 200px; + overflow-y: auto; + padding: 8px 12px; + width: 458px; +`; + +const ResultText = styled.div` + color: black; + flex: 1 1 0; + font-family: Roboto, sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 22px; + word-wrap: break-word; +`; + +const SubmitButton = styled.button` + background: none; + border: none; + cursor: pointer; + outline: none; + padding: 0 8px; /* Adjust padding as needed */ +`; + +const AskAIOverlay = ({ setPosition, position, config }) => { + const { activeView } = useContext(WaxContext); + const [result, setResult] = useState(''); + const [isSubmitted, setIsSubmitted] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const AskAiContentTransformation = config.AskAiContentTransformation; + 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 overLayComponent = document.getElementById('ai-overlay'); + let overLayComponentCoords; + if (overLayComponent) + overLayComponentCoords = overLayComponent.getBoundingClientRect(); + const top = end.top - WaxSurface.top + 20; + const left = end.left - WaxSurface.left - overLayComponentCoords.width / 2; + setPosition({ ...position, left, top }); + }, [position.left]); + + useEffect(() => { + if (resultDivRef.current) { + setIsScrollable( + resultDivRef.current.scrollHeight > resultDivRef.current.clientHeight, + ); + } + }, [result]); + + useEffect(() => { + // Add a delay of 2 seconds before showing the overlay + const timer = setTimeout(() => { + setIsVisible(true); + }, 1216); + + return () => clearTimeout(timer); + }, []); + + const tryAgain = () => { + // Reset the state to initial values + setIsSubmitted(false); + setResult(''); + + // Call the handleSubmit function again + handleSubmit(new Event('submit')); + // add underline + }; + + const handleInsertTextBelow = () => { + insertTextBelowSelection(activeView, result); + }; + + const handleSubmit = async () => { + setIsLoading(true); + const inputValue = inputRef.current.value; + + // Get the highlighted text from the editor + const { from, to } = activeView.state.selection; + const highlightedText = activeView.state.doc.textBetween(from, to); + + // Combine the user's input and the highlighted text + const combinedInput = `${inputValue}\n\nHighlighted Text: ${highlightedText}`; + + try { + const response = await AskAiContentTransformation(combinedInput); + setResult(response); + setIsSubmitted(true); + } catch (error) { + setResult(error); + setIsSubmitted(true); + } finally { + setIsLoading(false); + } + }; + + const handleReplaceText = () => { + replaceSelectedText(activeView, result); + }; + + const discardResults = () => { + // Clear the input field + inputRef.current.value = ''; + + // Reset the state variables + setResult(''); + setIsSubmitted(false); + }; + + const handleKeyDown = e => { + if (e.key === 'Enter') { + handleSubmit(); + } + }; + + return ( + <> + <AskAIForm + className={`fade-in ${isVisible ? 'show' : ''}`} + id="ai-overlay" + > + <AskAIFormInput + id="askAiInput" + onKeyPress={handleKeyDown} + placeholder="Find a better way to word this" + ref={inputRef} + type="text" + /> + <SubmitButton onClick={handleSubmit}> + {isLoading ? <icons.loaderIco /> : <icons.submitIco />} + </SubmitButton> + </AskAIForm> + {isSubmitted && ( + <> + <ResultDiv ref={resultDivRef} isScrollable={isScrollable}> + <ResultText>{result}</ResultText> + </ResultDiv> + <ActionSection> + <ActionButton onClick={handleReplaceText}> + <ActionText> + <icons.replaceIco /> Replace selected text + </ActionText> + </ActionButton> + <ActionButton onClick={handleInsertTextBelow}> + <ActionText> + <icons.insertIco /> Insert + </ActionText> + </ActionButton> + <ActionButton onClick={tryAgain}> + <ActionText> + <icons.tryAgain /> Try again + </ActionText> + </ActionButton> + <ActionButton onClick={discardResults}> + <ActionText color="#FF4E4E"> + <icons.deleteIco /> Discard + </ActionText> + </ActionButton> + </ActionSection> + </> + )} + </> + ); +}; + +export default AskAIOverlay; diff --git a/wax-prosemirror-services/src/AiService/components/AskAiButton.js b/wax-prosemirror-services/src/AiService/components/AskAiButton.js new file mode 100644 index 0000000000000000000000000000000000000000..dfdb32275eef54d4f7dbd107e3f2aa13bb861b4d --- /dev/null +++ b/wax-prosemirror-services/src/AiService/components/AskAiButton.js @@ -0,0 +1,57 @@ +/* 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 new file mode 100644 index 0000000000000000000000000000000000000000..f41275dbc3b762acfa604ada3cfa1acf368579fe --- /dev/null +++ b/wax-prosemirror-services/src/AiService/components/AskAiComponent.js @@ -0,0 +1,31 @@ +/* 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 new file mode 100644 index 0000000000000000000000000000000000000000..41672bdf6104e2361b0adbfcb3be9a20101f3799 --- /dev/null +++ b/wax-prosemirror-services/src/AiService/plugins/AskAiSelectionPlugin.js @@ -0,0 +1,45 @@ +import { Plugin, PluginKey } from 'prosemirror-state'; +import { Decoration, DecorationSet } from 'prosemirror-view'; + +const key = new PluginKey('askAiSelectionPlugin'); + +export default props => { + return new Plugin({ + key, + state: { + init: (_, state) => { + return {}; + }, + apply(tr, prev, prevState, newState) { + let createDecoration; + const askAiInput = document.getElementById('askAiInput'); + + if (askAiInput) { + createDecoration = DecorationSet.create(newState.doc, [ + Decoration.inline(newState.selection.from, newState.selection.to, { + class: 'ask-ai-selection', + }), + ]); + } + + return { + createDecoration, + }; + }, + }, + props: { + decorations: state => { + 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)); + // }, + // }, + }, + }); +}; diff --git a/wax-prosemirror-services/src/AiService/replaceText.js b/wax-prosemirror-services/src/AiService/replaceText.js new file mode 100644 index 0000000000000000000000000000000000000000..ca63d257453870580ce524d99a31054f4f928233 --- /dev/null +++ b/wax-prosemirror-services/src/AiService/replaceText.js @@ -0,0 +1,97 @@ +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 } })); + }, + ); +};