import { IconButton, Size } from '@dash/components'; import { action, computed, IReactionDisposer, makeObservable, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import ReactLoading from 'react-loading'; import { Doc, DocListCast, Opt, StrListCast } from '../../../../fields/Doc'; import { List } from '../../../../fields/List'; import { DateCast, DocCast, NumCast, toList } from '../../../../fields/Types'; import { emptyFunction } from '../../../../Utils'; import { Docs } from '../../../documents/Documents'; import { DocumentType } from '../../../documents/DocumentTypes'; import { DragManager } from '../../../util/DragManager'; import { SnappingManager } from '../../../util/SnappingManager'; import { undoable } from '../../../util/UndoManager'; import { CollectionView } from '../../collections/CollectionView'; import { ViewBoxAnnotatableComponent } from '../../DocComponent'; import { AspectRatioLimits, FireflyImageDimensions } from '../../smartdraw/FireflyConstants'; import { SmartDrawHandler } from '../../smartdraw/SmartDrawHandler'; import { DocumentView } from '../DocumentView'; import { FieldView, FieldViewProps } from '../FieldView'; import { ImageBox } from '../ImageBox'; import './ScrapbookBox.scss'; import { ScrapbookItemConfig } from './ScrapbookPreset'; import { createPreset, getPresetNames } from './ScrapbookPresetRegistry'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { DocUtils } from '../../../documents/DocUtils'; import { returnTrue } from '../../../../ClientUtils'; function createPlaceholder(cfg: ScrapbookItemConfig, doc: Doc) { const placeholder = new Doc(); placeholder.proto = doc; placeholder.original = doc; placeholder.x = cfg.x; placeholder.y = cfg.y; if (cfg.width !== null) placeholder._width = cfg.width; if (cfg.height !== null) placeholder._height = cfg.height; return placeholder; } function createMessagePlaceholder(cfg: ScrapbookItemConfig) { return createPlaceholder(cfg, Docs.Create.TextDocument(cfg.message ?? ('[placeholder] ' + cfg.acceptTags?.[0]), { placeholder: "", placeholder_docType: cfg.type, placeholder_acceptTags: new List(cfg.acceptTags) }) ); // prettier-ignore } export function buildPlaceholdersFromConfigs(configs: ScrapbookItemConfig[]) { return configs.map(cfg => { if (cfg.children?.length) { const childDocs = cfg.children.map(createMessagePlaceholder); const protoW = cfg.containerWidth ?? cfg.width; const protoH = cfg.containerHeight ?? cfg.height; // Create a stacking document with the child placeholders const containerProto = Docs.Create.StackingDocument(childDocs, { ...(protoW !== null ? { _width: protoW } : {}), ...(protoH !== null ? { _height: protoH } : {}), title: cfg.message, }); return createPlaceholder(cfg, containerProto); } return createMessagePlaceholder(cfg); }); } export async function slotRealDocIntoPlaceholders(realDoc: Doc, placeholders: Doc[]) { if (!realDoc.$tags_chart) { await DocumentView.getFirstDocumentView(realDoc)?.ComponentView?.autoTag?.(); } const realTags = new Set(StrListCast(realDoc.$tags_chat).map(t => t.toLowerCase?.() ?? '')); // Find placeholder with most matching tags let bestMatch: Doc | null = null; let maxMatches = 0; // match fields based on type, or by analyzing content .. simple example of matching text in placeholder to dropped doc's type placeholders .filter(ph => ph.placeholder_docType === realDoc.$type) // Skip this placeholder entirely if types do not match. .forEach(ph => { const matches = StrListCast(ph.placeholder_acceptTags) .map(t => t.toLowerCase?.()) .filter(tag => realTags.has(tag)); if (matches.length > maxMatches) { maxMatches = matches.length; bestMatch = ph; } }); if (bestMatch && maxMatches > 0) { setTimeout(undoable(() => (bestMatch!.proto = realDoc), 'Scrapbook add')); return true; } return false; } // Scrapbook view: a container that lays out its child items in a template @observer export class ScrapbookBox extends ViewBoxAnnotatableComponent() { public static LayoutString(fieldStr: string) { return FieldView.LayoutString(ScrapbookBox, fieldStr); } private _disposers: { [name: string]: IReactionDisposer } = {}; private _imageBoxRef = React.createRef(); constructor(props: FieldViewProps) { super(props); makeObservable(this); } @observable _selectedPreset = getPresetNames()[0]; @observable _loading = false; @computed get createdDate() { return DateCast(this.dataDoc.$author_date)?.date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', }); } @computed get ScrapbookLayoutDocs() { return DocListCast(this.dataDoc[this.fieldKey]); } // prettier-ignore @computed get BackgroundDoc() { return DocCast(this.dataDoc[this.fieldKey + '_background']); } // prettier-ignore set ScrapbookLayoutDocs(doc: Doc[]) { this.dataDoc[this.fieldKey] = new List(doc); } // prettier-ignore set BackgroundDoc(doc: Opt) { this.dataDoc[this.fieldKey + '_background'] = doc; } // prettier-ignore @action setDefaultPlaceholder = () => { this.ScrapbookLayoutDocs = [ createMessagePlaceholder({ message: 'To create a scrapbook from existing documents, marquee select. For existing scrapbook arrangements, select a preset from the dropdown.', type: DocumentType.RTF, width: 250, height: 200, x: 0, y: 0, }), ]; const placeholder1 = createMessagePlaceholder({ acceptTags: ['PERSON'], type: DocumentType.IMG, width: 250, height: 200, x: 0, y: -100 }); const placeholder2 = createMessagePlaceholder({ acceptTags: ['lengthy description'], type: DocumentType.RTF, width: 250, height: undefined, x: 0, y: 200 }); const placeholder3 = createMessagePlaceholder({ acceptTags: ['title'], type: DocumentType.RTF, width: 50, height: 200, x: 280, y: -50 }); const placeholder4 = createPlaceholder( { width: 100, height: 200, x: -200, y: -100 }, Docs.Create.StackingDocument([ createMessagePlaceholder({ acceptTags: ['LANDSCAPE'], type: DocumentType.IMG, width: 50, height: 100, x: 0, y: -100 }) ], { _width: 300, _height: 300, title: 'internal coll' })); // prettier-ignore console.log('UNUSED', placeholder4, placeholder3, placeholder2, placeholder1); /* 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 versus just the raw array? */ }; selectPreset = action((presetName: string) => (this.ScrapbookLayoutDocs = buildPlaceholdersFromConfigs(createPreset(presetName)))); componentDidMount() { const title = `Scrapbook - ${this.createdDate}`; if (!this.ScrapbookLayoutDocs.length) this.setDefaultPlaceholder(); if (!this.BackgroundDoc) this.generateAiImage(this.regenPrompt); if (this.dataDoc.title !== title) this.dataDoc.title = title; // ensure title is set this._disposers.propagateResize = reaction( () => ({ w: this.layoutDoc._width, h: this.layoutDoc._height }), (dims, prev) => { const imageBox = this._imageBoxRef.current; // prev is undefined on the first run if (prev && SnappingManager.ShiftKey && this.BackgroundDoc && imageBox) { this.BackgroundDoc[imageBox.fieldKey + '_outpaintOriginalWidth'] = prev.w; this.BackgroundDoc[imageBox.fieldKey + '_outpaintOriginalHeight'] = prev.h; imageBox.layoutDoc._width = dims.w; imageBox.layoutDoc._height = dims.h; } } ); } isOutpaintable = returnTrue; showBorderRounding = returnTrue; @action generateAiImage = (prompt: string) => { this._loading = true; const ratio = NumCast(this.layoutDoc._width, 1) / NumCast(this.layoutDoc._height, 1); // Measure the scrapbook’s current aspect const choosePresetForDimensions = (() => { // Pick the Firefly preset that best matches the aspect ratio if (ratio > AspectRatioLimits[FireflyImageDimensions.Widescreen]) return FireflyImageDimensions.Widescreen; if (ratio > AspectRatioLimits[FireflyImageDimensions.Landscape]) return FireflyImageDimensions.Landscape; if (ratio < AspectRatioLimits[FireflyImageDimensions.Portrait]) return FireflyImageDimensions.Portrait; return FireflyImageDimensions.Square; })(); // prettier-ignore SmartDrawHandler.CreateWithFirefly(prompt, choosePresetForDimensions) // Call exactly the same CreateWithFirefly that ImageBox uses .then(action(doc => { if (doc instanceof Doc) { this.BackgroundDoc = doc; // set the background image directly on the scrapbook } else { alert('Failed to generate document.'); } })) .catch(e => alert(`Generation error: ${e}`)) .finally(action(() => (this._loading = false))); // prettier-ignore }; // eslint-disable-next-line @typescript-eslint/no-unused-vars childRejectDrop = (de: DragManager.DropEvent, subView?: DocumentView) => true; // disable dropping documents onto any child of the scrapbook. // eslint-disable-next-line @typescript-eslint/no-unused-vars rejectDrop = (de: DragManager.DropEvent, subView?: DocumentView) => false; // allow all Docs to be dropped onto scrapbook -- let filterAddDocument make the final decision. /** * Filter function to determine if a document can be added to the scrapbook. * This checks if the document matches any of the placeholder slots in the scrapbook. * @param docs - The document(s) being added to the scrapbook. * @returns true if the document can be added, false otherwise. */ filterAddDocument = (docs: Doc | Doc[]) => { toList(docs).forEach(doc => slotRealDocIntoPlaceholders(doc, DocUtils.unwrapPlaceholders(this.ScrapbookLayoutDocs))); return false; }; @computed get regenPrompt() { const allDocs = DocUtils.unwrapPlaceholders(this.ScrapbookLayoutDocs); // find all non-collections in scrapbook (e.g., placeholder content docs) const internalTagsSet = new Set(allDocs.flatMap(doc => StrListCast(doc.$tags_chat).filter(tag => !tag.startsWith?.('ASPECT_')))); const internalTags = Array.from(internalTagsSet).join(', '); return internalTags ? `Create a new scrapbook background featuring: ${internalTags}` : 'A serene mountain landscape at sunrise, ultra-wide, pastel sky, abstract, scrapbook background'; } render() { return (
{this.BackgroundDoc && }
} onClick={() => !this._loading && this.generateAiImage(this.regenPrompt)} />
); } } Docs.Prototypes.TemplateMap.set(DocumentType.SCRAPBOOK, { layout: { view: ScrapbookBox, dataField: 'items' }, options: { acl: '', _height: 200, _xMargin: 10, _yMargin: 10, _layout_fitWidth: false, _layout_autoHeight: true, _layout_reflowVertical: true, _layout_reflowHorizontal: true, _freeform_fitContentsToBox: true, defaultDoubleClick: 'ignore', systemIcon: 'BsImages', }, });