From d5b5d4fc270222a8882fa426b475cf39d1808486 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 16 Jul 2020 21:26:23 -0700 Subject: [PATCH] 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 Co-authored-by: Nan Yu --- .../Document => }/components/DocumentMeta.js | 0 app/components/{Editor => }/Editor.js | 2 +- app/components/Editor/index.js | 3 - app/components/HoverPreview.js | 235 ++++++++++++++++++ app/components/HoverPreviewDocument.js | 50 ++++ app/models/Document.js | 11 +- app/scenes/Document/components/Document.js | 1 + app/scenes/Document/components/Editor.js | 34 ++- app/stores/DocumentsStore.js | 2 +- app/utils/isInternalUrl.js | 1 + package.json | 4 +- shared/styles/animations.js | 12 + shared/utils/domains.js | 10 + shared/utils/parseDocumentIds.js | 11 + yarn.lock | 16 +- 15 files changed, 374 insertions(+), 18 deletions(-) rename app/{scenes/Document => }/components/DocumentMeta.js (100%) rename app/components/{Editor => }/Editor.js (98%) delete mode 100644 app/components/Editor/index.js create mode 100644 app/components/HoverPreview.js create mode 100644 app/components/HoverPreviewDocument.js diff --git a/app/scenes/Document/components/DocumentMeta.js b/app/components/DocumentMeta.js similarity index 100% rename from app/scenes/Document/components/DocumentMeta.js rename to app/components/DocumentMeta.js diff --git a/app/components/Editor/Editor.js b/app/components/Editor.js similarity index 98% rename from app/components/Editor/Editor.js rename to app/components/Editor.js index dee1eddca..de042ebee 100644 --- a/app/components/Editor/Editor.js +++ b/app/components/Editor.js @@ -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 = []; diff --git a/app/components/Editor/index.js b/app/components/Editor/index.js deleted file mode 100644 index cef858bed..000000000 --- a/app/components/Editor/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// @flow -import Editor from "./Editor"; -export default Editor; diff --git a/app/components/HoverPreview.js b/app/components/HoverPreview.js new file mode 100644 index 000000000..0f5591b48 --- /dev/null +++ b/app/components/HoverPreview.js @@ -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 ( + + +
+ + {content => + isVisible ? ( + + + + {content} + + + + ) : null + } + +
+
+
+ ); +} + +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); diff --git a/app/components/HoverPreviewDocument.js b/app/components/HoverPreviewDocument.js new file mode 100644 index 000000000..0574b08bd --- /dev/null +++ b/app/components/HoverPreviewDocument.js @@ -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( + + {document.title} + + + + + ); +} + +const Content = styled(Link)` + cursor: pointer; +`; + +const Heading = styled.h2` + margin: 0 0 0.75em; +`; + +export default inject("documents")(observer(HoverPreviewDocument)); diff --git a/app/models/Document.js b/app/models/Document.js index 35ebf094e..e46193163 100644 --- a/app/models/Document.js +++ b/app/models/Document.js @@ -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(); diff --git a/app/scenes/Document/components/Document.js b/app/scenes/Document/components/Document.js index a562feeb1..de9a24dd2 100644 --- a/app/scenes/Document/components/Document.js +++ b/app/scenes/Document/components/Document.js @@ -426,6 +426,7 @@ class DocumentScene extends React.Component { this.editor = ref; } }} + isShare={isShare} isDraft={document.isDraft} key={disableEmbeds ? "embeds-disabled" : "embeds-enabled"} title={revision ? revision.title : this.title} diff --git a/app/scenes/Document/components/Editor.js b/app/scenes/Document/components/Editor.js index 955d72b03..b4e7274dc 100644 --- a/app/scenes/Document/components/Editor.js +++ b/app/scenes/Document/components/Editor.js @@ -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 { + @observable activeLinkEvent: ?MouseEvent; editor: ?Editor; focusAtStart = () => { @@ -50,8 +54,23 @@ class DocumentEditor extends React.Component { } }; + 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 { 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 && } + {this.activeLinkEvent && + !isShare && + readOnly && ( + + )} ); } diff --git a/app/stores/DocumentsStore.js b/app/stores/DocumentsStore.js index b0ea646c8..7df10109c 100644 --- a/app/stores/DocumentsStore.js +++ b/app/stores/DocumentsStore.js @@ -316,7 +316,7 @@ export default class DocumentsStore extends BaseStore { @action prefetchDocument = (id: string) => { - if (!this.data.get(id)) { + if (!this.data.get(id) && !this.getByUrl(id)) { return this.fetch(id, { prefetch: true }); } }; diff --git a/app/utils/isInternalUrl.js b/app/utils/isInternalUrl.js index 8f0f59e46..c1699d65d 100644 --- a/app/utils/isInternalUrl.js +++ b/app/utils/isInternalUrl.js @@ -6,6 +6,7 @@ export default function isInternalUrl(href: string) { const outline = parseDomain(window.location.href); const parsed = parseDomain(href); + if ( parsed && outline && diff --git a/package.json b/package.json index d627508b5..ba069cf1c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/shared/styles/animations.js b/shared/styles/animations.js index 55b6f4746..d09a3d2a1 100644 --- a/shared/styles/animations.js +++ b/shared/styles/animations.js @@ -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%, diff --git a/shared/utils/domains.js b/shared/utils/domains.js index 1bbb7af88..f8d18bcbb 100644 --- a/shared/utils/domains.js +++ b/shared/utils/domains.js @@ -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; } diff --git a/shared/utils/parseDocumentIds.js b/shared/utils/parseDocumentIds.js index 4678b8e95..f1eaaa730 100644 --- a/shared/utils/parseDocumentIds.js +++ b/shared/utils/parseDocumentIds.js @@ -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\//, ""); +} diff --git a/yarn.lock b/yarn.lock index 6fd3ee83f..7373bfaef 100644 --- a/yarn.lock +++ b/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"