From 616af1502f6def57df0392c4750f228897867266 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 17 Dec 2025 23:01:44 +0200 Subject: [PATCH 01/74] feat(layout/right_pane): create empty container --- apps/client/src/layouts/desktop_layout.tsx | 4 +++- apps/client/src/widgets/sidebar/RightPanelContainer.tsx | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 apps/client/src/widgets/sidebar/RightPanelContainer.tsx diff --git a/apps/client/src/layouts/desktop_layout.tsx b/apps/client/src/layouts/desktop_layout.tsx index 4f6f7e5af..013460723 100644 --- a/apps/client/src/layouts/desktop_layout.tsx +++ b/apps/client/src/layouts/desktop_layout.tsx @@ -44,6 +44,7 @@ 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 RightPanelContainer from "../widgets/sidebar/RightPanelContainer.jsx"; import SqlResults from "../widgets/sql_result.js"; import SqlTableSchemas from "../widgets/sql_table_schemas.js"; import TabRowWidget from "../widgets/tab_row.js"; @@ -174,12 +175,13 @@ export default class DesktopLayout { .child(...this.customWidgets.get("center-pane")) ) - .child( + .optChild(!isNewLayout, new RightPaneContainer() .child(new TocWidget()) .child(new HighlightsListWidget()) .child(...this.customWidgets.get("right-pane")) ) + .optChild(isNewLayout, ) ) .optChild(!launcherPaneIsHorizontal && isNewLayout, ) ) diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx new file mode 100644 index 000000000..fc379c5a9 --- /dev/null +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -0,0 +1,9 @@ +//! This is currently only used for the new layout. + +export default function RightPanelContainer() { + return ( +
+ Hi there. +
+ ); +} From f46de50f17efb271b8e12f72deb4f36fece4d01d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 17 Dec 2025 23:03:57 +0200 Subject: [PATCH 02/74] refactor(layout/right_pane): CSS for container --- apps/client/src/widgets/sidebar/RightPanelContainer.css | 3 +++ apps/client/src/widgets/sidebar/RightPanelContainer.tsx | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 apps/client/src/widgets/sidebar/RightPanelContainer.css diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.css b/apps/client/src/widgets/sidebar/RightPanelContainer.css new file mode 100644 index 000000000..65cff510e --- /dev/null +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.css @@ -0,0 +1,3 @@ +body.experimental-feature-new-layout #right-pane { + width: 300px; +} diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index fc379c5a9..b00386a10 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -1,8 +1,9 @@ //! This is currently only used for the new layout. +import "./RightPanelContainer.css"; export default function RightPanelContainer() { return ( -
+
Hi there.
); From dac923e45d9a225d6f1f030f227f9b656a612948 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 17 Dec 2025 23:17:25 +0200 Subject: [PATCH 03/74] chore(layout/right_pane): bring back resizer --- .../widgets/sidebar/RightPanelContainer.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index b00386a10..8b57147ce 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -1,7 +1,28 @@ //! This is currently only used for the new layout. import "./RightPanelContainer.css"; +import Split from "@triliumnext/split.js"; +import { useEffect } from "preact/hooks"; + +import options from "../../services/options"; +import { DEFAULT_GUTTER_SIZE } from "../../services/resizer"; + +const MIN_WIDTH_PERCENT = 5; + export default function RightPanelContainer() { + useEffect(() => { + // We are intentionally omitting useTriliumOption to avoid re-render due to size change. + const rightPaneWidth = Math.max(MIN_WIDTH_PERCENT, options.getInt("rightPaneWidth") ?? MIN_WIDTH_PERCENT); + const splitInstance = Split(["#center-pane", "#right-pane"], { + sizes: [100 - rightPaneWidth, rightPaneWidth], + gutterSize: DEFAULT_GUTTER_SIZE, + minSize: [300, 180], + rtl: glob.isRtl, + onDragEnd: (sizes) => options.save("rightPaneWidth", Math.round(sizes[1])) + }); + return () => splitInstance.destroy(); + }, []); + return (
Hi there. From 98ed442d27cc763883ea4630d479099b9fd6ab1b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 17 Dec 2025 23:19:42 +0200 Subject: [PATCH 04/74] chore(layout/right_pane): empty table of contents --- .../src/widgets/sidebar/RightPanelContainer.tsx | 3 ++- apps/client/src/widgets/sidebar/TableOfContents.tsx | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 apps/client/src/widgets/sidebar/TableOfContents.tsx diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index 8b57147ce..34ec8360c 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -6,6 +6,7 @@ import { useEffect } from "preact/hooks"; import options from "../../services/options"; import { DEFAULT_GUTTER_SIZE } from "../../services/resizer"; +import TableOfContents from "./TableOfContents"; const MIN_WIDTH_PERCENT = 5; @@ -25,7 +26,7 @@ export default function RightPanelContainer() { return (
- Hi there. +
); } diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx new file mode 100644 index 000000000..23e08506d --- /dev/null +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -0,0 +1,12 @@ +import { t } from "../../services/i18n"; +import RightPanelWidget from "./RightPanelWidget"; + +export default function TableOfContents() { + + return ( + + Toc is here. + + ); + +} From 2e484a11e635908ebcb644b143cec9f6b2f0d960 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Wed, 17 Dec 2025 23:36:26 +0200 Subject: [PATCH 05/74] feat(layout/right_pane): basic store to read content without blob --- apps/client/src/widgets/react/NoteStore.ts | 33 +++++++++++++++++++ apps/client/src/widgets/react/hooks.tsx | 13 +++++++- .../widgets/sidebar/RightPanelContainer.tsx | 6 +++- .../src/widgets/sidebar/TableOfContents.tsx | 7 ++-- 4 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 apps/client/src/widgets/react/NoteStore.ts diff --git a/apps/client/src/widgets/react/NoteStore.ts b/apps/client/src/widgets/react/NoteStore.ts new file mode 100644 index 000000000..6f34c6d23 --- /dev/null +++ b/apps/client/src/widgets/react/NoteStore.ts @@ -0,0 +1,33 @@ +type Listener = () => void; + +class NoteSavedDataStore { + private data = new Map(); + private listeners = new Map>(); + + get(noteId: string) { + return this.data.get(noteId); + } + + set(noteId: string, value: string) { + this.data.set(noteId, value); + this.listeners.get(noteId)?.forEach(l => l()); + } + + subscribe(noteId: string, listener: Listener) { + let set = this.listeners.get(noteId); + if (!set) { + set = new Set(); + this.listeners.set(noteId, set); + } + set.add(listener); + + return () => { + set!.delete(listener); + if (set!.size === 0) { + this.listeners.delete(noteId); + } + }; + } +} + +export const noteSavedDataStore = new NoteSavedDataStore(); diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index e43ee4c8d..0a9177209 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -2,7 +2,7 @@ import { FilterLabelsByType, KeyboardActionNames, OptionNames, RelationNames } f import { Tooltip } from "bootstrap"; import Mark from "mark.js"; import { RefObject, VNode } from "preact"; -import { CSSProperties } from "preact/compat"; +import { CSSProperties, useSyncExternalStore } from "preact/compat"; import { MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; import appContext, { EventData, EventNames } from "../../components/app_context"; @@ -25,6 +25,7 @@ import utils, { escapeRegExp, randomString, reloadFrontendApp } from "../../serv import BasicWidget, { ReactWrappedWidget } from "../basic_widget"; import NoteContextAwareWidget from "../note_context_aware_widget"; import { DragData } from "../note_tree"; +import { noteSavedDataStore } from "./NoteStore"; import { NoteContextContext, ParentComponent, refToJQuerySelector } from "./react_utils"; export function useTriliumEvent(eventName: T, handler: (data: EventData) => void) { @@ -112,6 +113,7 @@ export function useEditorSpacedUpdate({ note, noteContext, getData, onContentCha protected_session_holder.touchProtectedSessionIfNecessary(note); await server.put(`notes/${note.noteId}/data`, data, parentComponent?.componentId); + noteSavedDataStore.set(note.noteId, data.content); dataSaved?.(data); }; }, [ note, getData, dataSaved ]); @@ -120,6 +122,7 @@ export function useEditorSpacedUpdate({ note, noteContext, getData, onContentCha // React to note/blob changes. useEffect(() => { if (!blob) return; + noteSavedDataStore.set(note.noteId, blob.content); spacedUpdate.allowUpdateWithoutChange(() => onContentChange(blob.content)); }, [ blob ]); @@ -151,6 +154,14 @@ export function useEditorSpacedUpdate({ note, noteContext, getData, onContentCha return spacedUpdate; } +export function useNoteSavedData(noteId: string | undefined) { + return useSyncExternalStore( + (cb) => noteId ? noteSavedDataStore.subscribe(noteId, cb) : () => {}, + () => noteId ? noteSavedDataStore.get(noteId) : undefined + ); +} + + /** * Allows a React component to read and write a Trilium option, while also watching for external changes. * diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index 34ec8360c..7b838c518 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -6,11 +6,13 @@ import { useEffect } from "preact/hooks"; import options from "../../services/options"; import { DEFAULT_GUTTER_SIZE } from "../../services/resizer"; +import { useActiveNoteContext } from "../react/hooks"; import TableOfContents from "./TableOfContents"; const MIN_WIDTH_PERCENT = 5; export default function RightPanelContainer() { + const { note } = useActiveNoteContext(); useEffect(() => { // We are intentionally omitting useTriliumOption to avoid re-render due to size change. const rightPaneWidth = Math.max(MIN_WIDTH_PERCENT, options.getInt("rightPaneWidth") ?? MIN_WIDTH_PERCENT); @@ -26,7 +28,9 @@ export default function RightPanelContainer() { return (
- + {note && <> + + }
); } diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index 23e08506d..01135d440 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -1,11 +1,14 @@ +import FNote from "../../entities/fnote"; import { t } from "../../services/i18n"; +import { useNoteSavedData } from "../react/hooks"; import RightPanelWidget from "./RightPanelWidget"; -export default function TableOfContents() { +export default function TableOfContents({ note }: { note: FNote }) { + const content = useNoteSavedData(note.noteId); return ( - Toc is here. + {content?.length} ); From 3a46a9fbc3e4885ecb72ab83d300c4e443c317f1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 00:16:03 +0200 Subject: [PATCH 06/74] chore(toc): attempt to read using CKEditor API --- .../src/widgets/sidebar/TableOfContents.tsx | 55 +++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index 01135d440..2fc3db61e 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -1,15 +1,62 @@ +import { CKTextEditor, ModelElement } from "@triliumnext/ckeditor5"; +import { useEffect, useState } from "preact/hooks"; + import FNote from "../../entities/fnote"; import { t } from "../../services/i18n"; -import { useNoteSavedData } from "../react/hooks"; +import { useActiveNoteContext, useNoteSavedData, useTriliumEvent } from "../react/hooks"; import RightPanelWidget from "./RightPanelWidget"; -export default function TableOfContents({ note }: { note: FNote }) { - const content = useNoteSavedData(note.noteId); +interface CKHeading { + level: number; + text: string; + element: ModelElement; +} + +export default function TableOfContents() { + const { ntxId } = useActiveNoteContext(); + const [ textEditor, setTextEditor ] = useState(null); + const [ headings, setHeadings ] = useState([]); + + // Populate the cache with the toolbar of every note context. + useTriliumEvent("textEditorRefreshed", ({ ntxId: eventNtxId, editor }) => { + if (eventNtxId !== ntxId) return; + setTextEditor(editor); + }); + + useEffect(() => { + if (!textEditor) return; + const headings = extractTocFromTextEditor(textEditor); + setHeadings(headings); + }, [ textEditor ]); + + console.log("Render with ", headings); return ( - {content?.length} + {headings.map(heading => ( +
  • {heading.text}
  • + ))}
    ); } + +function extractTocFromTextEditor(editor: CKTextEditor) { + const headings: CKHeading[] = []; + + const root = editor.model.document.getRoot(); + if (!root) return []; + + for (const { type, item } of editor.model.createRangeIn(root).getWalker()) { + if (type !== "elementStart" || !item.is('element') || !item.name.startsWith('heading')) continue; + + const level = Number(item.name.replace( 'heading', '' )); + const text = Array.from( item.getChildren() ) + .map( c => c.is( '$text' ) ? c.data : '' ) + .join( '' ); + + headings.push({ level, text, element: item }); + } + + return headings; +} From 094f77b1afc1062f79f927a52638aa8857f1797a Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 00:38:40 +0200 Subject: [PATCH 07/74] chore(toc): react to changes --- .../src/widgets/sidebar/TableOfContents.tsx | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index 2fc3db61e..16c4102bf 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -1,9 +1,8 @@ import { CKTextEditor, ModelElement } from "@triliumnext/ckeditor5"; import { useEffect, useState } from "preact/hooks"; -import FNote from "../../entities/fnote"; import { t } from "../../services/i18n"; -import { useActiveNoteContext, useNoteSavedData, useTriliumEvent } from "../react/hooks"; +import { useActiveNoteContext, useTriliumEvent } from "../react/hooks"; import RightPanelWidget from "./RightPanelWidget"; interface CKHeading { @@ -26,10 +25,26 @@ export default function TableOfContents() { useEffect(() => { if (!textEditor) return; const headings = extractTocFromTextEditor(textEditor); - setHeadings(headings); - }, [ textEditor ]); - console.log("Render with ", headings); + // React to changes. + const changeCallback = () => { + const changes = textEditor.model.document.differ.getChanges(); + + const affectsHeadings = changes.some( change => { + return ( + change.type === 'insert' || change.type === 'remove' || (change.type === 'attribute' && change.attributeKey === 'headingLevel') + ); + }); + if (affectsHeadings) { + setHeadings(extractTocFromTextEditor(textEditor)); + } + }; + + textEditor.model.document.on("change:data", changeCallback); + setHeadings(headings); + + return () => textEditor.model.document.off("change:data", changeCallback); + }, [ textEditor ]); return ( From 97a3e439d290c5666cc375e8e0b90a9bafa68a41 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 09:58:10 +0200 Subject: [PATCH 08/74] refactor(toc): decouple CKEditor TOC --- .../src/widgets/sidebar/TableOfContents.tsx | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index 16c4102bf..1bbdb225e 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -2,7 +2,7 @@ import { CKTextEditor, ModelElement } from "@triliumnext/ckeditor5"; import { useEffect, useState } from "preact/hooks"; import { t } from "../../services/i18n"; -import { useActiveNoteContext, useTriliumEvent } from "../react/hooks"; +import { useActiveNoteContext, useIsNoteReadOnly, useNoteProperty, useTriliumEvent } from "../react/hooks"; import RightPanelWidget from "./RightPanelWidget"; interface CKHeading { @@ -12,11 +12,22 @@ interface CKHeading { } export default function TableOfContents() { + const { note, noteContext } = useActiveNoteContext(); + const noteType = useNoteProperty(note, "type"); + const { isReadOnly } = useIsNoteReadOnly(note, noteContext); + + return ( + + {noteType === "text" && !isReadOnly && } + + ); +} + +function EditableTextTableOfContents() { const { ntxId } = useActiveNoteContext(); const [ textEditor, setTextEditor ] = useState(null); const [ headings, setHeadings ] = useState([]); - // Populate the cache with the toolbar of every note context. useTriliumEvent("textEditorRefreshed", ({ ntxId: eventNtxId, editor }) => { if (eventNtxId !== ntxId) return; setTextEditor(editor); @@ -46,14 +57,18 @@ export default function TableOfContents() { return () => textEditor.model.document.off("change:data", changeCallback); }, [ textEditor ]); - return ( - - {headings.map(heading => ( -
  • {heading.text}
  • - ))} -
    - ); + return ; +} +function AbstractTableOfContents({ headings }: { + headings: { + level: number; + text: string; + }[]; +}) { + return headings.map(heading => ( +
  • {heading.text}
  • + )); } function extractTocFromTextEditor(editor: CKTextEditor) { From 60342c0f6f6c290106f1ca48292bf871b21bb2d1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 10:08:10 +0200 Subject: [PATCH 09/74] fix(toc): not working on note switch --- apps/client/src/widgets/react/hooks.tsx | 30 +++++++++++++++++++ .../src/widgets/sidebar/TableOfContents.tsx | 15 ++++------ 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 0a9177209..b3bd6cf29 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -1,3 +1,4 @@ +import { CKTextEditor } from "@triliumnext/ckeditor5"; import { FilterLabelsByType, KeyboardActionNames, OptionNames, RelationNames } from "@triliumnext/commons"; import { Tooltip } from "bootstrap"; import Mark from "mark.js"; @@ -25,6 +26,7 @@ import utils, { escapeRegExp, randomString, reloadFrontendApp } from "../../serv import BasicWidget, { ReactWrappedWidget } from "../basic_widget"; import NoteContextAwareWidget from "../note_context_aware_widget"; import { DragData } from "../note_tree"; +import CKEditor from "./CKEditor"; import { noteSavedDataStore } from "./NoteStore"; import { NoteContextContext, ParentComponent, refToJQuerySelector } from "./react_utils"; @@ -1085,3 +1087,31 @@ export function useNoteColorClass(note: FNote | null | undefined) { }, [ color, note ]); return colorClass; } + +export function useTextEditor(noteContext: NoteContext | null | undefined) { + const [ textEditor, setTextEditor ] = useState(null); + const requestIdRef = useRef(0); + + // React to note context change and initial state. + useEffect(() => { + if (!noteContext) { + setTextEditor(null); + return; + } + + const requestId = ++requestIdRef.current; + noteContext.getTextEditor((textEditor) => { + // Prevent stale async. + if (requestId !== requestIdRef.current) return; + setTextEditor(textEditor); + }); + }, [ noteContext ]); + + // React to editor initializing. + useTriliumEvent("textEditorRefreshed", ({ ntxId: eventNtxId, editor }) => { + if (eventNtxId !== noteContext?.ntxId) return; + setTextEditor(editor); + }); + + return textEditor; +} diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index 1bbdb225e..14378248b 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -2,7 +2,7 @@ import { CKTextEditor, ModelElement } from "@triliumnext/ckeditor5"; import { useEffect, useState } from "preact/hooks"; import { t } from "../../services/i18n"; -import { useActiveNoteContext, useIsNoteReadOnly, useNoteProperty, useTriliumEvent } from "../react/hooks"; +import { useActiveNoteContext, useIsNoteReadOnly, useNoteProperty, useTextEditor, useTriliumEvent } from "../react/hooks"; import RightPanelWidget from "./RightPanelWidget"; interface CKHeading { @@ -24,18 +24,13 @@ export default function TableOfContents() { } function EditableTextTableOfContents() { - const { ntxId } = useActiveNoteContext(); - const [ textEditor, setTextEditor ] = useState(null); + const { note, noteContext } = useActiveNoteContext(); + const textEditor = useTextEditor(noteContext); const [ headings, setHeadings ] = useState([]); - useTriliumEvent("textEditorRefreshed", ({ ntxId: eventNtxId, editor }) => { - if (eventNtxId !== ntxId) return; - setTextEditor(editor); - }); - useEffect(() => { if (!textEditor) return; - const headings = extractTocFromTextEditor(textEditor); + const headings = extractTocFromTextEditor(textEditor); // React to changes. const changeCallback = () => { @@ -55,7 +50,7 @@ function EditableTextTableOfContents() { setHeadings(headings); return () => textEditor.model.document.off("change:data", changeCallback); - }, [ textEditor ]); + }, [ textEditor, note ]); return ; } From 87a98201b4ba0b699cba70466c55ce23cc61e13b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 10:28:35 +0200 Subject: [PATCH 10/74] chore(toc): reintroduce hierarchy --- .../src/widgets/sidebar/TableOfContents.tsx | 58 ++++++++++++++++--- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index 14378248b..b4391b678 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -2,15 +2,22 @@ import { CKTextEditor, ModelElement } from "@triliumnext/ckeditor5"; import { useEffect, useState } from "preact/hooks"; import { t } from "../../services/i18n"; -import { useActiveNoteContext, useIsNoteReadOnly, useNoteProperty, useTextEditor, useTriliumEvent } from "../react/hooks"; +import { useActiveNoteContext, useIsNoteReadOnly, useNoteProperty, useTextEditor } from "../react/hooks"; import RightPanelWidget from "./RightPanelWidget"; -interface CKHeading { +interface RawHeading { level: number; text: string; +} + +interface CKHeading extends RawHeading { element: ModelElement; } +interface HeadingsWithNesting extends RawHeading { + children: HeadingsWithNesting[]; +} + export default function TableOfContents() { const { note, noteContext } = useActiveNoteContext(); const noteType = useNoteProperty(note, "type"); @@ -56,16 +63,48 @@ function EditableTextTableOfContents() { } function AbstractTableOfContents({ headings }: { - headings: { - level: number; - text: string; - }[]; + headings: RawHeading[]; }) { - return headings.map(heading => ( -
  • {heading.text}
  • - )); + const nestedHeadings = buildHeadingTree(headings); + return nestedHeadings.map(heading => ); } +function TableOfContentsHeading({ heading }: { heading: HeadingsWithNesting }) { + return ( +
  • + {heading.text} + {heading.children && ( +
      + {heading.children.map(heading => )} +
    + )} +
  • + ); +} + +function buildHeadingTree(headings: RawHeading[]): HeadingsWithNesting[] { + const root: HeadingsWithNesting = { level: 0, text: "", children: [] }; + const stack: HeadingsWithNesting[] = [root]; + + for (const h of headings) { + const node: HeadingsWithNesting = { ...h, children: [] }; + + // Pop until we find a parent with lower level + while (stack.length > 1 && stack[stack.length - 1].level >= h.level) { + stack.pop(); + } + + // Attach to current parent + stack[stack.length - 1].children.push(node); + + // This node becomes the new parent + stack.push(node); + } + + return root.children; +} + + function extractTocFromTextEditor(editor: CKTextEditor) { const headings: CKHeading[] = []; @@ -85,3 +124,4 @@ function extractTocFromTextEditor(editor: CKTextEditor) { return headings; } + From 73f1b91d341d71945d3f96a94611aab35c91c671 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 10:49:33 +0200 Subject: [PATCH 11/74] chore(toc): reintroduce basic collapse support --- apps/client/src/widgets/react/Icon.tsx | 16 +++- .../src/widgets/sidebar/TableOfContents.css | 82 +++++++++++++++++++ .../src/widgets/sidebar/TableOfContents.tsx | 32 ++++++-- 3 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 apps/client/src/widgets/sidebar/TableOfContents.css diff --git a/apps/client/src/widgets/react/Icon.tsx b/apps/client/src/widgets/react/Icon.tsx index e047a1762..6d17dc3b9 100644 --- a/apps/client/src/widgets/react/Icon.tsx +++ b/apps/client/src/widgets/react/Icon.tsx @@ -1,8 +1,16 @@ -interface IconProps { +import clsx from "clsx"; +import { HTMLAttributes } from "preact"; + +interface IconProps extends Pick, "className" | "onClick"> { icon?: string; className?: string; } -export default function Icon({ icon, className }: IconProps) { - return -} \ No newline at end of file +export default function Icon({ icon, className, ...restProps }: IconProps) { + return ( + + ); +} diff --git a/apps/client/src/widgets/sidebar/TableOfContents.css b/apps/client/src/widgets/sidebar/TableOfContents.css new file mode 100644 index 000000000..79c6ee548 --- /dev/null +++ b/apps/client/src/widgets/sidebar/TableOfContents.css @@ -0,0 +1,82 @@ +.toc ol { + position: relative; + overflow: hidden; + padding-inline-start: 0px; + transition: max-height 0.3s ease; +} + +.toc li.collapsed + ol { + display:none; +} + +.toc li + ol:before { + content: ""; + position: absolute; + height: 100%; + border-inline-start: 1px solid var(--main-border-color); + z-index: 10; +} + +.toc li { + display: flex; + position: relative; + list-style: none; + align-items: center; + padding-inline-start: 7px; + cursor: pointer; + text-align: justify; + word-wrap: break-word; + hyphens: auto; +} + +.toc > ol { + --toc-depth-level: 1; +} +.toc > ol > ol { + --toc-depth-level: 2; +} +.toc > ol > ol > ol { + --toc-depth-level: 3; +} +.toc > ol > ol > ol > ol { + --toc-depth-level: 4; +} +.toc > ol > ol > ol > ol > ol { + --toc-depth-level: 5; +} + +.toc > ol ol::before { + inset-inline-start: calc((var(--toc-depth-level) - 2) * 20px + 14px); +} + +.toc li { + padding-inline-start: calc((var(--toc-depth-level) - 1) * 20px + 4px); +} + +.toc li .collapse-button { + display: flex; + position: relative; + width: 21px; + height: 21px; + flex-shrink: 0; + align-items: center; + justify-content: center; + transition: transform 0.3s ease; +} + +.toc li.collapsed .collapse-button { + transform: rotate(-90deg); +} + +.toc li .item-content { + margin-inline-start: 25px; + flex: 1; +} + +.toc li .collapse-button + .item-content { + margin-inline-start: 4px; +} + +.toc li:hover { + font-weight: bold; +} diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index b4391b678..8ed728c26 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -1,8 +1,12 @@ +import "./TableOfContents.css"; + import { CKTextEditor, ModelElement } from "@triliumnext/ckeditor5"; +import clsx from "clsx"; import { useEffect, useState } from "preact/hooks"; import { t } from "../../services/i18n"; import { useActiveNoteContext, useIsNoteReadOnly, useNoteProperty, useTextEditor } from "../react/hooks"; +import Icon from "../react/Icon"; import RightPanelWidget from "./RightPanelWidget"; interface RawHeading { @@ -66,19 +70,35 @@ function AbstractTableOfContents({ headings }: { headings: RawHeading[]; }) { const nestedHeadings = buildHeadingTree(headings); - return nestedHeadings.map(heading => ); + return ( + +
      + {nestedHeadings.map(heading => )} +
    +
    + ); } function TableOfContentsHeading({ heading }: { heading: HeadingsWithNesting }) { + const [ collapsed, setCollapsed ] = useState(false); return ( -
  • - {heading.text} + <> +
  • + {heading.children.length > 0 && ( + setCollapsed(!collapsed)} + /> + )} + {heading.text} +
  • {heading.children && ( -
      +
        {heading.children.map(heading => )} -
    + )} - + ); } From 852398426ecb8b15e73306c5a4909de83d9dca14 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 10:56:44 +0200 Subject: [PATCH 12/74] chore(toc): add unique keys to headings --- .../src/widgets/sidebar/TableOfContents.tsx | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index 8ed728c26..3d63ef0df 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -10,6 +10,7 @@ import Icon from "../react/Icon"; import RightPanelWidget from "./RightPanelWidget"; interface RawHeading { + id: string; level: number; text: string; } @@ -73,7 +74,7 @@ function AbstractTableOfContents({ headings }: { return (
      - {nestedHeadings.map(heading => )} + {nestedHeadings.map(heading => )}
    ); @@ -95,7 +96,7 @@ function TableOfContentsHeading({ heading }: { heading: HeadingsWithNesting }) { {heading.children && (
      - {heading.children.map(heading => )} + {heading.children.map(heading => )}
    )} @@ -103,7 +104,7 @@ function TableOfContentsHeading({ heading }: { heading: HeadingsWithNesting }) { } function buildHeadingTree(headings: RawHeading[]): HeadingsWithNesting[] { - const root: HeadingsWithNesting = { level: 0, text: "", children: [] }; + const root: HeadingsWithNesting = { level: 0, text: "", children: [], id: "_root" }; const stack: HeadingsWithNesting[] = [root]; for (const h of headings) { @@ -124,6 +125,7 @@ function buildHeadingTree(headings: RawHeading[]): HeadingsWithNesting[] { return root.children; } +const TOC_ID = 'tocId'; function extractTocFromTextEditor(editor: CKTextEditor) { const headings: CKHeading[] = []; @@ -131,16 +133,25 @@ function extractTocFromTextEditor(editor: CKTextEditor) { const root = editor.model.document.getRoot(); if (!root) return []; - for (const { type, item } of editor.model.createRangeIn(root).getWalker()) { - if (type !== "elementStart" || !item.is('element') || !item.name.startsWith('heading')) continue; + editor.model.change(writer => { + for (const { type, item } of editor.model.createRangeIn(root).getWalker()) { + if (type !== "elementStart" || !item.is('element') || !item.name.startsWith('heading')) continue; - const level = Number(item.name.replace( 'heading', '' )); - const text = Array.from( item.getChildren() ) - .map( c => c.is( '$text' ) ? c.data : '' ) - .join( '' ); + const level = Number(item.name.replace( 'heading', '' )); + const text = Array.from( item.getChildren() ) + .map( c => c.is( '$text' ) ? c.data : '' ) + .join( '' ); - headings.push({ level, text, element: item }); - } + // Assign a unique ID + let tocId = item.getAttribute(TOC_ID) as string | undefined; + if (!tocId) { + tocId = crypto.randomUUID(); + writer.setAttribute(TOC_ID, tocId, item); + } + + headings.push({ level, text, element: item, id: tocId }); + } + }); return headings; } From 41751c205caa8e6110e16a652b2a2a7ec8da891c Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 10:58:25 +0200 Subject: [PATCH 13/74] refactor(toc): reorder according to purpose --- .../src/widgets/sidebar/TableOfContents.tsx | 77 ++++++++++--------- 1 file changed, 40 insertions(+), 37 deletions(-) diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index 3d63ef0df..55178811c 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -9,16 +9,13 @@ import { useActiveNoteContext, useIsNoteReadOnly, useNoteProperty, useTextEditor import Icon from "../react/Icon"; import RightPanelWidget from "./RightPanelWidget"; +//#region Generic impl. interface RawHeading { id: string; level: number; text: string; } -interface CKHeading extends RawHeading { - element: ModelElement; -} - interface HeadingsWithNesting extends RawHeading { children: HeadingsWithNesting[]; } @@ -35,38 +32,6 @@ export default function TableOfContents() { ); } -function EditableTextTableOfContents() { - const { note, noteContext } = useActiveNoteContext(); - const textEditor = useTextEditor(noteContext); - const [ headings, setHeadings ] = useState([]); - - useEffect(() => { - if (!textEditor) return; - const headings = extractTocFromTextEditor(textEditor); - - // React to changes. - const changeCallback = () => { - const changes = textEditor.model.document.differ.getChanges(); - - const affectsHeadings = changes.some( change => { - return ( - change.type === 'insert' || change.type === 'remove' || (change.type === 'attribute' && change.attributeKey === 'headingLevel') - ); - }); - if (affectsHeadings) { - setHeadings(extractTocFromTextEditor(textEditor)); - } - }; - - textEditor.model.document.on("change:data", changeCallback); - setHeadings(headings); - - return () => textEditor.model.document.off("change:data", changeCallback); - }, [ textEditor, note ]); - - return ; -} - function AbstractTableOfContents({ headings }: { headings: RawHeading[]; }) { @@ -124,9 +89,47 @@ function buildHeadingTree(headings: RawHeading[]): HeadingsWithNesting[] { return root.children; } +//#endregion +//#region Editable text (CKEditor) const TOC_ID = 'tocId'; +interface CKHeading extends RawHeading { + element: ModelElement; +} + +function EditableTextTableOfContents() { + const { note, noteContext } = useActiveNoteContext(); + const textEditor = useTextEditor(noteContext); + const [ headings, setHeadings ] = useState([]); + + useEffect(() => { + if (!textEditor) return; + const headings = extractTocFromTextEditor(textEditor); + + // React to changes. + const changeCallback = () => { + const changes = textEditor.model.document.differ.getChanges(); + + const affectsHeadings = changes.some( change => { + return ( + change.type === 'insert' || change.type === 'remove' || (change.type === 'attribute' && change.attributeKey === 'headingLevel') + ); + }); + if (affectsHeadings) { + setHeadings(extractTocFromTextEditor(textEditor)); + } + }; + + textEditor.model.document.on("change:data", changeCallback); + setHeadings(headings); + + return () => textEditor.model.document.off("change:data", changeCallback); + }, [ textEditor, note ]); + + return ; +} + function extractTocFromTextEditor(editor: CKTextEditor) { const headings: CKHeading[] = []; @@ -155,4 +158,4 @@ function extractTocFromTextEditor(editor: CKTextEditor) { return headings; } - +//#endregion From b93c80fe7bbc4272d0391875912daa00d0d446ff Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 11:12:27 +0200 Subject: [PATCH 14/74] feat(toc): basic support for read-only text --- apps/client/src/components/note_context.ts | 2 +- apps/client/src/widgets/react/hooks.tsx | 16 ++++++++++ .../src/widgets/sidebar/TableOfContents.tsx | 29 +++++++++++++++++-- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/apps/client/src/components/note_context.ts b/apps/client/src/components/note_context.ts index 15791c741..5295007a7 100644 --- a/apps/client/src/components/note_context.ts +++ b/apps/client/src/components/note_context.ts @@ -390,7 +390,7 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded"> * If no content could be determined `null` is returned instead. */ async getContentElement() { - return this.timeout>( + return this.timeout | null>( new Promise((resolve) => appContext.triggerCommand("executeWithContentElement", { resolve, diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index b3bd6cf29..5ec68a42d 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -1115,3 +1115,19 @@ export function useTextEditor(noteContext: NoteContext | null | undefined) { return textEditor; } + +export function useContentElement(noteContext: NoteContext | null | undefined) { + const [ contentElement, setContentElement ] = useState(null); + const requestIdRef = useRef(0); + + useEffect(() => { + const requestId = ++requestIdRef.current; + noteContext?.getContentElement().then(contentElement => { + // Prevent stale async. + if (requestId !== requestIdRef.current) return; + setContentElement(contentElement?.[0] ?? null); + }); + }, [ noteContext ]); + + return contentElement; +} diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index 55178811c..64249bd27 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -5,7 +5,7 @@ import clsx from "clsx"; import { useEffect, useState } from "preact/hooks"; import { t } from "../../services/i18n"; -import { useActiveNoteContext, useIsNoteReadOnly, useNoteProperty, useTextEditor } from "../react/hooks"; +import { useActiveNoteContext, useContentElement, useIsNoteReadOnly, useNoteProperty, useTextEditor } from "../react/hooks"; import Icon from "../react/Icon"; import RightPanelWidget from "./RightPanelWidget"; @@ -27,7 +27,9 @@ export default function TableOfContents() { return ( - {noteType === "text" && !isReadOnly && } + {noteType === "text" && ( + isReadOnly ? : + )} ); } @@ -159,3 +161,26 @@ function extractTocFromTextEditor(editor: CKTextEditor) { return headings; } //#endregion + +function ReadOnlyTextTableOfContents() { + const { noteContext } = useActiveNoteContext(); + const contentEl = useContentElement(noteContext); + const headings = extractTocFromStaticHtml(contentEl); + + return ; +} + +function extractTocFromStaticHtml(el: HTMLElement | null) { + if (!el) return []; + + const headings: RawHeading[] = []; + for (const headingEl of el.querySelectorAll("h1,h2,h3,h4,h5,h6")) { + headings.push({ + id: crypto.randomUUID(), + level: parseInt(headingEl.tagName.substring(1), 10), + text: headingEl.textContent + }); + } + + return headings; +} From 704dcd011e6096df241cdacfc6cff3c6cd16c0f2 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 11:46:13 +0200 Subject: [PATCH 15/74] feat(toc): basic support for docs --- apps/client/src/components/app_context.ts | 74 +++++++++---------- apps/client/src/widgets/react/hooks.tsx | 9 +++ .../src/widgets/sidebar/TableOfContents.tsx | 5 +- apps/client/src/widgets/type_widgets/Doc.tsx | 16 ++-- 4 files changed, 58 insertions(+), 46 deletions(-) diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index b5ad30003..e0b8c651b 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -1,40 +1,41 @@ -import froca from "../services/froca.js"; -import RootCommandExecutor from "./root_command_executor.js"; -import Entrypoints from "./entrypoints.js"; -import options from "../services/options.js"; -import utils, { hasTouchBar } from "../services/utils.js"; -import zoomComponent from "./zoom.js"; -import TabManager from "./tab_manager.js"; -import Component from "./component.js"; -import keyboardActionsService from "../services/keyboard_actions.js"; -import linkService, { type ViewScope } from "../services/link.js"; -import MobileScreenSwitcherExecutor, { type Screen } from "./mobile_screen_switcher.js"; -import MainTreeExecutors from "./main_tree_executors.js"; -import toast from "../services/toast.js"; -import ShortcutComponent from "./shortcut_component.js"; -import { t, initLocale } from "../services/i18n.js"; -import type { ResolveOptions } from "../widgets/dialogs/delete_notes.js"; -import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js"; -import type { ConfirmWithMessageOptions, ConfirmWithTitleOptions } from "../widgets/dialogs/confirm.js"; -import type LoadResults from "../services/load_results.js"; -import type { Attribute } from "../services/attribute_parser.js"; -import type NoteTreeWidget from "../widgets/note_tree.js"; -import type { default as NoteContext, GetTextEditorCallback } from "./note_context.js"; -import type { NativeImage, TouchBar } from "electron"; -import TouchBarComponent from "./touch_bar.js"; import type { CKTextEditor } from "@triliumnext/ckeditor5"; import type CodeMirror from "@triliumnext/codemirror"; -import { StartupChecks } from "./startup_checks.js"; -import type { CreateNoteOpts } from "../services/note_create.js"; -import { ColumnComponent } from "tabulator-tables"; -import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx"; -import type RootContainer from "../widgets/containers/root_container.js"; import { SqlExecuteResults } from "@triliumnext/commons"; -import { AddLinkOpts } from "../widgets/dialogs/add_link.jsx"; -import { IncludeNoteOpts } from "../widgets/dialogs/include_note.jsx"; +import type { NativeImage, TouchBar } from "electron"; +import { ColumnComponent } from "tabulator-tables"; + +import type { Attribute } from "../services/attribute_parser.js"; +import froca from "../services/froca.js"; +import { initLocale,t } from "../services/i18n.js"; +import keyboardActionsService from "../services/keyboard_actions.js"; +import linkService, { type ViewScope } from "../services/link.js"; +import type LoadResults from "../services/load_results.js"; +import type { CreateNoteOpts } from "../services/note_create.js"; +import options from "../services/options.js"; +import toast from "../services/toast.js"; +import utils, { hasTouchBar } from "../services/utils.js"; import { ReactWrappedWidget } from "../widgets/basic_widget.js"; -import type { MarkdownImportOpts } from "../widgets/dialogs/markdown_import.jsx"; +import type RootContainer from "../widgets/containers/root_container.js"; +import { AddLinkOpts } from "../widgets/dialogs/add_link.jsx"; +import type { ConfirmWithMessageOptions, ConfirmWithTitleOptions } from "../widgets/dialogs/confirm.js"; +import type { ResolveOptions } from "../widgets/dialogs/delete_notes.js"; +import { IncludeNoteOpts } from "../widgets/dialogs/include_note.jsx"; import type { InfoProps } from "../widgets/dialogs/info.jsx"; +import type { MarkdownImportOpts } from "../widgets/dialogs/markdown_import.jsx"; +import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx"; +import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js"; +import type NoteTreeWidget from "../widgets/note_tree.js"; +import Component from "./component.js"; +import Entrypoints from "./entrypoints.js"; +import MainTreeExecutors from "./main_tree_executors.js"; +import MobileScreenSwitcherExecutor, { type Screen } from "./mobile_screen_switcher.js"; +import type { default as NoteContext, GetTextEditorCallback } from "./note_context.js"; +import RootCommandExecutor from "./root_command_executor.js"; +import ShortcutComponent from "./shortcut_component.js"; +import { StartupChecks } from "./startup_checks.js"; +import TabManager from "./tab_manager.js"; +import TouchBarComponent from "./touch_bar.js"; +import zoomComponent from "./zoom.js"; interface Layout { getRootWidget: (appContext: AppContext) => RootContainer; @@ -447,6 +448,7 @@ type EventMappings = { }; searchRefreshed: { ntxId?: string | null }; textEditorRefreshed: { ntxId?: string | null, editor: CKTextEditor }; + contentElRefreshed: { ntxId?: string | null, contentEl: HTMLElement }; hoistedNoteChanged: { noteId: string; ntxId: string | null; @@ -695,10 +697,8 @@ $(window).on("beforeunload", () => { console.log(`Component ${component.componentId} is not finished saving its state.`); allSaved = false; } - } else { - if (!listener()) { - allSaved = false; - } + } else if (!listener()) { + allSaved = false; } } @@ -708,7 +708,7 @@ $(window).on("beforeunload", () => { } }); -$(window).on("hashchange", function () { +$(window).on("hashchange", () => { const { notePath, ntxId, viewScope, searchString } = linkService.parseNavigationStateFromUrl(window.location.href); if (notePath || ntxId) { diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 5ec68a42d..73fbfc101 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -1119,6 +1119,7 @@ export function useTextEditor(noteContext: NoteContext | null | undefined) { export function useContentElement(noteContext: NoteContext | null | undefined) { const [ contentElement, setContentElement ] = useState(null); const requestIdRef = useRef(0); + const [, forceUpdate] = useState(0); useEffect(() => { const requestId = ++requestIdRef.current; @@ -1126,8 +1127,16 @@ export function useContentElement(noteContext: NoteContext | null | undefined) { // Prevent stale async. if (requestId !== requestIdRef.current) return; setContentElement(contentElement?.[0] ?? null); + forceUpdate(v => v + 1); }); }, [ noteContext ]); + // React to content changes initializing. + useTriliumEvent("contentElRefreshed", ({ ntxId: eventNtxId, contentEl }) => { + if (eventNtxId !== noteContext?.ntxId) return; + setContentElement(contentEl); + forceUpdate(v => v + 1); + }); + return contentElement; } diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index 64249bd27..696e37e56 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -27,9 +27,8 @@ export default function TableOfContents() { return ( - {noteType === "text" && ( - isReadOnly ? : - )} + {((noteType === "text" && isReadOnly) || (noteType === "doc")) && } + {noteType === "text" && !isReadOnly && } ); } diff --git a/apps/client/src/widgets/type_widgets/Doc.tsx b/apps/client/src/widgets/type_widgets/Doc.tsx index 5c7a31890..93929c7ba 100644 --- a/apps/client/src/widgets/type_widgets/Doc.tsx +++ b/apps/client/src/widgets/type_widgets/Doc.tsx @@ -1,10 +1,12 @@ -import { useEffect, useRef, useState } from "preact/hooks"; -import { RawHtmlBlock } from "../react/RawHtml"; -import renderDoc from "../../services/doc_renderer"; import "./Doc.css"; -import { TypeWidgetProps } from "./type_widget"; + +import { useEffect, useRef } from "preact/hooks"; + +import appContext from "../../components/app_context"; +import renderDoc from "../../services/doc_renderer"; import { useTriliumEvent } from "../react/hooks"; import { refToJQuerySelector } from "../react/react_utils"; +import { TypeWidgetProps } from "./type_widget"; export default function Doc({ note, viewScope, ntxId }: TypeWidgetProps) { const initialized = useRef | null>(null); @@ -14,9 +16,11 @@ export default function Doc({ note, viewScope, ntxId }: TypeWidgetProps) { if (!note) return; initialized.current = renderDoc(note).then($content => { - containerRef.current?.replaceChildren(...$content); + if (!containerRef.current) return; + containerRef.current.replaceChildren(...$content); + appContext.triggerEvent("contentElRefreshed", { ntxId, contentEl: containerRef.current }); }); - }, [ note ]); + }, [ note, ntxId ]); useTriliumEvent("executeWithContentElement", async ({ resolve, ntxId: eventNtxId}) => { if (eventNtxId !== ntxId) return; From 96ccb1e67e90b45d320a8fbe89881cc1797223ae Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 11:46:21 +0200 Subject: [PATCH 16/74] fix(toc): sometimes not reacting to read-only note switching --- .../type_widgets/text/ReadOnlyText.tsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/apps/client/src/widgets/type_widgets/text/ReadOnlyText.tsx b/apps/client/src/widgets/type_widgets/text/ReadOnlyText.tsx index e23380030..3352f621a 100644 --- a/apps/client/src/widgets/type_widgets/text/ReadOnlyText.tsx +++ b/apps/client/src/widgets/type_widgets/text/ReadOnlyText.tsx @@ -1,23 +1,24 @@ -import { useEffect, useMemo, useRef } from "preact/hooks"; -import { TypeWidgetProps } from "../type_widget"; import "./ReadOnlyText.css"; -import { useNoteBlob, useNoteLabel, useTriliumEvent, useTriliumOptionBool } from "../../react/hooks"; -import { RawHtmlBlock } from "../../react/RawHtml"; - // we load CKEditor also for read only notes because they contain content styles required for correct rendering of even read only notes // we could load just ckeditor-content.css but that causes CSS conflicts when both build CSS and this content CSS is loaded at the same time // (see https://github.com/zadam/trilium/issues/1590 for example of such conflict) import "@triliumnext/ckeditor5"; + +import clsx from "clsx"; +import { useEffect, useMemo, useRef } from "preact/hooks"; + +import appContext from "../../../components/app_context"; import FNote from "../../../entities/fnote"; +import { applyInlineMermaid, rewriteMermaidDiagramsInContainer } from "../../../services/content_renderer_text"; import { getLocaleById } from "../../../services/i18n"; -import { loadIncludedNote, refreshIncludedNote, setupImageOpening } from "./utils"; import { renderMathInElement } from "../../../services/math"; import { formatCodeBlocks } from "../../../services/syntax_highlight"; +import { useNoteBlob, useNoteLabel, useTriliumEvent, useTriliumOptionBool } from "../../react/hooks"; +import { RawHtmlBlock } from "../../react/RawHtml"; import TouchBar, { TouchBarButton, TouchBarSpacer } from "../../react/TouchBar"; -import appContext from "../../../components/app_context"; +import { TypeWidgetProps } from "../type_widget"; import { applyReferenceLinks } from "./read_only_helper"; -import { applyInlineMermaid, rewriteMermaidDiagramsInContainer } from "../../../services/content_renderer_text"; -import clsx from "clsx"; +import { loadIncludedNote, refreshIncludedNote, setupImageOpening } from "./utils"; export default function ReadOnlyText({ note, noteContext, ntxId }: TypeWidgetProps) { const blob = useNoteBlob(note); @@ -30,6 +31,8 @@ export default function ReadOnlyText({ note, noteContext, ntxId }: TypeWidgetPro const container = contentRef.current; if (!container) return; + appContext.triggerEvent("contentElRefreshed", { ntxId, contentEl: container }); + rewriteMermaidDiagramsInContainer(container); applyInlineMermaid(container); applyIncludedNotes(container); @@ -74,7 +77,7 @@ export default function ReadOnlyText({ note, noteContext, ntxId }: TypeWidgetPro /> - ) + ); } function useNoteLanguage(note: FNote) { From bf5c56a61ad3c4da03ece2e9c0ed53a12ecb7c83 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 12:04:42 +0200 Subject: [PATCH 17/74] chore(toc): reintroduce navigation in editable text notes --- .../src/widgets/sidebar/TableOfContents.tsx | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index 696e37e56..b84a138bf 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -2,7 +2,7 @@ import "./TableOfContents.css"; import { CKTextEditor, ModelElement } from "@triliumnext/ckeditor5"; import clsx from "clsx"; -import { useEffect, useState } from "preact/hooks"; +import { useCallback, useEffect, useState } from "preact/hooks"; import { t } from "../../services/i18n"; import { useActiveNoteContext, useContentElement, useIsNoteReadOnly, useNoteProperty, useTextEditor } from "../react/hooks"; @@ -33,20 +33,25 @@ export default function TableOfContents() { ); } -function AbstractTableOfContents({ headings }: { +function AbstractTableOfContents({ headings, scrollToHeading }: { headings: RawHeading[]; + scrollToHeading(heading: RawHeading): void; }) { const nestedHeadings = buildHeadingTree(headings); return (
      - {nestedHeadings.map(heading => )} + {nestedHeadings.map(heading => )}
    ); } -function TableOfContentsHeading({ heading }: { heading: HeadingsWithNesting }) { +function TableOfContentsHeading({ heading, scrollToHeading }: { + heading: HeadingsWithNesting; + scrollToHeading(heading: RawHeading): void; +}) { + console.log("Got ", scrollToHeading); const [ collapsed, setCollapsed ] = useState(false); return ( <> @@ -58,11 +63,14 @@ function TableOfContentsHeading({ heading }: { heading: HeadingsWithNesting }) { onClick={() => setCollapsed(!collapsed)} /> )} - {heading.text} + scrollToHeading(heading)} + >{heading.text} {heading.children && (
      - {heading.children.map(heading => )} + {heading.children.map(heading => )}
    )} @@ -128,7 +136,20 @@ function EditableTextTableOfContents() { return () => textEditor.model.document.off("change:data", changeCallback); }, [ textEditor, note ]); - return ; + const scrollToHeading = useCallback((heading: CKHeading) => { + if (!textEditor) return; + + const viewEl = textEditor.editing.mapper.toViewElement(heading.element); + if (!viewEl) return; + + const domEl = textEditor.editing.view.domConverter.mapViewToDom(viewEl); + domEl?.scrollIntoView(); + }, [ textEditor ]); + + return ; } function extractTocFromTextEditor(editor: CKTextEditor) { From b0e1751dc751de069592d99d748567cf0c064ae4 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 12:09:05 +0200 Subject: [PATCH 18/74] chore(toc): reintroduce navigation in readonly text notes --- .../src/widgets/sidebar/TableOfContents.tsx | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index b84a138bf..b6f7553e5 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -33,9 +33,9 @@ export default function TableOfContents() { ); } -function AbstractTableOfContents({ headings, scrollToHeading }: { - headings: RawHeading[]; - scrollToHeading(heading: RawHeading): void; +function AbstractTableOfContents({ headings, scrollToHeading }: { + headings: T[]; + scrollToHeading(heading: T): void; }) { const nestedHeadings = buildHeadingTree(headings); return ( @@ -51,7 +51,6 @@ function TableOfContentsHeading({ heading, scrollToHeading }: { heading: HeadingsWithNesting; scrollToHeading(heading: RawHeading): void; }) { - console.log("Got ", scrollToHeading); const [ collapsed, setCollapsed ] = useState(false); return ( <> @@ -181,24 +180,35 @@ function extractTocFromTextEditor(editor: CKTextEditor) { return headings; } //#endregion +interface DomHeading extends RawHeading { + element: HTMLHeadingElement; +} function ReadOnlyTextTableOfContents() { const { noteContext } = useActiveNoteContext(); const contentEl = useContentElement(noteContext); const headings = extractTocFromStaticHtml(contentEl); - return ; + const scrollToHeading = useCallback((heading: DomHeading) => { + heading.element.scrollIntoView(); + }, []); + + return ; } function extractTocFromStaticHtml(el: HTMLElement | null) { if (!el) return []; - const headings: RawHeading[] = []; - for (const headingEl of el.querySelectorAll("h1,h2,h3,h4,h5,h6")) { + const headings: DomHeading[] = []; + for (const headingEl of el.querySelectorAll("h1,h2,h3,h4,h5,h6")) { headings.push({ id: crypto.randomUUID(), level: parseInt(headingEl.tagName.substring(1), 10), - text: headingEl.textContent + text: headingEl.textContent, + element: headingEl }); } From dbf29ed23f2ca962ab1ad5cb2502330891479c93 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 12:38:45 +0200 Subject: [PATCH 19/74] chore(highlights_list): start from scratch --- .../src/widgets/sidebar/HighlightsList.tsx | 28 +++++++++++++++++++ .../widgets/sidebar/RightPanelContainer.tsx | 4 ++- 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 apps/client/src/widgets/sidebar/HighlightsList.tsx diff --git a/apps/client/src/widgets/sidebar/HighlightsList.tsx b/apps/client/src/widgets/sidebar/HighlightsList.tsx new file mode 100644 index 000000000..c18f1770b --- /dev/null +++ b/apps/client/src/widgets/sidebar/HighlightsList.tsx @@ -0,0 +1,28 @@ +import { t } from "../../services/i18n"; +import { useActiveNoteContext, useIsNoteReadOnly, useNoteProperty } from "../react/hooks"; +import RightPanelWidget from "./RightPanelWidget"; + +export default function HighlightsList() { + const { note, noteContext } = useActiveNoteContext(); + const noteType = useNoteProperty(note, "type"); + const { isReadOnly } = useIsNoteReadOnly(note, noteContext); + + return ( + + {((noteType === "text" && isReadOnly) || (noteType === "doc")) && } + {noteType === "text" && !isReadOnly && } + + ); +} + +//#region Editable text (CKEditor) +function EditableTextHighlightsList() { + return "Editable"; +} +//#endregion + +//#region Read-only text +function ReadOnlyTextHighlightsList() { + return "Read-only"; +} +//#endregion diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index 7b838c518..b5ad216fd 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -7,6 +7,7 @@ import { useEffect } from "preact/hooks"; import options from "../../services/options"; import { DEFAULT_GUTTER_SIZE } from "../../services/resizer"; import { useActiveNoteContext } from "../react/hooks"; +import HighlightsList from "./HighlightsList"; import TableOfContents from "./TableOfContents"; const MIN_WIDTH_PERCENT = 5; @@ -29,7 +30,8 @@ export default function RightPanelContainer() { return (
    {note && <> - + + }
    ); From 73f2f56932684b7af29b971deb2aadb6a3f673b4 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 12:52:14 +0200 Subject: [PATCH 20/74] chore(highlights_list): read highlights from CK --- .../src/widgets/sidebar/HighlightsList.tsx | 77 ++++++++++++++++++- .../src/widgets/sidebar/TableOfContents.tsx | 2 +- packages/ckeditor5/src/index.ts | 2 +- 3 files changed, 77 insertions(+), 4 deletions(-) diff --git a/apps/client/src/widgets/sidebar/HighlightsList.tsx b/apps/client/src/widgets/sidebar/HighlightsList.tsx index c18f1770b..1755230f8 100644 --- a/apps/client/src/widgets/sidebar/HighlightsList.tsx +++ b/apps/client/src/widgets/sidebar/HighlightsList.tsx @@ -1,7 +1,16 @@ +import { CKTextEditor, ModelTextProxy } from "@triliumnext/ckeditor5"; +import { useEffect, useState } from "preact/hooks"; + import { t } from "../../services/i18n"; -import { useActiveNoteContext, useIsNoteReadOnly, useNoteProperty } from "../react/hooks"; +import { useActiveNoteContext, useIsNoteReadOnly, useNoteProperty, useTextEditor } from "../react/hooks"; import RightPanelWidget from "./RightPanelWidget"; +interface RawHighlight { + id: string; + text: string; + attrs: Record; +} + export default function HighlightsList() { const { note, noteContext } = useActiveNoteContext(); const noteType = useNoteProperty(note, "type"); @@ -15,9 +24,73 @@ export default function HighlightsList() { ); } +function AbstractHighlightsList({ highlights }: { + highlights: RawHighlight[] +}) { + return ( + +
      + {highlights.map(highlight => ( +
    1. + {highlight.text} +
    2. + ))} +
    +
    + ); +} + //#region Editable text (CKEditor) +interface CKHighlight extends RawHighlight { + element: ModelTextProxy; +} + function EditableTextHighlightsList() { - return "Editable"; + const { note, noteContext } = useActiveNoteContext(); + const textEditor = useTextEditor(noteContext); + const [ highlights, setHighlights ] = useState([]); + + useEffect(() => { + if (!textEditor) return; + + const highlights = extractHighlightsFromTextEditor(textEditor); + setHighlights(highlights); + }, [ textEditor, note ]); + + return ; +} + +function extractHighlightsFromTextEditor(editor: CKTextEditor) { + const result: CKHighlight[] = []; + const root = editor.model.document.getRoot(); + if (!root) return []; + + for (const { item } of editor.model.createRangeIn(root).getWalker({ ignoreElementEnd: true })) { + if (!item.is('$textProxy')) continue; + console.log("Got ", item); + + const attrs = { + bold: item.hasAttribute('bold'), + italic: item.hasAttribute('italic'), + underline: item.hasAttribute('underline'), + color: item.getAttribute('fontColor'), + background: item.getAttribute('fontBackgroundColor') + }; + console.log("Got ", attrs); + + if (Object.values(attrs).some(Boolean)) { + result.push({ + id: crypto.randomUUID(), + text: item.data, + attrs, + element: item + }); + } + } + + return result; } //#endregion diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index b6f7553e5..d60d4e8dc 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -114,6 +114,7 @@ function EditableTextTableOfContents() { useEffect(() => { if (!textEditor) return; const headings = extractTocFromTextEditor(textEditor); + setHeadings(headings); // React to changes. const changeCallback = () => { @@ -130,7 +131,6 @@ function EditableTextTableOfContents() { }; textEditor.model.document.on("change:data", changeCallback); - setHeadings(headings); return () => textEditor.model.document.off("change:data", changeCallback); }, [ textEditor, note ]); diff --git a/packages/ckeditor5/src/index.ts b/packages/ckeditor5/src/index.ts index a6b193477..745c428cf 100644 --- a/packages/ckeditor5/src/index.ts +++ b/packages/ckeditor5/src/index.ts @@ -6,7 +6,7 @@ import { BalloonEditor, DecoupledEditor, FindAndReplaceEditing, FindCommand } fr import "./translation_overrides.js"; export { default as EditorWatchdog } from "./custom_watchdog"; export { PREMIUM_PLUGINS } from "./plugins.js"; -export type { EditorConfig, MentionFeed, MentionFeedObjectItem, ModelNode, ModelPosition, ModelElement, WatchdogConfig, WatchdogState } from "ckeditor5"; +export type { EditorConfig, MentionFeed, MentionFeedObjectItem, ModelNode, ModelPosition, ModelElement, ModelTextProxy, WatchdogConfig, WatchdogState } from "ckeditor5"; export type { TemplateDefinition } from "ckeditor5-premium-features"; export { default as buildExtraCommands } from "./extra_slash_commands.js"; export { default as getCkLocale } from "./i18n.js"; From 7085e62cfc8280272514fd0ea76571f4c923c1fc Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 13:09:53 +0200 Subject: [PATCH 21/74] chore(highlights_list): reintroduce navigation --- .../src/widgets/sidebar/HighlightsList.tsx | 30 ++++++++++++++----- packages/ckeditor5/src/index.ts | 2 +- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/apps/client/src/widgets/sidebar/HighlightsList.tsx b/apps/client/src/widgets/sidebar/HighlightsList.tsx index 1755230f8..e34e39ff4 100644 --- a/apps/client/src/widgets/sidebar/HighlightsList.tsx +++ b/apps/client/src/widgets/sidebar/HighlightsList.tsx @@ -1,5 +1,5 @@ -import { CKTextEditor, ModelTextProxy } from "@triliumnext/ckeditor5"; -import { useEffect, useState } from "preact/hooks"; +import { CKTextEditor, ModelText } from "@triliumnext/ckeditor5"; +import { useCallback, useEffect, useState } from "preact/hooks"; import { t } from "../../services/i18n"; import { useActiveNoteContext, useIsNoteReadOnly, useNoteProperty, useTextEditor } from "../react/hooks"; @@ -24,14 +24,15 @@ export default function HighlightsList() { ); } -function AbstractHighlightsList({ highlights }: { - highlights: RawHighlight[] +function AbstractHighlightsList({ highlights, scrollToHighlight }: { + highlights: T[], + scrollToHighlight(highlight: T): void; }) { return (
      {highlights.map(highlight => ( -
    1. +
    2. scrollToHighlight(highlight)}> {highlight.text}
    3. ))} @@ -42,7 +43,8 @@ function AbstractHighlightsList({ highlights }: { //#region Editable text (CKEditor) interface CKHighlight extends RawHighlight { - element: ModelTextProxy; + textNode: ModelText; + offset: number | null; } function EditableTextHighlightsList() { @@ -57,8 +59,21 @@ function EditableTextHighlightsList() { setHighlights(highlights); }, [ textEditor, note ]); + const scrollToHeading = useCallback((highlight: CKHighlight) => { + if (!textEditor) return; + + const modelPos = textEditor.model.createPositionAt(highlight.textNode, "before"); + const viewPos = textEditor.editing.mapper.toViewPosition(modelPos); + const domConverter = textEditor.editing.view.domConverter; + const domPos = domConverter.viewPositionToDom(viewPos); + + if (!domPos) return; + (domPos.parent as HTMLElement).scrollIntoView(); + }, [ textEditor ]); + return ; } @@ -85,7 +100,8 @@ function extractHighlightsFromTextEditor(editor: CKTextEditor) { id: crypto.randomUUID(), text: item.data, attrs, - element: item + textNode: item.textNode, + offset: item.startOffset }); } } diff --git a/packages/ckeditor5/src/index.ts b/packages/ckeditor5/src/index.ts index 745c428cf..7dff34803 100644 --- a/packages/ckeditor5/src/index.ts +++ b/packages/ckeditor5/src/index.ts @@ -6,7 +6,7 @@ import { BalloonEditor, DecoupledEditor, FindAndReplaceEditing, FindCommand } fr import "./translation_overrides.js"; export { default as EditorWatchdog } from "./custom_watchdog"; export { PREMIUM_PLUGINS } from "./plugins.js"; -export type { EditorConfig, MentionFeed, MentionFeedObjectItem, ModelNode, ModelPosition, ModelElement, ModelTextProxy, WatchdogConfig, WatchdogState } from "ckeditor5"; +export type { EditorConfig, MentionFeed, MentionFeedObjectItem, ModelNode, ModelPosition, ModelElement, ModelText, WatchdogConfig, WatchdogState } from "ckeditor5"; export type { TemplateDefinition } from "ckeditor5-premium-features"; export { default as buildExtraCommands } from "./extra_slash_commands.js"; export { default as getCkLocale } from "./i18n.js"; From b42a4dcb3641083662b10757cc24eff5d5c80753 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 13:12:58 +0200 Subject: [PATCH 22/74] chore(highlights_list): react to changes --- .../src/widgets/sidebar/HighlightsList.tsx | 37 +++++++++++++++++-- .../src/widgets/sidebar/TableOfContents.tsx | 1 - 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/apps/client/src/widgets/sidebar/HighlightsList.tsx b/apps/client/src/widgets/sidebar/HighlightsList.tsx index e34e39ff4..95ecea6d4 100644 --- a/apps/client/src/widgets/sidebar/HighlightsList.tsx +++ b/apps/client/src/widgets/sidebar/HighlightsList.tsx @@ -54,9 +54,40 @@ function EditableTextHighlightsList() { useEffect(() => { if (!textEditor) return; + setHighlights(extractHighlightsFromTextEditor(textEditor)); - const highlights = extractHighlightsFromTextEditor(textEditor); - setHighlights(highlights); + // React to changes. + const changeCallback = () => { + const changes = textEditor.model.document.differ.getChanges(); + const affectsHighlights = changes.some(change => { + // Text inserted or removed + if (change.type === 'insert' || change.type === 'remove') { + return true; + } + + // Formatting attribute changed + if (change.type === 'attribute' && + ( + change.attributeKey === 'bold' || + change.attributeKey === 'italic' || + change.attributeKey === 'underline' || + change.attributeKey === 'fontColor' || + change.attributeKey === 'fontBackgroundColor' + ) + ) { + return true; + } + + return false; + }); + + if (affectsHighlights) { + setHighlights(extractHighlightsFromTextEditor(textEditor)); + } + }; + + textEditor.model.document.on("change:data", changeCallback); + return () => textEditor.model.document.off("change:data", changeCallback); }, [ textEditor, note ]); const scrollToHeading = useCallback((highlight: CKHighlight) => { @@ -84,7 +115,6 @@ function extractHighlightsFromTextEditor(editor: CKTextEditor) { for (const { item } of editor.model.createRangeIn(root).getWalker({ ignoreElementEnd: true })) { if (!item.is('$textProxy')) continue; - console.log("Got ", item); const attrs = { bold: item.hasAttribute('bold'), @@ -93,7 +123,6 @@ function extractHighlightsFromTextEditor(editor: CKTextEditor) { color: item.getAttribute('fontColor'), background: item.getAttribute('fontBackgroundColor') }; - console.log("Got ", attrs); if (Object.values(attrs).some(Boolean)) { result.push({ diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index d60d4e8dc..ccc623ab8 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -131,7 +131,6 @@ function EditableTextTableOfContents() { }; textEditor.model.document.on("change:data", changeCallback); - return () => textEditor.model.document.off("change:data", changeCallback); }, [ textEditor, note ]); From d920da9e6f709e9a1a5490a2994e1eb775217738 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 13:17:41 +0200 Subject: [PATCH 23/74] chore(highlights_list): render highlights --- .../src/widgets/sidebar/HighlightsList.tsx | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/client/src/widgets/sidebar/HighlightsList.tsx b/apps/client/src/widgets/sidebar/HighlightsList.tsx index 95ecea6d4..6f09ddd03 100644 --- a/apps/client/src/widgets/sidebar/HighlightsList.tsx +++ b/apps/client/src/widgets/sidebar/HighlightsList.tsx @@ -8,7 +8,13 @@ import RightPanelWidget from "./RightPanelWidget"; interface RawHighlight { id: string; text: string; - attrs: Record; + attrs: { + bold: boolean; + italic: boolean; + underline: boolean; + color: string | undefined; + background: string | undefined; + } } export default function HighlightsList() { @@ -33,7 +39,15 @@ function AbstractHighlightsList({ highlights, scrollToHi
        {highlights.map(highlight => (
      1. scrollToHighlight(highlight)}> - {highlight.text} + {highlight.text}
      2. ))}
      @@ -116,12 +130,12 @@ function extractHighlightsFromTextEditor(editor: CKTextEditor) { for (const { item } of editor.model.createRangeIn(root).getWalker({ ignoreElementEnd: true })) { if (!item.is('$textProxy')) continue; - const attrs = { + const attrs: RawHighlight["attrs"] = { bold: item.hasAttribute('bold'), italic: item.hasAttribute('italic'), underline: item.hasAttribute('underline'), - color: item.getAttribute('fontColor'), - background: item.getAttribute('fontBackgroundColor') + color: item.getAttribute('fontColor') as string | undefined, + background: item.getAttribute('fontBackgroundColor') as string | undefined }; if (Object.values(attrs).some(Boolean)) { From 925049357aed19af38a85b28fe6282944865d17b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 13:20:04 +0200 Subject: [PATCH 24/74] fix(highlights_list): missing key --- apps/client/src/widgets/sidebar/HighlightsList.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/client/src/widgets/sidebar/HighlightsList.tsx b/apps/client/src/widgets/sidebar/HighlightsList.tsx index 6f09ddd03..523359da3 100644 --- a/apps/client/src/widgets/sidebar/HighlightsList.tsx +++ b/apps/client/src/widgets/sidebar/HighlightsList.tsx @@ -38,7 +38,10 @@ function AbstractHighlightsList({ highlights, scrollToHi
        {highlights.map(highlight => ( -
      1. scrollToHighlight(highlight)}> +
      2. scrollToHighlight(highlight)} + > Date: Thu, 18 Dec 2025 13:29:36 +0200 Subject: [PATCH 25/74] chore(highlights_list): reintroduce support for read-only notes --- .../src/widgets/sidebar/HighlightsList.tsx | 60 ++++++++++++++++++- .../src/widgets/sidebar/TableOfContents.tsx | 3 + 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/apps/client/src/widgets/sidebar/HighlightsList.tsx b/apps/client/src/widgets/sidebar/HighlightsList.tsx index 523359da3..a575f2f1a 100644 --- a/apps/client/src/widgets/sidebar/HighlightsList.tsx +++ b/apps/client/src/widgets/sidebar/HighlightsList.tsx @@ -1,8 +1,9 @@ +import { headingIsHorizontal } from "@excalidraw/excalidraw/element/heading"; import { CKTextEditor, ModelText } from "@triliumnext/ckeditor5"; import { useCallback, useEffect, useState } from "preact/hooks"; import { t } from "../../services/i18n"; -import { useActiveNoteContext, useIsNoteReadOnly, useNoteProperty, useTextEditor } from "../react/hooks"; +import { useActiveNoteContext, useContentElement, useIsNoteReadOnly, useNoteProperty, useTextEditor } from "../react/hooks"; import RightPanelWidget from "./RightPanelWidget"; interface RawHighlight { @@ -157,7 +158,62 @@ function extractHighlightsFromTextEditor(editor: CKTextEditor) { //#endregion //#region Read-only text +interface DomHighlight extends RawHighlight { + element: HTMLElement; +} + function ReadOnlyTextHighlightsList() { - return "Read-only"; + const { noteContext } = useActiveNoteContext(); + const contentEl = useContentElement(noteContext); + const highlights = extractHeadingsFromStaticHtml(contentEl); + + const scrollToHighlight = useCallback((highlight: DomHighlight) => { + highlight.element.scrollIntoView(); + }, []); + + return ; +} + +function extractHeadingsFromStaticHtml(el: HTMLElement | null) { + if (!el) return []; + + const walker = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null + ); + + const highlights: DomHighlight[] = []; + + let node: Node | null; + while ((node = walker.nextNode())) { + const el = node.parentElement; + if (!el || !node.textContent?.trim()) continue; + + const style = getComputedStyle(el); + + if ( + el.closest('strong, em, u') || + style.color || style.backgroundColor + ) { + highlights.push({ + id: crypto.randomUUID(), + text: node.textContent, + element: el, + attrs: { + bold: !!el.closest("strong"), + italic: !!el.closest("em"), + underline: !!el.closest("u"), + background: el.style.backgroundColor, + color: el.style.color + } + }); + } + } + + return highlights; } //#endregion diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index ccc623ab8..46967655f 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -179,6 +179,8 @@ function extractTocFromTextEditor(editor: CKTextEditor) { return headings; } //#endregion + +//#region Read-only text interface DomHeading extends RawHeading { element: HTMLHeadingElement; } @@ -213,3 +215,4 @@ function extractTocFromStaticHtml(el: HTMLElement | null) { return headings; } +//#endregion From d18ac0c6139f8a241ee49282e7a5f16602486bc4 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 13:33:08 +0200 Subject: [PATCH 26/74] fix(highlights_list): displaying non-highlighted attributes --- .../src/widgets/sidebar/HighlightsList.tsx | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/apps/client/src/widgets/sidebar/HighlightsList.tsx b/apps/client/src/widgets/sidebar/HighlightsList.tsx index a575f2f1a..755071cab 100644 --- a/apps/client/src/widgets/sidebar/HighlightsList.tsx +++ b/apps/client/src/widgets/sidebar/HighlightsList.tsx @@ -191,7 +191,7 @@ function extractHeadingsFromStaticHtml(el: HTMLElement | null) { let node: Node | null; while ((node = walker.nextNode())) { const el = node.parentElement; - if (!el || !node.textContent?.trim()) continue; + if (!el || !node.textContent) continue; const style = getComputedStyle(el); @@ -199,18 +199,22 @@ function extractHeadingsFromStaticHtml(el: HTMLElement | null) { el.closest('strong, em, u') || style.color || style.backgroundColor ) { - highlights.push({ - id: crypto.randomUUID(), - text: node.textContent, - element: el, - attrs: { - bold: !!el.closest("strong"), - italic: !!el.closest("em"), - underline: !!el.closest("u"), - background: el.style.backgroundColor, - color: el.style.color - } - }); + const attrs: RawHighlight["attrs"] = { + bold: !!el.closest("strong"), + italic: !!el.closest("em"), + underline: !!el.closest("u"), + background: el.style.backgroundColor, + color: el.style.color + }; + + if (Object.values(attrs).some(Boolean)) { + highlights.push({ + id: crypto.randomUUID(), + text: node.textContent, + element: el, + attrs + }); + } } } From 751a874c5119fc7e209f6309a2c6235d146f5222 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 13:38:52 +0200 Subject: [PATCH 27/74] chore(highlights_list): improve performance by matching fewer elements --- apps/client/src/widgets/sidebar/HighlightsList.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/client/src/widgets/sidebar/HighlightsList.tsx b/apps/client/src/widgets/sidebar/HighlightsList.tsx index 755071cab..4007cc91f 100644 --- a/apps/client/src/widgets/sidebar/HighlightsList.tsx +++ b/apps/client/src/widgets/sidebar/HighlightsList.tsx @@ -180,6 +180,8 @@ function ReadOnlyTextHighlightsList() { function extractHeadingsFromStaticHtml(el: HTMLElement | null) { if (!el) return []; + const { color: defaultColor, backgroundColor: defaultBackgroundColor } = getComputedStyle(el); + const walker = document.createTreeWalker( el, NodeFilter.SHOW_TEXT, @@ -197,7 +199,8 @@ function extractHeadingsFromStaticHtml(el: HTMLElement | null) { if ( el.closest('strong, em, u') || - style.color || style.backgroundColor + style.color !== defaultColor || + style.backgroundColor !== defaultBackgroundColor ) { const attrs: RawHighlight["attrs"] = { bold: !!el.closest("strong"), From 28d9d98964f907f80b07e10f45427d8df61c51d3 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 13:45:45 +0200 Subject: [PATCH 28/74] fix(highlights_list): unable to scroll to text fragments --- apps/client/src/widgets/sidebar/HighlightsList.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/client/src/widgets/sidebar/HighlightsList.tsx b/apps/client/src/widgets/sidebar/HighlightsList.tsx index 4007cc91f..be1e170e1 100644 --- a/apps/client/src/widgets/sidebar/HighlightsList.tsx +++ b/apps/client/src/widgets/sidebar/HighlightsList.tsx @@ -117,7 +117,12 @@ function EditableTextHighlightsList() { const domPos = domConverter.viewPositionToDom(viewPos); if (!domPos) return; - (domPos.parent as HTMLElement).scrollIntoView(); + if (domPos.parent instanceof HTMLElement) { + domPos.parent.scrollIntoView(); + } else if (domPos.parent instanceof Text) { + domPos.parent.parentElement?.scrollIntoView(); + } + }, [ textEditor ]); return Date: Thu, 18 Dec 2025 14:10:16 +0200 Subject: [PATCH 29/74] chore(right_pane): improve style slightly --- .../src/widgets/sidebar/RightPanelContainer.css | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.css b/apps/client/src/widgets/sidebar/RightPanelContainer.css index 65cff510e..631480c1b 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.css +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.css @@ -1,3 +1,17 @@ body.experimental-feature-new-layout #right-pane { width: 300px; + + .card { + margin-inline: 0; + border-bottom: 1px solid var(--main-border-color); + border-radius: 0; + + .card-header-title { + padding-inline: 0.5em; + } + + &:last-of-type { + border-bottom: 0; + } + } } From b5bfb02d967ac728fe2944087815722e9c3aad89 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 14:12:28 +0200 Subject: [PATCH 30/74] chore(right_pane): experiment with resizable sections --- .../widgets/sidebar/RightPanelContainer.tsx | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index b5ad216fd..d3ec7e912 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -6,14 +6,13 @@ import { useEffect } from "preact/hooks"; import options from "../../services/options"; import { DEFAULT_GUTTER_SIZE } from "../../services/resizer"; -import { useActiveNoteContext } from "../react/hooks"; import HighlightsList from "./HighlightsList"; import TableOfContents from "./TableOfContents"; const MIN_WIDTH_PERCENT = 5; export default function RightPanelContainer() { - const { note } = useActiveNoteContext(); + // Split between right pane and the content pane. useEffect(() => { // We are intentionally omitting useTriliumOption to avoid re-render due to size change. const rightPaneWidth = Math.max(MIN_WIDTH_PERCENT, options.getInt("rightPaneWidth") ?? MIN_WIDTH_PERCENT); @@ -27,12 +26,25 @@ export default function RightPanelContainer() { return () => splitInstance.destroy(); }, []); + const items = [ + , + + ]; + + // Split between items. + useEffect(() => { + const rightPaneContainer = document.getElementById("right-pane"); + const elements = Array.from(rightPaneContainer?.children ?? []) as HTMLElement[]; + console.log("Got ", elements); + const splitInstance = Split(elements, { + direction: "vertical" + }); + return () => splitInstance.destroy(); + }, [ items ]); + return (
        - {note && <> - - - } + {items}
        ); } From 682c61305c9e850928233993e92526a0c74a06e8 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 14:42:21 +0200 Subject: [PATCH 31/74] chore(right_pane): basic collapse support --- .../widgets/sidebar/RightPanelContainer.css | 4 ++ .../widgets/sidebar/RightPanelContainer.tsx | 41 +++++++++++++++++-- .../src/widgets/sidebar/RightPanelWidget.tsx | 26 ++++++++++-- 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.css b/apps/client/src/widgets/sidebar/RightPanelContainer.css index 631480c1b..d737e9d52 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.css +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.css @@ -13,5 +13,9 @@ body.experimental-feature-new-layout #right-pane { &:last-of-type { border-bottom: 0; } + + &.collapsed .card-header > .bx { + transform: rotate(-90deg); + } } } diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index d3ec7e912..ef0af28f0 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -2,7 +2,8 @@ import "./RightPanelContainer.css"; import Split from "@triliumnext/split.js"; -import { useEffect } from "preact/hooks"; +import { createContext } from "preact"; +import { useEffect, useRef } from "preact/hooks"; import options from "../../services/options"; import { DEFAULT_GUTTER_SIZE } from "../../services/resizer"; @@ -10,6 +11,11 @@ import HighlightsList from "./HighlightsList"; import TableOfContents from "./TableOfContents"; const MIN_WIDTH_PERCENT = 5; +const COLLAPSED_SIZE = 32; + +export const RightPanelContext = createContext({ + setExpanded(cardEl: HTMLElement, expanded: boolean) {} +}); export default function RightPanelContainer() { // Split between right pane and the content pane. @@ -32,19 +38,46 @@ export default function RightPanelContainer() { ]; // Split between items. + const innerSplitRef = useRef(null); useEffect(() => { const rightPaneContainer = document.getElementById("right-pane"); const elements = Array.from(rightPaneContainer?.children ?? []) as HTMLElement[]; - console.log("Got ", elements); const splitInstance = Split(elements, { - direction: "vertical" + direction: "vertical", + minSize: COLLAPSED_SIZE, + gutterSize: 1 }); + innerSplitRef.current = splitInstance; return () => splitInstance.destroy(); }, [ items ]); return (
        - {items} + .card") ?? []); + const pos = children.indexOf(cardEl); + if (pos === -1) return; + const sizes = splitInstance.getSizes(); + if (!expanded) { + const sizeBeforeCollapse = sizes[pos]; + sizes[pos] = 0; + const itemToExpand = pos > 0 ? pos - 1 : pos + 1; + + if (sizes[itemToExpand] > COLLAPSED_SIZE) { + sizes[itemToExpand] += sizeBeforeCollapse; + } + } + console.log("Set sizes to ", sizes); + splitInstance.setSizes(sizes); + }, + }}> + {items} +
        ); } diff --git a/apps/client/src/widgets/sidebar/RightPanelWidget.tsx b/apps/client/src/widgets/sidebar/RightPanelWidget.tsx index 94a5cf04c..7f4391d6c 100644 --- a/apps/client/src/widgets/sidebar/RightPanelWidget.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelWidget.tsx @@ -1,6 +1,10 @@ -import { useContext, useRef } from "preact/hooks"; -import { ParentComponent } from "../react/react_utils"; +import clsx from "clsx"; import { ComponentChildren } from "preact"; +import { useContext, useRef, useState } from "preact/hooks"; + +import Icon from "../react/Icon"; +import { ParentComponent } from "../react/react_utils"; +import { RightPanelContext } from "./RightPanelContainer"; interface RightPanelWidgetProps { title: string; @@ -9,6 +13,8 @@ interface RightPanelWidgetProps { } export default function RightPanelWidget({ title, buttons, children }: RightPanelWidgetProps) { + const rightPanelContext = useContext(RightPanelContext); + const [ expanded, setExpanded ] = useState(true); const containerRef = useRef(null); const parentComponent = useContext(ParentComponent); @@ -17,15 +23,27 @@ export default function RightPanelWidget({ title, buttons, children }: RightPane } return ( -
        +
        + { + if (containerRef.current) { + rightPanelContext.setExpanded(containerRef.current, !expanded); + } + setExpanded(!expanded); + }} + />
        {title}
        {buttons}
        - {children} + {expanded && children}
        From 5dacfd3ac68aa68ee6d70f35dba2af5740ea496f Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 14:52:22 +0200 Subject: [PATCH 32/74] chore(right_pane): basic expand support --- .../src/widgets/sidebar/RightPanelContainer.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index ef0af28f0..8d41929cc 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -51,6 +51,8 @@ export default function RightPanelContainer() { return () => splitInstance.destroy(); }, [ items ]); + const sizesBeforeCollapse = useRef(new WeakMap()); + return (
        0 ? pos - 1 : pos + 1; if (sizes[itemToExpand] > COLLAPSED_SIZE) { sizes[itemToExpand] += sizeBeforeCollapse; } + } else { + const itemToExpand = pos > 0 ? pos - 1 : pos + 1; + const sizeBeforeCollapse = sizesBeforeCollapse.current.get(cardEl) ?? 50; + + if (sizes[itemToExpand] > COLLAPSED_SIZE) { + sizes[itemToExpand] -= sizeBeforeCollapse; + } + sizes[pos] = sizeBeforeCollapse; } console.log("Set sizes to ", sizes); splitInstance.setSizes(sizes); From ea3222cf127120b642d4901bc4a94cbca502809a Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 14:57:42 +0200 Subject: [PATCH 33/74] chore(right_pane): more advanced expand/collapse --- apps/client/src/services/utils.ts | 99 ++++++++-------- .../widgets/sidebar/RightPanelContainer.tsx | 111 +++++++++++++++--- 2 files changed, 149 insertions(+), 61 deletions(-) diff --git a/apps/client/src/services/utils.ts b/apps/client/src/services/utils.ts index 8c2a12c6a..36277bbb1 100644 --- a/apps/client/src/services/utils.ts +++ b/apps/client/src/services/utils.ts @@ -1,8 +1,9 @@ import { dayjs } from "@triliumnext/commons"; -import type { ViewMode, ViewScope } from "./link.js"; -import FNote from "../entities/fnote"; import { snapdom } from "@zumer/snapdom"; +import FNote from "../entities/fnote"; +import type { ViewMode, ViewScope } from "./link.js"; + const SVG_MIME = "image/svg+xml"; export const isShare = !window.glob; @@ -113,9 +114,9 @@ function formatDateISO(date: Date) { export function formatDateTime(date: Date, userSuppliedFormat?: string): string { if (userSuppliedFormat?.trim()) { return dayjs(date).format(userSuppliedFormat); - } else { - return `${formatDate(date)} ${formatTime(date)}`; - } + } + return `${formatDate(date)} ${formatTime(date)}`; + } function localNowDateTime() { @@ -191,9 +192,9 @@ export function formatSize(size: number | null | undefined) { if (size < 1024) { return `${size} KiB`; - } else { - return `${Math.round(size / 102.4) / 10} MiB`; - } + } + return `${Math.round(size / 102.4) / 10} MiB`; + } function toObject(array: T[], fn: (arg0: T) => [key: string, value: R]) { @@ -297,18 +298,18 @@ function formatHtml(html: string) { let indent = "\n"; const tab = "\t"; let i = 0; - let pre: { indent: string; tag: string }[] = []; + const pre: { indent: string; tag: string }[] = []; html = html - .replace(new RegExp("
        ([\\s\\S]+?)?
        "), function (x) { + .replace(new RegExp("
        ([\\s\\S]+?)?
        "), (x) => { pre.push({ indent: "", tag: x }); - return "<--TEMPPRE" + i++ + "/-->"; + return `<--TEMPPRE${ i++ }/-->`; }) - .replace(new RegExp("<[^<>]+>[^<]?", "g"), function (x) { + .replace(new RegExp("<[^<>]+>[^<]?", "g"), (x) => { let ret; const tagRegEx = /<\/?([^\s/>]+)/.exec(x); - let tag = tagRegEx ? tagRegEx[1] : ""; - let p = new RegExp("<--TEMPPRE(\\d+)/-->").exec(x); + const tag = tagRegEx ? tagRegEx[1] : ""; + const p = new RegExp("<--TEMPPRE(\\d+)/-->").exec(x); if (p) { const pInd = parseInt(p[1]); @@ -318,24 +319,22 @@ function formatHtml(html: string) { if (["area", "base", "br", "col", "command", "embed", "hr", "img", "input", "keygen", "link", "menuitem", "meta", "param", "source", "track", "wbr"].indexOf(tag) >= 0) { // self closing tag ret = indent + x; + } else if (x.indexOf("") ret = indent + x.substr(0, x.length - 1) + indent + tab + x.substr(x.length - 1, x.length); + else ret = indent + x; + !p && (indent += tab); } else { - if (x.indexOf("") ret = indent + x.substr(0, x.length - 1) + indent + tab + x.substr(x.length - 1, x.length); - else ret = indent + x; - !p && (indent += tab); - } else { - //close tag - indent = indent.substr(0, indent.length - 1); - if (x.charAt(x.length - 1) !== ">") ret = indent + x.substr(0, x.length - 1) + indent + x.substr(x.length - 1, x.length); - else ret = indent + x; - } + //close tag + indent = indent.substr(0, indent.length - 1); + if (x.charAt(x.length - 1) !== ">") ret = indent + x.substr(0, x.length - 1) + indent + x.substr(x.length - 1, x.length); + else ret = indent + x; } return ret; }); for (i = pre.length; i--;) { - html = html.replace("<--TEMPPRE" + i + "/-->", pre[i].tag.replace("
        ", "
        \n").replace("
        ", pre[i].indent + "
        ")); + html = html.replace(`<--TEMPPRE${ i }/-->`, pre[i].tag.replace("
        ", "
        \n").replace("
        ", `${pre[i].indent }
        `)); } return html.charAt(0) === "\n" ? html.substr(1, html.length - 1) : html; @@ -364,11 +363,11 @@ type dynamicRequireMappings = { export function dynamicRequire(moduleName: T): Awaited{ if (typeof __non_webpack_require__ !== "undefined") { return __non_webpack_require__(moduleName); - } else { - // explicitly pass as string and not as expression to suppress webpack warning - // 'Critical dependency: the request of a dependency is an expression' - return require(`${moduleName}`); - } + } + // explicitly pass as string and not as expression to suppress webpack warning + // 'Critical dependency: the request of a dependency is an expression' + return require(`${moduleName}`); + } function timeLimit(promise: Promise, limitMs: number, errorMessage?: string) { @@ -509,8 +508,8 @@ export function escapeRegExp(str: string) { function areObjectsEqual(...args: unknown[]) { let i; let l; - let leftChain: Object[]; - let rightChain: Object[]; + let leftChain: object[]; + let rightChain: object[]; function compare2Objects(x: unknown, y: unknown) { let p; @@ -695,9 +694,9 @@ async function downloadAsSvg(nameWithoutExtension: string, svgSource: string | S try { const result = await snapdom(element, { - backgroundColor: "transparent", - scale: 2 - }); + backgroundColor: "transparent", + scale: 2 + }); triggerDownload(`${nameWithoutExtension}.svg`, result.url); } finally { cleanup(); @@ -733,9 +732,9 @@ async function downloadAsPng(nameWithoutExtension: string, svgSource: string | S try { const result = await snapdom(element, { - backgroundColor: "transparent", - scale: 2 - }); + backgroundColor: "transparent", + scale: 2 + }); const pngImg = await result.toPng(); await triggerDownload(`${nameWithoutExtension}.png`, pngImg.src); } finally { @@ -763,11 +762,11 @@ export function getSizeFromSvg(svgContent: string) { return { width: parseFloat(width), height: parseFloat(height) - } - } else { - console.warn("SVG export error", svgDocument.documentElement); - return null; - } + }; + } + console.warn("SVG export error", svgDocument.documentElement); + return null; + } /** @@ -896,9 +895,9 @@ export function mapToKeyValueArray(map: R export function getErrorMessage(e: unknown) { if (e && typeof e === "object" && "message" in e && typeof e.message === "string") { return e.message; - } else { - return "Unknown error"; - } + } + return "Unknown error"; + } /** @@ -913,6 +912,12 @@ export function handleRightToLeftPlacement(placement: T) { return placement; } +export function clamp(value: number, min: number, max: number) { + if (value < min) return min; + if (value > max) return max; + return value; +} + export default { reloadFrontendApp, restartDesktopApp, diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index 8d41929cc..8a708ea7a 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -7,6 +7,7 @@ import { useEffect, useRef } from "preact/hooks"; import options from "../../services/options"; import { DEFAULT_GUTTER_SIZE } from "../../services/resizer"; +import { clamp } from "../../services/utils"; import HighlightsList from "./HighlightsList"; import TableOfContents from "./TableOfContents"; @@ -64,31 +65,113 @@ export default function RightPanelContainer() { const children = Array.from(rightPaneEl?.querySelectorAll(":scope > .card") ?? []); const pos = children.indexOf(cardEl); if (pos === -1) return; - const sizes = splitInstance.getSizes(); + + const sizes = splitInstance.getSizes(); // percentages + const COLLAPSED_SIZE = 0; // keep your current behavior; consider a small min later + + // Choose recipients/donors: nearest expanded panes first; if none, all except pos. + const recipients = getRecipientsByDistance(sizes, pos, COLLAPSED_SIZE); + const fallback = getExpandedIndices(sizes, pos, -Infinity); // all other panes + const targets = recipients.length ? recipients : fallback; + if (!expanded) { const sizeBeforeCollapse = sizes[pos]; sizesBeforeCollapse.current.set(cardEl, sizeBeforeCollapse); - sizes[pos] = 0; - const itemToExpand = pos > 0 ? pos - 1 : pos + 1; - if (sizes[itemToExpand] > COLLAPSED_SIZE) { - sizes[itemToExpand] += sizeBeforeCollapse; - } + // Collapse + sizes[pos] = COLLAPSED_SIZE; + + // Give freed space to other panes + const freed = sizeBeforeCollapse - COLLAPSED_SIZE; + distributeInto(sizes, targets, freed); } else { - const itemToExpand = pos > 0 ? pos - 1 : pos + 1; - const sizeBeforeCollapse = sizesBeforeCollapse.current.get(cardEl) ?? 50; + const want = sizesBeforeCollapse.current.get(cardEl) ?? 50; - if (sizes[itemToExpand] > COLLAPSED_SIZE) { - sizes[itemToExpand] -= sizeBeforeCollapse; - } - sizes[pos] = sizeBeforeCollapse; + // Take space back from other panes to expand this one + const took = takeFrom(sizes, targets, want); + + sizes[pos] = COLLAPSED_SIZE + took; // if donors couldn't provide all, expand partially } - console.log("Set sizes to ", sizes); + + // Optional: tiny cleanup to avoid negatives / floating drift + for (let i = 0; i < sizes.length; i++) sizes[i] = clamp(sizes[i], 0, 100); + + // Normalize to sum to 100 (Split.js likes this) + const sum = sizes.reduce((a, b) => a + b, 0); + if (sum > 0) { + for (let i = 0; i < sizes.length; i++) sizes[i] = (sizes[i] / sum) * 100; + } + splitInstance.setSizes(sizes); - }, + } }}> {items}
        ); } + +function getExpandedIndices(sizes, skipIndex, COLLAPSED_SIZE) { + const idxs = []; + for (let i = 0; i < sizes.length; i++) { + if (i === skipIndex) continue; + if (sizes[i] > COLLAPSED_SIZE) idxs.push(i); + } + return idxs; +} + +// Prefer nearby panes (VS Code-ish). Falls back to "all expanded panes". +function getRecipientsByDistance(sizes, pos, COLLAPSED_SIZE) { + const recipients = []; + for (let d = 1; d < sizes.length; d++) { + const left = pos - d; + const right = pos + d; + if (left >= 0 && sizes[left] > COLLAPSED_SIZE) recipients.push(left); + if (right < sizes.length && sizes[right] > COLLAPSED_SIZE) recipients.push(right); + } + return recipients; +} + +// Distribute `amount` into `recipients` proportionally to their current sizes. +function distributeInto(sizes, recipients, amount) { + if (amount === 0 || recipients.length === 0) return; + const total = recipients.reduce((sum, i) => sum + sizes[i], 0); + if (total <= 0) { + // equal split fallback + const delta = amount / recipients.length; + recipients.forEach(i => (sizes[i] += delta)); + return; + } + recipients.forEach(i => { + const share = (sizes[i] / total) * amount; + sizes[i] += share; + }); +} + +// Take `amount` out of `donors` proportionally, without driving anyone below 0. +// Returns how much was actually taken. +function takeFrom(sizes, donors, amount) { + if (amount <= 0 || donors.length === 0) return 0; + + // max each donor can contribute (don’t go below 0 here; you can change min if you want) + const caps = donors.map(i => ({ i, cap: Math.max(0, sizes[i]) })); + let remaining = amount; + + // iterative proportional take with caps + for (let iter = 0; iter < 5 && remaining > 1e-9; iter++) { + const active = caps.filter(x => x.cap > 1e-9); + if (active.length === 0) break; + + const total = active.reduce((s, x) => s + sizes[x.i], 0) || active.length; + for (const x of active) { + const weight = total === active.length ? 1 / active.length : (sizes[x.i] / total); + const want = remaining * weight; + const took = Math.min(x.cap, want); + sizes[x.i] -= took; + x.cap -= took; + remaining -= took; + if (remaining <= 1e-9) break; + } + } + return amount - remaining; +} From 7f7ec5d858b24a6b2e99c8346786c402fad67f91 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 15:40:09 +0200 Subject: [PATCH 34/74] chore(right_pane): make the gutter slightly bigger --- apps/client/src/widgets/sidebar/RightPanelContainer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index 8a708ea7a..f4f0984a3 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -46,7 +46,7 @@ export default function RightPanelContainer() { const splitInstance = Split(elements, { direction: "vertical", minSize: COLLAPSED_SIZE, - gutterSize: 1 + gutterSize: 4 }); innerSplitRef.current = splitInstance; return () => splitInstance.destroy(); From 02294206ecd114b721e1b0b477d2378a06368b23 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 15:42:44 +0200 Subject: [PATCH 35/74] chore(right_pane): revert note data store --- apps/client/src/widgets/react/NoteStore.ts | 33 ---------------------- apps/client/src/widgets/react/hooks.tsx | 14 +-------- 2 files changed, 1 insertion(+), 46 deletions(-) delete mode 100644 apps/client/src/widgets/react/NoteStore.ts diff --git a/apps/client/src/widgets/react/NoteStore.ts b/apps/client/src/widgets/react/NoteStore.ts deleted file mode 100644 index 6f34c6d23..000000000 --- a/apps/client/src/widgets/react/NoteStore.ts +++ /dev/null @@ -1,33 +0,0 @@ -type Listener = () => void; - -class NoteSavedDataStore { - private data = new Map(); - private listeners = new Map>(); - - get(noteId: string) { - return this.data.get(noteId); - } - - set(noteId: string, value: string) { - this.data.set(noteId, value); - this.listeners.get(noteId)?.forEach(l => l()); - } - - subscribe(noteId: string, listener: Listener) { - let set = this.listeners.get(noteId); - if (!set) { - set = new Set(); - this.listeners.set(noteId, set); - } - set.add(listener); - - return () => { - set!.delete(listener); - if (set!.size === 0) { - this.listeners.delete(noteId); - } - }; - } -} - -export const noteSavedDataStore = new NoteSavedDataStore(); diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 73fbfc101..eadbf12a6 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -3,7 +3,7 @@ import { FilterLabelsByType, KeyboardActionNames, OptionNames, RelationNames } f import { Tooltip } from "bootstrap"; import Mark from "mark.js"; import { RefObject, VNode } from "preact"; -import { CSSProperties, useSyncExternalStore } from "preact/compat"; +import { CSSProperties } from "preact/compat"; import { MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; import appContext, { EventData, EventNames } from "../../components/app_context"; @@ -26,8 +26,6 @@ import utils, { escapeRegExp, randomString, reloadFrontendApp } from "../../serv import BasicWidget, { ReactWrappedWidget } from "../basic_widget"; import NoteContextAwareWidget from "../note_context_aware_widget"; import { DragData } from "../note_tree"; -import CKEditor from "./CKEditor"; -import { noteSavedDataStore } from "./NoteStore"; import { NoteContextContext, ParentComponent, refToJQuerySelector } from "./react_utils"; export function useTriliumEvent(eventName: T, handler: (data: EventData) => void) { @@ -115,7 +113,6 @@ export function useEditorSpacedUpdate({ note, noteContext, getData, onContentCha protected_session_holder.touchProtectedSessionIfNecessary(note); await server.put(`notes/${note.noteId}/data`, data, parentComponent?.componentId); - noteSavedDataStore.set(note.noteId, data.content); dataSaved?.(data); }; }, [ note, getData, dataSaved ]); @@ -124,7 +121,6 @@ export function useEditorSpacedUpdate({ note, noteContext, getData, onContentCha // React to note/blob changes. useEffect(() => { if (!blob) return; - noteSavedDataStore.set(note.noteId, blob.content); spacedUpdate.allowUpdateWithoutChange(() => onContentChange(blob.content)); }, [ blob ]); @@ -156,14 +152,6 @@ export function useEditorSpacedUpdate({ note, noteContext, getData, onContentCha return spacedUpdate; } -export function useNoteSavedData(noteId: string | undefined) { - return useSyncExternalStore( - (cb) => noteId ? noteSavedDataStore.subscribe(noteId, cb) : () => {}, - () => noteId ? noteSavedDataStore.get(noteId) : undefined - ); -} - - /** * Allows a React component to read and write a Trilium option, while also watching for external changes. * From 7b04ca8cc7ca625b15368cc61ace163f5a4d2af4 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 15:57:48 +0200 Subject: [PATCH 36/74] style(right_pane): improve header space slightly --- .../src/widgets/sidebar/RightPanelContainer.css | 12 ++++++++++-- .../src/widgets/sidebar/RightPanelContainer.tsx | 2 +- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.css b/apps/client/src/widgets/sidebar/RightPanelContainer.css index d737e9d52..40d0cb6c0 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.css +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.css @@ -6,8 +6,12 @@ body.experimental-feature-new-layout #right-pane { border-bottom: 1px solid var(--main-border-color); border-radius: 0; - .card-header-title { - padding-inline: 0.5em; + .card-header { + padding-block: 0.2em; + + .card-header-title { + padding-inline: 0.5em; + } } &:last-of-type { @@ -18,4 +22,8 @@ body.experimental-feature-new-layout #right-pane { transform: rotate(-90deg); } } + + .gutter-vertical + .card .card-header { + padding-top: 0; + } } diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index f4f0984a3..41ca58fc2 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -12,7 +12,7 @@ import HighlightsList from "./HighlightsList"; import TableOfContents from "./TableOfContents"; const MIN_WIDTH_PERCENT = 5; -const COLLAPSED_SIZE = 32; +const COLLAPSED_SIZE = 25; export const RightPanelContext = createContext({ setExpanded(cardEl: HTMLElement, expanded: boolean) {} From 7af063e7cd08d8db6165fc1fd9a22fbb19e7da16 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 16:15:21 +0200 Subject: [PATCH 37/74] feat(right_pane): simplify collapsing mechanism --- .../widgets/sidebar/RightPanelContainer.css | 6 + .../widgets/sidebar/RightPanelContainer.tsx | 136 +----------------- .../src/widgets/sidebar/RightPanelWidget.tsx | 11 +- 3 files changed, 10 insertions(+), 143 deletions(-) diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.css b/apps/client/src/widgets/sidebar/RightPanelContainer.css index 40d0cb6c0..424c34501 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.css +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.css @@ -1,5 +1,7 @@ body.experimental-feature-new-layout #right-pane { width: 300px; + display: flex; + flex-direction: column; .card { margin-inline: 0; @@ -23,6 +25,10 @@ body.experimental-feature-new-layout #right-pane { } } + .card:not(.collapsed) { + flex-grow: 1; + } + .gutter-vertical + .card .card-header { padding-top: 0; } diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index 41ca58fc2..1a837bd1d 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -12,11 +12,6 @@ import HighlightsList from "./HighlightsList"; import TableOfContents from "./TableOfContents"; const MIN_WIDTH_PERCENT = 5; -const COLLAPSED_SIZE = 25; - -export const RightPanelContext = createContext({ - setExpanded(cardEl: HTMLElement, expanded: boolean) {} -}); export default function RightPanelContainer() { // Split between right pane and the content pane. @@ -38,140 +33,11 @@ export default function RightPanelContainer() { ]; - // Split between items. - const innerSplitRef = useRef(null); - useEffect(() => { - const rightPaneContainer = document.getElementById("right-pane"); - const elements = Array.from(rightPaneContainer?.children ?? []) as HTMLElement[]; - const splitInstance = Split(elements, { - direction: "vertical", - minSize: COLLAPSED_SIZE, - gutterSize: 4 - }); - innerSplitRef.current = splitInstance; - return () => splitInstance.destroy(); - }, [ items ]); - const sizesBeforeCollapse = useRef(new WeakMap()); return (
        - .card") ?? []); - const pos = children.indexOf(cardEl); - if (pos === -1) return; - - const sizes = splitInstance.getSizes(); // percentages - const COLLAPSED_SIZE = 0; // keep your current behavior; consider a small min later - - // Choose recipients/donors: nearest expanded panes first; if none, all except pos. - const recipients = getRecipientsByDistance(sizes, pos, COLLAPSED_SIZE); - const fallback = getExpandedIndices(sizes, pos, -Infinity); // all other panes - const targets = recipients.length ? recipients : fallback; - - if (!expanded) { - const sizeBeforeCollapse = sizes[pos]; - sizesBeforeCollapse.current.set(cardEl, sizeBeforeCollapse); - - // Collapse - sizes[pos] = COLLAPSED_SIZE; - - // Give freed space to other panes - const freed = sizeBeforeCollapse - COLLAPSED_SIZE; - distributeInto(sizes, targets, freed); - } else { - const want = sizesBeforeCollapse.current.get(cardEl) ?? 50; - - // Take space back from other panes to expand this one - const took = takeFrom(sizes, targets, want); - - sizes[pos] = COLLAPSED_SIZE + took; // if donors couldn't provide all, expand partially - } - - // Optional: tiny cleanup to avoid negatives / floating drift - for (let i = 0; i < sizes.length; i++) sizes[i] = clamp(sizes[i], 0, 100); - - // Normalize to sum to 100 (Split.js likes this) - const sum = sizes.reduce((a, b) => a + b, 0); - if (sum > 0) { - for (let i = 0; i < sizes.length; i++) sizes[i] = (sizes[i] / sum) * 100; - } - - splitInstance.setSizes(sizes); - } - }}> - {items} - + {items}
        ); } - -function getExpandedIndices(sizes, skipIndex, COLLAPSED_SIZE) { - const idxs = []; - for (let i = 0; i < sizes.length; i++) { - if (i === skipIndex) continue; - if (sizes[i] > COLLAPSED_SIZE) idxs.push(i); - } - return idxs; -} - -// Prefer nearby panes (VS Code-ish). Falls back to "all expanded panes". -function getRecipientsByDistance(sizes, pos, COLLAPSED_SIZE) { - const recipients = []; - for (let d = 1; d < sizes.length; d++) { - const left = pos - d; - const right = pos + d; - if (left >= 0 && sizes[left] > COLLAPSED_SIZE) recipients.push(left); - if (right < sizes.length && sizes[right] > COLLAPSED_SIZE) recipients.push(right); - } - return recipients; -} - -// Distribute `amount` into `recipients` proportionally to their current sizes. -function distributeInto(sizes, recipients, amount) { - if (amount === 0 || recipients.length === 0) return; - const total = recipients.reduce((sum, i) => sum + sizes[i], 0); - if (total <= 0) { - // equal split fallback - const delta = amount / recipients.length; - recipients.forEach(i => (sizes[i] += delta)); - return; - } - recipients.forEach(i => { - const share = (sizes[i] / total) * amount; - sizes[i] += share; - }); -} - -// Take `amount` out of `donors` proportionally, without driving anyone below 0. -// Returns how much was actually taken. -function takeFrom(sizes, donors, amount) { - if (amount <= 0 || donors.length === 0) return 0; - - // max each donor can contribute (don’t go below 0 here; you can change min if you want) - const caps = donors.map(i => ({ i, cap: Math.max(0, sizes[i]) })); - let remaining = amount; - - // iterative proportional take with caps - for (let iter = 0; iter < 5 && remaining > 1e-9; iter++) { - const active = caps.filter(x => x.cap > 1e-9); - if (active.length === 0) break; - - const total = active.reduce((s, x) => s + sizes[x.i], 0) || active.length; - for (const x of active) { - const weight = total === active.length ? 1 / active.length : (sizes[x.i] / total); - const want = remaining * weight; - const took = Math.min(x.cap, want); - sizes[x.i] -= took; - x.cap -= took; - remaining -= took; - if (remaining <= 1e-9) break; - } - } - return amount - remaining; -} diff --git a/apps/client/src/widgets/sidebar/RightPanelWidget.tsx b/apps/client/src/widgets/sidebar/RightPanelWidget.tsx index 7f4391d6c..b421ba3c3 100644 --- a/apps/client/src/widgets/sidebar/RightPanelWidget.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelWidget.tsx @@ -4,7 +4,6 @@ import { useContext, useRef, useState } from "preact/hooks"; import Icon from "../react/Icon"; import { ParentComponent } from "../react/react_utils"; -import { RightPanelContext } from "./RightPanelContainer"; interface RightPanelWidgetProps { title: string; @@ -13,7 +12,6 @@ interface RightPanelWidgetProps { } export default function RightPanelWidget({ title, buttons, children }: RightPanelWidgetProps) { - const rightPanelContext = useContext(RightPanelContext); const [ expanded, setExpanded ] = useState(true); const containerRef = useRef(null); const parentComponent = useContext(ParentComponent); @@ -31,9 +29,6 @@ export default function RightPanelWidget({ title, buttons, children }: RightPane { - if (containerRef.current) { - rightPanelContext.setExpanded(containerRef.current, !expanded); - } setExpanded(!expanded); }} /> @@ -42,9 +37,9 @@ export default function RightPanelWidget({ title, buttons, children }: RightPane
        -
        - {expanded && children} -
        + {expanded &&
        + {children} +
        }
    ); From 57081a1bfb448a892436c2e62e1e4f84e6b0cc60 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 16:16:52 +0200 Subject: [PATCH 38/74] feat(right_pane): make whole title clickable --- apps/client/src/widgets/sidebar/RightPanelContainer.css | 1 + apps/client/src/widgets/sidebar/RightPanelWidget.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.css b/apps/client/src/widgets/sidebar/RightPanelContainer.css index 424c34501..4153db7c1 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.css +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.css @@ -10,6 +10,7 @@ body.experimental-feature-new-layout #right-pane { .card-header { padding-block: 0.2em; + cursor: pointer; .card-header-title { padding-inline: 0.5em; diff --git a/apps/client/src/widgets/sidebar/RightPanelWidget.tsx b/apps/client/src/widgets/sidebar/RightPanelWidget.tsx index b421ba3c3..7f2aceedf 100644 --- a/apps/client/src/widgets/sidebar/RightPanelWidget.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelWidget.tsx @@ -25,12 +25,12 @@ export default function RightPanelWidget({ title, buttons, children }: RightPane ref={containerRef} class={clsx("card widget", !expanded && "collapsed")} > -
    +
    setExpanded(!expanded)} + > { - setExpanded(!expanded); - }} />
    {title}
    {buttons}
    From a4024d17baa1b81cf1c2b22be23c3bebe9f08e61 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 16:18:31 +0200 Subject: [PATCH 39/74] fix(highlights_list): empty results --- apps/client/src/widgets/sidebar/HighlightsList.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/client/src/widgets/sidebar/HighlightsList.tsx b/apps/client/src/widgets/sidebar/HighlightsList.tsx index be1e170e1..ad634935f 100644 --- a/apps/client/src/widgets/sidebar/HighlightsList.tsx +++ b/apps/client/src/widgets/sidebar/HighlightsList.tsx @@ -137,7 +137,7 @@ function extractHighlightsFromTextEditor(editor: CKTextEditor) { if (!root) return []; for (const { item } of editor.model.createRangeIn(root).getWalker({ ignoreElementEnd: true })) { - if (!item.is('$textProxy')) continue; + if (!item.is('$textProxy') || !item.data.trim()) continue; const attrs: RawHighlight["attrs"] = { bold: item.hasAttribute('bold'), @@ -198,7 +198,7 @@ function extractHeadingsFromStaticHtml(el: HTMLElement | null) { let node: Node | null; while ((node = walker.nextNode())) { const el = node.parentElement; - if (!el || !node.textContent) continue; + if (!el || !node.textContent?.trim()) continue; const style = getComputedStyle(el); From ddb6b3ea8ae3129efe7c68523af5cc211d9ada4b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 16:29:35 +0200 Subject: [PATCH 40/74] feat(right_pane): store expansion state --- .../src/widgets/sidebar/HighlightsList.tsx | 2 +- .../widgets/sidebar/RightPanelContainer.tsx | 10 ++------- .../src/widgets/sidebar/RightPanelWidget.tsx | 21 ++++++++++++++++--- .../src/widgets/sidebar/TableOfContents.tsx | 2 +- apps/server/src/routes/api/options.ts | 3 ++- apps/server/src/services/options_init.ts | 1 + packages/commons/src/lib/options_interface.ts | 1 + 7 files changed, 26 insertions(+), 14 deletions(-) diff --git a/apps/client/src/widgets/sidebar/HighlightsList.tsx b/apps/client/src/widgets/sidebar/HighlightsList.tsx index ad634935f..ed73bac60 100644 --- a/apps/client/src/widgets/sidebar/HighlightsList.tsx +++ b/apps/client/src/widgets/sidebar/HighlightsList.tsx @@ -24,7 +24,7 @@ export default function HighlightsList() { const { isReadOnly } = useIsNoteReadOnly(note, noteContext); return ( - + {((noteType === "text" && isReadOnly) || (noteType === "doc")) && } {noteType === "text" && !isReadOnly && } diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index 1a837bd1d..a12bb447c 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -28,16 +28,10 @@ export default function RightPanelContainer() { return () => splitInstance.destroy(); }, []); - const items = [ - , - - ]; - - const sizesBeforeCollapse = useRef(new WeakMap()); - return (
    - {items} + +
    ); } diff --git a/apps/client/src/widgets/sidebar/RightPanelWidget.tsx b/apps/client/src/widgets/sidebar/RightPanelWidget.tsx index 7f2aceedf..6b3a56925 100644 --- a/apps/client/src/widgets/sidebar/RightPanelWidget.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelWidget.tsx @@ -2,17 +2,20 @@ import clsx from "clsx"; import { ComponentChildren } from "preact"; import { useContext, useRef, useState } from "preact/hooks"; +import { useTriliumOptionJson } from "../react/hooks"; import Icon from "../react/Icon"; import { ParentComponent } from "../react/react_utils"; interface RightPanelWidgetProps { + id: string; title: string; children: ComponentChildren; buttons?: ComponentChildren; } -export default function RightPanelWidget({ title, buttons, children }: RightPanelWidgetProps) { - const [ expanded, setExpanded ] = useState(true); +export default function RightPanelWidget({ id, title, buttons, children }: RightPanelWidgetProps) { + const [ rightPaneCollapsedItems, setRightPaneCollapsedItems ] = useTriliumOptionJson("rightPaneCollapsedItems"); + const [ expanded, setExpanded ] = useState(!rightPaneCollapsedItems.includes(id)); const containerRef = useRef(null); const parentComponent = useContext(ParentComponent); @@ -27,7 +30,19 @@ export default function RightPanelWidget({ title, buttons, children }: RightPane >
    setExpanded(!expanded)} + onClick={() => { + const newExpanded = !expanded; + setExpanded(newExpanded); + const rightPaneCollapsedItemsSet = new Set(rightPaneCollapsedItems); + if (newExpanded) { + rightPaneCollapsedItemsSet.delete(id); + } else { + rightPaneCollapsedItemsSet.add(id); + } + if (rightPaneCollapsedItemsSet.size !== rightPaneCollapsedItems.length) { + setRightPaneCollapsedItems(Array.from(rightPaneCollapsedItemsSet)); + } + }} > + {((noteType === "text" && isReadOnly) || (noteType === "doc")) && } {noteType === "text" && !isReadOnly && } diff --git a/apps/server/src/routes/api/options.ts b/apps/server/src/routes/api/options.ts index 0a6940cb7..e7377bdfd 100644 --- a/apps/server/src/routes/api/options.ts +++ b/apps/server/src/routes/api/options.ts @@ -51,8 +51,9 @@ const ALLOWED_OPTIONS = new Set([ "imageMaxWidthHeight", "imageJpegQuality", "leftPaneWidth", - "rightPaneWidth", "leftPaneVisible", + "rightPaneWidth", + "rightPaneCollapsedItems", "rightPaneVisible", "nativeTitleBarVisible", "headingStyle", diff --git a/apps/server/src/services/options_init.ts b/apps/server/src/services/options_init.ts index 30754ab11..e8ef3c694 100644 --- a/apps/server/src/services/options_init.ts +++ b/apps/server/src/services/options_init.ts @@ -105,6 +105,7 @@ const defaultOptions: DefaultOption[] = [ { name: "leftPaneVisible", value: "true", isSynced: false }, { name: "rightPaneWidth", value: "25", isSynced: false }, { name: "rightPaneVisible", value: "true", isSynced: false }, + { name: "rightPaneCollapsedItems", value: "[]", isSynced: false }, { name: "nativeTitleBarVisible", value: "false", isSynced: false }, { name: "eraseEntitiesAfterTimeInSeconds", value: "604800", isSynced: true }, // default is 7 days { name: "eraseEntitiesAfterTimeScale", value: "86400", isSynced: true }, // default 86400 seconds = Day diff --git a/packages/commons/src/lib/options_interface.ts b/packages/commons/src/lib/options_interface.ts index 4bf445c12..fc1564e33 100644 --- a/packages/commons/src/lib/options_interface.ts +++ b/packages/commons/src/lib/options_interface.ts @@ -77,6 +77,7 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions Date: Thu, 18 Dec 2025 16:31:43 +0200 Subject: [PATCH 41/74] chore(floating_buttons): revert changes due to new layout --- apps/client/src/layouts/desktop_layout.tsx | 2 +- .../widgets/FloatingButtonsDefinitions.tsx | 27 +++++++++---------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/apps/client/src/layouts/desktop_layout.tsx b/apps/client/src/layouts/desktop_layout.tsx index 013460723..af47fd478 100644 --- a/apps/client/src/layouts/desktop_layout.tsx +++ b/apps/client/src/layouts/desktop_layout.tsx @@ -146,7 +146,7 @@ export default class DesktopLayout { .optChild(isNewLayout, )) .optChild(!isNewLayout, ) .child(new WatchedFileUpdateStatusWidget()) - .child() + .optChild(!isNewLayout, ) .child( new ScrollingContainer() .filling() diff --git a/apps/client/src/widgets/FloatingButtonsDefinitions.tsx b/apps/client/src/widgets/FloatingButtonsDefinitions.tsx index 4cd6e1c5f..35dbc92ae 100644 --- a/apps/client/src/widgets/FloatingButtonsDefinitions.tsx +++ b/apps/client/src/widgets/FloatingButtonsDefinitions.tsx @@ -78,10 +78,8 @@ export const POPUP_HIDDEN_FLOATING_BUTTONS: FloatingButtonsList = [ ToggleReadOnlyButton ]; -const isNewLayout = isExperimentalFeatureEnabled("new-layout"); - function RefreshBackendLogButton({ note, parentComponent, noteContext, isDefaultViewMode }: FloatingButtonContext) { - const isEnabled = !isNewLayout && (note.noteId === "_backendLog" || note.type === "render") && isDefaultViewMode; + const isEnabled = (note.noteId === "_backendLog" || note.type === "render") && isDefaultViewMode; return isEnabled && (null); const isEnabled = ( - !isNewLayout - && ["mermaid", "canvas", "mindMap", "image"].includes(note?.type ?? "") + ["mermaid", "canvas", "mindMap", "image"].includes(note?.type ?? "") && note?.isContentAvailable() && isDefaultViewMode ); @@ -287,7 +284,7 @@ function CopyImageReferenceButton({ note, isDefaultViewMode }: FloatingButtonCon } function ExportImageButtons({ note, triggerEvent, isDefaultViewMode }: FloatingButtonContext) { - const isEnabled = !isNewLayout && ["mermaid", "mindMap"].includes(note?.type ?? "") + const isEnabled = ["mermaid", "mindMap"].includes(note?.type ?? "") && note?.isContentAvailable() && isDefaultViewMode; return isEnabled && ( <> @@ -308,7 +305,7 @@ function ExportImageButtons({ note, triggerEvent, isDefaultViewMode }: FloatingB function InAppHelpButton({ note }: FloatingButtonContext) { const helpUrl = getHelpUrlForNote(note); - const isEnabled = !!helpUrl && !isNewLayout; + const isEnabled = !!helpUrl; return isEnabled && ( 0; + const isEnabled = isDefaultViewMode && backlinkCount > 0; return (isEnabled &&
    Date: Thu, 18 Dec 2025 16:58:15 +0200 Subject: [PATCH 42/74] feat(floating_buttons): handle case when empty --- .../src/translations/en/translation.json | 4 ++ .../src/widgets/sidebar/HighlightsList.tsx | 5 +-- .../widgets/sidebar/RightPanelContainer.css | 17 +++++++ .../widgets/sidebar/RightPanelContainer.tsx | 44 ++++++++++++++----- .../src/widgets/sidebar/TableOfContents.tsx | 2 +- 5 files changed, 58 insertions(+), 14 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 0f1e12781..c7aeaf50c 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -2196,5 +2196,9 @@ "note_paths_other": "{{count}} paths", "note_paths_title": "Note paths", "code_note_switcher": "Change language mode" + }, + "right_pane": { + "empty_message": "Nothing to show for this note", + "empty_button": "Hide the panel" } } diff --git a/apps/client/src/widgets/sidebar/HighlightsList.tsx b/apps/client/src/widgets/sidebar/HighlightsList.tsx index ed73bac60..fa9919a32 100644 --- a/apps/client/src/widgets/sidebar/HighlightsList.tsx +++ b/apps/client/src/widgets/sidebar/HighlightsList.tsx @@ -1,4 +1,3 @@ -import { headingIsHorizontal } from "@excalidraw/excalidraw/element/heading"; import { CKTextEditor, ModelText } from "@triliumnext/ckeditor5"; import { useCallback, useEffect, useState } from "preact/hooks"; @@ -23,9 +22,9 @@ export default function HighlightsList() { const noteType = useNoteProperty(note, "type"); const { isReadOnly } = useIsNoteReadOnly(note, noteContext); - return ( + return (noteType === "text") && ( - {((noteType === "text" && isReadOnly) || (noteType === "doc")) && } + {noteType === "text" && isReadOnly && } {noteType === "text" && !isReadOnly && } ); diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.css b/apps/client/src/widgets/sidebar/RightPanelContainer.css index 4153db7c1..443fc35b8 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.css +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.css @@ -33,4 +33,21 @@ body.experimental-feature-new-layout #right-pane { .gutter-vertical + .card .card-header { padding-top: 0; } + + .no-items { + display: flex; + align-items: center; + justify-content: center; + flex-grow: 1; + flex-direction: column; + color: var(--muted-text-color); + + .bx { + font-size: 3em; + } + + button { + margin-top: 1em; + } + } } diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index a12bb447c..83a8d957f 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -2,18 +2,49 @@ import "./RightPanelContainer.css"; import Split from "@triliumnext/split.js"; -import { createContext } from "preact"; -import { useEffect, useRef } from "preact/hooks"; +import { useEffect } from "preact/hooks"; +import { t } from "../../services/i18n"; import options from "../../services/options"; import { DEFAULT_GUTTER_SIZE } from "../../services/resizer"; -import { clamp } from "../../services/utils"; +import Button from "../react/Button"; +import { useActiveNoteContext, useNoteProperty, useTriliumOptionBool } from "../react/hooks"; +import Icon from "../react/Icon"; import HighlightsList from "./HighlightsList"; import TableOfContents from "./TableOfContents"; const MIN_WIDTH_PERCENT = 5; export default function RightPanelContainer() { + useSplit(); + + const [ rightPaneVisible, setRightPaneVisible ] = useTriliumOptionBool("rightPaneVisible"); + const { note } = useActiveNoteContext(); + const noteType = useNoteProperty(note, "type"); + const items = [ + noteType === "text" || noteType === "doc" && , + noteType === "text" && + ].filter(Boolean); + + return ( +
    + {items.length > 0 ? ( + items + ) : ( +
    + + {t("right_pane.empty_message")} +
    + )} +
    + ); +} + +function useSplit() { // Split between right pane and the content pane. useEffect(() => { // We are intentionally omitting useTriliumOption to avoid re-render due to size change. @@ -27,11 +58,4 @@ export default function RightPanelContainer() { }); return () => splitInstance.destroy(); }, []); - - return ( -
    - - -
    - ); } diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index 48c5217e9..bbdde25f8 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -25,7 +25,7 @@ export default function TableOfContents() { const noteType = useNoteProperty(note, "type"); const { isReadOnly } = useIsNoteReadOnly(note, noteContext); - return ( + return (noteType === "text" || noteType === "doc") && ( {((noteType === "text" && isReadOnly) || (noteType === "doc")) && } {noteType === "text" && !isReadOnly && } From a986c84ce7586b76239ae45409eaf849ebb6b96e Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 16:59:40 +0200 Subject: [PATCH 43/74] chore(right_pane): remove redundant check for note type --- apps/client/src/widgets/sidebar/HighlightsList.tsx | 2 +- apps/client/src/widgets/sidebar/TableOfContents.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/client/src/widgets/sidebar/HighlightsList.tsx b/apps/client/src/widgets/sidebar/HighlightsList.tsx index fa9919a32..c3621d6f5 100644 --- a/apps/client/src/widgets/sidebar/HighlightsList.tsx +++ b/apps/client/src/widgets/sidebar/HighlightsList.tsx @@ -22,7 +22,7 @@ export default function HighlightsList() { const noteType = useNoteProperty(note, "type"); const { isReadOnly } = useIsNoteReadOnly(note, noteContext); - return (noteType === "text") && ( + return ( {noteType === "text" && isReadOnly && } {noteType === "text" && !isReadOnly && } diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index bbdde25f8..48c5217e9 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -25,7 +25,7 @@ export default function TableOfContents() { const noteType = useNoteProperty(note, "type"); const { isReadOnly } = useIsNoteReadOnly(note, noteContext); - return (noteType === "text" || noteType === "doc") && ( + return ( {((noteType === "text" && isReadOnly) || (noteType === "doc")) && } {noteType === "text" && !isReadOnly && } From 334c31e79da2a0845b04f29a38d0b5efb373b1cb Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Thu, 18 Dec 2025 17:00:11 +0200 Subject: [PATCH 44/74] fix(right_pane): table of contents no longer visible --- apps/client/src/widgets/sidebar/RightPanelContainer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index 83a8d957f..e10fae749 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -22,7 +22,7 @@ export default function RightPanelContainer() { const { note } = useActiveNoteContext(); const noteType = useNoteProperty(note, "type"); const items = [ - noteType === "text" || noteType === "doc" && , + (noteType === "text" || noteType === "doc") && , noteType === "text" && ].filter(Boolean); From d22583457f1f7074475c539bb328362393b67dee Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 19 Dec 2025 20:51:27 +0200 Subject: [PATCH 45/74] style(right_pane): left-align title --- apps/client/src/widgets/sidebar/RightPanelContainer.css | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.css b/apps/client/src/widgets/sidebar/RightPanelContainer.css index 443fc35b8..39c67fd9a 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.css +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.css @@ -11,6 +11,7 @@ body.experimental-feature-new-layout #right-pane { .card-header { padding-block: 0.2em; cursor: pointer; + justify-content: flex-start; .card-header-title { padding-inline: 0.5em; From 9acef4d502d601d1d656d007b2f16cdb9e5e6ae3 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 19 Dec 2025 21:05:21 +0200 Subject: [PATCH 46/74] feat(layout): button to toggle right pane on horizontal layout --- apps/client/src/layouts/desktop_layout.tsx | 2 ++ .../src/translations/en/translation.json | 3 ++- .../src/widgets/buttons/right_pane_toggle.tsx | 21 +++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 apps/client/src/widgets/buttons/right_pane_toggle.tsx diff --git a/apps/client/src/layouts/desktop_layout.tsx b/apps/client/src/layouts/desktop_layout.tsx index af47fd478..a4f60aa05 100644 --- a/apps/client/src/layouts/desktop_layout.tsx +++ b/apps/client/src/layouts/desktop_layout.tsx @@ -9,6 +9,7 @@ import CreatePaneButton from "../widgets/buttons/create_pane_button.js"; import GlobalMenu from "../widgets/buttons/global_menu.jsx"; import LeftPaneToggle from "../widgets/buttons/left_pane_toggle.js"; import MovePaneButton from "../widgets/buttons/move_pane_button.js"; +import RightPaneToggle from "../widgets/buttons/right_pane_toggle.jsx"; import CloseZenModeButton from "../widgets/close_zen_button.jsx"; import NoteList from "../widgets/collections/NoteList.jsx"; import ContentHeader from "../widgets/containers/content_header.js"; @@ -91,6 +92,7 @@ export default class DesktopLayout { .optChild(launcherPaneIsHorizontal, ) .child() .child(new TabRowWidget().class("full-width")) + .optChild(launcherPaneIsHorizontal && isNewLayout, ) .optChild(customTitleBarButtons, ) .css("height", "40px") .css("background-color", "var(--launcher-pane-background-color)") diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index c7aeaf50c..077d24fac 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -2199,6 +2199,7 @@ }, "right_pane": { "empty_message": "Nothing to show for this note", - "empty_button": "Hide the panel" + "empty_button": "Hide the panel", + "toggle": "Toggle right panel" } } diff --git a/apps/client/src/widgets/buttons/right_pane_toggle.tsx b/apps/client/src/widgets/buttons/right_pane_toggle.tsx new file mode 100644 index 000000000..53bf67ae2 --- /dev/null +++ b/apps/client/src/widgets/buttons/right_pane_toggle.tsx @@ -0,0 +1,21 @@ +import clsx from "clsx"; + +import { t } from "../../services/i18n"; +import ActionButton from "../react/ActionButton"; +import { useTriliumOptionBool } from "../react/hooks"; + +export default function RightPaneToggle() { + const [ rightPaneVisible, setRightPaneVisible ] = useTriliumOptionBool("rightPaneVisible"); + + return ( + setRightPaneVisible(!rightPaneVisible)} + /> + ); +} From 3d9efb23eca45f88fa49ba0ef741ade2a338719c Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 19 Dec 2025 21:11:46 +0200 Subject: [PATCH 47/74] chore(right_pane): make right pane collapsible --- .../widgets/sidebar/RightPanelContainer.css | 1 - .../widgets/sidebar/RightPanelContainer.tsx | 38 ++++++++++--------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.css b/apps/client/src/widgets/sidebar/RightPanelContainer.css index 39c67fd9a..9afce65e8 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.css +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.css @@ -1,5 +1,4 @@ body.experimental-feature-new-layout #right-pane { - width: 300px; display: flex; flex-direction: column; diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index e10fae749..bb15ee10a 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -16,37 +16,41 @@ import TableOfContents from "./TableOfContents"; const MIN_WIDTH_PERCENT = 5; export default function RightPanelContainer() { - useSplit(); - const [ rightPaneVisible, setRightPaneVisible ] = useTriliumOptionBool("rightPaneVisible"); + useSplit(rightPaneVisible); + const { note } = useActiveNoteContext(); const noteType = useNoteProperty(note, "type"); - const items = [ + const items = (rightPaneVisible ? [ (noteType === "text" || noteType === "doc") && , noteType === "text" && - ].filter(Boolean); + ] : []).filter(Boolean); return (
    - {items.length > 0 ? ( - items - ) : ( -
    - - {t("right_pane.empty_message")} -
    + {rightPaneVisible && ( + items.length > 0 ? ( + items + ) : ( +
    + + {t("right_pane.empty_message")} +
    + ) )}
    ); } -function useSplit() { +function useSplit(visible: boolean) { // Split between right pane and the content pane. useEffect(() => { + if (!visible) return; + // We are intentionally omitting useTriliumOption to avoid re-render due to size change. const rightPaneWidth = Math.max(MIN_WIDTH_PERCENT, options.getInt("rightPaneWidth") ?? MIN_WIDTH_PERCENT); const splitInstance = Split(["#center-pane", "#right-pane"], { @@ -57,5 +61,5 @@ function useSplit() { onDragEnd: (sizes) => options.save("rightPaneWidth", Math.round(sizes[1])) }); return () => splitInstance.destroy(); - }, []); + }, [ visible ]); } From 06ad0bfa9014b9050d9bc5c143b03e4abec9f5f7 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 19 Dec 2025 21:11:57 +0200 Subject: [PATCH 48/74] feat(hooks): react faster to setting options --- apps/client/src/widgets/react/hooks.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index eadbf12a6..9540cb8ad 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -168,6 +168,7 @@ export function useTriliumOption(name: OptionNames, needsRefresh?: boolean): [st const wrappedSetValue = useMemo(() => { return async (newValue: OptionValue) => { + setValue(String(newValue)); await options.save(name, newValue); if (needsRefresh) { From 45dd47d039824c966653cc976fda367328c3bdcf Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 19 Dec 2025 21:20:36 +0200 Subject: [PATCH 49/74] feat(layout): button to toggle right pane on vertical layout --- apps/client/src/layouts/desktop_layout.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/client/src/layouts/desktop_layout.tsx b/apps/client/src/layouts/desktop_layout.tsx index a4f60aa05..e8a43955a 100644 --- a/apps/client/src/layouts/desktop_layout.tsx +++ b/apps/client/src/layouts/desktop_layout.tsx @@ -118,8 +118,11 @@ export default class DesktopLayout { new FlexContainer("row") .child() .child(new TabRowWidget()) + .optChild(isNewLayout, ) .optChild(customTitleBarButtons, ) - .css("height", "40px")) + .css("height", "40px") + .css("align-items", "center") + ) .optChild(isNewLayout, ) .child( new FlexContainer("row") From 7d386c249af02c605693e0b07891a894dacd5f8d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 19 Dec 2025 22:23:46 +0200 Subject: [PATCH 50/74] fix(right_pane_widget): toggle button clipped on desktop --- apps/client/src/layouts/desktop_layout.tsx | 1 + apps/client/src/stylesheets/style.css | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/client/src/layouts/desktop_layout.tsx b/apps/client/src/layouts/desktop_layout.tsx index e8a43955a..601b0e643 100644 --- a/apps/client/src/layouts/desktop_layout.tsx +++ b/apps/client/src/layouts/desktop_layout.tsx @@ -116,6 +116,7 @@ export default class DesktopLayout { .css("flex-grow", "1") .optChild(!fullWidthTabBar, new FlexContainer("row") + .class("tab-row-container") .child() .child(new TabRowWidget()) .optChild(isNewLayout, ) diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css index f10ace694..5ea6776ff 100644 --- a/apps/client/src/stylesheets/style.css +++ b/apps/client/src/stylesheets/style.css @@ -1961,7 +1961,7 @@ body.electron.platform-darwin:not(.native-titlebar):not(.full-screen) #tab-row-l width: 80px; } -.tab-row-widget { +.tab-row-container { padding-inline-end: calc(100vw - env(titlebar-area-width, 100vw)); } From 01d4fa8afdbf37be28838aa69cc50f55078b7436 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 19 Dec 2025 22:26:17 +0200 Subject: [PATCH 51/74] chore(right_pane_widget): add padding to no items --- apps/client/src/widgets/sidebar/RightPanelContainer.css | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.css b/apps/client/src/widgets/sidebar/RightPanelContainer.css index 9afce65e8..3ec53abda 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.css +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.css @@ -40,6 +40,7 @@ body.experimental-feature-new-layout #right-pane { justify-content: center; flex-grow: 1; flex-direction: column; + padding: 0.75em; color: var(--muted-text-color); .bx { From 9d351ae479ddf6ea2e3ce1b4bfe51e3066f501d3 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 19 Dec 2025 22:33:26 +0200 Subject: [PATCH 52/74] chore(options/text_notes): adapt to new layout sidebar --- .../type_widgets/options/text_notes.tsx | 86 ++++++++++--------- 1 file changed, 47 insertions(+), 39 deletions(-) diff --git a/apps/client/src/widgets/type_widgets/options/text_notes.tsx b/apps/client/src/widgets/type_widgets/options/text_notes.tsx index 6179b5bc7..b17c2bb3d 100644 --- a/apps/client/src/widgets/type_widgets/options/text_notes.tsx +++ b/apps/client/src/widgets/type_widgets/options/text_notes.tsx @@ -1,24 +1,28 @@ -import { useEffect, useMemo, useState } from "preact/hooks"; -import { t } from "../../../services/i18n"; -import FormCheckbox from "../../react/FormCheckbox"; -import FormRadioGroup from "../../react/FormRadioGroup"; -import { useTriliumOption, useTriliumOptionBool, useTriliumOptionJson } from "../../react/hooks"; -import OptionsSection from "./components/OptionsSection"; -import { formatDateTime, toggleBodyClass } from "../../../services/utils"; -import FormGroup from "../../react/FormGroup"; -import Column from "../../react/Column"; -import { FormSelectGroup, FormSelectWithGroups } from "../../react/FormSelect"; -import { Themes } from "@triliumnext/highlightjs"; -import { ensureMimeTypesForHighlighting, loadHighlightingTheme } from "../../../services/syntax_highlight"; import { normalizeMimeTypeForCKEditor, type OptionNames } from "@triliumnext/commons"; -import { getHtml } from "../../react/RawHtml"; +import { Themes } from "@triliumnext/highlightjs"; import type { CSSProperties } from "preact/compat"; +import { useEffect, useMemo, useState } from "preact/hooks"; +import { Trans } from "react-i18next"; + +import { isExperimentalFeatureEnabled } from "../../../services/experimental_features"; +import { t } from "../../../services/i18n"; +import { ensureMimeTypesForHighlighting, loadHighlightingTheme } from "../../../services/syntax_highlight"; +import { formatDateTime, toggleBodyClass } from "../../../services/utils"; +import Column from "../../react/Column"; +import FormCheckbox from "../../react/FormCheckbox"; +import FormGroup from "../../react/FormGroup"; +import FormRadioGroup from "../../react/FormRadioGroup"; +import { FormSelectGroup, FormSelectWithGroups } from "../../react/FormSelect"; import FormText from "../../react/FormText"; import FormTextBox, { FormTextBoxWithUnit } from "../../react/FormTextBox"; -import CheckboxList from "./components/CheckboxList"; +import { useTriliumOption, useTriliumOptionBool, useTriliumOptionJson } from "../../react/hooks"; import KeyboardShortcut from "../../react/KeyboardShortcut"; -import { Trans } from "react-i18next"; +import { getHtml } from "../../react/RawHtml"; import AutoReadOnlySize from "./components/AutoReadOnlySize"; +import CheckboxList from "./components/CheckboxList"; +import OptionsSection from "./components/OptionsSection"; + +const isNewLayout = isExperimentalFeatureEnabled("new-layout"); export default function TextNoteSettings() { return ( @@ -32,7 +36,7 @@ export default function TextNoteSettings() { - ) + ); } function FormattingToolbar() { @@ -65,7 +69,7 @@ function FormattingToolbar() { containerStyle={{ marginInlineStart: "1em" }} /> - ) + ); } function EditorFeatures() { @@ -119,7 +123,7 @@ function CodeBlockStyle() { for (const [ id, theme ] of Object.entries(Themes)) { const data: ThemeData = { - val: "default:" + id, + val: `default:${ id}`, title: theme.name }; @@ -177,7 +181,7 @@ function CodeBlockStyle() { - ) + ); } const SAMPLE_LANGUAGE = normalizeMimeTypeForCKEditor("application/javascript;env=frontend"); @@ -219,9 +223,9 @@ function CodeBlockPreview({ theme, wordWrap }: { theme: string, wordWrap: boolea const codeStyle = useMemo(() => { if (wordWrap) { return { whiteSpace: "pre-wrap" }; - } else { - return { whiteSpace: "pre"}; } + return { whiteSpace: "pre"}; + }, [ wordWrap ]); return ( @@ -230,7 +234,7 @@ function CodeBlockPreview({ theme, wordWrap }: { theme: string, wordWrap: boolea
    - ) + ); } interface ThemeData { @@ -241,7 +245,7 @@ interface ThemeData { function TableOfContent() { const [ minTocHeadings, setMinTocHeadings ] = useTriliumOption("minTocHeadings"); - return ( + return (!isNewLayout && {t("table_of_contents.description")} @@ -257,7 +261,7 @@ function TableOfContent() { {t("table_of_contents.disable_info")} {t("table_of_contents.shortcut_info")} - ) + ); } function HighlightsList() { @@ -277,13 +281,17 @@ function HighlightsList() { keyProperty="val" titleProperty="title" currentValue={highlightsList} onChange={setHighlightsList} /> -
    -
    {t("highlights_list.visibility_title")}
    - {t("highlights_list.visibility_description")} - {t("highlights_list.shortcut_info")} + {!isNewLayout && ( + <> +
    +
    {t("highlights_list.visibility_title")}
    + {t("highlights_list.visibility_description")} + {t("highlights_list.shortcut_info")} + + )} - ) + ); } function DateTimeFormatOptions() { @@ -302,19 +310,19 @@ function DateTimeFormatOptions() {
    - - + - + /> + - -
    - {formatDateTime(new Date(), customDateTimeFormat)} -
    -
    + +
    + {formatDateTime(new Date(), customDateTimeFormat)} +
    +
    - ) + ); } From 7a5d24f9688d0aafdf539884d54fa2c6a0ac759f Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 19 Dec 2025 23:02:32 +0200 Subject: [PATCH 53/74] feat(right_pane_widget): options modal for highlight list --- .../src/translations/en/translation.json | 3 +- .../src/widgets/sidebar/HighlightsList.tsx | 43 +++++++++++++++++-- .../widgets/sidebar/RightPanelContainer.css | 1 + .../type_widgets/options/text_notes.tsx | 30 ++++++++----- 4 files changed, 61 insertions(+), 16 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 077d24fac..903cdc8b6 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1723,7 +1723,8 @@ }, "highlights_list_2": { "title": "Highlights List", - "options": "Options" + "options": "Options", + "modal_title": "Configure Highlights List" }, "quick-search": { "placeholder": "Quick search", diff --git a/apps/client/src/widgets/sidebar/HighlightsList.tsx b/apps/client/src/widgets/sidebar/HighlightsList.tsx index c3621d6f5..0eb3dcf03 100644 --- a/apps/client/src/widgets/sidebar/HighlightsList.tsx +++ b/apps/client/src/widgets/sidebar/HighlightsList.tsx @@ -1,8 +1,12 @@ import { CKTextEditor, ModelText } from "@triliumnext/ckeditor5"; +import { createPortal } from "preact/compat"; import { useCallback, useEffect, useState } from "preact/hooks"; import { t } from "../../services/i18n"; +import ActionButton from "../react/ActionButton"; import { useActiveNoteContext, useContentElement, useIsNoteReadOnly, useNoteProperty, useTextEditor } from "../react/hooks"; +import Modal from "../react/Modal"; +import { HighlightsListOptions } from "../type_widgets/options/text_notes"; import RightPanelWidget from "./RightPanelWidget"; interface RawHighlight { @@ -21,12 +25,43 @@ export default function HighlightsList() { const { note, noteContext } = useActiveNoteContext(); const noteType = useNoteProperty(note, "type"); const { isReadOnly } = useIsNoteReadOnly(note, noteContext); + const [ shown, setShown ] = useState(false); return ( - - {noteType === "text" && isReadOnly && } - {noteType === "text" && !isReadOnly && } - + <> + { + e.stopPropagation(); + setShown(true); + }} + /> + )} + > + {noteType === "text" && isReadOnly && } + {noteType === "text" && !isReadOnly && } + + {createPortal(, document.body)} + + ); +} + +function HighlightListOptionsModal({ shown, setShown }: { shown: boolean, setShown(value: boolean): void }) { + return ( + setShown(false)} + > + + ); } diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.css b/apps/client/src/widgets/sidebar/RightPanelContainer.css index 3ec53abda..595656c29 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.css +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.css @@ -14,6 +14,7 @@ body.experimental-feature-new-layout #right-pane { .card-header-title { padding-inline: 0.5em; + flex-grow: 1; } } diff --git a/apps/client/src/widgets/type_widgets/options/text_notes.tsx b/apps/client/src/widgets/type_widgets/options/text_notes.tsx index b17c2bb3d..7b6e15dda 100644 --- a/apps/client/src/widgets/type_widgets/options/text_notes.tsx +++ b/apps/client/src/widgets/type_widgets/options/text_notes.tsx @@ -265,10 +265,27 @@ function TableOfContent() { } function HighlightsList() { + return ( + + + + {!isNewLayout && ( + <> +
    +
    {t("highlights_list.visibility_title")}
    + {t("highlights_list.visibility_description")} + {t("highlights_list.shortcut_info")} + + )} +
    + ); +} + +export function HighlightsListOptions() { const [ highlightsList, setHighlightsList ] = useTriliumOptionJson("highlightsList"); return ( - + <> {t("highlights_list.description")} - - {!isNewLayout && ( - <> -
    -
    {t("highlights_list.visibility_title")}
    - {t("highlights_list.visibility_description")} - {t("highlights_list.shortcut_info")} - - )} -
    + ); } From e94704ce64977dfb2dd14636d25f8b36d9d2edc6 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 19 Dec 2025 23:18:28 +0200 Subject: [PATCH 54/74] chore(right_pane_widget): respect highlight settings --- .../src/widgets/sidebar/HighlightsList.tsx | 48 ++++++++++++------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/apps/client/src/widgets/sidebar/HighlightsList.tsx b/apps/client/src/widgets/sidebar/HighlightsList.tsx index 0eb3dcf03..ed00d47dd 100644 --- a/apps/client/src/widgets/sidebar/HighlightsList.tsx +++ b/apps/client/src/widgets/sidebar/HighlightsList.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from "preact/hooks"; import { t } from "../../services/i18n"; import ActionButton from "../react/ActionButton"; -import { useActiveNoteContext, useContentElement, useIsNoteReadOnly, useNoteProperty, useTextEditor } from "../react/hooks"; +import { useActiveNoteContext, useContentElement, useIsNoteReadOnly, useNoteProperty, useTextEditor, useTriliumOptionJson } from "../react/hooks"; import Modal from "../react/Modal"; import { HighlightsListOptions } from "../type_widgets/options/text_notes"; import RightPanelWidget from "./RightPanelWidget"; @@ -69,25 +69,39 @@ function AbstractHighlightsList({ highlights, scrollToHi highlights: T[], scrollToHighlight(highlight: T): void; }) { + const [ highlightsList ] = useTriliumOptionJson<["bold" | "italic" | "underline" | "color" | "bgColor"]>("highlightsList"); + const highlightsListSet = new Set(highlightsList || []); + return (
      - {highlights.map(highlight => ( -
    1. scrollToHighlight(highlight)} - > - {highlight.text} -
    2. - ))} + {highlights + .filter(highlight => { + const { attrs } = highlight; + return ( + (highlightsListSet.has("bold") && attrs.bold) || + (highlightsListSet.has("italic") && attrs.italic) || + (highlightsListSet.has("underline") && attrs.underline) || + (highlightsListSet.has("color") && !!attrs.color) || + (highlightsListSet.has("bgColor") && !!attrs.background) + ); + }) + .map(highlight => ( +
    3. scrollToHighlight(highlight)} + > + {highlight.text} +
    4. + ))}
    ); From c0cd9e36d9078fda9ce67468b0de6aae01dffdea Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 19 Dec 2025 23:25:58 +0200 Subject: [PATCH 55/74] feat(right_pane_widget): hide highlights if disabled in settings --- apps/client/src/widgets/sidebar/RightPanelContainer.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index bb15ee10a..3422ebf36 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -8,7 +8,7 @@ import { t } from "../../services/i18n"; import options from "../../services/options"; import { DEFAULT_GUTTER_SIZE } from "../../services/resizer"; import Button from "../react/Button"; -import { useActiveNoteContext, useNoteProperty, useTriliumOptionBool } from "../react/hooks"; +import { useActiveNoteContext, useNoteProperty, useTriliumOptionBool, useTriliumOptionJson } from "../react/hooks"; import Icon from "../react/Icon"; import HighlightsList from "./HighlightsList"; import TableOfContents from "./TableOfContents"; @@ -17,13 +17,14 @@ const MIN_WIDTH_PERCENT = 5; export default function RightPanelContainer() { const [ rightPaneVisible, setRightPaneVisible ] = useTriliumOptionBool("rightPaneVisible"); + const [ highlightsList ] = useTriliumOptionJson("highlightsList"); useSplit(rightPaneVisible); const { note } = useActiveNoteContext(); const noteType = useNoteProperty(note, "type"); const items = (rightPaneVisible ? [ (noteType === "text" || noteType === "doc") && , - noteType === "text" && + noteType === "text" && highlightsList.length > 0 && ] : []).filter(Boolean); return ( From fad6414e1da671c700d9dff7af5bf45d8774c2d3 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 19 Dec 2025 23:29:52 +0200 Subject: [PATCH 56/74] feat(right_pane_widget): handle zero highlights --- .../src/translations/en/translation.json | 3 +- .../src/widgets/sidebar/HighlightsList.tsx | 33 +++++++++++-------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 903cdc8b6..fc3bd5879 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1724,7 +1724,8 @@ "highlights_list_2": { "title": "Highlights List", "options": "Options", - "modal_title": "Configure Highlights List" + "modal_title": "Configure Highlights List", + "no_highlights": "No highlights found." }, "quick-search": { "placeholder": "Quick search", diff --git a/apps/client/src/widgets/sidebar/HighlightsList.tsx b/apps/client/src/widgets/sidebar/HighlightsList.tsx index ed00d47dd..7c3605361 100644 --- a/apps/client/src/widgets/sidebar/HighlightsList.tsx +++ b/apps/client/src/widgets/sidebar/HighlightsList.tsx @@ -71,22 +71,22 @@ function AbstractHighlightsList({ highlights, scrollToHi }) { const [ highlightsList ] = useTriliumOptionJson<["bold" | "italic" | "underline" | "color" | "bgColor"]>("highlightsList"); const highlightsListSet = new Set(highlightsList || []); + const filteredHighlights = highlights.filter(highlight => { + const { attrs } = highlight; + return ( + (highlightsListSet.has("bold") && attrs.bold) || + (highlightsListSet.has("italic") && attrs.italic) || + (highlightsListSet.has("underline") && attrs.underline) || + (highlightsListSet.has("color") && !!attrs.color) || + (highlightsListSet.has("bgColor") && !!attrs.background) + ); + }); return ( -
      - {highlights - .filter(highlight => { - const { attrs } = highlight; - return ( - (highlightsListSet.has("bold") && attrs.bold) || - (highlightsListSet.has("italic") && attrs.italic) || - (highlightsListSet.has("underline") && attrs.underline) || - (highlightsListSet.has("color") && !!attrs.color) || - (highlightsListSet.has("bgColor") && !!attrs.background) - ); - }) - .map(highlight => ( + {filteredHighlights.length > 0 ? ( +
        + {filteredHighlights.map(highlight => (
      1. scrollToHighlight(highlight)} @@ -102,7 +102,12 @@ function AbstractHighlightsList({ highlights, scrollToHi >{highlight.text}
      2. ))} -
      +
    + ) : ( +
    + {t("highlights_list_2.no_highlights")} +
    + )}
    ); } From 6da42fac20c73567ceb235e1bcbd121f82f53bf8 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 19 Dec 2025 23:32:58 +0200 Subject: [PATCH 57/74] feat(right_pane_widget): handle zero headings --- apps/client/src/translations/en/translation.json | 3 ++- apps/client/src/widgets/sidebar/TableOfContents.tsx | 10 +++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index fc3bd5879..458ea6b0c 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1796,7 +1796,8 @@ }, "toc": { "table_of_contents": "Table of Contents", - "options": "Options" + "options": "Options", + "no_headings": "No headings." }, "watched_file_update_status": { "file_last_modified": "File has been last modified on .", diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index 48c5217e9..b58c2d8c4 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -40,9 +40,13 @@ function AbstractTableOfContents({ headings, scrollToHeadi const nestedHeadings = buildHeadingTree(headings); return ( -
      - {nestedHeadings.map(heading => )} -
    + {nestedHeadings.length > 0 ? ( +
      + {nestedHeadings.map(heading => )} +
    + ) : ( +
    {t("toc.no_headings")}
    + )}
    ); } From a5f322617d678a4febe5451b1064da6152de6a34 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Fri, 19 Dec 2025 23:44:19 +0200 Subject: [PATCH 58/74] chore(script): remove node-detail-pane --- apps/client/src/layouts/desktop_layout.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/client/src/layouts/desktop_layout.tsx b/apps/client/src/layouts/desktop_layout.tsx index 601b0e643..3191f5030 100644 --- a/apps/client/src/layouts/desktop_layout.tsx +++ b/apps/client/src/layouts/desktop_layout.tsx @@ -172,10 +172,7 @@ export default class DesktopLayout { ) .child() .child(new FindWidget()) - .child( - ...this.customWidgets.get("node-detail-pane"), // typo, let's keep it for a while as BC - ...this.customWidgets.get("note-detail-pane") - ) + .child(...this.customWidgets.get("note-detail-pane")) ) ) .child(...this.customWidgets.get("center-pane")) From 8f1614f603d7b09dce5ce637242f9036e44eebcf Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 20 Dec 2025 00:01:21 +0200 Subject: [PATCH 59/74] chore(right_pane_widget): basic support for custom widgets --- apps/client/src/layouts/desktop_layout.tsx | 2 +- .../src/widgets/sidebar/RightPanelContainer.tsx | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/client/src/layouts/desktop_layout.tsx b/apps/client/src/layouts/desktop_layout.tsx index 3191f5030..30b62a3b1 100644 --- a/apps/client/src/layouts/desktop_layout.tsx +++ b/apps/client/src/layouts/desktop_layout.tsx @@ -184,7 +184,7 @@ export default class DesktopLayout { .child(new HighlightsListWidget()) .child(...this.customWidgets.get("right-pane")) ) - .optChild(isNewLayout, ) + .optChild(isNewLayout, ) ) .optChild(!launcherPaneIsHorizontal && isNewLayout, ) ) diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index 3422ebf36..85010e850 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -7,15 +7,16 @@ import { useEffect } from "preact/hooks"; import { t } from "../../services/i18n"; import options from "../../services/options"; import { DEFAULT_GUTTER_SIZE } from "../../services/resizer"; +import BasicWidget from "../basic_widget"; import Button from "../react/Button"; -import { useActiveNoteContext, useNoteProperty, useTriliumOptionBool, useTriliumOptionJson } from "../react/hooks"; +import { useActiveNoteContext, useLegacyWidget, useNoteProperty, useTriliumOptionBool, useTriliumOptionJson } from "../react/hooks"; import Icon from "../react/Icon"; import HighlightsList from "./HighlightsList"; import TableOfContents from "./TableOfContents"; const MIN_WIDTH_PERCENT = 5; -export default function RightPanelContainer() { +export default function RightPanelContainer({ customWidgets }: { customWidgets: BasicWidget[] }) { const [ rightPaneVisible, setRightPaneVisible ] = useTriliumOptionBool("rightPaneVisible"); const [ highlightsList ] = useTriliumOptionJson("highlightsList"); useSplit(rightPaneVisible); @@ -24,7 +25,8 @@ export default function RightPanelContainer() { const noteType = useNoteProperty(note, "type"); const items = (rightPaneVisible ? [ (noteType === "text" || noteType === "doc") && , - noteType === "text" && highlightsList.length > 0 && + noteType === "text" && highlightsList.length > 0 && , + ...customWidgets.map((w) => ) ] : []).filter(Boolean); return ( @@ -64,3 +66,8 @@ function useSplit(visible: boolean) { return () => splitInstance.destroy(); }, [ visible ]); } + +function CustomWidget({ originalWidget }: { originalWidget: BasicWidget }) { + const [ el ] = useLegacyWidget(() => originalWidget); + return <>{el}; +} From aac4316fb85e6be71102e5871a1563b6c2e90c41 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 20 Dec 2025 10:33:28 +0200 Subject: [PATCH 60/74] feat(right_pane): render title bar --- apps/client/src/widgets/react/hooks.tsx | 17 ++++---- .../widgets/sidebar/RightPanelContainer.tsx | 39 +++++++++++++++++-- .../src/widgets/sidebar/RightPanelWidget.tsx | 11 +++--- 3 files changed, 51 insertions(+), 16 deletions(-) diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 9540cb8ad..d6b0db34c 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -605,10 +605,11 @@ export function useNoteBlob(note: FNote | null | undefined, componentId?: string return blob; } -export function useLegacyWidget(widgetFactory: () => T, { noteContext, containerClassName, containerStyle }: { +export function useLegacyWidget(widgetFactory: () => T, { noteContext, containerClassName, containerStyle, noAttach }: { noteContext?: NoteContext; containerClassName?: string; containerStyle?: CSSProperties; + noAttach?: boolean; } = {}): [VNode, T] { const ref = useRef(null); const parentComponent = useContext(ParentComponent); @@ -627,22 +628,24 @@ export function useLegacyWidget(widgetFactory: () => T, { const renderedWidget = widget.render(); return [ widget, renderedWidget ]; - }, []); + }, [ noteContext, parentComponent, widgetFactory]); // Attach the widget to the parent. useEffect(() => { - if (ref.current) { - ref.current.innerHTML = ""; - renderedWidget.appendTo(ref.current); + if (noAttach) return; + const parentContainer = ref.current; + if (parentContainer) { + parentContainer.replaceChildren(); + renderedWidget.appendTo(parentContainer); } - }, [ renderedWidget ]); + }, [ renderedWidget, noAttach ]); // Inject the note context. useEffect(() => { if (noteContext && widget instanceof NoteContextAwareWidget) { widget.activeContextChangedEvent({ noteContext }); } - }, [ noteContext ]); + }, [ noteContext, widget ]); useDebugValue(widget); diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index 85010e850..fd339563a 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -2,7 +2,7 @@ import "./RightPanelContainer.css"; import Split from "@triliumnext/split.js"; -import { useEffect } from "preact/hooks"; +import { useEffect, useRef } from "preact/hooks"; import { t } from "../../services/i18n"; import options from "../../services/options"; @@ -11,7 +11,9 @@ import BasicWidget from "../basic_widget"; import Button from "../react/Button"; import { useActiveNoteContext, useLegacyWidget, useNoteProperty, useTriliumOptionBool, useTriliumOptionJson } from "../react/hooks"; import Icon from "../react/Icon"; +import LegacyRightPanelWidget from "../right_panel_widget"; import HighlightsList from "./HighlightsList"; +import RightPanelWidget from "./RightPanelWidget"; import TableOfContents from "./TableOfContents"; const MIN_WIDTH_PERCENT = 5; @@ -67,7 +69,36 @@ function useSplit(visible: boolean) { }, [ visible ]); } -function CustomWidget({ originalWidget }: { originalWidget: BasicWidget }) { - const [ el ] = useLegacyWidget(() => originalWidget); - return <>{el}; +function CustomWidget({ originalWidget }: { originalWidget: LegacyRightPanelWidget }) { + const containerRef = useRef(null); + const [ el ] = useLegacyWidget(() => { + // Monkey-patch the original widget by replacing the default initialization logic. + originalWidget.doRender = function doRender(this: LegacyRightPanelWidget) { + if (!containerRef.current) { + this.$widget = $("
    "); + return; + }; + this.$widget = $(containerRef.current); + this.$body = this.$widget.find(".card-body"); + const renderResult = this.doRenderBody(); + if (typeof renderResult === "object" && "catch" in renderResult) { + this.initialized = renderResult.catch((e) => { + this.logRenderingError(e); + }); + } else { + this.initialized = Promise.resolve(); + } + }; + + return originalWidget; + }, { + noAttach: true + }); + return ( + {el} + ); } diff --git a/apps/client/src/widgets/sidebar/RightPanelWidget.tsx b/apps/client/src/widgets/sidebar/RightPanelWidget.tsx index 6b3a56925..a64b636d6 100644 --- a/apps/client/src/widgets/sidebar/RightPanelWidget.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelWidget.tsx @@ -1,8 +1,8 @@ import clsx from "clsx"; -import { ComponentChildren } from "preact"; -import { useContext, useRef, useState } from "preact/hooks"; +import { ComponentChildren, RefObject } from "preact"; +import { useContext, useState } from "preact/hooks"; -import { useTriliumOptionJson } from "../react/hooks"; +import { useSyncedRef, useTriliumOptionJson } from "../react/hooks"; import Icon from "../react/Icon"; import { ParentComponent } from "../react/react_utils"; @@ -11,12 +11,13 @@ interface RightPanelWidgetProps { title: string; children: ComponentChildren; buttons?: ComponentChildren; + containerRef?: RefObject; } -export default function RightPanelWidget({ id, title, buttons, children }: RightPanelWidgetProps) { +export default function RightPanelWidget({ id, title, buttons, children, containerRef: externalContainerRef }: RightPanelWidgetProps) { const [ rightPaneCollapsedItems, setRightPaneCollapsedItems ] = useTriliumOptionJson("rightPaneCollapsedItems"); const [ expanded, setExpanded ] = useState(!rightPaneCollapsedItems.includes(id)); - const containerRef = useRef(null); + const containerRef = useSyncedRef(externalContainerRef, null); const parentComponent = useContext(ParentComponent); if (parentComponent) { From e1df65adce143c277c483b89f56351a076c3dcff Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 20 Dec 2025 11:09:59 +0200 Subject: [PATCH 61/74] fix(right_pane): custom widgets not rendering after being expanded --- apps/client/src/widgets/react/hooks.tsx | 6 ++-- .../widgets/sidebar/RightPanelContainer.tsx | 34 +++++++++++-------- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index d6b0db34c..898e425ad 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -605,11 +605,10 @@ export function useNoteBlob(note: FNote | null | undefined, componentId?: string return blob; } -export function useLegacyWidget(widgetFactory: () => T, { noteContext, containerClassName, containerStyle, noAttach }: { +export function useLegacyWidget(widgetFactory: () => T, { noteContext, containerClassName, containerStyle }: { noteContext?: NoteContext; containerClassName?: string; containerStyle?: CSSProperties; - noAttach?: boolean; } = {}): [VNode, T] { const ref = useRef(null); const parentComponent = useContext(ParentComponent); @@ -632,13 +631,12 @@ export function useLegacyWidget(widgetFactory: () => T, { // Attach the widget to the parent. useEffect(() => { - if (noAttach) return; const parentContainer = ref.current; if (parentContainer) { parentContainer.replaceChildren(); renderedWidget.appendTo(parentContainer); } - }, [ renderedWidget, noAttach ]); + }); // Inject the note context. useEffect(() => { diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index fd339563a..21d10cd99 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -71,15 +71,26 @@ function useSplit(visible: boolean) { function CustomWidget({ originalWidget }: { originalWidget: LegacyRightPanelWidget }) { const containerRef = useRef(null); + + return ( + + + + ); +} + +function CustomWidgetContent({ originalWidget }: { originalWidget: LegacyRightPanelWidget }) { const [ el ] = useLegacyWidget(() => { + originalWidget.contentSized(); + // Monkey-patch the original widget by replacing the default initialization logic. originalWidget.doRender = function doRender(this: LegacyRightPanelWidget) { - if (!containerRef.current) { - this.$widget = $("
    "); - return; - }; - this.$widget = $(containerRef.current); - this.$body = this.$widget.find(".card-body"); + this.$widget = $("
    "); + this.$body = this.$widget; const renderResult = this.doRenderBody(); if (typeof renderResult === "object" && "catch" in renderResult) { this.initialized = renderResult.catch((e) => { @@ -91,14 +102,7 @@ function CustomWidget({ originalWidget }: { originalWidget: LegacyRightPanelWidg }; return originalWidget; - }, { - noAttach: true }); - return ( - {el} - ); + + return el; } From e82e92c22ca018456f776ec8b94cb89ab11a5483 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 20 Dec 2025 11:16:41 +0200 Subject: [PATCH 62/74] fix(right_pane): custom widgets not aware of note context --- apps/client/src/widgets/sidebar/RightPanelContainer.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index 21d10cd99..31743a446 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -84,6 +84,7 @@ function CustomWidget({ originalWidget }: { originalWidget: LegacyRightPanelWidg } function CustomWidgetContent({ originalWidget }: { originalWidget: LegacyRightPanelWidget }) { + const { noteContext } = useActiveNoteContext(); const [ el ] = useLegacyWidget(() => { originalWidget.contentSized(); @@ -102,6 +103,8 @@ function CustomWidgetContent({ originalWidget }: { originalWidget: LegacyRightPa }; return originalWidget; + }, { + noteContext }); return el; From eeea96b98c1dd4972333cf672d248e38811363c0 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 20 Dec 2025 11:17:29 +0200 Subject: [PATCH 63/74] chore(right_pane): missing key for custom widgets --- apps/client/src/widgets/sidebar/RightPanelContainer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index 31743a446..498013c1e 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -28,7 +28,7 @@ export default function RightPanelContainer({ customWidgets }: { customWidgets: const items = (rightPaneVisible ? [ (noteType === "text" || noteType === "doc") && , noteType === "text" && highlightsList.length > 0 && , - ...customWidgets.map((w) => ) + ...customWidgets.map((w) => ) ] : []).filter(Boolean); return ( From dced799976996f83baba6380af4dabbf1c490b7f Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 20 Dec 2025 11:39:46 +0200 Subject: [PATCH 64/74] feat(right_pane): add context menu with go to source for custom widgets --- .../src/translations/en/translation.json | 3 ++- .../widgets/sidebar/RightPanelContainer.tsx | 8 +++++++ .../src/widgets/sidebar/RightPanelWidget.tsx | 24 +++++++++++++++++-- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 458ea6b0c..a9ba89461 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -2203,6 +2203,7 @@ "right_pane": { "empty_message": "Nothing to show for this note", "empty_button": "Hide the panel", - "toggle": "Toggle right panel" + "toggle": "Toggle right panel", + "custom_widget_go_to_source": "Go to source code" } } diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index 498013c1e..c49dc15cf 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -4,6 +4,7 @@ import "./RightPanelContainer.css"; import Split from "@triliumnext/split.js"; import { useEffect, useRef } from "preact/hooks"; +import appContext from "../../components/app_context"; import { t } from "../../services/i18n"; import options from "../../services/options"; import { DEFAULT_GUTTER_SIZE } from "../../services/resizer"; @@ -77,6 +78,13 @@ function CustomWidget({ originalWidget }: { originalWidget: LegacyRightPanelWidg id={originalWidget._noteId} title={originalWidget.widgetTitle} containerRef={containerRef} + contextMenuItems={[ + { + title: t("right_pane.custom_widget_go_to_source"), + uiIcon: "bx bx-code-curly", + handler: () => appContext.tabManager.openInNewTab(originalWidget._noteId, null, true) + } + ]} > diff --git a/apps/client/src/widgets/sidebar/RightPanelWidget.tsx b/apps/client/src/widgets/sidebar/RightPanelWidget.tsx index a64b636d6..42d569d64 100644 --- a/apps/client/src/widgets/sidebar/RightPanelWidget.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelWidget.tsx @@ -2,6 +2,8 @@ import clsx from "clsx"; import { ComponentChildren, RefObject } from "preact"; import { useContext, useState } from "preact/hooks"; +import contextMenu, { MenuItem } from "../../menus/context_menu"; +import ActionButton from "../react/ActionButton"; import { useSyncedRef, useTriliumOptionJson } from "../react/hooks"; import Icon from "../react/Icon"; import { ParentComponent } from "../react/react_utils"; @@ -12,9 +14,10 @@ interface RightPanelWidgetProps { children: ComponentChildren; buttons?: ComponentChildren; containerRef?: RefObject; + contextMenuItems?: MenuItem[]; } -export default function RightPanelWidget({ id, title, buttons, children, containerRef: externalContainerRef }: RightPanelWidgetProps) { +export default function RightPanelWidget({ id, title, buttons, children, containerRef: externalContainerRef, contextMenuItems }: RightPanelWidgetProps) { const [ rightPaneCollapsedItems, setRightPaneCollapsedItems ] = useTriliumOptionJson("rightPaneCollapsedItems"); const [ expanded, setExpanded ] = useState(!rightPaneCollapsedItems.includes(id)); const containerRef = useSyncedRef(externalContainerRef, null); @@ -49,7 +52,24 @@ export default function RightPanelWidget({ id, title, buttons, children, contain icon="bx bx-chevron-down" />
    {title}
    -
    {buttons}
    +
    + {buttons} + {contextMenuItems && ( + { + e.stopPropagation(); + contextMenu.show({ + x: e.pageX, + y: e.pageY, + items: contextMenuItems, + selectMenuItemHandler: () => {} + }); + }} + /> + )} +
    From a0577dc20279decf137ad6dcd701d363bd511c8a Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 20 Dec 2025 11:42:21 +0200 Subject: [PATCH 65/74] chore(right_pane): use menu instead of button for highlights list --- .../client/src/translations/en/translation.json | 1 + .../src/widgets/sidebar/HighlightsList.tsx | 17 +++++++---------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index a9ba89461..76d674985 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1725,6 +1725,7 @@ "title": "Highlights List", "options": "Options", "modal_title": "Configure Highlights List", + "menu_configure": "Configure highlights list...", "no_highlights": "No highlights found." }, "quick-search": { diff --git a/apps/client/src/widgets/sidebar/HighlightsList.tsx b/apps/client/src/widgets/sidebar/HighlightsList.tsx index 7c3605361..e93db959c 100644 --- a/apps/client/src/widgets/sidebar/HighlightsList.tsx +++ b/apps/client/src/widgets/sidebar/HighlightsList.tsx @@ -32,16 +32,13 @@ export default function HighlightsList() { { - e.stopPropagation(); - setShown(true); - }} - /> - )} + contextMenuItems={[ + { + title: t("highlights_list_2.menu_configure"), + uiIcon: "bx bx-cog", + handler: () => setShown(true) + } + ]} > {noteType === "text" && isReadOnly && } {noteType === "text" && !isReadOnly && } From cd49c365290f431556fb67aabddaf5c48c096517 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 20 Dec 2025 11:45:08 +0200 Subject: [PATCH 66/74] chore(right_pane): decrease context menu size slightly --- apps/client/src/widgets/sidebar/RightPanelContainer.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.css b/apps/client/src/widgets/sidebar/RightPanelContainer.css index 595656c29..7e3072d42 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.css +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.css @@ -8,9 +8,10 @@ body.experimental-feature-new-layout #right-pane { border-radius: 0; .card-header { - padding-block: 0.2em; + padding: 0; cursor: pointer; justify-content: flex-start; + --icon-button-size: 26px; .card-header-title { padding-inline: 0.5em; From bc8c852a4d6e90e641b0d089cd08343d47e9ff7e Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 20 Dec 2025 11:48:47 +0200 Subject: [PATCH 67/74] chore(right_pane): align collapse icon with menu item --- apps/client/src/widgets/sidebar/RightPanelContainer.css | 9 +++++++-- apps/client/src/widgets/sidebar/RightPanelWidget.tsx | 4 +--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.css b/apps/client/src/widgets/sidebar/RightPanelContainer.css index 7e3072d42..ce33b8430 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.css +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.css @@ -8,17 +8,22 @@ body.experimental-feature-new-layout #right-pane { border-radius: 0; .card-header { - padding: 0; + padding: 1px 0 0 0; cursor: pointer; justify-content: flex-start; --icon-button-size: 26px; .card-header-title { - padding-inline: 0.5em; + padding-inline: 0; flex-grow: 1; } } + .card-header-buttons { + transform: none; + top: 0; + } + &:last-of-type { border-bottom: 0; } diff --git a/apps/client/src/widgets/sidebar/RightPanelWidget.tsx b/apps/client/src/widgets/sidebar/RightPanelWidget.tsx index 42d569d64..3c3c600d7 100644 --- a/apps/client/src/widgets/sidebar/RightPanelWidget.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelWidget.tsx @@ -48,9 +48,7 @@ export default function RightPanelWidget({ id, title, buttons, children, contain } }} > - +
    {title}
    {buttons} From 2b827991ef48396562b1a478ac7e9f0e2768ebe4 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 20 Dec 2025 11:52:40 +0200 Subject: [PATCH 68/74] feat(right_pane): only grow table of contents & highlights --- apps/client/src/widgets/sidebar/HighlightsList.tsx | 1 + apps/client/src/widgets/sidebar/RightPanelContainer.css | 2 +- apps/client/src/widgets/sidebar/RightPanelWidget.tsx | 8 ++++++-- apps/client/src/widgets/sidebar/TableOfContents.tsx | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/client/src/widgets/sidebar/HighlightsList.tsx b/apps/client/src/widgets/sidebar/HighlightsList.tsx index e93db959c..2fb19e38b 100644 --- a/apps/client/src/widgets/sidebar/HighlightsList.tsx +++ b/apps/client/src/widgets/sidebar/HighlightsList.tsx @@ -39,6 +39,7 @@ export default function HighlightsList() { handler: () => setShown(true) } ]} + grow > {noteType === "text" && isReadOnly && } {noteType === "text" && !isReadOnly && } diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.css b/apps/client/src/widgets/sidebar/RightPanelContainer.css index ce33b8430..4e1a92147 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.css +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.css @@ -33,7 +33,7 @@ body.experimental-feature-new-layout #right-pane { } } - .card:not(.collapsed) { + .card.grow:not(.collapsed) { flex-grow: 1; } diff --git a/apps/client/src/widgets/sidebar/RightPanelWidget.tsx b/apps/client/src/widgets/sidebar/RightPanelWidget.tsx index 3c3c600d7..4cb8aedf3 100644 --- a/apps/client/src/widgets/sidebar/RightPanelWidget.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelWidget.tsx @@ -15,9 +15,10 @@ interface RightPanelWidgetProps { buttons?: ComponentChildren; containerRef?: RefObject; contextMenuItems?: MenuItem[]; + grow?: boolean; } -export default function RightPanelWidget({ id, title, buttons, children, containerRef: externalContainerRef, contextMenuItems }: RightPanelWidgetProps) { +export default function RightPanelWidget({ id, title, buttons, children, containerRef: externalContainerRef, contextMenuItems, grow }: RightPanelWidgetProps) { const [ rightPaneCollapsedItems, setRightPaneCollapsedItems ] = useTriliumOptionJson("rightPaneCollapsedItems"); const [ expanded, setExpanded ] = useState(!rightPaneCollapsedItems.includes(id)); const containerRef = useSyncedRef(externalContainerRef, null); @@ -30,7 +31,10 @@ export default function RightPanelWidget({ id, title, buttons, children, contain return (
    + {((noteType === "text" && isReadOnly) || (noteType === "doc")) && } {noteType === "text" && !isReadOnly && } From 35afd60d0083e7d8cbb32a358c3112bc2855bb7b Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 20 Dec 2025 12:17:14 +0200 Subject: [PATCH 69/74] feat(right_pane): respect position --- .../widgets/sidebar/RightPanelContainer.tsx | 47 +++++++++++++++---- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx index c49dc15cf..c79560457 100644 --- a/apps/client/src/widgets/sidebar/RightPanelContainer.tsx +++ b/apps/client/src/widgets/sidebar/RightPanelContainer.tsx @@ -2,6 +2,7 @@ import "./RightPanelContainer.css"; import Split from "@triliumnext/split.js"; +import { VNode } from "preact"; import { useEffect, useRef } from "preact/hooks"; import appContext from "../../components/app_context"; @@ -19,19 +20,17 @@ import TableOfContents from "./TableOfContents"; const MIN_WIDTH_PERCENT = 5; +interface RightPanelWidgetDefinition { + el: VNode; + enabled: boolean; + position: number; +} + export default function RightPanelContainer({ customWidgets }: { customWidgets: BasicWidget[] }) { const [ rightPaneVisible, setRightPaneVisible ] = useTriliumOptionBool("rightPaneVisible"); - const [ highlightsList ] = useTriliumOptionJson("highlightsList"); + const items = useItems(rightPaneVisible, customWidgets); useSplit(rightPaneVisible); - const { note } = useActiveNoteContext(); - const noteType = useNoteProperty(note, "type"); - const items = (rightPaneVisible ? [ - (noteType === "text" || noteType === "doc") && , - noteType === "text" && highlightsList.length > 0 && , - ...customWidgets.map((w) => ) - ] : []).filter(Boolean); - return (
    {rightPaneVisible && ( @@ -52,6 +51,36 @@ export default function RightPanelContainer({ customWidgets }: { customWidgets: ); } +function useItems(rightPaneVisible: boolean, customWidgets: BasicWidget[]) { + const { note } = useActiveNoteContext(); + const noteType = useNoteProperty(note, "type"); + const [ highlightsList ] = useTriliumOptionJson("highlightsList"); + + if (!rightPaneVisible) return []; + const definitions: RightPanelWidgetDefinition[] = [ + { + el: , + enabled: (noteType === "text" || noteType === "doc"), + position: 10, + }, + { + el: , + enabled: noteType === "text" && highlightsList.length > 0, + position: 20, + }, + ...customWidgets.map((w, i) => ({ + el: , + enabled: true, + position: w.position ?? 30 + i * 10 + })) + ]; + + return definitions + .filter(e => e.enabled) + .toSorted((a, b) => a.position - b.position) + .map(e => e.el); +} + function useSplit(visible: boolean) { // Split between right pane and the content pane. useEffect(() => { From b248805905dc10f4b78097733da843d52564a97f Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 20 Dec 2025 12:25:43 +0200 Subject: [PATCH 70/74] feat(right_pane): add count to highlights list --- .../src/translations/en/translation.json | 2 + .../src/widgets/sidebar/HighlightsList.tsx | 89 ++++++++++--------- 2 files changed, 47 insertions(+), 44 deletions(-) diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 76d674985..b45f26cb5 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1723,6 +1723,8 @@ }, "highlights_list_2": { "title": "Highlights List", + "title_with_count_one": "{{count}} highlight", + "title_with_count_other": "{{count}} highlights", "options": "Options", "modal_title": "Configure Highlights List", "menu_configure": "Configure highlights list...", diff --git a/apps/client/src/widgets/sidebar/HighlightsList.tsx b/apps/client/src/widgets/sidebar/HighlightsList.tsx index 2fb19e38b..0eaeb40dc 100644 --- a/apps/client/src/widgets/sidebar/HighlightsList.tsx +++ b/apps/client/src/widgets/sidebar/HighlightsList.tsx @@ -3,7 +3,6 @@ import { createPortal } from "preact/compat"; import { useCallback, useEffect, useState } from "preact/hooks"; import { t } from "../../services/i18n"; -import ActionButton from "../react/ActionButton"; import { useActiveNoteContext, useContentElement, useIsNoteReadOnly, useNoteProperty, useTextEditor, useTriliumOptionJson } from "../react/hooks"; import Modal from "../react/Modal"; import { HighlightsListOptions } from "../type_widgets/options/text_notes"; @@ -25,26 +24,11 @@ export default function HighlightsList() { const { note, noteContext } = useActiveNoteContext(); const noteType = useNoteProperty(note, "type"); const { isReadOnly } = useIsNoteReadOnly(note, noteContext); - const [ shown, setShown ] = useState(false); return ( <> - setShown(true) - } - ]} - grow - > - {noteType === "text" && isReadOnly && } - {noteType === "text" && !isReadOnly && } - - {createPortal(, document.body)} + {noteType === "text" && isReadOnly && } + {noteType === "text" && !isReadOnly && } ); } @@ -80,33 +64,50 @@ function AbstractHighlightsList({ highlights, scrollToHi ); }); + const [ shown, setShown ] = useState(false); return ( - - {filteredHighlights.length > 0 ? ( -
      - {filteredHighlights.map(highlight => ( -
    1. scrollToHighlight(highlight)} - > - {highlight.text} -
    2. - ))} -
    - ) : ( -
    - {t("highlights_list_2.no_highlights")} -
    - )} -
    + <> + setShown(true) + } + ]} + grow + > + + {filteredHighlights.length > 0 ? ( +
      + {filteredHighlights.map(highlight => ( +
    1. scrollToHighlight(highlight)} + > + {highlight.text} +
    2. + ))} +
    + ) : ( +
    + {t("highlights_list_2.no_highlights")} +
    + )} +
    +
    + {createPortal(, document.body)} + ); } From ea76fd797c32443a19f60231b48052b2ecc1b177 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 20 Dec 2025 12:29:43 +0200 Subject: [PATCH 71/74] chore(right_pane): address requested changes --- apps/client/src/services/utils.ts | 99 ++++++++++++------------- apps/client/src/widgets/react/hooks.tsx | 2 +- 2 files changed, 48 insertions(+), 53 deletions(-) diff --git a/apps/client/src/services/utils.ts b/apps/client/src/services/utils.ts index 36277bbb1..8c2a12c6a 100644 --- a/apps/client/src/services/utils.ts +++ b/apps/client/src/services/utils.ts @@ -1,8 +1,7 @@ import { dayjs } from "@triliumnext/commons"; -import { snapdom } from "@zumer/snapdom"; - -import FNote from "../entities/fnote"; import type { ViewMode, ViewScope } from "./link.js"; +import FNote from "../entities/fnote"; +import { snapdom } from "@zumer/snapdom"; const SVG_MIME = "image/svg+xml"; @@ -114,9 +113,9 @@ function formatDateISO(date: Date) { export function formatDateTime(date: Date, userSuppliedFormat?: string): string { if (userSuppliedFormat?.trim()) { return dayjs(date).format(userSuppliedFormat); - } - return `${formatDate(date)} ${formatTime(date)}`; - + } else { + return `${formatDate(date)} ${formatTime(date)}`; + } } function localNowDateTime() { @@ -192,9 +191,9 @@ export function formatSize(size: number | null | undefined) { if (size < 1024) { return `${size} KiB`; - } - return `${Math.round(size / 102.4) / 10} MiB`; - + } else { + return `${Math.round(size / 102.4) / 10} MiB`; + } } function toObject(array: T[], fn: (arg0: T) => [key: string, value: R]) { @@ -298,18 +297,18 @@ function formatHtml(html: string) { let indent = "\n"; const tab = "\t"; let i = 0; - const pre: { indent: string; tag: string }[] = []; + let pre: { indent: string; tag: string }[] = []; html = html - .replace(new RegExp("
    ([\\s\\S]+?)?
    "), (x) => { + .replace(new RegExp("
    ([\\s\\S]+?)?
    "), function (x) { pre.push({ indent: "", tag: x }); - return `<--TEMPPRE${ i++ }/-->`; + return "<--TEMPPRE" + i++ + "/-->"; }) - .replace(new RegExp("<[^<>]+>[^<]?", "g"), (x) => { + .replace(new RegExp("<[^<>]+>[^<]?", "g"), function (x) { let ret; const tagRegEx = /<\/?([^\s/>]+)/.exec(x); - const tag = tagRegEx ? tagRegEx[1] : ""; - const p = new RegExp("<--TEMPPRE(\\d+)/-->").exec(x); + let tag = tagRegEx ? tagRegEx[1] : ""; + let p = new RegExp("<--TEMPPRE(\\d+)/-->").exec(x); if (p) { const pInd = parseInt(p[1]); @@ -319,22 +318,24 @@ function formatHtml(html: string) { if (["area", "base", "br", "col", "command", "embed", "hr", "img", "input", "keygen", "link", "menuitem", "meta", "param", "source", "track", "wbr"].indexOf(tag) >= 0) { // self closing tag ret = indent + x; - } else if (x.indexOf("") ret = indent + x.substr(0, x.length - 1) + indent + tab + x.substr(x.length - 1, x.length); - else ret = indent + x; - !p && (indent += tab); } else { - //close tag - indent = indent.substr(0, indent.length - 1); - if (x.charAt(x.length - 1) !== ">") ret = indent + x.substr(0, x.length - 1) + indent + x.substr(x.length - 1, x.length); - else ret = indent + x; + if (x.indexOf("") ret = indent + x.substr(0, x.length - 1) + indent + tab + x.substr(x.length - 1, x.length); + else ret = indent + x; + !p && (indent += tab); + } else { + //close tag + indent = indent.substr(0, indent.length - 1); + if (x.charAt(x.length - 1) !== ">") ret = indent + x.substr(0, x.length - 1) + indent + x.substr(x.length - 1, x.length); + else ret = indent + x; + } } return ret; }); for (i = pre.length; i--;) { - html = html.replace(`<--TEMPPRE${ i }/-->`, pre[i].tag.replace("
    ", "
    \n").replace("
    ", `${pre[i].indent }
    `)); + html = html.replace("<--TEMPPRE" + i + "/-->", pre[i].tag.replace("
    ", "
    \n").replace("
    ", pre[i].indent + "
    ")); } return html.charAt(0) === "\n" ? html.substr(1, html.length - 1) : html; @@ -363,11 +364,11 @@ type dynamicRequireMappings = { export function dynamicRequire(moduleName: T): Awaited{ if (typeof __non_webpack_require__ !== "undefined") { return __non_webpack_require__(moduleName); - } - // explicitly pass as string and not as expression to suppress webpack warning - // 'Critical dependency: the request of a dependency is an expression' - return require(`${moduleName}`); - + } else { + // explicitly pass as string and not as expression to suppress webpack warning + // 'Critical dependency: the request of a dependency is an expression' + return require(`${moduleName}`); + } } function timeLimit(promise: Promise, limitMs: number, errorMessage?: string) { @@ -508,8 +509,8 @@ export function escapeRegExp(str: string) { function areObjectsEqual(...args: unknown[]) { let i; let l; - let leftChain: object[]; - let rightChain: object[]; + let leftChain: Object[]; + let rightChain: Object[]; function compare2Objects(x: unknown, y: unknown) { let p; @@ -694,9 +695,9 @@ async function downloadAsSvg(nameWithoutExtension: string, svgSource: string | S try { const result = await snapdom(element, { - backgroundColor: "transparent", - scale: 2 - }); + backgroundColor: "transparent", + scale: 2 + }); triggerDownload(`${nameWithoutExtension}.svg`, result.url); } finally { cleanup(); @@ -732,9 +733,9 @@ async function downloadAsPng(nameWithoutExtension: string, svgSource: string | S try { const result = await snapdom(element, { - backgroundColor: "transparent", - scale: 2 - }); + backgroundColor: "transparent", + scale: 2 + }); const pngImg = await result.toPng(); await triggerDownload(`${nameWithoutExtension}.png`, pngImg.src); } finally { @@ -762,11 +763,11 @@ export function getSizeFromSvg(svgContent: string) { return { width: parseFloat(width), height: parseFloat(height) - }; - } - console.warn("SVG export error", svgDocument.documentElement); - return null; - + } + } else { + console.warn("SVG export error", svgDocument.documentElement); + return null; + } } /** @@ -895,9 +896,9 @@ export function mapToKeyValueArray(map: R export function getErrorMessage(e: unknown) { if (e && typeof e === "object" && "message" in e && typeof e.message === "string") { return e.message; - } - return "Unknown error"; - + } else { + return "Unknown error"; + } } /** @@ -912,12 +913,6 @@ export function handleRightToLeftPlacement(placement: T) { return placement; } -export function clamp(value: number, min: number, max: number) { - if (value < min) return min; - if (value > max) return max; - return value; -} - export default { reloadFrontendApp, restartDesktopApp, diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 898e425ad..034e77dab 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -636,7 +636,7 @@ export function useLegacyWidget(widgetFactory: () => T, { parentContainer.replaceChildren(); renderedWidget.appendTo(parentContainer); } - }); + }, [ renderedWidget ]); // Inject the note context. useEffect(() => { From f1ca8881a1d952c70ac7c9640a4fad43ac2a88e1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 20 Dec 2025 12:32:20 +0200 Subject: [PATCH 72/74] chore(right_pane): fix typecheck --- apps/client/src/widgets/highlights_list.ts | 62 +++++++++++----------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/apps/client/src/widgets/highlights_list.ts b/apps/client/src/widgets/highlights_list.ts index 5023facb9..af5844b48 100644 --- a/apps/client/src/widgets/highlights_list.ts +++ b/apps/client/src/widgets/highlights_list.ts @@ -5,14 +5,14 @@ * - For example, if there is a formula in the middle of the highlighted text, the two ends of the formula will be regarded as two entries */ -import { t } from "../services/i18n.js"; -import attributeService from "../services/attributes.js"; -import RightPanelWidget from "./right_panel_widget.js"; -import options from "../services/options.js"; -import OnClickButtonWidget from "./buttons/onclick_button.js"; import appContext, { type EventData } from "../components/app_context.js"; import type FNote from "../entities/fnote.js"; +import attributeService from "../services/attributes.js"; +import { t } from "../services/i18n.js"; import katex from "../services/math.js"; +import options from "../services/options.js"; +import OnClickButtonWidget from "./buttons/onclick_button.js"; +import RightPanelWidget from "./right_panel_widget.js"; const TPL = /*html*/`