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 { 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 { 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'; import { computedFn } from 'mobx-utils'; enum cardSortings { Time = 'time', Type = 'type', Color = 'color', 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(); private _dropped = false; // set when a card doc has just moved and the drop method has been called - prevents the pointerUp method from hiding doc decorations (which needs to be done when clicking on a card to animate it to front/center) @observable _forceChildXf = 0; @observable _hoveredNodeIndex = -1; @observable _docRefs = new ObservableMap(); @observable _maxRowCount = 10; @observable _docDraggedIndex: 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._props.setContentViewBox?.(this); this._disposers.sort = reaction( () => GPTPopup.Instance.visible, isVis => { if (isVis) { this.openChatPopup(); } else { this.Document.cardSort = this.cardSort === cardSortings.Chat ? '' : this.Document.cardSort; } } ); // if card deck moves, then the child doc views are hidden so their screen to local transforms will return empty rectangles // when inquired from the dom (below in childScreenToLocal). When the doc is actually renders, we need to act like the // dash data just changed and trigger a React involidation with the correct data (read from the dom). this._disposers.child = reaction( () => [this.Document.x, this.Document.y], () => { if (!Array.from(this._docRefs.values()).every(dv => dv.ContentDiv?.getBoundingClientRect().width)) { setTimeout(action(() => this._forceChildXf++)); } } ); } 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-- everything other than ink/link docs (which are marks as being svg's) */ @computed get childDocsWithoutLinks() { return this.childDocs.filter(l => !l.layout_isSvg); } /** * 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); DocumentView.getDocumentView(this.childDocs[randomIndex])?.select(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 (!SnappingManager.IsDragging) this._hoveredNodeIndex = index; }; isSelected = (doc: Doc) => this._docRefs.get(doc)?.IsSelected; childPanelWidth = () => NumCast(this.layoutDoc.childPanelWidth, this._props.PanelWidth() / 2); childPanelHeight = () => this._props.PanelHeight() * this.fitContentScale; onChildDoubleClick = () => ScriptCast(this.layoutDoc.onChildDoubleClick); isContentActive = () => this._props.isSelected() || this._props.isContentActive() || this.isAnyChildContentActive(); isAnyChildContentActive = this._props.isAnyChildContentActive; /** * 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)); if (amCards % 2 === 0) { if (possRotate === 0) { return possRotate + Math.abs(-30 + (index - 1) * (30 / (amCards / 2))); } if (index > (amCards + 1) / 2) { const stepMag = Math.abs(-30 + (amCards / 2 - 1) * (30 / ((amCards - (amCards % 2)) / 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 */ @action onPointerMove = (x: number, y: number) => { this._docDraggedIndex = DragManager.docsBeingDragged.length ? this.findCardDropIndex(x, y) : -1; }; /** * 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); } this._dropped = true; } 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 leftmost 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 = (docsIn: Doc[], sortType: cardSortings, isDesc: boolean, dragIndex: number) => { const docs = docsIn.slice(); // need make new object list since sort() modifies the incoming list which confuses mobx caching sortType && docs.sort((docA, docB) => { const [typeA, typeB] = (() => { switch (sortType) { default: case cardSortings.Type: return [StrCast(docA.type), StrCast(docB.type)]; case cardSortings.Chat: return [NumCast(docA.chatIndex, 9999), NumCast(docB.chatIndex,9999)]; case cardSortings.Time: return [DateCast(docA.author_date)?.date ?? Date.now(), DateCast(docB.author_date)?.date ?? Date.now()]; case cardSortings.Color:return [DashColor(StrCast(docA.backgroundColor)).hsv().hue(), DashColor(StrCast(docB.backgroundColor)).hsv().hue()]; } })(); //prettier-ignore return (typeA < typeB ? -1 : typeA > typeB ? 1 : 0) * (isDesc ? 1 : -1); }); if (dragIndex !== -1) { const draggedDoc = DragManager.docsBeingDragged[0]; const originalIndex = docs.findIndex(doc => doc === draggedDoc); originalIndex !== -1 && docs.splice(originalIndex, 1); draggedDoc && docs.splice(dragIndex, 0, draggedDoc); } return docs; }; isChildContentActive = () => this._props.isContentActive?.() === false ? false : this._props.isDocumentActive?.() && (this._props.childDocumentsActive?.() || BoolCast(this.Document.childDocumentsActive)) ? true : this._props.childDocumentsActive?.() === false || this.Document.childDocumentsActive === false ? false : undefined; displayDoc = (doc: Doc, screenToLocalTransform: () => Transform) => ( (!r?.ContentDiv ? this._docRefs.delete(doc) : 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} containerViewPath={this.childContainerViewPath} ScreenToLocalTransform={screenToLocalTransform} // makes sure the box wrapper thing is in the right spot isDocumentActive={this._props.childDocumentsActive?.() || this.Document._childDocumentsActive ? this._props.isDocumentActive : this.isContentActive} PanelWidth={this.childPanelWidth} PanelHeight={this.childPanelHeight} 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={BoolCast(this.layoutDoc.showChildTags)} whenChildContentsActiveChanged={this._props.whenChildContentsActiveChanged} isContentActive={this.isChildContentActive} 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; } const totalCards = this.sortedDocs.length; // if 9 or less if (index < totalCards - (totalCards % this._maxRowCount)) { return this._maxRowCount; } 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 isActive * @param realIndex * @param amCards * @param calcRowIndex * @returns */ calculateTranslateY = (isHovered: boolean, isActive: 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 (isActive) 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(); }; childScreenToLocal = computedFn((doc: Doc, index: number, calcRowIndex: number, isSelected: boolean, amCards: number) => () => { // need to explicitly trigger an invalidation since we're reading everything from the Dom this._forceChildXf; this._props.ScreenToLocalTransform(); const dref = this._docRefs.get(doc); const { translateX, translateY, scale } = ClientUtils.GetScreenTransform(dref?.ContentDiv); if (!scale) return new Transform(0, 0, 0); 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 }); cardPointerUp = action((doc: Doc) => { // if a card doc has just moved, or a card is selected and in front, then ignore this event if (this.isSelected(doc) || this._dropped) { this._dropped = false; } else { // otherwise, turn off documentDecorations becase we're in a selection transition and want to avoid artifacts. // Turn them back on when the animation has completed and the render and backend structures are in synch SnappingManager.SetIsResizing(doc[Id]); setTimeout( action(() => { SnappingManager.SetIsResizing(undefined); this._forceChildXf++; }), 1000 ); } }); /** * Actually renders all the cards */ @computed get renderCards() { if (!this.childDocsWithoutLinks.length) { 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 view = DocumentView.getDocumentView(doc, this.DocumentView?.()); const childScreenToLocal = this.childScreenToLocal(doc, index, calcRowIndex, !!view?.IsContentActive, amCards); 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; }; const aspect = NumCast(doc.height) / NumCast(doc.width, 1); const vscale = Math.max(1,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 (
this.cardPointerUp(doc)} style={{ width: this.childPanelWidth(), height: 'max-content', transform: `translateY(${this.calculateTranslateY(this._hoveredNodeIndex === index, !!view?.IsContentActive, realIndex, amCards, calcRowIndex)}px) translateX(calc(${view?.IsContentActive ? translateIfSelected() : 0}% + ${this.translateOverflowX(realIndex, amCards)}px)) rotate(${!view?.IsContentActive ? this.rotate(amCards, calcRowIndex) : 0}deg) scale(${view?.IsContentActive ? `${Math.min(hscale, vscale) * 100}%` : this._hoveredNodeIndex === index ? 1.1 : 1})`, }} // prettier-ignore onPointerEnter={() => this.setHoveredNodeIndex(index)} onPointerLeave={() => this.setHoveredNodeIndex(-1)}> {this.displayDoc(doc, childScreenToLocal)}
); }); } render() { const isEmpty = this.childDocsWithoutLinks.length === 0; return (
this.createDashEventsTarget(ele)} onPointerLeave={action(() => (this._docDraggedIndex = -1))} onPointerMove={e => this.onPointerMove(...this._props.ScreenToLocalTransform().transformPoint(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.renderCards}
); } }