diff options
7 files changed, 274 insertions, 4 deletions
diff --git a/src/client/documents/DocumentTypes.ts b/src/client/documents/DocumentTypes.ts index 1123bcac9..209b34f91 100644 --- a/src/client/documents/DocumentTypes.ts +++ b/src/client/documents/DocumentTypes.ts @@ -62,5 +62,7 @@ export enum CollectionViewType { Pile = 'pileup', StackedTimeline = 'stacked timeline', NoteTaking = 'notetaking', - Calendar = 'calendar' + Calendar = 'calendar', + Card = 'card' + } diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index 17cb6fef8..d12c61fd7 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -1219,6 +1219,15 @@ export namespace Docs { ); } + export function CardDeckDocument(documents: Array<Doc>, options: DocumentOptions, id?: string) { + return InstanceFromProto( + Prototypes.get(DocumentType.COL), + new List(documents), + { backgroundColor: 'transparent', dropAction: dropActionType.move, _forceActive: true, _freeform_noZoom: true, _freeform_noAutoPan: true, ...options, _type_collection: CollectionViewType.Card }, + id + ); + } + export function LinearDocument(documents: Array<Doc>, options: DocumentOptions, id?: string) { return InstanceFromProto(Prototypes.get(DocumentType.COL), new List(documents), { ...options, _type_collection: CollectionViewType.Linear }, id); } @@ -1876,6 +1885,44 @@ export namespace DocUtils { return newCollection; } } + + export function spreadCards(docList: Doc[], x: number = 0, y: number = 0, spreadAngle: number = 30, radius: number = 100, create: boolean = true) { + const totalCards = docList.length; + const halfSpreadAngle = spreadAngle * 0.5; + const angleStep = spreadAngle / (totalCards - 1); + + runInAction(() => { + docList.forEach((d, i) => { + DocUtils.iconify(d); + const angle = (-halfSpreadAngle + angleStep * i) * (Math.PI / 180); // Convert degrees to radians + d.x = x + Math.cos(angle) * radius; + d.y = y + Math.sin(angle) * radius; + d.rotation = angle; // Assuming 'd.rotation' sets the rotation, adjust accordingly + d._timecodeToShow = undefined; + }); + }); + + if (create) { + const newCollection = Docs.Create.CardDeckDocument(docList, { + title: 'card-spread', + _freeform_noZoom: true, + x: x - radius, + y: y - radius, + _width: radius * 2, + _height: radius * 2, + dragWhenActive: true, + _layout_fitWidth: false + }); + // Adjust position based on the collection's dimensions if needed + newCollection.x = NumCast(newCollection.x) + NumCast(newCollection._width) / 2 - radius; + newCollection.y = NumCast(newCollection.y) + NumCast(newCollection._height) / 2 - radius; + newCollection._width = newCollection._height = radius * 2; + return newCollection; + } + } + + + export function makeIntoPortal(doc: Doc, layoutDoc: Doc, allLinks: Doc[]) { const portalLink = allLinks.find(d => d.link_anchor_1 === doc && d.link_relationship === 'portal to:portal from'); if (!portalLink) { diff --git a/src/client/views/collections/CollectionCardDeckView.tsx b/src/client/views/collections/CollectionCardDeckView.tsx new file mode 100644 index 000000000..feb9e61cc --- /dev/null +++ b/src/client/views/collections/CollectionCardDeckView.tsx @@ -0,0 +1,164 @@ +import { action, computed, IReactionDisposer, makeObservable } from 'mobx'; +import { observer } from 'mobx-react'; +import * as React from 'react'; +import { Doc, DocListCast } from '../../../fields/Doc'; +import { ScriptField } from '../../../fields/ScriptField'; +import { NumCast, StrCast } from '../../../fields/Types'; +import { emptyFunction, returnFalse, setupMoveUpEvents } from '../../../Utils'; +import { DocUtils } from '../../documents/Documents'; +import { SelectionManager } from '../../util/SelectionManager'; +import { undoBatch, UndoManager } from '../../util/UndoManager'; +import { OpenWhere } from '../nodes/DocumentView'; +import { computePassLayout, computeStarburstLayout, computeCardDeckLayout } from './collectionFreeForm'; +import { CollectionFreeFormView } from './collectionFreeForm/CollectionFreeFormView'; +import './CollectionPileView.scss'; +import { CollectionSubView } from './CollectionSubView'; +import { dropActionType } from '../../util/DragManager'; + +@observer +export class CollectionCardView extends CollectionSubView() { + _originalChrome: any = ''; + _disposers: { [name: string]: IReactionDisposer } = {}; + + constructor(props: any) { + super(props); + makeObservable(this); + } + + componentDidMount() { + if (this.layoutEngine() !== computePassLayout.name && this.layoutEngine() !== computeCardDeckLayout.name) { + this.Document._freeform_cardEngine = computePassLayout.name; + } + this._originalChrome = this.layoutDoc._chromeHidden; + this.layoutDoc._chromeHidden = true; + } + componentWillUnmount() { + this.layoutDoc._chromeHidden = this._originalChrome; + Object.values(this._disposers).forEach(disposer => disposer?.()); + } + + layoutEngine = () => StrCast(this.Document._freeform_cardEngine); + + @undoBatch + addCardDoc = (doc: Doc | Doc[]) => { + (doc instanceof Doc ? [doc] : doc).map(d => DocUtils.iconify(d)); + return this._props.addDocument?.(doc) || false; + }; + + @undoBatch + removeCardDoc = (doc: Doc | Doc[], targetCollection: Doc | undefined, addDoc: (doc: Doc | Doc[]) => boolean) => { + (doc instanceof Doc ? [doc] : doc).forEach(d => Doc.deiconifyView(d)); + const ret = this._props.moveDocument?.(doc, targetCollection, addDoc) || false; + if (ret && !DocListCast(this.dataDoc[this.fieldKey ?? 'data']).length) this.DocumentView?.()._props.removeDocument?.(this.Document); + return ret; + }; + + @computed get toggleIcon() { + return ScriptField.MakeScript('documentView.iconify()', { documentView: 'any' }); + } + @computed get contentEvents() { + const isCard = this.layoutEngine() === computeCardDeckLayout.name; + return this._props.isContentActive() && isCard ? undefined : 'none'; + } + + // returns the contents of the cardSpread in a CollectionFreeFormView + @computed get contents() { + return ( + <div className="collectionPileView-innards" style={{ pointerEvents: this.contentEvents }}> + <CollectionFreeFormView + {...this._props} // + layoutEngine={this.layoutEngine} + addDocument={this.addCardDoc} + moveDocument={this.removeCardDoc} + // pile children never have their contents active, but will be document active whenever the entire pile is. + childContentsActive={returnFalse} + childDocumentsActive={this._props.isDocumentActive} + childDragAction={dropActionType.move} + childClickScript={this.toggleIcon} + /> + </div> + ); + } + + // // toggles the pileup between starburst to compact + // toggleStarburst = action(() => { + // this.layoutDoc._freeform_scale = undefined; + // if (this.layoutEngine() === computeStarburstLayout.name) { + // if (NumCast(this.layoutDoc._width) !== NumCast(this.Document._starburstDiameter, 500)) { + // this.Document._starburstDiameter = NumCast(this.layoutDoc._width); + // } + // const defaultSize = 110; + // this.Document.x = NumCast(this.Document.x) + NumCast(this.layoutDoc._width) / 2 - NumCast(this.layoutDoc._freeform_pileWidth, defaultSize) / 2; + // this.Document.y = NumCast(this.Document.y) + NumCast(this.layoutDoc._height) / 2 - NumCast(this.layoutDoc._freeform_pileHeight, defaultSize) / 2; + // this.layoutDoc._width = NumCast(this.layoutDoc._freeform_pileWidth, defaultSize); + // this.layoutDoc._height = NumCast(this.layoutDoc._freeform_pileHeight, defaultSize); + // DocUtils.pileup(this.childDocs, undefined, undefined, NumCast(this.layoutDoc._width) / 2, false); + // this.layoutDoc._freeform_panX = 0; + // this.layoutDoc._freeform_panY = -10; + // this.Document._freeform_pileEngine = computePassLayout.name; + // } else { + // const defaultSize = NumCast(this.Document._starburstDiameter, 400); + // this.Document.x = NumCast(this.Document.x) + NumCast(this.layoutDoc._width) / 2 - defaultSize / 2; + // this.Document.y = NumCast(this.Document.y) + NumCast(this.layoutDoc._height) / 2 - defaultSize / 2; + // this.layoutDoc._freeform_pileWidth = NumCast(this.layoutDoc._width); + // this.layoutDoc._freeform_pileHeight = NumCast(this.layoutDoc._height); + // this.layoutDoc._freeform_panX = this.layoutDoc._freeform_panY = 0; + // this.layoutDoc._width = this.layoutDoc._height = defaultSize; + // this.layoutDoc.background; + // this.Document._freeform_pileEngine = computeStarburstLayout.name; + // } + // }); + + // for dragging documents out of the pileup view + _undoBatch: UndoManager.Batch | undefined; + pointerDown = (e: React.PointerEvent) => { + let dist = 0; + setupMoveUpEvents( + this, + e, + (e: PointerEvent, down: number[], delta: number[]) => { + if (this.layoutEngine() === 'pass' && this.childDocs.length && e.shiftKey) { + dist += Math.sqrt(delta[0] * delta[0] + delta[1] * delta[1]); + if (dist > 100) { + if (!this._undoBatch) { + this._undoBatch = UndoManager.StartBatch('layout pile'); + } + const doc = this.childDocs[0]; + doc.x = e.clientX; + doc.y = e.clientY; + this._props.addDocTab(doc, OpenWhere.inParentFromScreen) && (this._props.removeDocument?.(doc) || false); + dist = 0; + } + } + return false; + }, + () => { + this._undoBatch?.end(); + this._undoBatch = undefined; + }, + emptyFunction, + e.shiftKey && this.layoutEngine() === computePassLayout.name, + this.layoutEngine() === computePassLayout.name && e.shiftKey + ); // this sets _doubleTap + }; + + // onClick for toggling the pileup view + // @undoBatch + // onClick = (e: React.MouseEvent) => { + // if (e.button === 0) { + // SelectionManager.DeselectAll(); + // this.toggleStarburst(); + // e.stopPropagation(); + // } + // }; + + render() { + return ( + <div className={`collectionPileView`} + // onClick={this.onClick} + onPointerDown={this.pointerDown} style={{ width: this._props.PanelWidth(), height: '100%' }}> + {this.contents} + </div> + ); + } +} diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx index 18eb4dd1f..b7805bf3f 100644 --- a/src/client/views/collections/CollectionView.tsx +++ b/src/client/views/collections/CollectionView.tsx @@ -33,6 +33,8 @@ import { CollectionLinearView } from './collectionLinear'; import { CollectionMulticolumnView } from './collectionMulticolumn/CollectionMulticolumnView'; import { CollectionMultirowView } from './collectionMulticolumn/CollectionMultirowView'; import { CollectionSchemaView } from './collectionSchema/CollectionSchemaView'; +import { CollectionCardView } from './CollectionCardDeckView'; + export interface CollectionViewProps extends React.PropsWithChildren<FieldViewProps> { isAnnotationOverlay?: boolean; // is the collection an annotation overlay (eg an overlay on an image/video/etc) isAnnotationOverlayScrollable?: boolean; // whether the annotation overlay can be vertically scrolled (just for tree views, currently) @@ -134,6 +136,9 @@ export class CollectionView extends ViewBoxAnnotatableComponent<CollectionViewPr case CollectionViewType.Masonry: return <CollectionStackingView key="collview" {...props} />; case CollectionViewType.Time: return <CollectionTimeView key="collview" {...props} />; case CollectionViewType.Grid: return <CollectionGridView key="collview" {...props} />; + case CollectionViewType.Card: return <CollectionCardView key="collview" {...props} />; + + } }; diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx index b8c0967c1..e972f44f1 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx @@ -126,6 +126,40 @@ export function computeStarburstLayout(poolData: Map<string, PoolData>, pivotDoc return normalizeResults(burstDiam, 12, docMap, poolData, viewDefsToJSX, [], 0, [divider]); } +export function computeCardDeckLayout(poolData: Map<string, PoolData>, pivotDoc: Doc, childPairs: { layout: Doc; data?: Doc }[], panelDim: number[], viewDefsToJSX: (views: ViewDefBounds[]) => ViewDefResult[], engineProps: any) { + const docMap = new Map<string, PoolData>(); + const spreadWidth = Math.min(panelDim[0], childPairs.length * 50); // Total width of the spread + const startX = -(spreadWidth / 2); // Starting X position + const fanAngle = 5; // Angle in degrees for fanning out cards + const baseZIndex = 1000; // Base Z-index to ensure cards are stacked in order + + childPairs.forEach(({ layout, data }, i) => { + const aspect = NumCast(layout._height) / NumCast(layout._width); + const docSize = Math.min(400, NumCast(layout._width)) * NumCast(pivotDoc._starburstDocScale, 1); + const posX = startX + (spreadWidth / childPairs.length) * i; + const posY = 0; // Adjust if you want to change the vertical alignment + const rotation = (i - (childPairs.length / 2)) * fanAngle; // Calculate rotation for fanning effect + + docMap.set(layout[Id], { + x: posX, + y: posY, + width: docSize, + height: docSize * aspect, + zIndex: baseZIndex + i, // Increment Z-index for each card to stack them correctly + rotation: rotation, // Optional: Add this if you want to rotate elements for a fanned effect + pair: { layout, data }, + replica: '', + color: 'white', + backgroundColor: 'white', + transition: 'all 0.3s', + }); + }); + + // This is a placeholder for the divider object and may need to be adjusted based on actual usage + const divider = { type: 'div', color: 'transparent', x: -panelDim[0] / 2, y: -panelDim[1] / 2, width: 15, height: 15, payload: undefined }; + return normalizeResults(panelDim, 12, docMap, poolData, viewDefsToJSX, [], 0, [divider]); +} + export function computePivotLayout(poolData: Map<string, PoolData>, pivotDoc: Doc, childPairs: { layout: Doc; data?: Doc }[], panelDim: number[], viewDefsToJSX: (views: ViewDefBounds[]) => ViewDefResult[], engineProps: any) { const docMap = new Map<string, PoolData>(); const fieldKey = 'data'; diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index 1fd453e96..f9fe306fa 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -49,7 +49,7 @@ import { CollectionSubView } from '../CollectionSubView'; import { TreeViewType } from '../CollectionTreeView'; import { CollectionFreeFormBackgroundGrid } from './CollectionFreeFormBackgroundGrid'; import { CollectionFreeFormInfoUI } from './CollectionFreeFormInfoUI'; -import { computePassLayout, computePivotLayout, computeStarburstLayout, computeTimelineLayout, PoolData, ViewDefBounds, ViewDefResult } from './CollectionFreeFormLayoutEngines'; +import { computePassLayout, computePivotLayout, computeStarburstLayout, computeTimelineLayout, PoolData, ViewDefBounds, ViewDefResult, computeCardDeckLayout } from './CollectionFreeFormLayoutEngines'; import { CollectionFreeFormPannableContents } from './CollectionFreeFormPannableContents'; import { CollectionFreeFormRemoteCursors } from './CollectionFreeFormRemoteCursors'; import './CollectionFreeFormView.scss'; @@ -1383,6 +1383,8 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection case computeTimelineLayout.name: return { newPool, computedElementData: this.doEngineLayout(newPool, computeTimelineLayout) }; case computePivotLayout.name: return { newPool, computedElementData: this.doEngineLayout(newPool, computePivotLayout) }; case computeStarburstLayout.name: return { newPool, computedElementData: this.doEngineLayout(newPool, computeStarburstLayout) }; + case computeCardDeckLayout.name: return { newPool, computedElementData: this.doEngineLayout(newPool, computeCardDeckLayout) }; + } return { newPool, computedElementData: this.doFreeformLayout(newPool) }; } diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx index b913e05ad..d0ac5f6db 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx @@ -383,6 +383,18 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps this.hideMarquee(); }); + @undoBatch + spreadCards = action((e: KeyboardEvent | React.PointerEvent | undefined) => { + const selected = this.marqueeSelect(false); + SelectionManager.DeselectAll(); + selected.forEach(d => this._props.removeDocument?.(d)); + const newCollection = DocUtils.spreadCards(selected, this.Bounds.left + this.Bounds.width / 2, this.Bounds.top + this.Bounds.height / 2)!; + this._props.addDocument?.(newCollection); + this._props.selectDocuments([newCollection]); + MarqueeOptionsMenu.Instance.fadeOut(true); + this.hideMarquee(); + }); + /** * This triggers the TabDocView.PinDoc method which is the universal method * used to pin documents to the currently active presentation trail. @@ -508,6 +520,7 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps @action marqueeCommand = (e: KeyboardEvent) => { + if (this._commandExecuted || (e as any).propagationIsStopped) { return; } @@ -518,7 +531,8 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps this.delete(e, e.key === 'h'); e.stopPropagation(); } - if ('ctsSpg'.indexOf(e.key) !== -1) { + if ('ctsSpga'.indexOf(e.key) !== -1) { + this._commandExecuted = true; e.stopPropagation(); e.preventDefault(); @@ -526,7 +540,9 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps if (e.key === 'g') this.collection(e, true); if (e.key === 'c' || e.key === 't') this.collection(e); if (e.key === 's' || e.key === 'S') this.summary(e); - if (e.key === 'p') this.pileup(e); + if (e.key === 'p') this.pileup(e) + if (e.key === 'a') this.spreadCards(e); + this.cleanupInteractions(false); } if (e.key === 'r' || e.key === ' ') { |