From ae004c4334c4b2c69862fc2ac8eb833b913c325a Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sat, 18 Apr 2026 10:28:29 +0300 Subject: [PATCH] feat(text): basic insert link with anchors --- apps/client/src/services/link.ts | 4 ++- .../src/translations/en/translation.json | 4 ++- apps/client/src/widgets/dialogs/add_link.tsx | 32 +++++++++++++++++-- .../type_widgets/text/EditableText.tsx | 8 +++++ .../type_widgets/text/ReadOnlyText.tsx | 14 +++++++- apps/server/src/becca/entities/battribute.ts | 10 +++++- .../commons/src/lib/builtin_attributes.ts | 1 + 7 files changed, 67 insertions(+), 6 deletions(-) diff --git a/apps/client/src/services/link.ts b/apps/client/src/services/link.ts index 3405161dd5..b6c5dbfdb6 100644 --- a/apps/client/src/services/link.ts +++ b/apps/client/src/services/link.ts @@ -60,6 +60,8 @@ export interface ViewScope { */ tocPreviousVisible?: boolean; tocCollapsedHeadings?: Set; + /** When set, scrolls to a bookmark anchor within the note after navigation. */ + bookmark?: string; } interface CreateLinkOptions { @@ -244,7 +246,7 @@ export function parseNavigationStateFromUrl(url: string | undefined) { hoistedNoteId = value; } else if (name === "searchString") { searchString = value; // supports triggering search from URL, e.g. #?searchString=blabla - } else if (["viewMode", "attachmentId"].includes(name)) { + } else if (["viewMode", "attachmentId", "bookmark"].includes(name)) { (viewScope as any)[name] = value; } else if (name === "popup") { openInPopup = true; diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index ef322e1fb1..3145ff7f03 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -41,6 +41,8 @@ "link_title_mirrors": "link title mirrors the note's current title", "link_title_arbitrary": "link title can be changed arbitrarily", "link_title": "Link title", + "bookmark": "Bookmark (optional)", + "bookmark_none": "None (link to note)", "button_add_link": "Add link" }, "branch_prefix": { @@ -861,7 +863,7 @@ "none": "none" }, "auto_link_attribute_list": { - "title": "System Links" + "title": "System Attributes" }, "note_info_widget": { "note_id": "Note ID", diff --git a/apps/client/src/widgets/dialogs/add_link.tsx b/apps/client/src/widgets/dialogs/add_link.tsx index 4bb1d1711c..9d1f5522ff 100644 --- a/apps/client/src/widgets/dialogs/add_link.tsx +++ b/apps/client/src/widgets/dialogs/add_link.tsx @@ -5,6 +5,7 @@ import FormRadioGroup from "../react/FormRadioGroup"; import NoteAutocomplete from "../react/NoteAutocomplete"; import { useRef, useState, useEffect } from "preact/hooks"; import tree from "../../services/tree"; +import froca from "../../services/froca"; import note_autocomplete, { Suggestion } from "../../services/note_autocomplete"; import { logError } from "../../services/ws"; import FormGroup from "../react/FormGroup.js"; @@ -24,6 +25,8 @@ export default function AddLinkDialog() { const [ linkTitle, setLinkTitle ] = useState(""); const [ linkType, setLinkType ] = useState(); const [ suggestion, setSuggestion ] = useState(null); + const [ bookmarks, setBookmarks ] = useState([]); + const [ selectedBookmark, setSelectedBookmark ] = useState(""); const [ shown, setShown ] = useState(false); const hasSubmittedRef = useRef(false); @@ -61,6 +64,11 @@ export default function AddLinkDialog() { const noteId = tree.getNoteIdFromUrl(suggestion.notePath); if (noteId) { setDefaultLinkTitle(noteId); + froca.getNote(noteId).then((note) => { + const bkms = note?.getLabels("internalBookmark").map((l) => l.value) ?? []; + setBookmarks(bkms); + setSelectedBookmark(""); + }); } resetExternalLink(); } @@ -114,8 +122,11 @@ export default function AddLinkDialog() { hasSubmittedRef.current = false; if (suggestion.notePath) { - // Handle note link - opts.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle); + // Handle note link, optionally with a bookmark anchor + const path = selectedBookmark + ? `${suggestion.notePath}?bookmark=${encodeURIComponent(selectedBookmark)}` + : suggestion.notePath; + opts.addLink(path, linkType === "reference-link" ? null : linkTitle); } else if (suggestion.externalLink) { // Handle external link opts.addLink(suggestion.externalLink, linkTitle, true); @@ -123,6 +134,8 @@ export default function AddLinkDialog() { } setSuggestion(null); + setBookmarks([]); + setSelectedBookmark(""); setShown(false); }} show={shown} @@ -138,6 +151,21 @@ export default function AddLinkDialog() { /> + {bookmarks.length > 0 && ( + + + + )} + {!opts?.hasSelection && (
{(linkType !== "external-link") && ( diff --git a/apps/client/src/widgets/type_widgets/text/EditableText.tsx b/apps/client/src/widgets/type_widgets/text/EditableText.tsx index 8cacc40e25..a002cde7e9 100644 --- a/apps/client/src/widgets/type_widgets/text/EditableText.tsx +++ b/apps/client/src/widgets/type_widgets/text/EditableText.tsx @@ -263,6 +263,14 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext // We are not using CKEditor's built-in watch dog content, instead we are using the data we store regularly in the spaced update (see `dataSaved`). editor.setData(contentRef.current); parentComponent?.triggerEvent("textEditorRefreshed", { ntxId, editor }); + + // Scroll to bookmark anchor if navigated with ?bookmark=... + const viewScope = noteContext?.viewScope; + if (viewScope?.bookmark) { + const el = editor.editing.view.getDomRoot()?.querySelector(`[id="${CSS.escape(viewScope.bookmark)}"]`); + el?.scrollIntoView({ behavior: "smooth", block: "center" }); + viewScope.bookmark = undefined; + } }} />} diff --git a/apps/client/src/widgets/type_widgets/text/ReadOnlyText.tsx b/apps/client/src/widgets/type_widgets/text/ReadOnlyText.tsx index da7e08ebab..a58b21de54 100644 --- a/apps/client/src/widgets/type_widgets/text/ReadOnlyText.tsx +++ b/apps/client/src/widgets/type_widgets/text/ReadOnlyText.tsx @@ -6,7 +6,7 @@ import "@triliumnext/ckeditor5"; import clsx from "clsx"; import { Ref } from "preact"; -import { useEffect, useLayoutEffect, useMemo } from "preact/hooks"; +import { useEffect, useLayoutEffect, useMemo, useRef as usePreactRef } from "preact/hooks"; import appContext from "../../../components/app_context"; import FNote from "../../../entities/fnote"; @@ -24,6 +24,17 @@ import { loadIncludedNote, refreshIncludedNote, setupImageOpening } from "./util export default function ReadOnlyText({ note, noteContext, ntxId }: TypeWidgetProps) { const blob = useNoteBlob(note); const { isRtl } = useNoteLanguage(note); + const readOnlyContentRef = usePreactRef(null); + + // Scroll to bookmark anchor if navigated with ?bookmark=... + useEffect(() => { + const viewScope = noteContext?.viewScope; + if (!viewScope?.bookmark || !readOnlyContentRef.current) return; + + const el = readOnlyContentRef.current.querySelector(`[id="${CSS.escape(viewScope.bookmark)}"]`); + el?.scrollIntoView({ behavior: "smooth", block: "center" }); + viewScope.bookmark = undefined; + }, [blob]); return ( <> @@ -31,6 +42,7 @@ export default function ReadOnlyText({ note, noteContext, ntxId }: TypeWidgetPro html={blob?.content ?? ""} ntxId={ntxId} dir={isRtl ? "rtl" : "ltr"} + contentRef={readOnlyContentRef} /> diff --git a/apps/server/src/becca/entities/battribute.ts b/apps/server/src/becca/entities/battribute.ts index dbb6502113..997b297750 100644 --- a/apps/server/src/becca/entities/battribute.ts +++ b/apps/server/src/becca/entities/battribute.ts @@ -119,7 +119,15 @@ class BAttribute extends AbstractBeccaEntity { } isAutoLink() { - return this.type === "relation" && ["internalLink", "imageLink", "relationMapLink", "includeNoteLink"].includes(this.name); + if (this.type === "relation") { + return ["internalLink", "imageLink", "relationMapLink", "includeNoteLink"].includes(this.name); + } + + if (this.type === "label") { + return this.name === "internalBookmark"; + } + + return false; } get note() { diff --git a/packages/commons/src/lib/builtin_attributes.ts b/packages/commons/src/lib/builtin_attributes.ts index 76cdf033dc..64a2ad48d7 100644 --- a/packages/commons/src/lib/builtin_attributes.ts +++ b/packages/commons/src/lib/builtin_attributes.ts @@ -91,6 +91,7 @@ export default [ { type: "label", name: "printPageSize" }, { type: "label", name: "printScale" }, { type: "label", name: "printMargins" }, + { type: "label", name: "internalBookmark" }, // relation names { type: "relation", name: "internalLink" },