diff options
Diffstat (limited to 'src/client/views')
10 files changed, 663 insertions, 17 deletions
diff --git a/src/client/views/ViewBoxInterface.ts b/src/client/views/ViewBoxInterface.ts index d8dab8e89..b532dfe35 100644 --- a/src/client/views/ViewBoxInterface.ts +++ b/src/client/views/ViewBoxInterface.ts @@ -24,6 +24,7 @@ export abstract class ViewBoxInterface<P> extends ObservableReactComponent<React promoteCollection?: () => void; // moves contents of collection to parent hasChildDocs?: () => Doc[]; docEditorView?: () => void; + autoTag?: () => void; // auto tag the document showSmartDraw?: (x: number, y: number, regenerate?: boolean) => void; updateIcon?: (usePanelDimensions?: boolean) => Promise<void>; // updates the icon representation of the document getAnchor?: (addAsAnnotation: boolean, pinData?: PinProps) => Doc; // returns an Anchor Doc that represents the current state of the doc's componentview (e.g., the current playhead location of a an audio/video box) diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx index ff9fb14e7..038b1c6f9 100644 --- a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx +++ b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx @@ -158,17 +158,19 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() { this._currentLabel = e.target.value; }); - classifyImagesInBox = async () => { + classifyImagesInBox = async (selectedImages? : Doc[], prompt? : string) => { this.startLoading(); + alert('Classifying images...'); + selectedImages ??= this._selectedImages; // Converts the images into a Base64 format, afterwhich the information is sent to GPT to label them. - const imageInfos = this._selectedImages.map(async doc => { + const imageInfos = selectedImages.map(async doc => { if (!doc.$tags_chat) { const url = ImageCastWithSuffix(doc[Doc.LayoutDataKey(doc)], '_o') ?? ''; return imageUrlToBase64(url).then(hrefBase64 => !hrefBase64 ? undefined : - gptImageLabel(hrefBase64,'Give three labels to describe this image.').then(labels => + gptImageLabel(hrefBase64, prompt ?? 'Give three labels to describe this image.').then(labels => ({ doc, labels }))) ; // prettier-ignore } }); diff --git a/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx b/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx index abd828945..2ec59e5d5 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx @@ -16,6 +16,7 @@ export class MarqueeOptionsMenu extends AntimodeMenu<AntimodeMenuProps> { public createCollection: (e: KeyboardEvent | React.PointerEvent | undefined, group?: boolean, selection?: Doc[]) => Doc | void = unimplementedFunction; public delete: (e: KeyboardEvent | React.PointerEvent | undefined) => void = unimplementedFunction; public summarize: (e: KeyboardEvent | React.PointerEvent | undefined) => void = unimplementedFunction; + public generateScrapbook: (e: KeyboardEvent | React.PointerEvent | undefined) => void = unimplementedFunction; public showMarquee: () => void = unimplementedFunction; public hideMarquee: () => void = unimplementedFunction; public pinWithView: (e: KeyboardEvent | React.PointerEvent | undefined) => void = unimplementedFunction; @@ -38,6 +39,7 @@ export class MarqueeOptionsMenu extends AntimodeMenu<AntimodeMenuProps> { <IconButton tooltip="Create a Collection" onPointerDown={this.createCollection} icon={<FontAwesomeIcon icon="object-group" />} color={this.userColor} /> <IconButton tooltip="Create a Grouping" onPointerDown={e => this.createCollection(e, true)} icon={<FontAwesomeIcon icon="layer-group" />} color={this.userColor} /> <IconButton tooltip="Summarize Documents" onPointerDown={this.summarize} icon={<FontAwesomeIcon icon="compress-arrows-alt" />} color={this.userColor} /> + <IconButton tooltip="Generate Scrapbook" onPointerDown={this.generateScrapbook} icon={<FontAwesomeIcon icon="palette" />} color={this.userColor} /> <IconButton tooltip="Delete Documents" onPointerDown={this.delete} icon={<FontAwesomeIcon icon="trash-alt" />} color={this.userColor} /> <IconButton tooltip="Pin selected region" onPointerDown={this.pinWithView} icon={<FontAwesomeIcon icon="map-pin" />} color={this.userColor} /> <IconButton tooltip="Classify and Sort Images" onPointerDown={this.classifyImages} icon={<FontAwesomeIcon icon="object-group" />} color={this.userColor} /> diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx index 3cc7c0f2d..f5e699d3e 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx @@ -275,6 +275,7 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps MarqueeOptionsMenu.Instance.createCollection = this.collection; MarqueeOptionsMenu.Instance.delete = this.delete; MarqueeOptionsMenu.Instance.summarize = this.summary; + MarqueeOptionsMenu.Instance.generateScrapbook = this.generateScrapbook; MarqueeOptionsMenu.Instance.showMarquee = this.showMarquee; MarqueeOptionsMenu.Instance.hideMarquee = this.hideMarquee; MarqueeOptionsMenu.Instance.jumpTo(e.clientX, e.clientY); @@ -517,6 +518,39 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps MarqueeOptionsMenu.Instance.fadeOut(true); }); + + + @undoBatch + generateScrapbook = action(() => { + let docs = new Array<Doc>(); + const selected = this.marqueeSelect(false).map(d => { + this._props.removeDocument?.(d); + d.x = NumCast(d.x) - this.Bounds.left; + d.y = NumCast(d.y) - this.Bounds.top; + docs.push(d); + return d; + }); + const scrapbook = Docs.Create.ScrapbookDocument(docs, { + backgroundColor: '#e2ad32', + x: this.Bounds.left, + y: this.Bounds.top, + followLinkToggle: true, + _width: 200, + _height: 200, + _layout_showSidebar: true, + title: 'overview', + }); + const portal = Docs.Create.FreeformDocument(selected, { title: 'summarized documents', x: this.Bounds.left + 200, y: this.Bounds.top, isGroup: true, backgroundColor: 'transparent' }); + DocUtils.MakeLink(scrapbook, portal, { link_relationship: 'summary of:summarized by' }); + + portal.hidden = true; + this._props.addDocument?.(portal); + //this._props.addLiveTextDocument(summary); + this._props.addDocument?.(scrapbook); + MarqueeOptionsMenu.Instance.fadeOut(true); + }); + + @action marqueeCommand = (e: KeyboardEvent) => { const ee = e as unknown as KeyboardEvent & { propagationIsStopped?: boolean }; @@ -538,6 +572,7 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps if (e.key === 'g') this.collection(e, true); if (e.key === 'c' || e.key === 't') this.collection(e); if (e.key === 's' || e.key === 'S') this.summary(); + if (e.key === 'g' || e.key === 'G') this.generateScrapbook(); // ← scrapbook shortcut if (e.key === 'p') this.pileup(); this.cleanupInteractions(false); } diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx index f7ad5c7e2..9d459d7eb 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -7,8 +7,9 @@ import { observer } from 'mobx-react'; import { extname } from 'path'; import * as React from 'react'; import { AiOutlineSend } from 'react-icons/ai'; +import { ImageLabelBoxData } from '../collections/collectionFreeForm/ImageLabelBox'; import ReactLoading from 'react-loading'; -import { ClientUtils, DashColor, returnEmptyString, returnFalse, returnOne, returnZero, setupMoveUpEvents, UpdateIcon } from '../../../ClientUtils'; +import { ClientUtils, imageUrlToBase64, DashColor, returnEmptyString, returnFalse, returnOne, returnZero, setupMoveUpEvents, UpdateIcon } from '../../../ClientUtils'; import { Doc, DocListCast, Opt } from '../../../fields/Doc'; import { DocData } from '../../../fields/DocSymbols'; import { Id } from '../../../fields/FieldSymbols'; @@ -16,7 +17,7 @@ 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 { Cast, DocCast, ImageCast, NumCast, RTFCast, StrCast, ImageCastWithSuffix } from '../../../fields/Types'; import { ImageField } from '../../../fields/URLField'; import { TraceMobx } from '../../../fields/util'; import { emptyFunction } from '../../../Utils'; @@ -45,6 +46,8 @@ import { FieldView, FieldViewProps } from './FieldView'; import { FocusViewOptions } from './FocusViewOptions'; import './ImageBox.scss'; import { OpenWhere } from './OpenWhere'; +import { gptImageLabel } from '../../apis/gpt/GPT'; +import { ImageLabelBox } from '../collections/collectionFreeForm/ImageLabelBox'; const DefaultPath = '/assets/unknown-file-icon-hi.png'; export class ImageEditorData { @@ -117,6 +120,52 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { this._dropDisposer?.(); ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this), this.Document)); }; + + autoTag = async () => { + + try { + // 1) grab the full-size URL + const layoutKey = Doc.LayoutDataKey(this.Document); + const url = ImageCastWithSuffix(this.Document[layoutKey], '_o') ?? ''; + if (!url) throw new Error('No image URL found'); + + // 2) convert to base64 + const base64 = await imageUrlToBase64(url); + if (!base64) throw new Error('Failed to load image data'); + + // 3) ask GPT for exactly one label: PERSON or LANDSCAPE + const raw = await gptImageLabel( + base64, + 'Classify this image as PERSON or LANDSCAPE. You may only respond with one of these two options.' + ); + + // 4) normalize and prefix + const label = raw + .trim() + .toUpperCase() + + // 5) stash it on the Doc + // overwrite any old tags so re-runs still work + this.Document.$tags_chat = new List<string>(); + (this.Document.$tags_chat as List<string>).push(label); + + // 6) flip on “show tags” in the layout + // (same flag that ImageLabelBox.toggleDisplayInformation uses) + //note to self: What if i used my own field (ex: Document.$auto_description or something + //Would i still have to toggle it on for it to show in the metadata? + this.Document._layout_showTags = true; + + } catch (err) { + console.error('autoTag failed:', err); + } finally { + } + }; + + //Doc.getDescription(this.Document).then(desc => this.desc = desc) + + + + getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => { const visibleAnchor = this._getAnchor?.(this._savedAnnotations, true); // use marquee anchor, otherwise, save zoom/pan as anchor const anchor = diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index 57720baae..97049d0eb 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx @@ -64,6 +64,7 @@ import { removeMarkWithAttrs } from './prosemirrorPatches'; import { RichTextMenu, RichTextMenuPlugin } from './RichTextMenu'; import { RichTextRules } from './RichTextRules'; import { schema } from './schema_rts'; +import { tickStep } from 'd3'; // import * as applyDevTools from 'prosemirror-dev-tools'; export interface FormattedTextBoxProps extends FieldViewProps { @@ -304,6 +305,14 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB } }; + autoTag = async () => { + this.Document.$tags_chat = new List<string>(); + gptAPICall(RTFCast(this.Document[Doc.LayoutDataKey(this.Document)])?.Text ?? StrCast(this.Document[Doc.LayoutDataKey(this.Document)]), + GPTCallType.CLASSIFYTEXT).then(desc => (this.Document.$tags_chat as List<string>).push(desc)); + this.Document._layout_showTags = true; + //or... then(desc => this.Document.$tags_chat = desc); + } + leafText = (node: Node) => { if (node.type === this.EditorView?.state.schema.nodes.dashField) { const refDoc = !node.attrs.docId ? this.rootDoc : (DocServer.GetCachedRefField(node.attrs.docId as string) as Doc); @@ -1237,6 +1246,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB }, { fireImmediately: true } ); + this._disposers.search = reaction( () => Doc.IsSearchMatch(this.Document), @@ -1270,6 +1280,14 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB { fireImmediately: true } ); + this._disposers.tagger = reaction( + () => ({ title: this.Document.title, sel: this.props.isSelected() }), + action(() => { + this.autoTag(); + }), + { fireImmediately: true } + ); + if (!this._props.dontRegisterView) { this._disposers.record = reaction( () => this.recordingDictation, diff --git a/src/client/views/nodes/scrapbook/ScrapbookBox.tsx b/src/client/views/nodes/scrapbook/ScrapbookBox.tsx index 6cfe9a62c..731715964 100644 --- a/src/client/views/nodes/scrapbook/ScrapbookBox.tsx +++ b/src/client/views/nodes/scrapbook/ScrapbookBox.tsx @@ -1,6 +1,6 @@ import { action, makeObservable, observable } from 'mobx'; import * as React from 'react'; -import { Doc, DocListCast } from '../../../../fields/Doc'; +import { Doc, DocListCast, StrListCast } from '../../../../fields/Doc'; import { List } from '../../../../fields/List'; import { emptyFunction } from '../../../../Utils'; import { Docs } from '../../../documents/Documents'; @@ -12,21 +12,36 @@ import { FieldView, FieldViewProps } from '../FieldView'; import { DragManager } from '../../../util/DragManager'; import { RTFCast, StrCast, toList } from '../../../../fields/Types'; import { undoable } from '../../../util/UndoManager'; +import { ScrapbookItemConfig, ScrapbookPreset } from './ScrapbookPreset'; + +enum ScrapbookPresetType { + Classic = 'Classic', + Default = 'Default', + Collage = 'Collage', + Spotlight = 'Spotlight', +} + // Scrapbook view: a container that lays out its child items in a grid/template export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @observable createdDate: string; + @observable configs : ScrapbookItemConfig[] constructor(props: FieldViewProps) { super(props); makeObservable(this); this.createdDate = this.getFormattedDate(); + this.configs = + ScrapbookPreset.createPreset(presetType); + // 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.initScrapbook(ScrapbookPresetType.Default); this.setTitle(); + //this.setLayout(ScrapbookPreset.Spotlight); } public static LayoutString(fieldStr: string) { @@ -41,6 +56,188 @@ export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>() }); } + + @action + initScrapbook(presetType: ScrapbookPresetType) { + // 1) ensure title is set + const title = `Scrapbook - ${this.createdDate}`; + if (this.dataDoc.title !== title) { + this.dataDoc.title = title; + } + + // 2) build placeholders from the preset + const configs = ScrapbookPreset.createPreset(presetType); + const placeholders: Doc[] = []; + + for (const cfg of configs) { + if (cfg.children) { + // --- nested container --- + const childDocs = cfg.children.map(child => { + const doc = Docs.Create.TextDocument(child.tag); + doc.accepts_docType = child.type; + doc.accepts_tagType = child.acceptTag ?? child.tag; + + const ph = new Doc(); + ph.proto = doc; + ph.original = doc; + ph.x = child.x; + ph.y = child.y; + if (child.width != null) ph._width = child.width; + if (child.height != null) ph._height = child.height; + return ph; + }); + + const protoW = cfg.containerWidth ?? cfg.width; + const protoH = cfg.containerHeight ?? cfg.height; + const containerProto = Docs.Create.StackingDocument( + childDocs, + { + ...(protoW != null ? { _width: protoW } : {}), + ...(protoH != null ? { _height: protoH } : {}), + title: cfg.tag + } + ); + + const ph = new Doc(); + ph.proto = containerProto; + ph.original = containerProto; + ph.x = cfg.x; + ph.y = cfg.y; + if (cfg.width != null) ph._width = cfg.width; + if (cfg.height != null) ph._height = cfg.height; + placeholders.push(ph); + + } else { + // --- flat placeholder --- + const doc = Docs.Create.TextDocument(cfg.tag); + doc.accepts_docType = cfg.type; + doc.accepts_tagType = cfg.acceptTag ?? cfg.tag; + + const ph = new Doc(); + ph.proto = doc; + ph.original = doc; + ph.x = cfg.x; + ph.y = cfg.y; + if (cfg.width != null) ph._width = cfg.width; + if (cfg.height != null) ph._height = cfg.height; + placeholders.push(ph); + } + } + + // 3) commit them into the field + this.dataDoc[this.fieldKey] = new List<Doc>(placeholders); + } + @action + //INACTIVE VER ignore!! not in use rn, implementation ver 1 + setLayout(preset: ScrapbookPreset) { + // helper to wrap a TextDocument proto in a Doc with positioning + function makePlaceholder( + proto: Doc, x: number, y: number, + width: number, height: number + ): Doc { + const d = new Doc(); + d.proto = proto; + d.original = proto; + d.x = x; + d.y = y; + d._width = width; + d._height = height; + return d; + } + + let placeholders: Doc[]; + + switch (preset) { + case ScrapbookPresetType.Classic: + // One large landscape image on top, caption below, sidebar at right + const imgClassic = Docs.Create.TextDocument('image'); + imgClassic.accepts_docType = DocumentType.IMG; + imgClassic.accepts_tagType = 'LANDSCAPE'; + const phImageClassic = makePlaceholder(imgClassic, 0, -120, 300, 180); + + const captionClassic = Docs.Create.TextDocument('caption'); + captionClassic.accepts_docType = DocumentType.RTF; + captionClassic.accepts_tagType = 'caption'; + const phCaptionClassic = makePlaceholder(captionClassic, 0, 80, 300, 60); + + const sidebarClassic = Docs.Create.TextDocument('sidebar'); + sidebarClassic.accepts_docType = DocumentType.RTF; + sidebarClassic.accepts_tagType = 'lengthy description'; + const phSidebarClassic = makePlaceholder(sidebarClassic, 320, -50, 80, 200); + + placeholders = [phImageClassic, phCaptionClassic, phSidebarClassic]; + break; + + case ScrapbookPresetType.Collage: + // Grid of four person images, small captions under each + const personDocs: Doc[] = []; + for (let i = 0; i < 4; i++) { + const img = Docs.Create.TextDocument(`person ${i+1}`); + img.accepts_docType = DocumentType.IMG; + img.accepts_tagType = 'PERSON'; + // position in 2x2 grid + const x = (i % 2) * 160 - 80; + const y = Math.floor(i / 2) * 160 - 80; + personDocs.push(makePlaceholder(img, x, y, 150, 120)); + + const cap = Docs.Create.TextDocument(`caption ${i+1}`); + cap.accepts_docType = DocumentType.RTF; + cap.accepts_tagType = 'caption'; + personDocs.push(makePlaceholder(cap, x, y + 70, 150, 30)); + } + placeholders = personDocs; + break; + + case ScrapbookPresetType.Spotlight: + // Full-width title, then a stacking of an internal person image + landscape, then description + const titleSpot = Docs.Create.TextDocument('title'); + titleSpot.accepts_docType = DocumentType.RTF; + titleSpot.accepts_tagType = 'title'; + const phTitleSpot = makePlaceholder(titleSpot, 0, -180, 400, 60); + + const internalImg = Docs.Create.TextDocument('<person>'); + internalImg.accepts_docType = DocumentType.IMG; + internalImg.accepts_tagType = 'PERSON'; + const phInternal = makePlaceholder(internalImg, -100, -120, 120, 160); + + const landscapeImg = Docs.Create.TextDocument('<landscape>'); + landscapeImg.accepts_docType = DocumentType.IMG; + landscapeImg.accepts_tagType = 'LANDSCAPE'; + const phLandscape = makePlaceholder(landscapeImg, 50, 0, 200, 160); + + const stack = Docs.Create.StackingDocument( + [phInternal, phLandscape], + { _width: 360, _height: 180, title: 'spotlight stack' } + ); + const phStack = (() => { + const d = new Doc(); + d.proto = stack; + d.original = stack; + d.x = 8; + d.y = -84; + d._width = 360; + d._height = 180; + return d; + })(); + + const descSpot = Docs.Create.TextDocument('description'); + descSpot.accepts_docType = DocumentType.RTF; + descSpot.accepts_tagType = 'lengthy description'; + const phDescSpot = makePlaceholder(descSpot, 0, 140, 400, 100); + + placeholders = [phTitleSpot, phStack, phDescSpot]; + break; + + default: + placeholders = []; + } + + // finally assign into the dataDoc + this.dataDoc[this.fieldKey] = new List<Doc>(placeholders); + } + + + @action setTitle() { const title = `Scrapbook - ${this.createdDate}`; @@ -49,6 +246,7 @@ export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>() const image = Docs.Create.TextDocument('image'); image.accepts_docType = DocumentType.IMG; + image.accepts_tagType = 'LANDSCAPE' //should i be writing fields on this doc? clarify diff between this and proto, original const placeholder = new Doc(); placeholder.proto = image; placeholder.original = image; @@ -57,10 +255,11 @@ export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>() placeholder.x = 0; placeholder.y = -100; //placeholder.overrideFields = new List<string>(['x', 'y']); // shouldn't need to do this for layout fields since the placeholder already overrides its protos - + const summary = Docs.Create.TextDocument('summary'); summary.accepts_docType = DocumentType.RTF; - summary.accepts_textType = 'one line'; + summary.accepts_tagType = 'caption'; + //summary.$tags_chat = new List<string>(['lengthy description']); //we need to go back and set this const placeholder2 = new Doc(); placeholder2.proto = summary; placeholder2.original = summary; @@ -68,11 +267,64 @@ export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>() placeholder2.y = 200; placeholder2._width = 250; //placeholder2.overrideFields = new List<string>(['x', 'y', '_width']); // shouldn't need to do this for layout fields since the placeholder already overrides its protos - this.dataDoc[this.fieldKey] = new List<Doc>([placeholder, placeholder2]); + + const sidebar = Docs.Create.TextDocument('sidebar'); + sidebar.accepts_docType = DocumentType.RTF; + sidebar.accepts_tagType = 'lengthy description'; //accepts_textType = 'lengthy description' + const placeholder3 = new Doc(); + placeholder3.proto = sidebar; + placeholder3.original = sidebar; + placeholder3.x = 280; + placeholder3.y = -50; + placeholder3._width = 50; + placeholder3._height = 200; + + + + const internalImg = Docs.Create.TextDocument('image internal'); + internalImg.accepts_docType = DocumentType.IMG; + internalImg.accepts_tagType = 'PERSON' //should i be writing fields on this doc? clarify diff between this and proto, original + const placeholder5 = new Doc(); + placeholder5.proto = internalImg; + placeholder5.original = internalImg; + placeholder5._width = 50; + placeholder5._height = 100; + placeholder5.x = 0; + placeholder5.y = -100; + + const collection = Docs.Create.StackingDocument([placeholder5], { _width: 300, _height: 300, title: "internal coll" }); + //collection.accepts_docType = DocumentType.COL; don't mark this field + const placeholder4 = new Doc(); + placeholder4.proto = collection; + placeholder4.original = collection; + placeholder4.x = -200; + placeholder4.y = -100; + placeholder4._width = 100; + placeholder4._height = 200; + /*note-to-self + would doing: + + const collection = Docs.Create.ScrapbookDocument([placeholder, placeholder2, placeholder3]); + create issues with references to the same object?*/ + + /*note-to-self + Should we consider that there are more collections than just COL type collections? + when spreading*/ + + + + /*note-to-self + difference between passing a new List<Doc> versus just the raw array? + */ + + this.dataDoc[this.fieldKey] = this.dataDoc[this.fieldKey] ?? new List<Doc>([placeholder, placeholder2, placeholder3, placeholder4]); + + } } componentDidMount() { + //this.initScrapbook(ScrapbookPresetType.Default); this.setTitle(); } @@ -86,20 +338,64 @@ export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>() }; filterAddDocument = (docIn: Doc | Doc[]) => { - const docs = toList(docIn); + const docs = toList(docIn); //The docs being added to the scrapbook + + // 1) Grab all template slots: + const slots = DocListCast(this.dataDoc[this.fieldKey]); + + // 2) recursive unwrap: + const unwrap = (items: Doc[]): Doc[] => + items.flatMap(d => + d.$type === DocumentType.COL + ? unwrap(DocListCast(d[Doc.LayoutDataKey(d)])) + : [d] + ); + + // 3) produce a flat list of every doc, unwrapping any number of nested COLs + const allDocs: Doc[] = unwrap(slots); + + if (docs?.length === 1) { - const placeholder = DocListCast(this.dataDoc[this.fieldKey]).find(d => + const placeholder = allDocs.filter(d => + (d.accepts_docType === docs[0].$type || // match fields based on type, or by analyzing content .. simple example of matching text in placeholder to dropped doc's type RTFCast(d[Doc.LayoutDataKey(d)])?.Text.includes(StrCast(docs[0].$type))) ); // prettier-ignore + //DocListCast(this.Document.items).map(doc => DocListCast(doc[Doc.LayoutDataKey(doc)]) + if (placeholder) { + /**Look at the autotags and see what matches*RTFCast(d[Doc.LayoutDataKey(d)])?.Text*/ // ugh. we have to tell the underlying view not to add the Doc so that we can add it where we want it. // However, returning 'false' triggers an undo. so this settimeout is needed to make the assignment happen after the undo. setTimeout( undoable(() => { + + const slotTagsList: Set<string>[] = placeholder.map(doc => + new Set<string>(StrListCast(doc.$tags_chat)) + ); + // turn docs[0].$tags_chat into a Set + const targetTags = new Set(StrListCast(docs[0].$tags_chat)); + //StrListCast(placeholder.overrideFields).map(field => (docs[0][field] = placeholder[field])); // // shouldn't need to do this for layout fields since the placeholder already overrides its protos - placeholder.proto = docs[0]; + + // find the first placeholder that shares *any* tag + const match = placeholder.find(ph => + ph.accepts_tagType != null && // make sure it actually has one + targetTags.has(StrCast(ph.accepts_tagType)) // test membership in the Set + //StrListCast(ph.$tags_chat).some(tag => targetTags.has(tag)) + ); + if (match) { + match.proto = docs[0]; + } + + /*const chosenPlaceholder = placeholder.find(d => + pl = new Set<string>(StrListCast(d.$tags_chat) + + d.$tags_chat && d.$tags_chat[0].equals(docs[0].$tags_chat)); //why [0] + if (chosenPlaceholder){ + chosenPlaceholder.proto = docs[0];}*/ + //excess if statement?? }, 'Scrapbook add') ); return false; @@ -124,6 +420,37 @@ export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>() } } + + +function extractScrapbookConfigs(docs: Doc[]): ScrapbookItemConfig[] { + return docs.map(doc => extractConfig(doc)); +} + +// function extractConfig(doc: Doc): ScrapbookItemConfig { +// const layoutKey = Doc.LayoutDataKey(doc); +// const childDocs = doc[layoutKey] ? DocListCast(doc[layoutKey]) : []; + +// const isContainer = childDocs.length > 0; + +// const cfg: ScrapbookItemConfig = { +// type: isContainer ? DocumentType.COL : doc.$type, +// tag: +// acceptTag: doc.accepts_tagType, +// x: doc.x || 0, +// y: doc.y || 0, +// width: doc._width, +// height: doc._height, +// }; + +// if (isContainer) { +// cfg.containerWidth = doc.proto._width; +// cfg.containerHeight = doc.proto._height; +// cfg.children = childDocs.map(child => extractConfig(child)); +// } + +// return cfg; +// } + // Register scrapbook Docs.Prototypes.TemplateMap.set(DocumentType.SCRAPBOOK, { layout: { view: ScrapbookBox, dataField: 'items' }, diff --git a/src/client/views/nodes/scrapbook/ScrapbookPreset.tsx b/src/client/views/nodes/scrapbook/ScrapbookPreset.tsx new file mode 100644 index 000000000..3cae4382b --- /dev/null +++ b/src/client/views/nodes/scrapbook/ScrapbookPreset.tsx @@ -0,0 +1,146 @@ +import { DocumentType } from '../../../documents/DocumentTypes'; + +export enum ScrapbookPresetType { + Default = 'Default', + Classic = 'Classic', + Collage = 'Collage', + Spotlight = 'Spotlight', +} + +export interface ScrapbookItemConfig { + type: DocumentType; + /** text shown in the placeholder bubble */ + tag: string; + /** what this slot actually accepts (defaults to `tag`) */ + acceptTag?: string; + + x: number; + y: number; + /** the frame this placeholder occupies */ + width?: number; + height?: number; + /** if this is a container with children, use these for the proto’s own size */ + containerWidth?: number; + containerHeight?: number; + children?: ScrapbookItemConfig[]; +} + +export class ScrapbookPreset { + static createPreset(presetType: ScrapbookPresetType): ScrapbookItemConfig[] { + switch (presetType) { + case ScrapbookPresetType.Classic: + return ScrapbookPreset.createClassicPreset(); + case ScrapbookPresetType.Collage: + return ScrapbookPreset.createCollagePreset(); + case ScrapbookPresetType.Spotlight: + return ScrapbookPreset.createSpotlightPreset(); + case ScrapbookPresetType.Default: + return ScrapbookPreset.createDefaultPreset(); + default: + throw new Error(`Unknown preset type: ${presetType}`); + } + } + + private static createClassicPreset(): ScrapbookItemConfig[] { + return [ + { type: DocumentType.IMG, + tag: 'LANDSCAPE', + acceptTag: 'LANDSCAPE', + x: 0, y: -100, width: 250, height: 200 + }, + { type: DocumentType.RTF, + tag: 'caption', + acceptTag: 'caption', + x: 0, y: 200, width: 250, height: 50 + }, + { type: DocumentType.RTF, + tag: 'lengthy description', + acceptTag: 'lengthy description', + x: 280, y: -50, width: 50, height: 200 + }, + { type: DocumentType.IMG, + tag: 'PERSON', + acceptTag: 'PERSON', + x: -200, y: -100, width: 100, height: 200 + }, + ]; + } + + private static createDefaultPreset(): ScrapbookItemConfig[] { + return [ + { type: DocumentType.IMG, + tag: 'image', + acceptTag: 'LANDSCAPE', + x: 0, y: -100, width: 250, height: 200 + }, + { type: DocumentType.RTF, + tag: 'summary', + acceptTag: 'caption', + x: 0, y: 200, width: 250 + }, + { type: DocumentType.RTF, + tag: 'sidebar', + acceptTag: 'lengthy description', + x: 280, y: -50, width: 50, height: 200 + }, + { + type: DocumentType.COL, + tag: 'internal coll', + x: -200, y: -100, width: 100, height: 200, + containerWidth: 300, containerHeight: 300, + children: [ + { type: DocumentType.IMG, + tag: 'image internal', + acceptTag: 'PERSON', + x: 0, y: 0, width: 50, height: 100 + } + ] + } + ]; + } + + private static createCollagePreset(): ScrapbookItemConfig[] { + return [ + { type: DocumentType.IMG, + tag: 'LANDSCAPE', + acceptTag: 'LANDSCAPE', + x: -150, y: -150, width: 150, height: 150 + }, + { type: DocumentType.IMG, + tag: 'PERSON', + acceptTag: 'PERSON', + x: 0, y: -150, width: 150, height: 150 + }, + { type: DocumentType.RTF, + tag: 'caption', + acceptTag: 'caption', + x: -150, y: 0, width: 300, height: 100 + }, + { type: DocumentType.RTF, + tag: 'lengthy description', + acceptTag: 'lengthy description', + x: 0, y: 100, width: 300, height: 100 + } + ]; + } + + private static createSpotlightPreset(): ScrapbookItemConfig[] { + return [ + { type: DocumentType.RTF, + tag: 'title', + acceptTag: 'title', + x: 0, y: -180, width: 300, height: 40 + }, + { type: DocumentType.IMG, + tag: 'LANDSCAPE', + acceptTag: 'LANDSCAPE', + x: 0, y: 0, width: 300, height: 200 + }, + { type: DocumentType.RTF, + tag: 'caption', + acceptTag: 'caption', + x: 0, y: 230, width: 300, height: 50 + } + ]; + } +} diff --git a/src/client/views/nodes/scrapbook/ScrapbookSettingsPanel.tsx b/src/client/views/nodes/scrapbook/ScrapbookSettingsPanel.tsx new file mode 100644 index 000000000..5808ab4d1 --- /dev/null +++ b/src/client/views/nodes/scrapbook/ScrapbookSettingsPanel.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faRedoAlt } from '@fortawesome/free-solid-svg-icons'; +import { action } from 'mobx'; + +export default class ScrapbookSettingsPanel extends React.Component { + + constructor(props) { + super(props); + this.state = { regenerating: false }; + } + + regenerateScrapbook = async () => { + this.setState({ regenerating: true }); + try { + // Example API call or method invoking ChatGPT for JSON + const newLayout = await fetch('/api/generate-scrapbook-layout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ currentLayout: this.props.currentLayout }) + }).then(res => res.json()); + + action(() => { + // Apply new layout + this.props.applyNewLayout(newLayout); + })(); + } catch (err) { + console.error('Failed to regenerate layout:', err); + } finally { + this.setState({ regenerating: false }); + } + }; + + render() { + const { regenerating } = this.state; + + return ( + <div className="scrapbook-settings-panel" style={{ display: 'flex', alignItems: 'center', padding: '8px', backgroundColor: '#f0f0f0', borderRadius: '8px', boxShadow: '0 4px 8px rgba(0,0,0,0.1)' }}> + <button + className="regenerate-scrapbook-btn" + title="Regenerate Scrapbook" + onClick={this.regenerateScrapbook} + disabled={regenerating} + style={{ + padding: '8px 12px', + background: regenerating ? '#ccc' : '#007bff', + color: 'white', + border: 'none', + borderRadius: '4px', + cursor: regenerating ? 'default' : 'pointer', + display: 'flex', + alignItems: 'center' + }}> + <FontAwesomeIcon icon={faRedoAlt} style={{ marginRight: '6px' }} /> + {regenerating ? 'Regenerating...' : 'Regenerate Scrapbook'} + </button> + </div> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/search/FaceRecognitionHandler.tsx b/src/client/views/search/FaceRecognitionHandler.tsx index 3ad5bc844..256e68afd 100644 --- a/src/client/views/search/FaceRecognitionHandler.tsx +++ b/src/client/views/search/FaceRecognitionHandler.tsx @@ -9,6 +9,8 @@ import { ImageField } from '../../../fields/URLField'; import { DocumentType } from '../../documents/DocumentTypes'; import { Docs } from '../../documents/Documents'; import { DocumentManager } from '../../util/DocumentManager'; +import { reaction } from 'mobx'; +import { DocumentView } from '../nodes/DocumentView'; /** * A singleton class that handles face recognition and manages face Doc collections for each face found. @@ -33,7 +35,7 @@ export class FaceRecognitionHandler { // eslint-disable-next-line no-use-before-define static _instance: FaceRecognitionHandler; private _apiModelReady = false; - private _pendingAPIModelReadyDocs: Doc[] = []; + private _pendingAPIModelReadyDocs: DocumentView[] = []; public static get Instance() { return FaceRecognitionHandler._instance ?? new FaceRecognitionHandler(); @@ -126,7 +128,7 @@ export class FaceRecognitionHandler { constructor() { FaceRecognitionHandler._instance = this; this.loadAPIModels().then(() => this._pendingAPIModelReadyDocs.forEach(this.classifyFacesInImage)); - DocumentManager.Instance.AddAnyViewRenderedCB(dv => FaceRecognitionHandler.Instance.classifyFacesInImage(dv.Document)); + DocumentManager.Instance.AddAnyViewRenderedCB(dv => FaceRecognitionHandler.Instance.classifyFacesInImage(dv)); } /** @@ -199,14 +201,18 @@ export class FaceRecognitionHandler { * match them to existing unique faces, otherwise new unique face(s) are created. * @param imgDoc The document being analyzed. */ - private classifyFacesInImage = async (imgDoc: Doc) => { + private classifyFacesInImage = async (imgDocView: DocumentView) => { + const imgDoc = imgDocView.Document; if (!Doc.UserDoc().recognizeFaceImages) return; const activeDashboard = Doc.ActiveDashboard; if (!this._apiModelReady || !activeDashboard) { - this._pendingAPIModelReadyDocs.push(imgDoc); + this._pendingAPIModelReadyDocs.push(imgDocView); } else if (imgDoc.type === DocumentType.LOADING && !imgDoc.loadingError) { - setTimeout(() => this.classifyFacesInImage(imgDoc), 1000); + setTimeout(() => this.classifyFacesInImage(imgDocView), 1000); } else { + reaction(() => ({sel:imgDocView.isSelected()}), ({sel}) => !sel && + imgDocView.ComponentView?.autoTag?.(), {fireImmediately: true} + ) const imgUrl = ImageCast(imgDoc[Doc.LayoutDataKey(imgDoc)]); if (imgUrl && !DocListCast(Doc.MyFaceCollection?.examinedFaceDocs).includes(imgDoc[DocData])) { // only examine Docs that have an image and that haven't already been examined. |
