mirror of
https://github.com/zadam/trilium.git
synced 2025-10-20 07:08:55 +02:00
feat(react/ribbon): port note paths tab
This commit is contained in:
parent
8287063aab
commit
8f69b87dd1
@ -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 && "+ "}
|
||||
|
@ -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} />
|
||||
|
||||
}
|
@ -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]), []);
|
||||
}
|
||||
|
@ -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 ? (
|
||||
|
@ -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";
|
||||
|
107
apps/client/src/widgets/ribbon/NotePathsTab.tsx
Normal file
107
apps/client/src/widgets/ribbon/NotePathsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
|
@ -4,4 +4,6 @@ export interface TabContext {
|
||||
note: FNote | null | undefined;
|
||||
hidden: boolean;
|
||||
ntxId?: string | null | undefined;
|
||||
hoistedNoteId?: string;
|
||||
notePath?: string;
|
||||
}
|
||||
|
@ -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 */
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user