Preview mentions (#5571)

Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
Apoorv Mishra
2023-07-22 21:43:09 +05:30
committed by GitHub
parent dbd85d62cb
commit 5d71398ea6
27 changed files with 923 additions and 361 deletions

View File

@@ -349,7 +349,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
)}
{activeLinkElement && !shareId && (
<HoverPreview
id={props.id}
element={activeLinkElement}
onClose={handleLinkInactive}
/>

View File

@@ -1,241 +0,0 @@
import { transparentize } from "polished";
import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isExternalUrl } from "@shared/utils/urls";
import HoverPreviewDocument from "~/components/HoverPreviewDocument";
import useMobile from "~/hooks/useMobile";
import { fadeAndSlideDown } from "~/styles/animations";
const DELAY_OPEN = 300;
const DELAY_CLOSE = 300;
type Props = {
/* The document associated with the editor, if any */
id?: string;
/* The HTML element that is being hovered over */
element: HTMLAnchorElement;
/* A callback on close of the hover preview */
onClose: () => void;
};
function HoverPreviewInternal({ element, id, onClose }: Props) {
const slug = parseDocumentSlug(element.href);
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
const timerOpen = React.useRef<ReturnType<typeof setTimeout>>();
const cardRef = React.useRef<HTMLDivElement>(null);
const startCloseTimer = () => {
stopOpenTimer();
timerClose.current = setTimeout(() => {
if (isVisible) {
setVisible(false);
}
onClose();
}, DELAY_CLOSE);
};
const stopCloseTimer = () => {
if (timerClose.current) {
clearTimeout(timerClose.current);
}
};
const startOpenTimer = () => {
timerOpen.current = setTimeout(() => setVisible(true), DELAY_OPEN);
};
const stopOpenTimer = () => {
if (timerOpen.current) {
clearTimeout(timerOpen.current);
}
};
React.useEffect(() => {
startOpenTimer();
if (cardRef.current) {
cardRef.current.addEventListener("mouseenter", stopCloseTimer);
}
if (cardRef.current) {
cardRef.current.addEventListener("mouseleave", startCloseTimer);
}
element.addEventListener("mouseout", startCloseTimer);
element.addEventListener("mouseover", stopCloseTimer);
element.addEventListener("mouseover", startOpenTimer);
return () => {
element.removeEventListener("mouseout", startCloseTimer);
element.removeEventListener("mouseover", stopCloseTimer);
element.removeEventListener("mouseover", startOpenTimer);
if (cardRef.current) {
cardRef.current.removeEventListener("mouseenter", stopCloseTimer);
}
if (cardRef.current) {
cardRef.current.removeEventListener("mouseleave", startCloseTimer);
}
if (timerClose.current) {
clearTimeout(timerClose.current);
}
};
}, [element, slug]);
const anchorBounds = element.getBoundingClientRect();
const cardBounds = cardRef.current?.getBoundingClientRect();
const left = cardBounds
? Math.min(anchorBounds.left, window.innerWidth - 16 - 350)
: anchorBounds.left;
const leftOffset = anchorBounds.left - left;
return (
<Portal>
<Position
top={anchorBounds.bottom + window.scrollY}
left={left}
aria-hidden
>
<div ref={cardRef}>
<HoverPreviewDocument url={element.href} id={id}>
{(content: React.ReactNode) =>
isVisible ? (
<Animate>
<Card>
<Margin />
<CardContent>{content}</CardContent>
</Card>
<Pointer offset={leftOffset + anchorBounds.width / 2} />
</Animate>
) : null
}
</HoverPreviewDocument>
</div>
</Position>
</Portal>
);
}
function HoverPreview({ element, ...rest }: Props) {
const isMobile = useMobile();
if (isMobile) {
return null;
}
// previews only work for internal doc links for now
if (isExternalUrl(element.href)) {
return null;
}
return <HoverPreviewInternal {...rest} element={element} />;
}
const Animate = styled.div`
animation: ${fadeAndSlideDown} 150ms ease;
@media print {
display: none;
}
`;
// fills the gap between the card and pointer to avoid a dead zone
const Margin = styled.div`
position: absolute;
top: -11px;
left: 0;
right: 0;
height: 11px;
`;
const CardContent = styled.div`
overflow: hidden;
max-height: 20em;
user-select: none;
`;
// &:after — gradient mask for overflow text
const Card = styled.div`
backdrop-filter: blur(10px);
background: ${s("background")};
border-radius: 4px;
box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3),
0 0 1px 1px rgba(0, 0, 0, 0.05);
padding: 16px;
width: 350px;
font-size: 0.9em;
position: relative;
.placeholder,
.heading-anchor {
display: none;
}
&:after {
content: "";
display: block;
position: absolute;
pointer-events: none;
background: linear-gradient(
90deg,
${(props) => transparentize(1, props.theme.background)} 0%,
${(props) => transparentize(1, props.theme.background)} 75%,
${s("background")} 90%
);
bottom: 0;
left: 0;
right: 0;
height: 1.7em;
border-bottom: 16px solid ${s("background")};
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
`;
const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
margin-top: 10px;
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
z-index: ${depths.hoverPreview};
display: flex;
max-height: 75%;
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
`;
const Pointer = styled.div<{ offset: number }>`
top: -22px;
left: ${(props) => props.offset}px;
width: 22px;
height: 22px;
position: absolute;
transform: translateX(-50%);
pointer-events: none;
&:before,
&:after {
content: "";
display: inline-block;
position: absolute;
bottom: 0;
right: 0;
}
&:before {
border: 8px solid transparent;
border-bottom-color: ${(props) =>
props.theme.menuBorder || "rgba(0, 0, 0, 0.1)"};
right: -1px;
}
&:after {
border: 7px solid transparent;
border-bottom-color: ${s("background")};
}
`;
export default HoverPreview;

View File

@@ -0,0 +1,25 @@
import { Link } from "react-router-dom";
import styled from "styled-components";
import { s } from "@shared/styles";
import Text from "~/components/Text";
export const Preview = styled(Link)`
cursor: var(--pointer);
margin-bottom: 0;
${(props) => (!props.to ? "pointer-events: none;" : "")}
`;
export const Title = styled.h2`
font-size: 1.25em;
margin: 2px 0 0 0;
color: ${s("text")};
`;
export const Description = styled(Text)`
margin-bottom: 0;
padding-top: 2px;
`;
export const Summary = styled.div`
margin-top: 8px;
`;

View File

@@ -0,0 +1,282 @@
import { transparentize } from "polished";
import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import { UnfurlType } from "@shared/types";
import LoadingIndicator from "~/components/LoadingIndicator";
import useMobile from "~/hooks/useMobile";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { fadeAndSlideDown } from "~/styles/animations";
import { client } from "~/utils/ApiClient";
import HoverPreviewDocument from "./HoverPreviewDocument";
import HoverPreviewMention from "./HoverPreviewMention";
const DELAY_OPEN = 300;
const DELAY_CLOSE = 300;
const CARD_PADDING = 16;
const CARD_MAX_WIDTH = 375;
type Props = {
/* The HTML element that is being hovered over */
element: HTMLAnchorElement;
/* A callback on close of the hover preview */
onClose: () => void;
};
function HoverPreviewInternal({ element, onClose }: Props) {
const url = element.href || element.dataset.url;
const [isVisible, setVisible] = React.useState(false);
const timerClose = React.useRef<ReturnType<typeof setTimeout>>();
const timerOpen = React.useRef<ReturnType<typeof setTimeout>>();
const cardRef = React.useRef<HTMLDivElement>(null);
const stores = useStores();
const { data, request, loading } = useRequest(
React.useCallback(
() =>
client.post("/urls.unfurl", {
url,
documentId: stores.ui.activeDocumentId,
}),
[url, stores.ui.activeDocumentId]
)
);
React.useEffect(() => {
if (url) {
void request();
}
}, [url, request]);
const startCloseTimer = React.useCallback(() => {
stopOpenTimer();
timerClose.current = setTimeout(() => {
if (isVisible) {
setVisible(false);
}
onClose();
}, DELAY_CLOSE);
}, [isVisible, onClose]);
const stopCloseTimer = () => {
if (timerClose.current) {
clearTimeout(timerClose.current);
}
};
const startOpenTimer = () => {
timerOpen.current = setTimeout(() => setVisible(true), DELAY_OPEN);
};
const stopOpenTimer = () => {
if (timerOpen.current) {
clearTimeout(timerOpen.current);
}
};
React.useEffect(() => {
const card = cardRef.current;
if (data && !loading) {
startOpenTimer();
if (card) {
card.addEventListener("mouseenter", stopCloseTimer);
card.addEventListener("mouseleave", startCloseTimer);
}
element.addEventListener("mouseout", startCloseTimer);
element.addEventListener("mouseover", stopCloseTimer);
element.addEventListener("mouseover", startOpenTimer);
}
return () => {
element.removeEventListener("mouseout", startCloseTimer);
element.removeEventListener("mouseover", stopCloseTimer);
element.removeEventListener("mouseover", startOpenTimer);
if (card) {
card.removeEventListener("mouseenter", stopCloseTimer);
card.removeEventListener("mouseleave", startCloseTimer);
}
stopCloseTimer();
};
}, [element, startCloseTimer, data, loading]);
const elemBounds = element.getBoundingClientRect();
const cardBounds = cardRef.current?.getBoundingClientRect();
const left = cardBounds
? Math.min(
elemBounds.left,
window.innerWidth - CARD_PADDING - CARD_MAX_WIDTH
)
: elemBounds.left;
const leftOffset = elemBounds.left - left;
if (loading) {
return <LoadingIndicator />;
}
if (!data) {
return null;
}
return (
<Portal>
<Position
top={elemBounds.bottom + window.scrollY}
left={left}
aria-hidden
>
<div ref={cardRef}>
{isVisible ? (
<Animate>
<Card fadeOut={data.type !== UnfurlType.Mention}>
<Margin />
<CardContent>
{data.type === UnfurlType.Mention ? (
<HoverPreviewMention
url={data.thumbnailUrl}
title={data.title}
description={data.description}
color={data.meta.color}
/>
) : data.type === UnfurlType.Document ? (
<HoverPreviewDocument
id={data.meta.id}
url={data.url}
title={data.title}
description={data.description}
summary={data.meta.summary}
/>
) : null}
</CardContent>
</Card>
<Pointer offset={leftOffset + elemBounds.width / 2} />
</Animate>
) : null}
</div>
</Position>
</Portal>
);
}
function HoverPreview({ element, ...rest }: Props) {
const isMobile = useMobile();
if (isMobile) {
return null;
}
return <HoverPreviewInternal {...rest} element={element} />;
}
const Animate = styled.div`
animation: ${fadeAndSlideDown} 150ms ease;
@media print {
display: none;
}
`;
// fills the gap between the card and pointer to avoid a dead zone
const Margin = styled.div`
position: absolute;
top: -11px;
left: 0;
right: 0;
height: 11px;
`;
const CardContent = styled.div`
overflow: hidden;
max-height: 20em;
user-select: none;
`;
// &:after — gradient mask for overflow text
const Card = styled.div<{ fadeOut?: boolean }>`
backdrop-filter: blur(10px);
background: ${(props) => props.theme.menuBackground};
border-radius: 4px;
box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3),
0 0 1px 1px rgba(0, 0, 0, 0.05);
padding: ${CARD_PADDING}px;
min-width: 350px;
max-width: ${CARD_MAX_WIDTH}px;
font-size: 0.9em;
position: relative;
.placeholder,
.heading-anchor {
display: none;
}
${(props) =>
props.fadeOut !== false
? `&:after {
content: "";
display: block;
position: absolute;
pointer-events: none;
background: linear-gradient(
90deg,
${transparentize(1, props.theme.menuBackground)} 0%,
${transparentize(1, props.theme.menuBackground)} 75%,
${props.theme.menuBackground} 90%
);
bottom: 0;
left: 0;
right: 0;
height: 1.7em;
border-bottom: 16px solid ${props.theme.menuBackground};
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}`
: ""}
`;
const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
margin-top: 10px;
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
z-index: ${depths.hoverPreview};
display: flex;
max-height: 75%;
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
`;
const Pointer = styled.div<{ offset: number }>`
top: -22px;
left: ${(props) => props.offset}px;
width: 22px;
height: 22px;
position: absolute;
transform: translateX(-50%);
pointer-events: none;
&:before,
&:after {
content: "";
display: inline-block;
position: absolute;
bottom: 0;
right: 0;
}
&:before {
border: 8px solid transparent;
border-bottom-color: ${(props) =>
props.theme.menuBorder || "rgba(0, 0, 0, 0.1)"};
right: -1px;
}
&:after {
border: 7px solid transparent;
border-bottom-color: ${s("background")};
}
`;
export default HoverPreview;

