Editor embeds (#680)

- [x] Make deleting an embed easier
- [x] Add document level ability to disable embeds
- [x] Add team level ability to disable embeds
- [x] GitHub
- [x] Numeracy
- [x] Mode Analytics
- [x] Figma
- [x] Airtable
- [x] Vimeo
- [x] RealtimeBoard
- [x] Loom
- [x] Lucidcharts
- [x] Framer
- [x] InVision
- [x] Typeform
- [x] Marvel
- [x] Spotify
- [x] Codepen
- [x] Trello
This commit is contained in:
Tom Moor
2018-12-15 14:06:29 -08:00
committed by GitHub
parent 836f9a88a2
commit 044b4f16bc
50 changed files with 1121 additions and 18 deletions

View File

@ -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<Props> {
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 (
<RichMarkdownEditor
@ -58,6 +77,7 @@ class Editor extends React.Component<Props> {
uploadImage={this.onUploadImage}
onClickLink={this.onClickLink}
onShowToast={this.onShowToast}
getLinkComponent={this.getLinkComponent}
{...this.props}
/>
);

View File

@ -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 (
<Container
contentEditable={false}
isSelected={isSelected}
{...attributes}
>
<EmbedComponent matches={matches} url={this.url} />
</Container>
);
}
}
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'};
`;

View File

@ -0,0 +1,3 @@
// @flow
import Editor from './Editor';
export default Editor;

27
app/embeds/Airtable.js Normal file
View File

@ -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<Props> {
static ENABLED = [URL_REGEX];
render() {
const { matches } = this.props;
const shareId = matches[1];
return (
<Frame
src={`https://airtable.com/embed/${shareId}`}
title={`Airtable (${shareId})`}
border
/>
);
}
}

View File

@ -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);
});
});

19
app/embeds/Codepen.js Normal file
View File

@ -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<Props> {
static ENABLED = [URL_REGEX];
render() {
const normalizedUrl = this.props.url.replace(/\/pen\//, '/embed/');
return <Frame src={normalizedUrl} title="Codepen Embed" />;
}
}

View File

@ -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);
});
});

27
app/embeds/Figma.js Normal file
View File

@ -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<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
src={`https://www.figma.com/embed?embed_host=outline&url=${
this.props.url
}`}
title="Figma Embed"
border
/>
);
}
}

24
app/embeds/Figma.test.js Normal file
View File

@ -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);
});
});

17
app/embeds/Framer.js Normal file
View File

@ -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<Props> {
static ENABLED = [URL_REGEX];
render() {
return <Frame src={this.props.url} title="Framer Embed" border />;
}
}

15
app/embeds/Framer.test.js Normal file
View File

@ -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);
});
});

70
app/embeds/Gist.js Normal file
View File

@ -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<Props> {
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 = `<script type="text/javascript" src="${
gistLink
}"></script>`;
const styles =
'<style>*{ font-size:12px; } body { margin: 0; } .gist .blob-wrapper.data { max-height:150px; overflow:auto; }</style>';
const iframeHtml = `<html><head><base target="_parent">${
styles
}</head><body>${gistScript}</body></html>`;
doc.open();
doc.writeln(iframeHtml);
doc.close();
}
render() {
const id = this.id;
return (
<iframe
ref={ref => {
this.iframeNode = ref;
}}
type="text/html"
frameBorder="0"
width="100%"
height="200px"
id={`gist-${id}`}
title={`Github Gist (${id})`}
/>
);
}
}
export default Gist;

19
app/embeds/Gist.test.js Normal file
View File

@ -0,0 +1,19 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import embeds from '.';
const { Gist } = embeds;
describe('Gist', () => {
const match = Gist.ENABLED[0];
test('to be enabled on gist link', () => {
expect(
'https://gist.github.com/wmertens/0b4fd66ca7055fd290ecc4b9d95271a9'.match(
match
)
).toBeTruthy();
});
test('to not be enabled elsewhere', () => {
expect('https://gist.github.com/tommoor'.match(match)).toBe(null);
});
});

