fix setup of new document, closes #966

This commit is contained in:
zadam
2020-04-14 21:57:42 +02:00
parent 48aadc8309
commit 29cec8112e
149 changed files with 194 additions and 181 deletions

View File

@@ -0,0 +1,94 @@
import utils from "../services/utils.js";
import linkService from "../services/link.js";
import ws from "../services/ws.js";
import CollapsibleWidget from "./collapsible_widget.js";
export default class AttributesWidget extends CollapsibleWidget {
get widgetTitle() { return "Attributes"; }
get help() {
return {
title: "Attributes are key-value records owned by assigned to this note.",
url: "https://github.com/zadam/trilium/wiki/Attributes"
};
}
get headerActions() {
const $showFullButton = $("<a>").append("show dialog").addClass('widget-header-action');
$showFullButton.on('click', async () => {
const attributesDialog = await import("../dialogs/attributes.js");
attributesDialog.showDialog();
});
return [$showFullButton];
}
async refreshWithNote(note) {
const ownedAttributes = note.getOwnedAttributes();
const $attributesContainer = $("<div>");
await this.renderAttributes(ownedAttributes, $attributesContainer);
const $inheritedAttrs = $("<span>").append($("<strong>").text("Inherited: "));
const $showInheritedAttributes = $("<a>")
.attr("href", "javascript:")
.text("+show inherited")
.on('click', async () => {
const attributes = note.getAttributes();
const inheritedAttributes = attributes.filter(attr => attr.noteId !== this.noteId);
if (inheritedAttributes.length === 0) {
$inheritedAttrs.text("No inherited attributes yet...");
}
else {
await this.renderAttributes(inheritedAttributes, $inheritedAttrs);
}
$inheritedAttrs.show();
$showInheritedAttributes.hide();
$hideInheritedAttributes.show();
});
const $hideInheritedAttributes = $("<a>")
.attr("href", "javascript:")
.text("-hide inherited")
.on('click', () => {
$showInheritedAttributes.show();
$hideInheritedAttributes.hide();
$inheritedAttrs.empty().hide();
});
$attributesContainer.append($showInheritedAttributes, $inheritedAttrs, $hideInheritedAttributes);
$inheritedAttrs.hide();
$hideInheritedAttributes.hide();
this.$body.empty().append($attributesContainer);
}
async renderAttributes(attributes, $container) {
for (const attribute of attributes) {
if (attribute.type === 'label') {
$container.append(utils.formatLabel(attribute) + " ");
} else if (attribute.type === 'relation') {
if (attribute.value) {
$container.append('@' + attribute.name + "=");
$container.append(await linkService.createNoteLink(attribute.value));
$container.append(" ");
} else {
ws.logError(`Relation ${attribute.attributeId} has empty target`);
}
} else if (attribute.type === 'label-definition' || attribute.type === 'relation-definition') {
$container.append(attribute.name + " definition ");
} else {
ws.logError("Unknown attr type: " + attribute.type);
}
}
}
entitiesReloadedEvent({loadResults}) {
if (loadResults.getAttributes().find(attr => attr.noteId === this.noteId)) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,109 @@
import Component from "./component.js";
class BasicWidget extends Component {
constructor() {
super();
this.attrs = {
style: ''
};
this.classes = [];
}
id(id) {
this.attrs.id = id;
return this;
}
class(className) {
this.classes.push(className);
return this;
}
css(name, value) {
this.attrs.style += `${name}: ${value};`;
return this;
}
collapsible() {
this.css('min-height', '0');
return this;
}
hideInZenMode() {
this.class('hide-in-zen-mode');
return this;
}
cssBlock(block) {
this.cssEl = block;
return this;
}
render() {
const $widget = this.doRender();
$widget.addClass('component')
.prop('component', this);
this.toggleInt(this.isEnabled());
if (this.cssEl) {
const css = this.cssEl.trim().startsWith('<style>') ? this.cssEl : `<style>${this.cssEl}</style>`;
$widget.append(css);
}
for (const key in this.attrs) {
if (key === 'style') {
if (this.attrs[key]) {
$widget.attr(key, $widget.attr('style') + ';' + this.attrs[key]);
}
}
else {
$widget.attr(key, this.attrs[key]);
}
}
for (const className of this.classes) {
$widget.addClass(className);
}
return $widget;
}
isEnabled() {
return true;
}
/**
* for overriding
*/
doRender() {}
toggleInt(show) {
this.$widget.toggleClass('hidden-int', !show);
}
toggleExt(show) {
this.$widget.toggleClass('hidden-ext', !show);
}
isVisible() {
return this.$widget.is(":visible");
}
getPosition() {
return this.position;
}
remove() {
if (this.$widget) {
this.$widget.remove();
}
}
cleanup() {}
}
export default BasicWidget;

View File

@@ -0,0 +1,164 @@
import CollapsibleWidget from "./collapsible_widget.js";
import libraryLoader from "../services/library_loader.js";
import utils from "../services/utils.js";
import dateNoteService from "../services/date_notes.js";
import server from "../services/server.js";
import appContext from "../services/app_context.js";
const TPL = `
<div class="calendar-widget">
<div class="calendar-header">
<button class="calendar-btn bx bx-left-arrow-alt" data-calendar-toggle="previous"></button>
<div class="calendar-header-label" data-calendar-label="month">
March 2017
</div>
<button class="calendar-btn bx bx-right-arrow-alt" data-calendar-toggle="next"></button>
</div>
<div class="calendar-week">
<span>Mon</span> <span>Tue</span><span>Wed</span> <span>Thu</span> <span>Fri</span> <span>Sat</span> <span>Sun</span>
</div>
<div class="calendar-body" data-calendar-area="month"></div>
</div>
`;
export default class CalendarWidget extends CollapsibleWidget {
get widgetTitle() { return "Calendar"; }
isEnabled() {
return super.isEnabled()
&& this.note.hasOwnedLabel("dateNote");
}
async doRenderBody() {
await libraryLoader.requireLibrary(libraryLoader.CALENDAR_WIDGET);
this.$body.html(TPL);
this.$month = this.$body.find('[data-calendar-area="month"]');
this.$next = this.$body.find('[data-calendar-toggle="next"]');
this.$previous = this.$body.find('[data-calendar-toggle="previous"]');
this.$label = this.$body.find('[data-calendar-label="month"]');
this.$next.on('click', () => {
this.date.setMonth(this.date.getMonth() + 1);
this.createMonth();
});
this.$previous.on('click', () => {
this.date.setMonth(this.date.getMonth() - 1);
this.createMonth();
});
this.$body.on('click', '.calendar-date', async ev => {
const date = $(ev.target).closest('.calendar-date').attr('data-calendar-date');
const note = await dateNoteService.getDateNote(date);
if (note) {
appContext.tabManager.getActiveTabContext().setNote(note.noteId);
}
else {
alert("Cannot find day note");
}
});
}
async refreshWithNote(note) {
this.init(this.$body, note.getOwnedLabelValue("dateNote"));
}
init($el, activeDate) {
this.activeDate = new Date(activeDate + "T12:00:00"); // attaching time fixes local timezone handling
this.todaysDate = new Date();
this.date = new Date(this.activeDate.getTime());
this.date.setDate(1);
this.createMonth();
}
createDay(dateNotesForMonth, num, day) {
const $newDay = $('<a>')
.addClass("calendar-date")
.attr('data-calendar-date', utils.formatDateISO(this.date));
const $date = $('<span>').html(num);
// if it's the first day of the month
if (num === 1) {
if (day === 0) {
$newDay.css("marginLeft", (6 * 14.28) + '%');
} else {
$newDay.css("marginLeft", ((day - 1) * 14.28) + '%');
}
}
const dateNoteId = dateNotesForMonth[utils.formatDateISO(this.date)];
if (dateNoteId) {
$newDay.addClass('calendar-date-exists');
$newDay.attr("data-note-path", dateNoteId);
}
if (this.isEqual(this.date, this.activeDate)) {
$newDay.addClass('calendar-date-active');
}
if (this.isEqual(this.date, this.todaysDate)) {
$newDay.addClass('calendar-date-today');
}
$newDay.append($date);
return $newDay;
}
isEqual(a, b) {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate();
}
async createMonth() {
const month = utils.formatDateISO(this.date).substr(0, 7);
const dateNotesForMonth = await server.get('date-notes/notes-for-month/' + month);
this.$month.empty();
const currentMonth = this.date.getMonth();
while (this.date.getMonth() === currentMonth) {
const $day = this.createDay(
dateNotesForMonth,
this.date.getDate(),
this.date.getDay(),
this.date.getFullYear()
);
this.$month.append($day);
this.date.setDate(this.date.getDate() + 1);
}
// while loop trips over and day is at 30/31, bring it back
this.date.setDate(1);
this.date.setMonth(this.date.getMonth() - 1);
this.$label.html(this.monthsAsString(this.date.getMonth()) + ' ' + this.date.getFullYear());
}
monthsAsString(monthIndex) {
return [
'January',
'Febuary',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December'
][monthIndex];
}
}

View File

@@ -0,0 +1,18 @@
import BasicWidget from "./basic_widget.js";
const TPL = `
<button type="button" class="action-button d-sm-none d-md-none d-lg-none d-xl-none" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>`;
class CloseDetailButtonWidget extends BasicWidget {
doRender() {
this.$widget = $(TPL);
this.$widget.on('click', () => this.triggerCommand('setActiveScreen', {screen:'tree'}));
return this.$widget;
}
}
export default CloseDetailButtonWidget;

View File

@@ -0,0 +1,101 @@
import TabAwareWidget from "./tab_aware_widget.js";
import options from "../services/options.js";
const WIDGET_TPL = `
<div class="card widget">
<div class="card-header">
<div>
<button class="btn btn-sm widget-title" data-toggle="collapse" data-target="#[to be set]">
Collapsible Group Item
</button>
<a class="widget-help external no-arrow bx bx-info-circle"></a>
</div>
<div class="widget-header-actions"></div>
</div>
<div id="[to be set]" class="collapse body-wrapper" style="transition: none; ">
<div class="card-body"></div>
</div>
</div>`;
export default class CollapsibleWidget extends TabAwareWidget {
get widgetTitle() { return "Untitled widget"; }
get headerActions() { return []; }
get help() { return {}; }
doRender() {
this.$widget = $(WIDGET_TPL);
this.$widget.find('[data-target]').attr('data-target', "#" + this.componentId);
this.$bodyWrapper = this.$widget.find('.body-wrapper');
this.$bodyWrapper.attr('id', this.componentId); // for toggle to work we need id
this.widgetName = this.constructor.name;
if (!options.is(this.widgetName + 'Collapsed')) {
this.$bodyWrapper.collapse("show");
}
// using immediate variants of the event so that the previous collapse is not caught
this.$bodyWrapper.on('hide.bs.collapse', () => this.saveCollapsed(true));
this.$bodyWrapper.on('show.bs.collapse', () => this.saveCollapsed(false));
this.$body = this.$bodyWrapper.find('.card-body');
this.$title = this.$widget.find('.widget-title');
this.$title.text(this.widgetTitle);
this.$help = this.$widget.find('.widget-help');
if (this.help.title) {
this.$help.attr("title", this.help.title);
this.$help.attr("href", this.help.url || "javascript:");
if (!this.help.url) {
this.$help.addClass('no-link');
}
}
else {
this.$help.hide();
}
this.$headerActions = this.$widget.find('.widget-header-actions');
this.$headerActions.append(...this.headerActions);
this.initialized = this.doRenderBody();
this.decorateWidget();
return this.$widget;
}
saveCollapsed(collapse) {
options.save(this.widgetName + 'Collapsed', collapse.toString());
this.triggerEvent(`widgetCollapsedStateChanged`, {widgetName: this.widgetName, collapse});
}
/**
* This event is used to synchronize collapsed state of all the tab-cached widgets since they are all rendered
* separately but should behave uniformly for the user.
*/
widgetCollapsedStateChangedEvent({widgetName, collapse}) {
if (widgetName === this.widgetName) {
this.$bodyWrapper.toggleClass('show', !collapse);
}
}
/** for overriding */
decorateWidget() {}
/** for overriding */
async doRenderBody() {}
isExpanded() {
return this.$bodyWrapper.hasClass("show");
}
}

View File

@@ -0,0 +1,96 @@
import utils from '../services/utils.js';
import Mutex from "../services/mutex.js";
/**
* Abstract class for all components in the Trilium's frontend.
*
* Contains also event implementation with following properties:
* - event / command distribution is synchronous which among others mean that events are well ordered - event
* which was sent out first will also be processed first by the component since it was added to the mutex queue
* as the first one
* - execution of the event / command is asynchronous - each component executes the event on its own without regard for
* other components.
* - although the execution is async, we are collecting all the promises and therefore it is possible to wait until the
* event / command is executed in all components - by simply awaiting the `triggerEvent()`.
*/
export default class Component {
constructor() {
this.componentId = `comp-${this.constructor.name}-` + utils.randomString(6);
/** @type Component[] */
this.children = [];
this.initialized = Promise.resolve();
this.mutex = new Mutex();
}
setParent(parent) {
/** @type Component */
this.parent = parent;
return this;
}
child(...components) {
for (const component of components) {
component.setParent(this);
this.children.push(component);
}
return this;
}
/** @return {Promise} */
handleEvent(name, data) {
return Promise.all([
this.initialized.then(() => this.callMethod(this[name + 'Event'], data)),
this.handleEventInChildren(name, data)
]);
}
/** @return {Promise} */
triggerEvent(name, data) {
return this.parent.triggerEvent(name, data);
}
/** @return {Promise} */
handleEventInChildren(name, data) {
const promises = [];
for (const child of this.children) {
promises.push(child.handleEvent(name, data));
}
return Promise.all(promises);
}
/** @return {Promise} */
triggerCommand(name, data = {}) {
const fun = this[name + 'Command'];
if (fun) {
return this.callMethod(fun, data);
}
else {
return this.parent.triggerCommand(name, data);
}
}
async callMethod(fun, data) {
if (typeof fun !== 'function') {
return false;
}
let release;
try {
release = await this.mutex.acquire();
await fun.call(this, data);
return true;
} finally {
if (release) {
release();
}
}
}
}

View File

@@ -0,0 +1,158 @@
import FlexContainer from "./flex_container.js";
import GlobalMenuWidget from "./global_menu.js";
import TabRowWidget from "./tab_row.js";
import TitleBarButtonsWidget from "./title_bar_buttons.js";
import StandardTopWidget from "./standard_top_widget.js";
import SidePaneContainer from "./side_pane_container.js";
import GlobalButtonsWidget from "./global_buttons.js";
import SearchBoxWidget from "./search_box.js";
import SearchResultsWidget from "./search_results.js";
import NoteTreeWidget from "./note_tree.js";
import TabCachingWidget from "./tab_caching_widget.js";
import NotePathsWidget from "./note_paths.js";
import NoteTitleWidget from "./note_title.js";
import RunScriptButtonsWidget from "./run_script_buttons.js";
import ProtectedNoteSwitchWidget from "./protected_note_switch.js";
import NoteTypeWidget from "./note_type.js";
import NoteActionsWidget from "./note_actions.js";
import PromotedAttributesWidget from "./promoted_attributes.js";
import NoteDetailWidget from "./note_detail.js";
import NoteInfoWidget from "./note_info.js";
import CalendarWidget from "./calendar.js";
import AttributesWidget from "./attributes.js";
import LinkMapWidget from "./link_map.js";
import NoteRevisionsWidget from "./note_revisions.js";
import SimilarNotesWidget from "./similar_notes.js";
import WhatLinksHereWidget from "./what_links_here.js";
import SidePaneToggles from "./side_pane_toggles.js";
import appContext from "../services/app_context.js";
const RIGHT_PANE_CSS = `
<style>
#right-pane {
overflow: auto;
}
#right-pane .card {
border: 0;
display: flex;
flex-shrink: 0;
flex-direction: column;
}
#right-pane .card-header {
background: inherit;
padding: 3px 10px 3px 10px;
width: 99%; /* to give minimal right margin */
background-color: var(--button-background-color);
border-color: var(--button-border-color);
border-width: 1px;
border-radius: 4px;
border-style: solid;
display: flex;
justify-content: space-between;
}
#right-pane .widget-title {
border-radius: 0;
padding: 0;
border: 0;
background: inherit;
font-weight: bold;
text-transform: uppercase;
color: var(--muted-text-color) !important;
}
#right-pane .widget-header-action {
color: var(--link-color) !important;
cursor: pointer;
}
#right-pane .widget-help {
color: var(--muted-text-color);
position: relative;
top: 2px;
}
#right-pane .widget-help.no-link:hover {
cursor: default;
text-decoration: none;
}
#right-pane .body-wrapper {
overflow: auto;
}
#right-pane .card-body {
width: 100%;
padding: 8px;
border: 0;
height: 100%;
overflow: auto;
max-height: 300px;
}
#right-pane .card-body ul {
padding-left: 25px;
margin-bottom: 5px;
}
</style>`;
export default class DesktopLayout {
constructor(customWidgets) {
this.customWidgets = customWidgets;
}
getRootWidget(appContext) {
appContext.mainTreeWidget = new NoteTreeWidget();
return new FlexContainer('column')
.setParent(appContext)
.id('root-widget')
.css('height', '100vh')
.child(new FlexContainer('row')
.child(new GlobalMenuWidget())
.child(new TabRowWidget())
.child(new TitleBarButtonsWidget()))
.child(new StandardTopWidget()
.hideInZenMode())
.child(new FlexContainer('row')
.collapsible()
.child(new SidePaneContainer('left')
.hideInZenMode()
.child(new GlobalButtonsWidget())
.child(new SearchBoxWidget())
.child(new SearchResultsWidget())
.child(new TabCachingWidget(() => new NotePathsWidget()))
.child(appContext.mainTreeWidget)
.child(...this.customWidgets.get('left-pane'))
)
.child(new FlexContainer('column').id('center-pane')
.child(new FlexContainer('row').class('title-row')
.cssBlock('.title-row > * { margin: 5px; }')
.child(new NoteTitleWidget())
.child(new RunScriptButtonsWidget().hideInZenMode())
.child(new ProtectedNoteSwitchWidget().hideInZenMode())
.child(new NoteTypeWidget().hideInZenMode())
.child(new NoteActionsWidget().hideInZenMode())
)
.child(new TabCachingWidget(() => new PromotedAttributesWidget()))
.child(new TabCachingWidget(() => new NoteDetailWidget()))
.child(...this.customWidgets.get('center-pane'))
)
.child(new SidePaneContainer('right')
.cssBlock(RIGHT_PANE_CSS)
.hideInZenMode()
.child(new NoteInfoWidget())
.child(new TabCachingWidget(() => new CalendarWidget()))
.child(new TabCachingWidget(() => new AttributesWidget()))
.child(new TabCachingWidget(() => new LinkMapWidget()))
.child(new TabCachingWidget(() => new NoteRevisionsWidget()))
.child(new TabCachingWidget(() => new SimilarNotesWidget()))
.child(new TabCachingWidget(() => new WhatLinksHereWidget()))
.child(...this.customWidgets.get('right-pane'))
)
.child(new SidePaneToggles().hideInZenMode())
);
}
}

View File

@@ -0,0 +1,53 @@
import CollapsibleWidget from "./collapsible_widget.js";
import linkService from "../services/link.js";
import server from "../services/server.js";
import treeCache from "../services/tree_cache.js";
export default class EditedNotesWidget extends CollapsibleWidget {
get widgetTitle() { return "Edited notes on this day"; }
get help() {
return {
title: "This contains a list of notes created or updated on this day."
};
}
isEnabled() {
return super.isEnabled()
&& this.note.hasOwnedLabel("dateNote");
}
async refreshWithNote(note) {
// remember which title was when we found the similar notes
this.title = note.title;
let editedNotes = await server.get('edited-notes/' + note.getLabelValue("dateNote"));
editedNotes = editedNotes.filter(n => n.noteId !== note.noteId);
if (editedNotes.length === 0) {
this.$body.text("No edited notes on this day yet ...");
return;
}
const noteIds = editedNotes.flatMap(n => n.noteId);
await treeCache.getNotes(noteIds, true); // preload all at once
const $list = $('<ul>');
for (const editedNote of editedNotes) {
const $item = $("<li>");
if (editedNote.isDeleted) {
$item.append($("<i>").text(editedNote.title + " (deleted)"));
}
else {
$item.append(editedNote.notePath ? await linkService.createNoteLink(editedNote.notePath.join("/"), {showNotePath: true}) : editedNote.title);
}
$list.append($item);
}
this.$body.empty().append($list);
}
}

View File

@@ -0,0 +1,46 @@
import BasicWidget from "./basic_widget.js";
export default class FlexContainer extends BasicWidget {
constructor(direction) {
super();
if (!direction || !['row', 'column'].includes(direction)) {
throw new Error(`Direction argument given as "${direction}", use either 'row' or 'column'`);
}
this.attrs.style = `display: flex; flex-direction: ${direction};`;
this.children = [];
this.positionCounter = 10;
}
child(...components) {
if (!components) {
return this;
}
super.child(...components);
for (const component of components) {
if (!component.position) {
component.position = this.positionCounter;
this.positionCounter += 10;
}
}
this.children.sort((a, b) => a.position - b.position < 0 ? -1 : 1);
return this;
}
doRender() {
this.$widget = $(`<div>`);
for (const widget of this.children) {
this.$widget.append(widget.render());
}
return this.$widget;
}
}

View File

@@ -0,0 +1,42 @@
import BasicWidget from "./basic_widget.js";
const WIDGET_TPL = `
<div class="global-buttons">
<style>
.global-buttons {
display: flex;
justify-content: space-around;
border: 1px solid var(--main-border-color);
border-radius: 7px;
margin: 3px 5px 5px 5px;
}
</style>
<a data-trigger-command="createTopLevelNote"
title="Create new top level note"
class="icon-action bx bx-folder-plus"></a>
<a data-trigger-command="collapseTree"
title="Collapse note tree"
data-command="collapseTree"
class="icon-action bx bx-layer-minus"></a>
<a data-trigger-command="scrollToActiveNote"
title="Scroll to active note"
data-command="scrollToActiveNote"
class="icon-action bx bx-crosshair"></a>
<a data-trigger-command="searchNotes"
title="Search in notes"
data-command="searchNotes"
class="icon-action bx bx-search"></a>
</div>
`;
class GlobalButtonsWidget extends BasicWidget {
doRender() {
return this.$widget = $(WIDGET_TPL);
}
}
export default GlobalButtonsWidget;

View File

@@ -0,0 +1,114 @@
import BasicWidget from "./basic_widget.js";
import keyboardActionService from "../services/keyboard_actions.js";
import utils from "../services/utils.js";
import syncService from "../services/sync.js";
const TPL = `
<div class="global-menu-wrapper">
<style>
.global-menu-wrapper {
height: 35px;
border-bottom: 1px solid var(--main-border-color);
}
.global-menu button {
margin-right: 10px;
height: 33px;
border-bottom: none;
}
.global-menu .dropdown-menu {
width: 20em;
}
</style>
<div class="dropdown global-menu">
<button type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" class="btn btn-sm dropdown-toggle">
<span class="bx bx-menu"></span>
Menu
<span class="caret"></span>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item options-button" data-trigger-command="showOptions">
<span class="bx bx-slider"></span>
Options
</a>
<a class="dropdown-item sync-now-button" title="Trigger sync">
<span class="bx bx-refresh"></span>
Sync (<span id="outstanding-syncs-count">0</span>)
</a>
<a class="dropdown-item open-dev-tools-button" data-trigger-command="openDevTools">
<span class="bx bx-terminal"></span>
Open Dev Tools
<kbd data-command="openDevTools"></kbd>
</a>
<a class="dropdown-item" data-trigger-command="showSQLConsole">
<span class="bx bx-data"></span>
Open SQL Console
<kbd data-command="showSQLConsole"></kbd>
</a>
<a class="dropdown-item" data-trigger-command="showBackendLog">
<span class="bx bx-empty"></span>
Show backend log
<kbd data-command="showBackendLog"></kbd>
</a>
<a class="dropdown-item" data-trigger-command="reloadFrontendApp"
title="Reload can help with some visual glitches without restarting the whole app.">
<span class="bx bx-empty"></span>
Reload frontend
<kbd data-command="reloadFrontendApp"></kbd>
</a>
<a class="dropdown-item" data-trigger-command="toggleZenMode">
<span class="bx bx-empty"></span>
Toggle Zen mode
<kbd data-command="toggleZenMode"></kbd>
</a>
<a class="dropdown-item" data-trigger-command="toggleFullscreen">
<span class="bx bx-empty"></span>
Toggle fullscreen
<kbd data-command="toggleFullscreen"></kbd>
</a>
<a class="dropdown-item" data-trigger-command="showHelp">
<span class="bx bx-info-circle"></span>
Show Help
<kbd data-command="showHelp"></kbd>
</a>
<a class="dropdown-item show-about-dialog-button">
<span class="bx bx-empty"></span>
About Trilium Notes
</a>
<a class="dropdown-item logout-button" data-trigger-command="logout">
<span class="bx bx-log-out"></span>
Logout
</a>
</div>
</div>
</div>
`;
export default class GlobalMenuWidget extends BasicWidget {
doRender() {
this.$widget = $(TPL);
this.$widget.find(".show-about-dialog-button").on('click',
() => import("../dialogs/about.js").then(d => d.showDialog()));
this.$widget.find(".sync-now-button").on('click', () => syncService.syncNow());
this.$widget.find(".logout-button").toggle(!utils.isElectron());
this.$widget.find(".open-dev-tools-button").toggle(utils.isElectron());
return this.$widget;
}
}

View File

@@ -0,0 +1,104 @@
import BasicWidget from "./basic_widget.js";
import utils from "../services/utils.js";
import keyboardActionService from "../services/keyboard_actions.js";
import contextMenu from "../services/context_menu.js";
import treeService from "../services/tree.js";
import appContext from "../services/app_context.js";
const TPL = `
<div class="history-navigation">
<style>
.history-navigation {
margin: 0 15px 0 5px;
}
</style>
<a title="Go to previous note." data-trigger-command="backInNoteHistory" class="icon-action bx bx-left-arrow-circle"></a>
<a title="Go to next note." data-trigger-command="forwardInNoteHistory" class="icon-action bx bx-right-arrow-circle"></a>
</div>
`;
export default class HistoryNavigationWidget extends BasicWidget {
doRender() {
if (utils.isElectron()) {
this.$widget = $(TPL);
const contextMenuHandler = e => {
e.preventDefault();
if (this.webContents.history.length < 2) {
return;
}
this.showContextMenu(e);
};
this.$backInHistory = this.$widget.find("[data-trigger-command='backInNoteHistory']");
this.$backInHistory.on('contextmenu', contextMenuHandler);
this.$forwardInHistory = this.$widget.find("[data-trigger-command='forwardInNoteHistory']");
this.$forwardInHistory.on('contextmenu', contextMenuHandler);
const electron = utils.dynamicRequire('electron');
this.webContents = electron.remote.getCurrentWindow().webContents;
// without this the history is preserved across frontend reloads
this.webContents.clearHistory();
this.refresh();
}
else {
this.$widget = $("<div>");
}
return this.$widget;
}
async showContextMenu(e) {
let items = [];
const activeIndex = this.webContents.getActiveIndex();
for (const idx in this.webContents.history) {
const url = this.webContents.history[idx];
const [_, notePathWithTab] = url.split('#');
const [notePath, tabId] = notePathWithTab.split('-');
const title = await treeService.getNotePathTitle(notePath);
items.push({
title,
idx,
uiIcon: idx == activeIndex ? "radio-circle-marked" : // compare with type coercion!
(idx < activeIndex ? "left-arrow-alt" : "right-arrow-alt")
});
}
items.reverse();
if (items.length > 20) {
items = items.slice(0, 50);
}
contextMenu.show({
x: e.pageX,
y: e.pageY,
items,
selectMenuItemHandler: ({idx}) => this.webContents.goToIndex(idx)
});
}
refresh() {
if (!utils.isElectron()) {
return;
}
this.$backInHistory.toggleClass('disabled', !this.webContents.canGoBack());
this.$forwardInHistory.toggleClass('disabled', !this.webContents.canGoForward());
}
activeNoteChangedEvent() {
this.refresh();
}
}

View File

@@ -0,0 +1,93 @@
import CollapsibleWidget from "./collapsible_widget.js";
let linkMapContainerIdCtr = 1;
const TPL = `
<div class="link-map-widget">
<div class="link-map-container" style="height: 300px;"></div>
</div>
`;
export default class LinkMapWidget extends CollapsibleWidget {
get widgetTitle() { return "Link map"; }
get help() {
return {
title: "Link map shows incoming and outgoing links from/to the current note.",
url: "https://github.com/zadam/trilium/wiki/Link-map"
};
}
get headerActions() {
const $showFullButton = $("<a>").append("show full").addClass('widget-header-action');
$showFullButton.on('click', async () => {
const linkMapDialog = await import("../dialogs/link_map.js");
linkMapDialog.showDialog();
});
return [$showFullButton];
}
decorateWidget() {
this.$body.css("max-height", "400px");
}
async refreshWithNote(note) {
const noteId = this.noteId;
let shown = false;
// avoid executing this expensive operation multiple times when just going through notes (with keyboard especially)
// until the users settles on a note
setTimeout(() => {
if (this.noteId === noteId) {
// there's a problem with centering the rendered link map before it is actually shown on the screen
// that's why we make the whole process lazy and with the help of IntersectionObserver wait until the
// tab is really shown and only then render
const observer = new IntersectionObserver(entries => {
if (!shown && entries[0].isIntersecting) {
shown = true;
this.displayLinkMap(note);
}
}, {
rootMargin: '0px',
threshold: 0.1
});
observer.observe(this.$body[0]);
}
}, 1000);
}
async displayLinkMap(note) {
this.$body.css('opacity', 0);
this.$body.html(TPL);
const $linkMapContainer = this.$body.find('.link-map-container');
$linkMapContainer.attr("id", "link-map-container-" + linkMapContainerIdCtr++);
const LinkMapServiceClass = (await import('../services/link_map.js')).default;
this.linkMapService = new LinkMapServiceClass(note, $linkMapContainer, {
maxDepth: 1,
zoom: 0.6,
stopCheckerCallback: () => this.noteId !== note.noteId // stop when current note is not what was originally requested
});
await this.linkMapService.render();
this.$body.animate({opacity: 1}, 300);
}
cleanup() {
if (this.linkMapService) {
this.linkMapService.cleanup();
}
}
entitiesReloadedEvent({loadResults}) {
if (loadResults.getAttributes().find(attr => attr.type === 'relation' && (attr.noteId === this.noteId || attr.value === this.noteId))) {
this.noteSwitched();
}
}
}

View File

@@ -0,0 +1,46 @@
import BasicWidget from "./basic_widget.js";
import appContext from "../services/app_context.js";
import contextMenu from "../services/context_menu.js";
import noteCreateService from "../services/note_create.js";
import branchService from "../services/branches.js";
const TPL = `<button type="button" class="action-button bx bx-menu"></button>`;
class MobileDetailMenuWidget extends BasicWidget {
doRender() {
this.$widget = $(TPL);
this.$widget.on("click", async e => {
const note = appContext.tabManager.getActiveTabNote();
contextMenu.show({
x: e.pageX,
y: e.pageY,
items: [
{ title: "Insert child note", command: "insertChildNote", uiIcon: "plus",
enabled: note.type !== 'search' },
{ title: "Delete this note", command: "delete", uiIcon: "trash",
enabled: note.noteId !== 'root' }
],
selectMenuItemHandler: async ({command}) => {
if (command === "insertChildNote") {
noteCreateService.createNote(note.noteId);
}
else if (command === "delete") {
if (await branchService.deleteNotes(note.getBranchIds()[0])) {
// move to the tree
togglePanes();
}
}
else {
throw new Error("Unrecognized command " + command);
}
}
});
});
return this.$widget;
}
}
export default MobileDetailMenuWidget;

View File

@@ -0,0 +1,39 @@
import BasicWidget from "./basic_widget.js";
const WIDGET_TPL = `
<div id="global-buttons">
<style>
#global-buttons {
display: flex;
flex-shrink: 0;
justify-content: space-around;
padding: 3px 0 3px 0;
margin: 0 10px 0 16px;
font-size: larger;
}
</style>
<a data-trigger-command="createTopLevelNote" title="Create new top level note" class="icon-action bx bx-folder-plus"></a>
<a data-trigger-command="collapseTree" title="Collapse note tree" class="icon-action bx bx-layer-minus"></a>
<a data-trigger-command="scrollToActiveNote" title="Scroll to active note" class="icon-action bx bx-crosshair"></a>
<div class="dropdown">
<a title="Global actions" class="icon-action bx bx-cog dropdown-toggle" data-toggle="dropdown"></a>
<div class="dropdown-menu dropdown-menu-right">
<a class="dropdown-item" data-trigger-command="switchToDesktopVersion"><span class="bx bx-laptop"></span> Switch to desktop version</a>
<a class="dropdown-item" data-trigger-command="logout"><span class="bx bx-log-out"></span> Logout</a>
</div>
</div>
</div>
`;
class MobileGlobalButtonsWidget extends BasicWidget {
doRender() {
return this.$widget = $(WIDGET_TPL);
}
}
export default MobileGlobalButtonsWidget;

View File

@@ -0,0 +1,88 @@
import FlexContainer from "./flex_container.js";
import NoteTitleWidget from "./note_title.js";
import NoteDetailWidget from "./note_detail.js";
import NoteTreeWidget from "./note_tree.js";
import MobileGlobalButtonsWidget from "./mobile_global_buttons.js";
import CloseDetailButtonWidget from "./close_detail_button.js";
import MobileDetailMenuWidget from "./mobile_detail_menu.js";
import ScreenContainer from "./screen_container.js";
const MOBILE_CSS = `
<style>
kbd {
display: none;
}
.dropdown-menu {
font-size: larger;
}
.action-button {
background: none;
border: none;
cursor: pointer;
font-size: 1.5em;
padding-left: 0.5em;
padding-right: 0.5em;
}
</style>`;
const FANCYTREE_CSS = `
<style>
.fancytree-custom-icon {
font-size: 2em;
}
.fancytree-title {
font-size: 1.5em;
margin-left: 0.6em !important;
}
.fancytree-node {
padding: 5px;
}
.fancytree-node .fancytree-expander:before {
font-size: 2em !important;
}
span.fancytree-expander {
width: 24px !important;
}
.fancytree-loading span.fancytree-expander {
width: 24px;
height: 32px;
}
.fancytree-loading span.fancytree-expander:after {
width: 20px;
height: 20px;
margin-top: 4px;
border-width: 2px;
border-style: solid;
}
</style>`;
export default class MobileLayout {
getRootWidget(appContext) {
return new FlexContainer('row').cssBlock(MOBILE_CSS)
.setParent(appContext)
.id('root-widget')
.css('height', '100vh')
.child(new ScreenContainer("tree", 'column')
.class("d-sm-flex d-md-flex d-lg-flex d-xl-flex col-12 col-sm-5 col-md-4 col-lg-4 col-xl-4")
.child(new MobileGlobalButtonsWidget())
.child(new NoteTreeWidget().cssBlock(FANCYTREE_CSS)))
.child(new ScreenContainer("detail", "column")
.class("d-sm-flex d-md-flex d-lg-flex d-xl-flex col-12 col-sm-7 col-md-8 col-lg-8")
.child(new FlexContainer('row')
.child(new MobileDetailMenuWidget())
.child(new NoteTitleWidget()
.css('padding', '10px')
.css('font-size', 'larger'))
.child(new CloseDetailButtonWidget()))
.child(new NoteDetailWidget()
.css('padding', '5px 20px 10px 0')));
}
}

View File

@@ -0,0 +1,15 @@
import Component from "./component.js";
export default class MobileScreenSwitcherExecutor extends Component {
setActiveScreenCommand({screen}) {
if (screen !== this.activeScreen) {
this.activeScreen = screen;
this.triggerEvent('activeScreenChanged', {activeScreen: screen});
}
}
initialRenderCompleteEvent() {
this.setActiveScreenCommand({screen: 'tree'});
}
}

View File

@@ -0,0 +1,68 @@
import TabAwareWidget from "./tab_aware_widget.js";
const TPL = `
<div class="dropdown note-actions">
<style>
.note-actions .dropdown-menu {
width: 15em;
}
.note-actions .dropdown-item[disabled], .note-actions .dropdown-item[disabled]:hover {
color: var(--muted-text-color) !important;
background-color: transparent !important;
pointer-events: none; /* makes it unclickable */
}
</style>
<button type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" class="btn btn-sm dropdown-toggle">
Note actions
<span class="caret"></span>
</button>
<div class="dropdown-menu dropdown-menu-right">
<a data-trigger-command="showNoteRevisions" class="dropdown-item show-note-revisions-button">Revisions</a>
<a data-trigger-command="showAttributes" class="dropdown-item show-attributes-button"><kbd data-command="showAttributes"></kbd> Attributes</a>
<a data-trigger-command="showLinkMap" class="dropdown-item show-link-map-button"><kbd data-command="showLinkMap"></kbd> Link map</a>
<a data-trigger-command="showNoteSource" class="dropdown-item show-source-button"><kbd data-command="showNoteSource"></kbd> Note source</a>
<a class="dropdown-item import-files-button">Import files</a>
<a class="dropdown-item export-note-button">Export note</a>
<a data-trigger-command="printActiveNote" class="dropdown-item print-note-button"><kbd data-command="printActiveNote"></kbd> Print note</a>
<a data-trigger-command="showNoteInfo" class="dropdown-item show-note-info-button"><kbd data-command="showNoteInfo"></kbd> Note info</a>
</div>
</div>`;
export default class NoteActionsWidget extends TabAwareWidget {
doRender() {
this.$widget = $(TPL);
this.$showSourceButton = this.$widget.find('.show-source-button');
this.$exportNoteButton = this.$widget.find('.export-note-button');
this.$exportNoteButton.on("click", () => {
if (this.$exportNoteButton.hasClass("disabled")) {
return;
}
import('../dialogs/export.js').then(d => d.showDialog(this.tabContext.notePath, 'single'));
});
this.$importNoteButton = this.$widget.find('.import-files-button');
this.$importNoteButton.on("click", () => import('../dialogs/import.js').then(d => d.showDialog(this.noteId)));
return this.$widget;
}
refreshWithNote(note) {
if (['text', 'relation-map', 'search', 'code'].includes(note.type)) {
this.$showSourceButton.removeAttr('disabled');
} else {
this.$showSourceButton.attr('disabled', 'disabled');
}
if (note.type === 'text') {
this.$exportNoteButton.removeAttr('disabled');
}
else {
this.$exportNoteButton.attr('disabled', 'disabled');
}
}
}

View File

@@ -0,0 +1,289 @@
import TabAwareWidget from "./tab_aware_widget.js";
import utils from "../services/utils.js";
import protectedSessionHolder from "../services/protected_session_holder.js";
import SpacedUpdate from "../services/spaced_update.js";
import server from "../services/server.js";
import libraryLoader from "../services/library_loader.js";
import EmptyTypeWidget from "./type_widgets/empty.js";
import EditableTextTypeWidget from "./type_widgets/editable_text.js";
import CodeTypeWidget from "./type_widgets/code.js";
import FileTypeWidget from "./type_widgets/file.js";
import ImageTypeWidget from "./type_widgets/image.js";
import SearchTypeWidget from "./type_widgets/search.js";
import RenderTypeWidget from "./type_widgets/render.js";
import RelationMapTypeWidget from "./type_widgets/relation_map.js";
import ProtectedSessionTypeWidget from "./type_widgets/protected_session.js";
import BookTypeWidget from "./type_widgets/book.js";
import appContext from "../services/app_context.js";
import keyboardActionsService from "../services/keyboard_actions.js";
import noteCreateService from "../services/note_create.js";
import DeletedTypeWidget from "./type_widgets/deleted.js";
import ReadOnlyTextTypeWidget from "./type_widgets/read_only_text.js";
const TPL = `
<div class="note-detail">
<style>
.note-detail {
height: 100%;
min-height: 0;
font-family: var(--detail-font-family);
font-size: var(--detail-font-size);
}
</style>
</div>
`;
const typeWidgetClasses = {
'empty': EmptyTypeWidget,
'deleted': DeletedTypeWidget,
'editable-text': EditableTextTypeWidget,
'read-only-text': ReadOnlyTextTypeWidget,
'code': CodeTypeWidget,
'file': FileTypeWidget,
'image': ImageTypeWidget,
'search': SearchTypeWidget,
'render': RenderTypeWidget,
'relation-map': RelationMapTypeWidget,
'protected-session': ProtectedSessionTypeWidget,
'book': BookTypeWidget
};
export default class NoteDetailWidget extends TabAwareWidget {
constructor() {
super();
this.typeWidgets = {};
this.spacedUpdate = new SpacedUpdate(async () => {
const {note} = this.tabContext;
const {noteId} = note;
const dto = note.dto;
dto.content = this.getTypeWidget().getContent();
await server.put('notes/' + noteId, dto, this.componentId);
});
}
isEnabled() {
return true;
}
doRender() {
this.$widget = $(TPL);
this.$widget.on("dragover", e => e.preventDefault());
this.$widget.on("dragleave", e => e.preventDefault());
this.$widget.on("drop", async e => {
const activeNote = appContext.tabManager.getActiveTabNote();
if (!activeNote) {
return;
}
const files = [...e.originalEvent.dataTransfer.files]; // chrome has issue that dataTransfer.files empties after async operation
const importService = await import("../services/import.js");
importService.uploadFiles(activeNote.noteId, files, {
safeImport: true,
shrinkImages: true,
textImportedAsText: true,
codeImportedAsCode: true,
explodeArchives: true
});
});
return this.$widget;
}
async refresh() {
this.type = await this.getWidgetType();
this.mime = this.note ? this.note.mime : null;
if (!(this.type in this.typeWidgets)) {
const clazz = typeWidgetClasses[this.type];
const typeWidget = this.typeWidgets[this.type] = new clazz();
typeWidget.spacedUpdate = this.spacedUpdate;
typeWidget.setParent(this);
const $renderedWidget = typeWidget.render();
keyboardActionsService.updateDisplayedShortcuts($renderedWidget);
this.$widget.append($renderedWidget);
await typeWidget.handleEvent('setTabContext', {tabContext: this.tabContext});
// this is happening in update() so note has been already set and we need to reflect this
await typeWidget.handleEvent('tabNoteSwitched', {
tabContext: this.tabContext,
notePath: this.tabContext.notePath
});
this.child(typeWidget);
}
this.setupClasses();
}
setupClasses() {
for (const clazz of Array.from(this.$widget[0].classList)) { // create copy to safely iterate over while removing classes
if (clazz !== 'note-detail' && !clazz.startsWith('hidden-')) {
this.$widget.removeClass(clazz);
}
}
const note = this.note;
if (note) {
this.$widget.addClass(note.getCssClass());
this.$widget.addClass(utils.getNoteTypeClass(note.type));
this.$widget.addClass(utils.getMimeTypeClass(note.mime));
this.$widget.toggleClass("protected", note.isProtected);
}
}
getTypeWidget() {
if (!this.typeWidgets[this.type]) {
throw new Error("Could not find typeWidget for type: " + this.type);
}
return this.typeWidgets[this.type];
}
async getWidgetType() {
const note = this.note;
if (!note) {
return "empty";
} else if (note.isDeleted) {
return "deleted";
}
let type = note.type;
if (type === 'text' && !this.tabContext.autoBookDisabled
&& note.hasChildren()
&& utils.isDesktop()) {
const noteComplement = await this.tabContext.getNoteComplement();
if (utils.isHtmlEmpty(noteComplement.content)) {
type = 'book';
}
}
if (type === 'text' && !this.tabContext.textPreviewDisabled) {
const noteComplement = await this.tabContext.getNoteComplement();
if (note.hasLabel('readOnly') ||
(noteComplement.content && noteComplement.content.length > 10000)) {
type = 'read-only-text';
}
}
if (type === 'text') {
type = 'editable-text';
}
if (note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable()) {
type = 'protected-session';
}
return type;
}
async focusOnDetailEvent({tabId}) {
if (this.tabContext.tabId === tabId) {
await this.refresh();
const widget = this.getTypeWidget();
widget.focus();
}
}
async beforeNoteSwitchEvent({tabContext}) {
if (this.isTab(tabContext.tabId)) {
await this.spacedUpdate.updateNowIfNecessary();
}
}
async beforeTabRemoveEvent({tabId}) {
if (this.isTab(tabId)) {
await this.spacedUpdate.updateNowIfNecessary();
}
}
async printActiveNoteEvent() {
if (!this.tabContext.isActive()) {
return;
}
await libraryLoader.requireLibrary(libraryLoader.PRINT_THIS);
this.$widget.find('.note-detail-printable:visible').printThis({
header: $("<h2>").text(this.note && this.note.title).prop('outerHTML'),
footer: "<script>document.body.className += ' ck-content';</script>",
importCSS: false,
loadCSS: [
"libraries/codemirror/codemirror.css",
"libraries/ckeditor/ckeditor-content.css",
"libraries/ckeditor/ckeditor-content.css",
"libraries/bootstrap/css/bootstrap.min.css",
"stylesheets/print.css",
"stylesheets/relation_map.css",
"stylesheets/themes.css",
"stylesheets/detail.css"
],
debug: true
});
}
hoistedNoteChangedEvent() {
this.refresh();
}
async entitiesReloadedEvent({loadResults}) {
// FIXME: we should test what happens when the loaded note is deleted
if (loadResults.isNoteContentReloaded(this.noteId, this.componentId)
|| (loadResults.isNoteReloaded(this.noteId, this.componentId) && (this.type !== await this.getWidgetType() || this.mime !== this.note.mime))) {
this.handleEvent('noteTypeMimeChanged', {noteId: this.noteId});
}
}
beforeUnloadEvent() {
this.spacedUpdate.updateNowIfNecessary();
}
autoBookDisabledEvent({tabContext}) {
if (this.isTab(tabContext.tabId)) {
this.refresh();
}
}
textPreviewDisabledEvent({tabContext}) {
if (this.isTab(tabContext.tabId)) {
this.refresh();
}
}
async cutIntoNoteCommand() {
const note = appContext.tabManager.getActiveTabNote();
if (!note) {
return;
}
await noteCreateService.createNote(note.noteId, {
isProtected: note.isProtected,
saveSelection: true
});
}
}

View File

@@ -0,0 +1,82 @@
import CollapsibleWidget from "./collapsible_widget.js";
const TPL = `
<table class="note-info-widget-table">
<style>
.note-info-widget-table {
table-layout: fixed;
width: 100%;
}
.note-info-widget-table td, .note-info-widget-table th {
padding: 5px;
}
.note-info-mime {
max-width: 13em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
<tr>
<th nowrap>Note ID:</th>
<td nowrap colspan="3" class="note-info-note-id"></td>
</tr>
<tr>
<th nowrap>Created:</th>
<td nowrap colspan="3" style="overflow: hidden; text-overflow: ellipsis;" class="note-info-date-created"></td>
</tr>
<tr>
<th nowrap>Modified:</th>
<td nowrap colspan="3" style="overflow: hidden; text-overflow: ellipsis;" class="note-info-date-modified"></td>
</tr>
<tr>
<th>Type:</th>
<td class="note-info-type"></td>
<th>MIME:</th>
<td class="note-info-mime"></td>
</tr>
</table>
`;
export default class NoteInfoWidget extends CollapsibleWidget {
get widgetTitle() { return "Note info"; }
async doRenderBody() {
this.$body.html(TPL);
this.$noteId = this.$body.find(".note-info-note-id");
this.$dateCreated = this.$body.find(".note-info-date-created");
this.$dateModified = this.$body.find(".note-info-date-modified");
this.$type = this.$body.find(".note-info-type");
this.$mime = this.$body.find(".note-info-mime");
}
async refreshWithNote(note) {
const noteComplement = await this.tabContext.getNoteComplement();
this.$noteId.text(note.noteId);
this.$dateCreated
.text(noteComplement.dateCreated)
.attr("title", noteComplement.dateCreated);
this.$dateModified
.text(noteComplement.dateModified)
.attr("title", noteComplement.dateCreated);
this.$type.text(note.type);
this.$mime
.text(note.mime)
.attr("title", note.mime);
}
entitiesReloadedEvent({loadResults}) {
if (loadResults.isNoteReloaded(this.noteId)) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,160 @@
import TabAwareWidget from "./tab_aware_widget.js";
import treeService from "../services/tree.js";
import linkService from "../services/link.js";
import hoistedNoteService from "../services/hoisted_note.js";
const TPL = `
<div class="note-paths-widget">
<style>
.note-paths-widget {
display: flex;
flex-direction: row;
border-bottom: 1px solid var(--main-border-color);
padding: 5px 10px 5px 10px;
}
.note-path-list a.current {
font-weight: bold;
}
.note-path-list-button {
padding: 0;
width: 24px;
height: 24px;
margin-left: 5px;
position: relative;
top: -2px;
}
.note-path-list-button::after {
display: none !important; // disabling the standard caret
}
.current-path {
flex-grow: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
<div class="current-path"></div>
<div class="dropdown hide-in-zen-mode">
<button class="btn btn-sm dropdown-toggle note-path-list-button bx bx-caret-down" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></button>
<ul class="note-path-list dropdown-menu dropdown-menu-right" aria-labelledby="note-path-list-button">
</ul>
</div>
</div>`;
export default class NotePathsWidget extends TabAwareWidget {
doRender() {
this.$widget = $(TPL);
this.$currentPath = this.$widget.find('.current-path');
this.$dropdown = this.$widget.find(".dropdown");
this.$notePathList = this.$dropdown.find(".note-path-list");
this.$dropdown.on('show.bs.dropdown', () => this.renderDropdown());
return this.$widget;
}
async refreshWithNote(note, notePath) {
const noteIdsPath = treeService.parseNotePath(notePath);
this.$currentPath.empty();
let parentNoteId = 'root';
let curPath = '';
let passedHoistedNote = false;
for (let i = 0; i < noteIdsPath.length; i++) {
const noteId = noteIdsPath[i];
curPath += (curPath ? '/' : '') + noteId;
if (noteId === hoistedNoteService.getHoistedNoteId()) {
passedHoistedNote = true;
}
if (passedHoistedNote && (noteId !== hoistedNoteService.getHoistedNoteId() || noteIdsPath.length - i < 3)) {
this.$currentPath.append(
$("<a>")
.attr('href', '#' + curPath)
.addClass('no-tooltip-preview')
.text(await treeService.getNoteTitle(noteId, parentNoteId))
);
if (i !== noteIdsPath.length - 1) {
this.$currentPath.append(' / ');
}
}
parentNoteId = noteId;
}
}
async renderDropdown() {
this.$notePathList.empty();
this.$notePathList.append(
$("<div>")
.addClass("dropdown-header")
.text('This note is placed into the following paths:')
);
if (this.noteId === 'root') {
await this.addPath('root', true);
return;
}
const pathSegments = treeService.parseNotePath(this.notePath);
const activeNoteParentNoteId = pathSegments[pathSegments.length - 2]; // we know this is not root so there must be a parent
for (const parentNote of this.note.getParentNotes()) {
const parentNotePath = await treeService.getSomeNotePath(parentNote);
// this is to avoid having root notes leading '/'
const notePath = parentNotePath ? (parentNotePath + '/' + this.noteId) : this.noteId;
const isCurrent = activeNoteParentNoteId === parentNote.noteId;
await this.addPath(notePath, isCurrent);
}
const cloneLink = $("<div>")
.addClass("dropdown-header")
.append(
$('<button class="btn btn-sm">')
.text('Clone note to new location...')
.on('click', () => import("../dialogs/clone_to.js").then(d => d.showDialog([this.noteId])))
);
this.$notePathList.append(cloneLink);
}
async addPath(notePath, isCurrent) {
const title = await treeService.getNotePathTitle(notePath);
const noteLink = await linkService.createNoteLink(notePath, {title});
noteLink
.addClass("dropdown-item");
noteLink
.find('a')
.addClass("no-tooltip-preview");
if (isCurrent) {
noteLink.addClass("current");
}
this.$notePathList.append(noteLink);
}
entitiesReloadedEvent({loadResults}) {
if (loadResults.getBranches().find(branch => branch.noteId === this.noteId)) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,80 @@
import server from "../services/server.js";
import CollapsibleWidget from "./collapsible_widget.js";
const TPL = `
<ul class="note-revision-list" style="max-height: 150px; overflow: auto;">
</ul>
`;
class NoteRevisionsWidget extends CollapsibleWidget {
get widgetTitle() { return "Note revisions"; }
get help() {
return {
title: "Note revisions track changes in the note across the time.",
url: "https://github.com/zadam/trilium/wiki/Note-revisions"
};
}
get headerActions() {
const $showFullButton = $("<a>").append("show dialog").addClass('widget-header-action');
$showFullButton.on('click', async () => {
const attributesDialog = await import("../dialogs/note_revisions.js");
attributesDialog.showCurrentNoteRevisions(this.noteId);
});
return [$showFullButton];
}
noteSwitched() {
const noteId = this.noteId;
// avoid executing this expensive operation multiple times when just going through notes (with keyboard especially)
// until the users settles on a note
setTimeout(() => {
if (this.noteId === noteId) {
this.refresh();
}
}, 1000);
}
async refreshWithNote(note) {
const revisionItems = await server.get(`notes/${note.noteId}/revisions`);
if (revisionItems.length === 0) {
this.$body.text("No revisions yet...");
return;
}
if (note.noteId !== this.noteId) {
return;
}
this.$body.html(TPL);
const $list = this.$body.find('.note-revision-list');
for (const item of revisionItems) {
const $listItem = $('<li>').append($("<a>", {
'data-action': 'note-revision',
'data-note-path': note.noteId,
'data-note-revision-id': item.noteRevisionId,
href: 'javascript:'
}).text(item.dateLastEdited.substr(0, 16)));
if (item.contentLength !== null) {
$listItem.append($("<span>").text(` (${item.contentLength} bytes)`))
}
$list.append($listItem);
}
}
entitiesReloadedEvent({loadResults}) {
if (loadResults.hasNoteRevisionForNote(this.noteId)) {
this.refresh();
}
}
}
export default NoteRevisionsWidget;

View File

@@ -0,0 +1,84 @@
import TabAwareWidget from "./tab_aware_widget.js";
import utils from "../services/utils.js";
import protectedSessionHolder from "../services/protected_session_holder.js";
import server from "../services/server.js";
import SpacedUpdate from "../services/spaced_update.js";
const TPL = `
<div class="note-title-container">
<style>
.note-title-container {
flex-grow: 100;
}
.note-title-container input.note-title {
font-size: 150%;
border: 0;
min-width: 5em;
width: 100%;
}
</style>
<input autocomplete="off" value="" class="note-title" tabindex="1">
</div>`;
export default class NoteTitleWidget extends TabAwareWidget {
constructor() {
super();
this.spacedUpdate = new SpacedUpdate(async () => {
const title = this.$noteTitle.val();
await server.put(`notes/${this.noteId}/change-title`, {title});
});
}
doRender() {
this.$widget = $(TPL);
this.$noteTitle = this.$widget.find(".note-title");
this.$noteTitle.on('input', () => this.spacedUpdate.scheduleUpdate());
utils.bindElShortcut(this.$noteTitle, 'return', () => {
this.triggerCommand('focusOnDetail', {tabId: this.tabContext.tabId});
});
return this.$widget;
}
async refreshWithNote(note) {
this.$noteTitle.val(note.title);
this.$noteTitle.prop("readonly", note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable());
}
async beforeNoteSwitchEvent({tabContext}) {
if (this.isTab(tabContext.tabId)) {
await this.spacedUpdate.updateNowIfNecessary();
}
}
async beforeTabRemoveEvent({tabId}) {
if (this.isTab(tabId)) {
await this.spacedUpdate.updateNowIfNecessary();
}
}
focusOnTitleEvent() {
if (this.tabContext && this.tabContext.isActive()) {
this.$noteTitle.trigger('focus');
}
}
focusAndSelectTitleEvent() {
if (this.tabContext && this.tabContext.isActive()) {
this.$noteTitle
.trigger('focus')
.trigger('select');
}
}
beforeUnloadEvent() {
this.spacedUpdate.updateNowIfNecessary();
}
}

View File

@@ -0,0 +1,818 @@
import hoistedNoteService from "../services/hoisted_note.js";
import treeService from "../services/tree.js";
import utils from "../services/utils.js";
import contextMenu from "../services/context_menu.js";
import treeCache from "../services/tree_cache.js";
import treeBuilder from "../services/tree_builder.js";
import branchService from "../services/branches.js";
import ws from "../services/ws.js";
import TabAwareWidget from "./tab_aware_widget.js";
import server from "../services/server.js";
import noteCreateService from "../services/note_create.js";
import toastService from "../services/toast.js";
import appContext from "../services/app_context.js";
import keyboardActionsService from "../services/keyboard_actions.js";
import clipboard from "../services/clipboard.js";
import protectedSessionService from "../services/protected_session.js";
import syncService from "../services/sync.js";
const TPL = `
<div class="tree">
<style>
.tree {
overflow: auto;
flex-grow: 1;
flex-shrink: 1;
flex-basis: 60%;
font-family: var(--tree-font-family);
font-size: var(--tree-font-size);
}
.refresh-search-button {
cursor: pointer;
position: relative;
top: -1px;
border: 1px solid transparent;
padding: 2px;
border-radius: 2px;
}
.refresh-search-button:hover {
border-color: var(--button-border-color);
}
</style>
</div>
`;
export default class NoteTreeWidget extends TabAwareWidget {
doRender() {
this.$widget = $(TPL);
this.$widget.on("click", ".unhoist-button", hoistedNoteService.unhoist);
this.$widget.on("click", ".refresh-search-button", () => this.refreshSearch());
// fancytree doesn't support middle click so this is a way to support it
this.$widget.on('mousedown', '.fancytree-title', e => {
if (e.which === 2) {
const node = $.ui.fancytree.getNode(e);
const notePath = treeService.getNotePath(node);
if (notePath) {
appContext.tabManager.openTabWithNote(notePath);
}
e.stopPropagation();
e.preventDefault();
}
});
this.initialized = this.initFancyTree();
return this.$widget;
}
async initFancyTree() {
const treeData = [await treeBuilder.prepareRootNode()];
this.$widget.fancytree({
autoScroll: true,
keyboard: false, // we takover keyboard handling in the hotkeys plugin
extensions: utils.isMobile() ? ["dnd5", "clones"] : ["hotkeys", "dnd5", "clones"],
source: treeData,
scrollParent: this.$widget,
minExpandLevel: 2, // root can't be collapsed
click: (event, data) => {
const targetType = data.targetType;
const node = data.node;
if (targetType === 'title' || targetType === 'icon') {
if (event.shiftKey) {
node.setSelected(!node.isSelected());
node.setFocus(true);
}
else if (event.ctrlKey) {
const notePath = treeService.getNotePath(node);
appContext.tabManager.openTabWithNote(notePath);
}
else if (data.node.isActive()) {
// this is important for single column mobile view, otherwise it's not possible to see again previously displayed note
this.tree.reactivate(true);
}
else {
node.setActive();
this.clearSelectedNodes();
}
return false;
}
},
activate: async (event, data) => {
// click event won't propagate so let's close context menu manually
contextMenu.hide();
const notePath = treeService.getNotePath(data.node);
const activeTabContext = appContext.tabManager.getActiveTabContext();
await activeTabContext.setNote(notePath);
if (utils.isMobile()) {
this.triggerCommand('setActiveScreen', {screen:'detail'});
}
},
expand: (event, data) => this.setExpandedToServer(data.node.data.branchId, true),
collapse: (event, data) => this.setExpandedToServer(data.node.data.branchId, false),
hotkeys: utils.isMobile() ? undefined : { keydown: await this.getHotKeys() },
dnd5: {
autoExpandMS: 600,
dragStart: (node, data) => {
// don't allow dragging root node
if (node.data.noteId === hoistedNoteService.getHoistedNoteId()
|| node.getParent().data.noteType === 'search') {
return false;
}
const notes = this.getSelectedOrActiveNodes(node).map(node => ({
noteId: node.data.noteId,
title: node.title
}));
data.dataTransfer.setData("text", JSON.stringify(notes));
// This function MUST be defined to enable dragging for the tree.
// Return false to cancel dragging of node.
return true;
},
dragEnter: (node, data) => true, // allow drop on any node
dragOver: (node, data) => true,
dragDrop: async (node, data) => {
if ((data.hitMode === 'over' && node.data.noteType === 'search') ||
(['after', 'before'].includes(data.hitMode)
&& (node.data.noteId === hoistedNoteService.getHoistedNoteId() || node.getParent().data.noteType === 'search'))) {
const infoDialog = await import('../dialogs/info.js');
await infoDialog.info("Dropping notes into this location is not allowed.");
return;
}
const dataTransfer = data.dataTransfer;
if (dataTransfer && dataTransfer.files && dataTransfer.files.length > 0) {
const files = [...dataTransfer.files]; // chrome has issue that dataTransfer.files empties after async operation
const importService = await import('../services/import.js');
importService.uploadFiles(node.data.noteId, files, {
safeImport: true,
shrinkImages: true,
textImportedAsText: true,
codeImportedAsCode: true,
explodeArchives: true
});
}
else {
// This function MUST be defined to enable dropping of items on the tree.
// data.hitMode is 'before', 'after', or 'over'.
const selectedBranchIds = this.getSelectedNodes().map(node => node.data.branchId);
if (data.hitMode === "before") {
branchService.moveBeforeBranch(selectedBranchIds, node.data.branchId);
} else if (data.hitMode === "after") {
branchService.moveAfterBranch(selectedBranchIds, node.data.branchId);
} else if (data.hitMode === "over") {
branchService.moveToParentNote(selectedBranchIds, node.data.noteId);
} else {
throw new Error("Unknown hitMode=" + data.hitMode);
}
}
}
},
lazyLoad: function(event, data) {
const noteId = data.node.data.noteId;
data.result = treeCache.getNote(noteId).then(note => treeBuilder.prepareBranch(note));
},
clones: {
highlightActiveClones: true
},
enhanceTitle: async function (event, data) {
const node = data.node;
const $span = $(node.span);
if (node.data.noteId !== 'root'
&& node.data.noteId === hoistedNoteService.getHoistedNoteId()
&& $span.find('.unhoist-button').length === 0) {
const unhoistButton = $('<span>&nbsp; (<a class="unhoist-button">unhoist</a>)</span>');
$span.append(unhoistButton);
}
const note = await treeCache.getNote(node.data.noteId);
if (note.type === 'search' && $span.find('.refresh-search-button').length === 0) {
const refreshSearchButton = $('<span>&nbsp; <span class="refresh-search-button bx bx-refresh" title="Refresh saved search results"></span></span>');
$span.append(refreshSearchButton);
}
},
// this is done to automatically lazy load all expanded search notes after tree load
loadChildren: (event, data) => {
data.node.visit((subNode) => {
// Load all lazy/unloaded child nodes
// (which will trigger `loadChildren` recursively)
if (subNode.isUndefined() && subNode.isExpanded()) {
subNode.load();
}
});
}
});
this.$widget.on('contextmenu', '.fancytree-node', e => {
const node = $.ui.fancytree.getNode(e);
import("../services/tree_context_menu.js").then(({default: TreeContextMenu}) => {
const treeContextMenu = new TreeContextMenu(this, node);
treeContextMenu.show(e);
});
return false; // blocks default browser right click menu
});
this.tree = $.ui.fancytree.getTree(this.$widget);
}
/** @return {FancytreeNode[]} */
getSelectedNodes(stopOnParents = false) {
return this.tree.getSelectedNodes(stopOnParents);
}
/** @return {FancytreeNode[]} */
getSelectedOrActiveNodes(node = null) {
const notes = this.getSelectedNodes(true);
if (notes.length === 0) {
notes.push(node ? node : this.getActiveNode());
}
return notes;
}
collapseTree(node = null) {
if (!node) {
const hoistedNoteId = hoistedNoteService.getHoistedNoteId();
node = this.getNodesByNoteId(hoistedNoteId)[0];
}
node.setExpanded(false);
node.visit(node => node.setExpanded(false));
}
/**
* @return {FancytreeNode|null}
*/
getActiveNode() {
return this.tree.getActiveNode();
}
/**
* focused & not active node can happen during multiselection where the node is selected
* but not activated (its content is not displayed in the detail)
* @return {FancytreeNode|null}
*/
getFocusedNode() {
return this.tree.getFocusNode();
}
clearSelectedNodes() {
for (const selectedNode of this.getSelectedNodes()) {
selectedNode.setSelected(false);
}
}
async scrollToActiveNoteEvent() {
const activeContext = appContext.tabManager.getActiveTabContext();
if (activeContext && activeContext.notePath) {
this.tree.setFocus();
const node = await this.expandToNote(activeContext.notePath);
await node.makeVisible({scrollIntoView: true});
node.setFocus();
}
}
/** @return {FancytreeNode} */
async getNodeFromPath(notePath, expand = false, expandOpts = {}) {
utils.assertArguments(notePath);
const hoistedNoteId = hoistedNoteService.getHoistedNoteId();
/** @var {FancytreeNode} */
let parentNode = null;
const runPath = await treeService.getRunPath(notePath);
if (!runPath) {
console.error("Could not find run path for notePath:", notePath);
return;
}
for (const childNoteId of runPath) {
if (childNoteId === hoistedNoteId) {
// there must be exactly one node with given hoistedNoteId
parentNode = this.getNodesByNoteId(childNoteId)[0];
continue;
}
// we expand only after hoisted note since before then nodes are not actually present in the tree
if (parentNode) {
if (!parentNode.isLoaded()) {
await parentNode.load();
}
if (expand) {
await parentNode.setExpanded(true, expandOpts);
}
this.updateNode(parentNode);
let foundChildNode = this.findChildNode(parentNode, childNoteId);
if (!foundChildNode) { // note might be recently created so we'll force reload and try again
await parentNode.load(true);
foundChildNode = this.findChildNode(parentNode, childNoteId);
if (!foundChildNode) {
ws.logError(`Can't find node for child node of noteId=${childNoteId} for parent of noteId=${parentNode.data.noteId} and hoistedNoteId=${hoistedNoteId}, requested path is ${notePath}`);
return;
}
}
parentNode = foundChildNode;
}
}
return parentNode;
}
/** @return {FancytreeNode} */
findChildNode(parentNode, childNoteId) {
let foundChildNode = null;
for (const childNode of parentNode.getChildren()) {
if (childNode.data.noteId === childNoteId) {
foundChildNode = childNode;
break;
}
}
return foundChildNode;
}
/** @return {FancytreeNode} */
async expandToNote(notePath, expandOpts) {
return this.getNodeFromPath(notePath, true, expandOpts);
}
updateNode(node) {
const note = treeCache.getNoteFromCache(node.data.noteId);
const branch = treeCache.getBranch(node.data.branchId);
node.data.isProtected = note.isProtected;
node.data.noteType = note.type;
node.folder = note.type === 'search' || note.getChildNoteIds().length > 0;
node.icon = treeBuilder.getIcon(note);
node.extraClasses = treeBuilder.getExtraClasses(note);
node.title = (branch.prefix ? (branch.prefix + " - ") : "") + note.title;
node.renderTitle();
}
/** @return {FancytreeNode[]} */
getNodesByBranchId(branchId) {
utils.assertArguments(branchId);
const branch = treeCache.getBranch(branchId);
return this.getNodesByNoteId(branch.noteId).filter(node => node.data.branchId === branchId);
}
/** @return {FancytreeNode[]} */
getNodesByNoteId(noteId) {
utils.assertArguments(noteId);
const list = this.tree.getNodesByRef(noteId);
return list ? list : []; // if no nodes with this refKey are found, fancy tree returns null
}
async reload() {
const rootNode = await treeBuilder.prepareRootNode();
await this.tree.reload([rootNode]);
}
// must be event since it's triggered from outside the tree
collapseTreeEvent() { this.collapseTree(); }
isEnabled() {
return !!this.tabContext;
}
async refresh() {
this.toggleInt(this.isEnabled());
const oldActiveNode = this.getActiveNode();
if (oldActiveNode) {
oldActiveNode.setActive(false);
oldActiveNode.setFocus(false);
}
if (this.tabContext && this.tabContext.notePath && !this.tabContext.note.isDeleted) {
const newActiveNode = await this.getNodeFromPath(this.tabContext.notePath);
if (newActiveNode) {
if (!newActiveNode.isVisible()) {
await this.expandToNote(this.tabContext.notePath);
}
newActiveNode.setActive(true, {noEvents: true});
newActiveNode.makeVisible({scrollIntoView: true});
}
}
}
async refreshSearch() {
const activeNode = this.getActiveNode();
activeNode.load(true);
activeNode.setExpanded(true);
toastService.showMessage("Saved search note refreshed.");
}
async entitiesReloadedEvent({loadResults}) {
const activeNode = this.getActiveNode();
const activeNotePath = activeNode ? treeService.getNotePath(activeNode) : null;
const activeNoteId = activeNode ? activeNode.data.noteId : null;
const noteIdsToUpdate = new Set();
const noteIdsToReload = new Set();
for (const attr of loadResults.getAttributes()) {
if (attr.type === 'label' && ['iconClass', 'cssClass'].includes(attr.name)) {
if (attr.isInheritable) {
noteIdsToReload.add(attr.noteId);
}
else {
noteIdsToUpdate.add(attr.noteId);
}
}
else if (attr.type === 'relation' && attr.name === 'template') {
// missing handling of things inherited from template
noteIdsToReload.add(attr.noteId);
}
}
for (const branch of loadResults.getBranches()) {
for (const node of this.getNodesByBranchId(branch.branchId)) {
if (branch.isDeleted) {
if (node.isActive()) {
const newActiveNode = node.getNextSibling()
|| node.getPrevSibling()
|| node.getParent();
if (newActiveNode) {
newActiveNode.setActive(true, {noEvents: true, noFocus: true});
}
}
node.remove();
noteIdsToUpdate.add(branch.parentNoteId);
}
else {
noteIdsToUpdate.add(branch.noteId);
}
}
if (!branch.isDeleted) {
for (const parentNode of this.getNodesByNoteId(branch.parentNoteId)) {
if (parentNode.isFolder() && !parentNode.isLoaded()) {
continue;
}
const found = (parentNode.getChildren() || []).find(child => child.data.noteId === branch.noteId);
if (!found) {
noteIdsToReload.add(branch.parentNoteId);
}
}
}
}
for (const noteId of loadResults.getNoteIds()) {
noteIdsToUpdate.add(noteId);
}
for (const noteId of noteIdsToReload) {
for (const node of this.getNodesByNoteId(noteId)) {
await node.load(true);
this.updateNode(node);
}
}
for (const noteId of noteIdsToUpdate) {
for (const node of this.getNodesByNoteId(noteId)) {
this.updateNode(node);
}
}
for (const parentNoteId of loadResults.getNoteReorderings()) {
for (const node of this.getNodesByNoteId(parentNoteId)) {
if (node.isLoaded()) {
node.sortChildren((nodeA, nodeB) => {
const branchA = treeCache.branches[nodeA.data.branchId];
const branchB = treeCache.branches[nodeB.data.branchId];
if (!branchA || !branchB) {
return 0;
}
return branchA.notePosition - branchB.notePosition;
});
}
}
}
if (activeNotePath) {
let node = await this.expandToNote(activeNotePath);
if (node && node.data.noteId !== activeNoteId) {
// if the active note has been moved elsewhere then it won't be found by the path
// so we switch to the alternative of trying to find it by noteId
const notesById = this.getNodesByNoteId(activeNoteId);
// if there are multiple clones then we'd rather not activate any one
node = notesById.length === 1 ? notesById[0] : null;
}
if (node) {
node.setActive(true, {noEvents: true});
}
}
}
async setExpandedToServer(branchId, isExpanded) {
utils.assertArguments(branchId);
const expandedNum = isExpanded ? 1 : 0;
await server.put('branches/' + branchId + '/expanded/' + expandedNum);
}
async reloadTreeFromCache() {
const activeNode = this.getActiveNode();
const activeNotePath = activeNode !== null ? treeService.getNotePath(activeNode) : null;
await this.reload();
if (activeNotePath) {
const node = await this.getNodeFromPath(activeNotePath, true);
await node.setActive(true, {noEvents: true});
}
}
hoistedNoteChangedEvent() {
this.reloadTreeFromCache();
}
treeCacheReloadedEvent() {
this.reloadTreeFromCache();
}
async getHotKeys() {
const actions = await keyboardActionsService.getActionsForScope('note-tree');
const hotKeyMap = {
// code below shouldn't be necessary normally, however there's some problem with interaction with context menu plugin
// after opening context menu, standard shortcuts don't work, but they are detected here
// so we essentially takeover the standard handling with our implementation.
"left": node => {
node.navigate($.ui.keyCode.LEFT, true);
this.clearSelectedNodes();
return false;
},
"right": node => {
node.navigate($.ui.keyCode.RIGHT, true);
this.clearSelectedNodes();
return false;
},
"up": node => {
node.navigate($.ui.keyCode.UP, true);
this.clearSelectedNodes();
return false;
},
"down": node => {
node.navigate($.ui.keyCode.DOWN, true);
this.clearSelectedNodes();
return false;
}
};
for (const action of actions) {
for (const shortcut of action.effectiveShortcuts) {
hotKeyMap[utils.normalizeShortcut(shortcut)] = node => {
this.triggerCommand(action.actionName, {node});
return false;
}
}
}
return hotKeyMap;
}
/**
* @param {FancytreeNode} node
*/
getSelectedOrActiveBranchIds(node) {
const nodes = this.getSelectedOrActiveNodes(node);
return nodes.map(node => node.data.branchId);
}
async deleteNotesCommand({node}) {
const branchIds = this.getSelectedOrActiveBranchIds(node);
await branchService.deleteNotes(branchIds);
this.clearSelectedNodes();
}
moveNoteUpCommand({node}) {
const beforeNode = node.getPrevSibling();
if (beforeNode !== null) {
branchService.moveBeforeBranch([node.data.branchId], beforeNode.data.branchId);
}
}
moveNoteDownCommand({node}) {
const afterNode = node.getNextSibling();
if (afterNode !== null) {
branchService.moveAfterBranch([node.data.branchId], afterNode.data.branchId);
}
}
moveNoteUpInHierarchyCommand({node}) {
branchService.moveNodeUpInHierarchy(node);
}
moveNoteDownInHierarchyCommand({node}) {
const toNode = node.getPrevSibling();
if (toNode !== null) {
branchService.moveToParentNote([node.data.branchId], toNode.data.noteId);
}
}
addNoteAboveToSelectionCommand() {
const node = this.getFocusedNode();
if (!node) {
return;
}
if (node.isActive()) {
node.setSelected(true);
}
const prevSibling = node.getPrevSibling();
if (prevSibling) {
prevSibling.setActive(true, {noEvents: true});
if (prevSibling.isSelected()) {
node.setSelected(false);
}
prevSibling.setSelected(true);
}
}
addNoteBelowToSelectionCommand() {
const node = this.getFocusedNode();
if (!node) {
return;
}
if (node.isActive()) {
node.setSelected(true);
}
const nextSibling = node.getNextSibling();
if (nextSibling) {
nextSibling.setActive(true, {noEvents: true});
if (nextSibling.isSelected()) {
node.setSelected(false);
}
nextSibling.setSelected(true);
}
}
collapseSubtreeCommand({node}) {
this.collapseTree(node);
}
sortChildNotesCommand({node}) {
treeService.sortAlphabetically(node.data.noteId);
}
async recentChangesInSubtreeCommand({node}) {
const recentChangesDialog = await import('../dialogs/recent_changes.js');
recentChangesDialog.showDialog(node.data.noteId);
}
selectAllNotesInParentCommand({node}) {
for (const child of node.getParent().getChildren()) {
child.setSelected(true);
}
}
copyNotesToClipboardCommand({node}) {
clipboard.copy(this.getSelectedOrActiveBranchIds(node));
}
cutNotesToClipboardCommand({node}) {
clipboard.cut(this.getSelectedOrActiveBranchIds(node));
}
pasteNotesFromClipboardCommand({node}) {
clipboard.pasteInto(node.data.noteId);
}
pasteNotesAfterFromClipboard({node}) {
clipboard.pasteAfter(node.data.branchId);
}
async exportNoteCommand({node}) {
const exportDialog = await import('../dialogs/export.js');
const notePath = treeService.getNotePath(node);
exportDialog.showDialog(notePath,"subtree");
}
async importIntoNoteCommand({node}) {
const importDialog = await import('../dialogs/import.js');
importDialog.showDialog(node.data.noteId);
}
forceNoteSyncCommand({node}) {
syncService.forceNoteSync(node.data.noteId);
}
editNoteTitleCommand({node}) {
appContext.triggerCommand('focusOnTitle');
}
activateParentNoteCommand({node}) {
if (!hoistedNoteService.isRootNode(node)) {
node.getParent().setActive().then(this.clearSelectedNodes);
}
}
protectSubtreeCommand({node}) {
protectedSessionService.protectNote(node.data.noteId, true, true);
}
unprotectSubtreeCommand({node}) {
protectedSessionService.protectNote(node.data.noteId, false, true);
}
duplicateNoteCommand({node}) {
const branch = treeCache.getBranch(node.data.branchId);
noteCreateService.duplicateNote(node.data.noteId, branch.parentNoteId);
}
}

View File

@@ -0,0 +1,150 @@
import server from '../services/server.js';
import mimeTypesService from '../services/mime_types.js';
import TabAwareWidget from "./tab_aware_widget.js";
const NOTE_TYPES = [
{ type: "file", title: "File", selectable: false },
{ type: "image", title: "Image", selectable: false },
{ type: "search", title: "Saved search", selectable: false },
{ type: "text", mime: "text/html", title: "Text", selectable: true },
{ type: "relation-map", mime: "application/json", title: "Relation Map", selectable: true },
{ type: "render", mime: '', title: "Render Note", selectable: true },
{ type: "book", mime: '', title: "Book", selectable: true },
{ type: "code", mime: 'text/plain', title: "Code", selectable: true }
];
const TPL = `
<div class="dropdown note-type">
<style>
.note-type-dropdown {
max-height: 500px;
overflow-y: auto;
overflow-x: hidden;
}
</style>
<button type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" class="btn btn-sm dropdown-toggle note-type-button">
Type: <span class="note-type-desc"></span>
<span class="caret"></span>
</button>
<div class="note-type-dropdown dropdown-menu dropdown-menu-right"></div>
</div>
`;
export default class NoteTypeWidget extends TabAwareWidget {
doRender() {
this.$widget = $(TPL);
this.$widget.on('show.bs.dropdown', () => this.renderDropdown());
this.$noteTypeDropdown = this.$widget.find(".note-type-dropdown");
this.$noteTypeButton = this.$widget.find(".note-type-button");
this.$noteTypeDesc = this.$widget.find(".note-type-desc");
return this.$widget;
}
async refreshWithNote(note) {
this.$noteTypeButton.prop("disabled",
() => ["file", "image", "search"].includes(note.type));
this.$noteTypeDesc.text(await this.findTypeTitle(note.type, note.mime));
}
/** actual body is rendered lazily on note-type button click */
async renderDropdown() {
this.$noteTypeDropdown.empty();
for (const noteType of NOTE_TYPES.filter(nt => nt.selectable)) {
const $typeLink = $('<a class="dropdown-item">')
.attr("data-note-type", noteType.type)
.append('<span class="check">&check;</span> ')
.append($('<strong>').text(noteType.title))
.on('click', e => {
const type = $typeLink.attr('data-note-type');
const noteType = NOTE_TYPES.find(nt => nt.type === type);
this.save(noteType.type, noteType.mime);
});
if (this.note.type === noteType.type) {
$typeLink.addClass("selected");
}
this.$noteTypeDropdown.append($typeLink);
if (noteType.type !== 'code') {
this.$noteTypeDropdown.append('<div class="dropdown-divider"></div>');
}
}
for (const mimeType of await mimeTypesService.getMimeTypes()) {
if (!mimeType.enabled) {
continue;
}
const $mimeLink = $('<a class="dropdown-item">')
.attr("data-mime-type", mimeType.mime)
.append('<span class="check">&check;</span> ')
.append($('<span>').text(mimeType.title))
.on('click', e => {
const $link = $(e.target).closest('.dropdown-item');
this.save('code', $link.attr('data-mime-type'))
});
if (this.note.type === 'code' && this.note.mime === mimeType.mime) {
$mimeLink.addClass("selected");
this.$noteTypeDesc.text(mimeType.title);
}
this.$noteTypeDropdown.append($mimeLink);
}
}
async findTypeTitle(type, mime) {
if (type === 'code') {
const mimeTypes = await mimeTypesService.getMimeTypes();
const found = mimeTypes.find(mt => mt.mime === mime);
return found ? found.title : mime;
}
else {
const noteType = NOTE_TYPES.find(nt => nt.type === type);
return noteType ? noteType.title : type;
}
}
async save(type, mime) {
if (type === this.note.type && mime === this.note.mime) {
return;
}
if (type !== this.note.type && !await this.confirmChangeIfContent()) {
return;
}
await server.put('notes/' + this.noteId
+ '/type/' + encodeURIComponent(type)
+ '/mime/' + encodeURIComponent(mime));
}
async confirmChangeIfContent() {
const noteComplement = await this.tabContext.getNoteComplement();
if (!noteComplement.content || !noteComplement.content.trim().length) {
return true;
}
const confirmDialog = await import("../dialogs/confirm.js");
return await confirmDialog.confirm("It is not recommended to change note type when note content is not empty. Do you want to continue anyway?");
}
async entitiesReloadedEvent({loadResults}) {
if (loadResults.isNoteReloaded(this.noteId, this.componentId)) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,269 @@
import server from "../services/server.js";
import ws from "../services/ws.js";
import treeService from "../services/tree.js";
import noteAutocompleteService from "../services/note_autocomplete.js";
import TabAwareWidget from "./tab_aware_widget.js";
const TPL = `
<div class="promoted-attributes-wrapper">
<style>
.promoted-attributes-wrapper {
margin: auto;
/* setting the display to block since "table" doesn't support scrolling */
display: block;
/** flex-basis: content; - use once "content" is implemented by chrome */
flex-shrink: 0;
flex-grow: 0;
overflow: auto;
}
.promoted-attributes td, .promoted-attributes th {
padding: 5px;
}
</style>
<table class="promoted-attributes"></table>
</div>
`;
export default class PromotedAttributesWidget extends TabAwareWidget {
doRender() {
this.$widget = $(TPL);
this.$container = this.$widget.find(".promoted-attributes");
return this.$widget;
}
async refreshWithNote(note) {
this.$container.empty();
const attributes = note.getAttributes();
const promoted = attributes
.filter(attr => attr.type === 'label-definition' || attr.type === 'relation-definition')
.filter(attr => !attr.name.startsWith("child:"))
.filter(attr => {
const json = attr.jsonValue;
return json && json.isPromoted;
});
const hidePromotedAttributes = attributes.some(attr => attr.type === 'label' && attr.name === 'hidePromotedAttributes');
if (promoted.length > 0 && !hidePromotedAttributes) {
const $tbody = $("<tbody>");
for (const definitionAttr of promoted) {
const definitionType = definitionAttr.type;
const valueType = definitionType.substr(0, definitionType.length - 11);
let valueAttrs = attributes.filter(el => el.name === definitionAttr.name && el.type === valueType);
if (valueAttrs.length === 0) {
valueAttrs.push({
attributeId: "",
type: valueType,
name: definitionAttr.name,
value: ""
});
}
if (definitionAttr.value.multiplicityType === 'singlevalue') {
valueAttrs = valueAttrs.slice(0, 1);
}
for (const valueAttr of valueAttrs) {
const $tr = await this.createPromotedAttributeRow(definitionAttr, valueAttr);
$tbody.append($tr);
}
}
// we replace the whole content in one step so there can't be any race conditions
// (previously we saw promoted attributes doubling)
this.$container.empty().append($tbody);
this.toggleInt(true);
}
else {
this.toggleInt(false);
}
return attributes;
}
async createPromotedAttributeRow(definitionAttr, valueAttr) {
const definition = definitionAttr.jsonValue;
const $tr = $("<tr>");
const $labelCell = $("<th>").append(valueAttr.name);
const $input = $("<input>")
.prop("tabindex", definitionAttr.position)
.prop("attribute-id", valueAttr.isOwned ? valueAttr.attributeId : '') // if not owned, we'll force creation of a new attribute instead of updating the inherited one
.prop("attribute-type", valueAttr.type)
.prop("attribute-name", valueAttr.name)
.prop("value", valueAttr.value)
.addClass("form-control")
.addClass("promoted-attribute-input")
.on('change', event => this.promotedAttributeChanged(event));
const $inputCell = $("<td>").append($("<div>").addClass("input-group").append($input));
const $actionCell = $("<td>");
const $multiplicityCell = $("<td>")
.addClass("multiplicity")
.attr("nowrap", true);
$tr
.append($labelCell)
.append($inputCell)
.append($actionCell)
.append($multiplicityCell);
if (valueAttr.type === 'label') {
if (definition.labelType === 'text') {
$input.prop("type", "text");
// no need to await for this, can be done asynchronously
server.get('attributes/values/' + encodeURIComponent(valueAttr.name)).then(attributeValues => {
if (attributeValues.length === 0) {
return;
}
attributeValues = attributeValues.map(attribute => ({ value: attribute }));
$input.autocomplete({
appendTo: document.querySelector('body'),
hint: false,
autoselect: false,
openOnFocus: true,
minLength: 0,
tabAutocomplete: false
}, [{
displayKey: 'value',
source: function (term, cb) {
term = term.toLowerCase();
const filtered = attributeValues.filter(attr => attr.value.toLowerCase().includes(term));
cb(filtered);
}
}]);
});
}
else if (definition.labelType === 'number') {
$input.prop("type", "number");
let step = 1;
for (let i = 0; i < (definition.numberPrecision || 0) && i < 10; i++) {
step /= 10;
}
$input.prop("step", step);
}
else if (definition.labelType === 'boolean') {
$input.prop("type", "checkbox");
if (valueAttr.value === "true") {
$input.prop("checked", "checked");
}
}
else if (definition.labelType === 'date') {
$input.prop("type", "date");
}
else if (definition.labelType === 'url') {
$input.prop("placeholder", "http://website...");
const $openButton = $("<span>")
.addClass("input-group-text open-external-link-button bx bx-trending-up")
.prop("title", "Open external link")
.on('click', () => window.open($input.val(), '_blank'));
$input.after($("<div>")
.addClass("input-group-append")
.append($openButton));
}
else {
ws.logError("Unknown labelType=" + definitionAttr.labelType);
}
}
else if (valueAttr.type === 'relation') {
if (valueAttr.value) {
$input.val(await treeService.getNoteTitle(valueAttr.value));
}
// no need to wait for this
noteAutocompleteService.initNoteAutocomplete($input);
$input.on('autocomplete:selected', (event, suggestion, dataset) => {
this.promotedAttributeChanged(event);
});
$input.setSelectedPath(valueAttr.value);
}
else {
ws.logError("Unknown attribute type=" + valueAttr.type);
return;
}
if (definition.multiplicityType === "multivalue") {
const addButton = $("<span>")
.addClass("bx bx-plus pointer")
.prop("title", "Add new attribute")
.on('click', async () => {
const $new = await this.createPromotedAttributeRow(definitionAttr, {
attributeId: "",
type: valueAttr.type,
name: definitionAttr.name,
value: ""
});
$tr.after($new);
$new.find('input').trigger('focus');
});
const removeButton = $("<span>")
.addClass("bx bx-trash pointer")
.prop("title", "Remove this attribute")
.on('click', async () => {
if (valueAttr.attributeId) {
await server.remove("notes/" + this.noteId + "/attributes/" + valueAttr.attributeId);
}
$tr.remove();
});
$multiplicityCell.append(addButton).append(" &nbsp;").append(removeButton);
}
return $tr;
}
async promotedAttributeChanged(event) {
const $attr = $(event.target);
let value;
if ($attr.prop("type") === "checkbox") {
value = $attr.is(':checked') ? "true" : "false";
}
else if ($attr.prop("attribute-type") === "relation") {
const selectedPath = $attr.getSelectedPath();
value = selectedPath ? treeService.getNoteIdFromNotePath(selectedPath) : "";
}
else {
value = $attr.val();
}
const result = await server.put(`notes/${this.noteId}/attribute`, {
attributeId: $attr.prop("attribute-id"),
type: $attr.prop("attribute-type"),
name: $attr.prop("attribute-name"),
value: value
});
$attr.prop("attribute-id", result.attributeId);
}
}

View File

@@ -0,0 +1,42 @@
import protectedSessionService from "../services/protected_session.js";
import TabAwareWidget from "./tab_aware_widget.js";
const TPL = `
<div class="btn-group btn-group-xs">
<button type="button"
class="btn btn-sm icon-button bx bx-check-shield protect-button"
title="Protected note can be viewed and edited only after entering password">
</button>
<button type="button"
class="btn btn-sm icon-button bx bx-shield-x unprotect-button"
title="Not protected note can be viewed without entering password">
</button>
</div>`;``;
export default class ProtectedNoteSwitchWidget extends TabAwareWidget {
doRender() {
this.$widget = $(TPL);
this.$protectButton = this.$widget.find(".protect-button");
this.$protectButton.on('click', () => protectedSessionService.protectNote(this.noteId, true, false));
this.$unprotectButton = this.$widget.find(".unprotect-button");
this.$unprotectButton.on('click', () => protectedSessionService.protectNote(this.noteId, false, false));
return this.$widget;
}
refreshWithNote(note) {
this.$protectButton.toggleClass("active", note.isProtected);
this.$protectButton.prop("disabled", note.isProtected);
this.$unprotectButton.toggleClass("active", !note.isProtected);
this.$unprotectButton.prop("disabled", !note.isProtected);
}
async entitiesReloadedEvent({loadResults}) {
if (loadResults.isNoteReloaded(this.noteId)) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,26 @@
import TabAwareWidget from "./tab_aware_widget.js";
const TPL = `
<div style="display: inline-flex;">
<button class="btn btn-sm icon-button bx bx-play-circle render-button"
title="Render"></button>
<button class="btn btn-sm icon-button bx bx-play-circle execute-script-button"
title="Execute (Ctrl+Enter)"></button>
</div>`;
export default class RunScriptButtonsWidget extends TabAwareWidget {
doRender() {
this.$widget = $(TPL);
this.$renderButton = this.$widget.find('.render-button');
this.$executeScriptButton = this.$widget.find('.execute-script-button');
return this.$widget;
}
refreshWithNote(note) {
this.$renderButton.toggle(note.type === 'render');
this.$executeScriptButton.toggle(note.mime.startsWith('application/javascript'));
}
}

View File

@@ -0,0 +1,18 @@
import FlexContainer from "./flex_container.js";
export default class ScreenContainer extends FlexContainer {
constructor(screenName, direction) {
super(direction);
this.screenName = screenName;
}
activeScreenChangedEvent({activeScreen}) {
if (activeScreen === this.screenName) {
this.$widget.removeClass('d-none');
}
else {
this.$widget.addClass('d-none');
}
}
}

View File

@@ -0,0 +1,177 @@
import BasicWidget from "./basic_widget.js";
import toastService from "../services/toast.js";
import appContext from "../services/app_context.js";
import noteCreateService from "../services/note_create.js";
import utils from "../services/utils.js";
const TPL = `
<div class="search-box">
<style>
.search-box {
display: none;
padding: 10px;
margin-top: 10px;
}
.search-text {
border: 1px solid var(--main-border-color);
}
</style>
<div class="form-group">
<div class="input-group">
<input name="search-text" class="search-text form-control"
placeholder="Search text, labels" autocomplete="off">
<div class="input-group-append">
<button class="do-search-button btn btn-sm icon-button bx bx-search" title="Search (enter)"></button>
</div>
</div>
</div>
<div style="display: flex; align-items: center; justify-content: space-evenly; flex-wrap: wrap;">
<button class="save-search-button btn btn-sm"
title="This will create new saved search note under active note.">
<span class="bx bx-save"></span> Save search
</button>
<button class="close-search-button btn btn-sm">
<span class="bx bx-x"></span> Close search
</button>
</div>
</div>`;
export default class SearchBoxWidget extends BasicWidget {
doRender() {
this.$widget = $(TPL);
this.$searchBox = this.$widget;
this.$closeSearchButton = this.$widget.find(".close-search-button");
this.$searchInput = this.$widget.find("input[name='search-text']");
this.$resetSearchButton = this.$widget.find(".reset-search-button");
this.$doSearchButton = this.$widget.find(".do-search-button");
this.$saveSearchButton = this.$widget.find(".save-search-button");
this.$searchInput.on('keyup',e => {
const searchText = this.$searchInput.val();
if (e && e.which === $.ui.keyCode.ESCAPE || $.trim(searchText) === "") {
this.$resetSearchButton.trigger('click');
return;
}
if (e && e.which === $.ui.keyCode.ENTER) {
this.doSearch();
}
});
this.$doSearchButton.on('click', () => this.doSearch()); // keep long form because of argument
this.$resetSearchButton.on('click', () => this.resetSearchEvent());
this.$saveSearchButton.on('click', () => this.saveSearch());
this.$closeSearchButton.on('click', () => this.hideSearch());
return this.$widget;
}
doSearch(searchText) {
if (searchText) {
this.$searchInput.val(searchText);
}
else {
searchText = this.$searchInput.val();
}
if (searchText.trim().length === 0) {
toastService.showMessage("Please enter search criteria first.");
this.$searchInput.trigger('focus');
return;
}
this.triggerCommand('searchForResults', {
searchText: this.$searchInput.val()
});
this.$searchBox.tooltip("hide");
}
async saveSearch() {
const searchString = this.$searchInput.val().trim();
if (searchString.length === 0) {
alert("Write some search criteria first so there is something to save.");
return;
}
// FIXME
let activeNote = appContext.tabManager.getActiveTabNote();
if (activeNote.type === 'search') {
activeNote = activeNote.getParentNotes()[0];
}
await noteCreateService.createNote(activeNote.noteId, {
type: "search",
mime: "application/json",
title: searchString,
content: JSON.stringify({ searchString: searchString })
});
this.resetSearchEvent();
}
showSearchEvent() {
utils.saveFocusedElement();
this.$searchBox.slideDown();
this.$searchBox.tooltip({
trigger: 'focus',
html: true,
title: window.glob.SEARCH_HELP_TEXT,
placement: 'right',
delay: {
show: 500, // necessary because sliding out may cause wrong position
hide: 200
}
});
this.$searchInput.trigger('focus');
}
hideSearch() {
this.resetSearchEvent();
this.$searchBox.slideUp();
this.triggerCommand('searchFlowEnded');
}
toggleSearchEvent() {
if (this.$searchBox.is(":hidden")) {
this.showSearchEvent();
}
else {
this.hideSearch();
}
}
searchNotesEvent() {
this.toggleSearchEvent();
}
resetSearchEvent() {
this.$searchInput.val("");
}
searchInSubtreeEvent({node}) {
const noteId = node.data.noteId;
this.showSearchEvent();
this.$searchInput.val(`@in=${noteId} @text*=*`);
}
}

View File

@@ -0,0 +1,63 @@
import BasicWidget from "./basic_widget.js";
import toastService from "../services/toast.js";
import server from "../services/server.js";
const TPL = `
<div class="search-results">
<style>
.search-results {
padding: 0 5px 5px 15px;
flex-basis: 40%;
flex-grow: 1;
flex-shrink: 1;
margin-top: 10px;
display: none;
overflow: auto;
border-bottom: 2px solid var(--main-border-color);
}
.search-results-list {
padding: 5px 5px 5px 15px;
}
</style>
<strong>Search results:</strong>
<ul class="search-results-list"></ul>
</div>
`;
export default class SearchResultsWidget extends BasicWidget {
doRender() {
this.$widget = $(TPL);
this.$searchResults = this.$widget;
this.$searchResultsInner = this.$widget.find(".search-results-list");
this.toggleInt(false);
return this.$widget;
}
searchResultsEvent({results}) {
this.toggleInt(true);
this.$searchResultsInner.empty();
this.$searchResults.show();
for (const result of results) {
const link = $('<a>', {
href: 'javascript:',
text: result.title
}).attr('data-action', 'note').attr('data-note-path', result.path);
const $result = $('<li>').append(link);
this.$searchResultsInner.append($result);
}
}
searchFlowEndedEvent() {
this.$searchResults.hide();
}
}

View File

@@ -0,0 +1,25 @@
import options from "../services/options.js";
import FlexContainer from "./flex_container.js";
export default class SidePaneContainer extends FlexContainer {
constructor(side) {
super('column');
this.side = side;
this.id(side + '-pane');
this.css('height', '100%');
}
isEnabled() {
return super.isEnabled() && options.is(this.side + 'PaneVisible');
}
sidebarVisibilityChangedEvent({side, show}) {
this.toggleInt(this.isEnabled());
if (this.side === side && show) {
this.handleEvent('lazyLoaded');
}
}
}

View File

@@ -0,0 +1,74 @@
import options from "../services/options.js";
import splitService from "../services/split.js";
import BasicWidget from "./basic_widget.js";
const TPL = `
<div class="hide-in-zen-mode">
<style>
.hide-right-pane-button, .show-right-pane-button {
position: fixed;
bottom: 10px;
right: 10px;
z-index: 1000;
}
.hide-left-pane-button, .show-left-pane-button {
position: fixed;
bottom: 10px;
left: 10px;
z-index: 1000;
}
</style>
<button class="hide-left-pane-button btn btn-sm icon-button bx bx-chevrons-left" title="Show sidebar"></button>
<button class="show-left-pane-button btn btn-sm icon-button bx bx-chevrons-right" title="Hide sidebar"></button>
<button class="hide-right-pane-button btn btn-sm icon-button bx bx-chevrons-right" title="Hide sidebar"></button>
<button class="show-right-pane-button btn btn-sm icon-button bx bx-chevrons-left" title="Show sidebar"></button>
</div>
`;
export default class SidePaneToggles extends BasicWidget {
constructor() {
super();
this.paneVisible = {};
}
doRender() {
this.$widget = $(TPL);
this.$widget.find(".show-right-pane-button").on('click', () => this.toggleAndSave('right', true));
this.$widget.find(".hide-right-pane-button").on('click', () => this.toggleAndSave('right', false));
this.$widget.find(".show-left-pane-button").on('click', () => this.toggleAndSave('left', true));
this.$widget.find(".hide-left-pane-button").on('click', () => this.toggleAndSave('left', false));
return this.$widget;
}
toggleSidebar(side, show) {
$(`#${side}-pane`).toggle(show);
this.$widget.find(`.show-${side}-pane-button`).toggle(!show);
this.$widget.find(`.hide-${side}-pane-button`).toggle(show);
this.paneVisible[side] = show;
}
async toggleAndSave(side, show) {
await options.save(`${side}PaneVisible`, show.toString());
this.toggleSidebar(side, show);
splitService.setupSplit(this.paneVisible.left, this.paneVisible.right);
this.triggerEvent('sidebarVisibilityChanged', {side, show});
}
initialRenderCompleteEvent() {
this.toggleSidebar('left', options.is('leftPaneVisible'));
this.toggleSidebar('right', options.is('rightPaneVisible'));
splitService.setupSplit(this.paneVisible.left, this.paneVisible.right);
}
}

View File

@@ -0,0 +1,69 @@
import CollapsibleWidget from "./collapsible_widget.js";
import linkService from "../services/link.js";
import server from "../services/server.js";
import treeCache from "../services/tree_cache.js";
export default class SimilarNotesWidget extends CollapsibleWidget {
get widgetTitle() { return "Similar notes"; }
get help() {
return {
title: "This list contains notes which might be similar to the current note based on textual similarity of note title."
};
}
noteSwitched() {
const noteId = this.noteId;
this.$body.empty();
// avoid executing this expensive operation multiple times when just going through notes (with keyboard especially)
// until the users settles on a note
setTimeout(() => {
if (this.noteId === noteId) {
this.refresh();
}
}, 1000);
}
async refreshWithNote(note) {
// remember which title was when we found the similar notes
this.title = note.title;
const similarNotes = await server.get('similar-notes/' + this.noteId);
if (similarNotes.length === 0) {
this.$body.text("No similar notes found ...");
return;
}
const noteIds = similarNotes.flatMap(note => note.notePath);
await treeCache.getNotes(noteIds, true); // preload all at once
const $list = $('<ul>');
for (const similarNote of similarNotes) {
const note = await treeCache.getNote(similarNote.noteId, true);
if (!note) {
continue;
}
const $item = $("<li>")
.append(await linkService.createNoteLink(similarNote.notePath.join("/"), {showNotePath: true}));
$list.append($item);
}
this.$body.empty().append($list);
}
entitiesReloadedEvent({loadResults}) {
if (this.note && this.title !== this.note.title) {
this.rendered = false;
this.refresh();
}
}
}

View File

@@ -0,0 +1,90 @@
import BasicWidget from "./basic_widget.js";
import HistoryNavigationWidget from "./history_navigation.js";
import protectedSessionService from "../services/protected_session.js";
const TPL = `
<div class="standard-top-widget">
<style>
.standard-top-widget {
background-color: var(--header-background-color);
display: flex;
align-items: center;
padding-top: 4px;
}
.standard-top-widget button {
padding: 1px 5px 1px 5px;
font-size: smaller;
margin-bottom: 2px;
margin-top: 2px;
margin-right: 8px;
border-color: transparent !important;
}
.standard-top-widget button.btn-sm .bx {
position: relative;
top: 1px;
}
.standard-top-widget button:hover {
border-color: var(--button-border-color) !important;
}
</style>
<div style="flex-grow: 100; display: flex;">
<button class="btn btn-sm jump-to-note-dialog-button" data-command="jumpToNote">
<span class="bx bx-crosshair"></span>
Jump to note
</button>
<button class="btn btn-sm recent-changes-button" data-command="showRecentChanges">
<span class="bx bx-history"></span>
Recent changes
</button>
<button class="btn btn-sm enter-protected-session-button"
title="Enter protected session to be able to find and view protected notes">
<span class="bx bx-log-in"></span>
Enter protected session
</button>
<button class="btn btn-sm leave-protected-session-button"
title="Leave protected session so that protected notes are not accessible any more."
style="display: none;">
<span class="bx bx-log-out"></span>
Leave protected session
</button>
</div>
<div id="plugin-buttons"></div>
</div>`;
export default class StandardTopWidget extends BasicWidget {
doRender() {
this.$widget = $(TPL);
const historyNavigationWidget = new HistoryNavigationWidget();
this.child(historyNavigationWidget);
this.$widget.prepend(historyNavigationWidget.render());
this.$widget.find(".jump-to-note-dialog-button").on('click', () => this.triggerCommand('jumpToNote'));
this.$widget.find(".recent-changes-button").on('click', () => this.triggerCommand('showRecentChanges'));
this.$enterProtectedSessionButton = this.$widget.find(".enter-protected-session-button");
this.$enterProtectedSessionButton.on('click', protectedSessionService.enterProtectedSession);
this.$leaveProtectedSessionButton = this.$widget.find(".leave-protected-session-button");
this.$leaveProtectedSessionButton.on('click', protectedSessionService.leaveProtectedSession);
return this.$widget
}
protectedSessionStartedEvent() {
this.$enterProtectedSessionButton.hide();
this.$leaveProtectedSessionButton.show();
}
}

View File

@@ -0,0 +1,102 @@
import BasicWidget from "./basic_widget.js";
import appContext from "../services/app_context.js";
export default class TabAwareWidget extends BasicWidget {
isTab(tabId) {
return this.tabContext && this.tabContext.tabId === tabId;
}
isNote(noteId) {
return this.noteId === noteId;
}
get note() {
return this.tabContext && this.tabContext.note;
}
get noteId() {
return this.note && this.note.noteId;
}
get notePath() {
return this.tabContext && this.tabContext.notePath;
}
isEnabled() {
return !!this.note;
}
async refresh() {
if (this.isEnabled()) {
const start = Date.now();
this.toggleInt(true);
await this.refreshWithNote(this.note, this.notePath);
const end = Date.now();
if (glob.PROFILING_LOG && end - start > 10) {
console.log(`Refresh of ${this.componentId} took ${end-start}ms`);
}
}
else {
this.toggleInt(false);
}
}
async refreshWithNote(note, notePath) {}
async tabNoteSwitchedEvent({tabContext, notePath}) {
// if notePath does not match then the tabContext has been switched to another note in the mean time
if (tabContext.notePath === notePath) {
await this.noteSwitched();
}
}
async noteSwitched() {
await this.refresh();
}
async activeTabChangedEvent({tabContext}) {
this.tabContext = tabContext;
await this.activeTabChanged();
}
async activeTabChanged() {
await this.refresh();
}
// when note is both switched and activated, this should not produce double refresh
async tabNoteSwitchedAndActivatedEvent({tabContext, notePath}) {
this.tabContext = tabContext;
// if notePath does not match then the tabContext has been switched to another note in the mean time
if (this.notePath === notePath) {
await this.refresh();
}
}
setTabContextEvent({tabContext}) {
/** @var {TabContext} */
this.tabContext = tabContext;
}
async noteTypeMimeChangedEvent({noteId}) {
if (this.isNote(noteId)) {
await this.refresh();
}
}
async treeCacheReloadedEvent() {
await this.refresh();
}
async lazyLoadedEvent() {
if (!this.tabContext) { // has not been loaded yet
this.tabContext = appContext.tabManager.getActiveTabContext();
}
await this.refresh();
}
}

View File

@@ -0,0 +1,85 @@
import TabAwareWidget from "./tab_aware_widget.js";
import keyboardActionsService from "../services/keyboard_actions.js";
export default class TabCachingWidget extends TabAwareWidget {
constructor(widgetFactory) {
super();
this.widgetFactory = widgetFactory;
this.widgets = {};
}
doRender() {
return this.$widget = $(`<div class="marker" style="display: none;">`);
}
async newTabOpenedEvent({tabContext}) {
const {tabId} = tabContext;
if (this.widgets[tabId]) {
return;
}
this.widgets[tabId] = this.widgetFactory();
const $renderedWidget = this.widgets[tabId].render();
this.widgets[tabId].toggleExt(false); // new tab is always not active, can be activated after creation
this.$widget.after($renderedWidget);
keyboardActionsService.updateDisplayedShortcuts($renderedWidget);
await this.widgets[tabId].handleEvent('setTabContext', {tabContext});
this.child(this.widgets[tabId]); // add as child only once it is ready (rendered with tabContext)
}
tabRemovedEvent({tabId}) {
const widget = this.widgets[tabId];
if (widget) {
widget.remove();
delete this.widgets[tabId];
this.children = this.children.filter(ch => ch !== widget);
}
}
async refresh() {
this.toggleExt(true);
}
toggleInt(show) {} // not needed
toggleExt(show) {
for (const tabId in this.widgets) {
this.widgets[tabId].toggleExt(show && this.isTab(tabId));
}
}
handleEventInChildren(name, data) {
// stop propagation of the event to the children, individual tab widget should not know about tab switching
// since they are per-tab
if (name === 'tabNoteSwitchedAndActivated') {
name = 'tabNoteSwitched';
}
if (name === 'tabNoteSwitched') {
// this event is propagated only to the widgets of a particular tab
const widget = this.widgets[data.tabContext.tabId];
if (widget) {
return widget.handleEvent(name, data);
}
else {
return Promise.resolve();
}
}
if (name !== 'activeTabChanged') {
return super.handleEventInChildren(name, data);
}
return Promise.resolve();
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,57 @@
import BasicWidget from "./basic_widget.js";
import options from "../services/options.js";
import utils from "../services/utils.js";
const TPL = `
<div class="title-bar-buttons">
<style>
.title-bar-buttons {
margin-top: 4px;
min-width: 100px;
}
</style>
<button class="btn icon-action bx bx-minus minimize-btn"></button>
<button class="btn icon-action bx bx-checkbox maximize-btn"></button>
<button class="btn icon-action bx bx-x close-btn"></button>
</div>`;
export default class TitleBarButtonsWidget extends BasicWidget {
doRender() {
if (!utils.isElectron() || options.is('nativeTitleBarVisible')) {
return this.$widget = $('<div>');
}
this.$widget = $(TPL);
const $minimizeBtn = this.$widget.find(".minimize-btn");
const $maximizeBtn = this.$widget.find(".maximize-btn");
const $closeBtn = this.$widget.find(".close-btn");
$minimizeBtn.on('click', () => {
$minimizeBtn.trigger('blur');
const {remote} = utils.dynamicRequire('electron');
remote.BrowserWindow.getFocusedWindow().minimize();
});
$maximizeBtn.on('click', () => {
$maximizeBtn.trigger('blur');
const {remote} = utils.dynamicRequire('electron');
const focusedWindow = remote.BrowserWindow.getFocusedWindow();
if (focusedWindow.isMaximized()) {
focusedWindow.unmaximize();
} else {
focusedWindow.maximize();
}
});
$closeBtn.on('click', () => {
$closeBtn.trigger('blur');
const {remote} = utils.dynamicRequire('electron');
remote.BrowserWindow.getFocusedWindow().close();
});
return this.$widget;
}
}

View File

@@ -0,0 +1,65 @@
import TypeWidget from "./type_widget.js";
import appContext from "../../services/app_context.js";
import treeCache from "../../services/tree_cache.js";
import linkService from "../../services/link.js";
import noteContentRenderer from "../../services/note_content_renderer.js";
export default class AbstractTextTypeWidget extends TypeWidget {
doRender() {
this.$widget.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];
appContext.tabManager.getActiveTabContext().setNote(noteId);
}
else {
window.open(src, '_blank');
}
});
}
async loadIncludedNote(noteId, $el) {
const note = await treeCache.getNote(noteId);
if (note) {
const $link = await linkService.createNoteLink(note.noteId, {
showTooltip: false
});
$el.empty().append(
$('<h4 class="include-note-title">')
.append($link)
);
const {renderedContent} = await noteContentRenderer.getRenderedContent(note);
$el.append(
$('<div class="include-note-content">')
.append(renderedContent)
);
}
}
async loadReferenceLinkTitle(noteId, $el) {
const note = await treeCache.getNote(noteId, true);
let title;
if (!note) {
title = '[missing]';
}
else if (!note.isDeleted) {
title = note.title;
}
else {
title = note.isErased ? '[erased]' : `${note.title} (deleted)`;
}
$el.text(title);
}
}

View File

@@ -0,0 +1,244 @@
import linkService from "../../services/link.js";
import treeCache from "../../services/tree_cache.js";
import noteContentRenderer from "../../services/note_content_renderer.js";
import TypeWidget from "./type_widget.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"
}
};
const TPL = `
<div class="note-detail-book note-detail-printable">
<div class="btn-group floating-button" style="right: 20px; top: 20px;">
<button type="button"
class="expand-children-button btn icon-button bx bx-move-vertical"
title="Expand all children"></button>
<button type="button"
class="book-zoom-in-button btn icon-button bx bx-zoom-in"
title="Zoom In"></button>
<button type="button"
class="book-zoom-out-button btn icon-button bx bx-zoom-out"
title="Zoom Out"></button>
</div>
<div class="note-detail-book-help alert alert-warning" style="margin: 50px; padding: 20px;">
This note of type Book doesn't have any child notes so there's nothing to display. See <a href="https://github.com/zadam/trilium/wiki/Book-note">wiki</a> for details.
</div>
<div class="note-detail-book-content"></div>
</div>`;
export default class BookTypeWidget extends TypeWidget {
static getType() { return "book"; }
doRender() {
this.$widget = $(TPL);
this.$content = this.$widget.find('.note-detail-book-content');
this.$zoomInButton = this.$widget.find('.book-zoom-in-button');
this.$zoomOutButton = this.$widget.find('.book-zoom-out-button');
this.$expandChildrenButton = this.$widget.find('.expand-children-button');
this.$help = this.$widget.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();
});
return this.$widget;
}
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 doRefresh(note) {
this.$content.empty();
this.$help.hide();
if (this.isAutoBook()) {
const $addTextLink = $('<a href="javascript:">here</a>').on('click', () => {
this.tabContext.autoBookDisabled = true;
this.triggerEvent('autoBookDisabled', {tabContext: this.tabContext});
});
this.$content.append($('<div class="note-book-auto-message"></div>')
.append(`This note doesn't have any content so we display its children. <br> Click `)
.append($addTextLink)
.append(' if you want to add some text.'));
}
const zoomLevel = parseInt(note.getLabelValue('bookZoomLevel')) || this.getDefaultZoomLevel();
this.setZoom(zoomLevel);
await this.renderIntoElement(note, this.$content);
}
async renderIntoElement(note, $container) {
const childNotes = await note.getChildNotes();
if (childNotes.length === 0) {
this.$help.show();
}
const imageLinks = note.getRelations('imageLink');
for (const childNote of childNotes) {
// image is already visible in the parent note so no need to display it separately in the book
if (imageLinks.find(rel => rel.value === childNote.noteId)) {
continue;
}
const $card = await this.renderNote(childNote);
$container.append($card);
}
}
async renderNote(note) {
const notePath = this.notePath + '/' + note.noteId;
const $content = $('<div class="note-book-content">')
.css("max-height", ZOOMS[this.zoomLevel].height);
const $card = $('<div class="note-book-card">')
.attr('data-note-id', note.noteId)
.css("flex-basis", ZOOMS[this.zoomLevel].width)
.append($('<h5 class="note-book-title">').append(await linkService.createNoteLink(notePath, {showTooltip: false})))
.append($content);
try {
const {type, renderedContent} = await noteContentRenderer.getRenderedContent(note);
$card.addClass("type-" + type);
$content.append(renderedContent);
} catch (e) {
console.log(`Caught error while rendering note ${note.noteId} of type ${note.type}: ${e.message}, stack: ${e.stack}`);
$content.append("rendering error");
}
const imageLinks = note.getRelations('imageLink');
const childCount = note.getChildNoteIds()
.filter(childNoteId => !imageLinks.find(rel => rel.value === childNoteId))
.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 no-print" href="javascript:">+ Show ${label}</a>`))
.append($(`<a class="note-book-hide-children-button no-print" href="javascript:">- Hide ${label}</a>`).hide())
.append($('<div class="note-book-children-content">'))
);
}
return $card;
}
/** @return {boolean} true if this is "auto book" activated (empty text note) and not explicit book note */
isAutoBook() {
return this.note.type !== 'book';
}
getDefaultZoomLevel() {
if (this.isAutoBook()) {
const w = this.$widget.width();
if (w <= 600) {
return 1;
} else if (w <= 900) {
return 2;
} else if (w <= 1300) {
return 3;
} else {
return 4;
}
}
else {
return 1;
}
}
cleanup() {
this.$content.empty();
}
}

View File

@@ -0,0 +1,136 @@
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 keyboardActionService from "../../services/keyboard_actions.js";
import TypeWidget from "./type_widget.js";
const TPL = `
<div class="note-detail-code note-detail-printable">
<style>
.note-detail-code {
overflow: auto;
height: 100%;
}
.note-detail-code-editor {
min-height: 500px;
}
</style>
<div class="note-detail-code-editor"></div>
</div>`;
export default class CodeTypeWidget extends TypeWidget {
static getType() { return "code"; }
doRender() {
this.$widget = $(TPL);
this.$editor = this.$widget.find('.note-detail-code-editor');
this.$executeScriptButton = this.$widget.find(".execute-script-button");
keyboardActionService.setElementActionHandler(this.$widget, 'runActiveNote', () => this.executeCurrentNote());
this.$executeScriptButton.on('click', () => this.executeCurrentNote());
this.initialized = this.initEditor();
return this.$widget;
}
async initEditor() {
await libraryLoader.requireLibrary(libraryLoader.CODE_MIRROR);
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.$editor[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.codeEditor.on('change', () => this.spacedUpdate.scheduleUpdate());
}
async doRefresh(note) {
const noteComplement = await this.tabContext.getNoteComplement();
this.spacedUpdate.allowUpdateWithoutChange(() => {
// CodeMirror breaks pretty badly on null so even though it shouldn't happen (guarded by consistency check)
// we provide fallback
this.codeEditor.setValue(noteComplement.content || "");
this.codeEditor.clearHistory();
const info = CodeMirror.findModeByMIME(note.mime);
if (info) {
this.codeEditor.setOption("mode", info.mime);
CodeMirror.autoLoadMode(this.codeEditor, info.mode);
}
});
this.show();
}
show() {
this.$widget.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.note.type !== 'code') {
return;
}
// make sure note is saved so we load latest changes
await this.spacedUpdate.updateNowIfNecessary();
if (this.note.mime.endsWith("env=frontend")) {
await bundleService.getAndExecuteBundle(this.noteId);
}
if (this.note.mime.endsWith("env=backend")) {
await server.post('script/run/' + this.noteId);
}
toastService.showMessage("Note executed");
}
cleanup() {
if (this.codeEditor) {
this.spacedUpdate.allowUpdateWithoutChange(() => {
this.codeEditor.setValue('');
});
}
}
}

View File

@@ -0,0 +1,20 @@
import TypeWidget from "./type_widget.js";
const TPL = `
<div class="note-detail-deleted note-detail-printable">
<div style="padding: 100px;">
<div class="alert alert-warning" style="padding: 20px;">
This note has been deleted.
</div>
</div>
</div>`;
export default class DeletedTypeWidget extends TypeWidget {
static getType() { return "deleted"; }
doRender() {
this.$widget = $(TPL);
return this.$widget;
}
}

View File

@@ -0,0 +1,262 @@
import libraryLoader from "../../services/library_loader.js";
import noteAutocompleteService from '../../services/note_autocomplete.js';
import mimeTypesService from '../../services/mime_types.js';
import TypeWidget from "./type_widget.js";
import utils from "../../services/utils.js";
import appContext from "../../services/app_context.js";
import keyboardActionService from "../../services/keyboard_actions.js";
import treeCache from "../../services/tree_cache.js";
import linkService from "../../services/link.js";
import noteContentRenderer from "../../services/note_content_renderer.js";
import AbstractTextTypeWidget from "./abstract_text_type_widget.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-printable">
<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;
height: 100%;
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>
`;
export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
static getType() { return "editable-text"; }
doRender() {
this.$widget = $(TPL);
this.$editor = this.$widget.find('.note-detail-text-editor');
this.initialized = this.initEditor();
keyboardActionService.setupActionsForElement('text-detail', this.$widget, this);
super.doRender();
return this.$widget;
}
async initEditor() {
await libraryLoader.requireLibrary(libraryLoader.CKEDITOR);
const codeBlockLanguages =
(await mimeTypesService.getMimeTypes())
.filter(mt => mt.enabled)
.map(mt => ({
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 $widget in both branches.
this.$widget.show();
this.textEditor = await BalloonEditor.create(this.$editor[0], {
placeholder: "Type the content of your note here ...",
mention: mentionSetup,
codeBlock: {
languages: codeBlockLanguages
}
});
this.textEditor.model.document.on('change:data', () => this.spacedUpdate.scheduleUpdate());
if (glob.isDev && ENABLE_INSPECTOR) {
await import(/* webpackIgnore: true */'../../../libraries/ckeditor/inspector.js');
CKEditorInspector.attach(this.textEditor);
}
}
async doRefresh(note) {
const noteComplement = await treeCache.getNoteComplement(note.noteId);
await this.spacedUpdate.allowUpdateWithoutChange(() => {
this.textEditor.setData(noteComplement.content);
});
}
getContent() {
const 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
return utils.isHtmlEmpty(content) ? '' : content;
}
focus() {
this.$editor.trigger('focus');
}
show() {}
getEditor() {
return this.textEditor;
}
cleanup() {
if (this.textEditor) {
this.spacedUpdate.allowUpdateWithoutChange(() => {
this.textEditor.setData('');
});
}
}
insertDateTimeToTextCommand() {
const date = new Date();
const dateString = utils.formatDateTime(date);
this.addTextToEditor(dateString);
}
async addLinkToEditor(linkHref, linkTitle) {
await this.initialized;
this.textEditor.model.change(writer => {
const insertPosition = this.textEditor.model.document.selection.getFirstPosition();
writer.insertText(linkTitle, {linkHref: linkHref}, insertPosition);
});
}
async addTextToEditor(text) {
await this.initialized;
this.textEditor.model.change(writer => {
const insertPosition = this.textEditor.model.document.selection.getFirstPosition();
writer.insertText(text, insertPosition);
});
}
addTextToActiveEditorEvent(text) {
if (!this.isActive()) {
return;
}
this.addTextToEditor(text);
}
async addLink(notePath, linkTitle) {
await this.initialized;
if (linkTitle) {
if (this.hasSelection()) {
this.textEditor.execute('link', '#' + notePath);
} else {
await this.addLinkToEditor('#' + notePath, linkTitle);
}
}
else {
this.textEditor.execute('referenceLink', { notePath: notePath });
}
this.textEditor.editing.view.focus();
}
// returns true if user selected some text, false if there's no selection
hasSelection() {
const model = this.textEditor.model;
const selection = model.document.selection;
return !selection.isCollapsed;
}
async executeInActiveEditorEvent({callback}) {
if (!this.isActive()) {
return;
}
await this.initialized;
callback(this.textEditor);
}
addLinkToTextCommand() {
import("../../dialogs/add_link.js").then(d => d.showDialog(this));
}
addIncludeNoteToTextCommand() {
import("../../dialogs/include_note.js").then(d => d.showDialog(this));
}
addIncludeNote(noteId, boxSize) {
this.textEditor.model.change( writer => {
// Insert <includeNote>*</includeNote> at the current selection position
// in a way that will result in creating a valid model structure
this.textEditor.model.insertContent(writer.createElement('includeNote', {
noteId: noteId,
boxSize: boxSize
}));
} );
}
async addImage(noteId) {
const note = await treeCache.getNote(noteId);
this.textEditor.model.change( writer => {
const src = `api/images/${note.noteId}/${note.title}`;
const imageElement = writer.createElement( 'image', { 'src': src } );
this.textEditor.model.insertContent(imageElement, this.textEditor.model.document.selection);
} );
}
}

View File

@@ -0,0 +1,41 @@
import noteAutocompleteService from '../../services/note_autocomplete.js';
import TypeWidget from "./type_widget.js";
import appContext from "../../services/app_context.js";
const TPL = `
<div class="note-detail-empty note-detail-printable">
<div class="form-group">
<label>Open note by typing note's title into input below or choose a note in the tree.</label>
<div class="input-group">
<input class="form-control note-autocomplete" placeholder="search for note by its name">
</div>
</div>
</div>`;
export default class EmptyTypeWidget extends TypeWidget {
static getType() { return "empty"; }
doRender() {
// FIXME: this might be optimized - cleaned up after use since it's always used only for new tab
this.$widget = $(TPL);
this.$autoComplete = this.$widget.find(".note-autocomplete");
noteAutocompleteService.initNoteAutocomplete(this.$autoComplete, { hideGoToSelectedNoteButton: true })
.on('autocomplete:selected', function(event, suggestion, dataset) {
if (!suggestion.path) {
return false;
}
appContext.tabManager.getActiveTabContext().setNote(suggestion.path);
});
noteAutocompleteService.showRecentNotes(this.$autoComplete);
return this.$widget;
}
doRefresh(note) {
this.$autoComplete.trigger('focus');
}
}

View File

@@ -0,0 +1,133 @@
import utils from "../../services/utils.js";
import server from "../../services/server.js";
import toastService from "../../services/toast.js";
import TypeWidget from "./type_widget.js";
const TPL = `
<div class="note-detail-file note-detail-printable">
<table class="file-table">
<tr>
<th nowrap>Note ID:</th>
<td class="file-note-id"></td>
<th nowrap>Original file name:</th>
<td class="file-filename"></td>
</tr>
<tr>
<th nowrap>File type:</th>
<td class="file-filetype"></td>
<th nowrap>File size:</th>
<td class="file-filesize"></td>
</tr>
</table>
<pre class="file-preview-content"></pre>
<iframe class="pdf-preview" style="width: 100%; height: 100%; flex-grow: 100;"></iframe>
<div style="padding: 10px; display: flex; justify-content: space-evenly;">
<button class="file-download btn btn-sm btn-primary" type="button">Download</button>
&nbsp;
<button class="file-open btn btn-sm btn-primary" type="button">Open</button>
&nbsp;
<button class="file-upload-new-revision btn btn-sm btn-primary">Upload new revision</button>
<input type="file" class="file-upload-new-revision-input" style="display: none">
</div>
</div>`;
export default class FileTypeWidget extends TypeWidget {
static getType() { return "file"; }
doRender() {
this.$widget = $(TPL);
this.$fileNoteId = this.$widget.find(".file-note-id");
this.$fileName = this.$widget.find(".file-filename");
this.$fileType = this.$widget.find(".file-filetype");
this.$fileSize = this.$widget.find(".file-filesize");
this.$previewContent = this.$widget.find(".file-preview-content");
this.$pdfPreview = this.$widget.find(".pdf-preview");
this.$downloadButton = this.$widget.find(".file-download");
this.$openButton = this.$widget.find(".file-open");
this.$uploadNewRevisionButton = this.$widget.find(".file-upload-new-revision");
this.$uploadNewRevisionInput = this.$widget.find(".file-upload-new-revision-input");
this.$downloadButton.on('click', () => utils.download(this.getFileUrl()));
this.$openButton.on('click', () => {
if (utils.isElectron()) {
const open = utils.dynamicRequire("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.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.");
this.refresh();
}
else {
toastService.showError("Upload of a new file revision failed.");
}
});
return this.$widget;
}
async doRefresh(note) {
const attributes = note.getAttributes();
const attributeMap = utils.toObject(attributes, l => [l.name, l.value]);
this.$widget.show();
this.$fileNoteId.text(note.noteId);
this.$fileName.text(attributeMap.originalFileName || "?");
this.$fileSize.text(note.contentLength + " bytes");
this.$fileType.text(note.mime);
const noteComplement = await this.tabContext.getNoteComplement();
this.$previewContent.empty().hide();
this.$pdfPreview.attr('src', '').empty().hide();
if (noteComplement.content) {
this.$previewContent.show();
this.$previewContent.text(noteComplement.content);
}
else if (note.mime === 'application/pdf' && utils.isElectron()) {
this.$pdfPreview.show();
this.$pdfPreview.attr("src", utils.getUrlForDownload("api/notes/" + this.noteId + "/open"));
}
// open doesn't work for protected notes since it works through browser which isn't in protected session
this.$openButton.toggle(!note.isProtected);
}
getFileUrl() {
return utils.getUrlForDownload("api/notes/" + this.noteId + "/download");
}
}

View File

@@ -0,0 +1,142 @@
import utils from "../../services/utils.js";
import toastService from "../../services/toast.js";
import server from "../../services/server.js";
import TypeWidget from "./type_widget.js";
const TPL = `
<div class="note-detail-image note-detail-printable">
<div class="no-print" style="display: flex; justify-content: space-evenly; margin: 10px;">
<button class="image-download btn btn-sm btn-primary" type="button">Download</button>
<button class="image-copy-to-clipboard btn btn-sm btn-primary" type="button">Copy to clipboard</button>
<button class="image-upload-new-revision btn btn-sm btn-primary" type="button">Upload new revision</button>
</div>
<div class="note-detail-image-wrapper">
<img class="note-detail-image-view" />
</div>
<div style="display: flex; justify-content: space-evenly; margin: 10px;">
<span>
<strong>Original file name:</strong>
<span class="image-filename"></span>
</span>
<span>
<strong>File type:</strong>
<span class="image-filetype"></span>
</span>
<span>
<strong>File size:</strong>
<span class="image-filesize"></span>
</span>
</div>
<input type="file" class="image-upload-new-revision-input" style="display: none">
</div>`;
class ImageTypeWidget extends TypeWidget {
static getType() { return "image"; }
doRender() {
this.$widget = $(TPL);
this.$imageWrapper = this.$widget.find('.note-detail-image-wrapper');
this.$imageView = this.$widget.find('.note-detail-image-view');
this.$copyToClipboardButton = this.$widget.find(".image-copy-to-clipboard");
this.$uploadNewRevisionButton = this.$widget.find(".image-upload-new-revision");
this.$uploadNewRevisionInput = this.$widget.find(".image-upload-new-revision-input");
this.$fileName = this.$widget.find(".image-filename");
this.$fileType = this.$widget.find(".image-filetype");
this.$fileSize = this.$widget.find(".image-filesize");
this.$imageDownloadButton = this.$widget.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.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();
this.refresh();
}
else {
toastService.showError("Upload of a new image revision failed: " + result.message);
}
});
return this.$widget;
}
async doRefresh(note) {
const attributes = note.getAttributes();
const attributeMap = utils.toObject(attributes, l => [l.name, l.value]);
this.$widget.show();
this.$fileName.text(attributeMap.originalFileName || "?");
this.$fileSize.text(note.contentLength + " bytes");
this.$fileType.text(note.mime);
const imageHash = utils.randomString(10);
this.$imageView.prop("src", `api/images/${note.noteId}/${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.noteId}/download`);
}
}
export default ImageTypeWidget

View File

@@ -0,0 +1,42 @@
import protectedSessionService from '../../services/protected_session.js';
import TypeWidget from "./type_widget.js";
const TPL = `
<div class="protected-session-password-component note-detail-printable">
<style>
.protected-session-password-component {
width: 300px;
margin: 30px auto auto;
}
</style>
<form class="protected-session-password-form">
<div class="form-group">
<label for="protected-session-password-in-detail">Showing protected note requires entering your password:</label>
<input class="protected-session-password-in-detail form-control protected-session-password" type="password">
</div>
<button class="btn btn-primary">Start protected session <kbd>enter</kbd></button>
</form>
</div>`;
export default class ProtectedSessionTypeWidget extends TypeWidget {
static getType() { return "protected-session"; }
doRender() {
this.$widget = $(TPL);
this.$passwordForm = this.$widget.find(".protected-session-password-form");
this.$passwordInput = this.$widget.find(".protected-session-password");
this.$passwordForm.on('submit', () => {
const password = this.$passwordInput.val();
this.$passwordInput.val("");
protectedSessionService.setupProtectedSession(password);
return false;
});
return this.$widget;
}
}

View File

@@ -0,0 +1,80 @@
import treeCache from "../../services/tree_cache.js";
import AbstractTextTypeWidget from "./abstract_text_type_widget.js";
import treeService from "../../services/tree.js";
const TPL = `
<div class="note-detail-readonly-text note-detail-printable">
<style>
.note-detail-readonly-text h1 { font-size: 2.0em; }
.note-detail-readonly-text h2 { font-size: 1.8em; }
.note-detail-readonly-text h3 { font-size: 1.6em; }
.note-detail-readonly-text h4 { font-size: 1.4em; }
.note-detail-readonly-text h5 { font-size: 1.2em; }
.note-detail-readonly-text h6 { font-size: 1.1em; }
.note-detail-readonly-text {
overflow: auto;
height: 100%;
padding: 10px;
font-family: var(--detail-text-font-family);
}
.note-detail-readonly-text p:first-child, .note-detail-text::before {
margin-top: 0;
}
</style>
<div class="alert alert-warning no-print">
Read only text view is shown. <a href="#" class="edit-note">Click here</a> to edit the note.
</div>
<div class="note-detail-readonly-text-content ck-content"></div>
</div>
`;
export default class ReadOnlyTextTypeWidget extends AbstractTextTypeWidget {
static getType() { return "read-only-text"; }
doRender() {
this.$widget = $(TPL);
this.$content = this.$widget.find('.note-detail-readonly-text-content');
this.$widget.find('a.edit-note').on('click', () => {
this.tabContext.textPreviewDisabled = true;
this.triggerEvent('textPreviewDisabled', {tabContext: this.tabContext});
});
super.doRender();
return this.$widget;
}
cleanup() {
this.$content.html('');
}
scrollToTop() {
this.$content.scrollTop(0);
}
async doRefresh(note) {
const noteComplement = await treeCache.getNoteComplement(note.noteId);
this.$content.html(noteComplement.content);
this.$content.find("a.reference-link").each(async (_, el) => {
const notePath = $(el).attr('href');
const noteId = treeService.getNoteIdFromNotePath(notePath);
this.loadReferenceLinkTitle(noteId, $(el));
});
this.$content.find("section").each(async (_, el) => {
const noteId = $(el).attr('data-note-id');
this.loadIncludedNote(noteId, $(el));
});
}
}

View File

@@ -0,0 +1,635 @@
import server from "../../services/server.js";
import linkService from "../../services/link.js";
import libraryLoader from "../../services/library_loader.js";
import contextMenu from "../../services/context_menu.js";
import toastService from "../../services/toast.js";
import attributeAutocompleteService from "../../services/attribute_autocomplete.js";
import TypeWidget from "./type_widget.js";
import appContext from "../../services/app_context.js";
import utils from "../../services/utils.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
} ]
];
const TPL = `
<div class="note-detail-relation-map note-detail-printable">
<button class="relation-map-create-child-note btn btn-sm floating-button no-print" type="button"
title="Create new child note and add it into this relation map">
<span class="bx bx-folder-plus"></span>
Create child note
</button>
<button type="button"
class="relation-map-reset-pan-zoom btn icon-button floating-button bx bx-crop no-print"
title="Reset pan & zoom to initial coordinates and magnification"
style="right: 70px;"></button>
<div class="btn-group floating-button no-print" style="right: 10px;">
<button type="button"
class="relation-map-zoom-in btn icon-button bx bx-zoom-in"
title="Zoom In"></button>
<button type="button"
class="relation-map-zoom-out btn icon-button bx bx-zoom-out"
title="Zoom Out"></button>
</div>
<div class="relation-map-wrapper">
<div class="relation-map-container"></div>
</div>
</div>`;
let containerCounter = 1;
export default class RelationMapTypeWidget extends TypeWidget {
static getType() { return "relation-map"; }
doRender() {
this.$widget = $(TPL);
this.$relationMapContainer = this.$widget.find(".relation-map-container");
this.$createChildNote = this.$widget.find(".relation-map-create-child-note");
this.$zoomInButton = this.$widget.find(".relation-map-zoom-in");
this.$zoomOutButton = this.$widget.find(".relation-map-zoom-out");
this.$resetPanZoomButton = this.$widget.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 = this.$widget.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 => {
contextMenu.show({
x: e.pageX,
y: e.pageY,
items: [
{title: "Open in new tab", command: "openInNewTab", uiIcon: "empty"},
{title: "Remove note", command: "remove", uiIcon: "trash"},
{title: "Edit title", command: "editTitle", uiIcon: "pencil"},
],
selectMenuItemHandler: ({command}) => this.contextMenuHandler(command, e.target)
});
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.noteId}/children?target=into`, {
title,
content: '',
type: 'text'
});
toastService.showMessage("Click on canvas to place new note");
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.$widget.on("drop", ev => this.dropNoteOntoRelationMapHandler(ev));
this.$widget.on("dragover", ev => ev.preventDefault());
this.initialized = new Promise(async res => {
await libraryLoader.requireLibrary(libraryLoader.RELATION_MAP);
jsPlumb.ready(res);
});
return this.$widget;
}
async contextMenuHandler(command, originalTarget) {
const $noteBox = $(originalTarget).closest(".note-box");
const $title = $noteBox.find(".title a");
const noteId = this.idToNoteId($noteBox.prop("id"));
if (command === "openInNewTab") {
appContext.tabManager.openTabWithNote(noteId);
}
else if (command === "remove") {
const confirmDialog = await import('../../dialogs/confirm.js');
if (!await confirmDialog.confirmDeleteNoteBoxWithNote($title.text())) {
return;
}
this.jsPlumbInstance.remove(this.noteIdToId(noteId));
if (confirmDialog.isDeleteNoteChecked()) {
const taskId = utils.randomString(10);
await server.remove(`notes/${noteId}?taskId=${taskId}&last=true`);
}
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 (command === "editTitle") {
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 });
$title.text(title);
}
}
async 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
}
};
const noteComplement = await this.tabContext.getNoteComplement();
if (noteComplement.content) {
try {
this.mapData = JSON.parse(noteComplement.content);
} catch (e) {
console.log("Could not parse content: ", e);
}
}
}
noteIdToId(noteId) {
return "rel-map-note-" + noteId;
}
idToNoteId(id) {
return id.substr(13);
}
async doRefresh(note) {
await 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();
contextMenu.show({
x: event.pageX,
y: event.pageY,
items: [ {title: "Remove relation", command: "remove", uiIcon: "trash"} ],
selectMenuItemHandler: async ({command}) => {
if (command === '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}`);
this.loadNotesAndRelations();
}
saveData() {
this.spacedUpdate.scheduleUpdate();
}
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
});
}
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();
this.loadNotesAndRelations();
}
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);
}
}

