diff --git a/src/public/app/components/app_context.ts b/src/public/app/components/app_context.ts index a6f69af64..6e3f7d4c8 100644 --- a/src/public/app/components/app_context.ts +++ b/src/public/app/components/app_context.ts @@ -18,7 +18,6 @@ import type NoteDetailWidget from "../widgets/note_detail.js"; import type { ResolveOptions } from "../widgets/dialogs/delete_notes.js"; import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js"; import type { ConfirmWithMessageOptions, ConfirmWithTitleOptions } from "../widgets/dialogs/confirm.js"; -import type { Node } from "../services/tree.js"; import type LoadResults from "../services/load_results.js"; import type { Attribute } from "../services/attribute_parser.js"; import type NoteTreeWidget from "../widgets/note_tree.js"; @@ -49,15 +48,15 @@ export interface CommandData { * Represents a set of commands that are triggered from the context menu, providing information such as the selected note. */ export interface ContextMenuCommandData extends CommandData { - node: Node; - notePath: string; + node: Fancytree.FancytreeNode; + notePath?: string; noteId?: string; - selectedOrActiveBranchIds: any; // TODO: Remove any once type is defined + selectedOrActiveBranchIds?: any; // TODO: Remove any once type is defined selectedOrActiveNoteIds: any; // TODO: Remove any once type is defined } export interface NoteCommandData extends CommandData { - notePath: string; + notePath?: string; hoistedNoteId?: string; viewScope?: ViewScope; } @@ -72,6 +71,7 @@ export interface ExecuteCommandData extends CommandData { export type CommandMappings = { "api-log-messages": CommandData; focusTree: CommandData, + focusOnTitle: CommandData; focusOnDetail: CommandData; focusOnSearchDefinition: Required; searchNotes: CommandData & { @@ -79,6 +79,7 @@ export type CommandMappings = { ancestorNoteId?: string | null; }; closeTocCommand: CommandData; + closeHlt: CommandData; showLaunchBarSubtree: CommandData; showRevisions: CommandData; showOptions: CommandData & { @@ -106,13 +107,18 @@ export type CommandMappings = { showPromptDialog: PromptDialogOptions; showInfoDialog: ConfirmWithMessageOptions; showConfirmDialog: ConfirmWithMessageOptions; + showRecentChanges: CommandData & { ancestorNoteId: string }; + showImportDialog: CommandData & { noteId: string; }; openNewNoteSplit: NoteCommandData; openInWindow: NoteCommandData; openNoteInNewTab: CommandData; openNoteInNewSplit: CommandData; openNoteInNewWindow: CommandData; + openAboutDialog: CommandData; + hideFloatingButtons: {}; hideLeftPane: CommandData; showLeftPane: CommandData; + hoistNote: CommandData & { noteId: string }; leaveProtectedSession: CommandData; enterProtectedSession: CommandData; @@ -122,9 +128,12 @@ export type CommandMappings = { insertNoteAfter: ContextMenuCommandData; insertChildNote: ContextMenuCommandData; delete: ContextMenuCommandData; + editNoteTitle: ContextMenuCommandData; protectSubtree: ContextMenuCommandData; unprotectSubtree: ContextMenuCommandData; - openBulkActionsDialog: ContextMenuCommandData; + openBulkActionsDialog: ContextMenuCommandData | { + selectedOrActiveNoteIds?: string[] + }; editBranchPrefix: ContextMenuCommandData; convertNoteToAttachment: ContextMenuCommandData; duplicateSubtree: ContextMenuCommandData; @@ -143,6 +152,11 @@ export type CommandMappings = { importIntoNote: ContextMenuCommandData; exportNote: ContextMenuCommandData; searchInSubtree: ContextMenuCommandData; + moveNoteUp: ContextMenuCommandData; + moveNoteDown: ContextMenuCommandData; + moveNoteUpInHierarchy: ContextMenuCommandData; + moveNoteDownInHierarchy: ContextMenuCommandData; + selectAllNotesInParent: ContextMenuCommandData; addNoteLauncher: ContextMenuCommandData; addScriptLauncher: ContextMenuCommandData; @@ -175,6 +189,7 @@ export type CommandMappings = { importMarkdownInline: CommandData; showPasswordNotSet: CommandData; showProtectedSessionPasswordDialog: CommandData; + showUploadAttachmentsDialog: CommandData & { noteId: string }; closeProtectedSessionPasswordDialog: CommandData; copyImageReferenceToClipboard: CommandData; copyImageToClipboard: CommandData; @@ -198,6 +213,7 @@ export type CommandMappings = { screen: Screen; }; closeTab: CommandData; + closeToc: CommandData; closeOtherTabs: CommandData; closeRightTabs: CommandData; closeAllTabs: CommandData; @@ -216,15 +232,20 @@ export type CommandMappings = { scrollContainerToCommand: CommandData & { position: number; }; - moveThisNoteSplit: CommandData & { - isMovingLeft: boolean; - }; + scrollToEnd: CommandData; + closeThisNoteSplit: CommandData; + moveThisNoteSplit: CommandData & { isMovingLeft: boolean; }; // Geomap deleteFromMap: { noteId: string }, openGeoLocation: { noteId: string, event: JQuery.MouseDownEvent } toggleZenMode: CommandData; + + updateAttributeList: CommandData & { attributes: Attribute[] }; + saveAttributes: CommandData; + reloadAttributes: CommandData; + refreshNoteList: CommandData & { noteId: string; }; }; type EventMappings = { @@ -329,7 +350,6 @@ type EventMappings = { showToc: { noteId: string; }; - scrollToEnd: { ntxId: string }; noteTypeMimeChanged: { noteId: string }; zenModeChanged: { isEnabled: boolean }; }; diff --git a/src/public/app/components/component.ts b/src/public/app/components/component.ts index 0ab812bbf..416449fc7 100644 --- a/src/public/app/components/component.ts +++ b/src/public/app/components/component.ts @@ -80,8 +80,7 @@ export class TypedComponent> { return promises.length > 0 ? Promise.all(promises) : null; } - triggerCommand(name: string, _data?: CommandMappings[K]): Promise | undefined | null { - const data = _data || {}; + triggerCommand(name: K, data?: CommandMappings[K]): Promise | undefined | null { const fun = (this as any)[`${name}Command`]; if (fun) { diff --git a/src/public/app/components/note_context.ts b/src/public/app/components/note_context.ts index 6179ac57f..7cc8040b9 100644 --- a/src/public/app/components/note_context.ts +++ b/src/public/app/components/note_context.ts @@ -11,7 +11,7 @@ import type { ViewScope } from "../services/link.js"; import type FNote from "../entities/fnote.js"; import type TypeWidget from "../widgets/type_widgets/type_widget.js"; -interface SetNoteOpts { +export interface SetNoteOpts { triggerSwitchEvent?: unknown; viewScope?: ViewScope; } diff --git a/src/public/app/entities/fbranch.ts b/src/public/app/entities/fbranch.ts index f63401b2d..7a5cde4f8 100644 --- a/src/public/app/entities/fbranch.ts +++ b/src/public/app/entities/fbranch.ts @@ -8,6 +8,7 @@ export interface FBranchRow { prefix?: string; isExpanded?: boolean; fromSearchNote: boolean; + isDeleted?: boolean; } /** diff --git a/src/public/app/menus/launcher_context_menu.ts b/src/public/app/menus/launcher_context_menu.ts index 3c4fb68f3..3aa436632 100644 --- a/src/public/app/menus/launcher_context_menu.ts +++ b/src/public/app/menus/launcher_context_menu.ts @@ -1,4 +1,4 @@ -import treeService, { type Node } from "../services/tree.js"; +import treeService from "../services/tree.js"; import froca from "../services/froca.js"; import contextMenu, { type MenuCommandItem, type MenuItem } from "./context_menu.js"; import dialogService from "../services/dialog.js"; @@ -12,17 +12,17 @@ type LauncherCommandNames = FilteredCommandNames; export default class LauncherContextMenu implements SelectMenuItemEventListener { private treeWidget: NoteTreeWidget; - private node: Node; + private node: Fancytree.FancytreeNode; - constructor(treeWidget: NoteTreeWidget, node: Node) { + constructor(treeWidget: NoteTreeWidget, node: Fancytree.FancytreeNode) { this.treeWidget = treeWidget; this.node = node; } - async show(e: PointerEvent) { + async show(e: PointerEvent | JQuery.TouchStartEvent | JQuery.ContextMenuEvent) { contextMenu.show({ - x: e.pageX, - y: e.pageY, + x: e.pageX ?? 0, + y: e.pageY ?? 0, items: await this.getMenuItems(), selectMenuItemHandler: (item, e) => this.selectMenuItemHandler(item) }); diff --git a/src/public/app/menus/tree_context_menu.ts b/src/public/app/menus/tree_context_menu.ts index c765f9d0d..5c2c74fe9 100644 --- a/src/public/app/menus/tree_context_menu.ts +++ b/src/public/app/menus/tree_context_menu.ts @@ -1,4 +1,4 @@ -import treeService, { type Node } from "../services/tree.js"; +import treeService from "../services/tree.js"; import froca from "../services/froca.js"; import clipboard from "../services/clipboard.js"; import noteCreateService from "../services/note_create.js"; @@ -18,21 +18,23 @@ interface ConvertToAttachmentResponse { attachment?: FAttachment; } -type TreeCommandNames = FilteredCommandNames; +// This will include all commands that implement ContextMenuCommandData, but it will not work if it additional options are added via the `|` operator, +// so they need to be added manually. +export type TreeCommandNames = FilteredCommandNames | "openBulkActionsDialog"; export default class TreeContextMenu implements SelectMenuItemEventListener { private treeWidget: NoteTreeWidget; - private node: Node; + private node: Fancytree.FancytreeNode; - constructor(treeWidget: NoteTreeWidget, node: Node) { + constructor(treeWidget: NoteTreeWidget, node: Fancytree.FancytreeNode) { this.treeWidget = treeWidget; this.node = node; } - async show(e: PointerEvent) { + async show(e: PointerEvent | JQuery.TouchStartEvent | JQuery.ContextMenuEvent) { contextMenu.show({ - x: e.pageX, - y: e.pageY, + x: e.pageX ?? 0, + y: e.pageY ?? 0, items: await this.getMenuItems(), selectMenuItemHandler: (item, e) => this.selectMenuItemHandler(item) }); diff --git a/src/public/app/services/branches.ts b/src/public/app/services/branches.ts index 9c546b731..eab70ff19 100644 --- a/src/public/app/services/branches.ts +++ b/src/public/app/services/branches.ts @@ -6,7 +6,6 @@ import hoistedNoteService from "./hoisted_note.js"; import ws from "./ws.js"; import appContext from "../components/app_context.js"; import { t } from "./i18n.js"; -import type { Node } from "./tree.js"; import type { ResolveOptions } from "../widgets/dialogs/delete_notes.js"; // TODO: Deduplicate type with server @@ -160,7 +159,7 @@ async function activateParentNotePath() { } } -async function moveNodeUpInHierarchy(node: Node) { +async function moveNodeUpInHierarchy(node: Fancytree.FancytreeNode) { if (hoistedNoteService.isHoistedNode(node) || hoistedNoteService.isTopLevelNode(node) || node.getParent().data.noteType === "search") { return; } diff --git a/src/public/app/services/hoisted_note.ts b/src/public/app/services/hoisted_note.ts index 597de9467..f41f08e06 100644 --- a/src/public/app/services/hoisted_note.ts +++ b/src/public/app/services/hoisted_note.ts @@ -1,5 +1,5 @@ import appContext from "../components/app_context.js"; -import treeService, { type Node } from "./tree.js"; +import treeService from "./tree.js"; import dialogService from "./dialog.js"; import froca from "./froca.js"; import type NoteContext from "../components/note_context.js"; @@ -19,11 +19,11 @@ async function unhoist() { } } -function isTopLevelNode(node: Node) { +function isTopLevelNode(node: Fancytree.FancytreeNode) { return isHoistedNode(node.getParent()); } -function isHoistedNode(node: Node) { +function isHoistedNode(node: Fancytree.FancytreeNode) { // even though check for 'root' should not be necessary, we keep it just in case return node.data.noteId === "root" || node.data.noteId === getHoistedNoteId(); } diff --git a/src/public/app/services/import.ts b/src/public/app/services/import.ts index 9f06807f2..97cc40c94 100644 --- a/src/public/app/services/import.ts +++ b/src/public/app/services/import.ts @@ -5,7 +5,16 @@ import utils from "./utils.js"; import appContext from "../components/app_context.js"; import { t } from "./i18n.js"; -export async function uploadFiles(entityType: string, parentNoteId: string, files: string[], options: Record) { +interface UploadFilesOptions { + safeImport: boolean; + shrinkImages: boolean; + textImportedAsText: boolean; + codeImportedAsCode: boolean; + explodeArchives: boolean; + replaceUnderscoresWithSpaces: boolean; +} + +export async function uploadFiles(entityType: string, parentNoteId: string, files: string[], options: UploadFilesOptions) { if (!["notes", "attachments"].includes(entityType)) { throw new Error(`Unrecognized import entity type '${entityType}'.`); } @@ -26,7 +35,7 @@ export async function uploadFiles(entityType: string, parentNoteId: string, file formData.append("last", counter === files.length ? "true" : "false"); for (const key in options) { - formData.append(key, options[key]); + formData.append(key, (options as any)[key]); } await $.ajax({ diff --git a/src/public/app/services/load_results.ts b/src/public/app/services/load_results.ts index eabbcfc74..223364e83 100644 --- a/src/public/app/services/load_results.ts +++ b/src/public/app/services/load_results.ts @@ -8,11 +8,14 @@ interface NoteRow { isDeleted?: boolean; } -interface BranchRow { +// TODO: Deduplicate with BranchRow from `rows.ts`/ +export interface BranchRow { noteId?: string; branchId: string; componentId: string; parentNoteId?: string; + isDeleted?: boolean; + isExpanded?: boolean; } export interface AttributeRow { diff --git a/src/public/app/services/note_types.ts b/src/public/app/services/note_types.ts index fc6e7bed6..9c6af3b5c 100644 --- a/src/public/app/services/note_types.ts +++ b/src/public/app/services/note_types.ts @@ -2,12 +2,10 @@ import server from "./server.js"; import froca from "./froca.js"; import { t } from "./i18n.js"; import type { MenuItem } from "../menus/context_menu.js"; -import type { ContextMenuCommandData, FilteredCommandNames } from "../components/app_context.js"; +import type { TreeCommandNames } from "../menus/tree_context_menu.js"; -type NoteTypeCommandNames = FilteredCommandNames; - -async function getNoteTypeItems(command?: NoteTypeCommandNames) { - const items: MenuItem[] = [ +async function getNoteTypeItems(command?: TreeCommandNames) { + const items: MenuItem[] = [ { title: t("note_types.text"), command, type: "text", uiIcon: "bx bx-note" }, { title: t("note_types.code"), command, type: "code", uiIcon: "bx bx-code" }, { title: t("note_types.saved-search"), command, type: "search", uiIcon: "bx bx-file-find" }, diff --git a/src/public/app/services/tree.ts b/src/public/app/services/tree.ts index 0a56771a4..e7c94ca0e 100644 --- a/src/public/app/services/tree.ts +++ b/src/public/app/services/tree.ts @@ -4,20 +4,6 @@ import froca from "./froca.js"; import hoistedNoteService from "../services/hoisted_note.js"; import appContext from "../components/app_context.js"; -export interface Node { - title: string; - getParent(): Node; - getChildren(): Node[]; - folder: boolean; - renderTitle(): void; - data: { - noteId?: string; - isProtected?: boolean; - branchId: string; - noteType: string; - }; -} - /** * @returns {string|null} */ @@ -148,7 +134,7 @@ ws.subscribeToMessages((message) => { } }); -function getParentProtectedStatus(node: Node) { +function getParentProtectedStatus(node: Fancytree.FancytreeNode) { return hoistedNoteService.isHoistedNode(node) ? false : node.getParent().data.isProtected; } @@ -205,7 +191,7 @@ function getNoteIdAndParentIdFromUrl(urlOrNotePath: string) { }; } -function getNotePath(node: Node) { +function getNotePath(node: Fancytree.FancytreeNode) { if (!node) { logError("Node is null"); return ""; diff --git a/src/public/app/services/utils.ts b/src/public/app/services/utils.ts index f07f55258..3786d5771 100644 --- a/src/public/app/services/utils.ts +++ b/src/public/app/services/utils.ts @@ -137,7 +137,7 @@ function isCtrlKey(evt: KeyboardEvent | MouseEvent | JQuery.ClickEvent | JQuery. return (!isMac() && evt.ctrlKey) || (isMac() && evt.metaKey); } -function assertArguments(...args: string[]) { +function assertArguments(...args: T[]) { for (const i in args) { if (!args[i]) { console.trace(`Argument idx#${i} should not be falsy: ${args[i]}`); diff --git a/src/public/app/types-fancytree.d.ts b/src/public/app/types-fancytree.d.ts new file mode 100644 index 000000000..c016dad8a --- /dev/null +++ b/src/public/app/types-fancytree.d.ts @@ -0,0 +1,1165 @@ +/* + ! This is an altered copy of the @types/jquery.fancytree definition as of version 0.0.11. + + The reason for this is that upstream also brings the whole type definitions of JQuery UI + which conflicts with bootstrap's globals such as .tooltip. Due to this, we are removing the + reference to jquery UI and inject the globals ourselves. + */ + +//#region Changes +/// + +interface JQueryStatic { + ui: JQueryUI.UI; +}; + +declare namespace JQueryUI { + interface UI { + fancytree: Fancytree.FancytreeStatic; + } +} + +//#endregion + +//#region Same as upstream (0.0.11) +interface JQuery { + fancytree(options?: Fancytree.FancytreeOptions): Fancytree.Fancytree; + fancytree(option?: string, ...rest: any[]): any; +} + +declare namespace Fancytree { + interface Fancytree { + $div: JQuery; + widget: any; // JQueryUI.Widget; + rootNode: FancytreeNode; + $container: JQuery; + focusNode: FancytreeNode; + options: FancytreeOptions; + + /** Activate node with a given key and fire focus and + * activate events. A previously activated node will be + * deactivated. If activeVisible option is set, all parents + * will be expanded as necessary. Pass key = false, to deactivate + * the current node only. + * + * @returns {FancytreeNode} activate node (null, if not found) + */ + activateKey(key: string | boolean): FancytreeNode; + + /** (experimental) + * + * @returns resolved, when all patches have been applied + */ + applyPatch(patchList: NodePatch[]): JQueryPromise; + + /** [ext-clones] Replace a refKey with a new one. */ + changeRefKey(oldRefKey: string, newRefKey: string): void; + + /** [ext-persist] Remove persistence cookies of the given type(s). + * Called like $("#tree").fancytree("getTree").clearCookies("active expanded focus selected"); */ + clearCookies(): void; + + /** [ext-filter] Reset the filter. */ + clearFilter(): void; + + /** Return the number of nodes. */ + count(): number; + + /** Write to browser console if debugLevel >= 2 (prepending tree name) */ + debug(msg: any): void; + + /** Expand (or collapse) all parent nodes. */ + expandAll(flag?: boolean, options?: Object): void; + + /** [ext-filter] Dimm or hide whole branches. + * @returns {integer} count + */ + filterBranches(filter: string): number; + + /** [ext-filter] Dimm or hide whole branches. + * @returns {integer} count + */ + filterBranches(filter: (node: FancytreeNode) => boolean): number; + + /** [ext-filter] Dimm or hide nodes. + * @returns {integer} count + */ + filterNodes(filter: string, leavesOnly?: boolean): number; + + /** [ext-filter] Dimm or hide nodes. + * @returns {integer} count + */ + filterNodes(filter: (node: FancytreeNode) => boolean, leavesOnly?: boolean): number; + + /** Find the next visible node that starts with `match`, starting at `startNode` and wrap-around at the end. + * + * @returns matching node or null + */ + findNextNode(match: string, startNode?: FancytreeNode): FancytreeNode; + + /** Find the next visible node that starts with `match`, starting at `startNode` and wrap-around at the end. + * + * @returns matching node or null + */ + findNextNode(match: (node: FancytreeNode) => boolean, startNode?: FancytreeNode): FancytreeNode; + + /** Find all nodes that matches condition. + * + * @returns array of nodes (may be empty) + */ + findAll(match: string | ((node: FancytreeNode) => boolean | undefined)): FancytreeNode[]; + + /** Generate INPUT elements that can be submitted with html forms. In selectMode 3 only the topmost selected nodes are considered. */ + generateFormElements(selected?: boolean, active?: boolean): void; + + /** Return the currently active node or null. */ + getActiveNode(): FancytreeNode; + + /** Return the first top level node if any (not the invisible root node). */ + getFirstChild(): FancytreeNode; + + /** Return node that has keyboard focus. + * + * @param ifTreeHasFocus (default: false) (not yet implemented) + */ + getFocusNode(ifTreeHasFocus?: boolean): FancytreeNode; + + /** Return node with a given key or null if not found. + * + * @param searchRoot (optional) only search below this node. + */ + getNodeByKey(key: string, searchRoot?: FancytreeNode): FancytreeNode; + + /** [ext-clones] Return all nodes with a given refKey (null if not found). + * + * @param rootNode optionally restrict results to descendants of this node. + */ + getNodesByRef(refKey: string, rootNode?: FancytreeNode): FancytreeNode[]; + + /** [ext-persist] Return persistence information from cookies Called like $("#tree").fancytree("getTree").getPersistData(); */ + getPersistData(): PersistData; + + /** Return the invisible system root node. */ + getRootNode(): FancytreeNode; + + /** Return an array of selected nodes. + * + * @param stopOnParents only return the topmost selected node (useful with selectMode 3) + */ + getSelectedNodes(stopOnParents?: boolean): FancytreeNode[]; + + /** Return true if the tree control has keyboard focus */ + hasFocus(): boolean; + + /** Write to browser console if debugLevel >= 1 (prepending tree name) */ + info(msg: any): void; + + /** [ext-edit] Check if any node in this tree in edit mode. */ + isEditing(): FancytreeNode; + + /** Make sure that a node with a given ID is loaded, by traversing - and loading - its parents. This method is ment for lazy hierarchies. A callback is executed for every node as we go. + * + * @param keyPathList one or more key paths (e.g. '/3/2_1/7') + * @param callback callback(node, status) is called for every visited node ('loading', 'loaded', 'ok', 'error') + */ + loadKeyPath(keyPathList: string[], callback: (node: FancytreeNode, status: string) => void): JQueryPromise; + + /** Make sure that a node with a given ID is loaded, by traversing - and loading - its parents. This method is ment for lazy hierarchies. A callback is executed for every node as we go. + * + * @param keyPath a key path (e.g. '/3/2_1/7') + * @param callback callback(node, status) is called for every visited node ('loading', 'loaded', 'ok', 'error') + */ + loadKeyPath(keyPath: string, callback: (node: FancytreeNode, status: string) => void): JQueryPromise; + + /** Re-fire beforeActivate and activate events. */ + reactivate(): void; + + /** Reload tree from source and return a promise. + * + * @param source optional new source (defaults to initial source data) + */ + reload(source?: any): JQueryPromise; + + /** Render tree (i.e. create DOM elements for all top-level nodes). + * + * @param force create DOM elements, even is parent is collapsed (default = false) + * @param deep (default = false) + */ + render(force?: boolean, deep?: boolean): void; + + /** @param flag (default = true) */ + setFocus(flag?: boolean): void; + + /** Return all nodes as nested list of NodeData. + * + * @param callback Called for every node + * @param includeRoot Returns the hidden system root node (and its children) (default = false) + */ + toDict(includeRoot?: boolean, callback?: (node: FancytreeNode) => void): any; + + /** Call fn(node) for all nodes. + * + * @param fn the callback function. Return false to stop iteration, return "skip" to skip this node and children only. + * @returns false, if the iterator was stopped. + */ + visit(fn: (node: FancytreeNode) => any): boolean; + + /** Write warning to browser console (prepending tree info) */ + warn(msg: any): void; + + /** Temporarily suppress rendering to improve performance on bulk-updates. + * + * @param {boolean} flag + * @returns {boolean} previous status + * @since 2.19 */ + enableUpdate(enabled: boolean): void; + } + + /** A FancytreeNode represents the hierarchical data model and operations. */ + interface FancytreeNode { + // #region Properties + /** The tree instance */ + tree: Fancytree; + /** The parent node */ + parent: FancytreeNode; + /** Node id (must be unique inside the tree) */ + key: string; + /** Display name (may contain HTML) */ + title: string; + /** Contains all extra data that was passed on node creation */ + data: any; + /** Array of child nodes. For lazy nodes, null or undefined means 'not yet loaded'. Use an empty array to define a node that has no children. */ + children: FancytreeNode[]; + /** Use isExpanded(), setExpanded() to access this property. */ + expanded: boolean; + /** Addtional CSS classes, added to the node's ``. */ + extraClasses: string; + /** Folder nodes have different default icons and click behavior. Note: Also non-folders may have children. */ + folder: boolean; + /** Icon of the tree node. */ + icon: string; + /** null or type of temporarily generated system node like 'loading', or 'error'. */ + statusNodeType: string; + /** True if this node is loaded on demand, i.e. on first expansion. */ + lazy: boolean; + /** Alternative description used as hover banner */ + tooltip: string; + /** Outer element of single nodes */ + span: HTMLElement; + /** Outer element of single nodes for table extension */ + tr: HTMLTableRowElement; + unselectable?: boolean | undefined; + unselectableIgnore?: boolean | undefined; + unselectableStatus?: boolean | undefined; + + // #endregion + + // #region Methods + /** + * Append (or insert) a list of child nodes. + * + * @param children array of child node definitions (also single child accepted) + * @param insertBefore child node to insert nodes before. If omitted, the new children is appended. + * @returns The first child added. + */ + addChildren(children: Fancytree.NodeData[], insertBefore?: FancytreeNode): FancytreeNode; + /** + * Append (or insert) a list of child nodes. + * + * @param children array of child node definitions (also single child accepted) + * @param insertBefore key of the child node to insert nodes before. If omitted, the new children is appended. + * @returns The first child added. + */ + addChildren(children: Fancytree.NodeData[], insertBefore?: string): FancytreeNode; + /** + * Append (or insert) a list of child nodes. + * + * @param children array of child node definitions (also single child accepted) + * @param insertBefore index of the child node to insert nodes before. If omitted, the new children is appended. + * @returns The first child added. + */ + addChildren(children: Fancytree.NodeData[], insertBefore?: number): FancytreeNode; + /** + * Append (or insert) a single child node. + * + * @param child node to add + * @param insertBefore child node to insert this node before. If omitted, the new child is appended. + * @returns The child added. + */ + addChildren(child: Fancytree.NodeData, insertBefore?: FancytreeNode): FancytreeNode; + /** + * Append (or insert) a single child node. + * + * @param child node to add + * @param insertBefore key of the child node to insert this node before. If omitted, the new child is appended. + * @returns The child added. + */ + addChildren(child: Fancytree.NodeData, insertBefore?: string): FancytreeNode; + /** + * Append (or insert) a single child node. + * + * @param child node to add + * @param insertBefore index of the child node to insert this node before. If omitted, the new child is appended. + * @returns The child added. + */ + addChildren(child: Fancytree.NodeData, insertBefore?: number): FancytreeNode; + + /** Add class to node's span tag and to .extraClasses. + * @param className class name + */ + addClass(className: string): void; + + /** Append or prepend a node, or append a child node. This a convenience function that calls addChildren() + * + * @param mode 'before', 'after', 'firstChild', or 'child' ('over' is a synonym for 'child') (default='child') + * @returns new node. + */ + addNode(node: NodeData, mode?: string): FancytreeNode; + + /** Modify existing child nodes. */ + applyPatch(patch: NodePatch): JQueryPromise; + + /** Collapse all sibling nodes. */ + collapseSiblings(): JQueryPromise; + + /** Copy this node as sibling or child of `node`. + * + * @param node source node + * @param mode 'before' | 'after' | 'child' (default='child') + * @param map callback function(NodeData) that could modify the new node + * @returns new node. + */ + copyTo(node: FancytreeNode, mode?: string, map?: (node: NodeData) => void): FancytreeNode; + + /** Count direct and indirect children. + * + * @param deep pass 'false' to only count direct children. (default=true) + */ + countChildren(deep?: boolean): number; + + /** Write to browser console if debugLevel >= 2 (prepending node info) */ + debug(msg: any): void; + + /** [ext-edit] Create a new child or sibling node and start edit mode. + * + * @param mode 'before', 'after', or 'child' (default='child') + * @param init NodeData (or simple title string) + */ + editCreateNode(mode?: string, init?: Object): void; + + /** [ext-edit] Stop inline editing. + * + * @param applyChanges false: cancel edit, true: save (if modified) + */ + editEnd(applyChanges: boolean): void; + + /** [ext-edit] Start inline editing of current node title. */ + editStart(): void; + + /** Find all nodes that contain `match` in the title. + * + * @param match string to search for + */ + findAll(match: string): FancytreeNode[]; + + /** Find all nodes that contain `match` in the title. + * + * @param match a function that returns `true` if a node is matched. + */ + findAll(match: (node: FancytreeNode) => boolean): FancytreeNode[]; + + /** Find first node that contains `match` in the title (not including self). + * + * @param match string to search for + */ + findFirst(match: string): FancytreeNode; + + /** Find first node that contains `match` in the title (not including self). + * + * @param match a function that returns `true` if a node is matched. + */ + findFirst(match: (node: FancytreeNode) => boolean): FancytreeNode; + + /** Fix selection status, after this node was (de)selected in multi-hier mode. This includes (de)selecting all children. */ + fixSelection3AfterClick(): void; + + /** Fix selection status for multi-hier mode. Only end-nodes are considered to update the descendants branch and parents. Should be called after this node has loaded new children or after children have been modified using the API. */ + fixSelection3FromEndNodes(): void; + + /** Update node data. If dict contains 'children', then also replace the hole sub tree. */ + fromDict(dict: NodeData): void; + + /** Return the list of child nodes (undefined for unexpanded lazy nodes). */ + getChildren(): FancytreeNode[]; + + /** [ext-clones] Return a list of clone-nodes or null. */ + getCloneList(includeSelf?: boolean): FancytreeNode[]; + + /** Return the first child node or null. */ + getFirstChild(): FancytreeNode; + + /** Return the 0-based child index. */ + getIndex(): number; + + /** Return the hierarchical child index (1-based, e.g. '3.2.4'). */ + getIndexHier(): string; + + /** Return the parent keys separated by options.keyPathSeparator, e.g. "id_1/id_17/id_32". */ + getKeyPath(excludeSelf: boolean): string; + + /** Return the last child of this node or null. */ + getLastChild(): FancytreeNode; + + /** Return node depth. 0: System root node, 1: visible top-level node, 2: first sub-level, ... . */ + getLevel(): number; + + /** Return the successor node (under the same parent) or null. */ + getNextSibling(): FancytreeNode; + + /** Return the parent node (null for the system root node). */ + getParent(): FancytreeNode; + + /** Return an array of all parent nodes (top-down). + * + * @param includeRoot Include the invisible system root node. (default=false) + * @param includeSelf Include the node itself (default=false). + */ + getParentList(includeRoot: boolean, includeSelf: boolean): FancytreeNode[]; + + /** Return the predecessor node (under the same parent) or null. */ + getPrevSibling(): FancytreeNode; + + /** Return true if node has children. Return undefined if not sure, i.e. the node is lazy and not yet loaded). */ + hasChildren(): boolean; + + /** Return true if node has keyboard focus. */ + hasFocus(): boolean; + + /** Write to browser console if debugLevel >= 1 (prepending node info) */ + info(msg: string): void; + + /** Return true if node is active (see also FancytreeNode.isSelected). */ + isActive(): boolean; + + /** Return true if node is a direct child of otherNode. */ + isChildOf(otherNode: FancytreeNode): boolean; + + /** [ext-clones] Return true if this node has at least another clone with same refKey. */ + isClone(): boolean; + + /** Return true, if node is a direct or indirect sub node of otherNode. */ + isDescendantOf(otherNode: FancytreeNode): boolean; + + /** [ext-edit] Check if this node is in edit mode. */ + isEditing(): boolean; + + /** Return true if node is expanded. */ + isExpanded(): boolean; + + /** Return true if node is the first node of its parent's children. */ + isFirstSibling(): boolean; + + /** Return true if node is a folder, i.e. has the node.folder attribute set. */ + isFolder(): boolean; + + /** Return true if node is the last node of its parent's children. */ + isLastSibling(): boolean; + + /** Return true if node is lazy (even if data was already loaded) */ + isLazy(): boolean; + + /** Return true if node is lazy and loaded. For non-lazy nodes always return true. */ + isLoaded(): boolean; + + /**Return true if children are currently beeing loaded, i.e. a Ajax request is pending. */ + isLoading(): boolean; + + /** Return true if this is the (invisible) system root node. */ + isRootNode(): boolean; + + /** Return true if node is selected, i.e. has a checkmark set (see also FancytreeNode#isActive). */ + isSelected(): boolean; + + /** Return true if this node is a temporarily generated system node like 'loading', or 'error' (node.statusNodeType contains the type). */ + isStatusNode(): boolean; + + /** Return true if this a top level node, i.e. a direct child of the (invisible) system root node. */ + isTopLevel(): boolean; + + /** Return true if node is lazy and not yet loaded. For non-lazy nodes always return false. */ + isUndefined(): boolean; + + /** Return true if all parent nodes are expanded. Note: this does not check whether the node is scrolled into the visible part of the screen. */ + isVisible(): boolean; + + /** Load all children of a lazy node if neccessary. The *expanded* state is maintained. + * + * @param forceReload Pass true to discard any existing nodes before. + */ + load(forceReload?: boolean): JQueryPromise; + + /** Expand all parents and optionally scroll into visible area as neccessary. Promise is resolved, when lazy loading and animations are done. + * + * @param opts passed to `setExpanded()`. Defaults to {noAnimation: false, noEvents: false, scrollIntoView: true} + */ + makeVisible(opts?: Object): JQueryPromise; + + /** Move this node to targetNode. + * + * @param mode 'child': append this node as last child of targetNode. + * This is the default. To be compatble with the D'n'd + * hitMode, we also accept 'over'. + * 'before': add this node as sibling before targetNode. + * 'after': add this node as sibling after targetNode. + * + * @param map optional callback(FancytreeNode) to allow modifcations + */ + moveTo(targetNode: FancytreeNode, mode: string, map?: (node: FancytreeNode) => void): void; + + /** Set focus relative to this node and optionally activate. + * + * @param where The keyCode that would normally trigger this move, e.g. `$.ui.keyCode.LEFT` would collapse the node if it is expanded or move to the parent oterwise. + * @param activate (default=true) + */ + navigate(where: number, activate?: boolean): JQueryPromise; + + /** Remove this node (not allowed for system root). */ + remove(): void; + + /** Remove childNode from list of direct children. */ + removeChild(childNode: FancytreeNode): void; + + /** Remove all child nodes and descendents. This converts the node into a leaf. + * If this was a lazy node, it is still considered 'loaded'; call node.resetLazy() in order to trigger lazyLoad on next expand. + */ + removeChildren(): void; + + /** Remove class from node's span tag and .extraClasses. + * @param className class name + */ + removeClass(className: string): void; + + /** This method renders and updates all HTML markup that is required to display this node in its current state. + * + * @param force re-render, even if html markup was already created + * @param deep also render all descendants, even if parent is collapsed + */ + render(force?: boolean, deep?: boolean): void; + + /** Update element's CSS classes according to node state. */ + renderStatus(): void; + + /** Create HTML markup for the node's outer (expander, checkbox, icon, and title). */ + renderTitle(): void; + + /** [ext-clones] Update key and/or refKey for an existing node. */ + reRegister(key: string, refKey: string): boolean; + + /** Remove all children, collapse, and set the lazy-flag, so that the lazyLoad event is triggered on next expand. */ + resetLazy(): void; + + /** Schedule activity for delayed execution (cancel any pending request). scheduleAction('cancel') will only cancel a pending request (if any). */ + scheduleAction(mode: string, ms: number): void; + + /** + * @param effects animation options. + * @param options {topNode: null, effects: ..., parent: ...} this node will remain visible in any case, even if `this` is outside the scroll pane. + */ + scrollIntoView(effects?: boolean, options?: Object): JQueryPromise; + + /** + * @param effects animation options. + * @param options {topNode: null, effects: ..., parent: ...} this node will remain visible in any case, even if `this` is outside the scroll pane. + */ + scrollIntoView(effects?: Object, options?: Object): JQueryPromise; + + /** + * @param flag pass false to deactivate + * @param opts additional options. Defaults to {noEvents: false} + */ + setActive(flag?: boolean, opts?: Object): JQueryPromise; + + /** + * @param flag pass false to collapse. + * @param opts additional options. Defaults to {noAnimation:false, noEvents:false} + */ + setExpanded(flag?: boolean, opts?: Object): JQueryPromise; + + /** + * Set keyboard focus to this node. + * + * @param flag pass false to blur. + */ + setFocus(flag?: boolean): void; + + /** + * Select this node, i.e. check the checkbox. + * + * @param flag pass false to deselect. + */ + setSelected(flag?: boolean): void; + + /** + * Mark a lazy node as 'error', 'loading', or 'ok'. + * + * @param status 'error', 'ok' + */ + setStatus(status: string, message?: string, details?: string): void; + + /** Rename this node. */ + setTitle(title: string): void; + + /** + * Sort child list by title. + * + * @param cmp custom compare function(a, b) that returns -1, 0, or 1 (defaults to sort by title). + * @param deep pass true to sort all descendant nodes + */ + sortChildren(cmp?: (a: FancytreeNode, b: FancytreeNode) => number, deep?: boolean): void; + + /** + * Convert node (or whole branch) into a plain object. The result is compatible with node.addChildren(). + * + * @param recursive include child nodes. + * @param callback callback(dict) is called for every node, in order to allow modifications + */ + toDict(recursive?: boolean, callback?: (dict: NodeData) => void): NodeData; + + /** Set, clear, or toggle class of node's span tag and .extraClasses. + * @param {string} className class name (separate multiple classes by space) + * @param {boolean} [flag] true/false to add/remove class. If omitted, class is toggled. + * @return true if a class was added + */ + toggleClass(className: string, flag?: boolean): boolean; + + /** Flip expanded status. */ + toggleExpanded(): void; + + /** Flip selection status. */ + toggleSelected(): void; + + /** + * Call fn(node) for all child nodes. + * Stop iteration, if fn() returns false. Skip current branch, + * if fn() returns "skip". Return false if iteration was stopped. + * + * @param fn the callback function. Return false to stop iteration, return "skip" to skip this node and its children only. + * @param includeSelf (default=false) + */ + visit(fn: (node: FancytreeNode) => any, includeSelf?: boolean): boolean; + + /** + * Call fn(node) for all child nodes and recursively load lazy children. + * Note: If you need this method, you probably should consider to review your architecture! Recursivley loading nodes is + * a perfect way for lazy programmers to flood the server with requests ;-) + * + * @param fn the callback function. Return false to stop iteration, return "skip" to skip this node and its children only. + * @param includeSelf (default=false) + */ + visitAndLoad(fn: (node: FancytreeNode) => any, includeSelf?: boolean): JQueryPromise; + + /** + * Call fn(node) for all parent nodes, bottom-up, including invisible system root. + * Stop iteration, if fn() returns false. + * Return false if iteration was stopped. + * + * @param fn the callback function. Return false to stop iteration, return "skip" to skip this node and its children only. + * @param includeSelf (default=false) + */ + visitParents(fn: (node: FancytreeNode) => any, includeSelf?: boolean): boolean; + + /** + * Write warning to browser console (prepending node info) + */ + warn(msg: any): void; + // #endregion + } + + enum FancytreeClickFolderMode { + activate = 1, + expand = 2, + activate_and_expand = 3, + activate_dblclick_expands = 4, + } + + enum FancytreeSelectMode { + single = 1, + multi = 2, + mutlti_hier = 3, + } + + /** Context object passed to events and hook functions. */ + interface EventData { + /** The tree instance */ + tree: Fancytree; + /** The jQuery UI tree widget */ + widget: any; // JQueryUI.Widget; + /** Shortcut to tree.options */ + options: FancytreeOptions; + /** The jQuery Event that initially triggered this call */ + originalEvent: JQueryEventObject; + /** The node that this call applies to (`null` for tree events) */ + node: FancytreeNode; + /** (output parameter) Event handlers can return values back to the + * caller. Used by `lazyLoad`, `postProcess`, ... */ + result: any; + /** (only for click and dblclick events) 'title' | 'prefix' | 'expander' | 'checkbox' | 'icon' */ + targetType: string; + /** (only for postProcess event) Original ajax response */ + response: any; + } + + /** The `this` context of any event function is set to tree's the HTMLDivElement */ + interface FancytreeEvents { + /** 'data.node' was deactivated. */ + activate?(event: JQueryEventObject, data: EventData): void; + /** Return false to prevent default processing */ + beforeActivate?(event: JQueryEventObject, data: EventData): boolean; + /** Return `false` to prevent default processing */ + beforeExpand?(event: JQueryEventObject, data: EventData): boolean; + /** Return `false` to prevent default processing */ + beforeSelect?(event: JQueryEventObject, data: EventData): boolean; + /** `data.node` lost keyboard focus */ + blur?(event: JQueryEventObject, data: EventData): void; + /** `data.tree` lost keyboard focus */ + blurTree?(event: JQueryEventObject, data: EventData): void; + /** `data.node` was clicked. `data.targetType` contains the region ("title", "expander", ...). Return `false` to prevent default processing, i.e. activating, etc. */ + click?(event: JQueryEventObject, data: EventData): boolean; + /** `data.node` was collapsed */ + collapse?(event: JQueryEventObject, data: EventData): void; + /** Widget was created (called only once, even if re-initialized). */ + create?(event: JQueryEventObject, data: EventData): void; + /** Allow tweaking and binding, after node was created for the first time (NOTE: this event is only available as callback, but not for bind()) */ + createNode?(event: JQueryEventObject, data: EventData): void; + /** `data.node` was double-clicked. `data.targetType` contains the region ("title", "expander", ...). Return `false` to prevent default processing, i.e. expanding, etc. */ + dblclick?(event: JQueryEventObject, data: EventData): boolean; + /** `data.node` was deactivated */ + deactivate?(event: JQueryEventObject, data: EventData): void; + /** `data.node` was expanded */ + expand?(event: JQueryEventObject, data: EventData): void; + /** `data.node` received keyboard focus */ + focus?(event: JQueryEventObject, data: EventData): void; + /**`data.tree` received keyboard focus */ + focusTree?(event: JQueryEventObject, data: EventData): void; + /** Widget was (re-)initialized. */ + init?(event: JQueryEventObject, data: EventData): void; + /** `data.node` received key. `event.which` contains the key. Return `false` to prevent default processing, i.e. navigation. Call `data.result = "preventNav";` to prevent navigation but still allow default handling inside embedded input controls. */ + keydown?(event: JQueryEventObject, data: EventData): boolean; + /** (currently unused) */ + keypress?(event: JQueryEventObject, data: EventData): void; + /** `data.node` is a lazy node that is expanded for the first time. The new child data must be returned in the `data.result` property (see `source` option for available formats). */ + lazyLoad?(event: JQueryEventObject, data: EventData): void; + /** Node data was loaded, i.e. `node.nodeLoadChildren()` finished */ + loadChildren?(event: JQueryEventObject, data: EventData): void; + /** A load error occured. Return `false` to prevent default processing. */ + loadError?(event: JQueryEventObject, data: EventData): boolean; + /** Allows to modify the ajax response. */ + postProcess?(event: JQueryEventObject, data: EventData): void; + /** `data.node` was removed (NOTE: this event is only available as callback, but not for bind()) */ + removeNode?(event: JQueryEventObject, data: EventData): void; + /** (used by table extension) */ + renderColumns?(event: JQueryEventObject, data: EventData): void; + /** Allow tweaking after node state was rendered (NOTE: this event is only available as callback, but not for bind()) */ + renderNode?(event: JQueryEventObject, data: EventData): void; + /** Allow replacing the `` markup (NOTE: this event is only available as callback, but not for bind()) */ + renderTitle?(event: JQueryEventObject, data: EventData): void; + /** ext-persist has expanded, selected, and activated the previous state */ + restore?(event: JQueryEventObject, data: EventData): void; + /** `data.node` was selected */ + select?(event: JQueryEventObject, data: EventData): void; + /** Enable RTL version, default is false */ + rtl?: boolean | undefined; + } + + interface FancytreeOptions extends FancytreeEvents { + /** Make sure that the active node is always visible, i.e. its parents are expanded (default: true). */ + activeVisible?: boolean | undefined; + /** Default options for ajax requests. */ + ajax?: { + /** + * HTTP Method (default: 'GET') + */ + type: string; + /** + * false: Append random '_' argument to the request url to prevent caching. + */ + cache: boolean; + /** + * Default 'json' -> Expect json format and pass json object to callbacks. + */ + dataType: string; + } | undefined; + /** (default: false) Add WAI-ARIA attributes to markup */ + aria?: boolean | undefined; + /** Activate a node when focused with the keyboard (default: true) */ + autoActivate?: boolean | undefined; + /** Automatically collapse all siblings, when a node is expanded (default: false). */ + autoCollapse?: boolean | undefined; + /** Scroll node into visible area, when focused by keyboard (default: false). */ + autoScroll?: boolean | undefined; + /** Display checkboxes to allow selection (default: false) */ + checkbox?: boolean | string | ((event: JQueryEventObject, data: EventData) => boolean) | undefined; + /** Defines what happens, when the user click a folder node. (default: activate_dblclick_expands) */ + clickFolderMode?: FancytreeClickFolderMode | undefined; + /** 0..4 (null: use global setting $.ui.fancytree.debugInfo) */ + debugLevel?: 0 | 1 | 2 | 3 | 4 | undefined; + /** callback(node) is called for new nodes without a key. Must return a new unique key. (default null: generates default keys like that: "_" + counter) */ + defaultKey?: ((node: FancytreeNode) => string) | undefined; + /** Accept passing ajax data in a property named `d` (default: true). */ + enableAspx?: boolean | undefined; + /** Enable titles (default: false) */ + enableTitles?: boolean | undefined; + /** List of active extensions (default: []) */ + extensions?: Array | undefined; + /** Set focus when node is checked by a mouse click (default: false) */ + focusOnSelect?: boolean | undefined; + /** Add `id="..."` to node markup (default: true). */ + generateIds?: boolean | undefined; + /** Node icon url, if only filename, please use imagePath to set the path */ + icon?: boolean | string | undefined; + /** Prefix (default: "ft_") */ + idPrefix?: string | undefined; + /** Path to a folder containing icons (default: null, using 'skin/' subdirectory). */ + imagePath?: string | undefined; + /** Support keyboard navigation (default: true). */ + keyboard?: boolean | undefined; + /** (default: "/") */ + keyPathSeparator?: string | undefined; + /** 2: top-level nodes are not collapsible (default: 1) */ + minExpandLevel?: number | undefined; + /** navigate to next node by typing the first letters (default: false) */ + quicksearch?: boolean | undefined; + /** Right to left mode (default: false) */ + rtl?: boolean | undefined; + /** optional margins for node.scrollIntoView() (default: {top: 0, bottom: 0}) */ + scrollOfs?: { top: number; bottom: number } | undefined; + /** scrollable container for node.scrollIntoView() (default: $container) */ + scrollParent?: JQuery | null | undefined; + /** default: multi_hier */ + selectMode?: FancytreeSelectMode | undefined; + /** Used to Initialize the tree. */ + source?: any[] | any | undefined; + /** Translation table */ + strings?: TranslationTable | undefined; + /** Add tabindex='0' to container, so tree can be reached using TAB */ + tabbable?: boolean | undefined; + /** Add tabindex='0' to node title span, so it can receive keyboard focus */ + titlesTabbable?: boolean | undefined; + /** Animation options, false:off (default: { effect: "blind", options: {direction: "vertical", scale: "box"}, duration: 200 }) */ + toggleEffect?: any; + /** Tooltips */ + tooltip?: boolean | undefined; + + /** (dynamic Option)Prevent (de-)selection using mouse or keyboard. */ + unselectable?: + | boolean + | ((event: JQueryEventObject, data: Fancytree.EventData) => boolean | undefined) + | undefined; + /** (dynamic Option)Ignore this node when calculating the partsel status of parent nodes in selectMode 3 propagation. */ + unselectableIgnore?: + | boolean + | ((event: JQueryEventObject, data: Fancytree.EventData) => boolean | undefined) + | undefined; + /** (dynamic Option)Use this as constant selected value (overriding selectMode 3 propagation). */ + unselectableStatus?: + | boolean + | ((event: JQueryEventObject, data: Fancytree.EventData) => boolean | undefined) + | undefined; + + //////////////// + // EXTENSIONS // + //////////////// + dnd5?: Extensions.DragAndDrop5 | undefined; + filter?: Extensions.Filter | undefined; + table?: Extensions.Table | undefined; + + /** Options for misc extensions - see docs for typings */ + [extension: string]: any; + } + + interface TranslationTable { + /** + * "Loading..." // … would be escaped when escapeTitles is true + */ + loading?: string | undefined; + /** + * "Load error!" + */ + loadError?: string | undefined; + /** + * "More..." + */ + moreData?: string | undefined; + /** + * "No data." + */ + noData?: string | undefined; + } + + interface PersistData { + active: string | null; + expanded: string[]; + focus: string | null; + selected: string[]; + } + + namespace Extensions { + interface List { + dnd5?: DragAndDrop5 | undefined; + filter?: Filter | undefined; + table?: Table | undefined; + [extension: string]: any; + } + + interface DragAndDrop5 { + /** + * Expand nodes after n milliseconds of hovering. + */ + autoExpandMS?: number | undefined; + /** + * Absolute position offset for .fancytree-drop-marker + */ + dropMarkerOffsetX?: number | undefined; + /** + * Additional offset for drop-marker with hitMode = "before"/"after" + */ + dropMarkerInsertOffsetX?: number | undefined; + /** + * true: Drag multiple (i.e. selected) nodes. + */ + multiSource?: boolean | undefined; + /** + * Prevent dropping nodes from different Fancytrees + */ + preventForeignNodes?: boolean | undefined; + /** + * Prevent dropping items other than Fancytree nodes + */ + preventNonNodes?: boolean | undefined; + /** + * Prevent dropping nodes on own descendants + */ + preventRecursiveMoves?: boolean | undefined; + /** + * Prevent dropping nodes 'before self', etc. + */ + preventVoidMoves?: boolean | undefined; + /** + * Enable auto-scrolling while dragging + */ + scroll?: boolean | undefined; + /** + * Active top/bottom margin in pixel + */ + scrollSensitivity?: number | undefined; + /** + * Pixel per event + */ + scrollSpeed?: number | undefined; + /** + * Allow dragging of nodes to different IE windows, default: false + */ + setTextTypeJson?: boolean | undefined; + /** + * Callback(sourceNode, data), return true, to enable dnd drag + */ + dragStart?: ((sourceNode: FancytreeNode, data: any) => void) | undefined; + dragDrag?: ((sourceNode: FancytreeNode, data: any) => void) | undefined; + dragEnd?: ((sourceNode: FancytreeNode, data: any) => void) | undefined; + /** + * Callback(targetNode, data), return true, to enable dnd drop + */ + dragEnter?: ((targetNode: FancytreeNode, data: any) => void) | undefined; + /** + * Events (drag over) + */ + dragOver?: ((targetNode: FancytreeNode, data: any) => void) | undefined; + /** + * Callback(targetNode, data), return false to prevent autoExpand + */ + dragExpand?: ((targetNode: FancytreeNode, data: any) => void) | undefined; + /** + * Events (drag drop) + */ + dragDrop?: ((node: FancytreeNode, data: any) => void) | undefined; + dragLeave?: ((targetNode: FancytreeNode, data: any) => void) | undefined; + /** + * Support misc options + */ + [key: string]: any; + } + /** + * Define filter-extension options + */ + interface Filter { + /** + * Re-apply last filter if lazy data is loaded + */ + autoApply?: boolean | undefined; + /** + * Expand all branches that contain matches while filtered + */ + autoExpand?: boolean | undefined; + /** + * Show a badge with number of matching child nodes near parent icons + */ + counter?: boolean | undefined; + /** + * Match single characters in order, e.g. 'fb' will match 'FooBar' + */ + fuzzy?: boolean | undefined; + /** + * Hide counter badge if parent is expanded + */ + hideExpandedCounter?: boolean | undefined; + /** + * Hide expanders if all child nodes are hidden by filter + */ + hideExpanders?: boolean | undefined; + /** + * Highlight matches by wrapping inside tags + */ + highlight?: boolean | undefined; + /** + * Match end nodes only + */ + leavesOnly?: boolean | undefined; + /** + * Display a 'no data' status node if result is empty + */ + nodata?: boolean | undefined; + /** + * Grayout unmatched nodes (pass "hide" to remove unmatched node instead); default 'dimm' + */ + mode?: "dimm" | "hide" | undefined; + /** + * Support misc options + */ + [key: string]: any; + } + /** + * Define table-extension options + */ + interface Table { + /** + * Render the checkboxes into the this column index (default: nodeColumnIdx) + */ + checkboxColumnIdx: any; + /** + * Indent every node level by 16px; default: 16 + */ + indentation: number; + /** + * Render node expander, icon, and title to this column (default: 0) + */ + nodeColumnIdx: number; + /** + * Support misc options + */ + [key: string]: any; + } + } + + /** Data object passed to FancytreeNode() constructor. Note: typically these attributes are accessed by meber methods, e.g. `node.isExpanded()` and `node.setSelected(false)`. */ + interface NodeData { + /** node text (may contain HTML tags) */ + title: string; + icon?: boolean | string | undefined; + /** unique key for this node (auto-generated if omitted) */ + key?: string | undefined; + /** (reserved) */ + refKey?: string | undefined; + expanded?: boolean | undefined; + /** (initialization only, but will not be stored with the node). */ + active?: boolean | undefined; + /** (initialization only, but will not be stored with the node). */ + focus?: boolean | undefined; + folder?: boolean | undefined; + hideCheckbox?: boolean | undefined; + lazy?: boolean | undefined; + selected?: boolean | undefined; + unselectable?: boolean | undefined; + /** optional array of child nodes */ + children?: NodeData[] | undefined; + tooltip?: string | undefined; + /** class names added to the node markup (separate with space) */ + extraClasses?: string | undefined; + /** all properties from will be copied to `node.data` */ + data?: Object | undefined; + + /** Will be added as title attribute of the node's icon span,thus enabling a tooltip. */ + iconTooltip?: string | undefined; + + /** If set, make this node a status node. Values: 'error', 'loading', 'nodata', 'paging'. */ + statusNodeType?: string | undefined; + + /** Made available as node.type. */ + type?: string | undefined; + + /** Ignore this node when calculating the partsel status of parent nodes in selectMode 3 propagation. */ + unselectableIgnore?: boolean | undefined; + + /** Use this as constant selected value(overriding selectMode 3 propagation). */ + unselectableStatus?: boolean | undefined; + } + + /** Data object similar to NodeData, but with additional options. + * May be passed to FancytreeNode#applyPatch (Every property that is omitted (or set to undefined) will be ignored) */ + interface NodePatch { + /** (not yet implemented) */ + appendChildren?: NodeData | undefined; + /** (not yet implemented) */ + replaceChildren?: NodeData | undefined; + /** (not yet implemented) */ + insertChildren?: NodeData | undefined; + } + + /** May be passed to Fancytree#applyPatch. */ + interface TreePatch { + [key: string]: NodePatch; + } + + interface FancytreeStatic { + buildType: string; + debugLevel: number; + version: string; + + /** Throw an error if condition fails (debug method). */ + assert(cond: boolean, msg: string): void; + + /** Return a function that executes *fn* at most every *timeout* ms. */ + debounce void>(timeout: number, fn: T, invokeAsap?: boolean, ctx?: any): T; + + debug(msg: string): void; + + error(msg: string): void; + + escapeHtml(s: string): string; + + getEventTarget(event: Event): Object; + + getEventTargetType(event: Event): string; + + getNode(el: JQuery): FancytreeNode; + getNode(el: Event): FancytreeNode; + getNode(el: Element): FancytreeNode; + + getTree(el: Element | JQuery | Event | number | string): Fancytree; + + info(msg: string): void; + + /** Convert a keydown event to a string like 'ctrl+a', 'ctrl+shift+f2'. */ + keyEventToString(event: Event): string; + + /** Parse tree data from HTML markup */ + parseHtml($ul: JQuery): NodeData[]; + + /** Add Fancytree extension definition to the list of globally available extensions. */ + registerExtension(definition: Object): void; + + unescapeHtml(s: string): string; + + warn(msg: string): void; + } +} +//#endregion diff --git a/src/public/app/types.d.ts b/src/public/app/types.d.ts index 89454eb46..1ef429650 100644 --- a/src/public/app/types.d.ts +++ b/src/public/app/types.d.ts @@ -107,7 +107,7 @@ declare global { } } - var logError: (message: string, e?: Error) => void; + var logError: (message: string, e?: Error | string) => void; var logInfo: (message: string) => void; var glob: CustomGlobals; var require: RequireMethod; diff --git a/src/public/app/widgets/note_context_aware_widget.ts b/src/public/app/widgets/note_context_aware_widget.ts index 9f9d2448b..3c1153c2f 100644 --- a/src/public/app/widgets/note_context_aware_widget.ts +++ b/src/public/app/widgets/note_context_aware_widget.ts @@ -125,7 +125,7 @@ class NoteContextAwareWidget extends BasicWidget { } } - async frocaReloadedEvent() { + async frocaReloadedEvent(): Promise { await this.refresh(); } } diff --git a/src/public/app/widgets/note_tree.js b/src/public/app/widgets/note_tree.ts similarity index 81% rename from src/public/app/widgets/note_tree.js rename to src/public/app/widgets/note_tree.ts index be896780f..93016edf8 100644 --- a/src/public/app/widgets/note_tree.js +++ b/src/public/app/widgets/note_tree.ts @@ -9,7 +9,7 @@ import NoteContextAwareWidget from "./note_context_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 "../components/app_context.js"; +import appContext, { type CommandListenerData, type EventData } from "../components/app_context.js"; import keyboardActionsService from "../services/keyboard_actions.js"; import clipboard from "../services/clipboard.js"; import protectedSessionService from "../services/protected_session.js"; @@ -19,6 +19,12 @@ import protectedSessionHolder from "../services/protected_session_holder.js"; import dialogService from "../services/dialog.js"; import shortcutService from "../services/shortcuts.js"; import { t } from "../services/i18n.js"; +import type FBranch from "../entities/fbranch.js"; +import type LoadResults from "../services/load_results.js"; +import type FNote from "../entities/fnote.js"; +import type { NoteType } from "../entities/fnote.js"; +import type { AttributeRow, BranchRow } from "../services/load_results.js"; +import type { SetNoteOpts } from "../components/note_context.js"; const TPL = `
@@ -139,9 +145,54 @@ const TPL = ` const MAX_SEARCH_RESULTS_IN_TREE = 100; // this has to be hanged on the actual elements to effectively intercept and stop click event -const cancelClickPropagation = (e) => e.stopPropagation(); +const cancelClickPropagation: JQuery.TypeEventHandler = (e) => e.stopPropagation(); + +// TODO: Fix once we remove Node.js API from public +type Timeout = NodeJS.Timeout | string | number | undefined; + +// TODO: Deduplicate with server special_notes +type LauncherType = "launcher" | "note" | "script" | "customWidget" | "spacer"; + +// TODO: Deduplicate with the server +interface CreateLauncherResponse { + success: boolean; + message: string; + note: { + noteId: string; + } +} + +interface ExpandedSubtreeResponse { + branchIds: string[] +} + +interface Node extends Fancytree.NodeData { + noteId: string; + parentNoteId: string; + branchId: string; + isProtected: boolean; + noteType: NoteType; +} + +interface RefreshContext { + noteIdsToUpdate: Set; + noteIdsToReload: Set; +} export default class NoteTreeWidget extends NoteContextAwareWidget { + + private $tree!: JQuery; + private $treeActions!: JQuery; + private $treeSettingsButton!: JQuery; + private $treeSettingsPopup!: JQuery; + private $saveTreeSettingsButton!: JQuery; + private $hideArchivedNotesCheckbox!: JQuery; + private $autoCollapseNoteTree!: JQuery; + private treeName: "main"; + private autoCollapseTimeoutId?: Timeout; + private lastFilteredHoistedNotePath?: string | null; + private tree!: Fancytree.Fancytree; + constructor() { super(); @@ -156,7 +207,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { this.$tree.on("mousedown", ".unhoist-button", () => hoistedNoteService.unhoist()); this.$tree.on("mousedown", ".refresh-search-button", (e) => this.refreshSearch(e)); this.$tree.on("mousedown", ".add-note-button", (e) => { - const node = $.ui.fancytree.getNode(e); + const node = $.ui.fancytree.getNode(e as unknown as Event); const parentNotePath = treeService.getNotePath(node); noteCreateService.createNote(parentNotePath, { @@ -165,7 +216,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { }); this.$tree.on("mousedown", ".enter-workspace-button", (e) => { - const node = $.ui.fancytree.getNode(e); + const node = $.ui.fancytree.getNode(e as unknown as Event); this.triggerCommand("hoistNote", { noteId: node.data.noteId }); }); @@ -173,7 +224,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { // fancytree doesn't support middle click, so this is a way to support it this.$tree.on("mousedown", ".fancytree-title", (e) => { if (e.which === 2) { - const node = $.ui.fancytree.getNode(e); + const node = $.ui.fancytree.getNode(e as unknown as Event); const notePath = treeService.getNotePath(node); @@ -200,8 +251,8 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { this.$hideArchivedNotesCheckbox.prop("checked", this.hideArchivedNotes); this.$autoCollapseNoteTree.prop("checked", this.autoCollapseNoteTree); - const top = this.$treeActions[0].offsetTop - this.$treeSettingsPopup.outerHeight(); - const left = Math.max(0, this.$treeActions[0].offsetLeft - this.$treeSettingsPopup.outerWidth() + this.$treeActions.outerWidth()); + const top = this.$treeActions[0].offsetTop - (this.$treeSettingsPopup.outerHeight() ?? 0); + const left = Math.max(0, this.$treeActions[0].offsetLeft - (this.$treeSettingsPopup.outerWidth() ?? 0) + (this.$treeActions.outerWidth() ?? 0)); this.$treeSettingsPopup .css({ @@ -241,16 +292,19 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { // see https://github.com/zadam/trilium/pull/1120 for discussion // code inspired by https://gist.github.com/jtsternberg/c272d7de5b967cec2d3d - const isEnclosing = ($container, $sub) => { + const isEnclosing = ($container: JQuery, $sub: JQuery) => { const conOffset = $container.offset(); - const conDistanceFromTop = conOffset.top + $container.outerHeight(true); - const conDistanceFromLeft = conOffset.left + $container.outerWidth(true); + const conDistanceFromTop = (conOffset?.top ?? 0) + ($container.outerHeight(true) ?? 0); + const conDistanceFromLeft = (conOffset?.left ?? 0) + ($container.outerWidth(true) ?? 0); const subOffset = $sub.offset(); - const subDistanceFromTop = subOffset.top + $sub.outerHeight(true); - const subDistanceFromLeft = subOffset.left + $sub.outerWidth(true); + const subDistanceFromTop = (subOffset?.top ?? 0) + ($sub.outerHeight(true) ?? 0); + const subDistanceFromLeft = (subOffset?.left ?? 0) + ($sub.outerWidth(true) ?? 0); - return conDistanceFromTop > subDistanceFromTop && conOffset.top < subOffset.top && conDistanceFromLeft > subDistanceFromLeft && conOffset.left < subOffset.left; + return conDistanceFromTop > subDistanceFromTop + && (conOffset?.top ?? 0) < (subOffset?.top ?? 0) + && conDistanceFromLeft > subDistanceFromLeft + && (conOffset?.left ?? 0) < (subOffset?.left ?? 0); }; this.$tree.on("mouseenter", "span.fancytree-title", (e) => { @@ -262,7 +316,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { return options.is(`hideArchivedNotes_${this.treeName}`); } - async setHideArchivedNotes(val) { + async setHideArchivedNotes(val: string) { await options.save(`hideArchivedNotes_${this.treeName}`, val.toString()); } @@ -270,7 +324,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { return options.is("autoCollapseNoteTree"); } - async setAutoCollapseNoteTree(val) { + async setAutoCollapseNoteTree(val: string) { await options.save("autoCollapseNoteTree", val.toString()); } @@ -288,7 +342,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { }, scrollParent: this.$tree, minExpandLevel: 2, // root can't be collapsed - click: (event, data) => { + click: (event, data): boolean => { this.activityDetected(); const targetType = data.targetType; @@ -305,12 +359,12 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { const activeNode = this.getActiveNode(); if (activeNode.getParent() !== node.getParent()) { - return; + return true; } this.clearSelectedNodes(); - function selectInBetween(first, second) { + function selectInBetween(first: Fancytree.FancytreeNode, second: Fancytree.FancytreeNode) { for (let i = 0; first && first !== second && i < 10000; i++) { first.setSelected(true); first = first.getNextSibling(); @@ -334,13 +388,15 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { node.setFocus(true); } 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); + this.tree.reactivate(); } else { node.setActive(); } return false; } + + return true; }, beforeActivate: (event, { node }) => { // hidden subtree is hidden hackily - we want it to be present in the tree so that we can switch to it @@ -368,8 +424,8 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { const notePath = treeService.getNotePath(data.node); const activeNoteContext = appContext.tabManager.getActiveContext(); - const opts = {}; - if (activeNoteContext.viewScope.viewMode === "contextual-help") { + const opts: SetNoteOpts = {}; + if (activeNoteContext.viewScope?.viewMode === "contextual-help") { opts.viewScope = activeNoteContext.viewScope; } await activeNoteContext.setNote(notePath, opts); @@ -450,7 +506,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { }); } else { const jsonStr = dataTransfer.getData("text"); - let notes = null; + let notes: BranchRow[]; try { notes = JSON.parse(jsonStr); @@ -462,7 +518,9 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { // This function MUST be defined to enable dropping of items on the tree. // data.hitMode is 'before', 'after', or 'over'. - const selectedBranchIds = notes.map((note) => note.branchId); + const selectedBranchIds = notes + .map((note) => note.branchId) + .filter((branchId) => branchId) as string[]; if (data.hitMode === "before") { branchService.moveBeforeBranch(selectedBranchIds, node.data.branchId); @@ -513,7 +571,10 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { clones: { highlightActiveClones: true }, - enhanceTitle: async function (event, data) { + enhanceTitle: async function (event: Event, data: { + node: Fancytree.FancytreeNode; + noteId: string; + }) { const node = data.node; if (!node.data.noteId) { @@ -602,7 +663,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { const isMobile = utils.isMobile(); if (isMobile) { - let showTimeout; + let showTimeout: Timeout; this.$tree.on("touchstart", ".fancytree-node", (e) => { touchStart = new Date().getTime(); @@ -642,8 +703,8 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { this.tree = $.ui.fancytree.getTree(this.$tree); } - showContextMenu(e) { - const node = $.ui.fancytree.getNode(e); + showContextMenu(e: PointerEvent | JQuery.TouchStartEvent | JQuery.ContextMenuEvent) { + const node = $.ui.fancytree.getNode(e as unknown as Event); const note = froca.getNoteFromCache(node.data.noteId); if (note.isLaunchBarConfig()) { @@ -660,13 +721,11 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { } prepareRootNode() { - return this.prepareNode(froca.getBranch("none_root")); + const branch = froca.getBranch("none_root"); + return branch && this.prepareNode(branch); } - /** - * @param {FNote} parentNote - */ - prepareChildren(parentNote) { + prepareChildren(parentNote: FNote) { utils.assertArguments(parentNote); const noteList = []; @@ -697,7 +756,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { return noteList; } - async updateNode(node) { + async updateNode(node: Fancytree.FancytreeNode) { const note = froca.getNoteFromCache(node.data.noteId); const branch = froca.getBranch(node.data.branchId); @@ -725,11 +784,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { node.renderTitle(); } - /** - * @param {FBranch} branch - * @param {boolean} forceLazy - */ - prepareNode(branch, forceLazy = false) { + prepareNode(branch: FBranch, forceLazy = false) { const note = branch.getNoteFromCache(); if (!note) { @@ -741,7 +796,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { const isFolder = note.isFolder(); - const node = { + const node: Node = { noteId: note.noteId, parentNoteId: branch.parentNoteId, branchId: branch.branchId, @@ -749,7 +804,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { noteType: note.type, title: utils.escapeHtml(title), extraClasses: this.getExtraClasses(note), - icon: note.getIcon(isFolder), + icon: note.getIcon(), refKey: note.noteId, lazy: true, folder: isFolder, @@ -764,7 +819,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { return node; } - getExtraClasses(note) { + getExtraClasses(note: FNote) { utils.assertArguments(note); const extraClasses = []; @@ -780,9 +835,9 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { if (note.getParentNoteIds().length > 1) { const realClones = note .getParentNoteIds() - .map((noteId) => froca.notes[noteId]) - .filter((note) => !!note) - .filter((note) => !["_share", "_lbBookmarks"].includes(note.noteId) && note.type !== "search"); + .map((noteId: string) => froca.notes[noteId]) + .filter((note: FNote) => !!note) + .filter((note: FNote) => !["_share", "_lbBookmarks"].includes(note.noteId) && note.type !== "search"); if (realClones.length > 1) { extraClasses.push("multiple-parents"); @@ -820,8 +875,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { return this.tree.getSelectedNodes(stopOnParents); } - /** @returns {FancytreeNode[]} */ - getSelectedOrActiveNodes(node = null) { + getSelectedOrActiveNodes(node: Fancytree.FancytreeNode | null = null) { const nodes = this.getSelectedNodes(true); // the node you start dragging should be included even if not selected @@ -838,14 +892,14 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { return nodes.filter((node) => hoistedNoteService.getHoistedNoteId() !== "root" || node.data.noteId !== "_hidden"); } - async setExpandedStatusForSubtree(node, isExpanded) { + async setExpandedStatusForSubtree(node: Fancytree.FancytreeNode | null, isExpanded: boolean) { if (!node) { const hoistedNoteId = hoistedNoteService.getHoistedNoteId(); node = this.getNodesByNoteId(hoistedNoteId)[0]; } - const { branchIds } = await server.put(`branches/${node.data.branchId}/expanded-subtree/${isExpanded ? 1 : 0}`); + const { branchIds } = await server.put(`branches/${node.data.branchId}/expanded-subtree/${isExpanded ? 1 : 0}`); froca.getBranches(branchIds, true).forEach((branch) => (branch.isExpanded = !!isExpanded)); @@ -863,11 +917,11 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { // don't activate the active note, see discussion in https://github.com/zadam/trilium/issues/3664 } - async expandTree(node = null) { + async expandTree(node: Fancytree.FancytreeNode | null = null) { await this.setExpandedStatusForSubtree(node, true); } - async collapseTree(node = null) { + async collapseTree(node: Fancytree.FancytreeNode | null = null) { await this.setExpandedStatusForSubtree(node, false); } @@ -918,8 +972,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { this.tree.setFocus(true); } - /** @returns {FancytreeNode} */ - async getNodeFromPath(notePath, expand = false, logErrors = true) { + async getNodeFromPath(notePath: string, expand = false, logErrors = true) { utils.assertArguments(notePath); /** @let {FancytreeNode} */ let parentNode = this.getNodesByNoteId("root")[0]; @@ -951,7 +1004,9 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { // although the previous line should set the expanded status, it seems to happen asynchronously, // so we need to make sure it is set properly before calling updateNode which uses this flag const branch = froca.getBranch(parentNode.data.branchId); - branch.isExpanded = true; + if (branch) { + branch.isExpanded = true; + } } await this.updateNode(parentNode); @@ -990,25 +1045,25 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { return parentNode; } - /** @returns {FancytreeNode} */ - findChildNode(parentNode, childNoteId) { + findChildNode(parentNode: Fancytree.FancytreeNode, childNoteId: string) { return parentNode.getChildren().find((childNode) => childNode.data.noteId === childNoteId); } - /** @returns {FancytreeNode} */ - async expandToNote(notePath, logErrors = true) { + async expandToNote(notePath: string, logErrors = true) { return this.getNodeFromPath(notePath, true, logErrors); } - /** @returns {FancytreeNode[]} */ - getNodesByBranch(branch) { + getNodesByBranch(branch: BranchRow) { utils.assertArguments(branch); + if (!branch.noteId) { + return []; + } + return this.getNodesByNoteId(branch.noteId).filter((node) => node.data.branchId === branch.branchId); } - /** @returns {FancytreeNode[]} */ - getNodesByNoteId(noteId) { + getNodesByNoteId(noteId: string) { utils.assertArguments(noteId); const list = this.tree.getNodesByRef(noteId); @@ -1043,7 +1098,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { } if (newActiveNode) { - if (!newActiveNode.isVisible()) { + if (!newActiveNode.isVisible() && this.noteContext?.notePath) { await this.expandToNote(this.noteContext.notePath); } @@ -1055,8 +1110,8 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { this.filterHoistedBranch(false); } - async refreshSearch(e) { - const activeNode = $.ui.fancytree.getNode(e); + async refreshSearch(e: JQuery.MouseDownEvent) { + const activeNode = $.ui.fancytree.getNode(e as unknown as Event); activeNode.load(true); activeNode.setExpanded(true, { noAnimation: true }); @@ -1064,7 +1119,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { toastService.showMessage(t("note_tree.saved-search-note-refreshed")); } - async batchUpdate(cb) { + async batchUpdate(cb: () => Promise) { try { // disable rendering during update for increased performance this.tree.enableUpdate(false); @@ -1115,7 +1170,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { }, 600 * 1000); } - async entitiesReloadedEvent({ loadResults }) { + async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { this.activityDetected(); if (loadResults.isEmptyForTree()) { @@ -1126,14 +1181,15 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { const activeNodeFocused = activeNode?.hasFocus(); const activeNotePath = activeNode ? treeService.getNotePath(activeNode) : null; - const refreshCtx = { + const refreshCtx: RefreshContext = { noteIdsToUpdate: new Set(), noteIdsToReload: new Set() }; this.#processAttributeRows(loadResults.getAttributeRows(), refreshCtx); - const { movedActiveNode, parentsOfAddedNodes } = await this.#processBranchRows(loadResults.getBranchRows(), refreshCtx); + const branchRows = loadResults.getBranchRows(); + const { movedActiveNode, parentsOfAddedNodes } = await this.#processBranchRows(branchRows, refreshCtx); for (const noteId of loadResults.getNoteIds()) { refreshCtx.noteIdsToUpdate.add(noteId); @@ -1149,17 +1205,17 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { } } - #processAttributeRows(attributeRows, refreshCtx) { + #processAttributeRows(attributeRows: AttributeRow[], refreshCtx: RefreshContext) { for (const attrRow of attributeRows) { const dirtyingLabels = ["iconClass", "cssClass", "workspace", "workspaceIconClass", "color"]; - if (attrRow.type === "label" && dirtyingLabels.includes(attrRow.name)) { + if (attrRow.type === "label" && dirtyingLabels.includes(attrRow.name ?? "") && attrRow.noteId) { if (attrRow.isInheritable) { refreshCtx.noteIdsToReload.add(attrRow.noteId); } else { refreshCtx.noteIdsToUpdate.add(attrRow.noteId); } - } else if (attrRow.type === "label" && attrRow.name === "archived") { + } else if (attrRow.type === "label" && attrRow.name === "archived" && attrRow.noteId) { const note = froca.getNoteFromCache(attrRow.noteId); if (note) { @@ -1169,13 +1225,13 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { refreshCtx.noteIdsToReload.add(parentNote.noteId); } } - } else if (attrRow.type === "relation" && (attrRow.name === "template" || attrRow.name === "inherit")) { + } else if (attrRow.type === "relation" && (attrRow.name === "template" || attrRow.name === "inherit") && attrRow.noteId) { // missing handling of things inherited from template refreshCtx.noteIdsToReload.add(attrRow.noteId); - } else if (attrRow.type === "relation" && attrRow.name === "imageLink") { + } else if (attrRow.type === "relation" && attrRow.name === "imageLink" && attrRow.noteId) { const note = froca.getNoteFromCache(attrRow.noteId); - if (note && note.getChildNoteIds().includes(attrRow.value)) { + if (note && note.getChildNoteIds().includes(attrRow.value ?? "")) { // there's a new /deleted imageLink between note and its image child - which can show/hide // the image (if there is an imageLink relation between parent and child, // then it is assumed to be "contained" in the note and thus does not have to be displayed in the tree) @@ -1185,7 +1241,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { } } - async #processBranchRows(branchRows, refreshCtx) { + async #processBranchRows(branchRows: BranchRow[], refreshCtx: RefreshContext) { const allBranchesDeleted = branchRows.every((branchRow) => !!branchRow.isDeleted); // activeNode is supposed to be moved when we find out activeNode is deleted but not all branches are deleted. save it for fixing activeNodePath after all nodes loaded. @@ -1193,12 +1249,14 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { let parentsOfAddedNodes = []; for (const branchRow of branchRows) { - if (branchRow.parentNoteId === "_share") { - // all shared notes have a sign in the tree, even the descendants of shared notes - refreshCtx.noteIdsToReload.add(branchRow.noteId); - } else { - // adding noteId itself to update all potential clones - refreshCtx.noteIdsToUpdate.add(branchRow.noteId); + if (branchRow.noteId) { + if (branchRow.parentNoteId === "_share") { + // all shared notes have a sign in the tree, even the descendants of shared notes + refreshCtx.noteIdsToReload.add(branchRow.noteId); + } else { + // adding noteId itself to update all potential clones + refreshCtx.noteIdsToUpdate.add(branchRow.noteId); + } } if (branchRow.isDeleted) { @@ -1219,37 +1277,44 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { node.remove(); } - refreshCtx.noteIdsToUpdate.add(branchRow.parentNoteId); + if (branchRow.parentNoteId) { + refreshCtx.noteIdsToUpdate.add(branchRow.parentNoteId); + } } - } else { + } else if (branchRow.parentNoteId) { for (const parentNode of this.getNodesByNoteId(branchRow.parentNoteId)) { parentsOfAddedNodes.push(parentNode); - if (parentNode.isFolder() && !parentNode.isLoaded()) { + if (!branchRow.noteId || (parentNode.isFolder() && !parentNode.isLoaded())) { continue; } const note = await froca.getNote(branchRow.noteId); - const frocaBranch = froca.getBranch(branchRow.branchId); + const frocaBranch = branchRow.branchId ? froca.getBranch(branchRow.branchId) : null; const foundNode = (parentNode.getChildren() || []).find((child) => child.data.noteId === branchRow.noteId); if (foundNode) { // the branch already exists in the tree - if (branchRow.isExpanded !== foundNode.isExpanded()) { + if (branchRow.isExpanded !== foundNode.isExpanded() && frocaBranch) { refreshCtx.noteIdsToReload.add(frocaBranch.noteId); } - } else { + } else if (frocaBranch) { // make sure it's loaded // we're forcing lazy since it's not clear if the whole required subtree is in froca - parentNode.addChildren([this.prepareNode(frocaBranch, true)]); + const newNode = this.prepareNode(frocaBranch, true); + if (newNode) { + parentNode.addChildren([newNode]); + } - if (frocaBranch.isExpanded && note.hasChildren()) { + if (frocaBranch?.isExpanded && note && note.hasChildren()) { refreshCtx.noteIdsToReload.add(frocaBranch.noteId); } this.sortChildren(parentNode); // this might be a first child which would force an icon change - refreshCtx.noteIdsToUpdate.add(branchRow.parentNoteId); + if (branchRow.parentNoteId) { + refreshCtx.noteIdsToUpdate.add(branchRow.parentNoteId); + } } } } @@ -1261,7 +1326,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { }; } - async #executeTreeUpdates(refreshCtx, loadResults) { + async #executeTreeUpdates(refreshCtx: RefreshContext, loadResults: LoadResults) { await this.batchUpdate(async () => { for (const noteId of refreshCtx.noteIdsToReload) { for (const node of this.getNodesByNoteId(noteId)) { @@ -1288,7 +1353,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { } } - async #setActiveNode(activeNotePath, activeNodeFocused, movedActiveNode, parentsOfAddedNodes) { + async #setActiveNode(activeNotePath: string | null, activeNodeFocused: boolean, movedActiveNode: Fancytree.FancytreeNode | null, parentsOfAddedNodes: Fancytree.FancytreeNode[]) { if (movedActiveNode) { for (const parentNode of parentsOfAddedNodes) { const foundNode = (parentNode.getChildren() || []).find((child) => child.data.noteId === movedActiveNode.data.noteId); @@ -1303,15 +1368,19 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { return; } - let node = await this.expandToNote(activeNotePath, false); + let node: Fancytree.FancytreeNode | null | undefined = await this.expandToNote(activeNotePath, false); if (node && node.data.noteId !== treeService.getNoteIdFromUrl(activeNotePath)) { // 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(treeService.getNoteIdFromUrl(activeNotePath)); + const noteId = treeService.getNoteIdFromUrl(activeNotePath); - // if there are multiple clones, then we'd rather not activate anyone - node = notesById.length === 1 ? notesById[0] : null; + if (noteId) { + const notesById = this.getNodesByNoteId(noteId); + + // if there are multiple clones, then we'd rather not activate anyone + node = notesById.length === 1 ? notesById[0] : null; + } } if (!node) { @@ -1326,7 +1395,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { await node.setActive(true, { noEvents: true, noFocus: !activeNodeFocused }); } - sortChildren(node) { + sortChildren(node: Fancytree.FancytreeNode) { node.sortChildren((nodeA, nodeB) => { const branchA = froca.branches[nodeA.data.branchId]; const branchB = froca.branches[nodeB.data.branchId]; @@ -1339,7 +1408,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { }); } - setExpanded(branchId, isExpanded) { + setExpanded(branchId: string, isExpanded: boolean) { utils.assertArguments(branchId); const branch = froca.getBranch(branchId, true); @@ -1381,7 +1450,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { } } - async hoistedNoteChangedEvent({ ntxId }) { + async hoistedNoteChangedEvent({ ntxId }: EventData<"hoistedNoteChanged">) { if (this.isNoteContext(ntxId)) { await this.filterHoistedBranch(true); } @@ -1402,7 +1471,9 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { this.lastFilteredHoistedNotePath = hoistedNotePath; - await this.getNodeFromPath(hoistedNotePath); + if (hoistedNotePath) { + await this.getNodeFromPath(hoistedNotePath); + } if (this.noteContext.hoistedNoteId === "root") { this.tree.clearFilter(); @@ -1411,7 +1482,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { // hack when hoisted note is cloned then it could be filtered multiple times while we want only 1 this.tree.filterBranches( (node) => - node.data.noteId === this.noteContext.hoistedNoteId && // optimization to not having always resolve the node path + node.data.noteId === this.noteContext?.hoistedNoteId && // optimization to not having always resolve the node path treeService.getNotePath(node) === hoistedNotePath ); @@ -1419,18 +1490,19 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { } } - toggleHiddenNode(show) { + toggleHiddenNode(show: boolean) { const hiddenNode = this.getNodesByNoteId("_hidden")[0]; - $(hiddenNode.li).toggleClass("hidden-node-is-hidden", !show); + // TODO: Check how .li exists here. + $((hiddenNode as any).li).toggleClass("hidden-node-is-hidden", !show); } - frocaReloadedEvent() { + async frocaReloadedEvent() { this.reloadTreeFromCache(); } async getHotKeys() { const actions = await keyboardActionsService.getActionsForScope("note-tree"); - const hotKeyMap = {}; + const hotKeyMap: Record boolean> = {}; for (const action of actions) { for (const shortcut of action.effectiveShortcuts) { @@ -1447,25 +1519,19 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { return hotKeyMap; } - /** - * @param {FancytreeNode} node - */ - getSelectedOrActiveBranchIds(node) { + getSelectedOrActiveBranchIds(node: Fancytree.FancytreeNode) { const nodes = this.getSelectedOrActiveNodes(node); return nodes.map((node) => node.data.branchId); } - /** - * @param {FancytreeNode} node - */ - getSelectedOrActiveNoteIds(node) { + getSelectedOrActiveNoteIds(node: Fancytree.FancytreeNode): string[] { const nodes = this.getSelectedOrActiveNodes(node); return nodes.map((node) => node.data.noteId); } - async deleteNotesCommand({ node }) { + async deleteNotesCommand({ node }: CommandListenerData<"deleteNotes">) { const branchIds = this.getSelectedOrActiveBranchIds(node).filter((branchId) => !branchId.startsWith("virt-")); // search results can't be deleted if (!branchIds.length) { @@ -1477,7 +1543,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { this.clearSelectedNodes(); } - canBeMovedUpOrDown(node) { + canBeMovedUpOrDown(node: Fancytree.FancytreeNode) { if (node.data.noteId === "root") { return false; } @@ -1487,8 +1553,8 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { return !parentNote?.hasLabel("sorted"); } - moveNoteUpCommand({ node }) { - if (!this.canBeMovedUpOrDown(node)) { + moveNoteUpCommand({ node }: CommandListenerData<"moveNoteUp">) { + if (!node || !this.canBeMovedUpOrDown(node)) { return; } @@ -1499,7 +1565,7 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { } } - moveNoteDownCommand({ node }) { + moveNoteDownCommand({ node }: CommandListenerData<"moveNoteDown">) { if (!this.canBeMovedUpOrDown(node)) { return; } @@ -1511,11 +1577,11 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { } } - moveNoteUpInHierarchyCommand({ node }) { + moveNoteUpInHierarchyCommand({ node }: CommandListenerData<"moveNoteUpInHierarchy">) { branchService.moveNodeUpInHierarchy(node); } - moveNoteDownInHierarchyCommand({ node }) { + moveNoteDownInHierarchyCommand({ node }: CommandListenerData<"moveNoteDownInHierarchy">) { const toNode = node.getPrevSibling(); if (toNode !== null) { @@ -1571,63 +1637,63 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { } } - expandSubtreeCommand({ node }) { + expandSubtreeCommand({ node }: CommandListenerData<"expandSubtree">) { this.expandTree(node); } - collapseSubtreeCommand({ node }) { + collapseSubtreeCommand({ node }: CommandListenerData<"collapseSubtree">) { this.collapseTree(node); } - async recentChangesInSubtreeCommand({ node }) { + async recentChangesInSubtreeCommand({ node }: CommandListenerData<"recentChangesInSubtree">) { this.triggerCommand("showRecentChanges", { ancestorNoteId: node.data.noteId }); } - selectAllNotesInParentCommand({ node }) { + selectAllNotesInParentCommand({ node }: CommandListenerData<"selectAllNotesInParent">) { for (const child of node.getParent().getChildren()) { child.setSelected(true); } } - copyNotesToClipboardCommand({ node }) { + copyNotesToClipboardCommand({ node }: CommandListenerData<"copyNotesToClipboard">) { clipboard.copy(this.getSelectedOrActiveBranchIds(node)); } - cutNotesToClipboardCommand({ node }) { + cutNotesToClipboardCommand({ node }: CommandListenerData<"cutNotesToClipboard">) { clipboard.cut(this.getSelectedOrActiveBranchIds(node)); } - pasteNotesFromClipboardCommand({ node }) { + pasteNotesFromClipboardCommand({ node }: CommandListenerData<"pasteNotesFromClipboard">) { clipboard.pasteInto(node.data.branchId); } - pasteNotesAfterFromClipboardCommand({ node }) { + pasteNotesAfterFromClipboardCommand({ node }: CommandListenerData<"pasteNotesAfterFromClipboard">) { clipboard.pasteAfter(node.data.branchId); } - async exportNoteCommand({ node }) { + async exportNoteCommand({ node }: CommandListenerData<"exportNote">) { const notePath = treeService.getNotePath(node); this.triggerCommand("showExportDialog", { notePath, defaultType: "subtree" }); } - async importIntoNoteCommand({ node }) { + async importIntoNoteCommand({ node }: CommandListenerData<"importIntoNote">) { this.triggerCommand("showImportDialog", { noteId: node.data.noteId }); } - editNoteTitleCommand({ node }) { + editNoteTitleCommand({ node }: CommandListenerData<"editNoteTitle">) { appContext.triggerCommand("focusOnTitle"); } - protectSubtreeCommand({ node }) { + protectSubtreeCommand({ node }: CommandListenerData<"protectSubtree">) { protectedSessionService.protectNote(node.data.noteId, true, true); } - unprotectSubtreeCommand({ node }) { + unprotectSubtreeCommand({ node }: CommandListenerData<"unprotectSubtree">) { protectedSessionService.protectNote(node.data.noteId, false, true); } - duplicateSubtreeCommand({ node }) { + duplicateSubtreeCommand({ node }: CommandListenerData<"duplicateSubtree">) { const nodesToDuplicate = this.getSelectedOrActiveNodes(node); for (const nodeToDuplicate of nodesToDuplicate) { @@ -1639,19 +1705,21 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { const branch = froca.getBranch(nodeToDuplicate.data.branchId); - noteCreateService.duplicateSubtree(nodeToDuplicate.data.noteId, branch.parentNoteId); + if (branch?.parentNoteId) { + noteCreateService.duplicateSubtree(nodeToDuplicate.data.noteId, branch.parentNoteId); + } } } - moveLauncherToVisibleCommand({ selectedOrActiveBranchIds }) { + moveLauncherToVisibleCommand({ selectedOrActiveBranchIds }: CommandListenerData<"moveLauncherToVisible">) { this.#moveLaunchers(selectedOrActiveBranchIds, "_lbVisibleLaunchers", "_lbMobileVisibleLaunchers"); } - moveLauncherToAvailableCommand({ selectedOrActiveBranchIds }) { + moveLauncherToAvailableCommand({ selectedOrActiveBranchIds }: CommandListenerData<"moveLauncherToAvailable">) { this.#moveLaunchers(selectedOrActiveBranchIds, "_lbAvailableLaunchers", "_lbMobileAvailableLaunchers"); } - #moveLaunchers(selectedOrActiveBranchIds, desktopParent, mobileParent) { + #moveLaunchers(selectedOrActiveBranchIds: string[], desktopParent: string, mobileParent: string) { const desktopLaunchersToMove = selectedOrActiveBranchIds.filter((branchId) => !branchId.startsWith("_lbMobile")); if (desktopLaunchersToMove) { branchService.moveToParentNote(desktopLaunchersToMove, "_lbRoot_" + desktopParent); @@ -1663,24 +1731,24 @@ export default class NoteTreeWidget extends NoteContextAwareWidget { } } - addNoteLauncherCommand({ node }) { + addNoteLauncherCommand({ node }: CommandListenerData<"addNoteLauncher">) { this.createLauncherNote(node, "note"); } - addScriptLauncherCommand({ node }) { + addScriptLauncherCommand({ node }: CommandListenerData<"addScriptLauncher">) { this.createLauncherNote(node, "script"); } - addWidgetLauncherCommand({ node }) { + addWidgetLauncherCommand({ node }: CommandListenerData<"addWidgetLauncher">) { this.createLauncherNote(node, "customWidget"); } - addSpacerLauncherCommand({ node }) { + addSpacerLauncherCommand({ node }: CommandListenerData<"addSpacerLauncher">) { this.createLauncherNote(node, "spacer"); } - async createLauncherNote(node, launcherType) { - const resp = await server.post(`special-notes/launchers/${node.data.noteId}/${launcherType}`); + async createLauncherNote(node: Fancytree.FancytreeNode, launcherType: LauncherType) { + const resp = await server.post(`special-notes/launchers/${node.data.noteId}/${launcherType}`); if (!resp.success) { toastService.showError(resp.message); diff --git a/src/routes/api/special_notes.ts b/src/routes/api/special_notes.ts index cf339794b..b443f9ec3 100644 --- a/src/routes/api/special_notes.ts +++ b/src/routes/api/special_notes.ts @@ -3,7 +3,7 @@ import dateNoteService from "../../services/date_notes.js"; import sql from "../../services/sql.js"; import cls from "../../services/cls.js"; -import specialNotesService from "../../services/special_notes.js"; +import specialNotesService, { type LauncherType } from "../../services/special_notes.js"; import becca from "../../becca/becca.js"; import type { Request } from "express"; @@ -85,7 +85,8 @@ function getHoistedNote() { function createLauncher(req: Request) { return specialNotesService.createLauncher({ parentNoteId: req.params.parentNoteId, - launcherType: req.params.launcherType + // TODO: Validate the parameter + launcherType: req.params.launcherType as LauncherType }); } diff --git a/src/services/special_notes.ts b/src/services/special_notes.ts index 2c5b2cf4f..11c0fd2bc 100644 --- a/src/services/special_notes.ts +++ b/src/services/special_notes.ts @@ -156,9 +156,11 @@ function createScriptLauncher(parentNoteId: string, forceNoteId?: string) { return note; } +export type LauncherType = "launcher" | "note" | "script" | "customWidget" | "spacer"; + interface LauncherConfig { parentNoteId: string; - launcherType: string; + launcherType: LauncherType; noteId?: string; }