feat: Document hover cards (#1346)
* stash * refactor * refactor, styling * tweaks * pointer * styling * fi: Hide when printing * fix: No hover cards on shared links * remove suppressions no longer needed * fix: Don't show hover cards when editing, they get in the way * fix: Prevent hover card from going off rhs edge of screen * fix: Remount hover card when changing between links * fix: allow one part domains in links (#1350) * allow one part domains in links * no TLD when only one part domain * return null for parseDomain of empty string * fix fiddly hover preview behavior * WIP * refactor hover preview * fix: Non-rounded bottom corners * fix: Fixes an edgecase where mounting the nested editor in hovercard causesdocument to scroll if there is a hash in the url * fix: Incorrect document preview rendering * lint Co-authored-by: Nan Yu <thenanyu@gmail.com> Co-authored-by: Nan Yu <nan@getoutline.com>
This commit is contained in:
39
app/components/DocumentMeta.js
Normal file
39
app/components/DocumentMeta.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { inject } from "mobx-react";
|
||||
import ViewsStore from "stores/ViewsStore";
|
||||
import Document from "models/Document";
|
||||
import PublishingInfo from "components/PublishingInfo";
|
||||
|
||||
type Props = {|
|
||||
views: ViewsStore,
|
||||
document: Document,
|
||||
isDraft: boolean,
|
||||
|};
|
||||
|
||||
function DocumentMeta({ views, isDraft, document }: Props) {
|
||||
const totalViews = views.countForDocument(document.id);
|
||||
|
||||
return (
|
||||
<Meta document={document}>
|
||||
{totalViews && !isDraft ? (
|
||||
<React.Fragment>
|
||||
· Viewed{" "}
|
||||
{totalViews === 1 ? "once" : `${totalViews} times`}
|
||||
</React.Fragment>
|
||||
) : null}
|
||||
</Meta>
|
||||
);
|
||||
}
|
||||
|
||||
const Meta = styled(PublishingInfo)`
|
||||
margin: -12px 0 2em 0;
|
||||
font-size: 14px;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default inject("views")(DocumentMeta);
|
||||
@@ -10,7 +10,7 @@ import { uploadFile } from "utils/uploadFile";
|
||||
import isInternalUrl from "utils/isInternalUrl";
|
||||
import Tooltip from "components/Tooltip";
|
||||
import UiStore from "stores/UiStore";
|
||||
import embeds from "../../embeds";
|
||||
import embeds from "../embeds";
|
||||
|
||||
const EMPTY_ARRAY = [];
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// @flow
|
||||
import Editor from "./Editor";
|
||||
export default Editor;
|
||||
235
app/components/HoverPreview.js
Normal file
235
app/components/HoverPreview.js
Normal file
@@ -0,0 +1,235 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { inject } from "mobx-react";
|
||||
import { transparentize } from "polished";
|
||||
import HoverPreviewDocument from "components/HoverPreviewDocument";
|
||||
import styled from "styled-components";
|
||||
import { Portal } from "react-portal";
|
||||
import { fadeAndSlideIn } from "shared/styles/animations";
|
||||
import isInternalUrl from "utils/isInternalUrl";
|
||||
import { parseDocumentSlugFromUrl } from "shared/utils/parseDocumentIds";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
|
||||
const DELAY_OPEN = 300;
|
||||
const DELAY_CLOSE = 300;
|
||||
|
||||
type Props = {
|
||||
node: HTMLAnchorElement,
|
||||
event: MouseEvent,
|
||||
documents: DocumentsStore,
|
||||
onClose: () => void,
|
||||
};
|
||||
|
||||
function HoverPreview({ node, documents, onClose, event }: Props) {
|
||||
// previews only work for internal doc links for now
|
||||
if (!isInternalUrl(node.href)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const slug = parseDocumentSlugFromUrl(node.href);
|
||||
|
||||
const [isVisible, setVisible] = React.useState(false);
|
||||
const timerClose = React.useRef(null);
|
||||
const timerOpen = React.useRef(null);
|
||||
const cardRef = React.useRef(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(
|
||||
() => {
|
||||
if (slug) {
|
||||
documents.prefetchDocument(slug, {
|
||||
prefetch: true,
|
||||
});
|
||||
}
|
||||
|
||||
startOpenTimer();
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.addEventListener("mouseenter", stopCloseTimer);
|
||||
cardRef.current.addEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
node.addEventListener("mouseout", startCloseTimer);
|
||||
node.addEventListener("mouseover", stopCloseTimer);
|
||||
node.addEventListener("mouseover", startOpenTimer);
|
||||
|
||||
return () => {
|
||||
node.removeEventListener("mouseout", startCloseTimer);
|
||||
node.removeEventListener("mouseover", stopCloseTimer);
|
||||
node.removeEventListener("mouseover", startOpenTimer);
|
||||
|
||||
if (cardRef.current) {
|
||||
cardRef.current.removeEventListener("mouseenter", stopCloseTimer);
|
||||
cardRef.current.removeEventListener("mouseleave", startCloseTimer);
|
||||
}
|
||||
|
||||
if (timerClose.current) {
|
||||
clearTimeout(timerClose.current);
|
||||
}
|
||||
};
|
||||
},
|
||||
[node]
|
||||
);
|
||||
|
||||
const anchorBounds = node.getBoundingClientRect();
|
||||
const cardBounds = cardRef.current
|
||||
? cardRef.current.getBoundingClientRect()
|
||||
: undefined;
|
||||
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={node.href}>
|
||||
{content =>
|
||||
isVisible ? (
|
||||
<Animate>
|
||||
<Card>
|
||||
<Margin />
|
||||
<CardContent>{content}</CardContent>
|
||||
</Card>
|
||||
<Pointer offset={leftOffset + anchorBounds.width / 2} />
|
||||
</Animate>
|
||||
) : null
|
||||
}
|
||||
</HoverPreviewDocument>
|
||||
</div>
|
||||
</Position>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
const Animate = styled.div`
|
||||
animation: ${fadeAndSlideIn} 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: 350px;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
// &:after — gradient mask for overflow text
|
||||
const Card = styled.div`
|
||||
backdrop-filter: blur(10px);
|
||||
background: ${props => props.theme.background};
|
||||
border: ${props =>
|
||||
props.theme.menuBorder ? `1px solid ${props.theme.menuBorder}` : "none"};
|
||||
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(
|
||||
180deg,
|
||||
${props => transparentize(1, props.theme.background)} 0%,
|
||||
${props => props.theme.background} 90%
|
||||
);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4em;
|
||||
border-bottom: 16px solid ${props => props.theme.background};
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Position = styled.div`
|
||||
margin-top: 10px;
|
||||
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
|
||||
display: flex;
|
||||
max-height: 75%;
|
||||
|
||||
${({ top }) => (top !== undefined ? `top: ${top}px` : "")};
|
||||
${({ left }) => (left !== undefined ? `left: ${left}px` : "")};
|
||||
`;
|
||||
|
||||
const Pointer = styled.div`
|
||||
top: -22px;
|
||||
left: ${props => props.offset}px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
|
||||
&: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: ${props => props.theme.background};
|
||||
}
|
||||
`;
|
||||
|
||||
export default inject("documents")(HoverPreview);
|
||||
50
app/components/HoverPreviewDocument.js
Normal file
50
app/components/HoverPreviewDocument.js
Normal file
@@ -0,0 +1,50 @@
|
||||
// @flow
|
||||
import * as React from "react";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import Editor from "components/Editor";
|
||||
import styled from "styled-components";
|
||||
import { parseDocumentSlugFromUrl } from "shared/utils/parseDocumentIds";
|
||||
import DocumentsStore from "stores/DocumentsStore";
|
||||
import DocumentMeta from "components/DocumentMeta";
|
||||
|
||||
type Props = {
|
||||
url: string,
|
||||
documents: DocumentsStore,
|
||||
children: React.Node => React.Node,
|
||||
};
|
||||
|
||||
function HoverPreviewDocument({ url, documents, children }: Props) {
|
||||
const slug = parseDocumentSlugFromUrl(url);
|
||||
|
||||
documents.prefetchDocument(slug, {
|
||||
prefetch: true,
|
||||
});
|
||||
|
||||
const document = slug ? documents.getByUrl(slug) : undefined;
|
||||
if (!document) return null;
|
||||
|
||||
return children(
|
||||
<Content to={document.url}>
|
||||
<Heading>{document.title}</Heading>
|
||||
<DocumentMeta isDraft={document.isDraft} document={document} />
|
||||
|
||||
<Editor
|
||||
key={document.id}
|
||||
defaultValue={document.getSummary()}
|
||||
disableEmbeds
|
||||
readOnly
|
||||
/>
|
||||
</Content>
|
||||
);
|
||||
}
|
||||
|
||||
const Content = styled(Link)`
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const Heading = styled.h2`
|
||||
margin: 0 0 0.75em;
|
||||
`;
|
||||
|
||||
export default inject("documents")(observer(HoverPreviewDocument));
|
||||
Reference in New Issue
Block a user