Add breadcrumbs to navigation (#7995)

This commit is contained in:
Elian Doran 2025-12-09 13:15:03 +02:00 committed by GitHub
commit e688f2cdb6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 363 additions and 67 deletions

View File

@ -498,10 +498,6 @@ type EventMappings = {
noteIds: string[];
};
refreshData: { ntxId: string | null | undefined };
contentSafeMarginChanged: {
top: number;
noteContext: NoteContext;
}
};
export type EventListener<T extends EventNames> = {

View File

@ -44,6 +44,7 @@ import NoteDetail from "../widgets/NoteDetail.jsx";
import PromotedAttributes from "../widgets/PromotedAttributes.jsx";
import SpacerWidget from "../widgets/launch_bar/SpacerWidget.jsx";
import LauncherContainer from "../widgets/launch_bar/LauncherContainer.jsx";
import Breadcrumb from "../widgets/Breadcrumb.jsx";
export default class DesktopLayout {
@ -117,29 +118,37 @@ export default class DesktopLayout {
new NoteWrapperWidget()
.child(
new FlexContainer("row")
.class("title-row")
.css("height", "50px")
.css("min-height", "50px")
.class("breadcrumb-row")
.css("height", "30px")
.css("min-height", "30px")
.css("align-items", "center")
.cssBlock(".title-row > * { margin: 5px; }")
.child(<NoteIconWidget />)
.child(<NoteTitleWidget />)
.css("padding", "10px")
.cssBlock(".breadcrumb-row > * { margin: 5px; }")
.child(<Breadcrumb />)
.child(<SpacerWidget baseSize={0} growthFactor={1} />)
.child(<MovePaneButton direction="left" />)
.child(<MovePaneButton direction="right" />)
.child(<ClosePaneButton />)
.child(<CreatePaneButton />)
)
.child(<Ribbon />)
.child(new WatchedFileUpdateStatusWidget())
.child(<FloatingButtons items={DESKTOP_FLOATING_BUTTONS} />)
.child(
new ScrollingContainer()
.filling()
.child(new ContentHeader()
.child(new FlexContainer("row")
.class("title-row")
.css("height", "50px")
.css("min-height", "50px")
.css("align-items", "center")
.child(<NoteIconWidget />)
.child(<NoteTitleWidget />)
)
.child(<ReadOnlyNoteInfoBar />)
.child(<SharedInfo />)
)
.child(<Ribbon />)
.child(<PromotedAttributes />)
.child(<SqlTableSchemas />)
.child(<NoteDetail />)

View File

@ -99,7 +99,7 @@ async function createLink(notePath: string | undefined, options: CreateLinkOptio
const viewMode = viewScope.viewMode || "default";
let linkTitle = options.title;
if (!linkTitle) {
if (linkTitle === undefined) {
if (viewMode === "attachments" && viewScope.attachmentId) {
const attachment = await froca.getAttachment(viewScope.attachmentId);

View File

@ -24,8 +24,8 @@
--bs-body-font-family: var(--main-font-family) !important;
--bs-body-font-weight: var(--main-font-weight) !important;
--bs-body-color: var(--main-text-color) !important;
--bs-body-bg: var(--main-background-color) !important;
--ck-mention-list-max-height: 500px;
--bs-body-bg: var(--main-background-color) !important;
--ck-mention-list-max-height: 500px;
--tn-modal-max-height: 90vh;
--tree-item-light-theme-max-color-lightness: 50;
@ -471,7 +471,7 @@ body.mobile .dropdown .dropdown-submenu > span {
padding-inline-start: 12px;
}
.dropdown-menu kbd {
.dropdown-menu kbd {
color: var(--muted-text-color);
border: none;
background-color: transparent;
@ -487,7 +487,7 @@ body.mobile .dropdown .dropdown-submenu > span {
border: 1px solid transparent !important;
}
/* This is a workaround for Firefox not supporting break-before / break-after: avoid on columns.
/* This is a workaround for Firefox not supporting break-before / break-after: avoid on columns.
* It usually wraps a menu item followed by a separator / header and another menu item. */
.dropdown-no-break {
break-inside: avoid;
@ -1591,7 +1591,7 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
inset-inline-start: 0;
inset-inline-end: 0;
margin: 0 !important;
max-height: 85vh;
max-height: 85vh;
display: flex;
}
@ -2093,7 +2093,7 @@ body.zen .note-split.type-text .scrolling-container {
body.zen:not(.backdrop-effects-disabled) .note-split.type-text .scrolling-container {
--padding-top: 50px; /* Should be enough to cover the title row */
padding-top: var(--padding-top);
scroll-padding-top: var(--padding-top);
}
@ -2365,7 +2365,7 @@ footer.webview-footer button {
margin-bottom: 0;
}
.admonition::before {
.admonition::before {
color: var(--accent-color);
font-family: boxicons !important;
position: absolute;
@ -2391,7 +2391,7 @@ footer.webview-footer button {
.ck-content ul.todo-list li:has(> span.todo-list__label input[type="checkbox"]:checked) > span.todo-list__label span.todo-list__label__description {
text-decoration: line-through;
opacity: 0.6;
opacity: 0.6;
}
.chat-options-container {
@ -2524,6 +2524,7 @@ iframe.print-iframe {
position: relative;
flex-grow: 1;
width: 100%;
overflow: hidden;
}
/* Calendar collection */
@ -2538,7 +2539,7 @@ iframe.print-iframe {
body.mobile {
.split-note-container-widget {
flex-direction: column !important;
.note-split {
width: 100%;
}
@ -2553,4 +2554,4 @@ iframe.print-iframe {
opacity: 0.4;
}
}
}
}

View File

@ -164,7 +164,7 @@ ul.editability-dropdown li.dropdown-item > div {
background: var(--cmd-button-hover-background-color);
}
/*
/*
* Note info
*/
@ -177,4 +177,16 @@ ul.editability-dropdown li.dropdown-item > div {
/* Narrow width layout */
.note-info-widget {
container: info-section / inline-size;
}
}
/*
* Styling as a floating toolbar
*/
.ribbon-container {
position: sticky;
top: 0;
left: 0;
right: 0;
background: var(--main-background-color);
z-index: 997;
}

View File

@ -0,0 +1,55 @@
.breadcrumb-row {
position: relative;
}
.component.breadcrumb {
contain: none;
display: flex;
margin: 0;
align-items: center;
font-size: 0.9em;
gap: 0.25em;
flex-wrap: nowrap;
overflow: hidden;
max-width: 85%;
> span,
> span > span {
display: flex;
align-items: center;
min-width: 0;
a {
color: inherit;
text-decoration: none;
min-width: 0;
max-width: 150px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
display: block;
flex-shrink: 2;
}
}
> span:last-of-type a {
max-width: 300px;
flex-shrink: 1;
}
ul {
flex-direction: column;
list-style-type: none;
margin: 0;
padding: 0;
}
.dropdown-item span,
.dropdown-item strong {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
display: block;
max-width: 300px;
}
}

View File

@ -0,0 +1,166 @@
import "./Breadcrumb.css";
import { useMemo } from "preact/hooks";
import { Fragment } from "preact/jsx-runtime";
import NoteContext from "../components/note_context";
import froca from "../services/froca";
import ActionButton from "./react/ActionButton";
import Dropdown from "./react/Dropdown";
import { FormListItem } from "./react/FormList";
import { useChildNotes, useNoteContext, useNoteLabel, useNoteProperty } from "./react/hooks";
import Icon from "./react/Icon";
import NoteLink from "./react/NoteLink";
import link_context_menu from "../menus/link_context_menu";
const COLLAPSE_THRESHOLD = 5;
const INITIAL_ITEMS = 2;
const FINAL_ITEMS = 2;
export default function Breadcrumb() {
const { note, noteContext } = useNoteContext();
const notePath = buildNotePaths(noteContext?.notePathArray);
return (
<div className="breadcrumb">
{notePath.length > COLLAPSE_THRESHOLD ? (
<>
{notePath.slice(0, INITIAL_ITEMS).map((item, index) => (
<Fragment key={item}>
{index === 0
? <BreadcrumbRoot noteContext={noteContext} />
: <BreadcrumbItem notePath={item} />
}
<BreadcrumbSeparator notePath={item} activeNotePath={notePath[index + 1]} noteContext={noteContext} />
</Fragment>
))}
<BreadcrumbCollapsed items={notePath.slice(INITIAL_ITEMS, -FINAL_ITEMS)} noteContext={noteContext} />
{notePath.slice(-FINAL_ITEMS).map((item, index) => (
<Fragment key={item}>
<BreadcrumbSeparator notePath={notePath[notePath.length - FINAL_ITEMS - (1 - index)]} activeNotePath={item} noteContext={noteContext} />
<BreadcrumbItem notePath={item} />
</Fragment>
))}
</>
) : (
notePath.map((item, index) => (
<Fragment key={item}>
{index === 0
? <BreadcrumbRoot noteContext={noteContext} />
: <BreadcrumbItem notePath={item} />
}
{(index < notePath.length - 1 || note?.hasChildren()) &&
<BreadcrumbSeparator notePath={item} activeNotePath={notePath[index + 1]} noteContext={noteContext} />}
</Fragment>
))
)}
</div>
);
}
function BreadcrumbRoot({ noteContext }: { noteContext: NoteContext | undefined }) {
const note = useMemo(() => froca.getNoteFromCache("root"), []);
useNoteLabel(note, "iconClass");
const title = useNoteProperty(note, "title");
return (note &&
<ActionButton
icon={note.getIcon()}
text={title ?? ""}
onClick={() => noteContext?.setNote("root")}
onContextMenu={(e) => {
e.preventDefault();
link_context_menu.openContextMenu(note.noteId, e);
}}
/>
);
}
function BreadcrumbItem({ notePath }: { notePath: string }) {
return (
<NoteLink
notePath={notePath}
noPreview
/>
);
}
function BreadcrumbSeparator({ notePath, noteContext, activeNotePath }: { notePath: string, activeNotePath: string, noteContext: NoteContext | undefined }) {
return (
<Dropdown
text={<Icon icon="bx bx-chevron-right" />}
noSelectButtonStyle
buttonClassName="icon-action"
hideToggleArrow
dropdownOptions={{ popperConfig: { strategy: "fixed" } }}
>
<BreadcrumbSeparatorDropdownContent notePath={notePath} noteContext={noteContext} activeNotePath={activeNotePath} />
</Dropdown>
);
}
function BreadcrumbSeparatorDropdownContent({ notePath, noteContext, activeNotePath }: { notePath: string, activeNotePath: string, noteContext: NoteContext | undefined }) {
const notePathComponents = notePath.split("/");
const parentNoteId = notePathComponents.at(-1);
const childNotes = useChildNotes(parentNoteId);
return (
<ul className="breadcrumb-child-list">
{childNotes.map((note) => {
const childNotePath = `${notePath}/${note.noteId}`;
return <li key={note.noteId}>
<FormListItem
icon={note.getIcon()}
onClick={() => noteContext?.setNote(childNotePath)}
>
{childNotePath !== activeNotePath
? <span>{note.title}</span>
: <strong>{note.title}</strong>}
</FormListItem>
</li>;
})}
</ul>
);
}
function BreadcrumbCollapsed({ items, noteContext }: { items: string[], noteContext: NoteContext | undefined }) {
return (
<Dropdown
text={<Icon icon="bx bx-dots-horizontal-rounded" />}
noSelectButtonStyle
buttonClassName="icon-action"
hideToggleArrow
dropdownOptions={{ popperConfig: { strategy: "fixed" } }}
>
<ul className="breadcrumb-child-list">
{items.map((notePath) => {
const notePathComponents = notePath.split("/");
const noteId = notePathComponents[notePathComponents.length - 1];
const note = froca.getNoteFromCache(noteId);
if (!note) return null;
return <li key={note.noteId}>
<FormListItem
icon={note.getIcon()}
onClick={() => noteContext?.setNote(notePath)}
>
<span>{note.title}</span>
</FormListItem>
</li>;
})}
</ul>
</Dropdown>
);
}
function buildNotePaths(notePathArray: string[] | undefined) {
if (!notePathArray) return [];
let prefix = "";
const output: string[] = [];
for (const notePath of notePathArray) {
output.push(`${prefix}${notePath}`);
prefix += `${notePath}/`;
}
return output;
}

View File

@ -6,11 +6,12 @@
.floating-buttons-children,
.show-floating-buttons {
position: absolute;
top: var(--floating-buttons-vert-offset, 14px);
top: calc(var(--floating-buttons-vert-offset, 14px) + var(--ribbon-height, 0px) + var(--content-header-height, 0px));
inset-inline-end: var(--floating-buttons-horiz-offset, 10px);
display: flex;
flex-direction: row;
z-index: 100;
transition: top 0.3s ease;
}
.note-split.rtl .floating-buttons-children,

View File

@ -48,12 +48,6 @@ export default function FloatingButtons({ items }: FloatingButtonsProps) {
const [ visible, setVisible ] = useState(true);
useEffect(() => setVisible(true), [ note ]);
useTriliumEvent("contentSafeMarginChanged", (e) => {
if (e.noteContext === noteContext) {
setTop(e.top);
}
});
return (
<div className="floating-buttons no-print" style={{top}}>
<div className={`floating-buttons-children ${!visible ? "temporarily-hidden" : ""}`}>
@ -93,9 +87,9 @@ function CloseFloatingButton({ setVisible }: { setVisible(visible: boolean): voi
className="close-floating-buttons-button"
icon="bx bx-chevrons-right"
text={t("hide_floating_buttons_button.button_title")}
onClick={() => setVisible(false)}
onClick={() => setVisible(false)}
noIconActionClass
/>
</div>
);
}
}

View File

@ -2,9 +2,13 @@
position: absolute;
top: 1em;
right: 1em;
.floating-buttons-children {
top: 0;
}
}
.presentation-container {
width: 100%;
height: 100%;
}
}

View File

@ -0,0 +1,11 @@
.content-header-widget {
position: relative;
z-index: 8;
}
.content-header-widget.floating {
position: sticky;
top: 0;
z-index: 11;
background-color: var(--main-background-color, #fff);
}

View File

@ -2,15 +2,19 @@ import { EventData } from "../../components/app_context";
import BasicWidget from "../basic_widget";
import Container from "./container";
import NoteContext from "../../components/note_context";
import "./content_header.css";
export default class ContentHeader extends Container<BasicWidget> {
noteContext?: NoteContext;
thisElement?: HTMLElement;
parentElement?: HTMLElement;
resizeObserver: ResizeObserver;
currentHeight: number = 0;
currentSafeMargin: number = NaN;
previousScrollTop: number = 0;
isFloating: boolean = false;
scrollThreshold: number = 10; // pixels before triggering float
constructor() {
super();
@ -35,19 +39,39 @@ export default class ContentHeader extends Container<BasicWidget> {
this.thisElement = this.$widget.get(0)!;
this.resizeObserver.observe(this.thisElement);
this.parentElement.addEventListener("scroll", this.updateSafeMargin.bind(this));
this.parentElement.addEventListener("scroll", this.updateScrollState.bind(this), { passive: true });
}
updateScrollState() {
const currentScrollTop = this.parentElement!.scrollTop;
const isScrollingUp = currentScrollTop < this.previousScrollTop;
const hasMovedEnough = Math.abs(currentScrollTop - this.previousScrollTop) > this.scrollThreshold;
if (hasMovedEnough) {
this.setFloating(isScrollingUp);
}
this.previousScrollTop = currentScrollTop;
this.updateSafeMargin();
}
setFloating(shouldFloat: boolean) {
if (shouldFloat !== this.isFloating) {
this.isFloating = shouldFloat;
if (shouldFloat) {
this.$widget.addClass("floating");
} else {
this.$widget.removeClass("floating");
}
}
}
updateSafeMargin() {
const newSafeMargin = Math.max(this.currentHeight - this.parentElement!.scrollTop, 0);
if (newSafeMargin !== this.currentSafeMargin) {
this.currentSafeMargin = newSafeMargin;
this.triggerEvent("contentSafeMarginChanged", {
top: newSafeMargin,
noteContext: this.noteContext!
});
const parentEl = this.parentElement?.closest<HTMLDivElement>(".note-split");
if (this.isFloating || this.parentElement!.scrollTop === 0) {
parentEl!.style.setProperty("--content-header-height", `${this.currentHeight}px`);
} else {
parentEl!.style.removeProperty("--content-header-height");
}
}
@ -60,4 +84,4 @@ export default class ContentHeader extends Container<BasicWidget> {
}
}
}
}

View File

@ -1,5 +1,5 @@
.note-icon-widget {
padding-inline-start: 7px;
padding-inline-start: 10px;
margin-inline-end: 0;
width: 50px;
height: 50px;
@ -13,7 +13,7 @@
cursor: pointer;
color: var(--muted-text-color);
}
.note-icon-widget button.note-icon:disabled {
cursor: default;
opacity: .75;
@ -68,4 +68,4 @@
border: 1px dashed var(--muted-text-color);
width: 1em;
height: 1em;
}
}

View File

@ -27,4 +27,4 @@ body.mobile .note-title-widget input.note-title {
body.desktop .note-title-widget input.note-title {
font-size: 180%;
}
}

View File

@ -17,7 +17,6 @@
.info-bar-subtle {
color: var(--muted-text-color);
background: var(--main-background-color);
border-bottom: 1px solid var(--main-border-color);
margin-block: 0;
padding-inline: 22px;
}
}

View File

@ -886,12 +886,15 @@ async function isNoteReadOnly(note: FNote, noteContext: NoteContext) {
return true;
}
export function useChildNotes(parentNoteId: string) {
export function useChildNotes(parentNoteId: string | undefined) {
const [ childNotes, setChildNotes ] = useState<FNote[]>([]);
useEffect(() => {
(async function() {
const parentNote = await froca.getNote(parentNoteId);
const childNotes = await parentNote?.getChildNotes();
let childNotes: FNote[] | undefined;
if (parentNoteId) {
const parentNote = await froca.getNote(parentNoteId);
childNotes = await parentNote?.getChildNotes();
}
setChildNotes(childNotes ?? []);
})();
}, [ parentNoteId ]);

View File

@ -44,7 +44,7 @@ export function disposeReactWidget(container: Element) {
render(null, container);
}
export function joinElements(components: ComponentChild[] | undefined, separator = ", ") {
export function joinElements(components: ComponentChild[] | undefined, separator: ComponentChild = ", ") {
if (!components) return <></>;
const joinedComponents: ComponentChild[] = [];

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { useNoteContext, useNoteProperty, useStaticTooltipWithKeyboardShortcut, useTriliumEvents } from "../react/hooks";
import { useElementSize, useNoteContext, useNoteProperty, useStaticTooltipWithKeyboardShortcut, useTriliumEvents } from "../react/hooks";
import "./style.css";
import { Indexed, numberObjectsInPlace } from "../../services/utils";
@ -42,6 +42,16 @@ export default function Ribbon() {
refresh();
}, [ note, noteType, isReadOnlyTemporarilyDisabled ]);
// Manage height.
const containerRef = useRef<HTMLDivElement>(null);
const size = useElementSize(containerRef);
useEffect(() => {
if (!containerRef.current || !size) return;
const parentEl = containerRef.current.closest<HTMLDivElement>(".note-split");
if (!parentEl) return;
parentEl.style.setProperty("--ribbon-height", `${size.height}px`);
}, [ size ]);
// Automatically activate the first ribbon tab that needs to be activated whenever a note changes.
useEffect(() => {
if (!computedTabs) return;
@ -65,6 +75,7 @@ export default function Ribbon() {
return (
<div
ref={containerRef}
className={clsx("ribbon-container", noteContext?.viewScope?.viewMode !== "default" && "hidden-ext")}
style={{ contain: "none" }}
>

View File

@ -1,5 +1,14 @@
.ribbon-container {
margin-bottom: 5px;
position: relative;
z-index: 8;
}
/* When content header is floating, ribbon sticks below it */
.scrolling-container:has(.content-header-widget.floating) .ribbon-container {
position: sticky;
top: var(--content-header-height, 100px);
z-index: 10;
}
.ribbon-top-row {
@ -24,12 +33,14 @@
max-width: max-content;
flex-grow: 10;
user-select: none;
display: flex;
align-items: center;
font-size: 0.9em;
}
.ribbon-tab-title .bx {
font-size: 150%;
font-size: 125%;
position: relative;
top: 3px;
}
.ribbon-tab-title.active {
@ -71,12 +82,9 @@
display: flex;
border-bottom: 1px solid var(--main-border-color);
margin-inline-end: 5px;
}
.ribbon-button-container > * {
position: relative;
top: -3px;
margin-inline-start: 10px;
align-items: center;
height: 36px;
gap: 10px;
}
.ribbon-body {
@ -386,6 +394,8 @@ body[dir=rtl] .attribute-list-editor {
.note-actions {
width: 35px;
height: 35px;
display: flex;
align-items: center;
}
.note-actions .dropdown-menu {
@ -404,4 +414,4 @@ body[dir=rtl] .attribute-list-editor {
background-color: transparent !important;
pointer-events: none; /* makes it unclickable */
}
/* #endregion */
/* #endregion */

View File

@ -63,7 +63,7 @@ const mainConfig = [
}
},
{
files: ["**/*.{js,ts,mjs,cjs}"],
files: ["**/*.{js,ts,mjs,cjs,tsx}"],
languageOptions: {
parser: tsParser