19
app/embeds/InVision.js Normal file
View File

@ -0,0 +1,19 @@
// @flow
import * as React from 'react';
import Frame from './components/Frame';
const URL_REGEX = new RegExp(
'^https://(invis.io/.*)|(projects.invisionapp.com/share/.*)$'
);
type Props = {
url: string,
};
export default class InVision extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return <Frame src={this.props.url} title="InVision Embed" />;
}
}

View File

@ -0,0 +1,23 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import embeds from '.';
const { InVision } = embeds;
describe('InVision', () => {
const match = InVision.ENABLED[0];
test('to be enabled on shortlink', () => {
expect('https://invis.io/69PG07QYQTE'.match(match)).toBeTruthy();
});
test('to be enabled on share', () => {
expect(
'https://projects.invisionapp.com/share/69PG07QYQTE'.match(match)
).toBeTruthy();
});
test('to not be enabled elsewhere', () => {
expect('https://invis.io'.match(match)).toBe(null);
expect('https://invisionapp.com'.match(match)).toBe(null);
expect('https://projects.invisionapp.com'.match(match)).toBe(null);
});
});

26
app/embeds/Loom.js Normal file
View File

@ -0,0 +1,26 @@
// @flow
import * as React from 'react';
import Frame from './components/Frame';
const URL_REGEX = /^https:\/\/(www\.)?useloom.com\/(embed|share)\/(.*)$/;
type Props = {
url: string,
};
export default class Loom extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
const normalizedUrl = this.props.url.replace('share', 'embed');
return (
<Frame
width="420px"
height="235px"
src={normalizedUrl}
title="Loom Embed"
/>
);
}
}

28
app/embeds/Loom.test.js Normal file
View File

@ -0,0 +1,28 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import embeds from '.';
const { Loom } = embeds;
describe('Loom', () => {
const match = Loom.ENABLED[0];
test('to be enabled on share link', () => {
expect(
'https://www.useloom.com/share/55327cbb265743f39c2c442c029277e0'.match(
match
)
).toBeTruthy();
});
test('to be enabled on embed link', () => {
expect(
'https://www.useloom.com/embed/55327cbb265743f39c2c442c029277e0'.match(
match
)
).toBeTruthy();
});
test('to not be enabled elsewhere', () => {
expect('https://www.useloom.com'.match(match)).toBe(null);
expect('https://www.useloom.com/features'.match(match)).toBe(null);
});
});

26
app/embeds/Lucidchart.js Normal file
View File

@ -0,0 +1,26 @@
// @flow
import * as React from 'react';
import Frame from './components/Frame';
const URL_REGEX = /^https:\/\/(www\.)?lucidchart.com\/documents\/(embeddedchart|view)\/([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})(?:\/.*)?$/;
type Props = {
url: string,
matches: string[],
};
export default class Lucidchart extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
const { matches } = this.props;
const chartId = matches[3];
return (
<Frame
src={`http://lucidchart.com/documents/embeddedchart/${chartId}`}
title="Lucidchart Embed"
/>
);
}
}

View File

@ -0,0 +1,30 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import embeds from '.';
const { Lucidchart } = embeds;
describe('Lucidchart', () => {
const match = Lucidchart.ENABLED[0];
test('to be enabled on view link', () => {
expect(
'https://www.lucidchart.com/documents/view/2f4a79cb-7637-433d-8ffb-27cce65a05e7'.match(
match
)
).toBeTruthy();
});
test('to be enabled on visited link', () => {
expect(
'https://www.lucidchart.com/documents/view/2f4a79cb-7637-433d-8ffb-27cce65a05e7/0'.match(
match
)
).toBeTruthy();
});
test('to not be enabled elsewhere', () => {
expect('https://lucidchart.com'.match(match)).toBe(null);
expect('https://www.lucidchart.com'.match(match)).toBe(null);
expect('https://www.lucidchart.com/features'.match(match)).toBe(null);
expect('https://www.lucidchart.com/documents/view'.match(match)).toBe(null);
});
});

