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:
@@ -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));
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ export default function isInternalUrl(href: string) {
|
||||
|
||||
const outline = parseDomain(window.location.href);
|
||||
const parsed = parseDomain(href);
|
||||
|
||||
if (
|
||||
parsed &&
|
||||
outline &&
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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%,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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\//, "");
|
||||
}
|
||||
|
||||
16
yarn.lock
16
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user