diff options
27 files changed, 1055 insertions, 201 deletions
diff --git a/src/client/documents/DocUtils.ts b/src/client/documents/DocUtils.ts index 7b36c3a66..df14dce5a 100644 --- a/src/client/documents/DocUtils.ts +++ b/src/client/documents/DocUtils.ts @@ -308,6 +308,7 @@ export namespace DocUtils { */ export async function DocumentFromType(type: string, path: string, options: DocumentOptions, overwriteDoc?: Doc): Promise<Opt<Doc>> { let ctor: ((path: string, options: DocumentOptions, overwriteDoc?: Doc) => Doc | Promise<Doc | undefined>) | undefined; + if (type.indexOf('image') !== -1) { ctor = Docs.Create.ImageDocument; if (!options._width) options._width = 300; @@ -711,33 +712,31 @@ export namespace DocUtils { nativeWidth: 40, nativeHeight: 40, }) - : (defaultTextTemplate?.type === DocumentType.JOURNAL ? Docs.Create.DailyJournalDocument:Docs.Create.TextDocument)( - '', - { - annotationOn, - backgroundColor, - x, - y, - title, - ...(defaultTextTemplate - ? {} // if the new doc will inherit from a template, don't set any layout fields since that would block the inheritance - : { - _width: width || BoolCast(Doc.UserDoc().fitBox) ? Number(StrCast(Doc.UserDoc().fontSize).replace('px', '')) * 1.5 * 6 : 200, - _height: BoolCast(Doc.UserDoc().fitBox) ? Number(StrCast(Doc.UserDoc().fontSize).replace('px', '')) * 1.5 : 35, - _layout_autoHeight: true, - backgroundColor: StrCast(Doc.UserDoc().textBackgroundColor), - borderColor: Doc.UserDoc().borderColor as string, - borderWidth: Doc.UserDoc().borderWidth as number, - text_centered: BoolCast(Doc.UserDoc().textCentered), - text_fitBox: BoolCast(Doc.UserDoc().fitBox), - text_align: StrCast(Doc.UserDoc().textAlign), - text_fontColor: StrCast(Doc.UserDoc().fontColor), - text_fontFamily: StrCast(Doc.UserDoc().fontFamily), - text_fontWeight: StrCast(Doc.UserDoc().fontWeight), - text_fontStyle: StrCast(Doc.UserDoc().fontStyle), - text_fontDecoration: StrCast(Doc.UserDoc().fontDecoration), - }), - }); + : (defaultTextTemplate?.type === DocumentType.JOURNAL ? Docs.Create.DailyJournalDocument : Docs.Create.TextDocument)('', { + annotationOn, + backgroundColor, + x, + y, + title, + ...(defaultTextTemplate + ? {} // if the new doc will inherit from a template, don't set any layout fields since that would block the inheritance + : { + _width: width || BoolCast(Doc.UserDoc().fitBox) ? Number(StrCast(Doc.UserDoc().fontSize).replace('px', '')) * 1.5 * 6 : 200, + _height: BoolCast(Doc.UserDoc().fitBox) ? Number(StrCast(Doc.UserDoc().fontSize).replace('px', '')) * 1.5 : 35, + _layout_autoHeight: true, + backgroundColor: StrCast(Doc.UserDoc().textBackgroundColor), + borderColor: Doc.UserDoc().borderColor as string, + borderWidth: Doc.UserDoc().borderWidth as number, + text_centered: BoolCast(Doc.UserDoc().textCentered), + text_fitBox: BoolCast(Doc.UserDoc().fitBox), + text_align: StrCast(Doc.UserDoc().textAlign), + text_fontColor: StrCast(Doc.UserDoc().fontColor), + text_fontFamily: StrCast(Doc.UserDoc().fontFamily), + text_fontWeight: StrCast(Doc.UserDoc().fontWeight), + text_fontStyle: StrCast(Doc.UserDoc().fontStyle), + text_fontDecoration: StrCast(Doc.UserDoc().fontDecoration), + }), + }); if (defaultTextTemplate) { tbox.layout_fieldKey = 'layout_' + StrCast(defaultTextTemplate.title); diff --git a/src/client/documents/DocumentTypes.ts b/src/client/documents/DocumentTypes.ts index 5c6559836..cef44e999 100644 --- a/src/client/documents/DocumentTypes.ts +++ b/src/client/documents/DocumentTypes.ts @@ -44,6 +44,7 @@ export enum DocumentType { SCRIPTDB = 'scriptdb', // database of scripts GROUPDB = 'groupdb', // database of groups + SCRAPBOOK = 'scrapbook', JOURNAL = 'journal', // AARAV ADD } export enum CollectionViewType { diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index bf9cc5bd4..2a2f9d342 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -525,6 +525,19 @@ export class DocumentOptions { ai?: string; // to mark items as ai generated ai_firefly_seed?: number; ai_firefly_prompt?: string; + + /** + * JSON‐stringified slot configuration for ScrapbookBox + */ + scrapbookConfig?: string; + + /** + * The list of embedded Doc instances in each Scrapbook slot + */ + scrapbookContents?: List<Doc>; + + _outpaintingMetadata?: STRt = new StrInfo('serialized JSON metadata needed for image outpainting', false); + } export const DocOptions = new DocumentOptions(); @@ -587,6 +600,18 @@ export namespace Docs { }, ], // AARAV ADD // + + [ + DocumentType.SCRAPBOOK, + { + layout: { view: EmptyBox, dataField: 'text' }, + options: { + title: 'Scrapbook', + acl_Guest: SharingPermissions.View, + }, + + }, + ], ]); const suffix = 'Proto'; @@ -919,6 +944,26 @@ export namespace Docs { return InstanceFromProto(Prototypes.get(DocumentType.RTF), field, options, undefined, fieldKey); } + export function ScrapbookDocument( + items: Doc[] = [], + options: DocumentOptions = {}, + fieldKey: string = 'items' + ) { + return InstanceFromProto( + Prototypes.get(DocumentType.SCRAPBOOK), + new List<Doc>(items), + { + title: options.title + ?? new Date().toLocaleDateString(undefined, { + year: 'numeric', month: 'short', day: 'numeric' + }), + ...options, + }, + undefined, + fieldKey + ); + } + // AARAV ADD // export function DailyJournalDocument(text: string | RichTextField, options: DocumentOptions = {}, fieldKey: string = 'text') { @@ -961,6 +1006,8 @@ export namespace Docs { // AARAV ADD // + + export function LinkDocument(source: Doc, target: Doc, options: DocumentOptions = {}, id?: string) { const linkDoc = InstanceFromProto( Prototypes.get(DocumentType.LINK), diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts index 99a67ebb2..8f4d568ab 100644 --- a/src/client/util/CurrentUserUtils.ts +++ b/src/client/util/CurrentUserUtils.ts @@ -407,6 +407,8 @@ pie title Minerals in my tap water {key: "DataViz", creator: opts => Docs.Create.DataVizDocument("", opts), opts: { _width: 300, _height: 300, }}, // AARAV ADD // {key: "DailyJournal",creator:opts => Docs.Create.DailyJournalDocument("", opts),opts: { _width: 300, _height: 300, }}, + {key: "Scrapbook",creator:opts => Docs.Create.ScrapbookDocument([], opts),opts:{ _width: 300, _height: 300}}, + //{key: "Scrapbook",creator:opts => Docs.Create.ScrapbookDocument([], opts),opts:{ _width: 300, _height: 300}}, {key: "Chat", creator: Docs.Create.ChatDocument, opts: { _width: 500, _height: 500, _layout_fitWidth: true, }}, {key: "MetaNote", creator: metaNoteTemplate, opts: { _width: 300, _height: 120, _header_pointerEvents: "all", _header_height: 50, _header_fontSize: 9,_layout_autoHeightMargins: 50, _layout_autoHeight: true, treeView_HideUnrendered: true}}, {key: "ViewSlide", creator: slideView, opts: { _width: 400, _height: 300, _xMargin: 3, _yMargin: 3,}}, @@ -449,6 +451,7 @@ pie title Minerals in my tap water { toolTip: "Tap or drag to create a button", title: "Button", icon: "circle", dragFactory: doc.emptyButton as Doc, clickFactory: DocCast(doc.emptyButton)}, { toolTip: "Tap or drag to create a scripting box", title: "Script", icon: "terminal", dragFactory: doc.emptyScript as Doc, clickFactory: DocCast(doc.emptyScript), funcs: { hidden: "IsNoviceMode()"}}, { toolTip: "Tap or drag to create a data viz node", title: "DataViz", icon: "chart-bar", dragFactory: doc.emptyDataViz as Doc, clickFactory: DocCast(doc.emptyDataViz)}, + { toolTip: "Tap or drag to create a scrapbook template", title: "Scrapbook", icon: "palette", dragFactory: doc.emptyScrapbook as Doc,clickFactory:DocCast(doc.emptyScrapbook), }, { toolTip: "Tap or drag to create a journal entry", title: "Journal", icon: "book", dragFactory:doc.emptyDailyJournal as Doc,clickFactory: DocCast(doc.emptyDataJournal), }, { toolTip: "Tap or drag to create a bullet slide", title: "PPT Slide", icon:"person-chalkboard",dragFactory: doc.emptySlide as Doc, clickFactory: DocCast(doc.emptySlide), openFactoryLocation: OpenWhere.overlay, funcs: { hidden: "IsNoviceMode()"}}, { toolTip: "Tap or drag to create a view slide", title: "View Slide", icon: "address-card", dragFactory: doc.emptyViewSlide as Doc, clickFactory: DocCast(doc.emptyViewSlide), openFactoryLocation: OpenWhere.overlay, funcs: { hidden: "IsNoviceMode()"}}, diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index 4b7fdc560..3f11a4713 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -473,6 +473,8 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora const tl = docView.screenToContentsTransform().inverse().transformPoint(0, 0); return project([e.clientX + this._offset.x, e.clientY + this._offset.y], tl, [tl[0] + fixedAspect, tl[1] + 1]); }; + + // Modify the onPointerMove method to handle shift+click during resize onPointerMove = (e: PointerEvent): boolean => { const first = DocumentView.Selected()[0]; const effectiveAcl = GetEffectiveAcl(first.Document); @@ -488,20 +490,136 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora const { scale, refPt } = this.getResizeVals(thisPt, dragHdl); - !this._interactionLock && runInAction(async () => { // resize selected docs if we're not in the middle of a resize (ie, throttle input events to frame rate) - this._interactionLock = true; - this._snapPt = thisPt; - e.ctrlKey && (DocumentView.Selected().forEach(docView => !Doc.NativeHeight(docView.Document) && docView.toggleNativeDimensions())); - const hasFixedAspect = DocumentView.Selected().map(dv => dv.Document).some(this.hasFixedAspect); - const scaleAspect = {x:scale.x === 1 && hasFixedAspect ? scale.y : scale.x, y: scale.x !== 1 && hasFixedAspect ? scale.x : scale.y}; - DocumentView.Selected().forEach(docView => - this.resizeView(docView, refPt, scaleAspect, { dragHdl, ctrlKey:e.ctrlKey })); // prettier-ignore - await new Promise<void>(res => { setTimeout(() => { res(this._interactionLock = undefined)})}); - }); // prettier-ignore + !this._interactionLock && + runInAction(async () => { + // resize selected docs if we're not in the middle of a resize (ie, throttle input events to frame rate) + this._interactionLock = true; + this._snapPt = thisPt; + + // Special handling for shift+click (outpainting mode) + if (e.shiftKey && DocumentView.Selected().some(dv => dv.ComponentView instanceof ImageBox)) { + DocumentView.Selected().forEach(docView => { + if (docView.ComponentView instanceof ImageBox) { + // Set flag for outpainting mode + docView.Document._outpaintingResize = true; + + // Adjust only the document dimensions without scaling internal content + this.resizeViewForOutpainting(docView, refPt, scale, { dragHdl, shiftKey: e.shiftKey }); + } else { + // Handle regular resize for non-image components + e.ctrlKey && !Doc.NativeHeight(docView.Document) && docView.toggleNativeDimensions(); + const hasFixedAspect = this.hasFixedAspect(docView.Document); + const scaleAspect = { x: scale.x === 1 && hasFixedAspect ? scale.y : scale.x, y: scale.x !== 1 && hasFixedAspect ? scale.x : scale.y }; + this.resizeView(docView, refPt, scaleAspect, { dragHdl, ctrlKey: e.ctrlKey }); + } + }); + } else { + // Regular resize behavior (existing code) + e.ctrlKey && DocumentView.Selected().forEach(docView => !Doc.NativeHeight(docView.Document) && docView.toggleNativeDimensions()); + const hasFixedAspect = DocumentView.Selected() + .map(dv => dv.Document) + .some(this.hasFixedAspect); + const scaleAspect = { x: scale.x === 1 && hasFixedAspect ? scale.y : scale.x, y: scale.x !== 1 && hasFixedAspect ? scale.x : scale.y }; + DocumentView.Selected().forEach(docView => this.resizeView(docView, refPt, scaleAspect, { dragHdl, ctrlKey: e.ctrlKey })); + } + + await new Promise<void>(res => { + setTimeout(() => { + res((this._interactionLock = undefined)); + }); + }); + }); return false; }; + resizeViewForOutpainting = (docView: DocumentView, refPt: number[], scale: { x: number; y: number }, opts: { dragHdl: string; shiftKey: boolean }) => { + const doc = docView.Document; + + if (doc.isGroup) { + DocListCast(doc.data) + .map(member => DocumentView.getDocumentView(member, docView)!) + .forEach(member => this.resizeViewForOutpainting(member, refPt, scale, opts)); + doc.xPadding = NumCast(doc.xPadding) * scale.x; + doc.yPadding = NumCast(doc.yPadding) * scale.y; + return; + } + + if (!doc._outpaintingOriginalWidth || !doc._outpaintingOriginalHeight) { + doc._outpaintingOriginalWidth = NumCast(doc._width); + doc._outpaintingOriginalHeight = NumCast(doc._height); + } + + // Calculate new boundary dimensions + const originalWidth = NumCast(doc._width); + const originalHeight = NumCast(doc._height); + const newWidth = Math.max(NumCast(doc._width_min, 25), originalWidth * scale.x); + const newHeight = Math.max(NumCast(doc._height_min, 10), originalHeight * scale.y); + + // Apply new dimensions + doc._width = newWidth; + doc._height = newHeight; + + const refCent = docView.screenToViewTransform().transformPoint(refPt[0], refPt[1]); + const { deltaX, deltaY } = this.realignRefPt(doc, refCent, originalWidth, originalHeight); + doc.x = NumCast(doc.x) + deltaX; + doc.y = NumCast(doc.y) + deltaY; + + doc._layout_modificationDate = new DateField(); + + // Trigger outpainting + doc._needsOutpainting = true; + + // Store metadata needed for outpainting + doc._outpaintingMetadata = JSON.stringify({ + originalWidth: doc._outpaintingOriginalWidth, + originalHeight: doc._outpaintingOriginalHeight, + newWidth, + newHeight, + scaleX: scale.x, + scaleY: scale.y, + anchorHandle: opts.dragHdl, + }); + }; + + @action + onPointerUp = (): void => { + SnappingManager.SetIsResizing(undefined); + SnappingManager.clearSnapLines(); + + // Check if any outpainting needs to be processed + DocumentView.Selected().forEach(view => { + if (view.Document._needsOutpainting && view.ComponentView instanceof ImageBox) { + // Trigger outpainting process in the ImageBox component + (view.ComponentView as ImageBox).processOutpainting(); + + // Clear flags + view.Document._needsOutpainting = false; + view.Document._outpaintingResize = false; + } + }); + + this._resizeHdlId = ''; + this._resizeUndo?.end(); + + // detect layout_autoHeight gesture and apply + DocumentView.Selected().forEach(view => { + NumCast(view.Document._height) < 20 && (view.layoutDoc._layout_autoHeight = true); + }); + // need to change points for resize, or else rotation/control points will fail. + this._inkDragDocs + .map(oldbds => ({ oldbds, inkPts: Cast(oldbds.doc.data, InkField)?.inkData || [] })) + .forEach(({ oldbds: { doc, x, y, width, height }, inkPts }) => { + doc.$data = new InkField(inkPts.map( + (ipt) => ({// (new x — oldx) + newWidth * (oldxpoint /oldWidth) + X: NumCast(doc.x) - x + (NumCast(doc._width) * ipt.X) / width, + Y: NumCast(doc.y) - y + (NumCast(doc._height) * ipt.Y) / height, + }))); // prettier-ignore + Doc.SetNativeWidth(doc, undefined); + Doc.SetNativeHeight(doc, undefined); + }); + }; + // // determines how much to resize, and determines the resize reference point // @@ -606,31 +724,6 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora }; }; - @action - onPointerUp = (): void => { - SnappingManager.SetIsResizing(undefined); - SnappingManager.clearSnapLines(); - this._resizeHdlId = ''; - this._resizeUndo?.end(); - - // detect layout_autoHeight gesture and apply - DocumentView.Selected().forEach(view => { - NumCast(view.Document._height) < 20 && (view.layoutDoc._layout_autoHeight = true); - }); - // need to change points for resize, or else rotation/control points will fail. - this._inkDragDocs - .map(oldbds => ({ oldbds, inkPts: Cast(oldbds.doc.data, InkField)?.inkData || [] })) - .forEach(({ oldbds: { doc, x, y, width, height }, inkPts }) => { - doc.$data = new InkField(inkPts.map( - (ipt) => ({// (new x — oldx) + newWidth * (oldxpoint /oldWidth) - X: NumCast(doc.x) - x + (NumCast(doc._width) * ipt.X) / width, - Y: NumCast(doc.y) - y + (NumCast(doc._height) * ipt.Y) / height, - }))); // prettier-ignore - Doc.SetNativeWidth(doc, undefined); - Doc.SetNativeHeight(doc, undefined); - }); - }; - @computed get selectionTitle(): string { if (DocumentView.Selected().length === 1) { const selected = DocumentView.Selected()[0]; diff --git a/src/client/views/InkingStroke.tsx b/src/client/views/InkingStroke.tsx index f555808ef..0a49ea28e 100644 --- a/src/client/views/InkingStroke.tsx +++ b/src/client/views/InkingStroke.tsx @@ -245,7 +245,7 @@ export class InkingStroke extends ViewBoxAnnotatableComponent<FieldViewProps>() * factor for converting between ink and screen space. */ inkScaledData = () => { - const inkData = Cast(this.dataDoc[this.fieldKey], InkField, Cast(this.layoutDoc[this.fieldKey], InkField, null))?.inkData ?? []; + const inkData = Cast(this.dataDoc[this.fieldKey], InkField, Cast(this.layoutDoc[this.fieldKey], InkField, null) ?? null)?.inkData ?? []; const inkStrokeWidth = NumCast(this.layoutDoc.stroke_width, 1); const inkTop = Math.min(...inkData.map(p => p.Y)) - inkStrokeWidth / 2; const inkBottom = Math.max(...inkData.map(p => p.Y)) + inkStrokeWidth / 2; diff --git a/src/client/views/Main.tsx b/src/client/views/Main.tsx index e4bbb1c0f..b884eb8c8 100644 --- a/src/client/views/Main.tsx +++ b/src/client/views/Main.tsx @@ -65,6 +65,7 @@ import { PresBox, PresSlideBox } from './nodes/trails'; import { FaceRecognitionHandler } from './search/FaceRecognitionHandler'; import { SearchBox } from './search/SearchBox'; import { StickerPalette } from './smartdraw/StickerPalette'; +import { ScrapbookBox } from './nodes/scrapbook/ScrapbookBox'; dotenv.config(); @@ -134,6 +135,7 @@ FieldLoader.ServerLoadStatus = { requested: 0, retrieved: 0, message: 'cache' }; VideoBox, AudioBox, RecordingBox, + ScrapbookBox, PresBox, PresSlideBox, SearchBox, diff --git a/src/client/views/collections/CollectionStackingView.tsx b/src/client/views/collections/CollectionStackingView.tsx index 9155227dd..f11e646cc 100644 --- a/src/client/views/collections/CollectionStackingView.tsx +++ b/src/client/views/collections/CollectionStackingView.tsx @@ -343,7 +343,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection childFitWidth = (doc: Doc) => Cast(this.Document.childLayoutFitWidth, 'boolean', this._props.childLayoutFitWidth?.(doc) ?? Cast(doc.layout_fitWidth, 'boolean', null) ?? null); // this is what renders the document that you see on the screen // called in Children: this actually adds a document to our children list - getDisplayDoc(doc: Doc, trans: () => string, count: number) { + getDisplayDoc = (doc: Doc, trans: () => string, count: number) => { const dataDoc = doc.isTemplateDoc || doc.isTemplateForField ? this._props.TemplateDataDocument : undefined; this._docXfs.push({ stackedDocTransform: this.getDocTransform(doc), width: this.getDocWidth(doc), height: this.getDocHeight(doc) }); return count > this._renderCount ? null : ( @@ -384,6 +384,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection searchFilterDocs={this.searchFilterDocs} xPadding={NumCast(this.layoutDoc._childXPadding, this._props.childXPadding)} yPadding={NumCast(this.layoutDoc._childYPadding, this._props.childYPadding)} + rejectDrop={this._props.childRejectDrop} addDocument={this._props.addDocument} moveDocument={this._props.moveDocument} removeDocument={this._props.removeDocument} @@ -393,7 +394,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection pinToPres={this._props.pinToPres} /> ); - } + }; getDocTransform = computedFn((doc: Doc) => () => { // these must be referenced for document decorations to update when the text box container is scrolled diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx index bc7d6f897..9a0fda3f8 100644 --- a/src/client/views/collections/CollectionSubView.tsx +++ b/src/client/views/collections/CollectionSubView.tsx @@ -58,6 +58,7 @@ export interface CollectionViewProps extends React.PropsWithChildren<FieldViewPr childOpacity?: () => number; childContextMenuItems?: () => { script: ScriptField; label: string }[]; childLayoutTemplate?: () => Doc | undefined; // specify a layout Doc template to use for children of the collection + childRejectDrop?: (draggedDoc: Doc[] | undefined, subView?: DocumentView) => boolean; // whether a child document can be dropped on this document childHideDecorationTitle?: boolean; childHideResizeHandles?: boolean; childHideDecorations?: boolean; @@ -323,7 +324,7 @@ export function CollectionSubView<X>() { protected onInternalDrop(e: Event, de: DragManager.DropEvent): boolean { const { docDragData } = de.complete; - if (docDragData && !docDragData.draggedDocuments.includes(this.Document)) { + if (docDragData && !docDragData.draggedDocuments.includes(this.Document) && !this._props.rejectDrop?.(docDragData.draggedDocuments, this.DocumentView?.())) { let added; const dropAction = docDragData.dropAction || docDragData.userDropAction; const targetDocments = DocListCast(this.dataDoc[this._props.fieldKey]); diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx index c9af92a1b..eb9caf29d 100644 --- a/src/client/views/collections/CollectionView.tsx +++ b/src/client/views/collections/CollectionView.tsx @@ -183,15 +183,17 @@ export class CollectionView extends ViewBoxAnnotatableComponent<CollectionViewPr }, }) ); - DocListCast(Cast(Doc.UserDoc()['clickFuncs-child'], Doc, null)?.data).forEach(childClick => - onClicks.push({ - description: `Set child ${childClick.title}`, - icon: 'edit', - event: () => { - this.dataDoc[StrCast(childClick.targetScriptKey)] = ObjectField.MakeCopy(ScriptCast(childClick.data)); - }, - }) - ); + DocListCast(Cast(Doc.UserDoc()['clickFuncs-child'], Doc, null)?.data) + .filter(childClick => ScriptCast(childClick.data)) + .forEach(childClick => + onClicks.push({ + description: `Set child ${childClick.title}`, + icon: 'edit', + event: () => { + this.dataDoc[StrCast(childClick.targetScriptKey)] = ObjectField.MakeCopy(ScriptCast(childClick.data)!); + }, + }) + ); !Doc.IsSystem(this.Document) && !existingOnClick && cm.addItem({ description: 'OnClick...', noexpand: true, subitems: onClicks, icon: 'mouse-pointer' }); } diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index 842293358..08126d4fe 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -496,6 +496,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection }, 'link drop'); onInternalDrop = (e: Event, de: DragManager.DropEvent): boolean => { + if (this._props.rejectDrop?.(de.complete.docDragData?.draggedDocuments, this._props.DocumentView?.())) return false; if (de.complete.annoDragData?.dragDocument && super.onInternalDrop(e, de)) return this.internalAnchorAnnoDrop(e, de, de.complete.annoDragData); if (de.complete.linkDragData) return this.internalLinkDrop(e, de, de.complete.linkDragData); if (de.complete.docDragData?.droppedDocuments.length) return this.internalDocDrop(e, de, de.complete.docDragData); @@ -1562,6 +1563,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection pinToPres={this._props.pinToPres} whenChildContentsActiveChanged={this._props.whenChildContentsActiveChanged} dragAction={(this.Document.childDragAction ?? this._props.childDragAction) as dropActionType} + rejectDrop={this._props.childRejectDrop} showTitle={this._props.childlayout_showTitle} dontRegisterView={this._props.dontRegisterView} pointerEvents={this.childPointerEventsFunc} diff --git a/src/client/views/collections/collectionGrid/CollectionGridView.tsx b/src/client/views/collections/collectionGrid/CollectionGridView.tsx index a7603a45b..1d5e70be7 100644 --- a/src/client/views/collections/collectionGrid/CollectionGridView.tsx +++ b/src/client/views/collections/collectionGrid/CollectionGridView.tsx @@ -201,6 +201,7 @@ export class CollectionGridView extends CollectionSubView() { isContentActive={this.isChildContentActive} PanelWidth={width} PanelHeight={height} + rejectDrop={this._props.childRejectDrop} ScreenToLocalTransform={dxf} setContentViewBox={emptyFunction} whenChildContentsActiveChanged={this._props.whenChildContentsActiveChanged} diff --git a/src/client/views/collections/collectionLinear/CollectionLinearView.tsx b/src/client/views/collections/collectionLinear/CollectionLinearView.tsx index 8d3947653..3c2a99b1e 100644 --- a/src/client/views/collections/collectionLinear/CollectionLinearView.tsx +++ b/src/client/views/collections/collectionLinear/CollectionLinearView.tsx @@ -192,6 +192,7 @@ export class CollectionLinearView extends CollectionSubView() { renderDepth={this._props.renderDepth + 1} dontRegisterView={BoolCast(this.Document.childDontRegisterViews)} focus={emptyFunction} + rejectDrop={this._props.childRejectDrop} styleProvider={this._props.styleProvider} containerViewPath={this.childContainerViewPath} whenChildContentsActiveChanged={emptyFunction} diff --git a/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx b/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx index adaceb053..caad1c7e0 100644 --- a/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx +++ b/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx @@ -281,6 +281,7 @@ export class CollectionMulticolumnView extends CollectionSubView() { PanelWidth={this.childWidth(childLayout)} PanelHeight={this.childHeight} rootSelected={this.rootSelected} + rejectDrop={this._props.childRejectDrop} dragAction={StrCast(this.Document.childDragAction, this._props.childDragAction) as dropActionType} onClickScript={this.onChildClickHandler} onDoubleClickScript={this.onChildDoubleClickHandler} diff --git a/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx b/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx index 27c41583c..593598479 100644 --- a/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx +++ b/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx @@ -263,6 +263,7 @@ export class CollectionMultirowView extends CollectionSubView() { PanelWidth={this.childWidth} PanelHeight={this.childHeight(childLayout)} rootSelected={this.rootSelected} + rejectDrop={this._props.childRejectDrop} dragAction={StrCast(this.Document.childDragAction, this._props.childDragAction) as dropActionType} onClickScript={this.onChildClickHandler} onDoubleClickScript={this.onChildDoubleClickHandler} diff --git a/src/client/views/nodes/FieldView.tsx b/src/client/views/nodes/FieldView.tsx index de49f502f..b4473d9dc 100644 --- a/src/client/views/nodes/FieldView.tsx +++ b/src/client/views/nodes/FieldView.tsx @@ -68,7 +68,7 @@ export interface FieldViewSharedProps { isGroupActive?: () => string | undefined; // is this document part of a group that is active // eslint-disable-next-line no-use-before-define setContentViewBox?: (view: ViewBoxInterface<FieldViewProps>) => void; // called by rendered field's viewBox so that DocumentView can make direct calls to the viewBox - + rejectDrop?: (draggedDoc: Doc[] | undefined, subView?: DocumentView) => boolean; // whether a document drop is rejected PanelWidth: () => number; PanelHeight: () => number; isDocumentActive?: () => boolean | undefined; // whether a document should handle pointer events diff --git a/src/client/views/nodes/ImageBox.scss b/src/client/views/nodes/ImageBox.scss index 4a6e8eb49..9fc20ffd4 100644 --- a/src/client/views/nodes/ImageBox.scss +++ b/src/client/views/nodes/ImageBox.scss @@ -241,3 +241,35 @@ color: black; } } +.imageBox-regenerate-dialog { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: white; + padding: 20px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + z-index: 10000; + + h3 { margin-top: 0; } + + input { + width: 300px; + padding: 8px; + margin-bottom: 10px; + } + + .buttons { + display: flex; + justify-content: flex-end; + gap: 10px; + + .generate-btn { + background: #0078d4; + color: white; + border: none; + padding: 8px 16px; + } + } + }
\ No newline at end of file diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx index 0c475b7bb..c59c38fd8 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -1,23 +1,24 @@ -import { Button, Colors, Size, Type } from '@dash/components'; +import { Button, Colors, EditableText, Size, Type } from '@dash/components'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Slider, Tooltip } from '@mui/material'; import axios from 'axios'; -import { action, computed, IReactionDisposer, makeObservable, observable, ObservableMap, reaction } from 'mobx'; +import { action, computed, IReactionDisposer, makeObservable, observable, ObservableMap, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import { extname } from 'path'; import * as React from 'react'; import { AiOutlineSend } from 'react-icons/ai'; import ReactLoading from 'react-loading'; -import { ClientUtils, DashColor, returnEmptyString, returnFalse, returnOne, returnZero, setupMoveUpEvents, UpdateIcon } from '../../../ClientUtils'; +import { ClientUtils, DashColor, returnFalse, returnOne, returnZero, setupMoveUpEvents, UpdateIcon } from '../../../ClientUtils'; import { Doc, DocListCast, Opt } from '../../../fields/Doc'; import { DocData } from '../../../fields/DocSymbols'; import { Id } from '../../../fields/FieldSymbols'; import { InkTool } from '../../../fields/InkField'; +import { List } from '../../../fields/List'; import { ObjectField } from '../../../fields/ObjectField'; +import { ComputedField } from '../../../fields/ScriptField'; import { Cast, DocCast, ImageCast, NumCast, RTFCast, StrCast } from '../../../fields/Types'; import { ImageField } from '../../../fields/URLField'; import { TraceMobx } from '../../../fields/util'; -import { Upload } from '../../../server/SharedMediaTypes'; import { emptyFunction } from '../../../Utils'; import { Docs } from '../../documents/Documents'; import { DocumentType } from '../../documents/DocumentTypes'; @@ -26,7 +27,7 @@ import { Networking } from '../../Network'; import { DragManager } from '../../util/DragManager'; import { SettingsManager } from '../../util/SettingsManager'; import { SnappingManager } from '../../util/SnappingManager'; -import { undoable, undoBatch } from '../../util/UndoManager'; +import { undoable, undoBatch, UndoManager } from '../../util/UndoManager'; import { CollectionFreeFormView } from '../collections/collectionFreeForm/CollectionFreeFormView'; import { ContextMenu } from '../ContextMenu'; import { ContextMenuProps } from '../ContextMenuItem'; @@ -45,7 +46,6 @@ import { FieldView, FieldViewProps } from './FieldView'; import { FocusViewOptions } from './FocusViewOptions'; import './ImageBox.scss'; import { OpenWhere } from './OpenWhere'; -import { ComputedField } from '../../../fields/ScriptField'; const DefaultPath = '/assets/unknown-file-icon-hi.png'; export class ImageEditorData { @@ -104,6 +104,10 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @observable private _regenerateLoading = false; @observable private _prevImgs: FireflyImageData[] = StrCast(this.Document.ai_firefly_history) ? JSON.parse(StrCast(this.Document.ai_firefly_history)) : []; + // Add these observable properties to the ImageBox class + @observable private _outpaintingInProgress = false; + @observable private _outpaintingPrompt = ''; + constructor(props: FieldViewProps) { super(props); makeObservable(this); @@ -136,6 +140,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { }; componentDidMount() { + super.componentDidMount?.(); this._disposers.sizer = reaction( () => ({ forceFull: this._props.renderDepth < 1 || this.layoutDoc._showFullRes, @@ -166,6 +171,14 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { }, { fireImmediately: true } ); + this._disposers.outpainting = reaction( + () => this.Document?._needsOutpainting, + needsOutpainting => { + if (needsOutpainting && this.Document?._outpaintingResize) { + this.processOutpainting(); + } + } + ); } componentWillUnmount() { @@ -200,7 +213,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { drop = undoable( action((e: Event, de: DragManager.DropEvent) => { - if (de.complete.docDragData) { + if (de.complete.docDragData && this._props.rejectDrop?.(de.complete.docDragData?.draggedDocuments, this.DocumentView?.())) { let added: boolean | undefined; const hitDropTarget = (ele: HTMLElement, dropTarget: HTMLDivElement | null): boolean => { if (!ele) return false; @@ -339,6 +352,145 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { } }); + @observable _showOutpaintPrompt: boolean = false; + @observable _outpaintPromptInput: string = 'Extend this image naturally with matching content'; + + @action + openOutpaintPrompt = () => { + this._showOutpaintPrompt = true; + }; + + @action + closeOutpaintPrompt = () => { + this._showOutpaintPrompt = false; + }; + + @action + handlePromptChange = (val: string | number) => { + this._outpaintPromptInput = '' + val; + }; + + @action + submitOutpaintPrompt = () => { + this.closeOutpaintPrompt(); + this.processOutpaintingWithPrompt(this._outpaintPromptInput); + }; + + @action + processOutpaintingWithPrompt = async (customPrompt: string) => { + const field = Cast(this.dataDoc[this.fieldKey], ImageField); + if (!field) return; + + const origWidth = NumCast(this.Document._outpaintingOriginalWidth); + const origHeight = NumCast(this.Document._outpaintingOriginalHeight); + + if (!origWidth || !origHeight) { + console.error('Original dimensions (_outpaintingOriginalWidth/_outpaintingOriginalHeight) not set. Ensure resizeViewForOutpainting was called first.'); + return; + } + + // Set flag that outpainting is in progress + this._outpaintingInProgress = true; + + // Revert dimensions if prompt is blank (acts like Cancel) + if (!customPrompt) { + this.Document._width = origWidth; + this.Document._height = origHeight; + this._outpaintingInProgress = false; + return; + } + + try { + const currentPath = this.choosePath(field.url); + const newWidth = NumCast(this.Document._width); + const newHeight = NumCast(this.Document._height); + + // Optional: add loading indicator + const loadingOverlay = document.createElement('div'); + loadingOverlay.style.position = 'absolute'; + loadingOverlay.style.top = '0'; + loadingOverlay.style.left = '0'; + loadingOverlay.style.width = '100%'; + loadingOverlay.style.height = '100%'; + loadingOverlay.style.background = 'rgba(0,0,0,0.5)'; + loadingOverlay.style.display = 'flex'; + loadingOverlay.style.justifyContent = 'center'; + loadingOverlay.style.alignItems = 'center'; + loadingOverlay.innerHTML = '<div style="color: white; font-size: 16px;">Generating outpainted image...</div>'; + this._mainCont?.appendChild(loadingOverlay); + + const response = await Networking.PostToServer('/outpaintImage', { + imageUrl: currentPath, + prompt: customPrompt, + originalDimensions: { width: origWidth, height: origHeight }, + newDimensions: { width: newWidth, height: newHeight }, + }); + + const batch = UndoManager.StartBatch('outpaint image'); + if (response && typeof response === 'object' && 'url' in response && typeof response.url === 'string') { + console.log('Received outpainted image:', response.url); + + if (!this.dataDoc[this.fieldKey + '_alternates']) { + this.dataDoc[this.fieldKey + '_alternates'] = new List<Doc>(); + } + + const originalDoc = Docs.Create.ImageDocument(field.url.href, { + title: `Original: ${this.Document.title}`, + _nativeWidth: Doc.NativeWidth(this.dataDoc), + _nativeHeight: Doc.NativeHeight(this.dataDoc), + }); + + Doc.AddDocToList(this.dataDoc, this.fieldKey + '_alternates', originalDoc); + + // Replace with new outpainted image + this.dataDoc[this.fieldKey] = new ImageField(response.url); + + Doc.SetNativeWidth(this.dataDoc, newWidth); + Doc.SetNativeHeight(this.dataDoc, newHeight); + + this.Document.$ai = true; + this.Document.$ai_outpainted = true; + this.Document.$ai_outpaint_prompt = customPrompt; + } else { + console.error('Unexpected API response:', response); + this.Document._width = origWidth; + this.Document._height = origHeight; + alert('Failed to receive a valid image URL from server.'); + } + batch.end(); + + this._mainCont?.removeChild(loadingOverlay); + } catch (error) { + console.error('Error during outpainting:', error); + this.Document._width = origWidth; + this.Document._height = origHeight; + alert('An error occurred while outpainting. Please try again.'); + } finally { + runInAction(() => (this._outpaintingInProgress = false)); + } + }; + + processOutpainting = () => this.openOutpaintPrompt(); + + componentUI = () => + !this._showOutpaintPrompt ? null : ( + <div className="imageBox-regenerate-dialog" style={{ backgroundColor: SettingsManager.userBackgroundColor, color: SettingsManager.userColor }}> + <h3>Outpaint Image</h3> + <EditableText + placeholder="Enter a prompt for extending the image:" + setVal={val => this.handlePromptChange(val)} + val={this._outpaintPromptInput} + type={Type.TERT} + color={SettingsManager.userColor} + background={SettingsManager.userBackgroundColor} + /> + <div className="buttons"> + <Button text="Cancel" type={Type.TERT} onClick={this.closeOutpaintPrompt} color={SnappingManager.userColor} background={SnappingManager.userVariantColor} /> + <Button text="Generate" type={Type.TERT} onClick={this.submitOutpaintPrompt} color={SnappingManager.userColor} background={SnappingManager.userVariantColor} /> + </div> + </div> + ); + specificContextMenu = (): void => { const field = Cast(this.dataDoc[this.fieldKey], ImageField); if (field) { @@ -346,9 +498,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { funcs.push({ description: 'Rotate Clockwise 90', event: this.rotate, icon: 'redo-alt' }); 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: 'GetImageText', - event: () => { + funcs.push({ description: 'GetImageText', event: () => { Networking.PostToServer('/queryFireflyImageText', { file: (file => { const ext = file ? extname(file) : ''; @@ -357,25 +507,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { }).then(text => alert(text)); }, icon: 'expand-arrows-alt', - }); - funcs.push({ - description: 'Expand Image', - event: () => { - Networking.PostToServer('/expandImage', { - prompt: 'sunny skies', - file: (file => { - const ext = file ? extname(file) : ''; - return file?.replace(ext, (this._error ? '_o' : this._curSuffix) + ext); - })(ImageCast(this.Document[Doc.LayoutDataKey(this.Document)])?.url.href), - }).then(res => { - const info = res as Upload.ImageInformation; - const img = Docs.Create.ImageDocument(info.accessPaths.agnostic.client, { title: 'expand:' + this.Document.title }); - DocUtils.assignImageInfo(info, img); - this._props.addDocTab(img, OpenWhere.addRight); - }); - }, - icon: 'expand-arrows-alt', - }); + }); // prettier-ignore funcs.push({ description: 'Copy path', event: () => ClientUtils.CopyText(this.choosePath(field.url)), icon: 'copy' }); funcs.push({ description: 'Open Image Editor', event: this.docEditorView, icon: 'pencil-alt' }); this.layoutDoc.ai && @@ -396,6 +528,22 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { event: action(undoable(async () => await StickerPalette.addToPalette(this.Document), 'save to palette')), icon: this.Document.savedAsSticker ? 'clipboard-check' : 'file-arrow-down', }); + // Add new outpainting option + funcs.push({ description: 'Outpaint Image', event: () => this.openOutpaintPrompt(), icon: 'brush' }); + + // Add outpainting history option if the image was outpainted + this.Document.ai_outpainted && + funcs.push({ + description: 'View Original Image', + event: action(() => { + const alternates = DocListCast(this.dataDoc[this.fieldKey + '_alternates']); + if (alternates && alternates.length) { + // Toggle to show the original image + this.layoutDoc[this.fieldKey + '_usePath'] = 'alternate'; + } + }), + icon: 'image', + }); ContextMenu.Instance?.addItem({ description: 'Options...', subitems: funcs, icon: 'asterisk' }); } }; @@ -432,7 +580,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { const ext = extname(url.href); return url.href.replace(ext, (this._error ? '_o' : this._curSuffix) + ext); }; - getScrollHeight = () => (this._props.fitWidth?.(this.Document) !== false && NumCast(this.layoutDoc._freeform_scale, 1) === NumCast(this.dataDoc._freeform_scaleMin, 1) ? this.nativeSize.nativeHeight : undefined); + getScrollHeight = () => (this._props.fitWidth?.(this.Document) !== false && NumCast(this.layoutDoc._freeform_scale, 1) === NumCast(this.dataDoc.freeform_scaleMin, 1) ? this.nativeSize.nativeHeight : undefined); @computed get usingAlternate() { const usePath = StrCast(this.Document[this.fieldKey + '_usePath']); @@ -752,82 +900,61 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { const borderRad = this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BorderRounding) as string; const borderRadius = borderRad?.includes('px') ? `${Number(borderRad.split('px')[0]) / (this._props.NativeDimScaling?.() || 1)}px` : borderRad; return ( - <div - className="imageBox" - onContextMenu={this.specificContextMenu} - ref={this.createDropTarget} - onScroll={action(() => { - if (!this._forcedScroll) { - if (this.layoutDoc._layout_scrollTop || this._mainCont?.scrollTop) { - this._ignoreScroll = true; - this.layoutDoc._layout_scrollTop = this._mainCont?.scrollTop; - this._ignoreScroll = false; + <> + <div + className="imageBox" + onContextMenu={this.specificContextMenu} + ref={this.createDropTarget} + onScroll={action(() => { + if (!this._forcedScroll) { + if (this.layoutDoc._layout_scrollTop || this._mainCont?.scrollTop) { + this._ignoreScroll = true; + this.layoutDoc._layout_scrollTop = this._mainCont?.scrollTop; + this._ignoreScroll = false; + } } - } - })} - style={{ - width: this._props.PanelWidth() ? undefined : `100%`, - height: this._props.PanelHeight() ? undefined : `100%`, - pointerEvents: this.layoutDoc._lockedPosition ? 'none' : undefined, - borderRadius, - overflow: this.layoutDoc.layout_fitWidth || this._props.fitWidth?.(this.Document) ? 'auto' : 'hidden', - }}> - <CollectionFreeFormView - ref={this._ffref} - {...this._props} - Document={this.Document} - setContentViewBox={emptyFunction} - NativeWidth={returnZero} - NativeHeight={returnZero} - renderDepth={this._props.renderDepth + 1} - fieldKey={this.annotationKey} - styleProvider={this._props.styleProvider} - isAnnotationOverlay - annotationLayerHostsContent - PanelWidth={this._props.PanelWidth} - PanelHeight={this._props.PanelHeight} - ScreenToLocalTransform={this.screenToLocalTransform} - select={emptyFunction} - focus={this.focus} - getScrollHeight={this.getScrollHeight} - NativeDimScaling={returnOne} - isAnyChildContentActive={returnFalse} - whenChildContentsActiveChanged={this.whenChildContentsActiveChanged} - removeDocument={this.removeDocument} - moveDocument={this.moveDocument} - addDocument={this.addDocument}> - {this.content} - </CollectionFreeFormView> - {this.Loading ? ( - <div className="loading-spinner" style={{ position: 'absolute' }}> - <ReactLoading type="spin" height={50} width={50} color={'blue'} /> - </div> - ) : null} - {this.regenerateImageIcon} - {this.overlayImageIcon} - {this.annotationLayer} - {!this._mainCont || !this.DocumentView || !this._annotationLayer.current ? null : ( - <MarqueeAnnotator + })} + style={{ + width: this._props.PanelWidth() ? undefined : `100%`, + height: this._props.PanelHeight() ? undefined : `100%`, + pointerEvents: this.layoutDoc._lockedPosition ? 'none' : undefined, + borderRadius, + overflow: this.layoutDoc.layout_fitWidth || this._props.fitWidth?.(this.Document) ? 'auto' : 'hidden', + }}> + <CollectionFreeFormView + ref={this._ffref} + {...this._props} Document={this.Document} - ref={this.marqueeref} - scrollTop={0} - annotationLayerScrollTop={0} - scaling={returnOne} - annotationLayerScaling={this._props.NativeDimScaling} - screenTransform={this.DocumentView().screenToViewTransform} - docView={this.DocumentView} - addDocument={this.addDocument} - finishMarquee={this.finishMarquee} - savedAnnotations={this.savedAnnotations} - selectionText={returnEmptyString} - annotationLayer={this._annotationLayer.current} - marqueeContainer={this._mainCont} - highlightDragSrcColor="" - anchorMenuCrop={this.crop} - // anchorMenuFlashcard={() => this.getImageDesc()} - /> - )} - </div> + setContentViewBox={emptyFunction} + NativeWidth={returnZero} + NativeHeight={returnZero} + renderDepth={this._props.renderDepth + 1} + fieldKey={this.annotationKey} + styleProvider={this._props.styleProvider} + isAnnotationOverlay + annotationLayerHostsContent + PanelWidth={this._props.PanelWidth} + PanelHeight={this._props.PanelHeight} + ScreenToLocalTransform={this.screenToLocalTransform} + select={emptyFunction} + focus={this.focus} + rejectDrop={this._props.rejectDrop} + getScrollHeight={this.getScrollHeight} + NativeDimScaling={returnOne} + isAnyChildContentActive={returnFalse} + whenChildContentsActiveChanged={this.whenChildContentsActiveChanged} + removeDocument={this.removeDocument} + moveDocument={this.moveDocument} + addDocument={this.addDocument}> + {this.content} + </CollectionFreeFormView> + {this._outpaintingInProgress && ( + <div className="imageBox-outpaintingSpinner"> + <ReactLoading type="spin" color="#666" height={60} width={60} /> + </div> + )} + </div> + </> ); } @@ -840,10 +967,14 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { const file = input.files?.[0]; if (file) { const disposer = OverlayView.ShowSpinner(); - DocUtils.uploadFileToDoc(file, {}, this.Document).then(doc => { - disposer(); - doc && (doc.height = undefined); - }); + const [{ result }] = await Networking.UploadFilesToServer({ file }); + if (result instanceof Error) { + alert('Error uploading files - possibly due to unsupported file types'); + } else { + this.dataDoc[this.fieldKey] = new ImageField(result.accessPaths.agnostic.client); + !(result instanceof Error) && DocUtils.assignImageInfo(result, this.dataDoc); + } + disposer(); } else { console.log('No file selected'); } diff --git a/src/client/views/nodes/scrapbook/EmbeddedDocView.tsx b/src/client/views/nodes/scrapbook/EmbeddedDocView.tsx new file mode 100644 index 000000000..e99bf67c7 --- /dev/null +++ b/src/client/views/nodes/scrapbook/EmbeddedDocView.tsx @@ -0,0 +1,52 @@ +//IGNORE FOR NOW, CURRENTLY NOT USED IN SCRAPBOOK IMPLEMENTATION +import * as React from "react"; +import { observer } from "mobx-react"; +import { Doc } from "../../../../fields/Doc"; +import { DocumentView } from "../DocumentView"; +import { Transform } from "../../../util/Transform"; + +interface EmbeddedDocViewProps { + doc: Doc; + width?: number; + height?: number; + slotId?: string; +} + +@observer +export class EmbeddedDocView extends React.Component<EmbeddedDocViewProps> { + render() { + const { doc, width = 300, height = 200, slotId } = this.props; + + // Use either an existing embedding or create one + let docToDisplay = doc; + + // If we need an embedding, create or use one + if (!docToDisplay.isEmbedding) { + docToDisplay = Doc.BestEmbedding(doc) || Doc.MakeEmbedding(doc); + // Set the container to the slot's ID so we can track it + if (slotId) { + docToDisplay.embedContainer = `scrapbook-slot-${slotId}`; + } + } + + return ( + <DocumentView + Document={docToDisplay} + renderDepth={0} + // Required sizing functions + NativeWidth={() => width} + NativeHeight={() => height} + PanelWidth={() => width} + PanelHeight={() => height} + // Required state functions + isContentActive={() => true} + childFilters={() => []} + ScreenToLocalTransform={() => new Transform()} + // Display options + hideDeleteButton={true} + hideDecorations={true} + hideResizeHandles={true} + /> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/scrapbook/ScrapbookBox.tsx b/src/client/views/nodes/scrapbook/ScrapbookBox.tsx new file mode 100644 index 000000000..b02976067 --- /dev/null +++ b/src/client/views/nodes/scrapbook/ScrapbookBox.tsx @@ -0,0 +1,111 @@ +import { action, makeObservable, observable } from 'mobx'; +import * as React from 'react'; +import { Doc } from '../../../../fields/Doc'; +import { List } from '../../../../fields/List'; +import { emptyFunction } from '../../../../Utils'; +import { Docs } from '../../../documents/Documents'; +import { DocumentType } from '../../../documents/DocumentTypes'; +import { CollectionView } from '../../collections/CollectionView'; +import { ViewBoxAnnotatableComponent } from '../../DocComponent'; +import { DocumentView } from '../DocumentView'; +import { FieldView, FieldViewProps } from '../FieldView'; +import { DragManager } from '../../../util/DragManager'; +// Scrapbook view: a container that lays out its child items in a grid/template +export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { + @observable createdDate: string; + private _dropDisposer?: DragManager.DragDropDisposer; + + constructor(props: FieldViewProps) { + super(props); + makeObservable(this); + this.createdDate = this.getFormattedDate(); + + // ensure we always have a List<Doc> in dataDoc['items'] + if (!this.dataDoc[this.fieldKey]) { + this.dataDoc[this.fieldKey] = new List<Doc>(); + } + this.createdDate = this.getFormattedDate(); + this.setTitle(); + } + + public static LayoutString(fieldStr: string) { + return FieldView.LayoutString(ScrapbookBox, fieldStr); + } + + getFormattedDate(): string { + return new Date().toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } + + @action + setTitle() { + const title = `Scrapbook - ${this.createdDate}`; + if (this.dataDoc.title !== title) { + this.dataDoc.title = title; + } + } + + componentDidMount() { + this.setTitle(); + } + + childRejectDrop = (draggedDoc: Doc[] | undefined, subView?: DocumentView) => { + if (draggedDoc?.length === 1 && subView) { + if (subView.Document.type === DocumentType.IMG && draggedDoc[0].$type !== DocumentType.IMG) { + return true; + } + } + return false; + }; + rejectDrop = (draggedDoc: Doc[] | undefined, subView?: DocumentView) => { + if (draggedDoc?.length === 1 && draggedDoc[0].$type !== DocumentType.IMG) { + return true; + } + return false; + }; + onInternalDrop = (e: Event, de: DragManager.DropEvent) => { + if (de.complete.docDragData?.draggedDocuments[0]?.$type === DocumentType.IMG) { + return true; + } + return false; + }; + + protected createDashEventsTarget = (ele: HTMLDivElement | null) => { + this._dropDisposer?.(); + if (ele) { + this._dropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.layoutDoc); + } + }; + + render() { + return ( + <div style={{ background: 'beige', width: '100%', height: '100%' }} ref={r => r && this.createDashEventsTarget(r)}> + <CollectionView + {...this._props} // + setContentViewBox={emptyFunction} + rejectDrop={this.rejectDrop} + childRejectDrop={this.childRejectDrop} + /> + </div> + ); + } +} + +// Register scrapbook +Docs.Prototypes.TemplateMap.set(DocumentType.SCRAPBOOK, { + layout: { view: ScrapbookBox, dataField: 'items' }, + options: { + acl: '', + _height: 200, + _xMargin: 10, + _yMargin: 10, + _layout_autoHeight: true, + _layout_reflowVertical: true, + _layout_reflowHorizontal: true, + defaultDoubleClick: 'ignore', + systemIcon: 'BsImages', + }, +}); diff --git a/src/client/views/nodes/scrapbook/ScrapbookContent.tsx b/src/client/views/nodes/scrapbook/ScrapbookContent.tsx new file mode 100644 index 000000000..ad1d308e8 --- /dev/null +++ b/src/client/views/nodes/scrapbook/ScrapbookContent.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { observer } from "mobx-react-lite"; +// Import the Doc type from your actual module. +import { Doc } from "../../../../fields/Doc"; + +export interface ScrapbookContentProps { + doc: Doc; +} + +// A simple view that displays a document's title and content. +// Adjust how you extract the text if your Doc fields are objects. +export const ScrapbookContent: React.FC<ScrapbookContentProps> = observer(({ doc }) => { + // If doc.title or doc.content are not plain strings, convert them. + const titleText = doc.title ? doc.title.toString() : "Untitled"; + const contentText = doc.content ? doc.content.toString() : "No content available."; + + return ( + <div className="scrapbook-content"> + <h3>{titleText}</h3> + <p>{contentText}</p> + </div> + ); +}); diff --git a/src/client/views/nodes/scrapbook/ScrapbookSlot.scss b/src/client/views/nodes/scrapbook/ScrapbookSlot.scss new file mode 100644 index 000000000..ae647ad36 --- /dev/null +++ b/src/client/views/nodes/scrapbook/ScrapbookSlot.scss @@ -0,0 +1,85 @@ +//IGNORE FOR NOW, CURRENTLY NOT USED IN SCRAPBOOK IMPLEMENTATION +.scrapbook-slot { + position: absolute; + background-color: rgba(245, 245, 245, 0.7); + border: 2px dashed #ccc; + border-radius: 5px; + box-sizing: border-box; + transition: all 0.2s ease; + overflow: hidden; + + &.scrapbook-slot-over { + border-color: #4a90e2; + background-color: rgba(74, 144, 226, 0.1); + } + + &.scrapbook-slot-filled { + border-style: solid; + border-color: rgba(0, 0, 0, 0.1); + background-color: transparent; + + &.scrapbook-slot-over { + border-color: #4a90e2; + background-color: rgba(74, 144, 226, 0.1); + } + } + + .scrapbook-slot-empty { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + } + + .scrapbook-slot-placeholder { + text-align: center; + color: #888; + } + + .scrapbook-slot-title { + font-weight: bold; + margin-bottom: 5px; + } + + .scrapbook-slot-instruction { + font-size: 0.9em; + font-style: italic; + } + + .scrapbook-slot-content { + width: 100%; + height: 100%; + position: relative; + } + + .scrapbook-slot-controls { + position: absolute; + top: 5px; + right: 5px; + z-index: 10; + opacity: 0; + transition: opacity 0.2s ease; + + .scrapbook-slot-remove-btn { + background-color: rgba(255, 255, 255, 0.8); + border: 1px solid #ccc; + border-radius: 50%; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 10px; + + &:hover { + background-color: rgba(255, 0, 0, 0.1); + } + } + } + + &:hover .scrapbook-slot-controls { + opacity: 1; + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/scrapbook/ScrapbookSlot.tsx b/src/client/views/nodes/scrapbook/ScrapbookSlot.tsx new file mode 100644 index 000000000..2c8f93778 --- /dev/null +++ b/src/client/views/nodes/scrapbook/ScrapbookSlot.tsx @@ -0,0 +1,28 @@ + +//IGNORE FOR NOW, CURRENTLY NOT USED IN SCRAPBOOK IMPLEMENTATION +export interface SlotDefinition { + id: string; + x: number; y: number; + defaultWidth: number; + defaultHeight: number; + } + + export interface SlotContentMap { + slotId: string; + docId?: string; + } + + export interface ScrapbookConfig { + slots: SlotDefinition[]; + contents?: SlotContentMap[]; + } + + export const DEFAULT_SCRAPBOOK_CONFIG: ScrapbookConfig = { + slots: [ + { id: "slot1", x: 10, y: 10, defaultWidth: 180, defaultHeight: 120 }, + { id: "slot2", x: 200, y: 10, defaultWidth: 180, defaultHeight: 120 }, + // …etc + ], + contents: [] + }; +
\ No newline at end of file diff --git a/src/client/views/nodes/scrapbook/ScrapbookSlotTypes.ts b/src/client/views/nodes/scrapbook/ScrapbookSlotTypes.ts new file mode 100644 index 000000000..686917d9a --- /dev/null +++ b/src/client/views/nodes/scrapbook/ScrapbookSlotTypes.ts @@ -0,0 +1,25 @@ +// ScrapbookSlotTypes.ts +export interface SlotDefinition { + id: string; + title: string; + x: number; + y: number; + defaultWidth: number; + defaultHeight: number; + } + + export interface ScrapbookConfig { + slots: SlotDefinition[]; + contents?: { slotId: string; docId: string }[]; + } + + // give it three slots by default: + export const DEFAULT_SCRAPBOOK_CONFIG: ScrapbookConfig = { + slots: [ + { id: "main", title: "Main Content", x: 20, y: 20, defaultWidth: 360, defaultHeight: 200 }, + { id: "notes", title: "Notes", x: 20, y: 240, defaultWidth: 360, defaultHeight: 160 }, + { id: "resources", title: "Resources", x: 400, y: 20, defaultWidth: 320, defaultHeight: 380 }, + ], + contents: [], + }; +
\ No newline at end of file diff --git a/src/client/views/nodes/scrapbook/ScrapbookVersionTwo.tsx b/src/client/views/nodes/scrapbook/ScrapbookVersionTwo.tsx new file mode 100644 index 000000000..d15d2fe56 --- /dev/null +++ b/src/client/views/nodes/scrapbook/ScrapbookVersionTwo.tsx @@ -0,0 +1,125 @@ +//IGNORE FOR NOW, CURRENTLY NOT USED IN SCRAPBOOK IMPLEMENTATION +import { action, makeObservable, observable } from 'mobx'; +import * as React from 'react'; +import { RichTextField } from '../../../../fields/RichTextField'; +import { Docs } from '../../../documents/Documents'; +import { DocumentType } from '../../../documents/DocumentTypes'; +import { ViewBoxAnnotatableComponent } from '../../DocComponent'; +import { FieldView, FieldViewProps } from '../FieldView'; +import { FormattedTextBox, FormattedTextBoxProps } from '../formattedText/FormattedTextBox'; + +export class ScrapbookVersionTwo extends ViewBoxAnnotatableComponent<FieldViewProps>() { + @observable scrapbookDate: string; + + public static LayoutString(fieldStr: string) { + return FieldView.LayoutString(ScrapbookVersionTwo, fieldStr); + } + + constructor(props: FormattedTextBoxProps) { + super(props); + makeObservable(this); + this.scrapbookDate = this.getFormattedDate(); + + console.log('Constructor: Setting initial title and text...'); + this.setDailyTitle(); + this.setDailyText(); + } + + getFormattedDate(): string { + const date = new Date().toLocaleDateString(undefined, { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); + console.log('getFormattedDate():', date); + return date; + } + + @action + setDailyTitle() { + console.log('setDailyTitle() called...'); + console.log('Current title before update:', this.dataDoc.title); + + if (!this.dataDoc.title || this.dataDoc.title !== this.scrapbookDate) { + console.log('Updating title to:', this.scrapbookDate); + this.dataDoc.title = this.scrapbookDate; + } + + console.log('New title after update:', this.dataDoc.title); + } + + @action + setDailyText() { + console.log('setDailyText() called...'); + const placeholderText = 'Start writing here...'; + const initialText = `Scrapbook - $\n${placeholderText}`; + + console.log('Checking if dataDoc has text field...'); + + const styles = { + bold: true, // Make the journal date bold + color: 'red', // Set the journal date color to blue + fontSize: 12, // Set the font size to 18px for the whole text + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(100px, 1fr))', + gap: '8px', + padding: '10px', + background: '#fafafa', + width: '100%', + height: '100%', + }; + + console.log('Setting new text field with:', initialText); + this.dataDoc[this.fieldKey] = RichTextField.textToRtf( + initialText, + undefined, // No image DocId + styles, // Pass the styles object here + placeholderText.length // The position for text selection + ); + + console.log('Current text field:', this.dataDoc[this.fieldKey]); + } + + componentDidMount(): void { + console.log('componentDidMount() triggered...'); + // bcz: This should be moved into Docs.Create.DailyJournalDocument() + // otherwise, it will override all the text whenever the note is reloaded + this.setDailyTitle(); + this.setDailyText(); + } + + render() { + return ( + <div + style={{ + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(100px, 1fr))', + gap: '8px', + padding: '10px', + background: '#fafafa', + width: '100%', + height: '100%', + }} + > + <FormattedTextBox {...this._props} fieldKey={'text'} Document={this.Document} TemplateDataDocument={undefined} /> + </div> + ); + } +} + +Docs.Prototypes.TemplateMap.set(DocumentType.SCRAPBOOK, { + layout: { view: ScrapbookVersionTwo, dataField: 'text' }, + options: { + acl: '', + _height: 35, + _xMargin: 10, + _yMargin: 10, + _layout_autoHeight: true, + _layout_nativeDimEditable: true, + _layout_reflowVertical: true, + _layout_reflowHorizontal: true, + defaultDoubleClick: 'ignore', + systemIcon: 'BsFileEarmarkTextFill', + }, +});
\ No newline at end of file diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts index abce7ed26..ad8585bf4 100644 --- a/src/fields/Doc.ts +++ b/src/fields/Doc.ts @@ -491,6 +491,10 @@ export class Doc extends RefField { } } export namespace Doc { + export let SelectOnLoad: Doc | undefined; + export function SetSelectOnLoad(doc: Doc | undefined) { + SelectOnLoad = doc; + } export let DocDragDataName: string = ''; export function SetDocDragDataName(name: string) { DocDragDataName = name; @@ -1192,6 +1196,26 @@ export namespace Doc { const dheight = NumCast((dataDoc || doc)[Doc.LayoutDataKey(doc) + '_nativeHeight'], useHeight ? NumCast(doc._height) : 0); return NumCast(doc._nativeHeight, nheight || dheight); } + + export function OutpaintingWidth(doc?: Doc, dataDoc?: Doc, useWidth?: boolean) { + return !doc ? 0 : NumCast(doc._outpaintingWidth, NumCast((dataDoc || doc)[Doc.LayoutDataKey(doc) + '_outpaintingWidth'], useWidth ? NumCast(doc._width) : 0)); + } + + export function OutpaintingHeight(doc?: Doc, dataDoc?: Doc, useHeight?: boolean) { + if (!doc) return 0; + const oheight = (Doc.OutpaintingWidth(doc, dataDoc, useHeight) / NumCast(doc._width)) * NumCast(doc._height); + const dheight = NumCast((dataDoc || doc)[Doc.LayoutDataKey(doc) + '_outpaintingHeight'], useHeight ? NumCast(doc._height) : 0); + return NumCast(doc._outpaintingHeight, oheight || dheight); + } + + export function SetOutpaintingWidth(doc: Doc, width: number | undefined, fieldKey?: string) { + doc[(fieldKey || Doc.LayoutDataKey(doc)) + '_outpaintingWidth'] = width; + } + + export function SetOutpaintingHeight(doc: Doc, height: number | undefined, fieldKey?: string) { + doc[(fieldKey || Doc.LayoutDataKey(doc)) + '_outpaintingHeight'] = height; + } + export function SetNativeWidth(doc: Doc, width: number | undefined, fieldKey?: string) { doc[(fieldKey || Doc.LayoutDataKey(doc)) + '_nativeWidth'] = width; } diff --git a/src/server/ApiManagers/FireflyManager.ts b/src/server/ApiManagers/FireflyManager.ts index d22142d7d..fd61f6c9e 100644 --- a/src/server/ApiManagers/FireflyManager.ts +++ b/src/server/ApiManagers/FireflyManager.ts @@ -8,6 +8,7 @@ import { DashUploadUtils } from '../DashUploadUtils'; import { _error, _invalid, _success, Method } from '../RouteManager'; import { Directory, filesDirectory } from '../SocketData'; import ApiManager, { Registration } from './ApiManager'; +import { Upload } from '../SharedMediaTypes'; export default class FireflyManager extends ApiManager { getBearerToken = () => @@ -313,6 +314,85 @@ export default class FireflyManager extends ApiManager { }) ), // prettier-ignore }); + + register({ + method: Method.POST, + subscription: '/outpaintImage', + secureHandler: ({ req, res }) => + new Promise<void>(resolver => + this.uploadImageToDropbox(req.body.imageUrl, req.user as DashUserModel) + .then(uploadUrl => + this.getBearerToken() + .then(tokenResponse => tokenResponse?.json()) + .then((tokenData: { access_token: string }) => + fetch('https://firefly-api.adobe.io/v3/images/expand', { + method: 'POST', + headers: [ + ['Content-Type', 'application/json'], + ['Accept', 'application/json'], + ['x-api-key', process.env._CLIENT_FIREFLY_CLIENT_ID ?? ''], + ['Authorization', `Bearer ${tokenData.access_token}`], + ], + body: JSON.stringify({ + image: { + source: { url: uploadUrl }, + }, + size: { + width: Math.round(req.body.newDimensions.width), + height: Math.round(req.body.newDimensions.height), + }, + prompt: req.body.prompt ?? '', + numVariations: 1, + placement: { + inset: { + left: 0, + top: 0, + right: Math.round(req.body.newDimensions.width - req.body.originalDimensions.width), + bottom: Math.round(req.body.newDimensions.height - req.body.originalDimensions.height), + }, + alignment: { + horizontal: 'center', + vertical: 'center', + }, + }, + }), + }) + .then(expandResp => expandResp?.json()) + .then(expandData => { + if (expandData.error_code || !expandData.outputs?.[0]?.image?.url) { + console.error('Firefly validation error:', expandData); + _error(res, expandData.message ?? 'Failed to generate image'); + } else { + return DashUploadUtils.UploadImage(expandData.outputs[0].image.url) + .then((info: Upload.ImageInformation | Error) => { + if (info instanceof Error) { + _invalid(res, info.message); + } else { + _success(res, { url: info.accessPaths.agnostic.client }); + } + }) + .catch(uploadErr => { + console.error('DashUpload Error:', uploadErr); + _error(res, 'Failed to upload generated image.'); + }); + } + }) + ) + ) + .catch(e => { + _invalid(res, e.message); + resolver(); + }) + ), + }); + + /* register({ + method: Method.POST + subscription: '/queryFireflyOutpaint', + secureHandler: ({req, res}) => + this.outpaintImage() + })*/ + register({ method: Method.POST, subscription: '/queryFireflyImage', @@ -339,23 +419,6 @@ export default class FireflyManager extends ApiManager { ) ), }); - register({ - method: Method.POST, - subscription: '/expandImage', - secureHandler: ({ req, res }) => - this.uploadImageToDropbox(req.body.file, req.user as DashUserModel) - .then(uploadUrl => - this.expandImage(uploadUrl, req.body.prompt).then(text => { - if (text.error_code) _error(res, text.message); - else - DashUploadUtils.UploadImage(text.outputs[0].image.url).then(info => { - if (info instanceof Error) _invalid(res, info.message); - else _success(res, info); - }); - }) - ) - .catch(e => _invalid(res, e.message)), - }); // construct this url and send user to it. It will allow them to authorize their dropbox account and will send the resulting token to our endpoint /refreshDropbox // https://www.dropbox.com/oauth2/authorize?client_id=DROPBOX_CLIENT_ID&response_type=code&token_access_type=offline&redirect_uri=http://localhost:1050/refreshDropbox |