New layout refinement (#8053)

This commit is contained in:
Elian Doran
2025-12-15 07:36:58 +02:00
committed by GitHub
36 changed files with 1260 additions and 759 deletions

View File

@@ -79,19 +79,6 @@ export default class DesktopLayout {
const customTitleBarButtons = !hasNativeTitleBar && !isMac && !isWindows;
const isNewLayout = isExperimentalFeatureEnabled("new-layout");
const titleRow = new FlexContainer("row")
.class("title-row")
.cssBlock(".title-row > * { margin: 5px; }")
.child(<NoteIconWidget />)
.child(<NoteTitleWidget />)
.optChild(isNewLayout, <NoteBadges />)
.optChild(!isNewLayout, <SpacerWidget baseSize={0} growthFactor={1} />)
.child(<MovePaneButton direction="left" />)
.child(<MovePaneButton direction="right" />)
.child(<ClosePaneButton />)
.child(<CreatePaneButton />)
.optChild(isNewLayout, <NoteActions />);
const rootContainer = new RootContainer(true)
.setParent(appContext)
.class(`${launcherPaneIsHorizontal ? "horizontal" : "vertical" }-layout`)
@@ -144,9 +131,19 @@ export default class DesktopLayout {
.child(
new SplitNoteContainer(() =>
new NoteWrapperWidget()
.child(titleRow)
.optChild(!isNewLayout, <Ribbon><NoteActions /></Ribbon>)
.optChild(isNewLayout, <Ribbon />)
.child(new FlexContainer("row")
.class("title-row")
.cssBlock(".title-row > * { margin: 5px; }")
.child(<NoteIconWidget />)
.child(<NoteTitleWidget />)
.optChild(isNewLayout, <NoteBadges />)
.child(<SpacerWidget baseSize={0} growthFactor={1} />)
.optChild(!isNewLayout, <MovePaneButton direction="left" />)
.optChild(!isNewLayout, <MovePaneButton direction="right" />)
.optChild(!isNewLayout, <ClosePaneButton />)
.optChild(!isNewLayout, <CreatePaneButton />)
.optChild(isNewLayout, <NoteActions />))
.optChild(!isNewLayout, <Ribbon />)
.child(new WatchedFileUpdateStatusWidget())
.child(<FloatingButtons items={DESKTOP_FLOATING_BUTTONS} />)
.child(

View File

@@ -1315,13 +1315,16 @@ body.desktop li.dropdown-submenu:hover > ul.dropdown-menu {
top: 0;
inset-inline-start: calc(100% - 2px); /* -2px, otherwise there's a small gap between menu and submenu where the hover can disappear */
margin-top: -10px;
min-width: max-content;
max-width: 300px;
/* to make submenu scrollable https://github.com/zadam/trilium/issues/3136 */
max-height: 600px;
overflow: auto;
}
body.desktop .dropdown-submenu > .dropdown-menu {
min-width: max-content;
max-width: 300px;
}
.dropdown-submenu.dropstart > .dropdown-menu {
inset-inline-start: auto;
inset-inline-end: calc(100% - 2px);

View File

@@ -176,7 +176,7 @@ body.desktop .dropdown-submenu .dropdown-menu {
cursor: default !important;
}
.dropdown-menu:has(> .dropdown-submenu.dropstart) > .dropdown-item {
body.desktop .dropdown-menu:has(> .dropdown-submenu.dropstart) > .dropdown-item {
padding-inline-end: var(--menu-item-start-padding) !important;
padding-inline-start: var(--menu-item-end-padding) !important;
}
@@ -254,7 +254,8 @@ html body .dropdown-item[disabled] {
}
/* Menu item arrow */
.dropdown-submenu:not(.dropstart) .dropdown-toggle::after {
body.mobile .dropdown-submenu .dropdown-toggle::after,
body.desktop .dropdown-submenu:not(.dropstart) .dropdown-toggle::after {
content: "\ed3b" !important;
position: absolute;
display: flex !important;
@@ -270,7 +271,11 @@ html body .dropdown-item[disabled] {
color: var(--menu-item-arrow-color) !important;
}
.dropdown-submenu.dropstart .dropdown-toggle::before {
body.mobile .dropdown-submenu.dropstart .dropdown-toggle::before {
content: unset;
}
body.desktop .dropdown-submenu.dropstart .dropdown-toggle::before {
content: "\ea4d" !important;
position: absolute;
display: flex !important;

View File

@@ -168,12 +168,6 @@ ul.editability-dropdown li.dropdown-item > div {
* Note info
*/
:root .note-info-widget-table button.calculate-button {
min-width: 0;
padding: 4px 10px !important;
font-size: 0.8em;
}
/* Narrow width layout */
.note-info-widget {
container: info-section / inline-size;

View File

@@ -795,7 +795,7 @@
"file_type": "File type",
"file_size": "File size",
"download": "Download",
"open": "Open",
"open": "Open externally",
"upload_new_revision": "Upload new revision",
"upload_success": "New file revision has been uploaded.",
"upload_failed": "Upload of a new file revision failed.",
@@ -826,7 +826,8 @@
"note_size_info": "Note size provides rough estimate of storage requirements for this note. It takes into account note's content and content of its note revisions.",
"calculate": "calculate",
"subtree_size": "(subtree size: {{size}} in {{count}} notes)",
"title": "Note Info"
"title": "Note Info",
"show_similar_notes": "Show similar notes"
},
"note_map": {
"open_full": "Expand to full",
@@ -889,7 +890,8 @@
"search_parameters": "Search Parameters",
"unknown_search_option": "Unknown search option {{searchOptionName}}",
"search_note_saved": "Search note has been saved into {{- notePathTitle}}",
"actions_executed": "Actions have been executed."
"actions_executed": "Actions have been executed.",
"view_options": "View options:"
},
"similar_notes": {
"title": "Similar Notes",
@@ -1758,7 +1760,8 @@
"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_collection": "Collections",
"edited_notes": "Edited notes"
},
"search_result": {
"no_notes_found": "No notes have been found for given search parameters.",
@@ -2157,16 +2160,22 @@
"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.",
"language_title": "Change content language",
"note_info_title": "View note info (e.g., dates, note size)",
"backlinks_one": "{{count}} backlink",
"backlinks_other": "{{count}} backlinks",
"backlinks_title_one": "View backlink",
"backlinks_title_other": "View backlinks",
"attachments_one": "{{count}} attachment",
"attachments_other": "{{count}} attachments",
"attachments_title_one": "View attachment in a new tab",
"attachments_title_other": "View 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.",
"attributes_title": "Owned attributes and inherited attributes",
"note_paths_one": "{{count}} path",
"note_paths_other": "{{count}} paths",
"note_paths_title": "Note paths",
"code_note_switcher": "Change language mode"
}
}

View File

@@ -1,26 +1,27 @@
import { BacklinkCountResponse, BacklinksResponse, SaveSqlConsoleResponse } from "@triliumnext/commons";
import { VNode } from "preact";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
import appContext, { EventData, EventNames } from "../components/app_context";
import Component from "../components/component";
import NoteContext from "../components/note_context";
import FNote from "../entities/fnote";
import ActionButton, { ActionButtonProps } from "./react/ActionButton";
import { useIsNoteReadOnly, useNoteLabelBoolean, useTriliumEvent, useTriliumOption, useWindowSize } from "./react/hooks";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "preact/hooks";
import { createImageSrcUrl, openInAppHelpFromUrl } from "../services/utils";
import server from "../services/server";
import { BacklinkCountResponse, BacklinksResponse, SaveSqlConsoleResponse } from "@triliumnext/commons";
import toast from "../services/toast";
import attributes from "../services/attributes";
import { isExperimentalFeatureEnabled } from "../services/experimental_features";
import froca from "../services/froca";
import { t } from "../services/i18n";
import { copyImageReferenceToClipboard } from "../services/image";
import tree from "../services/tree";
import { getHelpUrlForNote } from "../services/in_app_help";
import froca from "../services/froca";
import LoadResults from "../services/load_results";
import server from "../services/server";
import toast from "../services/toast";
import tree from "../services/tree";
import { createImageSrcUrl, openInAppHelpFromUrl } from "../services/utils";
import { ViewTypeOptions } from "./collections/interface";
import ActionButton, { ActionButtonProps } from "./react/ActionButton";
import { useIsNoteReadOnly, useNoteLabelBoolean, useTriliumEvent, useTriliumOption, useWindowSize } from "./react/hooks";
import NoteLink from "./react/NoteLink";
import RawHtml from "./react/RawHtml";
import { ViewTypeOptions } from "./collections/interface";
import attributes from "../services/attributes";
import LoadResults from "../services/load_results";
import { isExperimentalFeatureEnabled } from "../services/experimental_features";
export interface FloatingButtonContext {
parentComponent: Component;
@@ -38,7 +39,7 @@ function FloatingButton({ className, ...props }: ActionButtonProps) {
className={`floating-button ${className ?? ""}`}
noIconActionClass
{...props}
/>
/>;
}
export type FloatingButtonsList = ((context: FloatingButtonContext) => false | VNode)[];
@@ -85,7 +86,7 @@ function RefreshBackendLogButton({ note, parentComponent, noteContext, isDefault
text={t("backend_log.refresh")}
icon="bx bx-refresh"
onClick={() => parentComponent.triggerEvent("refreshData", { ntxId: noteContext.ntxId })}
/>
/>;
}
function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: FloatingButtonContext) {
@@ -97,7 +98,7 @@ function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: F
text={upcomingOrientation === "vertical" ? t("switch_layout_button.title_vertical") : t("switch_layout_button.title_horizontal")}
icon={upcomingOrientation === "vertical" ? "bx bxs-dock-bottom" : "bx bxs-dock-left"}
onClick={() => setSplitEditorOrientation(upcomingOrientation)}
/>
/>;
}
function ToggleReadOnlyButton({ note, viewType, isDefaultViewMode }: FloatingButtonContext) {
@@ -109,7 +110,7 @@ function ToggleReadOnlyButton({ note, viewType, isDefaultViewMode }: FloatingBut
text={isReadOnly ? t("toggle_read_only_button.unlock-editing") : t("toggle_read_only_button.lock-editing")}
icon={isReadOnly ? "bx bx-lock-open-alt" : "bx bx-lock-alt"}
onClick={() => setReadOnly(!isReadOnly)}
/>
/>;
}
function EditButton({ note, noteContext }: FloatingButtonContext) {
@@ -132,7 +133,7 @@ function EditButton({ note, noteContext }: FloatingButtonContext) {
icon="bx bx-pencil"
className={animationClass}
onClick={() => enableEditing()}
/>
/>;
}
function ShowTocWidgetButton({ note, noteContext, isDefaultViewMode }: FloatingButtonContext) {
@@ -150,7 +151,7 @@ function ShowTocWidgetButton({ note, noteContext, isDefaultViewMode }: FloatingB
appContext.triggerEvent("showTocWidget", { noteId: noteContext.noteId });
}
}}
/>
/>;
}
function ShowHighlightsListWidgetButton({ note, noteContext, isDefaultViewMode }: FloatingButtonContext) {
@@ -168,7 +169,7 @@ function ShowHighlightsListWidgetButton({ note, noteContext, isDefaultViewMode }
appContext.triggerEvent("showHighlightsListWidget", { noteId: noteContext.noteId });
}
}}
/>
/>;
}
function RunActiveNoteButton({ note }: FloatingButtonContext) {
@@ -177,7 +178,7 @@ function RunActiveNoteButton({ note }: FloatingButtonContext) {
icon="bx bx-play"
text={t("code_buttons.execute_button_title")}
triggerCommand="runActiveNote"
/>
/>;
}
function OpenTriliumApiDocsButton({ note }: FloatingButtonContext) {
@@ -186,7 +187,7 @@ function OpenTriliumApiDocsButton({ note }: FloatingButtonContext) {
icon="bx bx-help-circle"
text={t("code_buttons.trilium_api_docs_button_title")}
onClick={() => openInAppHelpFromUrl(note.mime.endsWith("frontend") ? "Q2z6av6JZVWm" : "MEtfsqa5VwNi")}
/>
/>;
}
function SaveToNoteButton({ note }: FloatingButtonContext) {
@@ -204,7 +205,7 @@ function SaveToNoteButton({ note }: FloatingButtonContext) {
await appContext.tabManager.getActiveContext()?.setNote(notePath);
}
}}
/>
/>;
}
function RelationMapButtons({ note, isDefaultViewMode, triggerEvent }: FloatingButtonContext) {
@@ -237,7 +238,7 @@ function RelationMapButtons({ note, isDefaultViewMode, triggerEvent }: FloatingB
/>
</div>
</>
)
);
}
function GeoMapButtons({ triggerEvent, viewType, isReadOnly }: FloatingButtonContext) {
@@ -253,8 +254,11 @@ function GeoMapButtons({ triggerEvent, viewType, isReadOnly }: FloatingButtonCon
function CopyImageReferenceButton({ note, isDefaultViewMode }: FloatingButtonContext) {
const hiddenImageCopyRef = useRef<HTMLDivElement>(null);
const isEnabled = ["mermaid", "canvas", "mindMap", "image"].includes(note?.type ?? "")
&& note?.isContentAvailable() && isDefaultViewMode;
const isEnabled = (
!isNewLayout
&& ["mermaid", "canvas", "mindMap", "image"].includes(note?.type ?? "")
&& note?.isContentAvailable() && isDefaultViewMode
);
return isEnabled && (
<>
@@ -275,7 +279,7 @@ function CopyImageReferenceButton({ note, isDefaultViewMode }: FloatingButtonCon
position: "absolute" // Take out of the the hidden image from flexbox to prevent the layout being affected
}} />
</>
)
);
}
function ExportImageButtons({ note, triggerEvent, isDefaultViewMode }: FloatingButtonContext) {
@@ -295,7 +299,7 @@ function ExportImageButtons({ note, triggerEvent, isDefaultViewMode }: FloatingB
onClick={() => triggerEvent("exportPng")}
/>
</>
)
);
}
function InAppHelpButton({ note }: FloatingButtonContext) {
@@ -308,7 +312,7 @@ function InAppHelpButton({ note }: FloatingButtonContext) {
text={t("help-button.title")}
onClick={() => helpUrl && openInAppHelpFromUrl(helpUrl)}
/>
)
);
}
function Backlinks({ note, isDefaultViewMode }: FloatingButtonContext) {

View File

@@ -139,6 +139,8 @@ function BreadcrumbSeparatorDropdownContent({ notePath, noteContext, activeNoteP
return (
<ul className="breadcrumb-child-list">
{childNotes.map((note) => {
if (note.noteId === "_hidden") return;
const childNotePath = `${notePath}/${note.noteId}`;
return <li key={note.noteId}>
<FormListItem

View File

@@ -89,3 +89,24 @@ body.prefers-centered-content .inline-title {
flex-shrink: 0;
}
}
.edited-notes {
padding: 1.5em 0;
.collapsible-inner-body {
display: flex;
flex-wrap: wrap;
gap: 0.3em;
.badge {
margin: 0;
a.tn-link {
color: inherit;
text-transform: none;
text-decoration: none;
display: inline-block;
}
}
}
}

View File

@@ -16,10 +16,13 @@ import server from "../../services/server";
import { formatDateTime } from "../../utils/formatters";
import NoteIcon from "../note_icon";
import NoteTitleWidget from "../note_title";
import { Badge, BadgeWithDropdown } from "../react/Badge";
import SimpleBadge, { Badge, BadgeWithDropdown } from "../react/Badge";
import Collapsible from "../react/Collapsible";
import { FormDropdownDivider, FormListItem } from "../react/FormList";
import { useNoteBlob, useNoteContext, useNoteProperty, useStaticTooltip, useTriliumEvent } from "../react/hooks";
import { useNoteBlob, useNoteContext, useNoteLabel, useNoteProperty, useStaticTooltip, useTriliumEvent, useTriliumOptionBool } from "../react/hooks";
import NoteLink from "../react/NoteLink";
import { joinElements } from "../react/react_utils";
import { useEditedNotes } from "../ribbon/EditedNotesTab";
import { useNoteMetadata } from "../ribbon/NoteInfoTab";
import { onWheelHorizontalScroll } from "../widget_utils";
@@ -72,6 +75,7 @@ export default function InlineTitle() {
</div>
<NoteTitleDetails />
<EditedNotes />
<NoteTypeSwitcher />
</div>
);
@@ -301,3 +305,41 @@ function useBuiltinTemplates() {
return templates;
}
//#endregion
//#region Edited Notes
function EditedNotes() {
const { note } = useNoteContext();
const [ dateNote ] = useNoteLabel(note, "dateNote");
const [ editedNotesOpenInRibbon ] = useTriliumOptionBool("editedNotesOpenInRibbon");
return (note && dateNote &&
<Collapsible
className="edited-notes"
title={t("note_title.edited_notes")}
initiallyExpanded={editedNotesOpenInRibbon}
>
<EditedNotesContent note={note} />
</Collapsible>
);
}
function EditedNotesContent({ note }: { note: FNote }) {
const editedNotes = useEditedNotes(note);
return (
<>
{editedNotes?.map(editedNote => (
<SimpleBadge
key={editedNote.noteId}
title={(
<NoteLink
notePath={editedNote.noteId}
showNoteIcon
/>
)}
/>
))}
</>
);
}
//#endregion

View File

@@ -4,25 +4,8 @@ body.experimental-feature-new-layout {
}
.title-actions {
padding: 0;
display: flex;
gap: 0.25em;
align-items: center;
width: 100%;
max-width: unset;
padding-inline-start: 15px;
padding-bottom: 0.2em;
font-size: 0.8em;
.dropdown-menu {
input.form-control {
padding: 2px 8px;
margin-left: 1em;
}
}
.spacer {
flex-grow: 1;
&.visible {
padding: 0.75em 15px;
}
}
}

View File

@@ -1,15 +1,38 @@
import CollectionProperties from "../note_bars/CollectionProperties";
import { useNoteContext, useNoteProperty } from "../react/hooks";
import "./NoteTitleActions.css";
import clsx from "clsx";
import FNote from "../../entities/fnote";
import { t } from "../../services/i18n";
import CollectionProperties from "../note_bars/CollectionProperties";
import Collapsible from "../react/Collapsible";
import { useNoteContext, useNoteProperty } from "../react/hooks";
import SearchDefinitionTab from "../ribbon/SearchDefinitionTab";
export default function NoteTitleActions() {
const { note } = useNoteContext();
const { note, ntxId } = useNoteContext();
const isHiddenNote = note && note.noteId !== "_search" && note.noteId.startsWith("_");
const noteType = useNoteProperty(note, "type");
const items = [
note && noteType === "search" && <SearchProperties note={note} ntxId={ntxId} />,
note && !isHiddenNote && noteType === "book" && <CollectionProperties note={note} />
].filter(Boolean);
return (
<div className="title-actions">
{note && !isHiddenNote && noteType === "book" && <CollectionProperties note={note} />}
<div className={clsx("title-actions", items.length > 0 && "visible")}>
{items}
</div>
);
}
function SearchProperties({ note, ntxId }: { note: FNote, ntxId: string | null | undefined }) {
return (note &&
<Collapsible
title={t("search_definition.search_parameters")}
initiallyExpanded={note.isInHiddenSubtree()} // not saved searches
>
<SearchDefinitionTab note={note} ntxId={ntxId} hidden={false} />
</Collapsible>
);
}

View File

@@ -57,10 +57,16 @@
}
.dropdown-note-info {
padding: 1em !important;
ul {
--row-block-margin: .2em;
list-style-type: none;
padding: 0.5em;
padding: 0;
margin: 0;
margin-top: calc(0px - var(--row-block-margin));
margin-bottom: 12px;
display: table;
li {
@@ -68,7 +74,8 @@
> strong {
display: table-cell;
padding: 0.2em 0;
padding: var(--row-block-margin) 0;
opacity: .5;
}
> span {
@@ -85,9 +92,62 @@
padding: 0.5em;
}
.note-path-intro {
color: var(--muted-text-color);
}
.note-path-list {
margin: 1em;
margin: 12px 0;
padding: 0;
list-style: none;
li {
--border-radius: 6px;
position: relative;
background: var(--card-background-color);
padding: 8px 20px 8px 25px;
&:first-child {
border-radius: var(--border-radius) var(--border-radius) 0 0;
}
&:last-child {
border-radius: 0 0 var(--border-radius) var(--border-radius);
}
& + li {
margin-top: 2px;
}
&.path-current::before {
position: absolute;
display: flex;
justify-content: flex-end;
align-items: center;
content: "\ee8f";
top: 0;
left: 0;
width: 20px;
bottom: 0;
font-family: "boxicons";
font-size: .85em;
color: var(--menu-item-icon-color);
}
}
a {
margin-inline: 2px;
padding-inline: 2px;
color: currentColor;
font-weight: normal;
text-decoration: none;
&.basename {
color: var(--muted-text-color);
}
}
}
}

View File

@@ -1,6 +1,7 @@
import "./StatusBar.css";
import { Locale } from "@triliumnext/commons";
import { Dropdown as BootstrapDropdown } from "bootstrap";
import clsx from "clsx";
import { type ComponentChildren } from "preact";
import { createPortal } from "preact/compat";
@@ -18,14 +19,16 @@ 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 { useActiveNoteContext, useLegacyImperativeHandlers, useNoteLabel, useNoteProperty, useStaticTooltip, useTriliumEvent, useTriliumEvents } from "../react/hooks";
import Icon from "../react/Icon";
import LinkButton from "../react/LinkButton";
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 SimilarNotesTab from "../ribbon/SimilarNotesTab";
import { useAttachments } from "../type_widgets/Attachment";
import { useProcessedLocales } from "../type_widgets/options/components/LocaleSelector";
import Breadcrumb from "./Breadcrumb";
@@ -40,17 +43,27 @@ interface StatusBarContext {
export default function StatusBar() {
const { note, notePath, noteContext, viewScope, hoistedNoteId } = useActiveNoteContext();
const [ attributesShown, setAttributesShown ] = useState(false);
const [ activePane, setActivePane ] = useState<"attributes" | "similar-notes" | null>(null);
const context: StatusBarContext | undefined | null = note && noteContext && { note, notePath, noteContext, viewScope, hoistedNoteId };
const attributesContext: AttributesProps | undefined | null = context && { ...context, attributesShown, setAttributesShown };
const attributesContext: AttributesProps | undefined | null = context && {
...context,
attributesShown: activePane === "attributes",
setAttributesShown: () => setActivePane("attributes")
};
const noteInfoContext: NoteInfoContext | undefined | null = context && {
...context,
similarNotesShown: activePane === "similar-notes",
setSimilarNotesShown: () => setActivePane("similar-notes")
};
const isHiddenNote = note?.isInHiddenSubtree();
return (
<div className="status-bar">
{attributesContext && <AttributesPane {...attributesContext} />}
{noteInfoContext && <SimilarNotesPane {...noteInfoContext} />}
<div className="status-bar-main-row">
{context && attributesContext && <>
{context && attributesContext && noteInfoContext && <>
<Breadcrumb {...context} />
<div className="actions-row">
@@ -60,7 +73,7 @@ export default function StatusBar() {
<AttributesButton {...attributesContext} />
<AttachmentCount {...context} />
<BacklinksBadge {...context} />
<NoteInfoBadge {...context} />
<NoteInfoBadge {...noteInfoContext} />
</div>
</>}
</div>
@@ -81,6 +94,7 @@ function StatusBarDropdown({ children, icon, text, buttonClassName, titleOptions
...titleOptions?.popperConfig,
strategy: "fixed"
},
animation: false,
...titleOptions
}}
dropdownOptions={{
@@ -92,7 +106,7 @@ function StatusBarDropdown({ children, icon, text, buttonClassName, titleOptions
}}
text={<>
{icon && (<><Icon icon={icon} />&nbsp;</>)}
{text}
<span className="text">{text}</span>
</>}
{...dropdownProps}
>
@@ -105,7 +119,7 @@ interface StatusBarButtonBaseProps {
className?: string;
icon: string;
title: string;
text?: string | number;
text: string | number;
disabled?: boolean;
active?: boolean;
}
@@ -120,6 +134,7 @@ function StatusBarButton({ className, icon, text, title, active, ...restProps }:
placement: "top",
fallbackPlacements: [ "top" ],
popperConfig: { strategy: "fixed" },
animation: false,
title
});
@@ -136,7 +151,7 @@ function StatusBarButton({ className, icon, text, title, active, ...restProps }:
}
}}
>
<Icon icon={icon} />&nbsp;{text}
<Icon icon={icon} />&nbsp;<span className="text">{text}</span>
</button>
);
}
@@ -194,24 +209,41 @@ export function getLocaleName(locale: Locale | null | undefined) {
}
//#endregion
//#region Note info
export function NoteInfoBadge({ note }: { note: FNote | null | undefined }) {
//#region Note info & Similar
interface NoteInfoContext extends StatusBarContext {
similarNotesShown: boolean;
setSimilarNotesShown: (value: boolean) => void;
}
export function NoteInfoBadge({ note, setSimilarNotesShown }: NoteInfoContext) {
const dropdownRef = useRef<BootstrapDropdown>(null);
const { metadata, ...sizeProps } = useNoteMetadata(note);
const [ originalFileName ] = useNoteLabel(note, "originalFileName");
return (note &&
<StatusBarDropdown
icon="bx bx-info-circle"
title={t("status_bar.note_info_title")}
dropdownRef={dropdownRef}
dropdownContainerClassName="dropdown-note-info"
dropdownOptions={{ autoClose: "outside" }}
>
<ul>
{originalFileName && <NoteInfoValue text={t("file_properties.original_file_name")} value={originalFileName} />}
<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>
<LinkButton
text={t("note_info_widget.show_similar_notes")}
onClick={() => {
dropdownRef.current?.hide();
setSimilarNotesShown(true);
}}
/>
</StatusBarDropdown>
);
}
@@ -224,6 +256,14 @@ function NoteInfoValue({ text, title, value }: { text: string; title?: string, v
</li>
);
}
function SimilarNotesPane({ note, similarNotesShown }: NoteInfoContext) {
return (similarNotesShown &&
<div className="similar-notes-pane">
<SimilarNotesTab note={note} />
</div>
);
}
//#endregion
//#region Backlinks
@@ -233,7 +273,7 @@ function BacklinksBadge({ note, viewScope }: StatusBarContext) {
<StatusBarDropdown
className="backlinks-badge backlinks-widget"
icon="bx bx-link"
text={count}
text={t("status_bar.backlinks", { count })}
title={t("status_bar.backlinks_title", { count })}
dropdownContainerClassName="backlinks-items"
>
@@ -252,7 +292,7 @@ function AttachmentCount({ note }: StatusBarContext) {
<StatusBarButton
className="attachment-count-button"
icon="bx bx-paperclip"
text={count}
text={t("status_bar.attachments", { count })}
title={t("status_bar.attachments_title", { count })}
triggerCommand="showAttachments"
/>
@@ -330,13 +370,14 @@ function AttributesPane({ note, noteContext, attributesShown, setAttributesShown
//#region Note paths
function NotePaths({ note, hoistedNoteId, notePath }: StatusBarContext) {
const sortedNotePaths = useSortedNotePaths(note, hoistedNoteId);
const count = sortedNotePaths?.length ?? 0;
return (
return (count > 1 &&
<StatusBarDropdown
title={t("status_bar.note_paths_title")}
dropdownContainerClassName="dropdown-note-paths"
icon="bx bx-directions"
text={sortedNotePaths?.length}
text={t("status_bar.note_paths", { count })}
>
<NotePathsWidget
sortedNotePaths={sortedNotePaths}

View File

@@ -0,0 +1,20 @@
.collection-properties {
padding: 0;
display: flex;
gap: 0.25em;
align-items: center;
width: 100%;
max-width: unset;
font-size: 0.8em;
.dropdown-menu {
input.form-control {
padding: 2px 8px;
margin-left: 1em;
}
}
.spacer {
flex-grow: 1;
}
}

View File

@@ -1,9 +1,14 @@
import "./CollectionProperties.css";
import { t } from "i18next";
import { useContext } from "preact/hooks";
import { Fragment } from "preact/jsx-runtime";
import FNote from "../../entities/fnote";
import { getHelpUrlForNote } from "../../services/in_app_help";
import { openInAppHelpFromUrl } from "../../services/utils";
import { ViewTypeOptions } from "../collections/interface";
import ActionButton from "../react/ActionButton";
import Dropdown from "../react/Dropdown";
import { FormDropdownDivider, FormDropdownSubmenu, FormListItem, FormListToggleableItem } from "../react/FormList";
import FormTextBox from "../react/FormTextBox";
@@ -12,9 +17,6 @@ import Icon from "../react/Icon";
import { ParentComponent } from "../react/react_utils";
import { bookPropertiesConfig, BookProperty, ButtonProperty, CheckBoxProperty, ComboBoxItem, ComboBoxProperty, NumberProperty, SplitButtonProperty } from "../ribbon/collection-properties-config";
import { useViewType, VIEW_TYPE_MAPPINGS } from "../ribbon/CollectionPropertiesTab";
import ActionButton from "../react/ActionButton";
import { getHelpUrlForNote } from "../../services/in_app_help";
import { openInAppHelpFromUrl } from "../../services/utils";
const ICON_MAPPINGS: Record<ViewTypeOptions, string> = {
grid: "bx bxs-grid",
@@ -30,12 +32,12 @@ export default function CollectionProperties({ note }: { note: FNote }) {
const [ viewType, setViewType ] = useViewType(note);
return (
<>
<div className="collection-properties">
<ViewTypeSwitcher viewType={viewType} setViewType={setViewType} />
<ViewOptions note={note} viewType={viewType} />
<div className="spacer" />
<HelpButton note={note} />
</>
</div>
);
}
@@ -187,9 +189,9 @@ function ComboBoxPropertyView({ note, property }: { note: FNote, property: Combo
{index < property.options.length - 1 && <FormDropdownDivider />}
</Fragment>
);
} else {
return renderItem(option);
}
return renderItem(option);
})}
</FormDropdownSubmenu>
);

View File

@@ -10,7 +10,7 @@ import Icon from "./Icon";
interface SimpleBadgeProps {
className?: string;
title: string;
title: ComponentChildren;
}
interface BadgeProps {

View File

@@ -0,0 +1,32 @@
.collapsible {
.collapsible-title {
line-height: 1em;
display: flex;
align-items: center;
appearance: none;
background: transparent;
border: 0;
color: inherit;
.arrow {
font-size: 1.3em;
transition: transform 250ms ease-in;
}
}
.collapsible-body {
height: 0;
overflow: hidden;
transition: height 250ms ease-in;
}
.collapsible-inner-body {
padding-top: 0.5em;
}
&.expanded {
.collapsible-title .arrow {
transform: rotate(90deg);
}
}
}

View File

@@ -0,0 +1,52 @@
import "./Collapsible.css";
import clsx from "clsx";
import { ComponentChildren, HTMLAttributes } from "preact";
import { useRef, useState } from "preact/hooks";
import { useElementSize, useUniqueName } from "./hooks";
import Icon from "./Icon";
interface CollapsibleProps extends Pick<HTMLAttributes<HTMLDivElement>, "className"> {
title: string;
children: ComponentChildren;
initiallyExpanded?: boolean;
}
export default function Collapsible({ title, children, className, initiallyExpanded }: CollapsibleProps) {
const bodyRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const [ expanded, setExpanded ] = useState(initiallyExpanded);
const { height } = useElementSize(innerRef) ?? {};
const contentId = useUniqueName();
return (
<div className={clsx("collapsible", className, expanded && "expanded")}>
<button
className="collapsible-title"
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
aria-controls={contentId}
>
<Icon className="arrow" icon="bx bx-chevron-right" />&nbsp;
{title}
</button>
<div
id={contentId}
ref={bodyRef}
className="collapsible-body"
style={{ height: expanded ? height : "0" }}
aria-hidden={!expanded}
>
<div
ref={innerRef}
className="collapsible-inner-body"
>
{children}
</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,9 @@
import { Ref } from "preact";
import Button, { ButtonProps } from "./Button";
import { useEffect, useRef } from "preact/hooks";
import ActionButton, { ActionButtonProps } from "./ActionButton";
import Button, { ButtonProps } from "./Button";
interface FormFileUploadProps {
name?: string;
onChange: (files: FileList | null) => void;
@@ -26,7 +28,7 @@ export default function FormFileUpload({ inputRef, name, onChange, multiple, hid
multiple={multiple}
onChange={e => onChange((e.target as HTMLInputElement).files)} />
</label>
)
);
}
/**
@@ -49,5 +51,27 @@ export function FormFileUploadButton({ onChange, ...buttonProps }: Omit<ButtonPr
onChange={onChange}
/>
</>
)
);
}
/**
* Similar to {@link FormFileUploadButton}, but uses an {@link ActionButton} instead of a normal {@link Button}.
* @param param the change listener for the file upload and the properties for the button.
*/
export function FormFileUploadActionButton({ onChange, ...buttonProps }: Omit<ActionButtonProps, "onClick"> & Pick<FormFileUploadProps, "onChange">) {
const inputRef = useRef<HTMLInputElement>(null);
return (
<>
<ActionButton
{...buttonProps}
onClick={() => inputRef.current?.click()}
/>
<FormFileUpload
inputRef={inputRef}
hidden
onChange={onChange}
/>
</>
);
}

View File

@@ -1,13 +1,15 @@
import { Dropdown as BootstrapDropdown, Tooltip } from "bootstrap";
import { ComponentChildren } from "preact";
import Icon from "./Icon";
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "preact/compat";
import "./FormList.css";
import { CommandNames } from "../../components/app_context";
import { useStaticTooltip } from "./hooks";
import { handleRightToLeftPlacement, isMobile, openInAppHelpFromUrl } from "../../services/utils";
import { Dropdown as BootstrapDropdown, Tooltip } from "bootstrap";
import clsx from "clsx";
import { ComponentChildren } from "preact";
import { type CSSProperties,useEffect, useMemo, useRef, useState } from "preact/compat";
import { CommandNames } from "../../components/app_context";
import { handleRightToLeftPlacement, isMobile, openInAppHelpFromUrl } from "../../services/utils";
import FormToggle from "./FormToggle";
import { useStaticTooltip } from "./hooks";
import Icon from "./Icon";
interface FormListOpts {
children: ComponentChildren;
@@ -33,7 +35,7 @@ export default function FormList({ children, onSelect, style, fullHeight, wrappe
return () => {
$wrapperRef.off("hide.bs.dropdown");
dropdown.dispose();
}
};
}, [ triggerRef, wrapperRef ]);
const builtinStyles = useMemo(() => {
@@ -51,8 +53,7 @@ export default function FormList({ children, onSelect, style, fullHeight, wrappe
<button
ref={triggerRef}
type="button" style="display: none;"
data-bs-toggle="dropdown" data-bs-display="static">
</button>
data-bs-toggle="dropdown" data-bs-display="static" />
<div class="dropdown-menu static show" style={{
...style ?? {},
@@ -199,7 +200,7 @@ export function FormListHeader({ text }: FormListHeaderOpts) {
<li>
<h6 className="dropdown-header">{text}</h6>
</li>
)
);
}
export function FormDropdownDivider() {
@@ -216,21 +217,22 @@ export function FormDropdownSubmenu({ icon, title, children, dropStart, onDropdo
const [ openOnMobile, setOpenOnMobile ] = useState(false);
return (
<li className={clsx("dropdown-item dropdown-submenu", { "submenu-open": openOnMobile, "dropstart": dropStart })}>
<span
className="dropdown-toggle"
onClick={(e) => {
<li
className={clsx("dropdown-item dropdown-submenu", { "submenu-open": openOnMobile, "dropstart": dropStart })}
onClick={(e) => {
e.stopPropagation();
if (!isMobile() && onDropdownToggleClicked) {
onDropdownToggleClicked();
}
}}
>
<span className="dropdown-toggle" onClick={(e) => {
if (isMobile()) {
e.stopPropagation();
if (isMobile()) {
setOpenOnMobile(!openOnMobile);
}
if (onDropdownToggleClicked) {
onDropdownToggleClicked();
}
}}
>
setOpenOnMobile(!openOnMobile);
}
}}>
<Icon icon={icon} />{" "}
{title}
</span>

View File

@@ -1,16 +1,26 @@
import { ComponentChild } from "preact";
import { CommandNames } from "../../components/app_context";
interface LinkButtonProps {
onClick: () => void;
onClick?: () => void;
text: ComponentChild;
triggerCommand?: CommandNames;
}
export default function LinkButton({ onClick, text }: LinkButtonProps) {
export default function LinkButton({ onClick, text, triggerCommand }: LinkButtonProps) {
return (
<a class="tn-link" href="javascript:" onClick={(e) => {
e.preventDefault();
onClick();
}}>
<a class="tn-link" href="#"
data-trigger-command={triggerCommand}
role="button"
onKeyDown={(e)=> {
if (e.code === "Space") {
onClick?.();
}
}}
onClick={(e) => {
e.preventDefault();
onClick?.();
}}>
{text}
</a>
)

View File

@@ -1,24 +1,16 @@
import { useEffect, useState } from "preact/hooks";
import { TabContext } from "./ribbon-interface";
import { EditedNotesResponse } from "@triliumnext/commons";
import server from "../../services/server";
import { t } from "../../services/i18n";
import { useEffect, useState } from "preact/hooks";
import FNote from "../../entities/fnote";
import froca from "../../services/froca";
import { t } from "../../services/i18n";
import server from "../../services/server";
import NoteLink from "../react/NoteLink";
import { joinElements } from "../react/react_utils";
import { TabContext } from "./ribbon-interface";
export default function EditedNotesTab({ note }: TabContext) {
const [ editedNotes, setEditedNotes ] = useState<EditedNotesResponse>();
useEffect(() => {
if (!note) return;
server.get<EditedNotesResponse>(`edited-notes/${note.getLabelValue("dateNote")}`).then(async editedNotes => {
editedNotes = editedNotes.filter((n) => n.noteId !== note.noteId);
const noteIds = editedNotes.flatMap((n) => n.noteId);
await froca.getNotes(noteIds, true); // preload all at once
setEditedNotes(editedNotes);
});
}, [ note?.noteId ]);
const editedNotes = useEditedNotes(note);
return (
<div className="edited-notes-widget" style={{
@@ -31,7 +23,7 @@ export default function EditedNotesTab({ note }: TabContext) {
<div className="edited-notes-list use-tn-links">
{joinElements(editedNotes.map(editedNote => {
return (
<span className="edited-note-line">
<span key={editedNote.noteId} className="edited-note-line">
{editedNote.isDeleted ? (
<i>{`${editedNote.title} ${t("edited_notes.deleted")}`}</i>
) : (
@@ -40,12 +32,28 @@ export default function EditedNotesTab({ note }: TabContext) {
</>
)}
</span>
)
);
}), " ")}
</div>
) : (
<div className="no-edited-notes-found">{t("edited_notes.no_edited_notes_found")}</div>
)}
</div>
)
);
}
export function useEditedNotes(note: FNote | null | undefined) {
const [ editedNotes, setEditedNotes ] = useState<EditedNotesResponse>();
useEffect(() => {
if (!note) return;
server.get<EditedNotesResponse>(`edited-notes/${note.getLabelValue("dateNote")}`).then(async editedNotes => {
editedNotes = editedNotes.filter((n) => n.noteId !== note.noteId);
const noteIds = editedNotes.flatMap((n) => n.noteId);
await froca.getNotes(noteIds, true); // preload all at once
setEditedNotes(editedNotes);
});
}, [ note ]);
return editedNotes;
}

View File

@@ -1,13 +1,13 @@
import FNote from "../../entities/fnote";
import { t } from "../../services/i18n";
import { downloadFileNote, openNoteExternally } from "../../services/open";
import protected_session_holder from "../../services/protected_session_holder";
import server from "../../services/server";
import toast from "../../services/toast";
import { formatSize } from "../../services/utils";
import Button from "../react/Button";
import { FormFileUploadButton } from "../react/FormFileUpload";
import { useNoteBlob, useNoteLabel } from "../react/hooks";
import Button from "../react/Button";
import protected_session_holder from "../../services/protected_session_holder";
import { downloadFileNote, openNoteExternally } from "../../services/open";
import toast from "../../services/toast";
import server from "../../services/server";
import FNote from "../../entities/fnote";
export default function FilePropertiesTab({ note }: { note?: FNote | null }) {
const [ originalFileName ] = useNoteLabel(note, "originalFileName");
@@ -54,19 +54,7 @@ export default function FilePropertiesTab({ note }: { note?: FNote | null }) {
icon="bx bx-folder-open"
text={t("file_properties.upload_new_revision")}
disabled={!canAccessProtectedNote}
onChange={(fileToUpload) => {
if (!fileToUpload) {
return;
}
server.upload(`notes/${note.noteId}/file`, fileToUpload[0]).then((result) => {
if (result.uploaded) {
toast.showMessage(t("file_properties.upload_success"));
} else {
toast.showError(t("file_properties.upload_failed"));
}
});
}}
onChange={buildUploadNewFileRevisionListener(note)}
/>
</div>
</td>
@@ -77,3 +65,19 @@ export default function FilePropertiesTab({ note }: { note?: FNote | null }) {
</div>
);
}
export function buildUploadNewFileRevisionListener(note: FNote) {
return (fileToUpload: FileList | null) => {
if (!fileToUpload) {
return;
}
server.upload(`notes/${note.noteId}/file`, fileToUpload[0]).then((result) => {
if (result.uploaded) {
toast.showMessage(t("file_properties.upload_success"));
} else {
toast.showError(t("file_properties.upload_failed"));
}
});
};
}

View File

@@ -1,14 +1,16 @@
import { t } from "../../services/i18n";
import { useNoteBlob, useNoteLabel } from "../react/hooks";
import { TabContext } from "./ribbon-interface";
import { clearBrowserCache, formatSize } from "../../services/utils";
import Button from "../react/Button";
import { downloadFileNote, openNoteExternally } from "../../services/open";
import { ParentComponent } from "../react/react_utils";
import { useContext } from "preact/hooks";
import { FormFileUploadButton } from "../react/FormFileUpload";
import FNote from "../../entities/fnote";
import { t } from "../../services/i18n";
import { downloadFileNote, openNoteExternally } from "../../services/open";
import server from "../../services/server";
import toast from "../../services/toast";
import { clearBrowserCache, formatSize } from "../../services/utils";
import Button from "../react/Button";
import { FormFileUploadButton } from "../react/FormFileUpload";
import { useNoteBlob, useNoteLabel } from "../react/hooks";
import { ParentComponent } from "../react/react_utils";
import { TabContext } from "./ribbon-interface";
export default function ImagePropertiesTab({ note, ntxId }: TabContext) {
const [ originalFileName ] = useNoteLabel(note, "originalFileName");
@@ -60,23 +62,27 @@ export default function ImagePropertiesTab({ note, ntxId }: TabContext) {
<FormFileUploadButton
text={t("image_properties.upload_new_revision")}
icon="bx bx-folder-open"
onChange={async (files) => {
if (!files) return;
const fileToUpload = files[0]; // copy to allow reset below
const result = await server.upload(`images/${note.noteId}`, fileToUpload);
if (result.uploaded) {
toast.showMessage(t("image_properties.upload_success"));
await clearBrowserCache();
} else {
toast.showError(t("image_properties.upload_failed", { message: result.message }));
}
}}
onChange={buildUploadNewImageRevisionListener(note)}
/>
</div>
</>
)}
</div>
)
);
}
export function buildUploadNewImageRevisionListener(note: FNote) {
return async (files: FileList | null) => {
if (!files) return;
const fileToUpload = files[0]; // copy to allow reset below
const result = await server.upload(`images/${note.noteId}`, fileToUpload);
if (result.uploaded) {
toast.showMessage(t("image_properties.upload_success"));
await clearBrowserCache();
} else {
toast.showError(t("image_properties.upload_failed", { message: result.message }));
}
};
}

View File

@@ -1,31 +1,44 @@
import { ConvertToAttachmentResponse } from "@triliumnext/commons";
import { useContext, useState } from "preact/hooks";
import { useContext } from "preact/hooks";
import appContext, { CommandNames } from "../../components/app_context";
import NoteContext from "../../components/note_context";
import FNote from "../../entities/fnote";
import branches from "../../services/branches";
import dialog from "../../services/dialog";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
import { t } from "../../services/i18n";
import protected_session from "../../services/protected_session";
import server from "../../services/server";
import toast from "../../services/toast";
import { isElectron as getIsElectron, isMac as getIsMac } from "../../services/utils";
import ws from "../../services/ws";
import ClosePaneButton from "../buttons/close_pane_button";
import CreatePaneButton from "../buttons/create_pane_button";
import MovePaneButton from "../buttons/move_pane_button";
import ActionButton from "../react/ActionButton";
import Dropdown from "../react/Dropdown";
import { FormDropdownDivider, FormDropdownSubmenu, FormListHeader, FormListItem, FormListToggleableItem } from "../react/FormList";
import { useIsNoteReadOnly, useNoteContext, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useTriliumOption } from "../react/hooks";
import { ParentComponent } from "../react/react_utils";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
import { NoteTypeDropdownContent, useNoteBookmarkState, useShareState } from "./BasicPropertiesTab";
import protected_session from "../../services/protected_session";
import NoteActionsCustom from "./NoteActionsCustom";
const isNewLayout = isExperimentalFeatureEnabled("new-layout");
export default function NoteActions() {
const { note, noteContext } = useNoteContext();
const { note, ntxId, noteContext } = useNoteContext();
return (
<div className="ribbon-button-container" style={{ contain: "none" }}>
{isNewLayout && (
<>
{note && ntxId && <NoteActionsCustom note={note} ntxId={ntxId} />}
<MovePaneButton direction="left" />
<MovePaneButton direction="right" />
<ClosePaneButton />
<CreatePaneButton />
</>
)}
{note && !isNewLayout && <RevisionsButton note={note} />}
{note && note.type !== "launcher" && <NoteContextMenu note={note as FNote} noteContext={noteContext} />}
</div>
@@ -79,14 +92,14 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not
<CommandItem command="findInText" icon="bx bx-search" disabled={!isSearchable} text={t("note_actions.search_in_note")} />
<CommandItem command="showAttachments" icon="bx bx-paperclip" disabled={isInOptionsOrHelp} text={t("note_actions.note_attachments")} />
{isNewLayout && <CommandItem command="toggleRibbonTabNoteMap" icon="bx bxs-network-chart" disabled={isInOptionsOrHelp} text={t("note_actions.note_map")} />}
<FormDropdownDivider />
{isNewLayout && isNormalViewMode && !isHelpPage && <>
<NoteBasicProperties note={note} />
<FormDropdownDivider />
</>}
<CommandItem icon="bx bx-import" text={t("note_actions.import_files")}
disabled={isInOptionsOrHelp || note.type === "search"}
command={() => parentComponent?.triggerCommand("showImportDialog", { noteId: note.noteId })} />
@@ -98,7 +111,7 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not
})} />
{isElectron && <CommandItem command="exportAsPdf" icon="bx bxs-file-pdf" disabled={!isPrintable} text={t("note_actions.print_pdf")} />}
<CommandItem command="printActiveNote" icon="bx bx-printer" disabled={!isPrintable} text={t("note_actions.print_note")} />
<FormDropdownDivider />
<CommandItem command="showRevisions" icon="bx bx-history" text={t("note_actions.view_revisions")} />
@@ -107,7 +120,7 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not
<FormDropdownDivider />
{canBeConvertedToAttachment && <ConvertToAttachment note={note} />}
{note.type === "render" && <CommandItem command="renderActiveNote" icon="bx bx-extension" text={t("note_actions.re_render_note")}
{note.type === "render" && <CommandItem command="renderActiveNote" icon="bx bx-extension" text={t("note_actions.re_render_note")}
/>}
<FormDropdownSubmenu icon="bx bx-wrench" title={t("note_actions.advanced")} dropStart>
@@ -122,7 +135,7 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not
</FormDropdownSubmenu>
<FormDropdownDivider />
<CommandItem icon="bx bx-trash destructive-action-icon" text={t("note_actions.delete_note")} destructive
disabled={isInOptionsOrHelp}
command={() => branches.deleteNotes([note.getParentBranches()[0].branchId])}
@@ -168,7 +181,7 @@ function NoteBasicProperties({ note }: { note: FNote }) {
currentValue={isTemplate} onChange={setIsTemplate}
helpPage="KC1HB96bqqHX"
disabled={note?.noteId.startsWith("_options")}
/>
/>
</>;
}

View File

@@ -0,0 +1,122 @@
import { NoteType } from "@triliumnext/commons";
import { useContext } from "preact/hooks";
import FNote from "../../entities/fnote";
import { t } from "../../services/i18n";
import { downloadFileNote, openNoteExternally } from "../../services/open";
import ActionButton from "../react/ActionButton";
import { FormFileUploadActionButton } from "../react/FormFileUpload";
import { useNoteProperty } from "../react/hooks";
import { ParentComponent } from "../react/react_utils";
import { buildUploadNewFileRevisionListener } from "./FilePropertiesTab";
import { buildUploadNewImageRevisionListener } from "./ImagePropertiesTab";
interface NoteActionsCustomProps {
note: FNote;
ntxId: string;
}
interface NoteActionsCustomInnerProps extends NoteActionsCustomProps {
noteType: NoteType;
}
/**
* Part of {@link NoteActions} on the new layout, but are rendered with a slight spacing
* from the rest of the note items and the buttons differ based on the note type.
*/
export default function NoteActionsCustom(props: NoteActionsCustomProps) {
const noteType = useNoteProperty(props.note, "type");
const innerProps: NoteActionsCustomInnerProps | undefined = noteType && {
...props,
noteType
};
return (innerProps &&
<div className="note-actions-custom">
<CopyReferenceToClipboardButton {...innerProps} />
<NoteActionsCustomInner {...innerProps} />
</div>
);
}
//#region Note type mappings
function NoteActionsCustomInner(props: NoteActionsCustomInnerProps) {
switch (props.note.type) {
case "file":
return <FileActions {...props} />;
case "image":
return <ImageActions {...props} />;
default:
return null;
}
}
function FileActions(props: NoteActionsCustomInnerProps) {
return (
<>
<UploadNewRevisionButton {...props} onChange={buildUploadNewFileRevisionListener(props.note)} />
<OpenExternallyButton {...props} />
<DownloadFileButton {...props} />
</>
);
}
function ImageActions(props: NoteActionsCustomInnerProps) {
return (
<>
<UploadNewRevisionButton {...props} onChange={buildUploadNewImageRevisionListener(props.note)} />
<OpenExternallyButton {...props} />
<DownloadFileButton {...props} />
</>
);
}
//#endregion
//#region Shared buttons
function UploadNewRevisionButton({ note, onChange }: NoteActionsCustomInnerProps & {
onChange: (files: FileList | null) => void;
}) {
return (
<FormFileUploadActionButton
icon="bx bx-folder-open"
text={t("image_properties.upload_new_revision")}
disabled={!note.isContentAvailable()}
onChange={onChange}
/>
);
}
function OpenExternallyButton({ note }: NoteActionsCustomInnerProps) {
return (
<ActionButton
icon="bx bx-link-external"
text={t("file_properties.open")}
disabled={note.isProtected}
onClick={() => openNoteExternally(note.noteId, note.mime)}
/>
);
}
function DownloadFileButton({ note }: NoteActionsCustomInnerProps) {
return (
<ActionButton
icon="bx bx-download"
text={t("file_properties.download")}
disabled={!note.isContentAvailable()}
onClick={() => downloadFileNote(note.noteId)}
/>
);
}
function CopyReferenceToClipboardButton({ ntxId, noteType }: NoteActionsCustomInnerProps) {
const parentComponent = useContext(ParentComponent);
return (["mermaid", "canvas", "mindMap", "image"].includes(noteType) &&
<ActionButton
text={t("image_properties.copy_reference_to_clipboard")}
icon="bx bx-copy"
onClick={() => parentComponent?.triggerEvent("copyImageReferenceToClipboard", { ntxId })}
/>
);
}
//#endregion

View File

@@ -9,6 +9,7 @@ import LoadingSpinner from "../react/LoadingSpinner";
import { useTriliumEvent } from "../react/hooks";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
import FNote from "../../entities/fnote";
import LinkButton from "../react/LinkButton";
const isNewLayout = isExperimentalFeatureEnabled("new-layout");
@@ -53,9 +54,7 @@ export default function NoteInfoTab({ note }: { note: FNote | null | undefined }
export function NoteSizeWidget({ isLoading, noteSizeResponse, subtreeSizeResponse, requestSizeInfo }: Omit<ReturnType<typeof useNoteMetadata>, "metadata">) {
return <>
{!isLoading && !noteSizeResponse && !subtreeSizeResponse && (
<Button
className="calculate-button"
icon="bx bx-calculator"
<LinkButton
text={t("note_info_widget.calculate")}
onClick={requestSizeInfo}
/>

View File

@@ -3,11 +3,13 @@ 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 NoteLink from "../react/NoteLink";
import { joinElements } from "../react/react_utils";
import { TabContext } from "./ribbon-interface";
import LinkButton from "../react/LinkButton";
import clsx from "clsx";
export default function NotePathsTab({ note, hoistedNoteId, notePath }: TabContext) {
const sortedNotePaths = useSortedNotePaths(note, hoistedNoteId);
@@ -35,9 +37,9 @@ export function NotePathsWidget({ sortedNotePaths, currentNotePath }: {
)) : undefined}
</ul>
<Button
triggerCommand="cloneNoteIdsTo"
<LinkButton
text={t("note_paths.clone_button")}
triggerCommand="cloneNoteIdsTo"
/>
</>
</div>
@@ -108,12 +110,15 @@ function NotePath({ currentNotePath, notePathRecord }: { currentNotePath?: strin
return (
<li class={classes}>
{joinElements(fullNotePaths.map(notePath => (
<NoteLink key={notePath} notePath={notePath} noPreview />
{joinElements(fullNotePaths.map((notePath, index, arr) => (
<NoteLink key={notePath}
className={clsx({"basename": (index === arr.length - 1)})}
notePath={notePath}
noPreview />
)), NOTE_PATH_TITLE_SEPARATOR)}
{icons.map(({ icon, title }) => (
<span key={title} class={icon} title={title} />
<i key={title} class={icon} title={title} />
))}
</li>
);

View File

@@ -5,9 +5,9 @@ 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 NoteActions from "./NoteActions";
import { TabConfiguration, TitleContext } from "./ribbon-interface";
import { RIBBON_TAB_DEFINITIONS } from "./RibbonDefinition";
@@ -17,9 +17,7 @@ interface ComputedTab extends Indexed<TabConfiguration> {
shouldShow: boolean;
}
const isNewLayout = isExperimentalFeatureEnabled("new-layout");
export default function Ribbon({ children }: { children?: preact.ComponentChildren }) {
export default function Ribbon() {
const { note, ntxId, hoistedNoteId, notePath, noteContext, componentId, isReadOnlyTemporarilyDisabled } = useNoteContext();
const noteType = useNoteProperty(note, "type");
const [ activeTabIndex, setActiveTabIndex ] = useState<number | undefined>();
@@ -32,8 +30,7 @@ export default function Ribbon({ children }: { children?: preact.ComponentChildr
async function refresh() {
const computedTabs: ComputedTab[] = [];
for (const tab of TAB_CONFIGURATION) {
const shouldAvoid = (isNewLayout && tab.avoidInNewLayout);
const shouldShow = !shouldAvoid && await shouldShowTab(tab.show, titleContext);
const shouldShow = await shouldShowTab(tab.show, titleContext);
computedTabs.push({
...tab,
shouldShow: !!shouldShow
@@ -92,7 +89,7 @@ export default function Ribbon({ children }: { children?: preact.ComponentChildr
/>
))}
</div>
{children}
<NoteActions />
</div>
<div className="ribbon-body-container">
@@ -115,7 +112,7 @@ export default function Ribbon({ children }: { children?: preact.ComponentChildr
noteContext={noteContext}
componentId={componentId}
activate={useCallback(() => {
setActiveTabIndex(tab.index)
setActiveTabIndex(tab.index);
}, [setActiveTabIndex])}
/>
</div>

View File

@@ -1,4 +1,3 @@
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
import { t } from "../../services/i18n";
import options from "../../services/options";
import BasicPropertiesTab from "./BasicPropertiesTab";
@@ -18,8 +17,6 @@ import ScriptTab from "./ScriptTab";
import SearchDefinitionTab from "./SearchDefinitionTab";
import SimilarNotesTab from "./SimilarNotesTab";
const isNewLayout = isExperimentalFeatureEnabled("new-layout");
export const RIBBON_TAB_DEFINITIONS: TabConfiguration[] = [
{
title: t("classic_editor_toolbar.title"),
@@ -30,15 +27,14 @@ export const RIBBON_TAB_DEFINITIONS: TabConfiguration[] = [
toggleCommand: "toggleRibbonTabClassicEditor",
content: FormattingToolbar,
activate: ({ note }) => !options.is("editedNotesOpenInRibbon") || !note?.hasOwnedLabel("dateNote"),
stayInDom: !isNewLayout,
avoidInNewLayout: true
stayInDom: true
},
{
title: ({ note }) => note?.isTriliumSqlite() ? t("script_executor.query") : t("script_executor.script"),
icon: "bx bx-play",
content: ScriptTab,
activate: true,
show: ({ note }) => note && !isNewLayout &&
show: ({ note }) => note &&
(note.isTriliumScript() || note.isTriliumSqlite()) &&
(note.hasLabel("executeDescription") || note.hasLabel("executeButton"))
},
@@ -60,14 +56,14 @@ export const RIBBON_TAB_DEFINITIONS: TabConfiguration[] = [
title: t("book_properties.book_properties"),
icon: "bx bx-book",
content: CollectionPropertiesTab,
show: ({ note }) => !isNewLayout && note?.type === "book" || note?.type === "search",
show: ({ note }) => (note?.type === "book" || note?.type === "search"),
toggleCommand: "toggleRibbonTabBookProperties"
},
{
title: t("note_properties.info"),
icon: "bx bx-info-square",
content: NotePropertiesTab,
show: ({ note }) => !isNewLayout && !!note?.getLabelValue("pageUrl"),
show: ({ note }) => !!note?.getLabelValue("pageUrl"),
activate: true
},
{
@@ -90,49 +86,49 @@ export const RIBBON_TAB_DEFINITIONS: TabConfiguration[] = [
title: t("basic_properties.basic_properties"),
icon: "bx bx-slider",
content: BasicPropertiesTab,
show: ({note}) => !isNewLayout && !note?.isLaunchBarConfig(),
show: ({note}) => !note?.isLaunchBarConfig(),
toggleCommand: "toggleRibbonTabBasicProperties"
},
{
title: t("owned_attribute_list.owned_attributes"),
icon: "bx bx-list-check",
content: OwnedAttributesTab,
show: ({note}) => !isNewLayout && !note?.isLaunchBarConfig(),
show: ({note}) => !note?.isLaunchBarConfig(),
toggleCommand: "toggleRibbonTabOwnedAttributes",
stayInDom: !isNewLayout
stayInDom: true
},
{
title: t("inherited_attribute_list.title"),
icon: "bx bx-list-plus",
content: InheritedAttributesTab,
show: ({note}) => !isNewLayout && !note?.isLaunchBarConfig(),
show: ({note}) => !note?.isLaunchBarConfig(),
toggleCommand: "toggleRibbonTabInheritedAttributes"
},
{
title: t("note_paths.title"),
icon: "bx bx-collection",
content: NotePathsTab,
show: !isNewLayout,
show: true,
toggleCommand: "toggleRibbonTabNotePaths"
},
{
title: t("note_map.title"),
icon: "bx bxs-network-chart",
content: NoteMapTab,
show: !isNewLayout,
show: true,
toggleCommand: "toggleRibbonTabNoteMap"
},
{
title: t("similar_notes.title"),
icon: "bx bx-bar-chart",
show: ({ note }) => !isNewLayout && note?.type !== "search" && !note?.isLabelTruthy("similarNotesWidgetDisabled"),
show: ({ note }) => note?.type !== "search" && !note?.isLabelTruthy("similarNotesWidgetDisabled"),
content: SimilarNotesTab,
toggleCommand: "toggleRibbonTabSimilarNotes"
},
{
title: t("note_info_widget.title"),
icon: "bx bx-info-circle",
show: ({ note }) => !isNewLayout && !!note,
show: ({ note }) => !!note,
content: NoteInfoTab,
toggleCommand: "toggleRibbonTabNoteInfo"
}

View File

@@ -1,360 +1,361 @@
import FormTextArea from "../react/FormTextArea";
import NoteAutocomplete from "../react/NoteAutocomplete";
import FormSelect from "../react/FormSelect";
import Icon from "../react/Icon";
import FormTextBox from "../react/FormTextBox";
import { AttributeType } from "@triliumnext/commons";
import { ComponentChildren, VNode } from "preact";
import { useEffect, useMemo, useRef } from "preact/hooks";
import appContext from "../../components/app_context";
import FNote from "../../entities/fnote";
import { removeOwnedAttributesByNameOrType } from "../../services/attributes";
import { AttributeType } from "@triliumnext/commons";
import { useNoteLabel, useNoteRelation, useSpacedUpdate, useTooltip } from "../react/hooks";
import { t } from "../../services/i18n";
import { useEffect, useMemo, useRef } from "preact/hooks";
import appContext from "../../components/app_context";
import server from "../../services/server";
import FormSelect from "../react/FormSelect";
import FormTextArea from "../react/FormTextArea";
import FormTextBox from "../react/FormTextBox";
import HelpRemoveButtons from "../react/HelpRemoveButtons";
import { useNoteLabel, useNoteRelation, useSpacedUpdate, useTooltip } from "../react/hooks";
import Icon from "../react/Icon";
import NoteAutocomplete from "../react/NoteAutocomplete";
export interface SearchOption {
attributeName: string;
attributeType: "label" | "relation";
icon: string;
label: string;
tooltip?: string;
component: (props: SearchOptionProps) => VNode;
defaultValue?: string;
additionalAttributesToDelete?: { type: "label" | "relation", name: string }[];
attributeName: string;
attributeType: "label" | "relation";
icon: string;
label: string;
tooltip?: string;
component: (props: SearchOptionProps) => VNode;
defaultValue?: string;
additionalAttributesToDelete?: { type: "label" | "relation", name: string }[];
}
interface SearchOptionProps {
note: FNote;
refreshResults: () => void;
attributeName: string;
attributeType: "label" | "relation";
additionalAttributesToDelete?: { type: "label" | "relation", name: string }[];
defaultValue?: string;
error?: { message: string };
note: FNote;
refreshResults: () => void;
attributeName: string;
attributeType: "label" | "relation";
additionalAttributesToDelete?: { type: "label" | "relation", name: string }[];
defaultValue?: string;
error?: { message: string };
}
export const SEARCH_OPTIONS: SearchOption[] = [
{
attributeName: "searchString",
attributeType: "label",
icon: "bx bx-text",
label: t("search_definition.search_string"),
component: SearchStringOption
},
{
attributeName: "searchScript",
attributeType: "relation",
defaultValue: "root",
icon: "bx bx-code",
label: t("search_definition.search_script"),
component: SearchScriptOption
},
{
attributeName: "ancestor",
attributeType: "relation",
defaultValue: "root",
icon: "bx bx-filter-alt",
label: t("search_definition.ancestor"),
component: AncestorOption,
additionalAttributesToDelete: [ { type: "label", name: "ancestorDepth" } ]
},
{
attributeName: "fastSearch",
attributeType: "label",
icon: "bx bx-run",
label: t("search_definition.fast_search"),
tooltip: t("search_definition.fast_search_description"),
component: FastSearchOption
},
{
attributeName: "includeArchivedNotes",
attributeType: "label",
icon: "bx bx-archive",
label: t("search_definition.include_archived"),
tooltip: t("search_definition.include_archived_notes_description"),
component: IncludeArchivedNotesOption
},
{
attributeName: "orderBy",
attributeType: "label",
defaultValue: "relevancy",
icon: "bx bx-arrow-from-top",
label: t("search_definition.order_by"),
component: OrderByOption,
additionalAttributesToDelete: [ { type: "label", name: "orderDirection" } ]
},
{
attributeName: "limit",
attributeType: "label",
defaultValue: "10",
icon: "bx bx-stop",
label: t("search_definition.limit"),
tooltip: t("search_definition.limit_description"),
component: LimitOption
},
{
attributeName: "debug",
attributeType: "label",
icon: "bx bx-bug",
label: t("search_definition.debug"),
tooltip: t("search_definition.debug_description"),
component: DebugOption
}
{
attributeName: "searchString",
attributeType: "label",
icon: "bx bx-text",
label: t("search_definition.search_string"),
component: SearchStringOption
},
{
attributeName: "searchScript",
attributeType: "relation",
defaultValue: "root",
icon: "bx bx-code",
label: t("search_definition.search_script"),
component: SearchScriptOption
},
{
attributeName: "ancestor",
attributeType: "relation",
defaultValue: "root",
icon: "bx bx-filter-alt",
label: t("search_definition.ancestor"),
component: AncestorOption,
additionalAttributesToDelete: [ { type: "label", name: "ancestorDepth" } ]
},
{
attributeName: "fastSearch",
attributeType: "label",
icon: "bx bx-run",
label: t("search_definition.fast_search"),
tooltip: t("search_definition.fast_search_description"),
component: FastSearchOption
},
{
attributeName: "includeArchivedNotes",
attributeType: "label",
icon: "bx bx-archive",
label: t("search_definition.include_archived"),
tooltip: t("search_definition.include_archived_notes_description"),
component: IncludeArchivedNotesOption
},
{
attributeName: "orderBy",
attributeType: "label",
defaultValue: "relevancy",
icon: "bx bx-arrow-from-top",
label: t("search_definition.order_by"),
component: OrderByOption,
additionalAttributesToDelete: [ { type: "label", name: "orderDirection" } ]
},
{
attributeName: "limit",
attributeType: "label",
defaultValue: "10",
icon: "bx bx-stop",
label: t("search_definition.limit"),
tooltip: t("search_definition.limit_description"),
component: LimitOption
},
{
attributeName: "debug",
attributeType: "label",
icon: "bx bx-bug",
label: t("search_definition.debug"),
tooltip: t("search_definition.debug_description"),
component: DebugOption
}
];
function SearchOption({ note, title, titleIcon, children, help, attributeName, attributeType, additionalAttributesToDelete }: {
note: FNote;
title: string,
titleIcon?: string,
children?: ComponentChildren,
help?: ComponentChildren,
attributeName: string,
attributeType: AttributeType,
additionalAttributesToDelete?: { type: "label" | "relation", name: string }[]
note: FNote;
title: string,
titleIcon?: string,
children?: ComponentChildren,
help?: ComponentChildren,
attributeName: string,
attributeType: AttributeType,
additionalAttributesToDelete?: { type: "label" | "relation", name: string }[]
}) {
return (
<tr className={attributeName}>
<td className="title-column">
{titleIcon && <><Icon icon={titleIcon} />{" "}</>}
{title}
</td>
<td>{children}</td>
<HelpRemoveButtons
help={help}
removeText={t("abstract_search_option.remove_this_search_option")}
onRemove={() => {
removeOwnedAttributesByNameOrType(note, attributeType, attributeName);
if (additionalAttributesToDelete) {
for (const { type, name } of additionalAttributesToDelete) {
removeOwnedAttributesByNameOrType(note, type, name);
}
}
}}
/>
</tr>
)
return (
<tr className={attributeName}>
<td className="title-column">
{titleIcon && <><Icon icon={titleIcon} />{" "}</>}
{title}
</td>
<td>{children}</td>
<HelpRemoveButtons
help={help}
removeText={t("abstract_search_option.remove_this_search_option")}
onRemove={() => {
removeOwnedAttributesByNameOrType(note, attributeType, attributeName);
if (additionalAttributesToDelete) {
for (const { type, name } of additionalAttributesToDelete) {
removeOwnedAttributesByNameOrType(note, type, name);
}
}
}}
/>
</tr>
);
}
function SearchStringOption({ note, refreshResults, error, ...restProps }: SearchOptionProps) {
const [ searchString, setSearchString ] = useNoteLabel(note, "searchString");
const inputRef = useRef<HTMLTextAreaElement>(null);
const currentValue = useRef(searchString ?? "");
const spacedUpdate = useSpacedUpdate(async () => {
const searchString = currentValue.current;
appContext.lastSearchString = searchString;
setSearchString(searchString);
const [ searchString, setSearchString ] = useNoteLabel(note, "searchString");
const inputRef = useRef<HTMLTextAreaElement>(null);
const currentValue = useRef(searchString ?? "");
const spacedUpdate = useSpacedUpdate(async () => {
const searchString = currentValue.current;
appContext.lastSearchString = searchString;
setSearchString(searchString);
if (note.title.startsWith(t("search_string.search_prefix"))) {
await server.put(`notes/${note.noteId}/title`, {
title: `${t("search_string.search_prefix")} ${searchString.length < 30 ? searchString : `${searchString.substr(0, 30)}`}`
});
}
}, 1000);
// React to errors
const { showTooltip, hideTooltip } = useTooltip(inputRef, {
trigger: "manual",
title: `${t("search_string.error", { error: error?.message })}`,
html: true,
placement: "bottom"
});
// Auto-focus.
useEffect(() => inputRef.current?.focus(), []);
useEffect(() => {
if (error) {
showTooltip();
setTimeout(() => hideTooltip(), 4000);
} else {
hideTooltip();
}
}, [ error ]);
return <SearchOption
title={t("search_string.title_column")}
help={<>
<strong>{t("search_string.search_syntax")}</strong> - {t("search_string.also_see")} <a href="#" data-help-page="search.html">{t("search_string.complete_help")}</a>
<ul style="marigin-bottom: 0;">
<li>{t("search_string.full_text_search")}</li>
<li><code>#abc</code> - {t("search_string.label_abc")}</li>
<li><code>#year = 2019</code> - {t("search_string.label_year")}</li>
<li><code>#rock #pop</code> - {t("search_string.label_rock_pop")}</li>
<li><code>#rock or #pop</code> - {t("search_string.label_rock_or_pop")}</li>
<li><code>#year &lt;= 2000</code> - {t("search_string.label_year_comparison")}</li>
<li><code>note.dateCreated &gt;= MONTH-1</code> - {t("search_string.label_date_created")}</li>
</ul>
</>}
note={note} {...restProps}
>
<FormTextArea
inputRef={inputRef}
className="search-string"
placeholder={t("search_string.placeholder")}
currentValue={searchString ?? ""}
onChange={text => {
currentValue.current = text;
spacedUpdate.scheduleUpdate();
}}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
// this also in effect disallows new lines in query string.
// on one hand, this makes sense since search string is a label
// on the other hand, it could be nice for structuring long search string. It's probably a niche case though.
await spacedUpdate.updateNowIfNecessary();
refreshResults();
if (note.title.startsWith(t("search_string.search_prefix"))) {
await server.put(`notes/${note.noteId}/title`, {
title: `${t("search_string.search_prefix")} ${searchString.length < 30 ? searchString : `${searchString.substr(0, 30)}`}`
});
}
}}
/>
</SearchOption>
}, 1000);
// React to errors
const { showTooltip, hideTooltip } = useTooltip(inputRef, {
trigger: "manual",
title: `${t("search_string.error", { error: error?.message })}`,
html: true,
placement: "bottom"
});
// Auto-focus.
useEffect(() => inputRef.current?.focus(), []);
useEffect(() => {
if (error) {
showTooltip();
setTimeout(() => hideTooltip(), 4000);
} else {
hideTooltip();
}
}, [ error ]);
return <SearchOption
title={t("search_string.title_column")}
help={<>
<strong>{t("search_string.search_syntax")}</strong> - {t("search_string.also_see")} <a href="#" data-help-page="search.html">{t("search_string.complete_help")}</a>
<ul style="marigin-bottom: 0;">
<li>{t("search_string.full_text_search")}</li>
<li><code>#abc</code> - {t("search_string.label_abc")}</li>
<li><code>#year = 2019</code> - {t("search_string.label_year")}</li>
<li><code>#rock #pop</code> - {t("search_string.label_rock_pop")}</li>
<li><code>#rock or #pop</code> - {t("search_string.label_rock_or_pop")}</li>
<li><code>#year &lt;= 2000</code> - {t("search_string.label_year_comparison")}</li>
<li><code>note.dateCreated &gt;= MONTH-1</code> - {t("search_string.label_date_created")}</li>
</ul>
</>}
note={note} {...restProps}
>
<FormTextArea
inputRef={inputRef}
className="search-string"
placeholder={t("search_string.placeholder")}
currentValue={searchString ?? ""}
onChange={text => {
currentValue.current = text;
spacedUpdate.scheduleUpdate();
}}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
// this also in effect disallows new lines in query string.
// on one hand, this makes sense since search string is a label
// on the other hand, it could be nice for structuring long search string. It's probably a niche case though.
await spacedUpdate.updateNowIfNecessary();
refreshResults();
}
}}
/>
</SearchOption>;
}
function SearchScriptOption({ note, ...restProps }: SearchOptionProps) {
const [ searchScript, setSearchScript ] = useNoteRelation(note, "searchScript");
const [ searchScript, setSearchScript ] = useNoteRelation(note, "searchScript");
return <SearchOption
title={t("search_script.title")}
help={<>
<p>{t("search_script.description1")}</p>
<p>{t("search_script.description2")}</p>
<p>{t("search_script.example_title")}</p>
<pre>{t("search_script.example_code")}</pre>
{t("search_script.note")}
</>}
note={note} {...restProps}
>
<NoteAutocomplete
noteId={searchScript !== "root" ? searchScript ?? undefined : undefined}
noteIdChanged={noteId => setSearchScript(noteId ?? "root")}
placeholder={t("search_script.placeholder")}
/>
</SearchOption>
return <SearchOption
title={t("search_script.title")}
help={<>
<p>{t("search_script.description1")}</p>
<p>{t("search_script.description2")}</p>
<p>{t("search_script.example_title")}</p>
<pre>{t("search_script.example_code")}</pre>
{t("search_script.note")}
</>}
note={note} {...restProps}
>
<NoteAutocomplete
noteId={searchScript !== "root" ? searchScript ?? undefined : undefined}
noteIdChanged={noteId => setSearchScript(noteId ?? "root")}
placeholder={t("search_script.placeholder")}
/>
</SearchOption>;
}
function AncestorOption({ note, ...restProps}: SearchOptionProps) {
const [ ancestor, setAncestor ] = useNoteRelation(note, "ancestor");
const [ depth, setDepth ] = useNoteLabel(note, "ancestorDepth");
const [ ancestor, setAncestor ] = useNoteRelation(note, "ancestor");
const [ depth, setDepth ] = useNoteLabel(note, "ancestorDepth");
const options = useMemo(() => {
const options: { value: string | undefined; label: string }[] = [
{ value: "", label: t("ancestor.depth_doesnt_matter") },
{ value: "eq1", label: `${t("ancestor.depth_eq", { count: 1 })} (${t("ancestor.direct_children")})` }
];
const options = useMemo(() => {
const options: { value: string | undefined; label: string }[] = [
{ value: "", label: t("ancestor.depth_doesnt_matter") },
{ value: "eq1", label: `${t("ancestor.depth_eq", { count: 1 })} (${t("ancestor.direct_children")})` }
];
for (let i=2; i<=9; i++) options.push({ value: "eq" + i, label: t("ancestor.depth_eq", { count: i }) });
for (let i=0; i<=9; i++) options.push({ value: "gt" + i, label: t("ancestor.depth_gt", { count: i }) });
for (let i=2; i<=9; i++) options.push({ value: "lt" + i, label: t("ancestor.depth_lt", { count: i }) });
for (let i=2; i<=9; i++) options.push({ value: `eq${ i}`, label: t("ancestor.depth_eq", { count: i }) });
for (let i=0; i<=9; i++) options.push({ value: `gt${ i}`, label: t("ancestor.depth_gt", { count: i }) });
for (let i=2; i<=9; i++) options.push({ value: `lt${ i}`, label: t("ancestor.depth_lt", { count: i }) });
return options;
}, []);
return options;
}, []);
return <SearchOption
title={t("ancestor.label")}
note={note} {...restProps}
>
<div style={{display: "flex", alignItems: "center"}}>
<NoteAutocomplete
noteId={ancestor !== "root" ? ancestor ?? undefined : undefined}
noteIdChanged={noteId => setAncestor(noteId ?? "root")}
placeholder={t("ancestor.placeholder")}
/>
return <SearchOption
title={t("ancestor.label")}
note={note} {...restProps}
>
<div style={{display: "flex", alignItems: "center"}}>
<NoteAutocomplete
noteId={ancestor !== "root" ? ancestor ?? undefined : undefined}
noteIdChanged={noteId => setAncestor(noteId ?? "root")}
placeholder={t("ancestor.placeholder")}
/>
<div style="margin-inline-start: 10px; margin-inline-end: 10px">{t("ancestor.depth_label")}:</div>
<FormSelect
values={options}
keyProperty="value" titleProperty="label"
currentValue={depth ?? ""} onChange={(value) => setDepth(value ? value : null)}
style={{ flexShrink: 3 }}
/>
</div>
</SearchOption>;
<div style="margin-inline-start: 10px; margin-inline-end: 10px">{t("ancestor.depth_label")}:</div>
<FormSelect
values={options}
keyProperty="value" titleProperty="label"
currentValue={depth ?? ""} onChange={(value) => setDepth(value ? value : null)}
style={{ flexShrink: 3 }}
/>
</div>
</SearchOption>;
}
function FastSearchOption({ ...restProps }: SearchOptionProps) {
return <SearchOption
titleIcon="bx bx-run" title={t("fast_search.fast_search")}
help={t("fast_search.description")}
{...restProps}
/>
return <SearchOption
titleIcon="bx bx-run" title={t("fast_search.fast_search")}
help={t("fast_search.description")}
{...restProps}
/>;
}
function DebugOption({ ...restProps }: SearchOptionProps) {
return <SearchOption
titleIcon="bx bx-bug" title={t("debug.debug")}
help={<>
<p>{t("debug.debug_info")}</p>
{t("debug.access_info")}
</>}
{...restProps}
/>
return <SearchOption
titleIcon="bx bx-bug" title={t("debug.debug")}
help={<>
<p>{t("debug.debug_info")}</p>
{t("debug.access_info")}
</>}
{...restProps}
/>;
}
function IncludeArchivedNotesOption({ ...restProps }: SearchOptionProps) {
return <SearchOption
titleIcon="bx bx-archive" title={t("include_archived_notes.include_archived_notes")}
{...restProps}
/>
return <SearchOption
titleIcon="bx bx-archive" title={t("include_archived_notes.include_archived_notes")}
{...restProps}
/>;
}
function OrderByOption({ note, ...restProps }: SearchOptionProps) {
const [ orderBy, setOrderBy ] = useNoteLabel(note, "orderBy");
const [ orderDirection, setOrderDirection ] = useNoteLabel(note, "orderDirection");
const [ orderBy, setOrderBy ] = useNoteLabel(note, "orderBy");
const [ orderDirection, setOrderDirection ] = useNoteLabel(note, "orderDirection");
return <SearchOption
titleIcon="bx bx-arrow-from-top"
title={t("order_by.order_by")}
note={note} {...restProps}
>
<FormSelect
className="w-auto d-inline"
currentValue={orderBy ?? "relevancy"} onChange={setOrderBy}
keyProperty="value" titleProperty="title"
values={[
{ value: "relevancy", title: t("order_by.relevancy") },
{ value: "title", title: t("order_by.title") },
{ value: "dateCreated", title: t("order_by.date_created") },
{ value: "dateModified", title: t("order_by.date_modified") },
{ value: "contentSize", title: t("order_by.content_size") },
{ value: "contentAndAttachmentsSize", title: t("order_by.content_and_attachments_size") },
{ value: "contentAndAttachmentsAndRevisionsSize", title: t("order_by.content_and_attachments_and_revisions_size") },
{ value: "revisionCount", title: t("order_by.revision_count") },
{ value: "childrenCount", title: t("order_by.children_count") },
{ value: "parentCount", title: t("order_by.parent_count") },
{ value: "ownedLabelCount", title: t("order_by.owned_label_count") },
{ value: "ownedRelationCount", title: t("order_by.owned_relation_count") },
{ value: "targetRelationCount", title: t("order_by.target_relation_count") },
{ value: "random", title: t("order_by.random") }
]}
/>
{" "}
<FormSelect
className="w-auto d-inline"
currentValue={orderDirection ?? "asc"} onChange={setOrderDirection}
keyProperty="value" titleProperty="title"
values={[
{ value: "asc", title: t("order_by.asc") },
{ value: "desc", title: t("order_by.desc") }
]}
/>
</SearchOption>
return <SearchOption
titleIcon="bx bx-arrow-from-top"
title={t("order_by.order_by")}
note={note} {...restProps}
>
<FormSelect
className="w-auto d-inline"
currentValue={orderBy ?? "relevancy"} onChange={setOrderBy}
keyProperty="value" titleProperty="title"
values={[
{ value: "relevancy", title: t("order_by.relevancy") },
{ value: "title", title: t("order_by.title") },
{ value: "dateCreated", title: t("order_by.date_created") },
{ value: "dateModified", title: t("order_by.date_modified") },
{ value: "contentSize", title: t("order_by.content_size") },
{ value: "contentAndAttachmentsSize", title: t("order_by.content_and_attachments_size") },
{ value: "contentAndAttachmentsAndRevisionsSize", title: t("order_by.content_and_attachments_and_revisions_size") },
{ value: "revisionCount", title: t("order_by.revision_count") },
{ value: "childrenCount", title: t("order_by.children_count") },
{ value: "parentCount", title: t("order_by.parent_count") },
{ value: "ownedLabelCount", title: t("order_by.owned_label_count") },
{ value: "ownedRelationCount", title: t("order_by.owned_relation_count") },
{ value: "targetRelationCount", title: t("order_by.target_relation_count") },
{ value: "random", title: t("order_by.random") }
]}
/>
{" "}
<FormSelect
className="w-auto d-inline"
currentValue={orderDirection ?? "asc"} onChange={setOrderDirection}
keyProperty="value" titleProperty="title"
values={[
{ value: "asc", title: t("order_by.asc") },
{ value: "desc", title: t("order_by.desc") }
]}
/>
</SearchOption>;
}
function LimitOption({ note, defaultValue, ...restProps }: SearchOptionProps) {
const [ limit, setLimit ] = useNoteLabel(note, "limit");
const [ limit, setLimit ] = useNoteLabel(note, "limit");
return <SearchOption
titleIcon="bx bx-stop"
title={t("limit.limit")}
help={t("limit.take_first_x_results")}
note={note} {...restProps}
>
<FormTextBox
type="number" min="1" step="1"
currentValue={limit ?? defaultValue} onChange={setLimit}
/>
</SearchOption>
return <SearchOption
titleIcon="bx bx-stop"
title={t("limit.limit")}
help={t("limit.take_first_x_results")}
note={note} {...restProps}
>
<FormTextBox
type="number" min="1" step="1"
currentValue={limit ?? defaultValue} onChange={setLimit}
/>
</SearchOption>;
}

View File

@@ -1,207 +1,218 @@
import { t } from "../../services/i18n";
import Button from "../react/Button";
import { TabContext } from "./ribbon-interface";
import { SaveSearchNoteResponse } from "@triliumnext/commons";
import attributes from "../../services/attributes";
import FNote from "../../entities/fnote";
import toast from "../../services/toast";
import froca from "../../services/froca";
import { useContext, useEffect, useState } from "preact/hooks";
import { ParentComponent } from "../react/react_utils";
import { useTriliumEvent } from "../react/hooks";
import appContext from "../../components/app_context";
import server from "../../services/server";
import ws from "../../services/ws";
import tree from "../../services/tree";
import { SEARCH_OPTIONS, SearchOption } from "./SearchDefinitionOptions";
import Dropdown from "../react/Dropdown";
import Icon from "../react/Icon";
import bulk_action, { ACTION_GROUPS } from "../../services/bulk_action";
import { FormListHeader, FormListItem } from "../react/FormList";
import RenameNoteBulkAction from "../bulk_actions/note/rename_note";
import { getErrorMessage } from "../../services/utils";
import "./SearchDefinitionTab.css";
export default function SearchDefinitionTab({ note, ntxId, hidden }: TabContext) {
const parentComponent = useContext(ParentComponent);
const [ searchOptions, setSearchOptions ] = useState<{ availableOptions: SearchOption[], activeOptions: SearchOption[] }>();
const [ error, setError ] = useState<{ message: string }>();
import { SaveSearchNoteResponse } from "@triliumnext/commons";
import { useContext, useEffect, useState } from "preact/hooks";
function refreshOptions() {
if (!note) return;
import appContext from "../../components/app_context";
import FNote from "../../entities/fnote";
import attributes from "../../services/attributes";
import bulk_action, { ACTION_GROUPS } from "../../services/bulk_action";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
import froca from "../../services/froca";
import { t } from "../../services/i18n";
import server from "../../services/server";
import toast from "../../services/toast";
import tree from "../../services/tree";
import { getErrorMessage } from "../../services/utils";
import ws from "../../services/ws";
import RenameNoteBulkAction from "../bulk_actions/note/rename_note";
import CollectionProperties from "../note_bars/CollectionProperties";
import Button from "../react/Button";
import Dropdown from "../react/Dropdown";
import { FormListHeader, FormListItem } from "../react/FormList";
import { useTriliumEvent } from "../react/hooks";
import Icon from "../react/Icon";
import { ParentComponent } from "../react/react_utils";
import { TabContext } from "./ribbon-interface";
import { SEARCH_OPTIONS, SearchOption } from "./SearchDefinitionOptions";
const availableOptions: SearchOption[] = [];
const activeOptions: SearchOption[] = [];
const isNewLayout = isExperimentalFeatureEnabled("new-layout");
for (const searchOption of SEARCH_OPTIONS) {
const attr = note.getAttribute(searchOption.attributeType, searchOption.attributeName);
if (attr) {
activeOptions.push(searchOption);
} else {
availableOptions.push(searchOption);
}
}
export default function SearchDefinitionTab({ note, ntxId, hidden }: Pick<TabContext, "note" | "ntxId" | "hidden">) {
const parentComponent = useContext(ParentComponent);
const [ searchOptions, setSearchOptions ] = useState<{ availableOptions: SearchOption[], activeOptions: SearchOption[] }>();
const [ error, setError ] = useState<{ message: string }>();
setSearchOptions({ availableOptions, activeOptions });
}
function refreshOptions() {
if (!note) return;
async function refreshResults() {
const noteId = note?.noteId;
if (!noteId) {
return;
}
const availableOptions: SearchOption[] = [];
const activeOptions: SearchOption[] = [];
try {
const result = await froca.loadSearchNote(noteId);
if (result?.error) {
setError({ message: result?.error})
} else {
setError(undefined);
for (const searchOption of SEARCH_OPTIONS) {
const attr = note.getAttribute(searchOption.attributeType, searchOption.attributeName);
if (attr) {
activeOptions.push(searchOption);
} else {
availableOptions.push(searchOption);
}
}
} catch (e: unknown) {
toast.showError(getErrorMessage(e));
setSearchOptions({ availableOptions, activeOptions });
}
parentComponent?.triggerEvent("searchRefreshed", { ntxId });
}
// Refresh the list of available and active options.
useEffect(refreshOptions, [ note ]);
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
if (loadResults.getAttributeRows().find((attrRow) => attributes.isAffecting(attrRow, note))) {
refreshOptions();
}
});
return (
<div className="search-definition-widget">
<div className="search-settings">
{note && !hidden &&
<table className="search-setting-table">
<tbody>
<tr>
<td className="title-column">{t("search_definition.add_search_option")}</td>
<td colSpan={2} className="add-search-option">
{searchOptions?.availableOptions.map(({ icon, label, tooltip, attributeName, attributeType, defaultValue }) => (
<Button
size="small"
icon={icon}
text={label}
title={tooltip}
onClick={() => attributes.setAttribute(note, attributeType, attributeName, defaultValue ?? "")}
/>
))}
<AddBulkActionButton note={note} />
</td>
</tr>
</tbody>
<tbody className="search-options">
{searchOptions?.activeOptions.map(({ attributeType, attributeName, component, additionalAttributesToDelete, defaultValue }) => {
const Component = component;
return <Component
attributeName={attributeName}
attributeType={attributeType}
note={note}
refreshResults={refreshResults}
error={error}
additionalAttributesToDelete={additionalAttributesToDelete}
defaultValue={defaultValue}
/>;
})}
</tbody>
<BulkActionsList note={note} />
<tbody className="search-actions">
<tr>
<td colSpan={3}>
<div className="search-actions-container">
<Button
icon="bx bx-search"
text={t("search_definition.search_button")}
keyboardShortcut="Enter"
onClick={refreshResults}
/>
<Button
icon="bx bxs-zap"
text={t("search_definition.search_execute")}
onClick={async () => {
await server.post(`search-and-execute-note/${note.noteId}`);
refreshResults();
toast.showMessage(t("search_definition.actions_executed"), 3000);
}}
/>
{note.isHiddenCompletely() && <Button
icon="bx bx-save"
text={t("search_definition.save_to_note")}
onClick={async () => {
const { notePath } = await server.post<SaveSearchNoteResponse>("special-notes/save-search-note", { searchNoteId: note.noteId });
if (!notePath) {
return;
}
await ws.waitForMaxKnownEntityChangeId();
await appContext.tabManager.getActiveContext()?.setNote(notePath);
// Note the {{- notePathTitle}} in json file is not typo, it's unescaping
// See https://www.i18next.com/translation-function/interpolation#unescape
toast.showMessage(t("search_definition.search_note_saved", { notePathTitle: await tree.getNotePathTitle(notePath) }));
}}
/>}
</div>
</td>
</tr>
</tbody>
</table>
async function refreshResults() {
const noteId = note?.noteId;
if (!noteId) {
return;
}
</div>
</div>
)
try {
const result = await froca.loadSearchNote(noteId);
if (result?.error) {
setError({ message: result?.error});
} else {
setError(undefined);
}
} catch (e: unknown) {
toast.showError(getErrorMessage(e));
}
parentComponent?.triggerEvent("searchRefreshed", { ntxId });
}
// Refresh the list of available and active options.
useEffect(refreshOptions, [ note ]);
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
if (loadResults.getAttributeRows().find((attrRow) => attributes.isAffecting(attrRow, note))) {
refreshOptions();
}
});
return (
<div className="search-definition-widget">
<div className="search-settings">
{note && !hidden && (
<table className="search-setting-table">
<tbody>
<tr>
<td className="title-column">{t("search_definition.add_search_option")}</td>
<td colSpan={2} className="add-search-option">
{searchOptions?.availableOptions.map(({ icon, label, tooltip, attributeName, attributeType, defaultValue }) => (
<Button
size="small"
icon={icon}
text={label}
title={tooltip}
onClick={() => attributes.setAttribute(note, attributeType, attributeName, defaultValue ?? "")}
/>
))}
<AddBulkActionButton note={note} />
</td>
</tr>
</tbody>
<tbody className="search-options">
{searchOptions?.activeOptions.map(({ attributeType, attributeName, component, additionalAttributesToDelete, defaultValue }) => {
const Component = component;
return <Component
attributeName={attributeName}
attributeType={attributeType}
note={note}
refreshResults={refreshResults}
error={error}
additionalAttributesToDelete={additionalAttributesToDelete}
defaultValue={defaultValue}
/>;
})}
{isNewLayout && <tr className="view-options">
<td className="title-column">{t("search_definition.view_options")}</td>
<td><CollectionProperties note={note} /></td>
</tr>}
</tbody>
<BulkActionsList note={note} />
<tbody className="search-actions">
<tr>
<td colSpan={3}>
<div className="search-actions-container">
<Button
icon="bx bx-search"
text={t("search_definition.search_button")}
keyboardShortcut="Enter"
onClick={refreshResults}
/>
<Button
icon="bx bxs-zap"
text={t("search_definition.search_execute")}
onClick={async () => {
await server.post(`search-and-execute-note/${note.noteId}`);
refreshResults();
toast.showMessage(t("search_definition.actions_executed"), 3000);
}}
/>
{note.isHiddenCompletely() && <Button
icon="bx bx-save"
text={t("search_definition.save_to_note")}
onClick={async () => {
const { notePath } = await server.post<SaveSearchNoteResponse>("special-notes/save-search-note", { searchNoteId: note.noteId });
if (!notePath) {
return;
}
await ws.waitForMaxKnownEntityChangeId();
await appContext.tabManager.getActiveContext()?.setNote(notePath);
// Note the {{- notePathTitle}} in json file is not typo, it's unescaping
// See https://www.i18next.com/translation-function/interpolation#unescape
toast.showMessage(t("search_definition.search_note_saved", { notePathTitle: await tree.getNotePathTitle(notePath) }));
}}
/>}
</div>
</td>
</tr>
</tbody>
</table>
)}
</div>
</div>
);
}
function BulkActionsList({ note }: { note: FNote }) {
const [ bulkActions, setBulkActions ] = useState<RenameNoteBulkAction[]>();
const [ bulkActions, setBulkActions ] = useState<RenameNoteBulkAction[]>();
function refreshBulkActions() {
if (note) {
setBulkActions(bulk_action.parseActions(note));
function refreshBulkActions() {
if (note) {
setBulkActions(bulk_action.parseActions(note));
}
}
}
// React to changes.
useEffect(refreshBulkActions, [ note ]);
useTriliumEvent("entitiesReloaded", ({loadResults}) => {
if (loadResults.getAttributeRows().find(attr => attr.type === "label" && attr.name === "action" && attributes.isAffecting(attr, note))) {
refreshBulkActions();
}
});
// React to changes.
useEffect(refreshBulkActions, [ note ]);
useTriliumEvent("entitiesReloaded", ({loadResults}) => {
if (loadResults.getAttributeRows().find(attr => attr.type === "label" && attr.name === "action" && attributes.isAffecting(attr, note))) {
refreshBulkActions();
}
});
return (
<tbody className="action-options">
{bulkActions?.map(bulkAction => (
bulkAction.doRender()
))}
</tbody>
)
return (
<tbody className="action-options">
{bulkActions?.map(bulkAction => (
bulkAction.doRender()
))}
</tbody>
);
}
function AddBulkActionButton({ note }: { note: FNote }) {
return (
<Dropdown
buttonClassName="action-add-toggle btn btn-sm"
text={<><Icon icon="bx bxs-zap" />{" "}{t("search_definition.action")}</>}
noSelectButtonStyle
>
{ACTION_GROUPS.map(({ actions, title }) => (
<>
<FormListHeader text={title} />
return (
<Dropdown
buttonClassName="action-add-toggle btn btn-sm"
text={<><Icon icon="bx bxs-zap" />{" "}{t("search_definition.action")}</>}
noSelectButtonStyle
>
{ACTION_GROUPS.map(({ actions, title }) => (
<>
<FormListHeader text={title} />
{actions.map(({ actionName, actionTitle }) => (
<FormListItem onClick={() => bulk_action.addAction(note.noteId, actionName)}>{actionTitle}</FormListItem>
{actions.map(({ actionName, actionTitle }) => (
<FormListItem onClick={() => bulk_action.addAction(note.noteId, actionName)}>{actionTitle}</FormListItem>
))}
</>
))}
</>
))}
</Dropdown>
)
</Dropdown>
);
}

View File

@@ -1,12 +1,13 @@
import { useEffect, useState } from "preact/hooks";
import { TabContext } from "./ribbon-interface";
import { SimilarNoteResponse } from "@triliumnext/commons";
import server from "../../services/server";
import { t } from "../../services/i18n";
import froca from "../../services/froca";
import NoteLink from "../react/NoteLink";
import { useEffect, useState } from "preact/hooks";
export default function SimilarNotesTab({ note }: TabContext) {
import froca from "../../services/froca";
import { t } from "../../services/i18n";
import server from "../../services/server";
import NoteLink from "../react/NoteLink";
import { TabContext } from "./ribbon-interface";
export default function SimilarNotesTab({ note }: Pick<TabContext, "note">) {
const [ similarNotes, setSimilarNotes ] = useState<SimilarNoteResponse>();
useEffect(() => {
@@ -17,7 +18,7 @@ export default function SimilarNotesTab({ note }: TabContext) {
await froca.getNotes(noteIds, true); // preload all at once
}
setSimilarNotes(similarNotes);
});
});
}
}, [ note?.noteId ]);
@@ -42,5 +43,5 @@ export default function SimilarNotesTab({ note }: TabContext) {
)}
</div>
</div>
)
}
);
}

View File

@@ -1,7 +1,8 @@
import { KeyboardActionNames } from "@triliumnext/commons";
import { VNode } from "preact";
import NoteContext from "../../components/note_context";
import FNote from "../../entities/fnote";
import { VNode } from "preact";
export interface TabContext {
note: FNote | null | undefined;
@@ -30,5 +31,4 @@ export interface TabConfiguration {
* By default the tab content will not be rendered unless the tab is active (i.e. selected by the user). Setting to `true` will ensure that the tab is rendered even when inactive, for cases where the tab needs to be accessible at all times (e.g. for the detached editor toolbar) or if event handling is needed.
*/
stayInDom?: boolean;
avoidInNewLayout?: boolean;
}

View File

@@ -207,9 +207,6 @@ body.experimental-feature-new-layout .classic-toolbar-widget {
vertical-align: middle !important;
}
.note-info-widget .calculate-button {
padding: 0 10px;
}
/* #endregion */
/* #region Similar Notes */
@@ -448,8 +445,22 @@ body.experimental-feature-new-layout {
}
.ribbon-button-container {
--button-gap: 5px;
border-bottom: 0 !important;
margin: 0;
gap: var(--button-gap);
.note-actions-custom {
display: flex;
align-items: center;
height: 36px;
gap: var(--button-gap);
&> button:last-of-type {
margin-right: 1em;
}
}
}
}
/* #endregion */

View File

@@ -18,6 +18,7 @@ type Labels = {
language: string;
originalFileName: string;
pageUrl: string;
dateNote: string;
// Search
searchString: string;