New layout refinement (#8088)
Some checks are pending
Checks / main (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Dev / Test development (push) Waiting to run
Dev / Build Docker image (push) Blocked by required conditions
Dev / Check Docker build (Dockerfile) (push) Blocked by required conditions
Dev / Check Docker build (Dockerfile.alpine) (push) Blocked by required conditions
/ Check Docker build (Dockerfile) (push) Waiting to run
/ Check Docker build (Dockerfile.alpine) (push) Waiting to run
/ Build Docker images (Dockerfile, ubuntu-24.04-arm, linux/arm64) (push) Blocked by required conditions
/ Build Docker images (Dockerfile.alpine, ubuntu-latest, linux/amd64) (push) Blocked by required conditions
/ Build Docker images (Dockerfile.legacy, ubuntu-24.04-arm, linux/arm/v7) (push) Blocked by required conditions
/ Build Docker images (Dockerfile.legacy, ubuntu-24.04-arm, linux/arm/v8) (push) Blocked by required conditions
/ Merge manifest lists (push) Blocked by required conditions
playwright / E2E tests on linux-arm64 (push) Waiting to run
playwright / E2E tests on linux-x64 (push) Waiting to run

This commit is contained in:
Elian Doran 2025-12-17 18:51:38 +02:00 committed by GitHub
commit 231ec39025
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 546 additions and 156 deletions

View File

@ -1,14 +1,14 @@
import Component from "./component.js";
import appContext, { type CommandData, type CommandListenerData } from "./app_context.js";
import dateNoteService from "../services/date_notes.js"; import dateNoteService from "../services/date_notes.js";
import treeService from "../services/tree.js";
import openService from "../services/open.js";
import protectedSessionService from "../services/protected_session.js";
import options from "../services/options.js";
import froca from "../services/froca.js"; import froca from "../services/froca.js";
import utils, { openInReusableSplit } from "../services/utils.js";
import toastService from "../services/toast.js";
import noteCreateService from "../services/note_create.js"; import noteCreateService from "../services/note_create.js";
import openService from "../services/open.js";
import options from "../services/options.js";
import protectedSessionService from "../services/protected_session.js";
import toastService from "../services/toast.js";
import treeService from "../services/tree.js";
import utils, { openInReusableSplit } from "../services/utils.js";
import appContext, { type CommandListenerData } from "./app_context.js";
import Component from "./component.js";
export default class RootCommandExecutor extends Component { export default class RootCommandExecutor extends Component {
editReadOnlyNoteCommand() { editReadOnlyNoteCommand() {
@ -193,10 +193,13 @@ export default class RootCommandExecutor extends Component {
appContext.triggerEvent("zenModeChanged", { isEnabled }); appContext.triggerEvent("zenModeChanged", { isEnabled });
} }
async toggleRibbonTabNoteMapCommand() { async toggleRibbonTabNoteMapCommand(data: CommandListenerData<"toggleRibbonTabNoteMap">) {
const { isExperimentalFeatureEnabled } = await import("../services/experimental_features.js"); const { isExperimentalFeatureEnabled } = await import("../services/experimental_features.js");
const isNewLayout = isExperimentalFeatureEnabled("new-layout"); const isNewLayout = isExperimentalFeatureEnabled("new-layout");
if (!isNewLayout) return; if (!isNewLayout) {
this.triggerEvent("toggleRibbonTabNoteMap", data);
return;
}
const activeContext = appContext.tabManager.getActiveContext(); const activeContext = appContext.tabManager.getActiveContext();
if (!activeContext?.notePath) return; if (!activeContext?.notePath) return;
@ -272,7 +275,7 @@ export default class RootCommandExecutor extends Component {
} }
catch (e) { catch (e) {
console.error("Error creating AI Chat note:", e); console.error("Error creating AI Chat note:", e);
toastService.showError("Failed to create AI Chat note: " + (e as Error).message); toastService.showError(`Failed to create AI Chat note: ${(e as Error).message}`);
} }
} }
} }

View File

@ -1,17 +1,17 @@
import server from "../services/server.js";
import noteAttributeCache from "../services/note_attribute_cache.js";
import protectedSessionHolder from "../services/protected_session_holder.js";
import cssClassManager from "../services/css_class_manager.js"; import cssClassManager from "../services/css_class_manager.js";
import type { Froca } from "../services/froca-interface.js"; import type { Froca } from "../services/froca-interface.js";
import type FAttachment from "./fattachment.js"; import noteAttributeCache from "../services/note_attribute_cache.js";
import type { default as FAttribute, AttributeType } from "./fattribute.js"; import protectedSessionHolder from "../services/protected_session_holder.js";
import utils from "../services/utils.js";
import search from "../services/search.js"; import search from "../services/search.js";
import server from "../services/server.js";
import utils from "../services/utils.js";
import type FAttachment from "./fattachment.js";
import type { AttributeType,default as FAttribute } from "./fattribute.js";
const LABEL = "label"; const LABEL = "label";
const RELATION = "relation"; const RELATION = "relation";
const NOTE_TYPE_ICONS = { export const NOTE_TYPE_ICONS = {
file: "bx bx-file", file: "bx bx-file",
image: "bx bx-image", image: "bx bx-image",
code: "bx bx-code", code: "bx bx-code",
@ -268,13 +268,12 @@ export default class FNote {
} }
} }
return results; return results;
} else {
return this.children;
} }
return this.children;
} }
async getSubtreeNoteIds(includeArchived = false) { async getSubtreeNoteIds(includeArchived = false) {
let noteIds: (string | string[])[] = []; const noteIds: (string | string[])[] = [];
for (const child of await this.getChildNotes()) { for (const child of await this.getChildNotes()) {
if (child.isArchived && !includeArchived) continue; if (child.isArchived && !includeArchived) continue;
@ -471,9 +470,8 @@ export default class FNote {
return a.isHidden ? 1 : -1; return a.isHidden ? 1 : -1;
} else if (a.isSearch !== b.isSearch) { } else if (a.isSearch !== b.isSearch) {
return a.isSearch ? 1 : -1; return a.isSearch ? 1 : -1;
} else {
return a.notePath.length - b.notePath.length;
} }
return a.notePath.length - b.notePath.length;
}); });
return notePaths; return notePaths;
@ -597,14 +595,12 @@ export default class FNote {
} else if (this.type === "text") { } else if (this.type === "text") {
if (this.isFolder()) { if (this.isFolder()) {
return "bx bx-folder"; return "bx bx-folder";
} else {
return "bx bx-note";
} }
return "bx bx-note";
} else if (this.type === "code" && this.mime.startsWith("text/x-sql")) { } else if (this.type === "code" && this.mime.startsWith("text/x-sql")) {
return "bx bx-data"; return "bx bx-data";
} else {
return NOTE_TYPE_ICONS[this.type];
} }
return NOTE_TYPE_ICONS[this.type];
} }
getColorClass() { getColorClass() {
@ -617,7 +613,7 @@ export default class FNote {
} }
getFilteredChildBranches() { getFilteredChildBranches() {
let childBranches = this.getChildBranches(); const childBranches = this.getChildBranches();
if (!childBranches) { if (!childBranches) {
console.error(`No children for '${this.noteId}'. This shouldn't happen.`); console.error(`No children for '${this.noteId}'. This shouldn't happen.`);
@ -811,9 +807,9 @@ export default class FNote {
return this.getLabelValue(nameWithPrefix.substring(1)); return this.getLabelValue(nameWithPrefix.substring(1));
} else if (nameWithPrefix.startsWith("~")) { } else if (nameWithPrefix.startsWith("~")) {
return this.getRelationValue(nameWithPrefix.substring(1)); return this.getRelationValue(nameWithPrefix.substring(1));
} else {
return this.getLabelValue(nameWithPrefix);
} }
return this.getLabelValue(nameWithPrefix);
} }
/** /**
@ -878,10 +874,10 @@ export default class FNote {
promotedAttrs.sort((a, b) => { promotedAttrs.sort((a, b) => {
if (a.noteId === b.noteId) { if (a.noteId === b.noteId) {
return a.position < b.position ? -1 : 1; return a.position < b.position ? -1 : 1;
} else {
// inherited promoted attributes should stay grouped: https://github.com/zadam/trilium/issues/3761
return a.noteId < b.noteId ? -1 : 1;
} }
// inherited promoted attributes should stay grouped: https://github.com/zadam/trilium/issues/3761
return a.noteId < b.noteId ? -1 : 1;
}); });
return promotedAttrs; return promotedAttrs;

View File

@ -20,11 +20,19 @@ export type ExperimentalFeatureId = typeof experimentalFeatures[number]["id"];
let enabledFeatures: Set<ExperimentalFeatureId> | null = null; let enabledFeatures: Set<ExperimentalFeatureId> | null = null;
export function isExperimentalFeatureEnabled(featureId: ExperimentalFeatureId): boolean { export function isExperimentalFeatureEnabled(featureId: ExperimentalFeatureId): boolean {
if (featureId === "new-layout") {
return options.is("newLayout");
}
return getEnabledFeatures().has(featureId); return getEnabledFeatures().has(featureId);
} }
export function getEnabledExperimentalFeatureIds() { export function getEnabledExperimentalFeatureIds() {
return getEnabledFeatures().values(); const values = [ ...getEnabledFeatures().values() ];
if (options.is("newLayout")) {
values.push("new-layout");
}
return values;
} }
export async function toggleExperimentalFeature(featureId: ExperimentalFeatureId, enable: boolean) { export async function toggleExperimentalFeature(featureId: ExperimentalFeatureId, enable: boolean) {

View File

@ -717,12 +717,17 @@ table.promoted-attributes-in-tooltip th {
.tooltip { .tooltip {
font-size: var(--main-font-size) !important; font-size: var(--main-font-size) !important;
z-index: calc(var(--ck-z-panel) - 1) !important; z-index: calc(var(--ck-z-panel) - 1) !important;
white-space: pre-wrap;
} }
.tooltip.tooltip-top { .tooltip.tooltip-top {
z-index: 32767 !important; z-index: 32767 !important;
} }
.pre-wrap-text {
white-space: pre-wrap;
}
.bs-tooltip-bottom .tooltip-arrow::before { .bs-tooltip-bottom .tooltip-arrow::before {
border-bottom-color: var(--main-border-color) !important; border-bottom-color: var(--main-border-color) !important;
} }

View File

@ -2109,6 +2109,8 @@
"background_effects_title": "Background effects are now stable", "background_effects_title": "Background effects are now stable",
"background_effects_message": "On Windows devices, background effects are now fully stable. The background effects adds a touch of color to the user interface by blurring the background behind it. This technique is also used in other applications such as Windows Explorer.", "background_effects_message": "On Windows devices, background effects are now fully stable. The background effects adds a touch of color to the user interface by blurring the background behind it. This technique is also used in other applications such as Windows Explorer.",
"background_effects_button": "Enable background effects", "background_effects_button": "Enable background effects",
"new_layout_title": "New layout",
"new_layout_message": "Weve introduced a modernized layout for Trilium. The ribbon has been removed and seamlessly integrated into the main interface, with a new status bar and expandable sections (such as promoted attributes) taking over key functions.\n\nThe new layout is enabled by default, and can be temporarily disabled via Options → Appearance.",
"dismiss": "Dismiss" "dismiss": "Dismiss"
}, },
"settings": { "settings": {
@ -2116,7 +2118,10 @@
}, },
"settings_appearance": { "settings_appearance": {
"related_code_blocks": "Color scheme for code blocks in text notes", "related_code_blocks": "Color scheme for code blocks in text notes",
"related_code_notes": "Color scheme for code notes" "related_code_notes": "Color scheme for code notes",
"ui": "User interface",
"ui_old_layout": "Old layout",
"ui_new_layout": "New layout"
}, },
"units": { "units": {
"percentage": "%" "percentage": "%"

View File

@ -1,7 +1,7 @@
import "./global_menu.css"; import "./global_menu.css";
import { KeyboardActionNames } from "@triliumnext/commons"; import { KeyboardActionNames } from "@triliumnext/commons";
import { ComponentChildren } from "preact"; import { ComponentChildren, RefObject } from "preact";
import { useContext, useEffect, useRef, useState } from "preact/hooks"; import { useContext, useEffect, useRef, useState } from "preact/hooks";
import { CommandNames } from "../../components/app_context"; import { CommandNames } from "../../components/app_context";
@ -30,13 +30,15 @@ export default function GlobalMenu({ isHorizontalLayout }: { isHorizontalLayout:
const parentComponent = useContext(ParentComponent); const parentComponent = useContext(ParentComponent);
const { isUpdateAvailable, latestVersion } = useTriliumUpdateStatus(); const { isUpdateAvailable, latestVersion } = useTriliumUpdateStatus();
const isMobileLocal = isMobile(); const isMobileLocal = isMobile();
const logoRef = useRef<SVGSVGElement>(null);
useStaticTooltip(logoRef);
return ( return (
<Dropdown <Dropdown
className="global-menu" className="global-menu"
buttonClassName={`global-menu-button ${isHorizontalLayout ? "bx bx-menu" : ""}`} noSelectButtonStyle iconAction hideToggleArrow buttonClassName={`global-menu-button ${isHorizontalLayout ? "bx bx-menu" : ""}`} noSelectButtonStyle iconAction hideToggleArrow
text={<> text={<>
{isVerticalLayout && <VerticalLayoutIcon />} {isVerticalLayout && <VerticalLayoutIcon logoRef={logoRef} />}
{isUpdateAvailable && <div class="global-menu-button-update-available"> {isUpdateAvailable && <div class="global-menu-button-update-available">
<span className="bx bxs-down-arrow-alt global-menu-button-update-available-button" title={t("update_available.update_available")} /> <span className="bx bxs-down-arrow-alt global-menu-button-update-available-button" title={t("update_available.update_available")} />
</div>} </div>}
@ -159,10 +161,7 @@ function KeyboardActionMenuItem({ text, command, ...props }: MenuItemProps<Keybo
/>; />;
} }
function VerticalLayoutIcon() { export function VerticalLayoutIcon({ logoRef }: { logoRef?: RefObject<SVGSVGElement> }) {
const logoRef = useRef<SVGSVGElement>(null);
useStaticTooltip(logoRef);
return ( return (
<svg ref={logoRef} viewBox="0 0 256 256" title={t("global_menu.menu")}> <svg ref={logoRef} viewBox="0 0 256 256" title={t("global_menu.menu")}>
<g> <g>

View File

@ -1,8 +1,9 @@
import { useMemo, useState } from "preact/hooks"; import { useMemo, useState } from "preact/hooks";
import { t } from "../../services/i18n";
import Button from "../react/Button"; import Button from "../react/Button";
import Modal from "../react/Modal"; import Modal from "../react/Modal";
import { dismissCallToAction, getCallToActions } from "./call_to_action_definitions"; import { dismissCallToAction, getCallToActions } from "./call_to_action_definitions";
import { t } from "../../services/i18n";
export default function CallToActionDialog() { export default function CallToActionDialog() {
const activeCallToActions = useMemo(() => getCallToActions(), []); const activeCallToActions = useMemo(() => getCallToActions(), []);
@ -40,7 +41,7 @@ export default function CallToActionDialog() {
)} )}
</>} </>}
> >
<p>{activeItem.message}</p> <p className="pre-wrap-text">{activeItem.message}</p>
</Modal> </Modal>
) );
} }

View File

@ -1,6 +1,6 @@
import utils from "../../services/utils";
import options from "../../services/options";
import { t } from "../../services/i18n"; import { t } from "../../services/i18n";
import options from "../../services/options";
import utils from "../../services/utils";
/** /**
* A "call-to-action" is an interactive message for the user, generally to present new features. * A "call-to-action" is an interactive message for the user, generally to present new features.
@ -46,20 +46,11 @@ function isNextTheme() {
const CALL_TO_ACTIONS: CallToAction[] = [ const CALL_TO_ACTIONS: CallToAction[] = [
{ {
id: "next_theme", id: "new_layout",
title: t("call_to_action.next_theme_title"), title: t("call_to_action.new_layout_title"),
message: t("call_to_action.next_theme_message"), message: t("call_to_action.new_layout_message"),
enabled: () => !isNextTheme(), enabled: () => true,
buttons: [ buttons: []
{
text: t("call_to_action.next_theme_button"),
async onClick() {
await options.save("theme", "next");
await options.save("backgroundEffects", "true");
utils.reloadFrontendApp("call-to-action");
}
}
]
}, },
{ {
id: "background_effects", id: "background_effects",
@ -75,6 +66,22 @@ const CALL_TO_ACTIONS: CallToAction[] = [
} }
} }
] ]
},
{
id: "next_theme",
title: t("call_to_action.next_theme_title"),
message: t("call_to_action.next_theme_message"),
enabled: () => !isNextTheme(),
buttons: [
{
text: t("call_to_action.next_theme_button"),
async onClick() {
await options.save("theme", "next");
await options.save("backgroundEffects", "true");
utils.reloadFrontendApp("call-to-action");
}
}
]
} }
]; ];

View File

@ -7,7 +7,6 @@
} }
.inline-title { .inline-title {
margin-top: 2px; /* Allow space for the focus outline */
max-width: var(--max-content-width); max-width: var(--max-content-width);
container-type: inline-size; container-type: inline-size;
padding-inline-start: 24px; padding-inline-start: 24px;

View File

@ -22,11 +22,10 @@ export default function NoteBadges() {
function ReadOnlyBadge() { function ReadOnlyBadge() {
const { note, noteContext } = useNoteContext(); const { note, noteContext } = useNoteContext();
const { isReadOnly, enableEditing } = useIsNoteReadOnly(note, noteContext); const { isReadOnly, enableEditing, temporarilyEditable } = useIsNoteReadOnly(note, noteContext);
const isExplicitReadOnly = note?.isLabelTruthy("readOnly"); const isExplicitReadOnly = note?.isLabelTruthy("readOnly");
const isTemporarilyEditable = noteContext?.ntxId !== "_popup-editor" && noteContext?.viewScope?.readOnlyTemporarilyDisabled;
if (isTemporarilyEditable) { if (temporarilyEditable) {
return <Badge return <Badge
icon="bx bx-lock-open-alt" icon="bx bx-lock-open-alt"
text={t("breadcrumb_badges.read_only_temporarily_disabled")} text={t("breadcrumb_badges.read_only_temporarily_disabled")}

View File

@ -1,7 +1,7 @@
import "./NoteTitleActions.css"; import "./NoteTitleActions.css";
import clsx from "clsx"; import clsx from "clsx";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useRef, useState } from "preact/hooks";
import NoteContext from "../../components/note_context"; import NoteContext from "../../components/note_context";
import FNote from "../../entities/fnote"; import FNote from "../../entities/fnote";
@ -10,7 +10,7 @@ import CollectionProperties from "../note_bars/CollectionProperties";
import { checkFullHeight, getExtendedWidgetType } from "../NoteDetail"; import { checkFullHeight, getExtendedWidgetType } from "../NoteDetail";
import { PromotedAttributesContent, usePromotedAttributeData } from "../PromotedAttributes"; import { PromotedAttributesContent, usePromotedAttributeData } from "../PromotedAttributes";
import Collapsible, { ExternallyControlledCollapsible } from "../react/Collapsible"; import Collapsible, { ExternallyControlledCollapsible } from "../react/Collapsible";
import { useNoteContext, useNoteProperty } from "../react/hooks"; import { useNoteContext, useNoteProperty, useTriliumEvent } from "../react/hooks";
import SearchDefinitionTab from "../ribbon/SearchDefinitionTab"; import SearchDefinitionTab from "../ribbon/SearchDefinitionTab";
export default function NoteTitleActions() { export default function NoteTitleActions() {
@ -57,6 +57,9 @@ function PromotedAttributes({ note, componentId, noteContext }: {
}); });
}, [ note, noteContext ]); }, [ note, noteContext ]);
// Keyboard shortcut.
useTriliumEvent("toggleRibbonTabPromotedAttributes", () => setExpanded(!expanded));
if (!cells?.length) return false; if (!cells?.length) return false;
return (note && ( return (note && (
<ExternallyControlledCollapsible <ExternallyControlledCollapsible

View File

@ -1,6 +1,6 @@
import "./StatusBar.css"; import "./StatusBar.css";
import { Locale } from "@triliumnext/commons"; import { KeyboardActionNames, Locale } from "@triliumnext/commons";
import { Dropdown as BootstrapDropdown } from "bootstrap"; import { Dropdown as BootstrapDropdown } from "bootstrap";
import clsx from "clsx"; import clsx from "clsx";
import { type ComponentChildren } from "preact"; import { type ComponentChildren } from "preact";
@ -9,7 +9,7 @@ import { useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { CommandNames } from "../../components/app_context"; import { CommandNames } from "../../components/app_context";
import NoteContext from "../../components/note_context"; import NoteContext from "../../components/note_context";
import FNote from "../../entities/fnote"; import FNote, { NOTE_TYPE_ICONS } from "../../entities/fnote";
import attributes from "../../services/attributes"; import attributes from "../../services/attributes";
import { t } from "../../services/i18n"; import { t } from "../../services/i18n";
import { ViewScope } from "../../services/link"; import { ViewScope } from "../../services/link";
@ -216,14 +216,19 @@ interface NoteInfoContext extends StatusBarContext {
setSimilarNotesShown: (value: boolean) => void; setSimilarNotesShown: (value: boolean) => void;
} }
export function NoteInfoBadge({ note, setSimilarNotesShown }: NoteInfoContext) { export function NoteInfoBadge({ note, similarNotesShown, setSimilarNotesShown }: NoteInfoContext) {
const dropdownRef = useRef<BootstrapDropdown>(null); const dropdownRef = useRef<BootstrapDropdown>(null);
const { metadata, ...sizeProps } = useNoteMetadata(note); const { metadata, ...sizeProps } = useNoteMetadata(note);
const [ originalFileName ] = useNoteLabel(note, "originalFileName"); const [ originalFileName ] = useNoteLabel(note, "originalFileName");
const currentNoteType = useNoteProperty(note, "type"); const noteType = useNoteProperty(note, "type");
const currentNoteTypeData = useMemo(() => NOTE_TYPES.find(t => t.type === currentNoteType), [ currentNoteType ]); const noteTypeMapping = useMemo(() => NOTE_TYPES.find(t => t.type === noteType), [ noteType ]);
const enabled = note && noteType && noteTypeMapping;
return (note && currentNoteTypeData && // Keyboard shortcut.
useTriliumEvent("toggleRibbonTabNoteInfo", () => enabled && dropdownRef.current?.show());
useTriliumEvent("toggleRibbonTabSimilarNotes", () => setSimilarNotesShown(!similarNotesShown));
return (enabled &&
<StatusBarDropdown <StatusBarDropdown
icon="bx bx-info-circle" icon="bx bx-info-circle"
title={t("status_bar.note_info_title")} title={t("status_bar.note_info_title")}
@ -235,7 +240,7 @@ export function NoteInfoBadge({ note, setSimilarNotesShown }: NoteInfoContext) {
{originalFileName && <NoteInfoValue text={t("file_properties.original_file_name")} value={originalFileName} />} {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.created")} value={formatDateTime(metadata?.dateCreated)} />
<NoteInfoValue text={t("note_info_widget.modified")} value={formatDateTime(metadata?.dateModified)} /> <NoteInfoValue text={t("note_info_widget.modified")} value={formatDateTime(metadata?.dateModified)} />
<NoteInfoValue text={t("note_info_widget.type")} value={<><Icon icon={`bx ${currentNoteTypeData.icon}`}/>{" "}{currentNoteTypeData?.title}</>} /> <NoteInfoValue text={t("note_info_widget.type")} value={<><Icon icon={`bx ${noteTypeMapping.icon ?? NOTE_TYPE_ICONS[noteType]}`}/>{" "}{noteTypeMapping?.title}</>} />
{note.mime && <NoteInfoValue text={t("note_info_widget.mime")} value={note.mime} />} {note.mime && <NoteInfoValue text={t("note_info_widget.mime")} value={note.mime} />}
<NoteInfoValue text={t("note_info_widget.note_id")} value={<code>{note.noteId}</code>} /> <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} />} /> <NoteInfoValue text={t("note_info_widget.note_size")} title={t("note_info_widget.note_size_info")} value={<NoteSizeWidget {...sizeProps} />} />
@ -349,6 +354,10 @@ function AttributesPane({ note, noteContext, attributesShown, setAttributesShown
// Show on keyboard shortcuts. // Show on keyboard shortcuts.
useTriliumEvents([ "addNewLabel", "addNewRelation" ], () => setAttributesShown(true)); useTriliumEvents([ "addNewLabel", "addNewRelation" ], () => setAttributesShown(true));
useTriliumEvents([ "toggleRibbonTabOwnedAttributes", "toggleRibbonTabInheritedAttributes" ], () => setAttributesShown(!attributesShown));
// Auto-focus the owned attributes.
useEffect(() => api.current?.focus(), [ attributesShown ]);
// Interaction with the attribute editor. // Interaction with the attribute editor.
useLegacyImperativeHandlers(useMemo(() => ({ useLegacyImperativeHandlers(useMemo(() => ({
@ -373,12 +382,18 @@ function AttributesPane({ note, noteContext, attributesShown, setAttributesShown
//#region Note paths //#region Note paths
function NotePaths({ note, hoistedNoteId, notePath }: StatusBarContext) { function NotePaths({ note, hoistedNoteId, notePath }: StatusBarContext) {
const dropdownRef = useRef<BootstrapDropdown>(null);
const sortedNotePaths = useSortedNotePaths(note, hoistedNoteId); const sortedNotePaths = useSortedNotePaths(note, hoistedNoteId);
const count = sortedNotePaths?.length ?? 0; const count = sortedNotePaths?.length ?? 0;
const enabled = count > 1;
return (count > 1 && // Keyboard shortcut.
useTriliumEvent("toggleRibbonTabNotePaths", () => enabled && dropdownRef.current?.show());
return (enabled &&
<StatusBarDropdown <StatusBarDropdown
title={t("status_bar.note_paths_title")} title={t("status_bar.note_paths_title")}
dropdownRef={dropdownRef}
dropdownContainerClassName="dropdown-note-paths" dropdownContainerClassName="dropdown-note-paths"
icon="bx bx-directions" icon="bx bx-directions"
text={t("status_bar.note_paths", { count })} text={t("status_bar.note_paths", { count })}

View File

@ -1,7 +1,7 @@
import "./CollectionProperties.css"; import "./CollectionProperties.css";
import { t } from "i18next"; import { t } from "i18next";
import { useContext } from "preact/hooks"; import { useContext, useRef } from "preact/hooks";
import { Fragment } from "preact/jsx-runtime"; import { Fragment } from "preact/jsx-runtime";
import FNote from "../../entities/fnote"; import FNote from "../../entities/fnote";
@ -12,7 +12,7 @@ import ActionButton from "../react/ActionButton";
import Dropdown from "../react/Dropdown"; import Dropdown from "../react/Dropdown";
import { FormDropdownDivider, FormDropdownSubmenu, FormListItem, FormListToggleableItem } from "../react/FormList"; import { FormDropdownDivider, FormDropdownSubmenu, FormListItem, FormListToggleableItem } from "../react/FormList";
import FormTextBox from "../react/FormTextBox"; import FormTextBox from "../react/FormTextBox";
import { useNoteLabel, useNoteLabelBoolean, useNoteLabelWithDefault } from "../react/hooks"; import { useNoteLabel, useNoteLabelBoolean, useNoteLabelWithDefault, useTriliumEvent } from "../react/hooks";
import Icon from "../react/Icon"; import Icon from "../react/Icon";
import { ParentComponent } from "../react/react_utils"; import { ParentComponent } from "../react/react_utils";
import { bookPropertiesConfig, BookProperty, ButtonProperty, CheckBoxProperty, ComboBoxItem, ComboBoxProperty, NumberProperty, SplitButtonProperty } from "../ribbon/collection-properties-config"; import { bookPropertiesConfig, BookProperty, ButtonProperty, CheckBoxProperty, ComboBoxItem, ComboBoxProperty, NumberProperty, SplitButtonProperty } from "../ribbon/collection-properties-config";
@ -42,8 +42,15 @@ export default function CollectionProperties({ note }: { note: FNote }) {
} }
function ViewTypeSwitcher({ viewType, setViewType }: { viewType: ViewTypeOptions, setViewType: (newValue: ViewTypeOptions) => void }) { function ViewTypeSwitcher({ viewType, setViewType }: { viewType: ViewTypeOptions, setViewType: (newValue: ViewTypeOptions) => void }) {
// Keyboard shortcut
const dropdownContainerRef = useRef<HTMLDivElement>(null);
useTriliumEvent("toggleRibbonTabBookProperties", () => {
dropdownContainerRef.current?.querySelector("button")?.focus();
});
return ( return (
<Dropdown <Dropdown
dropdownContainerRef={dropdownContainerRef}
text={<> text={<>
<Icon icon={ICON_MAPPINGS[viewType]} />&nbsp; <Icon icon={ICON_MAPPINGS[viewType]} />&nbsp;
{VIEW_TYPE_MAPPINGS[viewType]} {VIEW_TYPE_MAPPINGS[viewType]}

View File

@ -2,13 +2,13 @@ import "./FormList.css";
import { Dropdown as BootstrapDropdown, Tooltip } from "bootstrap"; import { Dropdown as BootstrapDropdown, Tooltip } from "bootstrap";
import clsx from "clsx"; import clsx from "clsx";
import { ComponentChildren } from "preact"; import { ComponentChildren, RefObject } from "preact";
import { type CSSProperties,useEffect, useMemo, useRef, useState } from "preact/compat"; import { type CSSProperties,useEffect, useMemo, useRef, useState } from "preact/compat";
import { CommandNames } from "../../components/app_context"; import { CommandNames } from "../../components/app_context";
import { handleRightToLeftPlacement, isMobile, openInAppHelpFromUrl } from "../../services/utils"; import { handleRightToLeftPlacement, isMobile, openInAppHelpFromUrl } from "../../services/utils";
import FormToggle from "./FormToggle"; import FormToggle from "./FormToggle";
import { useStaticTooltip } from "./hooks"; import { useStaticTooltip, useSyncedRef } from "./hooks";
import Icon from "./Icon"; import Icon from "./Icon";
interface FormListOpts { interface FormListOpts {
@ -97,6 +97,7 @@ interface FormListItemOpts {
className?: string; className?: string;
rtl?: boolean; rtl?: boolean;
postContent?: ComponentChildren; postContent?: ComponentChildren;
itemRef?: RefObject<HTMLLIElement>;
} }
const TOOLTIP_CONFIG: Partial<Tooltip.Options> = { const TOOLTIP_CONFIG: Partial<Tooltip.Options> = {
@ -104,8 +105,8 @@ const TOOLTIP_CONFIG: Partial<Tooltip.Options> = {
fallbackPlacements: [ handleRightToLeftPlacement("right") ] fallbackPlacements: [ handleRightToLeftPlacement("right") ]
}; };
export function FormListItem({ className, icon, value, title, active, disabled, checked, container, onClick, selected, rtl, triggerCommand, description, ...contentProps }: FormListItemOpts) { export function FormListItem({ className, icon, value, title, active, disabled, checked, container, onClick, selected, rtl, triggerCommand, description, itemRef: externalItemRef, ...contentProps }: FormListItemOpts) {
const itemRef = useRef<HTMLLIElement>(null); const itemRef = useSyncedRef<HTMLLIElement>(externalItemRef, null);
if (checked) { if (checked) {
icon = "bx bx-check"; icon = "bx bx-check";

View File

@ -933,11 +933,13 @@ export function useIsNoteReadOnly(note: FNote | null | undefined, noteContext: N
const [ isReadOnly, setIsReadOnly ] = useState<boolean | undefined>(undefined); const [ isReadOnly, setIsReadOnly ] = useState<boolean | undefined>(undefined);
const [ readOnlyAttr ] = useNoteLabelBoolean(note, "readOnly"); const [ readOnlyAttr ] = useNoteLabelBoolean(note, "readOnly");
const [ autoReadOnlyDisabledAttr ] = useNoteLabelBoolean(note, "autoReadOnlyDisabled"); const [ autoReadOnlyDisabledAttr ] = useNoteLabelBoolean(note, "autoReadOnlyDisabled");
const [ temporarilyEditable, setTemporarilyEditable ] = useState(false);
const enableEditing = useCallback((enabled = true) => { const enableEditing = useCallback((enabled = true) => {
if (noteContext?.viewScope) { if (noteContext?.viewScope) {
noteContext.viewScope.readOnlyTemporarilyDisabled = enabled; noteContext.viewScope.readOnlyTemporarilyDisabled = enabled;
appContext.triggerEvent("readOnlyTemporarilyDisabled", {noteContext}); appContext.triggerEvent("readOnlyTemporarilyDisabled", {noteContext});
setTemporarilyEditable(enabled);
} }
}, [noteContext]); }, [noteContext]);
@ -945,6 +947,7 @@ export function useIsNoteReadOnly(note: FNote | null | undefined, noteContext: N
if (note && noteContext) { if (note && noteContext) {
isNoteReadOnly(note, noteContext).then((readOnly) => { isNoteReadOnly(note, noteContext).then((readOnly) => {
setIsReadOnly(readOnly); setIsReadOnly(readOnly);
setTemporarilyEditable(false);
}); });
} }
}, [ note, noteContext, noteContext?.viewScope, readOnlyAttr, autoReadOnlyDisabledAttr ]); }, [ note, noteContext, noteContext?.viewScope, readOnlyAttr, autoReadOnlyDisabledAttr ]);
@ -952,10 +955,11 @@ export function useIsNoteReadOnly(note: FNote | null | undefined, noteContext: N
useTriliumEvent("readOnlyTemporarilyDisabled", ({noteContext: eventNoteContext}) => { useTriliumEvent("readOnlyTemporarilyDisabled", ({noteContext: eventNoteContext}) => {
if (noteContext?.ntxId === eventNoteContext.ntxId) { if (noteContext?.ntxId === eventNoteContext.ntxId) {
setIsReadOnly(!noteContext.viewScope?.readOnlyTemporarilyDisabled); setIsReadOnly(!noteContext.viewScope?.readOnlyTemporarilyDisabled);
setTemporarilyEditable(true);
} }
}); });
return { isReadOnly, enableEditing }; return { isReadOnly, enableEditing, temporarilyEditable };
} }
async function isNoteReadOnly(note: FNote, noteContext: NoteContext) { async function isNoteReadOnly(note: FNote, noteContext: NoteContext) {

View File

@ -49,6 +49,21 @@ export function FixedFormattingToolbar() {
const renderState = useRenderState(noteContext, note); const renderState = useRenderState(noteContext, note);
const [ toolbarToRender, setToolbarToRender ] = useState<HTMLElement | null | undefined>(); const [ toolbarToRender, setToolbarToRender ] = useState<HTMLElement | null | undefined>();
// Keyboard shortcut.
const lastFocusedElement = useRef<Element>(null);
useTriliumEvent("toggleRibbonTabClassicEditor", () => {
if (!toolbarToRender) return;
if (!toolbarToRender.contains(document.activeElement)) {
// Focus to the fixed formatting toolbar.
lastFocusedElement.current = document.activeElement;
toolbarToRender.querySelector<HTMLButtonElement>(".ck-toolbar__items button")?.focus();
} else {
// Focus back to the last selection.
(lastFocusedElement.current as HTMLElement)?.focus();
lastFocusedElement.current = null;
}
});
// Populate the cache with the toolbar of every note context. // Populate the cache with the toolbar of every note context.
useTriliumEvent("textEditorRefreshed", ({ ntxId: eventNtxId, editor }) => { useTriliumEvent("textEditorRefreshed", ({ ntxId: eventNtxId, editor }) => {
if (!eventNtxId) return; if (!eventNtxId) return;

View File

@ -1,5 +1,7 @@
import { ConvertToAttachmentResponse } from "@triliumnext/commons"; import { ConvertToAttachmentResponse } from "@triliumnext/commons";
import { useContext } from "preact/hooks"; import { Dropdown as BootstrapDropdown } from "bootstrap";
import { RefObject } from "preact";
import { useContext, useEffect, useRef } from "preact/hooks";
import appContext, { CommandNames } from "../../components/app_context"; import appContext, { CommandNames } from "../../components/app_context";
import Component from "../../components/component"; import Component from "../../components/component";
@ -20,7 +22,7 @@ import MovePaneButton from "../buttons/move_pane_button";
import ActionButton from "../react/ActionButton"; import ActionButton from "../react/ActionButton";
import Dropdown from "../react/Dropdown"; import Dropdown from "../react/Dropdown";
import { FormDropdownDivider, FormDropdownSubmenu, FormListHeader, FormListItem, FormListToggleableItem } from "../react/FormList"; import { FormDropdownDivider, FormDropdownSubmenu, FormListHeader, FormListItem, FormListToggleableItem } from "../react/FormList";
import { useIsNoteReadOnly, useNoteContext, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useTriliumOption } from "../react/hooks"; import { useIsNoteReadOnly, useNoteContext, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useTriliumEvent, useTriliumOption } from "../react/hooks";
import { ParentComponent } from "../react/react_utils"; import { ParentComponent } from "../react/react_utils";
import { NoteTypeDropdownContent, useNoteBookmarkState, useShareState } from "./BasicPropertiesTab"; import { NoteTypeDropdownContent, useNoteBookmarkState, useShareState } from "./BasicPropertiesTab";
import NoteActionsCustom from "./NoteActionsCustom"; import NoteActionsCustom from "./NoteActionsCustom";
@ -59,7 +61,10 @@ function RevisionsButton({ note }: { note: FNote }) {
); );
} }
type ItemToFocus = "basic-properties";
function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: NoteContext }) { function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: NoteContext }) {
const dropdownRef = useRef<BootstrapDropdown>(null);
const parentComponent = useContext(ParentComponent); const parentComponent = useContext(ParentComponent);
const noteType = useNoteProperty(note, "type") ?? ""; const noteType = useNoteProperty(note, "type") ?? "";
const [viewType] = useNoteLabel(note, "viewType"); const [viewType] = useNoteLabel(note, "viewType");
@ -77,14 +82,25 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not
const [syncServerHost] = useTriliumOption("syncServerHost"); const [syncServerHost] = useTriliumOption("syncServerHost");
const { isReadOnly, enableEditing } = useIsNoteReadOnly(note, noteContext); const { isReadOnly, enableEditing } = useIsNoteReadOnly(note, noteContext);
const isNormalViewMode = noteContext?.viewScope?.viewMode === "default"; const isNormalViewMode = noteContext?.viewScope?.viewMode === "default";
const itemToFocusRef = useRef<ItemToFocus>(null);
// Keyboard shortcuts.
useTriliumEvent("toggleRibbonTabBasicProperties", () => {
if (!isNewLayout) return;
itemToFocusRef.current = "basic-properties";
dropdownRef.current?.toggle();
});
return ( return (
<Dropdown <Dropdown
dropdownRef={dropdownRef}
buttonClassName={ isNewLayout ? "bx bx-dots-horizontal-rounded" : "bx bx-dots-vertical-rounded" } buttonClassName={ isNewLayout ? "bx bx-dots-horizontal-rounded" : "bx bx-dots-vertical-rounded" }
className="note-actions" className="note-actions"
hideToggleArrow hideToggleArrow
noSelectButtonStyle noSelectButtonStyle
iconAction> iconAction
onHidden={() => itemToFocusRef.current = null }
>
{isReadOnly && <> {isReadOnly && <>
<CommandItem icon="bx bx-pencil" text={t("read-only-info.edit-note")} <CommandItem icon="bx bx-pencil" text={t("read-only-info.edit-note")}
@ -99,7 +115,7 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not
<FormDropdownDivider /> <FormDropdownDivider />
{isNewLayout && isNormalViewMode && !isHelpPage && <> {isNewLayout && isNormalViewMode && !isHelpPage && <>
<NoteBasicProperties note={note} /> <NoteBasicProperties note={note} focus={itemToFocusRef} />
<FormDropdownDivider /> <FormDropdownDivider />
</>} </>}
@ -148,12 +164,22 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not
); );
} }
function NoteBasicProperties({ note }: { note: FNote }) { function NoteBasicProperties({ note, focus }: {
note: FNote;
focus: RefObject<ItemToFocus>;
}) {
const itemToFocusRef = useRef<HTMLLIElement>(null);
const [ isBookmarked, setIsBookmarked ] = useNoteBookmarkState(note); const [ isBookmarked, setIsBookmarked ] = useNoteBookmarkState(note);
const [ isShared, switchShareState ] = useShareState(note); const [ isShared, switchShareState ] = useShareState(note);
const [ isTemplate, setIsTemplate ] = useNoteLabelBoolean(note, "template"); const [ isTemplate, setIsTemplate ] = useNoteLabelBoolean(note, "template");
const isProtected = useNoteProperty(note, "isProtected"); const isProtected = useNoteProperty(note, "isProtected");
useEffect(() => {
if (focus.current === "basic-properties") {
itemToFocusRef.current?.focus();
}
}, [ focus ]);
return <> return <>
<FormListToggleableItem <FormListToggleableItem
icon="bx bx-share-alt" icon="bx bx-share-alt"
@ -161,6 +187,7 @@ function NoteBasicProperties({ note }: { note: FNote }) {
currentValue={isShared} onChange={switchShareState} currentValue={isShared} onChange={switchShareState}
helpPage="R9pX4DGra2Vt" helpPage="R9pX4DGra2Vt"
disabled={["root", "_share", "_hidden"].includes(note?.noteId ?? "") || note?.noteId.startsWith("_options")} disabled={["root", "_share", "_hidden"].includes(note?.noteId ?? "") || note?.noteId.startsWith("_options")}
itemRef={itemToFocusRef}
/> />
<FormListToggleableItem <FormListToggleableItem
icon="bx bx-lock-alt" icon="bx bx-lock-alt"

View File

@ -1,5 +1,5 @@
import { NoteType } from "@triliumnext/commons"; import { NoteType } from "@triliumnext/commons";
import { useContext, useEffect, useState } from "preact/hooks"; import { useContext, useEffect, useRef, useState } from "preact/hooks";
import Component from "../../components/component"; import Component from "../../components/component";
import NoteContext from "../../components/note_context"; import NoteContext from "../../components/note_context";
@ -12,7 +12,7 @@ import { ViewTypeOptions } from "../collections/interface";
import { buildSaveSqlToNoteHandler } from "../FloatingButtonsDefinitions"; import { buildSaveSqlToNoteHandler } from "../FloatingButtonsDefinitions";
import ActionButton from "../react/ActionButton"; import ActionButton from "../react/ActionButton";
import { FormFileUploadActionButton } from "../react/FormFileUpload"; import { FormFileUploadActionButton } from "../react/FormFileUpload";
import { useNoteLabel, useNoteLabelBoolean, useNoteProperty, useTriliumEvent, useTriliumOption } from "../react/hooks"; import { useNoteLabel, useNoteLabelBoolean, useNoteProperty, useTriliumEvent, useTriliumEvents, useTriliumOption } from "../react/hooks";
import { ParentComponent } from "../react/react_utils"; import { ParentComponent } from "../react/react_utils";
import { buildUploadNewFileRevisionListener } from "./FilePropertiesTab"; import { buildUploadNewFileRevisionListener } from "./FilePropertiesTab";
import { buildUploadNewImageRevisionListener } from "./ImagePropertiesTab"; import { buildUploadNewImageRevisionListener } from "./ImagePropertiesTab";
@ -38,6 +38,7 @@ interface NoteActionsCustomInnerProps extends NoteActionsCustomProps {
*/ */
export default function NoteActionsCustom(props: NoteActionsCustomProps) { export default function NoteActionsCustom(props: NoteActionsCustomProps) {
const { note } = props; const { note } = props;
const containerRef = useRef<HTMLDivElement>(null);
const noteType = useNoteProperty(note, "type"); const noteType = useNoteProperty(note, "type");
const noteMime = useNoteProperty(note, "mime"); const noteMime = useNoteProperty(note, "mime");
const [ viewType ] = useNoteLabel(note, "viewType"); const [ viewType ] = useNoteLabel(note, "viewType");
@ -53,8 +54,15 @@ export default function NoteActionsCustom(props: NoteActionsCustomProps) {
isReadOnly isReadOnly
}; };
useTriliumEvents([ "toggleRibbonTabFileProperties", "toggleRibbonTabImageProperties" ], () => {
(containerRef.current?.firstElementChild as HTMLElement)?.focus();
});
return (innerProps && return (innerProps &&
<div className="note-actions-custom"> <div
ref={containerRef}
className="note-actions-custom"
>
<AddChildButton {...innerProps} /> <AddChildButton {...innerProps} />
<RunActiveNoteButton {...innerProps } /> <RunActiveNoteButton {...innerProps } />
<OpenTriliumApiDocsButton {...innerProps} /> <OpenTriliumApiDocsButton {...innerProps} />

View File

@ -1,25 +1,26 @@
import { MutableRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "preact/hooks";
import { AttributeEditor as CKEditorAttributeEditor, MentionFeed, ModelElement, ModelNode, ModelPosition } from "@triliumnext/ckeditor5"; import { AttributeEditor as CKEditorAttributeEditor, MentionFeed, ModelElement, ModelNode, ModelPosition } from "@triliumnext/ckeditor5";
import { AttributeType } from "@triliumnext/commons";
import { MutableRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "preact/hooks";
import type { CommandData, FilteredCommandNames } from "../../../components/app_context";
import FAttribute from "../../../entities/fattribute";
import FNote from "../../../entities/fnote";
import contextMenu from "../../../menus/context_menu";
import attribute_parser, { Attribute } from "../../../services/attribute_parser";
import attribute_renderer from "../../../services/attribute_renderer";
import attributes from "../../../services/attributes";
import froca from "../../../services/froca";
import { t } from "../../../services/i18n"; import { t } from "../../../services/i18n";
import server from "../../../services/server"; import link from "../../../services/link";
import note_autocomplete, { Suggestion } from "../../../services/note_autocomplete"; import note_autocomplete, { Suggestion } from "../../../services/note_autocomplete";
import note_create from "../../../services/note_create";
import server from "../../../services/server";
import { isIMEComposing } from "../../../services/shortcuts";
import { escapeQuotes, getErrorMessage } from "../../../services/utils";
import AttributeDetailWidget from "../../attribute_widgets/attribute_detail";
import ActionButton from "../../react/ActionButton";
import CKEditor, { CKEditorApi } from "../../react/CKEditor"; import CKEditor, { CKEditorApi } from "../../react/CKEditor";
import { useLegacyImperativeHandlers, useLegacyWidget, useTooltip, useTriliumEvent, useTriliumOption } from "../../react/hooks"; import { useLegacyImperativeHandlers, useLegacyWidget, useTooltip, useTriliumEvent, useTriliumOption } from "../../react/hooks";
import FAttribute from "../../../entities/fattribute";
import attribute_renderer from "../../../services/attribute_renderer";
import FNote from "../../../entities/fnote";
import AttributeDetailWidget from "../../attribute_widgets/attribute_detail";
import attribute_parser, { Attribute } from "../../../services/attribute_parser";
import ActionButton from "../../react/ActionButton";
import { escapeQuotes, getErrorMessage } from "../../../services/utils";
import link from "../../../services/link";
import { isIMEComposing } from "../../../services/shortcuts";
import froca from "../../../services/froca";
import contextMenu from "../../../menus/context_menu";
import type { CommandData, FilteredCommandNames } from "../../../components/app_context";
import { AttributeType } from "@triliumnext/commons";
import attributes from "../../../services/attributes";
import note_create from "../../../services/note_create";
type AttributeCommandNames = FilteredCommandNames<CommandData>; type AttributeCommandNames = FilteredCommandNames<CommandData>;
@ -52,7 +53,7 @@ const mentionSetup: MentionFeed[] = [
return names.map((name) => { return names.map((name) => {
return { return {
id: `#${name}`, id: `#${name}`,
name: name name
}; };
}); });
}, },
@ -66,7 +67,7 @@ const mentionSetup: MentionFeed[] = [
return names.map((name) => { return names.map((name) => {
return { return {
id: `~${name}`, id: `~${name}`,
name: name name
}; };
}); });
}, },
@ -85,9 +86,10 @@ interface AttributeEditorProps {
} }
export interface AttributeEditorImperativeHandlers { export interface AttributeEditorImperativeHandlers {
save: () => Promise<void>; save(): Promise<void>;
refresh: () => void; refresh(): void;
renderOwnedAttributes: (ownedAttributes: FAttribute[]) => Promise<void>; focus(): void;
renderOwnedAttributes(ownedAttributes: FAttribute[]): Promise<void>;
} }
export default function AttributeEditor({ api, note, componentId, notePath, ntxId, hidden }: AttributeEditorProps) { export default function AttributeEditor({ api, note, componentId, notePath, ntxId, hidden }: AttributeEditorProps) {
@ -124,7 +126,7 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI
// attrs are not resorted if position changes after the initial load // attrs are not resorted if position changes after the initial load
ownedAttributes.sort((a, b) => a.position - b.position); ownedAttributes.sort((a, b) => a.position - b.position);
let htmlAttrs = ("<p>" + (await attribute_renderer.renderAttributes(ownedAttributes, true)).html() + "</p>"); let htmlAttrs = (`<p>${(await attribute_renderer.renderAttributes(ownedAttributes, true)).html()}</p>`);
if (saved) { if (saved) {
lastSavedContent.current = htmlAttrs; lastSavedContent.current = htmlAttrs;
@ -162,7 +164,7 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI
wrapperRef.current.style.opacity = "0"; wrapperRef.current.style.opacity = "0";
setTimeout(() => { setTimeout(() => {
if (wrapperRef.current) { if (wrapperRef.current) {
wrapperRef.current.style.opacity = "1" wrapperRef.current.style.opacity = "1";
} }
}, 100); }, 100);
} }
@ -252,7 +254,7 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI
if (notePath) { if (notePath) {
result = await note_create.createNoteWithTypePrompt(notePath, { result = await note_create.createNoteWithTypePrompt(notePath, {
activate: false, activate: false,
title: title title
}); });
} }
@ -274,7 +276,8 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI
useImperativeHandle(api, () => ({ useImperativeHandle(api, () => ({
save, save,
refresh, refresh,
renderOwnedAttributes: (attributes) => renderOwnedAttributes(attributes as FAttribute[], false) renderOwnedAttributes: (attributes) => renderOwnedAttributes(attributes as FAttribute[], false),
focus: () => editorRef.current?.focus()
}), [ save, refresh, renderOwnedAttributes ]); }), [ save, refresh, renderOwnedAttributes ]);
return ( return (
@ -404,7 +407,7 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI
{attributeDetailWidgetEl} {attributeDetailWidgetEl}
</> </>
) );
} }
function getPreprocessedData(currentValue: string) { function getPreprocessedData(currentValue: string) {

View File

@ -0,0 +1,114 @@
.old-layout-illustration {
width: 170px;
height: 130px;
border: 1px solid var(--main-border-color);
border-radius: 6px;
display: flex;
background: var(--root-background);
overflow: hidden;
.launcher-pane {
width: 10%;
background: var(--launcher-pane-vert-background-color);
display: flex;
flex-direction: column;
align-items: center;
padding: 1px 0;
svg {
margin-top: 1px;
margin-bottom: 5px;
}
.bx {
margin: 4px 0;
font-size: 12px;
opacity: 0.5;
}
}
.tree {
width: 20%;
font-size: 4px;
padding: 12px 5px;
overflow: hidden;
flex-shrink: 0;
filter: blur(1px);
ul {
list-style-type: none;
margin: 0;
padding: 0;
}
}
.main {
display: flex;
flex-direction: column;
flex-grow: 1;
font-size: 8px;
.tab-bar {
height: 10px;
flex-shrink: 0;
}
.content {
background-color: var(--main-background-color);
flex-grow: 1;
border-top-left-radius: 6px;
display: flex;
flex-direction: column;
min-height: 0;
.title-bar {
display: flex;
align-items: center;
font-size: 14px;
padding: 5px;
.title {
flex-grow: 1;
}
}
.ribbon {
padding: 0 5px;
.bx {
font-size: 10px;
}
.ribbon-header {
display: flex;
}
.ribbon-body {
height: 20px;
background-color: rgba(0, 0, 0, 0.05);
border-radius: 6px;
margin: 1px 0;
}
}
.content-inner {
font-size: 6px;
overflow: hidden;
padding: 5px;
opacity: 0.5;
filter: blur(1px);
}
.status-bar {
background-color: var(--left-pane-background-color);
flex-shrink: 0;
padding: 0 2px;
display: flex;
&> .status-bar-breadcrumb {
flex-grow: 1;
}
}
}
}
}

View File

@ -1,18 +1,24 @@
import "./appearance.css";
import { FontFamily, OptionNames } from "@triliumnext/commons";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { t } from "../../../services/i18n"; import { t } from "../../../services/i18n";
import { isElectron, isMobile, reloadFrontendApp, restartDesktopApp } from "../../../services/utils";
import Column from "../../react/Column";
import FormRadioGroup from "../../react/FormRadioGroup";
import FormSelect, { FormSelectWithGroups } from "../../react/FormSelect";
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
import OptionsSection from "./components/OptionsSection";
import server from "../../../services/server"; import server from "../../../services/server";
import { isElectron, isMobile, reloadFrontendApp, restartDesktopApp } from "../../../services/utils";
import { VerticalLayoutIcon } from "../../buttons/global_menu";
import Button from "../../react/Button";
import Column from "../../react/Column";
import FormCheckbox from "../../react/FormCheckbox"; import FormCheckbox from "../../react/FormCheckbox";
import FormGroup from "../../react/FormGroup"; import FormGroup from "../../react/FormGroup";
import { FontFamily, OptionNames } from "@triliumnext/commons"; import FormRadioGroup from "../../react/FormRadioGroup";
import FormTextBox, { FormTextBoxWithUnit } from "../../react/FormTextBox"; import FormSelect, { FormSelectWithGroups } from "../../react/FormSelect";
import FormText from "../../react/FormText"; import FormText from "../../react/FormText";
import Button from "../../react/Button"; import FormTextBox, { FormTextBoxWithUnit } from "../../react/FormTextBox";
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
import Icon from "../../react/Icon";
import OptionsSection from "./components/OptionsSection";
import RadioWithIllustration from "./components/RadioWithIllustration";
import RelatedSettings from "./components/RelatedSettings"; import RelatedSettings from "./components/RelatedSettings";
const MIN_CONTENT_WIDTH = 640; const MIN_CONTENT_WIDTH = 640;
@ -30,7 +36,7 @@ const BUILTIN_THEMES: Theme[] = [
{ val: "auto", title: t("theme.auto_theme") }, { val: "auto", title: t("theme.auto_theme") },
{ val: "light", title: t("theme.light_theme") }, { val: "light", title: t("theme.light_theme") },
{ val: "dark", title: t("theme.dark_theme") } { val: "dark", title: t("theme.dark_theme") }
] ];
interface FontFamilyEntry { interface FontFamilyEntry {
value: FontFamily; value: FontFamily;
@ -84,6 +90,7 @@ export default function AppearanceSettings() {
return ( return (
<div> <div>
{!isMobile() && <LayoutSwitcher />}
{!isMobile() && <LayoutOrientation />} {!isMobile() && <LayoutOrientation />}
<ApplicationTheme /> <ApplicationTheme />
{overrideThemeFonts === "true" && <Fonts />} {overrideThemeFonts === "true" && <Fonts />}
@ -102,7 +109,99 @@ export default function AppearanceSettings() {
} }
]} /> ]} />
</div> </div>
) );
}
function LayoutSwitcher() {
const [ newLayout, setNewLayout ] = useTriliumOptionBool("newLayout");
return (
<OptionsSection title={t("settings_appearance.ui")}>
<RadioWithIllustration
currentValue={newLayout ? "new-layout" : "old-layout"}
onChange={async newValue => {
await setNewLayout(newValue === "new-layout");
reloadFrontendApp();
}}
values={[
{ key: "old-layout", text: t("settings_appearance.ui_old_layout"), illustration: <LayoutIllustration /> },
{ key: "new-layout", text: t("settings_appearance.ui_new_layout"), illustration: <LayoutIllustration isNewLayout /> }
]}
/>
</OptionsSection>
);
}
function LayoutIllustration({ isNewLayout }: { isNewLayout?: boolean }) {
return (
<div className="old-layout-illustration">
<div className="launcher-pane">
<VerticalLayoutIcon />
<Icon icon="bx bx-send" />
<Icon icon="bx bx-file-blank" />
<Icon icon="bx bx-search" />
</div>
<div className="tree">
<ul>
<li>Options</li>
<ul>
<li>Appearance</li>
<li>Shortcuts</li>
<li>Text Notes</li>
<li>Code Notes</li>
<li>Images</li>
</ul>
</ul>
</div>
<div className="main">
<div className="tab-bar" />
<div className="content">
<div className="title-bar">
<Icon icon="bx bx-note" />
<span className="title">Title</span>
<Icon icon="bx bx-dock-right" />
</div>
{!isNewLayout && <div className="ribbon">
<div className="ribbon-header">
<Icon icon="bx bx-slider" />
<Icon icon="bx bx-list-check" />
<Icon icon="bx bx-list-plus" />
<Icon icon="bx bx-collection" />
</div>
<div className="ribbon-body" />
</div>}
{isNewLayout && <div className="note-title-actions">
<Icon icon="bx bx-chevron-down" />{" "}Promoted attributes
</div>}
<div className="content-inner">
This is a "demo" document packaged with Trilium to showcase some of its features and also give you some ideas on how you might structure your notes. You can play with it, and modify the note content and tree structure as you wish.
</div>
{isNewLayout && <div className="status-bar">
<div className="status-bar-breadcrumb">
<Icon icon="bx bx-home" />
<Icon icon="bx bx-chevron-right" />
Note
<Icon icon="bx bx-chevron-right" />
Note
</div>
<div className="status-bar-actions">
<Icon icon="bx bx-list-check" />
<Icon icon="bx bx-info-circle" />
</div>
</div>}
</div>
</div>
</div>
);
} }
function LayoutOrientation() { function LayoutOrientation() {
@ -141,7 +240,7 @@ function ApplicationTheme() {
setThemes([ setThemes([
...BUILTIN_THEMES, ...BUILTIN_THEMES,
...userThemes ...userThemes
]) ]);
}); });
}, []); }, []);
@ -162,7 +261,7 @@ function ApplicationTheme() {
</FormGroup> </FormGroup>
</div> </div>
</OptionsSection> </OptionsSection>
) );
} }
function Fonts() { function Fonts() {
@ -245,7 +344,7 @@ function ElectronIntegration() {
<Button text={t("electron_integration.restart-app-button")} onClick={restartDesktopApp} /> <Button text={t("electron_integration.restart-app-button")} onClick={restartDesktopApp} />
</OptionsSection> </OptionsSection>
) );
} }
function Performance() { function Performance() {
@ -271,7 +370,7 @@ function Performance() {
{isElectron() && <SmoothScrollEnabledOption />} {isElectron() && <SmoothScrollEnabledOption />}
</OptionsSection> </OptionsSection>;
} }
function SmoothScrollEnabledOption() { function SmoothScrollEnabledOption() {
@ -280,7 +379,7 @@ function SmoothScrollEnabledOption() {
return <FormCheckbox return <FormCheckbox
label={`${t("ui-performance.enable-smooth-scroll")} ${t("ui-performance.app-restart-required")}`} label={`${t("ui-performance.enable-smooth-scroll")} ${t("ui-performance.app-restart-required")}`}
currentValue={smoothScrollEnabled} onChange={setSmoothScrollEnabled} currentValue={smoothScrollEnabled} onChange={setSmoothScrollEnabled}
/> />;
} }
function MaxContentWidth() { function MaxContentWidth() {
@ -302,10 +401,10 @@ function MaxContentWidth() {
</Column> </Column>
<FormCheckbox label={t("max_content_width.centerContent")} <FormCheckbox label={t("max_content_width.centerContent")}
currentValue={centerContent} currentValue={centerContent}
onChange={setCenterContent} /> onChange={setCenterContent} />
</OptionsSection> </OptionsSection>
) );
} }
function RibbonOptions() { function RibbonOptions() {
@ -318,5 +417,5 @@ function RibbonOptions() {
currentValue={editedNotesOpenInRibbon} onChange={setEditedNotesOpenInRibbon} currentValue={editedNotesOpenInRibbon} onChange={setEditedNotesOpenInRibbon}
/> />
</OptionsSection> </OptionsSection>
) );
} }

View File

@ -8,6 +8,7 @@
.option-row > label { .option-row > label {
width: 40%; width: 40%;
margin-bottom: 0 !important; margin-bottom: 0 !important;
flex-shrink: 0;
} }
.option-row > select, .option-row > select,

View File

@ -0,0 +1,28 @@
.options-section .radio-with-illustration {
list-style-type: none;
margin-bottom: 0;
padding: 0;
display: flex;
gap: 1.5em;
justify-content: center;
figure {
figcaption {
margin-top: 0.25em;
text-align: center;
}
margin-bottom: 0;
&> .illustration {
border-radius: 6px;
padding: 3px;
cursor: pointer;
}
}
&> .selected figure > .illustration {
outline: 3px solid var(--input-focus-outline-color);
}
}

View File

@ -0,0 +1,38 @@
import "./RadioWithIllustration.css";
import clsx from "clsx";
import { ComponentChild } from "preact";
interface RadioWithIllustrationProps {
values: {
key: string;
text: string;
illustration: ComponentChild;
}[];
currentValue: string;
onChange(newValue: string): void;
}
export default function RadioWithIllustration({ currentValue, onChange, values }: RadioWithIllustrationProps) {
return (
<ul className="radio-with-illustration">
{values.map(value => (
<li
key={value.key}
className={clsx(value.key === currentValue && "selected")}
>
<figure>
<div
className="illustration"
role="button"
onClick={() => onChange(value.key)}
>
{value.illustration}
</div>
<figcaption>{value.text}</figcaption>
</figure>
</li>
))}
</ul>
);
}

Binary file not shown.

View File

@ -1,13 +1,14 @@
"use strict";
import optionService from "../../services/options.js";
import log from "../../services/log.js";
import searchService from "../../services/search/services/search.js";
import ValidationError from "../../errors/validation_error.js";
import type { Request } from "express";
import { changeLanguage, getLocales } from "../../services/i18n.js";
import type { OptionNames } from "@triliumnext/commons"; import type { OptionNames } from "@triliumnext/commons";
import type { Request } from "express";
import ValidationError from "../../errors/validation_error.js";
import config from "../../services/config.js"; import config from "../../services/config.js";
import { changeLanguage, getLocales } from "../../services/i18n.js";
import log from "../../services/log.js";
import optionService from "../../services/options.js";
import searchService from "../../services/search/services/search.js";
interface UserTheme { interface UserTheme {
val: string; // value of the theme, used in the URL val: string; // value of the theme, used in the URL
@ -100,6 +101,7 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
"splitEditorOrientation", "splitEditorOrientation",
"seenCallToActions", "seenCallToActions",
"experimentalFeatures", "experimentalFeatures",
"newLayout",
// AI/LLM integration options // AI/LLM integration options
"aiEnabled", "aiEnabled",

View File

@ -1,10 +1,11 @@
import optionService from "./options.js"; import { type KeyboardShortcutWithRequiredActionName, type OptionMap, type OptionNames, SANITIZER_DEFAULT_ALLOWED_TAGS } from "@triliumnext/commons";
import appInfo from "./app_info.js"; import appInfo from "./app_info.js";
import { randomSecureToken, isWindows } from "./utils.js";
import log from "./log.js";
import dateUtils from "./date_utils.js"; import dateUtils from "./date_utils.js";
import keyboardActions from "./keyboard_actions.js"; import keyboardActions from "./keyboard_actions.js";
import { SANITIZER_DEFAULT_ALLOWED_TAGS, type KeyboardShortcutWithRequiredActionName, type OptionMap, type OptionNames } from "@triliumnext/commons"; import log from "./log.js";
import optionService from "./options.js";
import { isWindows,randomSecureToken } from "./utils.js";
function initDocumentOptions() { function initDocumentOptions() {
optionService.createOption("documentId", randomSecureToken(16), false); optionService.createOption("documentId", randomSecureToken(16), false);
@ -156,6 +157,7 @@ const defaultOptions: DefaultOption[] = [
{ name: "shadowsEnabled", value: "true", isSynced: false }, { name: "shadowsEnabled", value: "true", isSynced: false },
{ name: "backdropEffectsEnabled", value: "true", isSynced: false }, { name: "backdropEffectsEnabled", value: "true", isSynced: false },
{ name: "smoothScrollEnabled", value: "true", isSynced: false }, { name: "smoothScrollEnabled", value: "true", isSynced: false },
{ name: "newLayout", value: "true", isSynced: true },
// Internationalization // Internationalization
{ name: "locale", value: "en", isSynced: true }, { name: "locale", value: "en", isSynced: true },
@ -171,9 +173,9 @@ const defaultOptions: DefaultOption[] = [
value: (optionsMap) => { value: (optionsMap) => {
if (optionsMap.theme === "light") { if (optionsMap.theme === "light") {
return "default:stackoverflow-light"; return "default:stackoverflow-light";
} else {
return "default:stackoverflow-dark";
} }
return "default:stackoverflow-dark";
}, },
isSynced: false isSynced: false
}, },

View File

@ -131,6 +131,7 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions<KeyboardActi
/** Whether keyboard auto-completion for editing commands is triggered when typing `/`. */ /** Whether keyboard auto-completion for editing commands is triggered when typing `/`. */
textNoteSlashCommandsEnabled: boolean; textNoteSlashCommandsEnabled: boolean;
backgroundEffects: boolean; backgroundEffects: boolean;
newLayout: boolean;
// Share settings // Share settings
redirectBareDomain: boolean; redirectBareDomain: boolean;