diff --git a/apps/client/src/features/editor/components/link/link-editor-panel.tsx b/apps/client/src/features/editor/components/link/link-editor-panel.tsx index 733455cf..e7e3b72b 100644 --- a/apps/client/src/features/editor/components/link/link-editor-panel.tsx +++ b/apps/client/src/features/editor/components/link/link-editor-panel.tsx @@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next"; export const LinkEditorPanel = ({ onSetLink, initialUrl, + onUnsetLink, }: LinkEditorPanelProps) => { const { t } = useTranslation(); const state = useLinkEditorState({ @@ -25,10 +26,16 @@ export const LinkEditorPanel = ({ placeholder={t("Paste link")} value={state.url} onChange={state.onChange} + style={{ flex: 1 }} /> + {onUnsetLink && ( + + )} diff --git a/apps/client/src/features/editor/components/link/link-view.tsx b/apps/client/src/features/editor/components/link/link-view.tsx new file mode 100644 index 00000000..3bbdb2ed --- /dev/null +++ b/apps/client/src/features/editor/components/link/link-view.tsx @@ -0,0 +1,415 @@ +import { MarkViewContent, MarkViewProps } from '@tiptap/react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { + IconFileDescription, + IconCopy, + IconExternalLink, + IconLinkOff, +} from '@tabler/icons-react'; +import { useState, useCallback, useRef, useEffect } from 'react'; +import { useLongPress } from '@/features/editor/hooks/use-long-press'; +import { notifications } from '@mantine/notifications'; +import { + Card, + Group, + Button, + TextInput, + Text, + ActionIcon, + Stack, + CloseButton, + Tooltip, +} from '@mantine/core'; +import classes from './link.module.css'; +import { useTranslation } from 'react-i18next'; +import { createPortal } from 'react-dom'; +import { INTERNAL_LINK_REGEX } from '@/lib/constants'; + +const isTouchDevice = () => { + if (typeof window === 'undefined') return false; + return 'ontouchstart' in window || navigator.maxTouchPoints > 0; +}; + +const isInternalLink = (href: string): boolean => { + if (!href) return false; + const match = INTERNAL_LINK_REGEX.exec(href); + if (!match) return false; + + return !(match[2] && match[2] !== window.location.host); +}; + +const extractLinkLabel = (href: string): string => { + if (!href) return ''; + + const match = INTERNAL_LINK_REGEX.exec(href); + if (match) { + const slug = match[5]; + // Extract page name from slug (remove the ID suffix) + const namePart = slug.split('-').slice(0, -1).join('-'); + return namePart || slug; + } + + // For external links, show domain + try { + const url = new URL(href); + return url.hostname.replace('www.', ''); + } catch { + return href.slice(0, 30); + } +}; + +export default function LinkView(props: MarkViewProps) { + const { mark, editor } = props; + const href = mark.attrs.href as string; + const navigate = useNavigate(); + const location = useLocation(); + const { t } = useTranslation(); + + const [isHovered, setIsHovered] = useState(false); + const [showEditPanel, setShowEditPanel] = useState(false); + const [editUrl, setEditUrl] = useState(href); + const [editTitle, setEditTitle] = useState(''); + const wrapperRef = useRef(null); + const hoverTimeoutRef = useRef | null>(null); + const isTouch = isTouchDevice(); + const isEditable = editor.isEditable; + const isInternal = isInternalLink(href); + + const getLinkText = useCallback(() => { + const { state } = editor; + let text = ''; + state.doc.descendants((node) => { + const linkMark = node.marks.find( + (m) => m.type.name === 'link' && m.attrs.href === href + ); + if (linkMark && node.isText) { + text = node.text || ''; + return false; + } + }); + return text; + }, [editor, href]); + + useEffect(() => { + if (showEditPanel) { + setEditUrl(href); + setEditTitle(getLinkText()); + } + }, [showEditPanel, href, getLinkText]); + + const handleMouseEnter = useCallback(() => { + if (showEditPanel) return; + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + setIsHovered(true); + }, [showEditPanel]); + + const handleMouseLeave = useCallback(() => { + if (showEditPanel) return; + hoverTimeoutRef.current = setTimeout(() => { + setIsHovered(false); + }, 200); + }, [showEditPanel]); + + const handleNavigate = useCallback(() => { + if (!href) return; + + if (isInternal) { + // Get pathname for navigation (handle both relative and absolute URLs) + let targetPath = href; + let anchor = ''; + + try { + const url = new URL(href); + targetPath = url.pathname; + anchor = url.hash.slice(1); // Remove the # prefix + } catch { + // Relative URL + if (href.includes('#')) { + [targetPath, anchor] = href.split('#'); + } + } + + // Handle anchor links on same page + if (anchor) { + const currentPath = location.pathname; + if (!targetPath || targetPath === currentPath) { + const element = document.getElementById(anchor); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'start' }); + navigate(`${currentPath}#${anchor}`, { replace: true }); + return; + } + } + } + + navigate(anchor ? `${targetPath}#${anchor}` : targetPath); + } else { + window.open(href, '_blank', 'noopener,noreferrer'); + } + }, [href, navigate, location.pathname, isInternal]); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!showEditPanel) { + handleNavigate(); + } + }, + [handleNavigate, showEditPanel] + ); + + const handleLongPress = useCallback(() => { + if (isEditable) { + setShowEditPanel(true); + setIsHovered(false); + } + }, [isEditable]); + + const handleTapNavigate = useCallback( + (e: React.TouchEvent | React.MouseEvent) => { + e.preventDefault(); + if (!showEditPanel) { + handleNavigate(); + } + }, + [handleNavigate, showEditPanel] + ); + + const longPressHandlers = useLongPress({ + threshold: 500, + onLongPress: handleLongPress, + onClick: handleTapNavigate, + }); + + const handleOpenEdit = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setShowEditPanel(true); + setIsHovered(false); + }, []); + + const handleCloseEdit = useCallback(() => { + setShowEditPanel(false); + }, []); + + const handleCopy = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const fullUrl = isInternal ? `${window.location.origin}${href}` : href; + navigator.clipboard.writeText(fullUrl); + notifications.show({ + message: t('Link copied to clipboard'), + color: 'green', + autoClose: 2000, + }); + }, + [href, isInternal, t] + ); + + const handleSave = useCallback(() => { + const { state } = editor; + const { tr } = state; + + let updated = false; + state.doc.descendants((node, pos) => { + if (updated) return false; + + const linkMark = node.marks.find( + (m) => m.type.name === 'link' && m.attrs.href === href + ); + if (linkMark && node.isText) { + const from = pos; + const to = pos + node.nodeSize; + + if (editUrl !== href) { + tr.removeMark(from, to, linkMark.type); + tr.addMark(from, to, linkMark.type.create({ href: editUrl })); + } + + const currentText = node.text || ''; + if (editTitle && editTitle !== currentText) { + tr.replaceWith( + from, + to, + state.schema.text(editTitle, [ + linkMark.type.create({ href: editUrl || href }), + ]) + ); + } + + updated = true; + return false; + } + }); + + if (updated) { + editor.view.dispatch(tr); + } + + setShowEditPanel(false); + }, [editor, href, editUrl, editTitle]); + + const handleRemoveLink = useCallback(() => { + editor.chain().focus().extendMarkRange('link').unsetLink().run(); + setShowEditPanel(false); + }, [editor]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + // Stop all keyboard events from bubbling to TipTap editor + e.stopPropagation(); + + if (e.key === 'Enter') { + e.preventDefault(); + handleSave(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleCloseEdit(); + } + }, + [handleSave, handleCloseEdit] + ); + + const interactionProps = isTouch + ? { ...longPressHandlers } + : { + onClick: handleClick, + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + }; + + const linkLabel = extractLinkLabel(href); + + return ( + <> + + e.preventDefault()} + target={isInternal ? undefined : '_blank'} + rel={isInternal ? undefined : 'noopener noreferrer'} + > + + + + {/* Hover Toolbar */} + {isEditable && !isTouch && isHovered && !showEditPanel && ( + + + + + {isInternal ? ( + + ) : ( + + )} + + {linkLabel} + + + + + + + + + + + + + + + + + + + + )} + + {/* Edit Panel */} + {isEditable && showEditPanel && ( + <> + {createPortal( +
, + document.body + )} +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + onMouseUp={(e) => e.stopPropagation()} + > + + + setEditUrl(e.target.value)} + onKeyDown={handleKeyDown} + rightSection={ + editUrl && ( + setEditUrl('')} /> + ) + } + autoFocus + withAsterisk + /> + + setEditTitle(e.target.value)} + onKeyDown={handleKeyDown} + /> + + + + + + + +
+ + )} + + + ); +} diff --git a/apps/client/src/features/editor/components/link/link.module.css b/apps/client/src/features/editor/components/link/link.module.css index 2168997f..528cbc3d 100644 --- a/apps/client/src/features/editor/components/link/link.module.css +++ b/apps/client/src/features/editor/components/link/link.module.css @@ -1,6 +1,51 @@ .link { - color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1)); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} \ No newline at end of file + color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-1)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.linkWrapper { + position: relative; + display: inline; +} + +.linkText { + color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4)); + text-decoration: underline; + text-decoration-color: light-dark( + var(--mantine-color-blue-3), + var(--mantine-color-blue-7) + ); + text-underline-offset: 2px; + cursor: pointer; + + &:hover { + text-decoration-color: light-dark( + var(--mantine-color-blue-6), + var(--mantine-color-blue-4) + ); + } +} + +.linkToolbar { + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + z-index: 100; +} + +.editPanel { + position: absolute; + top: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + z-index: 101; +} + +.editPanelOverlay { + position: fixed; + inset: 0; + z-index: 100; +} diff --git a/apps/client/src/features/editor/components/link/types.ts b/apps/client/src/features/editor/components/link/types.ts index 853dcae6..a2cc2024 100644 --- a/apps/client/src/features/editor/components/link/types.ts +++ b/apps/client/src/features/editor/components/link/types.ts @@ -1,4 +1,5 @@ export type LinkEditorPanelProps = { initialUrl?: string; onSetLink: (url: string, openInNewTab?: boolean) => void; + onUnsetLink?: () => void; }; diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index ef03108b..380ad7c5 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -72,8 +72,9 @@ import fortran from "highlight.js/lib/languages/fortran"; import haskell from "highlight.js/lib/languages/haskell"; import scala from "highlight.js/lib/languages/scala"; import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion.ts"; -import { ReactNodeViewRenderer } from "@tiptap/react"; +import { ReactNodeViewRenderer, ReactMarkViewRenderer } from "@tiptap/react"; import MentionView from "@/features/editor/components/mention/mention-view.tsx"; +import LinkView from "@/features/editor/components/link/link-view.tsx"; import i18n from "@/i18n.ts"; import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts"; import EmojiCommand from "./emoji-command"; @@ -136,6 +137,10 @@ export const mainExtensions = [ }), LinkExtension.configure({ openOnClick: false, + }).extend({ + addMarkView() { + return ReactMarkViewRenderer(LinkView); + }, }), Superscript, SubScript, diff --git a/apps/client/src/features/editor/hooks/use-long-press.ts b/apps/client/src/features/editor/hooks/use-long-press.ts new file mode 100644 index 00000000..6e4aaec8 --- /dev/null +++ b/apps/client/src/features/editor/hooks/use-long-press.ts @@ -0,0 +1,106 @@ +import { useCallback, useRef } from 'react'; + +type LongPressOptions = { + threshold?: number; + onLongPress: (e: React.TouchEvent | React.MouseEvent) => void; + onClick?: (e: React.TouchEvent | React.MouseEvent) => void; +}; + +type LongPressHandlers = { + onMouseDown: (e: React.MouseEvent) => void; + onMouseUp: (e: React.MouseEvent) => void; + onMouseLeave: (e: React.MouseEvent) => void; + onTouchStart: (e: React.TouchEvent) => void; + onTouchEnd: (e: React.TouchEvent) => void; +}; + +export function useLongPress({ + threshold = 400, + onLongPress, + onClick, +}: LongPressOptions): LongPressHandlers { + const timerRef = useRef | null>(null); + const isLongPressRef = useRef(false); + const startPosRef = useRef<{ x: number; y: number } | null>(null); + + const start = useCallback( + (e: React.TouchEvent | React.MouseEvent) => { + isLongPressRef.current = false; + + // Store initial position to detect movement + if ('touches' in e) { + startPosRef.current = { + x: e.touches[0].clientX, + y: e.touches[0].clientY, + }; + } else { + startPosRef.current = { x: e.clientX, y: e.clientY }; + } + + timerRef.current = setTimeout(() => { + isLongPressRef.current = true; + onLongPress(e); + }, threshold); + }, + [onLongPress, threshold] + ); + + const clear = useCallback( + (e: React.TouchEvent | React.MouseEvent, shouldTriggerClick = true) => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + + if (shouldTriggerClick && !isLongPressRef.current && onClick) { + onClick(e); + } + }, + [onClick] + ); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + // Only handle left click + if (e.button !== 0) return; + start(e); + }, + [start] + ); + + const handleMouseUp = useCallback( + (e: React.MouseEvent) => { + clear(e); + }, + [clear] + ); + + const handleMouseLeave = useCallback( + (e: React.MouseEvent) => { + clear(e, false); + }, + [clear] + ); + + const handleTouchStart = useCallback( + (e: React.TouchEvent) => { + start(e); + }, + [start] + ); + + const handleTouchEnd = useCallback( + (e: React.TouchEvent) => { + clear(e); + }, + [clear] + ); + + return { + onMouseDown: handleMouseDown, + onMouseUp: handleMouseUp, + onMouseLeave: handleMouseLeave, + onTouchStart: handleTouchStart, + onTouchEnd: handleTouchEnd, + }; +} diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx index da8bd84a..393e02d6 100644 --- a/apps/client/src/features/editor/page-editor.tsx +++ b/apps/client/src/features/editor/page-editor.tsx @@ -50,7 +50,6 @@ import { handleFileDrop, handlePaste, } from "@/features/editor/components/common/editor-paste-handler.tsx"; -import LinkMenu from "@/features/editor/components/link/link-menu.tsx"; import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu"; import DrawioMenu from "./components/drawio/drawio-menu"; import { useCollabToken } from "@/features/auth/queries/auth-query.tsx"; @@ -414,7 +413,6 @@ export default function PageEditor({ -
)} {showCommentPopup && }