From aa528c65b74890d27add5d51d1d8c30249b19c95 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Dec 2025 16:05:11 +0200 Subject: [PATCH 01/11] chore(layout/formatting_toolbar): render without adapter --- apps/client/src/layouts/desktop_layout.tsx | 81 +++++++++---------- .../src/widgets/ribbon/FormattingToolbar.tsx | 25 +++++- 2 files changed, 63 insertions(+), 43 deletions(-) diff --git a/apps/client/src/layouts/desktop_layout.tsx b/apps/client/src/layouts/desktop_layout.tsx index 2aa7c030a..83e90f859 100644 --- a/apps/client/src/layouts/desktop_layout.tsx +++ b/apps/client/src/layouts/desktop_layout.tsx @@ -1,58 +1,57 @@ -import { applyModals } from "./layout_commons.js"; -import { DESKTOP_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx"; +import type { AppContext } from "../components/app_context.js"; +import type { WidgetsByParent } from "../services/bundle.js"; +import { isExperimentalFeatureEnabled } from "../services/experimental_features.js"; +import options from "../services/options.js"; +import utils from "../services/utils.js"; import ApiLog from "../widgets/api_log.jsx"; import ClosePaneButton from "../widgets/buttons/close_pane_button.js"; -import CloseZenModeButton from "../widgets/close_zen_button.jsx"; -import ContentHeader from "../widgets/containers/content_header.js"; import CreatePaneButton from "../widgets/buttons/create_pane_button.js"; -import FindWidget from "../widgets/find.js"; -import FlexContainer from "../widgets/containers/flex_container.js"; -import FloatingButtons from "../widgets/FloatingButtons.jsx"; import GlobalMenu from "../widgets/buttons/global_menu.jsx"; -import HighlightsListWidget from "../widgets/highlights_list.js"; -import LeftPaneContainer from "../widgets/containers/left_pane_container.js"; import LeftPaneToggle from "../widgets/buttons/left_pane_toggle.js"; import MovePaneButton from "../widgets/buttons/move_pane_button.js"; -import NoteIconWidget from "../widgets/note_icon.jsx"; +import CloseZenModeButton from "../widgets/close_zen_button.jsx"; import NoteList from "../widgets/collections/NoteList.jsx"; -import NoteTitleWidget from "../widgets/note_title.jsx"; -import NoteTreeWidget from "../widgets/note_tree.js"; -import NoteWrapperWidget from "../widgets/note_wrapper.js"; -import options from "../services/options.js"; -import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js"; -import QuickSearchWidget from "../widgets/quick_search.js"; -import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx"; -import Ribbon from "../widgets/ribbon/Ribbon.jsx"; +import ContentHeader from "../widgets/containers/content_header.js"; +import FlexContainer from "../widgets/containers/flex_container.js"; +import LeftPaneContainer from "../widgets/containers/left_pane_container.js"; import RightPaneContainer from "../widgets/containers/right_pane_container.js"; import RootContainer from "../widgets/containers/root_container.js"; import ScrollingContainer from "../widgets/containers/scrolling_container.js"; -import ScrollPadding from "../widgets/scroll_padding.js"; -import SearchResult from "../widgets/search_result.jsx"; -import SharedInfo from "../widgets/shared_info.jsx"; import SplitNoteContainer from "../widgets/containers/split_note_container.js"; -import SqlResults from "../widgets/sql_result.js"; -import SqlTableSchemas from "../widgets/sql_table_schemas.js"; -import TabRowWidget from "../widgets/tab_row.js"; -import TitleBarButtons from "../widgets/title_bar_buttons.jsx"; -import TocWidget from "../widgets/toc.js"; -import type { AppContext } from "../components/app_context.js"; -import type { WidgetsByParent } from "../services/bundle.js"; +import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js"; import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js"; -import utils from "../services/utils.js"; -import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js"; -import NoteDetail from "../widgets/NoteDetail.jsx"; -import PromotedAttributes from "../widgets/PromotedAttributes.jsx"; -import SpacerWidget from "../widgets/launch_bar/SpacerWidget.jsx"; +import FindWidget from "../widgets/find.js"; +import FloatingButtons from "../widgets/FloatingButtons.jsx"; +import { DESKTOP_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx"; +import HighlightsListWidget from "../widgets/highlights_list.js"; import LauncherContainer from "../widgets/launch_bar/LauncherContainer.jsx"; -import TabHistoryNavigationButtons from "../widgets/TabHistoryNavigationButtons.jsx"; -import { isExperimentalFeatureEnabled } from "../services/experimental_features.js"; -import NoteActions from "../widgets/ribbon/NoteActions.jsx"; -import FormattingToolbar from "../widgets/ribbon/FormattingToolbar.jsx"; -import StandaloneRibbonAdapter from "../widgets/ribbon/components/StandaloneRibbonAdapter.jsx"; +import SpacerWidget from "../widgets/launch_bar/SpacerWidget.jsx"; +import InlineTitle from "../widgets/layout/InlineTitle.jsx"; import NoteBadges from "../widgets/layout/NoteBadges.jsx"; import NoteTitleActions from "../widgets/layout/NoteTitleActions.jsx"; import StatusBar from "../widgets/layout/StatusBar.jsx"; -import InlineTitle from "../widgets/layout/InlineTitle.jsx"; +import NoteIconWidget from "../widgets/note_icon.jsx"; +import NoteTitleWidget from "../widgets/note_title.jsx"; +import NoteTreeWidget from "../widgets/note_tree.js"; +import NoteWrapperWidget from "../widgets/note_wrapper.js"; +import NoteDetail from "../widgets/NoteDetail.jsx"; +import PromotedAttributes from "../widgets/PromotedAttributes.jsx"; +import QuickSearchWidget from "../widgets/quick_search.js"; +import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx"; +import { FixedFormattingToolbar } from "../widgets/ribbon/FormattingToolbar.jsx"; +import NoteActions from "../widgets/ribbon/NoteActions.jsx"; +import Ribbon from "../widgets/ribbon/Ribbon.jsx"; +import ScrollPadding from "../widgets/scroll_padding.js"; +import SearchResult from "../widgets/search_result.jsx"; +import SharedInfo from "../widgets/shared_info.jsx"; +import SqlResults from "../widgets/sql_result.js"; +import SqlTableSchemas from "../widgets/sql_table_schemas.js"; +import TabRowWidget from "../widgets/tab_row.js"; +import TabHistoryNavigationButtons from "../widgets/TabHistoryNavigationButtons.jsx"; +import TitleBarButtons from "../widgets/title_bar_buttons.jsx"; +import TocWidget from "../widgets/toc.js"; +import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js"; +import { applyModals } from "./layout_commons.js"; export default class DesktopLayout { @@ -95,7 +94,7 @@ export default class DesktopLayout { const rootContainer = new RootContainer(true) .setParent(appContext) - .class((launcherPaneIsHorizontal ? "horizontal" : "vertical") + "-layout") + .class(`${launcherPaneIsHorizontal ? "horizontal" : "vertical" }-layout`) .optChild( fullWidthTabBar, new FlexContainer("row") @@ -141,7 +140,7 @@ export default class DesktopLayout { .filling() .collapsible() .id("center-pane") - .optChild(isNewLayout, ) + .optChild(isNewLayout, ) .child( new SplitNoteContainer(() => new NoteWrapperWidget() diff --git a/apps/client/src/widgets/ribbon/FormattingToolbar.tsx b/apps/client/src/widgets/ribbon/FormattingToolbar.tsx index 47963b2bf..0e52ca8db 100644 --- a/apps/client/src/widgets/ribbon/FormattingToolbar.tsx +++ b/apps/client/src/widgets/ribbon/FormattingToolbar.tsx @@ -1,5 +1,6 @@ import { useRef } from "preact/hooks"; -import { useTriliumEvent, useTriliumOption } from "../react/hooks"; + +import { useActiveNoteContext, useIsNoteReadOnly, useNoteProperty, useTriliumEvent, useTriliumOption } from "../react/hooks"; import { TabContext } from "./ribbon-interface"; /** @@ -33,5 +34,25 @@ export default function FormattingToolbar({ hidden, ntxId }: TabContext) { ref={containerRef} className={`classic-toolbar-widget ${hidden ? "hidden-ext" : ""}`} /> - ) + ); }; + +export function FixedFormattingToolbar() { + const containerRef = useRef(null); + const [ textNoteEditorType ] = useTriliumOption("textNoteEditorType"); + const { note, noteContext } = useActiveNoteContext(); + const noteType = useNoteProperty(note, "type"); + const { isReadOnly } = useIsNoteReadOnly(note, noteContext); + const shown = ( + textNoteEditorType === "ckeditor-classic" && + noteType === "text" && + !isReadOnly + ); + + return ( +
Hi
+ ); +} From 4182f6043af37d0871bdb0cbb22c1ba228687206 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Dec 2025 16:26:01 +0200 Subject: [PATCH 02/11] feat(layout/formatting_toolbar): render cached components --- .../src/widgets/ribbon/FormattingToolbar.tsx | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/apps/client/src/widgets/ribbon/FormattingToolbar.tsx b/apps/client/src/widgets/ribbon/FormattingToolbar.tsx index 0e52ca8db..c1e7d103f 100644 --- a/apps/client/src/widgets/ribbon/FormattingToolbar.tsx +++ b/apps/client/src/widgets/ribbon/FormattingToolbar.tsx @@ -1,4 +1,4 @@ -import { useRef } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; import { useActiveNoteContext, useIsNoteReadOnly, useNoteProperty, useTriliumEvent, useTriliumOption } from "../react/hooks"; import { TabContext } from "./ribbon-interface"; @@ -37,22 +37,52 @@ export default function FormattingToolbar({ hidden, ntxId }: TabContext) { ); }; +const toolbarCache = new Map(); + export function FixedFormattingToolbar() { const containerRef = useRef(null); const [ textNoteEditorType ] = useTriliumOption("textNoteEditorType"); - const { note, noteContext } = useActiveNoteContext(); + const { note, noteContext, ntxId, viewScope } = useActiveNoteContext(); const noteType = useNoteProperty(note, "type"); const { isReadOnly } = useIsNoteReadOnly(note, noteContext); const shown = ( + viewScope?.viewMode === "default" && textNoteEditorType === "ckeditor-classic" && noteType === "text" && !isReadOnly ); + const [ toolbarToRender, setToolbarToRender ] = useState(); + + // Populate the cache with the toolbar of every note context. + useTriliumEvent("textEditorRefreshed", ({ ntxId: eventNtxId, editor }) => { + if (!eventNtxId) return; + const toolbar = editor.ui.view.toolbar?.element; + toolbarCache.set(eventNtxId, toolbar); + // Replace on the spot if the editor crashed. + if (eventNtxId === ntxId) { + setToolbarToRender(toolbar); + } + }); + + // Switch between the cached toolbar when user navigates to a different note context. + useEffect(() => { + if (!ntxId) return; + setToolbarToRender(toolbarCache.get(ntxId)); + }, [ ntxId, noteContext ]); + + // Render the toolbar. + useEffect(() => { + if (toolbarToRender) { + containerRef.current?.replaceChildren(toolbarToRender); + } else { + containerRef.current?.replaceChildren(); + } + }, [ toolbarToRender ]); return (
Hi
+ /> ); } From 476c1620163b30ce906090cc43b330c08a818b3b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Dec 2025 16:31:19 +0200 Subject: [PATCH 03/11] fix(layout/formatting_toolbar): memory leak for closed tabs --- apps/client/src/widgets/ribbon/FormattingToolbar.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/client/src/widgets/ribbon/FormattingToolbar.tsx b/apps/client/src/widgets/ribbon/FormattingToolbar.tsx index c1e7d103f..c8d32aab7 100644 --- a/apps/client/src/widgets/ribbon/FormattingToolbar.tsx +++ b/apps/client/src/widgets/ribbon/FormattingToolbar.tsx @@ -64,6 +64,13 @@ export function FixedFormattingToolbar() { } }); + // Clean the cache when tabs are closed. + useTriliumEvent("noteContextRemoved", ({ ntxIds: eventNtxIds }) => { + for (const eventNtxId of eventNtxIds) { + toolbarCache.delete(eventNtxId); + } + }); + // Switch between the cached toolbar when user navigates to a different note context. useEffect(() => { if (!ntxId) return; From b56e5b2483c88eb64aed2441d3b36d1f96df2e6d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Dec 2025 16:33:33 +0200 Subject: [PATCH 04/11] fix(inline_title): note type switcher visible for options --- apps/client/src/widgets/layout/InlineTitle.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/widgets/layout/InlineTitle.tsx b/apps/client/src/widgets/layout/InlineTitle.tsx index 60b65d2dc..cc4887308 100644 --- a/apps/client/src/widgets/layout/InlineTitle.tsx +++ b/apps/client/src/widgets/layout/InlineTitle.tsx @@ -159,7 +159,7 @@ function NoteTypeSwitcher() { const currentNoteTypeData = useMemo(() => NOTE_TYPES.find(t => t.type === currentNoteType), [ currentNoteType ]); const { builtinTemplates, collectionTemplates } = useBuiltinTemplates(); - return ( + return (currentNoteType && supportedNoteTypes.has(currentNoteType) &&
Date: Sat, 13 Dec 2025 16:35:48 +0200 Subject: [PATCH 05/11] feat(layout/formatting_toolbar): move above sidebar --- apps/client/src/layouts/desktop_layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/layouts/desktop_layout.tsx b/apps/client/src/layouts/desktop_layout.tsx index 83e90f859..c6de2f3bc 100644 --- a/apps/client/src/layouts/desktop_layout.tsx +++ b/apps/client/src/layouts/desktop_layout.tsx @@ -130,6 +130,7 @@ export default class DesktopLayout { .child(new TabRowWidget()) .optChild(customTitleBarButtons, ) .css("height", "40px")) + .optChild(isNewLayout, ) .child( new FlexContainer("row") .filling() @@ -140,7 +141,6 @@ export default class DesktopLayout { .filling() .collapsible() .id("center-pane") - .optChild(isNewLayout, ) .child( new SplitNoteContainer(() => new NoteWrapperWidget() From b10e7f18114169247086c7c6b9878a34fada176f Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Dec 2025 16:42:06 +0200 Subject: [PATCH 06/11] fix(inline_title): some badges not visible in split --- apps/client/src/widgets/layout/InlineTitle.css | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/client/src/widgets/layout/InlineTitle.css b/apps/client/src/widgets/layout/InlineTitle.css index 67bd4f980..1dd6d67b3 100644 --- a/apps/client/src/widgets/layout/InlineTitle.css +++ b/apps/client/src/widgets/layout/InlineTitle.css @@ -81,5 +81,6 @@ body.prefers-centered-content .inline-title { --color: var(--input-background-color); color: var(--main-text-color); font-size: 0.9rem; + flex-shrink: 0; } } From 0c9ff4dae46021a229794f01d175bc0e8ddca1db Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Dec 2025 16:43:27 +0200 Subject: [PATCH 07/11] chore(inline_title): fix type error --- apps/client/src/widgets/layout/InlineTitle.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/widgets/layout/InlineTitle.tsx b/apps/client/src/widgets/layout/InlineTitle.tsx index cc4887308..c3f86d10e 100644 --- a/apps/client/src/widgets/layout/InlineTitle.tsx +++ b/apps/client/src/widgets/layout/InlineTitle.tsx @@ -164,7 +164,7 @@ function NoteTypeSwitcher() { className="note-type-switcher" onWheel={onWheelHorizontalScroll} > - {blob?.contentLength === 0 && ( + {note && blob?.contentLength === 0 && ( <>
{t("note_title.note_type_switcher_label", { type: currentNoteTypeData?.title.toLocaleLowerCase() })}
{pinnedNoteTypes.map(noteType => noteType.type !== currentNoteType && ( From 2b1bc8e2b9f8ecb3f22bfd3d86cd24c20294ae79 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Dec 2025 16:54:04 +0200 Subject: [PATCH 08/11] feat(inline_title): in split, avoid layout shift by maintaining the toolbar --- .../src/widgets/ribbon/FormattingToolbar.tsx | 15 +++++++++++---- apps/client/src/widgets/ribbon/style.css | 9 +++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/client/src/widgets/ribbon/FormattingToolbar.tsx b/apps/client/src/widgets/ribbon/FormattingToolbar.tsx index c8d32aab7..8c4ba105a 100644 --- a/apps/client/src/widgets/ribbon/FormattingToolbar.tsx +++ b/apps/client/src/widgets/ribbon/FormattingToolbar.tsx @@ -1,3 +1,4 @@ +import clsx from "clsx"; import { useEffect, useRef, useState } from "preact/hooks"; import { useActiveNoteContext, useIsNoteReadOnly, useNoteProperty, useTriliumEvent, useTriliumOption } from "../react/hooks"; @@ -48,7 +49,7 @@ export function FixedFormattingToolbar() { const shown = ( viewScope?.viewMode === "default" && textNoteEditorType === "ckeditor-classic" && - noteType === "text" && + (noteContext?.getMainContext().getSubContexts() ?? []).some(sub => sub.note?.type === "text") && !isReadOnly ); const [ toolbarToRender, setToolbarToRender ] = useState(); @@ -74,8 +75,11 @@ export function FixedFormattingToolbar() { // Switch between the cached toolbar when user navigates to a different note context. useEffect(() => { if (!ntxId) return; - setToolbarToRender(toolbarCache.get(ntxId)); - }, [ ntxId, noteContext ]); + const toolbar = toolbarCache.get(ntxId); + if (toolbar) { + setToolbarToRender(toolbar); + } + }, [ ntxId, noteType, noteContext ]); // Render the toolbar. useEffect(() => { @@ -89,7 +93,10 @@ export function FixedFormattingToolbar() { return (
); } diff --git a/apps/client/src/widgets/ribbon/style.css b/apps/client/src/widgets/ribbon/style.css index bb0da9c4d..8cada3319 100644 --- a/apps/client/src/widgets/ribbon/style.css +++ b/apps/client/src/widgets/ribbon/style.css @@ -152,6 +152,15 @@ opacity: 0.3; } +body.experimental-feature-new-layout .classic-toolbar-widget { + transition: opacity 250ms ease-in; + + &.disabled { + opacity: 0.3; + pointer-events: none; + } +} + /* #endregion */ /* #region Script Tab */ From 8d536a6040f33b418fcc8237ad61e1a52051bcf0 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Dec 2025 19:14:23 +0200 Subject: [PATCH 09/11] fix(formatting_toolbar): view mode check not working in multi-split --- apps/client/src/components/note_context.ts | 28 ++-- apps/client/src/test/easy-froca.ts | 13 +- .../widgets/ribbon/FormattingToolbar.spec.ts | 151 ++++++++++++++++++ .../src/widgets/ribbon/FormattingToolbar.tsx | 78 +++++++-- 4 files changed, 241 insertions(+), 29 deletions(-) create mode 100644 apps/client/src/widgets/ribbon/FormattingToolbar.spec.ts diff --git a/apps/client/src/components/note_context.ts b/apps/client/src/components/note_context.ts index b360c6fce..caf9bdb13 100644 --- a/apps/client/src/components/note_context.ts +++ b/apps/client/src/components/note_context.ts @@ -1,18 +1,19 @@ -import protectedSessionHolder from "../services/protected_session_holder.js"; -import server from "../services/server.js"; -import utils from "../services/utils.js"; -import appContext, { type EventData, type EventListener } from "./app_context.js"; -import treeService from "../services/tree.js"; -import Component from "./component.js"; -import froca from "../services/froca.js"; -import hoistedNoteService from "../services/hoisted_note.js"; -import options from "../services/options.js"; -import type { ViewScope } from "../services/link.js"; -import type FNote from "../entities/fnote.js"; import type { CKTextEditor } from "@triliumnext/ckeditor5"; import type CodeMirror from "@triliumnext/codemirror"; + +import type FNote from "../entities/fnote.js"; import { closeActiveDialog } from "../services/dialog.js"; +import froca from "../services/froca.js"; +import hoistedNoteService from "../services/hoisted_note.js"; +import type { ViewScope } from "../services/link.js"; +import options from "../services/options.js"; +import protectedSessionHolder from "../services/protected_session_holder.js"; +import server from "../services/server.js"; +import treeService from "../services/tree.js"; +import utils from "../services/utils.js"; import { ReactWrappedWidget } from "../widgets/basic_widget.js"; +import appContext, { type EventData, type EventListener } from "./app_context.js"; +import Component from "./component.js"; export interface SetNoteOpts { triggerSwitchEvent?: unknown; @@ -64,21 +65,25 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded"> } async setNote(inputNotePath: string | undefined, opts: SetNoteOpts = {}) { + console.log("Set note to ", inputNotePath); opts.triggerSwitchEvent = opts.triggerSwitchEvent !== undefined ? opts.triggerSwitchEvent : true; opts.viewScope = opts.viewScope || {}; opts.viewScope.viewMode = opts.viewScope.viewMode || "default"; if (!inputNotePath) { + console.log("EXIT A"); return; } const resolvedNotePath = await this.getResolvedNotePath(inputNotePath); if (!resolvedNotePath) { + console.log("EXIT B"); return; } if (this.notePath === resolvedNotePath && utils.areObjectsEqual(this.viewScope, opts.viewScope)) { + console.log("EXIT C"); return; } @@ -89,6 +94,7 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded"> this.notePath = resolvedNotePath; this.viewScope = opts.viewScope; ({ noteId: this.noteId, parentNoteId: this.parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(resolvedNotePath)); + console.log("Note ID set to ", this.noteId); this.saveToRecentNotes(resolvedNotePath); diff --git a/apps/client/src/test/easy-froca.ts b/apps/client/src/test/easy-froca.ts index bcfa7eb0a..59d4c2e4a 100644 --- a/apps/client/src/test/easy-froca.ts +++ b/apps/client/src/test/easy-froca.ts @@ -1,10 +1,12 @@ -import utils from "../services/utils.js"; +import { NoteType } from "@triliumnext/commons"; + +import FAttribute from "../entities/fattribute.js"; +import FBlob from "../entities/fblob.js"; +import FBranch from "../entities/fbranch.js"; import FNote from "../entities/fnote.js"; import froca from "../services/froca.js"; -import FAttribute from "../entities/fattribute.js"; import noteAttributeCache from "../services/note_attribute_cache.js"; -import FBranch from "../entities/fbranch.js"; -import FBlob from "../entities/fblob.js"; +import utils from "../services/utils.js"; type AttributeDefinitions = { [key in `#${string}`]: string; }; type RelationDefinitions = { [key in `~${string}`]: string; }; @@ -12,6 +14,7 @@ type RelationDefinitions = { [key in `~${string}`]: string; }; interface NoteDefinition extends AttributeDefinitions, RelationDefinitions { id?: string | undefined; title: string; + type?: NoteType; children?: NoteDefinition[]; content?: string; } @@ -45,7 +48,7 @@ export function buildNote(noteDef: NoteDefinition) { const note = new FNote(froca, { noteId: noteDef.id ?? utils.randomString(12), title: noteDef.title, - type: "text", + type: noteDef.type ?? "text", mime: "text/html", isProtected: false, blobId: "" diff --git a/apps/client/src/widgets/ribbon/FormattingToolbar.spec.ts b/apps/client/src/widgets/ribbon/FormattingToolbar.spec.ts new file mode 100644 index 000000000..f35a97609 --- /dev/null +++ b/apps/client/src/widgets/ribbon/FormattingToolbar.spec.ts @@ -0,0 +1,151 @@ +import { NoteType } from "@triliumnext/commons"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import NoteContext from "../../components/note_context"; +import { ViewMode } from "../../services/link"; +import { randomString } from "../../services/utils"; +import { buildNote } from "../../test/easy-froca"; +import { getFormattingToolbarState } from "./FormattingToolbar"; + +interface NoteContextInfo { + type: NoteType; + viewScope?: ViewMode; + isReadOnly?: boolean; +} + +describe("Formatting toolbar logic", () => { + beforeAll(() => { + vi.mock("../../services/tree.ts", () => ({ + default: { + getActiveContextNotePath() { + return "root"; + }, + resolveNotePath(inputNotePath: string) { + return inputNotePath; + }, + getNoteIdFromUrl(url) { + return url.split("/").at(-1); + } + } + })); + + buildNote({ + id: "root", + title: "Root" + }); + }); + + async function buildConfig(noteContextInfos: NoteContextInfo[], activeIndex: number = 0) { + const noteContexts: NoteContext[] = []; + for (const noteContextData of noteContextInfos) { + const noteContext = new NoteContext(randomString(10)); + const note = buildNote({ + title: randomString(5), + type: noteContextData.type + }); + + noteContext.noteId = note.noteId; + expect(noteContext.note).toBe(note); + noteContext.viewScope = { + viewMode: noteContextData.viewScope ?? "default" + }; + noteContext.isReadOnly = async () => !!noteContextData.isReadOnly; + noteContext.getSubContexts = () => []; + noteContexts.push(noteContext); + }; + + const mainNoteContext = noteContexts[0]; + for (const noteContext of noteContexts) { + noteContext.getMainContext = () => mainNoteContext; + } + + mainNoteContext.getSubContexts = () => noteContexts; + return noteContexts[activeIndex]; + } + + async function testSplit(noteContextInfos: NoteContextInfo[], activeIndex: number = 0, editor = "ckeditor-classic") { + const noteContext = await buildConfig(noteContextInfos, activeIndex); + return await getFormattingToolbarState(noteContext, noteContext.note, editor); + } + + describe("Single split", () => { + it("should be hidden for floating toolbar", async () => { + expect(await testSplit([ { type: "text" } ], 0, "ckeditor-balloon")).toBe("hidden"); + }); + + it("should be visible for single text note", async () => { + expect(await testSplit([ { type: "text" } ])).toBe("visible"); + }); + + it("should be hidden for read-only text note", async () => { + expect(await testSplit([ { type: "text", isReadOnly: true } ])).toBe("hidden"); + }); + + it("should be hidden for non-text note", async () => { + expect(await testSplit([ { type: "code" } ])).toBe("hidden"); + }); + + it("should be hidden for wrong view mode", async () => { + expect(await testSplit([ { type: "text", viewScope: "attachments" } ])).toBe("hidden"); + }); + }); + + describe("Multi split", () => { + it("should be hidden for floating toolbar", async () => { + expect(await testSplit([ + { type: "text" }, + { type: "text" }, + ], 0, "ckeditor-balloon")).toBe("hidden"); + }); + + it("should be visible for two text notes", async () => { + expect(await testSplit([ + { type: "text" }, + { type: "text" }, + ])).toBe("visible"); + }); + + it("should be disabled if on a non-text note", async () => { + expect(await testSplit([ + { type: "text" }, + { type: "code" }, + ], 1)).toBe("disabled"); + }); + + it("should be hidden for all non-text notes", async () => { + expect(await testSplit([ + { type: "code" }, + { type: "canvas" }, + ])).toBe("hidden"); + }); + + it("should be hidden for all read-only text notes", async () => { + expect(await testSplit([ + { type: "text", isReadOnly: true }, + { type: "text", isReadOnly: true }, + ])).toBe("hidden"); + }); + + it("should be visible for mixed view mode", async () => { + expect(await testSplit([ + { type: "text" }, + { type: "text", viewScope: "attachments" } + ])).toBe("visible"); + }); + + it("should be hidden for all wrong view mode", async () => { + expect(await testSplit([ + { type: "text", viewScope: "attachments" }, + { type: "text", viewScope: "attachments" } + ])).toBe("hidden"); + }); + + it("should be disabled for wrong view mode", async () => { + expect(await testSplit([ + { type: "text" }, + { type: "text", viewScope: "attachments" } + ], 1)).toBe("disabled"); + }); + }); + +}); diff --git a/apps/client/src/widgets/ribbon/FormattingToolbar.tsx b/apps/client/src/widgets/ribbon/FormattingToolbar.tsx index 8c4ba105a..a68ade290 100644 --- a/apps/client/src/widgets/ribbon/FormattingToolbar.tsx +++ b/apps/client/src/widgets/ribbon/FormattingToolbar.tsx @@ -1,7 +1,9 @@ import clsx from "clsx"; -import { useEffect, useRef, useState } from "preact/hooks"; +import { useCallback, useEffect, useRef, useState } from "preact/hooks"; -import { useActiveNoteContext, useIsNoteReadOnly, useNoteProperty, useTriliumEvent, useTriliumOption } from "../react/hooks"; +import NoteContext from "../../components/note_context"; +import FNote from "../../entities/fnote"; +import { useActiveNoteContext, useIsNoteReadOnly, useNoteProperty, useTriliumEvent, useTriliumEvents, useTriliumOption } from "../react/hooks"; import { TabContext } from "./ribbon-interface"; /** @@ -42,16 +44,9 @@ const toolbarCache = new Map(); export function FixedFormattingToolbar() { const containerRef = useRef(null); - const [ textNoteEditorType ] = useTriliumOption("textNoteEditorType"); - const { note, noteContext, ntxId, viewScope } = useActiveNoteContext(); + const { note, noteContext, ntxId } = useActiveNoteContext(); const noteType = useNoteProperty(note, "type"); - const { isReadOnly } = useIsNoteReadOnly(note, noteContext); - const shown = ( - viewScope?.viewMode === "default" && - textNoteEditorType === "ckeditor-classic" && - (noteContext?.getMainContext().getSubContexts() ?? []).some(sub => sub.note?.type === "text") && - !isReadOnly - ); + const renderState = useRenderState(noteContext, note); const [ toolbarToRender, setToolbarToRender ] = useState(); // Populate the cache with the toolbar of every note context. @@ -94,9 +89,66 @@ export function FixedFormattingToolbar() {
); } + +function useRenderState(activeNoteContext: NoteContext | undefined, activeNote: FNote | null | undefined) { + const [ textNoteEditorType ] = useTriliumOption("textNoteEditorType"); + const [ state, setState ] = useState("hidden"); + + useTriliumEvents([ "newNoteContextCreated", "noteContextRemoved" ], () => { + getFormattingToolbarState(activeNoteContext, activeNote, textNoteEditorType).then(setState); + }); + + useEffect(() => { + getFormattingToolbarState(activeNoteContext, activeNote, textNoteEditorType).then(setState); + }, [ activeNoteContext, activeNote, textNoteEditorType ]); + + return state; +} + +export async function getFormattingToolbarState(activeNoteContext: NoteContext | undefined, activeNote: FNote | null | undefined, textNoteEditorType: string) { + if (!activeNoteContext || textNoteEditorType !== "ckeditor-classic") { + return "hidden"; + } + + const subContexts = activeNoteContext?.getMainContext().getSubContexts() ?? []; + if (subContexts.length === 1) { + if (activeNote?.type !== "text" || activeNoteContext.viewScope?.viewMode !== "default") { + return "hidden"; + } + + const isReadOnly = await activeNoteContext.isReadOnly(); + if (isReadOnly) { + return "hidden"; + } + + return "visible"; + } + + // If there are multiple note contexts (e.g. splits), the logic is slightly different. + const textNoteContexts = subContexts.filter(s => s.note?.type === "text" && s.viewScope?.viewMode === "default"); + const textNoteContextsReadOnly = await Promise.all(textNoteContexts.map(sc => sc.isReadOnly())); + + // If all text notes are hidden, no need to display the toolbar at all. + if (textNoteContextsReadOnly.indexOf(false) === -1) { + return "hidden"; + } + + // If the current subcontext is not a text note, but there is at least an editable text then it must be disabled. + if (activeNote?.type !== "text") return "disabled"; + + // If the current subcontext is a text note, it must not be read-only. + if (activeNote.type === "text") { + const subContextIndex = textNoteContexts.indexOf(activeNoteContext); + if (subContextIndex !== -1) { + if (textNoteContextsReadOnly[subContextIndex]) return "disabled"; + } + if (activeNoteContext.viewScope?.viewMode !== "default") return "disabled"; + } + return "visible"; +} From ad08fb81320fab0c565fd6e2c4929ea77ebf9d51 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Dec 2025 19:32:44 +0200 Subject: [PATCH 10/11] chore(formatting_toolbar): address self-review --- apps/client/src/components/note_context.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/client/src/components/note_context.ts b/apps/client/src/components/note_context.ts index caf9bdb13..15791c741 100644 --- a/apps/client/src/components/note_context.ts +++ b/apps/client/src/components/note_context.ts @@ -65,25 +65,21 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded"> } async setNote(inputNotePath: string | undefined, opts: SetNoteOpts = {}) { - console.log("Set note to ", inputNotePath); opts.triggerSwitchEvent = opts.triggerSwitchEvent !== undefined ? opts.triggerSwitchEvent : true; opts.viewScope = opts.viewScope || {}; opts.viewScope.viewMode = opts.viewScope.viewMode || "default"; if (!inputNotePath) { - console.log("EXIT A"); return; } const resolvedNotePath = await this.getResolvedNotePath(inputNotePath); if (!resolvedNotePath) { - console.log("EXIT B"); return; } if (this.notePath === resolvedNotePath && utils.areObjectsEqual(this.viewScope, opts.viewScope)) { - console.log("EXIT C"); return; } @@ -94,7 +90,6 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded"> this.notePath = resolvedNotePath; this.viewScope = opts.viewScope; ({ noteId: this.noteId, parentNoteId: this.parentNoteId } = treeService.getNoteIdAndParentIdFromUrl(resolvedNotePath)); - console.log("Note ID set to ", this.noteId); this.saveToRecentNotes(resolvedNotePath); From 3b909fd739d67f628ee521ac99c560455b660a7e Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 13 Dec 2025 19:59:45 +0200 Subject: [PATCH 11/11] chore(layout/formatting_toolbar): address requested changes --- .../src/widgets/ribbon/FormattingToolbar.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/client/src/widgets/ribbon/FormattingToolbar.tsx b/apps/client/src/widgets/ribbon/FormattingToolbar.tsx index a68ade290..b78734f9f 100644 --- a/apps/client/src/widgets/ribbon/FormattingToolbar.tsx +++ b/apps/client/src/widgets/ribbon/FormattingToolbar.tsx @@ -1,9 +1,9 @@ import clsx from "clsx"; -import { useCallback, useEffect, useRef, useState } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; import NoteContext from "../../components/note_context"; import FNote from "../../entities/fnote"; -import { useActiveNoteContext, useIsNoteReadOnly, useNoteProperty, useTriliumEvent, useTriliumEvents, useTriliumOption } from "../react/hooks"; +import { useActiveNoteContext, useNoteProperty, useTriliumEvent, useTriliumEvents, useTriliumOption } from "../react/hooks"; import { TabContext } from "./ribbon-interface"; /** @@ -143,12 +143,10 @@ export async function getFormattingToolbarState(activeNoteContext: NoteContext | if (activeNote?.type !== "text") return "disabled"; // If the current subcontext is a text note, it must not be read-only. - if (activeNote.type === "text") { - const subContextIndex = textNoteContexts.indexOf(activeNoteContext); - if (subContextIndex !== -1) { - if (textNoteContextsReadOnly[subContextIndex]) return "disabled"; - } - if (activeNoteContext.viewScope?.viewMode !== "default") return "disabled"; + const subContextIndex = textNoteContexts.indexOf(activeNoteContext); + if (subContextIndex !== -1) { + if (textNoteContextsReadOnly[subContextIndex]) return "disabled"; } + if (activeNoteContext.viewScope?.viewMode !== "default") return "disabled"; return "visible"; }