mirror of
https://github.com/zadam/trilium.git
synced 2025-11-15 09:45:52 +01:00
fix setup of new document, closes #966
This commit is contained in:
94
src/public/app/widgets/attributes.js
Normal file
94
src/public/app/widgets/attributes.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
109
src/public/app/widgets/basic_widget.js
Normal file
109
src/public/app/widgets/basic_widget.js
Normal 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;
|
||||
164
src/public/app/widgets/calendar.js
Normal file
164
src/public/app/widgets/calendar.js
Normal 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];
|
||||
}
|
||||
}
|
||||
18
src/public/app/widgets/close_detail_button.js
Normal file
18
src/public/app/widgets/close_detail_button.js
Normal 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">×</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;
|
||||
101
src/public/app/widgets/collapsible_widget.js
Normal file
101
src/public/app/widgets/collapsible_widget.js
Normal 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");
|
||||
}
|
||||
}
|
||||
96
src/public/app/widgets/component.js
Normal file
96
src/public/app/widgets/component.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
158
src/public/app/widgets/desktop_layout.js
Normal file
158
src/public/app/widgets/desktop_layout.js
Normal 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())
|
||||
);
|
||||
}
|
||||
}
|
||||
53
src/public/app/widgets/edited_notes.js
Normal file
53
src/public/app/widgets/edited_notes.js
Normal 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);
|
||||
}
|
||||
}
|
||||
46
src/public/app/widgets/flex_container.js
Normal file
46
src/public/app/widgets/flex_container.js
Normal 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;
|
||||
}
|
||||
}
|
||||
42
src/public/app/widgets/global_buttons.js
Normal file
42
src/public/app/widgets/global_buttons.js
Normal 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;
|
||||
114
src/public/app/widgets/global_menu.js
Normal file
114
src/public/app/widgets/global_menu.js
Normal 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;
|
||||
}
|
||||
}
|
||||
104
src/public/app/widgets/history_navigation.js
Normal file
104
src/public/app/widgets/history_navigation.js
Normal 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();
|
||||
}
|
||||
}
|
||||
93
src/public/app/widgets/link_map.js
Normal file
93
src/public/app/widgets/link_map.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
46
src/public/app/widgets/mobile_detail_menu.js
Normal file
46
src/public/app/widgets/mobile_detail_menu.js
Normal 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;
|
||||
39
src/public/app/widgets/mobile_global_buttons.js
Normal file
39
src/public/app/widgets/mobile_global_buttons.js
Normal 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;
|
||||
88
src/public/app/widgets/mobile_layout.js
Normal file
88
src/public/app/widgets/mobile_layout.js
Normal 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')));
|
||||
}
|
||||
}
|
||||
15
src/public/app/widgets/mobile_screen_switcher.js
Normal file
15
src/public/app/widgets/mobile_screen_switcher.js
Normal 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'});
|
||||
}
|
||||
}
|
||||
68
src/public/app/widgets/note_actions.js
Normal file
68
src/public/app/widgets/note_actions.js
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
289
src/public/app/widgets/note_detail.js
Normal file
289
src/public/app/widgets/note_detail.js
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
82
src/public/app/widgets/note_info.js
Normal file
82
src/public/app/widgets/note_info.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
160
src/public/app/widgets/note_paths.js
Normal file
160
src/public/app/widgets/note_paths.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
80
src/public/app/widgets/note_revisions.js
Normal file
80
src/public/app/widgets/note_revisions.js
Normal 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;
|
||||
84
src/public/app/widgets/note_title.js
Normal file
84
src/public/app/widgets/note_title.js
Normal 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();
|
||||
}
|
||||
}
|
||||
818
src/public/app/widgets/note_tree.js
Normal file
818
src/public/app/widgets/note_tree.js
Normal 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> (<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> <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);
|
||||
}
|
||||
}
|
||||
150
src/public/app/widgets/note_type.js
Normal file
150
src/public/app/widgets/note_type.js
Normal 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">✓</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">✓</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();
|
||||
}
|
||||
}
|
||||
}
|
||||
269
src/public/app/widgets/promoted_attributes.js
Normal file
269
src/public/app/widgets/promoted_attributes.js
Normal 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(" ").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);
|
||||
}
|
||||
}
|
||||
42
src/public/app/widgets/protected_note_switch.js
Normal file
42
src/public/app/widgets/protected_note_switch.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/public/app/widgets/run_script_buttons.js
Normal file
26
src/public/app/widgets/run_script_buttons.js
Normal 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'));
|
||||
}
|
||||
}
|
||||
18
src/public/app/widgets/screen_container.js
Normal file
18
src/public/app/widgets/screen_container.js
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
177
src/public/app/widgets/search_box.js
Normal file
177
src/public/app/widgets/search_box.js
Normal 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*=*`);
|
||||
}
|
||||
}
|
||||
63
src/public/app/widgets/search_results.js
Normal file
63
src/public/app/widgets/search_results.js
Normal 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();
|
||||
}
|
||||
}
|
||||
25
src/public/app/widgets/side_pane_container.js
Normal file
25
src/public/app/widgets/side_pane_container.js
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
74
src/public/app/widgets/side_pane_toggles.js
Normal file
74
src/public/app/widgets/side_pane_toggles.js
Normal 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);
|
||||
}
|
||||
}
|
||||
69
src/public/app/widgets/similar_notes.js
Normal file
69
src/public/app/widgets/similar_notes.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
90
src/public/app/widgets/standard_top_widget.js
Normal file
90
src/public/app/widgets/standard_top_widget.js
Normal 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();
|
||||
}
|
||||
}
|
||||
102
src/public/app/widgets/tab_aware_widget.js
Normal file
102
src/public/app/widgets/tab_aware_widget.js
Normal 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();
|
||||
}
|
||||
}
|
||||
85
src/public/app/widgets/tab_caching_widget.js
Normal file
85
src/public/app/widgets/tab_caching_widget.js
Normal 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();
|
||||
}
|
||||
}
|
||||
637
src/public/app/widgets/tab_row.js
Normal file
637
src/public/app/widgets/tab_row.js
Normal file
File diff suppressed because one or more lines are too long
57
src/public/app/widgets/title_bar_buttons.js
Normal file
57
src/public/app/widgets/title_bar_buttons.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
244
src/public/app/widgets/type_widgets/book.js
Normal file
244
src/public/app/widgets/type_widgets/book.js
Normal 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();
|
||||
}
|
||||
}
|
||||
136
src/public/app/widgets/type_widgets/code.js
Normal file
136
src/public/app/widgets/type_widgets/code.js
Normal 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('');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/public/app/widgets/type_widgets/deleted.js
Normal file
20
src/public/app/widgets/type_widgets/deleted.js
Normal 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;
|
||||
}
|
||||
}
|
||||
262
src/public/app/widgets/type_widgets/editable_text.js
Normal file
262
src/public/app/widgets/type_widgets/editable_text.js
Normal 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> </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);
|
||||
} );
|
||||
}
|
||||
}
|
||||
41
src/public/app/widgets/type_widgets/empty.js
Normal file
41
src/public/app/widgets/type_widgets/empty.js
Normal 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');
|
||||
}
|
||||
}
|
||||
133
src/public/app/widgets/type_widgets/file.js
Normal file
133
src/public/app/widgets/type_widgets/file.js
Normal 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>
|
||||
|
||||
<button class="file-open btn btn-sm btn-primary" type="button">Open</button>
|
||||
|
||||
<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");
|
||||
}
|
||||
}
|
||||
142
src/public/app/widgets/type_widgets/image.js
Normal file
142
src/public/app/widgets/type_widgets/image.js
Normal 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
|
||||
42
src/public/app/widgets/type_widgets/protected_session.js
Normal file
42
src/public/app/widgets/type_widgets/protected_session.js
Normal 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;
|
||||
}
|
||||
}
|
||||
80
src/public/app/widgets/type_widgets/read_only_text.js
Normal file
80
src/public/app/widgets/type_widgets/read_only_text.js
Normal 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));
|
||||
});
|
||||
}
|
||||
}
|
||||
635
src/public/app/widgets/type_widgets/relation_map.js
Normal file
635
src/public/app/widgets/type_widgets/relation_map.js
Normal 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);
|
||||
}
|
||||
}
|
||||
49
src/public/app/widgets/type_widgets/render.js
Normal file
49
src/public/app/widgets/type_widgets/render.js
Normal 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();
|
||||
}
|
||||
}
|
||||
51
src/public/app/widgets/type_widgets/search.js
Normal file
51
src/public/app/widgets/type_widgets/search.js
Normal 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: </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()
|
||||
});
|
||||
}
|
||||
}
|
||||
51
src/public/app/widgets/type_widgets/type_widget.js
Normal file
51
src/public/app/widgets/type_widgets/type_widget.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
56
src/public/app/widgets/what_links_here.js
Normal file
56
src/public/app/widgets/what_links_here.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user