diff --git a/editors/demo/src/Editoria/config/config.js b/editors/demo/src/Editoria/config/config.js index ea23c5aa1f73f024814f7cb5f6be5bacc38e3176..cfa7bd8621643848bf339e4c46d965036b7a46d8 100644 --- a/editors/demo/src/Editoria/config/config.js +++ b/editors/demo/src/Editoria/config/config.js @@ -52,7 +52,11 @@ async function DummyPromise(userInput, { askKb }) { } else { // JSON response test const json = JSON.stringify({ - content: askKb ? 'KB will be queried' : 'Just a normal call', + content: askKb + ? 'KB will be queried' + : `<p>Hello my friend</p> +<strong>this is a strong</strong> +<h1>this a title</h1>`, citations: ['citation 1', 'citation 2', 'citation 3'], links: ['https://coko.foundation/', 'https://waxjs.net/about/'], }); @@ -321,7 +325,7 @@ export default { // GenerateImages: false, CustomPromptsOn: true, FreeTextPromptsOn: true, - CustomPrompts: [], + CustomPrompts: ['custom promt here!!'], }, services: [ diff --git a/editors/demo/src/Editoria/layout/EditoriaLayout.js b/editors/demo/src/Editoria/layout/EditoriaLayout.js index b93ce476c635fa6e8ae555dab143fde265cf6211..951a115d0ef83505e0f805138acf49721b7e4ba1 100644 --- a/editors/demo/src/Editoria/layout/EditoriaLayout.js +++ b/editors/demo/src/Editoria/layout/EditoriaLayout.js @@ -1,3 +1,4 @@ +/* stylelint-disable no-descending-specificity */ import React, { useContext, useState, useCallback, useEffect } from 'react'; import styled, { css, ThemeProvider } from 'styled-components'; import PanelGroup from 'react-panelgroup'; @@ -15,6 +16,7 @@ const divider = css` .panelGroup { background: #fff; } + .divider { > div { background: ${th('colorBorder')}; @@ -31,16 +33,17 @@ const divider = css` const Wrapper = styled.div` background: ${th('colorBackground')}; + display: flex; + flex-direction: column; font-family: ${th('fontInterface')}; font-size: ${th('fontSizeBase')}; + height: 100%; line-height: ${grid(4)}; - display: flex; - flex-direction: column; - height: 100%; - width: 100%; overflow: hidden; + width: 100%; + /* stylelint-disable-next-line order/properties-alphabetical-order */ ${divider} * { @@ -55,37 +58,38 @@ const Main = styled.div` `; const TopMenu = styled.div` + background: ${th('colorBackgroundToolBar')}; + border-bottom: ${th('borderWidth')} ${th('borderStyle')} ${th('colorBorder')}; + border-top: ${th('borderWidth')} ${th('borderStyle')} ${th('colorBorder')}; display: flex; min-height: 40px; user-select: none; - background: ${th('colorBackgroundToolBar')}; - border-top: ${th('borderWidth')} ${th('borderStyle')} ${th('colorBorder')}; - border-bottom: ${th('borderWidth')} ${th('borderStyle')} ${th('colorBorder')}; + + > div:last-child { + margin-left: 0; + margin-right: ${grid(5)}; + } > div:not(:last-child) { - border-right: ${th('borderWidth')} ${th('borderStyle')} - ${th('colorFurniture')}; + border-right-color: ${th('colorFurniture')}; + border-right-style: ${th('borderStyle')}; + border-right-width: ${th('borderWidth')}; } > div:nth-last-of-type(-n + 2) { margin-left: auto; } - > div:last-child { - margin-left: 0; - margin-right: ${grid(5)}; - } - > div[data-name='Tables'] { border-right: none; } `; const SideMenu = styled.div` - background: ${th('colorBackgroundToolBar')} + background: ${th('colorBackgroundToolBar')}; border-right: ${th('borderWidth')} ${th('borderStyle')} ${th('colorBorder')}; - min-width: 250px; height: calc(100% - 16px); + min-width: 250px; `; const EditorArea = styled.div` @@ -93,19 +97,19 @@ const EditorArea = styled.div` `; const WaxSurfaceScroll = styled.div` - overflow-y: auto; - display: flex; box-sizing: border-box; + display: flex; height: 100%; - width: 100%; + overflow-y: auto; position: absolute; - /* PM styles for main content*/ - ${EditorElements}; + width: 100%; + /* stylelint-disable-next-line order/properties-alphabetical-order */ + ${EditorElements} `; const EditorContainer = styled.div` - width: 65%; height: 100%; + width: 65%; .ProseMirror { box-shadow: 0 0 8px #ecedf1; @@ -117,38 +121,38 @@ const EditorContainer = styled.div` const CommentsContainer = styled.div` display: flex; flex-direction: column; - width: 35%; height: 100%; + width: 35%; `; const CommentsContainerNotes = styled.div` display: flex; flex-direction: column; - width: 35%; height: 100%; + width: 35%; `; const CommentTrackToolsContainer = styled.div` + background: white; display: flex; - position: fixed; padding-top: 5px; + position: fixed; right: 30px; - z-index: 1; - background: white; width: 25%; + z-index: 1; `; const CommentTrackTools = styled.div` - margin-left: auto; display: flex; + margin-left: auto; position: relative; z-index: 1; `; const CommentTrackOptions = styled.div` + bottom: 5px; display: flex; margin-left: 10px; - bottom: 5px; position: relative; `; @@ -156,10 +160,10 @@ const NotesAreaContainer = styled.div` background: #fff; display: flex; flex-direction: row; - width: 100%; height: 100%; overflow-y: scroll; position: absolute; + width: 100%; /* PM styles for note content*/ .ProseMirror { display: inline; @@ -170,18 +174,19 @@ const NotesContainer = styled.div` counter-reset: footnote-view; display: flex; flex-direction: column; - padding-top: 10px; + height: 100%; padding-bottom: ${grid(4)}; padding-left: ${grid(10)}; - height: 100%; + padding-top: 10px; width: 65%; - ${EditorElements}; + /* stylelint-disable-next-line order/properties-alphabetical-order */ + ${EditorElements} `; const WaxBottomRightInfo = styled.div``; const InfoContainer = styled.div` + bottom: 1px; display: flex; position: fixed; - bottom: 1px; right: 21px; z-index: 999; `; @@ -273,7 +278,7 @@ const EditoriaLayout = props => { ]} onResizeEnd={onResizeEnd} > - <WaxSurfaceScroll> + <WaxSurfaceScroll id="wax-surface-scroll" l> <EditorContainer> <WaxView {...props} /> </EditorContainer> diff --git a/wax-prosemirror-core/src/Wax.js b/wax-prosemirror-core/src/Wax.js index 28e933fc2a2290d8e81e4fc133b44baad24771cb..6a470b54f6dabc8fff0671e1588740d3f0ea0fcc 100644 --- a/wax-prosemirror-core/src/Wax.js +++ b/wax-prosemirror-core/src/Wax.js @@ -63,6 +63,10 @@ const Wax = forwardRef((props, innerViewRef) => { const [application, setApplication] = useState(); const configHash = createConfigWithHash(config); + useEffect(() => { + return () => application?.resetApp(); + }, []); + useEffect(() => { const newApplication = createApplication(props); WaxLayout = setupLayout(newApplication, layout); diff --git a/wax-prosemirror-core/src/components/icons/icons.js b/wax-prosemirror-core/src/components/icons/icons.js index f812211f3eac37c21c89971ceaacd5e18fc6bf95..e9e5a57d6faaf75c8a06e0dc18d4c432cef24851 100644 --- a/wax-prosemirror-core/src/components/icons/icons.js +++ b/wax-prosemirror-core/src/components/icons/icons.js @@ -911,6 +911,19 @@ L 5.65 14.6 15.4 9.4 5.65 4.2 6.65 8.9 9.25 9.4 6.65 9.9 Z" </g> </svg> ), + copy: ({ className }) => { + return ( + <svg + className={className} + id="copy" + viewBox="0 0 50 50" + xmlns="http://www.w3.org/2000/svg" + > + <path d="M15.243 19.194a1 1 0 1 0 0-2h-3.77a1 1 0 0 0-1 1v21.951a1 1 0 0 0 1 1h21.951a1 1 0 0 0 1-1V36.38a1 1 0 1 0-2 0v2.765h-19.95V19.194h2.769z" /> + <path d="M41.474 9.146H19.522a1 1 0 0 0-1 1v21.951a1 1 0 0 0 1 1h21.951a1 1 0 0 0 1-1V10.146a.998.998 0 0 0-.999-1zm-1 21.951H20.522V11.146h19.951v19.951z" /> + </svg> + ); + }, AskKb: ({ className }) => { return ( <svg @@ -946,19 +959,17 @@ L 5.65 14.6 15.4 9.4 5.65 4.2 6.65 8.9 9.25 9.4 6.65 9.9 Z" <svg className={className} fill="#000000" - viewBox="0 0 52 52" + height="13px" + id="Layer_1" + version="1.1" + viewBox="0 0 330 330" + width="13px" xmlns="http://www.w3.org/2000/svg" xmlSpace="preserve" > <path - clipRule="evenodd" - d="M47,4.5H5c-1.7,0-3,1.3-3,3v30.6c0,1.7,1.3,3,3,3h14.5l3.6,5.2 - c1,1.4,2.8,1.7,4.2,0.7c0.2-0.2,0.4-0.4,0.6-0.6l4.2-5.3H47c1.7,0,3-1.3,3-3V7.5C50,5.8,48.7,4.5,47,4.5z M21.3,32L21.3,32 - c-0.2,0.2-0.3,0.3-0.5,0.3l-5,1.2c-0.5,0.1-0.9-0.3-0.8-0.8l1.2-5c0-0.1,0.1-0.3,0.2-0.4l0.1-0.1c0.1-0.1,0.4-0.1,0.6,0l4.2,4.2 - C21.4,31.6,21.4,31.9,21.3,32z M33.4,19.7L23.1,30c-0.2,0.2-0.5,0.2-0.6,0l-4.1-4.1c-0.2-0.1-0.2-0.4,0-0.6L28.6,15 - c0.2-0.2,0.5-0.2,0.6,0l4.1,4.1C33.5,19.3,33.5,19.5,33.4,19.7z M36.5,16.7l-1.2,1.2c-0.2,0.2-0.5,0.2-0.6,0l-4.1-4.1 - c-0.2-0.2-0.2-0.5,0-0.6l1.1-1.2c0.7-0.7,1.9-0.7,2.6,0l2.2,2.2C37.2,14.9,37.2,16,36.5,16.7z" - fillRule="evenodd" + d="M325.607,79.393c-5.857-5.857-15.355-5.858-21.213,0.001l-139.39,139.393L25.607,79.393 c-5.857-5.857-15.355-5.858-21.213,0.001c-5.858,5.858-5.858,15.355,0,21.213l150.004,150c2.813,2.813,6.628,4.393,10.606,4.393 s7.794-1.581,10.606-4.394l149.996-150C331.465,94.749,331.465,85.251,325.607,79.393z" + id="XMLID_225_" /> </svg> ); diff --git a/wax-prosemirror-services/src/AiService/AskAiContent.css b/wax-prosemirror-services/src/AiService/AskAiContent.css index 730cec8b46c2735aa7b6a02a5a7c3ee621204aba..71ad7ffc0b5b34f0a7d5bb347e2773b4a50a0667 100644 --- a/wax-prosemirror-services/src/AiService/AskAiContent.css +++ b/wax-prosemirror-services/src/AiService/AskAiContent.css @@ -8,7 +8,7 @@ } .ask-ai-selection { - background-color: #C5D7FE; + background-color: #0000; } .rc-switch { diff --git a/wax-prosemirror-services/src/AiService/ReplaceSelectedText.js b/wax-prosemirror-services/src/AiService/ReplaceSelectedText.js index 3fd6459968d914b96b047ddd5133c35a2340bd69..ce9fb0da15abd1bc8e64436ae2bd17abddce6857 100644 --- a/wax-prosemirror-services/src/AiService/ReplaceSelectedText.js +++ b/wax-prosemirror-services/src/AiService/ReplaceSelectedText.js @@ -3,8 +3,12 @@ import { TextSelection } from 'prosemirror-state'; const elementFromString = string => { const wrappedValue = `<body>${string}</body>`; + const { body } = new window.DOMParser().parseFromString( + wrappedValue, + 'text/html', + ); - return new window.DOMParser().parseFromString(wrappedValue, 'text/html').body; + return body; }; const replaceSelectedText = (view, responseText, replace = false) => { @@ -70,23 +74,22 @@ const replaceSelectedText = (view, responseText, replace = false) => { view.dispatch(tr); - // Fetch the most recent state again - state = view.state; - - // Update the selection to the end of the new text - 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(); + try { + // Fetch the most recent state again + state = view.state; + + // Update the selection to the end of the new text + const newFrom = replace ? from : to; + const newTo = newFrom + responseText.length; + const cursorPosition = paragraphNodes.length !== 0 ? newTo + 2 : newTo; + const newSelection = TextSelection.create(state.doc, newTo, newTo); + tr = state.tr.setSelection(newSelection); + // Dispatch the final transaction to update the state + view.dispatch(tr); + view.focus(); + } catch (error) { + console.log(error); + } }; export default replaceSelectedText; diff --git a/wax-prosemirror-services/src/AiService/components/AiSettingsMenu.js b/wax-prosemirror-services/src/AiService/components/AiSettingsMenu.js index 9bbf95db227a0756c7e0fd5784e75c8f73e9f2b9..0f3cc37e6e7fb2e0144fb3a1d57d4c87fa7cd08f 100644 --- a/wax-prosemirror-services/src/AiService/components/AiSettingsMenu.js +++ b/wax-prosemirror-services/src/AiService/components/AiSettingsMenu.js @@ -1,30 +1,11 @@ -import React, { useContext, useEffect } from 'react'; +import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; import styled from 'styled-components'; import { th } from '@pubsweet/ui-toolkit'; -import { WaxContext, icons } from 'wax-prosemirror-core'; -import { keys } from 'lodash'; +import { icons } from 'wax-prosemirror-core'; +import { isBoolean, keys } from 'lodash'; import SwitchComponent from './Switch'; -// #region CONSTANTS --------------------------------------------- -// const LABEL_POS = 'left'; - -const SETTINGS = [ - { - key: 'AskKb', - label: 'Ask knowledge base', - }, - { - key: 'CustomPromptsOn', - label: 'Custom prompts', - }, - { - key: 'GenerateImages', - label: 'Generate image', - }, -]; -// #endregion CONSTANTS --------------------------------------------- - // #region STYLED COMPONENTS --------------------------------------------- export const ToggleInput = styled(SwitchComponent)` @@ -85,51 +66,15 @@ export const ToggleInput = styled(SwitchComponent)` } `; -// const SettingsDropdown = styled.ul` -// --item-height: var(--ai-settings-menu-height, 30px); -// --items-length: ${p => p.$listlng}; -// --max-height: calc(var(--item-height) * var(--items-length)); - -// background-color: #f8f8f8; -// border: ${p => (p.$show ? 'var(--ai-tool-border)' : '0 solid #0000')}; -// border-radius: 0 0 0.3rem 0.3rem; -// border-top: none; -// display: flex; -// flex-direction: column; -// list-style: none; -// margin: 0; -// max-height: ${p => (p.$show ? 'var(--max-height)' : 0)}; -// overflow: hidden; -// padding: 0 0.5rem; -// position: absolute; -// right: calc(-1 * var(--ai-tool-border-width)); -// top: calc(100% + var(--ai-tool-border-width)); -// transition: all ${p => `${0.15 * p.$listlng}s`}; -// z-index: 15; - -// > li { -// align-items: center; -// display: flex; -// height: var(--item-height); -// justify-content: ${LABEL_POS === 'right' ? 'flex-start' : 'flex-end'}; -// padding: 0; -// user-select: none; -// } - -// > li:not(:first-child) { -// border-top: 1px solid #0001; -// } -// `; - const SettingsContainer = styled.div` align-items: center; - background-color: #fafafa; - border-bottom: var(--ai-tool-border); + border-left: 1px solid #aaa; display: flex; gap: 5px; justify-content: space-between; - padding: 2px 5px; - width: 100%; + margin-left: 10px; + padding: 0 8px; + width: fit-content; span { display: flex; @@ -137,93 +82,70 @@ const SettingsContainer = styled.div` } `; -const SettingsButton = styled.button.attrs({ type: 'button' })` +const OptionButton = styled.button.attrs({ type: 'button' })` align-items: center; background: none; border: none; cursor: pointer; display: flex; justify-content: center; - /* outline: ${p => (p.$checked ? 'var(--ai-tool-border)' : 'none')}; */ - padding: 2px; + padding: 0; > svg { fill: ${p => (p.$checked ? '#000' : '#aaa')}; - width: 15px; + width: 18px; } `; // #endregion STYLED COMPONENTS --------------------------------------------- -const AiSettingsMenu = ({ aiService, optionsState, setOptionsState }) => { - const ctx = useContext(WaxContext); - +const PromptOptions = ({ aiService, optionsState, setOption, options }) => { + if (!options) return null; const onAiService = ({ key }) => keys(aiService).includes(key); - - const enabledSettings = SETTINGS.filter(onAiService).map(setting => ({ - ...setting, - type: typeof aiService[setting.key], - })); + const existentOptions = options.filter(onAiService); useEffect(() => { - enabledSettings.forEach(({ key }) => setOption(key, aiService[key])); + existentOptions.forEach(({ key }) => setOption(key, aiService[key])); }, []); - const setOption = (key, state) => { - ctx.setOption({ [key]: state }); - setOptionsState(prev => ({ - ...prev, - [key]: state, - })); - }; - - const handlers = (key, type, value) => { - const typeBasedHandlers = { - boolean: () => { - setOption(key, !value); - }, - }; - return typeBasedHandlers[type] ?? (() => {}); - }; - - const renderSettings = ({ key, label, type }) => { - const value = optionsState[key]; - const Icon = icons[key] ?? null; - const typeBasedSettingUI = { - boolean: ( - <SettingsButton - $checked={optionsState[key]} - key={key} - onClick={handlers(key, type, value)} - title={`${label} (${value ? 'enabled' : 'disabled'})`} - > - {Icon ? <Icon /> : ''} - {Icon ? '' : label} - </SettingsButton> - ), - }; - - return typeBasedSettingUI[type] ?? typeBasedSettingUI.boolean; - }; - - return ( - <SettingsContainer - onClick={e => e.stopPropagation()} // to prevent !showSettings - > - <span>AI Wizard</span> - <span>{enabledSettings?.map(renderSettings)}</span> + return existentOptions ? ( + <SettingsContainer> + {existentOptions.map(({ key, label, stateText, customIcon }) => { + const value = optionsState[key]; + const Icon = customIcon || icons[key] || null; + + return ( + <OptionButton + $checked={value} + key={key} + onClick={() => setOption(key, isBoolean(value) ? !value : value)} + title={`${label}${stateText(value)}`} + > + {Icon ? <Icon /> : ''} + {Icon ? '' : label} + </OptionButton> + ); + })} </SettingsContainer> - ); + ) : null; }; -AiSettingsMenu.propTypes = { +PromptOptions.propTypes = { aiService: PropTypes.shape({}), optionsState: PropTypes.shape({}), - setOptionsState: PropTypes.func, + setOption: PropTypes.func, + options: PropTypes.arrayOf( + PropTypes.shape({ + key: PropTypes.string, + label: PropTypes.string, + stateText: PropTypes.func, // recieves the option state as the only arg, string expected + }), + ), }; -AiSettingsMenu.defaultProps = { +PromptOptions.defaultProps = { aiService: {}, optionsState: {}, - setOptionsState: () => {}, + setOption: () => {}, + options: [], }; -export default AiSettingsMenu; +export default PromptOptions; diff --git a/wax-prosemirror-services/src/AiService/components/AskAIOverlay.js b/wax-prosemirror-services/src/AiService/components/AskAIOverlay.js index f73dd4fa1b6435b1e1861e4baa8544225f032169..d784e8479c608c9cd5c14def786e1fd3858b7381 100644 --- a/wax-prosemirror-services/src/AiService/components/AskAIOverlay.js +++ b/wax-prosemirror-services/src/AiService/components/AskAIOverlay.js @@ -1,35 +1,56 @@ +/* stylelint-disable no-descending-specificity */ /* eslint-disable react/jsx-props-no-spreading */ import React, { useRef, useLayoutEffect, useContext, useState } from 'react'; import styled from 'styled-components'; -import { capitalize, isEmpty, keys } from 'lodash'; +import { capitalize, debounce, isEmpty, keys } from 'lodash'; import { useTranslation } from 'react-i18next'; import { WaxContext, icons } from 'wax-prosemirror-core'; import { PropTypes } from 'prop-types'; import replaceSelectedText from '../ReplaceSelectedText'; -import AiSettingsMenu from './AiSettingsMenu'; -import { safeParse, resultsToHtml, getUpdatedPosition } from '../helpers'; +import PromptOptions from './AiSettingsMenu'; +import { + safeParse, + resultsToHtml, + getUpdatedPosition, + copyTextContent, +} from '../helpers'; const AI_TOOL_ID = 'ai-overlay'; const DEFAULT_KEY = 'content'; +const OPTIONS = { + prompt: [ + { + key: 'AskKb', + label: 'Ask knowledge base', + stateText: val => (val ? ' (enabled)' : ' (disabled)'), + }, + { + key: 'GenerateImages', + label: 'Generate image', + stateText: val => (val ? '' : ''), + }, + ], +}; // #region STYLED COMPONENTS ------------------------------------------------ // #region MAIN & MISC------------------- const Root = styled.div` - --ai-tool-result-height: 500px; + --ai-tool-result-max-height: ${p => (p.$fullScreen ? '100%' : '500px')}; + --ai-tool-result-height: ${p => (p.$fullScreen ? '100%' : 'fit-content')}; --ai-tool-width: 100%; --ai-tool-border-width: 1px; - --ai-tool-border: var(--ai-tool-border-width) solid #0001; + --ai-tool-border: var(--ai-tool-border-width) solid #333; align-items: flex-end; background: #fff; border: var(--ai-tool-border); display: flex; - filter: drop-shadow(0 0 1px #0002); flex-direction: column; + height: ${p => (p.$fullScreen ? '100%' : 'unset')}; margin: 0 10px 10px; - max-width: 95%; - min-width: 500px; + max-width: 97.5%; + min-width: 600px; width: var(--ai-tool-width); div { @@ -39,7 +60,7 @@ const Root = styled.div` } ::-webkit-scrollbar-thumb { - background: var(--scrollbar-color, #0004); + background: var(--scrollbar-color, #3334); width: 5px; } @@ -59,10 +80,8 @@ const Heading = styled.header` ); /* border-block: var(--ai-tool-border); */ display: flex; - height: 23px; + height: 25px; margin: 0; - max-height: 23px; - min-height: 23px; padding-left: 3px; width: 100%; `; @@ -88,16 +107,48 @@ const ButtonBase = styled.button.attrs({ type: 'button' })` outline: none; user-select: none; `; +const MainHeading = styled(Heading)` + background: #333; + border: none; + color: white; + height: 60px; /* to avoid clipping */ + justify-content: space-between; + max-height: 30px; + + svg { + fill: #fff; + } + + > :first-child { + align-items: center; + display: flex; + gap: 5px; + justify-content: center; + line-height: 0.5; + padding-left: 5px; + + svg { + height: 18px; + width: 18px; + } + } +`; // #endregion MAIN & MISC---------------- // #region FORM ------------------------- const AskAIForm = styled(FlexCol)` - background: #fafafa; + align-self: center; + background: #fff; + border: var(--ai-tool-border); + border-color: #aaa; + border-radius: 2rem; display: ${p => (p.$show ? 'flex' : 'none')}; - padding: 8px 12px; + margin-block: 13px; + padding: 8px 15px; position: relative; + width: 91%; `; const PromptInput = styled.input` @@ -113,6 +164,7 @@ const PromptInput = styled.input` `; const SendButton = styled(ButtonBase)` + --ai-tool-icon-color: #333; margin-bottom: 3px; padding: 0 0 0 5px; transform: scale(1.3); @@ -128,28 +180,51 @@ const ResultContainer = styled.div` display: flex; flex-direction: column; gap: 0; - height: fit-content; + height: var(--ai-tool-result-height); justify-content: flex-start; - max-height: ${p => (p.$show ? 'var(--ai-tool-result-height)' : '0')}; + max-height: ${p => (p.$show ? 'var(--ai-tool-result-max-height)' : '0')}; min-height: ${p => (p.$show ? '200px' : '0')}; overflow-y: auto; padding: 0; position: relative; transition: all 0.3s; width: 100%; + + &::before, + &::after { + background-image: linear-gradient(to bottom, #fff0, #fff); + content: ' '; + display: flex; + height: 20px; + left: 0; + position: absolute; + width: 100%; + } + + &::after { + bottom: 0; + } + + &::before { + background-image: linear-gradient(to top, #fff0 50%, #fff); + top: 35px; + } `; const ResultHeading = styled(Heading)` align-items: flex-end; - padding-left: 0; + gap: 5px; + height: 34px; + padding-left: 5px; `; const ResultTab = styled(ButtonBase)` background: ${p => (p.$selected ? '#fff' : '#fff4')}; border: var(--ai-tool-border); - border-bottom-color: ${p => (p.$selected ? '#fff' : '#0001')}; + border-color: #aaa; + border-bottom-color: ${p => (p.$selected ? '#fff' : '#aaa')}; margin-bottom: -1px; - padding: 3px 0.5rem; + padding: 6px 1rem; text-decoration: underline; text-decoration-color: #bbb0; text-underline-offset: 5px; @@ -163,52 +238,59 @@ const ResultTab = styled(ButtonBase)` `; const ResultActions = styled.div` - --separation: 7px; - align-self: flex-end; - background-color: #f8f8f8; - border: var(--ai-tool-border); - bottom: var(--separation); display: flex; + justify-content: flex-end; max-height: ${p => (p.$show ? '150px' : '0')}; opacity: ${p => (p.$show ? '1' : '0')}; - overflow: hidden; padding: 0; - position: absolute; - right: var(--separation); transition: all 0.3s; - width: fit-content; + width: 100%; - > button:not(:first-child) { - border-left: 1px solid #0001; - } + /* > button:not(:first-child) { + border-left: 1px solid #3331; + } */ `; const ResultActionButton = styled(ButtonBase)` background-color: #fff0; - outline: none; + gap: 5px; overflow: hidden; - padding: 3.2px; + padding: 8px; transition: background-color 0.3s; + svg { + height: var(--result-action-icon-size, 18px); + stroke: var(--result-action-icon-stroke, #333); + width: var(--result-action-icon-size, 18px); + + * { + /* stylelint-disable-next-line declaration-no-important */ + stroke: var(--result-action-icon-stroke, #333) !important; + } + } + &:hover { - background-color: #f2f2f2; + background-color: #ebebeb; } `; const ResultContent = styled.div` - border-top: var(--ai-tool-border); + border: none; + border-top: 1px solid #aaa; color: #555; font-family: Roboto, sans-serif; font-size: 14px; font-weight: 400; line-height: 19px; + min-height: 200px; + outline: none; overflow-y: scroll; - padding: 14px 20px 32px; + padding: 32px 6%; white-space: pre-line; width: 100%; word-wrap: break-word; - * { + p { margin: 0; } @@ -222,35 +304,38 @@ const ResultContent = styled.div` // #region CUSTOM PROMPTS --------------- const CustomPromptContainer = styled.div` - border-top: ${p => (p.$show ? 'var(--ai-tool-border)' : '0 solid #0000')}; + border-top: ${p => (p.$show ? '1px solid #aaa' : '0 solid #3330')}; display: flex; flex-direction: column; - max-height: ${p => (p.$show ? '205px' : '0')}; - overflow: hidden; + height: ${p => (p.$show ? 'fit-content' : 'unset')}; padding: 0; - transition: all 0.3s linear; width: 100%; `; -const CustomPromptsHeading = styled(Heading)` - --gradient-direction: to top; - color: #777; - font-size: 11px; - font-style: italic; - line-height: 1; - padding: 0.4rem; +const CustomPromptsHeading = styled(ButtonBase)` + align-self: flex-end; + background: #fffb; + border: none; + border-top: 1px solid #ddd; + font-size: 12px; + height: 35px; + justify-content: space-between; + min-height: 14px; /* to avoid clipping */ + padding: 5px 5px 5px 15px; + width: 100%; `; const CustomPromptsList = styled.div` background: #f2f2f2; display: flex; flex-direction: column; + max-height: ${p => (p.$show ? '205px' : '0')}; overflow-y: scroll; padding: 0; transition: all 0.3s linear; > *:not(:first-child) { - border-top: var(--ai-tool-border); + border-top: 1px solid #ddd; } `; @@ -259,8 +344,7 @@ const CustomPromptButton = styled(ButtonBase)` padding: 0; p { - background-color: ${p => (p.$selected ? '#fffa' : '#fff6')}; - border: var(--ai-tool-border); + background-color: #fff; border-left: 4px solid ${p => (p.$selected ? '#ddd' : '#aaa')}; color: #777; margin: 0; @@ -277,12 +361,6 @@ const CustomPromptButton = styled(ButtonBase)` } } `; - -const NoCustomPrompts = styled(FlexRow)` - color: #555; - justify-content: center; - text-align: center; -`; // #endregion CUSTOM PROMPTS ------------ // #endregion STYLED COMPONENTS --------------------------------------------- @@ -296,15 +374,17 @@ const AskAIOverlay = ({ setPosition, position, config }) => { pmViews: { main }, options, } = ctx; + const inputRef = useRef(null); const resultRef = useRef(null); const [userPrompt, setUserPrompt] = useState(''); const [optionsState, setOptionsState] = useState({ ...options }); - - const [isSubmitted, setIsSubmitted] = useState(false); + const [fullScreen, setFullScreen] = useState(false); const [isLoading, setIsLoading] = useState(false); - const [result, setResult] = useState({ [DEFAULT_KEY]: '' }); + const [result, setResult] = useState({ + [DEFAULT_KEY]: '', + }); const [resultKey, setResultKey] = useState(DEFAULT_KEY); const aiService = app.config.get('config.AskAiContentService'); @@ -319,11 +399,24 @@ const AskAIOverlay = ({ setPosition, position, config }) => { end: main.coordsAtPos(main.state.selection.to - 1), overlay: aiOverlay.getBoundingClientRect(), }; - + const waxSurfaceScroll = document.getElementById('wax-surface-scroll'); const { left, top } = getUpdatedPosition(coords); - setPosition({ ...position, left, top }); - }, [position.left, options.AiOn]); + setPosition({ + ...position, + left: fullScreen ? 0 : left, + top: fullScreen ? waxSurfaceScroll.scrollTop + 20 : top, + }); + + const { from, to } = main.state.selection; + const selectedText = main.state.doc.textBetween(from, to, undefined, '\n'); + !result[DEFAULT_KEY] && + setResult(prev => ({ ...prev, [DEFAULT_KEY]: selectedText })); + aiOverlay.parentNode.style.width = fullScreen ? '100%' : '80%'; + aiOverlay.parentNode.style.zIndex = '9999'; + aiOverlay.parentNode.style.height = fullScreen ? '94%' : 'unset'; + inputRef?.current && debouncedFocus(); + }, [position.left, options.AiOn, fullScreen]); // #endregion HOOKS & INIT -------------------- @@ -341,6 +434,32 @@ const AskAIOverlay = ({ setPosition, position, config }) => { : fallback; }; + const saveResult = () => { + resultRef?.current && + result[resultKey] && + setResult(prev => ({ + ...prev, + [resultKey]: resultRef.current.innerHTML, + })); + }; + + const setOption = (key, state) => { + ctx.setOption({ [key]: state }); + setOptionsState(prev => ({ + ...prev, + [key]: state, + })); + }; + + const copyText = async () => { + if (!resultRef?.current) return; + copyTextContent(resultRef?.current.textContent); + }; + + const debouncedFocus = debounce(() => { + inputRef.current && inputRef.current.focus(); + }, 200); + // #endregion HELPERS -------------------------- // #region HANDLERS ---------------------------- @@ -355,36 +474,45 @@ const AskAIOverlay = ({ setPosition, position, config }) => { } }; - const handleSubmit = async () => { - if (!enabled.send) { + const handleSubmit = async (passedInput = userPrompt, force) => { + if (!enabled.send && !force) { inputRef.current.focus(); return; } - setIsSubmitted(false); setIsLoading(true); - const { from, to } = main.state.selection; - const highlightedText = main.state.doc.textBetween(from, to); - // Updated to the new input format from gpt4o, We can pass an array of base64 images under image_url prop - const input = { text: [userPrompt, highlightedText] }; + const input = { text: [passedInput, resultRef?.current?.textContent] }; try { const response = await AskAiContentTransformation(input, { askKb: optionsState.AskKb, + prevResult: result, }); + const processedRes = safeParse(response, DEFAULT_KEY); + saveResult(); setResultKey(keys(processedRes)[0] ?? DEFAULT_KEY); setResult(processedRes); } catch (error) { setResult({ [DEFAULT_KEY]: error }); } finally { - setIsSubmitted(true); setIsLoading(false); + setUserPrompt(''); } }; + const handleTabChange = tab => { + !['links', 'citations'].includes(resultKey) && saveResult(); + setResultKey(tab); + }; + + const handleAddCustomPrompt = async prompt => { + fillAndFocusInput(prompt); + handleSubmit(prompt, true); + }; + // #endregion HANDLERS ------------------------- // #region UI ---------------------------------- @@ -402,13 +530,15 @@ const AskAIOverlay = ({ setPosition, position, config }) => { const enabled = { component: !!options?.AiOn, input: !!FreeTextPromptsOn, - results: isSubmitted && !!result[resultKey], + results: !!result[resultKey], customprompts: !!options?.CustomPromptsOn, send: userPrompt.length > 1, + resultEdit: resultKey === DEFAULT_KEY, }; const resultActions = { replace: { + label: 'Replace', Icon: icons.replaceIco, title: safeTranslation( `Wax.AI.Replace selected text`, @@ -416,26 +546,36 @@ const AskAIOverlay = ({ setPosition, position, config }) => { ), onClick: () => { replaceSelectedText(main, resultString, true); + main.focus(); }, - tabIndex: result.enabled ? 0 : -1, + tabIndex: enabled.results ? 0 : -1, }, insert: { + label: 'Insert', Icon: icons.insertIco, title: safeTranslation(`Wax.AI.Insert`, 'Insert'), onClick: () => { replaceSelectedText(main, resultString); + main.focus(); }, - tabIndex: result.enabled ? 0 : -1, + tabIndex: enabled.results ? 0 : -1, + }, + copy: { + Icon: icons.copy, + title: 'Copy', + onClick: () => copyText(), + tabIndex: enabled.results ? 0 : -1, + style: { '--result-action-icon-stroke': '#777' }, }, tryAgain: { Icon: icons.tryAgain, title: safeTranslation(`Wax.AI. Try again`, 'Try again'), onClick: () => { - setIsSubmitted(false); setResult({}); handleSubmit(); }, - tabIndex: result.enabled ? 0 : -1, + tabIndex: enabled.results ? 0 : -1, + style: { '--result-action-icon-size': '16px' }, }, discard: { Icon: icons.deleteIco, @@ -443,25 +583,58 @@ const AskAIOverlay = ({ setPosition, position, config }) => { onClick: () => { setUserPrompt(''); setResult({}); - setIsSubmitted(false); }, - tabIndex: result.enabled ? 0 : -1, + tabIndex: enabled.results ? 0 : -1, + style: { '--result-action-icon-size': '16px' }, }, }; // #endregion UI ------------------------------- return enabled.component ? ( - <Root id={AI_TOOL_ID}> - <AiSettingsMenu - aiService={aiService} - optionsState={optionsState} - setOptionsState={setOptionsState} - /> + <Root $fullScreen={fullScreen} id={AI_TOOL_ID}> + <MainHeading> + <span> + <icons.ai /> + AI Assistant + </span> + <ButtonBase onClick={() => setFullScreen(!fullScreen)}> + {fullScreen ? <icons.fullScreenExit /> : <icons.fullScreen />} + </ButtonBase> + </MainHeading> + <ResultContainer $show={enabled.results}> + <ResultHeading> + {resultKeys?.map(k => ( + <ResultTab + $selected={resultKey === k} + key={k} + onClick={() => handleTabChange(k)} + > + {capitalize(k)} + </ResultTab> + ))} + <ResultActions $show={enabled.results}> + {Object.values(resultActions).map( + ({ title, Icon, label, ...rest }) => ( + <ResultActionButton key={title} title={title} {...rest}> + <Icon /> {label || ''} + </ResultActionButton> + ), + )} + </ResultActions> + </ResultHeading> + <ResultContent + contentEditable={enabled.resultEdit} + dangerouslySetInnerHTML={{ + __html: resultsToHtml(resultKey, result[resultKey]), + }} + ref={resultRef} + /> + </ResultContainer> <AskAIForm $show> <FlexRow> <PromptInput - disabled={!enabled.input} + $disabled={!enabled.input} id="askAiInput" onChange={handleInputChange} onKeyDown={handleKeyDown} @@ -473,67 +646,50 @@ const AskAIOverlay = ({ setPosition, position, config }) => { type="text" value={userPrompt} /> - <SendButton disabled={!enabled.send} onClick={handleSubmit}> + <SendButton + disabled={!enabled.send} + onClick={() => { + handleSubmit(); + }} + > {submitIcon} </SendButton> + <PromptOptions + aiService={aiService} + options={OPTIONS.prompt} + optionsState={optionsState} + setOption={setOption} + /> </FlexRow> </AskAIForm> - <ResultContainer $show={enabled.results}> - <ResultHeading> - {resultKeys?.map(k => ( - <ResultTab - $selected={resultKey === k} - key={k} - onClick={() => setResultKey(k)} - > - {capitalize(k)} - </ResultTab> - ))} - </ResultHeading> - <ResultContent - contentEditable - dangerouslySetInnerHTML={{ - __html: resultsToHtml(resultKey, result[resultKey]), - }} - ref={resultRef} // to get the text from this innerHTML in the future - /> - <ResultActions $show={enabled.results}> - {Object.values(resultActions).map(({ title, Icon, ...rest }) => ( - <ResultActionButton key={title} {...rest}> - <Icon /> - </ResultActionButton> - ))} - </ResultActions> - </ResultContainer> - - <CustomPromptContainer $show={enabled.customprompts}> - <CustomPromptsHeading>Custom Prompts</CustomPromptsHeading> - <CustomPromptsList> - {CustomPrompts.length > 0 ? ( - CustomPrompts?.map(prompt => ( + {CustomPrompts.length > 0 && ( + <CustomPromptContainer> + <CustomPromptsHeading + onClick={() => + setOption('CustomPromptsOn', !optionsState.CustomPromptsOn) + } + > + <span>Custom Prompts</span> + {!optionsState.CustomPromptsOn ? ( + <icons.arrowDown /> + ) : ( + <icons.arrowUp /> + )} + </CustomPromptsHeading> + <CustomPromptsList $show={enabled.customprompts}> + {CustomPrompts?.map(prompt => ( <CustomPromptButton $selected={userPrompt === prompt} key={prompt} - onClick={() => fillAndFocusInput(prompt)} + onClick={() => handleAddCustomPrompt(prompt)} > <p>{`"${prompt}"`}</p> </CustomPromptButton> - )) - ) : ( - <NoCustomPrompts> - <p> - -- - {safeTranslation( - `Wax.AI.No custom prompts`, - `You don't have any custom prompts`, - )}{' '} - -- - </p> - </NoCustomPrompts> - )} - </CustomPromptsList> - </CustomPromptContainer> + ))} + </CustomPromptsList> + </CustomPromptContainer> + )} </Root> ) : null; }; @@ -545,6 +701,7 @@ AskAIOverlay.propTypes = { setPosition: PropTypes.func, config: PropTypes.shape({ AskAiContentTransformation: PropTypes.func }), }; + AskAIOverlay.defaultProps = { position: {}, setPosition: () => {}, diff --git a/wax-prosemirror-services/src/AiService/helpers/index.js b/wax-prosemirror-services/src/AiService/helpers/index.js index 0c74fb22e65738c89a7f4108d9213c6d91f91d42..fb88c8fba167b14fcf2c006b24e9972e24ddaec3 100644 --- a/wax-prosemirror-services/src/AiService/helpers/index.js +++ b/wax-prosemirror-services/src/AiService/helpers/index.js @@ -9,19 +9,18 @@ export const safeParse = (str, fallbackKey) => { export const resultsToHtml = (key, value) => { const variations = { - content: value, - citations: - typeof value === 'string' - ? value - : value?.map(item => `<blockquote>${item}</blockquote>`).join(''), links: typeof value === 'string' ? value : value ?.map(item => `<a href="${item}" target="_blank">${item}</a></br>`) .join(''), + default: + typeof value === 'string' + ? value + : value?.map(item => `<p>${item}</p>`).join('') ?? '', }; - return variations[key] ?? value; + return variations[key] ?? variations.default; }; export const getUpdatedPosition = ({ surface, end, overlay }) => { @@ -39,3 +38,8 @@ export const getUpdatedPosition = ({ surface, end, overlay }) => { return { left, top }; }; + +export const copyTextContent = async text => { + if (!text) return; + await navigator.clipboard.writeText(text); +};