diff --git a/apps/client/src/components/root_command_executor.ts b/apps/client/src/components/root_command_executor.ts index 918809cd1..048aa4bd0 100644 --- a/apps/client/src/components/root_command_executor.ts +++ b/apps/client/src/components/root_command_executor.ts @@ -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 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 utils, { openInReusableSplit } from "../services/utils.js"; -import toastService from "../services/toast.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 { editReadOnlyNoteCommand() { @@ -193,10 +193,13 @@ export default class RootCommandExecutor extends Component { appContext.triggerEvent("zenModeChanged", { isEnabled }); } - async toggleRibbonTabNoteMapCommand() { + async toggleRibbonTabNoteMapCommand(data: CommandListenerData<"toggleRibbonTabNoteMap">) { const { isExperimentalFeatureEnabled } = await import("../services/experimental_features.js"); const isNewLayout = isExperimentalFeatureEnabled("new-layout"); - if (!isNewLayout) return; + if (!isNewLayout) { + this.triggerEvent("toggleRibbonTabNoteMap", data); + return; + } const activeContext = appContext.tabManager.getActiveContext(); if (!activeContext?.notePath) return; @@ -272,7 +275,7 @@ export default class RootCommandExecutor extends Component { } catch (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}`); } } } diff --git a/apps/client/src/entities/fnote.ts b/apps/client/src/entities/fnote.ts index 0f6df098a..cd1a8b7a7 100644 --- a/apps/client/src/entities/fnote.ts +++ b/apps/client/src/entities/fnote.ts @@ -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 type { Froca } from "../services/froca-interface.js"; -import type FAttachment from "./fattachment.js"; -import type { default as FAttribute, AttributeType } from "./fattribute.js"; -import utils from "../services/utils.js"; +import noteAttributeCache from "../services/note_attribute_cache.js"; +import protectedSessionHolder from "../services/protected_session_holder.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 RELATION = "relation"; -const NOTE_TYPE_ICONS = { +export const NOTE_TYPE_ICONS = { file: "bx bx-file", image: "bx bx-image", code: "bx bx-code", @@ -268,13 +268,12 @@ export default class FNote { } } return results; - } else { - return this.children; } + return this.children; } async getSubtreeNoteIds(includeArchived = false) { - let noteIds: (string | string[])[] = []; + const noteIds: (string | string[])[] = []; for (const child of await this.getChildNotes()) { if (child.isArchived && !includeArchived) continue; @@ -471,9 +470,8 @@ export default class FNote { return a.isHidden ? 1 : -1; } else if (a.isSearch !== b.isSearch) { return a.isSearch ? 1 : -1; - } else { - return a.notePath.length - b.notePath.length; } + return a.notePath.length - b.notePath.length; }); return notePaths; @@ -597,14 +595,12 @@ export default class FNote { } else if (this.type === "text") { if (this.isFolder()) { return "bx bx-folder"; - } else { - return "bx bx-note"; } + return "bx bx-note"; } else if (this.type === "code" && this.mime.startsWith("text/x-sql")) { return "bx bx-data"; - } else { - return NOTE_TYPE_ICONS[this.type]; } + return NOTE_TYPE_ICONS[this.type]; } getColorClass() { @@ -617,7 +613,7 @@ export default class FNote { } getFilteredChildBranches() { - let childBranches = this.getChildBranches(); + const childBranches = this.getChildBranches(); if (!childBranches) { 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)); } else if (nameWithPrefix.startsWith("~")) { 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) => { if (a.noteId === b.noteId) { 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; diff --git a/apps/client/src/services/experimental_features.ts b/apps/client/src/services/experimental_features.ts index 398467410..d252e2a77 100644 --- a/apps/client/src/services/experimental_features.ts +++ b/apps/client/src/services/experimental_features.ts @@ -20,11 +20,19 @@ export type ExperimentalFeatureId = typeof experimentalFeatures[number]["id"]; let enabledFeatures: Set | null = null; export function isExperimentalFeatureEnabled(featureId: ExperimentalFeatureId): boolean { + if (featureId === "new-layout") { + return options.is("newLayout"); + } + return getEnabledFeatures().has(featureId); } 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) { diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css index a7a13f288..f10ace694 100644 --- a/apps/client/src/stylesheets/style.css +++ b/apps/client/src/stylesheets/style.css @@ -717,12 +717,17 @@ table.promoted-attributes-in-tooltip th { .tooltip { font-size: var(--main-font-size) !important; z-index: calc(var(--ck-z-panel) - 1) !important; + white-space: pre-wrap; } .tooltip.tooltip-top { z-index: 32767 !important; } +.pre-wrap-text { + white-space: pre-wrap; +} + .bs-tooltip-bottom .tooltip-arrow::before { border-bottom-color: var(--main-border-color) !important; } diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 8764f8001..19c294044 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -2109,6 +2109,8 @@ "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_button": "Enable background effects", + "new_layout_title": "New layout", + "new_layout_message": "We’ve 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" }, "settings": { @@ -2116,7 +2118,10 @@ }, "settings_appearance": { "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": { "percentage": "%" diff --git a/apps/client/src/widgets/buttons/global_menu.tsx b/apps/client/src/widgets/buttons/global_menu.tsx index fa24c8387..255cf89c9 100644 --- a/apps/client/src/widgets/buttons/global_menu.tsx +++ b/apps/client/src/widgets/buttons/global_menu.tsx @@ -1,7 +1,7 @@ import "./global_menu.css"; import { KeyboardActionNames } from "@triliumnext/commons"; -import { ComponentChildren } from "preact"; +import { ComponentChildren, RefObject } from "preact"; import { useContext, useEffect, useRef, useState } from "preact/hooks"; import { CommandNames } from "../../components/app_context"; @@ -30,13 +30,15 @@ export default function GlobalMenu({ isHorizontalLayout }: { isHorizontalLayout: const parentComponent = useContext(ParentComponent); const { isUpdateAvailable, latestVersion } = useTriliumUpdateStatus(); const isMobileLocal = isMobile(); + const logoRef = useRef(null); + useStaticTooltip(logoRef); return ( - {isVerticalLayout && } + {isVerticalLayout && } {isUpdateAvailable && } @@ -135,9 +137,9 @@ function SwitchToOptions() { return; } else if (!isMobile()) { return ; - } + } return ; - + } function MenuItem({ icon, text, title, command, disabled, active }: MenuItemProps void)>) { @@ -159,10 +161,7 @@ function KeyboardActionMenuItem({ text, command, ...props }: MenuItemProps; } -function VerticalLayoutIcon() { - const logoRef = useRef(null); - useStaticTooltip(logoRef); - +export function VerticalLayoutIcon({ logoRef }: { logoRef?: RefObject }) { return ( diff --git a/apps/client/src/widgets/dialogs/call_to_action.tsx b/apps/client/src/widgets/dialogs/call_to_action.tsx index 1ae4a14eb..4f6da5293 100644 --- a/apps/client/src/widgets/dialogs/call_to_action.tsx +++ b/apps/client/src/widgets/dialogs/call_to_action.tsx @@ -1,11 +1,12 @@ import { useMemo, useState } from "preact/hooks"; + +import { t } from "../../services/i18n"; import Button from "../react/Button"; import Modal from "../react/Modal"; import { dismissCallToAction, getCallToActions } from "./call_to_action_definitions"; -import { t } from "../../services/i18n"; export default function CallToActionDialog() { - const activeCallToActions = useMemo(() => getCallToActions(), []); + const activeCallToActions = useMemo(() => getCallToActions(), []); const [ activeIndex, setActiveIndex ] = useState(0); const [ shown, setShown ] = useState(true); const activeItem = activeCallToActions[activeIndex]; @@ -36,11 +37,11 @@ export default function CallToActionDialog() { await dismissCallToAction(activeItem.id); await button.onClick(); goToNext(); - }}/> + }}/> )} } > -

{activeItem.message}

+

{activeItem.message}

- ) + ); } diff --git a/apps/client/src/widgets/dialogs/call_to_action_definitions.ts b/apps/client/src/widgets/dialogs/call_to_action_definitions.ts index 31982689e..056672b16 100644 --- a/apps/client/src/widgets/dialogs/call_to_action_definitions.ts +++ b/apps/client/src/widgets/dialogs/call_to_action_definitions.ts @@ -1,6 +1,6 @@ -import utils from "../../services/utils"; -import options from "../../services/options"; 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. @@ -46,20 +46,11 @@ function isNextTheme() { 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"); - } - } - ] + id: "new_layout", + title: t("call_to_action.new_layout_title"), + message: t("call_to_action.new_layout_message"), + enabled: () => true, + buttons: [] }, { 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"); + } + } + ] } ]; diff --git a/apps/client/src/widgets/layout/InlineTitle.css b/apps/client/src/widgets/layout/InlineTitle.css index a667e35fc..613c845e3 100644 --- a/apps/client/src/widgets/layout/InlineTitle.css +++ b/apps/client/src/widgets/layout/InlineTitle.css @@ -7,7 +7,6 @@ } .inline-title { - margin-top: 2px; /* Allow space for the focus outline */ max-width: var(--max-content-width); container-type: inline-size; padding-inline-start: 24px; @@ -111,7 +110,7 @@ body.prefers-centered-content .inline-title { .note-type-switcher { --badge-radius: 12px; - + position: relative; top: 5px; padding: .25em 0; @@ -121,7 +120,7 @@ body.prefers-centered-content .inline-title { min-width: 0; gap: 5px; min-height: 35px; - + >* { flex-shrink: 0; animation: note-type-switcher-intro 200ms ease-in; diff --git a/apps/client/src/widgets/layout/NoteBadges.tsx b/apps/client/src/widgets/layout/NoteBadges.tsx index 9cbbf6fcc..245f23199 100644 --- a/apps/client/src/widgets/layout/NoteBadges.tsx +++ b/apps/client/src/widgets/layout/NoteBadges.tsx @@ -22,11 +22,10 @@ export default function NoteBadges() { function ReadOnlyBadge() { const { note, noteContext } = useNoteContext(); - const { isReadOnly, enableEditing } = useIsNoteReadOnly(note, noteContext); + const { isReadOnly, enableEditing, temporarilyEditable } = useIsNoteReadOnly(note, noteContext); const isExplicitReadOnly = note?.isLabelTruthy("readOnly"); - const isTemporarilyEditable = noteContext?.ntxId !== "_popup-editor" && noteContext?.viewScope?.readOnlyTemporarilyDisabled; - if (isTemporarilyEditable) { + if (temporarilyEditable) { return setExpanded(!expanded)); + if (!cells?.length) return false; return (note && ( void; } -export function NoteInfoBadge({ note, setSimilarNotesShown }: NoteInfoContext) { +export function NoteInfoBadge({ note, similarNotesShown, setSimilarNotesShown }: NoteInfoContext) { const dropdownRef = useRef(null); const { metadata, ...sizeProps } = useNoteMetadata(note); const [ originalFileName ] = useNoteLabel(note, "originalFileName"); - const currentNoteType = useNoteProperty(note, "type"); - const currentNoteTypeData = useMemo(() => NOTE_TYPES.find(t => t.type === currentNoteType), [ currentNoteType ]); + const noteType = useNoteProperty(note, "type"); + 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 && } - {" "}{currentNoteTypeData?.title}} /> + {" "}{noteTypeMapping?.title}} /> {note.mime && } {note.noteId}} /> } /> @@ -349,6 +354,10 @@ function AttributesPane({ note, noteContext, attributesShown, setAttributesShown // Show on keyboard shortcuts. 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. useLegacyImperativeHandlers(useMemo(() => ({ @@ -373,12 +382,18 @@ function AttributesPane({ note, noteContext, attributesShown, setAttributesShown //#region Note paths function NotePaths({ note, hoistedNoteId, notePath }: StatusBarContext) { + const dropdownRef = useRef(null); const sortedNotePaths = useSortedNotePaths(note, hoistedNoteId); const count = sortedNotePaths?.length ?? 0; + const enabled = count > 1; - return (count > 1 && + // Keyboard shortcut. + useTriliumEvent("toggleRibbonTabNotePaths", () => enabled && dropdownRef.current?.show()); + + return (enabled && void }) { + // Keyboard shortcut + const dropdownContainerRef = useRef(null); + useTriliumEvent("toggleRibbonTabBookProperties", () => { + dropdownContainerRef.current?.querySelector("button")?.focus(); + }); + return (   {VIEW_TYPE_MAPPINGS[viewType]} diff --git a/apps/client/src/widgets/react/FormList.tsx b/apps/client/src/widgets/react/FormList.tsx index dd40bd08c..84cc4d8c6 100644 --- a/apps/client/src/widgets/react/FormList.tsx +++ b/apps/client/src/widgets/react/FormList.tsx @@ -2,13 +2,13 @@ import "./FormList.css"; import { Dropdown as BootstrapDropdown, Tooltip } from "bootstrap"; import clsx from "clsx"; -import { ComponentChildren } from "preact"; +import { ComponentChildren, RefObject } 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 { useStaticTooltip, useSyncedRef } from "./hooks"; import Icon from "./Icon"; interface FormListOpts { @@ -97,6 +97,7 @@ interface FormListItemOpts { className?: string; rtl?: boolean; postContent?: ComponentChildren; + itemRef?: RefObject; } const TOOLTIP_CONFIG: Partial = { @@ -104,8 +105,8 @@ const TOOLTIP_CONFIG: Partial = { fallbackPlacements: [ handleRightToLeftPlacement("right") ] }; -export function FormListItem({ className, icon, value, title, active, disabled, checked, container, onClick, selected, rtl, triggerCommand, description, ...contentProps }: FormListItemOpts) { - const itemRef = useRef(null); +export function FormListItem({ className, icon, value, title, active, disabled, checked, container, onClick, selected, rtl, triggerCommand, description, itemRef: externalItemRef, ...contentProps }: FormListItemOpts) { + const itemRef = useSyncedRef(externalItemRef, null); if (checked) { icon = "bx bx-check"; diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index f23b85d0c..e43ee4c8d 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -933,11 +933,13 @@ export function useIsNoteReadOnly(note: FNote | null | undefined, noteContext: N const [ isReadOnly, setIsReadOnly ] = useState(undefined); const [ readOnlyAttr ] = useNoteLabelBoolean(note, "readOnly"); const [ autoReadOnlyDisabledAttr ] = useNoteLabelBoolean(note, "autoReadOnlyDisabled"); + const [ temporarilyEditable, setTemporarilyEditable ] = useState(false); const enableEditing = useCallback((enabled = true) => { if (noteContext?.viewScope) { noteContext.viewScope.readOnlyTemporarilyDisabled = enabled; appContext.triggerEvent("readOnlyTemporarilyDisabled", {noteContext}); + setTemporarilyEditable(enabled); } }, [noteContext]); @@ -945,6 +947,7 @@ export function useIsNoteReadOnly(note: FNote | null | undefined, noteContext: N if (note && noteContext) { isNoteReadOnly(note, noteContext).then((readOnly) => { setIsReadOnly(readOnly); + setTemporarilyEditable(false); }); } }, [ note, noteContext, noteContext?.viewScope, readOnlyAttr, autoReadOnlyDisabledAttr ]); @@ -952,10 +955,11 @@ export function useIsNoteReadOnly(note: FNote | null | undefined, noteContext: N useTriliumEvent("readOnlyTemporarilyDisabled", ({noteContext: eventNoteContext}) => { if (noteContext?.ntxId === eventNoteContext.ntxId) { setIsReadOnly(!noteContext.viewScope?.readOnlyTemporarilyDisabled); + setTemporarilyEditable(true); } }); - return { isReadOnly, enableEditing }; + return { isReadOnly, enableEditing, temporarilyEditable }; } async function isNoteReadOnly(note: FNote, noteContext: NoteContext) { diff --git a/apps/client/src/widgets/ribbon/FormattingToolbar.tsx b/apps/client/src/widgets/ribbon/FormattingToolbar.tsx index 610063a3c..0225a74bc 100644 --- a/apps/client/src/widgets/ribbon/FormattingToolbar.tsx +++ b/apps/client/src/widgets/ribbon/FormattingToolbar.tsx @@ -49,6 +49,21 @@ export function FixedFormattingToolbar() { const renderState = useRenderState(noteContext, note); const [ toolbarToRender, setToolbarToRender ] = useState(); + // Keyboard shortcut. + const lastFocusedElement = useRef(null); + useTriliumEvent("toggleRibbonTabClassicEditor", () => { + if (!toolbarToRender) return; + if (!toolbarToRender.contains(document.activeElement)) { + // Focus to the fixed formatting toolbar. + lastFocusedElement.current = document.activeElement; + toolbarToRender.querySelector(".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. useTriliumEvent("textEditorRefreshed", ({ ntxId: eventNtxId, editor }) => { if (!eventNtxId) return; diff --git a/apps/client/src/widgets/ribbon/NoteActions.tsx b/apps/client/src/widgets/ribbon/NoteActions.tsx index bb6d9a8d8..dfb0d93bb 100644 --- a/apps/client/src/widgets/ribbon/NoteActions.tsx +++ b/apps/client/src/widgets/ribbon/NoteActions.tsx @@ -1,5 +1,7 @@ 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 Component from "../../components/component"; @@ -20,7 +22,7 @@ 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 { useIsNoteReadOnly, useNoteContext, useNoteLabel, useNoteLabelBoolean, useNoteProperty, useTriliumEvent, useTriliumOption } from "../react/hooks"; import { ParentComponent } from "../react/react_utils"; import { NoteTypeDropdownContent, useNoteBookmarkState, useShareState } from "./BasicPropertiesTab"; 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 }) { + const dropdownRef = useRef(null); const parentComponent = useContext(ParentComponent); const noteType = useNoteProperty(note, "type") ?? ""; const [viewType] = useNoteLabel(note, "viewType"); @@ -77,14 +82,25 @@ function NoteContextMenu({ note, noteContext }: { note: FNote, noteContext?: Not const [syncServerHost] = useTriliumOption("syncServerHost"); const { isReadOnly, enableEditing } = useIsNoteReadOnly(note, noteContext); const isNormalViewMode = noteContext?.viewScope?.viewMode === "default"; + const itemToFocusRef = useRef(null); + + // Keyboard shortcuts. + useTriliumEvent("toggleRibbonTabBasicProperties", () => { + if (!isNewLayout) return; + itemToFocusRef.current = "basic-properties"; + dropdownRef.current?.toggle(); + }); return ( + iconAction + onHidden={() => itemToFocusRef.current = null } + > {isReadOnly && <> {isNewLayout && isNormalViewMode && !isHelpPage && <> - + } @@ -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; +}) { + const itemToFocusRef = useRef(null); const [ isBookmarked, setIsBookmarked ] = useNoteBookmarkState(note); const [ isShared, switchShareState ] = useShareState(note); const [ isTemplate, setIsTemplate ] = useNoteLabelBoolean(note, "template"); const isProtected = useNoteProperty(note, "isProtected"); + useEffect(() => { + if (focus.current === "basic-properties") { + itemToFocusRef.current?.focus(); + } + }, [ focus ]); + return <> (null); const noteType = useNoteProperty(note, "type"); const noteMime = useNoteProperty(note, "mime"); const [ viewType ] = useNoteLabel(note, "viewType"); @@ -53,8 +54,15 @@ export default function NoteActionsCustom(props: NoteActionsCustomProps) { isReadOnly }; + useTriliumEvents([ "toggleRibbonTabFileProperties", "toggleRibbonTabImageProperties" ], () => { + (containerRef.current?.firstElementChild as HTMLElement)?.focus(); + }); + return (innerProps && -
+
diff --git a/apps/client/src/widgets/ribbon/components/AttributeEditor.tsx b/apps/client/src/widgets/ribbon/components/AttributeEditor.tsx index bf0aa9428..ee9129bbd 100644 --- a/apps/client/src/widgets/ribbon/components/AttributeEditor.tsx +++ b/apps/client/src/widgets/ribbon/components/AttributeEditor.tsx @@ -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 { 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 server from "../../../services/server"; +import link from "../../../services/link"; 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 { 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; @@ -52,7 +53,7 @@ const mentionSetup: MentionFeed[] = [ return names.map((name) => { return { id: `#${name}`, - name: name + name }; }); }, @@ -66,7 +67,7 @@ const mentionSetup: MentionFeed[] = [ return names.map((name) => { return { id: `~${name}`, - name: name + name }; }); }, @@ -85,9 +86,10 @@ interface AttributeEditorProps { } export interface AttributeEditorImperativeHandlers { - save: () => Promise; - refresh: () => void; - renderOwnedAttributes: (ownedAttributes: FAttribute[]) => Promise; + save(): Promise; + refresh(): void; + focus(): void; + renderOwnedAttributes(ownedAttributes: FAttribute[]): Promise; } 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 ownedAttributes.sort((a, b) => a.position - b.position); - let htmlAttrs = ("

" + (await attribute_renderer.renderAttributes(ownedAttributes, true)).html() + "

"); + let htmlAttrs = (`

${(await attribute_renderer.renderAttributes(ownedAttributes, true)).html()}

`); if (saved) { lastSavedContent.current = htmlAttrs; @@ -162,7 +164,7 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI wrapperRef.current.style.opacity = "0"; setTimeout(() => { if (wrapperRef.current) { - wrapperRef.current.style.opacity = "1" + wrapperRef.current.style.opacity = "1"; } }, 100); } @@ -252,7 +254,7 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI if (notePath) { result = await note_create.createNoteWithTypePrompt(notePath, { activate: false, - title: title + title }); } @@ -274,7 +276,8 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI useImperativeHandle(api, () => ({ save, refresh, - renderOwnedAttributes: (attributes) => renderOwnedAttributes(attributes as FAttribute[], false) + renderOwnedAttributes: (attributes) => renderOwnedAttributes(attributes as FAttribute[], false), + focus: () => editorRef.current?.focus() }), [ save, refresh, renderOwnedAttributes ]); return ( @@ -404,7 +407,7 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI {attributeDetailWidgetEl} - ) + ); } function getPreprocessedData(currentValue: string) { diff --git a/apps/client/src/widgets/type_widgets/options/appearance.css b/apps/client/src/widgets/type_widgets/options/appearance.css new file mode 100644 index 000000000..04643120d --- /dev/null +++ b/apps/client/src/widgets/type_widgets/options/appearance.css @@ -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; + } + } + } + } +} diff --git a/apps/client/src/widgets/type_widgets/options/appearance.tsx b/apps/client/src/widgets/type_widgets/options/appearance.tsx index 809d05e9e..feecb56f5 100644 --- a/apps/client/src/widgets/type_widgets/options/appearance.tsx +++ b/apps/client/src/widgets/type_widgets/options/appearance.tsx @@ -1,18 +1,24 @@ +import "./appearance.css"; + +import { FontFamily, OptionNames } from "@triliumnext/commons"; import { useEffect, useState } from "preact/hooks"; + 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 { 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 FormGroup from "../../react/FormGroup"; -import { FontFamily, OptionNames } from "@triliumnext/commons"; -import FormTextBox, { FormTextBoxWithUnit } from "../../react/FormTextBox"; +import FormRadioGroup from "../../react/FormRadioGroup"; +import FormSelect, { FormSelectWithGroups } from "../../react/FormSelect"; 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"; const MIN_CONTENT_WIDTH = 640; @@ -30,7 +36,7 @@ const BUILTIN_THEMES: Theme[] = [ { val: "auto", title: t("theme.auto_theme") }, { val: "light", title: t("theme.light_theme") }, { val: "dark", title: t("theme.dark_theme") } -] +]; interface FontFamilyEntry { value: FontFamily; @@ -84,6 +90,7 @@ export default function AppearanceSettings() { return (
+ {!isMobile() && } {!isMobile() && } {overrideThemeFonts === "true" && } @@ -102,7 +109,99 @@ export default function AppearanceSettings() { } ]} />
- ) + ); +} + +function LayoutSwitcher() { + const [ newLayout, setNewLayout ] = useTriliumOptionBool("newLayout"); + + return ( + + { + await setNewLayout(newValue === "new-layout"); + reloadFrontendApp(); + }} + values={[ + { key: "old-layout", text: t("settings_appearance.ui_old_layout"), illustration: }, + { key: "new-layout", text: t("settings_appearance.ui_new_layout"), illustration: } + ]} + /> + + ); +} + +function LayoutIllustration({ isNewLayout }: { isNewLayout?: boolean }) { + return ( +
+
+ + + + +
+ +
+
    +
  • Options
  • +
      +
    • Appearance
    • +
    • Shortcuts
    • +
    • Text Notes
    • +
    • Code Notes
    • +
    • Images
    • +
    +
+
+ +
+
+ +
+
+ + Title + +
+ + {!isNewLayout &&
+
+ + + + +
+ +
+
} + + {isNewLayout &&
+ {" "}Promoted attributes +
} + +
+ 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. +
+ + {isNewLayout &&
+
+ + + Note + + Note +
+ +
+ + +
+
} +
+
+
+ ); } function LayoutOrientation() { @@ -141,7 +240,7 @@ function ApplicationTheme() { setThemes([ ...BUILTIN_THEMES, ...userThemes - ]) + ]); }); }, []); @@ -162,7 +261,7 @@ function ApplicationTheme() {
- ) + ); } function Fonts() { @@ -245,7 +344,7 @@ function ElectronIntegration() {