diff --git a/src/public/javascripts/services/sidebar.js b/src/public/javascripts/services/sidebar.js
index 905c15f46..987621da5 100644
--- a/src/public/javascripts/services/sidebar.js
+++ b/src/public/javascripts/services/sidebar.js
@@ -1,4 +1,5 @@
import NoteInfoWidget from "../widgets/note_info.js";
+import LinkMapWidget from "../widgets/link_map.js";
const WIDGET_TPL = `
@@ -46,7 +47,7 @@ class Sidebar {
this.$widgets.empty();
this.addNoteInfoWidget();
- this.addNoteInfoWidget();
+ this.addLinkMapWidget();
}
async addNoteInfoWidget() {
@@ -55,6 +56,15 @@ class Sidebar {
const noteInfoWidget = new NoteInfoWidget(this.ctx, $widget);
await noteInfoWidget.renderBody();
+ this.$widgets.append($widget);
+ }
+
+ async addLinkMapWidget() {
+ const $widget = this.createWidgetElement();
+
+ const linkMapWidget = new LinkMapWidget(this.ctx, $widget);
+ await linkMapWidget.renderBody();
+
console.log($widget);
this.$widgets.append($widget);
diff --git a/src/public/javascripts/widgets/link_map.js b/src/public/javascripts/widgets/link_map.js
new file mode 100644
index 000000000..32ab46173
--- /dev/null
+++ b/src/public/javascripts/widgets/link_map.js
@@ -0,0 +1,220 @@
+import libraryLoader from "../services/library_loader.js";
+import server from "../services/server.js";
+import treeCache from "../services/tree_cache.js";
+import linkService from "../services/link.js";
+
+let linkMapContainerIdCtr = 1;
+
+const TPL = `
+
+`;
+
+const linkOverlays = [
+ [ "Arrow", {
+ location: 1,
+ id: "arrow",
+ length: 10,
+ width: 10,
+ foldback: 0.7
+ } ]
+];
+
+class LinkMapWidget {
+ /**
+ * @param {TabContext} ctx
+ * @param {jQuery} $widget
+ */
+ constructor(ctx, $widget) {
+ this.ctx = ctx;
+ this.$widget = $widget;
+ this.$title = this.$widget.find('.widget-title');
+ this.$title.text("Link map");
+ }
+
+ async renderBody() {
+ const $body = this.$widget.find('.card-body');
+ $body.html(TPL);
+
+ this.$linkMapContainer = $body.find('.link-map-container');
+ this.$linkMapContainer.attr("id", "link-map-container-" + linkMapContainerIdCtr++);
+
+ await libraryLoader.requireLibrary(libraryLoader.LINK_MAP);
+
+ jsPlumb.ready(() => {
+ this.initJsPlumbInstance();
+
+ this.initPanZoom();
+
+ this.loadNotesAndRelations();
+ });
+ }
+
+ async loadNotesAndRelations() {
+ this.cleanup();
+
+ const linkTypes = [ "hyper", "image", "relation", "relation-map" ];
+ const maxNotes = 50;
+
+ const noteId = this.ctx.note.noteId;
+
+ const links = await server.post(`notes/${noteId}/link-map`, {
+ linkTypes,
+ maxNotes
+ });
+
+ const noteIds = new Set(links.map(l => l.noteId).concat(links.map(l => l.targetNoteId)));
+
+ if (noteIds.size === 0) {
+ noteIds.add(noteId);
+ }
+
+ // preload all notes
+ const notes = await treeCache.getNotes(Array.from(noteIds));
+
+ const graph = new Springy.Graph();
+ graph.addNodes(...noteIds);
+ graph.addEdges(...links.map(l => [l.noteId, l.targetNoteId]));
+
+ const layout = new Springy.Layout.ForceDirected(
+ graph,
+ 400.0, // Spring stiffness
+ 400.0, // Node repulsion
+ 0.5 // Damping
+ );
+
+ const getNoteBox = noteId => {
+ const noteBoxId = this.noteIdToId(noteId);
+ const $existingNoteBox = $("#" + noteBoxId);
+
+ if ($existingNoteBox.length > 0) {
+ return $existingNoteBox;
+ }
+
+ const note = notes.find(n => n.noteId === noteId);
+
+ const $noteBox = $("
")
+ .addClass("note-box")
+ .prop("id", noteBoxId);
+
+ linkService.createNoteLink(noteId, note.title).then($link => {
+ $noteBox.append($("").addClass("title").append($link));
+ });
+
+ if (noteId === noteId) {
+ $noteBox.addClass("link-map-active-note");
+ }
+
+ this.$linkMapContainer.append($noteBox);
+
+ this.jsPlumbInstance.draggable($noteBox[0], {
+ start: params => {
+ renderer.stop();
+ },
+ drag: params => {},
+ stop: params => {}
+ });
+
+
+ return $noteBox;
+ };
+
+ this.renderer = new Springy.Renderer(
+ layout,
+ () => {},
+ (edge, p1, p2) => {
+ const connectionId = edge.source.id + '-' + edge.target.id;
+
+ if ($("#" + connectionId).length > 0) {
+ return;
+ }
+
+ getNoteBox(edge.source.id);
+ getNoteBox(edge.target.id);
+
+ const connection = this.jsPlumbInstance.connect({
+ source: this.noteIdToId(edge.source.id),
+ target: this.noteIdToId(edge.target.id),
+ type: 'link'
+ });
+
+ connection.canvas.id = connectionId;
+ },
+ (node, p) => {
+ const $noteBox = getNoteBox(node.id);
+ const middleW = this.$linkMapContainer.width() / 2;
+ const middleH = this.$linkMapContainer.height() / 2;
+
+ $noteBox
+ .css("left", (middleW + p.x * 100) + "px")
+ .css("top", (middleH + p.y * 100) + "px");
+ },
+ () => {},
+ () => {},
+ () => {
+ this.jsPlumbInstance.repaintEverything();
+ }
+ );
+
+ this.renderer.start();
+ }
+
+ initPanZoom() {
+ if (this.pzInstance) {
+ return;
+ }
+
+ this.pzInstance = panzoom(this.$linkMapContainer[0], {
+ maxZoom: 2,
+ minZoom: 0.3,
+ smoothScroll: false,
+ filterKey: function (e, dx, dy, dz) {
+ // if ALT is pressed then panzoom should bubble the event up
+ // this is to preserve ALT-LEFT, ALT-RIGHT navigation working
+ return e.altKey;
+ }
+ });
+ }
+
+ cleanup() {
+ if (this.renderer) {
+ this.renderer.stop();
+ }
+
+ // delete all endpoints and connections
+ // this is done at this point (after async operations) to reduce flicker to the minimum
+ this.jsPlumbInstance.deleteEveryEndpoint();
+
+ // without this we still end up with note boxes remaining in the canvas
+ this.$linkMapContainer.empty();
+
+ // reset zoom/pan
+ this.pzInstance.zoomTo(0, 0, 0.5);
+ this.pzInstance.moveTo(0, 0);
+ }
+
+ initJsPlumbInstance() {
+ if (this.jsPlumbInstance) {
+ this.cleanup();
+
+ return;
+ }
+
+ this.jsPlumbInstance = jsPlumb.getInstance({
+ Endpoint: ["Blank", {}],
+ ConnectionOverlays: linkOverlays,
+ PaintStyle: { stroke: "var(--muted-text-color)", strokeWidth: 1 },
+ HoverPaintStyle: { stroke: "var(--main-text-color)", strokeWidth: 1 },
+ Container: this.$linkMapContainer.attr("id")
+ });
+
+ this.jsPlumbInstance.registerConnectionType("link", { anchor: "Continuous", connector: "Straight", overlays: linkOverlays });
+ }
+
+ noteIdToId(noteId) {
+ return "link-map-note-" + noteId;
+ }
+}
+
+export default LinkMapWidget;
\ No newline at end of file
diff --git a/src/public/stylesheets/link_map.css b/src/public/stylesheets/link_map.css
index fdc8dfb16..8e917bc27 100644
--- a/src/public/stylesheets/link_map.css
+++ b/src/public/stylesheets/link_map.css
@@ -1,10 +1,10 @@
-#link-map-container {
+.link-map-container {
position: relative;
- height: calc(95vh - 130px);
+ height: 300px;
outline: none; /* remove dotted outline on click */
}
-#link-map-container .note-box {
+.link-map-container .note-box {
padding: 8px;
position: absolute !important;
background-color: var(--accented-background-color);
@@ -23,16 +23,16 @@
overflow: hidden;
}
-#link-map-container .note-box:hover {
+.link-map-container .note-box:hover {
background-color: var(--more-accented-background-color);
}
-#link-map-container .note-box .title {
+.link-map-container .note-box .title {
font-size: larger;
font-weight: 600;
}
-#link-map-container .floating-button {
+.link-map-container .floating-button {
position: absolute !important;
z-index: 100;
}
diff --git a/src/public/stylesheets/style.css b/src/public/stylesheets/style.css
index fe2a1757b..6c603f268 100644
--- a/src/public/stylesheets/style.css
+++ b/src/public/stylesheets/style.css
@@ -116,10 +116,19 @@ ul.fancytree-container {
}
.note-detail-sidebar {
- min-width: 300px;
+ min-width: 350px;
overflow: auto;
padding-top: 12px;
padding-left: 7px;
+ font-size: 90%;
+}
+
+.note-detail-sidebar .widget-title {
+ width: 100%;
+ border-radius: 0;
+ padding: 0;
+ border-left: 0;
+ border-right: 0;
}
.note-detail-sidebar .card {
@@ -141,7 +150,7 @@ ul.fancytree-container {
}
.note-detail-sidebar .card-body {
- max-width: 300px;
+ width: 100%;
padding: 8px;
border: 0;
}