diff options
Diffstat (limited to 'src/client/views/collections/collectionGrid/CollectionGridView.tsx')
| -rw-r--r-- | src/client/views/collections/collectionGrid/CollectionGridView.tsx | 307 |
1 files changed, 307 insertions, 0 deletions
diff --git a/src/client/views/collections/collectionGrid/CollectionGridView.tsx b/src/client/views/collections/collectionGrid/CollectionGridView.tsx new file mode 100644 index 000000000..fe89b63ee --- /dev/null +++ b/src/client/views/collections/collectionGrid/CollectionGridView.tsx @@ -0,0 +1,307 @@ +import { action, computed, Lambda, observable, reaction } from 'mobx'; +import { observer } from 'mobx-react'; +import * as React from "react"; +import { Doc, Opt } from '../../../../fields/Doc'; +import { documentSchema } from '../../../../fields/documentSchemas'; +import { Id } from '../../../../fields/FieldSymbols'; +import { makeInterface } from '../../../../fields/Schema'; +import { BoolCast, NumCast, ScriptCast, StrCast } from '../../../../fields/Types'; +import { emptyFunction, returnFalse, returnZero, setupMoveUpEvents } from '../../../../Utils'; +import { Docs } from '../../../documents/Documents'; +import { DragManager } from '../../../util/DragManager'; +import { SnappingManager } from '../../../util/SnappingManager'; +import { Transform } from '../../../util/Transform'; +import { undoBatch } from '../../../util/UndoManager'; +import { ContextMenu } from '../../ContextMenu'; +import { ContextMenuProps } from '../../ContextMenuItem'; +import { ContentFittingDocumentView } from '../../nodes/ContentFittingDocumentView'; +import { FormattedTextBox } from '../../nodes/formattedText/FormattedTextBox'; +import { CollectionSubView } from '../CollectionSubView'; +import "./CollectionGridView.scss"; +import Grid, { Layout } from "./Grid"; + +type GridSchema = makeInterface<[typeof documentSchema]>; +const GridSchema = makeInterface(documentSchema); + +@observer +export class CollectionGridView extends CollectionSubView(GridSchema) { + private _containerRef: React.RefObject<HTMLDivElement> = React.createRef(); + private _changeListenerDisposer: Opt<Lambda>; // listens for changes in this.childLayoutPairs + private _resetListenerDisposer: Opt<Lambda>; // listens for when the reset button is clicked + @observable private _rowHeight: Opt<number>; // temporary store of row height to make change undoable + @observable private _scroll: number = 0; // required to make sure the decorations box container updates on scroll + + @computed get onChildClickHandler() { return ScriptCast(this.Document.onChildClick); } + + @computed get numCols() { return NumCast(this.props.Document.gridNumCols, 10); } + @computed get rowHeight() { return this._rowHeight === undefined ? NumCast(this.props.Document.gridRowHeight, 100) : this._rowHeight; } + // sets the default width and height of the grid nodes + @computed get defaultW() { return NumCast(this.props.Document.gridDefaultW, 2); } + @computed get defaultH() { return NumCast(this.props.Document.gridDefaultH, 2); } + + @computed get colWidthPlusGap() { return (this.props.PanelWidth() - this.margin) / this.numCols; } + @computed get rowHeightPlusGap() { return this.rowHeight + this.margin; } + + @computed get margin() { return NumCast(this.props.Document.margin, 10); } // sets the margin between grid nodes + + @computed get flexGrid() { return BoolCast(this.props.Document.gridFlex, true); } // is grid static/flexible i.e. whether nodes be moved around and resized + @computed get compaction() { return StrCast(this.props.Document.gridStartCompaction, StrCast(this.props.Document.gridCompaction, "vertical")); } // is grid static/flexible i.e. whether nodes be moved around and resized + + componentDidMount() { + this._changeListenerDisposer = reaction(() => this.childLayoutPairs, (pairs) => { + const newLayouts: Layout[] = []; + const oldLayouts = this.savedLayoutList; + pairs.forEach((pair, i) => { + const existing = oldLayouts.find(l => l.i === pair.layout[Id]); + if (existing) newLayouts.push(existing); + else this.addLayoutItem(newLayouts, this.makeLayoutItem(pair.layout, this.unflexedPosition(i), !this.flexGrid)); + }); + pairs?.length && this.setLayoutList(newLayouts); + }, { fireImmediately: true }); + + // updates the layouts if the reset button has been clicked + this._resetListenerDisposer = reaction(() => this.props.Document.gridResetLayout, (reset) => { + if (reset && this.flexGrid) { + this.setLayout(this.childLayoutPairs.map((pair, index) => this.makeLayoutItem(pair.layout, this.unflexedPosition(index)))); + } + this.props.Document.gridResetLayout = false; + }); + } + + componentWillUnmount() { + this._changeListenerDisposer?.(); + this._resetListenerDisposer?.(); + } + + unflexedPosition(index: number): Omit<Layout, "i"> { + return { + x: (index % Math.floor(this.numCols / this.defaultW)) * this.defaultW, + y: Math.floor(index / Math.floor(this.numCols / this.defaultH)) * this.defaultH, + w: this.defaultW, + h: this.defaultH, + static: true + }; + } + + screenToCell(sx: number, sy: number) { + const pt = this.props.ScreenToLocalTransform().transformPoint(sx, sy); + const x = Math.floor(pt[0] / this.colWidthPlusGap); + const y = Math.floor((pt[1] + this._scroll) / this.rowHeight); + return { x, y }; + } + + makeLayoutItem = (doc: Doc, pos: { x: number, y: number }, Static: boolean = false, w: number = this.defaultW, h: number = this.defaultH) => { + return ({ i: doc[Id], w, h, x: pos.x, y: pos.y, static: Static }); + } + + addLayoutItem = (layouts: Layout[], layout: Layout) => { + const f = layouts.findIndex(l => l.i === layout.i); + f !== -1 && layouts.splice(f, 1); + layouts.push(layout); + return layouts; + } + /** + * @returns the transform that will correctly place the document decorations box. + */ + private lookupIndividualTransform = (layout: Layout) => { + const xypos = this.flexGrid ? layout : this.unflexedPosition(this.renderedLayoutList.findIndex(l => l.i === layout.i)); + const pos = { x: xypos.x * this.colWidthPlusGap + this.margin, y: xypos.y * this.rowHeightPlusGap + this.margin - this._scroll }; + + return this.props.ScreenToLocalTransform().translate(-pos.x, -pos.y); + } + + /** + * @returns the layout list converted from JSON + */ + get savedLayoutList() { + return (this.props.Document.gridLayoutString ? JSON.parse(StrCast(this.props.Document.gridLayoutString)) : []) as Layout[]; + } + + /** + * Stores the layout list on the Document as JSON + */ + setLayoutList(layouts: Layout[]) { + this.props.Document.gridLayoutString = JSON.stringify(layouts); + } + + /** + * + * @param layout + * @param dxf the x- and y-translations of the decorations box as a transform i.e. this.lookupIndividualTransform + * @param width + * @param height + * @returns the `ContentFittingDocumentView` of the node + */ + getDisplayDoc(layout: Doc, dxf: () => Transform, width: () => number, height: () => number) { + return <ContentFittingDocumentView + {...this.props} + Document={layout} + DataDoc={layout.resolvedDataDoc as Doc} + NativeHeight={returnZero} + NativeWidth={returnZero} + backgroundColor={this.props.backgroundColor} + ContainingCollectionDoc={this.props.Document} + PanelWidth={width} + PanelHeight={height} + ScreenToLocalTransform={dxf} + onClick={this.onChildClickHandler} + renderDepth={this.props.renderDepth + 1} + parentActive={this.props.active} + display={StrCast(this.props.Document.display, "contents")} // sets the css display type of the ContentFittingDocumentView component + />; + } + + /** + * Saves the layouts received from the Grid to the Document. + * @param layouts `Layout[]` + */ + @action + setLayout = (layoutArray: Layout[]) => { + // for every child in the collection, check to see if there's a corresponding grid layout object and + // updated layout object. If both exist, which they should, update the grid layout object from the updated object + if (this.flexGrid) { + const savedLayouts = this.savedLayoutList; + this.childLayoutPairs.forEach(({ layout: doc }) => { + let gridLayout = savedLayouts.find(gridLayout => gridLayout.i === doc[Id]); + gridLayout && Object.assign(gridLayout, layoutArray.find(layout => layout.i === doc[Id]) || gridLayout); + }); + + if (this.props.Document.gridStartCompaction) { + undoBatch(() => { + this.props.Document.gridCompaction = this.props.Document.gridStartCompaction; + this.setLayoutList(savedLayouts); + })(); + this.props.Document.gridStartCompaction = undefined; + } else { + undoBatch(() => this.setLayoutList(savedLayouts))(); + } + } + } + + /** + * @returns a list of `ContentFittingDocumentView`s inside wrapper divs. + * The key of the wrapper div must be the same as the `i` value of the corresponding layout. + */ + @computed + private get contents(): JSX.Element[] { + const collector: JSX.Element[] = []; + if (this.renderedLayoutList.length === this.childLayoutPairs.length) { + this.renderedLayoutList.forEach(l => { + const child = this.childLayoutPairs.find(c => c.layout[Id] === l.i); + const dxf = () => this.lookupIndividualTransform(l); + const width = () => (this.flexGrid ? l.w : this.defaultW) * this.colWidthPlusGap - this.margin; + const height = () => (this.flexGrid ? l.h : this.defaultH) * this.rowHeightPlusGap - this.margin; + child && collector.push( + <div key={child.layout[Id]} className={"document-wrapper" + (this.flexGrid && this.props.isSelected() ? "" : " static")} > + {this.getDisplayDoc(child.layout, dxf, width, height)} + </div > + ); + }); + } + return collector; + } + + /** + * @returns a list of `Layout` objects with attributes depending on whether the grid is flexible or static + */ + @computed get renderedLayoutList(): Layout[] { + return this.flexGrid ? + this.savedLayoutList.map(({ i, x, y, w, h }) => ({ + i, y, h, + x: x + w > this.numCols ? 0 : x, // handles wrapping around of nodes when numCols decreases + w: Math.min(w, this.numCols), // reduces width if greater than numCols + static: BoolCast(this.childLayoutPairs.find(({ layout }) => layout[Id] === i)?.layout.lockedPosition, false) // checks if the lock position item has been selected in the context menu + })) : + this.savedLayoutList.map((layout, index) => Object.assign(layout, this.unflexedPosition(index))); + } + + @action + onInternalDrop = (e: Event, de: DragManager.DropEvent) => { + const savedLayouts = this.savedLayoutList; + const dropped = de.complete.docDragData?.droppedDocuments; + if (dropped && super.onInternalDrop(e, de) && savedLayouts.length !== this.childDocs.length) { + dropped.forEach(doc => this.addLayoutItem(savedLayouts, this.makeLayoutItem(doc, this.screenToCell(de.x, de.y)))); // shouldn't place all docs in the same cell; + this.setLayoutList(savedLayouts); + return true; + } + return false; + } + + /** + * Handles the change in the value of the rowHeight slider. + */ + @action + onSliderChange = (event: React.ChangeEvent<HTMLInputElement>) => { + this._rowHeight = event.currentTarget.valueAsNumber; + } + @action + onSliderDown = (e: React.PointerEvent) => { + this._rowHeight = this.rowHeight; // uses _rowHeight during dragging and sets doc's rowHeight when finished so that operation is undoable + setupMoveUpEvents(this, e, returnFalse, action(() => { + undoBatch(() => this.props.Document.gridRowHeight = this._rowHeight)(); + this._rowHeight = undefined; + }), emptyFunction, false, false); + e.stopPropagation(); + } + /** + * Adds the display option to change the css display attribute of the `ContentFittingDocumentView`s + */ + onContextMenu = () => { + const displayOptionsMenu: ContextMenuProps[] = []; + displayOptionsMenu.push({ description: "Contents", event: () => this.props.Document.display = "contents", icon: "copy" }); + displayOptionsMenu.push({ description: "Undefined", event: () => this.props.Document.display = undefined, icon: "exclamation" }); + ContextMenu.Instance.addItem({ description: "Display", subitems: displayOptionsMenu, icon: "tv" }); + } + + onPointerDown = (e: React.PointerEvent) => { + if (this.props.active(true)) { + setupMoveUpEvents(this, e, returnFalse, returnFalse, + (e: PointerEvent, doubleTap?: boolean) => { + if (doubleTap) { + undoBatch(action(() => { + const text = Docs.Create.TextDocument("", { _width: 150, _height: 50 }); + FormattedTextBox.SelectOnLoad = text[Id];// track the new text box so we can give it a prop that tells it to focus itself when it's displayed + Doc.AddDocToList(this.props.Document, this.props.fieldKey, text); + this.setLayoutList(this.addLayoutItem(this.savedLayoutList, this.makeLayoutItem(text, this.screenToCell(e.clientX, e.clientY)))); + }))(); + } + }, + false); + if (this.props.isSelected(true)) e.stopPropagation(); + } + } + + render() { + return ( + <div className="collectionGridView-contents" ref={this.createDashEventsTarget} + style={{ pointerEvents: !this.props.active() && !SnappingManager.GetIsDragging() ? "none" : undefined }} + onContextMenu={this.onContextMenu} + onPointerDown={e => this.onPointerDown(e)} > + <div className="collectionGridView-gridContainer" ref={this._containerRef} + onWheel={e => e.stopPropagation()} + onScroll={action(e => { + if (!this.props.isSelected()) e.currentTarget.scrollTop = this._scroll; + else this._scroll = e.currentTarget.scrollTop; + })} > + <Grid + width={this.props.PanelWidth()} + nodeList={this.contents.length ? this.contents : null} + layout={this.contents.length ? this.renderedLayoutList : undefined} + childrenDraggable={this.props.isSelected() ? true : false} + numCols={this.numCols} + rowHeight={this.rowHeight} + setLayout={this.setLayout} + transformScale={this.props.ScreenToLocalTransform().Scale} + compactType={this.compaction} // determines whether nodes should remain in position, be bound to the top, or to the left + preventCollision={BoolCast(this.props.Document.gridPreventCollision)}// determines whether nodes should move out of the way (i.e. collide) when other nodes are dragged over them + margin={this.margin} + /> + <input className="rowHeightSlider" type="range" + style={{ width: this.props.PanelHeight() - 30 }} + min={1} value={this.rowHeight} max={this.props.PanelHeight() - 30} + onPointerDown={this.onSliderDown} onChange={this.onSliderChange} /> + </div> + </div > + ); + } +}
\ No newline at end of file |
