New layout: status bar (#8021)

This commit is contained in:
Elian Doran 2025-12-13 10:23:12 +02:00 committed by GitHub
commit 15b5885982
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 802 additions and 355 deletions

View File

@ -265,7 +265,7 @@ export type CommandMappings = {
reEvaluateRightPaneVisibility: CommandData;
runActiveNote: CommandData;
scrollContainerToCommand: CommandData & {
scrollContainerTo: CommandData & {
position: number;
};
scrollToEnd: CommandData;

View File

@ -44,7 +44,6 @@ 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";
import TabHistoryNavigationButtons from "../widgets/TabHistoryNavigationButtons.jsx";
import { isExperimentalFeatureEnabled } from "../services/experimental_features.js";
import NoteActions from "../widgets/ribbon/NoteActions.jsx";
@ -52,7 +51,7 @@ import FormattingToolbar from "../widgets/ribbon/FormattingToolbar.jsx";
import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibbonAdapter.jsx";
import BreadcrumbBadges from "../widgets/BreadcrumbBadges.jsx";
import NoteTitleDetails from "../widgets/NoteTitleDetails.jsx";
import NoteStatusBar from "../widgets/NoteStatusBar.jsx";
import StatusBar from "../widgets/layout/StatusBar.jsx";
export default class DesktopLayout {
@ -134,6 +133,7 @@ export default class DesktopLayout {
.filling()
.collapsible()
.id("center-pane")
.optChild(isNewLayout, <StandaloneRibbonAdapter component={FormattingToolbar} />)
.child(
new SplitNoteContainer(() =>
new NoteWrapperWidget()
@ -141,7 +141,6 @@ export default class DesktopLayout {
new FlexContainer("row")
.class("breadcrumb-row")
.cssBlock(".breadcrumb-row > * { margin: 5px; }")
.child(<Breadcrumb />)
.optChild(isNewLayout, <BreadcrumbBadges />)
.child(<SpacerWidget baseSize={0} growthFactor={1} />)
.child(<MovePaneButton direction="left" />)
@ -152,7 +151,7 @@ export default class DesktopLayout {
)
.optChild(!isFloatingTitlebar, titleRow)
.optChild(!isNewLayout, <Ribbon><NoteActions /></Ribbon>)
.optChild(isNewLayout, <StandaloneRibbonAdapter component={FormattingToolbar} />)
.optChild(isNewLayout, <Ribbon />)
.child(new WatchedFileUpdateStatusWidget())
.child(<FloatingButtons items={DESKTOP_FLOATING_BUTTONS} />)
.child(
@ -178,14 +177,10 @@ export default class DesktopLayout {
...this.customWidgets.get("node-detail-pane"), // typo, let's keep it for a while as BC
...this.customWidgets.get("note-detail-pane")
)
.optChild(isNewLayout, (
<Ribbon>
<NoteStatusBar />
</Ribbon>
))
)
)
.child(...this.customWidgets.get("center-pane"))
)
.child(
new RightPaneContainer()
@ -194,8 +189,10 @@ export default class DesktopLayout {
.child(...this.customWidgets.get("right-pane"))
)
)
.optChild(!launcherPaneIsHorizontal && isNewLayout, <StatusBar />)
)
)
.optChild(launcherPaneIsHorizontal && isNewLayout, <StatusBar />)
.child(<CloseZenModeButton />)
// Desktop-specific dialogs.

View File

@ -154,7 +154,7 @@ button.btn.btn-success kbd {
color: var(--button-group-active-button-text-color);
}
/*
/*
* Input boxes
*/
@ -399,7 +399,8 @@ button.select-button.dropdown-toggle.btn:active {
select:focus,
select.form-select:focus,
select.form-control:focus,
.select-button.dropdown-toggle.btn:focus {
.select-button.dropdown-toggle.btn:focus,
.select-button.focus-outline:focus {
box-shadow: unset;
outline: 3px solid var(--input-focus-outline-color);
outline-offset: 0;
@ -422,7 +423,7 @@ optgroup {
line-height: 40px;
}
/*
/*
* File input
*
* <label class="tn-file-input tn-input-field">
@ -784,4 +785,4 @@ input[type="range"] {
scrollbar-color: unset;
scrollbar-width: unset;
}
}
}

View File

@ -2146,15 +2146,24 @@
"shared_publicly_description": "This note has been published online at {{- link}}, and is publicly accessible.\n\nClick to navigate to the shared note or right click for more options.",
"shared_locally": "Shared locally",
"shared_locally_description": "This note is shared on the local network only at {{- link}}.\n\nClick to navigate to the shared note or right click for more options.",
"backlinks_one": "{{count}} backlink",
"backlinks_other": "{{count}} backlinks",
"backlinks_description_one": "This note is linked from {{count}} other note.\n\nClick to view the list of backlinks.",
"backlinks_description_other": "This note is linked from {{count}} other notes.\n\nClick to view the list of backlinks.",
"clipped_note": "Web clip",
"clipped_note_description": "This note was originally taken from {{url}}.\n\nClick to navigate to the source webpage.",
"execute_script": "Run script",
"execute_script_description": "This note is a script note. Click to execute the script.",
"execute_sql": "Run SQL",
"execute_sql_description": "This note is a SQL note. Click to execute the SQL query."
},
"status_bar": {
"language_title": "Change the language of the entire content",
"note_info_title": "View information about this note such as the creation/modification date or the note size.",
"backlinks_title_one": "This note is linked from {{count}} other note.\n\nClick to view the list of backlinks.",
"backlinks_title_other": "This note is linked from {{count}} other notes.\n\nClick to view the list of backlinks.",
"attachments_title_one": "This note has {{count}} attachment. Click to open the list of attachments in a new tab.",
"attachments_title_other": "This note has {{count}} attachments. Click to open the list of attachments in a new tab.",
"attributes_one": "{{count}} attribute",
"attributes_other": "{{count}} attributes",
"attributes_title": "Click to open a dedicated pane to edit this note's owned attributes, as well as to see the list of inherited attributes.",
"note_paths_title": "Click to see the paths where this note is placed into the tree.",
"code_note_switcher": "Change language mode"
}
}

View File

@ -34,7 +34,6 @@
&.read-only-badge { --color: #e33f3b; }
&.share-badge { --color: #3b82f6; }
&.clipped-note-badge { --color: #57a2a5; }
&.backlinks-badge { color: var(--badge-text-color); }
&.execute-badge { --color: #f59e0b; }
a {
@ -61,30 +60,6 @@
min-width: 500px;
}
&.dropdown-note-info-badge {
.dropdown-menu.show ul {
list-style-type: none;
padding: 0.5em;
margin: 0;
display: table;
li {
display: table-row;
> strong {
display: table-cell;
padding: 0.2em 0;
}
> span {
display: table-cell;
user-select: text;
padding-left: 2em;
}
}
}
}
.breadcrumb-badge {
border-radius: 0;
}

View File

@ -5,56 +5,22 @@ import { ComponentChildren, MouseEventHandler } from "preact";
import { useRef } from "preact/hooks";
import { t } from "../services/i18n";
import { formatDateTime } from "../utils/formatters";
import { BacklinksList, useBacklinkCount } from "./FloatingButtonsDefinitions";
import Dropdown, { DropdownProps } from "./react/Dropdown";
import { useIsNoteReadOnly, useNoteContext, useNoteLabel, useNoteLabelBoolean, useStaticTooltip } from "./react/hooks";
import Icon from "./react/Icon";
import { NoteSizeWidget, useNoteMetadata } from "./ribbon/NoteInfoTab";
import { useShareInfo } from "./shared_info";
import FNote from "../entities/fnote";
export default function BreadcrumbBadges() {
return (
<div className="breadcrumb-badges">
<ReadOnlyBadge />
<ShareBadge />
<BacklinksBadge />
<ClippedNoteBadge />
<ExecuteBadge />
</div>
);
}
export function NoteInfoBadge({ note }: { note: FNote | null | undefined }) {
const { metadata, ...sizeProps } = useNoteMetadata(note);
return (note &&
<BadgeWithDropdown
icon="bx bx-info-circle"
className="note-info-badge"
dropdownOptions={{ dropdownOptions: { autoClose: "outside" } }}
>
<ul>
<NoteInfoValue text={t("note_info_widget.created")} value={formatDateTime(metadata?.dateCreated)} />
<NoteInfoValue text={t("note_info_widget.modified")} value={formatDateTime(metadata?.dateModified)} />
<NoteInfoValue text={t("note_info_widget.type")} value={<span>{note.type} {note.mime && <span>({note.mime})</span>}</span>} />
<NoteInfoValue text={t("note_info_widget.note_id")} value={<code>{note.noteId}</code>} />
<NoteInfoValue text={t("note_info_widget.note_size")} title={t("note_info_widget.note_size_info")} value={<NoteSizeWidget {...sizeProps} />} />
</ul>
</BadgeWithDropdown>
);
}
function NoteInfoValue({ text, title, value }: { text: string; title?: string, value: ComponentChildren }) {
return (
<li>
<strong title={title}>{text}{": "}</strong>
<span>{value}</span>
</li>
);
}
function ReadOnlyBadge() {
const { note, noteContext } = useNoteContext();
const { isReadOnly, enableEditing } = useIsNoteReadOnly(note, noteContext);
@ -98,24 +64,6 @@ function ShareBadge() {
);
}
function BacklinksBadge() {
const { note, viewScope } = useNoteContext();
const count = useBacklinkCount(note, viewScope?.viewMode === "default");
return (note && count > 0 &&
<BadgeWithDropdown
className="backlinks-badge backlinks-widget"
icon="bx bx-revision"
text={t("breadcrumb_badges.backlinks", { count })}
tooltip={t("breadcrumb_badges.backlinks_description", { count })}
dropdownOptions={{
dropdownContainerClassName: "backlinks-items"
}}
>
<BacklinksList note={note} />
</BadgeWithDropdown>
);
}
function ClippedNoteBadge() {
const { note } = useNoteContext();
const [ pageUrl ] = useNoteLabel(note, "pageUrl");

View File

@ -1,13 +0,0 @@
.note-status-bar {
display: flex;
align-items: center;
padding-inline: 1em;
.dropdown {
font-size: 0.85em;
.dropdown-toggle {
padding: 0.1em 0.25em;
}
}
}

View File

@ -1,25 +0,0 @@
import "./NoteStatusBar.css";
import { t } from "../services/i18n";
import { openInAppHelpFromUrl } from "../services/utils";
import { FormListItem } from "./react/FormList";
import { useNoteContext } from "./react/hooks";
import { NoteLanguageSelector } from "./ribbon/BasicPropertiesTab";
export default function NoteStatusBar() {
const { note } = useNoteContext();
return (
<div className="note-status-bar">
<NoteLanguageSelector
note={note}
extraChildren={(
<FormListItem
onClick={() => openInAppHelpFromUrl("veGu4faJErEM")}
icon="bx bx-help-circle"
>{t("note_language.help-on-languages")}</FormListItem>
)}
/>
</div>
);
}

View File

@ -3,11 +3,12 @@ import { useNoteContext, useNoteProperty } from "./react/hooks";
export default function NoteTitleDetails() {
const { note } = useNoteContext();
const isHiddenNote = note && note.noteId !== "_search" && note.noteId.startsWith("_");
const noteType = useNoteProperty(note, "type");
return (
<div className="title-details">
{note && noteType === "book" && <CollectionProperties note={note} />}
{note && !isHiddenNote && noteType === "book" && <CollectionProperties note={note} />}
</div>
);
}

View File

@ -104,10 +104,12 @@ function BrowserOnlyOptions() {
}
function DevelopmentOptions() {
const [ layoutOrientation ] = useTriliumOption("layoutOrientation");
return <>
<FormDropdownDivider />
<FormListItem disabled>Development Options</FormListItem>
<FormDropdownSubmenu icon="bx bx-test-tube" title="Experimental features">
<FormDropdownSubmenu icon="bx bx-test-tube" title="Experimental features" dropStart={layoutOrientation === "horizontal"}>
{experimentalFeatures.map((feature) => (
<ExperimentalFeatureToggle key={feature.id} experimentalFeature={feature as ExperimentalFeature} />
))}

View File

@ -49,7 +49,7 @@ export default class ScrollingContainer extends Container<BasicWidget> {
}
}
scrollContainerToCommand({ position }: CommandListenerData<"scrollContainerToCommand">) {
scrollContainerToCommand({ position }: CommandListenerData<"scrollContainerTo">) {
this.$widget.scrollTop(position);
}
}

View File

@ -1,60 +1,6 @@
.breadcrumb-row {
.breadcrumb {
position: relative;
height: 30px;
min-height: 30px;
align-items: center;
padding: 10px;
container-type: inline-size;
@container (max-width: 700px) {
.breadcrumb-badges {
flex-shrink: 0;
>* {
flex-shrink: 0;
width: 18px;
}
.dropdown {
button {
flex-shrink: 0;
}
}
.breadcrumb-badge {
flex-shrink: 0;
padding: 0 2px;
>* {
text-overflow: clip;
}
.text {
display: none;
}
}
}
}
@container (max-width: 500px) {
.breadcrumb {
.btn.icon-action {
width: 16px;
}
}
.icon-action {
margin: 0;
}
}
}
body.experimental-feature-new-layout .breadcrumb-row {
padding-inline-end: 0;
}
.component.breadcrumb {
contain: none;
display: flex;
margin: 0;
align-items: center;
@ -62,7 +8,6 @@ body.experimental-feature-new-layout .breadcrumb-row {
gap: 0.25em;
flex-wrap: nowrap;
overflow: hidden;
max-width: 85%;
> span,
> span > span {
@ -108,7 +53,6 @@ body.experimental-feature-new-layout .breadcrumb-row {
.breadcrumb-last-item {
text-decoration: none;
color: unset;
cursor: text;
}
input {

View File

@ -3,25 +3,23 @@ import "./Breadcrumb.css";
import { useMemo, useState } 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";
import { TitleEditor } from "./collections/board";
import server from "../services/server";
import { NoteInfoBadge } from "./BreadcrumbBadges";
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 froca from "../../services/froca";
import ActionButton from "../react/ActionButton";
import Dropdown from "../react/Dropdown";
import { FormListItem } from "../react/FormList";
import { useChildNotes, useNoteLabel, useNoteProperty } from "../react/hooks";
import Icon from "../react/Icon";
import NoteLink from "../react/NoteLink";
const COLLAPSE_THRESHOLD = 5;
const INITIAL_ITEMS = 2;
const FINAL_ITEMS = 2;
export default function Breadcrumb() {
const { note, noteContext } = useNoteContext();
export default function Breadcrumb({ note, noteContext }: { note: FNote, noteContext: NoteContext }) {
const notePath = buildNotePaths(noteContext?.notePathArray);
return (
@ -65,6 +63,7 @@ function BreadcrumbRoot({ noteContext }: { noteContext: NoteContext | undefined
return (note &&
<ActionButton
className="root-note"
icon={note.getIcon()}
text={title ?? ""}
onClick={() => noteContext?.setNote("root")}
@ -87,30 +86,20 @@ function BreadcrumbLink({ notePath }: { notePath: string }) {
function BreadcrumbLastItem({ notePath }: { notePath: string }) {
const noteId = notePath.split("/").at(-1);
const [ note ] = useState(() => froca.getNoteFromCache(noteId!));
const [ isEditing, setIsEditing ] = useState(false);
const title = useNoteProperty(note, "title");
if (!note) return null;
if (!isEditing) {
return (
<a
href="#"
className="breadcrumb-last-item tn-link"
onClick={(e) => {
e.preventDefault();
setIsEditing(true);
}}
>{title}</a>
);
}
return (
<TitleEditor
currentValue={title}
save={(newTitle) => { return server.put(`notes/${noteId}/title`, { title: newTitle.trim() }); }}
dismiss={() => setIsEditing(false)}
/>
<a
href="#"
className="breadcrumb-last-item tn-link"
onClick={() => {
const activeNtxId = appContext.tabManager.activeNtxId;
const scrollingContainer = document.querySelector(`[data-ntx-id="${activeNtxId}"] .scrolling-container`);
scrollingContainer?.scrollTo({ top: 0, behavior: "smooth" });
}}
>{title}</a>
);
}
@ -122,7 +111,6 @@ function BreadcrumbItem({ index, notePath, noteContext, notePathLength }: { inde
if (index === notePathLength - 1) {
return <>
<BreadcrumbLastItem notePath={notePath} />
<NoteInfoBadge note={noteContext?.note} />
</>;
}
@ -136,7 +124,7 @@ function BreadcrumbSeparator({ notePath, noteContext, activeNotePath }: { notePa
noSelectButtonStyle
buttonClassName="icon-action"
hideToggleArrow
dropdownOptions={{ popperConfig: { strategy: "fixed" } }}
dropdownOptions={{ popperConfig: { strategy: "fixed", placement: "top" } }}
>
<BreadcrumbSeparatorDropdownContent notePath={notePath} noteContext={noteContext} activeNotePath={activeNotePath} />
</Dropdown>

View File

@ -0,0 +1,114 @@
.component.status-bar {
contain: none;
border-top: 1px solid var(--main-border-color);
background-color: var(--left-pane-background-color);
> .status-bar-main-row {
min-height: 28px;
display: flex;
align-items: center;
padding-inline: 0.25em;
font-size: 0.85em;
> .breadcrumb {
flex-grow: 1;
--icon-button-size: 23px;
}
> .actions-row {
padding: 0.1em;
display: flex;
gap: 0.1em;
.btn {
padding: 0 0.5em !important;
background: transparent;
display: flex;
align-items: center;
border: 0;
span:first-of-type {
font-size: 1rem;
}
&.active,
&.dropdown-toggle.show,
&:focus,
&:hover {
background: var(--input-background-color);
}
}
.status-bar-dropdown-button {
&:after {
content: unset;
}
}
}
.dropdown {
.dropdown-toggle {
padding: 0.1em 0.25em;
}
.dropdown-menu {
width: max-content;
}
}
.dropdown-note-info {
ul {
list-style-type: none;
padding: 0.5em;
margin: 0;
display: table;
li {
display: table-row;
> strong {
display: table-cell;
padding: 0.2em 0;
}
> span {
display: table-cell;
user-select: text;
padding-left: 2em;
}
}
}
}
.dropdown-note-paths {
.note-paths-widget {
padding: 0.5em;
}
.note-path-list {
margin: 1em;
padding: 0;
}
}
.dropdown-code-note-switcher {
max-height: 90vh;
overflow: scroll;
}
}
> .attribute-list {
font-size: 0.9em;
padding: 0.5em 0.75em;
.inherited-attributes-widget > div {
padding: 0;
font-size: 0.9em;
}
.attribute-list-editor {
padding: 0 !important;
}
}
}

View File

@ -0,0 +1,382 @@
import "./StatusBar.css";
import { Locale } from "@triliumnext/commons";
import clsx from "clsx";
import { type ComponentChildren } from "preact";
import { createPortal } from "preact/compat";
import { useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { CommandNames } from "../../components/app_context";
import NoteContext from "../../components/note_context";
import FNote from "../../entities/fnote";
import attributes from "../../services/attributes";
import { t } from "../../services/i18n";
import { ViewScope } from "../../services/link";
import { openInAppHelpFromUrl } from "../../services/utils";
import { formatDateTime } from "../../utils/formatters";
import { BacklinksList, useBacklinkCount } from "../FloatingButtonsDefinitions";
import Dropdown, { DropdownProps } from "../react/Dropdown";
import { FormDropdownDivider, FormListItem } from "../react/FormList";
import { useActiveNoteContext, useLegacyImperativeHandlers, useNoteProperty, useStaticTooltip, useTriliumEvent, useTriliumEvents } from "../react/hooks";
import Icon from "../react/Icon";
import { ParentComponent } from "../react/react_utils";
import { ContentLanguagesModal, NoteTypeCodeNoteList, NoteTypeOptionsModal, useLanguageSwitcher, useMimeTypes } from "../ribbon/BasicPropertiesTab";
import AttributeEditor, { AttributeEditorImperativeHandlers } from "../ribbon/components/AttributeEditor";
import InheritedAttributesTab from "../ribbon/InheritedAttributesTab";
import { NoteSizeWidget, useNoteMetadata } from "../ribbon/NoteInfoTab";
import { NotePathsWidget, useSortedNotePaths } from "../ribbon/NotePathsTab";
import { useAttachments } from "../type_widgets/Attachment";
import { useProcessedLocales } from "../type_widgets/options/components/LocaleSelector";
import Breadcrumb from "./Breadcrumb";
import server from "../../services/server";
interface StatusBarContext {
note: FNote;
notePath: string | null | undefined;
noteContext: NoteContext;
viewScope?: ViewScope;
hoistedNoteId?: string;
}
export default function StatusBar() {
const { note, notePath, noteContext, viewScope, hoistedNoteId } = useActiveNoteContext();
const [ attributesShown, setAttributesShown ] = useState(false);
const context: StatusBarContext | undefined | null = note && noteContext && { note, notePath, noteContext, viewScope, hoistedNoteId };
const attributesContext: AttributesProps | undefined | null = context && { ...context, attributesShown, setAttributesShown };
const isHiddenNote = note?.isInHiddenSubtree();
return (
<div className="status-bar">
{attributesContext && <AttributesPane {...attributesContext} />}
<div className="status-bar-main-row">
{context && attributesContext && <>
<Breadcrumb {...context} />
<div className="actions-row">
<CodeNoteSwitcher {...context} />
<LanguageSwitcher {...context} />
{!isHiddenNote && <NotePaths {...context} />}
<AttributesButton {...attributesContext} />
<AttachmentCount {...context} />
<BacklinksBadge {...context} />
<NoteInfoBadge {...context} />
</div>
</>}
</div>
</div>
);
}
function StatusBarDropdown({ children, icon, text, buttonClassName, titleOptions, dropdownOptions, ...dropdownProps }: Omit<DropdownProps, "hideToggleArrow" | "title" | "titlePosition"> & {
title: string;
icon?: string;
}) {
return (
<Dropdown
buttonClassName={clsx("status-bar-dropdown-button", buttonClassName)}
titlePosition="top"
titleOptions={{
popperConfig: {
...titleOptions?.popperConfig,
strategy: "fixed"
},
...titleOptions
}}
dropdownOptions={{
autoClose: "outside",
popperConfig: {
strategy: "fixed",
placement: "top"
},
...dropdownOptions
}}
text={<>
{icon && (<><Icon icon={icon} />&nbsp;</>)}
{text}
</>}
{...dropdownProps}
>
{children}
</Dropdown>
);
}
interface StatusBarButtonBaseProps {
className?: string;
icon: string;
title: string;
text?: string | number;
disabled?: boolean;
active?: boolean;
}
type StatusBarButtonWithCommand = StatusBarButtonBaseProps & { triggerCommand: CommandNames; };
type StatusBarButtonWithClick = StatusBarButtonBaseProps & { onClick: () => void; };
function StatusBarButton({ className, icon, text, title, active, ...restProps }: StatusBarButtonWithCommand | StatusBarButtonWithClick) {
const parentComponent = useContext(ParentComponent);
const buttonRef = useRef<HTMLButtonElement>(null);
useStaticTooltip(buttonRef, {
placement: "top",
fallbackPlacements: [ "top" ],
popperConfig: { strategy: "fixed" },
title
});
return (
<button
ref={buttonRef}
className={clsx("btn select-button focus-outline", className, active && "active")}
type="button"
onClick={() => {
if ("triggerCommand" in restProps) {
parentComponent?.triggerCommand(restProps.triggerCommand);
} else {
restProps.onClick();
}
}}
>
<Icon icon={icon} />&nbsp;{text}
</button>
);
}
//#region Language Switcher
function LanguageSwitcher({ note }: StatusBarContext) {
const [ modalShown, setModalShown ] = useState(false);
const { locales, DEFAULT_LOCALE, currentNoteLanguage, setCurrentNoteLanguage } = useLanguageSwitcher(note);
const { activeLocale, processedLocales } = useProcessedLocales(locales, DEFAULT_LOCALE, currentNoteLanguage ?? DEFAULT_LOCALE.id);
return (
<>
{note.type === "text" && <StatusBarDropdown
icon="bx bx-globe"
title={t("status_bar.language_title")}
text={<span dir={activeLocale?.rtl ? "rtl" : "ltr"}>{getLocaleName(activeLocale)}</span>}
>
{processedLocales.map((locale, index) =>
(typeof locale === "object") ? (
<FormListItem
key={locale.id}
rtl={locale.rtl}
checked={locale.id === currentNoteLanguage}
onClick={() => setCurrentNoteLanguage(locale.id)}
>{locale.name}</FormListItem>
) : (
<FormDropdownDivider key={`divider-${index}`} />
)
)}
<FormDropdownDivider />
<FormListItem
onClick={() => openInAppHelpFromUrl("veGu4faJErEM")}
icon="bx bx-help-circle"
>{t("note_language.help-on-languages")}</FormListItem>
<FormListItem
onClick={() => setModalShown(true)}
icon="bx bx-cog"
>{t("note_language.configure-languages")}</FormListItem>
</StatusBarDropdown>}
{createPortal(
<ContentLanguagesModal modalShown={modalShown} setModalShown={setModalShown} />,
document.body
)}
</>
);
}
export function getLocaleName(locale: Locale | null | undefined) {
if (!locale) return "";
if (!locale.id) return "-";
if (locale.name.length <= 4 || locale.rtl) return locale.name; // Some locales like Japanese and Chinese look better than their ID.
return locale.id
.replace("_", "-")
.toLocaleUpperCase();
}
//#endregion
//#region Note info
export function NoteInfoBadge({ note }: { note: FNote | null | undefined }) {
const { metadata, ...sizeProps } = useNoteMetadata(note);
return (note &&
<StatusBarDropdown
icon="bx bx-info-circle"
title={t("status_bar.note_info_title")}
dropdownContainerClassName="dropdown-note-info"
>
<ul>
<NoteInfoValue text={t("note_info_widget.created")} value={formatDateTime(metadata?.dateCreated)} />
<NoteInfoValue text={t("note_info_widget.modified")} value={formatDateTime(metadata?.dateModified)} />
<NoteInfoValue text={t("note_info_widget.type")} value={<span>{note.type} {note.mime && <span>({note.mime})</span>}</span>} />
<NoteInfoValue text={t("note_info_widget.note_id")} value={<code>{note.noteId}</code>} />
<NoteInfoValue text={t("note_info_widget.note_size")} title={t("note_info_widget.note_size_info")} value={<NoteSizeWidget {...sizeProps} />} />
</ul>
</StatusBarDropdown>
);
}
function NoteInfoValue({ text, title, value }: { text: string; title?: string, value: ComponentChildren }) {
return (
<li>
<strong title={title}>{text}{": "}</strong>
<span>{value}</span>
</li>
);
}
//#endregion
//#region Backlinks
function BacklinksBadge({ note, viewScope }: StatusBarContext) {
const count = useBacklinkCount(note, viewScope?.viewMode === "default");
return (note && count > 0 &&
<StatusBarDropdown
className="backlinks-badge backlinks-widget"
icon="bx bx-revision"
text={count}
title={t("status_bar.backlinks_title", { count })}
dropdownContainerClassName="backlinks-items"
>
<BacklinksList note={note} />
</StatusBarDropdown>
);
}
//#endregion
//#region Attachment count
function AttachmentCount({ note }: StatusBarContext) {
const attachments = useAttachments(note);
const count = attachments.length;
return (note && count > 0 &&
<StatusBarButton
className="attachment-count-button"
icon="bx bx-paperclip"
text={count}
title={t("status_bar.attachments_title", { count })}
triggerCommand="showAttachments"
/>
);
}
//#endregion
//#region Attributes
interface AttributesProps extends StatusBarContext {
attributesShown: boolean;
setAttributesShown: (shown: boolean) => void;
}
function AttributesButton({ note, attributesShown, setAttributesShown }: AttributesProps) {
const [ count, setCount ] = useState(note.attributes.length);
// React to note changes.
useEffect(() => {
setCount(note.attributes.length);
}, [ note ]);
// React to changes in count.
useTriliumEvent("entitiesReloaded", (({loadResults}) => {
if (loadResults.getAttributeRows().some(attr => attributes.isAffecting(attr, note))) {
setCount(note.attributes.length);
}
}));
return (
<StatusBarButton
className="attributes-button"
icon="bx bx-list-check"
title={t("status_bar.attributes_title")}
text={t("status_bar.attributes", { count })}
active={attributesShown}
onClick={() => setAttributesShown(!attributesShown)}
/>
);
}
function AttributesPane({ note, noteContext, attributesShown, setAttributesShown }: AttributesProps) {
const parentComponent = useContext(ParentComponent);
const api = useRef<AttributeEditorImperativeHandlers>(null);
const context = parentComponent && {
componentId: parentComponent.componentId,
note,
hidden: !note
};
// Show on keyboard shortcuts.
useTriliumEvents([ "addNewLabel", "addNewRelation" ], () => setAttributesShown(true));
// Interaction with the attribute editor.
useLegacyImperativeHandlers(useMemo(() => ({
saveAttributesCommand: () => api.current?.save(),
reloadAttributesCommand: () => api.current?.refresh(),
updateAttributeListCommand: ({ attributes }) => api.current?.renderOwnedAttributes(attributes)
}), [ api ]));
return (context &&
<div className={clsx("attribute-list", !attributesShown && "hidden-ext")}>
<InheritedAttributesTab {...context} />
<AttributeEditor
{...context}
api={api}
ntxId={noteContext.ntxId}
/>
</div>
);
}
//#endregion
//#region Note paths
function NotePaths({ note, hoistedNoteId, notePath }: StatusBarContext) {
const sortedNotePaths = useSortedNotePaths(note, hoistedNoteId);
return (
<StatusBarDropdown
title={t("status_bar.note_paths_title")}
dropdownContainerClassName="dropdown-note-paths"
icon="bx bx-link-alt"
text={sortedNotePaths?.length}
>
<NotePathsWidget
sortedNotePaths={sortedNotePaths}
currentNotePath={notePath}
/>
</StatusBarDropdown>
);
}
//#endregion
//#region Code note switcher
function CodeNoteSwitcher({ note }: StatusBarContext) {
const [ modalShown, setModalShown ] = useState(false);
const currentNoteMime = useNoteProperty(note, "mime");
const mimeTypes = useMimeTypes();
const correspondingMimeType = useMemo(() => (
mimeTypes.find(m => m.mime === currentNoteMime)
), [ mimeTypes, currentNoteMime ]);
return (
<>
<StatusBarDropdown
icon="bx bx-code-curly"
text={correspondingMimeType?.title}
title={t("status_bar.code_note_switcher")}
dropdownContainerClassName="dropdown-code-note-switcher"
dropdownOptions={{ autoClose: true }}
>
<NoteTypeCodeNoteList
currentMimeType={currentNoteMime}
mimeTypes={mimeTypes}
changeNoteType={(type, mime) => server.put(`notes/${note.noteId}/type`, { type, mime })}
setModalShown={() => setModalShown(true)}
/>
</StatusBarDropdown>
{createPortal(
<NoteTypeOptionsModal modalShown={modalShown} setModalShown={setModalShown} />,
document.body
)}
</>
);
}
//#endregion

View File

@ -316,7 +316,7 @@ export function useNoteContext() {
useDebugValue(() => `notePath=${notePath}, ntxId=${noteContext?.ntxId}`);
return {
note: note,
note,
noteId: noteContext?.note?.noteId,
notePath: noteContext?.notePath,
hoistedNoteId: noteContext?.hoistedNoteId,
@ -327,7 +327,65 @@ export function useNoteContext() {
parentComponent,
isReadOnlyTemporarilyDisabled
};
}
/**
* Similar to {@link useNoteContext}, but instead of using the note context from the split container that the component is part of, it uses the active note context instead
* (the note currently focused by the user).
*/
export function useActiveNoteContext() {
const [ noteContext, setNoteContext ] = useState<NoteContext | undefined>(appContext.tabManager.getActiveContext() ?? undefined);
const [ notePath, setNotePath ] = useState<string | null | undefined>();
const [ note, setNote ] = useState<FNote | null | undefined>();
const [ , setViewScope ] = useState<ViewScope>();
const [ isReadOnlyTemporarilyDisabled, setIsReadOnlyTemporarilyDisabled ] = useState<boolean | null | undefined>(noteContext?.viewScope?.isReadOnly);
const [ refreshCounter, setRefreshCounter ] = useState(0);
useEffect(() => {
if (!noteContext) {
setNoteContext(appContext.tabManager.getActiveContext() ?? undefined);
}
}, [ noteContext ]);
useEffect(() => {
setNote(noteContext?.note);
}, [ notePath ]);
useTriliumEvents([ "setNoteContext", "activeContextChanged", "noteSwitchedAndActivated", "noteSwitched" ], () => {
const noteContext = appContext.tabManager.getActiveContext() ?? undefined;
setNoteContext(noteContext);
setNotePath(noteContext?.notePath);
setViewScope(noteContext?.viewScope);
});
useTriliumEvent("frocaReloaded", () => {
setNote(noteContext?.note);
});
useTriliumEvent("noteTypeMimeChanged", ({ noteId }) => {
if (noteId === note?.noteId) {
setRefreshCounter(refreshCounter + 1);
}
});
useTriliumEvent("readOnlyTemporarilyDisabled", ({ noteContext: eventNoteContext }) => {
if (eventNoteContext.ntxId === noteContext?.ntxId) {
setIsReadOnlyTemporarilyDisabled(eventNoteContext?.viewScope?.readOnlyTemporarilyDisabled);
}
});
const parentComponent = useContext(ParentComponent) as ReactWrappedWidget;
useDebugValue(() => `notePath=${notePath}, ntxId=${noteContext?.ntxId}`);
return {
note,
noteId: noteContext?.note?.noteId,
notePath: noteContext?.notePath,
hoistedNoteId: noteContext?.hoistedNoteId,
ntxId: noteContext?.ntxId,
viewScope: noteContext?.viewScope,
componentId: parentComponent.componentId,
noteContext,
parentComponent,
isReadOnlyTemporarilyDisabled
};
}
/**

View File

@ -1,4 +1,4 @@
import { NoteType, ToggleInParentResponse } from "@triliumnext/commons";
import { MimeType, NoteType, ToggleInParentResponse } from "@triliumnext/commons";
import { ComponentChildren } from "preact";
import { createPortal } from "preact/compat";
import { Dispatch, StateUpdater, useCallback, useEffect, useMemo, useState } from "preact/hooks";
@ -65,13 +65,15 @@ function NoteTypeWidget({ note }: { note?: FNote | null }) {
);
}
export function NoteTypeDropdownContent({ currentNoteType, currentNoteMime, note, setModalShown }: { currentNoteType?: NoteType, currentNoteMime?: string | null, note?: FNote | null, setModalShown: Dispatch<StateUpdater<boolean>> }) {
const [ codeNotesMimeTypes ] = useTriliumOption("codeNotesMimeTypes");
export function NoteTypeDropdownContent({ currentNoteType, currentNoteMime, note, setModalShown, noCodeNotes }: {
currentNoteType?: NoteType;
currentNoteMime?: string | null;
note?: FNote | null;
setModalShown: Dispatch<StateUpdater<boolean>>;
noCodeNotes?: boolean;
}) {
const mimeTypes = useMimeTypes();
const noteTypes = useMemo(() => NOTE_TYPES.filter((nt) => !nt.reserved && !nt.static), []);
const mimeTypes = useMemo(() => {
mime_types.loadMimeTypes();
return mime_types.getMimeTypes().filter(mimeType => mimeType.enabled);
}, [ codeNotesMimeTypes ]);
const changeNoteType = useCallback(async (type: NoteType, mime?: string) => {
if (!note || (type === currentNoteType && mime === currentNoteMime)) {
return;
@ -107,7 +109,7 @@ export function NoteTypeDropdownContent({ currentNoteType, currentNoteMime, note
}
const checked = (type === currentNoteType);
if (type !== "code") {
if (noCodeNotes || type !== "code") {
return (
<FormListItem
checked={checked}
@ -130,8 +132,25 @@ export function NoteTypeDropdownContent({ currentNoteType, currentNoteMime, note
}
})}
{!noCodeNotes && <NoteTypeCodeNoteList mimeTypes={mimeTypes} changeNoteType={changeNoteType} setModalShown={setModalShown} />}
</>
);
}
export function NoteTypeCodeNoteList({ currentMimeType, mimeTypes, changeNoteType, setModalShown }: {
currentMimeType?: string;
mimeTypes: MimeType[];
changeNoteType(type: NoteType, mime: string): void;
setModalShown(shown: boolean): void;
}) {
return (
<>
{mimeTypes.map(({ title, mime }) => (
<FormListItem onClick={() => changeNoteType("code", mime)}>
<FormListItem
key={mime}
checked={mime === currentMimeType}
onClick={() => changeNoteType("code", mime)}
>
{title}
</FormListItem>
))}
@ -142,7 +161,16 @@ export function NoteTypeDropdownContent({ currentNoteType, currentNoteMime, note
);
}
function NoteTypeOptionsModal({ modalShown, setModalShown }: { modalShown: boolean, setModalShown: (shown: boolean) => void }) {
export function useMimeTypes() {
const [ codeNotesMimeTypes ] = useTriliumOption("codeNotesMimeTypes");
const mimeTypes = useMemo(() => {
mime_types.loadMimeTypes();
return mime_types.getMimeTypes().filter(mimeType => mimeType.enabled);
}, [ codeNotesMimeTypes ]); // eslint-disable-line react-hooks/exhaustive-deps
return mimeTypes;
}
export function NoteTypeOptionsModal({ modalShown, setModalShown }: { modalShown: boolean, setModalShown: (shown: boolean) => void }) {
return (
<Modal
className="code-mime-types-modal"
@ -330,28 +358,17 @@ function NoteLanguageSwitch({ note }: { note?: FNote | null }) {
);
}
export function NoteLanguageSelector({ note, extraChildren }: { note: FNote | null | undefined, extraChildren?: ComponentChildren }) {
export function NoteLanguageSelector({ note }: { note: FNote | null | undefined }) {
const [ modalShown, setModalShown ] = useState(false);
const [ languages ] = useTriliumOption("languages");
const DEFAULT_LOCALE = {
id: "",
name: t("note_language.not_set")
};
const [ currentNoteLanguage, setCurrentNoteLanguage ] = useNoteLabel(note, "language");
const locales = useMemo(() => {
const enabledLanguages = JSON.parse(languages ?? "[]") as string[];
const filteredLanguages = getAvailableLocales().filter((l) => typeof l !== "object" || enabledLanguages.includes(l.id));
return filteredLanguages;
}, [ languages ]);
const { locales, DEFAULT_LOCALE, currentNoteLanguage, setCurrentNoteLanguage } = useLanguageSwitcher(note);
return (
<>
<LocaleSelector
locales={locales}
defaultLocale={DEFAULT_LOCALE}
currentValue={currentNoteLanguage ?? ""} onChange={setCurrentNoteLanguage}
currentValue={currentNoteLanguage} onChange={setCurrentNoteLanguage}
extraChildren={<>
{extraChildren}
<FormListItem
onClick={() => setModalShown(true)}
icon="bx bx-cog"
@ -366,7 +383,22 @@ export function NoteLanguageSelector({ note, extraChildren }: { note: FNote | nu
);
}
function ContentLanguagesModal({ modalShown, setModalShown }: { modalShown: boolean, setModalShown: (shown: boolean) => void }) {
export function useLanguageSwitcher(note: FNote | null | undefined) {
const [ languages ] = useTriliumOption("languages");
const DEFAULT_LOCALE = {
id: "",
name: t("note_language.not_set")
};
const [ currentNoteLanguage, setCurrentNoteLanguage ] = useNoteLabel(note, "language");
const locales = useMemo(() => {
const enabledLanguages = JSON.parse(languages ?? "[]") as string[];
const filteredLanguages = getAvailableLocales().filter((l) => typeof l !== "object" || enabledLanguages.includes(l.id));
return filteredLanguages;
}, [ languages ]);
return { locales, DEFAULT_LOCALE, currentNoteLanguage, setCurrentNoteLanguage };
}
export function ContentLanguagesModal({ modalShown, setModalShown }: { modalShown: boolean, setModalShown: (shown: boolean) => void }) {
return (
<Modal
className="content-languages-modal"

View File

@ -9,7 +9,7 @@ import RawHtml from "../react/RawHtml";
import { joinElements } from "../react/react_utils";
import AttributeDetailWidget from "../attribute_widgets/attribute_detail";
export default function InheritedAttributesTab({ note, componentId }: TabContext) {
export default function InheritedAttributesTab({ note, componentId }: Pick<TabContext, "note" | "componentId">) {
const [ inheritedAttributes, setInheritedAttributes ] = useState<FAttribute[]>();
const [ attributeDetailWidgetEl, attributeDetailWidget ] = useLegacyWidget(() => new AttributeDetailWidget());
@ -34,7 +34,7 @@ export default function InheritedAttributesTab({ note, componentId }: TabContext
refresh();
}
});
return (
<div className="inherited-attributes-widget">
<div className="inherited-attributes-container selectable-text">
@ -83,4 +83,4 @@ function InheritedAttribute({ attribute, onClick }: { attribute: FAttribute, onC
onClick={onClick}
/>
);
}
}

View File

@ -184,11 +184,16 @@ function EditabilityDropdown({ note }: { note: FNote }) {
function NoteTypeDropdown({ note }: { note: FNote }) {
const currentNoteType = useNoteProperty(note, "type") ?? undefined;
const currentNoteMime = useNoteProperty(note, "mime");
const [ modalShown, setModalShown ] = useState(false);
return (
<FormDropdownSubmenu title={t("basic_properties.note_type")} icon="bx bx-file" dropStart>
<NoteTypeDropdownContent currentNoteType={currentNoteType} currentNoteMime={currentNoteMime} note={note} setModalShown={setModalShown} />
<NoteTypeDropdownContent
currentNoteType={currentNoteType}
currentNoteMime={currentNoteMime}
note={note}
setModalShown={() => { /* no-op since no code notes are displayed here */ }}
noCodeNotes
/>
</FormDropdownSubmenu>
);
}

View File

@ -1,33 +1,23 @@
import { TabContext } from "./ribbon-interface";
import { useEffect, useMemo, useState } from "preact/hooks";
import FNote, { NotePathRecord } from "../../entities/fnote";
import { t } from "../../services/i18n";
import { NOTE_PATH_TITLE_SEPARATOR } from "../../services/tree";
import Button from "../react/Button";
import { useTriliumEvent } 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";
import { NOTE_PATH_TITLE_SEPARATOR } from "../../services/tree";
import { TabContext } from "./ribbon-interface";
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 ]);
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
const noteId = note?.noteId;
if (!noteId) return;
if (loadResults.getBranchRows().find((branch) => branch.noteId === noteId)
|| loadResults.isNoteReloaded(noteId)) {
refresh();
}
});
const sortedNotePaths = useSortedNotePaths(note, hoistedNoteId);
return <NotePathsWidget sortedNotePaths={sortedNotePaths} currentNotePath={notePath} />;
}
export function NotePathsWidget({ sortedNotePaths, currentNotePath }: {
sortedNotePaths: NotePathRecord[] | undefined;
currentNotePath?: string | null | undefined;
}) {
return (
<div class="note-paths-widget">
<>
@ -38,7 +28,8 @@ export default function NotePathsTab({ note, hoistedNoteId, notePath }: TabConte
<ul className="note-path-list">
{sortedNotePaths?.length ? sortedNotePaths.map(sortedNotePath => (
<NotePath
currentNotePath={notePath}
key={sortedNotePath.notePath}
currentNotePath={currentNotePath}
notePathRecord={sortedNotePath}
/>
)) : undefined}
@ -50,12 +41,35 @@ export default function NotePathsTab({ note, hoistedNoteId, notePath }: TabConte
/>
</>
</div>
)
);
}
export function useSortedNotePaths(note: FNote | null | undefined, hoistedNoteId?: string) {
const [ sortedNotePaths, setSortedNotePaths ] = useState<NotePathRecord[]>();
function refresh() {
if (!note) return;
setSortedNotePaths(note
.getSortedNotePathRecords(hoistedNoteId)
.filter((notePath) => !notePath.isHidden));
}
useEffect(refresh, [ note, hoistedNoteId ]);
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
const noteId = note?.noteId;
if (!noteId) return;
if (loadResults.getBranchRows().find((branch) => branch.noteId === noteId)
|| loadResults.isNoteReloaded(noteId)) {
refresh();
}
});
return sortedNotePaths;
}
function NotePath({ currentNotePath, notePathRecord }: { currentNotePath?: string | null, notePathRecord?: NotePathRecord }) {
const notePath = notePathRecord?.notePath ?? [];
const notePathString = useMemo(() => notePath.join("/"), [ notePath ]);
const notePath = notePathRecord?.notePath;
const notePathString = useMemo(() => (notePath ?? []).join("/"), [ notePath ]);
const [ classes, icons ] = useMemo(() => {
const classes: string[] = [];
@ -68,17 +82,17 @@ function NotePath({ currentNotePath, notePathRecord }: { currentNotePath?: strin
if (!notePathRecord || notePathRecord.isInHoistedSubTree) {
classes.push("path-in-hoisted-subtree");
} else {
icons.push({ icon: "bx bx-trending-up", title: t("note_paths.outside_hoisted") })
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") })
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") })
icons.push({ icon: "bx bx-search", title: t("note_paths.search") });
}
return [ classes.join(" "), icons ];
@ -87,7 +101,7 @@ function NotePath({ currentNotePath, notePathRecord }: { currentNotePath?: strin
// 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) {
for (const noteId of notePath ?? []) {
pathSegments.push(noteId);
fullNotePaths.push(pathSegments.join("/"));
}
@ -95,12 +109,12 @@ function NotePath({ currentNotePath, notePathRecord }: { currentNotePath?: strin
return (
<li class={classes}>
{joinElements(fullNotePaths.map(notePath => (
<NoteLink notePath={notePath} noPreview />
<NoteLink key={notePath} notePath={notePath} noPreview />
)), NOTE_PATH_TITLE_SEPARATOR)}
{icons.map(({ icon, title }) => (
<span class={icon} title={title} />
<span key={title} class={icon} title={title} />
))}
</li>
)
);
}

View File

@ -1,4 +1,5 @@
import { useMemo, useRef } from "preact/hooks";
import { useLegacyImperativeHandlers, useTriliumEvents } from "../react/hooks";
import AttributeEditor, { AttributeEditorImperativeHandlers } from "./components/AttributeEditor";
import { TabContext } from "./ribbon-interface";
@ -25,5 +26,5 @@ export default function OwnedAttributesTab({ note, hidden, activate, ntxId, ...r
<AttributeEditor api={api} ntxId={ntxId} note={note} {...restProps} hidden={hidden} />
)}
</div>
)
);
}

View File

@ -1,14 +1,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { useElementSize, useNoteContext, useNoteProperty, useStaticTooltipWithKeyboardShortcut, useTriliumEvents } from "../react/hooks";
import "./style.css";
import { Indexed, numberObjectsInPlace } from "../../services/utils";
import { EventNames } from "../../components/app_context";
import { KeyboardActionNames } from "@triliumnext/commons";
import { RIBBON_TAB_DEFINITIONS } from "./RibbonDefinition";
import { TabConfiguration, TitleContext } from "./ribbon-interface";
import clsx from "clsx";
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { EventNames } from "../../components/app_context";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
import { Indexed, numberObjectsInPlace } from "../../services/utils";
import { useNoteContext, useNoteProperty, useStaticTooltipWithKeyboardShortcut, useTriliumEvents } from "../react/hooks";
import { TabConfiguration, TitleContext } from "./ribbon-interface";
import { RIBBON_TAB_DEFINITIONS } from "./RibbonDefinition";
const TAB_CONFIGURATION = numberObjectsInPlace<TabConfiguration>(RIBBON_TAB_DEFINITIONS);
@ -45,16 +46,6 @@ export default function Ribbon({ children }: { children?: preact.ComponentChildr
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;
@ -79,7 +70,6 @@ export default function Ribbon({ children }: { children?: preact.ComponentChildr
const shouldShowRibbon = (noteContext?.viewScope?.viewMode === "default" && !noteContext.noteId?.startsWith("_options"));
return (
<div
ref={containerRef}
className={clsx("ribbon-container", !shouldShowRibbon && "hidden-ext")}
style={{ contain: "none" }}
>
@ -133,7 +123,7 @@ export default function Ribbon({ children }: { children?: preact.ComponentChildr
})}
</div>
</div>
)
);
}
function RibbonTab({ icon, title, active, onClick, toggleCommand }: { icon: string; title: string; active: boolean, onClick: () => void, toggleCommand?: KeyboardActionNames }) {
@ -156,7 +146,7 @@ function RibbonTab({ icon, title, active, onClick, toggleCommand }: { icon: stri
<div class="ribbon-tab-spacer" />
</>
)
);
}
export async function shouldShowTab(showConfig: boolean | ((context: TitleContext) => Promise<boolean | null | undefined> | boolean | null | undefined), context: TitleContext) {

View File

@ -97,22 +97,22 @@ export const RIBBON_TAB_DEFINITIONS: TabConfiguration[] = [
title: t("owned_attribute_list.owned_attributes"),
icon: "bx bx-list-check",
content: OwnedAttributesTab,
show: ({note}) => !note?.isLaunchBarConfig(),
show: ({note}) => !isNewLayout && !note?.isLaunchBarConfig(),
toggleCommand: "toggleRibbonTabOwnedAttributes",
stayInDom: true
stayInDom: !isNewLayout
},
{
title: t("inherited_attribute_list.title"),
icon: "bx bx-list-plus",
content: InheritedAttributesTab,
show: ({note}) => !note?.isLaunchBarConfig(),
show: ({note}) => !isNewLayout && !note?.isLaunchBarConfig(),
toggleCommand: "toggleRibbonTabInheritedAttributes"
},
{
title: t("note_paths.title"),
icon: "bx bx-collection",
content: NotePathsTab,
show: true,
show: !isNewLayout,
toggleCommand: "toggleRibbonTabNotePaths"
},
{
@ -125,7 +125,7 @@ export const RIBBON_TAB_DEFINITIONS: TabConfiguration[] = [
{
title: t("similar_notes.title"),
icon: "bx bx-bar-chart",
show: ({ note }) => note?.type !== "search" && !note?.isLabelTruthy("similarNotesWidgetDisabled"),
show: ({ note }) => !isNewLayout && note?.type !== "search" && !note?.isLabelTruthy("similarNotesWidgetDisabled"),
content: SimilarNotesTab,
toggleCommand: "toggleRibbonTabSimilarNotes"
},

View File

@ -355,6 +355,10 @@ body[dir=rtl] .attribute-list-editor {
max-height: 200px;
overflow: auto;
padding: 14px 12px 13px 12px;
a.reference-link {
text-decoration: underline;
}
}
/* #endregion */
@ -418,6 +422,10 @@ body[dir=rtl] .attribute-list-editor {
/* #region Experimental layout */
body.experimental-feature-new-layout {
.ribbon-top-row {
min-height: 0;
}
.ribbon-container {
display: flex;
flex-direction: column-reverse;

View File

@ -26,24 +26,13 @@ import ws from "../../services/ws";
import appContext from "../../components/app_context";
import { ConvertAttachmentToNoteResponse } from "@triliumnext/commons";
import options from "../../services/options";
import FNote from "../../entities/fnote";
/**
* Displays the full list of attachments of a note and allows the user to interact with them.
*/
export function AttachmentList({ note }: TypeWidgetProps) {
const [ attachments, setAttachments ] = useState<FAttachment[]>([]);
function refresh() {
note.getAttachments().then(attachments => setAttachments(Array.from(attachments)));
}
useEffect(refresh, [ note ]);
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
if (loadResults.getAttachmentRows().some((att) => att.attachmentId && att.ownerId === note.noteId)) {
refresh();
}
});
const attachments = useAttachments(note);
return (
<>
@ -59,7 +48,25 @@ export function AttachmentList({ note }: TypeWidgetProps) {
)}
</div>
</>
)
);
}
export function useAttachments(note: FNote) {
const [ attachments, setAttachments ] = useState<FAttachment[]>([]);
function refresh() {
note.getAttachments().then(attachments => setAttachments(Array.from(attachments)));
}
useEffect(refresh, [ note ]);
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
if (loadResults.getAttachmentRows().some((att) => att.attachmentId && att.ownerId === note.noteId)) {
refresh();
}
});
return attachments;
}
function AttachmentListHeader({ noteId }: { noteId: string }) {

View File

@ -8,11 +8,40 @@ import { FormDropdownDivider, FormListItem } from "../../../react/FormList";
export function LocaleSelector({ id, locales, currentValue, onChange, defaultLocale, extraChildren }: {
id?: string;
locales: Locale[],
currentValue: string,
currentValue: string | null | undefined,
onChange: (newLocale: string) => void,
defaultLocale?: Locale,
extraChildren?: ComponentChildren
extraChildren?: ComponentChildren,
}) {
const currentValueWithDefault = currentValue ?? defaultLocale?.id ?? "";
const { activeLocale, processedLocales } = useProcessedLocales(locales, defaultLocale, currentValueWithDefault);
return (
<Dropdown id={id} text={activeLocale?.name}>
{processedLocales.map((locale, index) => (
(typeof locale === "object") ? (
<FormListItem
key={locale.id}
rtl={locale.rtl}
checked={locale.id === currentValue}
onClick={() => {
onChange(locale.id);
}}
>{locale.name}</FormListItem>
) : (
<FormDropdownDivider key={`divider-${index}`} />
)
))}
{extraChildren && (
<>
<FormDropdownDivider />
{extraChildren}
</>
)}
</Dropdown>
);
}
export function useProcessedLocales(locales: Locale[], defaultLocale: Locale | undefined, currentValue: string) {
const activeLocale = defaultLocale?.id === currentValue ? defaultLocale : locales.find(l => l.id === currentValue);
const processedLocales = useMemo(() => {
@ -35,28 +64,8 @@ export function LocaleSelector({ id, locales, currentValue, onChange, defaultLoc
];
}
if (extraChildren) {
items.push("---");
}
return items;
}, [ locales ]);
}, [ locales, defaultLocale ]);
return (
<Dropdown id={id} text={activeLocale?.name}>
{processedLocales.map(locale => {
if (typeof locale === "object") {
return <FormListItem
rtl={locale.rtl}
checked={locale.id === currentValue}
onClick={() => {
onChange(locale.id);
}}
>{locale.name}</FormListItem>
} else {
return <FormDropdownDivider />
}
})}
{extraChildren}
</Dropdown>
)
return { activeLocale, processedLocales };
}