View File

@@ -0,0 +1,37 @@
import * as React from "react";
import Editor from "~/components/Editor";
import Flex from "~/components/Flex";
import { Preview, Title, Description, Summary } from "./Components";
type Props = {
/** Document id associated with the editor, if any */
id?: string;
/** Document url */
url: string;
/** Title for the preview card */
title: string;
/** Description about recent activity on document */
description: string;
/** Summary of document content */
summary: string;
};
function HoverPreviewDocument({ id, url, title, description, summary }: Props) {
return (
<Preview to={url}>
<Flex column>
<Title>{title}</Title>
<Description type="tertiary" size="xsmall">
{description}
</Description>
<Summary>
<React.Suspense fallback={<div />}>
<Editor key={id} defaultValue={summary} embedsDisabled readOnly />
</React.Suspense>
</Summary>
</Flex>
</Preview>
);
}
export default HoverPreviewDocument;

View File

@@ -0,0 +1,41 @@
import * as React from "react";
import Avatar from "~/components/Avatar";
import { AvatarSize } from "~/components/Avatar/Avatar";
import Flex from "~/components/Flex";
import { Preview, Title, Description } from "./Components";
type Props = {
/** Resource url, avatar url in case of user mention */
url: string;
/** Title for the preview card*/
title: string;
/** Description about mentioned user's recent activity */
description: string;
/** Used for avatar's background color in absence of avatar url */
color: string;
};
function HoverPreviewMention({ url, title, description, color }: Props) {
return (
<Preview to="">
<Flex gap={12}>
<Avatar
model={{
avatarUrl: url,
initial: title ? title[0] : "?",
color,
}}
size={AvatarSize.XLarge}
/>
<Flex column>
<Title>{title}</Title>
<Description type="tertiary" size="xsmall">
{description}
</Description>
</Flex>
</Flex>
</Preview>
);
}
export default HoverPreviewMention;

