aboutsummaryrefslogtreecommitdiff
path: root/src/client/views/collections/collectionMulticolumn
diff options
context:
space:
mode:
authorHannah <hannah_chow@brown.edu>2020-02-11 06:34:19 -0500
committerHannah <hannah_chow@brown.edu>2020-02-11 06:34:19 -0500
commitb60154479add51f826afaeff074cf43a6b416831 (patch)
tree723f7f1356b1c3dcf5c409347b125d5a577b538f /src/client/views/collections/collectionMulticolumn
parent74ca8ac01e32faa8adab54ddfd806fe46cda03ec (diff)
parentdc6453e27375a1b3d614a74b7fd1d83695f130d7 (diff)
merge
Diffstat (limited to 'src/client/views/collections/collectionMulticolumn')
-rw-r--r--src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.scss33
-rw-r--r--src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx258
-rw-r--r--src/client/views/collections/collectionMulticolumn/MulticolumnResizer.tsx116
-rw-r--r--src/client/views/collections/collectionMulticolumn/MulticolumnWidthLabel.tsx56
4 files changed, 463 insertions, 0 deletions
diff --git a/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.scss b/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.scss
new file mode 100644
index 000000000..f57ba438a
--- /dev/null
+++ b/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.scss
@@ -0,0 +1,33 @@
+.collectionMulticolumnView_contents {
+ display: flex;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+
+ .document-wrapper {
+ display: flex;
+ flex-direction: column;
+
+ .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: column;
+
+ .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/CollectionMulticolumnView.tsx b/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx
new file mode 100644
index 000000000..041eb69da
--- /dev/null
+++ b/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx
@@ -0,0 +1,258 @@
+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 "./collectionMulticolumnView.scss";
+import { computed, trace, observable, action } from 'mobx';
+import { Transform } from '../../../util/Transform';
+import WidthLabel from './MulticolumnWidthLabel';
+import ResizeBar from './MulticolumnResizer';
+import { undoBatch } from '../../../util/UndoManager';
+import { DragManager } from '../../../util/DragManager';
+
+type MulticolumnDocument = makeInterface<[typeof documentSchema]>;
+const MulticolumnDocument = makeInterface(documentSchema);
+
+interface WidthSpecifier {
+ magnitude: number;
+ unit: string;
+}
+
+interface LayoutData {
+ widthSpecifiers: WidthSpecifier[];
+ starSum: number;
+}
+
+export const WidthUnit = {
+ Pixel: "px",
+ Ratio: "*"
+};
+
+const resolvedUnits = Object.values(WidthUnit);
+const resizerWidth = 4;
+
+@observer
+export class CollectionMulticolumnView extends CollectionSubView(MulticolumnDocument) {
+
+ /**
+ * @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) === WidthUnit.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 widthSpecifiers: WidthSpecifier[] = [];
+ this.childLayoutPairs.map(({ layout: { widthUnit, widthMagnitude } }) => {
+ const unit = StrCast(widthUnit);
+ const magnitude = NumCast(widthMagnitude);
+ if (unit && magnitude && magnitude > 0 && resolvedUnits.includes(unit)) {
+ (unit === WidthUnit.Ratio) && (starSum += magnitude);
+ widthSpecifiers.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(({ widthMagnitude }) => NumCast(widthMagnitude)));
+ if (minimum !== 0) {
+ ratioDefinedDocs.forEach(layout => layout.widthMagnitude = NumCast(layout.widthMagnitude) / minimum);
+ }
+ }
+ });
+
+ return { widthSpecifiers, 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?.widthSpecifiers.reduce(
+ (sum, { magnitude, unit }) => sum + (unit === WidthUnit.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.widthSpecifiers.length;
+ if (layoutInfoLen > 0 && this.totalFixedAllocation !== undefined) {
+ return this.props.PanelWidth() - (this.totalFixedAllocation + resizerWidth * (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 columnUnitLength(): 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 getColumnUnitLength = () => this.columnUnitLength;
+
+ /**
+ * @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 column width if already in pixels,
+ * or the ratio width evaluated to a pixel value
+ */
+ private lookupPixels = (layout: Doc): number => {
+ const columnUnitLength = this.columnUnitLength;
+ if (columnUnitLength === undefined) {
+ return 0; // we're still waiting on promises to resolve
+ }
+ let width = NumCast(layout.widthMagnitude);
+ if (StrCast(layout.widthUnit) === WidthUnit.Ratio) {
+ width *= columnUnitLength;
+ }
+ return width;
+ }
+
+ /**
+ * @returns the transform that will correctly place
+ * the document decorations box, shifted to the right by
+ * the sum of all the resolved column widths of the
+ * documents before the target.
+ */
+ private lookupIndividualTransform = (layout: Doc) => {
+ const columnUnitLength = this.columnUnitLength;
+ if (columnUnitLength === 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(-offset, 0);
+ }
+ offset += this.lookupPixels(candidate) + resizerWidth;
+ }
+ 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.widthUnit = "*";
+ d.widthMagnitude = 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, PanelHeight } = 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}
+ PanelWidth={() => this.lookupPixels(layout)}
+ PanelHeight={() => PanelHeight() - (BoolCast(Document.showWidthLabels) ? 20 : 0)}
+ getTransform={() => this.lookupIndividualTransform(layout)}
+ onClick={this.onChildClickHandler}
+ />
+ <WidthLabel
+ layout={layout}
+ collectionDoc={Document}
+ />
+ </div>,
+ <ResizeBar
+ width={resizerWidth}
+ key={Utils.GenerateGuid()}
+ columnUnitLength={this.getColumnUnitLength}
+ toLeft={layout}
+ toRight={childLayoutPairs[i + 1]?.layout}
+ />
+ );
+ }
+ collector.pop(); // removes the final extraneous resize bar
+ return collector;
+ }
+
+ render(): JSX.Element {
+ return (
+ <div className={"collectionMulticolumnView_contents"} ref={this.createDashEventsTarget}>
+ {this.contents}
+ </div>
+ );
+ }
+
+} \ No newline at end of file
diff --git a/src/client/views/collections/collectionMulticolumn/MulticolumnResizer.tsx b/src/client/views/collections/collectionMulticolumn/MulticolumnResizer.tsx
new file mode 100644
index 000000000..11e210958
--- /dev/null
+++ b/src/client/views/collections/collectionMulticolumn/MulticolumnResizer.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 { WidthUnit } from "./CollectionMulticolumnView";
+
+interface ResizerProps {
+ width: number;
+ columnUnitLength(): number | undefined;
+ toLeft?: Doc;
+ toRight?: 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 = ({ movementX }: PointerEvent) => {
+ const { toLeft, toRight, columnUnitLength } = this.props;
+ const movingRight = movementX > 0;
+ const toNarrow = movingRight ? toRight : toLeft;
+ const toWiden = movingRight ? toLeft : toRight;
+ const unitLength = columnUnitLength();
+ if (unitLength) {
+ if (toNarrow) {
+ const { widthUnit, widthMagnitude } = toNarrow;
+ const scale = widthUnit === WidthUnit.Ratio ? unitLength : 1;
+ toNarrow.widthMagnitude = NumCast(widthMagnitude) - Math.abs(movementX) / scale;
+ }
+ if (this.resizeMode === ResizeMode.Pinned && toWiden) {
+ const { widthUnit, widthMagnitude } = toWiden;
+ const scale = widthUnit === WidthUnit.Ratio ? unitLength : 1;
+ toWiden.widthMagnitude = NumCast(widthMagnitude) + Math.abs(movementX) / scale;
+ }
+ }
+ }
+
+ private get isActivated() {
+ const { toLeft, toRight } = this.props;
+ if (toLeft && toRight) {
+ if (StrCast(toLeft.widthUnit) === WidthUnit.Pixel && StrCast(toRight.widthUnit) === WidthUnit.Pixel) {
+ return false;
+ }
+ return true;
+ } else if (toLeft) {
+ if (StrCast(toLeft.widthUnit) === WidthUnit.Pixel) {
+ return false;
+ }
+ return true;
+ } else if (toRight) {
+ if (StrCast(toRight.widthUnit) === WidthUnit.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={{
+ width: this.props.width,
+ 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
diff --git a/src/client/views/collections/collectionMulticolumn/MulticolumnWidthLabel.tsx b/src/client/views/collections/collectionMulticolumn/MulticolumnWidthLabel.tsx
new file mode 100644
index 000000000..b394fed62
--- /dev/null
+++ b/src/client/views/collections/collectionMulticolumn/MulticolumnWidthLabel.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 { WidthUnit } from "./CollectionMulticolumnView";
+
+interface WidthLabelProps {
+ layout: Doc;
+ collectionDoc: Doc;
+ decimals?: number;
+}
+
+@observer
+export default class WidthLabel extends React.Component<WidthLabelProps> {
+
+ @computed
+ private get contents() {
+ const { layout, decimals } = this.props;
+ const getUnit = () => StrCast(layout.widthUnit);
+ const getMagnitude = () => String(+NumCast(layout.widthMagnitude).toFixed(decimals ?? 3));
+ return (
+ <div className={"label-wrapper"}>
+ <EditableView
+ GetValue={getMagnitude}
+ SetValue={value => {
+ const converted = Number(value);
+ if (!isNaN(converted) && converted > 0) {
+ layout.widthMagnitude = converted;
+ return true;
+ }
+ return false;
+ }}
+ contents={getMagnitude()}
+ />
+ <EditableView
+ GetValue={getUnit}
+ SetValue={value => {
+ if (Object.values(WidthUnit).includes(value)) {
+ layout.widthUnit = value;
+ return true;
+ }
+ return false;
+ }}
+ contents={getUnit()}
+ />
+ </div>
+ );
+ }
+
+ render() {
+ return BoolCast(this.props.collectionDoc.showWidthLabels) ? this.contents : (null);
+ }
+
+} \ No newline at end of file