View File

@@ -0,0 +1,49 @@
import renderService from "../../services/render.js";
import TypeWidget from "./type_widget.js";
const TPL = `
<div class="note-detail-render note-detail-printable">
<style>
.note-detail-render {
height: 100%;
}
</style>
<div class="note-detail-render-help alert alert-warning" style="margin: 50px; padding: 20px;">
<p><strong>This help note is shown because this note of type Render HTML doesn't have required relation to function properly.</strong></p>
<p>Render HTML note type is used for <a href="https://github.com/zadam/trilium/wiki/Scripts">scripting</a>. In short, you have a HTML code note (optionally with some JavaScript) and this note will render it. To make it work, you need to define a relation (in <a class="show-attributes-button">Attributes dialog</a>) called "renderNote" pointing to the HTML note to render. Once that's defined you can click on the "play" button to render.</p>
</div>
<div class="note-detail-render-content" style="height: 100%; overflow: auto;"></div>
</div>`;
export default class RenderTypeWidget extends TypeWidget {
static getType() { return "render"; }
doRender() {
this.$widget = $(TPL);
this.$noteDetailRenderHelp = this.$widget.find('.note-detail-render-help');
this.$noteDetailRenderContent = this.$widget.find('.note-detail-render-content');
this.$renderButton = this.$widget.find('.render-button');
this.$renderButton.on('click', () => this.refresh());
return this.$widget;
}
async doRefresh(note) {
this.$widget.show();
this.$noteDetailRenderHelp.hide();
const renderNotesFound = await renderService.render(note, this.$noteDetailRenderContent);
if (!renderNotesFound) {
this.$noteDetailRenderHelp.show();
}
}
cleanup() {
this.$noteDetailRenderContent.empty();
}
}

