This commit is contained in:
Philipinho
2026-01-28 11:08:28 +00:00
parent 66dcf53c2c
commit 1411a4bf6f
2 changed files with 62 additions and 54 deletions
@@ -1,14 +1,14 @@
import { MarkViewContent, MarkViewProps } from '@tiptap/react'; import { MarkViewContent, MarkViewProps } from "@tiptap/react";
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from "react-router-dom";
import { import {
IconFileDescription, IconFileDescription,
IconCopy, IconCopy,
IconExternalLink, IconExternalLink,
IconLinkOff, IconLinkOff,
} from '@tabler/icons-react'; } from "@tabler/icons-react";
import { useState, useCallback, useRef, useEffect } from 'react'; import { useState, useCallback, useRef, useEffect } from "react";
import { useLongPress } from '@/features/editor/hooks/use-long-press'; import { useLongPress } from "@/features/editor/hooks/use-long-press";
import { notifications } from '@mantine/notifications'; import { notifications } from "@mantine/notifications";
import { import {
Card, Card,
Group, Group,
@@ -19,15 +19,15 @@ import {
Stack, Stack,
CloseButton, CloseButton,
Tooltip, Tooltip,
} from '@mantine/core'; } from "@mantine/core";
import classes from './link.module.css'; import classes from "./link.module.css";
import { useTranslation } from 'react-i18next'; import { useTranslation } from "react-i18next";
import { createPortal } from 'react-dom'; import { createPortal } from "react-dom";
import { INTERNAL_LINK_REGEX } from '@/lib/constants'; import { INTERNAL_LINK_REGEX } from "@/lib/constants";
const isTouchDevice = () => { const isTouchDevice = () => {
if (typeof window === 'undefined') return false; if (typeof window === "undefined") return false;
return 'ontouchstart' in window || navigator.maxTouchPoints > 0; return "ontouchstart" in window || navigator.maxTouchPoints > 0;
}; };
const isInternalLink = (href: string): boolean => { const isInternalLink = (href: string): boolean => {
@@ -39,20 +39,20 @@ const isInternalLink = (href: string): boolean => {
}; };
const extractLinkLabel = (href: string): string => { const extractLinkLabel = (href: string): string => {
if (!href) return ''; if (!href) return "";
const match = INTERNAL_LINK_REGEX.exec(href); const match = INTERNAL_LINK_REGEX.exec(href);
if (match) { if (match) {
const slug = match[5]; const slug = match[5];
// Extract page name from slug (remove the ID suffix) // Extract page name from slug (remove the ID suffix)
const namePart = slug.split('-').slice(0, -1).join('-'); const namePart = slug.split("-").slice(0, -1).join("-");
return namePart || slug; return namePart || slug;
} }
// For external links, show domain // For external links, show domain
try { try {
const url = new URL(href); const url = new URL(href);
return url.hostname.replace('www.', ''); return url.hostname.replace("www.", "");
} catch { } catch {
return href.slice(0, 30); return href.slice(0, 30);
} }
@@ -68,7 +68,7 @@ export default function LinkView(props: MarkViewProps) {
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [showEditPanel, setShowEditPanel] = useState(false); const [showEditPanel, setShowEditPanel] = useState(false);
const [editUrl, setEditUrl] = useState(href); const [editUrl, setEditUrl] = useState(href);
const [editTitle, setEditTitle] = useState(''); const [editTitle, setEditTitle] = useState("");
const wrapperRef = useRef<HTMLSpanElement>(null); const wrapperRef = useRef<HTMLSpanElement>(null);
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isTouch = isTouchDevice(); const isTouch = isTouchDevice();
@@ -77,13 +77,13 @@ export default function LinkView(props: MarkViewProps) {
const getLinkText = useCallback(() => { const getLinkText = useCallback(() => {
const { state } = editor; const { state } = editor;
let text = ''; let text = "";
state.doc.descendants((node) => { state.doc.descendants((node) => {
const linkMark = node.marks.find( const linkMark = node.marks.find(
(m) => m.type.name === 'link' && m.attrs.href === href (m) => m.type.name === "link" && m.attrs.href === href,
); );
if (linkMark && node.isText) { if (linkMark && node.isText) {
text = node.text || ''; text = node.text || "";
return false; return false;
} }
}); });
@@ -119,7 +119,7 @@ export default function LinkView(props: MarkViewProps) {
if (isInternal) { if (isInternal) {
// Get pathname for navigation (handle both relative and absolute URLs) // Get pathname for navigation (handle both relative and absolute URLs)
let targetPath = href; let targetPath = href;
let anchor = ''; let anchor = "";
try { try {
const url = new URL(href); const url = new URL(href);
@@ -127,8 +127,8 @@ export default function LinkView(props: MarkViewProps) {
anchor = url.hash.slice(1); // Remove the # prefix anchor = url.hash.slice(1); // Remove the # prefix
} catch { } catch {
// Relative URL // Relative URL
if (href.includes('#')) { if (href.includes("#")) {
[targetPath, anchor] = href.split('#'); [targetPath, anchor] = href.split("#");
} }
} }
@@ -138,7 +138,7 @@ export default function LinkView(props: MarkViewProps) {
if (!targetPath || targetPath === currentPath) { if (!targetPath || targetPath === currentPath) {
const element = document.getElementById(anchor); const element = document.getElementById(anchor);
if (element) { if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' }); element.scrollIntoView({ behavior: "smooth", block: "start" });
navigate(`${currentPath}#${anchor}`, { replace: true }); navigate(`${currentPath}#${anchor}`, { replace: true });
return; return;
} }
@@ -147,7 +147,7 @@ export default function LinkView(props: MarkViewProps) {
navigate(anchor ? `${targetPath}#${anchor}` : targetPath); navigate(anchor ? `${targetPath}#${anchor}` : targetPath);
} else { } else {
window.open(href, '_blank', 'noopener,noreferrer'); window.open(href, "_blank", "noopener,noreferrer");
} }
}, [href, navigate, location.pathname, isInternal]); }, [href, navigate, location.pathname, isInternal]);
@@ -159,7 +159,7 @@ export default function LinkView(props: MarkViewProps) {
handleNavigate(); handleNavigate();
} }
}, },
[handleNavigate, showEditPanel] [handleNavigate, showEditPanel],
); );
const handleLongPress = useCallback(() => { const handleLongPress = useCallback(() => {
@@ -176,7 +176,7 @@ export default function LinkView(props: MarkViewProps) {
handleNavigate(); handleNavigate();
} }
}, },
[handleNavigate, showEditPanel] [handleNavigate, showEditPanel],
); );
const longPressHandlers = useLongPress({ const longPressHandlers = useLongPress({
@@ -204,12 +204,12 @@ export default function LinkView(props: MarkViewProps) {
const fullUrl = isInternal ? `${window.location.origin}${href}` : href; const fullUrl = isInternal ? `${window.location.origin}${href}` : href;
navigator.clipboard.writeText(fullUrl); navigator.clipboard.writeText(fullUrl);
notifications.show({ notifications.show({
message: t('Link copied to clipboard'), message: t("Link copied to clipboard"),
color: 'green', color: "green",
autoClose: 2000, autoClose: 2000,
}); });
}, },
[href, isInternal, t] [href, isInternal, t],
); );
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
@@ -221,7 +221,7 @@ export default function LinkView(props: MarkViewProps) {
if (updated) return false; if (updated) return false;
const linkMark = node.marks.find( const linkMark = node.marks.find(
(m) => m.type.name === 'link' && m.attrs.href === href (m) => m.type.name === "link" && m.attrs.href === href,
); );
if (linkMark && node.isText) { if (linkMark && node.isText) {
const from = pos; const from = pos;
@@ -232,14 +232,14 @@ export default function LinkView(props: MarkViewProps) {
tr.addMark(from, to, linkMark.type.create({ href: editUrl })); tr.addMark(from, to, linkMark.type.create({ href: editUrl }));
} }
const currentText = node.text || ''; const currentText = node.text || "";
if (editTitle && editTitle !== currentText) { if (editTitle && editTitle !== currentText) {
tr.replaceWith( tr.replaceWith(
from, from,
to, to,
state.schema.text(editTitle, [ state.schema.text(editTitle, [
linkMark.type.create({ href: editUrl || href }), linkMark.type.create({ href: editUrl || href }),
]) ]),
); );
} }
@@ -256,7 +256,7 @@ export default function LinkView(props: MarkViewProps) {
}, [editor, href, editUrl, editTitle]); }, [editor, href, editUrl, editTitle]);
const handleRemoveLink = useCallback(() => { const handleRemoveLink = useCallback(() => {
editor.chain().focus().extendMarkRange('link').unsetLink().run(); editor.chain().focus().extendMarkRange("link").unsetLink().run();
setShowEditPanel(false); setShowEditPanel(false);
}, [editor]); }, [editor]);
@@ -265,15 +265,15 @@ export default function LinkView(props: MarkViewProps) {
// Stop all keyboard events from bubbling to TipTap editor // Stop all keyboard events from bubbling to TipTap editor
e.stopPropagation(); e.stopPropagation();
if (e.key === 'Enter') { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
handleSave(); handleSave();
} else if (e.key === 'Escape') { } else if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
handleCloseEdit(); handleCloseEdit();
} }
}, },
[handleSave, handleCloseEdit] [handleSave, handleCloseEdit],
); );
const interactionProps = isTouch const interactionProps = isTouch
@@ -297,8 +297,8 @@ export default function LinkView(props: MarkViewProps) {
href={href} href={href}
className={classes.linkText} className={classes.linkText}
onClick={(e) => e.preventDefault()} onClick={(e) => e.preventDefault()}
target={isInternal ? undefined : '_blank'} target={isInternal ? undefined : "_blank"}
rel={isInternal ? undefined : 'noopener noreferrer'} rel={isInternal ? undefined : "noopener noreferrer"}
> >
<MarkViewContent /> <MarkViewContent />
</a> </a>
@@ -316,7 +316,7 @@ export default function LinkView(props: MarkViewProps) {
<Group <Group
gap={6} gap={6}
wrap="nowrap" wrap="nowrap"
style={{ cursor: 'pointer', maxWidth: 180 }} style={{ cursor: "pointer", maxWidth: 180 }}
onClick={handleNavigate} onClick={handleNavigate}
> >
{isInternal ? ( {isInternal ? (
@@ -329,20 +329,28 @@ export default function LinkView(props: MarkViewProps) {
</Text> </Text>
</Group> </Group>
<Tooltip label={t('Copy link')} withArrow> <Tooltip label={t("Copy link")} withArrow>
<ActionIcon variant="subtle" color="gray" onClick={handleCopy}> <ActionIcon
variant="subtle"
color="gray"
onClick={handleCopy}
>
<IconCopy size={18} /> <IconCopy size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label={t('Remove link')} withArrow> <Tooltip label={t("Remove link")} withArrow>
<ActionIcon variant="subtle" color="gray" onClick={handleRemoveLink}> <ActionIcon
variant="subtle"
color="gray"
onClick={handleRemoveLink}
>
<IconLinkOff size={18} /> <IconLinkOff size={18} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Button size="xs" variant="subtle" onClick={handleOpenEdit}> <Button size="xs" variant="subtle" onClick={handleOpenEdit}>
{t('Edit')} {t("Edit")}
</Button> </Button>
</Group> </Group>
</Card> </Card>
@@ -357,7 +365,7 @@ export default function LinkView(props: MarkViewProps) {
className={classes.editPanelOverlay} className={classes.editPanelOverlay}
onClick={handleCloseEdit} onClick={handleCloseEdit}
/>, />,
document.body document.body,
)} )}
<div <div
contentEditable={false} contentEditable={false}
@@ -369,14 +377,14 @@ export default function LinkView(props: MarkViewProps) {
<Card shadow="md" padding="md" radius="md" withBorder w={320}> <Card shadow="md" padding="md" radius="md" withBorder w={320}>
<Stack gap="md"> <Stack gap="md">
<TextInput <TextInput
label={t('Search or paste a link')} label={t("Search or paste a link")}
placeholder="https://..." placeholder="https://..."
value={editUrl} value={editUrl}
onChange={(e) => setEditUrl(e.target.value)} onChange={(e) => setEditUrl(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
rightSection={ rightSection={
editUrl && ( editUrl && (
<CloseButton size="sm" onClick={() => setEditUrl('')} /> <CloseButton size="sm" onClick={() => setEditUrl("")} />
) )
} }
autoFocus autoFocus
@@ -384,9 +392,9 @@ export default function LinkView(props: MarkViewProps) {
/> />
<TextInput <TextInput
label={t('Display text (optional)')} label={t("Display text (optional)")}
description={t('Give this link a title or description')} description={t("Give this link a title or description")}
placeholder={t('Text to display')} placeholder={t("Text to display")}
value={editTitle} value={editTitle}
onChange={(e) => setEditTitle(e.target.value)} onChange={(e) => setEditTitle(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@@ -398,10 +406,10 @@ export default function LinkView(props: MarkViewProps) {
onClick={handleCloseEdit} onClick={handleCloseEdit}
size="sm" size="sm"
> >
{t('Cancel')} {t("Cancel")}
</Button> </Button>
<Button onClick={handleSave} size="sm"> <Button onClick={handleSave} size="sm">
{t('Save')} {t("Save")}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
@@ -42,7 +42,7 @@ export const useEditorScroll = ({
return; return;
} }
const dom = editor.view.dom.querySelector(`[id="${targetId}"]`); const dom = editor.view.dom.querySelector(`[id="${targetId}"], [data-id="${targetId}"]`);
if (dom) { if (dom) {
dom.scrollIntoView({ behavior: 'smooth', block: 'start' }); dom.scrollIntoView({ behavior: 'smooth', block: 'start' });
resolve(true); resolve(true);