New layout: Integrate small ribbon categories + collection properties (#8018)

This commit is contained in:
Elian Doran 2025-12-11 20:59:31 +02:00 committed by GitHub
commit 792a10ace5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 347 additions and 78 deletions

View File

@ -2031,7 +2031,7 @@
"book_properties_config": {
"hide-weekends": "Hide weekends",
"display-week-numbers": "Display week numbers",
"map-style": "Map style:",
"map-style": "Map style",
"max-nesting-depth": "Max nesting depth:",
"raster": "Raster",
"vector_light": "Vector (Light)",
@ -2146,6 +2146,12 @@
"backlinks_one": "{{count}} backlink",
"backlinks_other": "{{count}} backlinks",
"backlinks_description_one": "This note is linked from {{count}} other note.\n\nClick to view the list of backlinks.",
"backlinks_description_other": "This note is linked from {{count}} other notes.\n\nClick to view the list of backlinks."
"backlinks_description_other": "This note is linked from {{count}} other notes.\n\nClick to view the list of backlinks.",
"clipped_note": "Web clip",
"clipped_note_description": "This note was originally taken from {{url}}.\n\nClick to navigate to the source webpage.",
"execute_script": "Run script",
"execute_script_description": "This note is a script note. Click to execute the script.",
"execute_sql": "Run SQL",
"execute_sql_description": "This note is a SQL note. Click to execute the SQL query."
}
}

View File

