diff --git a/app/components/Editor.js b/app/components/Editor/Editor.js similarity index 75% rename from app/components/Editor.js rename to app/components/Editor/Editor.js index b8679c2667..b117ba74bf 100644 --- a/app/components/Editor.js +++ b/app/components/Editor/Editor.js @@ -3,12 +3,15 @@ import * as React from 'react'; import RichMarkdownEditor from 'rich-markdown-editor'; import { uploadFile } from 'utils/uploadFile'; import isInternalUrl from 'utils/isInternalUrl'; +import Embed from './Embed'; +import embeds from '../../embeds'; type Props = { titlePlaceholder?: string, bodyPlaceholder?: string, defaultValue?: string, readOnly?: boolean, + disableEmbeds?: boolean, forwardedRef: *, history: *, ui: *, @@ -51,6 +54,22 @@ class Editor extends React.Component { this.props.ui.showToast(message, 'success'); }; + getLinkComponent = node => { + if (this.props.disableEmbeds) return; + + const url = node.data.get('href'); + const keys = Object.keys(embeds); + + for (const key of keys) { + const component = embeds[key]; + + for (const host of component.ENABLED) { + const matches = url.match(host); + if (matches) return Embed; + } + } + }; + render() { return ( { uploadImage={this.onUploadImage} onClickLink={this.onClickLink} onShowToast={this.onShowToast} + getLinkComponent={this.getLinkComponent} {...this.props} /> ); diff --git a/app/components/Editor/Embed.js b/app/components/Editor/Embed.js new file mode 100644 index 0000000000..0b50cf61bd --- /dev/null +++ b/app/components/Editor/Embed.js @@ -0,0 +1,52 @@ +// @flow +import * as React from 'react'; +import styled from 'styled-components'; +import { fadeIn } from 'shared/styles/animations'; +import embeds from '../../embeds'; + +export default class Embed extends React.Component<*> { + get url(): string { + return this.props.node.data.get('href'); + } + + get matches(): ?{ component: *, matches: string[] } { + const keys = Object.keys(embeds); + + for (const key of keys) { + const component = embeds[key]; + + for (const host of component.ENABLED) { + const matches = this.url.match(host); + if (matches) return { component, matches }; + } + } + } + + render() { + const result = this.matches; + if (!result) return null; + + const { attributes, isSelected } = this.props; + const { component, matches } = result; + const EmbedComponent = component; + + return ( + + + + ); + } +} + +const Container = styled.div` + animation: ${fadeIn} 500ms ease-in-out; + line-height: 0; + + border-radius: 3px; + box-shadow: ${props => + props.isSelected ? `0 0 0 2px ${props.theme.selected}` : 'none'}; +`; diff --git a/app/components/Editor/index.js b/app/components/Editor/index.js new file mode 100644 index 0000000000..223d4abd1d --- /dev/null +++ b/app/components/Editor/index.js @@ -0,0 +1,3 @@ +// @flow +import Editor from './Editor'; +export default Editor; diff --git a/app/embeds/Airtable.js b/app/embeds/Airtable.js new file mode 100644 index 0000000000..7e0be8dd24 --- /dev/null +++ b/app/embeds/Airtable.js @@ -0,0 +1,27 @@ +// @flow +import * as React from 'react'; +import Frame from './components/Frame'; + +const URL_REGEX = new RegExp('https://airtable.com/(?:embed/)?(shr.*)$'); + +type Props = { + url: string, + matches: string[], +}; + +export default class Airtable extends React.Component { + static ENABLED = [URL_REGEX]; + + render() { + const { matches } = this.props; + const shareId = matches[1]; + + return ( + + ); + } +} diff --git a/app/embeds/Airtable.test.js b/app/embeds/Airtable.test.js new file mode 100644 index 0000000000..d18702ff4c --- /dev/null +++ b/app/embeds/Airtable.test.js @@ -0,0 +1,23 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ +import embeds from '.'; + +const { Airtable } = embeds; + +describe('Airtable', () => { + const match = Airtable.ENABLED[0]; + test('to be enabled on share link', () => { + expect('https://airtable.com/shrEoQs3erLnppMie'.match(match)).toBeTruthy(); + }); + + test('to be enabled on embed link', () => { + expect( + 'https://airtable.com/embed/shrEoQs3erLnppMie'.match(match) + ).toBeTruthy(); + }); + + test('to not be enabled elsewhere', () => { + expect('https://airtable.com'.match(match)).toBe(null); + expect('https://airtable.com/features'.match(match)).toBe(null); + expect('https://airtable.com/pricing'.match(match)).toBe(null); + }); +}); diff --git a/app/embeds/Codepen.js b/app/embeds/Codepen.js new file mode 100644 index 0000000000..0fc0acdff2 --- /dev/null +++ b/app/embeds/Codepen.js @@ -0,0 +1,19 @@ +// @flow +import * as React from 'react'; +import Frame from './components/Frame'; + +const URL_REGEX = new RegExp('^https://codepen.io/(.*?)/(pen|embed)/(.*)$'); + +type Props = { + url: string, +}; + +export default class Codepen extends React.Component { + static ENABLED = [URL_REGEX]; + + render() { + const normalizedUrl = this.props.url.replace(/\/pen\//, '/embed/'); + + return ; + } +} diff --git a/app/embeds/Codepen.test.js b/app/embeds/Codepen.test.js new file mode 100644 index 0000000000..24303d17b5 --- /dev/null +++ b/app/embeds/Codepen.test.js @@ -0,0 +1,24 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ +import embeds from '.'; + +const { Codepen } = embeds; + +describe('Codepen', () => { + const match = Codepen.ENABLED[0]; + test('to be enabled on pen link', () => { + expect( + 'https://codepen.io/chriscoyier/pen/gfdDu'.match(match) + ).toBeTruthy(); + }); + + test('to be enabled on embed link', () => { + expect( + 'https://codepen.io/chriscoyier/embed/gfdDu'.match(match) + ).toBeTruthy(); + }); + + test('to not be enabled elsewhere', () => { + expect('https://codepen.io'.match(match)).toBe(null); + expect('https://codepen.io/chriscoyier'.match(match)).toBe(null); + }); +}); diff --git a/app/embeds/Figma.js b/app/embeds/Figma.js new file mode 100644 index 0000000000..8e622ebe0d --- /dev/null +++ b/app/embeds/Figma.js @@ -0,0 +1,27 @@ +// @flow +import * as React from 'react'; +import Frame from './components/Frame'; + +const URL_REGEX = new RegExp( + 'https://([w.-]+.)?figma.com/(file|proto)/([0-9a-zA-Z]{22,128})(?:/.*)?$' +); + +type Props = { + url: string, +}; + +export default class Figma extends React.Component { + static ENABLED = [URL_REGEX]; + + render() { + return ( + + ); + } +} diff --git a/app/embeds/Figma.test.js b/app/embeds/Figma.test.js new file mode 100644 index 0000000000..53979d3643 --- /dev/null +++ b/app/embeds/Figma.test.js @@ -0,0 +1,24 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ +import embeds from '.'; + +const { Figma } = embeds; + +describe('Figma', () => { + const match = Figma.ENABLED[0]; + test('to be enabled on file link', () => { + expect( + 'https://www.figma.com/file/LKQ4FJ4bTnCSjedbRpk931'.match(match) + ).toBeTruthy(); + }); + + test('to be enabled on prototype link', () => { + expect( + 'https://www.figma.com/proto/LKQ4FJ4bTnCSjedbRpk931'.match(match) + ).toBeTruthy(); + }); + + test('to not be enabled elsewhere', () => { + expect('https://www.figma.com'.match(match)).toBe(null); + expect('https://www.figma.com/features'.match(match)).toBe(null); + }); +}); diff --git a/app/embeds/Framer.js b/app/embeds/Framer.js new file mode 100644 index 0000000000..47568c3dc4 --- /dev/null +++ b/app/embeds/Framer.js @@ -0,0 +1,17 @@ +// @flow +import * as React from 'react'; +import Frame from './components/Frame'; + +const URL_REGEX = new RegExp('^https://framer.cloud/(.*)$'); + +type Props = { + url: string, +}; + +export default class Framer extends React.Component { + static ENABLED = [URL_REGEX]; + + render() { + return ; + } +} diff --git a/app/embeds/Framer.test.js b/app/embeds/Framer.test.js new file mode 100644 index 0000000000..42cbf1d14a --- /dev/null +++ b/app/embeds/Framer.test.js @@ -0,0 +1,15 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ +import embeds from '.'; + +const { Framer } = embeds; + +describe('Framer', () => { + const match = Framer.ENABLED[0]; + test('to be enabled on share link', () => { + expect('https://framer.cloud/PVwJO'.match(match)).toBeTruthy(); + }); + + test('to not be enabled on root', () => { + expect('https://framer.cloud'.match(match)).toBe(null); + }); +}); diff --git a/app/embeds/Gist.js b/app/embeds/Gist.js new file mode 100644 index 0000000000..56b93fcb78 --- /dev/null +++ b/app/embeds/Gist.js @@ -0,0 +1,70 @@ +// @flow +import * as React from 'react'; + +const URL_REGEX = new RegExp( + '^https://gist.github.com/([a-zd](?:[a-zd]|-(?=[a-zd])){0,38})/(.*)$' +); + +type Props = { + url: string, +}; + +class Gist extends React.Component { + iframeNode: ?HTMLIFrameElement; + + static ENABLED = [URL_REGEX]; + + componentDidMount() { + this.updateIframeContent(); + } + + get id() { + const gistUrl = new URL(this.props.url); + return gistUrl.pathname.split('/')[2]; + } + + updateIframeContent() { + const id = this.id; + const iframe = this.iframeNode; + if (!iframe) return; + + // $FlowFixMe + let doc = iframe.document; + if (iframe.contentDocument) doc = iframe.contentDocument; + else if (iframe.contentWindow) doc = iframe.contentWindow.document; + + const gistLink = `https://gist.github.com/${id}.js`; + const gistScript = ``; + const styles = + ''; + const iframeHtml = `${ + styles + }${gistScript}`; + + doc.open(); + doc.writeln(iframeHtml); + doc.close(); + } + + render() { + const id = this.id; + + return ( +