feat(react/ribbon): port note paths tab

This commit is contained in:
Elian Doran 2025-08-22 21:45:03 +03:00
parent 8287063aab
commit 8f69b87dd1
No known key found for this signature in database
10 changed files with 161 additions and 170 deletions

View File

@ -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) => (
<>
<kbd>{key}</kbd> {i + 1 < keys.length && "+ "}

View File

@ -6,9 +6,10 @@ interface NoteLinkOpts {
notePath: string | string[];
showNotePath?: boolean;
style?: Record<string, string | number>;
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<JQuery<HTMLElement>>();
@ -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 <RawHtml html={jqueryEl} />
}

View File

@ -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<any>((acc, item) =>
(acc.length ? [...acc, ", ", item] : [item]), []);
(acc.length ? [...acc, separator, item] : [item]), []);
}

View File

@ -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<EditedNotesResponse>();
@ -29,7 +29,7 @@ export default function EditedNotesTab({ note }: TabContext) {
}}>
{editedNotes ? (
<div className="edited-notes-list use-tn-links">
{separateByCommas(editedNotes.map(editedNote => {
{joinElements(editedNotes.map(editedNote => {
return (
<span className="edited-note-line">
{editedNote.isDeleted ? (

View File

@ -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";

View File

@ -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<NotePathRecord[]>();
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 (
<div class="note-paths-widget">
{sortedNotePaths && (
<>
<div className="note-path-intro">
{sortedNotePaths.length > 0 ? t("note_paths.intro_placed") : t("note_paths.intro_not_placed")}
</div>
<ul className="note-path-list">
{sortedNotePaths.map(sortedNotePath => (
<NotePath
currentNotePath={notePath}
notePathRecord={sortedNotePath}
/>
))}
</ul>
<Button
triggerCommand="cloneNoteIdsTo"
text={t("note_paths.clone_button")}
/>
</>
)}
</div>
)
}
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 (
<li class={classes}>
{joinElements(fullNotePaths.map(notePath => (
<NoteLink notePath={notePath} noPreview />
)), " / ")}
{icons.map(({ icon, title }) => (
<span class={icon} title={title} />
))}
</li>
)
}

View File

@ -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<TabConfiguration>([
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<TabConfiguration>([
]);
export default function Ribbon() {
const { note, ntxId } = useNoteContext();
const { note, ntxId, hoistedNoteId, notePath } = useNoteContext();
const titleContext: TitleContext = { note };
const [ activeTabIndex, setActiveTabIndex ] = useState<number | undefined>();
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 (
<div class="ribbon-container" style={{ contain: "none" }}>
@ -178,7 +181,9 @@ export default function Ribbon() {
return tab?.content && tab.content({
note,
hidden: !isActive,
ntxId
ntxId,
hoistedNoteId,
notePath
});
})}
</div>

View File

@ -4,4 +4,6 @@ export interface TabContext {
note: FNote | null | undefined;
hidden: boolean;
ntxId?: string | null | undefined;
hoistedNoteId?: string;
notePath?: string;
}

View File

@ -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 */

View File

@ -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*/`
<div class="note-paths-widget">
<style>
.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;
}
</style>
<div class="note-path-intro"></div>
<ul class="note-path-list"></ul>
<button class="btn btn-sm" data-trigger-command="cloneNoteIdsTo">${t("note_paths.clone_button")}</button>
</div>`;
export default class NotePathsWidget extends NoteContextAwareWidget {
private $notePathIntro!: JQuery<HTMLElement>;
private $notePathList!: JQuery<HTMLElement>;
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<HTMLElement>[] = [];
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 = $("<li>");
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(`<span class="bx bx-trending-up" title="${t("note_paths.outside_hoisted")}"></span>`);
}
if (notePathRecord?.isArchived) {
$pathItem.addClass("path-archived");
icons.push(`<span class="bx bx-archive" title="${t("note_paths.archived")}"></span>`);
}
if (notePathRecord?.isSearch) {
$pathItem.addClass("path-search");
icons.push(`<span class="bx bx-search" title="${t("note_paths.search")}"></span>`);
}
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();
}
}
}