diff --git a/frontend/editor/src/core/components/viewer/LinkLayer.tsx b/frontend/editor/src/core/components/viewer/LinkLayer.tsx index 64c900b42..227045ebb 100644 --- a/frontend/editor/src/core/components/viewer/LinkLayer.tsx +++ b/frontend/editor/src/core/components/viewer/LinkLayer.tsx @@ -1,4 +1,12 @@ -import React, { useCallback, useState, useMemo, useRef } from "react"; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useState, + useMemo, + useRef, +} from "react"; +import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { useDocumentState } from "@embedpdf/core/react"; import { useScroll } from "@embedpdf/plugin-scroll/react"; @@ -8,6 +16,7 @@ import { PdfActionType, type PdfLinkAnnoObject, } from "@embedpdf/models"; +import { Z_INDEX_VIEWER_FLOATING_MENU } from "@app/styles/zIndex"; // --------------------------------------------------------------------------- // Inline SVG icons (thin-stroke, modern) @@ -123,7 +132,9 @@ function isInternalLink(annotationLink: PdfLinkAnnoObject): boolean { interface LinkToolbarProps { annotationLink: PdfLinkAnnoObject; - scale: number; + toolbarRef: React.Ref; + left: number; + top: number; flipped: boolean; onNavigate: (annotationLink: PdfLinkAnnoObject) => void; onDelete: (annotationLink: PdfLinkAnnoObject) => void; @@ -133,11 +144,43 @@ interface LinkToolbarProps { const TOOLBAR_HEIGHT = 32; const TOOLBAR_GAP = 8; +const TOOLBAR_EDGE_MARGIN = 4; +const TOOLBAR_LEAVE_DELAY = 120; + +interface ToolbarPlacement { + linkId: string; + left: number; + top: number; + flipped: boolean; +} + +function clampToolbarCenter(centerX: number, toolbarWidth: number): number { + if (toolbarWidth <= 0 || typeof window === "undefined") return centerX; + + const minCenter = TOOLBAR_EDGE_MARGIN + toolbarWidth / 2; + const maxCenter = window.innerWidth - TOOLBAR_EDGE_MARGIN - toolbarWidth / 2; + + if (minCenter > maxCenter) return window.innerWidth / 2; + return Math.min(Math.max(centerX, minCenter), maxCenter); +} + +function isRectOutsideViewport(rect: DOMRect): boolean { + if (typeof window === "undefined") return false; + + return ( + rect.bottom < 0 || + rect.top > window.innerHeight || + rect.right < 0 || + rect.left > window.innerWidth + ); +} const LinkToolbar: React.FC = React.memo( ({ annotationLink, - scale, + toolbarRef, + left, + top, flipped, onNavigate, onDelete, @@ -145,22 +188,20 @@ const LinkToolbar: React.FC = React.memo( onMouseLeave, }) => { const { t } = useTranslation(); - const centerX = - (annotationLink.rect.origin.x + annotationLink.rect.size.width / 2) * - scale; - const topY = flipped - ? (annotationLink.rect.origin.y + annotationLink.rect.size.height) * - scale + - TOOLBAR_GAP - : annotationLink.rect.origin.y * scale - TOOLBAR_HEIGHT - TOOLBAR_GAP; - const internal = isInternalLink(annotationLink); const label = getLinkLabel(annotationLink); return (
@@ -188,11 +229,7 @@ const LinkToolbar: React.FC = React.memo( e.stopPropagation(); onNavigate(annotationLink); }} - aria-label={ - internal - ? `Go to page ${annotationLink.target?.type === "destination" ? annotationLink.target.destination.pageIndex + 1 : ""}` - : "Open link" - } + aria-label={internal ? `Go to ${label}` : "Open link"} title={label} > {internal ? : } @@ -223,7 +260,11 @@ export const LinkLayer: React.FC = ({ const documentState = useDocumentState(documentId); const [hoveredLinkId, setHoveredLinkId] = useState(null); + const [toolbarPlacement, setToolbarPlacement] = + useState(null); const leaveTimerRef = useRef | null>(null); + const linkElementRefs = useRef>(new Map()); + const toolbarElementRef = useRef(null); // Extract link annotations for this page from EmbedPDF annotation state const linkAnnotations = useMemo(() => { @@ -251,6 +292,52 @@ export const LinkLayer: React.FC = ({ // EmbedPDF scale factor (annotation rects are in PDF points at scale 1) const scale = documentState?.scale ?? 1; + // The portal position is measured from DOM, so this key realigns it when the page transform changes. + const toolbarPositionKey = [ + scale, + documentState?.document?.pages?.[pageIndex]?.rotation ?? 0, + documentState?.rotation ?? 0, + ].join(":"); + + const updateToolbarPlacement = useCallback((linkId: string | null) => { + if (!linkId) { + setToolbarPlacement(null); + return; + } + + const linkElement = linkElementRefs.current.get(linkId); + if (!linkElement) { + setHoveredLinkId(null); + setToolbarPlacement(null); + return; + } + + if (typeof window === "undefined") return; + + const rect = linkElement.getBoundingClientRect(); + if (isRectOutsideViewport(rect)) { + setHoveredLinkId(null); + setToolbarPlacement(null); + return; + } + + const toolbarWidth = toolbarElementRef.current?.offsetWidth ?? 0; + const verticalSpace = TOOLBAR_HEIGHT + TOOLBAR_GAP + TOOLBAR_EDGE_MARGIN; + const fitsAbove = rect.top >= verticalSpace; + const fitsBelow = window.innerHeight - rect.bottom >= verticalSpace; + const centerX = rect.left + rect.width / 2; + const flipped = !fitsAbove && fitsBelow; + const top = flipped + ? rect.bottom + TOOLBAR_GAP + : Math.max(TOOLBAR_EDGE_MARGIN, rect.top - TOOLBAR_HEIGHT - TOOLBAR_GAP); + + setToolbarPlacement({ + linkId, + left: clampToolbarCenter(centerX, toolbarWidth), + top, + flipped, + }); + }, []); const clearLeaveTimer = useCallback(() => { if (leaveTimerRef.current) { @@ -263,15 +350,17 @@ export const LinkLayer: React.FC = ({ clearLeaveTimer(); leaveTimerRef.current = setTimeout(() => { setHoveredLinkId(null); - }, 120); + setToolbarPlacement(null); + }, TOOLBAR_LEAVE_DELAY); }, [clearLeaveTimer]); const handleLinkMouseEnter = useCallback( (id: string) => { clearLeaveTimer(); setHoveredLinkId(id); + updateToolbarPlacement(id); }, - [clearLeaveTimer], + [clearLeaveTimer, updateToolbarPlacement], ); const handleLinkMouseLeave = useCallback(() => { @@ -290,6 +379,7 @@ export const LinkLayer: React.FC = ({ (annotationLink: PdfLinkAnnoObject) => { if (!annotationLink.target) { setHoveredLinkId(null); + setToolbarPlacement(null); return; } @@ -329,6 +419,7 @@ export const LinkLayer: React.FC = ({ } setHoveredLinkId(null); + setToolbarPlacement(null); }, [scroll], ); @@ -336,35 +427,103 @@ export const LinkLayer: React.FC = ({ const handleDelete = useCallback( (annotationLink: PdfLinkAnnoObject) => { setHoveredLinkId(null); + setToolbarPlacement(null); if (!scope) return; scope.deleteAnnotation(pageIndex, annotationLink.id); }, [scope, pageIndex], ); + useEffect(() => () => clearLeaveTimer(), [clearLeaveTimer]); + + useEffect(() => { + if (!hoveredLinkId) { + setToolbarPlacement(null); + return; + } + + updateToolbarPlacement(hoveredLinkId); + + if (typeof window === "undefined") return; + + const handleViewportChange = () => { + updateToolbarPlacement(hoveredLinkId); + }; + + window.addEventListener("scroll", handleViewportChange, true); + window.addEventListener("resize", handleViewportChange); + + return () => { + window.removeEventListener("scroll", handleViewportChange, true); + window.removeEventListener("resize", handleViewportChange); + }; + }, [ + hoveredLinkId, + linkAnnotations, + toolbarPositionKey, + updateToolbarPlacement, + ]); + + useLayoutEffect(() => { + if (!hoveredLinkId || toolbarPlacement?.linkId !== hoveredLinkId) return; + updateToolbarPlacement(hoveredLinkId); + }, [ + hoveredLinkId, + toolbarPlacement?.linkId, + toolbarPositionKey, + updateToolbarPlacement, + ]); + + const hoveredAnnotationLink = useMemo( + () => linkAnnotations.find((link) => link.id === hoveredLinkId) ?? null, + [linkAnnotations, hoveredLinkId], + ); + if (linkAnnotations.length === 0) return null; + const toolbarPortal = + hoveredAnnotationLink && + toolbarPlacement?.linkId === hoveredAnnotationLink.id && + typeof document !== "undefined" + ? createPortal( + , + document.body, + ) + : null; + return ( -
- {linkAnnotations.map((annotationLink) => { - const isHovered = hoveredLinkId === annotationLink.id; - const left = annotationLink.rect.origin.x * scale; - const top = annotationLink.rect.origin.y * scale; - const width = annotationLink.rect.size.width * scale; - const height = annotationLink.rect.size.height * scale; + <> +
+ {linkAnnotations.map((annotationLink) => { + const isHovered = hoveredLinkId === annotationLink.id; + const left = annotationLink.rect.origin.x * scale; + const top = annotationLink.rect.origin.y * scale; + const width = annotationLink.rect.size.width * scale; + const height = annotationLink.rect.size.height * scale; - // Flip toolbar below if link is near the top of the page - const flipped = - annotationLink.rect.origin.y * scale < - TOOLBAR_HEIGHT + TOOLBAR_GAP + 4; - - return ( - - {/* Hit-area overlay */} + return ( { + if (node) { + linkElementRefs.current.set(annotationLink.id, node); + } else { + linkElementRefs.current.delete(annotationLink.id); + } + }} href="#" onClick={(e) => { e.preventDefault(); @@ -387,22 +546,10 @@ export const LinkLayer: React.FC = ({ tabIndex={0} aria-label={getLinkLabel(annotationLink)} /> - - {/* Floating toolbar */} - {isHovered && ( - - )} - - ); - })} -
+ ); + })} +
+ {toolbarPortal} + ); }; diff --git a/frontend/editor/src/core/styles/theme.css b/frontend/editor/src/core/styles/theme.css index 265afa439..0e5242e36 100644 --- a/frontend/editor/src/core/styles/theme.css +++ b/frontend/editor/src/core/styles/theme.css @@ -202,8 +202,6 @@ --icon-sign-color: #ffffff; --icon-automate-bg: #a576e3; --icon-automate-color: #ffffff; - --icon-watchedFolders-bg: #f59e0b; - --icon-watchedFolders-color: #ffffff; --icon-files-bg: #d3e7f7; --icon-files-color: #0a8bff; --icon-activity-bg: #d3e7f7; @@ -537,8 +535,6 @@ --icon-sign-color: #eaeaea; --icon-automate-bg: #4b525a; --icon-automate-color: #eaeaea; - --icon-watchedFolders-bg: #4b525a; - --icon-watchedFolders-color: #eaeaea; --icon-files-bg: #4b525a; --icon-files-color: #eaeaea; --icon-activity-bg: #4b525a; @@ -800,10 +796,11 @@ } /* ── PDF Link Overlay (viewer) ── */ :root { - --link-hover-bg: rgba(10, 139, 255, 0.04); - --link-hover-border: rgba(10, 139, 255, 0.15); + --link-hover-bg: rgba(10, 139, 255, 0.1); + --link-hover-border: rgba(10, 139, 255, 0.32); --link-hover-shadow: - 0 0 0 0.5px rgba(10, 139, 255, 0.05), 0 1px 3px rgba(10, 139, 255, 0.04); + inset 0 0 0 1px rgba(255, 255, 255, 0.35), + 0 1px 4px rgba(10, 139, 255, 0.12); --link-focus-ring: rgba(10, 139, 255, 0.25); --link-toolbar-bg: rgba(15, 23, 42, 0.88); --link-toolbar-border: rgba(255, 255, 255, 0.1); @@ -813,10 +810,11 @@ } [data-mantine-color-scheme="dark"] { - --link-hover-bg: rgba(10, 139, 255, 0.06); - --link-hover-border: rgba(59, 170, 255, 0.2); + --link-hover-bg: rgba(59, 170, 255, 0.14); + --link-hover-border: rgba(59, 170, 255, 0.38); --link-hover-shadow: - 0 0 0 0.5px rgba(59, 170, 255, 0.1), 0 1px 3px rgba(10, 139, 255, 0.08); + inset 0 0 0 1px rgba(255, 255, 255, 0.14), + 0 1px 4px rgba(10, 139, 255, 0.18); --link-focus-ring: rgba(59, 170, 255, 0.3); --link-toolbar-bg: rgba(15, 23, 42, 0.92); --link-toolbar-border: rgba(255, 255, 255, 0.08); @@ -847,7 +845,7 @@ .pdf-link-overlay:hover, .pdf-link-overlay--active { background: var(--link-hover-bg); - border-bottom: 2px solid var(--color-primary-500); + border-color: var(--link-hover-border); box-shadow: var(--link-hover-shadow); } @@ -889,6 +887,7 @@ align-items: center; height: 32px; padding: 0 4px; + box-sizing: border-box; pointer-events: auto; white-space: nowrap; user-select: none; @@ -934,11 +933,15 @@ cursor: pointer; transition: all 0.12s ease; white-space: nowrap; - line-height: 1; + line-height: 1.25; border-radius: 9999px; } .pdf-link-toolbar-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.25; transition: color 0.12s ease; } @@ -985,6 +988,10 @@ background: rgba(255, 255, 255, 0.08); } +.pdf-link-toolbar-btn--go { + min-width: 0; +} + .pdf-link-toolbar-sep { width: 1px; height: 16px; diff --git a/frontend/editor/src/core/styles/zIndex.ts b/frontend/editor/src/core/styles/zIndex.ts index 3cec59abf..8d7052397 100644 --- a/frontend/editor/src/core/styles/zIndex.ts +++ b/frontend/editor/src/core/styles/zIndex.ts @@ -34,6 +34,9 @@ export const Z_INDEX_COOKIE_PREFERENCES_MODAL = 1450; // Sign-in modal — must appear above all app UI including config and analytics modals export const Z_INDEX_SIGN_IN_MODAL = 9000; +// Floating viewer menus rendered through document.body portals. +export const Z_INDEX_VIEWER_FLOATING_MENU = 10000; + // Toast notifications and error displays - Always on top (higher than rainbow theme at 10000) export const Z_INDEX_TOAST = 10001;