Port floating buttons to React (#6811)

This commit is contained in:
Elian Doran 2025-08-28 21:01:43 +03:00 committed by GitHub
commit 88bbc7e8c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 768 additions and 1155 deletions

View file

@ -64,7 +64,7 @@ export interface NoteMetaData {
/**
* Note is the main node and concept in Trilium.
*/
class FNote {
export default class FNote {
private froca: Froca;
noteId!: string;
@ -1035,5 +1035,3 @@ class FNote {
return await server.get<NoteMetaData>(`notes/${this.noteId}/metadata`);
}
}
export default FNote;

View file

@ -22,40 +22,26 @@ import LeftPaneToggleWidget from "../widgets/buttons/left_pane_toggle.js";
import CreatePaneButton from "../widgets/buttons/create_pane_button.js";
import ClosePaneButton from "../widgets/buttons/close_pane_button.js";
import RightPaneContainer from "../widgets/containers/right_pane_container.js";
import EditButton from "../widgets/floating_buttons/edit_button.js";
import ShowTocWidgetButton from "../widgets/buttons/show_toc_widget_button.js";
import ShowHighlightsListWidgetButton from "../widgets/buttons/show_highlights_list_widget_button.js";
import NoteWrapperWidget from "../widgets/note_wrapper.js";
import BacklinksWidget from "../widgets/floating_buttons/zpetne_odkazy.js";
import SharedInfoWidget from "../widgets/shared_info.js";
import FindWidget from "../widgets/find.js";
import TocWidget from "../widgets/toc.js";
import HighlightsListWidget from "../widgets/highlights_list.js";
import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js";
import FloatingButtons from "../widgets/floating_buttons/floating_buttons.js";
import RelationMapButtons from "../widgets/floating_buttons/relation_map_buttons.js";
import SvgExportButton from "../widgets/floating_buttons/svg_export_button.js";
import LauncherContainer from "../widgets/containers/launcher_container.js";
import CodeButtonsWidget from "../widgets/floating_buttons/code_buttons.js";
import ApiLogWidget from "../widgets/api_log.js";
import HideFloatingButtonsButton from "../widgets/floating_buttons/hide_floating_buttons_button.js";
import MovePaneButton from "../widgets/buttons/move_pane_button.js";
import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js";
import CopyImageReferenceButton from "../widgets/floating_buttons/copy_image_reference_button.js";
import ScrollPaddingWidget from "../widgets/scroll_padding.js";
import options from "../services/options.js";
import utils from "../services/utils.js";
import GeoMapButtons from "../widgets/floating_buttons/geo_map_button.js";
import ContextualHelpButton from "../widgets/floating_buttons/help_button.js";
import CloseZenButton from "../widgets/close_zen_button.js";
import type { AppContext } from "../components/app_context.js";
import type { WidgetsByParent } from "../services/bundle.js";
import SwitchSplitOrientationButton from "../widgets/floating_buttons/switch_layout_button.js";
import ToggleReadOnlyButton from "../widgets/floating_buttons/toggle_read_only_button.js";
import PngExportButton from "../widgets/floating_buttons/png_export_button.js";
import RefreshButton from "../widgets/floating_buttons/refresh_button.js";
import { applyModals } from "./layout_commons.js";
import Ribbon from "../widgets/ribbon/Ribbon.jsx";
import FloatingButtons from "../widgets/FloatingButtons.jsx";
import { DESKTOP_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx";
export default class DesktopLayout {
@ -145,24 +131,7 @@ export default class DesktopLayout {
.child(<Ribbon />)
.child(new SharedInfoWidget())
.child(new WatchedFileUpdateStatusWidget())
.child(
new FloatingButtons()
.child(new RefreshButton())
.child(new SwitchSplitOrientationButton())
.child(new ToggleReadOnlyButton())
.child(new EditButton())
.child(new ShowTocWidgetButton())
.child(new ShowHighlightsListWidgetButton())
.child(new CodeButtonsWidget())
.child(new RelationMapButtons())
.child(new GeoMapButtons())
.child(new CopyImageReferenceButton())
.child(new SvgExportButton())
.child(new PngExportButton())
.child(new BacklinksWidget())
.child(new ContextualHelpButton())
.child(new HideFloatingButtonsButton())
)
.child(<FloatingButtons items={DESKTOP_FLOATING_BUTTONS} />)
.child(
new ScrollingContainer()
.filling()

View file

@ -7,12 +7,6 @@ import ToggleSidebarButtonWidget from "../widgets/mobile_widgets/toggle_sidebar_
import MobileDetailMenuWidget from "../widgets/mobile_widgets/mobile_detail_menu.js";
import ScreenContainer from "../widgets/mobile_widgets/screen_container.js";
import ScrollingContainer from "../widgets/containers/scrolling_container.js";
import FloatingButtons from "../widgets/floating_buttons/floating_buttons.js";
import EditButton from "../widgets/floating_buttons/edit_button.js";
import RelationMapButtons from "../widgets/floating_buttons/relation_map_buttons.js";
import SvgExportButton from "../widgets/floating_buttons/svg_export_button.js";
import BacklinksWidget from "../widgets/floating_buttons/zpetne_odkazy.js";
import HideFloatingButtonsButton from "../widgets/floating_buttons/hide_floating_buttons_button.js";
import NoteListWidget from "../widgets/note_list.js";
import GlobalMenuWidget from "../widgets/buttons/global_menu.js";
import LauncherContainer from "../widgets/containers/launcher_container.js";
@ -22,14 +16,13 @@ import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
import SidebarContainer from "../widgets/mobile_widgets/sidebar_container.js";
import type AppContext from "../components/app_context.js";
import TabRowWidget from "../widgets/tab_row.js";
import RefreshButton from "../widgets/floating_buttons/refresh_button.js";
import MobileEditorToolbar from "../widgets/type_widgets/ckeditor/mobile_editor_toolbar.js";
import { applyModals } from "./layout_commons.js";
import CloseZenButton from "../widgets/close_zen_button.js";
import FilePropertiesTab from "../widgets/ribbon/FilePropertiesTab.jsx";
import { useNoteContext } from "../widgets/react/hooks.jsx";
import { useContext } from "preact/hooks";
import { ParentComponent } from "../widgets/react/react_utils.jsx";
import FloatingButtons from "../widgets/FloatingButtons.jsx";
import { MOBILE_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.jsx";
const MOBILE_CSS = `
<style>
@ -151,15 +144,7 @@ export default class MobileLayout {
.child(new MobileDetailMenuWidget(true).contentSized())
)
.child(new SharedInfoWidget())
.child(
new FloatingButtons()
.child(new RefreshButton())
.child(new EditButton())
.child(new RelationMapButtons())
.child(new SvgExportButton())
.child(new BacklinksWidget())
.child(new HideFloatingButtonsButton())
)
.child(<FloatingButtons items={MOBILE_FLOATING_BUTTONS} />)
.child(new PromotedAttributesWidget())
.child(
new ScrollingContainer()

View file

@ -1,7 +1,7 @@
import { t } from "./i18n.js";
import toastService, { showError } from "./toast.js";
function copyImageReferenceToClipboard($imageWrapper: JQuery<HTMLElement>) {
export function copyImageReferenceToClipboard($imageWrapper: JQuery<HTMLElement>) {
try {
$imageWrapper.attr("contenteditable", "true");
selectImage($imageWrapper.get(0));

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { byBookType, byNoteType } from "./help_button.js";
import { byBookType, byNoteType } from "./in_app_help.js";
import fs from "fs";
import type { HiddenSubtreeItem } from "@triliumnext/commons";
import path from "path";
@ -25,7 +25,7 @@ describe("Help button", () => {
...Object.values(byBookType)
].filter((noteId) => noteId) as string[];
const metaPath = path.resolve(path.join(__dirname, "../../../../server/src/assets/doc_notes/en/User Guide/!!!meta.json"));
const metaPath = path.resolve(path.join(__dirname, "../../../server/src/assets/doc_notes/en/User Guide/!!!meta.json"));
const meta: HiddenSubtreeItem[] = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
const allNoteIds = new Set(getNoteIds(meta));

View file

@ -0,0 +1,43 @@
import { NoteType } from "@triliumnext/commons";
import { ViewTypeOptions } from "./note_list_renderer";
import FNote from "../entities/fnote";
export const byNoteType: Record<Exclude<NoteType, "book">, string | null> = {
canvas: null,
code: null,
contentWidget: null,
doc: null,
file: null,
image: null,
launcher: null,
mermaid: null,
mindMap: null,
noteMap: null,
relationMap: null,
render: null,
search: null,
text: null,
webView: null,
aiChat: null
};
export const byBookType: Record<ViewTypeOptions, string | null> = {
list: "mULW0Q3VojwY",
grid: "8QqnMzx393bx",
calendar: "xWbu3jpNWapp",
table: "2FvYrpmOXm29",
geoMap: "81SGnPGMk7Xc",
board: "CtBQqbwXDx1w"
};
export function getHelpUrlForNote(note: FNote | null | undefined) {
if (note && note.type !== "book" && byNoteType[note.type]) {
return byNoteType[note.type];
} else if (note?.hasLabel("calendarRoot")) {
return "l0tKav7yLHGF";
} else if (note?.hasLabel("textSnippet")) {
return "pwc194wlRzcH";
} else if (note && note.type === "book") {
return byBookType[note.getAttributeValue("label", "viewType") as ViewTypeOptions ?? ""]
}
}

View file

@ -1,5 +1,6 @@
import dayjs from "dayjs";
import type { ViewScope } from "./link.js";
import FNote from "../entities/fnote";
const SVG_MIME = "image/svg+xml";
@ -574,8 +575,7 @@ function copyHtmlToClipboard(content: string) {
document.removeEventListener("copy", listener);
}
// TODO: Set to FNote once the file is ported.
function createImageSrcUrl(note: { noteId: string; title: string }) {
export function createImageSrcUrl(note: FNote) {
return `api/images/${note.noteId}/${encodeURIComponent(note.title)}?timestamp=${Date.now()}`;
}

View file

@ -1375,7 +1375,7 @@ div.floating-buttons-children .floating-button:active {
}
/* The first visible floating button */
div.floating-buttons-children > *:nth-child(1 of .visible) {
div.floating-buttons-children > *:first-child {
--border-radius: var(--border-radius-size) 0 0 var(--border-radius-size);
border-radius: var(--border-radius);
}
@ -1477,13 +1477,6 @@ div.floating-buttons-children .close-floating-buttons:has(.close-floating-button
padding-inline-start: 8px;
}
/* Copy image reference */
.floating-buttons .copy-image-reference-button .hidden-image-copy {
/* Take out of the the hidden image from flexbox to prevent the layout being affected */
position: absolute;
}
/* Code, relation map buttons */
.floating-buttons .code-buttons-widget,

View file

@ -0,0 +1,163 @@
/* #region Generic floating buttons styles */
.floating-buttons {
position: relative;
}
.floating-buttons-children,
.show-floating-buttons {
position: absolute;
top: 10px;
right: 10px;
display: flex;
flex-direction: row;
z-index: 100;
}
.note-split.rtl .floating-buttons-children,
.note-split.rtl .show-floating-buttons {
right: unset;
left: 10px;
}
.note-split.rtl .close-floating-buttons {
order: -1;
}
.note-split.rtl .close-floating-buttons,
.note-split.rtl .show-floating-buttons {
transform: rotate(180deg);
}
.type-canvas .floating-buttons-children {
top: 70px;
}
.type-canvas .floating-buttons-children > * {
--border-radius: 0; /* Overridden by themes */
}
.floating-buttons-children > *:not(.hidden-int):not(.no-content-hidden) {
margin: 2px;
}
.floating-buttons-children > *:not(.has-overflow) {
overflow: hidden;
}
.floating-buttons-children > button, .floating-buttons-children .floating-button {
font-size: 150%;
padding: 5px 10px 4px 10px;
width: 40px;
cursor: pointer;
color: var(--button-text-color);
background: var(--button-background-color);
border-radius: var(--button-border-radius);
border: 1px solid transparent;
display: flex;
justify-content: space-around;
}
.floating-buttons-children > button:hover, .floating-buttons-children .floating-button:hover {
text-decoration: none;
border-color: var(--button-border-color);
}
.floating-buttons .floating-buttons-children.temporarily-hidden {
display: none;
}
/* #endregion */
/* #region Show floating button */
.floating-buttons-children.temporarily-hidden+.show-floating-buttons {
display: block;
}
.show-floating-buttons {
/* display: none;*/
margin-left: 5px !important;
}
.show-floating-buttons-button {
border: 1px solid transparent;
color: var(--button-text-color);
padding: 6px;
border-radius: 100px !important;
}
.show-floating-buttons-button:hover {
border: 1px solid var(--button-border-color);
}
/* #endregion */
/* #region Geo map buttons */
.leaflet-pane {
z-index: 50;
}
/* #endregion */
/* #region Close floating buttons */
.close-floating-buttons {
margin-left: 5px !important;
}
.close-floating-buttons:first-child {
display: none !important;
}
.close-floating-buttons-button {
border: 1px solid transparent;
color: var(--button-text-color);
padding: 6px;
border-radius: 100px;
}
.close-floating-buttons-button:hover {
border: 1px solid var(--button-border-color);
}
/* #endregion */
/* #region Backlinks */
.backlinks-widget {
position: relative;
}
.backlinks-ticker {
border-radius: 10px;
border-color: var(--main-border-color);
background-color: var(--more-accented-background-color);
padding: 4px 10px 4px 10px;
opacity: 90%;
display: flex;
justify-content: space-between;
align-items: center;
}
.backlinks-count {
cursor: pointer;
}
.backlinks-items {
z-index: 10;
position: absolute;
top: 50px;
right: 10px;
width: 400px;
border-radius: 10px;
background-color: var(--accented-background-color);
color: var(--main-text-color);
padding: 20px;
overflow-y: auto;
}
.backlink-excerpt {
border-left: 2px solid var(--main-border-color);
padding-left: 10px;
opacity: 80%;
font-size: 90%;
}
.backlink-excerpt .backlink-link { /* the actual backlink */
font-weight: bold;
background-color: yellow;
}
/* #endregion */

View file

@ -0,0 +1,94 @@
import { t } from "i18next";
import "./FloatingButtons.css";
import { useNoteContext, useNoteLabel, useNoteLabelBoolean } from "./react/hooks";
import { useContext, useEffect, useMemo, useState } from "preact/hooks";
import { ParentComponent } from "./react/react_utils";
import { EventData, EventNames } from "../components/app_context";
import { type FloatingButtonsList, type FloatingButtonContext } from "./FloatingButtonsDefinitions";
import ActionButton from "./react/ActionButton";
import { ViewTypeOptions } from "../services/note_list_renderer";
interface FloatingButtonsProps {
items: FloatingButtonsList;
}
/*
* Note:
*
* For floating button widgets that require content to overflow, the has-overflow CSS class should
* be applied to the root element of the widget. Additionally, this root element may need to
* properly handle rounded corners, as defined by the --border-radius CSS variable.
*/
export default function FloatingButtons({ items }: FloatingButtonsProps) {
const { note, noteContext } = useNoteContext();
const parentComponent = useContext(ParentComponent);
const [ viewType ] = useNoteLabel(note, "viewType");
const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly");
const context = useMemo<FloatingButtonContext | null>(() => {
if (!note || !noteContext || !parentComponent) return null;
return {
note,
noteContext,
parentComponent,
isDefaultViewMode: noteContext.viewScope?.viewMode === "default",
viewType: viewType as ViewTypeOptions,
isReadOnly,
triggerEvent<T extends EventNames>(name: T, data?: Omit<EventData<T>, "ntxId">) {
parentComponent.triggerEvent(name, {
ntxId: noteContext.ntxId,
...data
} as EventData<T>);
}
};
}, [ note, noteContext, parentComponent, viewType, isReadOnly ]);
// Manage the user-adjustable visibility of the floating buttons.
const [ visible, setVisible ] = useState(true);
useEffect(() => setVisible(true), [ note ]);
return (
<div className="floating-buttons no-print">
<div className={`floating-buttons-children ${!visible ? "temporarily-hidden" : ""}`}>
{context && items.map((Component) => (
<Component {...context} />
))}
{visible && <CloseFloatingButton setVisible={setVisible} />}
</div>
{!visible && <ShowFloatingButton setVisible={setVisible} /> }
</div>
)
}
/**
* Show button that displays floating button after click on close button
*/
function ShowFloatingButton({ setVisible }: { setVisible(visible: boolean): void }) {
return (
<div className="show-floating-buttons">
<ActionButton
className="show-floating-buttons-button"
icon="bx bx-chevrons-left"
text={t("show_floating_buttons_button.button_title")}
onClick={() => setVisible(true)}
noIconActionClass
/>
</div>
);
}
function CloseFloatingButton({ setVisible }: { setVisible(visible: boolean): void }) {
return (
<div className="close-floating-buttons">
<ActionButton
className="close-floating-buttons-button"
icon="bx bx-chevrons-right"
text={t("hide_floating_buttons_button.button_title")}
onClick={() => setVisible(false)}
noIconActionClass
/>
</div>
);
}

View file

@ -0,0 +1,398 @@
import { VNode } from "preact";
import appContext, { EventData, EventNames } from "../components/app_context";
import Component from "../components/component";
import NoteContext from "../components/note_context";
import FNote from "../entities/fnote";
import ActionButton, { ActionButtonProps } from "./react/ActionButton";
import { useNoteLabelBoolean, useTriliumEvent, useTriliumOption, useWindowSize } from "./react/hooks";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
import { createImageSrcUrl, openInAppHelpFromUrl } from "../services/utils";
import server from "../services/server";
import { BacklinkCountResponse, BacklinksResponse, SaveSqlConsoleResponse } from "@triliumnext/commons";
import toast from "../services/toast";
import { t } from "../services/i18n";
import { copyImageReferenceToClipboard } from "../services/image";
import tree from "../services/tree";
import protected_session_holder from "../services/protected_session_holder";
import options from "../services/options";
import { getHelpUrlForNote } from "../services/in_app_help";
import froca from "../services/froca";
import NoteLink from "./react/NoteLink";
import RawHtml from "./react/RawHtml";
import { ViewTypeOptions } from "../services/note_list_renderer";
export interface FloatingButtonContext {
parentComponent: Component;
note: FNote;
noteContext: NoteContext;
isDefaultViewMode: boolean;
isReadOnly: boolean;
/** Shorthand for triggering an event from the parent component. The `ntxId` is automatically handled for convenience. */
triggerEvent<T extends EventNames>(name: T, data?: Omit<EventData<T>, "ntxId">): void;
viewType?: ViewTypeOptions | null;
}
function FloatingButton({ className, ...props }: ActionButtonProps) {
return <ActionButton
className={`floating-button ${className ?? ""}`}
noIconActionClass
{...props}
/>
}
export type FloatingButtonsList = ((context: FloatingButtonContext) => false | VNode)[];
export const DESKTOP_FLOATING_BUTTONS: FloatingButtonsList = [
RefreshBackendLogButton,
SwitchSplitOrientationButton,
ToggleReadOnlyButton,
EditButton,
ShowTocWidgetButton,
ShowHighlightsListWidgetButton,
RunActiveNoteButton,
OpenTriliumApiDocsButton,
SaveToNoteButton,
RelationMapButtons,
GeoMapButtons,
CopyImageReferenceButton,
ExportImageButtons,
InAppHelpButton,
Backlinks
];
export const MOBILE_FLOATING_BUTTONS: FloatingButtonsList = [
RefreshBackendLogButton,
EditButton,
RelationMapButtons,
ExportImageButtons,
Backlinks
]
function RefreshBackendLogButton({ note, parentComponent, noteContext, isDefaultViewMode }: FloatingButtonContext) {
const isEnabled = note.noteId === "_backendLog" && isDefaultViewMode;
return isEnabled && <FloatingButton
text={t("backend_log.refresh")}
icon="bx bx-refresh"
onClick={() => parentComponent.triggerEvent("refreshData", { ntxId: noteContext.ntxId })}
/>
}
function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: FloatingButtonContext) {
const isEnabled = note.type === "mermaid" && note.isContentAvailable() && !isReadOnly && isDefaultViewMode;
const [ splitEditorOrientation, setSplitEditorOrientation ] = useTriliumOption("splitEditorOrientation");
const upcomingOrientation = splitEditorOrientation === "horizontal" ? "vertical" : "horizontal";
return isEnabled && <FloatingButton
text={upcomingOrientation === "vertical" ? t("switch_layout_button.title_vertical") : t("switch_layout_button.title_horizontal")}
icon={upcomingOrientation === "vertical" ? "bx bxs-dock-bottom" : "bx bxs-dock-left"}
onClick={() => setSplitEditorOrientation(upcomingOrientation)}
/>
}
function ToggleReadOnlyButton({ note, viewType, isDefaultViewMode }: FloatingButtonContext) {
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
const isEnabled = (note.type === "mermaid" || viewType === "geoMap")
&& note.isContentAvailable() && isDefaultViewMode;
return isEnabled && <FloatingButton
text={isReadOnly ? t("toggle_read_only_button.unlock-editing") : t("toggle_read_only_button.lock-editing")}
icon={isReadOnly ? "bx bx-lock-open-alt" : "bx bx-lock-alt"}
onClick={() => setReadOnly(!isReadOnly)}
/>
}
function EditButton({ note, noteContext, isDefaultViewMode }: FloatingButtonContext) {
const [ animationClass, setAnimationClass ] = useState("");
const [ isEnabled, setIsEnabled ] = useState(false);
useEffect(() => {
noteContext.isReadOnly().then(isReadOnly => {
setIsEnabled(
isDefaultViewMode
&& (!note.isProtected || protected_session_holder.isProtectedSessionAvailable())
&& !options.is("databaseReadonly")
&& isReadOnly
);
});
}, [ note ]);
useTriliumEvent("readOnlyTemporarilyDisabled", ({ noteContext: eventNoteContext }) => {
if (noteContext?.ntxId === eventNoteContext.ntxId) {
setIsEnabled(false);
}
});
// make the edit button stand out on the first display, otherwise
// it's difficult to notice that the note is readonly
useEffect(() => {
if (isEnabled) {
setAnimationClass("bx-tada bx-lg");
setTimeout(() => {
setAnimationClass("");
}, 1700);
}
}, [ isEnabled ]);
return isEnabled && <FloatingButton
text={t("edit_button.edit_this_note")}
icon="bx bx-pencil"
className={animationClass}
onClick={() => {
if (noteContext.viewScope) {
noteContext.viewScope.readOnlyTemporarilyDisabled = true;
appContext.triggerEvent("readOnlyTemporarilyDisabled", { noteContext });
}
}}
/>
}
function ShowTocWidgetButton({ note, noteContext, isDefaultViewMode }: FloatingButtonContext) {
const [ isEnabled, setIsEnabled ] = useState(false);
useTriliumEvent("reEvaluateTocWidgetVisibility", () => {
setIsEnabled(note.type === "text" && isDefaultViewMode && !!noteContext.viewScope?.tocTemporarilyHidden);
});
return isEnabled && <FloatingButton
text={t("show_toc_widget_button.show_toc")}
icon="bx bx-tn-toc"
onClick={() => {
if (noteContext?.viewScope && noteContext.noteId) {
noteContext.viewScope.tocTemporarilyHidden = false;
appContext.triggerEvent("showTocWidget", { noteId: noteContext.noteId });
}
}}
/>
}
function ShowHighlightsListWidgetButton({ note, noteContext, isDefaultViewMode }: FloatingButtonContext) {
const [ isEnabled, setIsEnabled ] = useState(false);
useTriliumEvent("reEvaluateHighlightsListWidgetVisibility", () => {
setIsEnabled(note.type === "text" && isDefaultViewMode && !!noteContext.viewScope?.highlightsListTemporarilyHidden);
});
return isEnabled && <FloatingButton
text={t("show_highlights_list_widget_button.show_highlights_list")}
icon="bx bx-bookmarks"
onClick={() => {
if (noteContext?.viewScope && noteContext.noteId) {
noteContext.viewScope.highlightsListTemporarilyHidden = false;
appContext.triggerEvent("showHighlightsListWidget", { noteId: noteContext.noteId });
}
}}
/>
}
function RunActiveNoteButton({ note }: FloatingButtonContext) {
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")}
triggerCommand="runActiveNote"
/>
}
function OpenTriliumApiDocsButton({ note }: FloatingButtonContext) {
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")}
onClick={() => openInAppHelpFromUrl(note.mime.endsWith("frontend") ? "Q2z6av6JZVWm" : "MEtfsqa5VwNi")}
/>
}
function SaveToNoteButton({ note }: FloatingButtonContext) {
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")}
onClick={async (e) => {
e.preventDefault();
const { notePath } = await server.post<SaveSqlConsoleResponse>("special-notes/save-sql-console", { sqlConsoleNoteId: note.noteId });
if (notePath) {
toast.showMessage(t("code_buttons.sql_console_saved_message", { "note_path": await tree.getNotePathTitle(notePath) }));
// TODO: This hangs the navigation, for some reason.
//await ws.waitForMaxKnownEntityChangeId();
await appContext.tabManager.getActiveContext()?.setNote(notePath);
}
}}
/>
}
function RelationMapButtons({ note, triggerEvent }: FloatingButtonContext) {
const isEnabled = (note.type === "relationMap");
return isEnabled && (
<>
<FloatingButton
icon="bx bx-folder-plus"
text={t("relation_map_buttons.create_child_note_title")}
onClick={() => triggerEvent("relationMapCreateChildNote")}
/>
<FloatingButton
icon="bx bx-crop"
text={t("relation_map_buttons.reset_pan_zoom_title")}
onClick={() => triggerEvent("relationMapResetPanZoom")}
/>
<div className="btn-group">
<FloatingButton
icon="bx bx-zoom-in"
text={t("relation_map_buttons.zoom_in_title")}
onClick={() => triggerEvent("relationMapResetZoomIn")}
/>
<FloatingButton
icon="bx bx-zoom-out"
text={t("relation_map_buttons.zoom_out_title")}
onClick={() => triggerEvent("relationMapResetZoomOut")}
/>
</div>
</>
)
}
function GeoMapButtons({ triggerEvent, viewType, isReadOnly }: FloatingButtonContext) {
const isEnabled = viewType === "geoMap" && !isReadOnly;
return isEnabled && (
<FloatingButton
icon="bx bx-plus-circle"
text={t("geo-map.create-child-note-title")}
onClick={() => triggerEvent("geoMapCreateChildNote")}
/>
);
}
function CopyImageReferenceButton({ note, isDefaultViewMode }: FloatingButtonContext) {
const hiddenImageCopyRef = useRef<HTMLDivElement>(null);
const isEnabled = ["mermaid", "canvas", "mindMap"].includes(note?.type ?? "")
&& note?.isContentAvailable() && isDefaultViewMode;
return isEnabled && (
<>
<FloatingButton
icon="bx bx-copy"
text={t("copy_image_reference_button.button_title")}
onClick={() => {
if (!hiddenImageCopyRef.current) return;
const imageEl = document.createElement("img");
imageEl.src = createImageSrcUrl(note);
hiddenImageCopyRef.current.replaceChildren(imageEl);
copyImageReferenceToClipboard($(hiddenImageCopyRef.current));
hiddenImageCopyRef.current.removeChild(imageEl);
}}
/>
<div ref={hiddenImageCopyRef} className="hidden-image-copy" style={{
position: "absolute" // Take out of the the hidden image from flexbox to prevent the layout being affected
}} />
</>
)
}
function ExportImageButtons({ note, triggerEvent, isDefaultViewMode }: FloatingButtonContext) {
const isEnabled = ["mermaid", "mindMap"].includes(note?.type ?? "")
&& note?.isContentAvailable() && isDefaultViewMode;
return isEnabled && (
<>
<FloatingButton
icon="bx bxs-file-image"
text={t("svg_export_button.button_title")}
onClick={() => triggerEvent("exportSvg")}
/>
<FloatingButton
icon="bx bxs-file-png"
text={t("png_export_button.button_title")}
onClick={() => triggerEvent("exportPng")}
/>
</>
)
}
function InAppHelpButton({ note }: FloatingButtonContext) {
const helpUrl = getHelpUrlForNote(note);
return !!helpUrl && (
<FloatingButton
icon="bx bx-help-circle"
text={t("help-button.title")}
onClick={() => helpUrl && openInAppHelpFromUrl(helpUrl)}
/>
)
}
function Backlinks({ note, isDefaultViewMode }: FloatingButtonContext) {
let [ backlinkCount, setBacklinkCount ] = useState(0);
let [ popupOpen, setPopupOpen ] = useState(false);
const backlinksContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isDefaultViewMode) return;
server.get<BacklinkCountResponse>(`note-map/${note.noteId}/backlink-count`).then(resp => {
setBacklinkCount(resp.count);
});
}, [ note ]);
// Determine the max height of the container.
const { windowHeight } = useWindowSize();
useLayoutEffect(() => {
const el = backlinksContainerRef.current;
if (popupOpen && el) {
const box = el.getBoundingClientRect();
const maxHeight = windowHeight - box.top - 10;
el.style.maxHeight = `${maxHeight}px`;
}
}, [ popupOpen, windowHeight ]);
const isEnabled = isDefaultViewMode && backlinkCount > 0;
return (isEnabled &&
<div className="backlinks-widget has-overflow">
<div
className="backlinks-ticker"
onClick={() => setPopupOpen(!popupOpen)}
>
<span className="backlinks-count">{t("zpetne_odkazy.backlink", { count: backlinkCount })}</span>
</div>
{popupOpen && (
<div ref={backlinksContainerRef} className="backlinks-items dropdown-menu" style={{ display: "block" }}>
<BacklinksList noteId={note.noteId} />
</div>
)}
</div>
);
}
function BacklinksList({ noteId }: { noteId: string }) {
const [ backlinks, setBacklinks ] = useState<BacklinksResponse>([]);
useEffect(() => {
server.get<BacklinksResponse>(`note-map/${noteId}/backlinks`).then(async (backlinks) => {
// prefetch all
const noteIds = backlinks
.filter(bl => "noteId" in bl)
.map((bl) => bl.noteId);
await froca.getNotes(noteIds);
setBacklinks(backlinks);
});
}, [ noteId ]);
return backlinks.map(backlink => (
<div>
<NoteLink
notePath={backlink.noteId}
showNotePath showNoteIcon
noPreview
/>
{"relationName" in backlink ? (
<p>{backlink.relationName}</p>
) : (
backlink.excerpts.map(excerpt => (
<RawHtml html={excerpt} />
))
)}
</div>
));
}

View file

@ -1,62 +0,0 @@
import OnClickButtonWidget from "./onclick_button.js";
import appContext from "../../components/app_context.js";
import attributeService from "../../services/attributes.js";
import { t } from "../../services/i18n.js";
import LoadResults from "../../services/load_results.js";
import type { AttributeRow } from "../../services/load_results.js";
export default class ShowHighlightsListWidgetButton extends OnClickButtonWidget {
isEnabled(): boolean {
return Boolean(super.isEnabled() && this.note && this.note.type === "text" && this.noteContext?.viewScope?.viewMode === "default");
}
constructor() {
super();
this.icon("bx-bookmarks")
.title(t("show_highlights_list_widget_button.show_highlights_list"))
.titlePlacement("bottom")
.onClick(() => {
if (this.noteContext?.viewScope && this.noteId) {
this.noteContext.viewScope.highlightsListTemporarilyHidden = false;
appContext.triggerEvent("showHighlightsListWidget", { noteId: this.noteId });
}
this.toggleInt(false);
});
}
async refreshWithNote(): Promise<void> {
if (this.noteContext?.viewScope) {
this.toggleInt(this.noteContext.viewScope.highlightsListTemporarilyHidden);
}
}
async reEvaluateHighlightsListWidgetVisibilityEvent({ noteId }: { noteId: string }): Promise<void> {
if (noteId === this.noteId) {
await this.refresh();
}
}
async entitiesReloadedEvent({ loadResults }: { loadResults: LoadResults }): Promise<void> {
if (this.noteId && loadResults.isNoteContentReloaded(this.noteId)) {
await this.refresh();
} else if (
loadResults
.getAttributeRows()
.find((attr: AttributeRow) =>
attr.type === "label" &&
(attr.name?.toLowerCase().includes("readonly") || attr.name === "hideHighlightWidget") &&
this.note &&
attributeService.isAffecting(attr, this.note)
)
) {
await this.refresh();
}
}
async noteTypeMimeChangedEvent({ noteId }: { noteId: string }): Promise<void> {
if (this.isNote(noteId)) {
await this.refresh();
}
}
}

View file

@ -1,62 +0,0 @@
import OnClickButtonWidget from "./onclick_button.js";
import appContext from "../../components/app_context.js";
import attributeService from "../../services/attributes.js";
import { t } from "../../services/i18n.js";
import LoadResults from "../../services/load_results.js";
import type { AttributeRow } from "../../services/load_results.js";
export default class ShowTocWidgetButton extends OnClickButtonWidget {
isEnabled(): boolean {
return Boolean(super.isEnabled() && this.note && this.note.type === "text" && this.noteContext?.viewScope?.viewMode === "default");
}
constructor() {
super();
this.icon("bx-tn-toc")
.title(t("show_toc_widget_button.show_toc"))
.titlePlacement("bottom")
.onClick(() => {
if (this.noteContext?.viewScope && this.noteId) {
this.noteContext.viewScope.tocTemporarilyHidden = false;
appContext.triggerEvent("showTocWidget", { noteId: this.noteId });
}
this.toggleInt(false);
});
}
async refreshWithNote(): Promise<void> {
if (this.noteContext?.viewScope) {
this.toggleInt(this.noteContext.viewScope.tocTemporarilyHidden);
}
}
async reEvaluateTocWidgetVisibilityEvent({ noteId }: { noteId: string }): Promise<void> {
if (noteId === this.noteId) {
await this.refresh();
}
}
async entitiesReloadedEvent({ loadResults }: { loadResults: LoadResults }): Promise<void> {
if (this.noteId && loadResults.isNoteContentReloaded(this.noteId)) {
await this.refresh();
} else if (
loadResults
.getAttributeRows()
.find((attr: AttributeRow) =>
attr.type === "label" &&
(attr.name?.toLowerCase().includes("readonly") || attr.name === "toc") &&
this.note &&
attributeService.isAffecting(attr, this.note)
)
) {
await this.refresh();
}
}
async noteTypeMimeChangedEvent({ noteId }: { noteId: string }): Promise<void> {
if (this.isNote(noteId)) {
await this.refresh();
}
}
}

View file

@ -1,93 +0,0 @@
import { t } from "../../services/i18n.js";
import server from "../../services/server.js";
import ws from "../../services/ws.js";
import appContext, { type EventData } from "../../components/app_context.js";
import toastService from "../../services/toast.js";
import treeService from "../../services/tree.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import keyboardActionService from "../../services/keyboard_actions.js";
import type FNote from "../../entities/fnote.js";
const TPL = /*html*/`
<div class="code-buttons-widget">
<style>
.code-buttons-widget {
display: flex;
gap: 10px;
}
</style>
<button data-trigger-command="runActiveNote" class="execute-button floating-button btn" title="${t("code_buttons.execute_button_title")}">
<span class="bx bx-play"></span>
</button>
<button class="trilium-api-docs-button floating-button btn" title="${t("code_buttons.trilium_api_docs_button_title")}">
<span class="bx bx-help-circle"></span>
</button>
<button class="save-to-note-button floating-button btn" title="${t("code_buttons.save_to_note_button_title")}">
<span class="bx bx-save"></span>
</button>
</div>`;
// TODO: Deduplicate with server.
interface SaveSqlConsoleResponse {
notePath: string;
}
export default class CodeButtonsWidget extends NoteContextAwareWidget {
private $openTriliumApiDocsButton!: JQuery<HTMLElement>;
private $executeButton!: JQuery<HTMLElement>;
private $saveToNoteButton!: JQuery<HTMLElement>;
isEnabled() {
return super.isEnabled() && this.note && (this.note.mime.startsWith("application/javascript") || this.note.mime === "text/x-sqlite;schema=trilium");
}
doRender() {
this.$widget = $(TPL);
this.$openTriliumApiDocsButton = this.$widget.find(".trilium-api-docs-button");
this.$openTriliumApiDocsButton.on("click", () => {
toastService.showMessage(t("code_buttons.opening_api_docs_message"));
if (this.note?.mime.endsWith("frontend")) {
window.open("https://triliumnext.github.io/Notes/Script%20API/interfaces/Frontend_Script_API.Api.html", "_blank");
} else {
window.open("https://triliumnext.github.io/Notes/Script%20API/interfaces/Backend_Script_API.Api.html", "_blank");
}
});
this.$executeButton = this.$widget.find(".execute-button");
this.$saveToNoteButton = this.$widget.find(".save-to-note-button");
this.$saveToNoteButton.on("click", async () => {
const { notePath } = await server.post<SaveSqlConsoleResponse>("special-notes/save-sql-console", { sqlConsoleNoteId: this.noteId });
await ws.waitForMaxKnownEntityChangeId();
await appContext.tabManager.getActiveContext()?.setNote(notePath);
toastService.showMessage(t("code_buttons.sql_console_saved_message", { notePath: await treeService.getNotePathTitle(notePath) }));
});
keyboardActionService.updateDisplayedShortcuts(this.$widget);
this.contentSized();
super.doRender();
}
async refreshWithNote(note: FNote) {
this.$executeButton.toggle(note.mime.startsWith("application/javascript") || note.mime === "text/x-sqlite;schema=trilium");
this.$saveToNoteButton.toggle(note.mime === "text/x-sqlite;schema=trilium" && note.isHiddenCompletely());
this.$openTriliumApiDocsButton.toggle(note.mime.startsWith("application/javascript;env="));
}
async noteTypeMimeChangedEvent({ noteId }: EventData<"noteTypeMimeChanged">) {
if (this.isNote(noteId)) {
await this.refresh();
}
}
}

View file

@ -1,42 +0,0 @@
import { t } from "../../services/i18n.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import utils from "../../services/utils.js";
import imageService from "../../services/image.js";
const TPL = /*html*/`
<button type="button"
class="copy-image-reference-button"
title="${t("copy_image_reference_button.button_title")}">
<span class="bx bx-copy"></span>
<div class="hidden-image-copy"></div>
</button>`;
export default class CopyImageReferenceButton extends NoteContextAwareWidget {
private $hiddenImageCopy!: JQuery<HTMLElement>;
isEnabled() {
return super.isEnabled() && ["mermaid", "canvas", "mindMap"].includes(this.note?.type ?? "") && this.note?.isContentAvailable() && this.noteContext?.viewScope?.viewMode === "default";
}
doRender() {
super.doRender();
this.$widget = $(TPL);
this.$hiddenImageCopy = this.$widget.find(".hidden-image-copy");
this.$widget.on("click", () => {
if (!this.note) {
return;
}
this.$hiddenImageCopy.empty().append($("<img>").attr("src", utils.createImageSrcUrl(this.note)));
imageService.copyImageReferenceToClipboard(this.$hiddenImageCopy);
this.$hiddenImageCopy.empty();
});
this.contentSized();
}
}

View file

@ -1,84 +0,0 @@
import OnClickButtonWidget from "../buttons/onclick_button.js";
import appContext from "../../components/app_context.js";
import attributeService from "../../services/attributes.js";
import protectedSessionHolder from "../../services/protected_session_holder.js";
import { t } from "../../services/i18n.js";
import LoadResults from "../../services/load_results.js";
import type { AttributeRow } from "../../services/load_results.js";
import FNote from "../../entities/fnote.js";
import options from "../../services/options.js";
export default class EditButton extends OnClickButtonWidget {
isEnabled(): boolean {
return Boolean(super.isEnabled() && this.note && this.noteContext?.viewScope?.viewMode === "default");
}
constructor() {
super();
this.icon("bx-pencil")
.title(t("edit_button.edit_this_note"))
.titlePlacement("bottom")
.onClick((widget) => {
if (this.noteContext?.viewScope) {
this.noteContext.viewScope.readOnlyTemporarilyDisabled = true;
appContext.triggerEvent("readOnlyTemporarilyDisabled", { noteContext: this.noteContext });
}
});
}
async refreshWithNote(note: FNote): Promise<void> {
if (options.is("databaseReadonly")) {
this.toggleInt(false);
return;
}
if (note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable()) {
this.toggleInt(false);
} else {
// prevent flickering by assuming hidden before async operation
this.toggleInt(false);
const wasVisible = this.isVisible();
// can't do this in isEnabled() since isReadOnly is async
const isReadOnly = await this.noteContext?.isReadOnly();
this.toggleInt(Boolean(isReadOnly));
// make the edit button stand out on the first display, otherwise
// it's difficult to notice that the note is readonly
if (this.isVisible() && !wasVisible && this.$widget) {
this.$widget.addClass("bx-tada bx-lg");
setTimeout(() => {
this.$widget?.removeClass("bx-tada bx-lg");
}, 1700);
}
}
await super.refreshWithNote(note);
}
entitiesReloadedEvent({ loadResults }: { loadResults: LoadResults }): void {
if (loadResults.getAttributeRows().find((attr: AttributeRow) =>
attr.type === "label" &&
attr.name?.toLowerCase().includes("readonly") &&
this.note &&
attributeService.isAffecting(attr, this.note)
)) {
if (this.noteContext?.viewScope) {
this.noteContext.viewScope.readOnlyTemporarilyDisabled = false;
}
this.refresh();
}
}
readOnlyTemporarilyDisabledEvent() {
this.refresh();
}
async noteTypeMimeChangedEvent({ noteId }: { noteId: string }): Promise<void> {
if (this.isNote(noteId)) {
await this.refresh();
}
}
}

View file

@ -1,147 +0,0 @@
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import { t } from "../../services/i18n.js";
import type FNote from "../../entities/fnote.js";
import type BasicWidget from "../basic_widget.js";
/*
* Note:
*
* For floating button widgets that require content to overflow, the has-overflow CSS class should
* be applied to the root element of the widget. Additionally, this root element may need to
* properly handle rounded corners, as defined by the --border-radius CSS variable.
*/
const TPL = /*html*/`
<div class="floating-buttons no-print">
<style>
.floating-buttons {
position: relative;
}
.floating-buttons-children,
.show-floating-buttons {
position: absolute;
top: 10px;
right: 10px;
display: flex;
flex-direction: row;
z-index: 100;
}
.note-split.rtl .floating-buttons-children,
.note-split.rtl .show-floating-buttons {
right: unset;
left: 10px;
}
.note-split.rtl .close-floating-buttons {
order: -1;
}
.note-split.rtl .close-floating-buttons,
.note-split.rtl .show-floating-buttons {
transform: rotate(180deg);
}
.type-canvas .floating-buttons-children {
top: 70px;
}
.type-canvas .floating-buttons-children > * {
--border-radius: 0; /* Overridden by themes */
}
.floating-buttons-children > *:not(.hidden-int):not(.no-content-hidden) {
margin: 2px;
}
.floating-buttons-children > *:not(.has-overflow) {
overflow: hidden;
}
.floating-buttons-children > button, .floating-buttons-children .floating-button {
font-size: 150%;
padding: 5px 10px 4px 10px;
width: 40px;
cursor: pointer;
color: var(--button-text-color);
background: var(--button-background-color);
border-radius: var(--button-border-radius);
border: 1px solid transparent;
display: flex;
justify-content: space-around;
}
.floating-buttons-children > button:hover, .floating-buttons-children .floating-button:hover {
text-decoration: none;
border-color: var(--button-border-color);
}
.floating-buttons .floating-buttons-children.temporarily-hidden {
display: none;
}
</style>
<div class="floating-buttons-children"></div>
<!-- Show button that displays floating button after click on close button -->
<div class="show-floating-buttons">
<style>
.floating-buttons-children.temporarily-hidden+.show-floating-buttons {
display: block;
}
.floating-buttons-children:not(.temporarily-hidden)+.show-floating-buttons {
display: none;
}
.show-floating-buttons {
/* display: none;*/
margin-left: 5px !important;
}
.show-floating-buttons-button {
border: 1px solid transparent;
color: var(--button-text-color);
padding: 6px;
border-radius: 100px;
}
.show-floating-buttons-button:hover {
border: 1px solid var(--button-border-color);
}
</style>
<button type="button" class="show-floating-buttons-button btn bx bx-chevrons-left"
title="${t("show_floating_buttons_button.button_title")}"></button>
</div>
</div>`;
export default class FloatingButtons extends NoteContextAwareWidget {
private $children!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.$children = this.$widget.find(".floating-buttons-children");
for (const widget of this.children) {
if ("render" in widget) {
this.$children.append((widget as BasicWidget).render());
}
}
}
async refreshWithNote(note: FNote) {
this.toggle(true);
this.$widget.find(".show-floating-buttons-button").on("click", () => this.toggle(true));
}
toggle(show: boolean) {
this.$widget.find(".floating-buttons-children").toggleClass("temporarily-hidden", !show);
}
hideFloatingButtonsCommand() {
this.toggle(false);
}
}

View file

@ -1,38 +0,0 @@
import { t } from "../../services/i18n.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
const TPL = /*html*/`\
<div class="geo-map-buttons">
<style>
.geo-map-buttons {
contain: none;
display: flex;
gap: 10px;
}
.leaflet-pane {
z-index: 50;
}
</style>
<button type="button"
class="geo-map-create-child-note floating-button btn bx bx-plus-circle"
title="${t("geo-map.create-child-note-title")}" />
</div>`;
export default class GeoMapButtons extends NoteContextAwareWidget {
isEnabled() {
return super.isEnabled()
&& this.note?.getLabelValue("viewType") === "geoMap"
&& !this.note.hasLabel("readOnly");
}
doRender() {
super.doRender();
this.$widget = $(TPL);
this.$widget.find(".geo-map-create-child-note").on("click", () => this.triggerEvent("geoMapCreateChildNote", { ntxId: this.ntxId }));
}
}

View file

@ -1,78 +0,0 @@
import { type EventData } from "../../components/app_context.js";
import type FNote from "../../entities/fnote.js";
import type { NoteType } from "../../entities/fnote.js";
import { t } from "../../services/i18n.js";
import type { ViewTypeOptions } from "../../services/note_list_renderer.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
const TPL = /*html*/`
<button class="open-contextual-help-button" title="${t("help-button.title")}">
<span class="bx bx-help-circle"></span>
</button>
`;
export const byNoteType: Record<Exclude<NoteType, "book">, string | null> = {
canvas: null,
code: null,
contentWidget: null,
doc: null,
file: null,
image: null,
launcher: null,
mermaid: null,
mindMap: null,
noteMap: null,
relationMap: null,
render: null,
search: null,
text: null,
webView: null,
aiChat: null
};
export const byBookType: Record<ViewTypeOptions, string | null> = {
list: "mULW0Q3VojwY",
grid: "8QqnMzx393bx",
calendar: "xWbu3jpNWapp",
table: "2FvYrpmOXm29",
geoMap: "81SGnPGMk7Xc",
board: "CtBQqbwXDx1w"
};
export default class ContextualHelpButton extends NoteContextAwareWidget {
isEnabled() {
if (!super.isEnabled()) {
return false;
}
return !!ContextualHelpButton.#getUrlToOpen(this.note);
}
doRender() {
this.$widget = $(TPL);
}
static #getUrlToOpen(note: FNote | null | undefined) {
if (note && note.type !== "book" && byNoteType[note.type]) {
return byNoteType[note.type];
} else if (note?.hasLabel("calendarRoot")) {
return "l0tKav7yLHGF";
} else if (note?.hasLabel("textSnippet")) {
return "pwc194wlRzcH";
} else if (note && note.type === "book") {
return byBookType[note.getAttributeValue("label", "viewType") as ViewTypeOptions ?? ""]
}
}
async refreshWithNote(note: FNote | null | undefined): Promise<void> {
this.$widget.attr("data-in-app-help", ContextualHelpButton.#getUrlToOpen(this.note) ?? "");
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (this.note?.type === "book" && loadResults.getAttributeRows().find((attr) => attr.noteId === this.noteId && attr.name === "viewType")) {
this.refresh();
}
}
}

View file

@ -1,43 +0,0 @@
import { t } from "../../services/i18n.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
const TPL = /*html*/`
<div class="close-floating-buttons">
<style>
.close-floating-buttons {
display: none;
margin-left: 5px !important;
}
/* conditionally display close button if there's some other button visible */
.floating-buttons *:not(.hidden-int):not(.hidden-no-content) ~ .close-floating-buttons {
display: block;
}
.close-floating-buttons-button {
border: 1px solid transparent;
color: var(--button-text-color);
padding: 6px;
border-radius: 100px;
}
.close-floating-buttons-button:hover {
border: 1px solid var(--button-border-color);
}
</style>
<button type="button"
class="close-floating-buttons-button btn bx bx-chevrons-right"
title="${t("hide_floating_buttons_button.button_title")}"></button>
</div>
`;
export default class HideFloatingButtonsButton extends NoteContextAwareWidget {
doRender() {
super.doRender();
this.$widget = $(TPL);
this.$widget.on("click", () => this.triggerCommand("hideFloatingButtons"));
this.contentSized();
}
}

View file

@ -1,24 +0,0 @@
import { t } from "../../services/i18n.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
const TPL = /*html*/`
<button type="button"
class="export-svg-button"
title="${t("png_export_button.button_title")}">
<span class="bx bxs-file-png"></span>
</button>
`;
export default class PngExportButton extends NoteContextAwareWidget {
isEnabled() {
return super.isEnabled() && ["mermaid", "mindMap"].includes(this.note?.type ?? "") && this.note?.isContentAvailable() && this.noteContext?.viewScope?.viewMode === "default";
}
doRender() {
super.doRender();
this.$widget = $(TPL);
this.$widget.on("click", () => this.triggerEvent("exportPng", { ntxId: this.ntxId }));
this.contentSized();
}
}

View file

@ -1,21 +0,0 @@
import appContext from "../../components/app_context.js";
import { t } from "../../services/i18n.js";
import OnClickButtonWidget from "../buttons/onclick_button.js";
export default class RefreshButton extends OnClickButtonWidget {
constructor() {
super();
this
.title(t("backend_log.refresh"))
.icon("bx-refresh")
.onClick(() => this.triggerEvent("refreshData", { ntxId: this.noteContext?.ntxId }))
}
isEnabled(): boolean | null | undefined {
return super.isEnabled()
&& this.note?.noteId === "_backendLog"
&& this.noteContext?.viewScope?.viewMode === "default";
}
}

View file

@ -1,60 +0,0 @@
import { t } from "../../services/i18n.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
const TPL = /*html*/`
<div class="relation-map-buttons">
<style>
.relation-map-buttons {
display: flex;
gap: 10px;
}
</style>
<button type="button"
class="relation-map-create-child-note floating-button btn bx bx-folder-plus"
title="${t("relation_map_buttons.create_child_note_title")}"></button>
<button type="button"
class="relation-map-reset-pan-zoom floating-button btn bx bx-crop"
title="${t("relation_map_buttons.reset_pan_zoom_title")}"></button>
<div class="btn-group">
<button type="button"
class="relation-map-zoom-in floating-button btn bx bx-zoom-in"
title="${t("relation_map_buttons.zoom_in_title")}"></button>
<button type="button"
class="relation-map-zoom-out floating-button btn bx bx-zoom-out"
title="${t("relation_map_buttons.zoom_out_title")}"></button>
</div>
</div>`;
export default class RelationMapButtons extends NoteContextAwareWidget {
private $createChildNote!: JQuery<HTMLElement>;
private $zoomInButton!: JQuery<HTMLElement>;
private $zoomOutButton!: JQuery<HTMLElement>;
private $resetPanZoomButton!: JQuery<HTMLElement>;
isEnabled() {
return super.isEnabled() && this.note?.type === "relationMap";
}
doRender() {
super.doRender();
this.$widget = $(TPL);
this.$createChildNote = this.$widget.find(".relation-map-create-child-note");
this.$zoomInButton = this.$widget.find(".relation-map-zoom-in");
this.$zoomOutButton = this.$widget.find(".relation-map-zoom-out");
this.$resetPanZoomButton = this.$widget.find(".relation-map-reset-pan-zoom");
// TODO: Deduplicate object creation here.
this.$createChildNote.on("click", () => this.triggerEvent("relationMapCreateChildNote", { ntxId: this.ntxId }));
this.$resetPanZoomButton.on("click", () => this.triggerEvent("relationMapResetPanZoom", { ntxId: this.ntxId }));
this.$zoomInButton.on("click", () => this.triggerEvent("relationMapResetZoomIn", { ntxId: this.ntxId }));
this.$zoomOutButton.on("click", () => this.triggerEvent("relationMapResetZoomOut", { ntxId: this.ntxId }));
this.contentSized();
}
}

View file

@ -1,24 +0,0 @@
import { t } from "../../services/i18n.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
const TPL = /*html*/`
<button type="button"
class="export-svg-button"
title="${t("svg_export_button.button_title")}">
<span class="bx bxs-file-image"></span>
</button>
`;
export default class SvgExportButton extends NoteContextAwareWidget {
isEnabled() {
return super.isEnabled() && ["mermaid", "mindMap"].includes(this.note?.type ?? "") && this.note?.isContentAvailable() && this.noteContext?.viewScope?.viewMode === "default";
}
doRender() {
super.doRender();
this.$widget = $(TPL);
this.$widget.on("click", () => this.triggerEvent("exportSvg", { ntxId: this.ntxId }));
this.contentSized();
}
}

View file

@ -1,62 +0,0 @@
import type { EventData } from "../../components/app_context.js";
import { t } from "../../services/i18n.js";
import options from "../../services/options.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
const TPL = /*html*/`
<button type="button"
class="switch-layout-button">
<span class="bx"></span>
</button>
`;
export default class SwitchSplitOrientationButton extends NoteContextAwareWidget {
isEnabled() {
return super.isEnabled()
&& ["mermaid"].includes(this.note?.type ?? "")
&& this.note?.isContentAvailable()
&& !this.note?.hasLabel("readOnly")
&& this.noteContext?.viewScope?.viewMode === "default";
}
doRender(): void {
super.doRender();
this.$widget = $(TPL);
this.$widget.on("click", () => {
const currentOrientation = options.get("splitEditorOrientation");
options.save("splitEditorOrientation", toggleOrientation(currentOrientation));
});
this.#adjustIcon();
this.contentSized();
}
#adjustIcon() {
const currentOrientation = options.get("splitEditorOrientation");
const upcomingOrientation = toggleOrientation(currentOrientation);
const $icon = this.$widget.find("span.bx");
$icon
.toggleClass("bxs-dock-bottom", upcomingOrientation === "vertical")
.toggleClass("bxs-dock-left", upcomingOrientation === "horizontal");
if (upcomingOrientation === "vertical") {
this.$widget.attr("title", t("switch_layout_button.title_vertical"));
} else {
this.$widget.attr("title", t("switch_layout_button.title_horizontal"));
}
}
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
if (loadResults.isOptionReloaded("splitEditorOrientation")) {
this.#adjustIcon();
}
}
}
function toggleOrientation(orientation: string) {
if (orientation === "horizontal") {
return "vertical";
} else {
return "horizontal";
}
}

View file

@ -1,58 +0,0 @@
import type FNote from "../../entities/fnote.js";
import attributes from "../../services/attributes.js";
import { t } from "../../services/i18n.js";
import OnClickButtonWidget from "../buttons/onclick_button.js";
export default class ToggleReadOnlyButton extends OnClickButtonWidget {
private isReadOnly?: boolean;
constructor() {
super();
this
.title(() => this.isReadOnly ? t("toggle_read_only_button.unlock-editing") : t("toggle_read_only_button.lock-editing"))
.titlePlacement("bottom")
.icon(() => this.isReadOnly ? "bx-lock-open-alt" : "bx-lock-alt")
.onClick(() => this.#toggleReadOnly());
}
#toggleReadOnly() {
if (!this.noteId || !this.note) {
return;
}
if (this.isReadOnly) {
attributes.removeOwnedLabelByName(this.note, "readOnly");
} else {
attributes.setLabel(this.noteId, "readOnly");
}
}
async refreshWithNote(note: FNote | null | undefined) {
const isReadOnly = !!note?.hasLabel("readOnly");
if (isReadOnly !== this.isReadOnly) {
this.isReadOnly = isReadOnly;
this.refreshIcon();
}
}
isEnabled() {
if (!super.isEnabled()) {
return false;
}
if (!this?.note?.isContentAvailable()) {
return false;
}
if (this.noteContext?.viewScope?.viewMode !== "default") {
return false;
}
return this.note.type === "mermaid" ||
(this.note.getLabelValue("viewType") === "geoMap");
}
}

View file

@ -1,167 +0,0 @@
/**
* !!! Filename is intentionally mangled, because some adblockers don't like the word "backlinks".
*/
import { t } from "../../services/i18n.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import linkService from "../../services/link.js";
import server from "../../services/server.js";
import froca from "../../services/froca.js";
import type FNote from "../../entities/fnote.js";
const TPL = /*html*/`
<div class="backlinks-widget has-overflow">
<style>
.backlinks-widget {
position: relative;
}
.backlinks-ticker {
border-radius: 10px;
border-color: var(--main-border-color);
background-color: var(--more-accented-background-color);
padding: 4px 10px 4px 10px;
opacity: 90%;
display: flex;
justify-content: space-between;
align-items: center;
}
.backlinks-count {
cursor: pointer;
}
.backlinks-items {
z-index: 10;
position: absolute;
top: 50px;
right: 10px;
width: 400px;
border-radius: 10px;
background-color: var(--accented-background-color);
color: var(--main-text-color);
padding: 20px;
overflow-y: auto;
}
.backlink-excerpt {
border-left: 2px solid var(--main-border-color);
padding-left: 10px;
opacity: 80%;
font-size: 90%;
}
.backlink-excerpt .backlink-link { /* the actual backlink */
font-weight: bold;
background-color: yellow;
}
</style>
<div class="backlinks-ticker">
<span class="backlinks-count"></span>
</div>
<div class="backlinks-items dropdown-menu" style="display: none;"></div>
</div>
`;
// TODO: Deduplicate with server
interface Backlink {
noteId: string;
relationName?: string;
excerpts?: string[];
}
export default class BacklinksWidget extends NoteContextAwareWidget {
private $count!: JQuery<HTMLElement>;
private $items!: JQuery<HTMLElement>;
private $ticker!: JQuery<HTMLElement>;
doRender() {
this.$widget = $(TPL);
this.$count = this.$widget.find(".backlinks-count");
this.$items = this.$widget.find(".backlinks-items");
this.$ticker = this.$widget.find(".backlinks-ticker");
this.$count.on("click", () => {
this.$items.toggle();
this.$items.css("max-height", ($(window).height() ?? 0) - (this.$items.offset()?.top ?? 0) - 10);
if (this.$items.is(":visible")) {
this.renderBacklinks();
}
});
this.contentSized();
}
async refreshWithNote(note: FNote) {
this.clearItems();
if (this.noteContext?.viewScope?.viewMode !== "default") {
this.toggle(false);
return;
}
// can't use froca since that would count only relations from loaded notes
// TODO: Deduplicate response type
const resp = await server.get<{ count: number }>(`note-map/${this.noteId}/backlink-count`);
if (!resp || !resp.count) {
this.toggle(false);
return;
}
this.toggle(true);
this.$count.text(
// i18next plural
`${t("zpetne_odkazy.backlink", { count: resp.count })}`
);
}
toggle(show: boolean) {
this.$widget.toggleClass("hidden-no-content", !show)
.toggleClass("visible", !!show);
}
clearItems() {
this.$items.empty().hide();
}
async renderBacklinks() {
if (!this.note) {
return;
}
this.$items.empty();
const backlinks = await server.get<Backlink[]>(`note-map/${this.noteId}/backlinks`);
if (!backlinks.length) {
return;
}
await froca.getNotes(backlinks.map((bl) => bl.noteId)); // prefetch all
for (const backlink of backlinks) {
const $item = $("<div>");
$item.append(
await linkService.createLink(backlink.noteId, {
showNoteIcon: true,
showNotePath: true,
showTooltip: false
})
);
if (backlink.relationName) {
$item.append($("<p>").text(`${t("zpetne_odkazy.relation")}: ${backlink.relationName}`));
} else {
$item.append(...(backlink.excerpts ?? []));
}
this.$items.append($item);
}
}
}

View file

@ -375,6 +375,7 @@ export default class HighlightsListWidget extends RightPanelWidget {
if (this.noteId === noteId) {
await this.refresh();
this.triggerCommand("reEvaluateRightPaneVisibility");
appContext.triggerEvent("reEvaluateHighlightsListWidgetVisibility", { noteId: this.noteId });
}
}

View file

@ -1,18 +1,37 @@
import { useEffect, useRef, useState } from "preact/hooks";
import { CommandNames } from "../../components/app_context";
import { useStaticTooltip } from "./hooks";
import keyboard_actions from "../../services/keyboard_actions";
interface ActionButtonProps {
export interface ActionButtonProps {
text: string;
titlePosition?: "bottom"; // TODO: Use it
titlePosition?: "bottom" | "left"; // TODO: Use it
icon: string;
className?: string;
onClick?: (e: MouseEvent) => void;
triggerCommand?: CommandNames;
noIconActionClass?: boolean;
}
export default function ActionButton({ text, icon, className, onClick, triggerCommand }: ActionButtonProps) {
export default function ActionButton({ text, icon, className, onClick, triggerCommand, titlePosition, noIconActionClass }: ActionButtonProps) {
const buttonRef = useRef<HTMLButtonElement>(null);
const [ keyboardShortcut, setKeyboardShortcut ] = useState<string[]>();
useStaticTooltip(buttonRef, {
title: keyboardShortcut?.length ? `${text} (${keyboardShortcut?.join(",")})` : text,
placement: titlePosition ?? "bottom",
fallbackPlacements: [ titlePosition ?? "bottom" ]
});
useEffect(() => {
if (triggerCommand) {
keyboard_actions.getAction(triggerCommand).then(action => setKeyboardShortcut(action?.effectiveShortcuts));
}
}, [triggerCommand]);
return <button
class={`icon-action ${icon} ${className ?? ""}`}
title={text}
ref={buttonRef}
class={`${className ?? ""} ${!noIconActionClass ? "icon-action" : "btn"} ${icon}`}
onClick={onClick}
data-trigger-command={triggerCommand}
/>;

View file

@ -5,17 +5,18 @@ import RawHtml from "./RawHtml";
interface NoteLinkOpts {
notePath: string | string[];
showNotePath?: boolean;
showNoteIcon?: boolean;
style?: Record<string, string | number>;
noPreview?: boolean;
noTnLink?: boolean;
}
export default function NoteLink({ notePath, showNotePath, style, noPreview, noTnLink }: NoteLinkOpts) {
export default function NoteLink({ notePath, showNotePath, showNoteIcon, style, noPreview, noTnLink }: NoteLinkOpts) {
const stringifiedNotePath = Array.isArray(notePath) ? notePath.join("/") : notePath;
const [ jqueryEl, setJqueryEl ] = useState<JQuery<HTMLElement>>();
useEffect(() => {
link.createLink(stringifiedNotePath, { showNotePath })
link.createLink(stringifiedNotePath, { showNotePath, showNoteIcon })
.then(setJqueryEl);
}, [ stringifiedNotePath, showNotePath ]);

View file

@ -196,6 +196,7 @@ export function useNoteContext() {
const [ noteContext, setNoteContext ] = useState<NoteContext>();
const [ notePath, setNotePath ] = useState<string | null | undefined>();
const [ note, setNote ] = useState<FNote | null | undefined>();
const [ refreshCounter, setRefreshCounter ] = useState(0);
useEffect(() => {
setNote(noteContext?.note);
@ -208,6 +209,11 @@ export function useNoteContext() {
useTriliumEvent("frocaReloaded", () => {
setNote(noteContext?.note);
});
useTriliumEvent("noteTypeMimeChanged", ({ noteId }) => {
if (noteId === note?.noteId) {
setRefreshCounter(refreshCounter + 1);
}
});
const parentComponent = useContext(ParentComponent) as ReactWrappedWidget;
useDebugValue(() => `notePath=${notePath}, ntxId=${noteContext?.ntxId}`);

View file

@ -475,6 +475,7 @@ export default class TocWidget extends RightPanelWidget {
if (this.noteId === noteId) {
await this.refresh();
this.triggerCommand("reEvaluateRightPaneVisibility");
appContext.triggerEvent("reEvaluateTocWidgetVisibility", { noteId: this.noteId });
}
}

View file

@ -5,7 +5,6 @@ import TypeWidget from "./type_widget.js";
import Split from "split.js";
import { DEFAULT_GUTTER_SIZE } from "../../services/resizer.js";
import options from "../../services/options.js";
import type SwitchSplitOrientationButton from "../floating_buttons/switch_layout_button.js";
import type { EventData } from "../../components/app_context.js";
import type OnClickButtonWidget from "../buttons/onclick_button.js";
import type { EditorConfig } from "@triliumnext/codemirror";
@ -113,7 +112,7 @@ const TPL = /*html*/`\
*
* - The two panes are resizeable via a split, on desktop. The split can be optionally customized via {@link buildSplitExtraOptions}.
* - Can display errors to the user via {@link setError}.
* - Horizontal or vertical orientation for the editor/preview split, adjustable via {@link SwitchSplitOrientationButton}.
* - Horizontal or vertical orientation for the editor/preview split, adjustable via the switch split orientation button floating button.
*/
export default abstract class AbstractSplitTypeWidget extends TypeWidget {

View file

@ -5,12 +5,7 @@ import { JSDOM } from "jsdom";
import type BNote from "../../becca/entities/bnote.js";
import type BAttribute from "../../becca/entities/battribute.js";
import type { Request } from "express";
interface Backlink {
noteId: string;
relationName?: string;
excerpts?: string[];
}
import { BacklinkCountResponse, BacklinksResponse } from "@triliumnext/commons";
interface TreeLink {
sourceNoteId: string;
@ -361,10 +356,10 @@ function getBacklinkCount(req: Request) {
return {
count: getFilteredBacklinks(note).length
};
} satisfies BacklinkCountResponse;
}
function getBacklinks(req: Request): Backlink[] {
function getBacklinks(req: Request): BacklinksResponse {
const { noteId } = req.params;
const note = becca.getNoteOrThrow(noteId);
@ -377,17 +372,16 @@ function getBacklinks(req: Request): Backlink[] {
return {
noteId: sourceNote.noteId,
relationName: backlink.name
};
} satisfies BacklinksResponse[number];
}
backlinksWithExcerptCount++;
const excerpts = findExcerpts(sourceNote, noteId);
return {
noteId: sourceNote.noteId,
excerpts
};
} satisfies BacklinksResponse[number];
});
}

View file

@ -10,7 +10,7 @@ import SearchContext from "./search/search_context.js";
import { LBTPL_NOTE_LAUNCHER, LBTPL_CUSTOM_WIDGET, LBTPL_SPACER, LBTPL_SCRIPT } from "./hidden_subtree.js";
import { t } from "i18next";
import { BNote } from "./backend_script_entrypoint.js";
import { SaveSearchNoteResponse } from "@triliumnext/commons";
import { SaveSearchNoteResponse, SaveSqlConsoleResponse } from "@triliumnext/commons";
function getInboxNote(date: string) {
const workspaceNote = hoistedNoteService.getWorkspaceNote();
@ -67,7 +67,7 @@ async function saveSqlConsole(sqlConsoleNoteId: string) {
}
}
return result;
return result satisfies SaveSqlConsoleResponse;
}
function createSearchNote(searchString: string, ancestorNoteId: string) {

View file

@ -206,3 +206,17 @@ export interface CloneResponse {
export interface ConvertToAttachmentResponse {
attachment: AttachmentRow;
}
export type SaveSqlConsoleResponse = CloneResponse;
export interface BacklinkCountResponse {
count: number;
}
export type BacklinksResponse = ({
noteId: string;
relationName: string;
} | {
noteId: string;
excerpts: string[]
})[];