diff --git a/apps/client/src/widgets/FloatingButtons.css b/apps/client/src/widgets/FloatingButtons.css index 596738390..f034ae925 100644 --- a/apps/client/src/widgets/FloatingButtons.css +++ b/apps/client/src/widgets/FloatingButtons.css @@ -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 */ \ No newline at end of file diff --git a/apps/client/src/widgets/FloatingButtonsDefinitions.tsx b/apps/client/src/widgets/FloatingButtonsDefinitions.tsx index 1c2726e64..28092839b 100644 --- a/apps/client/src/widgets/FloatingButtonsDefinitions.tsx +++ b/apps/client/src/widgets/FloatingButtonsDefinitions.tsx @@ -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(null); + + useEffect(() => { + server.get(`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 ( +
+ {backlinkCount > 0 && <> +
setPopupOpen(!popupOpen)} + > + {t("zpetne_odkazy.backlink", { count: backlinkCount })} +
+ + {popupOpen && ( +
+ +
+ )} + } +
+ ); +} + +function BacklinksList({ noteId }: { noteId: string }) { + const [ backlinks, setBacklinks ] = useState([]); + + useEffect(() => { + server.get(`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 => ( +
+ + + {"relationName" in backlink ? ( +

{backlink.relationName}

+ ) : ( + backlink.excerpts.map(excerpt => ( + + )) + )} +
+ )); } \ No newline at end of file diff --git a/apps/client/src/widgets/floating_buttons/zpetne_odkazy.ts b/apps/client/src/widgets/floating_buttons/zpetne_odkazy.ts deleted file mode 100644 index 29dff9124..000000000 --- a/apps/client/src/widgets/floating_buttons/zpetne_odkazy.ts +++ /dev/null @@ -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*/` - -`; - -// TODO: Deduplicate with server -interface Backlink { - noteId: string; - relationName?: string; - excerpts?: string[]; -} - -export default class BacklinksWidget extends NoteContextAwareWidget { - - private $count!: JQuery; - private $items!: JQuery; - private $ticker!: JQuery; - - 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(`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 = $("
"); - - $item.append( - await linkService.createLink(backlink.noteId, { - showNoteIcon: true, - showNotePath: true, - showTooltip: false - }) - ); - - if (backlink.relationName) { - $item.append($("

").text(`${t("zpetne_odkazy.relation")}: ${backlink.relationName}`)); - } else { - $item.append(...(backlink.excerpts ?? [])); - } - - this.$items.append($item); - } - } -} diff --git a/apps/client/src/widgets/react/NoteLink.tsx b/apps/client/src/widgets/react/NoteLink.tsx index c722d916d..221b902af 100644 --- a/apps/client/src/widgets/react/NoteLink.tsx +++ b/apps/client/src/widgets/react/NoteLink.tsx @@ -5,17 +5,18 @@ import RawHtml from "./RawHtml"; interface NoteLinkOpts { notePath: string | string[]; showNotePath?: boolean; + showNoteIcon?: boolean; style?: Record; 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>(); useEffect(() => { - link.createLink(stringifiedNotePath, { showNotePath }) + link.createLink(stringifiedNotePath, { showNotePath, showNoteIcon }) .then(setJqueryEl); }, [ stringifiedNotePath, showNotePath ]); diff --git a/apps/server/src/routes/api/note_map.ts b/apps/server/src/routes/api/note_map.ts index b77c634c2..3a2bed5b2 100644 --- a/apps/server/src/routes/api/note_map.ts +++ b/apps/server/src/routes/api/note_map.ts @@ -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]; }); } diff --git a/packages/commons/src/lib/server_api.ts b/packages/commons/src/lib/server_api.ts index 0a9d36227..cadbcfe0c 100644 --- a/packages/commons/src/lib/server_api.ts +++ b/packages/commons/src/lib/server_api.ts @@ -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[] +})[];