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 } from '../../../Utils'; import { Doc } from '../../../fields/Doc'; import { DocData } from '../../../fields/DocSymbols'; import { Id } from '../../../fields/FieldSymbols'; import { List } from '../../../fields/List'; import { BoolCast, DateCast, DocCast, 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 { dropActionType } from '../../util/DropActionTypes'; import { SelectionManager } from '../../util/SelectionManager'; import { SnappingManager } from '../../util/SnappingManager'; import { Transform } from '../../util/Transform'; import { undoable } from '../../util/UndoManager'; import { StyleProp } from '../StyleProp'; import { TagItem } from '../TagsView'; import { DocumentView } from '../nodes/DocumentView'; import { GPTPopup, GPTPopupMode } from '../pdf/GPTPopup/GPTPopup'; import './CollectionCardDeckView.scss'; import { CollectionSubView, SubCollectionViewProps } from './CollectionSubView'; enum cardSortings { Time = 'time', Type = 'type', Color = 'color', Custom = 'custom', Chat = 'chat', Tag = 'tag', None = '', } /** * New view type specifically for studying more dynamically. Allows you to reorder docs however you see fit, easily * sort and filter using presets, and customize your experience with chat gpt. * * This file contains code as to how the docs are to be rendered (there place geographically and also in regards to sorting), * and callback functions for the gpt popup */ @observer export class CollectionCardView extends CollectionSubView() { private _dropDisposer?: DragManager.DragDropDisposer; private _disposers: { [key: string]: IReactionDisposer } = {}; private _textToDoc = new Map(); @observable _forceChildXf = false; @observable _hoveredNodeIndex = -1; @observable _docRefs = new ObservableMap(); @observable _maxRowCount = 10; @observable _docDraggedIndex: number = -1; @observable overIndex: number = -1; 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: SubCollectionViewProps) { super(props); makeObservable(this); this.setRegenerateCallback(); } protected createDashEventsTarget = (ele: HTMLDivElement | null) => { this._dropDisposer?.(); if (ele) { this._dropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.layoutDoc); } }; /** * Callback to ensure gpt's text versions of the child docs are updated */ setRegenerateCallback = () => GPTPopup.Instance.setRegenerateCallback(this.childPairStringListAndUpdateSortDesc); /** * update's gpt's doc-text list and initializes callbacks */ @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, questionType: string, tag?: string) => this.processGptOutput(sortResult, questionType, tag); GPTPopup.Instance.onQuizRandom = () => this.quizMode(); }; componentDidMount() { this.Document.childFilters_boolean = 'OR'; // bcz: really shouldn't be assigning to fields from within didMount -- this should be a default/override beahavior somehow // Reaction to cardSort changes this._disposers.sort = reaction( () => GPTPopup.Instance.visible, isVis => { if (isVis) { this.openChatPopup(); } else { this.Document.cardSort = this.cardSort === cardSortings.Chat ? '' : this.Document.cardSort; } } ); } componentWillUnmount() { Object.keys(this._disposers).forEach(key => this._disposers[key]?.()); this._dropDisposer?.(); } @computed get cardSort() { return StrCast(this.Document.cardSort) as cardSortings; } /** * 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() { return this.childDocs.filter(l => l.type !== DocumentType.LINK); } /** * 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.childPanelWidth() * length) / this._props.PanelWidth(); } /** * When in quiz mode, randomly selects a document */ quizMode = () => { const randomIndex = Math.floor(Math.random() * this.childDocs.length); SelectionManager.DeselectAll(); DocumentView.SelectView(DocumentView.getDocumentView(this.childDocs[randomIndex]), false); }; /** * Number of rows of cards to be rendered */ @computed get numRows() { return Math.ceil(this.sortedDocs.length / this._maxRowCount); } @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)); childPanelWidth = () => NumCast(this.layoutDoc.childPanelWidth, this._props.PanelWidth() / 2); 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 Magnitude = this.childPanelWidth() / 2; // 400 const stepMag = Magnitude / 2 / ((amCards - evenOdd) / 2) + Math.abs((apex - index) * 25); let rowOffset = 0; if (realIndex > this._maxRowCount - 1) { rowOffset = Magnitude * ((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; }; /** * When dragging a card, determines the index the card should be set to if dropped * @param mouseX mouse's x location * @param mouseY mouses' y location * @returns the card's new index */ findCardDropIndex = (mouseX: number, mouseY: number) => { 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 { 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; }; /** * Checks to see if a card is being dragged and calls the appropriate methods if so * @param e the current pointer event */ @action onPointerMove = (x: number, y: number) => { this._docDraggedIndex = -1; if (DragManager.docsBeingDragged.length) { const newIndex = this.findCardDropIndex(x, y); if (newIndex !== this._docDraggedIndex && newIndex != -1) { this._docDraggedIndex = newIndex; } } }; /** * Handles external drop of images/PDFs etc from outside Dash. */ onExternalDrop = async (e: React.DragEvent): Promise => { super.onExternalDrop(e, {}); }; /** * Resets all the doc dragging vairables once a card is dropped * @param e * @param de drop event * @returns true if a card has been dropped, falls if not */ onInternalDrop = undoable( action((e: Event, de: DragManager.DropEvent) => { if (de.complete.docDragData) { const dragIndex = this._docDraggedIndex; const draggedDoc = DragManager.docsBeingDragged[0]; if (dragIndex > -1 && draggedDoc) { this._docDraggedIndex = -1; const sorted = this.sortedDocs; const originalIndex = sorted.findIndex(doc => doc === draggedDoc); this.Document.cardSort = ''; originalIndex !== -1 && sorted.splice(originalIndex, 1); sorted.splice(dragIndex, 0, draggedDoc); if (de.complete.docDragData.removeDocument?.(draggedDoc)) { this.dataDoc[this.fieldKey] = new List(sorted); } } e.stopPropagation(); return true; } return false; }), '' ); @computed get sortedDocs() { return this.sort(this.childDocsWithoutLinks, this.cardSort, BoolCast(this.Document.cardSort_isDesc), this._docDraggedIndex); } /** * Used to determine how to sort cards based on tags. The lestmost tags are given lower values while cards to the right are * given higher values. Decimals are used to determine placement for cards with multiple tags * @param doc the doc whose value is being determined * @returns its value based on its tags */ tagValue = (doc: Doc) => Doc.MyFilterHotKeys.map((key, i) => ({ has: TagItem.docHasTag(doc, StrCast(key.toolType)), i })) .filter(({ has }) => has) .map(({ i }) => i) .join('.'); /** * 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) => { sortType && 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: { const d1 = DashColor(StrCast(docA.backgroundColor)); const d2 = DashColor(StrCast(docB.backgroundColor)); return [d1.hsv().hue(), d2.hsv().hue()]; } case cardSortings.Tag: return [this.tagValue(docA) ?? 9999, this.tagValue(docB) ?? 9999]; case cardSortings.Chat: return [NumCast(docA.chatIndex) ?? 9999, NumCast(docB.chatIndex) ?? 9999]; default: return [StrCast(docA.type), StrCast(docB.type)]; } })(); const out = typeA < typeB ? -1 : typeA > typeB ? 1 : 0; return isDesc ? out : -out; }); if (dragIndex != -1) { const draggedDoc = DragManager.docsBeingDragged[0]; const originalIndex = docs.findIndex(doc => doc === draggedDoc); originalIndex !== -1 && docs.splice(originalIndex, 1); docs.splice(dragIndex, 0, draggedDoc); } return [...docs]; // need to spread docs into a new object list since sort() modifies the incoming list which confuses mobx caching }; 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.childPanelWidth} PanelHeight={() => this._props.PanelHeight() * this.fitContentScale} dontCenter="y" // Don't center it vertically, because the grid it's in is already doing that and we don't want to do it twice. dragAction={(this.Document.childDragAction ?? this._props.childDragAction) as dropActionType} showTags={true} dontHideOnDrag /> ); /** * 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 % this._maxRowCount)) { return this._maxRowCount; } // (3) return totalCards % this._maxRowCount; }; /** * Determines the index a card is in in a row * @param realIndex * @returns */ overflowIndexCalc = (realIndex: number) => realIndex % this._maxRowCount; /** * 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 : (this._maxRowCount - calcRowCards) * (this.childPanelWidth() / 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; return 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)) ?? ''; doc.gptInputText = docText; 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(error); } return ''; }; /** * Processes gpt's output depending on the type of question the user asked. Converts gpt's string output to * usable code * @param gptOutput */ @action processGptOutput = undoable((gptOutput: string, questionType: string, tag?: string) => { // Split the string into individual list items const listItems = gptOutput.split('======').filter(item => item.trim() !== ''); if (questionType === '2' || questionType === '4') { this.childDocs.forEach(d => { d.chatFilter = false; }); } if (questionType === '6') { this.Document.cardSort = 'chat'; } listItems.forEach((item, index) => { const normalizedItem = item.trim(); // find the corresponding Doc in the textToDoc map const doc = this._textToDoc.get(normalizedItem); if (doc) { switch (questionType) { case '6': doc.chatIndex = index; break; case '1': { const allHotKeys = Doc.MyFilterHotKeys; let myTag = ''; if (tag) { for (let i = 0; i < allHotKeys.length; i++) { // bcz: CHECK THIS CODE OUT -- SOMETHING CHANGED const keyTag = StrCast(allHotKeys[i].toolType); if (tag.includes(keyTag)) { myTag = keyTag; break; } } if (myTag != '') { doc[myTag] = true; } } break; } case '2': case '4': doc.chatFilter = true; Doc.setDocFilter(DocCast(this.Document.embedContainer), 'chatFilter', true, 'match'); break; } } 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); GPTPopup.Instance.setCardsDoneLoading(true); // Set dataDoneLoading to true after data is loaded await this.childPairStringListAndUpdateSortDesc(); }; /** * Actually renders all the cards */ @computed get renderCards() { const sortedDocs = this.sortedDocs; const anySelected = this.childDocs.some(doc => DocumentView.SelectedDocs().includes(doc)); const isEmpty = this.childDocsWithoutLinks.length === 0; if (isEmpty) { return ( Sorry ! There are no cards in this group ); } // Map sorted documents to their rendered components return sortedDocs.map((doc, index) => { const realIndex = sortedDocs.indexOf(doc); const calcRowIndex = this.overflowIndexCalc(realIndex); const amCards = this.overflowAmCardsCalc(realIndex); const view = DocumentView.getDocumentView(doc, this.DocumentView?.()); const isSelected = view?.ComponentView?.isAnyChildContentActive?.() || view?.IsSelected ? true : false; 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, sortedDocs.length - rowIndex * this._maxRowCount) / 2; return (rowCenterIndex - indexInRow) * 100 - 50; }; const aspect = NumCast(doc.height) / NumCast(doc.width, 1); const vscale = Math.min((this._props.PanelHeight() * 0.95 * this.fitContentScale) / (aspect * this.childPanelWidth()), (this._props.PanelHeight() - 80) / (aspect * (this._props.PanelWidth() / 10))); // prettier-ignore const hscale = Math.min(this.sortedDocs.length, this._maxRowCount) / 2; // bcz: hack - the grid is divided evenly into maxRowCount cells, so the max scaling would be maxRowCount -- but making things that wide is ugly, so cap it off at half the window size return (
{ if (DocumentView.SelectedDocs().includes(doc)) return; // this turns off documentDecorations during a transition, then turns them back on afterward. SnappingManager.SetIsResizing(doc[Id]); setTimeout( action(() => { SnappingManager.SetIsResizing(undefined); this._forceChildXf = !this._forceChildXf; }), 900 ); }} style={{ width: this.childPanelWidth(), 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 ? `${Math.min(hscale, vscale) * 100}%` : this._hoveredNodeIndex === index ? 1.05 : 1})`, }} // prettier-ignore onMouseEnter={() => this.setHoveredNodeIndex(index)}> {this.displayDoc(doc, childScreenToLocal)}
); }); } render() { const isEmpty = this.childDocsWithoutLinks.length === 0; return (
this.createDashEventsTarget(ele)} onPointerMove={e => this.onPointerMove(e.clientX, e.clientY)} onDrop={this.onExternalDrop.bind(this)} style={{ background: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BackgroundColor) as string, color: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Color) as string, }}>
this.setHoveredNodeIndex(-1)}> {this.renderCards}
); } }