diff options
Diffstat (limited to 'src/client/views/collections')
16 files changed, 423 insertions, 213 deletions
diff --git a/src/client/views/collections/CollectionStackingView.tsx b/src/client/views/collections/CollectionStackingView.tsx index 648ff5087..bffaf86b1 100644 --- a/src/client/views/collections/CollectionStackingView.tsx +++ b/src/client/views/collections/CollectionStackingView.tsx @@ -226,7 +226,7 @@ export class CollectionStackingView extends CollectionSubView<StackingDocument, layerProvider={this.props.layerProvider} docViewPath={this.props.docViewPath} fitWidth={this.props.childFitWidth} - isContentActive={returnFalse} + isContentActive={emptyFunction} isDocumentActive={this.isContentActive} LayoutTemplate={this.props.childLayoutTemplate} LayoutTemplateString={this.props.childLayoutString} diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx index 5dffc65fc..fc1bcb8b9 100644 --- a/src/client/views/collections/CollectionSubView.tsx +++ b/src/client/views/collections/CollectionSubView.tsx @@ -22,7 +22,6 @@ import ReactLoading from 'react-loading'; export interface SubCollectionViewProps extends CollectionViewProps { CollectionView: Opt<CollectionView>; - SetSubView?: (subView: any) => void; isAnyChildContentActive: () => boolean; } @@ -49,10 +48,6 @@ export function CollectionSubView<T, X>(schemaCtor: (doc: Doc) => T, moreProps?: this.createDashEventsTarget(ele); } - componentDidMount() { - this.props.SetSubView?.(this); - } - componentWillUnmount() { this.gestureDisposer?.(); this._multiTouchDisposer?.(); @@ -220,7 +215,7 @@ export function CollectionSubView<T, X>(schemaCtor: (doc: Doc) => T, moreProps?: const movedDocs = docDragData.droppedDocuments.filter((d, i) => docDragData.draggedDocuments[i] === d); const addedDocs = docDragData.droppedDocuments.filter((d, i) => docDragData.draggedDocuments[i] !== d); if (movedDocs.length) { - const canAdd = this.props.Document._viewType === CollectionViewType.Pile || de.embedKey || !this.props.isAnnotationOverlay || + const canAdd = this.props.Document._viewType === CollectionViewType.Pile || de.embedKey || (!this.props.isAnnotationOverlay || this.props.Document.allowOverlayDrop) || Doc.AreProtosEqual(Cast(movedDocs[0].annotationOn, Doc, null), this.props.Document); added = docDragData.moveDocument(movedDocs, this.props.Document, canAdd ? this.addDocument : returnFalse); } else { diff --git a/src/client/views/collections/CollectionTreeView.scss b/src/client/views/collections/CollectionTreeView.scss index d370d21ab..b664d9d82 100644 --- a/src/client/views/collections/CollectionTreeView.scss +++ b/src/client/views/collections/CollectionTreeView.scss @@ -71,6 +71,15 @@ display: none; } +.collectionTreeView-titleBar { + display: inline-block; + width: 100%; + height: max-content; + .contentFittingDocumentView { + display: block; // makes titleBar take up full width of the treeView (flex doesn't for some reason) + } +} + .collectionTreeView-keyHeader:hover { background: #797777; cursor: pointer; diff --git a/src/client/views/collections/CollectionTreeView.tsx b/src/client/views/collections/CollectionTreeView.tsx index 3852987b9..ea077ea40 100644 --- a/src/client/views/collections/CollectionTreeView.tsx +++ b/src/client/views/collections/CollectionTreeView.tsx @@ -1,4 +1,3 @@ -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { action, computed, IReactionDisposer, observable, reaction } from "mobx"; import { observer } from "mobx-react"; import { DataSym, Doc, DocListCast, HeightSym, Opt, StrListCast, WidthSym } from '../../../fields/Doc'; @@ -8,13 +7,14 @@ import { Document, listSpec } from '../../../fields/Schema'; import { ScriptField } from '../../../fields/ScriptField'; import { BoolCast, Cast, NumCast, ScriptCast, StrCast } from '../../../fields/Types'; import { TraceMobx } from '../../../fields/util'; -import { returnEmptyDoclist, returnEmptyFilter, returnFalse, returnTrue, emptyFunction } from '../../../Utils'; +import { emptyFunction, OmitKeys, returnEmptyDoclist, returnEmptyFilter, returnFalse, returnTrue, returnOne } from '../../../Utils'; import { DocUtils } from '../../documents/Documents'; import { CurrentUserUtils } from '../../util/CurrentUserUtils'; import { DocumentManager } from '../../util/DocumentManager'; import { DragManager, dropActionType } from "../../util/DragManager"; import { SelectionManager } from '../../util/SelectionManager'; import { SnappingManager } from '../../util/SnappingManager'; +import { Transform } from '../../util/Transform'; import { undoBatch, UndoManager } from '../../util/UndoManager'; import { ContextMenu } from '../ContextMenu'; import { ContextMenuProps } from '../ContextMenuItem'; @@ -22,11 +22,11 @@ import { EditableView } from "../EditableView"; import { DocumentView } from '../nodes/DocumentView'; import { FormattedTextBox } from '../nodes/formattedText/FormattedTextBox'; import { StyleProp } from '../StyleProvider'; +import { CollectionFreeFormView } from './collectionFreeForm'; import { CollectionSubView } from "./CollectionSubView"; import "./CollectionTreeView.scss"; import { TreeView } from "./TreeView"; import React = require("react"); -import { Transform } from '../../util/Transform'; const _global = (window /* browser */ || global /* node */) as any; export type collectionTreeViewProps = { @@ -41,10 +41,14 @@ export type collectionTreeViewProps = { @observer export class CollectionTreeView extends CollectionSubView<Document, Partial<collectionTreeViewProps>>(Document) { - private treedropDisposer?: DragManager.DragDropDisposer; + private _treedropDisposer?: DragManager.DragDropDisposer; private _mainEle?: HTMLDivElement; + private _titleRef?: HTMLDivElement | HTMLInputElement | null; private _disposers: { [name: string]: IReactionDisposer } = {}; - MainEle = () => this._mainEle; + private _isDisposing = false; // notes that instance is in process of being disposed + private refList: Set<any> = new Set(); // list of tree view items to monitor for height changes + private observer: any; // observer for monitoring tree view items. + private static expandViewLabelSize = 20; @computed get doc() { return this.props.Document; } @computed get dataDoc() { return this.props.DataDoc || this.doc; } @@ -54,6 +58,10 @@ export class CollectionTreeView extends CollectionSubView<Document, Partial<coll @computed get fileSysMode() { return this.doc.treeViewType === "fileSystem"; } @computed get dashboardMode() { return this.doc === Doc.UserDoc().myDashboards; } + @observable _explainerHeight = 0; // height of the description of the tree view + + MainEle = () => this._mainEle; + // these should stay in synch with counterparts in DocComponent.ts ViewBoxAnnotatableComponent @observable _isAnyChildContentActive = false; whenChildContentsActiveChanged = action((isActive: boolean) => this.props.whenChildContentsActiveChanged(this._isAnyChildContentActive = isActive)); @@ -62,11 +70,10 @@ export class CollectionTreeView extends CollectionSubView<Document, Partial<coll this.props.isSelected(outsideReaction) || this._isAnyChildContentActive || this.props.rootSelected(outsideReaction)) ? true : false) - isDisposing = false; componentWillUnmount() { - this.isDisposing = true; + this._isDisposing = true; super.componentWillUnmount(); - this.treedropDisposer?.(); + this._treedropDisposer?.(); Object.values(this._disposers).forEach(disposer => disposer?.()); } @@ -76,13 +83,13 @@ export class CollectionTreeView extends CollectionSubView<Document, Partial<coll { fireImmediately: true }); } - refList: Set<any> = new Set(); - observer: any; computeHeight = () => { - if (this.isDisposing) return; - const bodyHeight = Array.from(this.refList).reduce((p, r) => p + Number(getComputedStyle(r).height.replace("px", "")), this.paddingTop() + this.paddingBot()); - this.layoutDoc._autoHeightMargins = bodyHeight; - this.props.setHeight(this.documentTitleHeight() + bodyHeight); + if (!this._isDisposing) { + const titleHeight = !this._titleRef ? this.marginTop() : Number(getComputedStyle(this._titleRef).height.replace("px", "")); + const bodyHeight = Array.from(this.refList).reduce((p, r) => p + Number(getComputedStyle(r).height.replace("px", "")), this.marginBot()); + this.layoutDoc._autoHeightMargins = bodyHeight; + this.props.setHeight(bodyHeight + titleHeight); + } } unobserveHeight = (ref: any) => { this.refList.delete(ref); @@ -101,8 +108,8 @@ export class CollectionTreeView extends CollectionSubView<Document, Partial<coll } } protected createTreeDropTarget = (ele: HTMLDivElement) => { - this.treedropDisposer?.(); - if (this._mainEle = ele) this.treedropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.doc, this.onInternalPreDrop.bind(this)); + this._treedropDisposer?.(); + if (this._mainEle = ele) this._treedropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.doc, this.onInternalPreDrop.bind(this)); } protected onInternalPreDrop = (e: Event, de: DragManager.DropEvent, targetAction: dropActionType) => { @@ -165,60 +172,44 @@ export class CollectionTreeView extends CollectionSubView<Document, Partial<coll this.addDoc(TreeView.makeTextBullet(), childDocs.length ? childDocs[0] : undefined, true); } - editableTitle = (childDocs: Doc[]) => { - return !this.dataDoc ? (null) : - <EditableView - contents={this.dataDoc.title} - display={"block"} - maxHeight={72} - height={"auto"} - GetValue={() => StrCast(this.dataDoc.title)} - SetValue={undoBatch((value: string, shift: boolean, enter: boolean) => { - if (enter && this.props.Document.treeViewType === "outline") this.makeTextCollection(childDocs); - this.dataDoc.title = value; - return true; - })} />; + get editableTitle() { + return <EditableView + contents={this.dataDoc.title} + display={"block"} + maxHeight={72} + height={"auto"} + GetValue={() => StrCast(this.dataDoc.title)} + SetValue={undoBatch((value: string, shift: boolean, enter: boolean) => { + if (enter && this.props.Document.treeViewType === "outline") this.makeTextCollection(this.treeChildren); + this.dataDoc.title = value; + return true; + })} />; } - documentTitle = (childDocs: Doc[]) => { - return <div style={{ display: "inline-block", width: "100%", height: this.documentTitleHeight() }} key={this.doc[Id]} - onKeyDown={e => { - e.stopPropagation(); - e.key === "Enter" && this.makeTextCollection(childDocs); - }}> - <DocumentView - Document={this.doc} - DataDoc={undefined} - LayoutTemplateString={FormattedTextBox.LayoutString("text")} - renderDepth={this.props.renderDepth + 1} - isContentActive={this.isContentActive} - isDocumentActive={this.isContentActive} - rootSelected={returnTrue} - docViewPath={this.props.docViewPath} - styleProvider={this.props.styleProvider} - layerProvider={this.props.layerProvider} - PanelWidth={this.documentTitleWidth} - PanelHeight={this.documentTitleHeight} - NativeWidth={this.documentTitleWidth} - NativeHeight={this.documentTitleHeight} - focus={this.props.focus} - treeViewDoc={this.props.Document} - ScreenToLocalTransform={this.titleTransform} - docFilters={returnEmptyFilter} - docRangeFilters={returnEmptyFilter} - searchFilterDocs={returnEmptyDoclist} - ContainingCollectionDoc={this.doc} - ContainingCollectionView={this.props.CollectionView} - addDocument={this.props.addDocument} - moveDocument={returnFalse} - removeDocument={returnFalse} - whenChildContentsActiveChanged={this.whenChildContentsActiveChanged} - addDocTab={this.props.addDocTab} - pinToPres={this.props.pinToPres} - bringToFront={returnFalse} - /> - </div>; + get documentTitle() { + return <FormattedTextBox + {...this.props} + fieldKey={"text"} + renderDepth={this.props.renderDepth + 1} + isContentActive={this.isContentActive} + isDocumentActive={this.isContentActive} + rootSelected={returnTrue} + forceAutoHeight={true} // needed to make the title resize even if the rest of the tree view is not autoHeight + PanelWidth={this.documentTitleWidth} + PanelHeight={this.documentTitleHeight} + scaling={returnOne} + docFilters={returnEmptyFilter} + docRangeFilters={returnEmptyFilter} + searchFilterDocs={returnEmptyDoclist} + ContainingCollectionDoc={this.doc} + ContainingCollectionView={this.props.CollectionView} + addDocument={returnFalse} + moveDocument={returnFalse} + removeDocument={returnFalse} + whenChildContentsActiveChanged={this.whenChildContentsActiveChanged} + bringToFront={returnFalse} + />; } childContextMenuItems = () => { const customScripts = Cast(this.doc.childContextMenuScripts, listSpec(ScriptField), []); @@ -263,21 +254,31 @@ export class CollectionTreeView extends CollectionSubView<Document, Partial<coll ); } @computed get titleBar() { - const hideTitle = this.props.treeViewHideTitle || this.doc.treeViewHideTitle; - return hideTitle ? (null) : (this.outlineMode ? this.documentTitle : this.editableTitle)(this.treeChildren); + return this.dataDoc === null ? (null) : + <div className="collectionTreeView-titleBar" key={this.doc[Id]} + style={!this.outlineMode ? { paddingLeft: this.marginX(), paddingTop: this.marginTop() } : {}} + ref={r => this._titleRef = r} + onKeyDown={e => { + if (this.outlineMode) { + e.stopPropagation(); + e.key === "Enter" && this.makeTextCollection(this.treeChildren); + } + }}> + {this.outlineMode ? this.documentTitle : this.editableTitle} + </div>; + } + + @computed get noviceExplainer() { + return !Doc.UserDoc().noviceMode || !this.rootDoc.explainer ? (null) : + <div className="documentExplanation"> {this.rootDoc.explainer} </div>; } return35 = () => 35; @computed get buttonMenu() { - const menuDoc: Doc = Cast(this.rootDoc.buttonMenuDoc, Doc, null); + const menuDoc = Cast(this.rootDoc.buttonMenuDoc, Doc, null); // To create a multibutton menu add a CollectionLinearView - if (menuDoc) { - - const width: number = NumCast(menuDoc._width, 30); - const height: number = NumCast(menuDoc._height, 30); - console.log(menuDoc.title, width, height); - return (<div className="buttonMenu-docBtn" - style={{ width: width, height: height }}> + return !menuDoc ? null : + (<div className="buttonMenu-docBtn" style={{ width: NumCast(menuDoc._width, 30), height: NumCast(menuDoc._height, 30) }}> <DocumentView Document={menuDoc} DataDoc={menuDoc} @@ -306,11 +307,8 @@ export class CollectionTreeView extends CollectionSubView<Document, Partial<coll ContainingCollectionDoc={undefined} /> </div>); - } } - @observable _explainerHeight: number = 0; - @computed get nativeWidth() { return Doc.NativeWidth(this.Document, undefined, true); } @computed get nativeHeight() { return Doc.NativeHeight(this.Document, undefined, true); } @@ -321,47 +319,81 @@ export class CollectionTreeView extends CollectionSubView<Document, Partial<coll const wscale = nw ? this.props.PanelWidth() / nw : 1; return wscale < hscale ? wscale : hscale; } - paddingX = () => NumCast(this.doc._xPadding, 15); - paddingTop = () => NumCast(this.doc._yPadding, 20); - paddingBot = () => NumCast(this.doc._yPadding, 20); + marginX = () => NumCast(this.doc._xMargin); + marginTop = () => NumCast(this.doc._yMargin); + marginBot = () => NumCast(this.doc._yMargin); documentTitleWidth = () => Math.min(this.layoutDoc?.[WidthSym](), this.panelWidth()); documentTitleHeight = () => (this.layoutDoc?.[HeightSym]() || 0) - NumCast(this.layoutDoc.autoHeightMargins); - titleTransform = () => this.props.ScreenToLocalTransform().translate(-NumCast(this.doc._xPadding, 10), -NumCast(this.doc._yPadding, 20)); truncateTitleWidth = () => this.treeViewtruncateTitleWidth; onChildClick = () => this.props.onChildClick?.() || ScriptCast(this.doc.onChildClick); - panelWidth = () => (this.props.PanelWidth() - 2 * this.paddingX()) * (this.props.scaling?.() || 1); - render() { - TraceMobx(); + panelWidth = () => Math.max(0, this.props.PanelWidth() - this.marginX() - CollectionTreeView.expandViewLabelSize) * (this.props.scaling?.() || 1); + + addAnnotationDocument = (doc: Doc | Doc[]) => this.props.CollectionView?.addDocument(doc, `${this.props.fieldKey}-annotations`) || false; + remAnnotationDocument = (doc: Doc | Doc[]) => this.props.CollectionView?.removeDocument(doc, `${this.props.fieldKey}-annotations`) || false; + moveAnnotationDocument = (doc: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (document: Doc | Doc[], annotationKey?: string) => boolean) => + this.props.CollectionView?.moveDocument(doc, targetCollection, addDocument, `${this.props.fieldKey}-annotations`) || false + + contentFunc = () => { const background = () => this.props.styleProvider?.(this.doc, this.props, StyleProp.BackgroundColor); const pointerEvents = () => !this.props.isContentActive() && !SnappingManager.GetIsDragging() ? "none" : undefined; - const buttonMenu = this.rootDoc.buttonMenu; - const noviceExplainer = this.rootDoc.explainer; - - return !(this.doc instanceof Doc) || !this.treeChildren ? (null) : - <> - {this.titleBar} + const titleBar = this.props.treeViewHideTitle || this.doc.treeViewHideTitle ? (null) : this.titleBar; + return [ + <div className="collectionTreeView-contents" key="tree" style={{ + ...(!titleBar ? { paddingLeft: this.marginX(), paddingTop: this.marginTop() } : {}), + overflow: "auto", + height: this.layoutDoc._autoHeight ? "max-content" : "100%" + }} > + {titleBar} <div className="collectionTreeView-container" - style={this.outlineMode ? { transform: `scale(${this.contentScaling})`, width: `calc(${100 / this.contentScaling}%)` } : {}} + style={{ + transform: this.outlineMode ? `scale(${this.contentScaling})` : "", + paddingLeft: `${this.marginX()}px`, + height: "max-content", + width: this.outlineMode ? `calc(${100 / this.contentScaling}%)` : "" + }} onContextMenu={this.onContextMenu}> - {buttonMenu || noviceExplainer ? <div className="documentButtonMenu" ref={action((r: HTMLDivElement) => r && (this._explainerHeight = r.getBoundingClientRect().height))}> - {buttonMenu ? this.buttonMenu : null} - {Doc.UserDoc().noviceMode && noviceExplainer ? - <div className="documentExplanation"> - {noviceExplainer} - </div> - : null - } - </div> : null} + {!this.buttonMenu && !this.noviceExplainer ? (null) : + <div className="documentButtonMenu" ref={action((r: HTMLDivElement) => r && (this._explainerHeight = r.getBoundingClientRect().height))}> + {this.buttonMenu} + {this.noviceExplainer} + </div> + } <div className="collectionTreeView-dropTarget" - style={{ background: background(), height: `calc(100% - ${this._explainerHeight}px)`, paddingLeft: `${this.paddingX()}px`, paddingRight: `${this.paddingX()}px`, paddingBottom: `${this.paddingBot()}px`, paddingTop: `${this.paddingTop()}px`, pointerEvents: pointerEvents() }} + style={{ + background: background(), + height: `calc(100% - ${this._explainerHeight}px)`, + pointerEvents: pointerEvents() + }} onWheel={e => e.stopPropagation()} onDrop={this.onTreeDrop} - ref={this.createTreeDropTarget}> + ref={r => !this.doc.treeViewHasOverlay && r && this.createTreeDropTarget(r)}> <ul className={`no-indent${this.outlineMode ? "-outline" : ""}`} > {this.treeViewElements} </ul> </div > </div> - </>; + </div> + ]; + } + render() { + TraceMobx(); + + return !(this.doc instanceof Doc) || !this.treeChildren ? (null) : + this.doc.treeViewHasOverlay ? + <CollectionFreeFormView {...OmitKeys(this.props, ["NativeWidth", "NativeHeight", "setContentView"]).omit} + isAnnotationOverlay={true} + isAnnotationOverlayScrollable={true} + childDocumentsActive={this.props.isDocumentActive} + fieldKey={this.props.fieldKey + "-annotations"} + dropAction={"move"} + select={emptyFunction} + addDocument={this.addAnnotationDocument} + removeDocument={this.remAnnotationDocument} + moveDocument={this.moveAnnotationDocument} + bringToFront={emptyFunction} + renderDepth={this.props.renderDepth + 1} > + {this.contentFunc} + </CollectionFreeFormView> : + this.contentFunc(); } }
\ No newline at end of file diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx index 958ba62f7..510ec6ba9 100644 --- a/src/client/views/collections/CollectionView.tsx +++ b/src/client/views/collections/CollectionView.tsx @@ -62,6 +62,7 @@ export enum CollectionViewType { } export interface CollectionViewProps extends 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) layoutEngine?: () => string; setPreviewCursor?: (func: (x: number, y: number, drag: boolean, hide: boolean) => void) => void; @@ -124,8 +125,9 @@ export class CollectionView extends ViewBoxAnnotatableComponent<ViewBoxAnnotatab } screenToLocalTransform = () => this.props.renderDepth ? this.props.ScreenToLocalTransform() : this.props.ScreenToLocalTransform().scale(this.props.PanelWidth() / this.bodyPanelWidth()); - private SubView = (type: CollectionViewType, props: SubCollectionViewProps) => { + private renderSubView = (type: CollectionViewType | undefined, props: SubCollectionViewProps) => { TraceMobx(); + if (type === undefined) return null; switch (type) { default: case CollectionViewType.Freeform: return <CollectionFreeFormView key="collview" {...props} />; @@ -244,17 +246,13 @@ export class CollectionView extends ViewBoxAnnotatableComponent<ViewBoxAnnotatab childLayoutTemplate = () => this.props.childLayoutTemplate?.() || Cast(this.rootDoc.childLayoutTemplate, Doc, null); @computed get childLayoutString() { return StrCast(this.rootDoc.childLayoutString); } - - @observable _subView: any = undefined; - isContentActive = (outsideReaction?: boolean) => { - return this.props.isContentActive() ? true : false; + return this.props.isContentActive(); } render() { TraceMobx(); const props: SubCollectionViewProps = { ...this.props, - SetSubView: action((subView: any) => this._subView = subView), addDocument: this.addDocument, moveDocument: this.moveDocument, removeDocument: this.removeDocument, @@ -271,7 +269,7 @@ export class CollectionView extends ViewBoxAnnotatableComponent<ViewBoxAnnotatab return (<div className={"collectionView"} onContextMenu={this.onContextMenu} style={{ pointerEvents: this.props.layerProvider?.(this.rootDoc) === false ? "none" : undefined }}> {this.showIsTagged()} - {this.collectionViewType !== undefined ? this.SubView(this.collectionViewType, props) : (null)} + {this.renderSubView(this.collectionViewType, props)} </div>); } } diff --git a/src/client/views/collections/TabDocView.tsx b/src/client/views/collections/TabDocView.tsx index 6c6a2fb05..7e57d0e89 100644 --- a/src/client/views/collections/TabDocView.tsx +++ b/src/client/views/collections/TabDocView.tsx @@ -476,7 +476,6 @@ export class TabMinimapView extends React.Component<TabMinimapViewProps> { <div className="miniMap" style={{ width: miniSize, height: miniSize, background: this.props.background() }}> <CollectionFreeFormView Document={this.props.document} - SetSubView={() => this} CollectionView={undefined} ContainingCollectionView={undefined} ContainingCollectionDoc={undefined} @@ -484,7 +483,7 @@ export class TabMinimapView extends React.Component<TabMinimapViewProps> { childLayoutTemplate={this.childLayoutTemplate} // bcz: Ugh .. should probably be rendering a CollectionView or the minimap should be part of the collectionFreeFormView to avoid having to set stuff like this. noOverlay={true} // don't render overlay Docs since they won't scale setHeight={returnFalse} - isContentActive={returnFalse} + isContentActive={emptyFunction} isAnyChildContentActive={returnFalse} select={emptyFunction} dropAction={undefined} diff --git a/src/client/views/collections/TreeView.scss b/src/client/views/collections/TreeView.scss index 1ebc5873e..2e33d3564 100644 --- a/src/client/views/collections/TreeView.scss +++ b/src/client/views/collections/TreeView.scss @@ -53,14 +53,11 @@ } } +.treeView-container-outline-active .treeView-container-active { z-index: 100; position: relative; - - .formattedTextbox-sidebar { - background-color: #ffff001f !important; - height: 500px !important; - } + pointer-events: all; } .treeView-openRight { diff --git a/src/client/views/collections/TreeView.tsx b/src/client/views/collections/TreeView.tsx index 7f2128230..eedb353e3 100644 --- a/src/client/views/collections/TreeView.tsx +++ b/src/client/views/collections/TreeView.tsx @@ -81,7 +81,7 @@ export class TreeView extends React.Component<TreeViewProps> { static _openLevelScript: Opt<ScriptField | undefined>; private _header: React.RefObject<HTMLDivElement> = React.createRef(); private _tref = React.createRef<HTMLDivElement>(); - private _docRef: Opt<DocumentView>; + @observable _docRef: Opt<DocumentView>; private _selDisposer: Opt<IReactionDisposer>; private _editTitleScript: (() => ScriptField) | undefined; private _openScript: (() => ScriptField) | undefined; @@ -116,7 +116,8 @@ export class TreeView extends React.Component<TreeViewProps> { @computed get childLinks() { return this.childDocList("links"); } @computed get childAliases() { return this.childDocList("aliases"); } @computed get childAnnos() { return this.childDocList(this.fieldKey + "-annotations"); } - @computed get selected() { return SelectionManager.Views().lastElement()?.props.Document === this.props.document; } + @computed get selected() { return SelectionManager.IsSelected(this._docRef); } + // SelectionManager.Views().lastElement()?.props.Document === this.props.document; } childDocList(field: string) { const layout = Cast(Doc.LayoutField(this.doc), Doc, null); @@ -125,7 +126,12 @@ export class TreeView extends React.Component<TreeViewProps> { DocListCastOrNull(this.doc[field]); // otherwise use the document's data field } @undoBatch move = (doc: Doc | Doc[], target: Doc | undefined, addDoc: (doc: Doc | Doc[]) => boolean) => { - return this.doc !== target && this.props.removeDoc?.(doc) === true && addDoc(doc); + if (this.doc !== target && addDoc !== returnFalse) { // bcz: this should all be running in a Temp undo batch instead of hackily testing for returnFalse + if (this.props.removeDoc?.(doc) === true) { + return addDoc(doc); + } + } + return false; } @undoBatch @action remove = (doc: Doc | Doc[], key: string) => { this.props.treeView.props.select(false); @@ -141,8 +147,10 @@ export class TreeView extends React.Component<TreeViewProps> { this._editTitle = false; } else if (docView.isSelected()) { + const doc = docView.Document; + SelectionManager.SelectSchemaViewDoc(doc); this._editTitle = true; - this._selDisposer = reaction(() => docView.isSelected(), sel => !sel && this.setEditTitle(undefined)); + this._selDisposer = reaction(() => SelectionManager.SelectedSchemaDoc(), seldoc => seldoc !== doc && this.setEditTitle(undefined)); } else { docView.select(false); } @@ -213,16 +221,18 @@ export class TreeView extends React.Component<TreeViewProps> { const before = pt[1] < rect.top + rect.height / 2; const inside = pt[0] > Math.min(rect.left + 75, rect.left + rect.width * .75) || (!before && this.treeViewOpen && this.childDocList.length); this._header.current!.className = "treeView-header"; - if (inside) this._header.current!.className += " treeView-header-inside"; - else if (before) this._header.current!.className += " treeView-header-above"; - else if (!before) this._header.current!.className += " treeView-header-below"; + if (!this.props.treeView.outlineMode || DragManager.DocDragData?.treeViewDoc === this.props.treeView.rootDoc) { + if (inside) this._header.current!.className += " treeView-header-inside"; + else if (before) this._header.current!.className += " treeView-header-above"; + else if (!before) this._header.current!.className += " treeView-header-below"; + } e.stopPropagation(); } public static makeTextBullet() { const bullet = Docs.Create.TextDocument("-text-", { layout: CollectionView.LayoutString("data"), - title: "-title-", "sidebarColor": "transparent", "sidebarViewType": CollectionViewType.Freeform, + title: "-title-", treeViewExpandedViewLock: true, treeViewExpandedView: "data", _viewType: CollectionViewType.Tree, hideLinkButton: true, _showSidebar: true, treeViewType: "outline", x: 0, y: 0, _xMargin: 0, _yMargin: 0, _autoHeight: true, _singleLine: true, backgroundColor: "transparent", _width: 1000, _height: 10 @@ -244,9 +254,7 @@ export class TreeView extends React.Component<TreeViewProps> { TreeView._editTitleOnLoad = { id: folder[Id], parent: this.props.parentTreeView }; return this.props.addDocument(folder); } - deleteFolder = () => { - return this.props.removeDoc?.(this.doc); - } + deleteItem = () => this.props.removeDoc?.(this.doc); preTreeDrop = (e: Event, de: DragManager.DropEvent, targetAction: dropActionType) => { const dragData = de.complete.docDragData; @@ -266,23 +274,25 @@ export class TreeView extends React.Component<TreeViewProps> { e.stopPropagation(); } const docDragData = de.complete.docDragData; - if (docDragData) { - e.stopPropagation(); + if (docDragData && pt[0] < rect.left + rect.width) { if (docDragData.draggedDocuments[0] === this.doc) return true; - this.dropDocuments(docDragData.droppedDocuments, before, inside, docDragData.dropAction, docDragData.moveDocument, docDragData.treeViewDoc === this.props.treeView.props.Document); + if (this.dropDocuments(docDragData.droppedDocuments, before, inside, docDragData.dropAction, docDragData.moveDocument, docDragData.treeViewDoc === this.props.treeView.props.Document)) { + e.stopPropagation(); + } } } dropDocuments(droppedDocuments: Doc[], before: boolean, inside: number | boolean, dropAction: dropActionType, moveDocument: DragManager.MoveFunction | undefined, forceAdd: boolean) { const parentAddDoc = (doc: Doc | Doc[]) => this.props.addDocument(doc, undefined, before); - const canAdd = !StrCast((inside ? this.props.document : this.props.containerCollection)?.freezeChildren).includes("add") || forceAdd; + const canAdd = (!this.props.treeView.outlineMode && !StrCast((inside ? this.props.document : this.props.containerCollection)?.freezeChildren).includes("add")) || forceAdd; const localAdd = (doc: Doc) => Doc.AddDocToList(this.dataDoc, this.fieldKey, doc) && ((doc.context = this.doc.context) || true) ? true : false; const addDoc = !inside ? parentAddDoc : (doc: Doc | Doc[]) => (doc instanceof Doc ? [doc] : doc).reduce((flg, doc) => flg && localAdd(doc), true as boolean); const move = (!dropAction || dropAction === "proto" || dropAction === "move" || dropAction === "same") && moveDocument; if (canAdd) { - UndoManager.RunInTempBatch(() => droppedDocuments.reduce((added, d) => (move ? move(d, undefined, addDoc) || (dropAction === "proto" ? addDoc(d) : false) : addDoc(d)) || added, false)); + return UndoManager.RunInTempBatch(() => droppedDocuments.reduce((added, d) => (move ? move(d, undefined, addDoc) || (dropAction === "proto" ? addDoc(d) : false) : addDoc(d)) || added, false)); } + return false; } refTransform = (ref: HTMLDivElement | undefined | null) => { @@ -432,7 +442,7 @@ export class TreeView extends React.Component<TreeViewProps> { </div> </ul>; } - return <ul>{this.renderEmbeddedDocument(false)}</ul>; // "layout" + return <ul onPointerDown={e => { e.preventDefault(); e.stopPropagation(); }}>{this.renderEmbeddedDocument(false, returnFalse)}</ul>; // "layout" } get onCheckedClick() { return this.doc.type === DocumentType.COL ? undefined : this.props.onCheckedClick?.() ?? ScriptCast(this.doc.onCheckedClick); } @@ -519,16 +529,16 @@ export class TreeView extends React.Component<TreeViewProps> { } contextMenuItems = () => { const makeFolder = { script: ScriptField.MakeFunction(`scriptContext.makeFolder()`, { scriptContext: "any" })!, icon: "folder-plus", label: "New Folder" }; - const deleteFolder = { script: ScriptField.MakeFunction(`scriptContext.deleteFolder()`, { scriptContext: "any" })!, icon: "folder-plus", label: "Delete Folder" }; - const folderOp = this.childDocs?.length ? makeFolder : deleteFolder; + const deleteItem = { script: ScriptField.MakeFunction(`scriptContext.deleteItem()`, { scriptContext: "any" })!, icon: "folder-plus", label: "Delete" }; + const folderOp = this.childDocs?.length ? [makeFolder] : []; const openAlias = { script: ScriptField.MakeFunction(`openOnRight(getAlias(self))`)!, icon: "copy", label: "Open Alias" }; const focusDoc = { script: ScriptField.MakeFunction(`DocFocusOrOpen(self)`)!, icon: "eye", label: "Focus or Open" }; - return [...this.props.contextMenuItems.filter(mi => !mi.filter ? true : mi.filter.script.run({ doc: this.doc })?.result), ... (this.doc.isFolder ? [folderOp] : + return [...this.props.contextMenuItems.filter(mi => !mi.filter ? true : mi.filter.script.run({ doc: this.doc })?.result), ... (this.doc.isFolder ? folderOp : Doc.IsSystem(this.doc) ? [] : this.props.treeView.fileSysMode && this.doc === Doc.GetProto(this.doc) ? [openAlias, makeFolder] : this.doc.viewType === CollectionViewType.Docking ? [] : - [openAlias, focusDoc])]; + [deleteItem, openAlias, focusDoc])]; } childContextMenuItems = () => { const customScripts = Cast(this.doc.childContextMenuScripts, listSpec(ScriptField), []); @@ -581,6 +591,7 @@ export class TreeView extends React.Component<TreeViewProps> { } titleWidth = () => Math.max(20, Math.min(this.props.treeView.truncateTitleWidth(), this.props.panelWidth() - 2 * treeBulletWidth())); + return18 = () => 18; /** * Renders the EditableView title element for placement into the tree. */ @@ -636,10 +647,10 @@ export class TreeView extends React.Component<TreeViewProps> { moveDocument={this.move} removeDocument={this.props.removeDoc} ScreenToLocalTransform={this.getTransform} - NativeHeight={() => 18} + NativeHeight={this.return18} NativeWidth={this.titleWidth} PanelWidth={this.titleWidth} - PanelHeight={() => 18} + PanelHeight={this.return18} contextMenuItems={this.contextMenuItems} renderDepth={1} isContentActive={this.props.isContentActive} @@ -679,6 +690,7 @@ export class TreeView extends React.Component<TreeViewProps> { renderBulletHeader = (contents: JSX.Element, editing: boolean) => { return <> <div className={`treeView-header` + (editing ? "-editing" : "")} key="titleheader" + style={{ width: "max-content" }} ref={this._header} onClick={this.ignoreEvent} onPointerDown={this.ignoreEvent} @@ -691,7 +703,7 @@ export class TreeView extends React.Component<TreeViewProps> { } - renderEmbeddedDocument = (asText: boolean) => { + renderEmbeddedDocument = (asText: boolean, isActive: () => boolean | undefined) => { const layout = StrCast(Doc.LayoutField(this.layoutDoc)); const isExpandable = layout.includes(FormattedTextBox.name) || layout.includes(SliderBox.name); const panelWidth = asText || isExpandable ? this.rtfWidth : this.expandPanelWidth; @@ -704,8 +716,8 @@ export class TreeView extends React.Component<TreeViewProps> { NativeWidth={!asText && (this.layoutDoc.type === DocumentType.RTF || this.layoutDoc.type === DocumentType.SLIDER) ? this.rtfWidth : undefined} NativeHeight={!asText && (this.layoutDoc.type === DocumentType.RTF || this.layoutDoc.type === DocumentType.SLIDER) ? this.rtfHeight : undefined} LayoutTemplateString={asText ? FormattedTextBox.LayoutString("text") : undefined} - isContentActive={asText ? this.props.isContentActive : returnFalse} - isDocumentActive={asText ? this.props.isContentActive : returnFalse} + isContentActive={isActive} + isDocumentActive={isActive} styleProvider={asText ? this.titleStyleProvider : this.embeddedStyleProvider} hideTitle={asText} fitContentsToDoc={returnTrue} @@ -749,7 +761,7 @@ export class TreeView extends React.Component<TreeViewProps> { @computed get renderDocumentAsHeader() { return <> {this.renderBullet} - {this.renderEmbeddedDocument(true)} + {this.renderEmbeddedDocument(true, this.props.isContentActive)} </>; } @@ -770,19 +782,19 @@ export class TreeView extends React.Component<TreeViewProps> { const docs = this.props.treeView.onTreeDrop(de, (docs: Doc[]) => this.dropDocuments(docs, before, inside, "copy", undefined, false)); } + render() { TraceMobx(); const hideTitle = this.doc.treeViewHideHeader || this.props.treeView.outlineMode; return this.props.renderedIds.indexOf(this.doc[Id]) !== -1 ? "<" + this.doc.title + ">" : // just print the title of documents we've previously rendered in this hierarchical path to avoid cycles <div className={`treeView-container${this.props.isContentActive() ? "-active" : ""}`} ref={this.createTreeDropTarget} - onDrop={this.onTreeDrop} //onPointerDown={e => this.props.isContentActive(true) && SelectionManager.DeselectAll()} // bcz: this breaks entering a text filter in a filterBox since it deselects the filter's target document onKeyDown={this.onKeyDown}> <li className="collection-child"> {hideTitle && this.doc.type !== DocumentType.RTF ? - this.renderEmbeddedDocument(false) : + this.renderEmbeddedDocument(false, returnFalse) : this.renderBulletHeader(hideTitle ? this.renderDocumentAsHeader : this.renderTitleAsHeader, this._editTitle)} </li> </div>; diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx index 9769453a0..9cc887e3d 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx @@ -153,28 +153,44 @@ export class CollectionFreeFormLinkView extends React.Component<CollectionFreeFo const atop = this.visibleY(adiv); const btop = this.visibleY(bdiv); if (!a.width || !b.width) return undefined; + const aDocBounds = (A.props as any).DocumentView?.().getBounds() || { left: 0, right: 0, top: 0, bottom: 0 }; + const bDocBounds = (B.props as any).DocumentView?.().getBounds() || { left: 0, right: 0, top: 0, bottom: 0 }; + const acentX = (a.left + a.right) / 2; + const acentY = (a.top + a.bottom) / 2; + const bcentX = (b.left + b.right) / 2; + const bcentY = (b.top + b.bottom) / 2; + const pt1Arc = ((acentX - aDocBounds.left) > 0.1 && (aDocBounds.right - acentX) > 0.1) || + ((acentY - aDocBounds.top) > 0.1 && (aDocBounds.bottom - acentY) > 0.1); + const pt2Arc = ((bcentX - bDocBounds.left) > 0.1 && (bDocBounds.right - bcentX) > 0.1) || + ((bcentY - bDocBounds.top) > 0.1 && (bDocBounds.bottom - bcentY) > 0.1); const atop2 = this.visibleY(adiv); const btop2 = this.visibleY(bdiv); const aleft = this.visibleX(adiv); const bleft = this.visibleX(bdiv); const clipped = aleft !== a.left || atop !== a.top || bleft !== b.left || btop !== b.top; - const apt = Utils.closestPtBetweenRectangles(aleft, atop, a.width, a.height, bleft, btop, b.width, b.height, a.left + a.width / 2, a.top + a.height / 2); - const bpt = Utils.closestPtBetweenRectangles(bleft, btop, b.width, b.height, aleft, atop, a.width, a.height, apt.point.x, apt.point.y); - const pt1 = [apt.point.x, apt.point.y]; - const pt2 = [bpt.point.x, bpt.point.y]; - const pt1vec = [pt1[0] - (aleft + a.width / 2), pt1[1] - (atop + a.height / 2)]; - const pt2vec = [pt2[0] - (bleft + b.width / 2), pt2[1] - (btop + b.height / 2)]; + const pt1 = [aleft + a.width / 2, atop + a.height / 2]; + const pt2 = [bleft + b.width / 2, btop + b.width / 2]; + const pt1vec = [pt1[0] - (aDocBounds.left + aDocBounds.right) / 2, pt1[1] - (aDocBounds.top + aDocBounds.bottom) / 2]; + const pt2vec = [pt2[0] - (bDocBounds.left + bDocBounds.right) / 2, pt2[1] - (bDocBounds.top + bDocBounds.bottom) / 2]; const pt1len = Math.sqrt((pt1vec[0] * pt1vec[0]) + (pt1vec[1] * pt1vec[1])); const pt2len = Math.sqrt((pt2vec[0] * pt2vec[0]) + (pt2vec[1] * pt2vec[1])); const ptlen = Math.sqrt((pt1[0] - pt2[0]) * (pt1[0] - pt2[0]) + (pt1[1] - pt2[1]) * (pt1[1] - pt2[1])) / 2; - const pt1norm = clipped ? [0, 0] : [pt1vec[0] / pt1len * ptlen, pt1vec[1] / pt1len * ptlen]; - const pt2norm = clipped ? [0, 0] : [pt2vec[0] / pt2len * ptlen, pt2vec[1] / pt2len * ptlen]; + const pt1norm = clipped ? [0, 0] : !pt1Arc ? [pt1vec[0] / pt1len * ptlen, pt1vec[1] / pt1len * ptlen] : + Math.abs(acentY - aDocBounds.top) < 0.01 || + Math.abs(acentY - aDocBounds.bottom) < 0.01 ? [0, (pt2[1] - pt1[1]) / 2] : [(pt2[0] - pt1[0]) / 2, 0]; + const pt2norm = clipped ? [0, 0] : !pt2Arc ? [pt2vec[0] / pt2len * ptlen, pt2vec[1] / pt2len * ptlen] : + Math.abs(bcentY - bDocBounds.top) < 0.01 || + Math.abs(bcentY - bDocBounds.bottom) < 0.01 ? [0, (pt1[1] - pt2[1]) / 2] : [(pt1[0] - pt2[0]) / 2, 0]; + const pt1normlen = Math.sqrt(pt1norm[0] * pt1norm[0] + pt1norm[1] * pt1norm[1]) || 1; + const pt2normlen = Math.sqrt(pt2norm[0] * pt2norm[0] + pt2norm[1] * pt2norm[1]) || 1; + const pt1normalized = [pt1norm[0] / pt1normlen, pt1norm[1] / pt1normlen]; + const pt2normalized = [pt2norm[0] / pt2normlen, pt2norm[1] / pt2normlen]; const aActive = A.isSelected() || Doc.IsBrushed(A.rootDoc); const bActive = B.isSelected() || Doc.IsBrushed(B.rootDoc); const textX = (Math.min(pt1[0], pt2[0]) + Math.max(pt1[0], pt2[0])) / 2 + NumCast(LinkDocs[0].linkOffsetX); const textY = (pt1[1] + pt2[1]) / 2 + NumCast(LinkDocs[0].linkOffsetY); - return { a, b, pt1norm, pt2norm, aActive, bActive, textX, textY, pt1, pt2 }; + return { a, b, pt1norm, pt2norm, aActive, bActive, textX, textY, pt1: [pt1[0] + pt1normalized[0] * 13, pt1[1] + pt1normalized[1] * 13], pt2: [pt2[0] + pt2normalized[0] * 13, pt2[1] + pt2normalized[1] * 13] }; } render() { diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index febccbfcc..aeda71d01 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -1,11 +1,12 @@ -import { action, computed, IReactionDisposer, observable, reaction, runInAction, ObservableMap } from "mobx"; +import { Bezier } from "bezier-js"; +import { action, computed, IReactionDisposer, observable, reaction, runInAction } from "mobx"; import { observer } from "mobx-react"; import { computedFn } from "mobx-utils"; import { DateField } from "../../../../fields/DateField"; import { Doc, HeightSym, Opt, StrListCast, WidthSym } from "../../../../fields/Doc"; import { collectionSchema, documentSchema } from "../../../../fields/documentSchemas"; import { Id } from "../../../../fields/FieldSymbols"; -import { InkData, InkField, InkTool } from "../../../../fields/InkField"; +import { InkData, InkField, InkTool, PointData, Segment } from "../../../../fields/InkField"; import { List } from "../../../../fields/List"; import { ObjectField } from "../../../../fields/ObjectField"; import { RichTextField } from "../../../../fields/RichTextField"; @@ -27,14 +28,15 @@ import { InteractionUtils } from "../../../util/InteractionUtils"; import { LinkManager } from "../../../util/LinkManager"; import { SearchUtil } from "../../../util/SearchUtil"; import { SelectionManager } from "../../../util/SelectionManager"; +import { ColorScheme } from "../../../util/SettingsManager"; import { SnappingManager } from "../../../util/SnappingManager"; import { Transform } from "../../../util/Transform"; -import { undoBatch } from "../../../util/UndoManager"; +import { undoBatch, UndoManager } from "../../../util/UndoManager"; import { COLLECTION_BORDER_WIDTH } from "../../../views/global/globalCssVariables.scss"; import { Timeline } from "../../animationtimeline/Timeline"; import { ContextMenu } from "../../ContextMenu"; -import { DocumentDecorations } from "../../DocumentDecorations"; -import { ActiveArrowEnd, ActiveArrowStart, ActiveDash, ActiveFillColor, ActiveInkBezierApprox, ActiveInkColor, ActiveInkWidth } from "../../InkingStroke"; +import { GestureOverlay } from "../../GestureOverlay"; +import { ActiveArrowEnd, ActiveArrowStart, ActiveDash, ActiveFillColor, ActiveInkBezierApprox, ActiveInkColor, ActiveInkWidth, InkingStroke, SetActiveInkColor, SetActiveInkWidth } from "../../InkingStroke"; import { LightboxView } from "../../LightboxView"; import { CollectionFreeFormDocumentView } from "../../nodes/CollectionFreeFormDocumentView"; import { DocFocusOptions, DocumentView, DocumentViewProps, ViewAdjustment, ViewSpecPrefix } from "../../nodes/DocumentView"; @@ -50,7 +52,6 @@ import { CollectionFreeFormRemoteCursors } from "./CollectionFreeFormRemoteCurso import "./CollectionFreeFormView.scss"; import { MarqueeView } from "./MarqueeView"; import React = require("react"); -import { ColorScheme } from "../../../util/SettingsManager"; export const panZoomSchema = createSchema({ _panX: "number", @@ -98,6 +99,7 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P private _layoutSizeData = observable.map<string, { width?: number, height?: number }>(); private _cachedPool: Map<string, PoolData> = new Map(); private _lastTap = 0; + private _batch: UndoManager.Batch | undefined = undefined; private get isAnnotationOverlay() { return this.props.isAnnotationOverlay; } private get scaleFieldKey() { return this.props.scaleField || "_viewScale"; } @@ -111,6 +113,7 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P @observable _pullDirection: string = ""; @observable _showAnimTimeline = false; @observable _clusterSets: (Doc[])[] = []; + @observable _deleteList: DocumentView[] = []; @observable _timelineRef = React.createRef<Timeline>(); @observable _marqueeRef = React.createRef<HTMLDivElement>(); @observable _keyframeEditing = false; @@ -146,7 +149,7 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P return this.props.ScreenToLocalTransform().translate(-this.borderWidth, -this.borderWidth); } @computed get cachedGetTransform(): Transform { - return this.getTransformOverlay().translate(- this.cachedCenteringShiftX, - this.cachedCenteringShiftY).transform(this.cachedGetLocalTransform); + return this.getContainerTransform().translate(- this.cachedCenteringShiftX, - this.cachedCenteringShiftY).transform(this.cachedGetLocalTransform); } @action setKeyFrameEditing = (set: boolean) => this._keyframeEditing = set; @@ -165,11 +168,10 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P panX = () => this.freeformData()?.bounds.cx ?? NumCast(this.Document._panX); panY = () => this.freeformData()?.bounds.cy ?? NumCast(this.Document._panY); zoomScaling = () => (this.freeformData()?.scale ?? NumCast(this.Document[this.scaleFieldKey], 1)); - contentTransform = () => `translate(${this.cachedCenteringShiftX}px, ${this.cachedCenteringShiftY}px) scale(${this.zoomScaling()}) translate(${-this.panX()}px, ${-this.panY()}px)`; + contentTransform = () => !this.cachedCenteringShiftX && !this.cachedCenteringShiftY && this.zoomScaling() === 1 ? "" : `translate(${this.cachedCenteringShiftX}px, ${this.cachedCenteringShiftY}px) scale(${this.zoomScaling()}) translate(${-this.panX()}px, ${-this.panY()}px)`; getTransform = () => this.cachedGetTransform.copy(); getLocalTransform = () => this.cachedGetLocalTransform.copy(); getContainerTransform = () => this.cachedGetContainerTransform.copy(); - getTransformOverlay = () => this.getContainerTransform().translate(1, 1); getActiveDocuments = () => this.childLayoutPairs.filter(pair => this.isCurrent(pair.layout)).map(pair => pair.layout); isAnyChildContentActive = () => this.props.isAnyChildContentActive(); addLiveTextBox = (newBox: Doc) => { @@ -221,7 +223,7 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P if (!de.embedKey && !this.ChildDrag && this.props.layerProvider?.(this.props.Document) !== false && this.props.Document._isGroup) return false; if (!super.onInternalDrop(e, de)) return false; const refDoc = docDragData.droppedDocuments[0]; - const [xpo, ypo] = this.getTransformOverlay().transformPoint(de.x, de.y); + const [xpo, ypo] = this.getContainerTransform().transformPoint(de.x, de.y); const z = NumCast(refDoc.z); const x = (z ? xpo : xp) - docDragData.offset[0]; const y = (z ? ypo : yp) - docDragData.offset[1]; @@ -433,25 +435,29 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P @action onPointerDown = (e: React.PointerEvent): void => { - if (e.nativeEvent.cancelBubble || InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE) || InteractionUtils.IsType(e, InteractionUtils.PENTYPE) || - ([InkTool.Pen, InkTool.Highlighter].includes(CurrentUserUtils.SelectedTool))) { - return; - } - this._hitCluster = this.pickCluster(this.getTransform().transformPoint(e.clientX, e.clientY)); + this._downX = this._lastX = e.pageX; + this._downY = this._lastY = e.pageY; if (e.button === 0 && !e.altKey && !e.ctrlKey && this.props.isContentActive(true)) { - document.removeEventListener("pointermove", this.onPointerMove); - document.removeEventListener("pointerup", this.onPointerUp); - document.addEventListener("pointermove", this.onPointerMove); - document.addEventListener("pointerup", this.onPointerUp); - // if not using a pen and in no ink mode - if (CurrentUserUtils.SelectedTool === InkTool.None) { - this._downX = this._lastX = e.pageX; - this._downY = this._lastY = e.pageY; - } - // eraser plus anything else mode - else { - e.stopPropagation(); - e.preventDefault(); + if (!e.nativeEvent.cancelBubble && + !this.props.Document._isGroup && // group freeforms don't pan when dragged -- instead let the event go through to allow the group itself to drag + !InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE) && + !InteractionUtils.IsType(e, InteractionUtils.PENTYPE)) { + switch (CurrentUserUtils.SelectedTool) { + case InkTool.Highlighter: + case InkTool.Pen: break; // the GestureOverlay handles ink stroke input -- either as gestures, or drying as ink strokes that are added to document views + case InkTool.Eraser: + document.addEventListener("pointermove", this.onEraserMove); + document.addEventListener("pointerup", this.onEraserUp); + this._batch = UndoManager.StartBatch("collectionErase"); + e.stopPropagation(); + e.preventDefault(); + break; + case InkTool.None: + this._hitCluster = this.pickCluster(this.getTransform().transformPoint(e.clientX, e.clientY)); + document.addEventListener("pointermove", this.onPointerMove); + document.addEventListener("pointerup", this.onPointerUp); + break; + } } } } @@ -592,7 +598,18 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P } } } + @action + onEraserUp = (e: PointerEvent): void => { + if (!InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE)) { + document.removeEventListener("pointermove", this.onEraserMove); + document.removeEventListener("pointerup", this.onEraserUp); + this._deleteList.forEach(ink => ink.props.removeDocument?.(ink.rootDoc)); + this._deleteList = []; + this._batch?.end(); + } + } + @action onPointerUp = (e: PointerEvent): void => { if (!InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE)) { document.removeEventListener("pointermove", this.onPointerMove); @@ -623,25 +640,159 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P this._lastY = e.clientY; } + /** + * Erases strokes by intersecting them with an invisible "eraser stroke". + * By default this iterates through all intersected ink strokes, determines their segmentation, draws back the non-intersected segments, + * and deletes the original stroke. + * However, if Shift is held, then no segmentation is done -- instead any intersected stroke is deleted in its entirety. + */ + @action + onEraserMove = (e: PointerEvent) => { + const currPoint = { X: e.clientX, Y: e.clientY }; + this.getEraserIntersections({ X: this._lastX, Y: this._lastY }, currPoint).forEach(intersect => { + if (!this._deleteList.includes(intersect.inkView)) { + this._deleteList.push(intersect.inkView); + SetActiveInkWidth(StrCast(intersect.inkView.rootDoc.strokeWidth?.toString()) || "1"); + SetActiveInkColor(StrCast(intersect.inkView.rootDoc.color?.toString()) || "black"); + // create a new curve by appending all curves of the current segment together in order to render a single new stroke. + !e.shiftKey && this.segmentInkStroke(intersect.inkView, intersect.t).forEach(segment => + GestureOverlay.Instance.dispatchGesture(GestureUtils.Gestures.Stroke, + segment.reduce((data, curve) => [...data, ...curve.points + .map(p => intersect.inkView.ComponentView?.ptToScreen?.({ X: p.x, Y: p.y }) ?? { X: 0, Y: 0 }) + ], [] as PointData[]))); + // Lower ink opacity to give the user a visual indicator of deletion. + intersect.inkView.layoutDoc.opacity = 0.5; + } + }); + this._lastX = currPoint.X; + this._lastY = currPoint.Y; + + e.stopPropagation(); // doesn't actually stop propagation since all our listeners are listening to events on 'document' however it does mark the event as cancelBubble=true which we test for in the move event handlers + e.preventDefault(); + } + @action onPointerMove = (e: PointerEvent): void => { - if (this.props.Document._isGroup) return; // groups don't pan when dragged -- instead let the event go through to allow the group itself to drag if (InteractionUtils.IsType(e, InteractionUtils.PENTYPE)) return; if (InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE)) { if (this.props.isContentActive(true)) e.stopPropagation(); } else if (!e.cancelBubble) { - if (CurrentUserUtils.SelectedTool === InkTool.None) { - if (this.tryDragCluster(e, this._hitCluster)) { - document.removeEventListener("pointermove", this.onPointerMove); - document.removeEventListener("pointerup", this.onPointerUp); - } - else this.pan(e); + if (this.tryDragCluster(e, this._hitCluster)) { + document.removeEventListener("pointermove", this.onPointerMove); + document.removeEventListener("pointerup", this.onPointerUp); } + else this.pan(e); e.stopPropagation(); // doesn't actually stop propagation since all our listeners are listening to events on 'document' however it does mark the event as cancelBubble=true which we test for in the move event handlers e.preventDefault(); } } + /** + * Determines if the Eraser tool has intersected with an ink stroke in the current freeform collection. + * @returns an array of tuples containing the intersected ink DocumentView and the t-value where it was intersected + */ + getEraserIntersections = (lastPoint: { X: number, Y: number }, currPoint: { X: number, Y: number }) => { + const eraserMin = { X: Math.min(lastPoint.X, currPoint.X), Y: Math.min(lastPoint.Y, currPoint.Y) }; + const eraserMax = { X: Math.max(lastPoint.X, currPoint.X), Y: Math.max(lastPoint.Y, currPoint.Y) }; + return this.childDocs + .map(doc => DocumentManager.Instance.getDocumentView(doc, this.props.CollectionView)) + .filter(inkView => inkView?.ComponentView instanceof InkingStroke) + .map(inkView => ({ inkViewBounds: inkView!.getBounds(), inkStroke: inkView!.ComponentView as InkingStroke, inkView: inkView! })) + .filter(({ inkViewBounds }) => inkViewBounds && // bounding box of eraser segment and ink stroke overlap + eraserMin.X <= inkViewBounds.right && eraserMin.Y <= inkViewBounds.bottom && + eraserMax.X >= inkViewBounds.left && eraserMax.Y >= inkViewBounds.top) + .reduce((intersections, { inkStroke, inkView }) => { + const { inkData } = inkStroke.inkScaledData(); + // Convert from screen space to ink space for the intersection. + const prevPointInkSpace = inkStroke.ptFromScreen(lastPoint); + const currPointInkSpace = inkStroke.ptFromScreen(currPoint); + for (var i = 0; i < inkData.length - 3; i += 4) { + const intersects = Array.from(new Set(InkField.Segment(inkData, i).intersects({ // compute all unique intersections + p1: { x: prevPointInkSpace.X, y: prevPointInkSpace.Y }, + p2: { x: currPointInkSpace.X, y: currPointInkSpace.Y } + }) as (number | string)[])); // convert to more manageable union array type + // return tuples of the inkingStroke intersected, and the t value of the intersection + intersections.push(...intersects.map(t => ({ inkView, t: (+t) + Math.floor(i / 4) })));// convert string t's to numbers and add start of curve segment to convert from local t value to t value along complete curve + } + return intersections; + }, [] as { t: number, inkView: DocumentView }[]); + } + + /** + * Performs segmentation of the ink stroke - creates "segments" or subsections of the current ink stroke at points in which the + * ink stroke intersects any other ink stroke (including itself). + * @param ink The ink DocumentView intersected by the eraser. + * @param excludeT The index of the curve in the ink document that the eraser intersection occurred. + * @returns The ink stroke represented as a list of segments, excluding the segment in which the eraser intersection occurred. + */ + @action + segmentInkStroke = (ink: DocumentView, excludeT: number): Segment[] => { + const segments: Segment[] = []; + var segment: Segment = []; + var startSegmentT = 0; + const { inkData } = (ink?.ComponentView as InkingStroke).inkScaledData(); + // This iterates through all segments of the curve and splits them where they intersect another curve. + // if 'excludeT' is specified, then any segment containing excludeT will be skipped (ie, deleted) + for (var i = 0; i < inkData.length - 3; i += 4) { + const inkSegment = InkField.Segment(inkData, i); + // Getting all t-value intersections of the current curve with all other curves. + const tVals = this.getInkIntersections(i, ink, inkSegment).sort(); + if (tVals.length) { + tVals.forEach((t, index) => { + const docCurveTVal = t + Math.floor(i / 4); + if (excludeT < startSegmentT || excludeT > docCurveTVal) { + const localStartTVal = startSegmentT - Math.floor(i / 4); + segment.push(inkSegment.split(localStartTVal < 0 ? 0 : localStartTVal, t)); + segment.length && segments.push(segment); + } + // start a new segment from the intersection t value + segment = tVals.length - 1 === index ? [inkSegment.split(t).right] : []; + startSegmentT = docCurveTVal; + }); + } else { + segment.push(inkSegment); + } + } + if (excludeT < startSegmentT || excludeT > (inkData.length / 4)) { + segment.length && segments.push(segment); + } + return segments; + } + + /** + * Determines all possible intersections of the current curve of the intersected ink stroke with all other curves of all + * ink strokes in the current collection. + * @param i The index of the current curve within the inkData of the intersected ink stroke. + * @param ink The intersected DocumentView of the ink stroke. + * @param curve The current curve of the intersected ink stroke. + * @returns A list of all t-values at which intersections occur at the current curve of the intersected ink stroke. + */ + getInkIntersections = (i: number, ink: DocumentView, curve: Bezier): number[] => { + const tVals: number[] = []; + // Iterating through all ink strokes in the current freeform collection. + this.childDocs + .filter(doc => doc.type === DocumentType.INK) + .forEach(doc => { + const otherInk = DocumentManager.Instance.getDocumentView(doc, this.props.CollectionView)?.ComponentView as InkingStroke; + const { inkData: otherInkData } = otherInk?.inkScaledData() ?? { inkData: [] }; + const otherScreenPts = otherInkData.map(point => otherInk.ptToScreen(point)); + const otherCtrlPts = otherScreenPts.map(spt => (ink.ComponentView as InkingStroke).ptFromScreen(spt)); + for (var j = 0; j < otherCtrlPts.length - 3; j += 4) { + const neighboringSegment = i === j || i === j - 4 || i === j + 4; + // Ensuring that the curve intersected by the eraser is not checked for further ink intersections. + if (ink?.Document === otherInk.props.Document && neighboringSegment) continue; + + const otherCurve = new Bezier(otherCtrlPts.slice(j, j + 4).map(p => ({ x: p.X, y: p.Y }))); + curve.intersects(otherCurve).forEach((val: string | number, i: number) => { + // Converting the Bezier.js Split type to a t-value number. + const t = +val.toString().split("/")[0]; + if (i % 2 === 0 && !tVals.includes(t)) tVals.push(t); // bcz: Hack! don't know why but intersection points are doubled from bezier.js (but not identical). + }); + } + }); + return tVals; + } + handle1PointerMove = (e: TouchEvent, me: InteractionUtils.MultiTouchEvent<TouchEvent>) => { if (!e.cancelBubble) { const myTouches = InteractionUtils.GetMyTargetTouches(me, this.prevPoints, true); @@ -803,7 +954,7 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P else if (this.props.isContentActive(true) && !this.Document._isGroup) { e.stopPropagation(); e.preventDefault(); - this.zoom(e.clientX, e.clientY, e.deltaY); // if (!this.props.isAnnotationOverlay) // bcz: do we want to zoom in on images/videos/etc? + !this.props.isAnnotationOverlayScrollable && this.zoom(e.clientX, e.clientY, e.deltaY); // if (!this.props.isAnnotationOverlay) // bcz: do we want to zoom in on images/videos/etc? } } @@ -1010,13 +1161,13 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P rootSelected={childData ? this.rootSelected : returnFalse} onClick={this.onChildClickHandler} onDoubleClick={this.onChildDoubleClickHandler} - ScreenToLocalTransform={childLayout.z ? this.getTransformOverlay : this.getTransform} + ScreenToLocalTransform={childLayout.z ? this.getContainerTransform : this.getTransform} PanelWidth={childLayout[WidthSym]} PanelHeight={childLayout[HeightSym]} docFilters={this.childDocFilters} docRangeFilters={this.childDocRangeFilters} searchFilterDocs={this.searchFilterDocs} - isContentActive={returnFalse} + isContentActive={emptyFunction} isDocumentActive={this.props.childDocumentsActive ? this.props.isDocumentActive : this.isContentActive} focus={this.focusDocument} addDocTab={this.addDocTab} @@ -1035,7 +1186,7 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P bringToFront={this.bringToFront} showTitle={this.props.childShowTitle} dontRegisterView={this.props.dontRenderDocuments || this.props.dontRegisterView} - pointerEvents={this.backgroundActive || this.props.childPointerEvents ? "all" : + pointerEvents={this.props.isContentActive() === false ? "none" : this.backgroundActive || this.props.childPointerEvents ? "all" : (this.props.viewDefDivClick || (engine === "pass" && !this.props.isSelected(true))) ? "none" : this.props.pointerEvents} jitterRotation={this.props.styleProvider?.(childLayout, this.props, StyleProp.JitterRotation) || 0} //fitToBox={this.props.fitToBox || BoolCast(this.props.freezeChildDimensions)} // bcz: check this @@ -1403,7 +1554,6 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P } onPointerOver = (e: React.PointerEvent) => { - (DocumentDecorations.Instance.Interacting || (this.props.layerProvider?.(this.props.Document) !== false && SnappingManager.GetIsDragging())) && this.setupDragLines(e.ctrlKey || e.shiftKey); e.stopPropagation(); } @@ -1476,6 +1626,7 @@ export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, P {this.layoutDoc._backgroundGridShow ? this.backgroundGrid : (null)} <CollectionFreeFormViewPannableContents isAnnotationOverlay={this.isAnnotationOverlay} + isAnnotationOverlayScrollable={this.props.isAnnotationOverlayScrollable} transform={this.contentTransform} zoomScaling={this.zoomScaling} presPaths={BoolCast(this.Document.presPathView)} @@ -1584,6 +1735,7 @@ interface CollectionFreeFormViewPannableContentsProps { progressivize?: boolean; presPinView?: boolean; isAnnotationOverlay: boolean | undefined; + isAnnotationOverlayScrollable: boolean | undefined; } @observer diff --git a/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx b/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx index 65c345547..ec1cbadd5 100644 --- a/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx +++ b/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx @@ -6,7 +6,7 @@ import { documentSchema } from '../../../../fields/documentSchemas'; import { List } from '../../../../fields/List'; import { makeInterface } from '../../../../fields/Schema'; import { BoolCast, NumCast, ScriptCast, StrCast } from '../../../../fields/Types'; -import { returnFalse, emptyPath, returnEmptyDoclist } from '../../../../Utils'; +import { returnFalse, emptyPath, returnEmptyDoclist, emptyFunction } from '../../../../Utils'; import { DragManager, dropActionType } from '../../../util/DragManager'; import { Transform } from '../../../util/Transform'; import { undoBatch } from '../../../util/UndoManager'; @@ -228,7 +228,7 @@ export class CollectionMulticolumnView extends CollectionSubView(MulticolumnDocu LayoutTemplateString={this.props.childLayoutString} freezeDimensions={this.props.childFreezeDimensions} renderDepth={this.props.renderDepth + 1} - isContentActive={returnFalse} + isContentActive={emptyFunction} PanelWidth={width} PanelHeight={height} rootSelected={this.rootSelected} diff --git a/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx b/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx index 30836854a..a2d51e2e7 100644 --- a/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx +++ b/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx @@ -6,7 +6,7 @@ import { documentSchema } from '../../../../fields/documentSchemas'; import { List } from '../../../../fields/List'; import { makeInterface } from '../../../../fields/Schema'; import { BoolCast, NumCast, ScriptCast, StrCast } from '../../../../fields/Types'; -import { returnFalse, emptyPath, returnEmptyDoclist } from '../../../../Utils'; +import { returnFalse, emptyPath, returnEmptyDoclist, emptyFunction } from '../../../../Utils'; import { DragManager, dropActionType } from '../../../util/DragManager'; import { Transform } from '../../../util/Transform'; import { undoBatch } from '../../../util/UndoManager'; @@ -237,7 +237,7 @@ export class CollectionMultirowView extends CollectionSubView(MultirowDocument) ScreenToLocalTransform={dxf} focus={this.props.focus} docFilters={this.childDocFilters} - isContentActive={returnFalse} + isContentActive={emptyFunction} docRangeFilters={this.childDocRangeFilters} searchFilterDocs={this.searchFilterDocs} ContainingCollectionDoc={this.props.CollectionView?.props.Document} diff --git a/src/client/views/collections/collectionMulticolumn/MulticolumnResizer.tsx b/src/client/views/collections/collectionMulticolumn/MulticolumnResizer.tsx index 9fe18d118..273e609ca 100644 --- a/src/client/views/collections/collectionMulticolumn/MulticolumnResizer.tsx +++ b/src/client/views/collections/collectionMulticolumn/MulticolumnResizer.tsx @@ -11,7 +11,7 @@ import { StyleProp } from "../../StyleProvider"; interface ResizerProps { width: number; styleProvider?: StyleProviderFunc; - isContentActive?: () => boolean; + isContentActive?: () => boolean | undefined; columnUnitLength(): number | undefined; toLeft?: Doc; toRight?: Doc; diff --git a/src/client/views/collections/collectionMulticolumn/MultirowResizer.tsx b/src/client/views/collections/collectionMulticolumn/MultirowResizer.tsx index 5478bf709..006ef4df6 100644 --- a/src/client/views/collections/collectionMulticolumn/MultirowResizer.tsx +++ b/src/client/views/collections/collectionMulticolumn/MultirowResizer.tsx @@ -11,7 +11,7 @@ import { StyleProviderFunc } from "../../nodes/DocumentView"; interface ResizerProps { height: number; styleProvider?: StyleProviderFunc; - isContentActive?: () => boolean; + isContentActive?: () => boolean | undefined; columnUnitLength(): number | undefined; toTop?: Doc; toBottom?: Doc; diff --git a/src/client/views/collections/collectionSchema/CollectionSchemaHeaders.tsx b/src/client/views/collections/collectionSchema/CollectionSchemaHeaders.tsx index 1306b79cb..dc35b5749 100644 --- a/src/client/views/collections/collectionSchema/CollectionSchemaHeaders.tsx +++ b/src/client/views/collections/collectionSchema/CollectionSchemaHeaders.tsx @@ -225,7 +225,7 @@ export interface KeysDropdownProps { fieldKey: string; ContainingCollectionDoc: Doc | undefined; ContainingCollectionView: Opt<CollectionView>; - active?: (outsideReaction?: boolean) => boolean; + active?: (outsideReaction?: boolean) => boolean | undefined; openHeader: (column: any, screenx: number, screeny: number) => void; col: SchemaHeaderField; icon: IconProp; diff --git a/src/client/views/collections/collectionSchema/SchemaTable.tsx b/src/client/views/collections/collectionSchema/SchemaTable.tsx index bc5a9559f..2219345f6 100644 --- a/src/client/views/collections/collectionSchema/SchemaTable.tsx +++ b/src/client/views/collections/collectionSchema/SchemaTable.tsx @@ -68,7 +68,7 @@ export interface SchemaTableProps { addDocument?: (document: Doc | Doc[]) => boolean; moveDocument?: (document: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (document: Doc | Doc[]) => boolean) => boolean; ScreenToLocalTransform: () => Transform; - active: (outsideReaction: boolean | undefined) => boolean; + active: (outsideReaction: boolean | undefined) => boolean | undefined; onDrop: (e: React.DragEvent<Element>, options: DocumentOptions, completed?: (() => void) | undefined) => void; addDocTab: (document: Doc, where: string) => boolean; pinToPres: (document: Doc) => void; |
