Skip to content

Commit 39a3518

Browse files
authored
Show a HoverCard when an internal link is broken (#3516)
1 parent d903273 commit 39a3518

File tree

15 files changed

+182
-138
lines changed

15 files changed

+182
-138
lines changed

packages/gitbook/src/components/DocumentView/InlineLink/InlineLink.tsx

Lines changed: 31 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { type DocumentInlineLink, SiteInsightsLinkPosition } from '@gitbook/api';
22

33
import { getSpaceLanguage, tString } from '@/intl/server';
4-
import { languages } from '@/intl/translations';
5-
import type { GitBookAnyContext } from '@/lib/context';
4+
import { type TranslationLanguage, languages } from '@/intl/translations';
65
import { type ResolvedContentRef, resolveContentRef } from '@/lib/references';
76
import { Icon } from '@gitbook/icons';
8-
import { StyledLink } from '../../primitives';
7+
import { HoverCard, HoverCardRoot, HoverCardTrigger, StyledLink } from '../../primitives';
98
import type { InlineProps } from '../Inline';
109
import { Inlines } from '../Inlines';
1110
import { InlineLinkTooltip } from './InlineLinkTooltip';
@@ -19,17 +18,34 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
1918
resolveAnchorText: false,
2019
})
2120
: null;
21+
const { contentContext } = context;
2222