17
app/embeds/Marvel.js Normal file
View File

@ -0,0 +1,17 @@
// @flow
import * as React from 'react';
import Frame from './components/Frame';
const URL_REGEX = new RegExp('^https://marvelapp.com/([A-Za-z0-9-]{6})/?$');
type Props = {
url: string,
};
export default class Marvel extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return <Frame src={this.props.url} title="Marvel Embed" border />;
}
}

16
app/embeds/Marvel.test.js Normal file
View File

@ -0,0 +1,16 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import embeds from '.';
const { Marvel } = embeds;
describe('Marvel', () => {
const match = Marvel.ENABLED[0];
test('to be enabled on share link', () => {
expect('https://marvelapp.com/75hj91'.match(match)).toBeTruthy();
});
test('to not be enabled elsewhere', () => {
expect('https://marvelapp.com'.match(match)).toBe(null);
expect('https://marvelapp.com/features'.match(match)).toBe(null);
});
});

View File

@ -0,0 +1,24 @@
// @flow
import * as React from 'react';
import Frame from './components/Frame';
const URL_REGEX = new RegExp(
'https://([w.-]+.)?modeanalytics.com/(.*)/reports/(.*)$'
);
type Props = {
url: string,
};
export default class ModeAnalytics extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
// Allow users to paste embed or standard urls and handle them the same
const normalizedUrl = this.props.url.replace(/\/embed$/, '');
return (
<Frame src={`${normalizedUrl}/embed`} title="Mode Analytics Embed" />
);
}
}

View File

@ -0,0 +1,19 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import embeds from '.';
const { ModeAnalytics } = embeds;
describe('ModeAnalytics', () => {
const match = ModeAnalytics.ENABLED[0];
test('to be enabled on report link', () => {
expect(
'https://modeanalytics.com/outline/reports/5aca06064f56'.match(match)
).toBeTruthy();
});
test('to not be enabled elsewhere', () => {
expect('https://modeanalytics.com'.match(match)).toBe(null);
expect('https://modeanalytics.com/outline'.match(match)).toBe(null);
expect('https://modeanalytics.com/outline/reports'.match(match)).toBe(null);
});
});

22
app/embeds/Numeracy.js Normal file
View File

@ -0,0 +1,22 @@
// @flow
import * as React from 'react';
import Frame from './components/Frame';
const URL_REGEX = new RegExp('https://([w.-]+.)?numeracy.co/(.*)/(.*)$');
type Props = {
url: string,
};
export default class Numeracy extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
// Allow users to paste embed or standard urls and handle them the same
const normalizedUrl = this.props.url.replace(/\.embed$/, '');
return (
<Frame src={`${normalizedUrl}.embed`} title="Numeracy Embed" border />
);
}
}

View File

@ -0,0 +1,22 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import embeds from '.';
const { Numeracy } = embeds;
describe('Numeracy', () => {
const match = Numeracy.ENABLED[0];
test('to be enabled on share link', () => {
expect('https://numeracy.co/outline/n8ZIVOC2OS'.match(match)).toBeTruthy();
});
test('to be enabled on embed link', () => {
expect(
'https://numeracy.co/outline/n8ZIVOC2OS.embed'.match(match)
).toBeTruthy();
});
test('to not be enabled elsewhere', () => {
expect('https://numeracy.co'.match(match)).toBe(null);
expect('https://numeracy.co/outline'.match(match)).toBe(null);
});
});

View File

@ -0,0 +1,26 @@
// @flow
import * as React from 'react';
import Frame from './components/Frame';
const URL_REGEX = /^https:\/\/realtimeboard.com\/app\/board\/(.*)$/;
type Props = {
url: string,
matches: string[],
};
export default class RealtimeBoard extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
const { matches } = this.props;
const boardId = matches[1];
return (
<Frame
src={`http://realtimeboard.com/app/embed/${boardId}`}
title={`RealtimeBoard (${boardId})`}
/>
);
}
}

