diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index 5cd4eecbe..bd6f0473a 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -498,10 +498,6 @@ type EventMappings = { noteIds: string[]; }; refreshData: { ntxId: string | null | undefined }; - contentSafeMarginChanged: { - top: number; - noteContext: NoteContext; - } }; export type EventListener = { diff --git a/apps/client/src/layouts/desktop_layout.tsx b/apps/client/src/layouts/desktop_layout.tsx index 50dc05d99..41a6bb68f 100644 --- a/apps/client/src/layouts/desktop_layout.tsx +++ b/apps/client/src/layouts/desktop_layout.tsx @@ -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() - .child() + .css("padding", "10px") + .cssBlock(".breadcrumb-row > * { margin: 5px; }") + .child() .child() .child() .child() .child() .child() ) - .child() .child(new WatchedFileUpdateStatusWidget()) .child() .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() + .child() + ) .child() .child() ) + .child() .child() .child() .child() diff --git a/apps/client/src/services/link.ts b/apps/client/src/services/link.ts index a596e7136..79bc18c2a 100644 --- a/apps/client/src/services/link.ts +++ b/apps/client/src/services/link.ts @@ -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); diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css index f2f554629..ce76ad286 100644 --- a/apps/client/src/stylesheets/style.css +++ b/apps/client/src/stylesheets/style.css @@ -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; } } -} \ No newline at end of file +} diff --git a/apps/client/src/stylesheets/theme-next/ribbon.css b/apps/client/src/stylesheets/theme-next/ribbon.css index 3718602cf..a518e05dc 100644 --- a/apps/client/src/stylesheets/theme-next/ribbon.css +++ b/apps/client/src/stylesheets/theme-next/ribbon.css @@ -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; -} \ No newline at end of file +} + +/* + * Styling as a floating toolbar + */ +.ribbon-container { + position: sticky; + top: 0; + left: 0; + right: 0; + background: var(--main-background-color); + z-index: 997; +} diff --git a/apps/client/src/widgets/Breadcrumb.css b/apps/client/src/widgets/Breadcrumb.css new file mode 100644 index 000000000..9739f33db --- /dev/null +++ b/apps/client/src/widgets/Breadcrumb.css @@ -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; + } +} diff --git a/apps/client/src/widgets/Breadcrumb.tsx b/apps/client/src/widgets/Breadcrumb.tsx new file mode 100644 index 000000000..38efb1364 --- /dev/null +++ b/apps/client/src/widgets/Breadcrumb.tsx @@ -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 ( +
+ {notePath.length > COLLAPSE_THRESHOLD ? ( + <> + {notePath.slice(0, INITIAL_ITEMS).map((item, index) => ( + + {index === 0 + ? + : + } + + + ))} + + {notePath.slice(-FINAL_ITEMS).map((item, index) => ( + + + + + ))} + + ) : ( + notePath.map((item, index) => ( + + {index === 0 + ? + : + } + {(index < notePath.length - 1 || note?.hasChildren()) && + } + + )) + )} +
+ ); +} + +function BreadcrumbRoot({ noteContext }: { noteContext: NoteContext | undefined }) { + const note = useMemo(() => froca.getNoteFromCache("root"), []); + useNoteLabel(note, "iconClass"); + const title = useNoteProperty(note, "title"); + + return (note && + noteContext?.setNote("root")} + onContextMenu={(e) => { + e.preventDefault(); + link_context_menu.openContextMenu(note.noteId, e); + }} + /> + ); +} + +function BreadcrumbItem({ notePath }: { notePath: string }) { + return ( + + ); +} + +function BreadcrumbSeparator({ notePath, noteContext, activeNotePath }: { notePath: string, activeNotePath: string, noteContext: NoteContext | undefined }) { + return ( + } + noSelectButtonStyle + buttonClassName="icon-action" + hideToggleArrow + dropdownOptions={{ popperConfig: { strategy: "fixed" } }} + > + + + ); +} + +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 ( +
    + {childNotes.map((note) => { + const childNotePath = `${notePath}/${note.noteId}`; + return
  • + noteContext?.setNote(childNotePath)} + > + {childNotePath !== activeNotePath + ? {note.title} + : {note.title}} + +
  • ; + })} +
+ ); +} + +function BreadcrumbCollapsed({ items, noteContext }: { items: string[], noteContext: NoteContext | undefined }) { + return ( + } + noSelectButtonStyle + buttonClassName="icon-action" + hideToggleArrow + dropdownOptions={{ popperConfig: { strategy: "fixed" } }} + > +
    + {items.map((notePath) => { + const notePathComponents = notePath.split("/"); + const noteId = notePathComponents[notePathComponents.length - 1]; + const note = froca.getNoteFromCache(noteId); + if (!note) return null; + + return
  • + noteContext?.setNote(notePath)} + > + {note.title} + +
  • ; + })} +
