mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 02:16:05 +01:00 
			
		
		
		
	feat(react/floating_buttons): port backlinks
This commit is contained in:
		| @@ -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 */ | ||||
| @@ -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> | ||||
|     )); | ||||
| } | ||||
| @@ -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); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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 ]); | ||||
|  | ||||
|   | ||||
| @@ -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]; | ||||
|     }); | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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[] | ||||
| })[]; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user