View File

@ -0,0 +1,18 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import embeds from '.';
const { RealtimeBoard } = embeds;
describe('RealtimeBoard', () => {
const match = RealtimeBoard.ENABLED[0];
test('to be enabled on share link', () => {
expect(
'https://realtimeboard.com/app/board/o9J_k0fwiss='.match(match)
).toBeTruthy();
});
test('to not be enabled elsewhere', () => {
expect('https://realtimeboard.com'.match(match)).toBe(null);
expect('https://realtimeboard.com/features'.match(match)).toBe(null);
});
});

36
app/embeds/Spotify.js Normal file
View File

@ -0,0 +1,36 @@
// @flow
import * as React from 'react';
import Frame from './components/Frame';
const URL_REGEX = new RegExp('https?://open.spotify.com/(.*)$');
type Props = {
url: string,
};
export default class Spotify extends React.Component<Props> {
static ENABLED = [URL_REGEX];
get pathname() {
try {
const parsed = new URL(this.props.url);
return parsed.pathname;
} catch (err) {
return '';
}
}
render() {
const normalizedPath = this.pathname.replace(/^\/embed/, '/');
return (
<Frame
width="300px"
height="380px"
src={`https://open.spotify.com/embed${normalizedPath}`}
title="Spotify Embed"
allow="encrypted-media"
/>
);
}
}

View File

@ -0,0 +1,29 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import embeds from '.';
const { Spotify } = embeds;
describe('Spotify', () => {
const match = Spotify.ENABLED[0];
test('to be enabled on song link', () => {
expect(
'https://open.spotify.com/track/29G1ScCUhgjgI0H72qN4DE?si=DxjEUxV2Tjmk6pSVckPDRg'.match(
match
)
).toBeTruthy();
});
test('to be enabled on playlist link', () => {
expect(
'https://open.spotify.com/user/spotify/playlist/29G1ScCUhgjgI0H72qN4DE?si=DxjEUxV2Tjmk6pSVckPDRg'.match(
match
)
).toBeTruthy();
});
test('to not be enabled elsewhere', () => {
expect('https://spotify.com'.match(match)).toBe(null);
expect('https://open.spotify.com'.match(match)).toBe(null);
expect('https://www.spotify.com'.match(match)).toBe(null);
});
});

39
app/embeds/Trello.js Normal file
View File

@ -0,0 +1,39 @@
// @flow
import * as React from 'react';
import Frame from './components/Frame';
const URL_REGEX = /^https:\/\/trello.com\/(c|b)\/(.*)$/;
type Props = {
url: string,
matches: string[],
};
export default class Trello extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
const { matches } = this.props;
const objectId = matches[2];
if (matches[1] === 'c') {
return (
<Frame
width="316px"
height="158px"
src={`https://trello.com/embed/card?id=${objectId}`}
title={`Trello Card (${objectId})`}
/>
);
}
return (
<Frame
width="248px"
height="185px"
src={`https://trello.com/embed/board?id=${objectId}`}
title={`Trello Board (${objectId})`}
/>
);
}
}

19
app/embeds/Typeform.js Normal file
View File

@ -0,0 +1,19 @@
// @flow
import * as React from 'react';
import Frame from './components/Frame';
const URL_REGEX = new RegExp(
'^https://([A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?).typeform.com/to/(.*)$'
);
type Props = {
url: string,
};
export default class Typeform extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return <Frame src={this.props.url} title="Typeform Embed" />;
}
}

View File

