feat(text): basic insert link with anchors

This commit is contained in:
Elian Doran
2026-04-18 10:28:29 +03:00
parent 4dcbd36b2d
commit ae004c4334
7 changed files with 67 additions and 6 deletions

View File

@@ -60,6 +60,8 @@ export interface ViewScope {
*/
tocPreviousVisible?: boolean;
tocCollapsedHeadings?: Set<string>;
/** 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;

View File

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

View File

@@ -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<LinkType>();
const [ suggestion, setSuggestion ] = useState<Suggestion | null>(null);
const [ bookmarks, setBookmarks ] = useState<string[]>([]);
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() {
/>
</FormGroup>
{bookmarks.length > 0 && (
<FormGroup label={t("add_link.bookmark")} name="bookmark">
<select
className="form-select"
value={selectedBookmark}
onChange={(e) => setSelectedBookmark((e.target as HTMLSelectElement).value)}
>
<option value="">{t("add_link.bookmark_none")}</option>
{bookmarks.map((bk) => (
<option key={bk} value={bk}>{bk}</option>
))}
</select>
</FormGroup>
)}
{!opts?.hasSelection && (
<div className="add-link-title-settings">
{(linkType !== "external-link") && (

View File

@@ -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;
}
}}
/>}

View File

@@ -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<HTMLDivElement>(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}
/>
<TouchBar>

View File

@@ -119,7 +119,15 @@ class BAttribute extends AbstractBeccaEntity<BAttribute> {
}
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() {

View File

@@ -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" },