@ -33,7 +33,9 @@
&.temporarily-editable-badge { --color: #4fa52b; }
&.read-only-badge { --color: #e33f3b; }
&.share-badge { --color: #3b82f6; }
&.clipped-note-badge { --color: #57a2a5; }
&.backlinks-badge { color: var(--badge-text-color); }
&.execute-badge { --color: #f59e0b; }
a {
color: inherit !important;

View File

@ -8,7 +8,7 @@ import { t } from "../services/i18n";
import { formatDateTime } from "../utils/formatters";
import { BacklinksList, useBacklinkCount } from "./FloatingButtonsDefinitions";
import Dropdown, { DropdownProps } from "./react/Dropdown";
import { useIsNoteReadOnly, useNoteContext, useStaticTooltip } from "./react/hooks";
import { useIsNoteReadOnly, useNoteContext, useNoteLabel, useNoteLabelBoolean, useStaticTooltip } from "./react/hooks";
import Icon from "./react/Icon";
import { NoteSizeWidget, useNoteMetadata } from "./ribbon/NoteInfoTab";
import { useShareInfo } from "./shared_info";
@ -20,6 +20,8 @@ export default function BreadcrumbBadges() {
<ReadOnlyBadge />
<ShareBadge />
<BacklinksBadge />
<ClippedNoteBadge />
<ExecuteBadge />
</div>
);
}
@ -114,6 +116,40 @@ function BacklinksBadge() {
);
}
function ClippedNoteBadge() {
const { note } = useNoteContext();
const [ pageUrl ] = useNoteLabel(note, "pageUrl");
return (pageUrl &&
<Badge
className="clipped-note-badge"
icon="bx bx-globe"
text={t("breadcrumb_badges.clipped_note")}
tooltip={t("breadcrumb_badges.clipped_note_description", { url: pageUrl })}
href={pageUrl}
/>
);
}
function ExecuteBadge() {
const { note, parentComponent } = useNoteContext();
const isScript = note?.isTriliumScript();
const isSql = note?.isTriliumSqlite();
const isExecutable = isScript || isSql;
const [ executeDescription ] = useNoteLabel(note, "executeDescription");
const [ executeButton ] = useNoteLabelBoolean(note, "executeButton");
return (note && isExecutable && (executeDescription || executeButton) &&
<Badge
className="execute-badge"
icon="bx bx-play"
text={isScript ? t("breadcrumb_badges.execute_script") : t("breadcrumb_badges.execute_sql")}
tooltip={executeDescription || (isScript ? t("breadcrumb_badges.execute_script_description") : t("breadcrumb_badges.execute_sql_description"))}
onClick={() => parentComponent.triggerCommand("runActiveNote")}
/>
);
}
interface BadgeProps {
text?: string;
icon?: string;

View File

@ -300,8 +300,9 @@ function ExportImageButtons({ note, triggerEvent, isDefaultViewMode }: FloatingB
function InAppHelpButton({ note }: FloatingButtonContext) {
const helpUrl = getHelpUrlForNote(note);
const isEnabled = !!helpUrl && (!isNewLayout || (note?.type !== "book"));
return !!helpUrl && (
return isEnabled && (
<FloatingButton
icon="bx bx-help-circle"
text={t("help-button.title")}

View File

@ -1,46 +1,13 @@
import { type ComponentChild } from "preact";
import { formatDateTime } from "../utils/formatters";
import { useNoteContext, useStaticTooltip } from "./react/hooks";
import { joinElements } from "./react/react_utils";
import { useNoteMetadata } from "./ribbon/NoteInfoTab";
import { Trans } from "react-i18next";
import { useRef } from "preact/hooks";
import CollectionProperties from "./note_bars/CollectionProperties";
import { useNoteContext, useNoteProperty } from "./react/hooks";
export default function NoteTitleDetails() {
const { note, noteContext } = useNoteContext();
const isHiddenNote = note?.noteId.startsWith("_");
const isDefaultView = noteContext?.viewScope?.viewMode === "default";
const { note } = useNoteContext();
const noteType = useNoteProperty(note, "type");
const items: ComponentChild[] = [].filter(item => !!item);
return items.length && (
return (
<div className="title-details">
{joinElements(items, " • ")}
{note && noteType === "book" && <CollectionProperties note={note} />}
</div>
);
}
function TextWithValue({ i18nKey, value, valueTooltip }: {
i18nKey: string;
value: string;
valueTooltip: string;
}) {
const listItemRef = useRef<HTMLLIElement>(null);
useStaticTooltip(listItemRef, {
selector: "span.value",
title: valueTooltip,
popperConfig: { placement: "bottom" }
});
return (
<li ref={listItemRef}>
<Trans
i18nKey={i18nKey}
components={{
Value: <span className="value">{value}</span> as React.ReactElement
}}
/>
</li>
);
}

View File

@ -0,0 +1,220 @@
import { t } from "i18next";
import { useContext } from "preact/hooks";
import { Fragment } from "preact/jsx-runtime";
import FNote from "../../entities/fnote";
import { ViewTypeOptions } from "../collections/interface";
import Dropdown from "../react/Dropdown";
import { FormDropdownDivider, FormDropdownSubmenu, FormListItem, FormListToggleableItem } from "../react/FormList";
import FormTextBox from "../react/FormTextBox";
import { useNoteLabel, useNoteLabelBoolean, useNoteLabelWithDefault } from "../react/hooks";
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",
list: "bx bx-list-ul",
calendar: "bx bx-calendar",
table: "bx bx-table",
geoMap: "bx bx-map-alt",
board: "bx bx-columns",
presentation: "bx bx-rectangle"
};
export default function CollectionProperties({ note }: { note: FNote }) {
const [ viewType, setViewType ] = useViewType(note);
return (
<>
<ViewTypeSwitcher viewType={viewType} setViewType={setViewType} />
<ViewOptions note={note} viewType={viewType} />
<div className="spacer" />
<HelpButton note={note} />
</>
);
}
function ViewTypeSwitcher({ viewType, setViewType }: { viewType: ViewTypeOptions, setViewType: (newValue: ViewTypeOptions) => void }) {
return (
<Dropdown
text={<>
<Icon icon={ICON_MAPPINGS[viewType]} />&nbsp;
{VIEW_TYPE_MAPPINGS[viewType]}
</>}
>
{Object.entries(VIEW_TYPE_MAPPINGS).map(([ key, label ]) => (
<FormListItem
key={key}
onClick={() => setViewType(key as ViewTypeOptions)}
selected={viewType === key}
disabled={viewType === key}
icon={ICON_MAPPINGS[key as ViewTypeOptions]}
>{label}</FormListItem>
))}
</Dropdown>
);
}
function ViewOptions({ note, viewType }: { note: FNote, viewType: ViewTypeOptions }) {
const properties = bookPropertiesConfig[viewType].properties;
return (
<Dropdown
buttonClassName="bx bx-cog icon-action"
hideToggleArrow
>
{properties.map(property => (
<ViewProperty key={property.label} note={note} property={property} />
))}
{properties.length > 0 && <FormDropdownDivider />}
<ViewProperty note={note} property={{
type: "checkbox",
icon: "bx bx-archive",
label: t("book_properties.include_archived_notes"),
bindToLabel: "includeArchived"
} as CheckBoxProperty} />
</Dropdown>
);
}
function ViewProperty({ note, property }: { note: FNote, property: BookProperty }) {
switch (property.type) {
case "button":
return <ButtonPropertyView note={note} property={property} />;
case "split-button":
return <SplitButtonPropertyView note={note} property={property} />;
case "checkbox":
return <CheckBoxPropertyView note={note} property={property} />;
case "number":
return <NumberPropertyView note={note} property={property} />;
case "combobox":
return <ComboBoxPropertyView note={note} property={property} />;
}
}
function ButtonPropertyView({ note, property }: { note: FNote, property: ButtonProperty }) {
const parentComponent = useContext(ParentComponent);
return (
<FormListItem
icon={property.icon}
title={property.title}
onClick={() => {
if (!parentComponent) return;
property.onClick({
note,
triggerCommand: parentComponent.triggerCommand.bind(parentComponent)
});
}}
>{property.label}</FormListItem>
);
}
function SplitButtonPropertyView({ note, property }: { note: FNote, property: SplitButtonProperty }) {
const parentComponent = useContext(ParentComponent);
const ItemsComponent = property.items;
const clickContext = parentComponent && {
note,
triggerCommand: parentComponent.triggerCommand.bind(parentComponent)
};
return (parentComponent &&
<FormDropdownSubmenu
icon={property.icon ?? "bx bx-empty"}
title={property.label}
onDropdownToggleClicked={() => clickContext && property.onClick(clickContext)}
>
<ItemsComponent note={note} parentComponent={parentComponent} />
</FormDropdownSubmenu>
);
}
function NumberPropertyView({ note, property }: { note: FNote, property: NumberProperty }) {
//@ts-expect-error Interop with text box which takes in string values even for numbers.
const [ value, setValue ] = useNoteLabel(note, property.bindToLabel);
const disabled = property.disabled?.(note);
return (
<FormListItem
icon={property.icon}
disabled={disabled}
onClick={(e) => e.stopPropagation()}
>
{property.label}
<FormTextBox
type="number"
currentValue={value ?? ""} onChange={setValue}
style={{ width: (property.width ?? 100) }}
min={property.min ?? 0}
disabled={disabled}
/>
</FormListItem>
);
}
function ComboBoxPropertyView({ note, property }: { note: FNote, property: ComboBoxProperty }) {
const [ value, setValue ] = useNoteLabelWithDefault(note, property.bindToLabel, property.defaultValue ?? "");
function renderItem(option: ComboBoxItem) {
return (
<FormListItem
key={option.value}
checked={value === option.value}
onClick={() => setValue(option.value)}
>
{option.label}
</FormListItem>
);
}
return (
<FormDropdownSubmenu
title={property.label}
icon={property.icon ?? "bx bx-empty"}
>
{(property.options).map((option, index) => {
if ("items" in option) {
return (
<Fragment key={option.title}>
<FormListItem key={option.title} disabled>{option.title}</FormListItem>
{option.items.map(renderItem)}
{index < property.options.length - 1 && <FormDropdownDivider />}
</Fragment>
);
} else {
return renderItem(option);
}
})}
</FormDropdownSubmenu>
);
}
function CheckBoxPropertyView({ note, property }: { note: FNote, property: CheckBoxProperty }) {
const [ value, setValue ] = useNoteLabelBoolean(note, property.bindToLabel);
return (
<FormListToggleableItem
icon={property.icon}
title={property.label}
currentValue={value}
onChange={setValue}
/>
);
}
function HelpButton({ note }: { note: FNote }) {
const helpUrl = getHelpUrlForNote(note);
return (helpUrl && (
<ActionButton
icon="bx bx-help-circle"
onClick={(() => openInAppHelpFromUrl(helpUrl))}
text={t("help-button.title")}
/>
));
}

View File

@ -37,6 +37,21 @@ body.experimental-feature-new-layout {
padding-inline-start: 24px;
}
.title-details {
padding-inline-end: 16px;
.dropdown-menu {
input.form-control {
padding: 2px 8px;
margin-left: 1em;
}
}
.spacer {
flex-grow: 1;
}
}
.title-row {
margin-left: 12px;
@ -75,7 +90,8 @@ body.experimental-feature-new-layout {
}
}
.scrolling-container:has(> :is(.note-detail.full-height, .note-list-widget.full-height)) {
.scrolling-container:has(> :is(.note-detail.full-height, .note-list-widget.full-height)),
.note-split.type-book {
.title-row,
.title-details {
width: 100%;
@ -84,12 +100,11 @@ body.experimental-feature-new-layout {
}
.title-row {
margin-top: 0;
padding: 0;
}
.title-details {
margin-bottom: 0.2em;
opacity: 0.65;
padding-bottom: 0.2em;
font-size: 0.8em;
}
}

View File

@ -206,10 +206,11 @@ export function FormDropdownDivider() {
return <div className="dropdown-divider" />;
}
export function FormDropdownSubmenu({ icon, title, children, dropStart }: {
export function FormDropdownSubmenu({ icon, title, children, dropStart, onDropdownToggleClicked }: {
icon: string,
title: ComponentChildren,
children: ComponentChildren,
onDropdownToggleClicked?: () => void,
dropStart?: boolean
}) {
const [ openOnMobile, setOpenOnMobile ] = useState(false);
@ -224,6 +225,10 @@ export function FormDropdownSubmenu({ icon, title, children, dropStart }: {
if (isMobile()) {
setOpenOnMobile(!openOnMobile);
}
if (onDropdownToggleClicked) {
onDropdownToggleClicked();
}
}}
>
<Icon icon={icon} />{" "}

View File

@ -12,9 +12,9 @@ import FormCheckbox from "../react/FormCheckbox";
import FormTextBox from "../react/FormTextBox";
import { ComponentChildren } from "preact";
import { ViewTypeOptions } from "../collections/interface";
import { FormDropdownDivider, FormListItem } from "../react/FormList";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features";
const VIEW_TYPE_MAPPINGS: Record<ViewTypeOptions, string> = {
export const VIEW_TYPE_MAPPINGS: Record<ViewTypeOptions, string> = {
grid: t("book_properties.grid"),
list: t("book_properties.list"),
calendar: t("book_properties.calendar"),
@ -24,24 +24,31 @@ const VIEW_TYPE_MAPPINGS: Record<ViewTypeOptions, string> = {
presentation: t("book_properties.presentation")
};
const isNewLayout = isExperimentalFeatureEnabled("new-layout");
export default function CollectionPropertiesTab({ note }: TabContext) {
const [ viewType, setViewType ] = useNoteLabel(note, "viewType");
const defaultViewType = (note?.type === "search" ? "list" : "grid");
const viewTypeWithDefault = (viewType ?? defaultViewType) as ViewTypeOptions;
const properties = bookPropertiesConfig[viewTypeWithDefault].properties;
const [viewType, setViewType] = useViewType(note);
const properties = bookPropertiesConfig[viewType].properties;
return (
<div className="book-properties-widget">
{note && (
<>
<CollectionTypeSwitcher viewType={viewTypeWithDefault} setViewType={setViewType} />
<BookProperties viewType={viewTypeWithDefault} note={note} properties={properties} />
{!isNewLayout && <CollectionTypeSwitcher viewType={viewType} setViewType={setViewType} />}
<BookProperties viewType={viewType} note={note} properties={properties} />
</>
)}
</div>
);
}
export function useViewType(note: FNote | null | undefined) {
const [ viewType, setViewType ] = useNoteLabel(note, "viewType");
const defaultViewType = (note?.type === "search" ? "list" : "grid");
const viewTypeWithDefault = (viewType ?? defaultViewType) as ViewTypeOptions;
return [ viewTypeWithDefault, setViewType ] as const;
}
function CollectionTypeSwitcher({ viewType, setViewType }: { viewType: string, setViewType: (newValue: string) => void }) {
const collectionTypes = useMemo(() => mapToKeyValueArray(VIEW_TYPE_MAPPINGS), []);
@ -148,7 +155,7 @@ function NumberPropertyView({ note, property }: { note: FNote, property: NumberP
<FormTextBox
type="number"
currentValue={value ?? ""} onChange={setValue}
style={{ width: (property.width ?? 100) + "px" }}
style={{ width: (property.width ?? 100) }}
min={property.min ?? 0}
disabled={disabled}
/>

View File

@ -38,7 +38,7 @@ export const RIBBON_TAB_DEFINITIONS: TabConfiguration[] = [
icon: "bx bx-play",
content: ScriptTab,
activate: true,
show: ({ note }) => note &&
show: ({ note }) => note && !isNewLayout &&
(note.isTriliumScript() || note.isTriliumSqlite()) &&
(note.hasLabel("executeDescription") || note.hasLabel("executeButton"))
},
@ -60,14 +60,14 @@ export const RIBBON_TAB_DEFINITIONS: TabConfiguration[] = [
title: t("book_properties.book_properties"),
icon: "bx bx-book",
content: CollectionPropertiesTab,
show: ({ note }) => note?.type === "book" || note?.type === "search",
show: ({ note }) => !isNewLayout && note?.type === "book" || note?.type === "search",
toggleCommand: "toggleRibbonTabBookProperties"
},
{
title: t("note_properties.info"),
icon: "bx bx-info-square",
content: NotePropertiesTab,
show: ({ note }) => !!note?.getLabelValue("pageUrl"),
show: ({ note }) => !isNewLayout && !!note?.getLabelValue("pageUrl"),
activate: true
},
{

View File

@ -18,7 +18,8 @@ interface BookConfig {
export interface CheckBoxProperty {
type: "checkbox",
label: string;
bindToLabel: FilterLabelsByType<boolean>
bindToLabel: FilterLabelsByType<boolean>;
icon?: string;
}
export interface ButtonProperty {
@ -40,10 +41,11 @@ export interface NumberProperty {
bindToLabel: FilterLabelsByType<number>;
width?: number;
min?: number;
icon?: string;
disabled?: (note: FNote) => boolean;
}
interface ComboBoxItem {
export interface ComboBoxItem {
value: string;
label: string;
}
@ -56,6 +58,7 @@ interface ComboBoxGroup {
export interface ComboBoxProperty {
type: "combobox",
label: string;
icon?: string;
bindToLabel: FilterLabelsByType<string>;
/**
* The default value is used when the label is not set.
@ -107,11 +110,13 @@ export const bookPropertiesConfig: Record<ViewTypeOptions, BookConfig> = {
properties: [
{
label: t("book_properties_config.hide-weekends"),
icon: "bx bx-calendar-week",
type: "checkbox",
bindToLabel: "calendar:hideWeekends"
},
{
label: t("book_properties_config.display-week-numbers"),
icon: "bx bx-hash",
type: "checkbox",
bindToLabel: "calendar:weekNumbers"
}
@ -121,6 +126,7 @@ export const bookPropertiesConfig: Record<ViewTypeOptions, BookConfig> = {
properties: [
{
label: t("book_properties_config.map-style"),
icon: "bx bx-palette",
type: "combobox",
bindToLabel: "map:style",
defaultValue: DEFAULT_MAP_LAYER_NAME,
@ -147,6 +153,7 @@ export const bookPropertiesConfig: Record<ViewTypeOptions, BookConfig> = {
},
{
label: t("book_properties_config.show-scale"),
icon: "bx bx-ruler",
type: "checkbox",
bindToLabel: "map:scale"
}
@ -156,6 +163,7 @@ export const bookPropertiesConfig: Record<ViewTypeOptions, BookConfig> = {
properties: [
{
label: t("book_properties_config.max-nesting-depth"),
icon: "bx bx-subdirectory-right",
type: "number",
bindToLabel: "maxNestingDepth",
width: 65,
@ -171,6 +179,7 @@ export const bookPropertiesConfig: Record<ViewTypeOptions, BookConfig> = {
{
label: "Theme",
type: "combobox",
icon: "bx bx-palette",
bindToLabel: "presentation:theme",
defaultValue: DEFAULT_THEME,
options: getPresentationThemes().map(theme => ({

View File

@ -5,6 +5,7 @@ type Labels = {
color: string;
iconClass: string;
workspaceIconClass: string;
executeButton: boolean;
executeDescription: string;
executeTitle: string;
limit: string; // should be probably be number