import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import * as CSS from 'csstype'; import { action, computed, IReactionDisposer, makeObservable, observable, ObservableMap, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import { computedFn } from 'mobx-utils'; import * as React from 'react'; import { ClientUtils, returnFalse, returnNever, returnZero, setupMoveUpEvents } from '../../../ClientUtils'; import { emptyFunction } from '../../../Utils'; import { Doc, DocListCast, Opt } from '../../../fields/Doc'; import { Animation } from '../../../fields/DocSymbols'; import { Id } from '../../../fields/FieldSymbols'; import { List } from '../../../fields/List'; import { ScriptField } from '../../../fields/ScriptField'; import { BoolCast, DocCast, NumCast, ScriptCast, StrCast } from '../../../fields/Types'; import { DocumentType } from '../../documents/DocumentTypes'; import { Docs } from '../../documents/Documents'; import { DragManager } from '../../util/DragManager'; import { dropActionType } from '../../util/DropActionTypes'; import { SettingsManager } from '../../util/SettingsManager'; import { SnappingManager } from '../../util/SnappingManager'; import { Transform } from '../../util/Transform'; import { undoable, UndoManager } from '../../util/UndoManager'; import { PinDocView, PinProps } from '../PinFuncs'; import { StyleProp } from '../StyleProp'; import { TagItem } from '../TagsView'; import { DocumentViewProps } from '../nodes/DocumentContentsView'; import { DocumentView } from '../nodes/DocumentView'; import { FocusViewOptions } from '../nodes/FocusViewOptions'; import './CollectionCardDeckView.scss'; import { CollectionSubView, SubCollectionViewProps } from './CollectionSubView'; /** * 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 _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) private _setCurDocScript = () => ScriptField.MakeScript('scriptContext.layoutDoc._card_curDoc=this', { scriptContext: 'any' })!; private _draggerRef = React.createRef(); @observable _forceChildXf = 0; @observable _hoveredNodeIndex = -1; @observable _docRefs = new ObservableMap(); @observable _cursor: CSS.Property.Cursor = 'ew-resize'; constructor(props: SubCollectionViewProps) { super(props); makeObservable(this); } protected createDashEventsTarget = (ele: HTMLDivElement | null) => { this._dropDisposer?.(); this.fixWheelEvents(ele, this._props.isContentActive); }; @computed get cardWidth() { return NumCast(this.layoutDoc._cardWidth, 50); } @computed get _maxRowCount() { return Math.ceil(this.cardDeckWidth / this.cardWidth); } componentDidMount() { this._props.setContentViewBox?.(this); // 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 rendered, 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++)); } } ); this._disposers.select = reaction( () => this.childDocs.find(d => this._docRefs.get(d)?.IsSelected), selected => { selected && (this.layoutDoc._card_curDoc = selected); } ); } componentWillUnmount() { Object.keys(this._disposers).forEach(key => this._disposers[key]?.()); this._dropDisposer?.(); } /** * Number of rows of cards to be rendered */ @computed get numRows() { return Math.ceil(this.childDocs.length / this._maxRowCount); } /** * Circle arc size, in radians, to layout cards */ @computed get archAngle() { return NumCast(this.layoutDoc.card_arch, 90) * (Math.PI / 180) * (this.childDocsNoInk.length < this._maxRowCount ? this.childDocsNoInk.length / this._maxRowCount : 1); } /** * Spacing card rows as a percent of Doc size. 100 means rows spread out to fill 100% of the Doc vertically. Default is 60% */ @computed get cardSpacing() { return NumCast(this.layoutDoc.card_spacing, 60); } /** * The child documents to be rendered-- everything other than ink/link docs (which are marks as being svg's) */ @computed get childDocsNoInk() { return this.childLayoutPairs.filter(pair => !pair.layout.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.childDocsNoInk.length, this._maxRowCount); return (this.childPanelWidth() * length) / (this._props.PanelWidth() - 2 * this.xMargin); } @computed get nativeScaling() { return this._props.NativeDimScaling?.() || 1; } @computed get xMargin() { return NumCast(this.layoutDoc._xMargin, Math.max(3, 0.05 * this._props.PanelWidth())); } @computed get yMargin() { return this._props.yMargin || NumCast(this.layoutDoc._yMargin, Math.min(5, 0.05 * this._props.PanelWidth())); } @computed get cardDeckWidth() { return this._props.PanelWidth() - 2 * this.xMargin; } setHoveredNodeIndex = action((index: number) => { if (!SnappingManager.IsDragging) this._hoveredNodeIndex = index; }); isSelected = (doc: Doc) => this._docRefs.get(doc)?.IsSelected; childPanelWidth = () => NumCast(this.layoutDoc.childPanelWidth, Math.max(150, this._props.PanelWidth() / (this.childDocsNoInk.length > this._maxRowCount ? this._maxRowCount : this.childDocsNoInk.length) / this.nativeScaling)); childPanelHeight = () => this._props.PanelHeight() * this.fitContentScale; onChildDoubleClick = () => ScriptCast(this.layoutDoc.onChildDoubleClick); isContentActive = () => this._props.isSelected() || this._props.isContentActive() || this.isAnyChildContentActive(); isAnyChildContentActive = this._props.isAnyChildContentActive; /** * 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 cardCount = this.childDocs.length; let index = 0; const cardWidth = cardCount < this._maxRowCount ? this._props.PanelWidth() / cardCount : this._props.PanelWidth() / this._maxRowCount; // Calculate the adjusted X position accounting for the initial offset let adjustedX = mouseX; const rowHeight = this._props.PanelHeight() / this.numRows; const currRow = Math.floor((mouseY - 100) / rowHeight); //rows start at 0 if (adjustedX < 0) { return 0; // Before the first column } if (cardCount < this._maxRowCount) { index = Math.floor(adjustedX / cardWidth); } else if (currRow != this.numRows - 1) { index = Math.floor(adjustedX / cardWidth) + currRow * this._maxRowCount; } else { const cardsInRow = cardCount - currRow * this._maxRowCount; const offset = ((this._maxRowCount - cardsInRow) / 2) * cardWidth; adjustedX = mouseX - offset; index = Math.floor(adjustedX / cardWidth) + currRow * this._maxRowCount; } return index; }; /** * if pointer moves over cardDeck while dragging a Doc that is in the Deck or that can be dropped in the deck, * then this sets the card index where the dragged card would be added. */ @action onPointerMove = (x: number, y: number) => { if (DragManager.docsBeingDragged.some(doc => this.childDocs.includes(doc)) || SnappingManager.CanEmbed) { this.docDraggedIndex = this.findCardDropIndex(x, y); } }; /** * 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.childDocs; const originalIndex = sorted.findIndex(doc => doc === draggedDoc); this.Document[this._props.fieldKey + '_sort'] = ''; 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; }), '' ); /** * 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('.'); isChildContentActive = computedFn( (doc: Doc) => () => this._props.isContentActive?.() === false ? false : this._props.isDocumentActive?.() && this.curDoc() === doc ? true : this._props.childDocumentsActive?.() === false || this.Document.childDocumentsActive === false ? false : undefined ); // prettier-ignore displayDoc = (doc: Doc, screenToLocalTransform: () => Transform) => ( (!r?.ContentDiv ? this._docRefs.delete(doc) : this._docRefs.set(doc, r)))} Document={doc} NativeWidth={returnZero} NativeHeight={returnZero} PanelWidth={this.childPanelWidth} PanelHeight={this.childPanelHeight} 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} isContentActive={this.isChildContentActive(doc)} fitWidth={returnFalse} waitForDoubleClickToClick={returnNever} scriptContext={this} focus={this.focus} onDoubleClickScript={this.onChildDoubleClick} onClickScript={this.curDoc() === doc ? undefined : this._setCurDocScript} 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) || BoolCast(this.Document._layout_showTags)} whenChildContentsActiveChanged={this._props.whenChildContentsActiveChanged} dontHideOnDrag /> ); /** * Determines how many cards are in the row of a card at a specific index * @param index numerical index of card in total list of all cards * @returns number of cards in row that contains index */ cardsInRowThatIncludesCardIndex = (index: number) => { if (this.childDocsNoInk.length < this._maxRowCount) { return this.childDocsNoInk.length; } const totalCards = this.childDocsNoInk.length; if (index < totalCards - (totalCards % this._maxRowCount)) { return this._maxRowCount; } return totalCards % this._maxRowCount; }; /** * Determines the index a card is in in a row. If the row is not full, then the cards * are centered within the row (as if unrendered cards had been added to the start and end * of the row) and the retuned index is the index the card in this virtual full row. * @param index numerical index of card in total list of all cards * @returns index of card in its row, normalized to a full size row */ centeredIndexOfCardInRow = (index: number) => { const cardsInRow = this.cardsInRowThatIncludesCardIndex(index); const lineIndex = index % this._maxRowCount; if (cardsInRow === this._maxRowCount) return lineIndex; return lineIndex + (this._maxRowCount - cardsInRow) / 2; }; /** * Returns the rotation of a card in radians based on its horizontal location (and thus m apping to a circle arc). * The amount of rotation is goverend by the Doc's card_arch field which specifies, in degrees, the range of a circle * arc that cards should cover -- by default, -45 to 45 degrees. * @param index numerical index of card in total list of all cards * @returns angle of rotation in radians */ rotate = (index: number) => { const cardsInRow = this.cardsInRowThatIncludesCardIndex(index); const centeredIndexInRow = (cardsInRow < this._maxRowCount ? index + (this._maxRowCount - cardsInRow) / 2 : index) % this._maxRowCount; const rowIndexMax = this._maxRowCount - 1; return ((this.archAngle / 2) * (centeredIndexInRow - rowIndexMax / 2)) / (rowIndexMax / 2); }; /** * Provides a vertical adjustment to a card's grid position so that it will lie along an arch. * @param index numerical index of card in total list of all cards */ translateY = (index: number) => { const Magnitude = ((this._props.PanelHeight() * this.fitContentScale) / 2) * Math.sqrt(((this.archAngle * (180 / Math.PI)) / 60) * 4); return Magnitude * (1 - Math.sin(this.rotate(index) + Math.PI / 2) - (1 - Math.sin(this.archAngle / 2 + Math.PI / 2)) / 2); }; /** * When the card index is for a row (not the first row) that is not full, this returns a horizontal adjustment that centers the row * @param index index of card from start of deck * @param cardsInRow number of cards in the row containing the indexed card * @returns horizontal pixel translation */ horizontalAdjustmentForPartialRows = (index: number, cardsInRow: number) => (index < this._maxRowCount ? 0 : (this._maxRowCount - cardsInRow) * (this.childPanelWidth() / 2)); /** * Adjusts the vertical placement of the card from its grid position so that it will either line on a * circular arc if the card isn't active, or so that it will be centered otherwise. * @param isActive whether the card is focused for interaction * @param index index of card from start of deck * @returns vertical pixel translation */ adjustCardYtoFitArch = (isActive: boolean, index: number) => { const rowHeight = this._props.PanelHeight() / this.numRows; const rowIndex = Math.floor(index / this._maxRowCount); const rowToCenterShift = this.numRows / 2 - rowIndex; return isActive ? (rowToCenterShift * rowHeight - rowHeight / 2) * ((this.cardSpacing * this.fitContentScale) / 100) // : this.translateY(index); }; childScreenToLocal = computedFn((doc: Doc, index: number, isSelected: boolean) => () => { // 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, 1); return new Transform(-translateX + (dref?.centeringX || 0) * scale, -translateY + (dref?.centeringY || 0) * scale, 1) .scale(1 / scale).rotate(!isSelected ? -this.rotate(this.centeredIndexOfCardInRow(index)) : 0); // prettier-ignore }); /** * Releases the currently focused Doc by deselecting it and returning it to its location on the arch, and selecting the * cardDeck itself. * This will also force the Doc to recompute its layout transform when the animation completes. * In addition, this sets an animating flag on the Doc so that it will receive no poiner events when animating, such as hover * events that would trigger a flashcard to flip. * @param doc doc that will be animated away from center focus */ releaseCurDoc = action(() => { const selDoc = this.curDoc(); this.layoutDoc._card_curDoc = undefined; const cardDocView = DocumentView.getDocumentView(selDoc, this.DocumentView?.()); if (cardDocView && selDoc) { DocumentView.DeselectView(cardDocView); this._props.select(false); selDoc[Animation] = selDoc; // turns off pointer events & doc decorations while animating - useful for flashcards that reveal back on hover setTimeout(action(() => { selDoc[Animation] = undefined; this._forceChildXf++; }), 350); // prettier-ignore } }); cardSizerDown = (e: React.PointerEvent) => { runInAction(() => { this._cursor = 'grabbing'; }); const batch = UndoManager.StartBatch('card view size'); setupMoveUpEvents( this, e, (emove: PointerEvent) => { this.layoutDoc._cardWidth = Math.max(10, this.ScreenToLocalBoxXf().transformPoint(emove.clientX, 0)[0] - this.xMargin); return false; }, action(() => { this._cursor = 'ew-resize'; batch.end(); }), emptyFunction ); }; /** * turns off the _dropped flag at the end of a drag/drop, or releases the focused Doc if a different Doc is clicked */ cardPointerUp = action((doc: Doc) => { if (this.curDoc() === doc || this._dropped) { this._dropped = false; } else { this.releaseCurDoc(); // NOTE: the onClick script for the card will select the new card (ie, 'doc') } }); focus = action((anchor: Doc, options: FocusViewOptions): Opt => { const docs = DocListCast(this.Document[this.fieldKey]); if (anchor.type === DocumentType.CONFIG || docs.includes(anchor)) { const foundDoc = DocCast( anchor.config_card_curDoc, docs.find(doc => doc === DocCast(anchor.annotationOn, anchor)) ); options.didMove = foundDoc !== this.curDoc() ? true : false; options.didMove && (this.layoutDoc._card_curDoc = foundDoc); } return undefined; }); getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => { const anchor = Docs.Create.ConfigDocument({ annotationOn: this.Document, config_card_curDoc: this.curDoc() }); PinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: { collectionType: true, filters: true } }, this.Document); addAsAnnotation && Doc.AddDocToList(this.dataDoc, this.fieldKey + '_annotations', anchor); // when added as an annotation, links to anchors can be found as links to the document even if the anchors are not rendered return anchor; }; addDocTab = this.addLinkedDocTab; /** * Actually renders all the cards */ @computed get renderCards() { // Map sorted documents to their rendered components return this.childDocs.map((doc, index) => { const cardsInRow = this.cardsInRowThatIncludesCardIndex(index); const childScreenToLocal = this.childScreenToLocal(doc, index, doc === this.curDoc()); const translateToCenterIfActive = () => (doc === this.curDoc() ? (cardsInRow / 2 - (index % this._maxRowCount)) * 100 - 50 : 0); const aspect = NumCast(doc.height) / NumCast(doc.width, 1); const vscale = Math.max(1,Math.min((this._props.PanelHeight() * 0.95 * this.fitContentScale * this.nativeScaling) / (aspect * this.childPanelWidth()), (this._props.PanelHeight() - 80) / (aspect * (this._props.PanelWidth() / 10)))); // prettier-ignore const hscale = Math.min(this.childDocs.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.adjustCardYtoFitArch(doc === this.curDoc(), index)}px) translateX(calc(${translateToCenterIfActive()}% + ${this.horizontalAdjustmentForPartialRows(index, cardsInRow)}px)) rotate(${doc !== this.curDoc()? this.rotate(index) : 0}rad) scale(${doc === this.curDoc()? `${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)}
); }); } contentScreenToLocalXf = () => this._props.ScreenToLocalTransform().scale(this._props.NativeDimScaling?.() || 1); docViewProps = (): DocumentViewProps => ({ ...this._props, // isDocumentActive: this._props.childDocumentsActive?.() ? this._props.isDocumentActive : this._props.isContentActive, isContentActive: emptyFunction, ScreenToLocalTransform: this.contentScreenToLocalXf, }); answered = () => { this.layoutDoc._card_curDoc = this.curDoc() ? this.filteredChildDocs()[(this.filteredChildDocs().findIndex(d => d === this.curDoc()) + 1) % (this.filteredChildDocs().length || 1)] : undefined; }; curDoc = () => DocCast(this.layoutDoc._card_curDoc); render() { const fitContentScale = this.childDocsNoInk.length === 0 ? 1 : this.fitContentScale; return (
this.createDashEventsTarget(ele)} onPointerDown={e => e.button !== 2 && !e.ctrlKey && this.releaseCurDoc()} 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, paddingLeft: this.xMargin, paddingRight: this.xMargin, }}>
{this.renderCards}
{this.flashCardUI(this.curDoc, this.docViewProps, this.answered)}
); } }