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);
overflow: hidden;
position: absolute;
min-width: 350px;
max-width: 375px;
width: 375px;
`;
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 { depths } from "@shared/styles";
import { UnfurlResourceType } from "@shared/types";
import LoadingIndicator from "~/components/LoadingIndicator";
import env from "~/env";
import useEventListener from "~/hooks/useEventListener";
import useKeyDown from "~/hooks/useKeyDown";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import usePrevious from "~/hooks/usePrevious";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
import LoadingIndicator from "../LoadingIndicator";
import { CARD_MARGIN } from "./Components";
import HoverPreviewDocument from "./HoverPreviewDocument";
import HoverPreviewIssue from "./HoverPreviewIssue";
@@ -21,13 +16,17 @@ import HoverPreviewLink from "./HoverPreviewLink";
import HoverPreviewMention from "./HoverPreviewMention";
import HoverPreviewPullRequest from "./HoverPreviewPullRequest";
const DELAY_CLOSE = 600;
const DELAY_CLOSE = 500;
const POINTER_HEIGHT = 22;
const POINTER_WIDTH = 22;
type Props = {
/** The HTML element that is being hovered over, or null if none. */
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. */
onClose: () => void;
};
@@ -37,12 +36,10 @@ enum Direction {
DOWN,
}
function HoverPreviewDesktop({ element, onClose }: Props) {
const url = element?.getAttribute("href") || element?.dataset.url;
const previousUrl = usePrevious(url, true);
function HoverPreviewDesktop({ element, data, dataLoading, onClose }: Props) {
const [isVisible, setVisible] = React.useState(false);
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 } =
useHoverPosition({
cardRef,
@@ -68,12 +65,12 @@ function HoverPreviewDesktop({ element, onClose }: Props) {
// Open and close the preview when the element changes.
React.useEffect(() => {
if (element) {
if (element && data && !dataLoading) {
setVisible(true);
} else {
startCloseTimer();
}
}, [startCloseTimer, element]);
}, [startCloseTimer, element, data, dataLoading]);
// Close the preview on Escape, scroll, or click outside.
useOnClickOutside(cardRef, closePreview);
@@ -101,108 +98,7 @@ function HoverPreviewDesktop({ element, onClose }: Props) {
};
}, [element, startCloseTimer, isVisible, stopCloseTimer]);
const displayUrl = url ?? previousUrl;
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) {
if (dataLoading) {
return <LoadingIndicator />;
}
@@ -210,16 +106,93 @@ function DataLoader({
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();
if (isMobile) {
return null;
}
return <HoverPreviewDesktop {...rest} element={element} />;
return (
<HoverPreviewDesktop
{...rest}
element={element}
data={data}
dataLoading={dataLoading}
/>
);
}
function useHoverPosition({

View File

@@ -4,6 +4,8 @@ import { EditorView } from "prosemirror-view";
import * as React from "react";
import Extension from "@shared/editor/lib/Extension";
import HoverPreview from "~/components/HoverPreview";
import env from "~/env";
import { client } from "~/utils/ApiClient";
interface HoverPreviewsOptions {
/** Delay before the target is considered "hovered" and callback is triggered. */
@@ -13,13 +15,17 @@ interface HoverPreviewsOptions {
export default class HoverPreviews extends Extension {
state: {
activeLinkElement: HTMLElement | null;
data: Record<string, any> | null;
dataLoading: boolean;
} = observable({
activeLinkElement: null,
data: null,
dataLoading: false,
});
get defaultOptions(): HoverPreviewsOptions {
return {
delay: 500,
delay: 600,
};
}
@@ -45,8 +51,30 @@ export default class HoverPreviews extends Extension {
);
if (isHoverTarget(target, view)) {
hoveringTimeout = setTimeout(
action(() => {
this.state.activeLinkElement = target as HTMLElement;
action(async () => {
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
);
@@ -72,6 +100,8 @@ export default class HoverPreviews extends Extension {
widget = () => (
<HoverPreview
element={this.state.activeLinkElement}
data={this.state.data}
dataLoading={this.state.dataLoading}
onClose={action(() => {
this.state.activeLinkElement = null;
})}

View File

@@ -1,5 +1,7 @@
import isNil from "lodash/isNil";
import isUUID from "validator/lib/isUUID";
import { z } from "zod";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { isUrl } from "@shared/utils/urls";
import { ValidateURL } from "@server/validation";
import { BaseSchema } from "../schema";
@@ -24,7 +26,16 @@ export const UrlsUnfurlSchema = BaseSchema.extend({
},
{ 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(
(val) =>