Merge pull request #9506 from streetwriters/editor/internal-link-title-in-popup

editor: show internal link's title in hover popup
This commit is contained in:
Abdullah Atta
2026-04-06 09:37:31 +05:00
committed by GitHub
9 changed files with 209 additions and 38 deletions

View File

@@ -20,7 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/* eslint-disable no-case-declarations */ /* eslint-disable no-case-declarations */
/* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable @typescript-eslint/no-var-requires */
import { isFeatureAvailable, useAreFeaturesAvailable } from "@notesnook/common"; import { isFeatureAvailable, useAreFeaturesAvailable } from "@notesnook/common";
import { ItemReference } from "@notesnook/core"; import { ItemReference, parseInternalLink } from "@notesnook/core";
import type { Attachment } from "@notesnook/editor"; import type { Attachment } from "@notesnook/editor";
import { EditorEvents } from "@notesnook/editor-mobile/src/utils/editor-events"; import { EditorEvents } from "@notesnook/editor-mobile/src/utils/editor-events";
import { NativeEvents } from "@notesnook/editor-mobile/src/utils/native-events"; import { NativeEvents } from "@notesnook/editor-mobile/src/utils/native-events";
@@ -471,9 +471,8 @@ export const useEditorEvents = (
relationType: "from", relationType: "from",
title: strings.dataTypesPluralCamelCase.reminder(), title: strings.dataTypesPluralCamelCase.reminder(),
onAdd: async () => { onAdd: async () => {
const reminderFeature = await isFeatureAvailable( const reminderFeature =
"activeReminders" await isFeatureAvailable("activeReminders");
);
if (!reminderFeature.isAllowed) { if (!reminderFeature.isAllowed) {
ToastManager.show({ ToastManager.show({
type: "info", type: "info",
@@ -533,7 +532,59 @@ export const useEditorEvents = (
downloadAttachment((editorMessage.value as Attachment)?.hash, true); downloadAttachment((editorMessage.value as Attachment)?.hash, true);
break; break;
} }
case EditorEvents.getLinkData: {
const url = (editorMessage.value as any)?.url as string;
const link = parseInternalLink(url);
if (!link) return;
switch (link.type) {
case "note":
case "notebook":
case "tag": {
const table =
link.type === "note"
? "notes"
: link.type === "notebook"
? "notebooks"
: "tags";
const item = await db
.sql()
.selectFrom(table)
.where("id", "=", link.id)
.select("title")
.executeTakeFirst();
editor.postMessage(NativeEvents.resolve, {
resolverId: editorMessage.resolverId,
data: {
type: link.type,
title: item?.title || ""
}
});
break;
}
case "color": {
const color = await db
.sql()
.selectFrom("colors")
.where("id", "=", link.id)
.select(["title", "colorCode"])
.executeTakeFirst();
editor.postMessage(NativeEvents.resolve, {
resolverId: editorMessage.resolverId,
data: {
type: "color",
title: color?.title || "",
metadata: {
colorCode: color?.colorCode || ""
}
}
});
}
}
break;
}
case EditorEvents.getAttachmentData: { case EditorEvents.getAttachmentData: {
const data = (editorMessage.value as any)?.attachment as Attachment; const data = (editorMessage.value as any)?.attachment as Attachment;

View File

@@ -1,12 +1,12 @@
{ {
"name": "@notesnook/mobile", "name": "@notesnook/mobile",
"version": "3.3.17", "version": "3.3.18",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@notesnook/mobile", "name": "@notesnook/mobile",
"version": "3.3.17", "version": "3.3.18",
"hasInstallScript": true, "hasInstallScript": true,
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {

View File

@@ -69,6 +69,7 @@ import { UpgradeDialog } from "../../dialogs/buy-dialog/upgrade-dialog";
import { ConfirmDialog } from "../../dialogs/confirm"; import { ConfirmDialog } from "../../dialogs/confirm";
import { strings } from "@notesnook/intl"; import { strings } from "@notesnook/intl";
import { handleInternalLink } from "../../common"; import { handleInternalLink } from "../../common";
import { db } from "../../common/db";
export type OnChangeHandler = ( export type OnChangeHandler = (
content: () => string, content: () => string,
@@ -426,6 +427,48 @@ function TipTap(props: TipTapProps) {
const link = parseInternalLink(url); const link = parseInternalLink(url);
if (link) handleInternalLink(url, openInNewTab); if (link) handleInternalLink(url, openInNewTab);
else window.open(url, "_blank"); else window.open(url, "_blank");
},
getLinkData: async (url) => {
const link = parseInternalLink(url);
if (!link) return;
switch (link.type) {
case "note":
case "notebook":
case "tag": {
const table =
link.type === "note"
? "notes"
: link.type === "notebook"
? "notebooks"
: "tags";
const item = await db
.sql()
.selectFrom(table)
.where("id", "=", link.id)
.select("title")
.executeTakeFirst();
return {
type: link.type,
title: item?.title || ""
};
}
case "color": {
const color = await db
.sql()
.selectFrom("colors")
.where("id", "=", link.id)
.select(["title", "colorCode"])
.executeTakeFirst();
return {
type: "color",
title: color?.title || "",
metadata: {
colorCode: color?.colorCode || ""
}
};
}
}
} }
}; };
}, [ }, [

View File

@@ -68,7 +68,7 @@
"@notesnook/intl": "file:../intl", "@notesnook/intl": "file:../intl",
"@notesnook/theme": "file:../theme", "@notesnook/theme": "file:../theme",
"@notesnook/ui": "file:../ui", "@notesnook/ui": "file:../ui",
"@social-embed/lib": "^0.1.0-next.7", "@social-embed/lib": "^0.1.0-next.11",
"@tiptap/core": "2.6.6", "@tiptap/core": "2.6.6",
"@tiptap/extension-blockquote": "^2.6.6", "@tiptap/extension-blockquote": "^2.6.6",
"@tiptap/extension-bullet-list": "^2.6.6", "@tiptap/extension-bullet-list": "^2.6.6",

View File

@@ -162,6 +162,11 @@ const Tiptap = ({
attachment attachment
) as Promise<string | undefined>; ) as Promise<string | undefined>;
}, },
getLinkData: (url: string) => {
return postAsyncWithTimeout(EditorEvents.getLinkData, {
url: url
});
},
createInternalLink(attributes) { createInternalLink(attributes) {
return postAsyncWithTimeout(EditorEvents.createInternalLink, { return postAsyncWithTimeout(EditorEvents.createInternalLink, {
attributes attributes

View File

@@ -55,5 +55,6 @@ export const EditorEvents = {
goForward: "editor-events:go-forward", goForward: "editor-events:go-forward",
saveScroll: "editor-events:save-scroll", saveScroll: "editor-events:save-scroll",
newNote: "editor-events:new-note", newNote: "editor-events:new-note",
downloadCsv: "editor-events:download-csv" downloadCsv: "editor-events:download-csv",
getLinkData: "editor-events:get-link-data"
} as const; } as const;

View File

@@ -88,12 +88,14 @@ import { strings } from "@notesnook/intl";
import { InlineCode } from "./extensions/inline-code/inline-code.js"; import { InlineCode } from "./extensions/inline-code/inline-code.js";
import { FontLigature } from "./extensions/font-ligature/font-ligature.js"; import { FontLigature } from "./extensions/font-ligature/font-ligature.js";
import { SearchResult } from "./extensions/search-result/search-result.js"; import { SearchResult } from "./extensions/search-result/search-result.js";
import { LinkData } from "./types.js";
interface TiptapStorage { interface TiptapStorage {
dateFormat?: DateTimeOptions["dateFormat"]; dateFormat?: DateTimeOptions["dateFormat"];
timeFormat?: DateTimeOptions["timeFormat"]; timeFormat?: DateTimeOptions["timeFormat"];
dayFormat?: DateTimeOptions["dayFormat"]; dayFormat?: DateTimeOptions["dayFormat"];
openLink?: (url: string, openInNewTab?: boolean) => void; openLink?: (url: string, openInNewTab?: boolean) => void;
getLinkData?: (url: string) => Promise<LinkData | undefined>;
downloadAttachment?: (attachment: Attachment) => void; downloadAttachment?: (attachment: Attachment) => void;
openAttachmentPicker?: (type: AttachmentType) => void; openAttachmentPicker?: (type: AttachmentType) => void;
previewAttachment?: (attachment: Attachment) => void; previewAttachment?: (attachment: Attachment) => void;
@@ -148,6 +150,7 @@ const useTiptap = (
openAttachmentPicker, openAttachmentPicker,
previewAttachment, previewAttachment,
openLink, openLink,
getLinkData,
onBeforeCreate, onBeforeCreate,
dateFormat, dateFormat,
timeFormat, timeFormat,
@@ -285,7 +288,13 @@ const useTiptap = (
}).configure({ }).configure({
openOnClick: !isMobile, openOnClick: !isMobile,
autolink: false, autolink: false,
linkOnPaste: true linkOnPaste: true,
protocols: [
{
scheme: "nn",
optionalSlashes: true
}
]
}), }),
Table.configure({ Table.configure({
resizable: true, resizable: true,
@@ -395,6 +404,7 @@ const useTiptap = (
editor.storage.createInternalLink = createInternalLink; editor.storage.createInternalLink = createInternalLink;
editor.storage.getAttachmentData = getAttachmentData; editor.storage.getAttachmentData = getAttachmentData;
editor.storage.downloadCsvTable = downloadCsvTable; editor.storage.downloadCsvTable = downloadCsvTable;
editor.storage.getLinkData = getLinkData;
if (onBeforeCreate) onBeforeCreate({ editor }); if (onBeforeCreate) onBeforeCreate({ editor });
}, },

View File

@@ -19,20 +19,24 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { ToolProps } from "../types.js"; import { ToolProps } from "../types.js";
import { ToolButton } from "../components/tool-button.js"; import { ToolButton } from "../components/tool-button.js";
import { useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { ResponsivePresenter } from "../../components/responsive/index.js"; import { ResponsivePresenter } from "../../components/responsive/index.js";
import { LinkPopup } from "../popups/link-popup.js"; import { LinkPopup } from "../popups/link-popup.js";
import { useToolbarLocation } from "../stores/toolbar-store.js"; import { useToolbarLocation } from "../stores/toolbar-store.js";
import { MoreTools } from "../components/more-tools.js"; import { MoreTools } from "../components/more-tools.js";
import { useRefValue } from "../../hooks/use-ref-value.js"; import { useRefValue } from "../../hooks/use-ref-value.js";
import { findMark, selectionToOffset } from "../../utils/prosemirror.js"; import { findMark, selectionToOffset } from "../../utils/prosemirror.js";
import { Flex, Link } from "@theme-ui/components"; import { Flex, Link, Text } from "@theme-ui/components";
import { ImageNode } from "../../extensions/image/index.js"; import { ImageNode } from "../../extensions/image/index.js";
import { Link as LinkNode } from "../../extensions/link/index.js"; import { Link as LinkNode } from "../../extensions/link/index.js";
import { getMarkAttributes } from "@tiptap/core"; import { getMarkAttributes } from "@tiptap/core";
import { useHoverPopupContext } from "../floating-menus/hover-popup/context.js"; import { useHoverPopupContext } from "../floating-menus/hover-popup/context.js";
import { strings } from "@notesnook/intl"; import { strings } from "@notesnook/intl";
import { find } from "linkifyjs"; import { find } from "linkifyjs";
import { Icons } from "../icons.js";
import { mdiNoteOutline, mdiBookOutline, mdiPound } from "@mdi/js";
import { Icon } from "@notesnook/ui";
import { LinkData } from "../../types.js";
export function LinkSettings(props: ToolProps) { export function LinkSettings(props: ToolProps) {
const { editor } = props; const { editor } = props;
@@ -204,36 +208,62 @@ export function OpenLink(props: ToolProps) {
); );
const { node } = selectedNode.current || {}; const { node } = selectedNode.current || {};
const link = node ? findMark(node, "link") : null; const link = node ? findMark(node, "link") : null;
if (!link) return null; const href = link?.attrs.href ?? null;
const href = link?.attrs.href; const [loading, setLoading] = useState(false);
if (!href) return null; const [linkData, setLinkData] = useState<LinkData | undefined>(undefined);
useEffect(() => {
if (!href) return;
(async () => {
try {
setLoading(true);
const result = await editor.storage.getLinkData?.(href);
setLinkData(result);
setLoading(false);
} catch (e) {
setLoading(false);
}
})();
}, [href]);
if (!link || !href) return null;
const title = linkData?.title || href;
return ( return (
<Flex sx={{ alignItems: "center" }}> <Flex sx={{ alignItems: "center" }}>
<Link {linkData?.type && (
href={href} <LinkTypeIcon type={linkData.type} metadata={linkData.metadata} />
onClick={(e) => { )}
e.preventDefault(); {loading ? (
editor.storage.openLink?.(href); <Text sx={{ fontSize: "subBody" }}>{strings.loading()}</Text>
hide(); ) : (
}} <Link
target="_blank" href={href}
variant="body" onClick={(e) => {
sx={{ e.preventDefault();
fontSize: "subBody", editor.storage.openLink?.(href);
fontFamily: "body", hide();
mr: 1, }}
color: "accent", target="_blank"
maxWidth: [150, 250], variant="body"
overflow: "hidden", sx={{
textOverflow: "ellipsis", fontSize: "subBody",
whiteSpace: "nowrap", fontFamily: "body",
":visited": { color: "accent" }, mr: 4,
":hover": { color: "accent", opacity: 0.8 } color: "accent",
}} maxWidth: [150, 250],
> overflow: "hidden",
{href} textOverflow: "ellipsis",
</Link> whiteSpace: "nowrap",
":visited": { color: "accent" },
":hover": { color: "accent", opacity: 0.8 }
}}
>
{title}
</Link>
)}
<ToolButton <ToolButton
icon={props.icon} icon={props.icon}
title={props.title} title={props.title}
@@ -335,6 +365,31 @@ function LinkTool(props: LinkToolProps) {
); );
} }
const LINK_TYPE_ICONS: Record<LinkData["type"], string> = {
note: mdiNoteOutline,
notebook: mdiBookOutline,
tag: mdiPound,
color: Icons.circle
};
function LinkTypeIcon({
type,
metadata
}: {
type: LinkData["type"];
metadata?: LinkData["metadata"];
}) {
const path = LINK_TYPE_ICONS[type];
if (!path) return null;
const color =
type === "color" && metadata?.colorCode
? metadata.colorCode
: "icon-secondary";
return <Icon path={path} color={color} size={13} sx={{ mr: 1 }} />;
}
export function isInternalLink(href?: string | null) { export function isInternalLink(href?: string | null) {
return typeof href === "string" ? href.startsWith("nn://") : false; return typeof href === "string" ? href.startsWith("nn://") : false;
} }

View File

@@ -25,6 +25,12 @@ export type PermissionRequestEvent = CustomEvent<{
silent: boolean; silent: boolean;
}>; }>;
export type LinkData = {
type: "note" | "notebook" | "color" | "tag";
title?: string;
metadata?: Record<string, string | undefined>;
};
export class Editor extends TiptapEditor { export class Editor extends TiptapEditor {
private mutex: Mutex = new Mutex(); private mutex: Mutex = new Mutex();