diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index e404651e6..611f527fd 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1759,11 +1759,11 @@ "note_title": { "placeholder": "type note's title here...", "created_on": "Created on ", - "last_modified": "Last modified on ", + "last_modified": "Modified on ", "note_type_switcher_label": "Switch from {{type}} to:", - "note_type_switcher_others": "More note types", - "note_type_switcher_templates": "Templates", - "note_type_switcher_collection": "Collections", + "note_type_switcher_others": "Other note type", + "note_type_switcher_templates": "Template", + "note_type_switcher_collection": "Collection", "edited_notes": "Edited notes" }, "search_result": { @@ -2144,6 +2144,12 @@ "go-back": "Go back to previous note", "go-forward": "Go forward to next note" }, + "breadcrumb": { + "hoisted_badge": "Hoisted", + "hoisted_badge_title": "Unhoist", + "workspace_badge": "Workspace", + "scroll_to_top_title": "Jump to the beginning of the note" + }, "breadcrumb_badges": { "read_only_explicit": "Read-only", "read_only_explicit_description": "This note has been manually set to read-only.\nClick to edit it temporarily.", diff --git a/apps/client/src/widgets/layout/Breadcrumb.css b/apps/client/src/widgets/layout/Breadcrumb.css index d98b1ed91..436b9cb0f 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.css +++ b/apps/client/src/widgets/layout/Breadcrumb.css @@ -8,6 +8,12 @@ gap: 0.25em; flex-wrap: nowrap; overflow: hidden; + --badge-radius: 6px; + + .badge-hoisted { + --color: var(--input-background-color); + color: var(--main-text-color); + } > span, > span > span { @@ -15,6 +21,10 @@ align-items: center; min-width: 0; + .bx { + margin-inline: 6px; + } + a { color: inherit; text-decoration: none; diff --git a/apps/client/src/widgets/layout/Breadcrumb.tsx b/apps/client/src/widgets/layout/Breadcrumb.tsx index 5f0397c7e..a814fb13a 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.tsx +++ b/apps/client/src/widgets/layout/Breadcrumb.tsx @@ -1,17 +1,21 @@ import "./Breadcrumb.css"; -import { useMemo, useState } from "preact/hooks"; +import { useRef, useState } from "preact/hooks"; import { Fragment } from "preact/jsx-runtime"; import appContext from "../../components/app_context"; import NoteContext from "../../components/note_context"; import FNote from "../../entities/fnote"; import link_context_menu from "../../menus/link_context_menu"; +import { getReadableTextColor } from "../../services/css_class_manager"; import froca from "../../services/froca"; +import hoisted_note from "../../services/hoisted_note"; +import { t } from "../../services/i18n"; import ActionButton from "../react/ActionButton"; +import { Badge } from "../react/Badge"; import Dropdown from "../react/Dropdown"; import { FormListItem } from "../react/FormList"; -import { useChildNotes, useNoteLabel, useNoteProperty } from "../react/hooks"; +import { useChildNotes, useNote, useNoteIcon, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useStaticTooltip } from "../react/hooks"; import Icon from "../react/Icon"; import NoteLink from "../react/NoteLink"; @@ -20,7 +24,7 @@ const INITIAL_ITEMS = 2; const FINAL_ITEMS = 2; export default function Breadcrumb({ note, noteContext }: { note: FNote, noteContext: NoteContext }) { - const notePath = buildNotePaths(noteContext?.notePathArray); + const notePath = buildNotePaths(noteContext); return (
@@ -57,41 +61,73 @@ export default function Breadcrumb({ note, noteContext }: { note: FNote, noteCon } function BreadcrumbRoot({ noteContext }: { noteContext: NoteContext | undefined }) { - const note = useMemo(() => froca.getNoteFromCache("root"), []); - useNoteLabel(note, "iconClass"); - const title = useNoteProperty(note, "title"); + const noteId = noteContext?.hoistedNoteId ?? "root"; + if (noteId !== "root") { + return ; + } + // Root note is icon only. + const note = froca.getNoteFromCache("root"); return (note && noteContext?.setNote("root")} + text={""} + onClick={() => noteContext?.setNote(note.noteId)} onContextMenu={(e) => { e.preventDefault(); link_context_menu.openContextMenu(note.noteId, e); }} /> ); + } -function BreadcrumbLink({ notePath }: { notePath: string }) { - return ( - +function BreadcrumbHoistedNoteRoot({ noteId }: { noteId: string }) { + const note = useNote(noteId); + const noteIcon = useNoteIcon(note); + const [ workspace ] = useNoteLabelBoolean(note, "workspace"); + const [ workspaceIconClass ] = useNoteLabel(note, "workspaceIconClass"); + const [ workspaceColor ] = useNoteLabel(note, "workspaceTabBackgroundColor"); + + // Hoisted workspace shows both text and icon and a way to exit easily out of the hoisting. + return (note && + <> + hoisted_note.unhoist()} + style={workspaceColor ? { + "--color": workspaceColor, + "color": getReadableTextColor(workspaceColor) + } : undefined} + /> + + ); } function BreadcrumbLastItem({ notePath }: { notePath: string }) { + const linkRef = useRef(null); const noteId = notePath.split("/").at(-1); const [ note ] = useState(() => froca.getNoteFromCache(noteId!)); const title = useNoteProperty(note, "title"); + useStaticTooltip(linkRef, { + placement: "top", + title: t("breadcrumb.scroll_to_top_title") + }); if (!note) return null; return ( { @@ -114,7 +150,7 @@ function BreadcrumbItem({ index, notePath, noteContext, notePathLength }: { inde ; } - return ; + return ; } function BreadcrumbSeparator({ notePath, noteContext, activeNotePath }: { notePath: string, activeNotePath: string, noteContext: NoteContext | undefined }) { @@ -187,14 +223,27 @@ function BreadcrumbCollapsed({ items, noteContext }: { items: string[], noteCont ); } -function buildNotePaths(notePathArray: string[] | undefined) { +function buildNotePaths(noteContext: NoteContext) { + const notePathArray = noteContext.notePathArray; if (!notePathArray) return []; let prefix = ""; - const output: string[] = []; + let output: string[] = []; + let pos = 0; + let hoistedNotePos = -1; for (const notePath of notePathArray) { + if (noteContext.hoistedNoteId !== "root" && notePath === noteContext.hoistedNoteId) { + hoistedNotePos = pos; + } output.push(`${prefix}${notePath}`); prefix += `${notePath}/`; + pos++; } + + // When hoisted, display only the path starting with the hoisted note. + if (noteContext.hoistedNoteId !== "root" && hoistedNotePos > -1) { + output = output.slice(hoistedNotePos); + } + return output; } diff --git a/apps/client/src/widgets/layout/InlineTitle.css b/apps/client/src/widgets/layout/InlineTitle.css index c39322957..be153dbb0 100644 --- a/apps/client/src/widgets/layout/InlineTitle.css +++ b/apps/client/src/widgets/layout/InlineTitle.css @@ -62,6 +62,7 @@ body.prefers-centered-content .inline-title { margin-top: 4px; list-style-type: none; opacity: .5; + flex-wrap: wrap; span.value { font-weight: 500; diff --git a/apps/client/src/widgets/layout/NoteBadges.tsx b/apps/client/src/widgets/layout/NoteBadges.tsx index 0959b773c..b2a51a6a7 100644 --- a/apps/client/src/widgets/layout/NoteBadges.tsx +++ b/apps/client/src/widgets/layout/NoteBadges.tsx @@ -1,15 +1,9 @@ import "./NoteBadges.css"; -import clsx from "clsx"; -import { ComponentChildren, MouseEventHandler } from "preact"; -import { useRef } from "preact/hooks"; - import { t } from "../../services/i18n"; -import Dropdown, { DropdownProps } from "../react/Dropdown"; -import { useIsNoteReadOnly, useNoteContext, useNoteLabel, useNoteLabelBoolean, useStaticTooltip } from "../react/hooks"; -import Icon from "../react/Icon"; -import { useShareInfo } from "../shared_info"; import { Badge } from "../react/Badge"; +import { useIsNoteReadOnly, useNoteContext, useNoteLabel, useNoteLabelBoolean } from "../react/hooks"; +import { useShareInfo } from "../shared_info"; export default function NoteBadges() { return ( diff --git a/apps/client/src/widgets/react/Badge.css b/apps/client/src/widgets/react/Badge.css index 83f3f05ef..cf8542a11 100644 --- a/apps/client/src/widgets/react/Badge.css +++ b/apps/client/src/widgets/react/Badge.css @@ -39,6 +39,16 @@ .ext-badge { border-radius: 0; + + .text { + display: inline-flex; + align-items: center; + + .arrow { + font-size: 1.3em; + margin-left: 0.25em; + } + } } .btn { diff --git a/apps/client/src/widgets/react/Badge.tsx b/apps/client/src/widgets/react/Badge.tsx index a03e6b5fb..f45f377e5 100644 --- a/apps/client/src/widgets/react/Badge.tsx +++ b/apps/client/src/widgets/react/Badge.tsx @@ -1,7 +1,7 @@ import "./Badge.css"; import clsx from "clsx"; -import { ComponentChildren, MouseEventHandler } from "preact"; +import { ComponentChildren, HTMLAttributes } from "preact"; import { useRef } from "preact/hooks"; import Dropdown, { DropdownProps } from "./Dropdown"; @@ -13,12 +13,11 @@ interface SimpleBadgeProps { title: ComponentChildren; } -interface BadgeProps { - text?: string; +interface BadgeProps extends Pick, "onClick" | "style"> { + text?: ComponentChildren; icon?: string; className?: string; tooltip?: string; - onClick?: MouseEventHandler; href?: string; } @@ -26,7 +25,7 @@ export default function SimpleBadge({ title, className }: SimpleBadgeProps) { return {title}; } -export function Badge({ icon, className, text, tooltip, onClick, href }: BadgeProps) { +export function Badge({ icon, className, text, tooltip, href, ...containerProps }: BadgeProps) { const containerRef = useRef(null); useStaticTooltip(containerRef, { placement: "bottom", @@ -44,22 +43,26 @@ export function Badge({ icon, className, text, tooltip, onClick, href }: BadgePr return (
{href ? {content} : {content}}
); } -export function BadgeWithDropdown({ children, tooltip, className, dropdownOptions, ...props }: BadgeProps & { +export function BadgeWithDropdown({ text, children, tooltip, className, dropdownOptions, ...props }: BadgeProps & { children: ComponentChildren, dropdownOptions?: Partial }) { return ( } + text={{text} } + className={className} + {...props} + />} noDropdownListStyle noSelectButtonStyle hideToggleArrow diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 0b9546694..d01fb8156 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -1,29 +1,30 @@ -import { CSSProperties } from "preact/compat"; -import { DragData } from "../note_tree"; import { FilterLabelsByType, KeyboardActionNames, OptionNames, RelationNames } from "@triliumnext/commons"; -import { MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; -import { NoteContextContext, ParentComponent, refToJQuerySelector } from "./react_utils"; -import { RefObject, VNode } from "preact"; import { Tooltip } from "bootstrap"; -import { ViewMode, ViewScope } from "../../services/link"; +import Mark from "mark.js"; +import { RefObject, VNode } from "preact"; +import { CSSProperties } from "preact/compat"; +import { MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; + import appContext, { EventData, EventNames } from "../../components/app_context"; -import attributes from "../../services/attributes"; -import BasicWidget, { ReactWrappedWidget } from "../basic_widget"; import Component from "../../components/component"; +import NoteContext from "../../components/note_context"; import FBlob from "../../entities/fblob"; import FNote from "../../entities/fnote"; +import attributes from "../../services/attributes"; +import froca from "../../services/froca"; import keyboard_actions from "../../services/keyboard_actions"; -import Mark from "mark.js"; -import NoteContext from "../../components/note_context"; -import NoteContextAwareWidget from "../note_context_aware_widget"; +import { ViewScope } from "../../services/link"; import options, { type OptionValue } from "../../services/options"; import protected_session_holder from "../../services/protected_session_holder"; +import server from "../../services/server"; +import shortcuts, { Handler, removeIndividualBinding } from "../../services/shortcuts"; import SpacedUpdate from "../../services/spaced_update"; import toast, { ToastOptions } from "../../services/toast"; import utils, { escapeRegExp, randomString, reloadFrontendApp } from "../../services/utils"; -import server from "../../services/server"; -import shortcuts, { Handler, removeIndividualBinding } from "../../services/shortcuts"; -import froca from "../../services/froca"; +import BasicWidget, { ReactWrappedWidget } from "../basic_widget"; +import NoteContextAwareWidget from "../note_context_aware_widget"; +import { DragData } from "../note_tree"; +import { NoteContextContext, ParentComponent, refToJQuerySelector } from "./react_utils"; export function useTriliumEvent(eventName: T, handler: (data: EventData) => void) { const parentComponent = useContext(ParentComponent); @@ -42,7 +43,7 @@ export function useTriliumEvents(eventNames: T[], handler: for (const eventName of eventNames) { handlers.push({ eventName, callback: (data) => { handler(data, eventName); - }}) + }}); } for (const { eventName, callback } of handlers) { @@ -111,8 +112,8 @@ export function useEditorSpacedUpdate({ note, noteContext, getData, onContentCha await server.put(`notes/${note.noteId}/data`, data, parentComponent?.componentId); dataSaved?.(data); - } - }, [ note, getData, dataSaved ]) + }; + }, [ note, getData, dataSaved ]); const spacedUpdate = useSpacedUpdate(callback); // React to note/blob changes. @@ -137,7 +138,7 @@ export function useEditorSpacedUpdate({ note, noteContext, getData, onContentCha useTriliumEvent("beforeNoteContextRemove", async ({ ntxIds }) => { if (!noteContext?.ntxId || !ntxIds.includes(noteContext.ntxId)) return; await spacedUpdate.updateNowIfNecessary(); - }) + }); // Save if needed upon window/browser closing. useEffect(() => { @@ -170,7 +171,7 @@ export function useTriliumOption(name: OptionNames, needsRefresh?: boolean): [st if (needsRefresh) { reloadFrontendApp(`option change: ${name}`); } - } + }; }, [ name, needsRefresh ]); useTriliumEvent("entitiesReloaded", useCallback(({ loadResults }) => { @@ -178,14 +179,14 @@ export function useTriliumOption(name: OptionNames, needsRefresh?: boolean): [st const newValue = options.get(name); setValue(newValue); } - }, [ name, setValue ])); + }, [ name, setValue ])); useDebugValue(name); return [ value, wrappedSetValue - ] + ]; } /** @@ -266,7 +267,7 @@ export function useTriliumOptions(...names: T[]) { * @returns a name with the given prefix and a random alpanumeric string appended to it. */ export function useUniqueName(prefix?: string) { - return useMemo(() => (prefix ? prefix + "-" : "") + utils.randomString(10), [ prefix ]); + return useMemo(() => (prefix ? `${prefix}-` : "") + utils.randomString(10), [ prefix ]); } export function useNoteContext() { @@ -274,6 +275,7 @@ export function useNoteContext() { const [ noteContext, setNoteContext ] = useState(noteContextContext ?? undefined); const [ notePath, setNotePath ] = useState(); const [ note, setNote ] = useState(); + const [ hoistedNoteId, setHoistedNoteId ] = useState(noteContext?.hoistedNoteId); const [ , setViewScope ] = useState(); const [ isReadOnlyTemporarilyDisabled, setIsReadOnlyTemporarilyDisabled ] = useState(noteContext?.viewScope?.isReadOnly); const [ refreshCounter, setRefreshCounter ] = useState(0); @@ -281,6 +283,7 @@ export function useNoteContext() { useEffect(() => { if (!noteContextContext) return; setNoteContext(noteContextContext); + setHoistedNoteId(noteContextContext.hoistedNoteId); setNote(noteContextContext.note); setNotePath(noteContextContext.notePath); setViewScope(noteContextContext.viewScope); @@ -294,6 +297,7 @@ export function useNoteContext() { useTriliumEvents([ "setNoteContext", "activeContextChanged", "noteSwitchedAndActivated", "noteSwitched" ], ({ noteContext }) => { if (noteContextContext) return; setNoteContext(noteContext); + setHoistedNoteId(noteContext.hoistedNoteId); setNotePath(noteContext.notePath); setViewScope(noteContext.viewScope); }); @@ -311,6 +315,11 @@ export function useNoteContext() { setIsReadOnlyTemporarilyDisabled(eventNoteContext?.viewScope?.readOnlyTemporarilyDisabled); } }); + useTriliumEvent("hoistedNoteChanged", ({ noteId, ntxId }) => { + if (ntxId === noteContext?.ntxId) { + setHoistedNoteId(noteId); + } + }); const parentComponent = useContext(ParentComponent) as ReactWrappedWidget; useDebugValue(() => `notePath=${notePath}, ntxId=${noteContext?.ntxId}`); @@ -319,7 +328,7 @@ export function useNoteContext() { note, noteId: noteContext?.note?.noteId, notePath: noteContext?.notePath, - hoistedNoteId: noteContext?.hoistedNoteId, + hoistedNoteId, ntxId: noteContext?.ntxId, viewScope: noteContext?.viewScope, componentId: parentComponent.componentId, @@ -338,6 +347,7 @@ export function useActiveNoteContext() { const [ notePath, setNotePath ] = useState(); const [ note, setNote ] = useState(); const [ , setViewScope ] = useState(); + const [ hoistedNoteId, setHoistedNoteId ] = useState(noteContext?.hoistedNoteId); const [ isReadOnlyTemporarilyDisabled, setIsReadOnlyTemporarilyDisabled ] = useState(noteContext?.viewScope?.isReadOnly); const [ refreshCounter, setRefreshCounter ] = useState(0); @@ -354,6 +364,7 @@ export function useActiveNoteContext() { useTriliumEvents([ "setNoteContext", "activeContextChanged", "noteSwitchedAndActivated", "noteSwitched" ], () => { const noteContext = appContext.tabManager.getActiveContext() ?? undefined; setNoteContext(noteContext); + setHoistedNoteId(noteContext?.hoistedNoteId); setNotePath(noteContext?.notePath); setViewScope(noteContext?.viewScope); }); @@ -370,6 +381,11 @@ export function useActiveNoteContext() { setIsReadOnlyTemporarilyDisabled(eventNoteContext?.viewScope?.readOnlyTemporarilyDisabled); } }); + useTriliumEvent("hoistedNoteChanged", ({ noteId, ntxId }) => { + if (ntxId === noteContext?.ntxId) { + setHoistedNoteId(noteId); + } + }); const parentComponent = useContext(ParentComponent) as ReactWrappedWidget; useDebugValue(() => `notePath=${notePath}, ntxId=${noteContext?.ntxId}`); @@ -378,7 +394,7 @@ export function useActiveNoteContext() { note, noteId: noteContext?.note?.noteId, notePath: noteContext?.notePath, - hoistedNoteId: noteContext?.hoistedNoteId, + hoistedNoteId, ntxId: noteContext?.ntxId, viewScope: noteContext?.viewScope, componentId: parentComponent.componentId, @@ -428,7 +444,7 @@ export function useNoteRelation(note: FNote | undefined | null, relationName: Re const setter = useCallback((value: string | undefined) => { if (note) { - attributes.setAttribute(note, "relation", relationName, value) + attributes.setAttribute(note, "relation", relationName, value); } }, [note]); @@ -478,7 +494,7 @@ export function useNoteLabel(note: FNote | undefined | null, labelName: FilterLa const setter = useCallback((value: string | null | undefined) => { if (note) { if (value !== null) { - attributes.setLabel(note.noteId, labelName, value) + attributes.setLabel(note.noteId, labelName, value); } else { attributes.removeOwnedLabelByName(note, labelName); } @@ -537,7 +553,7 @@ export function useNoteLabelInt(note: FNote | undefined | null, labelName: Filte return [ (value ? parseInt(value, 10) : undefined), (newValue) => setValue(String(newValue)) - ] + ]; } export function useNoteBlob(note: FNote | null | undefined, componentId?: string): FBlob | null | undefined { @@ -554,7 +570,7 @@ export function useNoteBlob(note: FNote | null | undefined, componentId?: string } } - useEffect(() => { refresh() }, [ note?.noteId ]); + useEffect(() => { refresh(); }, [ note?.noteId ]); useTriliumEvent("entitiesReloaded", ({ loadResults }) => { if (!note) return; @@ -616,7 +632,7 @@ export function useLegacyWidget(widgetFactory: () => T, { useDebugValue(widget); - return [
, widget ] + return [
, widget ]; } /** @@ -643,7 +659,7 @@ export function useElementSize(ref: RefObject) { return () => { resizeObserver.unobserve(element); resizeObserver.disconnect(); - } + }; }, [ ref ]); return size; @@ -703,7 +719,7 @@ export function useTooltip(elRef: RefObject, config: Partial(); +const tooltips = new Set(); /** * Similar to {@link useTooltip}, but doesn't expose methods to imperatively hide or show the tooltip. @@ -732,7 +748,7 @@ export function useStaticTooltip(elRef: RefObject, config?: Partial { if (!highlightedTokens?.length) return null; const regex = highlightedTokens.map((token) => escapeRegExp(token)).join("|"); - return new RegExp(regex, "gi") + return new RegExp(regex, "gi"); }, [ highlightedTokens ]); return (el: HTMLElement | null | undefined) => { @@ -842,7 +858,7 @@ export function useNoteTreeDrag(containerRef: MutableRef { container.removeEventListener("dragenter", onDragEnter); @@ -878,7 +894,7 @@ export function useKeyboardShortcuts(scope: "code-detail" | "text-detail", conta for (const binding of bindings) { removeIndividualBinding(binding); } - } + }; }, [ scope, containerRef, parentComponent, ntxId ]); } @@ -986,3 +1002,50 @@ export function useLauncherVisibility(launchNoteId: string) { return isVisible; } + +export function useNote(noteId: string | null | undefined, silentNotFoundError = false) { + const [ note, setNote ] = useState(noteId ? froca.getNoteFromCache(noteId) : undefined); + const requestIdRef = useRef(0); + + useEffect(() => { + if (!noteId) { + setNote(undefined); + return; + } + + if (note?.noteId === noteId) { + return; + } + + // Try to read from cache. + const cachedNote = froca.getNoteFromCache(noteId); + if (cachedNote) { + setNote(cachedNote); + return; + } + + // Read it asynchronously. + const requestId = ++requestIdRef.current; + froca.getNote(noteId, silentNotFoundError).then(readNote => { + // Only update if this is the latest request. + if (readNote && requestId === requestIdRef.current) { + setNote(readNote); + } + }); + }, [ note, noteId, silentNotFoundError ]); + + if (note?.noteId === noteId) { + return note; + } + return undefined; +} + +export function useNoteIcon(note: FNote | null | undefined) { + const [ icon, setIcon ] = useState(note?.getIcon()); + const iconClass = useNoteLabel(note, "iconClass"); + useEffect(() => { + setIcon(note?.getIcon()); + }, [ note, iconClass ]); + + return icon; +} diff --git a/packages/commons/src/lib/attribute_names.ts b/packages/commons/src/lib/attribute_names.ts index db1ffc9f2..afb45fcb3 100644 --- a/packages/commons/src/lib/attribute_names.ts +++ b/packages/commons/src/lib/attribute_names.ts @@ -4,6 +4,8 @@ type Labels = { color: string; iconClass: string; + workspace: boolean; + workspaceTabBackgroundColor: string; workspaceIconClass: string; executeButton: boolean; executeDescription: string;