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:
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,
|
||||
};
|
||||
Reference in New Issue
Block a user