From ff41904d72b164f2e853bd86a1f489feb12eb7e4 Mon Sep 17 00:00:00 2001 From: zadam Date: Sat, 4 May 2019 00:16:41 +0200 Subject: [PATCH] convert more note details to new style --- .../javascripts/services/note_detail.js | 8 +- .../javascripts/services/note_detail_code.js | 191 ++-- .../services/note_detail_relation_map.js | 971 +++++++++--------- .../services/note_detail_render.js | 71 +- .../services/note_detail_search.js | 83 +- .../javascripts/services/note_detail_text.js | 2 +- src/public/stylesheets/style.css | 4 +- src/views/tabs.ejs | 2 +- 8 files changed, 687 insertions(+), 645 deletions(-) diff --git a/src/public/javascripts/services/note_detail.js b/src/public/javascripts/services/note_detail.js index bfec00715..933370a55 100644 --- a/src/public/javascripts/services/note_detail.js +++ b/src/public/javascripts/services/note_detail.js @@ -121,12 +121,12 @@ async function loadNoteDetail(noteId, newTab = false) { let ctx; if (noteContexts.length === 0 || newTab) { - const tabContent = $("#note-tab-content-template").clone(); + const $tabContent = $(".note-tab-content-template").clone(); - tabContent.removeAttr('id'); - tabContent.attr('data-note-id', noteId); + $tabContent.removeClass('note-tab-content-template'); + $tabContent.attr('data-note-id', noteId); - $noteTabContentsContainer.append(tabContent); + $noteTabContentsContainer.append($tabContent); // if it's a new tab explicitly by user then it's in background ctx = new NoteContext(loadedNote, newTab); diff --git a/src/public/javascripts/services/note_detail_code.js b/src/public/javascripts/services/note_detail_code.js index 77269a0f1..a785440b7 100644 --- a/src/public/javascripts/services/note_detail_code.js +++ b/src/public/javascripts/services/note_detail_code.js @@ -5,109 +5,112 @@ import server from "./server.js"; import noteDetailService from "./note_detail.js"; import utils from "./utils.js"; -let codeEditor = null; +class NoteDetailCode { -const $component = $('#note-detail-code'); -const $executeScriptButton = $("#execute-script-button"); + /** + * @param {NoteContext} ctx + */ + constructor(ctx) { + this.ctx = ctx; + this.codeEditor = null; + this.$component = ctx.$noteTabContent.find('.note-detail-code'); + this.$executeScriptButton = ctx.$noteTabContent.find(".execute-script-button"); -async function show() { - await libraryLoader.requireLibrary(libraryLoader.CODE_MIRROR); + utils.bindShortcut("ctrl+return", this.executeCurrentNote); - if (!codeEditor) { - CodeMirror.keyMap.default["Shift-Tab"] = "indentLess"; - CodeMirror.keyMap.default["Tab"] = "indentMore"; - - // these conflict with backward/forward navigation shortcuts - delete CodeMirror.keyMap.default["Alt-Left"]; - delete CodeMirror.keyMap.default["Alt-Right"]; - - CodeMirror.modeURL = 'libraries/codemirror/mode/%N/%N.js'; - - codeEditor = CodeMirror($component[0], { - value: "", - viewportMargin: Infinity, - indentUnit: 4, - matchBrackets: true, - matchTags: {bothTags: true}, - highlightSelectionMatches: {showToken: /\w/, annotateScrollbar: false}, - lint: true, - gutters: ["CodeMirror-lint-markers"], - lineNumbers: true, - tabindex: 100, - // we linewrap partly also because without it horizontal scrollbar displays only when you scroll - // all the way to the bottom of the note. With line wrap there's no horizontal scrollbar so no problem - lineWrapping: true - }); - - onNoteChange(noteDetailService.noteChanged); + this.$executeScriptButton.click(this.executeCurrentNote); } - $component.show(); + async show() { + await libraryLoader.requireLibrary(libraryLoader.CODE_MIRROR); - const activeNote = noteDetailService.getActiveNote(); + if (!this.codeEditor) { + CodeMirror.keyMap.default["Shift-Tab"] = "indentLess"; + CodeMirror.keyMap.default["Tab"] = "indentMore"; - // this needs to happen after the element is shown, otherwise the editor won't be refreshed - // CodeMirror breaks pretty badly on null so even though it shouldn't happen (guarded by consistency check) - // we provide fallback - codeEditor.setValue(activeNote.content || ""); + // these conflict with backward/forward navigation shortcuts + delete CodeMirror.keyMap.default["Alt-Left"]; + delete CodeMirror.keyMap.default["Alt-Right"]; - const info = CodeMirror.findModeByMIME(activeNote.mime); + CodeMirror.modeURL = 'libraries/codemirror/mode/%N/%N.js'; - if (info) { - codeEditor.setOption("mode", info.mime); - CodeMirror.autoLoadMode(codeEditor, info.mode); - } + this.codeEditor = CodeMirror(this.$component[0], { + value: "", + viewportMargin: Infinity, + indentUnit: 4, + matchBrackets: true, + matchTags: {bothTags: true}, + highlightSelectionMatches: {showToken: /\w/, annotateScrollbar: false}, + lint: true, + gutters: ["CodeMirror-lint-markers"], + lineNumbers: true, + tabindex: 100, + // we linewrap partly also because without it horizontal scrollbar displays only when you scroll + // all the way to the bottom of the note. With line wrap there's no horizontal scrollbar so no problem + lineWrapping: true + }); - codeEditor.refresh(); -} - -function getContent() { - return codeEditor.getValue(); -} - -function focus() { - codeEditor.focus(); -} - -async function executeCurrentNote() { - // ctrl+enter is also used elsewhere so make sure we're running only when appropriate - if (noteDetailService.getActiveNoteType() !== 'code') { - return; - } - - // make sure note is saved so we load latest changes - await noteDetailService.saveNotesIfChanged(); - - const activeNote = noteDetailService.getActiveNote(); - - if (activeNote.mime.endsWith("env=frontend")) { - await bundleService.getAndExecuteBundle(noteDetailService.getActiveNoteId()); - } - - if (activeNote.mime.endsWith("env=backend")) { - await server.post('script/run/' + noteDetailService.getActiveNoteId()); - } - - infoService.showMessage("Note executed"); -} - -function onNoteChange(func) { - codeEditor.on('change', func); -} - -utils.bindShortcut("ctrl+return", executeCurrentNote); - -$executeScriptButton.click(executeCurrentNote); - -export default { - show, - getContent, - focus, - onNoteChange, - cleanup: () => { - if (codeEditor) { - codeEditor.setValue(''); + this.onNoteChange(noteDetailService.noteChanged); } - }, - scrollToTop: () => $component.scrollTop(0) + + this.$component.show(); + + // this needs to happen after the element is shown, otherwise the editor won't be refreshed + // CodeMirror breaks pretty badly on null so even though it shouldn't happen (guarded by consistency check) + // we provide fallback + this.codeEditor.setValue(this.ctx.note.content || ""); + + const info = CodeMirror.findModeByMIME(this.ctx.note.mime); + + if (info) { + this.codeEditor.setOption("mode", info.mime); + CodeMirror.autoLoadMode(this.codeEditor, info.mode); + } + + this.codeEditor.refresh(); + } + + getContent() { + return this.codeEditor.getValue(); + } + + focus() { + this.codeEditor.focus(); + } + + async executeCurrentNote() { + // ctrl+enter is also used elsewhere so make sure we're running only when appropriate + if (this.ctx.note.type !== 'code') { + return; + } + + // make sure note is saved so we load latest changes + await noteDetailService.saveNotesIfChanged(); + + if (this.ctx.note.mime.endsWith("env=frontend")) { + await bundleService.getAndExecuteBundle(this.ctx.note.noteId); + } + + if (this.ctx.note.mime.endsWith("env=backend")) { + await server.post('script/run/' + this.ctx.note.noteId); + } + + infoService.showMessage("Note executed"); + } + + onNoteChange(func) { + this.codeEditor.on('change', func); + } + + cleanup() { + if (this.codeEditor) { + this.codeEditor.setValue(''); + } + } + + scrollToTop() { + this.$component.scrollTop(0); + } } + +export default NoteDetailCode; \ No newline at end of file diff --git a/src/public/javascripts/services/note_detail_relation_map.js b/src/public/javascripts/services/note_detail_relation_map.js index ee3c1ddff..1450779e8 100644 --- a/src/public/javascripts/services/note_detail_relation_map.js +++ b/src/public/javascripts/services/note_detail_relation_map.js @@ -10,19 +10,6 @@ import promptDialog from "../dialogs/prompt.js"; import infoDialog from "../dialogs/info.js"; import confirmDialog from "../dialogs/confirm.js"; -const $component = $("#note-detail-relation-map"); -const $relationMapContainer = $("#relation-map-container"); -const $createChildNote = $("#relation-map-create-child-note"); -const $zoomInButton = $("#relation-map-zoom-in"); -const $zoomOutButton = $("#relation-map-zoom-out"); -const $resetPanZoomButton = $("#relation-map-reset-pan-zoom"); - -let mapData; -let jsPlumbInstance; -// outside of mapData because they are not persisted in the note content -let relations; -let pzInstance; - const uniDirectionalOverlays = [ [ "Arrow", { location: 1, @@ -77,504 +64,532 @@ const linkOverlays = [ } ] ]; -function loadMapData() { - const activeNote = noteDetailService.getActiveNote(); - mapData = { - notes: [], - // it is important to have this exact value here so that initial transform is same as this - // which will guarantee note won't be saved on first conversion to relation map note type - // this keeps the principle that note type change doesn't destroy note content unless user - // does some actual change - transform: { - x: 0, - y: 0, - scale: 1 - } - }; +let containerCounter = 1; - if (activeNote.content) { - try { - mapData = JSON.parse(activeNote.content); - } catch (e) { - console.log("Could not parse content: ", e); - } - } -} +class NoteDetailRelationMap { + /** + * @param {NoteContext} ctx + */ + constructor(ctx) { + this.ctx = ctx; + this.$component = ctx.$noteTabContent.find(".note-detail-relation-map"); + this.$relationMapContainer = ctx.$noteTabContent.find(".relation-map-container"); + this.$createChildNote = ctx.$noteTabContent.find(".relation-map-create-child-note"); + this.$zoomInButton = ctx.$noteTabContent.find(".relation-map-zoom-in"); + this.$zoomOutButton = ctx.$noteTabContent.find(".relation-map-zoom-out"); + this.$resetPanZoomButton = ctx.$noteTabContent.find(".relation-map-reset-pan-zoom"); -function noteIdToId(noteId) { - return "rel-map-note-" + noteId; -} + this.mapData = null; + this.jsPlumbInstance = null; + // outside of mapData because they are not persisted in the note content + this.relations = null; + this.pzInstance = null; -function idToNoteId(id) { - return id.substr(13); -} - -async function show() { - $component.show(); - - await libraryLoader.requireLibrary(libraryLoader.RELATION_MAP); - - loadMapData(); - - jsPlumb.ready(() => { - initJsPlumbInstance(); - - initPanZoom(); - - loadNotesAndRelations(); - }); - -} - -function clearMap() { - // delete all endpoints and connections - // this is done at this point (after async operations) to reduce flicker to the minimum - jsPlumbInstance.deleteEveryEndpoint(); - - // without this we still end up with note boxes remaining in the canvas - $relationMapContainer.empty(); -} - -async function loadNotesAndRelations() { - const noteIds = mapData.notes.map(note => note.noteId); - const data = await server.post("notes/relation-map", {noteIds}); - - relations = []; - - for (const relation of data.relations) { - const match = relations.find(rel => - rel.name === data.inverseRelations[relation.name] - && ((rel.sourceNoteId === relation.sourceNoteId && rel.targetNoteId === relation.targetNoteId) - || (rel.sourceNoteId === relation.targetNoteId && rel.targetNoteId === relation.sourceNoteId))); - - if (match) { - match.type = relation.type = relation.name === data.inverseRelations[relation.name] ? 'biDirectional' : 'inverse'; - relation.render = false; // don't render second relation - } else { - relation.type = 'uniDirectional'; - relation.render = true; - } - - relations.push(relation); - } - - mapData.notes = mapData.notes.filter(note => note.noteId in data.noteTitles); - - jsPlumbInstance.batch(async function () { - clearMap(); - - for (const note of mapData.notes) { - const title = data.noteTitles[note.noteId]; - - await createNoteBox(note.noteId, title, note.x, note.y); - } - - for (const relation of relations) { - if (!relation.render) { - continue; - } - - const connection = jsPlumbInstance.connect({ - source: noteIdToId(relation.sourceNoteId), - target: noteIdToId(relation.targetNoteId), - type: relation.type + this.$relationMapContainer.attr("id", "relation-map-container-" + (containerCounter++)); + this.$relationMapContainer.on("contextmenu", ".note-box", e => { + contextMenuWidget.initContextMenu(e, { + getContextMenuItems: () => { + return [ + {title: "Remove note", cmd: "remove", uiIcon: "trash"}, + {title: "Edit title", cmd: "edit-title", uiIcon: "pencil"}, + ]; + }, + selectContextMenuItem: (event, cmd) => this.noteContextMenuHandler(event, cmd) }); - connection.id = relation.attributeId; - - if (relation.type === 'inverse') { - connection.getOverlay("label-source").setLabel(relation.name); - connection.getOverlay("label-target").setLabel(data.inverseRelations[relation.name]); - } - else { - connection.getOverlay("label").setLabel(relation.name); - } - - connection.canvas.setAttribute("data-connection-id", connection.id); - } - - for (const link of data.links) { - jsPlumbInstance.connect({ - source: noteIdToId(link.sourceNoteId), - target: noteIdToId(link.targetNoteId), - type: 'link' - }); - } - }); -} - -function initPanZoom() { - if (pzInstance) { - return; - } - - pzInstance = panzoom($relationMapContainer[0], { - maxZoom: 2, - minZoom: 0.3, - smoothScroll: false, - onMouseDown: function(event) { - if (clipboard) { - let {x, y} = getMousePosition(event); - - // modifying position so that cursor is on the top-center of the box - x -= 80; - y -= 15; - - createNoteBox(clipboard.noteId, clipboard.title, x, y); - - mapData.notes.push({ noteId: clipboard.noteId, x, y }); - - saveData(); - - clipboard = null; - } - - return true; - }, - 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; - } - }); - - pzInstance.on('transform', () => { // gets triggered on any transform change - jsPlumbInstance.setZoom(getZoom()); - - saveCurrentTransform(); - }); - - if (mapData.transform) { - pzInstance.zoomTo(0, 0, mapData.transform.scale); - - pzInstance.moveTo(mapData.transform.x, mapData.transform.y); - } - else { - // set to initial coordinates - pzInstance.moveTo(0, 0); - } - - $zoomInButton.click(() => pzInstance.zoomTo(0, 0, 1.2)); - $zoomOutButton.click(() => pzInstance.zoomTo(0, 0, 0.8)); -} - -function saveCurrentTransform() { - const newTransform = pzInstance.getTransform(); - - if (JSON.stringify(newTransform) !== JSON.stringify(mapData.transform)) { - // clone transform object - mapData.transform = JSON.parse(JSON.stringify(newTransform)); - - saveData(); - } -} - -function cleanup() { - if (jsPlumbInstance) { - clearMap(); - } - - if (pzInstance) { - pzInstance.dispose(); - pzInstance = null; - } -} - -function initJsPlumbInstance () { - if (jsPlumbInstance) { - cleanup(); - - return; - } - - jsPlumbInstance = jsPlumb.getInstance({ - Endpoint: ["Dot", {radius: 2}], - Connector: "StateMachine", - ConnectionOverlays: uniDirectionalOverlays, - HoverPaintStyle: { stroke: "#777", strokeWidth: 1 }, - Container: "relation-map-container" - }); - - jsPlumbInstance.registerConnectionType("uniDirectional", { anchor:"Continuous", connector:"StateMachine", overlays: uniDirectionalOverlays }); - - jsPlumbInstance.registerConnectionType("biDirectional", { anchor:"Continuous", connector:"StateMachine", overlays: biDirectionalOverlays }); - - jsPlumbInstance.registerConnectionType("inverse", { anchor:"Continuous", connector:"StateMachine", overlays: inverseRelationsOverlays }); - - jsPlumbInstance.registerConnectionType("link", { anchor:"Continuous", connector:"StateMachine", overlays: linkOverlays }); - - jsPlumbInstance.bind("connection", connectionCreatedHandler); - - // so that canvas is not panned when clicking/dragging note box - $relationMapContainer.on('mousedown touchstart', '.note-box, .connection-label', e => e.stopPropagation()); -} - -function connectionContextMenuHandler(connection, event) { - event.preventDefault(); - event.stopPropagation(); - - const items = [ {title: "Remove relation", cmd: "remove", uiIcon: "trash"} ]; - - contextMenuWidget.initContextMenu(event, items, async (event, cmd) => { - if (cmd === 'remove') { - if (!await confirmDialog.confirm("Are you sure you want to remove the relation?")) { - return; - } - - const relation = relations.find(rel => rel.attributeId === connection.id); - - await server.remove(`notes/${relation.sourceNoteId}/relations/${relation.name}/to/${relation.targetNoteId}`); - - jsPlumbInstance.deleteConnection(connection); - - relations = relations.filter(relation => relation.attributeId !== connection.id); - } - }); -} - -async function connectionCreatedHandler(info, originalEvent) { - const connection = info.connection; - - connection.bind("contextmenu", (obj, event) => { - if (connection.getType().includes("link")) { - // don't create context menu if it's a link since there's nothing to do with link from relation map - // (don't open browser menu either) - event.preventDefault(); - } - else { - connectionContextMenuHandler(connection, event); - } - }); - - // if there's no event, then this has been triggered programatically - if (!originalEvent) { - return; - } - - const name = await promptDialog.ask({ - message: "Specify new relation name:", - shown: ({ $answer }) => - attributeAutocompleteService.initAttributeNameAutocomplete({ - $el: $answer, - attributeType: "relation", - open: true - }) - }); - - if (!name || !name.trim()) { - jsPlumbInstance.deleteConnection(connection); - - return; - } - - const targetNoteId = idToNoteId(connection.target.id); - const sourceNoteId = idToNoteId(connection.source.id); - - const relationExists = relations.some(rel => - rel.targetNoteId === targetNoteId - && rel.sourceNoteId === sourceNoteId - && rel.name === name); - - if (relationExists) { - await infoDialog.info("Connection '" + name + "' between these notes already exists."); - - jsPlumbInstance.deleteConnection(connection); - - return; - } - - await server.put(`notes/${sourceNoteId}/relations/${name}/to/${targetNoteId}`); - - await refresh(); -} - -$relationMapContainer.on("contextmenu", ".note-box", e => { - const items = [ - {title: "Remove note", cmd: "remove", uiIcon: "trash"}, - {title: "Edit title", cmd: "edit-title", uiIcon: "pencil"}, - ]; - - contextMenuWidget.initContextMenu(e, items, noteContextMenuHandler); - - return false; -}); - -async function noteContextMenuHandler(event, cmd) { - const $noteBox = $(event.originalTarget).closest(".note-box"); - const $title = $noteBox.find(".title a"); - const noteId = idToNoteId($noteBox.prop("id")); - - if (cmd === "remove") { - if (!await confirmDialog.confirmDeleteNoteBoxWithNote($title.text())) { - return; - } - - jsPlumbInstance.remove(noteIdToId(noteId)); - - if (confirmDialog.isDeleteNoteChecked()) { - await server.remove("notes/" + noteId); - - // to force it to disappear from the tree - treeService.reload(); - } - - mapData.notes = mapData.notes.filter(note => note.noteId !== noteId); - - relations = relations.filter(relation => relation.sourceNoteId !== noteId && relation.targetNoteId !== noteId); - - saveData(); - } - else if (cmd === "edit-title") { - const title = await promptDialog.ask({ - message: "Enter new note title:", - defaultValue: $title.text() + return false; }); - if (!title) { - return; - } + this.clipboard = null; - await server.put(`notes/${noteId}/change-title`, { title }); + this.$createChildNote.click(async () => { + const title = await promptDialog.ask({ message: "Enter title of new note", defaultValue: "new note" }); - treeService.setNoteTitle(noteId, title); - - $title.text(title); - } -} - -function saveData() { - noteDetailService.noteChanged(); -} - -async function createNoteBox(noteId, title, x, y) { - const $noteBox = $("
") - .addClass("note-box") - .prop("id", noteIdToId(noteId)) - .append($("").addClass("title").html(await linkService.createNoteLink(noteId, title))) - .append($("
").addClass("endpoint").attr("title", "Start dragging relations from here and drop them on another note.")) - .css("left", x + "px") - .css("top", y + "px"); - - jsPlumbInstance.getContainer().appendChild($noteBox[0]); - - jsPlumbInstance.draggable($noteBox[0], { - start: params => {}, - drag: params => {}, - stop: params => { - const noteId = idToNoteId(params.el.id); - - const note = mapData.notes.find(note => note.noteId === noteId); - - if (!note) { - console.error(`Note ${noteId} not found!`); + if (!title.trim()) { return; } - [note.x, note.y] = params.finalPos; + const {note} = await server.post(`notes/${this.ctx.note.noteId}/children`, { + title, + target: 'into' + }); - saveData(); - } - }); + infoService.showMessage("Click on canvas to place new note"); - jsPlumbInstance.makeSource($noteBox[0], { - filter: ".endpoint", - anchor: "Continuous", - connectorStyle: { stroke: "#000", strokeWidth: 1 }, - connectionType: "basic", - extract:{ - "action": "the-action" - } - }); + // reloading tree so that the new note appears there + // no need to wait for it to finish + treeService.reload(); - jsPlumbInstance.makeTarget($noteBox[0], { - dropOptions: { hoverClass: "dragHover" }, - anchor: "Continuous", - allowLoopback: true - }); -} + this.clipboard = { noteId: note.noteId, title }; + }); -async function refresh() { - await loadNotesAndRelations(); -} + this.$resetPanZoomButton.click(() => { + // reset to initial pan & zoom state + this.pzInstance.zoomTo(0, 0, 1 / getZoom()); + this.pzInstance.moveTo(0, 0); + }); -let clipboard = null; - -$createChildNote.click(async () => { - const title = await promptDialog.ask({ message: "Enter title of new note", defaultValue: "new note" }); - - if (!title.trim()) { - return; + this.$component.on("drop", this.dropNoteOntoRelationMapHandler); + this.$component.on("dragover", ev => ev.preventDefault()); } - const {note} = await server.post(`notes/${noteDetailService.getActiveNoteId()}/children`, { - title, - target: 'into' - }); + async noteContextMenuHandler(event, cmd) { + const $noteBox = $(event.originalTarget).closest(".note-box"); + const $title = $noteBox.find(".title a"); + const noteId = this.idToNoteId($noteBox.prop("id")); - infoService.showMessage("Click on canvas to place new note"); + if (cmd === "remove") { + if (!await confirmDialog.confirmDeleteNoteBoxWithNote($title.text())) { + return; + } - // reloading tree so that the new note appears there - // no need to wait for it to finish - treeService.reload(); + this.jsPlumbInstance.remove(this.noteIdToId(noteId)); - clipboard = { noteId: note.noteId, title }; -}); + if (confirmDialog.isDeleteNoteChecked()) { + await server.remove("notes/" + noteId); -function getZoom() { - const matrixRegex = /matrix\((-?\d*\.?\d+),\s*0,\s*0,\s*-?\d*\.?\d+,\s*-?\d*\.?\d+,\s*-?\d*\.?\d+\)/; + // to force it to disappear from the tree + treeService.reload(); + } - const matches = $relationMapContainer.css('transform').match(matrixRegex); + this.mapData.notes = this.mapData.notes.filter(note => note.noteId !== noteId); - return matches[1]; -} + this.relations = this.relations.filter(relation => relation.sourceNoteId !== noteId && relation.targetNoteId !== noteId); -async function dropNoteOntoRelationMapHandler(ev) { - ev.preventDefault(); + this.saveData(); + } + else if (cmd === "edit-title") { + const title = await promptDialog.ask({ + message: "Enter new note title:", + defaultValue: $title.text() + }); - const note = JSON.parse(ev.originalEvent.dataTransfer.getData("text")); + if (!title) { + return; + } - let {x, y} = getMousePosition(ev); + await server.put(`notes/${noteId}/change-title`, { title }); - const exists = mapData.notes.some(n => n.noteId === note.noteId); + treeService.setNoteTitle(noteId, title); - if (exists) { - await infoDialog.info(`Note "${note.title}" is already placed into the diagram`); - - return; + $title.text(title); + } } - mapData.notes.push({noteId: note.noteId, x, y}); + loadMapData() { + this.mapData = { + notes: [], + // it is important to have this exact value here so that initial transform is same as this + // which will guarantee note won't be saved on first conversion to relation map note type + // this keeps the principle that note type change doesn't destroy note content unless user + // does some actual change + transform: { + x: 0, + y: 0, + scale: 1 + } + }; - saveData(); + if (this.ctx.note.content) { + try { + this.mapData = JSON.parse(this.ctx.note.content); + } catch (e) { + console.log("Could not parse content: ", e); + } + } + } - await refresh(); + noteIdToId(noteId) { + return "rel-map-note-" + noteId; + } + + idToNoteId(id) { + return id.substr(13); + } + + async show() { + this.$component.show(); + + await libraryLoader.requireLibrary(libraryLoader.RELATION_MAP); + + this.loadMapData(); + + jsPlumb.ready(() => { + this.initJsPlumbInstance(); + + this.initPanZoom(); + + this.loadNotesAndRelations(); + }); + + } + + clearMap() { + // 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.$relationMapContainer.empty(); + } + + async loadNotesAndRelations() { + const noteIds = this.mapData.notes.map(note => note.noteId); + const data = await server.post("notes/relation-map", {noteIds}); + + this.relations = []; + + for (const relation of data.relations) { + const match = this.relations.find(rel => + rel.name === data.inverseRelations[relation.name] + && ((rel.sourceNoteId === relation.sourceNoteId && rel.targetNoteId === relation.targetNoteId) + || (rel.sourceNoteId === relation.targetNoteId && rel.targetNoteId === relation.sourceNoteId))); + + if (match) { + match.type = relation.type = relation.name === data.inverseRelations[relation.name] ? 'biDirectional' : 'inverse'; + relation.render = false; // don't render second relation + } else { + relation.type = 'uniDirectional'; + relation.render = true; + } + + this.relations.push(relation); + } + + this.mapData.notes = this.mapData.notes.filter(note => note.noteId in data.noteTitles); + + this.jsPlumbInstance.batch(async () => { + this.clearMap(); + + for (const note of this.mapData.notes) { + const title = data.noteTitles[note.noteId]; + + await this.createNoteBox(note.noteId, title, note.x, note.y); + } + + for (const relation of this.relations) { + if (!relation.render) { + continue; + } + + const connection = this.jsPlumbInstance.connect({ + source: this.noteIdToId(relation.sourceNoteId), + target: this.noteIdToId(relation.targetNoteId), + type: relation.type + }); + + connection.id = relation.attributeId; + + if (relation.type === 'inverse') { + connection.getOverlay("label-source").setLabel(relation.name); + connection.getOverlay("label-target").setLabel(data.inverseRelations[relation.name]); + } + else { + connection.getOverlay("label").setLabel(relation.name); + } + + connection.canvas.setAttribute("data-connection-id", connection.id); + } + + for (const link of data.links) { + this.jsPlumbInstance.connect({ + source: this.noteIdToId(link.sourceNoteId), + target: this.noteIdToId(link.targetNoteId), + type: 'link' + }); + } + }); + } + + initPanZoom() { + if (this.pzInstance) { + return; + } + + this.pzInstance = panzoom(this.$relationMapContainer[0], { + maxZoom: 2, + minZoom: 0.3, + smoothScroll: false, + onMouseDown: function(event) { + if (this.clipboard) { + let {x, y} = this.getMousePosition(event); + + // modifying position so that cursor is on the top-center of the box + x -= 80; + y -= 15; + + this.createNoteBox(this.clipboard.noteId, this.clipboard.title, x, y); + + this.mapData.notes.push({ noteId: this.clipboard.noteId, x, y }); + + this.saveData(); + + this.clipboard = null; + } + + return true; + }, + 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; + } + }); + + this.pzInstance.on('transform', () => { // gets triggered on any transform change + this.jsPlumbInstance.setZoom(this.getZoom()); + + this.saveCurrentTransform(); + }); + + if (this.mapData.transform) { + this.pzInstance.zoomTo(0, 0, this.mapData.transform.scale); + + this.pzInstance.moveTo(this.mapData.transform.x, this.mapData.transform.y); + } + else { + // set to initial coordinates + this.pzInstance.moveTo(0, 0); + } + + this.$zoomInButton.click(() => this.pzInstance.zoomTo(0, 0, 1.2)); + this.$zoomOutButton.click(() => this.pzInstance.zoomTo(0, 0, 0.8)); + } + + saveCurrentTransform() { + const newTransform = this.pzInstance.getTransform(); + + if (JSON.stringify(newTransform) !== JSON.stringify(this.mapData.transform)) { + // clone transform object + this.mapData.transform = JSON.parse(JSON.stringify(newTransform)); + + this.saveData(); + } + } + + cleanup() { + if (this.jsPlumbInstance) { + this.clearMap(); + } + + if (this.pzInstance) { + this.pzInstance.dispose(); + this.pzInstance = null; + } + } + + initJsPlumbInstance () { + if (this.jsPlumbInstance) { + this.cleanup(); + + return; + } + + this.jsPlumbInstance = jsPlumb.getInstance({ + Endpoint: ["Dot", {radius: 2}], + Connector: "StateMachine", + ConnectionOverlays: uniDirectionalOverlays, + HoverPaintStyle: { stroke: "#777", strokeWidth: 1 }, + Container: this.$relationMapContainer.attr("id") + }); + + this.jsPlumbInstance.registerConnectionType("uniDirectional", { anchor:"Continuous", connector:"StateMachine", overlays: uniDirectionalOverlays }); + + this.jsPlumbInstance.registerConnectionType("biDirectional", { anchor:"Continuous", connector:"StateMachine", overlays: biDirectionalOverlays }); + + this.jsPlumbInstance.registerConnectionType("inverse", { anchor:"Continuous", connector:"StateMachine", overlays: inverseRelationsOverlays }); + + this.jsPlumbInstance.registerConnectionType("link", { anchor:"Continuous", connector:"StateMachine", overlays: linkOverlays }); + + this.jsPlumbInstance.bind("connection", (info, originalEvent) => this.connectionCreatedHandler(info, originalEvent)); + + // so that canvas is not panned when clicking/dragging note box + this.$relationMapContainer.on('mousedown touchstart', '.note-box, .connection-label', e => e.stopPropagation()); + } + + async connectionCreatedHandler(info, originalEvent) { + const connection = info.connection; + + connection.bind("contextmenu", (obj, event) => { + if (connection.getType().includes("link")) { + // don't create context menu if it's a link since there's nothing to do with link from relation map + // (don't open browser menu either) + event.preventDefault(); + } + else { + event.preventDefault(); + event.stopPropagation(); + + contextMenuWidget.initContextMenu(event, { + getContextMenuItems: () => { + return [ {title: "Remove relation", cmd: "remove", uiIcon: "trash"} ]; + }, + selectContextMenuItem: async (event, cmd) => { + if (cmd === 'remove') { + if (!await confirmDialog.confirm("Are you sure you want to remove the relation?")) { + return; + } + + const relation = this.relations.find(rel => rel.attributeId === connection.id); + + await server.remove(`notes/${relation.sourceNoteId}/relations/${relation.name}/to/${relation.targetNoteId}`); + + this.jsPlumbInstance.deleteConnection(connection); + + this.relations = this.relations.filter(relation => relation.attributeId !== connection.id); + } + } + }); + } + }); + + // if there's no event, then this has been triggered programatically + if (!originalEvent) { + return; + } + + const name = await promptDialog.ask({ + message: "Specify new relation name:", + shown: ({ $answer }) => + attributeAutocompleteService.initAttributeNameAutocomplete({ + $el: $answer, + attributeType: "relation", + open: true + }) + }); + + if (!name || !name.trim()) { + this.jsPlumbInstance.deleteConnection(connection); + + return; + } + + const targetNoteId = this.idToNoteId(connection.target.id); + const sourceNoteId = this.idToNoteId(connection.source.id); + + const relationExists = this.relations.some(rel => + rel.targetNoteId === targetNoteId + && rel.sourceNoteId === sourceNoteId + && rel.name === name); + + if (relationExists) { + await infoDialog.info("Connection '" + name + "' between these notes already exists."); + + this.jsPlumbInstance.deleteConnection(connection); + + return; + } + + await server.put(`notes/${sourceNoteId}/relations/${name}/to/${targetNoteId}`); + + await this.refresh(); + } + + saveData() { + this.ctx.noteChanged(); + } + + async createNoteBox(noteId, title, x, y) { + const $noteBox = $("
") + .addClass("note-box") + .prop("id", this.noteIdToId(noteId)) + .append($("").addClass("title").html(await linkService.createNoteLink(noteId, title))) + .append($("
").addClass("endpoint").attr("title", "Start dragging relations from here and drop them on another note.")) + .css("left", x + "px") + .css("top", y + "px"); + + this.jsPlumbInstance.getContainer().appendChild($noteBox[0]); + + this.jsPlumbInstance.draggable($noteBox[0], { + start: params => {}, + drag: params => {}, + stop: params => { + const noteId = this.idToNoteId(params.el.id); + + const note = this.mapData.notes.find(note => note.noteId === noteId); + + if (!note) { + console.error(`Note ${noteId} not found!`); + return; + } + + [note.x, note.y] = params.finalPos; + + this.saveData(); + } + }); + + this.jsPlumbInstance.makeSource($noteBox[0], { + filter: ".endpoint", + anchor: "Continuous", + connectorStyle: { stroke: "#000", strokeWidth: 1 }, + connectionType: "basic", + extract:{ + "action": "the-action" + } + }); + + this.jsPlumbInstance.makeTarget($noteBox[0], { + dropOptions: { hoverClass: "dragHover" }, + anchor: "Continuous", + allowLoopback: true + }); + } + + async refresh() { + await this.loadNotesAndRelations(); + } + + getZoom() { + const matrixRegex = /matrix\((-?\d*\.?\d+),\s*0,\s*0,\s*-?\d*\.?\d+,\s*-?\d*\.?\d+,\s*-?\d*\.?\d+\)/; + + const matches = this.$relationMapContainer.css('transform').match(matrixRegex); + + return matches[1]; + } + + async dropNoteOntoRelationMapHandler(ev) { + ev.preventDefault(); + + const note = JSON.parse(ev.originalEvent.dataTransfer.getData("text")); + + let {x, y} = this.getMousePosition(ev); + + const exists = this.mapData.notes.some(n => n.noteId === note.noteId); + + if (exists) { + await infoDialog.info(`Note "${note.title}" is already placed into the diagram`); + + return; + } + + this.mapData.notes.push({noteId: note.noteId, x, y}); + + this.saveData(); + + await this.refresh(); + } + + getMousePosition(evt) { + const rect = this.$relationMapContainer[0].getBoundingClientRect(); + + const zoom = this.getZoom(); + + return { + x: (evt.clientX - rect.left) / zoom, + y: (evt.clientY - rect.top) / zoom + }; + } + + getContent() { + return JSON.stringify(this.mapData); + } + + focus() {} + + onNoteChange() {} + + scrollToTop() {} } -function getMousePosition(evt) { - const rect = $relationMapContainer[0].getBoundingClientRect(); - - const zoom = getZoom(); - - return { - x: (evt.clientX - rect.left) / zoom, - y: (evt.clientY - rect.top) / zoom - }; -} - -$resetPanZoomButton.click(() => { - // reset to initial pan & zoom state - pzInstance.zoomTo(0, 0, 1 / getZoom()); - pzInstance.moveTo(0, 0); -}); - -$component.on("drop", dropNoteOntoRelationMapHandler); -$component.on("dragover", ev => ev.preventDefault()); - -export default { - show, - getContent: () => JSON.stringify(mapData), - focus: () => null, - onNoteChange: () => null, - cleanup, - scrollToTop: () => null -} \ No newline at end of file +export default NoteDetailRelationMap; \ No newline at end of file diff --git a/src/public/javascripts/services/note_detail_render.js b/src/public/javascripts/services/note_detail_render.js index e37d4f4ef..f99c2627c 100644 --- a/src/public/javascripts/services/note_detail_render.js +++ b/src/public/javascripts/services/note_detail_render.js @@ -3,40 +3,55 @@ import server from "./server.js"; import noteDetailService from "./note_detail.js"; import attributeService from "./attributes.js"; -const $component = $('#note-detail-render'); -const $noteDetailRenderHelp = $('#note-detail-render-help'); -const $noteDetailRenderContent = $('#note-detail-render-content'); -const $renderButton = $('#render-button'); +class NoteDetailRender { + /** + * @param {NoteContext} ctx + */ + constructor(ctx) { + this.ctx = ctx; + this.$component = ctx.$noteTabContent.find('.note-detail-render'); + this.$noteDetailRenderHelp = ctx.$noteTabContent.find('.note-detail-render-help'); + this.$noteDetailRenderContent = ctx.$noteTabContent.find('.note-detail-render-content'); + this.$renderButton = ctx.$noteTabContent.find('.render-button'); -async function render() { - const attributes = await attributeService.getAttributes(); - const renderNotes = attributes.filter(attr => - attr.type === 'relation' - && attr.name === 'renderNote' - && !!attr.value); + this.$renderButton.click(this.show); + } - $component.show(); + async show() { + const attributes = await attributeService.getAttributes(); + const renderNotes = attributes.filter(attr => + attr.type === 'relation' + && attr.name === 'renderNote' + && !!attr.value); - $noteDetailRenderContent.empty(); - $noteDetailRenderContent.toggle(renderNotes.length > 0); - $noteDetailRenderHelp.toggle(renderNotes.length === 0); + this.$component.show(); - for (const renderNote of renderNotes) { - const bundle = await server.get('script/bundle/' + renderNote.value); + this.$noteDetailRenderContent.empty(); + this.$noteDetailRenderContent.toggle(renderNotes.length > 0); + this.$noteDetailRenderHelp.toggle(renderNotes.length === 0); - $noteDetailRenderContent.append(bundle.html); + for (const renderNote of renderNotes) { + const bundle = await server.get('script/bundle/' + renderNote.value); - await bundleService.executeBundle(bundle, noteDetailService.getActiveNote()); + this.$noteDetailRenderContent.append(bundle.html); + + await bundleService.executeBundle(bundle, noteDetailService.getActiveNote()); + } + } + + getContent() {} + + focus() {} + + onNoteChange() {} + + cleanup() { + this.$noteDetailRenderContent.empty(); + } + + scrollToTop() { + this.$component.scrollTop(0); } } -$renderButton.click(render); - -export default { - show: render, - getContent: () => "", - focus: () => null, - onNoteChange: () => null, - cleanup: () => $noteDetailRenderContent.empty(), - scrollToTop: () => $component.scrollTop(0) -} \ No newline at end of file +export default NoteDetailRender; \ No newline at end of file diff --git a/src/public/javascripts/services/note_detail_search.js b/src/public/javascripts/services/note_detail_search.js index c8130ca54..1e5d3274f 100644 --- a/src/public/javascripts/services/note_detail_search.js +++ b/src/public/javascripts/services/note_detail_search.js @@ -1,46 +1,55 @@ import noteDetailService from "./note_detail.js"; import searchNotesService from "./search_notes.js"; -const $searchString = $("#search-string"); -const $component = $('#note-detail-search'); -const $refreshButton = $('#note-detail-search-refresh-results-button'); -const $help = $("#note-detail-search-help"); +class NoteDetailSearch { + /** + * @param {NoteContext} ctx + */ + constructor(ctx) { + this.ctx = ctx; + this.$searchString = ctx.$noteTabContent.find(".search-string"); + this.$component = ctx.$noteTabContent.find('.note-detail-search'); + this.$help = ctx.$noteTabContent.find(".note-detail-search-help"); + this.$refreshButton = ctx.$noteTabContent.find('.note-detail-search-refresh-results-button'); -function show() { - $help.html(searchNotesService.getHelpText()); + this.$refreshButton.click(async () => { + await noteDetailService.saveNotesIfChanged(); - $component.show(); - - try { - const json = JSON.parse(noteDetailService.getActiveNote().content); - - $searchString.val(json.searchString); - } - catch (e) { - console.log(e); - $searchString.val(''); + await searchNotesService.refreshSearch(); + }); } - $searchString.on('input', noteDetailService.noteChanged); + show() { + this.$help.html(searchNotesService.getHelpText()); + + this.$component.show(); + + try { + const json = JSON.parse(this.ctx.note.content); + + this.$searchString.val(json.searchString); + } + catch (e) { + console.log(e); + this.$searchString.val(''); + } + + this.$searchString.on('input', noteDetailService.noteChanged); + } + + getContent() { + return JSON.stringify({ + searchString: this.$searchString.val() + }); + } + + focus() {} + + onNoteChange() {} + + cleanup() {} + + scrollToTop() {} } -function getContent() { - return JSON.stringify({ - searchString: $searchString.val() - }); -} - -$refreshButton.click(async () => { - await noteDetailService.saveNotesIfChanged(); - - await searchNotesService.refreshSearch(); -}); - -export default { - getContent, - show, - focus: () => null, - onNoteChange: () => null, - cleanup: () => null, - scrollToTop: () => null -} \ No newline at end of file +export default NoteDetailSearch; \ No newline at end of file diff --git a/src/public/javascripts/services/note_detail_text.js b/src/public/javascripts/services/note_detail_text.js index 16c21632d..40ae432eb 100644 --- a/src/public/javascripts/services/note_detail_text.js +++ b/src/public/javascripts/services/note_detail_text.js @@ -53,7 +53,7 @@ class NoteDetailText { this.$component.show(); -// this.textEditor.setData(this.ctx.note.content); + this.textEditor.setData(this.ctx.note.content); } getContent() { diff --git a/src/public/stylesheets/style.css b/src/public/stylesheets/style.css index cc56ce891..1895e8d3e 100644 --- a/src/public/stylesheets/style.css +++ b/src/public/stylesheets/style.css @@ -103,8 +103,8 @@ ul.fancytree-container { display: none; } -#note-tab-content-template { - display: none; +.note-tab-content-template { + display: none !important; } .note-tab-content { diff --git a/src/views/tabs.ejs b/src/views/tabs.ejs index 5a80d24b4..ecb302c98 100644 --- a/src/views/tabs.ejs +++ b/src/views/tabs.ejs @@ -3,7 +3,7 @@
-
+
<% include title.ejs %>