From d7ee801fe4672ddc8aa0246efc3fe780a57ce2b3 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 16 Feb 2022 18:05:02 -0800 Subject: [PATCH] feat: Show icon on external links (#3100) * feat: External links get treatment * cache decorations --- app/components/Editor.tsx | 3 +- app/components/HoverPreview.tsx | 2 +- app/editor/components/LinkEditor.tsx | 13 +- app/editor/components/Styles.ts | 16 ++- app/hooks/useDictionary.ts | 1 + app/scenes/Document/components/DataLoader.tsx | 2 +- app/utils/urls.ts | 22 --- shared/editor/marks/{Link.ts => Link.tsx} | 131 ++++++++++++------ shared/editor/queries/findLinkNodes.ts | 19 +++ shared/i18n/locales/en_US/translation.json | 1 + shared/utils/urls.ts | 25 ++++ 11 files changed, 162 insertions(+), 73 deletions(-) rename shared/editor/marks/{Link.ts => Link.tsx} (52%) create mode 100644 shared/editor/queries/findLinkNodes.ts diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index a4d6e0f0d..a1e3f00cb 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { Optional } from "utility-types"; import embeds from "@shared/editor/embeds"; +import { isInternalUrl } from "@shared/utils/urls"; import ErrorBoundary from "~/components/ErrorBoundary"; import { Props as EditorProps } from "~/editor"; import useDictionary from "~/hooks/useDictionary"; @@ -8,7 +9,7 @@ import useToasts from "~/hooks/useToasts"; import history from "~/utils/history"; import { isModKey } from "~/utils/keyboard"; import { uploadFile } from "~/utils/uploadFile"; -import { isInternalUrl, isHash } from "~/utils/urls"; +import { isHash } from "~/utils/urls"; const SharedEditor = React.lazy( () => diff --git a/app/components/HoverPreview.tsx b/app/components/HoverPreview.tsx index 32fb8133d..ab49c1948 100644 --- a/app/components/HoverPreview.tsx +++ b/app/components/HoverPreview.tsx @@ -3,10 +3,10 @@ import * as React from "react"; import { Portal } from "react-portal"; import styled from "styled-components"; import parseDocumentSlug from "@shared/utils/parseDocumentSlug"; +import { isInternalUrl } from "@shared/utils/urls"; import HoverPreviewDocument from "~/components/HoverPreviewDocument"; import useStores from "~/hooks/useStores"; import { fadeAndSlideDown } from "~/styles/animations"; -import { isInternalUrl } from "~/utils/urls"; const DELAY_OPEN = 300; const DELAY_CLOSE = 300; diff --git a/app/editor/components/LinkEditor.tsx b/app/editor/components/LinkEditor.tsx index dee3b960b..d98813e2e 100644 --- a/app/editor/components/LinkEditor.tsx +++ b/app/editor/components/LinkEditor.tsx @@ -1,4 +1,5 @@ import { + ArrowIcon, DocumentIcon, CloseIcon, PlusIcon, @@ -11,6 +12,7 @@ import { EditorView } from "prosemirror-view"; import * as React from "react"; import styled from "styled-components"; import isUrl from "@shared/editor/lib/isUrl"; +import { isInternalUrl } from "@shared/utils/urls"; import Flex from "~/components/Flex"; import { Dictionary } from "~/hooks/useDictionary"; import Input from "./Input"; @@ -299,6 +301,7 @@ class LinkEditor extends React.Component { const looksLikeUrl = value.match(/^https?:\/\//i); const suggestedLinkTitle = this.suggestedLinkTitle; + const isInternal = isInternalUrl(value); const showCreateLink = !!this.props.onCreateLink && @@ -324,9 +327,15 @@ class LinkEditor extends React.Component { autoFocus={this.href === ""} /> - + - + {isInternal ? ( + + ) : ( + + )} diff --git a/app/editor/components/Styles.ts b/app/editor/components/Styles.ts index dfd578254..7436a4c45 100644 --- a/app/editor/components/Styles.ts +++ b/app/editor/components/Styles.ts @@ -529,13 +529,16 @@ const EditorStyles = styled.div<{ a { color: ${(props) => props.theme.text}; - border-bottom: 1px solid ${(props) => lighten(0.5, props.theme.text)}; - text-decoration: none !important; + text-decoration: underline; + text-decoration-color: ${(props) => lighten(0.5, props.theme.text)}; + text-decoration-thickness: 1px; + text-underline-offset: .15em; font-weight: 500; &:hover { - border-bottom: 1px solid ${(props) => props.theme.text}; - text-decoration: none; + text-decoration: underline; + text-decoration-color: ${(props) => props.theme.text}; + text-decoration-thickness: 1px; } } } @@ -718,6 +721,11 @@ const EditorStyles = styled.div<{ } } + .external-link { + position: relative; + top: 2px; + } + .code-actions, .notice-actions { display: flex; diff --git a/app/hooks/useDictionary.ts b/app/hooks/useDictionary.ts index 7b394da20..ce3db1171 100644 --- a/app/hooks/useDictionary.ts +++ b/app/hooks/useDictionary.ts @@ -50,6 +50,7 @@ export default function useDictionary() { newLineWithSlash: `${t("Keep typing to filter")}…`, noResults: t("No results"), openLink: t("Open link"), + goToLink: t("Go to link"), orderedList: t("Ordered list"), pageBreak: t("Page break"), pasteLink: `${t("Paste a link")}…`, diff --git a/app/scenes/Document/components/DataLoader.tsx b/app/scenes/Document/components/DataLoader.tsx index 3899928ef..c8a3792c5 100644 --- a/app/scenes/Document/components/DataLoader.tsx +++ b/app/scenes/Document/components/DataLoader.tsx @@ -6,6 +6,7 @@ import { observer } from "mobx-react"; import * as React from "react"; import { RouteComponentProps, StaticContext } from "react-router"; import parseDocumentSlug from "@shared/utils/parseDocumentSlug"; +import { isInternalUrl } from "@shared/utils/urls"; import RootStore from "~/stores/RootStore"; import Document from "~/models/Document"; import Revision from "~/models/Revision"; @@ -17,7 +18,6 @@ import { NavigationNode } from "~/types"; import { NotFoundError, OfflineError } from "~/utils/errors"; import history from "~/utils/history"; import { matchDocumentEdit } from "~/utils/routeHelpers"; -import { isInternalUrl } from "~/utils/urls"; import HideSidebar from "./HideSidebar"; import Loading from "./Loading"; diff --git a/app/utils/urls.ts b/app/utils/urls.ts index 70b3b9472..49dc41c9b 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -1,25 +1,3 @@ -import { parseDomain } from "@shared/utils/domains"; - -export function isInternalUrl(href: string) { - if (href[0] === "/") { - return true; - } - const outline = parseDomain(window.location.href); - const parsed = parseDomain(href); - - if ( - parsed && - outline && - parsed.subdomain === outline.subdomain && - parsed.domain === outline.domain && - parsed.tld === outline.tld - ) { - return true; - } - - return false; -} - export function isHash(href: string) { if (href[0] === "#") { return true; diff --git a/shared/editor/marks/Link.ts b/shared/editor/marks/Link.tsx similarity index 52% rename from shared/editor/marks/Link.ts rename to shared/editor/marks/Link.tsx index 7feac6da2..6fb579327 100644 --- a/shared/editor/marks/Link.ts +++ b/shared/editor/marks/Link.tsx @@ -1,4 +1,5 @@ import Token from "markdown-it/lib/token"; +import { OpenIcon } from "outline-icons"; import { toggleMark } from "prosemirror-commands"; import { InputRule } from "prosemirror-inputrules"; import { MarkdownSerializerState } from "prosemirror-markdown"; @@ -9,6 +10,11 @@ import { Mark as ProsemirrorMark, } from "prosemirror-model"; import { Transaction, EditorState, Plugin } from "prosemirror-state"; +import { Decoration, DecorationSet } from "prosemirror-view"; +import * as React from "react"; +import ReactDOM from "react-dom"; +import { isInternalUrl } from "../../utils/urls"; +import findLinkNodes from "../queries/findLinkNodes"; import Mark from "./Mark"; const LINK_INPUT_REGEX = /\[([^[]+)]\((\S+)\)$/; @@ -109,56 +115,97 @@ export default class Link extends Mark { } get plugins() { - return [ - new Plugin({ - props: { - handleDOMEvents: { - mouseover: (_view, event: MouseEvent) => { - if ( - event.target instanceof HTMLAnchorElement && - !event.target.className.includes("ProseMirror-widget") - ) { - if (this.options.onHoverLink) { - return this.options.onHoverLink(event); - } + const getLinkDecorations = (doc: Node) => { + const decorations: Decoration[] = []; + const links = findLinkNodes(doc); + + links.forEach((nodeWithPos) => { + const linkMark = nodeWithPos.node.marks.find( + (mark) => mark.type.name === "link" + ); + if (linkMark && !isInternalUrl(linkMark.attrs.href)) { + decorations.push( + Decoration.widget( + // place the decoration at the end of the link + nodeWithPos.pos + nodeWithPos.node.nodeSize, + () => { + const component = ; + const icon = document.createElement("span"); + icon.className = "external-link"; + ReactDOM.render(component, icon); + return icon; + }, + { + // position on the right side of the position + side: 1, } + ) + ); + } + }); + + return DecorationSet.create(doc, decorations); + }; + + const plugin: Plugin = new Plugin({ + state: { + init: (config, state) => { + return getLinkDecorations(state.doc); + }, + apply: (tr, oldState) => { + return tr.docChanged ? getLinkDecorations(tr.doc) : oldState; + }, + }, + props: { + decorations: (state) => plugin.getState(state), + handleDOMEvents: { + mouseover: (_view, event: MouseEvent) => { + if ( + event.target instanceof HTMLAnchorElement && + !event.target.className.includes("ProseMirror-widget") + ) { + if (this.options.onHoverLink) { + return this.options.onHoverLink(event); + } + } + return false; + }, + click: (view, event: MouseEvent) => { + if (!(event.target instanceof HTMLAnchorElement)) { return false; - }, - click: (view, event: MouseEvent) => { - if (!(event.target instanceof HTMLAnchorElement)) { - return false; + } + + // clicking a link while editing should show the link toolbar, + // clicking in read-only will navigate + if (!view.editable) { + const href = + event.target.href || + (event.target.parentNode instanceof HTMLAnchorElement + ? event.target.parentNode.href + : ""); + + const isHashtag = href.startsWith("#"); + if (isHashtag && this.options.onClickHashtag) { + event.stopPropagation(); + event.preventDefault(); + this.options.onClickHashtag(href, event); } - // clicking a link while editing should show the link toolbar, - // clicking in read-only will navigate - if (!view.editable) { - const href = - event.target.href || - (event.target.parentNode instanceof HTMLAnchorElement - ? event.target.parentNode.href - : ""); - - const isHashtag = href.startsWith("#"); - if (isHashtag && this.options.onClickHashtag) { - event.stopPropagation(); - event.preventDefault(); - this.options.onClickHashtag(href, event); - } - - if (this.options.onClickLink) { - event.stopPropagation(); - event.preventDefault(); - this.options.onClickLink(href, event); - } - return true; + if (this.options.onClickLink) { + event.stopPropagation(); + event.preventDefault(); + this.options.onClickLink(href, event); } + return true; + } - return false; - }, + return false; }, }, - }), - ]; + }, + }); + + return [plugin]; } toMarkdown() { diff --git a/shared/editor/queries/findLinkNodes.ts b/shared/editor/queries/findLinkNodes.ts new file mode 100644 index 000000000..d80fe7cf9 --- /dev/null +++ b/shared/editor/queries/findLinkNodes.ts @@ -0,0 +1,19 @@ +import { Node } from "prosemirror-model"; +import { findTextNodes, NodeWithPos } from "prosemirror-utils"; + +export default function findLinkNodes(doc: Node): NodeWithPos[] { + const textNodes = findTextNodes(doc); + const nodes: NodeWithPos[] = []; + + for (const nodeWithPos of textNodes) { + const hasLinkMark = nodeWithPos.node.marks.find( + (mark) => mark.type.name === "link" + ); + + if (hasLinkMark) { + nodes.push(nodeWithPos); + } + } + + return nodes; +} diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 82fd23850..462593005 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -206,6 +206,7 @@ "Type '/' to insert": "Type '/' to insert", "Keep typing to filter": "Keep typing to filter", "Open link": "Open link", + "Go to link": "Go to link", "Ordered list": "Ordered list", "Page break": "Page break", "Paste a link": "Paste a link", diff --git a/shared/utils/urls.ts b/shared/utils/urls.ts index 75171b401..94077f409 100644 --- a/shared/utils/urls.ts +++ b/shared/utils/urls.ts @@ -1,5 +1,30 @@ +import { parseDomain } from "./domains"; + const env = typeof window !== "undefined" ? window.env : process.env; export function cdnPath(path: string): string { return `${env.CDN_URL}${path}`; } + +export function isInternalUrl(href: string) { + if (href[0] === "/") { + return true; + } + const outline = + typeof window !== "undefined" + ? parseDomain(window.location.href) + : undefined; + const parsed = parseDomain(href); + + if ( + parsed && + outline && + parsed.subdomain === outline.subdomain && + parsed.domain === outline.domain && + parsed.tld === outline.tld + ) { + return true; + } + + return false; +}