1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
|
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 { numberRange } 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 { 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, SubCollectionViewProps } 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: SubCollectionViewProps) {
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 'chat' | 'star' | 'idea' | 'like';
}
@computed get cardSort() {
return StrCast(this.Document.cardSort) 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 (!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) => {
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) => (
<DocumentView
// eslint-disable-next-line react/jsx-props-no-spreading
{...this._props}
ref={action((r: DocumentView) => 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={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: 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<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;
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, d) => this.cardSort_customField && set.add(NumCast(d[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 => DocumentView.SelectedDocs().includes(doc));
// Map sorted documents to their rendered components
return this.sortedDocs.map((doc, index) => {
const realIndex = this.sortedDocs.filter(sortDoc => !DocumentView.SelectedDocs().includes(sortDoc)).indexOf(doc);
const calcRowIndex = this.overflowIndexCalc(realIndex);
const amCards = this.overflowAmCardsCalc(realIndex);
const isSelected = DocumentView.SelectedDocs().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
};
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[Id]);
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) as string,
color: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Color) as string,
}}>
<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>
);
}
}
|