Fixes hover preview going out of window bounds (#6776)

* fix: hover preview out of bounds

* fix: pop

* fix: check for both element and data

* fix: show loading indicator

* fix: width
This commit is contained in:
Apoorv Mishra
2024-04-13 18:31:40 +05:30
committed by GitHub
parent 054bddb666
commit 90ed6a5366
4 changed files with 137 additions and 124 deletions

View File

@@ -25,8 +25,7 @@ export const Preview = styled(Link)`
0 0 1px 1px rgba(0, 0, 0, 0.05); 0 0 1px 1px rgba(0, 0, 0, 0.05);
overflow: hidden; overflow: hidden;
position: absolute; position: absolute;
min-width: 350px; width: 375px;
max-width: 375px;
`; `;
export const Title = styled(Text).attrs({ as: "h2", size: "large" })` export const Title = styled(Text).attrs({ as: "h2", size: "large" })`

View File

@@ -4,16 +4,11 @@ import { Portal } from "react-portal";
import styled from "styled-components"; import styled from "styled-components";
import { depths } from "@shared/styles"; import { depths } from "@shared/styles";
import { UnfurlResourceType } from "@shared/types"; import { UnfurlResourceType } from "@shared/types";
import LoadingIndicator from "~/components/LoadingIndicator";
import env from "~/env";
import useEventListener from "~/hooks/useEventListener"; import useEventListener from "~/hooks/useEventListener";
import useKeyDown from "~/hooks/useKeyDown"; import useKeyDown from "~/hooks/useKeyDown";
import useMobile from "~/hooks/useMobile"; import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside"; import useOnClickOutside from "~/hooks/useOnClickOutside";
import usePrevious from "~/hooks/usePrevious"; import LoadingIndicator from "../LoadingIndicator";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
import { CARD_MARGIN } from "./Components"; import { CARD_MARGIN } from "./Components";
import HoverPreviewDocument from "./HoverPreviewDocument"; import HoverPreviewDocument from "./HoverPreviewDocument";
import HoverPreviewIssue from "./HoverPreviewIssue"; import HoverPreviewIssue from "./HoverPreviewIssue";
@@ -21,13 +16,17 @@ import HoverPreviewLink from "./HoverPreviewLink";
import HoverPreviewMention from "./HoverPreviewMention"; import HoverPreviewMention from "./HoverPreviewMention";
import HoverPreviewPullRequest from "./HoverPreviewPullRequest"; import HoverPreviewPullRequest from "./HoverPreviewPullRequest";
const DELAY_CLOSE = 600; const DELAY_CLOSE = 500;
const POINTER_HEIGHT = 22; const POINTER_HEIGHT = 22;
const POINTER_WIDTH = 22; const POINTER_WIDTH = 22;
type Props = { type Props = {
/** The HTML element that is being hovered over, or null if none. */ /** The HTML element that is being hovered over, or null if none. */
element: HTMLElement | null; element: HTMLElement | null;
/** Data to be previewed */
data: Record<string, any> | null;
/** Whether the preview data is being loaded */
dataLoading: boolean;
/** A callback on close of the hover preview. */ /** A callback on close of the hover preview. */
onClose: () => void; onClose: () => void;
}; };
@@ -37,12 +36,10 @@ enum Direction {
DOWN, DOWN,
} }
function HoverPreviewDesktop({ element, onClose }: Props) { function HoverPreviewDesktop({ element, data, dataLoading, onClose }: Props) {
const url = element?.getAttribute("href") || element?.dataset.url;
const previousUrl = usePrevious(url, true);
const [isVisible, setVisible] = React.useState(false); const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef<ReturnType<typeof setTimeout>>(); const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
const cardRef = React.useRef<HTMLDivElement>(null); const cardRef = React.useRef<HTMLDivElement | null>(null);
const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } = const { cardLeft, cardTop, pointerLeft, pointerTop, pointerDir } =
useHoverPosition({ useHoverPosition({
cardRef, cardRef,
@@ -68,12 +65,12 @@ function HoverPreviewDesktop({ element, onClose }: Props) {
// Open and close the preview when the element changes. // Open and close the preview when the element changes.
React.useEffect(() => { React.useEffect(() => {
if (element) { if (element && data && !dataLoading) {
setVisible(true); setVisible(true);
} else { } else {
startCloseTimer(); startCloseTimer();
} }
}, [startCloseTimer, element]); }, [startCloseTimer, element, data, dataLoading]);
// Close the preview on Escape, scroll, or click outside. // Close the preview on Escape, scroll, or click outside.
useOnClickOutside(cardRef, closePreview); useOnClickOutside(cardRef, closePreview);
@@ -101,108 +98,7 @@ function HoverPreviewDesktop({ element, onClose }: Props) {
}; };
}, [element, startCloseTimer, isVisible, stopCloseTimer]); }, [element, startCloseTimer, isVisible, stopCloseTimer]);
const displayUrl = url ?? previousUrl; if (dataLoading) {
if (!isVisible || !displayUrl) {
return null;
}
return (
<Portal>
<Position top={cardTop} left={cardLeft} ref={cardRef} aria-hidden>
<DataLoader url={displayUrl}>
{(data) => (
<Animate
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
animate={{
opacity: 1,
y: 0,
transitionEnd: { pointerEvents: "auto" },
}}
>
{data.type === UnfurlResourceType.Mention ? (
<HoverPreviewMention
name={data.name}
avatarUrl={data.avatarUrl}
color={data.color}
lastActive={data.lastActive}
/>
) : data.type === UnfurlResourceType.Document ? (
<HoverPreviewDocument
url={data.url}
id={data.id}
title={data.title}
summary={data.summary}
lastActivityByViewer={data.lastActivityByViewer}
/>
) : data.type === UnfurlResourceType.Issue ? (
<HoverPreviewIssue
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
labels={data.labels}
state={data.state}
createdAt={data.createdAt}
/>
) : data.type === UnfurlResourceType.PR ? (
<HoverPreviewPullRequest
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
createdAt={data.createdAt}
state={data.state}
/>
) : (
<HoverPreviewLink
url={data.url}
thumbnailUrl={data.thumbnailUrl}
title={data.title}
description={data.description}
/>
)}
<Pointer
top={pointerTop}
left={pointerLeft}
direction={pointerDir}
/>
</Animate>
)}
</DataLoader>
</Position>
</Portal>
);
}
function DataLoader({
url,
children,
}: {
url: string;
children: (data: any) => React.ReactNode;
}) {
const { ui } = useStores();
const { data, request, loading } = useRequest(
React.useCallback(
() =>
client.post("/urls.unfurl", {
url: url.startsWith("/") ? env.URL + url : url,
documentId: ui.activeDocumentId,
}),
[url, ui.activeDocumentId]
)
);
React.useEffect(() => {
if (url) {
void request();
}
}, [url, request]);
if (loading) {
return <LoadingIndicator />; return <LoadingIndicator />;
} }
@@ -210,16 +106,93 @@ function DataLoader({
return null; return null;
} }
return <>{children(data)}</>; return (
<Portal>
<Position top={cardTop} left={cardLeft} aria-hidden>
{isVisible ? (
<Animate
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
animate={{
opacity: 1,
y: 0,
transitionEnd: { pointerEvents: "auto" },
}}
>
{data.type === UnfurlResourceType.Mention ? (
<HoverPreviewMention
ref={cardRef}
name={data.name}
avatarUrl={data.avatarUrl}
color={data.color}
lastActive={data.lastActive}
/>
) : data.type === UnfurlResourceType.Document ? (
<HoverPreviewDocument
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
summary={data.summary}
lastActivityByViewer={data.lastActivityByViewer}
/>
) : data.type === UnfurlResourceType.Issue ? (
<HoverPreviewIssue
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
labels={data.labels}
state={data.state}
createdAt={data.createdAt}
/>
) : data.type === UnfurlResourceType.PR ? (
<HoverPreviewPullRequest
ref={cardRef}
url={data.url}
id={data.id}
title={data.title}
description={data.description}
author={data.author}
createdAt={data.createdAt}
state={data.state}
/>
) : (
<HoverPreviewLink
ref={cardRef}
url={data.url}
thumbnailUrl={data.thumbnailUrl}
title={data.title}
description={data.description}
/>
)}
<Pointer
top={pointerTop}
left={pointerLeft}
direction={pointerDir}
/>
</Animate>
) : null}
</Position>
</Portal>
);
} }
function HoverPreview({ element, ...rest }: Props) { function HoverPreview({ element, data, dataLoading, ...rest }: Props) {
const isMobile = useMobile(); const isMobile = useMobile();
if (isMobile) { if (isMobile) {
return null; return null;
} }
return <HoverPreviewDesktop {...rest} element={element} />; return (
<HoverPreviewDesktop
{...rest}
element={element}
data={data}
dataLoading={dataLoading}
/>
);
} }
function useHoverPosition({ function useHoverPosition({

View File

@@ -4,6 +4,8 @@ import { EditorView } from "prosemirror-view";
import * as React from "react"; import * as React from "react";
import Extension from "@shared/editor/lib/Extension"; import Extension from "@shared/editor/lib/Extension";
import HoverPreview from "~/components/HoverPreview"; import HoverPreview from "~/components/HoverPreview";
import env from "~/env";
import { client } from "~/utils/ApiClient";
interface HoverPreviewsOptions { interface HoverPreviewsOptions {
/** Delay before the target is considered "hovered" and callback is triggered. */ /** Delay before the target is considered "hovered" and callback is triggered. */
@@ -13,13 +15,17 @@ interface HoverPreviewsOptions {
export default class HoverPreviews extends Extension { export default class HoverPreviews extends Extension {
state: { state: {
activeLinkElement: HTMLElement | null; activeLinkElement: HTMLElement | null;
data: Record<string, any> | null;
dataLoading: boolean;
} = observable({ } = observable({
activeLinkElement: null, activeLinkElement: null,
data: null,
dataLoading: false,
}); });
get defaultOptions(): HoverPreviewsOptions { get defaultOptions(): HoverPreviewsOptions {
return { return {
delay: 500, delay: 600,
}; };
} }
@@ -45,8 +51,30 @@ export default class HoverPreviews extends Extension {
); );
if (isHoverTarget(target, view)) { if (isHoverTarget(target, view)) {
hoveringTimeout = setTimeout( hoveringTimeout = setTimeout(
action(() => { action(async () => {
this.state.activeLinkElement = target as HTMLElement; const element = target as HTMLElement;
const url =
element?.getAttribute("href") || element?.dataset.url;
const documentId = window.location.pathname
.split("/")
.pop();
if (url) {
this.state.dataLoading = true;
try {
const data = await client.post("/urls.unfurl", {
url: url.startsWith("/") ? env.URL + url : url,
documentId,
});
this.state.activeLinkElement = element;
this.state.data = data;
} catch (err) {
this.state.activeLinkElement = null;
} finally {
this.state.dataLoading = false;
}
}
}), }),
this.options.delay this.options.delay
); );
@@ -72,6 +100,8 @@ export default class HoverPreviews extends Extension {
widget = () => ( widget = () => (
<HoverPreview <HoverPreview
element={this.state.activeLinkElement} element={this.state.activeLinkElement}
data={this.state.data}
dataLoading={this.state.dataLoading}
onClose={action(() => { onClose={action(() => {
this.state.activeLinkElement = null; this.state.activeLinkElement = null;
})} })}

View File

@@ -1,5 +1,7 @@
import isNil from "lodash/isNil"; import isNil from "lodash/isNil";
import isUUID from "validator/lib/isUUID";
import { z } from "zod"; import { z } from "zod";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { isUrl } from "@shared/utils/urls"; import { isUrl } from "@shared/utils/urls";
import { ValidateURL } from "@server/validation"; import { ValidateURL } from "@server/validation";
import { BaseSchema } from "../schema"; import { BaseSchema } from "../schema";
@@ -24,7 +26,16 @@ export const UrlsUnfurlSchema = BaseSchema.extend({
}, },
{ message: ValidateURL.message } { message: ValidateURL.message }
), ),
documentId: z.string().uuid().optional(), documentId: z
.string()
.optional()
.refine(
(val) =>
val ? isUUID(val) || UrlHelper.SLUG_URL_REGEX.test(val) : true,
{
message: "must be uuid or url slug",
}
),
}) })
.refine( .refine(
(val) => (val) =>