From 8f69b87dd16c429ef743e31ae78a0b19c12707d1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 22 Aug 2025 21:45:03 +0300 Subject: [PATCH] feat(react/ribbon): port note paths tab --- .../src/widgets/react/KeyboardShortcut.tsx | 4 +- apps/client/src/widgets/react/NoteLink.tsx | 10 +- apps/client/src/widgets/react/react_utils.tsx | 4 +- .../src/widgets/ribbon/EditedNotesTab.tsx | 4 +- .../src/widgets/ribbon/FilePropertiesTab.tsx | 6 +- .../src/widgets/ribbon/NotePathsTab.tsx | 107 ++++++++++++ apps/client/src/widgets/ribbon/Ribbon.tsx | 17 +- .../src/widgets/ribbon/ribbon-interface.ts | 2 + apps/client/src/widgets/ribbon/style.css | 24 +++ .../src/widgets/ribbon_widgets/note_paths.ts | 153 ------------------ 10 files changed, 161 insertions(+), 170 deletions(-) create mode 100644 apps/client/src/widgets/ribbon/NotePathsTab.tsx delete mode 100644 apps/client/src/widgets/ribbon_widgets/note_paths.ts diff --git a/apps/client/src/widgets/react/KeyboardShortcut.tsx b/apps/client/src/widgets/react/KeyboardShortcut.tsx index 852fc8733..bb44820dc 100644 --- a/apps/client/src/widgets/react/KeyboardShortcut.tsx +++ b/apps/client/src/widgets/react/KeyboardShortcut.tsx @@ -1,7 +1,7 @@ import { ActionKeyboardShortcut, KeyboardActionNames } from "@triliumnext/commons"; import { useEffect, useState } from "preact/hooks"; import keyboard_actions from "../../services/keyboard_actions"; -import { separateByCommas } from "./react_utils"; +import { joinElements } from "./react_utils"; interface KeyboardShortcutProps { actionName: KeyboardActionNames; @@ -22,7 +22,7 @@ export default function KeyboardShortcut({ actionName }: KeyboardShortcutProps) <> {action.effectiveShortcuts?.map((shortcut, i) => { const keys = shortcut.split("+"); - return separateByCommas(keys + return joinElements(keys .map((key, i) => ( <> {key} {i + 1 < keys.length && "+ "} diff --git a/apps/client/src/widgets/react/NoteLink.tsx b/apps/client/src/widgets/react/NoteLink.tsx index 21c0af3d3..b7f3a3409 100644 --- a/apps/client/src/widgets/react/NoteLink.tsx +++ b/apps/client/src/widgets/react/NoteLink.tsx @@ -6,9 +6,10 @@ interface NoteLinkOpts { notePath: string | string[]; showNotePath?: boolean; style?: Record; + noPreview?: boolean; } -export default function NoteLink({ notePath, showNotePath, style }: NoteLinkOpts) { +export default function NoteLink({ notePath, showNotePath, style, noPreview }: NoteLinkOpts) { const stringifiedNotePath = Array.isArray(notePath) ? notePath.join("/") : notePath; const [ jqueryEl, setJqueryEl ] = useState>(); @@ -21,6 +22,13 @@ export default function NoteLink({ notePath, showNotePath, style }: NoteLinkOpts jqueryEl?.css(style); } + const $linkEl = jqueryEl?.find("a"); + if (noPreview) { + $linkEl?.addClass("no-tooltip-preview"); + } + + $linkEl?.addClass("tn-link"); + return } \ No newline at end of file diff --git a/apps/client/src/widgets/react/react_utils.tsx b/apps/client/src/widgets/react/react_utils.tsx index 490dd2094..ebb4c6156 100644 --- a/apps/client/src/widgets/react/react_utils.tsx +++ b/apps/client/src/widgets/react/react_utils.tsx @@ -43,7 +43,7 @@ export function disposeReactWidget(container: Element) { render(null, container); } -export function separateByCommas(components: ComponentChild[]) { +export function joinElements(components: ComponentChild[], separator = ", ") { return components.reduce((acc, item) => - (acc.length ? [...acc, ", ", item] : [item]), []); + (acc.length ? [...acc, separator, item] : [item]), []); } diff --git a/apps/client/src/widgets/ribbon/EditedNotesTab.tsx b/apps/client/src/widgets/ribbon/EditedNotesTab.tsx index c85ac4ad1..ce7bcfc19 100644 --- a/apps/client/src/widgets/ribbon/EditedNotesTab.tsx +++ b/apps/client/src/widgets/ribbon/EditedNotesTab.tsx @@ -5,7 +5,7 @@ import server from "../../services/server"; import { t } from "../../services/i18n"; import froca from "../../services/froca"; import NoteLink from "../react/NoteLink"; -import { separateByCommas } from "../react/react_utils"; +import { joinElements } from "../react/react_utils"; export default function EditedNotesTab({ note }: TabContext) { const [ editedNotes, setEditedNotes ] = useState(); @@ -29,7 +29,7 @@ export default function EditedNotesTab({ note }: TabContext) { }}> {editedNotes ? (
- {separateByCommas(editedNotes.map(editedNote => { + {joinElements(editedNotes.map(editedNote => { return ( {editedNote.isDeleted ? ( diff --git a/apps/client/src/widgets/ribbon/FilePropertiesTab.tsx b/apps/client/src/widgets/ribbon/FilePropertiesTab.tsx index 916f17ff9..5d738d50f 100644 --- a/apps/client/src/widgets/ribbon/FilePropertiesTab.tsx +++ b/apps/client/src/widgets/ribbon/FilePropertiesTab.tsx @@ -1,10 +1,8 @@ -import { useEffect, useRef, useState } from "preact/hooks"; import { t } from "../../services/i18n"; import { formatSize } from "../../services/utils"; -import FormFileUpload, { FormFileUploadButton } from "../react/FormFileUpload"; -import { useNoteBlob, useNoteLabel, useTriliumEventBeta } from "../react/hooks"; +import { FormFileUploadButton } from "../react/FormFileUpload"; +import { useNoteBlob, useNoteLabel } from "../react/hooks"; import { TabContext } from "./ribbon-interface"; -import FBlob from "../../entities/fblob"; import Button from "../react/Button"; import protected_session_holder from "../../services/protected_session_holder"; import { downloadFileNote, openNoteExternally } from "../../services/open"; diff --git a/apps/client/src/widgets/ribbon/NotePathsTab.tsx b/apps/client/src/widgets/ribbon/NotePathsTab.tsx new file mode 100644 index 000000000..c5017af09 --- /dev/null +++ b/apps/client/src/widgets/ribbon/NotePathsTab.tsx @@ -0,0 +1,107 @@ +import { TabContext } from "./ribbon-interface"; +import { t } from "../../services/i18n"; +import Button from "../react/Button"; +import { useTriliumEventBeta } from "../react/hooks"; +import { useEffect, useMemo, useState } from "preact/hooks"; +import { NotePathRecord } from "../../entities/fnote"; +import NoteLink from "../react/NoteLink"; +import { joinElements } from "../react/react_utils"; + +export default function NotePathsTab({ note, hoistedNoteId, notePath }: TabContext) { + const [ sortedNotePaths, setSortedNotePaths ] = useState(); + + function refresh() { + if (!note) return; + setSortedNotePaths(note + .getSortedNotePathRecords(hoistedNoteId) + .filter((notePath) => !notePath.isHidden)); + } + + useEffect(refresh, [ note?.noteId ]); + useTriliumEventBeta("entitiesReloaded", ({ loadResults }) => { + const noteId = note?.noteId; + if (!noteId) return; + if (loadResults.getBranchRows().find((branch) => branch.noteId === noteId) + || loadResults.isNoteReloaded(noteId)) { + refresh(); + } + }); + + return ( +
+ {sortedNotePaths && ( + <> +
+ {sortedNotePaths.length > 0 ? t("note_paths.intro_placed") : t("note_paths.intro_not_placed")} +
+ +
    + {sortedNotePaths.map(sortedNotePath => ( + + ))} +
+ +
+ ) +} + +function NotePath({ currentNotePath, notePathRecord }: { currentNotePath: string, notePathRecord?: NotePathRecord }) { + const notePath = notePathRecord?.notePath ?? []; + const notePathString = useMemo(() => notePath.join("/"), [ notePath ]); + + const [ classes, icons ] = useMemo(() => { + const classes: string[] = []; + const icons: { icon: string, title: string }[] = []; + + if (notePathString === currentNotePath) { + classes.push("path-current"); + } + + if (!notePathRecord || notePathRecord.isInHoistedSubTree) { + classes.push("path-in-hoisted-subtree"); + } else { + icons.push({ icon: "bx bx-trending-up", title: t("note_paths.outside_hoisted") }) + } + + if (notePathRecord?.isArchived) { + classes.push("path-archived"); + icons.push({ icon: "bx bx-archive", title: t("note_paths.archived") }) + } + + if (notePathRecord?.isSearch) { + classes.push("path-search"); + icons.push({ icon: "bx bx-search", title: t("note_paths.search") }) + } + + return [ classes.join(" "), icons ]; + }, [ notePathString, currentNotePath, notePathRecord ]); + + // Determine the full note path (for the links) of every component of the current note path. + const pathSegments: string[] = []; + const fullNotePaths: string[] = []; + for (const noteId of notePath) { + pathSegments.push(noteId); + fullNotePaths.push(pathSegments.join("/")); + } + + return ( +
  • + {joinElements(fullNotePaths.map(notePath => ( + + )), " / ")} + + {icons.map(({ icon, title }) => ( + + ))} +
  • + ) +} \ No newline at end of file diff --git a/apps/client/src/widgets/ribbon/Ribbon.tsx b/apps/client/src/widgets/ribbon/Ribbon.tsx index 4d6c84be7..e14ae45c8 100644 --- a/apps/client/src/widgets/ribbon/Ribbon.tsx +++ b/apps/client/src/widgets/ribbon/Ribbon.tsx @@ -17,6 +17,7 @@ import NoteInfoTab from "./NoteInfoTab"; import SimilarNotesTab from "./SimilarNotesTab"; import FilePropertiesTab from "./FilePropertiesTab"; import ImagePropertiesTab from "./ImagePropertiesTab"; +import NotePathsTab from "./NotePathsTab"; interface TitleContext { note: FNote | null | undefined; @@ -27,7 +28,7 @@ interface TabConfiguration { icon: string; // TODO: Mark as required after porting them all. content?: (context: TabContext) => VNode; - show?: (context: TitleContext) => boolean | null | undefined; + show?: boolean | ((context: TitleContext) => boolean | null | undefined); toggleCommand?: CommandNames; activate?: boolean | ((context: TitleContext) => boolean); /** @@ -113,9 +114,11 @@ const TAB_CONFIGURATION = numberObjectsInPlace([ icon: "bx bx-list-plus" }, { - // NotePathsWidget title: t("note_paths.title"), - icon: "bx bx-collection" + icon: "bx bx-collection", + content: NotePathsTab, + show: true, + toggleCommand: "toggleRibbonTabNotePaths" }, { // NoteMapRibbonWidget @@ -139,10 +142,10 @@ const TAB_CONFIGURATION = numberObjectsInPlace([ ]); export default function Ribbon() { - const { note, ntxId } = useNoteContext(); + const { note, ntxId, hoistedNoteId, notePath } = useNoteContext(); const titleContext: TitleContext = { note }; const [ activeTabIndex, setActiveTabIndex ] = useState(); - const filteredTabs = useMemo(() => TAB_CONFIGURATION.filter(tab => tab.show?.(titleContext)), [ titleContext, note ]); + const filteredTabs = useMemo(() => TAB_CONFIGURATION.filter(tab => typeof tab.show === "boolean" ? tab.show : tab.show?.(titleContext)), [ titleContext, note ]); return (
    @@ -178,7 +181,9 @@ export default function Ribbon() { return tab?.content && tab.content({ note, hidden: !isActive, - ntxId + ntxId, + hoistedNoteId, + notePath }); })}
    diff --git a/apps/client/src/widgets/ribbon/ribbon-interface.ts b/apps/client/src/widgets/ribbon/ribbon-interface.ts index bc8186734..1165447e2 100644 --- a/apps/client/src/widgets/ribbon/ribbon-interface.ts +++ b/apps/client/src/widgets/ribbon/ribbon-interface.ts @@ -4,4 +4,6 @@ export interface TabContext { note: FNote | null | undefined; hidden: boolean; ntxId?: string | null | undefined; + hoistedNoteId?: string; + notePath?: string; } diff --git a/apps/client/src/widgets/ribbon/style.css b/apps/client/src/widgets/ribbon/style.css index 87baedfc2..dee220bf6 100644 --- a/apps/client/src/widgets/ribbon/style.css +++ b/apps/client/src/widgets/ribbon/style.css @@ -221,4 +221,28 @@ display: flex; justify-content: space-evenly; } +/* #endregion */ + +/* #region Note paths */ +.note-paths-widget { + padding: 12px; + max-height: 300px; + overflow-y: auto; +} + +.note-path-list { + margin-top: 10px; +} + +.note-path-list .path-current a { + font-weight: bold; +} + +.note-path-list .path-archived a { + color: var(--muted-text-color) !important; +} + +.note-path-list .path-search a { + font-style: italic; +} /* #endregion */ \ No newline at end of file diff --git a/apps/client/src/widgets/ribbon_widgets/note_paths.ts b/apps/client/src/widgets/ribbon_widgets/note_paths.ts deleted file mode 100644 index 9377a2198..000000000 --- a/apps/client/src/widgets/ribbon_widgets/note_paths.ts +++ /dev/null @@ -1,153 +0,0 @@ -import NoteContextAwareWidget from "../note_context_aware_widget.js"; -import treeService from "../../services/tree.js"; -import linkService from "../../services/link.js"; -import { t } from "../../services/i18n.js"; -import type FNote from "../../entities/fnote.js"; -import type { NotePathRecord } from "../../entities/fnote.js"; -import type { EventData } from "../../components/app_context.js"; - -const TPL = /*html*/` -
    - - -
    - -
      - - -
      `; - -export default class NotePathsWidget extends NoteContextAwareWidget { - - private $notePathIntro!: JQuery; - private $notePathList!: JQuery; - - get name() { - return "notePaths"; - } - - get toggleCommand() { - return "toggleRibbonTabNotePaths"; - } - - getTitle() { - return { - show: true, - - }; - } - - doRender() { - this.$widget = $(TPL); - this.contentSized(); - - this.$notePathIntro = this.$widget.find(".note-path-intro"); - this.$notePathList = this.$widget.find(".note-path-list"); - } - - async refreshWithNote(note: FNote) { - this.$notePathList.empty(); - - if (!this.note || this.noteId === "root") { - this.$notePathList.empty().append(await this.getRenderedPath(["root"])); - - return; - } - - const sortedNotePaths = this.note.getSortedNotePathRecords(this.hoistedNoteId).filter((notePath) => !notePath.isHidden); - - if (sortedNotePaths.length > 0) { - this.$notePathIntro.text(t("note_paths.intro_placed")); - } else { - this.$notePathIntro.text(t("note_paths.intro_not_placed")); - } - - const renderedPaths: JQuery[] = []; - - for (const notePathRecord of sortedNotePaths) { - const notePath = notePathRecord.notePath; - - renderedPaths.push(await this.getRenderedPath(notePath, notePathRecord)); - } - - this.$notePathList.empty().append(...renderedPaths); - } - - async getRenderedPath(notePath: string[], notePathRecord: NotePathRecord | null = null) { - const $pathItem = $("
    • "); - const pathSegments: string[] = []; - const lastIndex = notePath.length - 1; - - for (let i = 0; i < notePath.length; i++) { - const noteId = notePath[i]; - pathSegments.push(noteId); - const title = await treeService.getNoteTitle(noteId); - const $noteLink = await linkService.createLink(pathSegments.join("/"), { title }); - - $noteLink.find("a").addClass("no-tooltip-preview tn-link"); - $pathItem.append($noteLink); - - if (i != lastIndex) { - $pathItem.append(" / "); - } - } - - const icons: string[] = []; - - if (this.notePath === notePath.join("/")) { - $pathItem.addClass("path-current"); - } - - if (!notePathRecord || notePathRecord.isInHoistedSubTree) { - $pathItem.addClass("path-in-hoisted-subtree"); - } else { - icons.push(``); - } - - if (notePathRecord?.isArchived) { - $pathItem.addClass("path-archived"); - - icons.push(``); - } - - if (notePathRecord?.isSearch) { - $pathItem.addClass("path-search"); - - icons.push(``); - } - - if (icons.length > 0) { - $pathItem.append(` ${icons.join(" ")}`); - } - - return $pathItem; - } - - entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) { - if (loadResults.getBranchRows().find((branch) => branch.noteId === this.noteId) || (this.noteId != null && loadResults.isNoteReloaded(this.noteId))) { - this.refresh(); - } - } -}