From 972f76a34e3c1a1aa5f0be59639fbd5763c9c16f Mon Sep 17 00:00:00 2001
From: Sam Wilkins <abdullah_ahmed@brown.edu>
Date: Thu, 6 Jun 2019 17:45:26 -0400
Subject: links get saved to global table

---
 src/client/views/nodes/LinkBox.tsx | 17 ++++++++++-------
 1 file changed, 10 insertions(+), 7 deletions(-)

(limited to 'src/client/views/nodes/LinkBox.tsx')

diff --git a/src/client/views/nodes/LinkBox.tsx b/src/client/views/nodes/LinkBox.tsx
index 68b692aad..ebca1dc69 100644
--- a/src/client/views/nodes/LinkBox.tsx
+++ b/src/client/views/nodes/LinkBox.tsx
@@ -1,5 +1,5 @@
 import { library } from '@fortawesome/fontawesome-svg-core';
-import { faEdit, faEye, faTimes } from '@fortawesome/free-solid-svg-icons';
+import { faEdit, faEye, faTimes, faArrowRight } from '@fortawesome/free-solid-svg-icons';
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
 import { observer } from "mobx-react";
 import { DocumentManager } from "../../util/DocumentManager";
@@ -16,6 +16,7 @@ import { action } from 'mobx';
 library.add(faEye);
 library.add(faEdit);
 library.add(faTimes);
