diff options
Diffstat (limited to 'src/client/views/linking')
-rw-r--r-- | src/client/views/linking/LinkEditor.scss | 145 | ||||
-rw-r--r-- | src/client/views/linking/LinkEditor.tsx | 400 | ||||
-rw-r--r-- | src/client/views/linking/LinkFollowBox.scss | 93 | ||||
-rw-r--r-- | src/client/views/linking/LinkFollowBox.tsx | 605 | ||||
-rw-r--r-- | src/client/views/linking/LinkMenu.scss | 137 | ||||
-rw-r--r-- | src/client/views/linking/LinkMenu.tsx | 76 | ||||
-rw-r--r-- | src/client/views/linking/LinkMenuGroup.tsx | 111 | ||||
-rw-r--r-- | src/client/views/linking/LinkMenuItem.tsx | 139 |
8 files changed, 1706 insertions, 0 deletions
diff --git a/src/client/views/linking/LinkEditor.scss b/src/client/views/linking/LinkEditor.scss new file mode 100644 index 000000000..fc5f2410c --- /dev/null +++ b/src/client/views/linking/LinkEditor.scss @@ -0,0 +1,145 @@ +@import "../globalCssVariables"; + +.linkEditor { + width: 100%; + height: auto; + font-size: 12px; // TODO +} + +.linkEditor-back { + margin-bottom: 6px; +} + +.linkEditor-info { + border-bottom: 0.5px solid $light-color-secondary; + padding-bottom: 6px; + margin-bottom: 6px; + display: flex; + justify-content: space-between; + + .linkEditor-linkedTo { + width: calc(100% - 26px); + } +} + +.linkEditor-button { + width: 20px; + height: 20px; + margin-left: 6px; + padding: 0; + // font-size: 12px; + border-radius: 10px; + + &:disabled { + background-color: gray; + } +} + +.linkEditor-groupsLabel { + display: flex; + justify-content: space-between; +} + +.linkEditor-group { + background-color: $light-color-secondary; + padding: 6px; + margin: 3px 0; + border-radius: 3px; + + .linkEditor-group-row { + display: flex; + margin-bottom: 3px; + + .linkEditor-group-row-label { + margin-right: 6px; + } + } + + .linkEditor-metadata-row { + display: flex; + justify-content: space-between; + margin-bottom: 6px; + + .linkEditor-error { + border-color: red; + } + + input { + width: calc(50% - 16px); + height: 20px; + } + + button { + width: 20px; + height: 20px; + margin-left: 3px; + padding: 0; + font-size: 10px; + } + } +} + + +.linkEditor-dropdown { + width: 100%; + position: relative; + z-index: 999; + + input { + width: 100%; + } + + .linkEditor-options-wrapper { + width: 100%; + position: absolute; + top: 19px; + left: 0; + display: flex; + flex-direction: column; + } + + .linkEditor-option { + background-color: $light-color-secondary; + border: 1px solid $intermediate-color; + border-top: 0; + padding: 3px; + cursor: pointer; + + &:hover { + background-color: lightgray; + } + + &.onDown { + background-color: gray; + } + } +} + +.linkEditor-typeButton { + background-color: transparent; + color: $dark-color; + width: 100%; + height: 20px; + padding: 0 3px; + padding-bottom: 2px; + text-align: left; + text-transform: none; + letter-spacing: normal; + font-size: 12px; + font-weight: bold; + + &:hover { + background-color: $light-color; + } +} + +.linkEditor-group-buttons { + height: 20px; + display: flex; + justify-content: flex-end; + margin-top: 5px; + + .linkEditor-button { + margin-left: 6px; + } +}
\ No newline at end of file diff --git a/src/client/views/linking/LinkEditor.tsx b/src/client/views/linking/LinkEditor.tsx new file mode 100644 index 000000000..ecb3e9db4 --- /dev/null +++ b/src/client/views/linking/LinkEditor.tsx @@ -0,0 +1,400 @@ +import { observable, computed, action, trace } from "mobx"; +import React = require("react"); +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 { 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"; + +library.add(faArrowLeft, faEllipsisV, faTable, faTrash, faCog, faExchangeAlt, faTimes, faPlus); + + +interface GroupTypesDropdownProps { + groupType: string; + setGroupType: (group: string) => void; +} +// this dropdown could be generalized +@observer +class GroupTypesDropdown extends React.Component<GroupTypesDropdownProps> { + @observable private _searchTerm: string = this.props.groupType; + @observable private _groupType: string = this.props.groupType; + @observable private _isEditing: boolean = false; + + @action + createGroup = (groupType: string): void => { + this.props.setGroupType(groupType); + LinkManager.Instance.addGroupType(groupType); + } + + @action + onChange = (val: string): void => { + this._searchTerm = val; + this._groupType = val; + this._isEditing = true; + } + + @action + onKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === "Enter") { + let allGroupTypes = Array.from(LinkManager.Instance.getAllGroupTypes()); + let groupOptions = allGroupTypes.filter(groupType => groupType.toUpperCase().indexOf(this._searchTerm.toUpperCase()) > -1); + let exactFound = groupOptions.findIndex(groupType => groupType.toUpperCase() === this._searchTerm.toUpperCase()); + + if (exactFound > -1) { + let groupType = groupOptions[exactFound]; + this.props.setGroupType(groupType); + this._groupType = groupType; + } else { + this.createGroup(this._searchTerm); + this._groupType = this._searchTerm; + } + + this._searchTerm = this._groupType; + this._isEditing = false; + } + } + + @action + onOptionClick = (value: string, createNew: boolean): void => { + if (createNew) { + this.createGroup(this._searchTerm); + this._groupType = this._searchTerm; + + } else { + this.props.setGroupType(value); + this._groupType = value; + + } + this._searchTerm = this._groupType; + this._isEditing = false; + } + + @action + onButtonPointerDown = (): void => { + this._isEditing = true; + } + + renderOptions = (): JSX.Element[] | JSX.Element => { + if (this._searchTerm === "") return <></>; + + let allGroupTypes = Array.from(LinkManager.Instance.getAllGroupTypes()); + let groupOptions = allGroupTypes.filter(groupType => groupType.toUpperCase().indexOf(this._searchTerm.toUpperCase()) > -1); + let exactFound = groupOptions.findIndex(groupType => groupType.toUpperCase() === this._searchTerm.toUpperCase()) > -1; + + let options = groupOptions.map(groupType => { + let ref = React.createRef<HTMLDivElement>(); + return <div key={groupType} ref={ref} className="linkEditor-option" + onClick={() => this.onOptionClick(groupType, false)}>{groupType}</div>; + }); + + // if search term does not already exist as a group type, give option to create new group type + if (!exactFound && this._searchTerm !== "") { + let ref = React.createRef<HTMLDivElement>(); + options.push(<div key={""} ref={ref} className="linkEditor-option" + onClick={() => this.onOptionClick(this._searchTerm, true)}>Define new "{this._searchTerm}" relationship</div>); + } + + return options; + } + + render() { + if (this._isEditing || this._groupType === "") { + return ( + <div className="linkEditor-dropdown"> + <input type="text" value={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()} + </div> + </div > + ); + } else { + return <button className="linkEditor-typeButton" onClick={() => this.onButtonPointerDown()}>{this._groupType}</button>; + } + } +} + + +interface LinkMetadataEditorProps { + id: string; + groupType: string; + mdDoc: Doc; + mdKey: string; + mdValue: string; + changeMdIdKey: (id: string, newKey: string) => void; +} +@observer +class LinkMetadataEditor extends React.Component<LinkMetadataEditorProps> { + @observable private _key: string = this.props.mdKey; + @observable private _value: string = this.props.mdValue; + @observable private _keyError: boolean = false; + + @action + setMetadataKey = (value: string): void => { + let groupMdKeys = LinkManager.Instance.getMetadataKeysInGroup(this.props.groupType); + + // don't allow user to create existing key + let newIndex = groupMdKeys.findIndex(key => key.toUpperCase() === value.toUpperCase()); + if (newIndex > -1) { + this._keyError = true; + this._key = value; + return; + } else { + this._keyError = false; + } + + // set new value for key + let currIndex = groupMdKeys.findIndex(key => { + return StrCast(key).toUpperCase() === this._key.toUpperCase(); + }); + if (currIndex === -1) console.error("LinkMetadataEditor: key was not found"); + groupMdKeys[currIndex] = value; + + this.props.changeMdIdKey(this.props.id, value); + this._key = value; + LinkManager.Instance.setMetadataKeysForGroup(this.props.groupType, [...groupMdKeys]); + } + + @action + setMetadataValue = (value: string): void => { + if (!this._keyError) { + this._value = value; + this.props.mdDoc[this._key] = value; + } + } + + @action + removeMetadata = (): void => { + let groupMdKeys = LinkManager.Instance.getMetadataKeysInGroup(this.props.groupType); + + let index = groupMdKeys.findIndex(key => key.toUpperCase() === this._key.toUpperCase()); + if (index === -1) console.error("LinkMetadataEditor: key was not found"); + groupMdKeys.splice(index, 1); + + LinkManager.Instance.setMetadataKeysForGroup(this.props.groupType, groupMdKeys); + this._key = ""; + } + + render() { + return ( + <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> + </div> + ); + } +} + +interface LinkGroupEditorProps { + sourceDoc: Doc; + linkDoc: Doc; + groupDoc: Doc; +} +@observer +export class LinkGroupEditor extends React.Component<LinkGroupEditorProps> { + + private _metadataIds: Map<string, string> = new Map(); + + constructor(props: LinkGroupEditorProps) { + super(props); + + let groupMdKeys = LinkManager.Instance.getMetadataKeysInGroup(StrCast(props.groupDoc.type)); + groupMdKeys.forEach(key => { + this._metadataIds.set(key, Utils.GenerateGuid()); + }); + } + + @action + setGroupType = (groupType: string): void => { + this.props.groupDoc.type = groupType; + } + + removeGroupFromLink = (groupType: string): void => { + LinkManager.Instance.removeGroupFromAnchor(this.props.linkDoc, this.props.sourceDoc, groupType); + } + + deleteGroup = (groupType: string): void => { + LinkManager.Instance.deleteGroupType(groupType); + } + + copyGroup = async (groupType: string): Promise<void> => { + let sourceGroupDoc = this.props.groupDoc; + const sourceMdDoc = await Cast(sourceGroupDoc.metadata, Doc); + if (!sourceMdDoc) return; + + let destDoc = LinkManager.Instance.getOppositeAnchor(this.props.linkDoc, this.props.sourceDoc); + // let destGroupList = LinkManager.Instance.getAnchorGroups(this.props.linkDoc, destDoc); + let keys = LinkManager.Instance.getMetadataKeysInGroup(groupType); + + // create new metadata doc with copied kvp + let destMdDoc = new Doc(); + destMdDoc.anchor1 = StrCast(sourceMdDoc.anchor2); + destMdDoc.anchor2 = StrCast(sourceMdDoc.anchor1); + keys.forEach(key => { + let val = sourceMdDoc[key] === undefined ? "" : StrCast(sourceMdDoc[key]); + destMdDoc[key] = val; + }); + + // create new group doc with new metadata doc + let 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 => { + this._metadataIds.set("new key", Utils.GenerateGuid()); + let mdKeys = LinkManager.Instance.getMetadataKeysInGroup(groupType); + // only add "new key" if there is no other key with value "new key"; prevents spamming + if (mdKeys.indexOf("new key") === -1) mdKeys.push("new key"); + LinkManager.Instance.setMetadataKeysForGroup(groupType, mdKeys); + } + + // for key rendering purposes + changeMdIdKey = (id: string, newKey: string) => { + this._metadataIds.set(newKey, id); + } + + renderMetadata = (): JSX.Element[] => { + let metadata: Array<JSX.Element> = []; + let groupDoc = this.props.groupDoc; + const mdDoc = FieldValue(Cast(groupDoc.metadata, Doc)); + if (!mdDoc) { + return []; + } + let groupType = StrCast(groupDoc.type); + let groupMdKeys = LinkManager.Instance.getMetadataKeysInGroup(groupType); + + groupMdKeys.forEach((key) => { + let val = StrCast(mdDoc[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} /> + ); + }); + return metadata; + } + + viewGroupAsTable = (groupType: string): JSX.Element => { + let keys = LinkManager.Instance.getMetadataKeysInGroup(groupType); + let index = keys.indexOf(""); + if (index > -1) keys.splice(index, 1); + let cols = ["anchor1", "anchor2", ...[...keys]].map(c => new SchemaHeaderField(c, "#f1efeb")); + let docs: Doc[] = LinkManager.Instance.getAllMetadataDocsInGroup(groupType); + let createTable = action(() => Docs.Create.SchemaDocument(cols, docs, { width: 500, height: 300, title: groupType + " table" })); + let 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() { + let groupType = StrCast(this.props.groupDoc.type); + // 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)} + </> + ); + } + return ( + <div className="linkEditor-group"> + <div className="linkEditor-group-row "> + <p className="linkEditor-group-row-label">type:</p> + <GroupTypesDropdown groupType={groupType} setGroupType={this.setGroupType} /> + </div> + {this.renderMetadata().length > 0 ? <p className="linkEditor-group-row-label">metadata:</p> : <></>} + {this.renderMetadata()} + <div className="linkEditor-group-buttons"> + {buttons} + </div> + </div> + ); + } +} + + +interface LinkEditorProps { + sourceDoc: Doc; + linkDoc: Doc; + showLinks: () => void; +} +@observer +export class LinkEditor extends React.Component<LinkEditorProps> { + + @action + deleteLink = (): void => { + LinkManager.Instance.deleteLink(this.props.linkDoc); + this.props.showLinks(); + } + + @action + addGroup = (): void => { + // create new metadata document for group + let mdDoc = new Doc(); + mdDoc.anchor1 = this.props.sourceDoc.title; + let opp = LinkManager.Instance.getOppositeAnchor(this.props.linkDoc, this.props.sourceDoc); + if (opp) { + mdDoc.anchor2 = opp.title; + } + + // create new group document + let groupDoc = new Doc(); + groupDoc.type = ""; + groupDoc.metadata = mdDoc; + + LinkManager.Instance.addGroupToAnchor(this.props.linkDoc, this.props.sourceDoc, groupDoc); + } + + render() { + let destination = LinkManager.Instance.getOppositeAnchor(this.props.linkDoc, this.props.sourceDoc); + + let groupList = LinkManager.Instance.getAnchorGroups(this.props.linkDoc, this.props.sourceDoc); + let groups = groupList.map(groupDoc => { + return <LinkGroupEditor key={"gred-" + StrCast(groupDoc.type)} 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>} + </div> + + ); + } + } +}
\ No newline at end of file diff --git a/src/client/views/linking/LinkFollowBox.scss b/src/client/views/linking/LinkFollowBox.scss new file mode 100644 index 000000000..9eeed1cc8 --- /dev/null +++ b/src/client/views/linking/LinkFollowBox.scss @@ -0,0 +1,93 @@ +@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/LinkFollowBox.tsx b/src/client/views/linking/LinkFollowBox.tsx new file mode 100644 index 000000000..74663f9af --- /dev/null +++ b/src/client/views/linking/LinkFollowBox.tsx @@ -0,0 +1,605 @@ +import { observable, computed, action, runInAction, reaction, IReactionDisposer } from "mobx"; +import React = require("react"); +import { observer } from "mobx-react"; +import { FieldViewProps, FieldView } from "../nodes/FieldView"; +import { Doc, DocListCastAsync } from "../../../new_fields/Doc"; +import { undoBatch } from "../../util/UndoManager"; +import { NumCast, FieldValue, Cast, StrCast } from "../../../new_fields/Types"; +import { CollectionViewType } from "../collections/CollectionBaseView"; +import { CollectionDockingView } from "../collections/CollectionDockingView"; +import { SelectionManager } from "../../util/SelectionManager"; +import { DocumentManager } from "../../util/DocumentManager"; +import { DocumentView } from "../nodes/DocumentView"; +import "./LinkFollowBox.scss"; +import { SearchUtil } from "../../util/SearchUtil"; +import { Id } from "../../../new_fields/FieldSymbols"; +import { listSpec } from "../../../new_fields/Schema"; +import { DocServer } from "../../DocServer"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faTimes } from '@fortawesome/free-solid-svg-icons'; +import { CurrentUserUtils } from "../../../server/authentication/models/current_user_utils"; + +enum FollowModes { + OPENTAB = "Open in Tab", + OPENRIGHT = "Open in Right Split", + OPENFULL = "Open Full Screen", + PAN = "Pan to Document", + INPLACE = "Open In Place" +} + +enum FollowOptions { + ZOOM = "Zoom", + NOZOOM = "No Zoom", +} + +@observer +export class LinkFollowBox extends React.Component<FieldViewProps> { + + public static LayoutString() { return FieldView.LayoutString(LinkFollowBox); } + public static Instance: LinkFollowBox | undefined; + @observable static linkDoc: Doc | undefined = undefined; + @observable static destinationDoc: Doc | undefined = undefined; + @observable static sourceDoc: Doc | undefined = undefined; + @observable selectedMode: string = ""; + @observable selectedContext: Doc | undefined = undefined; + @observable selectedContextAliases: Doc[] | undefined = undefined; + @observable selectedOption: string = ""; + @observable selectedContextString: string = ""; + @observable sourceView: DocumentView | undefined = undefined; + @observable canPan: boolean = false; + @observable shouldUseOnlyParentContext = false; + _contextDisposer?: IReactionDisposer; + + @observable private _docs: { col: Doc, target: Doc }[] = []; + @observable private _otherDocs: { col: Doc, target: Doc }[] = []; + + constructor(props: FieldViewProps) { + super(props); + LinkFollowBox.Instance = this; + this.resetVars(); + this.props.Document.isBackground = true; + } + + componentDidMount = () => { + this.resetVars(); + + this._contextDisposer = reaction( + () => this.selectedContextString, + async () => { + let ref = await DocServer.GetRefField(this.selectedContextString); + runInAction(() => { + if (ref instanceof Doc) { + this.selectedContext = ref; + } + }); + if (this.selectedContext instanceof Doc) { + let aliases = await SearchUtil.GetViewsOfDocument(this.selectedContext); + runInAction(() => { this.selectedContextAliases = aliases; }); + } + } + ); + } + + componentWillUnmount = () => { + this._contextDisposer && this._contextDisposer(); + } + + async resetPan() { + if (LinkFollowBox.destinationDoc && this.sourceView && this.sourceView.props.ContainingCollectionView) { + let colDoc = this.sourceView.props.ContainingCollectionView.props.Document; + runInAction(() => { this.canPan = false; }); + if (colDoc.viewType && colDoc.viewType === CollectionViewType.Freeform) { + let docs = Cast(colDoc.data, listSpec(Doc), []); + let aliases = await SearchUtil.GetViewsOfDocument(Doc.GetProto(LinkFollowBox.destinationDoc)); + + aliases.forEach(alias => { + if (docs.filter(doc => doc === alias).length > 0) { + runInAction(() => { this.canPan = true; }); + } + }); + } + } + } + + @action + resetVars = () => { + this.selectedContext = undefined; + this.selectedContextString = ""; + this.selectedMode = ""; + this.selectedOption = ""; + LinkFollowBox.linkDoc = undefined; + LinkFollowBox.sourceDoc = undefined; + LinkFollowBox.destinationDoc = undefined; + this.sourceView = undefined; + this.canPan = false; + this.shouldUseOnlyParentContext = false; + } + + async fetchDocuments() { + if (LinkFollowBox.destinationDoc) { + let dest: Doc = LinkFollowBox.destinationDoc; + let aliases = await SearchUtil.GetViewsOfDocument(Doc.GetProto(dest)); + const { docs } = await SearchUtil.Search("", true, { fq: `data_l:"${dest[Id]}"` }); + const map: Map<Doc, Doc> = new Map; + const allDocs = await Promise.all(aliases.map(doc => SearchUtil.Search("", true, { fq: `data_l:"${doc[Id]}"` }).then(result => result.docs))); + allDocs.forEach((docs, index) => docs.forEach(doc => map.set(doc, aliases[index]))); + docs.forEach(doc => map.delete(doc)); + runInAction(async () => { + this._docs = docs.filter(doc => !Doc.AreProtosEqual(doc, CollectionDockingView.Instance.props.Document)).map(doc => ({ col: doc, target: dest })); + this._otherDocs = Array.from(map.entries()).filter(entry => !Doc.AreProtosEqual(entry[0], CollectionDockingView.Instance.props.Document)).map(([col, target]) => ({ col, target })); + let tcontext = LinkFollowBox.linkDoc && (await Cast(LinkFollowBox.linkDoc.targetContext, Doc)) as Doc; + runInAction(() => tcontext && this._docs.splice(0, 0, { col: tcontext, target: dest })); + }); + } + } + + @action + setLinkDocs = (linkDoc: Doc, source: Doc, dest: Doc) => { + this.resetVars(); + + LinkFollowBox.linkDoc = linkDoc; + LinkFollowBox.sourceDoc = source; + LinkFollowBox.destinationDoc = dest; + this.fetchDocuments(); + + SelectionManager.SelectedDocuments().forEach(dv => { + if (dv.props.Document === LinkFollowBox.sourceDoc) { + this.sourceView = dv; + } + }); + + this.resetPan(); + } + + unhighlight = () => { + Doc.UnhighlightAll(); + document.removeEventListener("pointerdown", this.unhighlight); + } + + @action + highlightDoc = () => { + if (LinkFollowBox.destinationDoc) { + document.removeEventListener("pointerdown", this.unhighlight); + Doc.HighlightDoc(LinkFollowBox.destinationDoc); + window.setTimeout(() => { + document.addEventListener("pointerdown", this.unhighlight); + }, 10000); + } + } + + @undoBatch + openFullScreen = () => { + if (LinkFollowBox.destinationDoc) { + let view: DocumentView | null = DocumentManager.Instance.getDocumentView(LinkFollowBox.destinationDoc); + view && CollectionDockingView.Instance && CollectionDockingView.Instance.OpenFullScreen(view); + SelectionManager.DeselectAll(); + } + } + + @undoBatch + openColFullScreen = (options: { context: Doc }) => { + if (LinkFollowBox.destinationDoc) { + if (NumCast(options.context.viewType, CollectionViewType.Invalid) === CollectionViewType.Freeform) { + const newPanX = NumCast(LinkFollowBox.destinationDoc.x) + NumCast(LinkFollowBox.destinationDoc.width) / NumCast(LinkFollowBox.destinationDoc.zoomBasis, 1) / 2; + const newPanY = NumCast(LinkFollowBox.destinationDoc.y) + NumCast(LinkFollowBox.destinationDoc.height) / NumCast(LinkFollowBox.destinationDoc.zoomBasis, 1) / 2; + options.context.panX = newPanX; + options.context.panY = newPanY; + } + let view = DocumentManager.Instance.getDocumentView(options.context); + view && CollectionDockingView.Instance && CollectionDockingView.Instance.OpenFullScreen(view); + this.highlightDoc(); + SelectionManager.DeselectAll(); + } + } + + // should container be a doc or documentview or what? This one needs work and is more long term + @undoBatch + openInContainer = (options: { container: Doc }) => { + + } + + @undoBatch + openLinkColRight = (options: { context: Doc, shouldZoom: boolean }) => { + if (LinkFollowBox.destinationDoc) { + options.context = Doc.IsPrototype(options.context) ? Doc.MakeDelegate(options.context) : options.context; + if (NumCast(options.context.viewType, CollectionViewType.Invalid) === CollectionViewType.Freeform) { + const newPanX = NumCast(LinkFollowBox.destinationDoc.x) + NumCast(LinkFollowBox.destinationDoc.width) / NumCast(LinkFollowBox.destinationDoc.zoomBasis, 1) / 2; + const newPanY = NumCast(LinkFollowBox.destinationDoc.y) + NumCast(LinkFollowBox.destinationDoc.height) / NumCast(LinkFollowBox.destinationDoc.zoomBasis, 1) / 2; + options.context.panX = newPanX; + options.context.panY = newPanY; + } + CollectionDockingView.Instance.AddRightSplit(options.context, undefined); + + if (options.shouldZoom) this.jumpToLink({ shouldZoom: options.shouldZoom }); + + this.highlightDoc(); + SelectionManager.DeselectAll(); + } + } + + @undoBatch + openLinkRight = () => { + if (LinkFollowBox.destinationDoc) { + let alias = Doc.MakeAlias(LinkFollowBox.destinationDoc); + CollectionDockingView.Instance.AddRightSplit(alias, undefined); + this.highlightDoc(); + SelectionManager.DeselectAll(); + } + + } + + @undoBatch + jumpToLink = async (options: { shouldZoom: boolean }) => { + if (LinkFollowBox.destinationDoc && LinkFollowBox.linkDoc) { + let jumpToDoc: Doc = LinkFollowBox.destinationDoc; + let pdfDoc = FieldValue(Cast(LinkFollowBox.destinationDoc, Doc)); + if (pdfDoc) { + jumpToDoc = pdfDoc; + } + let proto = Doc.GetProto(LinkFollowBox.linkDoc); + let targetContext = await Cast(proto.targetContext, Doc); + let sourceContext = await Cast(proto.sourceContext, Doc); + + let dockingFunc = (document: Doc) => { this.props.addDocTab(document, undefined, "inTab"); SelectionManager.DeselectAll(); }; + + if (LinkFollowBox.destinationDoc === LinkFollowBox.linkDoc.anchor2 && targetContext) { + DocumentManager.Instance.jumpToDocument(jumpToDoc, options.shouldZoom, false, async document => dockingFunc(document), undefined, targetContext); + } + else if (LinkFollowBox.destinationDoc === LinkFollowBox.linkDoc.anchor1 && sourceContext) { + DocumentManager.Instance.jumpToDocument(jumpToDoc, options.shouldZoom, false, document => dockingFunc(sourceContext!)); + } + else if (DocumentManager.Instance.getDocumentView(jumpToDoc)) { + DocumentManager.Instance.jumpToDocument(jumpToDoc, options.shouldZoom, undefined, undefined, + NumCast((LinkFollowBox.destinationDoc === LinkFollowBox.linkDoc.anchor2 ? LinkFollowBox.linkDoc.anchor2Page : LinkFollowBox.linkDoc.anchor1Page))); + + } + else { + DocumentManager.Instance.jumpToDocument(jumpToDoc, options.shouldZoom, false, dockingFunc); + } + + this.highlightDoc(); + SelectionManager.DeselectAll(); + } + } + + @undoBatch + openLinkTab = () => { + if (LinkFollowBox.destinationDoc) { + let fullScreenAlias = Doc.MakeAlias(LinkFollowBox.destinationDoc); + // THIS IS EMPTY FUNCTION + this.props.addDocTab(fullScreenAlias, undefined, "inTab"); + console.log(this.props.addDocTab); + + this.highlightDoc(); + SelectionManager.DeselectAll(); + } + } + + @undoBatch + openLinkColTab = (options: { context: Doc, shouldZoom: boolean }) => { + if (LinkFollowBox.destinationDoc) { + options.context = Doc.IsPrototype(options.context) ? Doc.MakeDelegate(options.context) : options.context; + if (NumCast(options.context.viewType, CollectionViewType.Invalid) === CollectionViewType.Freeform) { + const newPanX = NumCast(LinkFollowBox.destinationDoc.x) + NumCast(LinkFollowBox.destinationDoc.width) / NumCast(LinkFollowBox.destinationDoc.zoomBasis, 1) / 2; + const newPanY = NumCast(LinkFollowBox.destinationDoc.y) + NumCast(LinkFollowBox.destinationDoc.height) / NumCast(LinkFollowBox.destinationDoc.zoomBasis, 1) / 2; + options.context.panX = newPanX; + options.context.panY = newPanY; + } + this.props.addDocTab(options.context, undefined, "inTab"); + if (options.shouldZoom) this.jumpToLink({ shouldZoom: options.shouldZoom }); + + this.highlightDoc(); + SelectionManager.DeselectAll(); + } + } + + @undoBatch + openLinkInPlace = (options: { shouldZoom: boolean }) => { + + if (LinkFollowBox.destinationDoc && LinkFollowBox.sourceDoc) { + let alias = Doc.MakeAlias(LinkFollowBox.destinationDoc); + let y = NumCast(LinkFollowBox.sourceDoc.y); + let x = NumCast(LinkFollowBox.sourceDoc.x); + + let width = NumCast(LinkFollowBox.sourceDoc.width); + let height = NumCast(LinkFollowBox.sourceDoc.height); + + alias.x = x + width + 30; + alias.y = y; + alias.width = width; + alias.height = height; + + if (this.sourceView && this.sourceView.props.addDocument) { + this.sourceView.props.addDocument(alias, false); + } + + this.jumpToLink({ shouldZoom: options.shouldZoom }); + + this.highlightDoc(); + SelectionManager.DeselectAll(); + } + } + + //set this to be the default link behavior, can be any of the above + public defaultLinkBehavior: (options?: any) => void = this.openLinkTab; + + @action + currentLinkBehavior = () => { + // this.resetPan(); + if (LinkFollowBox.destinationDoc) { + if (this.selectedContextString === "") { + this.selectedContextString = "self"; + this.selectedContext = LinkFollowBox.destinationDoc; + } + if (this.selectedOption === "") this.selectedOption = FollowOptions.NOZOOM; + let shouldZoom: boolean = this.selectedOption === FollowOptions.NOZOOM ? false : true; + let notOpenInContext: boolean = this.selectedContextString === "self" || this.selectedContextString === LinkFollowBox.destinationDoc[Id]; + + if (this.selectedMode === FollowModes.INPLACE) { + if (shouldZoom !== undefined) this.openLinkInPlace({ shouldZoom: shouldZoom }); + } + else if (this.selectedMode === FollowModes.OPENFULL) { + if (notOpenInContext) this.openFullScreen(); + else this.selectedContext && this.openColFullScreen({ context: this.selectedContext }); + } + else if (this.selectedMode === FollowModes.OPENRIGHT) { + if (notOpenInContext) this.openLinkRight(); + else this.selectedContext && this.openLinkColRight({ context: this.selectedContext, shouldZoom: shouldZoom }); + } + else if (this.selectedMode === FollowModes.OPENTAB) { + if (notOpenInContext) this.openLinkTab(); + else this.selectedContext && this.openLinkColTab({ context: this.selectedContext, shouldZoom: shouldZoom }); + } + else if (this.selectedMode === FollowModes.PAN) { + this.jumpToLink({ shouldZoom: shouldZoom }); + } + else return; + } + } + + @action + handleModeChange = (e: React.ChangeEvent) => { + let target = e.target as HTMLInputElement; + this.selectedMode = target.value; + this.selectedContext = undefined; + this.selectedContextString = ""; + + this.shouldUseOnlyParentContext = (this.selectedMode === FollowModes.INPLACE || this.selectedMode === FollowModes.PAN); + + if (this.shouldUseOnlyParentContext) { + if (this.sourceView && this.sourceView.props.ContainingCollectionView) { + this.selectedContext = this.sourceView.props.ContainingCollectionView.props.Document; + this.selectedContextString = (StrCast(this.sourceView.props.ContainingCollectionView.props.Document.title)); + } + } + } + + @action + handleOptionChange = (e: React.ChangeEvent) => { + let target = e.target as HTMLInputElement; + this.selectedOption = target.value; + } + + @action + handleContextChange = (e: React.ChangeEvent) => { + let target = e.target as HTMLInputElement; + this.selectedContextString = target.value; + // selectedContext is updated in reaction + this.selectedOption = ""; + } + + @computed + get canOpenInPlace() { + if (this.sourceView && this.sourceView.props.ContainingCollectionView) { + let colView = this.sourceView.props.ContainingCollectionView; + let colDoc = colView.props.Document; + if (colDoc.viewType && colDoc.viewType === CollectionViewType.Freeform) return true; + } + return false; + } + + @computed + get availableModes() { + return ( + <div> + <label><input + type="radio" + name="mode" + value={FollowModes.OPENRIGHT} + checked={this.selectedMode === FollowModes.OPENRIGHT} + onChange={this.handleModeChange} + disabled={false} /> + {FollowModes.OPENRIGHT} + </label><br /> + <label><input + type="radio" + name="mode" + value={FollowModes.OPENTAB} + checked={this.selectedMode === FollowModes.OPENTAB} + onChange={this.handleModeChange} + disabled={false} /> + {FollowModes.OPENTAB} + </label><br /> + <label><input + type="radio" + name="mode" + value={FollowModes.OPENFULL} + checked={this.selectedMode === FollowModes.OPENFULL} + onChange={this.handleModeChange} + disabled={false} /> + {FollowModes.OPENFULL} + </label><br /> + <label><input + type="radio" + name="mode" + value={FollowModes.PAN} + checked={this.selectedMode === FollowModes.PAN} + onChange={this.handleModeChange} + disabled={!this.canPan} /> + {FollowModes.PAN} + </label><br /> + <label><input + type="radio" + name="mode" + value={FollowModes.INPLACE} + checked={this.selectedMode === FollowModes.INPLACE} + onChange={this.handleModeChange} + disabled={!this.canOpenInPlace} /> + {FollowModes.INPLACE} + </label><br /> + </div> + ); + } + + @computed + get parentName() { + if (this.sourceView && this.sourceView.props.ContainingCollectionView) { + let colView = this.sourceView.props.ContainingCollectionView; + return colView.props.Document.title; + } + } + + @computed + get parentID(): string { + if (this.sourceView && this.sourceView.props.ContainingCollectionView) { + let colView = this.sourceView.props.ContainingCollectionView; + return StrCast(colView.props.Document[Id]); + } + return "col"; + } + + @computed + get availableContexts() { + return ( + this.shouldUseOnlyParentContext ? + <label><input + type="radio" disabled={true} + name="context" + value={this.parentID} + checked={true} /> + {this.parentName} (Parent Collection) + </label> + : + <div> + <label><input + type="radio" disabled={LinkFollowBox.linkDoc ? false : true} + name="context" + value={LinkFollowBox.destinationDoc ? StrCast(LinkFollowBox.destinationDoc[Id]) : "self"} + checked={LinkFollowBox.destinationDoc ? this.selectedContextString === StrCast(LinkFollowBox.destinationDoc[Id]) || this.selectedContextString === "self" : true} + onChange={this.handleContextChange} /> + Open Self + </label><br /> + {[...this._docs, ...this._otherDocs].map(doc => { + if (doc && doc.target && doc.col.title !== "Recently Closed") { + return <div key={doc.col[Id] + doc.target[Id]}><label key={doc.col[Id] + doc.target[Id]}> + <input + type="radio" disabled={LinkFollowBox.linkDoc ? false : true} + name="context" + value={StrCast(doc.col[Id])} + checked={this.selectedContextString === StrCast(doc.col[Id])} + onChange={this.handleContextChange} /> + {doc.col.title} + </label><br /></div>; + } + })} + </div> + ); + } + + @computed + get shouldShowZoom(): boolean { + if (this.selectedMode === FollowModes.OPENFULL) return false; + if (this.shouldUseOnlyParentContext) return true; + if (LinkFollowBox.destinationDoc ? this.selectedContextString === LinkFollowBox.destinationDoc[Id] : "self") return false; + + let contextMatch: boolean = false; + if (this.selectedContextAliases) { + this.selectedContextAliases.forEach(alias => { + if (alias.viewType === CollectionViewType.Freeform) contextMatch = true; + }); + } + if (contextMatch) return true; + + return false; + } + + @computed + get availableOptions() { + if (LinkFollowBox.destinationDoc) { + return ( + this.shouldShowZoom ? + <div> + <label><input + type="radio" + name="option" + value={FollowOptions.ZOOM} + checked={this.selectedOption === FollowOptions.ZOOM} + onChange={this.handleOptionChange} + disabled={false} /> + {FollowOptions.ZOOM} + </label><br /> + <label><input + type="radio" + name="option" + value={FollowOptions.NOZOOM} + checked={this.selectedOption === FollowOptions.NOZOOM} + onChange={this.handleOptionChange} + disabled={false} /> + {FollowOptions.NOZOOM} + </label><br /> + </div> + : + <div>No Available Options</div> + ); + } + return null; + } + + render() { + return ( + <div className="linkFollowBox-main" style={{ height: NumCast(this.props.Document.height), width: NumCast(this.props.Document.width) }}> + <div className="linkFollowBox-header"> + <div className="topHeader"> + {LinkFollowBox.linkDoc ? "Link Title: " + StrCast(LinkFollowBox.linkDoc.title) : "No Link Selected"} + <div onClick={() => this.props.Document.isMinimized = true} className="closeDocument"><FontAwesomeIcon icon={faTimes} size="lg" /></div> + </div> + <div className=" direction-indicator">{LinkFollowBox.linkDoc ? + LinkFollowBox.sourceDoc && LinkFollowBox.destinationDoc ? "Source: " + StrCast(LinkFollowBox.sourceDoc.title) + ", Destination: " + StrCast(LinkFollowBox.destinationDoc.title) + : "" : ""}</div> + </div> + <div className="linkFollowBox-content" style={{ height: NumCast(this.props.Document.height) - 110 }}> + <div className="linkFollowBox-item"> + <div className="linkFollowBox-item title">Mode</div> + <div className="linkFollowBox-itemContent"> + {LinkFollowBox.linkDoc ? this.availableModes : "Please select a link to view modes"} + </div> + </div> + <div className="linkFollowBox-item"> + <div className="linkFollowBox-item title">Context</div> + <div className="linkFollowBox-itemContent"> + {this.selectedMode !== "" ? this.availableContexts : "Please select a mode to view contexts"} + </div> + </div> + <div className="linkFollowBox-item"> + <div className="linkFollowBox-item title">Options</div> + <div className="linkFollowBox-itemContent"> + {this.selectedContextString !== "" ? this.availableOptions : "Please select a context to view options"} + </div> + </div> + </div> + <div className="linkFollowBox-footer"> + <button + onClick={this.resetVars}> + Clear Link + </button> + <div style={{ width: 20 }}></div> + <button + onClick={this.currentLinkBehavior} + disabled={(LinkFollowBox.linkDoc) ? false : true}> + Follow Link + </button> + </div> + </div> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/linking/LinkMenu.scss b/src/client/views/linking/LinkMenu.scss new file mode 100644 index 000000000..a4018bd2d --- /dev/null +++ b/src/client/views/linking/LinkMenu.scss @@ -0,0 +1,137 @@ +@import "../globalCssVariables"; + +.linkMenu { + width: 100%; + height: auto; +} + +.linkMenu-list { + max-height: 200px; + overflow-y: scroll; +} + +.linkMenu-group { + border-bottom: 0.5px solid lightgray; + padding: 5px 0; + + + &:last-child { + border-bottom: none; + } + + .linkMenu-group-name { + display: flex; + + &:hover { + p { + background-color: lightgray; + } + p.expand-one { + width: calc(100% - 26px); + } + .linkEditor-tableButton { + display: block; + } + } + + p { + width: 100%; + padding: 4px 6px; + line-height: 12px; + border-radius: 5px; + font-weight: bold; + } + + .linkEditor-tableButton { + display: none; + } + } +} + +.linkMenu-item { + // border-top: 0.5px solid $main-accent; + position: relative; + display: flex; + font-size: 12px; + + + .link-name { + position: relative; + + p { + padding: 4px 6px; + line-height: 12px; + border-radius: 5px; + overflow-wrap: break-word; + } + } + + .linkMenu-item-content { + width: 100%; + } + + .link-metadata { + padding: 0 10px 0 16px; + margin-bottom: 4px; + color: $main-accent; + font-style: italic; + font-size: 10.5px; + } + + &:hover { + .linkMenu-item-buttons { + display: flex; + } + .linkMenu-item-content { + &.expand-two p { + width: calc(100% - 52px); + background-color: lightgray; + } + &.expand-three p { + width: calc(100% - 84px); + background-color: lightgray; + } + } + } +} + +.linkMenu-item-buttons { + display: none; + position: absolute; + top: 50%; + right: 0; + transform: translateY(-50%); + + .button { + width: 20px; + height: 20px; + margin: 0; + margin-right: 6px; + border-radius: 50%; + cursor: pointer; + pointer-events: auto; + background-color: $dark-color; + color: $light-color; + font-size: 65%; + transition: transform 0.2s; + text-align: center; + position: relative; + + .fa-icon { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + + &:last-child { + margin-right: 0; + } + &:hover { + background: $main-accent; + } + } +} + + + diff --git a/src/client/views/linking/LinkMenu.tsx b/src/client/views/linking/LinkMenu.tsx new file mode 100644 index 000000000..842ce45b1 --- /dev/null +++ b/src/client/views/linking/LinkMenu.tsx @@ -0,0 +1,76 @@ +import { action, observable } from "mobx"; +import { observer } from "mobx-react"; +import { DocumentView } from "../nodes/DocumentView"; +import { LinkEditor } from "./LinkEditor"; +import './LinkMenu.scss'; +import React = require("react"); +import { Doc } from "../../../new_fields/Doc"; +import { LinkManager } from "../../util/LinkManager"; +import { LinkMenuGroup } from "./LinkMenuGroup"; +import { faTrash } from '@fortawesome/free-solid-svg-icons'; +import { library } from "@fortawesome/fontawesome-svg-core"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +library.add(faTrash); + +interface Props { + docView: DocumentView; + changeFlyout: () => void; + addDocTab: (document: Doc, dataDoc: Doc | undefined, where: string) => void; +} + +@observer +export class LinkMenu extends React.Component<Props> { + + @observable private _editingLink?: Doc; + + @action + componentWillReceiveProps() { + this._editingLink = undefined; + } + + clearAllLinks = () => { + LinkManager.Instance.deleteAllLinksOnAnchor(this.props.docView.props.Document); + } + + renderAllGroups = (groups: Map<string, Array<Doc>>): Array<JSX.Element> => { + let linkItems: Array<JSX.Element> = []; + groups.forEach((group, groupType) => { + linkItems.push( + <LinkMenuGroup + key={groupType} + docView={this.props.docView} + sourceDoc={this.props.docView.props.Document} + group={group} + groupType={groupType} + showEditor={action((linkDoc: Doc) => this._editingLink = linkDoc)} + addDocTab={this.props.addDocTab} /> + ); + }); + + // if source doc has no links push message + if (linkItems.length === 0) linkItems.push(<p key="">No links have been created yet. Drag the linking button onto another document to create a link.</p>); + + return linkItems; + } + + render() { + let sourceDoc = this.props.docView.props.Document; + let groups: Map<string, Doc[]> = LinkManager.Instance.getRelatedGroupedLinks(sourceDoc); + 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> + {/* <input id="linkMenu-searchBar" type="text" placeholder="Search..."></input> */} + <div className="linkMenu-list"> + {this.renderAllGroups(groups)} + </div> + </div> + ); + } else { + return ( + <LinkEditor sourceDoc={this.props.docView.props.Document} linkDoc={this._editingLink} showLinks={action(() => this._editingLink = undefined)}></LinkEditor> + ); + } + } +}
\ No newline at end of file diff --git a/src/client/views/linking/LinkMenuGroup.tsx b/src/client/views/linking/LinkMenuGroup.tsx new file mode 100644 index 000000000..b6a24b0c8 --- /dev/null +++ b/src/client/views/linking/LinkMenuGroup.tsx @@ -0,0 +1,111 @@ +import { action, observable } from "mobx"; +import { observer } from "mobx-react"; +import { DocumentView } from "../nodes/DocumentView"; +import { LinkMenuItem } from "./LinkMenuItem"; +import { LinkEditor } from "./LinkEditor"; +import './LinkMenu.scss'; +import React = require("react"); +import { Doc, DocListCast } from "../../../new_fields/Doc"; +import { Id } from "../../../new_fields/FieldSymbols"; +import { LinkManager } from "../../util/LinkManager"; +import { DragLinksAsDocuments, DragManager, SetupDrag } from "../../util/DragManager"; +import { emptyFunction } from "../../../Utils"; +import { Docs } from "../../documents/Documents"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { UndoManager } from "../../util/UndoManager"; +import { StrCast } from "../../../new_fields/Types"; +import { SchemaHeaderField, RandomPastel } from "../../../new_fields/SchemaHeaderField"; + +interface LinkMenuGroupProps { + sourceDoc: Doc; + group: Doc[]; + groupType: string; + showEditor: (linkDoc: Doc) => void; + addDocTab: (document: Doc, dataDoc: Doc | undefined, where: string) => void; + docView: DocumentView; + +} + +@observer +export class LinkMenuGroup extends React.Component<LinkMenuGroupProps> { + + private _drag = React.createRef<HTMLDivElement>(); + private _table = React.createRef<HTMLDivElement>(); + + onLinkButtonDown = (e: React.PointerEvent): void => { + e.stopPropagation(); + document.removeEventListener("pointermove", this.onLinkButtonMoved); + document.addEventListener("pointermove", this.onLinkButtonMoved); + document.removeEventListener("pointerup", this.onLinkButtonUp); + document.addEventListener("pointerup", this.onLinkButtonUp); + } + + onLinkButtonUp = (e: PointerEvent): void => { + document.removeEventListener("pointermove", this.onLinkButtonMoved); + document.removeEventListener("pointerup", this.onLinkButtonUp); + e.stopPropagation(); + } + + + onLinkButtonMoved = async (e: PointerEvent) => { + UndoManager.RunInBatch(() => { + if (this._drag.current !== null && (e.movementX > 1 || e.movementY > 1)) { + document.removeEventListener("pointermove", this.onLinkButtonMoved); + document.removeEventListener("pointerup", this.onLinkButtonUp); + + let draggedDocs = this.props.group.map(linkDoc => { + let opp = LinkManager.Instance.getOppositeAnchor(linkDoc, this.props.sourceDoc); + if (opp) return opp; + }) as Doc[]; + let dragData = new DragManager.DocumentDragData(draggedDocs, draggedDocs.map(d => undefined)); + + DragManager.StartLinkedDocumentDrag([this._drag.current], dragData, e.x, e.y, { + handlers: { + dragComplete: action(emptyFunction), + }, + hideSource: false + }); + } + }, "drag links"); + e.stopPropagation(); + } + + viewGroupAsTable = (groupType: string): JSX.Element => { + let keys = LinkManager.Instance.getMetadataKeysInGroup(groupType); + let index = keys.indexOf(""); + if (index > -1) keys.splice(index, 1); + let cols = ["anchor1", "anchor2", ...[...keys]].map(c => new SchemaHeaderField(c, "#f1efeb")); + let docs: Doc[] = LinkManager.Instance.getAllMetadataDocsInGroup(groupType); + let createTable = action(() => Docs.Create.SchemaDocument(cols, docs, { width: 500, height: 300, title: groupType + " table" })); + let 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>; + } + + render() { + let groupItems = this.props.group.map(linkDoc => { + let destination = LinkManager.Instance.getOppositeAnchor(linkDoc, this.props.sourceDoc); + if (destination && this.props.sourceDoc) { + return <LinkMenuItem key={destination[Id] + this.props.sourceDoc[Id]} + groupType={this.props.groupType} + addDocTab={this.props.addDocTab} + linkDoc={linkDoc} + sourceDoc={this.props.sourceDoc} + destinationDoc={destination} + showEditor={this.props.showEditor} />; + } + }); + + return ( + <div className="linkMenu-group"> + <div className="linkMenu-group-name"> + <p ref={this._drag} onPointerDown={this.onLinkButtonDown} + className={this.props.groupType === "*" || this.props.groupType === "" ? "" : "expand-one"} > {this.props.groupType}:</p> + {this.props.groupType === "*" || this.props.groupType === "" ? <></> : this.viewGroupAsTable(this.props.groupType)} + </div> + <div className="linkMenu-group-wrapper"> + {groupItems} + </div> + </div > + ); + } +}
\ No newline at end of file diff --git a/src/client/views/linking/LinkMenuItem.tsx b/src/client/views/linking/LinkMenuItem.tsx new file mode 100644 index 000000000..6895dae9a --- /dev/null +++ b/src/client/views/linking/LinkMenuItem.tsx @@ -0,0 +1,139 @@ +import { library } from '@fortawesome/fontawesome-svg-core'; +import { faEdit, faEye, faTimes, faArrowRight, faChevronDown, faChevronUp, faGlobeAsia } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { observer } from "mobx-react"; +import { DocumentManager } from "../../util/DocumentManager"; +import { undoBatch } from "../../util/UndoManager"; +import './LinkMenu.scss'; +import React = require("react"); +import { Doc, DocListCastAsync, WidthSym } from '../../../new_fields/Doc'; +import { StrCast, Cast, FieldValue, NumCast } from '../../../new_fields/Types'; +import { observable, action, computed } from 'mobx'; +import { LinkManager } from '../../util/LinkManager'; +import { DragLinkAsDocument } from '../../util/DragManager'; +import { CollectionDockingView } from '../collections/CollectionDockingView'; +import { SelectionManager } from '../../util/SelectionManager'; +import { CollectionViewType } from '../collections/CollectionBaseView'; +import { DocumentView } from '../nodes/DocumentView'; +import { SearchUtil } from '../../util/SearchUtil'; +import { LinkFollowBox } from './LinkFollowBox'; +import { ContextMenu } from '../ContextMenu'; +import { MainView } from '../MainView'; +import { Docs } from '../../documents/Documents'; +import { CurrentUserUtils } from '../../../server/authentication/models/current_user_utils'; +library.add(faEye, faEdit, faTimes, faArrowRight, faChevronDown, faChevronUp); + + +interface LinkMenuItemProps { + groupType: string; + linkDoc: Doc; + sourceDoc: Doc; + destinationDoc: Doc; + showEditor: (linkDoc: Doc) => void; + addDocTab: (document: Doc, dataDoc: Doc | undefined, where: string) => void; +} + +@observer +export class LinkMenuItem extends React.Component<LinkMenuItemProps> { + private _drag = React.createRef<HTMLDivElement>(); + @observable private _showMore: boolean = false; + @action toggleShowMore() { this._showMore = !this._showMore; } + + onEdit = (e: React.PointerEvent): void => { + e.stopPropagation(); + this.props.showEditor(this.props.linkDoc); + //SelectionManager.DeselectAll(); + } + + renderMetadata = (): JSX.Element => { + let groups = LinkManager.Instance.getAnchorGroups(this.props.linkDoc, this.props.sourceDoc); + let index = groups.findIndex(groupDoc => StrCast(groupDoc.type).toUpperCase() === this.props.groupType.toUpperCase()); + let groupDoc = index > -1 ? groups[index] : undefined; + + let mdRows: Array<JSX.Element> = []; + if (groupDoc) { + let mdDoc = Cast(groupDoc.metadata, Doc, null); + if (mdDoc) { + let 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>); + }); + } + } + + return (<div className="link-metadata">{mdRows}</div>); + } + + onLinkButtonDown = (e: React.PointerEvent): void => { + e.stopPropagation(); + document.removeEventListener("pointermove", this.onLinkButtonMoved); + document.addEventListener("pointermove", this.onLinkButtonMoved); + document.removeEventListener("pointerup", this.onLinkButtonUp); + document.addEventListener("pointerup", this.onLinkButtonUp); + } + + onLinkButtonUp = (e: PointerEvent): void => { + document.removeEventListener("pointermove", this.onLinkButtonMoved); + document.removeEventListener("pointerup", this.onLinkButtonUp); + e.stopPropagation(); + } + + onLinkButtonMoved = async (e: PointerEvent) => { + if (this._drag.current !== null && (e.movementX > 1 || e.movementY > 1)) { + document.removeEventListener("pointermove", this.onLinkButtonMoved); + document.removeEventListener("pointerup", this.onLinkButtonUp); + + DragLinkAsDocument(this._drag.current, e.x, e.y, this.props.linkDoc, this.props.sourceDoc); + } + 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.Instance.props.Document.isMinimized = false; + 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; + MainView.Instance.toggleLinkFollowBox(false); + LinkFollowBox.Instance.setLinkDocs(this.props.linkDoc, this.props.sourceDoc, this.props.destinationDoc); + } + } + + render() { + + let keys = LinkManager.Instance.getMetadataKeysInGroup(this.props.groupType);//groupMetadataKeys.get(this.props.groupType); + let canExpand = keys ? keys.length > 0 : false; + + return ( + <div className="linkMenu-item"> + <div className={canExpand ? "linkMenu-item-content expand-three" : "linkMenu-item-content expand-two"}> + <div className="link-name"> + <p ref={this._drag} onPointerDown={this.onLinkButtonDown}>{StrCast(this.props.destinationDoc.title)}</p> + <div className="linkMenu-item-buttons"> + {canExpand ? <div title="Show more" className="button" onPointerDown={() => this.toggleShowMore()}> + <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="Follow link" className="button" onClick={this.followDefault} onContextMenu={this.onContextMenu}><FontAwesomeIcon className="fa-icon" icon="arrow-right" size="sm" /></div> + </div> + </div> + {this._showMore ? this.renderMetadata() : <></>} + </div> + + </div > + ); + } +}
\ No newline at end of file |