diff --git a/apps/client/src/components/note_context.ts b/apps/client/src/components/note_context.ts
index b360c6fce..15791c741 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;
diff --git a/apps/client/src/layouts/desktop_layout.tsx b/apps/client/src/layouts/desktop_layout.tsx
index 2aa7c030a..c6de2f3bc 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")
@@ -131,6 +130,7 @@ export default class DesktopLayout {
.child(new TabRowWidget())
.optChild(customTitleBarButtons, )
.css("height", "40px"))
+ .optChild(isNewLayout, )
.child(
new FlexContainer("row")
.filling()
@@ -141,7 +141,6 @@ export default class DesktopLayout {
.filling()
.collapsible()
.id("center-pane")
- .optChild(isNewLayout, )
.child(
new SplitNoteContainer(() =>
new NoteWrapperWidget()
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/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;
}
}
diff --git a/apps/client/src/widgets/layout/InlineTitle.tsx b/apps/client/src/widgets/layout/InlineTitle.tsx
index 60b65d2dc..c3f86d10e 100644
--- a/apps/client/src/widgets/layout/InlineTitle.tsx
+++ b/apps/client/src/widgets/layout/InlineTitle.tsx
@@ -159,12 +159,12 @@ function NoteTypeSwitcher() {
const currentNoteTypeData = useMemo(() => NOTE_TYPES.find(t => t.type === currentNoteType), [ currentNoteType ]);
const { builtinTemplates, collectionTemplates } = useBuiltinTemplates();
- return (
+ return (currentNoteType && supportedNoteTypes.has(currentNoteType) &&
- {blob?.contentLength === 0 && (
+ {note && blob?.contentLength === 0 && (
<>
{t("note_title.note_type_switcher_label", { type: currentNoteTypeData?.title.toLocaleLowerCase() })}
{pinnedNoteTypes.map(noteType => noteType.type !== currentNoteType && (
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 47963b2bf..b78734f9f 100644
--- a/apps/client/src/widgets/ribbon/FormattingToolbar.tsx
+++ b/apps/client/src/widgets/ribbon/FormattingToolbar.tsx
@@ -1,5 +1,9 @@
-import { useRef } from "preact/hooks";
-import { useTriliumEvent, useTriliumOption } from "../react/hooks";
+import clsx from "clsx";
+import { useEffect, useRef, useState } from "preact/hooks";
+
+import NoteContext from "../../components/note_context";
+import FNote from "../../entities/fnote";
+import { useActiveNoteContext, useNoteProperty, useTriliumEvent, useTriliumEvents, useTriliumOption } from "../react/hooks";
import { TabContext } from "./ribbon-interface";
/**
@@ -33,5 +37,116 @@ export default function FormattingToolbar({ hidden, ntxId }: TabContext) {
ref={containerRef}
className={`classic-toolbar-widget ${hidden ? "hidden-ext" : ""}`}
/>
- )
+ );
};
+
+const toolbarCache = new Map
();
+
+export function FixedFormattingToolbar() {
+ const containerRef = useRef(null);
+ const { note, noteContext, ntxId } = useActiveNoteContext();
+ const noteType = useNoteProperty(note, "type");
+ const renderState = useRenderState(noteContext, note);
+ 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);
+ }
+ });
+
+ // 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;
+ const toolbar = toolbarCache.get(ntxId);
+ if (toolbar) {
+ setToolbarToRender(toolbar);
+ }
+ }, [ ntxId, noteType, noteContext ]);
+
+ // Render the toolbar.
+ useEffect(() => {
+ if (toolbarToRender) {
+ containerRef.current?.replaceChildren(toolbarToRender);
+ } else {
+ containerRef.current?.replaceChildren();
+ }
+ }, [ toolbarToRender ]);
+
+ return (
+
+ );
+}
+
+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.
+ const subContextIndex = textNoteContexts.indexOf(activeNoteContext);
+ if (subContextIndex !== -1) {
+ if (textNoteContextsReadOnly[subContextIndex]) return "disabled";
+ }
+ if (activeNoteContext.viewScope?.viewMode !== "default") return "disabled";
+ return "visible";
+}
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 */