mirror of
https://github.com/zadam/trilium.git
synced 2025-11-15 09:45:52 +01:00
move components
This commit is contained in:
211
src/public/javascripts/widgets/detail/note_detail_book.js
Normal file
211
src/public/javascripts/widgets/detail/note_detail_book.js
Normal file
@@ -0,0 +1,211 @@
|
||||
import linkService from "../../services/link.js";
|
||||
import treeCache from "../../services/tree_cache.js";
|
||||
import noteContentRenderer from "../../services/note_content_renderer.js";
|
||||
|
||||
const MIN_ZOOM_LEVEL = 1;
|
||||
const MAX_ZOOM_LEVEL = 6;
|
||||
|
||||
const ZOOMS = {
|
||||
1: {
|
||||
width: "100%",
|
||||
height: "100%"
|
||||
},
|
||||
2: {
|
||||
width: "49%",
|
||||
height: "350px"
|
||||
},
|
||||
3: {
|
||||
width: "32%",
|
||||
height: "250px"
|
||||
},
|
||||
4: {
|
||||
width: "24%",
|
||||
height: "200px"
|
||||
},
|
||||
5: {
|
||||
width: "19%",
|
||||
height: "175px"
|
||||
},
|
||||
6: {
|
||||
width: "16%",
|
||||
height: "150px"
|
||||
}
|
||||
};
|
||||
|
||||
class NoteDetailBook {
|
||||
/**
|
||||
* @param {TabContext} ctx
|
||||
*/
|
||||
constructor(ctx) {
|
||||
this.ctx = ctx;
|
||||
this.$component = ctx.$tabContent.find('.note-detail-book');
|
||||
this.$content = this.$component.find('.note-detail-book-content');
|
||||
this.$zoomInButton = this.$component.find('.book-zoom-in-button');
|
||||
this.$zoomOutButton = this.$component.find('.book-zoom-out-button');
|
||||
this.$expandChildrenButton = this.$component.find('.expand-children-button');
|
||||
this.$help = this.$component.find('.note-detail-book-help');
|
||||
|
||||
this.$zoomInButton.on('click', () => this.setZoom(this.zoomLevel - 1));
|
||||
this.$zoomOutButton.on('click', () => this.setZoom(this.zoomLevel + 1));
|
||||
|
||||
this.$expandChildrenButton.on('click', async () => {
|
||||
for (let i = 1; i < 30; i++) { // protection against infinite cycle
|
||||
const $unexpandedLinks = this.$content.find('.note-book-open-children-button:visible');
|
||||
|
||||
if ($unexpandedLinks.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const link of $unexpandedLinks) {
|
||||
const $card = $(link).closest(".note-book-card");
|
||||
|
||||
await this.expandCard($card);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.$content.on('click', '.note-book-open-children-button', async ev => {
|
||||
const $card = $(ev.target).closest('.note-book-card');
|
||||
|
||||
await this.expandCard($card);
|
||||
});
|
||||
|
||||
this.$content.on('click', '.note-book-hide-children-button', async ev => {
|
||||
const $card = $(ev.target).closest('.note-book-card');
|
||||
|
||||
$card.find('.note-book-open-children-button').show();
|
||||
$card.find('.note-book-hide-children-button').hide();
|
||||
|
||||
$card.find('.note-book-children-content').empty();
|
||||
});
|
||||
}
|
||||
|
||||
async expandCard($card) {
|
||||
const noteId = $card.attr('data-note-id');
|
||||
const note = await treeCache.getNote(noteId);
|
||||
|
||||
$card.find('.note-book-open-children-button').hide();
|
||||
$card.find('.note-book-hide-children-button').show();
|
||||
|
||||
await this.renderIntoElement(note, $card.find('.note-book-children-content'));
|
||||
}
|
||||
|
||||
setZoom(zoomLevel) {
|
||||
if (!(zoomLevel in ZOOMS)) {
|
||||
zoomLevel = this.getDefaultZoomLevel();
|
||||
}
|
||||
|
||||
this.zoomLevel = zoomLevel;
|
||||
|
||||
this.$zoomInButton.prop("disabled", zoomLevel === MIN_ZOOM_LEVEL);
|
||||
this.$zoomOutButton.prop("disabled", zoomLevel === MAX_ZOOM_LEVEL);
|
||||
|
||||
this.$content.find('.note-book-card').css("flex-basis", ZOOMS[zoomLevel].width);
|
||||
this.$content.find('.note-book-content').css("max-height", ZOOMS[zoomLevel].height);
|
||||
}
|
||||
|
||||
async render() {
|
||||
this.$content.empty();
|
||||
this.$help.hide();
|
||||
|
||||
if (this.isAutoBook()) {
|
||||
const $addTextLink = $('<a href="javascript:">here</a>').on('click', () => {
|
||||
this.ctx.renderComponent(true);
|
||||
});
|
||||
|
||||
this.$content.append($('<div class="note-book-auto-message"></div>')
|
||||
.append(`This note doesn't have any content so we display its children. Click `)
|
||||
.append($addTextLink)
|
||||
.append(' if you want to add some text.'))
|
||||
}
|
||||
|
||||
const zoomLevel = parseInt(await this.ctx.note.getLabelValue('bookZoomLevel')) || this.getDefaultZoomLevel();
|
||||
this.setZoom(zoomLevel);
|
||||
|
||||
await this.renderIntoElement(this.ctx.note, this.$content);
|
||||
}
|
||||
|
||||
async renderIntoElement(note, $container) {
|
||||
const childNotes = await note.getChildNotes();
|
||||
|
||||
for (const childNote of childNotes) {
|
||||
const childNotePath = this.ctx.notePath + '/' + childNote.noteId;
|
||||
|
||||
const {type, renderedContent} = await noteContentRenderer.getRenderedContent(childNote);
|
||||
|
||||
const $card = $('<div class="note-book-card">')
|
||||
.attr('data-note-id', childNote.noteId)
|
||||
.css("flex-basis", ZOOMS[this.zoomLevel].width)
|
||||
.addClass("type-" + type)
|
||||
.append($('<h5 class="note-book-title">').append(await linkService.createNoteLink(childNotePath, {showTooltip: false})))
|
||||
.append($('<div class="note-book-content">')
|
||||
.css("max-height", ZOOMS[this.zoomLevel].height)
|
||||
.append(renderedContent));
|
||||
|
||||
const childCount = childNote.getChildNoteIds().length;
|
||||
|
||||
if (childCount > 0) {
|
||||
const label = `${childCount} child${childCount > 1 ? 'ren' : ''}`;
|
||||
|
||||
$card.append($('<div class="note-book-children">')
|
||||
.append($(`<a class="note-book-open-children-button" href="javascript:">+ Show ${label}</a>`))
|
||||
.append($(`<a class="note-book-hide-children-button" href="javascript:">- Hide ${label}</a>`).hide())
|
||||
.append($('<div class="note-book-children-content">'))
|
||||
);
|
||||
}
|
||||
|
||||
$container.append($card);
|
||||
}
|
||||
|
||||
if (childNotes.length === 0) {
|
||||
this.$help.show();
|
||||
}
|
||||
}
|
||||
|
||||
/** @return {boolean} true if this is "auto book" activated (empty text note) and not explicit book note */
|
||||
isAutoBook() {
|
||||
return this.ctx.note.type !== 'book';
|
||||
}
|
||||
|
||||
getDefaultZoomLevel() {
|
||||
if (this.isAutoBook()) {
|
||||
const w = this.$component.width();
|
||||
|
||||
if (w <= 600) {
|
||||
return 1;
|
||||
} else if (w <= 900) {
|
||||
return 2;
|
||||
} else if (w <= 1300) {
|
||||
return 3;
|
||||
} else {
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
else {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
getContent() {
|
||||
// for auto-book cases when renaming title there should be content
|
||||
return "";
|
||||
}
|
||||
|
||||
show() {
|
||||
this.$component.show();
|
||||
}
|
||||
|
||||
focus() {}
|
||||
|
||||
onNoteChange() {}
|
||||
|
||||
cleanup() {
|
||||
this.$content.empty();
|
||||
}
|
||||
|
||||
scrollToTop() {
|
||||
this.$component.scrollTop(0);
|
||||
}
|
||||
}
|
||||
|
||||
export default NoteDetailBook;
|
||||
131
src/public/javascripts/widgets/detail/note_detail_code.js
Normal file
131
src/public/javascripts/widgets/detail/note_detail_code.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import libraryLoader from "../../services/library_loader.js";
|
||||
import bundleService from "../../services/bundle.js";
|
||||
import toastService from "../../services/toast.js";
|
||||
import server from "../../services/server.js";
|
||||
import noteDetailService from "../../services/note_detail.js";
|
||||
import keyboardActionService from "../../services/keyboard_actions.js";
|
||||
|
||||
const TPL = `
|
||||
<div class="note-detail-code note-detail-component">
|
||||
<div class="note-detail-code-editor"></div>
|
||||
</div>`;
|
||||
|
||||
class NoteDetailCode {
|
||||
|
||||
/**
|
||||
* @param {TabContext} ctx
|
||||
*/
|
||||
constructor(ctx) {
|
||||
this.ctx = ctx;
|
||||
this.codeEditor = null;
|
||||
this.$component = ctx.$tabContent.find('.note-detail-code');
|
||||
this.$editorEl = this.$component.find('.note-detail-code-editor');
|
||||
this.$executeScriptButton = ctx.$tabContent.find(".execute-script-button");
|
||||
|
||||
keyboardActionService.setElementActionHandler(ctx.$tabContent, 'RunActiveNote', () => this.executeCurrentNote());
|
||||
|
||||
this.$executeScriptButton.on('click', () => this.executeCurrentNote());
|
||||
}
|
||||
|
||||
async render() {
|
||||
await libraryLoader.requireLibrary(libraryLoader.CODE_MIRROR);
|
||||
|
||||
if (!this.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';
|
||||
|
||||
this.codeEditor = CodeMirror(this.$editorEl[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,
|
||||
dragDrop: false // with true the editor inlines dropped files which is not what we expect
|
||||
});
|
||||
|
||||
this.onNoteChange(() => this.ctx.noteChanged());
|
||||
}
|
||||
|
||||
// lazy loading above can take time and tab might have been already switched to another note
|
||||
if (this.ctx.note && this.ctx.note.type === 'code') {
|
||||
// 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.show();
|
||||
}
|
||||
}
|
||||
|
||||
show() {
|
||||
this.$component.show();
|
||||
|
||||
if (this.codeEditor) { // show can be called before render
|
||||
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);
|
||||
}
|
||||
|
||||
toastService.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;
|
||||
44
src/public/javascripts/widgets/detail/note_detail_empty.js
Normal file
44
src/public/javascripts/widgets/detail/note_detail_empty.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import noteAutocompleteService from '../../services/note_autocomplete.js';
|
||||
import treeService from "../../services/tree.js";
|
||||
|
||||
class NoteDetailEmpty {
|
||||
/**
|
||||
* @param {TabContext} ctx
|
||||
*/
|
||||
constructor(ctx) {
|
||||
this.ctx = ctx;
|
||||
this.$component = ctx.$tabContent.find('.note-detail-empty');
|
||||
this.$autoComplete = ctx.$tabContent.find(".note-autocomplete");
|
||||
}
|
||||
|
||||
render() {
|
||||
this.$component.show();
|
||||
this.ctx.$noteTitleRow.hide();
|
||||
|
||||
noteAutocompleteService.initNoteAutocomplete(this.$autoComplete, { hideGoToSelectedNoteButton: true })
|
||||
.on('autocomplete:selected', function(event, suggestion, dataset) {
|
||||
if (!suggestion.path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
treeService.activateNote(suggestion.path);
|
||||
});
|
||||
|
||||
noteAutocompleteService.showRecentNotes(this.$autoComplete);
|
||||
this.$autoComplete.trigger('focus');
|
||||
}
|
||||
|
||||
show() {}
|
||||
|
||||
getContent() {}
|
||||
|
||||
focus() {}
|
||||
|
||||
onNoteChange() {}
|
||||
|
||||
cleanup() {}
|
||||
|
||||
scrollToTop() {}
|
||||
}
|
||||
|
||||
export default NoteDetailEmpty;
|
||||
109
src/public/javascripts/widgets/detail/note_detail_file.js
Normal file
109
src/public/javascripts/widgets/detail/note_detail_file.js
Normal file
@@ -0,0 +1,109 @@
|
||||
import utils from "../../services/utils.js";
|
||||
import server from "../../services/server.js";
|
||||
import toastService from "../../services/toast.js";
|
||||
import noteDetailService from "../../services/note_detail.js";
|
||||
|
||||
class NoteDetailFile {
|
||||
/**
|
||||
* @param {TabContext} ctx
|
||||
*/
|
||||
constructor(ctx) {
|
||||
this.ctx = ctx;
|
||||
this.$component = ctx.$tabContent.find('.note-detail-file');
|
||||
this.$fileNoteId = ctx.$tabContent.find(".file-note-id");
|
||||
this.$fileName = ctx.$tabContent.find(".file-filename");
|
||||
this.$fileType = ctx.$tabContent.find(".file-filetype");
|
||||
this.$fileSize = ctx.$tabContent.find(".file-filesize");
|
||||
this.$previewRow = ctx.$tabContent.find(".file-preview-row");
|
||||
this.$previewContent = ctx.$tabContent.find(".file-preview-content");
|
||||
this.$downloadButton = ctx.$tabContent.find(".file-download");
|
||||
this.$openButton = ctx.$tabContent.find(".file-open");
|
||||
this.$uploadNewRevisionButton = ctx.$tabContent.find(".file-upload-new-revision");
|
||||
this.$uploadNewRevisionInput = ctx.$tabContent.find(".file-upload-new-revision-input");
|
||||
|
||||
this.$downloadButton.on('click', () => utils.download(this.getFileUrl()));
|
||||
|
||||
this.$openButton.on('click', () => {
|
||||
if (utils.isElectron()) {
|
||||
const open = require("open");
|
||||
|
||||
open(this.getFileUrl(), {url: true});
|
||||
}
|
||||
else {
|
||||
window.location.href = this.getFileUrl();
|
||||
}
|
||||
});
|
||||
|
||||
this.$uploadNewRevisionButton.on("click", () => {
|
||||
this.$uploadNewRevisionInput.trigger("click");
|
||||
});
|
||||
|
||||
this.$uploadNewRevisionInput.on('change', async () => {
|
||||
const fileToUpload = this.$uploadNewRevisionInput[0].files[0]; // copy to allow reset below
|
||||
this.$uploadNewRevisionInput.val('');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('upload', fileToUpload);
|
||||
|
||||
const result = await $.ajax({
|
||||
url: baseApiUrl + 'notes/' + this.ctx.note.noteId + '/file',
|
||||
headers: server.getHeaders(),
|
||||
data: formData,
|
||||
type: 'PUT',
|
||||
timeout: 60 * 60 * 1000,
|
||||
contentType: false, // NEEDED, DON'T REMOVE THIS
|
||||
processData: false, // NEEDED, DON'T REMOVE THIS
|
||||
});
|
||||
|
||||
if (result.uploaded) {
|
||||
toastService.showMessage("New file revision has been uploaded.");
|
||||
|
||||
await noteDetailService.reload();
|
||||
}
|
||||
else {
|
||||
toastService.showError("Upload of a new file revision failed.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async render() {
|
||||
const attributes = await server.get('notes/' + this.ctx.note.noteId + '/attributes');
|
||||
const attributeMap = utils.toObject(attributes, l => [l.name, l.value]);
|
||||
|
||||
this.$component.show();
|
||||
|
||||
this.$fileNoteId.text(this.ctx.note.noteId);
|
||||
this.$fileName.text(attributeMap.originalFileName || "?");
|
||||
this.$fileSize.text(this.ctx.note.contentLength + " bytes");
|
||||
this.$fileType.text(this.ctx.note.mime);
|
||||
|
||||
if (this.ctx.note.content) {
|
||||
this.$previewRow.show();
|
||||
this.$previewContent.text(this.ctx.note.content);
|
||||
}
|
||||
else {
|
||||
this.$previewRow.hide();
|
||||
}
|
||||
|
||||
// open doesn't work for protected notes since it works through browser which isn't in protected session
|
||||
this.$openButton.toggle(!this.ctx.note.isProtected);
|
||||
}
|
||||
|
||||
getFileUrl() {
|
||||
return utils.getUrlForDownload("api/notes/" + this.ctx.note.noteId + "/download");
|
||||
}
|
||||
|
||||
show() {}
|
||||
|
||||
getContent() {}
|
||||
|
||||
focus() {}
|
||||
|
||||
onNoteChange() {}
|
||||
|
||||
cleanup() {}
|
||||
|
||||
scrollToTop() {}
|
||||
}
|
||||
|
||||
export default NoteDetailFile;
|
||||
122
src/public/javascripts/widgets/detail/note_detail_image.js
Normal file
122
src/public/javascripts/widgets/detail/note_detail_image.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import utils from "../../services/utils.js";
|
||||
import toastService from "../../services/toast.js";
|
||||
import server from "../../services/server.js";
|
||||
import noteDetailService from "../../services/note_detail.js";
|
||||
|
||||
class NoteDetailImage {
|
||||
/**
|
||||
* @param {TabContext} ctx
|
||||
*/
|
||||
constructor(ctx) {
|
||||
this.ctx = ctx;
|
||||
this.$component = ctx.$tabContent.find('.note-detail-image');
|
||||
this.$imageWrapper = ctx.$tabContent.find('.note-detail-image-wrapper');
|
||||
this.$imageView = ctx.$tabContent.find('.note-detail-image-view');
|
||||
this.$copyToClipboardButton = ctx.$tabContent.find(".image-copy-to-clipboard");
|
||||
this.$uploadNewRevisionButton = ctx.$tabContent.find(".image-upload-new-revision");
|
||||
this.$uploadNewRevisionInput = ctx.$tabContent.find(".image-upload-new-revision-input");
|
||||
this.$fileName = ctx.$tabContent.find(".image-filename");
|
||||
this.$fileType = ctx.$tabContent.find(".image-filetype");
|
||||
this.$fileSize = ctx.$tabContent.find(".image-filesize");
|
||||
|
||||
this.$imageDownloadButton = ctx.$tabContent.find(".image-download");
|
||||
this.$imageDownloadButton.on('click', () => utils.download(this.getFileUrl()));
|
||||
|
||||
this.$copyToClipboardButton.on('click',() => {
|
||||
this.$imageWrapper.attr('contenteditable','true');
|
||||
|
||||
try {
|
||||
this.selectImage(this.$imageWrapper.get(0));
|
||||
|
||||
const success = document.execCommand('copy');
|
||||
|
||||
if (success) {
|
||||
toastService.showMessage("Image copied to the clipboard");
|
||||
}
|
||||
else {
|
||||
toastService.showAndLogError("Could not copy the image to clipboard.");
|
||||
}
|
||||
}
|
||||
finally {
|
||||
window.getSelection().removeAllRanges();
|
||||
this.$imageWrapper.removeAttr('contenteditable');
|
||||
}
|
||||
});
|
||||
|
||||
this.$uploadNewRevisionButton.on("click", () => {
|
||||
this.$uploadNewRevisionInput.trigger("click");
|
||||
});
|
||||
|
||||
this.$uploadNewRevisionInput.on('change', async () => {
|
||||
const fileToUpload = this.$uploadNewRevisionInput[0].files[0]; // copy to allow reset below
|
||||
this.$uploadNewRevisionInput.val('');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('upload', fileToUpload);
|
||||
|
||||
const result = await $.ajax({
|
||||
url: baseApiUrl + 'images/' + this.ctx.note.noteId,
|
||||
headers: server.getHeaders(),
|
||||
data: formData,
|
||||
type: 'PUT',
|
||||
timeout: 60 * 60 * 1000,
|
||||
contentType: false, // NEEDED, DON'T REMOVE THIS
|
||||
processData: false, // NEEDED, DON'T REMOVE THIS
|
||||
});
|
||||
|
||||
if (result.uploaded) {
|
||||
toastService.showMessage("New image revision has been uploaded.");
|
||||
|
||||
await utils.clearBrowserCache();
|
||||
|
||||
await noteDetailService.reload();
|
||||
}
|
||||
else {
|
||||
toastService.showError("Upload of a new image revision failed: " + result.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async render() {
|
||||
const attributes = await server.get('notes/' + this.ctx.note.noteId + '/attributes');
|
||||
const attributeMap = utils.toObject(attributes, l => [l.name, l.value]);
|
||||
|
||||
this.$component.show();
|
||||
|
||||
this.$fileName.text(attributeMap.originalFileName || "?");
|
||||
this.$fileSize.text(this.ctx.note.contentLength + " bytes");
|
||||
this.$fileType.text(this.ctx.note.mime);
|
||||
|
||||
const imageHash = this.ctx.note.utcDateModified.replace(" ", "_");
|
||||
|
||||
this.$imageView.prop("src", `api/images/${this.ctx.note.noteId}/${this.ctx.note.title}?${imageHash}`);
|
||||
}
|
||||
|
||||
selectImage(element) {
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
getFileUrl() {
|
||||
return utils.getUrlForDownload(`api/notes/${this.ctx.note.noteId}/download`);
|
||||
}
|
||||
|
||||
show() {}
|
||||
|
||||
getContent() {}
|
||||
|
||||
focus() {}
|
||||
|
||||
onNoteChange() {}
|
||||
|
||||
cleanup() {}
|
||||
|
||||
scrollToTop() {
|
||||
this.$component.scrollTop(0);
|
||||
}
|
||||
}
|
||||
|
||||
export default NoteDetailImage
|
||||
@@ -0,0 +1,42 @@
|
||||
import protectedSessionService from '../../services/protected_session.js';
|
||||
|
||||
class NoteDetailProtectedSession {
|
||||
/**
|
||||
* @param {TabContext} ctx
|
||||
*/
|
||||
constructor(ctx) {
|
||||
this.ctx = ctx;
|
||||
this.$component = ctx.$tabContent.find(".protected-session-password-component");
|
||||
this.$passwordForm = ctx.$tabContent.find(".protected-session-password-form");
|
||||
this.$passwordInput = ctx.$tabContent.find(".protected-session-password");
|
||||
|
||||
this.$passwordForm.on('submit', () => {
|
||||
const password = this.$passwordInput.val();
|
||||
this.$passwordInput.val("");
|
||||
|
||||
protectedSessionService.setupProtectedSession(password);
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
this.$component.show();
|
||||
}
|
||||
|
||||
show() {}
|
||||
|
||||
getContent() {}
|
||||
|
||||
focus() {}
|
||||
|
||||
onNoteChange() {}
|
||||
|
||||
cleanup() {}
|
||||
|
||||
scrollToTop() {
|
||||
this.$component.scrollTop(0);
|
||||
}
|
||||
}
|
||||
|
||||
export default NoteDetailProtectedSession;
|
||||
@@ -0,0 +1,628 @@
|
||||
import server from "../../services/server.js";
|
||||
import noteDetailService from "../../services/note_detail.js";
|
||||
import linkService from "../../services/link.js";
|
||||
import libraryLoader from "../../services/library_loader.js";
|
||||
import treeService from "../../services/tree.js";
|
||||
import contextMenuWidget from "../../services/context_menu.js";
|
||||
import toastService from "../../services/toast.js";
|
||||
import attributeAutocompleteService from "../../services/attribute_autocomplete.js";
|
||||
|
||||
const uniDirectionalOverlays = [
|
||||
[ "Arrow", {
|
||||
location: 1,
|
||||
id: "arrow",
|
||||
length: 14,
|
||||
foldback: 0.8
|
||||
} ],
|
||||
[ "Label", { label: "", id: "label", cssClass: "connection-label" }]
|
||||
];
|
||||
|
||||
const biDirectionalOverlays = [
|
||||
[ "Arrow", {
|
||||
location: 1,
|
||||
id: "arrow",
|
||||
length: 14,
|
||||
foldback: 0.8
|
||||
} ],
|
||||
[ "Label", { label: "", id: "label", cssClass: "connection-label" }],
|
||||
[ "Arrow", {
|
||||
location: 0,
|
||||
id: "arrow2",
|
||||
length: 14,
|
||||
direction: -1,
|
||||
foldback: 0.8
|
||||
} ]
|
||||
];
|
||||
|
||||
const inverseRelationsOverlays = [
|
||||
[ "Arrow", {
|
||||
location: 1,
|
||||
id: "arrow",
|
||||
length: 14,
|
||||
foldback: 0.8
|
||||
} ],
|
||||
[ "Label", { label: "", location: 0.2, id: "label-source", cssClass: "connection-label" }],
|
||||
[ "Label", { label: "", location: 0.8, id: "label-target", cssClass: "connection-label" }],
|
||||
[ "Arrow", {
|
||||
location: 0,
|
||||
id: "arrow2",
|
||||
length: 14,
|
||||
direction: -1,
|
||||
foldback: 0.8
|
||||
} ]
|
||||
];
|
||||
|
||||
const linkOverlays = [
|
||||
[ "Arrow", {
|
||||
location: 1,
|
||||
id: "arrow",
|
||||
length: 14,
|
||||
foldback: 0.8
|
||||
} ]
|
||||
];
|
||||
|
||||
let containerCounter = 1;
|
||||
|
||||
class NoteDetailRelationMap {
|
||||
/**
|
||||
* @param {TabContext} ctx
|
||||
*/
|
||||
constructor(ctx) {
|
||||
this.ctx = ctx;
|
||||
this.$component = ctx.$tabContent.find(".note-detail-relation-map");
|
||||
this.$relationMapContainer = ctx.$tabContent.find(".relation-map-container");
|
||||
this.$createChildNote = ctx.$tabContent.find(".relation-map-create-child-note");
|
||||
this.$zoomInButton = ctx.$tabContent.find(".relation-map-zoom-in");
|
||||
this.$zoomOutButton = ctx.$tabContent.find(".relation-map-zoom-out");
|
||||
this.$resetPanZoomButton = ctx.$tabContent.find(".relation-map-reset-pan-zoom");
|
||||
|
||||
this.mapData = null;
|
||||
this.jsPlumbInstance = null;
|
||||
// outside of mapData because they are not persisted in the note content
|
||||
this.relations = null;
|
||||
this.pzInstance = null;
|
||||
|
||||
this.$relationMapWrapper = ctx.$tabContent.find('.relation-map-wrapper');
|
||||
this.$relationMapWrapper.on('click', 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;
|
||||
});
|
||||
|
||||
this.$relationMapContainer.attr("id", "relation-map-container-" + (containerCounter++));
|
||||
this.$relationMapContainer.on("contextmenu", ".note-box", e => {
|
||||
contextMenuWidget.initContextMenu(e, {
|
||||
getContextMenuItems: () => {
|
||||
return [
|
||||
{title: "Open in new tab", cmd: "open-in-new-tab", uiIcon: "empty"},
|
||||
{title: "Remove note", cmd: "remove", uiIcon: "trash"},
|
||||
{title: "Edit title", cmd: "edit-title", uiIcon: "pencil"},
|
||||
];
|
||||
},
|
||||
selectContextMenuItem: (event, cmd) => this.tabContextMenuHandler(event, cmd)
|
||||
});
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
this.clipboard = null;
|
||||
|
||||
this.$createChildNote.on('click', async () => {
|
||||
const promptDialog = await import('../../dialogs/prompt.js');
|
||||
const title = await promptDialog.ask({ message: "Enter title of new note", defaultValue: "new note" });
|
||||
|
||||
if (!title.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {note} = await server.post(`notes/${this.ctx.note.noteId}/children?target=into`, {
|
||||
title,
|
||||
content: '',
|
||||
type: 'text'
|
||||
});
|
||||
|
||||
toastService.showMessage("Click on canvas to place new note");
|
||||
|
||||
// reloading tree so that the new note appears there
|
||||
// no need to wait for it to finish
|
||||
treeService.reload();
|
||||
|
||||
this.clipboard = { noteId: note.noteId, title };
|
||||
});
|
||||
|
||||
this.$resetPanZoomButton.on('click', () => {
|
||||
// reset to initial pan & zoom state
|
||||
this.pzInstance.zoomTo(0, 0, 1 / this.getZoom());
|
||||
this.pzInstance.moveTo(0, 0);
|
||||
});
|
||||
|
||||
this.$component.on("drop", ev => this.dropNoteOntoRelationMapHandler(ev));
|
||||
this.$component.on("dragover", ev => ev.preventDefault());
|
||||
}
|
||||
|
||||
async tabContextMenuHandler(event, cmd) {
|
||||
const $noteBox = $(event.originalTarget).closest(".note-box");
|
||||
const $title = $noteBox.find(".title a");
|
||||
const noteId = this.idToNoteId($noteBox.prop("id"));
|
||||
|
||||
if (cmd === "open-in-new-tab") {
|
||||
noteDetailService.openInTab(noteId, false);
|
||||
}
|
||||
else if (cmd === "remove") {
|
||||
const confirmDialog = await import('../../dialogs/confirm.js');
|
||||
|
||||
if (!await confirmDialog.confirmDeleteNoteBoxWithNote($title.text())) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.jsPlumbInstance.remove(this.noteIdToId(noteId));
|
||||
|
||||
if (confirmDialog.isDeleteNoteChecked()) {
|
||||
await server.remove("notes/" + noteId);
|
||||
|
||||
// to force it to disappear from the tree
|
||||
treeService.reload();
|
||||
}
|
||||
|
||||
this.mapData.notes = this.mapData.notes.filter(note => note.noteId !== noteId);
|
||||
|
||||
this.relations = this.relations.filter(relation => relation.sourceNoteId !== noteId && relation.targetNoteId !== noteId);
|
||||
|
||||
this.saveData();
|
||||
}
|
||||
else if (cmd === "edit-title") {
|
||||
const promptDialog = await import("../../dialogs/prompt.js");
|
||||
const title = await promptDialog.ask({
|
||||
message: "Enter new note title:",
|
||||
defaultValue: $title.text()
|
||||
});
|
||||
|
||||
if (!title) {
|
||||
return;
|
||||
}
|
||||
|
||||
await server.put(`notes/${noteId}/change-title`, { title });
|
||||
|
||||
treeService.setNoteTitle(noteId, title);
|
||||
|
||||
$title.text(title);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
if (this.ctx.note.content) {
|
||||
try {
|
||||
this.mapData = JSON.parse(this.ctx.note.content);
|
||||
} catch (e) {
|
||||
console.log("Could not parse content: ", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
noteIdToId(noteId) {
|
||||
return "rel-map-note-" + noteId;
|
||||
}
|
||||
|
||||
idToNoteId(id) {
|
||||
return id.substr(13);
|
||||
}
|
||||
|
||||
async render() {
|
||||
this.$component.show();
|
||||
|
||||
await libraryLoader.requireLibrary(libraryLoader.RELATION_MAP);
|
||||
|
||||
jsPlumb.ready(() => {
|
||||
// lazy loading above can take time and tab might have been already switched to another note
|
||||
if (this.ctx.note && this.ctx.note.type === 'relation-map') {
|
||||
this.loadMapData();
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initPanZoom() {
|
||||
if (this.pzInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pzInstance = panzoom(this.$relationMapContainer[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;
|
||||
}
|
||||
});
|
||||
|
||||
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.on('click', () => this.pzInstance.zoomTo(0, 0, 1.2));
|
||||
this.$zoomOutButton.on('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));
|
||||
}
|
||||
|
||||
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') {
|
||||
const confirmDialog = await import('../../dialogs/confirm.js');
|
||||
|
||||
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 promptDialog = await import("../../dialogs/prompt.js");
|
||||
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) {
|
||||
const infoDialog = await import('../../dialogs/info.js');
|
||||
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 $link = await linkService.createNoteLink(noteId, {title});
|
||||
$link.mousedown(e => {
|
||||
console.log(e);
|
||||
|
||||
linkService.goToLink(e);
|
||||
});
|
||||
|
||||
const $noteBox = $("<div>")
|
||||
.addClass("note-box")
|
||||
.prop("id", this.noteIdToId(noteId))
|
||||
.append($("<span>").addClass("title").append($link))
|
||||
.append($("<div>").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 transform = this.$relationMapContainer.css('transform');
|
||||
|
||||
if (transform === 'none') {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const matches = transform.match(matrixRegex);
|
||||
|
||||
if (!matches) {
|
||||
throw new Error("Cannot match transform: " + transform);
|
||||
}
|
||||
|
||||
return matches[1];
|
||||
}
|
||||
|
||||
async dropNoteOntoRelationMapHandler(ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
const notes = JSON.parse(ev.originalEvent.dataTransfer.getData("text"));
|
||||
|
||||
let {x, y} = this.getMousePosition(ev);
|
||||
|
||||
for (const note of notes) {
|
||||
const exists = this.mapData.notes.some(n => n.noteId === note.noteId);
|
||||
|
||||
if (exists) {
|
||||
toastService.showError(`Note "${note.title}" is already in the diagram.`);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
this.mapData.notes.push({noteId: note.noteId, x, y});
|
||||
|
||||
if (x > 1000) {
|
||||
y += 100;
|
||||
x = 0;
|
||||
}
|
||||
else {
|
||||
x += 200;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
show() {}
|
||||
|
||||
focus() {}
|
||||
|
||||
onNoteChange() {}
|
||||
|
||||
scrollToTop() {}
|
||||
}
|
||||
|
||||
export default NoteDetailRelationMap;
|
||||
45
src/public/javascripts/widgets/detail/note_detail_render.js
Normal file
45
src/public/javascripts/widgets/detail/note_detail_render.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import renderService from "../../services/render.js";
|
||||
|
||||
class NoteDetailRender {
|
||||
/**
|
||||
* @param {TabContext} ctx
|
||||
*/
|
||||
constructor(ctx) {
|
||||
this.ctx = ctx;
|
||||
this.$component = ctx.$tabContent.find('.note-detail-render');
|
||||
this.$noteDetailRenderHelp = ctx.$tabContent.find('.note-detail-render-help');
|
||||
this.$noteDetailRenderContent = ctx.$tabContent.find('.note-detail-render-content');
|
||||
this.$renderButton = ctx.$tabContent.find('.render-button');
|
||||
|
||||
this.$renderButton.on('click', () => this.render()); // long form!
|
||||
}
|
||||
|
||||
async render() {
|
||||
this.$component.show();
|
||||
this.$noteDetailRenderHelp.hide();
|
||||
|
||||
const renderNotesFound = await renderService.render(this.ctx.note, this.$noteDetailRenderContent, this.ctx);
|
||||
|
||||
if (!renderNotesFound) {
|
||||
this.$noteDetailRenderHelp.show();
|
||||
}
|
||||
}
|
||||
|
||||
getContent() {}
|
||||
|
||||
show() {}
|
||||
|
||||
focus() {}
|
||||
|
||||
onNoteChange() {}
|
||||
|
||||
cleanup() {
|
||||
this.$noteDetailRenderContent.empty();
|
||||
}
|
||||
|
||||
scrollToTop() {
|
||||
this.$component.scrollTop(0);
|
||||
}
|
||||
}
|
||||
|
||||
export default NoteDetailRender;
|
||||
57
src/public/javascripts/widgets/detail/note_detail_search.js
Normal file
57
src/public/javascripts/widgets/detail/note_detail_search.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import noteDetailService from "../../services/note_detail.js";
|
||||
import searchNotesService from "../../services/search_notes.js";
|
||||
|
||||
class NoteDetailSearch {
|
||||
/**
|
||||
* @param {TabContext} ctx
|
||||
*/
|
||||
constructor(ctx) {
|
||||
this.ctx = ctx;
|
||||
this.$searchString = ctx.$tabContent.find(".search-string");
|
||||
this.$component = ctx.$tabContent.find('.note-detail-search');
|
||||
this.$help = ctx.$tabContent.find(".note-detail-search-help");
|
||||
this.$refreshButton = ctx.$tabContent.find('.note-detail-search-refresh-results-button');
|
||||
|
||||
this.$refreshButton.on('click', async () => {
|
||||
await noteDetailService.saveNotesIfChanged();
|
||||
|
||||
await searchNotesService.refreshSearch();
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
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', () => this.ctx.noteChanged());
|
||||
}
|
||||
|
||||
getContent() {
|
||||
return JSON.stringify({
|
||||
searchString: this.$searchString.val()
|
||||
});
|
||||
}
|
||||
|
||||
focus() {}
|
||||
|
||||
show() {}
|
||||
|
||||
onNoteChange() {}
|
||||
|
||||
cleanup() {}
|
||||
|
||||
scrollToTop() {}
|
||||
}
|
||||
|
||||
export default NoteDetailSearch;
|
||||
203
src/public/javascripts/widgets/detail/note_detail_text.js
Normal file
203
src/public/javascripts/widgets/detail/note_detail_text.js
Normal file
@@ -0,0 +1,203 @@
|
||||
import libraryLoader from "../../services/library_loader.js";
|
||||
import treeService from '../../services/tree.js';
|
||||
import noteAutocompleteService from '../../services/note_autocomplete.js';
|
||||
import mimeTypesService from '../../services/mime_types.js';
|
||||
|
||||
const ENABLE_INSPECTOR = false;
|
||||
|
||||
const mentionSetup = {
|
||||
feeds: [
|
||||
{
|
||||
marker: '@',
|
||||
feed: queryText => {
|
||||
return new Promise((res, rej) => {
|
||||
noteAutocompleteService.autocompleteSource(queryText, rows => {
|
||||
if (rows.length === 1 && rows[0].title === 'No results') {
|
||||
rows = [];
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
row.text = row.name = row.noteTitle;
|
||||
row.id = '@' + row.text;
|
||||
row.link = '#' + row.path;
|
||||
}
|
||||
|
||||
res(rows);
|
||||
});
|
||||
});
|
||||
},
|
||||
itemRenderer: item => {
|
||||
const itemElement = document.createElement('span');
|
||||
|
||||
itemElement.classList.add('mentions-item');
|
||||
itemElement.innerHTML = `${item.highlightedTitle} `;
|
||||
|
||||
return itemElement;
|
||||
},
|
||||
minimumCharacters: 0
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const TPL = `
|
||||
<div class="note-detail-text note-detail-component">
|
||||
<style>
|
||||
.note-detail-text h1 { font-size: 2.0em; }
|
||||
.note-detail-text h2 { font-size: 1.8em; }
|
||||
.note-detail-text h3 { font-size: 1.6em; }
|
||||
.note-detail-text h4 { font-size: 1.4em; }
|
||||
.note-detail-text h5 { font-size: 1.2em; }
|
||||
.note-detail-text h6 { font-size: 1.1em; }
|
||||
|
||||
.note-detail-text {
|
||||
overflow: auto;
|
||||
font-family: var(--detail-text-font-family);
|
||||
}
|
||||
|
||||
.note-detail-text-editor {
|
||||
padding-top: 10px;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
/* This is because with empty content height of editor is 0 and it's impossible to click into it */
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.note-detail-text p:first-child, .note-detail-text::before {
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="note-detail-text-editor" tabindex="10000"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
class NoteDetailText {
|
||||
/**
|
||||
* @param {TabContext} ctx
|
||||
*/
|
||||
constructor(ctx, $parent) {
|
||||
this.$component = $(TPL);
|
||||
$parent.append(this.$component);
|
||||
this.ctx = ctx;
|
||||
this.$editorEl = this.$component.find('.note-detail-text-editor');
|
||||
this.textEditorPromise = null;
|
||||
this.textEditor = null;
|
||||
|
||||
this.$component.on("dblclick", "img", e => {
|
||||
const $img = $(e.target);
|
||||
const src = $img.prop("src");
|
||||
|
||||
const match = src.match(/\/api\/images\/([A-Za-z0-9]+)\//);
|
||||
|
||||
if (match) {
|
||||
const noteId = match[1];
|
||||
|
||||
treeService.activateNote(noteId);
|
||||
}
|
||||
else {
|
||||
window.open(src, '_blank');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async render() {
|
||||
if (!this.textEditorPromise) {
|
||||
this.textEditorPromise = this.initEditor();
|
||||
}
|
||||
|
||||
await this.textEditorPromise;
|
||||
|
||||
// lazy loading above can take time and tab might have been already switched to another note
|
||||
if (this.ctx.note && this.ctx.note.type === 'text') {
|
||||
this.textEditor.isReadOnly = await this.isReadOnly();
|
||||
|
||||
this.$component.show();
|
||||
|
||||
this.textEditor.setData(this.ctx.note.content);
|
||||
}
|
||||
}
|
||||
|
||||
async initEditor() {
|
||||
await libraryLoader.requireLibrary(libraryLoader.CKEDITOR);
|
||||
|
||||
const codeBlockLanguages =
|
||||
(await mimeTypesService.getMimeTypes())
|
||||
.filter(mt => mt.enabled)
|
||||
.map(mt => {
|
||||
return {
|
||||
language: mt.mime.toLowerCase().replace(/[\W_]+/g,"-"),
|
||||
label: mt.title
|
||||
}
|
||||
});
|
||||
|
||||
// CKEditor since version 12 needs the element to be visible before initialization. At the same time
|
||||
// we want to avoid flicker - i.e. show editor only once everything is ready. That's why we have separate
|
||||
// display of $component in both branches.
|
||||
this.$component.show();
|
||||
|
||||
const textEditorInstance = await BalloonEditor.create(this.$editorEl[0], {
|
||||
placeholder: "Type the content of your note here ...",
|
||||
mention: mentionSetup,
|
||||
codeBlock: {
|
||||
languages: codeBlockLanguages
|
||||
}
|
||||
});
|
||||
|
||||
if (glob.isDev && ENABLE_INSPECTOR) {
|
||||
await import('../../libraries/ckeditor/inspector.js');
|
||||
CKEditorInspector.attach(textEditorInstance);
|
||||
}
|
||||
|
||||
this.textEditor = textEditorInstance;
|
||||
|
||||
this.onNoteChange(() => this.ctx.noteChanged());
|
||||
}
|
||||
|
||||
getContent() {
|
||||
const content = this.textEditor.getData();
|
||||
|
||||
// if content is only tags/whitespace (typically <p> </p>), then just make it empty
|
||||
// this is important when setting new note to code
|
||||
return this.isContentEmpty(content) ? '' : content;
|
||||
}
|
||||
|
||||
isContentEmpty(content) {
|
||||
content = content.toLowerCase();
|
||||
|
||||
return jQuery(content).text().trim() === ''
|
||||
&& !content.includes("<img")
|
||||
&& !content.includes("<section")
|
||||
}
|
||||
|
||||
async isReadOnly() {
|
||||
const attributes = await this.ctx.attributes.getAttributes();
|
||||
|
||||
return attributes.some(attr => attr.type === 'label' && attr.name === 'readOnly');
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.$editorEl.trigger('focus');
|
||||
}
|
||||
|
||||
show() {}
|
||||
|
||||
getEditor() {
|
||||
return this.textEditor;
|
||||
}
|
||||
|
||||
onNoteChange(func) {
|
||||
this.textEditor.model.document.on('change:data', func);
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this.textEditor) {
|
||||
this.textEditor.setData('');
|
||||
}
|
||||
}
|
||||
|
||||
scrollToTop() {
|
||||
this.$component.scrollTop(0);
|
||||
}
|
||||
}
|
||||
|
||||
export default NoteDetailText
|
||||
@@ -17,16 +17,16 @@ const TPL = `
|
||||
`;
|
||||
|
||||
const componentClasses = {
|
||||
'empty': "../services/note_detail_empty.js",
|
||||
'text': "../services/note_detail_text.js",
|
||||
'code': "../services/note_detail_code.js",
|
||||
'file': "../services/note_detail_file.js",
|
||||
'image': "../services/note_detail_image.js",
|
||||
'search': "../services/note_detail_search.js",
|
||||
'render': "../services/note_detail_render.js",
|
||||
'relation-map': "../services/note_detail_relation_map.js",
|
||||
'protected-session': "../services/note_detail_protected_session.js",
|
||||
'book': "../services/note_detail_book.js"
|
||||
'empty': "./detail/note_detail_empty.js",
|
||||
'text': "./detail/note_detail_text.js",
|
||||
'code': "./detail/note_detail_code.js",
|
||||
'file': "./detail/note_detail_file.js",
|
||||
'image': "./detail/note_detail_image.js",
|
||||
'search': "./detail/note_detail_search.js",
|
||||
'render': "./detail/note_detail_render.js",
|
||||
'relation-map': "./detail/note_detail_relation_map.js",
|
||||
'protected-session': "./detail/note_detail_protected_session.js",
|
||||
'book': "./detail/note_detail_book.js"
|
||||
};
|
||||
|
||||
export default class NoteDetailWidget extends TabAwareWidget {
|
||||
|
||||
633
src/public/javascripts/widgets/tab_row.js
Normal file
633
src/public/javascripts/widgets/tab_row.js
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user