View File

@@ -0,0 +1,51 @@
import TypeWidget from "./type_widget.js";
const TPL = `
<div class="note-detail-search note-detail-printable">
<div style="display: flex; align-items: center; margin-right: 20px; margin-top: 15px;">
<strong>Search string: &nbsp; &nbsp;</strong>
<textarea rows="4" style="width: auto !important; flex-grow: 4" class="search-string form-control"></textarea>
</div>
<br />
<div class="note-detail-search-help"></div>
</div>`;
export default class SearchTypeWidget extends TypeWidget {
static getType() { return "search"; }
doRender() {
this.$widget = $(TPL);
this.$searchString = this.$widget.find(".search-string");
this.$component = this.$widget.find('.note-detail-search');
this.$help = this.$widget.find(".note-detail-search-help");
return this.$widget;
}
async doRefresh(note) {
this.$help.html(window.glob.SEARCH_HELP_TEXT);
this.$component.show();
try {
const noteComplement = await this.tabContext.getNoteComplement();
const json = JSON.parse(noteComplement.content);
this.$searchString.val(json.searchString);
}
catch (e) {
console.log(e);
this.$searchString.val('');
}
this.$searchString.on('input', () => this.spacedUpdate.scheduleUpdate());
}
getContent() {
return JSON.stringify({
searchString: this.$searchString.val()
});
}
}

