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:
Tom Moor
2020-07-16 21:26:23 -07:00
committed by GitHub
parent d8603cc961
commit d5b5d4fc27
15 changed files with 374 additions and 18 deletions

View File

@@ -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 = [];

View File

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

View 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);

View 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));

View File

@@ -117,7 +117,6 @@ export default class Document extends BaseModel {
@action
disableEmbeds = () => {
this.embedsDisabled = true;
debugger;
};
@action
@@ -210,6 +209,16 @@ export default class Document extends BaseModel {
return this.store.duplicate(this);
};
getSummary = (paragraphs: number = 4) => {
const result = this.text
.trim()
.split("\n")
.slice(0, paragraphs)
.join("\n");
return result;
};
download = async () => {
// Ensure the document is upto date with latest server contents
await this.fetch();

View File

@@ -426,6 +426,7 @@ class DocumentScene extends React.Component<Props> {
this.editor = ref;
}
}}
isShare={isShare}
isDraft={document.isDraft}
key={disableEmbeds ? "embeds-disabled" : "embeds-enabled"}
title={revision ? revision.title : this.title}

View File

@@ -2,13 +2,15 @@
import * as React from "react";
import styled from "styled-components";
import Textarea from "react-autosize-textarea";
import { observable } from "mobx";
import { observer } from "mobx-react";
import Editor from "components/Editor";
import ClickablePadding from "components/ClickablePadding";
import Flex from "components/Flex";
import HoverPreview from "components/HoverPreview";
import parseTitle from "shared/utils/parseTitle";
import Document from "models/Document";
import DocumentMeta from "./DocumentMeta";
import DocumentMeta from "components/DocumentMeta";
type Props = {
onChangeTitle: (event: SyntheticInputEvent<>) => void,
@@ -16,11 +18,13 @@ type Props = {
defaultValue: string,
document: Document,
isDraft: boolean,
isShare: boolean,
readOnly?: boolean,
};
@observer
class DocumentEditor extends React.Component<Props> {
@observable activeLinkEvent: ?MouseEvent;
editor: ?Editor;
focusAtStart = () => {
@@ -50,8 +54,23 @@ class DocumentEditor extends React.Component<Props> {
}
};
handleLinkActive = (event: MouseEvent) => {
this.activeLinkEvent = event;
};
handleLinkInactive = () => {
this.activeLinkEvent = null;
};
render() {
const { document, title, onChangeTitle, isDraft, readOnly } = this.props;
const {
document,
title,
onChangeTitle,
isDraft,
isShare,
readOnly,
} = this.props;
const { emoji } = parseTitle(title);
const startsWithEmojiAndSpace = !!(emoji && title.startsWith(`${emoji} `));
@@ -73,10 +92,21 @@ class DocumentEditor extends React.Component<Props> {
ref={ref => (this.editor = ref)}
autoFocus={title && !this.props.defaultValue}
placeholder="…the rest is up to you"
onHoverLink={this.handleLinkActive}
scrollTo={window.location.hash}
grow
{...this.props}
/>
{!readOnly && <ClickablePadding onClick={this.focusAtEnd} grow />}
{this.activeLinkEvent &&
!isShare &&
readOnly && (
<HoverPreview
node={this.activeLinkEvent.target}
event={this.activeLinkEvent}
onClose={this.handleLinkInactive}
/>
)}
</Flex>
);
}

View File

@@ -316,7 +316,7 @@ export default class DocumentsStore extends BaseStore<Document> {
@action
prefetchDocument = (id: string) => {
if (!this.data.get(id)) {
if (!this.data.get(id) && !this.getByUrl(id)) {
return this.fetch(id, { prefetch: true });
}
};

View File

@@ -6,6 +6,7 @@ export default function isInternalUrl(href: string) {
const outline = parseDomain(window.location.href);
const parsed = parseDomain(href);
if (
parsed &&
outline &&

View File

@@ -125,7 +125,7 @@
"oy-vey": "^0.10.0",
"pg": "^6.1.5",
"pg-hstore": "2.3.2",
"polished": "3.6.4",
"polished": "3.6.5",
"query-string": "^4.3.4",
"randomstring": "1.1.5",
"raw-loader": "^0.5.1",
@@ -141,7 +141,7 @@
"react-portal": "^4.0.0",
"react-router-dom": "^5.1.2",
"react-waypoint": "^9.0.2",
"rich-markdown-editor": "^10.2.1",
"rich-markdown-editor": "^10.4.0-1",
"semver": "^7.3.2",
"sequelize": "^5.21.1",
"sequelize-cli": "^5.5.0",

View File

@@ -18,6 +18,18 @@ export const fadeAndScaleIn = keyframes`
}
`;
export const fadeAndSlideIn = keyframes`
from {
opacity: 0;
transform: scale(.98) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0px);
}
`;
export const bounceIn = keyframes`
from,
20%,

View File

@@ -12,6 +12,7 @@ type Domain = {
// unneccessarily for our usecase of trusted input.
export function parseDomain(url: string): ?Domain {
if (typeof url !== "string") return null;
if (url === "") return null;
// strip extermeties and whitespace from input
const normalizedDomain = trim(url.replace(/(https?:)?\/\//, ""));
@@ -39,6 +40,15 @@ export function parseDomain(url: string): ?Domain {
};
}
// one-part domain handler for things like localhost
if (parts.length === 1) {
return {
subdomain: "",
domain: cleanTLD(parts.slice(0).join()),
tld: "",
};
}
return null;
}

View File

@@ -37,3 +37,14 @@ export default function parseDocumentIds(text: string): string[] {
findLinks(value);
return links;
}
export function parseDocumentSlugFromUrl(url: string) {
let parsed;
try {
parsed = new URL(url);
} catch (err) {
return;
}
return parsed.pathname.replace(/^\/doc\//, "");
}

View File

@@ -7556,10 +7556,10 @@ pn@^1.1.0:
resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb"
integrity sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==
polished@3.6.4:
version "3.6.4"
resolved "https://registry.yarnpkg.com/polished/-/polished-3.6.4.tgz#cec6bc0fbffc5d6ce5799c85bcc1bca5e63f1dee"
integrity sha512-21moJXCm/7EvjeKQz5w89QDDKNPCoimc83CqwZZGJluFdMXsFlMQl9lPA/OMRkoceZ19kU0anKlMgZmY7LJSJw==
polished@3.6.5:
version "3.6.5"
resolved "https://registry.yarnpkg.com/polished/-/polished-3.6.5.tgz#dbefdde64c675935ec55119fe2a2ab627ca82e9c"
integrity sha512-VwhC9MlhW7O5dg/z7k32dabcAFW1VI2+7fSe8cE/kXcfL7mVdoa5UxciYGW2sJU78ldDLT6+ROEKIZKFNTnUXQ==
dependencies:
"@babel/runtime" "^7.9.2"
@@ -8599,10 +8599,10 @@ retry-as-promised@^3.2.0:
dependencies:
any-promise "^1.3.0"
rich-markdown-editor@^10.2.1:
version "10.2.1"
resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-10.2.1.tgz#b76ac31070b087dc497691618274a12260274d35"
integrity sha512-bZcU4+N226Iey7SJB/tlGxl9EORplHokFIVH1Wg6HZ2oJfxgbM1Y0f7JWghv3wliW6ac7qaRNjj+Q66Vqxv7pw==
rich-markdown-editor@^10.4.0-1:
version "10.4.0-1"
resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-10.4.0-1.tgz#1f5dc63262b0bcae4d94e83ec9076dd229a8e199"
integrity sha512-donV4yFbS9fCdWhWYI70xo+hbkxdBbYGJKEg3NcnhkdZ7NbE6Cn4R3JmSdg7BpBgIM/7H+UMWqUV5M9vJHaLHQ==
dependencies:
copy-to-clipboard "^3.0.8"
lodash "^4.17.11"