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