@ -0,0 +1,19 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import embeds from '.';
const { Typeform } = embeds;
describe('Typeform', () => {
const match = Typeform.ENABLED[0];
test('to be enabled on share link', () => {
expect(
'https://beardyman.typeform.com/to/zvlr4L'.match(match)
).toBeTruthy();
});
test('to not be enabled elsewhere', () => {
expect('https://www.typeform.com'.match(match)).toBe(null);
expect('https://typeform.com/to/zvlr4L'.match(match)).toBe(null);
expect('https://typeform.com/features'.match(match)).toBe(null);
});
});

28
app/embeds/Vimeo.js Normal file
View File

@ -0,0 +1,28 @@
// @flow
import * as React from 'react';
import Frame from './components/Frame';
const URL_REGEX = /(http|https)?:\/\/(www\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^\/]*)\/videos\/|)(\d+)(?:|\/\?)/;
type Props = {
url: string,
matches: string[],
};
export default class Vimeo extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
const { matches } = this.props;
const videoId = matches[4];
return (
<Frame
width="420px"
height="235px"
src={`http://player.vimeo.com/video/${videoId}?byline=0`}
title={`Vimeo Embed (${videoId})`}
/>
);
}
}

20
app/embeds/Vimeo.test.js Normal file
View File

@ -0,0 +1,20 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import embeds from '.';
const { Vimeo } = embeds;
describe('Vimeo', () => {
const match = Vimeo.ENABLED[0];
test('to be enabled on video link', () => {
expect('https://vimeo.com/265045525'.match(match)).toBeTruthy();
});
test('to not be enabled elsewhere', () => {
expect('https://vimeo.com'.match(match)).toBe(null);
expect('https://www.vimeo.com'.match(match)).toBe(null);
expect('https://vimeo.com/upgrade'.match(match)).toBe(null);
expect('https://vimeo.com/features/video-marketing'.match(match)).toBe(
null
);
});
});

28
app/embeds/YouTube.js Normal file
View File

@ -0,0 +1,28 @@
// @flow
import * as React from 'react';
import Frame from './components/Frame';
const URL_REGEX = /(?:https?:\/\/)?(?:www\.)?youtu\.?be(?:\.com)?\/?.*(?:watch|embed)?(?:.*v=|v\/|\/)([a-zA-Z0-9_-]{11})$/i;
type Props = {
url: string,
matches: string[],
};
export default class YouTube extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
const { matches } = this.props;
const videoId = matches[1];
return (
<Frame
width="420px"
height="235px"
src={`https://www.youtube.com/embed/${videoId}?modestbranding=1`}
title={`YouTube (${videoId})`}
/>
);
}
}

View File

@ -0,0 +1,33 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import embeds from '.';
const { YouTube } = embeds;
describe('YouTube', () => {
const match = YouTube.ENABLED[0];
test('to be enabled on video link', () => {
expect(
'https://www.youtube.com/watch?v=dQw4w9WgXcQ'.match(match)
).toBeTruthy();
});
test('to be enabled on embed link', () => {
expect(
'https://www.youtube.com/embed?v=dQw4w9WgXcQ'.match(match)
).toBeTruthy();
});
test('to be enabled on shortlink', () => {
expect('https://youtu.be/dQw4w9WgXcQ'.match(match)).toBeTruthy();
});
test('to not be enabled elsewhere', () => {
expect('https://youtu.be'.match(match)).toBe(null);
expect('https://youtube.com'.match(match)).toBe(null);
expect('https://www.youtube.com'.match(match)).toBe(null);
expect('https://www.youtube.com/logout'.match(match)).toBe(null);
expect('https://www.youtube.com/feed/subscriptions'.match(match)).toBe(
null
);
});
});

View File

