diff options
author | bob <bcz@cs.brown.edu> | 2020-01-31 18:30:42 -0500 |
---|---|---|
committer | bob <bcz@cs.brown.edu> | 2020-01-31 18:30:42 -0500 |
commit | d20a21384fc685082955cfbb9deb9c0f1176e7ad (patch) | |
tree | 76b0fe775943914674e332bd98eb671ee3175132 /src | |
parent | 349a4c81c8e8864174c1965039b0046be137c98a (diff) |
added multirow collection. cleaned up CollectionTimeview a bit.
Diffstat (limited to 'src')
8 files changed, 484 insertions, 8 deletions
diff --git a/src/client/views/collections/CollectionTimeView.tsx b/src/client/views/collections/CollectionTimeView.tsx index dfcf93ef6..19a48d6ef 100644 --- a/src/client/views/collections/CollectionTimeView.tsx +++ b/src/client/views/collections/CollectionTimeView.tsx @@ -74,7 +74,7 @@ export class CollectionTimeView extends CollectionSubView(doc => doc) { } _canClick = false; _facetWidthOnDown = 0; - @observable _facetWidth = 200; + @observable _facetWidth = 0; onPointerDown = (e: React.PointerEvent) => { this._canClick = true; this._facetWidthOnDown = e.screenX; diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx index 027cf2404..5a7bdef61 100644 --- a/src/client/views/collections/CollectionView.tsx +++ b/src/client/views/collections/CollectionView.tsx @@ -37,6 +37,7 @@ import { CollectionTreeView } from "./CollectionTreeView"; import './CollectionView.scss'; import { CollectionViewBaseChrome } from './CollectionViewChromes'; import { CollectionTimeView } from './CollectionTimeView'; +import { CollectionMultirowView } from './collectionMulticolumn/CollectionMultirowView'; export const COLLECTION_BORDER_WIDTH = 2; const path = require('path'); library.add(faTh, faTree, faSquare, faProjectDiagram, faSignature, faThList, faFingerprint, faColumns, faEllipsisV, faImage, faEye as any, faCopy); @@ -50,6 +51,7 @@ export enum CollectionViewType { Stacking, Masonry, Multicolumn, + Multirow, Pivot, Time, Carousel, @@ -68,6 +70,7 @@ export namespace CollectionViewType { ["stacking", CollectionViewType.Stacking], ["masonry", CollectionViewType.Masonry], ["multicolumn", CollectionViewType.Multicolumn], + ["multirow", CollectionViewType.Multirow], ["pivot", CollectionViewType.Pivot], ["time", CollectionViewType.Time], ["carousel", CollectionViewType.Carousel], @@ -183,6 +186,7 @@ export class CollectionView extends Touchable<FieldViewProps> { case CollectionViewType.Tree: return (<CollectionTreeView key="collview" {...props} />); case CollectionViewType.Staff: return (<CollectionStaffView chromeCollapsed={true} key="collview" {...props} ChromeHeight={this.chromeHeight} CollectionView={this} />); case CollectionViewType.Multicolumn: return (<CollectionMulticolumnView chromeCollapsed={true} key="collview" {...props} ChromeHeight={this.chromeHeight} CollectionView={this} />); + case CollectionViewType.Multirow: return (<CollectionMultirowView chromeCollapsed={true} key="rpwview" {...props} ChromeHeight={this.chromeHeight} CollectionView={this} />); case CollectionViewType.Linear: { return (<CollectionLinearView key="collview" {...props} />); } case CollectionViewType.Carousel: { return (<CollectionCarouselView key="collview" {...props} />); } case CollectionViewType.Stacking: { this.props.Document.singleColumn = true; return (<CollectionStackingView key="collview" {...props} />); } @@ -227,6 +231,7 @@ export class CollectionView extends Touchable<FieldViewProps> { }); subItems.push({ description: "Staff", event: () => this.props.Document._viewType = CollectionViewType.Staff, icon: "music" }); subItems.push({ description: "Multicolumn", event: () => this.props.Document._viewType = CollectionViewType.Multicolumn, icon: "columns" }); + subItems.push({ description: "Multirow", event: () => this.props.Document._viewType = CollectionViewType.Multirow, icon: "columns" }); subItems.push({ description: "Masonry", event: () => this.props.Document._viewType = CollectionViewType.Masonry, icon: "columns" }); subItems.push({ description: "Carousel", event: () => this.props.Document._viewType = CollectionViewType.Carousel, icon: "columns" }); subItems.push({ description: "Pivot", event: () => this.props.Document._viewType = CollectionViewType.Pivot, icon: "columns" }); diff --git a/src/client/views/collections/CollectionViewChromes.tsx b/src/client/views/collections/CollectionViewChromes.tsx index 9cb668d20..c5db6f10f 100644 --- a/src/client/views/collections/CollectionViewChromes.tsx +++ b/src/client/views/collections/CollectionViewChromes.tsx @@ -417,11 +417,12 @@ export class CollectionViewBaseChrome extends React.Component<CollectionViewChro <option className="collectionViewBaseChrome-viewOption" onPointerDown={stopPropagation} value="4">Tree</option> <option className="collectionViewBaseChrome-viewOption" onPointerDown={stopPropagation} value="5">Stacking</option> <option className="collectionViewBaseChrome-viewOption" onPointerDown={stopPropagation} value="6">Masonry</option> - <option className="collectionViewBaseChrome-viewOption" onPointerDown={stopPropagation} value="7">Multicolumn</option> - <option className="collectionViewBaseChrome-viewOption" onPointerDown={stopPropagation} value="8">Pivot</option> - <option className="collectionViewBaseChrome-viewOption" onPointerDown={stopPropagation} value="9">Time</option> - <option className="collectionViewBaseChrome-viewOption" onPointerDown={stopPropagation} value="10">Carousel</option> - <option className="collectionViewBaseChrome-viewOption" onPointerDown={stopPropagation} value="11">Linear</option> + <option className="collectionViewBaseChrome-viewOption" onPointerDown={stopPropagation} value="7">MultiCol</option> + <option className="collectionViewBaseChrome-viewOption" onPointerDown={stopPropagation} value="8">MultiRow</option> + <option className="collectionViewBaseChrome-viewOption" onPointerDown={stopPropagation} value="9">Pivot</option> + <option className="collectionViewBaseChrome-viewOption" onPointerDown={stopPropagation} value="10">Time</option> + <option className="collectionViewBaseChrome-viewOption" onPointerDown={stopPropagation} value="11">Carousel</option> + <option className="collectionViewBaseChrome-viewOption" onPointerDown={stopPropagation} value="12">Linear</option> </select> <div className="collectionViewBaseChrome-viewSpecs" title="filter documents to show" style={{ display: collapsed ? "none" : "grid" }}> <div className="collectionViewBaseChrome-filterIcon" onPointerDown={this.openViewSpecs} > diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx index 8a1147c61..6e4af3520 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx @@ -10,7 +10,6 @@ import { ObservableMap, runInAction } from "mobx"; import { Id, ToString } from "../../../../new_fields/FieldSymbols"; import { ObjectField } from "../../../../new_fields/ObjectField"; import { RefField } from "../../../../new_fields/RefField"; -import { createPromiseCapability } from "../../../../../deploy/assets/pdf.worker"; interface PivotData { type: string; @@ -142,8 +141,11 @@ export function computeTimelineLayout( poolData: ObservableMap<string, any>, pivotDoc: Doc, childDocs: Doc[], - childPairs: { layout: Doc, data?: Doc }[], panelDim: number[], viewDefsToJSX: (views: any) => ViewDefResult[] + childPairs: { layout: Doc, data?: Doc }[], + panelDim: number[], + viewDefsToJSX: (views: any) => ViewDefResult[] ) { + const fieldKey = "data"; const pivotAxisWidth = NumCast(pivotDoc.pivotWidth, 200); const pivotDateGroups = new Map<number, Doc[]>(); @@ -159,6 +161,9 @@ export function computeTimelineLayout( minTime = Math.min(num, minTime); maxTime = Math.max(num, maxTime); } + minTime = NumCast(pivotDoc[fieldKey + "-timelineMin"], minTime); + maxTime = NumCast(pivotDoc[fieldKey + "-timelineMax"], maxTime); + const curTime = Cast(pivotDoc[fieldKey + "-timelineCur"], "number", null); const docMap = new Map<Doc, ViewDefBounds>(); const groupNames: PivotData[] = []; diff --git a/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.scss b/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.scss new file mode 100644 index 000000000..ef4b4a19c --- /dev/null +++ b/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.scss @@ -0,0 +1,34 @@ +.collectionMultirowView_contents { + display: flex; + width: 100%; + height: 100%; + overflow: hidden; + flex-direction: column; + + .document-wrapper { + display: flex; + flex-direction: row; + + .label-wrapper { + display: flex; + flex-direction: row; + justify-content: center; + height: 20px; + } + + } + + .resizer { + cursor: ew-resize; + transition: 0.5s opacity ease; + display: flex; + flex-direction: row; + + .internal { + width: 100%; + height: 100%; + transition: 0.5s background-color ease; + } + } + +}
\ No newline at end of file diff --git a/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx b/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx new file mode 100644 index 000000000..e07985bb4 --- /dev/null +++ b/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx @@ -0,0 +1,259 @@ +import { observer } from 'mobx-react'; +import { makeInterface } from '../../../../new_fields/Schema'; +import { documentSchema } from '../../../../new_fields/documentSchemas'; +import { CollectionSubView, SubCollectionViewProps } from '../CollectionSubView'; +import * as React from "react"; +import { Doc } from '../../../../new_fields/Doc'; +import { NumCast, StrCast, BoolCast, ScriptCast } from '../../../../new_fields/Types'; +import { ContentFittingDocumentView } from '../../nodes/ContentFittingDocumentView'; +import { Utils } from '../../../../Utils'; +import "./collectionMultirowView.scss"; +import { computed, trace, observable, action } from 'mobx'; +import { Transform } from '../../../util/Transform'; +import HeightLabel from './MultirowHeightLabel'; +import ResizeBar from './MultirowResizer'; +import { undoBatch } from '../../../util/UndoManager'; +import { DragManager } from '../../../util/DragManager'; + +type MultirowDocument = makeInterface<[typeof documentSchema]>; +const MultirowDocument = makeInterface(documentSchema); + +interface HeightSpecifier { + magnitude: number; + unit: string; +} + +interface LayoutData { + heightSpecifiers: HeightSpecifier[]; + starSum: number; +} + +export const HeightUnit = { + Pixel: "px", + Ratio: "*" +}; + +const resolvedUnits = Object.values(HeightUnit); +const resizerHeight = 4; + +@observer +export class CollectionMultirowView extends CollectionSubView(MultirowDocument) { + + /** + * @returns the list of layout documents whose width unit is + * *, denoting that it will be displayed with a ratio, not fixed pixel, value + */ + @computed + private get ratioDefinedDocs() { + return this.childLayoutPairs.map(({ layout }) => layout).filter(({ widthUnit }) => StrCast(widthUnit) === HeightUnit.Ratio); + } + + /** + * This loops through all childLayoutPairs and extracts the values for widthUnit + * and widthMagnitude, ignoring any that are malformed. Additionally, it then + * normalizes the ratio values so that one * value is always 1, with the remaining + * values proportionate to that easily readable metric. + * @returns the list of the resolved width specifiers (unit and magnitude pairs) + * as well as the sum of the * coefficients, i.e. the ratio magnitudes + */ + @computed + private get resolvedLayoutInformation(): LayoutData { + let starSum = 0; + const heightSpecifiers: HeightSpecifier[] = []; + this.childLayoutPairs.map(({ layout: { heightUnit, heightMagnitude } }) => { + const unit = StrCast(heightUnit); + const magnitude = NumCast(heightMagnitude); + if (unit && magnitude && magnitude > 0 && resolvedUnits.includes(unit)) { + (unit === HeightUnit.Ratio) && (starSum += magnitude); + heightSpecifiers.push({ magnitude, unit }); + } + /** + * Otherwise, the child document is ignored and the remaining + * space is allocated as if the document were absent from the child list + */ + }); + + /** + * Here, since these values are all relative, adjustments during resizing or + * manual updating can, though their ratios remain the same, cause the values + * themselves to drift toward zero. Thus, whenever we change any of the values, + * we normalize everything (dividing by the smallest magnitude). + */ + setTimeout(() => { + const { ratioDefinedDocs } = this; + if (this.childLayoutPairs.length) { + const minimum = Math.min(...ratioDefinedDocs.map(({ heightMagnitude }) => NumCast(heightMagnitude))); + if (minimum !== 0) { + ratioDefinedDocs.forEach(layout => layout.heightMagnitude = NumCast(layout.heightMagnitude) / minimum); + } + } + }); + + return { heightSpecifiers, starSum }; + } + + /** + * This returns the total quantity, in pixels, that this + * view needs to reserve for child documents that have + * (with higher priority) requested a fixed pixel width. + * + * If the underlying resolvedLayoutInformation returns null + * because we're waiting on promises to resolve, this value will be undefined as well. + */ + @computed + private get totalFixedAllocation(): number | undefined { + return this.resolvedLayoutInformation?.heightSpecifiers.reduce( + (sum, { magnitude, unit }) => sum + (unit === HeightUnit.Pixel ? magnitude : 0), 0); + } + + /** + * @returns the total quantity, in pixels, that this + * view needs to reserve for child documents that have + * (with lower priority) requested a certain relative proportion of the + * remaining pixel width not allocated for fixed widths. + * + * If the underlying totalFixedAllocation returns undefined + * because we're waiting indirectly on promises to resolve, this value will be undefined as well. + */ + @computed + private get totalRatioAllocation(): number | undefined { + const layoutInfoLen = this.resolvedLayoutInformation.heightSpecifiers.length; + if (layoutInfoLen > 0 && this.totalFixedAllocation !== undefined) { + return this.props.PanelHeight() - (this.totalFixedAllocation + resizerHeight * (layoutInfoLen - 1)); + } + } + + /** + * @returns the total quantity, in pixels, that + * 1* (relative / star unit) is worth. For example, + * if the configuration has three documents, with, respectively, + * widths of 2*, 2* and 1*, and the panel width returns 1000px, + * this accessor returns 1000 / (2 + 2 + 1), or 200px. + * Elsewhere, this is then multiplied by each relative-width + * document's (potentially decimal) * count to compute its actual width (400px, 400px and 200px). + * + * If the underlying totalRatioAllocation or this.resolveLayoutInformation return undefined + * because we're waiting indirectly on promises to resolve, this value will be undefined as well. + */ + @computed + private get rowUnitLength(): number | undefined { + if (this.resolvedLayoutInformation && this.totalRatioAllocation !== undefined) { + return this.totalRatioAllocation / this.resolvedLayoutInformation.starSum; + } + } + + /** + * This wrapper function exists to prevent mobx from + * needlessly rerendering the internal ContentFittingDocumentViews + */ + private getRowUnitLength = () => this.rowUnitLength; + + /** + * @param layout the document whose transform we'd like to compute + * Given a layout document, this function + * returns the resolved width it has requested, in pixels. + * @returns the stored row width if already in pixels, + * or the ratio width evaluated to a pixel value + */ + private lookupPixels = (layout: Doc): number => { + const rowUnitLength = this.rowUnitLength; + if (rowUnitLength === undefined) { + return 0; // we're still waiting on promises to resolve + } + let height = NumCast(layout.heightMagnitude); + if (StrCast(layout.heightUnit) === HeightUnit.Ratio) { + height *= rowUnitLength; + } + return height; + } + + /** + * @returns the transform that will correctly place + * the document decorations box, shifted to the right by + * the sum of all the resolved row widths of the + * documents before the target. + */ + private lookupIndividualTransform = (layout: Doc) => { + const rowUnitLength = this.rowUnitLength; + if (rowUnitLength === undefined) { + return Transform.Identity(); // we're still waiting on promises to resolve + } + let offset = 0; + for (const { layout: candidate } of this.childLayoutPairs) { + if (candidate === layout) { + return this.props.ScreenToLocalTransform().translate(0, -offset); + } + offset += this.lookupPixels(candidate) + resizerHeight; + } + return Transform.Identity(); // type coersion, this case should never be hit + } + + @undoBatch + @action + drop = (e: Event, de: DragManager.DropEvent) => { + if (super.drop(e, de)) { + de.complete.docDragData?.droppedDocuments.forEach(action((d: Doc) => { + d.heightUnit = "*"; + d.heightMagnitude = 1; + })); + } + return false; + } + + + @computed get onChildClickHandler() { return ScriptCast(this.Document.onChildClick); } + + /** + * @returns the resolved list of rendered child documents, displayed + * at their resolved pixel widths, each separated by a resizer. + */ + @computed + private get contents(): JSX.Element[] | null { + const { childLayoutPairs } = this; + const { Document, PanelWidth } = this.props; + const collector: JSX.Element[] = []; + for (let i = 0; i < childLayoutPairs.length; i++) { + const { layout } = childLayoutPairs[i]; + collector.push( + <div + className={"document-wrapper"} + key={Utils.GenerateGuid()} + > + <ContentFittingDocumentView + {...this.props} + Document={layout} + DataDocument={layout.resolvedDataDoc as Doc} + CollectionDoc={this.props.Document} + PanelHeight={() => this.lookupPixels(layout)} + PanelWidth={() => PanelWidth() - (BoolCast(Document.showHeightLabels) ? 20 : 0)} + getTransform={() => this.lookupIndividualTransform(layout)} + onClick={this.onChildClickHandler} + renderDepth={this.props.renderDepth + 1} + /> + <HeightLabel + layout={layout} + collectionDoc={Document} + /> + </div>, + <ResizeBar + height={resizerHeight} + key={Utils.GenerateGuid()} + columnUnitLength={this.getRowUnitLength} + toTop={layout} + toBottom={childLayoutPairs[i + 1]?.layout} + /> + ); + } + collector.pop(); // removes the final extraneous resize bar + return collector; + } + + render(): JSX.Element { + return ( + <div className={"collectionMultirowView_contents"} ref={this.createDashEventsTarget}> + {this.contents} + </div> + ); + } + +}
\ No newline at end of file diff --git a/src/client/views/collections/collectionMulticolumn/MultirowHeightLabel.tsx b/src/client/views/collections/collectionMulticolumn/MultirowHeightLabel.tsx new file mode 100644 index 000000000..56a2e868d --- /dev/null +++ b/src/client/views/collections/collectionMulticolumn/MultirowHeightLabel.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import { observer } from "mobx-react"; +import { computed } from "mobx"; +import { Doc } from "../../../../new_fields/Doc"; +import { NumCast, StrCast, BoolCast } from "../../../../new_fields/Types"; +import { EditableView } from "../../EditableView"; +import { HeightUnit } from "./CollectionMultirowView"; + +interface HeightLabelProps { + layout: Doc; + collectionDoc: Doc; + decimals?: number; +} + +@observer +export default class HeightLabel extends React.Component<HeightLabelProps> { + + @computed + private get contents() { + const { layout, decimals } = this.props; + const getUnit = () => StrCast(layout.heightUnit); + const getMagnitude = () => String(+NumCast(layout.heightMagnitude).toFixed(decimals ?? 3)); + return ( + <div className={"label-wrapper"}> + <EditableView + GetValue={getMagnitude} + SetValue={value => { + const converted = Number(value); + if (!isNaN(converted) && converted > 0) { + layout.heightMagnitude = converted; + return true; + } + return false; + }} + contents={getMagnitude()} + /> + <EditableView + GetValue={getUnit} + SetValue={value => { + if (Object.values(HeightUnit).includes(value)) { + layout.heightUnit = value; + return true; + } + return false; + }} + contents={getUnit()} + /> + </div> + ); + } + + render() { + return BoolCast(this.props.collectionDoc.showHeightLabels) ? this.contents : (null); + } + +}
\ No newline at end of file diff --git a/src/client/views/collections/collectionMulticolumn/MultirowResizer.tsx b/src/client/views/collections/collectionMulticolumn/MultirowResizer.tsx new file mode 100644 index 000000000..20c6cd3df --- /dev/null +++ b/src/client/views/collections/collectionMulticolumn/MultirowResizer.tsx @@ -0,0 +1,116 @@ +import * as React from "react"; +import { observer } from "mobx-react"; +import { observable, action } from "mobx"; +import { Doc } from "../../../../new_fields/Doc"; +import { NumCast, StrCast } from "../../../../new_fields/Types"; +import { HeightUnit } from "./CollectionMultirowView"; + +interface ResizerProps { + height: number; + columnUnitLength(): number | undefined; + toTop?: Doc; + toBottom?: Doc; +} + +enum ResizeMode { + Global = "blue", + Pinned = "red", + Undefined = "black" +} + +const resizerOpacity = 1; + +@observer +export default class ResizeBar extends React.Component<ResizerProps> { + @observable private isHoverActive = false; + @observable private isResizingActive = false; + @observable private resizeMode = ResizeMode.Undefined; + + @action + private registerResizing = (e: React.PointerEvent<HTMLDivElement>, mode: ResizeMode) => { + e.stopPropagation(); + e.preventDefault(); + this.resizeMode = mode; + window.removeEventListener("pointermove", this.onPointerMove); + window.removeEventListener("pointerup", this.onPointerUp); + window.addEventListener("pointermove", this.onPointerMove); + window.addEventListener("pointerup", this.onPointerUp); + this.isResizingActive = true; + } + + private onPointerMove = ({ movementY }: PointerEvent) => { + const { toTop: toTop, toBottom: toBottom, columnUnitLength } = this.props; + const movingDown = movementY > 0; + const toNarrow = movingDown ? toBottom : toTop; + const toWiden = movingDown ? toTop : toBottom; + const unitLength = columnUnitLength(); + if (unitLength) { + if (toNarrow) { + const { heightUnit, heightMagnitude } = toNarrow; + const scale = heightUnit === HeightUnit.Ratio ? unitLength : 1; + toNarrow.heightMagnitude = NumCast(heightMagnitude) - Math.abs(movementY) / scale; + } + if (this.resizeMode === ResizeMode.Pinned && toWiden) { + const { heightUnit, heightMagnitude } = toWiden; + const scale = heightUnit === HeightUnit.Ratio ? unitLength : 1; + toWiden.heightMagnitude = NumCast(heightMagnitude) + Math.abs(movementY) / scale; + } + } + } + + private get isActivated() { + const { toTop, toBottom } = this.props; + if (toTop && toBottom) { + if (StrCast(toTop.heightUnit) === HeightUnit.Pixel && StrCast(toBottom.heightUnit) === HeightUnit.Pixel) { + return false; + } + return true; + } else if (toTop) { + if (StrCast(toTop.heightUnit) === HeightUnit.Pixel) { + return false; + } + return true; + } else if (toBottom) { + if (StrCast(toBottom.heightUnit) === HeightUnit.Pixel) { + return false; + } + return true; + } + return false; + } + + @action + private onPointerUp = () => { + this.resizeMode = ResizeMode.Undefined; + this.isResizingActive = false; + this.isHoverActive = false; + window.removeEventListener("pointermove", this.onPointerMove); + window.removeEventListener("pointerup", this.onPointerUp); + } + + render() { + return ( + <div + className={"resizer"} + style={{ + height: this.props.height, + opacity: this.isActivated && this.isHoverActive ? resizerOpacity : 0 + }} + onPointerEnter={action(() => this.isHoverActive = true)} + onPointerLeave={action(() => !this.isResizingActive && (this.isHoverActive = false))} + > + <div + className={"internal"} + onPointerDown={e => this.registerResizing(e, ResizeMode.Pinned)} + style={{ backgroundColor: this.resizeMode }} + /> + <div + className={"internal"} + onPointerDown={e => this.registerResizing(e, ResizeMode.Global)} + style={{ backgroundColor: this.resizeMode }} + /> + </div> + ); + } + +}
\ No newline at end of file |