diff --git a/editors/demo/src/HHMI/config/config.js b/editors/demo/src/HHMI/config/config.js index 162052fd573df0c359984459f9aba25fbf667e51..51441eab0db7264344c129c0faeedf57b3c9c1c2 100644 --- a/editors/demo/src/HHMI/config/config.js +++ b/editors/demo/src/HHMI/config/config.js @@ -22,10 +22,74 @@ import { EssayService, MatchingService, MultipleDropDownService, + AnyStyleService, } from 'wax-prosemirror-services'; import { DefaultSchema } from 'wax-prosemirror-core'; import invisibles, { hardBreak } from '@guardian/prosemirror-invisibles'; +const API_KEY = ''; + +async function AnyStyleTransformation(prompt) { + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${API_KEY}`, + }, + body: JSON.stringify({ + model: 'gpt-3.5-turbo', + messages: [ + { + role: 'user', + content: prompt, + }, + ], + temperature: 0, + // max_tokens: 400, + // n: 1, + // stop: null, + }), + }); + + try { + const data = await response.json(); + console.log(data); + return data.choices[0].message.content; + } catch (e) { + console.error(e); + } finally { + } + return prompt; +} + +// async function AnyStyleTransformation(prompt) { +// const response = await fetch('https://api.openai.com/v1/completions', { +// method: 'POST', +// headers: { +// 'Content-Type': 'application/json', +// Authorization: `Bearer ${API_KEY}`, +// }, +// body: JSON.stringify({ +// model: 'text-davinci-003', +// prompt: prompt, +// max_tokens: 400, +// n: 1, +// stop: null, +// temperature: 0.5, +// }), +// }); + +// try { +// const data = await response.json(); +// console.log(data); +// return data.choices[0].text.trim(); +// } catch (e) { +// console.error(e); +// } finally { +// console.log('We do cleanup here'); +// } +// return 'Nothing found'; +// } export default { MenuService: [ @@ -47,6 +111,7 @@ export default { 'Lists', 'Images', 'Tables', + 'AnyStyle', 'QuestionsDropDown', 'FullScreen', ], @@ -60,6 +125,9 @@ export default { toolGroups: ['MultipleDropDown'], }, ], + AnyStyleService: { + AnyStyleTransformation: AnyStyleTransformation, + }, SchemaService: DefaultSchema, RulesService: [emDash, ellipsis], @@ -67,6 +135,7 @@ export default { PmPlugins: [columnResizing(), tableEditing(), invisibles([hardBreak()])], services: [ + new AnyStyleService(), new MatchingService(), new FillTheGapQuestionService(), new MultipleChoiceQuestionService(), diff --git a/wax-prosemirror-services/index.js b/wax-prosemirror-services/index.js index d3da09ef62f22cf882ba6965b9bb29cbbddaf4a3..ef7a4b2f8c0b6ac21bef18e8510ed99c6169cc08 100644 --- a/wax-prosemirror-services/index.js +++ b/wax-prosemirror-services/index.js @@ -39,6 +39,7 @@ export { default as MultipleDropDownService } from './src/MultipleDropDownServic export { default as OENContainersService } from './src/OENContainersService/OENContainersService'; export { default as YjsService } from './src/YjsService/YjsService'; +export { default as AnyStyleService } from './src/AnyStyleService/AnyStyleService'; /* ToolGroups */ diff --git a/wax-prosemirror-services/src/AnyStyleService/AnyStyleService.js b/wax-prosemirror-services/src/AnyStyleService/AnyStyleService.js new file mode 100644 index 0000000000000000000000000000000000000000..c1a1ed47e1e6df6b8c9900d82ed4ea2deff8f3cd --- /dev/null +++ b/wax-prosemirror-services/src/AnyStyleService/AnyStyleService.js @@ -0,0 +1,24 @@ +import { Service } from 'wax-prosemirror-core'; +import AnyStyleTool from './AnyStyleTool'; +import AnyStyleToolGroupService from './AnyStyleToolGroupService/AnyStyleToolGroupService'; +import AnyStylePlaceHolderPlugin from './plugins/AnyStylePlaceHolderPlugin'; +import './anyStyle.css'; + +class AnyStyleService extends Service { + name = 'AnyStyleService'; + + boot() { + this.app.PmPlugins.add( + 'anyStylePlaceHolder', + AnyStylePlaceHolderPlugin('anyStylePlaceHolder'), + ); + } + + register() { + this.container.bind('AnyStyleTool').to(AnyStyleTool); + } + + dependencies = [new AnyStyleToolGroupService()]; +} + +export default AnyStyleService; diff --git a/wax-prosemirror-services/src/AnyStyleService/AnyStyleTool.js b/wax-prosemirror-services/src/AnyStyleService/AnyStyleTool.js new file mode 100644 index 0000000000000000000000000000000000000000..d070dd0cf56eeaf6c1636d4184aaca26fd147f12 --- /dev/null +++ b/wax-prosemirror-services/src/AnyStyleService/AnyStyleTool.js @@ -0,0 +1,68 @@ +import React, { useContext } from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import { isEmpty } from 'lodash'; +import { injectable } from 'inversify'; +import { WaxContext, Commands, Tools } from 'wax-prosemirror-core'; +import AnyStyleButton from './components/AnyStyleButton'; +import replaceText from './replaceText'; + +@injectable() +class AnyStyleTool extends Tools { + title = 'ChatGPT'; + name = 'ChatGPT'; + label = 'ChatGPT'; + + get run() { + return true; + } + + select = activeView => { + return true; + }; + + get enable() { + return state => { + return true; + }; + } + + renderTool(view) { + if (isEmpty(view)) return null; + const context = useContext(WaxContext); + const anyStyle = replaceText( + view, + this.config.get('config.AnyStyleService').AnyStyleTransformation, + this.pmplugins.get('anyStylePlaceHolder'), + context, + ); + return this.isDisplayed() ? ( + <AnyStyleButton + anyStyle={anyStyle} + item={this.toJSON()} + key={uuidv4()} + view={view} + /> + ) : null; + } + + // renderTool(view) { + // if (isEmpty(view)) return null; + // const context = useContext(WaxContext); + // const upload = fileUpload( + // view, + // this.config.get('fileUpload'), + // this.pmplugins.get('imagePlaceHolder'), + // context, + // ); + // return this.isDisplayed() ? ( + // <ImageUpload + // fileUpload={upload} + // item={this.toJSON()} + // key={uuidv4()} + // view={view} + // /> + // ) : null; + // } +} + +export default AnyStyleTool; diff --git a/wax-prosemirror-services/src/AnyStyleService/AnyStyleToolGroupService/AnyStyle.js b/wax-prosemirror-services/src/AnyStyleService/AnyStyleToolGroupService/AnyStyle.js new file mode 100644 index 0000000000000000000000000000000000000000..57217fd6791e37ede78b30d4b9dbd810b7ac6db3 --- /dev/null +++ b/wax-prosemirror-services/src/AnyStyleService/AnyStyleToolGroupService/AnyStyle.js @@ -0,0 +1,13 @@ +import { injectable, inject } from 'inversify'; +import { ToolGroup } from 'wax-prosemirror-core'; + +@injectable() +class Anystyle extends ToolGroup { + tools = []; + constructor(@inject('AnyStyleTool') anyStyleTool) { + super(); + this.tools = [anyStyleTool]; + } +} + +export default Anystyle; diff --git a/wax-prosemirror-services/src/AnyStyleService/AnyStyleToolGroupService/AnyStyleToolGroupService.js b/wax-prosemirror-services/src/AnyStyleService/AnyStyleToolGroupService/AnyStyleToolGroupService.js new file mode 100644 index 0000000000000000000000000000000000000000..703a4688e34d31d57045cc56121d6d0dfdec993b --- /dev/null +++ b/wax-prosemirror-services/src/AnyStyleService/AnyStyleToolGroupService/AnyStyleToolGroupService.js @@ -0,0 +1,10 @@ +import { Service } from 'wax-prosemirror-core'; +import AnyStyle from './AnyStyle'; + +class AnyStyleToolGroupService extends Service { + register() { + this.container.bind('AnyStyle').to(AnyStyle); + } +} + +export default AnyStyleToolGroupService; diff --git a/wax-prosemirror-services/src/AnyStyleService/anyStyle.css b/wax-prosemirror-services/src/AnyStyleService/anyStyle.css new file mode 100644 index 0000000000000000000000000000000000000000..ef4885cbd069a2368e985ffe1f2bd90f26447d26 --- /dev/null +++ b/wax-prosemirror-services/src/AnyStyleService/anyStyle.css @@ -0,0 +1,5 @@ +placeholder-any-style:before { + position: relative; + top: 3px; + content: url("data:image/svg+xml; utf8, <svg xmlns='http://www.w3.org/2000/svg' height='24' width='24'><path d='M8 20h8v-3q0-1.65-1.175-2.825Q13.65 13 12 13q-1.65 0-2.825 1.175Q8 15.35 8 17Zm-4 2v-2h2v-3q0-1.525.713-2.863Q7.425 12.8 8.7 12q-1.275-.8-1.987-2.138Q6 8.525 6 7V4H4V2h16v2h-2v3q0 1.525-.712 2.862Q16.575 11.2 15.3 12q1.275.8 1.988 2.137Q18 15.475 18 17v3h2v2Z' /></svg>"); +} \ No newline at end of file diff --git a/wax-prosemirror-services/src/AnyStyleService/components/AnyStyleButton.js b/wax-prosemirror-services/src/AnyStyleService/components/AnyStyleButton.js new file mode 100644 index 0000000000000000000000000000000000000000..eafacca2053069392faeb6c5ee9c69ba98689d20 --- /dev/null +++ b/wax-prosemirror-services/src/AnyStyleService/components/AnyStyleButton.js @@ -0,0 +1,58 @@ +/* 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 AnyStyleButton = ({ view = {}, item, anyStyle }) => { + 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; + /* this is the content that we have to get from the selection */ + const textSelection = new TextSelection($from, $to); + + const content = textSelection.content(); + + anyStyle(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 AnyStyleButtonComponent = useMemo( + () => ( + <MenuButton + active={isActive || false} + disabled={isDisabled} + iconName={icon} + label={label} + onMouseDown={e => handleMouseDown(e, view.state, view.dispatch)} + title={title} + /> + ), + [isActive, isDisabled], + ); + + return AnyStyleButtonComponent; +}; + +export default AnyStyleButton; diff --git a/wax-prosemirror-services/src/AnyStyleService/plugins/AnyStylePlaceHolderPlugin.js b/wax-prosemirror-services/src/AnyStyleService/plugins/AnyStylePlaceHolderPlugin.js new file mode 100644 index 0000000000000000000000000000000000000000..990b75ee2173d6255fc6091b2210595b30a33f45 --- /dev/null +++ b/wax-prosemirror-services/src/AnyStyleService/plugins/AnyStylePlaceHolderPlugin.js @@ -0,0 +1,37 @@ +/* eslint-disable no-param-reassign */ +import { Plugin, PluginKey } from 'prosemirror-state'; +import { Decoration, DecorationSet } from 'prosemirror-view'; + +export default key => + new Plugin({ + key: new PluginKey(key), + state: { + init: function init() { + return DecorationSet.empty; + }, + apply: function apply(tr, set) { + // Adjust decoration positions to changes made by the transaction + set = set.map(tr.mapping, tr.doc); + // See if the transaction adds or removes any placeholders + const action = tr.getMeta(this); + if (action && action.add) { + const widget = document.createElement('placeholder-any-style'); + const deco = Decoration.widget(action.add.pos, widget, { + id: action.add.id, + }); + + set = set.add(tr.doc, [deco]); + } else if (action && action.remove) { + set = set.remove( + set.find(null, null, spec => spec.id === action.remove.id), + ); + } + return set; + }, + }, + props: { + decorations: function decorations(state) { + return this.getState(state); + }, + }, + }); diff --git a/wax-prosemirror-services/src/AnyStyleService/replaceText.js b/wax-prosemirror-services/src/AnyStyleService/replaceText.js new file mode 100644 index 0000000000000000000000000000000000000000..20b258fdd573516b78712a53a939f0156aec3770 --- /dev/null +++ b/wax-prosemirror-services/src/AnyStyleService/replaceText.js @@ -0,0 +1,62 @@ +import { v4 as uuidv4 } from 'uuid'; +import { DOMParser } from 'prosemirror-model'; + +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; +}; + +export default ( + view, + AnyStyleTransformation, + 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); + + AnyStyleTransformation(data).then( + text => { + const pos = findPlaceholder(view.state, id, placeholderPlugin); + // If the content around the placeholder has been deleted, drop + // the image + if (pos == null) { + return; + } + const parser = DOMParser.fromSchema( + context.pmViews.main.state.config.schema, + ); + const parsedContent = parser.parse(elementFromString(text)); + // Otherwise, insert it at the placeholder's position, and remove + // the placeholder + context.pmViews[context.activeViewId].dispatch( + context.pmViews[context.activeViewId].state.tr + .replaceWith(pos, pos, parsedContent) + .setMeta(placeholderPlugin, { remove: { id } }), + ); + }, + + () => { + // On failure, just clean up the placeholder + view.dispatch(tr.setMeta(placeholderPlugin, { remove: { id } })); + }, + ); +};