@ -0,0 +1,82 @@
// @flow
import * as React from 'react';
import styled from 'styled-components';
type Props = {
src?: string,
border?: boolean,
forwardedRef: *,
width?: string,
height?: string,
};
type State = {
isLoaded: boolean,
};
class Frame extends React.Component<Props, State> {
mounted: boolean;
state = { isLoaded: false };
componentDidMount() {
this.mounted = true;
setImmediate(this.loadIframe);
}
componentWillUnmount() {
this.mounted = false;
}
loadIframe = () => {
if (!this.mounted) return;
this.setState({ isLoaded: true });
};
render() {
const {
border,
width = '100%',
height = '400',
forwardedRef,
...rest
} = this.props;
const Component = border ? Iframe : 'iframe';
return (
<Rounded width={width} height={height}>
{this.state.isLoaded && (
<Component
ref={forwardedRef}
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
width={width}
height={height}
type="text/html"
frameBorder="0"
title="embed"
allowFullScreen
{...rest}
/>
)}
</Rounded>
);
}
}
const Rounded = styled.div`
border-radius: 3px;
overflow: hidden;
width: ${props => props.width};
height: ${props => props.height};
`;
const Iframe = styled.iframe`
border: 1px solid;
border-color: #ddd #ddd #ccc;
border-radius: 3px;
`;
// $FlowIssue - https://github.com/facebook/flow/issues/6103
export default React.forwardRef((props, ref) => (
<Frame {...props} forwardedRef={ref} />
));

38
app/embeds/index.js Normal file
View File

@ -0,0 +1,38 @@
// @flow
import Airtable from './Airtable';
import Codepen from './Codepen';
import Figma from './Figma';
import Framer from './Framer';
import Gist from './Gist';
import InVision from './InVision';
import Loom from './Loom';
import Lucidchart from './Lucidchart';
import Marvel from './Marvel';
import ModeAnalytics from './ModeAnalytics';
import Numeracy from './Numeracy';
import RealtimeBoard from './RealtimeBoard';
import Spotify from './Spotify';
import Trello from './Trello';
import Typeform from './Typeform';
import Vimeo from './Vimeo';
import YouTube from './YouTube';
export default {
Airtable,
Codepen,
Figma,
Framer,
Gist,
InVision,
Loom,
Lucidchart,
Marvel,
ModeAnalytics,
Numeracy,
RealtimeBoard,
Spotify,
Trello,
Typeform,
Vimeo,
YouTube,
};

View File

