refactor(react/floating_buttons): use enabled at component level

This commit is contained in:
Elian Doran 2025-08-28 19:56:53 +03:00
parent e4900ce87b
commit 0c0504ffd1
No known key found for this signature in database
2 changed files with 112 additions and 158 deletions

View File

@ -1,22 +1,12 @@
import { t } from "i18next"; import { t } from "i18next";
import "./FloatingButtons.css"; import "./FloatingButtons.css";
import { useNoteContext, useNoteProperty, useTriliumEvent, useTriliumEvents } from "./react/hooks"; import { useNoteContext, useNoteLabel, useNoteLabelBoolean } from "./react/hooks";
import { useContext, useEffect, useMemo, useState } from "preact/hooks"; import { useContext, useEffect, useMemo, useState } from "preact/hooks";
import { ParentComponent } from "./react/react_utils"; import { ParentComponent } from "./react/react_utils";
import attributes from "../services/attributes";
import { EventData, EventNames } from "../components/app_context"; import { EventData, EventNames } from "../components/app_context";
import { FLOATING_BUTTON_DEFINITIONS, FloatingButtonContext, FloatingButtonDefinition } from "./FloatingButtonsDefinitions"; import { FLOATING_BUTTONS, type FloatingButtonContext } from "./FloatingButtonsDefinitions";
import ActionButton from "./react/ActionButton"; import ActionButton from "./react/ActionButton";
import { ViewTypeOptions } from "../services/note_list_renderer";
async function getFloatingButtonDefinitions(context: FloatingButtonContext) {
const defs: FloatingButtonDefinition[] = [];
for (const def of FLOATING_BUTTON_DEFINITIONS) {
if (await def.isEnabled(context)) {
defs.push(def);
}
}
return defs;
}
/* /*
* Note: * Note:
@ -28,6 +18,8 @@ async function getFloatingButtonDefinitions(context: FloatingButtonContext) {
export default function FloatingButtons() { export default function FloatingButtons() {
const { note, noteContext } = useNoteContext(); const { note, noteContext } = useNoteContext();
const parentComponent = useContext(ParentComponent); const parentComponent = useContext(ParentComponent);
const [ viewType ] = useNoteLabel(note, "viewType");
const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly");
const context = useMemo<FloatingButtonContext | null>(() => { const context = useMemo<FloatingButtonContext | null>(() => {
if (!note || !noteContext || !parentComponent) return null; if (!note || !noteContext || !parentComponent) return null;
@ -35,6 +27,9 @@ export default function FloatingButtons() {
note, note,
noteContext, noteContext,
parentComponent, parentComponent,
isDefaultViewMode: noteContext.viewScope?.viewMode === "default",
viewType: viewType as ViewTypeOptions,
isReadOnly,
triggerEvent<T extends EventNames>(name: T, data?: Omit<EventData<T>, "ntxId">) { triggerEvent<T extends EventNames>(name: T, data?: Omit<EventData<T>, "ntxId">) {
parentComponent.triggerEvent(name, { parentComponent.triggerEvent(name, {
ntxId: noteContext.ntxId, ntxId: noteContext.ntxId,
@ -42,33 +37,7 @@ export default function FloatingButtons() {
} as EventData<T>); } as EventData<T>);
} }
}; };
}, [ note, noteContext, parentComponent ]); }, [ note, noteContext, parentComponent, viewType, isReadOnly ]);
// Refresh on any note attribute change.
const [ refreshCounter, setRefreshCounter ] = useState(0);
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
if (loadResults.getAttributeRows().find(attrRow => attributes.isAffecting(attrRow, note))) {
setRefreshCounter(refreshCounter + 1);
}
});
useTriliumEvent("readOnlyTemporarilyDisabled", ({ noteContext: eventNoteContext }) => {
if (noteContext?.ntxId === eventNoteContext.ntxId) {
setRefreshCounter(refreshCounter + 1);
}
});
useTriliumEvents(["reEvaluateTocWidgetVisibility", "reEvaluateHighlightsListWidgetVisibility"], ({ noteId }) => {
if (noteId === note?.noteId) {
setRefreshCounter(refreshCounter + 1);
}
});
// Manage the list of items
const noteMime = useNoteProperty(note, "mime");
const [ definitions, setDefinitions ] = useState<FloatingButtonDefinition[]>([]);
useEffect(() => {
if (!context) return;
getFloatingButtonDefinitions(context).then(setDefinitions);
}, [ context, refreshCounter, noteMime ]);
// Manage the user-adjustable visibility of the floating buttons. // Manage the user-adjustable visibility of the floating buttons.
const [ visible, setVisible ] = useState(true); const [ visible, setVisible ] = useState(true);
@ -77,14 +46,14 @@ export default function FloatingButtons() {
return ( return (
<div className="floating-buttons no-print"> <div className="floating-buttons no-print">
<div className={`floating-buttons-children ${!visible ? "temporarily-hidden" : ""}`}> <div className={`floating-buttons-children ${!visible ? "temporarily-hidden" : ""}`}>
{context && definitions.map(({ component: Component }) => ( {context && FLOATING_BUTTONS.map((Component) => (
<Component {...context} /> <Component {...context} />
))} ))}
{definitions.length && <CloseFloatingButton setVisible={setVisible} />} {visible && <CloseFloatingButton setVisible={setVisible} />}
</div> </div>
{!visible && definitions.length && <ShowFloatingButton setVisible={setVisible} /> } {!visible && <ShowFloatingButton setVisible={setVisible} /> }
</div> </div>
) )
} }

View File

@ -4,7 +4,7 @@ import Component from "../components/component";
import NoteContext from "../components/note_context"; import NoteContext from "../components/note_context";
import FNote from "../entities/fnote"; import FNote from "../entities/fnote";
import ActionButton, { ActionButtonProps } from "./react/ActionButton"; import ActionButton, { ActionButtonProps } from "./react/ActionButton";
import { useNoteLabelBoolean, useTriliumOption, useWindowSize } from "./react/hooks"; import { useNoteLabelBoolean, useTriliumEvent, useTriliumOption, useWindowSize } from "./react/hooks";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
import { createImageSrcUrl, openInAppHelpFromUrl } from "../services/utils"; import { createImageSrcUrl, openInAppHelpFromUrl } from "../services/utils";
import server from "../services/server"; import server from "../services/server";
@ -19,18 +19,17 @@ import { getHelpUrlForNote } from "../services/in_app_help";
import froca from "../services/froca"; import froca from "../services/froca";
import NoteLink from "./react/NoteLink"; import NoteLink from "./react/NoteLink";
import RawHtml from "./react/RawHtml"; import RawHtml from "./react/RawHtml";
import { ViewTypeOptions } from "../services/note_list_renderer";
export interface FloatingButtonDefinition {
component: (context: FloatingButtonContext) => VNode;
isEnabled: (context: FloatingButtonContext) => boolean | Promise<boolean>;
}
export interface FloatingButtonContext { export interface FloatingButtonContext {
parentComponent: Component; parentComponent: Component;
note: FNote; note: FNote;
noteContext: NoteContext; noteContext: NoteContext;
isDefaultViewMode: boolean;
isReadOnly: boolean;
/** Shorthand for triggering an event from the parent component. The `ntxId` is automatically handled for convenience. */ /** 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; triggerEvent<T extends EventNames>(name: T, data?: Omit<EventData<T>, "ntxId">): void;
viewType?: ViewTypeOptions | null;
} }
function FloatingButton({ className, ...props }: ActionButtonProps) { function FloatingButton({ className, ...props }: ActionButtonProps) {
@ -41,126 +40,90 @@ function FloatingButton({ className, ...props }: ActionButtonProps) {
/> />
} }
export const FLOATING_BUTTON_DEFINITIONS: FloatingButtonDefinition[] = [ export const FLOATING_BUTTONS: ((context: FloatingButtonContext) => false | VNode)[] = [
{ RefreshBackendLogButton,
component: RefreshBackendLogButton, SwitchSplitOrientationButton,
isEnabled: ({ note, noteContext }) => note.noteId === "_backendLog" && noteContext.viewScope?.viewMode === "default", ToggleReadOnlyButton,
}, EditButton,
{ ShowTocWidgetButton,
component: SwitchSplitOrientationButton, ShowHighlightsListWidgetButton,
isEnabled: ({ note, noteContext }) => note.type === "mermaid" && note.isContentAvailable() && !note.hasLabel("readOnly") && noteContext.viewScope?.viewMode === "default" RunActiveNoteButton,
}, OpenTriliumApiDocsButton,
{ SaveToNoteButton,
component: ToggleReadOnlyButton, RelationMapButtons,
isEnabled: ({ note, noteContext }) => GeoMapButtons,
(note.type === "mermaid" || note.getLabelValue("viewType") === "geoMap") CopyImageReferenceButton,
&& note.isContentAvailable() ExportImageButtons,
&& noteContext.viewScope?.viewMode === "default" InAppHelpButton,
}, Backlinks
{
component: EditButton,
isEnabled: async ({ note, noteContext }) =>
noteContext.viewScope?.viewMode === "default"
&& (!note.isProtected || protected_session_holder.isProtectedSessionAvailable())
&& !options.is("databaseReadonly")
&& await noteContext?.isReadOnly()
},
{
component: ShowTocWidgetButton,
isEnabled: ({ note, noteContext }) =>
note.type === "text" && noteContext?.viewScope?.viewMode === "default"
&& !!noteContext.viewScope?.tocTemporarilyHidden
},
{
component: ShowHighlightsListWidgetButton,
isEnabled: ({ note, noteContext }) =>
note.type === "text" && noteContext?.viewScope?.viewMode === "default"
&& !!noteContext.viewScope?.highlightsListTemporarilyHidden
},
{
component: RunActiveNoteButton,
isEnabled: ({ note }) => note.mime.startsWith("application/javascript") || note.mime === "text/x-sqlite;schema=trilium"
},
{
component: OpenTriliumApiDocsButton,
isEnabled: ({ note }) => note.mime.startsWith("application/javascript;env=")
},
{
component: SaveToNoteButton,
isEnabled: ({ note }) => note.mime === "text/x-sqlite;schema=trilium" && note.isHiddenCompletely()
},
{
component: RelationMapButtons,
isEnabled: ({ note }) => note.type === "relationMap"
},
{
component: GeoMapButtons,
isEnabled: ({ note }) => note?.getLabelValue("viewType") === "geoMap" && !note.hasLabel("readOnly")
},
{
component: CopyImageReferenceButton,
isEnabled: ({ note, noteContext }) =>
["mermaid", "canvas", "mindMap"].includes(note?.type ?? "")
&& note?.isContentAvailable() && noteContext.viewScope?.viewMode === "default"
},
{
component: ExportImageButtons,
isEnabled: ({ note, noteContext }) =>
["mermaid", "mindMap"].includes(note?.type ?? "")
&& note?.isContentAvailable() && noteContext?.viewScope?.viewMode === "default"
},
{
component: InAppHelpButton,
isEnabled: ({ note }) => !!getHelpUrlForNote(note)
},
{
component: Backlinks,
isEnabled: ({ noteContext }) => noteContext.viewScope?.viewMode === "default"
}
]; ];
function RefreshBackendLogButton({ parentComponent, noteContext }: FloatingButtonContext) { function RefreshBackendLogButton({ note, parentComponent, noteContext, isDefaultViewMode }: FloatingButtonContext) {
return <FloatingButton const isEnabled = note.noteId === "_backendLog" && isDefaultViewMode;
return isEnabled && <FloatingButton
text={t("backend_log.refresh")} text={t("backend_log.refresh")}
icon="bx bx-refresh" icon="bx bx-refresh"
onClick={() => parentComponent.triggerEvent("refreshData", { ntxId: noteContext.ntxId })} onClick={() => parentComponent.triggerEvent("refreshData", { ntxId: noteContext.ntxId })}
/> />
} }
function SwitchSplitOrientationButton({ }: FloatingButtonContext) { function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: FloatingButtonContext) {
const isEnabled = note.type === "mermaid" && note.isContentAvailable() && !isReadOnly && isDefaultViewMode;
const [ splitEditorOrientation, setSplitEditorOrientation ] = useTriliumOption("splitEditorOrientation"); const [ splitEditorOrientation, setSplitEditorOrientation ] = useTriliumOption("splitEditorOrientation");
const upcomingOrientation = splitEditorOrientation === "horizontal" ? "vertical" : "horizontal"; const upcomingOrientation = splitEditorOrientation === "horizontal" ? "vertical" : "horizontal";
return <FloatingButton return isEnabled && <FloatingButton
text={upcomingOrientation === "vertical" ? t("switch_layout_button.title_vertical") : t("switch_layout_button.title_horizontal")} 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"} icon={upcomingOrientation === "vertical" ? "bx bxs-dock-bottom" : "bx bxs-dock-left"}
onClick={() => setSplitEditorOrientation(upcomingOrientation)} onClick={() => setSplitEditorOrientation(upcomingOrientation)}
/> />
} }
function ToggleReadOnlyButton({ note }: FloatingButtonContext) { function ToggleReadOnlyButton({ note, viewType, isDefaultViewMode }: FloatingButtonContext) {
const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly"); const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly");
const isEnabled = (note.type === "mermaid" || viewType === "geoMap")
&& note.isContentAvailable() && isDefaultViewMode;
return <FloatingButton return isEnabled && <FloatingButton
text={isReadOnly ? t("toggle_read_only_button.unlock-editing") : t("toggle_read_only_button.lock-editing")} 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"} icon={isReadOnly ? "bx bx-lock-open-alt" : "bx bx-lock-alt"}
onClick={() => setReadOnly(!isReadOnly)} onClick={() => setReadOnly(!isReadOnly)}
/> />
} }
function EditButton({ noteContext }: FloatingButtonContext) { function EditButton({ note, noteContext, isDefaultViewMode }: FloatingButtonContext) {
const [ animationClass, setAnimationClass ] = useState(""); 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 // make the edit button stand out on the first display, otherwise
// it's difficult to notice that the note is readonly // it's difficult to notice that the note is readonly
useEffect(() => { useEffect(() => {
if (isEnabled) {
setAnimationClass("bx-tada bx-lg"); setAnimationClass("bx-tada bx-lg");
setTimeout(() => { setTimeout(() => {
setAnimationClass(""); setAnimationClass("");
}, 1700); }, 1700);
}, []); }
}, [ isEnabled ]);
return <FloatingButton return isEnabled && <FloatingButton
text={t("edit_button.edit_this_note")} text={t("edit_button.edit_this_note")}
icon="bx bx-pencil" icon="bx bx-pencil"
className={animationClass} className={animationClass}
@ -173,8 +136,13 @@ function EditButton({ noteContext }: FloatingButtonContext) {
/> />
} }
function ShowTocWidgetButton({ noteContext }: FloatingButtonContext) { function ShowTocWidgetButton({ note, noteContext, isDefaultViewMode }: FloatingButtonContext) {
return <FloatingButton 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")} text={t("show_toc_widget_button.show_toc")}
icon="bx bx-tn-toc" icon="bx bx-tn-toc"
onClick={() => { onClick={() => {
@ -186,8 +154,13 @@ function ShowTocWidgetButton({ noteContext }: FloatingButtonContext) {
/> />
} }
function ShowHighlightsListWidgetButton({ noteContext }: FloatingButtonContext) { function ShowHighlightsListWidgetButton({ note, noteContext, isDefaultViewMode }: FloatingButtonContext) {
return <FloatingButton 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")} text={t("show_highlights_list_widget_button.show_highlights_list")}
icon="bx bx-bookmarks" icon="bx bx-bookmarks"
onClick={() => { onClick={() => {
@ -199,8 +172,9 @@ function ShowHighlightsListWidgetButton({ noteContext }: FloatingButtonContext)
/> />
} }
function RunActiveNoteButton() { function RunActiveNoteButton({ note }: FloatingButtonContext) {
return <FloatingButton const isEnabled = note.mime.startsWith("application/javascript") || note.mime === "text/x-sqlite;schema=trilium";
return isEnabled && <FloatingButton
icon="bx bx-play" icon="bx bx-play"
text={t("code_buttons.execute_button_title")} text={t("code_buttons.execute_button_title")}
triggerCommand="runActiveNote" triggerCommand="runActiveNote"
@ -208,7 +182,8 @@ function RunActiveNoteButton() {
} }
function OpenTriliumApiDocsButton({ note }: FloatingButtonContext) { function OpenTriliumApiDocsButton({ note }: FloatingButtonContext) {
return <FloatingButton const isEnabled = note.mime.startsWith("application/javascript;env=");
return isEnabled && <FloatingButton
icon="bx bx-help-circle" icon="bx bx-help-circle"
text={t("code_buttons.trilium_api_docs_button_title")} text={t("code_buttons.trilium_api_docs_button_title")}
onClick={() => openInAppHelpFromUrl(note.mime.endsWith("frontend") ? "Q2z6av6JZVWm" : "MEtfsqa5VwNi")} onClick={() => openInAppHelpFromUrl(note.mime.endsWith("frontend") ? "Q2z6av6JZVWm" : "MEtfsqa5VwNi")}
@ -216,7 +191,8 @@ function OpenTriliumApiDocsButton({ note }: FloatingButtonContext) {
} }
function SaveToNoteButton({ note }: FloatingButtonContext) { function SaveToNoteButton({ note }: FloatingButtonContext) {
return <FloatingButton const isEnabled = note.mime === "text/x-sqlite;schema=trilium" && note.isHiddenCompletely();
return isEnabled && <FloatingButton
icon="bx bx-save" icon="bx bx-save"
text={t("code_buttons.save_to_note_button_title")} text={t("code_buttons.save_to_note_button_title")}
onClick={async (e) => { onClick={async (e) => {
@ -232,8 +208,9 @@ function SaveToNoteButton({ note }: FloatingButtonContext) {
/> />
} }
function RelationMapButtons({ triggerEvent }: FloatingButtonContext) { function RelationMapButtons({ note, triggerEvent }: FloatingButtonContext) {
return ( const isEnabled = (note.type === "relationMap");
return isEnabled && (
<> <>
<FloatingButton <FloatingButton
icon="bx bx-folder-plus" icon="bx bx-folder-plus"
@ -264,8 +241,9 @@ function RelationMapButtons({ triggerEvent }: FloatingButtonContext) {
) )
} }
function GeoMapButtons({ triggerEvent }) { function GeoMapButtons({ triggerEvent, viewType, isReadOnly }: FloatingButtonContext) {
return ( const isEnabled = viewType === "geoMap" && !isReadOnly;
return isEnabled && (
<FloatingButton <FloatingButton
icon="bx bx-plus-circle" icon="bx bx-plus-circle"
text={t("geo-map.create-child-note-title")} text={t("geo-map.create-child-note-title")}
@ -274,10 +252,12 @@ function GeoMapButtons({ triggerEvent }) {
); );
} }
function CopyImageReferenceButton({ note }: FloatingButtonContext) { function CopyImageReferenceButton({ note, isDefaultViewMode }: FloatingButtonContext) {
const hiddenImageCopyRef = useRef<HTMLDivElement>(null); const hiddenImageCopyRef = useRef<HTMLDivElement>(null);
const isEnabled = ["mermaid", "canvas", "mindMap"].includes(note?.type ?? "")
&& note?.isContentAvailable() && isDefaultViewMode;
return ( return isEnabled && (
<> <>
<FloatingButton <FloatingButton
icon="bx bx-copy" icon="bx bx-copy"
@ -299,8 +279,10 @@ function CopyImageReferenceButton({ note }: FloatingButtonContext) {
) )
} }
function ExportImageButtons({ triggerEvent }: FloatingButtonContext) { function ExportImageButtons({ note, triggerEvent, isDefaultViewMode }: FloatingButtonContext) {
return ( const isEnabled = ["mermaid", "mindMap"].includes(note?.type ?? "")
&& note?.isContentAvailable() && isDefaultViewMode;
return isEnabled && (
<> <>
<FloatingButton <FloatingButton
icon="bx bxs-file-image" icon="bx bxs-file-image"
@ -320,7 +302,7 @@ function ExportImageButtons({ triggerEvent }: FloatingButtonContext) {
function InAppHelpButton({ note }: FloatingButtonContext) { function InAppHelpButton({ note }: FloatingButtonContext) {
const helpUrl = getHelpUrlForNote(note); const helpUrl = getHelpUrlForNote(note);
return ( return !!helpUrl && (
<FloatingButton <FloatingButton
icon="bx bx-help-circle" icon="bx bx-help-circle"
text={t("help-button.title")} text={t("help-button.title")}
@ -329,12 +311,14 @@ function InAppHelpButton({ note }: FloatingButtonContext) {
) )
} }
function Backlinks({ note }: FloatingButtonContext) { function Backlinks({ note, isDefaultViewMode }: FloatingButtonContext) {
let [ backlinkCount, setBacklinkCount ] = useState(0); let [ backlinkCount, setBacklinkCount ] = useState(0);
let [ popupOpen, setPopupOpen ] = useState(false); let [ popupOpen, setPopupOpen ] = useState(false);
const backlinksContainerRef = useRef<HTMLDivElement>(null); const backlinksContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!isDefaultViewMode) return;
server.get<BacklinkCountResponse>(`note-map/${note.noteId}/backlink-count`).then(resp => { server.get<BacklinkCountResponse>(`note-map/${note.noteId}/backlink-count`).then(resp => {
setBacklinkCount(resp.count); setBacklinkCount(resp.count);
}); });
@ -351,7 +335,8 @@ function Backlinks({ note }: FloatingButtonContext) {
} }
}, [ popupOpen, windowHeight ]); }, [ popupOpen, windowHeight ]);
return (backlinkCount > 0 && const isEnabled = isDefaultViewMode && backlinkCount > 0;
return (isEnabled &&
<div className="backlinks-widget has-overflow"> <div className="backlinks-widget has-overflow">
<div <div
className="backlinks-ticker" className="backlinks-ticker"