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:
@ -3,12 +3,15 @@ import * as React from 'react';
|
|||||||
import RichMarkdownEditor from 'rich-markdown-editor';
|
import RichMarkdownEditor from 'rich-markdown-editor';
|
||||||
import { uploadFile } from 'utils/uploadFile';
|
import { uploadFile } from 'utils/uploadFile';
|
||||||
import isInternalUrl from 'utils/isInternalUrl';
|
import isInternalUrl from 'utils/isInternalUrl';
|
||||||
|
import Embed from './Embed';
|
||||||
|
import embeds from '../../embeds';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
titlePlaceholder?: string,
|
titlePlaceholder?: string,
|
||||||
bodyPlaceholder?: string,
|
bodyPlaceholder?: string,
|
||||||
defaultValue?: string,
|
defaultValue?: string,
|
||||||
readOnly?: boolean,
|
readOnly?: boolean,
|
||||||
|
disableEmbeds?: boolean,
|
||||||
forwardedRef: *,
|
forwardedRef: *,
|
||||||
history: *,
|
history: *,
|
||||||
ui: *,
|
ui: *,
|
||||||
@ -51,6 +54,22 @@ class Editor extends React.Component<Props> {
|
|||||||
this.props.ui.showToast(message, 'success');
|
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() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<RichMarkdownEditor
|
<RichMarkdownEditor
|
||||||
@ -58,6 +77,7 @@ class Editor extends React.Component<Props> {
|
|||||||
uploadImage={this.onUploadImage}
|
uploadImage={this.onUploadImage}
|
||||||
onClickLink={this.onClickLink}
|
onClickLink={this.onClickLink}
|
||||||
onShowToast={this.onShowToast}
|
onShowToast={this.onShowToast}
|
||||||
|
getLinkComponent={this.getLinkComponent}
|
||||||
{...this.props}
|
{...this.props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
52
app/components/Editor/Embed.js
Normal file
52
app/components/Editor/Embed.js
Normal 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'};
|
||||||
|
`;
|
3
app/components/Editor/index.js
Normal file
3
app/components/Editor/index.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
// @flow
|
||||||
|
import Editor from './Editor';
|
||||||
|
export default Editor;
|
27
app/embeds/Airtable.js
Normal file
27
app/embeds/Airtable.js
Normal 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
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
23
app/embeds/Airtable.test.js
Normal file
23
app/embeds/Airtable.test.js
Normal 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
19
app/embeds/Codepen.js
Normal 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" />;
|
||||||
|
}
|
||||||
|
}
|
24
app/embeds/Codepen.test.js
Normal file
24
app/embeds/Codepen.test.js
Normal 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
27
app/embeds/Figma.js
Normal 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
24
app/embeds/Figma.test.js
Normal 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
17
app/embeds/Framer.js
Normal 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
15
app/embeds/Framer.test.js
Normal 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
70
app/embeds/Gist.js
Normal 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
19
app/embeds/Gist.test.js
Normal 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
19
app/embeds/InVision.js
Normal 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" />;
|
||||||
|
}
|
||||||
|
}
|
23
app/embeds/InVision.test.js
Normal file
23
app/embeds/InVision.test.js
Normal 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
26
app/embeds/Loom.js
Normal 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
28
app/embeds/Loom.test.js
Normal 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
26
app/embeds/Lucidchart.js
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
30
app/embeds/Lucidchart.test.js
Normal file
30
app/embeds/Lucidchart.test.js
Normal 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
17
app/embeds/Marvel.js
Normal 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
16
app/embeds/Marvel.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
24
app/embeds/ModeAnalytics.js
Normal file
24
app/embeds/ModeAnalytics.js
Normal 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" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
19
app/embeds/ModeAnalytics.test.js
Normal file
19
app/embeds/ModeAnalytics.test.js
Normal 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
22
app/embeds/Numeracy.js
Normal 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 />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
22
app/embeds/Numeracy.test.js
Normal file
22
app/embeds/Numeracy.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
26
app/embeds/RealtimeBoard.js
Normal file
26
app/embeds/RealtimeBoard.js
Normal 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})`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
18
app/embeds/RealtimeBoard.test.js
Normal file
18
app/embeds/RealtimeBoard.test.js
Normal 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
36
app/embeds/Spotify.js
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
29
app/embeds/Spotify.test.js
Normal file
29
app/embeds/Spotify.test.js
Normal 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
39
app/embeds/Trello.js
Normal 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
19
app/embeds/Typeform.js
Normal 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" />;
|
||||||
|
}
|
||||||
|
}
|
19
app/embeds/Typeform.test.js
Normal file
19
app/embeds/Typeform.test.js
Normal 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
28
app/embeds/Vimeo.js
Normal 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
20
app/embeds/Vimeo.test.js
Normal 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
28
app/embeds/YouTube.js
Normal 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})`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
33
app/embeds/YouTube.test.js
Normal file
33
app/embeds/YouTube.test.js
Normal 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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
82
app/embeds/components/Frame.js
Normal file
82
app/embeds/components/Frame.js
Normal 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
38
app/embeds/index.js
Normal 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,
|
||||||
|
};
|
@ -18,6 +18,7 @@ type Props = {
|
|||||||
document: Document,
|
document: Document,
|
||||||
className: string,
|
className: string,
|
||||||
showPrint?: boolean,
|
showPrint?: boolean,
|
||||||
|
showToggleEmbeds?: boolean,
|
||||||
};
|
};
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
@ -75,7 +76,14 @@ class DocumentMenu extends React.Component<Props> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
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;
|
const canShareDocuments = auth.team && auth.team.sharing;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -106,6 +114,19 @@ class DocumentMenu extends React.Component<Props> {
|
|||||||
Share link…
|
Share link…
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{showToggleEmbeds && (
|
||||||
|
<React.Fragment>
|
||||||
|
{document.embedsDisabled ? (
|
||||||
|
<DropdownMenuItem onClick={document.enableEmbeds}>
|
||||||
|
Enable embeds
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem onClick={document.disableEmbeds}>
|
||||||
|
Disable embeds
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
<hr />
|
<hr />
|
||||||
<DropdownMenuItem onClick={this.handleDocumentHistory}>
|
<DropdownMenuItem onClick={this.handleDocumentHistory}>
|
||||||
Document history
|
Document history
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import { action, set, computed } from 'mobx';
|
import { action, set, computed, observable } from 'mobx';
|
||||||
import invariant from 'invariant';
|
import invariant from 'invariant';
|
||||||
|
|
||||||
import { client } from 'utils/ApiClient';
|
import { client } from 'utils/ApiClient';
|
||||||
@ -42,6 +42,7 @@ export default class Document extends BaseModel {
|
|||||||
shareUrl: ?string;
|
shareUrl: ?string;
|
||||||
views: number;
|
views: number;
|
||||||
revision: number;
|
revision: number;
|
||||||
|
@observable embedsDisabled: ?boolean;
|
||||||
|
|
||||||
constructor(data?: Object = {}, store: *) {
|
constructor(data?: Object = {}, store: *) {
|
||||||
super(data, 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
|
@action
|
||||||
star = async () => {
|
star = async () => {
|
||||||
this.starred = true;
|
this.starred = true;
|
||||||
|
@ -8,6 +8,7 @@ class Team extends BaseModel {
|
|||||||
slackConnected: boolean;
|
slackConnected: boolean;
|
||||||
googleConnected: boolean;
|
googleConnected: boolean;
|
||||||
sharing: boolean;
|
sharing: boolean;
|
||||||
|
documentEmbeds: boolean;
|
||||||
subdomain: ?string;
|
subdomain: ?string;
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
@ -277,7 +277,8 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { location, match } = this.props;
|
const { location, auth, match } = this.props;
|
||||||
|
const team = auth.team;
|
||||||
const Editor = this.editorComponent;
|
const Editor = this.editorComponent;
|
||||||
const document = this.document;
|
const document = this.document;
|
||||||
const revision = this.revision;
|
const revision = this.revision;
|
||||||
@ -296,7 +297,7 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!document || !Editor) {
|
if (!document || !team || !Editor) {
|
||||||
return (
|
return (
|
||||||
<Container column auto>
|
<Container column auto>
|
||||||
<PageTitle title={location.state ? location.state.title : ''} />
|
<PageTitle title={location.state ? location.state.title : ''} />
|
||||||
@ -307,6 +308,8 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const embedsDisabled = document.embedsDisabled || !team.documentEmbeds;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Container
|
<Container
|
||||||
@ -359,10 +362,12 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
)}
|
)}
|
||||||
<MaxWidth column auto>
|
<MaxWidth column auto>
|
||||||
<Editor
|
<Editor
|
||||||
|
key={embedsDisabled ? 'embeds-disabled' : 'embeds-enabled'}
|
||||||
titlePlaceholder="Start with a title…"
|
titlePlaceholder="Start with a title…"
|
||||||
bodyPlaceholder="…the rest is your canvas"
|
bodyPlaceholder="…the rest is your canvas"
|
||||||
defaultValue={revision ? revision.text : document.text}
|
defaultValue={revision ? revision.text : document.text}
|
||||||
pretitle={document.emoji}
|
pretitle={document.emoji}
|
||||||
|
disableEmbeds={embedsDisabled}
|
||||||
onImageUploadStart={this.onImageUploadStart}
|
onImageUploadStart={this.onImageUploadStart}
|
||||||
onImageUploadStop={this.onImageUploadStop}
|
onImageUploadStop={this.onImageUploadStop}
|
||||||
onSearchLink={this.onSearchLink}
|
onSearchLink={this.onSearchLink}
|
||||||
|
@ -95,6 +95,7 @@ class Header extends React.Component<Props> {
|
|||||||
auth,
|
auth,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
const canShareDocuments = auth.team && auth.team.sharing;
|
const canShareDocuments = auth.team && auth.team.sharing;
|
||||||
|
const canToggleEmbeds = auth.team && auth.team.documentEmbeds;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Actions
|
<Actions
|
||||||
@ -168,7 +169,11 @@ class Header extends React.Component<Props> {
|
|||||||
)}
|
)}
|
||||||
{!isEditing && (
|
{!isEditing && (
|
||||||
<Action>
|
<Action>
|
||||||
<DocumentMenu document={document} showPrint />
|
<DocumentMenu
|
||||||
|
document={document}
|
||||||
|
showToggleEmbeds={canToggleEmbeds}
|
||||||
|
showPrint
|
||||||
|
/>
|
||||||
</Action>
|
</Action>
|
||||||
)}
|
)}
|
||||||
{!isEditing &&
|
{!isEditing &&
|
||||||
|
@ -21,10 +21,12 @@ class Security extends React.Component<Props> {
|
|||||||
form: ?HTMLFormElement;
|
form: ?HTMLFormElement;
|
||||||
|
|
||||||
@observable sharing: boolean;
|
@observable sharing: boolean;
|
||||||
|
@observable documentEmbeds: boolean;
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
const { auth } = this.props;
|
const { auth } = this.props;
|
||||||
if (auth.team) {
|
if (auth.team) {
|
||||||
|
this.documentEmbeds = auth.team.documentEmbeds;
|
||||||
this.sharing = auth.team.sharing;
|
this.sharing = auth.team.sharing;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -34,13 +36,18 @@ class Security extends React.Component<Props> {
|
|||||||
|
|
||||||
await this.props.auth.updateTeam({
|
await this.props.auth.updateTeam({
|
||||||
sharing: this.sharing,
|
sharing: this.sharing,
|
||||||
|
documentEmbeds: this.documentEmbeds,
|
||||||
});
|
});
|
||||||
this.props.ui.showToast('Settings saved', 'success');
|
this.props.ui.showToast('Settings saved', 'success');
|
||||||
};
|
};
|
||||||
|
|
||||||
handleChange = (ev: SyntheticInputEvent<*>) => {
|
handleChange = (ev: SyntheticInputEvent<*>) => {
|
||||||
if (ev.target.name === 'sharing') {
|
switch (ev.target.name) {
|
||||||
this.sharing = ev.target.checked;
|
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" />
|
<PageTitle title="Security" />
|
||||||
<h1>Security</h1>
|
<h1>Security</h1>
|
||||||
<HelpText>
|
<HelpText>
|
||||||
Settings that impact the access, security and privacy of your
|
Settings that impact the access, security and content of your
|
||||||
knowledgebase.
|
knowledgebase.
|
||||||
</HelpText>
|
</HelpText>
|
||||||
|
|
||||||
<form onSubmit={this.handleSubmit} ref={ref => (this.form = ref)}>
|
<form onSubmit={this.handleSubmit} ref={ref => (this.form = ref)}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label="Allow sharing documents"
|
label="Public document sharing"
|
||||||
name="sharing"
|
name="sharing"
|
||||||
checked={this.sharing}
|
checked={this.sharing}
|
||||||
onChange={this.handleChange}
|
onChange={this.handleChange}
|
||||||
note="When enabled documents can be shared publicly by any team member"
|
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}>
|
<Button type="submit" disabled={isSaving || !this.isValid}>
|
||||||
{isSaving ? 'Saving…' : 'Save'}
|
{isSaving ? 'Saving…' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -159,7 +159,7 @@
|
|||||||
"react-waypoint": "^7.3.1",
|
"react-waypoint": "^7.3.1",
|
||||||
"redis": "^2.6.2",
|
"redis": "^2.6.2",
|
||||||
"redis-lock": "^0.1.0",
|
"redis-lock": "^0.1.0",
|
||||||
"rich-markdown-editor": "^6.1.1",
|
"rich-markdown-editor": "^7.0.0-1",
|
||||||
"safestart": "1.1.0",
|
"safestart": "1.1.0",
|
||||||
"sequelize": "4.28.6",
|
"sequelize": "4.28.6",
|
||||||
"sequelize-cli": "^2.7.0",
|
"sequelize-cli": "^2.7.0",
|
||||||
|
@ -11,7 +11,7 @@ const { authorize } = policy;
|
|||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
|
||||||
router.post('team.update', auth(), async ctx => {
|
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 endpoint = publicS3Endpoint();
|
||||||
|
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
@ -24,6 +24,7 @@ router.post('team.update', auth(), async ctx => {
|
|||||||
|
|
||||||
if (name) team.name = name;
|
if (name) team.name = name;
|
||||||
if (sharing !== undefined) team.sharing = sharing;
|
if (sharing !== undefined) team.sharing = sharing;
|
||||||
|
if (documentEmbeds !== undefined) team.documentEmbeds = documentEmbeds;
|
||||||
if (avatarUrl && avatarUrl.startsWith(`${endpoint}/uploads/${user.id}`)) {
|
if (avatarUrl && avatarUrl.startsWith(`${endpoint}/uploads/${user.id}`)) {
|
||||||
team.avatarUrl = avatarUrl;
|
team.avatarUrl = avatarUrl;
|
||||||
}
|
}
|
||||||
|
12
server/migrations/20181215192422-document-embeds.js
Normal file
12
server/migrations/20181215192422-document-embeds.js
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
@ -44,6 +44,11 @@ const Team = sequelize.define(
|
|||||||
googleId: { type: DataTypes.STRING, allowNull: true },
|
googleId: { type: DataTypes.STRING, allowNull: true },
|
||||||
avatarUrl: { type: DataTypes.STRING, allowNull: true },
|
avatarUrl: { type: DataTypes.STRING, allowNull: true },
|
||||||
sharing: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true },
|
sharing: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true },
|
||||||
|
documentEmbeds: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: true,
|
||||||
|
},
|
||||||
slackData: DataTypes.JSONB,
|
slackData: DataTypes.JSONB,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -11,6 +11,7 @@ function present(ctx: Object, team: Team) {
|
|||||||
slackConnected: !!team.slackId,
|
slackConnected: !!team.slackId,
|
||||||
googleConnected: !!team.googleId,
|
googleConnected: !!team.googleId,
|
||||||
sharing: team.sharing,
|
sharing: team.sharing,
|
||||||
|
documentEmbeds: team.documentEmbeds,
|
||||||
subdomain: team.subdomain,
|
subdomain: team.subdomain,
|
||||||
url: team.url,
|
url: team.url,
|
||||||
};
|
};
|
||||||
|
14
yarn.lock
14
yarn.lock
@ -8986,9 +8986,9 @@ retry-axios@0.3.2, retry-axios@^0.3.2:
|
|||||||
version "0.3.2"
|
version "0.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/retry-axios/-/retry-axios-0.3.2.tgz#5757c80f585b4cc4c4986aa2ffd47a60c6d35e13"
|
resolved "https://registry.yarnpkg.com/retry-axios/-/retry-axios-0.3.2.tgz#5757c80f585b4cc4c4986aa2ffd47a60c6d35e13"
|
||||||
|
|
||||||
rich-markdown-editor@^6.1.1:
|
rich-markdown-editor@^7.0.0-1:
|
||||||
version "6.1.1"
|
version "7.0.0-1"
|
||||||
resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-6.1.1.tgz#f3be0e5f43a804cc6c32b3cc6810907080494b54"
|
resolved "https://registry.yarnpkg.com/rich-markdown-editor/-/rich-markdown-editor-7.0.0-1.tgz#e587894609cedaacc7e5c35cfd278799ff420f61"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@tommoor/slate-drop-or-paste-images" "^0.8.1"
|
"@tommoor/slate-drop-or-paste-images" "^0.8.1"
|
||||||
babel-plugin-transform-async-to-generator "^6.24.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-collapse-on-escape "^0.6.1"
|
||||||
slate-edit-code "^0.15.5"
|
slate-edit-code "^0.15.5"
|
||||||
slate-edit-list "^0.11.3"
|
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-paste-linkify "^0.5.1"
|
||||||
slate-plain-serializer "0.5.4"
|
slate-plain-serializer "0.5.4"
|
||||||
slate-prism "^0.5.0"
|
slate-prism "^0.5.0"
|
||||||
@ -9381,9 +9381,9 @@ slate-edit-list@^0.11.3:
|
|||||||
version "0.11.3"
|
version "0.11.3"
|
||||||
resolved "https://registry.yarnpkg.com/slate-edit-list/-/slate-edit-list-0.11.3.tgz#d27ff2ff93a83bef49131a6a44b87a9558c9d44c"
|
resolved "https://registry.yarnpkg.com/slate-edit-list/-/slate-edit-list-0.11.3.tgz#d27ff2ff93a83bef49131a6a44b87a9558c9d44c"
|
||||||
|
|
||||||
slate-md-serializer@^5.2.0:
|
slate-md-serializer@^5.2.2:
|
||||||
version "5.2.0"
|
version "5.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/slate-md-serializer/-/slate-md-serializer-5.2.0.tgz#bc45a338f8cdc483f9b501ab8543cb920992376f"
|
resolved "https://registry.yarnpkg.com/slate-md-serializer/-/slate-md-serializer-5.2.2.tgz#86003e476746af3361d6ccf61c1cb41e9494eb6e"
|
||||||
dependencies:
|
dependencies:
|
||||||
hashtag-regex "^2.0.0"
|
hashtag-regex "^2.0.0"
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user