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; }