aboutsummaryrefslogtreecommitdiff
path: root/src/client/views/collections/CollectionCardDeckView.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/views/collections/CollectionCardDeckView.tsx')
-rw-r--r--src/client/views/collections/CollectionCardDeckView.tsx513
1 files changed, 513 insertions, 0 deletions
diff --git a/src/client/views/collections/CollectionCardDeckView.tsx b/src/client/views/collections/CollectionCardDeckView.tsx
new file mode 100644
index 000000000..5f8ddd5c1
--- /dev/null
+++ b/src/client/views/collections/CollectionCardDeckView.tsx
@@ -0,0 +1,513 @@
+import { IReactionDisposer, ObservableMap, action, computed, makeObservable, observable, reaction } from 'mobx';
+import { observer } from 'mobx-react';
+import * as React from 'react';
+import { DashColor, Utils, numberRange, returnFalse, returnZero } from '../../../Utils';
+import { Doc, NumListCast } 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 { SelectionManager } from '../../util/SelectionManager';
+import { SnappingManager } from '../../util/SnappingManager';
+import { Transform } from '../../util/Transform';
+import { undoable } from '../../util/UndoManager';
+import { StyleProp } from '../StyleProvider';
+import { DocumentView } from '../nodes/DocumentView';
+import { GPTPopup, GPTPopupMode } from '../pdf/GPTPopup/GPTPopup';
+import './CollectionCardDeckView.scss';
+import { CollectionSubView } from './CollectionSubView';
+
+enum cardSortings {
+ Time = 'time',
+ Type = 'type',
+ Color = 'color',
+ Custom = 'custom',
+ 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<string, Doc>();
+
+ @observable _forceChildXf = false;
+ @observable _isLoading = false;
+ @observable _hoveredNodeIndex = -1;
+ @observable _docRefs = new ObservableMap<Doc, DocumentView>();
+ @observable _maxRowCount = 10;
+
+ static getButtonGroup(groupFieldKey: 'chat' | 'star' | 'idea' | 'like', doc: Doc): number | undefined {
+ return Cast(doc[groupFieldKey], 'number', null);
+ }
+
+ static imageUrlToBase64 = async (imageUrl: string): Promise<string> => {
+ 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;
+ }
+ };
+
+ protected createDashEventsTarget = (ele: HTMLDivElement | null) => {
+ this._dropDisposer?.();
+ if (ele) {
+ this._dropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.layoutDoc);
+ }
+ };
+
+ constructor(props: any) {
+ super(props);
+ makeObservable(this);
+ }
+
+ componentDidMount(): void {
+ this._disposers.sort = reaction(
+ () => ({ cardSort: this.cardSort, field: this.cardSort_customField }),
+ ({ cardSort, field }) => (cardSort === cardSortings.Custom && field === '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();
+ }
+
+ @computed get translateWrapperX() {
+ let translate = 0;
+
+ if (this.inactiveDocs().length !== this.childDocsWithoutLinks.length && this.inactiveDocs().length < 10) {
+ translate += this.panelWidth() / 2;
+ }
+ return translate;
+ }
+
+ /**
+ * 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 = NumListCast(this.Document.cardSort_visibleSortGroups);
+
+ if (activeGroups.length > 0 && this.cardSort === cardSortings.Custom) {
+ return regularDocs.filter(doc => {
+ // Get the group number for the current index
+ const groupNumber = CollectionCardView.getButtonGroup(this.cardSort_customField, doc);
+ // Check if the group number is in the active groups
+ return groupNumber !== undefined && activeGroups.includes(groupNumber);
+ });
+ }
+
+ // Default return for non-custom cardSort or other cases, filtering out links
+ return regularDocs;
+ }
+
+ /**
+ * Determines the order in which the cards will be rendered depending on the current sort type
+ */
+ @computed get sortedDocs() {
+ return this.sort(this.childDocsWithoutLinks, this.cardSort, BoolCast(this.layoutDoc.sortDesc));
+ }
+
+ @action
+ setHoveredNodeIndex = (index: number) => {
+ if (!SelectionManager.IsSelected(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 && !SelectionManager.IsSelected(this.childDocs[index]) ? -50 : 0);
+
+ isSelected = (index: number) => SelectionManager.IsSelected(this.childDocs[index]);
+
+ /**
+ * Returns all the documents except the one that's currently selected
+ */
+ inactiveDocs = () => this.childDocsWithoutLinks.filter(d => !SelectionManager.IsSelected(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() ? true : false);
+
+ /**
+ * 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) => {
+ 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;
+ };
+
+ /**
+ * Translates the selected node to the middle fo the screen
+ * @param index
+ * @returns
+ */
+ translateSelected = (index: number): number => {
+ // if (this.isSelected(index)) {
+ const middleOfPanel = this._props.PanelWidth() / 2;
+ const scaledNodeWidth = this.panelWidth() * 1.25;
+
+ // Calculate the position of the node's left edge before scaling
+ const nodeLeftEdge = index * this.panelWidth();
+ // Find the center of the node after scaling
+ const scaledNodeCenter = nodeLeftEdge + scaledNodeWidth / 2;
+
+ // Calculate the translation needed to align the scaled node's center with the panel's center
+ const translation = middleOfPanel - scaledNodeCenter - scaledNodeWidth - scaledNodeWidth / 4;
+
+ return translation;
+ };
+
+ /**
+ * 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) => {
+ if (sortType === cardSortings.None) return docs;
+ 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 [DashColor(StrCast(docA.backgroundColor)).hsv().toString(), // If docA.type is undefined, use an empty string
+ DashColor(StrCast(docB.backgroundColor)).hsv().toString()]; // If docB.type is undefined, use an empty string
+ case cardSortings.Custom:
+ return [CollectionCardView.getButtonGroup(this.cardSort_customField, docA)??0,
+ CollectionCardView.getButtonGroup(this.cardSort_customField, docB)??0];
+ default: return [StrCast(docA.type), // If docA.type is undefined, use an empty string
+ StrCast(docB.type)]; // If docB.type is undefined, use an empty string
+ } // prettier-ignore
+ })();
+
+ const out = typeA < typeB ? -1 : typeA > typeB ? 1 : 0;
+ return isDesc ? -out : out; // Reverse the sort order if descending is true
+ });
+
+ return docs;
+ };
+
+ displayDoc = (doc: Doc, screenToLocalTransform: () => Transform) => {
+ return (
+ <DocumentView
+ {...this._props}
+ ref={action((r: DocumentView) => r?.ContentDiv && this._docRefs.set(doc, r))}
+ Document={doc}
+ NativeWidth={returnZero}
+ NativeHeight={returnZero}
+ layout_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={this.isChildContentActive}
+ isDocumentActive={this._props.childDocumentsActive?.() || this.Document._childDocumentsActive ? this._props.isDocumentActive : this.isContentActive}
+ PanelWidth={this.panelWidth}
+ PanelHeight={this.panelHeight(doc)}
+ />
+ );
+ };
+
+ /**
+ * Determines how many cards are in the row of a card at a specific index
+ * @param index
+ * @returns
+ */
+ overflowAmCardsCalc = (index: number) => {
+ if (this.inactiveDocs().length < this._maxRowCount) {
+ return this.inactiveDocs().length;
+ }
+ // 13 - 3 = 10
+ const totalCards = this.inactiveDocs().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) => {
+ if (isSelected) return 50 * this.fitContentScale;
+ const trans = isHovered ? this.translateHover(realIndex) : 0;
+ return trans + this.translateY(amCards, calcRowIndex, realIndex);
+ };
+
+ /**
+ * 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((buttonID: number, doc: Doc) => this.cardSort_customField && (doc[this.cardSort_customField] = buttonID), 'toggle custom button');
+
+ /**
+ * 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: const words = StrCast(doc.text).split(/\s+/);
+ return words.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.trim(), doc);
+ return `======${docText.replace(/\n/g, ' ').trim()}======`;
+ });
+ return Promise.all<string>(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.href;
+ 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');
+ }
+ 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
+ */
+ processGptOutput = (gptOutput: string) => {
+ // Split the string into individual list items
+ const listItems = gptOutput.split('======').filter(item => item.trim() !== '');
+ listItems.forEach((item, index) => {
+ // Split the item by '~~~~~~' to get all descriptors
+ const parts = item.split('~~~~~~').map(part => part.trim());
+
+ parts.forEach(part => {
+ // Find the corresponding Doc in the textToDoc map
+ const doc = this._textToDoc.get(part);
+ if (doc) {
+ doc.chat = index;
+ }
+ });
+ });
+ };
+ /**
+ * Opens up the chat popup and starts the process for smart sorting.
+ */
+ openChatPopup = async () => {
+ GPTPopup.Instance.setVisible(true);
+ GPTPopup.Instance.setMode(GPTPopupMode.SORT);
+ 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 childPairIndex
+ * @param doc
+ * @returns
+ */
+ renderButtons = (doc: Doc, cardSort: cardSortings) => {
+ if (cardSort !== cardSortings.Custom) return '';
+ const amButtons = Math.max(4, this.childDocs?.reduce((set, doc) => this.cardSort_customField && set.add(NumCast(doc[this.cardSort_customField])), new Set<number>()).size ?? 0);
+ const activeButtonIndex = CollectionCardView.getButtonGroup(this.cardSort_customField, doc);
+ const totalWidth = amButtons * 35 + amButtons * 2 * 5 + 6;
+ return (
+ <div className="card-button-container" style={{ width: `${totalWidth}px` }}>
+ {numberRange(amButtons).map(i => (
+ <button
+ key={i}
+ type="button"
+ style={{ backgroundColor: activeButtonIndex === i ? '#4476f7' : '#323232' }} //
+ onClick={() => this.toggleButton(i, doc)}
+ />
+ ))}
+ </div>
+ );
+ };
+ /**
+ * Actually renders all the cards
+ */
+ renderCards = () => {
+ const anySelected = this.childDocs.some(doc => SelectionManager.IsSelected(doc));
+ // Map sorted documents to their rendered components
+ return this.sortedDocs.map((doc, index) => {
+ const realIndex = this.sortedDocs.filter(sortDoc => !SelectionManager.IsSelected(sortDoc)).indexOf(doc);
+ const calcRowIndex = this.overflowIndexCalc(realIndex);
+ const amCards = this.overflowAmCardsCalc(realIndex);
+ const isSelected = SelectionManager.IsSelected(doc);
+
+ const childScreenToLocal = () => {
+ this._forceChildXf;
+ const dref = this._docRefs.get(doc);
+ const { translateX, translateY, scale } = Utils.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
+ };
+
+ return (
+ <div
+ key={doc[Id]}
+ className={`card-item${isSelected ? '-active' : anySelected ? '-inactive' : ''}`}
+ onPointerUp={() => {
+ // this turns off documentDecorations during a transition, then turns them back on afterward.
+ SnappingManager.SetIsResizing(this.Document);
+ setTimeout(
+ action(() => {
+ SnappingManager.SetIsResizing(undefined);
+ this._forceChildXf = !this._forceChildXf;
+ }),
+ 700
+ );
+ }}
+ style={{
+ width: this.panelWidth(),
+ height: 'max-content', // this.panelHeight(childPair.layout)(),
+ transform: `translateY(${this.calculateTranslateY(this._hoveredNodeIndex === index, isSelected, realIndex, amCards, calcRowIndex)}px)
+ translateX(${isSelected ? this.translateSelected(calcRowIndex) : this.translateOverflowX(realIndex, amCards)}px)
+ rotate(${!isSelected ? this.rotate(amCards, calcRowIndex) : 0}deg)
+ scale(${isSelected ? 1.25 : 1})`,
+ }}
+ onMouseEnter={() => this.setHoveredNodeIndex(index)}>
+ {this.displayDoc(doc, childScreenToLocal)}
+ {this.renderButtons(doc, this.cardSort)}
+ </div>
+ );
+ });
+ };
+ render() {
+ return (
+ <div
+ className="collectionCardView-outer"
+ ref={this.createDashEventsTarget}
+ style={{
+ background: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BackgroundColor),
+ color: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Color),
+ }}>
+ <div
+ className="card-wrapper"
+ style={{
+ transform: ` scale(${1 / this.fitContentScale}) translateX(${this.translateWrapperX}px)`,
+ height: `${100 * this.fitContentScale}%`,
+ }}
+ onMouseLeave={() => this.setHoveredNodeIndex(-1)}>
+ {this.renderCards()}
+ </div>
+ </div>
+ );
+ }
+}