mirror of
https://github.com/docmost/docmost.git
synced 2026-05-20 00:14:10 +08:00
WIP
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user