@ -18,6 +18,7 @@ type Props = {
document: Document,
className: string,
showPrint?: boolean,
showToggleEmbeds?: boolean,
};
@observer
@ -75,7 +76,14 @@ class DocumentMenu extends React.Component<Props> {
};
render() {
const { document, label, className, showPrint, auth } = this.props;
const {
document,
label,
className,
showPrint,
showToggleEmbeds,
auth,
} = this.props;
const canShareDocuments = auth.team && auth.team.sharing;
return (
@ -106,6 +114,19 @@ class DocumentMenu extends React.Component<Props> {
Share link
</DropdownMenuItem>
)}
{showToggleEmbeds && (
<React.Fragment>
{document.embedsDisabled ? (
<DropdownMenuItem onClick={document.enableEmbeds}>
Enable embeds
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={document.disableEmbeds}>
Disable embeds
</DropdownMenuItem>
)}
</React.Fragment>
)}
<hr />
<DropdownMenuItem onClick={this.handleDocumentHistory}>
Document history

View File

@ -1,5 +1,5 @@
// @flow
import { action, set, computed } from 'mobx';
import { action, set, computed, observable } from 'mobx';
import invariant from 'invariant';
import { client } from 'utils/ApiClient';
@ -42,6 +42,7 @@ export default class Document extends BaseModel {
shareUrl: ?string;
views: number;
revision: number;
@observable embedsDisabled: ?boolean;
constructor(data?: Object = {}, store: *) {
super(data, store);
@ -143,6 +144,16 @@ export default class Document extends BaseModel {
}
};
@action
enableEmbeds = () => {
this.embedsDisabled = false;
};
@action
disableEmbeds = () => {
this.embedsDisabled = true;
};
@action
star = async () => {
this.starred = true;

View File

@ -8,6 +8,7 @@ class Team extends BaseModel {
slackConnected: boolean;
googleConnected: boolean;
sharing: boolean;
documentEmbeds: boolean;
subdomain: ?string;
url: string;
}

View File

@ -277,7 +277,8 @@ class DocumentScene extends React.Component<Props> {
};
render() {
const { location, match } = this.props;
const { location, auth, match } = this.props;
const team = auth.team;
const Editor = this.editorComponent;
const document = this.document;
const revision = this.revision;
@ -296,7 +297,7 @@ class DocumentScene extends React.Component<Props> {
);
}
if (!document || !Editor) {
if (!document || !team || !Editor) {
return (
<Container column auto>
<PageTitle title={location.state ? location.state.title : ''} />
@ -307,6 +308,8 @@ class DocumentScene extends React.Component<Props> {
);
}
const embedsDisabled = document.embedsDisabled || !team.documentEmbeds;
return (
<ErrorBoundary>
<Container
@ -359,10 +362,12 @@ class DocumentScene extends React.Component<Props> {
)}
<MaxWidth column auto>
<Editor
key={embedsDisabled ? 'embeds-disabled' : 'embeds-enabled'}
titlePlaceholder="Start with a title…"
bodyPlaceholder="…the rest is your canvas"
defaultValue={revision ? revision.text : document.text}
pretitle={document.emoji}
disableEmbeds={embedsDisabled}
onImageUploadStart={this.onImageUploadStart}
onImageUploadStop={this.onImageUploadStop}
onSearchLink={this.onSearchLink}

View File

@ -95,6 +95,7 @@ class Header extends React.Component<Props> {
auth,
} = this.props;
const canShareDocuments = auth.team && auth.team.sharing;
const canToggleEmbeds = auth.team && auth.team.documentEmbeds;
return (
<Actions
@ -168,7 +169,11 @@ class Header extends React.Component<Props> {
)}
{!isEditing && (
<Action>
<DocumentMenu document={document} showPrint />
<DocumentMenu
document={document}
showToggleEmbeds={canToggleEmbeds}
showPrint
/>
</Action>
)}
{!isEditing &&

View File

@ -21,10 +21,12 @@ class Security extends React.Component<Props> {
form: ?HTMLFormElement;
@observable sharing: boolean;
@observable documentEmbeds: boolean;
componentDidMount() {
const { auth } = this.props;
if (auth.team) {
this.documentEmbeds = auth.team.documentEmbeds;
this.sharing = auth.team.sharing;
}
}
@ -34,13 +36,18 @@ class Security extends React.Component<Props> {
await this.props.auth.updateTeam({
sharing: this.sharing,
documentEmbeds: this.documentEmbeds,
});
this.props.ui.showToast('Settings saved', 'success');
};
handleChange = (ev: SyntheticInputEvent<*>) => {
if (ev.target.name === 'sharing') {
this.sharing = ev.target.checked;
switch (ev.target.name) {
case 'sharing':
return (this.sharing = ev.target.checked);
case 'documentEmbeds':
return (this.documentEmbeds = ev.target.checked);
default:
}
};
@ -56,18 +63,25 @@ class Security extends React.Component<Props> {
<PageTitle title="Security" />
<h1>Security</h1>
<HelpText>
Settings that impact the access, security and privacy of your
Settings that impact the access, security and content of your
knowledgebase.
</HelpText>
<form onSubmit={this.handleSubmit} ref={ref => (this.form = ref)}>
<Checkbox
label="Allow sharing documents"
label="Public document sharing"
name="sharing"
checked={this.sharing}
onChange={this.handleChange}
note="When enabled documents can be shared publicly by any team member"
/>
<Checkbox
label="Rich service embeds"
name="documentEmbeds"
checked={this.documentEmbeds}
onChange={this.handleChange}
note="Convert links to supported services into rich embeds within your documents"
/>
<Button type="submit" disabled={isSaving || !this.isValid}>
{isSaving ? 'Saving…' : 'Save'}
</Button>

View File

@ -159,7 +159,7 @@
"react-waypoint": "^7.3.1",
"redis": "^2.6.2",
"redis-lock": "^0.1.0",
"rich-markdown-editor": "^6.1.1",
"rich-markdown-editor": "^7.0.0-1",
"safestart": "1.1.0",
"sequelize": "4.28.6",
"sequelize-cli": "^2.7.0",

View File

@ -11,7 +11,7 @@ const { authorize } = policy;
const router = new Router();
router.post('team.update', auth(), async ctx => {
const { name, avatarUrl, subdomain, sharing } = ctx.body;
const { name, avatarUrl, subdomain, sharing, documentEmbeds } = ctx.body;
const endpoint = publicS3Endpoint();
const user = ctx.state.user;
@ -24,6 +24,7 @@ router.post('team.update', auth(), async ctx => {
if (name) team.name = name;
if (sharing !== undefined) team.sharing = sharing;
if (documentEmbeds !== undefined) team.documentEmbeds = documentEmbeds;
if (avatarUrl && avatarUrl.startsWith(`${endpoint}/uploads/${user.id}`)) {
team.avatarUrl = avatarUrl;
}

View File

@ -0,0 +1,12 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('teams', 'documentEmbeds', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('teams', 'documentEmbeds');
}
}

View File

@ -44,6 +44,11 @@ const Team = sequelize.define(
googleId: { type: DataTypes.STRING, allowNull: true },
avatarUrl: { type: DataTypes.STRING, allowNull: true },
sharing: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true },
documentEmbeds: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
slackData: DataTypes.JSONB,
},
{

View File

@ -11,6 +11,7 @@ function present(ctx: Object, team: Team) {
slackConnected: !!team.slackId,
googleConnected: !!team.googleId,
sharing: team.sharing,
documentEmbeds: team.documentEmbeds,
subdomain: team.subdomain,
url: team.url,
};

View File

@ -8986,9 +8986,9 @@ retry-axios@0.3.2, retry-axios@^0.3.2:
version "0.3.2"
resolved "https://registry.yarnpkg.com/retry-axios/-/retry-axios-0.3.2.tgz#5757c80f585b4cc4c4986aa2ffd47a60c6d35e13"
rich-markdown-editor@^6.1.1:
version "6.1.1"
resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-6.1.1.tgz#f3be0e5f43a804cc6c32b3cc6810907080494b54"
rich-markdown-editor@^7.0.0-1:
version "7.0.0-1"
resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-7.0.0-1.tgz#e587894609cedaacc7e5c35cfd278799ff420f61"
dependencies:
"@tommoor/slate-drop-or-paste-images" "^0.8.1"
babel-plugin-transform-async-to-generator "^6.24.1"
@ -9009,7 +9009,7 @@ rich-markdown-editor@^6.1.1:
slate-collapse-on-escape "^0.6.1"
slate-edit-code "^0.15.5"
slate-edit-list "^0.11.3"
slate-md-serializer "^5.2.0"
slate-md-serializer "^5.2.2"
slate-paste-linkify "^0.5.1"
slate-plain-serializer "0.5.4"
slate-prism "^0.5.0"
@ -9381,9 +9381,9 @@ slate-edit-list@^0.11.3:
version "0.11.3"
resolved "https://registry.yarnpkg.com/slate-edit-list/-/slate-edit-list-0.11.3.tgz#d27ff2ff93a83bef49131a6a44b87a9558c9d44c"
slate-md-serializer@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/slate-md-serializer/-/slate-md-serializer-5.2.0.tgz#bc45a338f8cdc483f9b501ab8543cb920992376f"
slate-md-serializer@^5.2.2:
version "5.2.2"
resolved "https://registry.yarnpkg.com/slate-md-serializer/-/slate-md-serializer-5.2.2.tgz#86003e476746af3361d6ccf61c1cb41e9494eb6e"
dependencies:
hashtag-regex "^2.0.0"