This commit is contained in:
zadam 2019-05-01 22:19:29 +02:00
parent c9183f8bd4
commit 7e03f14e01
6 changed files with 306 additions and 259 deletions

View file

@ -0,0 +1,153 @@
import treeService from "./tree";
import protectedSessionHolder from "./protected_session_holder";
import server from "./server";
import bundleService from "./bundle";
import attributeService from "./attributes";
import treeUtils from "./tree_utils";
import utils from "./utils";
import noteDetailCode from "./note_detail_code";
import noteDetailText from "./note_detail_text";
import noteDetailFile from "./note_detail_file";
import noteDetailImage from "./note_detail_image";
import noteDetailSearch from "./note_detail_search";
import noteDetailRender from "./note_detail_render";
import noteDetailRelationMap from "./note_detail_relation_map";
const componentClasses = {
'code': noteDetailCode,
'text': noteDetailText,
'file': noteDetailFile,
'image': noteDetailImage,
'search': noteDetailSearch,
'render': noteDetailRender,
'relation-map': noteDetailRelationMap
};
class NoteContext {
constructor(noteId) {
/** @type {NoteFull} */
this.note = null;
this.noteId = noteId;
this.$noteTab = $noteTabsContainer.find(`[data-note-id="${noteId}"]`);
this.$noteTitle = this.$noteTab.find(".note-title");
this.$noteDetailComponents = this.$noteTab.find(".note-detail-component");
this.$protectButton = this.$noteTab.find(".protect-button");
this.$unprotectButton = this.$noteTab.find(".unprotect-button");
this.$childrenOverview = this.$noteTab.find(".children-overview");
this.$scriptArea = this.$noteTab.find(".note-detail-script-area");
this.isNoteChanged = false;
this.components = {};
this.$noteTitle.on('input', () => {
this.noteChanged();
const title = this.$noteTitle.val();
treeService.setNoteTitle(this.noteId, title);
});
}
getComponent(type) {
if (!type) {
type = this.note.type;
}
if (!(type in this.components)) {
this.components[type] = new componentClasses[type](this);
}
return this.components[type];
}
async saveNote() {
if (this.note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable()) {
return;
}
this.note.title = this.$noteTitle.val();
this.note.content = getActiveNoteContent(this.note);
// it's important to set the flag back to false immediatelly after retrieving title and content
// otherwise we might overwrite another change (especially async code)
this.isNoteChanged = false;
treeService.setNoteTitle(this.note.noteId, this.note.title);
await server.put('notes/' + this.note.noteId, this.note.dto);
if (this.note.isProtected) {
protectedSessionHolder.touchProtectedSession();
}
$savedIndicator.fadeIn();
// run async
bundleService.executeRelationBundles(getActiveNote(), 'runOnNoteChange');
}
async saveNoteIfChanged() {
if (this.isNoteChanged) {
await this.saveNote();
}
}
noteChanged() {
if (noteChangeDisabled) {
return;
}
this.isNoteChanged = true;
$savedIndicator.fadeOut();
}
async showChildrenOverview() {
return; // FIXME
const attributes = await attributeService.getAttributes();
const hideChildrenOverview = attributes.some(attr => attr.type === 'label' && attr.name === 'hideChildrenOverview')
|| this.note.type === 'relation-map'
|| this.note.type === 'image'
|| this.note.type === 'file';
if (hideChildrenOverview) {
this.$childrenOverview.hide();
return;
}
this.$childrenOverview.empty();
const notePath = await treeService.getActiveNotePath();
for (const childBranch of await this.note.getChildBranches()) {
const link = $('<a>', {
href: 'javascript:',
text: await treeUtils.getNoteTitle(childBranch.noteId, childBranch.parentNoteId)
}).attr('data-action', 'note').attr('data-note-path', notePath + '/' + childBranch.noteId);
const childEl = $('<div class="child-overview-item">').html(link);
this.$childrenOverview.append(childEl);
}
this.$childrenOverview.show();
}
updateNoteView() {
this.$noteTab.toggleClass("protected", this.note.isProtected);
this.$protectButton.toggleClass("active", this.note.isProtected);
this.$protectButton.prop("disabled", this.note.isProtected);
this.$unprotectButton.toggleClass("active", !this.note.isProtected);
this.$unprotectButton.prop("disabled", !this.note.isProtected || !protectedSessionHolder.isProtectedSessionAvailable());
for (const clazz of Array.from(this.$noteTab[0].classList)) { // create copy to safely iterate over while removing classes
if (clazz.startsWith("type-") || clazz.startsWith("mime-")) {
this.$noteTab.removeClass(clazz);
}
}
this.$noteTab.addClass(utils.getNoteTypeClass(this.note.type));
this.$noteTab.addClass(utils.getMimeTypeClass(this.note.mime));
}
}
export default NoteContext;

