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

@ -2524,6 +2524,7 @@ iframe.print-iframe {
position: relative;
flex-grow: 1;
width: 100%;
overflow: hidden;
}
/* Calendar collection */

View File

@ -178,3 +178,15 @@ ul.editability-dropdown li.dropdown-item > div {
.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" : ""}`}>

View File

@ -2,6 +2,10 @@
position: absolute;
top: 1em;
right: 1em;
.floating-buttons-children {
top: 0;
}
}
.presentation-container {

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,6 +2,7 @@ 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> {
@ -11,6 +12,9 @@ export default class ContentHeader extends Container<BasicWidget> {
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");
}
}

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;

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 {

View File

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