Notes/src/public/app/components/note_context.ts

388 lines
12 KiB
TypeScript
Raw Normal View History

2022-12-01 13:07:23 +01:00
import protectedSessionHolder from "../services/protected_session_holder.js";
import server from "../services/server.js";
import utils from "../services/utils.js";
import appContext, { type EventData, type EventListener } from "./app_context.js";
2022-12-01 13:07:23 +01:00
import treeService from "../services/tree.js";
import Component from "./component.js";
import froca from "../services/froca.js";
import hoistedNoteService from "../services/hoisted_note.js";
import options from "../services/options.js";
import type { ViewScope } from "../services/link.js";
import type FNote from "../entities/fnote.js";
2025-02-07 20:27:22 +02:00
import type TypeWidget from "../widgets/type_widgets/type_widget.js";
2019-05-01 23:06:18 +02:00
interface SetNoteOpts {
triggerSwitchEvent?: unknown;
viewScope?: ViewScope;
}
export type GetTextEditorCallback = () => void;
2025-01-09 18:07:02 +02:00
class NoteContext extends Component implements EventListener<"entitiesReloaded"> {
ntxId: string | null;
hoistedNoteId: string;
2025-01-09 20:20:06 +02:00
mainNtxId: string | null;
notePath?: string | null;
2025-01-09 20:20:06 +02:00
noteId?: string | null;
2025-01-17 21:25:36 +02:00
parentNoteId?: string | null;
viewScope?: ViewScope;
2025-01-09 18:07:02 +02:00
constructor(ntxId: string | null = null, hoistedNoteId: string = "root", mainNtxId: string | null = null) {
2020-02-27 10:03:14 +01:00
super();
2020-01-15 21:36:01 +01:00
this.ntxId = ntxId || NoteContext.generateNtxId();
2020-11-22 23:05:02 +01:00
this.hoistedNoteId = hoistedNoteId;
2021-05-22 12:26:45 +02:00
this.mainNtxId = mainNtxId;
this.resetViewScope();
2019-05-02 22:24:43 +02:00
}
static generateNtxId() {
return utils.randomString(6);
}
2020-02-28 11:46:35 +01:00
setEmpty() {
this.notePath = null;
this.noteId = null;
this.parentNoteId = null;
// hoisted note is kept intentionally
2025-01-09 18:07:02 +02:00
this.triggerEvent("noteSwitched", {
2021-05-22 12:26:45 +02:00
noteContext: this,
2020-02-28 11:46:35 +01:00
notePath: this.notePath
});
this.resetViewScope();
2020-02-28 11:46:35 +01:00
}
isEmpty() {
return !this.noteId;
}
async setNote(inputNotePath: string | undefined, opts: SetNoteOpts = {}) {
opts.triggerSwitchEvent = opts.triggerSwitchEvent !== undefined ? opts.triggerSwitchEvent : true;
2023-04-11 17:45:51 +02:00
opts.viewScope = opts.viewScope || {};
opts.viewScope.viewMode = opts.viewScope.viewMode || "default";
if (!inputNotePath) {
return;
}
2021-01-29 22:44:59 +01:00
const resolvedNotePath = await this.getResolvedNotePath(inputNotePath);
2020-03-23 16:39:03 +01:00
2021-01-29 22:44:59 +01:00
if (!resolvedNotePath) {
return;
2020-02-02 22:04:28 +01:00
}
2023-04-11 17:45:51 +02:00
if (this.notePath === resolvedNotePath && utils.areObjectsEqual(this.viewScope, opts.viewScope)) {
return;
}
2025-01-09 18:07:02 +02:00
await this.triggerEvent("beforeNoteSwitch", { noteContext: this });
2020-02-03 21:56:45 +01:00
utils.closeActiveDialog();
2020-08-24 23:33:27 +02:00
this.notePath = resolvedNotePath;
2023-04-11 17:45:51 +02:00
this.viewScope = opts.viewScope;
2025-01-09 18:07:02 +02:00
({ noteId: this.noteId, parentNoteId: this.parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(resolvedNotePath));
2020-02-02 11:44:08 +01:00
2021-01-29 22:44:59 +01:00
this.saveToRecentNotes(resolvedNotePath);
2019-05-14 22:29:47 +02:00
protectedSessionHolder.touchProtectedSessionIfNecessary(this.note);
2020-01-19 11:03:34 +01:00
if (opts.triggerSwitchEvent) {
2025-01-09 18:07:02 +02:00
await this.triggerEvent("noteSwitched", {
2021-05-22 12:26:45 +02:00
noteContext: this,
2020-02-27 12:26:42 +01:00
notePath: this.notePath
});
}
2020-10-19 22:10:25 +02:00
await this.setHoistedNoteIfNeeded();
if (utils.isMobile()) {
2025-01-09 18:07:02 +02:00
this.triggerCommand("setActiveScreen", { screen: "detail" });
}
}
async setHoistedNoteIfNeeded() {
2025-01-09 18:07:02 +02:00
if (this.hoistedNoteId === "root" && this.notePath?.startsWith("root/_hidden") && !this.note?.isLabelTruthy("keepCurrentHoisting")) {
// hidden subtree displays only when hoisted, so it doesn't make sense to keep root as hoisted note
2025-01-09 18:07:02 +02:00
let hoistedNoteId = "_hidden";
if (this.note?.isLaunchBarConfig()) {
2025-01-09 18:07:02 +02:00
hoistedNoteId = "_lbRoot";
} else if (this.note?.isOptions()) {
2025-01-09 18:07:02 +02:00
hoistedNoteId = "_options";
}
await this.setHoistedNoteId(hoistedNoteId);
}
}
2021-05-22 13:04:08 +02:00
getSubContexts() {
2025-01-09 18:07:02 +02:00
return appContext.tabManager.noteContexts.filter((nc) => nc.ntxId === this.ntxId || nc.mainNtxId === this.ntxId);
2021-05-20 23:13:34 +02:00
}
/**
* A main context represents a tab and also the first split. Further splits are the children contexts of the main context.
* Imagine you have a tab with 3 splits, each showing notes A, B, C (in this order).
* In such a scenario, A context is the main context (also representing the tab as a whole), and B, C are the children
* of context A.
*
* @returns {boolean} true if the context is main (= tab)
*/
2021-05-24 22:29:49 +02:00
isMainContext() {
2023-05-05 23:41:11 +02:00
// if null, then this is a main context
2021-05-24 22:29:49 +02:00
return !this.mainNtxId;
}
/**
* See docs for isMainContext() for better explanation.
*
* @returns {NoteContext}
*/
2021-05-22 13:04:08 +02:00
getMainContext() {
2021-05-22 12:26:45 +02:00
if (this.mainNtxId) {
try {
return appContext.tabManager.getNoteContextById(this.mainNtxId);
2025-01-09 18:07:02 +02:00
} catch (e) {
this.mainNtxId = null;
return this;
}
2025-01-09 18:07:02 +02:00
} else {
2021-05-20 23:13:34 +02:00
return this;
}
}
saveToRecentNotes(resolvedNotePath: string) {
2021-01-29 22:44:59 +01:00
setTimeout(async () => {
2023-05-05 23:41:11 +02:00
// we include the note in the recent list only if the user stayed on the note at least 5 seconds
2021-01-29 22:44:59 +01:00
if (resolvedNotePath && resolvedNotePath === this.notePath) {
2025-01-09 18:07:02 +02:00
await server.post("recent-notes", {
noteId: this.note?.noteId,
2021-01-29 22:44:59 +01:00
notePath: this.notePath
});
utils.reloadTray();
2021-01-29 22:44:59 +01:00
}
}, 5000);
}
async getResolvedNotePath(inputNotePath: string) {
const resolvedNotePath = await treeService.resolveNotePath(inputNotePath, this.hoistedNoteId);
2021-01-29 22:44:59 +01:00
if (!resolvedNotePath) {
logError(`Cannot resolve note path ${inputNotePath}`);
return;
}
2025-01-09 18:07:02 +02:00
if ((await hoistedNoteService.checkNoteAccess(resolvedNotePath, this)) === false) {
2021-01-29 22:44:59 +01:00
return; // note is outside of hoisted subtree and user chose not to unhoist
}
return resolvedNotePath;
}
get note(): FNote | null {
2021-05-11 22:00:16 +02:00
if (!this.noteId || !(this.noteId in froca.notes)) {
return null;
2020-10-19 22:10:25 +02:00
}
2021-04-16 22:57:37 +02:00
return froca.notes[this.noteId];
2020-02-01 11:33:31 +01:00
}
/** @returns {string[]} */
get notePathArray() {
2025-01-09 18:07:02 +02:00
return this.notePath ? this.notePath.split("/") : [];
}
2020-01-21 21:43:23 +01:00
isActive() {
2021-05-22 13:04:08 +02:00
return appContext.tabManager.activeNtxId === this.ntxId;
2020-01-21 21:43:23 +01:00
}
2023-04-11 22:00:04 +02:00
getPojoState() {
2025-01-09 18:07:02 +02:00
if (this.hoistedNoteId !== "root") {
2022-12-13 16:57:46 +01:00
// keeping empty hoisted tab is esp. important for mobile (e.g. opened launcher config)
if (!this.notePath && this.getSubContexts().length === 0) {
return null;
}
2019-08-15 10:04:03 +02:00
}
return {
2021-05-22 12:26:45 +02:00
ntxId: this.ntxId,
mainNtxId: this.mainNtxId,
2019-08-15 10:04:03 +02:00
notePath: this.notePath,
2020-11-22 23:05:02 +01:00
hoistedNoteId: this.hoistedNoteId,
active: this.isActive(),
2023-04-03 23:47:24 +02:00
viewScope: this.viewScope
2025-01-09 18:07:02 +02:00
};
2019-08-15 10:04:03 +02:00
}
2020-11-22 23:05:02 +01:00
async unhoist() {
2025-01-09 18:07:02 +02:00
await this.setHoistedNoteId("root");
2020-11-22 23:05:02 +01:00
}
async setHoistedNoteId(noteIdToHoist: string) {
if (this.hoistedNoteId === noteIdToHoist) {
return;
}
this.hoistedNoteId = noteIdToHoist;
if (!this.notePathArray?.includes(noteIdToHoist)) {
await this.setNote(noteIdToHoist);
}
2025-01-09 18:07:02 +02:00
await this.triggerEvent("hoistedNoteChanged", {
2020-11-22 23:05:02 +01:00
noteId: noteIdToHoist,
2021-05-22 12:26:45 +02:00
ntxId: this.ntxId
2020-11-22 23:05:02 +01:00
});
}
2023-05-05 22:21:51 +02:00
/** @returns {Promise<boolean>} */
async isReadOnly() {
if (this?.viewScope?.readOnlyTemporarilyDisabled) {
return false;
}
// "readOnly" is a state valid only for text/code notes
2025-01-09 18:07:02 +02:00
if (!this.note || (this.note.type !== "text" && this.note.type !== "code")) {
return false;
}
2025-01-09 18:07:02 +02:00
if (this.note.isLabelTruthy("readOnly")) {
return true;
}
2025-01-09 18:07:02 +02:00
if (this.viewScope?.viewMode === "source") {
return true;
}
2023-05-05 22:21:51 +02:00
const blob = await this.note.getBlob();
if (!blob) {
return false;
}
2025-01-09 18:07:02 +02:00
const sizeLimit = this.note.type === "text" ? options.getInt("autoReadonlySizeText") : options.getInt("autoReadonlySizeCode");
2025-01-09 18:07:02 +02:00
return sizeLimit && blob.contentLength > sizeLimit && !this.note.isLabelTruthy("autoReadOnlyDisabled");
}
2025-01-09 18:07:02 +02:00
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (this.noteId && loadResults.isNoteReloaded(this.noteId)) {
2025-01-09 18:07:02 +02:00
const noteRow = loadResults.getEntityRow("notes", this.noteId);
2020-02-09 22:31:52 +01:00
if (noteRow.isDeleted) {
2020-02-09 22:31:52 +01:00
this.noteId = null;
this.notePath = null;
2025-01-09 18:07:02 +02:00
this.triggerEvent("noteSwitched", {
2021-05-22 12:26:45 +02:00
noteContext: this,
2020-02-09 22:31:52 +01:00
notePath: this.notePath
});
}
2020-01-24 17:54:47 +01:00
}
2019-08-15 10:04:03 +02:00
}
2022-01-07 19:33:59 +01:00
hasNoteList() {
2025-01-09 18:07:02 +02:00
return (
this.note &&
this.viewScope?.viewMode === "default" &&
this.note.hasChildren() &&
["book", "text", "code"].includes(this.note.type) &&
this.note.mime !== "text/x-sqlite;schema=trilium" &&
!this.note.isLabelTruthy("hideChildrenOverview")
);
2022-01-07 19:33:59 +01:00
}
async getTextEditor(callback?: GetTextEditorCallback) {
2025-01-09 18:07:02 +02:00
return this.timeout<TextEditor>(
new Promise((resolve) =>
appContext.triggerCommand("executeWithTextEditor", {
callback,
resolve,
ntxId: this.ntxId
})
)
);
}
async getCodeEditor() {
2025-01-09 18:07:02 +02:00
return this.timeout(
new Promise((resolve) =>
appContext.triggerCommand("executeWithCodeEditor", {
resolve,
ntxId: this.ntxId
})
)
);
2022-05-25 23:38:06 +02:00
}
/**
* Returns a promise which will retrieve the JQuery element of the content of this note context.
*
* Do note that retrieving the content element needs to be handled by the type widget, which is the one which
* provides the content element by listening to the `executeWithContentElement` event. Not all note types support
* this.
*
* If no content could be determined `null` is returned instead.
*/
2022-05-25 23:38:06 +02:00
async getContentElement() {
2025-01-09 18:07:02 +02:00
return this.timeout<JQuery<HTMLElement>>(
new Promise((resolve) =>
appContext.triggerCommand("executeWithContentElement", {
resolve,
ntxId: this.ntxId
})
)
);
}
2022-06-03 22:05:18 +02:00
async getTypeWidget() {
2025-01-09 18:07:02 +02:00
return this.timeout(
2025-02-07 20:27:22 +02:00
new Promise<TypeWidget | null>((resolve) =>
2025-01-09 18:07:02 +02:00
appContext.triggerCommand("executeWithTypeWidget", {
resolve,
ntxId: this.ntxId
})
)
);
}
2025-01-07 12:34:10 +02:00
timeout<T>(promise: Promise<T | null>) {
2025-01-09 18:07:02 +02:00
return Promise.race([promise, new Promise((res) => setTimeout(() => res(null), 200))]) as Promise<T>;
2022-06-03 22:05:18 +02:00
}
resetViewScope() {
// view scope contains data specific to one note context and one "view".
2023-05-05 23:41:11 +02:00
// it is used to e.g., make read-only note temporarily editable or to hide TOC
// this is reset after navigating to a different note
this.viewScope = {};
}
async getNavigationTitle() {
if (!this.note) {
return null;
}
const { note, viewScope } = this;
2025-01-09 18:07:02 +02:00
let title = viewScope?.viewMode === "default" ? note.title : `${note.title}: ${viewScope?.viewMode}`;
if (viewScope?.attachmentId) {
// assuming the attachment has been already loaded
const attachment = await note.getAttachmentById(viewScope.attachmentId);
if (attachment) {
title += `: ${attachment.title}`;
}
}
return title;
}
2019-05-01 22:19:29 +02:00
}
2021-05-22 12:26:45 +02:00
export default NoteContext;