aboutsummaryrefslogtreecommitdiff
path: root/src/client/views/linking
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/views/linking')
-rw-r--r--src/client/views/linking/LinkEditor.scss145
-rw-r--r--src/client/views/linking/LinkEditor.tsx400
-rw-r--r--src/client/views/linking/LinkFollowBox.tsx9
-rw-r--r--src/client/views/linking/LinkMenu.scss137
-rw-r--r--src/client/views/linking/LinkMenu.tsx76
-rw-r--r--src/client/views/linking/LinkMenuGroup.tsx111
-rw-r--r--src/client/views/linking/LinkMenuItem.tsx282
7 files changed, 1160 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.tsx b/src/client/views/linking/LinkFollowBox.tsx
new file mode 100644
index 000000000..487281d50
--- /dev/null
+++ b/src/client/views/linking/LinkFollowBox.tsx
@@ -0,0 +1,9 @@
+import { observable, computed, action, trace } from "mobx";
+import React = require("react");
+import { observer } from "mobx-react";
+import { FieldViewProps } from "../nodes/FieldView";
+
+@observer
+export class LinkFollowBox extends React.Component<FieldViewProps> {
+
+} \ 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..7dc0a9fcd
--- /dev/null
+++ b/src/client/views/linking/LinkMenuItem.tsx
@@ -0,0 +1,282 @@
+import { library } from '@fortawesome/fontawesome-svg-core';
+import { faEdit, faEye, faTimes, faArrowRight, faChevronDown, faChevronUp } 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 } from '../../../new_fields/Doc';
+import { StrCast, Cast, FieldValue, NumCast } from '../../../new_fields/Types';
+import { observable, action } 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';
+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; }
+
+
+ unhighlight = () => {
+ Doc.UnhighlightAll();
+ document.removeEventListener("pointerdown", this.unhighlight);
+ }
+
+ @action
+ highlightDoc = () => {
+ document.removeEventListener("pointerdown", this.unhighlight);
+ Doc.HighlightDoc(this.props.destinationDoc);
+ window.setTimeout(() => {
+ document.addEventListener("pointerdown", this.unhighlight);
+ }, 10000);
+ }
+
+ // NOT TESTED
+ // col = collection the doc is in
+ // target = the document to center on
+ @undoBatch
+ openLinkColRight = ({ col, target }: { col: Doc, target: Doc }) => {
+ col = Doc.IsPrototype(col) ? Doc.MakeDelegate(col) : col;
+ if (NumCast(col.viewType, CollectionViewType.Invalid) === CollectionViewType.Freeform) {
+ const newPanX = NumCast(target.x) + NumCast(target.width) / NumCast(target.zoomBasis, 1) / 2;
+ const newPanY = NumCast(target.y) + NumCast(target.height) / NumCast(target.zoomBasis, 1) / 2;
+ col.panX = newPanX;
+ col.panY = newPanY;
+ }
+ CollectionDockingView.Instance.AddRightSplit(col, undefined);
+ }
+
+ // DONE
+ // this opens the linked doc in a right split, NOT in its collection
+ @undoBatch
+ openLinkRight = () => {
+ this.highlightDoc();
+ let alias = Doc.MakeAlias(this.props.destinationDoc);
+ CollectionDockingView.Instance.AddRightSplit(alias, undefined);
+ SelectionManager.DeselectAll();
+ }
+
+ // DONE
+ // this is the standard "follow link" (jump to document)
+ // taken from follow link
+ @undoBatch
+ jumpToLink = async (shouldZoom: boolean) => {
+ //there is an issue right now so this will be false automatically
+ shouldZoom = false;
+ this.highlightDoc();
+ let jumpToDoc = this.props.destinationDoc;
+ let pdfDoc = FieldValue(Cast(this.props.destinationDoc, Doc));
+ if (pdfDoc) {
+ jumpToDoc = pdfDoc;
+ }
+ let proto = Doc.GetProto(this.props.linkDoc);
+ let targetContext = await Cast(proto.targetContext, Doc);
+ let sourceContext = await Cast(proto.sourceContext, Doc);
+ let self = this;
+
+ let dockingFunc = (document: Doc) => { this.props.addDocTab(document, undefined, "inTab"); SelectionManager.DeselectAll(); };
+
+ if (this.props.destinationDoc === self.props.linkDoc.anchor2 && targetContext) {
+ DocumentManager.Instance.jumpToDocument(jumpToDoc, shouldZoom, false, async document => dockingFunc(document), undefined, targetContext);
+ }
+ else if (this.props.destinationDoc === self.props.linkDoc.anchor1 && sourceContext) {
+ DocumentManager.Instance.jumpToDocument(jumpToDoc, shouldZoom, false, document => dockingFunc(sourceContext!));
+ }
+ else if (DocumentManager.Instance.getDocumentView(jumpToDoc)) {
+ DocumentManager.Instance.jumpToDocument(jumpToDoc, shouldZoom, undefined, undefined, NumCast((this.props.destinationDoc === self.props.linkDoc.anchor2 ? self.props.linkDoc.anchor2Page : self.props.linkDoc.anchor1Page)));
+
+ }
+ else {
+ DocumentManager.Instance.jumpToDocument(jumpToDoc, shouldZoom, false, dockingFunc);
+ }
+ }
+
+ // DONE
+ // opens link in new tab (not in a collection)
+ // this opens it full screen, do we need a separate full screen option?
+ @undoBatch
+ openLinkTab = () => {
+ this.highlightDoc();
+ let fullScreenAlias = Doc.MakeAlias(this.props.destinationDoc);
+ this.props.addDocTab(fullScreenAlias, undefined, "inTab");
+ SelectionManager.DeselectAll();
+ }
+
+ // NOT TESTED
+ // opens link in new tab in collection
+ // col = collection the doc is in
+ // target = the document to center on
+ @undoBatch
+ openLinkColTab = ({ col, target }: { col: Doc, target: Doc }) => {
+ this.highlightDoc();
+ col = Doc.IsPrototype(col) ? Doc.MakeDelegate(col) : col;
+ if (NumCast(col.viewType, CollectionViewType.Invalid) === CollectionViewType.Freeform) {
+ const newPanX = NumCast(target.x) + NumCast(target.width) / NumCast(target.zoomBasis, 1) / 2;
+ const newPanY = NumCast(target.y) + NumCast(target.height) / NumCast(target.zoomBasis, 1) / 2;
+ col.panX = newPanX;
+ col.panY = newPanY;
+ }
+ // CollectionDockingView.Instance.AddRightSplit(col, undefined);
+ this.props.addDocTab(col, undefined, "inTab");
+ SelectionManager.DeselectAll();
+ }
+
+ // this will open a link next to the source doc
+ @undoBatch
+ openLinkInPlace = () => {
+ this.highlightDoc();
+
+ let alias = Doc.MakeAlias(this.props.destinationDoc);
+ let y = this.props.sourceDoc.y;
+ let x = this.props.sourceDoc.x;
+ let parentView: any = undefined;
+ let parentDoc: Doc = this.props.sourceDoc;
+
+ SelectionManager.SelectedDocuments().map(dv => {
+ if (dv.props.Document === this.props.sourceDoc) {
+ parentView = dv.props.ContainingCollectionView;
+ }
+ });
+
+ if (parentView) {
+ // console.log(parentDoc)
+ console.log(parentView.props.addDocument);
+ }
+ }
+
+ //set this to be the default link behavior, can be any of the above
+ private defaultLinkBehavior: any = this.openLinkInPlace;
+
+ 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();
+ }
+
+ 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>
+ {/* Original */}
+ {/* <div title="Follow link" className="button" onPointerDown={this.onFollowLink}><FontAwesomeIcon className="fa-icon" icon="arrow-right" size="sm" /></div> */}
+ {/* New */}
+ <div title="Follow link" className="button" onPointerDown={this.defaultLinkBehavior}><FontAwesomeIcon className="fa-icon" icon="arrow-right" size="sm" /></div>
+ </div>
+ </div>
+ {this._showMore ? this.renderMetadata() : <></>}
+ </div>
+
+ </div >
+ );
+ }
+}
+
+ // @undoBatch
+ // onFollowLink = async (e: React.PointerEvent): Promise<void> => {
+ // e.stopPropagation();
+ // e.persist();
+ // let jumpToDoc = this.props.destinationDoc;
+ // let pdfDoc = FieldValue(Cast(this.props.destinationDoc, Doc));
+ // if (pdfDoc) {
+ // jumpToDoc = pdfDoc;
+ // }
+ // let proto = Doc.GetProto(this.props.linkDoc);
+ // let targetContext = await Cast(proto.targetContext, Doc);
+ // let sourceContext = await Cast(proto.sourceContext, Doc);
+ // let self = this;
+
+
+ // let dockingFunc = (document: Doc) => { this.props.addDocTab(document, undefined, "inTab"); SelectionManager.DeselectAll(); };
+ // if (e.ctrlKey) {
+ // dockingFunc = (document: Doc) => CollectionDockingView.Instance.AddRightSplit(document, undefined);
+ // }
+
+ // if (this.props.destinationDoc === self.props.linkDoc.anchor2 && targetContext) {
+ // DocumentManager.Instance.jumpToDocument(jumpToDoc, e.altKey, false, async document => dockingFunc(document), undefined, targetContext!);
+ // console.log("1")
+ // }
+ // else if (this.props.destinationDoc === self.props.linkDoc.anchor1 && sourceContext) {
+ // DocumentManager.Instance.jumpToDocument(jumpToDoc, e.altKey, false, document => dockingFunc(sourceContext!));
+ // console.log("2")
+ // }
+ // else if (DocumentManager.Instance.getDocumentView(jumpToDoc)) {
+ // DocumentManager.Instance.jumpToDocument(jumpToDoc, e.altKey, undefined, undefined, NumCast((this.props.destinationDoc === self.props.linkDoc.anchor2 ? self.props.linkDoc.anchor2Page : self.props.linkDoc.anchor1Page)));
+ // console.log("3")
+
+ // }
+ // else {
+ // DocumentManager.Instance.jumpToDocument(jumpToDoc, e.altKey, false, dockingFunc);
+ // console.log("4")
+
+ // }
+ // } \ No newline at end of file