feat(react/floating_buttons): port backlinks

This commit is contained in:
Elian Doran 2025-08-28 17:32:29 +03:00
parent 1766d28fc2
commit a95e28c085
No known key found for this signature in database
6 changed files with 151 additions and 183 deletions

View File

@ -110,4 +110,50 @@
.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

@ -4,11 +4,11 @@ import Component from "../components/component";
import NoteContext from "../components/note_context";
import FNote from "../entities/fnote";
import ActionButton, { ActionButtonProps } from "./react/ActionButton";
import { useNoteLabelBoolean, useTriliumOption } from "./react/hooks";
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { useNoteLabelBoolean, 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 { SaveSqlConsoleResponse } from "@triliumnext/commons";
import { BacklinkCountResponse, BacklinksResponse, SaveSqlConsoleResponse } from "@triliumnext/commons";
import toast from "../services/toast";
import { t } from "../services/i18n";
import { copyImageReferenceToClipboard } from "../services/image";
@ -16,6 +16,9 @@ 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";
export interface FloatingButtonDefinition {
component: (context: FloatingButtonContext) => VNode;
@ -109,6 +112,10 @@ export const FLOATING_BUTTON_DEFINITIONS: FloatingButtonDefinition[] = [
{
component: InAppHelpButton,
isEnabled: ({ note }) => !!getHelpUrlForNote(note)
},
{
component: Backlinks,
isEnabled: ({ noteContext }) => noteContext.viewScope?.viewMode === "default"
}
];
@ -320,4 +327,79 @@ function InAppHelpButton({ note }: FloatingButtonContext) {
onClick={() => helpUrl && openInAppHelpFromUrl(helpUrl)}
/>
)
}
function Backlinks({ note }: FloatingButtonContext) {
let [ backlinkCount, setBacklinkCount ] = useState(0);
let [ popupOpen, setPopupOpen ] = useState(true);
const backlinksContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
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 ]);
return (
<div className="backlinks-widget has-overflow">
{backlinkCount > 0 && <>
<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,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

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

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

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