diff options
Diffstat (limited to 'src')
25 files changed, 1376 insertions, 214 deletions
diff --git a/src/client/apis/gpt/GPT.ts b/src/client/apis/gpt/GPT.ts index 4b3960902..6b4106f56 100644 --- a/src/client/apis/gpt/GPT.ts +++ b/src/client/apis/gpt/GPT.ts @@ -14,7 +14,7 @@ type GPTCallOpts = { }; const callTypeMap: { [type: string]: GPTCallOpts } = { - summary: { model: 'text-davinci-003', maxTokens: 256, temp: 0.5, prompt: 'Summarize this text briefly: ' }, + summary: { model: 'text-davinci-003', maxTokens: 256, temp: 0.5, prompt: 'Summarize this text in simpler terms: ' }, edit: { model: 'text-davinci-003', maxTokens: 256, temp: 0.5, prompt: 'Reword this: ' }, completion: { model: 'text-davinci-003', maxTokens: 256, temp: 0.5, prompt: '' }, }; @@ -39,7 +39,7 @@ const gptAPICall = async (inputText: string, callType: GPTCallType) => { temperature: opts.temp, prompt: `${opts.prompt}${inputText}`, }); - console.log(response.data.choices[0]); + // console.log(response.data.choices[0]); return response.data.choices[0].text; } catch (err) { console.log(err); @@ -47,7 +47,7 @@ const gptAPICall = async (inputText: string, callType: GPTCallType) => { } }; -const gptImageCall = async (prompt: string) => { +const gptImageCall = async (prompt: string, n?: number) => { try { const configuration = new Configuration({ apiKey: process.env.OPENAI_KEY, @@ -55,33 +55,15 @@ const gptImageCall = async (prompt: string) => { const openai = new OpenAIApi(configuration); const response = await openai.createImage({ prompt: prompt, - n: 1, + n: n ?? 1, size: '1024x1024', }); - return response.data.data[0].url; + return response.data.data.map(data => data.url); + // return response.data.data[0].url; } catch (err) { console.error(err); return; } }; -// const gptEditCall = async (selectedText: string, fullText: string) => { -// try { -// const configuration = new Configuration({ -// apiKey: process.env.OPENAI_KEY, -// }); -// const openai = new OpenAIApi(configuration); -// const response = await openai.createCompletion({ -// model: 'text-davinci-003', -// max_tokens: 256, -// temperature: 0.1, -// prompt: `Replace the phrase ${selectedText} inside of ${fullText}.`, -// }); -// return response.data.choices[0].text.trim(); -// } catch (err) { -// console.log(err); -// return 'Error connecting with API.'; -// } -// }; - export { gptAPICall, gptImageCall, GPTCallType }; diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts index af43ef16a..408b9ef18 100644 --- a/src/client/util/CurrentUserUtils.ts +++ b/src/client/util/CurrentUserUtils.ts @@ -632,6 +632,8 @@ export class CurrentUserUtils { { title: "Snap", icon: "th", toolTip: "Show Snap Lines", btnType: ButtonType.ToggleButton, ignoreClick: true, expertMode: false, toolType:"snaplines", funcs: {}, scripts: { onClick: '{ return showFreeform(self.toolType, _readOnly_);}'}}, // Only when floating document is selected in freeform { title: "Grid", icon: "border-all", toolTip: "Show Grid", btnType: ButtonType.ToggleButton, ignoreClick: true, expertMode: false, toolType:"grid", funcs: {}, scripts: { onClick: '{ return showFreeform(self.toolType, _readOnly_);}'}}, // Only when floating document is selected in freeform { title: "View All", icon: "object-group", toolTip: "Fit all Docs to View", btnType: ButtonType.ToggleButton, ignoreClick: true, expertMode: false, toolType:"viewAll", funcs: {}, scripts: { onClick: '{ return showFreeform(self.toolType, _readOnly_);}'}}, // Only when floating document is selected in freeform + // want the same style as toggle button, but don't want it to act as an actual toggle, so set disableToggle to true, + { title: "Fit All", icon: "arrows-left-right", toolTip: "Fit all Docs to View (persistent)", btnType: ButtonType.IconClickButton, ignoreClick: false, expertMode: false, toolType:"viewAllPersist", funcs: {}, scripts: { onClick: '{ return showFreeform(self.toolType, _readOnly_);}'}}, // Only when floating document is selected in freeform { title: "Clusters", icon: "braille", toolTip: "Show Doc Clusters", btnType: ButtonType.ToggleButton, ignoreClick: true, expertMode: false, toolType:"clusters", funcs: {}, scripts: { onClick: '{ return showFreeform(self.toolType, _readOnly_);}'}}, // Only when floating document is selected in freeform { title: "Cards", icon: "brain", toolTip: "Flashcards", btnType: ButtonType.ToggleButton, ignoreClick: true, expertMode: false, toolType:"flashcards", funcs: {}, scripts: { onClick: '{ return showFreeform(self.toolType, _readOnly_);}'}}, // Only when floating document is selected in freeform { title: "Arrange",icon:"arrow-down-short-wide",toolTip:"Toggle Auto Arrange", btnType: ButtonType.ToggleButton, ignoreClick: true, expertMode: false, toolType:"arrange", funcs: {hidden: 'IsNoviceMode()'}, scripts: { onClick: '{ return showFreeform(self.toolType, _readOnly_);}'}}, // Only when floating document is selected in freeform diff --git a/src/client/util/DragManager.ts b/src/client/util/DragManager.ts index f4ff38515..489c9df4a 100644 --- a/src/client/util/DragManager.ts +++ b/src/client/util/DragManager.ts @@ -195,7 +195,7 @@ export namespace DragManager { } // drag a document and drop it (or make an embed/copy on drop) - export function StartDocumentDrag(eles: HTMLElement[], dragData: DocumentDragData, downX: number, downY: number, options?: DragOptions, dropEvent?: () => any) { + export function StartDocumentDrag(eles: HTMLElement[], dragData: DocumentDragData, downX: number, downY: number, options?: DragOptions, onDropCompleted?: (e?: DragCompleteEvent) => any) { const addAudioTag = (dropDoc: any) => { dropDoc && !dropDoc.author_date && (dropDoc.author_date = new DateField()); dropDoc instanceof Doc && DocUtils.MakeLinkToActiveAudio(() => dropDoc); @@ -203,7 +203,7 @@ export namespace DragManager { }; const finishDrag = async (e: DragCompleteEvent) => { const docDragData = e.docDragData; - dropEvent?.(); // glr: optional additional function to be called - in this case with presentation trails + onDropCompleted?.(e); // glr: optional additional function to be called - in this case with presentation trails if (docDragData && !docDragData.droppedDocuments.length) { docDragData.dropAction = dragData.userDropAction || dragData.dropAction; docDragData.droppedDocuments = ( diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index e376c4fdf..67afa5867 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -65,6 +65,8 @@ import { PreviewCursor } from './PreviewCursor'; import { PropertiesView } from './PropertiesView'; import { DashboardStyleProvider, DefaultStyleProvider } from './StyleProvider'; import { TopBar } from './topbar/TopBar'; +import GenerativeFill from './nodes/generativeFill/GenerativeFill'; +import { GPTPopup } from './pdf/GPTPopup/GPTPopup'; const _global = (window /* browser */ || global) /* node */ as any; @observer @@ -72,6 +74,14 @@ export class MainView extends React.Component { public static Instance: MainView; public static Live: boolean = false; private _docBtnRef = React.createRef<HTMLDivElement>(); + // for ai image editor + @observable public imageEditorOpen: boolean = false; + @action public setImageEditorOpen = (open: boolean) => (this.imageEditorOpen = open); + @observable public imageEditorSource: string = ''; + @action public setImageEditorSource = (source: string) => (this.imageEditorSource = source); + @observable public imageRootDoc: Doc | undefined; + @observable public addDoc: ((doc: Doc | Doc[], annotationKey?: string) => boolean) | undefined; + @observable public LastButton: Opt<Doc>; @observable private _windowWidth: number = 0; @observable private _windowHeight: number = 0; @@ -339,6 +349,7 @@ export class MainView extends React.Component { fa.faMousePointer, fa.faMusic, fa.faObjectGroup, + fa.faArrowsLeftRight, fa.faPause, fa.faPen, fa.faPenNib, @@ -1012,6 +1023,8 @@ export class MainView extends React.Component { <InkTranscription /> {this.snapLines} <LightboxView key="lightbox" PanelWidth={this._windowWidth} PanelHeight={this._windowHeight} maxBorder={[200, 50]} /> + <GPTPopup key="gptpopup" /> + <GenerativeFill imageEditorOpen={this.imageEditorOpen} imageEditorSource={this.imageEditorSource} imageRootDoc={this.imageRootDoc} addDoc={this.addDoc} /> {/* <NewLightboxView key="newLightbox" PanelWidth={this._windowWidth} PanelHeight={this._windowHeight} maxBorder={[200, 50]} /> */} </div> ); diff --git a/src/client/views/collections/TabDocView.tsx b/src/client/views/collections/TabDocView.tsx index ea473d5cf..f379d09ff 100644 --- a/src/client/views/collections/TabDocView.tsx +++ b/src/client/views/collections/TabDocView.tsx @@ -137,7 +137,11 @@ export class TabDocView extends React.Component<TabDocViewProps> { setupMoveUpEvents( this, e, - e => !e.defaultPrevented && DragManager.StartDocumentDrag([iconWrap], new DragManager.DocumentDragData([doc], doc.dropAction as dropActionType), e.clientX, e.clientY), + e => + !e.defaultPrevented && + DragManager.StartDocumentDrag([iconWrap], new DragManager.DocumentDragData([doc], doc.dropAction as dropActionType), e.clientX, e.clientY, undefined, () => { + CollectionDockingView.CloseSplit(doc); + }), returnFalse, action(e => { if (this.view) { diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx index fb8ec93b2..747ab9249 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx @@ -21,6 +21,8 @@ export interface CollectionFreeFormLinkViewProps { LinkDocs: Doc[]; } +// props.screentolocatransform + @observer export class CollectionFreeFormLinkView extends React.Component<CollectionFreeFormLinkViewProps> { @observable _opacity: number = 0; @@ -235,11 +237,12 @@ export class CollectionFreeFormLinkView extends React.Component<CollectionFreeFo bActive, textX, textY, - // pt1, - // pt2, + // fully connected + pt1, + pt2, // this code adds space between links - pt1: [pt1[0] + pt1normalized[0] * 13, pt1[1] + pt1normalized[1] * 13], - pt2: [pt2[0] + pt2normalized[0] * 13, pt2[1] + pt2normalized[1] * 13], + // pt1: [pt1[0] + pt1normalized[0] * 13, pt1[1] + pt1normalized[1] * 13], + // pt2: [pt2[0] + pt2normalized[0] * 13, pt2[1] + pt2normalized[1] * 13], }; } @@ -297,6 +300,7 @@ export class CollectionFreeFormLinkView extends React.Component<CollectionFreeFo className="collectionfreeformlinkview-linkLine" style={{ pointerEvents: 'visibleStroke', opacity: this._opacity, stroke, strokeWidth }} onClick={this.onClickLine} + // d={`M ${pt1[0]} ${pt1[1]} L ${pt2[0]} ${pt2[1]}`} d={`M ${pt1[0]} ${pt1[1]} C ${pt1[0] + pt1norm[0]} ${pt1[1] + pt1norm[1]}, ${pt2[0] + pt2norm[0]} ${pt2[1] + pt2norm[1]}, ${pt2[0]} ${pt2[1]}`} markerEnd={link.link_displayArrow ? `url(#${link[Id] + 'arrowhead'})` : ''} /> diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index ffcf0999c..91a78c2af 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -237,7 +237,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection onChildClickHandler = () => this.props.childClickScript || ScriptCast(this.Document.onChildClick); onChildDoubleClickHandler = () => this.props.childDoubleClickScript || ScriptCast(this.Document.onChildDoubleClick); elementFunc = () => this._layoutElements; - shrinkWrap = () => { + fitContentOnce = () => { if (this.props.DocumentView?.().nativeWidth) return; const vals = this.fitToContentVals; this.layoutDoc._freeform_panX = vals.bounds.cx; @@ -1595,6 +1595,15 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection elements => (this._layoutElements = elements || []), { fireImmediately: true, name: 'doLayout' } ); + + this._disposers.fitContent = reaction( + () => this.rootDoc.fitContentOnce, + fitContentOnce => { + if (fitContentOnce) this.fitContentOnce(); + this.rootDoc.fitContentOnce = false; + }, + { fireImmediately: true, name: 'fitContent' } + ); }) ); } @@ -1785,6 +1794,14 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection !Doc.noviceMode && optionItems.push({ description: (this._showAnimTimeline ? 'Close' : 'Open') + ' Animation Timeline', event: action(() => (this._showAnimTimeline = !this._showAnimTimeline)), icon: 'eye' }); this.props.renderDepth && optionItems.push({ description: 'Use Background Color as Default', event: () => (Cast(Doc.UserDoc().emptyCollection, Doc, null)._backgroundColor = StrCast(this.layoutDoc._backgroundColor)), icon: 'palette' }); + this.props.renderDepth && + optionItems.push({ + description: 'Fit Content Once', + event: () => { + this.fitContentOnce(); + }, + icon: 'object-group', + }); if (!Doc.noviceMode) { optionItems.push({ description: (!Doc.NativeWidth(this.layoutDoc) || !Doc.NativeHeight(this.layoutDoc) ? 'Freeze' : 'Unfreeze') + ' Aspect', event: this.toggleNativeDimensions, icon: 'snowflake' }); } diff --git a/src/client/views/global/globalScripts.ts b/src/client/views/global/globalScripts.ts index 256377758..38bf1042d 100644 --- a/src/client/views/global/globalScripts.ts +++ b/src/client/views/global/globalScripts.ts @@ -85,10 +85,10 @@ ScriptingGlobals.add(function toggleOverlay(checkResult?: boolean) { selected ? selected.props.CollectionFreeFormDocumentView?.().float() : console.log('[FontIconBox.tsx] toggleOverlay failed'); }); -ScriptingGlobals.add(function showFreeform(attr: 'flashcards' | 'grid' | 'snaplines' | 'clusters' | 'arrange' | 'viewAll', checkResult?: boolean) { +ScriptingGlobals.add(function showFreeform(attr: 'flashcards' | 'grid' | 'snaplines' | 'clusters' | 'arrange' | 'viewAll' | 'viewAllPersist', checkResult?: boolean) { const selected = SelectionManager.Docs().lastElement(); // prettier-ignore - const map: Map<'flashcards' | 'grid' | 'snaplines' | 'clusters' | 'arrange'| 'viewAll', { waitForRender?: boolean, checkResult: (doc:Doc) => any; setDoc: (doc:Doc) => void;}> = new Map([ + const map: Map<'flashcards' | 'grid' | 'snaplines' | 'clusters' | 'arrange'| 'viewAll' | 'viewAllPersist', { waitForRender?: boolean, checkResult: (doc:Doc) => any; setDoc: (doc:Doc) => void;}> = new Map([ ['grid', { checkResult: (doc:Doc) => BoolCast(doc._freeform_backgroundGrid, false), setDoc: (doc:Doc) => doc._freeform_backgroundGrid = !doc._freeform_backgroundGrid, @@ -101,6 +101,10 @@ ScriptingGlobals.add(function showFreeform(attr: 'flashcards' | 'grid' | 'snapli checkResult: (doc:Doc) => BoolCast(doc._freeform_fitContentsToBox, false), setDoc: (doc:Doc) => doc._freeform_fitContentsToBox = !doc._freeform_fitContentsToBox, }], + ['viewAllPersist', { + checkResult: (doc:Doc) => false, + setDoc: (doc:Doc) => doc.fitContentOnce = true + }], ['clusters', { waitForRender: true, // flags that undo batch should terminate after a re-render giving the script the chance to fire checkResult: (doc:Doc) => BoolCast(doc._freeform_useClusters, false), diff --git a/src/client/views/nodes/FontIconBox/FontIconBox.tsx b/src/client/views/nodes/FontIconBox/FontIconBox.tsx index 5ff5f7bfa..0e230e19b 100644 --- a/src/client/views/nodes/FontIconBox/FontIconBox.tsx +++ b/src/client/views/nodes/FontIconBox/FontIconBox.tsx @@ -29,6 +29,7 @@ export enum ButtonType { DropdownList = 'dropdownList', DropdownButton = 'dropdownBtn', ClickButton = 'clickBtn', + IconClickButton = 'iconClickBtn', ToggleButton = 'toggleBtn', ColorButton = 'colorBtn', ToolButton = 'toolBtn', @@ -352,6 +353,32 @@ export class FontIconBox extends DocComponent<ButtonProps>() { ); } + @computed get iconClickButton() { + // Determine the type of toggle button + const buttonText: string = StrCast(this.rootDoc.buttonText); + const tooltip: string = StrCast(this.rootDoc.toolTip); + + const script = ScriptCast(this.rootDoc.onClick); + const toggleStatus = script ? script.script.run({ this: this.layoutDoc, self: this.rootDoc, value: undefined, _readOnly_: true }).result : false; + // Colors + const color = this.props.styleProvider?.(this.rootDoc, this.props, StyleProp.Color); + const backgroundColor = this.props.styleProvider?.(this.rootDoc, this.props, StyleProp.BackgroundColor); + + return ( + <Toggle + tooltip={`${tooltip}`} + toggleType={ToggleType.BUTTON} + type={Type.PRIM} + toggleStatus={toggleStatus} + text={buttonText} + color={color} + icon={this.Icon(color)!} + label={this.label} + onPointerDown={() => script.script.run({ this: this.layoutDoc, self: this.rootDoc, value: !toggleStatus, _readOnly_: false })} + /> + ); + } + /** * Default */ @@ -399,6 +426,7 @@ export class FontIconBox extends DocComponent<ButtonProps>() { case ButtonType.DropdownButton: return this.dropdownButton; case ButtonType.MultiToggleButton: return this.multiToggleButton; case ButtonType.ToggleButton: return this.toggleButton; + case ButtonType.IconClickButton: return this.iconClickButton; case ButtonType.ClickButton: case ButtonType.ToolButton: return <IconButton {...btnProps} size={Size.LARGE} color={color} />; case ButtonType.TextButton: return <Button {...btnProps} text={StrCast(this.rootDoc.buttonText)}/>; diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx index c7ccd1ea0..284228676 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -37,6 +37,7 @@ import './ImageBox.scss'; import { PinProps, PresBox } from './trails'; import React = require('react'); import Color = require('color'); +import { MainView } from '../MainView'; export const pageSchema = createSchema({ googlePhotosUrl: 'string', @@ -247,6 +248,16 @@ export class ImageBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProp funcs.push({ description: `Show ${this.layoutDoc._showFullRes ? 'Dynamic Res' : 'Full Res'}`, event: this.resolution, icon: 'expand' }); funcs.push({ description: 'Set Native Pixel Size', event: this.setNativeSize, icon: 'expand-arrows-alt' }); funcs.push({ description: 'Copy path', event: () => Utils.CopyText(this.choosePath(field.url)), icon: 'copy' }); + funcs.push({ + description: 'Open Image Editor', + event: action(() => { + MainView.Instance.setImageEditorOpen(true); + MainView.Instance.setImageEditorSource(this.choosePath(field.url)); + MainView.Instance.addDoc = this.props.addDocument; + MainView.Instance.imageRootDoc = this.rootDoc; + }), + icon: 'pencil-alt', + }); if (!Doc.noviceMode) { funcs.push({ description: 'Export to Google Photos', event: () => GooglePhotos.Transactions.UploadImages([this.props.Document]), icon: 'caret-square-right' }); diff --git a/src/client/views/nodes/WebBox.tsx b/src/client/views/nodes/WebBox.tsx index febf8341e..a1e46b69b 100644 --- a/src/client/views/nodes/WebBox.tsx +++ b/src/client/views/nodes/WebBox.tsx @@ -358,6 +358,11 @@ export class WebBox extends ViewBoxAnnotatableComponent<ViewBoxAnnotatableProps AnchorMenu.Instance.jumpTo(e.clientX * scale + mainContBounds.translateX, e.clientY * scale + mainContBounds.translateY - NumCast(this.layoutDoc._layout_scrollTop) * scale); // Changing which document to add the annotation to (the currently selected WebBox) GPTPopup.Instance.setSidebarId(`${this.props.fieldKey}_${this._urlHash}_sidebar`); + const anchor = this._getAnchor(undefined, false); + if (anchor) { + console.log(anchor); + GPTPopup.Instance.setTargetAnchor(anchor); + } GPTPopup.Instance.addDoc = this.sidebarAddDocument; } } diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index eea4f513e..fd30c43d7 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx @@ -70,6 +70,7 @@ import { schema } from './schema_rts'; import { SummaryView } from './SummaryView'; import applyDevTools = require('prosemirror-dev-tools'); import React = require('react'); +import { GPTPopup, GPTPopupMode } from '../../pdf/GPTPopup/GPTPopup'; const translateGoogleApi = require('translate-google-api'); export const GoogleRef = 'googleDocId'; type PullHandler = (exportState: Opt<GoogleApiClientUtils.Docs.ImportResult>, dataDoc: Doc) => void; @@ -903,57 +904,88 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps this._downX = this._downY = Number.NaN; }; - animateRes = (resIndex: number) => { - if (resIndex < this.gptRes.length) { - this.dataDoc.text = (this.dataDoc.text as RichTextField)?.Text + this.gptRes[resIndex]; + animateRes = (resIndex: number, newText: string) => { + if (resIndex < newText.length) { + const marks = this._editorView?.state.storedMarks ?? []; + // if (!marks) return; + this._editorView?.dispatch(this._editorView.state.tr.setStoredMarks(marks).insertText(newText[resIndex]).setStoredMarks(marks)); setTimeout(() => { - this.animateRes(resIndex + 1); + this.animateRes(resIndex + 1, newText); }, 20); } }; + // breakupdictiation for reference + // prepareForTyping + // stored marks - userMark and timestamp, setStoredMarks + // the._editorView.dispatch(state.tr.setSelection(updated).insertText('\n, to)) + askGPT = action(async () => { + // const state = this._editorView?.state; + // if (!state) return; + // const to = state.selection.to; + // const updated = TextSelection.create(state.doc, to, to); + // this._editorView?.dispatch(state.tr.setSelection(updated).insertText('\n', to)); + // this._editorView?.dispatch(this._editorView.state.tr.setStoredMarks(marks).insertText('\nTesting').setStoredMarks(marks)); + // console.log('After ', this._editorView?.state.storedMarks); try { let res = await gptAPICall((this.dataDoc.text as RichTextField)?.Text, GPTCallType.COMPLETION); - if (res) { - this.gptRes = res; - this.animateRes(0); + if (!res) { + console.error('GPT call failed'); + this.animateRes(0, 'Something went wrong.'); + } else { + this.animateRes(0, res); } } catch (err) { - console.log(err); - this.dataDoc.text = (this.dataDoc.text as RichTextField)?.Text + 'Something went wrong'; + console.error('GPT call failed'); + this.animateRes(0, 'Something went wrong.'); } }); generateImage = async () => { console.log('Generate image from text: ', (this.dataDoc.text as RichTextField)?.Text); - try { - let image_url = await gptImageCall((this.dataDoc.text as RichTextField)?.Text); - if (image_url) { - const [result] = await Networking.PostToServer('/uploadRemoteImage', { sources: [image_url] }); - const source = result.accessPaths.agnostic.client; - const newDoc = Docs.Create.ImageDocument(source, { - x: NumCast(this.rootDoc.x) + NumCast(this.layoutDoc._width) + 10, - y: NumCast(this.rootDoc.y), - _height: 200, - _width: 200, - data_nativeWidth: result.nativeWidth, - data_nativeHeight: result.nativeHeight, - }); - if (Doc.IsInMyOverlay(this.rootDoc)) { - newDoc.overlayX = this.rootDoc.x; - newDoc.overlayY = NumCast(this.rootDoc.y) + NumCast(this.rootDoc._height); - Doc.AddToMyOverlay(newDoc); - } else { - this.props.addDocument?.(newDoc); - } - // Create link between prompt and image - DocUtils.MakeLink(this.rootDoc, newDoc, { link_relationship: 'Image Prompt' }); - } - } catch (err) { - console.log(err); - return ''; - } + GPTPopup.Instance?.setTextAnchor(this.getAnchor(false)); + GPTPopup.Instance?.setImgTargetDoc(this.rootDoc); + // GPTPopup.Instance.setImgUrls([]); + // GPTPopup.Instance.setMode(GPTPopupMode.IMAGE); + // GPTPopup.Instance.setVisible(true); + GPTPopup.Instance.addToCollection = this.props.addDocument; + // GPTPopup.Instance.setLoading(true); + GPTPopup.Instance.setImgDesc((this.dataDoc.text as RichTextField)?.Text); + GPTPopup.Instance.generateImage(); + + // try { + // // make this support multiple images + // let image_urls = await gptImageCall((this.dataDoc.text as RichTextField)?.Text); + // console.log(image_urls); + // if (image_urls) { + // const [result] = await Networking.PostToServer('/uploadRemoteImage', { sources: [image_urls[0]] }); + // const source = Utils.prepend(result.accessPaths.agnostic.client); + // GPTPopup.Instance.setImgUrls([source]); + + // // const newDoc = Docs.Create.ImageDocument(source, { + // // x: NumCast(this.rootDoc.x) + NumCast(this.layoutDoc._width) + 10, + // // y: NumCast(this.rootDoc.y), + // // _height: 200, + // // _width: 200, + // // data_nativeWidth: result.nativeWidth, + // // data_nativeHeight: result.nativeHeight, + // // }); + // // if (Doc.IsInMyOverlay(this.rootDoc)) { + // // newDoc.overlayX = this.rootDoc.x; + // // newDoc.overlayY = NumCast(this.rootDoc.y) + NumCast(this.rootDoc._height); + // // Doc.AddToMyOverlay(newDoc); + // // } else { + // // this.props.addDocument?.(newDoc); + // // } + // // // Create link between prompt and image + // // DocUtils.MakeLink(this.rootDoc, newDoc, { link_relationship: 'Image Prompt' }); + // } + // } catch (err) { + // console.log(err); + // return ''; + // } + // GPTPopup.Instance.setLoading(false); }; breakupDictation = () => { @@ -1248,11 +1280,13 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps RichTextMenu.Instance?.updateMenu(this._editorView, undefined, this.props); setTimeout(this.autoLink, 20); } - // Accessing editor and text doc for gpt assisted text edits - if (this._editorView && selected) { - AnchorMenu.Instance?.setEditorView(this._editorView); - AnchorMenu.Instance?.setTextDoc(this.dataDoc); - } + // // Accessing editor and text doc for gpt assisted text edits + // if (this._editorView && selected) { + // console.log('Setting'); + // GPTPopup.Instance?.setTextAnchor(this.getAnchor(false)); + // // AnchorMenu.Instance?.setEditorView(this._editorView); + // // AnchorMenu.Instance?.setTextDoc(this.dataDoc); + // } }), { fireImmediately: true } ); diff --git a/src/client/views/nodes/generativeFill/GenerativeFill.scss b/src/client/views/nodes/generativeFill/GenerativeFill.scss new file mode 100644 index 000000000..c2669a950 --- /dev/null +++ b/src/client/views/nodes/generativeFill/GenerativeFill.scss @@ -0,0 +1,97 @@ +$navHeight: 5rem; +$canvasSize: 1024px; +$scale: 0.5; + +.generativeFillContainer { + position: absolute; + top: 0; + left: 0; + z-index: 9999; + height: 100vh; + width: 100vw; + display: flex; + flex-direction: column; + overflow: hidden; + + .generativeFillControls { + flex-shrink: 0; + height: $navHeight; + color: #000000; + background-color: #ffffff; + z-index: 999; + width: 100%; + display: flex; + gap: 3rem; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid #c7cdd0; + padding: 0 2rem; + + h1 { + font-size: 1.5rem; + } + } + + .drawingArea { + cursor: none; + touch-action: none; + position: relative; + flex-grow: 1; + display: flex; + justify-content: center; + align-items: center; + width: 100%; + background-color: #f0f4f6; + + canvas { + display: block; + position: absolute; + transform-origin: 50% 50%; + } + + .pointer { + pointer-events: none; + position: absolute; + border-radius: 50%; + width: 50px; + height: 50px; + border: 1px solid #ffffff; + transform: translate(-50%, -50%); + display: flex; + justify-content: center; + align-items: center; + + .innerPointer { + width: 100%; + height: 100%; + border: 1px solid #000000; + border-radius: 50%; + } + } + + .iconContainer { + position: absolute; + top: 2rem; + left: 2rem; + display: flex; + flex-direction: column; + gap: 2rem; + } + + .editsBox { + position: absolute; + top: 2rem; + right: 2rem; + display: flex; + flex-direction: column; + gap: 1rem; + + img { + transition: all 0.2s ease-in-out; + &:hover { + opacity: 0.8; + } + } + } + } +} diff --git a/src/client/views/nodes/generativeFill/GenerativeFill.tsx b/src/client/views/nodes/generativeFill/GenerativeFill.tsx new file mode 100644 index 000000000..f136982bc --- /dev/null +++ b/src/client/views/nodes/generativeFill/GenerativeFill.tsx @@ -0,0 +1,561 @@ +import './GenerativeFill.scss'; +import React = require('react'); +import { useEffect, useRef, useState } from 'react'; +import { APISuccess, ImageUtility } from './generativeFillUtils/ImageHandler'; +import { BrushHandler } from './generativeFillUtils/BrushHandler'; +import { Box, IconButton, Slider, TextField } from '@mui/material'; +import { CursorData, Point } from './generativeFillUtils/generativeFillInterfaces'; +import { activeColor, canvasSize, eraserColor, freeformRenderSize, newCollectionSize, offsetDistanceY, offsetX } from './generativeFillUtils/generativeFillConstants'; +import { PointerHandler } from './generativeFillUtils/PointerHandler'; +import { BsEraser, BsX } from 'react-icons/bs'; +import { CiUndo, CiRedo } from 'react-icons/ci'; +import Buttons from './GenerativeFillButtons'; +import { MainView } from '../../MainView'; +import { Doc } from '../../../../fields/Doc'; +import { Networking } from '../../../Network'; +import { Utils } from '../../../../Utils'; +import { DocUtils, Docs } from '../../../documents/Documents'; +import { Cast, DocCast, NumCast } from '../../../../fields/Types'; +import { CollectionDockingView } from '../../collections/CollectionDockingView'; +import { OpenWhere, OpenWhereMod } from '../DocumentView'; +import { Oval } from 'react-loader-spinner'; + +/** + * For images not 1024x1024 fill in the rest in solid black, or a + * reflected version of the image. + * + * add a branch from image directly checkbox + */ + +/** + * + * + * CollectionDockingView.AddSplit(Doc.MakeCopy(DocCast(Doc.UserDoc().emptyPane)), OpenWhereMod.right); + * CollectionDockingView.AddSplit(newCollection,OpenWhere.inParent) + * mind mapping + * this.props.addDocTab(); + */ + +enum BrushStyle { + ADD, + SUBTRACT, + MARQUEE, +} + +interface ImageEdit { + imgElement: HTMLImageElement; + parent: ImageEdit; + children: ImageEdit[]; +} + +interface GenerativeFillProps { + imageEditorOpen: boolean; + imageEditorSource: string; + imageRootDoc: Doc | undefined; + addDoc: ((doc: Doc | Doc[], annotationKey?: string) => boolean) | undefined; +} + +const GenerativeFill = ({ imageEditorOpen, imageEditorSource, imageRootDoc, addDoc }: GenerativeFillProps) => { + const canvasRef = useRef<HTMLCanvasElement>(null); + const canvasBackgroundRef = useRef<HTMLCanvasElement>(null); + const drawingAreaRef = useRef<HTMLDivElement>(null); + const fileRef = useRef<HTMLInputElement>(null); + const [cursorData, setCursorData] = useState<CursorData>({ + x: 0, + y: 0, + width: 150, + }); + const [isBrushing, setIsBrushing] = useState(false); + const [canvasScale, setCanvasScale] = useState(0.5); + const [edits, setEdits] = useState<string[]>([]); + const [brushStyle, setBrushStyle] = useState<BrushStyle>(BrushStyle.ADD); + const [input, setInput] = useState(''); + const [loading, setLoading] = useState(false); + const [saveLoading, setSaveLoading] = useState(false); + // the current image in the main canvas + const currImg = useRef<HTMLImageElement | null>(null); + // the unedited version of each generation (parent) + const originalImg = useRef<HTMLImageElement | null>(null); + // stores history of data urls + const undoStack = useRef<string[]>([]); + // stores redo stack + const redoStack = useRef<string[]>([]); + + // early stage properly, likely will get rid of + const freeformPosition = useRef<number[]>([0, 0]); + + // which urls were already saved to canvas + const savedSrcs = useRef<Set<string>>(new Set()); + + // references to keep track of tree structure + const newCollectionRef = useRef<Doc | null>(null); + const parentDoc = useRef<Doc | null>(null); + const childrenDocs = useRef<Doc[]>([]); + const addToExistingCollection = useRef<boolean>(false); + + // Undo and Redo + const handleUndo = () => { + const ctx = ImageUtility.getCanvasContext(canvasRef); + if (!ctx || !currImg.current || !canvasRef.current) return; + + const target = undoStack.current[undoStack.current.length - 1]; + if (!target) { + ImageUtility.drawImgToCanvas(currImg.current, canvasRef); + } else { + redoStack.current = [...redoStack.current, canvasRef.current.toDataURL()]; + const img = new Image(); + img.src = target; + ImageUtility.drawImgToCanvas(img, canvasRef); + undoStack.current = undoStack.current.slice(0, -1); + } + }; + + const handleRedo = () => { + // TODO: handle undo as well + const target = redoStack.current[redoStack.current.length - 1]; + if (!target) { + } else { + const img = new Image(); + img.src = target; + ImageUtility.drawImgToCanvas(img, canvasRef); + redoStack.current = redoStack.current.slice(0, -1); + } + }; + + const handleReset = () => { + if (!canvasRef.current || !currImg.current) return; + const ctx = ImageUtility.getCanvasContext(canvasRef); + if (!ctx) return; + ctx.clearRect(0, 0, canvasSize, canvasSize); + undoStack.current = []; + redoStack.current = []; + ImageUtility.drawImgToCanvas(currImg.current, canvasRef, true); + }; + + // initiate brushing + const handlePointerDown = (e: React.PointerEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = ImageUtility.getCanvasContext(canvasRef); + if (!ctx) return; + + undoStack.current = [...undoStack.current, canvasRef.current.toDataURL()]; + redoStack.current = []; + + setIsBrushing(true); + const { x, y } = PointerHandler.getPointRelativeToElement(canvas, e, canvasScale); + + BrushHandler.brushCircleOverlay(x, y, cursorData.width / 2 / canvasScale, ctx, eraserColor, brushStyle === BrushStyle.SUBTRACT); + }; + + // stop brushing, push to undo stack + const handlePointerUp = (e: React.PointerEvent) => { + const ctx = ImageUtility.getCanvasContext(canvasBackgroundRef); + if (!ctx) return; + if (!isBrushing) return; + setIsBrushing(false); + }; + + // handles brushing on pointer movement + useEffect(() => { + if (!isBrushing) return; + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = ImageUtility.getCanvasContext(canvasRef); + if (!ctx) return; + + const handlePointerMove = (e: PointerEvent) => { + const currPoint = PointerHandler.getPointRelativeToElement(canvas, e, canvasScale); + const lastPoint: Point = { + x: currPoint.x - e.movementX / canvasScale, + y: currPoint.y - e.movementY / canvasScale, + }; + BrushHandler.createBrushPathOverlay(lastPoint, currPoint, cursorData.width / 2 / canvasScale, ctx, eraserColor, brushStyle === BrushStyle.SUBTRACT); + }; + + drawingAreaRef.current?.addEventListener('pointermove', handlePointerMove); + return () => { + drawingAreaRef.current?.removeEventListener('pointermove', handlePointerMove); + }; + }, [isBrushing]); + + // first load + useEffect(() => { + console.log('first load'); + if (!imageEditorSource || imageEditorSource === '') return; + const img = new Image(); + console.log('source', imageEditorSource); + img.src = imageEditorSource; + console.log('drawing image'); + ImageUtility.drawImgToCanvas(img, canvasRef); + currImg.current = img; + originalImg.current = img; + freeformPosition.current = [0, 0]; + + return () => { + console.log('cleanup'); + newCollectionRef.current = null; + parentDoc.current = null; + childrenDocs.current = []; + currImg.current = null; + originalImg.current = null; + freeformPosition.current = [0, 0]; + undoStack.current = []; + redoStack.current = []; + }; + }, [canvasRef, imageEditorSource]); + + // handles brush sizing + useEffect(() => { + const handleKeyPress = (e: KeyboardEvent) => { + if (e.key === 'ArrowUp') { + e.preventDefault(); + e.stopPropagation(); + setCursorData(data => ({ ...data, width: data.width + 5 })); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + e.stopPropagation(); + setCursorData(data => (data.width >= 20 ? { ...data, width: data.width - 5 } : data)); + } + }; + window.addEventListener('keydown', handleKeyPress); + return () => window.removeEventListener('keydown', handleKeyPress); + }, []); + + // handle pinch zoom + useEffect(() => { + const handlePinch = (e: WheelEvent) => { + e.preventDefault(); + e.stopPropagation(); + const delta = e.deltaY; + const scaleFactor = delta > 0 ? 0.98 : 1.02; // Adjust the scale factor as per your requirement + setCanvasScale(prevScale => prevScale * scaleFactor); + }; + + drawingAreaRef.current?.addEventListener('wheel', handlePinch, { + passive: false, + }); + return () => drawingAreaRef.current?.removeEventListener('wheel', handlePinch); + }, [drawingAreaRef]); + + // updates the current position of the cursor + const updateCursorData = (e: React.PointerEvent) => { + const drawingArea = drawingAreaRef.current; + if (!drawingArea) return; + const { x, y } = PointerHandler.getPointRelativeToElement(drawingArea, e, 1); + setCursorData(data => ({ + ...data, + x, + y, + })); + }; + + // Get AI Edit + const getEdit = async () => { + const img = currImg.current; + if (!img) return; + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = ImageUtility.getCanvasContext(canvasRef); + if (!ctx) return; + setLoading(true); + // need to adjust later + try { + const maskBlob = await ImageUtility.canvasToBlob(canvas); + const imgBlob = await ImageUtility.canvasToBlob(ImageUtility.getCanvasImg(img)); + const res = await ImageUtility.getEdit(imgBlob, maskBlob, input !== '' ? input + ' in the same style' : 'Fill in the image in the same style', 2); + // const res = await ImageUtility.mockGetEdit(img.src); + + // create first image + if (!newCollectionRef.current) { + if (addToExistingCollection.current) { + } + if (!(originalImg.current && imageRootDoc)) return; + console.log('creating first image'); + // create new collection and add it to the view + newCollectionRef.current = Docs.Create.FreeformDocument([], { + x: NumCast(imageRootDoc.x) + NumCast(imageRootDoc._width) + offsetX, + y: NumCast(imageRootDoc.y), + _width: newCollectionSize, + _height: newCollectionSize, + title: 'Image edit collection', + }); + DocUtils.MakeLink(imageRootDoc, newCollectionRef.current, { link_relationship: 'Image Edit Version History', link_displayLine: false }); + // add the doc to the main freeform + // addDoc?.(newCollectionRef.current); + await createNewImgDoc(originalImg.current, true); + } else { + parentDoc.current = childrenDocs.current[childrenDocs.current.length - 1]; + childrenDocs.current = []; + } + + originalImg.current = currImg.current; + + const { urls } = res as APISuccess; + const image = new Image(); + image.src = urls[0]; + setEdits(urls); + ImageUtility.drawImgToCanvas(image, canvasRef); + currImg.current = image; + onSave(); + freeformPosition.current[0] += 1; + freeformPosition.current[1] = 0; + } catch (err) { + console.log(err); + } + setLoading(false); + }; + + // adjusts all the img positions to be aligned + const adjustImgPositions = () => { + if (!parentDoc.current) return; + const startY = NumCast(parentDoc.current.y); + const len = childrenDocs.current.length; + let initialYPositions: number[] = []; + for (let i = 0; i < len; i++) { + initialYPositions.push(startY + i * offsetDistanceY); + } + childrenDocs.current.forEach((doc, i) => { + if (len % 2 === 1) { + doc.y = initialYPositions[i] - Math.floor(len / 2) * offsetDistanceY; + } else { + doc.y = initialYPositions[i] - (len / 2 - 1 / 2) * offsetDistanceY; + } + }); + }; + + // creates a new image document and returns its reference + const createNewImgDoc = async (img: HTMLImageElement, firstDoc: boolean): Promise<Doc | undefined> => { + if (!newCollectionRef.current || !imageRootDoc) return; + const src = img.src; + const [result] = await Networking.PostToServer('/uploadRemoteImage', { sources: [src] }); + const source = Utils.prepend(result.accessPaths.agnostic.client); + + if (firstDoc) { + const x = 0; + const initialY = 0; + console.log('first doc'); + + const newImg = Docs.Create.ImageDocument(source, { + x: x, + y: initialY, + _height: freeformRenderSize, + _width: freeformRenderSize, + data_nativeWidth: result.nativeWidth, + data_nativeHeight: result.nativeHeight, + }); + + Doc.AddDocToList(newCollectionRef.current, undefined, newImg); + parentDoc.current = newImg; + return newImg; + } else { + if (!parentDoc.current) return; + const x = NumCast(parentDoc.current.x) + freeformRenderSize + offsetX; + // dummy position + console.log('creating child elements'); + const initialY = 0; + + const newImg = Docs.Create.ImageDocument(source, { + x: x, + y: initialY, + _height: freeformRenderSize, + _width: freeformRenderSize, + data_nativeWidth: result.nativeWidth, + data_nativeHeight: result.nativeHeight, + }); + + childrenDocs.current.push(newImg); + DocUtils.MakeLink(parentDoc.current, newImg, { link_relationship: 'Image Edit', link_displayLine: true }); + adjustImgPositions(); + + Doc.AddDocToList(newCollectionRef.current, undefined, newImg); + return newImg; + } + }; + + // need to maybe call on every img click, not just when the save btn is clicked + const onSave = async () => { + setSaveLoading(true); + if (!currImg.current || !originalImg.current || !imageRootDoc) return; + try { + console.log('creating another image'); + await createNewImgDoc(currImg.current, false); + } catch (err) { + console.log(err); + } + setSaveLoading(false); + }; + + const handleViewClose = () => { + if (newCollectionRef.current) { + newCollectionRef.current.fitContentOnce = true; + CollectionDockingView.AddSplit(newCollectionRef.current, OpenWhereMod.right); + } + MainView.Instance.setImageEditorOpen(false); + MainView.Instance.setImageEditorSource(''); + setEdits([]); + }; + + return ( + <div className="generativeFillContainer" style={{ display: imageEditorOpen ? 'flex' : 'none' }}> + <div className="generativeFillControls"> + <h1>AI Image Editor</h1> + <div style={{ display: 'flex', alignItems: 'center', gap: '1.5rem' }}> + <Buttons canvasRef={canvasRef} currImg={currImg} getEdit={getEdit} loading={loading} onReset={handleReset} /> + <IconButton onClick={handleViewClose}> + <BsX color={activeColor} /> + </IconButton> + {saveLoading && ( + <span style={{ height: '100%', display: 'flex', alignItems: 'center', gap: '8px' }}> + Saving image... <Oval height={20} width={20} color="#000000" visible={true} ariaLabel="oval-loading" secondaryColor="#ffffff89" strokeWidth={3} strokeWidthSecondary={3} /> + </span> + )} + </div> + </div> + {/* Main canvas for editing */} + <div + className="drawingArea" // this only works if pointerevents: none is set on the custom pointer + ref={drawingAreaRef} + onPointerOver={updateCursorData} + onPointerMove={updateCursorData} + onPointerDown={handlePointerDown} + onPointerUp={handlePointerUp}> + <canvas ref={canvasRef} width={canvasSize} height={canvasSize} style={{ transform: `scale(${canvasScale})` }} /> + <canvas ref={canvasBackgroundRef} width={canvasSize} height={canvasSize} style={{ transform: `scale(${canvasScale})` }} /> + <div + className="pointer" + style={{ + left: cursorData.x, + top: cursorData.y, + width: cursorData.width, + height: cursorData.width, + }}> + <div className="innerPointer"></div> + </div> + {/* Icons */} + <div className="iconContainer"> + <IconButton + onClick={() => { + setBrushStyle(BrushStyle.ADD); + }}> + <BsEraser color={brushStyle === BrushStyle.ADD ? activeColor : 'inherit'} /> + </IconButton> + {/* Undo and Redo */} + <IconButton + onPointerDown={e => { + e.stopPropagation(); + handleUndo(); + }} + onPointerUp={e => { + e.stopPropagation(); + }}> + <CiUndo /> + </IconButton> + <IconButton + onPointerDown={e => { + e.stopPropagation(); + handleRedo(); + }} + onPointerUp={e => { + e.stopPropagation(); + }}> + <CiRedo /> + </IconButton> + <Box + sx={{ + height: 225, + width: '100%', + display: 'flex', + justifyContent: 'center', + }}> + <Slider + sx={{ + '& input[type="range"]': { + WebkitAppearance: 'slider-vertical', + }, + }} + orientation="vertical" + min={10} + max={500} + defaultValue={150} + onChange={(e, val) => { + setCursorData(prev => ({ ...prev, width: val as number })); + }} + /> + </Box> + </div> + {/* Edits thumbnails*/} + <div className="editsBox"> + {edits.map((edit, i) => ( + <img + key={i} + width={100} + height={100} + src={edit} + onClick={async () => { + // if (savedSrcs.current.has(edit)) return; + const img = new Image(); + img.src = edit; + ImageUtility.drawImgToCanvas(img, canvasRef); + currImg.current = img; + savedSrcs.current.add(edit); + await onSave(); + }} + /> + ))} + {/* Original img thumbnail */} + {edits.length > 0 && ( + <div style={{ position: 'relative' }}> + <label + style={{ + position: 'absolute', + bottom: 10, + left: 10, + color: '#ffffff', + fontSize: '0.8rem', + letterSpacing: '1px', + textTransform: 'uppercase', + }}> + Original + </label> + <img + width={100} + height={100} + src={originalImg.current?.src} + style={{ cursor: 'pointer' }} + onClick={() => { + if (!originalImg.current) return; + const img = new Image(); + img.src = originalImg.current.src; + ImageUtility.drawImgToCanvas(img, canvasRef); + currImg.current = img; + }} + /> + </div> + )} + </div> + </div> + <div> + <TextField + value={input} + onChange={e => setInput(e.target.value)} + disabled={isBrushing} + type="text" + label="Prompt" + placeholder="Prompt..." + InputLabelProps={{ style: { fontSize: '16px' } }} + inputProps={{ style: { fontSize: '16px' } }} + sx={{ + backgroundColor: '#ffffff', + position: 'absolute', + bottom: '16px', + transform: 'translateX(calc(50vw - 50%))', + width: 'calc(100vw - 64px)', + }} + /> + </div> + </div> + ); +}; + +export default GenerativeFill; diff --git a/src/client/views/nodes/generativeFill/GenerativeFillButtons.scss b/src/client/views/nodes/generativeFill/GenerativeFillButtons.scss new file mode 100644 index 000000000..0180ef904 --- /dev/null +++ b/src/client/views/nodes/generativeFill/GenerativeFillButtons.scss @@ -0,0 +1,4 @@ +.generativeFillBtnContainer { + display: flex; + gap: 1rem; +} diff --git a/src/client/views/nodes/generativeFill/GenerativeFillButtons.tsx b/src/client/views/nodes/generativeFill/GenerativeFillButtons.tsx new file mode 100644 index 000000000..b4d56b408 --- /dev/null +++ b/src/client/views/nodes/generativeFill/GenerativeFillButtons.tsx @@ -0,0 +1,44 @@ +import { Button } from '@mui/material'; +import { ImageUtility } from './generativeFillUtils/ImageHandler'; +import { canvasSize } from './generativeFillUtils/generativeFillConstants'; +import { Oval } from 'react-loader-spinner'; +import './GenerativeFillButtons.scss'; +import React = require('react'); +import { Doc } from '../../../../fields/Doc'; + +interface ButtonContainerProps { + canvasRef: React.RefObject<HTMLCanvasElement>; + currImg: React.MutableRefObject<HTMLImageElement | null>; + getEdit: () => Promise<void>; + loading: boolean; + onReset: () => void; +} + +const Buttons = ({ canvasRef, currImg, loading, getEdit, onReset }: ButtonContainerProps) => { + + return ( + <div className="generativeFillBtnContainer"> + <Button onClick={onReset}>Reset</Button> + {/* <Button onClick={handleSave}>Save</Button> */} + {/* <Button + onClick={() => { + if (!canvasRef.current) return; + ImageUtility.downloadImageCanvas('/assets/firefly.png'); + }}> + Download Original + </Button> */} + <Button + variant="contained" + onClick={() => { + getEdit(); + }}> + <span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}> + Get Edit + {loading && <Oval height={20} width={20} color="#ffffff" visible={true} ariaLabel="oval-loading" secondaryColor="#ffffff89" strokeWidth={3} strokeWidthSecondary={3} />} + </span> + </Button> + </div> + ); +}; + +export default Buttons; diff --git a/src/client/views/nodes/generativeFill/generativeFillUtils/BrushHandler.ts b/src/client/views/nodes/generativeFill/generativeFillUtils/BrushHandler.ts new file mode 100644 index 000000000..f84f04190 --- /dev/null +++ b/src/client/views/nodes/generativeFill/generativeFillUtils/BrushHandler.ts @@ -0,0 +1,45 @@ +import { GenerativeFillMathHelpers } from './GenerativeFillMathHelpers'; +import { eraserColor } from './generativeFillConstants'; +import { Point } from './generativeFillInterfaces'; + +export class BrushHandler { + static brushCircle = (x: number, y: number, brushRadius: number, ctx: CanvasRenderingContext2D) => { + ctx.globalCompositeOperation = 'destination-out'; + ctx.shadowColor = '#ffffffeb'; + ctx.shadowBlur = 5; + ctx.beginPath(); + ctx.arc(x, y, brushRadius, 0, 2 * Math.PI); + ctx.fill(); + ctx.closePath(); + }; + + static brushCircleOverlay = (x: number, y: number, brushRadius: number, ctx: CanvasRenderingContext2D, fillColor: string, erase: boolean) => { + ctx.globalCompositeOperation = 'destination-out'; + // ctx.globalCompositeOperation = erase ? "destination-out" : "source-over"; + ctx.fillStyle = fillColor; + ctx.shadowColor = eraserColor; + ctx.shadowBlur = 5; + ctx.beginPath(); + ctx.arc(x, y, brushRadius, 0, 2 * Math.PI); + ctx.fill(); + ctx.closePath(); + }; + + static createBrushPath = (startPoint: Point, endPoint: Point, brushRadius: number, ctx: CanvasRenderingContext2D) => { + const dist = GenerativeFillMathHelpers.distanceBetween(startPoint, endPoint); + + for (let i = 0; i < dist; i += 5) { + const s = i / dist; + BrushHandler.brushCircle(startPoint.x * (1 - s) + endPoint.x * s, startPoint.y * (1 - s) + endPoint.y * s, brushRadius, ctx); + } + }; + + static createBrushPathOverlay = (startPoint: Point, endPoint: Point, brushRadius: number, ctx: CanvasRenderingContext2D, fillColor: string, erase: boolean) => { + const dist = GenerativeFillMathHelpers.distanceBetween(startPoint, endPoint); + + for (let i = 0; i < dist; i += 5) { + const s = i / dist; + BrushHandler.brushCircleOverlay(startPoint.x * (1 - s) + endPoint.x * s, startPoint.y * (1 - s) + endPoint.y * s, brushRadius, ctx, fillColor, erase); + } + }; +} diff --git a/src/client/views/nodes/generativeFill/generativeFillUtils/GenerativeFillMathHelpers.ts b/src/client/views/nodes/generativeFill/generativeFillUtils/GenerativeFillMathHelpers.ts new file mode 100644 index 000000000..027b99a52 --- /dev/null +++ b/src/client/views/nodes/generativeFill/generativeFillUtils/GenerativeFillMathHelpers.ts @@ -0,0 +1,11 @@ +import { Point } from "./generativeFillInterfaces"; + +export class GenerativeFillMathHelpers { + // math helpers + static distanceBetween = (p1: Point, p2: Point) => { + return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); + }; + static angleBetween = (p1: Point, p2: Point) => { + return Math.atan2(p2.x - p1.x, p2.y - p1.y); + }; +} diff --git a/src/client/views/nodes/generativeFill/generativeFillUtils/ImageHandler.ts b/src/client/views/nodes/generativeFill/generativeFillUtils/ImageHandler.ts new file mode 100644 index 000000000..1954ab3fb --- /dev/null +++ b/src/client/views/nodes/generativeFill/generativeFillUtils/ImageHandler.ts @@ -0,0 +1,130 @@ +import { RefObject } from 'react'; +import { canvasSize } from './generativeFillConstants'; + +export interface APISuccess { + status: 'success'; + urls: string[]; +} + +export interface APIError { + status: 'error'; + message: string; +} + +export class ImageUtility { + static canvasToBlob = (canvas: HTMLCanvasElement): Promise<Blob> => { + return new Promise(resolve => { + canvas.toBlob(blob => { + if (blob) { + resolve(blob); + } + }, 'image/png'); + }); + }; + + static getEdit = async (imgBlob: Blob, maskBlob: Blob, prompt: string, n?: number): Promise<APISuccess | APIError> => { + const apiUrl = 'https://api.openai.com/v1/images/edits'; + const fd = new FormData(); + fd.append('image', imgBlob, 'image.png'); + fd.append('mask', maskBlob, 'mask.png'); + fd.append('prompt', prompt); + fd.append('size', '512x512'); + fd.append('n', n ? JSON.stringify(n) : '1'); + fd.append('response_format', 'b64_json'); + + try { + const res = await fetch(apiUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${process.env.OPENAI_KEY}`, + }, + body: fd, + }); + const data = await res.json(); + console.log(data.data); + return { + status: 'success', + urls: (data.data as { b64_json: string }[]).map(data => `data:image/png;base64,${data.b64_json}`), + }; + } catch (err) { + console.log(err); + return { status: 'error', message: 'API error.' }; + } + }; + + static mockGetEdit = async (mockSrc: string): Promise<APISuccess | APIError> => { + return { + status: 'success', + urls: [mockSrc, mockSrc, mockSrc], + }; + }; + + static getCanvasContext = (canvasRef: RefObject<HTMLCanvasElement>): CanvasRenderingContext2D | null => { + if (!canvasRef.current) return null; + const ctx = canvasRef.current.getContext('2d'); + if (!ctx) return null; + return ctx; + }; + + static downloadCanvas = (canvas: HTMLCanvasElement) => { + const url = canvas.toDataURL(); + const downloadLink = document.createElement('a'); + downloadLink.href = url; + downloadLink.download = 'canvas'; + + downloadLink.click(); + downloadLink.remove(); + }; + + static downloadImageCanvas = (imgUrl: string) => { + const img = new Image(); + img.src = imgUrl; + img.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = canvasSize; + canvas.height = canvasSize; + const ctx = canvas.getContext('2d'); + ctx?.drawImage(img, 0, 0, canvasSize, canvasSize); + + this.downloadCanvas(canvas); + }; + }; + + static drawImgToCanvas = (img: HTMLImageElement, canvasRef: React.RefObject<HTMLCanvasElement>, loaded?: boolean) => { + if (loaded) { + const ctx = this.getCanvasContext(canvasRef); + if (!ctx) return; + ctx.globalCompositeOperation = 'source-over'; + const scale = Math.max(canvasSize / img.width, canvasSize / img.height); + const width = img.width * scale; + const height = img.height * scale; + ctx.clearRect(0, 0, canvasSize, canvasSize); + ctx.drawImage(img, 0, 0, width, height); + } else { + console.log('loading image'); + img.onload = () => { + console.log('loaded'); + const ctx = this.getCanvasContext(canvasRef); + if (!ctx) return; + ctx.globalCompositeOperation = 'source-over'; + const scale = Math.max(canvasSize / img.width, canvasSize / img.height); + const width = img.width * scale; + const height = img.height * scale; + ctx.clearRect(0, 0, canvasSize, canvasSize); + ctx.drawImage(img, 0, 0, width, height); + }; + } + }; + + // The image must be loaded! + static getCanvasImg = (img: HTMLImageElement): HTMLCanvasElement => { + const canvas = document.createElement('canvas'); + canvas.width = canvasSize; + canvas.height = canvasSize; + const ctx = canvas.getContext('2d'); + ctx?.clearRect(0, 0, canvasSize, canvasSize); + ctx?.drawImage(img, 0, 0, canvasSize, canvasSize); + + return canvas; + }; +} diff --git a/src/client/views/nodes/generativeFill/generativeFillUtils/PointerHandler.ts b/src/client/views/nodes/generativeFill/generativeFillUtils/PointerHandler.ts new file mode 100644 index 000000000..9e620ad11 --- /dev/null +++ b/src/client/views/nodes/generativeFill/generativeFillUtils/PointerHandler.ts @@ -0,0 +1,15 @@ +import { Point } from "./generativeFillInterfaces"; + +export class PointerHandler { + static getPointRelativeToElement = ( + element: HTMLElement, + e: React.PointerEvent | PointerEvent, + scale: number + ): Point => { + const boundingBox = element.getBoundingClientRect(); + return { + x: (e.clientX - boundingBox.x) / scale, + y: (e.clientY - boundingBox.y) / scale, + }; + }; +} diff --git a/src/client/views/nodes/generativeFill/generativeFillUtils/generativeFillConstants.ts b/src/client/views/nodes/generativeFill/generativeFillUtils/generativeFillConstants.ts new file mode 100644 index 000000000..412a4d238 --- /dev/null +++ b/src/client/views/nodes/generativeFill/generativeFillUtils/generativeFillConstants.ts @@ -0,0 +1,9 @@ +// constants +export const canvasSize = 512; +export const freeformRenderSize = 300; +export const offsetDistanceY = freeformRenderSize + 200; +export const offsetX = 200; +export const newCollectionSize = 500; + +export const activeColor = '#1976d2'; +export const eraserColor = '#e1e9ec'; diff --git a/src/client/views/nodes/generativeFill/generativeFillUtils/generativeFillInterfaces.ts b/src/client/views/nodes/generativeFill/generativeFillUtils/generativeFillInterfaces.ts new file mode 100644 index 000000000..9b9b9d3c2 --- /dev/null +++ b/src/client/views/nodes/generativeFill/generativeFillUtils/generativeFillInterfaces.ts @@ -0,0 +1,16 @@ +// interfaces +export interface CursorData { + x: number; + y: number; + width: number; +} + +export interface Point { + x: number; + y: number; +} + +export enum BrushMode { + ADD, + SUBTRACT, +} diff --git a/src/client/views/pdf/AnchorMenu.tsx b/src/client/views/pdf/AnchorMenu.tsx index 8e53a87f6..10cf50dff 100644 --- a/src/client/views/pdf/AnchorMenu.tsx +++ b/src/client/views/pdf/AnchorMenu.tsx @@ -49,9 +49,6 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { @observable public Status: 'marquee' | 'annotation' | '' = ''; // GPT additions - @observable private GPTpopupText: string = ''; - @observable private loadingGPT: boolean = false; - @observable private showGPTPopup: boolean = false; @observable private GPTMode: GPTPopupMode = GPTPopupMode.SUMMARY; @observable private selectedText: string = ''; @observable private editorView?: EditorView; @@ -60,25 +57,11 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { private selectionRange: number[] | undefined; @action - setGPTPopupVis = (vis: boolean) => { - this.showGPTPopup = vis; - }; - @action setGPTMode = (mode: GPTPopupMode) => { this.GPTMode = mode; }; @action - setGPTPopupText = (txt: string) => { - this.GPTpopupText = txt; - }; - - @action - setLoading = (loading: boolean) => { - this.loadingGPT = loading; - }; - - @action setHighlightRange(r: number[] | undefined) { this.highlightRange = r; } @@ -131,19 +114,12 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { componentDidMount() { this._disposer2 = reaction( () => this._opacity, - opacity => { - if (!opacity) { - this.setGPTPopupVis(false); - this.setGPTPopupText(''); - } - }, + opacity => {}, { fireImmediately: true } ); this._disposer = reaction( () => SelectionManager.Views().slice(), selected => { - this.setGPTPopupVis(false); - this.setGPTPopupText(''); AnchorMenu.Instance.fadeOut(true); } ); @@ -154,23 +130,23 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { * @param e pointer down event */ gptSummarize = async (e: React.PointerEvent) => { + // move this logic to gptpopup, need to implement generate again + GPTPopup.Instance.setVisible(true); this.setHighlightRange(undefined); - this.setGPTPopupVis(true); - this.setGPTMode(GPTPopupMode.SUMMARY); - this.setLoading(true); + GPTPopup.Instance.setMode(GPTPopupMode.SUMMARY); + GPTPopup.Instance.setLoading(true); try { const res = await gptAPICall(this.selectedText, GPTCallType.SUMMARY); if (res) { - this.setGPTPopupText(res); + GPTPopup.Instance.setText(res); } else { - this.setGPTPopupText('Something went wrong.'); + GPTPopup.Instance.setText('Something went wrong.'); } } catch (err) { console.error(err); } - - this.setLoading(false); + GPTPopup.Instance.setLoading(false); }; /** @@ -185,9 +161,9 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { const fullText = state.doc.textBetween(0, this.editorView.state.doc.content.size, ' \n'); const selectedText = state.doc.textBetween(sel.from, sel.to); - this.setGPTPopupVis(true); + GPTPopup.Instance.setVisible(true); this.setGPTMode(GPTPopupMode.EDIT); - this.setLoading(true); + GPTPopup.Instance.setLoading(true); try { let res = await gptAPICall(selectedText, GPTCallType.EDIT); @@ -197,16 +173,20 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { const resultText = fullText.slice(0, sel.from - 1) + res + fullText.slice(sel.to - 1); if (res) { - this.setGPTPopupText(resultText); + GPTPopup.Instance.setText(resultText); this.setHighlightRange([sel.from - 1, sel.from - 1 + res.length]); } else { - this.setGPTPopupText('Something went wrong.'); + GPTPopup.Instance.setText('Something went wrong.'); } } catch (err) { console.error(err); } - this.setLoading(false); + GPTPopup.Instance.setLoading(false); + }; + + gptImage = async () => { + console.log(this.GetAnchor(undefined, false)); }; /** @@ -325,17 +305,6 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { color={StrCast(Doc.UserDoc().userColor)} /> )} - <GPTPopup - key="gptpopup" - visible={this.showGPTPopup} - text={this.GPTpopupText} - highlightRange={this.highlightRange} - loading={this.loadingGPT} - callSummaryApi={this.gptSummarize} - callEditApi={this.gptEdit} - replaceText={this.replaceText} - mode={this.GPTMode} - /> {AnchorMenu.Instance.OnAudio === unimplementedFunction ? null : ( <IconButton tooltip="Click to Record Annotation" // @@ -344,14 +313,9 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { color={StrCast(Doc.UserDoc().userColor)} /> )} - {this.canEdit() && ( - <IconButton - tooltip="AI edit suggestions" // - onPointerDown={this.gptEdit} - icon={<FontAwesomeIcon icon="pencil-alt" />} - color={StrCast(Doc.UserDoc().userColor)} - /> - )} + {/* Removed text editing for now, not quite ready */} + {/* {this.canEdit() && <IconButton tooltip={'AI edit suggestions'} onPointerDown={this.gptEdit} icon={<FontAwesomeIcon icon="pencil-alt" />} color={StrCast(Doc.UserDoc().userColor)} />} */} + {<IconButton tooltip={'Generate DALL-E Image'} onPointerDown={this.gptImage} icon={<FontAwesomeIcon icon="image" />} color={StrCast(Doc.UserDoc().userColor)} />} <Popup tooltip="Find document to link to selected text" // type={Type.PRIM} diff --git a/src/client/views/pdf/GPTPopup/GPTPopup.scss b/src/client/views/pdf/GPTPopup/GPTPopup.scss index 44413ede7..5d966395c 100644 --- a/src/client/views/pdf/GPTPopup/GPTPopup.scss +++ b/src/client/views/pdf/GPTPopup/GPTPopup.scss @@ -6,12 +6,6 @@ $button: #5b97ff; $highlightedText: #82e0ff; .summary-box { - display: flex; - flex-direction: column; - justify-content: space-between; - background-color: #ffffff; - box-shadow: 0 2px 5px #7474748d; - color: $textgrey; position: fixed; bottom: 10px; right: 10px; @@ -21,9 +15,16 @@ $highlightedText: #82e0ff; padding: 15px; padding-bottom: 0; z-index: 999; + display: flex; + flex-direction: column; + justify-content: space-between; + background-color: #ffffff; + box-shadow: 0 2px 5px #7474748d; + color: $textgrey; .summary-heading { display: flex; + justify-content: space-between; align-items: center; border-bottom: 1px solid $greyborder; padding-bottom: 5px; @@ -110,6 +111,59 @@ $highlightedText: #82e0ff; } } +.image-content-wrapper { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + padding-bottom: 16px; + + .img-wrapper { + position: relative; + cursor: pointer; + + .img-container { + pointer-events: none; + position: relative; + + img { + pointer-events: all; + position: relative; + } + } + + .img-container::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + opacity: 0; + transition: opacity 0.3s ease; + } + + .btn-container { + position: absolute; + right: 8px; + bottom: 8px; + opacity: 0; + transition: opacity 0.3s ease; + } + + &:hover { + .img-container::after { + opacity: 1; + } + + .btn-container { + opacity: 1; + } + } + } +} + // Typist CSS .Typist .Cursor { display: inline-block; diff --git a/src/client/views/pdf/GPTPopup/GPTPopup.tsx b/src/client/views/pdf/GPTPopup/GPTPopup.tsx index 8bd060d4f..943c38d42 100644 --- a/src/client/views/pdf/GPTPopup/GPTPopup.tsx +++ b/src/client/views/pdf/GPTPopup/GPTPopup.tsx @@ -1,82 +1,234 @@ import React = require('react'); +import './GPTPopup.scss'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { action, observable } from 'mobx'; import { observer } from 'mobx-react'; import ReactLoading from 'react-loading'; import Typist from 'react-typist'; import { Doc } from '../../../../fields/Doc'; -import { Docs } from '../../../documents/Documents'; -import './GPTPopup.scss'; +import { DocUtils, Docs } from '../../../documents/Documents'; +import { Button, IconButton, Type } from 'browndash-components'; +import { NumCast, StrCast } from '../../../../fields/Types'; +import { CgClose } from 'react-icons/cg'; +import { AnchorMenu } from '../AnchorMenu'; +import { gptAPICall, gptImageCall } from '../../../apis/gpt/GPT'; +import { RichTextField } from '../../../../fields/RichTextField'; +import { Networking } from '../../../Network'; +import { Utils } from '../../../../Utils'; export enum GPTPopupMode { SUMMARY, EDIT, + IMAGE, } -interface GPTPopupProps { - visible: boolean; - text: string; - loading: boolean; - mode: GPTPopupMode; - callSummaryApi: (e: React.PointerEvent) => Promise<void>; - callEditApi: (e: React.PointerEvent) => Promise<void>; - replaceText: (replacement: string) => void; - highlightRange?: number[]; -} +interface GPTPopupProps {} @observer export class GPTPopup extends React.Component<GPTPopupProps> { static Instance: GPTPopup; @observable - private done: boolean = false; + public visible: boolean = false; + @action + public setVisible = (vis: boolean) => { + this.visible = vis; + }; @observable - private sidebarId: string = ''; + public loading: boolean = false; + @action + public setLoading = (loading: boolean) => { + this.loading = loading; + }; + @observable + public text: string = ''; + @action + public setText = (text: string) => { + this.text = text; + }; + + @observable + public imgDesc: string = ''; + @action + public setImgDesc = (text: string) => { + this.imgDesc = text; + }; + + @observable + public imgUrls: string[][] = []; + @action + public setImgUrls = (imgs: string[][]) => { + this.imgUrls = imgs; + }; + @observable + public mode: GPTPopupMode = GPTPopupMode.SUMMARY; + @action + public setMode = (mode: GPTPopupMode) => { + this.mode = mode; + }; + + @observable + public highlightRange: number[] = []; + @action callSummaryApi = () => {}; + @action callEditApi = () => {}; + @action replaceText = (replacement: string) => {}; + + @observable + private done: boolean = false; @action public setDone = (done: boolean) => { this.done = done; }; + + // change what can be a ref into a ref + @observable + private sidebarId: string = ''; @action public setSidebarId = (id: string) => { this.sidebarId = id; }; + // pdfs and webpages + @observable + private targetAnchor: Doc | undefined; + @action + public setTargetAnchor = (anchor: Doc) => { + this.targetAnchor = anchor; + }; + + @observable + private imgTargetDoc: Doc | undefined; + @action + public setImgTargetDoc = (anchor: Doc) => { + this.imgTargetDoc = anchor; + }; + + @observable + private textAnchor: Doc | undefined; + @action + public setTextAnchor = (anchor: Doc) => { + this.textAnchor = anchor; + }; + public addDoc: (doc: Doc | Doc[], sidebarKey?: string | undefined) => boolean = () => false; + public addToCollection: ((doc: Doc | Doc[], annotationKey?: string | undefined) => boolean) | undefined; + + generateImage = async () => { + if (this.imgDesc === '') return; + this.setImgUrls([]); + this.setMode(GPTPopupMode.IMAGE); + this.setVisible(true); + this.setLoading(true); + + try { + // make this support multiple images + let image_urls = await gptImageCall(this.imgDesc); + console.log(image_urls); + if (image_urls && image_urls[0]) { + const [result] = await Networking.PostToServer('/uploadRemoteImage', { sources: [image_urls[0]] }); + const source = Utils.prepend(result.accessPaths.agnostic.client); + console.log('Source', source); + this.setImgUrls([[image_urls[0], source]]); + } + } catch (err) { + console.log(err); + return ''; + } + GPTPopup.Instance.setLoading(false); + }; /** * Transfers the summarization text to a sidebar annotation text document. */ private transferToText = () => { - const newDoc = Docs.Create.TextDocument(this.props.text.trim(), { + const newDoc = Docs.Create.TextDocument(this.text.trim(), { _width: 200, _height: 50, _layout_fitWidth: true, _layout_autoHeight: true, }); this.addDoc(newDoc, this.sidebarId); + const anchor = AnchorMenu.Instance?.GetAnchor(undefined, false); + if (anchor) { + DocUtils.MakeLink(newDoc, anchor, { + link_relationship: 'GPT Summary', + }); + } + }; + + /** + * Transfers the image urls to actual image docs + */ + private transferToImage = (source: string) => { + const textAnchor = this.imgTargetDoc; + if (!textAnchor) return; + const newDoc = Docs.Create.ImageDocument(source, { + x: NumCast(textAnchor.x) + NumCast(textAnchor._width) + 10, + y: NumCast(textAnchor.y), + _height: 200, + _width: 200, + data_nativeWidth: 1024, + data_nativeHeight: 1024, + }); + if (Doc.IsInMyOverlay(textAnchor)) { + newDoc.overlayX = textAnchor.x; + newDoc.overlayY = NumCast(textAnchor.y) + NumCast(textAnchor._height); + Doc.AddToMyOverlay(newDoc); + } else { + this.addToCollection?.(newDoc); + } + // Create link between prompt and image + DocUtils.MakeLink(textAnchor, newDoc, { link_relationship: 'Image Prompt' }); }; + private getPreviewUrl = (source: string) => source.split('.').join('_m.'); + constructor(props: GPTPopupProps) { super(props); GPTPopup.Instance = this; } componentDidUpdate = () => { - if (this.props.loading) { + if (this.loading) { this.setDone(false); } }; + imageBox = () => { + return ( + <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}> + {this.heading('GENERATED IMAGE')} + <div className="image-content-wrapper"> + {this.imgUrls.map(rawSrc => ( + <div className="img-wrapper"> + <div className="img-container"> + <img key={rawSrc[0]} src={rawSrc[0]} width={150} height={150} alt="dalle generation" /> + </div> + <div className="btn-container"> + <Button text="Save Image" onClick={() => this.transferToImage(rawSrc[1])} color={StrCast(Doc.UserDoc().userColor)} type={Type.TERT} /> + </div> + </div> + ))} + </div> + {!this.loading && ( + <> + <IconButton tooltip="Generate Again" onClick={this.generateImage} icon={<FontAwesomeIcon icon="redo-alt" size="lg" />} color={StrCast(Doc.UserDoc().userVariantColor)} /> + </> + )} + </div> + ); + }; + summaryBox = () => ( <> <div> {this.heading('SUMMARY')} <div className="content-wrapper"> - {!this.props.loading && + {!this.loading && (!this.done ? ( <Typist - key={this.props.text} + key={this.text} avgTypingDelay={15} cursor={{ hideWhenDone: true }} onTypingDone={() => { @@ -84,39 +236,32 @@ export class GPTPopup extends React.Component<GPTPopupProps> { this.setDone(true); }, 500); }}> - {this.props.text} + {this.text} </Typist> ) : ( - this.props.text + this.text ))} </div> </div> - {!this.props.loading && ( + {!this.loading && ( <div className="btns-wrapper"> {this.done ? ( <> - <button className="icon-btn" onPointerDown={e => this.props.callSummaryApi(e)}> - <FontAwesomeIcon icon="redo-alt" size="lg" /> - </button> - <button - className="text-btn" - onClick={e => { - this.transferToText(); - }}> - Transfer to Text - </button> + <IconButton tooltip="Generate Again" onClick={this.callSummaryApi} icon={<FontAwesomeIcon icon="redo-alt" size="lg" />} color={StrCast(Doc.UserDoc().userVariantColor)} /> + <Button tooltip="Transfer to text" text="Transfer To Text" onClick={this.transferToText} color={StrCast(Doc.UserDoc().userVariantColor)} type={Type.TERT} /> </> ) : ( <div className="summarizing"> <span>Summarizing</span> <ReactLoading type="bubbles" color="#bcbcbc" width={20} height={20} /> - <button - className="btn-secondary" - onClick={e => { + <Button + text="Stop Animation" + onClick={() => { this.setDone(true); - }}> - Stop Animation - </button> + }} + color={StrCast(Doc.UserDoc().userVariantColor)} + type={Type.TERT} + /> </div> )} </div> @@ -124,43 +269,6 @@ export class GPTPopup extends React.Component<GPTPopupProps> { </> ); - editBox = () => { - const hr = this.props.highlightRange; - return ( - <> - <div> - {this.heading('TEXT EDIT SUGGESTIONS')} - <div className="content-wrapper"> - {hr && ( - <div> - {this.props.text.slice(0, hr[0])} <span className="highlighted-text">{this.props.text.slice(hr[0], hr[1])}</span> {this.props.text.slice(hr[1])} - </div> - )} - </div> - </div> - {hr && !this.props.loading && ( - <> - <div className="btns-wrapper"> - <> - <button className="icon-btn" onPointerDown={e => this.props.callEditApi(e)}> - <FontAwesomeIcon icon="redo-alt" size="lg" /> - </button> - <button - className="text-btn" - onClick={e => { - this.props.replaceText(this.props.text); - }}> - Replace Text - </button> - </> - </div> - {this.aiWarning()} - </> - )} - </> - ); - }; - aiWarning = () => this.done ? ( <div className="ai-warning"> @@ -174,14 +282,14 @@ export class GPTPopup extends React.Component<GPTPopupProps> { heading = (headingText: string) => ( <div className="summary-heading"> <label className="summary-text">{headingText}</label> - {this.props.loading && <ReactLoading type="spin" color="#bcbcbc" width={14} height={14} />} + {this.loading ? <ReactLoading type="spin" color="#bcbcbc" width={14} height={14} /> : <IconButton color={StrCast(Doc.UserDoc().userVariantColor)} tooltip="close" icon={<CgClose size="16px" />} onClick={() => this.setVisible(false)} />} </div> ); render() { return ( - <div className="summary-box" style={{ display: this.props.visible ? 'flex' : 'none' }}> - {this.props.mode === GPTPopupMode.SUMMARY ? this.summaryBox() : this.editBox()} + <div className="summary-box" style={{ display: this.visible ? 'flex' : 'none' }}> + {this.mode === GPTPopupMode.SUMMARY ? this.summaryBox() : this.mode === GPTPopupMode.IMAGE ? this.imageBox() : <></>} </div> ); } |