View file

@ -1,5 +1,5 @@
import treeService from './tree.js';
import treeUtils from './tree_utils.js';
import NoteContext from './note_context.js';
import noteTypeService from './note_type.js';
import protectedSessionService from './protected_session.js';
import protectedSessionHolder from './protected_session_holder.js';
@ -8,67 +8,26 @@ import messagingService from "./messaging.js";
import infoService from "./info.js";
import treeCache from "./tree_cache.js";
import NoteFull from "../entities/note_full.js";
import noteDetailCode from './note_detail_code.js';
import noteDetailText from './note_detail_text.js';
import noteDetailFile from './note_detail_file.js';
import noteDetailImage from './note_detail_image.js';
import noteDetailSearch from './note_detail_search.js';
import noteDetailRender from './note_detail_render.js';
import noteDetailRelationMap from './note_detail_relation_map.js';
import bundleService from "./bundle.js";
import attributeService from "./attributes.js";
import utils from "./utils.js";
import importDialog from "../dialogs/import.js";
const $noteTitle = $("#note-title");
const $noteDetailComponents = $(".note-detail-component");
const $protectButton = $("#protect-button");
const $unprotectButton = $("#unprotect-button");
const $noteTabContent = $(".note-tab-content");
const $noteTabsContainer = $("#note-tab-container");
const $childrenOverview = $("#children-overview");
const $scriptArea = $("#note-detail-script-area");
const $savedIndicator = $("#saved-indicator");
const $body = $("body");
let activeNote = null;
let noteChangeDisabled = false;
let isNoteChanged = false;
let detailLoadedListeners = [];
const components = {
'code': noteDetailCode,
'text': noteDetailText,
'file': noteDetailFile,
'image': noteDetailImage,
'search': noteDetailSearch,
'render': noteDetailRender,
'relation-map': noteDetailRelationMap
};
function getComponent(type) {
if (!type) {
type = getActiveNote().type;
}
if (components[type]) {
return components[type];
}
else {
infoService.throwError("Unrecognized type: " + type);
}
}
function getActiveNote() {
return activeNote;
const activeContext = getActiveContext();
return activeContext ? activeContext.note : null;
}
function getActiveNoteId() {
const activeNote = getActiveNote();
return activeNote ? activeNote.noteId : null;
}
@ -78,16 +37,6 @@ function getActiveNoteType() {
return activeNote ? activeNote.type : null;
}
function noteChanged() {
if (noteChangeDisabled) {
return;
}
isNoteChanged = true;
$savedIndicator.fadeOut();
}
async function reload() {
// no saving here
@ -96,78 +45,33 @@ async function reload() {
async function switchToNote(noteId) {
if (getActiveNoteId() !== noteId) {
await saveNoteIfChanged();
await saveNotesIfChanged();
await loadNoteDetail(noteId);
}
}
function getActiveNoteContent() {
return getComponent().getContent();
return getActiveContext().getComponent().getContent();
}
function onNoteChange(func) {
return getComponent().onNoteChange(func);
return getActiveContext().getComponent().onNoteChange(func);
}
async function saveNote() {
const note = getActiveNote();
if (note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable()) {
return;
}
note.title = $noteTitle.val();
note.content = getActiveNoteContent(note);
// it's important to set the flag back to false immediatelly after retrieving title and content
// otherwise we might overwrite another change (especially async code)
isNoteChanged = false;
treeService.setNoteTitle(note.noteId, note.title);
await server.put('notes/' + note.noteId, note.dto);
if (note.isProtected) {
protectedSessionHolder.touchProtectedSession();
}
$savedIndicator.fadeIn();
// run async
bundleService.executeRelationBundles(getActiveNote(), 'runOnNoteChange');
}
async function saveNoteIfChanged() {
if (isNoteChanged) {
await saveNote();
async function saveNotesIfChanged() {
for (const ctx of noteContexts) {
await ctx.saveNoteIfChanged();
}
// make sure indicator is visible in a case there was some race condition.
$savedIndicator.fadeIn();
}
function updateNoteView() {
$noteTabContent.toggleClass("protected", activeNote.isProtected);
$protectButton.toggleClass("active", activeNote.isProtected);
$protectButton.prop("disabled", activeNote.isProtected);
$unprotectButton.toggleClass("active", !activeNote.isProtected);
$unprotectButton.prop("disabled", !activeNote.isProtected || !protectedSessionHolder.isProtectedSessionAvailable());
for (const clazz of Array.from($body[0].classList)) { // create copy to safely iterate over while removing classes
if (clazz.startsWith("type-") || clazz.startsWith("mime-")) {
$body.removeClass(clazz);
}
}
$body.addClass(utils.getNoteTypeClass(activeNote.type));
$body.addClass(utils.getMimeTypeClass(activeNote.mime));
}
async function handleProtectedSession() {
const newSessionCreated = await protectedSessionService.ensureProtectedSession(activeNote.isProtected, false);
const newSessionCreated = await protectedSessionService.ensureProtectedSession(getActiveNote().isProtected, false);
if (activeNote.isProtected) {
if (getActiveNote().isProtected) {
protectedSessionHolder.touchProtectedSession();
}
@ -178,7 +82,34 @@ async function handleProtectedSession() {
return newSessionCreated;
}
/** @type {Object.<string, NoteContext>} */
const noteContexts = {};
/** @returns {NoteContext} */
function getContext(noteId) {
if (noteId in noteContexts) {
return noteContexts[noteId];
}
else {
throw new Error(`Can't find note context for ${noteId}`);
}
}
/** @returns {NoteContext} */
function getActiveContext() {
const currentTreeNode = treeService.getActiveNode();
return getContext(currentTreeNode.data.noteId);
}
function showTab(noteId) {
for (const ctx of noteContexts) {
ctx.$noteTab.toggle(ctx.noteId === noteId);
}
}
async function loadNoteDetail(noteId) {
const ctx = getContext(noteId);
const loadedNote = await loadNote(noteId);
// we will try to render the new note only if it's still the active one in the tree
@ -191,38 +122,41 @@ async function loadNoteDetail(noteId) {
}
// only now that we're in sync with tree active node we will switch activeNote
activeNote = loadedNote;
ctx.note = loadedNote;
ctx.noteId = loadedNote.noteId;
if (utils.isDesktop()) {
// needs to happen after loading the note itself because it references active noteId
attributeService.refreshAttributes();
// FIXME
//attributeService.refreshAttributes();
}
else {
// mobile usually doesn't need attributes so we just invalidate
attributeService.invalidateAttributes();
// FIXME
//attributeService.invalidateAttributes();
}
updateNoteView();
ctx.updateNoteView();
$noteTabContent.show();
showTab(noteId);
noteChangeDisabled = true;
try {
$noteTitle.val(activeNote.title);
ctx.$noteTitle.val(ctx.note.title);
if (utils.isDesktop()) {
noteTypeService.setNoteType(activeNote.type);
noteTypeService.setNoteMime(activeNote.mime);
noteTypeService.setNoteType(ctx.note.type);
noteTypeService.setNoteMime(ctx.note.mime);
}
for (const componentType in components) {
if (componentType !== activeNote.type) {
components[componentType].cleanup();
for (const componentType in ctx.components) {
if (componentType !== ctx.note.type) {
ctx.components[componentType].cleanup();
}
}
$noteDetailComponents.hide();
ctx.$noteDetailComponents.hide();
const newSessionCreated = await handleProtectedSession();
if (newSessionCreated) {
@ -230,9 +164,9 @@ async function loadNoteDetail(noteId) {
return;
}
$noteTitle.removeAttr("readonly"); // this can be set by protected session service
ctx.$noteTitle.removeAttr("readonly"); // this can be set by protected session service
await getComponent(activeNote.type).show();
await ctx.getComponent(ctx.note.type).show(ctx);
}
finally {
noteChangeDisabled = false;
@ -241,51 +175,21 @@ async function loadNoteDetail(noteId) {
treeService.setBranchBackgroundBasedOnProtectedStatus(noteId);
// after loading new note make sure editor is scrolled to the top
getComponent(activeNote.type).scrollToTop();
ctx.getComponent(ctx.note.type).scrollToTop();
fireDetailLoaded();
$scriptArea.empty();
ctx.$scriptArea.empty();
await bundleService.executeRelationBundles(getActiveNote(), 'runOnNoteView');
if (utils.isDesktop()) {
await attributeService.showAttributes();
await showChildrenOverview();
await ctx.showChildrenOverview();
}
}
async function showChildrenOverview() {
const note = getActiveNote();
const attributes = await attributeService.getAttributes();
const hideChildrenOverview = attributes.some(attr => attr.type === 'label' && attr.name === 'hideChildrenOverview')
|| note.type === 'relation-map'
|| note.type === 'image'
|| note.type === 'file';
if (hideChildrenOverview) {
$childrenOverview.hide();
return;
}
$childrenOverview.empty();
const notePath = await treeService.getActiveNotePath();
for (const childBranch of await note.getChildBranches()) {
const link = $('<a>', {
href: 'javascript:',
text: await treeUtils.getNoteTitle(childBranch.noteId, childBranch.parentNoteId)
}).attr('data-action', 'note').attr('data-note-path', notePath + '/' + childBranch.noteId);
const childEl = $('<div class="child-overview-item">').html(link);
$childrenOverview.append(childEl);
}
$childrenOverview.show();
}
async function loadNote(noteId) {
const row = await server.get('notes/' + noteId);
@ -293,11 +197,11 @@ async function loadNote(noteId) {
}
function focusOnTitle() {
$noteTitle.focus();
getActiveContext().$noteTitle.focus();
}
function focusAndSelectTitle() {
$noteTitle.focus().select();
getActiveContext().$noteTitle.focus().select();
}
/**
@ -315,7 +219,7 @@ function addDetailLoadedListener(noteId, callback) {
function fireDetailLoaded() {
for (const {noteId, callback} of detailLoadedListeners) {
if (noteId === activeNote.noteId) {
if (noteId === getActiveNoteId()) {
callback();
}
}
@ -346,28 +250,15 @@ $noteTabsContainer.on("drop", e => {
});
});
$(document).ready(() => {
$noteTitle.on('input', () => {
noteChanged();
const title = $noteTitle.val();
treeService.setNoteTitle(getActiveNoteId(), title);
});
noteDetailText.focus();
});
// this makes sure that when user e.g. reloads the page or navigates away from the page, the note's content is saved
// this sends the request asynchronously and doesn't wait for result
$(window).on('beforeunload', () => { saveNoteIfChanged(); }); // don't convert to short form, handler doesn't like returned promise
$(window).on('beforeunload', () => { saveNotesIfChanged(); }); // don't convert to short form, handler doesn't like returned promise
setInterval(saveNoteIfChanged, 3000);
setInterval(saveNotesIfChanged, 3000);
export default {
reload,
switchToNote,
updateNoteView,
loadNote,
getActiveNote,
getActiveNoteContent,
@ -375,9 +266,7 @@ export default {
getActiveNoteId,
focusOnTitle,
focusAndSelectTitle,
saveNote,
saveNoteIfChanged,
noteChanged,
saveNotesIfChanged,
onNoteChange,
addDetailLoadedListener
};

View file

@ -76,7 +76,7 @@ async function executeCurrentNote() {
}
// make sure note is saved so we load latest changes
await noteDetailService.saveNoteIfChanged();
await noteDetailService.saveNotesIfChanged();
const activeNote = noteDetailService.getActiveNote();

View file

@ -31,7 +31,7 @@ function getContent() {
}
$refreshButton.click(async () => {
await noteDetailService.saveNoteIfChanged();
await noteDetailService.saveNotesIfChanged();
await searchNotesService.refreshSearch();
});

View file

@ -3,93 +3,98 @@ import noteDetailService from './note_detail.js';
import treeService from './tree.js';
import attributeService from "./attributes.js";
const $component = $('#note-detail-text');
class NoteDetailText {
/**
* @param {NoteContext} ctx
*/
constructor(ctx) {
this.$component = ctx.$noteTab.find('.note-detail-text');
this.textEditor = null;
let textEditor = null;
this.$component.on("dblclick", "img", e => {
const $img = $(e.target);
const src = $img.prop("src");
async function show() {
if (!textEditor) {
await libraryLoader.requireLibrary(libraryLoader.CKEDITOR);
const match = src.match(/\/api\/images\/([A-Za-z0-9]+)\//);
// 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.
$component.show();
if (match) {
const noteId = match[1];
// textEditor might have been initialized during previous await so checking again
// looks like double initialization can freeze CKEditor pretty badly
if (!textEditor) {
textEditor = await BalloonEditor.create($component[0], {
placeholder: "Type the content of your note here ..."
});
treeService.activateNote(noteId);
}
else {
window.open(src, '_blank');
}
})
}
onNoteChange(noteDetailService.noteChanged);
async show() {
if (!this.textEditor) {
await libraryLoader.requireLibrary(libraryLoader.CKEDITOR);
// 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();
// textEditor might have been initialized during previous await so checking again
// looks like double initialization can freeze CKEditor pretty badly
if (!this.textEditor) {
this.textEditor = await BalloonEditor.create(this.$component[0], {
placeholder: "Type the content of your note here ..."
});
this.onNoteChange(noteDetailService.noteChanged);
}
}
this.textEditor.isReadOnly = await isReadOnly();
this.$component.show();
this.textEditor.setData(noteDetailService.getActiveNote().content);
}
getContent() {
let content = this.textEditor.getData();
// if content is only tags/whitespace (typically <p>&nbsp;</p>), then just make it empty
// this is important when setting new note to code
if (jQuery(content).text().trim() === '' && !content.includes("<img")) {
content = '';
}
return content;
}
async isReadOnly() {
const attributes = await attributeService.getAttributes();
return attributes.some(attr => attr.type === 'label' && attr.name === 'readOnly');
}
focus() {
this.$component.focus();
}
getEditor() {
return this.textEditor;
}
onNoteChange(func) {
this.textEditor.model.document.on('change:data', func);
}
cleanup() {
if (this.textEditor) {
this.textEditor.setData('');
}
}
textEditor.isReadOnly = await isReadOnly();
$component.show();
textEditor.setData(noteDetailService.getActiveNote().content);
}
function getContent() {
let content = textEditor.getData();
// if content is only tags/whitespace (typically <p>&nbsp;</p>), then just make it empty
// this is important when setting new note to code
if (jQuery(content).text().trim() === '' && !content.includes("<img")) {
content = '';
scrollToTop() {
this.$component.scrollTop(0);
}
return content;
}
async function isReadOnly() {
const attributes = await attributeService.getAttributes();
return attributes.some(attr => attr.type === 'label' && attr.name === 'readOnly');
}
function focus() {
$component.focus();
}
function getEditor() {
return textEditor;
}
function onNoteChange(func) {
textEditor.model.document.on('change:data', func);
}
$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');
}
});
export default {
show,
getEditor,
getContent,
focus,
onNoteChange,
cleanup: () => {
if (textEditor) {
textEditor.setData('');
}
},
scrollToTop: () => $component.scrollTop(0)
}
export default NoteDetailText

View file

@ -624,7 +624,7 @@ async function createNote(node, parentNoteId, target, extraOptions = {}) {
window.cutToNote.removeSelection();
}
await noteDetailService.saveNoteIfChanged();
await noteDetailService.saveNotesIfChanged();
noteDetailService.addDetailLoadedListener(note.noteId, noteDetailService.focusAndSelectTitle);