+
+ ); +} + +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; +} diff --git a/apps/client/src/widgets/FloatingButtons.css b/apps/client/src/widgets/FloatingButtons.css index b61df46da..46ed6dd80 100644 --- a/apps/client/src/widgets/FloatingButtons.css +++ b/apps/client/src/widgets/FloatingButtons.css @@ -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, diff --git a/apps/client/src/widgets/FloatingButtons.tsx b/apps/client/src/widgets/FloatingButtons.tsx index f38039afd..85b6d5817 100644 --- a/apps/client/src/widgets/FloatingButtons.tsx +++ b/apps/client/src/widgets/FloatingButtons.tsx @@ -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 (
@@ -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 />
); -} \ No newline at end of file +} diff --git a/apps/client/src/widgets/collections/presentation/index.css b/apps/client/src/widgets/collections/presentation/index.css index 5aafffd9f..ac3d628c3 100644 --- a/apps/client/src/widgets/collections/presentation/index.css +++ b/apps/client/src/widgets/collections/presentation/index.css @@ -2,9 +2,13 @@ position: absolute; top: 1em; right: 1em; + + .floating-buttons-children { + top: 0; + } } .presentation-container { width: 100%; height: 100%; -} \ No newline at end of file +} diff --git a/apps/client/src/widgets/containers/content_header.css b/apps/client/src/widgets/containers/content_header.css new file mode 100644 index 000000000..060abde12 --- /dev/null +++ b/apps/client/src/widgets/containers/content_header.css @@ -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); +} diff --git a/apps/client/src/widgets/containers/content_header.ts b/apps/client/src/widgets/containers/content_header.ts index ac001d40a..32b2d13a4 100644 --- a/apps/client/src/widgets/containers/content_header.ts +++ b/apps/client/src/widgets/containers/content_header.ts @@ -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 { - + 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 { 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(".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 { } } -} \ No newline at end of file +} diff --git a/apps/client/src/widgets/note_icon.css b/apps/client/src/widgets/note_icon.css index a3be50dc6..2163de8e5 100644 --- a/apps/client/src/widgets/note_icon.css +++ b/apps/client/src/widgets/note_icon.css @@ -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; -} \ No newline at end of file +} diff --git a/apps/client/src/widgets/note_title.css b/apps/client/src/widgets/note_title.css index dd56edf96..477d93209 100644 --- a/apps/client/src/widgets/note_title.css +++ b/apps/client/src/widgets/note_title.css @@ -27,4 +27,4 @@ body.mobile .note-title-widget input.note-title { body.desktop .note-title-widget input.note-title { font-size: 180%; -} \ No newline at end of file +} diff --git a/apps/client/src/widgets/react/InfoBar.css b/apps/client/src/widgets/react/InfoBar.css index cf9774e0b..4a7a859db 100644 --- a/apps/client/src/widgets/react/InfoBar.css +++ b/apps/client/src/widgets/react/InfoBar.css @@ -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; -} \ No newline at end of file +} diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 24a7948dc..0c9883df2 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -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([]); 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 ]); diff --git a/apps/client/src/widgets/react/react_utils.tsx b/apps/client/src/widgets/react/react_utils.tsx index 468a0e73e..3bbce9066 100644 --- a/apps/client/src/widgets/react/react_utils.tsx +++ b/apps/client/src/widgets/react/react_utils.tsx @@ -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[] = []; diff --git a/apps/client/src/widgets/ribbon/Ribbon.tsx b/apps/client/src/widgets/ribbon/Ribbon.tsx index 60b2e2667..55ade760c 100644 --- a/apps/client/src/widgets/ribbon/Ribbon.tsx +++ b/apps/client/src/widgets/ribbon/Ribbon.tsx @@ -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(null); + const size = useElementSize(containerRef); + useEffect(() => { + if (!containerRef.current || !size) return; + const parentEl = containerRef.current.closest(".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 (
diff --git a/apps/client/src/widgets/ribbon/style.css b/apps/client/src/widgets/ribbon/style.css index 290d1b30e..07619c89d 100644 --- a/apps/client/src/widgets/ribbon/style.css +++ b/apps/client/src/widgets/ribbon/style.css @@ -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 */ \ No newline at end of file +/* #endregion */ diff --git a/eslint.config.mjs b/eslint.config.mjs index 3a2254d69..8d9119044 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -63,7 +63,7 @@ const mainConfig = [ } }, { - files: ["**/*.{js,ts,mjs,cjs}"], + files: ["**/*.{js,ts,mjs,cjs,tsx}"], languageOptions: { parser: tsParser