New layout: Right panel (sidebar) (#8095)

This commit is contained in:
Elian Doran 2025-12-20 13:09:59 +02:00 committed by GitHub
commit 78ac59581e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1152 additions and 174 deletions

View File

@ -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) {

View File

@ -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<JQuery<HTMLElement>>(
return this.timeout<JQuery<HTMLElement> | null>(
new Promise((resolve) =>
appContext.triggerCommand("executeWithContentElement", {
resolve,

View File

@ -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";
@ -44,6 +45,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";
@ -90,6 +92,7 @@ export default class DesktopLayout {
.optChild(launcherPaneIsHorizontal, <LeftPaneToggle isHorizontalLayout={true} />)
.child(<TabHistoryNavigationButtons />)
.child(new TabRowWidget().class("full-width"))
.optChild(launcherPaneIsHorizontal && isNewLayout, <RightPaneToggle />)
.optChild(customTitleBarButtons, <TitleBarButtons />)
.css("height", "40px")
.css("background-color", "var(--launcher-pane-background-color)")
@ -113,10 +116,14 @@ export default class DesktopLayout {
.css("flex-grow", "1")
.optChild(!fullWidthTabBar,
new FlexContainer("row")
.class("tab-row-container")
.child(<TabHistoryNavigationButtons />)
.child(new TabRowWidget())
.optChild(isNewLayout, <RightPaneToggle />)
.optChild(customTitleBarButtons, <TitleBarButtons />)
.css("height", "40px"))
.css("height", "40px")
.css("align-items", "center")
)
.optChild(isNewLayout, <FixedFormattingToolbar />)
.child(
new FlexContainer("row")
@ -145,7 +152,7 @@ export default class DesktopLayout {
.optChild(isNewLayout, <NoteActions />))
.optChild(!isNewLayout, <Ribbon />)
.child(new WatchedFileUpdateStatusWidget())
.child(<FloatingButtons items={DESKTOP_FLOATING_BUTTONS} />)
.optChild(!isNewLayout, <FloatingButtons items={DESKTOP_FLOATING_BUTTONS} />)
.child(
new ScrollingContainer()
.filling()
@ -165,21 +172,19 @@ export default class DesktopLayout {
)
.child(<ApiLog />)
.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"))
)
.child(
.optChild(!isNewLayout,
new RightPaneContainer()
.child(new TocWidget())
.child(new HighlightsListWidget())
.child(...this.customWidgets.get("right-pane"))
)
.optChild(isNewLayout, <RightPanelContainer customWidgets={this.customWidgets.get("right-pane")} />)
)
.optChild(!launcherPaneIsHorizontal && isNewLayout, <StatusBar />)
)

View File

@ -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));
}

View File

@ -1723,7 +1723,12 @@
},
"highlights_list_2": {
"title": "Highlights List",
"options": "Options"
"title_with_count_one": "{{count}} highlight",
"title_with_count_other": "{{count}} highlights",
"options": "Options",
"modal_title": "Configure Highlights List",
"menu_configure": "Configure highlights list...",
"no_highlights": "No highlights found."
},
"quick-search": {
"placeholder": "Quick search",
@ -1794,7 +1799,8 @@
},
"toc": {
"table_of_contents": "Table of Contents",
"options": "Options"
"options": "Options",
"no_headings": "No headings."
},
"watched_file_update_status": {
"file_last_modified": "File <code class=\"file-path\"></code> has been last modified on <span class=\"file-last-modified\"></span>.",
@ -2196,5 +2202,11 @@
"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",
"toggle": "Toggle right panel",
"custom_widget_go_to_source": "Go to source code"
}
}

View File

@ -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 && <FloatingButton
text={t("backend_log.refresh")}
icon="bx bx-refresh"
@ -90,7 +88,7 @@ function RefreshBackendLogButton({ note, parentComponent, noteContext, isDefault
}
function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: FloatingButtonContext) {
const isEnabled = !isNewLayout && note.type === "mermaid" && note.isContentAvailable() && !isReadOnly && isDefaultViewMode;
const isEnabled = note.type === "mermaid" && note.isContentAvailable() && !isReadOnly && isDefaultViewMode;
const [ splitEditorOrientation, setSplitEditorOrientation ] = useTriliumOption("splitEditorOrientation");
const upcomingOrientation = splitEditorOrientation === "horizontal" ? "vertical" : "horizontal";
@ -103,7 +101,7 @@ function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: F
function ToggleReadOnlyButton({ note, viewType, isDefaultViewMode }: FloatingButtonContext) {
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
const isEnabled = !isNewLayout && ([ "mermaid", "mindMap", "canvas" ].includes(note.type) || viewType === "geoMap")
const isEnabled = ([ "mermaid", "mindMap", "canvas" ].includes(note.type) || viewType === "geoMap")
&& note.isContentAvailable() && isDefaultViewMode;
return isEnabled && <FloatingButton
@ -173,7 +171,7 @@ function ShowHighlightsListWidgetButton({ note, noteContext, isDefaultViewMode }
}
function RunActiveNoteButton({ note }: FloatingButtonContext) {
const isEnabled = !isNewLayout && (note.mime.startsWith("application/javascript") || note.mime === "text/x-sqlite;schema=trilium");
const isEnabled = (note.mime.startsWith("application/javascript") || note.mime === "text/x-sqlite;schema=trilium");
return isEnabled && <FloatingButton
icon="bx bx-play"
text={t("code_buttons.execute_button_title")}
@ -182,7 +180,7 @@ function RunActiveNoteButton({ note }: FloatingButtonContext) {
}
function OpenTriliumApiDocsButton({ note }: FloatingButtonContext) {
const isEnabled = !isNewLayout && note.mime.startsWith("application/javascript;env=");
const isEnabled = note.mime.startsWith("application/javascript;env=");
return isEnabled && <FloatingButton
icon="bx bx-help-circle"
text={t("code_buttons.trilium_api_docs_button_title")}
@ -191,7 +189,7 @@ function OpenTriliumApiDocsButton({ note }: FloatingButtonContext) {
}
function SaveToNoteButton({ note }: FloatingButtonContext) {
const isEnabled = !isNewLayout && note.mime === "text/x-sqlite;schema=trilium" && note.isHiddenCompletely();
const isEnabled = note.mime === "text/x-sqlite;schema=trilium" && note.isHiddenCompletely();
return isEnabled && <FloatingButton
icon="bx bx-save"
text={t("code_buttons.save_to_note_button_title")}
@ -213,7 +211,7 @@ export function buildSaveSqlToNoteHandler(note: FNote) {
}
function RelationMapButtons({ note, isDefaultViewMode, triggerEvent }: FloatingButtonContext) {
const isEnabled = (!isNewLayout && note.type === "relationMap" && isDefaultViewMode);
const isEnabled = (note.type === "relationMap" && isDefaultViewMode);
return isEnabled && (
<>
<FloatingButton
@ -246,7 +244,7 @@ function RelationMapButtons({ note, isDefaultViewMode, triggerEvent }: FloatingB
}
function GeoMapButtons({ triggerEvent, viewType, isReadOnly }: FloatingButtonContext) {
const isEnabled = !isNewLayout && viewType === "geoMap" && !isReadOnly;
const isEnabled = viewType === "geoMap" && !isReadOnly;
return isEnabled && (
<FloatingButton
icon="bx bx-plus-circle"
@ -259,8 +257,7 @@ function GeoMapButtons({ triggerEvent, viewType, isReadOnly }: FloatingButtonCon
function CopyImageReferenceButton({ note, isDefaultViewMode }: FloatingButtonContext) {
const hiddenImageCopyRef = useRef<HTMLDivElement>(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 && (
<FloatingButton
@ -335,7 +332,7 @@ function Backlinks({ note, isDefaultViewMode }: FloatingButtonContext) {
}
}, [ popupOpen, windowHeight ]);
const isEnabled = !isNewLayout && isDefaultViewMode && backlinkCount > 0;
const isEnabled = isDefaultViewMode && backlinkCount > 0;
return (isEnabled &&
<div className="backlinks-widget has-overflow">
<div

View File

@ -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 (
<ActionButton
className={clsx(
`toggle-button right-pane-toggle-button bx-flip-horizontal`,
rightPaneVisible ? "action-collapse" : "action-expand"
)}
text={t("right_pane.toggle")}
icon="bx bx-sidebar"
onClick={() => setRightPaneVisible(!rightPaneVisible)}
/>
);
}

View File

@ -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*/`<div class="highlights-list-widget">
<style>
@ -159,13 +159,13 @@ export default class HighlightsListWidget extends RightPanelWidget {
*/
async replaceMathTextWithKatax(html: string) {
const mathTextRegex = /<span class="math-tex">\\\(([\s\S]*?)\\\)<\/span>/g;
var matches = [...html.matchAll(mathTextRegex)];
const matches = [...html.matchAll(mathTextRegex)];
let modifiedText = html;
if (matches.length > 0) {
// Process all matches asynchronously
for (const match of matches) {
let latexCode = match[1];
const latexCode = match[1];
let rendered;
try {
@ -234,7 +234,7 @@ export default class HighlightsListWidget extends RightPanelWidget {
}
findSubStr = findSubStr.substring(1);
combinedRegexStr = `(` + combinedRegexStr.substring(1) + `)`;
combinedRegexStr = `(${combinedRegexStr.substring(1)})`;
const combinedRegex = new RegExp(combinedRegexStr, "gi");
const $highlightsList = $("<ol>");
let prevEndIndex = -1,
@ -302,26 +302,28 @@ export default class HighlightsListWidget extends RightPanelWidget {
let targetElement;
if (isReadOnly) {
const $container = await this.noteContext.getContentElement();
targetElement = $container
.find(findSubStr)
.filter(function () {
if (findSubStr.indexOf("color") >= 0 && findSubStr.indexOf("background-color") < 0) {
let color = this.style.color;
const $el = $(this as HTMLElement);
return !($el.prop("tagName") === "SPAN" && color === "");
} else {
if ($container) {
targetElement = $container
.find(findSubStr)
.filter(function () {
if (findSubStr.indexOf("color") >= 0 && findSubStr.indexOf("background-color") < 0) {
const color = this.style.color;
const $el = $(this as HTMLElement);
return !($el.prop("tagName") === "SPAN" && color === "");
}
return true;
}
})
.filter(function () {
const $el = $(this as HTMLElement);
return (
$el.parent(findSubStr).length === 0 &&
$el.parent().parent(findSubStr).length === 0 &&
$el.parent().parent().parent(findSubStr).length === 0 &&
$el.parent().parent().parent().parent(findSubStr).length === 0
);
});
})
.filter(function () {
const $el = $(this as HTMLElement);
return (
$el.parent(findSubStr).length === 0 &&
$el.parent().parent(findSubStr).length === 0 &&
$el.parent().parent().parent(findSubStr).length === 0 &&
$el.parent().parent().parent().parent(findSubStr).length === 0
);
});
}
} else {
const textEditor = await this.noteContext.getTextEditor();
const el = textEditor?.editing.view.domRoots.values().next().value;
@ -333,11 +335,11 @@ export default class HighlightsListWidget extends RightPanelWidget {
// the background-color error will be regarded as color, so it needs to be filtered
const $el = $(this as HTMLElement);
if (findSubStr.indexOf("color") >= 0 && findSubStr.indexOf("background-color") < 0) {
let color = this.style.color;
const color = this.style.color;
return !($el.prop("tagName") === "SPAN" && color === "");
} else {
return true;
}
return true;
})
.filter(function () {
// Need to filter out the child elements of the element that has been found

View File

@ -1,8 +1,16 @@
interface IconProps {
import clsx from "clsx";
import { HTMLAttributes } from "preact";
interface IconProps extends Pick<HTMLAttributes<HTMLSpanElement>, "className" | "onClick"> {
icon?: string;
className?: string;
}
export default function Icon({ icon, className }: IconProps) {
return <span class={`${icon ?? "bx bx-empty"} ${className ?? ""}`}></span>
}
export default function Icon({ icon, className, ...restProps }: IconProps) {
return (
<span
class={clsx(icon ?? "bx bx-empty", className)}
{...restProps}
/>
);
}

View File

@ -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";
@ -21,7 +22,8 @@ import shortcuts, { Handler, removeIndividualBinding } from "../../services/shor
import SpacedUpdate from "../../services/spaced_update";
import toast, { ToastOptions } from "../../services/toast";
import tree from "../../services/tree";
import utils, { escapeRegExp, randomString, reloadFrontendApp } from "../../services/utils";
import utils, { escapeRegExp, getErrorMessage, randomString, reloadFrontendApp } from "../../services/utils";
import ws from "../../services/ws";
import BasicWidget, { ReactWrappedWidget } from "../basic_widget";
import NoteContextAwareWidget from "../note_context_aware_widget";
import { DragData } from "../note_tree";
@ -167,13 +169,20 @@ export function useTriliumOption(name: OptionNames, needsRefresh?: boolean): [st
const wrappedSetValue = useMemo(() => {
return async (newValue: OptionValue) => {
await options.save(name, newValue);
const originalValue = value;
setValue(String(newValue));
try {
await options.save(name, newValue);
} catch (e: unknown) {
ws.logError(getErrorMessage(e));
setValue(originalValue);
}
if (needsRefresh) {
reloadFrontendApp(`option change: ${name}`);
}
};
}, [ name, needsRefresh ]);
}, [ name, needsRefresh, value ]);
useTriliumEvent("entitiesReloaded", useCallback(({ loadResults }) => {
if (loadResults.getOptionNames().includes(name)) {
@ -625,13 +634,14 @@ export function useLegacyWidget<T extends BasicWidget>(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);
const parentContainer = ref.current;
if (parentContainer) {
parentContainer.replaceChildren();
renderedWidget.appendTo(parentContainer);
}
}, [ renderedWidget ]);
@ -640,7 +650,7 @@ export function useLegacyWidget<T extends BasicWidget>(widgetFactory: () => T, {
if (noteContext && widget instanceof NoteContextAwareWidget) {
widget.activeContextChangedEvent({ noteContext });
}
}, [ noteContext ]);
}, [ noteContext, widget ]);
useDebugValue(widget);
@ -1074,3 +1084,56 @@ export function useNoteColorClass(note: FNote | null | undefined) {
}, [ color, note ]);
return colorClass;
}
export function useTextEditor(noteContext: NoteContext | null | undefined) {
const [ textEditor, setTextEditor ] = useState<CKTextEditor | null>(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;
}
export function useContentElement(noteContext: NoteContext | null | undefined) {
const [ contentElement, setContentElement ] = useState<HTMLElement | null>(null);
const requestIdRef = useRef(0);
const [, forceUpdate] = useState(0);
useEffect(() => {
const requestId = ++requestIdRef.current;
noteContext?.getContentElement().then(contentElement => {
// 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;
}

View File

@ -0,0 +1,283 @@
import { CKTextEditor, ModelText } from "@triliumnext/ckeditor5";
import { createPortal } from "preact/compat";
import { useCallback, useEffect, useState } from "preact/hooks";
import { t } from "../../services/i18n";
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";
interface RawHighlight {
id: string;
text: string;
attrs: {
bold: boolean;
italic: boolean;
underline: boolean;
color: string | undefined;
background: string | undefined;
}
}
export default function HighlightsList() {
const { note, noteContext } = useActiveNoteContext();
const noteType = useNoteProperty(note, "type");
const { isReadOnly } = useIsNoteReadOnly(note, noteContext);
return (
<>
{noteType === "text" && isReadOnly && <ReadOnlyTextHighlightsList />}
{noteType === "text" && !isReadOnly && <EditableTextHighlightsList />}
</>
);
}
function HighlightListOptionsModal({ shown, setShown }: { shown: boolean, setShown(value: boolean): void }) {
return (
<Modal
className="highlights-list-options-modal"
size="md"
title={t("highlights_list_2.modal_title")}
show={shown}
onHidden={() => setShown(false)}
>
<HighlightsListOptions />
</Modal>
);
}
function AbstractHighlightsList<T extends RawHighlight>({ highlights, scrollToHighlight }: {
highlights: T[],
scrollToHighlight(highlight: T): void;
}) {
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)
);
});
const [ shown, setShown ] = useState(false);
return (
<>
<RightPanelWidget
id="highlights"
title={t("highlights_list_2.title_with_count", { count: filteredHighlights.length })}
contextMenuItems={[
{
title: t("highlights_list_2.menu_configure"),
uiIcon: "bx bx-cog",
handler: () => setShown(true)
}
]}
grow
>
<span className="highlights-list">
{filteredHighlights.length > 0 ? (
<ol>
{filteredHighlights.map(highlight => (
<li
key={highlight.id}
onClick={() => scrollToHighlight(highlight)}
>
<span
style={{
fontWeight: highlight.attrs.bold ? "700" : undefined,
fontStyle: highlight.attrs.italic ? "italic" : undefined,
textDecoration: highlight.attrs.underline ? "underline" : undefined,
color: highlight.attrs.color,
backgroundColor: highlight.attrs.background
}}
>{highlight.text}</span>
</li>
))}
</ol>
) : (
<div className="no-highlights">
{t("highlights_list_2.no_highlights")}
</div>
)}
</span>
</RightPanelWidget>
{createPortal(<HighlightListOptionsModal shown={shown} setShown={setShown} />, document.body)}
</>
);
}
//#region Editable text (CKEditor)
interface CKHighlight extends RawHighlight {
textNode: ModelText;
offset: number | null;
}
function EditableTextHighlightsList() {
const { note, noteContext } = useActiveNoteContext();
const textEditor = useTextEditor(noteContext);
const [ highlights, setHighlights ] = useState<CKHighlight[]>([]);
useEffect(() => {
if (!textEditor) return;
setHighlights(extractHighlightsFromTextEditor(textEditor));
// 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) => {
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;
if (domPos.parent instanceof HTMLElement) {
domPos.parent.scrollIntoView();
} else if (domPos.parent instanceof Text) {
domPos.parent.parentElement?.scrollIntoView();
}
}, [ textEditor ]);
return <AbstractHighlightsList
highlights={highlights}
scrollToHighlight={scrollToHeading}
/>;
}
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') || !item.data.trim()) continue;
const attrs: RawHighlight["attrs"] = {
bold: item.hasAttribute('bold'),
italic: item.hasAttribute('italic'),
underline: item.hasAttribute('underline'),
color: item.getAttribute('fontColor') as string | undefined,
background: item.getAttribute('fontBackgroundColor') as string | undefined
};
if (Object.values(attrs).some(Boolean)) {
result.push({
id: crypto.randomUUID(),
text: item.data,
attrs,
textNode: item.textNode,
offset: item.startOffset
});
}
}
return result;
}
//#endregion
//#region Read-only text
interface DomHighlight extends RawHighlight {
element: HTMLElement;
}
function ReadOnlyTextHighlightsList() {
const { noteContext } = useActiveNoteContext();
const contentEl = useContentElement(noteContext);
const highlights = extractHighlightsFromStaticHtml(contentEl);
const scrollToHighlight = useCallback((highlight: DomHighlight) => {
highlight.element.scrollIntoView();
}, []);
return <AbstractHighlightsList
highlights={highlights}
scrollToHighlight={scrollToHighlight}
/>;
}
function extractHighlightsFromStaticHtml(el: HTMLElement | null) {
if (!el) return [];
const { color: defaultColor, backgroundColor: defaultBackgroundColor } = getComputedStyle(el);
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 !== defaultColor ||
style.backgroundColor !== defaultBackgroundColor
) {
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
});
}
}
}
return highlights;
}
//#endregion

View File

@ -0,0 +1,61 @@
body.experimental-feature-new-layout #right-pane {
display: flex;
flex-direction: column;
.card {
margin-inline: 0;
border-bottom: 1px solid var(--main-border-color);
border-radius: 0;
.card-header {
padding: 1px 0 0 0;
cursor: pointer;
justify-content: flex-start;
--icon-button-size: 26px;
.card-header-title {
padding-inline: 0;
flex-grow: 1;
}
}
.card-header-buttons {
transform: none;
top: 0;
}
&:last-of-type {
border-bottom: 0;
}
&.collapsed .card-header > .bx {
transform: rotate(-90deg);
}
}
.card.grow:not(.collapsed) {
flex-grow: 1;
}
.gutter-vertical + .card .card-header {
padding-top: 0;
}
.no-items {
display: flex;
align-items: center;
justify-content: center;
flex-grow: 1;
flex-direction: column;
padding: 0.75em;
color: var(--muted-text-color);
.bx {
font-size: 3em;
}
button {
margin-top: 1em;
}
}
}

View File

@ -0,0 +1,148 @@
//! This is currently only used for the new layout.
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";
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, 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;
interface RightPanelWidgetDefinition {
el: VNode;
enabled: boolean;
position: number;
}
export default function RightPanelContainer({ customWidgets }: { customWidgets: BasicWidget[] }) {
const [ rightPaneVisible, setRightPaneVisible ] = useTriliumOptionBool("rightPaneVisible");
const items = useItems(rightPaneVisible, customWidgets);
useSplit(rightPaneVisible);
return (
<div id="right-pane">
{rightPaneVisible && (
items.length > 0 ? (
items
) : (
<div className="no-items">
<Icon icon="bx bx-sidebar" />
{t("right_pane.empty_message")}
<Button
text={t("right_pane.empty_button")}
onClick={() => setRightPaneVisible(!rightPaneVisible)}
/>
</div>
)
)}
</div>
);
}
function useItems(rightPaneVisible: boolean, customWidgets: BasicWidget[]) {
const { note } = useActiveNoteContext();
const noteType = useNoteProperty(note, "type");
const [ highlightsList ] = useTriliumOptionJson<string[]>("highlightsList");
if (!rightPaneVisible) return [];
const definitions: RightPanelWidgetDefinition[] = [
{
el: <TableOfContents />,
enabled: (noteType === "text" || noteType === "doc"),
position: 10,
},
{
el: <HighlightsList />,
enabled: noteType === "text" && highlightsList.length > 0,
position: 20,
},
...customWidgets.map((w, i) => ({
el: <CustomWidget key={w._noteId} originalWidget={w as LegacyRightPanelWidget} />,
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(() => {
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"], {
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();
}, [ visible ]);
}
function CustomWidget({ originalWidget }: { originalWidget: LegacyRightPanelWidget }) {
const containerRef = useRef<HTMLDivElement>(null);
return (
<RightPanelWidget
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)
}
]}
>
<CustomWidgetContent originalWidget={originalWidget} />
</RightPanelWidget>
);
}
function CustomWidgetContent({ originalWidget }: { originalWidget: LegacyRightPanelWidget }) {
const { noteContext } = useActiveNoteContext();
const [ el ] = useLegacyWidget(() => {
originalWidget.contentSized();
// Monkey-patch the original widget by replacing the default initialization logic.
originalWidget.doRender = function doRender(this: LegacyRightPanelWidget) {
this.$widget = $("<div>");
this.$body = this.$widget;
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;
}, {
noteContext
});
return el;
}

View File

@ -1,15 +1,26 @@
import { useContext, useRef } from "preact/hooks";
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 { ParentComponent } from "../react/react_utils";
import { ComponentChildren } from "preact";
interface RightPanelWidgetProps {
id: string;
title: string;
children: ComponentChildren;
buttons?: ComponentChildren;
containerRef?: RefObject<HTMLDivElement>;
contextMenuItems?: MenuItem<unknown>[];
grow?: boolean;
}
export default function RightPanelWidget({ title, buttons, children }: RightPanelWidgetProps) {
const containerRef = useRef<HTMLDivElement>(null);
export default function RightPanelWidget({ id, title, buttons, children, containerRef: externalContainerRef, contextMenuItems, grow }: RightPanelWidgetProps) {
const [ rightPaneCollapsedItems, setRightPaneCollapsedItems ] = useTriliumOptionJson<string[]>("rightPaneCollapsedItems");
const [ expanded, setExpanded ] = useState(!rightPaneCollapsedItems.includes(id));
const containerRef = useSyncedRef<HTMLDivElement>(externalContainerRef, null);
const parentComponent = useContext(ParentComponent);
if (parentComponent) {
@ -17,16 +28,53 @@ export default function RightPanelWidget({ title, buttons, children }: RightPane
}
return (
<div ref={containerRef} class="card widget" style={{contain: "none"}}>
<div class="card-header">
<div
ref={containerRef}
class={clsx("card widget", {
collapsed: !expanded,
grow
})}
>
<div
class="card-header"
onClick={() => {
const newExpanded = !expanded;
setExpanded(newExpanded);
const rightPaneCollapsedItemsSet = new Set(rightPaneCollapsedItems);
if (newExpanded) {
rightPaneCollapsedItemsSet.delete(id);
} else {
rightPaneCollapsedItemsSet.add(id);
}
setRightPaneCollapsedItems(Array.from(rightPaneCollapsedItemsSet));
}}
>
<ActionButton icon="bx bx-chevron-down" text="" />
<div class="card-header-title">{title}</div>
<div class="card-header-buttons">{buttons}</div>
<div class="card-header-buttons">
{buttons}
{contextMenuItems && (
<ActionButton
icon="bx bx-dots-vertical-rounded"
text=""
onClick={e => {
e.stopPropagation();
contextMenu.show({
x: e.pageX,
y: e.pageY,
items: contextMenuItems,
selectMenuItemHandler: () => {}
});
}}
/>
)}
</div>
</div>
<div id={parentComponent?.componentId} class="body-wrapper">
<div class="card-body">
{expanded && <div class="card-body">
{children}
</div>
</div>}
</div>
</div>
);

View File

@ -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;
}

View File

@ -0,0 +1,222 @@
import "./TableOfContents.css";
import { CKTextEditor, ModelElement } from "@triliumnext/ckeditor5";
import clsx from "clsx";
import { useCallback, useEffect, useState } from "preact/hooks";
import { t } from "../../services/i18n";
import { useActiveNoteContext, useContentElement, useIsNoteReadOnly, useNoteProperty, useTextEditor } from "../react/hooks";
import Icon from "../react/Icon";
import RightPanelWidget from "./RightPanelWidget";
//#region Generic impl.
interface RawHeading {
id: string;
level: number;
text: string;
}
interface HeadingsWithNesting extends RawHeading {
children: HeadingsWithNesting[];
}
export default function TableOfContents() {
const { note, noteContext } = useActiveNoteContext();
const noteType = useNoteProperty(note, "type");
const { isReadOnly } = useIsNoteReadOnly(note, noteContext);
return (
<RightPanelWidget id="toc" title={t("toc.table_of_contents")} grow>
{((noteType === "text" && isReadOnly) || (noteType === "doc")) && <ReadOnlyTextTableOfContents />}
{noteType === "text" && !isReadOnly && <EditableTextTableOfContents />}
</RightPanelWidget>
);
}
function AbstractTableOfContents<T extends RawHeading>({ headings, scrollToHeading }: {
headings: T[];
scrollToHeading(heading: T): void;
}) {
const nestedHeadings = buildHeadingTree(headings);
return (
<span className="toc">
{nestedHeadings.length > 0 ? (
<ol>
{nestedHeadings.map(heading => <TableOfContentsHeading key={heading.id} heading={heading} scrollToHeading={scrollToHeading} />)}
</ol>
) : (
<div className="no-headings">{t("toc.no_headings")}</div>
)}
</span>
);
}
function TableOfContentsHeading({ heading, scrollToHeading }: {
heading: HeadingsWithNesting;
scrollToHeading(heading: RawHeading): void;
}) {
const [ collapsed, setCollapsed ] = useState(false);
return (
<>
<li className={clsx(collapsed && "collapsed")}>
{heading.children.length > 0 && (
<Icon
className="collapse-button"
icon="bx bx-chevron-down"
onClick={() => setCollapsed(!collapsed)}
/>
)}
<span
className="item-content"
onClick={() => scrollToHeading(heading)}
>{heading.text}</span>
</li>
{heading.children && (
<ol>
{heading.children.map(heading => <TableOfContentsHeading key={heading.id} heading={heading} scrollToHeading={scrollToHeading} />)}
</ol>
)}
</>
);
}
function buildHeadingTree(headings: RawHeading[]): HeadingsWithNesting[] {
const root: HeadingsWithNesting = { level: 0, text: "", children: [], id: "_root" };
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;
}
//#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<CKHeading[]>([]);
useEffect(() => {
if (!textEditor) return;
const headings = extractTocFromTextEditor(textEditor);
setHeadings(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);
return () => textEditor.model.document.off("change:data", changeCallback);
}, [ textEditor, note ]);
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 <AbstractTableOfContents
headings={headings}
scrollToHeading={scrollToHeading}
/>;
}
function extractTocFromTextEditor(editor: CKTextEditor) {
const headings: CKHeading[] = [];
const root = editor.model.document.getRoot();
if (!root) return [];
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( '' );
// 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;
}
//#endregion
//#region Read-only text
interface DomHeading extends RawHeading {
element: HTMLHeadingElement;
}
function ReadOnlyTextTableOfContents() {
const { noteContext } = useActiveNoteContext();
const contentEl = useContentElement(noteContext);
const headings = extractTocFromStaticHtml(contentEl);
const scrollToHeading = useCallback((heading: DomHeading) => {
heading.element.scrollIntoView();
}, []);
return <AbstractTableOfContents
headings={headings}
scrollToHeading={scrollToHeading}
/>;
}
function extractTocFromStaticHtml(el: HTMLElement | null) {
if (!el) return [];
const headings: DomHeading[] = [];
for (const headingEl of el.querySelectorAll<HTMLHeadingElement>("h1,h2,h3,h4,h5,h6")) {
headings.push({
id: crypto.randomUUID(),
level: parseInt(headingEl.tagName.substring(1), 10),
text: headingEl.textContent,
element: headingEl
});
}
return headings;
}
//#endregion

View File

@ -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<Promise<void> | 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;

View File

@ -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() {
<AutoReadOnlySize option="autoReadonlySizeText" label={t("text_auto_read_only_size.label")} />
<DateTimeFormatOptions />
</>
)
);
}
function FormattingToolbar() {
@ -65,7 +69,7 @@ function FormattingToolbar() {
containerStyle={{ marginInlineStart: "1em" }}
/>
</OptionsSection>
)
);
}
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() {
<CodeBlockPreview theme={codeBlockTheme} wordWrap={codeBlockWordWrap} />
</OptionsSection>
)
);
}
const SAMPLE_LANGUAGE = normalizeMimeTypeForCKEditor("application/javascript;env=frontend");
@ -219,9 +223,9 @@ function CodeBlockPreview({ theme, wordWrap }: { theme: string, wordWrap: boolea
const codeStyle = useMemo<CSSProperties>(() => {
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
<code className="code-sample" style={codeStyle} dangerouslySetInnerHTML={getHtml(code)} />
</pre>
</div>
)
);
}
interface ThemeData {
@ -241,7 +245,7 @@ interface ThemeData {
function TableOfContent() {
const [ minTocHeadings, setMinTocHeadings ] = useTriliumOption("minTocHeadings");
return (
return (!isNewLayout &&
<OptionsSection title={t("table_of_contents.title")}>
<FormText>{t("table_of_contents.description")}</FormText>
@ -257,14 +261,31 @@ function TableOfContent() {
<FormText>{t("table_of_contents.disable_info")}</FormText>
<FormText>{t("table_of_contents.shortcut_info")}</FormText>
</OptionsSection>
)
);
}
function HighlightsList() {
return (
<OptionsSection title={t("highlights_list.title")}>
<HighlightsListOptions />
{!isNewLayout && (
<>
<hr />
<h5>{t("highlights_list.visibility_title")}</h5>
<FormText>{t("highlights_list.visibility_description")}</FormText>
<FormText>{t("highlights_list.shortcut_info")}</FormText>
</>
)}
</OptionsSection>
);
}
export function HighlightsListOptions() {
const [ highlightsList, setHighlightsList ] = useTriliumOptionJson<string[]>("highlightsList");
return (
<OptionsSection title={t("highlights_list.title")}>
<>
<FormText>{t("highlights_list.description")}</FormText>
<CheckboxList
values={[
@ -277,13 +298,8 @@ function HighlightsList() {
keyProperty="val" titleProperty="title"
currentValue={highlightsList} onChange={setHighlightsList}
/>
<hr />
<h5>{t("highlights_list.visibility_title")}</h5>
<FormText>{t("highlights_list.visibility_description")}</FormText>
<FormText>{t("highlights_list.shortcut_info")}</FormText>
</OptionsSection>
)
</>
);
}
function DateTimeFormatOptions() {
@ -302,19 +318,19 @@ function DateTimeFormatOptions() {
</FormText>
<div className="row align-items-center">
<FormGroup name="custom-date-time-format" className="col-md-6" label={t("custom_date_time_format.format_string")}>
<FormTextBox
<FormGroup name="custom-date-time-format" className="col-md-6" label={t("custom_date_time_format.format_string")}>
<FormTextBox
placeholder="YYYY-MM-DD HH:mm"
currentValue={customDateTimeFormat || "YYYY-MM-DD HH:mm"} onChange={setCustomDateTimeFormat}
/>
</FormGroup>
/>
</FormGroup>
<FormGroup name="formatted-date" className="col-md-6" label={t("custom_date_time_format.formatted_time")}>
<div>
{formatDateTime(new Date(), customDateTimeFormat)}
</div>
</FormGroup>
<FormGroup name="formatted-date" className="col-md-6" label={t("custom_date_time_format.formatted_time")}>
<div>
{formatDateTime(new Date(), customDateTimeFormat)}
</div>
</FormGroup>
</div>
</OptionsSection>
)
);
}

View File

@ -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
/>
</TouchBar>
</>
)
);
}
function useNoteLanguage(note: FNote) {

View File

@ -51,8 +51,9 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
"imageMaxWidthHeight",
"imageJpegQuality",
"leftPaneWidth",
"rightPaneWidth",
"leftPaneVisible",
"rightPaneWidth",
"rightPaneCollapsedItems",
"rightPaneVisible",
"nativeTitleBarVisible",
"headingStyle",

View File

@ -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

View File

@ -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, 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";

View File

@ -77,6 +77,7 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions<KeyboardActi
imageJpegQuality: number;
leftPaneWidth: number;
rightPaneWidth: number;
rightPaneCollapsedItems: string;
eraseEntitiesAfterTimeInSeconds: number;
eraseEntitiesAfterTimeScale: number;
autoReadonlySizeText: number;