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:
@@ -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" })`
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
})}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user