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"