import { IReactionDisposer, ObservableMap, action, computed, makeObservable, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { ClientUtils, DashColor, returnFalse, returnZero } from '../../../ClientUtils'; import { emptyFunction, numberRange } from '../../../Utils'; import { Doc, NumListCast, StrListCast } from '../../../fields/Doc'; import { DocData } from '../../../fields/DocSymbols'; import { Id } from '../../../fields/FieldSymbols'; import { BoolCast, Cast, DateCast, NumCast, RTFCast, ScriptCast, StrCast } from '../../../fields/Types'; import { URLField } from '../../../fields/URLField'; import { gptImageLabel } from '../../apis/gpt/GPT'; import { DocumentType } from '../../documents/DocumentTypes'; import { DragManager } from '../../util/DragManager'; import { SnappingManager } from '../../util/SnappingManager'; import { Transform } from '../../util/Transform'; import { undoable } from '../../util/UndoManager'; import { StyleProp } from '../StyleProp'; import { DocumentView } from '../nodes/DocumentView'; import { GPTPopup, GPTPopupMode } from '../pdf/GPTPopup/GPTPopup'; import './CollectionCardDeckView.scss'; import { CollectionSubView } from './CollectionSubView'; import { FieldsDropdown } from '../FieldsDropdown'; import { Button, IconButton } from 'browndash-components'; import { faStar } from '@fortawesome/free-solid-svg-icons'; import { FaStar, FaHeart, FaRobot, FaCloud } from 'react-icons/fa'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { SettingsManager } from '../../util/SettingsManager'; import { Tooltip } from '@mui/material'; import { dropActionType } from '../../util/DropActionTypes'; import { List } from '../../../fields/List'; enum cardSortings { Time = 'time', Type = 'type', Color = 'color', Custom = 'custom', Chat = 'chat', None = '', } @observer export class CollectionCardView extends CollectionSubView() { private _dropDisposer?: DragManager.DragDropDisposer; private _childDocumentWidth = 600; // target width of a Doc... private _disposers: { [key: string]: IReactionDisposer } = {}; private _textToDoc = new Map(); @observable _forceChildXf = false; // @observable _isLoading = false; @observable _hoveredNodeIndex = -1; @observable _docRefs = new ObservableMap(); _draggerRef = React.createRef(); @observable _maxRowCount = 10; @observable _docDraggedIndex: number = -1; @observable _isACardBeingDragged: boolean = false; @observable overIndex: number = -1; static getButtonGroup(groupFieldKey: 'chat' | 'star' | 'idea' | 'like', doc: Doc): number | undefined { return Cast(doc[groupFieldKey], 'number', null); } static imageUrlToBase64 = async (imageUrl: string): Promise => { try { const response = await fetch(imageUrl); const blob = await response.blob(); return new Promise((resolve, reject) => { const reader = new FileReader(); reader.readAsDataURL(blob); reader.onloadend = () => resolve(reader.result as string); reader.onerror = error => reject(error); }); } catch (error) { console.error('Error:', error); throw error; } }; constructor(props: any) { super(props); makeObservable(this); this.setRegenerateCallback(); } setRegenerateCallback() { GPTPopup.Instance.setRegenerateCallback(this.childPairStringListAndUpdateSortDesc); } @action childPairStringListAndUpdateSortDesc = async () => { const sortDesc = await this.childPairStringList(); // Await the promise to get the string result GPTPopup.Instance.setSortDesc(sortDesc.join()); GPTPopup.Instance.onSortComplete = (sortResult: string) => this.processGptOutput(sortResult); }; componentDidMount(): void { this.Document.childFilters_boolean = 'OR' this._disposers.sort = reaction( () => ({ cardSort: this.cardSort }), ({ cardSort}) => (cardSort === cardSortings.Chat ? this.openChatPopup() : GPTPopup.Instance.setVisible(false)) ); } componentWillUnmount() { Object.keys(this._disposers).forEach(key => this._disposers[key]?.()); this._dropDisposer?.(); } @computed get cardSort_customField() { return StrCast(this.Document.cardSort_customField) as any as 'chat' | 'star' | 'idea' | 'like'; } @computed get cardSort() { return StrCast(this.Document.cardSort) as any as cardSortings; } /** * how much to scale down the contents of the view so that everything will fit */ @computed get fitContentScale() { const length = Math.min(this.childDocsWithoutLinks.length, this._maxRowCount); return (this._childDocumentWidth * length) / this._props.PanelWidth(); } /** * The child documents to be rendered-- either all of them except the Links or the docs in the currently active * custom group */ @computed get childDocsWithoutLinks() { const regularDocs = this.childDocs.filter(l => l.type !== DocumentType.LINK); const activeGroups = StrListCast(this.Document.cardSort_visibleSortGroups); if (activeGroups.length > 0) { return regularDocs.filter(doc => { const activeTags = StrListCast(doc.cardSort_activeIcons) return activeTags !== undefined && activeTags.some(tag => activeGroups.includes(tag)); }) } // Default return for non-custom cardSort or other cases, filtering out links return regularDocs; } /** * Number of rows of cards to be rendered */ @computed get numRows() { return Math.ceil(this.sortedDocs.length / 10); } @action setHoveredNodeIndex = (index: number) => { if (!DocumentView.SelectedDocs().includes(this.childDocs[index])) { this._hoveredNodeIndex = index; } }; /** * Translates the hovered node to the center of the screen * @param index * @returns */ translateHover = (index: number) => (this._hoveredNodeIndex === index && !DocumentView.SelectedDocs().includes(this.childDocs[index]) ? -50 : 0); isSelected = (index: number) => DocumentView.SelectedDocs().includes(this.childDocs[index]); /** * Returns all the documents except the one that's currently selected */ inactiveDocs = () => this.childDocsWithoutLinks.filter(d => !DocumentView.SelectedDocs().includes(d)); panelWidth = () => this._childDocumentWidth; panelHeight = (layout: Doc) => () => (this.panelWidth() * NumCast(layout._height)) / NumCast(layout._width); onChildDoubleClick = () => ScriptCast(this.layoutDoc.onChildDoubleClick); isContentActive = () => this._props.isSelected() || this._props.isContentActive() || this._props.isAnyChildContentActive(); isChildContentActive = () => !!this.isContentActive(); /** * Returns the degree to rotate a card dependind on the amount of cards in their row and their index in said row * @param amCards * @param index * @returns */ rotate = (amCards: number, index: number) => { if (amCards == 1) return 0; const possRotate = -30 + index * (30 / ((amCards - (amCards % 2)) / 2)); const stepMag = Math.abs(-30 + (amCards / 2 - 1) * (30 / ((amCards - (amCards % 2)) / 2))); if (amCards % 2 === 0 && possRotate === 0) { return possRotate + Math.abs(-30 + (index - 1) * (30 / (amCards / 2))); } if (amCards % 2 === 0 && index > (amCards + 1) / 2) { return possRotate + stepMag; } return possRotate; }; /** * Returns the degree to which a card should be translated in the y direction for the arch effect */ translateY = (amCards: number, index: number, realIndex: number) => { const evenOdd = amCards % 2; const apex = (amCards - evenOdd) / 2; const stepMag = 200 / ((amCards - evenOdd) / 2) + Math.abs((apex - index) * 25); let rowOffset = 0; if (realIndex > this._maxRowCount - 1) { rowOffset = 400 * ((realIndex - (realIndex % this._maxRowCount)) / this._maxRowCount); } if (evenOdd === 1 || index < apex - 1) { return Math.abs(stepMag * (apex - index)) - rowOffset; } if (index === apex || index === apex - 1) { return 0 - rowOffset; } return Math.abs(stepMag * (apex - index - 1)) - rowOffset; }; findCardDropIndex = (mouseX: number, mouseY: number, direction: 'left' | 'right') => { const amCardsTotal = this.sortedDocs.length let index = 0; const cardWidth = amCardsTotal < this._maxRowCount ? this._props.PanelWidth() / amCardsTotal : this._props.PanelWidth() / this._maxRowCount; // Calculate the adjusted X position accounting for the initial offset let adjustedX = mouseX const amRows = Math.ceil(amCardsTotal / this._maxRowCount); const rowHeight = this._props.PanelHeight( ) / amRows const currRow = Math.floor((mouseY - 100) / rowHeight) //rows start at 0 if (adjustedX < 0) { return 0; // Before the first column } if (amCardsTotal < this._maxRowCount) { index = Math.floor(adjustedX / cardWidth); } else if (currRow != amRows -1 ){ index = Math.floor(adjustedX / cardWidth) + (currRow * this._maxRowCount) } else { // console.log(amRows + "am rows") const rowAmCards = amCardsTotal - (currRow * this._maxRowCount) const offset = ((this._maxRowCount - rowAmCards ) / 2) * cardWidth adjustedX = mouseX - offset index = Math.floor(adjustedX / cardWidth) + (currRow * this._maxRowCount); } return index; }; @action onPointerMove = (e: React.PointerEvent) => { if (DragManager.docsBeingDragged.length != 0 ) { this._isACardBeingDragged = true const direction = e.movementX > 0 ? 'right' : 'left'; const newIndex = this.findCardDropIndex(e.clientX, e.clientY, direction); if (newIndex !== this._docDraggedIndex && newIndex != -1) { this._docDraggedIndex = newIndex; } } }; onInternalDrop = (e: Event, de: DragManager.DropEvent) => { if (de.complete.docDragData) { this._isACardBeingDragged = false; this._docDraggedIndex = -1; e.stopPropagation() const draggedDocs = de.complete.docDragData?.draggedDocuments; return true; } return false; }; @computed get sortedDocs() { // console.log("hi hi hi") // console.log(this.layoutDoc.cardSort_isDesc + "layoutdoc desc") console.log(this.cardSort + "card sort") return this.sort(this.childDocsWithoutLinks, this.cardSort, BoolCast(this.layoutDoc.cardSort_isDesc), this._docDraggedIndex); } /** * Called in the sortedDocsType method. Compares the cards' value in regards to the desired sort type-- earlier cards are move to the * front, latter cards to the back * @param docs * @param sortType * @param isDesc * @returns */ sort = (docs: Doc[], sortType: cardSortings, isDesc: boolean, dragIndex: number) => { // if (sortType === cardSortings.None) return docs; // if(sortType !== cardSortings.None){ docs.sort((docA, docB) => { const [typeA, typeB] = (() => { switch (sortType) { case cardSortings.Time: return [DateCast(docA.author_date)?.date ?? Date.now(), DateCast(docB.author_date)?.date ?? Date.now()]; case cardSortings.Color: return [ClientUtils.hexToHsv(StrCast(docA.backgroundColor)), ClientUtils.hexToHsv(StrCast(docB.backgroundColor))]; case cardSortings.Custom: return [CollectionCardView.getButtonGroup(this.cardSort_customField, docA) ?? 9999, CollectionCardView.getButtonGroup(this.cardSort_customField, docB) ?? 9999]; case cardSortings.Chat: return [NumCast(docA.chat) ?? 9999, NumCast(docB.chat) ?? 9999] default: return [StrCast(docA.type), StrCast(docB.type)] } })(); // console.log(`Sorting ${sortType}: ${typeA} vs ${typeB}`); const out = typeA < typeB ? -1 : typeA > typeB ? 1 : 0; // console.log(`Comparison result: ${out} (isDesc: ${isDesc})`); if (isDesc){ return out } return -out }); // } if (dragIndex != -1) { const draggedDoc = DragManager.docsBeingDragged[0]; const originalIndex = docs.findIndex(doc => doc === draggedDoc); docs.splice(originalIndex, 1); docs.splice(dragIndex, 0, draggedDoc); } return docs; }; displayDoc = (doc: Doc, screenToLocalTransform: () => Transform) => ( r?.ContentDiv && this._docRefs.set(doc, r))} Document={doc} NativeWidth={returnZero} NativeHeight={returnZero} fitWidth={returnFalse} onDoubleClickScript={this.onChildDoubleClick} renderDepth={this._props.renderDepth + 1} LayoutTemplate={this._props.childLayoutTemplate} LayoutTemplateString={this._props.childLayoutString} ScreenToLocalTransform={screenToLocalTransform} // makes sure the box wrapper thing is in the right spot isContentActive={emptyFunction} isDocumentActive={this._props.childDocumentsActive?.() || this.Document._childDocumentsActive ? this._props.isDocumentActive : this.isContentActive} PanelWidth={this.panelWidth} PanelHeight={this.panelHeight(doc)} dragAction={(this.Document.childDragAction ?? this._props.childDragAction) as dropActionType} dontHideOnDrag // pointerEvents={this.blockPointerEventsWhenDragging(doc)} /> ); /** * Determines how many cards are in the row of a card at a specific index * @param index * @returns */ overflowAmCardsCalc = (index: number) => { if (this.sortedDocs.length < this._maxRowCount) { return this.sortedDocs.length; } // 13 - 3 = 10 const totalCards = this.sortedDocs.length; // if 9 or less if (index < totalCards - (totalCards % 10)) { return this._maxRowCount; } // (3) return totalCards % 10; }; /** * Determines the index a card is in in a row * @param realIndex * @returns */ overflowIndexCalc = (realIndex: number) => realIndex % 10; /** * Translates the cards in the second rows and beyond over to the right * @param realIndex * @param calcIndex * @param calcRowCards * @returns */ translateOverflowX = (realIndex: number, calcRowCards: number) => (realIndex < this._maxRowCount ? 0 : (10 - calcRowCards) * (this.panelWidth() / 2)); /** * Determines how far to translate a card in the y direction depending on its index, whether or not its being hovered, or if it's selected * @param isHovered * @param isSelected * @param realIndex * @param amCards * @param calcRowIndex * @returns */ calculateTranslateY = (isHovered: boolean, isSelected: boolean, realIndex: number, amCards: number, calcRowIndex: number) => { const rowHeight = this._props.PanelHeight() * this.fitContentScale / this.numRows; const rowIndex = Math.trunc(realIndex / this._maxRowCount); const rowToCenterShift = (this.numRows / 2) - rowIndex; if (isSelected) return rowToCenterShift * rowHeight - rowHeight / 2; if (amCards == 1) return 50 * this.fitContentScale; // const trans = isHovered ? this.translateHover(realIndex) : 0; const trans = 0; return trans + this.translateY(amCards, calcRowIndex, realIndex); }; /** * A list of the text content of all the child docs. RTF documents will have just their text and pdf documents will have the first 50 words. * Image documents are converted to bse64 and gpt generates a description for them. all other documents use their title. This string is * inputted into the gpt prompt to sort everything together * @returns */ childPairStringList = () => { const docToText = (doc: Doc) => { switch (doc.type) { case DocumentType.PDF: return StrCast(doc.text).split(/\s+/).slice(0, 50).join(' '); // first 50 words of pdf text case DocumentType.IMG: return this.getImageDesc(doc); case DocumentType.RTF: return StrCast(RTFCast(doc.text).Text); default: return StrCast(doc.title); } // prettier-ignore }; const docTextPromises = this.childDocsWithoutLinks.map(async doc => { const docText = (await docToText(doc)) ?? ''; this._textToDoc.set(docText.replace(/\n/g, ' ').trim(), doc); return `======${docText.replace(/\n/g, ' ').trim()}======`; }); return Promise.all(docTextPromises); }; /** * Calls the gpt API to generate descriptions for the images in the view * @param image * @returns */ getImageDesc = async (image: Doc) => { if (StrCast(image.description)) return StrCast(image.description); // Return existing description const { href } = (image.data as URLField).url; const hrefParts = href.split('.'); const hrefComplete = `${hrefParts[0]}_o.${hrefParts[1]}`; try { const hrefBase64 = await CollectionCardView.imageUrlToBase64(hrefComplete); const response = await gptImageLabel(hrefBase64); image[DocData].description = response.trim(); return response; // Return the response from gptImageLabel } catch (error) { console.log('bad things have happened'); console.log(error); } return ''; }; /** * Converts the gpt output into a hashmap that can be used for sorting. lists are seperated by ==== while elements within the list are seperated by ~~~~~~ * @param gptOutput */ @action processGptOutput = (gptOutput: string) => { console.log("HIIII") console.log(StrCast(this.Document.cardSort) + "cardSort") // Split the string into individual list items const listItems = gptOutput.split('======').filter(item => item.trim() !== ''); // console.log(listItems + " LISTT"); // Debug: print the map contents // console.log("Map contents:", Array.from(this._textToDoc.entries())); listItems.forEach((item, index) => { // Normalize the item (trim whitespace) const normalizedItem = item.trim(); // console.log("Normalized item:", normalizedItem); // Find the corresponding Doc in the textToDoc map const doc = this._textToDoc.get(normalizedItem); // console.log("DOC:", doc); // console.log("ITEM:", normalizedItem); if (doc) { doc.chat = index; } else { console.warn(`No matching document found for item: ${normalizedItem}`); } }); } // ); // }; /** * Opens up the chat popup and starts the process for smart sorting. */ openChatPopup = async () => { GPTPopup.Instance.setVisible(true); GPTPopup.Instance.setMode(GPTPopupMode.CARD); const sortDesc = await this.childPairStringList(); // Await the promise to get the string result GPTPopup.Instance.setCardsDoneLoading(true); // Set dataDoneLoading to true after data is loaded GPTPopup.Instance.setSortDesc(sortDesc.join()); GPTPopup.Instance.onSortComplete = (sortResult: string) => this.processGptOutput(sortResult); }; /** * Renders the buttons to customize sorting depending on which group the card belongs to and the amount of total groups * @param doc * @param cardSort * @returns */ renderButtons = (doc: Doc, cardSort: cardSortings): JSX.Element | null => { // if (cardSort !== cardSortings.Custom) return null; const amButtons = 4 // const amButtons = Math.max( // 4, // this.childDocs?.reduce((set, d) => { // if (this.cardSort_customField) { // set.add(NumCast(d[this.cardSort_customField])); // } // return set; // }, new Set()).size ?? 0 // ); // const activeButtonIndex = CollectionCardView.getButtonGroup(this.cardSort_customField, doc); const totalWidth = amButtons * 72 + amButtons * 2 * 5 + 6; const iconMap: { [key: number]: any } = { 0: 'star', 1: 'heart', 2: 'cloud', 3: 'bolt' }; return (
{numberRange(amButtons).map(i => ( Click to add/remove this card from the {iconMap[i]} group
}> ))} ); }; /** * Toggles the buttons between on and off when creating custom sort groupings/changing those created by gpt * @param childPairIndex * @param buttonID * @param doc */ toggleButton = undoable((doc: Doc, icon: string) => { // this.cardSort_customField && (doc[this.cardSort_customField] = buttonID); // doc.cardSort_activeIcons = new List() // const list = StrListCast(doc.cardSort_activeIcons); // doc.cardSort_activeIcons = new List(list.includes(icon) ? list.filter(d => d !== icon) : [...list, icon]); BoolCast(doc[icon]) ? doc[icon] = false : doc[icon] = true // StrListCast(doc.cardSort_activeIcons).push(iconMap[buttonID]) }, 'toggle card tag'); getButtonIcon = (doc: Doc, icon: any): JSX.Element => { // const isActive = StrListCast(doc.cardSort_activeIcons).includes(icon) const isActive = doc[icon] // console.log(StrListCast(doc.cardSort_activeIcons)) const color = isActive ? '#4476f7' : '#323232'; return ; }; /** * Actually renders all the cards */ renderCards = () => { const anySelected = this.childDocs.some(doc => DocumentView.SelectedDocs().includes(doc)); const isEmpty = this.childDocsWithoutLinks.length === 0; const isDesc = BoolCast(this.Document.cardSort_isDesc) console.log(this.childDocsWithoutLinks.length + "length") if (isEmpty) { return ( Sorry ! There are no cards in this group ); } // Map sorted documents to their rendered components return this.sortedDocs.map((doc, index) => { const realIndex = this.sortedDocs.indexOf(doc); const calcRowIndex = this.overflowIndexCalc(realIndex); const amCards = this.overflowAmCardsCalc(realIndex); const isSelected = DocumentView.SelectedDocs().includes(doc); const isDragging = DragManager.docsBeingDragged.includes(doc); const childScreenToLocal = () => { this._forceChildXf; const dref = this._docRefs.get(doc); const { translateX, translateY, scale } = ClientUtils.GetScreenTransform(dref?.ContentDiv); return new Transform(-translateX + (dref?.centeringX || 0) * scale, -translateY + (dref?.centeringY || 0) * scale, 1) .scale(1 / scale).rotate(!isSelected ? -this.rotate(amCards, calcRowIndex) : 0); // prettier-ignore }; const translateIfSelected = () => { const indexInRow = index % this._maxRowCount; const rowIndex = Math.trunc(index / this._maxRowCount); const rowCenterIndex = Math.min(this._maxRowCount, this.sortedDocs.length - rowIndex * this._maxRowCount)/2; return (rowCenterIndex - indexInRow) * 100 - 50; } return (
{ // this turns off documentDecorations during a transition, then turns them back on afterward. SnappingManager.SetIsResizing(this.Document[Id]); setTimeout( action(() => { SnappingManager.SetIsResizing(undefined); this._forceChildXf = !this._forceChildXf; }), 900 ); }} style={{ width: this.panelWidth(), height: 'max-content', transform: `translateY(${this.calculateTranslateY(this._hoveredNodeIndex === index, isSelected, realIndex, amCards, calcRowIndex)}px) translateX(calc(${(isSelected ? translateIfSelected() : 0) + "% + " + this.translateOverflowX(realIndex, amCards)+"px"})) rotate(${!isSelected ? this.rotate(amCards, calcRowIndex) : 0}deg) scale(${isSelected ? 2 : this._hoveredNodeIndex === index ? 1.05 : 1})`, }} onMouseEnter={() => this.setHoveredNodeIndex(index)}> {this.displayDoc(doc, childScreenToLocal)} {this.renderButtons(doc, this.cardSort)}
); }); }; render() { const isEmpty = this.childDocsWithoutLinks.length === 0; const transformValue = `scale(${1 / this.fitContentScale})`; const heightValue = `${100 * this.fitContentScale}%`; return (
this.onPointerMove(e)} className="collectionCardView-outer" ref={(ele: HTMLDivElement | null) => this.createDashEventsTarget(ele)} style={{ background: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BackgroundColor), color: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Color), }}>
this.setHoveredNodeIndex(-1)}> {this.renderCards()}
); } }