diff options
-rw-r--r-- | src/client/apis/gpt/GPT.ts | 15 | ||||
-rw-r--r-- | src/client/views/collections/collectionFreeForm/MarqueeView.scss | 2 | ||||
-rw-r--r-- | src/client/views/collections/collectionFreeForm/MarqueeView.tsx | 191 | ||||
-rw-r--r-- | src/client/views/nodes/ImageBox.tsx | 6 | ||||
-rw-r--r-- | src/client/views/nodes/VideoBox.tsx | 3 | ||||
-rw-r--r-- | src/client/views/nodes/scrapbook/ScrapbookBox.tsx | 165 | ||||
-rw-r--r-- | src/client/views/nodes/scrapbook/ScrapbookPicker.scss | 40 | ||||
-rw-r--r-- | src/client/views/nodes/scrapbook/ScrapbookPicker.tsx | 84 | ||||
-rw-r--r-- | src/client/views/nodes/scrapbook/ScrapbookPreset.tsx | 44 | ||||
-rw-r--r-- | src/client/views/nodes/scrapbook/scrapbookleftover.ts | 46 |
10 files changed, 474 insertions, 122 deletions
diff --git a/src/client/apis/gpt/GPT.ts b/src/client/apis/gpt/GPT.ts index 03fce21f7..4642d79eb 100644 --- a/src/client/apis/gpt/GPT.ts +++ b/src/client/apis/gpt/GPT.ts @@ -14,7 +14,7 @@ export const DocSeperator = '------'; export enum TextClassifications { Title = 'title', //a few words Caption = 'caption', //few sentences - LengthyDescription = 'lengthy description' } + LengthyDescription = 'lengthy' } enum GPTCallType { SUMMARY = 'summary', @@ -106,8 +106,10 @@ const callTypeMap: { [type in GPTCallType]: GPTCallOpts } = { model: 'gpt-4o', maxTokens: 2048, temp: 0.25, - prompt: `Based on the content of the text, provide six descriptive tags (single words) separated by spaces. - Finally, include a seventh more detailed summary phrase using underscores.` + prompt: `Based on the content of the the text, classify it into the + most appropriate category: '${TextClassifications.Title}', '${TextClassifications.Caption}', or '${TextClassifications.LengthyDescription}'. + Then provide five more descriptive tags (single words) separated by spaces. + Finally, include a more detailed summary phrase tag using underscores, for a total of seven tags.` }, describe: { model: 'gpt-4-vision-preview', maxTokens: 2048, temp: 0, prompt: 'Describe these images in 3-5 words' }, flashcard: { @@ -175,14 +177,15 @@ const callTypeMap: { [type in GPTCallType]: GPTCallOpts } = { prompt: `Generate an aesthetically pleasing scrapbook layout preset based on these items. Return your response as JSON in the format: [{ - "type": DocumentType.RTF or DocumentType.IMG or DocumentType.PDF + "type": rich text or image or pdf or video or collection "tag": a singular tag summarizing the document + "acceptTags": [a list of all relevant tags that this document accepts, like ['PERSON', 'LANDSCAPE']] "x": number, "y": number, - "width": number, + "width": number, **note: if it is in an image, please respect existing aspect ratio if it is provided "height": number }, ...] - If there are mutliple documents, you may include + If there are mutliple documents and you wish to nest documents into a collection for aesthetic purposes, you may include "children": [ { type: tag: diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.scss b/src/client/views/collections/collectionFreeForm/MarqueeView.scss index 7c9d0f6e1..b514b0911 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.scss +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.scss @@ -28,4 +28,4 @@ .marquee-legend::after { content: 'Press <space> for lasso'; } -} +}
\ No newline at end of file diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx index 0b91d628b..05d4cd81d 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx @@ -22,6 +22,7 @@ import { ObservableReactComponent } from '../../ObservableReactComponent'; import { MarqueeViewBounds } from '../../PinFuncs'; import { PreviewCursor } from '../../PreviewCursor'; import { DocumentView } from '../../nodes/DocumentView'; +import { OverlayDisposer } from '../../OverlayView'; import { OpenWhere } from '../../nodes/OpenWhere'; import { pasteImageBitmap } from '../../nodes/WebBoxRenderer'; import { FormattedTextBox } from '../../nodes/formattedText/FormattedTextBox'; @@ -30,8 +31,15 @@ import { ImageLabelBoxData } from './ImageLabelBox'; import { MarqueeOptionsMenu } from './MarqueeOptionsMenu'; import { StrListCast } from '../../../../fields/Doc'; import { requestAiGeneratedPreset, DocumentDescriptor } from '../../nodes/scrapbook/AIPresetGenerator'; +import { ScrapbookItemConfig } from '../../nodes/scrapbook/ScrapbookPreset'; +import { OverlayView } from '../../OverlayView'; +import { runInAction } from 'mobx'; +import { ScrapbookPicker } from '../../nodes/scrapbook/ScrapbookPicker'; +import { buildPlaceholdersFromConfigs, slotRealDocIntoPlaceholders } from '../../nodes/scrapbook/ScrapbookBox'; import './MarqueeView.scss'; +import { build } from 'xregexp'; + interface MarqueeViewProps { Doc: Doc; getContainerTransform: () => Transform; @@ -78,6 +86,16 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps @observable _labelsVisibile: boolean = false; @observable _lassoPts: [number, number][] = []; @observable _lassoFreehand: boolean = false; + // ─── New Observables for “Pick 1 of N AI Scrapbook” ─── + @observable aiChoices: Doc[] = []; // temporary hidden Scrapbook docs + @observable pickerX = 0; // popup x coordinate + @observable pickerY = 0; // popup y coordinate + @observable pickerVisible = false; // show/hide ScrapbookPicker + // This will hold the “disposer” function returned by addWindow() + private _overlayDisposer: OverlayDisposer | null = null; + + + @computed get Transform() { return this._props.getTransform(); @@ -277,7 +295,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.generateScrapbook = this.generateAiScrapbooks; MarqueeOptionsMenu.Instance.showMarquee = this.showMarquee; MarqueeOptionsMenu.Instance.hideMarquee = this.hideMarquee; MarqueeOptionsMenu.Instance.jumpTo(e.clientX, e.clientY); @@ -521,6 +539,17 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps }); + getAiPresetsDescriptors(): DocumentDescriptor[] { + const selected = this.marqueeSelect(false); + return selected.map((doc) => ({ + type: typeof doc.$type === 'string' ? doc.$type : 'UNKNOWN', + tags: (() => { + const s = new Set<string>(); + StrListCast(doc.$tags_chat ?? new List<string>()).forEach((t) => s.add(t)); + return Array.from(s); + })(), + })); + } generateScrapbook = action(async () => { @@ -528,28 +557,23 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps const selectedDocs = this.marqueeSelect(false); if (!selectedDocs.length) return; - const descriptors: DocumentDescriptor[] = selectedDocs.map(doc => ({ - type: typeof doc.$type === 'string' ? doc.$type : 'UNKNOWN', - tags: (() => { - const internalTagsSet = new Set<string>(); - StrListCast(doc.$tags_chat ?? new List<string>()).forEach(tag => { - internalTagsSet.add(tag); - }); - return Array.from(internalTagsSet); - })() - })); - + const descriptors = this.getAiPresetsDescriptors(); + if (descriptors.length === 0) { + alert('No documents selected to generate a scrapbook from!'); + return; + } const aiPreset = await requestAiGeneratedPreset(descriptors); if (!aiPreset.length) { alert("Failed to generate preset"); return; } - + const scrapbookPlaceholders: Doc[] = buildPlaceholdersFromConfigs(aiPreset); + /* const scrapbookPlaceholders: Doc[] = aiPreset.map(cfg => { const placeholderDoc = Docs.Create.TextDocument(cfg.tag); placeholderDoc.accepts_docType = cfg.type as DocumentType; - placeholderDoc.accepts_tagType = cfg.acceptTag ?? cfg.tag; + placeholderDoc.accepts_tagType = new List<string>(cfg.acceptTags ?? [cfg.tag]); const placeholder = new Doc(); placeholder.proto = placeholderDoc; @@ -560,7 +584,7 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps if (cfg.height != null) placeholder._height = cfg.height; return placeholder; - }); + });*/ const scrapbook = Docs.Create.ScrapbookDocument(scrapbookPlaceholders, { backgroundColor: '#e2ad32', @@ -570,6 +594,25 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps _height: 500, title: 'AI-generated Scrapbook' }); + + + + // 3) Now grab that new scrapbook’s flat placeholders + const flatPl = DocListCast(scrapbook[Doc.LayoutDataKey(scrapbook)]) as Doc[]; + const unwrap = (items: Doc[]): Doc[] => + items.flatMap(d => + d.$type === DocumentType.COL + ? unwrap(DocListCast(d[Doc.LayoutDataKey(d)])) + : [d] + ); + const allPlaceholders = unwrap(flatPl); + + // 4) Slot each selectedDocs[i] into the first matching placeholder + selectedDocs.forEach(realDoc => { + slotRealDocIntoPlaceholders(realDoc, allPlaceholders + ); + }); + const selected = this.marqueeSelect(false).map(d => { this._props.removeDocument?.(d); d.x = NumCast(d.x) - this.Bounds.left; @@ -589,6 +632,102 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps }); + + + /** Called when the user clicks one of the N thumbnails */ + @action + onScrapbookChoice(idx: number) { + const chosenDoc = this.aiChoices[idx]; + if (!chosenDoc) return; + + // 1) Move chosenDoc from off‐screen to the marquee area + const bounds = this.Bounds; + chosenDoc.x = bounds.left; + chosenDoc.y = bounds.top; + chosenDoc._width = NumCast(bounds.width || chosenDoc._width); + chosenDoc._height = NumCast(bounds.height || chosenDoc._height); + chosenDoc.$title = 'AI‐chosen scrapbook'; + + // 2) Remove the other temp docs + this.aiChoices.forEach((doc, i) => { + if (i !== idx) { + this.props.removeDocument?.(doc); + } + }); + + // 3) Clear state and close the popup + this.aiChoices = []; + this.pickerVisible = false; + } + + /** Called when user clicks outside or the “×” in ScrapbookPicker */ + @action + cancelScrapbookChoice() { + // 1) Remove all temp scrapbooks + this.aiChoices.forEach((doc) => { + this.props.removeDocument?.(doc); + }); + + // 2) Clear array and hide popup + this.aiChoices = []; + this.pickerVisible = false; + } + @action + generateAiScrapbooks = async () => { + const n = 3; // Number of AI scrapbook presets + const descriptors = this.getAiPresetsDescriptors(); + if (descriptors.length === 0) { + alert('No documents selected to generate a scrapbook from!'); + return; + } + + // 1) Start N parallel requests for JSON configs + const calls: Promise<ScrapbookItemConfig[]>[] = []; + for (let i = 0; i < n; i++) { + calls.push(requestAiGeneratedPreset(descriptors)); + } + + // 2) Optionally show a “spinner” overlay in‐line here if you like… + // (But for brevity, let’s omit that.) + + try { + const allConfigsArrays = await Promise.all(calls); + + runInAction(() => { + // 3) For each returned config array, build placeholders and create _hidden_ Scrapbook doc + this.aiChoices = allConfigsArrays.map((cfgArr, i) => { + const placeholders = buildPlaceholdersFromConfigs(cfgArr); + // Off‐screen ScrapbookDocument: + const tempSb: Doc = Docs.Create.ScrapbookDocument(placeholders, { + title: `AI Preset (temp ${i + 1})`, + x: -10000, // far off screen + y: -10000, + _width: 600, + _height: 600, + backgroundColor: 'transparent', + _layout_showFlash: false, // keep it hidden until chosen + }); + this.props.addDocument?.(tempSb); + tempSb.$tags_chat = new List<string>(['@ai_preset']); + return tempSb; + }); + + // 4) Compute where to show the popup in screen coords + const bounds = this.Bounds; + const screenTopLeft = this._props + .getContainerTransform() + .transformPoint(bounds.left, bounds.top); + this.pickerX = screenTopLeft[0] + 20; + this.pickerY = screenTopLeft[1] + 20; + this.pickerVisible = true; + }); + } catch (err) { + console.error('Error generating AI scrapbooks:', err); + alert('Failed to generate presets. Please try again.'); + } + }; + + @action marqueeCommand = (e: KeyboardEvent) => { const ee = e as unknown as KeyboardEvent & { propagationIsStopped?: boolean }; @@ -755,6 +894,7 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps }; render() { return ( + <> <div className="marqueeView" ref={r => { @@ -774,6 +914,27 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps {this._visible ? this.marqueeDiv : null} {this.props.children} </div> + {this.pickerVisible && ( + <div + className="marqueeView-scrapbook-overlay" + style={{ + position: 'absolute', + top: this.pickerY, + left: this.pickerX, + zIndex: 2000, // just ensure it floats above + }} + > + <ScrapbookPicker + choices={this.aiChoices} + x={this.pickerX} + y={this.pickerY} + onSelect={(i) => this.onScrapbookChoice(i)} + onCancel={() => this.cancelScrapbookChoice()} + /> + </div> +)} + </> + ); } } diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx index 9067f7e0c..2473f1c0a 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -141,6 +141,11 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { that describes the image.` ); + const { nativeWidth, nativeHeight } = this.nativeSize; + const aspectRatio = nativeWidth && nativeHeight + ? (nativeWidth / nativeHeight).toFixed(2) + : '1.00'; + // 4) normalize and prefix const label = raw .trim() @@ -152,6 +157,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { this.Document.$tags_chat = new List<string>(); tokens.forEach(tok => { (this.Document.$tags_chat as List<string>).push(tok)}); + (this.Document.$tags_chat as List<string>).push(`ASPECT_${aspectRatio}`); //!!! changed may 11 (this.Document.$tags_chat as List<string>).push(label); // 6) flip on “show tags” in the layout diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx index 0e7afbab1..404be6a1b 100644 --- a/src/client/views/nodes/VideoBox.tsx +++ b/src/client/views/nodes/VideoBox.tsx @@ -151,7 +151,8 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { const tokens = label.split(/\s+/); this.Document.$tags_chat = new List<string>(); tokens.forEach(tok => (this.Document.$tags_chat as List<string>).push(tok)); - + const aspect = this.player!.videoWidth / (this.player!.videoHeight || 1); + (this.Document.$tags_chat as List<string>).push(`ASPECT_${aspect}`); // 5) Turn on tag display in layout this.Document._layout_showTags = true; diff --git a/src/client/views/nodes/scrapbook/ScrapbookBox.tsx b/src/client/views/nodes/scrapbook/ScrapbookBox.tsx index 5dd02295c..eb997024b 100644 --- a/src/client/views/nodes/scrapbook/ScrapbookBox.tsx +++ b/src/client/views/nodes/scrapbook/ScrapbookBox.tsx @@ -3,7 +3,6 @@ import * as React from 'react'; import { Doc, DocListCast, StrListCast } from '../../../../fields/Doc'; import { List } from '../../../../fields/List'; import { emptyFunction } from '../../../../Utils'; -import axios from 'axios'; import { Docs } from '../../../documents/Documents'; import { DocumentType } from '../../../documents/DocumentTypes'; import { CollectionView } from '../../collections/CollectionView'; @@ -35,54 +34,51 @@ export function buildPlaceholdersFromConfigs(configs: ScrapbookItemConfig[]): Do for (const cfg of configs) { if (cfg.children && cfg.children.length) { - // --- nested container --- const childDocs = cfg.children.map(child => { const doc = Docs.Create.TextDocument("[placeholder] " + child.tag); doc.accepts_docType = child.type; + doc.accepts_tagType = new List<string>(child.acceptTags ?? [child.tag]); const ph = new Doc(); - ph.proto = doc; + ph.proto = doc; ph.original = doc; - ph.x = child.x; - ph.y = child.y; - if (child.width != null) ph._width = child.width; + 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; }); - // wrap those children in a stacking container 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 - } - ); + // Create a stacking document with the child placeholders + 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.proto = containerProto; ph.original = containerProto; - ph.x = cfg.x; - ph.y = cfg.y; - if (cfg.width != null) ph._width = cfg.width; + 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 --- + } + + else { const doc = Docs.Create.TextDocument("[placeholder] " + cfg.tag); doc.accepts_docType = cfg.type; - doc.accepts_tagType = cfg.acceptTag ?? cfg.tag; + doc.accepts_tagType = new List<string>(cfg.acceptTags ?? [cfg.tag]); const ph = new Doc(); - ph.proto = doc; + ph.proto = doc; ph.original = doc; - ph.x = cfg.x; - ph.y = cfg.y; - if (cfg.width != null) ph._width = cfg.width; + 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); } @@ -90,7 +86,54 @@ export function buildPlaceholdersFromConfigs(configs: ScrapbookItemConfig[]): Do return placeholders; } -// Scrapbook view: a container that lays out its child items in a grid/template +export function slotRealDocIntoPlaceholders( + realDoc: Doc, + placeholders: Doc[] +): boolean { + const realTags = new Set<string>( + StrListCast(realDoc.$tags_chat ?? new List<string>()) + .map(t => t.toLowerCase()) +); + + // Find placeholder with most matching tags + let bestMatch: Doc | null = null; + let maxMatches = 0; +/* + (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)))*/ + + placeholders.forEach(ph => { + // 1) Enforce that placeholder.accepts_docType === realDoc.$type + if (ph.accepts_docType !== realDoc.$type) { + // Skip this placeholder entirely if types do not match. + return; + }; + const phTagTypes = StrListCast(ph.accepts_tagType ?? new List<string>()) + .map(t => t.toLowerCase()); + console.log({ realTags, phTagTypes }); + const matches = phTagTypes.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'), + 0 + ); + return true; + } + + return false; +} + +// Scrapbook view: a container that lays out its child items in a template @observer export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @observable selectedPreset = getPresetNames()[0]; @@ -406,56 +449,19 @@ export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>() // 3) produce a flat list of every doc, unwrapping any number of nested COLs const allDocs: Doc[] = unwrap(slots); - + if (docs?.length === 1) { + return slotRealDocIntoPlaceholders( + docs[0], + allDocs, + ) + ? false + : false; + } - if (docs?.length === 1) { - const placeholder = allDocs.filter(d => + return false; +}; + - (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 - - // 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; - } - } - return false; - }; @@ -474,10 +480,15 @@ export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>() allDocs.forEach(doc => { const tags = StrListCast(doc.$tags_chat ?? new List<string>()); - tags.forEach(tag => internalTagsSet.add(tag)); + tags.forEach(tag => + {if (!tag.startsWith("ASPECT_")) { + internalTagsSet.add(tag); + } + }); }); const internalTags = Array.from(internalTagsSet).join(', '); + return internalTags ? `Create a new scrapbook background featuring: ${internalTags}` diff --git a/src/client/views/nodes/scrapbook/ScrapbookPicker.scss b/src/client/views/nodes/scrapbook/ScrapbookPicker.scss new file mode 100644 index 000000000..237274433 --- /dev/null +++ b/src/client/views/nodes/scrapbook/ScrapbookPicker.scss @@ -0,0 +1,40 @@ +/* ScrapbookPicker.scss */ + +.scrapbook-picker-popup { + background: rgba(255, 0, 0, 0.5); /* semi-transparent red */ + position: absolute; /* ← make it float */ + z-index: 10000; /* so it sits above the overlay‐window’s background */ + border: 1px solid #ccc; + border-radius: 4px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); + padding: 8px; + min-width: 200px; /* at least give it some size */ +} + +.scrapbook-picker-close { + position: absolute; + top: 4px; + right: 8px; + cursor: pointer; + font-size: 14px; +} + +.scrapbook-picker-thumbnails { + margin-top: 24px; /* room under the close button */ + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.scrapbook-picker-thumb { + cursor: pointer; + border: 1px solid #ddd; + border-radius: 2px; + padding: 4px; + background: #f9f9f9; +} + +.scrapbook-picker-thumb-inner { + font-size: 12px; + text-align: center; +} diff --git a/src/client/views/nodes/scrapbook/ScrapbookPicker.tsx b/src/client/views/nodes/scrapbook/ScrapbookPicker.tsx new file mode 100644 index 000000000..6054cb98d --- /dev/null +++ b/src/client/views/nodes/scrapbook/ScrapbookPicker.tsx @@ -0,0 +1,84 @@ +// src/client/views/nodes/scrapbook/ScrapbookPicker.tsx +import * as React from 'react'; +import { observer } from 'mobx-react'; +import { Doc } from '../../../../fields/Doc'; +import { Id } from '../../../../fields/FieldSymbols'; +import { StrCast } from '../../../../fields/Types'; +import './ScrapbookPicker.scss'; + +export interface ScrapbookPickerProps { + choices: Doc[]; + x: number; + y: number; + onSelect: (index: number) => void; + onCancel: () => void; +} + +/** + * A floating popup that shows N “temporary” Scrapbook documents. + * When the user clicks one thumbnail, we call onSelect(i). + * When the user clicks × or outside, we call onCancel(). + * + * This component itself does not control its own visibility; MarqueeView / OverlayView will mount/unmount it. + */ +@observer +export class ScrapbookPicker extends React.Component<ScrapbookPickerProps> { + containerRef = React.createRef<HTMLDivElement>(); + + // Close when user clicks outside the popup + handleClickOutside = (e: MouseEvent) => { + if ( + this.containerRef.current && + !this.containerRef.current.contains(e.target as Node) + ) { + this.props.onCancel(); + } + }; + + componentDidMount() { + document.addEventListener('mousedown', this.handleClickOutside); + } + componentWillUnmount() { + document.removeEventListener('mousedown', this.handleClickOutside); + } + + render() { + const { choices, x, y, onSelect, onCancel } = this.props; + return ( + <div + className="scrapbook-picker-popup" + ref={this.containerRef} + style={{ + top: `${y}px`, + left: `${x}px`, + }} + > + {/* close icon */} + <div className="scrapbook-picker-close" onClick={onCancel}> + × + </div> + <div className="scrapbook-picker-thumbnails"> + {choices.map((doc, i) => { + // We simply show a small thumbnail representation of each temp scrapbook + // You could replace this with DocumentThumbnail or a custom mini‐preview. + return ( + <div + key={`${doc[Id]}_${i}`} + className="scrapbook-picker-thumb" + onClick={() => onSelect(i)} + > + {/* + For a minimal example, use the document’s title or ID as a placeholder. + In a real version, you might render a proper thumbnail/view of doc. + */} + <div className="scrapbook-picker-thumb-inner"> + {StrCast(doc.title)} + </div> + </div> + ); + })} + </div> + </div> + ); + } +} diff --git a/src/client/views/nodes/scrapbook/ScrapbookPreset.tsx b/src/client/views/nodes/scrapbook/ScrapbookPreset.tsx index 87821c7bf..96a8e9b5f 100644 --- a/src/client/views/nodes/scrapbook/ScrapbookPreset.tsx +++ b/src/client/views/nodes/scrapbook/ScrapbookPreset.tsx @@ -13,7 +13,7 @@ export interface ScrapbookItemConfig { /** text shown in the placeholder bubble */ tag: string; /** what this slot actually accepts (defaults to `tag`) */ - acceptTag?: string; + acceptTags?: string[]; x: number; y: number; @@ -48,22 +48,22 @@ export class ScrapbookPreset { return [ { type: DocumentType.IMG, tag: '[placeholder] LANDSCAPE', - acceptTag: 'LANDSCAPE', + acceptTags: ['LANDSCAPE'], x: 0, y: -100, width: 250, height: 200 }, { type: DocumentType.RTF, tag: '[placeholder] caption', - acceptTag: 'caption', + acceptTags: ['caption'], x: 0, y: 200, width: 250, height: 50 }, { type: DocumentType.RTF, tag: '[placeholder] lengthy description', - acceptTag: 'lengthy description', + acceptTags: ['lengthy description'], x: 280, y: -50, width: 50, height: 200 }, { type: DocumentType.IMG, tag: '[placeholder] PERSON', - acceptTag: 'PERSON', + acceptTags: ['PERSON'], x: -200, y: -100, width: 100, height: 200 }, ]; @@ -71,12 +71,12 @@ export class ScrapbookPreset { private static createGalleryPreset(): ScrapbookItemConfig[] { return [ - { type: DocumentType.IMG, tag: 'Gallery 1', acceptTag: 'LANDSCAPE', x: -150, y: -150, width: 150, height: 150 }, - { type: DocumentType.IMG, tag: 'Gallery 2', acceptTag: 'LANDSCAPE', x: 0, y: -150, width: 150, height: 150 }, - { type: DocumentType.IMG, tag: 'Gallery 3', acceptTag: 'LANDSCAPE', x: 150, y: -150, width: 150, height: 150 }, - { type: DocumentType.IMG, tag: 'Gallery 4', acceptTag: 'LANDSCAPE', x: -150, y: 0, width: 150, height: 150 }, - { type: DocumentType.IMG, tag: 'Gallery 5', acceptTag: 'LANDSCAPE', x: 0, y: 0, width: 150, height: 150 }, - { type: DocumentType.IMG, tag: 'Gallery 6', acceptTag: 'LANDSCAPE', x: 150, y: 0, width: 150, height: 150 }, + { type: DocumentType.IMG, tag: 'Gallery 1', acceptTags: ['LANDSCAPE'], x: -150, y: -150, width: 150, height: 150 }, + { type: DocumentType.IMG, tag: 'Gallery 2', acceptTags: ['LANDSCAPE'], x: 0, y: -150, width: 150, height: 150 }, + { type: DocumentType.IMG, tag: 'Gallery 3', acceptTags: ['LANDSCAPE'], x: 150, y: -150, width: 150, height: 150 }, + { type: DocumentType.IMG, tag: 'Gallery 4', acceptTags: ['LANDSCAPE'], x: -150, y: 0, width: 150, height: 150 }, + { type: DocumentType.IMG, tag: 'Gallery 5', acceptTags: ['LANDSCAPE'], x: 0, y: 0, width: 150, height: 150 }, + { type: DocumentType.IMG, tag: 'Gallery 6', acceptTags: ['LANDSCAPE'], x: 150, y: 0, width: 150, height: 150 }, ]; } @@ -85,17 +85,17 @@ export class ScrapbookPreset { return [ { type: DocumentType.IMG, tag: 'image', - acceptTag: 'LANDSCAPE', + acceptTags: ['LANDSCAPE'], x: 0, y: -100, width: 250, height: 200 }, { type: DocumentType.RTF, tag: 'summary', - acceptTag: 'caption', + acceptTags: ['caption'], x: 0, y: 200, width: 250 }, { type: DocumentType.RTF, tag: 'sidebar', - acceptTag: 'lengthy description', + acceptTags: ['lengthy description'], x: 280, y: -50, width: 50, height: 200 }, { @@ -106,7 +106,7 @@ export class ScrapbookPreset { children: [ { type: DocumentType.IMG, tag: 'image internal', - acceptTag: 'PERSON', + acceptTags: ['PERSON'], x: 0, y: 0, width: 50, height: 100 } ] @@ -118,22 +118,22 @@ export class ScrapbookPreset { return [ { type: DocumentType.IMG, tag: 'LANDSCAPE', - acceptTag: 'LANDSCAPE', + acceptTags: ['LANDSCAPE'], x: -150, y: -150, width: 150, height: 150 }, { type: DocumentType.IMG, tag: 'PERSON', - acceptTag: 'PERSON', + acceptTags: ['PERSON'], x: 0, y: -150, width: 150, height: 150 }, { type: DocumentType.RTF, tag: 'caption', - acceptTag: 'caption', + acceptTags: ['caption'], x: -150, y: 0, width: 300, height: 100 }, { type: DocumentType.RTF, tag: 'lengthy description', - acceptTag: 'lengthy description', + acceptTags: ['lengthy description'], x: 0, y: 100, width: 300, height: 100 } ]; @@ -143,17 +143,17 @@ export class ScrapbookPreset { return [ { type: DocumentType.RTF, tag: 'title', - acceptTag: 'title', + acceptTags: ['title'], x: 0, y: -180, width: 300, height: 40 }, { type: DocumentType.IMG, tag: 'LANDSCAPE', - acceptTag: 'LANDSCAPE', + acceptTags: ['LANDSCAPE'], x: 0, y: 0, width: 300, height: 200 }, { type: DocumentType.RTF, tag: 'caption', - acceptTag: 'caption', + acceptTags: ['caption'], x: 0, y: 230, width: 300, height: 50 } ]; diff --git a/src/client/views/nodes/scrapbook/scrapbookleftover.ts b/src/client/views/nodes/scrapbook/scrapbookleftover.ts new file mode 100644 index 000000000..2f381ab95 --- /dev/null +++ b/src/client/views/nodes/scrapbook/scrapbookleftover.ts @@ -0,0 +1,46 @@ + + + + + //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 + + // 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; + } + } + return false; + };
\ No newline at end of file |