diff --git a/src/public/javascripts/services/link_map.js b/src/public/javascripts/services/link_map.js new file mode 100644 index 000000000..6f1522516 --- /dev/null +++ b/src/public/javascripts/services/link_map.js @@ -0,0 +1,198 @@ +import libraryLoader from "./library_loader.js"; +import server from "./server.js"; +import treeCache from "./tree_cache.js"; +import linkService from "./link.js"; + +const linkOverlays = [ + [ "Arrow", { + location: 1, + id: "arrow", + length: 10, + width: 10, + foldback: 0.7 + } ] +]; + +export default class LinkMap { + constructor(note, $linkMapContainer) { + this.note = note; + this.$linkMapContainer = $linkMapContainer; + this.linkMapContainerId = this.$linkMapContainer.attr("id"); + } + + async render() { + await libraryLoader.requireLibrary(libraryLoader.LINK_MAP); + + jsPlumb.ready(() => { + this.initJsPlumbInstance(); + + this.initPanZoom(); + + this.loadNotesAndRelations(); + }); + } + + async loadNotesAndRelations() { + this.cleanup(); + + const maxNotes = 50; + + const currentNoteId = this.note.noteId; + + const links = await server.post(`notes/${currentNoteId}/link-map`, { + maxNotes, + maxDepth: 1 + }); + + const noteIds = new Set(links.map(l => l.noteId).concat(links.map(l => l.targetNoteId))); + + if (noteIds.size === 0) { + noteIds.add(currentNoteId); + } + + // 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 === currentNoteId) { + $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.7); + 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 this.linkMapContainerId + "-note-" + noteId; + } +} \ No newline at end of file diff --git a/src/public/javascripts/widgets/link_map.js b/src/public/javascripts/widgets/link_map.js index 3bed76fe3..36cc84130 100644 --- a/src/public/javascripts/widgets/link_map.js +++ b/src/public/javascripts/widgets/link_map.js @@ -1,7 +1,3 @@ -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"; import StandardWidget from "./standard_widget.js"; let linkMapContainerIdCtr = 1; @@ -12,16 +8,6 @@ const TPL = `
`; -const linkOverlays = [ - [ "Arrow", { - location: 1, - id: "arrow", - length: 10, - width: 10, - foldback: 0.7 - } ] -]; - class LinkMapWidget extends StandardWidget { getWidgetTitle() { return "Link map"; } @@ -38,182 +24,14 @@ class LinkMapWidget extends StandardWidget { async doRenderBody() { this.$body.html(TPL); - this.$linkMapContainer = this.$body.find('.link-map-container'); - this.$linkMapContainer.attr("id", "link-map-container-" + linkMapContainerIdCtr++); + const $linkMapContainer = this.$body.find('.link-map-container'); + $linkMapContainer.attr("id", "link-map-container-" + linkMapContainerIdCtr++); - await libraryLoader.requireLibrary(libraryLoader.LINK_MAP); + const LinkMapServiceClass = (await import('../services/link_map.js')).default; - jsPlumb.ready(() => { - this.initJsPlumbInstance(); + const linkMapService = new LinkMapServiceClass(this.ctx.note, $linkMapContainer); - this.initPanZoom(); - - this.loadNotesAndRelations(); - }); - } - - async loadNotesAndRelations() { - this.cleanup(); - - const maxNotes = 50; - - const currentNoteId = this.ctx.note.noteId; - - const links = await server.post(`notes/${currentNoteId}/link-map`, { - maxNotes, - maxDepth: 1 - }); - - const noteIds = new Set(links.map(l => l.noteId).concat(links.map(l => l.targetNoteId))); - - if (noteIds.size === 0) { - noteIds.add(currentNoteId); - } - - // 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 === currentNoteId) { - $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.7); - 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; + await linkMapService.render(); } }