View File

@@ -0,0 +1,51 @@
import TabAwareWidget from "../tab_aware_widget.js";
export default class TypeWidget extends TabAwareWidget {
// for overriding
static getType() {}
/**
* @param {NoteShort} note
*/
async doRefresh(note) {}
async refresh() {
const thisWidgetType = this.constructor.getType();
const noteWidgetType = await this.parent.getWidgetType();
if (thisWidgetType !== noteWidgetType) {
this.toggleInt(false);
this.cleanup();
}
else {
this.toggleInt(true);
await this.doRefresh(this.note);
}
}
isActive() {
return this.$widget.is(":visible");
}
getContent() {}
focus() {}
scrollToTop() {
this.$widget.scrollTop(0);
}
autoBookDisabledEvent({tabContext}) {
if (this.isTab(tabContext.tabId)) {
this.refresh();
}
}
textPreviewDisabledEvent({tabContext}) {
if (this.isTab(tabContext.tabId)) {
this.refresh();
}
}
}

View File

@@ -0,0 +1,56 @@
import CollapsibleWidget from "./collapsible_widget.js";
import linkService from "../services/link.js";
export default class WhatLinksHereWidget extends CollapsibleWidget {
get widgetTitle() { return "What links here"; }
get help() {
return {
title: "This list contains all notes which link to this note through links and relations."
};
}
get headerActions() {
const $showFullButton = $("<a>").append("show link map").addClass('widget-header-action');
$showFullButton.on('click', async () => {
const linkMapDialog = await import("../dialogs/link_map.js");
linkMapDialog.showDialog();
});
return [$showFullButton];
}
async refreshWithNote(note) {
const targetRelations = note.getTargetRelations();
if (targetRelations.length === 0) {
this.$body.text("Nothing links here yet ...");
return;
}
const $list = $("<ul>");
let i = 0;
for (; i < targetRelations.length && i < 50; i++) {
const rel = targetRelations[i];
const $item = $("<li>")
.append(await linkService.createNoteLink(rel.noteId))
.append($("<span>").text(" (" + rel.name + ")"));
$list.append($item);
}
if (i < targetRelations.length) {
$list.append($("<li>").text(`${targetRelations.length - i} more links ...`));
}
this.$body.empty().append($list);
}
entitiesReloadedEvent({loadResults}) {
if (loadResults.getAttributes().find(attr => attr.type === 'relation' && attr.value === this.noteId)) {
this.refresh();
}
}
}