diff options
Diffstat (limited to 'src/client/views')
182 files changed, 14858 insertions, 5761 deletions
diff --git a/src/client/views/AntimodeMenu.tsx b/src/client/views/AntimodeMenu.tsx index 4625eb92f..f810361c6 100644 --- a/src/client/views/AntimodeMenu.tsx +++ b/src/client/views/AntimodeMenu.tsx @@ -16,7 +16,8 @@ export default abstract class AntimodeMenu extends React.Component { @observable protected _top: number = -300; @observable protected _left: number = -300; @observable protected _opacity: number = 1; - @observable protected _transition: string = "opacity 0.5s"; + @observable protected _transitionProperty: string = "opacity"; + @observable protected _transitionDuration: string = "0.5s"; @observable protected _transitionDelay: string = ""; @observable protected _canFade: boolean = true; @@ -34,7 +35,7 @@ export default abstract class AntimodeMenu extends React.Component { */ public jumpTo = (x: number, y: number, forceJump: boolean = false) => { if (!this.Pinned || forceJump) { - this._transition = this._transitionDelay = ""; + this._transitionProperty = this._transitionDuration = this._transitionDelay = ""; this._opacity = 1; this._left = x; this._top = y; @@ -49,14 +50,16 @@ export default abstract class AntimodeMenu extends React.Component { public fadeOut = (forceOut: boolean) => { if (!this.Pinned) { if (this._opacity === 0.2) { - this._transition = "opacity 0.1s"; + this._transitionProperty = "opacity"; + this._transitionDuration = "0.1s"; this._transitionDelay = ""; this._opacity = 0; this._left = this._top = -300; } if (forceOut) { - this._transition = ""; + this._transitionProperty = ""; + this._transitionDuration = ""; this._transitionDelay = ""; this._opacity = 0; this._left = this._top = -300; @@ -67,7 +70,8 @@ export default abstract class AntimodeMenu extends React.Component { @action protected pointerLeave = (e: React.PointerEvent) => { if (!this.Pinned && this._canFade) { - this._transition = "opacity 0.5s"; + this._transitionProperty = "opacity"; + this._transitionDuration = "0.5s"; this._transitionDelay = "1s"; this._opacity = 0.2; setTimeout(() => this.fadeOut(false), 3000); @@ -76,7 +80,8 @@ export default abstract class AntimodeMenu extends React.Component { @action protected pointerEntered = (e: React.PointerEvent) => { - this._transition = "opacity 0.1s"; + this._transitionProperty = "opacity"; + this._transitionDuration = "0.1s"; this._transitionDelay = ""; this._opacity = 1; } @@ -133,7 +138,7 @@ export default abstract class AntimodeMenu extends React.Component { protected getElement(buttons: JSX.Element[]) { return ( <div className="antimodeMenu-cont" onPointerLeave={this.pointerLeave} onPointerEnter={this.pointerEntered} ref={this._mainCont} onContextMenu={this.handleContextMenu} - style={{ left: this._left, top: this._top, opacity: this._opacity, transition: this._transition, transitionDelay: this._transitionDelay }}> + style={{ left: this._left, top: this._top, opacity: this._opacity, transitionProperty: this._transitionProperty, transitionDuration: this._transitionDuration, transitionDelay: this._transitionDelay }}> {buttons} <div className="antimodeMenu-dragger" onPointerDown={this.dragStart} style={{ width: this.Pinned ? "20px" : "0px" }} /> </div> @@ -143,7 +148,7 @@ export default abstract class AntimodeMenu extends React.Component { protected getElementWithRows(rows: JSX.Element[], numRows: number, hasDragger: boolean = true) { return ( <div className="antimodeMenu-cont with-rows" onPointerLeave={this.pointerLeave} onPointerEnter={this.pointerEntered} ref={this._mainCont} onContextMenu={this.handleContextMenu} - style={{ left: this._left, top: this._top, opacity: this._opacity, transition: this._transition, transitionDelay: this._transitionDelay, height: 35 * numRows + "px" }}> + style={{ left: this._left, top: this._top, opacity: this._opacity, transitionProperty: this._transitionProperty, transitionDuration: this._transitionDuration, transitionDelay: this._transitionDelay, height: "auto" }}> {rows} {hasDragger ? <div className="antimodeMenu-dragger" onPointerDown={this.dragStart} style={{ width: this.Pinned ? "20px" : "0px" }} /> : <></>} </div> diff --git a/src/client/views/ContextMenu.scss b/src/client/views/ContextMenu.scss index d3286aa22..1bf242d93 100644 --- a/src/client/views/ContextMenu.scss +++ b/src/client/views/ContextMenu.scss @@ -62,6 +62,42 @@ letter-spacing: 2px; text-transform: uppercase; padding-right: 30px; + + .icon-background { + pointer-events: all; + background-color: transparent; + width: 35px; + text-align: center; + font-size: 20px; + margin-left: 5px; + margin-top: 5px; + margin-bottom: 5px; + height: 20px; + } +} +.contextMenu-description { + // width: 11vw; //10vw + background: whitesmoke; + display: flex; //comment out to allow search icon to be inline with search text + justify-content: left; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + transition: all .1s; + border-style: none; + // padding: 10px 0px 10px 0px; + white-space: nowrap; + font-size: 13px; + color: grey; + letter-spacing: 2px; + text-transform: uppercase; + padding-right: 30px; + margin-top: 5px; + height: 20px; + margin-bottom: 5px; } .contextMenu-item:hover { @@ -122,15 +158,4 @@ padding-left: 10px; border: solid black 1px; border-radius: 5px; -} - -.icon-background { - pointer-events: all; - height:100%; - margin-top: 15px; - background-color: transparent; - width: 35px; - text-align: center; - font-size: 20px; - margin-left: 5px; }
\ No newline at end of file diff --git a/src/client/views/ContextMenu.tsx b/src/client/views/ContextMenu.tsx index 4d04d4e89..5b66b63ed 100644 --- a/src/client/views/ContextMenu.tsx +++ b/src/client/views/ContextMenu.tsx @@ -99,6 +99,15 @@ export class ContextMenu extends React.Component { } } @action + moveAfter(item: ContextMenuProps, after: ContextMenuProps) { + if (this.findByDescription(after.description)) { + const curInd = this._items.findIndex((i) => i.description === item.description); + this._items.splice(curInd, 1); + const afterInd = this._items.findIndex((i) => i.description === after.description); + this._items.splice(afterInd + 1, 0, item); + } + } + @action setDefaultItem(prefix: string, item: (name: string) => void) { this._defaultPrefix = prefix; this._defaultItem = item; diff --git a/src/client/views/ContextMenuItem.tsx b/src/client/views/ContextMenuItem.tsx index fef9e5f60..99840047f 100644 --- a/src/client/views/ContextMenuItem.tsx +++ b/src/client/views/ContextMenuItem.tsx @@ -51,7 +51,8 @@ export class ContextMenuItem extends React.Component<ContextMenuProps & { select currentTimeout?: any; static readonly timeout = 300; - onPointerEnter = () => { + _overPosY = 0; + onPointerEnter = (e: React.MouseEvent) => { if (this.currentTimeout) { clearTimeout(this.currentTimeout); this.currentTimeout = undefined; @@ -59,6 +60,7 @@ export class ContextMenuItem extends React.Component<ContextMenuProps & { select if (this.overItem) { return; } + this._overPosY = e.clientY; this.currentTimeout = setTimeout(action(() => this.overItem = true), ContextMenuItem.timeout); } @@ -88,18 +90,22 @@ export class ContextMenuItem extends React.Component<ContextMenuProps & { select </div> ); } else if ("subitems" in this.props) { + const where = !this.overItem ? "" : this._overPosY < window.innerHeight / 3 ? "flex-start" : this._overPosY > window.innerHeight * 2 / 3 ? "flex-end" : "center"; + const marginTop = !this.overItem ? "" : this._overPosY < window.innerHeight / 3 ? "20px" : this._overPosY > window.innerHeight * 2 / 3 ? "-20px" : ""; const submenu = !this.overItem ? (null) : - <div className="contextMenu-subMenu-cont" style={{ marginLeft: "25%", left: "0px" }}> + <div className="contextMenu-subMenu-cont" style={{ marginLeft: "25%", left: "0px", marginTop }}> {this._items.map(prop => <ContextMenuItem {...prop} key={prop.description} closeMenu={this.props.closeMenu} />)} </div>; return ( - <div className={"contextMenu-item" + (this.props.selected ? " contextMenu-itemSelected" : "")} onMouseLeave={this.onPointerLeave} onMouseEnter={this.onPointerEnter}> + <div className={"contextMenu-item" + (this.props.selected ? " contextMenu-itemSelected" : "")} style={{ alignItems: where }} + onMouseLeave={this.onPointerLeave} onMouseEnter={this.onPointerEnter}> {this.props.icon ? ( - <span className="icon-background" onMouseEnter={this.onPointerLeave}> + <span className="icon-background" onMouseEnter={this.onPointerLeave} style={{ alignItems: "center" }}> <FontAwesomeIcon icon={this.props.icon} size="sm" /> </span> ) : null} - <div className="contextMenu-description" onMouseEnter={this.onPointerEnter} > + <div className="contextMenu-description" onMouseEnter={this.onPointerEnter} + style={{ alignItems: "center" }} > {this.props.description} <FontAwesomeIcon icon={faAngleRight} size="lg" style={{ position: "absolute", right: "10px" }} /> </div> diff --git a/src/client/views/DocComponent.tsx b/src/client/views/DocComponent.tsx index ce48e1215..0a8f0c9a7 100644 --- a/src/client/views/DocComponent.tsx +++ b/src/client/views/DocComponent.tsx @@ -1,63 +1,93 @@ -import { Doc } from '../../new_fields/Doc'; +import { Doc, Opt, DataSym } from '../../new_fields/Doc'; import { Touchable } from './Touchable'; import { computed, action, observable } from 'mobx'; -import { Cast } from '../../new_fields/Types'; +import { Cast, BoolCast } from '../../new_fields/Types'; import { listSpec } from '../../new_fields/Schema'; import { InkingControl } from './InkingControl'; import { InkTool } from '../../new_fields/InkField'; -import { PositionDocument } from '../../new_fields/documentSchemas'; +import { InteractionUtils } from '../util/InteractionUtils'; -/// DocComponent returns a generic React base class used by views that don't have any data extensions (e.g.,CollectionFreeFormDocumentView, DocumentView, ButtonBox) +/// DocComponent returns a generic React base class used by views that don't have 'fieldKey' props (e.g.,CollectionFreeFormDocumentView, DocumentView) interface DocComponentProps { Document: Doc; + LayoutDoc?: () => Opt<Doc>; } export function DocComponent<P extends DocComponentProps, T>(schemaCtor: (doc: Doc) => T) { class Component extends Touchable<P> { //TODO This might be pretty inefficient if doc isn't observed, because computed doesn't cache then @computed get Document(): T { return schemaCtor(this.props.Document); } - @computed get layoutDoc() { return PositionDocument(Doc.Layout(this.props.Document)); } + // This is the "The Document" -- it encapsulates, data, layout, and any templates + @computed get rootDoc() { return Cast(this.props.Document.rootDocument, Doc, null) || this.props.Document; } + // This is the rendering data of a document -- it may be "The Document", or it may be some template document that holds the rendering info + @computed get layoutDoc() { return Doc.Layout(this.props.Document); } + // This is the data part of a document -- ie, the data that is constant across all views of the document + @computed get dataDoc() { return this.props.Document[DataSym] as Doc; } + + protected multiTouchDisposer?: InteractionUtils.MultiTouchEventDisposer; } return Component; } -/// DocStaticProps return a base class for React document views that have data extensions but aren't annotatable (e.g. AudioBox, FormattedTextBox) -interface DocExtendableProps { +/// FieldViewBoxProps - a generic base class for field views that are not annotatable (e.g. AudioBox, FormattedTextBox) +interface ViewBoxBaseProps { Document: Doc; DataDoc?: Doc; fieldKey: string; isSelected: (outsideReaction?: boolean) => boolean; renderDepth: number; + rootSelected: (outsideReaction?: boolean) => boolean; } -export function DocExtendableComponent<P extends DocExtendableProps, T>(schemaCtor: (doc: Doc) => T) { +export function ViewBoxBaseComponent<P extends ViewBoxBaseProps, T>(schemaCtor: (doc: Doc) => T) { class Component extends Touchable<P> { //TODO This might be pretty inefficient if doc isn't observed, because computed doesn't cache then - @computed get Document(): T { return schemaCtor(this.props.Document); } + //@computed get Document(): T { return schemaCtor(this.props.Document); } + + // This is the "The Document" -- it encapsulates, data, layout, and any templates + @computed get rootDoc() { return Cast(this.props.Document.rootDocument, Doc, null) || this.props.Document; } + // This is the rendering data of a document -- it may be "The Document", or it may be some template document that holds the rendering info @computed get layoutDoc() { return Doc.Layout(this.props.Document); } - @computed get dataDoc() { return (this.props.DataDoc && (this.props.Document.isTemplateForField || this.props.Document.isTemplateDoc) ? this.props.DataDoc : Cast(this.props.Document.resolvedDataDoc, Doc, null) || Doc.GetProto(this.props.Document)) as Doc; } - active = (outsideReaction?: boolean) => !this.props.Document.isBackground && (this.props.Document.forceActive || this.props.isSelected(outsideReaction) || this.props.renderDepth === 0);// && !InkingControl.Instance.selectedTool; // bcz: inking state shouldn't affect static tools + // This is the data part of a document -- ie, the data that is constant across all views of the document + @computed get dataDoc() { return this.props.DataDoc && (this.props.Document.isTemplateForField || this.props.Document.isTemplateDoc) ? this.props.DataDoc : this.props.Document[DataSym]; } + + // key where data is stored + @computed get fieldKey() { return this.props.fieldKey; } + + active = (outsideReaction?: boolean) => !this.props.Document.isBackground && (this.props.rootSelected(outsideReaction) || this.props.isSelected(outsideReaction) || this.props.renderDepth === 0 || this.layoutDoc.forceActive);// && !InkingControl.Instance.selectedTool; // bcz: inking state shouldn't affect static tools + protected multiTouchDisposer?: InteractionUtils.MultiTouchEventDisposer; } return Component; } -/// DocAnnotatbleComponent return a base class for React views of document fields that are annotatable *and* interactive when selected (e.g., pdf, image) -export interface DocAnnotatableProps { +/// DocAnnotatbleComponent -return a base class for React views of document fields that are annotatable *and* interactive when selected (e.g., pdf, image) +export interface ViewBoxAnnotatableProps { Document: Doc; DataDoc?: Doc; fieldKey: string; active: () => boolean; whenActiveChanged: (isActive: boolean) => void; isSelected: (outsideReaction?: boolean) => boolean; + rootSelected: (outsideReaction?: boolean) => boolean; renderDepth: number; } -export function DocAnnotatableComponent<P extends DocAnnotatableProps, T>(schemaCtor: (doc: Doc) => T) { +export function ViewBoxAnnotatableComponent<P extends ViewBoxAnnotatableProps, T>(schemaCtor: (doc: Doc) => T) { class Component extends Touchable<P> { @observable _isChildActive = false; //TODO This might be pretty inefficient if doc isn't observed, because computed doesn't cache then @computed get Document(): T { return schemaCtor(this.props.Document); } - @computed get layoutDoc() { return Doc.Layout(this.props.Document); } - @computed get dataDoc() { return (this.props.DataDoc && (this.props.Document.isTemplateForField || this.props.Document.isTemplateDoc) ? this.props.DataDoc : Cast(this.props.Document.resolvedDataDoc, Doc, null) || Doc.GetProto(this.props.Document)) as Doc; } + + // This is the "The Document" -- it encapsulates, data, layout, and any templates + @computed get rootDoc() { return Cast(this.props.Document.rootDocument, Doc, null) || this.props.Document; } + // This is the rendering data of a document -- it may be "The Document", or it may be some template document that holds the rendering info + @computed get layoutDoc() { return schemaCtor(Doc.Layout(this.props.Document)); } + // This is the data part of a document -- ie, the data that is constant across all views of the document + @computed get dataDoc() { return this.props.DataDoc && (this.props.Document.isTemplateForField || this.props.Document.isTemplateDoc) ? this.props.DataDoc : this.props.Document[DataSym]; } + + // key where data is stored + @computed get fieldKey() { return this.props.fieldKey; } + + protected multiTouchDisposer?: InteractionUtils.MultiTouchEventDisposer; _annotationKey: string = "annotations"; public set annotationKey(val: string) { this._annotationKey = val; } @@ -78,14 +108,14 @@ export function DocAnnotatableComponent<P extends DocAnnotatableProps, T>(schema } @action.bound addDocument(doc: Doc): boolean { - Doc.GetProto(doc).annotationOn = this.props.Document; + doc.context = Doc.GetProto(doc).annotationOn = this.props.Document; return Doc.AddDocToList(this.dataDoc, this.props.fieldKey + "-" + this._annotationKey, doc) ? true : false; } whenActiveChanged = action((isActive: boolean) => this.props.whenActiveChanged(this._isChildActive = isActive)); active = (outsideReaction?: boolean) => ((InkingControl.Instance.selectedTool === InkTool.None && !this.props.Document.isBackground) && - (this.props.Document.forceActive || this.props.isSelected(outsideReaction) || this._isChildActive || this.props.renderDepth === 0) ? true : false) - annotationsActive = (outsideReaction?: boolean) => (InkingControl.Instance.selectedTool !== InkTool.None || + (this.props.rootSelected(outsideReaction) || this.props.isSelected(outsideReaction) || this._isChildActive || this.props.renderDepth === 0 || BoolCast((this.layoutDoc as any).forceActive)) ? true : false) + annotationsActive = (outsideReaction?: boolean) => (InkingControl.Instance.selectedTool !== InkTool.None || (this.props.Document.isBackground && this.props.active()) || (this.props.Document.forceActive || this.props.isSelected(outsideReaction) || this._isChildActive || this.props.renderDepth === 0) ? true : false) } return Component; diff --git a/src/client/views/DocumentButtonBar.tsx b/src/client/views/DocumentButtonBar.tsx index d6029dbe5..3624cdb6d 100644 --- a/src/client/views/DocumentButtonBar.tsx +++ b/src/client/views/DocumentButtonBar.tsx @@ -1,15 +1,13 @@ import { IconProp, library } from '@fortawesome/fontawesome-svg-core'; -import { faArrowAltCircleDown, faArrowAltCircleUp, faCheckCircle, faCloudUploadAlt, faLink, faShare, faStopCircle, faSyncAlt, faTag, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { faArrowAltCircleDown, faPhotoVideo, faArrowAltCircleUp, faCheckCircle, faCloudUploadAlt, faLink, faShare, faStopCircle, faSyncAlt, faTag, faTimes } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { action, computed, observable, runInAction } from "mobx"; import { observer } from "mobx-react"; import { Doc, DocListCast } from "../../new_fields/Doc"; -import { Id } from '../../new_fields/FieldSymbols'; import { RichTextField } from '../../new_fields/RichTextField'; import { NumCast, StrCast } from "../../new_fields/Types"; -import { emptyFunction } from "../../Utils"; +import { emptyFunction, setupMoveUpEvents } from "../../Utils"; import { Pulls, Pushes } from '../apis/google_docs/GoogleApiClientUtils'; -import RichTextMenu from '../util/RichTextMenu'; import { UndoManager } from "../util/UndoManager"; import { CollectionDockingView, DockedFrameRenderer } from './collections/CollectionDockingView'; import { ParentDocSelector } from './collections/ParentDocumentSelector'; @@ -17,13 +15,13 @@ import './collections/ParentDocumentSelector.scss'; import './DocumentButtonBar.scss'; import { LinkMenu } from "./linking/LinkMenu"; import { DocumentView } from './nodes/DocumentView'; -import { GoogleRef } from "./nodes/FormattedTextBox"; +import { GoogleRef } from "./nodes/formattedText/FormattedTextBox"; import { TemplateMenu } from "./TemplateMenu"; import { Template, Templates } from "./Templates"; import React = require("react"); import { DragManager } from '../util/DragManager'; import { MetadataEntryMenu } from './MetadataEntryMenu'; -import { CurrentUserUtils } from '../../server/authentication/models/current_user_utils'; +import GoogleAuthenticationManager from '../apis/GoogleAuthenticationManager'; const higflyout = require("@hig/flyout"); export const { anchorPoints } = higflyout; export const Flyout = higflyout.default; @@ -38,6 +36,7 @@ library.add(faCheckCircle); library.add(faCloudUploadAlt); library.add(faSyncAlt); library.add(faShare); +library.add(faPhotoVideo); const cloud: IconProp = "cloud-upload-alt"; const fetch: IconProp = "sync-alt"; @@ -106,52 +105,40 @@ export class DocumentButtonBar extends React.Component<{ views: (DocumentView | this._pullColorAnimating = false; }); - get view0() { return this.props.views && this.props.views.length ? this.props.views[0] : undefined; } + get view0() { return this.props.views?.[0]; } @action - onLinkButtonMoved = (e: PointerEvent): void => { - if (this._linkButton.current !== null && (Math.abs(e.clientX - this._downX) > 3 || Math.abs(e.clientY - this._downY) > 3)) { - document.removeEventListener("pointermove", this.onLinkButtonMoved); - document.removeEventListener("pointerup", this.onLinkButtonUp); + onLinkButtonMoved = (e: PointerEvent) => { + if (this._linkButton.current !== null) { const linkDrag = UndoManager.StartBatch("Drag Link"); this.view0 && DragManager.StartLinkDrag(this._linkButton.current, this.view0.props.Document, e.pageX, e.pageY, { dragComplete: dropEv => { const linkDoc = dropEv.linkDragData?.linkDocument as Doc; // equivalent to !dropEve.aborted since linkDocument is only assigned on a completed drop if (this.view0 && linkDoc) { - const proto = Doc.GetProto(linkDoc); - proto.sourceContext = this.view0.props.ContainingCollectionDoc; + Doc.GetProto(linkDoc).linkRelationship = "hyperlink"; - const anchor2Title = linkDoc.anchor2 instanceof Doc ? StrCast(linkDoc.anchor2.title) : "-untitled-"; - const anchor2Id = linkDoc.anchor2 instanceof Doc ? linkDoc.anchor2[Id] : ""; - const text = RichTextMenu.Instance.MakeLinkToSelection(linkDoc[Id], anchor2Title, e.ctrlKey ? "onRight" : "inTab", anchor2Id); - if (linkDoc.anchor2 instanceof Doc) { - proto.title = text === "" ? proto.title : text + " to " + linkDoc.anchor2.title; // TODO open to more descriptive descriptions of following in text link - } + // we want to allow specific views to handle the link creation in their own way (e.g., rich text makes text hyperlinks) + // the dragged view can regiser a linkDropCallback to be notified that the link was made and to update their data structures + // however, the dropped document isn't so accessible. What we do is set the newly created link document on the documentView + // The documentView passes a function prop returning this link doc to its descendants who can react to changes to it. + dropEv.linkDragData?.linkDropCallback?.(dropEv.linkDragData); + runInAction(() => this.view0!._link = linkDoc); + setTimeout(action(() => this.view0!._link = undefined), 0); } linkDrag?.end(); }, hideSource: false }); + return true; } - e.stopPropagation(); + return false; } onLinkButtonDown = (e: React.PointerEvent): void => { - this._downX = e.clientX; - this._downY = e.clientY; - document.removeEventListener("pointermove", this.onLinkButtonMoved); - document.addEventListener("pointermove", this.onLinkButtonMoved); - document.removeEventListener("pointerup", this.onLinkButtonUp); - document.addEventListener("pointerup", this.onLinkButtonUp); - e.stopPropagation(); + setupMoveUpEvents(this, e, this.onLinkButtonMoved, emptyFunction, emptyFunction); } - onLinkButtonUp = (e: PointerEvent): void => { - document.removeEventListener("pointermove", this.onLinkButtonMoved); - document.removeEventListener("pointerup", this.onLinkButtonUp); - e.stopPropagation(); - } @computed get considerGoogleDocsPush() { @@ -162,7 +149,8 @@ export class DocumentButtonBar extends React.Component<{ views: (DocumentView | title={`${published ? "Push" : "Publish"} to Google Docs`} className="documentButtonBar-linker" style={{ animation }} - onClick={() => { + onClick={async () => { + await GoogleAuthenticationManager.Instance.fetchOrGenerateAccessToken(); !published && runInAction(() => this.isAnimatingPulse = true); DocumentButtonBar.hasPushedHack = false; targetDoc[Pushes] = NumCast(targetDoc[Pushes]) + 1; @@ -201,9 +189,9 @@ export class DocumentButtonBar extends React.Component<{ views: (DocumentView | @computed get pinButton() { const targetDoc = this.view0?.props.Document; - const isPinned = targetDoc && CurrentUserUtils.IsDocPinned(targetDoc); + const isPinned = targetDoc && Doc.isDocPinned(targetDoc); return !targetDoc ? (null) : <div className="documentButtonBar-linker" - title={CurrentUserUtils.IsDocPinned(targetDoc) ? "Unpin from presentation" : "Pin to presentation"} + title={Doc.isDocPinned(targetDoc) ? "Unpin from presentation" : "Pin to presentation"} style={{ backgroundColor: isPinned ? "black" : "white", color: isPinned ? "white" : "black" }} onClick={e => { @@ -240,7 +228,7 @@ export class DocumentButtonBar extends React.Component<{ views: (DocumentView | return !view0 ? (null) : <div title="Show metadata panel" className="documentButtonBar-linkFlyout"> <Flyout anchorPoint={anchorPoints.LEFT_TOP} content={<MetadataEntryMenu docs={() => this.props.views.filter(dv => dv).map(dv => dv!.props.Document)} suggestWithFunction /> /* tfs: @bcz This might need to be the data document? */}> - <div className={"documentButtonBar-linkButton-" + "empty"} > + <div className={"documentButtonBar-linkButton-" + "empty"} onPointerDown={e => e.stopPropagation()} > {<FontAwesomeIcon className="documentdecorations-icon" icon="tag" size="sm" />} </div> </Flyout> @@ -249,37 +237,20 @@ export class DocumentButtonBar extends React.Component<{ views: (DocumentView | @computed get contextButton() { - return !this.view0 ? (null) : <ParentDocSelector Views={this.props.views.filter(v => v).map(v => v as DocumentView)} Document={this.view0.props.Document} addDocTab={(doc, data, where) => { - where === "onRight" ? CollectionDockingView.AddRightSplit(doc, data) : - this.props.stack ? CollectionDockingView.Instance.AddTab(this.props.stack, doc, data) : - this.view0?.props.addDocTab(doc, data, "onRight"); + return !this.view0 ? (null) : <ParentDocSelector Document={this.view0.props.Document} addDocTab={(doc, where) => { + where === "onRight" ? CollectionDockingView.AddRightSplit(doc) : + this.props.stack ? CollectionDockingView.Instance.AddTab(this.props.stack, doc) : + this.view0?.props.addDocTab(doc, "onRight"); return true; }} />; } - private _downx = 0; - private _downy = 0; - onAliasButtonUp = (e: PointerEvent): void => { - document.removeEventListener("pointermove", this.onAliasButtonMoved); - document.removeEventListener("pointerup", this.onAliasButtonUp); - e.stopPropagation(); - } - + @observable _aliasDown = false; onAliasButtonDown = (e: React.PointerEvent): void => { - this._downx = e.clientX; - this._downy = e.clientY; - e.stopPropagation(); - e.preventDefault(); - document.removeEventListener("pointermove", this.onAliasButtonMoved); - document.addEventListener("pointermove", this.onAliasButtonMoved); - document.removeEventListener("pointerup", this.onAliasButtonUp); - document.addEventListener("pointerup", this.onAliasButtonUp); + setupMoveUpEvents(this, e, this.onAliasButtonMoved, emptyFunction, emptyFunction); } - onAliasButtonMoved = (e: PointerEvent): void => { - if (this._dragRef.current !== null && (Math.abs(e.clientX - this._downx) > 4 || Math.abs(e.clientY - this._downy) > 4)) { - document.removeEventListener("pointermove", this.onAliasButtonMoved); - document.removeEventListener("pointerup", this.onAliasButtonUp); - + onAliasButtonMoved = () => { + if (this._dragRef.current) { const dragDocView = this.props.views[0]!; const dragData = new DragManager.DocumentDragData([dragDocView.props.Document]); const [left, top] = dragDocView.props.ScreenToLocalTransform().inverse().transformPoint(0, 0); @@ -290,8 +261,9 @@ export class DocumentButtonBar extends React.Component<{ views: (DocumentView | offsetY: dragData.offset[1], hideSource: false }); + return true; } - e.stopPropagation(); + return false; } @computed @@ -299,11 +271,11 @@ export class DocumentButtonBar extends React.Component<{ views: (DocumentView | const view0 = this.view0; const templates: Map<Template, boolean> = new Map(); Array.from(Object.values(Templates.TemplateList)).map(template => - templates.set(template, this.props.views.reduce((checked, doc) => checked || doc?.getLayoutPropStr("show" + template.Name) ? true : false, false as boolean))); - return !view0 ? (null) : <div title="Customize layout" className="documentButtonBar-linkFlyout" ref={this._dragRef}> - <Flyout anchorPoint={anchorPoints.LEFT_TOP} - content={<TemplateMenu docViews={this.props.views.filter(v => v).map(v => v as DocumentView)} templates={templates} />}> - <div className={"documentButtonBar-linkButton-" + "empty"} ref={this._dragRef} onPointerDown={this.onAliasButtonDown} > + templates.set(template, this.props.views.reduce((checked, doc) => checked || doc?.props.Document["_show" + template.Name] ? true : false, false as boolean))); + return !view0 ? (null) : <div title="Tap: Customize layout. Drag: Create alias" className="documentButtonBar-linkFlyout" ref={this._dragRef}> + <Flyout anchorPoint={anchorPoints.LEFT_TOP} onOpen={action(() => this._aliasDown = true)} onClose={action(() => this._aliasDown = false)} + content={!this._aliasDown ? (null) : <TemplateMenu docViews={this.props.views.filter(v => v).map(v => v as DocumentView)} templates={templates} />}> + <div className={"documentButtonBar-linkButton-empty"} ref={this._dragRef} onPointerDown={this.onAliasButtonDown} > {<FontAwesomeIcon className="documentdecorations-icon" icon="edit" size="sm" />} </div> </Flyout> @@ -313,10 +285,9 @@ export class DocumentButtonBar extends React.Component<{ views: (DocumentView | render() { if (!this.view0) return (null); - const isText = this.view0.props.Document.data instanceof RichTextField; // bcz: Todo - can't assume layout is using the 'data' field. need to add fieldKey to DocumentView + const isText = this.view0.props.Document[Doc.LayoutFieldKey(this.view0.props.Document)] instanceof RichTextField; const considerPull = isText && this.considerGoogleDocsPull; const considerPush = isText && this.considerGoogleDocsPush; - Doc.UserDoc().pr return <div className="documentButtonBar"> <div className="documentButtonBar-button"> {this.linkButton} diff --git a/src/client/views/DocumentDecorations.scss b/src/client/views/DocumentDecorations.scss index 32346165d..28cf9fd47 100644 --- a/src/client/views/DocumentDecorations.scss +++ b/src/client/views/DocumentDecorations.scss @@ -27,6 +27,17 @@ $linkGap : 3px; opacity: 1; } + .documentDecorations-selector { + pointer-events: auto; + height: 15px; + width: 15px; + left: -20px; + top: 20px; + display: inline-block; + position: absolute; + opacity: 0.5; + } + .documentDecorations-radius { pointer-events: auto; background: black; @@ -69,6 +80,7 @@ $linkGap : 3px; #documentDecorations-topLeftResizer, #documentDecorations-bottomRightResizer { cursor: nwse-resize; + background: dimGray; } #documentDecorations-bottomRightResizer { @@ -78,6 +90,7 @@ $linkGap : 3px; #documentDecorations-topRightResizer, #documentDecorations-bottomLeftResizer { cursor: nesw-resize; + background: dimGray; } #documentDecorations-topResizer, @@ -90,7 +103,15 @@ $linkGap : 3px; cursor: ew-resize; } - .title { + .documentDecorations-contextMenu { + background: $alt-accent; + width: 25px; + height: calc(100% + 8px); // 8px for the height of the top resizer bar + grid-column-start: 1; + grid-column-end : 2; + pointer-events: all; + } + .documentDecorations-title { background: $alt-accent; opacity: 1; grid-column-start: 3; @@ -98,6 +119,18 @@ $linkGap : 3px; pointer-events: auto; overflow: hidden; text-align: center; + display:flex; + } + .publishBox { + width: 20px; + height: 22px; + grid-column-start: 3; + grid-column-end: 4; + pointer-events: all; + background: darkgray; + display: inline-block; + position: absolute; + right: 0; } } @@ -110,7 +143,6 @@ $linkGap : 3px; pointer-events: all; text-align: center; cursor: pointer; - padding-right: 10px; } .documentDecorations-minimizeButton { @@ -124,9 +156,9 @@ $linkGap : 3px; position: absolute; left: 0px; top: 0px; - padding-top: 5px; width: $MINIMIZED_ICON_SIZE; height: $MINIMIZED_ICON_SIZE; + max-height: 20px; } .documentDecorations-background { @@ -153,11 +185,12 @@ $linkGap : 3px; .link-button-container { margin-top: $linkGap; - grid-column: 1/4; width: max-content; height: auto; display: flex; flex-direction: row; + z-index: 998; + position: absolute; } .linkButtonWrapper { @@ -246,6 +279,10 @@ $linkGap : 3px; } } +.documentDecorations-darkScheme { + background: dimgray; +} + #template-list { position: absolute; top: 25px; diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index a0ba16ea4..312acd5b2 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -1,15 +1,13 @@ import { IconProp, library } from '@fortawesome/fontawesome-svg-core'; -import { faArrowAltCircleDown, faArrowAltCircleUp, faCheckCircle, faCloudUploadAlt, faLink, faShare, faStopCircle, faSyncAlt, faTag, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { faCaretUp, faFilePdf, faFilm, faImage, faObjectGroup, faStickyNote, faTextHeight, faArrowAltCircleDown, faArrowAltCircleUp, faCheckCircle, faCloudUploadAlt, faLink, faShare, faStopCircle, faSyncAlt, faTag, faTimes } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { action, computed, observable, reaction } from "mobx"; +import { action, computed, observable, reaction, runInAction } from "mobx"; import { observer } from "mobx-react"; -import { Doc } from "../../new_fields/Doc"; +import { Doc, DataSym, Field } from "../../new_fields/Doc"; import { PositionDocument } from '../../new_fields/documentSchemas'; -import { ObjectField } from '../../new_fields/ObjectField'; import { ScriptField } from '../../new_fields/ScriptField'; -import { Cast, StrCast } from "../../new_fields/Types"; -import { CurrentUserUtils } from '../../server/authentication/models/current_user_utils'; -import { Utils } from "../../Utils"; +import { Cast, StrCast, NumCast } from "../../new_fields/Types"; +import { Utils, setupMoveUpEvents, emptyFunction, returnFalse, simulateMouseClick } from "../../Utils"; import { DocUtils } from "../documents/Documents"; import { DocumentType } from '../documents/DocumentTypes'; import { DragManager } from "../util/DragManager"; @@ -18,12 +16,16 @@ import { undoBatch, UndoManager } from "../util/UndoManager"; import { DocumentButtonBar } from './DocumentButtonBar'; import './DocumentDecorations.scss'; import { DocumentView } from "./nodes/DocumentView"; -import { IconBox } from "./nodes/IconBox"; import React = require("react"); -const higflyout = require("@hig/flyout"); -export const { anchorPoints } = higflyout; -export const Flyout = higflyout.default; +import { Id } from '../../new_fields/FieldSymbols'; +import e = require('express'); +import { CollectionDockingView } from './collections/CollectionDockingView'; +library.add(faCaretUp); +library.add(faObjectGroup); +library.add(faStickyNote); +library.add(faFilePdf); +library.add(faFilm, faTextHeight); library.add(faLink); library.add(faTag); library.add(faTimes); @@ -38,38 +40,56 @@ library.add(faShare); @observer export class DocumentDecorations extends React.Component<{}, { value: string }> { static Instance: DocumentDecorations; - private _isPointerDown = false; - private _resizing = ""; - private _keyinput: React.RefObject<HTMLInputElement>; + private _resizeHdlId = ""; + private _keyinput = React.createRef<HTMLInputElement>(); private _resizeBorderWidth = 16; private _linkBoxHeight = 20 + 3; // link button height + margin private _titleHeight = 20; - private _downX = 0; - private _downY = 0; private _resizeUndo?: UndoManager.Batch; - private _radiusDown = [0, 0]; @observable private _accumulatedTitle = ""; @observable private _titleControlString: string = "#title"; @observable private _edtingTitle = false; @observable private _hidden = false; - @observable private _opacity = 1; - @observable public Interacting = false; + @observable public Interacting = false; @observable public pushIcon: IconProp = "arrow-alt-circle-up"; @observable public pullIcon: IconProp = "arrow-alt-circle-down"; @observable public pullColor: string = "white"; - @observable public openHover = false; constructor(props: Readonly<{}>) { super(props); DocumentDecorations.Instance = this; - this._keyinput = React.createRef(); reaction(() => SelectionManager.SelectedDocuments().slice(), docs => this.titleBlur(false)); } - @action titleChanged = (event: any) => this._accumulatedTitle = event.target.value; + @computed + get Bounds(): { x: number, y: number, b: number, r: number } { + return SelectionManager.SelectedDocuments().reduce((bounds, documentView) => { + if (documentView.props.renderDepth === 0 || + Doc.AreProtosEqual(documentView.props.Document, Doc.UserDoc())) { + return bounds; + } + const transform = (documentView.props.ScreenToLocalTransform().scale(documentView.props.ContentScaling())).inverse(); + var [sptX, sptY] = transform.transformPoint(0, 0); + let [bptX, bptY] = transform.transformPoint(documentView.props.PanelWidth(), documentView.props.PanelHeight()); + if (documentView.props.Document.type === DocumentType.LINK) { + const docuBox = documentView.ContentDiv!.getElementsByClassName("linkAnchorBox-cont"); + if (docuBox.length) { + const rect = docuBox[0].getBoundingClientRect(); + sptX = rect.left; + sptY = rect.top; + bptX = rect.right; + bptY = rect.bottom; + } + } + return { + x: Math.min(sptX, bounds.x), y: Math.min(sptY, bounds.y), + r: Math.max(bptX, bounds.r), b: Math.max(bptY, bounds.b) + }; + }, { x: Number.MAX_VALUE, y: Number.MAX_VALUE, r: Number.MIN_VALUE, b: Number.MIN_VALUE }); + } - titleBlur = undoBatch(action((commit: boolean) => { + titleBlur = action((commit: boolean) => { this._edtingTitle = false; if (commit) { if (this._accumulatedTitle.startsWith("#") || this._accumulatedTitle.startsWith("=")) { @@ -77,12 +97,13 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> } else if (this._titleControlString.startsWith("#")) { const selectionTitleFieldKey = this._titleControlString.substring(1); selectionTitleFieldKey === "title" && (SelectionManager.SelectedDocuments()[0].props.Document.customTitle = !this._accumulatedTitle.startsWith("-")); - selectionTitleFieldKey && SelectionManager.SelectedDocuments().forEach(d => - Doc.SetInPlace(d.props.Document, selectionTitleFieldKey, typeof d.props.Document[selectionTitleFieldKey] === "number" ? +this._accumulatedTitle : this._accumulatedTitle, true) - ); + UndoManager.RunInBatch(() => selectionTitleFieldKey && SelectionManager.SelectedDocuments().forEach(d => { + const value = typeof d.props.Document[selectionTitleFieldKey] === "number" ? +this._accumulatedTitle : this._accumulatedTitle; + Doc.SetInPlace(d.props.Document, selectionTitleFieldKey, value, true); + }), "title blur"); } } - })); + }); @action titleEntered = (e: any) => { const key = e.keyCode || e.which; @@ -90,92 +111,45 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> if (key === 13) { const text = e.target.value; if (text.startsWith("::")) { - const targetID = text.slice(2, text.length); + this._accumulatedTitle = text.slice(2, text.length); const promoteDoc = SelectionManager.SelectedDocuments()[0]; - DocUtils.Publish(promoteDoc.props.Document, targetID, promoteDoc.props.addDocument, promoteDoc.props.removeDocument); - } else if (text.startsWith(">")) { - const fieldTemplateView = SelectionManager.SelectedDocuments()[0]; - SelectionManager.DeselectAll(); - const fieldTemplate = fieldTemplateView.props.Document; - const containerView = fieldTemplateView.props.ContainingCollectionView; - const docTemplate = fieldTemplateView.props.ContainingCollectionDoc; - if (containerView && docTemplate) { - const metaKey = text.startsWith(">>") ? text.slice(2, text.length) : text.slice(1, text.length); - if (metaKey !== containerView.props.fieldKey && containerView.props.DataDoc) { - const fd = fieldTemplate.data; - fd instanceof ObjectField && (Doc.GetProto(containerView.props.DataDoc)[metaKey] = ObjectField.MakeCopy(fd)); - } - fieldTemplate.title = metaKey; - Doc.MakeMetadataFieldTemplate(fieldTemplate, Doc.GetProto(docTemplate)); - if (text.startsWith(">>")) { - Doc.GetProto(docTemplate).layout = StrCast(fieldTemplateView.props.Document.layout).replace(/fieldKey={'[^']*'}/, `fieldKey={"${metaKey}"}`); - } - } + Doc.SetInPlace(promoteDoc.props.Document, "title", this._accumulatedTitle, true); + DocUtils.Publish(promoteDoc.props.Document, this._accumulatedTitle, promoteDoc.props.addDocument, promoteDoc.props.removeDocument); } e.target.blur(); } } @action onTitleDown = (e: React.PointerEvent): void => { - this._downX = e.clientX; - this._downY = e.clientY; - e.stopPropagation(); - document.removeEventListener("pointermove", this.onTitleMove); - document.removeEventListener("pointerup", this.onTitleUp); - document.addEventListener("pointermove", this.onTitleMove); - document.addEventListener("pointerup", this.onTitleUp); + setupMoveUpEvents(this, e, this.onBackgroundMove, (e) => { }, this.onTitleClick); } - @action onTitleMove = (e: PointerEvent): void => { - if (Math.abs(e.clientX - this._downX) > 4 || Math.abs(e.clientY - this._downY) > 4) { - this.Interacting = true; - } - if (this.Interacting) this.onBackgroundMove(e); - e.stopPropagation(); - } - @action onTitleUp = (e: PointerEvent): void => { - if (Math.abs(e.clientX - this._downX) < 4 || Math.abs(e.clientY - this._downY) < 4) { - !this._edtingTitle && (this._accumulatedTitle = this._titleControlString.startsWith("#") ? this.selectionTitle : this._titleControlString); - this._edtingTitle = true; - setTimeout(() => this._keyinput.current!.focus(), 0); - } - document.removeEventListener("pointermove", this.onTitleMove); - document.removeEventListener("pointerup", this.onTitleUp); - this.onBackgroundUp(e); + @action onTitleClick = (e: PointerEvent): void => { + !this._edtingTitle && (this._accumulatedTitle = this._titleControlString.startsWith("#") ? this.selectionTitle : this._titleControlString); + this._edtingTitle = true; + setTimeout(() => this._keyinput.current!.focus(), 0); } - @computed - get Bounds(): { x: number, y: number, b: number, r: number } { - return SelectionManager.SelectedDocuments().reduce((bounds, documentView) => { - if (documentView.props.renderDepth === 0 || - Doc.AreProtosEqual(documentView.props.Document, CurrentUserUtils.UserDocument)) { - return bounds; - } - const transform = (documentView.props.ScreenToLocalTransform().scale(documentView.props.ContentScaling())).inverse(); - var [sptX, sptY] = transform.transformPoint(0, 0); - let [bptX, bptY] = transform.transformPoint(documentView.props.PanelWidth(), documentView.props.PanelHeight()); - if (documentView.props.Document.type === DocumentType.LINK) { - const rect = documentView.ContentDiv!.getElementsByClassName("docuLinkBox-cont")[0].getBoundingClientRect(); - sptX = rect.left; - sptY = rect.top; - bptX = rect.right; - bptY = rect.bottom; + @action onSettingsDown = (e: React.PointerEvent): void => { + setupMoveUpEvents(this, e, () => false, (e) => { }, this.onSettingsClick); + } + @action onSettingsClick = (e: PointerEvent): void => { + if (e.button === 0 && !e.altKey && !e.ctrlKey) { + let child = SelectionManager.SelectedDocuments()[0].ContentDiv!.children[0]; + while (child.children.length) { + const next = Array.from(child.children).find(c => !c.className.includes("collectionViewChrome")); + if (next?.className.includes("documentView-node")) break; + if (next) child = next; + else break; } - return { - x: Math.min(sptX, bounds.x), y: Math.min(sptY, bounds.y), - r: Math.max(bptX, bounds.r), b: Math.max(bptY, bounds.b) - }; - }, { x: Number.MAX_VALUE, y: Number.MAX_VALUE, r: Number.MIN_VALUE, b: Number.MIN_VALUE }); + simulateMouseClick(child, e.clientX, e.clientY + 30, e.screenX, e.screenY + 30); + } } onBackgroundDown = (e: React.PointerEvent): void => { - document.removeEventListener("pointermove", this.onBackgroundMove); - document.removeEventListener("pointerup", this.onBackgroundUp); - document.addEventListener("pointermove", this.onBackgroundMove); - document.addEventListener("pointerup", this.onBackgroundUp); - e.stopPropagation(); + setupMoveUpEvents(this, e, this.onBackgroundMove, (e) => { }, (e) => { }); } @action - onBackgroundMove = (e: PointerEvent): void => { + onBackgroundMove = (e: PointerEvent, down: number[]): boolean => { const dragDocView = SelectionManager.SelectedDocuments()[0]; const dragData = new DragManager.DocumentDragData(SelectionManager.SelectedDocuments().map(dv => dv.props.Document)); const [left, top] = dragDocView.props.ScreenToLocalTransform().scale(dragDocView.props.ContentScaling()).inverse().transformPoint(0, 0); @@ -184,201 +158,129 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> dragData.isSelectionMove = true; this.Interacting = true; this._hidden = true; - document.removeEventListener("pointermove", this.onBackgroundMove); - document.removeEventListener("pointerup", this.onBackgroundUp); - document.removeEventListener("pointermove", this.onTitleMove); - document.removeEventListener("pointerup", this.onTitleUp); - DragManager.StartDocumentDrag(SelectionManager.SelectedDocuments().map(documentView => documentView.ContentDiv!), dragData, e.x, e.y, { + DragManager.StartDocumentDrag(SelectionManager.SelectedDocuments().map(dv => dv.ContentDiv!), dragData, e.x, e.y, { dragComplete: action(e => this._hidden = this.Interacting = false), hideSource: true }); - e.stopPropagation(); + return true; } - @action - onBackgroundUp = (e: PointerEvent): void => { - document.removeEventListener("pointermove", this.onBackgroundMove); - document.removeEventListener("pointerup", this.onBackgroundUp); - e.stopPropagation(); - e.preventDefault(); - } - - onCloseDown = (e: React.PointerEvent): void => { - e.stopPropagation(); - if (e.button === 0) { - document.removeEventListener("pointermove", this.onCloseMove); - document.addEventListener("pointermove", this.onCloseMove); - document.removeEventListener("pointerup", this.onCloseUp); - document.addEventListener("pointerup", this.onCloseUp); - } - } - onCloseMove = (e: PointerEvent): void => { - e.stopPropagation(); - if (e.button === 0) { - } + onIconifyDown = (e: React.PointerEvent): void => { + setupMoveUpEvents(this, e, (e, d) => false, (e) => { }, this.onIconifyClick); } @undoBatch @action - onCloseUp = async (e: PointerEvent) => { - e.stopPropagation(); + onCloseClick = async (e: PointerEvent) => { if (e.button === 0) { - const recent = Cast(CurrentUserUtils.UserDocument.recentlyClosed, Doc) as Doc; + const recent = Cast(Doc.UserDoc().myRecentlyClosed, Doc) as Doc; const selected = SelectionManager.SelectedDocuments().slice(); SelectionManager.DeselectAll(); + selected.map(dv => { recent && Doc.AddDocToList(recent, "data", dv.props.Document, undefined, true, true); - dv.props.removeDocument && dv.props.removeDocument(dv.props.Document); + dv.props.removeDocument?.(dv.props.Document); }); - document.removeEventListener("pointermove", this.onCloseMove); - document.removeEventListener("pointerup", this.onCloseUp); } } @action - onMinimizeDown = (e: React.PointerEvent): void => { - e.stopPropagation(); - if (e.button === 0) { - document.removeEventListener("pointermove", this.onMinimizeMove); - document.addEventListener("pointermove", this.onMinimizeMove); - document.removeEventListener("pointerup", this.onMinimizeUp); - document.addEventListener("pointerup", this.onMinimizeUp); - } + onMaximizeDown = (e: React.PointerEvent): void => { + setupMoveUpEvents(this, e, (e, d) => false, (e) => { }, this.onMaximizeClick); } - + @undoBatch @action - onMinimizeMove = (e: PointerEvent): void => { - e.stopPropagation(); - if (Math.abs(e.pageX - this._downX) > Utils.DRAG_THRESHOLD || - Math.abs(e.pageY - this._downY) > Utils.DRAG_THRESHOLD) { - document.removeEventListener("pointermove", this.onMinimizeMove); - document.removeEventListener("pointerup", this.onMinimizeUp); + onMaximizeClick = (e: PointerEvent): void => { + if (e.button === 0) { + const selectedDocs = SelectionManager.SelectedDocuments(); + if (selectedDocs.length) { + //CollectionDockingView.Instance?.OpenFullScreen(selectedDocs[0], selectedDocs[0].props.LibraryPath); + CollectionDockingView.AddRightSplit(Doc.MakeAlias(selectedDocs[0].props.Document), selectedDocs[0].props.LibraryPath); + } } + SelectionManager.DeselectAll(); } @undoBatch @action - onMinimizeUp = (e: PointerEvent): void => { - e.stopPropagation(); + onIconifyClick = (e: PointerEvent): void => { if (e.button === 0) { - document.removeEventListener("pointermove", this.onMinimizeMove); - document.removeEventListener("pointerup", this.onMinimizeUp); - const selectedDocs = SelectionManager.SelectedDocuments().map(sd => sd); - selectedDocs.map(dv => { - const layoutKey = Cast(dv.props.Document.layoutKey, "string", null); - const collapse = layoutKey !== "layout_icon"; - if (collapse) { - dv.setCustomView(collapse, "icon"); - if (layoutKey && layoutKey !== "layout") dv.props.Document.deiconifyLayout = layoutKey.replace("layout_", ""); - } else { - const deiconifyLayout = Cast(dv.props.Document.deiconifyLayout, "string", null); - dv.setCustomView(deiconifyLayout ? true : false, deiconifyLayout); - dv.props.Document.deiconifyLayout = undefined; - } - }); + SelectionManager.SelectedDocuments().forEach(dv => dv?.iconify()); } SelectionManager.DeselectAll(); } @action + onSelectorUp = (e: React.PointerEvent): void => { + setupMoveUpEvents(this, e, returnFalse, emptyFunction, action((e) => { + const selDoc = SelectionManager.SelectedDocuments()?.[0]; + if (selDoc) { + selDoc.props.ContainingCollectionView?.props.select(false); + } + })); + } + + @action onRadiusDown = (e: React.PointerEvent): void => { - e.stopPropagation(); + setupMoveUpEvents(this, e, this.onRadiusMove, (e) => this._resizeUndo?.end(), (e) => { }); if (e.button === 0) { - this._radiusDown = [e.clientX, e.clientY]; - this._isPointerDown = true; this._resizeUndo = UndoManager.StartBatch("DocDecs set radius"); - document.removeEventListener("pointermove", this.onRadiusMove); - document.removeEventListener("pointerup", this.onRadiusUp); - document.addEventListener("pointermove", this.onRadiusMove); - document.addEventListener("pointerup", this.onRadiusUp); } } - onRadiusMove = (e: PointerEvent): void => { - let dist = Math.sqrt((e.clientX - this._radiusDown[0]) * (e.clientX - this._radiusDown[0]) + (e.clientY - this._radiusDown[1]) * (e.clientY - this._radiusDown[1])); + onRadiusMove = (e: PointerEvent, down: number[]): boolean => { + let dist = Math.sqrt((e.clientX - down[0]) * (e.clientX - down[0]) + (e.clientY - down[1]) * (e.clientY - down[1])); dist = dist < 3 ? 0 : dist; - SelectionManager.SelectedDocuments().map(dv => dv.props.Document.layout instanceof Doc ? dv.props.Document.layout : dv.props.Document.isTemplateForField ? dv.props.Document : Doc.GetProto(dv.props.Document)). - map(d => d.borderRounding = `${Math.min(100, dist)}%`); - e.stopPropagation(); - e.preventDefault(); + SelectionManager.SelectedDocuments().map(dv => dv.props.Document).map(doc => doc.layout instanceof Doc ? doc.layout : doc.isTemplateForField ? doc : Doc.GetProto(doc)). + map(d => d.borderRounding = `${Math.max(0, dist)}px`); + return false; } - onRadiusUp = (e: PointerEvent): void => { - e.stopPropagation(); - e.preventDefault(); - this._isPointerDown = false; - this._resizeUndo && this._resizeUndo.end(); - document.removeEventListener("pointermove", this.onRadiusMove); - document.removeEventListener("pointerup", this.onRadiusUp); - } - - _lastX = 0; - _lastY = 0; @action onPointerDown = (e: React.PointerEvent): void => { - e.stopPropagation(); + setupMoveUpEvents(this, e, this.onPointerMove, this.onPointerUp, (e) => { }); if (e.button === 0) { - this._lastX = e.clientX; - this._lastY = e.clientY; - this._isPointerDown = true; - this._resizing = e.currentTarget.id; + this._resizeHdlId = e.currentTarget.id; this.Interacting = true; this._resizeUndo = UndoManager.StartBatch("DocDecs resize"); - document.removeEventListener("pointermove", this.onPointerMove); - document.addEventListener("pointermove", this.onPointerMove); - document.removeEventListener("pointerup", this.onPointerUp); - document.addEventListener("pointerup", this.onPointerUp); } } - - onPointerMove = (e: PointerEvent): void => { - e.stopPropagation(); - e.preventDefault(); - if (!this._isPointerDown) { - return; - } - + onPointerMove = (e: PointerEvent, down: number[], move: number[]): boolean => { let dX = 0, dY = 0, dW = 0, dH = 0; - const moveX = e.clientX - this._lastX; // e.movementX; - const moveY = e.clientY - this._lastY; // e.movementY; - this._lastX = e.clientX; - this._lastY = e.clientY; - - switch (this._resizing) { - case "": - break; + switch (this._resizeHdlId) { + case "": break; case "documentDecorations-topLeftResizer": dX = -1; dY = -1; - dW = -moveX; - dH = -moveY; + dW = -move[0]; + dH = -move[1]; break; case "documentDecorations-topRightResizer": - dW = moveX; + dW = move[0]; dY = -1; - dH = -moveY; + dH = -move[1]; break; case "documentDecorations-topResizer": dY = -1; - dH = -moveY; + dH = -move[1]; break; case "documentDecorations-bottomLeftResizer": dX = -1; - dW = -moveX; - dH = moveY; + dW = -move[0]; + dH = move[1]; break; case "documentDecorations-bottomRightResizer": - dW = moveX; - dH = moveY; + dW = move[0]; + dH = move[1]; break; case "documentDecorations-bottomResizer": - dH = moveY; + dH = move[1]; break; case "documentDecorations-leftResizer": dX = -1; - dW = -moveX; + dW = -move[0]; break; case "documentDecorations-rightResizer": - dW = moveX; + dW = move[0]; break; } @@ -391,21 +293,31 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> const width = (layoutDoc._width || 0); const height = (layoutDoc._height || (nheight / nwidth * width)); const scale = element.props.ScreenToLocalTransform().Scale * element.props.ContentScaling(); + if (nwidth && nheight) { + if (Math.abs(dW) > Math.abs(dH)) dH = dW * nheight / nwidth; + else dW = dH * nwidth / nheight; + } const actualdW = Math.max(width + (dW * scale), 20); const actualdH = Math.max(height + (dH * scale), 20); doc.x = (doc.x || 0) + dX * (actualdW - width); doc.y = (doc.y || 0) + dY * (actualdH - height); - const fixedAspect = e.ctrlKey || (!layoutDoc.ignoreAspect && nwidth && nheight); - if (fixedAspect && e.ctrlKey && layoutDoc.ignoreAspect) { - layoutDoc.ignoreAspect = false; - layoutDoc._nativeWidth = nwidth = layoutDoc._width || 0; - layoutDoc._nativeHeight = nheight = layoutDoc._height || 0; - } + const fixedAspect = (nwidth && nheight); if (fixedAspect && (!nwidth || !nheight)) { layoutDoc._nativeWidth = nwidth = layoutDoc._width || 0; layoutDoc._nativeHeight = nheight = layoutDoc._height || 0; } - if (nwidth > 0 && nheight > 0 && !layoutDoc.ignoreAspect) { + const anno = Cast(doc.annotationOn, Doc, null); + if (e.ctrlKey && anno) { + dW !== 0 && runInAction(() => { + const dataDoc = anno[DataSym]; + const fieldKey = Doc.LayoutFieldKey(anno); + const nw = NumCast(dataDoc[fieldKey + "-nativeWidth"]); + const nh = NumCast(dataDoc[fieldKey + "-nativeHeight"]); + dataDoc[fieldKey + "-nativeWidth"] = nw + (dW > 0 ? 10 : -10); + dataDoc[fieldKey + "-nativeHeight"] = nh + (dW > 0 ? 10 : -10) * nh / nw; + }); + } + else if (nwidth > 0 && nheight > 0) { if (Math.abs(dW) > Math.abs(dH)) { if (!fixedAspect) { layoutDoc._nativeWidth = actualdW / (layoutDoc._width || 1) * (layoutDoc._nativeWidth || 0); @@ -429,20 +341,15 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> } } })); + return false; } @action onPointerUp = (e: PointerEvent): void => { - e.stopPropagation(); - this._resizing = ""; + this._resizeHdlId = ""; this.Interacting = false; - if (e.button === 0) { - e.preventDefault(); - this._isPointerDown = false; - this._resizeUndo && this._resizeUndo.end(); - document.removeEventListener("pointermove", this.onPointerMove); - document.removeEventListener("pointerup", this.onPointerUp); - } + (e.button === 0) && this._resizeUndo?.end(); + this._resizeUndo = undefined; } @computed @@ -450,10 +357,10 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> if (SelectionManager.SelectedDocuments().length === 1) { const selected = SelectionManager.SelectedDocuments()[0]; if (this._titleControlString.startsWith("=")) { - return ScriptField.MakeFunction(this._titleControlString.substring(1), { doc: Doc.name })!.script.run({ this: selected.props.Document }, console.log).result?.toString() || ""; + return ScriptField.MakeFunction(this._titleControlString.substring(1), { doc: Doc.name })!.script.run({ self: selected.rootDoc, this: selected.layoutDoc }, console.log).result?.toString() || ""; } if (this._titleControlString.startsWith("#")) { - return selected.props.Document[this._titleControlString.substring(1)]?.toString() || "-unset-"; + return Field.toString(selected.props.Document[this._titleControlString.substring(1)] as Field) || "-unset-"; } return this._accumulatedTitle; } else if (SelectionManager.SelectedDocuments().length > 1) { @@ -468,17 +375,54 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> this.TextBar = ele; } } + public static DocumentIcon(layout: string) { + const button = layout.indexOf("PDFBox") !== -1 ? faFilePdf : + layout.indexOf("ImageBox") !== -1 ? faImage : + layout.indexOf("Formatted") !== -1 ? faStickyNote : + layout.indexOf("Video") !== -1 ? faFilm : + layout.indexOf("Collection") !== -1 ? faObjectGroup : + faCaretUp; + return <FontAwesomeIcon icon={button} className="documentView-minimizedIcon" />; + } render() { + const darkScheme = Cast(Doc.UserDoc().activeWorkspace, Doc, null)?.darkScheme ? "dimgray" : undefined; const bounds = this.Bounds; const seldoc = SelectionManager.SelectedDocuments().length ? SelectionManager.SelectedDocuments()[0] : undefined; - if (SelectionManager.GetIsDragging() || bounds.x === Number.MAX_VALUE || !seldoc || this._hidden || isNaN(bounds.r) || isNaN(bounds.b) || isNaN(bounds.x) || isNaN(bounds.y)) { + if (SelectionManager.GetIsDragging() || bounds.r - bounds.x < 2 || bounds.x === Number.MAX_VALUE || !seldoc || this._hidden || isNaN(bounds.r) || isNaN(bounds.b) || isNaN(bounds.x) || isNaN(bounds.y)) { return (null); } - const minimizeIcon = ( - <div className="documentDecorations-minimizeButton" onPointerDown={this.onMinimizeDown}> - {/* Currently, this is set to be enabled if there is no ink selected. It might be interesting to think about minimizing ink if it's useful? -syip2*/} - {SelectionManager.SelectedDocuments().length === 1 ? IconBox.DocumentIcon(StrCast(SelectionManager.SelectedDocuments()[0].props.Document.layout, "...")) : "..."} - </div>); + const minimal = bounds.r - bounds.x < 100 ? true : false; + const maximizeIcon = minimal ? ( + <div className="documentDecorations-contextMenu" title="Show context menu" onPointerDown={this.onSettingsDown}> + <FontAwesomeIcon size="lg" icon="cog" /> + </div>) : ( + <div className="documentDecorations-minimizeButton" title="Iconify" onPointerDown={this.onIconifyDown}> + {/* Currently, this is set to be enabled if there is no ink selected. It might be interesting to think about minimizing ink if it's useful? -syip2*/} + <FontAwesomeIcon className="documentdecorations-times" icon={faTimes} size="lg" /> + </div>); + + const titleArea = this._edtingTitle ? + <> + <input ref={this._keyinput} className="documentDecorations-title" type="text" name="dynbox" autoComplete="on" value={this._accumulatedTitle} style={{ width: minimal ? "100%" : "calc(100% - 20px)" }} + onBlur={e => this.titleBlur(true)} onChange={action(e => this._accumulatedTitle = e.target.value)} onKeyPress={this.titleEntered} /> + {minimal ? (null) : <div className="publishBox" title="make document referenceable by its title" + onPointerDown={action(e => { + if (!seldoc.props.Document.customTitle) { + seldoc.props.Document.customTitle = true; + StrCast(Doc.GetProto(seldoc.props.Document).title).startsWith("-") && (Doc.GetProto(seldoc.props.Document).title = StrCast(seldoc.props.Document.title).substring(1)); + this._accumulatedTitle = StrCast(seldoc.props.Document.title); + } + DocUtils.Publish(seldoc.props.Document, this._accumulatedTitle, seldoc.props.addDocument, seldoc.props.removeDocument); + })}> + <FontAwesomeIcon size="lg" color={SelectionManager.SelectedDocuments()[0].props.Document.title === SelectionManager.SelectedDocuments()[0].props.Document[Id] ? "green" : undefined} icon="sticky-note"></FontAwesomeIcon> + </div>} + </> : + <div className="documentDecorations-title" onPointerDown={this.onTitleDown} > + {minimal ? (null) : <div className="documentDecorations-contextMenu" title="Show context menu" onPointerDown={this.onSettingsDown}> + <FontAwesomeIcon size="lg" icon="cog" /> + </div>} + <span style={{ width: "calc(100% - 25px)", display: "inline-block" }}>{`${this.selectionTitle}`}</span> + </div>; bounds.x = Math.max(0, bounds.x - this._resizeBorderWidth / 2) + this._resizeBorderWidth / 2; bounds.y = Math.max(0, bounds.y - this._resizeBorderWidth / 2 - this._titleHeight) + this._resizeBorderWidth / 2 + this._titleHeight; @@ -491,7 +435,7 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> if (bounds.y > bounds.b) { bounds.y = bounds.b - (this._resizeBorderWidth + this._linkBoxHeight + this._titleHeight); } - return (<div className="documentDecorations"> + return (<div className="documentDecorations" style={{ background: darkScheme }} > <div className="documentDecorations-background" style={{ width: (bounds.r - bounds.x + this._resizeBorderWidth) + "px", height: (bounds.b - bounds.y + this._resizeBorderWidth) + "px", @@ -499,38 +443,48 @@ export class DocumentDecorations extends React.Component<{}, { value: string }> top: bounds.y - this._resizeBorderWidth / 2, pointerEvents: this.Interacting ? "none" : "all", zIndex: SelectionManager.SelectedDocuments().length > 1 ? 900 : 0, - }} onPointerDown={this.onBackgroundDown} onContextMenu={(e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); }} > + }} onPointerDown={this.onBackgroundDown} onContextMenu={e => { e.preventDefault(); e.stopPropagation(); }} > </div> <div className="documentDecorations-container" ref={this.setTextBar} style={{ width: (bounds.r - bounds.x + this._resizeBorderWidth) + "px", - height: (bounds.b - bounds.y + this._resizeBorderWidth + this._linkBoxHeight + this._titleHeight + 3) + "px", + height: (bounds.b - bounds.y + this._resizeBorderWidth + this._titleHeight) + "px", left: bounds.x - this._resizeBorderWidth / 2, top: bounds.y - this._resizeBorderWidth / 2 - this._titleHeight, - opacity: this._opacity }}> - {minimizeIcon} - - {this._edtingTitle ? - <input ref={this._keyinput} className="title" type="text" name="dynbox" value={this._accumulatedTitle} onBlur={e => this.titleBlur(true)} onChange={this.titleChanged} onKeyPress={this.titleEntered} /> : - <div className="title" onPointerDown={this.onTitleDown} ><span>{`${this.selectionTitle}`}</span></div>} - <div className="documentDecorations-closeButton" title="Close Document" onPointerDown={this.onCloseDown}> - <FontAwesomeIcon className="documentdecorations-times" icon={faTimes} size="lg" /> + {maximizeIcon} + {titleArea} + <div className="documentDecorations-closeButton" title="Open Document in Tab" onPointerDown={this.onMaximizeDown}> + {SelectionManager.SelectedDocuments().length === 1 ? DocumentDecorations.DocumentIcon(StrCast(seldoc.props.Document.layout, "...")) : "..."} </div> - <div id="documentDecorations-topLeftResizer" className="documentDecorations-resizer" onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> - <div id="documentDecorations-topResizer" className="documentDecorations-resizer" onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> - <div id="documentDecorations-topRightResizer" className="documentDecorations-resizer" onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> - <div id="documentDecorations-leftResizer" className="documentDecorations-resizer" onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> + <div id="documentDecorations-topLeftResizer" className="documentDecorations-resizer" + onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> + <div id="documentDecorations-topResizer" className="documentDecorations-resizer" + onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> + <div id="documentDecorations-topRightResizer" className="documentDecorations-resizer" + onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> + <div id="documentDecorations-leftResizer" className="documentDecorations-resizer" + onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> <div id="documentDecorations-centerCont"></div> - <div id="documentDecorations-rightResizer" className="documentDecorations-resizer" onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> - <div id="documentDecorations-bottomLeftResizer" className="documentDecorations-resizer" onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> - <div id="documentDecorations-bottomResizer" className="documentDecorations-resizer" onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> - <div id="documentDecorations-bottomRightResizer" className="documentDecorations-resizer" onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> - <div id="documentDecorations-borderRadius" className="documentDecorations-radius" onPointerDown={this.onRadiusDown} onContextMenu={(e) => e.preventDefault()}><span className="borderRadiusTooltip" title="Drag Corner Radius"></span></div> - <div className="link-button-container"> - <DocumentButtonBar views={SelectionManager.SelectedDocuments()} /> - </div> + <div id="documentDecorations-rightResizer" className="documentDecorations-resizer" + onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> + <div id="documentDecorations-bottomLeftResizer" className="documentDecorations-resizer" + onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> + <div id="documentDecorations-bottomResizer" className="documentDecorations-resizer" + onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> + <div id="documentDecorations-bottomRightResizer" className="documentDecorations-resizer" + onPointerDown={this.onPointerDown} onContextMenu={(e) => e.preventDefault()}></div> + {seldoc.props.renderDepth <= 1 || !seldoc.props.ContainingCollectionView ? (null) : <div id="documentDecorations-levelSelector" className="documentDecorations-selector" title="tap to select containing document" + onPointerDown={this.onSelectorUp} onContextMenu={(e) => e.preventDefault()}> + <FontAwesomeIcon className="documentdecorations-times" icon={faArrowAltCircleUp} size="lg" /> + </div>} + <div id="documentDecorations-borderRadius" className="documentDecorations-radius" + onPointerDown={this.onRadiusDown} onContextMenu={(e) => e.preventDefault()}></div> + </div > - </div> + <div className="link-button-container" style={{ left: bounds.x - this._resizeBorderWidth / 2, top: bounds.b + this._resizeBorderWidth / 2 }}> + <DocumentButtonBar views={SelectionManager.SelectedDocuments()} /> + </div> + </div > ); } }
\ No newline at end of file diff --git a/src/client/views/EditableView.tsx b/src/client/views/EditableView.tsx index 84c6b0dfd..c51173ad3 100644 --- a/src/client/views/EditableView.tsx +++ b/src/client/views/EditableView.tsx @@ -4,8 +4,6 @@ import { observer } from 'mobx-react'; import * as Autosuggest from 'react-autosuggest'; import { ObjectField } from '../../new_fields/ObjectField'; import { SchemaHeaderField } from '../../new_fields/SchemaHeaderField'; -import { ContextMenu } from './ContextMenu'; -import { ContextMenuProps } from './ContextMenuItem'; import "./EditableView.scss"; export interface EditableProps { @@ -48,7 +46,6 @@ export interface EditableProps { menuCallback?: (x: number, y: number) => void; showMenuOnLoad?: boolean; HeadingObject?: SchemaHeaderField | undefined; - HeadingsHack?: number; toggle?: () => void; color?: string | undefined; } @@ -60,12 +57,13 @@ export interface EditableProps { */ @observer export class EditableView extends React.Component<EditableProps> { + public static loadId = ""; @observable _editing: boolean = false; - @observable _headingsHack: number = 1; constructor(props: EditableProps) { super(props); this._editing = this.props.editing ? true : false; + EditableView.loadId = ""; } @action @@ -75,6 +73,7 @@ export class EditableView extends React.Component<EditableProps> { // to false. this will no longer do so -syip if (nextProps.editing && nextProps.editing !== this._editing) { this._editing = nextProps.editing; + EditableView.loadId = ""; } } @@ -84,12 +83,12 @@ export class EditableView extends React.Component<EditableProps> { onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { if (e.key === "Tab") { e.stopPropagation(); - this.finalizeEdit(e.currentTarget.value, e.shiftKey); + this.finalizeEdit(e.currentTarget.value, e.shiftKey, false); this.props.OnTab && this.props.OnTab(e.shiftKey); } else if (e.key === "Enter") { e.stopPropagation(); if (!e.ctrlKey) { - this.finalizeEdit(e.currentTarget.value, e.shiftKey); + this.finalizeEdit(e.currentTarget.value, e.shiftKey, false); } else if (this.props.OnFillDown) { this.props.OnFillDown(e.currentTarget.value); this._editing = false; @@ -119,10 +118,17 @@ export class EditableView extends React.Component<EditableProps> { } @action - private finalizeEdit(value: string, shiftDown: boolean) { - this._editing = false; + private finalizeEdit(value: string, shiftDown: boolean, lostFocus: boolean) { if (this.props.SetValue(value, shiftDown)) { + this._editing = false; + this.props.isEditingCallback?.(false); + } else { + this._editing = false; this.props.isEditingCallback?.(false); + !lostFocus && setTimeout(action(() => { + this._editing = true; + this.props.isEditingCallback?.(true); + }), 0); } } @@ -147,7 +153,7 @@ export class EditableView extends React.Component<EditableProps> { className: "editableView-input", onKeyDown: this.onKeyDown, autoFocus: true, - onBlur: e => this.finalizeEdit(e.currentTarget.value, false), + onBlur: e => this.finalizeEdit(e.currentTarget.value, false, true), onPointerDown: this.stopPropagation, onClick: this.stopPropagation, onPointerUp: this.stopPropagation, @@ -159,7 +165,7 @@ export class EditableView extends React.Component<EditableProps> { defaultValue={this.props.GetValue()} onKeyDown={this.onKeyDown} autoFocus={true} - onBlur={e => this.finalizeEdit(e.currentTarget.value, false)} + onBlur={e => this.finalizeEdit(e.currentTarget.value, false, true)} onPointerDown={this.stopPropagation} onClick={this.stopPropagation} onPointerUp={this.stopPropagation} style={{ display: this.props.display, fontSize: this.props.fontSize }} />; diff --git a/src/client/views/GestureOverlay.scss b/src/client/views/GestureOverlay.scss index d980b0a91..107077792 100644 --- a/src/client/views/GestureOverlay.scss +++ b/src/client/views/GestureOverlay.scss @@ -5,6 +5,21 @@ top: 0; left: 0; touch-action: none; + + .pointerBubbles { + width: 100%; + height: 100%; + position: absolute; + pointer-events: none; + + .bubble { + position: absolute; + width: 15px; + height: 15px; + border-radius: 100%; + border: .5px solid grey; + } + } } .clipboardDoc-cont { @@ -13,6 +28,35 @@ height: 300px; } +.inkToTextDoc-cont { + position: absolute; + width: 300px; + overflow: hidden; + pointer-events: none; + + .inkToTextDoc-scroller { + overflow: visible; + position: absolute; + width: 100%; + + .menuItem-cont { + width: 100%; + height: 25px; + padding: 2.5px; + border-bottom: .5px solid black; + } + } + + .shadow { + width: 100%; + height: calc(100% - 25px); + position: absolute; + top: 25px; + background-color: black; + opacity: 0.2; + } +} + .filter-cont { position: absolute; background-color: transparent; diff --git a/src/client/views/GestureOverlay.tsx b/src/client/views/GestureOverlay.tsx index 580c53a37..4f8f9ed69 100644 --- a/src/client/views/GestureOverlay.tsx +++ b/src/client/views/GestureOverlay.tsx @@ -2,49 +2,72 @@ import React = require("react"); import { Touchable } from "./Touchable"; import { observer } from "mobx-react"; import "./GestureOverlay.scss"; -import { computed, observable, action, runInAction, IReactionDisposer, reaction } from "mobx"; +import { computed, observable, action, runInAction, IReactionDisposer, reaction, flow, trace } from "mobx"; import { GestureUtils } from "../../pen-gestures/GestureUtils"; import { InteractionUtils } from "../util/InteractionUtils"; import { InkingControl } from "./InkingControl"; -import { InkTool } from "../../new_fields/InkField"; +import { InkTool, InkData } from "../../new_fields/InkField"; import { Doc } from "../../new_fields/Doc"; import { LinkManager } from "../util/LinkManager"; -import { DocUtils } from "../documents/Documents"; +import { DocUtils, Docs } from "../documents/Documents"; import { undoBatch } from "../util/UndoManager"; import { Scripting } from "../util/Scripting"; import { FieldValue, Cast, NumCast, BoolCast } from "../../new_fields/Types"; import { CurrentUserUtils } from "../../server/authentication/models/current_user_utils"; -import Palette from "./Palette"; -import { Utils, emptyPath, emptyFunction, returnFalse, returnOne, returnEmptyString, returnTrue, numberRange } from "../../Utils"; +import HorizontalPalette from "./Palette"; +import { Utils, emptyPath, emptyFunction, returnFalse, returnOne, returnEmptyString, returnTrue, numberRange, returnZero } from "../../Utils"; import { DocumentView } from "./nodes/DocumentView"; import { Transform } from "../util/Transform"; import { DocumentContentsView } from "./nodes/DocumentContentsView"; +import { CognitiveServices } from "../cognitive_services/CognitiveServices"; +import { DocServer } from "../DocServer"; +import htmlToImage from "html-to-image"; +import { ScriptField } from "../../new_fields/ScriptField"; +import { listSpec } from "../../new_fields/Schema"; +import { List } from "../../new_fields/List"; +import { CollectionViewType } from "./collections/CollectionView"; +import TouchScrollableMenu, { TouchScrollableMenuItem } from "./TouchScrollableMenu"; +import MobileInterface from "../../mobile/MobileInterface"; +import { MobileInkOverlayContent } from "../../server/Message"; +import MobileInkOverlay from "../../mobile/MobileInkOverlay"; +import { RadialMenu } from "./nodes/RadialMenu"; +import { SelectionManager } from "../util/SelectionManager"; + @observer export default class GestureOverlay extends Touchable { static Instance: GestureOverlay; - @observable public Color: string = "rgb(244, 67, 54)"; - @observable public Width: number = 5; + @observable public Color: string = "rgb(0, 0, 0)"; + @observable public Width: number = 2; @observable public SavedColor?: string; @observable public SavedWidth?: number; @observable public Tool: ToolglassTools = ToolglassTools.None; @observable private _thumbX?: number; @observable private _thumbY?: number; + @observable private _selectedIndex: number = -1; + @observable private _menuX: number = -300; + @observable private _menuY: number = -300; @observable private _pointerY?: number; @observable private _points: { X: number, Y: number }[] = []; + @observable private _strokes: InkData[] = []; @observable private _palette?: JSX.Element; @observable private _clipboardDoc?: JSX.Element; + @observable private _possibilities: JSX.Element[] = []; - @computed private get height(): number { return Math.max(this._pointerY && this._thumbY ? this._thumbY - this._pointerY : 300, 300); } + @computed private get height(): number { return 2 * Math.max(this._pointerY && this._thumbY ? this._thumbY - this._pointerY : 100, 100); } @computed private get showBounds() { return this.Tool !== ToolglassTools.None; } + @observable private showMobileInkOverlay: boolean = false; + private _d1: Doc | undefined; + private _inkToTextDoc: Doc | undefined; private _thumbDoc: Doc | undefined; private thumbIdentifier?: number; private pointerIdentifier?: number; private _hands: Map<number, React.Touch[]> = new Map<number, React.Touch[]>(); + private _holdTimer: NodeJS.Timeout | undefined; protected multiTouchDisposer?: InteractionUtils.MultiTouchEventDisposer; @@ -54,6 +77,14 @@ export default class GestureOverlay extends Touchable { GestureOverlay.Instance = this; } + componentDidMount = () => { + this._thumbDoc = FieldValue(Cast(CurrentUserUtils.setupThumbDoc(CurrentUserUtils.UserDocument), Doc)); + this._inkToTextDoc = FieldValue(Cast(this._thumbDoc?.inkToTextDoc, Doc)); + } + + /** + * Ignores all touch events that belong to a hand being held down. + */ getNewTouches(e: React.TouchEvent | TouchEvent) { const ntt: (React.Touch | Touch)[] = Array.from(e.targetTouches); const nct: (React.Touch | Touch)[] = Array.from(e.changedTouches); @@ -84,6 +115,17 @@ export default class GestureOverlay extends Touchable { } onReactTouchStart = (te: React.TouchEvent) => { + document.removeEventListener("touchmove", this.onReactHoldTouchMove); + document.removeEventListener("touchend", this.onReactHoldTouchEnd); + if (RadialMenu.Instance._display === true) { + te.preventDefault(); + te.stopPropagation(); + RadialMenu.Instance.closeMenu(); + return; + } + + // this chunk adds new touch targets to a map of pointer events; this helps us keep track of individual fingers + // so that we can know, for example, if two fingers are pinching out or in. const actualPts: React.Touch[] = []; for (let i = 0; i < te.touches.length; i++) { const pt: any = te.touches.item(i); @@ -91,9 +133,6 @@ export default class GestureOverlay extends Touchable { // pen is also a touch, but with a radius of 0.5 (at least with the surface pens) // and this seems to be the only way of differentiating pen and touch on touch events if (pt.radiusX > 1 && pt.radiusY > 1) { - // if (typeof pt.identifier !== "string") { - // pt.identifier = Utils.GenerateGuid(); - // } this.prevPoints.set(pt.identifier, pt); } } @@ -107,8 +146,7 @@ export default class GestureOverlay extends Touchable { ptsToDelete.forEach(pt => this.prevPoints.delete(pt)); const nts = this.getNewTouches(te); - console.log(nts.nt.length); - + // if there are fewer than five touch events, handle as a touch event if (nts.nt.length < 5) { const target = document.elementFromPoint(te.changedTouches.item(0).clientX, te.changedTouches.item(0).clientY); target?.dispatchEvent( @@ -125,11 +163,47 @@ export default class GestureOverlay extends Touchable { } ) ); + if (nts.nt.length === 1) { + // -- radial menu code -- + this._holdTimer = setTimeout(() => { + console.log("hold"); + const target = document.elementFromPoint(te.changedTouches.item(0).clientX, te.changedTouches.item(0).clientY); + const pt: any = te.touches[te.touches.length - 1]; + if (nts.nt.length === 1 && pt.radiusX > 1 && pt.radiusY > 1) { + target?.dispatchEvent( + new CustomEvent<InteractionUtils.MultiTouchEvent<React.TouchEvent>>("dashOnTouchHoldStart", + { + bubbles: true, + detail: { + fingers: this.prevPoints.size, + targetTouches: nts.ntt, + touches: nts.nt, + changedTouches: nts.nct, + touchEvent: te + } + } + ) + ); + this._holdTimer = undefined; + document.removeEventListener("touchmove", this.onReactTouchMove); + document.removeEventListener("touchend", this.onReactTouchEnd); + document.removeEventListener("touchmove", this.onReactHoldTouchMove); + document.removeEventListener("touchend", this.onReactHoldTouchEnd); + document.addEventListener("touchmove", this.onReactHoldTouchMove); + document.addEventListener("touchend", this.onReactHoldTouchEnd); + } + + }, (500)); + } + else { + this._holdTimer && clearTimeout(this._holdTimer); + } document.removeEventListener("touchmove", this.onReactTouchMove); document.removeEventListener("touchend", this.onReactTouchEnd); document.addEventListener("touchmove", this.onReactTouchMove); document.addEventListener("touchend", this.onReactTouchEnd); } + // otherwise, handle as a hand event else { this.handleHandDown(te); document.removeEventListener("touchmove", this.onReactTouchMove); @@ -139,6 +213,9 @@ export default class GestureOverlay extends Touchable { onReactTouchMove = (e: TouchEvent) => { const nts: any = this.getNewTouches(e); + this._holdTimer && clearTimeout(this._holdTimer); + this._holdTimer = undefined; + document.dispatchEvent( new CustomEvent<InteractionUtils.MultiTouchEvent<TouchEvent>>("dashOnTouchMove", { @@ -156,6 +233,9 @@ export default class GestureOverlay extends Touchable { onReactTouchEnd = (e: TouchEvent) => { const nts: any = this.getNewTouches(e); + this._holdTimer && clearTimeout(this._holdTimer); + this._holdTimer = undefined; + document.dispatchEvent( new CustomEvent<InteractionUtils.MultiTouchEvent<TouchEvent>>("dashOnTouchEnd", { @@ -169,6 +249,8 @@ export default class GestureOverlay extends Touchable { } }) ); + + // cleanup any lingering pointers for (let i = 0; i < e.changedTouches.length; i++) { const pt = e.changedTouches.item(i); if (pt) { @@ -186,6 +268,11 @@ export default class GestureOverlay extends Touchable { } handleHandDown = async (e: React.TouchEvent) => { + this._holdTimer && clearTimeout(this._holdTimer); + + // this chunk of code helps us keep track of which touch events are associated with a hand event + // so that if a hand is held down, but a second hand is interacting with dash, the second hand's events + // won't interfere with the first hand's events. const fingers = new Array<React.Touch>(); for (let i = 0; i < e.touches.length; i++) { const pt: any = e.touches.item(i); @@ -200,6 +287,8 @@ export default class GestureOverlay extends Touchable { } } } + + // this chunk of code determines whether this is a left hand or a right hand, as well as which pointer is the thumb and pointer const thumb = fingers.reduce((a, v) => a.clientY > v.clientY ? a : v, fingers[0]); const rightMost = Math.max(...fingers.map(f => f.clientX)); const leftMost = Math.min(...fingers.map(f => f.clientX)); @@ -216,26 +305,35 @@ export default class GestureOverlay extends Touchable { console.log("not hand"); } this.pointerIdentifier = pointer?.identifier; - runInAction(() => this._pointerY = pointer?.clientY); - if (thumb.identifier === this.thumbIdentifier) { - this._thumbX = thumb.clientX; - this._thumbY = thumb.clientY; - this._hands.set(thumb.identifier, fingers); - return; - } + + runInAction(() => { + this._pointerY = pointer?.clientY; + if (thumb.identifier === this.thumbIdentifier) { + this._thumbX = thumb.clientX; + this._thumbY = thumb.clientY; + this._hands.set(thumb.identifier, fingers); + return; + } + }); + this.thumbIdentifier = thumb?.identifier; this._hands.set(thumb.identifier, fingers); const others = fingers.filter(f => f !== thumb); const minX = Math.min(...others.map(f => f.clientX)); const minY = Math.min(...others.map(f => f.clientY)); + // load up the palette collection around the thumb const thumbDoc = await Cast(CurrentUserUtils.setupThumbDoc(CurrentUserUtils.UserDocument), Doc); if (thumbDoc) { runInAction(() => { + RadialMenu.Instance._display = false; + this._inkToTextDoc = FieldValue(Cast(thumbDoc.inkToTextDoc, Doc)); this._thumbDoc = thumbDoc; this._thumbX = thumb.clientX; this._thumbY = thumb.clientY; - this._palette = <Palette x={minX} y={minY} thumb={[thumb.clientX, thumb.clientY]} thumbDoc={thumbDoc} />; + this._menuX = thumb.clientX + 50; + this._menuY = thumb.clientY; + this._palette = <HorizontalPalette x={minX} y={minY} thumb={[thumb.clientX, thumb.clientY]} thumbDoc={thumbDoc} />; }); } @@ -248,6 +346,7 @@ export default class GestureOverlay extends Touchable { @action handleHandMove = (e: TouchEvent) => { + // update pointer trackers const fingers = new Array<React.Touch>(); for (let i = 0; i < e.touches.length; i++) { const pt: any = e.touches.item(i); @@ -266,19 +365,34 @@ export default class GestureOverlay extends Touchable { } } } + // update hand trackers const thumb = fingers.reduce((a, v) => a.clientY > v.clientY ? a : v, fingers[0]); if (thumb?.identifier && thumb?.identifier === this.thumbIdentifier) { this._hands.set(thumb.identifier, fingers); } + // loop through every changed pointer for (let i = 0; i < e.changedTouches.length; i++) { const pt = e.changedTouches.item(i); - if (pt && pt.identifier === this.thumbIdentifier && this._thumbX && this._thumbDoc) { - if (Math.abs(pt.clientX - this._thumbX) > 20) { - this._thumbDoc.selectedIndex = Math.max(0, NumCast(this._thumbDoc.selectedIndex) - Math.sign(pt.clientX - this._thumbX)); - this._thumbX = pt.clientX; + // if the thumb was moved + if (pt && pt.identifier === this.thumbIdentifier && this._thumbY) { + if (this._thumbX && this._thumbY) { + // moving a thumb horiz. changes the palette collection selection, moving vert. changes the selection of any menus on the current palette item + const yOverX = Math.abs(pt.clientX - this._thumbX) < Math.abs(pt.clientY - this._thumbY); + if ((yOverX && this._inkToTextDoc) || this._selectedIndex > -1) { + if (Math.abs(pt.clientY - this._thumbY) > (10 * window.devicePixelRatio)) { + this._selectedIndex = Math.min(Math.max(-1, (-Math.ceil((pt.clientY - this._thumbY) / (10 * window.devicePixelRatio)) - 1)), this._possibilities.length - 1); + } + } + else if (this._thumbDoc) { + if (Math.abs(pt.clientX - this._thumbX) > (15 * window.devicePixelRatio)) { + this._thumbDoc.selectedIndex = Math.max(-1, NumCast(this._thumbDoc.selectedIndex) - Math.sign(pt.clientX - this._thumbX)); + this._thumbX = pt.clientX; + } + } } } + // if the pointer finger was moved if (pt && pt.identifier === this.pointerIdentifier) { this._pointerY = pt.clientY; } @@ -287,16 +401,104 @@ export default class GestureOverlay extends Touchable { @action handleHandUp = (e: TouchEvent) => { + // sometimes, users may lift up their thumb or index finger if they can't stretch far enough to scroll an entire menu, + // so we don't want to just remove the palette when that happens if (e.touches.length < 3) { - // this.onTouchEnd(e); if (this.thumbIdentifier) this._hands.delete(this.thumbIdentifier); this._palette = undefined; this.thumbIdentifier = undefined; this._thumbDoc = undefined; + + // this chunk of code is for handling the ink to text toolglass + let scriptWorked = false; + if (NumCast(this._inkToTextDoc?.selectedIndex) > -1) { + // if there is a text option selected, activate it + const selectedButton = this._possibilities[this._selectedIndex]; + if (selectedButton) { + selectedButton.props.onClick(); + scriptWorked = true; + } + } + // if there isn't a text option selected, dry the ink strokes into ink documents + if (!scriptWorked) { + this._strokes.forEach(s => { + this.dispatchGesture(GestureUtils.Gestures.Stroke, s); + }); + } + + this._strokes = []; + this._points = []; + this._possibilities = []; document.removeEventListener("touchend", this.handleHandUp); } } + /** + * Code for radial menu + */ + onReactHoldTouchMove = (e: TouchEvent) => { + document.removeEventListener("touchmove", this.onReactTouchMove); + document.removeEventListener("touchend", this.onReactTouchEnd); + document.removeEventListener("touchmove", this.onReactHoldTouchMove); + document.removeEventListener("touchend", this.onReactHoldTouchEnd); + document.addEventListener("touchmove", this.onReactHoldTouchMove); + document.addEventListener("touchend", this.onReactHoldTouchEnd); + const nts: any = this.getNewTouches(e); + if (this.prevPoints.size === 1 && this._holdTimer) { + clearTimeout(this._holdTimer); + } + document.dispatchEvent( + new CustomEvent<InteractionUtils.MultiTouchEvent<TouchEvent>>("dashOnTouchHoldMove", + { + bubbles: true, + detail: { + fingers: this.prevPoints.size, + targetTouches: nts.ntt, + touches: nts.nt, + changedTouches: nts.nct, + touchEvent: e + } + }) + ); + } + + /** + * Code for radial menu + */ + onReactHoldTouchEnd = (e: TouchEvent) => { + const nts: any = this.getNewTouches(e); + if (this.prevPoints.size === 1 && this._holdTimer) { + clearTimeout(this._holdTimer); + this._holdTimer = undefined; + } + document.dispatchEvent( + new CustomEvent<InteractionUtils.MultiTouchEvent<TouchEvent>>("dashOnTouchHoldEnd", + { + bubbles: true, + detail: { + fingers: this.prevPoints.size, + targetTouches: nts.ntt, + touches: nts.nt, + changedTouches: nts.nct, + touchEvent: e + } + }) + ); + for (let i = 0; i < e.changedTouches.length; i++) { + const pt = e.changedTouches.item(i); + if (pt) { + if (this.prevPoints.has(pt.identifier)) { + this.prevPoints.delete(pt.identifier); + } + } + } + + document.removeEventListener("touchmove", this.onReactHoldTouchMove); + document.removeEventListener("touchend", this.onReactHoldTouchEnd); + + e.stopPropagation(); + } + @action onPointerDown = (e: React.PointerEvent) => { if (InteractionUtils.IsType(e, InteractionUtils.PENTYPE) || (InkingControl.Instance.selectedTool === InkTool.Highlighter || InkingControl.Instance.selectedTool === InkTool.Pen)) { @@ -317,26 +519,50 @@ export default class GestureOverlay extends Touchable { this._points.push({ X: e.clientX, Y: e.clientY }); e.stopPropagation(); e.preventDefault(); + + + if (this._points.length > 1) { + const B = this.svgBounds; + const initialPoint = this._points[0.]; + const xInGlass = initialPoint.X > (this._thumbX ?? Number.MAX_SAFE_INTEGER) && initialPoint.X < (this._thumbX ?? Number.MAX_SAFE_INTEGER) + this.height; + const yInGlass = initialPoint.Y > (this._thumbY ?? Number.MAX_SAFE_INTEGER) - this.height && initialPoint.Y < (this._thumbY ?? Number.MAX_SAFE_INTEGER); + if (this.Tool !== ToolglassTools.None && xInGlass && yInGlass) { + switch (this.Tool) { + case ToolglassTools.RadialMenu: + document.removeEventListener("pointermove", this.onPointerMove); + document.removeEventListener("pointerup", this.onPointerUp); + //this.handle1PointerHoldStart(e); + } + } + } } } handleLineGesture = (): boolean => { let actionPerformed = false; const B = this.svgBounds; + + // get the two targets at the ends of the line const ep1 = this._points[0]; const ep2 = this._points[this._points.length - 1]; - const target1 = document.elementFromPoint(ep1.X, ep1.Y); const target2 = document.elementFromPoint(ep2.X, ep2.Y); + + // callback function to be called by each target const callback = (doc: Doc) => { if (!this._d1) { this._d1 = doc; } + // we don't want to create a link of both endpoints are the same document (doing so makes drawing an l very hard) else if (this._d1 !== doc && !LinkManager.Instance.doesLinkExist(this._d1, doc)) { - DocUtils.MakeLink({ doc: this._d1 }, { doc: doc }); - actionPerformed = true; + // we don't want to create a link between ink strokes (doing so makes drawing a t very hard) + if (this._d1.type !== "ink" && doc.type !== "ink") { + DocUtils.MakeLink({ doc: this._d1 }, { doc: doc }, "gestural link"); + actionPerformed = true; + } } }; + const ge = new CustomEvent<GestureUtils.GestureEvent>("dashOnGesture", { bubbles: true, @@ -358,31 +584,70 @@ export default class GestureOverlay extends Touchable { const B = this.svgBounds; const points = this._points.map(p => ({ X: p.X - B.left, Y: p.Y - B.top })); - const xInGlass = points[0].X > (this._thumbX ?? Number.MAX_SAFE_INTEGER) && points[0].X < (this._thumbX ?? Number.MAX_SAFE_INTEGER) + this.height; - const yInGlass = points[0].Y > (this._thumbY ?? Number.MAX_SAFE_INTEGER) - this.height && points[0].Y < (this._thumbY ?? Number.MAX_SAFE_INTEGER); + if (MobileInterface.Instance && MobileInterface.Instance.drawingInk) { + const { selectedColor, selectedWidth } = InkingControl.Instance; + DocServer.Mobile.dispatchGesturePoints({ + points: this._points, + bounds: B, + color: selectedColor, + width: selectedWidth + }); + } + const initialPoint = this._points[0.]; + const xInGlass = initialPoint.X > (this._thumbX ?? Number.MAX_SAFE_INTEGER) && initialPoint.X < (this._thumbX ?? Number.MAX_SAFE_INTEGER) + (this.height); + const yInGlass = initialPoint.Y > (this._thumbY ?? Number.MAX_SAFE_INTEGER) - (this.height) && initialPoint.Y < (this._thumbY ?? Number.MAX_SAFE_INTEGER); + + // if a toolglass is selected and the stroke starts within the toolglass boundaries if (this.Tool !== ToolglassTools.None && xInGlass && yInGlass) { switch (this.Tool) { case ToolglassTools.InkToText: + document.removeEventListener("pointermove", this.onPointerMove); + document.removeEventListener("pointerup", this.onPointerUp); + this._strokes.push(new Array(...this._points)); + this._points = []; + CognitiveServices.Inking.Appliers.InterpretStrokes(this._strokes).then((results) => { + const wordResults = results.filter((r: any) => r.category === "line"); + const possibilities: string[] = []; + for (const wR of wordResults) { + if (wR?.recognizedText) { + possibilities.push(wR?.recognizedText); + } + possibilities.push(...wR?.alternates?.map((a: any) => a.recognizedString)); + } + const r = Math.max(this.svgBounds.right, ...this._strokes.map(s => this.getBounds(s).right)); + const l = Math.min(this.svgBounds.left, ...this._strokes.map(s => this.getBounds(s).left)); + const t = Math.min(this.svgBounds.top, ...this._strokes.map(s => this.getBounds(s).top)); + + // if we receive any word results from cognitive services, display them + runInAction(() => { + this._possibilities = possibilities.map(p => + <TouchScrollableMenuItem text={p} onClick={() => GestureOverlay.Instance.dispatchGesture(GestureUtils.Gestures.Text, [{ X: l, Y: t }], p)} />); + }); + }); + break; + case ToolglassTools.IgnoreGesture: + this.dispatchGesture(GestureUtils.Gestures.Stroke); + this._points = []; break; } } + // if we're not drawing in a toolglass try to recognize as gesture else { - const result = GestureUtils.GestureRecognizer.Recognize(new Array(points)); + const result = points.length > 2 && GestureUtils.GestureRecognizer.Recognize(new Array(points)); let actionPerformed = false; if (result && result.Score > 0.7) { switch (result.Name) { case GestureUtils.Gestures.Box: - const target = document.elementFromPoint(this._points[0].X, this._points[0].Y); - target?.dispatchEvent(new CustomEvent<GestureUtils.GestureEvent>("dashOnGesture", - { - bubbles: true, - detail: { - points: this._points, - gesture: GestureUtils.Gestures.Box, - bounds: B - } - })); + this.dispatchGesture(GestureUtils.Gestures.Box); + actionPerformed = true; + break; + case GestureUtils.Gestures.StartBracket: + this.dispatchGesture(GestureUtils.Gestures.StartBracket); + actionPerformed = true; + break; + case GestureUtils.Gestures.EndBracket: + this.dispatchGesture("endbracket"); actionPerformed = true; break; case GestureUtils.Gestures.Line: @@ -397,20 +662,9 @@ export default class GestureOverlay extends Touchable { } } + // if no gesture (or if the gesture was unsuccessful), "dry" the stroke into an ink document if (!actionPerformed) { - const target = document.elementFromPoint(this._points[0].X, this._points[0].Y); - target?.dispatchEvent( - new CustomEvent<GestureUtils.GestureEvent>("dashOnGesture", - { - bubbles: true, - detail: { - points: this._points, - gesture: GestureUtils.Gestures.Stroke, - bounds: B - } - } - ) - ); + this.dispatchGesture(GestureUtils.Gestures.Stroke); this._points = []; } } @@ -419,9 +673,26 @@ export default class GestureOverlay extends Touchable { document.removeEventListener("pointerup", this.onPointerUp); } - @computed get svgBounds() { - const xs = this._points.map(p => p.X); - const ys = this._points.map(p => p.Y); + dispatchGesture = (gesture: "box" | "line" | "startbracket" | "endbracket" | "stroke" | "scribble" | "text", stroke?: InkData, data?: any) => { + const target = document.elementFromPoint((stroke ?? this._points)[0].X, (stroke ?? this._points)[0].Y); + target?.dispatchEvent( + new CustomEvent<GestureUtils.GestureEvent>("dashOnGesture", + { + bubbles: true, + detail: { + points: stroke ?? this._points, + gesture: gesture as any, + bounds: this.getBounds(stroke ?? this._points), + text: data + } + } + ) + ); + } + + getBounds = (stroke: InkData) => { + const xs = stroke.map(p => p.X); + const ys = stroke.map(p => p.Y); const right = Math.max(...xs); const left = Math.min(...xs); const bottom = Math.max(...ys); @@ -429,28 +700,28 @@ export default class GestureOverlay extends Touchable { return { right: right, left: left, bottom: bottom, top: top, width: right - left, height: bottom - top }; } - @computed get currentStroke() { - if (this._points.length <= 1) { - return (null); - } - - const B = this.svgBounds; - - return ( - <svg width={B.width} height={B.height} style={{ transform: `translate(${B.left}px, ${B.top}px)`, pointerEvents: "none", position: "absolute", zIndex: 30000 }}> - {InteractionUtils.CreatePolyline(this._points, B.left, B.top, this.Color, this.Width)} - </svg> - ); + @computed get svgBounds() { + return this.getBounds(this._points); } @computed get elements() { + const B = this.svgBounds; return [ this.props.children, this._palette, - this.currentStroke + [this._strokes.map(l => { + const b = this.getBounds(l); + return <svg key={b.left} width={b.width} height={b.height} style={{ transform: `translate(${b.left}px, ${b.top}px)`, pointerEvents: "none", position: "absolute", zIndex: 30000 }}> + {InteractionUtils.CreatePolyline(l, b.left, b.top, GestureOverlay.Instance.Color, GestureOverlay.Instance.Width)} + </svg>; + }), + this._points.length <= 1 ? (null) : <svg width={B.width} height={B.height} style={{ transform: `translate(${B.left}px, ${B.top}px)`, pointerEvents: "none", position: "absolute", zIndex: 30000 }}> + {InteractionUtils.CreatePolyline(this._points, B.left, B.top, GestureOverlay.Instance.Color, GestureOverlay.Instance.Width)} + </svg>] ]; } - + screenToLocalTransform = () => new Transform(-(this._thumbX ?? 0), -(this._thumbY ?? 0) + this.height, 1); + return300 = () => 300; @action public openFloatingDoc = (doc: Doc) => { this._clipboardDoc = @@ -460,13 +731,16 @@ export default class GestureOverlay extends Touchable { LibraryPath={emptyPath} addDocument={undefined} addDocTab={returnFalse} + rootSelected={returnTrue} pinToPres={emptyFunction} onClick={undefined} removeDocument={undefined} - ScreenToLocalTransform={() => new Transform(-(this._thumbX ?? 0), -(this._thumbY ?? 0) + this.height, 1)} + ScreenToLocalTransform={this.screenToLocalTransform} ContentScaling={returnOne} - PanelWidth={() => 300} - PanelHeight={() => 300} + PanelWidth={this.return300} + PanelHeight={this.return300} + NativeHeight={returnZero} + NativeWidth={returnZero} renderDepth={0} backgroundColor={returnEmptyString} focus={emptyFunction} @@ -475,8 +749,6 @@ export default class GestureOverlay extends Touchable { bringToFront={emptyFunction} ContainingCollectionView={undefined} ContainingCollectionDoc={undefined} - zoomToScale={emptyFunction} - getScale={returnOne} />; } @@ -485,21 +757,28 @@ export default class GestureOverlay extends Touchable { this._clipboardDoc = undefined; } + @action + enableMobileInkOverlay = (content: MobileInkOverlayContent) => { + this.showMobileInkOverlay = content.enableOverlay; + } + render() { return ( <div className="gestureOverlay-cont" onPointerDown={this.onPointerDown} onTouchStart={this.onReactTouchStart}> + {this.showMobileInkOverlay ? <MobileInkOverlay /> : <></>} {this.elements} + <div className="clipboardDoc-cont" style={{ - transform: `translate(${this._thumbX}px, ${(this._thumbY ?? 0) - this.height}px)`, height: this.height, width: this.height, pointerEvents: this._clipboardDoc ? "unset" : "none", touchAction: this._clipboardDoc ? "unset" : "none", + transform: `translate(${this._thumbX}px, ${(this._thumbY || 0) - this.height} px)`, }}> {this._clipboardDoc} </div> <div className="filter-cont" style={{ - transform: `translate(${this._thumbX}px, ${(this._thumbY ?? 0) - this.height}px)`, + transform: `translate(${this._thumbX}px, ${(this._thumbY || 0) - this.height}px)`, height: this.height, width: this.height, pointerEvents: "none", @@ -507,12 +786,17 @@ export default class GestureOverlay extends Touchable { display: this.showBounds ? "unset" : "none", }}> </div> - </div >); + <TouchScrollableMenu options={this._possibilities} bounds={this.svgBounds} selectedIndex={this._selectedIndex} x={this._menuX} y={this._menuY} /> + </div>); } } +// export class + export enum ToolglassTools { InkToText = "inktotext", + IgnoreGesture = "ignoregesture", + RadialMenu = "radialmenu", None = "none", } @@ -530,7 +814,10 @@ Scripting.addGlobal(function setPen(width: any, color: any) { }); Scripting.addGlobal(function resetPen() { runInAction(() => { - GestureOverlay.Instance.Color = GestureOverlay.Instance.SavedColor ?? "rgb(244, 67, 54)"; - GestureOverlay.Instance.Width = GestureOverlay.Instance.SavedWidth ?? 5; + GestureOverlay.Instance.Color = GestureOverlay.Instance.SavedColor ?? "rgb(0, 0, 0)"; + GestureOverlay.Instance.Width = GestureOverlay.Instance.SavedWidth ?? 2; }); +}); +Scripting.addGlobal(function createText(text: any, x: any, y: any) { + GestureOverlay.Instance.dispatchGesture("text", [{ X: x, Y: y }], text); });
\ No newline at end of file diff --git a/src/client/views/GlobalKeyHandler.ts b/src/client/views/GlobalKeyHandler.ts index 979687ffb..185222541 100644 --- a/src/client/views/GlobalKeyHandler.ts +++ b/src/client/views/GlobalKeyHandler.ts @@ -7,8 +7,7 @@ import { action, runInAction } from "mobx"; import { Doc } from "../../new_fields/Doc"; import { DictationManager } from "../util/DictationManager"; import SharingManager from "../util/SharingManager"; -import { CurrentUserUtils } from "../../server/authentication/models/current_user_utils"; -import { Cast, PromiseValue } from "../../new_fields/Types"; +import { Cast, PromiseValue, NumCast } from "../../new_fields/Types"; import { ScriptField } from "../../new_fields/ScriptField"; import { InkingControl } from "./InkingControl"; import { InkTool } from "../../new_fields/InkField"; @@ -79,6 +78,7 @@ export default class KeyManager { } SelectionManager.DeselectAll(); DictationManager.Controls.stop(); + // RecommendationsBox.Instance.closeMenu(); SharingManager.Instance.close(); break; case "delete": @@ -88,13 +88,20 @@ export default class KeyManager { return { stopPropagation: false, preventDefault: false }; } } - UndoManager.RunInBatch(() => { - SelectionManager.SelectedDocuments().map(docView => { - const doc = docView.props.Document; - const remove = docView.props.removeDocument; - remove && remove(doc); - }); - }, "delete"); + UndoManager.RunInBatch(() => + SelectionManager.SelectedDocuments().map(dv => dv.props.removeDocument?.(dv.props.Document)), "delete"); + break; + case "arrowleft": + UndoManager.RunInBatch(() => SelectionManager.SelectedDocuments().map(dv => dv.props.nudge?.(-1, 0)), "nudge left"); + break; + case "arrowright": + UndoManager.RunInBatch(() => SelectionManager.SelectedDocuments().map(dv => dv.props.nudge?.(1, 0)), "nudge right"); + break; + case "arrowup": + UndoManager.RunInBatch(() => SelectionManager.SelectedDocuments().map(dv => dv.props.nudge?.(0, -1)), "nudge up"); + break; + case "arrowdown": + UndoManager.RunInBatch(() => SelectionManager.SelectedDocuments().map(dv => dv.props.nudge?.(0, 1)), "nudge down"); break; } @@ -105,14 +112,26 @@ export default class KeyManager { }); private shift = async (keyname: string) => { - let stopPropagation = false; - let preventDefault = false; + const stopPropagation = false; + const preventDefault = false; switch (keyname) { - case "~": - DictationManager.Controls.listen({ useOverlay: true, tryExecute: true }); - stopPropagation = true; - preventDefault = true; + // case "~": + // DictationManager.Controls.listen({ useOverlay: true, tryExecute: true }); + // stopPropagation = true; + // preventDefault = true; + case "arrowleft": + UndoManager.RunInBatch(() => SelectionManager.SelectedDocuments().map(dv => dv.props.nudge?.(-10, 0)), "nudge left"); + break; + case "arrowright": + UndoManager.RunInBatch(() => SelectionManager.SelectedDocuments().map(dv => dv.props.nudge?.(10, 0)), "nudge right"); + break; + case "arrowup": + UndoManager.RunInBatch(() => SelectionManager.SelectedDocuments().map(dv => dv.props.nudge?.(0, -10)), "nudge up"); + break; + case "arrowdown": + UndoManager.RunInBatch(() => SelectionManager.SelectedDocuments().map(dv => dv.props.nudge?.(0, 10)), "nudge down"); + break; } return { @@ -156,7 +175,7 @@ export default class KeyManager { return { stopPropagation: false, preventDefault: false }; } } - MainView.Instance.mainFreeform && CollectionDockingView.AddRightSplit(MainView.Instance.mainFreeform, undefined); + MainView.Instance.mainFreeform && CollectionDockingView.AddRightSplit(MainView.Instance.mainFreeform); break; case "arrowleft": if (document.activeElement) { @@ -174,7 +193,7 @@ export default class KeyManager { } break; case "t": - PromiseValue(Cast(CurrentUserUtils.UserDocument.Create, Doc)).then(pv => pv && (pv.onClick as ScriptField).script.run({ this: pv })); + PromiseValue(Cast(Doc.UserDoc()["tabs-button-tools"], Doc)).then(pv => pv && (pv.onClick as ScriptField).script.run({ this: pv })); if (MainView.Instance.flyoutWidth === 240) { MainView.Instance.flyoutWidth = 0; } else { @@ -182,7 +201,7 @@ export default class KeyManager { } break; case "l": - PromiseValue(Cast(CurrentUserUtils.UserDocument.Library, Doc)).then(pv => pv && (pv.onClick as ScriptField).script.run({ this: pv })); + PromiseValue(Cast(Doc.UserDoc()["tabs-button-library"], Doc)).then(pv => pv && (pv.onClick as ScriptField).script.run({ this: pv })); if (MainView.Instance.flyoutWidth === 250) { MainView.Instance.flyoutWidth = 0; } else { @@ -190,7 +209,7 @@ export default class KeyManager { } break; case "f": - PromiseValue(Cast(CurrentUserUtils.UserDocument.Search, Doc)).then(pv => pv && (pv.onClick as ScriptField).script.run({ this: pv })); + PromiseValue(Cast(Doc.UserDoc()["tabs-button-search"], Doc)).then(pv => pv && (pv.onClick as ScriptField).script.run({ this: pv })); if (MainView.Instance.flyoutWidth === 400) { MainView.Instance.flyoutWidth = 0; } else { diff --git a/src/client/views/InkingControl.tsx b/src/client/views/InkingControl.tsx index 6cee702ee..70ea955e1 100644 --- a/src/client/views/InkingControl.tsx +++ b/src/client/views/InkingControl.tsx @@ -8,12 +8,13 @@ import { Scripting } from "../util/Scripting"; import { SelectionManager } from "../util/SelectionManager"; import { undoBatch } from "../util/UndoManager"; import GestureOverlay from "./GestureOverlay"; +import { FormattedTextBox } from "./nodes/formattedText/FormattedTextBox"; export class InkingControl { @observable static Instance: InkingControl; - @computed private get _selectedTool(): InkTool { return FieldValue(NumCast(CurrentUserUtils.UserDocument.inkTool)) ?? InkTool.None; } - @computed private get _selectedColor(): string { return GestureOverlay.Instance.Color ?? FieldValue(StrCast(CurrentUserUtils.UserDocument.inkColor)) ?? "rgb(244, 67, 54)"; } - @computed private get _selectedWidth(): string { return GestureOverlay.Instance.Width?.toString() ?? FieldValue(StrCast(CurrentUserUtils.UserDocument.inkWidth)) ?? "5"; } + @computed private get _selectedTool(): InkTool { return FieldValue(NumCast(Doc.UserDoc().inkTool)) ?? InkTool.None; } + @computed private get _selectedColor(): string { return GestureOverlay.Instance.Color ?? FieldValue(StrCast(Doc.UserDoc().inkColor)) ?? "rgb(244, 67, 54)"; } + @computed private get _selectedWidth(): string { return GestureOverlay.Instance.Width?.toString() ?? FieldValue(StrCast(Doc.UserDoc().inkWidth)) ?? "5"; } @observable public _open: boolean = false; constructor() { @@ -22,19 +23,18 @@ export class InkingControl { switchTool = action((tool: InkTool): void => { // this._selectedTool = tool; - CurrentUserUtils.UserDocument.inkTool = tool; + Doc.UserDoc().inkTool = tool; }); decimalToHexString(number: number) { if (number < 0) { number = 0xFFFFFFFF + number + 1; } - - return number.toString(16).toUpperCase(); + return (number < 16 ? "0" : "") + number.toString(16).toUpperCase(); } @undoBatch switchColor = action((color: ColorState): void => { - CurrentUserUtils.UserDocument.inkColor = color.hex + (color.rgb.a !== undefined ? this.decimalToHexString(Math.round(color.rgb.a * 255)) : "ff"); + Doc.UserDoc().inkColor = color.hex + (color.rgb.a !== undefined ? this.decimalToHexString(Math.round(color.rgb.a * 255)) : "ff"); if (InkingControl.Instance.selectedTool === InkTool.None) { const selected = SelectionManager.SelectedDocuments(); @@ -42,7 +42,13 @@ export class InkingControl { const targetDoc = view.props.Document.dragFactory instanceof Doc ? view.props.Document.dragFactory : view.props.Document.layout instanceof Doc ? view.props.Document.layout : view.props.Document.isTemplateForField ? view.props.Document : Doc.GetProto(view.props.Document); - targetDoc && (Doc.Layout(view.props.Document).backgroundColor = CurrentUserUtils.UserDocument.inkColor); + if (targetDoc) { + if (StrCast(Doc.Layout(view.props.Document).layout).indexOf("FormattedTextBox") !== -1 && FormattedTextBox.HadSelection) { + Doc.Layout(view.props.Document).color = Doc.UserDoc().inkColor; + } else { + Doc.Layout(view.props.Document)._backgroundColor = Doc.UserDoc().inkColor; // '_backgroundColor' is template specific. 'backgroundColor' would apply to all templates, but has no UI at the moment + } + } }); } else { CurrentUserUtils.ActivePen && (CurrentUserUtils.ActivePen.backgroundColor = this._selectedColor); @@ -51,7 +57,7 @@ export class InkingControl { @action switchWidth = (width: string): void => { // this._selectedWidth = width; - CurrentUserUtils.UserDocument.inkWidth = width; + Doc.UserDoc().inkWidth = width; } @computed @@ -67,7 +73,7 @@ export class InkingControl { @action updateSelectedColor(value: string) { // this._selectedColor = value; - CurrentUserUtils.UserDocument.inkColor = value; + Doc.UserDoc().inkColor = value; } @computed @@ -79,7 +85,6 @@ export class InkingControl { Scripting.addGlobal(function activatePen(pen: any, width: any, color: any) { InkingControl.Instance.switchTool(pen ? InkTool.Pen : InkTool.None); InkingControl.Instance.switchWidth(width); InkingControl.Instance.updateSelectedColor(color); }); Scripting.addGlobal(function activateBrush(pen: any, width: any, color: any) { InkingControl.Instance.switchTool(pen ? InkTool.Highlighter : InkTool.None); InkingControl.Instance.switchWidth(width); InkingControl.Instance.updateSelectedColor(color); }); Scripting.addGlobal(function activateEraser(pen: any) { return InkingControl.Instance.switchTool(pen ? InkTool.Eraser : InkTool.None); }); -Scripting.addGlobal(function activateScrubber(pen: any) { return InkingControl.Instance.switchTool(pen ? InkTool.Scrubber : InkTool.None); }); Scripting.addGlobal(function activateStamp(pen: any) { return InkingControl.Instance.switchTool(pen ? InkTool.Stamp : InkTool.None); }); Scripting.addGlobal(function deactivateInk() { return InkingControl.Instance.switchTool(InkTool.None); }); Scripting.addGlobal(function setInkWidth(width: any) { return InkingControl.Instance.switchWidth(width); }); diff --git a/src/client/views/InkingStroke.scss b/src/client/views/InkingStroke.scss index cdbfdcff3..433433a42 100644 --- a/src/client/views/InkingStroke.scss +++ b/src/client/views/InkingStroke.scss @@ -1,3 +1,7 @@ -.inkingStroke-marker { - mix-blend-mode: multiply +.inkingStroke { + mix-blend-mode: multiply; + stroke-linejoin: round; + stroke-linecap: round; + overflow: visible !important; + transform-origin: top left; }
\ No newline at end of file diff --git a/src/client/views/InkingStroke.tsx b/src/client/views/InkingStroke.tsx index f315ce12a..7a318d5c2 100644 --- a/src/client/views/InkingStroke.tsx +++ b/src/client/views/InkingStroke.tsx @@ -1,10 +1,9 @@ -import { computed } from "mobx"; import { observer } from "mobx-react"; import { documentSchema } from "../../new_fields/documentSchemas"; import { InkData, InkField, InkTool } from "../../new_fields/InkField"; import { makeInterface } from "../../new_fields/Schema"; -import { Cast } from "../../new_fields/Types"; -import { DocExtendableComponent } from "./DocComponent"; +import { Cast, StrCast, NumCast } from "../../new_fields/Types"; +import { ViewBoxBaseComponent } from "./DocComponent"; import { InkingControl } from "./InkingControl"; import "./InkingStroke.scss"; import { FieldView, FieldViewProps } from "./nodes/FieldView"; @@ -22,40 +21,37 @@ type InkDocument = makeInterface<[typeof documentSchema]>; const InkDocument = makeInterface(documentSchema); @observer -export class InkingStroke extends DocExtendableComponent<FieldViewProps, InkDocument>(InkDocument) { +export class InkingStroke extends ViewBoxBaseComponent<FieldViewProps, InkDocument>(InkDocument) { public static LayoutString(fieldStr: string) { return FieldView.LayoutString(InkingStroke, fieldStr); } - @computed get PanelWidth() { return this.props.PanelWidth(); } - @computed get PanelHeight() { return this.props.PanelHeight(); } - private analyzeStrokes = () => { - const data: InkData = Cast(this.Document.data, InkField)?.inkData ?? []; - CognitiveServices.Inking.Appliers.ConcatenateHandwriting(this.Document, ["inkAnalysis", "handwriting"], [data]); + const data: InkData = Cast(this.dataDoc[this.fieldKey], InkField)?.inkData ?? []; + CognitiveServices.Inking.Appliers.ConcatenateHandwriting(this.dataDoc, ["inkAnalysis", "handwriting"], [data]); } render() { TraceMobx(); - const data: InkData = Cast(this.Document.data, InkField)?.inkData ?? []; + const data: InkData = Cast(this.dataDoc[this.fieldKey], InkField)?.inkData ?? []; const xs = data.map(p => p.X); const ys = data.map(p => p.Y); const left = Math.min(...xs); const top = Math.min(...ys); const right = Math.max(...xs); const bottom = Math.max(...ys); - const points = InteractionUtils.CreatePolyline(data, left, top, this.Document.color ?? InkingControl.Instance.selectedColor, this.Document.strokeWidth ?? parseInt(InkingControl.Instance.selectedWidth)); + const points = InteractionUtils.CreatePolyline(data, left, top, + StrCast(this.layoutDoc.color, InkingControl.Instance.selectedColor), + NumCast(this.layoutDoc.strokeWidth, parseInt(InkingControl.Instance.selectedWidth))); const width = right - left; const height = bottom - top; - const scaleX = this.PanelWidth / width; - const scaleY = this.PanelHeight / height; + const scaleX = this.props.PanelWidth() / width; + const scaleY = this.props.PanelHeight() / height; return ( - <svg + <svg className="inkingStroke" width={width} height={height} style={{ - transformOrigin: "top left", transform: `scale(${scaleX}, ${scaleY})`, - mixBlendMode: this.Document.tool === InkTool.Highlighter ? "multiply" : "unset", - pointerEvents: "all" + mixBlendMode: this.layoutDoc.tool === InkTool.Highlighter ? "multiply" : "unset", }} onContextMenu={() => { ContextMenu.Instance.addItem({ diff --git a/src/client/views/KeyphraseQueryView.scss b/src/client/views/KeyphraseQueryView.scss new file mode 100644 index 000000000..ac715e5e7 --- /dev/null +++ b/src/client/views/KeyphraseQueryView.scss @@ -0,0 +1,8 @@ +.fading { + animation: fanOut 1s +} + +@keyframes fanOut { + from {opacity: 0;} + to {opacity: 1;} +}
\ No newline at end of file diff --git a/src/client/views/KeyphraseQueryView.tsx b/src/client/views/KeyphraseQueryView.tsx new file mode 100644 index 000000000..1dc156968 --- /dev/null +++ b/src/client/views/KeyphraseQueryView.tsx @@ -0,0 +1,35 @@ +import { observer } from "mobx-react"; +import React = require("react"); +import "./KeyphraseQueryView.scss"; + +// tslint:disable-next-line: class-name +export interface KP_Props { + keyphrases: string; +} + +@observer +export class KeyphraseQueryView extends React.Component<KP_Props>{ + constructor(props: KP_Props) { + super(props); + console.log("FIRST KEY PHRASE: ", props.keyphrases[0]); + } + + render() { + const kps = this.props.keyphrases.toString(); + const keyterms = this.props.keyphrases.split(','); + return ( + <div> + <h5>Select queries to send:</h5> + <form> + {keyterms.map((kp: string) => { + //return (<p>{"-" + kp}</p>); + return (<p><label> + <input name="query" type="radio" /> + <span>{kp}</span> + </label></p>); + })} + </form> + </div> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/Main.scss b/src/client/views/Main.scss index 4709e7ef2..a2a9ceca5 100644 --- a/src/client/views/Main.scss +++ b/src/client/views/Main.scss @@ -24,7 +24,6 @@ body { .jsx-parser { width: 100%; height: 100%; - pointer-events: none; border-radius: inherit; position: inherit; // background: inherit; diff --git a/src/client/views/MainView.scss b/src/client/views/MainView.scss index d39c217ec..81d427f64 100644 --- a/src/client/views/MainView.scss +++ b/src/client/views/MainView.scss @@ -5,6 +5,7 @@ .mainView-tabButtons { position: relative; width: 100%; + margin-top: 10px; } .mainContent-div { @@ -21,14 +22,43 @@ z-index: 1; } -#mainView-container { +.mainView-container, .mainView-container-dark { + input { + color: unset !important; + } width: 100%; height: 100%; position: absolute; + pointer-events: all; top: 0; left: 0; z-index: 1; touch-action: none; + .searchBox-container { + background: lightgray; + } +} + +.mainView-container-dark { + .lm_goldenlayout { + background: dimgray; + } + .marquee { + border-color: white; + } + #search-input { + background: lightgray; + } + .searchBox-container { + background: rgb(45,45,45); + } + .contextMenu-cont, .contextMenu-item { + background: dimGray; + color: lightgray; + } + .contextMenu-item:hover { + background: gray; + } } .mainView-mainContent { @@ -43,6 +73,7 @@ flex-direction: column; position: relative; height: 100%; + background: dimgray; .documentView-node-topmost { background: lightgrey; diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index 638a1b299..be46e0107 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -1,68 +1,68 @@ import { library } from '@fortawesome/fontawesome-svg-core'; -import { - faFileAlt, faStickyNote, faArrowDown, faBullseye, faFilter, faArrowUp, faBolt, faCaretUp, faCat, faCheck, faChevronRight, faClone, faCloudUploadAlt, faCommentAlt, faCut, faEllipsisV, faExclamation, faFilePdf, faFilm, faFont, faGlobeAsia, faLongArrowAltRight, - faMusic, faObjectGroup, faPause, faMousePointer, faPenNib, faFileAudio, faPen, faEraser, faPlay, faPortrait, faRedoAlt, faThumbtack, faTree, faTv, faUndoAlt, faHighlighter, faMicrophone, faCompressArrowsAlt, faPhone, faStamp, faClipboard -} from '@fortawesome/free-solid-svg-icons'; +import { faTerminal, faCalculator, faWindowMaximize, faAddressCard, faQuestionCircle, faArrowDown, faArrowUp, faBolt, faBullseye, faCaretUp, faCat, faCheck, faChevronRight, faClipboard, faClone, faCloudUploadAlt, faCommentAlt, faCompressArrowsAlt, faCut, faEllipsisV, faEraser, faExclamation, faFileAlt, faFileAudio, faFilePdf, faFilm, faFilter, faFont, faGlobeAsia, faHighlighter, faLongArrowAltRight, faMicrophone, faMousePointer, faMusic, faObjectGroup, faPause, faPen, faPenNib, faPhone, faPlay, faPortrait, faRedoAlt, faStamp, faStickyNote, faThumbtack, faTree, faTv, faUndoAlt, faVideo } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { action, computed, configure, observable, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import "normalize.css"; import * as React from 'react'; import Measure from 'react-measure'; -import { Doc, DocListCast, Field, FieldResult, Opt } from '../../new_fields/Doc'; +import { Doc, DocListCast, Field, Opt } from '../../new_fields/Doc'; import { Id } from '../../new_fields/FieldSymbols'; import { List } from '../../new_fields/List'; import { listSpec } from '../../new_fields/Schema'; -import { Cast, FieldValue, StrCast } from '../../new_fields/Types'; +import { BoolCast, Cast, FieldValue, StrCast } from '../../new_fields/Types'; +import { TraceMobx } from '../../new_fields/util'; import { CurrentUserUtils } from '../../server/authentication/models/current_user_utils'; -import { emptyFunction, returnEmptyString, returnFalse, returnOne, returnTrue, Utils, emptyPath } from '../../Utils'; +import { emptyFunction, emptyPath, returnFalse, returnOne, returnZero, returnTrue, Utils } from '../../Utils'; import GoogleAuthenticationManager from '../apis/GoogleAuthenticationManager'; import { DocServer } from '../DocServer'; import { Docs, DocumentOptions } from '../documents/Documents'; +import { DocumentType } from '../documents/DocumentTypes'; import { HistoryUtil } from '../util/History'; +import RichTextMenu from './nodes/formattedText/RichTextMenu'; +import { Scripting } from '../util/Scripting'; +import SettingsManager from '../util/SettingsManager'; import SharingManager from '../util/SharingManager'; import { Transform } from '../util/Transform'; -import { CollectionLinearView } from './collections/CollectionLinearView'; -import { CollectionViewType, CollectionView } from './collections/CollectionView'; import { CollectionDockingView } from './collections/CollectionDockingView'; +import MarqueeOptionsMenu from './collections/collectionFreeForm/MarqueeOptionsMenu'; +import { CollectionLinearView } from './collections/CollectionLinearView'; +import { CollectionView, CollectionViewType } from './collections/CollectionView'; import { ContextMenu } from './ContextMenu'; import { DictationOverlay } from './DictationOverlay'; import { DocumentDecorations } from './DocumentDecorations'; +import GestureOverlay from './GestureOverlay'; import KeyManager from './GlobalKeyHandler'; import "./MainView.scss"; import { MainViewNotifs } from './MainViewNotifs'; +import { AudioBox } from './nodes/AudioBox'; import { DocumentView } from './nodes/DocumentView'; +import { RadialMenu } from './nodes/RadialMenu'; import { OverlayView } from './OverlayView'; import PDFMenu from './pdf/PDFMenu'; import { PreviewCursor } from './PreviewCursor'; -import MarqueeOptionsMenu from './collections/collectionFreeForm/MarqueeOptionsMenu'; -import GestureOverlay from './GestureOverlay'; -import { Scripting } from '../util/Scripting'; -import { AudioBox } from './nodes/AudioBox'; -import { Timeline } from './animationtimeline/Timeline'; +import { ScriptField } from '../../new_fields/ScriptField'; import { TimelineMenu } from './animationtimeline/TimelineMenu'; -import SettingsManager from '../util/SettingsManager'; -import { TraceMobx } from '../../new_fields/util'; -import { RadialMenu } from './nodes/RadialMenu'; -import RichTextMenu from '../util/RichTextMenu'; @observer export class MainView extends React.Component { public static Instance: MainView; - private _buttonBarHeight = 35; + private _buttonBarHeight = 26; private _flyoutSizeOnDown = 0; private _urlState: HistoryUtil.DocUrl; private _docBtnRef = React.createRef<HTMLDivElement>(); + private _mainViewRef = React.createRef<HTMLDivElement>(); @observable private _panelWidth: number = 0; @observable private _panelHeight: number = 0; @observable private _flyoutTranslate: boolean = true; @observable public flyoutWidth: number = 250; + private get darkScheme() { return BoolCast(Cast(this.userDoc.activeWorkspace, Doc, null)?.darkScheme); } - @computed private get userDoc() { return CurrentUserUtils.UserDocument; } + @computed private get userDoc() { return Doc.UserDoc(); } @computed private get mainContainer() { return this.userDoc ? FieldValue(Cast(this.userDoc.activeWorkspace, Doc)) : CurrentUserUtils.GuestWorkspace; } @computed public get mainFreeform(): Opt<Doc> { return (docs => (docs && docs.length > 1) ? docs[1] : undefined)(DocListCast(this.mainContainer!.data)); } - @computed public get sidebarButtonsDoc() { return Cast(CurrentUserUtils.UserDocument.sidebarButtons, Doc) as Doc; } + @computed public get sidebarButtonsDoc() { return Cast(this.userDoc["tabs-buttons"], Doc) as Doc; } public isPointerDown = false; @@ -103,7 +103,12 @@ export class MainView extends React.Component { } } + library.add(faTerminal); + library.add(faCalculator); + library.add(faWindowMaximize); library.add(faFileAlt); + library.add(faAddressCard); + library.add(faQuestionCircle); library.add(faStickyNote); library.add(faFont); library.add(faExclamation); @@ -141,6 +146,7 @@ export class MainView extends React.Component { library.add(faArrowUp); library.add(faCloudUploadAlt); library.add(faBolt); + library.add(faVideo); library.add(faChevronRight); library.add(faEllipsisV); library.add(faMusic); @@ -187,7 +193,7 @@ export class MainView extends React.Component { reaction(() => CollectionDockingView.Instance && CollectionDockingView.Instance.initialized, initialized => initialized && received && DocServer.GetRefField(received).then(docField => { if (docField instanceof Doc && docField._viewType !== CollectionViewType.Docking) { - CollectionDockingView.AddRightSplit(docField, undefined); + CollectionDockingView.AddRightSplit(docField); } }), ); @@ -203,7 +209,7 @@ export class MainView extends React.Component { @action createNewWorkspace = async (id?: string) => { - const workspaces = Cast(this.userDoc.workspaces, Doc) as Doc; + const workspaces = Cast(this.userDoc.myWorkspaces, Doc) as Doc; const workspaceCount = DocListCast(workspaces.data).length + 1; const freeformOptions: DocumentOptions = { x: 0, @@ -211,11 +217,15 @@ export class MainView extends React.Component { _width: this._panelWidth * .7, _height: this._panelHeight, title: "Collection " + workspaceCount, - backgroundColor: "white" }; const freeformDoc = CurrentUserUtils.GuestTarget || Docs.Create.FreeformDocument([], freeformOptions); - Doc.AddDocToList(Doc.GetProto(CurrentUserUtils.UserDocument.documents as Doc), "data", freeformDoc); - const mainDoc = Docs.Create.StandardCollectionDockingDocument([{ doc: freeformDoc, initialWidth: 600, path: [Doc.UserDoc().documents as Doc] }], { title: `Workspace ${workspaceCount}` }, id, "row"); + Doc.AddDocToList(Doc.GetProto(Doc.UserDoc().myDocuments as Doc), "data", freeformDoc); + const mainDoc = Docs.Create.StandardCollectionDockingDocument([{ doc: freeformDoc, initialWidth: 600, path: [Doc.UserDoc().myDocuments as Doc] }], { title: `Workspace ${workspaceCount}` }, id, "row"); + + const toggleTheme = ScriptField.MakeScript(`self.darkScheme = !self.darkScheme`); + mainDoc.contextMenuScripts = new List<ScriptField>([toggleTheme!]); + mainDoc.contextMenuLabels = new List<string>(["Toggle Theme Colors"]); + Doc.AddDocToList(workspaces, "data", mainDoc); // bcz: strangely, we need a timeout to prevent exceptions/issues initializing GoldenLayout (the rendering engine for Main Container) setTimeout(() => this.openWorkspace(mainDoc), 0); @@ -256,7 +266,7 @@ export class MainView extends React.Component { } // if there is a pending doc, and it has new data, show it (syip: we use a timeout to prevent collection docking view from being uninitialized) setTimeout(async () => { - const col = this.userDoc && await Cast(this.userDoc.optionalRightCollection, Doc); + const col = this.userDoc && await Cast(this.userDoc.rightSidebarCollection, Doc); col && Cast(col.data, listSpec(Doc)) && runInAction(() => MainViewNotifs.NotifsCol = col); }, 100); return true; @@ -277,6 +287,29 @@ export class MainView extends React.Component { getPHeight = () => this._panelHeight; getContentsHeight = () => this._panelHeight - this._buttonBarHeight; + defaultBackgroundColors = (doc: Doc) => { + if (this.darkScheme) { + switch (doc.type) { + case DocumentType.RTF || DocumentType.LABEL || DocumentType.BUTTON: return "#2d2d2d"; + case DocumentType.LINK: + case DocumentType.COL: { + if (doc._viewType !== CollectionViewType.Freeform && doc._viewType !== CollectionViewType.Time) return "rgb(62,62,62)"; + } + default: return "black"; + } + } else { + switch (doc.type) { + case DocumentType.RTF: return "#f1efeb"; + case DocumentType.BUTTON: + case DocumentType.LABEL: return "lightgray"; + case DocumentType.LINK: + case DocumentType.COL: { + if (doc._viewType !== CollectionViewType.Freeform && doc._viewType !== CollectionViewType.Time) return "lightgray"; + } + default: return "white"; + } + } + } @computed get mainDocView() { return <DocumentView Document={this.mainContainer!} DataDoc={undefined} @@ -284,22 +317,23 @@ export class MainView extends React.Component { addDocument={undefined} addDocTab={this.addDocTabFunc} pinToPres={emptyFunction} + rootSelected={returnTrue} onClick={undefined} + backgroundColor={this.defaultBackgroundColors} removeDocument={undefined} ScreenToLocalTransform={Transform.Identity} ContentScaling={returnOne} + NativeHeight={returnZero} + NativeWidth={returnZero} PanelWidth={this.getPWidth} PanelHeight={this.getPHeight} renderDepth={0} - backgroundColor={returnEmptyString} focus={emptyFunction} parentActive={returnTrue} whenActiveChanged={emptyFunction} bringToFront={emptyFunction} ContainingCollectionView={undefined} ContainingCollectionDoc={undefined} - zoomToScale={emptyFunction} - getScale={returnOne} />; } @computed get dockingContent() { @@ -362,45 +396,44 @@ export class MainView extends React.Component { document.removeEventListener("pointerup", this.onPointerUp); } flyoutWidthFunc = () => this.flyoutWidth; - addDocTabFunc = (doc: Doc, data: Opt<Doc>, where: string, libraryPath?: Doc[]): boolean => { + addDocTabFunc = (doc: Doc, where: string, libraryPath?: Doc[]): boolean => { return where === "close" ? CollectionDockingView.CloseRightSplit(doc) : doc.dockingConfig ? this.openWorkspace(doc) : - CollectionDockingView.AddRightSplit(doc, undefined, libraryPath); + CollectionDockingView.AddRightSplit(doc, libraryPath); } mainContainerXf = () => new Transform(0, -this._buttonBarHeight, 1); @computed get flyout() { - const sidebarContent = this.userDoc && this.userDoc.sidebarContainer; + const sidebarContent = this.userDoc?.["tabs-panelContainer"]; if (!(sidebarContent instanceof Doc)) { return (null); } - const sidebarButtonsDoc = Cast(CurrentUserUtils.UserDocument.sidebarButtons, Doc) as Doc; return <div className="mainView-flyoutContainer" > - <div className="mainView-tabButtons" style={{ height: `${this._buttonBarHeight}px` }}> + <div className="mainView-tabButtons" style={{ height: `${this._buttonBarHeight}px`, backgroundColor: StrCast(this.sidebarButtonsDoc.backgroundColor) }}> <DocumentView - Document={sidebarButtonsDoc} + Document={this.sidebarButtonsDoc} DataDoc={undefined} LibraryPath={emptyPath} addDocument={undefined} + rootSelected={returnTrue} addDocTab={this.addDocTabFunc} pinToPres={emptyFunction} removeDocument={undefined} onClick={undefined} ScreenToLocalTransform={Transform.Identity} ContentScaling={returnOne} + NativeHeight={returnZero} + NativeWidth={returnZero} PanelWidth={this.flyoutWidthFunc} PanelHeight={this.getPHeight} renderDepth={0} focus={emptyFunction} - backgroundColor={returnEmptyString} + backgroundColor={this.defaultBackgroundColors} parentActive={returnTrue} whenActiveChanged={emptyFunction} bringToFront={emptyFunction} ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} - zoomToScale={emptyFunction} - getScale={returnOne}> - </DocumentView> + ContainingCollectionDoc={undefined} /> </div> <div className="mainView-contentArea" style={{ position: "relative", height: `calc(100% - ${this._buttonBarHeight}px)`, width: "100%", overflow: "visible" }}> <DocumentView @@ -410,6 +443,9 @@ export class MainView extends React.Component { addDocument={undefined} addDocTab={this.addDocTabFunc} pinToPres={emptyFunction} + NativeHeight={returnZero} + NativeWidth={returnZero} + rootSelected={returnTrue} removeDocument={returnFalse} onClick={undefined} ScreenToLocalTransform={this.mainContainerXf} @@ -418,15 +454,12 @@ export class MainView extends React.Component { PanelHeight={this.getContentsHeight} renderDepth={0} focus={emptyFunction} - backgroundColor={returnEmptyString} + backgroundColor={this.defaultBackgroundColors} parentActive={returnTrue} whenActiveChanged={emptyFunction} bringToFront={emptyFunction} ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} - zoomToScale={emptyFunction} - getScale={returnOne}> - </DocumentView> + ContainingCollectionDoc={undefined} /> <button className="mainView-settings" key="settings" onClick={() => SettingsManager.Instance.open()}> Settings </button> @@ -439,12 +472,12 @@ export class MainView extends React.Component { } @computed get mainContent() { - const sidebar = this.userDoc && this.userDoc.sidebarContainer; + const sidebar = this.userDoc?.["tabs-panelContainer"]; return !this.userDoc || !(sidebar instanceof Doc) ? (null) : ( - <div className="mainView-mainContent" > + <div className="mainView-mainContent" style={{ color: this.darkScheme ? "rgb(205,205,205)" : "black" }} > <div className="mainView-flyoutContainer" onPointerLeave={this.pointerLeaveDragger} style={{ width: this.flyoutWidth }}> <div className="mainView-libraryHandle" onPointerDown={this.onPointerDown} onPointerOver={this.pointerOverDragger} - style={{ backgroundColor: `${StrCast(sidebar.backgroundColor, "lightGray")}` }} > + style={{ backgroundColor: this.defaultBackgroundColors(sidebar) }}> <span title="library View Dragger" style={{ width: (this.flyoutWidth !== 0 && this._flyoutTranslate) ? "100%" : "3vw", //height: (this.flyoutWidth !== 0 && this._flyoutTranslate) ? "100%" : "100vh", @@ -476,8 +509,8 @@ export class MainView extends React.Component { return !this._flyoutTranslate ? (<div className="mainView-expandFlyoutButton" title="Re-attach sidebar" onPointerDown={MainView.expandFlyout}><FontAwesomeIcon icon="chevron-right" color="grey" size="lg" /></div>) : (null); } - addButtonDoc = (doc: Doc) => Doc.AddDocToList(CurrentUserUtils.UserDocument.expandingButtons as Doc, "data", doc); - remButtonDoc = (doc: Doc) => Doc.RemoveDocFromList(CurrentUserUtils.UserDocument.expandingButtons as Doc, "data", doc); + addButtonDoc = (doc: Doc) => Doc.AddDocToList(Doc.UserDoc().dockedBtns as Doc, "data", doc); + remButtonDoc = (doc: Doc) => Doc.RemoveDocFromList(Doc.UserDoc().dockedBtns as Doc, "data", doc); moveButtonDoc = (doc: Doc, targetCollection: Doc | undefined, addDocument: (document: Doc) => boolean) => this.remButtonDoc(doc) && addDocument(doc); buttonBarXf = () => { @@ -486,18 +519,21 @@ export class MainView extends React.Component { return new Transform(-translateX, -translateY, 1 / scale); } @computed get docButtons() { - if (CurrentUserUtils.UserDocument?.expandingButtons instanceof Doc) { + const dockedBtns = Doc.UserDoc()?.dockedBtns; + if (dockedBtns instanceof Doc) { return <div className="mainView-docButtons" ref={this._docBtnRef} - style={{ height: !CurrentUserUtils.UserDocument.expandingButtons.isExpanded ? "42px" : undefined }} > + style={{ height: !dockedBtns.linearViewIsExpanded ? "42px" : undefined }} > <MainViewNotifs /> <CollectionLinearView - Document={CurrentUserUtils.UserDocument.expandingButtons} + Document={dockedBtns} DataDoc={undefined} LibraryPath={emptyPath} fieldKey={"data"} + dropAction={"alias"} annotationsKey={""} + rootSelected={returnTrue} + bringToFront={emptyFunction} select={emptyFunction} - chromeCollapsed={true} active={returnFalse} isSelected={returnFalse} moveDocument={this.moveButtonDoc} @@ -509,6 +545,8 @@ export class MainView extends React.Component { onClick={undefined} ScreenToLocalTransform={this.buttonBarXf} ContentScaling={returnOne} + NativeHeight={returnZero} + NativeWidth={returnZero} PanelWidth={this.flyoutWidthFunc} PanelHeight={this.getContentsHeight} renderDepth={0} @@ -521,8 +559,16 @@ export class MainView extends React.Component { return (null); } + get mainViewElement() { + return document.getElementById("mainView-container"); + } + + get mainViewRef() { + return this._mainViewRef; + } + render() { - return (<div id="mainView-container"> + return (<div className={"mainView-container" + (this.darkScheme ? "-dark" : "")} ref={this._mainViewRef}> <DictationOverlay /> <SharingManager /> <SettingsManager /> diff --git a/src/client/views/MainViewNotifs.tsx b/src/client/views/MainViewNotifs.tsx index 09fa1cb0c..82e07c449 100644 --- a/src/client/views/MainViewNotifs.tsx +++ b/src/client/views/MainViewNotifs.tsx @@ -15,7 +15,7 @@ export class MainViewNotifs extends React.Component { @observable static NotifsCol: Opt<Doc>; openNotifsCol = () => { if (MainViewNotifs.NotifsCol) { - CollectionDockingView.AddRightSplit(MainViewNotifs.NotifsCol, undefined); + CollectionDockingView.AddRightSplit(MainViewNotifs.NotifsCol); } } render() { diff --git a/src/client/views/MetadataEntryMenu.scss b/src/client/views/MetadataEntryMenu.scss index 5f4a52c0c..5776cf070 100644 --- a/src/client/views/MetadataEntryMenu.scss +++ b/src/client/views/MetadataEntryMenu.scss @@ -8,6 +8,12 @@ } } +.metadataEntry-autoSuggester { + width: 100%; + height: 100%; + padding-right: 10px; +} + #metadataEntry-outer { overflow: auto !important; } diff --git a/src/client/views/MetadataEntryMenu.tsx b/src/client/views/MetadataEntryMenu.tsx index 23b21ae0c..8bc80ed06 100644 --- a/src/client/views/MetadataEntryMenu.tsx +++ b/src/client/views/MetadataEntryMenu.tsx @@ -195,10 +195,10 @@ export class MetadataEntryMenu extends React.Component<MetadataEntryProps>{ _ref = React.createRef<HTMLInputElement>(); render() { - return ( - <div className="metadataEntry-outerDiv" id="metadataEntry-outer"> - <div className="metadataEntry-inputArea"> - Key: + return (<div className="metadataEntry-outerDiv" id="metadataEntry-outer" onPointerDown={e => e.stopPropagation()}> + <div className="metadataEntry-inputArea"> + Key: + <div className="metadataEntry-autoSuggester" onClick={e => this.autosuggestRef.current!.input?.focus()} > <Autosuggest inputProps={{ value: this._currentKey, onChange: this.onKeyChange }} getSuggestionValue={this.getSuggestionValue} suggestions={emptyPath} @@ -207,16 +207,17 @@ export class MetadataEntryMenu extends React.Component<MetadataEntryProps>{ onSuggestionsFetchRequested={emptyFunction} onSuggestionsClearRequested={emptyFunction} ref={this.autosuggestRef} /> - Value: - <input className="metadataEntry-input" ref={this._ref} value={this._currentValue} onClick={e => this._ref.current!.focus()} onChange={this.onValueChange} onKeyDown={this.onValueKeyDown} /> - {this.considerChildOptions} - </div> - <div className="metadataEntry-keys" > - <ul> - {this._allSuggestions.slice().sort().map(s => <li key={s} onClick={action(() => { this._currentKey = s; this.previewValue(); })} >{s}</li>)} - </ul> </div> + Value: + <input className="metadataEntry-input" ref={this._ref} value={this._currentValue} onClick={e => this._ref.current!.focus()} onChange={this.onValueChange} onKeyDown={this.onValueKeyDown} /> + {this.considerChildOptions} + </div> + <div className="metadataEntry-keys" > + <ul> + {this._allSuggestions.slice().sort().map(s => <li key={s} onClick={action(() => { this._currentKey = s; this.previewValue(); })} >{s}</li>)} + </ul> </div> + </div> ); } }
\ No newline at end of file diff --git a/src/client/views/OCRUtils.ts b/src/client/views/OCRUtils.ts new file mode 100644 index 000000000..282ec770e --- /dev/null +++ b/src/client/views/OCRUtils.ts @@ -0,0 +1,7 @@ +// import tesseract from "node-tesseract-ocr"; +// const tesseract = require("node-tesseract"); + + +export namespace OCRUtils { + +} diff --git a/src/client/views/OverlayView.tsx b/src/client/views/OverlayView.tsx index 7a99bf0ae..20aa14f84 100644 --- a/src/client/views/OverlayView.tsx +++ b/src/client/views/OverlayView.tsx @@ -1,16 +1,16 @@ -import * as React from "react"; +import { action, computed, observable } from "mobx"; import { observer } from "mobx-react"; -import { observable, action, trace, computed } from "mobx"; -import { Utils, emptyFunction, returnOne, returnTrue, returnEmptyString, returnZero, returnFalse, emptyPath } from "../../Utils"; - -import './OverlayView.scss'; -import { CurrentUserUtils } from "../../server/authentication/models/current_user_utils"; -import { DocListCast, Doc } from "../../new_fields/Doc"; +import * as React from "react"; +import { Doc, DocListCast } from "../../new_fields/Doc"; import { Id } from "../../new_fields/FieldSymbols"; -import { DocumentView } from "./nodes/DocumentView"; -import { Transform } from "../util/Transform"; import { NumCast } from "../../new_fields/Types"; +import { emptyFunction, emptyPath, returnEmptyString, returnFalse, returnOne, returnTrue, returnZero, Utils } from "../../Utils"; +import { Transform } from "../util/Transform"; import { CollectionFreeFormLinksView } from "./collections/collectionFreeForm/CollectionFreeFormLinksView"; +import { DocumentView } from "./nodes/DocumentView"; +import './OverlayView.scss'; +import { Scripting } from "../util/Scripting"; +import { ScriptingRepl } from './ScriptingRepl'; export type OverlayDisposer = () => void; @@ -140,10 +140,11 @@ export class OverlayView extends React.Component { } @computed get overlayDocs() { - if (!CurrentUserUtils.UserDocument) { + const userDocOverlays = Doc.UserDoc().myOverlayDocuments; + if (!userDocOverlays) { return (null); } - return CurrentUserUtils.UserDocument.overlays instanceof Doc && DocListCast(CurrentUserUtils.UserDocument.overlays.data).map(d => { + return userDocOverlays instanceof Doc && DocListCast(userDocOverlays.data).map(d => { setTimeout(() => d.inOverlay = true, 0); let offsetx = 0, offsety = 0; const onPointerMove = action((e: PointerEvent) => { @@ -169,11 +170,12 @@ export class OverlayView extends React.Component { document.addEventListener("pointermove", onPointerMove); document.addEventListener("pointerup", onPointerUp); }; - return <div className="overlayView-doc" key={d[Id]} onPointerDown={onPointerDown} style={{ transform: `translate(${d.x}px, ${d.y}px)`, display: d.isMinimized ? "none" : "" }}> + return <div className="overlayView-doc" key={d[Id]} onPointerDown={onPointerDown} style={{ transform: `translate(${d.x}px, ${d.y}px)` }}> <DocumentView Document={d} LibraryPath={emptyPath} ChromeHeight={returnZero} + rootSelected={returnTrue} // isSelected={returnFalse} // select={emptyFunction} // layoutKey={"layout"} @@ -181,6 +183,8 @@ export class OverlayView extends React.Component { addDocument={undefined} removeDocument={undefined} ContentScaling={returnOne} + NativeHeight={returnZero} + NativeWidth={returnZero} PanelWidth={returnOne} PanelHeight={returnOne} ScreenToLocalTransform={Transform.Identity} @@ -192,9 +196,7 @@ export class OverlayView extends React.Component { addDocTab={returnFalse} pinToPres={emptyFunction} ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} - zoomToScale={emptyFunction} - getScale={returnOne} /> + ContainingCollectionDoc={undefined} /> </div>; }); } @@ -210,4 +212,6 @@ export class OverlayView extends React.Component { </div> ); } -}
\ No newline at end of file +} +// bcz: ugh ... want to be able to pass ScriptingRepl as tag argument, but that doesn't seem to work.. runtime error +Scripting.addGlobal(function addOverlayWindow(Tag: string, options: OverlayElementOptions) { const x = <ScriptingRepl />; OverlayView.Instance.addWindow(x, options); });
\ No newline at end of file diff --git a/src/client/views/Palette.scss b/src/client/views/Palette.scss index 4513de2b0..0ec879288 100644 --- a/src/client/views/Palette.scss +++ b/src/client/views/Palette.scss @@ -1,13 +1,14 @@ .palette-container { .palette-thumb { touch-action: pan-x; - overflow: scroll; position: absolute; - width: 90px; height: 70px; + overflow: hidden; .palette-thumbContent { transition: transform .3s; + width: max-content; + overflow: hidden; .collectionView { overflow: visible; @@ -17,5 +18,13 @@ } } } + + .palette-cover { + width: 50px; + height: 50px; + position: absolute; + bottom: 0; + border: 1px solid black; + } } }
\ No newline at end of file diff --git a/src/client/views/Palette.tsx b/src/client/views/Palette.tsx index 10aac96a0..63744cb50 100644 --- a/src/client/views/Palette.tsx +++ b/src/client/views/Palette.tsx @@ -1,23 +1,12 @@ +import { IReactionDisposer, observable, reaction } from "mobx"; +import { observer } from "mobx-react"; import * as React from "react"; -import "./Palette.scss"; -import { PointData } from "../../new_fields/InkField"; import { Doc } from "../../new_fields/Doc"; -import { Docs } from "../documents/Documents"; -import { ScriptField, ComputedField } from "../../new_fields/ScriptField"; -import { List } from "../../new_fields/List"; -import { DocumentView } from "./nodes/DocumentView"; -import { emptyPath, returnFalse, emptyFunction, returnOne, returnEmptyString, returnTrue } from "../../Utils"; -import { CurrentUserUtils } from "../../server/authentication/models/current_user_utils"; +import { NumCast } from "../../new_fields/Types"; +import { emptyFunction, emptyPath, returnEmptyString, returnZero, returnFalse, returnOne, returnTrue } from "../../Utils"; import { Transform } from "../util/Transform"; -import { computed, action, IReactionDisposer, reaction, observable } from "mobx"; -import { FieldValue, Cast, NumCast } from "../../new_fields/Types"; -import { observer } from "mobx-react"; -import { DocumentContentsView } from "./nodes/DocumentContentsView"; -import { CollectionStackingView } from "./collections/CollectionStackingView"; -import { CollectionView } from "./collections/CollectionView"; -import { CollectionSubView, SubCollectionViewProps } from "./collections/CollectionSubView"; -import { makeInterface } from "../../new_fields/Schema"; -import { documentSchema } from "../../new_fields/documentSchemas"; +import { DocumentView } from "./nodes/DocumentView"; +import "./Palette.scss"; export interface PaletteProps { x: number; @@ -40,7 +29,7 @@ export default class Palette extends React.Component<PaletteProps> { } componentWillUnmount = () => { - this._selectedDisposer && this._selectedDisposer(); + this._selectedDisposer?.(); } render() { @@ -54,11 +43,14 @@ export default class Palette extends React.Component<PaletteProps> { LibraryPath={emptyPath} addDocument={undefined} addDocTab={returnFalse} + rootSelected={returnTrue} pinToPres={emptyFunction} removeDocument={undefined} onClick={undefined} ScreenToLocalTransform={Transform.Identity} ContentScaling={returnOne} + NativeHeight={returnZero} + NativeWidth={returnZero} PanelWidth={() => window.screen.width} PanelHeight={() => window.screen.height} renderDepth={0} @@ -68,10 +60,8 @@ export default class Palette extends React.Component<PaletteProps> { whenActiveChanged={emptyFunction} bringToFront={emptyFunction} ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} - zoomToScale={emptyFunction} - getScale={returnOne}> - </DocumentView> + ContainingCollectionDoc={undefined} /> + <div className="palette-cover" style={{ transform: `translate(${Math.max(0, this._selectedIndex) * 50.75 + 23}px, 0px)` }}></div> </div> </div> </div> diff --git a/src/client/views/PreviewCursor.tsx b/src/client/views/PreviewCursor.tsx index c011adb20..df30c1215 100644 --- a/src/client/views/PreviewCursor.tsx +++ b/src/client/views/PreviewCursor.tsx @@ -13,6 +13,7 @@ export class PreviewCursor extends React.Component<{}> { static _getTransform: () => Transform; static _addLiveTextDoc: (doc: Doc) => void; static _addDocument: (doc: Doc) => boolean; + static _nudge: (x: number, y: number) => boolean; @observable static _clickPoint = [0, 0]; @observable public static Visible = false; constructor(props: any) { @@ -85,9 +86,19 @@ export class PreviewCursor extends React.Component<{}> { !e.key.startsWith("Arrow") && !e.defaultPrevented) { if ((!e.ctrlKey || (e.keyCode >= 48 && e.keyCode <= 57)) && !e.metaKey) {// /^[a-zA-Z0-9$*^%#@+-=_|}{[]"':;?/><.,}]$/.test(e.key)) { - PreviewCursor.Visible && PreviewCursor._onKeyPress && PreviewCursor._onKeyPress(e); + PreviewCursor.Visible && PreviewCursor._onKeyPress?.(e); PreviewCursor.Visible = false; } + } else if (PreviewCursor.Visible) { + if (e.key === "ArrowRight") { + PreviewCursor._nudge?.(1 * (e.shiftKey ? 2 : 1), 0) && e.stopPropagation(); + } else if (e.key === "ArrowLeft") { + PreviewCursor._nudge?.(-1 * (e.shiftKey ? 2 : 1), 0) && e.stopPropagation(); + } else if (e.key === "ArrowUp") { + PreviewCursor._nudge?.(0, 1 * (e.shiftKey ? 2 : 1)) && e.stopPropagation(); + } else if (e.key === "ArrowDown") { + PreviewCursor._nudge?.(0, -1 * (e.shiftKey ? 2 : 1)) && e.stopPropagation(); + } } } @@ -101,12 +112,14 @@ export class PreviewCursor extends React.Component<{}> { onKeyPress: (e: KeyboardEvent) => void, addLiveText: (doc: Doc) => void, getTransform: () => Transform, - addDocument: (doc: Doc) => boolean) { + addDocument: (doc: Doc) => boolean, + nudge: (nudgeX: number, nudgeY: number) => boolean) { this._clickPoint = [x, y]; this._onKeyPress = onKeyPress; this._addLiveTextDoc = addLiveText; this._getTransform = getTransform; this._addDocument = addDocument; + this._nudge = nudge; this.Visible = true; } render() { diff --git a/src/client/views/RecommendationsBox.scss b/src/client/views/RecommendationsBox.scss new file mode 100644 index 000000000..7d89042a4 --- /dev/null +++ b/src/client/views/RecommendationsBox.scss @@ -0,0 +1,69 @@ +@import "globalCssVariables"; + +.rec-content *{ + display: inline-block; + margin: auto; + width: 50; + height: 150px; + border: 1px dashed grey; + padding: 10px 10px; +} + +.rec-content { + float: left; + width: inherit; + align-content: center; +} + +.rec-scroll { + overflow-y: scroll; + overflow-x: hidden; + position: absolute; + pointer-events: all; + // display: flex; + z-index: 10000; + box-shadow: gray 0.2vw 0.2vw 0.4vw; + // flex-direction: column; + background: whitesmoke; + padding-bottom: 10px; + padding-top: 20px; + // border-radius: 15px; + border: solid #BBBBBBBB 1px; + width: 100%; + text-align: center; + // max-height: 250px; + height: 100%; + text-transform: uppercase; + color: grey; + letter-spacing: 2px; +} + +.content { + padding: 10px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; +} + +.image-background { + pointer-events: none; + background-color: transparent; + width: 50%; + text-align: center; + margin-left: 5px; +} + +// bcz: UGH!! Can't have global settings like this!!! +// img{ +// width: 100%; +// height: 100%; +// } + +.score { + // margin-left: 15px; + width: 50%; + height: 100%; + text-align: center; + margin-left: 10px; +} diff --git a/src/client/views/RecommendationsBox.tsx b/src/client/views/RecommendationsBox.tsx new file mode 100644 index 000000000..e66fd3eb4 --- /dev/null +++ b/src/client/views/RecommendationsBox.tsx @@ -0,0 +1,200 @@ +import { observer } from "mobx-react"; +import React = require("react"); +import { observable, action, computed, runInAction } from "mobx"; +import Measure from "react-measure"; +import "./RecommendationsBox.scss"; +import { Doc, DocListCast, WidthSym, HeightSym } from "../../new_fields/Doc"; +import { DocumentIcon } from "./nodes/DocumentIcon"; +import { StrCast, NumCast } from "../../new_fields/Types"; +import { returnFalse, emptyFunction, returnEmptyString, returnOne, emptyPath, returnZero } from "../../Utils"; +import { Transform } from "../util/Transform"; +import { ObjectField } from "../../new_fields/ObjectField"; +import { DocumentView } from "./nodes/DocumentView"; +import { DocumentType } from '../documents/DocumentTypes'; +import { ClientRecommender } from "../ClientRecommender"; +import { DocServer } from "../DocServer"; +import { Id } from "../../new_fields/FieldSymbols"; +import { FieldView, FieldViewProps } from "./nodes/FieldView"; +import { DocumentManager } from "../util/DocumentManager"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { library } from "@fortawesome/fontawesome-svg-core"; +import { faBullseye, faLink } from "@fortawesome/free-solid-svg-icons"; +import { DocUtils } from "../documents/Documents"; + +export interface RecProps { + documents: { preview: Doc, similarity: number }[]; + node: Doc; +} + +library.add(faBullseye, faLink); + +@observer +export class RecommendationsBox extends React.Component<FieldViewProps> { + + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(RecommendationsBox, fieldKey); } + + // @observable private _display: boolean = false; + @observable private _pageX: number = 0; + @observable private _pageY: number = 0; + @observable private _width: number = 0; + @observable private _height: number = 0; + @observable.shallow private _docViews: JSX.Element[] = []; + // @observable private _documents: { preview: Doc, score: number }[] = []; + private previewDocs: Doc[] = []; + + constructor(props: FieldViewProps) { + super(props); + } + + @action + private DocumentIcon(doc: Doc) { + const layoutresult = StrCast(doc.type); + let renderDoc = doc; + //let box: number[] = []; + if (layoutresult.indexOf(DocumentType.COL) !== -1) { + renderDoc = Doc.MakeDelegate(renderDoc); + } + const returnXDimension = () => 150; + const returnYDimension = () => 150; + const scale = () => returnXDimension() / NumCast(renderDoc.nativeWidth, returnXDimension()); + //let scale = () => 1; + const newRenderDoc = Doc.MakeAlias(renderDoc); /// newRenderDoc -> renderDoc -> render"data"Doc -> TextProt + newRenderDoc.height = NumCast(this.props.Document.documentIconHeight); + newRenderDoc.autoHeight = false; + const docview = <div> + <DocumentView + fitToBox={StrCast(doc.type).indexOf(DocumentType.COL) !== -1} + Document={newRenderDoc} + addDocument={returnFalse} + LibraryPath={emptyPath} + removeDocument={returnFalse} + rootSelected={returnFalse} + ScreenToLocalTransform={Transform.Identity} + addDocTab={returnFalse} + pinToPres={returnFalse} + renderDepth={1} + NativeHeight={returnZero} + NativeWidth={returnZero} + PanelWidth={returnXDimension} + PanelHeight={returnYDimension} + focus={emptyFunction} + backgroundColor={returnEmptyString} + parentActive={returnFalse} + whenActiveChanged={returnFalse} + bringToFront={emptyFunction} + ContainingCollectionView={undefined} + ContainingCollectionDoc={undefined} + ContentScaling={scale} + /> + </div>; + return docview; + + } + + // @action + // closeMenu = () => { + // this._display = false; + // this.previewDocs.forEach(doc => DocServer.DeleteDocument(doc[Id])); + // this.previewDocs = []; + // } + + // @action + // resetDocuments = () => { + // this._documents = []; + // } + + // @action + // displayRecommendations(x: number, y: number) { + // this._pageX = x; + // this._pageY = y; + // this._display = true; + // } + + static readonly buffer = 20; + + // get pageX() { + // const x = this._pageX; + // if (x < 0) { + // return 0; + // } + // const width = this._width; + // if (x + width > window.innerWidth - RecommendationsBox.buffer) { + // return window.innerWidth - RecommendationsBox.buffer - width; + // } + // return x; + // } + + // get pageY() { + // const y = this._pageY; + // if (y < 0) { + // return 0; + // } + // const height = this._height; + // if (y + height > window.innerHeight - RecommendationsBox.buffer) { + // return window.innerHeight - RecommendationsBox.buffer - height; + // } + // return y; + // } + + // get createDocViews() { + // return DocListCast(this.props.Document.data).map(doc => { + // return ( + // <div className="content"> + // <span style={{ height: NumCast(this.props.Document.documentIconHeight) }} className="image-background"> + // {this.DocumentIcon(doc)} + // </span> + // <span className="score">{NumCast(doc.score).toFixed(4)}</span> + // <div style={{ marginRight: 50 }} onClick={() => DocumentManager.Instance.jumpToDocument(doc, false)}> + // <FontAwesomeIcon className="documentdecorations-icon" icon={"bullseye"} size="sm" /> + // </div> + // <div style={{ marginRight: 50 }} onClick={() => DocUtils.MakeLink({ doc: this.props.Document.sourceDoc as Doc }, { doc: doc }, "User Selected Link", "Generated from Recommender", undefined)}> + // <FontAwesomeIcon className="documentdecorations-icon" icon={"link"} size="sm" /> + // </div> + // </div> + // ); + // }); + // } + + componentDidMount() { //TODO: invoking a computedFn from outside an reactive context won't be memoized, unless keepAlive is set + runInAction(() => { + if (this._docViews.length === 0) { + this._docViews = DocListCast(this.props.Document.data).map(doc => { + return ( + <div className="content"> + <span style={{ height: NumCast(this.props.Document.documentIconHeight) }} className="image-background"> + {this.DocumentIcon(doc)} + </span> + <span className="score">{NumCast(doc.score).toFixed(4)}</span> + <div style={{ marginRight: 50 }} onClick={() => DocumentManager.Instance.jumpToDocument(doc, false)}> + <FontAwesomeIcon className="documentdecorations-icon" icon={"bullseye"} size="sm" /> + </div> + <div style={{ marginRight: 50 }} onClick={() => DocUtils.MakeLink({ doc: this.props.Document.sourceDoc as Doc }, { doc: doc }, "Recommender", undefined)}> + <FontAwesomeIcon className="documentdecorations-icon" icon={"link"} size="sm" /> + </div> + </div> + ); + }); + } + }); + } + + render() { //TODO: Invariant violation: max depth exceeded error. Occurs when images are rendered. + // if (!this._display) { + // return null; + // } + // let style = { left: this.pageX, top: this.pageY }; + //const transform = "translate(" + (NumCast(this.props.node.x) + 350) + "px, " + NumCast(this.props.node.y) + "px" + let title = StrCast((this.props.Document.sourceDoc as Doc).title); + if (title.length > 15) { + title = title.substring(0, 15) + "..."; + } + return ( + <div className="rec-scroll"> + <p>Recommendations for "{title}"</p> + {this._docViews} + </div> + ); + } + // + // +}
\ No newline at end of file diff --git a/src/client/views/ScriptBox.tsx b/src/client/views/ScriptBox.tsx index d24256886..153b81876 100644 --- a/src/client/views/ScriptBox.tsx +++ b/src/client/views/ScriptBox.tsx @@ -12,6 +12,7 @@ import { CompileScript } from "../util/Scripting"; import { ScriptField } from "../../new_fields/ScriptField"; import { DragManager } from "../util/DragManager"; import { EditableView } from "./EditableView"; +import { getEffectiveTypeRoots } from "typescript"; export interface ScriptBoxProps { onSave: (text: string, onError: (error: string) => void) => void; @@ -43,14 +44,12 @@ export class ScriptBox extends React.Component<ScriptBoxProps> { overlayDisposer?: () => void; onFocus = () => { - if (this.overlayDisposer) { - this.overlayDisposer(); - } + this.overlayDisposer?.(); this.overlayDisposer = OverlayView.Instance.addElement(<DocumentIconContainer />, { x: 0, y: 0 }); } onBlur = () => { - this.overlayDisposer && this.overlayDisposer(); + this.overlayDisposer?.(); } render() { @@ -93,30 +92,34 @@ export class ScriptBox extends React.Component<ScriptBoxProps> { const params: string[] = []; const setParams = (p: string[]) => params.splice(0, params.length, ...p); const scriptingBox = <ScriptBox initialText={originalText} setParams={setParams} onCancel={overlayDisposer} onSave={(text, onError) => { - const script = CompileScript(text, { - params: { this: Doc.name, ...contextParams }, - typecheck: false, - editable: true, - transformer: DocumentIconContainer.getTransformer() - }); - if (!script.compiled) { - onError(script.errors.map(error => error.messageText).join("\n")); - return; - } + if (!text) { + Doc.GetProto(doc)[fieldKey] = undefined; + } else { + const script = CompileScript(text, { + params: { this: Doc.name, ...contextParams }, + typecheck: false, + editable: true, + transformer: DocumentIconContainer.getTransformer() + }); + if (!script.compiled) { + onError(script.errors.map(error => error.messageText).join("\n")); + return; + } - const div = document.createElement("div"); - div.style.width = "90"; - div.style.height = "20"; - div.style.background = "gray"; - div.style.position = "absolute"; - div.style.display = "inline-block"; - div.style.transform = `translate(${clientX}px, ${clientY}px)`; - div.innerHTML = "button"; - params.length && DragManager.StartButtonDrag([div], text, doc.title + "-instance", {}, params, (button: Doc) => { }, clientX, clientY); + const div = document.createElement("div"); + div.style.width = "90"; + div.style.height = "20"; + div.style.background = "gray"; + div.style.position = "absolute"; + div.style.display = "inline-block"; + div.style.transform = `translate(${clientX}px, ${clientY}px)`; + div.innerHTML = "button"; + params.length && DragManager.StartButtonDrag([div], text, doc.title + "-instance", {}, params, (button: Doc) => { }, clientX, clientY); - doc[fieldKey] = new ScriptField(script); - overlayDisposer(); + Doc.GetProto(doc)[fieldKey] = new ScriptField(script); + overlayDisposer(); + } }} showDocumentIcons />; - overlayDisposer = OverlayView.Instance.addWindow(scriptingBox, { x: 400, y: 200, width: 500, height: 400, title: title }); + overlayDisposer = OverlayView.Instance.addWindow(scriptingBox, { x: 400, y: 200, width: 500, height: 400, title }); } } diff --git a/src/client/views/SearchDocBox.tsx b/src/client/views/SearchDocBox.tsx new file mode 100644 index 000000000..799fa9d85 --- /dev/null +++ b/src/client/views/SearchDocBox.tsx @@ -0,0 +1,431 @@ +import { library } from "@fortawesome/fontawesome-svg-core"; +import { faBullseye, faLink } from "@fortawesome/free-solid-svg-icons"; +import { action, computed, observable, runInAction } from "mobx"; +import { observer } from "mobx-react"; +//import "./SearchBoxDoc.scss"; +import { Doc, DocListCast } from "../../new_fields/Doc"; +import { Id } from "../../new_fields/FieldSymbols"; +import { BoolCast, Cast, NumCast, StrCast } from "../../new_fields/Types"; +import { returnFalse } from "../../Utils"; +import { Docs } from "../documents/Documents"; +import { SearchUtil } from "../util/SearchUtil"; +import { EditableView } from "./EditableView"; +import { ContentFittingDocumentView } from "./nodes/ContentFittingDocumentView"; +import { FieldView, FieldViewProps } from "./nodes/FieldView"; +import { FilterBox } from "./search/FilterBox"; +import { SearchItem } from "./search/SearchItem"; +import React = require("react"); + +export interface RecProps { + documents: { preview: Doc, similarity: number }[]; + node: Doc; + +} + +library.add(faBullseye, faLink); +export const keyPlaceholder = "Query"; + +@observer +export class SearchDocBox extends React.Component<FieldViewProps> { + + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(SearchDocBox, fieldKey); } + + // @observable private _display: boolean = false; + @observable private _pageX: number = 0; + @observable private _pageY: number = 0; + @observable private _width: number = 0; + @observable private _height: number = 0; + @observable.shallow private _docViews: JSX.Element[] = []; + // @observable private _documents: { preview: Doc, score: number }[] = []; + private previewDocs: Doc[] = []; + + constructor(props: FieldViewProps) { + super(props); + this.editingMetadata = this.editingMetadata || false; + //SearchBox.Instance = this; + this.resultsScrolled = this.resultsScrolled.bind(this); + } + + + @computed + private get editingMetadata() { + return BoolCast(this.props.Document.editingMetadata); + } + + private set editingMetadata(value: boolean) { + this.props.Document.editingMetadata = value; + } + + static readonly buffer = 20; + + componentDidMount() { + runInAction(() => { + console.log("didit" + ); + this.query = StrCast(this.props.Document.searchText); + this.content = (Docs.Create.TreeDocument(DocListCast(Doc.GetProto(this.props.Document).data), { _width: 200, _height: 400, _chromeStatus: "disabled", title: `Search Docs:` + this.query })); + + }); + if (this.inputRef.current) { + this.inputRef.current.focus(); + runInAction(() => { + this._searchbarOpen = true; + }); + } + } + + @observable + private content: Doc | undefined; + + @action + updateKey = async (newKey: string) => { + this.query = newKey; + if (newKey.length > 1) { + const newdocs = await this.getAllResults(this.query); + const things = newdocs.docs; + console.log(things); + console.log(this.content); + runInAction(() => { + this.content = Docs.Create.TreeDocument(things, { _width: 200, _height: 400, _chromeStatus: "disabled", title: `Search Docs:` + this.query }); + }); + console.log(this.content); + } + + + //this.keyRef.current && this.keyRef.current.setIsFocused(false); + //this.query.length === 0 && (this.query = keyPlaceholder); + return true; + } + + @computed + public get query() { + return StrCast(this.props.Document.query); + } + + public set query(value: string) { + this.props.Document.query = value; + } + + @observable private _searchString: string = ""; + @observable private _resultsOpen: boolean = false; + @observable private _searchbarOpen: boolean = false; + @observable private _results: [Doc, string[], string[]][] = []; + private _resultsSet = new Map<Doc, number>(); + @observable private _openNoResults: boolean = false; + @observable private _visibleElements: JSX.Element[] = []; + + private resultsRef = React.createRef<HTMLDivElement>(); + public inputRef = React.createRef<HTMLInputElement>(); + + private _isSearch: ("search" | "placeholder" | undefined)[] = []; + private _numTotalResults = -1; + private _endIndex = -1; + + + private _maxSearchIndex: number = 0; + private _curRequest?: Promise<any> = undefined; + + @action + getViews = async (doc: Doc) => { + const results = await SearchUtil.GetViewsOfDocument(doc); + let toReturn: Doc[] = []; + await runInAction(() => { + toReturn = results; + }); + return toReturn; + } + + @action.bound + onChange(e: React.ChangeEvent<HTMLInputElement>) { + this._searchString = e.target.value; + + this._openNoResults = false; + this._results = []; + this._resultsSet.clear(); + this._visibleElements = []; + this._numTotalResults = -1; + this._endIndex = -1; + this._curRequest = undefined; + this._maxSearchIndex = 0; + } + + enter = async (e: React.KeyboardEvent) => { + console.log(e.key); + if (e.key === "Enter") { + const newdocs = await this.getAllResults(this.query); + console.log(newdocs.docs); + this.content = Docs.Create.TreeDocument(newdocs.docs, { _width: 200, _height: 400, _chromeStatus: "disabled", title: `Search Docs: "Results"` }); + + } + } + + + @action + submitSearch = async () => { + let query = this._searchString; + query = FilterBox.Instance.getFinalQuery(query); + this._results = []; + this._resultsSet.clear(); + this._isSearch = []; + this._visibleElements = []; + FilterBox.Instance.closeFilter(); + + //if there is no query there should be no result + if (query === "") { + return; + } + else { + this._endIndex = 12; + this._maxSearchIndex = 0; + this._numTotalResults = -1; + await this.getResults(query); + } + + runInAction(() => { + this._resultsOpen = true; + this._searchbarOpen = true; + this._openNoResults = true; + this.resultsScrolled(); + }); + } + + getAllResults = async (query: string) => { + return SearchUtil.Search(query, true, { fq: this.filterQuery, start: 0, rows: 10000000 }); + } + + private get filterQuery() { + const types = FilterBox.Instance.filterTypes; + const includeDeleted = FilterBox.Instance.getDataStatus(); + return "NOT baseProto_b:true" + (includeDeleted ? "" : " AND NOT deleted_b:true") + (types ? ` AND (${types.map(type => `({!join from=id to=proto_i}type_t:"${type}" AND NOT type_t:*) OR type_t:"${type}" OR type_t:"extension"`).join(" ")})` : ""); + } + + + private NumResults = 25; + private lockPromise?: Promise<void>; + getResults = async (query: string) => { + if (this.lockPromise) { + await this.lockPromise; + } + this.lockPromise = new Promise(async res => { + while (this._results.length <= this._endIndex && (this._numTotalResults === -1 || this._maxSearchIndex < this._numTotalResults)) { + this._curRequest = SearchUtil.Search(query, true, { fq: this.filterQuery, start: this._maxSearchIndex, rows: this.NumResults, hl: true, "hl.fl": "*" }).then(action(async (res: SearchUtil.DocSearchResult) => { + + // happens at the beginning + if (res.numFound !== this._numTotalResults && this._numTotalResults === -1) { + this._numTotalResults = res.numFound; + } + + const highlighting = res.highlighting || {}; + const highlightList = res.docs.map(doc => highlighting[doc[Id]]); + const lines = new Map<string, string[]>(); + res.docs.map((doc, i) => lines.set(doc[Id], res.lines[i])); + const docs = await Promise.all(res.docs.map(async doc => (await Cast(doc.extendsDoc, Doc)) || doc)); + const highlights: typeof res.highlighting = {}; + docs.forEach((doc, index) => highlights[doc[Id]] = highlightList[index]); + const filteredDocs = FilterBox.Instance.filterDocsByType(docs); + runInAction(() => { + // this._results.push(...filteredDocs); + filteredDocs.forEach(doc => { + const index = this._resultsSet.get(doc); + const highlight = highlights[doc[Id]]; + const line = lines.get(doc[Id]) || []; + const hlights = highlight ? Object.keys(highlight).map(key => key.substring(0, key.length - 2)) : []; + if (index === undefined) { + this._resultsSet.set(doc, this._results.length); + this._results.push([doc, hlights, line]); + } else { + this._results[index][1].push(...hlights); + this._results[index][2].push(...line); + } + }); + }); + + this._curRequest = undefined; + })); + this._maxSearchIndex += this.NumResults; + + await this._curRequest; + } + this.resultsScrolled(); + res(); + }); + return this.lockPromise; + } + + collectionRef = React.createRef<HTMLSpanElement>(); + startDragCollection = async () => { + const res = await this.getAllResults(FilterBox.Instance.getFinalQuery(this._searchString)); + const filtered = FilterBox.Instance.filterDocsByType(res.docs); + // console.log(this._results) + const docs = filtered.map(doc => { + const isProto = Doc.GetT(doc, "isPrototype", "boolean", true); + if (isProto) { + return Doc.MakeDelegate(doc); + } else { + return Doc.MakeAlias(doc); + } + }); + let x = 0; + let y = 0; + for (const doc of docs.map(d => Doc.Layout(d))) { + doc.x = x; + doc.y = y; + const size = 200; + const aspect = NumCast(doc._nativeHeight) / NumCast(doc._nativeWidth, 1); + if (aspect > 1) { + doc._height = size; + doc._width = size / aspect; + } else if (aspect > 0) { + doc._width = size; + doc._height = size * aspect; + } else { + doc._width = size; + doc._height = size; + } + x += 250; + if (x > 1000) { + x = 0; + y += 300; + } + } + //return Docs.Create.TreeDocument(docs, { _width: 200, _height: 400, backgroundColor: "grey", title: `Search Docs: "${this._searchString}"` }); + return Docs.Create.QueryDocument({ _width: 200, _height: 400, searchText: this._searchString, title: `Query Docs: "${this._searchString}"` }); + } + + @action.bound + openSearch(e: React.SyntheticEvent) { + e.stopPropagation(); + this._openNoResults = false; + FilterBox.Instance.closeFilter(); + this._resultsOpen = true; + this._searchbarOpen = true; + FilterBox.Instance._pointerTime = e.timeStamp; + } + + @action.bound + closeSearch = () => { + FilterBox.Instance.closeFilter(); + this.closeResults(); + this._searchbarOpen = false; + } + + @action.bound + closeResults() { + this._resultsOpen = false; + this._results = []; + this._resultsSet.clear(); + this._visibleElements = []; + this._numTotalResults = -1; + this._endIndex = -1; + this._curRequest = undefined; + } + + @action + resultsScrolled = (e?: React.UIEvent<HTMLDivElement>) => { + if (!this.resultsRef.current) return; + const scrollY = e ? e.currentTarget.scrollTop : this.resultsRef.current ? this.resultsRef.current.scrollTop : 0; + const itemHght = 53; + const startIndex = Math.floor(Math.max(0, scrollY / itemHght)); + const endIndex = Math.ceil(Math.min(this._numTotalResults - 1, startIndex + (this.resultsRef.current.getBoundingClientRect().height / itemHght))); + + this._endIndex = endIndex === -1 ? 12 : endIndex; + + if ((this._numTotalResults === 0 || this._results.length === 0) && this._openNoResults) { + this._visibleElements = [<div className="no-result">No Search Results</div>]; + return; + } + + if (this._numTotalResults <= this._maxSearchIndex) { + this._numTotalResults = this._results.length; + } + + // only hit right at the beginning + // visibleElements is all of the elements (even the ones you can't see) + else if (this._visibleElements.length !== this._numTotalResults) { + // undefined until a searchitem is put in there + this._visibleElements = Array<JSX.Element>(this._numTotalResults === -1 ? 0 : this._numTotalResults); + // indicates if things are placeholders + this._isSearch = Array<undefined>(this._numTotalResults === -1 ? 0 : this._numTotalResults); + } + + for (let i = 0; i < this._numTotalResults; i++) { + //if the index is out of the window then put a placeholder in + //should ones that have already been found get set to placeholders? + if (i < startIndex || i > endIndex) { + if (this._isSearch[i] !== "placeholder") { + this._isSearch[i] = "placeholder"; + this._visibleElements[i] = <div className="searchBox-placeholder" key={`searchBox-placeholder-${i}`}>Loading...</div>; + } + } + else { + if (this._isSearch[i] !== "search") { + let result: [Doc, string[], string[]] | undefined = undefined; + if (i >= this._results.length) { + this.getResults(this._searchString); + if (i < this._results.length) result = this._results[i]; + if (result) { + const highlights = Array.from([...Array.from(new Set(result[1]).values())]); + this._visibleElements[i] = <SearchItem doc={result[0]} query={this._searchString} key={result[0][Id]} lines={result[2]} highlighting={highlights} />; + this._isSearch[i] = "search"; + } + } + else { + result = this._results[i]; + if (result) { + const highlights = Array.from([...Array.from(new Set(result[1]).values())]); + this._visibleElements[i] = <SearchItem doc={result[0]} query={this._searchString} key={result[0][Id]} lines={result[2]} highlighting={highlights} />; + this._isSearch[i] = "search"; + } + } + } + } + } + if (this._maxSearchIndex >= this._numTotalResults) { + this._visibleElements.length = this._results.length; + this._isSearch.length = this._results.length; + } + } + + @computed + get resFull() { return this._numTotalResults <= 8; } + + @computed + get resultHeight() { return this._numTotalResults * 70; } + + render() { + const isEditing = this.editingMetadata; + return !this.content ? (null) : ( + <div style={{ pointerEvents: "all" }}> + <ContentFittingDocumentView {...this.props} + Document={this.content} + rootSelected={returnFalse} + getTransform={this.props.ScreenToLocalTransform}> + </ContentFittingDocumentView> + <div + style={{ + position: "absolute", + right: 0, + width: 20, + height: 20, + background: "black", + pointerEvents: "all", + opacity: 1, + transition: "0.4s opacity ease", + zIndex: 99, + top: 0, + }} + title={"Add Metadata"} + onClick={action(() => this.editingMetadata = !this.editingMetadata)} + /> + <div className="editableclass" onKeyPress={this.enter} style={{ opacity: isEditing ? 1 : 0, pointerEvents: isEditing ? "auto" : "none", transition: "0.4s opacity ease", position: "absolute", top: 0, left: 0, height: 20, width: "-webkit-fill-available" }}> + <EditableView + contents={this.query} + SetValue={this.updateKey} + GetValue={() => ""} + /> + </div> + </div > + ); + } + +}
\ No newline at end of file diff --git a/src/client/views/TemplateMenu.tsx b/src/client/views/TemplateMenu.tsx index f61eb9cd0..665ab4e41 100644 --- a/src/client/views/TemplateMenu.tsx +++ b/src/client/views/TemplateMenu.tsx @@ -1,17 +1,20 @@ -import { action, observable, runInAction, ObservableSet } from "mobx"; +import { action, observable, runInAction, ObservableSet, trace, computed } from "mobx"; import { observer } from "mobx-react"; import { SelectionManager } from "../util/SelectionManager"; import { undoBatch } from "../util/UndoManager"; import './TemplateMenu.scss'; import { DocumentView } from "./nodes/DocumentView"; -import { Template, Templates } from "./Templates"; +import { Template } from "./Templates"; import React = require("react"); import { Doc, DocListCast } from "../../new_fields/Doc"; +import { Docs, } from "../documents/Documents"; import { StrCast, Cast } from "../../new_fields/Types"; -import { CurrentUserUtils } from "../../server/authentication/models/current_user_utils"; -const higflyout = require("@hig/flyout"); -export const { anchorPoints } = higflyout; -export const Flyout = higflyout.default; +import { CollectionTreeView } from "./collections/CollectionTreeView"; +import { returnTrue, emptyFunction, returnFalse, returnOne, emptyPath, returnZero } from "../../Utils"; +import { Transform } from "../util/Transform"; +import { ScriptField, ComputedField } from "../../new_fields/ScriptField"; +import { Scripting } from "../util/Scripting"; +import { List } from "../../new_fields/List"; @observer class TemplateToggle extends React.Component<{ template: Template, checked: boolean, toggle: (event: React.ChangeEvent<HTMLInputElement>, template: Template) => void }> { @@ -48,10 +51,15 @@ export interface TemplateMenuProps { @observer export class TemplateMenu extends React.Component<TemplateMenuProps> { + _addedKeys = new ObservableSet(); + _customRef = React.createRef<HTMLInputElement>(); @observable private _hidden: boolean = true; toggleLayout = (e: React.ChangeEvent<HTMLInputElement>, layout: string): void => { - this.props.docViews.map(dv => dv.setCustomView(e.target.checked, layout)); + this.props.docViews.map(dv => dv.switchViews(e.target.checked, layout)); + } + toggleDefault = (e: React.ChangeEvent<HTMLInputElement>): void => { + this.props.docViews.map(dv => dv.switchViews(false, "layout")); } toggleFloat = (e: React.ChangeEvent<HTMLInputElement>): void => { @@ -62,15 +70,14 @@ export class TemplateMenu extends React.Component<TemplateMenuProps> { DocumentView.FloatDoc(topDocView, ex, ey); } + toggleAudio = (e: React.ChangeEvent<HTMLInputElement>): void => { + this.props.docViews.map(dv => dv.props.Document._showAudio = e.target.checked); + } @undoBatch @action toggleTemplate = (event: React.ChangeEvent<HTMLInputElement>, template: Template): void => { - if (event.target.checked) { - this.props.docViews.map(d => d.Document["show" + template.Name] = template.Name.toLowerCase()); - } else { - this.props.docViews.map(d => d.Document["show" + template.Name] = ""); - } + this.props.docViews.forEach(d => Doc.Layout(d.layoutDoc)["_show" + template.Name] = event.target.checked ? template.Name.toLowerCase() : ""); } @action @@ -81,10 +88,8 @@ export class TemplateMenu extends React.Component<TemplateMenuProps> { @undoBatch @action toggleChrome = (): void => { - this.props.docViews.map(dv => { - const layout = Doc.Layout(dv.Document); - layout._chromeStatus = (layout._chromeStatus !== "disabled" ? "disabled" : "enabled"); - }); + this.props.docViews.map(dv => Doc.Layout(dv.layoutDoc)).forEach(layout => + layout._chromeStatus = (layout._chromeStatus !== "disabled" ? "disabled" : StrCast(layout._replacedChrome, "enabled"))); } // todo: add brushes to brushMap to save with a style name @@ -98,28 +103,80 @@ export class TemplateMenu extends React.Component<TemplateMenuProps> { Array.from(Object.keys(Doc.GetProto(this.props.docViews[0].props.Document))). filter(key => key.startsWith("layout_")). map(key => runInAction(() => this._addedKeys.add(key.replace("layout_", "")))); - DocListCast(Cast(CurrentUserUtils.UserDocument.expandingButtons, Doc, null)?.data)?.map(btnDoc => { - if (StrCast(Cast(btnDoc?.dragFactory, Doc, null)?.title)) { - runInAction(() => this._addedKeys.add(StrCast(Cast(btnDoc?.dragFactory, Doc, null)?.title))); - } - }); } - _addedKeys = new ObservableSet(); - _customRef = React.createRef<HTMLInputElement>(); + return100 = () => 100; + @computed get scriptField() { + return ScriptField.MakeScript("docs.map(d => switchView(d, this))", { this: Doc.name, heading: "string", checked: "string", containingTreeView: Doc.name, firstDoc: Doc.name }, + { docs: new List<Doc>(this.props.docViews.map(dv => dv.props.Document)) }); + } render() { - const layout = Doc.Layout(this.props.docViews[0].Document); + const firstDoc = this.props.docViews[0].props.Document; + const templateName = StrCast(firstDoc.layoutKey, "layout").replace("layout_", ""); + const noteTypes = DocListCast(Cast(Doc.UserDoc()["template-notes"], Doc, null)); + const addedTypes = DocListCast(Cast(Doc.UserDoc().templateButtons, Doc, null)?.data); + const layout = Doc.Layout(firstDoc); const templateMenu: Array<JSX.Element> = []; this.props.templates.forEach((checked, template) => templateMenu.push(<TemplateToggle key={template.Name} template={template} checked={checked} toggle={this.toggleTemplate} />)); - templateMenu.push(<OtherToggle key={"float"} name={"Float"} checked={this.props.docViews[0].Document.z ? true : false} toggle={this.toggleFloat} />); + templateMenu.push(<OtherToggle key={"audio"} name={"Audio"} checked={firstDoc._showAudio ? true : false} toggle={this.toggleAudio} />); + templateMenu.push(<OtherToggle key={"float"} name={"Float"} checked={firstDoc.z ? true : false} toggle={this.toggleFloat} />); templateMenu.push(<OtherToggle key={"chrome"} name={"Chrome"} checked={layout._chromeStatus !== "disabled"} toggle={this.toggleChrome} />); - this._addedKeys && Array.from(this._addedKeys).map(layout => - templateMenu.push(<OtherToggle key={layout} name={layout} checked={StrCast(this.props.docViews[0].Document.layoutKey, "layout") === "layout_" + layout} toggle={e => this.toggleLayout(e, layout)} />) - ); + templateMenu.push(<OtherToggle key={"default"} name={"Default"} checked={templateName === "layout"} toggle={this.toggleDefault} />); + addedTypes.concat(noteTypes).map(template => template.treeViewChecked = ComputedField.MakeFunction(`templateIsUsed(self,firstDoc)`, {}, { firstDoc })); + this._addedKeys && Array.from(this._addedKeys).filter(key => !noteTypes.some(nt => nt.title === key)).forEach(template => templateMenu.push( + <OtherToggle key={template} name={template} checked={templateName === template} toggle={e => this.toggleLayout(e, template)} />)); return <ul className="template-list" style={{ display: "block" }}> + <input placeholder="+ layout" ref={this._customRef} onKeyPress={this.onCustomKeypress} /> {templateMenu} - <input placeholder="+ layout" ref={this._customRef} onKeyPress={this.onCustomKeypress}></input> + <CollectionTreeView + Document={Doc.UserDoc().templateDocs as Doc} + CollectionView={undefined} + ContainingCollectionDoc={undefined} + ContainingCollectionView={undefined} + rootSelected={returnFalse} + onCheckedClick={this.scriptField!} + onChildClick={this.scriptField!} + LibraryPath={emptyPath} + dropAction={undefined} + active={returnTrue} + ContentScaling={returnOne} + bringToFront={emptyFunction} + focus={emptyFunction} + whenActiveChanged={emptyFunction} + ScreenToLocalTransform={Transform.Identity} + isSelected={returnFalse} + pinToPres={emptyFunction} + select={emptyFunction} + renderDepth={1} + addDocTab={returnFalse} + NativeHeight={returnZero} + NativeWidth={returnZero} + PanelWidth={this.return100} + PanelHeight={this.return100} + treeViewHideHeaderFields={true} + annotationsKey={""} + dontRegisterView={true} + fieldKey={"data"} + moveDocument={(doc: Doc) => false} + removeDocument={(doc: Doc) => false} + addDocument={(doc: Doc) => false} /> </ul>; } -}
\ No newline at end of file +} + +Scripting.addGlobal(function switchView(doc: Doc, template: Doc | undefined) { + if (template?.dragFactory) { + template = Cast(template.dragFactory, Doc, null); + } + const templateTitle = StrCast(template?.title); + return templateTitle && Doc.makeCustomViewClicked(doc, Docs.Create.FreeformDocument, templateTitle, template); +}); + +Scripting.addGlobal(function templateIsUsed(templateDoc: Doc, selDoc: Doc) { + if (selDoc) { + const template = StrCast(templateDoc.dragFactory ? Cast(templateDoc.dragFactory, Doc, null)?.title : templateDoc.title); + return StrCast(selDoc.layoutKey) === "layout_" + template ? 'check' : 'unchecked'; + } + return false; +});
\ No newline at end of file diff --git a/src/client/views/Templates.tsx b/src/client/views/Templates.tsx index 8c60f1c36..a6dbaa650 100644 --- a/src/client/views/Templates.tsx +++ b/src/client/views/Templates.tsx @@ -1,45 +1,23 @@ -import React = require("react"); - -export enum TemplatePosition { - InnerTop, - InnerBottom, - InnerRight, - InnerLeft, - TopRight, - OutterTop, - OutterBottom, - OutterRight, - OutterLeft, -} - export class Template { - constructor(name: string, position: TemplatePosition, layout: string) { + constructor(name: string, layout: string) { this._name = name; - this._position = position; this._layout = layout; } private _name: string; - private _position: TemplatePosition; private _layout: string; get Name(): string { return this._name; } - get Position(): TemplatePosition { - return this._position; - } - get Layout(): string { return this._layout; } } export namespace Templates { - // export const BasicLayout = new Template("Basic layout", "{layout}"); - - export const Caption = new Template("Caption", TemplatePosition.OutterBottom, + export const Caption = new Template("Caption", `<div> <div style="height:100%; width:100%;">{layout}</div> <div style="bottom: 0; font-size:14px; width:100%; position:absolute"> @@ -47,16 +25,7 @@ export namespace Templates { </div> </div>` ); - export const Title = new Template("Title", TemplatePosition.InnerTop, - `<div> - <div style="height:25px; width:100%; background-color: rgba(0, 0, 0, .4); color: white; z-index: 100"> - <span style="text-align:center;width:100%;font-size:20px;position:absolute;overflow:hidden;white-space:nowrap;text-overflow:ellipsis">{props.Document.title}</span> - </div> - <div style="height:calc(100% - 25px);"> - <div style="width:100%;overflow:auto">{layout}</div> - </div> - </div>` ); - export const TitleHover = new Template("TitleHover", TemplatePosition.InnerTop, + export const Title = new Template("Title", `<div> <div style="height:25px; width:100%; background-color: rgba(0, 0, 0, .4); color: white; z-index: 100"> <span style="text-align:center;width:100%;font-size:20px;position:absolute;overflow:hidden;white-space:nowrap;text-overflow:ellipsis">{props.Document.title}</span> @@ -65,14 +34,8 @@ export namespace Templates { <div style="width:100%;overflow:auto">{layout}</div> </div> </div>` ); + export const TitleHover = new Template("TitleHover", Title.Layout); export const TemplateList: Template[] = [Title, TitleHover, Caption]; - - export function sortTemplates(a: Template, b: Template) { - if (a.Position < b.Position) { return -1; } - if (a.Position > b.Position) { return 1; } - return 0; - } - } diff --git a/src/client/views/TouchScrollableMenu.tsx b/src/client/views/TouchScrollableMenu.tsx index 4bda0818e..969605be9 100644 --- a/src/client/views/TouchScrollableMenu.tsx +++ b/src/client/views/TouchScrollableMenu.tsx @@ -44,7 +44,7 @@ export default class TouchScrollableMenu extends React.Component<TouchScrollable <div className="shadow" style={{ height: `calc(100% - 25px - ${this.selectedIndex * 25}px)` }}> </div> </div> - ) + ); } } @@ -54,6 +54,6 @@ export class TouchScrollableMenuItem extends React.Component<TouchScrollableMenu <div className="menuItem-cont" onClick={this.props.onClick}> {this.props.text} </div> - ) + ); } }
\ No newline at end of file diff --git a/src/client/views/Touchable.tsx b/src/client/views/Touchable.tsx index 7800c4019..10d023d83 100644 --- a/src/client/views/Touchable.tsx +++ b/src/client/views/Touchable.tsx @@ -8,9 +8,11 @@ const HOLD_DURATION = 1000; export abstract class Touchable<T = {}> extends React.Component<T> { //private holdTimer: NodeJS.Timeout | undefined; - private holdTimer: NodeJS.Timeout | undefined; private moveDisposer?: InteractionUtils.MultiTouchEventDisposer; private endDisposer?: InteractionUtils.MultiTouchEventDisposer; + private holdMoveDisposer?: InteractionUtils.MultiTouchEventDisposer; + private holdEndDisposer?: InteractionUtils.MultiTouchEventDisposer; + protected abstract multiTouchDisposer?: InteractionUtils.MultiTouchEventDisposer; protected _touchDrag: boolean = false; @@ -26,6 +28,7 @@ export abstract class Touchable<T = {}> extends React.Component<T> { */ @action protected onTouchStart = (e: Event, me: InteractionUtils.MultiTouchEvent<React.TouchEvent>): void => { + const actualPts: React.Touch[] = []; const te = me.touchEvent; // loop through all touches on screen @@ -39,7 +42,7 @@ export abstract class Touchable<T = {}> extends React.Component<T> { if (pt.clientX === tPt.clientX && pt.clientY === tPt.clientY) { // pen is also a touch, but with a radius of 0.5 (at least with the surface pens) // and this seems to be the only way of differentiating pen and touch on touch events - if (pt.radiusX > 1 && pt.radiusY > 1) { + if ((pt as any).radiusX > 1 && (pt as any).radiusY > 1) { this.prevPoints.set(pt.identifier, pt); } } @@ -61,21 +64,15 @@ export abstract class Touchable<T = {}> extends React.Component<T> { case 1: this.handle1PointerDown(te, me); te.persist(); + // -- code for radial menu -- // if (this.holdTimer) { // clearTimeout(this.holdTimer) // this.holdTimer = undefined; // } - this.holdTimer = setTimeout(() => this.handle1PointerHoldStart(te, me), HOLD_DURATION); - // e.stopPropagation(); - // console.log(this.holdTimer); break; case 2: this.handle2PointersDown(te, me); - // e.stopPropagation(); break; - // case 5: - // this.handleHandDown(te); - // break; } } } @@ -91,11 +88,6 @@ export abstract class Touchable<T = {}> extends React.Component<T> { // if we're not actually moving a lot, don't consider it as dragging yet if (!InteractionUtils.IsDragging(this.prevPoints, myTouches, 5) && !this._touchDrag) return; this._touchDrag = true; - if (this.holdTimer) { - console.log("CLEAR"); - clearTimeout(this.holdTimer); - // this.holdTimer = undefined; - } // console.log(myTouches.length); switch (myTouches.length) { case 1: @@ -127,10 +119,6 @@ export abstract class Touchable<T = {}> extends React.Component<T> { } } } - if (this.holdTimer) { - clearTimeout(this.holdTimer); - console.log("clear"); - } this._touchDrag = false; te.stopPropagation(); @@ -174,10 +162,16 @@ export abstract class Touchable<T = {}> extends React.Component<T> { this.addEndListeners(); } - handle1PointerHoldStart = (e: React.TouchEvent, me: InteractionUtils.MultiTouchEvent<React.TouchEvent>): any => { + handle1PointerHoldStart = (e: Event, me: InteractionUtils.MultiTouchEvent<React.TouchEvent>): any => { e.stopPropagation(); - e.preventDefault(); + me.touchEvent.stopPropagation(); this.removeMoveListeners(); + this.removeEndListeners(); + this.removeHoldMoveListeners(); + this.removeHoldEndListeners(); + this.addHoldMoveListeners(); + this.addHoldEndListeners(); + } addMoveListeners = () => { @@ -200,6 +194,44 @@ export abstract class Touchable<T = {}> extends React.Component<T> { this.endDisposer && this.endDisposer(); } + addHoldMoveListeners = () => { + const handler = (e: Event) => this.handle1PointerHoldMove(e, (e as CustomEvent<InteractionUtils.MultiTouchEvent<TouchEvent>>).detail); + document.addEventListener("dashOnTouchHoldMove", handler); + this.holdMoveDisposer = () => document.removeEventListener("dashOnTouchHoldMove", handler); + } + + addHoldEndListeners = () => { + const handler = (e: Event) => this.handle1PointerHoldEnd(e, (e as CustomEvent<InteractionUtils.MultiTouchEvent<TouchEvent>>).detail); + document.addEventListener("dashOnTouchHoldEnd", handler); + this.holdEndDisposer = () => document.removeEventListener("dashOnTouchHoldEnd", handler); + } + + removeHoldMoveListeners = () => { + this.holdMoveDisposer && this.holdMoveDisposer(); + } + + removeHoldEndListeners = () => { + this.holdEndDisposer && this.holdEndDisposer(); + } + + + handle1PointerHoldMove = (e: Event, me: InteractionUtils.MultiTouchEvent<TouchEvent>): void => { + // e.stopPropagation(); + // me.touchEvent.stopPropagation(); + } + + + handle1PointerHoldEnd = (e: Event, me: InteractionUtils.MultiTouchEvent<TouchEvent>): void => { + e.stopPropagation(); + me.touchEvent.stopPropagation(); + this.removeHoldMoveListeners(); + this.removeHoldEndListeners(); + + me.touchEvent.stopPropagation(); + me.touchEvent.preventDefault(); + } + + handleHandDown = (e: React.TouchEvent) => { // e.stopPropagation(); // e.preventDefault(); diff --git a/src/client/views/collections/CollectionCarouselView.scss b/src/client/views/collections/CollectionCarouselView.scss index 4815f1a59..a9a1898f5 100644 --- a/src/client/views/collections/CollectionCarouselView.scss +++ b/src/client/views/collections/CollectionCarouselView.scss @@ -1,6 +1,7 @@ .collectionCarouselView-outer { background: gray; + height : 100%; .collectionCarouselView-caption { margin-left: 10%; margin-right: 10%; @@ -12,29 +13,26 @@ height: calc(100% - 50px); display: inline-block; width: 100%; + user-select: none; } } -.carouselView-back { +.carouselView-back, .carouselView-fwd { position: absolute; display: flex; - left: 0; top: 50%; width: 30; height: 30; - background: lightgray; align-items: center; border-radius: 5px; justify-content: center; + background : rgba(255, 255, 255, 0.46); } -.carouselView-fwd { - position: absolute; - display: flex; +.carouselView-fwd { right: 0; - top: 50%; - width: 30; - height: 30; +} +.carouselView-back { + left: 0; +} +.carouselView-back:hover, .carouselView-fwd:hover { background: lightgray; - align-items: center; - border-radius: 5px; - justify-content: center; }
\ No newline at end of file diff --git a/src/client/views/collections/CollectionCarouselView.tsx b/src/client/views/collections/CollectionCarouselView.tsx index 00edf71dd..eda8e5684 100644 --- a/src/client/views/collections/CollectionCarouselView.tsx +++ b/src/client/views/collections/CollectionCarouselView.tsx @@ -11,26 +11,23 @@ import "./CollectionCarouselView.scss"; import { CollectionSubView } from './CollectionSubView'; import { faCaretLeft, faCaretRight } from '@fortawesome/free-solid-svg-icons'; import { Doc } from '../../../new_fields/Doc'; -import { FormattedTextBox } from '../nodes/FormattedTextBox'; +import { FormattedTextBox } from '../nodes/formattedText/FormattedTextBox'; +import { ContextMenu } from '../ContextMenu'; +import { ObjectField } from '../../../new_fields/ObjectField'; type CarouselDocument = makeInterface<[typeof documentSchema,]>; const CarouselDocument = makeInterface(documentSchema); @observer export class CollectionCarouselView extends CollectionSubView(CarouselDocument) { - @observable public addMenuToggle = React.createRef<HTMLInputElement>(); private _dropDisposer?: DragManager.DragDropDisposer; - componentWillUnmount() { - this._dropDisposer && this._dropDisposer(); - } + componentWillUnmount() { this._dropDisposer?.(); } - componentDidMount() { - } protected createDashEventsTarget = (ele: HTMLDivElement) => { //used for stacking and masonry view - this._dropDisposer && this._dropDisposer(); + this._dropDisposer?.(); if (ele) { - this._dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this)); + this._dropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.layoutDoc); } } @@ -47,18 +44,27 @@ export class CollectionCarouselView extends CollectionSubView(CarouselDocument) @computed get content() { const index = NumCast(this.layoutDoc._itemIndex); return !(this.childLayoutPairs?.[index]?.layout instanceof Doc) ? (null) : - <div> - <div className="collectionCarouselView-image"> + <> + <div className="collectionCarouselView-image" key="image"> <ContentFittingDocumentView {...this.props} + renderDepth={this.props.renderDepth + 1} Document={this.childLayoutPairs[index].layout} DataDocument={this.childLayoutPairs[index].data} PanelHeight={this.panelHeight} getTransform={this.props.ScreenToLocalTransform} /> </div> - <div className="collectionCarouselView-caption" style={{ background: `${StrCast(this.props.Document.backgroundColor)}` }}> - <FormattedTextBox key={index} {...this.props} Document={this.childLayoutPairs[index].layout} DataDoc={undefined} fieldKey={"caption"}></FormattedTextBox> + <div className="collectionCarouselView-caption" key="caption" + style={{ + background: StrCast(this.layoutDoc._captionBackgroundColor, this.props.backgroundColor?.(this.props.Document)), + color: StrCast(this.layoutDoc._captionColor, StrCast(this.layoutDoc.color)), + borderRadius: StrCast(this.layoutDoc._captionBorderRounding), + }}> + <FormattedTextBox key={index} {...this.props} + xMargin={NumCast(this.layoutDoc["caption-xMargin"])} + yMargin={NumCast(this.layoutDoc["caption-yMargin"])} + Document={this.childLayoutPairs[index].layout} DataDoc={undefined} fieldKey={"caption"}></FormattedTextBox> </div> - </div> + </>; } @computed get buttons() { return <> @@ -70,10 +76,46 @@ export class CollectionCarouselView extends CollectionSubView(CarouselDocument) </div> </>; } + + + onContextMenu = (e: React.MouseEvent): void => { + // need to test if propagation has stopped because GoldenLayout forces a parallel react hierarchy to be created for its top-level layout + if (!e.isPropagationStopped()) { + ContextMenu.Instance.addItem({ + description: "Make Hero Image", event: () => { + const index = NumCast(this.layoutDoc._itemIndex); + (this.dataDoc || Doc.GetProto(this.props.Document)).hero = ObjectField.MakeCopy(this.childLayoutPairs[index].layout.data as ObjectField); + }, icon: "plus" + }); + } + } + _downX = 0; + _downY = 0; + onPointerDown = (e: React.PointerEvent) => { + this._downX = e.clientX; + this._downY = e.clientY; + console.log("CAROUSEL down"); + document.addEventListener("pointerup", this.onpointerup); + } + private _lastTap: number = 0; + private _doubleTap = false; + onpointerup = (e: PointerEvent) => { + console.log("CAROUSEL up"); + this._doubleTap = (Date.now() - this._lastTap < 300 && e.button === 0 && Math.abs(e.clientX - this._downX) < 2 && Math.abs(e.clientY - this._downY) < 2); + this._lastTap = Date.now(); + } + + onClick = (e: React.MouseEvent) => { + if (this._doubleTap) { + e.stopPropagation(); + this.props.Document.isLightboxOpen = true; + } + } + render() { - return <div className="collectionCarouselView-outer" ref={this.createDashEventsTarget}> + return <div className="collectionCarouselView-outer" onClick={this.onClick} onPointerDown={this.onPointerDown} ref={this.createDashEventsTarget} onContextMenu={this.onContextMenu}> {this.content} - {this.buttons} + {this.props.Document._chromeStatus !== "replaced" ? this.buttons : (null)} </div>; } }
\ No newline at end of file diff --git a/src/client/views/collections/CollectionDockingView.scss b/src/client/views/collections/CollectionDockingView.scss index f518ef8fb..2fafcecb2 100644 --- a/src/client/views/collections/CollectionDockingView.scss +++ b/src/client/views/collections/CollectionDockingView.scss @@ -1,8 +1,34 @@ @import "../../views/globalCssVariables.scss"; -.lm_active .messageCounter { - color: white; - background: #999999; +.lm_title { + margin-top: 3px; + background: black; + border-radius: 5px; + border: solid 1px dimgray; + border-width: 2px 2px 0px; + height: 20px; + transform: translate(0px, -3px); +} +.lm_title_wrap { + overflow: hidden; + height: 19px; + margin-top: -3px; + display:inline-block; +} +.lm_active .lm_title { + border: solid 1px lightgray; +} +.lm_header .lm_tab .lm_close_tab { + position: absolute; + text-align: center; +} + +.lm_header .lm_tab { + padding-right : 20px; +} + +.lm_popout { + display:none; } .messageCounter { @@ -26,9 +52,20 @@ top: 0; left: 0; // overflow: hidden; // bcz: menus don't show up when this is on (e.g., the parentSelectorMenu) - + .collectionDockingView-gear { + padding-left: 5px; + height: 15px; + width: 18px; + display: inline-block; + margin: auto; + } .collectionDockingView-dragAsDocument { touch-action: none; + position: absolute; + padding-left: 5px; + display: inline-block; + width: 100%; + height: 100%; } .lm_content { diff --git a/src/client/views/collections/CollectionDockingView.tsx b/src/client/views/collections/CollectionDockingView.tsx index cb413b3e3..0d859c3f1 100644 --- a/src/client/views/collections/CollectionDockingView.tsx +++ b/src/client/views/collections/CollectionDockingView.tsx @@ -1,26 +1,25 @@ import { library } from '@fortawesome/fontawesome-svg-core'; import { faFile } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import 'golden-layout/src/css/goldenlayout-base.css'; import 'golden-layout/src/css/goldenlayout-dark-theme.css'; -import { action, Lambda, observable, reaction, computed, runInAction, trace } from "mobx"; +import { action, computed, Lambda, observable, reaction, runInAction, trace } from "mobx"; import { observer } from "mobx-react"; import * as ReactDOM from 'react-dom'; import Measure from "react-measure"; import * as GoldenLayout from "../../../client/goldenLayout"; import { DateField } from '../../../new_fields/DateField'; -import { Doc, DocListCast, Field, Opt } from "../../../new_fields/Doc"; +import { Doc, DocListCast, Field, Opt, DataSym } from "../../../new_fields/Doc"; import { Id } from '../../../new_fields/FieldSymbols'; import { List } from '../../../new_fields/List'; import { FieldId } from "../../../new_fields/RefField"; -import { listSpec } from "../../../new_fields/Schema"; -import { BoolCast, Cast, NumCast, StrCast } from "../../../new_fields/Types"; -import { CurrentUserUtils } from '../../../server/authentication/models/current_user_utils'; -import { emptyFunction, returnEmptyString, returnFalse, returnOne, returnTrue, Utils } from "../../../Utils"; +import { Cast, NumCast, StrCast } from "../../../new_fields/Types"; +import { TraceMobx } from '../../../new_fields/util'; +import { emptyFunction, returnOne, returnTrue, Utils, returnZero } from "../../../Utils"; import { DocServer } from "../../DocServer"; import { Docs } from '../../documents/Documents'; import { DocumentManager } from '../../util/DocumentManager'; -import { DragManager } from "../../util/DragManager"; +import { DragManager, dropActionType } from "../../util/DragManager"; +import { Scripting } from '../../util/Scripting'; import { SelectionManager } from '../../util/SelectionManager'; import { Transform } from '../../util/Transform'; import { undoBatch } from "../../util/UndoManager"; @@ -28,14 +27,9 @@ import { MainView } from '../MainView'; import { DocumentView } from "../nodes/DocumentView"; import "./CollectionDockingView.scss"; import { SubCollectionViewProps } from "./CollectionSubView"; +import { DockingViewButtonSelector } from './ParentDocumentSelector'; import React = require("react"); -import { ButtonSelector } from './ParentDocumentSelector'; -import { DocumentType } from '../../documents/DocumentTypes'; -import { ComputedField } from '../../../new_fields/ScriptField'; -import { InteractionUtils } from '../../util/InteractionUtils'; -import { TraceMobx } from '../../../new_fields/util'; -import { Scripting } from '../../util/Scripting'; -import { PresElementBox } from '../presentationview/PresElementBox'; +import { CollectionViewType } from './CollectionView'; library.add(faFile); const _global = (window /* browser */ || global /* node */) as any; @@ -43,7 +37,7 @@ const _global = (window /* browser */ || global /* node */) as any; export class CollectionDockingView extends React.Component<SubCollectionViewProps> { @observable public static Instances: CollectionDockingView[] = []; @computed public static get Instance() { return CollectionDockingView.Instances[0]; } - public static makeDocumentConfig(document: Doc, dataDoc: Doc | undefined, width?: number, libraryPath?: Doc[]) { + public static makeDocumentConfig(document: Doc, width?: number, libraryPath?: Doc[]) { return { type: 'react-component', component: 'DocumentFrameRenderer', @@ -51,8 +45,7 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp width: width, props: { documentId: document[Id], - dataDocumentId: dataDoc && dataDoc[Id] !== document[Id] ? dataDoc[Id] : "", - libraryPath: libraryPath ? libraryPath.map(d => d[Id]) : [] + libraryPath: libraryPath?.map(d => d[Id]) //collectionDockingView: CollectionDockingView.Instance } }; @@ -81,12 +74,12 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp public StartOtherDrag(e: any, dragDocs: Doc[]) { let config: any; if (dragDocs.length === 1) { - config = CollectionDockingView.makeDocumentConfig(dragDocs[0], undefined); + config = CollectionDockingView.makeDocumentConfig(dragDocs[0]); } else { config = { type: 'row', content: dragDocs.map((doc, i) => { - CollectionDockingView.makeDocumentConfig(doc, undefined); + CollectionDockingView.makeDocumentConfig(doc); }) }; } @@ -101,11 +94,13 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp @undoBatch @action public OpenFullScreen(docView: DocumentView, libraryPath?: Doc[]) { + if (docView.props.Document._viewType === CollectionViewType.Docking && docView.props.Document.layoutKey === "layout") { + return MainView.Instance.openWorkspace(docView.props.Document); + } const document = Doc.MakeAlias(docView.props.Document); - const dataDoc = docView.props.DataDoc; const newItemStackConfig = { type: 'stack', - content: [CollectionDockingView.makeDocumentConfig(document, dataDoc, undefined, libraryPath)] + content: [CollectionDockingView.makeDocumentConfig(document, undefined, libraryPath)] }; const docconfig = this._goldenLayout.root.layoutManager.createContentItem(newItemStackConfig, this._goldenLayout); this._goldenLayout.root.contentItems[0].addChild(docconfig); @@ -134,36 +129,22 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp @undoBatch @action public static CloseRightSplit(document: Opt<Doc>): boolean { - if (!CollectionDockingView.Instance) return false; const instance = CollectionDockingView.Instance; - let retVal = false; - if (instance._goldenLayout.root.contentItems[0].isRow) { - retVal = Array.from(instance._goldenLayout.root.contentItems[0].contentItems).some((child: any) => { - if (child.contentItems.length === 1 && child.contentItems[0].config.component === "DocumentFrameRenderer" && - DocumentManager.Instance.getDocumentViewById(child.contentItems[0].config.props.documentId) && - ((!document && DocumentManager.Instance.getDocumentViewById(child.contentItems[0].config.props.documentId)!.Document.isDisplayPanel) || - (document && Doc.AreProtosEqual(DocumentManager.Instance.getDocumentViewById(child.contentItems[0].config.props.documentId)!.Document, document)))) { - child.contentItems[0].remove(); + const tryClose = (childItem: any) => { + if (childItem.config?.component === "DocumentFrameRenderer") { + const docView = DocumentManager.Instance.getDocumentViewById(childItem.config.props.documentId); + if (docView && ((!document && docView.Document.isDisplayPanel) || (document && Doc.AreProtosEqual(docView.props.Document, document)))) { + childItem.remove(); instance.layoutChanged(document); return true; - } else { - Array.from(child.contentItems).filter((tab: any) => tab.config.component === "DocumentFrameRenderer").some((tab: any, j: number) => { - if (DocumentManager.Instance.getDocumentViewById(tab.config.props.documentId) && - ((!document && DocumentManager.Instance.getDocumentViewById(tab.config.props.documentId)!.Document.isDisplayPanel) || - (document && Doc.AreProtosEqual(DocumentManager.Instance.getDocumentViewById(tab.config.props.documentId)!.Document, document)))) { - child.contentItems[j].remove(); - child.config.activeItemIndex = Math.max(child.contentItems.length - 1, 0); - return true; - } - return false; - }); } - return false; - }); - } - if (retVal) { - instance.stateChanged(); - } + } + return false; + }; + const retVal = !instance?._goldenLayout.root.contentItems[0].isRow ? false : + Array.from(instance._goldenLayout.root.contentItems[0].contentItems).some((child: any) => Array.from(child.contentItems).some(tryClose)); + + retVal && instance.stateChanged(); return retVal; } @@ -177,7 +158,7 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp } @undoBatch @action - public static ReplaceRightSplit(document: Doc, dataDoc: Doc | undefined, libraryPath?: Doc[], addToSplit?: boolean): boolean { + public static ReplaceRightSplit(document: Doc, libraryPath?: Doc[], addToSplit?: boolean): boolean { if (!CollectionDockingView.Instance) return false; const instance = CollectionDockingView.Instance; let retVal = false; @@ -185,7 +166,7 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp retVal = Array.from(instance._goldenLayout.root.contentItems[0].contentItems).some((child: any) => { if (child.contentItems.length === 1 && child.contentItems[0].config.component === "DocumentFrameRenderer" && DocumentManager.Instance.getDocumentViewById(child.contentItems[0].config.props.documentId)?.Document.isDisplayPanel) { - const newItemStackConfig = CollectionDockingView.makeDocumentConfig(document, dataDoc, undefined, libraryPath); + const newItemStackConfig = CollectionDockingView.makeDocumentConfig(document, undefined, libraryPath); child.addChild(newItemStackConfig, undefined); !addToSplit && child.contentItems[0].remove(); instance.layoutChanged(document); @@ -193,7 +174,7 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp } return Array.from(child.contentItems).filter((tab: any) => tab.config.component === "DocumentFrameRenderer").some((tab: any, j: number) => { if (DocumentManager.Instance.getDocumentViewById(tab.config.props.documentId)?.Document.isDisplayPanel) { - const newItemStackConfig = CollectionDockingView.makeDocumentConfig(document, dataDoc, undefined, libraryPath); + const newItemStackConfig = CollectionDockingView.makeDocumentConfig(document, undefined, libraryPath); child.addChild(newItemStackConfig, undefined); !addToSplit && child.contentItems[j].remove(); instance.layoutChanged(document); @@ -215,12 +196,12 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp // @undoBatch @action - public static AddRightSplit(document: Doc, dataDoc: Doc | undefined, libraryPath?: Doc[]) { + public static AddRightSplit(document: Doc, libraryPath?: Doc[]) { if (!CollectionDockingView.Instance) return false; const instance = CollectionDockingView.Instance; const newItemStackConfig = { type: 'stack', - content: [CollectionDockingView.makeDocumentConfig(document, dataDoc, undefined, libraryPath)] + content: [CollectionDockingView.makeDocumentConfig(document, undefined, libraryPath)] }; const newContentItem = instance._goldenLayout.root.layoutManager.createContentItem(newItemStackConfig, instance._goldenLayout); @@ -245,23 +226,92 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp return true; } + + // + // Creates a split on any side of the docking view based on the passed input pullSide and then adds the Document to the requested side + // + @undoBatch + @action + public static AddSplit(document: Doc, pullSide: string, libraryPath?: Doc[]) { + if (!CollectionDockingView.Instance) return false; + const instance = CollectionDockingView.Instance; + const newItemStackConfig = { + type: 'stack', + content: [CollectionDockingView.makeDocumentConfig(document, undefined, libraryPath)] + }; + + const newContentItem = instance._goldenLayout.root.layoutManager.createContentItem(newItemStackConfig, instance._goldenLayout); + + if (instance._goldenLayout.root.contentItems.length === 0) { // if no rows / columns + instance._goldenLayout.root.addChild(newContentItem); + } else if (instance._goldenLayout.root.contentItems[0].isRow) { // if row + if (pullSide === "left") { + instance._goldenLayout.root.contentItems[0].addChild(newContentItem, 0); + } else if (pullSide === "right") { + instance._goldenLayout.root.contentItems[0].addChild(newContentItem); + } else if (pullSide === "top" || pullSide === "bottom") { + // if not going in a row layout, must add already existing content into column + const rowlayout = instance._goldenLayout.root.contentItems[0]; + const newColumn = rowlayout.layoutManager.createContentItem({ type: "column" }, instance._goldenLayout); + rowlayout.parent.replaceChild(rowlayout, newColumn); + if (pullSide === "top") { + newColumn.addChild(rowlayout, undefined, true); + newColumn.addChild(newContentItem, 0, true); + } else if (pullSide === "bottom") { + newColumn.addChild(newContentItem, undefined, true); + newColumn.addChild(rowlayout, 0, true); + } + + rowlayout.config.height = 50; + newContentItem.config.height = 50; + } + } else if (instance._goldenLayout.root.contentItems[0].isColumn) { // if column + if (pullSide === "top") { + instance._goldenLayout.root.contentItems[0].addChild(newContentItem, 0); + } else if (pullSide === "bottom") { + instance._goldenLayout.root.contentItems[0].addChild(newContentItem); + } else if (pullSide === "left" || pullSide === "right") { + // if not going in a row layout, must add already existing content into column + const collayout = instance._goldenLayout.root.contentItems[0]; + const newRow = collayout.layoutManager.createContentItem({ type: "row" }, instance._goldenLayout); + collayout.parent.replaceChild(collayout, newRow); + + if (pullSide === "left") { + newRow.addChild(collayout, undefined, true); + newRow.addChild(newContentItem, 0, true); + } else if (pullSide === "right") { + newRow.addChild(newContentItem, undefined, true); + newRow.addChild(collayout, 0, true); + } + + collayout.config.width = 50; + newContentItem.config.width = 50; + } + } + + newContentItem.callDownwards('_$init'); + instance.layoutChanged(); + return true; + } + + // // Creates a vertical split on the right side of the docking view, and then adds the Document to that split // @undoBatch @action - public static UseRightSplit(document: Doc, dataDoc: Doc | undefined, libraryPath?: Doc[], shiftKey?: boolean) { + public static UseRightSplit(document: Doc, libraryPath?: Doc[], shiftKey?: boolean) { document.isDisplayPanel = true; - if (shiftKey || !CollectionDockingView.ReplaceRightSplit(document, dataDoc, libraryPath, shiftKey)) { - CollectionDockingView.AddRightSplit(document, dataDoc, libraryPath); + if (shiftKey || !CollectionDockingView.ReplaceRightSplit(document, libraryPath, shiftKey)) { + CollectionDockingView.AddRightSplit(document, libraryPath); } } @undoBatch @action - public AddTab = (stack: any, document: Doc, dataDocument: Doc | undefined, libraryPath?: Doc[]) => { + public AddTab = (stack: any, document: Doc, libraryPath?: Doc[]) => { Doc.GetProto(document).lastOpened = new DateField; - const docContentConfig = CollectionDockingView.makeDocumentConfig(document, dataDocument, undefined, libraryPath); + const docContentConfig = CollectionDockingView.makeDocumentConfig(document, undefined, libraryPath); if (stack === undefined) { let stack: any = this._goldenLayout.root; while (!stack.isStack) { @@ -328,8 +378,7 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp // Because this is in a set timeout, if this component unmounts right after mounting, // we will leak a GoldenLayout, because we try to destroy it before we ever create it setTimeout(() => this.setupGoldenLayout(), 1); - const userDoc = CurrentUserUtils.UserDocument; - userDoc && DocListCast((userDoc.workspaces as Doc).data).map(d => d.workspaceBrush = false); + DocListCast((Doc.UserDoc().myWorkspaces as Doc).data).map(d => d.workspaceBrush = false); this.props.Document.workspaceBrush = true; } this._ignoreStateChange = ""; @@ -384,16 +433,6 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp }); window.addEventListener("pointerup", onPointerUp); const className = (e.target as any).className; - if (className === "messageCounter") { - e.stopPropagation(); - e.preventDefault(); - const x = e.clientX; - const y = e.clientY; - const docid = (e.target as any).DashDocId; - const tab = (e.target as any).parentElement as HTMLElement; - DocServer.GetRefField(docid).then(action(async (sourceDoc: Opt<Field>) => - (sourceDoc instanceof Doc) && DragManager.StartLinkTargetsDrag(tab, x, y, sourceDoc))); - } if (className === "lm_drag_handle" || className === "lm_close" || className === "lm_maximise" || className === "lm_minimise" || className === "lm_close_tab") { this._flush = true; } @@ -435,24 +474,28 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp } tabCreated = async (tab: any) => { + tab.titleElement[0].Tab = tab; if (tab.hasOwnProperty("contentItem") && tab.contentItem.config.type !== "stack") { if (tab.contentItem.config.fixed) { tab.contentItem.parent.config.fixed = true; } const doc = await DocServer.GetRefField(tab.contentItem.config.props.documentId) as Doc; - const dataDoc = await DocServer.GetRefField(tab.contentItem.config.props.dataDocumentId) as Doc; if (doc instanceof Doc) { - const dragSpan = document.createElement("span"); - dragSpan.style.position = "relative"; - dragSpan.style.bottom = "6px"; - dragSpan.style.paddingLeft = "4px"; - dragSpan.style.paddingRight = "2px"; + //tab.titleElement[0].outerHTML = `<input class='lm_title' style="background:black" value='${doc.title}' />`; + tab.titleElement[0].onclick = (e: any) => tab.titleElement[0].focus(); + tab.titleElement[0].onchange = (e: any) => { + tab.titleElement[0].size = e.currentTarget.value.length + 1; + Doc.GetProto(doc).title = e.currentTarget.value, true; + }; + tab.titleElement[0].size = StrCast(doc.title).length + 1; + tab.titleElement[0].value = doc.title; + tab.titleElement[0].style["max-width"] = "100px"; const gearSpan = document.createElement("span"); + gearSpan.className = "collectionDockingView-gear"; gearSpan.style.position = "relative"; gearSpan.style.paddingLeft = "0px"; gearSpan.style.paddingRight = "12px"; - const upDiv = document.createElement("span"); const stack = tab.contentItem.parent; // shifts the focus to this tab when another tab is dragged over it tab.element[0].onmouseenter = (e: any) => { @@ -463,42 +506,47 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp } tab.setActive(true); }; - ReactDOM.render(<span title="Drag as document" - className="collectionDockingView-dragAsDocument" - onPointerDown={e => { + const onDown = (e: React.PointerEvent) => { + if (!(e.nativeEvent as any).defaultPrevented) { e.preventDefault(); e.stopPropagation(); const dragData = new DragManager.DocumentDragData([doc]); - dragData.dropAction = doc.dropAction === "alias" ? "alias" : doc.dropAction === "copy" ? "copy" : undefined; - DragManager.StartDocumentDrag([dragSpan], dragData, e.clientX, e.clientY); - }}> - <FontAwesomeIcon icon="file" size="lg" /> - </span>, dragSpan); - ReactDOM.render(<ButtonSelector Document={doc} Stack={stack} />, gearSpan); - tab.reactComponents = [dragSpan, gearSpan, upDiv]; - tab.element.append(dragSpan); + dragData.dropAction = doc.dropAction as dropActionType; + DragManager.StartDocumentDrag([gearSpan], dragData, e.clientX, e.clientY); + } + }; + let rendered = false; + tab.buttonDisposer = reaction(() => ((view: Opt<DocumentView>) => view ? [view] : [])(DocumentManager.Instance.getDocumentView(doc)), + (views) => { + !rendered && ReactDOM.render(<span title="Drag as document" className="collectionDockingView-dragAsDocument" onPointerDown={onDown} > + <DockingViewButtonSelector views={views} Stack={stack} /> + </span>, + gearSpan); + rendered = true; + }); + + tab.reactComponents = [gearSpan]; tab.element.append(gearSpan); - tab.element.append(upDiv); - tab.reactionDisposer = reaction(() => [doc.title, Doc.IsBrushedDegree(doc)], () => { - tab.titleElement[0].textContent = doc.title, { fireImmediately: true }; - tab.titleElement[0].style.outline = `${["transparent", "white", "white"][Doc.IsBrushedDegreeUnmemoized(doc)]} ${["none", "dashed", "solid"][Doc.IsBrushedDegreeUnmemoized(doc)]} 1px`; + tab.reactionDisposer = reaction(() => ({ title: doc.title, degree: Doc.IsBrushedDegree(doc) }), ({ title, degree }) => { + tab.titleElement[0].textContent = title, { fireImmediately: true }; + tab.titleElement[0].style.padding = degree ? 0 : 2; + tab.titleElement[0].style.border = `${["gray", "gray", "gray"][degree]} ${["none", "dashed", "solid"][degree]} 2px`; }); //TODO why can't this just be doc instead of the id? tab.titleElement[0].DashDocId = tab.contentItem.config.props.documentId; } } - tab.titleElement[0].Tab = tab; tab.closeElement.off('click') //unbind the current click handler .click(async function () { - tab.reactionDisposer && tab.reactionDisposer(); + tab.reactionDisposer?.(); + tab.buttonDisposer?.(); const doc = await DocServer.GetRefField(tab.contentItem.config.props.documentId); if (doc instanceof Doc) { const theDoc = doc; CollectionDockingView.Instance._removedDocs.push(theDoc); - const userDoc = CurrentUserUtils.UserDocument; - let recent: Doc | undefined; - if (userDoc && (recent = await Cast(CurrentUserUtils.UserDocument.recentlyClosed, Doc))) { + const recent = await Cast(Doc.UserDoc().myRecentlyClosed, Doc); + if (recent) { Doc.AddDocToList(recent, "data", doc, undefined, true, true); } SelectionManager.DeselectAll(); @@ -523,7 +571,7 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp stack.header.element[0].style.backgroundColor = DocServer.Control.isReadOnly() ? "#228540" : undefined; stack.header.element.on('mousedown', (e: any) => { if (e.target === stack.header.element[0] && e.button === 1) { - this.AddTab(stack, Docs.Create.FreeformDocument([], { _width: this.props.PanelWidth(), _height: this.props.PanelHeight(), title: "Untitled Collection" }), undefined); + this.AddTab(stack, Docs.Create.FreeformDocument([], { _width: this.props.PanelWidth(), _height: this.props.PanelHeight(), title: "Untitled Collection" })); } }); @@ -558,7 +606,7 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp const doc = await DocServer.GetRefField(contentItem.config.props.documentId); if (doc instanceof Doc) { let recent: Doc | undefined; - if (CurrentUserUtils.UserDocument && (recent = await Cast(CurrentUserUtils.UserDocument.recentlyClosed, Doc))) { + if (recent = await Cast(Doc.UserDoc().myRecentlyClosed, Doc)) { Doc.AddDocToList(recent, "data", doc, undefined, true, true); } const theDoc = doc; @@ -596,9 +644,9 @@ export class CollectionDockingView extends React.Component<SubCollectionViewProp interface DockedFrameProps { documentId: FieldId; - dataDocumentId: FieldId; glContainer: any; libraryPath: (FieldId[]); + backgroundColor?: (doc: Doc) => string | undefined; //collectionDockingView: CollectionDockingView } @observer @@ -608,7 +656,6 @@ export class DockedFrameRenderer extends React.Component<DockedFrameProps> { @observable private _panelWidth = 0; @observable private _panelHeight = 0; @observable private _document: Opt<Doc>; - @observable private _dataDoc: Opt<Doc>; @observable private _isActive: boolean = false; get _stack(): any { @@ -616,12 +663,7 @@ export class DockedFrameRenderer extends React.Component<DockedFrameProps> { } constructor(props: any) { super(props); - DocServer.GetRefField(this.props.documentId).then(action((f: Opt<Field>) => { - this._document = f as Doc; - if (this.props.dataDocumentId && this.props.documentId !== this.props.dataDocumentId) { - DocServer.GetRefField(this.props.dataDocumentId).then(action((f: Opt<Field>) => this._dataDoc = f as Doc)); - } - })); + DocServer.GetRefField(this.props.documentId).then(action((f: Opt<Field>) => this._document = f as Doc)); this.props.libraryPath && this.setupLibraryPath(); } @@ -639,13 +681,13 @@ export class DockedFrameRenderer extends React.Component<DockedFrameProps> { @action public static PinDoc(doc: Doc) { //add this new doc to props.Document - const curPres = Cast(CurrentUserUtils.UserDocument.curPresentation, Doc) as Doc; + const curPres = Cast(Doc.UserDoc().activePresentation, Doc) as Doc; if (curPres) { const pinDoc = Doc.MakeAlias(doc); pinDoc.presentationTargetDoc = doc; Doc.AddDocToList(curPres, "data", pinDoc); if (!DocumentManager.Instance.getDocumentView(curPres)) { - CollectionDockingView.AddRightSplit(curPres, undefined); + CollectionDockingView.AddRightSplit(curPres); } } } @@ -656,7 +698,7 @@ export class DockedFrameRenderer extends React.Component<DockedFrameProps> { @action public static UnpinDoc(doc: Doc) { //add this new doc to props.Document - const curPres = Cast(CurrentUserUtils.UserDocument.curPresentation, Doc) as Doc; + const curPres = Cast(Doc.UserDoc().activePresentation, Doc) as Doc; if (curPres) { const ind = DocListCast(curPres.data).findIndex((val) => Doc.AreProtosEqual(val, doc)); ind !== -1 && Doc.RemoveDocFromList(curPres, "data", DocListCast(curPres.data)[ind]); @@ -693,23 +735,31 @@ export class DockedFrameRenderer extends React.Component<DockedFrameProps> { panelWidth = () => this.layoutDoc && this.layoutDoc.maxWidth ? Math.min(Math.max(NumCast(this.layoutDoc._width), NumCast(this.layoutDoc._nativeWidth)), this._panelWidth) : this._panelWidth; panelHeight = () => this._panelHeight; - nativeWidth = () => !this.layoutDoc!.ignoreAspect && !this.layoutDoc!._fitWidth ? NumCast(this.layoutDoc!._nativeWidth) || this._panelWidth : 0; - nativeHeight = () => !this.layoutDoc!.ignoreAspect && !this.layoutDoc!._fitWidth ? NumCast(this.layoutDoc!._nativeHeight) || this._panelHeight : 0; + nativeWidth = () => !this.layoutDoc!._fitWidth ? NumCast(this.layoutDoc!._nativeWidth) || this._panelWidth : 0; + nativeHeight = () => !this.layoutDoc!._fitWidth ? NumCast(this.layoutDoc!._nativeHeight) || this._panelHeight : 0; contentScaling = () => { - if (this.layoutDoc!.type === DocumentType.PDF) { - if ((this.layoutDoc && this.layoutDoc._fitWidth) || - this._panelHeight / NumCast(this.layoutDoc!._nativeHeight) > this._panelWidth / NumCast(this.layoutDoc!._nativeWidth)) { - return this._panelWidth / NumCast(this.layoutDoc!._nativeWidth); - } else { - return this._panelHeight / NumCast(this.layoutDoc!._nativeHeight); - } - } const nativeH = this.nativeHeight(); const nativeW = this.nativeWidth(); - if (!nativeW || !nativeH) return 1; - const wscale = this.panelWidth() / nativeW; - return wscale * nativeH > this._panelHeight ? this._panelHeight / nativeH : wscale; + let scaling = 1; + if (!this.layoutDoc?._fitWidth && (!nativeW || !nativeH)) { + scaling = 1; + } else if ((this.layoutDoc?._fitWidth) || + this._panelHeight / NumCast(this.layoutDoc!._nativeHeight) > this._panelWidth / NumCast(this.layoutDoc!._nativeWidth)) { + scaling = this._panelWidth / NumCast(this.layoutDoc!._nativeWidth); + } else { + // if (this.layoutDoc!.type === DocumentType.PDF || this.layoutDoc!.type === DocumentType.WEB) { + // if ((this.layoutDoc?._fitWidth) || + // this._panelHeight / NumCast(this.layoutDoc!._nativeHeight) > this._panelWidth / NumCast(this.layoutDoc!._nativeWidth)) { + // return this._panelWidth / NumCast(this.layoutDoc!._nativeWidth); + // } else { + // return this._panelHeight / NumCast(this.layoutDoc!._nativeHeight); + // } + // } + const wscale = this.panelWidth() / nativeW; + scaling = wscale * nativeH > this._panelHeight ? this._panelHeight / nativeH : wscale; + } + return scaling; } ScreenToLocalTransform = () => { @@ -720,19 +770,19 @@ export class DockedFrameRenderer extends React.Component<DockedFrameProps> { } return Transform.Identity(); } - get previewPanelCenteringOffset() { return this.nativeWidth() && !this.layoutDoc!.ignoreAspect ? (this._panelWidth - this.nativeWidth() * this.contentScaling()) / 2 : 0; } - get widthpercent() { return this.nativeWidth() && !this.layoutDoc!.ignoreAspect ? `${(this.nativeWidth() * this.contentScaling()) / this.panelWidth() * 100}%` : undefined; } + get previewPanelCenteringOffset() { return this.nativeWidth() ? (this._panelWidth - this.nativeWidth() * this.contentScaling()) / 2 : 0; } + get widthpercent() { return this.nativeWidth() ? `${(this.nativeWidth() * this.contentScaling()) / this.panelWidth() * 100}%` : undefined; } - addDocTab = (doc: Doc, dataDoc: Opt<Doc>, location: string, libraryPath?: Doc[]) => { + addDocTab = (doc: Doc, location: string, libraryPath?: Doc[]) => { SelectionManager.DeselectAll(); - if (doc.dockingConfig) { + if (doc._viewType === CollectionViewType.Docking && doc.layoutKey === "layout") { return MainView.Instance.openWorkspace(doc); } else if (location === "onRight") { - return CollectionDockingView.AddRightSplit(doc, dataDoc, libraryPath); + return CollectionDockingView.AddRightSplit(doc, libraryPath); } else if (location === "close") { return CollectionDockingView.CloseRightSplit(doc); - } else { - return CollectionDockingView.Instance.AddTab(this._stack, doc, dataDoc, libraryPath); + } else {// if (location === "inPlace") { + return CollectionDockingView.Instance.AddTab(this._stack, doc, libraryPath); } } @@ -740,29 +790,30 @@ export class DockedFrameRenderer extends React.Component<DockedFrameProps> { TraceMobx(); if (!this._document) return (null); const document = this._document; - const resolvedDataDoc = document.layout instanceof Doc ? document : this._dataDoc; + const resolvedDataDoc = !Doc.AreProtosEqual(this._document[DataSym], this._document) ? this._document[DataSym] : undefined;// document.layout instanceof Doc ? document : this._dataDoc; return <DocumentView key={document[Id]} LibraryPath={this._libraryPath} Document={document} DataDoc={resolvedDataDoc} bringToFront={emptyFunction} + rootSelected={returnTrue} addDocument={undefined} removeDocument={undefined} ContentScaling={this.contentScaling} PanelWidth={this.panelWidth} PanelHeight={this.panelHeight} + NativeHeight={returnZero} + NativeWidth={returnZero} ScreenToLocalTransform={this.ScreenToLocalTransform} renderDepth={0} parentActive={returnTrue} whenActiveChanged={emptyFunction} focus={emptyFunction} - backgroundColor={returnEmptyString} + backgroundColor={CollectionDockingView.Instance.props.backgroundColor} addDocTab={this.addDocTab} pinToPres={DockedFrameRenderer.PinDoc} ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} - zoomToScale={emptyFunction} - getScale={returnOne} />; + ContainingCollectionDoc={undefined} />; } render() { @@ -777,5 +828,5 @@ export class DockedFrameRenderer extends React.Component<DockedFrameProps> { </div >); } } -Scripting.addGlobal(function openOnRight(doc: any) { CollectionDockingView.AddRightSplit(doc, undefined); }); -Scripting.addGlobal(function useRightSplit(doc: any, shiftKey?: boolean) { CollectionDockingView.UseRightSplit(doc, undefined, undefined, shiftKey); }); +Scripting.addGlobal(function openOnRight(doc: any) { CollectionDockingView.AddRightSplit(doc); }); +Scripting.addGlobal(function useRightSplit(doc: any, shiftKey?: boolean) { CollectionDockingView.UseRightSplit(doc, undefined, shiftKey); }); diff --git a/src/client/views/collections/CollectionLinearView.scss b/src/client/views/collections/CollectionLinearView.scss index eae9e0220..123a27deb 100644 --- a/src/client/views/collections/CollectionLinearView.scss +++ b/src/client/views/collections/CollectionLinearView.scss @@ -8,6 +8,8 @@ display:flex; height: 100%; >label { + margin-top: "auto"; + margin-bottom: "auto"; background: $dark-color; color: $light-color; display: inline-block; diff --git a/src/client/views/collections/CollectionLinearView.tsx b/src/client/views/collections/CollectionLinearView.tsx index 67062ae41..344dca23a 100644 --- a/src/client/views/collections/CollectionLinearView.tsx +++ b/src/client/views/collections/CollectionLinearView.tsx @@ -3,8 +3,8 @@ import { observer } from 'mobx-react'; import * as React from 'react'; import { Doc, HeightSym, WidthSym } from '../../../new_fields/Doc'; import { makeInterface } from '../../../new_fields/Schema'; -import { BoolCast, NumCast, StrCast, Cast } from '../../../new_fields/Types'; -import { emptyFunction, returnEmptyString, returnOne, returnTrue, Utils } from '../../../Utils'; +import { BoolCast, NumCast, StrCast, Cast, ScriptCast } from '../../../new_fields/Types'; +import { emptyFunction, returnEmptyString, returnOne, returnTrue, Utils, returnFalse, returnZero } from '../../../Utils'; import { DragManager } from '../../util/DragManager'; import { Transform } from '../../util/Transform'; import "./CollectionLinearView.scss"; @@ -13,7 +13,6 @@ import { CollectionSubView } from './CollectionSubView'; import { DocumentView } from '../nodes/DocumentView'; import { documentSchema } from '../../../new_fields/documentSchemas'; import { Id } from '../../../new_fields/FieldSymbols'; -import { ScriptField } from '../../../new_fields/ScriptField'; type LinearDocument = makeInterface<[typeof documentSchema,]>; @@ -28,18 +27,16 @@ export class CollectionLinearView extends CollectionSubView(LinearDocument) { private _selectedDisposer?: IReactionDisposer; componentWillUnmount() { - this._dropDisposer && this._dropDisposer(); - this._widthDisposer && this._widthDisposer(); - this._selectedDisposer && this._selectedDisposer(); - this.childLayoutPairs.filter((pair) => this.isCurrent(pair.layout)).map((pair, ind) => { - Cast(pair.layout.proto?.onPointerUp, ScriptField)?.script.run({ this: pair.layout.proto }, console.log); - }); + this._dropDisposer?.(); + this._widthDisposer?.(); + this._selectedDisposer?.(); + this.childLayoutPairs.map((pair, ind) => ScriptCast(pair.layout.proto?.onPointerUp)?.script.run({ this: pair.layout.proto }, console.log)); } componentDidMount() { // is there any reason this needs to exist? -syip. yes, it handles autoHeight for stacking views (masonry isn't yet supported). - this._widthDisposer = reaction(() => this.props.Document[HeightSym]() + this.childDocs.length + (this.props.Document.isExpanded ? 1 : 0), - () => this.props.Document._width = 5 + (this.props.Document.isExpanded ? this.childDocs.length * (this.props.Document[HeightSym]()) : 10), + this._widthDisposer = reaction(() => this.props.Document[HeightSym]() + this.childDocs.length + (this.props.Document.linearViewIsExpanded ? 1 : 0), + () => this.props.Document._width = 5 + (this.props.Document.linearViewIsExpanded ? this.childDocs.length * (this.props.Document[HeightSym]()) : 10), { fireImmediately: true } ); @@ -48,17 +45,17 @@ export class CollectionLinearView extends CollectionSubView(LinearDocument) { (i) => runInAction(() => { this._selectedIndex = i; let selected: any = undefined; - this.childLayoutPairs.filter((pair) => this.isCurrent(pair.layout)).map(async (pair, ind) => { + this.childLayoutPairs.map(async (pair, ind) => { const isSelected = this._selectedIndex === ind; if (isSelected) { selected = pair; } else { - Cast(pair.layout.proto?.onPointerUp, ScriptField)?.script.run({ this: pair.layout.proto }, console.log); + ScriptCast(pair.layout.proto?.onPointerUp)?.script.run({ this: pair.layout.proto }, console.log); } }); if (selected && selected.layout) { - Cast(selected.layout.proto?.onPointerDown, ScriptField)?.script.run({ this: selected.layout.proto }, console.log); + ScriptCast(selected.layout.proto?.onPointerDown)?.script.run({ this: selected.layout.proto }, console.log); } }), { fireImmediately: true } @@ -67,37 +64,42 @@ export class CollectionLinearView extends CollectionSubView(LinearDocument) { protected createDashEventsTarget = (ele: HTMLDivElement) => { //used for stacking and masonry view this._dropDisposer && this._dropDisposer(); if (ele) { - this._dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this)); + this._dropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.layoutDoc); } } - public isCurrent(doc: Doc) { return !doc.isMinimized && (Math.abs(NumCast(doc.displayTimecode, -1) - NumCast(this.Document.currentTimecode, -1)) < 1.5 || NumCast(doc.displayTimecode, -1) === -1); } - dimension = () => NumCast(this.props.Document._height); // 2 * the padding getTransform = (ele: React.RefObject<HTMLDivElement>) => () => { if (!ele.current) return Transform.Identity(); const { scale, translateX, translateY } = Utils.GetScreenTransform(ele.current); - return new Transform(-translateX, -translateY, 1 / scale); + return new Transform(-translateX, -translateY, 1); } render() { const guid = Utils.GenerateGuid(); + const flexDir: any = StrCast(this.Document.flexDirection); + const backgroundColor = StrCast(this.props.Document.backgroundColor, "black"); + const color = StrCast(this.props.Document.color, "white"); return <div className="collectionLinearView-outer"> <div className="collectionLinearView" ref={this.createDashEventsTarget} > - <input id={`${guid}`} type="checkbox" checked={BoolCast(this.props.Document.isExpanded)} ref={this.addMenuToggle} - onChange={action((e: any) => this.props.Document.isExpanded = this.addMenuToggle.current!.checked)} /> - <label htmlFor={`${guid}`} style={{ marginTop: "auto", marginBottom: "auto", background: StrCast(this.props.Document.backgroundColor, "black") === StrCast(this.props.Document.color, "white") ? "black" : StrCast(this.props.Document.backgroundColor, "black") }} title="Close Menu"><p>+</p></label> + <label htmlFor={`${guid}`} title="Close Menu" style={{ background: backgroundColor === color ? "black" : backgroundColor }} + onPointerDown={e => e.stopPropagation()} > + <p>+</p> + </label> + <input id={`${guid}`} type="checkbox" checked={BoolCast(this.props.Document.linearViewIsExpanded)} ref={this.addMenuToggle} + onChange={action((e: any) => this.props.Document.linearViewIsExpanded = this.addMenuToggle.current!.checked)} /> - <div className="collectionLinearView-content" style={{ height: this.dimension(), width: NumCast(this.props.Document._width, 25) }}> - {this.childLayoutPairs.filter((pair) => this.isCurrent(pair.layout)).map((pair, ind) => { + <div className="collectionLinearView-content" style={{ height: this.dimension(), flexDirection: flexDir }}> + {this.childLayoutPairs.map((pair, ind) => { const nested = pair.layout._viewType === CollectionViewType.Linear; const dref = React.createRef<HTMLDivElement>(); const nativeWidth = NumCast(pair.layout._nativeWidth, this.dimension()); const deltaSize = nativeWidth * .15 / 2; - return <div className={`collectionLinearView-docBtn` + (pair.layout.onClick || pair.layout.onDragStart ? "-scalable" : "")} key={pair.layout[Id]} ref={dref} + const scalable = pair.layout.onClick || pair.layout.onDragStart; + return <div className={`collectionLinearView-docBtn` + (scalable ? "-scalable" : "")} key={pair.layout[Id]} ref={dref} style={{ - width: nested ? pair.layout[WidthSym]() : this.dimension() - deltaSize, - height: nested && pair.layout.isExpanded ? pair.layout[HeightSym]() : this.dimension() - deltaSize, + width: scalable ? (nested ? pair.layout[WidthSym]() : this.dimension() - deltaSize) : undefined, + height: nested && pair.layout.linearViewIsExpanded ? pair.layout[HeightSym]() : this.dimension() - deltaSize, }} > <DocumentView Document={pair.layout} @@ -107,10 +109,13 @@ export class CollectionLinearView extends CollectionSubView(LinearDocument) { moveDocument={this.props.moveDocument} addDocTab={this.props.addDocTab} pinToPres={emptyFunction} + rootSelected={this.props.isSelected} removeDocument={this.props.removeDocument} onClick={undefined} ScreenToLocalTransform={this.getTransform(dref)} ContentScaling={returnOne} + NativeHeight={returnZero} + NativeWidth={returnZero} PanelWidth={nested ? pair.layout[WidthSym] : () => this.dimension()}// ugh - need to get rid of this inline function to avoid recomputing PanelHeight={nested ? pair.layout[HeightSym] : () => this.dimension()} renderDepth={this.props.renderDepth + 1} @@ -120,10 +125,7 @@ export class CollectionLinearView extends CollectionSubView(LinearDocument) { whenActiveChanged={emptyFunction} bringToFront={emptyFunction} ContainingCollectionView={undefined} - ContainingCollectionDoc={undefined} - zoomToScale={emptyFunction} - getScale={returnOne}> - </DocumentView> + ContainingCollectionDoc={undefined} /> </div>; })} </div> diff --git a/src/client/views/collections/CollectionMapView.scss b/src/client/views/collections/CollectionMapView.scss new file mode 100644 index 000000000..870b7fda8 --- /dev/null +++ b/src/client/views/collections/CollectionMapView.scss @@ -0,0 +1,30 @@ +.collectionMapView { + width: 100%; + height: 100%; + + .collectionMapView-contents { + width: 100%; + height: 100%; + > div { + position: unset !important; // when the sidebar filter flys out, this prevents the map from extending outside the document box + } + } +} + +.loadingWrapper { + width: 100%; + height: 100%; + background-color: pink; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + + .loadingGif { + align-self: center; + justify-self: center; + width: 50px; + height: 50px; + } +}
\ No newline at end of file diff --git a/src/client/views/collections/CollectionMapView.tsx b/src/client/views/collections/CollectionMapView.tsx new file mode 100644 index 000000000..971224482 --- /dev/null +++ b/src/client/views/collections/CollectionMapView.tsx @@ -0,0 +1,263 @@ +import { GoogleApiWrapper, Map as GeoMap, IMapProps, Marker } from "google-maps-react"; +import { observer } from "mobx-react"; +import { Doc, Opt, DocListCast, FieldResult, Field } from "../../../new_fields/Doc"; +import { documentSchema } from "../../../new_fields/documentSchemas"; +import { Id } from "../../../new_fields/FieldSymbols"; +import { makeInterface } from "../../../new_fields/Schema"; +import { Cast, NumCast, ScriptCast, StrCast } from "../../../new_fields/Types"; +import "./CollectionMapView.scss"; +import { CollectionSubView } from "./CollectionSubView"; +import React = require("react"); +import { DocumentManager } from "../../util/DocumentManager"; +import { UndoManager, undoBatch } from "../../util/UndoManager"; +import { computed, runInAction, Lambda, action } from "mobx"; +import requestPromise = require("request-promise"); + +type MapSchema = makeInterface<[typeof documentSchema]>; +const MapSchema = makeInterface(documentSchema); + +export type LocationData = google.maps.LatLngLiteral & { + address?: string + resolvedAddress?: string; + zoom?: number; +}; + +interface DocLatLng { + lat: FieldResult<Field>; + lng: FieldResult<Field>; +} + +// Nowhere, Oklahoma +const defaultLocation = { lat: 35.1592238, lng: -98.444512, zoom: 15 }; +const noResults = "ZERO_RESULTS"; + +const query = async (data: string | google.maps.LatLngLiteral) => { + const contents = typeof data === "string" ? `address=${data.replace(/\s+/g, "+")}` : `latlng=${data.lat},${data.lng}`; + const target = `https://maps.googleapis.com/maps/api/geocode/json?${contents}&key=${process.env.GOOGLE_MAPS_GEO}`; + try { + return JSON.parse(await requestPromise.get(target)); + } catch { + return undefined; + } +}; + +@observer +class CollectionMapView extends CollectionSubView<MapSchema, Partial<IMapProps> & { google: any }>(MapSchema) { + + private _cancelAddrReq = new Map<string, boolean>(); + private _cancelLocReq = new Map<string, boolean>(); + private _initialLookupPending = new Map<string, boolean>(); + private responders: { location: Lambda, address: Lambda }[] = []; + + /** + * Note that all the uses of runInAction below are not included + * as a way to update observables (documents handle this already + * in their property setters), but rather to create a single bulk + * update and thus prevent uneeded invocations of the location- + * and address–updating reactions. + */ + + private getLocation = (doc: Opt<Doc>, fieldKey: string, returnDefault: boolean = true): Opt<LocationData> => { + if (doc) { + const titleLoc = StrCast(doc.title).startsWith("@") ? StrCast(doc.title).substring(1) : undefined; + const lat = Cast(doc[`${fieldKey}-lat`], "number", null) || (Cast(doc[`${fieldKey}-lat`], "string", null) && Number(Cast(doc[`${fieldKey}-lat`], "string", null))) || undefined; + const lng = Cast(doc[`${fieldKey}-lng`], "number", null) || (Cast(doc[`${fieldKey}-lng`], "string", null) && Number(Cast(doc[`${fieldKey}-lng`], "string", null))) || undefined; + const zoom = Cast(doc[`${fieldKey}-zoom`], "number", null) || (Cast(doc[`${fieldKey}-zoom`], "string", null) && Number(Cast(doc[`${fieldKey}-zoom`], "string", null))) || undefined; + const address = titleLoc || StrCast(doc[`${fieldKey}-address`], StrCast(doc.title).replace(/^-/, "")); + if (titleLoc || (address && (lat === undefined || lng === undefined))) { + const id = doc[Id]; + if (!this._initialLookupPending.get(id)) { + this._initialLookupPending.set(id, true); + setTimeout(() => { + titleLoc && Doc.SetInPlace(doc, "title", titleLoc, true); + this.respondToAddressChange(doc, fieldKey, address).then(() => this._initialLookupPending.delete(id)); + }); + } + } + return (lat === undefined || lng === undefined) ? (returnDefault ? defaultLocation : undefined) : { lat, lng, zoom }; + } + return undefined; + } + + private markerClick = async (layout: Doc, { lat, lng, zoom }: LocationData) => { + const batch = UndoManager.StartBatch("marker click"); + const { fieldKey } = this.props; + runInAction(() => { + this.layoutDoc[`${fieldKey}-mapCenter-lat`] = lat; + this.layoutDoc[`${fieldKey}-mapCenter-lng`] = lng; + zoom && (this.layoutDoc[`${fieldKey}-mapCenter-zoom`] = zoom); + }); + if (layout.isLinkButton && DocListCast(layout.links).length) { + await DocumentManager.Instance.FollowLink(undefined, layout, (doc: Doc, where: string, finished?: () => void) => { + this.props.addDocTab(doc, where); + finished?.(); + }, false, this.props.ContainingCollectionDoc, batch.end, undefined); + } else { + ScriptCast(layout.onClick)?.script.run({ this: layout, self: Cast(layout.rootDocument, Doc, null) || layout }); + batch.end(); + } + } + + private renderMarkerIcon = (layout: Doc) => { + const { Document } = this.props; + const fieldKey = Doc.LayoutFieldKey(layout); + const iconUrl = StrCast(layout.mapIconUrl, StrCast(Document.mapIconUrl)); + if (iconUrl) { + const iconWidth = NumCast(layout[`${fieldKey}-iconWidth`], 45); + const iconHeight = NumCast(layout[`${fieldKey}-iconHeight`], 45); + const iconSize = new google.maps.Size(iconWidth, iconHeight); + return { + size: iconSize, + scaledSize: iconSize, + url: iconUrl + }; + } + } + + private renderMarker = (layout: Doc) => { + const location = this.getLocation(layout, Doc.LayoutFieldKey(layout)); + return !location ? (null) : + <Marker + key={layout[Id]} + label={StrCast(layout.title)} + position={location} + onClick={() => this.markerClick(layout, location)} + icon={this.renderMarkerIcon(layout)} + />; + } + + private respondToAddressChange = async (doc: Doc, fieldKey: string, newAddress: string, oldAddress?: string) => { + if (newAddress === oldAddress) { + return false; + } + const response = await query(newAddress); + const id = doc[Id]; + if (!response || response.status === noResults) { + this._cancelAddrReq.set(id, true); + doc[`${fieldKey}-address`] = oldAddress; + return false; + } + const { geometry, formatted_address } = response.results[0]; + const { lat, lng } = geometry.location; + runInAction(() => { + if (doc[`${fieldKey}-lat`] !== lat || doc[`${fieldKey}-lng`] !== lng) { + this._cancelLocReq.set(id, true); + Doc.SetInPlace(doc, `${fieldKey}-lat`, lat, true); + Doc.SetInPlace(doc, `${fieldKey}-lng`, lng, true); + } + if (formatted_address !== newAddress) { + this._cancelAddrReq.set(id, true); + Doc.SetInPlace(doc, `${fieldKey}-address`, formatted_address, true); + } + }); + return true; + } + + private respondToLocationChange = async (doc: Doc, fieldKey: string, newLatLng: DocLatLng, oldLatLng: Opt<DocLatLng>) => { + if (newLatLng === oldLatLng) { + return false; + } + const response = await query({ lat: NumCast(newLatLng.lat), lng: NumCast(newLatLng.lng) }); + const id = doc[Id]; + if (!response || response.status === noResults) { + this._cancelLocReq.set(id, true); + runInAction(() => { + doc[`${fieldKey}-lat`] = oldLatLng?.lat; + doc[`${fieldKey}-lng`] = oldLatLng?.lng; + }); + return false; + } + const { formatted_address } = response.results[0]; + if (formatted_address !== doc[`${fieldKey}-address`]) { + this._cancelAddrReq.set(doc[Id], true); + Doc.SetInPlace(doc, `${fieldKey}-address`, formatted_address, true); + } + return true; + } + + @computed get reactiveContents() { + this.responders.forEach(({ location, address }) => { location(); address(); }); + this.responders = []; + return this.childLayoutPairs.map(({ layout }) => { + const fieldKey = Doc.LayoutFieldKey(layout); + const id = layout[Id]; + this.responders.push({ + location: computed(() => ({ lat: layout[`${fieldKey}-lat`], lng: layout[`${fieldKey}-lng`] })) + .observe(({ oldValue, newValue }) => { + if (this._cancelLocReq.get(id)) { + this._cancelLocReq.set(id, false); + } else if (newValue.lat !== undefined && newValue.lng !== undefined) { + this.respondToLocationChange(layout, fieldKey, newValue, oldValue); + } + }), + address: computed(() => Cast(layout[`${fieldKey}-address`], "string", null)) + .observe(({ oldValue, newValue }) => { + if (this._cancelAddrReq.get(id)) { + this._cancelAddrReq.set(id, false); + } else if (newValue?.length) { + this.respondToAddressChange(layout, fieldKey, newValue, oldValue); + } + }) + }); + return this.renderMarker(layout); + }); + } + + render() { + const { childLayoutPairs } = this; + const { Document, fieldKey, active, google } = this.props; + let center = this.getLocation(Document, `${fieldKey}-mapCenter`, false); + if (center === undefined) { + const childLocations = childLayoutPairs.map(({ layout }) => this.getLocation(layout, Doc.LayoutFieldKey(layout), false)); + center = childLocations.find(location => location) || defaultLocation; + } + return <div className="collectionMapView" ref={this.createDashEventsTarget}> + <div className={"collectionMapView-contents"} + style={{ pointerEvents: active() ? undefined : "none" }} + onWheel={e => e.stopPropagation()} + onPointerDown={e => (e.button === 0 && !e.ctrlKey) && e.stopPropagation()} > + <GeoMap + google={google} + zoom={center.zoom || 10} + initialCenter={center} + center={center} + onIdle={(_props?: IMapProps, map?: google.maps.Map) => { + if (this.layoutDoc.lockedTransform) { + // reset zoom (ideally, we could probably can tell the map to disallow zooming somehow instead) + map?.setZoom(center?.zoom || 10); + map?.setCenter({ lat: center?.lat!, lng: center?.lng! }); + } else { + const zoom = map?.getZoom(); + (center?.zoom !== zoom) && undoBatch(action(() => { + Document[`${fieldKey}-mapCenter-zoom`] = zoom; + }))(); + } + }} + onDragend={(_props?: IMapProps, map?: google.maps.Map) => { + if (this.layoutDoc.lockedTransform) { + // reset the drag (ideally, we could probably can tell the map to disallow dragging somehow instead) + map?.setCenter({ lat: center?.lat!, lng: center?.lng! }); + } else { + undoBatch(action(({ lat, lng }) => { + Document[`${fieldKey}-mapCenter-lat`] = lat(); + Document[`${fieldKey}-mapCenter-lng`] = lng(); + }))(map?.getCenter()); + } + }} + > + {this.reactiveContents} + </GeoMap> + </div> + </div>; + } + +} + +export default GoogleApiWrapper({ + apiKey: process.env.GOOGLE_MAPS!, + LoadingContainer: () => ( + <div className={"loadingWrapper"}> + <img className={"loadingGif"} src={"/assets/loading.gif"} /> + </div> + ) +})(CollectionMapView) as any;
\ No newline at end of file diff --git a/src/client/views/collections/CollectionMasonryViewFieldRow.tsx b/src/client/views/collections/CollectionMasonryViewFieldRow.tsx index e25a2f5eb..7ad15ef41 100644 --- a/src/client/views/collections/CollectionMasonryViewFieldRow.tsx +++ b/src/client/views/collections/CollectionMasonryViewFieldRow.tsx @@ -2,24 +2,25 @@ import React = require("react"); import { library } from '@fortawesome/fontawesome-svg-core'; import { faPalette } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { action, observable, computed } from "mobx"; +import { action, computed, observable, runInAction } from "mobx"; import { observer } from "mobx-react"; -import Measure from "react-measure"; import { Doc } from "../../../new_fields/Doc"; import { PastelSchemaPalette, SchemaHeaderField } from "../../../new_fields/SchemaHeaderField"; import { ScriptField } from "../../../new_fields/ScriptField"; -import { StrCast } from "../../../new_fields/Types"; -import { numberRange } from "../../../Utils"; +import { StrCast, NumCast } from "../../../new_fields/Types"; +import { numberRange, setupMoveUpEvents, emptyFunction } from "../../../Utils"; import { Docs } from "../../documents/Documents"; import { DragManager } from "../../util/DragManager"; import { CompileScript } from "../../util/Scripting"; import { SelectionManager } from "../../util/SelectionManager"; import { Transform } from "../../util/Transform"; import { undoBatch } from "../../util/UndoManager"; -import { anchorPoints, Flyout } from "../DocumentDecorations"; import { EditableView } from "../EditableView"; import { CollectionStackingView } from "./CollectionStackingView"; import "./CollectionStackingView.scss"; +const higflyout = require("@hig/flyout"); +export const { anchorPoints } = higflyout; +export const Flyout = higflyout.default; library.add(faPalette); @@ -34,53 +35,67 @@ interface CMVFieldRowProps { createDropTarget: (ele: HTMLDivElement) => void; screenToLocalTransform: () => Transform; setDocHeight: (key: string, thisHeight: number) => void; + observeHeight: (myref: any) => void; + unobserveHeight: (myref: any) => void; + showHandle: boolean; } @observer export class CollectionMasonryViewFieldRow extends React.Component<CMVFieldRowProps> { @observable private _background = "inherit"; @observable private _createAliasSelected: boolean = false; - @observable private _collapsed: boolean = false; - @observable private _headingsHack: number = 1; - @observable private _heading = this.props.headingObject ? this.props.headingObject.heading : this.props.heading; - @observable private _color = this.props.headingObject ? this.props.headingObject.color : "#f1efeb"; + @observable private heading: string = ""; + @observable private color: string = "#f1efeb"; + @observable private collapsed: boolean = false; + private set _heading(value: string) { runInAction(() => this.props.headingObject && (this.props.headingObject.heading = this.heading = value)); } + private set _color(value: string) { runInAction(() => this.props.headingObject && (this.props.headingObject.color = this.color = value)); } + private set _collapsed(value: boolean) { runInAction(() => this.props.headingObject && (this.props.headingObject.collapsed = this.collapsed = value)); } private _dropDisposer?: DragManager.DragDropDisposer; private _headerRef: React.RefObject<HTMLDivElement> = React.createRef(); - private _startDragPosition: { x: number, y: number } = { x: 0, y: 0 }; private _contRef: React.RefObject<HTMLDivElement> = React.createRef(); - private _sensitivity: number = 16; - private _counter: number = 0; - + private _ele: any; createRowDropRef = (ele: HTMLDivElement | null) => { - this._dropDisposer && this._dropDisposer(); + this._dropDisposer?.(); if (ele) { + this._ele = ele; + this.props.observeHeight(ele); this._dropDisposer = DragManager.MakeDropTarget(ele, this.rowDrop.bind(this)); } } + @action + componentDidMount() { + this.heading = this.props.headingObject?.heading || ""; + this.color = this.props.headingObject?.color || "#f1efeb"; + this.collapsed = this.props.headingObject?.collapsed || false; + } + componentWillUnmount() { + this.props.unobserveHeight(this._ele); + } getTrueHeight = () => { - if (this._collapsed) { - this.props.setDocHeight(this._heading, 20); + if (this.collapsed) { + this.props.setDocHeight(this.heading, 20); } else { const rawHeight = this._contRef.current!.getBoundingClientRect().height + 15; //+ 15 accounts for the group header const transformScale = this.props.screenToLocalTransform().Scale; const trueHeight = rawHeight * transformScale; - this.props.setDocHeight(this._heading, trueHeight); + this.props.setDocHeight(this.heading, trueHeight); } } @undoBatch rowDrop = action((e: Event, de: DragManager.DropEvent) => { + console.log("masronry row drop"); this._createAliasSelected = false; if (de.complete.docDragData) { (this.props.parent.Document.dropConverter instanceof ScriptField) && this.props.parent.Document.dropConverter.script.run({ dragData: de.complete.docDragData }); - const key = StrCast(this.props.parent.props.Document.sectionFilter); - const castedValue = this.getValue(this._heading); + const key = StrCast(this.props.parent.props.Document._pivotField); + const castedValue = this.getValue(this.heading); de.complete.docDragData.droppedDocuments.forEach(d => d[key] = castedValue); - this.props.parent.drop(e, de); + this.props.parent.onInternalDrop(e, de); e.stopPropagation(); } }); @@ -96,7 +111,7 @@ export class CollectionMasonryViewFieldRow extends React.Component<CMVFieldRowPr @action headingChanged = (value: string, shiftDown?: boolean) => { this._createAliasSelected = false; - const key = StrCast(this.props.parent.props.Document.sectionFilter); + const key = StrCast(this.props.parent.props.Document._pivotField); const castedValue = this.getValue(value); if (castedValue) { if (this.props.parent.sectionHeaders) { @@ -105,10 +120,7 @@ export class CollectionMasonryViewFieldRow extends React.Component<CMVFieldRowPr } } this.props.docList.forEach(d => d[key] = castedValue); - if (this.props.headingObject) { - this.props.headingObject.setHeading(castedValue.toString()); - this._heading = this.props.headingObject.heading; - } + this._heading = castedValue.toString(); return true; } return false; @@ -117,10 +129,7 @@ export class CollectionMasonryViewFieldRow extends React.Component<CMVFieldRowPr @action changeColumnColor = (color: string) => { this._createAliasSelected = false; - if (this.props.headingObject) { - this.props.headingObject.setColor(color); - this._color = color; - } + this._color = color; } pointerEnteredRow = action(() => SelectionManager.GetIsDragging() && (this._background = "#b4b4b4")); @@ -129,21 +138,21 @@ export class CollectionMasonryViewFieldRow extends React.Component<CMVFieldRowPr pointerLeaveRow = () => { this._createAliasSelected = false; this._background = "inherit"; - document.removeEventListener("pointermove", this.startDrag); } @action addDocument = (value: string, shiftDown?: boolean) => { this._createAliasSelected = false; - const key = StrCast(this.props.parent.props.Document.sectionFilter); - const newDoc = Docs.Create.TextDocument("", { _height: 18, _width: 200, title: value }); + const key = StrCast(this.props.parent.props.Document._pivotField); + const newDoc = Docs.Create.TextDocument(value, { _autoHeight: true, _width: 200, title: value }); newDoc[key] = this.getValue(this.props.heading); - return this.props.parent.props.addDocument(newDoc); + const docs = this.props.parent.childDocList; + return docs ? (docs.splice(0, 0, newDoc) ? true : false) : this.props.parent.props.addDocument(newDoc); } deleteRow = undoBatch(action(() => { this._createAliasSelected = false; - const key = StrCast(this.props.parent.props.Document.sectionFilter); + const key = StrCast(this.props.parent.props.Document._pivotField); this.props.docList.forEach(d => d[key] = undefined); if (this.props.parent.sectionHeaders && this.props.headingObject) { const index = this.props.parent.sectionHeaders.indexOf(this.props.headingObject); @@ -152,62 +161,36 @@ export class CollectionMasonryViewFieldRow extends React.Component<CMVFieldRowPr })); @action - collapseSection = () => { + collapseSection = (e: any) => { this._createAliasSelected = false; - if (this.props.headingObject) { - this._headingsHack++; - this.props.headingObject.setCollapsed(!this.props.headingObject.collapsed); - this.toggleVisibility(); - } + this.toggleVisibility(); + e.stopPropagation(); } - startDrag = (e: PointerEvent) => { - const [dx, dy] = this.props.screenToLocalTransform().transformDirection(e.clientX - this._startDragPosition.x, e.clientY - this._startDragPosition.y); - if (Math.abs(dx) + Math.abs(dy) > this._sensitivity) { - const alias = Doc.MakeAlias(this.props.parent.props.Document); - const key = StrCast(this.props.parent.props.Document.sectionFilter); - let value = this.getValue(this._heading); - value = typeof value === "string" ? `"${value}"` : value; - const script = `return doc.${key} === ${value}`; - const compiled = CompileScript(script, { params: { doc: Doc.name } }); - if (compiled.compiled) { - alias.viewSpecScript = new ScriptField(compiled); - DragManager.StartDocumentDrag([this._headerRef.current!], new DragManager.DocumentDragData([alias]), e.clientX, e.clientY); - } - - e.stopPropagation(); - document.removeEventListener("pointermove", this.startDrag); - document.removeEventListener("pointerup", this.pointerUp); + headerMove = (e: PointerEvent) => { + const alias = Doc.MakeAlias(this.props.parent.props.Document); + const key = StrCast(this.props.parent.props.Document._pivotField); + let value = this.getValue(this.heading); + value = typeof value === "string" ? `"${value}"` : value; + const script = `return doc.${key} === ${value}`; + const compiled = CompileScript(script, { params: { doc: Doc.name } }); + if (compiled.compiled) { + alias.viewSpecScript = new ScriptField(compiled); + DragManager.StartDocumentDrag([this._headerRef.current!], new DragManager.DocumentDragData([alias]), e.clientX, e.clientY); } - } - - pointerUp = (e: PointerEvent) => { - e.stopPropagation(); - e.preventDefault(); - - document.removeEventListener("pointermove", this.startDrag); - document.removeEventListener("pointerup", this.pointerUp); + return true; } @action headerDown = (e: React.PointerEvent<HTMLDivElement>) => { - e.stopPropagation(); - e.preventDefault(); - - const [dx, dy] = this.props.screenToLocalTransform().transformDirection(e.clientX, e.clientY); - this._startDragPosition = { x: dx, y: dy }; - - if (this._createAliasSelected) { - document.removeEventListener("pointermove", this.startDrag); - document.addEventListener("pointermove", this.startDrag); - document.removeEventListener("pointerup", this.pointerUp); - document.addEventListener("pointerup", this.pointerUp); + if (e.button === 0 && !e.ctrlKey) { + setupMoveUpEvents(this, e, this.headerMove, emptyFunction, () => (this.props.parent.props.Document._chromeStatus === "disabled") && this.collapseSection(e)); + this._createAliasSelected = false; } - this._createAliasSelected = false; } renderColorPicker = () => { - const selected = this.props.headingObject ? this.props.headingObject.color : "#f1efeb"; + const selected = this.color; const pink = PastelSchemaPalette.get("pink2"); const purple = PastelSchemaPalette.get("purple4"); @@ -237,7 +220,7 @@ export class CollectionMasonryViewFieldRow extends React.Component<CMVFieldRowPr } toggleAlias = action(() => this._createAliasSelected = true); - toggleVisibility = action(() => this._collapsed = !this._collapsed); + toggleVisibility = () => this._collapsed = !this.collapsed; renderMenu = () => { const selected = this._createAliasSelected; @@ -249,27 +232,29 @@ export class CollectionMasonryViewFieldRow extends React.Component<CMVFieldRowPr </div>); } - handleResize = (size: any) => { - if (++this._counter !== 1) { - this.getTrueHeight(); - } - } - @computed get contentLayout() { const rows = Math.max(1, Math.min(this.props.docList.length, Math.floor((this.props.parent.props.PanelWidth() - 2 * this.props.parent.xMargin) / (this.props.parent.columnWidth + this.props.parent.gridGap)))); - const style = this.props.parent; const collapsed = this._collapsed; + const style = this.props.parent; const chromeStatus = this.props.parent.props.Document._chromeStatus; const newEditableViewProps = { GetValue: () => "", SetValue: this.addDocument, contents: "+ NEW", HeadingObject: this.props.headingObject, - HeadingsHack: this._headingsHack, toggle: this.toggleVisibility, - color: this._color + color: this.color }; - return collapsed ? (null) : + return this.collapsed ? (null) : <div style={{ position: "relative" }}> + {(chromeStatus !== 'view-mode' && chromeStatus !== 'disabled') ? + <div className="collectionStackingView-addDocumentButton" + style={{ + width: style.columnWidth / style.numGroupColumns, + padding: NumCast(this.props.parent.layoutDoc._yPadding) + }}> + <EditableView {...newEditableViewProps} /> + </div> : null + } <div className={`collectionStackingView-masonryGrid`} ref={this._contRef} style={{ @@ -279,30 +264,23 @@ export class CollectionMasonryViewFieldRow extends React.Component<CMVFieldRowPr gridTemplateColumns: numberRange(rows).reduce((list: string, i: any) => list + ` ${this.props.parent.columnWidth}px`, ""), }}> {this.props.parent.children(this.props.docList)} - {this.props.parent.columnDragger} + {this.props.showHandle && this.props.parent.props.active() ? this.props.parent.columnDragger : (null)} </div> - {(chromeStatus !== 'view-mode' && chromeStatus !== 'disabled') ? - <div className="collectionStackingView-addDocumentButton" - style={{ width: style.columnWidth / style.numGroupColumns }}> - <EditableView {...newEditableViewProps} /> - </div> : null - } </div>; } @computed get headingView() { - const heading = this._heading; - const key = StrCast(this.props.parent.props.Document.sectionFilter); - const evContents = heading ? heading : this.props.type && this.props.type === "number" ? "0" : `NO ${key.toUpperCase()} VALUE`; + const noChrome = this.props.parent.props.Document._chromeStatus === "disabled"; + const key = StrCast(this.props.parent.props.Document._pivotField); + const evContents = this.heading ? this.heading : this.props.type && this.props.type === "number" ? "0" : `NO ${key.toUpperCase()} VALUE`; const headerEditableViewProps = { GetValue: () => evContents, SetValue: this.headingChanged, contents: evContents, oneLine: true, HeadingObject: this.props.headingObject, - HeadingsHack: this._headingsHack, toggle: this.toggleVisibility, - color: this._color + color: this.color }; return this.props.parent.props.Document.miniHeaders ? <div className="collectionStackingView-miniHeader"> @@ -313,9 +291,9 @@ export class CollectionMasonryViewFieldRow extends React.Component<CMVFieldRowPr <div className="collectionStackingView-sectionHeader-subCont" onPointerDown={this.headerDown} title={evContents === `NO ${key.toUpperCase()} VALUE` ? `Documents that don't have a ${key} value will go here. This column cannot be removed.` : ""} - style={{ background: evContents !== `NO ${key.toUpperCase()} VALUE` ? this._color : "lightgrey", }}> - <EditableView {...headerEditableViewProps} /> - {evContents === `NO ${key.toUpperCase()} VALUE` ? (null) : + style={{ background: evContents !== `NO ${key.toUpperCase()} VALUE` ? this.color : "lightgrey" }}> + {noChrome ? evContents : <EditableView {...headerEditableViewProps} />} + {noChrome || evContents === `NO ${key.toUpperCase()} VALUE` ? (null) : <div className="collectionStackingView-sectionColor"> <Flyout anchorPoint={anchorPoints.CENTER_RIGHT} content={this.renderColorPicker()}> <button className="collectionStackingView-sectionColorButton"> @@ -324,10 +302,10 @@ export class CollectionMasonryViewFieldRow extends React.Component<CMVFieldRowPr </ Flyout > </div> } - <button className="collectionStackingView-sectionDelete" onClick={this.collapseSection}> - <FontAwesomeIcon icon={this._collapsed ? "chevron-down" : "chevron-up"} size="lg" /> - </button> - {evContents === `NO ${key.toUpperCase()} VALUE` ? (null) : + {noChrome ? (null) : <button className="collectionStackingView-sectionDelete" onClick={noChrome ? undefined : this.collapseSection}> + <FontAwesomeIcon icon={this.collapsed ? "chevron-down" : "chevron-up"} size="lg" /> + </button>} + {noChrome || evContents === `NO ${key.toUpperCase()} VALUE` ? (null) : <div className="collectionStackingView-sectionOptions"> <Flyout anchorPoint={anchorPoints.TOP_RIGHT} content={this.renderMenu()}> <button className="collectionStackingView-sectionOptionButton"> @@ -340,23 +318,15 @@ export class CollectionMasonryViewFieldRow extends React.Component<CMVFieldRowPr </div>; } render() { - const background = this._background; //to account for observables in Measure - const contentlayout = this.contentLayout; - const headingview = this.headingView; - return <Measure offset onResize={this.handleResize}> - {({ measureRef }) => { - return <div ref={measureRef}> - <div className="collectionStackingView-masonrySection" - style={{ width: this.props.parent.NodeWidth, background }} - ref={this.createRowDropRef} - onPointerEnter={this.pointerEnteredRow} - onPointerLeave={this.pointerLeaveRow} - > - {headingview} - {contentlayout} - </div > - </div>; - }} - </Measure>; + const background = this._background; + return <div className="collectionStackingView-masonrySection" + style={{ width: this.props.parent.NodeWidth, background }} + ref={this.createRowDropRef} + onPointerEnter={this.pointerEnteredRow} + onPointerLeave={this.pointerLeaveRow} + > + {this.headingView} + {this.contentLayout} + </div >; } }
\ No newline at end of file diff --git a/src/client/views/collections/CollectionPileView.scss b/src/client/views/collections/CollectionPileView.scss new file mode 100644 index 000000000..ac874b663 --- /dev/null +++ b/src/client/views/collections/CollectionPileView.scss @@ -0,0 +1,8 @@ +.collectionPileView { + display: flex; + flex-direction: row; + position: absolute; + height: 100%; + width: 100%; + overflow: visible; +} diff --git a/src/client/views/collections/CollectionPileView.tsx b/src/client/views/collections/CollectionPileView.tsx new file mode 100644 index 000000000..3bbfcc4d7 --- /dev/null +++ b/src/client/views/collections/CollectionPileView.tsx @@ -0,0 +1,127 @@ +import { action, computed, observable, runInAction } from "mobx"; +import { observer } from "mobx-react"; +import { HeightSym, Opt, WidthSym } from "../../../new_fields/Doc"; +import { ScriptField } from "../../../new_fields/ScriptField"; +import { BoolCast, NumCast, StrCast } from "../../../new_fields/Types"; +import { ContextMenu } from "../ContextMenu"; +import { ContextMenuProps } from "../ContextMenuItem"; +import { CollectionFreeFormView } from "./collectionFreeForm/CollectionFreeFormView"; +import { CollectionSubView } from "./CollectionSubView"; +import "./CollectionPileView.scss"; +import React = require("react"); +import { setupMoveUpEvents, emptyFunction, returnFalse } from "../../../Utils"; +import { SelectionManager } from "../../util/SelectionManager"; +import { UndoManager } from "../../util/UndoManager"; + +@observer +export class CollectionPileView extends CollectionSubView(doc => doc) { + _lastTap = 0; + _doubleTap: boolean | undefined = false; + _originalChrome: string = ""; + @observable _contentsActive = true; + @observable _layoutEngine = "pass"; + @observable _collapsed: boolean = false; + @observable _childClickedScript: Opt<ScriptField>; + componentDidMount() { + this._originalChrome = StrCast(this.layoutDoc._chromeStatus); + this.layoutDoc._chromeStatus = "disabled"; + this.layoutDoc.hideFilterView = true; + } + componentWillUnmount() { + this.layoutDoc.hideFilterView = false; + this.layoutDoc._chromeStatus = this._originalChrome; + } + + layoutEngine = () => this._layoutEngine; + + @computed get contents() { + return <div className="collectionPileView-innards" style={{ + width: "100%", + pointerEvents: this.layoutEngine() !== "pass" && (this.props.active() || this.layoutEngine() === "starburst") ? undefined : "none" + }} > + <CollectionFreeFormView {...this.props} layoutEngine={this.layoutEngine} /> + </div>; + } + + specificMenu = (e: React.MouseEvent) => { + const layoutItems: ContextMenuProps[] = []; + const doc = this.props.Document; + + ContextMenu.Instance.addItem({ description: "Options...", subitems: layoutItems, icon: "eye" }); + } + + toggleStarburst = action(() => { + if (this._layoutEngine === 'starburst') { + const defaultSize = 110; + this.layoutDoc.overflow = undefined; + this.rootDoc.x = NumCast(this.rootDoc.x) + this.layoutDoc[WidthSym]() / 2 - NumCast(this.layoutDoc._starburstPileWidth, defaultSize) / 2; + this.rootDoc.y = NumCast(this.rootDoc.y) + this.layoutDoc[HeightSym]() / 2 - NumCast(this.layoutDoc._starburstPileHeight, defaultSize) / 2; + this.layoutDoc._width = NumCast(this.layoutDoc._starburstPileWidth, defaultSize); + this.layoutDoc._height = NumCast(this.layoutDoc._starburstPileHeight, defaultSize); + this._layoutEngine = 'pass'; + } else { + const defaultSize = 25; + this.layoutDoc.overflow = 'visible'; + !this.layoutDoc._starburstRadius && (this.layoutDoc._starburstRadius = 500); + !this.layoutDoc._starburstDocScale && (this.layoutDoc._starburstDocScale = 2.5); + if (this._layoutEngine === 'pass') { + this.rootDoc.x = NumCast(this.rootDoc.x) + this.layoutDoc[WidthSym]() / 2 - defaultSize / 2; + this.rootDoc.y = NumCast(this.rootDoc.y) + this.layoutDoc[HeightSym]() / 2 - defaultSize / 2; + this.layoutDoc._starburstPileWidth = this.layoutDoc[WidthSym](); + this.layoutDoc._starburstPileHeight = this.layoutDoc[HeightSym](); + } + this.layoutDoc._width = this.layoutDoc._height = defaultSize; + this._layoutEngine = 'starburst'; + } + }); + + _undoBatch: UndoManager.Batch | undefined; + pointerDown = (e: React.PointerEvent) => { + let dist = 0; + SelectionManager.SetIsDragging(true); + // this._lastTap should be set to 0, and this._doubleTap should be set to false in the class header + setupMoveUpEvents(this, e, (e: PointerEvent, down: number[], delta: number[]) => { + if (this.layoutEngine() === "pass" && this.childDocs.length && this.props.isSelected(true)) { + dist += Math.sqrt(delta[0] * delta[0] + delta[1] * delta[1]); + if (dist > 100) { + if (!this._undoBatch) { + this._undoBatch = UndoManager.StartBatch("layout pile"); + } + const doc = this.childDocs[0]; + doc.x = e.clientX; + doc.y = e.clientY; + this.props.addDocTab(doc, "inParent") && this.props.removeDocument(doc); + dist = 0; + } + } + return false; + }, () => { + this._undoBatch?.end(); + this._undoBatch = undefined; + SelectionManager.SetIsDragging(false); + if (!this.childDocs.length) { + this.props.ContainingCollectionView?.removeDocument(this.props.Document); + } + }, emptyFunction, false, this.layoutEngine() === "pass" && this.props.isSelected(true)); // this sets _doubleTap + } + + onClick = (e: React.MouseEvent) => { + if (e.button === 0 && (this._doubleTap || this.layoutEngine() === "starburst")) { + SelectionManager.DeselectAll(); + this.toggleStarburst(); + e.stopPropagation(); + } + // else if (this.layoutEngine() === "pass") { + // runInAction(() => this._contentsActive = false); + // setTimeout(action(() => this._contentsActive = true), 300); + // } + } + + render() { + + return <div className={"collectionPileView"} onContextMenu={this.specificMenu} onClick={this.onClick} onPointerDown={this.pointerDown} + style={{ width: this.props.PanelWidth(), height: `calc(100% - ${this.props.Document._chromeStatus === "enabled" ? 51 : 0}px)` }}> + {this.contents} + </div>; + } +} diff --git a/src/client/views/collections/CollectionSchemaCells.tsx b/src/client/views/collections/CollectionSchemaCells.tsx index caffa7eb1..82204ca7b 100644 --- a/src/client/views/collections/CollectionSchemaCells.tsx +++ b/src/client/views/collections/CollectionSchemaCells.tsx @@ -4,8 +4,9 @@ import { observer } from "mobx-react"; import { CellInfo } from "react-table"; import "react-table/react-table.css"; import { emptyFunction, returnFalse, returnZero, returnOne } from "../../../Utils"; -import { Doc, DocListCast, DocListCastAsync, Field, Opt } from "../../../new_fields/Doc"; +import { Doc, DocListCast, Field, Opt } from "../../../new_fields/Doc"; import { Id } from "../../../new_fields/FieldSymbols"; +import { KeyCodes } from "../../util/KeyCodes"; import { SetupDrag, DragManager } from "../../util/DragManager"; import { CompileScript } from "../../util/Scripting"; import { Transform } from "../../util/Transform"; @@ -21,7 +22,6 @@ import { SelectionManager } from "../../util/SelectionManager"; import { library } from '@fortawesome/fontawesome-svg-core'; import { faExpand } from '@fortawesome/free-solid-svg-icons'; import { SchemaHeaderField } from "../../../new_fields/SchemaHeaderField"; -import { KeyCodes } from "../../northstar/utils/KeyCodes"; import { undoBatch } from "../../util/UndoManager"; library.add(faExpand); @@ -35,9 +35,10 @@ export interface CellProps { Document: Doc; fieldKey: string; renderDepth: number; - addDocTab: (document: Doc, dataDoc: Doc | undefined, where: string) => boolean; + addDocTab: (document: Doc, where: string) => boolean; pinToPres: (document: Doc) => void; - moveDocument: (document: Doc, targetCollection: Doc | undefined, addDocument: (document: Doc) => boolean) => boolean; + moveDocument: (document: Doc, targetCollection: Doc | undefined, + addDocument: (document: Doc) => boolean) => boolean; isFocused: boolean; changeFocusedCellByIndex: (row: number, col: number) => void; setIsEditing: (isEditing: boolean) => void; @@ -75,17 +76,28 @@ export class CollectionSchemaCell extends React.Component<CellProps> { @action isEditingCallback = (isEditing: boolean): void => { - document.addEventListener("keydown", this.onKeyDown); + document.removeEventListener("keydown", this.onKeyDown); + isEditing && document.addEventListener("keydown", this.onKeyDown); this._isEditing = isEditing; this.props.setIsEditing(isEditing); this.props.changeFocusedCellByIndex(this.props.row, this.props.col); } @action - onPointerDown = (e: React.PointerEvent): void => { + onPointerDown = async (e: React.PointerEvent): Promise<void> => { this.props.changeFocusedCellByIndex(this.props.row, this.props.col); this.props.setPreviewDoc(this.props.rowProps.original); + let url: string; + if (url = StrCast(this.props.rowProps.row.href)) { + try { + new URL(url); + const temp = window.open(url)!; + temp.blur(); + window.focus(); + } catch { } + } + // this._isEditing = true; // this.props.setIsEditing(true); @@ -144,6 +156,9 @@ export class CollectionSchemaCell extends React.Component<CellProps> { Document: this.props.rowProps.original, DataDoc: this.props.rowProps.original, LibraryPath: [], + dropAction: "alias", + bringToFront: emptyFunction, + rootSelected: returnFalse, fieldKey: this.props.rowProps.column.id as string, ContainingCollectionView: this.props.CollectionView, ContainingCollectionDoc: this.props.CollectionView && this.props.CollectionView.props.Document, @@ -156,6 +171,8 @@ export class CollectionSchemaCell extends React.Component<CellProps> { whenActiveChanged: emptyFunction, PanelHeight: returnZero, PanelWidth: returnZero, + NativeHeight: returnZero, + NativeWidth: returnZero, addDocTab: this.props.addDocTab, pinToPres: this.props.pinToPres, ContentScaling: returnOne @@ -235,7 +252,9 @@ export class CollectionSchemaCell extends React.Component<CellProps> { const script = CompileScript(value, { requiredType: type, typecheck: false, editable: true, addReturn: true, params: { this: Doc.name, $r: "number", $c: "number", $: "any" } }); if (script.compiled) { DocListCast(this.props.Document[this.props.fieldKey]). - forEach((doc, i) => this.applyToDoc(doc, i, this.props.col, script.run)); + forEach((doc, i) => value.startsWith(":=") ? + this.props.setComputed(value.substring(2), doc, this.props.rowProps.column.id!, i, this.props.col) : + this.applyToDoc(doc, i, this.props.col, script.run)); } }} /> diff --git a/src/client/views/collections/CollectionSchemaHeaders.tsx b/src/client/views/collections/CollectionSchemaHeaders.tsx index 92dc8780e..507ee89e4 100644 --- a/src/client/views/collections/CollectionSchemaHeaders.tsx +++ b/src/client/views/collections/CollectionSchemaHeaders.tsx @@ -5,11 +5,13 @@ import "./CollectionSchemaView.scss"; import { faPlus, faFont, faHashtag, faAlignJustify, faCheckSquare, faToggleOn, faSortAmountDown, faSortAmountUp, faTimes } from '@fortawesome/free-solid-svg-icons'; import { library, IconProp } from "@fortawesome/fontawesome-svg-core"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Flyout, anchorPoints } from "../DocumentDecorations"; import { ColumnType } from "./CollectionSchemaView"; import { faFile } from "@fortawesome/free-regular-svg-icons"; import { SchemaHeaderField, PastelSchemaPalette } from "../../../new_fields/SchemaHeaderField"; import { undoBatch } from "../../util/UndoManager"; +const higflyout = require("@hig/flyout"); +export const { anchorPoints } = higflyout; +export const Flyout = higflyout.default; library.add(faPlus, faFont, faHashtag, faAlignJustify, faCheckSquare, faToggleOn, faFile as any, faSortAmountDown, faSortAmountUp, faTimes); @@ -289,13 +291,11 @@ class KeysDropdown extends React.Component<KeysDropdownProps> { onKeyDown = (e: React.KeyboardEvent): void => { if (e.key === "Enter") { const keyOptions = this._searchTerm === "" ? this.props.possibleKeys : this.props.possibleKeys.filter(key => key.toUpperCase().indexOf(this._searchTerm.toUpperCase()) > -1); - const exactFound = keyOptions.findIndex(key => key.toUpperCase() === this._searchTerm.toUpperCase()) > -1 || - this.props.existingKeys.findIndex(key => key.toUpperCase() === this._searchTerm.toUpperCase()) > -1; - - if (!exactFound && this._searchTerm !== "" && this.props.canAddNew) { + if (keyOptions.length) { + this.onSelect(keyOptions[0]); + } else if (this._searchTerm !== "" && this.props.canAddNew) { + this.setSearchTerm(this._searchTerm || this._key); this.onSelect(this._searchTerm); - } else { - this.setSearchTerm(this._key); } } } @@ -336,7 +336,7 @@ class KeysDropdown extends React.Component<KeysDropdownProps> { this.props.existingKeys.findIndex(key => key.toUpperCase() === this._searchTerm.toUpperCase()) > -1; const options = keyOptions.map(key => { - return <div key={key} className="key-option" onClick={() => { this.onSelect(key); this.setSearchTerm(""); }}>{key}</div>; + return <div key={key} className="key-option" onPointerDown={e => e.stopPropagation()} onClick={() => { this.onSelect(key); this.setSearchTerm(""); }}>{key}</div>; }); // if search term does not already exist as a group type, give option to create new group type @@ -354,7 +354,7 @@ class KeysDropdown extends React.Component<KeysDropdownProps> { <div className="keys-dropdown"> <input className="keys-search" ref={this._inputRef} type="text" value={this._searchTerm} placeholder="Column key" onKeyDown={this.onKeyDown} onChange={e => this.onChange(e.target.value)} onFocus={this.onFocus} onBlur={this.onBlur}></input> - <div className="keys-options-wrapper" onPointerEnter={this.onPointerEnter} onPointerOut={this.onPointerOut}> + <div className="keys-options-wrapper" onPointerEnter={this.onPointerEnter} onPointerLeave={this.onPointerOut}> {this.renderOptions()} </div> </div > diff --git a/src/client/views/collections/CollectionSchemaMovableTableHOC.tsx b/src/client/views/collections/CollectionSchemaMovableTableHOC.tsx index 153bbd410..972714e34 100644 --- a/src/client/views/collections/CollectionSchemaMovableTableHOC.tsx +++ b/src/client/views/collections/CollectionSchemaMovableTableHOC.tsx @@ -3,9 +3,9 @@ import { ReactTableDefaults, TableCellRenderer, RowInfo } from "react-table"; import "./CollectionSchemaView.scss"; import { Transform } from "../../util/Transform"; import { Doc } from "../../../new_fields/Doc"; -import { DragManager, SetupDrag } from "../../util/DragManager"; +import { DragManager, SetupDrag, dropActionType } from "../../util/DragManager"; import { SelectionManager } from "../../util/SelectionManager"; -import { Cast, FieldValue } from "../../../new_fields/Types"; +import { Cast, FieldValue, StrCast } from "../../../new_fields/Types"; import { ContextMenu } from "../ContextMenu"; import { action } from "mobx"; import { library } from '@fortawesome/fontawesome-svg-core'; @@ -54,7 +54,7 @@ export class MovableColumn extends React.Component<MovableColumnProps> { } createColDropTarget = (ele: HTMLDivElement) => { - this._colDropDisposer && this._colDropDisposer(); + this._colDropDisposer?.(); if (ele) { this._colDropDisposer = DragManager.MakeDropTarget(ele, this.colDrop.bind(this)); } @@ -135,6 +135,7 @@ export interface MovableRowProps { rowFocused: boolean; textWrapRow: (doc: Doc) => void; rowWrapped: boolean; + dropAction: string; } export class MovableRow extends React.Component<MovableRowProps> { @@ -219,7 +220,7 @@ export class MovableRow extends React.Component<MovableRowProps> { if (!doc) return <></>; const reference = React.createRef<HTMLDivElement>(); - const onItemDown = SetupDrag(reference, () => doc, this.move); + const onItemDown = SetupDrag(reference, () => doc, this.move, StrCast(this.props.dropAction) as dropActionType); let className = "collectionSchema-row"; if (this.props.rowFocused) className += " row-focused"; diff --git a/src/client/views/collections/CollectionSchemaView.tsx b/src/client/views/collections/CollectionSchemaView.tsx index fa8be5177..380d91d2f 100644 --- a/src/client/views/collections/CollectionSchemaView.tsx +++ b/src/client/views/collections/CollectionSchemaView.tsx @@ -12,10 +12,8 @@ import { List } from "../../../new_fields/List"; import { listSpec } from "../../../new_fields/Schema"; import { SchemaHeaderField } from "../../../new_fields/SchemaHeaderField"; import { ComputedField } from "../../../new_fields/ScriptField"; -import { Cast, FieldValue, NumCast, StrCast } from "../../../new_fields/Types"; +import { Cast, FieldValue, NumCast, StrCast, BoolCast } from "../../../new_fields/Types"; import { Docs, DocumentOptions } from "../../documents/Documents"; -import { DocumentType } from "../../documents/DocumentTypes"; -import { Gateway } from "../../northstar/manager/Gateway"; import { CompileScript, Transformer, ts } from "../../util/Scripting"; import { Transform } from "../../util/Transform"; import { undoBatch } from "../../util/UndoManager"; @@ -29,6 +27,8 @@ import "./CollectionSchemaView.scss"; import { CollectionSubView } from "./CollectionSubView"; import { CollectionView } from "./CollectionView"; import { ContentFittingDocumentView } from "../nodes/ContentFittingDocumentView"; +import { setupMoveUpEvents, emptyFunction, returnZero, returnOne } from "../../../Utils"; +import { DocumentView } from "../nodes/DocumentView"; library.add(faCog, faPlus, faSortUp, faSortDown); library.add(faTable); @@ -44,20 +44,17 @@ export enum ColumnType { // this map should be used for keys that should have a const type of value const columnTypes: Map<string, ColumnType> = new Map([ ["title", ColumnType.String], - ["x", ColumnType.Number], ["y", ColumnType.Number], ["width", ColumnType.Number], ["height", ColumnType.Number], - ["nativeWidth", ColumnType.Number], ["nativeHeight", ColumnType.Number], ["isPrototype", ColumnType.Boolean], + ["x", ColumnType.Number], ["y", ColumnType.Number], ["_width", ColumnType.Number], ["_height", ColumnType.Number], + ["_nativeWidth", ColumnType.Number], ["_nativeHeight", ColumnType.Number], ["isPrototype", ColumnType.Boolean], ["page", ColumnType.Number], ["curPage", ColumnType.Number], ["currentTimecode", ColumnType.Number], ["zIndex", ColumnType.Number] ]); @observer export class CollectionSchemaView extends CollectionSubView(doc => doc) { - private _mainCont?: HTMLDivElement; - private _startPreviewWidth = 0; + private _previewCont?: HTMLDivElement; private DIVIDER_WIDTH = 4; - @observable previewScript: string = ""; @observable previewDoc: Doc | undefined = undefined; - @observable private _node: HTMLDivElement | null = null; @observable private _focusedTable: Doc = this.props.Document; @computed get previewWidth() { return () => NumCast(this.props.Document.schemaPreviewWidth); } @@ -66,7 +63,7 @@ export class CollectionSchemaView extends CollectionSubView(doc => doc) { @computed get borderWidth() { return Number(COLLECTION_BORDER_WIDTH); } private createTarget = (ele: HTMLDivElement) => { - this._mainCont = ele; + this._previewCont = ele; super.CreateDropTarget(ele); } @@ -76,9 +73,6 @@ export class CollectionSchemaView extends CollectionSubView(doc => doc) { @action setPreviewDoc = (doc: Doc) => this.previewDoc = doc; - @undoBatch - @action setPreviewScript = (script: string) => this.previewScript = script - //toggles preview side-panel of schema @action toggleExpander = () => { @@ -86,28 +80,17 @@ export class CollectionSchemaView extends CollectionSubView(doc => doc) { } onDividerDown = (e: React.PointerEvent) => { - this._startPreviewWidth = this.previewWidth(); - e.stopPropagation(); - e.preventDefault(); - document.addEventListener("pointermove", this.onDividerMove); - document.addEventListener('pointerup', this.onDividerUp); + setupMoveUpEvents(this, e, this.onDividerMove, emptyFunction, action(() => this.toggleExpander())); } @action - onDividerMove = (e: PointerEvent): void => { - const nativeWidth = this._mainCont!.getBoundingClientRect(); + onDividerMove = (e: PointerEvent, down: number[], delta: number[]) => { + const nativeWidth = this._previewCont!.getBoundingClientRect(); const minWidth = 40; const maxWidth = 1000; const movedWidth = this.props.ScreenToLocalTransform().transformDirection(nativeWidth.right - e.clientX, 0)[0]; const width = movedWidth < minWidth ? minWidth : movedWidth > maxWidth ? maxWidth : movedWidth; this.props.Document.schemaPreviewWidth = width; - } - @action - onDividerUp = (e: PointerEvent): void => { - document.removeEventListener("pointermove", this.onDividerMove); - document.removeEventListener('pointerup', this.onDividerUp); - if (this._startPreviewWidth === this.previewWidth()) { - this.toggleExpander(); - } + return false; } onPointerDown = (e: React.PointerEvent): void => { @@ -120,9 +103,7 @@ export class CollectionSchemaView extends CollectionSubView(doc => doc) { } @computed - get previewDocument(): Doc | undefined { - return this.previewDoc ? (this.previewScript && this.previewScript !== "this" ? FieldValue(Cast(this.previewDoc[this.previewScript], Doc)) : this.previewDoc) : undefined; - } + get previewDocument(): Doc | undefined { return this.previewDoc; } getPreviewTransform = (): Transform => { return this.props.ScreenToLocalTransform().translate(- this.borderWidth - this.DIVIDER_WIDTH - this.tableWidth, - this.borderWidth); @@ -136,27 +117,32 @@ export class CollectionSchemaView extends CollectionSubView(doc => doc) { @computed get previewPanel() { - const layoutDoc = this.previewDocument ? Doc.expandTemplateLayout(this.previewDocument, this.props.DataDoc) : undefined; - return <div ref={this.createTarget}> - <ContentFittingDocumentView - Document={layoutDoc} - DataDocument={this.previewDocument !== this.props.DataDoc ? this.props.DataDoc : undefined} - LibraryPath={this.props.LibraryPath} - childDocs={this.childDocs} - renderDepth={this.props.renderDepth} - PanelWidth={this.previewWidth} - PanelHeight={this.previewHeight} - getTransform={this.getPreviewTransform} - CollectionDoc={this.props.CollectionView && this.props.CollectionView.props.Document} - CollectionView={this.props.CollectionView} - moveDocument={this.props.moveDocument} - addDocument={this.props.addDocument} - removeDocument={this.props.removeDocument} - active={this.props.active} - whenActiveChanged={this.props.whenActiveChanged} - addDocTab={this.props.addDocTab} - pinToPres={this.props.pinToPres} - /> + return <div ref={this.createTarget} style={{ width: `${this.previewWidth()}px` }}> + {!this.previewDocument ? (null) : + <ContentFittingDocumentView + Document={this.previewDocument} + DataDocument={undefined} + NativeHeight={returnZero} + NativeWidth={returnZero} + fitToBox={true} + FreezeDimensions={true} + focus={emptyFunction} + LibraryPath={this.props.LibraryPath} + renderDepth={this.props.renderDepth} + rootSelected={this.rootSelected} + PanelWidth={this.previewWidth} + PanelHeight={this.previewHeight} + getTransform={this.getPreviewTransform} + CollectionDoc={this.props.CollectionView?.props.Document} + CollectionView={this.props.CollectionView} + moveDocument={this.props.moveDocument} + addDocument={this.props.addDocument} + removeDocument={this.props.removeDocument} + active={this.props.active} + whenActiveChanged={this.props.whenActiveChanged} + addDocTab={this.props.addDocTab} + pinToPres={this.props.pinToPres} + />} </div>; } @@ -175,7 +161,7 @@ export class CollectionSchemaView extends CollectionSubView(doc => doc) { moveDocument={this.props.moveDocument} ScreenToLocalTransform={this.props.ScreenToLocalTransform} active={this.props.active} - onDrop={this.onDrop} + onDrop={this.onExternalDrop} addDocTab={this.props.addDocTab} pinToPres={this.props.pinToPres} isSelected={this.props.isSelected} @@ -199,7 +185,7 @@ export class CollectionSchemaView extends CollectionSubView(doc => doc) { render() { return <div className="collectionSchemaView-container"> - <div className="collectionSchemaView-tableContainer" onPointerDown={this.onPointerDown} onWheel={e => this.props.active(true) && e.stopPropagation()} onDrop={e => this.onDrop(e, {})} ref={this.createTarget}> + <div className="collectionSchemaView-tableContainer" style={{ width: `calc(100% - ${this.previewWidth()}px)` }} onPointerDown={this.onPointerDown} onWheel={e => this.props.active(true) && e.stopPropagation()} onDrop={e => this.onExternalDrop(e, {})} ref={this.createTarget}> {this.schemaTable} </div> {this.dividerDragger} @@ -225,7 +211,7 @@ export interface SchemaTableProps { ScreenToLocalTransform: () => Transform; active: (outsideReaction: boolean) => boolean; onDrop: (e: React.DragEvent<Element>, options: DocumentOptions, completed?: (() => void) | undefined) => void; - addDocTab: (document: Doc, dataDoc: Doc | undefined, where: string) => boolean; + addDocTab: (document: Doc, where: string) => boolean; pinToPres: (document: Doc) => void; isSelected: (outsideReaction?: boolean) => boolean; isFocused: (document: Doc) => boolean; @@ -409,7 +395,8 @@ export class SchemaTable extends React.Component<SchemaTableProps> { rowInfo, rowFocused: !this._headerIsEditing && rowInfo.index === this._focusedCell.row && this.props.isFocused(this.props.Document), textWrapRow: this.toggleTextWrapRow, - rowWrapped: this.textWrappedRows.findIndex(id => rowInfo.original[Id] === id) > -1 + rowWrapped: this.textWrappedRows.findIndex(id => rowInfo.original[Id] === id) > -1, + dropAction: StrCast(this.props.Document.childDropAction) }; } @@ -477,8 +464,7 @@ export class SchemaTable extends React.Component<SchemaTableProps> { @undoBatch createRow = () => { - const newDoc = Docs.Create.TextDocument("", { title: "", _width: 100, _height: 30 }); - this.props.addDocument(newDoc); + this.props.addDocument(Docs.Create.TextDocument("", { title: "", _width: 100, _height: 30 })); } @undoBatch @@ -559,16 +545,6 @@ export class SchemaTable extends React.Component<SchemaTableProps> { columns[index] = columnField; this.columns = columns; } - - // const typesDoc = FieldValue(Cast(this.props.Document.schemaColumnTypes, Doc)); - // if (!typesDoc) { - // let newTypesDoc = new Doc(); - // newTypesDoc[key] = type; - // this.props.Document.schemaColumnTypes = newTypesDoc; - // return; - // } else { - // typesDoc[key] = type; - // } } @undoBatch @@ -692,28 +668,7 @@ export class SchemaTable extends React.Component<SchemaTableProps> { onContextMenu = (e: React.MouseEvent): void => { if (!e.isPropagationStopped() && this.props.Document[Id] !== "mainDoc") { // need to test this because GoldenLayout causes a parallel hierarchy in the React DOM for its children and the main document view7 // ContextMenu.Instance.addItem({ description: "Make DB", event: this.makeDB, icon: "table" }); - ContextMenu.Instance.addItem({ description: "Toggle text wrapping", event: this.toggleTextwrap, icon: "table" }) - } - } - - @action - makeDB = async () => { - let csv: string = this.columns.reduce((val, col) => val + col + ",", ""); - csv = csv.substr(0, csv.length - 1) + "\n"; - const self = this; - this.childDocs.map(doc => { - csv += self.columns.reduce((val, col) => val + (doc[col.heading] ? doc[col.heading]!.toString() : "0") + ",", ""); - csv = csv.substr(0, csv.length - 1) + "\n"; - }); - csv.substring(0, csv.length - 1); - const dbName = StrCast(this.props.Document.title); - const res = await Gateway.Instance.PostSchema(csv, dbName); - if (self.props.CollectionView && self.props.CollectionView.props.addDocument) { - const schemaDoc = await Docs.Create.DBDocument("https://www.cs.brown.edu/" + dbName, { title: dbName }, { dbDoc: self.props.Document }); - if (schemaDoc) { - //self.props.CollectionView.props.addDocument(schemaDoc, false); - self.props.Document.schemaDoc = schemaDoc; - } + ContextMenu.Instance.addItem({ description: "Toggle text wrapping", event: this.toggleTextwrap, icon: "table" }); } } @@ -781,7 +736,7 @@ export class SchemaTable extends React.Component<SchemaTableProps> { return (doc as any)[key][row + ${row}][(doc as any).schemaColumns[col + ${col}].heading]; } return ${script}`; - const compiled = CompileScript(script, { params: { this: Doc.name }, capturedVariables: { doc: this.props.Document, key: this.props.fieldKey }, typecheck: true, transformer: this.createTransformer(row, col) }); + const compiled = CompileScript(script, { params: { this: Doc.name }, capturedVariables: { doc: this.props.Document, key: this.props.fieldKey }, typecheck: false, transformer: this.createTransformer(row, col) }); if (compiled.compiled) { doc[field] = new ComputedField(compiled); return true; diff --git a/src/client/views/collections/CollectionStackingView.scss b/src/client/views/collections/CollectionStackingView.scss index 293dc5414..47faa9239 100644 --- a/src/client/views/collections/CollectionStackingView.scss +++ b/src/client/views/collections/CollectionStackingView.scss @@ -26,6 +26,9 @@ position: relative; display: block; } + .collectionStackingViewFieldColumn { + height:max-content; + } .collectionSchemaView-previewDoc { height: 100%; @@ -160,9 +163,7 @@ } .collectionStackingView-sectionHeader { text-align: center; - margin-left: 2px; - margin-right: 2px; - margin-top: 10px; + margin: auto; background: $main-accent; // overflow: hidden; overflow is visible so the color menu isn't hidden -ftong @@ -214,6 +215,7 @@ left: 0; top: 0; height: 100%; + display: none; [class*="css"] { max-width: 102px; @@ -251,6 +253,7 @@ right: 0; top: 0; height: 100%; + display: none; [class*="css"] { max-width: 102px; @@ -285,6 +288,18 @@ right: 25px; top: 0; height: 100%; + display: none; + } + } + .collectionStackingView-sectionHeader:hover { + .collectionStackingView-sectionColor { + display:unset; + } + .collectionStackingView-sectionOptions { + display:unset; + } + .collectionStackingView-sectionDelete { + display:unset; } } @@ -294,7 +309,6 @@ overflow: hidden; margin: auto; width: 90%; - color: lightgrey; overflow: ellipses; .editableView-container-editing-oneLine, diff --git a/src/client/views/collections/CollectionStackingView.tsx b/src/client/views/collections/CollectionStackingView.tsx index d772ace23..e3720bf01 100644 --- a/src/client/views/collections/CollectionStackingView.tsx +++ b/src/client/views/collections/CollectionStackingView.tsx @@ -9,60 +9,63 @@ import { Id } from "../../../new_fields/FieldSymbols"; import { List } from "../../../new_fields/List"; import { listSpec } from "../../../new_fields/Schema"; import { SchemaHeaderField } from "../../../new_fields/SchemaHeaderField"; -import { BoolCast, Cast, NumCast, StrCast, ScriptCast } from "../../../new_fields/Types"; -import { emptyFunction, Utils } from "../../../Utils"; -import { DragManager } from "../../util/DragManager"; +import { BoolCast, Cast, NumCast, ScriptCast, StrCast } from "../../../new_fields/Types"; +import { TraceMobx } from "../../../new_fields/util"; +import { Utils, setupMoveUpEvents, emptyFunction, returnZero, returnOne } from "../../../Utils"; +import { DragManager, dropActionType } from "../../util/DragManager"; import { Transform } from "../../util/Transform"; import { undoBatch } from "../../util/UndoManager"; +import { ContextMenu } from "../ContextMenu"; +import { ContextMenuProps } from "../ContextMenuItem"; import { EditableView } from "../EditableView"; import { ContentFittingDocumentView } from "../nodes/ContentFittingDocumentView"; +import { CollectionMasonryViewFieldRow } from "./CollectionMasonryViewFieldRow"; import "./CollectionStackingView.scss"; import { CollectionStackingViewFieldColumn } from "./CollectionStackingViewFieldColumn"; import { CollectionSubView } from "./CollectionSubView"; -import { ContextMenu } from "../ContextMenu"; -import { ContextMenuProps } from "../ContextMenuItem"; -import { CollectionMasonryViewFieldRow } from "./CollectionMasonryViewFieldRow"; -import { TraceMobx } from "../../../new_fields/util"; import { CollectionViewType } from "./CollectionView"; +import { SelectionManager } from "../../util/SelectionManager"; +const _global = (window /* browser */ || global /* node */) as any; @observer export class CollectionStackingView extends CollectionSubView(doc => doc) { _masonryGridRef: HTMLDivElement | null = null; _draggerRef = React.createRef<HTMLDivElement>(); - _heightDisposer?: IReactionDisposer; - _sectionFilterDisposer?: IReactionDisposer; + _pivotFieldDisposer?: IReactionDisposer; _docXfs: any[] = []; _columnStart: number = 0; @observable _heightMap = new Map<string, number>(); @observable _cursor: CursorProperty = "grab"; @observable _scroll = 0; // used to force the document decoration to update when scrolling @computed get sectionHeaders() { return Cast(this.props.Document.sectionHeaders, listSpec(SchemaHeaderField)); } - @computed get sectionFilter() { return StrCast(this.props.Document.sectionFilter); } - @computed get filteredChildren() { return this.childLayoutPairs.filter(pair => pair.layout instanceof Doc && !pair.layout.isMinimized).map(pair => pair.layout); } + @computed get pivotField() { return StrCast(this.props.Document._pivotField); } + @computed get filteredChildren() { return this.childLayoutPairs.filter(pair => pair.layout instanceof Doc).map(pair => pair.layout); } @computed get xMargin() { return NumCast(this.props.Document._xMargin, 2 * Math.min(this.gridGap, .05 * this.props.PanelWidth())); } - @computed get yMargin() { return Math.max(this.props.Document.showTitle && !this.props.Document.showTitleHover ? 30 : 0, NumCast(this.props.Document._yMargin, 0)); } // 2 * this.gridGap)); } + @computed get yMargin() { return Math.max(this.props.Document._showTitle && !this.props.Document._showTitleHover ? 30 : 0, NumCast(this.props.Document._yMargin, 0)); } // 2 * this.gridGap)); } @computed get gridGap() { return NumCast(this.props.Document._gridGap, 10); } @computed get isStackingView() { return BoolCast(this.props.Document.singleColumn, true); } @computed get numGroupColumns() { return this.isStackingView ? Math.max(1, this.Sections.size + (this.showAddAGroup ? 1 : 0)) : 1; } - @computed get showAddAGroup() { return (this.sectionFilter && (this.props.Document._chromeStatus !== 'view-mode' && this.props.Document._chromeStatus !== 'disabled')); } + @computed get showAddAGroup() { return (this.pivotField && (this.props.Document._chromeStatus !== 'view-mode' && this.props.Document._chromeStatus !== 'disabled')); } @computed get columnWidth() { + TraceMobx(); return Math.min(this.props.PanelWidth() / (this.props as any).ContentScaling() - 2 * this.xMargin, - this.isStackingView ? Number.MAX_VALUE : NumCast(this.props.Document.columnWidth, 250)); + this.isStackingView ? Number.MAX_VALUE : this.props.Document.columnWidth === -1 ? this.props.PanelWidth() - 2 * this.xMargin : NumCast(this.props.Document.columnWidth, 250)); } @computed get NodeWidth() { return this.props.PanelWidth() - this.gridGap; } children(docs: Doc[], columns?: number) { + TraceMobx(); this._docXfs.length = 0; return docs.map((d, i) => { const height = () => this.getDocHeight(d); - const width = () => this.widthScale * Math.min(d._nativeWidth && !d.ignoreAspect && !this.props.Document.fillColumn ? d[WidthSym]() : Number.MAX_VALUE, this.columnWidth / this.numGroupColumns); + const width = () => this.getDocWidth(d); const dref = React.createRef<HTMLDivElement>(); const dxf = () => this.getDocTransform(d, dref.current!); this._docXfs.push({ dxf: dxf, width: width, height: height }); const rowSpan = Math.ceil((height() + this.gridGap) / this.gridGap); - const style = this.isStackingView ? { width: width(), marginTop: i === 0 ? 0 : this.gridGap, height: height() } : { gridRowEnd: `span ${rowSpan}` }; + const style = this.isStackingView ? { width: width(), marginTop: i ? this.gridGap : 0, height: height() } : { gridRowEnd: `span ${rowSpan}` }; return <div className={`collectionStackingView-${this.isStackingView ? "columnDoc" : "masonryDoc"}`} key={d[Id]} ref={dref} style={style} > - {this.getDisplayDoc(d, Cast(d.resolvedDataDoc, Doc, null) || this.props.DataDoc, dxf, width)} + {this.getDisplayDoc(d, (!d.isTemplateDoc && !d.isTemplateForField && !d.PARAMS) ? undefined : this.props.DataDoc, dxf, width)} </div>; }); } @@ -72,73 +75,70 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { } get Sections() { - if (!this.sectionFilter || this.sectionHeaders instanceof Promise) return new Map<SchemaHeaderField, Doc[]>(); + if (!this.pivotField || this.sectionHeaders instanceof Promise) return new Map<SchemaHeaderField, Doc[]>(); if (this.sectionHeaders === undefined) { setTimeout(() => this.props.Document.sectionHeaders = new List<SchemaHeaderField>(), 0); return new Map<SchemaHeaderField, Doc[]>(); } - const sectionHeaders = this.sectionHeaders; + const sectionHeaders: SchemaHeaderField[] = Array.from(this.sectionHeaders); const fields = new Map<SchemaHeaderField, Doc[]>(sectionHeaders.map(sh => [sh, []] as [SchemaHeaderField, []])); + let changed = false; this.filteredChildren.map(d => { - const sectionValue = (d[this.sectionFilter] ? d[this.sectionFilter] : `NO ${this.sectionFilter.toUpperCase()} VALUE`) as object; + const sectionValue = (d[this.pivotField] ? d[this.pivotField] : `NO ${this.pivotField.toUpperCase()} VALUE`) as object; // the next five lines ensures that floating point rounding errors don't create more than one section -syip const parsed = parseInt(sectionValue.toString()); const castedSectionValue = !isNaN(parsed) ? parsed : sectionValue; // look for if header exists already - const existingHeader = sectionHeaders.find(sh => sh.heading === (castedSectionValue ? castedSectionValue.toString() : `NO ${this.sectionFilter.toUpperCase()} VALUE`)); + const existingHeader = sectionHeaders.find(sh => sh.heading === (castedSectionValue ? castedSectionValue.toString() : `NO ${this.pivotField.toUpperCase()} VALUE`)); if (existingHeader) { fields.get(existingHeader)!.push(d); } else { - const newSchemaHeader = new SchemaHeaderField(castedSectionValue ? castedSectionValue.toString() : `NO ${this.sectionFilter.toUpperCase()} VALUE`); + const newSchemaHeader = new SchemaHeaderField(castedSectionValue ? castedSectionValue.toString() : `NO ${this.pivotField.toUpperCase()} VALUE`); fields.set(newSchemaHeader, [d]); sectionHeaders.push(newSchemaHeader); + changed = true; } }); + // remove all empty columns if hideHeadings is set + if (this.props.Document.hideHeadings) { + Array.from(fields.keys()).filter(key => !fields.get(key)!.length).map(header => { + fields.delete(header); + sectionHeaders.splice(sectionHeaders.indexOf(header), 1); + changed = true; + }); + } + changed && setTimeout(action(() => { if (this.sectionHeaders) { this.sectionHeaders.length = 0; this.sectionHeaders.push(...sectionHeaders); } }), 0); return fields; } + getSimpleDocHeight(d?: Doc) { + if (!d) return 0; + const layoutDoc = Doc.Layout(d, this.props.childLayoutTemplate?.()); + const nw = NumCast(layoutDoc._nativeWidth); + const nh = NumCast(layoutDoc._nativeHeight); + let wid = this.columnWidth / (this.isStackingView ? this.numGroupColumns : 1); + if (!layoutDoc._fitWidth && nw && nh) { + const aspect = nw && nh ? nh / nw : 1; + if (!(this.props.Document.fillColumn)) wid = Math.min(layoutDoc[WidthSym](), wid); + return wid * aspect; + } + return layoutDoc._fitWidth ? wid * NumCast(layoutDoc.scrollHeight, nh) / (nw || 1) : layoutDoc[HeightSym](); + } componentDidMount() { super.componentDidMount(); - this._heightDisposer = reaction(() => { - if (this.props.Document._autoHeight) { - const sectionsList = Array.from(this.Sections.size ? this.Sections.values() : [this.filteredChildren]); - if (this.isStackingView) { - const res = this.props.ContentScaling() * sectionsList.reduce((maxHght, s) => { - const r1 = Math.max(maxHght, - (this.Sections.size ? 50 : 0) + s.reduce((height, d, i) => { - const val = height + this.getDocHeight(d) + (i === s.length - 1 ? this.yMargin : this.gridGap); - return val; - }, this.yMargin)); - return r1; - }, 0); - return res; - } else { - const sum = Array.from(this._heightMap.values()).reduce((acc: number, curr: number) => acc += curr, 0); - return this.props.ContentScaling() * (sum + (this.Sections.size ? (this.props.Document.miniHeaders ? 20 : 85) : -15)); - } - } - return -1; - }, - (hgt: number) => { - const doc = hgt === -1 ? undefined : this.props.DataDoc && this.props.DataDoc.layout === this.layoutDoc ? this.props.DataDoc : this.layoutDoc; - doc && hgt > 0 && (Doc.Layout(doc)._height = hgt); - }, - { fireImmediately: true } - ); // reset section headers when a new filter is inputted - this._sectionFilterDisposer = reaction( - () => this.sectionFilter, + this._pivotFieldDisposer = reaction( + () => this.pivotField, () => this.props.Document.sectionHeaders = new List() ); } componentWillUnmount() { super.componentWillUnmount(); - this._heightDisposer && this._heightDisposer(); - this._sectionFilterDisposer && this._sectionFilterDisposer(); + this._pivotFieldDisposer?.(); } @action @@ -153,67 +153,75 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { @computed get onChildClickHandler() { return ScriptCast(this.Document.onChildClick); } @computed get onClickHandler() { return ScriptCast(this.Document.onChildClick); } + addDocTab = (doc: Doc, where: string) => { + if (where === "inPlace" && this.layoutDoc.isInPlaceContainer) { + this.dataDoc[this.props.fieldKey] = new List<Doc>([doc]); + return true; + } + return this.props.addDocTab(doc, where); + } getDisplayDoc(doc: Doc, dataDoc: Doc | undefined, dxf: () => Transform, width: () => number) { - const layoutDoc = Doc.Layout(doc); + const layoutDoc = Doc.Layout(doc, this.props.childLayoutTemplate?.()); const height = () => this.getDocHeight(doc); return <ContentFittingDocumentView Document={doc} - DataDocument={dataDoc} + DataDocument={dataDoc || (doc[DataSym] !== doc && doc[DataSym])} + backgroundColor={this.props.backgroundColor} + LayoutDoc={this.props.childLayoutTemplate} LibraryPath={this.props.LibraryPath} + FreezeDimensions={this.props.freezeChildDimensions} renderDepth={this.props.renderDepth + 1} - fitToBox={this.props.fitToBox} - onClick={layoutDoc.isTemplateDoc ? this.onClickHandler : this.onChildClickHandler} PanelWidth={width} PanelHeight={height} + NativeHeight={returnZero} + NativeWidth={returnZero} + fitToBox={BoolCast(this.props.Document._freezeChildDimensions)} + rootSelected={this.rootSelected} + dropAction={StrCast(this.props.Document.childDropAction) as dropActionType} + onClick={layoutDoc.isTemplateDoc ? this.onClickHandler : this.onChildClickHandler} getTransform={dxf} focus={this.props.focus} - CollectionDoc={this.props.CollectionView && this.props.CollectionView.props.Document} + CollectionDoc={this.props.CollectionView?.props.Document} CollectionView={this.props.CollectionView} addDocument={this.props.addDocument} moveDocument={this.props.moveDocument} removeDocument={this.props.removeDocument} active={this.props.active} whenActiveChanged={this.props.whenActiveChanged} - addDocTab={this.props.addDocTab} - pinToPres={this.props.pinToPres}> - </ContentFittingDocumentView>; + addDocTab={this.addDocTab} + pinToPres={this.props.pinToPres} + />; + } + + getDocWidth(d?: Doc) { + if (!d) return 0; + const layoutDoc = Doc.Layout(d, this.props.childLayoutTemplate?.()); + const nw = NumCast(layoutDoc._nativeWidth); + return Math.min(nw && !this.props.Document.fillColumn ? d[WidthSym]() : Number.MAX_VALUE, this.columnWidth / this.numGroupColumns); } getDocHeight(d?: Doc) { if (!d) return 0; - const layoutDoc = Doc.Layout(d); + const layoutDoc = Doc.Layout(d, this.props.childLayoutTemplate?.()); const nw = NumCast(layoutDoc._nativeWidth); const nh = NumCast(layoutDoc._nativeHeight); let wid = this.columnWidth / (this.isStackingView ? this.numGroupColumns : 1); - if (!layoutDoc.ignoreAspect && !layoutDoc._fitWidth && nw && nh) { + if (!layoutDoc._fitWidth && nw && nh) { const aspect = nw && nh ? nh / nw : 1; - if (!(d._nativeWidth && !layoutDoc.ignoreAspect && this.props.Document.fillColumn)) wid = Math.min(layoutDoc[WidthSym](), wid); + if (!(this.props.Document.fillColumn)) wid = Math.min(layoutDoc[WidthSym](), wid); return wid * aspect; } - return layoutDoc._fitWidth ? !layoutDoc._nativeHeight ? this.props.PanelHeight() - 2 * this.yMargin : - Math.min(wid * NumCast(layoutDoc.scrollHeight, NumCast(layoutDoc._nativeHeight)) / NumCast(layoutDoc._nativeWidth, 1), this.props.PanelHeight() - 2 * this.yMargin) : layoutDoc[HeightSym](); + return layoutDoc._fitWidth ? !nh ? this.props.PanelHeight() - 2 * this.yMargin : + Math.min(wid * NumCast(layoutDoc.scrollHeight, nh) / (nw || 1), this.props.PanelHeight() - 2 * this.yMargin) : layoutDoc[HeightSym](); } columnDividerDown = (e: React.PointerEvent) => { - e.stopPropagation(); - e.preventDefault(); runInAction(() => this._cursor = "grabbing"); - document.addEventListener("pointermove", this.onDividerMove); - document.addEventListener('pointerup', this.onDividerUp); - this._columnStart = this.props.ScreenToLocalTransform().transformPoint(e.clientX, e.clientY)[0]; + setupMoveUpEvents(this, e, this.onDividerMove, action(() => this._cursor = "grab"), emptyFunction); } @action - onDividerMove = (e: PointerEvent): void => { - const dragPos = this.props.ScreenToLocalTransform().transformPoint(e.clientX, e.clientY)[0]; - const delta = dragPos - this._columnStart; - this._columnStart = dragPos; - this.layoutDoc.columnWidth = Math.max(10, this.columnWidth + delta); - } - - @action - onDividerUp = (e: PointerEvent): void => { - runInAction(() => this._cursor = "grab"); - document.removeEventListener("pointermove", this.onDividerMove); - document.removeEventListener('pointerup', this.onDividerUp); + onDividerMove = (e: PointerEvent, down: number[], delta: number[]) => { + this.layoutDoc.columnWidth = Math.max(10, this.columnWidth + delta[0]); + return false; } @computed get columnDragger() { @@ -225,7 +233,7 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { @undoBatch @action - drop = (e: Event, de: DragManager.DropEvent) => { + onInternalDrop = (e: Event, de: DragManager.DropEvent) => { const where = [de.x, de.y]; let targInd = -1; let plusOne = 0; @@ -239,7 +247,7 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { plusOne = where[axis] > (pos[axis] + pos1[axis]) / 2 ? 1 : 0; } }); - if (super.drop(e, de)) { + if (super.onInternalDrop(e, de)) { const newDoc = de.complete.docDragData.droppedDocuments[0]; const docs = this.childDocList; if (docs) { @@ -255,7 +263,7 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { } @undoBatch @action - onDrop = async (e: React.DragEvent): Promise<void> => { + onExternalDrop = async (e: React.DragEvent): Promise<void> => { const where = [e.clientX, e.clientY]; let targInd = -1; this._docXfs.map((cd, i) => { @@ -265,7 +273,7 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { targInd = i; } }); - super.onDrop(e, {}, () => { + super.onExternalDrop(e, {}, () => { if (targInd !== -1) { const newDoc = this.childDocs[this.childDocs.length - 1]; const docs = this.childDocList; @@ -276,9 +284,10 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { } }); } - headings = () => Array.from(this.Sections.keys()); + headings = () => Array.from(this.Sections); + refList: any[] = []; sectionStacking = (heading: SchemaHeaderField | undefined, docList: Doc[]) => { - const key = this.sectionFilter; + const key = this.pivotField; let type: "string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function" | undefined = undefined; const types = docList.length ? docList.map(d => typeof d[key]) : this.filteredChildren.map(d => typeof d[key]); if (types.map((i, idx) => types.indexOf(i) === idx).length === 1) { @@ -287,6 +296,19 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { const cols = () => this.isStackingView ? 1 : Math.max(1, Math.min(this.filteredChildren.length, Math.floor((this.props.PanelWidth() - 2 * this.xMargin) / (this.columnWidth + this.gridGap)))); return <CollectionStackingViewFieldColumn + unobserveHeight={(ref) => this.refList.splice(this.refList.indexOf(ref), 1)} + observeHeight={(ref) => { + if (ref) { + this.refList.push(ref); + const doc = this.props.DataDoc && this.props.DataDoc.layout === this.layoutDoc ? this.props.DataDoc : this.layoutDoc; + this.observer = new _global.ResizeObserver(action((entries: any) => { + if (this.props.Document._autoHeight && ref && this.refList.length && !SelectionManager.GetIsDragging()) { + Doc.Layout(doc)._height = Math.min(1200, Math.max(...this.refList.map(r => Number(getComputedStyle(r).height.replace("px", ""))))); + } + })); + this.observer.observe(ref); + } + }} key={heading ? heading.heading : ""} cols={cols} headings={this.headings} @@ -305,15 +327,13 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { const y = this._scroll; // required for document decorations to update when the text box container is scrolled const { scale, translateX, translateY } = Utils.GetScreenTransform(dref); const outerXf = Utils.GetScreenTransform(this._masonryGridRef!); - const scaling = 1 / Math.min(1, this.props.PanelHeight() / this.layoutDoc[HeightSym]()); const offset = this.props.ScreenToLocalTransform().transformDirection(outerXf.translateX - translateX, outerXf.translateY - translateY); - const offsetx = (doc[WidthSym]() - doc[WidthSym]() / scaling) / 2; const offsety = (this.props.ChromeHeight && this.props.ChromeHeight() < 0 ? this.props.ChromeHeight() : 0); - return this.props.ScreenToLocalTransform().translate(offset[0] - offsetx, offset[1] + offsety).scale(scaling); + return this.props.ScreenToLocalTransform().translate(offset[0], offset[1] + offsety); } - sectionMasonry = (heading: SchemaHeaderField | undefined, docList: Doc[]) => { - const key = this.sectionFilter; + sectionMasonry = (heading: SchemaHeaderField | undefined, docList: Doc[], first: boolean) => { + const key = this.pivotField; let type: "string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function" | undefined = undefined; const types = docList.length ? docList.map(d => typeof d[key]) : this.filteredChildren.map(d => typeof d[key]); if (types.map((i, idx) => types.indexOf(i) === idx).length === 1) { @@ -322,6 +342,20 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { const rows = () => !this.isStackingView ? 1 : Math.max(1, Math.min(docList.length, Math.floor((this.props.PanelWidth() - 2 * this.xMargin) / (this.columnWidth + this.gridGap)))); return <CollectionMasonryViewFieldRow + showHandle={first} + unobserveHeight={(ref) => this.refList.splice(this.refList.indexOf(ref), 1)} + observeHeight={(ref) => { + if (ref) { + this.refList.push(ref); + const doc = this.props.DataDoc && this.props.DataDoc.layout === this.layoutDoc ? this.props.DataDoc : this.layoutDoc; + this.observer = new _global.ResizeObserver(action((entries: any) => { + if (this.props.Document._autoHeight && ref && this.refList.length && !SelectionManager.GetIsDragging()) { + Doc.Layout(doc)._height = this.refList.reduce((p, r) => p + Number(getComputedStyle(r).height.replace("px", "")), 0); + } + })); + this.observer.observe(ref); + } + }} key={heading ? heading.heading : ""} rows={rows} headings={this.headings} @@ -339,7 +373,9 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { @action addGroup = (value: string) => { if (value && this.sectionHeaders) { - this.sectionHeaders.push(new SchemaHeaderField(value)); + const schemaHdrField = new SchemaHeaderField(value); + this.sectionHeaders.push(schemaHdrField); + Doc.addFieldEnumerations(undefined, this.pivotField, [{ title: value, _backgroundColor: schemaHdrField.color }]); return true; } return false; @@ -361,31 +397,27 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { if (!e.isPropagationStopped()) { const subItems: ContextMenuProps[] = []; subItems.push({ description: `${this.props.Document.fillColumn ? "Variable Size" : "Autosize"} Column`, event: () => this.props.Document.fillColumn = !this.props.Document.fillColumn, icon: "plus" }); - subItems.push({ description: `${this.props.Document.showTitles ? "Hide Titles" : "Show Titles"}`, event: () => this.props.Document.showTitles = !this.props.Document.showTitles ? "title" : "", icon: "plus" }); - subItems.push({ description: `${this.props.Document.showCaptions ? "Hide Captions" : "Show Captions"}`, event: () => this.props.Document.showCaptions = !this.props.Document.showCaptions ? "caption" : "", icon: "plus" }); - ContextMenu.Instance.addItem({ description: "Stacking Options ...", subitems: subItems, icon: "eye" }); + ContextMenu.Instance.addItem({ description: "Options...", subitems: subItems, icon: "eye" }); } } @computed get renderedSections() { TraceMobx(); let sections = [[undefined, this.filteredChildren] as [SchemaHeaderField | undefined, Doc[]]]; - if (this.sectionFilter) { + if (this.pivotField) { const entries = Array.from(this.Sections.entries()); sections = entries.sort(this.sortFunc); } - return sections.map(section => this.isStackingView ? this.sectionStacking(section[0], section[1]) : this.sectionMasonry(section[0], section[1])); - } - @computed get contentScale() { - const heightExtra = this.heightPercent > 1 ? this.heightPercent : 1; - return Math.min(this.props.Document[WidthSym]() / this.props.PanelWidth(), heightExtra * this.props.Document[HeightSym]() / this.props.PanelHeight()); - } - @computed get widthScale() { - return this.heightPercent < 1 ? Math.max(1, this.contentScale) : 1; - } - @computed get heightPercent() { - return this.props.PanelHeight() / this.layoutDoc[HeightSym](); + return sections.map((section, i) => this.isStackingView ? this.sectionStacking(section[0], section[1]) : this.sectionMasonry(section[0], section[1], i === 0)); } + + + @computed get nativeWidth() { return NumCast(this.layoutDoc._nativeWidth) || this.props.NativeWidth() || 0; } + @computed get nativeHeight() { return NumCast(this.layoutDoc._nativeHeight) || this.props.NativeHeight() || 0; } + + @computed get scaling() { return !this.nativeWidth ? 1 : this.props.PanelHeight() / this.nativeHeight; } + + observer: any; render() { TraceMobx(); const editableViewProps = { @@ -399,13 +431,13 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { ref={this.createRef} style={{ overflowY: this.props.active() ? "auto" : "hidden", - transform: `scale(${Math.min(1, this.heightPercent)})`, - height: `${100 * Math.max(1, this.contentScale)}%`, - width: `${100 * this.widthScale}%`, + transform: `scale(${this.scaling}`, + height: `${1 / this.scaling * 100}%`, + width: `${1 / this.scaling * 100}%`, transformOrigin: "top left", }} onScroll={action((e: React.UIEvent<HTMLDivElement>) => this._scroll = e.currentTarget.scrollTop)} - onDrop={this.onDrop.bind(this)} + onDrop={this.onExternalDrop.bind(this)} onContextMenu={this.onContextMenu} onWheel={e => this.props.active() && e.stopPropagation()} > {this.renderedSections} @@ -414,7 +446,7 @@ export class CollectionStackingView extends CollectionSubView(doc => doc) { style={{ width: !this.isStackingView ? "100%" : this.columnWidth / this.numGroupColumns - 10, marginTop: 10 }}> <EditableView {...editableViewProps} /> </div>} - {this.props.Document._chromeStatus !== 'disabled' ? <Switch + {this.props.Document._chromeStatus !== 'disabled' && this.props.isSelected() ? <Switch onChange={this.onToggle} onClick={this.onToggle} defaultChecked={this.props.Document._chromeStatus !== 'view-mode'} diff --git a/src/client/views/collections/CollectionStackingViewFieldColumn.tsx b/src/client/views/collections/CollectionStackingViewFieldColumn.tsx index 21982f1ca..323d7be25 100644 --- a/src/client/views/collections/CollectionStackingViewFieldColumn.tsx +++ b/src/client/views/collections/CollectionStackingViewFieldColumn.tsx @@ -8,20 +8,24 @@ import { Doc, DocListCast } from "../../../new_fields/Doc"; import { RichTextField } from "../../../new_fields/RichTextField"; import { PastelSchemaPalette, SchemaHeaderField } from "../../../new_fields/SchemaHeaderField"; import { ScriptField } from "../../../new_fields/ScriptField"; -import { NumCast, StrCast } from "../../../new_fields/Types"; +import { NumCast, StrCast, Cast } from "../../../new_fields/Types"; import { ImageField } from "../../../new_fields/URLField"; import { TraceMobx } from "../../../new_fields/util"; -import { Docs } from "../../documents/Documents"; +import { Docs, DocUtils } from "../../documents/Documents"; import { DragManager } from "../../util/DragManager"; import { SelectionManager } from "../../util/SelectionManager"; import { Transform } from "../../util/Transform"; import { undoBatch } from "../../util/UndoManager"; import { ContextMenu } from "../ContextMenu"; import { ContextMenuProps } from "../ContextMenuItem"; -import { anchorPoints, Flyout } from "../DocumentDecorations"; import { EditableView } from "../EditableView"; import { CollectionStackingView } from "./CollectionStackingView"; +import { setupMoveUpEvents, emptyFunction } from "../../../Utils"; import "./CollectionStackingView.scss"; +import { listSpec } from "../../../new_fields/Schema"; +const higflyout = require("@hig/flyout"); +export const { anchorPoints } = higflyout; +export const Flyout = higflyout.default; library.add(faPalette); @@ -35,43 +39,40 @@ interface CSVFieldColumnProps { type: "string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function" | undefined; createDropTarget: (ele: HTMLDivElement) => void; screenToLocalTransform: () => Transform; + observeHeight: (myref: any) => void; + unobserveHeight: (myref: any) => void; } @observer export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldColumnProps> { @observable private _background = "inherit"; - @observable private _createAliasSelected: boolean = false; - private _dropRef: HTMLDivElement | null = null; private dropDisposer?: DragManager.DragDropDisposer; private _headerRef: React.RefObject<HTMLDivElement> = React.createRef(); - private _startDragPosition: { x: number, y: number } = { x: 0, y: 0 }; - private _sensitivity: number = 16; @observable _heading = this.props.headingObject ? this.props.headingObject.heading : this.props.heading; @observable _color = this.props.headingObject ? this.props.headingObject.color : "#f1efeb"; + _ele: HTMLElement | null = null; createColumnDropRef = (ele: HTMLDivElement | null) => { - this._dropRef = ele; - this.dropDisposer && this.dropDisposer(); + this.dropDisposer?.(); if (ele) { + this._ele = ele; + this.props.observeHeight(ele); this.dropDisposer = DragManager.MakeDropTarget(ele, this.columnDrop.bind(this)); } } + componentWillUnmount() { + this.props.unobserveHeight(this._ele); + } @undoBatch columnDrop = action((e: Event, de: DragManager.DropEvent) => { - this._createAliasSelected = false; if (de.complete.docDragData) { - const key = StrCast(this.props.parent.props.Document.sectionFilter); + const key = StrCast(this.props.parent.props.Document._pivotField); const castedValue = this.getValue(this._heading); - if (castedValue) { - de.complete.docDragData.droppedDocuments.forEach(d => d[key] = castedValue); - } - else { - de.complete.docDragData.droppedDocuments.forEach(d => d[key] = undefined); - } - this.props.parent.drop(e, de); + de.complete.docDragData.droppedDocuments.forEach(d => Doc.SetInPlace(d, key, castedValue, false)); + this.props.parent.onInternalDrop(e, de); e.stopPropagation(); } }); @@ -91,8 +92,7 @@ export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldC @action headingChanged = (value: string, shiftDown?: boolean) => { - this._createAliasSelected = false; - const key = StrCast(this.props.parent.props.Document.sectionFilter); + const key = StrCast(this.props.parent.props.Document._pivotField); const castedValue = this.getValue(value); if (castedValue) { if (this.props.parent.sectionHeaders) { @@ -112,7 +112,6 @@ export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldC @action changeColumnColor = (color: string) => { - this._createAliasSelected = false; if (this.props.headingObject) { this.props.headingObject.setColor(color); this._color = color; @@ -122,35 +121,31 @@ export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldC @action pointerEntered = () => { if (SelectionManager.GetIsDragging()) { - this._createAliasSelected = false; this._background = "#b4b4b4"; } } @action pointerLeave = () => { - this._createAliasSelected = false; this._background = "inherit"; - document.removeEventListener("pointermove", this.startDrag); } @action addDocument = (value: string, shiftDown?: boolean) => { if (!value) return false; - this._createAliasSelected = false; - const key = StrCast(this.props.parent.props.Document.sectionFilter); + const key = StrCast(this.props.parent.props.Document._pivotField); const newDoc = Docs.Create.TextDocument(value, { _height: 18, _width: 200, title: value, _autoHeight: true }); newDoc[key] = this.getValue(this.props.heading); const maxHeading = this.props.docList.reduce((maxHeading, doc) => NumCast(doc.heading) > maxHeading ? NumCast(doc.heading) : maxHeading, 0); const heading = maxHeading === 0 || this.props.docList.length === 0 ? 1 : maxHeading === 1 ? 2 : 3; newDoc.heading = heading; - return this.props.parent.props.addDocument(newDoc); + this.props.parent.props.addDocument(newDoc); + return false; } @action deleteColumn = () => { - this._createAliasSelected = false; - const key = StrCast(this.props.parent.props.Document.sectionFilter); + const key = StrCast(this.props.parent.props.Document._pivotField); this.props.docList.forEach(d => d[key] = undefined); if (this.props.parent.sectionHeaders && this.props.headingObject) { const index = this.props.parent.sectionHeaders.indexOf(this.props.headingObject); @@ -160,54 +155,29 @@ export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldC @action collapseSection = () => { - this._createAliasSelected = false; if (this.props.headingObject) { - this._headingsHack++; this.props.headingObject.setCollapsed(!this.props.headingObject.collapsed); this.toggleVisibility(); } } - startDrag = (e: PointerEvent) => { - const [dx, dy] = this.props.screenToLocalTransform().transformDirection(e.clientX - this._startDragPosition.x, e.clientY - this._startDragPosition.y); - if (Math.abs(dx) + Math.abs(dy) > this._sensitivity) { - const alias = Doc.MakeAlias(this.props.parent.props.Document); - const key = StrCast(this.props.parent.props.Document.sectionFilter); - let value = this.getValue(this._heading); - value = typeof value === "string" ? `"${value}"` : value; - alias.viewSpecScript = ScriptField.MakeFunction(`doc.${key} === ${value}`, { doc: Doc.name }); - if (alias.viewSpecScript) { - DragManager.StartDocumentDrag([this._headerRef.current!], new DragManager.DocumentDragData([alias]), e.clientX, e.clientY); - } - - e.stopPropagation(); - document.removeEventListener("pointermove", this.startDrag); - document.removeEventListener("pointerup", this.pointerUp); - } - } - - pointerUp = (e: PointerEvent) => { - e.stopPropagation(); - e.preventDefault(); - - document.removeEventListener("pointermove", this.startDrag); - document.removeEventListener("pointerup", this.pointerUp); - } - headerDown = (e: React.PointerEvent<HTMLDivElement>) => { - e.stopPropagation(); - e.preventDefault(); - - const [dx, dy] = this.props.screenToLocalTransform().transformDirection(e.clientX, e.clientY); - this._startDragPosition = { x: dx, y: dy }; + setupMoveUpEvents(this, e, this.startDrag, emptyFunction, emptyFunction); + } - if (this._createAliasSelected) { - document.removeEventListener("pointermove", this.startDrag); - document.addEventListener("pointermove", this.startDrag); - document.removeEventListener("pointerup", this.pointerUp); - document.addEventListener("pointerup", this.pointerUp); + startDrag = (e: PointerEvent, down: number[], delta: number[]) => { + const alias = Doc.MakeAlias(this.props.parent.props.Document); + alias._width = this.props.parent.props.PanelWidth() / (Cast(this.props.parent.props.Document.sectionHeaders, listSpec(SchemaHeaderField))?.length || 1); + alias._pivotField = undefined; + const key = StrCast(this.props.parent.props.Document._pivotField); + let value = this.getValue(this._heading); + value = typeof value === "string" ? `"${value}"` : value; + alias.viewSpecScript = ScriptField.MakeFunction(`doc.${key} === ${value}`, { doc: Doc.name }); + if (alias.viewSpecScript) { + DragManager.StartDocumentDrag([this._headerRef.current!], new DragManager.DocumentDragData([alias]), e.clientX, e.clientY); + return true; } - runInAction(() => this._createAliasSelected = false); + return false; } renderColorPicker = () => { @@ -240,17 +210,11 @@ export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldC ); } - @action - toggleAlias = () => { - this._createAliasSelected = true; - } - renderMenu = () => { - const selected = this._createAliasSelected; return ( <div className="collectionStackingView-optionPicker"> <div className="optionOptions"> - <div className={"optionPicker" + (selected === true ? " active" : "")} onClick={this.toggleAlias}>Create Alias</div> + <div className={"optionPicker" + (true ? " active" : "")} onClick={action(() => { })}>Add options here</div> </div> </div > ); @@ -260,14 +224,14 @@ export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldC private toggleVisibility = action(() => this.collapsed = !this.collapsed); - @observable _headingsHack: number = 1; - menuCallback = (x: number, y: number) => { ContextMenu.Instance.clearItems(); const layoutItems: ContextMenuProps[] = []; const docItems: ContextMenuProps[] = []; - const dataDoc = this.props.parent.props.DataDoc || this.props.parent.Document; + + DocUtils.addDocumentCreatorMenuItems(this.props.parent.props.addDocument, this.props.parent.props.addDocument, x, y); + Array.from(Object.keys(Doc.GetProto(dataDoc))).filter(fieldKey => dataDoc[fieldKey] instanceof RichTextField || dataDoc[fieldKey] instanceof ImageField || typeof (dataDoc[fieldKey]) === "string").map(fieldKey => docItems.push({ description: ":" + fieldKey, event: () => { @@ -285,8 +249,10 @@ export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldC description: ":" + fieldKey, event: () => { const created = Docs.Create.CarouselDocument([], { _width: 400, _height: 200, title: fieldKey }); if (created) { - if (this.props.parent.Document.isTemplateDoc) { - Doc.MakeMetadataFieldTemplate(created, this.props.parent.props.Document); + const container = this.props.parent.Document.resolvedDataDoc ? Doc.GetProto(this.props.parent.Document) : this.props.parent.Document; + if (container.isTemplateDoc) { + Doc.MakeMetadataFieldTemplate(created, container); + return Doc.AddDocToList(container, Doc.LayoutFieldKey(container), created); } return this.props.parent.props.addDocument(created); } @@ -316,12 +282,13 @@ export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldC render() { TraceMobx(); const cols = this.props.cols(); - const key = StrCast(this.props.parent.props.Document.sectionFilter); + const key = StrCast(this.props.parent.props.Document._pivotField); let templatecols = ""; const headings = this.props.headings(); const heading = this._heading; const style = this.props.parent; const singleColumn = style.isStackingView; + const columnYMargin = this.props.headingObject ? 0 : NumCast(this.props.parent.props.Document._yMargin); const uniqueHeadings = headings.map((i, idx) => headings.indexOf(i) === idx); const evContents = heading ? heading : this.props.type && this.props.type === "number" ? "0" : `NO ${key.toUpperCase()} VALUE`; const headerEditableViewProps = { @@ -330,7 +297,6 @@ export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldC contents: evContents, oneLine: true, HeadingObject: this.props.headingObject, - HeadingsHack: this._headingsHack, toggle: this.toggleVisibility, color: this._color }; @@ -339,13 +305,13 @@ export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldC SetValue: this.addDocument, contents: "+ NEW", HeadingObject: this.props.headingObject, - HeadingsHack: this._headingsHack, toggle: this.toggleVisibility, color: this._color }; const headingView = this.props.headingObject ? <div key={heading} className="collectionStackingView-sectionHeader" ref={this._headerRef} style={{ + marginTop: NumCast(this.props.parent.props.Document._yMargin), width: (style.columnWidth) / ((uniqueHeadings.length + ((this.props.parent.props.Document._chromeStatus !== 'view-mode' && this.props.parent.props.Document._chromeStatus !== 'disabled') ? 1 : 0)) || 1) @@ -358,7 +324,7 @@ export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldC `Documents that don't have a ${key} value will go here. This column cannot be removed.` : ""} style={{ width: "100%", - background: evContents !== `NO ${key.toUpperCase()} VALUE` ? this._color : "lightgrey", + background: evContents !== `NO ${key.toUpperCase()} VALUE` ? this._color : "inherit", color: "grey" }}> <EditableView {...headerEditableViewProps} /> @@ -390,7 +356,12 @@ export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldC for (let i = 0; i < cols; i++) templatecols += `${style.columnWidth / style.numGroupColumns}px `; const chromeStatus = this.props.parent.props.Document._chromeStatus; return ( - <div className="collectionStackingViewFieldColumn" key={heading} style={{ width: `${100 / ((uniqueHeadings.length + ((chromeStatus !== 'view-mode' && chromeStatus !== 'disabled') ? 1 : 0)) || 1)}%`, background: this._background }} + <div className="collectionStackingViewFieldColumn" key={heading} + style={{ + width: `${100 / ((uniqueHeadings.length + ((chromeStatus !== 'view-mode' && chromeStatus !== 'disabled') ? 1 : 0)) || 1)}%`, + height: undefined, // SelectionManager.GetIsDragging() ? "100%" : undefined, + background: this._background + }} ref={this.createColumnDropRef} onPointerEnter={this.pointerEntered} onPointerLeave={this.pointerLeave}> {this.props.parent.Document.hideHeadings ? (null) : headingView} { @@ -398,7 +369,7 @@ export class CollectionStackingViewFieldColumn extends React.Component<CSVFieldC <div> <div key={`${heading}-stack`} className={`collectionStackingView-masonry${singleColumn ? "Single" : "Grid"}`} style={{ - padding: singleColumn ? `${style.yMargin}px ${0}px ${style.yMargin}px ${0}px` : `${style.yMargin}px ${0}px`, + padding: singleColumn ? `${columnYMargin}px ${0}px ${style.yMargin}px ${0}px` : `${columnYMargin}px ${0}px`, margin: "auto", width: "max-content", //singleColumn ? undefined : `${cols * (style.columnWidth + style.gridGap) + 2 * style.xMargin - style.gridGap}px`, height: 'max-content', diff --git a/src/client/views/collections/CollectionStaffView.tsx b/src/client/views/collections/CollectionStaffView.tsx index 8c7e113b2..5b9a69bf7 100644 --- a/src/client/views/collections/CollectionStaffView.tsx +++ b/src/client/views/collections/CollectionStaffView.tsx @@ -1,22 +1,20 @@ import { CollectionSubView } from "./CollectionSubView"; -import { Transform } from "../../util/Transform"; import React = require("react"); import { computed, action, IReactionDisposer, reaction, runInAction, observable } from "mobx"; -import { Doc } from "../../../new_fields/Doc"; import { NumCast } from "../../../new_fields/Types"; import "./CollectionStaffView.scss"; import { observer } from "mobx-react"; @observer export class CollectionStaffView extends CollectionSubView(doc => doc) { - private getTransform = (): Transform => this.props.ScreenToLocalTransform().translate(0, -this._mainCont.current!.scrollTop); - private _mainCont = React.createRef<HTMLDivElement>(); private _reactionDisposer: IReactionDisposer | undefined; @observable private _staves = NumCast(this.props.Document.staves); + componentWillUnmount() { + this._reactionDisposer?.(); + } componentDidMount = () => { - this._reactionDisposer = reaction( - () => NumCast(this.props.Document.staves), + this._reactionDisposer = reaction(() => NumCast(this.props.Document.staves), (staves) => runInAction(() => this._staves = staves) ); @@ -47,7 +45,7 @@ export class CollectionStaffView extends CollectionSubView(doc => doc) { } render() { - return <div className="collectionStaffView" ref={this._mainCont}> + return <div className="collectionStaffView"> {this.staves} {this.addStaffButton} </div>; diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx index 262c60f17..9ddc1296e 100644 --- a/src/client/views/collections/CollectionSubView.tsx +++ b/src/client/views/collections/CollectionSubView.tsx @@ -1,21 +1,20 @@ -import { action, computed, IReactionDisposer, reaction, trace } from "mobx"; -import * as rp from 'request-promise'; +import { action, computed, IReactionDisposer, reaction } from "mobx"; import CursorField from "../../../new_fields/CursorField"; -import { Doc, DocListCast, Opt } from "../../../new_fields/Doc"; +import { Doc, DocListCast, Opt, WidthSym, HeightSym } from "../../../new_fields/Doc"; import { Id } from "../../../new_fields/FieldSymbols"; import { List } from "../../../new_fields/List"; import { listSpec } from "../../../new_fields/Schema"; import { ScriptField } from "../../../new_fields/ScriptField"; -import { Cast } from "../../../new_fields/Types"; +import { Cast, StrCast } from "../../../new_fields/Types"; import { CurrentUserUtils } from "../../../server/authentication/models/current_user_utils"; import { Utils } from "../../../Utils"; import { DocServer } from "../../DocServer"; import { DocumentType } from "../../documents/DocumentTypes"; import { Docs, DocumentOptions } from "../../documents/Documents"; -import { DragManager } from "../../util/DragManager"; +import { DragManager, dropActionType } from "../../util/DragManager"; import { undoBatch, UndoManager } from "../../util/UndoManager"; import { FieldViewProps } from "../nodes/FieldView"; -import { FormattedTextBox, GoogleRef } from "../nodes/FormattedTextBox"; +import { FormattedTextBox, GoogleRef } from "../nodes/formattedText/FormattedTextBox"; import { CollectionView } from "./CollectionView"; import React = require("react"); import { DocComponent } from "../DocComponent"; @@ -26,6 +25,7 @@ import { ImageUtils } from "../../util/Import & Export/ImageUtils"; import { Networking } from "../../Network"; import { GestureUtils } from "../../../pen-gestures/GestureUtils"; import { InteractionUtils } from "../../util/InteractionUtils"; +import { Upload } from "../../../server/SharedMediaTypes"; export interface CollectionViewProps extends FieldViewProps { addDocument: (document: Doc) => boolean; @@ -34,31 +34,38 @@ export interface CollectionViewProps extends FieldViewProps { PanelWidth: () => number; PanelHeight: () => number; VisibleHeight?: () => number; - chromeCollapsed: boolean; setPreviewCursor?: (func: (x: number, y: number, drag: boolean) => void) => void; + rootSelected: (outsideReaction?: boolean) => boolean; fieldKey: string; + NativeWidth: () => number; + NativeHeight: () => number; } export interface SubCollectionViewProps extends CollectionViewProps { CollectionView: Opt<CollectionView>; children?: never | (() => JSX.Element[]) | React.ReactNode; + freezeChildDimensions?: boolean; // used by TimeView to coerce documents to treat their width height as their native width/height + overrideDocuments?: Doc[]; // used to override the documents shown by the sub collection to an explicit list (see LinkBox) + ignoreFields?: string[]; // used in TreeView to ignore specified fields (see LinkBox) isAnnotationOverlay?: boolean; annotationsKey: string; layoutEngine?: () => string; } -export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { - class CollectionSubView extends DocComponent<SubCollectionViewProps, T>(schemaCtor) { +export function CollectionSubView<T, X>(schemaCtor: (doc: Doc) => T, moreProps?: X) { + class CollectionSubView extends DocComponent<X & SubCollectionViewProps, T>(schemaCtor) { private dropDisposer?: DragManager.DragDropDisposer; private gestureDisposer?: GestureUtils.GestureEventDisposer; protected multiTouchDisposer?: InteractionUtils.MultiTouchEventDisposer; private _childLayoutDisposer?: IReactionDisposer; + protected _mainCont?: HTMLDivElement; protected createDashEventsTarget = (ele: HTMLDivElement) => { //used for stacking and masonry view this.dropDisposer?.(); this.gestureDisposer?.(); this.multiTouchDisposer?.(); if (ele) { - this.dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this)); + this._mainCont = ele; + this.dropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.layoutDoc); this.gestureDisposer = GestureUtils.MakeGestureTarget(ele, this.onGesture.bind(this)); this.multiTouchDisposer = InteractionUtils.MakeMultiTouchTarget(ele, this.onTouchStart.bind(this)); } @@ -68,51 +75,102 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { } componentDidMount() { - this._childLayoutDisposer = reaction(() => [this.childDocs, (Cast(this.props.Document.childLayout, Doc) as Doc)?.[Id]], - (args) => { - const childLayout = Cast(this.props.Document.childLayout, Doc); + this._childLayoutDisposer = reaction(() => ({ childDocs: this.childDocs, childLayout: Cast(this.props.Document.childLayout, Doc) }), + ({ childDocs, childLayout }) => { if (childLayout instanceof Doc) { - this.childDocs.map(doc => { + childDocs.map(doc => { doc.layout_fromParent = childLayout; doc.layoutKey = "layout_fromParent"; }); } else if (!(childLayout instanceof Promise)) { - this.childDocs.filter(d => !d.isTemplateForField).map(doc => doc.layoutKey === "layout_fromParent" && (doc.layoutKey = "layout")); + childDocs.filter(d => !d.isTemplateForField).map(doc => doc.layoutKey === "layout_fromParent" && (doc.layoutKey = "layout")); } }, { fireImmediately: true }); } componentWillUnmount() { - this._childLayoutDisposer && this._childLayoutDisposer(); + this.gestureDisposer?.(); + this.multiTouchDisposer?.(); + this._childLayoutDisposer?.(); + } + + @computed get dataDoc() { + return (this.props.DataDoc instanceof Doc && this.props.Document.isTemplateForField ? Doc.GetProto(this.props.DataDoc) : + this.props.Document.resolvedDataDoc ? this.props.Document : Doc.GetProto(this.props.Document)); // if the layout document has a resolvedDataDoc, then we don't want to get its parent which would be the unexpanded template } - @computed get dataDoc() { return this.props.DataDoc && this.props.Document.isTemplateForField ? Doc.GetProto(this.props.DataDoc) : Doc.GetProto(this.props.Document); } + rootSelected = (outsideReaction?: boolean) => { + return this.props.isSelected(outsideReaction) || (this.rootDoc && this.props.rootSelected(outsideReaction)); + } // The data field for rendering this collection will be on the this.props.Document unless we're rendering a template in which case we try to use props.DataDoc. // When a document has a DataDoc but it's not a template, then it contains its own rendering data, but needs to pass the DataDoc through // to its children which may be templates. // If 'annotationField' is specified, then all children exist on that field of the extension document, otherwise, they exist directly on the data document under 'fieldKey' @computed get dataField() { - const { annotationsKey, fieldKey } = this.props; - if (annotationsKey) { - return this.dataDoc[fieldKey + "-" + annotationsKey]; - } - return this.dataDoc[fieldKey]; + return this.dataDoc[this.props.fieldKey + (this.props.annotationsKey ? "-" + this.props.annotationsKey : "")]; } get childLayoutPairs(): { layout: Doc; data: Doc; }[] { const { Document, DataDoc } = this.props; const validPairs = this.childDocs.map(doc => Doc.GetLayoutDataDocPair(Document, !this.props.annotationsKey ? DataDoc : undefined, doc)).filter(pair => pair.layout); - return validPairs.map(({ data, layout }) => ({ data: data!, layout: layout! })); // this mapping is a bit of a hack to coerce types + return validPairs.map(({ data, layout }) => ({ data: data as Doc, layout: layout! })); // this mapping is a bit of a hack to coerce types } get childDocList() { return Cast(this.dataField, listSpec(Doc)); } - get childDocs() { - const docs = DocListCast(this.dataField); + @computed get childDocs() { + const docFilters = this.props.ignoreFields?.includes("_docFilters") ? [] : Cast(this.props.Document._docFilters, listSpec("string"), []); + const docRangeFilters = this.props.ignoreFields?.includes("_docRangeFilters") ? [] : Cast(this.props.Document._docRangeFilters, listSpec("string"), []); + const filterFacets: { [key: string]: { [value: string]: string } } = {}; // maps each filter key to an object with value=>modifier fields + for (let i = 0; i < docFilters.length; i += 3) { + const [key, value, modifiers] = docFilters.slice(i, i + 3); + if (!filterFacets[key]) { + filterFacets[key] = {}; + } + filterFacets[key][value] = modifiers; + } + + let rawdocs: (Doc | Promise<Doc>)[] = []; + if (this.dataField instanceof Doc) { // if collection data is just a document, then promote it to a singleton list; + rawdocs = [this.dataField]; + } else if (Cast(this.dataField, listSpec(Doc), null)) { // otherwise, if the collection data is a list, then use it. + rawdocs = Cast(this.dataField, listSpec(Doc), null); + } else { // Finally, if it's not a doc or a list and the document is a template, we try to render the root doc. + // For example, if an image doc is rendered with a slide template, the template will try to render the data field as a collection. + // Since the data field is actually an image, we set the list of documents to the singleton of root document's proto which will be an image. + const rootDoc = Cast(this.props.Document.rootDocument, Doc, null); + rawdocs = rootDoc && !this.props.annotationsKey ? [Doc.GetProto(rootDoc)] : []; + } + const docs = rawdocs.filter(d => !(d instanceof Promise)).map(d => d as Doc); const viewSpecScript = Cast(this.props.Document.viewSpecScript, ScriptField); - return viewSpecScript ? docs.filter(d => viewSpecScript.script.run({ doc: d }, console.log).result) : docs; + const childDocs = viewSpecScript ? docs.filter(d => viewSpecScript.script.run({ doc: d }, console.log).result) : docs; + + const filteredDocs = docFilters.length && !this.props.dontRegisterView ? childDocs.filter(d => { + for (const facetKey of Object.keys(filterFacets)) { + const facet = filterFacets[facetKey]; + const satisfiesFacet = Object.keys(facet).some(value => + (facet[value] === "x") !== Doc.matchFieldValue(d, facetKey, value)); + if (!satisfiesFacet) { + return false; + } + } + return true; + }) : childDocs; + const rangeFilteredDocs = filteredDocs.filter(d => { + for (let i = 0; i < docRangeFilters.length; i += 3) { + const key = docRangeFilters[i]; + const min = Number(docRangeFilters[i + 1]); + const max = Number(docRangeFilters[i + 2]); + const val = Cast(d[key], "number", null); + if (val !== undefined && (val < min || val > max)) { + return false; + } + } + return true; + }); + return rangeFilteredDocs; } @action @@ -149,24 +207,15 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { @undoBatch protected onGesture(e: Event, ge: GestureUtils.GestureEvent) { - } @undoBatch @action - protected drop(e: Event, de: DragManager.DropEvent): boolean { + protected onInternalDrop(e: Event, de: DragManager.DropEvent): boolean { const docDragData = de.complete.docDragData; (this.props.Document.dropConverter instanceof ScriptField) && this.props.Document.dropConverter.script.run({ dragData: docDragData }); /// bcz: check this - if (docDragData && !docDragData.applyAsTemplate) { - if (de.altKey && docDragData.draggedDocuments.length) { - this.childDocs.map(doc => { - doc.layout_fromParent = docDragData.draggedDocuments[0]; - doc.layoutKey = "layout_fromParent"; - }); - e.stopPropagation(); - return true; - } + if (docDragData) { let added = false; if (docDragData.dropAction || docDragData.userDropAction) { added = docDragData.droppedDocuments.reduce((added: boolean, d) => this.props.addDocument(d) || added, false); @@ -190,155 +239,182 @@ export function CollectionSubView<T>(schemaCtor: (doc: Doc) => T) { @undoBatch @action - protected async onDrop(e: React.DragEvent, options: DocumentOptions, completed?: () => void) { + protected async onExternalDrop(e: React.DragEvent, options: DocumentOptions, completed?: () => void) { if (e.ctrlKey) { e.stopPropagation(); // bcz: this is a hack to stop propagation when dropping an image on a text document with shift+ctrl return; } - const html = e.dataTransfer.getData("text/html"); - const text = e.dataTransfer.getData("text/plain"); + + const { dataTransfer } = e; + const html = dataTransfer.getData("text/html"); + const text = dataTransfer.getData("text/plain"); if (text && text.startsWith("<div")) { return; } + e.stopPropagation(); e.preventDefault(); - - if (html && FormattedTextBox.IsFragment(html)) { - const href = FormattedTextBox.GetHref(html); - if (href) { - const docid = FormattedTextBox.GetDocFromUrl(href); - if (docid) { // prosemirror text containing link to dash document - DocServer.GetRefField(docid).then(f => { - if (f instanceof Doc) { - if (options.x || options.y) { f.x = options.x; f.y = options.y; } // should be in CollectionFreeFormView - (f instanceof Doc) && this.props.addDocument(f); - } - }); - } else { - this.props.addDocument && this.props.addDocument(Docs.Create.WebDocument(href, { ...options, title: href })); - } - } else if (text) { - this.props.addDocument && this.props.addDocument(Docs.Create.TextDocument(text, { ...options, _width: 100, _height: 25 })); - } + const { addDocument } = this.props; + if (!addDocument) { + alert("this.props.addDocument does not exist. Aborting drop operation."); return; } - if (html && !html.startsWith("<a")) { - const tags = html.split("<"); - if (tags[0] === "") tags.splice(0, 1); - const img = tags[0].startsWith("img") ? tags[0] : tags.length > 1 && tags[1].startsWith("img") ? tags[1] : ""; - if (img) { - const split = img.split("src=\"")[1].split("\"")[0]; - let source = split; - if (split.startsWith("data:image") && split.includes("base64")) { - const [{ clientAccessPath }] = await Networking.PostToServer("/uploadRemoteImage", { sources: [split] }); - source = Utils.prepend(clientAccessPath); + + if (html) { + if (FormattedTextBox.IsFragment(html)) { + const href = FormattedTextBox.GetHref(html); + if (href) { + const docid = FormattedTextBox.GetDocFromUrl(href); + if (docid) { // prosemirror text containing link to dash document + DocServer.GetRefField(docid).then(f => { + if (f instanceof Doc) { + if (options.x || options.y) { f.x = options.x; f.y = options.y; } // should be in CollectionFreeFormView + (f instanceof Doc) && addDocument(f); + } + }); + } else { + addDocument(Docs.Create.WebDocument(href, { ...options, title: href })); + } + } else if (text) { + addDocument(Docs.Create.TextDocument(text, { ...options, _width: 100, _height: 25 })); } - const doc = Docs.Create.ImageDocument(source, { ...options, _width: 300 }); - ImageUtils.ExtractExif(doc); - this.props.addDocument(doc); return; - } else { - const path = window.location.origin + "/doc/"; - if (text.startsWith(path)) { - const docid = text.replace(Utils.prepend("/doc/"), "").split("?")[0]; - DocServer.GetRefField(docid).then(f => { - if (f instanceof Doc) { - if (options.x || options.y) { f.x = options.x; f.y = options.y; } // should be in CollectionFreeFormView - (f instanceof Doc) && this.props.addDocument(f); - } - }); + } + if (!html.startsWith("<a")) { + const tags = html.split("<"); + if (tags[0] === "") tags.splice(0, 1); + let img = tags[0].startsWith("img") ? tags[0] : tags.length > 1 && tags[1].startsWith("img") ? tags[1] : ""; + const cors = img.includes("corsProxy") ? img.match(/http.*corsProxy\//)![0] : ""; + img = cors ? img.replace(cors, "") : img; + if (img) { + const split = img.split("src=\"")[1].split("\"")[0]; + let source = split; + if (split.startsWith("data:image") && split.includes("base64")) { + const [{ accessPaths }] = await Networking.PostToServer("/uploadRemoteImage", { sources: [split] }); + source = Utils.prepend(accessPaths.agnostic.client); + } + if (source.startsWith("http")) { + const doc = Docs.Create.ImageDocument(source, { ...options, _width: 300 }); + ImageUtils.ExtractExif(doc); + addDocument(doc); + } + return; } else { - const htmlDoc = Docs.Create.HtmlDocument(html, { ...options, title: "-web page-", _width: 300, _height: 300, documentText: text }); - this.props.addDocument(htmlDoc); + const path = window.location.origin + "/doc/"; + if (text.startsWith(path)) { + const docid = text.replace(Utils.prepend("/doc/"), "").split("?")[0]; + DocServer.GetRefField(docid).then(f => { + if (f instanceof Doc) { + if (options.x || options.y) { f.x = options.x; f.y = options.y; } // should be in CollectionFreeFormView + (f instanceof Doc) && this.props.addDocument(f); + } + }); + } else { + const htmlDoc = Docs.Create.HtmlDocument(html, { ...options, title: "-web page-", _width: 300, _height: 300 }); + Doc.GetProto(htmlDoc)["data-text"] = text; + this.props.addDocument(htmlDoc); + } + return; } - return; } } - if (text && text.indexOf("www.youtube.com/watch") !== -1) { - const url = text.replace("youtube.com/watch?v=", "youtube.com/embed/"); - this.props.addDocument(Docs.Create.VideoDocument(url, { ...options, title: url, _width: 400, _height: 315, _nativeWidth: 600, _nativeHeight: 472.5 })); - return; + + if (text) { + if (text.includes("www.youtube.com/watch")) { + const url = text.replace("youtube.com/watch?v=", "youtube.com/embed/"); + addDocument(Docs.Create.VideoDocument(url, { + ...options, + title: url, + _width: 400, + _height: 315, + _nativeWidth: 600, + _nativeHeight: 472.5 + })); + return; + } + let matches: RegExpExecArray | null; + if ((matches = /(https:\/\/)?docs\.google\.com\/document\/d\/([^\\]+)\/edit/g.exec(text)) !== null) { + const newBox = Docs.Create.TextDocument("", { ...options, _width: 400, _height: 200, title: "Awaiting title from Google Docs..." }); + const proto = newBox.proto!; + const documentId = matches[2]; + proto[GoogleRef] = documentId; + proto.data = "Please select this document and then click on its pull button to load its contents from from Google Docs..."; + proto.backgroundColor = "#eeeeff"; + addDocument(newBox); + return; + } + if ((matches = /(https:\/\/)?photos\.google\.com\/(u\/3\/)?album\/([^\\]+)/g.exec(text)) !== null) { + const albumId = matches[3]; + const mediaItems = await GooglePhotos.Query.AlbumSearch(albumId); + console.log(mediaItems); + return; + } } - let matches: RegExpExecArray | null; - if ((matches = /(https:\/\/)?docs\.google\.com\/document\/d\/([^\\]+)\/edit/g.exec(text)) !== null) { - const newBox = Docs.Create.TextDocument("", { ...options, _width: 400, _height: 200, title: "Awaiting title from Google Docs..." }); - const proto = newBox.proto!; - const documentId = matches[2]; - proto[GoogleRef] = documentId; - proto.data = "Please select this document and then click on its pull button to load its contents from from Google Docs..."; - proto.backgroundColor = "#eeeeff"; - this.props.addDocument(newBox); - // const parent = Docs.Create.StackingDocument([newBox], { title: `Google Doc Import (${documentId})` }); - // CollectionDockingView.Instance.AddRightSplit(parent, undefined); - // proto.height = parent[HeightSym](); + + const { items } = e.dataTransfer; + const { length } = items; + const files: File[] = []; + const generatedDocuments: Doc[] = []; + if (!length) { + alert("No uploadable content found."); return; } - if ((matches = /(https:\/\/)?photos\.google\.com\/(u\/3\/)?album\/([^\\]+)/g.exec(text)) !== null) { - const albums = await GooglePhotos.Transactions.ListAlbums(); - const albumId = matches[3]; - const mediaItems = await GooglePhotos.Query.AlbumSearch(albumId); - console.log(mediaItems); - } + const batch = UndoManager.StartBatch("collection view drop"); - const promises: Promise<void>[] = []; - // tslint:disable-next-line:prefer-for-of - for (let i = 0; i < e.dataTransfer.items.length; i++) { + for (let i = 0; i < length; i++) { const item = e.dataTransfer.items[i]; - if (item.kind === "string" && item.type.indexOf("uri") !== -1) { - let str: string; - const prom = new Promise<string>(resolve => e.dataTransfer.items[i].getAsString(resolve)) - .then(action((s: string) => rp.head(Utils.CorsProxy(str = s)))) - .then(result => { - const type = result["content-type"]; - if (type) { - Docs.Get.DocumentFromType(type, str, options) - .then(doc => doc && this.props.addDocument(doc)); - } - }); - promises.push(prom); + if (item.kind === "string" && item.type.includes("uri")) { + const stringContents = await new Promise<string>(resolve => item.getAsString(resolve)); + const type = "html";// (await rp.head(Utils.CorsProxy(stringContents)))["content-type"]; + if (type) { + const doc = await Docs.Get.DocumentFromType(type, stringContents, options); + doc && generatedDocuments.push(doc); + } } - const type = item.type; if (item.kind === "file") { const file = item.getAsFile(); - const formData = new FormData(); - - if (!file || !file.type) { - continue; - } - - formData.append('file', file); - const dropFileName = file ? file.name : "-empty-"; - promises.push(Networking.PostFormDataToServer("/uploadFormData", formData).then(results => { - results.map(action((result: any) => { - const { clientAccessPath, nativeWidth, nativeHeight, contentSize } = result; - const full = { ...options, _width: 300, title: dropFileName }; - const pathname = Utils.prepend(clientAccessPath); - Docs.Get.DocumentFromType(type, pathname, full).then(doc => { - if (doc) { - const proto = Doc.GetProto(doc); - proto.fileUpload = basename(pathname).replace("upload_", "").replace(/\.[a-z0-9]*$/, ""); - nativeWidth && (proto["data-nativeWidth"] = nativeWidth); - nativeHeight && (proto["data-nativeHeight"] = nativeHeight); - contentSize && (proto.contentSize = contentSize); - this.props?.addDocument(doc); - } - }); - })); - })); + file && file.type && files.push(file); } } - - if (promises.length) { - Promise.all(promises).finally(() => { completed && completed(); batch.end(); }); + for (const { source: { name, type }, result } of await Networking.UploadFilesToServer(files)) { + if (result instanceof Error) { + alert(`Upload failed: ${result.message}`); + return; + } + const full = { ...options, _width: 400, title: name }; + const pathname = Utils.prepend(result.accessPaths.agnostic.client); + const doc = await Docs.Get.DocumentFromType(type, pathname, full); + if (!doc) { + continue; + } + const proto = Doc.GetProto(doc); + proto.text = result.rawText; + proto.fileUpload = basename(pathname).replace("upload_", "").replace(/\.[a-z0-9]*$/, ""); + if (Upload.isImageInformation(result)) { + proto["data-nativeWidth"] = (result.nativeWidth > result.nativeHeight) ? 400 * result.nativeWidth / result.nativeHeight : 400; + proto["data-nativeHeight"] = (result.nativeWidth > result.nativeHeight) ? 400 : 400 / (result.nativeWidth / result.nativeHeight); + proto.contentSize = result.contentSize; + } + generatedDocuments.push(doc); + } + if (generatedDocuments.length) { + const set = generatedDocuments.length > 1 && generatedDocuments.map(d => Doc.iconify(d)); + if (set) { + addDocument(Doc.pileup(generatedDocuments, options.x!, options.y!)); + } else { + generatedDocuments.forEach(addDocument); + } + completed?.(); } else { if (text && !text.includes("https://")) { - this.props.addDocument(Docs.Create.TextDocument(text, { ...options, _width: 400, _height: 315 })); + addDocument(Docs.Create.TextDocument(text, { ...options, _width: 400, _height: 315 })); } - batch.end(); } + batch.end(); } } + return CollectionSubView; } diff --git a/src/client/views/collections/CollectionTimeView.scss b/src/client/views/collections/CollectionTimeView.scss index a5ce73a92..fa7c87f4e 100644 --- a/src/client/views/collections/CollectionTimeView.scss +++ b/src/client/views/collections/CollectionTimeView.scss @@ -1,21 +1,25 @@ -.collectionTimeView, .collectionTimeView-pivot { +.collectionTimeView, +.collectionTimeView-pivot { display: flex; flex-direction: row; position: absolute; height: 100%; width: 100%; overflow: hidden; + .collectionTimeView-backBtn { background: green; display: inline; - margin-right: 20px; } + .collectionFreeform-customText { text-align: left; } + .collectionFreeform-customDiv { position: absolute; } + .collectionTimeView-thumb { position: absolute; width: 30px; @@ -28,20 +32,23 @@ border-radius: 9px; opacity: 0.25; } + .collectionTimeView-thumb-min { - margin-left:25%; + margin-left: 25%; } + .collectionTimeView-thumb-max { - margin-left:75%; + margin-left: 75%; } + .collectionTimeView-thumb-mid { - margin-left:50%; + margin-left: 50%; } .collectionTimeView-flyout { width: 400px; - height: 300px; - display: inline-block; + display: block; + text-align: left; .collectionTimeView-flyout-item { background-color: lightgray; @@ -60,44 +67,9 @@ pointer-events: all; padding: 5px; border: 1px solid black; - } - - .collectionTimeView-treeView { - display: flex; - flex-direction: column; - width: 200px; - height: 100%; - - .collectionTimeView-addfacet { - display: inline-block; - width: 200px; - height: 30px; - background: darkGray; - text-align: center; - - .collectionTimeView-button { - align-items: center; - display: flex; - width: 100%; - height: 100%; - - .collectionTimeView-span { - margin: auto; - } - } - - >div, - >div>div { - width: 100%; - height: 100%; - text-align: center; - } - } - - .collectionTimeView-tree { - display: inline-block; - width: 100%; - height: calc(100% - 30px); + display:none; + span { + margin-left : 10px; } } @@ -106,21 +78,16 @@ width: calc(100% - 200px); height: 100%; } - - .collectionTimeView-dragger { - background-color: lightgray; - height: 40px; - width: 20px; - position: absolute; - border-radius: 10px; - top: 55%; - border: 1px black solid; - z-index: 2; - left: -10px; - } } + .collectionTimeView-pivot { .collectionFreeform-customText { text-align: center; } +} + +.collectionTimeView:hover, .collectionTimeView-pivot:hover { + .pivotKeyEntry { + display:unset; + } }
\ No newline at end of file diff --git a/src/client/views/collections/CollectionTimeView.tsx b/src/client/views/collections/CollectionTimeView.tsx index 4983acbc2..045134225 100644 --- a/src/client/views/collections/CollectionTimeView.tsx +++ b/src/client/views/collections/CollectionTimeView.tsx @@ -1,116 +1,114 @@ -import { faEdit } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { action, computed, observable, trace, runInAction } from "mobx"; +import { action, computed, observable, runInAction } from "mobx"; import { observer } from "mobx-react"; -import { Set } from "typescript-collections"; -import { Doc, DocListCast, Field } from "../../../new_fields/Doc"; +import { Doc, Opt, DocCastAsync } from "../../../new_fields/Doc"; import { List } from "../../../new_fields/List"; +import { ObjectField } from "../../../new_fields/ObjectField"; import { RichTextField } from "../../../new_fields/RichTextField"; -import { listSpec } from "../../../new_fields/Schema"; import { ComputedField, ScriptField } from "../../../new_fields/ScriptField"; -import { Cast, NumCast, StrCast } from "../../../new_fields/Types"; -import { Docs } from "../../documents/Documents"; +import { NumCast, StrCast, BoolCast, Cast } from "../../../new_fields/Types"; +import { emptyFunction, returnFalse, setupMoveUpEvents } from "../../../Utils"; import { Scripting } from "../../util/Scripting"; import { ContextMenu } from "../ContextMenu"; import { ContextMenuProps } from "../ContextMenuItem"; import { EditableView } from "../EditableView"; -import { anchorPoints, Flyout } from "../TemplateMenu"; import { ViewDefBounds } from "./collectionFreeForm/CollectionFreeFormLayoutEngines"; import { CollectionFreeFormView } from "./collectionFreeForm/CollectionFreeFormView"; import { CollectionSubView } from "./CollectionSubView"; import "./CollectionTimeView.scss"; +const higflyout = require("@hig/flyout"); +export const { anchorPoints } = higflyout; +export const Flyout = higflyout.default; import React = require("react"); -import { CollectionTreeView } from "./CollectionTreeView"; -import { ObjectField } from "../../../new_fields/ObjectField"; @observer export class CollectionTimeView extends CollectionSubView(doc => doc) { _changing = false; @observable _layoutEngine = "pivot"; + @observable _collapsed: boolean = false; + @observable _childClickedScript: Opt<ScriptField>; + @observable _viewDefDivClick: Opt<ScriptField>; + async componentDidMount() { + const detailView = (await DocCastAsync(this.props.Document.childDetailView)) || Doc.findTemplate("detailView", StrCast(this.props.Document.type), ""); + const childText = "const alias = getAlias(self); switchView(alias, detailView); alias.dropAction='alias'; alias.removeDropProperties=new List<string>(['dropAction']); useRightSplit(alias, shiftKey); "; + runInAction(() => { + this._childClickedScript = ScriptField.MakeScript(childText, { this: Doc.name, shiftKey: "boolean" }, { detailView: detailView! }); + this._viewDefDivClick = ScriptField.MakeScript("pivotColumnClick(this,payload)", { payload: "any" }); + }); + } - componentDidMount() { - const childDetailed = this.props.Document.childDetailed; // bcz: needs to be here to make sure the childDetailed layout template has been loaded when the first item is clicked; - if (!this.props.Document._facetCollection) { - const facetCollection = Docs.Create.TreeDocument([], { title: "facetFilters", _yMargin: 0, treeViewHideTitle: true }); - facetCollection.target = this.props.Document; - this.props.Document.excludeFields = new List<string>(["_facetCollection", "_docFilter"]); + layoutEngine = () => this._layoutEngine; + toggleVisibility = action(() => this._collapsed = !this._collapsed); - const scriptText = "setDocFilter(containingTreeView.target, heading, this.title, checked)"; - const childText = "const alias = getAlias(this); Doc.ApplyTemplateTo(containingCollection.childDetailed, alias, 'layout_detailView'); alias.dropAction='alias'; alias.removeDropProperties=new List<string>(['dropAction']); useRightSplit(alias, shiftKey); "; - facetCollection.onCheckedClick = ScriptField.MakeScript(scriptText, { this: Doc.name, heading: "boolean", checked: "boolean", containingTreeView: Doc.name }); - this.props.Document.onChildClick = ScriptField.MakeScript(childText, { this: Doc.name, heading: "boolean", containingCollection: Doc.name, shiftKey: "boolean" }); - this.props.Document._facetCollection = facetCollection; - this.props.Document._fitToBox = true; - } - if (!this.props.Document.onViewDefClick) { - this.props.Document.onViewDefDivClick = ScriptField.MakeScript("pivotColumnClick(this,payload)", { payload: "any" }) - } + onMinDown = (e: React.PointerEvent) => { + setupMoveUpEvents(this, e, action((e: PointerEvent, down: number[], delta: number[]) => { + const minReq = NumCast(this.props.Document[this.props.fieldKey + "-timelineMinReq"], NumCast(this.props.Document[this.props.fieldKey + "-timelineMin"], 0)); + const maxReq = NumCast(this.props.Document[this.props.fieldKey + "-timelineMaxReq"], NumCast(this.props.Document[this.props.fieldKey + "-timelineMax"], 10)); + this.props.Document[this.props.fieldKey + "-timelineMinReq"] = minReq + (maxReq - minReq) * delta[0] / this.props.PanelWidth(); + this.props.Document[this.props.fieldKey + "-timelineSpan"] = undefined; + return false; + }), returnFalse, emptyFunction); } - bodyPanelWidth = () => this.props.PanelWidth() - this._facetWidth; - getTransform = () => this.props.ScreenToLocalTransform().translate(-this._facetWidth, 0); - - @computed get _allFacets() { - const facets = new Set<string>(); - this.childDocs.forEach(child => Object.keys(Doc.GetProto(child)).forEach(key => facets.add(key))); - Doc.AreProtosEqual(this.dataDoc, this.props.Document) && this.childDocs.forEach(child => Object.keys(child).forEach(key => facets.add(key))); - return facets.toArray(); + onMaxDown = (e: React.PointerEvent) => { + setupMoveUpEvents(this, e, action((e: PointerEvent, down: number[], delta: number[]) => { + const minReq = NumCast(this.props.Document[this.props.fieldKey + "-timelineMinReq"], NumCast(this.props.Document[this.props.fieldKey + "-timelineMin"], 0)); + const maxReq = NumCast(this.props.Document[this.props.fieldKey + "-timelineMaxReq"], NumCast(this.props.Document[this.props.fieldKey + "-timelineMax"], 10)); + this.props.Document[this.props.fieldKey + "-timelineMaxReq"] = maxReq + (maxReq - minReq) * delta[0] / this.props.PanelWidth(); + return false; + }), returnFalse, emptyFunction); } - /** - * Responds to clicking the check box in the flyout menu - */ - facetClick = (facetHeader: string) => { - const facetCollection = this.props.Document._facetCollection; - if (facetCollection instanceof Doc) { - const found = DocListCast(facetCollection.data).findIndex(doc => doc.title === facetHeader); - if (found !== -1) { - (facetCollection.data as List<Doc>).splice(found, 1); - const docFilter = Cast(this.props.Document._docFilter, listSpec("string")); - if (docFilter) { - let index: number; - while ((index = docFilter.findIndex(item => item === facetHeader)) !== -1) { - docFilter.splice(index, 3); - } - } + onMidDown = (e: React.PointerEvent) => { + setupMoveUpEvents(this, e, action((e: PointerEvent, down: number[], delta: number[]) => { + const minReq = NumCast(this.props.Document[this.props.fieldKey + "-timelineMinReq"], NumCast(this.props.Document[this.props.fieldKey + "-timelineMin"], 0)); + const maxReq = NumCast(this.props.Document[this.props.fieldKey + "-timelineMaxReq"], NumCast(this.props.Document[this.props.fieldKey + "-timelineMax"], 10)); + this.props.Document[this.props.fieldKey + "-timelineMinReq"] = minReq - (maxReq - minReq) * delta[0] / this.props.PanelWidth(); + this.props.Document[this.props.fieldKey + "-timelineMaxReq"] = maxReq - (maxReq - minReq) * delta[0] / this.props.PanelWidth(); + return false; + }), returnFalse, emptyFunction); + } + + contentsDown = (e: React.PointerEvent) => { + setupMoveUpEvents(this, e, returnFalse, returnFalse, action(() => { + let prevFilterIndex = NumCast(this.props.Document._prevFilterIndex); + if (prevFilterIndex > 0) { + prevFilterIndex--; + this.props.Document._docFilters = ObjectField.MakeCopy(this.props.Document["_prevDocFilter" + prevFilterIndex] as ObjectField); + this.props.Document._docRangeFilters = ObjectField.MakeCopy(this.props.Document["_prevDocRangeFilters" + prevFilterIndex] as ObjectField); + this.props.Document._prevFilterIndex = prevFilterIndex; } else { - const newFacet = Docs.Create.TreeDocument([], { title: facetHeader, treeViewOpen: true, isFacetFilter: true }); - const capturedVariables = { layoutDoc: this.props.Document, dataDoc: this.dataDoc }; - const params = { layoutDoc: Doc.name, dataDoc: Doc.name, }; - newFacet.data = ComputedField.MakeFunction(`readFacetData(layoutDoc, dataDoc, "${this.props.fieldKey}", "${facetHeader}")`, params, capturedVariables); - Doc.AddDocToList(facetCollection, "data", newFacet); + this.props.Document._docFilters = new List([]); } - } + }), false); + } + + @computed get contents() { + return <div className="collectionTimeView-innards" key="timeline" style={{ width: "100%", pointerEvents: this.props.active() ? undefined : "none" }} onPointerDown={this.contentsDown}> + <CollectionFreeFormView {...this.props} childClickScript={this._childClickedScript} viewDefDivClick={this._viewDefDivClick} fitToBox={true} freezeChildDimensions={BoolCast(this.layoutDoc._freezeChildDimensions, true)} layoutEngine={this.layoutEngine} /> + </div>; } - _canClick = false; - _facetWidthOnDown = 0; - @observable _facetWidth = 0; - onPointerDown = (e: React.PointerEvent) => { - this._canClick = true; - this._facetWidthOnDown = e.screenX; - document.removeEventListener("pointermove", this.onPointerMove); - document.removeEventListener("pointerup", this.onPointerUp); - document.addEventListener("pointermove", this.onPointerMove); - document.addEventListener("pointerup", this.onPointerUp); - e.stopPropagation(); - e.preventDefault(); + + public static SyncTimelineToPresentation(doc: Doc) { + const fieldKey = Doc.LayoutFieldKey(doc); + doc[fieldKey + "-timelineCur"] = ComputedField.MakeFunction("(activePresentationItem()[this._pivotField || 'year'] || 0)"); } + specificMenu = (e: React.MouseEvent) => { + const layoutItems: ContextMenuProps[] = []; + const doc = this.props.Document; + layoutItems.push({ description: "Force Timeline", event: () => { doc._forceRenderEngine = "timeline"; }, icon: "compress-arrows-alt" }); + layoutItems.push({ description: "Force Pivot", event: () => { doc._forceRenderEngine = "pivot"; }, icon: "compress-arrows-alt" }); + layoutItems.push({ description: "Auto Time/Pivot layout", event: () => { doc._forceRenderEngine = undefined; }, icon: "compress-arrows-alt" }); + layoutItems.push({ description: "Sync with presentation", event: () => CollectionTimeView.SyncTimelineToPresentation(doc), icon: "compress-arrows-alt" }); - @action - onPointerMove = (e: PointerEvent) => { - this._facetWidth = Math.max(this.props.ScreenToLocalTransform().transformPoint(e.clientX, 0)[0], 0); - Math.abs(e.movementX) > 6 && (this._canClick = false); + ContextMenu.Instance.addItem({ description: "Options...", subitems: layoutItems, icon: "eye" }); } - @action - onPointerUp = (e: PointerEvent) => { - if (Math.abs(e.screenX - this._facetWidthOnDown) < 6 && this._canClick) { - this._facetWidth = this._facetWidth < 15 ? 200 : 0; - } - document.removeEventListener("pointermove", this.onPointerMove); - document.removeEventListener("pointerup", this.onPointerUp); + @computed get _allFacets() { + const facets = new Set<string>(); + this.childDocs.forEach(child => Object.keys(Doc.GetProto(child)).forEach(key => facets.add(key))); + Doc.AreProtosEqual(this.dataDoc, this.props.Document) && this.childDocs.forEach(child => Object.keys(child).forEach(key => facets.add(key))); + return Array.from(facets); } - menuCallback = (x: number, y: number) => { ContextMenu.Instance.clearItems(); const docItems: ContextMenuProps[] = []; @@ -120,135 +118,15 @@ export class CollectionTimeView extends CollectionSubView(doc => doc) { pair.layout[fieldKey] instanceof RichTextField || typeof (pair.layout[fieldKey]) === "number" || typeof (pair.layout[fieldKey]) === "string").map(fieldKey => keySet.add(fieldKey))); - keySet.toArray().map(fieldKey => + Array.from(keySet).map(fieldKey => docItems.push({ description: ":" + fieldKey, event: () => this.props.Document._pivotField = fieldKey, icon: "compress-arrows-alt" })); - docItems.push({ description: ":(null)", event: () => this.props.Document._pivotField = undefined, icon: "compress-arrows-alt" }) + docItems.push({ description: ":(null)", event: () => this.props.Document._pivotField = undefined, icon: "compress-arrows-alt" }); ContextMenu.Instance.addItem({ description: "Pivot Fields ...", subitems: docItems, icon: "eye" }); const pt = this.props.ScreenToLocalTransform().inverse().transformPoint(x, y); ContextMenu.Instance.displayMenu(x, y, ":"); } - @observable private collapsed: boolean = false; - private toggleVisibility = action(() => this.collapsed = !this.collapsed); - - _downX = 0; - onMinDown = (e: React.PointerEvent) => { - document.removeEventListener("pointermove", this.onMinMove); - document.removeEventListener("pointerup", this.onMinUp); - document.addEventListener("pointermove", this.onMinMove); - document.addEventListener("pointerup", this.onMinUp); - this._downX = e.clientX; - e.stopPropagation(); - e.preventDefault(); - } - @action - onMinMove = (e: PointerEvent) => { - const delta = e.clientX - this._downX; - this._downX = e.clientX; - const minReq = NumCast(this.props.Document[this.props.fieldKey + "-timelineMinReq"], NumCast(this.props.Document[this.props.fieldKey + "-timelineMin"], 0)); - const maxReq = NumCast(this.props.Document[this.props.fieldKey + "-timelineMaxReq"], NumCast(this.props.Document[this.props.fieldKey + "-timelineMax"], 10)); - this.props.Document[this.props.fieldKey + "-timelineMinReq"] = minReq + (maxReq - minReq) * delta / this.props.PanelWidth(); - } - onMinUp = (e: PointerEvent) => { - document.removeEventListener("pointermove", this.onMinMove); - document.removeEventListener("pointermove", this.onMinUp); - } - - onMaxDown = (e: React.PointerEvent) => { - document.removeEventListener("pointermove", this.onMaxMove); - document.removeEventListener("pointermove", this.onMaxUp); - document.addEventListener("pointermove", this.onMaxMove); - document.addEventListener("pointerup", this.onMaxUp); - this._downX = e.clientX; - e.stopPropagation(); - e.preventDefault(); - } - @action - onMaxMove = (e: PointerEvent) => { - const delta = e.clientX - this._downX; - this._downX = e.clientX; - const minReq = NumCast(this.props.Document[this.props.fieldKey + "-timelineMinReq"], NumCast(this.props.Document[this.props.fieldKey + "-timelineMin"], 0)); - const maxReq = NumCast(this.props.Document[this.props.fieldKey + "-timelineMaxReq"], NumCast(this.props.Document[this.props.fieldKey + "-timelineMax"], 10)); - this.props.Document[this.props.fieldKey + "-timelineMaxReq"] = maxReq + (maxReq - minReq) * delta / this.props.PanelWidth(); - } - onMaxUp = (e: PointerEvent) => { - document.removeEventListener("pointermove", this.onMaxMove); - document.removeEventListener("pointermove", this.onMaxUp); - } - - onMidDown = (e: React.PointerEvent) => { - document.removeEventListener("pointermove", this.onMidMove); - document.removeEventListener("pointermove", this.onMidUp); - document.addEventListener("pointermove", this.onMidMove); - document.addEventListener("pointerup", this.onMidUp); - this._downX = e.clientX; - e.stopPropagation(); - e.preventDefault(); - } - @action - onMidMove = (e: PointerEvent) => { - const delta = e.clientX - this._downX; - this._downX = e.clientX; - const minReq = NumCast(this.props.Document[this.props.fieldKey + "-timelineMinReq"], NumCast(this.props.Document[this.props.fieldKey + "-timelineMin"], 0)); - const maxReq = NumCast(this.props.Document[this.props.fieldKey + "-timelineMaxReq"], NumCast(this.props.Document[this.props.fieldKey + "-timelineMax"], 10)); - this.props.Document[this.props.fieldKey + "-timelineMinReq"] = minReq - (maxReq - minReq) * delta / this.props.PanelWidth(); - this.props.Document[this.props.fieldKey + "-timelineMaxReq"] = maxReq - (maxReq - minReq) * delta / this.props.PanelWidth(); - } - onMidUp = (e: PointerEvent) => { - document.removeEventListener("pointermove", this.onMidMove); - document.removeEventListener("pointermove", this.onMidUp); - } - - layoutEngine = () => this._layoutEngine; - @computed get contents() { - return <div className="collectionTimeView-innards" key="timeline" style={{ width: this.bodyPanelWidth() }}> - <CollectionFreeFormView {...this.props} layoutEngine={this.layoutEngine} ScreenToLocalTransform={this.getTransform} PanelWidth={this.bodyPanelWidth} /> - </div>; - } - @computed get filterView() { - trace(); - const facetCollection = Cast(this.props.Document?._facetCollection, Doc, null); - const flyout = ( - <div className="collectionTimeView-flyout" style={{ width: `${this._facetWidth}` }}> - {this._allFacets.map(facet => <label className="collectionTimeView-flyout-item" key={`${facet}`} onClick={e => this.facetClick(facet)}> - <input type="checkbox" onChange={e => { }} checked={DocListCast((this.props.Document._facetCollection as Doc)?.data).some(d => d.title === facet)} /> - <span className="checkmark" /> - {facet} - </label>)} - </div> - ); - return <div className="collectionTimeView-treeView" style={{ width: `${this._facetWidth}px`, overflow: this._facetWidth < 15 ? "hidden" : undefined }}> - <div className="collectionTimeView-addFacet" style={{ width: `${this._facetWidth}px` }} onPointerDown={e => e.stopPropagation()}> - <Flyout anchorPoint={anchorPoints.LEFT_TOP} content={flyout}> - <div className="collectionTimeView-button"> - <span className="collectionTimeView-span">Facet Filters</span> - <FontAwesomeIcon icon={faEdit} size={"lg"} /> - </div> - </Flyout> - </div> - <div className="collectionTimeView-tree" key="tree"> - <CollectionTreeView {...this.props} Document={facetCollection} /> - </div> - </div>; - } - - public static SyncTimelineToPresentation(doc: Doc) { - const fieldKey = Doc.LayoutFieldKey(doc); - doc[fieldKey + "-timelineCur"] = ComputedField.MakeFunction("(curPresentationItem()[this._pivotField || 'year'] || 0)"); - } - specificMenu = (e: React.MouseEvent) => { - const layoutItems: ContextMenuProps[] = []; - const doc = this.props.Document; - - layoutItems.push({ description: "Force Timeline", event: () => { doc._forceRenderEngine = "timeline" }, icon: "compress-arrows-alt" }); - layoutItems.push({ description: "Force Pivot", event: () => { doc._forceRenderEngine = "pivot" }, icon: "compress-arrows-alt" }); - layoutItems.push({ description: "Auto Time/Pivot layout", event: () => { doc._forceRenderEngine = undefined }, icon: "compress-arrows-alt" }); - layoutItems.push({ description: "Sync with presentation", event: () => CollectionTimeView.SyncTimelineToPresentation(doc), icon: "compress-arrows-alt" }); - - ContextMenu.Instance.addItem({ description: "Pivot/Time Options ...", subitems: layoutItems, icon: "eye" }); - } - - render() { + @computed get pivotKeyUI() { const newEditableViewProps = { GetValue: () => "", SetValue: (value: any) => { @@ -263,7 +141,12 @@ export class CollectionTimeView extends CollectionSubView(doc => doc) { toggle: this.toggleVisibility, color: "#f1efeb" // this.props.headingObject ? this.props.headingObject.color : "#f1efeb"; }; + return <div className={"pivotKeyEntry"}> + <EditableView {...newEditableViewProps} display={"inline"} menuCallback={this.menuCallback} /> + </div>; + } + render() { let nonNumbers = 0; this.childDocs.map(doc => { const num = NumCast(doc[StrCast(this.props.Document._pivotField)], Number(StrCast(doc[StrCast(this.props.Document._pivotField)]))); @@ -283,48 +166,26 @@ export class CollectionTimeView extends CollectionSubView(doc => doc) { } } - - const facetCollection = Cast(this.props.Document?._facetCollection, Doc, null); - return !facetCollection ? (null) : - <div className={"collectionTimeView" + (doTimeline ? "" : "-pivot")} onContextMenu={this.specificMenu} - style={{ height: `calc(100% - ${this.props.Document._chromeStatus === "enabled" ? 51 : 0}px)` }}> - <div className={"pivotKeyEntry"}> - <button className="collectionTimeView-backBtn" style={{ width: 50, height: 20, background: "green" }} - onClick={action(() => { - let pfilterIndex = NumCast(this.props.Document._pfilterIndex); - if (pfilterIndex > 0) { - this.props.Document._docFilter = ObjectField.MakeCopy(this.props.Document["_pfilter" + --pfilterIndex] as ObjectField); - this.props.Document._pfilterIndex = pfilterIndex; - } else { - this.props.Document._docFilter = new List([]); - } - })}> - back - </button> - <EditableView {...newEditableViewProps} display={"inline"} menuCallback={this.menuCallback} /> - </div> - {!this.props.isSelected() || this.props.PanelHeight() < 100 ? (null) : - <div className="collectionTimeView-dragger" key="dragger" onPointerDown={this.onPointerDown} style={{ transform: `translate(${this._facetWidth}px, 0px)` }} > - <span title="library View Dragger" style={{ width: "5px", position: "absolute", top: "0" }} /> - </div> - } - {this.filterView} - {this.contents} - {!this.props.isSelected() || !doTimeline ? (null) : <> - <div className="collectionTimeView-thumb-min collectionTimeView-thumb" key="min" onPointerDown={this.onMinDown} /> - <div className="collectionTimeView-thumb-max collectionTimeView-thumb" key="mid" onPointerDown={this.onMaxDown} /> - <div className="collectionTimeView-thumb-mid collectionTimeView-thumb" key="max" onPointerDown={this.onMidDown} /> - </>} - </div>; + return <div className={"collectionTimeView" + (doTimeline ? "" : "-pivot")} onContextMenu={this.specificMenu} + style={{ width: this.props.PanelWidth(), height: `calc(100% - ${this.props.Document._chromeStatus === "enabled" ? 51 : 0}px)` }}> + {this.pivotKeyUI} + {this.contents} + {!this.props.isSelected() || !doTimeline ? (null) : <> + <div className="collectionTimeView-thumb-min collectionTimeView-thumb" key="min" onPointerDown={this.onMinDown} /> + <div className="collectionTimeView-thumb-max collectionTimeView-thumb" key="mid" onPointerDown={this.onMaxDown} /> + <div className="collectionTimeView-thumb-mid collectionTimeView-thumb" key="max" onPointerDown={this.onMidDown} /> + </>} + </div>; } } Scripting.addGlobal(function pivotColumnClick(pivotDoc: Doc, bounds: ViewDefBounds) { - let pfilterIndex = NumCast(pivotDoc._pfilterIndex); - pivotDoc["_pfilter" + pfilterIndex] = ObjectField.MakeCopy(pivotDoc._docFilter as ObjectField); - pivotDoc._pfilterIndex = ++pfilterIndex; + let prevFilterIndex = NumCast(pivotDoc._prevFilterIndex); + pivotDoc["_prevDocFilter" + prevFilterIndex] = ObjectField.MakeCopy(pivotDoc._docFilters as ObjectField); + pivotDoc["_prevDocRangeFilters" + prevFilterIndex] = ObjectField.MakeCopy(pivotDoc._docRangeFilters as ObjectField); + pivotDoc._prevFilterIndex = ++prevFilterIndex; runInAction(() => { - pivotDoc._docFilter = new List(); + pivotDoc._docFilters = new List(); (bounds.payload as string[]).map(filterVal => Doc.setDocFilter(pivotDoc, StrCast(pivotDoc._pivotField), filterVal, "check")); }); diff --git a/src/client/views/collections/CollectionTreeView.scss b/src/client/views/collections/CollectionTreeView.scss index 2fa6813d7..a00bb6bfb 100644 --- a/src/client/views/collections/CollectionTreeView.scss +++ b/src/client/views/collections/CollectionTreeView.scss @@ -22,6 +22,7 @@ ul { list-style: none; padding-left: 20px; + margin-bottom: 1px;// otherwise vertical scrollbars may pop up for no apparent reason.... } @@ -34,7 +35,9 @@ width: 15px; color: $intermediate-color; margin-top: 3px; - transform: scale(1.3, 1.3); + transform: scale(1.3, 1.3); + border: #80808030 1px solid; + border-radius: 4px; } .editableView-container { @@ -63,7 +66,9 @@ font-size: 8pt; margin-left: 3px; display: none; - background: lightgray; +} +.collectionTreeView-keyHeader:hover { + background: #797777; } .collectionTreeView-subtitle { @@ -77,6 +82,7 @@ text-overflow: ellipsis; white-space: pre-wrap; overflow: hidden; + min-width: 10px; // width:100%;//width: max-content; } @@ -84,24 +90,44 @@ .treeViewItem-openRight { display: none; height: 17px; - background: gray; width: 15px; } +.treeViewItem-openRight:hover { + background: #797777; +} .treeViewItem-border { display: inherit; border-left: dashed 1px #00000042; } +.treeViewItem-header { + border: transparent 1px solid; + display: flex; + + .editableView-container-editing-oneLine { + min-width: 15px; + } + .documentView-node-topmost { + width: unset; + } + > svg { + display: none; + } + +} + .treeViewItem-header:hover { .collectionTreeView-keyHeader { display: inherit; } + > svg { + display: inherit; + } .treeViewItem-openRight { display: inline-block; height: 17px; - background: #a8a7a7; width: 15px; // display: inline; @@ -113,15 +139,6 @@ } } -.treeViewItem-header { - border: transparent 1px solid; - display: flex; - - .editableView-container-editing-oneLine { - min-width: 15px; - } -} - .treeViewItem-header-above { border-top: black 1px solid; } diff --git a/src/client/views/collections/CollectionTreeView.tsx b/src/client/views/collections/CollectionTreeView.tsx index 001064b30..d938bd7ad 100644 --- a/src/client/views/collections/CollectionTreeView.tsx +++ b/src/client/views/collections/CollectionTreeView.tsx @@ -3,14 +3,14 @@ import { faAngleRight, faArrowsAltH, faBell, faCamera, faCaretDown, faCaretRight import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { action, computed, observable, runInAction, untracked } from "mobx"; import { observer } from "mobx-react"; -import { Doc, DocListCast, Field, HeightSym, WidthSym } from '../../../new_fields/Doc'; +import { DataSym, Doc, DocListCast, Field, HeightSym, Opt, WidthSym } from '../../../new_fields/Doc'; import { Id } from '../../../new_fields/FieldSymbols'; import { List } from '../../../new_fields/List'; +import { RichTextField } from '../../../new_fields/RichTextField'; import { Document, listSpec } from '../../../new_fields/Schema'; import { ComputedField, ScriptField } from '../../../new_fields/ScriptField'; import { BoolCast, Cast, NumCast, ScriptCast, StrCast } from '../../../new_fields/Types'; -import { CurrentUserUtils } from '../../../server/authentication/models/current_user_utils'; -import { emptyFunction, emptyPath, returnFalse, Utils } from '../../../Utils'; +import { emptyFunction, emptyPath, returnFalse, returnOne, returnTrue, returnZero, simulateMouseClick, Utils } from '../../../Utils'; import { Docs, DocUtils } from '../../documents/Documents'; import { DocumentType } from "../../documents/DocumentTypes"; import { DocumentManager } from '../../util/DocumentManager'; @@ -19,22 +19,20 @@ import { makeTemplate } from '../../util/DropConverter'; import { Scripting } from '../../util/Scripting'; import { SelectionManager } from '../../util/SelectionManager'; import { Transform } from '../../util/Transform'; -import { undoBatch } from '../../util/UndoManager'; +import { undoBatch, UndoManager } from '../../util/UndoManager'; import { ContextMenu } from '../ContextMenu'; import { ContextMenuProps } from '../ContextMenuItem'; import { EditableView } from "../EditableView"; import { MainView } from '../MainView'; import { ContentFittingDocumentView } from '../nodes/ContentFittingDocumentView'; +import { DocumentView } from '../nodes/DocumentView'; import { ImageBox } from '../nodes/ImageBox'; import { KeyValueBox } from '../nodes/KeyValueBox'; -import { ScriptBox } from '../ScriptBox'; import { Templates } from '../Templates'; import { CollectionSubView } from "./CollectionSubView"; import "./CollectionTreeView.scss"; -import React = require("react"); import { CollectionViewType } from './CollectionView'; -import { RichTextField } from '../../../new_fields/RichTextField'; -import { ObjectField } from '../../../new_fields/ObjectField'; +import React = require("react"); export interface TreeViewProps { @@ -46,8 +44,8 @@ export interface TreeViewProps { renderDepth: number; deleteDoc: (doc: Doc) => boolean; moveDocument: DragManager.MoveFunction; - dropAction: "alias" | "copy" | undefined; - addDocTab: (doc: Doc, dataDoc: Doc | undefined, where: string, libraryPath?: Doc[]) => boolean; + dropAction: dropActionType; + addDocTab: (doc: Doc, where: string, libraryPath?: Doc[]) => boolean; pinToPres: (document: Doc) => void; panelWidth: () => number; panelHeight: () => number; @@ -56,14 +54,17 @@ export interface TreeViewProps { indentDocument?: () => void; outdentDocument?: () => void; ScreenToLocalTransform: () => Transform; + backgroundColor?: (doc: Doc) => string | undefined; outerXf: () => { translateX: number, translateY: number }; treeViewId: Doc; parentKey: string; active: (outsideReaction?: boolean) => boolean; - hideHeaderFields: () => boolean; - preventTreeViewOpen: boolean; + treeViewHideHeaderFields: () => boolean; + treeViewPreventOpen: boolean; renderedIds: string[]; onCheckedClick?: ScriptField; + onChildClick?: ScriptField; + ignoreFields?: string[]; } library.add(faTrashAlt); @@ -84,23 +85,23 @@ library.add(faPlus, faMinus); * * special fields: * treeViewOpen : flag denoting whether the documents sub-tree (contents) is visible or hidden - * preventTreeViewOpen : ignores the treeViewOpen flag (for allowing a view to not be slaved to other views of the document) + * treeViewPreventOpen : ignores the treeViewOpen flag (for allowing a view to not be slaved to other views of the document) * treeViewExpandedView : name of field whose contents are being displayed as the document's subtree */ class TreeView extends React.Component<TreeViewProps> { - static loadId = ""; private _header?: React.RefObject<HTMLDivElement> = React.createRef(); private _treedropDisposer?: DragManager.DragDropDisposer; private _dref = React.createRef<HTMLDivElement>(); + private _tref = React.createRef<HTMLDivElement>(); get displayName() { return "TreeView(" + this.props.document.title + ")"; } // this makes mobx trace() statements more descriptive get defaultExpandedView() { return this.childDocs ? this.fieldKey : StrCast(this.props.document.defaultExpandedView, "fields"); } @observable _overrideTreeViewOpen = false; // override of the treeViewOpen field allowing the display state to be independent of the document's state - set treeViewOpen(c: boolean) { if (this.props.preventTreeViewOpen) this._overrideTreeViewOpen = c; else this.props.document.treeViewOpen = this._overrideTreeViewOpen = c; } - @computed get treeViewOpen() { return (!this.props.preventTreeViewOpen && BoolCast(this.props.document.treeViewOpen)) || this._overrideTreeViewOpen; } + set treeViewOpen(c: boolean) { if (this.props.treeViewPreventOpen) this._overrideTreeViewOpen = c; else this.props.document.treeViewOpen = this._overrideTreeViewOpen = c; } + @computed get treeViewOpen() { return (!this.props.treeViewPreventOpen && BoolCast(this.props.document.treeViewOpen)) || this._overrideTreeViewOpen; } @computed get treeViewExpandedView() { return StrCast(this.props.document.treeViewExpandedView, this.defaultExpandedView); } - @computed get MAX_EMBED_HEIGHT() { return NumCast(this.props.document.maxEmbedHeight, 300); } + @computed get MAX_EMBED_HEIGHT() { return NumCast(this.props.containingCollection.maxEmbedHeight, 200); } @computed get dataDoc() { return this.templateDataDoc ? this.templateDataDoc : this.props.document; } @computed get fieldKey() { const splits = StrCast(Doc.LayoutField(this.props.document)).split("fieldKey={\'"); @@ -128,7 +129,7 @@ class TreeView extends React.Component<TreeViewProps> { } @undoBatch delete = () => this.props.deleteDoc(this.props.document); - @undoBatch openRight = () => this.props.addDocTab(this.props.document, this.templateDataDoc, "onRight", this.props.libraryPath); + @undoBatch openRight = () => this.props.addDocTab(this.props.dropAction === "alias" ? Doc.MakeAlias(this.props.document) : this.props.document, "onRight", this.props.libraryPath); @undoBatch indent = () => this.props.addDocument(this.props.document) && this.delete(); @undoBatch move = (doc: Doc, target: Doc | undefined, addDoc: (doc: Doc) => boolean) => { return this.props.document !== target && this.props.deleteDoc(doc) && addDoc(doc); @@ -136,13 +137,15 @@ class TreeView extends React.Component<TreeViewProps> { @undoBatch @action remove = (document: Document, key: string) => { return Doc.RemoveDocFromList(this.dataDoc, key, document); } + @undoBatch @action removeDoc = (document: Document) => { + return Doc.RemoveDocFromList(this.props.containingCollection, Doc.LayoutFieldKey(this.props.containingCollection), document); + } protected createTreeDropTarget = (ele: HTMLDivElement) => { this._treedropDisposer && this._treedropDisposer(); - ele && (this._treedropDisposer = DragManager.MakeDropTarget(ele, this.treeDrop.bind(this))); + ele && (this._treedropDisposer = DragManager.MakeDropTarget(ele, this.treeDrop.bind(this)), this.props.document); } - onPointerDown = (e: React.PointerEvent) => e.stopPropagation(); onPointerEnter = (e: React.PointerEvent): void => { this.props.active(true) && Doc.BrushDoc(this.dataDoc); if (e.buttons === 1 && SelectionManager.GetIsDragging()) { @@ -171,55 +174,39 @@ class TreeView extends React.Component<TreeViewProps> { editableView = (key: string, style?: string) => (<EditableView oneLine={true} display={"inline-block"} - editing={this.dataDoc[Id] === TreeView.loadId} + editing={true /*this.dataDoc[Id] === EditableView.loadId*/} contents={StrCast(this.props.document[key])} height={12} fontStyle={style} fontSize={12} GetValue={() => StrCast(this.props.document[key])} - SetValue={undoBatch((value: string) => Doc.SetInPlace(this.props.document, key, value, false) || true)} + SetValue={undoBatch((value: string) => { + Doc.SetInPlace(this.props.document, key, value, false) || true; + Doc.SetInPlace(this.props.document, "editTitle", undefined, false); + })} OnFillDown={undoBatch((value: string) => { Doc.SetInPlace(this.props.document, key, value, false); - const layoutDoc = this.props.document.layout_custom instanceof Doc ? Doc.ApplyTemplate(Doc.GetProto(this.props.document.layout_custom)) : undefined; - const doc = layoutDoc || Docs.Create.FreeformDocument([], { title: "", x: 0, y: 0, _width: 100, _height: 25, templates: new List<string>([Templates.Title.Layout]) }); - TreeView.loadId = doc[Id]; + const doc = Docs.Create.FreeformDocument([], { title: "-", x: 0, y: 0, _width: 100, _height: 25, templates: new List<string>([Templates.Title.Layout]) }); + Doc.SetInPlace(this.props.document, "editTitle", undefined, false); + Doc.SetInPlace(doc, "editTitle", true, false); return this.props.addDocument(doc); })} + onClick={() => { + SelectionManager.DeselectAll(); + Doc.UserDoc().activeSelection = new List([this.props.document]); + return false; + }} OnTab={undoBatch((shift?: boolean) => { - TreeView.loadId = this.dataDoc[Id]; + EditableView.loadId = this.dataDoc[Id]; shift ? this.props.outdentDocument?.() : this.props.indentDocument?.(); setTimeout(() => { // unsetting/setting brushing for this doc will recreate & refocus this editableView after all other treeview changes have been made to the Dom (which may remove focus from this document). Doc.UnBrushDoc(this.props.document); Doc.BrushDoc(this.props.document); - TreeView.loadId = ""; + EditableView.loadId = ""; }, 0); })} />) - onWorkspaceContextMenu = (e: React.MouseEvent): void => { - if (!e.isPropagationStopped()) { // need to test this because GoldenLayout causes a parallel hierarchy in the React DOM for its children and the main document view - if (this.props.document === CurrentUserUtils.UserDocument.recentlyClosed) { - ContextMenu.Instance.addItem({ description: "Clear All", event: () => Doc.GetProto(CurrentUserUtils.UserDocument.recentlyClosed as Doc).data = new List<Doc>(), icon: "plus" }); - } else if (this.props.document !== CurrentUserUtils.UserDocument.workspaces) { - ContextMenu.Instance.addItem({ description: "Pin to Presentation", event: () => this.props.pinToPres(this.props.document), icon: "tv" }); - ContextMenu.Instance.addItem({ description: "Open Tab", event: () => this.props.addDocTab(this.props.document, this.templateDataDoc, "inTab", this.props.libraryPath), icon: "folder" }); - ContextMenu.Instance.addItem({ description: "Open Right", event: () => this.props.addDocTab(this.props.document, this.templateDataDoc, "onRight", this.props.libraryPath), icon: "caret-square-right" }); - if (DocumentManager.Instance.getDocumentViews(this.dataDoc).length) { - ContextMenu.Instance.addItem({ description: "Focus", event: () => (view => view && view.props.focus(this.props.document, true))(DocumentManager.Instance.getFirstDocumentView(this.props.document)), icon: "camera" }); - } - ContextMenu.Instance.addItem({ description: "Delete Item", event: () => this.props.deleteDoc(this.props.document), icon: "trash-alt" }); - } else { - ContextMenu.Instance.addItem({ description: "Delete Workspace", event: () => this.props.deleteDoc(this.props.document), icon: "trash-alt" }); - ContextMenu.Instance.addItem({ description: "Create New Workspace", event: () => MainView.Instance.createNewWorkspace(), icon: "plus" }); - } - ContextMenu.Instance.addItem({ description: "Open Fields", event: () => { const kvp = Docs.Create.KVPDocument(this.props.document, { _width: 300, _height: 300 }); this.props.addDocTab(kvp, this.props.dataDoc ? this.props.dataDoc : kvp, "onRight"); }, icon: "layer-group" }); - ContextMenu.Instance.addItem({ description: "Publish", event: () => DocUtils.Publish(this.props.document, StrCast(this.props.document.title), () => { }, () => { }), icon: "file" }); - ContextMenu.Instance.displayMenu(e.pageX > 156 ? e.pageX - 156 : 0, e.pageY - 15); - e.stopPropagation(); - e.preventDefault(); - } - } - @undoBatch treeDrop = (e: Event, de: DragManager.DropEvent) => { const pt = [de.x, de.y]; @@ -229,7 +216,7 @@ class TreeView extends React.Component<TreeViewProps> { if (de.complete.linkDragData) { const sourceDoc = de.complete.linkDragData.linkSourceDocument; const destDoc = this.props.document; - DocUtils.MakeLink({ doc: sourceDoc }, { doc: destDoc }); + DocUtils.MakeLink({ doc: sourceDoc }, { doc: destDoc }, "tree link"); e.stopPropagation(); } if (de.complete.docDragData) { @@ -240,7 +227,8 @@ class TreeView extends React.Component<TreeViewProps> { addDoc = (doc: Doc) => Doc.AddDocToList(this.dataDoc, this.fieldKey, doc) || addDoc(doc); } const movedDocs = (de.complete.docDragData.treeViewId === this.props.treeViewId[Id] ? de.complete.docDragData.draggedDocuments : de.complete.docDragData.droppedDocuments); - return ((de.complete.docDragData.dropAction && (de.complete.docDragData.treeViewId !== this.props.treeViewId[Id])) || de.complete.docDragData.userDropAction) ? + const move = de.complete.docDragData.dropAction === "move" || de.complete.docDragData.dropAction; + return ((!move && (de.complete.docDragData.treeViewId !== this.props.treeViewId[Id])) || de.complete.docDragData.userDropAction) ? de.complete.docDragData.droppedDocuments.reduce((added, d) => addDoc(d) || added, false) : de.complete.docDragData.moveDocument ? movedDocs.reduce((added, d) => de.complete.docDragData?.moveDocument?.(d, undefined, addDoc) || added, false) @@ -252,28 +240,36 @@ class TreeView extends React.Component<TreeViewProps> { docTransform = () => { const { scale, translateX, translateY } = Utils.GetScreenTransform(this._dref.current!); const outerXf = this.props.outerXf(); + const offset = this.props.ScreenToLocalTransform().transformDirection((outerXf.translateX - translateX), outerXf.translateY - translateY); + const finalXf = this.props.ScreenToLocalTransform().translate(offset[0], offset[1]); + + return finalXf; + } + getTransform = () => { + const { scale, translateX, translateY } = Utils.GetScreenTransform(this._tref.current!); + const outerXf = this.props.outerXf(); const offset = this.props.ScreenToLocalTransform().transformDirection(outerXf.translateX - translateX, outerXf.translateY - translateY); - const finalXf = this.props.ScreenToLocalTransform().translate(offset[0], offset[1] + (this.props.ChromeHeight && this.props.ChromeHeight() < 0 ? this.props.ChromeHeight() : 0)); + const finalXf = this.props.ScreenToLocalTransform().translate(offset[0], offset[1]); return finalXf; } docWidth = () => { const layoutDoc = Doc.Layout(this.props.document); - const aspect = NumCast(layoutDoc._nativeHeight) / NumCast(layoutDoc._nativeWidth); + const aspect = NumCast(layoutDoc._nativeHeight, layoutDoc._fitWidth ? 0 : layoutDoc[HeightSym]()) / NumCast(layoutDoc._nativeWidth, layoutDoc._fitWidth ? 1 : layoutDoc[WidthSym]()); if (aspect) return Math.min(layoutDoc[WidthSym](), Math.min(this.MAX_EMBED_HEIGHT / aspect, this.props.panelWidth() - 20)); return NumCast(layoutDoc._nativeWidth) ? Math.min(layoutDoc[WidthSym](), this.props.panelWidth() - 20) : this.props.panelWidth() - 20; } docHeight = () => { const layoutDoc = Doc.Layout(this.props.document); const bounds = this.boundsOfCollectionDocument; - return Math.min(this.MAX_EMBED_HEIGHT, (() => { - const aspect = NumCast(layoutDoc._nativeHeight) / NumCast(layoutDoc._nativeWidth, 1); + return Math.max(70, Math.min(this.MAX_EMBED_HEIGHT, (() => { + const aspect = NumCast(layoutDoc._nativeHeight, layoutDoc._fitWidth ? 0 : layoutDoc[HeightSym]()) / NumCast(layoutDoc._nativeWidth, layoutDoc._fitWidth ? 1 : layoutDoc[WidthSym]()); if (aspect) return this.docWidth() * aspect; if (bounds) return this.docWidth() * (bounds.b - bounds.y) / (bounds.r - bounds.x); return layoutDoc._fitWidth ? (!this.props.document.nativeHeight ? NumCast(this.props.containingCollection._height) : Math.min(this.docWidth() * NumCast(layoutDoc.scrollHeight, NumCast(layoutDoc._nativeHeight)) / NumCast(layoutDoc._nativeWidth, NumCast(this.props.containingCollection._height)))) : NumCast(layoutDoc._height) ? NumCast(layoutDoc._height) : 50; - })()); + })())); } @computed get expandedField() { @@ -283,6 +279,7 @@ class TreeView extends React.Component<TreeViewProps> { const rows: JSX.Element[] = []; for (const key of Object.keys(ids).slice().sort()) { + if (this.props.ignoreFields?.includes(key) || key === "title" || key === "treeViewOpen") continue; const contents = doc[key]; let contentElement: (JSX.Element | null)[] | JSX.Element = []; @@ -291,13 +288,13 @@ class TreeView extends React.Component<TreeViewProps> { const addDoc = (doc: Doc, addBefore?: Doc, before?: boolean) => Doc.AddDocToList(this.dataDoc, key, doc, addBefore, before, false, true); contentElement = TreeView.GetChildElements(contents instanceof Doc ? [contents] : DocListCast(contents), this.props.treeViewId, doc, undefined, key, this.props.containingCollection, this.props.prevSibling, addDoc, remDoc, this.move, - this.props.dropAction, this.props.addDocTab, this.props.pinToPres, this.props.ScreenToLocalTransform, this.props.outerXf, this.props.active, - this.props.panelWidth, this.props.ChromeHeight, this.props.renderDepth, this.props.hideHeaderFields, this.props.preventTreeViewOpen, - [...this.props.renderedIds, doc[Id]], this.props.libraryPath, this.props.onCheckedClick); + this.props.dropAction, this.props.addDocTab, this.props.pinToPres, this.props.backgroundColor, this.props.ScreenToLocalTransform, this.props.outerXf, this.props.active, + this.props.panelWidth, this.props.ChromeHeight, this.props.renderDepth, this.props.treeViewHideHeaderFields, this.props.treeViewPreventOpen, + [...this.props.renderedIds, doc[Id]], this.props.libraryPath, this.props.onCheckedClick, this.props.onChildClick, this.props.ignoreFields); } else { contentElement = <EditableView key="editableView" - contents={contents !== undefined ? contents.toString() : "null"} + contents={contents !== undefined ? Field.toString(contents as Field) : "null"} height={13} fontSize={12} GetValue={() => Field.toKeyValueString(doc, key)} @@ -324,19 +321,26 @@ class TreeView extends React.Component<TreeViewProps> { return rows; } + rtfWidth = () => Math.min(Doc.Layout(this.props.document)?.[WidthSym](), this.props.panelWidth() - 20); + rtfHeight = () => this.rtfWidth() < Doc.Layout(this.props.document)?.[WidthSym]() ? Math.min(Doc.Layout(this.props.document)?.[HeightSym](), this.MAX_EMBED_HEIGHT) : this.MAX_EMBED_HEIGHT; + @computed get renderContent() { const expandKey = this.treeViewExpandedView === this.fieldKey ? this.fieldKey : this.treeViewExpandedView === "links" ? "links" : undefined; if (expandKey !== undefined) { const remDoc = (doc: Doc) => this.remove(doc, expandKey); const addDoc = (doc: Doc, addBefore?: Doc, before?: boolean) => Doc.AddDocToList(this.dataDoc, expandKey, doc, addBefore, before, false, true); const docs = expandKey === "links" ? this.childLinks : this.childDocs; - return <ul key={expandKey + "more"}> + const sortKey = `${this.fieldKey}-sortAscending`; + return <ul key={expandKey + "more"} onClick={(e) => { + this.props.document[sortKey] = (this.props.document[sortKey] ? false : (this.props.document[sortKey] === false ? undefined : true)); + e.stopPropagation(); + }}> {!docs ? (null) : TreeView.GetChildElements(docs, this.props.treeViewId, Doc.Layout(this.props.document), this.templateDataDoc, expandKey, this.props.containingCollection, this.props.prevSibling, addDoc, remDoc, this.move, - this.props.dropAction, this.props.addDocTab, this.props.pinToPres, this.props.ScreenToLocalTransform, - this.props.outerXf, this.props.active, this.props.panelWidth, this.props.ChromeHeight, this.props.renderDepth, this.props.hideHeaderFields, this.props.preventTreeViewOpen, - [...this.props.renderedIds, this.props.document[Id]], this.props.libraryPath, this.props.onCheckedClick)} + StrCast(this.props.document.childDropAction, this.props.dropAction) as dropActionType, this.props.addDocTab, this.props.pinToPres, this.props.backgroundColor, this.props.ScreenToLocalTransform, + this.props.outerXf, this.props.active, this.props.panelWidth, this.props.ChromeHeight, this.props.renderDepth, this.props.treeViewHideHeaderFields, this.props.treeViewPreventOpen, + [...this.props.renderedIds, this.props.document[Id]], this.props.libraryPath, this.props.onCheckedClick, this.props.onChildClick, this.props.ignoreFields)} </ul >; } else if (this.treeViewExpandedView === "fields") { return <ul><div ref={this._dref} style={{ display: "inline-block" }} key={this.props.document[Id] + this.props.document.title}> @@ -344,15 +348,22 @@ class TreeView extends React.Component<TreeViewProps> { </div></ul>; } else { const layoutDoc = Doc.Layout(this.props.document); - return <div ref={this._dref} style={{ display: "inline-block", height: this.docHeight() }} key={this.props.document[Id] + this.props.document.title}> + const panelHeight = layoutDoc.type === DocumentType.RTF ? this.rtfHeight : this.docHeight; + const panelWidth = layoutDoc.type === DocumentType.RTF ? this.rtfWidth : this.docWidth; + return <div ref={this._dref} style={{ display: "inline-block", height: panelHeight() }} key={this.props.document[Id] + this.props.document.title}> <ContentFittingDocumentView Document={layoutDoc} DataDocument={this.templateDataDoc} LibraryPath={emptyPath} renderDepth={this.props.renderDepth + 1} + rootSelected={returnTrue} + backgroundColor={this.props.backgroundColor} fitToBox={this.boundsOfCollectionDocument !== undefined} - PanelWidth={this.docWidth} - PanelHeight={this.docHeight} + FreezeDimensions={true} + NativeWidth={layoutDoc.type === DocumentType.RTF ? this.rtfWidth : undefined} + NativeHeight={layoutDoc.type === DocumentType.RTF ? this.rtfHeight : undefined} + PanelWidth={panelWidth} + PanelHeight={panelHeight} getTransform={this.docTransform} CollectionDoc={this.props.containingCollection} CollectionView={undefined} @@ -367,11 +378,13 @@ class TreeView extends React.Component<TreeViewProps> { } } + get onCheckedClick() { return this.props.onCheckedClick || ScriptCast(this.props.document.onCheckedClick); } + @action bulletClick = (e: React.MouseEvent) => { - if (this.props.onCheckedClick && this.props.document.type !== DocumentType.COL) { + if (this.onCheckedClick && this.props.document.type !== DocumentType.COL) { // this.props.document.treeViewChecked = this.props.document.treeViewChecked === "check" ? "x" : this.props.document.treeViewChecked === "x" ? undefined : "check"; - ScriptCast(this.props.onCheckedClick).script.run({ + this.onCheckedClick.script.run({ this: this.props.document.isTemplateForField && this.props.dataDoc ? this.props.dataDoc : this.props.document, heading: this.props.containingCollection.title, checked: this.props.document.treeViewChecked === "check" ? "x" : this.props.document.treeViewChecked === "x" ? undefined : "check", @@ -385,60 +398,115 @@ class TreeView extends React.Component<TreeViewProps> { @computed get renderBullet() { - const checked = this.props.document.type === DocumentType.COL ? undefined : this.props.onCheckedClick ? (this.props.document.treeViewChecked ? this.props.document.treeViewChecked : "unchecked") : undefined; - return <div className="bullet" title="view inline" onClick={this.bulletClick} style={{ color: StrCast(this.props.document.color, checked === "unchecked" ? "white" : "black"), opacity: 0.4 }}> + const checked = this.props.document.type === DocumentType.COL ? undefined : this.onCheckedClick ? (this.props.document.treeViewChecked ? this.props.document.treeViewChecked : "unchecked") : undefined; + return <div className="bullet" title="view inline" onClick={this.bulletClick} style={{ color: StrCast(this.props.document.color, checked === "unchecked" ? "white" : "inherit"), opacity: checked === "unchecked" ? undefined : 0.4 }}> {<FontAwesomeIcon icon={checked === "check" ? "check" : (checked === "x" ? "times" : checked === "unchecked" ? "square" : !this.treeViewOpen ? (this.childDocs ? "caret-square-right" : "caret-right") : (this.childDocs ? "caret-square-down" : "caret-down"))} />} </div>; } + + showContextMenu = (e: React.MouseEvent) => { + simulateMouseClick(this._docRef.current!.ContentDiv!, e.clientX, e.clientY + 30, e.screenX, e.screenY + 30); + e.stopPropagation(); + } + focusOnDoc = (doc: Doc) => DocumentManager.Instance.getFirstDocumentView(doc)?.props.focus(doc, true); + contextMenuItems = () => { + const focusScript = ScriptField.MakeFunction(`DocFocus(self)`); + return [{ script: focusScript!, label: "Focus" }]; + } + _docRef = React.createRef<DocumentView>(); /** * Renders the EditableView title element for placement into the tree. */ @computed get renderTitle() { - const reference = React.createRef<HTMLDivElement>(); - const onItemDown = SetupDrag(reference, () => this.dataDoc, this.move, this.props.dropAction, this.props.treeViewId[Id], true); + const onItemDown = SetupDrag(this._tref, () => this.dataDoc, this.move, this.props.dropAction, this.props.treeViewId[Id], true); + const editTitle = ScriptField.MakeFunction("setInPlace(this, 'editTitle', true)"); const headerElements = ( - <span className="collectionTreeView-keyHeader" key={this.treeViewExpandedView} - onPointerDown={action(() => { - if (this.treeViewOpen) { - this.props.document.treeViewExpandedView = this.treeViewExpandedView === this.fieldKey ? "fields" : - this.treeViewExpandedView === "fields" && Doc.Layout(this.props.document) ? "layout" : - this.treeViewExpandedView === "layout" && this.props.document.links ? "links" : - this.childDocs ? this.fieldKey : "fields"; - } - this.treeViewOpen = true; - })}> - {this.treeViewExpandedView} - </span>); - const openRight = (<div className="treeViewItem-openRight" onPointerDown={this.onPointerDown} onClick={this.openRight}> + <> + <FontAwesomeIcon icon="cog" size="sm" onClick={e => this.showContextMenu(e)}></FontAwesomeIcon> + <span className="collectionTreeView-keyHeader" key={this.treeViewExpandedView} + onPointerDown={action(() => { + if (this.treeViewOpen) { + this.props.document.treeViewExpandedView = this.treeViewExpandedView === this.fieldKey ? "fields" : + this.treeViewExpandedView === "fields" && Doc.Layout(this.props.document) ? "layout" : + this.treeViewExpandedView === "layout" && this.props.document.links ? "links" : + this.childDocs ? this.fieldKey : "fields"; + } + this.treeViewOpen = true; + })}> + {this.treeViewExpandedView} + </span> + </>); + const openRight = (<div className="treeViewItem-openRight" onClick={this.openRight}> <FontAwesomeIcon title="open in pane on right" icon="angle-right" size="lg" /> </div>); return <> - <div className="docContainer" title="click to edit title" id={`docContainer-${this.props.parentKey}`} ref={reference} onPointerDown={onItemDown} + <div className="docContainer" ref={this._tref} title="click to edit title" id={`docContainer-${this.props.parentKey}`} onPointerDown={onItemDown} style={{ - color: this.props.document.isMinimized ? "red" : "black", background: Doc.IsHighlighted(this.props.document) ? "orange" : Doc.IsBrushed(this.props.document) ? "#06121212" : "0", fontWeight: this.props.document.searchMatch ? "bold" : undefined, + textDecoration: Doc.GetT(this.props.document, "title", "string", true) ? "underline" : undefined, outline: BoolCast(this.props.document.workspaceBrush) ? "dashed 1px #06123232" : undefined, - pointerEvents: this.props.active() || SelectionManager.GetIsDragging() ? "all" : "none" + pointerEvents: this.props.active() || SelectionManager.GetIsDragging() ? undefined : "none" }} > - {this.editableView("title")} + {Doc.GetT(this.props.document, "editTitle", "boolean", true) ? + this.editableView("title") : + <DocumentView + ref={this._docRef} + Document={this.props.document} + DataDoc={undefined} + LibraryPath={this.props.libraryPath || []} + addDocument={undefined} + addDocTab={this.props.addDocTab} + rootSelected={returnTrue} + pinToPres={emptyFunction} + onClick={this.props.onChildClick || editTitle} + dropAction={this.props.dropAction} + moveDocument={this.move} + removeDocument={this.removeDoc} + ScreenToLocalTransform={this.getTransform} + ContentScaling={returnOne} + PanelWidth={returnZero} + PanelHeight={returnZero} + NativeHeight={returnZero} + NativeWidth={returnZero} + contextMenuItems={this.contextMenuItems} + renderDepth={1} + focus={emptyFunction} + parentActive={returnTrue} + whenActiveChanged={emptyFunction} + bringToFront={emptyFunction} + dontRegisterView={BoolCast(this.props.treeViewId.dontRegisterChildren)} + ContainingCollectionView={undefined} + ContainingCollectionDoc={this.props.containingCollection} + />} </div > - {this.props.hideHeaderFields() ? (null) : headerElements} + {this.props.treeViewHideHeaderFields() ? (null) : headerElements} {openRight} </>; } render() { + const sorting = this.props.document[`${this.fieldKey}-sortAscending`]; setTimeout(() => runInAction(() => untracked(() => this._overrideTreeViewOpen = this.treeViewOpen)), 0); - return <div className="treeViewItem-container" ref={this.createTreeDropTarget} onContextMenu={this.onWorkspaceContextMenu}> + return <div className="treeViewItem-container" ref={this.createTreeDropTarget}> <li className="collection-child"> - <div className="treeViewItem-header" ref={this._header} onPointerEnter={this.onPointerEnter} onPointerLeave={this.onPointerLeave}> + <div className="treeViewItem-header" ref={this._header} onClick={e => { + if (this.props.active(true)) { + e.stopPropagation(); + e.preventDefault(); + } + }} onPointerDown={e => { + if (this.props.active(true)) { + e.stopPropagation(); + e.preventDefault(); + } + }} onPointerEnter={this.onPointerEnter} onPointerLeave={this.onPointerLeave}> {this.renderBullet} {this.renderTitle} </div> - <div className="treeViewItem-border"> + <div className="treeViewItem-border" style={{ borderColor: sorting === undefined ? undefined : sorting ? "crimson" : "blue" }}> {!this.treeViewOpen || this.props.renderedIds.indexOf(this.props.document[Id]) !== -1 ? (null) : this.renderContent} </div> </li> @@ -456,19 +524,22 @@ class TreeView extends React.Component<TreeViewProps> { remove: ((doc: Doc) => boolean), move: DragManager.MoveFunction, dropAction: dropActionType, - addDocTab: (doc: Doc, dataDoc: Doc | undefined, where: string) => boolean, + addDocTab: (doc: Doc, where: string) => boolean, pinToPres: (document: Doc) => void, + backgroundColor: undefined | ((document: Doc) => string | undefined), screenToLocalXf: () => Transform, outerXf: () => { translateX: number, translateY: number }, active: (outsideReaction?: boolean) => boolean, panelWidth: () => number, ChromeHeight: undefined | (() => number), renderDepth: number, - hideHeaderFields: () => boolean, - preventTreeViewOpen: boolean, + treeViewHideHeaderFields: () => boolean, + treeViewPreventOpen: boolean, renderedIds: string[], libraryPath: Doc[] | undefined, - onCheckedClick: ScriptField | undefined + onCheckedClick: ScriptField | undefined, + onChildClick: ScriptField | undefined, + ignoreFields: string[] | undefined ) { const viewSpecScript = Cast(containingCollection.viewSpecScript, ScriptField); if (viewSpecScript) { @@ -476,10 +547,8 @@ class TreeView extends React.Component<TreeViewProps> { } const docs = childDocs.slice(); - const dataExtension = containingCollection[key + "_ext"] as Doc; - const ascending = dataExtension && BoolCast(dataExtension.sortAscending, null); + const ascending = containingCollection?.[key + "-sortAscending"]; if (ascending !== undefined) { - const sortAlphaNum = (a: string, b: string): 0 | 1 | -1 => { const reN = /[0-9]*$/; const aA = a.replace(reN, ""); // get rid of trailing numbers @@ -560,9 +629,11 @@ class TreeView extends React.Component<TreeViewProps> { indentDocument={indent} outdentDocument={outdent} onCheckedClick={onCheckedClick} + onChildClick={onChildClick} renderDepth={renderDepth} deleteDoc={remove} addDocument={addDocument} + backgroundColor={backgroundColor} panelWidth={rowWidth} panelHeight={rowHeight} ChromeHeight={ChromeHeight} @@ -574,108 +645,115 @@ class TreeView extends React.Component<TreeViewProps> { outerXf={outerXf} parentKey={key} active={active} - hideHeaderFields={hideHeaderFields} - preventTreeViewOpen={preventTreeViewOpen} - renderedIds={renderedIds} />; + treeViewHideHeaderFields={treeViewHideHeaderFields} + treeViewPreventOpen={treeViewPreventOpen} + renderedIds={renderedIds} + ignoreFields={ignoreFields} />; }); } } +export type collectionTreeViewProps = { + treeViewHideTitle?: boolean; + treeViewHideHeaderFields?: boolean; + onCheckedClick?: ScriptField; + onChildClick?: ScriptField; +}; + @observer -export class CollectionTreeView extends CollectionSubView(Document) { +export class CollectionTreeView extends CollectionSubView<Document, Partial<collectionTreeViewProps>>(Document) { private treedropDisposer?: DragManager.DragDropDisposer; private _mainEle?: HTMLDivElement; @computed get dataDoc() { return this.props.DataDoc || this.props.Document; } protected createTreeDropTarget = (ele: HTMLDivElement) => { - this.treedropDisposer && this.treedropDisposer(); + this.treedropDisposer?.(); if (this._mainEle = ele) { - this.treedropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this)); + this.treedropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.props.Document); } } componentWillUnmount() { super.componentWillUnmount(); - this.treedropDisposer && this.treedropDisposer(); + this.treedropDisposer?.(); } @action remove = (document: Document): boolean => { - const children = Cast(this.props.Document[this.props.fieldKey], listSpec(Doc), []); + const children = Cast(this.props.Document[DataSym][this.props.fieldKey], listSpec(Doc), []); if (children.indexOf(document) !== -1) { children.splice(children.indexOf(document), 1); return true; } return false; } + @action + addDoc = (doc: Document, relativeTo: Opt<Doc>, before?: boolean): boolean => { + const doAddDoc = () => + Doc.AddDocToList(this.props.Document[DataSym], this.props.fieldKey, doc, relativeTo, before, false, false, false); + if (this.props.Document.resolvedDataDoc instanceof Promise) { + this.props.Document.resolvedDataDoc.then((resolved: any) => doAddDoc()); + } else { + doAddDoc(); + } + return true; + } onContextMenu = (e: React.MouseEvent): void => { // need to test if propagation has stopped because GoldenLayout forces a parallel react hierarchy to be created for its top-level layout - if (!e.isPropagationStopped() && this.props.Document === CurrentUserUtils.UserDocument.workspaces) { + if (!e.isPropagationStopped() && this.props.Document === Doc.UserDoc().myWorkspaces) { ContextMenu.Instance.addItem({ description: "Create Workspace", event: () => MainView.Instance.createNewWorkspace(), icon: "plus" }); ContextMenu.Instance.addItem({ description: "Delete Workspace", event: () => this.remove(this.props.Document), icon: "minus" }); e.stopPropagation(); e.preventDefault(); ContextMenu.Instance.displayMenu(e.pageX - 15, e.pageY - 15); - } else if (!e.isPropagationStopped() && this.props.Document === CurrentUserUtils.UserDocument.recentlyClosed) { - ContextMenu.Instance.addItem({ description: "Clear All", event: () => CurrentUserUtils.UserDocument.recentlyClosed = new List<Doc>(), icon: "plus" }); + } else if (!e.isPropagationStopped() && this.props.Document === Doc.UserDoc().myRecentlyClosed) { + ContextMenu.Instance.addItem({ description: "Clear All", event: () => Doc.UserDoc().myRecentlyClosed = new List<Doc>(), icon: "plus" }); e.stopPropagation(); e.preventDefault(); ContextMenu.Instance.displayMenu(e.pageX - 15, e.pageY - 15); } else { const layoutItems: ContextMenuProps[] = []; - layoutItems.push({ description: (this.props.Document.preventTreeViewOpen ? "Persist" : "Abandon") + "Treeview State", event: () => this.props.Document.preventTreeViewOpen = !this.props.Document.preventTreeViewOpen, icon: "paint-brush" }); - layoutItems.push({ description: (this.props.Document.hideHeaderFields ? "Show" : "Hide") + " Header Fields", event: () => this.props.Document.hideHeaderFields = !this.props.Document.hideHeaderFields, icon: "paint-brush" }); + layoutItems.push({ description: (this.props.Document.treeViewPreventOpen ? "Persist" : "Abandon") + "Treeview State", event: () => this.props.Document.treeViewPreventOpen = !this.props.Document.treeViewPreventOpen, icon: "paint-brush" }); + layoutItems.push({ description: (this.props.Document.treeViewHideHeaderFields ? "Show" : "Hide") + " Header Fields", event: () => this.props.Document.treeViewHideHeaderFields = !this.props.Document.treeViewHideHeaderFields, icon: "paint-brush" }); layoutItems.push({ description: (this.props.Document.treeViewHideTitle ? "Show" : "Hide") + " Title", event: () => this.props.Document.treeViewHideTitle = !this.props.Document.treeViewHideTitle, icon: "paint-brush" }); - ContextMenu.Instance.addItem({ description: "Treeview Options ...", subitems: layoutItems, icon: "eye" }); + ContextMenu.Instance.addItem({ description: "Options...", subitems: layoutItems, icon: "eye" }); } ContextMenu.Instance.addItem({ description: "Buxton Layout", icon: "eye", event: () => { DocListCast(this.dataDoc[this.props.fieldKey]).map(d => { DocListCast(d.data).map((img, i) => { - const caption = (d.captions as any)[i]?.data; - if (caption instanceof ObjectField) { - Doc.GetProto(img).caption = ObjectField.MakeCopy(caption); + const caption = (d.captions as any)[i]; + if (caption) { + Doc.GetProto(img).caption = caption; } - d.captions = undefined; }); }); - const { TextDocument, ImageDocument, CarouselDocument, TreeDocument } = Docs.Create; + const { ImageDocument } = Docs.Create; const { Document } = this.props; const fallbackImg = "http://www.cs.brown.edu/~bcz/face.gif"; - const detailedTemplate = `{ "doc": { "type": "doc", "content": [ { "type": "paragraph", "content": [ { "type": "dashField", "attrs": { "fieldKey": "year" } } ] }, { "type": "paragraph", "content": [ { "type": "dashField", "attrs": { "fieldKey": "company" } } ] } ] }, "selection":{"type":"text","anchor":1,"head":1},"storedMarks":[] }`; - - const textDoc = TextDocument("", { title: "details", _autoHeight: true }); - const detailView = Docs.Create.StackingDocument([ - CarouselDocument([], { title: "data", _height: 350, _itemIndex: 0, backgroundColor: "#9b9b9b3F" }), - textDoc, - TextDocument("", { title: "short_description", _autoHeight: true }), - TreeDocument([], { title: "narratives", _height: 75, treeViewHideTitle: true }) - ], { _chromeStatus: "disabled", _width: 300, _height: 300, _autoHeight: true, title: "detailView" }); - textDoc.data = new RichTextField(detailedTemplate, "year company"); - detailView.isTemplateDoc = makeTemplate(detailView); - - + const detailView = Cast(Cast(Doc.UserDoc()["template-button-detail"], Doc, null)?.dragFactory, Doc, null); const heroView = ImageDocument(fallbackImg, { title: "heroView", isTemplateDoc: true, isTemplateForField: "hero", }); // this acts like a template doc and a template field ... a little weird, but seems to work? heroView.proto!.layout = ImageBox.LayoutString("hero"); - heroView.showTitle = "title"; - heroView.showTitleHover = "titlehover"; + heroView._showTitle = "title"; + heroView._showTitleHover = "titlehover"; - Doc.AddDocToList(CurrentUserUtils.UserDocument.expandingButtons as Doc, "data", + Doc.AddDocToList(Doc.UserDoc().dockedBtns as Doc, "data", Docs.Create.FontIconDocument({ - _nativeWidth: 100, _nativeHeight: 100, _width: 100, _height: 100, dropAction: "alias", onDragStart: ScriptField.MakeFunction('getCopy(this.dragFactory, true)'), - dragFactory: heroView, removeDropProperties: new List<string>(["dropAction"]), title: "hero view", icon: "portrait" + title: "hero view", _nativeWidth: 100, _nativeHeight: 100, _width: 100, _height: 100, dropAction: "alias", + dragFactory: heroView, removeDropProperties: new List<string>(["dropAction"]), icon: "portrait", + onDragStart: ScriptField.MakeFunction('getCopy(this.dragFactory, true)'), })); - Doc.AddDocToList(CurrentUserUtils.UserDocument.expandingButtons as Doc, "data", + Doc.AddDocToList(Doc.UserDoc().dockedBtns as Doc, "data", Docs.Create.FontIconDocument({ - _nativeWidth: 100, _nativeHeight: 100, _width: 100, _height: 100, dropAction: "alias", onDragStart: ScriptField.MakeFunction('getCopy(this.dragFactory, true)'), - dragFactory: detailView, removeDropProperties: new List<string>(["dropAction"]), title: "detail view", icon: "file-alt" + title: "detail view", _nativeWidth: 100, _nativeHeight: 100, _width: 100, _height: 100, dropAction: "alias", + dragFactory: detailView, removeDropProperties: new List<string>(["dropAction"]), icon: "file-alt", + onDragStart: ScriptField.MakeFunction('getCopy(this.dragFactory, true)'), })); - Document.childLayout = heroView; - Document.childDetailed = detailView; + Document.childDetailView = detailView; Document._viewType = CollectionViewType.Time; Document._forceActive = true; Document._pivotField = "company"; @@ -685,13 +763,12 @@ export class CollectionTreeView extends CollectionSubView(Document) { const existingOnClick = ContextMenu.Instance.findByDescription("OnClick..."); const onClicks: ContextMenuProps[] = existingOnClick && "subitems" in existingOnClick ? existingOnClick.subitems : []; onClicks.push({ - description: "Edit onChecked Script", icon: "edit", event: (obj: any) => ScriptBox.EditButtonScript("On Checked Changed ...", this.props.Document, - "onCheckedClick", obj.x, obj.y, { heading: "boolean", checked: "boolean", treeViewContainer: Doc.name }) + description: "Edit onChecked Script", event: () => UndoManager.RunInBatch(() => Doc.makeCustomViewClicked(this.props.Document, undefined, "onCheckedClick"), "edit onCheckedClick"), icon: "edit" }); !existingOnClick && ContextMenu.Instance.addItem({ description: "OnClick...", subitems: onClicks, icon: "hand-point-right" }); } outerXf = () => Utils.GetScreenTransform(this._mainEle!); - onTreeDrop = (e: React.DragEvent) => this.onDrop(e, {}); + onTreeDrop = (e: React.DragEvent) => this.onExternalDrop(e, {}); @computed get renderClearButton() { return <div id="toolbar" key="toolbar"> @@ -703,18 +780,26 @@ export class CollectionTreeView extends CollectionSubView(Document) { } render() { - const dropAction = StrCast(this.props.Document.dropAction) as dropActionType; - const addDoc = (doc: Doc, relativeTo?: Doc, before?: boolean) => Doc.AddDocToList(this.props.Document, this.props.fieldKey, doc, relativeTo, before, false, false, false); + if (!(this.props.Document instanceof Doc)) return (null); + const dropAction = StrCast(this.props.Document.childDropAction) as dropActionType; + const addDoc = (doc: Doc, relativeTo?: Doc, before?: boolean) => this.addDoc(doc, relativeTo, before); const moveDoc = (d: Doc, target: Doc | undefined, addDoc: (doc: Doc) => boolean) => this.props.moveDocument(d, target, addDoc); - return !this.childDocs ? (null) : ( + const childDocs = this.props.overrideDocuments ? this.props.overrideDocuments : this.childDocs; + return !childDocs ? (null) : ( <div className="collectionTreeView-dropTarget" id="body" - style={{ background: StrCast(this.props.Document.backgroundColor, "lightgray"), paddingTop: `${NumCast(this.props.Document._yMargin, 20)}px` }} + style={{ + background: this.props.backgroundColor?.(this.props.Document), + paddingLeft: `${NumCast(this.props.Document._xPadding, 10)}px`, + paddingRight: `${NumCast(this.props.Document._xPadding, 10)}px`, + paddingTop: `${NumCast(this.props.Document._yPadding, 20)}px` + }} onContextMenu={this.onContextMenu} onWheel={(e: React.WheelEvent) => this._mainEle && this._mainEle.scrollHeight > this._mainEle.clientHeight && e.stopPropagation()} onDrop={this.onTreeDrop} ref={this.createTreeDropTarget}> - {(this.props.Document.treeViewHideTitle ? (null) : <EditableView + {(this.props.treeViewHideTitle || this.props.Document.treeViewHideTitle ? (null) : <EditableView contents={this.dataDoc.title} + editing={false} display={"block"} maxHeight={72} height={"auto"} @@ -722,18 +807,19 @@ export class CollectionTreeView extends CollectionSubView(Document) { SetValue={undoBatch((value: string) => Doc.SetInPlace(this.dataDoc, "title", value, false) || true)} OnFillDown={undoBatch((value: string) => { Doc.SetInPlace(this.dataDoc, "title", value, false); - const layoutDoc = this.props.Document.layout_custom instanceof Doc ? Doc.ApplyTemplate(Doc.GetProto(this.props.Document.layout_custom)) : undefined; - const doc = layoutDoc || Docs.Create.FreeformDocument([], { title: "", x: 0, y: 0, _width: 100, _height: 25, templates: new List<string>([Templates.Title.Layout]) }); - TreeView.loadId = doc[Id]; - Doc.AddDocToList(this.props.Document, this.props.fieldKey, doc, this.childDocs.length ? this.childDocs[0] : undefined, true, false, false, false); + const doc = Docs.Create.FreeformDocument([], { title: "", x: 0, y: 0, _width: 100, _height: 25, templates: new List<string>([Templates.Title.Layout]) }); + EditableView.loadId = doc[Id]; + Doc.SetInPlace(doc, "editTitle", true, false); + this.addDoc(doc, childDocs.length ? childDocs[0] : undefined, true); })} />)} {this.props.Document.allowClear ? this.renderClearButton : (null)} <ul className="no-indent" style={{ width: "max-content" }} > { - TreeView.GetChildElements(this.childDocs, this.props.Document, this.props.Document, this.props.DataDoc, this.props.fieldKey, this.props.ContainingCollectionDoc, undefined, addDoc, this.remove, - moveDoc, dropAction, this.props.addDocTab, this.props.pinToPres, this.props.ScreenToLocalTransform, - this.outerXf, this.props.active, this.props.PanelWidth, this.props.ChromeHeight, this.props.renderDepth, () => BoolCast(this.props.Document.hideHeaderFields), - BoolCast(this.props.Document.preventTreeViewOpen), [], this.props.LibraryPath, ScriptCast(this.props.Document.onCheckedClick)) + TreeView.GetChildElements(childDocs, this.props.Document, this.props.Document, this.props.DataDoc, this.props.fieldKey, this.props.ContainingCollectionDoc, undefined, addDoc, this.remove, + moveDoc, dropAction, this.props.addDocTab, this.props.pinToPres, this.props.backgroundColor, this.props.ScreenToLocalTransform, + this.outerXf, this.props.active, this.props.PanelWidth, this.props.ChromeHeight, this.props.renderDepth, () => this.props.treeViewHideHeaderFields || BoolCast(this.props.Document.treeViewHideHeaderFields), + BoolCast(this.props.Document.treeViewPreventOpen), [], this.props.LibraryPath, this.props.onCheckedClick, + this.props.onChildClick || ScriptCast(this.props.Document.onChildClick), this.props.ignoreFields) } </ul> </div > @@ -753,18 +839,17 @@ Scripting.addGlobal(function readFacetData(layoutDoc: Doc, dataDoc: Doc, dataKey nonNumbers++; } }); - const facetValueDocSet = (nonNumbers / facetValues.length > .1 ? facetValues.sort() : facetValues.sort((n1: string, n2: string) => Number(n1) - Number(n2))).map(facetValue => - Docs.Create.TextDocument("", { - title: facetValue.toString(), - treeViewChecked: ComputedField.MakeFunction("determineCheckedState(layoutDoc, facetHeader, facetValue)", - { layoutDoc: Doc.name, facetHeader: "string", facetValue: "string" }, - { layoutDoc, facetHeader, facetValue }) - })); + const facetValueDocSet = (nonNumbers / facetValues.length > .1 ? facetValues.sort() : facetValues.sort((n1: string, n2: string) => Number(n1) - Number(n2))).map(facetValue => { + const doc = new Doc(); + doc.title = facetValue.toString(); + doc.treeViewChecked = ComputedField.MakeFunction("determineCheckedState(layoutDoc, facetHeader, facetValue)", {}, { layoutDoc, facetHeader, facetValue }); + return doc; + }); return new List<Doc>(facetValueDocSet); }); Scripting.addGlobal(function determineCheckedState(layoutDoc: Doc, facetHeader: string, facetValue: string) { - const docFilters = Cast(layoutDoc._docFilter, listSpec("string"), []); + const docFilters = Cast(layoutDoc._docFilters, listSpec("string"), []); for (let i = 0; i < docFilters.length; i += 3) { const [header, value, state] = docFilters.slice(i, i + 3); if (header === facetHeader && value === facetValue) { diff --git a/src/client/views/collections/CollectionView.scss b/src/client/views/collections/CollectionView.scss index 1c46081a1..d43dd387a 100644 --- a/src/client/views/collections/CollectionView.scss +++ b/src/client/views/collections/CollectionView.scss @@ -10,6 +10,58 @@ width: 100%; height: 100%; overflow: hidden; // bcz: used to be 'auto' which would create scrollbars when there's a floating doc that's not visible. not sure if that's better, but the scrollbars are annoying... + + .collectionTimeView-dragger { + background-color: lightgray; + height: 40px; + width: 20px; + position: absolute; + border-radius: 10px; + top: 55%; + border: 1px black solid; + z-index: 2; + right: -10px; + } + .collectionTimeView-treeView { + display: flex; + flex-direction: column; + width: 200px; + height: 100%; + position: absolute; + right: 0; + top: 0; + + .collectionTimeView-addfacet { + display: inline-block; + width: 200px; + height: 30px; + background: darkGray; + text-align: left; + + .collectionTimeView-button { + align-items: center; + display: flex; + width: 100%; + height: 100%; + + .collectionTimeView-span { + margin: auto; + } + } + + >div, + >div>div { + width: 100%; + height: 100%; + } + } + + .collectionTimeView-tree { + display: inline-block; + width: 100%; + height: calc(100% - 30px); + } + } } #google-tags { diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx index bdd908807..801704673 100644 --- a/src/client/views/collections/CollectionView.tsx +++ b/src/client/views/collections/CollectionView.tsx @@ -1,20 +1,19 @@ import { library } from '@fortawesome/fontawesome-svg-core'; -import { faEye } from '@fortawesome/free-regular-svg-icons'; -import { faColumns, faCopy, faEllipsisV, faFingerprint, faImage, faProjectDiagram, faSignature, faSquare, faTh, faThList, faTree } from '@fortawesome/free-solid-svg-icons'; -import { action, IReactionDisposer, observable, reaction, runInAction, computed } from 'mobx'; +import { faEye, faEdit } from '@fortawesome/free-regular-svg-icons'; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faColumns, faCopy, faEllipsisV, faFingerprint, faImage, faProjectDiagram, faSignature, faSquare, faTh, faThList, faTree, faGlobeAmericas } from '@fortawesome/free-solid-svg-icons'; +import { action, observable, computed } from 'mobx'; import { observer } from "mobx-react"; import * as React from 'react'; import Lightbox from 'react-image-lightbox-with-rotate'; import 'react-image-lightbox-with-rotate/style.css'; // This only needs to be imported once in your app import { DateField } from '../../../new_fields/DateField'; -import { Doc, DocListCast } from '../../../new_fields/Doc'; -import { Id } from '../../../new_fields/FieldSymbols'; -import { listSpec } from '../../../new_fields/Schema'; -import { BoolCast, Cast, StrCast, NumCast } from '../../../new_fields/Types'; +import { DataSym, Doc, DocListCast, Field, Opt } from '../../../new_fields/Doc'; +import { List } from '../../../new_fields/List'; +import { BoolCast, Cast, NumCast, StrCast, ScriptCast } from '../../../new_fields/Types'; import { ImageField } from '../../../new_fields/URLField'; import { TraceMobx } from '../../../new_fields/util'; -import { CurrentUserUtils } from '../../../server/authentication/models/current_user_utils'; -import { Utils } from '../../../Utils'; +import { Utils, setupMoveUpEvents, returnFalse, returnZero, emptyPath, emptyFunction, returnOne } from '../../../Utils'; import { DocumentType } from '../../documents/DocumentTypes'; import { DocumentManager } from '../../util/DocumentManager'; import { ImageUtils } from '../../util/Import & Export/ImageUtils'; @@ -23,58 +22,54 @@ import { ContextMenu } from "../ContextMenu"; import { FieldView, FieldViewProps } from '../nodes/FieldView'; import { ScriptBox } from '../ScriptBox'; import { Touchable } from '../Touchable'; +import { CollectionCarouselView } from './CollectionCarouselView'; import { CollectionDockingView } from "./CollectionDockingView"; import { AddCustomFreeFormLayout } from './collectionFreeForm/CollectionFreeFormLayoutEngines'; import { CollectionFreeFormView } from './collectionFreeForm/CollectionFreeFormView'; -import { CollectionCarouselView } from './CollectionCarouselView'; import { CollectionLinearView } from './CollectionLinearView'; import { CollectionMulticolumnView } from './collectionMulticolumn/CollectionMulticolumnView'; +import { CollectionMultirowView } from './collectionMulticolumn/CollectionMultirowView'; import { CollectionSchemaView } from "./CollectionSchemaView"; import { CollectionStackingView } from './CollectionStackingView'; import { CollectionStaffView } from './CollectionStaffView'; +import { SubCollectionViewProps } from './CollectionSubView'; +import { CollectionTimeView } from './CollectionTimeView'; import { CollectionTreeView } from "./CollectionTreeView"; import './CollectionView.scss'; import { CollectionViewBaseChrome } from './CollectionViewChromes'; -import { CollectionTimeView } from './CollectionTimeView'; -import { CollectionMultirowView } from './collectionMulticolumn/CollectionMultirowView'; +import { CurrentUserUtils } from '../../../server/authentication/models/current_user_utils'; +import { Id } from '../../../new_fields/FieldSymbols'; +import { listSpec } from '../../../new_fields/Schema'; +import { Docs } from '../../documents/Documents'; +import { ScriptField, ComputedField } from '../../../new_fields/ScriptField'; +import { InteractionUtils } from '../../util/InteractionUtils'; +import { ObjectField } from '../../../new_fields/ObjectField'; +import CollectionMapView from './CollectionMapView'; +import { Transform } from 'prosemirror-transform'; +import { CollectionPileView } from './CollectionPileView'; +const higflyout = require("@hig/flyout"); +export const { anchorPoints } = higflyout; +export const Flyout = higflyout.default; 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); +library.add(faTh, faTree, faSquare, faProjectDiagram, faSignature, faThList, faFingerprint, faColumns, faGlobeAmericas, faEllipsisV, faImage, faEye as any, faCopy); export enum CollectionViewType { - Invalid, - Freeform, - Schema, - Docking, - Tree, - Stacking, - Masonry, - Multicolumn, - Multirow, - Time, - Carousel, - Linear, - Staff, - Timeline -} - -export namespace CollectionViewType { - const stringMapping = new Map<string, CollectionViewType>([ - ["invalid", CollectionViewType.Invalid], - ["freeform", CollectionViewType.Freeform], - ["schema", CollectionViewType.Schema], - ["docking", CollectionViewType.Docking], - ["tree", CollectionViewType.Tree], - ["stacking", CollectionViewType.Stacking], - ["masonry", CollectionViewType.Masonry], - ["multicolumn", CollectionViewType.Multicolumn], - ["multirow", CollectionViewType.Multirow], - ["time", CollectionViewType.Time], - ["carousel", CollectionViewType.Carousel], - ["linear", CollectionViewType.Linear], - ]); - - export const valueOf = (value: string) => stringMapping.get(value.toLowerCase()); + Invalid = "invalid", + Freeform = "freeform", + Schema = "schema", + Docking = "docking", + Tree = 'tree', + Stacking = "stacking", + Masonry = "masonry", + Multicolumn = "multicolumn", + Multirow = "multirow", + Time = "time", + Carousel = "carousel", + Linear = "linear", + Staff = "staff", + Map = "map", + Pile = "pileup" } export interface CollectionRenderProps { @@ -83,22 +78,24 @@ export interface CollectionRenderProps { moveDocument: (document: Doc, targetCollection: Doc | undefined, addDocument: (document: Doc) => boolean) => boolean; active: () => boolean; whenActiveChanged: (isActive: boolean) => void; + PanelWidth: () => number; } @observer export class CollectionView extends Touchable<FieldViewProps> { public static LayoutString(fieldStr: string) { return FieldView.LayoutString(CollectionView, fieldStr); } - private _reactionDisposer: IReactionDisposer | undefined; private _isChildActive = false; //TODO should this be observable? - @observable private _isLightboxOpen = false; + get _isLightboxOpen() { return BoolCast(this.props.Document.isLightboxOpen); } + set _isLightboxOpen(value) { this.props.Document.isLightboxOpen = value; } @observable private _curLightboxImg = 0; - @observable private _collapsed = true; @observable private static _safeMode = false; public static SetSafeMode(safeMode: boolean) { this._safeMode = safeMode; } + protected multiTouchDisposer?: InteractionUtils.MultiTouchEventDisposer; + get collectionViewType(): CollectionViewType | undefined { - const viewField = NumCast(this.props.Document._viewType); + const viewField = StrCast(this.props.Document._viewType); if (CollectionView._safeMode) { if (viewField === CollectionViewType.Freeform) { return CollectionViewType.Tree; @@ -107,34 +104,20 @@ export class CollectionView extends Touchable<FieldViewProps> { return CollectionViewType.Freeform; } } - return viewField; - } - - componentDidMount = () => { - this._reactionDisposer = reaction(() => StrCast(this.props.Document._chromeStatus), - () => { - // chrome status is one of disabled, collapsed, or visible. this determines initial state from document - // chrome status may also be view-mode, in reference to stacking view's toggle mode. it is essentially disabled mode, but prevents the toggle button from showing up on the left sidebar. - const chromeStatus = this.props.Document._chromeStatus; - if (chromeStatus && (chromeStatus === "disabled" || chromeStatus === "collapsed")) { - runInAction(() => this._collapsed = true); - } - }); + return viewField as any as CollectionViewType; } - componentWillUnmount = () => this._reactionDisposer && this._reactionDisposer(); - - // bcz: Argh? What's the height of the collection chromes?? - chromeHeight = () => (this.props.Document._chromeStatus === "enabled" ? -60 : 0); + active = (outsideReaction?: boolean) => (this.props.isSelected(outsideReaction) || this.props.rootSelected(outsideReaction) || this.props.Document.forceActive || this._isChildActive || this.props.renderDepth === 0) ? true : false; - active = (outsideReaction?: boolean) => this.props.isSelected(outsideReaction) || BoolCast(this.props.Document.forceActive) || this._isChildActive || this.props.renderDepth === 0; - - whenActiveChanged = (isActive: boolean) => { this.props.whenActiveChanged(this._isChildActive = isActive); }; + whenActiveChanged = (isActive: boolean) => this.props.whenActiveChanged(this._isChildActive = isActive); @action.bound addDocument(doc: Doc): boolean { - const targetDataDoc = Doc.GetProto(this.props.DataDoc || this.props.Document); - Doc.AddDocToList(targetDataDoc, this.props.fieldKey, doc); + const targetDataDoc = this.props.Document[DataSym]; + const docList = DocListCast(targetDataDoc[this.props.fieldKey]); + !docList.includes(doc) && (targetDataDoc[this.props.fieldKey] = new List<Doc>([...docList, doc])); // DocAddToList may write to targetdataDoc's parent ... we don't want this. should really change GetProto to GetDataDoc and test for resolvedDataDoc there + // Doc.AddDocToList(targetDataDoc, this.props.fieldKey, doc); + doc.context = this.props.Document; targetDataDoc[this.props.fieldKey + "-lastModified"] = new DateField(new Date(Date.now())); Doc.GetProto(doc).lastOpened = new DateField; return true; @@ -142,15 +125,18 @@ export class CollectionView extends Touchable<FieldViewProps> { @action.bound removeDocument(doc: Doc): boolean { + const targetDataDoc = this.props.Document[DataSym]; const docView = DocumentManager.Instance.getDocumentView(doc, this.props.ContainingCollectionView); docView && SelectionManager.DeselectDoc(docView); - const value = Cast((this.props.DataDoc || this.props.Document)[this.props.fieldKey], listSpec(Doc), []); + const value = DocListCast(targetDataDoc[this.props.fieldKey]); let index = value.reduce((p, v, i) => (v instanceof Doc && v === doc) ? i : p, -1); index = index !== -1 ? index : value.reduce((p, v, i) => (v instanceof Doc && Doc.AreProtosEqual(v, doc)) ? i : p, -1); - ContextMenu.Instance.clearItems(); + doc.context = undefined; + ContextMenu.Instance?.clearItems(); if (index !== -1) { value.splice(index, 1); + targetDataDoc[this.props.fieldKey] = new List<Doc>(value); return true; } return false; @@ -176,19 +162,21 @@ export class CollectionView extends Touchable<FieldViewProps> { } private SubViewHelper = (type: CollectionViewType, renderProps: CollectionRenderProps) => { - const props = { ...this.props, ...renderProps, chromeCollapsed: this._collapsed, ChromeHeight: this.chromeHeight, CollectionView: this, annotationsKey: "" }; + const props: SubCollectionViewProps = { ...this.props, ...renderProps, CollectionView: this, annotationsKey: "" }; switch (type) { case CollectionViewType.Schema: return (<CollectionSchemaView key="collview" {...props} />); case CollectionViewType.Docking: return (<CollectionDockingView key="collview" {...props} />); 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.Staff: return (<CollectionStaffView key="collview" {...props} />); + case CollectionViewType.Multicolumn: return (<CollectionMulticolumnView key="collview" {...props} />); + case CollectionViewType.Multirow: return (<CollectionMultirowView key="rpwview" {...props} />); case CollectionViewType.Linear: { return (<CollectionLinearView key="collview" {...props} />); } + case CollectionViewType.Pile: { return (<CollectionPileView key="collview" {...props} />); } case CollectionViewType.Carousel: { return (<CollectionCarouselView key="collview" {...props} />); } case CollectionViewType.Stacking: { this.props.Document.singleColumn = true; return (<CollectionStackingView key="collview" {...props} />); } case CollectionViewType.Masonry: { this.props.Document.singleColumn = false; return (<CollectionStackingView key="collview" {...props} />); } case CollectionViewType.Time: { return (<CollectionTimeView key="collview" {...props} />); } + case CollectionViewType.Map: return (<CollectionMapView key="collview" {...props} />); case CollectionViewType.Freeform: default: { this.props.Document._freeformLayoutEngine = undefined; return (<CollectionFreeFormView key="collview" {...props} />); } } @@ -196,70 +184,84 @@ export class CollectionView extends Touchable<FieldViewProps> { @action private collapse = (value: boolean) => { - this._collapsed = value; this.props.Document._chromeStatus = value ? "collapsed" : "enabled"; } private SubView = (type: CollectionViewType, renderProps: CollectionRenderProps) => { // currently cant think of a reason for collection docking view to have a chrome. mind may change if we ever have nested docking views -syip - const chrome = this.props.Document._chromeStatus === "disabled" || type === CollectionViewType.Docking ? (null) : - <CollectionViewBaseChrome CollectionView={this} key="chrome" type={type} collapse={this.collapse} />; + const chrome = this.props.Document._chromeStatus === "disabled" || this.props.Document._chromeStatus === "replaced" || type === CollectionViewType.Docking ? (null) : + <CollectionViewBaseChrome CollectionView={this} key="chrome" PanelWidth={this.bodyPanelWidth} type={type} collapse={this.collapse} />; return [chrome, this.SubViewHelper(type, renderProps)]; } + setupViewTypes(category: string, func: (viewType: CollectionViewType) => Doc, addExtras: boolean) { + const existingVm = ContextMenu.Instance.findByDescription(category); + const subItems = existingVm && "subitems" in existingVm ? existingVm.subitems : []; + + subItems.push({ description: "Freeform", event: () => func(CollectionViewType.Freeform), icon: "signature" }); + if (addExtras && CollectionView._safeMode) { + ContextMenu.Instance.addItem({ description: "Test Freeform", event: () => func(CollectionViewType.Invalid), icon: "project-diagram" }); + } + subItems.push({ description: "Schema", event: () => func(CollectionViewType.Schema), icon: "th-list" }); + subItems.push({ description: "Tree", event: () => func(CollectionViewType.Tree), icon: "tree" }); + subItems.push({ description: "Stacking", event: () => func(CollectionViewType.Stacking), icon: "ellipsis-v" }); + subItems.push({ description: "Stacking (AutoHeight)", event: () => func(CollectionViewType.Stacking)._autoHeight = true, icon: "ellipsis-v" }); + subItems.push({ description: "Staff", event: () => func(CollectionViewType.Staff), icon: "music" }); + subItems.push({ description: "Multicolumn", event: () => func(CollectionViewType.Multicolumn), icon: "columns" }); + subItems.push({ description: "Multirow", event: () => func(CollectionViewType.Multirow), icon: "columns" }); + subItems.push({ description: "Masonry", event: () => func(CollectionViewType.Masonry), icon: "columns" }); + subItems.push({ description: "Carousel", event: () => func(CollectionViewType.Carousel), icon: "columns" }); + subItems.push({ description: "Pivot/Time", event: () => func(CollectionViewType.Time), icon: "columns" }); + subItems.push({ description: "Map", event: () => func(CollectionViewType.Map), icon: "globe-americas" }); + if (addExtras && this.props.Document._viewType === CollectionViewType.Freeform) { + subItems.push({ description: "Custom", icon: "fingerprint", event: AddCustomFreeFormLayout(this.props.Document, this.props.fieldKey) }); + } + addExtras && subItems.push({ description: "lightbox", event: action(() => this._isLightboxOpen = true), icon: "eye" }); + !existingVm && ContextMenu.Instance.addItem({ description: category, subitems: subItems, icon: "eye" }); + } + onContextMenu = (e: React.MouseEvent): void => { if (!e.isPropagationStopped() && this.props.Document[Id] !== CurrentUserUtils.MainDocId) { // need to test this because GoldenLayout causes a parallel hierarchy in the React DOM for its children and the main document view7 - const existingVm = ContextMenu.Instance.findByDescription("View Modes..."); - const subItems = existingVm && "subitems" in existingVm ? existingVm.subitems : []; - subItems.push({ description: "Freeform", event: () => { this.props.Document._viewType = CollectionViewType.Freeform; }, icon: "signature" }); - if (CollectionView._safeMode) { - ContextMenu.Instance.addItem({ description: "Test Freeform", event: () => this.props.Document._viewType = CollectionViewType.Invalid, icon: "project-diagram" }); - } - subItems.push({ description: "Schema", event: () => this.props.Document._viewType = CollectionViewType.Schema, icon: "th-list" }); - subItems.push({ description: "Treeview", event: () => this.props.Document._viewType = CollectionViewType.Tree, icon: "tree" }); - subItems.push({ description: "Stacking", event: () => this.props.Document._viewType = CollectionViewType.Stacking, icon: "ellipsis-v" }); - subItems.push({ - description: "Stacking (AutoHeight)", event: () => { - this.props.Document._viewType = CollectionViewType.Stacking; - this.props.Document._autoHeight = true; - }, icon: "ellipsis-v" - }); - 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/Time", event: () => this.props.Document._viewType = CollectionViewType.Time, icon: "columns" }); - switch (this.props.Document._viewType) { - case CollectionViewType.Freeform: { - subItems.push({ description: "Custom", icon: "fingerprint", event: AddCustomFreeFormLayout(this.props.Document, this.props.fieldKey) }); - break; - } - } - subItems.push({ description: "lightbox", event: action(() => this._isLightboxOpen = true), icon: "eye" }); - !existingVm && ContextMenu.Instance.addItem({ description: "View Modes...", subitems: subItems, icon: "eye" }); - const existing = ContextMenu.Instance.findByDescription("Layout..."); + this.setupViewTypes("Change Perspective...", (vtype => { this.props.Document._viewType = vtype; return this.props.Document; }), true); + this.setupViewTypes("Add a Perspective...", vtype => { + const newRendition = Doc.MakeAlias(this.props.Document); + newRendition._viewType = vtype; + this.props.addDocTab(newRendition, "onRight"); + return newRendition; + }, false); + + const existing = ContextMenu.Instance.findByDescription("Options..."); const layoutItems = existing && "subitems" in existing ? existing.subitems : []; layoutItems.push({ description: `${this.props.Document.forceActive ? "Select" : "Force"} Contents Active`, event: () => this.props.Document.forceActive = !this.props.Document.forceActive, icon: "project-diagram" }); if (this.props.Document.childLayout instanceof Doc) { - layoutItems.push({ description: "View Child Layout", event: () => this.props.addDocTab(this.props.Document.childLayout as Doc, undefined, "onRight"), icon: "project-diagram" }); + layoutItems.push({ description: "View Child Layout", event: () => this.props.addDocTab(this.props.Document.childLayout as Doc, "onRight"), icon: "project-diagram" }); } - if (this.props.Document.childDetailed instanceof Doc) { - layoutItems.push({ description: "View Child Detailed Layout", event: () => this.props.addDocTab(this.props.Document.childDetailed as Doc, undefined, "onRight"), icon: "project-diagram" }); + if (this.props.Document.childDetailView instanceof Doc) { + layoutItems.push({ description: "View Child Detailed Layout", event: () => this.props.addDocTab(this.props.Document.childDetailView as Doc, "onRight"), icon: "project-diagram" }); } - !existing && ContextMenu.Instance.addItem({ description: "Layout...", subitems: layoutItems, icon: "hand-point-right" }); + layoutItems.push({ description: `${this.props.Document.isInPlaceContainer ? "Unset" : "Set"} inPlace Container`, event: () => this.props.Document.isInPlaceContainer = !this.props.Document.isInPlaceContainer, icon: "project-diagram" }); - const more = ContextMenu.Instance.findByDescription("More..."); - const moreItems = more && "subitems" in more ? more.subitems : []; - moreItems.push({ description: "Export Image Hierarchy", icon: "columns", event: () => ImageUtils.ExportHierarchyToFileSystem(this.props.Document) }); - !more && ContextMenu.Instance.addItem({ description: "More...", subitems: moreItems, icon: "hand-point-right" }); + !existing && ContextMenu.Instance.addItem({ description: "Options...", subitems: layoutItems, icon: "hand-point-right" }); const existingOnClick = ContextMenu.Instance.findByDescription("OnClick..."); const onClicks = existingOnClick && "subitems" in existingOnClick ? existingOnClick.subitems : []; - onClicks.push({ description: "Edit onChildClick script", icon: "edit", event: (obj: any) => ScriptBox.EditButtonScript("On Child Clicked...", this.props.Document, "onChildClick", obj.x, obj.y) }); + const funcs = [{ key: "onChildClick", name: "On Child Clicked", script: undefined as any as ScriptField }]; + DocListCast(Cast(Doc.UserDoc().childClickFuncs, Doc, null).data).forEach(childClick => + funcs.push({ key: "onChildClick", name: StrCast(childClick.title), script: ScriptCast(childClick.script) })); + funcs.map(func => onClicks.push({ + description: `Edit ${func.name} script`, icon: "edit", event: (obj: any) => { + func.script && (this.props.Document[func.key] = ObjectField.MakeCopy(func.script)); + ScriptBox.EditButtonScript(func.name + "...", this.props.Document, func.key, obj.x, obj.y, { thisContainer: Doc.name }); + } + })); !existingOnClick && ContextMenu.Instance.addItem({ description: "OnClick...", subitems: onClicks, icon: "hand-point-right" }); + + const more = ContextMenu.Instance.findByDescription("More..."); + const moreItems = more && "subitems" in more ? more.subitems : []; + moreItems.push({ description: "Export Image Hierarchy", icon: "columns", event: () => ImageUtils.ExportHierarchyToFileSystem(this.props.Document) }); + !more && ContextMenu.Instance.addItem({ description: "More...", subitems: moreItems, icon: "hand-point-right" }); } } @@ -279,6 +281,188 @@ export class CollectionView extends Touchable<FieldViewProps> { onMovePrevRequest={action(() => this._curLightboxImg = (this._curLightboxImg + images.length - 1) % images.length)} onMoveNextRequest={action(() => this._curLightboxImg = (this._curLightboxImg + 1) % images.length)} />); } + get _facetWidth() { return NumCast(this.props.Document._facetWidth); } + set _facetWidth(value) { this.props.Document._facetWidth = value; } + + bodyPanelWidth = () => this.props.PanelWidth() - this.facetWidth(); + facetWidth = () => Math.max(0, Math.min(this.props.PanelWidth() - 25, this._facetWidth)); + + @computed get dataDoc() { + return (this.props.DataDoc && this.props.Document.isTemplateForField ? Doc.GetProto(this.props.DataDoc) : + this.props.Document.resolvedDataDoc ? this.props.Document : Doc.GetProto(this.props.Document)); // if the layout document has a resolvedDataDoc, then we don't want to get its parent which would be the unexpanded template + } + // The data field for rendering this collection will be on the this.props.Document unless we're rendering a template in which case we try to use props.DataDoc. + // When a document has a DataDoc but it's not a template, then it contains its own rendering data, but needs to pass the DataDoc through + // to its children which may be templates. + // If 'annotationField' is specified, then all children exist on that field of the extension document, otherwise, they exist directly on the data document under 'fieldKey' + @computed get dataField() { + return this.dataDoc[this.props.fieldKey]; + } + + get childLayoutPairs(): { layout: Doc; data: Doc; }[] { + const { Document, DataDoc } = this.props; + const validPairs = this.childDocs.map(doc => Doc.GetLayoutDataDocPair(Document, DataDoc, doc)).filter(pair => pair.layout); + return validPairs.map(({ data, layout }) => ({ data: data as Doc, layout: layout! })); // this mapping is a bit of a hack to coerce types + } + get childDocList() { + return Cast(this.dataField, listSpec(Doc)); + } + get childDocs() { + const dfield = this.dataField; + const rawdocs = (dfield instanceof Doc) ? [dfield] : Cast(dfield, listSpec(Doc), Cast(this.props.Document.rootDocument, Doc, null) ? [Cast(this.props.Document.rootDocument, Doc, null)] : []); + const docs = rawdocs.filter(d => d && !(d instanceof Promise)).map(d => d as Doc); + const viewSpecScript = ScriptCast(this.props.Document.viewSpecScript); + return viewSpecScript ? docs.filter(d => viewSpecScript.script.run({ doc: d }, console.log).result) : docs; + } + @computed get _allFacets() { + const facets = new Set<string>(); + this.childDocs.filter(child => child).forEach(child => Object.keys(Doc.GetProto(child)).forEach(key => facets.add(key))); + Doc.AreProtosEqual(this.dataDoc, this.props.Document) && this.childDocs.filter(child => child).forEach(child => Object.keys(child).forEach(key => facets.add(key))); + return Array.from(facets); + } + + /** + * Responds to clicking the check box in the flyout menu + */ + facetClick = (facetHeader: string) => { + const facetCollection = this.props.Document; + const found = DocListCast(facetCollection[this.props.fieldKey + "-filter"]).findIndex(doc => doc.title === facetHeader); + if (found !== -1) { + (facetCollection[this.props.fieldKey + "-filter"] as List<Doc>).splice(found, 1); + const docFilter = Cast(this.props.Document._docFilters, listSpec("string")); + if (docFilter) { + let index: number; + while ((index = docFilter.findIndex(item => item === facetHeader)) !== -1) { + docFilter.splice(index, 3); + } + } + const docRangeFilters = Cast(this.props.Document._docRangeFilters, listSpec("string")); + if (docRangeFilters) { + let index: number; + while ((index = docRangeFilters.findIndex(item => item === facetHeader)) !== -1) { + docRangeFilters.splice(index, 3); + } + } + } else { + const allCollectionDocs = DocListCast(this.dataDoc[this.props.fieldKey]); + const facetValues = Array.from(allCollectionDocs.reduce((set, child) => + set.add(Field.toString(child[facetHeader] as Field)), new Set<string>())); + + let nonNumbers = 0; + let minVal = Number.MAX_VALUE, maxVal = -Number.MAX_VALUE; + facetValues.map(val => { + const num = Number(val); + if (Number.isNaN(num)) { + nonNumbers++; + } else { + minVal = Math.min(num, minVal); + maxVal = Math.max(num, maxVal); + } + }); + let newFacet: Opt<Doc>; + if (nonNumbers / allCollectionDocs.length < .1) { + newFacet = Docs.Create.SliderDocument({ title: facetHeader }); + const newFacetField = Doc.LayoutFieldKey(newFacet); + const ranged = Doc.readDocRangeFilter(this.props.Document, facetHeader); + Doc.GetProto(newFacet).type = DocumentType.COL; // forces item to show an open/close button instead ofa checkbox + newFacet.treeViewExpandedView = "layout"; + newFacet.treeViewOpen = true; + const extendedMinVal = minVal - Math.min(1, Math.abs(maxVal - minVal) * .05); + const extendedMaxVal = maxVal + Math.min(1, Math.abs(maxVal - minVal) * .05); + newFacet[newFacetField + "-min"] = ranged === undefined ? extendedMinVal : ranged[0]; + newFacet[newFacetField + "-max"] = ranged === undefined ? extendedMaxVal : ranged[1]; + Doc.GetProto(newFacet)[newFacetField + "-minThumb"] = extendedMinVal; + Doc.GetProto(newFacet)[newFacetField + "-maxThumb"] = extendedMaxVal; + newFacet.target = this.props.Document; + const scriptText = `setDocFilterRange(this.target, "${facetHeader}", range)`; + newFacet.onThumbChanged = ScriptField.MakeScript(scriptText, { this: Doc.name, range: "number" }); + + Doc.AddDocToList(facetCollection, this.props.fieldKey + "-filter", newFacet); + } else { + newFacet = new Doc(); + newFacet.title = facetHeader; + newFacet.treeViewOpen = true; + newFacet.type = DocumentType.COL; + const capturedVariables = { layoutDoc: this.props.Document, dataDoc: this.dataDoc }; + newFacet.data = ComputedField.MakeFunction(`readFacetData(layoutDoc, dataDoc, "${this.props.fieldKey}", "${facetHeader}")`, {}, capturedVariables); + } + newFacet && Doc.AddDocToList(facetCollection, this.props.fieldKey + "-filter", newFacet); + } + } + + onPointerDown = (e: React.PointerEvent) => { + setupMoveUpEvents(this, e, action((e: PointerEvent, down: number[], delta: number[]) => { + this._facetWidth = this.props.PanelWidth() - Math.max(this.props.ScreenToLocalTransform().transformPoint(e.clientX, 0)[0], 0); + return false; + }), returnFalse, action(() => this._facetWidth = this.facetWidth() < 15 ? Math.min(this.props.PanelWidth() - 25, 200) : 0)); + } + filterBackground = () => "rgba(105, 105, 105, 0.432)"; + get ignoreFields() { return ["_docFilters", "_docRangeFilters"]; } // this makes the tree view collection ignore these filters (otherwise, the filters would filter themselves) + @computed get scriptField() { + const scriptText = "setDocFilter(containingTreeView, heading, this.title, checked)"; + return ScriptField.MakeScript(scriptText, { this: Doc.name, heading: "string", checked: "string", containingTreeView: Doc.name }); + } + @computed get filterView() { + const facetCollection = this.props.Document; + const flyout = ( + <div className="collectionTimeView-flyout" style={{ width: `${this.facetWidth()}`, height: this.props.PanelHeight() - 30 }} onWheel={e => e.stopPropagation()}> + {this._allFacets.map(facet => <label className="collectionTimeView-flyout-item" key={`${facet}`} onClick={e => this.facetClick(facet)}> + <input type="checkbox" onChange={e => { }} checked={DocListCast(this.props.Document[this.props.fieldKey + "-filter"]).some(d => d.title === facet)} /> + <span className="checkmark" /> + {facet} + </label>)} + </div> + ); + return !this._facetWidth || this.props.dontRegisterView ? (null) : + <div className="collectionTimeView-treeView" style={{ width: `${this.facetWidth()}px`, overflow: this.facetWidth() < 15 ? "hidden" : undefined }}> + <div className="collectionTimeView-addFacet" style={{ width: `${this.facetWidth()}px` }} onPointerDown={e => e.stopPropagation()}> + <Flyout anchorPoint={anchorPoints.LEFT_TOP} content={flyout}> + <div className="collectionTimeView-button"> + <FontAwesomeIcon icon={faEdit} size={"lg"} /> + <span className="collectionTimeView-span">Facet Filters</span> + </div> + </Flyout> + </div> + <div className="collectionTimeView-tree" key="tree"> + <CollectionTreeView + Document={facetCollection} + DataDoc={facetCollection} + fieldKey={`${this.props.fieldKey}-filter`} + CollectionView={this} + ContainingCollectionDoc={this.props.ContainingCollectionDoc} + ContainingCollectionView={this.props.ContainingCollectionView} + PanelWidth={this.facetWidth} + PanelHeight={this.props.PanelHeight} + NativeHeight={returnZero} + NativeWidth={returnZero} + LibraryPath={emptyPath} + rootSelected={this.props.rootSelected} + renderDepth={1} + dropAction={this.props.dropAction} + ScreenToLocalTransform={this.props.ScreenToLocalTransform} + addDocTab={returnFalse} + pinToPres={returnFalse} + isSelected={returnFalse} + select={returnFalse} + bringToFront={emptyFunction} + active={this.props.active} + whenActiveChanged={returnFalse} + treeViewHideTitle={true} + ContentScaling={returnOne} + focus={returnFalse} + treeViewHideHeaderFields={true} + onCheckedClick={this.scriptField!} + ignoreFields={this.ignoreFields} + annotationsKey={""} + dontRegisterView={true} + backgroundColor={this.filterBackground} + moveDocument={returnFalse} + removeDocument={returnFalse} + addDocument={returnFalse} /> + </div> + </div>; + } + render() { TraceMobx(); const props: CollectionRenderProps = { @@ -287,21 +471,29 @@ export class CollectionView extends Touchable<FieldViewProps> { moveDocument: this.moveDocument, active: this.active, whenActiveChanged: this.whenActiveChanged, + PanelWidth: this.bodyPanelWidth }; return (<div className={"collectionView"} style={{ - pointerEvents: this.props.Document.isBackground ? "none" : "all", - boxShadow: this.props.Document.isBackground || this.collectionViewType === CollectionViewType.Linear ? undefined : `#9c9396 ${StrCast(this.props.Document.boxShadow, "0.2vw 0.2vw 0.8vw")}` + pointerEvents: this.props.Document.isBackground ? "none" : undefined, + boxShadow: this.props.Document.isBackground || this.collectionViewType === CollectionViewType.Linear ? undefined : + `${Cast(Doc.UserDoc().activeWorkspace, Doc, null)?.darkScheme ? "rgb(30, 32, 31)" : "#9c9396"} ${StrCast(this.props.Document.boxShadow, "0.2vw 0.2vw 0.8vw")}` }} onContextMenu={this.onContextMenu}> {this.showIsTagged()} - {this.collectionViewType !== undefined ? this.SubView(this.collectionViewType, props) : (null)} + <div style={{ width: `calc(100% - ${this.facetWidth()}px)` }}> + {this.collectionViewType !== undefined ? this.SubView(this.collectionViewType, props) : (null)} + </div> {this.lightbox(DocListCast(this.props.Document[this.props.fieldKey]).filter(d => d.type === DocumentType.IMG).map(d => Cast(d.data, ImageField) ? (Cast(d.data, ImageField)!.url.href.indexOf(window.location.origin) === -1) ? Utils.CorsProxy(Cast(d.data, ImageField)!.url.href) : Cast(d.data, ImageField)!.url.href : ""))} + {!this.props.isSelected() || this.props.PanelHeight() < 100 || this.props.Document.hideFilterView ? (null) : + <div className="collectionTimeView-dragger" title="library View Dragger" onPointerDown={this.onPointerDown} style={{ right: this.facetWidth() - 10 }} /> + } + {this.facetWidth() < 10 ? (null) : this.filterView} </div>); } }
\ No newline at end of file diff --git a/src/client/views/collections/CollectionViewChromes.scss b/src/client/views/collections/CollectionViewChromes.scss index d2dcf96d7..e4581eb46 100644 --- a/src/client/views/collections/CollectionViewChromes.scss +++ b/src/client/views/collections/CollectionViewChromes.scss @@ -2,7 +2,8 @@ @import '~js-datepicker/dist/datepicker.min.css'; .collectionViewChrome-cont { - position: relative; + position: absolute; + width:100%; opacity: 0.9; z-index: 9001; transition: top .5s; @@ -26,7 +27,6 @@ outline-color: black; border: none; padding: 12px 10px 11px 10px; - margin-left: 50px; } .collectionViewBaseChrome-viewPicker:active { @@ -69,19 +69,24 @@ position: absolute; width: 40px; transform-origin: top left; + pointer-events: all; // margin-top: 10px; } - .collectionViewBaseChrome-template { - margin-left: 10px; + .collectionViewBaseChrome-template, + .collectionViewBaseChrome-viewModes { display: grid; background: rgb(238, 238, 238); color:grey; margin-top:auto; margin-bottom:auto; + margin-left: 5px; + } + .collectionViewBaseChrome-viewModes { + margin-left: 25px; } .collectionViewBaseChrome-viewSpecs { - margin-left: 10px; + margin-left: 5px; display: grid; .collectionViewBaseChrome-filterIcon { @@ -179,23 +184,26 @@ } - .collectionStackingViewChrome-sectionFilter-cont, - .collectionTreeViewChrome-sectionFilter-cont { + .collectionStackingViewChrome-pivotField-cont, + .collectionTreeViewChrome-pivotField-cont { justify-self: right; display: flex; font-size: 75%; letter-spacing: 2px; - .collectionStackingViewChrome-sectionFilter-label, - .collectionTreeViewChrome-sectionFilter-label { + .collectionStackingViewChrome-pivotField-label, + .collectionTreeViewChrome-pivotField-label { vertical-align: center; - padding: 10px; + padding-left: 10px; + padding-top: 10px; + padding-bottom: 10px; } - .collectionStackingViewChrome-sectionFilter, - .collectionTreeViewChrome-sectionFilter { + .collectionStackingViewChrome-pivotField, + .collectionTreeViewChrome-pivotField { color: white; - width: 100px; + width:100%; + min-width: 100px; text-align: center; background: rgb(238, 238, 238); @@ -220,8 +228,8 @@ } } - .collectionStackingViewChrome-sectionFilter:hover, - .collectionTreeViewChrome-sectionFilter:hover { + .collectionStackingViewChrome-pivotField:hover, + .collectionTreeViewChrome-pivotField:hover { cursor: text; } } @@ -288,7 +296,7 @@ display:flex; flex-direction: row; width: 150px; - margin: auto 0 auto auto; + margin: auto auto auto auto; } .react-autosuggest__container { diff --git a/src/client/views/collections/CollectionViewChromes.tsx b/src/client/views/collections/CollectionViewChromes.tsx index 4933c6077..d26e3a38b 100644 --- a/src/client/views/collections/CollectionViewChromes.tsx +++ b/src/client/views/collections/CollectionViewChromes.tsx @@ -6,9 +6,8 @@ import { Doc, DocListCast } from "../../../new_fields/Doc"; import { Id } from "../../../new_fields/FieldSymbols"; import { List } from "../../../new_fields/List"; import { listSpec } from "../../../new_fields/Schema"; -import { ScriptField } from "../../../new_fields/ScriptField"; import { BoolCast, Cast, NumCast, StrCast } from "../../../new_fields/Types"; -import { Utils, emptyFunction } from "../../../Utils"; +import { Utils, emptyFunction, setupMoveUpEvents } from "../../../Utils"; import { DragManager } from "../../util/DragManager"; import { undoBatch } from "../../util/UndoManager"; import { EditableView } from "../EditableView"; @@ -16,14 +15,13 @@ import { COLLECTION_BORDER_WIDTH } from "../globalCssVariables.scss"; import { CollectionViewType } from "./CollectionView"; import { CollectionView } from "./CollectionView"; import "./CollectionViewChromes.scss"; -import * as Autosuggest from 'react-autosuggest'; -import KeyRestrictionRow from "./KeyRestrictionRow"; const datepicker = require('js-datepicker'); interface CollectionViewChromeProps { CollectionView: CollectionView; type: CollectionViewType; collapse?: (value: boolean) => any; + PanelWidth: () => number; } interface Filter { @@ -38,25 +36,30 @@ const stopPropagation = (e: React.SyntheticEvent) => e.stopPropagation(); export class CollectionViewBaseChrome extends React.Component<CollectionViewChromeProps> { //(!)?\(\(\(doc.(\w+) && \(doc.\w+ as \w+\).includes\(\"(\w+)\"\) + get target() { return this.props.CollectionView.props.Document; } _templateCommand = { - title: "=> item view", script: "setChildLayout(this.target, this.source?.[0])", params: ["target", "source"], + params: ["target", "source"], title: "=> item view", + script: "this.target.childLayout = getDocTemplate(this.source?.[0])", + immediate: (source: Doc[]) => this.target.childLayout = Doc.getDocTemplate(source?.[0]), initialize: emptyFunction, - immediate: (draggedDocs: Doc[]) => Doc.setChildLayout(this.props.CollectionView.props.Document, draggedDocs.length ? draggedDocs[0] : undefined) }; _narrativeCommand = { - title: "=> click item view", script: "setChildDetailedLayout(this.target, this.source?.[0])", params: ["target", "source"], + params: ["target", "source"], title: "=> click item view", + script: "this.target.childDetailView = getDocTemplate(this.source?.[0])", + immediate: (source: Doc[]) => this.target.childDetailView = Doc.getDocTemplate(source?.[0]), initialize: emptyFunction, - immediate: (draggedDocs: Doc[]) => Doc.setChildDetailedLayout(this.props.CollectionView.props.Document, draggedDocs.length ? draggedDocs[0] : undefined) }; _contentCommand = { - title: "=> content", script: "getProto(this.target).data = aliasDocs(this.source);", params: ["target", "source"], + params: ["target", "source"], title: "=> content", + script: "getProto(this.target).data = copyField(this.source);", + immediate: (source: Doc[]) => Doc.GetProto(this.target).data = new List<Doc>(source), // Doc.aliasDocs(source), initialize: emptyFunction, - immediate: (draggedDocs: Doc[]) => Doc.GetProto(this.props.CollectionView.props.Document).data = new List<Doc>(draggedDocs.map((d: any) => Doc.MakeAlias(d))) }; _viewCommand = { - title: "=> saved view", script: "this.target._panX = this.restoredPanX; this.target._panY = this.restoredPanY; this.target.scale = this.restoredScale;", params: ["target"], - initialize: (button: Doc) => { button.restoredPanX = this.props.CollectionView.props.Document._panX; button.restoredPanY = this.props.CollectionView.props.Document._panY; button.restoredScale = this.props.CollectionView.props.Document.scale; }, - immediate: (draggedDocs: Doc[]) => { this.props.CollectionView.props.Document._panX = 0; this.props.CollectionView.props.Document._panY = 0; this.props.CollectionView.props.Document.scale = 1; }, + params: ["target"], title: "=> saved view", + script: "this.target._panX = this.restoredPanX; this.target._panY = this.restoredPanY; this.target.scale = this.restoredScale;", + immediate: (source: Doc[]) => { this.target._panX = 0; this.target._panY = 0; this.target.scale = 1; }, + initialize: (button: Doc) => { button.restoredPanX = this.target._panX; button.restoredPanY = this.target._panY; button.restoredScale = this.target.scale; }, }; _freeform_commands = [this._contentCommand, this._templateCommand, this._narrativeCommand, this._viewCommand]; _stacking_commands = [this._contentCommand, this._templateCommand]; @@ -77,62 +80,11 @@ export class CollectionViewBaseChrome extends React.Component<CollectionViewChro } private _picker: any; private _commandRef = React.createRef<HTMLInputElement>(); - private _autosuggestRef = React.createRef<Autosuggest>(); + private _viewRef = React.createRef<HTMLInputElement>(); @observable private _currentKey: string = ""; - @observable private _viewSpecsOpen: boolean = false; - @observable private _dateWithinValue: string = ""; - @observable private _dateValue: Date | string = ""; - @observable private _keyRestrictions: [JSX.Element, string][] = []; - @observable private suggestions: string[] = []; - @computed private get filterValue() { return Cast(this.props.CollectionView.props.Document.viewSpecScript, ScriptField); } - - getFilters = (script: string) => { - const re: any = /(!)?\(\(\(doc\.(\w+)\s+&&\s+\(doc\.\w+\s+as\s+\w+\)\.includes\(\"(\w+)\"\)/g; - const arr: any[] = re.exec(script); - const toReturn: Filter[] = []; - if (arr !== null) { - const filter: Filter = { - key: arr[2], - value: arr[3], - contains: (arr[1] === "!") ? false : true, - }; - toReturn.push(filter); - script = script.replace(arr[0], ""); - if (re.exec(script) !== null) { - toReturn.push(...this.getFilters(script)); - } - else { return toReturn; } - } - return toReturn; - } - - addKeyRestrictions = (fields: Filter[]) => { - - if (fields.length !== 0) { - for (let i = 0; i < fields.length; i++) { - this._keyRestrictions.push([<KeyRestrictionRow field={fields[i].key} value={fields[i].value} key={Utils.GenerateGuid()} contains={fields[i].contains} script={(value: string) => runInAction(() => this._keyRestrictions[i][1] = value)} />, ""]); - - } - if (this._keyRestrictions.length === 1) { - this._keyRestrictions.push([<KeyRestrictionRow field="" value="" key={Utils.GenerateGuid()} contains={true} script={(value: string) => runInAction(() => this._keyRestrictions[1][1] = value)} />, ""]); - } - } - else { - this._keyRestrictions.push([<KeyRestrictionRow field="" value="" key={Utils.GenerateGuid()} contains={true} script={(value: string) => runInAction(() => this._keyRestrictions[0][1] = value)} />, ""]); - this._keyRestrictions.push([<KeyRestrictionRow field="" value="" key={Utils.GenerateGuid()} contains={false} script={(value: string) => runInAction(() => this._keyRestrictions[1][1] = value)} />, ""]); - } - } componentDidMount = () => { - - let fields: Filter[] = []; - if (this.filterValue) { - const string = this.filterValue.script.originalScript; - fields = this.getFilters(string); - } - runInAction(() => { - this.addKeyRestrictions(fields); // chrome status is one of disabled, collapsed, or visible. this determines initial state from document const chromeStatus = this.props.CollectionView.props.Document._chromeStatus; if (chromeStatus) { @@ -151,7 +103,7 @@ export class CollectionViewBaseChrome extends React.Component<CollectionViewChro @undoBatch viewChanged = (e: React.ChangeEvent) => { //@ts-ignore - this.props.CollectionView.props.Document._viewType = parseInt(e.target.selectedOptions[0].value); + this.document._viewType = e.target.selectedOptions[0].value; } commandChanged = (e: React.ChangeEvent) => { @@ -160,107 +112,99 @@ export class CollectionViewBaseChrome extends React.Component<CollectionViewChro } @action - openViewSpecs = (e: React.SyntheticEvent) => { - if (this._viewSpecsOpen) this.closeViewSpecs(); - else { - this._viewSpecsOpen = true; - - //@ts-ignore - if (!e.target?.classList[0]?.startsWith("qs")) { - this.closeDatePicker(); - } - - e.stopPropagation(); - document.removeEventListener("pointerdown", this.closeViewSpecs); - document.addEventListener("pointerdown", this.closeViewSpecs); - } + toggleViewSpecs = (e: React.SyntheticEvent) => { + this.document._facetWidth = this.document._facetWidth ? 0 : 200; + e.stopPropagation(); } @action closeViewSpecs = () => { - this._viewSpecsOpen = false; - document.removeEventListener("pointerdown", this.closeViewSpecs); - }; + this.document._facetWidth = 0; + } + + // @action + // openDatePicker = (e: React.PointerEvent) => { + // if (this._picker) { + // this._picker.alwaysShow = true; + // this._picker.show(); + // // TODO: calendar is offset when zoomed in/out + // // this._picker.calendar.style.position = "absolute"; + // // let transform = this.props.CollectionView.props.ScreenToLocalTransform(); + // // let x = parseInt(this._picker.calendar.style.left) / transform.Scale; + // // let y = parseInt(this._picker.calendar.style.top) / transform.Scale; + // // this._picker.calendar.style.left = x; + // // this._picker.calendar.style.top = y; + + // e.stopPropagation(); + // } + // } + + // <input className="collectionViewBaseChrome-viewSpecsMenu-rowRight" + // id={Utils.GenerateGuid()} + // ref={this.datePickerRef} + // value={this._dateValue instanceof Date ? this._dateValue.toLocaleDateString() : this._dateValue} + // onChange={(e) => runInAction(() => this._dateValue = e.target.value)} + // onPointerDown={this.openDatePicker} + // placeholder="Value" /> + // @action.bound + // applyFilter = (e: React.MouseEvent) => { + // const keyRestrictionScript = "(" + this._keyRestrictions.map(i => i[1]).filter(i => i.length > 0).join(" && ") + ")"; + // const yearOffset = this._dateWithinValue[1] === 'y' ? 1 : 0; + // const monthOffset = this._dateWithinValue[1] === 'm' ? parseInt(this._dateWithinValue[0]) : 0; + // const weekOffset = this._dateWithinValue[1] === 'w' ? parseInt(this._dateWithinValue[0]) : 0; + // const dayOffset = (this._dateWithinValue[1] === 'd' ? parseInt(this._dateWithinValue[0]) : 0) + weekOffset * 7; + // let dateRestrictionScript = ""; + // if (this._dateValue instanceof Date) { + // const lowerBound = new Date(this._dateValue.getFullYear() - yearOffset, this._dateValue.getMonth() - monthOffset, this._dateValue.getDate() - dayOffset); + // const upperBound = new Date(this._dateValue.getFullYear() + yearOffset, this._dateValue.getMonth() + monthOffset, this._dateValue.getDate() + dayOffset + 1); + // dateRestrictionScript = `((doc.creationDate as any).date >= ${lowerBound.valueOf()} && (doc.creationDate as any).date <= ${upperBound.valueOf()})`; + // } + // else { + // const createdDate = new Date(this._dateValue); + // if (!isNaN(createdDate.getTime())) { + // const lowerBound = new Date(createdDate.getFullYear() - yearOffset, createdDate.getMonth() - monthOffset, createdDate.getDate() - dayOffset); + // const upperBound = new Date(createdDate.getFullYear() + yearOffset, createdDate.getMonth() + monthOffset, createdDate.getDate() + dayOffset + 1); + // dateRestrictionScript = `((doc.creationDate as any).date >= ${lowerBound.valueOf()} && (doc.creationDate as any).date <= ${upperBound.valueOf()})`; + // } + // } + // const fullScript = dateRestrictionScript.length || keyRestrictionScript.length ? dateRestrictionScript.length ? + // `${dateRestrictionScript} ${keyRestrictionScript.length ? "&&" : ""} (${keyRestrictionScript})` : + // `(${keyRestrictionScript}) ${dateRestrictionScript.length ? "&&" : ""} ${dateRestrictionScript}` : + // "true"; + + // this.props.CollectionView.props.Document.viewSpecScript = ScriptField.MakeFunction(fullScript, { doc: Doc.name }); + // } + + // datePickerRef = (node: HTMLInputElement) => { + // if (node) { + // try { + // this._picker = datepicker("#" + node.id, { + // disabler: (date: Date) => date > new Date(), + // onSelect: (instance: any, date: Date) => runInAction(() => {}), // this._dateValue = date), + // dateSelected: new Date() + // }); + // } catch (e) { + // console.log("date picker exception:" + e); + // } + // } + // } - @action - openDatePicker = (e: React.PointerEvent) => { - this.openViewSpecs(e); - if (this._picker) { - this._picker.alwaysShow = true; - this._picker.show(); - // TODO: calendar is offset when zoomed in/out - // this._picker.calendar.style.position = "absolute"; - // let transform = this.props.CollectionView.props.ScreenToLocalTransform(); - // let x = parseInt(this._picker.calendar.style.left) / transform.Scale; - // let y = parseInt(this._picker.calendar.style.top) / transform.Scale; - // this._picker.calendar.style.left = x; - // this._picker.calendar.style.top = y; - - e.stopPropagation(); - } - } - - @action - addKeyRestriction = (e: React.MouseEvent) => { - const index = this._keyRestrictions.length; - this._keyRestrictions.push([<KeyRestrictionRow field="" value="" key={Utils.GenerateGuid()} contains={true} script={(value: string) => runInAction(() => this._keyRestrictions[index][1] = value)} />, ""]); - - this.openViewSpecs(e); - } - - @action.bound - applyFilter = (e: React.MouseEvent) => { - this.openViewSpecs(e); - - const keyRestrictionScript = "(" + this._keyRestrictions.map(i => i[1]).filter(i => i.length > 0).join(" && ") + ")"; - const yearOffset = this._dateWithinValue[1] === 'y' ? 1 : 0; - const monthOffset = this._dateWithinValue[1] === 'm' ? parseInt(this._dateWithinValue[0]) : 0; - const weekOffset = this._dateWithinValue[1] === 'w' ? parseInt(this._dateWithinValue[0]) : 0; - const dayOffset = (this._dateWithinValue[1] === 'd' ? parseInt(this._dateWithinValue[0]) : 0) + weekOffset * 7; - let dateRestrictionScript = ""; - if (this._dateValue instanceof Date) { - const lowerBound = new Date(this._dateValue.getFullYear() - yearOffset, this._dateValue.getMonth() - monthOffset, this._dateValue.getDate() - dayOffset); - const upperBound = new Date(this._dateValue.getFullYear() + yearOffset, this._dateValue.getMonth() + monthOffset, this._dateValue.getDate() + dayOffset + 1); - dateRestrictionScript = `((doc.creationDate as any).date >= ${lowerBound.valueOf()} && (doc.creationDate as any).date <= ${upperBound.valueOf()})`; - } - else { - const createdDate = new Date(this._dateValue); - if (!isNaN(createdDate.getTime())) { - const lowerBound = new Date(createdDate.getFullYear() - yearOffset, createdDate.getMonth() - monthOffset, createdDate.getDate() - dayOffset); - const upperBound = new Date(createdDate.getFullYear() + yearOffset, createdDate.getMonth() + monthOffset, createdDate.getDate() + dayOffset + 1); - dateRestrictionScript = `((doc.creationDate as any).date >= ${lowerBound.valueOf()} && (doc.creationDate as any).date <= ${upperBound.valueOf()})`; - } - } - const fullScript = dateRestrictionScript.length || keyRestrictionScript.length ? dateRestrictionScript.length ? - `${dateRestrictionScript} ${keyRestrictionScript.length ? "&&" : ""} (${keyRestrictionScript})` : - `(${keyRestrictionScript}) ${dateRestrictionScript.length ? "&&" : ""} ${dateRestrictionScript}` : - "true"; - - this.props.CollectionView.props.Document.viewSpecScript = ScriptField.MakeFunction(fullScript, { doc: Doc.name }); - } - - @action - closeDatePicker = () => { - if (this._picker) { - this._picker.alwaysShow = false; - this._picker.hide(); - } - document.removeEventListener("pointerdown", this.closeDatePicker); - } @action toggleCollapse = () => { - this.props.CollectionView.props.Document._chromeStatus = this.props.CollectionView.props.Document._chromeStatus === "enabled" ? "collapsed" : "enabled"; + this.document._chromeStatus = this.document._chromeStatus === "enabled" ? "collapsed" : "enabled"; if (this.props.collapse) { this.props.collapse(this.props.CollectionView.props.Document._chromeStatus !== "enabled"); } } subChrome = () => { + const collapsed = this.document._chromeStatus !== "enabled"; + if (collapsed) return null; switch (this.props.type) { - case CollectionViewType.Stacking: return (<CollectionStackingViewChrome key="collchrome" CollectionView={this.props.CollectionView} type={this.props.type} />); - case CollectionViewType.Schema: return (<CollectionSchemaViewChrome key="collchrome" CollectionView={this.props.CollectionView} type={this.props.type} />); - case CollectionViewType.Tree: return (<CollectionTreeViewChrome key="collchrome" CollectionView={this.props.CollectionView} type={this.props.type} />); - case CollectionViewType.Masonry: return (<CollectionStackingViewChrome key="collchrome" CollectionView={this.props.CollectionView} type={this.props.type} />); + case CollectionViewType.Stacking: return (<CollectionStackingViewChrome key="collchrome" PanelWidth={this.props.PanelWidth} CollectionView={this.props.CollectionView} type={this.props.type} />); + case CollectionViewType.Schema: return (<CollectionSchemaViewChrome key="collchrome" PanelWidth={this.props.PanelWidth} CollectionView={this.props.CollectionView} type={this.props.type} />); + case CollectionViewType.Tree: return (<CollectionTreeViewChrome key="collchrome" PanelWidth={this.props.PanelWidth} CollectionView={this.props.CollectionView} type={this.props.type} />); + case CollectionViewType.Masonry: return (<CollectionStackingViewChrome key="collchrome" PanelWidth={this.props.PanelWidth} CollectionView={this.props.CollectionView} type={this.props.type} />); default: return null; } } @@ -269,18 +213,11 @@ export class CollectionViewBaseChrome extends React.Component<CollectionViewChro return this.props.CollectionView.props.Document; } - @action.bound - clearFilter = () => { - this.props.CollectionView.props.Document.viewSpecScript = ScriptField.MakeFunction("true", { doc: Doc.name }); - this._keyRestrictions = []; - this.addKeyRestrictions([]); - } - private dropDisposer?: DragManager.DragDropDisposer; protected createDropTarget = (ele: HTMLDivElement) => { - this.dropDisposer && this.dropDisposer(); + this.dropDisposer?.(); if (ele) { - this.dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this)); + this.dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this), this.document); } } @@ -294,153 +231,82 @@ export class CollectionViewBaseChrome extends React.Component<CollectionViewChro return true; } - datePickerRef = (node: HTMLInputElement) => { - if (node) { - try { - this._picker = datepicker("#" + node.id, { - disabler: (date: Date) => date > new Date(), - onSelect: (instance: any, date: Date) => runInAction(() => this._dateValue = date), - dateSelected: new Date() - }); - } catch (e) { - console.log("date picker exception:" + e); - } - } - } - - renderSuggestion = (suggestion: string) => { - return <p>{suggestion}</p>; - } - getSuggestionValue = (suggestion: string) => suggestion; - - @action - onKeyChange = (e: React.ChangeEvent, { newValue }: { newValue: string }) => { - this._currentKey = newValue; - } - onSuggestionFetch = async ({ value }: { value: string }) => { - const sugg = await this.getKeySuggestions(value); - runInAction(() => this.suggestions = sugg); - } - @action - onSuggestionClear = () => { - this.suggestions = []; - } - getKeySuggestions = async (value: string): Promise<string[]> => { - return this._buttonizableCommands.filter(c => c.title.indexOf(value) !== -1).map(c => c.title); - } - - autoSuggestDown = (e: React.PointerEvent) => { - e.stopPropagation(); + dragViewDown = (e: React.PointerEvent) => { + setupMoveUpEvents(this, e, (e, down, delta) => { + const vtype = this.props.CollectionView.collectionViewType; + const c = { + params: ["target"], title: vtype, + script: `this.target._viewType = ${StrCast(this.props.CollectionView.props.Document._viewType)}`, + immediate: (source: Doc[]) => this.props.CollectionView.props.Document._viewType = Doc.getDocTemplate(source?.[0]), + initialize: emptyFunction, + }; + DragManager.StartButtonDrag([this._viewRef.current!], c.script, StrCast(c.title), + { target: this.props.CollectionView.props.Document }, c.params, c.initialize, e.clientX, e.clientY); + return true; + }, emptyFunction, emptyFunction); } - - private _startDragPosition: { x: number, y: number } = { x: 0, y: 0 }; - private _sensitivity: number = 16; - dragCommandDown = (e: React.PointerEvent) => { - - this._startDragPosition = { x: e.clientX, y: e.clientY }; - document.addEventListener("pointermove", this.dragPointerMove); - document.addEventListener("pointerup", this.dragPointerUp); - e.stopPropagation(); - e.preventDefault(); - } - - dragPointerMove = (e: PointerEvent) => { - e.stopPropagation(); - e.preventDefault(); - const [dx, dy] = [e.clientX - this._startDragPosition.x, e.clientY - this._startDragPosition.y]; - if (Math.abs(dx) + Math.abs(dy) > this._sensitivity) { + setupMoveUpEvents(this, e, (e, down, delta) => { this._buttonizableCommands.filter(c => c.title === this._currentKey).map(c => DragManager.StartButtonDrag([this._commandRef.current!], c.script, c.title, { target: this.props.CollectionView.props.Document }, c.params, c.initialize, e.clientX, e.clientY)); - document.removeEventListener("pointermove", this.dragPointerMove); - document.removeEventListener("pointerup", this.dragPointerUp); - } - } - dragPointerUp = (e: PointerEvent) => { - document.removeEventListener("pointermove", this.dragPointerMove); - document.removeEventListener("pointerup", this.dragPointerUp); - + return true; + }, emptyFunction, emptyFunction); } render() { const collapsed = this.props.CollectionView.props.Document._chromeStatus !== "enabled"; return ( - <div className="collectionViewChrome-cont" style={{ top: collapsed ? -70 : 0, height: collapsed ? 0 : undefined }}> - <div className="collectionViewChrome"> + <div className="collectionViewChrome-cont" style={{ + top: collapsed ? -70 : 0, height: collapsed ? 0 : undefined, + transform: collapsed ? "" : `scale(${Math.min(1, this.props.CollectionView.props.ScreenToLocalTransform().Scale)})`, + transformOrigin: "top left", + width: `${this.props.PanelWidth() / Math.min(1, this.props.CollectionView.props.ScreenToLocalTransform().Scale)}px` + }}> + <div className="collectionViewChrome" style={{ border: "unset", pointerEvents: collapsed ? "none" : undefined }}> <div className="collectionViewBaseChrome"> <button className="collectionViewBaseChrome-collapse" style={{ top: collapsed ? 70 : 10, - transform: `rotate(${collapsed ? 180 : 0}deg) scale(${collapsed ? 0.5 : 1}) translate(${collapsed ? "-100%, -100%" : "0, 0"})`, - opacity: (collapsed && !this.props.CollectionView.props.isSelected()) ? 0 : 0.9, + transform: `rotate(${collapsed ? 180 : 0}deg) scale(0.5) translate(${collapsed ? "-100%, -100%" : "0, 0"})`, + opacity: 0.9, + display: (collapsed && !this.props.CollectionView.props.isSelected()) ? "none" : undefined, left: (collapsed ? 0 : "unset"), }} title="Collapse collection chrome" onClick={this.toggleCollapse}> <FontAwesomeIcon icon="caret-up" size="2x" /> </button> - <select - className="collectionViewBaseChrome-viewPicker" - onPointerDown={stopPropagation} - onChange={this.viewChanged} - value={NumCast(this.props.CollectionView.props.Document._viewType)}> - <option className="collectionViewBaseChrome-viewOption" onPointerDown={stopPropagation} value="1">Freeform</option> - <option className="collectionViewBaseChrome-viewOption" onPointerDown={stopPropagation} value="2">Schema</option> - <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">MultiCol</option> - <option className="collectionViewBaseChrome-viewOption" onPointerDown={stopPropagation} value="8">MultiRow</option> - <option className="collectionViewBaseChrome-viewOption" onPointerDown={stopPropagation} value="9">Pivot/Time</option> - <option className="collectionViewBaseChrome-viewOption" onPointerDown={stopPropagation} value="10">Carousel</option> - </select> + <div className="collectionViewBaseChrome-viewModes" style={{ display: collapsed ? "none" : undefined }}> + <div className="commandEntry-outerDiv" title="drop document to apply or drag to create button" ref={this._viewRef} onPointerDown={this.dragViewDown}> + <div className="commandEntry-drop"> + <FontAwesomeIcon icon="bullseye" size="2x"></FontAwesomeIcon> + </div> + <select + className="collectionViewBaseChrome-viewPicker" + onPointerDown={stopPropagation} + onChange={this.viewChanged} + value={StrCast(this.props.CollectionView.props.Document._viewType)}> + {Object.values(CollectionViewType).map(type => ["invalid", "docking"].includes(type) ? (null) : ( + <option + key={Utils.GenerateGuid()} + className="collectionViewBaseChrome-viewOption" + onPointerDown={stopPropagation} + value={type}> + {type[0].toUpperCase() + type.substring(1)} + </option> + ))} + </select> + </div> + </div> <div className="collectionViewBaseChrome-viewSpecs" title="filter documents to show" style={{ display: collapsed ? "none" : "grid" }}> - <div className="collectionViewBaseChrome-filterIcon" onPointerDown={this.openViewSpecs} > + <div className="collectionViewBaseChrome-filterIcon" onPointerDown={this.toggleViewSpecs} > <FontAwesomeIcon icon="filter" size="2x" /> </div> - <div className="collectionViewBaseChrome-viewSpecsMenu" - onPointerDown={this.openViewSpecs} - style={{ - height: this._viewSpecsOpen ? "fit-content" : "0px", - overflow: this._viewSpecsOpen ? "initial" : "hidden" - }}> - {this._keyRestrictions.map(i => i[0])} - <div className="collectionViewBaseChrome-viewSpecsMenu-row"> - <div className="collectionViewBaseChrome-viewSpecsMenu-rowLeft"> - CREATED WITHIN: - </div> - <select className="collectionViewBaseChrome-viewSpecsMenu-rowMiddle" - style={{ textTransform: "uppercase", textAlign: "center" }} - value={this._dateWithinValue} - onChange={(e) => runInAction(() => this._dateWithinValue = e.target.value)}> - <option value="1d">1 day of</option> - <option value="3d">3 days of</option> - <option value="1w">1 week of</option> - <option value="2w">2 weeks of</option> - <option value="1m">1 month of</option> - <option value="2m">2 months of</option> - <option value="6m">6 months of</option> - <option value="1y">1 year of</option> - </select> - <input className="collectionViewBaseChrome-viewSpecsMenu-rowRight" - id={Utils.GenerateGuid()} - ref={this.datePickerRef} - value={this._dateValue instanceof Date ? this._dateValue.toLocaleDateString() : this._dateValue} - onChange={(e) => runInAction(() => this._dateValue = e.target.value)} - onPointerDown={this.openDatePicker} - placeholder="Value" /> - </div> - <div className="collectionViewBaseChrome-viewSpecsMenu-lastRow"> - <button className="collectonViewBaseChrome-viewSpecsMenu-lastRowButton" onClick={this.addKeyRestriction}> ADD KEY RESTRICTION </button> - <button className="collectonViewBaseChrome-viewSpecsMenu-lastRowButton" onClick={this.applyFilter}> APPLY FILTER </button> - <button className="collectonViewBaseChrome-viewSpecsMenu-lastRowButton" onClick={this.clearFilter}> CLEAR </button> - </div> - </div> </div> - <div className="collectionViewBaseChrome-template" ref={this.createDropTarget} > + <div className="collectionViewBaseChrome-template" ref={this.createDropTarget} style={{ display: collapsed ? "none" : undefined }}> <div className="commandEntry-outerDiv" title="drop document to apply or drag to create button" ref={this._commandRef} onPointerDown={this.dragCommandDown}> <div className="commandEntry-drop"> - <FontAwesomeIcon icon="bullseye" size="2x"></FontAwesomeIcon> + <FontAwesomeIcon icon="bullseye" size="2x" /> </div> <select className="collectionViewBaseChrome-cmdPicker" @@ -468,7 +334,7 @@ export class CollectionStackingViewChrome extends React.Component<CollectionView @observable private suggestions: string[] = []; @computed private get descending() { return BoolCast(this.props.CollectionView.props.Document.stackingHeadersSortDescending); } - @computed get sectionFilter() { return StrCast(this.props.CollectionView.props.Document.sectionFilter); } + @computed get pivotField() { return StrCast(this.props.CollectionView.props.Document._pivotField); } getKeySuggestions = async (value: string): Promise<string[]> => { value = value.toLowerCase(); @@ -506,26 +372,26 @@ export class CollectionStackingViewChrome extends React.Component<CollectionView } setValue = (value: string) => { - this.props.CollectionView.props.Document.sectionFilter = value; + this.props.CollectionView.props.Document._pivotField = value; return true; } @action toggleSort = () => { this.props.CollectionView.props.Document.stackingHeadersSortDescending = !this.props.CollectionView.props.Document.stackingHeadersSortDescending; }; - @action resetValue = () => { this._currentKey = this.sectionFilter; }; + @action resetValue = () => { this._currentKey = this.pivotField; }; render() { return ( <div className="collectionStackingViewChrome-cont"> - <div className="collectionStackingViewChrome-sectionFilter-cont"> - <div className="collectionStackingViewChrome-sectionFilter-label"> - GROUP ITEMS BY: + <div className="collectionStackingViewChrome-pivotField-cont"> + <div className="collectionStackingViewChrome-pivotField-label"> + GROUP BY: </div> <div className="collectionStackingViewChrome-sortIcon" onClick={this.toggleSort} style={{ transform: `rotate(${this.descending ? "180" : "0"}deg)` }}> <FontAwesomeIcon icon="caret-up" size="2x" color="white" /> </div> - <div className="collectionStackingViewChrome-sectionFilter"> + <div className="collectionStackingViewChrome-pivotField"> <EditableView - GetValue={() => this.sectionFilter} + GetValue={() => this.pivotField} autosuggestProps={ { resetValue: this.resetValue, @@ -547,7 +413,7 @@ export class CollectionStackingViewChrome extends React.Component<CollectionView }} oneLine SetValue={this.setValue} - contents={this.sectionFilter ? this.sectionFilter : "N/A"} + contents={this.pivotField ? this.pivotField : "N/A"} /> </div> </div> diff --git a/src/client/views/collections/ParentDocumentSelector.scss b/src/client/views/collections/ParentDocumentSelector.scss index a266861bd..4e704b58f 100644 --- a/src/client/views/collections/ParentDocumentSelector.scss +++ b/src/client/views/collections/ParentDocumentSelector.scss @@ -35,6 +35,10 @@ pointer-events: all; position: relative; display: inline-block; + svg { + width:20px !important; + height:20px; + } } .parentDocumentSelector-metadata { pointer-events: auto; @@ -46,8 +50,7 @@ div { overflow: visible !important; } - position: absolute; display: inline-block; - padding-left: 5px; - padding-right: 5px; + width:100%; + height:100%; }
\ No newline at end of file diff --git a/src/client/views/collections/ParentDocumentSelector.tsx b/src/client/views/collections/ParentDocumentSelector.tsx index 115f8d633..10c6ead1a 100644 --- a/src/client/views/collections/ParentDocumentSelector.tsx +++ b/src/client/views/collections/ParentDocumentSelector.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import './ParentDocumentSelector.scss'; import { Doc } from "../../../new_fields/Doc"; import { observer } from "mobx-react"; -import { observable, action, runInAction } from "mobx"; +import { observable, action, runInAction, trace, computed, reaction, IReactionDisposer } from "mobx"; import { Id } from "../../../new_fields/FieldSymbols"; import { SearchUtil } from "../../util/SearchUtil"; import { CollectionDockingView } from "./CollectionDockingView"; @@ -11,34 +11,34 @@ import { CollectionViewType } from "./CollectionView"; import { DocumentButtonBar } from "../DocumentButtonBar"; import { DocumentManager } from "../../util/DocumentManager"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faEdit, faChevronCircleUp } from "@fortawesome/free-solid-svg-icons"; +import { faCog, faChevronCircleUp } from "@fortawesome/free-solid-svg-icons"; import { library } from "@fortawesome/fontawesome-svg-core"; -import { MetadataEntryMenu } from "../MetadataEntryMenu"; import { DocumentView } from "../nodes/DocumentView"; +import { SelectionManager } from "../../util/SelectionManager"; const higflyout = require("@hig/flyout"); export const { anchorPoints } = higflyout; export const Flyout = higflyout.default; -library.add(faEdit); +library.add(faCog); type SelectorProps = { Document: Doc, - Views: DocumentView[], Stack?: any, - addDocTab(doc: Doc, dataDoc: Doc | undefined, location: string): void + addDocTab(doc: Doc, location: string): void }; @observer export class SelectorContextMenu extends React.Component<SelectorProps> { @observable private _docs: { col: Doc, target: Doc }[] = []; @observable private _otherDocs: { col: Doc, target: Doc }[] = []; + _reaction: IReactionDisposer | undefined; - constructor(props: SelectorProps) { - super(props); - - this.fetchDocuments(); + componentDidMount() { + this._reaction = reaction(() => this.props.Document, () => this.fetchDocuments(), { fireImmediately: true }); + } + componentWillUnmount() { + this._reaction?.(); } - async fetchDocuments() { const aliases = (await SearchUtil.GetAliasesOfDocument(this.props.Document)).filter(doc => doc !== this.props.Document); const { docs } = await SearchUtil.Search("", true, { fq: `data_l:"${this.props.Document[Id]}"` }); @@ -55,13 +55,13 @@ export class SelectorContextMenu extends React.Component<SelectorProps> { getOnClick({ col, target }: { col: Doc, target: Doc }) { return () => { col = Doc.IsPrototype(col) ? Doc.MakeDelegate(col) : col; - if (NumCast(col._viewType, CollectionViewType.Invalid) === CollectionViewType.Freeform) { + if (col._viewType === CollectionViewType.Freeform) { const newPanX = NumCast(target.x) + NumCast(target._width) / 2; const newPanY = NumCast(target.y) + NumCast(target._height) / 2; col._panX = newPanX; col._panY = newPanY; } - this.props.addDocTab(col, undefined, "inTab"); // bcz: dataDoc? + this.props.addDocTab(col, "inTab"); // bcz: dataDoc? }; } @@ -79,13 +79,12 @@ export class SelectorContextMenu extends React.Component<SelectorProps> { export class ParentDocSelector extends React.Component<SelectorProps> { render() { const flyout = ( - <div className="parentDocumentSelector-flyout" style={{}} title=" "> + <div className="parentDocumentSelector-flyout" title=" "> <SelectorContextMenu {...this.props} /> </div> ); - return <div title="Tap to View Contexts/Metadata" onPointerDown={e => e.stopPropagation()} className="parentDocumentSelector-linkFlyout"> - <Flyout anchorPoint={anchorPoints.LEFT_TOP} - content={flyout}> + return <div title="Show Contexts" onPointerDown={e => e.stopPropagation()} className="parentDocumentSelector-linkFlyout"> + <Flyout anchorPoint={anchorPoints.LEFT_TOP} content={flyout}> <span className="parentDocumentSelector-button" > <FontAwesomeIcon icon={faChevronCircleUp} size={"lg"} /> </span> @@ -95,14 +94,7 @@ export class ParentDocSelector extends React.Component<SelectorProps> { } @observer -export class ButtonSelector extends React.Component<{ Document: Doc, Stack: any }> { - @observable hover = false; - - @action - onPointerDown = (e: React.PointerEvent) => { - this.hover = !this.hover; - e.stopPropagation(); - } +export class DockingViewButtonSelector extends React.Component<{ views: DocumentView[], Stack: any }> { customStylesheet(styles: any) { return { ...styles, @@ -112,17 +104,26 @@ export class ButtonSelector extends React.Component<{ Document: Doc, Stack: any }, }; } + _ref = React.createRef<HTMLDivElement>(); - render() { - const view = DocumentManager.Instance.getDocumentView(this.props.Document); - const flyout = ( - <div className="ParentDocumentSelector-flyout" title=" "> - <DocumentButtonBar views={[view]} stack={this.props.Stack} /> + @computed get flyout() { + return ( + <div className="ParentDocumentSelector-flyout" title=" " ref={this._ref}> + <DocumentButtonBar views={this.props.views} stack={this.props.Stack} /> </div> ); - return <span title="Tap for menu" onPointerDown={e => e.stopPropagation()} className="buttonSelector"> - <Flyout anchorPoint={anchorPoints.LEFT_TOP} content={flyout} stylesheet={this.customStylesheet}> - <FontAwesomeIcon icon={faEdit} size={"sm"} /> + } + + render() { + return <span title="Tap for menu, drag tab as document" + onPointerDown={e => { + if (getComputedStyle(this._ref.current!).width !== "100%") { + e.stopPropagation(); e.preventDefault(); + } + this.props.views[0]?.select(false); + }} className="buttonSelector"> + <Flyout anchorPoint={anchorPoints.LEFT_TOP} content={this.flyout} stylesheet={this.customStylesheet}> + <FontAwesomeIcon icon={"cog"} size={"sm"} /> </Flyout> </span>; } diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx index baf09fe5b..9a864078a 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx @@ -1,4 +1,4 @@ -import { Doc, Field, FieldResult } from "../../../../new_fields/Doc"; +import { Doc, Field, FieldResult, WidthSym, HeightSym } from "../../../../new_fields/Doc"; import { NumCast, StrCast, Cast } from "../../../../new_fields/Types"; import { ScriptBox } from "../../ScriptBox"; import { CompileScript } from "../../../util/Scripting"; @@ -9,13 +9,15 @@ import React = require("react"); import { Id, ToString } from "../../../../new_fields/FieldSymbols"; import { ObjectField } from "../../../../new_fields/ObjectField"; import { RefField } from "../../../../new_fields/RefField"; +import { listSpec } from "../../../../new_fields/Schema"; export interface ViewDefBounds { type: string; - text?: string; + payload: any; x: number; y: number; z?: number; + text?: string; zIndex?: number; width?: number; height?: number; @@ -23,19 +25,22 @@ export interface ViewDefBounds { fontSize?: number; highlight?: boolean; color?: string; - payload: any; + replica?: string; + pair?: { layout: Doc, data?: Doc }; } export interface PoolData { - x?: number, - y?: number, - z?: number, - zIndex?: number, - width?: number, - height?: number, - color?: string, - transition?: string, - highlight?: boolean, + x: number; + y: number; + z?: number; + zIndex?: number; + width?: number; + height?: number; + color?: string; + transition?: string; + highlight?: boolean; + replica: string; + pair: { layout: Doc, data?: Doc }; } export interface ViewDefResult { @@ -43,6 +48,11 @@ export interface ViewDefResult { bounds?: ViewDefBounds; } function toLabel(target: FieldResult<Field>) { + if (typeof target === "number" || Number(target)) { + const truncated = Number(Number(target).toFixed(0)); + const precise = Number(Number(target).toFixed(2)); + return truncated === precise ? Number(target).toFixed(0) : Number(target).toFixed(2); + } if (target instanceof ObjectField || target instanceof RefField) { return target[ToString](); } @@ -58,47 +68,112 @@ function toLabel(target: FieldResult<Field>) { */ function getTextWidth(text: string, font: string): number { // re-use canvas object for better performance - var canvas = (getTextWidth as any).canvas || ((getTextWidth as any).canvas = document.createElement("canvas")); - var context = canvas.getContext("2d"); + const canvas = (getTextWidth as any).canvas || ((getTextWidth as any).canvas = document.createElement("canvas")); + const context = canvas.getContext("2d"); context.font = font; - var metrics = context.measureText(text); + const metrics = context.measureText(text); return metrics.width; } -interface pivotColumn { - docs: Doc[], - filters: string[] +interface PivotColumn { + docs: Doc[]; + replicas: string[]; + filters: string[]; +} + +export function computerPassLayout( + poolData: Map<string, PoolData>, + pivotDoc: Doc, + childPairs: { layout: Doc, data?: Doc }[], + panelDim: number[], + viewDefsToJSX: (views: ViewDefBounds[]) => ViewDefResult[] +) { + const docMap = new Map<string, PoolData>(); + childPairs.forEach(({ layout, data }, i) => { + docMap.set(layout[Id], { + x: NumCast(layout.x), + y: NumCast(layout.y), + width: layout[WidthSym](), + height: layout[HeightSym](), + pair: { layout, data }, + replica: "" + }); + }); + return normalizeResults(panelDim, 12, docMap, poolData, viewDefsToJSX, [], 0, []); +} + +export function computerStarburstLayout( + poolData: Map<string, PoolData>, + pivotDoc: Doc, + childPairs: { layout: Doc, data?: Doc }[], + panelDim: number[], + viewDefsToJSX: (views: ViewDefBounds[]) => ViewDefResult[] +) { + const docMap = new Map<string, PoolData>(); + const burstRadius = [NumCast(pivotDoc._starburstRadius, panelDim[0]), NumCast(pivotDoc._starburstRadius, panelDim[1])]; + const docScale = NumCast(pivotDoc._starburstDocScale); + const docSize = docScale * 100; // assume a icon sized at 100 + const scaleDim = [burstRadius[0] + docSize, burstRadius[1] + docSize]; + childPairs.forEach(({ layout, data }, i) => { + const deg = i / childPairs.length * Math.PI * 2; + docMap.set(layout[Id], { + x: Math.cos(deg) * (burstRadius[0] / 3) - docScale * layout[WidthSym]() / 2, + y: Math.sin(deg) * (burstRadius[1] / 3) - docScale * layout[HeightSym]() / 2, + width: docScale * layout[WidthSym](), + height: docScale * layout[HeightSym](), + pair: { layout, data }, + replica: "" + }); + }); + return normalizeResults(scaleDim, 12, docMap, poolData, viewDefsToJSX, [], 0, []); } export function computePivotLayout( poolData: Map<string, PoolData>, pivotDoc: Doc, - childDocs: Doc[], - filterDocs: Doc[], childPairs: { layout: Doc, data?: Doc }[], panelDim: number[], viewDefsToJSX: (views: ViewDefBounds[]) => ViewDefResult[] ) { + const docMap = new Map<string, PoolData>(); const fieldKey = "data"; - const pivotColumnGroups = new Map<FieldResult<Field>, pivotColumn>(); + const pivotColumnGroups = new Map<FieldResult<Field>, PivotColumn>(); const pivotFieldKey = toLabel(pivotDoc._pivotField); - for (const doc of filterDocs) { - const val = Field.toString(doc[pivotFieldKey] as Field); - if (val) { - !pivotColumnGroups.get(val) && pivotColumnGroups.set(val, { docs: [], filters: [val] }); - pivotColumnGroups.get(val)!.docs.push(doc); + childPairs.map(pair => { + const lval = Cast(pair.layout[pivotFieldKey], listSpec("string"), null); + const val = Field.toString(pair.layout[pivotFieldKey] as Field); + if (lval) { + lval.forEach((val, i) => { + !pivotColumnGroups.get(val) && pivotColumnGroups.set(val, { docs: [], filters: [val], replicas: [] }); + pivotColumnGroups.get(val)!.docs.push(pair.layout); + pivotColumnGroups.get(val)!.replicas.push(i.toString()); + }); + } else if (val) { + !pivotColumnGroups.get(val) && pivotColumnGroups.set(val, { docs: [], filters: [val], replicas: [] }); + pivotColumnGroups.get(val)!.docs.push(pair.layout); + pivotColumnGroups.get(val)!.replicas.push(""); + } else { + docMap.set(pair.layout[Id], { + x: 0, + y: 0, + zIndex: -99, + width: 0, + height: 0, + pair, + replica: "" + }); } - } + }); let nonNumbers = 0; - childDocs.map(doc => { - const num = toNumber(doc[pivotFieldKey]); + childPairs.map(pair => { + const num = toNumber(pair.layout[pivotFieldKey]); if (num === undefined || Number.isNaN(num)) { nonNumbers++; } }); - const pivotNumbers = nonNumbers / childDocs.length < .1; + const pivotNumbers = nonNumbers / childPairs.length < .1; if (pivotColumnGroups.size > 10) { const arrayofKeys = Array.from(pivotColumnGroups.keys()); const sortedKeys = pivotNumbers ? arrayofKeys.sort((n1: FieldResult, n2: FieldResult) => toNumber(n1)! - toNumber(n2)!) : arrayofKeys.sort(); @@ -110,6 +185,7 @@ export function computePivotLayout( const newgrp = pivotColumnGroups.get(sortedKeys[j])!; curgrp.docs.push(...newgrp.docs); curgrp.filters.push(...newgrp.filters); + curgrp.replicas.push(...newgrp.replicas); pivotColumnGroups.delete(sortedKeys[j]); } } @@ -118,7 +194,7 @@ export function computePivotLayout( const desc = `${fontSize}px ${getComputedStyle(document.body).fontFamily}`; const textlen = Array.from(pivotColumnGroups.keys()).map(c => getTextWidth(toLabel(c), desc)).reduce((p, c) => Math.max(p, c), 0 as number); const max_text = Math.min(Math.ceil(textlen / 120) * 28, panelDim[1] / 2); - let maxInColumn = Array.from(pivotColumnGroups.values()).reduce((p, s) => Math.max(p, s.docs.length), 1); + const maxInColumn = Array.from(pivotColumnGroups.values()).reduce((p, s) => Math.max(p, s.docs.length), 1); const colWidth = panelDim[0] / pivotColumnGroups.size; const colHeight = panelDim[1] - max_text; @@ -137,11 +213,11 @@ export function computePivotLayout( } } - const docMap = new Map<Doc, ViewDefBounds>(); const groupNames: ViewDefBounds[] = []; const expander = 1.05; const gap = .15; + const maxColHeight = pivotAxisWidth * expander * Math.ceil(maxInColumn / numCols); let x = 0; const sortedPivotKeys = pivotNumbers ? Array.from(pivotColumnGroups.keys()).sort((n1: FieldResult, n2: FieldResult) => toNumber(n1)! - toNumber(n2)!) : Array.from(pivotColumnGroups.keys()).sort(); sortedPivotKeys.forEach(key => { @@ -159,7 +235,7 @@ export function computePivotLayout( fontSize, payload: val }); - for (const doc of val.docs) { + val.docs.forEach((doc, i) => { const layoutDoc = Doc.Layout(doc); let wid = pivotAxisWidth; let hgt = layoutDoc._nativeWidth ? (NumCast(layoutDoc._nativeHeight) / NumCast(layoutDoc._nativeWidth)) * pivotAxisWidth : pivotAxisWidth; @@ -167,28 +243,27 @@ export function computePivotLayout( hgt = pivotAxisWidth; wid = layoutDoc._nativeHeight ? (NumCast(layoutDoc._nativeWidth) / NumCast(layoutDoc._nativeHeight)) * pivotAxisWidth : pivotAxisWidth; } - docMap.set(doc, { - type: "doc", + docMap.set(doc[Id] + (val.replicas || ""), { x: x + xCount * pivotAxisWidth * expander + (pivotAxisWidth - wid) / 2 + (val.docs.length < numCols ? (numCols - val.docs.length) * pivotAxisWidth / 2 : 0), y: -y + (pivotAxisWidth - hgt) / 2, width: wid, height: hgt, - payload: undefined + pair: { layout: doc }, + replica: val.replicas[i] }); xCount++; if (xCount >= numCols) { xCount = 0; y += pivotAxisWidth * expander; } - } + }); x += pivotAxisWidth * (numCols * expander + gap); }); - const maxColHeight = pivotAxisWidth * expander * Math.ceil(maxInColumn / numCols); const dividers = sortedPivotKeys.map((key, i) => - ({ type: "div", color: "lightGray", x: i * pivotAxisWidth * (numCols * expander + gap), y: -maxColHeight + pivotAxisWidth, width: pivotAxisWidth * numCols * expander, height: maxColHeight, payload: pivotColumnGroups.get(key)!.filters })); + ({ type: "div", color: "lightGray", x: i * pivotAxisWidth * (numCols * expander + gap) - pivotAxisWidth * (expander - 1) / 2, y: -maxColHeight + pivotAxisWidth, width: pivotAxisWidth * numCols * expander, height: maxColHeight, payload: pivotColumnGroups.get(key)!.filters })); groupNames.push(...dividers); - return normalizeResults(panelDim, max_text, childPairs, docMap, poolData, viewDefsToJSX, groupNames, 0, [], childDocs.filter(c => !filterDocs.includes(c))); + return normalizeResults(panelDim, max_text, docMap, poolData, viewDefsToJSX, groupNames, 0, []); } function toNumber(val: FieldResult<Field>) { @@ -198,35 +273,33 @@ function toNumber(val: FieldResult<Field>) { export function computeTimelineLayout( poolData: Map<string, PoolData>, pivotDoc: Doc, - childDocs: Doc[], - filterDocs: Doc[], childPairs: { layout: Doc, data?: Doc }[], panelDim: number[], viewDefsToJSX: (views: ViewDefBounds[]) => ViewDefResult[] ) { const fieldKey = "data"; const pivotDateGroups = new Map<number, Doc[]>(); - const docMap = new Map<Doc, ViewDefBounds>(); + const docMap = new Map<string, PoolData>(); const groupNames: ViewDefBounds[] = []; const timelineFieldKey = Field.toString(pivotDoc._pivotField as Field); const curTime = toNumber(pivotDoc[fieldKey + "-timelineCur"]); const curTimeSpan = Cast(pivotDoc[fieldKey + "-timelineSpan"], "number", null); - const minTimeReq = curTime === undefined ? Cast(pivotDoc[fieldKey + "-timelineMinReq"], "number", null) : curTimeSpan && (curTime - curTimeSpan); - const maxTimeReq = curTime === undefined ? Cast(pivotDoc[fieldKey + "-timelineMaxReq"], "number", null) : curTimeSpan && (curTime + curTimeSpan); + const minTimeReq = curTimeSpan === undefined ? Cast(pivotDoc[fieldKey + "-timelineMinReq"], "number", null) : curTime && (curTime - curTimeSpan); + const maxTimeReq = curTimeSpan === undefined ? Cast(pivotDoc[fieldKey + "-timelineMaxReq"], "number", null) : curTime && (curTime + curTimeSpan); const fontSize = NumCast(pivotDoc[fieldKey + "-timelineFontSize"], panelDim[1] > 58 ? 20 : Math.max(7, panelDim[1] / 3)); const fontHeight = panelDim[1] > 58 ? 30 : panelDim[1] / 2; const findStack = (time: number, stack: number[]) => { const index = stack.findIndex(val => val === undefined || val < x); return index === -1 ? stack.length : index; - } + }; - let minTime = Number.MAX_VALUE; - let maxTime = -Number.MAX_VALUE; - filterDocs.map(doc => { - const num = NumCast(doc[timelineFieldKey], Number(StrCast(doc[timelineFieldKey]))); - if (!(Number.isNaN(num) || (minTimeReq && num < minTimeReq) || (maxTimeReq && num > maxTimeReq))) { + let minTime = minTimeReq === undefined ? Number.MAX_VALUE : minTimeReq; + let maxTime = maxTimeReq === undefined ? -Number.MAX_VALUE : maxTimeReq; + childPairs.forEach(pair => { + const num = NumCast(pair.layout[timelineFieldKey], Number(StrCast(pair.layout[timelineFieldKey]))); + if (!Number.isNaN(num) && (!minTimeReq || num >= minTimeReq) && (!maxTimeReq || num <= maxTimeReq)) { !pivotDateGroups.get(num) && pivotDateGroups.set(num, []); - pivotDateGroups.get(num)!.push(doc); + pivotDateGroups.get(num)!.push(pair.layout); minTime = Math.min(num, minTime); maxTime = Math.max(num, maxTime); } @@ -254,38 +327,38 @@ export function computeTimelineLayout( let prevKey = Math.floor(minTime); if (sortedKeys.length && scaling * (sortedKeys[0] - prevKey) > 25) { - groupNames.push({ type: "text", text: prevKey.toString(), x: x, y: 0, height: fontHeight, fontSize, payload: undefined }); + groupNames.push({ type: "text", text: toLabel(prevKey), x: x, y: 0, height: fontHeight, fontSize, payload: undefined }); } if (!sortedKeys.length && curTime !== undefined) { - groupNames.push({ type: "text", text: curTime.toString(), x: (curTime - minTime) * scaling, zIndex: 1000, color: "orange", y: 0, height: fontHeight, fontSize, payload: undefined }); + groupNames.push({ type: "text", text: toLabel(curTime), x: (curTime - minTime) * scaling, zIndex: 1000, color: "orange", y: 0, height: fontHeight, fontSize, payload: undefined }); } const pivotAxisWidth = NumCast(pivotDoc.pivotTimeWidth, panelDim[1] / 2.5); - let stacking: number[] = []; + const stacking: number[] = []; let zind = 0; sortedKeys.forEach(key => { if (curTime !== undefined && curTime > prevKey && curTime <= key) { - groupNames.push({ type: "text", text: curTime.toString(), x: (curTime - minTime) * scaling, y: 0, zIndex: 1000, color: "orange", height: fontHeight, fontSize, payload: key }); + groupNames.push({ type: "text", text: toLabel(curTime), x: (curTime - minTime) * scaling, y: 0, zIndex: 1000, color: "orange", height: fontHeight, fontSize, payload: key }); } const keyDocs = pivotDateGroups.get(key)!; x += scaling * (key - prevKey); const stack = findStack(x, stacking); prevKey = key; if (!stack && (curTime === undefined || Math.abs(x - (curTime - minTime) * scaling) > pivotAxisWidth)) { - groupNames.push({ type: "text", text: key.toString(), x: x, y: stack * 25, height: fontHeight, fontSize, payload: undefined }); + groupNames.push({ type: "text", text: toLabel(key), x: x, y: stack * 25, height: fontHeight, fontSize, payload: undefined }); } layoutDocsAtTime(keyDocs, key); }); if (sortedKeys.length && curTime !== undefined && curTime > sortedKeys[sortedKeys.length - 1]) { x = (curTime - minTime) * scaling; - groupNames.push({ type: "text", text: curTime.toString(), x: x, y: 0, zIndex: 1000, color: "orange", height: fontHeight, fontSize, payload: undefined }); + groupNames.push({ type: "text", text: toLabel(curTime), x: x, y: 0, zIndex: 1000, color: "orange", height: fontHeight, fontSize, payload: undefined }); } if (Math.ceil(maxTime - minTime) * scaling > x + 25) { - groupNames.push({ type: "text", text: Math.ceil(maxTime).toString(), x: Math.ceil(maxTime - minTime) * scaling, y: 0, height: fontHeight, fontSize, payload: undefined }); + groupNames.push({ type: "text", text: toLabel(Math.ceil(maxTime)), x: Math.ceil(maxTime - minTime) * scaling, y: 0, height: fontHeight, fontSize, payload: undefined }); } - const divider = { type: "div", color: "black", x: 0, y: 0, width: panelDim[0], height: 1, payload: undefined }; - return normalizeResults(panelDim, fontHeight, childPairs, docMap, poolData, viewDefsToJSX, groupNames, (maxTime - minTime) * scaling, [divider], childDocs.filter(c => !filterDocs.includes(c))); + const divider = { type: "div", color: Cast(Doc.UserDoc().activeWorkspace, Doc, null)?.darkScheme ? "dimGray" : "black", x: 0, y: 0, width: panelDim[0], height: -1, payload: undefined }; + return normalizeResults(panelDim, fontHeight, docMap, poolData, viewDefsToJSX, groupNames, (maxTime - minTime) * scaling, [divider]); function layoutDocsAtTime(keyDocs: Doc[], key: number) { keyDocs.forEach(doc => { @@ -297,58 +370,67 @@ export function computeTimelineLayout( hgt = pivotAxisWidth; wid = layoutDoc._nativeHeight ? (NumCast(layoutDoc._nativeWidth) / NumCast(layoutDoc._nativeHeight)) * pivotAxisWidth : pivotAxisWidth; } - docMap.set(doc, { - type: "doc", + docMap.set(doc[Id], { x: x, y: -Math.sqrt(stack) * pivotAxisWidth / 2 - pivotAxisWidth + (pivotAxisWidth - hgt) / 2, - zIndex: (curTime === key ? 1000 : zind++), highlight: curTime === key, width: wid / (Math.max(stack, 1)), height: hgt, payload: undefined + zIndex: (curTime === key ? 1000 : zind++), + highlight: curTime === key, + width: wid / (Math.max(stack, 1)), + height: hgt / (Math.max(stack, 1)), + pair: { layout: doc }, + replica: "" }); stacking[stack] = x + pivotAxisWidth; }); } } -function normalizeResults(panelDim: number[], fontHeight: number, childPairs: { data?: Doc, layout: Doc }[], docMap: Map<Doc, ViewDefBounds>, - poolData: Map<string, PoolData>, viewDefsToJSX: (views: ViewDefBounds[]) => ViewDefResult[], groupNames: ViewDefBounds[], minWidth: number, extras: ViewDefBounds[], - extraDocs: Doc[]) { - +function normalizeResults( + panelDim: number[], + fontHeight: number, + docMap: Map<string, PoolData>, + poolData: Map<string, PoolData>, + viewDefsToJSX: (views: ViewDefBounds[]) => ViewDefResult[], + groupNames: ViewDefBounds[], + minWidth: number, + extras: ViewDefBounds[] +): ViewDefResult[] { const grpEles = groupNames.map(gn => ({ x: gn.x, y: gn.y, width: gn.width, height: gn.height }) as ViewDefBounds); - const docEles = childPairs.filter(d => docMap.get(d.layout)).map(pair => docMap.get(pair.layout) as ViewDefBounds); - const aggBounds = aggregateBounds(docEles.concat(grpEles), 0, 0); + const docEles = Array.from(docMap.entries()).map(ele => ele[1]); + const aggBounds = aggregateBounds(grpEles.concat(docEles.map(de => ({ ...de, type: "doc", payload: "" }))).filter(e => e.zIndex !== -99), 0, 0); aggBounds.r = Math.max(minWidth, aggBounds.r - aggBounds.x); const wscale = panelDim[0] / (aggBounds.r - aggBounds.x); let scale = wscale * (aggBounds.b - aggBounds.y) > panelDim[1] ? (panelDim[1]) / (aggBounds.b - aggBounds.y) : wscale; if (Number.isNaN(scale)) scale = 1; - childPairs.filter(d => docMap.get(d.layout)).map(pair => { - const newPosRaw = docMap.get(pair.layout); + Array.from(docMap.entries()).filter(ele => ele[1].pair).map(ele => { + const newPosRaw = ele[1]; if (newPosRaw) { const newPos = { x: newPosRaw.x * scale, y: newPosRaw.y * scale, z: newPosRaw.z, + replica: newPosRaw.replica, highlight: newPosRaw.highlight, zIndex: newPosRaw.zIndex, width: (newPosRaw.width || 0) * scale, - height: newPosRaw.height! * scale + height: newPosRaw.height! * scale, + pair: ele[1].pair }; - poolData.set(pair.layout[Id], { transition: "transform 1s", ...newPos }); + poolData.set(newPos.pair.layout[Id] + (newPos.replica || ""), { transition: "transform 1s", ...newPos }); } }); - extraDocs.map(ed => poolData.set(ed[Id], { x: 0, y: 0, zIndex: -99 })); - return { - elements: viewDefsToJSX(extras.concat(groupNames.map(gname => ({ - type: gname.type, - text: gname.text, - x: gname.x * scale, - y: gname.y * scale, - color: gname.color, - width: gname.width === undefined ? undefined : gname.width * scale, - height: Math.max(fontHeight, (gname.height || 0) * scale), - fontSize: gname.fontSize, - payload: gname.payload - })))) - }; + return viewDefsToJSX(extras.concat(groupNames).map(gname => ({ + type: gname.type, + text: gname.text, + x: gname.x * scale, + y: gname.y * scale, + color: gname.color, + width: gname.width === undefined ? undefined : gname.width * scale, + height: gname.height === -1 ? 1 : gname.type === "text" ? Math.max(fontHeight * scale, (gname.height || 0) * scale) : (gname.height || 0) * scale, + fontSize: gname.fontSize, + payload: gname.payload + }))); } export function AddCustomFreeFormLayout(doc: Doc, dataKey: string): () => void { diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.scss b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.scss index 75af11537..05111adb4 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.scss +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.scss @@ -4,6 +4,7 @@ pointer-events: all; stroke-width: 3px; transition: opacity 0.5s ease-in; + fill: transparent; } .collectionfreeformlinkview-linkCircle { stroke: rgb(0,0,0); diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx index f04b79ea4..cf12ef382 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx @@ -25,9 +25,9 @@ export class CollectionFreeFormLinkView extends React.Component<CollectionFreeFo this._anchorDisposer = reaction(() => [this.props.A.props.ScreenToLocalTransform(), this.props.B.props.ScreenToLocalTransform(), this.props.A.isSelected() || Doc.IsBrushed(this.props.A.props.Document), this.props.A.isSelected() || Doc.IsBrushed(this.props.A.props.Document)], action(() => { setTimeout(action(() => this._opacity = 1), 0); // since the render code depends on querying the Dom through getBoudndingClientRect, we need to delay triggering render() - setTimeout(action(() => this._opacity = 0.05), 750); // this will unhighlight the link line. - const acont = this.props.A.props.Document.type === DocumentType.LINK ? this.props.A.ContentDiv!.getElementsByClassName("docuLinkBox-cont") : []; - const bcont = this.props.B.props.Document.type === DocumentType.LINK ? this.props.B.ContentDiv!.getElementsByClassName("docuLinkBox-cont") : []; + setTimeout(action(() => (!this.props.LinkDocs.length || !this.props.LinkDocs[0].linkDisplay) && (this._opacity = 0.05)), 750); // this will unhighlight the link line. + const acont = this.props.A.props.Document.type === DocumentType.LINK ? this.props.A.ContentDiv!.getElementsByClassName("linkAnchorBox-cont") : []; + const bcont = this.props.B.props.Document.type === DocumentType.LINK ? this.props.B.ContentDiv!.getElementsByClassName("linkAnchorBox-cont") : []; const adiv = (acont.length ? acont[0] : this.props.A.ContentDiv!); const bdiv = (bcont.length ? bcont[0] : this.props.B.ContentDiv!); const a = adiv.getBoundingClientRect(); @@ -43,11 +43,11 @@ export class CollectionFreeFormLinkView extends React.Component<CollectionFreeFo const afield = StrCast(this.props.A.props.Document[StrCast(this.props.A.props.layoutKey, "layout")]).indexOf("anchor1") === -1 ? "anchor2" : "anchor1"; const bfield = afield === "anchor1" ? "anchor2" : "anchor1"; - // really hacky stuff to make the DocuLinkBox display where we want it to: + // really hacky stuff to make the LinkAnchorBox display where we want it to: // if there's an element in the DOM with the id of the opposite anchor, then that DOM element is a hyperlink source for the current anchor and we want to place our link box at it's top right // otherwise, we just use the computed nearest point on the document boundary to the target Document - const targetAhyperlink = window.document.getElementById((this.props.LinkDocs[0][afield] as Doc)[Id]); - const targetBhyperlink = window.document.getElementById((this.props.LinkDocs[0][bfield] as Doc)[Id]); + const targetAhyperlink = window.document.getElementById(this.props.LinkDocs[0][Id] + (this.props.LinkDocs[0][afield] as Doc)[Id]); + const targetBhyperlink = window.document.getElementById(this.props.LinkDocs[0][Id] + (this.props.LinkDocs[0][bfield] as Doc)[Id]); if (!targetBhyperlink) { this.props.A.props.Document[afield + "_x"] = (apt.point.x - abounds.left) / abounds.width * 100; this.props.A.props.Document[afield + "_y"] = (apt.point.y - abounds.top) / abounds.height * 100; @@ -81,8 +81,9 @@ export class CollectionFreeFormLinkView extends React.Component<CollectionFreeFo } render() { - const acont = this.props.A.props.Document.type === DocumentType.LINK ? this.props.A.ContentDiv!.getElementsByClassName("docuLinkBox-cont") : []; - const bcont = this.props.B.props.Document.type === DocumentType.LINK ? this.props.B.ContentDiv!.getElementsByClassName("docuLinkBox-cont") : []; + this.props.A.props.ScreenToLocalTransform().transform(this.props.B.props.ScreenToLocalTransform()); + const acont = this.props.A.props.Document.type === DocumentType.LINK ? this.props.A.ContentDiv!.getElementsByClassName("linkAnchorBox-cont") : []; + const bcont = this.props.B.props.Document.type === DocumentType.LINK ? this.props.B.ContentDiv!.getElementsByClassName("linkAnchorBox-cont") : []; const a = (acont.length ? acont[0] : this.props.A.ContentDiv!).getBoundingClientRect(); const b = (bcont.length ? bcont[0] : this.props.B.ContentDiv!).getBoundingClientRect(); const apt = Utils.closestPtBetweenRectangles(a.left, a.top, a.width, a.height, @@ -93,12 +94,26 @@ export class CollectionFreeFormLinkView extends React.Component<CollectionFreeFo 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] - (a.left + a.width / 2), pt1[1] - (a.top + a.height / 2)]; + const pt2vec = [pt2[0] - (b.left + b.width / 2), pt2[1] - (b.top + b.height / 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])) / 3; + const pt1norm = [pt1vec[0] / pt1len * ptlen, pt1vec[1] / pt1len * ptlen]; + const pt2norm = [pt2vec[0] / pt2len * ptlen, pt2vec[1] / pt2len * ptlen]; const aActive = this.props.A.isSelected() || Doc.IsBrushed(this.props.A.props.Document); const bActive = this.props.A.isSelected() || Doc.IsBrushed(this.props.A.props.Document); - return !aActive && !bActive ? (null) : - <line key="linkLine" className="collectionfreeformlinkview-linkLine" + const text = StrCast(this.props.A.props.Document.linkRelationship); + return !a.width || !b.width || ((!this.props.LinkDocs.length || !this.props.LinkDocs[0].linkDisplay) && !aActive && !bActive) ? (null) : (<> + <text x={(pt1[0] + pt2[0]) / 2} y={(pt1[1] + pt2[1]) / 2}> + {text !== "-ungrouped-" ? text : ""} + </text> + <path className="collectionfreeformlinkview-linkLine" style={{ opacity: this._opacity, strokeDasharray: "2 2" }} + d={`M ${pt1[0]} ${pt1[1]} C ${pt1[0] + pt1norm[0]} ${pt1[1] + pt1norm[1]}, ${pt2[0] + pt2norm[0]} ${pt2[1] + pt2norm[1]}, ${pt2[0]} ${pt2[1]}`} /> + {/* <line key="linkLine" className="collectionfreeformlinkview-linkLine" style={{ opacity: this._opacity, strokeDasharray: "2 2" }} x1={`${pt1[0]}`} y1={`${pt1[1]}`} - x2={`${pt2[0]}`} y2={`${pt2[1]}`} />; + x2={`${pt2[0]}`} y2={`${pt2[1]}`} /> */} + </>); } }
\ No newline at end of file diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.tsx index 044d35eca..4b5e977df 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.tsx @@ -1,4 +1,4 @@ -import { computed, IReactionDisposer } from "mobx"; +import { computed } from "mobx"; import { observer } from "mobx-react"; import { Doc } from "../../../../new_fields/Doc"; import { Id } from "../../../../new_fields/FieldSymbols"; @@ -7,69 +7,12 @@ import { DocumentView } from "../../nodes/DocumentView"; import "./CollectionFreeFormLinksView.scss"; import { CollectionFreeFormLinkView } from "./CollectionFreeFormLinkView"; import React = require("react"); -import { Utils } from "../../../../Utils"; +import { Utils, emptyFunction } from "../../../../Utils"; import { SelectionManager } from "../../../util/SelectionManager"; import { DocumentType } from "../../../documents/DocumentTypes"; @observer export class CollectionFreeFormLinksView extends React.Component { - - _brushReactionDisposer?: IReactionDisposer; - componentDidMount() { - // this._brushReactionDisposer = reaction( - // () => { - // let doclist = DocListCast(this.props.Document[this.props.fieldKey]); - // return { doclist: doclist ? doclist : [], xs: doclist.map(d => d.x) }; - // }, - // () => { - // let doclist = DocListCast(this.props.Document[this.props.fieldKey]); - // let views = doclist ? doclist.filter(doc => StrCast(doc.backgroundLayout).indexOf("istogram") !== -1) : []; - // views.forEach((dstDoc, i) => { - // views.forEach((srcDoc, j) => { - // let dstTarg = dstDoc; - // let srcTarg = srcDoc; - // let x1 = NumCast(srcDoc.x); - // let x2 = NumCast(dstDoc.x); - // let x1w = NumCast(srcDoc.width, -1); - // let x2w = NumCast(dstDoc.width, -1); - // if (x1w < 0 || x2w < 0 || i === j) { } - // else { - // let findBrush = (field: (Doc | Promise<Doc>)[]) => field.findIndex(brush => { - // let bdocs = brush instanceof Doc ? Cast(brush.brushingDocs, listSpec(Doc), []) : undefined; - // return bdocs && bdocs.length && ((bdocs[0] === dstTarg && bdocs[1] === srcTarg)) ? true : false; - // }); - // let brushAction = (field: (Doc | Promise<Doc>)[]) => { - // let found = findBrush(field); - // if (found !== -1) { - // field.splice(found, 1); - // } - // }; - // if (Math.abs(x1 + x1w - x2) < 20) { - // let linkDoc: Doc = new Doc(); - // linkDoc.title = "Histogram Brush"; - // linkDoc.linkDescription = "Brush between " + StrCast(srcTarg.title) + " and " + StrCast(dstTarg.Title); - // linkDoc.brushingDocs = new List([dstTarg, srcTarg]); - - // brushAction = (field: (Doc | Promise<Doc>)[]) => { - // if (findBrush(field) === -1) { - // field.push(linkDoc); - // } - // }; - // } - // if (dstTarg.brushingDocs === undefined) dstTarg.brushingDocs = new List<Doc>(); - // if (srcTarg.brushingDocs === undefined) srcTarg.brushingDocs = new List<Doc>(); - // let dstBrushDocs = Cast(dstTarg.brushingDocs, listSpec(Doc), []); - // let srcBrushDocs = Cast(srcTarg.brushingDocs, listSpec(Doc), []); - // brushAction(dstBrushDocs); - // brushAction(srcBrushDocs); - // } - // }); - // }); - // }); - } - componentWillUnmount() { - this._brushReactionDisposer && this._brushReactionDisposer(); - } @computed get uniqueConnections() { const connections = DocumentManager.Instance.LinkedDocumentViews.reduce((drawnPairs, connection) => { @@ -86,14 +29,16 @@ export class CollectionFreeFormLinksView extends React.Component { } return drawnPairs; }, [] as { a: DocumentView, b: DocumentView, l: Doc[] }[]); - return connections.filter(c => c.a.props.Document.type === DocumentType.LINK) // get rid of the filter to show links to documents in addition to document anchors - .map(c => <CollectionFreeFormLinkView key={Utils.GenerateGuid()} A={c.a} B={c.b} LinkDocs={c.l} />); + return connections.filter(c => + c.a.props.layoutKey && c.b.props.layoutKey && c.a.props.Document.type === DocumentType.LINK && + c.a.props.bringToFront !== emptyFunction && c.b.props.bringToFront !== emptyFunction // bcz: this prevents links to be drawn to anchors in CollectionTree views -- this is a hack that should be fixed + ).map(c => <CollectionFreeFormLinkView key={Utils.GenerateGuid()} A={c.a} B={c.b} LinkDocs={c.l} />); } render() { - return <div className="collectionfreeformlinksview-container"> + return SelectionManager.GetIsDragging() ? (null) : <div className="collectionfreeformlinksview-container"> <svg className="collectionfreeformlinksview-svgCanvas"> - {SelectionManager.GetIsDragging() ? (null) : this.uniqueConnections} + {this.uniqueConnections} </svg> {this.props.children} </div>; diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormRemoteCursors.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormRemoteCursors.tsx index bb9ae4326..92fa2781c 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormRemoteCursors.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormRemoteCursors.tsx @@ -8,74 +8,65 @@ import { CollectionViewProps } from "../CollectionSubView"; import "./CollectionFreeFormView.scss"; import React = require("react"); import v5 = require("uuid/v5"); +import { computed } from "mobx"; +import { FieldResult } from "../../../../new_fields/Doc"; +import { List } from "../../../../new_fields/List"; @observer export class CollectionFreeFormRemoteCursors extends React.Component<CollectionViewProps> { - protected getCursors(): CursorField[] { + @computed protected get cursors(): CursorField[] { const doc = this.props.Document; - const id = CurrentUserUtils.id; - if (!id) { + let cursors: FieldResult<List<CursorField>>; + const { id } = CurrentUserUtils; + if (!id || !(cursors = Cast(doc.cursors, listSpec(CursorField)))) { return []; } - - const cursors = Cast(doc.cursors, listSpec(CursorField)); - const now = mobxUtils.now(); - // const now = Date.now(); - return (cursors || []).filter(cursor => cursor.data.metadata.id !== id && (now - cursor.data.metadata.timestamp) < 1000); + return (cursors || []).filter(({ data: { metadata } }) => metadata.id !== id && (now - metadata.timestamp) < 1000); } - private crosshairs?: HTMLCanvasElement; - drawCrosshairs = (backgroundColor: string) => { - if (this.crosshairs) { - const ctx = this.crosshairs.getContext('2d'); - if (ctx) { - ctx.fillStyle = backgroundColor; - ctx.fillRect(0, 0, 20, 20); - - ctx.fillStyle = "black"; - ctx.lineWidth = 0.5; - - ctx.beginPath(); + @computed get renderedCursors() { + return this.cursors.map(({ data: { metadata, position: { x, y } } }) => { + return ( + <div key={metadata.id} className="collectionFreeFormRemoteCursors-cont" + style={{ transform: `translate(${x - 10}px, ${y - 10}px)` }} + > + <canvas className="collectionFreeFormRemoteCursors-canvas" + ref={(el) => { + if (el) { + const ctx = el.getContext('2d'); + if (ctx) { + ctx.fillStyle = "#" + v5(metadata.id, v5.URL).substring(0, 6).toUpperCase() + "22"; + ctx.fillRect(0, 0, 20, 20); - ctx.moveTo(10, 0); - ctx.lineTo(10, 8); + ctx.fillStyle = "black"; + ctx.lineWidth = 0.5; - ctx.moveTo(10, 20); - ctx.lineTo(10, 12); + ctx.beginPath(); - ctx.moveTo(0, 10); - ctx.lineTo(8, 10); + ctx.moveTo(10, 0); + ctx.lineTo(10, 8); - ctx.moveTo(20, 10); - ctx.lineTo(12, 10); + ctx.moveTo(10, 20); + ctx.lineTo(10, 12); - ctx.stroke(); + ctx.moveTo(0, 10); + ctx.lineTo(8, 10); - // ctx.font = "10px Arial"; - // ctx.fillText(Doc.CurrentUserEmail[0].toUpperCase(), 10, 10); - } - } - } + ctx.moveTo(20, 10); + ctx.lineTo(12, 10); - get sharedCursors() { - return this.getCursors().map(c => { - const m = c.data.metadata; - const l = c.data.position; - this.drawCrosshairs("#" + v5(m.id, v5.URL).substring(0, 6).toUpperCase() + "22"); - return ( - <div key={m.id} className="collectionFreeFormRemoteCursors-cont" - style={{ transform: `translate(${l.x - 10}px, ${l.y - 10}px)` }} - > - <canvas className="collectionFreeFormRemoteCursors-canvas" - ref={(el) => { if (el) this.crosshairs = el; }} + ctx.stroke(); + } + } + }} width={20} height={20} /> <p className="collectionFreeFormRemoteCursors-symbol"> - {m.identifier[0].toUpperCase()} + {metadata.identifier[0].toUpperCase()} </p> </div> ); @@ -83,6 +74,6 @@ export class CollectionFreeFormRemoteCursors extends React.Component<CollectionV } render() { - return this.sharedCursors; + return this.renderedCursors; } }
\ No newline at end of file diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.scss b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.scss index 0b5e44ccb..60c39c825 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.scss +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.scss @@ -9,10 +9,21 @@ height: 100%; transform-origin: left top; border-radius: inherit; + touch-action: none; + border-radius: inherit; +} + +.collectionfreeformview-viewdef { + > .collectionFreeFormDocumentView-container { + pointer-events: none; + .contentFittingDocumentDocumentView-previewDoc { + pointer-events: all; + } + } } .collectionfreeformview-ease { - transition: transform 1s; + transition: transform 500ms; } .collectionfreeformview-none { @@ -36,6 +47,8 @@ height: 100%; display: flex; align-items: center; + overflow: hidden; + .collectionfreeformview-placeholderSpan { font-size: 32; display: flex; @@ -99,4 +112,10 @@ #prevCursor { animation: blink 1s infinite; +} + +.pullpane-indicator { + z-index: 99999; + background-color: rgba($color: #000000, $alpha: .4); + position: absolute; }
\ No newline at end of file diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index 81fca3b54..b9e80bb43 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -1,22 +1,26 @@ import { library } from "@fortawesome/fontawesome-svg-core"; import { faEye } from "@fortawesome/free-regular-svg-icons"; import { faBraille, faChalkboard, faCompass, faCompressArrowsAlt, faExpandArrowsAlt, faFileUpload, faPaintBrush, faTable, faUpload } from "@fortawesome/free-solid-svg-icons"; -import { action, computed, observable, ObservableMap, reaction, runInAction, IReactionDisposer, trace } from "mobx"; +import { action, computed, IReactionDisposer, observable, ObservableMap, reaction, runInAction } from "mobx"; import { observer } from "mobx-react"; -import { Doc, DocListCast, HeightSym, Opt, WidthSym, DocListCastAsync, Field } from "../../../../new_fields/Doc"; +import { computedFn } from "mobx-utils"; +import { Doc, HeightSym, Opt, WidthSym, DocListCast } from "../../../../new_fields/Doc"; import { documentSchema, positionSchema } from "../../../../new_fields/documentSchemas"; import { Id } from "../../../../new_fields/FieldSymbols"; -import { InkTool, InkField, InkData } from "../../../../new_fields/InkField"; -import { createSchema, makeInterface, listSpec } from "../../../../new_fields/Schema"; +import { InkData, InkField, InkTool } from "../../../../new_fields/InkField"; +import { List } from "../../../../new_fields/List"; +import { RichTextField } from "../../../../new_fields/RichTextField"; +import { createSchema, listSpec, makeInterface } from "../../../../new_fields/Schema"; import { ScriptField } from "../../../../new_fields/ScriptField"; -import { BoolCast, Cast, DateCast, NumCast, StrCast, ScriptCast } from "../../../../new_fields/Types"; -import { CurrentUserUtils } from "../../../../server/authentication/models/current_user_utils"; -import { aggregateBounds, emptyFunction, intersectRect, returnOne, Utils } from "../../../../Utils"; +import { BoolCast, Cast, FieldValue, NumCast, ScriptCast, StrCast } from "../../../../new_fields/Types"; +import { TraceMobx } from "../../../../new_fields/util"; +import { GestureUtils } from "../../../../pen-gestures/GestureUtils"; +import { aggregateBounds, intersectRect, returnOne, Utils, returnZero, returnFalse } from "../../../../Utils"; +import { CognitiveServices } from "../../../cognitive_services/CognitiveServices"; import { DocServer } from "../../../DocServer"; -import { Docs, DocUtils } from "../../../documents/Documents"; -import { DocumentType } from "../../../documents/DocumentTypes"; +import { Docs } from "../../../documents/Documents"; import { DocumentManager } from "../../../util/DocumentManager"; -import { DragManager } from "../../../util/DragManager"; +import { DragManager, dropActionType } from "../../../util/DragManager"; import { HistoryUtil } from "../../../util/History"; import { InteractionUtils } from "../../../util/InteractionUtils"; import { SelectionManager } from "../../../util/SelectionManager"; @@ -27,21 +31,20 @@ import { ContextMenu } from "../../ContextMenu"; import { ContextMenuProps } from "../../ContextMenuItem"; import { InkingControl } from "../../InkingControl"; import { CollectionFreeFormDocumentView } from "../../nodes/CollectionFreeFormDocumentView"; -import { DocumentViewProps } from "../../nodes/DocumentView"; -import { FormattedTextBox } from "../../nodes/FormattedTextBox"; +import { DocumentViewProps, DocumentView } from "../../nodes/DocumentView"; +import { FormattedTextBox } from "../../nodes/formattedText/FormattedTextBox"; import { pageSchema } from "../../nodes/ImageBox"; import PDFMenu from "../../pdf/PDFMenu"; +import { CollectionDockingView } from "../CollectionDockingView"; import { CollectionSubView } from "../CollectionSubView"; -import { computePivotLayout, ViewDefResult, computeTimelineLayout, PoolData, ViewDefBounds } from "./CollectionFreeFormLayoutEngines"; +import { computePivotLayout, computeTimelineLayout, PoolData, ViewDefBounds, ViewDefResult, computerStarburstLayout, computerPassLayout } from "./CollectionFreeFormLayoutEngines"; import { CollectionFreeFormRemoteCursors } from "./CollectionFreeFormRemoteCursors"; import "./CollectionFreeFormView.scss"; import MarqueeOptionsMenu from "./MarqueeOptionsMenu"; import { MarqueeView } from "./MarqueeView"; import React = require("react"); -import { computedFn } from "mobx-utils"; -import { TraceMobx } from "../../../../new_fields/util"; +import { CollectionViewType } from "../CollectionView"; import { Timeline } from "../../animationtimeline/Timeline"; -import { GestureUtils } from "../../../../pen-gestures/GestureUtils"; library.add(faEye as any, faTable, faPaintBrush, faExpandArrowsAlt, faCompressArrowsAlt, faCompass, faUpload, faBraille, faChalkboard, faFileUpload); @@ -53,8 +56,8 @@ export const panZoomSchema = createSchema({ arrangeInit: ScriptField, useClusters: "boolean", fitToBox: "boolean", - xPadding: "number", // pixels of padding on left/right of collectionfreeformview contents when fitToBox is set - yPadding: "number", // pixels of padding on left/right of collectionfreeformview contents when fitToBox is set + _xPadding: "number", // pixels of padding on left/right of collectionfreeformview contents when fitToBox is set + _yPadding: "number", // pixels of padding on left/right of collectionfreeformview contents when fitToBox is set panTransformType: "string", scrollHeight: "number", fitX: "number", @@ -65,34 +68,51 @@ export const panZoomSchema = createSchema({ type PanZoomDocument = makeInterface<[typeof panZoomSchema, typeof documentSchema, typeof positionSchema, typeof pageSchema]>; const PanZoomDocument = makeInterface(panZoomSchema, documentSchema, positionSchema, pageSchema); +export type collectionFreeformViewProps = { + forceScaling?: boolean; // whether to force scaling of content (needed by ImageBox) + childClickScript?: ScriptField; + viewDefDivClick?: ScriptField; +}; @observer -export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { +export class CollectionFreeFormView extends CollectionSubView<PanZoomDocument, Partial<collectionFreeformViewProps>>(PanZoomDocument) { private _lastX: number = 0; private _lastY: number = 0; + private _downX: number = 0; + private _downY: number = 0; + private _inkToTextStartX: number | undefined; + private _inkToTextStartY: number | undefined; + private _wordPalette: Map<string, string> = new Map<string, string>(); private _clusterDistance: number = 75; private _hitCluster = false; private _layoutComputeReaction: IReactionDisposer | undefined; - private _layoutPoolData = observable.map<string, any>(); + private _layoutPoolData = new ObservableMap<string, PoolData>(); + private _layoutSizeData = new ObservableMap<string, { width?: number, height?: number }>(); + private _cachedPool: Map<string, PoolData> = new Map(); + @observable private _pullCoords: number[] = [0, 0]; + @observable private _pullDirection: string = ""; public get displayName() { return "CollectionFreeFormView(" + this.props.Document.title?.toString() + ")"; } // this makes mobx trace() statements more descriptive @observable.shallow _layoutElements: ViewDefResult[] = []; // shallow because some layout items (eg pivot labels) are just generated 'divs' and can't be frozen as observables @observable _clusterSets: (Doc[])[] = []; @observable _timelineRef = React.createRef<Timeline>(); + @computed get fitToContentScaling() { return this.fitToContent ? NumCast(this.layoutDoc.fitToContentScaling, 1) : 1; } @computed get fitToContent() { return (this.props.fitToBox || this.Document._fitToBox) && !this.isAnnotationOverlay; } @computed get parentScaling() { return this.props.ContentScaling && this.fitToContent && !this.isAnnotationOverlay ? this.props.ContentScaling() : 1; } - @computed get contentBounds() { return aggregateBounds(this._layoutElements.filter(e => e.bounds && !e.bounds.z).map(e => e.bounds!), NumCast(this.layoutDoc.xPadding, 10), NumCast(this.layoutDoc.yPadding, 10)); } - @computed get nativeWidth() { return this.Document._fitToContent ? 0 : NumCast(this.Document._nativeWidth); } - @computed get nativeHeight() { return this.fitToContent ? 0 : NumCast(this.Document._nativeHeight); } + @computed get contentBounds() { return aggregateBounds(this._layoutElements.filter(e => e.bounds && !e.bounds.z).map(e => e.bounds!), NumCast(this.layoutDoc._xPadding, 10), NumCast(this.layoutDoc._yPadding, 10)); } + @computed get nativeWidth() { return this.fitToContent ? 0 : NumCast(this.Document._nativeWidth, this.props.NativeWidth()); } + @computed get nativeHeight() { return this.fitToContent ? 0 : NumCast(this.Document._nativeHeight, this.props.NativeHeight()); } private get isAnnotationOverlay() { return this.props.isAnnotationOverlay; } private get borderWidth() { return this.isAnnotationOverlay ? 0 : COLLECTION_BORDER_WIDTH; } private easing = () => this.props.Document.panTransformType === "Ease"; private panX = () => this.fitToContent ? (this.contentBounds.x + this.contentBounds.r) / 2 : this.Document._panX || 0; private panY = () => this.fitToContent ? (this.contentBounds.y + this.contentBounds.b) / 2 : this.Document._panY || 0; - private zoomScaling = () => (1 / this.parentScaling) * (this.fitToContent ? - Math.min(this.props.PanelHeight() / (this.contentBounds.b - this.contentBounds.y), this.props.PanelWidth() / (this.contentBounds.r - this.contentBounds.x)) : + private zoomScaling = () => (this.fitToContentScaling / this.parentScaling) * (this.fitToContent ? + Math.min(this.props.PanelHeight() / (this.contentBounds.b - this.contentBounds.y), + this.props.PanelWidth() / (this.contentBounds.r - this.contentBounds.x)) : this.Document.scale || 1) + private centeringShiftX = () => !this.nativeWidth && !this.isAnnotationOverlay ? this.props.PanelWidth() / 2 / this.parentScaling : 0; // shift so pan position is at center of window for non-overlay collections private centeringShiftY = () => !this.nativeHeight && !this.isAnnotationOverlay ? this.props.PanelHeight() / 2 / this.parentScaling : 0;// shift so pan position is at center of window for non-overlay collections private getTransform = (): Transform => this.props.ScreenToLocalTransform().translate(-this.borderWidth + 1, -this.borderWidth + 1).translate(-this.centeringShiftX(), -this.centeringShiftY()).transform(this.getLocalTransform()); @@ -113,27 +133,27 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { SelectionManager.DeselectAll(); docs.map(doc => DocumentManager.Instance.getDocumentView(doc)).map(dv => dv && SelectionManager.SelectDoc(dv, true)); } - public isCurrent(doc: Doc) { return !doc.isMinimized && (Math.abs(NumCast(doc.displayTimecode, -1) - NumCast(this.Document.currentTimecode, -1)) < 1.5 || NumCast(doc.displayTimecode, -1) === -1); } + public isCurrent(doc: Doc) { return (Math.abs(NumCast(doc.displayTimecode, -1) - NumCast(this.Document.currentTimecode, -1)) < 1.5 || NumCast(doc.displayTimecode, -1) === -1); } public getActiveDocuments = () => { return this.childLayoutPairs.filter(pair => this.isCurrent(pair.layout)).map(pair => pair.layout); } @action - onDrop = (e: React.DragEvent): Promise<void> => { + onExternalDrop = (e: React.DragEvent): Promise<void> => { const pt = this.getTransform().transformPoint(e.pageX, e.pageY); - return super.onDrop(e, { x: pt[0], y: pt[1] }); + return super.onExternalDrop(e, { x: pt[0], y: pt[1] }); } @undoBatch @action - drop = (e: Event, de: DragManager.DropEvent) => { - if (this.props.Document.isBackground) return false; + onInternalDrop = (e: Event, de: DragManager.DropEvent) => { + // if (this.props.Document.isBackground) return false; const xf = this.getTransform(); const xfo = this.getTransformOverlay(); const [xp, yp] = xf.transformPoint(de.x, de.y); const [xpo, ypo] = xfo.transformPoint(de.x, de.y); - if (super.drop(e, de)) { + if (super.onInternalDrop(e, de)) { if (de.complete.docDragData) { if (de.complete.docDragData.droppedDocuments.length) { const firstDoc = de.complete.docDragData.droppedDocuments[0]; @@ -154,7 +174,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { const nh = NumCast(layoutDoc._nativeHeight); layoutDoc._height = nw && nh ? nh / nw * NumCast(layoutDoc._width) : 300; } - this.bringToFront(d); + d.isBackground === undefined && this.bringToFront(d); })); (de.complete.docDragData.droppedDocuments.length === 1 || de.shiftKey) && this.updateClusterDocs(de.complete.docDragData.droppedDocuments); @@ -209,6 +229,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { } @undoBatch + @action updateClusters(useClusters: boolean) { this.props.Document.useClusters = useClusters; this._clusterSets.length = 0; @@ -246,7 +267,6 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { docs.map(doc => this._clusterSets[doc.cluster = NumCast(docFirst.cluster)].push(doc)); } childLayouts.map(child => !this._clusterSets.some((set, i) => Doc.IndexOf(child, set) !== -1 && child.cluster === i) && this.updateCluster(child)); - childLayouts.map(child => Doc.GetProto(child).clusterStr = child.cluster?.toString()); } } @@ -282,16 +302,16 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { } getClusterColor = (doc: Doc) => { - let clusterColor = ""; + let clusterColor = this.props.backgroundColor?.(doc); const cluster = NumCast(doc.cluster); if (this.Document.useClusters) { if (this._clusterSets.length <= cluster) { setTimeout(() => this.updateCluster(doc), 0); } else { // choose a cluster color from a palette - const colors = ["#da42429e", "#31ea318c", "#8c4000", "#4a7ae2c4", "#d809ff", "#ff7601", "#1dffff", "yellow", "#1b8231f2", "#000000ad"]; + const colors = ["#da42429e", "#31ea318c", "rgba(197, 87, 20, 0.55)", "#4a7ae2c4", "rgba(216, 9, 255, 0.5)", "#ff7601", "#1dffff", "yellow", "rgba(27, 130, 49, 0.55)", "rgba(0, 0, 0, 0.268)"]; clusterColor = colors[cluster % colors.length]; - const set = this._clusterSets[cluster] && this._clusterSets[cluster].filter(s => s.backgroundColor && (s.backgroundColor !== s.defaultBackgroundColor)); + const set = this._clusterSets[cluster]?.filter(s => s.backgroundColor); // override the cluster color with an explicitly set color on a non-background document. then override that with an explicitly set color on a background document set && set.filter(s => !s.isBackground).map(s => clusterColor = StrCast(s.backgroundColor)); set && set.filter(s => s.isBackground).map(s => clusterColor = StrCast(s.backgroundColor)); @@ -312,43 +332,17 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { document.removeEventListener("pointerup", this.onPointerUp); document.addEventListener("pointermove", this.onPointerMove); document.addEventListener("pointerup", this.onPointerUp); - // if physically using a pen or we're in pen or highlighter mode - // if (InteractionUtils.IsType(e, InteractionUtils.PENTYPE) || (InkingControl.Instance.selectedTool === InkTool.Highlighter || InkingControl.Instance.selectedTool === InkTool.Pen)) { - // e.stopPropagation(); - // e.preventDefault(); - // const point = this.getTransform().transformPoint(e.pageX, e.pageY); - // this._points.push({ X: point[0], Y: point[1] }); - // } // if not using a pen and in no ink mode if (InkingControl.Instance.selectedTool === InkTool.None) { - this._lastX = e.pageX; - this._lastY = e.pageY; + this._downX = this._lastX = e.pageX; + this._downY = this._lastY = e.pageY; } - // eraser or scrubber plus anything else mode + // eraser plus anything else mode else { e.stopPropagation(); e.preventDefault(); } } - // if (e.button === 0 && !e.shiftKey && !e.altKey && !e.ctrlKey && this.props.active(true)) { - // document.removeEventListener("pointermove", this.onPointerMove); - // document.removeEventListener("pointerup", this.onPointerUp); - // document.addEventListener("pointermove", this.onPointerMove); - // document.addEventListener("pointerup", this.onPointerUp); - // if (InkingControl.Instance.selectedTool === InkTool.None) { - // this._lastX = e.pageX; - // this._lastY = e.pageY; - // } - // else { - // e.stopPropagation(); - // e.preventDefault(); - - // if (InkingControl.Instance.selectedTool !== InkTool.Eraser && InkingControl.Instance.selectedTool !== InkTool.Scrubber) { - // let point = this.getTransform().transformPoint(e.pageX, e.pageY); - // this._points.push({ x: point[0], y: point[1] }); - // } - // } - // } } @action @@ -413,11 +407,96 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { }); this.addDocument(Docs.Create.FreeformDocument(sel, { title: "nested collection", x: bounds.x, y: bounds.y, _width: bWidth, _height: bHeight, _panX: 0, _panY: 0 })); sel.forEach(d => this.props.removeDocument(d)); + e.stopPropagation(); break; - + case GestureUtils.Gestures.StartBracket: + const start = this.getTransform().transformPoint(Math.min(...ge.points.map(p => p.X)), Math.min(...ge.points.map(p => p.Y))); + this._inkToTextStartX = start[0]; + this._inkToTextStartY = start[1]; + console.log("start"); + break; + case GestureUtils.Gestures.EndBracket: + console.log("end"); + if (this._inkToTextStartX && this._inkToTextStartY) { + const end = this.getTransform().transformPoint(Math.max(...ge.points.map(p => p.X)), Math.max(...ge.points.map(p => p.Y))); + const setDocs = this.getActiveDocuments().filter(s => s.proto?.type === "text" && s.color); + const sets = setDocs.map((sd) => { + return Cast(sd.data, RichTextField)?.Text as string; + }); + if (sets.length && sets[0]) { + this._wordPalette.clear(); + const colors = setDocs.map(sd => FieldValue(sd.color) as string); + sets.forEach((st: string, i: number) => { + const words = st.split(","); + words.forEach(word => { + this._wordPalette.set(word, colors[i]); + }); + }); + } + const inks = this.getActiveDocuments().filter(doc => { + if (doc.type === "ink") { + const l = NumCast(doc.x); + const r = l + doc[WidthSym](); + const t = NumCast(doc.y); + const b = t + doc[HeightSym](); + const pass = !(this._inkToTextStartX! > r || end[0] < l || this._inkToTextStartY! > b || end[1] < t); + return pass; + } + return false; + }); + // const inkFields = inks.map(i => Cast(i.data, InkField)); + const strokes: InkData[] = []; + inks.forEach(i => { + const d = Cast(i.data, InkField); + const x = NumCast(i.x); + const y = NumCast(i.y); + const left = Math.min(...d?.inkData.map(pd => pd.X) ?? [0]); + const top = Math.min(...d?.inkData.map(pd => pd.Y) ?? [0]); + if (d) { + strokes.push(d.inkData.map(pd => ({ X: pd.X + x - left, Y: pd.Y + y - top }))); + } + }); + + CognitiveServices.Inking.Appliers.InterpretStrokes(strokes).then((results) => { + console.log(results); + const wordResults = results.filter((r: any) => r.category === "inkWord"); + for (const word of wordResults) { + const indices: number[] = word.strokeIds; + indices.forEach(i => { + const otherInks: Doc[] = []; + indices.forEach(i2 => i2 !== i && otherInks.push(inks[i2])); + inks[i].relatedInks = new List<Doc>(otherInks); + const uniqueColors: string[] = []; + Array.from(this._wordPalette.values()).forEach(c => uniqueColors.indexOf(c) === -1 && uniqueColors.push(c)); + inks[i].alternativeColors = new List<string>(uniqueColors); + if (this._wordPalette.has(word.recognizedText.toLowerCase())) { + inks[i].color = this._wordPalette.get(word.recognizedText.toLowerCase()); + } + else if (word.alternates) { + for (const alt of word.alternates) { + if (this._wordPalette.has(alt.recognizedString.toLowerCase())) { + inks[i].color = this._wordPalette.get(alt.recognizedString.toLowerCase()); + break; + } + } + } + }); + } + }); + this._inkToTextStartX = end[0]; + } + break; + case GestureUtils.Gestures.Text: + if (ge.text) { + const B = this.getTransform().transformPoint(ge.points[0].X, ge.points[0].Y); + this.addDocument(Docs.Create.TextDocument(ge.text, { title: ge.text, x: B[0], y: B[1] })); + e.stopPropagation(); + } } } + _lastTap = 0; + @action onPointerUp = (e: PointerEvent): void => { if (InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE)) return; @@ -428,39 +507,26 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { this.removeEndListeners(); } + onClick = (e: React.MouseEvent) => { + if (this.layoutDoc.targetScale && (Math.abs(e.pageX - this._downX) < 3 && Math.abs(e.pageY - this._downY) < 3)) { + if (Date.now() - this._lastTap < 300) { + const docpt = this.getTransform().transformPoint(e.clientX, e.clientY); + this.scaleAtPt(docpt, 1); + e.stopPropagation(); + e.preventDefault(); + } + this._lastTap = Date.now(); + } + } + @action pan = (e: PointerEvent | React.Touch | { clientX: number, clientY: number }): void => { - // I think it makes sense for the marquee menu to go away when panned. -syip2 - MarqueeOptionsMenu.Instance.fadeOut(true); + // bcz: theres should be a better way of doing these than referencing these static instances directly + MarqueeOptionsMenu.Instance?.fadeOut(true);// I think it makes sense for the marquee menu to go away when panned. -syip2 + PDFMenu.Instance.fadeOut(true); - let x = this.Document._panX || 0; - let y = this.Document._panY || 0; - const docs = this.childLayoutPairs.filter(pair => pair.layout instanceof Doc && !pair.layout.isMinimized).map(pair => pair.layout); const [dx, dy] = this.getTransform().transformDirection(e.clientX - this._lastX, e.clientY - this._lastY); - if (!this.isAnnotationOverlay && docs.length && this.childDataProvider(docs[0])) { - PDFMenu.Instance.fadeOut(true); - const minx = this.childDataProvider(docs[0]).x;//docs.length ? NumCast(docs[0].x) : 0; - const miny = this.childDataProvider(docs[0]).y;//docs.length ? NumCast(docs[0].y) : 0; - const maxx = this.childDataProvider(docs[0]).width + minx;//docs.length ? NumCast(docs[0].width) + minx : minx; - const maxy = this.childDataProvider(docs[0]).height + miny;//docs.length ? NumCast(docs[0].height) + miny : miny; - const ranges = docs.filter(doc => doc).filter(doc => this.childDataProvider(doc)).reduce((range, doc) => { - const x = this.childDataProvider(doc).x;//NumCast(doc.x); - const y = this.childDataProvider(doc).y;//NumCast(doc.y); - const xe = this.childDataProvider(doc).width + x;//x + NumCast(layoutDoc.width); - const ye = this.childDataProvider(doc).height + y; //y + NumCast(layoutDoc.height); - return [[range[0][0] > x ? x : range[0][0], range[0][1] < xe ? xe : range[0][1]], - [range[1][0] > y ? y : range[1][0], range[1][1] < ye ? ye : range[1][1]]]; - }, [[minx, maxx], [miny, maxy]]); - - const cscale = this.props.ContainingCollectionDoc ? NumCast(this.props.ContainingCollectionDoc.scale) : 1; - const panelDim = this.props.ScreenToLocalTransform().transformDirection(this.props.PanelWidth() / this.zoomScaling() * cscale, - this.props.PanelHeight() / this.zoomScaling() * cscale); - if (ranges[0][0] - dx > (this.panX() + panelDim[0] / 2)) x = ranges[0][1] + panelDim[0] / 2; - if (ranges[0][1] - dx < (this.panX() - panelDim[0] / 2)) x = ranges[0][0] - panelDim[0] / 2; - if (ranges[1][0] - dy > (this.panY() + panelDim[1] / 2)) y = ranges[1][1] + panelDim[1] / 2; - if (ranges[1][1] - dy < (this.panY() - panelDim[1] / 2)) y = ranges[1][0] - panelDim[1] / 2; - } - this.setPan(x - dx, y - dy); + this.setPan((this.Document._panX || 0) - dx, (this.Document._panY || 0) - dy, undefined, true); this._lastX = e.clientX; this._lastY = e.clientY; } @@ -549,7 +615,14 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { // use the centerx and centery as the "new mouse position" const centerX = Math.min(pt1.clientX, pt2.clientX) + Math.abs(pt2.clientX - pt1.clientX) / 2; const centerY = Math.min(pt1.clientY, pt2.clientY) + Math.abs(pt2.clientY - pt1.clientY) / 2; - this.pan({ clientX: centerX, clientY: centerY }); + // const transformed = this.getTransform().inverse().transformPoint(centerX, centerY); + + if (!this._pullDirection) { // if we are not bezel movement + this.pan({ clientX: centerX, clientY: centerY }); + } else { + this._pullCoords = [centerX, centerY]; + } + this._lastX = centerX; this._lastY = centerY; } @@ -574,6 +647,27 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { const centerY = Math.min(pt1.clientY, pt2.clientY) + Math.abs(pt2.clientY - pt1.clientY) / 2; this._lastX = centerX; this._lastY = centerY; + const screenBox = this._mainCont?.getBoundingClientRect(); + + + // determine if we are using a bezel movement + if (screenBox) { + if ((screenBox.right - centerX) < 100) { + this._pullCoords = [centerX, centerY]; + this._pullDirection = "right"; + } else if (centerX - screenBox.left < 100) { + this._pullCoords = [centerX, centerY]; + this._pullDirection = "left"; + } else if (screenBox.bottom - centerY < 100) { + this._pullCoords = [centerX, centerY]; + this._pullDirection = "bottom"; + } else if (centerY - screenBox.top < 100) { + this._pullCoords = [centerX, centerY]; + this._pullDirection = "top"; + } + } + + this.removeMoveListeners(); this.addMoveListeners(); this.removeEndListeners(); @@ -584,12 +678,24 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { } cleanUpInteractions = () => { + switch (this._pullDirection) { + case "left": + case "right": + case "top": + case "bottom": + CollectionDockingView.AddSplit(Docs.Create.FreeformDocument([], { title: "New Collection" }), this._pullDirection); + } + + this._pullDirection = ""; + this._pullCoords = [0, 0]; + document.removeEventListener("pointermove", this.onPointerMove); document.removeEventListener("pointerup", this.onPointerUp); this.removeMoveListeners(); this.removeEndListeners(); } + @action zoom = (pointX: number, pointY: number, deltaY: number): void => { let deltaScale = deltaY > 0 ? (1 / 1.1) : 1.1; @@ -617,10 +723,33 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { e.stopPropagation(); this.zoom(e.clientX, e.clientY, e.deltaY); } + this.props.Document.targetScale = NumCast(this.props.Document.scale); } @action - setPan(panX: number, panY: number, panType: string = "None") { + setPan(panX: number, panY: number, panType: string = "None", clamp: boolean = false) { + if (!this.isAnnotationOverlay && clamp) { + // this section wraps the pan position, horizontally and/or vertically whenever the content is panned out of the viewing bounds + const docs = this.childLayoutPairs.filter(pair => pair.layout instanceof Doc).map(pair => pair.layout); + const measuredDocs = docs.filter(doc => doc && this.childDataProvider(doc, "")).map(doc => this.childDataProvider(doc, "")); + if (measuredDocs.length) { + const ranges = measuredDocs.reduce(({ xrange, yrange }, { x, y, width, height }) => // computes range of content + ({ + xrange: { min: Math.min(xrange.min, x), max: Math.max(xrange.max, x + width) }, + yrange: { min: Math.min(yrange.min, y), max: Math.max(yrange.max, y + height) } + }) + , { + xrange: { min: Number.MAX_VALUE, max: -Number.MAX_VALUE }, + yrange: { min: Number.MAX_VALUE, max: -Number.MAX_VALUE } + }); + + const panelDim = [this.props.PanelWidth() / this.zoomScaling(), this.props.PanelHeight() / this.zoomScaling()]; + if (ranges.xrange.min >= (panX + panelDim[0] / 2)) panX = ranges.xrange.max + panelDim[0] / 2; // snaps pan position of range of content goes out of bounds + else if (ranges.xrange.max <= (panX - panelDim[0] / 2)) panX = ranges.xrange.min - panelDim[0] / 2; + if (ranges.yrange.min >= (panY + panelDim[1] / 2)) panY = ranges.yrange.max + panelDim[1] / 2; + else if (ranges.yrange.max <= (panY - panelDim[1] / 2)) panY = ranges.yrange.min - panelDim[1] / 2; + } + } if (!this.Document.lockedTransform || this.Document.inOverlay) { this.Document.panTransformType = panType; const scale = this.getLocalTransform().inverse().Scale; @@ -646,6 +775,17 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { } } + scaleAtPt(docpt: number[], scale: number) { + const screenXY = this.getTransform().inverse().transformPoint(docpt[0], docpt[1]); + this.Document.panTransformType = "Ease"; + this.layoutDoc.scale = scale; + const newScreenXY = this.getTransform().inverse().transformPoint(docpt[0], docpt[1]); + const scrDelta = { x: screenXY[0] - newScreenXY[0], y: screenXY[1] - newScreenXY[1] }; + const newpan = this.getTransform().transformDirection(scrDelta.x, scrDelta.y); + this.layoutDoc._panX = NumCast(this.layoutDoc._panX) - newpan[0]; + this.layoutDoc._panY = NumCast(this.layoutDoc._panY) - newpan[1]; + } + focusDocument = (doc: Doc, willZoom: boolean, scale?: number, afterFocus?: () => boolean) => { const state = HistoryUtil.getState(); @@ -668,10 +808,12 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { if (!annotOn) { this.props.focus(doc); } else { - const contextHgt = Doc.AreProtosEqual(annotOn, this.props.Document) && this.props.VisibleHeight ? this.props.VisibleHeight() : NumCast(annotOn.height); + const contextHgt = Doc.AreProtosEqual(annotOn, this.props.Document) && this.props.VisibleHeight ? this.props.VisibleHeight() : NumCast(annotOn._height); const offset = annotOn && (contextHgt / 2 * 96 / 72); this.props.Document.scrollY = NumCast(doc.y) - offset; } + + afterFocus && setTimeout(afterFocus, 1000); } else { const layoutdoc = Doc.Layout(doc); const newPanX = NumCast(doc.x) + NumCast(layoutdoc._width) / 2; @@ -682,44 +824,53 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { const savedState = { px: this.Document._panX, py: this.Document._panY, s: this.Document.scale, pt: this.Document.panTransformType }; - if (!doc.z) this.setPan(newPanX, newPanY, "Ease"); // docs that are floating in their collection can't be panned to from their collection -- need to propagate the pan to a parent freeform somehow + // if (!willZoom && DocumentView._focusHack.length) { + // Doc.BrushDoc(this.props.Document); + // !doc.z && NumCast(this.layoutDoc.scale) < 1 && this.scaleAtPt(DocumentView._focusHack, 1); // [NumCast(doc.x), NumCast(doc.y)], 1); + // } else { + if (DocListCast(this.dataDoc[this.props.fieldKey]).includes(doc)) { + if (!doc.z) this.setPan(newPanX, newPanY, "Ease", true); // docs that are floating in their collection can't be panned to from their collection -- need to propagate the pan to a parent freeform somehow + } Doc.BrushDoc(this.props.Document); this.props.focus(this.props.Document); willZoom && this.setScaleToZoom(layoutdoc, scale); Doc.linkFollowHighlight(doc); + //} afterFocus && setTimeout(() => { - if (afterFocus && afterFocus()) { + if (afterFocus?.()) { this.Document._panX = savedState.px; this.Document._panY = savedState.py; this.Document.scale = savedState.s; this.Document.panTransformType = savedState.pt; } - }, 1000); + }, 500); } } - setScaleToZoom = (doc: Doc, scale: number = 0.5) => { + setScaleToZoom = (doc: Doc, scale: number = 0.75) => { this.Document.scale = scale * Math.min(this.props.PanelWidth() / NumCast(doc._width), this.props.PanelHeight() / NumCast(doc._height)); } - zoomToScale = (scale: number) => { - this.Document.scale = scale; - } - - getScale = () => this.Document.scale || 1; - @computed get libraryPath() { return this.props.LibraryPath ? [...this.props.LibraryPath, this.props.Document] : []; } - @computed get onChildClickHandler() { return ScriptCast(this.Document.onChildClick); } - + @computed get onChildClickHandler() { return this.props.childClickScript || ScriptCast(this.Document.onChildClick); } + backgroundHalo = () => BoolCast(this.Document.useClusters); + @computed get backgroundActive() { return this.layoutDoc.isBackground && (this.props.ContainingCollectionView?.active() || this.props.active()); } + parentActive = () => this.props.active() || this.backgroundActive ? true : false; getChildDocumentViewProps(childLayout: Doc, childData?: Doc): DocumentViewProps { return { ...this.props, + NativeHeight: returnZero, + NativeWidth: returnZero, + fitToBox: false, DataDoc: childData, Document: childLayout, LibraryPath: this.libraryPath, + FreezeDimensions: this.props.freezeChildDimensions, layoutKey: undefined, + rootSelected: childData ? this.rootSelected : returnFalse, + dropAction: StrCast(this.props.Document.childDropAction) as dropActionType, //onClick: undefined, // this.props.onClick, // bcz: check this out -- I don't think we want to inherit click handlers, or we at least need a way to ignore them onClick: this.onChildClickHandler, ScreenToLocalTransform: childLayout.z ? this.getTransformOverlay : this.getTransform, @@ -731,22 +882,37 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { ContainingCollectionDoc: this.props.Document, focus: this.focusDocument, backgroundColor: this.getClusterColor, - parentActive: this.props.active, + backgroundHalo: this.backgroundHalo, + parentActive: this.parentActive, bringToFront: this.bringToFront, - zoomToScale: this.zoomToScale, - getScale: this.getScale + addDocTab: this.addDocTab, }; } - getCalculatedPositions(params: { doc: Doc, index: number, collection: Doc, docs: Doc[], state: any }): PoolData { + addDocTab = action((doc: Doc, where: string) => { + if (where === "inParent") { + const pt = this.getTransform().transformPoint(NumCast(doc.x), NumCast(doc.y)); + doc.x = pt[0]; + doc.y = pt[1]; + this.props.addDocument(doc); + return true; + } + if (where === "inPlace" && this.layoutDoc.isInPlaceContainer) { + this.dataDoc[this.props.fieldKey] = new List<Doc>([doc]); + return true; + } + return this.props.addDocTab(doc, where); + }); + getCalculatedPositions(params: { pair: { layout: Doc, data?: Doc }, index: number, collection: Doc, docs: Doc[], state: any }): PoolData { const result = this.Document.arrangeScript?.script.run(params, console.log); if (result?.success) { - return { ...result, transition: "transform 1s" }; + return { x: 0, y: 0, transition: "transform 1s", ...result, pair: params.pair, replica: "" }; } - const layoutDoc = Doc.Layout(params.doc); + const layoutDoc = Doc.Layout(params.pair.layout); + const { x, y, z, color, zIndex } = params.pair.layout; return { - x: Cast(params.doc.x, "number"), y: Cast(params.doc.y, "number"), z: Cast(params.doc.z, "number"), color: Cast(params.doc.color, "string"), - zIndex: Cast(params.doc.zIndex, "number"), width: Cast(layoutDoc._width, "number"), height: Cast(layoutDoc._height, "number") + x: NumCast(x), y: NumCast(y), z: Cast(z, "number"), color: StrCast(color), zIndex: Cast(zIndex, "number"), + width: Cast(layoutDoc._width, "number"), height: Cast(layoutDoc._height, "number"), pair: params.pair, replica: "" }; } @@ -755,137 +921,127 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { } onViewDefDivClick = (e: React.MouseEvent, payload: any) => { - (this.props.Document.onViewDefDivClick as ScriptField)?.script.run({ this: this.props.Document, payload }); + (this.props.viewDefDivClick || ScriptCast(this.props.Document.onViewDefDivClick))?.script.run({ this: this.props.Document, payload }); + e.stopPropagation(); } private viewDefToJSX(viewDef: ViewDefBounds): Opt<ViewDefResult> { - const x = Cast(viewDef.x, "number"); - const y = Cast(viewDef.y, "number"); - const z = Cast(viewDef.z, "number"); - const highlight = Cast(viewDef.highlight, "boolean"); - const zIndex = Cast(viewDef.zIndex, "number"); - const color = Cast(viewDef.color, "string"); - const width = Cast(viewDef.width, "number", null); - const height = Cast(viewDef.height, "number", null); + const { x, y, z } = viewDef; + const color = StrCast(viewDef.color); + const width = Cast(viewDef.width, "number"); + const height = Cast(viewDef.height, "number"); + const transform = `translate(${x}px, ${y}px)`; if (viewDef.type === "text") { const text = Cast(viewDef.text, "string"); // don't use NumCast, StrCast, etc since we want to test for undefined below const fontSize = Cast(viewDef.fontSize, "number"); return [text, x, y].some(val => val === undefined) ? undefined : { - ele: <div className="collectionFreeform-customText" key={(text || "") + x + y + z + color} - style={{ width, height, color, fontSize, transform: `translate(${x}px, ${y}px)` }}> + ele: <div className="collectionFreeform-customText" key={(text || "") + x + y + z + color} style={{ width, height, color, fontSize, transform }}> {text} </div>, bounds: viewDef }; } else if (viewDef.type === "div") { - const backgroundColor = Cast(viewDef.color, "string"); return [x, y].some(val => val === undefined) ? undefined : { ele: <div className="collectionFreeform-customDiv" title={viewDef.payload?.join(" ")} key={"div" + x + y + z} onClick={e => this.onViewDefDivClick(e, viewDef)} - style={{ width, height, backgroundColor, transform: `translate(${x}px, ${y}px)` }} />, + style={{ width, height, backgroundColor: color, transform }} />, bounds: viewDef }; } } - childDataProvider = computedFn(function childDataProvider(this: any, doc: Doc) { - if (!doc) { - console.log(doc); - } - return this._layoutPoolData.get(doc[Id]); + childDataProvider = computedFn(function childDataProvider(this: any, doc: Doc, replica: string) { + return this._layoutPoolData.get(doc[Id] + (replica || "")); + }.bind(this)); + childSizeProvider = computedFn(function childSizeProvider(this: any, doc: Doc, replica: string) { + return this._layoutSizeData.get(doc[Id] + (replica || "")); }.bind(this)); - doTimelineLayout(poolData: Map<string, any>) { - return computeTimelineLayout(poolData, this.props.Document, this.childDocs, this.filterDocs, - this.childLayoutPairs, [this.props.PanelWidth(), this.props.PanelHeight()], this.viewDefsToJSX); - } - - doPivotLayout(poolData: Map<string, any>) { - return computePivotLayout(poolData, this.props.Document, this.childDocs, this.filterDocs, - this.childLayoutPairs, [this.props.PanelWidth(), this.props.PanelHeight()], this.viewDefsToJSX); + doEngineLayout(poolData: Map<string, PoolData>, + engine: ( + poolData: Map<string, PoolData>, + pivotDoc: Doc, + childPairs: { layout: Doc, data?: Doc }[], + panelDim: number[], + viewDefsToJSX: ((views: ViewDefBounds[]) => ViewDefResult[])) => ViewDefResult[] + ) { + return engine(poolData, this.props.Document, this.childLayoutPairs, [this.props.PanelWidth(), this.props.PanelHeight()], this.viewDefsToJSX); } - _cachedPool: Map<string, any> = new Map(); - doFreeformLayout(poolData: Map<string, any>) { + doFreeformLayout(poolData: Map<string, PoolData>) { const layoutDocs = this.childLayoutPairs.map(pair => pair.layout); const initResult = this.Document.arrangeInit && this.Document.arrangeInit.script.run({ docs: layoutDocs, collection: this.Document }, console.log); const state = initResult && initResult.success ? initResult.result.scriptState : undefined; const elements = initResult && initResult.success ? this.viewDefsToJSX(initResult.result.views) : []; this.childLayoutPairs.filter(pair => this.isCurrent(pair.layout)).map((pair, i) => { - const pos = this.getCalculatedPositions({ doc: pair.layout, index: i, collection: this.Document, docs: layoutDocs, state }); + const pos = this.getCalculatedPositions({ pair, index: i, collection: this.Document, docs: layoutDocs, state }); poolData.set(pair.layout[Id], pos); }); - return { elements: elements }; + return elements; } @computed get doInternalLayoutComputation() { - const newPool = new Map<string, any>(); - switch (this.props.layoutEngine?.()) { - case "timeline": return { newPool, computedElementData: this.doTimelineLayout(newPool) }; - case "pivot": return { newPool, computedElementData: this.doPivotLayout(newPool) }; + TraceMobx(); + + + const newPool = new Map<string, PoolData>(); + const engine = this.props.layoutEngine?.() || StrCast(this.layoutDoc._layoutEngine); + switch (engine) { + case "pass": return { newPool, computedElementData: this.doEngineLayout(newPool, computerPassLayout) }; + case "timeline": return { newPool, computedElementData: this.doEngineLayout(newPool, computeTimelineLayout) }; + case "pivot": return { newPool, computedElementData: this.doEngineLayout(newPool, computePivotLayout) }; + case "starburst": return { newPool, computedElementData: this.doEngineLayout(newPool, computerStarburstLayout) }; } return { newPool, computedElementData: this.doFreeformLayout(newPool) }; } - @computed get filterDocs() { - const docFilters = Cast(this.props.Document._docFilter, listSpec("string"), []); - const clusters: { [key: string]: { [value: string]: string } } = {}; - for (let i = 0; i < docFilters.length; i += 3) { - const [key, value, modifiers] = docFilters.slice(i, i + 3); - const cluster = clusters[key]; - if (!cluster) { - const child: { [value: string]: string } = {}; - child[value] = modifiers; - clusters[key] = child; - } else { - cluster[value] = modifiers; - } - } - const filteredDocs = docFilters.length ? this.childDocs.filter(d => { - for (const key of Object.keys(clusters)) { - const cluster = clusters[key]; - const satisfiesFacet = Object.keys(cluster).some(inner => { - const modifier = cluster[inner]; - return (modifier === "x") !== Doc.matchFieldValue(d, key, inner); - }); - if (!satisfiesFacet) { - return false; - } - } - return true; - }) : this.childDocs; - return filteredDocs; - } + childLayoutDocFunc = () => this.props.childLayoutTemplate?.() || Cast(this.props.Document.childLayoutTemplate, Doc, null); get doLayoutComputation() { const { newPool, computedElementData } = this.doInternalLayoutComputation; runInAction(() => - Array.from(newPool.keys()).map(key => { - const lastPos = this._cachedPool.get(key); // last computed pos - const newPos = newPool.get(key); - if (!lastPos || newPos.x !== lastPos.x || newPos.y !== lastPos.y || newPos.z !== lastPos.z || newPos.zIndex !== lastPos.zIndex || newPos.width !== lastPos.width || newPos.height !== lastPos.height) { - this._layoutPoolData.set(key, newPos); + Array.from(newPool.entries()).map(entry => { + const lastPos = this._cachedPool.get(entry[0]); // last computed pos + const newPos = entry[1]; + if (!lastPos || newPos.x !== lastPos.x || newPos.y !== lastPos.y || newPos.z !== lastPos.z || newPos.zIndex !== lastPos.zIndex) { + this._layoutPoolData.set(entry[0], newPos); + } + if (!lastPos || newPos.height !== lastPos.height || newPos.width !== lastPos.width) { + this._layoutSizeData.set(entry[0], { width: newPos.width, height: newPos.height }); } })); this._cachedPool.clear(); - Array.from(newPool.keys()).forEach(k => this._cachedPool.set(k, newPool.get(k))); - this.childLayoutPairs.filter((pair, i) => this.isCurrent(pair.layout)).forEach(pair => - computedElementData.elements.push({ - ele: <CollectionFreeFormDocumentView key={pair.layout[Id]} {...this.getChildDocumentViewProps(pair.layout, pair.data)} + Array.from(newPool.entries()).forEach(k => this._cachedPool.set(k[0], k[1])); + const elements: ViewDefResult[] = computedElementData.slice(); + const engine = this.props.layoutEngine?.() || StrCast(this.props.Document._layoutEngine); + Array.from(newPool.entries()).filter(entry => this.isCurrent(entry[1].pair.layout)).forEach(entry => + elements.push({ + ele: <CollectionFreeFormDocumentView + key={entry[1].pair.layout[Id] + (entry[1].replica || "")} + {...this.getChildDocumentViewProps(entry[1].pair.layout, entry[1].pair.data)} + replica={entry[1].replica} dataProvider={this.childDataProvider} - jitterRotation={NumCast(this.props.Document.jitterRotation)} - fitToBox={this.props.fitToBox || this.props.layoutEngine !== undefined} />, - bounds: this.childDataProvider(pair.layout) + sizeProvider={this.childSizeProvider} + LayoutDoc={this.childLayoutDocFunc} + pointerEvents={ + this.backgroundActive ? + true : + (this.props.viewDefDivClick || (engine === "pass" && !this.props.isSelected(true))) ? false : undefined} + jitterRotation={NumCast(this.props.Document._jitterRotation)} + //fitToBox={this.props.fitToBox || BoolCast(this.props.freezeChildDimensions)} // bcz: check this + fitToBox={BoolCast(this.props.freezeChildDimensions)} // bcz: check this + FreezeDimensions={BoolCast(this.props.freezeChildDimensions)} + />, + bounds: this.childDataProvider(entry[1].pair.layout, entry[1].replica) })); - return computedElementData; + return elements; } componentDidMount() { super.componentDidMount(); - this._layoutComputeReaction = reaction( - () => (this.doLayoutComputation), - (computation) => this._layoutElements = computation?.elements.slice() || [], + this._layoutComputeReaction = reaction(() => this.doLayoutComputation, + (elements) => this._layoutElements = elements || [], { fireImmediately: true, name: "doLayout" }); } componentWillUnmount() { @@ -899,6 +1055,15 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { super.setCursorPosition(this.getTransform().transformPoint(e.clientX, e.clientY)); } + promoteCollection = undoBatch(action(() => { + this.childDocs.forEach(doc => { + const scr = this.getTransform().inverse().transformPoint(NumCast(doc.x), NumCast(doc.y)); + doc.x = scr?.[0]; + doc.y = scr?.[1]; + this.props.addDocTab(doc, "inParent") && this.props.removeDocument(doc); + }); + this.props.ContainingCollectionView?.removeDocument(this.props.Document); + })); layoutDocsInGrid = () => { UndoManager.RunInBatch(() => { const docs = this.childLayoutPairs; @@ -923,74 +1088,20 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { private thumbIdentifier?: number; - // @action - // handleHandDown = (e: React.TouchEvent) => { - // const fingers = InteractionUtils.GetMyTargetTouches(e, this.prevPoints, true); - // const thumb = fingers.reduce((a, v) => a.clientY > v.clientY ? a : v, fingers[0]); - // this.thumbIdentifier = thumb?.identifier; - // const others = fingers.filter(f => f !== thumb); - // const minX = Math.min(...others.map(f => f.clientX)); - // const minY = Math.min(...others.map(f => f.clientY)); - // const t = this.getTransform().transformPoint(minX, minY); - // const th = this.getTransform().transformPoint(thumb.clientX, thumb.clientY); - - // const thumbDoc = FieldValue(Cast(CurrentUserUtils.setupThumbDoc(CurrentUserUtils.UserDocument), Doc)); - // if (thumbDoc) { - // this._palette = <Palette x={t[0]} y={t[1]} thumb={th} thumbDoc={thumbDoc} />; - // } - - // document.removeEventListener("touchmove", this.onTouch); - // document.removeEventListener("touchmove", this.handleHandMove); - // document.addEventListener("touchmove", this.handleHandMove); - // document.removeEventListener("touchend", this.handleHandUp); - // document.addEventListener("touchend", this.handleHandUp); - // } - - // @action - // handleHandMove = (e: TouchEvent) => { - // for (let i = 0; i < e.changedTouches.length; i++) { - // const pt = e.changedTouches.item(i); - // if (pt?.identifier === this.thumbIdentifier) { - // } - // } - // } - - // @action - // handleHandUp = (e: TouchEvent) => { - // this.onTouchEnd(e); - // if (this.prevPoints.size < 3) { - // this._palette = undefined; - // document.removeEventListener("touchend", this.handleHandUp); - // } - // } - onContextMenu = (e: React.MouseEvent) => { - const layoutItems: ContextMenuProps[] = []; - const { Document } = this.props; - - layoutItems.push({ - description: "reset view", event: () => { - Doc.resetView(Document); - }, icon: "compress-arrows-alt" - }); - layoutItems.push({ - description: "set view origin", event: () => { - Doc.setView(Document); - }, icon: "expand-arrows-alt" - }); - layoutItems.push({ - description: "reset view to origin", event: () => { - Doc.resetViewToOrigin(Document); - }, icon: "expand-arrows-alt" - }); - - layoutItems.push({ description: `${this.Document._LODdisable ? "Enable LOD" : "Disable LOD"}`, event: () => this.Document._LODdisable = !this.Document._LODdisable, icon: "table" }); - layoutItems.push({ description: `${this.fitToContent ? "Unset" : "Set"} Fit To Container`, event: () => this.Document._fitToBox = !this.fitToContent, icon: !this.fitToContent ? "expand-arrows-alt" : "compress-arrows-alt" }); - layoutItems.push({ description: `${this.Document.useClusters ? "Uncluster" : "Use Clusters"}`, event: () => this.updateClusters(!this.Document.useClusters), icon: "braille" }); - layoutItems.push({ description: "Arrange contents in grid", event: this.layoutDocsInGrid, icon: "table" }); + if (this.props.children && this.props.annotationsKey) return; + const options = ContextMenu.Instance.findByDescription("Options..."); + const optionItems: ContextMenuProps[] = options && "subitems" in options ? options.subitems : []; + + optionItems.push({ description: "reset view", event: () => { this.props.Document._panX = this.props.Document._panY = 0; this.props.Document.scale = 1; }, icon: "compress-arrows-alt" }); + optionItems.push({ description: `${this.Document._LODdisable ? "Enable LOD" : "Disable LOD"}`, event: () => this.Document._LODdisable = !this.Document._LODdisable, icon: "table" }); + optionItems.push({ description: `${this.fitToContent ? "Unset" : "Set"} Fit To Container`, event: () => this.Document._fitToBox = !this.fitToContent, icon: !this.fitToContent ? "expand-arrows-alt" : "compress-arrows-alt" }); + optionItems.push({ description: `${this.Document.useClusters ? "Uncluster" : "Use Clusters"}`, event: () => this.updateClusters(!this.Document.useClusters), icon: "braille" }); + this.props.ContainingCollectionView && optionItems.push({ description: "Promote Collection", event: this.promoteCollection, icon: "table" }); + optionItems.push({ description: "Arrange contents in grid", event: this.layoutDocsInGrid, icon: "table" }); // layoutItems.push({ description: "Analyze Strokes", event: this.analyzeStrokes, icon: "paint-brush" }); - layoutItems.push({ description: "Jitter Rotation", event: action(() => this.props.Document.jitterRotation = 10), icon: "paint-brush" }); - layoutItems.push({ + optionItems.push({ description: "Jitter Rotation", event: action(() => this.props.Document._jitterRotation = (this.props.Document._jitterRotation ? 0 : 10)), icon: "paint-brush" }); + optionItems.push({ description: "Import document", icon: "upload", event: ({ x, y }) => { const input = document.createElement("input"); input.type = "file"; @@ -1009,7 +1120,7 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { if (doc instanceof Doc) { const [xx, yy] = this.props.ScreenToLocalTransform().transformPoint(x, y); doc.x = xx, doc.y = yy; - this.props.addDocument && this.props.addDocument(doc); + this.props.addDocument?.(doc); } } } @@ -1017,28 +1128,10 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { input.click(); } }); - //@ts-ignore - const subitems: ContextMenuProps[] = - DocListCast((CurrentUserUtils.UserDocument.noteTypes as Doc).data).map((note, i) => ({ - description: (i + 1) + ": " + StrCast(note.title), - event: () => console.log("Hi"), - icon: "eye" - })); - - layoutItems.push({ - description: "Add Note ...", - subitems: DocListCast((CurrentUserUtils.UserDocument.noteTypes as Doc).data).map((note, i) => ({ - description: (i + 1) + ": " + StrCast(note.title), - event: (args: { x: number, y: number }) => this.addLiveTextBox(Docs.Create.TextDocument("", { _width: 200, _height: 100, x: this.getTransform().transformPoint(args.x, args.y)[0], y: this.getTransform().transformPoint(args.x, args.y)[1], _autoHeight: true, layout: note, title: StrCast(note.title) })), - icon: "eye" - })) as ContextMenuProps[], - icon: "eye" - }); - ContextMenu.Instance.addItem({ description: "Freeform Options ...", subitems: layoutItems, icon: "eye" }); + ContextMenu.Instance.addItem({ description: "Options ...", subitems: optionItems, icon: "eye" }); this._timelineRef.current!.timelineContextMenu(e); } - private childViews = () => { const children = typeof this.props.children === "function" ? (this.props.children as any)() as JSX.Element[] : []; return [ @@ -1047,13 +1140,9 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { ]; } - // @observable private _palette?: JSX.Element; - children = () => { const eles: JSX.Element[] = []; eles.push(...this.childViews()); - // this._palette && (eles.push(this._palette)); - // this.currentStroke && (eles.push(this.currentStroke)); eles.push(<CollectionFreeFormRemoteCursors {...this.props} key="remoteCursors" />); return eles; } @@ -1062,23 +1151,40 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { <span className="collectionfreeformview-placeholderSpan">{this.props.Document.title?.toString()}</span> </div>; } + + _nudgeTime = 0; + nudge = action((x: number, y: number) => { + if (this.props.ContainingCollectionDoc?._viewType !== CollectionViewType.Freeform) { // bcz: this isn't ideal, but want to try it out... + this.setPan(NumCast(this.layoutDoc._panX) + this.props.PanelWidth() / 2 * x / this.zoomScaling(), + NumCast(this.layoutDoc._panY) + this.props.PanelHeight() / 2 * (-y) / this.zoomScaling(), "Ease", true); + this._nudgeTime = Date.now(); + setTimeout(() => (Date.now() - this._nudgeTime >= 500) && (this.Document.panTransformType = undefined), 500); + return true; + } + return false; + }); @computed get marqueeView() { - return <MarqueeView {...this.props} activeDocuments={this.getActiveDocuments} selectDocuments={this.selectDocuments} addDocument={this.addDocument} + return <MarqueeView {...this.props} nudge={this.nudge} activeDocuments={this.getActiveDocuments} selectDocuments={this.selectDocuments} addDocument={this.addDocument} addLiveTextDocument={this.addLiveTextBox} getContainerTransform={this.getContainerTransform} getTransform={this.getTransform} isAnnotationOverlay={this.isAnnotationOverlay}> - <CollectionFreeFormViewPannableContents centeringShiftX={this.centeringShiftX} centeringShiftY={this.centeringShiftY} - easing={this.easing} zoomScaling={this.zoomScaling} panX={this.panX} panY={this.panY}> + <CollectionFreeFormViewPannableContents centeringShiftX={this.centeringShiftX} centeringShiftY={this.centeringShiftY} shifted={!this.nativeHeight && !this.isAnnotationOverlay} + easing={this.easing} viewDefDivClick={this.props.viewDefDivClick} zoomScaling={this.zoomScaling} panX={this.panX} panY={this.panY}> {this.children} </CollectionFreeFormViewPannableContents> </MarqueeView>; } + @computed get contentScaling() { - if (this.props.annotationsKey) return 0; - const hscale = this.nativeHeight ? this.props.PanelHeight() / this.nativeHeight : 1; - const wscale = this.nativeWidth ? this.props.PanelWidth() / this.nativeWidth : 1; + if (this.props.annotationsKey && !this.props.forceScaling) return 0; + const nw = NumCast(this.Document._nativeWidth, this.props.NativeWidth()); + const nh = NumCast(this.Document._nativeHeight, this.props.NativeHeight()); + const hscale = nh ? this.props.PanelHeight() / nh : 1; + const wscale = nw ? this.props.PanelWidth() / nw : 1; return wscale < hscale ? wscale : hscale; } + @computed get backgroundEvents() { return this.layoutDoc.isBackground && SelectionManager.GetIsDragging(); } render() { TraceMobx(); + const clientRect = this._mainCont?.getBoundingClientRect(); // update the actual dimensions of the collection so that they can inquired (e.g., by a minimap) // this.Document.fitX = this.contentBounds && this.contentBounds.x; // this.Document.fitY = this.contentBounds && this.contentBounds.y; @@ -1086,26 +1192,34 @@ export class CollectionFreeFormView extends CollectionSubView(PanZoomDocument) { // this.Document.fitH = this.contentBounds && (this.contentBounds.b - this.contentBounds.y); // if isAnnotationOverlay is set, then children will be stored in the extension document for the fieldKey. // otherwise, they are stored in fieldKey. All annotations to this document are stored in the extension document - // let lodarea = this.Document[WidthSym]() * this.Document[HeightSym]() / this.props.ScreenToLocalTransform().Scale / this.props.ScreenToLocalTransform().Scale; - return <div> - <div className={"collectionfreeformview-container"} - ref={this.createDashEventsTarget} - onWheel={this.onPointerWheel}//pointerEvents: SelectionManager.GetIsDragging() ? "all" : undefined, - onPointerDown={this.onPointerDown} onPointerMove={this.onCursorMove} onDrop={this.onDrop.bind(this)} onContextMenu={this.onContextMenu} + return <div className={"collectionfreeformview-container"} + ref={this.createDashEventsTarget} + onWheel={this.onPointerWheel} onClick={this.onClick} //pointerEvents: SelectionManager.GetIsDragging() ? "all" : undefined, + onPointerDown={this.onPointerDown} onPointerMove={this.onCursorMove} onDrop={this.onExternalDrop.bind(this)} onContextMenu={this.onContextMenu} + style={{ + pointerEvents: this.backgroundEvents ? "all" : undefined, + transform: this.contentScaling ? `scale(${this.contentScaling})` : "", + transformOrigin: this.contentScaling ? "left top" : "", + width: this.contentScaling ? `${100 / this.contentScaling}%` : "", + height: this.contentScaling ? `${100 / this.contentScaling}%` : this.isAnnotationOverlay ? (this.props.Document.scrollHeight ? this.Document.scrollHeight : "100%") : this.props.PanelHeight() + }}> + {!this.Document._LODdisable && !this.props.active() && !this.props.isAnnotationOverlay && !this.props.annotationsKey && this.props.renderDepth > 0 ? + this.placeholder : this.marqueeView} + <CollectionFreeFormOverlayView elements={this.elementFunc} /> + + <div className={"pullpane-indicator"} style={{ - pointerEvents: SelectionManager.GetIsDragging() ? "all" : undefined, - transform: this.contentScaling ? `scale(${this.contentScaling})` : "", - transformOrigin: this.contentScaling ? "left top" : "", - width: this.contentScaling ? `${100 / this.contentScaling}%` : "", - height: this.contentScaling ? `${100 / this.contentScaling}%` : this.isAnnotationOverlay ? (this.props.Document.scrollHeight ? this.Document.scrollHeight : "100%") : this.props.PanelHeight() + display: this._pullDirection ? "block" : "none", + top: clientRect ? this._pullDirection === "bottom" ? this._pullCoords[1] - clientRect.y : 0 : "auto", + // left: clientRect ? this._pullDirection === "right" ? this._pullCoords[0] - clientRect.x - MainView.Instance.flyoutWidth : 0 : "auto", + left: clientRect ? this._pullDirection === "right" ? this._pullCoords[0] - clientRect.x : 0 : "auto", + width: clientRect ? this._pullDirection === "left" ? this._pullCoords[0] - clientRect.left : this._pullDirection === "right" ? clientRect.right - this._pullCoords[0] : clientRect.width : 0, + height: clientRect ? this._pullDirection === "top" ? this._pullCoords[1] - clientRect.top : this._pullDirection === "bottom" ? clientRect.bottom - this._pullCoords[1] : clientRect.height : 0, + }}> - {/* <Timeline ref={this._timelineRef} {...this.props} /> */} - {!this.Document._LODdisable && !this.props.active() && !this.props.isAnnotationOverlay && !this.props.annotationsKey && this.props.renderDepth > 0 ? // && this.props.CollectionView && lodarea < NumCast(this.Document.LODarea, 100000) ? - this.placeholder : this.marqueeView} - <CollectionFreeFormOverlayView elements={this.elementFunc} /> </div> - <Timeline ref={this._timelineRef} {...this.props} /> - </div>; + + </div >; } } @@ -1116,7 +1230,7 @@ interface CollectionFreeFormOverlayViewProps { @observer class CollectionFreeFormOverlayView extends React.Component<CollectionFreeFormOverlayViewProps>{ render() { - return this.props.elements().filter(ele => ele.bounds && ele.bounds.z).map(ele => ele.ele); + return this.props.elements().filter(ele => ele.bounds?.z).map(ele => ele.ele); } } @@ -1127,19 +1241,25 @@ interface CollectionFreeFormViewPannableContentsProps { panY: () => number; zoomScaling: () => number; easing: () => boolean; + viewDefDivClick?: ScriptField; children: () => JSX.Element[]; + shifted: boolean; } @observer class CollectionFreeFormViewPannableContents extends React.Component<CollectionFreeFormViewPannableContentsProps>{ render() { - const freeformclass = "collectionfreeformview" + (this.props.easing() ? "-ease" : "-none"); + const freeformclass = "collectionfreeformview" + (this.props.viewDefDivClick ? "-viewDef" : (this.props.easing() ? "-ease" : "-none")); const cenx = this.props.centeringShiftX(); const ceny = this.props.centeringShiftY(); const panx = -this.props.panX(); const pany = -this.props.panY(); const zoom = this.props.zoomScaling(); - return <div className={freeformclass} style={{ touchAction: "none", borderRadius: "inherit", transform: `translate(${cenx}px, ${ceny}px) scale(${zoom}) translate(${panx}px, ${pany}px)` }}> + return <div className={freeformclass} + style={{ + width: this.props.shifted ? 0 : undefined, height: this.props.shifted ? 0 : undefined, + transform: `translate(${cenx}px, ${ceny}px) scale(${zoom}) translate(${panx}px, ${pany}px)` + }}> {this.props.children()} </div>; } diff --git a/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx b/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx index 71f265484..db4b674b5 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx @@ -11,6 +11,7 @@ export default class MarqueeOptionsMenu extends AntimodeMenu { public createCollection: (e: KeyboardEvent | React.PointerEvent | undefined) => void = unimplementedFunction; public delete: (e: KeyboardEvent | React.PointerEvent | undefined) => void = unimplementedFunction; public summarize: (e: KeyboardEvent | React.PointerEvent | undefined) => void = unimplementedFunction; + public inkToText: (e: KeyboardEvent | React.PointerEvent | undefined) => void = unimplementedFunction; public showMarquee: () => void = unimplementedFunction; public hideMarquee: () => void = unimplementedFunction; @@ -43,6 +44,13 @@ export default class MarqueeOptionsMenu extends AntimodeMenu { onPointerDown={this.delete}> <FontAwesomeIcon icon="trash-alt" size="lg" /> </button>, + <button + className="antimodeMenu-button" + title="Change to Text" + key="inkToText" + onPointerDown={this.inkToText}> + <FontAwesomeIcon icon="font" size="lg" /> + </button>, ]; return this.getElement(buttons); } diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.scss b/src/client/views/collections/collectionFreeForm/MarqueeView.scss index 18d6da0da..1291e7dc1 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.scss +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.scss @@ -6,7 +6,6 @@ width:100%; height:100%; overflow: hidden; - pointer-events: inherit; border-radius: inherit; } diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx index e16f4011e..2d3bb6f3c 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx @@ -1,24 +1,26 @@ import { action, computed, observable } from "mobx"; import { observer } from "mobx-react"; -import { Doc, DocListCast } from "../../../../new_fields/Doc"; -import { InkField } from "../../../../new_fields/InkField"; +import { Doc, DocListCast, DataSym, WidthSym, HeightSym, Opt } from "../../../../new_fields/Doc"; +import { InkField, InkData } from "../../../../new_fields/InkField"; import { List } from "../../../../new_fields/List"; -import { listSpec } from "../../../../new_fields/Schema"; import { SchemaHeaderField } from "../../../../new_fields/SchemaHeaderField"; -import { ComputedField } from "../../../../new_fields/ScriptField"; -import { Cast, NumCast, StrCast } from "../../../../new_fields/Types"; -import { CurrentUserUtils } from "../../../../server/authentication/models/current_user_utils"; +import { Cast, NumCast, FieldValue, StrCast } from "../../../../new_fields/Types"; import { Utils } from "../../../../Utils"; -import { Docs } from "../../../documents/Documents"; +import { Docs, DocUtils, DocumentOptions } from "../../../documents/Documents"; import { SelectionManager } from "../../../util/SelectionManager"; import { Transform } from "../../../util/Transform"; import { undoBatch } from "../../../util/UndoManager"; +import { ContextMenu } from "../../ContextMenu"; import { PreviewCursor } from "../../PreviewCursor"; -import { CollectionViewType } from "../CollectionView"; +import { SubCollectionViewProps } from "../CollectionSubView"; +import MarqueeOptionsMenu from "./MarqueeOptionsMenu"; import "./MarqueeView.scss"; import React = require("react"); -import MarqueeOptionsMenu from "./MarqueeOptionsMenu"; -import { SubCollectionViewProps } from "../CollectionSubView"; +import { CognitiveServices } from "../../../cognitive_services/CognitiveServices"; +import { RichTextField } from "../../../../new_fields/RichTextField"; +import { CollectionView } from "../CollectionView"; +import { FormattedTextBox } from "../../nodes/formattedText/FormattedTextBox"; +import { ScriptField } from "../../../../new_fields/ScriptField"; interface MarqueeViewProps { getContainerTransform: () => Transform; @@ -30,6 +32,7 @@ interface MarqueeViewProps { addLiveTextDocument: (doc: Doc) => void; isSelected: () => boolean; isAnnotationOverlay?: boolean; + nudge: (x: number, y: number) => boolean; setPreviewCursor?: (func: (x: number, y: number, drag: boolean) => void) => void; } @@ -45,7 +48,7 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque _commandExecuted = false; componentDidMount() { - this.props.setPreviewCursor && this.props.setPreviewCursor(this.setPreviewCursor); + this.props.setPreviewCursor?.(this.setPreviewCursor); } @action @@ -66,7 +69,11 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque //make textbox and add it to this collection // tslint:disable-next-line:prefer-const let [x, y] = this.props.getTransform().transformPoint(this._downX, this._downY); - if (e.key === "q" && e.ctrlKey) { + if (e.key === ":") { + DocUtils.addDocumentCreatorMenuItems(this.props.addLiveTextDocument, this.props.addDocument, x, y); + + ContextMenu.Instance.displayMenu(this._downX, this._downY); + } else if (e.key === "q" && e.ctrlKey) { e.preventDefault(); (async () => { const text: string = await navigator.clipboard.readText(); @@ -100,13 +107,15 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque } }); } else if (!e.ctrlKey) { - this.props.addLiveTextDocument( - Docs.Create.TextDocument("", { _width: 200, _height: 100, x: x, y: y, _autoHeight: true, title: "-typed text-" })); - } else if (e.keyCode > 48 && e.keyCode <= 57) { - const notes = DocListCast((CurrentUserUtils.UserDocument.noteTypes as Doc).data); - const text = Docs.Create.TextDocument("", { _width: 200, _height: 100, x: x, y: y, _autoHeight: true, title: "-typed text-" }); - text.layout = notes[(e.keyCode - 49) % notes.length]; - this.props.addLiveTextDocument(text); + FormattedTextBox.SelectOnLoadChar = FormattedTextBox.DefaultLayout ? e.key : ""; + const tbox = Docs.Create.TextDocument("", { _width: 200, _height: 100, x: x, y: y, _autoHeight: true, title: "-typed text-" }); + const template = FormattedTextBox.DefaultLayout; + if (template instanceof Doc) { + tbox._width = NumCast(template._width); + tbox.layoutKey = "layout_" + StrCast(template.title); + tbox[StrCast(tbox.layoutKey)] = template; + } + this.props.addLiveTextDocument(tbox); } e.stopPropagation(); } @@ -204,6 +213,7 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque MarqueeOptionsMenu.Instance.createCollection = this.collection; MarqueeOptionsMenu.Instance.delete = this.delete; MarqueeOptionsMenu.Instance.summarize = this.summary; + MarqueeOptionsMenu.Instance.inkToText = this.syntaxHighlight; MarqueeOptionsMenu.Instance.showMarquee = this.showMarquee; MarqueeOptionsMenu.Instance.hideMarquee = this.hideMarquee; MarqueeOptionsMenu.Instance.jumpTo(e.clientX, e.clientY); @@ -235,15 +245,16 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque } else { this._downX = x; this._downY = y; - PreviewCursor.Show(x, y, this.onKeyPress, this.props.addLiveTextDocument, this.props.getTransform, this.props.addDocument); + PreviewCursor.Show(x, y, this.onKeyPress, this.props.addLiveTextDocument, this.props.getTransform, this.props.addDocument, this.props.nudge); } }); @action onClick = (e: React.MouseEvent): void => { - if (Math.abs(e.clientX - this._downX) < Utils.DRAG_THRESHOLD && + if ( + Math.abs(e.clientX - this._downX) < Utils.DRAG_THRESHOLD && Math.abs(e.clientY - this._downY) < Utils.DRAG_THRESHOLD) { - this.setPreviewCursor(e.clientX, e.clientY, false); + !(e.nativeEvent as any).formattedHandled && this.setPreviewCursor(e.clientX, e.clientY, false); // let the DocumentView stopPropagation of this event when it selects this document } else { // why do we get a click event when the cursor have moved a big distance? // let's cut it off here so no one else has to deal with it. @@ -299,17 +310,16 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque this.hideMarquee(); } - getCollection = (selected: Doc[], asTemplate: boolean) => { + getCollection = (selected: Doc[], creator: Opt<(documents: Array<Doc>, options: DocumentOptions, id?: string) => Doc>, isBackground?: boolean) => { const bounds = this.Bounds; // const inkData = this.ink ? this.ink.inkData : undefined; - const creator = asTemplate ? Docs.Create.StackingDocument : Docs.Create.FreeformDocument; - const newCollection = creator(selected, { + const newCollection = (creator || Docs.Create.FreeformDocument)(selected, { x: bounds.left, y: bounds.top, _panX: 0, _panY: 0, - backgroundColor: this.props.isAnnotationOverlay ? "#00000015" : "white", - defaultBackgroundColor: this.props.isAnnotationOverlay ? "#00000015" : "white", + isBackground, + backgroundColor: this.props.isAnnotationOverlay ? "#00000015" : isBackground ? "cyan" : undefined, _width: bounds.width, _height: bounds.height, _LODdisable: true, @@ -323,6 +333,18 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque } @action + pileup = (e: KeyboardEvent | React.PointerEvent | undefined) => { + const selected = this.marqueeSelect(false); + SelectionManager.DeselectAll(); + selected.forEach(d => this.props.removeDocument(d)); + const newCollection = Doc.pileup(selected, this.Bounds.left + this.Bounds.width / 2, this.Bounds.top + this.Bounds.height / 2); + this.props.addDocument(newCollection); + this.props.selectDocuments([newCollection], []); + MarqueeOptionsMenu.Instance.fadeOut(true); + this.hideMarquee(); + } + + @action collection = (e: KeyboardEvent | React.PointerEvent | undefined) => { const bounds = this.Bounds; const selected = this.marqueeSelect(false); @@ -331,11 +353,11 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque this.props.removeDocument(d); d.x = NumCast(d.x) - bounds.left - bounds.width / 2; d.y = NumCast(d.y) - bounds.top - bounds.height / 2; - d.displayTimecode = undefined; + d.displayTimecode = undefined; // bcz: this should be automatic somehow.. along with any other properties that were logically associated with the original collection return d; }); } - const newCollection = this.getCollection(selected, (e as KeyboardEvent)?.key === "t"); + const newCollection = this.getCollection(selected, (e as KeyboardEvent)?.key === "t" ? Docs.Create.StackingDocument : undefined); this.props.addDocument(newCollection); this.props.selectDocuments([newCollection], []); MarqueeOptionsMenu.Instance.fadeOut(true); @@ -343,11 +365,88 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque } @action + syntaxHighlight = (e: KeyboardEvent | React.PointerEvent | undefined) => { + const selected = this.marqueeSelect(false); + if (e instanceof KeyboardEvent ? e.key === "i" : true) { + const inks = selected.filter(s => s.proto?.type === "ink"); + const setDocs = selected.filter(s => s.proto?.type === "text" && s.color); + const sets = setDocs.map((sd) => { + return Cast(sd.data, RichTextField)?.Text as string; + }); + const colors = setDocs.map(sd => FieldValue(sd.color) as string); + const wordToColor = new Map<string, string>(); + sets.forEach((st: string, i: number) => { + const words = st.split(","); + words.forEach(word => { + wordToColor.set(word, colors[i]); + }); + }); + const strokes: InkData[] = []; + inks.forEach(i => { + const d = Cast(i.data, InkField); + const x = NumCast(i.x); + const y = NumCast(i.y); + const left = Math.min(...d?.inkData.map(pd => pd.X) ?? [0]); + const top = Math.min(...d?.inkData.map(pd => pd.Y) ?? [0]); + if (d) { + strokes.push(d.inkData.map(pd => ({ X: pd.X + x - left, Y: pd.Y + y - top }))); + } + }); + CognitiveServices.Inking.Appliers.InterpretStrokes(strokes).then((results) => { + // const wordResults = results.filter((r: any) => r.category === "inkWord"); + // console.log(wordResults); + // console.log(results); + // for (const word of wordResults) { + // const indices: number[] = word.strokeIds; + // indices.forEach(i => { + // if (wordToColor.has(word.recognizedText.toLowerCase())) { + // inks[i].color = wordToColor.get(word.recognizedText.toLowerCase()); + // } + // else { + // for (const alt of word.alternates) { + // if (wordToColor.has(alt.recognizedString.toLowerCase())) { + // inks[i].color = wordToColor.get(alt.recognizedString.toLowerCase()); + // break; + // } + // } + // } + // }) + // } + // const wordResults = results.filter((r: any) => r.category === "inkWord"); + // for (const word of wordResults) { + // const indices: number[] = word.strokeIds; + // indices.forEach(i => { + // const otherInks: Doc[] = []; + // indices.forEach(i2 => i2 !== i && otherInks.push(inks[i2])); + // inks[i].relatedInks = new List<Doc>(otherInks); + // const uniqueColors: string[] = []; + // Array.from(wordToColor.values()).forEach(c => uniqueColors.indexOf(c) === -1 && uniqueColors.push(c)); + // inks[i].alternativeColors = new List<string>(uniqueColors); + // if (wordToColor.has(word.recognizedText.toLowerCase())) { + // inks[i].color = wordToColor.get(word.recognizedText.toLowerCase()); + // } + // else if (word.alternates) { + // for (const alt of word.alternates) { + // if (wordToColor.has(alt.recognizedString.toLowerCase())) { + // inks[i].color = wordToColor.get(alt.recognizedString.toLowerCase()); + // break; + // } + // } + // } + // }); + // } + const lines = results.filter((r: any) => r.category === "line"); + console.log(lines); + const text = lines.map((l: any) => l.recognizedText).join("\r\n"); + this.props.addDocument(Docs.Create.TextDocument(text, { _width: this.Bounds.width, _height: this.Bounds.height, x: this.Bounds.left + this.Bounds.width, y: this.Bounds.top, title: text })); + }); + } + } + + @action summary = (e: KeyboardEvent | React.PointerEvent | undefined) => { const bounds = this.Bounds; const selected = this.marqueeSelect(false); - const newCollection = this.getCollection(selected, false); - selected.map(d => { this.props.removeDocument(d); d.x = NumCast(d.x) - bounds.left - bounds.width / 2; @@ -355,25 +454,26 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque d.page = -1; return d; }); - newCollection._chromeStatus = "disabled"; - const summary = Docs.Create.TextDocument("", { x: bounds.left, y: bounds.top, _width: 300, _height: 100, _autoHeight: true, backgroundColor: "#e2ad32" /* yellow */, title: "-summary-" }); - Doc.GetProto(summary).summarizedDocs = new List<Doc>([newCollection]); - newCollection.x = bounds.left + bounds.width; - Doc.GetProto(newCollection).summaryDoc = summary; - Doc.GetProto(newCollection).title = ComputedField.MakeFunction(`summaryTitle(this);`); - if (e instanceof KeyboardEvent ? e.key === "s" : true) { // summary is wrapped in an expand/collapse container that also contains the summarized documents in a free form view. - const container = Docs.Create.FreeformDocument([summary, newCollection], { - x: bounds.left, y: bounds.top, _width: 300, _height: 200, _autoHeight: true, - _viewType: CollectionViewType.Stacking, _chromeStatus: "disabled", title: "-summary-" - }); - Doc.GetProto(summary).maximizeLocation = "inPlace"; // or "onRight" - this.props.addLiveTextDocument(container); - } else if (e instanceof KeyboardEvent ? e.key === "S" : false) { // the summary stands alone, but is linked to a collection of the summarized documents - set the OnCLick behavior to link follow to access them - Doc.GetProto(summary).maximizeLocation = "inTab"; // or "inPlace", or "onRight" - this.props.addLiveTextDocument(summary); - } + const summary = Docs.Create.TextDocument("", { x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2, _width: 200, _height: 200, _fitToBox: true, _showSidebar: true, title: "overview" }); + const portal = Doc.MakeAlias(summary); + Doc.GetProto(summary)[Doc.LayoutFieldKey(summary) + "-annotations"] = new List<Doc>(selected); + Doc.GetProto(summary).layout_portal = CollectionView.LayoutString(Doc.LayoutFieldKey(summary) + "-annotations"); + summary._backgroundColor = "#e2ad32"; + portal.layoutKey = "layout_portal"; + portal.title = "document collection"; + DocUtils.MakeLink({ doc: summary }, { doc: portal }, "summarizing"); + + this.props.addLiveTextDocument(summary); MarqueeOptionsMenu.Instance.fadeOut(true); } + @action + background = (e: KeyboardEvent | React.PointerEvent | undefined) => { + const newCollection = this.getCollection([], undefined, true); + this.props.addDocument(newCollection); + MarqueeOptionsMenu.Instance.fadeOut(true); + this.hideMarquee(); + setTimeout(() => this.props.selectDocuments([newCollection], []), 0); + } @undoBatch @action @@ -388,7 +488,7 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque this.delete(); e.stopPropagation(); } - if (e.key === "c" || e.key === "t" || e.key === "s" || e.key === "S") { + if (e.key === "c" || e.key === "b" || e.key === "t" || e.key === "s" || e.key === "S" || e.key === "p") { this._commandExecuted = true; e.stopPropagation(); e.preventDefault(); @@ -396,10 +496,15 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque if (e.key === "c" || e.key === "t") { this.collection(e); } - if (e.key === "s" || e.key === "S") { this.summary(e); } + if (e.key === "b") { + this.background(e); + } + if (e.key === "p") { + this.pileup(e); + } this.cleanupInteractions(false); } } @@ -497,13 +602,19 @@ export class MarqueeView extends React.Component<SubCollectionViewProps & Marque * This contains the "C for collection, ..." text on marquees. * Commented out by syip2 when the marquee menu was added. */ - return <div className="marquee" style={{ transform: `translate(${p[0]}px, ${p[1]}px)`, width: `${Math.abs(v[0])}`, height: `${Math.abs(v[1])}`, zIndex: 2000 }} > + return <div className="marquee" style={{ + transform: `translate(${p[0]}px, ${p[1]}px)`, + width: `${Math.abs(v[0])}`, + height: `${Math.abs(v[1])}`, zIndex: 2000 + }} > {/* <span className="marquee-legend" /> */} </div>; } render() { - return <div className="marqueeView" onScroll={(e) => e.currentTarget.scrollTop = e.currentTarget.scrollLeft = 0} onClick={this.onClick} onPointerDown={this.onPointerDown}> + return <div className="marqueeView" + style={{ overflow: StrCast(this.props.Document.overflow), }} + onScroll={(e) => e.currentTarget.scrollTop = e.currentTarget.scrollLeft = 0} onClick={this.onClick} onPointerDown={this.onPointerDown}> {this._visible ? this.marqueeDiv : null} {this.props.children} </div>; diff --git a/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.scss b/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.scss index 0c74b8ddb..821c8d804 100644 --- a/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.scss +++ b/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.scss @@ -1,8 +1,8 @@ .collectionMulticolumnView_contents { display: flex; + overflow: hidden; width: 100%; height: 100%; - overflow: hidden; .document-wrapper { display: flex; diff --git a/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx b/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx index 7d8de0db4..9d09ecc3b 100644 --- a/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx +++ b/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.tsx @@ -4,8 +4,8 @@ import * as React from "react"; import { Doc } from '../../../../new_fields/Doc'; import { documentSchema } from '../../../../new_fields/documentSchemas'; import { makeInterface } from '../../../../new_fields/Schema'; -import { BoolCast, NumCast, ScriptCast, StrCast } from '../../../../new_fields/Types'; -import { DragManager } from '../../../util/DragManager'; +import { BoolCast, NumCast, ScriptCast, StrCast, Cast } from '../../../../new_fields/Types'; +import { DragManager, dropActionType } from '../../../util/DragManager'; import { Transform } from '../../../util/Transform'; import { undoBatch } from '../../../util/UndoManager'; import { ContentFittingDocumentView } from '../../nodes/ContentFittingDocumentView'; @@ -13,6 +13,8 @@ import { CollectionSubView } from '../CollectionSubView'; import "./collectionMulticolumnView.scss"; import ResizeBar from './MulticolumnResizer'; import WidthLabel from './MulticolumnWidthLabel'; +import { List } from '../../../../new_fields/List'; +import { returnZero } from '../../../../Utils'; type MulticolumnDocument = makeInterface<[typeof documentSchema]>; const MulticolumnDocument = makeInterface(documentSchema); @@ -189,8 +191,8 @@ export class CollectionMulticolumnView extends CollectionSubView(MulticolumnDocu @undoBatch @action - drop = (e: Event, de: DragManager.DropEvent) => { - if (super.drop(e, de)) { + onInternalDrop = (e: Event, de: DragManager.DropEvent) => { + if (super.onInternalDrop(e, de)) { de.complete.docDragData?.droppedDocuments.forEach(action((d: Doc) => { d.dimUnit = "*"; d.dimMagnitude = 1; @@ -202,18 +204,43 @@ export class CollectionMulticolumnView extends CollectionSubView(MulticolumnDocu @computed get onChildClickHandler() { return ScriptCast(this.Document.onChildClick); } + + addDocTab = (doc: Doc, where: string) => { + if (where === "inPlace" && this.layoutDoc.isInPlaceContainer) { + this.dataDoc[this.props.fieldKey] = new List<Doc>([doc]); + return true; + } + return this.props.addDocTab(doc, where); + } getDisplayDoc(layout: Doc, dxf: () => Transform, width: () => number, height: () => number) { return <ContentFittingDocumentView - {...this.props} Document={layout} DataDocument={layout.resolvedDataDoc as Doc} - CollectionDoc={this.props.Document} + backgroundColor={this.props.backgroundColor} + LayoutDoc={this.props.childLayoutTemplate} + LibraryPath={this.props.LibraryPath} + FreezeDimensions={this.props.freezeChildDimensions} + renderDepth={this.props.renderDepth + 1} PanelWidth={width} PanelHeight={height} - getTransform={dxf} + NativeHeight={returnZero} + NativeWidth={returnZero} + fitToBox={BoolCast(this.props.Document._freezeChildDimensions)} + rootSelected={this.rootSelected} + dropAction={StrCast(this.props.Document.childDropAction) as dropActionType} onClick={this.onChildClickHandler} - renderDepth={this.props.renderDepth + 1} - /> + getTransform={dxf} + focus={this.props.focus} + CollectionDoc={this.props.CollectionView?.props.Document} + CollectionView={this.props.CollectionView} + addDocument={this.props.addDocument} + moveDocument={this.props.moveDocument} + removeDocument={this.props.removeDocument} + active={this.props.active} + whenActiveChanged={this.props.whenActiveChanged} + addDocTab={this.addDocTab} + pinToPres={this.props.pinToPres} + />; } /** * @returns the resolved list of rendered child documents, displayed @@ -242,6 +269,7 @@ export class CollectionMulticolumnView extends CollectionSubView(MulticolumnDocu <ResizeBar width={resizerWidth} key={"resizer" + i} + select={this.props.select} columnUnitLength={this.getColumnUnitLength} toLeft={layout} toRight={childLayoutPairs[i + 1]?.layout} diff --git a/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.scss b/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.scss index 64f607680..79fb195e8 100644 --- a/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.scss +++ b/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.scss @@ -1,8 +1,8 @@ .collectionMultirowView_contents { display: flex; + overflow: hidden; width: 100%; height: 100%; - overflow: hidden; flex-direction: column; .document-wrapper { @@ -20,7 +20,7 @@ } .multiRowResizer { - cursor: ew-resize; + cursor: ns-resize; transition: 0.5s opacity ease; display: flex; flex-direction: row; diff --git a/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx b/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx index ff7c4998f..af0cc3b5c 100644 --- a/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx +++ b/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.tsx @@ -6,14 +6,15 @@ 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 { Utils, returnZero } 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'; +import { DragManager, dropActionType } from '../../../util/DragManager'; +import { List } from '../../../../new_fields/List'; type MultirowDocument = makeInterface<[typeof documentSchema]>; const MultirowDocument = makeInterface(documentSchema); @@ -190,8 +191,8 @@ export class CollectionMultirowView extends CollectionSubView(MultirowDocument) @undoBatch @action - drop = (e: Event, de: DragManager.DropEvent) => { - if (super.drop(e, de)) { + onInternalDrop = (e: Event, de: DragManager.DropEvent) => { + if (super.onInternalDrop(e, de)) { de.complete.docDragData?.droppedDocuments.forEach(action((d: Doc) => { d.dimUnit = "*"; d.dimMagnitude = 1; @@ -203,18 +204,43 @@ export class CollectionMultirowView extends CollectionSubView(MultirowDocument) @computed get onChildClickHandler() { return ScriptCast(this.Document.onChildClick); } + + addDocTab = (doc: Doc, where: string) => { + if (where === "inPlace" && this.layoutDoc.isInPlaceContainer) { + this.dataDoc[this.props.fieldKey] = new List<Doc>([doc]); + return true; + } + return this.props.addDocTab(doc, where); + } getDisplayDoc(layout: Doc, dxf: () => Transform, width: () => number, height: () => number) { return <ContentFittingDocumentView - {...this.props} Document={layout} DataDocument={layout.resolvedDataDoc as Doc} - CollectionDoc={this.props.Document} + backgroundColor={this.props.backgroundColor} + LayoutDoc={this.props.childLayoutTemplate} + LibraryPath={this.props.LibraryPath} + FreezeDimensions={this.props.freezeChildDimensions} + renderDepth={this.props.renderDepth + 1} PanelWidth={width} PanelHeight={height} - getTransform={dxf} + NativeHeight={returnZero} + NativeWidth={returnZero} + fitToBox={BoolCast(this.props.Document._freezeChildDimensions)} + rootSelected={this.rootSelected} + dropAction={StrCast(this.props.Document.childDropAction) as dropActionType} onClick={this.onChildClickHandler} - renderDepth={this.props.renderDepth + 1} - /> + getTransform={dxf} + focus={this.props.focus} + CollectionDoc={this.props.CollectionView?.props.Document} + CollectionView={this.props.CollectionView} + addDocument={this.props.addDocument} + moveDocument={this.props.moveDocument} + removeDocument={this.props.removeDocument} + active={this.props.active} + whenActiveChanged={this.props.whenActiveChanged} + addDocTab={this.addDocTab} + pinToPres={this.props.pinToPres} + />; } /** * @returns the resolved list of rendered child documents, displayed @@ -258,6 +284,8 @@ export class CollectionMultirowView extends CollectionSubView(MultirowDocument) return ( <div className={"collectionMultirowView_contents"} style={{ + width: `calc(100% - ${2 * NumCast(this.props.Document._xMargin)}px)`, + height: `calc(100% - ${2 * NumCast(this.props.Document._yMargin)}px)`, marginLeft: NumCast(this.props.Document._xMargin), marginRight: NumCast(this.props.Document._xMargin), marginTop: NumCast(this.props.Document._yMargin), marginBottom: NumCast(this.props.Document._yMargin) }} ref={this.createDashEventsTarget}> diff --git a/src/client/views/collections/collectionMulticolumn/MulticolumnResizer.tsx b/src/client/views/collections/collectionMulticolumn/MulticolumnResizer.tsx index 6b89402e6..e1e604686 100644 --- a/src/client/views/collections/collectionMulticolumn/MulticolumnResizer.tsx +++ b/src/client/views/collections/collectionMulticolumn/MulticolumnResizer.tsx @@ -4,18 +4,14 @@ import { observable, action } from "mobx"; import { Doc } from "../../../../new_fields/Doc"; import { NumCast, StrCast } from "../../../../new_fields/Types"; import { DimUnit } from "./CollectionMulticolumnView"; +import { UndoManager } from "../../../util/UndoManager"; interface ResizerProps { width: number; columnUnitLength(): number | undefined; toLeft?: Doc; toRight?: Doc; -} - -enum ResizeMode { - Global = "blue", - Pinned = "red", - Undefined = "black" + select: (isCtrlPressed: boolean) => void; } const resizerOpacity = 1; @@ -24,18 +20,19 @@ const resizerOpacity = 1; export default class ResizeBar extends React.Component<ResizerProps> { @observable private isHoverActive = false; @observable private isResizingActive = false; - @observable private resizeMode = ResizeMode.Undefined; + private _resizeUndo?: UndoManager.Batch; @action - private registerResizing = (e: React.PointerEvent<HTMLDivElement>, mode: ResizeMode) => { + private registerResizing = (e: React.PointerEvent<HTMLDivElement>) => { + this.props.select(false); 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; + this._resizeUndo = UndoManager.StartBatch("multcol resizing"); } private onPointerMove = ({ movementX }: PointerEvent) => { @@ -49,7 +46,7 @@ export default class ResizeBar extends React.Component<ResizerProps> { const scale = StrCast(toNarrow.dimUnit, "*") === DimUnit.Ratio ? unitLength : 1; toNarrow.dimMagnitude = Math.max(0.05, NumCast(toNarrow.dimMagnitude, 1) - Math.abs(movementX) / scale); } - if (this.resizeMode === ResizeMode.Pinned && toWiden) { + if (toWiden) { const scale = StrCast(toWiden.dimUnit, "*") === DimUnit.Ratio ? unitLength : 1; toWiden.dimMagnitude = Math.max(0.05, NumCast(toWiden.dimMagnitude, 1) + Math.abs(movementX) / scale); } @@ -79,11 +76,12 @@ export default class ResizeBar extends React.Component<ResizerProps> { @action private onPointerUp = () => { - this.resizeMode = ResizeMode.Undefined; this.isResizingActive = false; this.isHoverActive = false; window.removeEventListener("pointermove", this.onPointerMove); window.removeEventListener("pointerup", this.onPointerUp); + this._resizeUndo?.end(); + this._resizeUndo = undefined; } render() { @@ -97,16 +95,7 @@ export default class ResizeBar extends React.Component<ResizerProps> { onPointerEnter={action(() => this.isHoverActive = true)} onPointerLeave={action(() => !this.isResizingActive && (this.isHoverActive = false))} > - <div - className={"multiColumnResizer-hdl"} - onPointerDown={e => this.registerResizing(e, ResizeMode.Pinned)} - style={{ backgroundColor: this.resizeMode }} - /> - <div - className={"multiColumnResizer-hdl"} - onPointerDown={e => this.registerResizing(e, ResizeMode.Global)} - style={{ backgroundColor: this.resizeMode }} - /> + <div className={"multiColumnResizer-hdl"} onPointerDown={e => this.registerResizing(e)} /> </div> ); } diff --git a/src/client/views/collections/collectionMulticolumn/MultirowResizer.tsx b/src/client/views/collections/collectionMulticolumn/MultirowResizer.tsx index d00939b26..9df8cc3e2 100644 --- a/src/client/views/collections/collectionMulticolumn/MultirowResizer.tsx +++ b/src/client/views/collections/collectionMulticolumn/MultirowResizer.tsx @@ -4,6 +4,7 @@ import { observable, action } from "mobx"; import { Doc } from "../../../../new_fields/Doc"; import { NumCast, StrCast } from "../../../../new_fields/Types"; import { DimUnit } from "./CollectionMultirowView"; +import { UndoManager } from "../../../util/UndoManager"; interface ResizerProps { height: number; @@ -12,30 +13,24 @@ interface ResizerProps { 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; + private _resizeUndo?: UndoManager.Batch; @action - private registerResizing = (e: React.PointerEvent<HTMLDivElement>, mode: ResizeMode) => { + private registerResizing = (e: React.PointerEvent<HTMLDivElement>) => { 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; + this._resizeUndo = UndoManager.StartBatch("multcol resizing"); } private onPointerMove = ({ movementY }: PointerEvent) => { @@ -49,7 +44,7 @@ export default class ResizeBar extends React.Component<ResizerProps> { const scale = StrCast(toNarrow.dimUnit, "*") === DimUnit.Ratio ? unitLength : 1; toNarrow.dimMagnitude = Math.max(0.05, NumCast(toNarrow.dimMagnitude, 1) - Math.abs(movementY) / scale); } - if (this.resizeMode === ResizeMode.Pinned && toWiden) { + if (toWiden) { const scale = StrCast(toWiden.dimUnit, "*") === DimUnit.Ratio ? unitLength : 1; toWiden.dimMagnitude = Math.max(0.05, NumCast(toWiden.dimMagnitude, 1) + Math.abs(movementY) / scale); } @@ -79,11 +74,12 @@ export default class ResizeBar extends React.Component<ResizerProps> { @action private onPointerUp = () => { - this.resizeMode = ResizeMode.Undefined; this.isResizingActive = false; this.isHoverActive = false; window.removeEventListener("pointermove", this.onPointerMove); window.removeEventListener("pointerup", this.onPointerUp); + this._resizeUndo?.end(); + this._resizeUndo = undefined; } render() { @@ -97,16 +93,7 @@ export default class ResizeBar extends React.Component<ResizerProps> { onPointerEnter={action(() => this.isHoverActive = true)} onPointerLeave={action(() => !this.isResizingActive && (this.isHoverActive = false))} > - <div - className={"multiRowResizer-hdl"} - onPointerDown={e => this.registerResizing(e, ResizeMode.Pinned)} - style={{ backgroundColor: this.resizeMode }} - /> - <div - className={"multiRowResizer-hdl"} - onPointerDown={e => this.registerResizing(e, ResizeMode.Global)} - style={{ backgroundColor: this.resizeMode }} - /> + <div className={"multiRowResizer-hdl"} onPointerDown={e => this.registerResizing(e)} /> </div> ); } diff --git a/src/client/views/document_templates/caption_toggle/DetailedCaptionToggle.tsx b/src/client/views/document_templates/caption_toggle/DetailedCaptionToggle.tsx deleted file mode 100644 index 3aaf4120c..000000000 --- a/src/client/views/document_templates/caption_toggle/DetailedCaptionToggle.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import * as React from 'react'; -import { FontStyleProperty, ColorProperty } from 'csstype'; -import { observer } from 'mobx-react'; -import { observable, action, runInAction } from 'mobx'; -import { FormattedTextBox } from '../../nodes/FormattedTextBox'; -import { FieldViewProps } from '../../nodes/FieldView'; - -interface DetailedCaptionDataProps { - captionFieldKey?: string; - detailsFieldKey?: string; -} - -interface DetailedCaptionStylingProps { - sharedFontColor?: ColorProperty; - captionFontStyle?: FontStyleProperty; - detailsFontStyle?: FontStyleProperty; - toggleSize?: number; -} - -@observer -export default class DetailedCaptionToggle extends React.Component<DetailedCaptionDataProps & DetailedCaptionStylingProps & FieldViewProps> { - @observable loaded: boolean = false; - @observable detailsExpanded: boolean = false; - - @action toggleDetails = (e: React.MouseEvent<HTMLDivElement>) => { - e.preventDefault(); - e.stopPropagation(); - this.detailsExpanded = !this.detailsExpanded; - } - - componentDidMount() { - runInAction(() => this.loaded = true); - } - - render() { - const size = this.props.toggleSize || 20; - return ( - <div style={{ - transition: "0.5s opacity ease", - opacity: this.loaded ? 1 : 0, - bottom: 0, - fontSize: 14, - width: "100%", - position: "absolute" - }}> - {/* caption */} - <div style={{ opacity: this.detailsExpanded ? 0 : 1, transition: "opacity 0.3s ease" }}> - <FormattedTextBox {...this.props} fieldKey={this.props.captionFieldKey || "caption"} /> - </div> - {/* details */} - <div style={{ opacity: this.detailsExpanded ? 1 : 0, transition: "opacity 0.3s ease" }}> - <FormattedTextBox {...this.props} fieldKey={this.props.detailsFieldKey || "captiondetails"} /> - </div> - {/* toggle */} - <div - style={{ - width: size, - height: size, - borderRadius: "50%", - backgroundColor: "red", - zIndex: 3, - cursor: "pointer" - }} - onClick={this.toggleDetails} - > - <span style={{ color: "white" }}></span> - </div> - </div> - ); - } - -} diff --git a/src/client/views/document_templates/image_card/ImageCard.tsx b/src/client/views/document_templates/image_card/ImageCard.tsx deleted file mode 100644 index 868afc423..000000000 --- a/src/client/views/document_templates/image_card/ImageCard.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import * as React from 'react'; -import { FieldViewProps } from '../../nodes/FieldView'; -import { ImageBox } from '../../nodes/ImageBox'; - -export default class ImageCard extends React.Component<FieldViewProps> { - - render() { - return ( - <div style={{ padding: 30, borderRadius: 15 }}> - <ImageBox {...this.props} /> - </div> - ); - } - -}
\ No newline at end of file diff --git a/src/client/views/globalCssVariables.scss b/src/client/views/globalCssVariables.scss index 019f931f9..9d3d2e592 100644 --- a/src/client/views/globalCssVariables.scss +++ b/src/client/views/globalCssVariables.scss @@ -21,7 +21,7 @@ serif; // misc values $border-radius: 0.3em; // -$search-thumnail-size: 175; +$search-thumnail-size: 130; // dragged items $contextMenu-zindex: 100000; // context menu shows up over everything diff --git a/src/client/views/linking/LinkEditor.scss b/src/client/views/linking/LinkEditor.scss index fc5f2410c..b47c8976e 100644 --- a/src/client/views/linking/LinkEditor.scss +++ b/src/client/views/linking/LinkEditor.scss @@ -4,6 +4,7 @@ width: 100%; height: auto; font-size: 12px; // TODO + user-select: none; } .linkEditor-back { @@ -22,10 +23,9 @@ } } -.linkEditor-button { - width: 20px; - height: 20px; - margin-left: 6px; +.linkEditor-button, .linkEditor-addbutton { + width: 18px; + height: 18px; padding: 0; // font-size: 12px; border-radius: 10px; @@ -34,6 +34,9 @@ background-color: gray; } } +.linkEditor-addbutton{ + margin-left: 0px; +} .linkEditor-groupsLabel { display: flex; @@ -49,10 +52,11 @@ .linkEditor-group-row { display: flex; margin-bottom: 3px; + } - .linkEditor-group-row-label { - margin-right: 6px; - } + .linkEditor-group-row-label { + margin-right: 6px; + display:inline-block; } .linkEditor-metadata-row { @@ -118,7 +122,6 @@ .linkEditor-typeButton { background-color: transparent; color: $dark-color; - width: 100%; height: 20px; padding: 0 3px; padding-bottom: 2px; @@ -127,6 +130,8 @@ letter-spacing: normal; font-size: 12px; font-weight: bold; + display: inline-block; + width: calc(100% - 40px); &:hover { background-color: $light-color; @@ -140,6 +145,6 @@ margin-top: 5px; .linkEditor-button { - margin-left: 6px; + margin-left: 3px; } }
\ No newline at end of file diff --git a/src/client/views/linking/LinkEditor.tsx b/src/client/views/linking/LinkEditor.tsx index e3bf6b5f8..b7f3dd995 100644 --- a/src/client/views/linking/LinkEditor.tsx +++ b/src/client/views/linking/LinkEditor.tsx @@ -1,17 +1,14 @@ -import { observable, computed, action, trace } from "mobx"; -import React = require("react"); +import { library } from "@fortawesome/fontawesome-svg-core"; +import { faArrowLeft, faCog, faEllipsisV, faExchangeAlt, faPlus, faTable, faTimes, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { action, observable } from "mobx"; import { observer } from "mobx-react"; -import './LinkEditor.scss'; -import { StrCast, Cast, FieldValue } from "../../../new_fields/Types"; import { Doc } from "../../../new_fields/Doc"; -import { LinkManager } from "../../util/LinkManager"; -import { Docs } from "../../documents/Documents"; +import { StrCast } from "../../../new_fields/Types"; import { Utils } from "../../../Utils"; -import { faArrowLeft, faEllipsisV, faTable, faTrash, faCog, faExchangeAlt, faTimes, faPlus } from '@fortawesome/free-solid-svg-icons'; -import { library } from "@fortawesome/fontawesome-svg-core"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { SetupDrag } from "../../util/DragManager"; -import { SchemaHeaderField, RandomPastel } from "../../../new_fields/SchemaHeaderField"; +import { LinkManager } from "../../util/LinkManager"; +import './LinkEditor.scss'; +import React = require("react"); library.add(faArrowLeft, faEllipsisV, faTable, faTrash, faCog, faExchangeAlt, faTimes, faPlus); @@ -108,7 +105,7 @@ class GroupTypesDropdown extends React.Component<GroupTypesDropdownProps> { if (this._isEditing || this._groupType === "") { return ( <div className="linkEditor-dropdown"> - <input type="text" value={this._groupType} placeholder="Search for or create a new group" + <input type="text" value={this._groupType === "-ungrouped-" ? "" : this._groupType} placeholder="Search for or create a new group" onChange={e => this.onChange(e.target.value)} onKeyDown={this.onKeyDown} autoFocus></input> <div className="linkEditor-options-wrapper"> {this.renderOptions()} @@ -166,7 +163,7 @@ class LinkMetadataEditor extends React.Component<LinkMetadataEditorProps> { setMetadataValue = (value: string): void => { if (!this._keyError) { this._value = value; - this.props.mdDoc[this._key] = value; + Doc.GetProto(this.props.mdDoc)[this._key] = value; } } @@ -187,7 +184,7 @@ class LinkMetadataEditor extends React.Component<LinkMetadataEditorProps> { <div className="linkEditor-metadata-row"> <input className={this._keyError ? "linkEditor-error" : ""} type="text" value={this._key === "new key" ? "" : this._key} placeholder="key" onChange={e => this.setMetadataKey(e.target.value)}></input>: <input type="text" value={this._value} placeholder="value" onChange={e => this.setMetadataValue(e.target.value)}></input> - <button onClick={() => this.removeMetadata()}><FontAwesomeIcon icon="times" size="sm" /></button> + <button title="remove metadata from relationship" onClick={() => this.removeMetadata()}><FontAwesomeIcon icon="times" size="sm" /></button> </div> ); } @@ -206,15 +203,13 @@ export class LinkGroupEditor extends React.Component<LinkGroupEditorProps> { constructor(props: LinkGroupEditorProps) { super(props); - const groupMdKeys = LinkManager.Instance.getMetadataKeysInGroup(StrCast(props.groupDoc.type)); - groupMdKeys.forEach(key => { - this._metadataIds.set(key, Utils.GenerateGuid()); - }); + const groupMdKeys = LinkManager.Instance.getMetadataKeysInGroup(StrCast(props.groupDoc.linkRelationship)); + groupMdKeys.forEach(key => this._metadataIds.set(key, Utils.GenerateGuid())); } @action setGroupType = (groupType: string): void => { - this.props.groupDoc.type = groupType; + Doc.GetProto(this.props.groupDoc).linkRelationship = groupType; } removeGroupFromLink = (groupType: string): void => { @@ -225,33 +220,6 @@ export class LinkGroupEditor extends React.Component<LinkGroupEditorProps> { LinkManager.Instance.deleteGroupType(groupType); } - copyGroup = async (groupType: string): Promise<void> => { - const sourceGroupDoc = this.props.groupDoc; - const sourceMdDoc = await Cast(sourceGroupDoc.metadata, Doc); - if (!sourceMdDoc) return; - - const destDoc = LinkManager.Instance.getOppositeAnchor(this.props.linkDoc, this.props.sourceDoc); - // let destGroupList = LinkManager.Instance.getAnchorGroups(this.props.linkDoc, destDoc); - const keys = LinkManager.Instance.getMetadataKeysInGroup(groupType); - - // create new metadata doc with copied kvp - const destMdDoc = new Doc(); - destMdDoc.anchor1 = StrCast(sourceMdDoc.anchor2); - destMdDoc.anchor2 = StrCast(sourceMdDoc.anchor1); - keys.forEach(key => { - const val = sourceMdDoc[key] === undefined ? "" : StrCast(sourceMdDoc[key]); - destMdDoc[key] = val; - }); - - // create new group doc with new metadata doc - const destGroupDoc = new Doc(); - destGroupDoc.type = groupType; - destGroupDoc.metadata = destMdDoc; - - if (destDoc) { - LinkManager.Instance.addGroupToAnchor(this.props.linkDoc, destDoc, destGroupDoc, true); - } - } @action addMetadata = (groupType: string): void => { @@ -270,69 +238,34 @@ export class LinkGroupEditor extends React.Component<LinkGroupEditorProps> { renderMetadata = (): JSX.Element[] => { const metadata: Array<JSX.Element> = []; const groupDoc = this.props.groupDoc; - const mdDoc = FieldValue(Cast(groupDoc.metadata, Doc)); - if (!mdDoc) { - return []; - } - const groupType = StrCast(groupDoc.type); + const groupType = StrCast(groupDoc.linkRelationship); const groupMdKeys = LinkManager.Instance.getMetadataKeysInGroup(groupType); groupMdKeys.forEach((key) => { - const val = StrCast(mdDoc[key]); + const val = StrCast(groupDoc[key]); metadata.push( - <LinkMetadataEditor key={"mded-" + this._metadataIds.get(key)} id={this._metadataIds.get(key)!} groupType={groupType} mdDoc={mdDoc} mdKey={key} mdValue={val} changeMdIdKey={this.changeMdIdKey} /> + <LinkMetadataEditor key={"mded-" + this._metadataIds.get(key)} id={this._metadataIds.get(key)!} groupType={groupType} mdDoc={groupDoc} mdKey={key} mdValue={val} changeMdIdKey={this.changeMdIdKey} /> ); }); return metadata; } - viewGroupAsTable = (groupType: string): JSX.Element => { - const keys = LinkManager.Instance.getMetadataKeysInGroup(groupType); - const index = keys.indexOf(""); - if (index > -1) keys.splice(index, 1); - const cols = ["anchor1", "anchor2", ...[...keys]].map(c => new SchemaHeaderField(c, "#f1efeb")); - const docs: Doc[] = LinkManager.Instance.getAllMetadataDocsInGroup(groupType); - const createTable = action(() => Docs.Create.SchemaDocument(cols, docs, { _width: 500, _height: 300, title: groupType + " table" })); - const ref = React.createRef<HTMLDivElement>(); - return <div ref={ref}><button className="linkEditor-button" onPointerDown={SetupDrag(ref, createTable)} title="Drag to view relationship table"><FontAwesomeIcon icon="table" size="sm" /></button></div>; - } - render() { - const groupType = StrCast(this.props.groupDoc.type); + const groupType = StrCast(this.props.groupDoc.linkRelationship); // if ((groupType && LinkManager.Instance.getMetadataKeysInGroup(groupType).length > 0) || groupType === "") { - let buttons; - if (groupType === "") { - buttons = ( - <> - <button className="linkEditor-button" disabled={true} title="Add KVP"><FontAwesomeIcon icon="plus" size="sm" /></button> - <button className="linkEditor-button" disabled title="Copy group to opposite anchor"><FontAwesomeIcon icon="exchange-alt" size="sm" /></button> - <button className="linkEditor-button" onClick={() => this.removeGroupFromLink(groupType)} title="Remove group from link"><FontAwesomeIcon icon="times" size="sm" /></button> - <button className="linkEditor-button" disabled title="Delete group"><FontAwesomeIcon icon="trash" size="sm" /></button> - <button className="linkEditor-button" disabled title="Drag to view relationship table"><FontAwesomeIcon icon="table" size="sm" /></button> - </> - ); - } else { - buttons = ( - <> - <button className="linkEditor-button" onClick={() => this.addMetadata(groupType)} title="Add KVP"><FontAwesomeIcon icon="plus" size="sm" /></button> - <button className="linkEditor-button" onClick={() => this.copyGroup(groupType)} title="Copy group to opposite anchor"><FontAwesomeIcon icon="exchange-alt" size="sm" /></button> - <button className="linkEditor-button" onClick={() => this.removeGroupFromLink(groupType)} title="Remove group from link"><FontAwesomeIcon icon="times" size="sm" /></button> - <button className="linkEditor-button" onClick={() => this.deleteGroup(groupType)} title="Delete group"><FontAwesomeIcon icon="trash" size="sm" /></button> - {this.viewGroupAsTable(groupType)} - </> - ); - } + const buttons = <button className="linkEditor-button" disabled={groupType === ""} onClick={() => this.deleteGroup(groupType)} title="Delete Relationship from all links"><FontAwesomeIcon icon="trash" size="sm" /></button>; + const addButton = <button className="linkEditor-addbutton" onClick={() => this.addMetadata(groupType)} disabled={groupType === ""} title="Add metadata to relationship"><FontAwesomeIcon icon="plus" size="sm" /></button>; + return ( <div className="linkEditor-group"> <div className="linkEditor-group-row "> - <p className="linkEditor-group-row-label">type:</p> + {buttons} <GroupTypesDropdown groupType={groupType} setGroupType={this.setGroupType} /> + <button className="linkEditor-button" onClick={() => this.removeGroupFromLink(groupType)} title="Remove relationship from link"><FontAwesomeIcon icon="times" size="sm" /></button> </div> {this.renderMetadata().length > 0 ? <p className="linkEditor-group-row-label">metadata:</p> : <></>} + {addButton} {this.renderMetadata()} - <div className="linkEditor-group-buttons"> - {buttons} - </div> </div> ); } @@ -343,6 +276,7 @@ interface LinkEditorProps { sourceDoc: Doc; linkDoc: Doc; showLinks: () => void; + hideback?: boolean; } @observer export class LinkEditor extends React.Component<LinkEditorProps> { @@ -353,48 +287,23 @@ export class LinkEditor extends React.Component<LinkEditorProps> { this.props.showLinks(); } - @action - addGroup = (): void => { - // create new metadata document for group - const mdDoc = new Doc(); - mdDoc.anchor1 = this.props.sourceDoc.title; - const opp = LinkManager.Instance.getOppositeAnchor(this.props.linkDoc, this.props.sourceDoc); - if (opp) { - mdDoc.anchor2 = opp.title; - } - - // create new group document - const groupDoc = new Doc(); - groupDoc.type = ""; - groupDoc.metadata = mdDoc; - - LinkManager.Instance.addGroupToAnchor(this.props.linkDoc, this.props.sourceDoc, groupDoc); - } - render() { const destination = LinkManager.Instance.getOppositeAnchor(this.props.linkDoc, this.props.sourceDoc); - const groupList = LinkManager.Instance.getAnchorGroups(this.props.linkDoc, this.props.sourceDoc); - const groups = groupList.map(groupDoc => { - return <LinkGroupEditor key={"gred-" + StrCast(groupDoc.type)} linkDoc={this.props.linkDoc} sourceDoc={this.props.sourceDoc} groupDoc={groupDoc} />; + const groups = [this.props.linkDoc].map(groupDoc => { + return <LinkGroupEditor key={"gred-" + StrCast(groupDoc.linkRelationship)} linkDoc={this.props.linkDoc} sourceDoc={this.props.sourceDoc} groupDoc={groupDoc} />; }); - if (destination) { - return ( - <div className="linkEditor"> - <button className="linkEditor-back" onPointerDown={() => this.props.showLinks()}><FontAwesomeIcon icon="arrow-left" size="sm" /></button> - <div className="linkEditor-info"> - <p className="linkEditor-linkedTo">editing link to: <b>{destination.proto!.title}</b></p> - <button className="linkEditor-button" onPointerDown={() => this.deleteLink()} title="Delete link"><FontAwesomeIcon icon="trash" size="sm" /></button> - </div> - <div className="linkEditor-groupsLabel"> - <b>Relationships:</b> - <button className="linkEditor-button" onClick={() => this.addGroup()} title=" Add Group"><FontAwesomeIcon icon="plus" size="sm" /></button> - </div> - {groups.length > 0 ? groups : <div className="linkEditor-group">There are currently no relationships associated with this link.</div>} + return !destination ? (null) : ( + <div className="linkEditor"> + {this.props.hideback ? (null) : <button className="linkEditor-back" onPointerDown={() => this.props.showLinks()}><FontAwesomeIcon icon="arrow-left" size="sm" /></button>} + <div className="linkEditor-info"> + <p className="linkEditor-linkedTo">editing link to: <b>{destination.proto!.title}</b></p> + <button className="linkEditor-button" onPointerDown={() => this.deleteLink()} title="Delete link"><FontAwesomeIcon icon="trash" size="sm" /></button> </div> + {groups.length > 0 ? groups : <div className="linkEditor-group">There are currently no relationships associated with this link.</div>} + </div> - ); - } + ); } }
\ No newline at end of file diff --git a/src/client/views/linking/LinkFollowBox.scss b/src/client/views/linking/LinkFollowBox.scss deleted file mode 100644 index 9eeed1cc8..000000000 --- a/src/client/views/linking/LinkFollowBox.scss +++ /dev/null @@ -1,93 +0,0 @@ -@import "../globalCssVariables"; - -.linkFollowBox-main { - position: absolute; - background: whitesmoke; - color: grey; - border-radius: 15px; - box-shadow: $intermediate-color 0.2vw 0.2vw 0.4vw; - border: solid #BBBBBBBB 5px; - pointer-events: all; - - .linkFollowBox-header { - height: 50px; - text-align: center; - text-transform: uppercase; - letter-spacing: 2px; - font-size: 16px; - width: 100%; - } - - .direction-indicator { - font-size: 12px; - } - - .closeDocument { - position: relative; - max-width: 30px; - top: -20px; - left: 460px; - color: $darker-alt-accent - } - - .closeDocument:hover { - color: $main-accent; - } - - .topHeader { - width: 100%; - height: 25px; - } - - .linkFollowBox-footer { - height: 50px; - text-align: center; - display: flex; - justify-content: center; - align-items: center; - - button { - background-color: $darker-alt-accent; - width: 30%; - } - } - - .linkFollowBox-content { - display: grid; - grid-template-columns: 1fr 1fr 1fr; - grid-column-gap: 5px; - margin-left: 5px; - margin-right: 5px; - - .linkFollowBox-item { - background-color: $light-color; - width: 100%; - height: 100%; - - .linkFollowBox-itemContent { - padding: 5px; - font-size: 12px; - overflow: scroll; - - input[type=radio] { - border: 0px; - margin-right: 5px; - } - } - - .title { - display: flex; - justify-content: center; - align-items: center; - text-transform: uppercase; - color: $light-color; - background-color: $lighter-alt-accent; - width: 100%; - height: 30px; - border-bottom: solid $darker-alt-accent 5px; - font-size: 12px; - text-align: center; - } - } - } -}
\ No newline at end of file diff --git a/src/client/views/linking/LinkMenu.tsx b/src/client/views/linking/LinkMenu.tsx index 1a40f0c55..b768eacc3 100644 --- a/src/client/views/linking/LinkMenu.tsx +++ b/src/client/views/linking/LinkMenu.tsx @@ -16,7 +16,7 @@ library.add(faTrash); interface Props { docView: DocumentView; changeFlyout: () => void; - addDocTab: (document: Doc, dataDoc: Doc | undefined, where: string) => boolean; + addDocTab: (document: Doc, where: string) => boolean; } @observer @@ -60,7 +60,7 @@ export class LinkMenu extends React.Component<Props> { if (this._editingLink === undefined) { return ( <div className="linkMenu"> - <button className="linkEditor-button linkEditor-clearButton" onClick={() => this.clearAllLinks()} title="Clear all links"><FontAwesomeIcon icon="trash" size="sm" /></button> + {/* <button className="linkEditor-button linkEditor-clearButton" onClick={() => this.clearAllLinks()} title="Clear all links"><FontAwesomeIcon icon="trash" size="sm" /></button> */} {/* <input id="linkMenu-searchBar" type="text" placeholder="Search..."></input> */} <div className="linkMenu-list"> {this.renderAllGroups(groups)} diff --git a/src/client/views/linking/LinkMenuGroup.tsx b/src/client/views/linking/LinkMenuGroup.tsx index 0c38ff45c..928413a11 100644 --- a/src/client/views/linking/LinkMenuGroup.tsx +++ b/src/client/views/linking/LinkMenuGroup.tsx @@ -17,7 +17,7 @@ interface LinkMenuGroupProps { group: Doc[]; groupType: string; showEditor: (linkDoc: Doc) => void; - addDocTab: (document: Doc, dataDoc: Doc | undefined, where: string) => boolean; + addDocTab: (document: Doc, where: string) => boolean; docView: DocumentView; } @@ -47,7 +47,7 @@ export class LinkMenuGroup extends React.Component<LinkMenuGroupProps> { document.removeEventListener("pointerup", this.onLinkButtonUp); const targets = this.props.group.map(l => LinkManager.Instance.getOppositeAnchor(l, this.props.sourceDoc)).filter(d => d) as Doc[]; - DragManager.StartLinkTargetsDrag(this._drag.current, e.x, e.y, this.props.sourceDoc, targets); + DragManager.StartLinkTargetsDrag(this._drag.current, this.props.docView, e.x, e.y, this.props.sourceDoc, targets); } e.stopPropagation(); } @@ -58,7 +58,7 @@ export class LinkMenuGroup extends React.Component<LinkMenuGroupProps> { if (index > -1) keys.splice(index, 1); const cols = ["anchor1", "anchor2", ...[...keys]].map(c => new SchemaHeaderField(c, "#f1efeb")); const docs: Doc[] = LinkManager.Instance.getAllMetadataDocsInGroup(groupType); - const createTable = action(() => Docs.Create.SchemaDocument(cols, docs, { _width: 500, _height: 300, title: groupType + " table" })); + const createTable = action(() => Docs.Create.SchemaDocument(cols, docs, { _width: 500, _height: 300, title: groupType + " table", childDropAction: "alias" })); const ref = React.createRef<HTMLDivElement>(); return <div ref={ref}><button className="linkEditor-button linkEditor-tableButton" onPointerDown={SetupDrag(ref, createTable)} title="Drag to view relationship table"><FontAwesomeIcon icon="table" size="sm" /></button></div>; } @@ -70,6 +70,7 @@ export class LinkMenuGroup extends React.Component<LinkMenuGroupProps> { return <LinkMenuItem key={destination[Id] + this.props.sourceDoc[Id]} groupType={this.props.groupType} addDocTab={this.props.addDocTab} + docView={this.props.docView} linkDoc={linkDoc} sourceDoc={this.props.sourceDoc} destinationDoc={destination} diff --git a/src/client/views/linking/LinkMenuItem.tsx b/src/client/views/linking/LinkMenuItem.tsx index b7d27ee30..d091e06ef 100644 --- a/src/client/views/linking/LinkMenuItem.tsx +++ b/src/client/views/linking/LinkMenuItem.tsx @@ -8,19 +8,22 @@ import { Cast, StrCast } from '../../../new_fields/Types'; import { DragManager } from '../../util/DragManager'; import { LinkManager } from '../../util/LinkManager'; import { ContextMenu } from '../ContextMenu'; -import { LinkFollowBox } from './LinkFollowBox'; import './LinkMenuItem.scss'; import React = require("react"); +import { DocumentManager } from '../../util/DocumentManager'; +import { setupMoveUpEvents, emptyFunction } from '../../../Utils'; +import { DocumentView } from '../nodes/DocumentView'; library.add(faEye, faEdit, faTimes, faArrowRight, faChevronDown, faChevronUp); interface LinkMenuItemProps { groupType: string; linkDoc: Doc; + docView: DocumentView; sourceDoc: Doc; destinationDoc: Doc; showEditor: (linkDoc: Doc) => void; - addDocTab: (document: Doc, dataDoc: Doc | undefined, where: string) => boolean; + addDocTab: (document: Doc, where: string) => boolean; } @observer @@ -29,29 +32,28 @@ export class LinkMenuItem extends React.Component<LinkMenuItemProps> { private _downX = 0; private _downY = 0; private _eleClone: any; + + _editRef = React.createRef<HTMLDivElement>(); @observable private _showMore: boolean = false; - @action toggleShowMore() { this._showMore = !this._showMore; } + @action toggleShowMore(e: React.PointerEvent) { e.stopPropagation(); this._showMore = !this._showMore; } onEdit = (e: React.PointerEvent): void => { - e.stopPropagation(); - this.props.showEditor(this.props.linkDoc); - //SelectionManager.DeselectAll(); + setupMoveUpEvents(this, e, this.editMoved, emptyFunction, () => this.props.showEditor(this.props.linkDoc)); + } + + editMoved = (e: PointerEvent) => { + DragManager.StartDocumentDrag([this._editRef.current!], new DragManager.DocumentDragData([this.props.linkDoc]), e.x, e.y); + return true; } renderMetadata = (): JSX.Element => { - const groups = LinkManager.Instance.getAnchorGroups(this.props.linkDoc, this.props.sourceDoc); - const index = groups.findIndex(groupDoc => StrCast(groupDoc.type).toUpperCase() === this.props.groupType.toUpperCase()); - const groupDoc = index > -1 ? groups[index] : undefined; + const index = StrCast(this.props.linkDoc.title).toUpperCase() === this.props.groupType.toUpperCase() ? 0 : -1; + const mdDoc = index > -1 ? this.props.linkDoc : undefined; let mdRows: Array<JSX.Element> = []; - if (groupDoc) { - const mdDoc = Cast(groupDoc.metadata, Doc, null); - if (mdDoc) { - const keys = LinkManager.Instance.getMetadataKeysInGroup(this.props.groupType);//groupMetadataKeys.get(this.props.groupType); - mdRows = keys.map(key => { - return (<div key={key} className="link-metadata-row"><b>{key}</b>: {StrCast(mdDoc[key])}</div>); - }); - } + if (mdDoc) { + const keys = LinkManager.Instance.getMetadataKeysInGroup(this.props.groupType);//groupMetadataKeys.get(this.props.groupType); + mdRows = keys.map(key => <div key={key} className="link-metadata-row"><b>{key}</b>: {StrCast(mdDoc[key])}</div>); } return (<div className="link-metadata">{mdRows}</div>); @@ -72,11 +74,6 @@ export class LinkMenuItem extends React.Component<LinkMenuItemProps> { document.removeEventListener("pointermove", this.onLinkButtonMoved); document.removeEventListener("pointerup", this.onLinkButtonUp); - if (LinkFollowBox.Instance !== undefined) { - LinkFollowBox.Instance.props.Document.isMinimized = false; - LinkFollowBox.Instance.setLinkDocs(this.props.linkDoc, this.props.sourceDoc, this.props.destinationDoc); - LinkFollowBox.setAddDocTab(this.props.addDocTab); - } e.stopPropagation(); } @@ -86,33 +83,20 @@ export class LinkMenuItem extends React.Component<LinkMenuItemProps> { document.removeEventListener("pointerup", this.onLinkButtonUp); this._eleClone.style.transform = `translate(${e.x}px, ${e.y}px)`; - DragManager.StartLinkTargetsDrag(this._eleClone, e.x, e.y, this.props.sourceDoc, [this.props.linkDoc]); + DragManager.StartLinkTargetsDrag(this._eleClone, this.props.docView, e.x, e.y, this.props.sourceDoc, [this.props.linkDoc]); } e.stopPropagation(); } onContextMenu = (e: React.MouseEvent) => { e.preventDefault(); - ContextMenu.Instance.addItem({ description: "Open in Link Follower", event: () => this.openLinkFollower(), icon: "link" }); ContextMenu.Instance.addItem({ description: "Follow Default Link", event: () => this.followDefault(), icon: "arrow-right" }); ContextMenu.Instance.displayMenu(e.clientX, e.clientY); } @action.bound async followDefault() { - if (LinkFollowBox.Instance !== undefined) { - LinkFollowBox.setAddDocTab(this.props.addDocTab); - LinkFollowBox.Instance.setLinkDocs(this.props.linkDoc, this.props.sourceDoc, this.props.destinationDoc); - LinkFollowBox.Instance.defaultLinkBehavior(); - } - } - - @action.bound - async openLinkFollower() { - if (LinkFollowBox.Instance !== undefined) { - LinkFollowBox.Instance.props.Document.isMinimized = false; - LinkFollowBox.Instance.setLinkDocs(this.props.linkDoc, this.props.sourceDoc, this.props.destinationDoc); - } + DocumentManager.Instance.FollowLink(this.props.linkDoc, this.props.sourceDoc, doc => this.props.addDocTab(doc, "onRight"), false); } render() { @@ -125,9 +109,9 @@ export class LinkMenuItem extends React.Component<LinkMenuItemProps> { <div ref={this._drag} className="linkMenu-name" title="drag to view target. click to customize." onPointerDown={this.onLinkButtonDown}> <p >{StrCast(this.props.destinationDoc.title)}</p> <div className="linkMenu-item-buttons"> - {canExpand ? <div title="Show more" className="button" onPointerDown={() => this.toggleShowMore()}> + {canExpand ? <div title="Show more" className="button" onPointerDown={e => this.toggleShowMore(e)}> <FontAwesomeIcon className="fa-icon" icon={this._showMore ? "chevron-up" : "chevron-down"} size="sm" /></div> : <></>} - <div title="Edit link" className="button" onPointerDown={this.onEdit}><FontAwesomeIcon className="fa-icon" icon="edit" size="sm" /></div> + <div title="Edit link" className="button" ref={this._editRef} onPointerDown={this.onEdit}><FontAwesomeIcon className="fa-icon" icon="edit" size="sm" /></div> <div title="Follow link" className="button" onClick={this.followDefault} onContextMenu={this.onContextMenu}> <FontAwesomeIcon className="fa-icon" icon="arrow-right" size="sm" /> </div> diff --git a/src/client/views/nodes/AudioBox.scss b/src/client/views/nodes/AudioBox.scss index 3b19a6dba..53b54d7e4 100644 --- a/src/client/views/nodes/AudioBox.scss +++ b/src/client/views/nodes/AudioBox.scss @@ -5,11 +5,15 @@ display:flex; pointer-events: all; cursor:default; + .audiobox-buttons { + display: flex; + width: 100%; + align-items: center; + } .audiobox-handle { width:20px; height:100%; display:inline-block; - background: gray; } .audiobox-control, .audiobox-control-interactive { top:0; @@ -25,11 +29,14 @@ pointer-events: all; width:100%; height:100%; - position: absolute; + position: relative; pointer-events: none; } .audiobox-record-interactive { pointer-events: all; + width:100%; + height:100%; + position: relative; } .audiobox-controls { width:100%; @@ -37,7 +44,6 @@ position: relative; display: flex; padding-left: 2px; - border: gray solid 3px; .audiobox-player { margin-top:auto; margin-bottom:auto; @@ -46,13 +52,18 @@ position: relative; padding-right: 5px; display: flex; - .audiobox-playhead { + .audiobox-playhead, .audiobox-dictation { position: relative; margin-top: auto; margin-bottom: auto; width: 25px; padding: 2px; } + .audiobox-dictation { + align-items: center; + display: inherit; + background: dimgray; + } .audiobox-timeline { position:relative; height:100%; @@ -74,9 +85,10 @@ margin-left:-2.55px; background:gray; border-radius: 100%; + opacity:0.9; background-color: transparent; box-shadow: black 2px 2px 1px; - .docuLinkBox-cont { + .linkAnchorBox-cont { position: relative !important; height: 100% !important; width: 100% !important; @@ -91,7 +103,7 @@ box-shadow: black 1px 1px 1px; margin-left: -1; margin-top: -2; - .docuLinkBox-cont { + .linkAnchorBox-cont { position: relative !important; height: 100% !important; width: 100% !important; @@ -100,7 +112,7 @@ } } .audiobox-linker:hover, .audiobox-linker-mini:hover { - transform:scale(1.5); + opacity:1; } .audiobox-marker-container, .audiobox-marker-minicontainer { position:absolute; diff --git a/src/client/views/nodes/AudioBox.tsx b/src/client/views/nodes/AudioBox.tsx index 62a479b2a..6ff6d1b42 100644 --- a/src/client/views/nodes/AudioBox.tsx +++ b/src/client/views/nodes/AudioBox.tsx @@ -4,10 +4,10 @@ import { observer } from "mobx-react"; import "./AudioBox.scss"; import { Cast, DateCast, NumCast } from "../../../new_fields/Types"; import { AudioField, nullAudio } from "../../../new_fields/URLField"; -import { DocExtendableComponent } from "../DocComponent"; +import { ViewBoxBaseComponent } from "../DocComponent"; import { makeInterface, createSchema } from "../../../new_fields/Schema"; import { documentSchema } from "../../../new_fields/documentSchemas"; -import { Utils, returnTrue, emptyFunction, returnOne, returnTransparent } from "../../../Utils"; +import { Utils, returnTrue, emptyFunction, returnOne, returnTransparent, returnFalse, returnZero } from "../../../Utils"; import { runInAction, observable, reaction, IReactionDisposer, computed, action } from "mobx"; import { DateField } from "../../../new_fields/DateField"; import { SelectionManager } from "../../util/SelectionManager"; @@ -17,6 +17,12 @@ import { ContextMenu } from "../ContextMenu"; import { Id } from "../../../new_fields/FieldSymbols"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { DocumentView } from "./DocumentView"; +import { Docs } from "../../documents/Documents"; +import { ComputedField } from "../../../new_fields/ScriptField"; +import { Networking } from "../../Network"; +import { Upload } from "../../../server/SharedMediaTypes"; + +// testing testing interface Window { MediaRecorder: MediaRecorder; @@ -34,7 +40,7 @@ type AudioDocument = makeInterface<[typeof documentSchema, typeof audioSchema]>; const AudioDocument = makeInterface(documentSchema, audioSchema); @observer -export class AudioBox extends DocExtendableComponent<FieldViewProps, AudioDocument>(AudioDocument) { +export class AudioBox extends ViewBoxBaseComponent<FieldViewProps, AudioDocument>(AudioDocument) { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(AudioBox, fieldKey); } public static Enabled = false; @@ -44,131 +50,127 @@ export class AudioBox extends DocExtendableComponent<FieldViewProps, AudioDocume _ele: HTMLAudioElement | null = null; _recorder: any; _recordStart = 0; + _stream: MediaStream | undefined; @observable private static _scrubTime = 0; - @observable private _audioState: "unrecorded" | "recording" | "recorded" = "unrecorded"; - @observable private _playing = false; - public static SetScrubTime = action((timeInMillisFrom1970: number) => AudioBox._scrubTime = timeInMillisFrom1970); + @computed get audioState(): undefined | "recording" | "paused" | "playing" { return this.dataDoc.audioState as (undefined | "recording" | "paused" | "playing"); } + set audioState(value) { this.dataDoc.audioState = value; } + public static SetScrubTime = (timeInMillisFrom1970: number) => { runInAction(() => AudioBox._scrubTime = 0); runInAction(() => AudioBox._scrubTime = timeInMillisFrom1970); }; public static ActiveRecordings: Doc[] = []; + @computed get recordingStart() { return Cast(this.dataDoc[this.props.fieldKey + "-recordingStart"], DateField)?.date.getTime(); } + async slideTemplate() { return (await Cast((await Cast(Doc.UserDoc().slidesBtn, Doc) as Doc).dragFactory, Doc) as Doc); } + + componentWillUnmount() { + this._reactionDisposer?.(); + this._linkPlayDisposer?.(); + this._scrubbingDisposer?.(); + } componentDidMount() { - runInAction(() => this._audioState = this.path ? "recorded" : "unrecorded"); + runInAction(() => this.audioState = this.path ? "paused" : undefined); this._linkPlayDisposer = reaction(() => this.layoutDoc.scrollToLinkID, scrollLinkId => { - scrollLinkId && DocListCast(this.dataDoc.links).filter(l => l[Id] === scrollLinkId).map(l => { - const la1 = l.anchor1 as Doc; - const linkTime = Doc.AreProtosEqual(la1, this.dataDoc) ? NumCast(l.anchor1Timecode) : NumCast(l.anchor2Timecode); - setTimeout(() => { this.playFrom(linkTime); Doc.linkFollowHighlight(l); }, 250); - }); - scrollLinkId && Doc.SetInPlace(this.layoutDoc, "scrollToLinkID", undefined, false); + if (scrollLinkId) { + DocListCast(this.dataDoc.links).filter(l => l[Id] === scrollLinkId).map(l => { + const linkTime = Doc.AreProtosEqual(l.anchor1 as Doc, this.dataDoc) ? NumCast(l.anchor1_timecode) : NumCast(l.anchor2_timecode); + setTimeout(() => { this.playFromTime(linkTime); Doc.linkFollowHighlight(l); }, 250); + }); + Doc.SetInPlace(this.layoutDoc, "scrollToLinkID", undefined, false); + } }, { fireImmediately: true }); this._reactionDisposer = reaction(() => SelectionManager.SelectedDocuments(), selected => { const sel = selected.length ? selected[0].props.Document : undefined; - this.Document.playOnSelect && sel && !Doc.AreProtosEqual(sel, this.props.Document) && this.playFrom(DateCast(sel.creationTime).date.getTime()); + this.layoutDoc.playOnSelect && this.recordingStart && sel && sel.creationDate && !Doc.AreProtosEqual(sel, this.props.Document) && this.playFromTime(DateCast(sel.creationDate).date.getTime()); + this.layoutDoc.playOnSelect && this.recordingStart && !sel && this.pause(); }); - this._scrubbingDisposer = reaction(() => AudioBox._scrubTime, timeInMillisecondsFrom1970 => { - const start = DateCast(this.dataDoc[this.props.fieldKey + "-recordingStart"]); - start && this.playFrom((timeInMillisecondsFrom1970 - start.date.getTime()) / 1000); - }); + this._scrubbingDisposer = reaction(() => AudioBox._scrubTime, (time) => this.layoutDoc.playOnSelect && this.playFromTime(AudioBox._scrubTime)); } timecodeChanged = () => { const htmlEle = this._ele; - if (this._audioState === "recorded" && htmlEle) { + if (this.audioState !== "recording" && htmlEle) { htmlEle.duration && htmlEle.duration !== Infinity && runInAction(() => this.dataDoc.duration = htmlEle.duration); DocListCast(this.dataDoc.links).map(l => { let la1 = l.anchor1 as Doc; - let linkTime = NumCast(l.anchor2Timecode); + let linkTime = NumCast(l.anchor2_timecode); if (Doc.AreProtosEqual(la1, this.dataDoc)) { + linkTime = NumCast(l.anchor1_timecode); la1 = l.anchor2 as Doc; - linkTime = NumCast(l.anchor1Timecode); } - if (linkTime > NumCast(this.Document.currentTimecode) && linkTime < htmlEle.currentTime) { + if (linkTime > NumCast(this.layoutDoc.currentTimecode) && linkTime < htmlEle.currentTime) { Doc.linkFollowHighlight(la1); } }); - this.Document.currentTimecode = htmlEle.currentTime; + this.layoutDoc.currentTimecode = htmlEle.currentTime; } } pause = action(() => { this._ele!.pause(); - this._playing = false; + this.audioState = "paused"; }); + playFromTime = (absoluteTime: number) => { + this.recordingStart && this.playFrom((absoluteTime - this.recordingStart) / 1000); + } playFrom = (seekTimeInSeconds: number) => { if (this._ele && AudioBox.Enabled) { if (seekTimeInSeconds < 0) { - this.pause(); + if (seekTimeInSeconds > -1) { + setTimeout(() => this.playFrom(0), -seekTimeInSeconds * 1000); + } else { + this.pause(); + } } else if (seekTimeInSeconds <= this._ele.duration) { this._ele.currentTime = seekTimeInSeconds; this._ele.play(); - runInAction(() => this._playing = true); + runInAction(() => this.audioState = "playing"); } else { this.pause(); } } } - componentWillUnmount() { - this._reactionDisposer && this._reactionDisposer(); - this._linkPlayDisposer && this._linkPlayDisposer(); - this._scrubbingDisposer && this._scrubbingDisposer(); - } - updateRecordTime = () => { - if (this._audioState === "recording") { + if (this.audioState === "recording") { setTimeout(this.updateRecordTime, 30); - this.Document.currentTimecode = (new Date().getTime() - this._recordStart) / 1000; + this.layoutDoc.currentTimecode = (new Date().getTime() - this._recordStart) / 1000; } } - recordAudioAnnotation = () => { - let gumStream: any; - const self = this; - navigator.mediaDevices.getUserMedia({ - audio: true - }).then(function (stream) { - gumStream = stream; - self._recorder = new MediaRecorder(stream); - self.dataDoc[self.props.fieldKey + "-recordingStart"] = new DateField(new Date()); - AudioBox.ActiveRecordings.push(self.props.Document); - self._recorder.ondataavailable = async function (e: any) { - const formData = new FormData(); - formData.append("file", e.data); - const res = await fetch(Utils.prepend("/uploadFormData"), { - method: 'POST', - body: formData - }); - const files = await res.json(); - const url = Utils.prepend(files[0].path); - // upload to server with known URL - self.props.Document[self.props.fieldKey] = new AudioField(url); - }; - runInAction(() => self._audioState = "recording"); - self._recordStart = new Date().getTime(); - setTimeout(self.updateRecordTime, 0); - self._recorder.start(); - setTimeout(() => { - self.stopRecording(); - gumStream.getAudioTracks()[0].stop(); - }, 60 * 60 * 1000); // stop after an hour? - }); + recordAudioAnnotation = async () => { + this._stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + this._recorder = new MediaRecorder(this._stream); + this.dataDoc[this.props.fieldKey + "-recordingStart"] = new DateField(new Date()); + AudioBox.ActiveRecordings.push(this.props.Document); + this._recorder.ondataavailable = async (e: any) => { + const [{ result }] = await Networking.UploadFilesToServer(e.data); + if (!(result instanceof Error)) { + this.props.Document[this.props.fieldKey] = new AudioField(Utils.prepend(result.accessPaths.agnostic.client)); + } + }; + this._recordStart = new Date().getTime(); + runInAction(() => this.audioState = "recording"); + setTimeout(this.updateRecordTime, 0); + this._recorder.start(); + setTimeout(() => this._recorder && this.stopRecording(), 60 * 1000); // stop after an hour } specificContextMenu = (e: React.MouseEvent): void => { const funcs: ContextMenuProps[] = []; - funcs.push({ description: (this.Document.playOnSelect ? "Don't play" : "Play") + " when document selected", event: () => this.Document.playOnSelect = !this.Document.playOnSelect, icon: "expand-arrows-alt" }); + funcs.push({ description: (this.layoutDoc.playOnSelect ? "Don't play" : "Play") + " when document selected", event: () => this.layoutDoc.playOnSelect = !this.layoutDoc.playOnSelect, icon: "expand-arrows-alt" }); - ContextMenu.Instance.addItem({ description: "Audio Funcs...", subitems: funcs, icon: "asterisk" }); + ContextMenu.Instance.addItem({ description: "Options...", subitems: funcs, icon: "asterisk" }); } stopRecording = action(() => { this._recorder.stop(); + this._recorder = undefined; this.dataDoc.duration = (new Date().getTime() - this._recordStart) / 1000; - this._audioState = "recorded"; + this.audioState = "paused"; + this._stream?.getAudioTracks()[0].stop(); const ind = AudioBox.ActiveRecordings.indexOf(this.props.Document); ind !== -1 && (AudioBox.ActiveRecordings.splice(ind, 1)); }); @@ -185,14 +187,25 @@ export class AudioBox extends DocExtendableComponent<FieldViewProps, AudioDocume e.stopPropagation(); } onStop = (e: any) => { - this.pause(); - this._ele!.currentTime = 0; + this.layoutDoc.playOnSelect = !this.layoutDoc.playOnSelect; + e.stopPropagation(); + } + onFile = (e: any) => { + const newDoc = Docs.Create.TextDocument("", { + title: "", _chromeStatus: "disabled", + x: NumCast(this.props.Document.x), y: NumCast(this.props.Document.y) + NumCast(this.props.Document._height) + 10, + _width: NumCast(this.props.Document._width), _height: 3 * NumCast(this.props.Document._height) + }); + Doc.GetProto(newDoc).recordingSource = this.dataDoc; + Doc.GetProto(newDoc).recordingStart = ComputedField.MakeFunction(`self.recordingSource["${this.props.fieldKey}-recordingStart"]`); + Doc.GetProto(newDoc).audioState = ComputedField.MakeFunction("self.recordingSource.audioState"); + this.props.addDocument?.(newDoc); e.stopPropagation(); } setRef = (e: HTMLAudioElement | null) => { - e && e.addEventListener("timeupdate", this.timecodeChanged); - e && e.addEventListener("ended", this.pause); + e?.addEventListener("timeupdate", this.timecodeChanged); + e?.addEventListener("ended", this.pause); this._ele = e; } @@ -212,48 +225,58 @@ export class AudioBox extends DocExtendableComponent<FieldViewProps, AudioDocume render() { const interactive = this.active() ? "-interactive" : ""; - return <div className={`audiobox-container`} onContextMenu={this.specificContextMenu} - onClick={!this.path ? this.recordClick : undefined}> - <div className="audiobox-handle"></div> + return <div className={`audiobox-container`} onContextMenu={this.specificContextMenu} onClick={!this.path ? this.recordClick : undefined}> {!this.path ? - <button className={`audiobox-record${interactive}`} style={{ backgroundColor: this._audioState === "recording" ? "red" : "black" }}> - {this._audioState === "recording" ? "STOP" : "RECORD"} - </button> : + <div className="audiobox-buttons"> + <div className="audiobox-dictation" onClick={this.onFile}> + <FontAwesomeIcon style={{ width: "30px", background: this.layoutDoc.playOnSelect ? "yellow" : "dimGray" }} icon="file-alt" size={this.props.PanelHeight() < 36 ? "1x" : "2x"} /> + </div> + <button className={`audiobox-record${interactive}`} style={{ backgroundColor: this.audioState === "recording" ? "red" : "black" }}> + {this.audioState === "recording" ? "STOP" : "RECORD"} + </button> + </div> : <div className="audiobox-controls"> <div className="audiobox-player" onClick={this.onPlay}> - <div className="audiobox-playhead"> <FontAwesomeIcon style={{ width: "100%" }} icon={this._playing ? "pause" : "play"} size={this.props.PanelHeight() < 36 ? "1x" : "2x"} /></div> - <div className="audiobox-playhead" onClick={this.onStop}><FontAwesomeIcon style={{ width: "100%" }} icon="stop" size={this.props.PanelHeight() < 36 ? "1x" : "2x"} /></div> + <div className="audiobox-playhead"> <FontAwesomeIcon style={{ width: "100%" }} icon={this.audioState === "paused" ? "play" : "pause"} size={this.props.PanelHeight() < 36 ? "1x" : "2x"} /></div> + <div className="audiobox-playhead" onClick={this.onStop}><FontAwesomeIcon style={{ width: "100%", background: this.layoutDoc.playOnSelect ? "yellow" : "dimGray" }} icon="hand-point-left" size={this.props.PanelHeight() < 36 ? "1x" : "2x"} /></div> <div className="audiobox-timeline" onClick={e => e.stopPropagation()} onPointerDown={e => { if (e.button === 0 && !e.ctrlKey) { const rect = (e.target as any).getBoundingClientRect(); - this._ele!.currentTime = this.Document.currentTimecode = (e.clientX - rect.x) / rect.width * NumCast(this.dataDoc.duration); - this.pause(); + const wasPaused = this.audioState === "paused"; + this._ele!.currentTime = this.layoutDoc.currentTimecode = (e.clientX - rect.x) / rect.width * NumCast(this.dataDoc.duration); + wasPaused && this.pause(); e.stopPropagation(); } }} > {DocListCast(this.dataDoc.links).map((l, i) => { let la1 = l.anchor1 as Doc; let la2 = l.anchor2 as Doc; - let linkTime = NumCast(l.anchor2Timecode); + let linkTime = NumCast(l.anchor2_timecode); if (Doc.AreProtosEqual(la1, this.dataDoc)) { la1 = l.anchor2 as Doc; la2 = l.anchor1 as Doc; - linkTime = NumCast(l.anchor1Timecode); + linkTime = NumCast(l.anchor1_timecode); } return !linkTime ? (null) : <div className={this.props.PanelHeight() < 32 ? "audiobox-marker-minicontainer" : "audiobox-marker-container"} key={l[Id]} style={{ left: `${linkTime / NumCast(this.dataDoc.duration, 1) * 100}%` }}> <div className={this.props.PanelHeight() < 32 ? "audioBox-linker-mini" : "audioBox-linker"} key={"linker" + i}> - <DocumentView {...this.props} Document={l} layoutKey={Doc.LinkEndpoint(l, la2)} - parentActive={returnTrue} bringToFront={emptyFunction} zoomToScale={emptyFunction} getScale={returnOne} + <DocumentView {...this.props} + Document={l} + NativeHeight={returnZero} + NativeWidth={returnZero} + rootSelected={returnFalse} + layoutKey={Doc.LinkEndpoint(l, la2)} + ContainingCollectionDoc={this.props.Document} + parentActive={returnTrue} + bringToFront={emptyFunction} backgroundColor={returnTransparent} /> </div> <div key={i} className="audiobox-marker" onPointerEnter={() => Doc.linkFollowHighlight(la1)} - onPointerDown={e => { if (e.button === 0 && !e.ctrlKey) { this.playFrom(linkTime); e.stopPropagation(); } }} - onClick={e => { if (e.button === 0 && !e.ctrlKey) { this.pause(); e.stopPropagation(); } }} /> + onPointerDown={e => { if (e.button === 0 && !e.ctrlKey) { const wasPaused = this.audioState === "paused"; this.playFrom(linkTime); wasPaused && this.pause(); e.stopPropagation(); } }} /> </div>; })} - <div className="audiobox-current" style={{ left: `${NumCast(this.Document.currentTimecode) / NumCast(this.dataDoc.duration, 1) * 100}%` }} /> + <div className="audiobox-current" style={{ left: `${NumCast(this.layoutDoc.currentTimecode) / NumCast(this.dataDoc.duration, 1) * 100}%` }} /> {this.audio} </div> </div> diff --git a/src/client/views/nodes/ButtonBox.tsx b/src/client/views/nodes/ButtonBox.tsx deleted file mode 100644 index ee48b47b7..000000000 --- a/src/client/views/nodes/ButtonBox.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { library } from '@fortawesome/fontawesome-svg-core'; -import { faEdit } from '@fortawesome/free-regular-svg-icons'; -import { action, computed } from 'mobx'; -import { observer } from 'mobx-react'; -import * as React from 'react'; -import { Doc, DocListCast } from '../../../new_fields/Doc'; -import { List } from '../../../new_fields/List'; -import { createSchema, makeInterface, listSpec } from '../../../new_fields/Schema'; -import { ScriptField } from '../../../new_fields/ScriptField'; -import { BoolCast, StrCast, Cast, FieldValue } from '../../../new_fields/Types'; -import { DragManager } from '../../util/DragManager'; -import { undoBatch } from '../../util/UndoManager'; -import { DocComponent } from '../DocComponent'; -import './ButtonBox.scss'; -import { FieldView, FieldViewProps } from './FieldView'; -import { ContextMenuProps } from '../ContextMenuItem'; -import { ContextMenu } from '../ContextMenu'; -import { documentSchema } from '../../../new_fields/documentSchemas'; - - -library.add(faEdit as any); - -const ButtonSchema = createSchema({ - onClick: ScriptField, - buttonParams: listSpec("string"), - text: "string" -}); - -type ButtonDocument = makeInterface<[typeof ButtonSchema, typeof documentSchema]>; -const ButtonDocument = makeInterface(ButtonSchema, documentSchema); - -@observer -export class ButtonBox extends DocComponent<FieldViewProps, ButtonDocument>(ButtonDocument) { - public static LayoutString(fieldKey: string) { return FieldView.LayoutString(ButtonBox, fieldKey); } - private dropDisposer?: DragManager.DragDropDisposer; - - @computed get dataDoc() { - return this.props.DataDoc && - (this.Document.isTemplateForField || BoolCast(this.props.DataDoc.isTemplateForField) || - this.props.DataDoc.layout === this.props.Document) ? this.props.DataDoc : Doc.GetProto(this.props.Document); - } - - - protected createDropTarget = (ele: HTMLDivElement) => { - if (this.dropDisposer) { - this.dropDisposer(); - } - if (ele) { - this.dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this)); - } - } - - specificContextMenu = (e: React.MouseEvent): void => { - const funcs: ContextMenuProps[] = []; - funcs.push({ - description: "Clear Script Params", event: () => { - const params = FieldValue(this.Document.buttonParams); - params && params.map(p => this.props.Document[p] = undefined); - }, icon: "trash" - }); - - ContextMenu.Instance.addItem({ description: "OnClick...", subitems: funcs, icon: "asterisk" }); - } - - @undoBatch - @action - drop = (e: Event, de: DragManager.DropEvent) => { - const docDragData = de.complete.docDragData; - if (docDragData && e.target) { - this.props.Document[(e.target as any).textContent] = new List<Doc>(docDragData.droppedDocuments.map((d, i) => - d.onDragStart ? docDragData.draggedDocuments[i] : d)); - e.stopPropagation(); - } - } - // (!missingParams || !missingParams.length ? "" : "(" + missingParams.map(m => m + ":").join(" ") + ")") - render() { - const params = this.Document.buttonParams; - const missingParams = params && params.filter(p => this.props.Document[p] === undefined); - params && params.map(p => DocListCast(this.props.Document[p])); // bcz: really hacky form of prefetching ... - return ( - <div className="buttonBox-outerDiv" ref={this.createDropTarget} onContextMenu={this.specificContextMenu} - style={{ boxShadow: this.Document.opacity === 0 ? undefined : StrCast(this.Document.boxShadow, "") }}> - <div className="buttonBox-mainButton" style={{ - background: this.Document.backgroundColor, color: this.Document.color || "black", - fontSize: this.Document.fontSize, letterSpacing: this.Document.letterSpacing || "", textTransform: this.Document.textTransform || "" - }} > - <div className="buttonBox-mainButtonCenter"> - {(this.Document.text || this.Document.title)} - </div> - </div> - <div className="buttonBox-params" > - {!missingParams || !missingParams.length ? (null) : missingParams.map(m => <div key={m} className="buttonBox-missingParam">{m}</div>)} - </div> - </div> - ); - } -}
\ No newline at end of file diff --git a/src/client/views/nodes/CollectionFreeFormDocumentView.tsx b/src/client/views/nodes/CollectionFreeFormDocumentView.tsx index 3bceec45f..1c7d116c5 100644 --- a/src/client/views/nodes/CollectionFreeFormDocumentView.tsx +++ b/src/client/views/nodes/CollectionFreeFormDocumentView.tsx @@ -1,4 +1,3 @@ -import anime from "animejs"; import { computed, IReactionDisposer, observable, reaction, trace } from "mobx"; import { observer } from "mobx-react"; import { Doc, HeightSym, WidthSym } from "../../../new_fields/Doc"; @@ -10,11 +9,11 @@ import { DocumentView, DocumentViewProps } from "./DocumentView"; import React = require("react"); import { PositionDocument } from "../../../new_fields/documentSchemas"; import { TraceMobx } from "../../../new_fields/util"; -import { returnFalse } from "../../../Utils"; import { ContentFittingDocumentView } from "./ContentFittingDocumentView"; export interface CollectionFreeFormDocumentViewProps extends DocumentViewProps { - dataProvider?: (doc: Doc) => { x: number, y: number, zIndex?: number, highlight?: boolean, width: number, height: number, z: number, transition?: string } | undefined; + dataProvider?: (doc: Doc, replica: string) => { x: number, y: number, zIndex?: number, highlight?: boolean, z: number, transition?: string } | undefined; + sizeProvider?: (doc: Doc, replica: string) => { width: number, height: number } | undefined; x?: number; y?: number; z?: number; @@ -25,25 +24,34 @@ export interface CollectionFreeFormDocumentViewProps extends DocumentViewProps { jitterRotation: number; transition?: string; fitToBox?: boolean; + replica: string; } @observer export class CollectionFreeFormDocumentView extends DocComponent<CollectionFreeFormDocumentViewProps, PositionDocument>(PositionDocument) { @observable _animPos: number[] | undefined = undefined; + random(min: number, max: number) { // min should not be equal to max + const mseed = Math.abs(this.X * this.Y); + const seed = (mseed * 9301 + 49297) % 233280; + const rnd = seed / 233280; + return min + rnd * (max - min); + } get displayName() { return "CollectionFreeFormDocumentView(" + this.props.Document.title + ")"; } // this makes mobx trace() statements more descriptive - get transform() { return `scale(${this.props.ContentScaling()}) translate(${this.X}px, ${this.Y}px) rotate(${anime.random(-1, 1) * this.props.jitterRotation}deg)`; } + get transform() { return `scale(${this.props.ContentScaling()}) translate(${this.X}px, ${this.Y}px) rotate(${this.random(-1, 1) * this.props.jitterRotation}deg)`; } get X() { return this.renderScriptDim ? this.renderScriptDim.x : this.props.x !== undefined ? this.props.x : this.dataProvider ? this.dataProvider.x : (this.Document.x || 0); } get Y() { return this.renderScriptDim ? this.renderScriptDim.y : this.props.y !== undefined ? this.props.y : this.dataProvider ? this.dataProvider.y : (this.Document.y || 0); } get ZInd() { return this.dataProvider ? this.dataProvider.zIndex : (this.Document.zIndex || 0); } get Highlight() { return this.dataProvider?.highlight; } - get width() { return this.renderScriptDim ? this.renderScriptDim.width : this.props.width !== undefined ? this.props.width : this.props.dataProvider && this.dataProvider ? this.dataProvider.width : this.layoutDoc[WidthSym](); } + get width() { return this.renderScriptDim ? this.renderScriptDim.width : this.props.width !== undefined ? this.props.width : this.props.sizeProvider && this.sizeProvider ? this.sizeProvider.width : this.layoutDoc[WidthSym](); } get height() { - const hgt = this.renderScriptDim ? this.renderScriptDim.height : this.props.height !== undefined ? this.props.height : this.props.dataProvider && this.dataProvider ? this.dataProvider.height : this.layoutDoc[HeightSym](); + const hgt = this.renderScriptDim ? this.renderScriptDim.height : this.props.height !== undefined ? this.props.height : this.props.sizeProvider && this.sizeProvider ? this.sizeProvider.height : this.layoutDoc[HeightSym](); return (hgt === undefined && this.nativeWidth && this.nativeHeight) ? this.width * this.nativeHeight / this.nativeWidth : hgt; } - @computed get dataProvider() { return this.props.dataProvider && this.props.dataProvider(this.props.Document) ? this.props.dataProvider(this.props.Document) : undefined; } - @computed get nativeWidth() { return NumCast(this.layoutDoc._nativeWidth); } - @computed get nativeHeight() { return NumCast(this.layoutDoc._nativeHeight); } + @computed get freezeDimensions() { return this.props.FreezeDimensions; } + @computed get dataProvider() { return this.props.dataProvider?.(this.props.Document, this.props.replica); } + @computed get sizeProvider() { return this.props.sizeProvider?.(this.props.Document, this.props.replica); } + @computed get nativeWidth() { return NumCast(this.layoutDoc._nativeWidth, this.props.NativeWidth() || (this.freezeDimensions ? this.layoutDoc[WidthSym]() : 0)); } + @computed get nativeHeight() { return NumCast(this.layoutDoc._nativeHeight, this.props.NativeHeight() || (this.freezeDimensions ? this.layoutDoc[HeightSym]() : 0)); } @computed get renderScriptDim() { if (this.Document.renderScript) { @@ -59,18 +67,21 @@ export class CollectionFreeFormDocumentView extends DocComponent<CollectionFreeF } return undefined; } + nudge = (x: number, y: number) => { + this.props.Document.x = NumCast(this.props.Document.x) + x; + this.props.Document.y = NumCast(this.props.Document.y) + y; + } - contentScaling = () => this.nativeWidth > 0 && !this.props.Document.ignoreAspect && !this.props.fitToBox ? this.width / this.nativeWidth : 1; - clusterColorFunc = (doc: Doc) => this.clusterColor; - panelWidth = () => (this.dataProvider?.width || this.props.PanelWidth()); - panelHeight = () => (this.dataProvider?.height || this.props.PanelHeight()); + contentScaling = () => this.nativeWidth > 0 && !this.props.fitToBox && !this.freezeDimensions ? this.width / this.nativeWidth : 1; + panelWidth = () => (this.sizeProvider?.width || this.props.PanelWidth?.()); + panelHeight = () => (this.sizeProvider?.height || this.props.PanelHeight?.()); getTransform = (): Transform => this.props.ScreenToLocalTransform() .translate(-this.X, -this.Y) .scale(1 / this.contentScaling()) - @computed - get clusterColor() { return this.props.backgroundColor(this.props.Document); } focusDoc = (doc: Doc) => this.props.focus(doc, false); + NativeWidth = () => this.nativeWidth; + NativeHeight = () => this.nativeHeight; render() { TraceMobx(); return <div className="collectionFreeFormDocumentView-container" @@ -78,35 +89,41 @@ export class CollectionFreeFormDocumentView extends DocComponent<CollectionFreeF boxShadow: this.layoutDoc.opacity === 0 ? undefined : // if it's not visible, then no shadow this.layoutDoc.z ? `#9c9396 ${StrCast(this.layoutDoc.boxShadow, "10px 10px 0.9vw")}` : // if it's a floating doc, give it a big shadow - this.clusterColor ? (`${this.clusterColor} ${StrCast(this.layoutDoc.boxShadow, `0vw 0vw ${(this.layoutDoc.isBackground ? 100 : 50) / this.props.ContentScaling()}px`)}`) : // if it's just in a cluster, make the shadown roughly match the cluster border extent + this.props.backgroundHalo?.() ? (`${this.props.backgroundColor?.(this.props.Document)} ${StrCast(this.layoutDoc.boxShadow, `0vw 0vw ${(this.layoutDoc.isBackground ? 100 : 50) / this.props.ContentScaling()}px`)}`) : // if it's just in a cluster, make the shadown roughly match the cluster border extent this.layoutDoc.isBackground ? undefined : // if it's a background & has a cluster color, make the shadow spread really big StrCast(this.layoutDoc.boxShadow, ""), borderRadius: StrCast(Doc.Layout(this.layoutDoc).borderRounding), outline: this.Highlight ? "orange solid 2px" : "", transform: this.transform, - transition: this.Document.isAnimating ? ".5s ease-in" : this.props.transition ? this.props.transition : this.dataProvider ? this.dataProvider.transition : StrCast(this.layoutDoc.transition), + transition: this.props.transition ? this.props.transition : this.dataProvider ? this.dataProvider.transition : StrCast(this.layoutDoc.transition), width: this.width, height: this.height, zIndex: this.ZInd, display: this.ZInd === -99 ? "none" : undefined, - pointerEvents: this.props.Document.isBackground ? "none" : undefined + pointerEvents: this.props.Document.isBackground ? "none" : this.props.pointerEvents ? "all" : undefined }} > - {!this.props.fitToBox ? <DocumentView {...this.props} - dragDivName={"collectionFreeFormDocumentView-container"} - ContentScaling={this.contentScaling} - ScreenToLocalTransform={this.getTransform} - backgroundColor={this.clusterColorFunc} - PanelWidth={this.panelWidth} - PanelHeight={this.panelHeight} - /> : <ContentFittingDocumentView {...this.props} - CollectionDoc={this.props.ContainingCollectionDoc} - DataDocument={this.props.DataDoc} - getTransform={this.getTransform} - active={returnFalse} - focus={this.focusDoc} - PanelWidth={this.panelWidth} - PanelHeight={this.panelHeight} + {!this.props.fitToBox ? + <DocumentView {...this.props} + nudge={this.nudge} + dragDivName={"collectionFreeFormDocumentView-container"} + ContentScaling={this.contentScaling} + ScreenToLocalTransform={this.getTransform} + backgroundColor={this.props.backgroundColor} + NativeHeight={this.NativeHeight} + NativeWidth={this.NativeWidth} + PanelWidth={this.panelWidth} + PanelHeight={this.panelHeight} /> + : <ContentFittingDocumentView {...this.props} + CollectionDoc={this.props.ContainingCollectionDoc} + DataDocument={this.props.DataDoc} + getTransform={this.getTransform} + NativeHeight={this.NativeHeight} + NativeWidth={this.NativeWidth} + active={this.props.parentActive} + focus={this.focusDoc} + PanelWidth={this.panelWidth} + PanelHeight={this.panelHeight} />} </div>; } diff --git a/src/client/views/nodes/ColorBox.scss b/src/client/views/nodes/ColorBox.scss index bf334c939..da3266dc1 100644 --- a/src/client/views/nodes/ColorBox.scss +++ b/src/client/views/nodes/ColorBox.scss @@ -3,6 +3,7 @@ height:100%; position: relative; pointer-events: none; + transform-origin: top left; .sketch-picker { margin:auto; diff --git a/src/client/views/nodes/ColorBox.tsx b/src/client/views/nodes/ColorBox.tsx index 40674b034..6e4341b27 100644 --- a/src/client/views/nodes/ColorBox.tsx +++ b/src/client/views/nodes/ColorBox.tsx @@ -1,47 +1,32 @@ import React = require("react"); import { observer } from "mobx-react"; import { SketchPicker } from 'react-color'; -import { FieldView, FieldViewProps } from './FieldView'; -import "./ColorBox.scss"; -import { InkingControl } from "../InkingControl"; -import { DocExtendableComponent } from "../DocComponent"; +import { documentSchema } from "../../../new_fields/documentSchemas"; import { makeInterface } from "../../../new_fields/Schema"; -import { reaction, observable, action, IReactionDisposer } from "mobx"; -import { SelectionManager } from "../../util/SelectionManager"; import { StrCast } from "../../../new_fields/Types"; import { CurrentUserUtils } from "../../../server/authentication/models/current_user_utils"; -import { documentSchema } from "../../../new_fields/documentSchemas"; +import { SelectionManager } from "../../util/SelectionManager"; +import { ViewBoxBaseComponent } from "../DocComponent"; +import { InkingControl } from "../InkingControl"; +import "./ColorBox.scss"; +import { FieldView, FieldViewProps } from './FieldView'; type ColorDocument = makeInterface<[typeof documentSchema]>; const ColorDocument = makeInterface(documentSchema); @observer -export class ColorBox extends DocExtendableComponent<FieldViewProps, ColorDocument>(ColorDocument) { +export class ColorBox extends ViewBoxBaseComponent<FieldViewProps, ColorDocument>(ColorDocument) { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(ColorBox, fieldKey); } - _selectedDisposer: IReactionDisposer | undefined; - _penDisposer: IReactionDisposer | undefined; - @observable _startupColor = "black"; - - componentDidMount() { - this._selectedDisposer = reaction(() => SelectionManager.SelectedDocuments(), - action(() => this._startupColor = SelectionManager.SelectedDocuments().length ? StrCast(SelectionManager.SelectedDocuments()[0].Document.backgroundColor, "black") : "black"), - { fireImmediately: true }); - this._penDisposer = reaction(() => CurrentUserUtils.ActivePen, - action(() => this._startupColor = CurrentUserUtils.ActivePen ? StrCast(CurrentUserUtils.ActivePen.backgroundColor, "black") : "black"), - { fireImmediately: true }); - } - componentWillUnmount() { - this._penDisposer && this._penDisposer(); - this._selectedDisposer && this._selectedDisposer(); - } - render() { + const selDoc = SelectionManager.SelectedDocuments()?.[0]?.rootDoc; return <div className={`colorBox-container${this.active() ? "-interactive" : ""}`} onPointerDown={e => e.button === 0 && !e.ctrlKey && e.stopPropagation()} - style={{ transformOrigin: "top left", transform: `scale(${this.props.ContentScaling()})`, width: `${100 / this.props.ContentScaling()}%`, height: `${100 / this.props.ContentScaling()}%` }} > + style={{ transform: `scale(${this.props.ContentScaling()})`, width: `${100 / this.props.ContentScaling()}%`, height: `${100 / this.props.ContentScaling()}%` }} > - <SketchPicker color={this._startupColor} onChange={InkingControl.Instance.switchColor} /> + <SketchPicker onChange={InkingControl.Instance.switchColor} + color={StrCast(CurrentUserUtils.ActivePen ? CurrentUserUtils.ActivePen.backgroundColor : undefined, + StrCast(selDoc?._backgroundColor, StrCast(selDoc?.backgroundColor, "black")))} /> </div>; } }
\ No newline at end of file diff --git a/src/client/views/nodes/ContentFittingDocumentView.tsx b/src/client/views/nodes/ContentFittingDocumentView.tsx index bd1b6166f..641797cac 100644 --- a/src/client/views/nodes/ContentFittingDocumentView.tsx +++ b/src/client/views/nodes/ContentFittingDocumentView.tsx @@ -1,82 +1,75 @@ import React = require("react"); -import { action, computed } from "mobx"; +import { computed } from "mobx"; import { observer } from "mobx-react"; import "react-table/react-table.css"; -import { Doc } from "../../../new_fields/Doc"; -import { ComputedField, ScriptField } from "../../../new_fields/ScriptField"; +import { Doc, Opt, WidthSym, HeightSym } from "../../../new_fields/Doc"; +import { ScriptField } from "../../../new_fields/ScriptField"; import { NumCast, StrCast } from "../../../new_fields/Types"; -import { emptyFunction, returnEmptyString, returnOne } from "../../../Utils"; -import { DragManager } from "../../util/DragManager"; +import { TraceMobx } from "../../../new_fields/util"; +import { emptyFunction, returnOne } from "../../../Utils"; import { Transform } from "../../util/Transform"; -import { undoBatch } from "../../util/UndoManager"; +import { CollectionView } from "../collections/CollectionView"; import '../DocumentDecorations.scss'; import { DocumentView } from "../nodes/DocumentView"; import "./ContentFittingDocumentView.scss"; -import { CollectionView } from "../collections/CollectionView"; -import { TraceMobx } from "../../../new_fields/util"; +import { dropActionType } from "../../util/DragManager"; interface ContentFittingDocumentViewProps { - Document?: Doc; + Document: Doc; DataDocument?: Doc; + LayoutDoc?: () => Opt<Doc>; + NativeWidth?: () => number; + NativeHeight?: () => number; + FreezeDimensions?: boolean; LibraryPath: Doc[]; - childDocs?: Doc[]; renderDepth: number; fitToBox?: boolean; + layoutKey?: string; + dropAction?: dropActionType; PanelWidth: () => number; PanelHeight: () => number; focus?: (doc: Doc) => void; CollectionView?: CollectionView; CollectionDoc?: Doc; onClick?: ScriptField; + backgroundColor?: (doc: Doc) => string | undefined; getTransform: () => Transform; addDocument?: (document: Doc) => boolean; moveDocument?: (document: Doc, target: Doc | undefined, addDoc: ((doc: Doc) => boolean)) => boolean; removeDocument?: (document: Doc) => boolean; active: (outsideReaction: boolean) => boolean; whenActiveChanged: (isActive: boolean) => void; - addDocTab: (document: Doc, dataDoc: Doc | undefined, where: string) => boolean; + addDocTab: (document: Doc, where: string) => boolean; pinToPres: (document: Doc) => void; dontRegisterView?: boolean; + rootSelected: (outsideReaction?: boolean) => boolean; } @observer export class ContentFittingDocumentView extends React.Component<ContentFittingDocumentViewProps>{ public get displayName() { return "DocumentView(" + this.props.Document?.title + ")"; } // this makes mobx trace() statements more descriptive - private get layoutDoc() { return this.props.Document && Doc.Layout(this.props.Document); } - private get nativeWidth() { return NumCast(this.layoutDoc?._nativeWidth, this.props.PanelWidth()); } - private get nativeHeight() { return NumCast(this.layoutDoc?._nativeHeight, this.props.PanelHeight()); } + private get layoutDoc() { return this.props.LayoutDoc?.() || Doc.Layout(this.props.Document); } + @computed get freezeDimensions() { return this.props.FreezeDimensions; } + nativeWidth = () => NumCast(this.layoutDoc?._nativeWidth, this.props.NativeWidth?.() || (this.freezeDimensions && this.layoutDoc ? this.layoutDoc[WidthSym]() : this.props.PanelWidth())); + nativeHeight = () => NumCast(this.layoutDoc?._nativeHeight, this.props.NativeHeight?.() || (this.freezeDimensions && this.layoutDoc ? this.layoutDoc[HeightSym]() : this.props.PanelHeight())); @computed get scaling() { - const wscale = this.props.PanelWidth() / (this.nativeWidth || this.props.PanelWidth() || 1); - if (wscale * this.nativeHeight > this.props.PanelHeight()) { - return (this.props.PanelHeight() / (this.nativeHeight || this.props.PanelHeight() || 1)) || 1; + const wscale = this.props.PanelWidth() / this.nativeWidth(); + if (wscale * this.nativeHeight() > this.props.PanelHeight()) { + return (this.props.PanelHeight() / this.nativeHeight()) || 1; } return wscale || 1; } private contentScaling = () => this.scaling; - @undoBatch - @action - drop = (e: Event, de: DragManager.DropEvent) => { - const docDragData = de.complete.docDragData; - if (docDragData) { - this.props.childDocs && this.props.childDocs.map(otherdoc => { - const target = Doc.GetProto(otherdoc); - target.layout = ComputedField.MakeFunction("this.image_data[0]"); - target.layout_custom = Doc.MakeDelegate(docDragData.draggedDocuments[0]); - }); - e.stopPropagation(); - } - return true; - } private PanelWidth = () => this.panelWidth; - private PanelHeight = () => this.panelHeight;; + private PanelHeight = () => this.panelHeight; - @computed get panelWidth() { return this.nativeWidth && (!this.props.Document || !this.props.Document._fitWidth) ? this.nativeWidth * this.contentScaling() : this.props.PanelWidth(); } - @computed get panelHeight() { return this.nativeHeight && (!this.props.Document || !this.props.Document._fitWidth) ? this.nativeHeight * this.contentScaling() : this.props.PanelHeight(); } + @computed get panelWidth() { return this.nativeWidth && !this.props.Document._fitWidth ? this.nativeWidth() * this.contentScaling() : this.props.PanelWidth(); } + @computed get panelHeight() { return this.nativeHeight && !this.props.Document._fitWidth ? this.nativeHeight() * this.contentScaling() : this.props.PanelHeight(); } private getTransform = () => this.props.getTransform().translate(-this.centeringOffset, -this.centeringYOffset).scale(1 / this.contentScaling()); - private get centeringOffset() { return this.nativeWidth && (!this.props.Document || !this.props.Document._fitWidth) ? (this.props.PanelWidth() - this.nativeWidth * this.contentScaling()) / 2 : 0; } - private get centeringYOffset() { return Math.abs(this.centeringOffset) < 0.001 ? (this.props.PanelHeight() - this.nativeHeight * this.contentScaling()) / 2 : 0; } + private get centeringOffset() { return this.nativeWidth() && !this.props.Document._fitWidth ? (this.props.PanelWidth() - this.nativeWidth() * this.contentScaling()) / 2 : 0; } + private get centeringYOffset() { return Math.abs(this.centeringOffset) < 0.001 ? (this.props.PanelHeight() - this.nativeHeight() * this.contentScaling()) / 2 : 0; } @computed get borderRounding() { return StrCast(this.props.Document?.borderRounding); } @@ -91,15 +84,24 @@ export class ContentFittingDocumentView extends React.Component<ContentFittingDo style={{ transform: `translate(${this.centeringOffset}px, 0px)`, borderRadius: this.borderRounding, - height: Math.abs(this.centeringYOffset) > 0.001 ? `${100 * this.nativeHeight / this.nativeWidth * this.props.PanelWidth() / this.props.PanelHeight()}%` : this.props.PanelHeight(), + height: Math.abs(this.centeringYOffset) > 0.001 ? `${100 * this.nativeHeight() / this.nativeWidth() * this.props.PanelWidth() / this.props.PanelHeight()}%` : this.props.PanelHeight(), width: Math.abs(this.centeringOffset) > 0.001 ? `${100 * (this.props.PanelWidth() - this.centeringOffset * 2) / this.props.PanelWidth()}%` : this.props.PanelWidth() }}> <DocumentView {...this.props} Document={this.props.Document} DataDoc={this.props.DataDocument} + LayoutDoc={this.props.LayoutDoc} LibraryPath={this.props.LibraryPath} + NativeWidth={this.nativeWidth} + NativeHeight={this.nativeHeight} + PanelWidth={this.PanelWidth} + PanelHeight={this.PanelHeight} + ContentScaling={this.contentScaling} fitToBox={this.props.fitToBox} + layoutKey={this.props.layoutKey} + dropAction={this.props.dropAction} onClick={this.props.onClick} + backgroundColor={this.props.backgroundColor} addDocument={this.props.addDocument} removeDocument={this.props.removeDocument} moveDocument={this.props.moveDocument} @@ -111,15 +113,9 @@ export class ContentFittingDocumentView extends React.Component<ContentFittingDo parentActive={this.props.active} ScreenToLocalTransform={this.getTransform} renderDepth={this.props.renderDepth} - ContentScaling={this.contentScaling} - PanelWidth={this.PanelWidth} - PanelHeight={this.PanelHeight} focus={this.props.focus || emptyFunction} - backgroundColor={returnEmptyString} bringToFront={emptyFunction} dontRegisterView={this.props.dontRegisterView} - zoomToScale={emptyFunction} - getScale={returnOne} /> </div>)} </div>); diff --git a/src/client/views/nodes/DocuLinkBox.scss b/src/client/views/nodes/DocuLinkBox.scss deleted file mode 100644 index 57c1a66e0..000000000 --- a/src/client/views/nodes/DocuLinkBox.scss +++ /dev/null @@ -1,8 +0,0 @@ -.docuLinkBox-cont { - cursor: default; - position: absolute; - width: 25px; - height: 25px; - border-radius: 20px; - pointer-events: all; -}
\ No newline at end of file diff --git a/src/client/views/nodes/DocuLinkBox.tsx b/src/client/views/nodes/DocuLinkBox.tsx deleted file mode 100644 index a4a9a62aa..000000000 --- a/src/client/views/nodes/DocuLinkBox.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { action, observable } from "mobx"; -import { observer } from "mobx-react"; -import { Doc, WidthSym, HeightSym } from "../../../new_fields/Doc"; -import { makeInterface } from "../../../new_fields/Schema"; -import { NumCast, StrCast, Cast } from "../../../new_fields/Types"; -import { Utils } from '../../../Utils'; -import { DocumentManager } from "../../util/DocumentManager"; -import { DragManager } from "../../util/DragManager"; -import { DocComponent } from "../DocComponent"; -import "./DocuLinkBox.scss"; -import { FieldView, FieldViewProps } from "./FieldView"; -import React = require("react"); -import { DocumentType } from "../../documents/DocumentTypes"; -import { documentSchema } from "../../../new_fields/documentSchemas"; -import { Id } from "../../../new_fields/FieldSymbols"; - -type DocLinkSchema = makeInterface<[typeof documentSchema]>; -const DocLinkDocument = makeInterface(documentSchema); - -@observer -export class DocuLinkBox extends DocComponent<FieldViewProps, DocLinkSchema>(DocLinkDocument) { - public static LayoutString(fieldKey: string) { return FieldView.LayoutString(DocuLinkBox, fieldKey); } - _downx = 0; - _downy = 0; - @observable _x = 0; - @observable _y = 0; - @observable _selected = false; - _ref = React.createRef<HTMLDivElement>(); - - onPointerDown = (e: React.PointerEvent) => { - this._downx = e.clientX; - this._downy = e.clientY; - document.removeEventListener("pointermove", this.onPointerMove); - document.removeEventListener("pointerup", this.onPointerUp); - document.addEventListener("pointermove", this.onPointerMove); - document.addEventListener("pointerup", this.onPointerUp); - (e.button === 0 && !e.ctrlKey) && e.stopPropagation(); - } - onPointerMove = action((e: PointerEvent) => { - const cdiv = this._ref && this._ref.current && this._ref.current.parentElement; - if (cdiv && (Math.abs(e.clientX - this._downx) > 5 || Math.abs(e.clientY - this._downy) > 5)) { - const bounds = cdiv.getBoundingClientRect(); - const pt = Utils.getNearestPointInPerimeter(bounds.left, bounds.top, bounds.width, bounds.height, e.clientX, e.clientY); - const separation = Math.sqrt((pt[0] - e.clientX) * (pt[0] - e.clientX) + (pt[1] - e.clientY) * (pt[1] - e.clientY)); - const dragdist = Math.sqrt((pt[0] - this._downx) * (pt[0] - this._downx) + (pt[1] - this._downy) * (pt[1] - this._downy)); - if (separation > 100) { - DragManager.StartLinkTargetsDrag(this._ref.current!, pt[0], pt[1], Cast(this.props.Document[this.props.fieldKey], Doc) as Doc, [this.props.Document]); // Containging collection is the document, not a collection... hack. - document.removeEventListener("pointermove", this.onPointerMove); - document.removeEventListener("pointerup", this.onPointerUp); - } else if (dragdist > separation) { - this.props.Document[this.props.fieldKey + "_x"] = (pt[0] - bounds.left) / bounds.width * 100; - this.props.Document[this.props.fieldKey + "_y"] = (pt[1] - bounds.top) / bounds.height * 100; - } - } - }); - onPointerUp = (e: PointerEvent) => { - document.removeEventListener("pointermove", this.onPointerMove); - document.removeEventListener("pointerup", this.onPointerUp); - if (Math.abs(e.clientX - this._downx) < 3 && Math.abs(e.clientY - this._downy) < 3 && (e.button === 2 || e.ctrlKey || !this.props.Document.isButton)) { - this.props.select(false); - } - } - onClick = (e: React.MouseEvent) => { - if (!this.props.Document.onClick) { - if (Math.abs(e.clientX - this._downx) < 3 && Math.abs(e.clientY - this._downy) < 3 && (e.button !== 2 && !e.ctrlKey && this.props.Document.isButton)) { - DocumentManager.Instance.FollowLink(this.props.Document, this.props.Document[this.props.fieldKey] as Doc, document => this.props.addDocTab(document, undefined, "inTab"), false); - } - e.stopPropagation(); - } - } - - render() { - const x = NumCast(this.props.Document[this.props.fieldKey + "_x"], 100); - const y = NumCast(this.props.Document[this.props.fieldKey + "_y"], 100); - const c = StrCast(this.props.Document.backgroundColor, "lightblue"); - const anchor = this.props.fieldKey === "anchor1" ? "anchor2" : "anchor1"; - const anchorScale = (x === 0 || x === 100 || y === 0 || y === 100) ? 1 : .15; - - const timecode = this.props.Document[anchor + "Timecode"]; - const targetTitle = StrCast((this.props.Document[anchor]! as Doc).title) + (timecode !== undefined ? ":" + timecode : ""); - return <div className="docuLinkBox-cont" onPointerDown={this.onPointerDown} onClick={this.onClick} title={targetTitle} - ref={this._ref} style={{ - background: c, left: `calc(${x}% - 12.5px)`, top: `calc(${y}% - 12.5px)`, - transform: `scale(${anchorScale / this.props.ContentScaling()})` - }} />; - } -} diff --git a/src/client/views/nodes/DocumentBox.scss b/src/client/views/nodes/DocumentBox.scss index b7d06b364..ce21391ce 100644 --- a/src/client/views/nodes/DocumentBox.scss +++ b/src/client/views/nodes/DocumentBox.scss @@ -3,13 +3,12 @@ height: 100%; pointer-events: all; background: gray; - border: #00000021 solid 15px; - border-top: #0000005e inset 15px; - border-bottom: #0000005e outset 15px; .documentBox-lock { margin: auto; color: white; - margin-top: -15px; + position: absolute; + } + .contentFittingDocumentView { position: absolute; } }
\ No newline at end of file diff --git a/src/client/views/nodes/DocumentBox.tsx b/src/client/views/nodes/DocumentBox.tsx index 6b7b652c6..0d18baaed 100644 --- a/src/client/views/nodes/DocumentBox.tsx +++ b/src/client/views/nodes/DocumentBox.tsx @@ -1,111 +1,160 @@ -import { IReactionDisposer, reaction } from "mobx"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { IReactionDisposer, reaction, computed } from "mobx"; import { observer } from "mobx-react"; import { Doc, Field } from "../../../new_fields/Doc"; import { documentSchema } from "../../../new_fields/documentSchemas"; -import { List } from "../../../new_fields/List"; import { makeInterface } from "../../../new_fields/Schema"; import { ComputedField } from "../../../new_fields/ScriptField"; -import { Cast, StrCast, BoolCast } from "../../../new_fields/Types"; -import { emptyFunction, emptyPath } from "../../../Utils"; +import { Cast, NumCast, StrCast } from "../../../new_fields/Types"; +import { emptyPath } from "../../../Utils"; import { ContextMenu } from "../ContextMenu"; import { ContextMenuProps } from "../ContextMenuItem"; -import { DocComponent } from "../DocComponent"; +import { ViewBoxAnnotatableComponent } from "../DocComponent"; import { ContentFittingDocumentView } from "./ContentFittingDocumentView"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import "./DocumentBox.scss"; import { FieldView, FieldViewProps } from "./FieldView"; import React = require("react"); +import { TraceMobx } from "../../../new_fields/util"; +import { DocumentView } from "./DocumentView"; +import { Docs } from "../../documents/Documents"; -type DocBoxSchema = makeInterface<[typeof documentSchema]>; -const DocBoxDocument = makeInterface(documentSchema); +type DocHolderBoxSchema = makeInterface<[typeof documentSchema]>; +const DocHolderBoxDocument = makeInterface(documentSchema); @observer -export class DocumentBox extends DocComponent<FieldViewProps, DocBoxSchema>(DocBoxDocument) { - public static LayoutString(fieldKey: string) { return FieldView.LayoutString(DocumentBox, fieldKey); } +export class DocHolderBox extends ViewBoxAnnotatableComponent<FieldViewProps, DocHolderBoxSchema>(DocHolderBoxDocument) { + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(DocHolderBox, fieldKey); } _prevSelectionDisposer: IReactionDisposer | undefined; _selections: Doc[] = []; _curSelection = -1; componentDidMount() { - this._prevSelectionDisposer = reaction(() => Cast(this.props.Document[this.props.fieldKey], Doc) as Doc, (data) => { - if (data && !this._selections.includes(data)) { - this._selections.length = ++this._curSelection; + this._prevSelectionDisposer = reaction(() => this.contentDoc[this.props.fieldKey], (data) => { + if (data instanceof Doc && !this.isSelectionLocked()) { + this._selections.indexOf(data) !== -1 && this._selections.splice(this._selections.indexOf(data), 1); this._selections.push(data); + this._curSelection = this._selections.length - 1; } }); } componentWillUnmount() { - this._prevSelectionDisposer && this._prevSelectionDisposer(); + this._prevSelectionDisposer?.(); } specificContextMenu = (e: React.MouseEvent): void => { const funcs: ContextMenuProps[] = []; funcs.push({ description: (this.isSelectionLocked() ? "Show" : "Lock") + " Selection", event: () => this.toggleLockSelection, icon: "expand-arrows-alt" }); + funcs.push({ description: (this.props.Document.excludeCollections ? "Include" : "Exclude") + " Collections", event: () => Doc.GetProto(this.props.Document).excludeCollections = !this.props.Document.excludeCollections, icon: "expand-arrows-alt" }); funcs.push({ description: `${this.props.Document.forceActive ? "Select" : "Force"} Contents Active`, event: () => this.props.Document.forceActive = !this.props.Document.forceActive, icon: "project-diagram" }); - ContextMenu.Instance.addItem({ description: "DocumentBox Funcs...", subitems: funcs, icon: "asterisk" }); + ContextMenu.Instance.addItem({ description: "Options...", subitems: funcs, icon: "asterisk" }); + } + @computed get contentDoc() { + return (this.props.Document.isTemplateDoc || this.props.Document.isTemplateForField ? this.props.Document : Doc.GetProto(this.props.Document)); } lockSelection = () => { - Doc.GetProto(this.props.Document)[this.props.fieldKey] = this.props.Document[this.props.fieldKey]; + this.contentDoc[this.props.fieldKey] = this.props.Document[this.props.fieldKey]; } showSelection = () => { - Doc.GetProto(this.props.Document)[this.props.fieldKey] = ComputedField.MakeFunction("selectedDocs(this,true,[_last_])?.[0]"); + this.contentDoc[this.props.fieldKey] = ComputedField.MakeFunction(`selectedDocs(self,this.excludeCollections,[_last_])?.[0]`); } isSelectionLocked = () => { - const kvpstring = Field.toKeyValueString(this.props.Document, this.props.fieldKey); - return !(kvpstring.startsWith("=") || kvpstring.startsWith(":=")); + const kvpstring = Field.toKeyValueString(this.contentDoc, this.props.fieldKey); + return !kvpstring || kvpstring.includes("DOC"); } toggleLockSelection = () => { !this.isSelectionLocked() ? this.lockSelection() : this.showSelection(); + return true; } prevSelection = () => { + this.lockSelection(); if (this._curSelection > 0) { - Doc.UserDoc().SelectedDocs = new List([this._selections[--this._curSelection]]); + this.contentDoc[this.props.fieldKey] = this._selections[--this._curSelection]; + return true; } } nextSelection = () => { if (this._curSelection < this._selections.length - 1 && this._selections.length) { - Doc.UserDoc().SelectedDocs = new List([this._selections[++this._curSelection]]); + this.contentDoc[this.props.fieldKey] = this._selections[++this._curSelection]; + return true; } } onPointerDown = (e: React.PointerEvent) => { + if (this.active() && e.button === 0 && !e.ctrlKey) { + e.stopPropagation(); + } } + onLockClick = (e: React.MouseEvent) => { + this.toggleLockSelection(); + (e.nativeEvent as any).formattedHandled = true; + e.stopPropagation(); + } + get xPad() { return NumCast(this.props.Document._xPadding); } + get yPad() { return NumCast(this.props.Document._yPadding); } onClick = (e: React.MouseEvent) => { - if (this._contRef.current!.getBoundingClientRect().top + 15 > e.clientY) this.toggleLockSelection(); + let hitWidget: boolean | undefined = false; + if (this._contRef.current!.getBoundingClientRect().top + this.yPad > e.clientY) hitWidget = (() => { this.props.select(false); return true; })(); + else if (this._contRef.current!.getBoundingClientRect().bottom - this.yPad < e.clientY) hitWidget = (() => { this.props.select(false); return true; })(); else { - if (this._contRef.current!.getBoundingClientRect().left + 15 > e.clientX) this.prevSelection(); - if (this._contRef.current!.getBoundingClientRect().right - 15 < e.clientX) this.nextSelection(); + if (this._contRef.current!.getBoundingClientRect().left + this.xPad > e.clientX) hitWidget = this.prevSelection(); + if (this._contRef.current!.getBoundingClientRect().right - this.xPad < e.clientX) hitWidget = this.nextSelection(); + } + if (hitWidget) { + (e.nativeEvent as any).formattedHandled = true; + e.stopPropagation(); } } _contRef = React.createRef<HTMLDivElement>(); - pwidth = () => this.props.PanelWidth() - 30; - pheight = () => this.props.PanelHeight() - 30; - getTransform = () => this.props.ScreenToLocalTransform().translate(-15, -15); + pwidth = () => this.props.PanelWidth() - 2 * this.xPad; + pheight = () => this.props.PanelHeight() - 2 * this.yPad; + getTransform = () => this.props.ScreenToLocalTransform().translate(-this.xPad, -this.yPad); + get renderContents() { + const containedDoc = Cast(this.contentDoc[this.props.fieldKey], Doc, null); + const childTemplateName = StrCast(this.props.Document.childTemplateName); + if (containedDoc && childTemplateName && !containedDoc["layout_" + childTemplateName]) { + setTimeout(() => { + Doc.createCustomView(containedDoc, Docs.Create.StackingDocument, childTemplateName); + Doc.expandTemplateLayout(Cast(containedDoc["layout_" + childTemplateName], Doc, null), containedDoc, undefined); + }, 0); + } + const contents = !(containedDoc instanceof Doc) ? (null) : <ContentFittingDocumentView + Document={containedDoc} + DataDocument={undefined} + LibraryPath={emptyPath} + CollectionView={this as any} // bcz: hack! need to pass a prop that can be used to select the container (ie, 'this') when the up selector in document decorations is clicked. currently, the up selector allows only a containing collection to be selected + fitToBox={true} + layoutKey={childTemplateName ? "layout_" + childTemplateName : "layout"} + rootSelected={this.props.isSelected} + addDocument={this.props.addDocument} + moveDocument={this.props.moveDocument} + removeDocument={this.props.removeDocument} + addDocTab={this.props.addDocTab} + pinToPres={this.props.pinToPres} + getTransform={this.getTransform} + renderDepth={this.props.renderDepth + 1} + PanelWidth={this.pwidth} + PanelHeight={this.pheight} + focus={this.props.focus} + active={this.props.active} + dontRegisterView={!this.isSelectionLocked()} + whenActiveChanged={this.props.whenActiveChanged} + />; + return contents; + } render() { - const containedDoc = this.props.Document[this.props.fieldKey] as Doc; + TraceMobx(); return <div className="documentBox-container" ref={this._contRef} onContextMenu={this.specificContextMenu} onPointerDown={this.onPointerDown} onClick={this.onClick} - style={{ background: StrCast(this.props.Document.backgroundColor) }}> - <div className="documentBox-lock"> + style={{ + background: StrCast(this.props.Document.backgroundColor), + border: `#00000021 solid ${this.xPad}px`, + borderTop: `#0000005e solid ${this.yPad}px`, + borderBottom: `#0000005e solid ${this.yPad}px`, + }}> + {this.renderContents} + <div className="documentBox-lock" onClick={this.onLockClick} + style={{ marginTop: - this.yPad }}> <FontAwesomeIcon icon={this.isSelectionLocked() ? "lock" : "unlock"} size="sm" /> </div> - {!(containedDoc instanceof Doc) ? (null) : <ContentFittingDocumentView - Document={containedDoc} - DataDocument={undefined} - LibraryPath={emptyPath} - fitToBox={this.props.fitToBox} - addDocument={this.props.addDocument} - moveDocument={this.props.moveDocument} - removeDocument={this.props.removeDocument} - addDocTab={this.props.addDocTab} - pinToPres={this.props.pinToPres} - getTransform={this.getTransform} - renderDepth={this.props.Document.forceActive ? 0 : this.props.renderDepth + 1} // bcz: really need to have an 'alwaysSelected' prop that's not conflated with renderDepth - PanelWidth={this.pwidth} - PanelHeight={this.pheight} - focus={this.props.focus} - active={this.props.active} - whenActiveChanged={this.props.whenActiveChanged} - />} - </div>; + </div >; } } diff --git a/src/client/views/nodes/DocumentContentsView.tsx b/src/client/views/nodes/DocumentContentsView.tsx index 3b9015994..4d20d3e2c 100644 --- a/src/client/views/nodes/DocumentContentsView.tsx +++ b/src/client/views/nodes/DocumentContentsView.tsx @@ -1,39 +1,44 @@ import { computed } from "mobx"; import { observer } from "mobx-react"; -import { Doc } from "../../../new_fields/Doc"; -import { ScriptField } from "../../../new_fields/ScriptField"; -import { Cast, StrCast } from "../../../new_fields/Types"; -import { OmitKeys, Without } from "../../../Utils"; -import { HistogramBox } from "../../northstar/dash-nodes/HistogramBox"; +import { Doc, Opt, Field } from "../../../new_fields/Doc"; +import { Cast, StrCast, NumCast } from "../../../new_fields/Types"; +import { OmitKeys, Without, emptyPath } from "../../../Utils"; import DirectoryImportBox from "../../util/Import & Export/DirectoryImportBox"; import { CollectionDockingView } from "../collections/CollectionDockingView"; import { CollectionFreeFormView } from "../collections/collectionFreeForm/CollectionFreeFormView"; import { CollectionSchemaView } from "../collections/CollectionSchemaView"; import { CollectionView } from "../collections/CollectionView"; -import { LinkFollowBox } from "../linking/LinkFollowBox"; import { YoutubeBox } from "./../../apis/youtube/YoutubeBox"; import { AudioBox } from "./AudioBox"; -import { ButtonBox } from "./ButtonBox"; -import { DocumentBox } from "./DocumentBox"; +import { LabelBox } from "./LabelBox"; +import { SliderBox } from "./SliderBox"; +import { LinkBox } from "./LinkBox"; +import { ScriptingBox } from "./ScriptingBox"; +import { DocHolderBox } from "./DocumentBox"; import { DocumentViewProps } from "./DocumentView"; import "./DocumentView.scss"; import { FontIconBox } from "./FontIconBox"; import { FieldView, FieldViewProps } from "./FieldView"; -import { FormattedTextBox } from "./FormattedTextBox"; -import { IconBox } from "./IconBox"; +import { FormattedTextBox } from "./formattedText/FormattedTextBox"; import { ImageBox } from "./ImageBox"; import { KeyValueBox } from "./KeyValueBox"; import { PDFBox } from "./PDFBox"; import { PresBox } from "./PresBox"; import { QueryBox } from "./QueryBox"; import { ColorBox } from "./ColorBox"; -import { DocuLinkBox } from "./DocuLinkBox"; +import { DashWebRTCVideo } from "../webcam/DashWebRTCVideo"; +import { LinkAnchorBox } from "./LinkAnchorBox"; import { PresElementBox } from "../presentationview/PresElementBox"; +import { ScreenshotBox } from "./ScreenshotBox"; import { VideoBox } from "./VideoBox"; import { WebBox } from "./WebBox"; import { InkingStroke } from "../InkingStroke"; import React = require("react"); +import { RecommendationsBox } from "../RecommendationsBox"; import { TraceMobx } from "../../../new_fields/util"; +import { ScriptField } from "../../../new_fields/ScriptField"; +import XRegExp = require("xregexp"); + const JsxParser = require('react-jsx-parser').default; //TODO Why does this need to be imported like this? type BindingProps = Without<FieldViewProps, 'fieldKey'>; @@ -50,70 +55,156 @@ class ObserverJsxParser1 extends JsxParser { const ObserverJsxParser: typeof JsxParser = ObserverJsxParser1 as any; + +interface HTMLtagProps { + Document: Doc; + RootDoc: Doc; + htmltag: string; + onClick?: ScriptField; + onInput?: ScriptField; +} +//"<HTMLdiv borderRadius='100px' onClick={this.bannerColor=this.bannerColor==='red'?'green':'red'} width='100%' height='100%' transform='rotate({2*this.x+this.y}deg)'><ImageBox {...props} fieldKey={'data'}/><HTMLspan width='100%' marginTop='50%' height='10%' position='absolute' backgroundColor='{this.bannerColor===`green`?`dark`:`light`}grey'>{this.title}</HTMLspan></HTMLdiv>"@observer +@observer +export class HTMLtag extends React.Component<HTMLtagProps> { + click = (e: React.MouseEvent) => { + const clickScript = (this.props as any).onClick as Opt<ScriptField>; + clickScript?.script.run({ this: this.props.Document, self: this.props.RootDoc }); + } + onInput = (e: React.FormEvent<HTMLDivElement>) => { + const onInputScript = (this.props as any).onInput as Opt<ScriptField>; + onInputScript?.script.run({ this: this.props.Document, self: this.props.RootDoc, value: (e.target as any).textContent }); + } + render() { + const style: { [key: string]: any } = {}; + const divKeys = OmitKeys(this.props, ["children", "htmltag", "RootDoc", "Document", "key", "onInput", "onClick", "__proto__"]).omit; + Object.keys(divKeys).map((prop: string) => { + const p = (this.props as any)[prop] as string; + const replacer = (match: any, expr: string, offset: any, string: any) => { // bcz: this executes a script to convert a propery expression string: { script } into a value + return ScriptField.MakeFunction(expr, { self: Doc.name, this: Doc.name })?.script.run({ self: this.props.RootDoc, this: this.props.Document }).result as string || ""; + }; + style[prop] = p?.replace(/{([^.'][^}']+)}/g, replacer); + }); + const Tag = this.props.htmltag as keyof JSX.IntrinsicElements; + return <Tag style={style} onClick={this.click} onInput={this.onInput as any}> + {this.props.children} + </Tag>; + } +} + @observer export class DocumentContentsView extends React.Component<DocumentViewProps & { isSelected: (outsideReaction: boolean) => boolean, select: (ctrl: boolean) => void, layoutKey: string, + forceLayout?: string, + forceFieldKey?: string, + hideOnLeave?: boolean, + makeLink?: () => Opt<Doc>, // function to call when a link is made }> { @computed get layout(): string { TraceMobx(); if (!this.layoutDoc) return "<p>awaiting layout</p>"; - const layout = Cast(this.layoutDoc[StrCast(this.layoutDoc.layoutKey, this.layoutDoc === this.props.Document ? this.props.layoutKey : "layout")], "string"); - if (layout === undefined) { - return this.props.Document.data ? - "<FieldView {...props} fieldKey='data' />" : - KeyValueBox.LayoutString(this.layoutDoc.proto ? "proto" : ""); - } else if (typeof layout === "string") { - return layout; - } else { - return "<p>Loading layout</p>"; - } + // const layout = Cast(this.layoutDoc[StrCast(this.layoutDoc.layoutKey, this.layoutDoc === this.props.Document ? this.props.layoutKey : "layout")], "string"); // bcz: replaced this with below... is it right? + const layout = Cast(this.layoutDoc[this.layoutDoc === this.props.Document && this.props.layoutKey ? this.props.layoutKey : StrCast(this.layoutDoc.layoutKey, "layout")], "string"); + if (this.props.layoutKey === "layout_keyValue") { + return StrCast(this.props.Document.layout_keyValue, KeyValueBox.LayoutString("data")); + } else + if (layout === undefined) { + return this.props.Document.data ? + "<FieldView {...props} fieldKey='data' />" : + KeyValueBox.LayoutString(this.layoutDoc.proto ? "proto" : ""); + } else if (typeof layout === "string") { + return layout; + } else { + return "<p>Loading layout</p>"; + } } get dataDoc() { - if (this.props.DataDoc === undefined && typeof Doc.LayoutField(this.props.Document) !== "string") { - // if there is no dataDoc (ie, we're not rendering a template layout), but this document has a layout document (not a layout string), - // then we render the layout document as a template and use this document as the data context for the template layout. - const proto = Doc.GetProto(this.props.Document); - return proto instanceof Promise ? undefined : proto; - } - return this.props.DataDoc instanceof Promise ? undefined : this.props.DataDoc; + const proto = this.props.DataDoc || Doc.GetProto(this.props.Document); + return proto instanceof Promise ? undefined : proto; } get layoutDoc() { - if (this.props.DataDoc === undefined && typeof Doc.LayoutField(this.props.Document) !== "string") { - // if there is no dataDoc (ie, we're not rendering a template layout), but this document has a layout document (not a layout string), - // then we render the layout document as a template and use this document as the data context for the template layout. - return Doc.expandTemplateLayout(Doc.Layout(this.props.Document), this.props.Document); - } - return Doc.Layout(this.props.Document); + const params = StrCast(this.props.Document.PARAMS); + // bcz: replaced this with below : is it correct? change was made to accommodate passing fieldKey's from a layout script + // const template: Doc = this.props.LayoutDoc?.() || Doc.Layout(this.props.Document, this.props.layoutKey ? Cast(this.props.Document[this.props.layoutKey], Doc, null) : undefined); + const template: Doc = this.props.LayoutDoc?.() || + (this.props.layoutKey && StrCast(this.props.Document[this.props.layoutKey]) && this.props.Document) || + Doc.Layout(this.props.Document, this.props.layoutKey ? Cast(this.props.Document[this.props.layoutKey], Doc, null) : undefined); + return Doc.expandTemplateLayout(template, this.props.Document, params ? "(" + params + ")" : this.props.layoutKey); } - CreateBindings(): JsxBindings { + CreateBindings(onClick: Opt<ScriptField>, onInput: Opt<ScriptField>): JsxBindings { const list = { ...OmitKeys(this.props, ['parentActive'], (obj: any) => obj.active = this.props.parentActive).omit, + RootDoc: Cast(this.layoutDoc?.rootDocument, Doc, null) || this.layoutDoc, Document: this.layoutDoc, DataDoc: this.dataDoc, + onClick: onClick, + onInput: onInput }; return { props: list }; } render() { TraceMobx(); - return (this.props.renderDepth > 7 || !this.layout || !this.layoutDoc) ? (null) : - <ObserverJsxParser - blacklistedAttrs={[]} - components={{ - FormattedTextBox, ImageBox, IconBox, DirectoryImportBox, FontIconBox: FontIconBox, ButtonBox, FieldView, - CollectionFreeFormView, CollectionDockingView, CollectionSchemaView, CollectionView, WebBox, KeyValueBox, - PDFBox, VideoBox, AudioBox, HistogramBox, PresBox, YoutubeBox, LinkFollowBox, PresElementBox, QueryBox, - ColorBox, DocuLinkBox, InkingStroke, DocumentBox - }} - bindings={this.CreateBindings()} - jsx={this.layout} - showWarnings={true} - - onError={(test: any) => { console.log(test); }} - />; + let layoutFrame = this.layout; + + // replace code content with a script >{content}< as in <HTMLdiv>{this.title}</HTMLdiv> + const replacer = (match: any, prefix: string, expr: string, postfix: string, offset: any, string: any) => { + return prefix + (ScriptField.MakeFunction(expr, { self: Doc.name, this: Doc.name })?.script.run({ this: this.props.Document }).result as string || "") + postfix; + }; + layoutFrame = layoutFrame.replace(/(>[^{]*)\{([^.'][^<}]+)\}([^}]*<)/g, replacer); + + // replace HTML<tag> with corresponding HTML tag as in: <HTMLdiv> becomes <HTMLtag Document={props.Document} htmltag='div'> + const replacer2 = (match: any, p1: string, offset: any, string: any) => { + return `<HTMLtag RootDoc={props.RootDoc} Document={props.Document} htmltag='${p1}'`; + }; + layoutFrame = layoutFrame.replace(/<HTML([a-zA-Z0-9_-]+)/g, replacer2); + + // replace /HTML<tag> with </HTMLdiv> as in: </HTMLdiv> becomes </HTMLtag> + const replacer3 = (match: any, p1: string, offset: any, string: any) => { + return `</HTMLtag`; + }; + layoutFrame = layoutFrame.replace(/<\/HTML([a-zA-Z0-9_-]+)/g, replacer3); + + // add onClick function to props + const makeFuncProp = (func: string) => { + const splits = layoutFrame.split(`func=`); + if (splits.length > 1) { + const code = XRegExp.matchRecursive(splits[1], "{", "}", "", { valueNames: ["between", "left", "match", "right", "between"] }); + layoutFrame = splits[0] + ` ${func}={props.onClick} ` + splits[1].substring(code[1].end + 1); + return ScriptField.MakeScript(code[1].value, { this: Doc.name, self: Doc.name, value: "string" }); + } + return undefined; + // add input function to props + }; + const onClick = makeFuncProp("onClick"); + const onInput = makeFuncProp("onInput"); + + const bindings = this.CreateBindings(onClick, onInput); + // layoutFrame = splits.length > 1 ? splits[0] + splits[1].replace(/{([^{}]|(?R))*}/, replacer4) : ""; // might have been more elegant if javascript supported recursive patterns + + return (this.props.renderDepth > 12 || !layoutFrame || !this.layoutDoc) ? (null) : + this.props.forceLayout === "FormattedTextBox" && this.props.forceFieldKey ? + <FormattedTextBox {...bindings.props} fieldKey={this.props.forceFieldKey} /> + : + <ObserverJsxParser + key={42} + blacklistedAttrs={[]} + renderInWrapper={false} + components={{ + FormattedTextBox, ImageBox, DirectoryImportBox, FontIconBox, LabelBox, SliderBox, FieldView, + CollectionFreeFormView, CollectionDockingView, CollectionSchemaView, CollectionView, WebBox, KeyValueBox, + PDFBox, VideoBox, AudioBox, PresBox, YoutubeBox, PresElementBox, QueryBox, + ColorBox, DashWebRTCVideo, LinkAnchorBox, InkingStroke, DocHolderBox, LinkBox, ScriptingBox, + RecommendationsBox, ScreenshotBox, HTMLtag + }} + bindings={bindings} + jsx={layoutFrame} + showWarnings={true} + + onError={(test: any) => { console.log(test); }} + />; } }
\ No newline at end of file diff --git a/src/client/views/nodes/DocumentView.scss b/src/client/views/nodes/DocumentView.scss index 2ce56c73d..dea09cb30 100644 --- a/src/client/views/nodes/DocumentView.scss +++ b/src/client/views/nodes/DocumentView.scss @@ -5,6 +5,8 @@ position: inherit; top: 0; left: 0; + width: 100%; + height: 100%; border-radius: inherit; transition: outline .3s linear; cursor: grab; @@ -32,22 +34,48 @@ overflow-y: scroll; height: calc(100% - 20px); } + .documentView-linkAnchorBoxAnchor { + display:flex; + overflow: hidden; - .documentView-docuLinkWrapper { - pointer-events: none; + .documentView-node { + width:10px !important; + } + } + + .documentView-lock { + width: 20; + height: 20; + position: absolute; + right: -5; + top: -5; + background: transparent; + pointer-events: all; + opacity: 0.3; + display: flex; + color: gold; + border-radius: 3px; + justify-content: center; + cursor: default; + } + .documentView-lock:hover { + opacity:1; + } + + .documentView-contentBlocker { + pointer-events: all; position: absolute; - transform-origin: top left; width: 100%; height: 100%; - z-index: 1; + top: 0; + left: 0; } - .documentView-styleWrapper { position: absolute; display: inline-block; width: 100%; height: 100%; - pointer-events: none; + border-radius: inherit; .documentView-styleContentWrapper { width: 100%; @@ -63,7 +91,6 @@ width: 100%; height: 25; background: rgba(0, 0, 0, .4); - padding: 4px; text-align: center; text-overflow: ellipsis; white-space: pre; diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index dcdce5fce..fdcaa2df3 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -1,72 +1,74 @@ import { library } from '@fortawesome/fontawesome-svg-core'; import * as fa from '@fortawesome/free-solid-svg-icons'; -import { action, computed, runInAction, trace } from "mobx"; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { action, computed, observable, runInAction } from "mobx"; import { observer } from "mobx-react"; import * as rp from "request-promise"; -import { Doc, DocListCast, DocListCastAsync, Opt } from "../../../new_fields/Doc"; +import { Doc, DocListCast, HeightSym, Opt, WidthSym } from "../../../new_fields/Doc"; import { Document, PositionDocument } from '../../../new_fields/documentSchemas'; import { Id } from '../../../new_fields/FieldSymbols'; +import { InkTool } from '../../../new_fields/InkField'; +import { RichTextField } from '../../../new_fields/RichTextField'; import { listSpec } from "../../../new_fields/Schema"; +import { SchemaHeaderField } from '../../../new_fields/SchemaHeaderField'; import { ScriptField } from '../../../new_fields/ScriptField'; import { BoolCast, Cast, NumCast, StrCast } from "../../../new_fields/Types"; -import { ImageField, PdfField, VideoField, AudioField } from '../../../new_fields/URLField'; -import { CurrentUserUtils } from "../../../server/authentication/models/current_user_utils"; -import { emptyFunction, returnTransparent, returnTrue, Utils, returnOne } from "../../../Utils"; +import { AudioField, ImageField, PdfField, VideoField } from '../../../new_fields/URLField'; +import { TraceMobx } from '../../../new_fields/util'; +import { GestureUtils } from '../../../pen-gestures/GestureUtils'; +import { emptyFunction, OmitKeys, returnOne, returnTransparent, Utils } from "../../../Utils"; import { GooglePhotos } from '../../apis/google_docs/GooglePhotosClientUtils'; +import { ClientRecommender } from '../../ClientRecommender'; import { DocServer } from "../../DocServer"; -import { Docs, DocUtils, DocumentOptions } from "../../documents/Documents"; +import { Docs, DocumentOptions, DocUtils } from "../../documents/Documents"; import { DocumentType } from '../../documents/DocumentTypes'; import { ClientUtils } from '../../util/ClientUtils'; import { DocumentManager } from "../../util/DocumentManager"; import { DragManager, dropActionType } from "../../util/DragManager"; +import { InteractionUtils } from '../../util/InteractionUtils'; import { Scripting } from '../../util/Scripting'; +import { SearchUtil } from '../../util/SearchUtil'; import { SelectionManager } from "../../util/SelectionManager"; import SharingManager from '../../util/SharingManager'; import { Transform } from "../../util/Transform"; import { undoBatch, UndoManager } from "../../util/UndoManager"; -import { CollectionViewType } from '../collections/CollectionView'; import { CollectionDockingView } from "../collections/CollectionDockingView"; -import { CollectionView } from "../collections/CollectionView"; +import { CollectionView, CollectionViewType } from '../collections/CollectionView'; import { ContextMenu } from "../ContextMenu"; import { ContextMenuProps } from '../ContextMenuItem'; import { DocComponent } from "../DocComponent"; import { EditableView } from '../EditableView'; -import { OverlayView } from '../OverlayView'; -import { ScriptBox } from '../ScriptBox'; -import { ScriptingRepl } from '../ScriptingRepl'; +import { InkingControl } from '../InkingControl'; +import { KeyphraseQueryView } from '../KeyphraseQueryView'; import { DocumentContentsView } from "./DocumentContentsView"; import "./DocumentView.scss"; -import { FormattedTextBox } from './FormattedTextBox'; -import React = require("react"); -import { InteractionUtils } from '../../util/InteractionUtils'; -import { InkingControl } from '../InkingControl'; -import { InkTool } from '../../../new_fields/InkField'; -import { TraceMobx } from '../../../new_fields/util'; -import { List } from '../../../new_fields/List'; -import { FormattedTextBoxComment } from './FormattedTextBoxComment'; -import { GestureUtils } from '../../../pen-gestures/GestureUtils'; import { RadialMenu } from './RadialMenu'; -import { RadialMenuProps } from './RadialMenuItem'; - -import { CollectionStackingView } from '../collections/CollectionStackingView'; -import { RichTextField } from '../../../new_fields/RichTextField'; -import { HistoryUtil } from '../../util/History'; +import React = require("react"); library.add(fa.faEdit, fa.faTrash, fa.faShare, fa.faDownload, fa.faExpandArrowsAlt, fa.faCompressArrowsAlt, fa.faLayerGroup, fa.faExternalLinkAlt, fa.faAlignCenter, fa.faCaretSquareRight, fa.faSquare, fa.faConciergeBell, fa.faWindowRestore, fa.faFolder, fa.faMapPin, fa.faLink, fa.faFingerprint, fa.faCrosshairs, fa.faDesktop, fa.faUnlock, fa.faLock, fa.faLaptopCode, fa.faMale, fa.faCopy, fa.faHandPointRight, fa.faCompass, fa.faSnowflake, fa.faMicrophone); +export type DocFocusFunc = () => boolean; export interface DocumentViewProps { ContainingCollectionView: Opt<CollectionView>; ContainingCollectionDoc: Opt<Doc>; + FreezeDimensions?: boolean; + NativeWidth: () => number; + NativeHeight: () => number; Document: Doc; DataDoc?: Doc; + LayoutDoc?: () => Opt<Doc>; LibraryPath: Doc[]; fitToBox?: boolean; + contextMenuItems?: () => { script: ScriptField, label: string }[]; + rootSelected: (outsideReaction?: boolean) => boolean; // whether the root of a template has been selected onClick?: ScriptField; onPointerDown?: ScriptField; onPointerUp?: ScriptField; + dropAction?: dropActionType; dragDivName?: string; + nudge?: (x: number, y: number) => void; addDocument?: (doc: Doc) => boolean; removeDocument?: (doc: Doc) => boolean; moveDocument?: (doc: Doc, targetCollection: Doc | undefined, addDocument: (document: Doc) => boolean) => boolean; @@ -75,16 +77,15 @@ export interface DocumentViewProps { ContentScaling: () => number; PanelWidth: () => number; PanelHeight: () => number; - focus: (doc: Doc, willZoom: boolean, scale?: number, afterFocus?: () => boolean) => void; + pointerEvents?: boolean; + focus: (doc: Doc, willZoom: boolean, scale?: number, afterFocus?: DocFocusFunc) => void; parentActive: (outsideReaction: boolean) => boolean; whenActiveChanged: (isActive: boolean) => void; bringToFront: (doc: Doc, sendToBack?: boolean) => void; - addDocTab: (doc: Doc, dataDoc: Doc | undefined, where: string, libraryPath?: Doc[]) => boolean; + addDocTab: (doc: Doc, where: string, libraryPath?: Doc[]) => boolean; pinToPres: (document: Doc) => void; - zoomToScale: (scale: number) => void; - backgroundColor: (doc: Doc) => string | undefined; - getScale: () => number; - animateBetweenIcon?: (maximize: boolean, target: number[]) => void; + backgroundHalo?: () => boolean; + backgroundColor?: (doc: Doc) => string | undefined; ChromeHeight?: () => number; dontRegisterView?: boolean; layoutKey?: string; @@ -97,120 +98,145 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu private _downY: number = 0; private _lastTap: number = 0; private _doubleTap = false; - private _hitTemplateDrag = false; private _mainCont = React.createRef<HTMLDivElement>(); private _dropDisposer?: DragManager.DragDropDisposer; + private _showKPQuery: boolean = false; + private _queries: string = ""; private _gestureEventDisposer?: GestureUtils.GestureEventDisposer; private _titleRef = React.createRef<EditableView>(); protected multiTouchDisposer?: InteractionUtils.MultiTouchEventDisposer; + private holdDisposer?: InteractionUtils.MultiTouchEventDisposer; public get displayName() { return "DocumentView(" + this.props.Document.title + ")"; } // this makes mobx trace() statements more descriptive public get ContentDiv() { return this._mainCont.current; } - @computed get active() { return SelectionManager.IsSelected(this, true) || this.props.parentActive(true); } + get active() { return SelectionManager.IsSelected(this, true) || this.props.parentActive(true); } @computed get topMost() { return this.props.renderDepth === 0; } - @computed get nativeWidth() { return this.layoutDoc._nativeWidth || 0; } - @computed get nativeHeight() { return this.layoutDoc._nativeHeight || 0; } - @computed get onClickHandler() { return this.props.onClick || this.layoutDoc.onClick || this.Document.onClick; } + @computed get freezeDimensions() { return this.props.FreezeDimensions; } + @computed get nativeWidth() { return NumCast(this.layoutDoc._nativeWidth, this.props.NativeWidth() || (this.freezeDimensions ? this.layoutDoc[WidthSym]() : 0)); } + @computed get nativeHeight() { return NumCast(this.layoutDoc._nativeHeight, this.props.NativeHeight() || (this.freezeDimensions ? this.layoutDoc[HeightSym]() : 0)); } + @computed get onClickHandler() { return this.props.onClick || Cast(this.layoutDoc.onClick, ScriptField, null) || this.Document.onClick; } @computed get onPointerDownHandler() { return this.props.onPointerDown ? this.props.onPointerDown : this.Document.onPointerDown; } @computed get onPointerUpHandler() { return this.props.onPointerUp ? this.props.onPointerUp : this.Document.onPointerUp; } + NativeWidth = () => this.nativeWidth; + NativeHeight = () => this.nativeHeight; + + private _firstX: number = -1; + private _firstY: number = -1; - private _firstX: number = 0; - private _firstY: number = 0; - - - // handle1PointerHoldStart = (e: React.TouchEvent): any => { - // this.onRadialMenu(e); - // const pt = InteractionUtils.GetMyTargetTouches(e, this.prevPoints, true)[0]; - // this._firstX = pt.pageX; - // this._firstY = pt.pageY; - // e.stopPropagation(); - // e.preventDefault(); - - // document.removeEventListener("touchmove", this.onTouch); - // document.removeEventListener("touchmove", this.handle1PointerHoldMove); - // document.addEventListener("touchmove", this.handle1PointerHoldMove); - // document.removeEventListener("touchend", this.handle1PointerHoldEnd); - // document.addEventListener("touchend", this.handle1PointerHoldEnd); - // } - - // handle1PointerHoldMove = (e: TouchEvent): void => { - // const pt = InteractionUtils.GetMyTargetTouches(me, this.prevPoints, true)[0]; - // if (Math.abs(pt.pageX - this._firstX) > 150 || Math.abs(pt.pageY - this._firstY) > 150) { - // this.handleRelease(); - // } - // document.removeEventListener("touchmove", this.handle1PointerHoldMove); - // document.addEventListener("touchmove", this.handle1PointerHoldMove); - // document.removeEventListener("touchend", this.handle1PointerHoldEnd); - // document.addEventListener("touchend", this.handle1PointerHoldEnd); - // } - - // handleRelease() { - // RadialMenu.Instance.closeMenu(); - // document.removeEventListener("touchmove", this.handle1PointerHoldMove); - // document.removeEventListener("touchend", this.handle1PointerHoldEnd); - // } - - // handle1PointerHoldEnd = (e: TouchEvent): void => { - // RadialMenu.Instance.closeMenu(); - // document.removeEventListener("touchmove", this.handle1PointerHoldMove); - // document.removeEventListener("touchend", this.handle1PointerHoldEnd); - // } - - // @action - // onRadialMenu = (e: React.TouchEvent): void => { - // const pt = InteractionUtils.GetMyTargetTouches(me, this.prevPoints, true)[0]; - - // RadialMenu.Instance.openMenu(); - - // RadialMenu.Instance.addItem({ description: "Open Fields", event: () => this.props.addDocTab(Docs.Create.KVPDocument(this.props.Document, { width: 300, height: 300 }), undefined, "onRight"), icon: "layer-group", selected: -1 }); - // RadialMenu.Instance.addItem({ description: "Delete this document", event: () => this.props.ContainingCollectionView?.removeDocument(this.props.Document), icon: "trash", selected: -1 }); - // RadialMenu.Instance.addItem({ description: "Open in a new tab", event: () => this.props.addDocTab(this.props.Document, undefined, "onRight"), icon: "folder", selected: -1 }); - // RadialMenu.Instance.addItem({ description: "Pin to Presentation", event: () => this.props.pinToPres(this.props.Document), icon: "map-pin", selected: -1 }); - - // RadialMenu.Instance.displayMenu(pt.pageX - 15, pt.pageY - 15); - // if (!SelectionManager.IsSelected(this, true)) { - // SelectionManager.SelectDoc(this, false); - // } - // e.stopPropagation(); - // } + handle1PointerHoldStart = (e: Event, me: InteractionUtils.MultiTouchEvent<React.TouchEvent>): any => { + this.removeMoveListeners(); + this.removeEndListeners(); + document.removeEventListener("pointermove", this.onPointerMove); + document.removeEventListener("pointerup", this.onPointerUp); + console.log(SelectionManager.SelectedDocuments()); + console.log("START"); + if (RadialMenu.Instance._display === false) { + this.addHoldMoveListeners(); + this.addHoldEndListeners(); + this.onRadialMenu(e, me); + const pt = me.touchEvent.touches[me.touchEvent.touches.length - 1]; + this._firstX = pt.pageX; + this._firstY = pt.pageY; + } + + } + + handle1PointerHoldMove = (e: Event, me: InteractionUtils.MultiTouchEvent<TouchEvent>): void => { + + const pt = me.touchEvent.touches[me.touchEvent.touches.length - 1]; + + if (this._firstX === -1 || this._firstY === -1) { + return; + } + if (Math.abs(pt.pageX - this._firstX) > 150 || Math.abs(pt.pageY - this._firstY) > 150) { + this.handle1PointerHoldEnd(e, me); + } + } + + handle1PointerHoldEnd = (e: Event, me: InteractionUtils.MultiTouchEvent<TouchEvent>): void => { + this.removeHoldMoveListeners(); + this.removeHoldEndListeners(); + RadialMenu.Instance.closeMenu(); + this._firstX = -1; + this._firstY = -1; + SelectionManager.DeselectAll(); + me.touchEvent.stopPropagation(); + me.touchEvent.preventDefault(); + e.stopPropagation(); + if (RadialMenu.Instance.used) { + this.onContextMenu(me.touches[0]); + } + } + + @action + onRadialMenu = (e: Event, me: InteractionUtils.MultiTouchEvent<React.TouchEvent>): void => { + // console.log(InteractionUtils.GetMyTargetTouches(me, this.prevPoints, true)); + // const pt = InteractionUtils.GetMyTargetTouches(me, this.prevPoints, true)[0]; + const pt = me.touchEvent.touches[me.touchEvent.touches.length - 1]; + RadialMenu.Instance.openMenu(pt.pageX - 15, pt.pageY - 15); + + RadialMenu.Instance.addItem({ description: "Open Fields", event: () => this.props.addDocTab(Docs.Create.KVPDocument(this.props.Document, { _width: 300, _height: 300 }), "onRight"), icon: "map-pin", selected: -1 }); + RadialMenu.Instance.addItem({ description: "Delete this document", event: () => { this.props.ContainingCollectionView?.removeDocument(this.props.Document), RadialMenu.Instance.closeMenu(); }, icon: "layer-group", selected: -1 }); + RadialMenu.Instance.addItem({ description: "Open in a new tab", event: () => this.props.addDocTab(this.props.Document, "onRight"), icon: "trash", selected: -1 }); + RadialMenu.Instance.addItem({ description: "Pin to Presentation", event: () => this.props.pinToPres(this.props.Document), icon: "folder", selected: -1 }); + + // if (SelectionManager.IsSelected(this, true)) { + // SelectionManager.SelectDoc(this, false); + // } + SelectionManager.DeselectAll(); + + + } @action componentDidMount() { - this._mainCont.current && (this._dropDisposer = DragManager.MakeDropTarget(this._mainCont.current, this.drop.bind(this))); + this._mainCont.current && (this._dropDisposer = DragManager.MakeDropTarget(this._mainCont.current, this.drop.bind(this), this.props.Document)); this._mainCont.current && (this._gestureEventDisposer = GestureUtils.MakeGestureTarget(this._mainCont.current, this.onGesture.bind(this))); this._mainCont.current && (this.multiTouchDisposer = InteractionUtils.MakeMultiTouchTarget(this._mainCont.current, this.onTouchStart.bind(this))); + // this._mainCont.current && (this.holdDisposer = InteractionUtils.MakeHoldTouchTarget(this._mainCont.current, this.handle1PointerHoldStart.bind(this))); - !this.props.dontRegisterView && DocumentManager.Instance.DocumentViews.push(this); + if (!this.props.dontRegisterView) { + DocumentManager.Instance.DocumentViews.push(this); + } } @action componentDidUpdate() { - this._dropDisposer && this._dropDisposer(); - this._gestureEventDisposer && this._gestureEventDisposer(); - this.multiTouchDisposer && this.multiTouchDisposer(); - this._mainCont.current && (this._dropDisposer = DragManager.MakeDropTarget(this._mainCont.current, this.drop.bind(this))); - this._mainCont.current && (this._gestureEventDisposer = GestureUtils.MakeGestureTarget(this._mainCont.current, this.onGesture.bind(this))); - this._mainCont.current && (this.multiTouchDisposer = InteractionUtils.MakeMultiTouchTarget(this._mainCont.current, this.onTouchStart.bind(this))); + this._dropDisposer?.(); + this._gestureEventDisposer?.(); + this.multiTouchDisposer?.(); + this.holdDisposer?.(); + if (this._mainCont.current) { + this._dropDisposer = DragManager.MakeDropTarget(this._mainCont.current, this.drop.bind(this), this.props.Document); + this._gestureEventDisposer = GestureUtils.MakeGestureTarget(this._mainCont.current, this.onGesture.bind(this)); + this.multiTouchDisposer = InteractionUtils.MakeMultiTouchTarget(this._mainCont.current, this.onTouchStart.bind(this)); + this.holdDisposer = InteractionUtils.MakeHoldTouchTarget(this._mainCont.current, this.handle1PointerHoldStart.bind(this)); + } } @action componentWillUnmount() { - this._dropDisposer && this._dropDisposer(); + this._dropDisposer?.(); + this._gestureEventDisposer?.(); + this.multiTouchDisposer?.(); + this.holdDisposer?.(); Doc.UnBrushDoc(this.props.Document); - !this.props.dontRegisterView && DocumentManager.Instance.DocumentViews.splice(DocumentManager.Instance.DocumentViews.indexOf(this), 1); + if (!this.props.dontRegisterView) { + const index = DocumentManager.Instance.DocumentViews.indexOf(this); + index !== -1 && DocumentManager.Instance.DocumentViews.splice(index, 1); + } } - startDragging(x: number, y: number, dropAction: dropActionType, applyAsTemplate?: boolean) { + startDragging(x: number, y: number, dropAction: dropActionType) { if (this._mainCont.current) { const dragData = new DragManager.DocumentDragData([this.props.Document]); const [left, top] = this.props.ScreenToLocalTransform().scale(this.props.ContentScaling()).inverse().transformPoint(0, 0); dragData.offset = this.props.ScreenToLocalTransform().scale(this.props.ContentScaling()).transformDirection(x - left, y - top); dragData.dropAction = dropAction; + dragData.removeDocument = this.props.removeDocument; dragData.moveDocument = this.props.moveDocument;// this.Document.onDragStart ? undefined : this.props.moveDocument; - dragData.applyAsTemplate = applyAsTemplate; dragData.dragDivName = this.props.dragDivName; - this.props.Document.sourceContext = this.props.ContainingCollectionDoc; // bcz: !! shouldn't need this ... use search find the document's context dynamically DragManager.StartDocumentDrag([this._mainCont.current], dragData, x, y, { hideSource: !dropAction && !this.Document.onDragStart }); } } @@ -237,7 +263,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu e.stopPropagation(); e.preventDefault(); if (e.key === "†" || e.key === "t") { - if (!StrCast(this.layoutDoc.showTitle)) this.layoutDoc.showTitle = "title"; + if (!StrCast(this.layoutDoc._showTitle)) this.layoutDoc._showTitle = "title"; if (!this._titleRef.current) setTimeout(() => this._titleRef.current?.setIsFocused(true), 0); else if (!this._titleRef.current.setIsFocused(true)) { // if focus didn't change, focus on interior text... { @@ -255,79 +281,90 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu } } - onClick = undoBatch((e: React.MouseEvent | React.PointerEvent) => { - if (!e.nativeEvent.cancelBubble && !this.Document.ignoreClick && CurrentUserUtils.MainDocId !== this.props.Document[Id] && + onClick = action((e: React.MouseEvent | React.PointerEvent) => { + if (!e.nativeEvent.cancelBubble && !this.Document.ignoreClick && (Math.abs(e.clientX - this._downX) < Utils.DRAG_THRESHOLD && Math.abs(e.clientY - this._downY) < Utils.DRAG_THRESHOLD)) { - e.stopPropagation(); + let stopPropagate = true; let preventDefault = true; + !this.props.Document.isBackground && this.props.bringToFront(this.props.Document); if (this._doubleTap && this.props.renderDepth && !this.onClickHandler?.script) { // disable double-click to show full screen for things that have an on click behavior since clicking them twice can be misinterpreted as a double click - const fullScreenAlias = Doc.MakeAlias(this.props.Document); - if (StrCast(fullScreenAlias.layoutKey) !== "layout_custom" && fullScreenAlias.layout_custom !== undefined) { - fullScreenAlias.layoutKey = "layout_custom"; + if (!(e.nativeEvent as any).formattedHandled) { + const fullScreenAlias = Doc.MakeAlias(this.props.Document); + if (StrCast(fullScreenAlias.layoutKey) !== "layout_fullScreen" && fullScreenAlias.layout_fullScreen) { + fullScreenAlias.layoutKey = "layout_fullScreen"; + } + UndoManager.RunInBatch(() => this.props.addDocTab(fullScreenAlias, "inTab"), "double tap"); + SelectionManager.DeselectAll(); + Doc.UnBrushDoc(this.props.Document); } - this.props.addDocTab(fullScreenAlias, undefined, "inTab"); - SelectionManager.DeselectAll(); - Doc.UnBrushDoc(this.props.Document); - } else if (this.onClickHandler && this.onClickHandler.script) { - this.onClickHandler.script.run({ this: this.Document.isTemplateForField && this.props.DataDoc ? this.props.DataDoc : this.props.Document, containingCollection: this.props.ContainingCollectionDoc, shiftKey: e.shiftKey }, console.log); - } else if (this.Document.type === DocumentType.BUTTON) { - ScriptBox.EditButtonScript("On Button Clicked ...", this.props.Document, "onClick", e.clientX, e.clientY); - } else if (this.props.Document.isButton === "Selector") { // this should be moved to an OnClick script - FormattedTextBoxComment.Hide(); - this.Document.links?.[0] instanceof Doc && (Doc.UserDoc().SelectedDocs = new List([Doc.LinkOtherAnchor(this.Document.links[0], this.props.Document)])); - } else if (this.Document.isButton) { - SelectionManager.SelectDoc(this, e.ctrlKey); // don't think this should happen if a button action is actually triggered. - this.buttonClick(e.altKey, e.ctrlKey); + } else if (this.onClickHandler?.script && !StrCast(Doc.LayoutField(this.layoutDoc))?.includes("ScriptingBox")) { // bcz: hack? don't execute script if you're clicking on a scripting box itself + //SelectionManager.DeselectAll(); + const func = () => this.onClickHandler.script.run({ + this: this.layoutDoc, + self: this.rootDoc, + thisContainer: this.props.ContainingCollectionDoc, shiftKey: e.shiftKey + }, console.log); + if (this.props.Document !== Doc.UserDoc()["dockedBtn-undo"] && this.props.Document !== Doc.UserDoc()["dockedBtn-redo"]) { + UndoManager.RunInBatch(func, "on click"); + } else func(); + } else if (this.Document["onClick-rawScript"] && !StrCast(Doc.LayoutField(this.layoutDoc))?.includes("ScriptingBox")) {// bcz: hack? don't edit a script if you're clicking on a scripting box itself + UndoManager.RunInBatch(() => Doc.makeCustomViewClicked(this.props.Document, undefined, "onClick"), "edit onClick"); + //ScriptBox.EditButtonScript("On Button Clicked ...", this.props.Document, "onClick", e.clientX, e.clientY), "on button click"); + } else if (this.Document.isLinkButton && !e.shiftKey && !e.ctrlKey) { + DocListCast(this.props.Document.links).length && this.followLinkClick(e.altKey, e.ctrlKey, e.shiftKey); } else { - SelectionManager.SelectDoc(this, e.ctrlKey); + if ((this.props.Document.onDragStart || (this.props.Document.rootDocument)) && !(e.ctrlKey || e.button > 0)) { // onDragStart implies a button doc that we don't want to select when clicking. RootDocument & isTEmplaetForField implies we're clicking on part of a template instance and we want to select the whole template, not the part + stopPropagate = false; // don't stop propagation for field templates -- want the selection to propagate up to the root document of the template + } else { + // if (this.props.Document.type === DocumentType.RTF) { + // DocumentView._focusHack = this.props.ScreenToLocalTransform().transformPoint(e.clientX, e.clientY) || [0, 0]; + // DocumentView._focusHack = [DocumentView._focusHack[0] + NumCast(this.props.Document.x), DocumentView._focusHack[1] + NumCast(this.props.Document.y)]; + + // this.props.focus(this.props.Document, false); + // } + SelectionManager.SelectDoc(this, e.ctrlKey || e.shiftKey); + } preventDefault = false; } + stopPropagate && e.stopPropagation(); preventDefault && e.preventDefault(); } - }) - - buttonClick = async (altKey: boolean, ctrlKey: boolean) => { - const maximizedDocs = await DocListCastAsync(this.Document.maximizedDocs); - const summarizedDocs = await DocListCastAsync(this.Document.summarizedDocs); - const linkDocs = DocListCast(this.props.Document.links); - let expandedDocs: Doc[] = []; - expandedDocs = maximizedDocs ? [...maximizedDocs, ...expandedDocs] : expandedDocs; - expandedDocs = summarizedDocs ? [...summarizedDocs, ...expandedDocs] : expandedDocs; - // let expandedDocs = [ ...(maximizedDocs ? maximizedDocs : []), ...(summarizedDocs ? summarizedDocs : []),]; - if (expandedDocs.length) { - SelectionManager.DeselectAll(); - let maxLocation = StrCast(this.Document.maximizeLocation, "inPlace"); - maxLocation = this.Document.maximizeLocation = (!ctrlKey ? !altKey ? maxLocation : (maxLocation !== "inPlace" ? "inPlace" : "onRight") : (maxLocation !== "inPlace" ? "inPlace" : "inTab")); - if (maxLocation === "inPlace") { - expandedDocs.forEach(maxDoc => this.props.addDocument && this.props.addDocument(maxDoc)); - const scrpt = this.props.ScreenToLocalTransform().scale(this.props.ContentScaling()).inverse().transformPoint(NumCast(this.layoutDoc.width) / 2, NumCast(this.layoutDoc.height) / 2); - DocumentManager.Instance.animateBetweenPoint(scrpt, expandedDocs); + }); + + // follows a link - if the target is on screen, it highlights/pans to it. + // if the target isn't onscreen, then it will open up the target in a tab, on the right, or in place + // depending on the followLinkLocation property of the source (or the link itself as a fallback); + followLinkClick = async (altKey: boolean, ctrlKey: boolean, shiftKey: boolean) => { + const batch = UndoManager.StartBatch("follow link click"); + // open up target if it's not already in view ... + const createViewFunc = (doc: Doc, followLoc: string, finished: Opt<() => void>) => { + const targetFocusAfterDocFocus = () => { + const where = StrCast(this.Document.followLinkLocation) || followLoc; + const hackToCallFinishAfterFocus = () => { + finished && setTimeout(finished, 0); // finished() needs to be called right after hackToCallFinishAfterFocus(), but there's no callback for that so we use the hacky timeout. + return false; // we must return false here so that the zoom to the document is not reversed. If it weren't for needing to call finished(), we wouldn't need this function at all since not having it is equivalent to returning false + }; + this.props.addDocTab(doc, where) && this.props.focus(doc, BoolCast(this.Document.followLinkZoom, true), undefined, hackToCallFinishAfterFocus); // add the target and focus on it. + return where !== "inPlace"; // return true to reset the initial focus&zoom (return false for 'inPlace' since resetting the initial focus&zoom will negate the zoom into the target) + }; + if (!this.Document.followLinkZoom) { + targetFocusAfterDocFocus(); } else { - expandedDocs.forEach(maxDoc => (!this.props.addDocTab(maxDoc, undefined, "close") && this.props.addDocTab(maxDoc, undefined, maxLocation))); + // first focus & zoom onto this (the clicked document). Then execute the function to focus on the target + this.props.focus(this.props.Document, BoolCast(this.Document.followLinkZoom, true), 1, targetFocusAfterDocFocus); } - } - else if (linkDocs.length) { - DocumentManager.Instance.FollowLink(undefined, this.props.Document, - // open up target if it's not already in view ... by zooming into the button document first and setting flag to reset zoom afterwards - (doc: Doc, maxLocation: string) => this.props.focus(this.props.Document, true, 1, () => this.props.addDocTab(doc, undefined, maxLocation)), - ctrlKey, altKey, this.props.ContainingCollectionDoc); - } + }; + await DocumentManager.Instance.FollowLink(undefined, this.props.Document, createViewFunc, shiftKey, this.props.ContainingCollectionDoc, batch.end, altKey ? true : undefined); } handle1PointerDown = (e: React.TouchEvent, me: InteractionUtils.MultiTouchEvent<React.TouchEvent>) => { + SelectionManager.DeselectAll(); if (this.Document.onPointerDown) return; - const touch = InteractionUtils.GetMyTargetTouches(me, this.prevPoints, true)[0]; - console.log("down"); + const touch = me.touchEvent.changedTouches.item(0); if (touch) { this._downX = touch.clientX; this._downY = touch.clientY; if (!e.nativeEvent.cancelBubble) { - this._hitTemplateDrag = false; - for (let element = (e.target as any); element && !this._hitTemplateDrag; element = element.parentElement) { - if (element.className && element.className.toString() === "collectionViewBaseChrome-collapse") { - this._hitTemplateDrag = true; - } - } if ((this.active || this.Document.onDragStart || this.Document.onClick) && !e.ctrlKey && !this.Document.lockedPosition && !this.Document.inOverlay) e.stopPropagation(); this.removeMoveListeners(); this.addMoveListeners(); @@ -344,11 +381,12 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu this.removeMoveListeners(); } else if (!e.cancelBubble && (SelectionManager.IsSelected(this, true) || this.props.parentActive(true) || this.Document.onDragStart || this.Document.onClick) && !this.Document.lockedPosition && !this.Document.inOverlay) { - const touch = InteractionUtils.GetMyTargetTouches(me, this.prevPoints, true)[0]; - if (Math.abs(this._downX - touch.clientX) > 3 || Math.abs(this._downY - touch.clientY) > 3) { + + const touch = me.touchEvent.changedTouches.item(0); + if (touch && (Math.abs(this._downX - touch.clientX) > 3 || Math.abs(this._downY - touch.clientY) > 3)) { if (!e.altKey && (!this.topMost || this.Document.onDragStart || this.Document.onClick)) { this.cleanUpInteractions(); - this.startDragging(this._downX, this._downY, this.Document.dropAction ? this.Document.dropAction as any : e.ctrlKey || e.altKey ? "alias" : undefined, this._hitTemplateDrag); + this.startDragging(this._downX, this._downY, this.Document.dropAction ? this.Document.dropAction as any : e.ctrlKey || e.altKey ? "alias" : undefined); } } 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 @@ -369,6 +407,19 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu } } + public iconify() { + const layoutKey = Cast(this.props.Document.layoutKey, "string", null); + const collapse = layoutKey !== "layout_icon"; + if (collapse) { + this.switchViews(collapse, "icon"); + if (layoutKey && layoutKey !== "layout" && layoutKey !== "layout_icon") this.props.Document.deiconifyLayout = layoutKey.replace("layout_", ""); + } else { + const deiconifyLayout = Cast(this.props.Document.deiconifyLayout, "string", null); + this.switchViews(deiconifyLayout ? true : false, deiconifyLayout); + this.props.Document.deiconifyLayout = undefined; + } + } + @action handle2PointersMove = (e: TouchEvent, me: InteractionUtils.MultiTouchEvent<TouchEvent>) => { const myTouches = InteractionUtils.GetMyTargetTouches(me, this.prevPoints, true); @@ -401,18 +452,12 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu const actualdH = Math.max(height + (dH * scale), 20); doc.x = (doc.x || 0) + dX * (actualdW - width); doc.y = (doc.y || 0) + dY * (actualdH - height); - const fixedAspect = e.ctrlKey || (!layoutDoc.ignoreAspect && nwidth && nheight); - if (fixedAspect && e.ctrlKey && layoutDoc.ignoreAspect) { - layoutDoc.ignoreAspect = false; - - layoutDoc._nativeWidth = nwidth = layoutDoc._width || 0; - layoutDoc._nativeHeight = nheight = layoutDoc._height || 0; - } + const fixedAspect = e.ctrlKey || (nwidth && nheight); if (fixedAspect && (!nwidth || !nheight)) { layoutDoc._nativeWidth = nwidth = layoutDoc._width || 0; layoutDoc._nativeHeight = nheight = layoutDoc._height || 0; } - if (nwidth > 0 && nheight > 0 && !layoutDoc.ignoreAspect) { + if (nwidth > 0 && nheight > 0) { if (Math.abs(dW) > Math.abs(dH)) { if (!fixedAspect) { layoutDoc._nativeWidth = actualdW / (layoutDoc._width || 1) * (layoutDoc._nativeWidth || 0); @@ -441,38 +486,27 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu } onPointerDown = (e: React.PointerEvent): void => { - if (this.onPointerDownHandler && this.onPointerDownHandler.script) { - this.onPointerDownHandler.script.run({ this: this.Document.isTemplateForField && this.props.DataDoc ? this.props.DataDoc : this.props.Document }, console.log); - document.removeEventListener("pointerup", this.onPointerUp); - document.addEventListener("pointerup", this.onPointerUp); - return; - } // console.log(e.button) // console.log(e.nativeEvent) // continue if the event hasn't been canceled AND we are using a moues or this is has an onClick or onDragStart function (meaning it is a button document) if (!(InteractionUtils.IsType(e, InteractionUtils.MOUSETYPE) || InkingControl.Instance.selectedTool === InkTool.Highlighter || InkingControl.Instance.selectedTool === InkTool.Pen)) { if (!InteractionUtils.IsType(e, InteractionUtils.PENTYPE)) { e.stopPropagation(); + // TODO: check here for panning/inking } return; } - if (!e.nativeEvent.cancelBubble || this.onClickHandler || this.Document.onDragStart) { - this._downX = e.clientX; - this._downY = e.clientY; - this._hitTemplateDrag = false; - // this whole section needs to move somewhere else. We're trying to initiate a special "template" drag where - // this document is the template and we apply it to whatever we drop it on. - for (let element = (e.target as any); element && !this._hitTemplateDrag; element = element.parentElement) { - if (element.className && element.className.toString() === "collectionViewBaseChrome-collapse") { - this._hitTemplateDrag = true; - } - } + this._downX = e.clientX; + this._downY = e.clientY; + if ((!e.nativeEvent.cancelBubble || this.onClickHandler || this.Document.onDragStart) && + // if this is part of a template, let the event go up to the tempalte root unless right/ctrl clicking + !((this.props.Document.rootDocument) && !(e.ctrlKey || e.button > 0))) { if ((this.active || this.Document.onDragStart || this.onClickHandler) && !e.ctrlKey && (e.button === 0 || InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE)) && - !this.Document.lockedPosition && !this.Document.inOverlay) { e.stopPropagation(); // events stop at the lowest document that is active. if right dragging, we let it go through though to allow for context menu clicks. PointerMove callbacks should remove themselves if the move event gets stopPropagated by a lower-level handler (e.g, marquee drag); + } document.removeEventListener("pointermove", this.onPointerMove); document.removeEventListener("pointerup", this.onPointerUp); @@ -495,7 +529,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu if (!e.altKey && (!this.topMost || this.Document.onDragStart || this.onClickHandler) && (e.buttons === 1 || InteractionUtils.IsType(e, InteractionUtils.TOUCHTYPE))) { document.removeEventListener("pointermove", this.onPointerMove); document.removeEventListener("pointerup", this.onPointerUp); - this.startDragging(this._downX, this._downY, this.props.ContainingCollectionDoc?.childDropAction ? this.props.ContainingCollectionDoc?.childDropAction : this.Document.dropAction ? this.Document.dropAction as any : e.ctrlKey || e.altKey ? "alias" : undefined, this._hitTemplateDrag); + this.startDragging(this._downX, this._downY, this.props.dropAction ? this.props.dropAction : this.Document.dropAction ? this.Document.dropAction as any : e.ctrlKey || e.altKey ? "alias" : undefined); } } 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 @@ -504,11 +538,14 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu } onPointerUp = (e: PointerEvent): void => { - if (this.onPointerUpHandler && this.onPointerUpHandler.script && !InteractionUtils.IsType(e, InteractionUtils.PENTYPE)) { - this.onPointerUpHandler.script.run({ this: this.Document.isTemplateForField && this.props.DataDoc ? this.props.DataDoc : this.props.Document }, console.log); + this.cleanUpInteractions(); + + if (this.onPointerUpHandler?.script && !InteractionUtils.IsType(e, InteractionUtils.PENTYPE)) { + this.onPointerUpHandler.script.run({ self: this.rootDoc, this: this.layoutDoc }, console.log); document.removeEventListener("pointerup", this.onPointerUp); return; } + document.removeEventListener("pointermove", this.onPointerMove); document.removeEventListener("pointerup", this.onPointerUp); this._doubleTap = (Date.now() - this._lastTap < 300 && e.button === 0 && Math.abs(e.clientX - this._downX) < 2 && Math.abs(e.clientY - this._downY) < 2); @@ -527,68 +564,41 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu @undoBatch deleteClicked = (): void => { SelectionManager.DeselectAll(); this.props.removeDocument?.(this.props.Document); } - static makeNativeViewClicked = (doc: Doc) => { - undoBatch(() => Doc.setNativeView(doc))(); - } - - static makeCustomViewClicked = (doc: Doc, dataDoc: Opt<Doc>, creator: (documents: Array<Doc>, options: DocumentOptions, id?: string) => Doc, name: string = "custom", docLayoutTemplate?: Doc) => { - const batch = UndoManager.StartBatch("CustomViewClicked"); - const customName = "layout_" + name; - if (!StrCast(doc.title).endsWith(name)) doc.title = doc.title + "_" + name; - if (doc[customName] === undefined) { - const _width = NumCast(doc._width); - const _height = NumCast(doc._height); - const options = { title: "data", _width, x: -_width / 2, y: - _height / 2, _showSidebar: false }; - - const field = doc.data; - let fieldTemplate: Opt<Doc>; - if (field instanceof RichTextField || typeof (field) === "string") { - fieldTemplate = Docs.Create.TextDocument("", options); - } else if (field instanceof PdfField) { - fieldTemplate = Docs.Create.PdfDocument("http://www.msn.com", options); - } else if (field instanceof VideoField) { - fieldTemplate = Docs.Create.VideoDocument("http://www.cs.brown.edu", options); - } else if (field instanceof AudioField) { - fieldTemplate = Docs.Create.AudioDocument("http://www.cs.brown.edu", options); - } else if (field instanceof ImageField) { - fieldTemplate = Docs.Create.ImageDocument("http://www.cs.brown.edu", options); - } - - if (fieldTemplate) { - fieldTemplate.backgroundColor = doc.backgroundColor; - fieldTemplate.heading = 1; - fieldTemplate._autoHeight = true; - } - - const docTemplate = docLayoutTemplate || creator(fieldTemplate ? [fieldTemplate] : [], { title: customName + "(" + doc.title + ")", isTemplateDoc: true, _width: _width + 20, _height: Math.max(100, _height + 45) }); - fieldTemplate && Doc.MakeMetadataFieldTemplate(fieldTemplate, Doc.GetProto(docTemplate)); - Doc.ApplyTemplateTo(docTemplate, dataDoc || doc, customName, undefined); + @undoBatch + toggleLinkButtonBehavior = (): void => { + if (this.Document.isLinkButton || this.Document.onClick || this.Document.ignoreClick) { + this.Document.isLinkButton = false; + this.Document.ignoreClick = false; + this.Document.onClick = undefined; } else { - doc.layoutKey = customName; + this.Document.isLinkButton = true; + this.Document.followLinkZoom = false; + this.Document.followLinkLocation = undefined; } - batch.end(); } @undoBatch - makeBtnClicked = (): void => { - if (this.Document.isButton || this.Document.onClick || this.Document.ignoreClick) { - this.Document.isButton = false; - this.Document.ignoreClick = false; - this.Document.onClick = undefined; + toggleFollowInPlace = (): void => { + if (this.Document.isLinkButton) { + this.Document.isLinkButton = false; } else { - this.Document.isButton = true; + this.Document.isLinkButton = true; + this.Document.followLinkZoom = true; + this.Document.followLinkLocation = "inPlace"; } } @undoBatch - makeSelBtnClicked = (): void => { - if (this.Document.isButton || this.Document.onClick || this.Document.ignoreClick) { - this.Document.isButton = false; - this.Document.ignoreClick = false; - this.Document.onClick = undefined; + toggleFollowOnRight = (): void => { + if (this.Document.isLinkButton) { + this.Document.isLinkButton = false; } else { - this.props.Document.isButton = "Selector"; + this.Document.isLinkButton = true; + this.Document.followLinkZoom = false; + const first = DocListCast(this.Document.links).find(d => d instanceof Doc); + first && (first.hidden = true); + this.Document.followLinkLocation = "onRight"; } } @@ -600,75 +610,51 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu e.stopPropagation(); de.complete.annoDragData.linkedToDoc = true; - DocUtils.MakeLink({ doc: de.complete.annoDragData.annotationDocument }, { doc: this.props.Document, ctx: this.props.ContainingCollectionDoc }, - `Link from ${StrCast(de.complete.annoDragData.annotationDocument.title)}`); - } - if (de.complete.docDragData && de.complete.docDragData.applyAsTemplate) { - Doc.ApplyTemplateTo(de.complete.docDragData.draggedDocuments[0], this.props.Document, "layout_custom", undefined); - e.stopPropagation(); + DocUtils.MakeLink({ doc: de.complete.annoDragData.annotationDocument }, { doc: this.props.Document }, "link"); } if (de.complete.linkDragData) { e.stopPropagation(); // const docs = await SearchUtil.Search(`data_l:"${destDoc[Id]}"`, true); // const views = docs.map(d => DocumentManager.Instance.getDocumentView(d)).filter(d => d).map(d => d as DocumentView); de.complete.linkDragData.linkSourceDocument !== this.props.Document && - (de.complete.linkDragData.linkDocument = DocUtils.MakeLink({ doc: de.complete.linkDragData.linkSourceDocument }, { doc: this.props.Document, ctx: this.props.ContainingCollectionDoc }, "in-text link being created")); // TODODO this is where in text links get passed + (de.complete.linkDragData.linkDocument = DocUtils.MakeLink({ doc: de.complete.linkDragData.linkSourceDocument }, + { doc: this.props.Document }, `link`)); // TODODO this is where in text links get passed } } @undoBatch @action - freezeNativeDimensions = (): void => { - this.layoutDoc._autoHeight = false; - this.layoutDoc.ignoreAspect = !this.layoutDoc.ignoreAspect; - if (!this.layoutDoc.ignoreAspect && !this.layoutDoc._nativeWidth) { - this.layoutDoc._nativeWidth = this.props.PanelWidth(); - this.layoutDoc._nativeHeight = this.props.PanelHeight(); - } + public static unfreezeNativeDimensions(layoutDoc: Doc) { + layoutDoc._nativeWidth = undefined; + layoutDoc._nativeHeight = undefined; } - @undoBatch - @action - makeIntoPortal = async () => { - const anchors = await Promise.all(DocListCast(this.Document.links).map(async (d: Doc) => Cast(d.anchor2, Doc))); - if (!anchors.find(anchor2 => anchor2 && anchor2.title === this.Document.title + ".portal" ? true : false)) { - const portalID = (this.Document.title + ".portal").replace(/^-/, "").replace(/\([0-9]*\)$/, ""); - DocServer.GetRefField(portalID).then(existingPortal => { - const portal = existingPortal instanceof Doc ? existingPortal : Docs.Create.FreeformDocument([], { _width: (this.layoutDoc._width || 0) + 10, _height: this.layoutDoc._height || 0, title: portalID }); - DocUtils.MakeLink({ doc: this.props.Document, ctx: this.props.ContainingCollectionDoc }, { doc: portal }, portalID, "portal link"); - this.Document.isButton = true; - }); + toggleNativeDimensions = () => { + if (this.Document._nativeWidth || this.Document._nativeHeight) { + DocumentView.unfreezeNativeDimensions(this.layoutDoc); + } + else { + Doc.freezeNativeDimensions(this.layoutDoc, this.props.PanelWidth(), this.props.PanelHeight()); } } @undoBatch @action - setCustomView = - (custom: boolean, layout: string): void => { - // if (this.props.ContainingCollectionView?.props.DataDoc || this.props.ContainingCollectionView?.props.Document.isTemplateDoc) { - // Doc.MakeMetadataFieldTemplate(this.props.Document, this.props.ContainingCollectionView.props.Document); - // } else - if (custom) { - DocumentView.makeNativeViewClicked(this.props.Document); - - let foundLayout: Opt<Doc>; - DocListCast(Cast(Doc.UserDoc().expandingButtons, Doc, null)?.data)?.concat([Cast(Doc.UserDoc().iconView, Doc, null)]). - map(btnDoc => (btnDoc.dragFactory as Doc) || btnDoc).filter(doc => doc.isTemplateDoc).forEach(tempDoc => { - if (StrCast(tempDoc.title) === layout) { - foundLayout = tempDoc; - } - }) - DocumentView. - makeCustomViewClicked(this.props.Document, this.props.DataDoc, Docs.Create.StackingDocument, layout, foundLayout); - } else { - DocumentView.makeNativeViewClicked(this.props.Document); - } + makeIntoPortal = async () => { + const portalLink = DocListCast(this.Document.links).find(d => d.anchor1 === this.props.Document); + if (!portalLink) { + const portal = Docs.Create.FreeformDocument([], { _width: NumCast(this.layoutDoc._width) + 10, _height: NumCast(this.layoutDoc._height), title: StrCast(this.props.Document.title) + ".portal" }); + DocUtils.MakeLink({ doc: this.props.Document }, { doc: portal }, "portal to"); } + this.Document.followLinkZoom = true; + this.Document.isLinkButton = true; + } @undoBatch @action - makeBackground = (): void => { - this.Document.isBackground = !this.Document.isBackground; + toggleBackground = (temporary: boolean): void => { + this.Document.overflow = temporary ? "visible" : "hidden"; + this.Document.isBackground = !temporary ? !this.Document.isBackground : (this.Document.isBackground ? undefined : true); this.Document.isBackground && this.props.bringToFront(this.Document, true); } @@ -685,41 +671,65 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu } @action - onContextMenu = async (e: React.MouseEvent): Promise<void> => { + onContextMenu = async (e: React.MouseEvent | Touch): Promise<void> => { // the touch onContextMenu is button 0, the pointer onContextMenu is button 2 - if (e.button === 0 && !e.ctrlKey) { - e.preventDefault(); - return; - } - e.persist(); - e.stopPropagation(); - if (Math.abs(this._downX - e.clientX) > 3 || Math.abs(this._downY - e.clientY) > 3 || - e.isDefaultPrevented()) { + if (!(e instanceof Touch)) { + if (e.button === 0 && !e.ctrlKey) { + e.preventDefault(); + return; + } + e.persist(); + e?.stopPropagation(); + + if (Math.abs(this._downX - e.clientX) > 3 || Math.abs(this._downY - e.clientY) > 3 || + e.isDefaultPrevented()) { + e.preventDefault(); + return; + } e.preventDefault(); - return; } - e.preventDefault(); const cm = ContextMenu.Instance; - const subitems: ContextMenuProps[] = []; - subitems.push({ description: "Open Full Screen", event: () => CollectionDockingView.Instance && CollectionDockingView.Instance.OpenFullScreen(this, this.props.LibraryPath), icon: "desktop" }); - subitems.push({ description: "Open Tab ", event: () => this.props.addDocTab(this.props.Document, this.props.DataDoc, "inTab", this.props.LibraryPath), icon: "folder" }); - subitems.push({ description: "Open Right ", event: () => this.props.addDocTab(this.props.Document, this.props.DataDoc, "onRight", this.props.LibraryPath), icon: "caret-square-right" }); - subitems.push({ description: "Open Alias Tab ", event: () => this.props.addDocTab(Doc.MakeAlias(this.props.Document), this.props.DataDoc, "inTab"), icon: "folder" }); - subitems.push({ description: "Open Alias Right", event: () => this.props.addDocTab(Doc.MakeAlias(this.props.Document), this.props.DataDoc, "onRight"), icon: "caret-square-right" }); - subitems.push({ description: "Open Fields ", event: () => this.props.addDocTab(Docs.Create.KVPDocument(this.props.Document, { _width: 300, _height: 300 }), undefined, "onRight"), icon: "layer-group" }); - subitems.push({ description: "Open Repl", icon: "laptop-code", event: () => OverlayView.Instance.addWindow(<ScriptingRepl />, { x: 300, y: 100, width: 200, height: 200, title: "Scripting REPL" }) }); - cm.addItem({ description: "Open...", subitems: subitems, icon: "external-link-alt" }); - - - const existingOnClick = ContextMenu.Instance.findByDescription("OnClick..."); + const templateDoc = Cast(this.props.Document[StrCast(this.props.Document.layoutKey)], Doc, null); + + const customScripts = Cast(this.props.Document.contextMenuScripts, listSpec(ScriptField), []); + Cast(this.props.Document.contextMenuLabels, listSpec("string"), []).forEach((label, i) => + cm.addItem({ description: label, event: () => customScripts[i]?.script.run({ this: this.layoutDoc, self: this.rootDoc }), icon: "sticky-note" })); + this.props.contextMenuItems?.().forEach(item => + cm.addItem({ description: item.label, event: () => item.script.script.run({ this: this.layoutDoc, self: this.rootDoc }), icon: "sticky-note" })); + + + let open = cm.findByDescription("Add a Perspective..."); + const openItems: ContextMenuProps[] = open && "subitems" in open ? open.subitems : []; + openItems.push({ description: "Open Fields ", event: () => this.props.addDocTab(Docs.Create.KVPDocument(this.props.Document, { _width: 300, _height: 300 }), "onRight"), icon: "layer-group" }); + templateDoc && openItems.push({ description: "Open Template ", event: () => this.props.addDocTab(templateDoc, "onRight"), icon: "eye" }); + if (!open) { + open = { description: "Add a Perspective....", subitems: openItems, icon: "external-link-alt" }; + cm.addItem(open); + } + + let options = cm.findByDescription("Options..."); + const optionItems: ContextMenuProps[] = options && "subitems" in options ? options.subitems : []; + optionItems.push({ description: `${this.Document._chromeStatus !== "disabled" ? "Hide" : "Show"} Chrome`, event: () => this.Document._chromeStatus = (this.Document._chromeStatus !== "disabled" ? "disabled" : "enabled"), icon: "project-diagram" }); + optionItems.push({ description: `${this.Document._autoHeight ? "Variable Height" : "Auto Height"}`, event: () => this.layoutDoc._autoHeight = !this.layoutDoc._autoHeight, icon: "plus" }); + optionItems.push({ description: this.Document.lockedPosition ? "Unlock Position" : "Lock Position", event: this.toggleLockPosition, icon: BoolCast(this.Document.lockedPosition) ? "unlock" : "lock" }); + optionItems.push({ description: this.Document.lockedTransform ? "Unlock Transform" : "Lock Transform", event: this.toggleLockTransform, icon: BoolCast(this.Document.lockedTransform) ? "unlock" : "lock" }); + if (!options) { + options = { description: "Options...", subitems: optionItems, icon: "compass" }; + cm.addItem(options); + } + + cm.moveAfter(options, open); + + const existingOnClick = cm.findByDescription("OnClick..."); const onClicks: ContextMenuProps[] = existingOnClick && "subitems" in existingOnClick ? existingOnClick.subitems : []; onClicks.push({ description: "Enter Portal", event: this.makeIntoPortal, icon: "window-restore" }); - onClicks.push({ description: "Toggle Detail", event: () => this.Document.onClick = ScriptField.MakeScript("toggleDetail(this)"), icon: "window-restore" }); + onClicks.push({ description: "Toggle Detail", event: () => this.Document.onClick = ScriptField.MakeScript(`toggleDetail(self, "${this.props.Document.layoutKey}")`), icon: "window-restore" }); onClicks.push({ description: this.Document.ignoreClick ? "Select" : "Do Nothing", event: () => this.Document.ignoreClick = !this.Document.ignoreClick, icon: this.Document.ignoreClick ? "unlock" : "lock" }); - onClicks.push({ description: this.Document.isButton || this.Document.onClick ? "Remove Click Behavior" : "Follow Link", event: this.makeBtnClicked, icon: "concierge-bell" }); - onClicks.push({ description: this.props.Document.isButton ? "Remove Select Link Behavior" : "Select Link", event: this.makeSelBtnClicked, icon: "concierge-bell" }); - onClicks.push({ description: "Edit onClick Script", icon: "edit", event: (obj: any) => ScriptBox.EditButtonScript("On Button Clicked ...", this.props.Document, "onClick", obj.x, obj.y) }); + onClicks.push({ description: this.Document.isLinkButton ? "Remove Follow Behavior" : "Follow Link in Place", event: this.toggleFollowInPlace, icon: "concierge-bell" }); + onClicks.push({ description: this.Document.isLinkButton ? "Remove Follow Behavior" : "Follow Link on Right", event: this.toggleFollowOnRight, icon: "concierge-bell" }); + onClicks.push({ description: this.Document.isLinkButton || this.Document.onClick ? "Remove Click Behavior" : "Follow Link", event: this.toggleLinkButtonBehavior, icon: "concierge-bell" }); + onClicks.push({ description: "Edit onClick Script", event: () => UndoManager.RunInBatch(() => Doc.makeCustomViewClicked(this.props.Document, undefined, "onClick"), "edit onClick"), icon: "edit" }); !existingOnClick && cm.addItem({ description: "OnClick...", subitems: onClicks, icon: "hand-point-right" }); const funcs: ContextMenuProps[] = []; @@ -727,25 +737,13 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu funcs.push({ description: "Drag an Alias", icon: "edit", event: () => this.Document.dragFactory && (this.Document.onDragStart = ScriptField.MakeFunction('getAlias(this.dragFactory)')) }); funcs.push({ description: "Drag a Copy", icon: "edit", event: () => this.Document.dragFactory && (this.Document.onDragStart = ScriptField.MakeFunction('getCopy(this.dragFactory, true)')) }); funcs.push({ description: "Drag Document", icon: "edit", event: () => this.Document.onDragStart = undefined }); - ContextMenu.Instance.addItem({ description: "OnDrag...", subitems: funcs, icon: "asterisk" }); + cm.addItem({ description: "OnDrag...", subitems: funcs, icon: "asterisk" }); } - const existing = ContextMenu.Instance.findByDescription("Layout..."); - const layoutItems: ContextMenuProps[] = existing && "subitems" in existing ? existing.subitems : []; - layoutItems.push({ description: this.Document.isBackground ? "As Foreground" : "As Background", event: this.makeBackground, icon: this.Document.lockedPosition ? "unlock" : "lock" }); - layoutItems.push({ description: "Make View of Metadata Field", event: () => Doc.MakeMetadataFieldTemplate(this.props.Document, this.props.DataDoc), icon: "concierge-bell" }); - - layoutItems.push({ description: `${this.Document._chromeStatus !== "disabled" ? "Hide" : "Show"} Chrome`, event: () => this.Document._chromeStatus = (this.Document._chromeStatus !== "disabled" ? "disabled" : "enabled"), icon: "project-diagram" }); - layoutItems.push({ description: `${this.Document._autoHeight ? "Variable Height" : "Auto Height"}`, event: () => this.layoutDoc._autoHeight = !this.layoutDoc._autoHeight, icon: "plus" }); - layoutItems.push({ description: this.Document.ignoreAspect || !this.Document._nativeWidth || !this.Document._nativeHeight ? "Freeze" : "Unfreeze", event: this.freezeNativeDimensions, icon: "snowflake" }); - layoutItems.push({ description: this.Document.lockedPosition ? "Unlock Position" : "Lock Position", event: this.toggleLockPosition, icon: BoolCast(this.Document.lockedPosition) ? "unlock" : "lock" }); - layoutItems.push({ description: this.Document.lockedTransform ? "Unlock Transform" : "Lock Transform", event: this.toggleLockTransform, icon: BoolCast(this.Document.lockedTransform) ? "unlock" : "lock" }); - layoutItems.push({ description: "Center View", event: () => this.props.focus(this.props.Document, false), icon: "crosshairs" }); - layoutItems.push({ description: "Zoom to Document", event: () => this.props.focus(this.props.Document, true), icon: "search" }); - !existing && cm.addItem({ description: "Layout...", subitems: layoutItems, icon: "compass" }); - - const more = ContextMenu.Instance.findByDescription("More..."); + const more = cm.findByDescription("More..."); const moreItems: ContextMenuProps[] = more && "subitems" in more ? more.subitems : []; + moreItems.push({ description: "Make View of Metadata Field", event: () => Doc.MakeMetadataFieldTemplate(this.props.Document, this.props.DataDoc), icon: "concierge-bell" }); + moreItems.push({ description: !this.Document._nativeWidth || !this.Document._nativeHeight ? "Freeze" : "Unfreeze", event: this.toggleNativeDimensions, icon: "snowflake" }); if (!ClientUtils.RELEASE) { // let copies: ContextMenuProps[] = []; @@ -771,9 +769,36 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu // a.download = `DocExport-${this.props.Document[Id]}.zip`; // a.click(); }); + const recommender_subitems: ContextMenuProps[] = []; + + recommender_subitems.push({ + description: "Internal recommendations", + event: () => this.recommender(), + icon: "brain" + }); + + const ext_recommender_subitems: ContextMenuProps[] = []; + + ext_recommender_subitems.push({ + description: "arXiv", + event: () => this.externalRecommendation("arxiv"), + icon: "brain" + }); + ext_recommender_subitems.push({ + description: "Bing", + event: () => this.externalRecommendation("bing"), + icon: "brain" + }); + + recommender_subitems.push({ + description: "External recommendations", + subitems: ext_recommender_subitems, + icon: "brain" + }); - moreItems.push({ description: "Publish", event: () => DocUtils.Publish(this.props.Document, this.Document.title || "", this.props.addDocument, this.props.removeDocument), icon: "file" }); moreItems.push({ description: "Delete", event: this.deleteClicked, icon: "trash" }); + moreItems.push({ description: "Recommender System", subitems: recommender_subitems, icon: "brain" }); + moreItems.push({ description: "Publish", event: () => DocUtils.Publish(this.props.Document, this.Document.title || "", this.props.addDocument, this.props.removeDocument), icon: "file" }); moreItems.push({ description: "Undo Debug Test", event: () => UndoManager.TraceOpenBatches(), icon: "exclamation" }); !more && cm.addItem({ description: "More...", subitems: moreItems, icon: "hand-point-right" }); runInAction(() => { @@ -807,7 +832,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu icon: "external-link-alt" }); - if (!this.topMost) { + if (!this.topMost && !(e instanceof Touch)) { // DocumentViews should stop propagation of this event e.stopPropagation(); } @@ -816,8 +841,7 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu SelectionManager.SelectDoc(this, false); } }); - const path = this.props.LibraryPath.reduce((p: string, d: Doc) => p + "/" + (Doc.AreProtosEqual(d, (Doc.UserDoc().LibraryBtn as Doc).sourcePanel as Doc) ? "" : d.title), ""); - cm.addItem({ description: "Pin to Presentation", event: () => this.props.pinToPres(this.props.Document), icon: "map-pin" }); + const path = this.props.LibraryPath.reduce((p: string, d: Doc) => p + "/" + (Doc.AreProtosEqual(d, (Doc.UserDoc()["tabs-button-library"] as Doc).sourcePanel as Doc) ? "" : d.title), ""); cm.addItem({ description: `path: ${path}`, event: () => { this.props.LibraryPath.map(lp => Doc.GetProto(lp).treeViewOpen = lp.treeViewOpen = true); @@ -826,6 +850,95 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu }); } + recommender = async () => { + if (!ClientRecommender.Instance) new ClientRecommender({ title: "Client Recommender" }); + const documents: Doc[] = []; + const allDocs = await SearchUtil.GetAllDocs(); + // allDocs.forEach(doc => console.log(doc.title)); + // clears internal representation of documents as vectors + ClientRecommender.Instance.reset_docs(); + //ClientRecommender.Instance.arxivrequest("electrons"); + await Promise.all(allDocs.map((doc: Doc) => { + let isMainDoc: boolean = false; + const dataDoc = Doc.GetProto(doc); + if (doc.type === DocumentType.RTF) { + if (dataDoc === Doc.GetProto(this.props.Document)) { + isMainDoc = true; + } + if (!documents.includes(dataDoc)) { + documents.push(dataDoc); + const extdoc = doc.data_ext as Doc; + return ClientRecommender.Instance.extractText(doc, extdoc ? extdoc : doc, true, "", isMainDoc); + } + } + if (doc.type === DocumentType.IMG) { + if (dataDoc === Doc.GetProto(this.props.Document)) { + isMainDoc = true; + } + if (!documents.includes(dataDoc)) { + documents.push(dataDoc); + const extdoc = doc.data_ext as Doc; + return ClientRecommender.Instance.extractText(doc, extdoc ? extdoc : doc, true, "", isMainDoc, true); + } + } + })); + const doclist = ClientRecommender.Instance.computeSimilarities("cosine"); + const recDocs: { preview: Doc, score: number }[] = []; + // tslint:disable-next-line: prefer-for-of + for (let i = 0; i < doclist.length; i++) { + recDocs.push({ preview: doclist[i].actualDoc, score: doclist[i].score }); + } + + const data = recDocs.map(unit => { + unit.preview.score = unit.score; + return unit.preview; + }); + + console.log(recDocs.map(doc => doc.score)); + + const title = `Showing ${data.length} recommendations for "${StrCast(this.props.Document.title)}"`; + const recommendations = Docs.Create.RecommendationsDocument(data, { title }); + recommendations.documentIconHeight = 150; + recommendations.sourceDoc = this.props.Document; + recommendations.sourceDocContext = this.props.ContainingCollectionView!.props.Document; + CollectionDockingView.AddRightSplit(recommendations, undefined); + + // RecommendationsBox.Instance.displayRecommendations(e.pageX + 100, e.pageY); + } + + @action + externalRecommendation = async (api: string) => { + if (!ClientRecommender.Instance) new ClientRecommender({ title: "Client Recommender" }); + ClientRecommender.Instance.reset_docs(); + const doc = Doc.GetDataDoc(this.props.Document); + const extdoc = doc.data_ext as Doc; + const recs_and_kps = await ClientRecommender.Instance.extractText(doc, extdoc ? extdoc : doc, false, api); + let recs: any; + let kps: any; + if (recs_and_kps) { + recs = recs_and_kps.recs; + kps = recs_and_kps.keyterms; + } + else { + console.log("recommender system failed :("); + return; + } + console.log("ibm keyterms: ", kps.toString()); + const headers = [new SchemaHeaderField("title"), new SchemaHeaderField("href")]; + const bodies: Doc[] = []; + const titles = recs.title_vals; + const urls = recs.url_vals; + for (let i = 0; i < 5; i++) { + const body = Docs.Create.FreeformDocument([], { title: titles[i] }); + body.href = urls[i]; + bodies.push(body); + } + CollectionDockingView.AddRightSplit(Docs.Create.SchemaDocument(headers, bodies, { title: `Showing External Recommendations for "${StrCast(doc.title)}"` }), undefined); + this._showKPQuery = true; + this._queries = kps.toString(); + } + + // does Document set a layout prop // does Document set a layout prop setsLayoutProp = (prop: string) => this.props.Document[prop] !== this.props.Document["default" + prop[0].toUpperCase() + prop.slice(1)] && this.props.Document["default" + prop[0].toUpperCase() + prop.slice(1)]; // get the a layout prop by first choosing the prop from Document, then falling back to the layout doc otherwise. @@ -836,85 +949,129 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu select = (ctrlPressed: boolean) => { SelectionManager.SelectDoc(this, ctrlPressed); }; chromeHeight = () => { - const showTitle = StrCast(this.layoutDoc.showTitle); - const showTitleHover = StrCast(this.layoutDoc.showTitleHover); - return (showTitle && !showTitleHover ? 0 : 0) + 1; + const showTitle = StrCast(this.layoutDoc._showTitle); + const showTextTitle = showTitle && (StrCast(this.layoutDoc.layout).indexOf("PresBox") !== -1 || StrCast(this.layoutDoc.layout).indexOf("FormattedTextBox") !== -1) ? showTitle : undefined; + return showTextTitle ? 25 : 1; } @computed get finalLayoutKey() { - const { layoutKey } = this.props; - if (typeof layoutKey === "string") { - return layoutKey; + if (typeof this.props.layoutKey === "string") { + return this.props.layoutKey; } const fallback = Cast(this.props.Document.layoutKey, "string"); return typeof fallback === "string" ? fallback : "layout"; } + rootSelected = (outsideReaction?: boolean) => { + return this.isSelected(outsideReaction) || (this.props.Document.rootDocument && this.props.rootSelected?.(outsideReaction)) || false; + } childScaling = () => (this.layoutDoc._fitWidth ? this.props.PanelWidth() / this.nativeWidth : this.props.ContentScaling()); + panelWidth = () => this.props.PanelWidth(); + panelHeight = () => this.props.PanelHeight(); + screenToLocalTransform = () => this.props.ScreenToLocalTransform(); @computed get contents() { TraceMobx(); - return (<DocumentContentsView ContainingCollectionView={this.props.ContainingCollectionView} - ContainingCollectionDoc={this.props.ContainingCollectionDoc} - Document={this.props.Document} - DataDoc={this.props.DataDoc} - fitToBox={this.props.fitToBox} - LibraryPath={this.props.LibraryPath} - addDocument={this.props.addDocument} - removeDocument={this.props.removeDocument} - moveDocument={this.props.moveDocument} - ScreenToLocalTransform={this.props.ScreenToLocalTransform} - renderDepth={this.props.renderDepth} - ContentScaling={this.childScaling} - PanelWidth={this.props.PanelWidth} - PanelHeight={this.props.PanelHeight} - focus={this.props.focus} - parentActive={this.props.parentActive} - whenActiveChanged={this.props.whenActiveChanged} - bringToFront={this.props.bringToFront} - addDocTab={this.props.addDocTab} - pinToPres={this.props.pinToPres} - zoomToScale={this.props.zoomToScale} - backgroundColor={this.props.backgroundColor} - animateBetweenIcon={this.props.animateBetweenIcon} - getScale={this.props.getScale} - ChromeHeight={this.chromeHeight} - isSelected={this.isSelected} - select={this.select} - onClick={this.onClickHandler} - layoutKey={this.finalLayoutKey} />); + return (<> + <DocumentContentsView key={1} ContainingCollectionView={this.props.ContainingCollectionView} + ContainingCollectionDoc={this.props.ContainingCollectionDoc} + NativeWidth={this.NativeWidth} + NativeHeight={this.NativeHeight} + Document={this.props.Document} + DataDoc={this.props.DataDoc} + LayoutDoc={this.props.LayoutDoc} + makeLink={this.makeLink} + rootSelected={this.rootSelected} + dontRegisterView={this.props.dontRegisterView} + fitToBox={this.props.fitToBox} + LibraryPath={this.props.LibraryPath} + addDocument={this.props.addDocument} + removeDocument={this.props.removeDocument} + moveDocument={this.props.moveDocument} + ScreenToLocalTransform={this.screenToLocalTransform} + renderDepth={this.props.renderDepth} + PanelWidth={this.panelWidth} + PanelHeight={this.panelHeight} + focus={this.props.focus} + parentActive={this.props.parentActive} + whenActiveChanged={this.props.whenActiveChanged} + bringToFront={this.props.bringToFront} + addDocTab={this.props.addDocTab} + pinToPres={this.props.pinToPres} + backgroundColor={this.props.backgroundColor} + ContentScaling={this.childScaling} + ChromeHeight={this.chromeHeight} + isSelected={this.isSelected} + select={this.select} + onClick={this.onClickHandler} + layoutKey={this.finalLayoutKey} /> + {this.anchors} + </> + ); } linkEndpoint = (linkDoc: Doc) => Doc.LinkEndpoint(linkDoc, this.props.Document); - // used to decide whether a link document should be created or not. + // used to decide whether a link anchor view should be created or not. // if it's a tempoarl link (currently just for Audio), then the audioBox will display the anchor and we don't want to display it here. // would be good to generalize this some way. isNonTemporalLink = (linkDoc: Doc) => { const anchor = Cast(Doc.AreProtosEqual(this.props.Document, Cast(linkDoc.anchor1, Doc) as Doc) ? linkDoc.anchor1 : linkDoc.anchor2, Doc) as Doc; - const ept = Doc.AreProtosEqual(this.props.Document, Cast(linkDoc.anchor1, Doc) as Doc) ? linkDoc.anchor1Timecode : linkDoc.anchor2Timecode; + const ept = Doc.AreProtosEqual(this.props.Document, Cast(linkDoc.anchor1, Doc) as Doc) ? linkDoc.anchor1_timecode : linkDoc.anchor2_timecode; return anchor.type === DocumentType.AUDIO && NumCast(ept) ? false : true; } + + @observable _link: Opt<Doc>; // see DocumentButtonBar for explanation of how this works + makeLink = () => this._link; // pass the link placeholde to child views so they can react to make a specialized anchor. This is essentially a function call to the descendants since the value of the _link variable will immediately get set back to undefined. + + @undoBatch + hideLinkAnchor = (doc: Doc) => doc.hidden = true + anchorPanelWidth = () => this.props.PanelWidth() || 1; + anchorPanelHeight = () => this.props.PanelHeight() || 1; + @computed get anchors() { + TraceMobx(); + return this.layoutDoc.presBox ? (null) : DocListCast(this.Document.links).filter(d => !d.hidden && this.isNonTemporalLink).map((d, i) => + <DocumentView {...this.props} key={i + 1} + Document={d} + ContainingCollectionView={this.props.ContainingCollectionView} + ContainingCollectionDoc={this.props.Document} // bcz: hack this.props.Document is not a collection Need a better prop for passing the containing document to the LinkAnchorBox + PanelWidth={this.anchorPanelWidth} + PanelHeight={this.anchorPanelHeight} + layoutKey={this.linkEndpoint(d)} + ContentScaling={returnOne} + backgroundColor={returnTransparent} + removeDocument={this.hideLinkAnchor} + pointerEvents={false} + LayoutDoc={undefined} + />); + } @computed get innards() { TraceMobx(); - const showTitle = StrCast(this.getLayoutPropStr("showTitle")); - const showTitleHover = StrCast(this.getLayoutPropStr("showTitleHover")); - const showCaption = this.getLayoutPropStr("showCaption"); + if (!this.props.PanelWidth()) { // this happens when the document is a tree view label + return <div className="documentView-linkAnchorBoxAnchor" > + {StrCast(this.props.Document.title)} + {this.anchors} + </div>; + } + const showTitle = StrCast(this.layoutDoc._showTitle); + const showTitleHover = StrCast(this.layoutDoc._showTitleHover); + const showCaption = StrCast(this.layoutDoc._showCaption); const showTextTitle = showTitle && (StrCast(this.layoutDoc.layout).indexOf("PresBox") !== -1 || StrCast(this.layoutDoc.layout).indexOf("FormattedTextBox") !== -1) ? showTitle : undefined; - const searchHighlight = (!this.Document.searchFields ? (null) : - <div className="documentView-searchHighlight"> - {this.Document.searchFields} - </div>); const captionView = (!showCaption ? (null) : - <div className="documentView-captionWrapper"> - <FormattedTextBox {...this.props} - onClick={this.onClickHandler} DataDoc={this.props.DataDoc} active={returnTrue} - isSelected={this.isSelected} focus={emptyFunction} select={this.select} - hideOnLeave={true} fieldKey={showCaption} - /> + <div className="documentView-captionWrapper" style={{ backgroundColor: StrCast(this.layoutDoc["caption-backgroundColor"]), color: StrCast(this.layoutDoc["caption-color"]) }}> + <DocumentContentsView {...OmitKeys(this.props, ['children']).omit} + hideOnLeave={true} + forceLayout={"FormattedTextBox"} + forceFieldKey={showCaption} + ContentScaling={this.childScaling} + ChromeHeight={this.chromeHeight} + isSelected={this.isSelected} + select={this.select} + onClick={this.onClickHandler} + layoutKey={this.finalLayoutKey} /> </div>); const titleView = (!showTitle ? (null) : - <div className={`documentView-titleWrapper${showTitleHover ? "-hover" : ""}`} style={{ + <div className={`documentView-titleWrapper${showTitleHover ? "-hover" : ""}`} key="title" style={{ position: showTextTitle ? "relative" : "absolute", - pointerEvents: SelectionManager.GetIsDragging() || this.onClickHandler || this.Document.ignoreClick ? "none" : "all", + pointerEvents: this.onClickHandler || this.Document.ignoreClick ? "none" : undefined, }}> <EditableView ref={this._titleRef} contents={(this.props.DataDoc || this.props.Document)[showTitle]?.toString()} @@ -923,69 +1080,98 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu SetValue={undoBatch((value: string) => (Doc.GetProto(this.props.DataDoc || this.props.Document)[showTitle] = value) ? true : true)} /> </div>); - return <> - {this.Document.links && DocListCast(this.Document.links).filter(d => !d.hidden).filter(this.isNonTemporalLink).map((d, i) => - <div className="documentView-docuLinkWrapper" key={`${d[Id]}`}> - <DocumentView {...this.props} ContentScaling={returnOne} Document={d} layoutKey={this.linkEndpoint(d)} backgroundColor={returnTransparent} removeDocument={undoBatch(doc => doc.hidden = true)} /> - </div>)} - {!showTitle && !showCaption ? - this.Document.searchFields ? - (<div className="documentView-searchWrapper"> - {this.contents} - {searchHighlight} - </div>) - : - this.contents - : - <div className="documentView-styleWrapper" > - <div className="documentView-styleContentWrapper" style={{ height: showTextTitle ? "calc(100% - 29px)" : "100%", top: showTextTitle ? "29px" : undefined }}> - {this.contents} - </div> - {titleView} - {captionView} - {searchHighlight} - </div> - } - </>; + return !showTitle && !showCaption ? + this.contents : + <div className="documentView-styleWrapper" > + {this.Document.type !== DocumentType.RTF ? <> {this.contents} {titleView} </> : <> {titleView} {this.contents} </>} + {captionView} + </div>; } @computed get ignorePointerEvents() { - return (this.Document.isBackground && !this.isSelected()) || (this.Document.type === DocumentType.INK && InkingControl.Instance.selectedTool !== InkTool.None); + return this.props.pointerEvents === false || + (this.Document.isBackground && !this.isSelected() && !SelectionManager.GetIsDragging()) || + (this.Document.type === DocumentType.INK && InkingControl.Instance.selectedTool !== InkTool.None); + } + @undoBatch + @action + setCustomView = (custom: boolean, layout: string): void => { + Doc.setNativeView(this.props.Document); + if (custom) { + Doc.makeCustomViewClicked(this.props.Document, Docs.Create.StackingDocument, layout, undefined); + } } + @observable _animate = 0; + switchViews = action((custom: boolean, view: string) => { + SelectionManager.SetIsDragging(true); + this._animate = 0.1; + setTimeout(action(() => { + this.setCustomView(custom, view); + this._animate = 1; + setTimeout(action(() => { + this._animate = 0; + SelectionManager.SetIsDragging(false); + }), 400); + }), 400); + }); render() { if (!(this.props.Document instanceof Doc)) return (null); - const colorSet = this.setsLayoutProp("backgroundColor"); - const clusterCol = this.props.ContainingCollectionDoc && this.props.ContainingCollectionDoc.clusterOverridesDefaultBackground; - const backgroundColor = (clusterCol && !colorSet) ? - this.props.backgroundColor(this.Document) || StrCast(this.layoutDoc.backgroundColor) : - StrCast(this.layoutDoc.backgroundColor) || this.props.backgroundColor(this.Document); - + const backgroundColor = StrCast(this.layoutDoc._backgroundColor) || StrCast(this.layoutDoc.backgroundColor) || StrCast(this.Document.backgroundColor) || this.props.backgroundColor?.(this.Document); + const finalColor = this.layoutDoc.type === DocumentType.FONTICON || this.layoutDoc._viewType === CollectionViewType.Linear ? undefined : backgroundColor; const fullDegree = Doc.isBrushedHighlightedDegree(this.props.Document); - const borderRounding = this.getLayoutPropStr("borderRounding"); + const borderRounding = this.layoutDoc.borderRounding; const localScale = fullDegree; - const highlightColors = ["transparent", "maroon", "maroon", "yellow", "magenta", "cyan", "orange"]; + const highlightColors = Cast(Doc.UserDoc().activeWorkspace, Doc, null)?.darkScheme ? + ["transparent", "#65350c", "#65350c", "yellow", "magenta", "cyan", "orange"] : + ["transparent", "maroon", "maroon", "yellow", "magenta", "cyan", "orange"]; const highlightStyles = ["solid", "dashed", "solid", "solid", "solid", "solid", "solid"]; let highlighting = fullDegree && this.layoutDoc.type !== DocumentType.FONTICON && this.layoutDoc._viewType !== CollectionViewType.Linear; highlighting = highlighting && this.props.focus !== emptyFunction; // bcz: hack to turn off highlighting onsidebar panel documents. need to flag a document as not highlightable in a more direct way return <div className={`documentView-node${this.topMost ? "-topmost" : ""}`} ref={this._mainCont} onKeyDown={this.onKeyDown} onContextMenu={this.onContextMenu} onPointerDown={this.onPointerDown} onClick={this.onClick} - onPointerEnter={e => Doc.BrushDoc(this.props.Document)} onPointerLeave={e => Doc.UnBrushDoc(this.props.Document)} + // onPointerEnter={e => Doc.BrushDoc(this.props.Document)} + // onPointerLeave={e => Doc.BrushDoc(this.props.Document)} + onPointerEnter={action(() => Doc.BrushDoc(this.props.Document))} + onPointerLeave={action((e: React.PointerEvent<HTMLDivElement>) => { + let entered = false; + const target = document.elementFromPoint(e.nativeEvent.x, e.nativeEvent.y); + for (let child: any = target; child; child = child?.parentElement) { + if (child === this.ContentDiv) { + entered = true; + } + } + !entered && Doc.UnBrushDoc(this.props.Document); + })} style={{ - transition: this.Document.isAnimating ? ".5s linear" : StrCast(this.Document.transition), - pointerEvents: this.ignorePointerEvents ? "none" : "all", - color: StrCast(this.Document.color), + transformOrigin: this._animate ? "center center" : undefined, + transform: this._animate ? `scale(${this._animate})` : undefined, + transition: !this._animate ? StrCast(this.Document.transition) : this._animate < 1 ? "transform 0.5s ease-in" : "transform 0.5s ease-out", + pointerEvents: this.ignorePointerEvents ? "none" : undefined, + color: StrCast(this.layoutDoc.color, "inherit"), outline: highlighting && !borderRounding ? `${highlightColors[fullDegree]} ${highlightStyles[fullDegree]} ${localScale}px` : "solid 0px", border: highlighting && borderRounding ? `${highlightStyles[fullDegree]} ${highlightColors[fullDegree]} ${localScale}px` : undefined, boxShadow: this.props.Document.isTemplateForField ? "black 0.2vw 0.2vw 0.8vw" : undefined, - background: this.layoutDoc.type === DocumentType.FONTICON || this.layoutDoc._viewType === CollectionViewType.Linear ? undefined : backgroundColor, - width: "100%", - height: "100%", - opacity: this.Document.opacity + background: finalColor, + opacity: this.Document.opacity, + fontFamily: StrCast(this.Document._fontFamily, "inherit"), + fontSize: Cast(this.Document._fontSize, "number", null) }}> - {this.innards} + {this.onClickHandler && this.props.ContainingCollectionView?.props.Document._viewType === CollectionViewType.Time ? <> + {this.innards} + <div className="documentView-contentBlocker" /> + </> : + this.innards} + {(this.Document.isBackground !== undefined || this.isSelected(false)) && this.props.renderDepth > 0 && this.props.PanelWidth() > 0 ? + <div className="documentView-lock" onClick={() => this.toggleBackground(true)}> <FontAwesomeIcon icon={this.Document.isBackground ? "unlock" : "lock"} size="lg" /> </div> + : (null)} </div>; + { this._showKPQuery ? <KeyphraseQueryView keyphrases={this._queries}></KeyphraseQueryView> : undefined; } } } -Scripting.addGlobal(function toggleDetail(doc: any) { doc.layoutKey = StrCast(doc.layoutKey, "layout") === "layout" ? "layout_custom" : "layout"; });
\ No newline at end of file +Scripting.addGlobal(function toggleDetail(doc: any, layoutKey: string, otherKey: string = "layout") { + const dv = DocumentManager.Instance.getDocumentView(doc); + if (dv?.props.Document.layoutKey === layoutKey) dv?.switchViews(otherKey !== "layout", otherKey.replace("layout_", "")); + else dv?.switchViews(true, layoutKey.replace("layout_", "")); +});
\ No newline at end of file diff --git a/src/client/views/nodes/FieldView.tsx b/src/client/views/nodes/FieldView.tsx index dbbb76f83..a3790d38b 100644 --- a/src/client/views/nodes/FieldView.tsx +++ b/src/client/views/nodes/FieldView.tsx @@ -2,20 +2,15 @@ import React = require("react"); import { computed } from "mobx"; import { observer } from "mobx-react"; import { DateField } from "../../../new_fields/DateField"; -import { Doc, FieldResult, Opt } from "../../../new_fields/Doc"; -import { IconField } from "../../../new_fields/IconField"; +import { Doc, FieldResult, Opt, Field } from "../../../new_fields/Doc"; import { List } from "../../../new_fields/List"; -import { RichTextField } from "../../../new_fields/RichTextField"; -import { AudioField, ImageField, VideoField } from "../../../new_fields/URLField"; +import { ScriptField } from "../../../new_fields/ScriptField"; +import { AudioField, VideoField } from "../../../new_fields/URLField"; import { Transform } from "../../util/Transform"; import { CollectionView } from "../collections/CollectionView"; import { AudioBox } from "./AudioBox"; -import { FormattedTextBox } from "./FormattedTextBox"; -import { IconBox } from "./IconBox"; -import { ImageBox } from "./ImageBox"; -import { PDFBox } from "./PDFBox"; import { VideoBox } from "./VideoBox"; -import { ScriptField } from "../../../new_fields/ScriptField"; +import { dropActionType } from "../../util/DragManager"; // // these properties get assigned through the render() method of the DocumentView when it creates this node. @@ -31,23 +26,31 @@ export interface FieldViewProps { DataDoc?: Doc; LibraryPath: Doc[]; onClick?: ScriptField; + dropAction: dropActionType; isSelected: (outsideReaction?: boolean) => boolean; select: (isCtrlPressed: boolean) => void; + rootSelected: (outsideReaction?: boolean) => boolean; renderDepth: number; addDocument?: (document: Doc) => boolean; - addDocTab: (document: Doc, dataDoc: Doc | undefined, where: string) => boolean; + addDocTab: (document: Doc, where: string) => boolean; pinToPres: (document: Doc) => void; removeDocument?: (document: Doc) => boolean; moveDocument?: (document: Doc, targetCollection: Doc | undefined, addDocument: (document: Doc) => boolean) => boolean; + backgroundColor?: (document: Doc) => string | undefined; ScreenToLocalTransform: () => Transform; + bringToFront: (doc: Doc, sendToBack?: boolean) => void; active: (outsideReaction?: boolean) => boolean; whenActiveChanged: (isActive: boolean) => void; + dontRegisterView?: boolean; focus: (doc: Doc) => void; PanelWidth: () => number; PanelHeight: () => number; + NativeHeight: () => number; + NativeWidth: () => number; setVideoBox?: (player: VideoBox) => void; ContentScaling: () => number; ChromeHeight?: () => number; + childLayoutTemplate?: () => Opt<Doc>; } @observer @@ -78,9 +81,6 @@ export class FieldView extends React.Component<FieldViewProps> { // else if (field instaceof PresBox) { // return <PresBox {...this.props} />; // } - else if (field instanceof IconField) { - return <IconBox {...this.props} />; - } else if (field instanceof VideoField) { return <VideoBox {...this.props} />; } @@ -114,16 +114,14 @@ export class FieldView extends React.Component<FieldViewProps> { // ); } else if (field instanceof List) { - return (<div> - {field.map(f => f instanceof Doc ? f.title : (f && f.toString && f.toString())).join(", ")} - </div>); + return <div> {field.map(f => Field.toString(f)).join(", ")} </div>; } // bcz: this belongs here, but it doesn't render well so taking it out for now // else if (field instanceof HtmlField) { // return <WebBox {...this.props} /> // } else if (!(field instanceof Promise)) { - return <p>{field.toString()}</p>; + return <p>{Field.toString(field)}</p>; } else { return <p> {"Waiting for server..."} </p>; diff --git a/src/client/views/nodes/FontIconBox.scss b/src/client/views/nodes/FontIconBox.scss index f0fe7a54e..68b00a5be 100644 --- a/src/client/views/nodes/FontIconBox.scss +++ b/src/client/views/nodes/FontIconBox.scss @@ -8,6 +8,18 @@ border-radius: 100%; transform-origin: top left; + .fontIconBox-label { + background: gray; + color:white; + margin-left: -10px; + border-radius: 8px; + width:100%; + position: absolute; + text-align: center; + font-size: 8px; + margin-top:4px; + } + svg { width: 95% !important; height: 95%; diff --git a/src/client/views/nodes/FontIconBox.tsx b/src/client/views/nodes/FontIconBox.tsx index a191ac4f4..c6ea6a882 100644 --- a/src/client/views/nodes/FontIconBox.tsx +++ b/src/client/views/nodes/FontIconBox.tsx @@ -36,7 +36,7 @@ export class FontIconBox extends DocComponent<FieldViewProps, FontIconDocument>( showTemplate = (): void => { const dragFactory = Cast(this.props.Document.dragFactory, Doc, null); - dragFactory && this.props.addDocTab(dragFactory, undefined, "onRight"); + dragFactory && this.props.addDocTab(dragFactory, "onRight"); } specificContextMenu = (): void => { @@ -56,7 +56,8 @@ export class FontIconBox extends DocComponent<FieldViewProps, FontIconDocument>( background: StrCast(referenceLayout.backgroundColor), boxShadow: this.props.Document.ischecked ? `4px 4px 12px black` : undefined }}> - <FontAwesomeIcon className="fontIconBox-icon" icon={this.Document.icon as any} color={this._foregroundColor} size="sm" /> + <FontAwesomeIcon className="fontIconBox-icon" icon={this.dataDoc.icon as any} color={this._foregroundColor} size="sm" /> + {!this.rootDoc.label ? (null) : <div className="fontIconBox-label"> {StrCast(this.rootDoc.label).substring(0, 5)} </div>} </button>; } }
\ No newline at end of file diff --git a/src/client/views/nodes/IconBox.scss b/src/client/views/nodes/IconBox.scss deleted file mode 100644 index 488681027..000000000 --- a/src/client/views/nodes/IconBox.scss +++ /dev/null @@ -1,23 +0,0 @@ - -@import "../globalCssVariables"; -.iconBox-container { - position: inherit; - left:0; - top:0; - height: auto; - width: max-content; - // overflow: hidden; - pointer-events: all; - svg { - width: $MINIMIZED_ICON_SIZE !important; - height: $MINIMIZED_ICON_SIZE !important; - height: auto; - background: white; - } - .iconBox-label { - position: absolute; - width:max-content; - font-size: 14px; - margin-top: 3px; - } -}
\ No newline at end of file diff --git a/src/client/views/nodes/IconBox.tsx b/src/client/views/nodes/IconBox.tsx deleted file mode 100644 index 172338eb6..000000000 --- a/src/client/views/nodes/IconBox.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React = require("react"); -import { library } from '@fortawesome/fontawesome-svg-core'; -import { faCaretUp, faFilePdf, faFilm, faImage, faObjectGroup, faStickyNote, faTag, faTextHeight } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { computed, observable, runInAction } from "mobx"; -import { observer } from "mobx-react"; -import { FieldView, FieldViewProps } from './FieldView'; -import "./IconBox.scss"; -import { Cast, StrCast, BoolCast } from "../../../new_fields/Types"; -import { Doc, DocListCast } from "../../../new_fields/Doc"; -import { IconField } from "../../../new_fields/IconField"; -import { ContextMenu } from "../ContextMenu"; -import Measure from "react-measure"; -import { MINIMIZED_ICON_SIZE } from "../../views/globalCssVariables.scss"; -import { Scripting } from "../../util/Scripting"; -import { ComputedField } from "../../../new_fields/ScriptField"; - - -library.add(faCaretUp); -library.add(faObjectGroup); -library.add(faStickyNote); -library.add(faFilePdf); -library.add(faFilm, faTag, faTextHeight); - -@observer -export class IconBox extends React.Component<FieldViewProps> { - public static LayoutString(fieldKey: string) { return FieldView.LayoutString(IconBox, fieldKey); } - - @observable _panelWidth: number = 0; - @observable _panelHeight: number = 0; - @computed get layout(): string { const field = Cast(this.props.Document[this.props.fieldKey], IconField); return field ? field.icon : "<p>Error loading icon data</p>"; } - @computed get minimizedIcon() { return IconBox.DocumentIcon(this.layout); } - - public static summaryTitleScript(inputDoc: Doc) { - const sumDoc = Cast(inputDoc.summaryDoc, Doc) as Doc; - if (sumDoc && StrCast(sumDoc.title).startsWith("-")) { - return sumDoc.title + ".expanded"; - } - return "???"; - } - public static titleScript(inputDoc: Doc) { - const maxDoc = DocListCast(inputDoc.maximizedDocs); - if (maxDoc.length === 1) { - return maxDoc[0].title + ".icon"; - } - return maxDoc.length > 1 ? "-multiple-.icon" : "???"; - } - - public static AutomaticTitle(doc: Doc) { - Doc.GetProto(doc).title = ComputedField.MakeFunction('iconTitle(this);'); - } - - public static DocumentIcon(layout: string) { - const button = layout.indexOf("PDFBox") !== -1 ? faFilePdf : - layout.indexOf("ImageBox") !== -1 ? faImage : - layout.indexOf("Formatted") !== -1 ? faStickyNote : - layout.indexOf("Video") !== -1 ? faFilm : - layout.indexOf("Collection") !== -1 ? faObjectGroup : - faCaretUp; - return <FontAwesomeIcon icon={button} className="documentView-minimizedIcon" />; - } - - setLabelField = (): void => { - this.props.Document.hideLabel = !this.props.Document.hideLabel; - } - - specificContextMenu = (): void => { - const cm = ContextMenu.Instance; - cm.addItem({ description: this.props.Document.hideLabel ? "Show label with icon" : "Remove label from icon", event: this.setLabelField, icon: "tag" }); - if (!this.props.Document.hideLabel) { - cm.addItem({ description: "Use Target Title", event: () => IconBox.AutomaticTitle(this.props.Document), icon: "text-height" }); - } - } - render() { - const label = this.props.Document.hideLabel ? "" : this.props.Document.title; - return ( - <div className="iconBox-container" onContextMenu={this.specificContextMenu}> - {this.minimizedIcon} - <Measure offset onResize={(r) => runInAction(() => { - if (r.offset!.width || this.props.Document.hideLabel) { - this.props.Document.iconWidth = (r.offset!.width + Number(MINIMIZED_ICON_SIZE)); - if (this.props.Document._height === Number(MINIMIZED_ICON_SIZE)) this.props.Document._width = this.props.Document.iconWidth; - } - })}> - {({ measureRef }) => - <span ref={measureRef} className="iconBox-label">{label}</span> - } - </Measure> - </div>); - } -} -Scripting.addGlobal(function iconTitle(doc: any) { return IconBox.titleScript(doc); }); -Scripting.addGlobal(function summaryTitle(doc: any) { return IconBox.summaryTitleScript(doc); });
\ No newline at end of file diff --git a/src/client/views/nodes/ImageBox.scss b/src/client/views/nodes/ImageBox.scss index 7bbf4a368..15148d01d 100644 --- a/src/client/views/nodes/ImageBox.scss +++ b/src/client/views/nodes/ImageBox.scss @@ -1,6 +1,4 @@ -.imageBox, -.imageBox-dragging { - pointer-events: all; +.imageBox { border-radius: inherit; width: 100%; height: 100%; @@ -12,12 +10,6 @@ } } -.imageBox-dragging { - .imageBox-fader { - pointer-events: none; - } -} - #upload-icon { position: absolute; bottom: 0; diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx index 896ac1685..08917d281 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -1,41 +1,40 @@ import { library } from '@fortawesome/fontawesome-svg-core'; import { faEye } from '@fortawesome/free-regular-svg-icons'; -import { faAsterisk, faFileAudio, faImage, faPaintBrush } from '@fortawesome/free-solid-svg-icons'; +import { faAsterisk, faBrain, faFileAudio, faImage, faPaintBrush } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { action, computed, observable, runInAction, trace } from 'mobx'; +import { action, computed, observable, runInAction } from 'mobx'; import { observer } from "mobx-react"; -import { Doc, DocListCast, HeightSym, WidthSym, DataSym } from '../../../new_fields/Doc'; +import { DataSym, Doc, DocListCast, HeightSym, WidthSym } from '../../../new_fields/Doc'; +import { documentSchema } from '../../../new_fields/documentSchemas'; +import { Id } from '../../../new_fields/FieldSymbols'; import { List } from '../../../new_fields/List'; +import { ObjectField } from '../../../new_fields/ObjectField'; import { createSchema, listSpec, makeInterface } from '../../../new_fields/Schema'; import { ComputedField } from '../../../new_fields/ScriptField'; import { Cast, NumCast, StrCast } from '../../../new_fields/Types'; import { AudioField, ImageField } from '../../../new_fields/URLField'; -import { Utils, returnOne, emptyFunction } from '../../../Utils'; +import { TraceMobx } from '../../../new_fields/util'; +import { emptyFunction, returnOne, Utils, returnZero } from '../../../Utils'; import { CognitiveServices, Confidence, Service, Tag } from '../../cognitive_services/CognitiveServices'; import { Docs } from '../../documents/Documents'; +import { Networking } from '../../Network'; import { DragManager } from '../../util/DragManager'; +import { SelectionManager } from '../../util/SelectionManager'; import { undoBatch } from '../../util/UndoManager'; import { ContextMenu } from "../../views/ContextMenu"; +import { CollectionFreeFormView } from '../collections/collectionFreeForm/CollectionFreeFormView'; import { ContextMenuProps } from '../ContextMenuItem'; -import { DocAnnotatableComponent } from '../DocComponent'; +import { ViewBoxAnnotatableComponent } from '../DocComponent'; import FaceRectangles from './FaceRectangles'; import { FieldView, FieldViewProps } from './FieldView'; import "./ImageBox.scss"; import React = require("react"); -import { CollectionFreeFormView } from '../collections/collectionFreeForm/CollectionFreeFormView'; -import { documentSchema } from '../../../new_fields/documentSchemas'; -import { Id, Copy } from '../../../new_fields/FieldSymbols'; -import { TraceMobx } from '../../../new_fields/util'; -import { SelectionManager } from '../../util/SelectionManager'; -import { cache } from 'sharp'; -import { ObjectField } from '../../../new_fields/ObjectField'; -import { Networking } from '../../Network'; const requestImageSize = require('../../util/request-image-size'); const path = require('path'); const { Howl } = require('howler'); -library.add(faImage, faEye as any, faPaintBrush); +library.add(faImage, faEye as any, faPaintBrush, faBrain); library.add(faFileAudio, faAsterisk); @@ -66,7 +65,7 @@ const uploadIcons = { }; @observer -export class ImageBox extends DocAnnotatableComponent<FieldViewProps, ImageDocument>(ImageDocument) { +export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps, ImageDocument>(ImageDocument) { protected multiTouchDisposer?: import("../../util/InteractionUtils").InteractionUtils.MultiTouchEventDisposer | undefined; public static LayoutString(fieldKey: string) { return FieldView.LayoutString(ImageBox, fieldKey); } private _imgRef: React.RefObject<HTMLImageElement> = React.createRef(); @@ -77,7 +76,7 @@ export class ImageBox extends DocAnnotatableComponent<FieldViewProps, ImageDocum protected createDropTarget = (ele: HTMLDivElement) => { this._dropDisposer && this._dropDisposer(); - ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this))); + ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this), this.props.Document)); } @undoBatch @@ -86,16 +85,17 @@ export class ImageBox extends DocAnnotatableComponent<FieldViewProps, ImageDocum if (de.complete.docDragData) { if (de.metaKey) { de.complete.docDragData.droppedDocuments.forEach(action((drop: Doc) => { - Doc.AddDocToList(this.dataDoc, this.props.fieldKey + "-alternates", drop); + Doc.AddDocToList(this.dataDoc, this.fieldKey + "-alternates", drop); e.stopPropagation(); })); - } else if (de.altKey || !this.dataDoc[this.props.fieldKey]) { + } else if (de.altKey || !this.dataDoc[this.fieldKey]) { const layoutDoc = de.complete.docDragData?.draggedDocuments[0]; const targetField = Doc.LayoutFieldKey(layoutDoc); - if (layoutDoc?.[DataSym][targetField] instanceof ImageField) { - this.dataDoc[this.props.fieldKey] = ObjectField.MakeCopy(layoutDoc[DataSym][targetField] as ImageField); - this.dataDoc[this.props.fieldKey + "-nativeWidth"] = NumCast(layoutDoc[DataSym][targetField + "-nativeWidth"]); - this.dataDoc[this.props.fieldKey + "-nativeHeight"] = NumCast(layoutDoc[DataSym][targetField + "-nativeHeight"]); + const targetDoc = layoutDoc[DataSym]; + if (targetDoc[targetField] instanceof ImageField) { + this.dataDoc[this.fieldKey] = ObjectField.MakeCopy(targetDoc[targetField] as ImageField); + this.dataDoc[this.fieldKey + "-nativeWidth"] = NumCast(targetDoc[targetField + "-nativeWidth"]); + this.dataDoc[this.fieldKey + "-nativeHeight"] = NumCast(targetDoc[targetField + "-nativeHeight"]); e.stopPropagation(); } } @@ -123,9 +123,9 @@ export class ImageBox extends DocAnnotatableComponent<FieldViewProps, ImageDocum // upload to server with known URL const audioDoc = Docs.Create.AudioDocument(url, { title: "audio test", _width: 200, _height: 32 }); audioDoc.treeViewExpandedView = "layout"; - const audioAnnos = Cast(this.dataDoc[this.props.fieldKey + "-audioAnnotations"], listSpec(Doc)); + const audioAnnos = Cast(this.dataDoc[this.fieldKey + "-audioAnnotations"], listSpec(Doc)); if (audioAnnos === undefined) { - this.dataDoc[this.props.fieldKey + "-audioAnnotations"] = new List([audioDoc]); + this.dataDoc[this.fieldKey + "-audioAnnotations"] = new List([audioDoc]); } else { audioAnnos.push(audioDoc); } @@ -142,41 +142,53 @@ export class ImageBox extends DocAnnotatableComponent<FieldViewProps, ImageDocum @undoBatch rotate = action(() => { - const nw = NumCast(this.Document[this.props.fieldKey + "-nativeWidth"]); - const nh = NumCast(this.Document[this.props.fieldKey + "-nativeHeight"]); - const w = this.Document._width; - const h = this.Document._height; - this.dataDoc[this.props.fieldKey + "-rotation"] = (NumCast(this.dataDoc[this.props.fieldKey + "-rotation"]) + 90) % 360; - this.dataDoc[this.props.fieldKey + "-nativeWidth"] = nh; - this.dataDoc[this.props.fieldKey + "-nativeHeight"] = nw; - this.Document._width = h; - this.Document._height = w; + const nw = NumCast(this.dataDoc[this.fieldKey + "-nativeWidth"]); + const nh = NumCast(this.dataDoc[this.fieldKey + "-nativeHeight"]); + const w = this.layoutDoc._width; + const h = this.layoutDoc._height; + this.dataDoc[this.fieldKey + "-rotation"] = (NumCast(this.dataDoc[this.fieldKey + "-rotation"]) + 90) % 360; + this.dataDoc[this.fieldKey + "-nativeWidth"] = nh; + this.dataDoc[this.fieldKey + "-nativeHeight"] = nw; + this.layoutDoc._width = h; + this.layoutDoc._height = w; }); specificContextMenu = (e: React.MouseEvent): void => { - const field = Cast(this.Document[this.props.fieldKey], ImageField); + const field = Cast(this.dataDoc[this.fieldKey], ImageField); if (field) { const funcs: ContextMenuProps[] = []; funcs.push({ description: "Copy path", event: () => Utils.CopyText(field.url.href), icon: "expand-arrows-alt" }); funcs.push({ description: "Rotate", event: this.rotate, icon: "expand-arrows-alt" }); + funcs.push({ + description: "Reset Native Dimensions", event: action(async () => { + const curNW = NumCast(this.dataDoc[this.fieldKey + "-nativeWidth"]); + const curNH = NumCast(this.dataDoc[this.fieldKey + "-nativeHeight"]); + if (this.props.PanelWidth() / this.props.PanelHeight() > curNW / curNH) { + this.dataDoc[this.fieldKey + "-nativeWidth"] = this.props.PanelHeight() * curNW / curNH; + this.dataDoc[this.fieldKey + "-nativeHeight"] = this.props.PanelHeight(); + } else { + this.dataDoc[this.fieldKey + "-nativeWidth"] = this.props.PanelWidth(); + this.dataDoc[this.fieldKey + "-nativeHeight"] = this.props.PanelWidth() * curNH / curNW; + } + }), icon: "expand-arrows-alt" + }); const existingAnalyze = ContextMenu.Instance.findByDescription("Analyzers..."); const modes: ContextMenuProps[] = existingAnalyze && "subitems" in existingAnalyze ? existingAnalyze.subitems : []; modes.push({ description: "Generate Tags", event: this.generateMetadata, icon: "tag" }); modes.push({ description: "Find Faces", event: this.extractFaces, icon: "camera" }); + //modes.push({ description: "Recommend", event: this.extractText, icon: "brain" }); !existingAnalyze && ContextMenu.Instance.addItem({ description: "Analyzers...", subitems: modes, icon: "hand-point-right" }); - ContextMenu.Instance.addItem({ description: "Image Funcs...", subitems: funcs, icon: "asterisk" }); + ContextMenu.Instance.addItem({ description: "Options...", subitems: funcs, icon: "asterisk" }); } } extractFaces = () => { const converter = (results: any) => { - const faceDocs = new List<Doc>(); - results.reduce((face: CognitiveServices.Image.Face, faceDocs: List<Doc>) => faceDocs.push(Docs.Get.DocumentHierarchyFromJson(face, `Face: ${face.faceId}`)!), new List<Doc>()); - return faceDocs; + return results.map((face: CognitiveServices.Image.Face) => Docs.Get.FromJson({ data: face, title: `Face: ${face.faceId}` })!); }; - this.url && CognitiveServices.Image.Appliers.ProcessImage(this.dataDoc, [this.props.fieldKey + "-faces"], this.url, Service.Face, converter); + this.url && CognitiveServices.Image.Appliers.ProcessImage(this.dataDoc, [this.fieldKey + "-faces"], this.url, Service.Face, converter); } generateMetadata = (threshold: Confidence = Confidence.Excellent) => { @@ -188,16 +200,16 @@ export class ImageBox extends DocAnnotatableComponent<FieldViewProps, ImageDocum const sanitized = tag.name.replace(" ", "_"); tagDoc[sanitized] = ComputedField.MakeFunction(`(${tag.confidence} >= this.confidence) ? ${tag.confidence} : "${ComputedField.undefined}"`); }); - this.dataDoc[this.props.fieldKey + "-generatedTags"] = tagsList; + this.dataDoc[this.fieldKey + "-generatedTags"] = tagsList; tagDoc.title = "Generated Tags Doc"; tagDoc.confidence = threshold; return tagDoc; }; - this.url && CognitiveServices.Image.Appliers.ProcessImage(this.dataDoc, [this.props.fieldKey + "-generatedTagsDoc"], this.url, Service.ComputerVision, converter); + this.url && CognitiveServices.Image.Appliers.ProcessImage(this.dataDoc, [this.fieldKey + "-generatedTagsDoc"], this.url, Service.ComputerVision, converter); } @computed private get url() { - const data = Cast(this.dataDoc[this.props.fieldKey], ImageField); + const data = Cast(this.dataDoc[this.fieldKey], ImageField); return data ? data.url.href : undefined; } @@ -207,12 +219,12 @@ export class ImageBox extends DocAnnotatableComponent<FieldViewProps, ImageDocum return url.href; } else if (url.href.indexOf(window.location.origin) === -1) { return Utils.CorsProxy(url.href); - } else if (!(lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg"))) { + } else if (!/\.(png|jpg|jpeg|gif|webp)$/.test(lower)) { return url.href;//Why is this here } const ext = path.extname(url.href); - const suffix = this.props.renderDepth < 1 ? "_o" : this._curSuffix; - return url.href.replace(ext, suffix + ext); + this._curSuffix = this.props.renderDepth < 1 ? "_o" : this.layoutDoc[WidthSym]() < 100 ? "_s" : "_m"; + return url.href.replace(ext, this._curSuffix + ext); } @observable _smallRetryCount = 1; @@ -225,43 +237,46 @@ export class ImageBox extends DocAnnotatableComponent<FieldViewProps, ImageDocum } @action onError = (error: any) => { const timeout = this._curSuffix === "_s" ? this._smallRetryCount : this._curSuffix === "_m" ? this._mediumRetryCount : this._largeRetryCount; - if (timeout < 10) { - // setTimeout(this.retryPath, 500); - } - const original = StrCast(this.dataDoc.originalUrl); - if (error.type === "error" && original) { - this.dataDoc[this.props.fieldKey] = new ImageField(original); + if (timeout < 5) { + setTimeout(this.retryPath, 500); + } else { + const original = StrCast(this.dataDoc[this.fieldKey + "-originalUrl"]); + if (error.type === "error" && original) { + this.dataDoc[this.fieldKey] = new ImageField(original); + } } } _curSuffix = "_m"; resize = (imgPath: string) => { const cachedNativeSize = { - width: NumCast(this.dataDoc[this.props.fieldKey + "-nativeWidth"]), - height: NumCast(this.dataDoc[this.props.fieldKey + "-nativeHeight"]) + width: imgPath === this.dataDoc[this.fieldKey + "-path"] ? NumCast(this.dataDoc[this.fieldKey + "-nativeWidth"]) : 0, + height: imgPath === this.dataDoc[this.fieldKey + "-path"] ? NumCast(this.dataDoc[this.fieldKey + "-nativeHeight"]) : 0, }; - const cachedImgPath = this.dataDoc[this.props.fieldKey + "-imgPath"]; - if (!cachedNativeSize.width || !cachedNativeSize.height || imgPath !== cachedImgPath) { - (!this.layoutDoc.isTemplateDoc || this.dataDoc !== this.layoutDoc) && requestImageSize(imgPath).then((inquiredSize: any) => { - const rotation = NumCast(this.dataDoc[this.props.fieldKey + "-rotation"]) % 180; - const rotatedNativeSize = rotation === 90 || rotation === 270 ? { height: inquiredSize.width, width: inquiredSize.height } : inquiredSize; - const rotatedAspect = rotatedNativeSize.height / rotatedNativeSize.width; - const docAspect = this.Document[HeightSym]() / this.Document[WidthSym](); - setTimeout(action(() => { - if (this.Document[WidthSym]() && (!cachedNativeSize.width || !cachedNativeSize.height || Math.abs(1 - docAspect / rotatedAspect) > 0.1)) { - this.Document._height = this.Document[WidthSym]() * rotatedAspect; - this.dataDoc[this.props.fieldKey + "-nativeWidth"] = this.Document._nativeWidth = rotatedNativeSize.width; - this.dataDoc[this.props.fieldKey + "-nativeHeight"] = this.Document._nativeHeight = rotatedNativeSize.height; + const docAspect = this.layoutDoc[HeightSym]() / this.layoutDoc[WidthSym](); + const cachedAspect = cachedNativeSize.height / cachedNativeSize.width; + if (!cachedNativeSize.width || !cachedNativeSize.height || Math.abs(NumCast(this.layoutDoc._width) / NumCast(this.layoutDoc._height) - cachedNativeSize.width / cachedNativeSize.height) > 0.05) { + if (!this.layoutDoc.isTemplateDoc || this.dataDoc !== this.layoutDoc) { + requestImageSize(imgPath).then(action((inquiredSize: any) => { + const rotation = NumCast(this.dataDoc[this.fieldKey + "-rotation"]) % 180; + const rotatedNativeSize = rotation === 90 || rotation === 270 ? { height: inquiredSize.width, width: inquiredSize.height } : inquiredSize; + const rotatedAspect = rotatedNativeSize.height / rotatedNativeSize.width; + if (this.layoutDoc[WidthSym]() && (!cachedNativeSize.width || !cachedNativeSize.height || Math.abs(1 - docAspect / rotatedAspect) > 0.1)) { + this.layoutDoc._height = this.layoutDoc[WidthSym]() * rotatedAspect; + this.dataDoc[this.fieldKey + "-nativeWidth"] = this.layoutDoc._nativeWidth = this.layoutDoc._width; + this.dataDoc[this.fieldKey + "-nativeHeight"] = this.layoutDoc._nativeHeight = this.layoutDoc._height; + this.dataDoc[this.fieldKey + "-path"] = imgPath; } - this.dataDoc[this.props.fieldKey + "-imgPath"] = imgPath; - }), 0); - }) - .catch((err: any) => console.log(err)); - } else if (this.Document._nativeWidth !== cachedNativeSize.width || this.Document._nativeHeight !== cachedNativeSize.height) { - !(this.Document[StrCast(this.props.Document.layoutKey)] instanceof Doc) && setTimeout(() => { - if (!(this.Document[StrCast(this.props.Document.layoutKey)] instanceof Doc)) { - this.Document._nativeWidth = cachedNativeSize.width; - this.Document._nativeHeight = cachedNativeSize.height; + })).catch(console.log); + } else if (Math.abs(1 - docAspect / cachedAspect) > 0.1) { + this.layoutDoc._width = this.layoutDoc[WidthSym]() || cachedNativeSize.width; + this.layoutDoc._height = this.layoutDoc[WidthSym]() * cachedAspect; + } + } else if (this.layoutDoc._nativeWidth !== cachedNativeSize.width || this.layoutDoc._nativeHeight !== cachedNativeSize.height) { + !(this.layoutDoc[StrCast(this.layoutDoc.layoutKey)] instanceof Doc) && setTimeout(() => { + if (!(this.layoutDoc[StrCast(this.layoutDoc.layoutKey)] instanceof Doc)) { + this.layoutDoc._nativeWidth = cachedNativeSize.width; + this.layoutDoc._nativeHeight = cachedNativeSize.height; } }, 0); } @@ -270,7 +285,7 @@ export class ImageBox extends DocAnnotatableComponent<FieldViewProps, ImageDocum @action onPointerEnter = () => { const self = this; - const audioAnnos = DocListCast(this.dataDoc[this.props.fieldKey + "-audioAnnotations"]); + const audioAnnos = DocListCast(this.dataDoc[this.fieldKey + "-audioAnnotations"]); if (audioAnnos && audioAnnos.length && this._audioState === 0) { const anno = audioAnnos[Math.floor(Math.random() * audioAnnos.length)]; anno.data instanceof AudioField && new Howl({ @@ -290,8 +305,9 @@ export class ImageBox extends DocAnnotatableComponent<FieldViewProps, ImageDocum audioDown = () => this.recordAudioAnnotation(); considerGooglePhotosLink = () => { - const remoteUrl = this.Document.googlePhotosUrl; + const remoteUrl = this.dataDoc.googlePhotosUrl; return !remoteUrl ? (null) : (<img + style={{ transform: `scale(${this.props.ContentScaling()})`, transformOrigin: "bottom right" }} id={"google-photos"} src={"/assets/google_photos.png"} onClick={() => window.open(remoteUrl)} @@ -299,13 +315,13 @@ export class ImageBox extends DocAnnotatableComponent<FieldViewProps, ImageDocum } considerGooglePhotosTags = () => { - const tags = this.Document.googlePhotosTags; + const tags = this.dataDoc.googlePhotosTags; return !tags ? (null) : (<img id={"google-tags"} src={"/assets/google_tags.png"} />); } @computed private get considerDownloadIcon() { - const data = this.dataDoc[this.props.fieldKey]; + const data = this.dataDoc[this.fieldKey]; if (!(data instanceof ImageField)) { return (null); } @@ -316,17 +332,18 @@ export class ImageBox extends DocAnnotatableComponent<FieldViewProps, ImageDocum return ( <img id={"upload-icon"} + style={{ transform: `scale(${1 / this.props.ContentScaling()})`, transformOrigin: "bottom right" }} src={`/assets/${this.uploadIcon}`} onClick={async () => { const { dataDoc } = this; const { success, failure, idle, loading } = uploadIcons; runInAction(() => this.uploadIcon = loading); - const [{ clientAccessPath }] = await Networking.PostToServer("/uploadRemoteImage", { sources: [primary] }); - dataDoc.originalUrl = primary; + const [{ accessPaths }] = await Networking.PostToServer("/uploadRemoteImage", { sources: [primary] }); + dataDoc[this.props.fieldKey + "-originalUrl"] = primary; let succeeded = true; let data: ImageField | undefined; try { - data = new ImageField(Utils.prepend(clientAccessPath)); + data = new ImageField(Utils.prepend(accessPaths.agnostic.client)); } catch { succeeded = false; } @@ -334,7 +351,7 @@ export class ImageBox extends DocAnnotatableComponent<FieldViewProps, ImageDocum setTimeout(action(() => { this.uploadIcon = idle; if (data) { - dataDoc[this.props.fieldKey] = data; + dataDoc[this.fieldKey] = data; } }), 2000); }} @@ -343,44 +360,42 @@ export class ImageBox extends DocAnnotatableComponent<FieldViewProps, ImageDocum } @computed get nativeSize() { - const pw = typeof this.props.PanelWidth === "function" ? this.props.PanelWidth() : typeof this.props.PanelWidth === "number" ? (this.props.PanelWidth as any) as number : 50; - const nativeWidth = NumCast(this.dataDoc[this.props.fieldKey + "-nativeWidth"], pw); - const nativeHeight = NumCast(this.dataDoc[this.props.fieldKey + "-nativeHeight"], 1); + TraceMobx(); + const pw = this.props.PanelWidth?.() || 50; + const nativeWidth = NumCast(this.dataDoc[this.fieldKey + "-nativeWidth"], pw); + const nativeHeight = NumCast(this.dataDoc[this.fieldKey + "-nativeHeight"], 1); return { nativeWidth, nativeHeight }; } + // this._curSuffix = ""; + // if (w > 20) { + // if (w < 100 && this._smallRetryCount < 10) this._curSuffix = "_s"; + // else if (w < 600 && this._mediumRetryCount < 10) this._curSuffix = "_m"; + // else if (this._largeRetryCount < 10) this._curSuffix = "_l"; @computed get paths() { - let paths = [Utils.CorsProxy("http://www.cs.brown.edu/~bcz/noImage.png")]; - // this._curSuffix = ""; - // if (w > 20) { - const alts = DocListCast(this.dataDoc[this.props.fieldKey + "-alternates"]); - const altpaths = alts.filter(doc => doc.data instanceof ImageField).map(doc => this.choosePath((doc.data as ImageField).url)); - const field = this.dataDoc[this.props.fieldKey]; - // if (w < 100 && this._smallRetryCount < 10) this._curSuffix = "_s"; - // else if (w < 600 && this._mediumRetryCount < 10) this._curSuffix = "_m"; - // else if (this._largeRetryCount < 10) this._curSuffix = "_l"; - if (field instanceof ImageField) paths = [this.choosePath(field.url)]; - paths.push(...altpaths); - return paths; + const field = Cast(this.dataDoc[this.fieldKey], ImageField, null); // retrieve the primary image URL that is being rendered from the data doc + const alts = DocListCast(this.dataDoc[this.fieldKey + "-alternates"]); // retrieve alternate documents that may be rendered as alternate images + const altpaths = alts.map(doc => Cast(doc[Doc.LayoutFieldKey(doc)], ImageField, null)?.url).filter(url => url).map(url => this.choosePath(url)); // access the primary layout data of the alternate documents + const paths = field ? [this.choosePath(field.url), ...altpaths] : altpaths; + return paths.length ? paths : [Utils.CorsProxy("http://www.cs.brown.edu/~bcz/noImage.png")]; } @computed get content() { TraceMobx(); - const srcpath = this.paths[NumCast(this.props.Document.curPage, 0)]; + const srcpath = this.paths[0]; const fadepath = this.paths[Math.min(1, this.paths.length - 1)]; const { nativeWidth, nativeHeight } = this.nativeSize; - const rotation = NumCast(this.dataDoc[this.props.fieldKey + "-rotation"]); - const aspect = (rotation % 180) ? this.Document[HeightSym]() / this.Document[WidthSym]() : 1; - const shift = (rotation % 180) ? (nativeHeight - nativeWidth / aspect) / 2 : 0; + const rotation = NumCast(this.dataDoc[this.fieldKey + "-rotation"]); + const aspect = (rotation % 180) ? nativeHeight / nativeWidth : 1; + const shift = (rotation % 180) ? (nativeHeight - nativeWidth) * (1 - 1 / aspect) : 0; + this.resize(srcpath); - !this.Document.ignoreAspect && this.resize(srcpath); - - return <div className="imageBox-cont" key={this.props.Document[Id]} ref={this.createDropTarget} onContextMenu={this.specificContextMenu}> + return <div className="imageBox-cont" key={this.layoutDoc[Id]} ref={this.createDropTarget}> <div className="imageBox-fader" > <img key={this._smallRetryCount + (this._mediumRetryCount << 4) + (this._largeRetryCount << 8)} // force cache to update on retrys src={srcpath} - style={{ transform: `translate(0px, ${shift}px) rotate(${rotation}deg) scale(${aspect})` }} + style={{ transform: `scale(${aspect}) translate(0px, ${shift}px) rotate(${rotation}deg)` }} width={nativeWidth} ref={this._imgRef} onError={this.onError} /> @@ -393,35 +408,51 @@ export class ImageBox extends DocAnnotatableComponent<FieldViewProps, ImageDocum ref={this._imgRef} onError={this.onError} /></div>} </div> - <div className="imageBox-audioBackground" - onPointerDown={this.audioDown} - onPointerEnter={this.onPointerEnter} - style={{ height: `calc(${.1 * nativeHeight / nativeWidth * 100}%)` }} - > - <FontAwesomeIcon className="imageBox-audioFont" - style={{ color: [DocListCast(this.dataDoc[this.props.fieldKey + "-audioAnnotations"]).length ? "blue" : "gray", "green", "red"][this._audioState] }} - icon={!DocListCast(this.dataDoc[this.props.fieldKey + "-audioAnnotations"]).length ? "microphone" : faFileAudio} size="sm" /> - </div> + {!this.layoutDoc._showAudio ? (null) : + <div className="imageBox-audioBackground" + onPointerDown={this.audioDown} + onPointerEnter={this.onPointerEnter} + style={{ height: `calc(${.1 * nativeHeight / nativeWidth * 100}%)` }} + > + <FontAwesomeIcon className="imageBox-audioFont" + style={{ color: [DocListCast(this.dataDoc[this.fieldKey + "-audioAnnotations"]).length ? "blue" : "gray", "green", "red"][this._audioState] }} + icon={!DocListCast(this.dataDoc[this.fieldKey + "-audioAnnotations"]).length ? "microphone" : faFileAudio} size="sm" /> + </div>} {this.considerDownloadIcon} {this.considerGooglePhotosLink()} <FaceRectangles document={this.dataDoc} color={"#0000FF"} backgroundColor={"#0000FF"} /> </div>; } + // adjust y position to center image in panel aspect is bigger than image aspect. + // bcz :note, this is broken for rotated images + get ycenter() { + const { nativeWidth, nativeHeight } = this.nativeSize; + const rotation = NumCast(this.dataDoc[this.fieldKey + "-rotation"]); + const aspect = (rotation % 180) ? nativeWidth / nativeHeight : nativeHeight / nativeWidth; + return this.props.PanelHeight() / this.props.PanelWidth() > aspect ? + (this.props.PanelHeight() - this.props.PanelWidth() * aspect) / 2 : 0; + } + + screenToLocalTransform = () => this.props.ScreenToLocalTransform().translate(0, -this.ycenter / this.props.ContentScaling()); + contentFunc = () => [this.content]; render() { TraceMobx(); - const dragging = !SelectionManager.GetIsDragging() ? "" : "-dragging"; - return (<div className={`imageBox${dragging}`} onContextMenu={this.specificContextMenu} + return (<div className={`imageBox`} onContextMenu={this.specificContextMenu} style={{ - transform: `scale(${this.props.ContentScaling()})`, - width: `${100 / this.props.ContentScaling()}%`, - height: `${100 / this.props.ContentScaling()}%`, - pointerEvents: this.props.Document.isBackground ? "none" : undefined + transform: this.props.PanelWidth() ? `translate(0px, ${this.ycenter}px)` : `scale(${this.props.ContentScaling()})`, + width: this.props.PanelWidth() ? undefined : `${100 / this.props.ContentScaling()}%`, + height: this.props.PanelWidth() ? undefined : `${100 / this.props.ContentScaling()}%`, + pointerEvents: this.layoutDoc.isBackground ? "none" : undefined, + borderRadius: `${Number(StrCast(this.layoutDoc.borderRoundisng).replace("px", "")) / this.props.ContentScaling()}px` }} > <CollectionFreeFormView {...this.props} + forceScaling={true} PanelHeight={this.props.PanelHeight} PanelWidth={this.props.PanelWidth} + NativeHeight={returnZero} + NativeWidth={returnZero} annotationsKey={this.annotationKey} isAnnotationOverlay={true} focus={this.props.focus} @@ -434,10 +465,9 @@ export class ImageBox extends DocAnnotatableComponent<FieldViewProps, ImageDocum moveDocument={this.moveDocument} addDocument={this.addDocument} CollectionView={undefined} - ScreenToLocalTransform={this.props.ScreenToLocalTransform} + ScreenToLocalTransform={this.screenToLocalTransform} renderDepth={this.props.renderDepth + 1} - ContainingCollectionDoc={this.props.ContainingCollectionDoc} - chromeCollapsed={true}> + ContainingCollectionDoc={this.props.ContainingCollectionDoc}> {this.contentFunc} </CollectionFreeFormView> </div >); diff --git a/src/client/views/nodes/KeyValueBox.scss b/src/client/views/nodes/KeyValueBox.scss index 6e8a36c6a..eb7c2f32b 100644 --- a/src/client/views/nodes/KeyValueBox.scss +++ b/src/client/views/nodes/KeyValueBox.scss @@ -8,7 +8,6 @@ border-radius: $border-radius; box-sizing: border-box; display: inline-block; - pointer-events: all; cursor: default; .imageBox-cont img { width: auto; @@ -74,7 +73,7 @@ $header-height: 30px; .keyValueBox-evenRow { position: relative; - display: inline-block; + display: flex; width:100%; height:$header-height; background: $light-color; @@ -114,7 +113,7 @@ $header-height: 30px; .keyValueBox-oddRow { position: relative; - display: inline-block; + display: flex; width:100%; height:30px; background: $light-color-secondary; diff --git a/src/client/views/nodes/KeyValueBox.tsx b/src/client/views/nodes/KeyValueBox.tsx index 7aad6f90e..2970674a2 100644 --- a/src/client/views/nodes/KeyValueBox.tsx +++ b/src/client/views/nodes/KeyValueBox.tsx @@ -16,6 +16,8 @@ import { FieldView, FieldViewProps } from './FieldView'; import "./KeyValueBox.scss"; import { KeyValuePair } from "./KeyValuePair"; import React = require("react"); +import { ContextMenu } from "../ContextMenu"; +import { ContextMenuProps } from "../ContextMenuItem"; export type KVPScript = { script: CompiledScript; @@ -34,11 +36,7 @@ export class KeyValueBox extends React.Component<FieldViewProps> { @observable private _keyInput: string = ""; @observable private _valueInput: string = ""; @computed get splitPercentage() { return NumCast(this.props.Document.schemaSplitPercentage, 50); } - get fieldDocToLayout() { return this.props.fieldKey ? FieldValue(Cast(this.props.Document[this.props.fieldKey], Doc)) : this.props.Document; } - - constructor(props: FieldViewProps) { - super(props); - } + get fieldDocToLayout() { return this.props.fieldKey ? Cast(this.props.Document[this.props.fieldKey], Doc, null) : this.props.Document; } @action onEnterKey = (e: React.KeyboardEvent): void => { @@ -234,13 +232,26 @@ export class KeyValueBox extends React.Component<FieldViewProps> { return new Doc; } + specificContextMenu = (e: React.MouseEvent): void => { + const cm = ContextMenu.Instance; + const open = cm.findByDescription("Change Perspective..."); + const openItems: ContextMenuProps[] = open && "subitems" in open ? open.subitems : []; + openItems.push({ + description: "Default Perspective", event: () => { + this.props.addDocTab(this.fieldDocToLayout, "inTab"); + this.props.addDocTab(this.props.Document, "close"); + }, icon: "image" + }); + !open && cm.addItem({ description: "Change Perspective...", subitems: openItems, icon: "external-link-alt" }); + } + render() { const dividerDragger = this.splitPercentage === 0 ? (null) : <div className="keyValueBox-dividerDragger" style={{ transform: `translate(calc(${100 - this.splitPercentage}% - 5px), 0px)` }}> <div className="keyValueBox-dividerDraggerThumb" onPointerDown={this.onDividerDown} /> </div>; - return (<div className="keyValueBox-cont" onWheel={this.onPointerWheel} ref={this._mainCont}> + return (<div className="keyValueBox-cont" onWheel={this.onPointerWheel} onContextMenu={this.specificContextMenu} ref={this._mainCont}> <table className="keyValueBox-table"> <tbody className="keyValueBox-tbody"> <tr className="keyValueBox-header"> diff --git a/src/client/views/nodes/KeyValuePair.tsx b/src/client/views/nodes/KeyValuePair.tsx index e6b512adf..6dc4ae578 100644 --- a/src/client/views/nodes/KeyValuePair.tsx +++ b/src/client/views/nodes/KeyValuePair.tsx @@ -22,7 +22,7 @@ export interface KeyValuePairProps { keyWidth: number; PanelHeight: () => number; PanelWidth: () => number; - addDocTab: (doc: Doc, data: Opt<Doc>, where: string) => boolean; + addDocTab: (doc: Doc, where: string) => boolean; } @observer export class KeyValuePair extends React.Component<KeyValuePairProps> { @@ -46,7 +46,7 @@ export class KeyValuePair extends React.Component<KeyValuePairProps> { if (value instanceof Doc) { e.stopPropagation(); e.preventDefault(); - ContextMenu.Instance.addItem({ description: "Open Fields", event: () => this.props.addDocTab(Docs.Create.KVPDocument(value, { _width: 300, _height: 300 }), undefined, "onRight"), icon: "layer-group" }); + ContextMenu.Instance.addItem({ description: "Open Fields", event: () => this.props.addDocTab(Docs.Create.KVPDocument(value, { _width: 300, _height: 300 }), "onRight"), icon: "layer-group" }); ContextMenu.Instance.displayMenu(e.clientX, e.clientY); } } @@ -59,13 +59,18 @@ export class KeyValuePair extends React.Component<KeyValuePairProps> { ContainingCollectionView: undefined, ContainingCollectionDoc: undefined, fieldKey: this.props.keyName, + rootSelected: returnFalse, isSelected: returnFalse, select: emptyFunction, + dropAction: "alias", + bringToFront: emptyFunction, renderDepth: 1, active: returnFalse, whenActiveChanged: emptyFunction, ScreenToLocalTransform: Transform.Identity, focus: emptyFunction, + NativeHeight: returnZero, + NativeWidth: returnZero, PanelWidth: this.props.PanelWidth, PanelHeight: this.props.PanelHeight, addDocTab: returnFalse, diff --git a/src/client/views/nodes/ButtonBox.scss b/src/client/views/nodes/LabelBox.scss index 7c3825978..7c7e92379 100644 --- a/src/client/views/nodes/ButtonBox.scss +++ b/src/client/views/nodes/LabelBox.scss @@ -1,36 +1,35 @@ -.buttonBox-outerDiv { +.labelBox-outerDiv { width: 100%; height: 100%; - pointer-events: all; border-radius: inherit; display: flex; flex-direction: column; } -.buttonBox-mainButton { +.labelBox-mainButton { width: 100%; height: 100%; border-radius: inherit; - text-align: center; - display: table; - overflow: hidden; - text-overflow: ellipsis; letter-spacing: 2px; text-transform: uppercase; + overflow: hidden; + display:flex; } -.buttonBox-mainButtonCenter { - height: 100%; - display: table-cell; - vertical-align: middle; +.labelBox-mainButtonCenter { + overflow: hidden; + display: inline; + align-items: center; + margin: auto; } -.buttonBox-params { +.labelBox-params { display: flex; flex-direction: row; } -.buttonBox-missingParam { +.labelBox-missingParam { width: 100%; background: lightgray; + border: dimGray solid 1px; }
\ No newline at end of file diff --git a/src/client/views/nodes/LabelBox.tsx b/src/client/views/nodes/LabelBox.tsx new file mode 100644 index 000000000..3cdec8acb --- /dev/null +++ b/src/client/views/nodes/LabelBox.tsx @@ -0,0 +1,96 @@ +import { library } from '@fortawesome/fontawesome-svg-core'; +import { faEdit } from '@fortawesome/free-regular-svg-icons'; +import { action } from 'mobx'; +import { observer } from 'mobx-react'; +import * as React from 'react'; +import { Doc, DocListCast } from '../../../new_fields/Doc'; +import { documentSchema } from '../../../new_fields/documentSchemas'; +import { List } from '../../../new_fields/List'; +import { createSchema, listSpec, makeInterface } from '../../../new_fields/Schema'; +import { Cast, NumCast, StrCast } from '../../../new_fields/Types'; +import { DragManager } from '../../util/DragManager'; +import { undoBatch } from '../../util/UndoManager'; +import { ContextMenu } from '../ContextMenu'; +import { ContextMenuProps } from '../ContextMenuItem'; +import { ViewBoxBaseComponent } from '../DocComponent'; +import { FieldView, FieldViewProps } from './FieldView'; +import './LabelBox.scss'; + + +library.add(faEdit as any); + +const LabelSchema = createSchema({}); + +type LabelDocument = makeInterface<[typeof LabelSchema, typeof documentSchema]>; +const LabelDocument = makeInterface(LabelSchema, documentSchema); + +@observer +export class LabelBox extends ViewBoxBaseComponent<FieldViewProps, LabelDocument>(LabelDocument) { + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(LabelBox, fieldKey); } + private dropDisposer?: DragManager.DragDropDisposer; + + protected createDropTarget = (ele: HTMLDivElement) => { + this.dropDisposer?.(); + if (ele) { + this.dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this), this.props.Document); + } + } + + get paramsDoc() { return Doc.AreProtosEqual(this.layoutDoc, this.dataDoc) ? this.dataDoc : this.layoutDoc; } + specificContextMenu = (e: React.MouseEvent): void => { + const funcs: ContextMenuProps[] = []; + funcs.push({ + description: "Clear Script Params", event: () => { + const params = Cast(this.paramsDoc["onClick-paramFieldKeys"], listSpec("string"), []); + params?.map(p => this.paramsDoc[p] = undefined); + }, icon: "trash" + }); + + ContextMenu.Instance.addItem({ description: "OnClick...", subitems: funcs, icon: "asterisk" }); + } + + @undoBatch + @action + drop = (e: Event, de: DragManager.DropEvent) => { + const docDragData = de.complete.docDragData; + const params = Cast(this.paramsDoc["onClick-paramFieldKeys"], listSpec("string"), []); + const missingParams = params?.filter(p => !this.paramsDoc[p]); + if (docDragData && missingParams?.includes((e.target as any).textContent)) { + this.paramsDoc[(e.target as any).textContent] = new List<Doc>(docDragData.droppedDocuments.map((d, i) => + d.onDragStart ? docDragData.draggedDocuments[i] : d)); + e.stopPropagation(); + } + } + // (!missingParams || !missingParams.length ? "" : "(" + missingParams.map(m => m + ":").join(" ") + ")") + render() { + const params = Cast(this.paramsDoc["onClick-paramFieldKeys"], listSpec("string"), []); + const missingParams = params?.filter(p => !this.paramsDoc[p]); + params?.map(p => DocListCast(this.paramsDoc[p])); // bcz: really hacky form of prefetching ... + return ( + <div className="labelBox-outerDiv" ref={this.createDropTarget} onContextMenu={this.specificContextMenu} + style={{ boxShadow: this.layoutDoc.opacity ? StrCast(this.layoutDoc.boxShadow) : "" }}> + <div className="labelBox-mainButton" style={{ + background: StrCast(this.layoutDoc.backgroundColor), + color: StrCast(this.layoutDoc.color, "inherit"), + fontSize: NumCast(this.layoutDoc._fontSize) || "inherit", + fontFamily: StrCast(this.layoutDoc._fontFamily) || "inherit", + letterSpacing: StrCast(this.layoutDoc.letterSpacing), + textTransform: StrCast(this.layoutDoc.textTransform) as any, + paddingLeft: NumCast(this.layoutDoc._xPadding), + paddingRight: NumCast(this.layoutDoc._xPadding), + paddingTop: NumCast(this.layoutDoc._yPadding), + paddingBottom: NumCast(this.layoutDoc._yPadding), + textOverflow: this.layoutDoc._singleLine ? "ellipsis" : undefined, + whiteSpace: this.layoutDoc._singleLine ? "nowrap" : "pre-wrap" + }} > + <div className="labelBox-mainButtonCenter"> + {StrCast(this.rootDoc.text, StrCast(this.rootDoc.title))} + </div> + </div> + <div className="labelBox-fieldKeyParams" > + {!missingParams?.length ? (null) : missingParams.map(m => <div key={m} className="labelBox-missingParam">{m}</div>)} + </div> + </div> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/LinkAnchorBox.scss b/src/client/views/nodes/LinkAnchorBox.scss new file mode 100644 index 000000000..710f2178b --- /dev/null +++ b/src/client/views/nodes/LinkAnchorBox.scss @@ -0,0 +1,29 @@ +.linkAnchorBox-cont, .linkAnchorBox-cont-small { + cursor: default; + position: absolute; + width: 15; + height: 15; + border-radius: 20px; + user-select: none; + pointer-events: all; + + .linkAnchorBox-linkCloser { + position: absolute; + width: 18; + height: 18; + background: rgb(219, 21, 21); + top: -1px; + left: -1px; + border-radius: 5px; + display: flex; + justify-content: center; + align-items: center; + padding-left: 2px; + padding-top: 1px; + } +} + +.linkAnchorBox-cont-small { + width:5px; + height:5px; +}
\ No newline at end of file diff --git a/src/client/views/nodes/LinkAnchorBox.tsx b/src/client/views/nodes/LinkAnchorBox.tsx new file mode 100644 index 000000000..6c50abf21 --- /dev/null +++ b/src/client/views/nodes/LinkAnchorBox.tsx @@ -0,0 +1,149 @@ +import { action, observable } from "mobx"; +import { observer } from "mobx-react"; +import { Doc, DocListCast } from "../../../new_fields/Doc"; +import { documentSchema } from "../../../new_fields/documentSchemas"; +import { makeInterface } from "../../../new_fields/Schema"; +import { Cast, NumCast, StrCast } from "../../../new_fields/Types"; +import { Utils, setupMoveUpEvents, emptyFunction } from '../../../Utils'; +import { DocumentManager } from "../../util/DocumentManager"; +import { DragManager } from "../../util/DragManager"; +import { ViewBoxBaseComponent } from "../DocComponent"; +import "./LinkAnchorBox.scss"; +import { FieldView, FieldViewProps } from "./FieldView"; +import React = require("react"); +import { ContextMenuProps } from "../ContextMenuItem"; +import { ContextMenu } from "../ContextMenu"; +import { LinkEditor } from "../linking/LinkEditor"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { SelectionManager } from "../../util/SelectionManager"; +import { TraceMobx } from "../../../new_fields/util"; +import { DocumentView } from "./DocumentView"; +const higflyout = require("@hig/flyout"); +export const { anchorPoints } = higflyout; +export const Flyout = higflyout.default; + +type LinkAnchorSchema = makeInterface<[typeof documentSchema]>; +const LinkAnchorDocument = makeInterface(documentSchema); + +@observer +export class LinkAnchorBox extends ViewBoxBaseComponent<FieldViewProps, LinkAnchorSchema>(LinkAnchorDocument) { + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(LinkAnchorBox, fieldKey); } + _doubleTap = false; + _lastTap: number = 0; + _ref = React.createRef<HTMLDivElement>(); + _isOpen = false; + _timeout: NodeJS.Timeout | undefined; + @observable _x = 0; + @observable _y = 0; + @observable _selected = false; + @observable _editing = false; + @observable _forceOpen = false; + + onPointerDown = (e: React.PointerEvent) => { + setupMoveUpEvents(this, e, this.onPointerMove, emptyFunction, emptyFunction, false); + } + onPointerMove = action((e: PointerEvent, down: number[], delta: number[]) => { + const cdiv = this._ref && this._ref.current && this._ref.current.parentElement; + if (!this._isOpen && cdiv) { + const bounds = cdiv.getBoundingClientRect(); + const pt = Utils.getNearestPointInPerimeter(bounds.left, bounds.top, bounds.width, bounds.height, e.clientX, e.clientY); + const separation = Math.sqrt((pt[0] - e.clientX) * (pt[0] - e.clientX) + (pt[1] - e.clientY) * (pt[1] - e.clientY)); + const dragdist = Math.sqrt((pt[0] - down[0]) * (pt[0] - down[0]) + (pt[1] - down[1]) * (pt[1] - down[1])); + if (separation > 100) { + const dragData = new DragManager.DocumentDragData([this.rootDoc]); + dragData.dropAction = "alias"; + dragData.removeDropProperties = ["anchor1_x", "anchor1_y", "anchor2_x", "anchor2_y", "isLinkButton"]; + DragManager.StartDocumentDrag([this._ref.current!], dragData, down[0], down[1]); + return true; + } else if (dragdist > separation) { + this.layoutDoc[this.fieldKey + "_x"] = (pt[0] - bounds.left) / bounds.width * 100; + this.layoutDoc[this.fieldKey + "_y"] = (pt[1] - bounds.top) / bounds.height * 100; + } + } + return false; + }); + @action + onClick = (e: React.MouseEvent) => { + if ((e.button === 2 || e.ctrlKey || !this.layoutDoc.isLinkButton)) { + this.props.select(false); + } + if (!this._doubleTap && !e.ctrlKey && e.button < 2) { + const anchorContainerDoc = this.props.ContainingCollectionDoc; // bcz: hack! need a better prop for passing the anchor's container + this._editing = true; + anchorContainerDoc && this.props.bringToFront(anchorContainerDoc, false); + if (anchorContainerDoc && !this.layoutDoc.onClick && !this._isOpen) { + this._timeout = setTimeout(action(() => { + DocumentManager.Instance.FollowLink(this.rootDoc, anchorContainerDoc, document => this.props.addDocTab(document, StrCast(this.layoutDoc.linkOpenLocation, "inTab")), false); + this._editing = false; + }), 300 - (Date.now() - this._lastTap)); + } + } else { + this._timeout && clearTimeout(this._timeout); + this._timeout = undefined; + this._doubleTap = false; + this.openLinkEditor(e); + e.stopPropagation(); + } + } + + openLinkDocOnRight = (e: React.MouseEvent) => { + this.props.addDocTab(this.rootDoc, "onRight"); + } + openLinkTargetOnRight = (e: React.MouseEvent) => { + const alias = Doc.MakeAlias(Cast(this.layoutDoc[this.fieldKey], Doc, null)); + alias.isLinkButton = undefined; + alias.isBackground = undefined; + alias.layoutKey = "layout"; + this.props.addDocTab(alias, "onRight"); + } + @action + openLinkEditor = action((e: React.MouseEvent) => { + SelectionManager.DeselectAll(); + this._editing = this._forceOpen = true; + }); + + specificContextMenu = (e: React.MouseEvent): void => { + const funcs: ContextMenuProps[] = []; + funcs.push({ description: "Open Link Target on Right", event: () => this.openLinkTargetOnRight(e), icon: "eye" }); + funcs.push({ description: "Open Link on Right", event: () => this.openLinkDocOnRight(e), icon: "eye" }); + funcs.push({ description: "Open Link Editor", event: () => this.openLinkEditor(e), icon: "eye" }); + funcs.push({ description: "Toggle Always Show Link", event: () => this.props.Document.linkDisplay = !this.props.Document.linkDisplay, icon: "eye" }); + + ContextMenu.Instance.addItem({ description: "Options...", subitems: funcs, icon: "asterisk" }); + } + + render() { + TraceMobx(); + const x = this.props.PanelWidth() > 1 ? NumCast(this.layoutDoc[this.fieldKey + "_x"], 100) : 0; + const y = this.props.PanelWidth() > 1 ? NumCast(this.layoutDoc[this.fieldKey + "_y"], 100) : 0; + const c = StrCast(this.layoutDoc.backgroundColor, "lightblue"); + const anchor = this.fieldKey === "anchor1" ? "anchor2" : "anchor1"; + const anchorScale = (x === 0 || x === 100 || y === 0 || y === 100) ? 1 : .15; + + const timecode = this.dataDoc[anchor + "_timecode"]; + const targetTitle = StrCast((this.dataDoc[anchor] as Doc)?.title) + (timecode !== undefined ? ":" + timecode : ""); + const flyout = ( + <div className="linkAnchorBoxBox-flyout" title=" " onPointerOver={() => Doc.UnBrushDoc(this.rootDoc)}> + <LinkEditor sourceDoc={Cast(this.dataDoc[this.fieldKey], Doc, null)} hideback={true} linkDoc={this.rootDoc} showLinks={action(() => { })} /> + {!this._forceOpen ? (null) : <div className="linkAnchorBox-linkCloser" onPointerDown={action(() => this._isOpen = this._editing = this._forceOpen = false)}> + <FontAwesomeIcon color="dimGray" icon={"times"} size={"sm"} /> + </div>} + </div> + ); + const small = this.props.PanelWidth() <= 1; + return <div className={`linkAnchorBox-cont${small ? "-small" : ""}`} onPointerDown={this.onPointerDown} onClick={this.onClick} title={targetTitle} onContextMenu={this.specificContextMenu} + ref={this._ref} style={{ + background: c, + left: !small ? `calc(${x}% - 7.5px)` : undefined, + top: !small ? `calc(${y}% - 7.5px)` : undefined, + transform: `scale(${anchorScale / this.props.ContentScaling()})` + }} > + {!this._editing && !this._forceOpen ? (null) : + <Flyout anchorPoint={anchorPoints.LEFT_TOP} content={flyout} open={this._forceOpen ? true : undefined} onOpen={() => this._isOpen = true} onClose={action(() => this._isOpen = this._forceOpen = this._editing = false)}> + <span className="parentDocumentSelector-button" > + <FontAwesomeIcon icon={"eye"} size={"lg"} /> + </span> + </Flyout>} + </div>; + } +} diff --git a/src/client/views/nodes/LinkBox.scss b/src/client/views/nodes/LinkBox.scss new file mode 100644 index 000000000..b5b8e660f --- /dev/null +++ b/src/client/views/nodes/LinkBox.scss @@ -0,0 +1,3 @@ +.linkBox-container-interactive { + pointer-events: all; +}
\ No newline at end of file diff --git a/src/client/views/nodes/LinkBox.tsx b/src/client/views/nodes/LinkBox.tsx new file mode 100644 index 000000000..740f2ef04 --- /dev/null +++ b/src/client/views/nodes/LinkBox.tsx @@ -0,0 +1,36 @@ +import React = require("react"); +import { observer } from "mobx-react"; +import { documentSchema } from "../../../new_fields/documentSchemas"; +import { makeInterface, listSpec } from "../../../new_fields/Schema"; +import { returnFalse, returnZero } from "../../../Utils"; +import { CollectionTreeView } from "../collections/CollectionTreeView"; +import { ViewBoxBaseComponent } from "../DocComponent"; +import { FieldView, FieldViewProps } from './FieldView'; +import "./LinkBox.scss"; +import { Cast } from "../../../new_fields/Types"; + +type LinkDocument = makeInterface<[typeof documentSchema]>; +const LinkDocument = makeInterface(documentSchema); + +@observer +export class LinkBox extends ViewBoxBaseComponent<FieldViewProps, LinkDocument>(LinkDocument) { + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(LinkBox, fieldKey); } + render() { + return <div className={`linkBox-container${this.active() ? "-interactive" : ""}`} + style={{ background: this.props.backgroundColor?.(this.props.Document) }} > + + <CollectionTreeView {...this.props} + ChromeHeight={returnZero} + overrideDocuments={[this.dataDoc]} + NativeHeight={returnZero} + NativeWidth={returnZero} + ignoreFields={Cast(this.props.Document.linkBoxExcludedKeys, listSpec("string"), null)} + annotationsKey={""} + CollectionView={undefined} + addDocument={returnFalse} + removeDocument={returnFalse} + moveDocument={returnFalse}> + </CollectionTreeView> + </div>; + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/PDFBox.scss b/src/client/views/nodes/PDFBox.scss index c7d6f988c..6f18b1321 100644 --- a/src/client/views/nodes/PDFBox.scss +++ b/src/client/views/nodes/PDFBox.scss @@ -46,7 +46,27 @@ border-radius: 3px; pointer-events: all; } - } + } + .pdfBox-overlayButton-fwd, + .pdfBox-overlayButton-back { + background: #121721; + height: 25px; + width: 25px; + display: flex; + position: relative; + align-items: center; + justify-content: center; + border-radius: 3px; + pointer-events: all; + position: absolute; + top: 5; + } + .pdfBox-overlayButton-fwd { + left: 45; + } + .pdfBox-overlayButton-back { + left: 25; + } .pdfBox-nextIcon, .pdfBox-prevIcon { @@ -178,10 +198,6 @@ } .pdfBox { - pointer-events: none; - .collectionFreeFormView-none { - pointer-events: none; - } .pdfViewer-text { .textLayer { span { diff --git a/src/client/views/nodes/PDFBox.tsx b/src/client/views/nodes/PDFBox.tsx index e1c5fd27f..3712c648e 100644 --- a/src/client/views/nodes/PDFBox.tsx +++ b/src/client/views/nodes/PDFBox.tsx @@ -9,25 +9,24 @@ import { ScriptField } from '../../../new_fields/ScriptField'; import { Cast, NumCast, StrCast } from "../../../new_fields/Types"; import { PdfField, URLField } from "../../../new_fields/URLField"; import { Utils } from '../../../Utils'; -import { KeyCodes } from '../../northstar/utils/KeyCodes'; import { undoBatch } from '../../util/UndoManager'; import { panZoomSchema } from '../collections/collectionFreeForm/CollectionFreeFormView'; import { ContextMenu } from '../ContextMenu'; import { ContextMenuProps } from '../ContextMenuItem'; -import { DocAnnotatableComponent } from "../DocComponent"; +import { ViewBoxAnnotatableComponent } from "../DocComponent"; import { PDFViewer } from "../pdf/PDFViewer"; import { FieldView, FieldViewProps } from './FieldView'; import { pageSchema } from "./ImageBox"; +import { KeyCodes } from '../../util/KeyCodes'; import "./PDFBox.scss"; import React = require("react"); import { documentSchema } from '../../../new_fields/documentSchemas'; -import { url } from 'inspector'; type PdfDocument = makeInterface<[typeof documentSchema, typeof panZoomSchema, typeof pageSchema]>; const PdfDocument = makeInterface(documentSchema, panZoomSchema, pageSchema); @observer -export class PDFBox extends DocAnnotatableComponent<FieldViewProps, PdfDocument>(PdfDocument) { +export class PDFBox extends ViewBoxAnnotatableComponent<FieldViewProps, PdfDocument>(PdfDocument) { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(PDFBox, fieldKey); } private _keyValue: string = ""; private _valueValue: string = ""; @@ -52,11 +51,11 @@ export class PDFBox extends DocAnnotatableComponent<FieldViewProps, PdfDocument> this._initialScale = this.props.ScreenToLocalTransform().Scale; const nw = this.Document._nativeWidth = NumCast(this.dataDoc[this.props.fieldKey + "-nativeWidth"], NumCast(this.Document._nativeWidth, 927)); const nh = this.Document._nativeHeight = NumCast(this.dataDoc[this.props.fieldKey + "-nativeHeight"], NumCast(this.Document._nativeHeight, 1200)); - !this.Document._fitWidth && !this.Document.ignoreAspect && (this.Document._height = this.Document[WidthSym]() * (nh / nw)); + !this.Document._fitWidth && (this.Document._height = this.Document[WidthSym]() * (nh / nw)); const backup = "oldPath"; const { Document } = this.props; - const { url: { href } } = Cast(Document[this.props.fieldKey], PdfField)!; + const { url: { href } } = Cast(this.dataDoc[this.props.fieldKey], PdfField)!; const pathCorrectionTest = /upload\_[a-z0-9]{32}.(.*)/g; const matches = pathCorrectionTest.exec(href); console.log("\nHere's the { url } being fed into the outer regex:"); @@ -78,9 +77,7 @@ export class PDFBox extends DocAnnotatableComponent<FieldViewProps, PdfDocument> } } - componentWillUnmount() { - this._selectReactionDisposer && this._selectReactionDisposer(); - } + componentWillUnmount() { this._selectReactionDisposer?.(); } componentDidMount() { this._selectReactionDisposer = reaction(() => this.props.isSelected(), () => { @@ -93,14 +90,14 @@ export class PDFBox extends DocAnnotatableComponent<FieldViewProps, PdfDocument> this.dataDoc[this.props.fieldKey + "-numPages"] = np; this.dataDoc[this.props.fieldKey + "-nativeWidth"] = this.Document._nativeWidth = nw * 96 / 72; this.dataDoc[this.props.fieldKey + "-nativeHeight"] = this.Document._nativeHeight = nh * 96 / 72; - !this.Document._fitWidth && !this.Document.ignoreAspect && (this.Document._height = this.Document[WidthSym]() * (nh / nw)); + !this.Document._fitWidth && (this.Document._height = this.Document[WidthSym]() * (nh / nw)); } - public search(string: string, fwd: boolean) { this._pdfViewer && this._pdfViewer.search(string, fwd); } - public prevAnnotation() { this._pdfViewer && this._pdfViewer.prevAnnotation(); } - public nextAnnotation() { this._pdfViewer && this._pdfViewer.nextAnnotation(); } - public backPage() { this._pdfViewer!.gotoPage((this.Document.curPage || 1) - 1); } - public forwardPage() { this._pdfViewer!.gotoPage((this.Document.curPage || 1) + 1); } + public search = (string: string, fwd: boolean) => { this._pdfViewer?.search(string, fwd); }; + public prevAnnotation = () => { this._pdfViewer?.prevAnnotation(); }; + public nextAnnotation = () => { this._pdfViewer?.nextAnnotation(); }; + public backPage = () => { this._pdfViewer!.gotoPage((this.Document.curPage || 1) - 1); }; + public forwardPage = () => { this._pdfViewer!.gotoPage((this.Document.curPage || 1) + 1); }; public gotoPage = (p: number) => { this._pdfViewer!.gotoPage(p); }; @undoBatch @@ -140,12 +137,12 @@ export class PDFBox extends DocAnnotatableComponent<FieldViewProps, PdfDocument> settingsPanel() { const pageBtns = <> - <button className="pdfBox-overlayButton-iconCont" key="back" title="Page Back" - onPointerDown={e => e.stopPropagation()} onClick={e => this.backPage()} style={{ left: 45, top: 5 }}> + <button className="pdfBox-overlayButton-back" key="back" title="Page Back" + onPointerDown={e => e.stopPropagation()} onClick={e => this.backPage()} > <FontAwesomeIcon style={{ color: "white" }} icon={"arrow-left"} size="sm" /> </button> - <button className="pdfBox-overlayButton-iconCont" key="fwd" title="Page Forward" - onPointerDown={e => e.stopPropagation()} onClick={e => this.forwardPage()} style={{ left: 45, top: 5 }}> + <button className="pdfBox-overlayButton-fwd" key="fwd" title="Page Forward" + onPointerDown={e => e.stopPropagation()} onClick={e => this.forwardPage()} > <FontAwesomeIcon style={{ color: "white" }} icon={"arrow-right"} size="sm" /> </button> </>; @@ -213,7 +210,7 @@ export class PDFBox extends DocAnnotatableComponent<FieldViewProps, PdfDocument> pdfUrl && funcs.push({ description: "Copy path", event: () => Utils.CopyText(pdfUrl.url.pathname), icon: "expand-arrows-alt" }); funcs.push({ description: "Toggle Fit Width " + (this.Document._fitWidth ? "Off" : "On"), event: () => this.Document._fitWidth = !this.Document._fitWidth, icon: "expand-arrows-alt" }); - ContextMenu.Instance.addItem({ description: "Pdf Funcs...", subitems: funcs, icon: "asterisk" }); + ContextMenu.Instance.addItem({ description: "Options...", subitems: funcs, icon: "asterisk" }); } @computed get contentScaling() { return this.props.ContentScaling(); } @@ -233,7 +230,7 @@ export class PDFBox extends DocAnnotatableComponent<FieldViewProps, PdfDocument> isChildActive = (outsideReaction?: boolean) => this._isChildActive; @computed get renderPdfView() { const pdfUrl = Cast(this.dataDoc[this.props.fieldKey], PdfField); - return <div className={"pdfBox"} onContextMenu={this.specificContextMenu}> + return <div className={"pdfBox"} onContextMenu={this.specificContextMenu} style={{ height: this.props.Document._scrollTop && !this.Document._fitWidth ? NumCast(this.Document._height) * this.props.PanelWidth() / NumCast(this.Document._width) : undefined }}> <PDFViewer {...this.props} pdf={this._pdf!} url={pdfUrl!.url.pathname} active={this.props.active} loaded={this.loaded} setPdfViewer={this.setPdfViewer} ContainingCollectionView={this.props.ContainingCollectionView} renderDepth={this.props.renderDepth} PanelHeight={this.props.PanelHeight} PanelWidth={this.props.PanelWidth} @@ -243,7 +240,7 @@ export class PDFBox extends DocAnnotatableComponent<FieldViewProps, PdfDocument> ScreenToLocalTransform={this.props.ScreenToLocalTransform} select={this.props.select} isSelected={this.props.isSelected} whenActiveChanged={this.whenActiveChanged} isChildActive={this.isChildActive} - fieldKey={this.props.fieldKey} startupLive={this._initialScale < 2.5 ? true : false} /> + fieldKey={this.props.fieldKey} startupLive={this._initialScale < 2.5 || this.props.Document._scrollTop ? true : false} /> {this.settingsPanel()} </div>; } @@ -251,15 +248,15 @@ export class PDFBox extends DocAnnotatableComponent<FieldViewProps, PdfDocument> _pdfjsRequested = false; render() { const pdfUrl = Cast(this.dataDoc[this.props.fieldKey], PdfField, null); - if (this.props.isSelected() || this.props.Document.scrollY !== undefined) this._everActive = true; - if (pdfUrl && (this._everActive || (this.dataDoc[this.props.fieldKey + "-nativeWidth"] && this.props.ScreenToLocalTransform().Scale < 2.5))) { + if (this.props.isSelected() || this.props.renderDepth <= 1 || this.props.Document.scrollY !== undefined) this._everActive = true; + if (pdfUrl && (this._everActive || this.props.Document._scrollTop || (this.dataDoc[this.props.fieldKey + "-nativeWidth"] && this.props.ScreenToLocalTransform().Scale < 2.5))) { if (pdfUrl instanceof PdfField && this._pdf) { return this.renderPdfView; } if (!this._pdfjsRequested) { this._pdfjsRequested = true; const promise = Pdfjs.getDocument(pdfUrl.url.href).promise; - promise.then(pdf => { runInAction(() => { this._pdf = pdf; console.log("promise"); }) }); + promise.then(action(pdf => { this._pdf = pdf; console.log("promise"); })); } } diff --git a/src/client/views/nodes/PresBox.scss b/src/client/views/nodes/PresBox.scss index 7618aa7e3..78c19f351 100644 --- a/src/client/views/nodes/PresBox.scss +++ b/src/client/views/nodes/PresBox.scss @@ -2,27 +2,40 @@ position: absolute; z-index: 2; box-shadow: #AAAAAA .2vw .2vw .4vw; - right: 0; - top: 0; bottom: 0; width: 100%; min-width: 120px; height: 100%; - min-height: 50px; + min-height: 41px; letter-spacing: 2px; overflow: hidden; transition: 0.7s opacity ease; - pointer-events: all; + .presBox-listCont { + position: absolute; + height: calc(100% - 25px); + width: 100%; + } .presBox-buttons { padding: 10px; width: 100%; + background: gray; + padding-right: 10px; + padding-top: 5px; + padding-bottom: 5px; .presBox-button { margin-right: 2.5%; margin-left: 2.5%; width: 20%; border-radius: 5px; } + .collectionViewBaseChrome-viewPicker { + min-width: 50; + width: 5%; + height: 25; + position: relative; + display: inline-block; + } } .presBox-backward, .presBox-forward { width: 25px; diff --git a/src/client/views/nodes/PresBox.tsx b/src/client/views/nodes/PresBox.tsx index 6c4cbba12..80d043db1 100644 --- a/src/client/views/nodes/PresBox.tsx +++ b/src/client/views/nodes/PresBox.tsx @@ -1,135 +1,91 @@ import React = require("react"); import { library } from '@fortawesome/fontawesome-svg-core'; -import { faArrowLeft, faArrowRight, faEdit, faMinus, faPlay, faPlus, faStop, faTimes } from '@fortawesome/free-solid-svg-icons'; +import { faArrowLeft, faArrowRight, faEdit, faMinus, faPlay, faPlus, faStop, faHandPointLeft, faTimes } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { action, computed, IReactionDisposer, reaction, observable, runInAction } from "mobx"; +import { action, computed, IReactionDisposer, observable, reaction, runInAction } from "mobx"; import { observer } from "mobx-react"; -import { Doc, DocListCast, DocListCastAsync } from "../../../new_fields/Doc"; -import { listSpec, makeInterface } from "../../../new_fields/Schema"; -import { Cast, FieldValue, NumCast } from "../../../new_fields/Types"; -import { CurrentUserUtils } from "../../../server/authentication/models/current_user_utils"; -import { Docs } from "../../documents/Documents"; +import { Doc, DocListCast, DocCastAsync } from "../../../new_fields/Doc"; +import { InkTool } from "../../../new_fields/InkField"; +import { BoolCast, Cast, FieldValue, NumCast, StrCast } from "../../../new_fields/Types"; +import { returnFalse } from "../../../Utils"; +import { documentSchema } from "../../../new_fields/documentSchemas"; import { DocumentManager } from "../../util/DocumentManager"; import { undoBatch } from "../../util/UndoManager"; import { CollectionDockingView } from "../collections/CollectionDockingView"; import { CollectionView, CollectionViewType } from "../collections/CollectionView"; -import { ContextMenu } from "../ContextMenu"; +import { InkingControl } from "../InkingControl"; import { FieldView, FieldViewProps } from './FieldView'; import "./PresBox.scss"; -import { CollectionCarouselView } from "../collections/CollectionCarouselView"; -import { returnFalse } from "../../../Utils"; -import { ContextMenuProps } from "../ContextMenuItem"; -import { CollectionTimeView } from "../collections/CollectionTimeView"; -import { documentSchema } from "../../../new_fields/documentSchemas"; -import { InkingControl } from "../InkingControl"; -import { InkTool } from "../../../new_fields/InkField"; +import { ViewBoxBaseComponent } from "../DocComponent"; +import { makeInterface } from "../../../new_fields/Schema"; library.add(faArrowLeft); library.add(faArrowRight); library.add(faPlay); library.add(faStop); +library.add(faHandPointLeft); library.add(faPlus); library.add(faTimes); library.add(faMinus); library.add(faEdit); +type PresBoxSchema = makeInterface<[typeof documentSchema]>; +const PresBoxDocument = makeInterface(documentSchema); + @observer -export class PresBox extends React.Component<FieldViewProps> { +export class PresBox extends ViewBoxBaseComponent<FieldViewProps, PresBoxSchema>(PresBoxDocument) { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(PresBox, fieldKey); } _childReaction: IReactionDisposer | undefined; - _slideshowReaction: IReactionDisposer | undefined; @observable _isChildActive = false; - componentDidMount() { - const userDoc = CurrentUserUtils.UserDocument; - this._slideshowReaction = reaction(() => this.props.Document._slideshow, - (slideshow) => { - if (!slideshow) { - let presTemp = Cast(userDoc.presentationTemplate, Doc); - if (presTemp instanceof Promise) { - presTemp.then(presTemp => this.props.Document.childLayout = presTemp); - } - else if (presTemp === undefined) { - presTemp = userDoc.presentationTemplate = Docs.Create.PresElementBoxDocument({ backgroundColor: "transparent", _xMargin: 5, isTemplateDoc: true, isTemplateForField: "data" }); - } - else { - this.props.Document.childLayout = presTemp; - } - } else { - this.props.Document.childLayout = undefined; - } - }, { fireImmediately: true }); + this.layoutDoc._forceRenderEngine = "timeline"; + this.layoutDoc._replacedChrome = "replaced"; this._childReaction = reaction(() => this.childDocs.slice(), (children) => children.forEach((child, i) => child.presentationIndex = i), { fireImmediately: true }); } componentWillUnmount() { this._childReaction?.(); - this._slideshowReaction?.(); } - @computed get childDocs() { return DocListCast(this.props.Document[this.props.fieldKey]); } + @computed get childDocs() { return DocListCast(this.dataDoc[this.fieldKey]); } + @computed get currentIndex() { return NumCast(this.layoutDoc._itemIndex); } + + updateCurrentPresentation = action(() => Doc.UserDoc().activePresentation = this.rootDoc); - next = async () => { - runInAction(() => Doc.UserDoc().curPresentation = this.props.Document); - const current = NumCast(this.props.Document._itemIndex); - //asking to get document at current index - const docAtCurrentNext = await this.getDocAtIndex(current + 1); - if (docAtCurrentNext !== undefined) { - const presDocs = DocListCast(this.props.Document[this.props.fieldKey]); - let nextSelected = current + 1; + next = () => { + this.updateCurrentPresentation(); + if (this.childDocs[this.currentIndex + 1] !== undefined) { + let nextSelected = this.currentIndex + 1; + this.gotoDocument(nextSelected, this.currentIndex); - for (; nextSelected < presDocs.length - 1; nextSelected++) { - if (!presDocs[nextSelected + 1].groupButton) { + for (nextSelected = nextSelected + 1; nextSelected < this.childDocs.length; nextSelected++) { + if (!this.childDocs[nextSelected].groupButton) { break; + } else { + this.gotoDocument(nextSelected, this.currentIndex); } } - - this.gotoDocument(nextSelected, current); } } - back = async () => { - action(() => Doc.UserDoc().curPresentation = this.props.Document); - const current = NumCast(this.props.Document._itemIndex); - //requesting for the doc at current index - const docAtCurrent = await this.getDocAtIndex(current); - if (docAtCurrent !== undefined) { - - //asking for its presentation id. - let prevSelected = current; - let zoomOut: boolean = false; - - const presDocs = await DocListCastAsync(this.props.Document[this.props.fieldKey]); - const currentsArray: Doc[] = []; - for (; presDocs && prevSelected > 0 && presDocs[prevSelected].groupButton; prevSelected--) { - currentsArray.push(presDocs[prevSelected]); + back = () => { + this.updateCurrentPresentation(); + const docAtCurrent = this.childDocs[this.currentIndex]; + if (docAtCurrent) { + //check if any of the group members had used zooming in including the current document + //If so making sure to zoom out, which goes back to state before zooming action + let prevSelected = this.currentIndex; + let didZoom = docAtCurrent.zoomButton; + for (; !didZoom && prevSelected > 0 && this.childDocs[prevSelected].groupButton; prevSelected--) { + didZoom = this.childDocs[prevSelected].zoomButton; } prevSelected = Math.max(0, prevSelected - 1); - //checking if any of the group members had used zooming in - currentsArray.forEach((doc: Doc) => { - if (doc.showButton) { - zoomOut = true; - return; - } - }); - - // if a group set that flag to zero or a single element - //If so making sure to zoom out, which goes back to state before zooming action - if (current > 0) { - if (zoomOut || docAtCurrent.showButton) { - const prevScale = NumCast(this.childDocs[prevSelected].viewScale, null); - const curScale = DocumentManager.Instance.getScaleOfDocView(this.childDocs[current]); - if (prevScale !== undefined && prevScale !== curScale) { - DocumentManager.Instance.zoomIntoScale(docAtCurrent, prevScale); - } - } - } - this.gotoDocument(prevSelected, current); + this.gotoDocument(prevSelected, this.currentIndex); } } whenActiveChanged = action((isActive: boolean) => this.props.whenActiveChanged(this._isChildActive = isActive)); - active = (outsideReaction?: boolean) => ((InkingControl.Instance.selectedTool === InkTool.None && !this.props.Document.isBackground) && - (this.props.Document.forceActive || this.props.isSelected(outsideReaction) || this._isChildActive || this.props.renderDepth === 0) ? true : false) + active = (outsideReaction?: boolean) => ((InkingControl.Instance.selectedTool === InkTool.None && !this.layoutDoc.isBackground) && + (this.layoutDoc.forceActive || this.props.isSelected(outsideReaction) || this._isChildActive || this.props.renderDepth === 0) ? true : false) /** * This is the method that checks for the actions that need to be performed @@ -137,7 +93,7 @@ export class PresBox extends React.Component<FieldViewProps> { * Hide Until Presented, Hide After Presented, Fade After Presented */ showAfterPresented = (index: number) => { - action(() => Doc.UserDoc().curPresentation = this.props.Document); + this.updateCurrentPresentation(); this.childDocs.forEach((doc, ind) => { //the order of cases is aligned based on priority if (doc.hideTillShownButton && ind <= index) { @@ -158,7 +114,7 @@ export class PresBox extends React.Component<FieldViewProps> { * Hide Until Presented, Hide After Presented, Fade After Presented */ hideIfNotPresented = (index: number) => { - action(() => Doc.UserDoc().curPresentation = this.props.Document); + this.updateCurrentPresentation(); this.childDocs.forEach((key, ind) => { //the order of cases is aligned based on priority @@ -180,12 +136,11 @@ export class PresBox extends React.Component<FieldViewProps> { * te option open, navigates to that element. */ navigateToElement = async (curDoc: Doc, fromDocIndex: number) => { - action(() => Doc.UserDoc().curPresentation = this.props.Document); - const fromDoc = this.childDocs[fromDocIndex].presentationTargetDoc as Doc; + this.updateCurrentPresentation(); let docToJump = curDoc; let willZoom = false; - const presDocs = DocListCast(this.props.Document[this.props.fieldKey]); + const presDocs = DocListCast(this.dataDoc[this.props.fieldKey]); let nextSelected = presDocs.indexOf(curDoc); const currentDocGroups: Doc[] = []; for (; nextSelected < presDocs.length - 1; nextSelected++) { @@ -200,231 +155,177 @@ export class PresBox extends React.Component<FieldViewProps> { docToJump = doc; willZoom = false; } - if (doc.showButton) { + if (doc.zoomButton) { docToJump = doc; willZoom = true; } }); //docToJump stayed same meaning, it was not in the group or was the last element in the group - const aliasOf = await Cast(docToJump.aliasOf, Doc); - const srcContext = aliasOf && await Cast(aliasOf.sourceContext, Doc); + const aliasOf = await DocCastAsync(docToJump.aliasOf); + const srcContext = aliasOf && await DocCastAsync(aliasOf.context); if (docToJump === curDoc) { //checking if curDoc has navigation open - const target = await Cast(curDoc.presentationTargetDoc, Doc); + const target = await DocCastAsync(curDoc.presentationTargetDoc); if (curDoc.navButton && target) { DocumentManager.Instance.jumpToDocument(target, false, undefined, srcContext); - } else if (curDoc.showButton && target) { - const curScale = DocumentManager.Instance.getScaleOfDocView(fromDoc); + } else if (curDoc.zoomButton && target) { //awaiting jump so that new scale can be found, since jumping is async await DocumentManager.Instance.jumpToDocument(target, true, undefined, srcContext); - curDoc.viewScale = DocumentManager.Instance.getScaleOfDocView(target); - - //saving the scale user was on before zooming in - if (curScale !== 1) { - fromDoc.viewScale = curScale; - } - } - return; - } - const curScale = DocumentManager.Instance.getScaleOfDocView(fromDoc); - - //awaiting jump so that new scale can be found, since jumping is async - const presTargetDoc = await docToJump.presentationTargetDoc as Doc; - await DocumentManager.Instance.jumpToDocument(presTargetDoc, willZoom, undefined, srcContext); - const newScale = DocumentManager.Instance.getScaleOfDocView(await curDoc.presentationTargetDoc as Doc); - curDoc.viewScale = newScale; - //saving the scale that user was on - if (curScale !== 1) { - fromDoc.viewScale = curScale; - } - - } - - /** - * Async function that supposedly return the doc that is located at given index. - */ - getDocAtIndex = async (index: number) => { - const list = FieldValue(Cast(this.props.Document[this.props.fieldKey], listSpec(Doc))); - if (list && index >= 0 && index < list.length) { - this.props.Document._itemIndex = index; - //awaiting async call to finish to get Doc instance - return list[index]; + } else { + //awaiting jump so that new scale can be found, since jumping is async + const presTargetDoc = await DocCastAsync(docToJump.presentationTargetDoc); + presTargetDoc && await DocumentManager.Instance.jumpToDocument(presTargetDoc, willZoom, undefined, srcContext); } - return undefined; } @undoBatch public removeDocument = (doc: Doc) => { - const value = FieldValue(Cast(this.props.Document[this.props.fieldKey], listSpec(Doc))); - if (value) { - const indexOfDoc = value.indexOf(doc); - if (indexOfDoc !== - 1) { - value.splice(indexOfDoc, 1)[0]; - return true; - } - } - return false; + return Doc.RemoveDocFromList(this.dataDoc, this.fieldKey, doc); } //The function that is called when a document is clicked or reached through next or back. //it'll also execute the necessary actions if presentation is playing. - public gotoDocument = async (index: number, fromDoc: number) => { - action(() => Doc.UserDoc().curPresentation = this.props.Document); + public gotoDocument = (index: number, fromDoc: number) => { + this.updateCurrentPresentation(); Doc.UnBrushAllDocs(); - const list = FieldValue(Cast(this.props.Document[this.props.fieldKey], listSpec(Doc))); - if (list && index >= 0 && index < list.length) { - this.props.Document._itemIndex = index; + if (index >= 0 && index < this.childDocs.length) { + this.layoutDoc._itemIndex = index; - if (!this.props.Document.presStatus) { - this.props.Document.presStatus = true; + if (!this.layoutDoc.presStatus) { + this.layoutDoc.presStatus = true; this.startPresentation(index); } - const doc = await list[index]; - if (this.props.Document.presStatus) { - this.navigateToElement(doc, fromDoc); - this.hideIfNotPresented(index); - this.showAfterPresented(index); - } + this.navigateToElement(this.childDocs[index], fromDoc); + this.hideIfNotPresented(index); + this.showAfterPresented(index); } } //The function that starts or resets presentaton functionally, depending on status flag. startOrResetPres = () => { - action(() => Doc.UserDoc().curPresentation = this.props.Document); - if (this.props.Document.presStatus) { + this.updateCurrentPresentation(); + if (this.layoutDoc.presStatus) { this.resetPresentation(); } else { - this.props.Document.presStatus = true; + this.layoutDoc.presStatus = true; this.startPresentation(0); - this.gotoDocument(0, NumCast(this.props.Document._itemIndex)); + this.gotoDocument(0, this.currentIndex); } } addDocument = (doc: Doc) => { const newPinDoc = Doc.MakeAlias(doc); newPinDoc.presentationTargetDoc = doc; - return Doc.AddDocToList(this.props.Document, this.props.fieldKey, newPinDoc); + return Doc.AddDocToList(this.dataDoc, this.fieldKey, newPinDoc); } //The function that resets the presentation by removing every action done by it. It also //stops the presentaton. resetPresentation = () => { - action(() => Doc.UserDoc().curPresentation = this.props.Document); - this.childDocs.forEach((doc: Doc) => { - doc.opacity = 1; - doc.viewScale = 1; - }); - this.props.Document._itemIndex = 0; - this.props.Document.presStatus = false; - if (this.childDocs.length !== 0) { - DocumentManager.Instance.zoomIntoScale(this.childDocs[0], 1); - } + this.updateCurrentPresentation(); + this.childDocs.forEach(doc => (doc.presentationTargetDoc as Doc).opacity = 1); + this.layoutDoc._itemIndex = 0; + this.layoutDoc.presStatus = false; } //The function that starts the presentation, also checking if actions should be applied //directly at start. startPresentation = (startIndex: number) => { - action(() => Doc.UserDoc().curPresentation = this.props.Document); + this.updateCurrentPresentation(); this.childDocs.map(doc => { if (doc.hideTillShownButton && this.childDocs.indexOf(doc) > startIndex) { - doc.opacity = 0; + (doc.presentationTargetDoc as Doc).opacity = 0; } if (doc.hideAfterButton && this.childDocs.indexOf(doc) < startIndex) { - doc.opacity = 0; + (doc.presentationTargetDoc as Doc).opacity = 0; } if (doc.fadeButton && this.childDocs.indexOf(doc) < startIndex) { - doc.opacity = 0.5; + (doc.presentationTargetDoc as Doc).opacity = 0.5; } }); } - toggleMinimize = undoBatch(action((e: React.PointerEvent) => { - if (this.props.Document.inOverlay) { - Doc.RemoveDocFromList((CurrentUserUtils.UserDocument.overlays as Doc), this.props.fieldKey, this.props.Document); - CollectionDockingView.AddRightSplit(this.props.Document, this.props.DataDoc); - this.props.Document.inOverlay = false; - } else { - this.props.Document.x = e.clientX + 25; - this.props.Document.y = e.clientY - 25; - this.props.addDocTab && this.props.addDocTab(this.props.Document, this.props.DataDoc, "close"); - Doc.AddDocToList((CurrentUserUtils.UserDocument.overlays as Doc), this.props.fieldKey, this.props.Document); + updateMinimize = undoBatch(action((e: React.ChangeEvent, mode: CollectionViewType) => { + if (BoolCast(this.layoutDoc.inOverlay) !== (mode === CollectionViewType.Invalid)) { + if (this.layoutDoc.inOverlay) { + Doc.RemoveDocFromList((Doc.UserDoc().myOverlayDocuments as Doc), undefined, this.rootDoc); + CollectionDockingView.AddRightSplit(this.rootDoc); + this.layoutDoc.inOverlay = false; + } else { + this.layoutDoc.x = this.props.ScreenToLocalTransform().inverse().transformPoint(0, 0)[0];// 500;//e.clientX + 25; + this.layoutDoc.y = this.props.ScreenToLocalTransform().inverse().transformPoint(0, 0)[1];////e.clientY - 25; + this.props.addDocTab?.(this.rootDoc, "close"); + Doc.AddDocToList((Doc.UserDoc().myOverlayDocuments as Doc), undefined, this.rootDoc); + } } })); - specificContextMenu = (e: React.MouseEvent): void => { - const funcs: ContextMenuProps[] = []; - funcs.push({ description: "Show as Slideshow", event: action(() => this.props.Document._slideshow = "slideshow"), icon: "asterisk" }); - funcs.push({ description: "Show as Timeline", event: action(() => this.props.Document._slideshow = "timeline"), icon: "asterisk" }); - funcs.push({ description: "Show as List", event: action(() => this.props.Document._slideshow = undefined), icon: "asterisk" }); - ContextMenu.Instance.addItem({ description: "Presentation Funcs...", subitems: funcs, icon: "asterisk" }); - } - - /** - * Initially every document starts with a viewScale 1, which means - * that they will be displayed in a canvas with scale 1. - */ - initializeScaleViews = (docList: Doc[], viewtype: number) => { - this.props.Document._chromeStatus = "disabled"; + initializeViewAliases = (docList: Doc[], viewtype: CollectionViewType) => { const hgt = (viewtype === CollectionViewType.Tree) ? 50 : 46; - docList.forEach((doc: Doc) => { - doc.presBox = this.props.Document; - doc.presBoxKey = this.props.fieldKey; - doc.collapsedHeight = hgt; - doc._nativeWidth = doc._nativeHeight = undefined; - const curScale = NumCast(doc.viewScale, null); - if (curScale === undefined) { - doc.viewScale = 1; - } + docList.forEach(doc => { + doc.presBox = this.rootDoc; // give contained documents a reference to the presentation + doc.collapsedHeight = hgt; // set the collpased height for documents based on the type of view (Tree or Stack) they will be displaye din }); } selectElement = (doc: Doc) => { - const index = DocListCast(this.props.Document[this.props.fieldKey]).indexOf(doc); - index !== -1 && this.gotoDocument(index, NumCast(this.props.Document._itemIndex)); + this.gotoDocument(this.childDocs.indexOf(doc), NumCast(this.layoutDoc._itemIndex)); } getTransform = () => { - return this.props.ScreenToLocalTransform().translate(0, -50);// listBox padding-left and pres-box-cont minHeight + return this.props.ScreenToLocalTransform().translate(-5, -65);// listBox padding-left and pres-box-cont minHeight } panelHeight = () => { return this.props.PanelHeight() - 20; } + + @undoBatch + viewChanged = action((e: React.ChangeEvent) => { + //@ts-ignore + this.layoutDoc._viewType = e.target.selectedOptions[0].value; + this.layoutDoc._viewType === CollectionViewType.Stacking && (this.layoutDoc._pivotField = undefined); // pivot field may be set by the user in timeline view (or some other way) -- need to reset it here + this.updateMinimize(e, StrCast(this.layoutDoc._viewType)); + }); + + childLayoutTemplate = () => this.layoutDoc._viewType === CollectionViewType.Stacking ? Cast(Doc.UserDoc()["template-presentation"], Doc, null) : undefined; render() { - this.initializeScaleViews(this.childDocs, NumCast(this.props.Document._viewType)); - return (this.props.Document._slideshow ? - <div className="presBox-cont" onContextMenu={this.specificContextMenu} style={{ pointerEvents: this.active() ? "all" : "none" }} > - {this.props.Document.inOverlay ? (null) : - <div className="presBox-listCont" > - {this.props.Document._slideshow === "slideshow" ? - <CollectionCarouselView {...this.props} PanelHeight={this.panelHeight} chromeCollapsed={true} annotationsKey={""} CollectionView={undefined} - moveDocument={returnFalse} - addDocument={this.addDocument} removeDocument={returnFalse} focus={this.selectElement} ScreenToLocalTransform={this.getTransform} /> - : - <CollectionTimeView {...this.props} PanelHeight={this.panelHeight} chromeCollapsed={true} annotationsKey={""} CollectionView={undefined} - moveDocument={returnFalse} - addDocument={this.addDocument} removeDocument={returnFalse} focus={this.selectElement} ScreenToLocalTransform={this.getTransform} /> - } - </div>} - <button className="presBox-backward" title="Back" onClick={this.back}><FontAwesomeIcon icon={"arrow-left"} /></button> - <button className="presBox-forward" title="Next" onClick={this.next}><FontAwesomeIcon icon={"arrow-right"} /></button> + const mode = StrCast(this.layoutDoc._viewType) as CollectionViewType; + this.initializeViewAliases(this.childDocs, mode); + return <div className="presBox-cont" style={{ minWidth: this.layoutDoc.inOverlay ? 240 : undefined, pointerEvents: this.active() || this.layoutDoc.inOverlay ? "all" : "none" }} > + <div className="presBox-buttons" style={{ display: this.layoutDoc._chromeStatus === "disabled" ? "none" : undefined }}> + <select className="collectionViewBaseChrome-viewPicker" + onPointerDown={e => e.stopPropagation()} + onChange={this.viewChanged} + value={mode}> + <option className="collectionViewBaseChrome-viewOption" onPointerDown={e => e.stopPropagation()} value={CollectionViewType.Invalid}>Min</option> + <option className="collectionViewBaseChrome-viewOption" onPointerDown={e => e.stopPropagation()} value={CollectionViewType.Stacking}>List</option> + <option className="collectionViewBaseChrome-viewOption" onPointerDown={e => e.stopPropagation()} value={CollectionViewType.Time}>Time</option> + <option className="collectionViewBaseChrome-viewOption" onPointerDown={e => e.stopPropagation()} value={CollectionViewType.Carousel}>Slides</option> + </select> + <button className="presBox-button" title="Back" onClick={this.back}><FontAwesomeIcon icon={"arrow-left"} /></button> + <button className="presBox-button" title={"Reset Presentation" + this.layoutDoc.presStatus ? "" : " From Start"} onClick={this.startOrResetPres}> + <FontAwesomeIcon icon={this.layoutDoc.presStatus ? "stop" : "play"} /> + </button> + <button className="presBox-button" title="Next" onClick={this.next}><FontAwesomeIcon icon={"arrow-right"} /></button> + </div> + <div className="presBox-listCont" > + {mode !== CollectionViewType.Invalid ? + <CollectionView {...this.props} + PanelHeight={this.panelHeight} + moveDocument={returnFalse} + childLayoutTemplate={this.childLayoutTemplate} + addDocument={this.addDocument} + removeDocument={returnFalse} + focus={this.selectElement} + ScreenToLocalTransform={this.getTransform} /> + : (null) + } </div> - : <div className="presBox-cont" onContextMenu={this.specificContextMenu}> - <div className="presBox-buttons"> - <button className="presBox-button" title="Back" onClick={this.back}><FontAwesomeIcon icon={"arrow-left"} /></button> - <button className="presBox-button" title={"Reset Presentation" + this.props.Document.presStatus ? "" : " From Start"} onClick={this.startOrResetPres}> - <FontAwesomeIcon icon={this.props.Document.presStatus ? "stop" : "play"} /> - </button> - <button className="presBox-button" title="Next" onClick={this.next}><FontAwesomeIcon icon={"arrow-right"} /></button> - <button className="presBox-button" title={this.props.Document.inOverlay ? "Expand" : "Minimize"} onClick={this.toggleMinimize}><FontAwesomeIcon icon={"eye"} /></button> - {this.props.Document.inOverlay ? (null) : - <div className="presBox-listCont"> - <CollectionView {...this.props} whenActiveChanged={this.whenActiveChanged} PanelHeight={this.panelHeight} addDocument={this.addDocument} focus={this.selectElement} ScreenToLocalTransform={this.getTransform} /> - </div>} - </div></div>); + </div>; } }
\ No newline at end of file diff --git a/src/client/views/nodes/QueryBox.scss b/src/client/views/nodes/QueryBox.scss index e69de29bb..b5f90aa1e 100644 --- a/src/client/views/nodes/QueryBox.scss +++ b/src/client/views/nodes/QueryBox.scss @@ -0,0 +1,5 @@ +.queryBox, .queryBox-dragging { + width: 100%; + height: 100%; + position: absolute; +}
\ No newline at end of file diff --git a/src/client/views/nodes/QueryBox.tsx b/src/client/views/nodes/QueryBox.tsx index 99b5810fc..76885eada 100644 --- a/src/client/views/nodes/QueryBox.tsx +++ b/src/client/views/nodes/QueryBox.tsx @@ -1,35 +1,41 @@ import React = require("react"); -import { library } from '@fortawesome/fontawesome-svg-core'; -import { faArrowLeft, faArrowRight, faEdit, faMinus, faPlay, faPlus, faStop, faTimes } from '@fortawesome/free-solid-svg-icons'; import { IReactionDisposer } from "mobx"; import { observer } from "mobx-react"; -import { FilterBox } from "../search/FilterBox"; +import { documentSchema } from "../../../new_fields/documentSchemas"; +import { Id } from '../../../new_fields/FieldSymbols'; +import { makeInterface, listSpec } from "../../../new_fields/Schema"; +import { StrCast, Cast } from "../../../new_fields/Types"; +import { SelectionManager } from "../../util/SelectionManager"; +import { ViewBoxAnnotatableComponent } from '../DocComponent'; +import { SearchBox } from "../search/SearchBox"; import { FieldView, FieldViewProps } from './FieldView'; -import "./PresBox.scss"; +import "./QueryBox.scss"; +import { List } from "../../../new_fields/List"; -library.add(faArrowLeft); -library.add(faArrowRight); -library.add(faPlay); -library.add(faStop); -library.add(faPlus); -library.add(faTimes); -library.add(faMinus); -library.add(faEdit); +type QueryDocument = makeInterface<[typeof documentSchema]>; +const QueryDocument = makeInterface(documentSchema); @observer -export class QueryBox extends React.Component<FieldViewProps> { +export class QueryBox extends ViewBoxAnnotatableComponent<FieldViewProps, QueryDocument>(QueryDocument) { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(QueryBox, fieldKey); } _docListChangedReaction: IReactionDisposer | undefined; componentDidMount() { } componentWillUnmount() { - this._docListChangedReaction && this._docListChangedReaction(); + this._docListChangedReaction?.(); } render() { - return <div style={{ width: "100%", height: "100%", position: "absolute", pointerEvents: "all" }}> - <FilterBox></FilterBox> - </div>; + const dragging = !SelectionManager.GetIsDragging() ? "" : "-dragging"; + return <div className={`queryBox${dragging}`} onWheel={(e) => e.stopPropagation()} > + <SearchBox + id={this.props.Document[Id]} + setSearchQuery={q => this.dataDoc.searchQuery = q} + searchQuery={StrCast(this.dataDoc.searchQuery)} + setSearchFileTypes={q => this.dataDoc.searchFileTypes = new List<string>(q)} + searchFileTypes={Cast(this.dataDoc.searchFileTypes, listSpec("string"), [])} + filterQquery={StrCast(this.dataDoc.filterQuery)} /> + </div >; } }
\ No newline at end of file diff --git a/src/client/views/nodes/RadialMenu.scss b/src/client/views/nodes/RadialMenu.scss index ce0c263ef..daa620d12 100644 --- a/src/client/views/nodes/RadialMenu.scss +++ b/src/client/views/nodes/RadialMenu.scss @@ -67,17 +67,4 @@ s margin-left: 5px; text-align: left; display: inline; //need this? -} - - - -.icon-background { - pointer-events: all; - height:100%; - margin-top: 15px; - background-color: transparent; - width: 35px; - text-align: center; - font-size: 20px; - margin-left: 5px; }
\ No newline at end of file diff --git a/src/client/views/nodes/RadialMenu.tsx b/src/client/views/nodes/RadialMenu.tsx index 9314a3899..ddfdb67b4 100644 --- a/src/client/views/nodes/RadialMenu.tsx +++ b/src/client/views/nodes/RadialMenu.tsx @@ -1,10 +1,9 @@ import React = require("react"); +import { action, computed, IReactionDisposer, observable, reaction, runInAction } from "mobx"; import { observer } from "mobx-react"; -import { action, observable, computed, IReactionDisposer, reaction, runInAction } from "mobx"; -import { RadialMenuItem, RadialMenuProps } from "./RadialMenuItem"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import Measure from "react-measure"; +import MobileInterface from "../../../mobile/MobileInterface"; import "./RadialMenu.scss"; +import { RadialMenuItem, RadialMenuProps } from "./RadialMenuItem"; @observer export class RadialMenu extends React.Component { @@ -23,13 +22,23 @@ export class RadialMenu extends React.Component { @observable private _mouseDown: boolean = false; private _reactionDisposer?: IReactionDisposer; + public used: boolean = false; + + + catchTouch = (te: React.TouchEvent) => { + console.log("caught"); + te.stopPropagation(); + te.preventDefault(); + } @action onPointerDown = (e: PointerEvent) => { this._mouseDown = true; this._mouseX = e.clientX; this._mouseY = e.clientY; + this.used = false; document.addEventListener("pointermove", this.onPointerMove); + } @observable @@ -42,7 +51,6 @@ export class RadialMenu extends React.Component { const deltX = this._mouseX - curX; const deltY = this._mouseY - curY; const scale = Math.hypot(deltY, deltX); - if (scale < 150 && scale > 50) { const rad = Math.atan2(deltY, deltX) + Math.PI; let closest = 0; @@ -62,6 +70,7 @@ export class RadialMenu extends React.Component { } @action onPointerUp = (e: PointerEvent) => { + this.used = true; this._mouseDown = false; const curX = e.clientX; const curY = e.clientY; @@ -83,6 +92,7 @@ export class RadialMenu extends React.Component { @action componentDidMount = () => { + console.log(this._pageX); document.addEventListener("pointerdown", this.onPointerDown); document.addEventListener("pointerup", this.onPointerUp); this.previewcircle(); @@ -98,7 +108,7 @@ export class RadialMenu extends React.Component { @observable private _pageX: number = 0; @observable private _pageY: number = 0; - @observable private _display: boolean = false; + @observable _display: boolean = false; @observable private _yRelativeToTop: boolean = true; @@ -124,35 +134,34 @@ export class RadialMenu extends React.Component { displayMenu = (x: number, y: number) => { //maxX and maxY will change if the UI/font size changes, but will work for any amount //of items added to the menu - - this._pageX = x; - this._pageY = y; + this._mouseX = x; + this._mouseY = y; this._shouldDisplay = true; } - - get pageX() { - const x = this._pageX; - if (x < 0) { - return 0; - } - const width = this._width; - if (x + width > window.innerWidth - RadialMenu.buffer) { - return window.innerWidth - RadialMenu.buffer - width; - } - return x; - } - - get pageY() { - const y = this._pageY; - if (y < 0) { - return 0; - } - const height = this._height; - if (y + height > window.innerHeight - RadialMenu.buffer) { - return window.innerHeight - RadialMenu.buffer - height; - } - return y; - } + // @computed + // get pageX() { + // const x = this._pageX; + // if (x < 0) { + // return 0; + // } + // const width = this._width; + // if (x + width > window.innerWidth - RadialMenu.buffer) { + // return window.innerWidth - RadialMenu.buffer - width; + // } + // return x; + // } + // @computed + // get pageY() { + // const y = this._pageY; + // if (y < 0) { + // return 0; + // } + // const height = this._height; + // if (y + height > window.innerHeight - RadialMenu.buffer) { + // return window.innerHeight - RadialMenu.buffer - height; + // } + // return y; + // } @computed get menuItems() { return this._items.map((item, index) => <RadialMenuItem {...item} key={item.description} closeMenu={this.closeMenu} max={this._items.length} min={index} selected={this._closest} />); @@ -166,7 +175,10 @@ export class RadialMenu extends React.Component { } @action - openMenu = () => { + openMenu = (x: number, y: number) => { + + this._pageX = x; + this._pageY = y; this._shouldDisplay; this._display = true; } @@ -204,15 +216,15 @@ export class RadialMenu extends React.Component { render() { - if (!this._display) { + if (!this._display || MobileInterface.Instance) { return null; } - const style = this._yRelativeToTop ? { left: this._mouseX - 150, top: this._mouseY - 150 } : - { left: this._mouseX - 150, top: this._mouseY - 150 }; + const style = this._yRelativeToTop ? { left: this._pageX - 130, top: this._pageY - 130 } : + { left: this._pageX - 130, top: this._pageY - 130 }; return ( - <div className="radialMenu-cont" style={style}> + <div className="radialMenu-cont" onTouchStart={this.catchTouch} style={style}> <canvas id="newCanvas" style={{ position: "absolute" }} height="300" width="300"> Your browser does not support the HTML5 canvas tag.</canvas> {this.menuItems} </div> diff --git a/src/client/views/nodes/RadialMenuItem.tsx b/src/client/views/nodes/RadialMenuItem.tsx index fdc732d3f..bd5b3bff4 100644 --- a/src/client/views/nodes/RadialMenuItem.tsx +++ b/src/client/views/nodes/RadialMenuItem.tsx @@ -44,12 +44,12 @@ export class RadialMenuItem extends React.Component<RadialMenuProps> { setcircle() { let circlemin = 0; - let circlemax = 1 + let circlemax = 1; this.props.min ? circlemin = this.props.min : null; this.props.max ? circlemax = this.props.max : null; if (document.getElementById("myCanvas") !== null) { - var c: any = document.getElementById("myCanvas"); - let color = "white" + const c: any = document.getElementById("myCanvas"); + let color = "white"; switch (circlemin % 3) { case 1: color = "#c2c2c5"; @@ -70,38 +70,38 @@ export class RadialMenuItem extends React.Component<RadialMenuProps> { } if (c.getContext) { - var ctx = c.getContext("2d"); + const ctx = c.getContext("2d"); ctx.beginPath(); ctx.arc(150, 150, 150, (circlemin / circlemax) * 2 * Math.PI, ((circlemin + 1) / circlemax) * 2 * Math.PI); ctx.arc(150, 150, 50, ((circlemin + 1) / circlemax) * 2 * Math.PI, (circlemin / circlemax) * 2 * Math.PI, true); ctx.fillStyle = color; - ctx.fill() + ctx.fill(); } } } calculatorx() { let circlemin = 0; - let circlemax = 1 + let circlemax = 1; this.props.min ? circlemin = this.props.min : null; this.props.max ? circlemax = this.props.max : null; - let avg = ((circlemin / circlemax) + ((circlemin + 1) / circlemax)) / 2; - let degrees = 360 * avg; - let x = 100 * Math.cos(degrees * Math.PI / 180); - let y = -125 * Math.sin(degrees * Math.PI / 180); + const avg = ((circlemin / circlemax) + ((circlemin + 1) / circlemax)) / 2; + const degrees = 360 * avg; + const x = 100 * Math.cos(degrees * Math.PI / 180); + const y = -125 * Math.sin(degrees * Math.PI / 180); return x; } calculatory() { let circlemin = 0; - let circlemax = 1 + let circlemax = 1; this.props.min ? circlemin = this.props.min : null; this.props.max ? circlemax = this.props.max : null; - let avg = ((circlemin / circlemax) + ((circlemin + 1) / circlemax)) / 2; - let degrees = 360 * avg; - let x = 125 * Math.cos(degrees * Math.PI / 180); - let y = -100 * Math.sin(degrees * Math.PI / 180); + const avg = ((circlemin / circlemax) + ((circlemin + 1) / circlemax)) / 2; + const degrees = 360 * avg; + const x = 125 * Math.cos(degrees * Math.PI / 180); + const y = -100 * Math.sin(degrees * Math.PI / 180); return y; } diff --git a/src/client/views/nodes/ScreenshotBox.scss b/src/client/views/nodes/ScreenshotBox.scss new file mode 100644 index 000000000..141960f60 --- /dev/null +++ b/src/client/views/nodes/ScreenshotBox.scss @@ -0,0 +1,50 @@ +.screenshotBox { + transform-origin: top left; + background: white; + color: black; + // .screenshotBox-viewer { + // opacity: 0.99; // hack! overcomes some kind of Chrome weirdness where buttons (e.g., snapshot) disappear at some point as the video is resized larger + // } + // .inkingCanvas-paths-markers { + // opacity : 0.4; // we shouldn't have to do this, but since chrome crawls to a halt with z-index unset in videoBox-content, this is a workaround + // } +} + +.screenshotBox-content, .screenshotBox-content-interactive, .screenshotBox-cont-fullScreen { + width: 100%; + z-index: -1; // 0; // logically this should be 0 (or unset) which would give us transparent brush strokes over videos. However, this makes Chrome crawl to a halt + position: absolute; +} + +.screenshotBox-content, .screenshotBox-content-interactive, .screenshotBox-content-fullScreen { + height: Auto; +} + +.screenshotBox-uiButtons { + background:dimgray; + border: orange solid 1px; + position: absolute; + right: 25; + top: 0; + width:50; + height: 25; + .screenshotBox-snapshot{ + color : white; + top :0px; + right : 5px; + position: absolute; + background-color:rgba(50, 50, 50, 0.2); + transform-origin: left top; + pointer-events:all; + } + + .screenshotBox-recorder{ + color : white; + top :0px; + left: 5px; + position: absolute; + background-color:rgba(50, 50, 50, 0.2); + transform-origin: left top; + pointer-events:all; + } +} diff --git a/src/client/views/nodes/ScreenshotBox.tsx b/src/client/views/nodes/ScreenshotBox.tsx new file mode 100644 index 000000000..125690dc7 --- /dev/null +++ b/src/client/views/nodes/ScreenshotBox.tsx @@ -0,0 +1,194 @@ +import React = require("react"); +import { library } from "@fortawesome/fontawesome-svg-core"; +import { faVideo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { action, computed, IReactionDisposer, observable, runInAction } from "mobx"; +import { observer } from "mobx-react"; +import * as rp from 'request-promise'; +import { documentSchema, positionSchema } from "../../../new_fields/documentSchemas"; +import { makeInterface } from "../../../new_fields/Schema"; +import { Cast, NumCast } from "../../../new_fields/Types"; +import { VideoField } from "../../../new_fields/URLField"; +import { emptyFunction, returnFalse, returnOne, Utils, returnZero } from "../../../Utils"; +import { Docs, DocUtils } from "../../documents/Documents"; +import { CollectionFreeFormView } from "../collections/collectionFreeForm/CollectionFreeFormView"; +import { ContextMenu } from "../ContextMenu"; +import { ContextMenuProps } from "../ContextMenuItem"; +import { ViewBoxBaseComponent } from "../DocComponent"; +import { InkingControl } from "../InkingControl"; +import { FieldView, FieldViewProps } from './FieldView'; +import "./ScreenshotBox.scss"; +const path = require('path'); + +type ScreenshotDocument = makeInterface<[typeof documentSchema, typeof positionSchema]>; +const ScreenshotDocument = makeInterface(documentSchema, positionSchema); + +library.add(faVideo); + +@observer +export class ScreenshotBox extends ViewBoxBaseComponent<FieldViewProps, ScreenshotDocument>(ScreenshotDocument) { + private _reactionDisposer?: IReactionDisposer; + private _videoRef: HTMLVideoElement | null = null; + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(ScreenshotBox, fieldKey); } + + public get player(): HTMLVideoElement | null { + return this._videoRef; + } + + videoLoad = () => { + const aspect = this.player!.videoWidth / this.player!.videoHeight; + const nativeWidth = (this.layoutDoc._nativeWidth || 0); + const nativeHeight = (this.layoutDoc._nativeHeight || 0); + if (!nativeWidth || !nativeHeight) { + if (!this.layoutDoc._nativeWidth) this.layoutDoc._nativeWidth = 400; + this.layoutDoc._nativeHeight = NumCast(this.layoutDoc._nativeWidth) / aspect; + this.layoutDoc._height = NumCast(this.layoutDoc._width) / aspect; + } + } + + @action public Snapshot() { + const width = NumCast(this.layoutDoc._width); + const height = NumCast(this.layoutDoc._height); + const canvas = document.createElement('canvas'); + canvas.width = 640; + canvas.height = 640 * NumCast(this.layoutDoc._nativeHeight) / NumCast(this.layoutDoc._nativeWidth, 1); + const ctx = canvas.getContext('2d');//draw image to canvas. scale to target dimensions + if (ctx) { + ctx.rect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = "blue"; + ctx.fill(); + this._videoRef && ctx.drawImage(this._videoRef, 0, 0, canvas.width, canvas.height); + } + + if (this._videoRef) { + //convert to desired file format + const dataUrl = canvas.toDataURL('image/png'); // can also use 'image/png' + // if you want to preview the captured image, + const filename = path.basename(encodeURIComponent("screenshot" + Utils.GenerateGuid().replace(/\..*$/, "").replace(" ", "_"))); + ScreenshotBox.convertDataUri(dataUrl, filename).then(returnedFilename => { + setTimeout(() => { + if (returnedFilename) { + const imageSummary = Docs.Create.ImageDocument(Utils.prepend(returnedFilename), { + x: NumCast(this.layoutDoc.x) + width, y: NumCast(this.layoutDoc.y), + _width: 150, _height: height / width * 150, title: "--screenshot--" + }); + this.props.addDocument?.(imageSummary); + } + }, 500); + }); + } + } + + componentDidMount() { + } + + componentWillUnmount() { + this._reactionDisposer && this._reactionDisposer(); + } + + @action + setVideoRef = (vref: HTMLVideoElement | null) => { + this._videoRef = vref; + } + + public static async convertDataUri(imageUri: string, returnedFilename: string) { + try { + const posting = Utils.prepend("/uploadURI"); + const returnedUri = await rp.post(posting, { + body: { + uri: imageUri, + name: returnedFilename + }, + json: true, + }); + return returnedUri; + + } catch (e) { + console.log(e); + } + } + @observable _screenCapture = false; + specificContextMenu = (e: React.MouseEvent): void => { + const field = Cast(this.dataDoc[this.fieldKey], VideoField); + if (field) { + const url = field.url.href; + const subitems: ContextMenuProps[] = []; + subitems.push({ description: "Take Snapshot", event: () => this.Snapshot(), icon: "expand-arrows-alt" }); + subitems.push({ + description: "Screen Capture", event: (async () => { + runInAction(() => this._screenCapture = !this._screenCapture); + this._videoRef!.srcObject = !this._screenCapture ? undefined : await (navigator.mediaDevices as any).getDisplayMedia({ video: true }); + }), icon: "expand-arrows-alt" + }); + ContextMenu.Instance.addItem({ description: "Options...", subitems: subitems, icon: "video" }); + } + } + + @computed get content() { + const interactive = InkingControl.Instance.selectedTool || !this.props.isSelected() ? "" : "-interactive"; + const style = "videoBox-content" + interactive; + return <video className={`${style}`} key="video" autoPlay={this._screenCapture} ref={this.setVideoRef} + style={{ width: this._screenCapture ? "100%" : undefined, height: this._screenCapture ? "100%" : undefined }} + onCanPlay={this.videoLoad} + controls={true} + onClick={e => e.preventDefault()}> + <source type="video/mp4" /> + Not supported. + </video>; + } + + toggleRecording = action(async () => { + this._screenCapture = !this._screenCapture; + this._videoRef!.srcObject = !this._screenCapture ? undefined : await (navigator.mediaDevices as any).getDisplayMedia({ video: true }); + }); + + private get uIButtons() { + return (<div className="screenshotBox-uiButtons"> + <div className="screenshotBox-recorder" key="snap" onPointerDown={this.toggleRecording} > + <FontAwesomeIcon icon="file" size="lg" /> + </div>, + <div className="screenshotBox-snapshot" key="snap" onPointerDown={this.onSnapshot} > + <FontAwesomeIcon icon="camera" size="lg" /> + </div> + </div>); + } + + onSnapshot = (e: React.PointerEvent) => { + this.Snapshot(); + e.stopPropagation(); + e.preventDefault(); + } + + + contentFunc = () => [this.content]; + render() { + return (<div className="videoBox" onContextMenu={this.specificContextMenu} + style={{ transform: `scale(${this.props.ContentScaling()})`, width: `${100 / this.props.ContentScaling()}%`, height: `${100 / this.props.ContentScaling()}%` }} > + <div className="videoBox-viewer" > + <CollectionFreeFormView {...this.props} + PanelHeight={this.props.PanelHeight} + PanelWidth={this.props.PanelWidth} + NativeHeight={returnZero} + NativeWidth={returnZero} + annotationsKey={""} + focus={this.props.focus} + isSelected={this.props.isSelected} + isAnnotationOverlay={true} + select={emptyFunction} + active={returnFalse} + ContentScaling={returnOne} + whenActiveChanged={emptyFunction} + removeDocument={returnFalse} + moveDocument={returnFalse} + addDocument={returnFalse} + CollectionView={undefined} + ScreenToLocalTransform={this.props.ScreenToLocalTransform} + renderDepth={this.props.renderDepth + 1} + ContainingCollectionDoc={this.props.ContainingCollectionDoc}> + {this.contentFunc} + </CollectionFreeFormView> + </div> + {this.props.isSelected() ? this.uIButtons : (null)} + </div >); + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/ScriptingBox.scss b/src/client/views/nodes/ScriptingBox.scss new file mode 100644 index 000000000..43695f00d --- /dev/null +++ b/src/client/views/nodes/ScriptingBox.scss @@ -0,0 +1,35 @@ +.scriptingBox-outerDiv { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + background-color: rgb(241, 239, 235); + padding: 10px; + .scriptingBox-inputDiv { + display: flex; + flex-direction: column; + height: calc(100% - 30px); + .scriptingBox-errorMessage { + overflow: auto; + } + .scripting-params { + background: "beige"; + } + .scriptingBox-textArea { + width: 100%; + height: 100%; + box-sizing: border-box; + resize: none; + padding: 7px; + } + } + + .scriptingBox-toolbar { + width: 100%; + height: 30px; + .scriptingBox-button { + width: 50% + } + } +} + diff --git a/src/client/views/nodes/ScriptingBox.tsx b/src/client/views/nodes/ScriptingBox.tsx new file mode 100644 index 000000000..c607d6614 --- /dev/null +++ b/src/client/views/nodes/ScriptingBox.tsx @@ -0,0 +1,98 @@ +import { action, observable, computed } from "mobx"; +import { observer } from "mobx-react"; +import * as React from "react"; +import { documentSchema } from "../../../new_fields/documentSchemas"; +import { createSchema, makeInterface, listSpec } from "../../../new_fields/Schema"; +import { ScriptField } from "../../../new_fields/ScriptField"; +import { StrCast, ScriptCast, Cast } from "../../../new_fields/Types"; +import { InteractionUtils } from "../../util/InteractionUtils"; +import { CompileScript, isCompileError, ScriptParam } from "../../util/Scripting"; +import { ViewBoxAnnotatableComponent } from "../DocComponent"; +import { EditableView } from "../EditableView"; +import { FieldView, FieldViewProps } from "../nodes/FieldView"; +import "./ScriptingBox.scss"; +import { OverlayView } from "../OverlayView"; +import { DocumentIconContainer } from "./DocumentIcon"; +import { List } from "../../../new_fields/List"; + +const ScriptingSchema = createSchema({}); +type ScriptingDocument = makeInterface<[typeof ScriptingSchema, typeof documentSchema]>; +const ScriptingDocument = makeInterface(ScriptingSchema, documentSchema); + +@observer +export class ScriptingBox extends ViewBoxAnnotatableComponent<FieldViewProps, ScriptingDocument>(ScriptingDocument) { + protected multiTouchDisposer?: InteractionUtils.MultiTouchEventDisposer | undefined; + public static LayoutString(fieldStr: string) { return FieldView.LayoutString(ScriptingBox, fieldStr); } + + _overlayDisposer?: () => void; + + @observable private _errorMessage: string = ""; + + @computed get rawScript() { return StrCast(this.dataDoc[this.props.fieldKey + "-rawScript"], StrCast(this.layoutDoc[this.props.fieldKey + "-rawScript"])); } + @computed get compileParams() { return Cast(this.dataDoc[this.props.fieldKey + "-params"], listSpec("string"), Cast(this.layoutDoc[this.props.fieldKey + "-params"], listSpec("string"), [])); } + set rawScript(value) { this.dataDoc[this.props.fieldKey + "-rawScript"] = value; } + set compileParams(value) { this.dataDoc[this.props.fieldKey + "-params"] = value; } + + @action + componentDidMount() { + this.rawScript = ScriptCast(this.dataDoc[this.props.fieldKey])?.script?.originalScript || this.rawScript; + } + + componentWillUnmount() { this._overlayDisposer?.(); } + + @action + onCompile = () => { + const params = this.compileParams.reduce((o: ScriptParam, p: string) => { o[p] = "any"; return o; }, {} as ScriptParam); + const result = CompileScript(this.rawScript, { + editable: true, + transformer: DocumentIconContainer.getTransformer(), + params, + typecheck: false + }); + this._errorMessage = isCompileError(result) ? result.errors.map(e => e.messageText).join("\n") : ""; + return this.dataDoc[this.props.fieldKey] = result.compiled ? new ScriptField(result) : undefined; + } + + @action + onRun = () => { + this.onCompile()?.script.run({}, err => this._errorMessage = err.map((e: any) => e.messageText).join("\n")); + } + + onFocus = () => { + this._overlayDisposer?.(); + this._overlayDisposer = OverlayView.Instance.addElement(<DocumentIconContainer />, { x: 0, y: 0 }); + } + + render() { + const params = <EditableView + contents={this.compileParams.join(" ")} + display={"block"} + maxHeight={72} + height={35} + fontSize={28} + GetValue={() => ""} + SetValue={value => { this.compileParams = new List<string>(value.split(" ").filter(s => s !== " ")); return true; }} + />; + return ( + <div className="scriptingBox-outerDiv" + onWheel={e => this.props.isSelected(true) && e.stopPropagation()}> + <div className="scriptingBox-inputDiv" + onPointerDown={e => this.props.isSelected(true) && e.stopPropagation()} > + <textarea className="scriptingBox-textarea" + placeholder="write your script here" + onChange={e => this.rawScript = e.target.value} + value={this.rawScript} + onFocus={this.onFocus} + onBlur={e => this._overlayDisposer?.()} /> + <div className="scriptingBox-errorMessage" style={{ background: this._errorMessage ? "red" : "" }}>{this._errorMessage}</div> + <div className="scriptingBox-params" >{params}</div> + </div> + {this.rootDoc.layout === "layout" ? <div></div> : (null)} + <div className="scriptingBox-toolbar"> + <button className="scriptingBox-button" onPointerDown={e => { this.onCompile(); e.stopPropagation(); }}>Compile</button> + <button className="scriptingBox-button" onPointerDown={e => { this.onRun(); e.stopPropagation(); }}>Run</button> + </div> + </div> + ); + } +} diff --git a/src/client/views/nodes/SliderBox-components.tsx b/src/client/views/nodes/SliderBox-components.tsx new file mode 100644 index 000000000..874a1108f --- /dev/null +++ b/src/client/views/nodes/SliderBox-components.tsx @@ -0,0 +1,256 @@ +import * as React from "react"; +import { SliderItem } from "react-compound-slider"; +import "./SliderBox-tooltip.css"; + +const { Component, Fragment } = React; + +// ******************************************************* +// TOOLTIP RAIL +// ******************************************************* +const railStyle: React.CSSProperties = { + position: "absolute", + width: "100%", + height: 40, + top: -13, + borderRadius: 7, + cursor: "pointer", + opacity: 0.3, + zIndex: 300, + border: "1px solid grey" +}; + +const railCenterStyle: React.CSSProperties = { + position: "absolute", + width: "100%", + height: 14, + borderRadius: 7, + cursor: "pointer", + pointerEvents: "none", + backgroundColor: "rgb(155,155,155)" +}; + +interface TooltipRailProps { + activeHandleID: string; + getRailProps: (props: object) => object; + getEventData: (e: Event) => object; +} + +export class TooltipRail extends Component<TooltipRailProps> { + state = { + value: null, + percent: null + }; + + static defaultProps = { + disabled: false + }; + + onMouseEnter = () => { + document.addEventListener("mousemove", this.onMouseMove); + } + + onMouseLeave = () => { + this.setState({ value: null, percent: null }); + document.removeEventListener("mousemove", this.onMouseMove); + } + + onMouseMove = (e: Event) => { + const { activeHandleID, getEventData } = this.props; + + if (activeHandleID) { + this.setState({ value: null, percent: null }); + } else { + this.setState(getEventData(e)); + } + } + + render() { + const { value, percent } = this.state; + const { activeHandleID, getRailProps } = this.props; + + return ( + <Fragment> + {!activeHandleID && value ? ( + <div + style={{ + left: `${percent}%`, + position: "absolute", + marginLeft: "-11px", + marginTop: "-35px" + }} + > + <div className="tooltip"> + <span className="tooltiptext">Value: {value}</span> + </div> + </div> + ) : null} + <div + style={railStyle} + {...getRailProps({ + onMouseEnter: this.onMouseEnter, + onMouseLeave: this.onMouseLeave + })} + /> + <div style={railCenterStyle} /> + </Fragment> + ); + } +} + +// ******************************************************* +// HANDLE COMPONENT +// ******************************************************* +interface HandleProps { + key: string; + handle: SliderItem; + isActive: Boolean; + disabled?: Boolean; + domain: number[]; + getHandleProps: (id: string, config: object) => object; +} + +export class Handle extends Component<HandleProps> { + static defaultProps = { + disabled: false + }; + + state = { + mouseOver: false + }; + + onMouseEnter = () => { + this.setState({ mouseOver: true }); + } + + onMouseLeave = () => { + this.setState({ mouseOver: false }); + } + + render() { + const { + domain: [min, max], + handle: { id, value, percent }, + isActive, + disabled, + getHandleProps + } = this.props; + const { mouseOver } = this.state; + + return ( + <Fragment> + {(mouseOver || isActive) && !disabled ? ( + <div + style={{ + left: `${percent}%`, + position: "absolute", + marginLeft: "-11px", + marginTop: "-35px" + }} + > + <div className="tooltip"> + <span className="tooltiptext">Value: {value}</span> + </div> + </div> + ) : null} + <div + role="slider" + aria-valuemin={min} + aria-valuemax={max} + aria-valuenow={value} + style={{ + left: `${percent}%`, + position: "absolute", + marginLeft: "-11px", + marginTop: "-6px", + zIndex: 400, + width: 24, + height: 24, + cursor: "pointer", + border: 0, + borderRadius: "50%", + boxShadow: "1px 1px 1px 1px rgba(0, 0, 0, 0.4)", + backgroundColor: disabled ? "#666" : "#3e1db3" + }} + {...getHandleProps(id, { + onMouseEnter: this.onMouseEnter, + onMouseLeave: this.onMouseLeave + })} + /> + </Fragment> + ); + } +} + +// ******************************************************* +// TRACK COMPONENT +// ******************************************************* +interface TrackProps { + source: SliderItem; + target: SliderItem; + disabled: Boolean; + getTrackProps: () => object; +} + +export function Track({ + source, + target, + getTrackProps, + disabled = false +}: TrackProps) { + return ( + <div + style={{ + position: "absolute", + height: 14, + zIndex: 1, + backgroundColor: disabled ? "#999" : "#3e1db3", + borderRadius: 7, + cursor: "pointer", + left: `${source.percent}%`, + width: `${target.percent - source.percent}%` + }} + {...getTrackProps()} + /> + ); +} + +// ******************************************************* +// TICK COMPONENT +// ******************************************************* +interface TickProps { + tick: SliderItem; + count: number; + format: (val: number) => string; +} + +const defaultFormat = (d: number) => `d`; + +export function Tick({ tick, count, format = defaultFormat }: TickProps) { + return ( + <div> + <div + style={{ + position: "absolute", + marginTop: 17, + width: 1, + height: 5, + backgroundColor: "rgb(200,200,200)", + left: `${tick.percent}%` + }} + /> + <div + style={{ + position: "absolute", + marginTop: 25, + fontSize: 10, + textAlign: "center", + marginLeft: `${-(100 / count) / 2}%`, + width: `${100 / count}%`, + left: `${tick.percent}%` + }} + > + {format(tick.value)} + </div> + </div> + ); +} diff --git a/src/client/views/nodes/SliderBox-tooltip.css b/src/client/views/nodes/SliderBox-tooltip.css new file mode 100644 index 000000000..8afde8eb5 --- /dev/null +++ b/src/client/views/nodes/SliderBox-tooltip.css @@ -0,0 +1,33 @@ +.tooltip { + position: relative; + display: inline-block; + border-bottom: 1px dotted #222; + margin-left: 22px; + } + + .tooltip .tooltiptext { + width: 100px; + background-color: #222; + color: #fff; + opacity: 0.8; + text-align: center; + border-radius: 6px; + padding: 5px 0; + position: absolute; + z-index: 1; + bottom: 150%; + left: 50%; + margin-left: -60px; + } + + .tooltip .tooltiptext::after { + content: ""; + position: absolute; + top: 100%; + left: 50%; + margin-left: -5px; + border-width: 5px; + border-style: solid; + border-color: #222 transparent transparent transparent; + } +
\ No newline at end of file diff --git a/src/client/views/nodes/SliderBox.scss b/src/client/views/nodes/SliderBox.scss new file mode 100644 index 000000000..78015bd70 --- /dev/null +++ b/src/client/views/nodes/SliderBox.scss @@ -0,0 +1,7 @@ +.sliderBox-outerDiv { + width: 100%; + height: 100%; + border-radius: inherit; + display: flex; + flex-direction: column; +}
\ No newline at end of file diff --git a/src/client/views/nodes/SliderBox.tsx b/src/client/views/nodes/SliderBox.tsx new file mode 100644 index 000000000..cb2526769 --- /dev/null +++ b/src/client/views/nodes/SliderBox.tsx @@ -0,0 +1,125 @@ +import { library } from '@fortawesome/fontawesome-svg-core'; +import { faEdit } from '@fortawesome/free-regular-svg-icons'; +import { runInAction } from 'mobx'; +import { observer } from 'mobx-react'; +import * as React from 'react'; +import { Handles, Rail, Slider, Ticks, Tracks } from 'react-compound-slider'; +import { documentSchema } from '../../../new_fields/documentSchemas'; +import { createSchema, makeInterface } from '../../../new_fields/Schema'; +import { ScriptField } from '../../../new_fields/ScriptField'; +import { Cast, NumCast, StrCast } from '../../../new_fields/Types'; +import { ContextMenu } from '../ContextMenu'; +import { ContextMenuProps } from '../ContextMenuItem'; +import { ViewBoxBaseComponent } from '../DocComponent'; +import { ScriptBox } from '../ScriptBox'; +import { FieldView, FieldViewProps } from './FieldView'; +import { Handle, Tick, TooltipRail, Track } from './SliderBox-components'; +import './SliderBox.scss'; + + +library.add(faEdit as any); + +const SliderSchema = createSchema({ + _sliderMin: "number", + _sliderMax: "number", + _sliderMinThumb: "number", + _sliderMaxThumb: "number", +}); + +type SliderDocument = makeInterface<[typeof SliderSchema, typeof documentSchema]>; +const SliderDocument = makeInterface(SliderSchema, documentSchema); + +@observer +export class SliderBox extends ViewBoxBaseComponent<FieldViewProps, SliderDocument>(SliderDocument) { + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(SliderBox, fieldKey); } + + get minThumbKey() { return this.fieldKey + "-minThumb"; } + get maxThumbKey() { return this.fieldKey + "-maxThumb"; } + get minKey() { return this.fieldKey + "-min"; } + get maxKey() { return this.fieldKey + "-max"; } + specificContextMenu = (e: React.MouseEvent): void => { + const funcs: ContextMenuProps[] = []; + funcs.push({ description: "Edit Thumb Change Script", icon: "edit", event: (obj: any) => ScriptBox.EditButtonScript("On Thumb Change ...", this.props.Document, "onThumbChange", obj.x, obj.y) }); + ContextMenu.Instance.addItem({ description: "Options...", subitems: funcs, icon: "asterisk" }); + } + onChange = (values: readonly number[]) => runInAction(() => { + this.dataDoc[this.minThumbKey] = values[0]; + this.dataDoc[this.maxThumbKey] = values[1]; + Cast(this.layoutDoc.onThumbChanged, ScriptField, null)?.script.run({ self: this.rootDoc, range: values, this: this.layoutDoc }); + }) + + render() { + const domain = [NumCast(this.layoutDoc[this.minKey]), NumCast(this.layoutDoc[this.maxKey])]; + const defaultValues = [NumCast(this.dataDoc[this.minThumbKey]), NumCast(this.dataDoc[this.maxThumbKey])]; + return domain[1] <= domain[0] ? (null) : ( + <div className="sliderBox-outerDiv" onContextMenu={this.specificContextMenu} onPointerDown={e => e.stopPropagation()} + style={{ boxShadow: this.layoutDoc.opacity === 0 ? undefined : StrCast(this.layoutDoc.boxShadow, "") }}> + <div className="sliderBox-mainButton" onContextMenu={this.specificContextMenu} style={{ + background: StrCast(this.layoutDoc.backgroundColor), color: StrCast(this.layoutDoc.color, "black"), + fontSize: NumCast(this.layoutDoc._fontSize), letterSpacing: StrCast(this.layoutDoc.letterSpacing) + }} > + <Slider + mode={2} + step={1} + domain={domain} + rootStyle={{ position: "relative", width: "100%" }} + onChange={this.onChange} + values={defaultValues} + > + + <Rail>{railProps => <TooltipRail {...railProps} />}</Rail> + <Handles> + {({ handles, activeHandleID, getHandleProps }) => ( + <div className="slider-handles"> + {handles.map((handle, i) => { + const value = i === 0 ? defaultValues[0] : defaultValues[1]; + return ( + <div title={String(value)}> + <Handle + key={handle.id} + handle={handle} + domain={domain} + isActive={handle.id === activeHandleID} + getHandleProps={getHandleProps} + /> + </div> + ); + })} + </div> + )} + </Handles> + <Tracks left={false} right={false}> + {({ tracks, getTrackProps }) => ( + <div className="slider-tracks"> + {tracks.map(({ id, source, target }) => ( + <Track + key={id} + source={source} + target={target} + disabled={false} + getTrackProps={getTrackProps} + /> + ))} + </div> + )} + </Tracks> + <Ticks count={5}> + {({ ticks }) => ( + <div className="slider-tracks"> + {ticks.map((tick) => ( + <Tick + key={tick.id} + tick={tick} + count={ticks.length} + format={(val: number) => val.toString()} + /> + ))} + </div> + )} + </Ticks> + </Slider> + </div> + </div> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/VideoBox.scss b/src/client/views/nodes/VideoBox.scss index fabbf5196..0c0854ac2 100644 --- a/src/client/views/nodes/VideoBox.scss +++ b/src/client/views/nodes/VideoBox.scss @@ -1,5 +1,4 @@ .videoBox { - pointer-events: all; transform-origin: top left; .videoBox-viewer { opacity: 0.99; // hack! overcomes some kind of Chrome weirdness where buttons (e.g., snapshot) disappear at some point as the video is resized larger @@ -24,9 +23,9 @@ height: 100%; } -.videoBox-content-interactive, .videoBox-content-fullScreen, .videoBox-content-YouTube-fullScreen { - pointer-events: all; -} +// .videoBox-content-interactive, .videoBox-content-fullScreen, .videoBox-content-YouTube-fullScreen { +// pointer-events: all; +// } .videoBox-time{ color : white; diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx index 49fe9929f..feee1ffac 100644 --- a/src/client/views/nodes/VideoBox.tsx +++ b/src/client/views/nodes/VideoBox.tsx @@ -9,14 +9,14 @@ import { Doc } from "../../../new_fields/Doc"; import { InkTool } from "../../../new_fields/InkField"; import { createSchema, makeInterface } from "../../../new_fields/Schema"; import { ScriptField } from "../../../new_fields/ScriptField"; -import { Cast, StrCast, NumCast } from "../../../new_fields/Types"; +import { Cast, StrCast } from "../../../new_fields/Types"; import { VideoField } from "../../../new_fields/URLField"; -import { Utils, emptyFunction, returnOne } from "../../../Utils"; +import { Utils, emptyFunction, returnOne, returnZero } from "../../../Utils"; import { Docs, DocUtils } from "../../documents/Documents"; import { CollectionFreeFormView } from "../collections/collectionFreeForm/CollectionFreeFormView"; import { ContextMenu } from "../ContextMenu"; import { ContextMenuProps } from "../ContextMenuItem"; -import { DocAnnotatableComponent } from "../DocComponent"; +import { ViewBoxAnnotatableComponent } from "../DocComponent"; import { DocumentDecorations } from "../DocumentDecorations"; import { InkingControl } from "../InkingControl"; import { FieldView, FieldViewProps } from './FieldView'; @@ -33,7 +33,7 @@ const VideoDocument = makeInterface(documentSchema, positionSchema, timeSchema); library.add(faVideo); @observer -export class VideoBox extends DocAnnotatableComponent<FieldViewProps, VideoDocument>(VideoDocument) { +export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps, VideoDocument>(VideoDocument) { static _youtubeIframeCounter: number = 0; private _reactionDisposer?: IReactionDisposer; private _youtubeReactionDisposer?: IReactionDisposer; @@ -55,14 +55,10 @@ export class VideoBox extends DocAnnotatableComponent<FieldViewProps, VideoDocum videoLoad = () => { const aspect = this.player!.videoWidth / this.player!.videoHeight; - const nativeWidth = (this.Document._nativeWidth || 0); - const nativeHeight = (this.Document._nativeHeight || 0); - if (!nativeWidth || !nativeHeight) { - if (!this.Document._nativeWidth) this.Document._nativeWidth = this.player!.videoWidth; - this.Document._nativeHeight = (this.Document._nativeWidth || 0) / aspect; - this.Document._height = (this.Document._width || 0) / aspect; - } - if (!this.Document.duration) this.Document.duration = this.player!.duration; + this.layoutDoc._nativeWidth = this.player!.videoWidth; + this.layoutDoc._nativeHeight = (this.layoutDoc._nativeWidth || 0) / aspect; + this.layoutDoc._height = (this.layoutDoc._width || 0) / aspect; + this.dataDoc[this.fieldKey + "-" + "duration"] = this.player!.duration; } @action public Play = (update: boolean = true) => { @@ -90,7 +86,7 @@ export class VideoBox extends DocAnnotatableComponent<FieldViewProps, VideoDocum @action public FullScreen() { this._fullScreen = true; this.player && this.player.requestFullscreen(); - this._youtubePlayer && this.props.addDocTab(this.props.Document, this.props.DataDoc, "inTab"); + this._youtubePlayer && this.props.addDocTab(this.rootDoc, "inTab"); } choosePath(url: string) { @@ -101,11 +97,11 @@ export class VideoBox extends DocAnnotatableComponent<FieldViewProps, VideoDocum } @action public Snapshot() { - const width = this.Document._width || 0; - const height = this.Document._height || 0; + const width = (this.layoutDoc._width || 0); + const height = (this.layoutDoc._height || 0); const canvas = document.createElement('canvas'); canvas.width = 640; - canvas.height = 640 * (this.Document._nativeHeight || 0) / (this.Document._nativeWidth || 1); + canvas.height = 640 * (this.layoutDoc._nativeHeight || 0) / (this.layoutDoc._nativeWidth || 1); const ctx = canvas.getContext('2d');//draw image to canvas. scale to target dimensions if (ctx) { ctx.rect(0, 0, canvas.width, canvas.height); @@ -116,25 +112,28 @@ export class VideoBox extends DocAnnotatableComponent<FieldViewProps, VideoDocum if (!this._videoRef) { // can't find a way to take snapshots of videos const b = Docs.Create.ButtonDocument({ - x: (this.Document.x || 0) + width, y: (this.Document.y || 0), - _width: 150, _height: 50, title: (this.Document.currentTimecode || 0).toString() + x: (this.layoutDoc.x || 0) + width, y: (this.layoutDoc.y || 1), + _width: 150, _height: 50, title: (this.layoutDoc.currentTimecode || 0).toString() }); - b.onClick = ScriptField.MakeScript(`this.currentTimecode = ${(this.Document.currentTimecode || 0)}`); + b.onClick = ScriptField.MakeScript(`this.currentTimecode = ${(this.layoutDoc.currentTimecode || 0)}`); } else { //convert to desired file format const dataUrl = canvas.toDataURL('image/png'); // can also use 'image/png' // if you want to preview the captured image, - const filename = path.basename(encodeURIComponent("snapshot" + StrCast(this.Document.title).replace(/\..*$/, "") + "_" + (this.Document.currentTimecode || 0).toString().replace(/\./, "_"))); + const filename = path.basename(encodeURIComponent("snapshot" + StrCast(this.rootDoc.title).replace(/\..*$/, "") + "_" + (this.layoutDoc.currentTimecode || 0).toString().replace(/\./, "_"))); VideoBox.convertDataUri(dataUrl, filename).then(returnedFilename => { if (returnedFilename) { const url = this.choosePath(Utils.prepend(returnedFilename)); const imageSummary = Docs.Create.ImageDocument(url, { - x: (this.Document.x || 0) + width, y: (this.Document.y || 0), - _width: 150, _height: height / width * 150, title: "--snapshot" + (this.Document.currentTimecode || 0) + " image-" + _nativeWidth: this.layoutDoc._nativeWidth, _nativeHeight: this.layoutDoc._nativeHeight, + x: (this.layoutDoc.x || 0) + width, y: (this.layoutDoc.y || 0), + _width: 150, _height: height / width * 150, title: "--snapshot" + (this.layoutDoc.currentTimecode || 0) + " image-" }); - imageSummary.isButton = true; + Doc.GetProto(imageSummary)["data-nativeWidth"] = this.layoutDoc._nativeWidth; + Doc.GetProto(imageSummary)["data-nativeHeight"] = this.layoutDoc._nativeHeight; + imageSummary.isLinkButton = true; this.props.addDocument && this.props.addDocument(imageSummary); - DocUtils.MakeLink({ doc: imageSummary }, { doc: this.props.Document }, "snapshot from " + this.Document.title, "video frame snapshot"); + DocUtils.MakeLink({ doc: imageSummary }, { doc: this.rootDoc }, "video snapshot"); } }); } @@ -142,8 +141,8 @@ export class VideoBox extends DocAnnotatableComponent<FieldViewProps, VideoDocum @action updateTimecode = () => { - this.player && (this.Document.currentTimecode = this.player.currentTime); - this._youtubePlayer && (this.Document.currentTimecode = this._youtubePlayer.getCurrentTime()); + this.player && (this.layoutDoc.currentTimecode = this.player.currentTime); + this._youtubePlayer && (this.layoutDoc.currentTimecode = this._youtubePlayer.getCurrentTime()); } componentDidMount() { @@ -151,12 +150,12 @@ export class VideoBox extends DocAnnotatableComponent<FieldViewProps, VideoDocum if (this.youtubeVideoId) { const youtubeaspect = 400 / 315; - const nativeWidth = (this.Document._nativeWidth || 0); - const nativeHeight = (this.Document._nativeHeight || 0); + const nativeWidth = (this.layoutDoc._nativeWidth || 0); + const nativeHeight = (this.layoutDoc._nativeHeight || 0); if (!nativeWidth || !nativeHeight) { - if (!this.Document._nativeWidth) this.Document._nativeWidth = 600; - this.Document._nativeHeight = (this.Document._nativeWidth || 0) / youtubeaspect; - this.Document._height = (this.Document._width || 0) / youtubeaspect; + if (!this.layoutDoc._nativeWidth) this.layoutDoc._nativeWidth = 600; + this.layoutDoc._nativeHeight = (this.layoutDoc._nativeWidth || 0) / youtubeaspect; + this.layoutDoc._height = (this.layoutDoc._width || 0) / youtubeaspect; } } @@ -176,7 +175,7 @@ export class VideoBox extends DocAnnotatableComponent<FieldViewProps, VideoDocum this._videoRef!.ontimeupdate = this.updateTimecode; vref.onfullscreenchange = action((e) => this._fullScreen = vref.webkitDisplayingFullscreen); this._reactionDisposer && this._reactionDisposer(); - this._reactionDisposer = reaction(() => this.Document.currentTimecode || 0, + this._reactionDisposer = reaction(() => (this.layoutDoc.currentTimecode || 0), time => !this._playing && (vref.currentTime = time), { fireImmediately: true }); } } @@ -197,6 +196,7 @@ export class VideoBox extends DocAnnotatableComponent<FieldViewProps, VideoDocum console.log(e); } } + @observable _screenCapture = false; specificContextMenu = (e: React.MouseEvent): void => { const field = Cast(this.dataDoc[this.props.fieldKey], VideoField); if (field) { @@ -205,17 +205,29 @@ export class VideoBox extends DocAnnotatableComponent<FieldViewProps, VideoDocum subitems.push({ description: "Copy path", event: () => { Utils.CopyText(url); }, icon: "expand-arrows-alt" }); subitems.push({ description: "Toggle Show Controls", event: action(() => VideoBox._showControls = !VideoBox._showControls), icon: "expand-arrows-alt" }); subitems.push({ description: "Take Snapshot", event: () => this.Snapshot(), icon: "expand-arrows-alt" }); - ContextMenu.Instance.addItem({ description: "Video Funcs...", subitems: subitems, icon: "video" }); + subitems.push({ + description: "Screen Capture", event: (async () => { + runInAction(() => this._screenCapture = !this._screenCapture); + this._videoRef!.srcObject = !this._screenCapture ? undefined : await (navigator.mediaDevices as any).getDisplayMedia({ video: true }); + }), icon: "expand-arrows-alt" + }); + ContextMenu.Instance.addItem({ description: "Options...", subitems: subitems, icon: "video" }); } } @computed get content() { - const field = Cast(this.dataDoc[this.props.fieldKey], VideoField); + const field = Cast(this.dataDoc[this.fieldKey], VideoField); const interactive = InkingControl.Instance.selectedTool || !this.props.isSelected() ? "" : "-interactive"; const style = "videoBox-content" + (this._fullScreen ? "-fullScreen" : "") + interactive; return !field ? <div>Loading</div> : - <video className={`${style}`} key="video" ref={this.setVideoRef} onCanPlay={this.videoLoad} controls={VideoBox._showControls} - onPlay={() => this.Play()} onSeeked={this.updateTimecode} onPause={() => this.Pause()} onClick={e => e.preventDefault()}> + <video className={`${style}`} key="video" autoPlay={this._screenCapture} ref={this.setVideoRef} + style={{ width: this._screenCapture ? "100%" : undefined, height: this._screenCapture ? "100%" : undefined }} + onCanPlay={this.videoLoad} + controls={VideoBox._showControls} + onPlay={() => this.Play()} + onSeeked={this.updateTimecode} + onPause={() => this.Pause()} + onClick={e => e.preventDefault()}> <source src={field.url.href} type="video/mp4" /> Not supported. </video>; @@ -248,7 +260,7 @@ export class VideoBox extends DocAnnotatableComponent<FieldViewProps, VideoDocum const onYoutubePlayerReady = (event: any) => { this._reactionDisposer && this._reactionDisposer(); this._youtubeReactionDisposer && this._youtubeReactionDisposer(); - this._reactionDisposer = reaction(() => this.Document.currentTimecode, () => !this._playing && this.Seek(this.Document.currentTimecode || 0)); + this._reactionDisposer = reaction(() => this.layoutDoc.currentTimecode, () => !this._playing && this.Seek((this.layoutDoc.currentTimecode || 0))); this._youtubeReactionDisposer = reaction(() => [this.props.isSelected(), DocumentDecorations.Instance.Interacting, InkingControl.Instance.selectedTool], () => { const interactive = InkingControl.Instance.selectedTool === InkTool.None && this.props.isSelected(true) && !DocumentDecorations.Instance.Interacting; iframe.style.pointerEvents = interactive ? "all" : "none"; @@ -263,8 +275,7 @@ export class VideoBox extends DocAnnotatableComponent<FieldViewProps, VideoDocum } private get uIButtons() { - const scaling = Math.min(1.8, this.props.ScreenToLocalTransform().Scale); - const curTime = (this.Document.currentTimecode || 0); + const curTime = (this.layoutDoc.currentTimecode || 0); return ([<div className="videoBox-time" key="time" onPointerDown={this.onResetDown} > <span>{"" + Math.round(curTime)}</span> <span style={{ fontSize: 8 }}>{" " + Math.round((curTime - Math.trunc(curTime)) * 100)}</span> @@ -306,7 +317,7 @@ export class VideoBox extends DocAnnotatableComponent<FieldViewProps, VideoDocum onResetMove = (e: PointerEvent) => { this._isResetClick += Math.abs(e.movementX) + Math.abs(e.movementY); - this.Seek(Math.max(0, (this.Document.currentTimecode || 0) + Math.sign(e.movementX) * 0.0333)); + this.Seek(Math.max(0, (this.layoutDoc.currentTimecode || 0) + Math.sign(e.movementX) * 0.0333)); e.stopImmediatePropagation(); } @@ -314,22 +325,22 @@ export class VideoBox extends DocAnnotatableComponent<FieldViewProps, VideoDocum onResetUp = (e: PointerEvent) => { document.removeEventListener("pointermove", this.onResetMove, true); document.removeEventListener("pointerup", this.onResetUp, true); - this._isResetClick < 10 && (this.Document.currentTimecode = 0); + this._isResetClick < 10 && (this.layoutDoc.currentTimecode = 0); } @computed get youtubeContent() { this._youtubeIframeId = VideoBox._youtubeIframeCounter++; this._youtubeContentCreated = this._forceCreateYouTubeIFrame ? true : true; const style = "videoBox-content-YouTube" + (this._fullScreen ? "-fullScreen" : ""); - const start = untracked(() => Math.round(this.Document.currentTimecode || 0)); + const start = untracked(() => Math.round((this.layoutDoc.currentTimecode || 0))); return <iframe key={this._youtubeIframeId} id={`${this.youtubeVideoId + this._youtubeIframeId}-player`} - onLoad={this.youtubeIframeLoaded} className={`${style}`} width={(this.Document._nativeWidth || 640)} height={(this.Document._nativeHeight || 390)} + onLoad={this.youtubeIframeLoaded} className={`${style}`} width={(this.layoutDoc._nativeWidth || 640)} height={(this.layoutDoc._nativeHeight || 390)} src={`https://www.youtube.com/embed/${this.youtubeVideoId}?enablejsapi=1&rel=0&showinfo=1&autoplay=1&mute=1&start=${start}&modestbranding=1&controls=${VideoBox._showControls ? 1 : 0}`} />; } @action.bound addDocumentWithTimestamp(doc: Doc): boolean { - const curTime = (this.Document.currentTimecode || -1); + const curTime = (this.layoutDoc.currentTimecode || -1); curTime !== -1 && (doc.displayTimecode = curTime); return this.addDocument(doc); } @@ -342,6 +353,8 @@ export class VideoBox extends DocAnnotatableComponent<FieldViewProps, VideoDocum <CollectionFreeFormView {...this.props} PanelHeight={this.props.PanelHeight} PanelWidth={this.props.PanelWidth} + NativeHeight={returnZero} + NativeWidth={returnZero} annotationsKey={this.annotationKey} focus={this.props.focus} isSelected={this.props.isSelected} @@ -356,8 +369,7 @@ export class VideoBox extends DocAnnotatableComponent<FieldViewProps, VideoDocum CollectionView={undefined} ScreenToLocalTransform={this.props.ScreenToLocalTransform} renderDepth={this.props.renderDepth + 1} - ContainingCollectionDoc={this.props.ContainingCollectionDoc} - chromeCollapsed={true}> + ContainingCollectionDoc={this.props.ContainingCollectionDoc}> {this.contentFunc} </CollectionFreeFormView> </div> diff --git a/src/client/views/nodes/WebBox.scss b/src/client/views/nodes/WebBox.scss index fbe9bf063..af84a7d95 100644 --- a/src/client/views/nodes/WebBox.scss +++ b/src/client/views/nodes/WebBox.scss @@ -1,20 +1,36 @@ @import "../globalCssVariables.scss"; -.webBox-cont, -.webBox-cont-interactive { + +.webBox-container, .webBox-container-dragging { + transform-origin: top left; + .webBox-outerContent { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + overflow: auto; + .webBox-innerContent { + width:100%; + } + } + div.webBox-outerContent::-webkit-scrollbar-thumb { + display:none; + } +} +.webBox-cont { padding: 0vw; position: absolute; top: 0; left: 0; width: 100%; height: 100%; + transform-origin: top left; overflow: auto; pointer-events: none; } .webBox-cont-interactive { - pointer-events: all; - span { user-select: text !important; } @@ -30,22 +46,26 @@ width: 100%; height: 100%; position: absolute; - pointer-events: all; } -.webBox-button { - padding: 0vw; - border: none; +.webBox-buttons { + margin-left: 44; + background:lightGray; width: 100%; - height: 100%; +} +.webBox-freeze { + display: flex; + align-items: center; + justify-content: center; + margin-right: 5px; + width: 30px; } -.webView-urlEditor { +.webBox-urlEditor { position: relative; opacity: 0.9; z-index: 9001; transition: top .5s; - background: lightgrey; padding: 10px; @@ -90,4 +110,18 @@ width: 100%; margin-right: 10px; height: 100%; +} + +.touch-iframe-overlay { + width: 100%; + height: 100%; + position: absolute; + + .indicator { + position: absolute; + + &.active { + background-color: rgba(0, 0, 0, 0.1); + } + } }
\ No newline at end of file diff --git a/src/client/views/nodes/WebBox.tsx b/src/client/views/nodes/WebBox.tsx index a48dc286e..4e383e468 100644 --- a/src/client/views/nodes/WebBox.tsx +++ b/src/client/views/nodes/WebBox.tsx @@ -1,26 +1,28 @@ import { library } from "@fortawesome/fontawesome-svg-core"; -import { faStickyNote } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { action, computed, observable } from "mobx"; +import { faStickyNote, faPen, faMousePointer } from '@fortawesome/free-solid-svg-icons'; +import { action, computed, observable, trace, IReactionDisposer, reaction } from "mobx"; import { observer } from "mobx-react"; import { Doc, FieldResult } from "../../../new_fields/Doc"; +import { documentSchema } from "../../../new_fields/documentSchemas"; import { HtmlField } from "../../../new_fields/HtmlField"; import { InkTool } from "../../../new_fields/InkField"; import { makeInterface } from "../../../new_fields/Schema"; -import { Cast, NumCast } from "../../../new_fields/Types"; +import { Cast, NumCast, BoolCast, StrCast } from "../../../new_fields/Types"; import { WebField } from "../../../new_fields/URLField"; -import { emptyFunction, returnOne, Utils } from "../../../Utils"; +import { Utils, returnOne, emptyFunction, returnZero } from "../../../Utils"; import { Docs } from "../../documents/Documents"; -import { SelectionManager } from "../../util/SelectionManager"; -import { CollectionFreeFormView } from "../collections/collectionFreeForm/CollectionFreeFormView"; +import { DragManager } from "../../util/DragManager"; +import { ImageUtils } from "../../util/Import & Export/ImageUtils"; +import { ViewBoxAnnotatableComponent } from "../DocComponent"; import { DocumentDecorations } from "../DocumentDecorations"; import { InkingControl } from "../InkingControl"; import { FieldView, FieldViewProps } from './FieldView'; -import { KeyValueBox } from "./KeyValueBox"; import "./WebBox.scss"; import React = require("react"); -import { DocAnnotatableComponent } from "../DocComponent"; -import { documentSchema } from "../../../new_fields/documentSchemas"; +import * as WebRequest from 'web-request'; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { CollectionFreeFormView } from "../collections/collectionFreeForm/CollectionFreeFormView"; +const htmlToText = require("html-to-text"); library.add(faStickyNote); @@ -28,16 +30,57 @@ type WebDocument = makeInterface<[typeof documentSchema]>; const WebDocument = makeInterface(documentSchema); @observer -export class WebBox extends DocAnnotatableComponent<FieldViewProps, WebDocument>(WebDocument) { +export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps, WebDocument>(WebDocument) { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(WebBox, fieldKey); } - @observable private collapsed: boolean = true; - @observable private url: string = ""; + get _collapsed() { return StrCast(this.layoutDoc._chromeStatus) === "disabled"; } + set _collapsed(value) { this.layoutDoc._chromeStatus = !value ? "enabled" : "disabled"; } + @observable private _url: string = "hello"; + @observable private _pressX: number = 0; + @observable private _pressY: number = 0; + + private _longPressSecondsHack?: NodeJS.Timeout; + private _outerRef = React.createRef<HTMLDivElement>(); + private _iframeRef = React.createRef<HTMLIFrameElement>(); + private _iframeIndicatorRef = React.createRef<HTMLDivElement>(); + private _iframeDragRef = React.createRef<HTMLDivElement>(); + private _reactionDisposer?: IReactionDisposer; + private _setPreviewCursor: undefined | ((x: number, y: number, drag: boolean) => void); - componentDidMount() { + iframeLoaded = action((e: any) => { + this._iframeRef.current!.contentDocument?.addEventListener('pointerdown', this.iframedown, false); + this._iframeRef.current!.contentDocument?.addEventListener('scroll', this.iframeScrolled, false); + this.layoutDoc.scrollHeight = this._iframeRef.current!.contentDocument?.children?.[0].scrollHeight || 1000; + this._iframeRef.current!.contentDocument!.children[0].scrollTop = NumCast(this.layoutDoc.scrollTop); + this._reactionDisposer?.(); + this._reactionDisposer = reaction(() => this.layoutDoc.scrollY, + (scrollY) => { + if (scrollY !== undefined) { + this._outerRef.current!.scrollTop = scrollY; + this.layoutDoc.scrollY = undefined; + } + }, + { fireImmediately: true } + ); + }); + setPreviewCursor = (func?: (x: number, y: number, drag: boolean) => void) => this._setPreviewCursor = func; + iframedown = (e: PointerEvent) => { + this._setPreviewCursor?.(e.screenX, e.screenY, false); + } + iframeScrolled = (e: any) => { + const scroll = e.target?.children?.[0].scrollTop; + this.layoutDoc.scrollTop = this._outerRef.current!.scrollTop = scroll; + } + async componentDidMount() { - const field = Cast(this.props.Document[this.props.fieldKey], WebField); - if (field && field.url.href.indexOf("youtube") !== -1) { + this.setURL(); + + this._iframeRef.current!.setAttribute("enable-annotation", "true"); + + document.addEventListener("pointerup", this.onLongPressUp); + document.addEventListener("pointermove", this.onLongPressMove); + const field = Cast(this.rootDoc[this.props.fieldKey], WebField); + if (field?.url.href.indexOf("youtube") !== -1) { const youtubeaspect = 400 / 315; const nativeWidth = NumCast(this.layoutDoc._nativeWidth); const nativeHeight = NumCast(this.layoutDoc._nativeHeight); @@ -46,28 +89,35 @@ export class WebBox extends DocAnnotatableComponent<FieldViewProps, WebDocument> this.layoutDoc._nativeHeight = NumCast(this.layoutDoc._nativeWidth) / youtubeaspect; this.layoutDoc._height = NumCast(this.layoutDoc._width) / youtubeaspect; } + } else if (field?.url) { + const result = await WebRequest.get(Utils.CorsProxy(field.url.href)); + this.dataDoc.text = htmlToText.fromString(result.content); } + } - this.setURL(); + componentWillUnmount() { + this._reactionDisposer?.(); + document.removeEventListener("pointerup", this.onLongPressUp); + document.removeEventListener("pointermove", this.onLongPressMove); + this._iframeRef.current!.contentDocument?.removeEventListener('pointerdown', this.iframedown); + this._iframeRef.current!.contentDocument?.removeEventListener('scroll', this.iframeScrolled); } @action onURLChange = (e: React.ChangeEvent<HTMLInputElement>) => { - this.url = e.target.value; + this._url = e.target.value; } @action submitURL = () => { - const script = KeyValueBox.CompileKVPScript(`new WebField("${this.url}")`); - if (!script) return; - KeyValueBox.ApplyKVPScript(this.props.Document, "data", script); + this.dataDoc[this.props.fieldKey] = new WebField(new URL(this._url)); } @action setURL() { - const urlField: FieldResult<WebField> = Cast(this.props.Document.data, WebField); - if (urlField) this.url = urlField.url.toString(); - else this.url = ""; + const urlField: FieldResult<WebField> = Cast(this.dataDoc[this.props.fieldKey], WebField); + if (urlField) this._url = urlField.url.toString(); + else this._url = ""; } onValueKeyDown = async (e: React.KeyboardEvent) => { @@ -77,47 +127,45 @@ export class WebBox extends DocAnnotatableComponent<FieldViewProps, WebDocument> } } - - switchToText = () => { - let url: string = ""; - const field = Cast(this.props.Document[this.props.fieldKey], WebField); - if (field) url = field.url.href; - - const newBox = Docs.Create.TextDocument(url, { - x: NumCast(this.props.Document.x), - y: NumCast(this.props.Document.y), - title: url, - _width: 200, - _height: 70, - }); - - SelectionManager.SelectedDocuments().map(dv => { - dv.props.addDocument && dv.props.addDocument(newBox); - dv.props.removeDocument && dv.props.removeDocument(dv.props.Document); - }); - - Doc.BrushDoc(newBox); + toggleNativeDimensions = () => { + if (!this.layoutDoc.isAnnotating) { + //DocumentView.unfreezeNativeDimensions(this.layoutDoc); + this.layoutDoc.lockedTransform = false; + this.layoutDoc.isAnnotating = true; + } + else { + //Doc.freezeNativeDimensions(this.layoutDoc, this.props.PanelWidth(), this.props.PanelHeight()); + this.layoutDoc.lockedTransform = true; + this.layoutDoc.isAnnotating = false; + } } urlEditor() { + const frozen = this.layoutDoc._nativeWidth && this.layoutDoc.isAnnotating; return ( - <div className="webView-urlEditor" style={{ top: this.collapsed ? -70 : 0 }}> + <div className="webBox-urlEditor" style={{ top: this._collapsed ? -70 : 0 }}> <div className="urlEditor"> <div className="editorBase"> <button className="editor-collapse" style={{ - top: this.collapsed ? 70 : 10, - transform: `rotate(${this.collapsed ? 180 : 0}deg) scale(${this.collapsed ? 0.5 : 1}) translate(${this.collapsed ? "-100%, -100%" : "0, 0"})`, - opacity: (this.collapsed && !this.props.isSelected()) ? 0 : 0.9, - left: (this.collapsed ? 0 : "unset"), + top: this._collapsed ? 70 : 10, + transform: `rotate(${this._collapsed ? 180 : 0}deg) scale(${this._collapsed ? 0.5 : 1}) translate(${this._collapsed ? "-100%, -100%" : "0, 0"})`, + opacity: (this._collapsed && !this.props.isSelected()) ? 0 : 0.9, + left: (this._collapsed ? 0 : "unset"), }} title="Collapse Url Editor" onClick={this.toggleCollapse}> <FontAwesomeIcon icon="caret-up" size="2x" /> </button> - <div style={{ marginLeft: 54, width: "100%", display: this.collapsed ? "none" : "flex" }}> + <div className="webBox-buttons" style={{ display: this._collapsed ? "none" : "flex" }}> + <div className="webBox-freeze" title={"Annotate"} style={{ background: frozen ? "lightBlue" : "gray" }} onClick={this.toggleNativeDimensions} > + <FontAwesomeIcon icon={faPen} size={"2x"} /> + </div> + <div className="webBox-freeze" title={"Select"} style={{ background: !frozen ? "lightBlue" : "gray" }} onClick={this.toggleNativeDimensions} > + <FontAwesomeIcon icon={faMousePointer} size={"2x"} /> + </div> <input className="webpage-urlInput" placeholder="ENTER URL" - value={this.url} + value={this._url} onChange={this.onURLChange} onKeyDown={this.onValueKeyDown} /> @@ -130,9 +178,6 @@ export class WebBox extends DocAnnotatableComponent<FieldViewProps, WebDocument> <button className="submitUrl" onClick={this.submitURL}> SUBMIT </button> - <div className="switchToText" title="Convert web to text doc" onClick={this.switchToText} style={{ display: "flex", alignItems: "center", justifyContent: "center" }} > - <FontAwesomeIcon icon={faStickyNote} size={"lg"} /> - </div> </div> </div> </div> @@ -143,7 +188,7 @@ export class WebBox extends DocAnnotatableComponent<FieldViewProps, WebDocument> @action toggleCollapse = () => { - this.collapsed = !this.collapsed; + this._collapsed = !this._collapsed; } _ignore = 0; @@ -164,6 +209,107 @@ export class WebBox extends DocAnnotatableComponent<FieldViewProps, WebDocument> } } + onLongPressDown = (e: React.PointerEvent) => { + this._pressX = e.clientX; + this._pressY = e.clientY; + + // find the pressed element in the iframe (currently only works if its an img) + let pressedElement: HTMLElement | undefined; + let pressedBound: ClientRect | undefined; + let selectedText: string = ""; + let pressedImg: boolean = false; + if (this._iframeRef.current) { + const B = this._iframeRef.current.getBoundingClientRect(); + const iframeDoc = this._iframeRef.current.contentDocument; + if (B && iframeDoc) { + // TODO: this only works when scale = 1 as it is currently only inteded for mobile upload + const element = iframeDoc.elementFromPoint(this._pressX - B.left, this._pressY - B.top); + if (element && element.nodeName === "IMG") { + pressedBound = element.getBoundingClientRect(); + pressedElement = element.cloneNode(true) as HTMLElement; + pressedImg = true; + } else { + // check if there is selected text + const text = iframeDoc.getSelection(); + if (text && text.toString().length > 0) { + selectedText = text.toString(); + + // get html of the selected text + const range = text.getRangeAt(0); + const contents = range.cloneContents(); + const div = document.createElement("div"); + div.appendChild(contents); + pressedElement = div; + + pressedBound = range.getBoundingClientRect(); + } + } + } + } + + // mark the pressed element + if (pressedElement && pressedBound) { + if (this._iframeIndicatorRef.current) { + this._iframeIndicatorRef.current.style.top = pressedBound.top + "px"; + this._iframeIndicatorRef.current.style.left = pressedBound.left + "px"; + this._iframeIndicatorRef.current.style.width = pressedBound.width + "px"; + this._iframeIndicatorRef.current.style.height = pressedBound.height + "px"; + this._iframeIndicatorRef.current.classList.add("active"); + } + } + + // start dragging the pressed element if long pressed + this._longPressSecondsHack = setTimeout(() => { + if (pressedImg && pressedElement && pressedBound) { + e.stopPropagation(); + e.preventDefault(); + if (pressedElement.nodeName === "IMG") { + const src = pressedElement.getAttribute("src"); // TODO: may not always work + if (src) { + const doc = Docs.Create.ImageDocument(src); + ImageUtils.ExtractExif(doc); + + // add clone to div so that dragging ghost is placed properly + if (this._iframeDragRef.current) this._iframeDragRef.current.appendChild(pressedElement); + + const dragData = new DragManager.DocumentDragData([doc]); + DragManager.StartDocumentDrag([pressedElement], dragData, this._pressX, this._pressY, { hideSource: true }); + } + } + } else if (selectedText && pressedBound && pressedElement) { + e.stopPropagation(); + e.preventDefault(); + // create doc with the selected text's html + const doc = Docs.Create.HtmlDocument(pressedElement.innerHTML); + + // create dragging ghost with the selected text + if (this._iframeDragRef.current) this._iframeDragRef.current.appendChild(pressedElement); + + // start the drag + const dragData = new DragManager.DocumentDragData([doc]); + DragManager.StartDocumentDrag([pressedElement], dragData, this._pressX - pressedBound.top, this._pressY - pressedBound.top, { hideSource: true }); + } + }, 1500); + } + + onLongPressMove = (e: PointerEvent) => { + // this._pressX = e.clientX; + // this._pressY = e.clientY; + } + + onLongPressUp = (e: PointerEvent) => { + if (this._longPressSecondsHack) { + clearTimeout(this._longPressSecondsHack); + } + if (this._iframeIndicatorRef.current) { + this._iframeIndicatorRef.current.classList.remove("active"); + } + if (this._iframeDragRef.current) { + while (this._iframeDragRef.current.firstChild) this._iframeDragRef.current.removeChild(this._iframeDragRef.current.firstChild); + } + } + + @computed get content() { const field = this.dataDoc[this.props.fieldKey]; @@ -171,9 +317,10 @@ export class WebBox extends DocAnnotatableComponent<FieldViewProps, WebDocument> if (field instanceof HtmlField) { view = <span id="webBox-htmlSpan" dangerouslySetInnerHTML={{ __html: field.html }} />; } else if (field instanceof WebField) { - view = <iframe src={Utils.CorsProxy(field.url.href)} style={{ position: "absolute", width: "100%", height: "100%", top: 0 }} />; + const url = this.layoutDoc.UseCors ? Utils.CorsProxy(field.url.href) : field.url.href; + view = <iframe ref={this._iframeRef} onLoad={this.iframeLoaded} src={url} style={{ position: "absolute", width: "100%", height: "100%", top: 0 }} />; } else { - view = <iframe src={"https://crossorigin.me/https://cs.brown.edu"} style={{ position: "absolute", width: "100%", height: "100%", top: 0 }} />; + view = <iframe ref={this._iframeRef} src={"https://crossorigin.me/https://cs.brown.edu"} style={{ position: "absolute", width: "100%", height: "100%", top: 0 }} />; } const content = <div style={{ width: "100%", height: "100%", position: "absolute" }} onWheel={this.onPostWheel} onPointerDown={this.onPostPointer} onPointerMove={this.onPostPointer} onPointerUp={this.onPostPointer}> @@ -181,40 +328,68 @@ export class WebBox extends DocAnnotatableComponent<FieldViewProps, WebDocument> {view} </div>; - const frozen = !this.props.isSelected() || DocumentDecorations.Instance.Interacting; + const decInteracting = DocumentDecorations.Instance?.Interacting; - const classname = "webBox-cont" + (this.props.isSelected() && InkingControl.Instance.selectedTool === InkTool.None && !DocumentDecorations.Instance.Interacting ? "-interactive" : ""); - return ( - <> - <div className={classname} > - {content} - </div> - {!frozen ? (null) : <div className="webBox-overlay" onWheel={this.onPreWheel} onPointerDown={this.onPrePointer} onPointerMove={this.onPrePointer} onPointerUp={this.onPrePointer} />} - </>); + const frozen = !this.props.isSelected() || decInteracting; + + return (<> + <div className={"webBox-cont" + (this.props.isSelected() && InkingControl.Instance.selectedTool === InkTool.None && !decInteracting ? "-interactive" : "")} > + {content} + </div> + {!frozen ? (null) : + <div className="webBox-overlay" style={{ pointerEvents: this.layoutDoc.isBackground ? undefined : "all" }} + onWheel={this.onPreWheel} onPointerDown={this.onPrePointer} onPointerMove={this.onPrePointer} onPointerUp={this.onPrePointer}> + <div className="touch-iframe-overlay" onPointerDown={this.onLongPressDown} > + <div className="indicator" ref={this._iframeIndicatorRef}></div> + <div className="dragger" ref={this._iframeDragRef}></div> + </div> + </div>} + </>); } + scrollXf = () => this.props.ScreenToLocalTransform().translate(0, NumCast(this.props.Document.scrollTop)); render() { - return (<div className={"webBox-container"} > - <CollectionFreeFormView {...this.props} - PanelHeight={this.props.PanelHeight} - PanelWidth={this.props.PanelWidth} - annotationsKey={this.annotationKey} - focus={this.props.focus} - isSelected={this.props.isSelected} - isAnnotationOverlay={true} - select={emptyFunction} - active={this.active} - ContentScaling={returnOne} - whenActiveChanged={this.whenActiveChanged} - removeDocument={this.removeDocument} - moveDocument={this.moveDocument} - addDocument={this.addDocument} - CollectionView={undefined} - ScreenToLocalTransform={this.props.ScreenToLocalTransform} - renderDepth={this.props.renderDepth + 1} - ContainingCollectionDoc={this.props.ContainingCollectionDoc} - chromeCollapsed={true}> - {() => [this.content]} - </CollectionFreeFormView> + return (<div className={`webBox-container`} + style={{ + transform: `scale(${this.props.ContentScaling()})`, + width: `${100 / this.props.ContentScaling()}%`, + height: `${100 / this.props.ContentScaling()}%`, + pointerEvents: this.layoutDoc.isBackground ? "none" : undefined + }} > + {this.content} + <div className={"webBox-outerContent"} ref={this._outerRef} + style={{ pointerEvents: this.layoutDoc.isAnnotating && !this.layoutDoc.isBackground ? "all" : "none" }} + onWheel={e => e.stopPropagation()} + onScroll={e => { + if (this._iframeRef.current!.contentDocument!.children[0].scrollTop !== this._outerRef.current!.scrollTop) { + this._iframeRef.current!.contentDocument!.children[0].scrollTop = this._outerRef.current!.scrollTop; + } + //this._outerRef.current!.scrollTop !== this._scrollTop && (this._outerRef.current!.scrollTop = this._scrollTop) + }}> + <div className={"webBox-innerContent"} style={{ height: NumCast(this.layoutDoc.scrollHeight) }}> + <CollectionFreeFormView {...this.props} + PanelHeight={this.props.PanelHeight} + PanelWidth={this.props.PanelWidth} + annotationsKey={this.annotationKey} + NativeHeight={returnZero} + NativeWidth={returnZero} + focus={this.props.focus} + setPreviewCursor={this.setPreviewCursor} + isSelected={this.props.isSelected} + isAnnotationOverlay={true} + select={emptyFunction} + active={this.active} + ContentScaling={returnOne} + whenActiveChanged={this.whenActiveChanged} + removeDocument={this.removeDocument} + moveDocument={this.moveDocument} + addDocument={this.addDocument} + CollectionView={undefined} + ScreenToLocalTransform={this.scrollXf} + renderDepth={this.props.renderDepth + 1} + ContainingCollectionDoc={this.props.ContainingCollectionDoc}> + </CollectionFreeFormView> + </div> + </div> </div >); } }
\ No newline at end of file diff --git a/src/client/views/nodes/formattedText/DashDocCommentView.tsx b/src/client/views/nodes/formattedText/DashDocCommentView.tsx new file mode 100644 index 000000000..d94fe7fc6 --- /dev/null +++ b/src/client/views/nodes/formattedText/DashDocCommentView.tsx @@ -0,0 +1,95 @@ +import { IReactionDisposer, observable, reaction, runInAction } from "mobx"; +import { baseKeymap, toggleMark } from "prosemirror-commands"; +import { redo, undo } from "prosemirror-history"; +import { keymap } from "prosemirror-keymap"; +import { DOMOutputSpecArray, Fragment, MarkSpec, Node, NodeSpec, Schema, Slice } from "prosemirror-model"; +import { bulletList, listItem, orderedList } from 'prosemirror-schema-list'; +import { EditorState, NodeSelection, Plugin, TextSelection } from "prosemirror-state"; +import { StepMap } from "prosemirror-transform"; +import { EditorView } from "prosemirror-view"; +import * as ReactDOM from 'react-dom'; +import { Doc, DocListCast, Field, HeightSym, WidthSym } from "../../../../new_fields/Doc"; +import { Id } from "../../../../new_fields/FieldSymbols"; +import { List } from "../../../../new_fields/List"; +import { ObjectField } from "../../../../new_fields/ObjectField"; +import { listSpec } from "../../../../new_fields/Schema"; +import { SchemaHeaderField } from "../../../../new_fields/SchemaHeaderField"; +import { ComputedField } from "../../../../new_fields/ScriptField"; +import { BoolCast, Cast, NumCast, StrCast } from "../../../../new_fields/Types"; +import { emptyFunction, returnEmptyString, returnFalse, returnOne, Utils, returnZero } from "../../../../Utils"; +import { DocServer } from "../../../DocServer"; + +import React = require("react"); + +import { schema } from "./schema_rts"; + +interface IDashDocCommentView { + node: any; + view: any; + getPos: any; +} + +export class DashDocCommentView extends React.Component<IDashDocCommentView>{ + constructor(props: IDashDocCommentView) { + super(props); + } + + targetNode = () => { // search forward in the prosemirror doc for the attached dashDocNode that is the target of the comment anchor + for (let i = this.props.getPos() + 1; i < this.props.view.state.doc.content.size; i++) { + const m = this.props.view.state.doc.nodeAt(i); + if (m && m.type === this.props.view.state.schema.nodes.dashDoc && m.attrs.docid === this.props.node.attrs.docid) { + return { node: m, pos: i, hidden: m.attrs.hidden } as { node: any, pos: number, hidden: boolean }; + } + } + const dashDoc = this.props.view.state.schema.nodes.dashDoc.create({ width: 75, height: 35, title: "dashDoc", docid: this.props.node.attrs.docid, float: "right" }); + this.props.view.dispatch(this.props.view.state.tr.insert(this.props.getPos() + 1, dashDoc)); + setTimeout(() => { try { this.props.view.dispatch(this.props.view.state.tr.setSelection(TextSelection.create(this.props.view.state.tr.doc, this.props.getPos() + 2))); } catch (e) { } }, 0); + return undefined; + } + + onPointerDownCollapse = (e: any) => e.stopPropagation(); + + onPointerUpCollapse = (e: any) => { + const target = this.targetNode(); + if (target) { + const expand = target.hidden; + const tr = this.props.view.state.tr.setNodeMarkup(target.pos, undefined, { ...target.node.attrs, hidden: target.node.attrs.hidden ? false : true }); + this.props.view.dispatch(tr.setSelection(TextSelection.create(tr.doc, this.props.getPos() + (expand ? 2 : 1)))); // update the attrs + setTimeout(() => { + expand && DocServer.GetRefField(this.props.node.attrs.docid).then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowHighlight(dashDoc)); + try { this.props.view.dispatch(this.props.view.state.tr.setSelection(TextSelection.create(this.props.view.state.tr.doc, this.props.getPos() + (expand ? 2 : 1)))); } catch (e) { } + }, 0); + } + e.stopPropagation(); + } + + onPointerEnterCollapse = (e: any) => { + DocServer.GetRefField(this.props.node.attrs.docid).then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowHighlight(dashDoc, false)); + e.preventDefault(); + e.stopPropagation(); + } + + onPointerLeaveCollapse = (e: any) => { + DocServer.GetRefField(this.props.node.attrs.docid).then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowUnhighlight()); + e.preventDefault(); + e.stopPropagation(); + } + + render() { + + const collapsedId = "DashDocCommentView-" + this.props.node.attrs.docid; + + return ( + <span + className="formattedTextBox-inlineComment" + id={collapsedId} + onPointerDown={this.onPointerDownCollapse} + onPointerUp={this.onPointerUpCollapse} + onPointerEnter={this.onPointerEnterCollapse} + onPointerLeave={this.onPointerLeaveCollapse} + > + + </span > + ); + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/formattedText/DashDocView.tsx b/src/client/views/nodes/formattedText/DashDocView.tsx new file mode 100644 index 000000000..9fe8fa320 --- /dev/null +++ b/src/client/views/nodes/formattedText/DashDocView.tsx @@ -0,0 +1,269 @@ +import { IReactionDisposer, reaction } from "mobx"; +import { NodeSelection } from "prosemirror-state"; +import { Doc, HeightSym, WidthSym } from "../../../../new_fields/Doc"; +import { Id } from "../../../../new_fields/FieldSymbols"; +import { ObjectField } from "../../../../new_fields/ObjectField"; +import { ComputedField } from "../../../../new_fields/ScriptField"; +import { BoolCast, Cast, NumCast, StrCast } from "../../../../new_fields/Types"; +import { emptyFunction, returnEmptyString, returnFalse, Utils, returnZero } from "../../../../Utils"; +import { DocServer } from "../../../DocServer"; +import { Docs } from "../../../documents/Documents"; +import { DocumentView } from "../DocumentView"; +import { FormattedTextBox } from "./FormattedTextBox"; +import { Transform } from "../../../util/Transform"; +import React = require("react"); + +interface IDashDocView { + node: any; + view: any; + getPos: any; + tbox?: FormattedTextBox; + self: any; +} + +export class DashDocView extends React.Component<IDashDocView> { + + _dashDoc: Doc | undefined; + _reactionDisposer: IReactionDisposer | undefined; + _renderDisposer: IReactionDisposer | undefined; + _textBox: FormattedTextBox; + _finalLayout: any; + _resolvedDataDoc: any; + + + // constructor(node: any, view: any, getPos: any, tbox: FormattedTextBox) { + + constructor(props: IDashDocView) { + super(props); + + const node = this.props.node; + this._textBox = this.props.tbox as FormattedTextBox; + + const alias = node.attrs.alias; + const docid = node.attrs.docid || this._textBox.props.Document[Id]; + + DocServer.GetRefField(docid + alias).then(async dashDoc => { + if (!(dashDoc instanceof Doc)) { + alias && DocServer.GetRefField(docid).then(async dashDocBase => { + if (dashDocBase instanceof Doc) { + const aliasedDoc = Doc.MakeAlias(dashDocBase, docid + alias); + aliasedDoc.layoutKey = "layout"; + node.attrs.fieldKey && DocumentView.makeCustomViewClicked(aliasedDoc, Docs.Create.StackingDocument, node.attrs.fieldKey, undefined); + this._dashDoc = aliasedDoc; + // self.doRender(aliasedDoc, removeDoc, node, view, getPos); + } + }); + } else { + this._dashDoc = dashDoc; + // self.doRender(dashDoc, removeDoc, node, view, getPos); + } + }); + + this.onPointerLeave = this.onPointerLeave.bind(this); + this.onPointerEnter = this.onPointerEnter.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onKeyPress = this.onKeyPress.bind(this); + this.onKeyUp = this.onKeyUp.bind(this); + this.onWheel = this.onWheel.bind(this); + } + /* #region Internal functions */ + + removeDoc = () => { + const view = this.props.view; + const pos = this.props.getPos(); + const ns = new NodeSelection(view.state.doc.resolve(pos)); + view.dispatch(view.state.tr.setSelection(ns).deleteSelection()); + return true; + } + + getDocTransform = () => { + const outerElement = document.getElementById('dash-document-view-outer') as HTMLElement; + const { scale, translateX, translateY } = Utils.GetScreenTransform(outerElement); + return new Transform(-translateX, -translateY, 1).scale(1 / this.contentScaling() / scale); + } + contentScaling = () => NumCast(this._dashDoc!._nativeWidth) > 0 ? this._dashDoc![WidthSym]() / NumCast(this._dashDoc!._nativeWidth) : 1; + + outerFocus = (target: Doc) => this._textBox.props.focus(this._textBox.props.Document); // ideally, this would scroll to show the focus target + + onKeyPress = (e: any) => { + e.stopPropagation(); + } + onWheel = (e: any) => { + e.preventDefault(); + } + onKeyUp = (e: any) => { + e.stopPropagation(); + } + onKeyDown = (e: any) => { + e.stopPropagation(); + if (e.key === "Tab" || e.key === "Enter") { + e.preventDefault(); + } + } + onPointerLeave = () => { + const ele = document.getElementById("DashDocCommentView-" + this.props.node.attrs.docid); + if (ele) { + (ele as HTMLDivElement).style.backgroundColor = ""; + } + } + onPointerEnter = () => { + const ele = document.getElementById("DashDocCommentView-" + this.props.node.attrs.docid); + if (ele) { + (ele as HTMLDivElement).style.backgroundColor = "orange"; + } + } + /*endregion*/ + + componentWillMount = () => { + this._reactionDisposer?.(); + } + + componentDidUpdate = () => { + + this._renderDisposer?.(); + this._renderDisposer = reaction(() => { + + const dashDoc = this._dashDoc as Doc; + const dashLayoutDoc = Doc.Layout(dashDoc); + const finalLayout = this.props.node.attrs.docid ? dashDoc : Doc.expandTemplateLayout(dashLayoutDoc, dashDoc, this.props.node.attrs.fieldKey); + + if (finalLayout) { + if (!Doc.AreProtosEqual(finalLayout, dashDoc)) { + finalLayout.rootDocument = dashDoc.aliasOf; + } + const layoutKey = StrCast(finalLayout.layoutKey); + const finalKey = layoutKey && StrCast(finalLayout[layoutKey]).split("'")?.[1]; + if (finalLayout !== dashDoc && finalKey) { + const finalLayoutField = finalLayout[finalKey]; + if (finalLayoutField instanceof ObjectField) { + finalLayout[finalKey + "-textTemplate"] = ComputedField.MakeFunction(`copyField(this.${finalKey})`, { this: Doc.name }); + } + } + this._finalLayout = finalLayout; + this._resolvedDataDoc = Cast(finalLayout.resolvedDataDoc, Doc, null); + return { finalLayout, resolvedDataDoc: Cast(finalLayout.resolvedDataDoc, Doc, null) }; + } + }, + (res) => { + + if (res) { + this._finalLayout = res.finalLayout; + this._resolvedDataDoc = res.resolvedDataDoc; + + this.forceUpdate(); // doReactRender(res.finalLayout, res.resolvedDataDoc), + } + }, + { fireImmediately: true }); + + } + + render() { + // doRender(dashDoc: Doc, removeDoc: any, node: any, view: any, getPos: any) { + + const node = this.props.node; + const view = this.props.view; + const getPos = this.props.getPos; + + const spanStyle = { + width: this.props.node.props.width, + height: this.props.node.props.height, + position: 'absolute' as 'absolute', + display: 'inline-block' + }; + + + const outerStyle = { + position: "relative" as "relative", + textIndent: "0", + border: "1px solid " + StrCast(this._textBox.Document.color, (Cast(Doc.UserDoc().activeWorkspace, Doc, null).darkScheme ? "dimGray" : "lightGray")), + width: this.props.node.props.width, + height: this.props.node.props.height, + display: this.props.node.props.hidden ? "none" : "inline-block", + float: this.props.node.props.float, + }; + + const dashDoc = this._dashDoc as Doc; + const self = this; + const dashLayoutDoc = Doc.Layout(dashDoc); + const finalLayout = node.attrs.docid ? dashDoc : Doc.expandTemplateLayout(dashLayoutDoc, dashDoc, node.attrs.fieldKey); + const resolvedDataDoc = this._resolvedDataDoc; //Added this + + if (!finalLayout) { + return <div></div>; + // if (!finalLayout) setTimeout(() => self.doRender(dashDoc, removeDoc, node, view, getPos), 0); + } else { + + this._reactionDisposer?.(); + this._reactionDisposer = reaction(() => + ({ + dim: [finalLayout[WidthSym](), finalLayout[HeightSym]()], + color: finalLayout.color + }), + ({ dim, color }) => { + spanStyle.width = outerStyle.width = Math.max(20, dim[0]) + "px"; + spanStyle.height = outerStyle.height = Math.max(20, dim[1]) + "px"; + outerStyle.border = "1px solid " + StrCast(finalLayout.color, (Cast(Doc.UserDoc().activeWorkspace, Doc, null).darkScheme ? "dimGray" : "lightGray")); + }, { fireImmediately: true }); + + if (node.attrs.width !== dashDoc._width + "px" || node.attrs.height !== dashDoc._height + "px") { + try { // bcz: an exception will be thrown if two aliases are open at the same time when a doc view comment is made + view.dispatch(view.state.tr.setNodeMarkup(getPos(), null, { ...node.attrs, width: dashDoc._width + "px", height: dashDoc._height + "px" })); + } catch (e) { + console.log(e); + } + } + + + //const doReactRender = (finalLayout: Doc, resolvedDataDoc: Doc) => { + // ReactDOM.unmountComponentAtNode(this._dashSpan); + + return ( + <span id="dash-document-view-outer" + className="outer" + style={outerStyle} + > + <div id="dashSpan" + className="dash-span" + style={spanStyle} + onPointerLeave={this.onPointerLeave} + onPointerEnter={this.onPointerEnter} + onKeyDown={this.onKeyDown} + onKeyPress={this.onKeyPress} + onKeyUp={this.onKeyUp} + onWheel={this.onWheel} + > + <DocumentView + Document={finalLayout} + DataDoc={resolvedDataDoc} + LibraryPath={this._textBox.props.LibraryPath} + fitToBox={BoolCast(dashDoc._fitToBox)} + addDocument={returnFalse} + rootSelected={this._textBox.props.isSelected} + removeDocument={this.removeDoc} + ScreenToLocalTransform={this.getDocTransform} + addDocTab={this._textBox.props.addDocTab} + pinToPres={returnFalse} + renderDepth={self._textBox.props.renderDepth + 1} + NativeHeight={returnZero} + NativeWidth={returnZero} + PanelWidth={finalLayout[WidthSym]} + PanelHeight={finalLayout[HeightSym]} + focus={this.outerFocus} + backgroundColor={returnEmptyString} + parentActive={returnFalse} + whenActiveChanged={returnFalse} + bringToFront={emptyFunction} + dontRegisterView={false} + ContainingCollectionView={this._textBox.props.ContainingCollectionView} + ContainingCollectionDoc={this._textBox.props.ContainingCollectionDoc} + ContentScaling={this.contentScaling} + /> + + </div> + </span> + ); + + } + } + +}
\ No newline at end of file diff --git a/src/client/views/nodes/formattedText/DashFieldView.scss b/src/client/views/nodes/formattedText/DashFieldView.scss new file mode 100644 index 000000000..35ff9c1e6 --- /dev/null +++ b/src/client/views/nodes/formattedText/DashFieldView.scss @@ -0,0 +1,36 @@ +.dashFieldView { + position: relative; + display: inline-block; + + .dashFieldView-enumerables { + width: 10px; + height: 10px; + position: relative; + display: inline-block; + background: dimGray; + } + .dashFieldView-fieldCheck { + min-width: 12px; + position: relative; + display: inline-block; + background-color: rgba(155, 155, 155, 0.24); + } + .dashFieldView-labelSpan { + position: relative; + display: inline-block; + font-size: small; + } + .dashFieldView-fieldSpan { + min-width: 20px; + margin-left: 2px; + margin-right: 5px; + position: relative; + display: inline-block; + background-color: rgba(155, 155, 155, 0.24); + span { + min-width: 100%; + display: inline-block; + } + } +} +
\ No newline at end of file diff --git a/src/client/views/nodes/formattedText/DashFieldView.tsx b/src/client/views/nodes/formattedText/DashFieldView.tsx new file mode 100644 index 000000000..422710c3e --- /dev/null +++ b/src/client/views/nodes/formattedText/DashFieldView.tsx @@ -0,0 +1,211 @@ +import { IReactionDisposer, observable, runInAction, computed, action } from "mobx"; +import { Doc, DocListCast, Field } from "../../../../new_fields/Doc"; +import { List } from "../../../../new_fields/List"; +import { listSpec } from "../../../../new_fields/Schema"; +import { SchemaHeaderField } from "../../../../new_fields/SchemaHeaderField"; +import { ComputedField } from "../../../../new_fields/ScriptField"; +import { Cast, StrCast } from "../../../../new_fields/Types"; +import { DocServer } from "../../../DocServer"; +import { CollectionViewType } from "../../collections/CollectionView"; +import { FormattedTextBox } from "./FormattedTextBox"; +import React = require("react"); +import * as ReactDOM from 'react-dom'; +import "./DashFieldView.scss"; +import { observer } from "mobx-react"; + + +export class DashFieldView { + _fieldWrapper: HTMLDivElement; // container for label and value + + constructor(node: any, view: any, getPos: any, tbox: FormattedTextBox) { + this._fieldWrapper = document.createElement("div"); + this._fieldWrapper.style.width = node.attrs.width; + this._fieldWrapper.style.height = node.attrs.height; + this._fieldWrapper.style.fontWeight = "bold"; + this._fieldWrapper.style.position = "relative"; + this._fieldWrapper.style.display = "inline-block"; + this._fieldWrapper.onkeypress = function (e: any) { e.stopPropagation(); }; + this._fieldWrapper.onkeydown = function (e: any) { e.stopPropagation(); }; + this._fieldWrapper.onkeyup = function (e: any) { e.stopPropagation(); }; + this._fieldWrapper.onmousedown = function (e: any) { e.stopPropagation(); }; + + ReactDOM.render(<DashFieldViewInternal + fieldKey={node.attrs.fieldKey} + docid={node.attrs.docid} + width={node.attrs.width} + height={node.attrs.height} + view={view} + getPos={getPos} + tbox={tbox} + />, this._fieldWrapper); + (this as any).dom = this._fieldWrapper; + } + destroy() { + ReactDOM.unmountComponentAtNode(this._fieldWrapper); + } + selectNode() { } + +} +interface IDashFieldViewInternal { + fieldKey: string; + docid: string; + view: any; + getPos: any; + tbox: FormattedTextBox; + width: number; + height: number; +} + +@observer +export class DashFieldViewInternal extends React.Component<IDashFieldViewInternal> { + _reactionDisposer: IReactionDisposer | undefined; + _textBoxDoc: Doc; + _fieldKey: string; + _fieldStringRef = React.createRef<HTMLSpanElement>(); + @observable _showEnumerables: boolean = false; + @observable _dashDoc: Doc | undefined; + + constructor(props: IDashFieldViewInternal) { + super(props); + this._fieldKey = this.props.fieldKey; + this._textBoxDoc = this.props.tbox.props.Document; + + if (this.props.docid) { + DocServer.GetRefField(this.props.docid). + then(action(async dashDoc => dashDoc instanceof Doc && (this._dashDoc = dashDoc))); + } else { + this._dashDoc = this.props.tbox.props.DataDoc || this.props.tbox.dataDoc; + } + } + componentWillUnmount() { + this._reactionDisposer?.(); + } + + // set the display of the field's value (checkbox for booleans, span of text for strings) + @computed get fieldValueContent() { + if (this._dashDoc) { + const dashVal = this._dashDoc[this._fieldKey]; + const fval = StrCast(dashVal).startsWith(":=") || dashVal === "" ? Doc.Layout(this._textBoxDoc)[this._fieldKey] : dashVal; + const boolVal = Cast(fval, "boolean", null); + const strVal = Field.toString(fval as Field) || ""; + + // field value is a boolean, so use a checkbox or similar widget to display it + if (boolVal === true || boolVal === false) { + return <input + className="dashFieldView-fieldCheck" + type="checkbox" checked={boolVal} + onChange={e => this._dashDoc![this._fieldKey] = e.target.checked} + />; + } + else // field value is a string, so display it as an editable span + { + // bcz: this is unfortunate, but since this React component is nested within a non-React text box (prosemirror), we can't + // use React events. Essentially, React events occur after native events have been processed, so corresponding React events + // will never fire because Prosemirror has handled the native events. So we add listeners for native events here. + return <span contentEditable={true} suppressContentEditableWarning={true} defaultValue={strVal} ref={r => { + r?.addEventListener("keydown", e => this.fieldSpanKeyDown(e, r)); + r?.addEventListener("blur", e => r && this.updateText(r.textContent!, false)); + r?.addEventListener("pointerdown", action((e) => this._showEnumerables = true)); + }} > + {strVal} + </span> + } + } + } + + // we need to handle all key events on the input span or else they will propagate to prosemirror. + @action + fieldSpanKeyDown = (e: KeyboardEvent, span: HTMLSpanElement) => { + if (e.key === "Enter") { // handle the enter key by "submitting" the current text to Dash's database. + e.ctrlKey && Doc.addFieldEnumerations(this._textBoxDoc, this._fieldKey, [{ title: span.textContent! }]); + this.updateText(span.textContent!, true); + e.preventDefault();// prevent default to avoid a newline from being generated and wiping out this field view + } + if (e.key === "a" && (e.ctrlKey || e.metaKey)) { // handle ctrl-A to select all the text within the span + if (window.getSelection) { + const range = document.createRange(); + range.selectNodeContents(span); + window.getSelection()!.removeAllRanges(); + window.getSelection()!.addRange(range); + } + e.preventDefault(); //prevent default so that all the text in the prosemirror text box isn't selected + } + e.stopPropagation(); // we need to handle all events or else they will propagate to prosemirror. + } + + @action + updateText = (nodeText: string, forceMatch: boolean) => { + this._showEnumerables = false; + if (nodeText) { + const newText = nodeText.startsWith(":=") || nodeText.startsWith("=:=") ? ":=-computed-" : nodeText; + + // look for a document whose id === the fieldKey being displayed. If there's a match, then that document + // holds the different enumerated values for the field in the titles of its collected documents. + // if there's a partial match from the start of the input text, complete the text --- TODO: make this an auto suggest box and select from a drop down. + DocServer.GetRefField(this._fieldKey).then(options => { + let modText = ""; + (options instanceof Doc) && DocListCast(options.data).forEach(opt => (forceMatch ? StrCast(opt.title).startsWith(newText) : StrCast(opt.title) === newText) && (modText = StrCast(opt.title))); + if (modText) { + // elementfieldSpan.innerHTML = this._dashDoc![this._fieldKey as string] = modText; + Doc.addFieldEnumerations(this._textBoxDoc, this._fieldKey, []); + this._dashDoc![this._fieldKey] = modText; + } // if the text starts with a ':=' then treat it as an expression by making a computed field from its value storing it in the key + else if (nodeText.startsWith(":=")) { + this._dashDoc![this._fieldKey] = ComputedField.MakeFunction(nodeText.substring(2)); + } else if (nodeText.startsWith("=:=")) { + Doc.Layout(this._textBoxDoc)[this._fieldKey] = ComputedField.MakeFunction(nodeText.substring(3)); + } else { + this._dashDoc![this._fieldKey] = newText; + } + }); + } + } + + // display a collection of all the enumerable values for this field + onPointerDownEnumerables = async (e: any) => { + e.stopPropagation(); + const collview = await Doc.addFieldEnumerations(this._textBoxDoc, this._fieldKey, [{ title: this._fieldKey }]); + collview instanceof Doc && this.props.tbox.props.addDocTab(collview, "onRight"); + } + + + // clicking on the label creates a pivot view collection of all documents + // in the same collection. The pivot field is the fieldKey of this label + onPointerDownLabelSpan = (e: any) => { + e.stopPropagation(); + let container = this.props.tbox.props.ContainingCollectionView; + while (container?.props.Document.isTemplateForField || container?.props.Document.isTemplateDoc) { + container = container.props.ContainingCollectionView; + } + if (container) { + const alias = Doc.MakeAlias(container.props.Document); + alias.viewType = CollectionViewType.Time; + let list = Cast(alias.schemaColumns, listSpec(SchemaHeaderField)); + if (!list) { + alias.schemaColumns = list = new List<SchemaHeaderField>(); + } + list.map(c => c.heading).indexOf(this._fieldKey) === -1 && list.push(new SchemaHeaderField(this._fieldKey, "#f1efeb")); + list.map(c => c.heading).indexOf("text") === -1 && list.push(new SchemaHeaderField("text", "#f1efeb")); + alias._pivotField = this._fieldKey; + this.props.tbox.props.addDocTab(alias, "onRight"); + } + } + + render() { + return <div className="dashFieldView" style={{ + width: this.props.width, + height: this.props.height, + }}> + <span className="dashFieldView-labelSpan" title="click to see related tags" onPointerDown={this.onPointerDownLabelSpan}> + {this._fieldKey} + </span> + + <div className="dashFieldView-fieldSpan"> + {this.fieldValueContent} + </div> + + {!this._showEnumerables ? (null) : <div className="dashFieldView-enumerables" onPointerDown={this.onPointerDownEnumerables} />} + + </div >; + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/formattedText/FootnoteView.tsx b/src/client/views/nodes/formattedText/FootnoteView.tsx new file mode 100644 index 000000000..ee21fb765 --- /dev/null +++ b/src/client/views/nodes/formattedText/FootnoteView.tsx @@ -0,0 +1,162 @@ +import { EditorView } from "prosemirror-view"; +import { EditorState } from "prosemirror-state"; +import { keymap } from "prosemirror-keymap"; +import { baseKeymap, toggleMark } from "prosemirror-commands"; +import { schema } from "./schema_rts"; +import { redo, undo } from "prosemirror-history"; +import { StepMap } from "prosemirror-transform"; + +import React = require("react"); + +interface IFootnoteView { + innerView: any; + outerView: any; + node: any; + dom: any; + getPos: any; +} + +export class FootnoteView extends React.Component<IFootnoteView> { + _innerView: any; + _node: any; + + constructor(props: IFootnoteView) { + super(props); + const node = this.props.node; + const outerView = this.props.outerView; + const _innerView = this.props.innerView; + const getPos = this.props.getPos; + } + + selectNode() { + const attrs = { ...this.props.node.attrs }; + attrs.visibility = true; + this.dom.classList.add("ProseMirror-selectednode"); + if (!this.props.innerView) this.open(); + } + + deselectNode() { + const attrs = { ...this.props.node.attrs }; + attrs.visibility = false; + this.dom.classList.remove("ProseMirror-selectednode"); + if (this.props.innerView) this.close(); + } + open() { + // Append a tooltip to the outer node + const tooltip = this.dom.appendChild(document.createElement("div")); + tooltip.className = "footnote-tooltip"; + // And put a sub-ProseMirror into that + this.props.innerView.defineProperty(new EditorView(tooltip, { + // You can use any node as an editor document + state: EditorState.create({ + doc: this.props.node, + plugins: [keymap(baseKeymap), + keymap({ + "Mod-z": () => undo(this.props.outerView.state, this.props.outerView.dispatch), + "Mod-y": () => redo(this.props.outerView.state, this.props.outerView.dispatch), + "Mod-b": toggleMark(schema.marks.strong) + }), + // new Plugin({ + // view(newView) { + // // TODO -- make this work with RichTextMenu + // // return FormattedTextBox.getToolTip(newView); + // } + // }) + ], + + }), + // This is the magic part + dispatchTransaction: this.dispatchInner.bind(this), + handleDOMEvents: { + pointerdown: ((view: any, e: PointerEvent) => { + // Kludge to prevent issues due to the fact that the whole + // footnote is node-selected (and thus DOM-selected) when + // the parent editor is focused. + e.stopPropagation(); + document.addEventListener("pointerup", this.ignore, true); + if (this.props.outerView.hasFocus()) this.props.innerView.focus(); + }) as any + } + })); + setTimeout(() => this.props.innerView && this.props.innerView.docView.setSelection(0, 0, this.props.innerView.root, true), 0); + } + + ignore = (e: PointerEvent) => { + e.stopPropagation(); + document.removeEventListener("pointerup", this.ignore, true); + } + + dispatchInner(tr: any) { + const { state, transactions } = this.props.innerView.state.applyTransaction(tr); + this.props.innerView.updateState(state); + + if (!tr.getMeta("fromOutside")) { + const outerTr = this.props.outerView.state.tr, offsetMap = StepMap.offset(this.props.getPos() + 1); + for (const transaction of transactions) { + const steps = transaction.steps; + for (const step of steps) { + outerTr.step(step.map(offsetMap)); + } + } + if (outerTr.docChanged) this.props.outerView.dispatch(outerTr); + } + } + update(node: any) { + if (!node.sameMarkup(this.props.node)) return false; + this._node = node; //not sure + if (this.props.innerView) { + const state = this.props.innerView.state; + const start = node.content.findDiffStart(state.doc.content); + if (start !== null) { + let { a: endA, b: endB } = node.content.findDiffEnd(state.doc.content); + const overlap = start - Math.min(endA, endB); + if (overlap > 0) { endA += overlap; endB += overlap; } + this.props.innerView.dispatch( + state.tr + .replace(start, endB, node.slice(start, endA)) + .setMeta("fromOutside", true)); + } + } + return true; + } + onPointerUp = (e: any) => { + this.toggle(e); + } + + toggle = (e: any) => { + e.preventDefault(); + if (this.props.innerView) this.close(); + else { + this.open(); + } + } + + close() { + this.props.innerView && this.props.innerView.destroy(); + this._innerView = null; + this.dom.textContent = ""; + } + + destroy() { + if (this.props.innerView) this.close(); + } + + stopEvent(event: any) { + return this.props.innerView && this.props.innerView.dom.contains(event.target); + } + + ignoreMutation() { return true; } + + + render() { + return ( + <div + className="footnote" + onPointerUp={this.onPointerUp}> + <div className="footnote-tooltip" > + + </div > + </div> + ); + } +} diff --git a/src/client/views/nodes/FormattedTextBox.scss b/src/client/views/nodes/formattedText/FormattedTextBox.scss index c203ca0c3..477a2ca08 100644 --- a/src/client/views/nodes/FormattedTextBox.scss +++ b/src/client/views/nodes/formattedText/FormattedTextBox.scss @@ -1,4 +1,4 @@ -@import "../globalCssVariables"; +@import "../../globalCssVariables"; .ProseMirror { width: 100%; @@ -24,11 +24,10 @@ overflow-y: auto; overflow-x: hidden; color: initial; - height: 100%; - pointer-events: all; max-height: 100%; display: flex; flex-direction: row; + transition: opacity 1s; .formattedTextBox-dictation { height: 12px; @@ -38,11 +37,6 @@ position: absolute; } } - -.collectionfreeformview-container { - position: relative; -} - .formattedTextBox-outer { position: relative; overflow: auto; @@ -74,6 +68,10 @@ position: absolute; right: 0; + .collectionfreeformview-container { + position: relative; + } + >.formattedTextBox-sidebar-handle { right: unset; left: -5; @@ -95,8 +93,8 @@ .formattedTextBox-inner-rounded, .formattedTextBox-inner { - padding: 10px 10px; height: 100%; + white-space: pre-wrap; } // .menuicon { diff --git a/src/client/views/nodes/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index 9370d3745..248b4f467 100644 --- a/src/client/views/nodes/FormattedTextBox.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx @@ -1,57 +1,72 @@ import { library } from '@fortawesome/fontawesome-svg-core'; import { faEdit, faSmile, faTextHeight, faUpload } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { isEqual } from "lodash"; -import { action, computed, IReactionDisposer, Lambda, observable, reaction, runInAction, trace, _allowStateChangesInsideComputed } from "mobx"; +import { action, computed, IReactionDisposer, Lambda, observable, reaction, runInAction } from "mobx"; import { observer } from "mobx-react"; import { baseKeymap } from "prosemirror-commands"; import { history } from "prosemirror-history"; import { inputRules } from 'prosemirror-inputrules'; import { keymap } from "prosemirror-keymap"; -import { Fragment, Mark, Node, Node as ProsNode, Slice } from "prosemirror-model"; +import { Fragment, Mark, Node, Slice } from "prosemirror-model"; import { EditorState, NodeSelection, Plugin, TextSelection, Transaction } from "prosemirror-state"; import { ReplaceStep } from 'prosemirror-transform'; import { EditorView } from "prosemirror-view"; -import { DateField } from '../../../new_fields/DateField'; -import { Doc, DocListCastAsync, Opt, WidthSym, HeightSym, DataSym, Field } from "../../../new_fields/Doc"; -import { Copy, Id } from '../../../new_fields/FieldSymbols'; -import { RichTextField } from "../../../new_fields/RichTextField"; -import { RichTextUtils } from '../../../new_fields/RichTextUtils'; -import { createSchema, makeInterface } from "../../../new_fields/Schema"; -import { Cast, NumCast, StrCast } from "../../../new_fields/Types"; -import { numberRange, Utils, addStyleSheet, addStyleSheetRule, clearStyleSheetRules, emptyFunction, returnOne } from '../../../Utils'; -import { GoogleApiClientUtils, Pulls, Pushes } from '../../apis/google_docs/GoogleApiClientUtils'; -import { DocServer } from "../../DocServer"; -import { Docs, DocUtils } from '../../documents/Documents'; -import { DocumentType } from '../../documents/DocumentTypes'; -import { DictationManager } from '../../util/DictationManager'; -import { DragManager } from "../../util/DragManager"; -import buildKeymap from "../../util/ProsemirrorExampleTransfer"; -import { inpRules } from "../../util/RichTextRules"; -import { DashDocCommentView, FootnoteView, ImageResizeView, DashDocView, OrderedListView, schema, SummaryView, DashFieldView } from "../../util/RichTextSchema"; -import { SelectionManager } from "../../util/SelectionManager"; -import { undoBatch, UndoManager } from "../../util/UndoManager"; -import { DocAnnotatableComponent, DocAnnotatableProps } from "../DocComponent"; -import { DocumentButtonBar } from '../DocumentButtonBar'; -import { InkingControl } from "../InkingControl"; -import { FieldView, FieldViewProps } from "./FieldView"; +import { DateField } from '../../../../new_fields/DateField'; +import { DataSym, Doc, DocListCast, DocListCastAsync, Field, HeightSym, Opt, WidthSym } from "../../../../new_fields/Doc"; +import { documentSchema } from '../../../../new_fields/documentSchemas'; +import { Id } from '../../../../new_fields/FieldSymbols'; +import { InkTool } from '../../../../new_fields/InkField'; +import { PrefetchProxy } from '../../../../new_fields/Proxy'; +import { RichTextField } from "../../../../new_fields/RichTextField"; +import { RichTextUtils } from '../../../../new_fields/RichTextUtils'; +import { createSchema, makeInterface } from "../../../../new_fields/Schema"; +import { Cast, DateCast, NumCast, StrCast } from "../../../../new_fields/Types"; +import { TraceMobx } from '../../../../new_fields/util'; +import { addStyleSheet, addStyleSheetRule, clearStyleSheetRules, emptyFunction, numberRange, returnOne, returnZero, Utils } from '../../../../Utils'; +import { GoogleApiClientUtils, Pulls, Pushes } from '../../../apis/google_docs/GoogleApiClientUtils'; +import { DocServer } from "../../../DocServer"; +import { Docs, DocUtils } from '../../../documents/Documents'; +import { DocumentType } from '../../../documents/DocumentTypes'; +import { DictationManager } from '../../../util/DictationManager'; +import { DragManager } from "../../../util/DragManager"; +import { makeTemplate } from '../../../util/DropConverter'; +import buildKeymap from "./ProsemirrorExampleTransfer"; +import RichTextMenu from './RichTextMenu'; +import { RichTextRules } from "./RichTextRules"; +import { DashDocCommentView, DashDocView, FootnoteView, ImageResizeView, OrderedListView, SummaryView } from "./RichTextSchema"; +// import { DashDocCommentView, DashDocView, DashFieldView, FootnoteView, SummaryView } from "./RichTextSchema"; +// import { OrderedListView } from "./RichTextSchema"; +// import { ImageResizeView } from "./ImageResizeView"; +// import { DashDocCommentView } from "./DashDocCommentView"; +// import { FootnoteView } from "./FootnoteView"; +// import { SummaryView } from "./SummaryView"; +// import { DashDocView } from "./DashDocView"; +import { DashFieldView } from "./DashFieldView"; + +import { schema } from "./schema_rts"; +import { SelectionManager } from "../../../util/SelectionManager"; +import { undoBatch, UndoManager } from "../../../util/UndoManager"; +import { CollectionFreeFormView } from '../../collections/collectionFreeForm/CollectionFreeFormView'; +import { ContextMenu } from '../../ContextMenu'; +import { ContextMenuProps } from '../../ContextMenuItem'; +import { ViewBoxAnnotatableComponent } from "../../DocComponent"; +import { DocumentButtonBar } from '../../DocumentButtonBar'; +import { InkingControl } from "../../InkingControl"; +import { AudioBox } from '../AudioBox'; +import { FieldView, FieldViewProps } from "../FieldView"; import "./FormattedTextBox.scss"; import { FormattedTextBoxComment, formattedTextBoxCommentPlugin } from './FormattedTextBoxComment'; import React = require("react"); -import { ContextMenuProps } from '../ContextMenuItem'; -import { ContextMenu } from '../ContextMenu'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { documentSchema } from '../../../new_fields/documentSchemas'; -import { AudioBox } from './AudioBox'; -import { CollectionFreeFormView } from '../collections/collectionFreeForm/CollectionFreeFormView'; -import { InkTool } from '../../../new_fields/InkField'; -import { TraceMobx } from '../../../new_fields/util'; -import RichTextMenu from '../../util/RichTextMenu'; library.add(faEdit); library.add(faSmile, faTextHeight, faUpload); export interface FormattedTextBoxProps { hideOnLeave?: boolean; + makeLink?: () => Opt<Doc>; + xMargin?: number; + yMargin?: number; } const richTextSchema = createSchema({ @@ -66,7 +81,7 @@ const RichTextDocument = makeInterface(richTextSchema, documentSchema); type PullHandler = (exportState: Opt<GoogleApiClientUtils.Docs.ImportResult>, dataDoc: Doc) => void; @observer -export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & FormattedTextBoxProps), RichTextDocument>(RichTextDocument) { +export class FormattedTextBox extends ViewBoxAnnotatableComponent<(FieldViewProps & FormattedTextBoxProps), RichTextDocument>(RichTextDocument) { public static LayoutString(fieldStr: string) { return FieldView.LayoutString(FormattedTextBox, fieldStr); } public static blankState = () => EditorState.create(FormattedTextBox.Instance.config); public static Instance: FormattedTextBox; @@ -80,20 +95,17 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & private _lastX = 0; private _lastY = 0; private _undoTyping?: UndoManager.Batch; - private _searchReactionDisposer?: Lambda; - private _scrollToRegionReactionDisposer: Opt<IReactionDisposer>; - private _reactionDisposer: Opt<IReactionDisposer>; - private _heightReactionDisposer: Opt<IReactionDisposer>; - private _proxyReactionDisposer: Opt<IReactionDisposer>; - private _pullReactionDisposer: Opt<IReactionDisposer>; - private _pushReactionDisposer: Opt<IReactionDisposer>; - private _buttonBarReactionDisposer: Opt<IReactionDisposer>; + private _disposers: { [name: string]: IReactionDisposer } = {}; private dropDisposer?: DragManager.DragDropDisposer; + @computed get _recording() { return this.dataDoc.audioState === "recording"; } + set _recording(value) { this.dataDoc.audioState = value ? "recording" : undefined; } + @observable private _entered = false; public static FocusedBox: FormattedTextBox | undefined; public static SelectOnLoad = ""; + public static SelectOnLoadChar = ""; public static IsFragment(html: string) { return html.indexOf("data-pm-slice") !== -1; } @@ -147,7 +159,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & this.dataDoc[key] = doc || Docs.Create.FreeformDocument([], { title: value, _width: 500, _height: 500 }, value); DocUtils.Publish(this.dataDoc[key] as Doc, value, this.props.addDocument, this.props.removeDocument); if (linkDoc) { (linkDoc as Doc).anchor2 = this.dataDoc[key] as Doc; } - else DocUtils.MakeLink({ doc: this.dataDoc, ctx: this.props.ContainingCollectionDoc }, { doc: this.dataDoc[key] as Doc }, "Ref:" + value, "link to named target", id); + else DocUtils.MakeLink({ doc: this.props.Document }, { doc: this.dataDoc[key] as Doc }, "link to named target", id); }); }); }); @@ -183,27 +195,52 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & (tx.storedMarks && !this._editorView.state.storedMarks) && (this._editorView.state.storedMarks = tx.storedMarks); const tsel = this._editorView.state.selection.$from; - tsel.marks().filter(m => m.type === this._editorView!.state.schema.marks.user_mark).map(m => AudioBox.SetScrubTime(Math.max(0, m.attrs.modified * 5000 - 1000))); - this._applyingChange = true; - if (!this.props.Document._textTemplate || Doc.GetProto(this.props.Document) === this.dataDoc) { - this.dataDoc[this.props.fieldKey + "-lastModified"] && (this.dataDoc[this.props.fieldKey + "-backgroundColor"] = "lightGray"); + tsel.marks().filter(m => m.type === this._editorView!.state.schema.marks.user_mark).map(m => AudioBox.SetScrubTime(Math.max(0, m.attrs.modified * 1000))); + const curText = state.doc.textBetween(0, state.doc.content.size, " \n"); + const curTemp = Cast(this.props.Document[this.props.fieldKey + "-textTemplate"], RichTextField); + if (!this._applyingChange) { + this._applyingChange = true; this.dataDoc[this.props.fieldKey + "-lastModified"] = new DateField(new Date(Date.now())); - this.dataDoc[this.props.fieldKey] = new RichTextField(JSON.stringify(state.toJSON()), state.doc.textBetween(0, state.doc.content.size, "\n\n")); + if (!curTemp || curText) { // if no template, or there's text, write it to the document. (if this is driven by a template, then this overwrites the template text which is intended) + this.dataDoc[this.props.fieldKey] = new RichTextField(JSON.stringify(state.toJSON()), curText); + this.dataDoc[this.props.fieldKey + "-noTemplate"] = (curTemp?.Text || "") !== curText; // mark the data field as being split from the template if it has been edited + } else { // if we've deleted all the text in a note driven by a template, then restore the template data + this._editorView.updateState(EditorState.fromJSON(this.config, JSON.parse(curTemp.Data))); + this.dataDoc[this.props.fieldKey + "-noTemplate"] = undefined; // mark the data field as not being split from any template it might have + } + this._applyingChange = false; } - this._applyingChange = false; this.updateTitle(); this.tryUpdateHeight(); } } updateTitle = () => { - if (StrCast(this.dataDoc.title).startsWith("-") && this._editorView && !this.Document.customTitle) { + if ((this.props.Document.isTemplateForField === "text" || !this.props.Document.isTemplateForField) && // only update the title if the data document's data field is changing + StrCast(this.dataDoc.title).startsWith("-") && this._editorView && !this.rootDoc.customTitle) { const str = this._editorView.state.doc.textContent; const titlestr = str.substr(0, Math.min(40, str.length)); this.dataDoc.title = "-" + titlestr + (str.length > 40 ? "..." : ""); } } + // needs a better API for taking in a set of words with target documents instead of just one target + public hyperlinkTerms = (terms: string[], target: Doc) => { + if (this._editorView && (this._editorView as any).docView && terms.some(t => t)) { + const res = terms.filter(t => t).map(term => this.findInNode(this._editorView!, this._editorView!.state.doc, term)); + const tr = this._editorView.state.tr; + const flattened: TextSelection[] = []; + res.map(r => r.map(h => flattened.push(h))); + const lastSel = Math.min(flattened.length - 1, this._searchIndex); + this._searchIndex = ++this._searchIndex > flattened.length - 1 ? 0 : this._searchIndex; + const alink = DocUtils.MakeLink({ doc: this.props.Document }, { doc: target }, "automatic")!; + const link = this._editorView.state.schema.marks.link.create({ + href: Utils.prepend("/doc/" + alink[Id]), + title: "a link", location: location, linkId: alink[Id], targetId: target[Id] + }); + this._editorView.dispatch(tr.addMark(flattened[lastSel].from, flattened[lastSel].to, link)); + } + } public highlightSearchTerms = (terms: string[]) => { if (this._editorView && (this._editorView as any).docView && terms.some(t => t)) { const mark = this._editorView.state.schema.mark(this._editorView.state.schema.marks.search_highlight); @@ -234,8 +271,8 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & } protected createDropTarget = (ele: HTMLDivElement) => { this.ProseRef = ele; - this.dropDisposer && this.dropDisposer(); - ele && (this.dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this))); + this.dropDisposer?.(); + ele && (this.dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this), this.props.Document)); } @undoBatch @@ -244,22 +281,11 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & if (de.complete.docDragData) { const draggedDoc = de.complete.docDragData.draggedDocuments.length && de.complete.docDragData.draggedDocuments[0]; // replace text contents whend dragging with Alt - if (draggedDoc && draggedDoc.type === DocumentType.TEXT && !Doc.AreProtosEqual(draggedDoc, this.props.Document) && de.altKey) { + if (draggedDoc && draggedDoc.type === DocumentType.RTF && !Doc.AreProtosEqual(draggedDoc, this.props.Document) && de.altKey) { if (draggedDoc.data instanceof RichTextField) { Doc.GetProto(this.dataDoc)[this.props.fieldKey] = new RichTextField(draggedDoc.data.Data, draggedDoc.data.Text); e.stopPropagation(); } - // apply as template when dragging with Meta - } else if (draggedDoc && draggedDoc.type === DocumentType.TEXT && !Doc.AreProtosEqual(draggedDoc, this.props.Document) && de.metaKey) { - draggedDoc.isTemplateDoc = true; - let newLayout = Doc.Layout(draggedDoc); - if (typeof (draggedDoc.layout) === "string") { - newLayout = Doc.MakeDelegate(draggedDoc); - newLayout.layout = StrCast(newLayout.layout).replace(/fieldKey={'[^']*'}/, `fieldKey={'${this.props.fieldKey}'}`); - } - this.Document.layout_custom = newLayout; - this.Document.layoutKey = "layout_custom"; - e.stopPropagation(); // embed document when dragging with a userDropAction or an embedDoc flag set } else if (de.complete.docDragData.userDropAction || de.complete.docDragData.embedDoc) { const target = de.complete.docDragData.droppedDocuments[0]; @@ -277,8 +303,16 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & e.stopPropagation(); // } } // otherwise, fall through to outer collection to handle drop + } else if (de.complete.linkDragData) { + de.complete.linkDragData.linkDropCallback = this.linkDrop; } } + linkDrop = (data: DragManager.LinkDragData) => { + const linkDoc = data.linkDocument!; + const anchor1Title = linkDoc.anchor1 instanceof Doc ? StrCast(linkDoc.anchor1.title) : "-untitled-"; + const anchor1Id = linkDoc.anchor1 instanceof Doc ? linkDoc.anchor1[Id] : ""; + this.makeLinkToSelection(linkDoc[Id], anchor1Title, "onRight", anchor1Id); + } getNodeEndpoints(context: Node, node: Node): { from: number, to: number } | null { let offset = 0; @@ -326,10 +360,10 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & updateHighlights = () => { clearStyleSheetRules(FormattedTextBox._userStyleSheet); if (FormattedTextBox._highlights.indexOf("Text from Others") !== -1) { - addStyleSheetRule(FormattedTextBox._userStyleSheet, "userMark-remote", { background: "yellow" }); + addStyleSheetRule(FormattedTextBox._userStyleSheet, "UM-remote", { background: "yellow" }); } if (FormattedTextBox._highlights.indexOf("My Text") !== -1) { - addStyleSheetRule(FormattedTextBox._userStyleSheet, "userMark-" + Doc.CurrentUserEmail.replace(".", "").replace("@", ""), { background: "moccasin" }); + addStyleSheetRule(FormattedTextBox._userStyleSheet, "UM-" + Doc.CurrentUserEmail.replace(".", "").replace("@", ""), { background: "moccasin" }); } if (FormattedTextBox._highlights.indexOf("Todo Items") !== -1) { addStyleSheetRule(FormattedTextBox._userStyleSheet, "userTag-" + "todo", { outline: "black solid 1px" }); @@ -344,15 +378,15 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & addStyleSheetRule(FormattedTextBox._userStyleSheet, "userTag-" + "ignore", { "font-size": "1" }); } if (FormattedTextBox._highlights.indexOf("By Recent Minute") !== -1) { - addStyleSheetRule(FormattedTextBox._userStyleSheet, "userMark-" + Doc.CurrentUserEmail.replace(".", "").replace("@", ""), { opacity: "0.1" }); + addStyleSheetRule(FormattedTextBox._userStyleSheet, "UM-" + Doc.CurrentUserEmail.replace(".", "").replace("@", ""), { opacity: "0.1" }); const min = Math.round(Date.now() / 1000 / 60); - numberRange(10).map(i => addStyleSheetRule(FormattedTextBox._userStyleSheet, "userMark-min-" + (min - i), { opacity: ((10 - i - 1) / 10).toString() })); + numberRange(10).map(i => addStyleSheetRule(FormattedTextBox._userStyleSheet, "UM-min-" + (min - i), { opacity: ((10 - i - 1) / 10).toString() })); setTimeout(() => this.updateHighlights()); } if (FormattedTextBox._highlights.indexOf("By Recent Hour") !== -1) { - addStyleSheetRule(FormattedTextBox._userStyleSheet, "userMark-" + Doc.CurrentUserEmail.replace(".", "").replace("@", ""), { opacity: "0.1" }); + addStyleSheetRule(FormattedTextBox._userStyleSheet, "UM-" + Doc.CurrentUserEmail.replace(".", "").replace("@", ""), { opacity: "0.1" }); const hr = Math.round(Date.now() / 1000 / 60 / 60); - numberRange(10).map(i => addStyleSheetRule(FormattedTextBox._userStyleSheet, "userMark-hr-" + (hr - i), { opacity: ((10 - i - 1) / 10).toString() })); + numberRange(10).map(i => addStyleSheetRule(FormattedTextBox._userStyleSheet, "UM-hr-" + (hr - i), { opacity: ((10 - i - 1) / 10).toString() })); } } @@ -377,12 +411,29 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & toggleSidebar = () => this._sidebarMovement < 5 && (this.props.Document.sidebarWidthPercent = StrCast(this.props.Document.sidebarWidthPercent, "0%") === "0%" ? "25%" : "0%"); + public static get DefaultLayout(): Doc | string | undefined { + return Cast(Doc.UserDoc().defaultTextLayout, Doc, null) || StrCast(Doc.UserDoc().defaultTextLayout, null); + } specificContextMenu = (e: React.MouseEvent): void => { + const cm = ContextMenu.Instance; + const funcs: ContextMenuProps[] = []; - funcs.push({ description: "Toggle Sidebar", event: () => { e.stopPropagation(); this.props.Document._showSidebar = !this.props.Document._showSidebar }, icon: "expand-arrows-alt" }); - funcs.push({ description: "Record Bullet", event: () => { e.stopPropagation(); this.recordBullet(); }, icon: "expand-arrows-alt" }); + this.props.Document.isTemplateDoc && funcs.push({ description: "Make Default Layout", event: async () => Doc.UserDoc().defaultTextLayout = new PrefetchProxy(this.props.Document), icon: "eye" }); + funcs.push({ description: "Reset Default Layout", event: () => Doc.UserDoc().defaultTextLayout = undefined, icon: "eye" }); + !this.props.Document.rootDocument && funcs.push({ + description: "Make Template", event: () => { + this.props.Document.isTemplateDoc = makeTemplate(this.props.Document); + Doc.AddDocToList(Cast(Doc.UserDoc()["template-notes"], Doc, null), "data", this.props.Document); + }, icon: "eye" + }); + funcs.push({ description: "Toggle Single Line", event: () => this.props.Document._singleLine = !this.props.Document._singleLine, icon: "expand-arrows-alt" }); + funcs.push({ description: "Toggle Sidebar", event: () => this.props.Document._showSidebar = !this.props.Document._showSidebar, icon: "expand-arrows-alt" }); + funcs.push({ description: "Toggle Dictation Icon", event: () => this.props.Document._showAudio = !this.props.Document._showAudio, icon: "expand-arrows-alt" }); + funcs.push({ description: "Toggle Menubar", event: () => this.toggleMenubar(), icon: "expand-arrows-alt" }); + + const highlighting: ContextMenuProps[] = []; ["My Text", "Text from Others", "Todo Items", "Important Items", "Ignore Items", "Disagree Items", "By Recent Minute", "By Recent Hour"].forEach(option => - funcs.push({ + highlighting.push({ description: (FormattedTextBox._highlights.indexOf(option) === -1 ? "Highlight " : "Unhighlight ") + option, event: () => { e.stopPropagation(); if (FormattedTextBox._highlights.indexOf(option) === -1) { @@ -393,16 +444,40 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & this.updateHighlights(); }, icon: "expand-arrows-alt" })); + funcs.push({ description: "highlighting...", subitems: highlighting, icon: "hand-point-right" }); - ContextMenu.Instance.addItem({ description: "Text Funcs...", subitems: funcs, icon: "asterisk" }); - } + ContextMenu.Instance.addItem({ description: "Options...", subitems: funcs, icon: "asterisk" }); - @observable _recording = false; + const change = cm.findByDescription("Change Perspective..."); + const changeItems: ContextMenuProps[] = change && "subitems" in change ? change.subitems : []; + + const noteTypesDoc = Cast(Doc.UserDoc()["template-notes"], Doc, null); + DocListCast(noteTypesDoc?.data).forEach(note => { + changeItems.push({ + description: StrCast(note.title), event: undoBatch(() => { + Doc.setNativeView(this.props.Document); + Doc.makeCustomViewClicked(this.rootDoc, Docs.Create.TreeDocument, StrCast(note.title), note); + }), icon: "eye" + }); + }); + changeItems.push({ description: "FreeForm", event: undoBatch(() => Doc.makeCustomViewClicked(this.rootDoc, Docs.Create.FreeformDocument, "freeform"), "change view"), icon: "eye" }); + !change && cm.addItem({ description: "Change Perspective...", subitems: changeItems, icon: "external-link-alt" }); + + const open = cm.findByDescription("Add a Perspective..."); + const openItems: ContextMenuProps[] = open && "subitems" in open ? open.subitems : []; + + openItems.push({ + description: "FreeForm", event: undoBatch(() => { + const alias = Doc.MakeAlias(this.rootDoc); + Doc.makeCustomViewClicked(alias, Docs.Create.FreeformDocument, "freeform"); + this.props.addDocTab(alias, "onRight"); + }), icon: "eye" + }); + !open && cm.addItem({ description: "Add a Perspective...", subitems: openItems, icon: "external-link-alt" }); + + } recordDictation = () => { - //this._editorView!.focus(); - if (this._recording) return; - runInAction(() => this._recording = true); DictationManager.Controls.listen({ interimHandler: this.setCurrentBulletContent, continuous: { indefinite: false }, @@ -410,12 +485,14 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & if (results && [DictationManager.Controls.Infringed].includes(results)) { DictationManager.Controls.stop(); } - this._editorView!.focus(); + //this._editorView!.focus(); }); } - stopDictation = (abort: boolean) => { - runInAction(() => this._recording = false); - DictationManager.Controls.stop(!abort); + stopDictation = (abort: boolean) => { DictationManager.Controls.stop(!abort); }; + + @action + toggleMenubar = () => { + this.props.Document._chromeStatus = this.props.Document._chromeStatus === "disabled" ? "enabled" : "disabled"; } recordBullet = async () => { @@ -435,13 +512,25 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & setCurrentBulletContent = (value: string) => { if (this._editorView) { - let state = this._editorView.state; + const state = this._editorView.state; + const now = Date.now(); + let mark = schema.marks.user_mark.create({ userid: Doc.CurrentUserEmail, modified: Math.floor(now / 1000) }); + if (!this._break && state.selection.to !== state.selection.from) { + for (let i = state.selection.from; i <= state.selection.to; i++) { + const pos = state.doc.resolve(i); + const um = Array.from(pos.marks()).find(m => m.type === schema.marks.user_mark); + if (um) { + mark = um; + break; + } + } + } + const recordingStart = DateCast(this.props.Document.recordingStart).date.getTime(); + this._break = false; + value = "" + (mark.attrs.modified * 1000 - recordingStart) / 1000 + value; const from = state.selection.from; - const to = state.selection.to; - this._editorView.dispatch(state.tr.insertText(value, from, to)); - state = this._editorView.state; - const updated = TextSelection.create(state.doc, from, from + value.length); - this._editorView.dispatch(state.tr.setSelection(updated)); + const inserted = state.tr.insertText(value).addMark(from, from + value.length + 1, mark); + this._editorView.dispatch(inserted.setSelection(TextSelection.create(inserted.doc, from, from + value.length + 1))); } } @@ -464,13 +553,14 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & } _keymap: any = undefined; + _rules: RichTextRules | undefined; @computed get config() { - this._keymap = buildKeymap(schema); - (schema as any).Document = this.props.Document; + this._keymap = buildKeymap(schema, this.props); + this._rules = new RichTextRules(this.props.Document, this); return { schema, plugins: [ - inputRules(inpRules), + inputRules(this._rules.inpRules), this.richTextMenuPlugin(), history(), keymap(this._keymap), @@ -485,8 +575,15 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & }; } + makeLinkToSelection(linkDocId: string, title: string, location: string, targetDocId: string) { + if (this._editorView) { + const link = this._editorView.state.schema.marks.link.create({ href: Utils.prepend("/doc/" + linkDocId), title: title, location: location, linkId: linkDocId, targetId: targetDocId }); + this._editorView.dispatch(this._editorView.state.tr.removeMark(this._editorView.state.selection.from, this._editorView.state.selection.to, this._editorView.state.schema.marks.link). + addMark(this._editorView.state.selection.from, this._editorView.state.selection.to, link)); + } + } componentDidMount() { - this._buttonBarReactionDisposer = reaction( + this._disposers.buttonBar = reaction( () => DocumentButtonBar.Instance, instance => { if (instance) { @@ -495,22 +592,33 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & } } ); - - this._reactionDisposer = reaction( + this._disposers.linkMaker = reaction( + () => this.props.makeLink?.(), + (linkDoc: Opt<Doc>) => { + if (linkDoc) { + const anchor2Title = linkDoc.anchor2 instanceof Doc ? StrCast(linkDoc.anchor2.title) : "-untitled-"; + const anchor2Id = linkDoc.anchor2 instanceof Doc ? linkDoc.anchor2[Id] : ""; + this.makeLinkToSelection(linkDoc[Id], anchor2Title, "onRight", anchor2Id); + } + }, + { fireImmediately: true } + ); + this._disposers.editorState = reaction( () => { - const field = Cast(this.props.Document._textTemplate || this.dataDoc[this.props.fieldKey], RichTextField); - return field ? field.Data : RichTextUtils.Initialize(); + if (this.dataDoc[this.props.fieldKey + "-noTemplate"] || !this.props.Document[this.props.fieldKey + "-textTemplate"]) { + return Cast(this.dataDoc[this.props.fieldKey], RichTextField, null)?.Data; + } + return Cast(this.props.Document[this.props.fieldKey + "-textTemplate"], RichTextField, null)?.Data; }, incomingValue => { - if (this._editorView && !this._applyingChange) { + if (incomingValue !== undefined && this._editorView && !this._applyingChange) { const updatedState = JSON.parse(incomingValue); this._editorView.updateState(EditorState.fromJSON(this.config, updatedState)); this.tryUpdateHeight(); } } ); - - this._pullReactionDisposer = reaction( + this._disposers.pullDoc = reaction( () => this.props.Document[Pulls], () => { if (!DocumentButtonBar.hasPulledHack) { @@ -520,8 +628,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & } } ); - - this._pushReactionDisposer = reaction( + this._disposers.pushDoc = reaction( () => this.props.Document[Pushes], () => { if (!DocumentButtonBar.hasPushedHack) { @@ -530,19 +637,28 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & } } ); - - this._heightReactionDisposer = reaction( + this._disposers.height = reaction( () => [this.layoutDoc[WidthSym](), this.layoutDoc._autoHeight], () => this.tryUpdateHeight() ); this.setupEditor(this.config, this.props.fieldKey); - this._searchReactionDisposer = reaction(() => this.layoutDoc.searchMatch, + this._disposers.search = reaction(() => this.rootDoc.searchMatch, search => search ? this.highlightSearchTerms([Doc.SearchQuery()]) : this.unhighlightSearchTerms(), { fireImmediately: true }); - this._scrollToRegionReactionDisposer = reaction( + this._disposers.record = reaction(() => this._recording, + () => { + if (this._recording) { + setTimeout(action(() => { + this.stopDictation(true); + setTimeout(() => this.recordDictation(), 500); + }), 500); + } else setTimeout(() => this.stopDictation(true), 0); + } + ); + this._disposers.scrollToRegion = reaction( () => StrCast(this.layoutDoc.scrollToLinkID), async (scrollToLinkID) => { const findLinkFrag = (frag: Fragment, editor: EditorView) => { @@ -574,7 +690,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & if (ret.frag.size > 2 && ret.start >= 0) { let selection = TextSelection.near(editor.state.doc.resolve(ret.start)); // default to near the start if (ret.frag.firstChild) { - selection = TextSelection.between(editor.state.doc.resolve(ret.start + 2), editor.state.doc.resolve(ret.start + ret.frag.firstChild.nodeSize)); // bcz: looks better to not have the target selected + selection = TextSelection.between(editor.state.doc.resolve(ret.start), editor.state.doc.resolve(ret.start + ret.frag.firstChild.nodeSize)); // bcz: looks better to not have the target selected } editor.dispatch(editor.state.tr.setSelection(new TextSelection(selection.$from, selection.$from)).scrollIntoView()); const mark = editor.state.schema.mark(this._editorView.state.schema.marks.search_highlight); @@ -587,6 +703,9 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & }, { fireImmediately: true } ); + this._disposers.scroll = reaction(() => NumCast(this.props.Document.scrollPos), + pos => this._scrollRef.current && this._scrollRef.current.scrollTo({ top: pos }), { fireImmediately: true } + ); setTimeout(() => this.tryUpdateHeight(NumCast(this.layoutDoc.limitHeight, 0))); } @@ -650,7 +769,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & } }, 0); dataDoc.title = exportState.title; - this.Document.customTitle = true; + this.rootDoc.customTitle = true; dataDoc.unchanged = true; } else { delete dataDoc[GoogleRef]; @@ -703,7 +822,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & targetAnnotations?.push(pdfRegion); }); - const link = DocUtils.MakeLink({ doc: this.props.Document, ctx: this.props.ContainingCollectionDoc }, { doc: pdfRegion, ctx: pdfDoc }, "note on " + pdfDoc.title, "pasted PDF link"); + const link = DocUtils.MakeLink({ doc: this.props.Document }, { doc: pdfRegion }, "PDF pasted"); if (link) { cbe.clipboardData!.setData("dash/linkDoc", link[Id]); const linkId = link[Id]; @@ -739,20 +858,19 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & } private setupEditor(config: any, fieldKey: string) { - const rtfField = Cast(this.props.Document._textTemplate || this.dataDoc[fieldKey], RichTextField); + const curText = Cast(this.dataDoc[this.props.fieldKey], RichTextField, null); + const useTemplate = !curText?.Text && this.props.Document[this.props.fieldKey + "-textTemplate"]; + const rtfField = Cast((useTemplate && this.props.Document[this.props.fieldKey + "-textTemplate"]) || this.dataDoc[fieldKey], RichTextField); if (this.ProseRef) { const self = this; this._editorView?.destroy(); this._editorView = new EditorView(this.ProseRef, { state: rtfField?.Data ? EditorState.fromJSON(config, JSON.parse(rtfField.Data)) : EditorState.create(config), handleScrollToSelection: (editorView) => { - const ref = editorView.domAtPos(editorView.state.selection.from); - let refNode = ref.node as any; - while (refNode && !("getBoundingClientRect" in refNode)) refNode = refNode.parentElement; - const r1 = refNode?.getBoundingClientRect(); - const r3 = self._ref.current!.getBoundingClientRect(); - if (r1.top < r3.top || r1.top > r3.bottom) { - r1 && (self._scrollRef.current!.scrollTop += (r1.top - r3.top) * self.props.ScreenToLocalTransform().Scale); + const docPos = editorView.coordsAtPos(editorView.state.selection.from); + const viewRect = self._ref.current!.getBoundingClientRect(); + if (docPos.top < viewRect.top || docPos.top > viewRect.bottom) { + docPos && (self._scrollRef.current!.scrollTop += (docPos.top - viewRect.top) * self.props.ScreenToLocalTransform().Scale); } return true; }, @@ -761,7 +879,15 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & dashComment(node, view, getPos) { return new DashDocCommentView(node, view, getPos); }, dashField(node, view, getPos) { return new DashFieldView(node, view, getPos, self); }, dashDoc(node, view, getPos) { return new DashDocView(node, view, getPos, self); }, - image(node, view, getPos) { return new ImageResizeView(node, view, getPos, self.props.addDocTab); }, + // dashDoc(node, view, getPos) { return new DashDocView({ node, view, getPos, self }); }, + + // image(node, view, getPos) { + // //const addDocTab = this.props.addDocTab; + // return new ImageResizeView({ node, view, getPos, addDocTab: this.props.addDocTab }); + // }, + // // WAS : + // //image(node, view, getPos) { return new ImageResizeView(node, view, getPos, this.props.addDocTab); }, + summary(node, view, getPos) { return new SummaryView(node, view, getPos); }, ordered_list(node, view, getPos) { return new OrderedListView(); }, footnote(node, view, getPos) { return new FootnoteView(node, view, getPos); } @@ -769,21 +895,24 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & clipboardTextSerializer: this.clipboardTextSerializer, handlePaste: this.handlePaste, }); - this._editorView.state.schema.Document = this.props.Document; const startupText = !rtfField && this._editorView && Field.toString(this.dataDoc[fieldKey] as Field); if (startupText) { - this._editorView.dispatch(this._editorView.state.tr.insertText(startupText)); + const { state: { tr }, dispatch } = this._editorView; + dispatch(tr.insertText(startupText)); } } - const selectOnLoad = this.props.Document[Id] === FormattedTextBox.SelectOnLoad; - if (selectOnLoad) { + const selectOnLoad = this.rootDoc[Id] === FormattedTextBox.SelectOnLoad; + if (selectOnLoad && !this.props.dontRegisterView) { FormattedTextBox.SelectOnLoad = ""; this.props.select(false); + FormattedTextBox.SelectOnLoadChar && this._editorView!.dispatch(this._editorView!.state.tr.insertText(FormattedTextBox.SelectOnLoadChar)); + FormattedTextBox.SelectOnLoadChar = ""; + } (selectOnLoad /* || !rtfField?.Text*/) && this._editorView!.focus(); // add user mark for any first character that was typed since the user mark that gets set in KeyPress won't have been called yet. - this._editorView!.state.storedMarks = [...(this._editorView!.state.storedMarks ? this._editorView!.state.storedMarks : []), schema.marks.user_mark.create({ userid: Doc.CurrentUserEmail, modified: Math.round(Date.now() / 1000 / 5) })]; + this._editorView!.state.storedMarks = [...(this._editorView!.state.storedMarks ? this._editorView!.state.storedMarks : []), schema.marks.user_mark.create({ userid: Doc.CurrentUserEmail, modified: Math.floor(Date.now() / 1000) })]; } getFont(font: string) { switch (font) { @@ -799,23 +928,32 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & } componentWillUnmount() { - this._scrollToRegionReactionDisposer && this._scrollToRegionReactionDisposer(); - this._reactionDisposer && this._reactionDisposer(); - this._proxyReactionDisposer && this._proxyReactionDisposer(); - this._pushReactionDisposer && this._pushReactionDisposer(); - this._pullReactionDisposer && this._pullReactionDisposer(); - this._heightReactionDisposer && this._heightReactionDisposer(); - this._searchReactionDisposer && this._searchReactionDisposer(); - this._buttonBarReactionDisposer && this._buttonBarReactionDisposer(); - this._editorView && this._editorView.destroy(); + Object.values(this._disposers).forEach(disposer => disposer?.()); + this._editorView?.destroy(); } static _downEvent: any; + _downX = 0; + _downY = 0; + _break = false; onPointerDown = (e: React.PointerEvent): void => { + if (this._recording && !e.ctrlKey && e.button === 0) { + this.stopDictation(true); + this._break = true; + const state = this._editorView!.state; + const to = state.selection.to; + const updated = TextSelection.create(state.doc, to, to); + this._editorView!.dispatch(this._editorView!.state.tr.setSelection(updated).insertText("\n", to)); + e.preventDefault(); + e.stopPropagation(); + if (this._recording) setTimeout(() => this.recordDictation(), 500); + } + this._downX = e.clientX; + this._downY = e.clientY; this.doLinkOnDeselect(); FormattedTextBox._downEvent = true; FormattedTextBoxComment.textBox = this; - if (this.props.onClick && e.button === 0) { + if (this.props.onClick && e.button === 0 && !this.props.isSelected(false)) { e.preventDefault(); } if (e.button === 0 && this.active(true) && !e.altKey && !e.ctrlKey && !e.metaKey) { @@ -840,6 +978,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & if (e.buttons === 1 && this.props.isSelected(true) && !e.altKey) { e.stopPropagation(); } + this._downX = this._downY = Number.NaN; } @action @@ -853,12 +992,16 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & prosediv && (prosediv.keeplocation = undefined); const pos = this._editorView?.state.selection.$from.pos || 1; keeplocation && setTimeout(() => this._editorView?.dispatch(this._editorView?.state.tr.setSelection(TextSelection.create(this._editorView.state.doc, pos)))); + const coords = !Number.isNaN(this._downX) ? { left: this._downX, top: this._downY, bottom: this._downY, right: this._downX } : this._editorView?.coordsAtPos(pos); // jump rich text menu to this textbox - const { current } = this._ref; - if (current) { - const x = Math.min(Math.max(current.getBoundingClientRect().left, 0), window.innerWidth - RichTextMenu.Instance.width); - const y = this._ref.current!.getBoundingClientRect().top - RichTextMenu.Instance.height - 50; + const bounds = this._ref.current?.getBoundingClientRect(); + if (bounds && this.props.Document._chromeStatus !== "disabled") { + const x = Math.min(Math.max(bounds.left, 0), window.innerWidth - RichTextMenu.Instance.width); + let y = Math.min(Math.max(0, bounds.top - RichTextMenu.Instance.height - 50), window.innerHeight - RichTextMenu.Instance.height); + if (coords && coords.left > x && coords.left < x + RichTextMenu.Instance.width && coords.top > y && coords.top < y + RichTextMenu.Instance.height + 50) { + y = Math.min(bounds.bottom, window.innerHeight - RichTextMenu.Instance.height); + } RichTextMenu.Instance.jumpTo(x, y); } } @@ -910,7 +1053,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & // if (linkClicked) { // DocServer.GetRefField(linkClicked).then(async linkDoc => { // (linkDoc instanceof Doc) && - // DocumentManager.Instance.FollowLink(linkDoc, this.props.Document, document => this.props.addDocTab(document, undefined, location ? location : "inTab"), false); + // DocumentManager.Instance.FollowLink(linkDoc, this.props.Document, document => this.props.addDocTab(document, location ? location : "inTab"), false); // }); // } // } else { @@ -922,8 +1065,10 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & // } // } - this.hitBulletTargets(e.clientX, e.clientY, e.shiftKey, false); - if (this._recording) setTimeout(() => { this.stopDictation(true); setTimeout(() => this.recordDictation(), 500); }, 500); + if (Math.abs(e.clientX - this._downX) < 4 && Math.abs(e.clientX - this._downX) < 4) { + this.props.select(e.ctrlKey); + this.hitBulletTargets(e.clientX, e.clientY, e.shiftKey, false); + } } // this hackiness handles clicking on the list item bullets to do expand/collapse. the bullets are ::before pseudo elements so there's no real way to hit test against them. @@ -954,8 +1099,9 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & addStyleSheetRule(FormattedTextBox._bulletStyleSheet, list_node.attrs.mapStyle + list_node.attrs.bulletStyle + ":hover:before", { background: "lightgray" }); } else if (Math.abs(pos.pos - pos.inside) < 2) { if (!highlightOnly) { - this._editorView!.dispatch(this._editorView!.state.tr.setNodeMarkup(pos.inside, list_node.type, { ...list_node.attrs, visibility: !list_node.attrs.visibility })); - this._editorView!.dispatch(this._editorView!.state.tr.setSelection(TextSelection.create(this._editorView!.state.doc, pos.inside))); + const offset = this._editorView!.state.doc.nodeAt(pos.inside)?.type === schema.nodes.ordered_list ? 1 : 0; + this._editorView!.dispatch(this._editorView!.state.tr.setNodeMarkup(pos.inside + offset, list_node.type, { ...list_node.attrs, visibility: !list_node.attrs.visibility })); + this._editorView!.dispatch(this._editorView!.state.tr.setSelection(TextSelection.create(this._editorView!.state.doc, pos.inside + offset))); } addStyleSheetRule(FormattedTextBox._bulletStyleSheet, list_node.attrs.mapStyle + list_node.attrs.bulletStyle + ":hover:before", { background: "lightgray" }); } @@ -981,16 +1127,17 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & } richTextMenuPlugin() { - const self = FormattedTextBox; return new Plugin({ view(newView) { - RichTextMenu.Instance.changeView(newView); + RichTextMenu.Instance && RichTextMenu.Instance.changeView(newView); return RichTextMenu.Instance; } }); } + public static HadSelection: boolean = false; onBlur = (e: any) => { + FormattedTextBox.HadSelection = window.getSelection()?.toString() !== ""; //DictationManager.Controls.stop(false); if (this._undoTyping) { this._undoTyping.end(); @@ -1010,14 +1157,14 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & } const state = this._editorView!.state; if (!state.selection.empty && e.key === "%") { - state.schema.EnteringStyle = true; + this._rules!.EnteringStyle = true; e.preventDefault(); e.stopPropagation(); return; } - if (state.selection.empty || !state.schema.EnteringStyle) { - state.schema.EnteringStyle = false; + if (state.selection.empty || !this._rules!.EnteringStyle) { + this._rules!.EnteringStyle = false; } if (e.key === "Escape") { this._editorView!.dispatch(state.tr.setSelection(TextSelection.create(state.doc, state.selection.from, state.selection.from))); @@ -1028,19 +1175,18 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & if (e.key === "Tab" || e.key === "Enter") { e.preventDefault(); } - const mark = e.key !== " " && this._lastTimedMark ? this._lastTimedMark : schema.marks.user_mark.create({ userid: Doc.CurrentUserEmail, modified: Math.round(Date.now() / 1000 / 5) }); + const mark = e.key !== " " && this._lastTimedMark ? this._lastTimedMark : schema.marks.user_mark.create({ userid: Doc.CurrentUserEmail, modified: Math.floor(Date.now() / 1000) }); this._lastTimedMark = mark; this._editorView!.dispatch(this._editorView!.state.tr.removeStoredMark(schema.marks.user_mark.create({})).addStoredMark(mark)); if (!this._undoTyping) { this._undoTyping = UndoManager.StartBatch("undoTyping"); } - if (this._recording) { - this.stopDictation(true); - setTimeout(() => this.recordDictation(), 250); - } } + onscrolled = (ev: React.UIEvent) => { + this.props.Document.scrollPos = this._scrollRef.current!.scrollTop; + } @action tryUpdateHeight(limitHeight?: number) { let scrollHeight = this._ref.current?.scrollHeight; @@ -1051,7 +1197,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & this.layoutDoc.limitHeight = undefined; this.layoutDoc._autoHeight = false; } - const nh = this.Document.isTemplateForField ? 0 : NumCast(this.dataDoc._nativeHeight, 0); + const nh = this.layoutDoc.isTemplateForField ? 0 : NumCast(this.dataDoc._nativeHeight, 0); const dh = NumCast(this.layoutDoc._height, 0); const newHeight = Math.max(10, (nh ? dh / nh * scrollHeight : scrollHeight) + (this.props.ChromeHeight ? this.props.ChromeHeight() : 0)); if (Math.abs(newHeight - dh) > 1) { // bcz: Argh! without this, we get into a React crash if the same document is opened in a freeform view and in the treeview. no idea why, but after dragging the freeform document, selecting it, and selecting text, it will compute to 1 pixel higher than the treeview which causes a cycle @@ -1062,7 +1208,7 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & } @computed get sidebarWidthPercent() { return StrCast(this.props.Document.sidebarWidthPercent, "0%"); } - sidebarWidth = () => { return Number(this.sidebarWidthPercent.substring(0, this.sidebarWidthPercent.length - 1)) / 100 * this.props.PanelWidth(); } + sidebarWidth = () => Number(this.sidebarWidthPercent.substring(0, this.sidebarWidthPercent.length - 1)) / 100 * this.props.PanelWidth(); sidebarScreenToLocal = () => this.props.ScreenToLocalTransform().translate(-(this.props.PanelWidth() - this.sidebarWidth()), 0); @computed get sidebarColor() { return StrCast(this.layoutDoc[this.props.fieldKey + "-backgroundColor"], StrCast(this.layoutDoc[this.props.fieldKey + "-backgroundColor"], "transparent")); } render() { @@ -1075,15 +1221,16 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & FormattedTextBoxComment.Hide(); } return ( + <div className={`formattedTextBox-cont`} ref={this._ref} style={{ - height: this.layoutDoc._autoHeight ? "max-content" : undefined, - background: this.props.hideOnLeave ? "rgba(0,0,0 ,0.4)" : undefined, + height: this.layoutDoc._autoHeight && this.props.renderDepth ? "max-content" : `calc(100% - ${this.props.ChromeHeight?.() || 0}px`, + background: StrCast(this.layoutDoc[this.props.fieldKey + "-backgroundColor"], this.props.hideOnLeave ? "rgba(0,0,0 ,0.4)" : ""), opacity: this.props.hideOnLeave ? (this._entered ? 1 : 0.1) : 1, - color: this.props.hideOnLeave ? "white" : "inherit", - pointerEvents: interactive ? "none" : "all", - fontSize: NumCast(this.layoutDoc.fontSize, 13), - fontFamily: StrCast(this.layoutDoc.fontFamily, "Crimson Text"), + color: StrCast(this.layoutDoc[this.props.fieldKey + "-color"], this.props.hideOnLeave ? "white" : "inherit"), + pointerEvents: interactive ? "none" : undefined, + fontSize: Cast(this.layoutDoc._fontSize, "number", null), + fontFamily: StrCast(this.layoutDoc._fontFamily, "inherit"), }} onContextMenu={this.specificContextMenu} onKeyDown={this.onKeyPress} @@ -1096,10 +1243,22 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & onMouseUp={this.onMouseUp} onWheel={this.onPointerWheel} onPointerEnter={action(() => this._entered = true)} - onPointerLeave={action(() => this._entered = false)} + onPointerLeave={action((e: React.PointerEvent<HTMLDivElement>) => { + this._entered = false; + const target = document.elementFromPoint(e.nativeEvent.x, e.nativeEvent.y); + for (let child: any = target; child; child = child?.parentElement) { + if (child === this._ref.current!) { + this._entered = true; + } + } + })} > - <div className={`formattedTextBox-outer`} style={{ width: `calc(100% - ${this.sidebarWidthPercent})`, }} ref={this._scrollRef}> - <div className={`formattedTextBox-inner${rounded}`} style={{ whiteSpace: "pre-wrap", pointerEvents: ((this.Document.isButton || this.props.onClick) && !this.props.isSelected()) ? "none" : undefined }} ref={this.createDropTarget} /> + <div className={`formattedTextBox-outer`} style={{ width: `calc(100% - ${this.sidebarWidthPercent})`, }} onScroll={this.onscrolled} ref={this._scrollRef}> + <div className={`formattedTextBox-inner${rounded}`} ref={this.createDropTarget} + style={{ + padding: `${NumCast(this.layoutDoc._yMargin, this.props.yMargin || 0)}px ${NumCast(this.layoutDoc._xMargin, this.props.xMargin || 0)}px`, + pointerEvents: ((this.layoutDoc.isLinkButton || this.props.onClick) && !this.props.isSelected()) ? "none" : undefined + }} /> </div> {!this.props.Document._showSidebar ? (null) : this.sidebarWidthPercent === "0%" ? <div className="formattedTextBox-sidebar-handle" onPointerDown={this.sidebarDown} onClick={e => this.toggleSidebar()} /> : @@ -1108,6 +1267,8 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & <CollectionFreeFormView {...this.props} PanelHeight={this.props.PanelHeight} PanelWidth={this.sidebarWidth} + NativeHeight={returnZero} + NativeWidth={returnZero} annotationsKey={this.annotationKey} isAnnotationOverlay={false} focus={this.props.focus} @@ -1122,20 +1283,20 @@ export class FormattedTextBox extends DocAnnotatableComponent<(FieldViewProps & CollectionView={undefined} ScreenToLocalTransform={this.sidebarScreenToLocal} renderDepth={this.props.renderDepth + 1} - ContainingCollectionDoc={this.props.ContainingCollectionDoc} - chromeCollapsed={true}> + ContainingCollectionDoc={this.props.ContainingCollectionDoc}> </CollectionFreeFormView> <div className="formattedTextBox-sidebar-handle" onPointerDown={this.sidebarDown} onClick={e => this.toggleSidebar()} /> </div>} - <div className="formattedTextBox-dictation" - onClick={e => { - this._recording ? this.stopDictation(true) : this.recordDictation(); - setTimeout(() => this._editorView!.focus(), 500); - e.stopPropagation(); - }} > - <FontAwesomeIcon className="formattedTExtBox-audioFont" - style={{ color: this._recording ? "red" : "blue", opacity: this._recording ? 1 : 0.5, display: this.props.isSelected() ? "" : "none" }} icon={"microphone"} size="sm" /> - </div> + {!this.props.Document._showAudio ? (null) : + <div className="formattedTextBox-dictation" + onPointerDown={e => { + runInAction(() => this._recording = !this._recording); + setTimeout(() => this._editorView!.focus(), 500); + e.stopPropagation(); + }} > + <FontAwesomeIcon className="formattedTExtBox-audioFont" + style={{ color: this._recording ? "red" : "blue", opacity: this._recording ? 1 : 0.5, display: this.props.isSelected() ? "" : "none" }} icon={"microphone"} size="sm" /> + </div>} </div> ); } diff --git a/src/client/views/nodes/FormattedTextBoxComment.scss b/src/client/views/nodes/formattedText/FormattedTextBoxComment.scss index 2dd63ec21..2dd63ec21 100644 --- a/src/client/views/nodes/FormattedTextBoxComment.scss +++ b/src/client/views/nodes/formattedText/FormattedTextBoxComment.scss diff --git a/src/client/views/nodes/FormattedTextBoxComment.tsx b/src/client/views/nodes/formattedText/FormattedTextBoxComment.tsx index fda3e3285..f9e4c5210 100644 --- a/src/client/views/nodes/FormattedTextBoxComment.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBoxComment.tsx @@ -2,19 +2,20 @@ import { Mark, ResolvedPos } from "prosemirror-model"; import { EditorState, Plugin } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import * as ReactDOM from 'react-dom'; -import { Doc } from "../../../new_fields/Doc"; -import { Cast, FieldValue, NumCast } from "../../../new_fields/Types"; -import { emptyFunction, returnEmptyString, returnFalse, Utils, emptyPath } from "../../../Utils"; -import { DocServer } from "../../DocServer"; -import { DocumentManager } from "../../util/DocumentManager"; -import { schema } from "../../util/RichTextSchema"; -import { Transform } from "../../util/Transform"; -import { ContentFittingDocumentView } from "./ContentFittingDocumentView"; +import { Doc, DocCastAsync } from "../../../../new_fields/Doc"; +import { Cast, FieldValue, NumCast } from "../../../../new_fields/Types"; +import { emptyFunction, returnEmptyString, returnFalse, Utils, emptyPath } from "../../../../Utils"; +import { DocServer } from "../../../DocServer"; +import { DocumentManager } from "../../../util/DocumentManager"; +import { schema } from "./schema_rts"; +import { Transform } from "../../../util/Transform"; +import { ContentFittingDocumentView } from "../ContentFittingDocumentView"; import { FormattedTextBox } from "./FormattedTextBox"; import './FormattedTextBoxComment.scss'; import React = require("react"); -import { Docs } from "../../documents/Documents"; +import { Docs } from "../../../documents/Documents"; import wiki from "wikijs"; +import { DocumentType } from "../../../documents/DocumentTypes"; export let formattedTextBoxCommentPlugin = new Plugin({ view(editorView) { return new FormattedTextBoxComment(editorView); } @@ -83,10 +84,14 @@ export class FormattedTextBoxComment { const keep = e.target && (e.target as any).type === "checkbox" ? true : false; const textBox = FormattedTextBoxComment.textBox; if (FormattedTextBoxComment.linkDoc && !keep && textBox) { - DocumentManager.Instance.FollowLink(FormattedTextBoxComment.linkDoc, textBox.props.Document, - (doc: Doc, maxLocation: string) => textBox.props.addDocTab(doc, undefined, e.ctrlKey ? "inTab" : "onRight")); + if (FormattedTextBoxComment.linkDoc.type !== DocumentType.LINK) { + textBox.props.addDocTab(FormattedTextBoxComment.linkDoc, e.ctrlKey ? "inTab" : "onRight"); + } else { + DocumentManager.Instance.FollowLink(FormattedTextBoxComment.linkDoc, textBox.props.Document, + (doc: Doc, followLinkLocation: string) => textBox.props.addDocTab(doc, e.ctrlKey ? "inTab" : followLinkLocation)); + } } else if (textBox && (FormattedTextBoxComment.tooltipText as any).href) { - textBox.props.addDocTab(Docs.Create.WebDocument((FormattedTextBoxComment.tooltipText as any).href, { title: (FormattedTextBoxComment.tooltipText as any).href, _width: 200, _height: 400 }), undefined, "onRight"); + textBox.props.addDocTab(Docs.Create.WebDocument((FormattedTextBoxComment.tooltipText as any).href, { title: (FormattedTextBoxComment.tooltipText as any).href, _width: 200, _height: 400 }), "onRight"); } keep && textBox && FormattedTextBoxComment.start !== undefined && textBox.adoptAnnotation( FormattedTextBoxComment.start, FormattedTextBoxComment.end, FormattedTextBoxComment.mark); @@ -100,6 +105,7 @@ export class FormattedTextBoxComment { public static Hide() { FormattedTextBoxComment.textBox = undefined; FormattedTextBoxComment.tooltip && (FormattedTextBoxComment.tooltip.style.display = "none"); + ReactDOM.unmountComponentAtNode(FormattedTextBoxComment.tooltipText); } public static SetState(textBox: any, start: number, end: number, mark: Mark) { FormattedTextBoxComment.textBox = textBox; @@ -167,20 +173,25 @@ export class FormattedTextBoxComment { FormattedTextBoxComment.tooltipText.textContent = "target not found..."; (FormattedTextBoxComment.tooltipText as any).href = ""; const docTarget = mark.attrs.href.replace(Utils.prepend("/doc/"), "").split("?")[0]; - docTarget && DocServer.GetRefField(docTarget).then(linkDoc => { + try { + ReactDOM.unmountComponentAtNode(FormattedTextBoxComment.tooltipText); + } catch (e) { } + docTarget && DocServer.GetRefField(docTarget).then(async linkDoc => { if (linkDoc instanceof Doc) { (FormattedTextBoxComment.tooltipText as any).href = mark.attrs.href; FormattedTextBoxComment.linkDoc = linkDoc; - const target = FieldValue(Doc.AreProtosEqual(FieldValue(Cast(linkDoc.anchor1, Doc)), textBox.props.Document) ? Cast(linkDoc.anchor2, Doc) : (Cast(linkDoc.anchor1, Doc)) || linkDoc); - try { - ReactDOM.unmountComponentAtNode(FormattedTextBoxComment.tooltipText); - } catch (e) { } + const anchor = FieldValue(Doc.AreProtosEqual(FieldValue(Cast(linkDoc.anchor1, Doc)), textBox.dataDoc) ? Cast(linkDoc.anchor2, Doc) : (Cast(linkDoc.anchor1, Doc)) || linkDoc); + const target = anchor?.annotationOn ? await DocCastAsync(anchor.annotationOn) : anchor; + if (anchor !== target && anchor && target) { + target.scrollY = NumCast(anchor?.y); + } if (target) { ReactDOM.render(<ContentFittingDocumentView Document={target} LibraryPath={emptyPath} fitToBox={true} moveDocument={returnFalse} + rootSelected={returnFalse} getTransform={Transform.Identity} active={returnFalse} addDocument={returnFalse} @@ -189,8 +200,8 @@ export class FormattedTextBoxComment { pinToPres={returnFalse} dontRegisterView={true} renderDepth={1} - PanelWidth={() => Math.min(350, NumCast(target.width, 350))} - PanelHeight={() => Math.min(250, NumCast(target.height, 250))} + PanelWidth={() => Math.min(350, NumCast(target._width, 350))} + PanelHeight={() => Math.min(250, NumCast(target._height, 250))} focus={emptyFunction} whenActiveChanged={returnFalse} />, FormattedTextBoxComment.tooltipText); @@ -211,7 +222,7 @@ export class FormattedTextBoxComment { // let start = view.coordsAtPos(state.selection.from), end = view.coordsAtPos(state.selection.to); const start = view.coordsAtPos(state.selection.from - nbef), end = view.coordsAtPos(state.selection.from - nbef); // The box in which the tooltip is positioned, to use as base - const box = (document.getElementById("mainView-container") as any).getBoundingClientRect(); + const box = (document.getElementsByClassName("mainView-container") as any)[0].getBoundingClientRect(); // Find a center-ish x position from the selection endpoints (when // crossing lines, end may be more to the left) const left = Math.max((start.left + end.left) / 2, start.left + 3); diff --git a/src/client/views/nodes/formattedText/ImageResizeView.tsx b/src/client/views/nodes/formattedText/ImageResizeView.tsx new file mode 100644 index 000000000..8f98da0fd --- /dev/null +++ b/src/client/views/nodes/formattedText/ImageResizeView.tsx @@ -0,0 +1,138 @@ +import { NodeSelection } from "prosemirror-state"; +import { Doc } from "../../../../new_fields/Doc"; +import { DocServer } from "../../../DocServer"; +import { DocumentManager } from "../../../util/DocumentManager"; +import React = require("react"); + +import { schema } from "./schema_rts"; + +interface IImageResizeView { + node: any; + view: any; + getPos: any; + addDocTab: any; +} + +export class ImageResizeView extends React.Component<IImageResizeView> { + constructor(props: IImageResizeView) { + super(props); + } + + onClickImg = (e: any) => { + e.stopPropagation(); + e.preventDefault(); + if (this.props.view.state.selection.node && this.props.view.state.selection.node.type !== this.props.view.state.schema.nodes.image) { + this.props.view.dispatch(this.props.view.state.tr.setSelection(new NodeSelection(this.props.view.state.doc.resolve(this.props.view.state.selection.from - 2)))); + } + } + + onPointerDownImg = (e: any) => { + if (e.ctrlKey) { + e.preventDefault(); + e.stopPropagation(); + DocServer.GetRefField(this.props.node.attrs.docid).then(async linkDoc => + (linkDoc instanceof Doc) && + DocumentManager.Instance.FollowLink(linkDoc, this.props.view.state.schema.Document, + document => this.props.addDocTab(document, this.props.node.attrs.location ? this.props.node.attrs.location : "inTab"), false)); + } + } + + onPointerDownHandle = (e: any) => { + e.preventDefault(); + e.stopPropagation(); + const elementImage = document.getElementById("imageId") as HTMLElement; + const wid = Number(getComputedStyle(elementImage).width.replace(/px/, "")); + const hgt = Number(getComputedStyle(elementImage).height.replace(/px/, "")); + const startX = e.pageX; + const startWidth = parseFloat(this.props.node.attrs.width); + + const onpointermove = (e: any) => { + const elementOuter = document.getElementById("outerId") as HTMLElement; + + const currentX = e.pageX; + const diffInPx = currentX - startX; + elementOuter.style.width = `${startWidth + diffInPx}`; + elementOuter.style.height = `${(startWidth + diffInPx) * hgt / wid}`; + }; + + const onpointerup = () => { + document.removeEventListener("pointermove", onpointermove); + document.removeEventListener("pointerup", onpointerup); + const pos = this.props.view.state.selection.from; + const elementOuter = document.getElementById("outerId") as HTMLElement; + this.props.view.dispatch(this.props.view.state.tr.setNodeMarkup(this.props.getPos(), null, { ...this.props.node.attrs, width: elementOuter.style.width, height: elementOuter.style.height })); + this.props.view.dispatch(this.props.view.state.tr.setSelection(new NodeSelection(this.props.view.state.doc.resolve(pos)))); + }; + + document.addEventListener("pointermove", onpointermove); + document.addEventListener("pointerup", onpointerup); + } + + selectNode() { + const elementImage = document.getElementById("imageId") as HTMLElement; + const elementHandle = document.getElementById("handleId") as HTMLElement; + + elementImage.classList.add("ProseMirror-selectednode"); + elementHandle.style.display = ""; + } + + deselectNode() { + const elementImage = document.getElementById("imageId") as HTMLElement; + const elementHandle = document.getElementById("handleId") as HTMLElement; + + elementImage.classList.remove("ProseMirror-selectednode"); + elementHandle.style.display = "none"; + } + + + render() { + + const outerStyle = { + width: this.props.node.attrs.width, + height: this.props.node.attrs.height, + display: "inline-block", + overflow: "hidden", + float: this.props.node.attrs.float + }; + + const imageStyle = { + width: "100%", + }; + + const handleStyle = { + position: "absolute", + width: "20px", + heiht: "20px", + backgroundColor: "blue", + borderRadius: "15px", + display: "none", + bottom: "-10px", + right: "-10px" + + }; + + + + return ( + <div id="outer" + style={outerStyle} + > + <img + id="imageId" + style={imageStyle} + src={this.props.node.src} + onClick={this.onClickImg} + onPointerDown={this.onPointerDownImg} + + > + </img> + <span + id="handleId" + onPointerDown={this.onPointerDownHandle} + > + + </span> + </div > + ); + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/formattedText/ParagraphNodeSpec.ts b/src/client/views/nodes/formattedText/ParagraphNodeSpec.ts new file mode 100644 index 000000000..d80e64634 --- /dev/null +++ b/src/client/views/nodes/formattedText/ParagraphNodeSpec.ts @@ -0,0 +1,143 @@ +import clamp from '../../../util/clamp'; +import convertToCSSPTValue from '../../../util/convertToCSSPTValue'; +import toCSSLineSpacing from '../../../util/toCSSLineSpacing'; +import { Node, DOMOutputSpec } from 'prosemirror-model'; + +//import type { NodeSpec } from './Types'; +type NodeSpec = { + attrs?: { [key: string]: any }, + content?: string, + draggable?: boolean, + group?: string, + inline?: boolean, + name?: string, + parseDOM?: Array<any>, + toDOM?: (node: any) => DOMOutputSpec, +}; + +// This assumes that every 36pt maps to one indent level. +export const INDENT_MARGIN_PT_SIZE = 36; +export const MIN_INDENT_LEVEL = 0; +export const MAX_INDENT_LEVEL = 7; +export const ATTRIBUTE_INDENT = 'data-indent'; + +export const EMPTY_CSS_VALUE = new Set(['', '0%', '0pt', '0px']); + +const ALIGN_PATTERN = /(left|right|center|justify)/; + +// https://github.com/ProseMirror/prosemirror-schema-basic/blob/master/src/schema-basic.js +// :: NodeSpec A plain paragraph textblock. Represented in the DOM +// as a `<p>` element. +const ParagraphNodeSpec: NodeSpec = { + attrs: { + align: { default: null }, + color: { default: null }, + id: { default: null }, + indent: { default: null }, + inset: { default: null }, + lineSpacing: { default: null }, + // TODO: Add UI to let user edit / clear padding. + paddingBottom: { default: null }, + // TODO: Add UI to let user edit / clear padding. + paddingTop: { default: null }, + }, + content: 'inline*', + group: 'block', + parseDOM: [{ tag: 'p', getAttrs }], + toDOM, +}; + +function getAttrs(dom: HTMLElement): Object { + const { + lineHeight, + textAlign, + marginLeft, + paddingTop, + paddingBottom, + } = dom.style; + + let align = dom.getAttribute('align') || textAlign || ''; + align = ALIGN_PATTERN.test(align) ? align : ""; + + let indent = parseInt(dom.getAttribute(ATTRIBUTE_INDENT) || "", 10); + + if (!indent && marginLeft) { + indent = convertMarginLeftToIndentValue(marginLeft); + } + + indent = indent || MIN_INDENT_LEVEL; + + const lineSpacing = lineHeight ? toCSSLineSpacing(lineHeight) : null; + + const id = dom.getAttribute('id') || ''; + return { align, indent, lineSpacing, paddingTop, paddingBottom, id }; +} + +function toDOM(node: Node): DOMOutputSpec { + const { + align, + indent, + inset, + lineSpacing, + paddingTop, + paddingBottom, + id, + } = node.attrs; + const attrs: { [key: string]: any } | null = {}; + + let style = ''; + if (align && align !== 'left') { + style += `text-align: ${align};`; + } + + if (lineSpacing) { + const cssLineSpacing = toCSSLineSpacing(lineSpacing); + style += + `line-height: ${cssLineSpacing};` + + // This creates the local css variable `--czi-content-line-height` + // that its children may apply. + `--czi-content-line-height: ${cssLineSpacing}`; + } + + if (paddingTop && !EMPTY_CSS_VALUE.has(paddingTop)) { + style += `padding-top: ${paddingTop};`; + } + + if (paddingBottom && !EMPTY_CSS_VALUE.has(paddingBottom)) { + style += `padding-bottom: ${paddingBottom};`; + } + + if (indent) { + style += `text-indent: ${indent}; padding-left: ${indent < 0 ? -indent : undefined};`; + } + + if (inset) { + style += `margin-left: ${inset}; margin-right: ${inset};`; + } + + style && (attrs.style = style); + + if (indent) { + attrs[ATTRIBUTE_INDENT] = String(indent); + } + + if (id) { + attrs.id = id; + } + + return ['p', attrs, 0]; +} + +export const toParagraphDOM = toDOM; +export const getParagraphNodeAttrs = getAttrs; + +export function convertMarginLeftToIndentValue(marginLeft: string): number { + const ptValue = convertToCSSPTValue(marginLeft); + return clamp( + MIN_INDENT_LEVEL, + Math.floor(ptValue / INDENT_MARGIN_PT_SIZE), + MAX_INDENT_LEVEL + ); +} + +export default ParagraphNodeSpec;
\ No newline at end of file diff --git a/src/client/views/nodes/formattedText/ProsemirrorExampleTransfer.ts b/src/client/views/nodes/formattedText/ProsemirrorExampleTransfer.ts new file mode 100644 index 000000000..a0b02880e --- /dev/null +++ b/src/client/views/nodes/formattedText/ProsemirrorExampleTransfer.ts @@ -0,0 +1,241 @@ +import { chainCommands, exitCode, joinDown, joinUp, lift, selectParentNode, setBlockType, splitBlockKeepMarks, toggleMark, wrapIn } from "prosemirror-commands"; +import { redo, undo } from "prosemirror-history"; +import { undoInputRule } from "prosemirror-inputrules"; +import { Schema } from "prosemirror-model"; +import { liftListItem, sinkListItem } from "./prosemirrorPatches.js"; +import { splitListItem, wrapInList, } from "prosemirror-schema-list"; +import { EditorState, Transaction, TextSelection } from "prosemirror-state"; +import { SelectionManager } from "../../../util/SelectionManager"; +import { Docs } from "../../../documents/Documents"; +import { NumCast, BoolCast, Cast, StrCast } from "../../../../new_fields/Types"; +import { Doc } from "../../../../new_fields/Doc"; +import { FormattedTextBox } from "./FormattedTextBox"; +import { Id } from "../../../../new_fields/FieldSymbols"; + +const mac = typeof navigator !== "undefined" ? /Mac/.test(navigator.platform) : false; + +export type KeyMap = { [key: string]: any }; + +export let updateBullets = (tx2: Transaction, schema: Schema, mapStyle?: string) => { + let fontSize: number | undefined = undefined; + tx2.doc.descendants((node: any, offset: any, index: any) => { + if (node.type === schema.nodes.ordered_list || node.type === schema.nodes.list_item) { + const path = (tx2.doc.resolve(offset) as any).path; + let depth = Array.from(path).reduce((p: number, c: any) => p + (c.hasOwnProperty("type") && c.type === schema.nodes.ordered_list ? 1 : 0), 0); + if (node.type === schema.nodes.ordered_list) depth++; + fontSize = depth === 1 && node.attrs.setFontSize ? Number(node.attrs.setFontSize) : fontSize; + const fsize = fontSize && node.type === schema.nodes.ordered_list ? Math.max(6, fontSize - (depth - 1) * 4) : undefined; + tx2.setNodeMarkup(offset, node.type, { ...node.attrs, mapStyle: mapStyle ? mapStyle : node.attrs.mapStyle, bulletStyle: depth, inheritedFontSize: fsize }, node.marks); + } + }); + return tx2; +}; +export default function buildKeymap<S extends Schema<any>>(schema: S, props: any, mapKeys?: KeyMap): KeyMap { + const keys: { [key: string]: any } = {}; + + function bind(key: string, cmd: any) { + if (mapKeys) { + const mapped = mapKeys[key]; + if (mapped === false) return; + if (mapped) key = mapped; + } + keys[key] = cmd; + } + + bind("Mod-z", undo); + bind("Shift-Mod-z", redo); + bind("Backspace", undoInputRule); + + !mac && bind("Mod-y", redo); + + bind("Alt-ArrowUp", joinUp); + bind("Alt-ArrowDown", joinDown); + bind("Mod-BracketLeft", lift); + bind("Escape", (state: EditorState<S>, dispatch: (tx: Transaction<S>) => void) => { + dispatch(state.tr.setSelection(TextSelection.create(state.doc, state.selection.from, state.selection.from))); + (document.activeElement as any).blur?.(); + SelectionManager.DeselectAll(); + }); + + bind("Mod-b", toggleMark(schema.marks.strong)); + bind("Mod-B", toggleMark(schema.marks.strong)); + + bind("Mod-e", toggleMark(schema.marks.em)); + bind("Mod-E", toggleMark(schema.marks.em)); + + bind("Mod-u", toggleMark(schema.marks.underline)); + bind("Mod-U", toggleMark(schema.marks.underline)); + + bind("Mod-`", toggleMark(schema.marks.code)); + + bind("Ctrl-.", wrapInList(schema.nodes.bullet_list)); + + bind("Ctrl-n", wrapInList(schema.nodes.ordered_list)); + + bind("Ctrl->", wrapIn(schema.nodes.blockquote)); + + // bind("^", (state: EditorState<S>, dispatch: (tx: Transaction<S>) => void) => { + // let newNode = schema.nodes.footnote.create({}); + // if (dispatch && state.selection.from === state.selection.to) { + // let tr = state.tr; + // tr.replaceSelectionWith(newNode); // replace insertion with a footnote. + // dispatch(tr.setSelection(new NodeSelection( // select the footnote node to open its display + // tr.doc.resolve( // get the location of the footnote node by subtracting the nodesize of the footnote from the current insertion point anchor (which will be immediately after the footnote node) + // tr.selection.anchor - tr.selection.$anchor.nodeBefore!.nodeSize)))); + // return true; + // } + // return false; + // }); + + + const cmd = chainCommands(exitCode, (state, dispatch) => { + if (dispatch) { + dispatch(state.tr.replaceSelectionWith(schema.nodes.hard_break.create()).scrollIntoView()); + return true; + } + return false; + }); + bind("Mod-Enter", cmd); + bind("Shift-Enter", cmd); + mac && bind("Ctrl-Enter", cmd); + + + bind("Shift-Ctrl-0", setBlockType(schema.nodes.paragraph)); + + bind("Shift-Ctrl-\\", setBlockType(schema.nodes.code_block)); + + for (let i = 1; i <= 6; i++) { + bind("Shift-Ctrl-" + i, setBlockType(schema.nodes.heading, { level: i })); + } + + const hr = schema.nodes.horizontal_rule; + bind("Mod-_", (state: EditorState<S>, dispatch: (tx: Transaction<S>) => void) => { + dispatch(state.tr.replaceSelectionWith(hr.create()).scrollIntoView()); + return true; + }); + + bind("Tab", (state: EditorState<S>, dispatch: (tx: Transaction<S>) => void) => { + const ref = state.selection; + const range = ref.$from.blockRange(ref.$to); + const marks = state.storedMarks || (state.selection.$to.parentOffset && state.selection.$from.marks()); + if (!sinkListItem(schema.nodes.list_item)(state, (tx2: Transaction) => { + const tx3 = updateBullets(tx2, schema); + marks && tx3.ensureMarks([...marks]); + marks && tx3.setStoredMarks([...marks]); + dispatch(tx3); + })) { // couldn't sink into an existing list, so wrap in a new one + const newstate = state.applyTransaction(state.tr.setSelection(TextSelection.create(state.doc, range!.start, range!.end))); + if (!wrapInList(schema.nodes.ordered_list)(newstate.state, (tx2: Transaction) => { + const tx3 = updateBullets(tx2, schema); + // when promoting to a list, assume list will format things so don't copy the stored marks. + marks && tx3.ensureMarks([...marks]); + marks && tx3.setStoredMarks([...marks]); + dispatch(tx3); + })) { + console.log("bullet promote fail"); + } + } + }); + + bind("Shift-Tab", (state: EditorState<S>, dispatch: (tx: Transaction<S>) => void) => { + const marks = state.storedMarks || (state.selection.$to.parentOffset && state.selection.$from.marks()); + + if (!liftListItem(schema.nodes.list_item)(state.tr, (tx2: Transaction) => { + const tx3 = updateBullets(tx2, schema); + marks && tx3.ensureMarks([...marks]); + marks && tx3.setStoredMarks([...marks]); + dispatch(tx3); + })) { + console.log("bullet demote fail"); + } + }); + bind("Ctrl-Enter", (state: EditorState<S>, dispatch: (tx: Transaction<S>) => void) => { + const layoutDoc = props.Document; + const originalDoc = layoutDoc.rootDocument || layoutDoc; + if (originalDoc instanceof Doc) { + const layoutKey = StrCast(originalDoc.layoutKey); + const newDoc = Docs.Create.TextDocument("", { + layout: Cast(originalDoc.layout, Doc, null) || FormattedTextBox.DefaultLayout, + layoutKey, + _singleLine: BoolCast(originalDoc._singleLine), + x: NumCast(originalDoc.x), y: NumCast(originalDoc.y) + NumCast(originalDoc._height) + 10, _width: NumCast(layoutDoc._width), _height: NumCast(layoutDoc._height) + }); + if (layoutKey !== "layout" && originalDoc[layoutKey] instanceof Doc) { + newDoc[layoutKey] = originalDoc[layoutKey]; + } + FormattedTextBox.SelectOnLoad = newDoc[Id]; + props.addDocument(newDoc); + } + }); + + const splitMetadata = (marks: any, tx: Transaction) => { + marks && tx.ensureMarks(marks.filter((val: any) => val.type !== schema.marks.metadata && val.type !== schema.marks.metadataKey && val.type !== schema.marks.metadataVal)); + marks && tx.setStoredMarks(marks.filter((val: any) => val.type !== schema.marks.metadata && val.type !== schema.marks.metadataKey && val.type !== schema.marks.metadataVal)); + return tx; + }; + const addTextOnRight = (force: boolean) => { + const layoutDoc = props.Document; + const originalDoc = layoutDoc.rootDocument || layoutDoc; + if (force || props.Document._singleLine) { + const layoutKey = StrCast(originalDoc.layoutKey); + const newDoc = Docs.Create.TextDocument("", { + layout: Cast(originalDoc.layout, Doc, null) || FormattedTextBox.DefaultLayout, + layoutKey, + _singleLine: BoolCast(originalDoc._singleLine), + x: NumCast(originalDoc.x) + NumCast(originalDoc._width) + 10, y: NumCast(originalDoc.y), _width: NumCast(layoutDoc._width), _height: NumCast(layoutDoc._height) + }); + if (layoutKey !== "layout" && originalDoc[layoutKey] instanceof Doc) { + newDoc[layoutKey] = originalDoc[layoutKey]; + } + FormattedTextBox.SelectOnLoad = newDoc[Id]; + props.addDocument(newDoc); + return true; + } + return false; + }; + bind("Alt-Enter", (state: EditorState<S>, dispatch: (tx: Transaction<Schema<any, any>>) => void) => { + return addTextOnRight(true); + }); + bind("Enter", (state: EditorState<S>, dispatch: (tx: Transaction<Schema<any, any>>) => void) => { + if (addTextOnRight(false)) return true; + const marks = state.storedMarks || (state.selection.$to.parentOffset && state.selection.$from.marks()); + if (!splitListItem(schema.nodes.list_item)(state, dispatch)) { + if (!splitBlockKeepMarks(state, (tx3: Transaction) => { + splitMetadata(marks, tx3); + if (!liftListItem(schema.nodes.list_item)(tx3, dispatch as ((tx: Transaction<Schema<any, any>>) => void))) { + dispatch(tx3); + } + })) { + return false; + } + } + return true; + }); + bind("Space", (state: EditorState<S>, dispatch: (tx: Transaction<S>) => void) => { + const marks = state.storedMarks || (state.selection.$to.parentOffset && state.selection.$from.marks()); + dispatch(splitMetadata(marks, state.tr)); + return false; + }); + bind(":", (state: EditorState<S>, dispatch: (tx: Transaction<S>) => void) => { + const range = state.selection.$from.blockRange(state.selection.$to, (node: any) => { + return !node.marks || !node.marks.find((m: any) => m.type === schema.marks.metadata); + }); + const path = (state.doc.resolve(state.selection.from - 1) as any).path; + const spaceSeparator = path[path.length - 3].childCount > 1 ? 0 : -1; + const anchor = range!.end - path[path.length - 3].lastChild.nodeSize + spaceSeparator; + if (anchor >= 0) { + const textsel = TextSelection.create(state.doc, anchor, range!.end); + const text = range ? state.doc.textBetween(textsel.from, textsel.to) : ""; + let whitespace = text.length - 1; + for (; whitespace >= 0 && text[whitespace] !== " "; whitespace--) { } + if (text.endsWith(":")) { + dispatch(state.tr.addMark(textsel.from + whitespace + 1, textsel.to, schema.marks.metadata.create() as any). + addMark(textsel.from + whitespace + 1, textsel.to - 2, schema.marks.metadataKey.create() as any)); + } + } + return false; + }); + + + return keys; +} diff --git a/src/client/views/nodes/formattedText/RichTextMenu.scss b/src/client/views/nodes/formattedText/RichTextMenu.scss new file mode 100644 index 000000000..36da769c3 --- /dev/null +++ b/src/client/views/nodes/formattedText/RichTextMenu.scss @@ -0,0 +1,121 @@ +@import "../../globalCssVariables"; + +.button-dropdown-wrapper { + position: relative; + + .dropdown-button { + width: 15px; + padding-left: 5px; + padding-right: 5px; + } + + .dropdown-button-combined { + width: 50px; + display: flex; + justify-content: space-between; + + svg:nth-child(2) { + margin-top: 2px; + } + } + + .dropdown { + position: absolute; + top: 35px; + left: 0; + background-color: #323232; + color: $light-color-secondary; + border: 1px solid #4d4d4d; + border-radius: 0 6px 6px 6px; + box-shadow: 3px 3px 3px rgba(0, 0, 0, 0.25); + min-width: 150px; + padding: 5px; + font-size: 12px; + z-index: 10001; + + button { + background-color: #323232; + border: 1px solid black; + border-radius: 1px; + padding: 6px; + margin: 5px 0; + font-size: 10px; + + &:hover { + background-color: black; + } + + &:last-child { + margin-bottom: 0; + } + } + } + + input { + color: black; + } +} + +.link-menu { + .divider { + background-color: white; + height: 1px; + width: 100%; + } +} + +.color-preview-button { + .color-preview { + width: 100%; + height: 3px; + margin-top: 3px; + } +} + +.color-wrapper { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + + button.color-button { + width: 20px; + height: 20px; + border-radius: 15px !important; + margin: 3px; + border: 2px solid transparent !important; + padding: 3px; + + &.active { + border: 2px solid white !important; + } + } +} + +select { + background-color: #323232; + color: white; + border: 1px solid black; + // border-top: none; + // border-bottom: none; + font-size: 12px; + height: 100%; + margin-right: 3px; + + &:focus, + &:hover { + background-color: black; + } + + &::-ms-expand { + color: white; + } +} + +.row-2 { + display: flex; + justify-content: space-between; + + >div { + display: flex; + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/formattedText/RichTextMenu.tsx b/src/client/views/nodes/formattedText/RichTextMenu.tsx new file mode 100644 index 000000000..cc04e0d6d --- /dev/null +++ b/src/client/views/nodes/formattedText/RichTextMenu.tsx @@ -0,0 +1,875 @@ +import React = require("react"); +import AntimodeMenu from "../../AntimodeMenu"; +import { observable, action, } from "mobx"; +import { observer } from "mobx-react"; +import { Mark, MarkType, Node as ProsNode, NodeType, ResolvedPos, Schema } from "prosemirror-model"; +import { schema } from "./schema_rts"; +import { EditorView } from "prosemirror-view"; +import { EditorState, NodeSelection, TextSelection } from "prosemirror-state"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { IconProp, library } from '@fortawesome/fontawesome-svg-core'; +import { faBold, faItalic, faChevronLeft, faUnderline, faStrikethrough, faSubscript, faSuperscript, faIndent, faEyeDropper, faCaretDown, faPalette, faHighlighter, faLink, faPaintRoller, faSleigh } from "@fortawesome/free-solid-svg-icons"; +import { updateBullets } from "./ProsemirrorExampleTransfer"; +import { FieldViewProps } from "../FieldView"; +import { Cast, StrCast } from "../../../../new_fields/Types"; +import { FormattedTextBoxProps } from "./FormattedTextBox"; +import { unimplementedFunction, Utils } from "../../../../Utils"; +import { wrapInList } from "prosemirror-schema-list"; +import { PastelSchemaPalette, DarkPastelSchemaPalette } from '../../../../new_fields/SchemaHeaderField'; +import "./RichTextMenu.scss"; +import { DocServer } from "../../../DocServer"; +import { Doc } from "../../../../new_fields/Doc"; +import { SelectionManager } from "../../../util/SelectionManager"; +import { LinkManager } from "../../../util/LinkManager"; +const { toggleMark, setBlockType } = require("prosemirror-commands"); + +library.add(faBold, faItalic, faChevronLeft, faUnderline, faStrikethrough, faSuperscript, faSubscript, faIndent, faEyeDropper, faCaretDown, faPalette, faHighlighter, faLink, faPaintRoller); + +@observer +export default class RichTextMenu extends AntimodeMenu { + static Instance: RichTextMenu; + public overMenu: boolean = false; // kind of hacky way to prevent selects not being selectable + + private view?: EditorView; + public editorProps: FieldViewProps & FormattedTextBoxProps | undefined; + + public _brushMap: Map<string, Set<Mark>> = new Map(); + private fontSizeOptions: { mark: Mark | null, title: string, label: string, command: any, hidden?: boolean, style?: {} }[]; + private fontFamilyOptions: { mark: Mark | null, title: string, label: string, command: any, hidden?: boolean, style?: {} }[]; + private listTypeOptions: { node: NodeType | any | null, title: string, label: string, command: any, style?: {} }[]; + private fontColors: (string | undefined)[]; + private highlightColors: (string | undefined)[]; + + @observable private collapsed: boolean = false; + @observable private boldActive: boolean = false; + @observable private italicsActive: boolean = false; + @observable private underlineActive: boolean = false; + @observable private strikethroughActive: boolean = false; + @observable private subscriptActive: boolean = false; + @observable private superscriptActive: boolean = false; + + @observable private activeFontSize: string = ""; + @observable private activeFontFamily: string = ""; + @observable private activeListType: string = ""; + + @observable private brushIsEmpty: boolean = true; + @observable private brushMarks: Set<Mark> = new Set(); + @observable private showBrushDropdown: boolean = false; + + @observable private activeFontColor: string = "black"; + @observable private showColorDropdown: boolean = false; + + @observable private activeHighlightColor: string = "transparent"; + @observable private showHighlightDropdown: boolean = false; + + @observable private currentLink: string | undefined = ""; + @observable private showLinkDropdown: boolean = false; + + constructor(props: Readonly<{}>) { + super(props); + RichTextMenu.Instance = this; + this._canFade = false; + + this.fontSizeOptions = [ + { mark: schema.marks.pFontSize.create({ fontSize: 7 }), title: "Set font size", label: "7pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 8 }), title: "Set font size", label: "8pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 9 }), title: "Set font size", label: "9pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 10 }), title: "Set font size", label: "10pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 12 }), title: "Set font size", label: "12pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 14 }), title: "Set font size", label: "14pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 16 }), title: "Set font size", label: "16pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 18 }), title: "Set font size", label: "18pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 20 }), title: "Set font size", label: "20pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 24 }), title: "Set font size", label: "24pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 32 }), title: "Set font size", label: "32pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 48 }), title: "Set font size", label: "48pt", command: this.changeFontSize }, + { mark: schema.marks.pFontSize.create({ fontSize: 72 }), title: "Set font size", label: "72pt", command: this.changeFontSize }, + { mark: null, title: "", label: "various", command: unimplementedFunction, hidden: true }, + { mark: null, title: "", label: "13pt", command: unimplementedFunction, hidden: true }, // this is here because the default size is 13, but there is no actual 13pt option + ]; + + this.fontFamilyOptions = [ + { mark: schema.marks.pFontFamily.create({ family: "Times New Roman" }), title: "Set font family", label: "Times New Roman", command: this.changeFontFamily, style: { fontFamily: "Times New Roman" } }, + { mark: schema.marks.pFontFamily.create({ family: "Arial" }), title: "Set font family", label: "Arial", command: this.changeFontFamily, style: { fontFamily: "Arial" } }, + { mark: schema.marks.pFontFamily.create({ family: "Georgia" }), title: "Set font family", label: "Georgia", command: this.changeFontFamily, style: { fontFamily: "Georgia" } }, + { mark: schema.marks.pFontFamily.create({ family: "Comic Sans MS" }), title: "Set font family", label: "Comic Sans MS", command: this.changeFontFamily, style: { fontFamily: "Comic Sans MS" } }, + { mark: schema.marks.pFontFamily.create({ family: "Tahoma" }), title: "Set font family", label: "Tahoma", command: this.changeFontFamily, style: { fontFamily: "Tahoma" } }, + { mark: schema.marks.pFontFamily.create({ family: "Impact" }), title: "Set font family", label: "Impact", command: this.changeFontFamily, style: { fontFamily: "Impact" } }, + { mark: schema.marks.pFontFamily.create({ family: "Crimson Text" }), title: "Set font family", label: "Crimson Text", command: this.changeFontFamily, style: { fontFamily: "Crimson Text" } }, + { mark: null, title: "", label: "various", command: unimplementedFunction, hidden: true }, + // { mark: null, title: "", label: "default", command: unimplementedFunction, hidden: true }, + ]; + + this.listTypeOptions = [ + { node: schema.nodes.ordered_list.create({ mapStyle: "bullet" }), title: "Set list type", label: ":", command: this.changeListType }, + { node: schema.nodes.ordered_list.create({ mapStyle: "decimal" }), title: "Set list type", label: "1.1", command: this.changeListType }, + { node: schema.nodes.ordered_list.create({ mapStyle: "multi" }), title: "Set list type", label: "1.A", command: this.changeListType }, + { node: undefined, title: "Set list type", label: "Remove", command: this.changeListType }, + ]; + + this.fontColors = [ + DarkPastelSchemaPalette.get("pink2"), + DarkPastelSchemaPalette.get("purple4"), + DarkPastelSchemaPalette.get("bluegreen1"), + DarkPastelSchemaPalette.get("yellow4"), + DarkPastelSchemaPalette.get("red2"), + DarkPastelSchemaPalette.get("bluegreen7"), + DarkPastelSchemaPalette.get("bluegreen5"), + DarkPastelSchemaPalette.get("orange1"), + "#757472", + "#000" + ]; + + this.highlightColors = [ + PastelSchemaPalette.get("pink2"), + PastelSchemaPalette.get("purple4"), + PastelSchemaPalette.get("bluegreen1"), + PastelSchemaPalette.get("yellow4"), + PastelSchemaPalette.get("red2"), + PastelSchemaPalette.get("bluegreen7"), + PastelSchemaPalette.get("bluegreen5"), + PastelSchemaPalette.get("orange1"), + "white", + "transparent" + ]; + } + + @action + changeView(view: EditorView) { + this.view = view; + } + + update(view: EditorView, lastState: EditorState | undefined) { + this.updateFromDash(view, lastState, this.editorProps); + } + + + public MakeLinkToSelection = (linkDocId: string, title: string, location: string, targetDocId: string): string => { + if (this.view) { + const link = this.view.state.schema.marks.link.create({ href: Utils.prepend("/doc/" + linkDocId), title: title, location: location, linkId: linkDocId, targetId: targetDocId }); + this.view.dispatch(this.view.state.tr.removeMark(this.view.state.selection.from, this.view.state.selection.to, this.view.state.schema.marks.link). + addMark(this.view.state.selection.from, this.view.state.selection.to, link)); + return this.view.state.selection.$from.nodeAfter?.text || ""; + } + return ""; + } + + @action + public async updateFromDash(view: EditorView, lastState: EditorState | undefined, props: any) { + if (!view) { + console.log("no editor? why?"); + return; + } + this.view = view; + const state = view.state; + props && (this.editorProps = props); + + // Don't do anything if the document/selection didn't change + if (lastState && lastState.doc.eq(state.doc) && lastState.selection.eq(state.selection)) return; + + // update active marks + const activeMarks = this.getActiveMarksOnSelection(); + this.setActiveMarkButtons(activeMarks); + + // update active font family and size + const active = this.getActiveFontStylesOnSelection(); + const activeFamilies = active && active.get("families"); + const activeSizes = active && active.get("sizes"); + + this.activeFontFamily = !activeFamilies || activeFamilies.length === 0 ? "Arial" : activeFamilies.length === 1 ? String(activeFamilies[0]) : "various"; + this.activeFontSize = !activeSizes || activeSizes.length === 0 ? "13pt" : activeSizes.length === 1 ? String(activeSizes[0]) + "pt" : "various"; + + // update link in current selection + const targetTitle = await this.getTextLinkTargetTitle(); + this.setCurrentLink(targetTitle); + } + + setMark = (mark: Mark, state: EditorState<any>, dispatch: any) => { + if (mark) { + const node = (state.selection as NodeSelection).node; + if (node?.type === schema.nodes.ordered_list) { + let attrs = node.attrs; + if (mark.type === schema.marks.pFontFamily) attrs = { ...attrs, setFontFamily: mark.attrs.family }; + if (mark.type === schema.marks.pFontSize) attrs = { ...attrs, setFontSize: mark.attrs.fontSize }; + if (mark.type === schema.marks.pFontColor) attrs = { ...attrs, setFontColor: mark.attrs.color }; + const tr = updateBullets(state.tr.setNodeMarkup(state.selection.from, node.type, attrs), state.schema); + dispatch(tr.setSelection(new NodeSelection(tr.doc.resolve(state.selection.from)))); + } else { + toggleMark(mark.type, mark.attrs)(state, (tx: any) => { + const { from, $from, to, empty } = tx.selection; + if (!tx.doc.rangeHasMark(from, to, mark.type)) { + toggleMark(mark.type, mark.attrs)({ tr: tx, doc: tx.doc, selection: tx.selection, storedMarks: tx.storedMarks }, dispatch); + } else dispatch(tx); + }); + } + } + } + + // finds font sizes and families in selection + getActiveFontStylesOnSelection() { + if (!this.view) return; + + const activeFamilies: string[] = []; + const activeSizes: string[] = []; + const state = this.view.state; + const pos = this.view.state.selection.$from; + const ref_node = this.reference_node(pos); + if (ref_node && ref_node !== this.view.state.doc && ref_node.isText) { + ref_node.marks.forEach(m => { + m.type === state.schema.marks.pFontFamily && activeFamilies.push(m.attrs.family); + m.type === state.schema.marks.pFontSize && activeSizes.push(String(m.attrs.fontSize) + "pt"); + }); + } + + const styles = new Map<String, String[]>(); + styles.set("families", activeFamilies); + styles.set("sizes", activeSizes); + return styles; + } + + getMarksInSelection(state: EditorState<any>) { + const found = new Set<Mark>(); + const { from, to } = state.selection as TextSelection; + state.doc.nodesBetween(from, to, (node) => node.marks.forEach(m => found.add(m))); + return found; + } + + //finds all active marks on selection in given group + getActiveMarksOnSelection() { + if (!this.view) return; + + const markGroup = [schema.marks.strong, schema.marks.em, schema.marks.underline, schema.marks.strikethrough, schema.marks.superscript, schema.marks.subscript]; + if (this.view.state.storedMarks) return this.view.state.storedMarks.map(mark => mark.type); + //current selection + const { empty, ranges, $to } = this.view.state.selection as TextSelection; + const state = this.view.state; + let activeMarks: MarkType[] = []; + if (!empty) { + activeMarks = markGroup.filter(mark => { + const has = false; + for (let i = 0; !has && i < ranges.length; i++) { + return state.doc.rangeHasMark(ranges[i].$from.pos, ranges[i].$to.pos, mark); + } + return false; + }); + } + else { + const pos = this.view.state.selection.$from; + const ref_node: ProsNode | null = this.reference_node(pos); + if (ref_node !== null && ref_node !== this.view.state.doc) { + if (ref_node.isText) { + } + else { + return []; + } + activeMarks = markGroup.filter(mark_type => { + if (mark_type === state.schema.marks.pFontSize) { + return ref_node.marks.some(m => m.type.name === state.schema.marks.pFontSize.name); + } + const mark = state.schema.mark(mark_type); + return ref_node.marks.includes(mark); + }); + } + } + return activeMarks; + } + + destroy() { + this.fadeOut(true); + } + + @action + setActiveMarkButtons(activeMarks: MarkType[] | undefined) { + if (!activeMarks) return; + + this.boldActive = false; + this.italicsActive = false; + this.underlineActive = false; + this.strikethroughActive = false; + this.subscriptActive = false; + this.superscriptActive = false; + + activeMarks.forEach(mark => { + switch (mark.name) { + case "strong": this.boldActive = true; break; + case "em": this.italicsActive = true; break; + case "underline": this.underlineActive = true; break; + case "strikethrough": this.strikethroughActive = true; break; + case "subscript": this.subscriptActive = true; break; + case "superscript": this.superscriptActive = true; break; + } + }); + } + + createButton(faIcon: string, title: string, isActive: boolean = false, command?: any, onclick?: any) { + const self = this; + function onClick(e: React.PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + self.view && self.view.focus(); + self.view && command && command(self.view.state, self.view.dispatch, self.view); + self.view && onclick && onclick(self.view.state, self.view.dispatch, self.view); + self.setActiveMarkButtons(self.getActiveMarksOnSelection()); + } + + return ( + <button className={"antimodeMenu-button" + (isActive ? " active" : "")} key={title} title={title} onPointerDown={onClick}> + <FontAwesomeIcon icon={faIcon as IconProp} size="lg" /> + </button> + ); + } + + createMarksDropdown(activeOption: string, options: { mark: Mark | null, title: string, label: string, command: (mark: Mark, view: EditorView) => void, hidden?: boolean, style?: {} }[], key: string): JSX.Element { + const items = options.map(({ title, label, hidden, style }) => { + if (hidden) { + return label === activeOption ? + <option value={label} title={title} key={label} style={style ? style : {}} selected hidden>{label}</option> : + <option value={label} title={title} key={label} style={style ? style : {}} hidden>{label}</option>; + } + return label === activeOption ? + <option value={label} title={title} key={label} style={style ? style : {}} selected>{label}</option> : + <option value={label} title={title} key={label} style={style ? style : {}}>{label}</option>; + }); + + const self = this; + function onChange(e: React.ChangeEvent<HTMLSelectElement>) { + e.stopPropagation(); + e.preventDefault(); + options.forEach(({ label, mark, command }) => { + if (e.target.value === label) { + self.view && mark && command(mark, self.view); + } + }); + } + return <select onChange={onChange} key={key}>{items}</select>; + } + + createNodesDropdown(activeOption: string, options: { node: NodeType | any | null, title: string, label: string, command: (node: NodeType | any) => void, hidden?: boolean, style?: {} }[], key: string): JSX.Element { + const items = options.map(({ title, label, hidden, style }) => { + if (hidden) { + return label === activeOption ? + <option value={label} title={title} key={label} style={style ? style : {}} selected hidden>{label}</option> : + <option value={label} title={title} key={label} style={style ? style : {}} hidden>{label}</option>; + } + return label === activeOption ? + <option value={label} title={title} key={label} style={style ? style : {}} selected>{label}</option> : + <option value={label} title={title} key={label} style={style ? style : {}}>{label}</option>; + }); + + const self = this; + function onChange(val: string) { + options.forEach(({ label, node, command }) => { + if (val === label) { + self.view && node && command(node); + } + }); + } + return <select onChange={e => onChange(e.target.value)} key={key}>{items}</select>; + } + + changeFontSize = (mark: Mark, view: EditorView) => { + this.setMark(view.state.schema.marks.pFontSize.create({ fontSize: mark.attrs.fontSize }), view.state, view.dispatch); + } + + changeFontFamily = (mark: Mark, view: EditorView) => { + this.setMark(view.state.schema.marks.pFontFamily.create({ family: mark.attrs.family }), view.state, view.dispatch); + } + + // TODO: remove doesn't work + //remove all node type and apply the passed-in one to the selected text + changeListType = (nodeType: NodeType | undefined) => { + if (!this.view) return; + + if (nodeType === schema.nodes.bullet_list) { + wrapInList(nodeType)(this.view.state, this.view.dispatch); + } else { + const marks = this.view.state.storedMarks || (this.view.state.selection.$to.parentOffset && this.view.state.selection.$from.marks()); + if (!wrapInList(schema.nodes.ordered_list)(this.view.state, (tx2: any) => { + const tx3 = updateBullets(tx2, schema, nodeType && (nodeType as any).attrs.mapStyle); + marks && tx3.ensureMarks([...marks]); + marks && tx3.setStoredMarks([...marks]); + + this.view!.dispatch(tx2); + })) { + const tx2 = this.view.state.tr; + const tx3 = updateBullets(tx2, schema, nodeType && (nodeType as any).attrs.mapStyle); + marks && tx3.ensureMarks([...marks]); + marks && tx3.setStoredMarks([...marks]); + + this.view.dispatch(tx3); + } + } + } + + insertSummarizer(state: EditorState<any>, dispatch: any) { + if (state.selection.empty) return false; + const mark = state.schema.marks.summarize.create(); + const tr = state.tr; + tr.addMark(state.selection.from, state.selection.to, mark); + const content = tr.selection.content(); + const newNode = state.schema.nodes.summary.create({ visibility: false, text: content, textslice: content.toJSON() }); + dispatch && dispatch(tr.replaceSelectionWith(newNode).removeMark(tr.selection.from - 1, tr.selection.from, mark)); + return true; + } + + @action toggleBrushDropdown() { this.showBrushDropdown = !this.showBrushDropdown; } + + // todo: add brushes to brushMap to save with a style name + onBrushNameKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + RichTextMenu.Instance.brushMarks && RichTextMenu.Instance._brushMap.set(this._brushNameRef.current!.value, RichTextMenu.Instance.brushMarks); + this._brushNameRef.current!.style.background = "lightGray"; + } + } + _brushNameRef = React.createRef<HTMLInputElement>(); + + createBrushButton() { + const self = this; + function onBrushClick(e: React.PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + self.view && self.view.focus(); + self.view && self.fillBrush(self.view.state, self.view.dispatch); + } + + let label = "Stored marks: "; + if (this.brushMarks && this.brushMarks.size > 0) { + this.brushMarks.forEach((mark: Mark) => { + const markType = mark.type; + label += markType.name; + label += ", "; + }); + label = label.substring(0, label.length - 2); + } else { + label = "No marks are currently stored"; + } + + const button = + <button className="antimodeMenu-button" title="" onPointerDown={onBrushClick} style={this.brushMarks?.size > 0 ? { backgroundColor: "121212" } : {}}> + <FontAwesomeIcon icon="paint-roller" size="lg" style={{ transitionProperty: "transform", transitionDuration: "0.1s", transform: `rotate(${this.brushMarks?.size > 0 ? 45 : 0}deg)` }} /> + </button>; + + const dropdownContent = + <div className="dropdown"> + <p>{label}</p> + <button onPointerDown={this.clearBrush}>Clear brush</button> + <input placeholder="-brush name-" ref={this._brushNameRef} onKeyPress={this.onBrushNameKeyPress}></input> + </div>; + + return ( + <ButtonDropdown view={this.view} key={"brush dropdown"} button={button} dropdownContent={dropdownContent} /> + ); + } + + @action + clearBrush() { + RichTextMenu.Instance.brushIsEmpty = true; + RichTextMenu.Instance.brushMarks = new Set(); + } + + @action + fillBrush(state: EditorState<any>, dispatch: any) { + if (!this.view) return; + + if (this.brushIsEmpty) { + const selected_marks = this.getMarksInSelection(this.view.state); + if (selected_marks.size >= 0) { + this.brushMarks = selected_marks; + this.brushIsEmpty = !this.brushIsEmpty; + } + } + else { + const { from, to, $from } = this.view.state.selection; + if (!this.view.state.selection.empty && $from && $from.nodeAfter) { + if (this.brushMarks && to - from > 0) { + this.view.dispatch(this.view.state.tr.removeMark(from, to)); + Array.from(this.brushMarks).filter(m => m.type !== schema.marks.user_mark).forEach((mark: Mark) => { + this.setMark(mark, this.view!.state, this.view!.dispatch); + }); + } + } + else { + this.brushIsEmpty = !this.brushIsEmpty; + } + } + } + + @action toggleColorDropdown() { this.showColorDropdown = !this.showColorDropdown; } + @action setActiveColor(color: string) { this.activeFontColor = color; } + + createColorButton() { + const self = this; + function onColorClick(e: React.PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + self.view && self.view.focus(); + self.view && self.insertColor(self.activeFontColor, self.view.state, self.view.dispatch); + } + function changeColor(e: React.PointerEvent, color: string) { + e.preventDefault(); + e.stopPropagation(); + self.view && self.view.focus(); + self.setActiveColor(color); + self.view && self.insertColor(self.activeFontColor, self.view.state, self.view.dispatch); + } + + const button = + <button className="antimodeMenu-button color-preview-button" title="" onPointerDown={onColorClick}> + <FontAwesomeIcon icon="palette" size="lg" /> + <div className="color-preview" style={{ backgroundColor: this.activeFontColor }}></div> + </button>; + + const dropdownContent = + <div className="dropdown" > + <p>Change font color:</p> + <div className="color-wrapper"> + {this.fontColors.map(color => { + if (color) { + return this.activeFontColor === color ? + <button className="color-button active" key={"active" + color} style={{ backgroundColor: color }} onPointerDown={e => changeColor(e, color)}></button> : + <button className="color-button" key={"other" + color} style={{ backgroundColor: color }} onPointerDown={e => changeColor(e, color)}></button>; + } + })} + </div> + </div>; + + return ( + <ButtonDropdown view={this.view} key={"color dropdown"} button={button} dropdownContent={dropdownContent} /> + ); + } + + public insertColor(color: String, state: EditorState<any>, dispatch: any) { + const colorMark = state.schema.mark(state.schema.marks.pFontColor, { color: color }); + if (state.selection.empty) { + dispatch(state.tr.addStoredMark(colorMark)); + return false; + } + this.setMark(colorMark, state, dispatch); + } + + @action toggleHighlightDropdown() { this.showHighlightDropdown = !this.showHighlightDropdown; } + @action setActiveHighlight(color: string) { this.activeHighlightColor = color; } + + createHighlighterButton() { + const self = this; + function onHighlightClick(e: React.PointerEvent) { + e.preventDefault(); + e.stopPropagation(); + self.view && self.view.focus(); + self.view && self.insertHighlight(self.activeHighlightColor, self.view.state, self.view.dispatch); + } + function changeHighlight(e: React.PointerEvent, color: string) { + e.preventDefault(); + e.stopPropagation(); + self.view && self.view.focus(); + self.setActiveHighlight(color); + self.view && self.insertHighlight(self.activeHighlightColor, self.view.state, self.view.dispatch); + } + + const button = + <button className="antimodeMenu-button color-preview-button" title="" key="highilghter-button" onPointerDown={onHighlightClick}> + <FontAwesomeIcon icon="highlighter" size="lg" /> + <div className="color-preview" style={{ backgroundColor: this.activeHighlightColor }}></div> + </button>; + + const dropdownContent = + <div className="dropdown"> + <p>Change highlight color:</p> + <div className="color-wrapper"> + {this.highlightColors.map(color => { + if (color) { + return this.activeHighlightColor === color ? + <button className="color-button active" key={`active ${color}`} style={{ backgroundColor: color }} onPointerDown={e => changeHighlight(e, color)}>{color === "transparent" ? "X" : ""}</button> : + <button className="color-button" key={`inactive ${color}`} style={{ backgroundColor: color }} onPointerDown={e => changeHighlight(e, color)}>{color === "transparent" ? "X" : ""}</button>; + } + })} + </div> + </div>; + + return ( + <ButtonDropdown view={this.view} key={"highlighter"} button={button} dropdownContent={dropdownContent} /> + ); + } + + insertHighlight(color: String, state: EditorState<any>, dispatch: any) { + if (state.selection.empty) return false; + toggleMark(state.schema.marks.marker, { highlight: color })(state, dispatch); + } + + @action toggleLinkDropdown() { this.showLinkDropdown = !this.showLinkDropdown; } + @action setCurrentLink(link: string) { this.currentLink = link; } + + createLinkButton() { + const self = this; + + function onLinkChange(e: React.ChangeEvent<HTMLInputElement>) { + self.setCurrentLink(e.target.value); + } + + const link = this.currentLink ? this.currentLink : ""; + + const button = <FontAwesomeIcon icon="link" size="lg" />; + + const dropdownContent = + <div className="dropdown link-menu"> + <p>Linked to:</p> + <input value={link} placeholder="Enter URL" onChange={onLinkChange} /> + <button className="make-button" onPointerDown={e => this.makeLinkToURL(link, "onRight")}>Apply hyperlink</button> + <div className="divider"></div> + <button className="remove-button" onPointerDown={e => this.deleteLink()}>Remove link</button> + </div>; + + return ( + <ButtonDropdown view={this.view} key={"link button"} button={button} dropdownContent={dropdownContent} openDropdownOnButton={true} /> + ); + } + + async getTextLinkTargetTitle() { + if (!this.view) return; + + const node = this.view.state.selection.$from.nodeAfter; + const link = node && node.marks.find(m => m.type.name === "link"); + if (link) { + const href = link.attrs.href; + if (href) { + if (href.indexOf(Utils.prepend("/doc/")) === 0) { + const linkclicked = href.replace(Utils.prepend("/doc/"), "").split("?")[0]; + if (linkclicked) { + const linkDoc = await DocServer.GetRefField(linkclicked); + if (linkDoc instanceof Doc) { + const anchor1 = await Cast(linkDoc.anchor1, Doc); + const anchor2 = await Cast(linkDoc.anchor2, Doc); + const currentDoc = SelectionManager.SelectedDocuments().length && SelectionManager.SelectedDocuments()[0].props.Document; + if (currentDoc && anchor1 && anchor2) { + if (Doc.AreProtosEqual(currentDoc, anchor1)) { + return StrCast(anchor2.title); + } + if (Doc.AreProtosEqual(currentDoc, anchor2)) { + return StrCast(anchor1.title); + } + } + } + } + } else { + return href; + } + } else { + return link.attrs.title; + } + } + } + + // TODO: should check for valid URL + makeLinkToURL = (target: String, lcoation: string) => { + if (!this.view) return; + + let node = this.view.state.selection.$from.nodeAfter; + let link = this.view.state.schema.mark(this.view.state.schema.marks.link, { href: target, location: location }); + this.view.dispatch(this.view.state.tr.removeMark(this.view.state.selection.from, this.view.state.selection.to, this.view.state.schema.marks.link)); + this.view.dispatch(this.view.state.tr.addMark(this.view.state.selection.from, this.view.state.selection.to, link)); + node = this.view.state.selection.$from.nodeAfter; + link = node && node.marks.find(m => m.type.name === "link"); + } + + deleteLink = () => { + if (!this.view) return; + + const node = this.view.state.selection.$from.nodeAfter; + const link = node && node.marks.find(m => m.type === this.view!.state.schema.marks.link); + const href = link!.attrs.href; + if (href) { + if (href.indexOf(Utils.prepend("/doc/")) === 0) { + const linkclicked = href.replace(Utils.prepend("/doc/"), "").split("?")[0]; + if (linkclicked) { + DocServer.GetRefField(linkclicked).then(async linkDoc => { + if (linkDoc instanceof Doc) { + LinkManager.Instance.deleteLink(linkDoc); + this.view!.dispatch(this.view!.state.tr.removeMark(this.view!.state.selection.from, this.view!.state.selection.to, this.view!.state.schema.marks.link)); + } + }); + } + } else { + if (node) { + const { tr, schema, selection } = this.view.state; + const extension = this.linkExtend(selection.$anchor, href); + this.view.dispatch(tr.removeMark(extension.from, extension.to, schema.marks.link)); + } + } + } + } + + linkExtend($start: ResolvedPos, href: string) { + const mark = this.view!.state.schema.marks.link; + + let startIndex = $start.index(); + let endIndex = $start.indexAfter(); + + while (startIndex > 0 && $start.parent.child(startIndex - 1).marks.filter(m => m.type === mark && m.attrs.href === href).length) startIndex--; + while (endIndex < $start.parent.childCount && $start.parent.child(endIndex).marks.filter(m => m.type === mark && m.attrs.href === href).length) endIndex++; + + let startPos = $start.start(); + let endPos = startPos; + for (let i = 0; i < endIndex; i++) { + const size = $start.parent.child(i).nodeSize; + if (i < startIndex) startPos += size; + endPos += size; + } + return { from: startPos, to: endPos }; + } + + reference_node(pos: ResolvedPos<any>): ProsNode | null { + if (!this.view) return null; + + let ref_node: ProsNode = this.view.state.doc; + if (pos.nodeBefore !== null && pos.nodeBefore !== undefined) { + ref_node = pos.nodeBefore; + } + else if (pos.nodeAfter !== null && pos.nodeAfter !== undefined) { + ref_node = pos.nodeAfter; + } + else if (pos.pos > 0) { + let skip = false; + for (let i: number = pos.pos - 1; i > 0; i--) { + this.view.state.doc.nodesBetween(i, pos.pos, (node: ProsNode) => { + if (node.isLeaf && !skip) { + ref_node = node; + skip = true; + } + + }); + } + } + if (!ref_node.isLeaf && ref_node.childCount > 0) { + ref_node = ref_node.child(0); + } + return ref_node; + } + + @action onPointerEnter(e: React.PointerEvent) { RichTextMenu.Instance.overMenu = true; } + @action onPointerLeave(e: React.PointerEvent) { RichTextMenu.Instance.overMenu = false; } + + @action + toggleMenuPin = (e: React.MouseEvent) => { + this.Pinned = !this.Pinned; + if (!this.Pinned) { + this.fadeOut(true); + } + } + + @action + protected toggleCollapse = (e: React.MouseEvent) => { + this.collapsed = !this.collapsed; + setTimeout(() => { + const x = Math.min(this._left, window.innerWidth - RichTextMenu.Instance.width); + RichTextMenu.Instance.jumpTo(x, this._top); + }, 0); + } + + render() { + + const row1 = <div className="antimodeMenu-row" key="row1" style={{ display: this.collapsed ? "none" : undefined }}>{[ + this.createButton("bold", "Bold", this.boldActive, toggleMark(schema.marks.strong)), + this.createButton("italic", "Italic", this.italicsActive, toggleMark(schema.marks.em)), + this.createButton("underline", "Underline", this.underlineActive, toggleMark(schema.marks.underline)), + this.createButton("strikethrough", "Strikethrough", this.strikethroughActive, toggleMark(schema.marks.strikethrough)), + this.createButton("superscript", "Superscript", this.superscriptActive, toggleMark(schema.marks.superscript)), + this.createButton("subscript", "Subscript", this.subscriptActive, toggleMark(schema.marks.subscript)), + this.createColorButton(), + this.createHighlighterButton(), + this.createLinkButton(), + this.createBrushButton(), + this.createButton("indent", "Summarize", undefined, this.insertSummarizer), + ]}</div>; + + const row2 = <div className="antimodeMenu-row row-2" key="antimodemenu row2"> + <div key="row" style={{ display: this.collapsed ? "none" : undefined }}> + {[this.createMarksDropdown(this.activeFontSize, this.fontSizeOptions, "font size"), + this.createMarksDropdown(this.activeFontFamily, this.fontFamilyOptions, "font family"), + this.createNodesDropdown(this.activeListType, this.listTypeOptions, "nodes")]} + </div> + <div key="button"> + <div key="collapser"> + <button className="antimodeMenu-button" key="collapse menu" title="Collapse menu" onClick={this.toggleCollapse} style={{ backgroundColor: this.collapsed ? "#121212" : "", width: 25 }}> + <FontAwesomeIcon icon="chevron-left" size="lg" style={{ transitionProperty: "transform", transitionDuration: "0.3s", transform: `rotate(${this.collapsed ? 180 : 0}deg)` }} /> + </button> + </div> + <button className="antimodeMenu-button" key="pin menu" title="Pin menu" onClick={this.toggleMenuPin} style={{ backgroundColor: this.Pinned ? "#121212" : "", display: this.collapsed ? "none" : undefined }}> + <FontAwesomeIcon icon="thumbtack" size="lg" style={{ transitionProperty: "transform", transitionDuration: "0.1s", transform: `rotate(${this.Pinned ? 45 : 0}deg)` }} /> + </button> + {this.getDragger()} + </div> + </div>; + + return ( + <div className="richTextMenu" onPointerEnter={this.onPointerEnter} onPointerLeave={this.onPointerLeave}> + {this.getElementWithRows([row1, row2], 2, false)} + </div> + ); + } +} + +interface ButtonDropdownProps { + view?: EditorView; + button: JSX.Element; + dropdownContent: JSX.Element; + openDropdownOnButton?: boolean; +} + +@observer +class ButtonDropdown extends React.Component<ButtonDropdownProps> { + + @observable private showDropdown: boolean = false; + private ref: HTMLDivElement | null = null; + + componentDidMount() { + document.addEventListener("pointerdown", this.onBlur); + } + + componentWillUnmount() { + document.removeEventListener("pointerdown", this.onBlur); + } + + @action + setShowDropdown(show: boolean) { + this.showDropdown = show; + } + @action + toggleDropdown() { + this.showDropdown = !this.showDropdown; + } + + onDropdownClick = (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + this.props.view && this.props.view.focus(); + this.toggleDropdown(); + } + + onBlur = (e: PointerEvent) => { + setTimeout(() => { + if (this.ref !== null && !this.ref.contains(e.target as Node)) { + this.setShowDropdown(false); + } + }, 0); + } + + render() { + return ( + <div className="button-dropdown-wrapper" ref={node => this.ref = node}> + {this.props.openDropdownOnButton ? + <button className="antimodeMenu-button dropdown-button-combined" onPointerDown={this.onDropdownClick}> + {this.props.button} + <FontAwesomeIcon icon="caret-down" size="sm" /> + </button> : + <> + {this.props.button} + <button className="dropdown-button antimodeMenu-button" key="antimodebutton" onPointerDown={this.onDropdownClick}> + <FontAwesomeIcon icon="caret-down" size="sm" /> + </button> + </>} + + {this.showDropdown ? this.props.dropdownContent : (null)} + </div> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/formattedText/RichTextRules.ts b/src/client/views/nodes/formattedText/RichTextRules.ts new file mode 100644 index 000000000..d619bc4a0 --- /dev/null +++ b/src/client/views/nodes/formattedText/RichTextRules.ts @@ -0,0 +1,319 @@ +import { ellipsis, emDash, InputRule, smartQuotes, textblockTypeInputRule } from "prosemirror-inputrules"; +import { NodeSelection, TextSelection } from "prosemirror-state"; +import { DataSym, Doc } from "../../../../new_fields/Doc"; +import { Id } from "../../../../new_fields/FieldSymbols"; +import { ComputedField } from "../../../../new_fields/ScriptField"; +import { Cast, NumCast } from "../../../../new_fields/Types"; +import { returnFalse, Utils } from "../../../../Utils"; +import { DocServer } from "../../../DocServer"; +import { Docs, DocUtils } from "../../../documents/Documents"; +import { FormattedTextBox } from "./FormattedTextBox"; +import { wrappingInputRule } from "./prosemirrorPatches"; +import RichTextMenu from "./RichTextMenu"; +import { schema } from "./schema_rts"; + +export class RichTextRules { + public Document: Doc; + public TextBox: FormattedTextBox; + public EnteringStyle: boolean = false; + constructor(doc: Doc, textBox: FormattedTextBox) { + this.Document = doc; + this.TextBox = textBox; + } + public inpRules = { + rules: [ + ...smartQuotes, + ellipsis, + emDash, + + // > blockquote + wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote), + + // 1. ordered list + wrappingInputRule( + /^1\.\s$/, + schema.nodes.ordered_list, + () => { + return ({ mapStyle: "decimal", bulletStyle: 1 }); + }, + (match: any, node: any) => { + return node.childCount + node.attrs.order === +match[1]; + }, + (type: any) => ({ type: type, attrs: { mapStyle: "decimal", bulletStyle: 1 } }) + ), + // a. alphabbetical list + wrappingInputRule( + /^a\.\s$/, + schema.nodes.ordered_list, + // match => { + () => { + return ({ mapStyle: "alpha", bulletStyle: 1 }); + // return ({ order: +match[1] }) + }, + (match: any, node: any) => { + return node.childCount + node.attrs.order === +match[1]; + }, + (type: any) => ({ type: type, attrs: { mapStyle: "alpha", bulletStyle: 1 } }) + ), + + // * bullet list + wrappingInputRule(/^\s*([-+*])\s$/, schema.nodes.bullet_list), + + // ``` code block + textblockTypeInputRule(/^```$/, schema.nodes.code_block), + + // create an inline view of a tag stored under the '#' field + new InputRule( + new RegExp(/#([a-zA-Z_\-]+[a-zA-Z_\-0-9]*)\s$/), + (state, match, start, end) => { + const tag = match[1]; + if (!tag) return state.tr; + this.Document[DataSym]["#"] = tag; + const fieldView = state.schema.nodes.dashField.create({ fieldKey: "#" }); + return state.tr.deleteRange(start, end).insert(start, fieldView); + }), + + // # heading + textblockTypeInputRule( + new RegExp(/^(#{1,6})\s$/), + schema.nodes.heading, + match => { + return ({ level: match[1].length }); + } + ), + + // set the font size using #<font-size> + new InputRule( + new RegExp(/%([0-9]+)\s$/), + (state, match, start, end) => { + const size = Number(match[1]); + return state.tr.deleteRange(start, end).addStoredMark(schema.marks.pFontSize.create({ fontSize: size })); + }), + + // create a text display of a metadata field on this or another document, or create a hyperlink portal to another document [[ <fieldKey> : <Doc>]] // [[:Doc]] => hyperlink [[fieldKey]] => show field [[fieldKey:Doc]] => show field of doc + new InputRule( + new RegExp(/\[\[([a-zA-Z_@\? \-0-9]*)(=[a-zA-Z_@\? /\-0-9]*)?(:[a-zA-Z_@\? \-0-9]+)?\]\]$/), + (state, match, start, end) => { + const fieldKey = match[1]; + const docid = match[3]?.substring(1); + const value = match[2]?.substring(1); + if (!fieldKey) { + if (docid) { + DocServer.GetRefField(docid).then(docx => { + const target = ((docx instanceof Doc) && docx) || Docs.Create.FreeformDocument([], { title: docid, _width: 500, _height: 500, _LODdisable: true, }, docid); + DocUtils.Publish(target, docid, returnFalse, returnFalse); + DocUtils.MakeLink({ doc: this.Document }, { doc: target }, "portal to"); + }); + const link = state.schema.marks.link.create({ href: Utils.prepend("/doc/" + docid), location: "onRight", title: docid, targetId: docid }); + return state.tr.deleteRange(end - 1, end).deleteRange(start, start + 2).addMark(start, end - 3, link); + } + return state.tr; + } + if (value !== "" && value !== undefined) { + const num = value.match(/^[0-9.]$/); + this.Document[DataSym][fieldKey] = value === "true" ? true : value === "false" ? false : (num ? Number(value) : value); + } + const fieldView = state.schema.nodes.dashField.create({ fieldKey, docid }); + return state.tr.deleteRange(start, end).insert(start, fieldView); + }), + // create an inline view of a document {{ <layoutKey> : <Doc> }} // {{:Doc}} => show default view of document {{<layout>}} => show layout for this doc {{<layout> : Doc}} => show layout for another doc + new InputRule( + new RegExp(/\{\{([a-zA-Z_ \-0-9]*)(\([a-zA-Z0-9…._/\-]*\))?(:[a-zA-Z_ \-0-9]+)?\}\}$/), + (state, match, start, end) => { + const fieldKey = match[1] || ""; + const fieldParam = match[2]?.replace("…", "...") || ""; + const docid = match[3]?.substring(1); + if (!fieldKey && !docid) return state.tr; + docid && DocServer.GetRefField(docid).then(docx => { + if (!(docx instanceof Doc && docx)) { + const docx = Docs.Create.FreeformDocument([], { title: docid, _width: 500, _height: 500, _LODdisable: true }, docid); + DocUtils.Publish(docx, docid, returnFalse, returnFalse); + } + }); + const node = (state.doc.resolve(start) as any).nodeAfter; + const dashDoc = schema.nodes.dashDoc.create({ width: 75, height: 75, title: "dashDoc", docid, fieldKey: fieldKey + fieldParam, float: "unset", alias: Utils.GenerateGuid() }); + const sm = state.storedMarks || undefined; + return node ? state.tr.replaceRangeWith(start, end, dashDoc).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : state.tr; + }), + new InputRule( + new RegExp(/>>$/), + (state, match, start, end) => { + const textDoc = this.Document[DataSym]; + const numInlines = NumCast(textDoc.inlineTextCount); + textDoc.inlineTextCount = numInlines + 1; + const inlineFieldKey = "inline" + numInlines; // which field on the text document this annotation will write to + const inlineLayoutKey = "layout_" + inlineFieldKey; // the field holding the layout string that will render the inline annotation + const textDocInline = Docs.Create.TextDocument("", { layoutKey: inlineLayoutKey, _width: 75, _height: 35, annotationOn: textDoc, _autoHeight: true, _fontSize: 9, title: "inline comment" }); + textDocInline.title = inlineFieldKey; // give the annotation its own title + textDocInline.customTitle = true; // And make sure that it's 'custom' so that editing text doesn't change the title of the containing doc + textDocInline.isTemplateForField = inlineFieldKey; // this is needed in case the containing text doc is converted to a template at some point + textDocInline.proto = textDoc; // make the annotation inherit from the outer text doc so that it can resolve any nested field references, e.g., [[field]] + textDocInline._textContext = ComputedField.MakeFunction(`copyField(self.${inlineFieldKey})`); + textDoc[inlineLayoutKey] = FormattedTextBox.LayoutString(inlineFieldKey); // create a layout string for the layout key that will render the annotation text + textDoc[inlineFieldKey] = ""; // set a default value for the annotation + const node = (state.doc.resolve(start) as any).nodeAfter; + const newNode = schema.nodes.dashComment.create({ docid: textDocInline[Id] }); + const dashDoc = schema.nodes.dashDoc.create({ width: 75, height: 35, title: "dashDoc", docid: textDocInline[Id], float: "right" }); + const sm = state.storedMarks || undefined; + const replaced = node ? state.tr.insert(start, newNode).replaceRangeWith(start + 1, end + 1, dashDoc).insertText(" ", start + 2).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : + state.tr; + return replaced; + }), + // stop using active style + new InputRule( + new RegExp(/%%$/), + (state, match, start, end) => { + const tr = state.tr.deleteRange(start, end); + const marks = state.tr.selection.$anchor.nodeBefore?.marks; + return marks ? Array.from(marks).filter(m => m !== state.schema.marks.user_mark).reduce((tr, m) => tr.removeStoredMark(m), tr) : tr; + }), + + // set the Todo user-tag on the current selection (assumes % was used to initiate an EnteringStyle mode) + new InputRule( + new RegExp(/[ti!x]$/), + (state, match, start, end) => { + if (state.selection.to === state.selection.from || !this.EnteringStyle) return null; + const tag = match[0] === "t" ? "todo" : match[0] === "i" ? "ignore" : match[0] === "x" ? "disagree" : match[0] === "!" ? "important" : "??"; + const node = (state.doc.resolve(start) as any).nodeAfter; + if (node?.marks.findIndex((m: any) => m.type === schema.marks.user_tag) !== -1) return state.tr.removeMark(start, end, schema.marks.user_tag); + return node ? state.tr.addMark(start, end, schema.marks.user_tag.create({ userid: Doc.CurrentUserEmail, tag: tag, modified: Math.round(Date.now() / 1000 / 60) })) : state.tr; + }), + + // set the First-line indent node type for the selection's paragraph (assumes % was used to initiate an EnteringStyle mode) + new InputRule( + new RegExp(/(%d|d)$/), + (state, match, start, end) => { + if (!match[0].startsWith("%") && !this.EnteringStyle) return null; + const pos = (state.doc.resolve(start) as any); + for (let depth = pos.path.length / 3 - 1; depth >= 0; depth--) { + const node = pos.node(depth); + if (node.type === schema.nodes.paragraph) { + const replaced = state.tr.setNodeMarkup(pos.pos - pos.parentOffset - 1, node.type, { ...node.attrs, indent: node.attrs.indent === 25 ? undefined : 25 }); + const result = replaced.setSelection(new TextSelection(replaced.doc.resolve(start))); + return match[0].startsWith("%") ? result.deleteRange(start, end) : result; + } + } + return null; + }), + + // set the Hanging indent node type for the current selection's paragraph (assumes % was used to initiate an EnteringStyle mode) + new InputRule( + new RegExp(/(%h|h)$/), + (state, match, start, end) => { + if (!match[0].startsWith("%") && !this.EnteringStyle) return null; + const pos = (state.doc.resolve(start) as any); + for (let depth = pos.path.length / 3 - 1; depth >= 0; depth--) { + const node = pos.node(depth); + if (node.type === schema.nodes.paragraph) { + const replaced = state.tr.setNodeMarkup(pos.pos - pos.parentOffset - 1, node.type, { ...node.attrs, indent: node.attrs.indent === -25 ? undefined : -25 }); + const result = replaced.setSelection(new TextSelection(replaced.doc.resolve(start))); + return match[0].startsWith("%") ? result.deleteRange(start, end) : result; + } + } + return null; + }), + // set the Quoted indent node type for the current selection's paragraph (assumes % was used to initiate an EnteringStyle mode) + new InputRule( + new RegExp(/(%q|q)$/), + (state, match, start, end) => { + if (!match[0].startsWith("%") && !this.EnteringStyle) return null; + const pos = (state.doc.resolve(start) as any); + if (state.selection instanceof NodeSelection && state.selection.node.type === schema.nodes.ordered_list) { + const node = state.selection.node; + return state.tr.setNodeMarkup(pos.pos, node.type, { ...node.attrs, indent: node.attrs.indent === 30 ? undefined : 30 }); + } + for (let depth = pos.path.length / 3 - 1; depth >= 0; depth--) { + const node = pos.node(depth); + if (node.type === schema.nodes.paragraph) { + const replaced = state.tr.setNodeMarkup(pos.pos - pos.parentOffset - 1, node.type, { ...node.attrs, inset: node.attrs.inset === 30 ? undefined : 30 }); + const result = replaced.setSelection(new TextSelection(replaced.doc.resolve(start))); + return match[0].startsWith("%") ? result.deleteRange(start, end) : result; + } + } + return null; + }), + + + // center justify text + new InputRule( + new RegExp(/%\^$/), + (state, match, start, end) => { + const node = (state.doc.resolve(start) as any).nodeAfter; + const sm = state.storedMarks || undefined; + const replaced = node ? state.tr.replaceRangeWith(start, end, schema.nodes.paragraph.create({ align: "center" })).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : + state.tr; + return replaced.setSelection(new TextSelection(replaced.doc.resolve(end - 2))); + }), + // left justify text + new InputRule( + new RegExp(/%\[$/), + (state, match, start, end) => { + const node = (state.doc.resolve(start) as any).nodeAfter; + const sm = state.storedMarks || undefined; + const replaced = node ? state.tr.replaceRangeWith(start, end, schema.nodes.paragraph.create({ align: "left" })).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : + state.tr; + return replaced.setSelection(new TextSelection(replaced.doc.resolve(end - 2))); + }), + // right justify text + new InputRule( + new RegExp(/%\]$/), + (state, match, start, end) => { + const node = (state.doc.resolve(start) as any).nodeAfter; + const sm = state.storedMarks || undefined; + const replaced = node ? state.tr.replaceRangeWith(start, end, schema.nodes.paragraph.create({ align: "right" })).setStoredMarks([...node.marks, ...(sm ? sm : [])]) : + state.tr; + return replaced.setSelection(new TextSelection(replaced.doc.resolve(end - 2))); + }), + new InputRule( + new RegExp(/%\(/), + (state, match, start, end) => { + const node = (state.doc.resolve(start) as any).nodeAfter; + const sm = state.storedMarks || []; + const mark = state.schema.marks.summarizeInclusive.create(); + sm.push(mark); + const selected = state.tr.setSelection(new TextSelection(state.doc.resolve(start), state.doc.resolve(end))).addMark(start, end, mark); + const content = selected.selection.content(); + const replaced = node ? selected.replaceRangeWith(start, end, + schema.nodes.summary.create({ visibility: true, text: content, textslice: content.toJSON() })) : + state.tr; + return replaced.setSelection(new TextSelection(replaced.doc.resolve(end + 1))).setStoredMarks([...node.marks, ...sm]); + }), + new InputRule( + new RegExp(/%\)/), + (state, match, start, end) => { + return state.tr.deleteRange(start, end).removeStoredMark(state.schema.marks.summarizeInclusive.create()); + }), + new InputRule( + new RegExp(/%f$/), + (state, match, start, end) => { + const newNode = schema.nodes.footnote.create({}); + const tr = state.tr; + tr.deleteRange(start, end).replaceSelectionWith(newNode); // replace insertion with a footnote. + return tr.setSelection(new NodeSelection( // select the footnote node to open its display + tr.doc.resolve( // get the location of the footnote node by subtracting the nodesize of the footnote from the current insertion point anchor (which will be immediately after the footnote node) + tr.selection.anchor - tr.selection.$anchor.nodeBefore!.nodeSize))); + }), + + // activate a style by name using prefix '%' + new InputRule( + new RegExp(/%[a-z]+$/), + (state, match, start, end) => { + const color = match[0].substring(1, match[0].length); + const marks = RichTextMenu.Instance._brushMap.get(color); + if (marks) { + const tr = state.tr.deleteRange(start, end); + return marks ? Array.from(marks).reduce((tr, m) => tr.addStoredMark(m), tr) : tr; + } + const isValidColor = (strColor: string) => { + const s = new Option().style; + s.color = strColor; + return s.color === strColor.toLowerCase(); // 'false' if color wasn't assigned + }; + if (isValidColor(color)) { + return state.tr.deleteRange(start, end).addStoredMark(schema.marks.pFontColor.create({ color: color })); + } + return null; + }), + ] + }; +} diff --git a/src/client/views/nodes/formattedText/RichTextSchema.tsx b/src/client/views/nodes/formattedText/RichTextSchema.tsx new file mode 100644 index 000000000..33caf5751 --- /dev/null +++ b/src/client/views/nodes/formattedText/RichTextSchema.tsx @@ -0,0 +1,718 @@ +import { IReactionDisposer, observable, reaction, runInAction } from "mobx"; +import { baseKeymap, toggleMark } from "prosemirror-commands"; +import { redo, undo } from "prosemirror-history"; +import { keymap } from "prosemirror-keymap"; +import { DOMOutputSpecArray, Fragment, MarkSpec, Node, NodeSpec, Schema, Slice } from "prosemirror-model"; +import { bulletList, listItem, orderedList } from 'prosemirror-schema-list'; +import { EditorState, NodeSelection, Plugin, TextSelection } from "prosemirror-state"; +import { StepMap } from "prosemirror-transform"; +import { EditorView } from "prosemirror-view"; +import * as ReactDOM from 'react-dom'; +import { Doc, DocListCast, Field, HeightSym, WidthSym } from "../../../../new_fields/Doc"; +import { Id } from "../../../../new_fields/FieldSymbols"; +import { List } from "../../../../new_fields/List"; +import { ObjectField } from "../../../../new_fields/ObjectField"; +import { listSpec } from "../../../../new_fields/Schema"; +import { SchemaHeaderField } from "../../../../new_fields/SchemaHeaderField"; +import { ComputedField } from "../../../../new_fields/ScriptField"; +import { BoolCast, Cast, NumCast, StrCast, FieldValue } from "../../../../new_fields/Types"; +import { emptyFunction, returnEmptyString, returnFalse, returnOne, Utils, returnZero } from "../../../../Utils"; +import { DocServer } from "../../../DocServer"; +import { Docs } from "../../../documents/Documents"; +import { CollectionViewType } from "../../collections/CollectionView"; +import { DocumentView } from "../DocumentView"; +import { FormattedTextBox } from "./FormattedTextBox"; +import { DocumentManager } from "../../../util/DocumentManager"; +import { Transform } from "../../../util/Transform"; +import React = require("react"); + +import { schema } from "./schema_rts"; + +export class OrderedListView { + update(node: any) { + return false; // if attr's of an ordered_list (e.g., bulletStyle) change, return false forces the dom node to be recreated which is necessary for the bullet labels to update + } +} + +export class ImageResizeView { + _handle: HTMLElement; + _img: HTMLElement; + _outer: HTMLElement; + constructor(node: any, view: any, getPos: any, addDocTab: any) { + //moved + this._handle = document.createElement("span"); + this._img = document.createElement("img"); + this._outer = document.createElement("span"); + this._outer.style.position = "relative"; + this._outer.style.width = node.attrs.width; + this._outer.style.height = node.attrs.height; + this._outer.style.display = "inline-block"; + this._outer.style.overflow = "hidden"; + (this._outer.style as any).float = node.attrs.float; + //moved + this._img.setAttribute("src", node.attrs.src); + this._img.style.width = "100%"; + this._handle.style.position = "absolute"; + this._handle.style.width = "20px"; + this._handle.style.height = "20px"; + this._handle.style.backgroundColor = "blue"; + this._handle.style.borderRadius = "15px"; + this._handle.style.display = "none"; + this._handle.style.bottom = "-10px"; + this._handle.style.right = "-10px"; + const self = this; + //moved + this._img.onclick = function (e: any) { + e.stopPropagation(); + e.preventDefault(); + if (view.state.selection.node && view.state.selection.node.type !== view.state.schema.nodes.image) { + view.dispatch(view.state.tr.setSelection(new NodeSelection(view.state.doc.resolve(view.state.selection.from - 2)))); + } + }; + //moved + this._img.onpointerdown = function (e: any) { + if (e.ctrlKey) { + e.preventDefault(); + e.stopPropagation(); + DocServer.GetRefField(node.attrs.docid).then(async linkDoc => + (linkDoc instanceof Doc) && + DocumentManager.Instance.FollowLink(linkDoc, view.state.schema.Document, + document => addDocTab(document, node.attrs.location ? node.attrs.location : "inTab"), false)); + } + }; + //moved + this._handle.onpointerdown = function (e: any) { + e.preventDefault(); + e.stopPropagation(); + const wid = Number(getComputedStyle(self._img).width.replace(/px/, "")); + const hgt = Number(getComputedStyle(self._img).height.replace(/px/, "")); + const startX = e.pageX; + const startWidth = parseFloat(node.attrs.width); + const onpointermove = (e: any) => { + const currentX = e.pageX; + const diffInPx = currentX - startX; + self._outer.style.width = `${startWidth + diffInPx}`; + self._outer.style.height = `${(startWidth + diffInPx) * hgt / wid}`; + }; + + const onpointerup = () => { + document.removeEventListener("pointermove", onpointermove); + document.removeEventListener("pointerup", onpointerup); + const pos = view.state.selection.from; + view.dispatch(view.state.tr.setNodeMarkup(getPos(), null, { ...node.attrs, width: self._outer.style.width, height: self._outer.style.height })); + view.dispatch(view.state.tr.setSelection(new NodeSelection(view.state.doc.resolve(pos)))); + }; + + document.addEventListener("pointermove", onpointermove); + document.addEventListener("pointerup", onpointerup); + }; + //Moved + this._outer.appendChild(this._img); + this._outer.appendChild(this._handle); + (this as any).dom = this._outer; + } + + selectNode() { + this._img.classList.add("ProseMirror-selectednode"); + + this._handle.style.display = ""; + } + + deselectNode() { + this._img.classList.remove("ProseMirror-selectednode"); + + this._handle.style.display = "none"; + } +} + +export class DashDocCommentView { + _collapsed: HTMLElement; + _view: any; + constructor(node: any, view: any, getPos: any) { + //moved + this._collapsed = document.createElement("span"); + this._collapsed.className = "formattedTextBox-inlineComment"; + this._collapsed.id = "DashDocCommentView-" + node.attrs.docid; + this._view = view; + //moved + const targetNode = () => { // search forward in the prosemirror doc for the attached dashDocNode that is the target of the comment anchor + for (let i = getPos() + 1; i < view.state.doc.content.size; i++) { + const m = view.state.doc.nodeAt(i); + if (m && m.type === view.state.schema.nodes.dashDoc && m.attrs.docid === node.attrs.docid) { + return { node: m, pos: i, hidden: m.attrs.hidden } as { node: any, pos: number, hidden: boolean }; + } + } + const dashDoc = view.state.schema.nodes.dashDoc.create({ width: 75, height: 35, title: "dashDoc", docid: node.attrs.docid, float: "right" }); + view.dispatch(view.state.tr.insert(getPos() + 1, dashDoc)); + setTimeout(() => { try { view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.tr.doc, getPos() + 2))); } catch (e) { } }, 0); + return undefined; + }; + //moved + this._collapsed.onpointerdown = (e: any) => { + e.stopPropagation(); + }; + //moved + this._collapsed.onpointerup = (e: any) => { + const target = targetNode(); + if (target) { + const expand = target.hidden; + const tr = view.state.tr.setNodeMarkup(target.pos, undefined, { ...target.node.attrs, hidden: target.node.attrs.hidden ? false : true }); + view.dispatch(tr.setSelection(TextSelection.create(tr.doc, getPos() + (expand ? 2 : 1)))); // update the attrs + setTimeout(() => { + expand && DocServer.GetRefField(node.attrs.docid).then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowHighlight(dashDoc)); + try { view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.tr.doc, getPos() + (expand ? 2 : 1)))); } catch (e) { } + }, 0); + } + e.stopPropagation(); + }; + //moved + this._collapsed.onpointerenter = (e: any) => { + DocServer.GetRefField(node.attrs.docid).then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowHighlight(dashDoc, false)); + e.preventDefault(); + e.stopPropagation(); + }; + //moved + this._collapsed.onpointerleave = (e: any) => { + DocServer.GetRefField(node.attrs.docid).then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowUnhighlight()); + e.preventDefault(); + e.stopPropagation(); + }; + + (this as any).dom = this._collapsed; + } + //moved + selectNode() { } +} + +export class DashDocView { + _dashSpan: HTMLDivElement; + _outer: HTMLElement; + _dashDoc: Doc | undefined; + _reactionDisposer: IReactionDisposer | undefined; + _renderDisposer: IReactionDisposer | undefined; + _textBox: FormattedTextBox; + + getDocTransform = () => { + const { scale, translateX, translateY } = Utils.GetScreenTransform(this._outer); + return new Transform(-translateX, -translateY, 1).scale(1 / this.contentScaling() / scale); + } + contentScaling = () => NumCast(this._dashDoc!._nativeWidth) > 0 ? this._dashDoc![WidthSym]() / NumCast(this._dashDoc!._nativeWidth) : 1; + + outerFocus = (target: Doc) => this._textBox.props.focus(this._textBox.props.Document); // ideally, this would scroll to show the focus target + + constructor(node: any, view: any, getPos: any, tbox: FormattedTextBox) { + this._textBox = tbox; + this._dashSpan = document.createElement("div"); + this._outer = document.createElement("span"); + this._outer.style.position = "relative"; + this._outer.style.textIndent = "0"; + this._outer.style.border = "1px solid " + StrCast(tbox.layoutDoc.color, (Cast(Doc.UserDoc().activeWorkspace, Doc, null).darkScheme ? "dimGray" : "lightGray")); + this._outer.style.width = node.attrs.width; + this._outer.style.height = node.attrs.height; + this._outer.style.display = node.attrs.hidden ? "none" : "inline-block"; + // this._outer.style.overflow = "hidden"; // bcz: not sure if this is needed. if it's used, then the doc doesn't highlight when you hover over a docComment + (this._outer.style as any).float = node.attrs.float; + + this._dashSpan.style.width = node.attrs.width; + this._dashSpan.style.height = node.attrs.height; + this._dashSpan.style.position = "absolute"; + this._dashSpan.style.display = "inline-block"; + this._dashSpan.onpointerleave = () => { + const ele = document.getElementById("DashDocCommentView-" + node.attrs.docid); + if (ele) { + (ele as HTMLDivElement).style.backgroundColor = ""; + } + }; + this._dashSpan.onpointerenter = () => { + const ele = document.getElementById("DashDocCommentView-" + node.attrs.docid); + if (ele) { + (ele as HTMLDivElement).style.backgroundColor = "orange"; + } + }; + const removeDoc = () => { + const pos = getPos(); + const ns = new NodeSelection(view.state.doc.resolve(pos)); + view.dispatch(view.state.tr.setSelection(ns).deleteSelection()); + return true; + }; + const alias = node.attrs.alias; + + const docid = node.attrs.docid || tbox.props.Document[Id];// tbox.props.DataDoc?.[Id] || tbox.dataDoc?.[Id]; + DocServer.GetRefField(docid + alias).then(async dashDoc => { + if (!(dashDoc instanceof Doc)) { + alias && DocServer.GetRefField(docid).then(async dashDocBase => { + if (dashDocBase instanceof Doc) { + const aliasedDoc = Doc.MakeAlias(dashDocBase, docid + alias); + aliasedDoc.layoutKey = "layout"; + node.attrs.fieldKey && Doc.makeCustomViewClicked(aliasedDoc, Docs.Create.StackingDocument, node.attrs.fieldKey, undefined); + self.doRender(aliasedDoc, removeDoc, node, view, getPos); + } + }); + } else { + self.doRender(dashDoc, removeDoc, node, view, getPos); + } + }); + const self = this; + this._dashSpan.onkeydown = function (e: any) { + e.stopPropagation(); + if (e.key === "Tab" || e.key === "Enter") { + e.preventDefault(); + } + }; + this._dashSpan.onkeypress = function (e: any) { e.stopPropagation(); }; + this._dashSpan.onwheel = function (e: any) { e.preventDefault(); }; + this._dashSpan.onkeyup = function (e: any) { e.stopPropagation(); }; + this._outer.appendChild(this._dashSpan); + (this as any).dom = this._outer; + } + + doRender(dashDoc: Doc, removeDoc: any, node: any, view: any, getPos: any) { + this._dashDoc = dashDoc; + const self = this; + const dashLayoutDoc = Doc.Layout(dashDoc); + const finalLayout = node.attrs.docid ? dashDoc : Doc.expandTemplateLayout(dashLayoutDoc, dashDoc, node.attrs.fieldKey); + + if (!finalLayout) setTimeout(() => self.doRender(dashDoc, removeDoc, node, view, getPos), 0); + else { + this._reactionDisposer?.(); + this._reactionDisposer = reaction(() => ({ dim: [finalLayout[WidthSym](), finalLayout[HeightSym]()], color: finalLayout.color }), ({ dim, color }) => { + this._dashSpan.style.width = this._outer.style.width = Math.max(20, dim[0]) + "px"; + this._dashSpan.style.height = this._outer.style.height = Math.max(20, dim[1]) + "px"; + this._outer.style.border = "1px solid " + StrCast(finalLayout.color, (Cast(Doc.UserDoc().activeWorkspace, Doc, null).darkScheme ? "dimGray" : "lightGray")); + }, { fireImmediately: true }); + const doReactRender = (finalLayout: Doc, resolvedDataDoc: Doc) => { + ReactDOM.unmountComponentAtNode(this._dashSpan); + + ReactDOM.render(<DocumentView + Document={finalLayout} + DataDoc={resolvedDataDoc} + LibraryPath={this._textBox.props.LibraryPath} + fitToBox={BoolCast(dashDoc._fitToBox)} + addDocument={returnFalse} + rootSelected={this._textBox.props.isSelected} + removeDocument={removeDoc} + ScreenToLocalTransform={this.getDocTransform} + addDocTab={this._textBox.props.addDocTab} + pinToPres={returnFalse} + renderDepth={self._textBox.props.renderDepth + 1} + NativeHeight={returnZero} + NativeWidth={returnZero} + PanelWidth={finalLayout[WidthSym]} + PanelHeight={finalLayout[HeightSym]} + focus={this.outerFocus} + backgroundColor={returnEmptyString} + parentActive={returnFalse} + whenActiveChanged={returnFalse} + bringToFront={emptyFunction} + dontRegisterView={false} + ContainingCollectionView={this._textBox.props.ContainingCollectionView} + ContainingCollectionDoc={this._textBox.props.ContainingCollectionDoc} + ContentScaling={this.contentScaling} + />, this._dashSpan); + if (node.attrs.width !== dashDoc._width + "px" || node.attrs.height !== dashDoc._height + "px") { + try { // bcz: an exception will be thrown if two aliases are open at the same time when a doc view comment is made + view.dispatch(view.state.tr.setNodeMarkup(getPos(), null, { ...node.attrs, width: dashDoc._width + "px", height: dashDoc._height + "px" })); + } catch (e) { + console.log(e); + } + } + }; + this._renderDisposer?.(); + this._renderDisposer = reaction(() => { + // if (!Doc.AreProtosEqual(finalLayout, dashDoc)) { + // finalLayout.rootDocument = dashDoc.aliasOf; // bcz: check on this ... why is it here? + // } + const layoutKey = StrCast(finalLayout.layoutKey); + const finalKey = layoutKey && StrCast(finalLayout[layoutKey]).split("'")?.[1]; + if (finalLayout !== dashDoc && finalKey) { + const finalLayoutField = finalLayout[finalKey]; + if (finalLayoutField instanceof ObjectField) { + finalLayout[finalKey + "-textTemplate"] = ComputedField.MakeFunction(`copyField(this.${finalKey})`, { this: Doc.name }); + } + } + return { finalLayout, resolvedDataDoc: Cast(finalLayout.resolvedDataDoc, Doc, null) }; + }, + (res) => doReactRender(res.finalLayout, res.resolvedDataDoc), + { fireImmediately: true }); + } + } + destroy() { + ReactDOM.unmountComponentAtNode(this._dashSpan); + this._reactionDisposer?.(); + } +} + +export class DashFieldView { + _fieldWrapper: HTMLDivElement; // container for label and value + _labelSpan: HTMLSpanElement; // field label + _fieldSpan: HTMLSpanElement; // field value + _fieldCheck: HTMLInputElement; + _enumerables: HTMLDivElement; // field value + _reactionDisposer: IReactionDisposer | undefined; + _textBoxDoc: Doc; + @observable _dashDoc: Doc | undefined; + _fieldKey: string; + _options: Doc[] = []; + + constructor(node: any, view: any, getPos: any, tbox: FormattedTextBox) { + this._fieldKey = node.attrs.fieldKey; + this._textBoxDoc = tbox.props.Document; + this._fieldWrapper = document.createElement("p"); + this._fieldWrapper.style.width = node.attrs.width; + this._fieldWrapper.style.height = node.attrs.height; + this._fieldWrapper.style.fontWeight = "bold"; + this._fieldWrapper.style.position = "relative"; + this._fieldWrapper.style.display = "inline-block"; + + const self = this; + + this._enumerables = document.createElement("div"); + this._enumerables.style.width = "10px"; + this._enumerables.style.height = "10px"; + this._enumerables.style.position = "relative"; + this._enumerables.style.display = "none"; + + //Moved + this._enumerables.onpointerdown = async (e) => { + e.stopPropagation(); + const collview = await Doc.addFieldEnumerations(self._textBoxDoc, self._fieldKey, [{ title: self._fieldSpan.innerText }]); + collview instanceof Doc && tbox.props.addDocTab(collview, "onRight"); + }; + //Moved + const updateText = (forceMatch: boolean) => { + self._enumerables.style.display = "none"; + const newText = self._fieldSpan.innerText.startsWith(":=") || self._fieldSpan.innerText.startsWith("=:=") ? ":=-computed-" : self._fieldSpan.innerText; + + // look for a document whose id === the fieldKey being displayed. If there's a match, then that document + // holds the different enumerated values for the field in the titles of its collected documents. + // if there's a partial match from the start of the input text, complete the text --- TODO: make this an auto suggest box and select from a drop down. + DocServer.GetRefField(self._fieldKey).then(options => { + let modText = ""; + (options instanceof Doc) && DocListCast(options.data).forEach(opt => (forceMatch ? StrCast(opt.title).startsWith(newText) : StrCast(opt.title) === newText) && (modText = StrCast(opt.title))); + if (modText) { + self._fieldSpan.innerHTML = self._dashDoc![self._fieldKey] = modText; + Doc.addFieldEnumerations(self._textBoxDoc, self._fieldKey, []); + } // if the text starts with a ':=' then treat it as an expression by making a computed field from its value storing it in the key + else if (self._fieldSpan.innerText.startsWith(":=")) { + self._dashDoc![self._fieldKey] = ComputedField.MakeFunction(self._fieldSpan.innerText.substring(2)); + } else if (self._fieldSpan.innerText.startsWith("=:=")) { + Doc.Layout(tbox.props.Document)[self._fieldKey] = ComputedField.MakeFunction(self._fieldSpan.innerText.substring(3)); + } else { + self._dashDoc![self._fieldKey] = newText; + } + }); + }; + + //Moved + this._fieldCheck = document.createElement("input"); + this._fieldCheck.id = Utils.GenerateGuid(); + this._fieldCheck.type = "checkbox"; + this._fieldCheck.style.position = "relative"; + this._fieldCheck.style.display = "none"; + this._fieldCheck.style.minWidth = "12px"; + this._fieldCheck.style.backgroundColor = "rgba(155, 155, 155, 0.24)"; + this._fieldCheck.onchange = function (e: any) { + self._dashDoc![self._fieldKey] = e.target.checked; + }; + + this._fieldSpan = document.createElement("span"); + this._fieldSpan.id = Utils.GenerateGuid(); + this._fieldSpan.contentEditable = "true"; + this._fieldSpan.style.position = "relative"; + this._fieldSpan.style.display = "none"; + this._fieldSpan.style.minWidth = "12px"; + this._fieldSpan.style.fontSize = "large"; + this._fieldSpan.onkeypress = function (e: any) { e.stopPropagation(); }; + this._fieldSpan.onkeyup = function (e: any) { e.stopPropagation(); }; + this._fieldSpan.onmousedown = function (e: any) { e.stopPropagation(); self._enumerables.style.display = "inline-block"; }; + this._fieldSpan.onblur = function (e: any) { updateText(false); }; + + // MOVED + const setDashDoc = (doc: Doc) => { + self._dashDoc = doc; + if (self._options?.length && !self._dashDoc[self._fieldKey]) { + self._dashDoc[self._fieldKey] = StrCast(self._options[0].title); + } + this._labelSpan.innerHTML = `${self._fieldKey}: `; + const fieldVal = Cast(this._dashDoc?.[self._fieldKey], "boolean", null); + this._fieldCheck.style.display = (fieldVal === true || fieldVal === false) ? "inline-block" : "none"; + this._fieldSpan.style.display = !(fieldVal === true || fieldVal === false) ? StrCast(this._dashDoc?.[self._fieldKey]) ? "" : "inline-block" : "none"; + }; + + //Moved + this._fieldSpan.onkeydown = function (e: any) { + e.stopPropagation(); + if ((e.key === "a" && e.ctrlKey) || (e.key === "a" && e.metaKey)) { + if (window.getSelection) { + const range = document.createRange(); + range.selectNodeContents(self._fieldSpan); + window.getSelection()!.removeAllRanges(); + window.getSelection()!.addRange(range); + } + e.preventDefault(); + } + if (e.key === "Enter") { + e.preventDefault(); + e.ctrlKey && Doc.addFieldEnumerations(self._textBoxDoc, self._fieldKey, [{ title: self._fieldSpan.innerText }]); + updateText(true); + } + }; + + this._labelSpan = document.createElement("span"); + this._labelSpan.style.position = "relative"; + this._labelSpan.style.fontSize = "small"; + this._labelSpan.title = "click to see related tags"; + this._labelSpan.style.fontSize = "x-small"; + this._labelSpan.onpointerdown = function (e: any) { + e.stopPropagation(); + let container = tbox.props.ContainingCollectionView; + while (container?.props.Document.isTemplateForField || container?.props.Document.isTemplateDoc) { + container = container.props.ContainingCollectionView; + } + if (container) { + const alias = Doc.MakeAlias(container.props.Document); + alias.viewType = CollectionViewType.Time; + let list = Cast(alias.schemaColumns, listSpec(SchemaHeaderField)); + if (!list) { + alias.schemaColumns = list = new List<SchemaHeaderField>(); + } + list.map(c => c.heading).indexOf(self._fieldKey) === -1 && list.push(new SchemaHeaderField(self._fieldKey, "#f1efeb")); + list.map(c => c.heading).indexOf("text") === -1 && list.push(new SchemaHeaderField("text", "#f1efeb")); + alias._pivotField = self._fieldKey; + tbox.props.addDocTab(alias, "onRight"); + } + }; + this._labelSpan.innerHTML = `${self._fieldKey}: `; + //MOVED + if (node.attrs.docid) { + DocServer.GetRefField(node.attrs.docid). + then(async dashDoc => dashDoc instanceof Doc && runInAction(() => setDashDoc(dashDoc))); + } else { + setDashDoc(tbox.props.DataDoc || tbox.dataDoc); + } + + //Moved + this._reactionDisposer?.(); + this._reactionDisposer = reaction(() => { // this reaction will update the displayed text whenever the document's fieldKey's value changes + const dashVal = this._dashDoc?.[self._fieldKey]; + return StrCast(dashVal).startsWith(":=") || dashVal === "" ? Doc.Layout(tbox.props.Document)[self._fieldKey] : dashVal; + }, fval => { + const boolVal = Cast(fval, "boolean", null); + if (boolVal === true || boolVal === false) { + this._fieldCheck.checked = boolVal; + } else { + this._fieldSpan.innerHTML = Field.toString(fval as Field) || ""; + } + this._fieldCheck.style.display = (boolVal === true || boolVal === false) ? "inline-block" : "none"; + this._fieldSpan.style.display = !(fval === true || fval === false) ? (StrCast(fval) ? "" : "inline-block") : "none"; + }, { fireImmediately: true }); + + //MOVED IN ORDER + this._fieldWrapper.appendChild(this._labelSpan); + this._fieldWrapper.appendChild(this._fieldCheck); + this._fieldWrapper.appendChild(this._fieldSpan); + this._fieldWrapper.appendChild(this._enumerables); + (this as any).dom = this._fieldWrapper; + //updateText(false); + } + //MOVED + destroy() { + this._reactionDisposer?.(); + } + //moved + selectNode() { } +} + +export class FootnoteView { + innerView: any; + outerView: any; + node: any; + dom: any; + getPos: any; + + constructor(node: any, view: any, getPos: any) { + // We'll need these later + this.node = node; + this.outerView = view; + this.getPos = getPos; + + // The node's representation in the editor (empty, for now) + this.dom = document.createElement("footnote"); + this.dom.addEventListener("pointerup", this.toggle, true); + // These are used when the footnote is selected + this.innerView = null; + } + selectNode() { + const attrs = { ...this.node.attrs }; + attrs.visibility = true; + this.dom.classList.add("ProseMirror-selectednode"); + if (!this.innerView) this.open(); + } + + deselectNode() { + const attrs = { ...this.node.attrs }; + attrs.visibility = false; + this.dom.classList.remove("ProseMirror-selectednode"); + if (this.innerView) this.close(); + } + open() { + // Append a tooltip to the outer node + const tooltip = this.dom.appendChild(document.createElement("div")); + tooltip.className = "footnote-tooltip"; + // And put a sub-ProseMirror into that + this.innerView = new EditorView(tooltip, { + // You can use any node as an editor document + state: EditorState.create({ + doc: this.node, + plugins: [keymap(baseKeymap), + keymap({ + "Mod-z": () => undo(this.outerView.state, this.outerView.dispatch), + "Mod-y": () => redo(this.outerView.state, this.outerView.dispatch), + "Mod-b": toggleMark(schema.marks.strong) + }), + // new Plugin({ + // view(newView) { + // // TODO -- make this work with RichTextMenu + // // return FormattedTextBox.getToolTip(newView); + // } + // }) + ], + + }), + // This is the magic part + dispatchTransaction: this.dispatchInner.bind(this), + handleDOMEvents: { + pointerdown: ((view: any, e: PointerEvent) => { + // Kludge to prevent issues due to the fact that the whole + // footnote is node-selected (and thus DOM-selected) when + // the parent editor is focused. + e.stopPropagation(); + document.addEventListener("pointerup", this.ignore, true); + if (this.outerView.hasFocus()) this.innerView.focus(); + }) as any + } + + }); + setTimeout(() => this.innerView && this.innerView.docView.setSelection(0, 0, this.innerView.root, true), 0); + } + + ignore = (e: PointerEvent) => { + e.stopPropagation(); + document.removeEventListener("pointerup", this.ignore, true); + } + + toggle = () => { + if (this.innerView) this.close(); + else { + this.open(); + } + } + close() { + this.innerView && this.innerView.destroy(); + this.innerView = null; + this.dom.textContent = ""; + } + + dispatchInner(tr: any) { + const { state, transactions } = this.innerView.state.applyTransaction(tr); + this.innerView.updateState(state); + + if (!tr.getMeta("fromOutside")) { + const outerTr = this.outerView.state.tr, offsetMap = StepMap.offset(this.getPos() + 1); + for (const transaction of transactions) { + const steps = transaction.steps; + for (const step of steps) { + outerTr.step(step.map(offsetMap)); + } + } + if (outerTr.docChanged) this.outerView.dispatch(outerTr); + } + } + update(node: any) { + if (!node.sameMarkup(this.node)) return false; + this.node = node; + if (this.innerView) { + const state = this.innerView.state; + const start = node.content.findDiffStart(state.doc.content); + if (start !== null) { + let { a: endA, b: endB } = node.content.findDiffEnd(state.doc.content); + const overlap = start - Math.min(endA, endB); + if (overlap > 0) { endA += overlap; endB += overlap; } + this.innerView.dispatch( + state.tr + .replace(start, endB, node.slice(start, endA)) + .setMeta("fromOutside", true)); + } + } + return true; + } + + destroy() { + if (this.innerView) this.close(); + } + + stopEvent(event: any) { + return this.innerView && this.innerView.dom.contains(event.target); + } + + ignoreMutation() { return true; } +} + +export class SummaryView { + _collapsed: HTMLElement; + _view: any; + constructor(node: any, view: any, getPos: any) { + this._collapsed = document.createElement("span"); + this._collapsed.className = this.className(node.attrs.visibility); + this._view = view; + const js = node.toJSON; + node.toJSON = function () { + return js.apply(this, arguments); + }; + + this._collapsed.onpointerdown = (e: any) => { + const visible = !node.attrs.visibility; + const attrs = { ...node.attrs, visibility: visible }; + let textSelection = TextSelection.create(view.state.doc, getPos() + 1); + if (!visible) { // update summarized text and save in attrs + textSelection = this.updateSummarizedText(getPos() + 1); + attrs.text = textSelection.content(); + attrs.textslice = attrs.text.toJSON(); + } + view.dispatch(view.state.tr. + setSelection(textSelection). // select the current summarized text (or where it will be if its collapsed) + replaceSelection(!visible ? new Slice(Fragment.fromArray([]), 0, 0) : node.attrs.text). // collapse/expand it + setNodeMarkup(getPos(), undefined, attrs)); // update the attrs + e.preventDefault(); + e.stopPropagation(); + this._collapsed.className = this.className(visible); + }; + (this as any).dom = this._collapsed; + } + selectNode() { } + + deselectNode() { } + + className = (visible: boolean) => "formattedTextBox-summarizer" + (visible ? "" : "-collapsed"); + + updateSummarizedText(start?: any) { + const mtype = this._view.state.schema.marks.summarize; + const mtypeInc = this._view.state.schema.marks.summarizeInclusive; + let endPos = start; + + const visited = new Set(); + for (let i: number = start + 1; i < this._view.state.doc.nodeSize - 1; i++) { + let skip = false; + this._view.state.doc.nodesBetween(start, i, (node: Node, pos: number, parent: Node, index: number) => { + if (node.isLeaf && !visited.has(node) && !skip) { + if (node.marks.find((m: any) => m.type === mtype || m.type === mtypeInc)) { + visited.add(node); + endPos = i + node.nodeSize - 1; + } + else skip = true; + } + }); + } + return TextSelection.create(this._view.state.doc, start, endPos); + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/formattedText/SummaryView.tsx b/src/client/views/nodes/formattedText/SummaryView.tsx new file mode 100644 index 000000000..89908d8ee --- /dev/null +++ b/src/client/views/nodes/formattedText/SummaryView.tsx @@ -0,0 +1,81 @@ +import { TextSelection } from "prosemirror-state"; +import { Fragment, Node, Slice } from "prosemirror-model"; + +import React = require("react"); + +interface ISummaryView { + node: any; + view: any; + getPos: any; + self: any; +} +export class SummaryView extends React.Component<ISummaryView> { + + onPointerDown = (e: any) => { + const visible = !this.props.node.attrs.visibility; + const attrs = { ...this.props.node.attrs, visibility: visible }; + let textSelection = TextSelection.create(this.props.view.state.doc, this.props.getPos() + 1); + if (!visible) { // update summarized text and save in attrs + textSelection = this.updateSummarizedText(this.props.getPos() + 1); + attrs.text = textSelection.content(); + attrs.textslice = attrs.text.toJSON(); + } + this.props.view.dispatch(this.props.view.state.tr. + setSelection(textSelection). // select the current summarized text (or where it will be if its collapsed) + replaceSelection(!visible ? new Slice(Fragment.fromArray([]), 0, 0) : this.props.node.attrs.text). // collapse/expand it + setNodeMarkup(this.props.getPos(), undefined, attrs)); // update the attrs + e.preventDefault(); + e.stopPropagation(); + const _collapsed = document.getElementById('collapse') as HTMLElement; + _collapsed.className = this.className(visible); + } + + updateSummarizedText(start?: any) { + const mtype = this.props.view.state.schema.marks.summarize; + const mtypeInc = this.props.view.state.schema.marks.summarizeInclusive; + let endPos = start; + + const visited = new Set(); + for (let i: number = start + 1; i < this.props.view.state.doc.nodeSize - 1; i++) { + let skip = false; + this.props.view.state.doc.nodesBetween(start, i, (node: Node, pos: number, parent: Node, index: number) => { + if (this.props.node.isLeaf && !visited.has(node) && !skip) { + if (this.props.node.marks.find((m: any) => m.type === mtype || m.type === mtypeInc)) { + visited.add(node); + endPos = i + this.props.node.nodeSize - 1; + } + else skip = true; + } + }); + } + return TextSelection.create(this.props.view.state.doc, start, endPos); + } + + className = (visible: boolean) => "formattedTextBox-summarizer" + (visible ? "" : "-collapsed"); + + selectNode() { } + + deselectNode() { } + + render() { + const _view = this.props.node.view; + const js = this.props.node.toJSon; + + this.props.node.toJSON = function () { + return js.apply(this, arguments); + }; + + const spanCollapsedClassName = this.className(this.props.node.attrs.visibility); + + return ( + <span + className={spanCollapsedClassName} + id='collapse' + onPointerDown={this.onPointerDown} + > + + </span> + ); + + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/formattedText/TooltipTextMenu.scss b/src/client/views/nodes/formattedText/TooltipTextMenu.scss new file mode 100644 index 000000000..e2149e9c1 --- /dev/null +++ b/src/client/views/nodes/formattedText/TooltipTextMenu.scss @@ -0,0 +1,372 @@ +@import "../views/globalCssVariables"; +.ProseMirror-menu-dropdown-wrap { + display: inline-block; + position: relative; +} + +.ProseMirror-menu-dropdown { + vertical-align: 1px; + cursor: pointer; + position: relative; + padding: 0 15px 0 4px; + background: white; + border-radius: 2px; + text-align: left; + font-size: 12px; + white-space: nowrap; + margin-right: 4px; + + &:after { + content: ""; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 4px solid currentColor; + opacity: .6; + position: absolute; + right: 4px; + top: calc(50% - 2px); + } +} + +.ProseMirror-menu-submenu-wrap { + position: relative; + margin-right: -4px; +} + +.ProseMirror-menu-dropdown-menu, +.ProseMirror-menu-submenu { + font-size: 12px; + background: white; + border: 1px solid rgb(223, 223, 223); + min-width: 40px; + z-index: 50000; + position: absolute; + box-sizing: content-box; + + .ProseMirror-menu-dropdown-item { + cursor: pointer; + z-index: 100000; + text-align: left; + padding: 3px; + + &:hover { + background-color: $light-color-secondary; + } + } +} + + +.ProseMirror-menu-submenu-label:after { + content: ""; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 4px solid currentColor; + opacity: .6; + position: absolute; + right: 4px; + top: calc(50% - 4px); +} + + .ProseMirror-icon { + display: inline-block; + // line-height: .8; + // vertical-align: -2px; /* Compensate for padding */ + // padding: 2px 8px; + cursor: pointer; + + &.ProseMirror-menu-disabled { + cursor: default; + } + + svg { + fill:white; + height: 1em; + } + + span { + vertical-align: text-top; + } + } + +.wrapper { + position: absolute; + pointer-events: all; + display: flex; + align-items: center; + transform: translateY(-85px); + + height: 35px; + background: #323232; + border-radius: 6px; + box-shadow: 3px 3px 3px rgba(0, 0, 0, 0.25); + +} + +.tooltipMenu, .basic-tools { + z-index: 20000; + pointer-events: all; + padding: 3px; + padding-bottom: 5px; + display: flex; + align-items: center; + + .ProseMirror-example-setup-style hr { + padding: 2px 10px; + border: none; + margin: 1em 0; + } + + .ProseMirror-example-setup-style hr:after { + content: ""; + display: block; + height: 1px; + background-color: silver; + line-height: 2px; + } +} + +.menuicon { + width: 25px; + height: 25px; + cursor: pointer; + text-align: center; + line-height: 25px; + margin: 0 2px; + border-radius: 3px; + + &:hover { + background-color: black; + + #link-drag { + background-color: black; + } + } + + &> * { + margin-top: 50%; + margin-left: 50%; + transform: translate(-50%, -50%); + } + + svg { + fill: white; + width: 18px; + height: 18px; + } +} + +.menuicon-active { + width: 25px; + height: 25px; + cursor: pointer; + text-align: center; + line-height: 25px; + margin: 0 2px; + border-radius: 3px; + + &:hover { + background-color: black; + } + + &> * { + margin-top: 50%; + margin-left: 50%; + transform: translate(-50%, -50%); + } + + svg { + fill: greenyellow; + width: 18px; + height: 18px; + } +} + +#colorPicker { + position: relative; + + svg { + width: 18px; + height: 18px; + // margin-top: 11px; + } + + .buttonColor { + position: absolute; + top: 24px; + left: 1px; + width: 24px; + height: 4px; + margin-top: 0; + } +} + +#link-drag { + background-color: #323232; +} + +.underline svg { + margin-top: 13px; +} + + .font-size-indicator { + font-size: 12px; + padding-right: 0px; + } + .summarize{ + color: white; + height: 20px; + text-align: center; + } + + +.brush{ + display: inline-block; + width: 1em; + height: 1em; + stroke-width: 0; + stroke: currentColor; + fill: currentColor; + margin-right: 15px; +} + +.brush-active{ + display: inline-block; + width: 1em; + height: 1em; + stroke-width: 3; + fill: greenyellow; + margin-right: 15px; +} + +.dragger-wrapper { + color: #eee; + height: 22px; + padding: 0 5px; + box-sizing: content-box; + cursor: grab; + + .dragger { + width: 18px; + height: 100%; + display: flex; + justify-content: space-evenly; + } + + .dragger-line { + width: 2px; + height: 100%; + background-color: black; + } +} + +.button-dropdown-wrapper { + display: flex; + align-content: center; + + &:hover { + background-color: black; + } +} + +.buttonSettings-dropdown { + + &.ProseMirror-menu-dropdown { + width: 10px; + height: 25px; + margin: 0; + padding: 0 2px; + background-color: #323232; + text-align: center; + + &:after { + border-top: 4px solid white; + right: 2px; + } + + &:hover { + background-color: black; + } + } + + &.ProseMirror-menu-dropdown-menu { + min-width: 150px; + left: -27px; + top: 31px; + background-color: #323232; + border: 1px solid #4d4d4d; + color: $light-color-secondary; + // border: none; + // border: 1px solid $light-color-secondary; + border-radius: 0 6px 6px 6px; + padding: 3px; + box-shadow: 3px 3px 3px rgba(0, 0, 0, 0.25); + + .ProseMirror-menu-dropdown-item{ + cursor: default; + + &:last-child { + border-bottom: none; + } + + &:hover { + background-color: #323232; + } + + .button-setting, .button-setting-disabled { + padding: 2px; + border-radius: 2px; + } + + .button-setting:hover { + cursor: pointer; + background-color: black; + } + + .separated-button { + border-top: 1px solid $light-color-secondary; + padding-top: 6px; + } + + input { + color: black; + border: none; + border-radius: 1px; + padding: 3px; + } + + button { + padding: 6px; + background-color: #323232; + border: 1px solid black; + border-radius: 1px; + + &:hover { + background-color: black; + } + } + } + + + } +} + +.colorPicker-wrapper { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + margin-top: 3px; + margin-left: -3px; + width: calc(100% + 6px); +} + +button.colorPicker { + width: 20px; + height: 20px; + border-radius: 15px !important; + margin: 3px; + border: none !important; + + &.active { + border: 2px solid white !important; + } +} diff --git a/src/client/views/nodes/formattedText/marks_rts.ts b/src/client/views/nodes/formattedText/marks_rts.ts new file mode 100644 index 000000000..46bf481fb --- /dev/null +++ b/src/client/views/nodes/formattedText/marks_rts.ts @@ -0,0 +1,296 @@ +import React = require("react"); +import { DOMOutputSpecArray, Fragment, MarkSpec, Node, NodeSpec, Schema, Slice } from "prosemirror-model"; +import { Doc } from "../../../../new_fields/Doc"; + + +const emDOM: DOMOutputSpecArray = ["em", 0]; +const strongDOM: DOMOutputSpecArray = ["strong", 0]; +const codeDOM: DOMOutputSpecArray = ["code", 0]; + +// :: Object [Specs](#model.MarkSpec) for the marks in the schema. +export const marks: { [index: string]: MarkSpec } = { + // :: MarkSpec A link. Has `href` and `title` attributes. `title` + // defaults to the empty string. Rendered and parsed as an `<a>` + // element. + link: { + attrs: { + href: {}, + targetId: { default: "" }, + linkId: { default: "" }, + showPreview: { default: true }, + location: { default: null }, + title: { default: null }, + docref: { default: false } // flags whether the linked text comes from a document within Dash. If so, an attribution label is appended after the text + }, + inclusive: false, + parseDOM: [{ + tag: "a[href]", getAttrs(dom: any) { + return { href: dom.getAttribute("href"), location: dom.getAttribute("location"), title: dom.getAttribute("title"), targetId: dom.getAttribute("id") }; + } + }], + toDOM(node: any) { + return node.attrs.docref && node.attrs.title ? + ["div", ["span", `"`], ["span", 0], ["span", `"`], ["br"], ["a", { ...node.attrs, class: "prosemirror-attribution", title: `${node.attrs.title}` }, node.attrs.title], ["br"]] : + ["a", { ...node.attrs, id: node.attrs.linkId + node.attrs.targetId, title: `${node.attrs.title}` }, 0]; + } + }, + + + // :: MarkSpec Coloring on text. Has `color` attribute that defined the color of the marked text. + pFontColor: { + attrs: { + color: { default: "#000" } + }, + inclusive: true, + parseDOM: [{ + tag: "span", getAttrs(dom: any) { + return { color: dom.getAttribute("color") }; + } + }], + toDOM(node: any) { + return node.attrs.color ? ['span', { style: 'color:' + node.attrs.color }] : ['span', 0]; + } + }, + + marker: { + attrs: { + highlight: { default: "transparent" } + }, + inclusive: true, + parseDOM: [{ + tag: "span", getAttrs(dom: any) { + return { highlight: dom.getAttribute("backgroundColor") }; + } + }], + toDOM(node: any) { + return node.attrs.highlight ? ['span', { style: 'background-color:' + node.attrs.highlight }] : ['span', { style: 'background-color: transparent' }]; + } + }, + + // :: MarkSpec An emphasis mark. Rendered as an `<em>` element. + // Has parse rules that also match `<i>` and `font-style: italic`. + em: { + parseDOM: [{ tag: "i" }, { tag: "em" }, { style: "font-style: italic" }], + toDOM() { return emDOM; } + }, + + // :: MarkSpec A strong mark. Rendered as `<strong>`, parse rules + // also match `<b>` and `font-weight: bold`. + strong: { + parseDOM: [{ tag: "strong" }, + { tag: "b" }, + { style: "font-weight" }], + toDOM() { return strongDOM; } + }, + + strikethrough: { + parseDOM: [ + { tag: 'strike' }, + { style: 'text-decoration=line-through' }, + { style: 'text-decoration-line=line-through' } + ], + toDOM: () => ['span', { + style: 'text-decoration-line:line-through' + }] + }, + + subscript: { + excludes: 'superscript', + parseDOM: [ + { tag: 'sub' }, + { style: 'vertical-align=sub' } + ], + toDOM: () => ['sub'] + }, + + superscript: { + excludes: 'subscript', + parseDOM: [ + { tag: 'sup' }, + { style: 'vertical-align=super' } + ], + toDOM: () => ['sup'] + }, + + mbulletType: { + attrs: { + bulletType: { default: "decimal" } + }, + toDOM(node: any) { + return ['span', { + style: `background: ${node.attrs.bulletType === "decimal" ? "yellow" : node.attrs.bulletType === "upper-alpha" ? "blue" : "green"}` + }]; + } + }, + + metadata: { + toDOM() { + return ['span', { style: 'font-size:75%; background:rgba(100, 100, 100, 0.2); ' }]; + } + }, + metadataKey: { + toDOM() { + return ['span', { style: 'font-style:italic; ' }]; + } + }, + metadataVal: { + toDOM() { + return ['span']; + } + }, + + summarizeInclusive: { + parseDOM: [ + { + tag: "span", + getAttrs: (p: any) => { + if (typeof (p) !== "string") { + const style = getComputedStyle(p); + if (style.textDecoration === "underline") return null; + if (p.parentElement.outerHTML.indexOf("text-decoration: underline") !== -1 && + p.parentElement.outerHTML.indexOf("text-decoration-style: solid") !== -1) { + return null; + } + } + return false; + } + }, + ], + inclusive: true, + toDOM() { + return ['span', { + style: 'text-decoration: underline; text-decoration-style: solid; text-decoration-color: rgba(204, 206, 210, 0.92)' + }]; + } + }, + + summarize: { + inclusive: false, + parseDOM: [ + { + tag: "span", + getAttrs: (p: any) => { + if (typeof (p) !== "string") { + const style = getComputedStyle(p); + if (style.textDecoration === "underline") return null; + if (p.parentElement.outerHTML.indexOf("text-decoration: underline") !== -1 && + p.parentElement.outerHTML.indexOf("text-decoration-style: dotted") !== -1) { + return null; + } + } + return false; + } + }, + ], + toDOM() { + return ['span', { + style: 'text-decoration: underline; text-decoration-style: dotted; text-decoration-color: rgba(204, 206, 210, 0.92)' + }]; + } + }, + + underline: { + parseDOM: [ + { + tag: "span", + getAttrs: (p: any) => { + if (typeof (p) !== "string") { + const style = getComputedStyle(p); + if (style.textDecoration === "underline" || p.parentElement.outerHTML.indexOf("text-decoration-style:line") !== -1) { + return null; + } + } + return false; + } + } + // { style: "text-decoration=underline" } + ], + toDOM: () => ['span', { + style: 'text-decoration:underline;text-decoration-style:line' + }] + }, + + search_highlight: { + attrs: { + selected: { default: false } + }, + parseDOM: [{ style: 'background: yellow' }], + toDOM(node: any) { + return ['span', { + style: `background: ${node.attrs.selected ? "orange" : "yellow"}` + }]; + } + }, + + // the id of the user who entered the text + user_mark: { + attrs: { + userid: { default: "" }, + modified: { default: "when?" }, // 1 second intervals since 1970 + }, + group: "inline", + toDOM(node: any) { + const uid = node.attrs.userid.replace(".", "").replace("@", ""); + const min = Math.round(node.attrs.modified / 12); + const hr = Math.round(min / 60); + const day = Math.round(hr / 60 / 24); + const remote = node.attrs.userid !== Doc.CurrentUserEmail ? " userMark-remote" : ""; + return ['span', { class: "userMark-" + uid + remote + " userMark-min-" + min + " userMark-hr-" + hr + " userMark-day-" + day }, 0]; + } + }, + // the id of the user who entered the text + user_tag: { + attrs: { + userid: { default: "" }, + modified: { default: "when?" }, // 1 second intervals since 1970 + tag: { default: "" } + }, + group: "inline", + inclusive: false, + toDOM(node: any) { + const uid = node.attrs.userid.replace(".", "").replace("@", ""); + return ['span', { class: "userTag-" + uid + " userTag-" + node.attrs.tag }, 0]; + } + }, + + + // :: MarkSpec Code font mark. Represented as a `<code>` element. + code: { + parseDOM: [{ tag: "code" }], + toDOM() { return codeDOM; } + }, + + /* FONTS */ + pFontFamily: { + attrs: { + family: { default: "Crimson Text" }, + }, + parseDOM: [{ + tag: "span", getAttrs(dom: any) { + const cstyle = getComputedStyle(dom); + if (cstyle.font) { + if (cstyle.font.indexOf("Times New Roman") !== -1) return { family: "Times New Roman" }; + if (cstyle.font.indexOf("Arial") !== -1) return { family: "Arial" }; + if (cstyle.font.indexOf("Georgia") !== -1) return { family: "Georgia" }; + if (cstyle.font.indexOf("Comic Sans") !== -1) return { family: "Comic Sans MS" }; + if (cstyle.font.indexOf("Tahoma") !== -1) return { family: "Tahoma" }; + if (cstyle.font.indexOf("Crimson") !== -1) return { family: "Crimson Text" }; + } + } + }], + toDOM: (node) => ['span', { + style: `font-family: "${node.attrs.family}";` + }] + }, + + /** FONT SIZES */ + pFontSize: { + attrs: { + fontSize: { default: 10 } + }, + parseDOM: [{ style: 'font-size: 10px;' }], + toDOM: (node) => ['span', { + style: `font-size: ${node.attrs.fontSize}px;` + }] + }, +}; diff --git a/src/client/views/nodes/formattedText/nodes_rts.ts b/src/client/views/nodes/formattedText/nodes_rts.ts new file mode 100644 index 000000000..e7bcf444a --- /dev/null +++ b/src/client/views/nodes/formattedText/nodes_rts.ts @@ -0,0 +1,264 @@ +import React = require("react"); +import { DOMOutputSpecArray, Fragment, MarkSpec, Node, NodeSpec, Schema, Slice } from "prosemirror-model"; +import { bulletList, listItem, orderedList } from 'prosemirror-schema-list'; +import ParagraphNodeSpec from "./ParagraphNodeSpec"; + +const blockquoteDOM: DOMOutputSpecArray = ["blockquote", 0], hrDOM: DOMOutputSpecArray = ["hr"], + preDOM: DOMOutputSpecArray = ["pre", ["code", 0]], brDOM: DOMOutputSpecArray = ["br"], ulDOM: DOMOutputSpecArray = ["ul", 0]; + +// :: Object +// [Specs](#model.NodeSpec) for the nodes defined in this schema. +export const nodes: { [index: string]: NodeSpec } = { + // :: NodeSpec The top level document node. + doc: { + content: "block+" + }, + + footnote: { + group: "inline", + content: "inline*", + inline: true, + attrs: { + visibility: { default: false } + }, + // This makes the view treat the node as a leaf, even though it + // technically has content + atom: true, + toDOM: () => ["footnote", 0], + parseDOM: [{ tag: "footnote" }] + }, + + paragraph: ParagraphNodeSpec, + + // :: NodeSpec A blockquote (`<blockquote>`) wrapping one or more blocks. + blockquote: { + content: "block+", + group: "block", + defining: true, + parseDOM: [{ tag: "blockquote" }], + toDOM() { return blockquoteDOM; } + }, + + // :: NodeSpec A horizontal rule (`<hr>`). + horizontal_rule: { + group: "block", + parseDOM: [{ tag: "hr" }], + toDOM() { return hrDOM; } + }, + + // :: NodeSpec A heading textblock, with a `level` attribute that + // should hold the number 1 to 6. Parsed and serialized as `<h1>` to + // `<h6>` elements. + heading: { + attrs: { level: { default: 1 } }, + content: "inline*", + group: "block", + defining: true, + parseDOM: [{ tag: "h1", attrs: { level: 1 } }, + { tag: "h2", attrs: { level: 2 } }, + { tag: "h3", attrs: { level: 3 } }, + { tag: "h4", attrs: { level: 4 } }, + { tag: "h5", attrs: { level: 5 } }, + { tag: "h6", attrs: { level: 6 } }], + toDOM(node: any) { return ["h" + node.attrs.level, 0]; } + }, + + // :: NodeSpec A code listing. Disallows marks or non-text inline + // nodes by default. Represented as a `<pre>` element with a + // `<code>` element inside of it. + code_block: { + content: "text*", + marks: "", + group: "block", + code: true, + defining: true, + parseDOM: [{ tag: "pre", preserveWhitespace: "full" }], + toDOM() { return preDOM; } + }, + + // :: NodeSpec The text node. + text: { + group: "inline" + }, + + dashComment: { + attrs: { + docid: { default: "" }, + }, + inline: true, + group: "inline", + toDOM(node) { + const attrs = { style: `width: 40px` }; + return ["span", { ...node.attrs, ...attrs }, "←"]; + }, + }, + + summary: { + inline: true, + attrs: { + visibility: { default: false }, + text: { default: undefined }, + textslice: { default: undefined }, + }, + group: "inline", + toDOM(node) { + const attrs = { style: `width: 40px` }; + return ["span", { ...node.attrs, ...attrs }]; + }, + }, + + // :: NodeSpec An inline image (`<img>`) node. Supports `src`, + // `alt`, and `href` attributes. The latter two default to the empty + // string. + image: { + inline: true, + attrs: { + src: {}, + agnostic: { default: null }, + width: { default: 100 }, + alt: { default: null }, + title: { default: null }, + float: { default: "left" }, + location: { default: "onRight" }, + docid: { default: "" } + }, + group: "inline", + draggable: true, + parseDOM: [{ + tag: "img[src]", getAttrs(dom: any) { + return { + src: dom.getAttribute("src"), + title: dom.getAttribute("title"), + alt: dom.getAttribute("alt"), + width: Math.min(100, Number(dom.getAttribute("width"))), + }; + } + }], + // TODO if we don't define toDom, dragging the image crashes. Why? + toDOM(node) { + const attrs = { style: `width: ${node.attrs.width}` }; + return ["img", { ...node.attrs, ...attrs }]; + } + }, + + dashDoc: { + inline: true, + attrs: { + width: { default: 200 }, + height: { default: 100 }, + title: { default: null }, + float: { default: "right" }, + location: { default: "onRight" }, + hidden: { default: false }, + fieldKey: { default: "" }, + docid: { default: "" }, + alias: { default: "" } + }, + group: "inline", + draggable: false, + toDOM(node) { + const attrs = { style: `width: ${node.attrs.width}, height: ${node.attrs.height}` }; + return ["div", { ...node.attrs, ...attrs }]; + } + }, + + dashField: { + inline: true, + attrs: { + fieldKey: { default: "" }, + docid: { default: "" } + }, + group: "inline", + draggable: false, + toDOM(node) { + const attrs = { style: `width: ${node.attrs.width}, height: ${node.attrs.height}` }; + return ["div", { ...node.attrs, ...attrs }]; + } + }, + + video: { + inline: true, + attrs: { + src: {}, + width: { default: "100px" }, + alt: { default: null }, + title: { default: null } + }, + group: "inline", + draggable: true, + parseDOM: [{ + tag: "video[src]", getAttrs(dom: any) { + return { + src: dom.getAttribute("src"), + title: dom.getAttribute("title"), + alt: dom.getAttribute("alt"), + width: Math.min(100, Number(dom.getAttribute("width"))), + }; + } + }], + toDOM(node) { + const attrs = { style: `width: ${node.attrs.width}` }; + return ["video", { ...node.attrs, ...attrs }]; + } + }, + + // :: NodeSpec A hard line break, represented in the DOM as `<br>`. + hard_break: { + inline: true, + group: "inline", + selectable: false, + parseDOM: [{ tag: "br" }], + toDOM() { return brDOM; } + }, + + ordered_list: { + ...orderedList, + content: 'list_item+', + group: 'block', + attrs: { + bulletStyle: { default: 0 }, + mapStyle: { default: "decimal" }, + setFontSize: { default: undefined }, + setFontFamily: { default: "inherit" }, + setFontColor: { default: "inherit" }, + inheritedFontSize: { default: undefined }, + visibility: { default: true }, + indent: { default: undefined } + }, + toDOM(node: Node<any>) { + if (node.attrs.mapStyle === "bullet") return ['ul', 0]; + const map = node.attrs.bulletStyle ? node.attrs.mapStyle + node.attrs.bulletStyle : ""; + const fsize = node.attrs.setFontSize ? node.attrs.setFontSize : node.attrs.inheritedFontSize; + const ffam = node.attrs.setFontFamily; + const color = node.attrs.setFontColor; + return node.attrs.visibility ? + ['ol', { class: `${map}-ol`, style: `list-style: none; font-size: ${fsize}; font-family: ${ffam}; color:${color}; margin-left: ${node.attrs.indent}` }, 0] : + ['ol', { class: `${map}-ol`, style: `list-style: none;` }]; + } + }, + + bullet_list: { + ...bulletList, + content: 'list_item+', + group: 'block', + // parseDOM: [{ tag: "ul" }, { style: 'list-style-type=disc' }], + toDOM(node: Node<any>) { + return ['ul', 0]; + } + }, + + list_item: { + attrs: { + bulletStyle: { default: 0 }, + mapStyle: { default: "decimal" }, + visibility: { default: true } + }, + ...listItem, + content: 'paragraph block*', + toDOM(node: any) { + const map = node.attrs.bulletStyle ? node.attrs.mapStyle + node.attrs.bulletStyle : ""; + return node.attrs.visibility ? ["li", { class: `${map}` }, 0] : ["li", { class: `${map}` }, "..."]; + //return ["li", { class: `${map}` }, 0]; + } + }, +};
\ No newline at end of file diff --git a/src/client/views/nodes/formattedText/prosemirrorPatches.js b/src/client/views/nodes/formattedText/prosemirrorPatches.js new file mode 100644 index 000000000..269423482 --- /dev/null +++ b/src/client/views/nodes/formattedText/prosemirrorPatches.js @@ -0,0 +1,139 @@ +'use strict'; + +Object.defineProperty(exports, '__esModule', { value: true }); + +var prosemirrorInputRules = require('prosemirror-inputrules'); +var prosemirrorTransform = require('prosemirror-transform'); +var prosemirrorModel = require('prosemirror-model'); + +exports.liftListItem = liftListItem; +exports.sinkListItem = sinkListItem; +exports.wrappingInputRule = wrappingInputRule; +// :: (NodeType) → (state: EditorState, dispatch: ?(tr: Transaction)) → bool +// Create a command to lift the list item around the selection up into +// a wrapping list. +function liftListItem(itemType) { + return function (tx, dispatch) { + var ref = tx.selection; + var $from = ref.$from; + var $to = ref.$to; + var range = $from.blockRange($to, function (node) { return node.childCount && node.firstChild.type == itemType; }); + if (!range) { return false } + if (!dispatch) { return true } + if ($from.node(range.depth - 1).type == itemType) // Inside a parent list + { return liftToOuterList(tx, dispatch, itemType, range) } + else // Outer list node + { return liftOutOfList(tx, dispatch, range) } + } +} + +function liftToOuterList(tr, dispatch, itemType, range) { + var end = range.end, endOfList = range.$to.end(range.depth); + if (end < endOfList) { + // There are siblings after the lifted items, which must become + // children of the last item + tr.step(new prosemirrorTransform.ReplaceAroundStep(end - 1, endOfList, end, endOfList, + new prosemirrorModel.Slice(prosemirrorModel.Fragment.from(itemType.create(null, range.parent.copy())), 1, 0), 1, true)); + range = new prosemirrorModel.NodeRange(tr.doc.resolve(range.$from.pos), tr.doc.resolve(endOfList), range.depth); + } + dispatch(tr.lift(range, prosemirrorTransform.liftTarget(range)).scrollIntoView()); + return true +} + +function liftOutOfList(tr, dispatch, range) { + var list = range.parent; + // Merge the list items into a single big item + for (var pos = range.end, i = range.endIndex - 1, e = range.startIndex; i > e; i--) { + pos -= list.child(i).nodeSize; + tr.delete(pos - 1, pos + 1); + } + var $start = tr.doc.resolve(range.start), item = $start.nodeAfter; + var atStart = range.startIndex == 0, atEnd = range.endIndex == list.childCount; + var parent = $start.node(-1), indexBefore = $start.index(-1); + if (!parent.canReplace(indexBefore + (atStart ? 0 : 1), indexBefore + 1, + item.content.append(atEnd ? prosemirrorModel.Fragment.empty : prosemirrorModel.Fragment.from(list)))) { return false } + var start = $start.pos, end = start + item.nodeSize; + // Strip off the surrounding list. At the sides where we're not at + // the end of the list, the existing list is closed. At sides where + // this is the end, it is overwritten to its end. + tr.step(new prosemirrorTransform.ReplaceAroundStep(start - (atStart ? 1 : 0), end + (atEnd ? 1 : 0), start + 1, end - 1, + new prosemirrorModel.Slice((atStart ? prosemirrorModel.Fragment.empty : prosemirrorModel.Fragment.from(list.copy(prosemirrorModel.Fragment.empty))) + .append(atEnd ? prosemirrorModel.Fragment.empty : prosemirrorModel.Fragment.from(list.copy(prosemirrorModel.Fragment.empty))), + atStart ? 0 : 1, atEnd ? 0 : 1), atStart ? 0 : 1)); + dispatch(tr.scrollIntoView()); + return true +} + +// :: (NodeType) → (state: EditorState, dispatch: ?(tr: Transaction)) → bool +// Create a command to sink the list item around the selection down +// into an inner list. +function sinkListItem(itemType) { + return function (state, dispatch) { + var ref = state.selection; + var $from = ref.$from; + var $to = ref.$to; + var range = $from.blockRange($to, function (node) { return node.childCount && node.firstChild.type == itemType; }); + if (!range) { return false } + var startIndex = range.startIndex; + if (startIndex == 0) { return false } + var parent = range.parent, nodeBefore = parent.child(startIndex - 1); + if (nodeBefore.type != itemType) { return false; } + + if (dispatch) { + var nestedBefore = nodeBefore.lastChild && nodeBefore.lastChild.type == parent.type; + var inner = prosemirrorModel.Fragment.from(nestedBefore ? itemType.create() : null); + let slice = new prosemirrorModel.Slice(prosemirrorModel.Fragment.from(itemType.create(null, prosemirrorModel.Fragment.from(parent.type.create({ ...parent.attrs, fontSize: parent.attrs.fontSize ? parent.attrs.fontSize - 4 : undefined }, inner)))), + nestedBefore ? 3 : 1, 0); + var before = range.start, after = range.end; + dispatch(state.tr.step(new prosemirrorTransform.ReplaceAroundStep(before - (nestedBefore ? 3 : 1), after, + before, after, slice, 1, true)) + .scrollIntoView()); + } + return true + } +} + +function findWrappingOutside(range, type) { + var parent = range.parent; + var startIndex = range.startIndex; + var endIndex = range.endIndex; + var around = parent.contentMatchAt(startIndex).findWrapping(type); + if (!around) { return null } + var outer = around.length ? around[0] : type; + return parent.canReplaceWith(startIndex, endIndex, outer) ? around : null +} + +function findWrappingInside(range, type) { + var parent = range.parent; + var startIndex = range.startIndex; + var endIndex = range.endIndex; + var inner = parent.child(startIndex); + var inside = type.contentMatch.findWrapping(inner.type); + if (!inside) { return null } + var lastType = inside.length ? inside[inside.length - 1] : type; + var innerMatch = lastType.contentMatch; + for (var i = startIndex; innerMatch && i < endIndex; i++) { innerMatch = innerMatch.matchType(parent.child(i).type); } + if (!innerMatch || !innerMatch.validEnd) { return null } + return inside +} +function findWrapping(range, nodeType, attrs, innerRange, customWithAttrs = null) { + if (innerRange === void 0) innerRange = range; + let withAttrs = (type) => ({ type: type, attrs: null }); + var around = findWrappingOutside(range, nodeType); + var inner = around && findWrappingInside(innerRange, nodeType); + if (!inner) { return null } + return around.map(withAttrs).concat({ type: nodeType, attrs: attrs }).concat(inner.map(customWithAttrs ? customWithAttrs : withAttrs)) +} +function wrappingInputRule(regexp, nodeType, getAttrs, joinPredicate, customWithAttrs = null) { + return new prosemirrorInputRules.InputRule(regexp, function (state, match, start, end) { + var attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs; + var tr = state.tr.delete(start, end); + var $start = tr.doc.resolve(start), range = $start.blockRange(), wrapping = range && findWrapping(range, nodeType, attrs, undefined, customWithAttrs); + if (!wrapping) { return null } + tr.wrap(range, wrapping); + var before = tr.doc.resolve(start - 1).nodeBefore; + if (before && before.type == nodeType && prosemirrorTransform.canJoin(tr.doc, start - 1) && + (!joinPredicate || joinPredicate(match, before))) { tr.join(start - 1); } + return tr + }) +}
\ No newline at end of file diff --git a/src/client/views/nodes/formattedText/schema_rts.ts b/src/client/views/nodes/formattedText/schema_rts.ts new file mode 100644 index 000000000..83561073c --- /dev/null +++ b/src/client/views/nodes/formattedText/schema_rts.ts @@ -0,0 +1,26 @@ +import { Schema, Slice } from "prosemirror-model"; + +import { nodes } from "./nodes_rts"; +import { marks } from "./marks_rts"; + + +// :: Schema +// This schema rougly corresponds to the document schema used by +// [CommonMark](http://commonmark.org/), minus the list elements, +// which are defined in the [`prosemirror-schema-list`](#schema-list) +// module. +// +// To reuse elements from this schema, extend or read from its +// `spec.nodes` and `spec.marks` [properties](#model.Schema.spec). + +export const schema = new Schema({ nodes, marks }); + +const fromJson = schema.nodeFromJSON; + +schema.nodeFromJSON = (json: any) => { + const node = fromJson(json); + if (json.type === schema.nodes.summary.name) { + node.attrs.text = Slice.fromJSON(schema, node.attrs.textslice); + } + return node; +};
\ No newline at end of file diff --git a/src/client/views/pdf/Annotation.tsx b/src/client/views/pdf/Annotation.tsx index d8b340db6..672d3adb8 100644 --- a/src/client/views/pdf/Annotation.tsx +++ b/src/client/views/pdf/Annotation.tsx @@ -1,17 +1,18 @@ import React = require("react"); import { action, IReactionDisposer, observable, reaction, runInAction } from "mobx"; import { observer } from "mobx-react"; -import { Doc, DocListCast, HeightSym, WidthSym, Opt, DocListCastAsync } from "../../../new_fields/Doc"; +import { Doc, DocListCast, HeightSym, WidthSym } from "../../../new_fields/Doc"; import { Id } from "../../../new_fields/FieldSymbols"; import { List } from "../../../new_fields/List"; import { Cast, FieldValue, NumCast, StrCast } from "../../../new_fields/Types"; import { DocumentManager } from "../../util/DocumentManager"; import PDFMenu from "./PDFMenu"; import "./Annotation.scss"; +import { DocumentView } from "../nodes/DocumentView"; interface IAnnotationProps { anno: Doc; - addDocTab: (document: Doc, dataDoc: Opt<Doc>, where: string) => boolean; + addDocTab: (document: Doc, where: string) => boolean; pinToPres: (document: Doc) => void; focus: (doc: Doc) => void; dataDoc: Doc; @@ -30,7 +31,7 @@ interface IRegionAnnotationProps { y: number; width: number; height: number; - addDocTab: (document: Doc, dataDoc: Doc | undefined, where: string) => boolean; + addDocTab: (document: Doc, where: string) => boolean; pinToPres: (document: Doc) => void; document: Doc; dataDoc: Doc; @@ -97,9 +98,7 @@ class RegionAnnotation extends React.Component<IRegionAnnotationProps> { else if (e.button === 0) { const annoGroup = await Cast(this.props.document.group, Doc); if (annoGroup) { - DocumentManager.Instance.FollowLink(undefined, annoGroup, - (doc: Doc, maxLocation: string) => this.props.addDocTab(doc, undefined, e.ctrlKey ? "inTab" : "onRight"), - false, false, undefined); + DocumentManager.Instance.FollowLink(undefined, annoGroup, (doc, followLinkLocation) => this.props.addDocTab(doc, e.ctrlKey ? "inTab" : followLinkLocation), false, undefined); e.stopPropagation(); } } diff --git a/src/client/views/pdf/PDFMenu.tsx b/src/client/views/pdf/PDFMenu.tsx index 05c70b74a..2a6eff7ff 100644 --- a/src/client/views/pdf/PDFMenu.tsx +++ b/src/client/views/pdf/PDFMenu.tsx @@ -61,11 +61,10 @@ export default class PDFMenu extends AntimodeMenu { e.preventDefault(); } - @action - togglePin = (e: React.MouseEvent) => { + togglePin = action((e: React.MouseEvent) => { this.Pinned = !this.Pinned; !this.Pinned && (this.Highlighting = false); - } + }); @action highlightClicked = (e: React.MouseEvent) => { diff --git a/src/client/views/pdf/PDFViewer.scss b/src/client/views/pdf/PDFViewer.scss index 4f81c6f70..8541a3149 100644 --- a/src/client/views/pdf/PDFViewer.scss +++ b/src/client/views/pdf/PDFViewer.scss @@ -1,6 +1,5 @@ -.pdfViewer, .pdfViewer-zoomed { - pointer-events: all; +.pdfViewer, .pdfViewer-interactive { width: 100%; height: 100%; position: absolute; @@ -31,10 +30,6 @@ .page { position: relative; } - .collectionfreeformview-container { - pointer-events: none; - } - .pdfViewer-text-selected { .textLayer{ pointer-events: all; @@ -61,13 +56,8 @@ left: 0px; display: inline-block; width:100%; - pointer-events: none; - } - .pdfViewer-overlay-inking { - .collectionfreeformview-container { - pointer-events: all; - } } + .pdfViewer-annotationLayer { position: absolute; transform-origin: left top; @@ -91,7 +81,8 @@ z-index: 10; } } -.pdfViewer-zoomed { - overflow-x: scroll; + +.pdfViewer-interactive { + pointer-events: all; }
\ No newline at end of file diff --git a/src/client/views/pdf/PDFViewer.tsx b/src/client/views/pdf/PDFViewer.tsx index a7c1990e9..acaa4363e 100644 --- a/src/client/views/pdf/PDFViewer.tsx +++ b/src/client/views/pdf/PDFViewer.tsx @@ -1,36 +1,36 @@ -import { action, computed, IReactionDisposer, observable, reaction, trace, runInAction } from "mobx"; +import { action, computed, IReactionDisposer, observable, reaction, runInAction } from "mobx"; import { observer } from "mobx-react"; import * as Pdfjs from "pdfjs-dist"; import "pdfjs-dist/web/pdf_viewer.css"; +import * as rp from "request-promise"; import { Dictionary } from "typescript-collections"; -import { Doc, DocListCast, FieldResult, WidthSym, Opt, HeightSym } from "../../../new_fields/Doc"; +import { Doc, DocListCast, FieldResult, HeightSym, Opt, WidthSym } from "../../../new_fields/Doc"; +import { documentSchema } from "../../../new_fields/documentSchemas"; import { Id } from "../../../new_fields/FieldSymbols"; +import { InkTool } from "../../../new_fields/InkField"; import { List } from "../../../new_fields/List"; -import { makeInterface, createSchema } from "../../../new_fields/Schema"; +import { createSchema, makeInterface } from "../../../new_fields/Schema"; import { ScriptField } from "../../../new_fields/ScriptField"; -import { Cast, NumCast, StrCast } from "../../../new_fields/Types"; -import { smoothScroll, Utils, emptyFunction, returnOne, intersectRect, addStyleSheet, addStyleSheetRule, clearStyleSheetRules } from "../../../Utils"; +import { Cast, NumCast } from "../../../new_fields/Types"; +import { PdfField } from "../../../new_fields/URLField"; +import { TraceMobx } from "../../../new_fields/util"; +import { addStyleSheet, addStyleSheetRule, clearStyleSheetRules, emptyFunction, emptyPath, intersectRect, returnZero, smoothScroll, Utils } from "../../../Utils"; import { Docs, DocUtils } from "../../documents/Documents"; +import { DocumentType } from "../../documents/DocumentTypes"; import { DragManager } from "../../util/DragManager"; import { CompiledScript, CompileScript } from "../../util/Scripting"; -import { Transform } from "../../util/Transform"; -import PDFMenu from "./PDFMenu"; -import "./PDFViewer.scss"; -import React = require("react"); -import * as rp from "request-promise"; -import { CollectionView } from "../collections/CollectionView"; -import Annotation from "./Annotation"; -import { CollectionFreeFormView } from "../collections/collectionFreeForm/CollectionFreeFormView"; import { SelectionManager } from "../../util/SelectionManager"; +import { Transform } from "../../util/Transform"; import { undoBatch } from "../../util/UndoManager"; -import { DocAnnotatableComponent } from "../DocComponent"; -import { DocumentType } from "../../documents/DocumentTypes"; -import { documentSchema } from "../../../new_fields/documentSchemas"; +import { CollectionFreeFormView } from "../collections/collectionFreeForm/CollectionFreeFormView"; +import { CollectionView } from "../collections/CollectionView"; +import { ViewBoxAnnotatableComponent } from "../DocComponent"; import { DocumentDecorations } from "../DocumentDecorations"; import { InkingControl } from "../InkingControl"; -import { InkTool } from "../../../new_fields/InkField"; -import { TraceMobx } from "../../../new_fields/util"; -import { PdfField } from "../../../new_fields/URLField"; +import Annotation from "./Annotation"; +import PDFMenu from "./PDFMenu"; +import "./PDFViewer.scss"; +import React = require("react"); const PDFJSViewer = require("pdfjs-dist/web/pdf_viewer"); const pdfjsLib = require("pdfjs-dist"); @@ -58,6 +58,7 @@ interface IViewerProps { PanelHeight: () => number; ContentScaling: () => number; select: (isCtrlPressed: boolean) => void; + rootSelected: (outsideReaction?: boolean) => boolean; startupLive: boolean; renderDepth: number; focus: (doc: Doc) => void; @@ -65,7 +66,7 @@ interface IViewerProps { loaded: (nw: number, nh: number, np: number) => void; active: (outsideReaction?: boolean) => boolean; isChildActive: (outsideReaction?: boolean) => boolean; - addDocTab: (document: Doc, dataDoc: Doc | undefined, where: string) => boolean; + addDocTab: (document: Doc, where: string) => boolean; pinToPres: (document: Doc) => void; addDocument?: (doc: Doc) => boolean; setPdfViewer: (view: PDFViewer) => void; @@ -77,7 +78,7 @@ interface IViewerProps { * Handles rendering and virtualization of the pdf */ @observer -export class PDFViewer extends DocAnnotatableComponent<IViewerProps, PdfDocument>(PdfDocument) { +export class PDFViewer extends ViewBoxAnnotatableComponent<IViewerProps, PdfDocument>(PdfDocument) { static _annotationStyle: any = addStyleSheet(); @observable private _pageSizes: { width: number, height: number }[] = []; @observable private _annotations: Doc[] = []; @@ -101,6 +102,7 @@ export class PDFViewer extends DocAnnotatableComponent<IViewerProps, PdfDocument private _reactionDisposer?: IReactionDisposer; private _selectionReactionDisposer?: IReactionDisposer; private _annotationReactionDisposer?: IReactionDisposer; + private _scrollTopReactionDisposer?: IReactionDisposer; private _filterReactionDisposer?: IReactionDisposer; private _searchReactionDisposer?: IReactionDisposer; private _viewer: React.RefObject<HTMLDivElement> = React.createRef(); @@ -126,10 +128,9 @@ export class PDFViewer extends DocAnnotatableComponent<IViewerProps, PdfDocument !this.props.Document.lockedTransform && (this.props.Document.lockedTransform = true); // change the address to be the file address of the PNG version of each page // file address of the pdf - const { url: { href } } = Cast(this.props.Document[this.props.fieldKey], PdfField)!; - this._coverPath = href.startsWith(window.location.origin) ? - JSON.parse(await rp.get(Utils.prepend(`/thumbnail${this.props.url.substring("files/pdfs/".length, this.props.url.length - ".pdf".length)}-${(this.Document.curPage || 1)}.png`))) : - { width: 100, height: 100, path: "" }; + const { url: { href } } = Cast(this.dataDoc[this.props.fieldKey], PdfField)!; + const addr = Utils.prepend(`/thumbnail${this.props.url.substring("files/pdfs/".length, this.props.url.length - ".pdf".length)}-${(this.Document.curPage || 1)}.png`); + this._coverPath = href.startsWith(window.location.origin) ? JSON.parse(await rp.get(addr)) : { width: 100, height: 100, path: "" }; runInAction(() => this._showWaiting = this._showCover = true); this.props.startupLive && this.setupPdfJsViewer(); this._searchReactionDisposer = reaction(() => this.Document.searchMatch, search => { @@ -162,11 +163,12 @@ export class PDFViewer extends DocAnnotatableComponent<IViewerProps, PdfDocument } componentWillUnmount = () => { - this._reactionDisposer && this._reactionDisposer(); - this._annotationReactionDisposer && this._annotationReactionDisposer(); - this._filterReactionDisposer && this._filterReactionDisposer(); - this._selectionReactionDisposer && this._selectionReactionDisposer(); - this._searchReactionDisposer && this._searchReactionDisposer(); + this._reactionDisposer?.(); + this._scrollTopReactionDisposer?.(); + this._annotationReactionDisposer?.(); + this._filterReactionDisposer?.(); + this._selectionReactionDisposer?.(); + this._searchReactionDisposer?.(); document.removeEventListener("copy", this.copy); } @@ -207,6 +209,8 @@ export class PDFViewer extends DocAnnotatableComponent<IViewerProps, PdfDocument this.props.setPdfViewer(this); await this.initialLoad(); + this._scrollTopReactionDisposer = reaction(() => Cast(this.layoutDoc._scrollTop, "number", null), + (stop) => (stop !== undefined) && this._mainCont.current && smoothScroll(500, this._mainCont.current, stop), { fireImmediately: true }); this._annotationReactionDisposer = reaction( () => DocListCast(this.dataDoc[this.props.fieldKey + "-annotations"]), annotations => annotations?.length && (this._annotations = annotations), @@ -228,6 +232,12 @@ export class PDFViewer extends DocAnnotatableComponent<IViewerProps, PdfDocument this.createPdfViewer(); } + pagesinit = action(() => { + this._pdfViewer.currentScaleValue = this._zoomed = 1; + this.gotoPage(this.Document.curPage || 1); + document.removeEventListener("pagesinit", this.pagesinit); + }); + createPdfViewer() { if (!this._mainCont.current) { // bcz: I don't think this is ever triggered or needed if (this._retries < 5) { @@ -238,10 +248,7 @@ export class PDFViewer extends DocAnnotatableComponent<IViewerProps, PdfDocument } document.removeEventListener("copy", this.copy); document.addEventListener("copy", this.copy); - document.addEventListener("pagesinit", action(() => { - this._pdfViewer.currentScaleValue = this._zoomed = 1; - this.gotoPage(this.Document.curPage || 1); - })); + document.addEventListener("pagesinit", this.pagesinit); document.addEventListener("pagerendered", action(() => this._showCover = this._showWaiting = false)); const pdfLinkService = new PDFJSViewer.PDFLinkService(); const pdfFindController = new PDFJSViewer.PDFFindController({ linkService: pdfLinkService }); @@ -268,13 +275,12 @@ export class PDFViewer extends DocAnnotatableComponent<IViewerProps, PdfDocument let minY = Number.MAX_VALUE; if ((this._savedAnnotations.values()[0][0] as any).marqueeing) { const anno = this._savedAnnotations.values()[0][0]; - const annoDoc = Docs.Create.FreeformDocument([], { backgroundColor: color, title: "Annotation on " + this.Document.title }); + const annoDoc = Docs.Create.FreeformDocument([], { backgroundColor: color, _LODdisable: true, title: "Annotation on " + this.Document.title }); if (anno.style.left) annoDoc.x = parseInt(anno.style.left); if (anno.style.top) annoDoc.y = parseInt(anno.style.top); if (anno.style.height) annoDoc._height = parseInt(anno.style.height); if (anno.style.width) annoDoc._width = parseInt(anno.style.width); annoDoc.group = mainAnnoDoc; - annoDoc.isButton = true; annoDocs.push(annoDoc); anno.remove(); mainAnnoDoc = annoDoc; @@ -321,7 +327,7 @@ export class PDFViewer extends DocAnnotatableComponent<IViewerProps, PdfDocument @action gotoPage = (p: number) => { - this._pdfViewer && this._pdfViewer.scrollPageIntoView({ pageNumber: Math.min(Math.max(1, p), this._pageSizes.length) }); + this._pdfViewer?.scrollPageIntoView({ pageNumber: Math.min(Math.max(1, p), this._pageSizes.length) }); } @action @@ -411,7 +417,7 @@ export class PDFViewer extends DocAnnotatableComponent<IViewerProps, PdfDocument addStyleSheetRule(PDFViewer._annotationStyle, "pdfAnnotation", { "pointer-events": "none" }); if ((this.Document.scale || 1) !== 1) return; if ((e.button !== 0 || e.altKey) && this.active(true)) { - this._setPreviewCursor && this._setPreviewCursor(e.clientX, e.clientY, true); + this._setPreviewCursor?.(e.clientX, e.clientY, true); //e.stopPropagation(); } this._marqueeing = false; @@ -546,7 +552,7 @@ export class PDFViewer extends DocAnnotatableComponent<IViewerProps, PdfDocument highlight = (color: string) => { // creates annotation documents for current highlights const annotationDoc = this.makeAnnotationDocument(color); - annotationDoc && this.props.addDocument && this.props.addDocument(annotationDoc); + annotationDoc && this.props.addDocument?.(annotationDoc); return annotationDoc; } @@ -555,32 +561,38 @@ export class PDFViewer extends DocAnnotatableComponent<IViewerProps, PdfDocument * start a drag event and create or put the necessary info into the drag event. */ @action - startDrag = (e: PointerEvent, ele: HTMLElement): void => { + startDrag = async (e: PointerEvent, ele: HTMLElement) => { e.preventDefault(); e.stopPropagation(); + + const clipDoc = Doc.MakeAlias(this.dataDoc); + clipDoc._fitWidth = true; + clipDoc._width = this.marqueeWidth(); + clipDoc._height = this.marqueeHeight(); + clipDoc._scrollTop = this.marqueeY(); const targetDoc = Docs.Create.TextDocument("", { _width: 200, _height: 200, title: "Note linked to " + this.props.Document.title }); + Doc.GetProto(targetDoc).data = new List<Doc>([clipDoc]); + clipDoc.rootDocument = targetDoc; + Doc.makeCustomViewClicked(targetDoc, Docs.Create.StackingDocument, "slideView", undefined); + targetDoc.layoutKey = "layout"; + // const targetDoc = Docs.Create.TextDocument("", { _width: 200, _height: 200, title: "Note linked to " + this.props.Document.title }); + // Doc.GetProto(targetDoc).snipped = this.dataDoc[this.props.fieldKey][Copy](); + // const snipLayout = Docs.Create.PdfDocument("http://www.msn.com", { title: "snippetView", isTemplateDoc: true, isTemplateForField: "snipped", _fitWidth: true, _width: this.marqueeWidth(), _height: this.marqueeHeight(), _scrollTop: this.marqueeY() }); + // Doc.GetProto(snipLayout).layout = PDFBox.LayoutString("snipped"); const annotationDoc = this.highlight("rgba(146, 245, 95, 0.467)"); // yellowish highlight color when dragging out a text selection if (annotationDoc) { DragManager.StartPdfAnnoDrag([ele], new DragManager.PdfAnnoDragData(this.props.Document, annotationDoc, targetDoc), e.pageX, e.pageY, { - dragComplete: e => !e.aborted && e.annoDragData && !e.annoDragData.linkedToDoc && - DocUtils.MakeLink({ doc: annotationDoc }, { doc: e.annoDragData.dropDocument, ctx: e.annoDragData.targetContext }, `Annotation from ${this.Document.title}`, "link from PDF") + dragComplete: e => { + if (!e.aborted && e.annoDragData && !e.annoDragData.linkedToDoc) { + const link = DocUtils.MakeLink({ doc: annotationDoc }, { doc: e.annoDragData.dropDocument }, "Annotation"); + annotationDoc.isLinkButton = true; + if (link) link.followLinkLocation = "onRight"; + } + } }); } } - createSnippet = (marquee: { left: number, top: number, width: number, height: number }): void => { - const view = Doc.MakeAlias(this.props.Document); - const data = Doc.MakeDelegate(Doc.GetProto(this.props.Document)); - data.title = StrCast(data.title) + "_snippet"; - view.proto = data; - view._nativeHeight = marquee.height; - view._height = (this.Document[WidthSym]() / (this.Document._nativeWidth || 1)) * marquee.height; - view._nativeWidth = this.Document._nativeWidth; - view.startY = marquee.top; - view._width = this.Document[WidthSym](); - DragManager.StartDocumentDrag([], new DragManager.DocumentDragData([view]), 0, 0); - } - scrollXf = () => { return this._mainCont.current ? this.props.ScreenToLocalTransform().translate(0, this._scrollTop) : this.props.ScreenToLocalTransform(); } @@ -596,7 +608,7 @@ export class PDFViewer extends DocAnnotatableComponent<IViewerProps, PdfDocument getCoverImage = () => { - if (!this.props.Document[HeightSym]() || !this.props.Document.nativeHeight) { + if (!this.props.Document[HeightSym]() || !this.props.Document._nativeHeight) { setTimeout((() => { this.Document._height = this.Document[WidthSym]() * this._coverPath.height / this._coverPath.width; this.Document._nativeHeight = (this.Document._nativeWidth || 0) * this._coverPath.height / this._coverPath.width; @@ -621,22 +633,26 @@ export class PDFViewer extends DocAnnotatableComponent<IViewerProps, PdfDocument @computed get annotationLayer() { TraceMobx(); - return <div className="pdfViewer-annotationLayer" style={{ height: NumCast(this.Document.nativeHeight), transform: `scale(${this._zoomed})` }} ref={this._annotationLayer}> + return <div className="pdfViewer-annotationLayer" style={{ height: NumCast(this.Document._nativeHeight), transform: `scale(${this._zoomed})` }} ref={this._annotationLayer}> {this.nonDocAnnotations.sort((a, b) => NumCast(a.y) - NumCast(b.y)).map((anno, index) => - <Annotation {...this.props} focus={this.props.focus} dataDoc={this.dataDoc!} fieldKey={this.props.fieldKey} anno={anno} key={`${anno[Id]}-annotation`} />)} + <Annotation {...this.props} focus={this.props.focus} dataDoc={this.dataDoc} fieldKey={this.props.fieldKey} anno={anno} key={`${anno[Id]}-annotation`} />)} </div>; } overlayTransform = () => this.scrollXf().scale(1 / this._zoomed); panelWidth = () => (this.Document.scrollHeight || this.Document._nativeHeight || 0); panelHeight = () => this._pageSizes.length && this._pageSizes[0] ? this._pageSizes[0].width : (this.Document._nativeWidth || 0); @computed get overlayLayer() { - return <div className={`pdfViewer-overlay${InkingControl.Instance.selectedTool !== InkTool.None ? "-inking" : ""}`} id="overlay" style={{ transform: `scale(${this._zoomed})` }}> + return <div className={`pdfViewer-overlay${InkingControl.Instance.selectedTool !== InkTool.None ? "-inking" : ""}`} id="overlay" + style={{ transform: `scale(${this._zoomed})` }}> <CollectionFreeFormView {...this.props} - LibraryPath={this.props.ContainingCollectionView?.props.LibraryPath ?? []} + LibraryPath={this.props.ContainingCollectionView?.props.LibraryPath ?? emptyPath} annotationsKey={this.annotationKey} setPreviewCursor={this.setPreviewCursor} PanelHeight={this.panelWidth} PanelWidth={this.panelHeight} + NativeHeight={returnZero} + NativeWidth={returnZero} + dropAction={"alias"} VisibleHeight={this.visibleHeight} focus={this.props.focus} isSelected={this.props.isSelected} @@ -644,6 +660,7 @@ export class PDFViewer extends DocAnnotatableComponent<IViewerProps, PdfDocument select={emptyFunction} active={this.annotationsActive} ContentScaling={this.contentZoom} + bringToFront={emptyFunction} whenActiveChanged={this.whenActiveChanged} removeDocument={this.removeDocument} moveDocument={this.moveDocument} @@ -651,8 +668,7 @@ export class PDFViewer extends DocAnnotatableComponent<IViewerProps, PdfDocument CollectionView={undefined} ScreenToLocalTransform={this.overlayTransform} renderDepth={this.props.renderDepth + 1} - ContainingCollectionDoc={this.props.ContainingCollectionView?.props.Document} - chromeCollapsed={true}> + ContainingCollectionDoc={this.props.ContainingCollectionView?.props.Document}> </CollectionFreeFormView> </div>; } @@ -675,9 +691,10 @@ export class PDFViewer extends DocAnnotatableComponent<IViewerProps, PdfDocument contentZoom = () => this._zoomed; render() { TraceMobx(); - return <div className={"pdfViewer" + (this._zoomed !== 1 ? "-zoomed" : "")} ref={this._mainCont} + return <div className={"pdfViewer" + (this.active() ? "-interactive" : "")} ref={this._mainCont} onScroll={this.onScroll} onWheel={this.onZoomWheel} onPointerDown={this.onPointerDown} onClick={this.onClick} style={{ + overflowX: this._zoomed !== 1 ? "scroll" : undefined, width: !this.props.Document._fitWidth ? NumCast(this.props.Document._nativeWidth) : `${100 / this.contentScaling}%`, height: !this.props.Document._fitWidth ? NumCast(this.props.Document._nativeHeight) : `${100 / this.contentScaling}%`, transform: `scale(${this.props.ContentScaling()})` diff --git a/src/client/views/presentationview/PresElementBox.tsx b/src/client/views/presentationview/PresElementBox.tsx index 52773d466..dd0cbf929 100644 --- a/src/client/views/presentationview/PresElementBox.tsx +++ b/src/client/views/presentationview/PresElementBox.tsx @@ -2,19 +2,18 @@ import { library } from '@fortawesome/fontawesome-svg-core'; import { faFile as fileRegular } from '@fortawesome/free-regular-svg-icons'; import { faArrowDown, faArrowUp, faFile as fileSolid, faFileDownload, faLocationArrow, faSearch } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { action, computed, reaction, IReactionDisposer } from "mobx"; +import { action, computed, IReactionDisposer, reaction } from "mobx"; import { observer } from "mobx-react"; -import { Doc, DocListCast } from "../../../new_fields/Doc"; +import { Doc, DataSym } from "../../../new_fields/Doc"; import { documentSchema } from '../../../new_fields/documentSchemas'; import { Id } from "../../../new_fields/FieldSymbols"; import { createSchema, makeInterface } from '../../../new_fields/Schema'; -import { Cast, NumCast, StrCast } from "../../../new_fields/Types"; -import { emptyFunction, returnFalse, emptyPath } from "../../../Utils"; -import { DocumentType } from "../../documents/DocumentTypes"; +import { Cast, NumCast } from "../../../new_fields/Types"; +import { emptyFunction, emptyPath, returnFalse, returnTrue } from "../../../Utils"; import { Transform } from "../../util/Transform"; import { CollectionViewType } from '../collections/CollectionView'; +import { ViewBoxBaseComponent } from '../DocComponent'; import { ContentFittingDocumentView } from '../nodes/ContentFittingDocumentView'; -import { DocComponent, DocExtendableComponent } from '../DocComponent'; import { FieldView, FieldViewProps } from '../nodes/FieldView'; import "./PresElementBox.scss"; import React = require("react"); @@ -29,14 +28,13 @@ library.add(faArrowDown); export const presSchema = createSchema({ presentationTargetDoc: Doc, presBox: Doc, - presBoxKey: "string", - showButton: "boolean", + zoomButton: "boolean", navButton: "boolean", hideTillShownButton: "boolean", fadeButton: "boolean", hideAfterButton: "boolean", groupButton: "boolean", - embedOpen: "boolean" + expandInlineButton: "boolean" }); type PresDocument = makeInterface<[typeof presSchema, typeof documentSchema]>; @@ -46,19 +44,20 @@ const PresDocument = makeInterface(presSchema, documentSchema); * It involves some functionality for its buttons and options. */ @observer -export class PresElementBox extends DocExtendableComponent<FieldViewProps, PresDocument>(PresDocument) { +export class PresElementBox extends ViewBoxBaseComponent<FieldViewProps, PresDocument>(PresDocument) { public static LayoutString(fieldKey: string) { return FieldView.LayoutString(PresElementBox, fieldKey); } _heightDisposer: IReactionDisposer | undefined; - @computed get indexInPres() { return NumCast(this.originalLayout?.presentationIndex); } - @computed get presentationDoc() { return Cast(this.originalLayout?.presBox, Doc) as Doc; } - @computed get originalLayout() { return this.props.Document.expandedTemplate as Doc; } - @computed get targetDoc() { return this.originalLayout?.presentationTargetDoc as Doc; } - @computed get currentIndex() { return NumCast(this.presentationDoc?._itemIndex); } + @computed get indexInPres() { return NumCast(this.presElementDoc?.presentationIndex); } + @computed get presBoxDoc() { return Cast(this.presElementDoc?.presBox, Doc) as Doc; } + @computed get presElementDoc() { return this.rootDoc; } + @computed get presLayoutDoc() { return this.layoutDoc; } + @computed get targetDoc() { return this.presElementDoc?.presentationTargetDoc as Doc; } + @computed get currentIndex() { return NumCast(this.presBoxDoc?._itemIndex); } componentDidMount() { - this._heightDisposer = reaction(() => [this.originalLayout.embedOpen, this.originalLayout.collapsedHeight], - params => this.originalLayout._height = NumCast(params[1]) + (Number(params[0]) ? 100 : 0), { fireImmediately: true }); + this._heightDisposer = reaction(() => [this.presElementDoc.expandInlineButton, this.presElementDoc.collapsedHeight], + params => this.presLayoutDoc._height = NumCast(params[1]) + (Number(params[0]) ? 100 : 0), { fireImmediately: true }); } componentWillUnmount() { this._heightDisposer?.(); @@ -71,13 +70,13 @@ export class PresElementBox extends DocExtendableComponent<FieldViewProps, PresD @action onHideDocumentUntilPressClick = (e: React.MouseEvent) => { e.stopPropagation(); - this.originalLayout.hideTillShownButton = !this.originalLayout.hideTillShownButton; - if (!this.originalLayout.hideTillShownButton) { + this.presElementDoc.hideTillShownButton = !this.presElementDoc.hideTillShownButton; + if (!this.presElementDoc.hideTillShownButton) { if (this.indexInPres >= this.currentIndex && this.targetDoc) { this.targetDoc.opacity = 1; } } else { - if (this.presentationDoc.presStatus && this.indexInPres > this.currentIndex && this.targetDoc) { + if (this.presBoxDoc.presStatus && this.indexInPres > this.currentIndex && this.targetDoc) { this.targetDoc.opacity = 0; } } @@ -91,14 +90,14 @@ export class PresElementBox extends DocExtendableComponent<FieldViewProps, PresD @action onHideDocumentAfterPresentedClick = (e: React.MouseEvent) => { e.stopPropagation(); - this.originalLayout.hideAfterButton = !this.originalLayout.hideAfterButton; - if (!this.originalLayout.hideAfterButton) { + this.presElementDoc.hideAfterButton = !this.presElementDoc.hideAfterButton; + if (!this.presElementDoc.hideAfterButton) { if (this.indexInPres <= this.currentIndex && this.targetDoc) { this.targetDoc.opacity = 1; } } else { - if (this.originalLayout.fadeButton) this.originalLayout.fadeButton = false; - if (this.presentationDoc.presStatus && this.indexInPres < this.currentIndex && this.targetDoc) { + if (this.presElementDoc.fadeButton) this.presElementDoc.fadeButton = false; + if (this.presBoxDoc.presStatus && this.indexInPres < this.currentIndex && this.targetDoc) { this.targetDoc.opacity = 0; } } @@ -112,14 +111,14 @@ export class PresElementBox extends DocExtendableComponent<FieldViewProps, PresD @action onFadeDocumentAfterPresentedClick = (e: React.MouseEvent) => { e.stopPropagation(); - this.originalLayout.fadeButton = !this.originalLayout.fadeButton; - if (!this.originalLayout.fadeButton) { + this.presElementDoc.fadeButton = !this.presElementDoc.fadeButton; + if (!this.presElementDoc.fadeButton) { if (this.indexInPres <= this.currentIndex && this.targetDoc) { this.targetDoc.opacity = 1; } } else { - this.originalLayout.hideAfterButton = false; - if (this.presentationDoc.presStatus && (this.indexInPres < this.currentIndex) && this.targetDoc) { + this.presElementDoc.hideAfterButton = false; + if (this.presBoxDoc.presStatus && (this.indexInPres < this.currentIndex) && this.targetDoc) { this.targetDoc.opacity = 0.5; } } @@ -131,11 +130,11 @@ export class PresElementBox extends DocExtendableComponent<FieldViewProps, PresD @action onNavigateDocumentClick = (e: React.MouseEvent) => { e.stopPropagation(); - this.originalLayout.navButton = !this.originalLayout.navButton; - if (this.originalLayout.navButton) { - this.originalLayout.showButton = false; + this.presElementDoc.navButton = !this.presElementDoc.navButton; + if (this.presElementDoc.navButton) { + this.presElementDoc.zoomButton = false; if (this.currentIndex === this.indexInPres) { - this.props.focus(this.originalLayout); + this.props.focus(this.presElementDoc); } } } @@ -147,13 +146,13 @@ export class PresElementBox extends DocExtendableComponent<FieldViewProps, PresD onZoomDocumentClick = (e: React.MouseEvent) => { e.stopPropagation(); - this.originalLayout.showButton = !this.originalLayout.showButton; - if (!this.originalLayout.showButton) { - this.originalLayout.viewScale = 1; + this.presElementDoc.zoomButton = !this.presElementDoc.zoomButton; + if (!this.presElementDoc.zoomButton) { + this.presElementDoc.viewScale = 1; } else { - this.originalLayout.navButton = false; + this.presElementDoc.navButton = false; if (this.currentIndex === this.indexInPres) { - this.props.focus(this.originalLayout); + this.props.focus(this.presElementDoc); } } } @@ -162,19 +161,21 @@ export class PresElementBox extends DocExtendableComponent<FieldViewProps, PresD */ ScreenToLocalListTransform = (xCord: number, yCord: number) => [xCord, yCord]; - embedHeight = () => this.props.PanelHeight() - NumCast(this.originalLayout.collapsedHeight); + embedHeight = () => this.props.PanelHeight() - NumCast(this.presElementDoc.collapsedHeight); embedWidth = () => this.props.PanelWidth() - 20; /** * The function that is responsible for rendering the a preview or not for this * presentation element. */ renderEmbeddedInline = () => { - return !this.originalLayout.embedOpen || !this.targetDoc ? (null) : + return !this.presElementDoc.expandInlineButton || !this.targetDoc ? (null) : <div className="presElementBox-embedded" style={{ height: this.embedHeight() }}> <ContentFittingDocumentView Document={this.targetDoc} + DataDocument={this.targetDoc[DataSym] !== this.targetDoc && this.targetDoc[DataSym]} LibraryPath={emptyPath} fitToBox={true} + rootSelected={returnTrue} addDocument={returnFalse} removeDocument={returnFalse} addDocTab={returnFalse} @@ -196,24 +197,24 @@ export class PresElementBox extends DocExtendableComponent<FieldViewProps, PresD const treecontainer = this.props.ContainingCollectionDoc?._viewType === CollectionViewType.Tree; const className = "presElementBox-item" + (this.currentIndex === this.indexInPres ? " presElementBox-selected" : ""); const pbi = "presElementBox-interaction"; - return !this.originalLayout ? (null) : ( + return !(this.presElementDoc instanceof Doc) || this.targetDoc instanceof Promise ? (null) : ( <div className={className} key={this.props.Document[Id] + this.indexInPres} style={{ outlineWidth: Doc.IsBrushed(this.targetDoc) ? `1px` : "0px", }} - onClick={e => { this.props.focus(this.originalLayout); e.stopPropagation(); }}> + onClick={e => { this.props.focus(this.presElementDoc); e.stopPropagation(); }}> {treecontainer ? (null) : <> <strong className="presElementBox-name"> {`${this.indexInPres + 1}. ${this.targetDoc?.title}`} </strong> - <button className="presElementBox-closeIcon" onPointerDown={e => e.stopPropagation()} onClick={e => this.props.removeDocument && this.props.removeDocument(this.originalLayout)}>X</button> + <button className="presElementBox-closeIcon" onPointerDown={e => e.stopPropagation()} onClick={e => this.props.removeDocument?.(this.presElementDoc)}>X</button> <br /> </>} - <button title="Zoom" className={pbi + (this.originalLayout.showButton ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={this.onZoomDocumentClick}><FontAwesomeIcon icon={"search"} /></button> - <button title="Navigate" className={pbi + (this.originalLayout.navButton ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={this.onNavigateDocumentClick}><FontAwesomeIcon icon={"location-arrow"} /></button> - <button title="Hide Before" className={pbi + (this.originalLayout.hideTillShownButton ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={this.onHideDocumentUntilPressClick}><FontAwesomeIcon icon={fileSolid} /></button> - <button title="Fade After" className={pbi + (this.originalLayout.fadeButton ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={this.onFadeDocumentAfterPresentedClick}><FontAwesomeIcon icon={faFileDownload} /></button> - <button title="Hide After" className={pbi + (this.originalLayout.hideAfterButton ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={this.onHideDocumentAfterPresentedClick}><FontAwesomeIcon icon={faFileDownload} /></button> - <button title="Group With Up" className={pbi + (this.originalLayout.groupButton ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); this.originalLayout.groupButton = !this.originalLayout.groupButton; }}><FontAwesomeIcon icon={"arrow-up"} /></button> - <button title="Expand Inline" className={pbi + (this.originalLayout.embedOpen ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); this.originalLayout.embedOpen = !this.originalLayout.embedOpen; }}><FontAwesomeIcon icon={"arrow-down"} /></button> + <button title="Zoom" className={pbi + (this.presElementDoc.zoomButton ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={this.onZoomDocumentClick}><FontAwesomeIcon icon={"search"} /></button> + <button title="Navigate" className={pbi + (this.presElementDoc.navButton ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={this.onNavigateDocumentClick}><FontAwesomeIcon icon={"location-arrow"} /></button> + <button title="Hide Before" className={pbi + (this.presElementDoc.hideTillShownButton ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={this.onHideDocumentUntilPressClick}><FontAwesomeIcon icon={fileSolid} /></button> + <button title="Fade After" className={pbi + (this.presElementDoc.fadeButton ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={this.onFadeDocumentAfterPresentedClick}><FontAwesomeIcon icon={faFileDownload} /></button> + <button title="Hide After" className={pbi + (this.presElementDoc.hideAfterButton ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={this.onHideDocumentAfterPresentedClick}><FontAwesomeIcon icon={faFileDownload} /></button> + <button title="Group With Up" className={pbi + (this.presElementDoc.groupButton ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); this.presElementDoc.groupButton = !this.presElementDoc.groupButton; }}><FontAwesomeIcon icon={"arrow-up"} /></button> + <button title="Expand Inline" className={pbi + (this.presElementDoc.expandInlineButton ? "-selected" : "")} onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); this.presElementDoc.expandInlineButton = !this.presElementDoc.expandInlineButton; }}><FontAwesomeIcon icon={"arrow-down"} /></button> <br style={{ lineHeight: 0.1 }} /> {this.renderEmbeddedInline()} diff --git a/src/client/views/search/CheckBox.tsx b/src/client/views/search/CheckBox.tsx index a9d48219a..8c97d5dbc 100644 --- a/src/client/views/search/CheckBox.tsx +++ b/src/client/views/search/CheckBox.tsx @@ -17,8 +17,8 @@ interface CheckBoxProps { export class CheckBox extends React.Component<CheckBoxProps>{ // true = checked, false = unchecked @observable private _status: boolean; - @observable private uncheckTimeline: anime.AnimeTimelineInstance; - @observable private checkTimeline: anime.AnimeTimelineInstance; + // @observable private uncheckTimeline: anime.AnimeTimelineInstance; + // @observable private checkTimeline: anime.AnimeTimelineInstance; @observable private checkRef: any; @observable private _resetReaction?: IReactionDisposer; @@ -28,87 +28,87 @@ export class CheckBox extends React.Component<CheckBoxProps>{ this._status = this.props.originalStatus; this.checkRef = React.createRef(); - this.checkTimeline = anime.timeline({ - loop: false, - autoplay: false, - direction: "normal", - }); this.uncheckTimeline = anime.timeline({ - loop: false, - autoplay: false, - direction: "normal", - }); + // this.checkTimeline = anime.timeline({ + // loop: false, + // autoplay: false, + // direction: "normal", + // }); this.uncheckTimeline = anime.timeline({ + // loop: false, + // autoplay: false, + // direction: "normal", + // }); } - componentDidMount = () => { - this.uncheckTimeline.add({ - targets: this.checkRef.current, - easing: "easeInOutQuad", - duration: 500, - opacity: 0, - }); - this.checkTimeline.add({ - targets: this.checkRef.current, - easing: "easeInOutQuad", - duration: 500, - strokeDashoffset: [anime.setDashoffset, 0], - opacity: 1 - }); + // componentDidMount = () => { + // this.uncheckTimeline.add({ + // targets: this.checkRef.current, + // easing: "easeInOutQuad", + // duration: 500, + // opacity: 0, + // }); + // this.checkTimeline.add({ + // targets: this.checkRef.current, + // easing: "easeInOutQuad", + // duration: 500, + // strokeDashoffset: [anime.setDashoffset, 0], + // opacity: 1 + // }); - if (this.props.originalStatus) { - this.checkTimeline.play(); - this.checkTimeline.restart(); - } + // if (this.props.originalStatus) { + // this.checkTimeline.play(); + // this.checkTimeline.restart(); + // } - this._resetReaction = reaction( - () => this.props.parent._resetBoolean, - () => { - if (this.props.parent._resetBoolean) { - runInAction(() => { - this.reset(); - this.props.parent._resetCounter++; - if (this.props.parent._resetCounter === this.props.numCount) { - this.props.parent._resetCounter = 0; - this.props.parent._resetBoolean = false; - } - }); - } - }, - ); - } + // this._resetReaction = reaction( + // () => this.props.parent._resetBoolean, + // () => { + // if (this.props.parent._resetBoolean) { + // runInAction(() => { + // this.reset(); + // this.props.parent._resetCounter++; + // if (this.props.parent._resetCounter === this.props.numCount) { + // this.props.parent._resetCounter = 0; + // this.props.parent._resetBoolean = false; + // } + // }); + // } + // }, + // ); + // } - @action.bound - reset() { - if (this.props.default) { - if (!this._status) { - this._status = true; - this.checkTimeline.play(); - this.checkTimeline.restart(); - } - } - else { - if (this._status) { - this._status = false; - this.uncheckTimeline.play(); - this.uncheckTimeline.restart(); - } - } + // @action.bound + // reset() { + // if (this.props.default) { + // if (!this._status) { + // this._status = true; + // this.checkTimeline.play(); + // this.checkTimeline.restart(); + // } + // } + // else { + // if (this._status) { + // this._status = false; + // this.uncheckTimeline.play(); + // this.uncheckTimeline.restart(); + // } + // } - this.props.updateStatus(this.props.default); - } + // this.props.updateStatus(this.props.default); + // } @action.bound onClick = () => { - if (this._status) { - this.uncheckTimeline.play(); - this.uncheckTimeline.restart(); - } - else { - this.checkTimeline.play(); - this.checkTimeline.restart(); + // if (this._status) { + // this.uncheckTimeline.play(); + // this.uncheckTimeline.restart(); + // } + // else { + // this.checkTimeline.play(); + // this.checkTimeline.restart(); - } - this._status = !this._status; - this.props.updateStatus(this._status); + // } + // this._status = !this._status; + // this.props.updateStatus(this._status); } diff --git a/src/client/views/search/FilterBox.scss b/src/client/views/search/FilterBox.scss index ebb39460d..094ea9cc5 100644 --- a/src/client/views/search/FilterBox.scss +++ b/src/client/views/search/FilterBox.scss @@ -4,7 +4,6 @@ .filter-form { padding: 25px; width: 440px; - background: whitesmoke; position: relative; right: 1px; color: grey; @@ -12,9 +11,7 @@ display: inline-block; transform-origin: top; overflow: auto; - border-radius: 15px; - box-shadow: $intermediate-color 0.2vw 0.2vw 0.4vw; - border: solid #BBBBBBBB 1px; + border-bottom: solid black 3px; .top-filter-header { @@ -124,7 +121,7 @@ max-width: 40px; flex: initial; - &.icon{ + &.icon { width: 40px; text-align: center; margin-bottom: 5px; @@ -150,7 +147,7 @@ transition: all 0.2s ease-in-out; } - &.icon:hover + .description { + &.icon:hover+.description { opacity: 1; } } diff --git a/src/client/views/search/FilterBox.tsx b/src/client/views/search/FilterBox.tsx index 684f50766..662b37d77 100644 --- a/src/client/views/search/FilterBox.tsx +++ b/src/client/views/search/FilterBox.tsx @@ -33,7 +33,7 @@ export enum Keys { export class FilterBox extends React.Component { static Instance: FilterBox; - public _allIcons: string[] = [DocumentType.AUDIO, DocumentType.COL, DocumentType.IMG, DocumentType.LINK, DocumentType.PDF, DocumentType.TEXT, DocumentType.VID, DocumentType.WEB, DocumentType.TEMPLATE]; + public _allIcons: string[] = [DocumentType.AUDIO, DocumentType.COL, DocumentType.IMG, DocumentType.LINK, DocumentType.PDF, DocumentType.RTF, DocumentType.VID, DocumentType.WEB]; //if true, any keywords can be used. if false, all keywords are required. //this also serves as an indicator if the word status filter is applied @@ -387,7 +387,7 @@ export class FilterBox extends React.Component { {/* {this.getActiveFilters()} */} </div> {this._filterOpen ? ( - <div className="filter-form" onPointerDown={this.stopProp} id="filter-form" style={this._filterOpen ? { display: "flex" } : { display: "none" }}> + <div className="filter-form" onPointerDown={this.stopProp} id="filter-form" style={this._filterOpen ? { display: "flex", background: "black" } : { display: "none" }}> <div className="top-filter-header" style={{ display: "flex", width: "100%" }}> <div id="header">Filter Search Results</div> <div style={{ marginLeft: "auto" }}></div> diff --git a/src/client/views/search/IconBar.scss b/src/client/views/search/IconBar.scss index 2555ad271..013dcd57e 100644 --- a/src/client/views/search/IconBar.scss +++ b/src/client/views/search/IconBar.scss @@ -2,10 +2,9 @@ .icon-bar { display: flex; + flex-wrap: wrap; justify-content: space-evenly; - align-items: center; - height: 35px; + height: auto; width: 100%; - flex-wrap: wrap; - margin-bottom: 10px; + flex-direction: row-reverse; }
\ No newline at end of file diff --git a/src/client/views/search/IconBar.tsx b/src/client/views/search/IconBar.tsx index cff397407..9b7cf2fc6 100644 --- a/src/client/views/search/IconBar.tsx +++ b/src/client/views/search/IconBar.tsx @@ -9,7 +9,8 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { library } from '@fortawesome/fontawesome-svg-core'; import * as _ from "lodash"; import { IconButton } from './IconButton'; -import { FilterBox } from './FilterBox'; +import { DocumentType } from "../../documents/DocumentTypes"; + library.add(faSearch); library.add(faObjectGroup); @@ -23,8 +24,16 @@ library.add(faChartBar); library.add(faGlobeAsia); library.add(faBan); +export interface IconBarProps { + setIcons: (icons: string[]) => void; +} + + @observer -export class IconBar extends React.Component { +export class IconBar extends React.Component<IconBarProps> { + public _allIcons: string[] = [DocumentType.AUDIO, DocumentType.COL, DocumentType.IMG, DocumentType.LINK, DocumentType.PDF, DocumentType.RTF, DocumentType.VID, DocumentType.WEB]; + + @observable private _icons: string[] = this._allIcons; static Instance: IconBar; @@ -33,16 +42,25 @@ export class IconBar extends React.Component { @observable public _reset: number = 0; @observable public _select: number = 0; + @action.bound + updateIcon(newArray: string[]) { + this._icons = newArray; + this.props.setIcons?.(this._icons); + } + + @action.bound + getIcons(): string[] { return this._icons; } + constructor(props: any) { super(props); IconBar.Instance = this; } @action.bound - getList(): string[] { return FilterBox.Instance.getIcons(); } + getList(): string[] { return this.getIcons(); } @action.bound - updateList(newList: string[]) { FilterBox.Instance.updateIcon(newList); } + updateList(newList: string[]) { this.updateIcon(newList); } @action.bound resetSelf = () => { @@ -53,13 +71,13 @@ export class IconBar extends React.Component { @action.bound selectAll = () => { this._selectAllClicked = true; - this.updateList(FilterBox.Instance._allIcons); + this.updateList(this._allIcons); } render() { return ( <div className="icon-bar"> - {FilterBox.Instance._allIcons.map((type: string) => + {this._allIcons.map((type: string) => <IconButton key={type.toString()} type={type} /> )} </div> diff --git a/src/client/views/search/IconButton.scss b/src/client/views/search/IconButton.scss index 4a3107676..4ec03c7c9 100644 --- a/src/client/views/search/IconButton.scss +++ b/src/client/views/search/IconButton.scss @@ -5,7 +5,6 @@ flex-direction: column; align-items: center; width: 30px; - height: 60px; .type-icon { height: 30px; diff --git a/src/client/views/search/IconButton.tsx b/src/client/views/search/IconButton.tsx index f01508141..52641c543 100644 --- a/src/client/views/search/IconButton.tsx +++ b/src/client/views/search/IconButton.tsx @@ -11,7 +11,6 @@ import '../globalCssVariables.scss'; import * as _ from "lodash"; import { IconBar } from './IconBar'; import { props } from 'bluebird'; -import { FilterBox } from './FilterBox'; import { Search } from '../../../server/Search'; import { gravity } from 'sharp'; @@ -34,7 +33,7 @@ interface IconButtonProps { @observer export class IconButton extends React.Component<IconButtonProps>{ - @observable private _isSelected: boolean = FilterBox.Instance.getIcons().indexOf(this.props.type) !== -1; + @observable private _isSelected: boolean = IconBar.Instance.getIcons().indexOf(this.props.type) !== -1; @observable private _hover = false; private _resetReaction?: IReactionDisposer; private _selectAllReaction?: IReactionDisposer; @@ -87,15 +86,13 @@ export class IconButton extends React.Component<IconButtonProps>{ return faMusic; case (DocumentType.COL): return faObjectGroup; - case (DocumentType.HIST): - return faChartBar; case (DocumentType.IMG): return faImage; case (DocumentType.LINK): return faLink; case (DocumentType.PDF): return faFilePdf; - case (DocumentType.TEXT): + case (DocumentType.RTF): return faStickyNote; case (DocumentType.VID): return faVideo; @@ -108,7 +105,7 @@ export class IconButton extends React.Component<IconButtonProps>{ @action.bound onClick = () => { - const newList: string[] = FilterBox.Instance.getIcons(); + const newList: string[] = IconBar.Instance.getIcons(); if (!this._isSelected) { this._isSelected = true; @@ -119,21 +116,24 @@ export class IconButton extends React.Component<IconButtonProps>{ _.pull(newList, this.props.type); } - FilterBox.Instance.updateIcon(newList); + IconBar.Instance.updateIcon(newList); } selected = { opacity: 1, - backgroundColor: "rgb(128, 128, 128)" + backgroundColor: "#121721", + //backgroundColor: "rgb(128, 128, 128)" }; notSelected = { opacity: 0.2, + backgroundColor: "#121721", }; hoverStyle = { opacity: 1, - backgroundColor: "rgb(178, 206, 248)" //$darker-alt-accent + backgroundColor: "rgb(128, 128, 128)" + //backgroundColor: "rgb(178, 206, 248)" //$darker-alt-accent }; @action.bound @@ -156,15 +156,13 @@ export class IconButton extends React.Component<IconButtonProps>{ return (<FontAwesomeIcon className="fontawesome-icon" icon={faMusic} />); case (DocumentType.COL): return (<FontAwesomeIcon className="fontawesome-icon" icon={faObjectGroup} />); - case (DocumentType.HIST): - return (<FontAwesomeIcon className="fontawesome-icon" icon={faChartBar} />); case (DocumentType.IMG): return (<FontAwesomeIcon className="fontawesome-icon" icon={faImage} />); case (DocumentType.LINK): return (<FontAwesomeIcon className="fontawesome-icon" icon={faLink} />); case (DocumentType.PDF): return (<FontAwesomeIcon className="fontawesome-icon" icon={faFilePdf} />); - case (DocumentType.TEXT): + case (DocumentType.RTF): return (<FontAwesomeIcon className="fontawesome-icon" icon={faStickyNote} />); case (DocumentType.VID): return (<FontAwesomeIcon className="fontawesome-icon" icon={faVideo} />); @@ -186,7 +184,7 @@ export class IconButton extends React.Component<IconButtonProps>{ > {this.getFA()} </div> - <div className="filter-description">{this.props.type}</div> + {/* <div className="filter-description">{this.props.type}</div> */} </div> ); } diff --git a/src/client/views/search/SearchBox.scss b/src/client/views/search/SearchBox.scss index f492ea773..bb62113a1 100644 --- a/src/client/views/search/SearchBox.scss +++ b/src/client/views/search/SearchBox.scss @@ -4,20 +4,22 @@ .searchBox-container { display: flex; flex-direction: column; - width:100%; - height:100%; + width: 100%; + height: 100%; position: absolute; font-size: 10px; line-height: 1; - overflow: hidden; + overflow-y: auto; + overflow-x: visible; + background: lightgrey, } + .searchBox-bar { height: 32px; display: flex; justify-content: flex-end; align-items: center; padding-left: 2px; - padding-right: 2px; .searchBox-barChild { @@ -33,8 +35,7 @@ -webkit-transition: width 0.4s; transition: width 0.4s; align-self: stretch; - margin-left: 2px; - margin-right: 2px + } .searchBox-input:focus { @@ -44,6 +45,10 @@ &.searchBox-filter { align-self: stretch; + button:hover{ + transform:scale(1.0); + background:"#121721"; + } } &.searchBox-submit { @@ -65,7 +70,7 @@ } .searchBox-results { - display:flex; + display: flex; flex-direction: column; top: 300px; display: flex; @@ -83,6 +88,249 @@ text-transform: uppercase; text-align: left; font-weight: bold; - margin-left: 28px; + } +} + +.filter-form { + position: relative; + background: #121721; + flex-direction: column; + transform-origin: top; + transition: height 0.3s ease, display 0.6s ease; + height:0px; + overflow:hidden; + + + .filter-header { + display: flex; + position: relative; + flex-wrap:wrap; + right: 1px; + color: grey; + flex-direction: row-reverse; + transform-origin: top; + justify-content: space-evenly; + margin-bottom: 5px; + overflow:hidden; + transition:height 0.3s ease-out; + + + + .filter-item { + position: relative; + border:1px solid grey; + border-radius: 16px; + + } + } + + .filter-body { + position: relative; + right: 1px; + color: grey; + transform-origin: top; + border-top: 0px; + //padding-top: 5px; + margin-left: 10px; + margin-right: 10px; + overflow:hidden; + transition:height 0.3s ease-out; + height:0px; + + } + .filter-key { + position: relative; + right: 1px; + color: grey; + transform-origin: top; + border-top: 0px; + //padding-top: 5px; + margin-left: 10px; + margin-right: 10px; + overflow:hidden; + transition:height 0.3s ease-out; + height:0px; + .filter-keybar { + display: flex; + flex-wrap: wrap; + justify-content: space-evenly; + height: auto; + width: 100%; + flex-direction: row-reverse; + margin-top:5px; + + .filter-item { + position: relative; + border:1px solid grey; + border-radius: 16px; + + } + } + + + } +} + +// .top-filter-header { + +// #header { +// text-transform: uppercase; +// letter-spacing: 2px; +// font-size: 13; +// width: 80%; +// } + +// .close-icon { +// width: 20%; +// opacity: .6; +// position: relative; +// display: block; + +// .line { +// display: block; +// background: $alt-accent; +// width: 20; +// height: 3; +// position: absolute; +// right: 0; +// border-radius: ($height-line / 2); + +// &.line-1 { +// transform: rotate(45deg); +// top: 45%; +// } + +// &.line-2 { +// transform: rotate(-45deg); +// top: 45%; +// } +// } +// } + +// .close-icon:hover { +// opacity: 1; +// } + +// } + +// .filter-options { + +// .filter-div { +// margin-top: 10px; +// margin-bottom: 10px; +// display: inline-block; +// width: 100%; +// border-color: rgba(178, 206, 248, .2); // $darker-alt-accent +// border-top-style: solid; + +// .filter-header { +// display: flex; +// align-items: center; +// margin-bottom: 10px; +// letter-spacing: 2px; + +// .filter-title { +// font-size: 13; +// text-transform: uppercase; +// margin-top: 10px; +// margin-bottom: 10px; +// -webkit-transition: all 0.2s ease-in-out; +// -moz-transition: all 0.2s ease-in-out; +// -o-transition: all 0.2s ease-in-out; +// transition: all 0.2s ease-in-out; +// } +// } + +// .filter-header:hover .filter-title { +// transform: scale(1.05); +// } + +// .filter-panel { +// max-height: 0px; +// width: 100%; +// overflow: hidden; +// opacity: 0; +// transform-origin: top; +// -webkit-transition: all 0.2s ease-in-out; +// -moz-transition: all 0.2s ease-in-out; +// -o-transition: all 0.2s ease-in-out; +// transition: all 0.2s ease-in-out; +// text-align: center; +// } +// } +// } + +// .filter-buttons { +// border-color: rgba(178, 206, 248, .2); // $darker-alt-accent +// border-top-style: solid; +// padding-top: 10px; +// } + + +.active-filters { + display: flex; + flex-direction: row-reverse; + justify-content: flex-end; + width: 100%; + margin-right: 30px; + position: relative; + + .active-icon { + max-width: 40px; + flex: initial; + + &.icon { + width: 40px; + text-align: center; + margin-bottom: 5px; + position: absolute; + } + + &.container { + display: flex; + flex-direction: column; + width: 40px; + } + + &.description { + text-align: center; + top: 40px; + position: absolute; + width: 40px; + font-size: 9px; + opacity: 0; + -webkit-transition: all 0.2s ease-in-out; + -moz-transition: all 0.2s ease-in-out; + -o-transition: all 0.2s ease-in-out; + transition: all 0.2s ease-in-out; + } + + &.icon:hover+.description { + opacity: 1; + } + } + + .col-icon { + height: 35px; + margin-left: 5px; + width: 35px; + background-color: black; + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + + .save-filter, + .reset-filter, + .all-filter { + background-color: gray; + } + + .save-filter:hover, + .reset-filter:hover, + .all-filter:hover { + background-color: $darker-alt-accent; + } } }
\ No newline at end of file diff --git a/src/client/views/search/SearchBox.tsx b/src/client/views/search/SearchBox.tsx index be13dae03..e41b725b1 100644 --- a/src/client/views/search/SearchBox.tsx +++ b/src/client/views/search/SearchBox.tsx @@ -1,37 +1,56 @@ import { library } from '@fortawesome/fontawesome-svg-core'; import { faTimes } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { action, computed, observable, runInAction } from 'mobx'; +import { action, computed, observable, runInAction, IReactionDisposer, reaction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import * as rp from 'request-promise'; import { Doc } from '../../../new_fields/Doc'; import { Id } from '../../../new_fields/FieldSymbols'; -import { Cast, NumCast } from '../../../new_fields/Types'; +import { Cast, NumCast, StrCast } from '../../../new_fields/Types'; import { Utils } from '../../../Utils'; import { Docs } from '../../documents/Documents'; import { SetupDrag } from '../../util/DragManager'; import { SearchUtil } from '../../util/SearchUtil'; -import { FilterBox } from './FilterBox'; -import "./FilterBox.scss"; import "./SearchBox.scss"; import { SearchItem } from './SearchItem'; import { IconBar } from './IconBar'; +import { FieldView } from '../nodes/FieldView'; +import { DocumentType } from "../../documents/DocumentTypes"; +import { DocumentView } from '../nodes/DocumentView'; +import { SelectionManager } from '../../util/SelectionManager'; +import { listSpec } from '../../../new_fields/Schema'; library.add(faTimes); +export interface SearchProps { + id: string; + searchQuery: string; + filterQquery?: string; + setSearchQuery: (q: string) => {}; + searchFileTypes: string[]; + setSearchFileTypes: (types: string[]) => {}; +} + +export enum Keys { + TITLE = "title", + AUTHOR = "author", + DATA = "data" +} + @observer -export class SearchBox extends React.Component { +export class SearchBox extends React.Component<SearchProps> { - @observable private _searchString: string = ""; + private get _searchString() { return this.props.searchQuery; } + private set _searchString(value) { this.props.setSearchQuery(value); } @observable private _resultsOpen: boolean = false; @observable private _searchbarOpen: boolean = false; @observable private _results: [Doc, string[], string[]][] = []; - private _resultsSet = new Map<Doc, number>(); @observable private _openNoResults: boolean = false; @observable private _visibleElements: JSX.Element[] = []; - private resultsRef = React.createRef<HTMLDivElement>(); + private _resultsSet = new Map<Doc, number>(); + private _resultsRef = React.createRef<HTMLDivElement>(); public inputRef = React.createRef<HTMLInputElement>(); private _isSearch: ("search" | "placeholder" | undefined)[] = []; @@ -42,32 +61,36 @@ export class SearchBox extends React.Component { private _maxSearchIndex: number = 0; private _curRequest?: Promise<any> = undefined; + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(SearchBox, fieldKey); } + + + //if true, any keywords can be used. if false, all keywords are required. + //this also serves as an indicator if the word status filter is applied + @observable private _basicWordStatus: boolean = false; + @observable private _nodeStatus: boolean = false; + @observable private _keyStatus: boolean = false; + constructor(props: any) { super(props); - SearchBox.Instance = this; this.resultsScrolled = this.resultsScrolled.bind(this); } - componentDidMount = () => { + componentDidMount = action(() => { if (this.inputRef.current) { this.inputRef.current.focus(); - runInAction(() => { - this._searchbarOpen = true; - }); + this._searchbarOpen = true; } - } + if (this.props.searchQuery) { // bcz: why was this here? } && this.props.filterQquery) { + this._searchString = this.props.searchQuery; + this.submitSearch(); + } + }); + @action - getViews = async (doc: Doc) => { - const results = await SearchUtil.GetViewsOfDocument(doc); - let toReturn: Doc[] = []; - await runInAction(() => { - toReturn = results; - }); - return toReturn; - } + getViews = (doc: Doc) => SearchUtil.GetViewsOfDocument(doc) @action.bound onChange(e: React.ChangeEvent<HTMLInputElement>) { @@ -106,33 +129,175 @@ export class SearchBox extends React.Component { } } + public _allIcons: string[] = [DocumentType.AUDIO, DocumentType.COL, DocumentType.IMG, DocumentType.LINK, DocumentType.PDF, DocumentType.RTF, DocumentType.VID, DocumentType.WEB]; + //if true, any keywords can be used. if false, all keywords are required. + //this also serves as an indicator if the word status filter is applied + @observable private _filterOpen: boolean = false; + //if icons = all icons, then no icon filter is applied + get _icons() { return this.props.searchFileTypes; } + set _icons(value) { + this.props.setSearchFileTypes(value); + } + //if all of these are true, no key filter is applied + @observable private _titleFieldStatus: boolean = true; + @observable private _authorFieldStatus: boolean = true; + //this also serves as an indicator if the collection status filter is applied + @observable public _deletedDocsStatus: boolean = false; + @observable private _collectionStatus = false; + + + getFinalQuery(query: string): string { + //alters the query so it looks in the correct fields + //if this is true, then not all of the field boxes are checked + //TODO: data + if (this.fieldFiltersApplied) { + query = this.applyBasicFieldFilters(query); + query = query.replace(/\s+/g, ' ').trim(); + } + + //alters the query based on if all words or any words are required + //if this._wordstatus is false, all words are required and a + is added before each + if (!this._basicWordStatus) { + query = this.basicRequireWords(query); + query = query.replace(/\s+/g, ' ').trim(); + } + + //if should be searched in a specific collection + if (this._collectionStatus) { + query = this.addCollectionFilter(query); + query = query.replace(/\s+/g, ' ').trim(); + } + return query; + } + + basicRequireWords(query: string): string { + return query.split(" ").join(" + ").replace(/ + /, ""); + } + + @action + filterDocsByType(docs: Doc[]) { + if (this._icons.length === this._allIcons.length) { + return docs; + } + const finalDocs: Doc[] = []; + docs.forEach(doc => { + const layoutresult = Cast(doc.type, "string"); + if (layoutresult && this._icons.includes(layoutresult)) { + finalDocs.push(doc); + } + }); + return finalDocs; + } + + addCollectionFilter(query: string): string { + const collections: Doc[] = this.getCurCollections(); + const oldWords = query.split(" "); + + const collectionString: string[] = []; + collections.forEach(doc => { + const proto = doc.proto; + const protoId = (proto || doc)[Id]; + const colString: string = "{!join from=data_l to=id}id:" + protoId + " "; + collectionString.push(colString); + }); + + let finalColString = collectionString.join(" "); + finalColString = finalColString.trim(); + return "+(" + finalColString + ")" + query; + } + + get filterTypes() { + return this._icons.length === this._allIcons.length ? undefined : this._icons; + } + + //TODO: basically all of this + //gets all of the collections of all the docviews that are selected + //if a collection is the only thing selected, search only in that collection (not its container) + getCurCollections(): Doc[] { + const selectedDocs: DocumentView[] = SelectionManager.SelectedDocuments(); + const collections: Doc[] = []; + + selectedDocs.forEach(async element => { + const layout: string = StrCast(element.props.Document.layout); + //checks if selected view (element) is a collection. if it is, adds to list to search through + if (layout.indexOf("Collection") > -1) { + //makes sure collections aren't added more than once + if (!collections.includes(element.props.Document)) { + collections.push(element.props.Document); + } + } + //makes sure collections aren't added more than once + if (element.props.ContainingCollectionDoc && !collections.includes(element.props.ContainingCollectionDoc)) { + collections.push(element.props.ContainingCollectionDoc); + } + }); + + return collections; + } + + + applyBasicFieldFilters(query: string) { + let finalQuery = ""; + + if (this._titleFieldStatus) { + finalQuery = finalQuery + this.basicFieldFilters(query, Keys.TITLE); + } + if (this._authorFieldStatus) { + finalQuery = finalQuery + this.basicFieldFilters(query, Keys.AUTHOR); + } + if (this._deletedDocsStatus) { + finalQuery = finalQuery + this.basicFieldFilters(query, Keys.DATA); + } + return finalQuery; + } + + basicFieldFilters(query: string, type: string): string { + const oldWords = query.split(" "); + let mod = ""; + + if (type === Keys.AUTHOR) { + mod = " author_t:"; + } if (type === Keys.DATA) { + //TODO + } if (type === Keys.TITLE) { + mod = " title_t:"; + } + + const newWords: string[] = []; + oldWords.forEach(word => { + const newWrd = mod + word; + newWords.push(newWrd); + }); + + query = newWords.join(" "); + + return query; + } + + get fieldFiltersApplied() { return !(this._authorFieldStatus && this._titleFieldStatus); } + + @action submitSearch = async () => { - let query = this._searchString; - query = FilterBox.Instance.getFinalQuery(query); + const query = this._searchString; + this.getFinalQuery(query); this._results = []; this._resultsSet.clear(); this._isSearch = []; this._visibleElements = []; - FilterBox.Instance.closeFilter(); - - //if there is no query there should be no result - if (query === "") { - return; - } - else { + if (query !== "") { this._endIndex = 12; this._maxSearchIndex = 0; this._numTotalResults = -1; await this.getResults(query); - } - runInAction(() => { - this._resultsOpen = true; - this._searchbarOpen = true; - this._openNoResults = true; - this.resultsScrolled(); - }); + runInAction(() => { + this._resultsOpen = true; + this._searchbarOpen = true; + this._openNoResults = true; + this.resultsScrolled(); + }); + } } getAllResults = async (query: string) => { @@ -140,11 +305,18 @@ export class SearchBox extends React.Component { } private get filterQuery() { - const types = FilterBox.Instance.filterTypes; - const includeDeleted = FilterBox.Instance.getDataStatus(); - return "NOT baseProto_b:true" + (includeDeleted ? "" : " AND NOT deleted_b:true") + (types ? ` AND (${types.map(type => `({!join from=id to=proto_i}type_t:"${type}" AND NOT type_t:*) OR type_t:"${type}" OR type_t:"extension"`).join(" ")})` : ""); + const types = this.filterTypes; + const baseExpr = "NOT baseProto_b:true"; + const includeDeleted = this.getDataStatus() ? "" : " NOT deleted_b:true"; + const includeIcons = this.getDataStatus() ? "" : " NOT type_t:fonticonbox"; + const typeExpr = !types ? "" : ` (${types.map(type => `({!join from=id to=proto_i}type_t:"${type}" AND NOT type_t:*) OR type_t:"${type}"`).join(" ")})`; + // fq: type_t:collection OR {!join from=id to=proto_i}type_t:collection q:text_t:hello + const query = [baseExpr, includeDeleted, includeIcons, typeExpr].join(" AND ").replace(/AND $/, ""); + return query; } + getDataStatus() { return this._deletedDocsStatus; } + private NumResults = 25; private lockPromise?: Promise<void>; @@ -155,7 +327,6 @@ export class SearchBox extends React.Component { this.lockPromise = new Promise(async res => { while (this._results.length <= this._endIndex && (this._numTotalResults === -1 || this._maxSearchIndex < this._numTotalResults)) { this._curRequest = SearchUtil.Search(query, true, { fq: this.filterQuery, start: this._maxSearchIndex, rows: this.NumResults, hl: true, "hl.fl": "*" }).then(action(async (res: SearchUtil.DocSearchResult) => { - // happens at the beginning if (res.numFound !== this._numTotalResults && this._numTotalResults === -1) { this._numTotalResults = res.numFound; @@ -168,9 +339,9 @@ export class SearchBox extends React.Component { const docs = await Promise.all(res.docs.map(async doc => (await Cast(doc.extendsDoc, Doc)) || doc)); const highlights: typeof res.highlighting = {}; docs.forEach((doc, index) => highlights[doc[Id]] = highlightList[index]); - const filteredDocs = FilterBox.Instance.filterDocsByType(docs); + const filteredDocs = this.filterDocsByType(docs); runInAction(() => { - // this._results.push(...filteredDocs); + //this._results.push(...filteredDocs); filteredDocs.forEach(doc => { const index = this._resultsSet.get(doc); const highlight = highlights[doc[Id]]; @@ -200,9 +371,8 @@ export class SearchBox extends React.Component { collectionRef = React.createRef<HTMLSpanElement>(); startDragCollection = async () => { - const res = await this.getAllResults(FilterBox.Instance.getFinalQuery(this._searchString)); - const filtered = FilterBox.Instance.filterDocsByType(res.docs); - // console.log(this._results) + const res = await this.getAllResults(this.getFinalQuery(this._searchString)); + const filtered = this.filterDocsByType(res.docs); const docs = filtered.map(doc => { const isProto = Doc.GetT(doc, "isPrototype", "boolean", true); if (isProto) { @@ -234,22 +404,19 @@ export class SearchBox extends React.Component { y += 300; } } - return Docs.Create.TreeDocument(docs, { _width: 200, _height: 400, backgroundColor: "grey", title: `Search Docs: "${this._searchString}"` }); + return Docs.Create.QueryDocument({ _autoHeight: true, title: this._searchString, filterQuery: this.filterQuery, searchQuery: this._searchString }); } @action.bound openSearch(e: React.SyntheticEvent) { e.stopPropagation(); this._openNoResults = false; - FilterBox.Instance.closeFilter(); this._resultsOpen = true; this._searchbarOpen = true; - FilterBox.Instance._pointerTime = e.timeStamp; } @action.bound closeSearch = () => { - FilterBox.Instance.closeFilter(); this.closeResults(); this._searchbarOpen = false; } @@ -267,11 +434,11 @@ export class SearchBox extends React.Component { @action resultsScrolled = (e?: React.UIEvent<HTMLDivElement>) => { - if (!this.resultsRef.current) return; - const scrollY = e ? e.currentTarget.scrollTop : this.resultsRef.current ? this.resultsRef.current.scrollTop : 0; + if (!this._resultsRef.current) return; + const scrollY = e ? e.currentTarget.scrollTop : this._resultsRef.current ? this._resultsRef.current.scrollTop : 0; const itemHght = 53; const startIndex = Math.floor(Math.max(0, scrollY / itemHght)); - const endIndex = Math.ceil(Math.min(this._numTotalResults - 1, startIndex + (this.resultsRef.current.getBoundingClientRect().height / itemHght))); + const endIndex = Math.ceil(Math.min(this._numTotalResults - 1, startIndex + (this._resultsRef.current.getBoundingClientRect().height / itemHght))); this._endIndex = endIndex === -1 ? 12 : endIndex; @@ -337,9 +504,131 @@ export class SearchBox extends React.Component { @computed get resultHeight() { return this._numTotalResults * 70; } + //if true, any keywords can be used. if false, all keywords are required. + @action.bound + handleWordQueryChange = () => { + this._basicWordStatus = !this._basicWordStatus; + } + + @action.bound + handleNodeChange = () => { + this._nodeStatus = !this._nodeStatus; + if (this._nodeStatus) { + this.expandSection(`node${this.props.id}`); + } + else { + this.collapseSection(`node${this.props.id}`); + } + } + + @action.bound + handleKeyChange = () => { + this._keyStatus = !this._keyStatus; + if (this._keyStatus) { + this.expandSection(`key${this.props.id}`); + } + else { + this.collapseSection(`key${this.props.id}`); + } + } + + @action.bound + handleFilterChange = () => { + this._filterOpen = !this._filterOpen; + if (this._filterOpen) { + this.expandSection(`filterhead${this.props.id}`); + document.getElementById(`filterhead${this.props.id}`)!.style.padding = "5"; + } + else { + this.collapseSection(`filterhead${this.props.id}`); + + + } + } + + @computed + get menuHeight() { + return document.getElementById("hi")?.clientHeight; + } + + + collapseSection(thing: string) { + const id = this.props.id; + const element = document.getElementById(thing)!; + // get the height of the element's inner content, regardless of its actual size + const sectionHeight = element.scrollHeight; + + // temporarily disable all css transitions + const elementTransition = element.style.transition; + element.style.transition = ''; + + // on the next frame (as soon as the previous style change has taken effect), + // explicitly set the element's height to its current pixel height, so we + // aren't transitioning out of 'auto' + requestAnimationFrame(function () { + element.style.height = sectionHeight + 'px'; + element.style.transition = elementTransition; + + // on the next frame (as soon as the previous style change has taken effect), + // have the element transition to height: 0 + requestAnimationFrame(function () { + element.style.height = 0 + 'px'; + thing === `filterhead${id}` ? document.getElementById(`filterhead${id}`)!.style.padding = "0" : null; + }); + }); + + // mark the section as "currently collapsed" + element.setAttribute('data-collapsed', 'true'); + } + + expandSection(thing: string) { + console.log("expand"); + const element = document.getElementById(thing)!; + // get the height of the element's inner content, regardless of its actual size + const sectionHeight = element.scrollHeight; + + // have the element transition to the height of its inner content + element.style.height = sectionHeight + 'px'; + + // when the next css transition finishes (which should be the one we just triggered) + element.addEventListener('transitionend', function handler(e) { + // remove this event listener so it only gets triggered once + console.log("autoset"); + element.removeEventListener('transitionend', handler); + + // remove "height" from the element's inline styles, so it can return to its initial value + element.style.height = "auto"; + //element.style.height = undefined; + }); + + // mark the section as "currently not collapsed" + element.setAttribute('data-collapsed', 'false'); + + } + + autoset(thing: string) { + const element = document.getElementById(thing)!; + console.log("autoset"); + element.removeEventListener('transitionend', function (e) { }); + + // remove "height" from the element's inline styles, so it can return to its initial value + element.style.height = "auto"; + //element.style.height = undefined; + } + + @action.bound + updateTitleStatus() { this._titleFieldStatus = !this._titleFieldStatus; } + + @action.bound + updateAuthorStatus() { this._authorFieldStatus = !this._authorFieldStatus; } + + @action.bound + updateDataStatus() { this._deletedDocsStatus = !this._deletedDocsStatus; } + render() { + return ( - <div className="searchBox-container" onPointerDown={e => { e.stopPropagation(); e.preventDefault(); }}> + <div className="searchBox-container"> <div className="searchBox-bar"> <span className="searchBox-barChild searchBox-collection" onPointerDown={SetupDrag(this.collectionRef, () => this._searchString ? this.startDragCollection() : undefined)} ref={this.collectionRef} title="Drag Results as Collection"> <FontAwesomeIcon icon="object-group" size="lg" /> @@ -347,16 +636,33 @@ export class SearchBox extends React.Component { <input value={this._searchString} onChange={this.onChange} type="text" placeholder="Search..." id="search-input" ref={this.inputRef} className="searchBox-barChild searchBox-input" onPointerDown={this.openSearch} onKeyPress={this.enter} onFocus={this.openSearch} style={{ width: this._searchbarOpen ? "500px" : "100px" }} /> - <button className="searchBox-barChild searchBox-filter" title="Advanced Filtering Options" onClick={FilterBox.Instance.openFilter} onPointerDown={FilterBox.Instance.stopProp}><FontAwesomeIcon icon="ellipsis-v" color="white" /></button> + <button className="searchBox-barChild searchBox-filter" title="Advanced Filtering Options" onClick={() => this.handleFilterChange()}><FontAwesomeIcon icon="ellipsis-v" color="white" /></button> </div> - <div className="searchBox-quickFilter" onPointerDown={this.openSearch}> - <div className="filter-panel"><IconBar /></div> + + <div id={`filterhead${this.props.id}`} className="filter-form" > + <div id={`filterhead2${this.props.id}`} className="filter-header" style={this._filterOpen ? {} : {}}> + <button className="filter-item" style={this._basicWordStatus ? { background: "#aaaaa3", } : {}} onClick={this.handleWordQueryChange}>Keywords</button> + <button className="filter-item" style={this._keyStatus ? { background: "#aaaaa3" } : {}} onClick={this.handleKeyChange}>Keys</button> + <button className="filter-item" style={this._nodeStatus ? { background: "#aaaaa3" } : {}} onClick={this.handleNodeChange}>Nodes</button> + </div> + <div id={`node${this.props.id}`} className="filter-body" style={this._nodeStatus ? { borderTop: "grey 1px solid" } : { borderTop: "0px" }}> + <IconBar setIcons={(icons: string[]) => { + this._icons = icons; + }} /> + </div> + <div className="filter-key" id={`key${this.props.id}`} style={this._keyStatus ? { borderTop: "grey 1px solid" } : { borderTop: "0px" }}> + <div className="filter-keybar"> + <button className="filter-item" style={this._titleFieldStatus ? { background: "#aaaaa3", } : {}} onClick={this.updateTitleStatus}>Title</button> + <button className="filter-item" style={this._deletedDocsStatus ? { background: "#aaaaa3", } : {}} onClick={this.updateDataStatus}>Deleted Docs</button> + <button className="filter-item" style={this._authorFieldStatus ? { background: "#aaaaa3", } : {}} onClick={this.updateAuthorStatus}>Author</button> + </div> + </div> </div> <div className="searchBox-results" onScroll={this.resultsScrolled} style={{ display: this._resultsOpen ? "flex" : "none", height: this.resFull ? "auto" : this.resultHeight, overflow: "visibile" // this.resFull ? "auto" : "visible" - }} ref={this.resultsRef}> + }} ref={this._resultsRef}> {this._visibleElements} </div> </div> diff --git a/src/client/views/search/SearchItem.tsx b/src/client/views/search/SearchItem.tsx index 8aea737f0..fe2000700 100644 --- a/src/client/views/search/SearchItem.tsx +++ b/src/client/views/search/SearchItem.tsx @@ -4,24 +4,24 @@ import { faCaretUp, faChartBar, faFile, faFilePdf, faFilm, faFingerprint, faGlob import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { action, computed, observable, runInAction } from "mobx"; import { observer } from "mobx-react"; -import { Doc, DocListCast } from "../../../new_fields/Doc"; +import { Doc } from "../../../new_fields/Doc"; import { Id } from "../../../new_fields/FieldSymbols"; import { Cast, NumCast, StrCast } from "../../../new_fields/Types"; -import { emptyFunction, returnEmptyString, returnFalse, returnOne, Utils, emptyPath } from "../../../Utils"; +import { emptyFunction, emptyPath, returnFalse, Utils, returnTrue } from "../../../Utils"; import { DocumentType } from "../../documents/DocumentTypes"; import { DocumentManager } from "../../util/DocumentManager"; import { DragManager, SetupDrag } from "../../util/DragManager"; import { SearchUtil } from "../../util/SearchUtil"; import { Transform } from "../../util/Transform"; import { SEARCH_THUMBNAIL_SIZE } from "../../views/globalCssVariables.scss"; -import { CollectionViewType } from "../collections/CollectionView"; import { CollectionDockingView } from "../collections/CollectionDockingView"; +import { CollectionViewType } from "../collections/CollectionView"; +import { ParentDocSelector } from "../collections/ParentDocumentSelector"; import { ContextMenu } from "../ContextMenu"; +import { ContentFittingDocumentView } from "../nodes/ContentFittingDocumentView"; import { SearchBox } from "./SearchBox"; import "./SearchItem.scss"; import "./SelectorContextMenu.scss"; -import { ContentFittingDocumentView } from "../nodes/ContentFittingDocumentView"; -import { ButtonSelector, ParentDocSelector } from "../collections/ParentDocumentSelector"; export interface SearchItemProps { doc: Doc; @@ -68,13 +68,13 @@ export class SelectorContextMenu extends React.Component<SearchItemProps> { getOnClick({ col, target }: { col: Doc, target: Doc }) { return () => { col = Doc.IsPrototype(col) ? Doc.MakeDelegate(col) : col; - if (NumCast(col._viewType, CollectionViewType.Invalid) === CollectionViewType.Freeform) { + if (col._viewType === CollectionViewType.Freeform) { const newPanX = NumCast(target.x) + NumCast(target._width) / 2; const newPanY = NumCast(target.y) + NumCast(target._height) / 2; col._panX = newPanX; col._panY = newPanY; } - CollectionDockingView.AddRightSplit(col, undefined); + CollectionDockingView.AddRightSplit(col); }; } render() { @@ -108,7 +108,7 @@ export class LinkContextMenu extends React.Component<LinkMenuProps> { unHighlightDoc = (doc: Doc) => () => Doc.UnBrushDoc(doc); - getOnClick = (col: Doc) => () => CollectionDockingView.AddRightSplit(col, undefined); + getOnClick = (col: Doc) => () => CollectionDockingView.AddRightSplit(col); render() { return ( @@ -158,6 +158,7 @@ export class SearchItem extends React.Component<SearchItemProps> { <ContentFittingDocumentView Document={this.props.doc} LibraryPath={emptyPath} + rootSelected={returnFalse} fitToBox={StrCast(this.props.doc.type).indexOf(DocumentType.COL) !== -1} addDocument={returnFalse} removeDocument={returnFalse} @@ -177,14 +178,13 @@ export class SearchItem extends React.Component<SearchItemProps> { } const button = layoutresult.indexOf(DocumentType.PDF) !== -1 ? faFilePdf : layoutresult.indexOf(DocumentType.IMG) !== -1 ? faImage : - layoutresult.indexOf(DocumentType.TEXT) !== -1 ? faStickyNote : + layoutresult.indexOf(DocumentType.RTF) !== -1 ? faStickyNote : layoutresult.indexOf(DocumentType.VID) !== -1 ? faFilm : layoutresult.indexOf(DocumentType.COL) !== -1 ? faObjectGroup : layoutresult.indexOf(DocumentType.AUDIO) !== -1 ? faMusic : layoutresult.indexOf(DocumentType.LINK) !== -1 ? faLink : - layoutresult.indexOf(DocumentType.HIST) !== -1 ? faChartBar : - layoutresult.indexOf(DocumentType.WEB) !== -1 ? faGlobeAsia : - faCaretUp; + layoutresult.indexOf(DocumentType.WEB) !== -1 ? faGlobeAsia : + faCaretUp; return <div onClick={action(() => { this._useIcons = false; this._displayDim = Number(SEARCH_THUMBNAIL_SIZE); })} > <FontAwesomeIcon icon={button} size="2x" /> </div>; @@ -272,7 +272,7 @@ export class SearchItem extends React.Component<SearchItemProps> { @computed get contextButton() { - return <ParentDocSelector Views={DocumentManager.Instance.DocumentViews} Document={this.props.doc} addDocTab={(doc, data, where) => CollectionDockingView.AddRightSplit(doc, data)} />; + return <ParentDocSelector Document={this.props.doc} addDocTab={(doc, where) => CollectionDockingView.AddRightSplit(doc)} />; } render() { diff --git a/src/client/views/webcam/DashWebRTCVideo.scss b/src/client/views/webcam/DashWebRTCVideo.scss new file mode 100644 index 000000000..41307a808 --- /dev/null +++ b/src/client/views/webcam/DashWebRTCVideo.scss @@ -0,0 +1,83 @@ +@import "../globalCssVariables"; + +.webcam-cont { + background: whitesmoke; + color: grey; + border-radius: 15px; + box-shadow: #9c9396 0.2vw 0.2vw 0.4vw; + border: solid #BBBBBBBB 5px; + pointer-events: all; + display: flex; + flex-direction: column; + overflow: hidden; + + .webcam-header { + height: 50px; + text-align: center; + text-transform: uppercase; + letter-spacing: 2px; + font-size: 16px; + width: 100%; + margin-top: 20px; + } + + .videoContainer { + position: relative; + width: calc(100% - 20px); + height: 100%; + /* border: 10px solid red; */ + margin-left: 10px; + } + + .buttonContainer { + display: flex; + width: calc(100% - 20px); + height: 50px; + justify-content: center; + text-align: center; + /* border: 1px solid black; */ + margin-left: 10px; + margin-top: 0; + margin-bottom: 15px; + } + + #roomName { + outline: none; + border-radius: inherit; + border: 1px solid #BBBBBBBB; + margin: 10px; + padding: 10px; + } + + .side { + width: 25%; + height: 20%; + position: absolute; + /* top: 65%; */ + z-index: 2; + right: 0px; + bottom: 18px; + } + + .main { + position: absolute; + width: 100%; + height: 100%; + /* top: 20%; */ + align-self: center; + } + + .videoButtons { + border-radius: 50%; + height: 30px; + width: 30px; + display: flex; + justify-content: center; + align-items: center; + justify-self: center; + align-self: center; + margin: 5px; + border: 1px solid black; + } + +}
\ No newline at end of file diff --git a/src/client/views/webcam/DashWebRTCVideo.tsx b/src/client/views/webcam/DashWebRTCVideo.tsx new file mode 100644 index 000000000..2ea011316 --- /dev/null +++ b/src/client/views/webcam/DashWebRTCVideo.tsx @@ -0,0 +1,89 @@ +import { observer } from "mobx-react"; +import React = require("react"); +import { CollectionFreeFormDocumentViewProps } from "../nodes/CollectionFreeFormDocumentView"; +import { FieldViewProps, FieldView } from "../nodes/FieldView"; +import { observable, action } from "mobx"; +import { DocumentDecorations } from "../DocumentDecorations"; +import { InkingControl } from "../InkingControl"; +import "../../views/nodes/WebBox.scss"; +import "./DashWebRTCVideo.scss"; +import adapter from 'webrtc-adapter'; +import { initialize, hangup, refreshVideos } from "./WebCamLogic"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { IconProp, library } from '@fortawesome/fontawesome-svg-core'; +import { faSync, faPhoneSlash } from "@fortawesome/free-solid-svg-icons"; + +library.add(faSync); +library.add(faPhoneSlash); + + +/** + * This models the component that will be rendered, that can be used as a doc that will reflect the video cams. + */ +@observer +export class DashWebRTCVideo extends React.Component<CollectionFreeFormDocumentViewProps & FieldViewProps> { + + private roomText: HTMLInputElement | undefined; + @observable remoteVideoAdded: boolean = false; + + @action + changeUILook = () => { + this.remoteVideoAdded = true; + } + + /** + * Function that submits the title entered by user on enter press. + */ + private onEnterKeyDown = (e: React.KeyboardEvent) => { + if (e.keyCode === 13) { + const submittedTitle = this.roomText!.value; + this.roomText!.value = ""; + this.roomText!.blur(); + initialize(submittedTitle, this.changeUILook); + } + } + + + public static LayoutString(fieldKey: string) { return FieldView.LayoutString(DashWebRTCVideo, fieldKey); } + + @action + onClickRefresh = () => { + refreshVideos(); + } + + onClickHangUp = () => { + hangup(); + } + + render() { + const content = + <div className="webcam-cont" style={{ width: "100%", height: "100%" }}> + <div className="webcam-header">DashWebRTC</div> + <input id="roomName" type="text" placeholder="Enter room name" ref={(e) => this.roomText = e!} onKeyDown={this.onEnterKeyDown} /> + <div className="videoContainer"> + <video id="localVideo" className={"RTCVideo" + (this.remoteVideoAdded ? " side" : " main")} autoPlay playsInline muted ref={(e) => { + }}></video> + <video id="remoteVideo" className="RTCVideo main" autoPlay playsInline ref={(e) => { + }}></video> + </div> + <div className="buttonContainer"> + <div className="videoButtons" style={{ background: "red" }} onClick={this.onClickHangUp}><FontAwesomeIcon icon={faPhoneSlash} color="white" /></div> + <div className="videoButtons" style={{ background: "green" }} onClick={this.onClickRefresh}><FontAwesomeIcon icon={faSync} color="white" /></div> + </div> + </div >; + + const frozen = !this.props.isSelected() || DocumentDecorations.Instance.Interacting; + const classname = "webBox-cont" + (this.props.isSelected() && !InkingControl.Instance.selectedTool && !DocumentDecorations.Instance.Interacting ? "-interactive" : ""); + + + return ( + <> + <div className={classname} > + {content} + </div> + {!frozen ? (null) : <div className="webBox-overlay" />} + </>); + } + + +}
\ No newline at end of file diff --git a/src/client/views/webcam/WebCamLogic.js b/src/client/views/webcam/WebCamLogic.js new file mode 100644 index 000000000..f542fb983 --- /dev/null +++ b/src/client/views/webcam/WebCamLogic.js @@ -0,0 +1,292 @@ +'use strict'; +import io from "socket.io-client"; + +var socket; +var isChannelReady = false; +var isInitiator = false; +var isStarted = false; +var localStream; +var pc; +var remoteStream; +var turnReady; +var room; + +export function initialize(roomName, handlerUI) { + + var pcConfig = { + 'iceServers': [{ + 'urls': 'stun:stun.l.google.com:19302' + }] + }; + + // Set up audio and video regardless of what devices are present. + var sdpConstraints = { + offerToReceiveAudio: true, + offerToReceiveVideo: true + }; + + ///////////////////////////////////////////// + + room = roomName; + + socket = io.connect(`${window.location.protocol}//${window.location.hostname}:${4321}`); + + if (room !== '') { + socket.emit('create or join', room); + console.log('Attempted to create or join room', room); + } + + socket.on('created', function (room) { + console.log('Created room ' + room); + isInitiator = true; + }); + + socket.on('full', function (room) { + console.log('Room ' + room + ' is full'); + }); + + socket.on('join', function (room) { + console.log('Another peer made a request to join room ' + room); + console.log('This peer is the initiator of room ' + room + '!'); + isChannelReady = true; + }); + + socket.on('joined', function (room) { + console.log('joined: ' + room); + isChannelReady = true; + }); + + socket.on('log', function (array) { + console.log.apply(console, array); + }); + + //////////////////////////////////////////////// + + + // This client receives a message + socket.on('message', function (message) { + console.log('Client received message:', message); + if (message === 'got user media') { + maybeStart(); + } else if (message.type === 'offer') { + if (!isInitiator && !isStarted) { + maybeStart(); + } + pc.setRemoteDescription(new RTCSessionDescription(message)); + doAnswer(); + } else if (message.type === 'answer' && isStarted) { + pc.setRemoteDescription(new RTCSessionDescription(message)); + } else if (message.type === 'candidate' && isStarted) { + var candidate = new RTCIceCandidate({ + sdpMLineIndex: message.label, + candidate: message.candidate + }); + pc.addIceCandidate(candidate); + } else if (message === 'bye' && isStarted) { + handleRemoteHangup(); + } + }); + + //////////////////////////////////////////////////// + + var localVideo = document.querySelector('#localVideo'); + var remoteVideo = document.querySelector('#remoteVideo'); + + const gotStream = (stream) => { + console.log('Adding local stream.'); + localStream = stream; + localVideo.srcObject = stream; + sendMessage('got user media'); + if (isInitiator) { + maybeStart(); + } + } + + + navigator.mediaDevices.getUserMedia({ + audio: true, + video: true + }) + .then(gotStream) + .catch(function (e) { + alert('getUserMedia() error: ' + e.name); + }); + + + + var constraints = { + video: true + }; + + console.log('Getting user media with constraints', constraints); + + const requestTurn = (turnURL) => { + var turnExists = false; + for (var i in pcConfig.iceServers) { + if (pcConfig.iceServers[i].urls.substr(0, 5) === 'turn:') { + turnExists = true; + turnReady = true; + break; + } + } + if (!turnExists) { + console.log('Getting TURN server from ', turnURL); + // No TURN server. Get one from computeengineondemand.appspot.com: + var xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function () { + if (xhr.readyState === 4 && xhr.status === 200) { + var turnServer = JSON.parse(xhr.responseText); + console.log('Got TURN server: ', turnServer); + pcConfig.iceServers.push({ + 'urls': 'turn:' + turnServer.username + '@' + turnServer.turn, + 'credential': turnServer.password + }); + turnReady = true; + } + }; + xhr.open('GET', turnURL, true); + xhr.send(); + } + } + + + + + if (location.hostname !== 'localhost') { + requestTurn( + `${window.location.origin}/corsProxy/${encodeURIComponent("https://computeengineondemand.appspot.com/turn?username=41784574&key=4080218913")}` + ); + } + + const maybeStart = () => { + console.log('>>>>>>> maybeStart() ', isStarted, localStream, isChannelReady); + if (!isStarted && typeof localStream !== 'undefined' && isChannelReady) { + console.log('>>>>>> creating peer connection'); + createPeerConnection(); + pc.addStream(localStream); + isStarted = true; + console.log('isInitiator', isInitiator); + if (isInitiator) { + doCall(); + } + } + }; + + window.onbeforeunload = function () { + sendMessage('bye'); + }; + + ///////////////////////////////////////////////////////// + + const createPeerConnection = () => { + try { + pc = new RTCPeerConnection(null); + pc.onicecandidate = handleIceCandidate; + pc.onaddstream = handleRemoteStreamAdded; + pc.onremovestream = handleRemoteStreamRemoved; + console.log('Created RTCPeerConnnection'); + } catch (e) { + console.log('Failed to create PeerConnection, exception: ' + e.message); + alert('Cannot create RTCPeerConnection object.'); + return; + } + } + + const handleIceCandidate = (event) => { + console.log('icecandidate event: ', event); + if (event.candidate) { + sendMessage({ + type: 'candidate', + label: event.candidate.sdpMLineIndex, + id: event.candidate.sdpMid, + candidate: event.candidate.candidate + }); + } else { + console.log('End of candidates.'); + } + } + + const handleCreateOfferError = (event) => { + console.log('createOffer() error: ', event); + } + + const doCall = () => { + console.log('Sending offer to peer'); + pc.createOffer(setLocalAndSendMessage, handleCreateOfferError); + } + + const doAnswer = () => { + console.log('Sending answer to peer.'); + pc.createAnswer().then( + setLocalAndSendMessage, + onCreateSessionDescriptionError + ); + } + + const setLocalAndSendMessage = (sessionDescription) => { + pc.setLocalDescription(sessionDescription); + console.log('setLocalAndSendMessage sending message', sessionDescription); + sendMessage(sessionDescription); + } + + const onCreateSessionDescriptionError = (error) => { + trace('Failed to create session description: ' + error.toString()); + } + + + + const handleRemoteStreamAdded = (event) => { + console.log('Remote stream added.'); + remoteStream = event.stream; + remoteVideo.srcObject = remoteStream; + handlerUI(); + + }; + + const handleRemoteStreamRemoved = (event) => { + console.log('Remote stream removed. Event: ', event); + } +} + +export function hangup() { + console.log('Hanging up.'); + stop(); + sendMessage('bye'); + if (localStream) { + localStream.getTracks().forEach(track => track.stop()); + } +} + +function stop() { + isStarted = false; + if (pc) { + pc.close(); + } + pc = null; +} + +function handleRemoteHangup() { + console.log('Session terminated.'); + stop(); + isInitiator = false; + if (localStream) { + localStream.getTracks().forEach(track => track.stop()); + } +} + +function sendMessage(message) { + console.log('Client sending message: ', message); + socket.emit('message', message, room); +}; + +export function refreshVideos() { + var localVideo = document.querySelector('#localVideo'); + var remoteVideo = document.querySelector('#remoteVideo'); + if (localVideo) { + localVideo.srcObject = localStream; + } + if (remoteVideo) { + remoteVideo.srcObject = remoteStream; + } + +}
\ No newline at end of file |