23-
if (!context.contentContext || !resolved) {
23+
const language =
24+
contentContext && 'customization' in contentContext
25+
? getSpaceLanguage(contentContext.customization)
26+
: languages.en;
27+
28+
if (!contentContext || !resolved) {
2429
return (
25-
<span title="Broken link" className="underline">
26-
<Inlines
27-
context={context}
28-
document={document}
29-
nodes={inline.nodes}
30-
ancestorInlines={[...ancestorInlines, inline]}
31-
/>
32-
</span>
30+
<HoverCardRoot>
31+
<HoverCardTrigger>
32+
<span className="cursor-not-allowed underline">
33+
<Inlines
34+
context={context}
35+
document={document}
36+
nodes={inline.nodes}
37+
ancestorInlines={[...ancestorInlines, inline]}
38+
/>
39+
</span>
40+
</HoverCardTrigger>
41+
<HoverCard className="flex flex-col gap-1 p-4">
42+
<div className="flex items-center gap-2">
43+
<Icon icon="ban" className="size-4 text-tint-subtle" />
44+
<h5 className="font-semibold">{tString(language, 'notfound_title')}</h5>
45+
</div>
46+
<p className="text-sm text-tint">{tString(language, 'notfound_link')}</p>
47+
</HoverCard>
48+
</HoverCardRoot>
3349
);
3450
}
3551
const isExternal = inline.data.ref.kind === 'url';
@@ -61,11 +77,7 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
6177

6278
if (context.shouldRenderLinkPreviews) {
6379
return (
64-
<InlineLinkTooltipWrapper
65-
inline={inline}
66-
context={context.contentContext}
67-
resolved={resolved}
68-
>
80+
<InlineLinkTooltipWrapper inline={inline} language={language} resolved={resolved}>
6981
{content}
7082
</InlineLinkTooltipWrapper>
7183
);
@@ -80,15 +92,13 @@ export async function InlineLink(props: InlineProps<DocumentInlineLink>) {
8092
*/
8193
function InlineLinkTooltipWrapper(props: {
8294
inline: DocumentInlineLink;
83-
context: GitBookAnyContext;
8495
children: React.ReactNode;
8596
resolved: ResolvedContentRef;
97+
language: TranslationLanguage;
8698
}) {
87-
const { inline, context, resolved, children } = props;
99+
const { inline, language, resolved, children } = props;
88100

89101
let breadcrumbs = resolved.ancestors ?? [];
90-
const language =
91-
'customization' in context ? getSpaceLanguage(context.customization) : languages.en;
92102
const isExternal = inline.data.ref.kind === 'url';
93103
const isSamePage = inline.data.ref.kind === 'anchor' && inline.data.ref.page === undefined;
94104
if (isExternal) {
Lines changed: 68 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
'use client';
22
import { tcls } from '@/lib/tailwind';
33
import { Icon } from '@gitbook/icons';
4-
import * as Tooltip from '@radix-ui/react-tooltip';
54
import { Fragment } from 'react';
6-
import { Button, StyledLink } from '../../primitives';
5+
import { Button, HoverCard, HoverCardRoot, HoverCardTrigger, StyledLink } from '../../primitives';
76

87
export function InlineLinkTooltipImpl(props: {
98
isSamePage: boolean;
@@ -21,89 +20,76 @@ export function InlineLinkTooltipImpl(props: {
2120
const { isSamePage, isExternal, openInNewTabLabel, target, breadcrumbs, children } = props;
2221

2322
return (
24-
<Tooltip.Provider delayDuration={200}>
25-
<Tooltip.Root>
26-
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
27-
<Tooltip.Portal>
28-
<Tooltip.Content className="z-40 w-screen max-w-md animate-present px-4 sm:w-auto">
29-
<div className="overflow-hidden rounded-md straight-corners:rounded-none shadow-lg shadow-tint-12/4 ring-1 ring-tint-subtle dark:shadow-tint-1 ">
30-
<div className="bg-tint-base p-4">
31-
<div className="flex items-start gap-4">
32-
<div className="flex flex-col">
33-
{breadcrumbs && breadcrumbs.length > 0 ? (
34-
<div className="mb-1 flex grow flex-wrap items-center gap-x-2 gap-y-0.5 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
35-
{breadcrumbs.map((crumb, index) => {
36-
const Tag = crumb.href ? StyledLink : 'div';
23+
<HoverCardRoot>
24+
<HoverCardTrigger>{children}</HoverCardTrigger>
25+
<HoverCard className="p-4">
26+
<div className="flex items-start gap-4">
27+
<div className="flex flex-col">
28+
{breadcrumbs && breadcrumbs.length > 0 ? (
29+
<div className="mb-1 flex grow flex-wrap items-center gap-x-2 gap-y-0.5 font-semibold text-tint text-xs uppercase leading-tight tracking-wide">
30+
{breadcrumbs.map((crumb, index) => {
31+
const Tag = crumb.href ? StyledLink : 'div';
3732

38-
return (
39-
<Fragment key={crumb.label}>
40-
{index !== 0 ? (
41-
<Icon
42-
icon="chevron-right"
43-
className="size-3 text-tint-subtle"
44-
/>
45-
) : null}
46-
<Tag
47-
className={tcls(
48-
'flex gap-1',
49-
crumb.href &&
50-
'links-default:text-tint no-underline hover:underline contrast-more:underline contrast-more:decoration-current'
51-
)}
52-
href={crumb.href ?? '#'}
53-
>
54-
{crumb.icon ? (
55-
<span className="mt-0.5 text-tint-subtle empty:hidden">
56-
{crumb.icon}
57-
</span>
58-
) : null}
59-
{crumb.label}
60-
</Tag>
61-
</Fragment>
62-
);
63-
})}
64-
</div>
65-
) : null}
66-
<div
67-
className={tcls(
68-
'flex gap-2 leading-snug',
69-
isExternal && 'text-sm [overflow-wrap:anywhere]'
70-
)}
71-
>
72-
{target.icon ? (
73-
<div className="mt-1 text-tint-subtle empty:hidden">
74-
{target.icon}
75-
</div>
33+
return (
34+
<Fragment key={crumb.label}>
35+
{index !== 0 ? (
36+
<Icon
37+
icon="chevron-right"
38+
className="size-3 text-tint-subtle"
39+
/>
7640
) : null}
77-
<h5 className="font-semibold">{target.text}</h5>
78-
</div>
79-
</div>
80-
{!isSamePage && target.href ? (
81-
<Button
82-
className={tcls(
83-
'-mx-2 -my-2 ml-auto',
84-
breadcrumbs?.length === 0
85-
? 'place-self-center'
86-
: null
87-
)}
88-
variant="blank"
89-
href={target.href}
90-
target="_blank"
91-
label={openInNewTabLabel}
92-
size="small"
93-
icon="arrow-up-right-from-square"
94-
iconOnly={true}
95-
/>
96-
) : null}
97-
</div>
98-
{target.subText ? (
99-
<p className="mt-1 text-sm text-tint">{target.subText}</p>
100-
) : null}
41+
<Tag
42+
className={tcls(
43+
'flex gap-1',
44+
crumb.href &&
45+
'links-default:text-tint no-underline hover:underline contrast-more:underline contrast-more:decoration-current'
46+
)}
47+
href={crumb.href ?? '#'}
48+
>
49+
{crumb.icon ? (
50+
<span className="mt-0.5 text-tint-subtle empty:hidden">
51+
{crumb.icon}
52+
</span>
53+
) : null}
54+
{crumb.label}
55+
</Tag>
56+
</Fragment>
57+
);
58+
})}
10159
</div>
60+
) : null}
61+
<div
62+
className={tcls(
63+
'flex gap-2 leading-snug',
64+
isExternal && 'text-sm [overflow-wrap:anywhere]'
65+
)}
66+
>
67+
{target.icon ? (
68+
<div className="mt-1 text-tint-subtle empty:hidden">
69+
{target.icon}
70+
</div>
71+
) : null}
72+
<h5 className="font-semibold">{target.text}</h5>
10273
</div>
103-
<Tooltip.Arrow className="fill-tint-1" />
104-
</Tooltip.Content>
105-
</Tooltip.Portal>
106-
</Tooltip.Root>
107-
</Tooltip.Provider>
74+
</div>
75+
{!isSamePage && target.href ? (
76+
<Button
77+
className={tcls(
78+
'-mx-2 -my-2 ml-auto',
79+
breadcrumbs?.length === 0 ? 'place-self-center' : null
80+
)}
81+
variant="blank"
82+
href={target.href}
83+
target="_blank"
84+
label={openInNewTabLabel}
85+
size="small"
86+
icon="arrow-up-right-from-square"
87+
iconOnly={true}
88+
/>
89+
) : null}
90+
</div>
91+
{target.subText ? <p className="mt-1 text-sm text-tint">{target.subText}</p> : null}
92+
</HoverCard>
93+
</HoverCardRoot>
10894
);
10995
}

packages/gitbook/src/components/RootLayout/ClientContexts.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type React from 'react';
44

55
import { TranslateContext } from '@/intl/client';
66
import type { TranslationLanguage } from '@/intl/translations';
7+
import { TooltipProvider } from '@radix-ui/react-tooltip';
78
import { LoadingStateProvider } from '../primitives/LoadingStateProvider';
89

910
export function ClientContexts(props: {
@@ -14,7 +15,9 @@ export function ClientContexts(props: {
1415

1516
return (
1617
<TranslateContext.Provider value={language}>
17-
<LoadingStateProvider>{children}</LoadingStateProvider>
18+
<TooltipProvider delayDuration={200}>
19+
<LoadingStateProvider>{children}</LoadingStateProvider>
20+
</TooltipProvider>
1821
</TranslateContext.Provider>
1922
);
2023
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
'use client';
2+
import { tcls } from '@/lib/tailwind';
3+
import * as Tooltip from '@radix-ui/react-tooltip';
4+
5+
export function HoverCardRoot(props: Tooltip.TooltipProps) {
6+
return <Tooltip.Root delayDuration={200} {...props} />;
7+
}
8+
9+
export function HoverCardTrigger(props: { children: React.ReactNode }) {
10+
return <Tooltip.Trigger asChild>{props.children}</Tooltip.Trigger>;
11+
}
12+
13+
export function HoverCard(props: {
14+
children: React.ReactNode;
15+
className?: string;
16+
}) {
17+
return (
18+
<Tooltip.Portal>
19+
<Tooltip.Content className="z-40 w-screen max-w-md animate-present px-4 sm:w-auto">
20+
<div
21+
className={tcls(
22+
'overflow-hidden rounded-md straight-corners:rounded-none bg-tint-base shadow-lg shadow-tint-12/4 ring-1 ring-tint-subtle dark:shadow-tint-1',
23+
props.className
24+
)}
25+
>
26+
{props.children}
27+
</div>
28+
<Tooltip.Arrow className="fill-tint-1" />
29+
</Tooltip.Content>
30+
</Tooltip.Portal>
31+
);
32+
}

0 commit comments

Comments
 (0)