+library.add(faArrowRight);
 
 interface Props {
     linkDoc: Doc;
@@ -29,7 +30,7 @@ interface Props {
 export class LinkBox extends React.Component<Props> {
 
     @undoBatch
-    onViewButtonPressed = async (e: React.PointerEvent): Promise<void> => {
+    followLink = async (e: React.PointerEvent): Promise<void> => {
         e.stopPropagation();
         DocumentManager.Instance.jumpToDocument(this.props.pairedDoc, e.altKey);
     }
@@ -62,8 +63,8 @@ export class LinkBox extends React.Component<Props> {
 
         return (
             //<LinkEditor linkBox={this} linkDoc={this.props.linkDoc} />
-            <div className="link-container">
-                <div className="info-container" onPointerDown={this.onViewButtonPressed}>
+            <div className="link-menu-item">
+                <div className="link-menu-item-content">
                     <div className="link-name">
                         <p>{this.props.linkName}</p>
                     </div>
@@ -72,13 +73,15 @@ export class LinkBox extends React.Component<Props> {
                     </div>
                 </div>
 
-                <div className="button-container">
+                <div className="link-menu-item-buttons">
                     {/* <div title="Follow Link" className="button" onPointerDown={this.onViewButtonPressed}>
                         <FontAwesomeIcon className="fa-icon-view" icon="eye" size="sm" /></div> */}
+                    <div title="Follow Link" className="button" onPointerDown={this.followLink}>
+                        <FontAwesomeIcon className="fa-icon" icon="arrow-right" size="sm" /></div>
                     <div title="Edit Link" className="button" onPointerDown={this.onEditButtonPressed}>
-                        <FontAwesomeIcon className="fa-icon-edit" icon="edit" size="sm" /></div>
+                        <FontAwesomeIcon className="fa-icon" icon="edit" size="sm" /></div>
                     <div title="Delete Link" className="button" onPointerDown={this.onDeleteButtonPressed}>
-                        <FontAwesomeIcon className="fa-icon-delete" icon="times" size="sm" /></div>
+                        <FontAwesomeIcon className="fa-icon" icon="times" size="sm" /></div>
                 </div>
             </div>
         );
-- 
cgit v1.2.3-70-g09d2


From e1fd270f1806ffd51174c835b335ceb4ebb2fe56 Mon Sep 17 00:00:00 2001
From: Fawn <fangrui_tong@brown.edu>
Date: Wed, 12 Jun 2019 16:20:46 -0400
Subject: created remove handlers for link metadata and groups

---
 src/client/documents/Documents.ts      |  14 +-
 src/client/util/LinkManager.ts         | 110 ++++++++++-----
 src/client/views/nodes/LinkBox.tsx     |  38 ++---
 src/client/views/nodes/LinkEditor.scss |  32 ++++-
 src/client/views/nodes/LinkEditor.tsx  | 250 +++++++++++++++------------------
 src/client/views/nodes/LinkMenu.scss   |   2 +-
 src/client/views/nodes/LinkMenu.tsx    |  33 +----
 7 files changed, 251 insertions(+), 228 deletions(-)

(limited to 'src/client/views/nodes/LinkBox.tsx')

diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts
index 9f1501265..9517cbbda 100644
--- a/src/client/documents/Documents.ts
+++ b/src/client/documents/Documents.ts
@@ -89,9 +89,13 @@ export namespace DocUtils {
         // let protoSrc = source.proto ? source.proto : source;
         // let protoTarg = target.proto ? target.proto : target;
         UndoManager.RunInBatch(() => {
-            let groupDoc = Docs.TextDocument();
-            groupDoc.proto!.type = "*";
-            groupDoc.proto!.metadata = Docs.TextDocument();
+            // let groupDoc1 = Docs.TextDocument();
+            // groupDoc1.proto!.type = "*";
+            // groupDoc1.proto!.metadata = Docs.TextDocument();
+
+            // let groupDoc2 = Docs.TextDocument();
+            // groupDoc2.proto!.type = "*";
+            // groupDoc2.proto!.metadata = Docs.TextDocument();
 
             let linkDoc = Docs.TextDocument({ width: 100, height: 30, borderRounding: -1 });
             //let linkDoc = new Doc;
@@ -99,11 +103,11 @@ export namespace DocUtils {
             // linkDoc.proto!.linkDescription = "";
             linkDoc.proto!.anchor1 = source;
             linkDoc.proto!.anchor1Page = source.curPage;
-            linkDoc.proto!.anchor1Groups = new List<Doc>([groupDoc]);
+            linkDoc.proto!.anchor1Groups = new List<Doc>([]);
 
             linkDoc.proto!.anchor2 = target;
             linkDoc.proto!.anchor2Page = target.curPage;
-            linkDoc.proto!.anchor2Groups = new List<Doc>([groupDoc]);
+            linkDoc.proto!.anchor2Groups = new List<Doc>([]);
 
             // linkDoc.proto!.linkedTo = target;
             // linkDoc.proto!.linkedToPage = target.curPage;
diff --git a/src/client/util/LinkManager.ts b/src/client/util/LinkManager.ts
index 99fb4c6c0..cc8617052 100644
--- a/src/client/util/LinkManager.ts
+++ b/src/client/util/LinkManager.ts
@@ -9,6 +9,7 @@ import { StrCast, Cast } from "../../new_fields/Types";
 import { Doc } from "../../new_fields/Doc";
 import { listSpec } from "../../new_fields/Schema";
 import { List } from "../../new_fields/List";
+import { string } from "prop-types";
 
 export namespace LinkUtils {
     export function findOppositeAnchor(link: Doc, anchor: Doc): Doc {
@@ -40,14 +41,48 @@ export namespace LinkUtils {
     // }
 
     export function setAnchorGroups(link: Doc, anchor: Doc, groups: Doc[]) {
+        // console.log("setting groups for anchor", anchor["title"]);
         if (Doc.AreProtosEqual(anchor, Cast(link.anchor1, Doc, new Doc))) {
             link.anchor1Groups = new List<Doc>(groups);
         } else {
             link.anchor2Groups = new List<Doc>(groups);
         }
     }
+
+    export function removeGroupFromAnchor(link: Doc, anchor: Doc, groupType: string) {
+        let groups = [];
+        if (Doc.AreProtosEqual(anchor, Cast(link.anchor1, Doc, new Doc))) {
+            groups = Cast(link["anchor1Groups"], listSpec(Doc), []);
+        } else {
+            groups = Cast(link["anchor2Groups"], listSpec(Doc), []);
+        }
+
+        let newGroups: Doc[] = [];
+        groups.forEach(groupDoc => {
+            if (groupDoc instanceof Doc && StrCast(groupDoc["type"]) !== groupType) {
+                newGroups.push(groupDoc);
+            }
+        })
+        LinkUtils.setAnchorGroups(link, anchor, newGroups);
+    }
 }
 
+/* 
+ * link doc: 
+ * - anchor1: doc
+ * - anchor1page: number
+ * - anchor1groups: list of group docs representing the groups anchor1 categorizes this link/anchor2 in 
+ * - anchor2: doc
+ * - anchor2page: number
+ * - anchor2groups: list of group docs representing the groups anchor2 categorizes this link/anchor1 in 
+ * 
+ * group doc:
+ * - type: string representing the group type/name/category
+ * - metadata: doc representing the metadata kvps
+ * 
+ * metadata doc:
+ * - user defined kvps 
+ */
 export class LinkManager {
     private static _instance: LinkManager;
     public static get Instance(): LinkManager {
@@ -56,60 +91,57 @@ export class LinkManager {
     private constructor() {
     }
 
-    @observable public allLinks: Array<Doc> = [];
-    @observable public allGroups: Map<string, Array<string>> = new Map();
+    @observable public allLinks: Array<Doc> = []; // list of link docs
+    @observable public allGroups: Map<string, Array<string>> = new Map(); // map of group type to list of its metadata keys
 
     public findAllRelatedLinks(anchor: Doc): Array<Doc> {
         return LinkManager.Instance.allLinks.filter(
             link => Doc.AreProtosEqual(anchor, Cast(link.anchor1, Doc, new Doc)) || Doc.AreProtosEqual(anchor, Cast(link.anchor2, Doc, new Doc)));
     }
 
+    // returns map of group type to anchor's links in that group type
     public findRelatedGroupedLinks(anchor: Doc): Map<string, Array<Doc>> {
         let related = this.findAllRelatedLinks(anchor);
 
-        let anchorGroups = new Map();
+        let anchorGroups = new Map<string, Array<Doc>>();
         related.forEach(link => {
-            // get groups of given doc
-            let oppGroups = (Doc.AreProtosEqual(anchor, Cast(link.anchor1, Doc, new Doc))) ? Cast(link.anchor1Groups, listSpec(Doc), []) : Cast(link.anchor2Groups, listSpec(Doc), []);
-            if (oppGroups) {
-                if (oppGroups.length > 0) {
-                    oppGroups.forEach(groupDoc => {
-                        if (groupDoc instanceof Doc) {
-                            let groupType = StrCast(groupDoc.proto!.type);
-                            let group = anchorGroups.get(groupType); // TODO: clean this up lol
-                            if (group) group.push(link);
-                            else group = [link];
-                            anchorGroups.set(groupType, group);
-                        } else {
-                            // promise doc
-                        }
-
-                    })
-                }
-                else {
-                    // if link is in no groups then put it in default group
-                    let group = anchorGroups.get("*");
-                    if (group) group.push(link);
-                    else group = [link];
-                    anchorGroups.set("*", group);
-                }
-            }
 
+            // get groups of given anchor categorizes this link/opposite anchor in
+            let groups = (Doc.AreProtosEqual(anchor, Cast(link.anchor1, Doc, new Doc))) ? Cast(link.anchor1Groups, listSpec(Doc), []) : Cast(link.anchor2Groups, listSpec(Doc), []);
+            if (groups.length > 0) {
+                groups.forEach(groupDoc => {
+                    if (groupDoc instanceof Doc) {
+                        let groupType = StrCast(groupDoc["type"]);
+                        let group = anchorGroups.get(groupType); // TODO: clean this up lol
+                        if (group) group.push(link);
+                        else group = [link];
+                        anchorGroups.set(groupType, group);
+                    } else {
+                        // promise doc
+                    }
+
+                })
+            }
+            else {
+                // if link is in no groups then put it in default group
+                let group = anchorGroups.get("*");
+                if (group) group.push(link);
+                else group = [link];
+                anchorGroups.set("*", group);
+            }
 
-            //     let anchor = this.findOppositeAnchor(link, source);
-            //     let group = categories.get(link.linkTags);
-            //     if (group) group.push(link);
-            //     else group = [link];
-            //     categories.set(link.linkTags, group);
         })
         return anchorGroups;
     }
 
-
-
-
-    // public findAnchorTags(link: Doc, source: Doc): Doc[] {
-    //     if (source)
-    // }
+    public deleteGroup(groupType: string) {
+        let deleted = LinkManager.Instance.allGroups.delete(groupType);
+        if (deleted) {
+            LinkManager.Instance.allLinks.forEach(linkDoc => {
+                LinkUtils.removeGroupFromAnchor(linkDoc, Cast(linkDoc["anchor1"], Doc, new Doc), groupType);
+                LinkUtils.removeGroupFromAnchor(linkDoc, Cast(linkDoc["anchor2"], Doc, new Doc), groupType);
+            })
+        }
+    }
 
 }
\ No newline at end of file
diff --git a/src/client/views/nodes/LinkBox.tsx b/src/client/views/nodes/LinkBox.tsx
index ebca1dc69..5597bb1aa 100644
--- a/src/client/views/nodes/LinkBox.tsx
+++ b/src/client/views/nodes/LinkBox.tsx
@@ -41,23 +41,23 @@ export class LinkBox extends React.Component<Props> {
         this.props.showEditor();
     }
 
-    @action
-    onDeleteButtonPressed = async (e: React.PointerEvent): Promise<void> => {
-        e.stopPropagation();
-        const [linkedFrom, linkedTo] = await Promise.all([Cast(this.props.linkDoc.linkedFrom, Doc), Cast(this.props.linkDoc.linkedTo, Doc)]);
-        if (linkedFrom) {
-            const linkedToDocs = Cast(linkedFrom.linkedToDocs, listSpec(Doc));
-            if (linkedToDocs) {
-                linkedToDocs.splice(linkedToDocs.indexOf(this.props.linkDoc), 1);
-            }
-        }
-        if (linkedTo) {
-            const linkedFromDocs = Cast(linkedTo.linkedFromDocs, listSpec(Doc));
-            if (linkedFromDocs) {
-                linkedFromDocs.splice(linkedFromDocs.indexOf(this.props.linkDoc), 1);
-            }
-        }
-    }
+    // @action
+    // onDeleteButtonPressed = async (e: React.PointerEvent): Promise<void> => {
+    //     e.stopPropagation();
+    //     const [linkedFrom, linkedTo] = await Promise.all([Cast(this.props.linkDoc.linkedFrom, Doc), Cast(this.props.linkDoc.linkedTo, Doc)]);
+    //     if (linkedFrom) {
+    //         const linkedToDocs = Cast(linkedFrom.linkedToDocs, listSpec(Doc));
+    //         if (linkedToDocs) {
+    //             linkedToDocs.splice(linkedToDocs.indexOf(this.props.linkDoc), 1);
+    //         }
+    //     }
+    //     if (linkedTo) {
+    //         const linkedFromDocs = Cast(linkedTo.linkedFromDocs, listSpec(Doc));
+    //         if (linkedFromDocs) {
+    //             linkedFromDocs.splice(linkedFromDocs.indexOf(this.props.linkDoc), 1);
+    //         }
+    //     }
+    // }
 
     render() {
 
@@ -80,8 +80,8 @@ export class LinkBox extends React.Component<Props> {
                         <FontAwesomeIcon className="fa-icon" icon="arrow-right" size="sm" /></div>
                     <div title="Edit Link" className="button" onPointerDown={this.onEditButtonPressed}>
                         <FontAwesomeIcon className="fa-icon" icon="edit" size="sm" /></div>
-                    <div title="Delete Link" className="button" onPointerDown={this.onDeleteButtonPressed}>
-                        <FontAwesomeIcon className="fa-icon" icon="times" size="sm" /></div>
+                    {/* <div title="Delete Link" className="button" onPointerDown={this.onDeleteButtonPressed}>
+                        <FontAwesomeIcon className="fa-icon" icon="times" size="sm" /></div> */}
                 </div>
             </div>
         );
diff --git a/src/client/views/nodes/LinkEditor.scss b/src/client/views/nodes/LinkEditor.scss
index d3ac8cf19..b56f3046a 100644
--- a/src/client/views/nodes/LinkEditor.scss
+++ b/src/client/views/nodes/LinkEditor.scss
@@ -74,6 +74,7 @@
     }
     .linkEditor-group-row {
         display: flex;
+        margin-bottom: 6px;
         .linkEditor-group-row-label {
             margin-right: 6px;
         }
@@ -81,8 +82,17 @@
     .linkEditor-metadata-row {
         display: flex;
         justify-content: space-between;
+        margin-bottom: 6px;
         input {
-            width: calc(50% - 2px);
+            width: calc(50% - 18px);
+            height: 20px;
+        }
+        button {
+            width: 20px;
+            height: 20px;
+            margin-left: 6px;
+            padding: 0;
+            font-size: 14px;
         }
     }
 }
@@ -90,6 +100,7 @@
 .linkEditor-dropdown {
     width: 100%;
     position: relative;
+    z-index: 999;
     .linkEditor-options-wrapper {
         width: 100%;
         position: absolute;
@@ -107,4 +118,23 @@
             background-color:  $light-color-secondary;
         }
     }
+}
+
+.linkEditor-group-buttons {
+    height: 20px;
+    display: flex;
+    justify-content: space-between;
+    button {
+        width: 31%;
+        height: 20px;
+        margin-left: 6px;
+        padding: 0;
+        font-size: 10px;
+        &:first-child { // delete
+            font-size: 14px;
+        }
+        &:disabled { // delete
+            background-color: gray;
+        }
+    }
 }
\ No newline at end of file
diff --git a/src/client/views/nodes/LinkEditor.tsx b/src/client/views/nodes/LinkEditor.tsx
index 8d12bc30f..05e05c2ee 100644
--- a/src/client/views/nodes/LinkEditor.tsx
+++ b/src/client/views/nodes/LinkEditor.tsx
@@ -1,11 +1,7 @@
 import { observable, computed, action } from "mobx";
 import React = require("react");
-import { SelectionManager } from "../../util/SelectionManager";
 import { observer } from "mobx-react";
 import './LinkEditor.scss';
-import { props } from "bluebird";
-import { DocumentView } from "./DocumentView";
-import { link } from "fs";
 import { StrCast, Cast } from "../../../new_fields/Types";
 import { Doc } from "../../../new_fields/Doc";
 import { List } from "../../../new_fields/List";
@@ -21,7 +17,7 @@ import { string } from "prop-types";
 library.add(faArrowLeft);
 library.add(faEllipsisV);
 
-// this dropdown could be generalizeds
+// this dropdown could be generalized
 @observer
 class LinkGroupsDropdown extends React.Component<{ groupId: string, groupType: string, setGroup: (groupId: string, group: string) => void }> {
     @observable private _searchTerm: string = "";
@@ -63,7 +59,7 @@ class LinkGroupsDropdown extends React.Component<{ groupId: string, groupType: s
 
         if (!exactFound && this._searchTerm !== "") {
             options.push(<div key={""} className="linkEditor-option"
-                onClick={() => { this.createGroup(this._searchTerm); this.setGroupType(this._searchTerm); this.setSearchTerm("") }}>Create new "{this._searchTerm}" group</div>)
+                onClick={() => { this.createGroup(this._searchTerm); this.setGroupType(this._searchTerm); this.setSearchTerm("") }}>Define new "{this._searchTerm}" relationship</div>)
         }
 
         return options;
@@ -82,6 +78,8 @@ class LinkGroupsDropdown extends React.Component<{ groupId: string, groupType: s
     }
 }
 
+
+
 @observer
 class LinkMetadataEditor extends React.Component<{ groupType: string, mdDoc: Doc, mdKey: string, mdValue: string }> {
     @observable private _key: string = this.props.mdKey;
@@ -111,11 +109,28 @@ class LinkMetadataEditor extends React.Component<{ groupType: string, mdDoc: Doc
         this._value = value;
     }
 
+    @action
+    removeMetadata = (): void => {
+        let groupMdKeys = new Array(...LinkManager.Instance.allGroups.get(this.props.groupType)!);
+        if (groupMdKeys) {
+            let index = groupMdKeys.indexOf(this._key);
+            if (index > -1) {
+                groupMdKeys.splice(index, 1);
+            }
+            else {
+                console.log("OLD KEY WAS NOT FOUND", ...groupMdKeys)
+            }
+        }
+        this._key = "";
+        LinkManager.Instance.allGroups.set(this.props.groupType, groupMdKeys);
+    }
+
     render() {
         return (
             <div className="linkEditor-metadata-row">
                 <input type="text" value={this._key} placeholder="key" onChange={e => this.editMetadataKey(e.target.value)}></input>:
                 <input type="text" value={this._value} placeholder="value" onChange={e => this.editMetadataValue(e.target.value)}></input>
+                <button onClick={() => this.removeMetadata()}>X</button>
             </div>
         )
     }
@@ -131,85 +146,129 @@ interface LinkEditorProps {
 @observer
 export class LinkEditor extends React.Component<LinkEditorProps> {
 
-    @observable private _groups: Map<string, Doc> = new Map();
-    @observable private _metadata: Map<string, Map<string, Doc>> = new Map();
+    @observable private _groups: Map<string, Doc> = new Map(); // map of temp group id to the corresponding group doc
 
     constructor(props: LinkEditorProps) {
         super(props);
 
         let groups = new Map<string, Doc>();
-        let metadata: Map<string, Map<string, Doc>> = new Map();
         let groupList = (Doc.AreProtosEqual(props.sourceDoc, Cast(props.linkDoc.anchor1, Doc, new Doc))) ?
             Cast(props.linkDoc.anchor1Groups, listSpec(Doc), []) : Cast(props.linkDoc.anchor2Groups, listSpec(Doc), []);
         groupList.forEach(groupDoc => {
             if (groupDoc instanceof Doc) {
                 let id = Utils.GenerateGuid();
                 groups.set(id, groupDoc);
-
-                let metadataMap = new Map<string, Doc>();
-                let metadataDocs = Cast(groupDoc.proto!.metadata, listSpec(Doc), []);
-                metadataDocs.forEach(mdDoc => {
-                    if (mdDoc && mdDoc instanceof Doc) { // TODO: handle promise doc
-                        metadataMap.set(Utils.GenerateGuid(), mdDoc);
-                    }
-                })
-                metadata.set(id, metadataMap);
             } else {
                 // promise doc
             }
         })
         this._groups = groups;
-        this._metadata = metadata;
     }
 
-    // @action
-    // editGroup(groupId: string, value: string) {
-    //     let linkDoc = this.props.linkDoc.proto ? this.props.linkDoc.proto : this.props.linkDoc;
-    //     let groupDoc = this._groups.get(groupId);
-    //     if (groupDoc) {
-    //         groupDoc.proto!.type = value;
-    //         LinkUtils.setAnchorGroups(linkDoc, this.props.sourceDoc, [groupDoc]);
-    //     }
-    // }
-
     @action
-    addGroup = (e: React.MouseEvent): void => {
-        // create new document for group
-        let groupDoc = Docs.TextDocument();
-        groupDoc.proto!.title = "";
-        groupDoc.proto!.metadata = new List<Doc>([]);
+    addGroup = (): void => {
+        console.log("before adding", ...Array.from(this._groups.keys()));
+
+        let index = Array.from(this._groups.values()).findIndex(groupDoc => {
+            return groupDoc["type"] === "New Group";
+        })
+        if (index === -1) {
+            // create new document for group
+            let groupDoc = Docs.TextDocument();
+            groupDoc.proto!.type = "New Group";
+            groupDoc.proto!.metadata = Docs.TextDocument();
 
-        this._groups.set(Utils.GenerateGuid(), groupDoc);
+            this._groups.set(Utils.GenerateGuid(), groupDoc);
 
-        let linkDoc = this.props.linkDoc.proto ? this.props.linkDoc.proto : this.props.linkDoc;
-        LinkUtils.setAnchorGroups(linkDoc, this.props.sourceDoc, Array.from(this._groups.values()));
+            let linkDoc = this.props.linkDoc.proto ? this.props.linkDoc.proto : this.props.linkDoc;
+            LinkUtils.setAnchorGroups(linkDoc, this.props.sourceDoc, Array.from(this._groups.values()));
+        }
+
+
+        // console.log("set anchor groups for", this.props.sourceDoc["title"]);
+        console.log("after adding", ...Array.from(this._groups.keys()));
     }
 
     @action
-    setGroup = (groupId: string, group: string): void => {
+    setGroupType = (groupId: string, groupType: string): void => {
+        console.log("setting for ", groupId);
         let linkDoc = this.props.linkDoc.proto ? this.props.linkDoc.proto : this.props.linkDoc;
         let groupDoc = this._groups.get(groupId);
         if (groupDoc) {
-            groupDoc.proto!.type = group;
-            LinkUtils.setAnchorGroups(linkDoc, this.props.sourceDoc, [groupDoc]);
+            console.log("found group doc");
+            groupDoc.proto!.type = groupType;
+
+            this._groups.set(groupId, groupDoc);
+
+            let gd = this._groups.get(groupId);
+            if (gd)
+                console.log("just created", StrCast(gd["type"]));
+
+            LinkUtils.setAnchorGroups(linkDoc, this.props.sourceDoc, Array.from(this._groups.values()));
+            console.log("set", Array.from(this._groups.values()).length)
+        }
+
+        let anchor1groups: string[] = [];
+        Cast(this.props.linkDoc.anchor1Groups, listSpec(Doc), []).forEach(doc => {
+            if (doc instanceof Doc) {
+                anchor1groups.push(StrCast(doc.proto!.type));
+            } else {
+                console.log("promise");
+            }
+        })
+        let anchor2groups: string[] = [];
+        Cast(this.props.linkDoc.anchor2Groups, listSpec(Doc), []).forEach(doc => {
+            if (doc instanceof Doc) {
+                anchor2groups.push(StrCast(doc.proto!.type));
+            } else {
+                console.log("promise");
+            }
+        })
+        console.log("groups for anchors; anchor1: [", ...anchor1groups, "] ; anchor2: [", ...anchor2groups, "]")
+    }
+
+    removeGroupFromLink = (groupId: string, groupType: string) => {
+        // this._groups.delete(groupId);
+        let groupDoc = this._groups.get(groupId);
+        if (groupDoc) {
+            LinkUtils.removeGroupFromAnchor(this.props.linkDoc, this.props.sourceDoc, groupType);
+            this._groups.delete(groupId);
+        }
+        // LinkUtils.setAnchorGroups(this.props.linkDoc, this.props.sourceDoc, Array.from(this._groups.values()));
+        console.log("\nremoved", groupId, "remaining", ...Array.from(this._groups.keys()), "\n");
+    }
+
+    deleteGroup = (groupId: string, groupType: string) => {
+        let groupDoc = this._groups.get(groupId);
+        if (groupDoc) {
+            LinkManager.Instance.deleteGroup(groupType);
+            this._groups.delete(groupId);
         }
     }
 
     renderGroup(groupId: string, groupDoc: Doc) {
-        return (
-            <div key={groupId} className="linkEditor-group">
-                <div className="linkEditor-group-row">
-                    <p className="linkEditor-group-row-label">type:</p>
-                    <LinkGroupsDropdown groupId={groupId} groupType={StrCast(groupDoc.proto!.type)} setGroup={this.setGroup} />
-                    {/* <input key={groupId + "-type"} type="text" value={StrCast(groupDoc.proto!.type)} onChange={e => this.editGroup(groupId, e.target.value)}></input> */}
-                    {/* <input key={groupId + "-type"} type="text" value={StrCast(groupDoc.proto!.type)} onChange={e => this.editGroup(groupId, e.target.value)}></input> */}
+        let type = StrCast(groupDoc["type"]);
+        if ((type && LinkManager.Instance.allGroups.get(type)) || type === "New Group") {
+            return (
+                <div key={groupId} className="linkEditor-group">
+                    <div className="linkEditor-group-row">
+                        <p className="linkEditor-group-row-label">type:</p>
+                        <LinkGroupsDropdown groupId={groupId} groupType={StrCast(groupDoc.proto!.type)} setGroup={this.setGroupType} />
+                    </div>
+                    {this.renderMetadata(groupId)}
+                    <div className="linkEditor-group-buttons">
+                        {groupDoc["type"] === "New Group" ? <button disabled={true} title="Add KVP">+</button> :
+                            <button onClick={() => this.addMetadata(StrCast(groupDoc.proto!.type))} title="Add KVP">+</button>}
+                        {/* <button onClick={this.viewGroupAsTable}>view group as table</button> */}
+                        <button onClick={() => this.copyGroup(groupId, type)} title="Copy group to opposite anchor">↔</button>
+                        <button onClick={() => this.removeGroupFromLink(groupId, type)} title="Remove group from link">x</button>
+                        <button onClick={() => this.deleteGroup(groupId, type)} title="Delete group">xx</button>
+                    </div>
                 </div>
-                {this.renderMetadata(groupId)}
-                {groupDoc["type"] === "*" ? <></> : <button onClick={() => this.addMetadata(StrCast(groupDoc.proto!.type))}>add kvp</button>}
-                <button onClick={this.viewGroupAsTable}>view group as table</button>
-                {/* <button onClick={() => this.addMetadata(StrCast(groupDoc.proto!.type))}>+</button> */}
-            </div>
-        )
+            )
+        } else {
+            return <></>
+        }
     }
 
     viewGroupAsTable = (): void => {
@@ -228,65 +287,6 @@ export class LinkEditor extends React.Component<LinkEditorProps> {
         }
         LinkManager.Instance.allGroups.set(groupType, mdKeys);
 
-        // // create new metadata doc
-        // let mdDoc = Docs.TextDocument();
-        // mdDoc.proto!.title = "";
-        // mdDoc.proto!.value = "";
-
-        // // append to map
-        // let mdMap = this._metadata.get(groupId);
-        // if (mdMap) {
-        //     mdMap.set(Utils.GenerateGuid(), mdDoc);
-        // } else {
-        //     mdMap = new Map();
-        //     mdMap.set(Utils.GenerateGuid(), mdDoc);
-        // }
-
-        // // add to internal representation of metadata
-        // this._metadata.set(groupId, mdMap);
-
-        // // add to internatal representation of group
-        // let groupDoc = this._groups.get(groupId);
-        // if (groupDoc) {
-        //     groupDoc.proto!.metadata = new List<Doc>(Array.from(mdMap.values()));
-        //     this._groups.set(groupId, groupDoc);
-        // }
-
-        // // add to link doc
-        // let linkDoc = this.props.linkDoc.proto ? this.props.linkDoc.proto : this.props.linkDoc;
-        // LinkUtils.setAnchorGroups(linkDoc, this.props.sourceDoc, Array.from(this._groups.values()));
-    }
-
-    // @action
-    // editMetadataTitle(groupId: string, mdId: string, value: string) {
-    //     let groupMd = this._metadata.get(groupId);
-    //     if (groupMd) {
-    //         let mdDoc = groupMd.get(mdId);
-    //         if (mdDoc) {
-    //             mdDoc.proto!.title = value;
-    //         }
-    //     }
-    //     // set group and link?
-    // }
-
-    // @action
-    // editMetadataValue(groupId: string, mdId: string, value: string) {
-    //     let groupMd = this._metadata.get(groupId);
-    //     if (groupMd) {
-    //         let mdDoc = groupMd.get(mdId);
-    //         if (mdDoc) {
-    //             mdDoc.proto!.value = value;
-    //         }
-    //     }
-    //     // set group and link?
-    // }
-
-    @action
-    editMetadataKey(groupId: string, value: string) {
-        let groupDoc = this._groups.get(groupId);
-        if (groupDoc) {
-
-        }
     }
 
     renderMetadata(groupId: string) {
@@ -300,30 +300,10 @@ export class LinkEditor extends React.Component<LinkEditorProps> {
                 groupMdKeys.forEach((key, index) => {
                     metadata.push(
                         <LinkMetadataEditor key={"mded-" + index} groupType={groupType} mdDoc={mdDoc} mdKey={key} mdValue={(mdDoc[key] === undefined) ? "" : StrCast(mdDoc[key])} />
-                        // <div key={key} className="linkEditor-metadata-row">
-                        //     <input type="text" value={key} placeholder="key"></input>
-                        //     :
-                        //     <input type="text" value={(mdDoc[key] === undefined) ? "" : StrCast(mdDoc[key])} placeholder="value"></input>
-                        // </div>
                     )
                 })
             }
         }
-
-
-        // let metadataMap = this._metadata.get(groupId);
-        // if (metadataMap) {
-        //     metadataMap.forEach((mdDoc, mdId) => {
-        //         metadata.push(
-        //             <div key={mdId} className="linkEditor-metadata-row">
-        //                 <input type="text" value={StrCast(mdDoc.proto!.title)} placeholder="key" onChange={e => this.editMetadataTitle(groupId, mdId, e.target.value)}></input>
-        //                 :
-        //                 <input type="text" value={StrCast(mdDoc.proto!.value)} placeholder="value" onChange={e => this.editMetadataValue(groupId, mdId, e.target.value)}></input>
-        //             </div>
-        //         )
-        //     })
-        // }
-
         return metadata;
     }
 
@@ -340,10 +320,10 @@ export class LinkEditor extends React.Component<LinkEditorProps> {
                 <button className="linkEditor-back" onPointerDown={() => this.props.showLinks()}><FontAwesomeIcon icon="arrow-left" size="sm" /></button>
                 <p className="linkEditor-linkedTo">editing link to: <b>{destination.proto!.title}</b></p>
                 <div className="linkEditor-groupsLabel">
-                    <b>Groups:</b>
-                    <button onClick={this.addGroup} title="Add Group">+</button>
+                    <b>Relationships:</b>
+                    <button onClick={() => this.addGroup()} title="Add Group">+</button>
                 </div>
-                {groups}
+                {groups.length > 0 ? groups : <div className="linkEditor-group">There are currently no relationships associated with this link.</div>}
             </div>
 
         );
diff --git a/src/client/views/nodes/LinkMenu.scss b/src/client/views/nodes/LinkMenu.scss
index 860f31d8a..7e031b897 100644
--- a/src/client/views/nodes/LinkMenu.scss
+++ b/src/client/views/nodes/LinkMenu.scss
@@ -40,7 +40,7 @@
             display: flex;
         }
         &:hover .link-menu-item-content {
-            width: calc(100% - 72px);
+            width: calc(100% - 42px);
         }
     }
     .link-menu-item-buttons {
diff --git a/src/client/views/nodes/LinkMenu.tsx b/src/client/views/nodes/LinkMenu.tsx
index 21b5807ae..47bd6c3eb 100644
--- a/src/client/views/nodes/LinkMenu.tsx
+++ b/src/client/views/nodes/LinkMenu.tsx
@@ -32,7 +32,7 @@ export class LinkMenu extends React.Component<Props> {
     //     });
     // }
 
-    renderLinkGroupItems(links: Doc[]) {
+    renderGroup(links: Doc[]) {
         let source = this.props.docView.Document;
         return links.map(link => {
             let destination = LinkUtils.findOppositeAnchor(link, source);
@@ -43,7 +43,7 @@ export class LinkMenu extends React.Component<Props> {
         });
     }
 
-    renderLinkItems = (links: Map<string, Array<Doc>>): Array<JSX.Element> => {
+    renderLinks = (links: Map<string, Array<Doc>>): Array<JSX.Element> => {
         let linkItems: Array<JSX.Element> = [];
 
         links.forEach((links, group) => {
@@ -51,7 +51,7 @@ export class LinkMenu extends React.Component<Props> {
                 <div key={group} className="link-menu-group">
                     <p className="link-menu-group-name">{group}:</p>
                     <div className="link-menu-group-wrapper">
-                        {this.renderLinkGroupItems(links)}
+                        {this.renderGroup(links)}
                     </div>
                 </div>
             )
@@ -65,10 +65,7 @@ export class LinkMenu extends React.Component<Props> {
     }
 
     render() {
-        //get list of links from document
-        // let linkFrom = DocListCast(this.props.docView.props.Document.linkedFromDocs);
-        // let linkTo = DocListCast(this.props.docView.props.Document.linkedToDocs);
-        let related = LinkManager.Instance.findRelatedGroupedLinks(this.props.docView.props.Document);
+        let related: Map<string, Doc[]> = LinkManager.Instance.findRelatedGroupedLinks(this.props.docView.props.Document);
         if (this._editingLink === undefined) {
             return (
                 <div id="linkMenu-container">
@@ -76,31 +73,11 @@ export class LinkMenu extends React.Component<Props> {
                     <div id="linkMenu-list">
                         {/* {this.renderLinkItems(linkTo, "linkedTo", "Destination: ")}
                         {this.renderLinkItems(linkFrom, "linkedFrom", "Source: ")} */}
-                        {this.renderLinkItems(related)}
+                        {this.renderLinks(related)}
                     </div>
                 </div>
             );
         } else {
-            // let groups = new Map<string, Doc>();
-            // let metadata: Map<string, Map<string, Doc>> = new Map();
-            // let groupList = (Doc.AreProtosEqual(this.props.docView.props.Document, Cast(this._editingLink.anchor1, Doc, new Doc))) ?
-            //     Cast(this._editingLink.anchor1Groups, listSpec(Doc), []) : Cast(this._editingLink.anchor2Groups, listSpec(Doc), []);
-            // groupList.forEach(groupDoc => {
-            //     if (groupDoc instanceof Doc) {
-            //         let id = Utils.GenerateGuid();
-            //         groups.set(id, groupDoc);
-
-            //         let metadataMap = new Map<string, Doc>();
-            //         let metadataDocs = Cast(groupDoc.proto!.metadata, listSpec(Doc), []);
-            //         metadataDocs.forEach(mdDoc => {
-            //             if (mdDoc && mdDoc instanceof Doc) { // TODO: handle promise doc
-            //                 metadataMap.set(Utils.GenerateGuid(), mdDoc);
-            //             }
-            //         })
-            //         metadata.set(id, metadataMap);
-            //     }
-            // })
-
             return (
                 <LinkEditor sourceDoc={this.props.docView.props.Document} linkDoc={this._editingLink} showLinks={action(() => this._editingLink = undefined)}></LinkEditor>
             );
-- 
cgit v1.2.3-70-g09d2


From f54496c7aa930e385e77aaf37df8d51db733e3a2 Mon Sep 17 00:00:00 2001
From: Fawn <fangrui_tong@brown.edu>
Date: Fri, 14 Jun 2019 15:24:35 -0400
Subject: cleaned up link code

---
 src/client/util/LinkManager.ts         | 166 ++++++-----------
 src/client/views/nodes/LinkBox.scss    | 122 ++++++-------
 src/client/views/nodes/LinkBox.tsx     |  52 +-----
 src/client/views/nodes/LinkEditor.scss | 106 +++++------
 src/client/views/nodes/LinkEditor.tsx  | 319 +++++++++++++--------------------
 src/client/views/nodes/LinkMenu.scss   |  76 +-------
 src/client/views/nodes/LinkMenu.tsx    |  54 ++----
 7 files changed, 311 insertions(+), 584 deletions(-)

(limited to 'src/client/views/nodes/LinkBox.tsx')

diff --git a/src/client/util/LinkManager.ts b/src/client/util/LinkManager.ts
index 929fcbf21..aaed7388d 100644
--- a/src/client/util/LinkManager.ts
+++ b/src/client/util/LinkManager.ts
@@ -1,71 +1,9 @@
-import { observable, computed, action } from "mobx";
-import React = require("react");
-import { SelectionManager } from "./SelectionManager";
-import { observer } from "mobx-react";
-import { props } from "bluebird";
-import { DocumentView } from "../views/nodes/DocumentView";
-import { link } from "fs";
+import { observable } from "mobx";
 import { StrCast, Cast } from "../../new_fields/Types";
-import { Doc } from "../../new_fields/Doc";
+import { Doc, DocListCast } from "../../new_fields/Doc";
 import { listSpec } from "../../new_fields/Schema";
 import { List } from "../../new_fields/List";
-import { string } from "prop-types";
-import { Docs } from "../documents/Documents";
 
-export namespace LinkUtils {
-    export function findOppositeAnchor(link: Doc, anchor: Doc): Doc {
-        if (Doc.AreProtosEqual(anchor, Cast(link.anchor1, Doc, new Doc))) {
-            return Cast(link.anchor2, Doc, new Doc);
-        } else {
-            return Cast(link.anchor1, Doc, new Doc);
-        }
-    }
-
-    export function setAnchorGroups(link: Doc, anchor: Doc, groups: Doc[]) {
-        // console.log("setting groups for anchor", anchor["title"]);
-        if (Doc.AreProtosEqual(anchor, Cast(link.anchor1, Doc, new Doc))) {
-            link.anchor1Groups = new List<Doc>(groups);
-
-            let print: string[] = [];
-            Cast(link.anchor1Groups, listSpec(Doc), []).forEach(doc => {
-                if (doc instanceof Doc) {
-                    print.push(StrCast(doc.type));
-                }
-            });
-            console.log("set anchor's groups as", print);
-        } else {
-            link.anchor2Groups = new List<Doc>(groups);
-
-            let print: string[] = [];
-            Cast(link.anchor2Groups, listSpec(Doc), []).forEach(doc => {
-                if (doc instanceof Doc) {
-                    print.push(StrCast(doc.type));
-                }
-            });
-            console.log("set anchor's groups as", print);
-        }
-    }
-
-    export function removeGroupFromAnchor(link: Doc, anchor: Doc, groupType: string) {
-        let groups = Doc.AreProtosEqual(anchor, Cast(link.anchor1, Doc, new Doc)) ?
-            Cast(link.proto!.anchor1Groups, listSpec(Doc), []) : Cast(link.proto!.anchor2Groups, listSpec(Doc), []);
-
-        let newGroups: Doc[] = [];
-        groups.forEach(groupDoc => {
-            if (groupDoc instanceof Doc && StrCast(groupDoc.type) !== groupType) {
-                newGroups.push(groupDoc);
-            } // TODO: promise
-        });
-
-        // let grouptypes: string[] = [];
-        // newGroups.forEach(groupDoc => {
-        //     grouptypes.push(StrCast(groupDoc.type));
-        // });
-        // console.log("remove anchor's groups as", grouptypes);
-
-        LinkUtils.setAnchorGroups(link, anchor, newGroups);
-    }
-}
 
 /* 
  * link doc: 
@@ -94,6 +32,7 @@ export class LinkManager {
     @observable public allLinks: Array<Doc> = []; // list of link docs
     @observable public groupMetadataKeys: Map<string, Array<string>> = new Map(); // map of group type to list of its metadata keys
 
+    // finds all links that contain the given anchor
     public findAllRelatedLinks(anchor: Doc): Array<Doc> {
         return LinkManager.Instance.allLinks.filter(
             link => Doc.AreProtosEqual(anchor, Cast(link.anchor1, Doc, new Doc)) || Doc.AreProtosEqual(anchor, Cast(link.anchor2, Doc, new Doc)));
@@ -102,27 +41,19 @@ export class LinkManager {
     // returns map of group type to anchor's links in that group type
     public findRelatedGroupedLinks(anchor: Doc): Map<string, Array<Doc>> {
         let related = this.findAllRelatedLinks(anchor);
-
         let anchorGroups = new Map<string, Array<Doc>>();
         related.forEach(link => {
+            let groups = LinkManager.Instance.getAnchorGroups(link, anchor);
 
-            // get groups of given anchor categorizes this link/opposite anchor in
-            let groups = (Doc.AreProtosEqual(anchor, Cast(link.anchor1, Doc, new Doc))) ? Cast(link.anchor1Groups, listSpec(Doc), []) : Cast(link.anchor2Groups, listSpec(Doc), []);
             if (groups.length > 0) {
                 groups.forEach(groupDoc => {
-                    if (groupDoc instanceof Doc) {
-                        let groupType = StrCast(groupDoc.type);
-                        let group = anchorGroups.get(groupType); // TODO: clean this up lol
-                        if (group) group.push(link);
-                        else group = [link];
-                        anchorGroups.set(groupType, group);
-                    } else {
-                        // promise doc
-                    }
-
+                    let groupType = StrCast(groupDoc.type);
+                    let group = anchorGroups.get(groupType);
+                    if (group) group.push(link);
+                    else group = [link];
+                    anchorGroups.set(groupType, group);
                 });
-            }
-            else {
+            } else {
                 // if link is in no groups then put it in default group
                 let group = anchorGroups.get("*");
                 if (group) group.push(link);
@@ -134,52 +65,38 @@ export class LinkManager {
         return anchorGroups;
     }
 
-    public findMetadataInGroup(groupType: string) {
+    // returns a list of all metadata docs associated with the given group type
+    public findAllMetadataDocsInGroup(groupType: string): Array<Doc> {
         let md: Doc[] = [];
         let allLinks = LinkManager.Instance.allLinks;
-        // for every link find its groups
-        // allLinks.forEach(linkDoc => {
-        //     let anchor1groups = LinkManager.Instance.findRelatedGroupedLinks(Cast(linkDoc["anchor1"], Doc, new Doc));
-        //     if (anchor1groups.get(groupType)) {
-        //         md.push(linkDoc["anchor1"]["group"])
-        //     }
-        // })
         allLinks.forEach(linkDoc => {
-            let anchor1Groups = Cast(linkDoc.anchor1Groups, listSpec(Doc), []);
-            let anchor2Groups = Cast(linkDoc.anchor2Groups, listSpec(Doc), []);
-            anchor1Groups.forEach(groupDoc => {
-                if (groupDoc instanceof Doc) {
-                    if (StrCast(groupDoc.type) === groupType) {
-                        md.push(Cast(groupDoc.metadata, Doc, new Doc));
-                    }
-                } else {
-                    // TODO: promise
-                }
-            });
-            anchor2Groups.forEach(groupDoc => {
-                if (groupDoc instanceof Doc) {
-                    if (StrCast(groupDoc.type) === groupType) {
-                        md.push(Cast(groupDoc.metadata, Doc, new Doc));
-                    }
-                } else {
-                    // TODO: promise
-                }
-            });
-
+            let anchor1Groups = LinkManager.Instance.getAnchorGroups(linkDoc, Cast(linkDoc.anchor1, Doc, new Doc));
+            let anchor2Groups = LinkManager.Instance.getAnchorGroups(linkDoc, Cast(linkDoc.anchor2, Doc, new Doc));
+            anchor1Groups.forEach(groupDoc => { if (StrCast(groupDoc.type).toUpperCase() === groupType.toUpperCase()) md.push(Cast(groupDoc.metadata, Doc, new Doc)); });
+            anchor2Groups.forEach(groupDoc => { if (StrCast(groupDoc.type).toUpperCase() === groupType.toUpperCase()) md.push(Cast(groupDoc.metadata, Doc, new Doc)); });
         });
         return md;
     }
 
-    public deleteGroup(groupType: string) {
+    // removes all group docs from all links with the given group type
+    public deleteGroup(groupType: string): void {
         let deleted = LinkManager.Instance.groupMetadataKeys.delete(groupType);
         if (deleted) {
             LinkManager.Instance.allLinks.forEach(linkDoc => {
-                LinkUtils.removeGroupFromAnchor(linkDoc, Cast(linkDoc.anchor1, Doc, new Doc), groupType);
-                LinkUtils.removeGroupFromAnchor(linkDoc, Cast(linkDoc.anchor2, Doc, new Doc), groupType);
+                LinkManager.Instance.removeGroupFromAnchor(linkDoc, Cast(linkDoc.anchor1, Doc, new Doc), groupType);
+                LinkManager.Instance.removeGroupFromAnchor(linkDoc, Cast(linkDoc.anchor2, Doc, new Doc), groupType);
             });
         }
     }
 
+    // removes group doc of given group type only from given anchor on given link
+    public removeGroupFromAnchor(linkDoc: Doc, anchor: Doc, groupType: string) {
+        let groups = LinkManager.Instance.getAnchorGroups(linkDoc, anchor);
+        let newGroups = groups.filter(groupDoc => StrCast(groupDoc.type).toUpperCase() !== groupType.toUpperCase());
+        LinkManager.Instance.setAnchorGroups(linkDoc, anchor, newGroups);
+    }
+
+    // checks if a link with the given anchors exists
     public doesLinkExist(anchor1: Doc, anchor2: Doc) {
         let allLinks = LinkManager.Instance.allLinks;
         let index = allLinks.findIndex(linkDoc => {
@@ -189,4 +106,31 @@ export class LinkManager {
         return index !== -1;
     }
 
+    // finds the opposite anchor of a given anchor in a link
+    public findOppositeAnchor(linkDoc: Doc, anchor: Doc): Doc {
+        if (Doc.AreProtosEqual(anchor, Cast(linkDoc.anchor1, Doc, new Doc))) {
+            return Cast(linkDoc.anchor2, Doc, new Doc);
+        } else {
+            return Cast(linkDoc.anchor1, Doc, new Doc);
+        }
+    }
+
+    // gets the groups associates with an anchor in a link
+    public getAnchorGroups(linkDoc: Doc, anchor: Doc): Array<Doc> {
+        if (Doc.AreProtosEqual(anchor, Cast(linkDoc.anchor1, Doc, new Doc))) {
+            return DocListCast(linkDoc.anchor1Groups);
+        } else {
+            return DocListCast(linkDoc.anchor2Groups);
+        }
+    }
+
+    // sets the groups of the given anchor in the given link
+    public setAnchorGroups(linkDoc: Doc, anchor: Doc, groups: Doc[]) {
+        if (Doc.AreProtosEqual(anchor, Cast(linkDoc.anchor1, Doc, new Doc))) {
+            linkDoc.anchor1Groups = new List<Doc>(groups);
+        } else {
+            linkDoc.anchor2Groups = new List<Doc>(groups);
+        }
+    }
+
 }
\ No newline at end of file
diff --git a/src/client/views/nodes/LinkBox.scss b/src/client/views/nodes/LinkBox.scss
index 639f83b38..08fefaf4d 100644
--- a/src/client/views/nodes/LinkBox.scss
+++ b/src/client/views/nodes/LinkBox.scss
@@ -1,66 +1,62 @@
 @import "../globalCssVariables";
-.link-container {
-    width: 100%;
-    height: 50px;
+.link-menu-item {
+    border-top: 0.5px solid $light-color-secondary; 
+    padding: 6px;
+    position: relative;
     display: flex;
-    flex-direction: row;
-    border-top: 0.5px solid #bababa;
-}
-
-.info-container {
-    width: 65%;
-    padding-top: 5px;
-    padding-left: 5px;
-    display: flex;
-    flex-direction: column
-}
-
-.link-name {
-    font-size: 11px;
-}
-
-.doc-name {
-    font-size: 8px;
-}
-
-.button-container {
-    width: 35%;
-    padding-top: 8px;
-    display: flex;
-    flex-direction: row;
-}
-
-.button {
-    height: 20px;
-    width: 20px;
-    margin: 8px 4px;
-    border-radius: 50%;
-    opacity: 0.9;
-    pointer-events: auto;
-    background-color: $dark-color;
-    color: $light-color;
-    text-transform: uppercase;
-    letter-spacing: 2px;
-    font-size: 60%;
-    transition: transform 0.2s;
-}
-
-.button:hover {
-    background: $main-accent;
-    cursor: pointer;
-}
-
-// .fa-icon-view {
-//     margin-left: 3px;
-//     margin-top: 5px;
-// }
-
-.fa-icon-edit {
-    margin-left: 6px;
-    margin-top: 6px;
-}
-
-.fa-icon-delete {
-    margin-left: 7px;
-    margin-top: 6px;
+    font-size: 12px;
+
+    .link-menu-item-content {
+        width: 100%;
+    }
+
+    &:last-child {
+        border-bottom: 0.5px solid $light-color-secondary;
+    }
+    &:hover {
+        .link-menu-item-buttons {
+            display: flex;
+        }
+        .link-menu-item-content {
+            width: calc(100% - 42px);
+        }
+    }
+}
+
+.link-menu-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;
+        }
+    }
 }
\ No newline at end of file
diff --git a/src/client/views/nodes/LinkBox.tsx b/src/client/views/nodes/LinkBox.tsx
index 5597bb1aa..1a7cce4a3 100644
--- a/src/client/views/nodes/LinkBox.tsx
+++ b/src/client/views/nodes/LinkBox.tsx
@@ -4,13 +4,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
 import { observer } from "mobx-react";
 import { DocumentManager } from "../../util/DocumentManager";
 import { undoBatch } from "../../util/UndoManager";
-import { CollectionDockingView } from "../collections/CollectionDockingView";
 import './LinkBox.scss';
 import React = require("react");
 import { Doc } from '../../../new_fields/Doc';
-import { Cast, NumCast } from '../../../new_fields/Types';
-import { listSpec } from '../../../new_fields/Schema';
-import { action } from 'mobx';
+import { StrCast } from '../../../new_fields/Types';
 
 
 library.add(faEye);
@@ -20,9 +17,8 @@ library.add(faArrowRight);
 
 interface Props {
     linkDoc: Doc;
-    linkName: String;
-    pairedDoc: Doc;
-    type: String;
+    sourceDoc: Doc;
+    destinationDoc: Doc;
     showEditor: () => void;
 }
 
@@ -30,58 +26,28 @@ interface Props {
 export class LinkBox extends React.Component<Props> {
 
     @undoBatch
-    followLink = async (e: React.PointerEvent): Promise<void> => {
+    onFollowLink = async (e: React.PointerEvent): Promise<void> => {
         e.stopPropagation();
-        DocumentManager.Instance.jumpToDocument(this.props.pairedDoc, e.altKey);
+        DocumentManager.Instance.jumpToDocument(this.props.destinationDoc, e.altKey);
     }
 
-    onEditButtonPressed = (e: React.PointerEvent): void => {
+    onEdit = (e: React.PointerEvent): void => {
         e.stopPropagation();
-
         this.props.showEditor();
     }
 
-    // @action
-    // onDeleteButtonPressed = async (e: React.PointerEvent): Promise<void> => {
-    //     e.stopPropagation();
-    //     const [linkedFrom, linkedTo] = await Promise.all([Cast(this.props.linkDoc.linkedFrom, Doc), Cast(this.props.linkDoc.linkedTo, Doc)]);
-    //     if (linkedFrom) {
-    //         const linkedToDocs = Cast(linkedFrom.linkedToDocs, listSpec(Doc));
-    //         if (linkedToDocs) {
-    //             linkedToDocs.splice(linkedToDocs.indexOf(this.props.linkDoc), 1);
-    //         }
-    //     }
-    //     if (linkedTo) {
-    //         const linkedFromDocs = Cast(linkedTo.linkedFromDocs, listSpec(Doc));
-    //         if (linkedFromDocs) {
-    //             linkedFromDocs.splice(linkedFromDocs.indexOf(this.props.linkDoc), 1);
-    //         }
-    //     }
-    // }
-
     render() {
-
         return (
-            //<LinkEditor linkBox={this} linkDoc={this.props.linkDoc} />
             <div className="link-menu-item">
                 <div className="link-menu-item-content">
                     <div className="link-name">
-                        <p>{this.props.linkName}</p>
-                    </div>
-                    <div className="doc-name">
-                        <p>{this.props.type}{this.props.pairedDoc.Title}</p>
+                        <p>{StrCast(this.props.destinationDoc.title)}</p>
                     </div>
                 </div>
 
                 <div className="link-menu-item-buttons">
-                    {/* <div title="Follow Link" className="button" onPointerDown={this.onViewButtonPressed}>
-                        <FontAwesomeIcon className="fa-icon-view" icon="eye" size="sm" /></div> */}
-                    <div title="Follow Link" className="button" onPointerDown={this.followLink}>
-                        <FontAwesomeIcon className="fa-icon" icon="arrow-right" size="sm" /></div>
-                    <div title="Edit Link" className="button" onPointerDown={this.onEditButtonPressed}>
-                        <FontAwesomeIcon className="fa-icon" icon="edit" size="sm" /></div>
-                    {/* <div title="Delete Link" className="button" onPointerDown={this.onDeleteButtonPressed}>
-                        <FontAwesomeIcon className="fa-icon" icon="times" 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" onPointerDown={this.onFollowLink}><FontAwesomeIcon className="fa-icon" icon="arrow-right" size="sm" /></div>
                 </div>
             </div>
         );
diff --git a/src/client/views/nodes/LinkEditor.scss b/src/client/views/nodes/LinkEditor.scss
index 01f0cb82a..6aac39f45 100644
--- a/src/client/views/nodes/LinkEditor.scss
+++ b/src/client/views/nodes/LinkEditor.scss
@@ -1,92 +1,63 @@
 @import "../globalCssVariables";
-.edit-container {
+
+.linkEditor {
     width: 100%;
     height: auto;
-    display: flex;
-    flex-direction: column;
+    font-size: 12px; // TODO
 }
 
-.name-input {
-    margin-bottom: 10px;
-    padding: 5px;
-    font-size: 12px;
-    border: 1px solid #bababa;
+.linkEditor-back {
+    margin-bottom: 6px;
 }
 
-.description-input {
-    font-size: 11px;
-    padding: 5px;
-    margin-bottom: 10px;
-    border: 1px solid #bababa;
-}
+.linkEditor-groupsLabel {
+    display: flex;
+    justify-content: space-between;
 
-.save-button {
-    width: 50px;
-    height: 22px;
-    pointer-events: auto;
-    background-color: $dark-color;
-    color: $light-color;
-    text-transform: uppercase;
-    letter-spacing: 2px;
-    padding: 2px;
-    font-size: 10px;
-    margin: 0 auto;
-    transition: transform 0.2s;
-    text-align: center;
-    line-height: 20px;
+    button {
+        width: 20px;
+        height: 20px;
+        margin-left: 6px;
+        padding: 0;
+        font-size: 14px;
+    }
 }
 
-.save-button:hover {
-    background: $main-accent;
-    cursor: pointer;
+.linkEditor-linkedTo {
+    border-bottom: 0.5px solid $light-color-secondary; 
+    padding-bottom: 6px;
+    margin-bottom: 6px;
 }
 
-.linkEditor {
-    font-size: 12px; // TODO
-
-    .linkEditor-back {
-        // background-color: $dark-color;
-        // color: $light-color;
-        margin-bottom: 6px;
-    }
+.linkEditor-group {
+    background-color: $light-color-secondary; 
+    padding: 6px;
+    margin: 3px 0;
+    border-radius: 3px;
 
-    .linkEditor-groupsLabel {
-        display: flex;
-        justify-content: space-between;
-        button {
-            width: 20px;
-            height: 20px;
-            margin-left: 6px;
-            padding: 0;
-            font-size: 14px;
-        }
-    }
-    .linkEditor-linkedTo {
-        border-bottom: 0.5px solid $light-color-secondary; 
-        padding-bottom: 6px;
-        margin-bottom: 6px;
-    }
-    .linkEditor-group {
-        background-color: $light-color-secondary; 
-        padding: 6px;
-        margin: 3px 0;
-        border-radius: 3px;
-    }
     .linkEditor-group-row {
         display: flex;
         margin-bottom: 6px;
+
         .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% - 18px);
             height: 20px;
         }
+
         button {
             width: 20px;
             height: 20px;
@@ -97,10 +68,12 @@
     }
 }
 
+
 .linkEditor-dropdown {
     width: 100%;
     position: relative;
     z-index: 999;
+
     .linkEditor-options-wrapper {
         width: 100%;
         position: absolute;
@@ -109,12 +82,14 @@
         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: $intermediate-color;
             font-weight: bold;
@@ -126,19 +101,18 @@
     height: 20px;
     display: flex;
     justify-content: space-between;
+
     .linkEditor-groupOpts {
         width: calc(20% - 3px);
         height: 20px;
-        // margin-left: 6px;
         padding: 0;
         font-size: 10px;
-        &:first-child { // delete
-            font-size: 14px;
-        }
-        &:disabled { // delete
+
+        &:disabled {
             background-color: gray;
         }
     }
+
     .linkEditor-groupOpts button {
         width: 100%;
         height: 20px;
diff --git a/src/client/views/nodes/LinkEditor.tsx b/src/client/views/nodes/LinkEditor.tsx
index 5097d625e..484682c22 100644
--- a/src/client/views/nodes/LinkEditor.tsx
+++ b/src/client/views/nodes/LinkEditor.tsx
@@ -4,61 +4,52 @@ import { observer } from "mobx-react";
 import './LinkEditor.scss';
 import { StrCast, Cast } from "../../../new_fields/Types";
 import { Doc } from "../../../new_fields/Doc";
-import { List } from "../../../new_fields/List";
-import { listSpec } from "../../../new_fields/Schema";
-import { LinkManager, LinkUtils } from "../../util/LinkManager";
+import { LinkManager } from "../../util/LinkManager";
 import { Docs } from "../../documents/Documents";
 import { Utils } from "../../../Utils";
 import { faArrowLeft, faEllipsisV, faTable } from '@fortawesome/free-solid-svg-icons';
 import { library } from "@fortawesome/fontawesome-svg-core";
 import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
-import { string } from "prop-types";
 import { SetupDrag } from "../../util/DragManager";
 
 library.add(faArrowLeft);
 library.add(faEllipsisV);
 library.add(faTable);
 
+
+interface GroupTypesDropdownProps {
+    groupId: string;
+    groupType: string;
+    setGroup: (groupId: string, group: string) => void;
+}
 // this dropdown could be generalized
 @observer
-class LinkGroupsDropdown extends React.Component<{ groupId: string, groupType: string, setGroup: (groupId: string, group: string) => void }> {
+class GroupTypesDropdown extends React.Component<GroupTypesDropdownProps> {
     @observable private _searchTerm: string = "";
     @observable private _groupType: string = this.props.groupType;
 
-    @action
-    setSearchTerm(value: string) {
-        this._searchTerm = value;
-    }
+    @action setSearchTerm = (value: string): void => { this._searchTerm = value; };
+    @action setGroupType = (value: string): void => { this._groupType = value; };
 
     @action
-    setGroupType(value: string) {
-        this._groupType = value;
+    createGroup = (groupType: string): void => {
+        this.props.setGroup(this.props.groupId, groupType);
+        LinkManager.Instance.groupMetadataKeys.set(groupType, []);
     }
 
-    @action
-    createGroup(value: string) {
-        LinkManager.Instance.groupMetadataKeys.set(value, []);
-        this.props.setGroup(this.props.groupId, value);
-    }
+    renderOptions = (): JSX.Element[] | JSX.Element => {
+        if (this._searchTerm === "") return <></>;
 
-    renderOptions = (): JSX.Element[] => {
-        let allGroups: string[], searchTerm: string, results: string[], exactFound: boolean;
-        if (this._searchTerm !== "") {
-            allGroups = Array.from(LinkManager.Instance.groupMetadataKeys.keys());
-            searchTerm = this._searchTerm.toUpperCase();
-            results = allGroups.filter(group => group.toUpperCase().indexOf(searchTerm) > -1);
-            exactFound = results.findIndex(group => group.toUpperCase() === searchTerm) > -1;
-        } else {
-            results = [];
-            exactFound = false;
-        }
+        let allGroupTypes = Array.from(LinkManager.Instance.groupMetadataKeys.keys());
+        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 = [];
-        results.forEach(result => {
-            options.push(<div key={result} className="linkEditor-option"
-                onClick={() => { this.props.setGroup(this.props.groupId, result); this.setGroupType(result); this.setSearchTerm(""); }}>{result}</div>);
+        let options = groupOptions.map(groupType => {
+            return <div key={groupType} className="linkEditor-option"
+                onClick={() => { this.props.setGroup(this.props.groupId, groupType); this.setGroupType(groupType); this.setSearchTerm(""); }}>{groupType}</div>;
         });
 
+        // if search term does not already exist as a group type, give option to create new group type
         if (!exactFound && this._searchTerm !== "") {
             options.push(<div key={""} className="linkEditor-option"
                 onClick={() => { this.createGroup(this._searchTerm); this.setGroupType(this._searchTerm); this.setSearchTerm(""); }}>Define new "{this._searchTerm}" relationship</div>);
@@ -81,57 +72,66 @@ class LinkGroupsDropdown extends React.Component<{ groupId: string, groupType: s
 }
 
 
-
+interface LinkMetadataEditorProps {
+    groupType: string;
+    mdDoc: Doc;
+    mdKey: string;
+    mdValue: string;
+}
 @observer
-class LinkMetadataEditor extends React.Component<{ groupType: string, mdDoc: Doc, mdKey: string, mdValue: string }> {
+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
-    editMetadataKey = (value: string): void => {
-        // TODO: check that metadata doesnt already exist in group
+    setMetadataKey = (value: string): void => {
         let groupMdKeys = new Array(...LinkManager.Instance.groupMetadataKeys.get(this.props.groupType)!);
-        if (groupMdKeys) {
-            let index = groupMdKeys.indexOf(this._key);
-            if (index > -1) {
-                groupMdKeys[index] = value;
-            }
-            else {
-                console.log("OLD KEY WAS NOT FOUND", ...groupMdKeys);
-            }
+
+        // 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 => key.toUpperCase() === this._key.toUpperCase());
+        if (currIndex === -1) console.error("LinkMetadataEditor: key was not found");
+        groupMdKeys[currIndex] = value;
+
         this._key = value;
         LinkManager.Instance.groupMetadataKeys.set(this.props.groupType, groupMdKeys);
     }
 
     @action
-    editMetadataValue = (value: string): void => {
-        this.props.mdDoc[this._key] = value;
-        this._value = value;
+    setMetadataValue = (value: string): void => {
+        if (!this._keyError) {
+            this._value = value;
+            this.props.mdDoc[this._key] = value;
+        }
     }
 
     @action
     removeMetadata = (): void => {
         let groupMdKeys = new Array(...LinkManager.Instance.groupMetadataKeys.get(this.props.groupType)!);
-        if (groupMdKeys) {
-            let index = groupMdKeys.indexOf(this._key);
-            if (index > -1) {
-                groupMdKeys.splice(index, 1);
-            }
-            else {
-                console.log("OLD KEY WAS NOT FOUND", ...groupMdKeys);
-            }
-        }
-        this._key = "";
+
+        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.groupMetadataKeys.set(this.props.groupType, groupMdKeys);
+        this._key = "";
     }
 
     render() {
         return (
             <div className="linkEditor-metadata-row">
-                <input type="text" value={this._key} placeholder="key" onChange={e => this.editMetadataKey(e.target.value)}></input>:
-                <input type="text" value={this._value} placeholder="value" onChange={e => this.editMetadataValue(e.target.value)}></input>
+                <input className={this._keyError ? "linkEditor-error" : ""} type="text" value={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()}>x</button>
             </div>
         );
@@ -144,175 +144,127 @@ interface LinkEditorProps {
     linkDoc: Doc;
     showLinks: () => void;
 }
-
 @observer
 export class LinkEditor extends React.Component<LinkEditorProps> {
 
-    @observable private _groups: Map<string, Doc> = new Map(); // map of temp group id to the corresponding group doc
+    // map of temporary group id to the corresponding group doc
+    @observable private _groups: Map<string, Doc> = new Map();
 
     constructor(props: LinkEditorProps) {
         super(props);
 
         let groups = new Map<string, Doc>();
-        let groupList = (Doc.AreProtosEqual(props.sourceDoc, Cast(props.linkDoc.anchor1, Doc, new Doc))) ?
-            Cast(props.linkDoc.anchor1Groups, listSpec(Doc), []) : Cast(props.linkDoc.anchor2Groups, listSpec(Doc), []);
+        let groupList = LinkManager.Instance.getAnchorGroups(props.linkDoc, props.sourceDoc);
         groupList.forEach(groupDoc => {
-            if (groupDoc instanceof Doc) {
-                let id = Utils.GenerateGuid();
-                groups.set(id, groupDoc);
-            } else {
-                // promise doc
-            }
+            let id = Utils.GenerateGuid();
+            groups.set(id, groupDoc);
         });
         this._groups = groups;
     }
 
     @action
     addGroup = (): void => {
-        console.log("before adding", ...Array.from(this._groups.keys()));
-
         // new group only gets added if there is not already a group with type "new group"
         let index = Array.from(this._groups.values()).findIndex(groupDoc => {
             return groupDoc.type === "New Group";
         });
-        if (index === -1) {
-            // create new document for group
-            let mdDoc = Docs.TextDocument();
-            mdDoc.proto!.anchor1 = this.props.sourceDoc.title;
-            mdDoc.proto!.anchor2 = LinkUtils.findOppositeAnchor(this.props.linkDoc, this.props.sourceDoc).title;
+        if (index > -1) return;
 
-            let groupDoc = Docs.TextDocument();
-            groupDoc.proto!.type = "New Group";
-            groupDoc.proto!.metadata = mdDoc;
+        // create new metadata document for group
+        let mdDoc = Docs.TextDocument();
+        mdDoc.proto!.anchor1 = this.props.sourceDoc.title;
+        mdDoc.proto!.anchor2 = LinkManager.Instance.findOppositeAnchor(this.props.linkDoc, this.props.sourceDoc).title;
 
-            this._groups.set(Utils.GenerateGuid(), groupDoc);
+        // create new group document
+        let groupDoc = Docs.TextDocument();
+        groupDoc.proto!.type = "New Group";
+        groupDoc.proto!.metadata = mdDoc;
 
-            let linkDoc = this.props.linkDoc.proto ? this.props.linkDoc.proto : this.props.linkDoc;
-            LinkUtils.setAnchorGroups(linkDoc, this.props.sourceDoc, Array.from(this._groups.values()));
-        }
+        this._groups.set(Utils.GenerateGuid(), groupDoc);
 
-
-        // console.log("set anchor groups for", this.props.sourceDoc["title"]);
-        console.log("after adding", ...Array.from(this._groups.keys()));
+        let linkDoc = this.props.linkDoc.proto ? this.props.linkDoc.proto : this.props.linkDoc;
+        LinkManager.Instance.setAnchorGroups(linkDoc, this.props.sourceDoc, Array.from(this._groups.values()));
     }
 
     @action
     setGroupType = (groupId: string, groupType: string): void => {
-        console.log("setting for ", groupId);
-        // let linkDoc = this.props.linkDoc.proto ? this.props.linkDoc.proto : this.props.linkDoc;
         let groupDoc = this._groups.get(groupId);
         if (groupDoc) {
-            console.log("found group doc");
             groupDoc.proto!.type = groupType;
-
             this._groups.set(groupId, groupDoc);
-
-
-            LinkUtils.setAnchorGroups(this.props.linkDoc, this.props.sourceDoc, Array.from(this._groups.values()));
-            console.log("set", Array.from(this._groups.values()).length);
+            LinkManager.Instance.setAnchorGroups(this.props.linkDoc, this.props.sourceDoc, Array.from(this._groups.values()));
         }
-
-        let anchor1groups: string[] = [];
-        Cast(this.props.linkDoc.anchor1Groups, listSpec(Doc), []).forEach(doc => {
-            if (doc instanceof Doc) {
-                anchor1groups.push(StrCast(doc.proto!.type));
-            } else {
-                console.log("promise");
-            }
-        });
-        let anchor2groups: string[] = [];
-        Cast(this.props.linkDoc.anchor2Groups, listSpec(Doc), []).forEach(doc => {
-            if (doc instanceof Doc) {
-                anchor2groups.push(StrCast(doc.proto!.type));
-            } else {
-                console.log("promise");
-            }
-        });
-        console.log("groups for anchors; anchor1: [", ...anchor1groups, "] ; anchor2: [", ...anchor2groups, "]");
     }
 
-    removeGroupFromLink = (groupId: string, groupType: string) => {
-        // this._groups.delete(groupId);
+    removeGroupFromLink = (groupId: string, groupType: string): void => {
         let groupDoc = this._groups.get(groupId);
-        if (groupDoc) {
-            LinkUtils.removeGroupFromAnchor(this.props.linkDoc, this.props.sourceDoc, groupType);
-            this._groups.delete(groupId);
-        }
-        // LinkUtils.setAnchorGroups(this.props.linkDoc, this.props.sourceDoc, Array.from(this._groups.values()));
-        console.log("\nremoved", groupId, "remaining", ...Array.from(this._groups.keys()), "\n");
+        if (!groupDoc) console.error("LinkEditor: group not found");
+        LinkManager.Instance.removeGroupFromAnchor(this.props.linkDoc, this.props.sourceDoc, groupType);
+        this._groups.delete(groupId);
     }
 
-    deleteGroup = (groupId: string, groupType: string) => {
+    deleteGroup = (groupId: string, groupType: string): void => {
         let groupDoc = this._groups.get(groupId);
-        if (groupDoc) {
-            LinkManager.Instance.deleteGroup(groupType);
-            this._groups.delete(groupId);
-        }
+        if (!groupDoc) console.error("LinkEditor: group not found");
+        LinkManager.Instance.deleteGroup(groupType);
+        this._groups.delete(groupId);
     }
 
-    copyGroup = (groupId: string, groupType: string) => {
-        let oppAnchor = LinkUtils.findOppositeAnchor(this.props.linkDoc, this.props.sourceDoc);
-        let groupList = (Doc.AreProtosEqual(oppAnchor, Cast(this.props.linkDoc.anchor1, Doc, new Doc))) ?
-            Cast(this.props.linkDoc.anchor1Groups, listSpec(Doc), []) : Cast(this.props.linkDoc.anchor2Groups, listSpec(Doc), []);
-        // if group already exists on opposite anchor, copy value
-        let index = groupList.findIndex(groupDoc => {
-            if (groupDoc instanceof Doc) {
-                return StrCast(groupDoc.type) === groupType;
-            } else {
-                return false;
-            }
-        });
-        // TODO: clean
-        // if (index > 0) {
-        //     let thisGroupDoc = this._groups.get(groupId);
-        //     let oppGroupDoc = groupList[index];
-        //     let keys = LinkManager.Instance.allGroups.get(groupType);
-        //     if (keys) {
-        //         keys.forEach(key => {
-        //             if (thisGroupDoc && oppGroupDoc instanceof Doc) { // TODO: clean
-        //                 let val = thisGroupDoc[key] === undefined ? "" : StrCast(thisGroupDoc[key]);
-        //                 oppGroupDoc[key] = val;
-        //             }
-        //             //     mdDoc[key] === undefined) ? "" : StrCast(mdDoc[key])
-        //             // oppGroupDoc[key] = thisGroupDoc[key];
-        //         })
-        //     }
-        //     // LinkUtils.setAnchorGroups(this.props.linkDoc, oppAnchor, [oppGroupDoc]);
-        // } else {
-        let thisGroupDoc = this._groups.get(groupId);
-        let thisMdDoc = Cast(thisGroupDoc!.metadata, Doc, new Doc);
-        let newGroupDoc = Docs.TextDocument();
-        let newMdDoc = Docs.TextDocument();
-        newMdDoc.proto!.anchor1 = StrCast(thisMdDoc.anchor2);
-        newMdDoc.proto!.anchor2 = StrCast(thisMdDoc.anchor1);
+    copyGroup = (groupId: string, groupType: string): void => {
+        let sourceGroupDoc = this._groups.get(groupId);
+        let sourceMdDoc = Cast(sourceGroupDoc!.metadata, Doc, new Doc);
+        let destDoc = LinkManager.Instance.findOppositeAnchor(this.props.linkDoc, this.props.sourceDoc);
+        let destGroupList = LinkManager.Instance.getAnchorGroups(this.props.linkDoc, destDoc);
         let keys = LinkManager.Instance.groupMetadataKeys.get(groupType);
+
+        // create new metadata doc with copied kvp
+        let destMdDoc = Docs.TextDocument();
+        destMdDoc.proto!.anchor1 = StrCast(sourceMdDoc.anchor2);
+        destMdDoc.proto!.anchor2 = StrCast(sourceMdDoc.anchor1);
         if (keys) {
             keys.forEach(key => {
-                if (thisGroupDoc) { // TODO: clean
-                    let val = thisMdDoc[key] === undefined ? "" : StrCast(thisMdDoc[key]);
-                    newMdDoc[key] = val;
-                }
-                //     mdDoc[key] === undefined) ? "" : StrCast(mdDoc[key])
-                // oppGroupDoc[key] = thisGroupDoc[key];
+                let val = sourceMdDoc[key] === undefined ? "" : StrCast(sourceMdDoc[key]);
+                destMdDoc[key] = val;
             });
         }
-        newGroupDoc.proto!.type = groupType;
-        newGroupDoc.proto!.metadata = newMdDoc;
 
-        LinkUtils.setAnchorGroups(this.props.linkDoc, oppAnchor, [newGroupDoc]); // TODO: fix to append to list
-        // }
+        // create new group doc with new metadata doc
+        let destGroupDoc = Docs.TextDocument();
+        destGroupDoc.proto!.type = groupType;
+        destGroupDoc.proto!.metadata = destMdDoc;
 
-        // else create group on opposite anchor
+        // if group does not already exist on opposite anchor, create group doc
+        let index = destGroupList.findIndex(groupDoc => { StrCast(groupDoc.type).toUpperCase() === groupType.toUpperCase(); });
+        if (index > -1) {
+            destGroupList[index] = destGroupDoc;
+        } else {
+            destGroupList.push(destGroupDoc);
+        }
+
+        LinkManager.Instance.setAnchorGroups(this.props.linkDoc, destDoc, destGroupList);
+    }
+
+    viewGroupAsTable = (groupId: string, groupType: string): JSX.Element => {
+        let keys = LinkManager.Instance.groupMetadataKeys.get(groupType);
+        let groupDoc = this._groups.get(groupId);
+        if (keys && groupDoc) {
+            let docs: Doc[] = LinkManager.Instance.findAllMetadataDocsInGroup(groupType);
+            let createTable = action(() => Docs.SchemaDocument(["anchor1", "anchor2", ...keys!], docs, { width: 200, height: 200, title: groupType + " table" }));
+            let ref = React.createRef<HTMLDivElement>();
+            return <div className="linkEditor-groupOpts" ref={ref}><button onPointerDown={SetupDrag(ref, createTable)}><FontAwesomeIcon icon="table" size="sm" /></button></div>;
+        } else {
+            return <button className="linkEditor-groupOpts" disabled><FontAwesomeIcon icon="table" size="sm" /></button>;
+        }
     }
 
-    renderGroup(groupId: string, groupDoc: Doc) {
+    renderGroup = (groupId: string, groupDoc: Doc): JSX.Element => {
         let type = StrCast(groupDoc.type);
         if ((type && LinkManager.Instance.groupMetadataKeys.get(type)) || type === "New Group") {
             return (
                 <div key={groupId} className="linkEditor-group">
                     <div className="linkEditor-group-row">
                         <p className="linkEditor-group-row-label">type:</p>
-                        <LinkGroupsDropdown groupId={groupId} groupType={StrCast(groupDoc.proto!.type)} setGroup={this.setGroupType} />
+                        <GroupTypesDropdown groupId={groupId} groupType={StrCast(groupDoc.proto!.type)} setGroup={this.setGroupType} />
                     </div>
                     {this.renderMetadata(groupId)}
                     <div className="linkEditor-group-buttons">
@@ -330,35 +282,20 @@ export class LinkEditor extends React.Component<LinkEditorProps> {
         }
     }
 
-    viewGroupAsTable(groupId: string, groupType: string) {
-        let keys = LinkManager.Instance.groupMetadataKeys.get(groupType);
-        let groupDoc = this._groups.get(groupId);
-        if (keys && groupDoc) {
-            console.log("keys:", ...keys);
-            let docs: Doc[] = LinkManager.Instance.findMetadataInGroup(groupType);
-            let createTable = action(() => Docs.SchemaDocument(["anchor1", "anchor2", ...keys!], docs, { width: 200, height: 200, title: groupType + " table" }));
-            let ref = React.createRef<HTMLDivElement>();
-            return <div className="linkEditor-groupOpts" ref={ref}><button onPointerDown={SetupDrag(ref, createTable)}><FontAwesomeIcon icon="table" size="sm" /></button></div>;
-        } else {
-            return <button className="linkEditor-groupOpts" disabled><FontAwesomeIcon icon="table" size="sm" /></button>;
-        }
-    }
-
 
     @action
     addMetadata = (groupType: string): void => {
         let mdKeys = LinkManager.Instance.groupMetadataKeys.get(groupType);
         if (mdKeys) {
-            if (mdKeys.indexOf("new key") === -1) {
-                mdKeys.push("new key");
-            }
+            // 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");
         } else {
             mdKeys = ["new key"];
         }
         LinkManager.Instance.groupMetadataKeys.set(groupType, mdKeys);
     }
 
-    renderMetadata(groupId: string) {
+    renderMetadata = (groupId: string): JSX.Element[] => {
         let metadata: Array<JSX.Element> = [];
         let groupDoc = this._groups.get(groupId);
         if (groupDoc) {
@@ -377,7 +314,7 @@ export class LinkEditor extends React.Component<LinkEditorProps> {
     }
 
     render() {
-        let destination = LinkUtils.findOppositeAnchor(this.props.linkDoc, this.props.sourceDoc);
+        let destination = LinkManager.Instance.findOppositeAnchor(this.props.linkDoc, this.props.sourceDoc);
 
         let groups: Array<JSX.Element> = [];
         this._groups.forEach((groupDoc, groupId) => {
diff --git a/src/client/views/nodes/LinkMenu.scss b/src/client/views/nodes/LinkMenu.scss
index 7e031b897..09a830c9e 100644
--- a/src/client/views/nodes/LinkMenu.scss
+++ b/src/client/views/nodes/LinkMenu.scss
@@ -1,84 +1,14 @@
 @import "../globalCssVariables";
 
-#linkMenu-container {
+.linkMenu {
     width: 100%;
     height: auto;
-    display: flex;
-    flex-direction: column;
 }
 
-#linkMenu-searchBar {
-    width: 100%;
-    padding: 5px;
-    margin-bottom: 10px;
-    font-size: 12px;
-    border: 1px solid #bababa;
-}
-
-#linkMenu-list {
-    margin-top: 5px;
-    width: 100%;
-    height: 100px;
+.linkMenu-list {
+    max-height: 200px;
     overflow-y: scroll;
 }
 
-.link-menu-group {
-    .link-menu-item {
-        border-top: 0.5px solid $light-color-secondary; 
-        padding: 6px;
-        position: relative;
-        display: flex;
-
-        .link-menu-item-content {
-            width: 100%;
-        }
 
-        &:last-child {
-            border-bottom: 0.5px solid $light-color-secondary;
-        }
-        &:hover .link-menu-item-buttons {
-            display: flex;
-        }
-        &:hover .link-menu-item-content {
-            width: calc(100% - 42px);
-        }
-    }
-    .link-menu-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/nodes/LinkMenu.tsx b/src/client/views/nodes/LinkMenu.tsx
index 47bd6c3eb..ebca54c92 100644
--- a/src/client/views/nodes/LinkMenu.tsx
+++ b/src/client/views/nodes/LinkMenu.tsx
@@ -6,12 +6,8 @@ import { LinkEditor } from "./LinkEditor";
 import './LinkMenu.scss';
 import React = require("react");
 import { Doc, DocListCast } from "../../../new_fields/Doc";
-import { Cast, FieldValue, StrCast } from "../../../new_fields/Types";
 import { Id } from "../../../new_fields/FieldSymbols";
 import { LinkManager, LinkUtils } from "../../util/LinkManager";
-import { number, string } from "prop-types";
-import { listSpec } from "../../../new_fields/Schema";
-import { Utils } from "../../../Utils";
 
 interface Props {
     docView: DocumentView;
@@ -23,57 +19,42 @@ export class LinkMenu extends React.Component<Props> {
 
     @observable private _editingLink?: Doc;
 
-    // renderLinkItems(links: Doc[], key: string, type: string) {
-    //     return links.map(link => {
-    //         let doc = FieldValue(Cast(link[key], Doc));
-    //         if (doc) {
-    //             return <LinkBox key={doc[Id]} linkDoc={link} linkName={StrCast(link.title)} pairedDoc={doc} showEditor={action(() => this._editingLink = link)} type={type} />;
-    //         }
-    //     });
-    // }
-
-    renderGroup(links: Doc[]) {
+    renderGroup = (group: Doc[]): Array<JSX.Element> => {
         let source = this.props.docView.Document;
-        return links.map(link => {
-            let destination = LinkUtils.findOppositeAnchor(link, source);
-            let doc = FieldValue(Cast(destination, Doc));
-            if (doc) {
-                return <LinkBox key={doc[Id] + source[Id]} linkDoc={link} linkName={StrCast(destination.title)} pairedDoc={doc} showEditor={action(() => this._editingLink = link)} type={""} />;
-            }
+        return group.map(linkDoc => {
+            let destination = LinkUtils.findOppositeAnchor(linkDoc, source);
+            return <LinkBox key={destination[Id] + source[Id]} linkDoc={linkDoc} sourceDoc={source} destinationDoc={destination} showEditor={action(() => this._editingLink = linkDoc)} />;
         });
     }
 
-    renderLinks = (links: Map<string, Array<Doc>>): Array<JSX.Element> => {
+    renderAllGroups = (groups: Map<string, Array<Doc>>): Array<JSX.Element> => {
         let linkItems: Array<JSX.Element> = [];
-
-        links.forEach((links, group) => {
+        groups.forEach((group, groupType) => {
             linkItems.push(
-                <div key={group} className="link-menu-group">
-                    <p className="link-menu-group-name">{group}:</p>
+                <div key={groupType} className="link-menu-group">
+                    <p className="link-menu-group-name">{groupType}:</p>
                     <div className="link-menu-group-wrapper">
-                        {this.renderGroup(links)}
+                        {this.renderGroup(group)}
                     </div>
                 </div>
-            )
+            );
         });
 
-        if (linkItems.length === 0) {
-            linkItems.push(<p key="">no links have been created yet</p>);
-        }
+        // source doc has no links
+        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 related: Map<string, Doc[]> = LinkManager.Instance.findRelatedGroupedLinks(this.props.docView.props.Document);
+        let sourceDoc = this.props.docView.props.Document;
+        let groups: Map<string, Doc[]> = LinkManager.Instance.findRelatedGroupedLinks(sourceDoc);
         if (this._editingLink === undefined) {
             return (
-                <div id="linkMenu-container">
+                <div className="linkMenu">
                     {/* <input id="linkMenu-searchBar" type="text" placeholder="Search..."></input> */}
-                    <div id="linkMenu-list">
-                        {/* {this.renderLinkItems(linkTo, "linkedTo", "Destination: ")}
-                        {this.renderLinkItems(linkFrom, "linkedFrom", "Source: ")} */}
-                        {this.renderLinks(related)}
+                    <div className="linkMenu-list">
+                        {this.renderAllGroups(groups)}
                     </div>
                 </div>
             );
@@ -82,6 +63,5 @@ export class LinkMenu extends React.Component<Props> {
                 <LinkEditor sourceDoc={this.props.docView.props.Document} linkDoc={this._editingLink} showLinks={action(() => this._editingLink = undefined)}></LinkEditor>
             );
         }
-
     }
 }
\ No newline at end of file
-- 
cgit v1.2.3-70-g09d2


From 1afb8d18e0f63e7e9ab05ccf79f5f34533fdec05 Mon Sep 17 00:00:00 2001
From: Fawn <fangrui_tong@brown.edu>
Date: Fri, 14 Jun 2019 16:38:25 -0400
Subject: links can be expanded in menu to view metadata

---
 src/client/views/nodes/LinkBox.scss | 30 ++++++++++++++++------
 src/client/views/nodes/LinkBox.tsx  | 51 +++++++++++++++++++++++++++----------
 src/client/views/nodes/LinkMenu.tsx |  8 +++---
 3 files changed, 64 insertions(+), 25 deletions(-)

(limited to 'src/client/views/nodes/LinkBox.tsx')

diff --git a/src/client/views/nodes/LinkBox.scss b/src/client/views/nodes/LinkBox.scss
index 08fefaf4d..77462f611 100644
--- a/src/client/views/nodes/LinkBox.scss
+++ b/src/client/views/nodes/LinkBox.scss
@@ -1,29 +1,43 @@
 @import "../globalCssVariables";
-.link-menu-item {
-    border-top: 0.5px solid $light-color-secondary; 
+.linkMenu-item {
+    border-top: 0.5px solid $main-accent; 
     padding: 6px;
     position: relative;
     display: flex;
     font-size: 12px;
 
-    .link-menu-item-content {
+    .linkMenu-item-content {
         width: 100%;
     }
+    
+    .link-metadata {
+        margin-top: 6px;
+        padding: 3px;
+        border-top: 0.5px solid $light-color-secondary;
+        .link-metadata-row {
+            margin-left: 6px;
+        }
+    }
 
     &:last-child {
-        border-bottom: 0.5px solid $light-color-secondary;
+        border-bottom: 0.5px solid $main-accent;
     }
     &:hover {
-        .link-menu-item-buttons {
+        .linkMenu-item-buttons {
             display: flex;
         }
-        .link-menu-item-content {
-            width: calc(100% - 42px);
+        .linkMenu-item-content {
+            &.expand-two {
+                width: calc(100% - 46px);
+            }
+            &.expand-three {
+                width: calc(100% - 78px);
+            }
         }
     }
 }
 
-.link-menu-item-buttons {
+.linkMenu-item-buttons {
     display: none;
     position: absolute;
     top: 50%;
diff --git a/src/client/views/nodes/LinkBox.tsx b/src/client/views/nodes/LinkBox.tsx
index 1a7cce4a3..9fbe83126 100644
--- a/src/client/views/nodes/LinkBox.tsx
+++ b/src/client/views/nodes/LinkBox.tsx
@@ -1,5 +1,5 @@
 import { library } from '@fortawesome/fontawesome-svg-core';
-import { faEdit, faEye, faTimes, faArrowRight } from '@fortawesome/free-solid-svg-icons';
+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";
@@ -7,15 +7,14 @@ import { undoBatch } from "../../util/UndoManager";
 import './LinkBox.scss';
 import React = require("react");
 import { Doc } from '../../../new_fields/Doc';
-import { StrCast } from '../../../new_fields/Types';
+import { StrCast, Cast } from '../../../new_fields/Types';
+import { observable, action } from 'mobx';
+import { LinkManager } from '../../util/LinkManager';
+library.add(faEye, faEdit, faTimes, faArrowRight, faChevronDown, faChevronUp);
 
 
-library.add(faEye);
-library.add(faEdit);
-library.add(faTimes);
-library.add(faArrowRight);
-
 interface Props {
+    groupType: string;
     linkDoc: Doc;
     sourceDoc: Doc;
     destinationDoc: Doc;
@@ -24,6 +23,8 @@ interface Props {
 
 @observer
 export class LinkBox extends React.Component<Props> {
+    @observable private _showMore: boolean = false;
+    @action toggleShowMore() { this._showMore = !this._showMore; }
 
     @undoBatch
     onFollowLink = async (e: React.PointerEvent): Promise<void> => {
@@ -36,19 +37,43 @@ export class LinkBox extends React.Component<Props> {
         this.props.showEditor();
     }
 
+    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, new Doc);
+            let keys = LinkManager.Instance.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>);
+    }
+
     render() {
+
+        let keys = LinkManager.Instance.groupMetadataKeys.get(this.props.groupType);
+        let canExpand = keys ? keys.length > 0 : false;
+
         return (
-            <div className="link-menu-item">
-                <div className="link-menu-item-content">
+            <div className="linkMenu-item">
+                <div className={canExpand ? "linkMenu-item-content expand-three" : "linkMenu-item-content expand-two"}>
                     <div className="link-name">
                         <p>{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" onPointerDown={this.onFollowLink}><FontAwesomeIcon className="fa-icon" icon="arrow-right" size="sm" /></div>
+                        </div>
                     </div>
+                    {this._showMore ? this.renderMetadata() : <></>}
                 </div>
 
-                <div className="link-menu-item-buttons">
-                    <div title="Edit link" className="button" onPointerDown={this.onEdit}><FontAwesomeIcon className="fa-icon" icon="edit" size="sm" /></div>
-                    <div title="Follow link" className="button" onPointerDown={this.onFollowLink}><FontAwesomeIcon className="fa-icon" icon="arrow-right" size="sm" /></div>
-                </div>
             </div>
         );
     }
diff --git a/src/client/views/nodes/LinkMenu.tsx b/src/client/views/nodes/LinkMenu.tsx
index 2fcbd25fa..aef56df67 100644
--- a/src/client/views/nodes/LinkMenu.tsx
+++ b/src/client/views/nodes/LinkMenu.tsx
@@ -19,11 +19,11 @@ export class LinkMenu extends React.Component<Props> {
 
     @observable private _editingLink?: Doc;
 
-    renderGroup = (group: Doc[]): Array<JSX.Element> => {
+    renderGroup = (group: Doc[], groupType: string): Array<JSX.Element> => {
         let source = this.props.docView.Document;
         return group.map(linkDoc => {
             let destination = LinkManager.Instance.findOppositeAnchor(linkDoc, source);
-            return <LinkBox key={destination[Id] + source[Id]} linkDoc={linkDoc} sourceDoc={source} destinationDoc={destination} showEditor={action(() => this._editingLink = linkDoc)} />;
+            return <LinkBox key={destination[Id] + source[Id]} groupType={groupType} linkDoc={linkDoc} sourceDoc={source} destinationDoc={destination} showEditor={action(() => this._editingLink = linkDoc)} />;
         });
     }
 
@@ -34,13 +34,13 @@ export class LinkMenu extends React.Component<Props> {
                 <div key={groupType} className="link-menu-group">
                     <p className="link-menu-group-name">{groupType}:</p>
                     <div className="link-menu-group-wrapper">
-                        {this.renderGroup(group)}
+                        {this.renderGroup(group, groupType)}
                     </div>
                 </div>
             );
         });
 
-        // source doc has no links
+        // 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;
-- 
cgit v1.2.3-70-g09d2


From d3e734140c52e69f3d07f1ebe145e554a062addb Mon Sep 17 00:00:00 2001
From: Fawn <fangrui_tong@brown.edu>
Date: Fri, 14 Jun 2019 17:08:05 -0400
Subject: can drag follow button on link to move anchor

---
 src/client/views/nodes/LinkBox.tsx | 31 +++++++++++++++++++++++++++++--
 1 file changed, 29 insertions(+), 2 deletions(-)

(limited to 'src/client/views/nodes/LinkBox.tsx')

diff --git a/src/client/views/nodes/LinkBox.tsx b/src/client/views/nodes/LinkBox.tsx
index 9fbe83126..09df1eac5 100644
--- a/src/client/views/nodes/LinkBox.tsx
+++ b/src/client/views/nodes/LinkBox.tsx
@@ -10,6 +10,8 @@ import { Doc } from '../../../new_fields/Doc';
 import { StrCast, Cast } from '../../../new_fields/Types';
 import { observable, action } from 'mobx';
 import { LinkManager } from '../../util/LinkManager';
+import { DragLinksAsDocuments } from '../../util/DragManager';
+import { SelectionManager } from '../../util/SelectionManager';
 library.add(faEye, faEdit, faTimes, faArrowRight, faChevronDown, faChevronUp);
 
 
@@ -23,6 +25,7 @@ interface Props {
 
 @observer
 export class LinkBox extends React.Component<Props> {
+    private _drag = React.createRef<HTMLDivElement>();
     @observable private _showMore: boolean = false;
     @action toggleShowMore() { this._showMore = !this._showMore; }
 
@@ -54,6 +57,30 @@ export class LinkBox extends React.Component<Props> {
         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);
+
+            DragLinksAsDocuments(this._drag.current, e.x, e.y, SelectionManager.SelectedDocuments()[0].props.Document);
+        }
+        e.stopPropagation();
+    }
+
     render() {
 
         let keys = LinkManager.Instance.groupMetadataKeys.get(this.props.groupType);
@@ -68,13 +95,13 @@ export class LinkBox extends React.Component<Props> {
                             {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" onPointerDown={this.onFollowLink}><FontAwesomeIcon className="fa-icon" icon="arrow-right" size="sm" /></div>
+                            <div title="Follow link" className="button" ref={this._drag} onPointerDown={this.onLinkButtonDown} onPointerUp={this.onFollowLink}><FontAwesomeIcon className="fa-icon" icon="arrow-right" size="sm" /></div>
                         </div>
                     </div>
                     {this._showMore ? this.renderMetadata() : <></>}
                 </div>
 
-            </div>
+            </div >
         );
     }
 }
\ No newline at end of file
-- 
cgit v1.2.3-70-g09d2


From 70eaadb2773ae78f99d856c4986b8f27ebbb36ad Mon Sep 17 00:00:00 2001
From: Fawn <fangrui_tong@brown.edu>
Date: Mon, 17 Jun 2019 11:32:38 -0400
Subject: single link can be dragged from link menu

---
 src/client/util/DragManager.ts     | 12 ++++++++++++
 src/client/views/nodes/LinkBox.tsx |  4 ++--
 2 files changed, 14 insertions(+), 2 deletions(-)

(limited to 'src/client/views/nodes/LinkBox.tsx')

diff --git a/src/client/util/DragManager.ts b/src/client/util/DragManager.ts
index b32c25b0a..82d30e0e6 100644
--- a/src/client/util/DragManager.ts
+++ b/src/client/util/DragManager.ts
@@ -43,6 +43,18 @@ export function SetupDrag(_reference: React.RefObject<HTMLElement>, docFunc: ()
     return onItemDown;
 }
 
+export async function DragLinkAsDocument(dragEle: HTMLElement, x: number, y: number, linkDoc: Doc, sourceDoc: Doc) {
+    let draggeddoc = LinkManager.Instance.findOppositeAnchor(linkDoc, sourceDoc);
+    let moddrag = await Cast(draggeddoc.annotationOn, Doc);
+    let dragData = new DragManager.DocumentDragData(moddrag ? [moddrag] : [draggeddoc]);
+    DragManager.StartDocumentDrag([dragEle], dragData, x, y, {
+        handlers: {
+            dragComplete: action(emptyFunction),
+        },
+        hideSource: false
+    });
+}
+
 export async function DragLinksAsDocuments(dragEle: HTMLElement, x: number, y: number, sourceDoc: Doc) {
     let srcTarg = sourceDoc.proto;
     let draggedDocs: Doc[] = [];
diff --git a/src/client/views/nodes/LinkBox.tsx b/src/client/views/nodes/LinkBox.tsx
index 09df1eac5..8d07547ed 100644
--- a/src/client/views/nodes/LinkBox.tsx
+++ b/src/client/views/nodes/LinkBox.tsx
@@ -10,7 +10,7 @@ import { Doc } from '../../../new_fields/Doc';
 import { StrCast, Cast } from '../../../new_fields/Types';
 import { observable, action } from 'mobx';
 import { LinkManager } from '../../util/LinkManager';
-import { DragLinksAsDocuments } from '../../util/DragManager';
+import { DragLinksAsDocuments, DragLinkAsDocument } from '../../util/DragManager';
 import { SelectionManager } from '../../util/SelectionManager';
 library.add(faEye, faEdit, faTimes, faArrowRight, faChevronDown, faChevronUp);
 
@@ -76,7 +76,7 @@ export class LinkBox extends React.Component<Props> {
             document.removeEventListener("pointermove", this.onLinkButtonMoved);
             document.removeEventListener("pointerup", this.onLinkButtonUp);
 
-            DragLinksAsDocuments(this._drag.current, e.x, e.y, SelectionManager.SelectedDocuments()[0].props.Document);
+            DragLinkAsDocument(this._drag.current, e.x, e.y, this.props.linkDoc, this.props.sourceDoc);
         }
         e.stopPropagation();
     }
-- 
cgit v1.2.3-70-g09d2


From c5e401cb0a7fec2279ceecbc8d1429dcdd2f04b9 Mon Sep 17 00:00:00 2001
From: Fawn <fangrui_tong@brown.edu>
Date: Wed, 19 Jun 2019 22:27:21 -0400
Subject: buttons on cut links functional except for when dragged from link
 menu

---
 src/client/documents/Documents.ts                  |  47 +++++++--
 src/client/util/DragManager.ts                     |   7 ++
 src/client/util/LinkManager.ts                     |  15 ++-
 .../CollectionFreeFormLinkView.scss                |  59 ++++++++---
 .../CollectionFreeFormLinkView.tsx                 |   2 +-
 .../CollectionFreeFormLinkWithProxyView.tsx        | 109 +++++++++++++++++----
 .../CollectionFreeFormLinksView.tsx                |  42 +++++---
 src/client/views/nodes/DocumentContentsView.tsx    |   3 +-
 src/client/views/nodes/DocumentView.tsx            |  14 ++-
 src/client/views/nodes/FieldView.tsx               |   6 ++
 src/client/views/nodes/LinkBox.scss                |  76 --------------
 src/client/views/nodes/LinkBox.tsx                 | 107 --------------------
 src/client/views/nodes/LinkButtonBox.scss          |  18 ++++
 src/client/views/nodes/LinkButtonBox.tsx           |  63 ++++++++++++
 src/client/views/nodes/LinkMenu.tsx                |   4 +-
 src/client/views/nodes/LinkMenuItem.scss           |  76 ++++++++++++++
 src/client/views/nodes/LinkMenuItem.tsx            | 107 ++++++++++++++++++++
 src/new_fields/LinkButtonField.ts                  |  35 +++++++
 18 files changed, 548 insertions(+), 242 deletions(-)
 delete mode 100644 src/client/views/nodes/LinkBox.scss
 delete mode 100644 src/client/views/nodes/LinkBox.tsx
 create mode 100644 src/client/views/nodes/LinkButtonBox.scss
 create mode 100644 src/client/views/nodes/LinkButtonBox.tsx
 create mode 100644 src/client/views/nodes/LinkMenuItem.scss
 create mode 100644 src/client/views/nodes/LinkMenuItem.tsx
 create mode 100644 src/new_fields/LinkButtonField.ts

(limited to 'src/client/views/nodes/LinkBox.tsx')

diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts
index 79ba433c8..4d4fa2645 100644
--- a/src/client/documents/Documents.ts
+++ b/src/client/documents/Documents.ts
@@ -26,7 +26,7 @@ import { OmitKeys } from "../../Utils";
 import { ImageField, VideoField, AudioField, PdfField, WebField } from "../../new_fields/URLField";
 import { HtmlField } from "../../new_fields/HtmlField";
 import { List } from "../../new_fields/List";
-import { Cast, NumCast } from "../../new_fields/Types";
+import { Cast, NumCast, StrCast } from "../../new_fields/Types";
 import { IconField } from "../../new_fields/IconField";
 import { listSpec } from "../../new_fields/Schema";
 import { DocServer } from "../DocServer";
@@ -36,6 +36,10 @@ import { DateField } from "../../new_fields/DateField";
 import { UndoManager } from "../util/UndoManager";
 import { RouteStore } from "../../server/RouteStore";
 import { LinkManager } from "../util/LinkManager";
+import { LinkButtonBox } from "../views/nodes/LinkButtonBox";
+import { LinkButtonField, LinkButtonData } from "../../new_fields/LinkButtonField";
+import { DocumentManager } from "../util/DocumentManager";
+import { Id } from "../../new_fields/FieldSymbols";
 var requestImageSize = require('request-image-size');
 var path = require('path');
 
@@ -102,14 +106,32 @@ export namespace DocUtils {
 
             linkDocProto.context = targetContext;
 
-            let proxy1 = Docs.TextDocument({ width: 300, height: 100, borderRounding: 0 });
-            let proxy1Proto = Doc.GetProto(proxy1);
-            let proxy2 = Docs.TextDocument({ width: 300, height: 100, borderRounding: 0 });
-            let proxy2Proto = Doc.GetProto(proxy2);
+            let sourceViews = DocumentManager.Instance.getDocumentViews(source);
+            let targetViews = DocumentManager.Instance.getDocumentViews(target);
+            sourceViews.forEach(sv => {
+                targetViews.forEach(tv => {
 
-            linkDocProto.proxy1 = proxy1; // src: 1 targ: 2
-            linkDocProto.proxy2 = proxy2; // src: 2 targ: 1
+                    // TODO: do only for when diff contexts
+                    let proxy1 = Docs.LinkButtonDocument(
+                        { sourceViewId: StrCast(sv.props.Document[Id]), targetViewId: StrCast(tv.props.Document[Id]) },
+                        { width: 200, height: 100, borderRounding: 0 });
+                    let proxy1Proto = Doc.GetProto(proxy1);
+                    proxy1Proto.sourceViewId = StrCast(sv.props.Document[Id]);
+                    proxy1Proto.targetViewId = StrCast(tv.props.Document[Id]);
+                    proxy1Proto.isLinkButton = true;
 
+                    let proxy2 = Docs.LinkButtonDocument(
+                        { sourceViewId: StrCast(tv.props.Document[Id]), targetViewId: StrCast(sv.props.Document[Id]) },
+                        { width: 200, height: 100, borderRounding: 0 });
+                    let proxy2Proto = Doc.GetProto(proxy2);
+                    proxy2Proto.sourceViewId = StrCast(tv.props.Document[Id]);
+                    proxy2Proto.targetViewId = StrCast(sv.props.Document[Id]);
+                    proxy2Proto.isLinkButton = true;
+
+                    LinkManager.Instance.linkProxies.push(proxy1);
+                    LinkManager.Instance.linkProxies.push(proxy2);
+                });
+            });
 
             LinkManager.Instance.allLinks.push(linkDoc);
 
@@ -131,6 +153,7 @@ export namespace Docs {
     let audioProto: Doc;
     let pdfProto: Doc;
     let iconProto: Doc;
+    let linkProto: Doc;
     const textProtoId = "textProto";
     const histoProtoId = "histoProto";
     const pdfProtoId = "pdfProto";
@@ -141,6 +164,7 @@ export namespace Docs {
     const videoProtoId = "videoProto";
     const audioProtoId = "audioProto";
     const iconProtoId = "iconProto";
+    const linkProtoId = "linkProto";
 
     export function initProtos(): Promise<void> {
         return DocServer.GetRefFields([textProtoId, histoProtoId, collProtoId, imageProtoId, webProtoId, kvpProtoId, videoProtoId, audioProtoId, pdfProtoId, iconProtoId]).then(fields => {
@@ -154,6 +178,7 @@ export namespace Docs {
             audioProto = fields[audioProtoId] as Doc || CreateAudioPrototype();
             pdfProto = fields[pdfProtoId] as Doc || CreatePdfPrototype();
             iconProto = fields[iconProtoId] as Doc || CreateIconPrototype();
+            linkProto = fields[linkProtoId] as Doc || CreateLinkPrototype();
         });
     }
 
@@ -186,6 +211,11 @@ export namespace Docs {
             { x: 0, y: 0, width: Number(MINIMIZED_ICON_SIZE), height: Number(MINIMIZED_ICON_SIZE) });
         return iconProto;
     }
+    function CreateLinkPrototype(): Doc {
+        let linkProto = setupPrototypeOptions(linkProtoId, "LINK_PROTO", LinkButtonBox.LayoutString(),
+            { x: 0, y: 0, width: 300 });
+        return linkProto;
+    }
     function CreateTextPrototype(): Doc {
         let textProto = setupPrototypeOptions(textProtoId, "TEXT_PROTO", FormattedTextBox.LayoutString(),
             { x: 0, y: 0, width: 300, backgroundColor: "#f1efeb" });
@@ -272,6 +302,9 @@ export namespace Docs {
     export function IconDocument(icon: string, options: DocumentOptions = {}) {
         return CreateInstance(iconProto, new IconField(icon), options);
     }
+    export function LinkButtonDocument(data: LinkButtonData, options: DocumentOptions = {}) {
+        return CreateInstance(linkProto, new LinkButtonField(data), options);
+    }
     export function PdfDocument(url: string, options: DocumentOptions = {}) {
         return CreateInstance(pdfProto, new PdfField(new URL(url)), options);
     }
diff --git a/src/client/util/DragManager.ts b/src/client/util/DragManager.ts
index 4787ac40f..be00778e7 100644
--- a/src/client/util/DragManager.ts
+++ b/src/client/util/DragManager.ts
@@ -7,6 +7,8 @@ import * as globalCssVariables from "../views/globalCssVariables.scss";
 import { LinkManager } from "./LinkManager";
 import { URLField } from "../../new_fields/URLField";
 import { SelectionManager } from "./SelectionManager";
+import { Docs } from "../documents/Documents";
+import { DocumentManager } from "./DocumentManager";
 
 export type dropActionType = "alias" | "copy" | undefined;
 export function SetupDrag(_reference: React.RefObject<HTMLElement>, docFunc: () => Doc | Promise<Doc>, moveFunc?: DragManager.MoveFunction, dropAction?: dropActionType, options?: any, dontHideOnDrop?: boolean) {
@@ -223,6 +225,11 @@ export namespace DragManager {
         StartDrag([ele], dragData, downX, downY, options);
     }
 
+    export function StartLinkProxyDrag(ele: HTMLElement, dragData: DocumentDragData, downX: number, downY: number, options?: DragOptions) {
+        runInAction(() => StartDragFunctions.map(func => func()));
+        StartDrag([ele], dragData, downX, downY, options);
+    }
+
     export let AbortDrag: () => void = emptyFunction;
 
     function StartDrag(eles: HTMLElement[], dragData: { [id: string]: any }, downX: number, downY: number, options?: DragOptions, finishDrag?: (dropData: { [id: string]: any }) => void) {
diff --git a/src/client/util/LinkManager.ts b/src/client/util/LinkManager.ts
index 23ba9d2e4..544f2edda 100644
--- a/src/client/util/LinkManager.ts
+++ b/src/client/util/LinkManager.ts
@@ -1,4 +1,4 @@
-import { observable } from "mobx";
+import { observable, action } from "mobx";
 import { StrCast, Cast } from "../../new_fields/Types";
 import { Doc, DocListCast } from "../../new_fields/Doc";
 import { listSpec } from "../../new_fields/Schema";
@@ -32,6 +32,7 @@ export class LinkManager {
     @observable public allLinks: Array<Doc> = []; // list of link docs
     @observable public groupMetadataKeys: Map<string, Array<string>> = new Map();
     // map of group type to list of its metadata keys; serves as a dictionary of groups to what kind of metadata it hodls
+    @observable public linkProxies: Array<Doc> = []; // list of linkbutton docs - used to visualize link when an anchors are not in the same context
 
     // finds all links that contain the given anchor
     public findAllRelatedLinks(anchor: Doc): Array<Doc> {
@@ -134,4 +135,16 @@ export class LinkManager {
         }
     }
 
+    @action
+    public addLinkProxy(proxy: Doc) {
+        LinkManager.Instance.linkProxies.push(proxy);
+    }
+
+    public findLinkProxy(sourceViewId: string, targetViewId: string): Doc | undefined {
+        let index = LinkManager.Instance.linkProxies.findIndex(p => {
+            return StrCast(p.sourceViewId) === sourceViewId && StrCast(p.targetViewId) === targetViewId;
+        });
+        return index > -1 ? LinkManager.Instance.linkProxies[index] : undefined;
+    }
+
 }
\ No newline at end of file
diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.scss b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.scss
index fc5212edd..d8d518147 100644
--- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.scss
+++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.scss
@@ -1,19 +1,50 @@
-.collectionfreeformlinkview-linkLine {
-    stroke: black;
+// .collectionfreeformlinkview-linkLine {
+//     stroke: black;
+//     transform: translate(10000px,10000px);
+//     opacity: 0.5;
+//     pointer-events: all;
+// }
+// .collectionfreeformlinkview-linkCircle {
+//     stroke: rgb(0,0,0);
+//     opacity: 0.5;
+//     transform: translate(10000px,10000px);
+//     pointer-events: all;
+//     cursor: pointer;
+// }
+// .collectionfreeformlinkview-linkText {
+//     stroke: rgb(0,0,0);
+//     opacity: 0.5;
+//     transform: translate(10000px,10000px);
+//     pointer-events: all;
+// }
+
+.linkview-ele {
     transform: translate(10000px,10000px);
-    opacity: 0.5;
     pointer-events: all;
+
+    &.linkview-line {
+        stroke: black;
+        stroke-width: 2px;
+        opacity: 0.5;
+    }
 }
-.collectionfreeformlinkview-linkCircle {
-    stroke: rgb(0,0,0);
-    opacity: 0.5;
-    transform: translate(10000px,10000px);
-    pointer-events: all;
+
+.linkview-button {
+    width: 200px;
+    height: 100px;
+    border-radius: 5px;
+    padding: 10px;
+    position: relative;
+    background-color: black;
     cursor: pointer;
-}
-.collectionfreeformlinkview-linkText {
-    stroke: rgb(0,0,0);
-    opacity: 0.5;
-    transform: translate(10000px,10000px);
-    pointer-events: all;
+
+    p {
+        width: calc(100% - 20px);
+        color: white;
+        position: absolute;
+        top: 50%;
+        left: 50%;
+        transform: translate(-50%, -50%);
+    }
+
 }
diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx
index f995a35e3..13b5dc575 100644
--- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx
+++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkView.tsx
@@ -57,7 +57,7 @@ export class CollectionFreeFormLinkView extends React.Component<CollectionFreeFo
 
         return (
             <>
-                <line className="collectionfreeformlinkview-linkLine"
+                <line className="linkview-line linkview-ele"
                     style={{ strokeWidth: `${2 * 1 / 2}` }}
                     x1={`${x1}`} y1={`${y1}`}
                     x2={`${x2}`} y2={`${y2}`} />
diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkWithProxyView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkWithProxyView.tsx
index 81a00ba95..a4d122af2 100644
--- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkWithProxyView.tsx
+++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinkWithProxyView.tsx
@@ -7,13 +7,17 @@ import React = require("react");
 import v5 = require("uuid/v5");
 import { DocumentView } from "../../nodes/DocumentView";
 import { Docs } from "../../../documents/Documents";
-import { observable } from "mobx";
+import { observable, action } from "mobx";
+import { CollectionDockingView } from "../CollectionDockingView";
+import { dropActionType, DragManager } from "../../../util/DragManager";
+import { emptyFunction } from "../../../../Utils";
+import { DocumentManager } from "../../../util/DocumentManager";
 
 export interface CollectionFreeFormLinkViewProps {
     sourceView: DocumentView;
     targetView: DocumentView;
     proxyDoc: Doc;
-    addDocTab: (document: Doc, where: string) => void;
+    // addDocTab: (document: Doc, where: string) => void;
 }
 
 @observer
@@ -21,37 +25,104 @@ export class CollectionFreeFormLinkWithProxyView extends React.Component<Collect
 
     // @observable private _proxyX: number = NumCast(this.props.proxyDoc.x);
     // @observable private _proxyY: number = NumCast(this.props.proxyDoc.y);
+    private _ref = React.createRef<HTMLDivElement>();
+    private _downX: number = 0;
+    private _downY: number = 0;
+    @observable _x: number = 0;
+    @observable _y: number = 0;
+    // @observable private _proxyDoc: Doc = Docs.TextDocument(); // used for positioning
+
+    @action
+    componentDidMount() {
+        let a2 = this.props.proxyDoc;
+        this._x = NumCast(a2.x) + (BoolCast(a2.isMinimized, false) ? 5 : NumCast(a2.width) / NumCast(a2.zoomBasis, 1) / 2);
+        this._y = NumCast(a2.y) + (BoolCast(a2.isMinimized, false) ? 5 : NumCast(a2.height) / NumCast(a2.zoomBasis, 1) / 2);
+    }
+
 
     followButton = (e: React.PointerEvent): void => {
-        // TODO: would be nicer to open docview in context
         e.stopPropagation();
-        console.log("follow");
-        this.props.addDocTab(this.props.targetView.props.Document, "onRight");
+        let open = this.props.targetView.props.ContainingCollectionView ? this.props.targetView.props.ContainingCollectionView.props.Document : this.props.targetView.props.Document;
+        CollectionDockingView.Instance.AddRightSplit(open);
+        DocumentManager.Instance.jumpToDocument(this.props.targetView.props.Document, e.altKey);
+    }
+
+    @action
+    setPosition(x: number, y: number) {
+        this._x = x;
+        this._y = y;
+    }
+
+    startDragging(x: number, y: number) {
+        if (this._ref.current) {
+            let dragData = new DragManager.DocumentDragData([this.props.proxyDoc]);
+
+            DragManager.StartLinkProxyDrag(this._ref.current, dragData, x, y, {
+                handlers: {
+                    dragComplete: action(() => {
+                        let a2 = this.props.proxyDoc;
+                        let offset = NumCast(a2.width) / NumCast(a2.zoomBasis, 1) / 2;
+                        let x = NumCast(a2.x);// + NumCast(a2.width) / NumCast(a2.zoomBasis, 1) / 2;
+                        let y = NumCast(a2.y);// + NumCast(a2.height) / NumCast(a2.zoomBasis, 1) / 2;
+                        this.setPosition(x, y);
+
+                        // this is a hack :'( theres prob a better way to make the input doc not render
+                        let views = DocumentManager.Instance.getDocumentViews(this.props.proxyDoc);
+                        views.forEach(dv => {
+                            dv.props.removeDocument && dv.props.removeDocument(dv.props.Document);
+                        });
+                    }),
+                },
+                hideSource: true //?
+            });
+        }
+    }
+
+    onPointerDown = (e: React.PointerEvent): void => {
+        this._downX = e.clientX;
+        this._downY = e.clientY;
+
+        e.stopPropagation();
+        document.removeEventListener("pointermove", this.onPointerMove);
+        document.addEventListener("pointermove", this.onPointerMove);
+        document.removeEventListener("pointerup", this.onPointerUp);
+        document.addEventListener("pointerup", this.onPointerUp);
+    }
+
+    onPointerMove = (e: PointerEvent): void => {
+        if (Math.abs(this._downX - e.clientX) > 3 || Math.abs(this._downY - e.clientY) > 3) {
+            document.removeEventListener("pointermove", this.onPointerMove);
+            document.removeEventListener("pointerup", this.onPointerUp);
+            this.startDragging(this._downX, this._downY);
+        }
+        e.stopPropagation();
+        e.preventDefault();
+    }
+    onPointerUp = (e: PointerEvent): void => {
+        document.removeEventListener("pointermove", this.onPointerMove);
+        document.removeEventListener("pointerup", this.onPointerUp);
     }
 
     render() {
         let a1 = this.props.sourceView;
-        let a2 = this.props.proxyDoc;
         let x1 = NumCast(a1.Document.x) + (BoolCast(a1.Document.isMinimized, false) ? 5 : NumCast(a1.Document.width) / NumCast(a1.Document.zoomBasis, 1) / 2);
         let y1 = NumCast(a1.Document.y) + (BoolCast(a1.Document.isMinimized, false) ? 5 : NumCast(a1.Document.height) / NumCast(a1.Document.zoomBasis, 1) / 2);
 
-        let x2 = NumCast(a2.x) + (BoolCast(a2.isMinimized, false) ? 5 : NumCast(a2.width) / NumCast(a2.zoomBasis, 1) / 2);
-        let y2 = NumCast(a2.y) + (BoolCast(a2.isMinimized, false) ? 5 : NumCast(a2.height) / NumCast(a2.zoomBasis, 1) / 2);
-
-        // let containing = "";
-        // if (this.props.targetView.props.ContainingCollectionView) {
-        //     containing = StrCast(this.props.targetView.props.ContainingCollectionView.props.Document.title);
-        // }
+        let context = this.props.targetView.props.ContainingCollectionView ?
+            (" in the context of " + StrCast(this.props.targetView.props.ContainingCollectionView.props.Document.title)) : "";
+        let text = "link to " + StrCast(this.props.targetView.props.Document.title) + context;
 
-        // let text = "link to " + StrCast(this.props.targetView.props.Document.title) + (containing === "" ? "" : (" in the context of " + containing));
         return (
             <>
-                <line className="collectionfreeformlinkview-linkLine"
-                    style={{ strokeWidth: `${2 * 1 / 2}` }}
+                <line className="linkview-line linkview-ele"
+                    // style={{ strokeWidth: `${2 * 1 / 2}` }}
                     x1={`${x1}`} y1={`${y1}`}
-                    x2={`${x2}`} y2={`${y2}`} />
-                {/* <circle className="collectionfreeformlinkview-linkCircle" cx={x2} cy={y2} r={20} ></circle>
-                <text textAnchor="middle" className="collectionfreeformlinkview-linkText" x={`${x2}`} y={`${y2}`}> {text}</text> */}
+                    x2={`${this._x}`} y2={`${this._y}`} />
+                <foreignObject className="linkview-button-wrapper linkview-ele" width={200} height={100} x={this._x - 100} y={this._y - 50}>
+                    <div className="linkview-button" onPointerDown={this.onPointerDown} onPointerUp={this.followButton} ref={this._ref}>
+                        <p>{text}</p>
+                    </div>
+                </foreignObject>
             </>
         );
     }
diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.tsx
index eaef1f32a..bde68001b 100644
--- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.tsx
+++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLinksView.tsx
@@ -1,4 +1,4 @@
-import { computed, IReactionDisposer, reaction } from "mobx";
+import { computed, IReactionDisposer, reaction, action } from "mobx";
 import { observer } from "mobx-react";
 import { Doc, DocListCast } from "../../../../new_fields/Doc";
 import { Id } from "../../../../new_fields/FieldSymbols";
@@ -13,6 +13,8 @@ import { CollectionFreeFormLinkView } from "./CollectionFreeFormLinkView";
 import React = require("react");
 import { CollectionFreeFormLinkWithProxyView } from "./CollectionFreeFormLinkWithProxyView";
 import { Docs } from "../../../documents/Documents";
+import { LinkButtonField } from "../../../../new_fields/LinkButtonField";
+import { LinkManager } from "../../../util/LinkManager";
 
 @observer
 export class CollectionFreeFormLinksView extends React.Component<CollectionViewProps> {
@@ -195,18 +197,32 @@ export class CollectionFreeFormLinksView extends React.Component<CollectionViewP
                 if (sameContext) {
                     uniqueList.push(<CollectionFreeFormLinkView key={key} sourceView={u.sourceView} targetView={u.targetView} />);
                 } else {
-                    let proxyKey = Doc.AreProtosEqual(u.sourceView.Document, Cast(u.linkDoc.anchor1, Doc, new Doc)) ? "proxy1" : "proxy2";
-                    let proxy = Cast(u.linkDoc[proxyKey], Doc, new Doc);
-
-                    let context = u.targetView.props.ContainingCollectionView ? (" in the context of " + StrCast(u.targetView.props.ContainingCollectionView.props.Document.title)) : "";
-                    let text = proxyKey + " link to " + StrCast(u.targetView.props.Document.title) + context;
-
-                    let proxyProto = Doc.GetProto(proxy);
-                    proxyProto.data = text;
-
-                    this.props.addDocument(proxy, false);
-                    uniqueList.push(<CollectionFreeFormLinkWithProxyView key={key} sourceView={u.sourceView} targetView={u.targetView}
-                        proxyDoc={proxy} addDocTab={this.props.addDocTab} />);
+                    let proxy = LinkManager.Instance.findLinkProxy(StrCast(u.sourceView.props.Document[Id]), StrCast(u.targetView.props.Document[Id]));
+                    if (!proxy) {
+                        proxy = Docs.LinkButtonDocument(
+                            { sourceViewId: StrCast(u.sourceView.props.Document[Id]), targetViewId: StrCast(u.targetView.props.Document[Id]) },
+                            { width: 200, height: 100, borderRounding: 0 });
+                        let proxy1Proto = Doc.GetProto(proxy);
+                        proxy1Proto.sourceViewId = StrCast(u.sourceView.props.Document[Id]);
+                        proxy1Proto.targetViewId = StrCast(u.targetView.props.Document[Id]);
+                        proxy1Proto.isLinkButton = true;
+
+                        // LinkManager.Instance.linkProxies.push(proxy);
+                        LinkManager.Instance.addLinkProxy(proxy);
+                    }
+                    uniqueList.push(<CollectionFreeFormLinkWithProxyView key={key} sourceView={u.sourceView} targetView={u.targetView} proxyDoc={proxy} />);
+
+                    // let proxy = LinkManager.Instance.findLinkProxy(StrCast(u.sourceView.props.Document[Id]), StrCast(u.targetView.props.Document[Id]));
+                    // if (proxy) {
+                    //     this.props.addDocument(proxy, false);
+                    //     uniqueList.push(<CollectionFreeFormLinkWithProxyView key={key} sourceView={u.sourceView} targetView={u.targetView} />);
+                    // }
+                    // let proxyKey = Doc.AreProtosEqual(u.sourceView.Document, Cast(u.linkDoc.anchor1, Doc, new Doc)) ? "proxy1" : "proxy2";
+                    // let proxy = Cast(u.linkDoc[proxyKey], Doc, new Doc);
+                    // this.props.addDocument(proxy, false);
+
+                    // uniqueList.push(<CollectionFreeFormLinkWithProxyView key={key} sourceView={u.sourceView} targetView={u.targetView}
+                    //     proxyDoc={proxy} addDocTab={this.props.addDocTab} />);
                 }
             }
         });
diff --git a/src/client/views/nodes/DocumentContentsView.tsx b/src/client/views/nodes/DocumentContentsView.tsx
index 02396c3af..940b00a90 100644
--- a/src/client/views/nodes/DocumentContentsView.tsx
+++ b/src/client/views/nodes/DocumentContentsView.tsx
@@ -12,6 +12,7 @@ import "./DocumentView.scss";
 import { FormattedTextBox } from "./FormattedTextBox";
 import { ImageBox } from "./ImageBox";
 import { IconBox } from "./IconBox";
+import { LinkButtonBox } from "./LinkButtonBox";
 import { KeyValueBox } from "./KeyValueBox";
 import { PDFBox } from "./PDFBox";
 import { VideoBox } from "./VideoBox";
@@ -103,7 +104,7 @@ export class DocumentContentsView extends React.Component<DocumentViewProps & {
     render() {
         if (!this.layout && (this.props.layoutKey !== "overlayLayout" || !this.templates.length)) return (null);
         return <ObserverJsxParser
-            components={{ FormattedTextBox, ImageBox, IconBox, FieldView, CollectionFreeFormView, CollectionDockingView, CollectionSchemaView, CollectionView, CollectionPDFView, CollectionVideoView, WebBox, KeyValueBox, PDFBox, VideoBox, AudioBox, HistogramBox }}
+            components={{ FormattedTextBox, ImageBox, IconBox, FieldView, CollectionFreeFormView, CollectionDockingView, CollectionSchemaView, CollectionView, CollectionPDFView, CollectionVideoView, WebBox, KeyValueBox, PDFBox, VideoBox, AudioBox, HistogramBox, LinkButtonBox }}
             bindings={this.CreateBindings()}
             jsx={this.finalLayout}
             showWarnings={true}
diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx
index e98392a18..c424384a4 100644
--- a/src/client/views/nodes/DocumentView.tsx
+++ b/src/client/views/nodes/DocumentView.tsx
@@ -462,6 +462,17 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu
         var scaling = this.props.ContentScaling();
         var nativeHeight = this.nativeHeight > 0 ? `${this.nativeHeight}px` : "100%";
         var nativeWidth = this.nativeWidth > 0 ? `${this.nativeWidth}px` : "100%";
+
+        // // for linkbutton docs
+        // let isLinkButton = BoolCast(this.props.Document.isLinkButton);
+        // let activeDvs = DocumentManager.Instance.DocumentViews.filter(dv => dv.isSelected() || BoolCast(dv.props.Document.libraryBrush, false));
+        // let display = isLinkButton ? activeDvs.reduce((found, dv) => {
+        //     let matchSv = this.props.Document.sourceViewId === StrCast(dv.props.Document[Id]);
+        //     let matchTv = this.props.Document.targetViewId === StrCast(dv.props.Document[Id]);
+        //     let match = matchSv || matchTv;
+        //     return match || found;
+        // }, false) : true;
+
         return (
             <div className={`documentView-node${this.props.isTopMost ? "-topmost" : ""}`}
                 ref={this._mainCont}
@@ -476,7 +487,8 @@ export class DocumentView extends DocComponent<DocumentViewProps, Document>(Docu
                     background: this.Document.backgroundColor || "",
                     width: nativeWidth,
                     height: nativeHeight,
-                    transform: `scale(${scaling}, ${scaling})`
+                    transform: `scale(${scaling}, ${scaling})`,
+                    // display: display ? "block" : "none"
                 }}
                 onDrop={this.onDrop} onContextMenu={this.onContextMenu} onPointerDown={this.onPointerDown} onClick={this.onClick}
 
diff --git a/src/client/views/nodes/FieldView.tsx b/src/client/views/nodes/FieldView.tsx
index 5a83de8e3..6fecb34d7 100644
--- a/src/client/views/nodes/FieldView.tsx
+++ b/src/client/views/nodes/FieldView.tsx
@@ -18,6 +18,8 @@ import { FormattedTextBox } from "./FormattedTextBox";
 import { IconBox } from "./IconBox";
 import { ImageBox } from "./ImageBox";
 import { VideoBox } from "./VideoBox";
+import { LinkButtonBox } from "./LinkButtonBox";
+import { LinkButtonField } from "../../../new_fields/LinkButtonField";
 
 
 //
@@ -49,6 +51,7 @@ export interface FieldViewProps {
 @observer
 export class FieldView extends React.Component<FieldViewProps> {
     public static LayoutString(fieldType: { name: string }, fieldStr: string = "data") {
+        console.log("LAYOUT STRING", fieldType.name, fieldStr);
         return `<${fieldType.name} {...props} fieldKey={"${fieldStr}"} />`;
     }
 
@@ -74,6 +77,9 @@ export class FieldView extends React.Component<FieldViewProps> {
         else if (field instanceof IconField) {
             return <IconBox {...this.props} />;
         }
+        else if (field instanceof LinkButtonField) {
+            return <LinkButtonBox {...this.props} />;
+        }
         else if (field instanceof VideoField) {
             return <VideoBox {...this.props} />;
         }
diff --git a/src/client/views/nodes/LinkBox.scss b/src/client/views/nodes/LinkBox.scss
deleted file mode 100644
index 77462f611..000000000
--- a/src/client/views/nodes/LinkBox.scss
+++ /dev/null
@@ -1,76 +0,0 @@
-@import "../globalCssVariables";
-.linkMenu-item {
-    border-top: 0.5px solid $main-accent; 
-    padding: 6px;
-    position: relative;
-    display: flex;
-    font-size: 12px;
-
-    .linkMenu-item-content {
-        width: 100%;
-    }
-    
-    .link-metadata {
-        margin-top: 6px;
-        padding: 3px;
-        border-top: 0.5px solid $light-color-secondary;
-        .link-metadata-row {
-            margin-left: 6px;
-        }
-    }
-
-    &:last-child {
-        border-bottom: 0.5px solid $main-accent;
-    }
-    &:hover {
-        .linkMenu-item-buttons {
-            display: flex;
-        }
-        .linkMenu-item-content {
-            &.expand-two {
-                width: calc(100% - 46px);
-            }
-            &.expand-three {
-                width: calc(100% - 78px);
-            }
-        }
-    }
-}
-
-.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;
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/client/views/nodes/LinkBox.tsx b/src/client/views/nodes/LinkBox.tsx
deleted file mode 100644
index 8d07547ed..000000000
--- a/src/client/views/nodes/LinkBox.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-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 './LinkBox.scss';
-import React = require("react");
-import { Doc } from '../../../new_fields/Doc';
-import { StrCast, Cast } from '../../../new_fields/Types';
-import { observable, action } from 'mobx';
-import { LinkManager } from '../../util/LinkManager';
-import { DragLinksAsDocuments, DragLinkAsDocument } from '../../util/DragManager';
-import { SelectionManager } from '../../util/SelectionManager';
-library.add(faEye, faEdit, faTimes, faArrowRight, faChevronDown, faChevronUp);
-
-
-interface Props {
-    groupType: string;
-    linkDoc: Doc;
-    sourceDoc: Doc;
-    destinationDoc: Doc;
-    showEditor: () => void;
-}
-
-@observer
-export class LinkBox extends React.Component<Props> {
-    private _drag = React.createRef<HTMLDivElement>();
-    @observable private _showMore: boolean = false;
-    @action toggleShowMore() { this._showMore = !this._showMore; }
-
-    @undoBatch
-    onFollowLink = async (e: React.PointerEvent): Promise<void> => {
-        e.stopPropagation();
-        DocumentManager.Instance.jumpToDocument(this.props.destinationDoc, e.altKey);
-    }
-
-    onEdit = (e: React.PointerEvent): void => {
-        e.stopPropagation();
-        this.props.showEditor();
-    }
-
-    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, new Doc);
-            let keys = LinkManager.Instance.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.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>{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" ref={this._drag} onPointerDown={this.onLinkButtonDown} onPointerUp={this.onFollowLink}><FontAwesomeIcon className="fa-icon" icon="arrow-right" size="sm" /></div>
-                        </div>
-                    </div>
-                    {this._showMore ? this.renderMetadata() : <></>}
-                </div>
-
-            </div >
-        );
-    }
-}
\ No newline at end of file
diff --git a/src/client/views/nodes/LinkButtonBox.scss b/src/client/views/nodes/LinkButtonBox.scss
new file mode 100644
index 000000000..24bfd2c9f
--- /dev/null
+++ b/src/client/views/nodes/LinkButtonBox.scss
@@ -0,0 +1,18 @@
+.linkBox-cont {
+    width: 200px;
+    height: 100px;
+    background-color: black;
+    text-align: center;
+    color: white;
+    padding: 10px;
+    border-radius: 5px;
+    position: relative;
+
+    .linkBox-cont-wrapper {
+        width: calc(100% - 20px);
+        position: absolute;
+        left: 50%;
+        top: 50%;
+        transform: translate(-50%, -50%);
+    }
+}
\ No newline at end of file
diff --git a/src/client/views/nodes/LinkButtonBox.tsx b/src/client/views/nodes/LinkButtonBox.tsx
new file mode 100644
index 000000000..8a7c1ed8b
--- /dev/null
+++ b/src/client/views/nodes/LinkButtonBox.tsx
@@ -0,0 +1,63 @@
+import React = require("react");
+import { library } from '@fortawesome/fontawesome-svg-core';
+import { faCaretUp, faFilePdf, faFilm, faImage, faObjectGroup, faStickyNote } from '@fortawesome/free-solid-svg-icons';
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { computed, observable, runInAction } from "mobx";
+import { observer } from "mobx-react";
+import { FieldView, FieldViewProps } from './FieldView';
+import "./LinkButtonBox.scss";
+import { DocumentView } from "./DocumentView";
+import { Doc } from "../../../new_fields/Doc";
+import { LinkButtonField } from "../../../new_fields/LinkButtonField";
+import { Cast, StrCast, BoolCast } from "../../../new_fields/Types";
+import { CollectionDockingView } from "../collections/CollectionDockingView";
+import { DocumentManager } from "../../util/DocumentManager";
+import { Id } from "../../../new_fields/FieldSymbols";
+
+library.add(faCaretUp);
+library.add(faObjectGroup);
+library.add(faStickyNote);
+library.add(faFilePdf);
+library.add(faFilm);
+
+@observer
+export class LinkButtonBox extends React.Component<FieldViewProps> {
+    public static LayoutString() { return FieldView.LayoutString(LinkButtonBox); }
+
+    followLink = (): void => {
+        console.log("follow link???");
+        let field = Cast(this.props.Document[this.props.fieldKey], LinkButtonField, new LinkButtonField({ sourceViewId: "-1", targetViewId: "-1" }));
+        let targetView = DocumentManager.Instance.getDocumentViewById(field.data.targetViewId);
+        if (targetView && targetView.props.ContainingCollectionView) {
+            CollectionDockingView.Instance.AddRightSplit(targetView.props.ContainingCollectionView.props.Document);
+        }
+    }
+
+    render() {
+
+        let field = Cast(this.props.Document[this.props.fieldKey], LinkButtonField, new LinkButtonField({ sourceViewId: "-1", targetViewId: "-1" }));
+        let targetView = DocumentManager.Instance.getDocumentViewById(field.data.targetViewId);
+
+        let text = "Could not find link";
+        if (targetView) {
+            let context = targetView.props.ContainingCollectionView ? (" in the context of " + StrCast(targetView.props.ContainingCollectionView.props.Document.title)) : "";
+            text = "Link to " + StrCast(targetView.props.Document.title) + context;
+        }
+
+        let activeDvs = DocumentManager.Instance.DocumentViews.filter(dv => dv.isSelected() || BoolCast(dv.props.Document.libraryBrush, false));
+        let display = activeDvs.reduce((found, dv) => {
+            let matchSv = field.data.sourceViewId === StrCast(dv.props.Document[Id]);
+            let matchTv = field.data.targetViewId === StrCast(dv.props.Document[Id]);
+            let match = matchSv || matchTv;
+            return match || found;
+        }, false);
+
+        return (
+            <div className="linkBox-cont" style={{ display: display ? "block" : "none" }}>
+                <div className="linkBox-cont-wrapper">
+                    <p>{text}</p>
+                </div>
+            </div >
+        );
+    }
+}
\ No newline at end of file
diff --git a/src/client/views/nodes/LinkMenu.tsx b/src/client/views/nodes/LinkMenu.tsx
index aef56df67..7e4c15312 100644
--- a/src/client/views/nodes/LinkMenu.tsx
+++ b/src/client/views/nodes/LinkMenu.tsx
@@ -1,7 +1,7 @@
 import { action, observable } from "mobx";
 import { observer } from "mobx-react";
 import { DocumentView } from "./DocumentView";
-import { LinkBox } from "./LinkBox";
+import { LinkMenuItem } from "./LinkMenuItem";
 import { LinkEditor } from "./LinkEditor";
 import './LinkMenu.scss';
 import React = require("react");
@@ -23,7 +23,7 @@ export class LinkMenu extends React.Component<Props> {
         let source = this.props.docView.Document;
         return group.map(linkDoc => {
             let destination = LinkManager.Instance.findOppositeAnchor(linkDoc, source);
-            return <LinkBox key={destination[Id] + source[Id]} groupType={groupType} linkDoc={linkDoc} sourceDoc={source} destinationDoc={destination} showEditor={action(() => this._editingLink = linkDoc)} />;
+            return <LinkMenuItem key={destination[Id] + source[Id]} groupType={groupType} linkDoc={linkDoc} sourceDoc={source} destinationDoc={destination} showEditor={action(() => this._editingLink = linkDoc)} />;
         });
     }
 
diff --git a/src/client/views/nodes/LinkMenuItem.scss b/src/client/views/nodes/LinkMenuItem.scss
new file mode 100644
index 000000000..77462f611
--- /dev/null
+++ b/src/client/views/nodes/LinkMenuItem.scss
@@ -0,0 +1,76 @@
+@import "../globalCssVariables";
+.linkMenu-item {
+    border-top: 0.5px solid $main-accent; 
+    padding: 6px;
+    position: relative;
+    display: flex;
+    font-size: 12px;
+
+    .linkMenu-item-content {
+        width: 100%;
+    }
+    
+    .link-metadata {
+        margin-top: 6px;
+        padding: 3px;
+        border-top: 0.5px solid $light-color-secondary;
+        .link-metadata-row {
+            margin-left: 6px;
+        }
+    }
+
+    &:last-child {
+        border-bottom: 0.5px solid $main-accent;
+    }
+    &:hover {
+        .linkMenu-item-buttons {
+            display: flex;
+        }
+        .linkMenu-item-content {
+            &.expand-two {
+                width: calc(100% - 46px);
+            }
+            &.expand-three {
+                width: calc(100% - 78px);
+            }
+        }
+    }
+}
+
+.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;
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/client/views/nodes/LinkMenuItem.tsx b/src/client/views/nodes/LinkMenuItem.tsx
new file mode 100644
index 000000000..c68365584
--- /dev/null
+++ b/src/client/views/nodes/LinkMenuItem.tsx
@@ -0,0 +1,107 @@
+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 './LinkMenuItem.scss';
+import React = require("react");
+import { Doc } from '../../../new_fields/Doc';
+import { StrCast, Cast } from '../../../new_fields/Types';
+import { observable, action } from 'mobx';
+import { LinkManager } from '../../util/LinkManager';
+import { DragLinksAsDocuments, DragLinkAsDocument } from '../../util/DragManager';
+import { SelectionManager } from '../../util/SelectionManager';
+library.add(faEye, faEdit, faTimes, faArrowRight, faChevronDown, faChevronUp);
+
+
+interface LinkMenuItemProps {
+    groupType: string;
+    linkDoc: Doc;
+    sourceDoc: Doc;
+    destinationDoc: Doc;
+    showEditor: () => 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; }
+
+    @undoBatch
+    onFollowLink = async (e: React.PointerEvent): Promise<void> => {
+        e.stopPropagation();
+        DocumentManager.Instance.jumpToDocument(this.props.destinationDoc, e.altKey);
+    }
+
+    onEdit = (e: React.PointerEvent): void => {
+        e.stopPropagation();
+        this.props.showEditor();
+    }
+
+    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, new Doc);
+            let keys = LinkManager.Instance.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.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>{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" ref={this._drag} onPointerDown={this.onLinkButtonDown} onPointerUp={this.onFollowLink}><FontAwesomeIcon className="fa-icon" icon="arrow-right" size="sm" /></div>
+                        </div>
+                    </div>
+                    {this._showMore ? this.renderMetadata() : <></>}
+                </div>
+
+            </div >
+        );
+    }
+}
\ No newline at end of file
diff --git a/src/new_fields/LinkButtonField.ts b/src/new_fields/LinkButtonField.ts
new file mode 100644
index 000000000..92e1ed922
--- /dev/null
+++ b/src/new_fields/LinkButtonField.ts
@@ -0,0 +1,35 @@
+import { Deserializable } from "../client/util/SerializationHelper";
+import { serializable, primitive, createSimpleSchema, object } from "serializr";
+import { ObjectField } from "./ObjectField";
+import { Copy, ToScriptString } from "./FieldSymbols";
+import { Doc } from "./Doc";
+import { DocumentView } from "../client/views/nodes/DocumentView";
+
+export type LinkButtonData = {
+    sourceViewId: string,
+    targetViewId: string
+};
+
+const LinkButtonSchema = createSimpleSchema({
+    sourceViewId: true,
+    targetViewId: true
+});
+
+@Deserializable("linkButton")
+export class LinkButtonField extends ObjectField {
+    @serializable(object(LinkButtonSchema))
+    readonly data: LinkButtonData;
+
+    constructor(data: LinkButtonData) {
+        super();
+        this.data = data;
+    }
+
+    [Copy]() {
+        return new LinkButtonField(this.data);
+    }
+
+    [ToScriptString]() {
+        return "invalid";
+    }
+}
-- 
cgit v1.2.3-70-g09d2