View File

@@ -0,0 +1,3 @@
import HoverPreview from "./HoverPreview";
export default HoverPreview;

View File

@@ -1,64 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { s } from "@shared/styles";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import DocumentMeta from "~/components/DocumentMeta";
import Editor from "~/components/Editor";
import useStores from "~/hooks/useStores";
type Props = {
/* The document associated with the editor, if any */
id?: string;
/* The URL we want a preview for */
url: string;
children: (content: React.ReactNode) => React.ReactNode;
};
function HoverPreviewDocument({ url, id, children }: Props) {
const { documents } = useStores();
const slug = parseDocumentSlug(url);
React.useEffect(() => {
if (slug) {
void documents.prefetchDocument(slug);
}
}, [documents, slug]);
const document = slug ? documents.getByUrl(slug) : undefined;
if (!document || document.id === id) {
return null;
}
return (
<>
{children(
<Content to={document.url}>
<Heading>{document.titleWithDefault}</Heading>
<DocumentMeta document={document} />
<React.Suspense fallback={<div />}>
<Editor
key={document.id}
defaultValue={document.getSummary()}
embedsDisabled
readOnly
/>
</React.Suspense>
</Content>
)}
</>
);
}
const Content = styled(Link)`
cursor: var(--pointer);
`;
const Heading = styled.h2`
margin: 0 0 0.75em;
color: ${s("text")};
`;
export default observer(HoverPreviewDocument);

View File

@@ -1,8 +1,8 @@
import { format as formatDate, formatDistanceToNow } from "date-fns";
import * as React from "react";
import { dateLocale, locales } from "@shared/utils/date";
import Tooltip from "~/components/Tooltip";
import useUserLocale from "~/hooks/useUserLocale";
import { dateLocale, locales } from "~/utils/i18n";
let callbacks: (() => void)[] = [];