Collaborative editing (#1660)

This commit is contained in:
Tom Moor
2021-09-10 22:46:57 -07:00
committed by GitHub
parent 0a998789a3
commit 801f6681ba
144 changed files with 3552 additions and 310 deletions

30
shared/embeds/Abstract.js Normal file
View File

@@ -0,0 +1,30 @@
// @flow
import * as React from "react";
import Frame from "./components/Frame";
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class Abstract extends React.Component<Props> {
static ENABLED = [
new RegExp("https?://share.(?:go)?abstract.com/(.*)$"),
new RegExp("https?://app.(?:go)?abstract.com/(?:share|embed)/(.*)$"),
];
render() {
const { matches } = this.props.attrs;
const shareId = matches[1];
return (
<Frame
{...this.props}
src={`https://app.goabstract.com/embed/${shareId}`}
title={`Abstract (${shareId})`}
/>
);
}
}

View File

@@ -0,0 +1,58 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import Abstract from "./Abstract";
describe("Abstract", () => {
const match = Abstract.ENABLED[0];
const match2 = Abstract.ENABLED[1];
test("to be enabled on share subdomain link", () => {
expect(
"https://share.goabstract.com/aaec8bba-f473-4f64-96e7-bff41c70ff8a".match(
match
)
).toBeTruthy();
expect(
"https://share.abstract.com/aaec8bba-f473-4f64-96e7-bff41c70ff8a".match(
match
)
).toBeTruthy();
});
test("to be enabled on share link", () => {
expect(
"https://app.goabstract.com/share/aaec8bba-f473-4f64-96e7-bff41c70ff8a".match(
match2
)
).toBeTruthy();
expect(
"https://app.abstract.com/share/aaec8bba-f473-4f64-96e7-bff41c70ff8a".match(
match2
)
).toBeTruthy();
});
test("to be enabled on embed link", () => {
expect(
"https://app.goabstract.com/embed/aaec8bba-f473-4f64-96e7-bff41c70ff8a".match(
match2
)
).toBeTruthy();
expect(
"https://app.abstract.com/embed/aaec8bba-f473-4f64-96e7-bff41c70ff8a".match(
match2
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect("https://abstract.com".match(match)).toBe(null);
expect("https://goabstract.com".match(match)).toBe(null);
expect("https://app.goabstract.com".match(match)).toBe(null);
expect("https://abstract.com/features".match(match)).toBe(null);
expect("https://app.abstract.com/home".match(match)).toBe(null);
expect("https://abstract.com/pricing".match(match)).toBe(null);
expect("https://goabstract.com/pricing".match(match)).toBe(null);
expect("https://www.goabstract.com/pricing".match(match)).toBe(null);
});
});

30
shared/embeds/Airtable.js Normal file
View File

@@ -0,0 +1,30 @@
// @flow
import * as React from "react";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp("https://airtable.com/(?:embed/)?(shr.*)$");
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class Airtable extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
const { matches } = this.props.attrs;
const shareId = matches[1];
return (
<Frame
{...this.props}
src={`https://airtable.com/embed/${shareId}`}
title={`Airtable (${shareId})`}
border
/>
);
}
}

View File

@@ -0,0 +1,21 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import Airtable from "./Airtable";
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);
});
});

31
shared/embeds/Cawemo.js Normal file
View File

@@ -0,0 +1,31 @@
// @flow
import * as React from "react";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp("https?://cawemo.com/(?:share|embed)/(.*)$");
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class Cawemo extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
const { matches } = this.props.attrs;
const shareId = matches[1];
return (
<Frame
{...this.props}
src={`https://cawemo.com/embed/${shareId}`}
title={"Cawemo Embed"}
border
allowfullscreen
/>
);
}
}

View File

@@ -0,0 +1,22 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import Cawemo from "./Cawemo";
describe("Cawemo", () => {
const match = Cawemo.ENABLED[0];
test("to be enabled on embed link", () => {
expect(
"https://cawemo.com/embed/a82e9f22-e283-4253-8d11".match(match)
).toBeTruthy();
});
test("to be enabled on share link", () => {
expect(
"https://cawemo.com/embed/a82e9f22-e283-4253-8d11".match(match)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect("https://cawemo.com/".match(match)).toBe(null);
expect("https://cawemo.com/diagrams".match(match)).toBe(null);
});
});

28
shared/embeds/ClickUp.js Normal file
View File

@@ -0,0 +1,28 @@
// @flow
import * as React from "react";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp(
"^https?://share.clickup.com/[a-z]/[a-z]/(.*)/(.*)$"
);
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class ClickUp extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="ClickUp Embed"
/>
);
}
}

View File

@@ -0,0 +1,17 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import ClickUp from "./ClickUp";
describe("ClickUp", () => {
const match = ClickUp.ENABLED[0];
test("to be enabled on share link", () => {
expect(
"https://share.clickup.com/b/h/6-9310960-2/c9d837d74182317".match(match)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect("https://share.clickup.com".match(match)).toBe(null);
expect("https://clickup.com/".match(match)).toBe(null);
expect("https://clickup.com/features".match(match)).toBe(null);
});
});

22
shared/embeds/Codepen.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://codepen.io/(.*?)/(pen|embed)/(.*)$");
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class Codepen extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
const normalizedUrl = this.props.attrs.href.replace(/\/pen\//, "/embed/");
return <Frame {...this.props} src={normalizedUrl} title="Codepen Embed" />;
}
}

View File

@@ -0,0 +1,22 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import Codepen from "./Codepen";
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);
});
});

28
shared/embeds/Descript.js Normal file
View File

@@ -0,0 +1,28 @@
// @flow
import * as React from "react";
import Frame from "./components/Frame";
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class Descript extends React.Component<Props> {
static ENABLED = [new RegExp("https?://share.descript.com/view/(\\w+)$")];
render() {
const { matches } = this.props.attrs;
const shareId = matches[1];
return (
<Frame
{...this.props}
src={`https://share.descript.com/embed/${shareId}`}
title={`Descript (${shareId})`}
width="400px"
/>
);
}
}

52
shared/embeds/Diagrams.js Normal file
View File

@@ -0,0 +1,52 @@
// @flow
import * as React from "react";
import Frame from "./components/Frame";
import Image from "./components/Image";
const URL_REGEX = new RegExp("^https://viewer.diagrams.net/.*(title=\\w+)?");
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class Diagrams extends React.Component<Props> {
static ENABLED = [URL_REGEX];
get embedUrl() {
return this.props.attrs.matches[0];
}
get title() {
let title = "Diagrams.net";
const url = new URL(this.embedUrl);
const documentTitle = url.searchParams.get("title");
if (documentTitle) {
title += ` (${documentTitle})`;
}
return title;
}
render() {
return (
<Frame
{...this.props}
src={this.embedUrl}
title={this.title}
border
icon={
<Image
src="/images/diagrams.png"
alt="Diagrams.net"
width={16}
height={16}
/>
}
/>
);
}
}

View File

@@ -0,0 +1,20 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import Diagrams from "./Diagrams";
describe("Diagrams", () => {
const match = Diagrams.ENABLED[0];
test("to be enabled on viewer link", () => {
expect(
"https://viewer.diagrams.net/?target=blank&nav=1#ABCDefgh_A12345-6789".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect("https://app.diagrams.net/#ABCDefgh_A12345-6789".match(match)).toBe(
null
);
});
});

29
shared/embeds/Figma.js Normal file
View File

@@ -0,0 +1,29 @@
// @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 = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class Figma extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
{...this.props}
src={`https://www.figma.com/embed?embed_host=outline&url=${this.props.attrs.href}`}
title="Figma Embed"
border
/>
);
}
}

View File

@@ -0,0 +1,22 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import Figma from "./Figma";
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);
});
});

27
shared/embeds/Framer.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://framer.cloud/(.*)$");
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class Framer extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="Framer Embed"
border
/>
);
}
}

View File

@@ -0,0 +1,13 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import Framer from "./Framer";
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);
});
});

78
shared/embeds/Gist.js Normal file
View File

@@ -0,0 +1,78 @@
// @flow
import * as React from "react";
const URL_REGEX = new RegExp(
"^https://gist.github.com/([a-z\\d](?:[a-z\\d]|-(?=[a-z\\d])){0,38})/(.*)$"
);
type Props = {|
isSelected: boolean,
attrs: {|
href: string,
matches: string[],
|},
|};
class Gist extends React.Component<Props> {
static ENABLED = [URL_REGEX];
ref = React.createRef<HTMLIFrameElement>();
get id() {
const gistUrl = new URL(this.props.attrs.href);
return gistUrl.pathname.split("/")[2];
}
componentDidMount() {
this.updateIframeContent();
}
componentDidUpdate() {
this.updateIframeContent();
}
updateIframeContent = () => {
const iframe = this.ref.current;
if (!iframe) return;
const id = this.id;
// $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>`;
if (!doc) return;
doc.open();
doc.writeln(iframeHtml);
doc.close();
};
render() {
const id = this.id;
return (
<iframe
className={this.props.isSelected ? "ProseMirror-selectednode" : ""}
ref={this.ref}
type="text/html"
frameBorder="0"
width="100%"
height="200px"
id={`gist-${id}`}
title={`Github Gist (${id})`}
onLoad={this.updateIframeContent}
/>
);
}
}
export default Gist;

View File

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

View File

@@ -0,0 +1,29 @@
// @flow
import * as React from "react";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp(
"^https?://calendar.google.com/calendar/embed\\?src=(.*)$"
);
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class GoogleCalendar extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="Google Calendar"
border
/>
);
}
}

View File

@@ -0,0 +1,19 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import GoogleCalendar from "./GoogleCalendar";
describe("GoogleCalendar", () => {
const match = GoogleCalendar.ENABLED[0];
test("to be enabled on share link", () => {
expect(
"https://calendar.google.com/calendar/embed?src=tom%40outline.com&ctz=America%2FSao_Paulo".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect("https://calendar.google.com/calendar".match(match)).toBe(null);
expect("https://calendar.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
});
});

View File

@@ -0,0 +1,39 @@
// @flow
import * as React from "react";
import Frame from "./components/Frame";
import Image from "./components/Image";
const URL_REGEX = new RegExp(
"^https?://datastudio.google.com/(embed|u/0)/reporting/(.*)/page/(.*)(/edit)?$"
);
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class GoogleDataStudio extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href.replace("u/0", "embed").replace("/edit", "")}
icon={
<Image
src="/images/google-datastudio.png"
alt="Google Data Studio Icon"
width={16}
height={16}
/>
}
canonicalUrl={this.props.attrs.href}
title="Google Data Studio"
border
/>
);
}
}

View File

@@ -0,0 +1,19 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import GoogleDataStudio from "./GoogleDataStudio";
describe("GoogleDataStudio", () => {
const match = GoogleDataStudio.ENABLED[0];
test("to be enabled on share link", () => {
expect(
"https://datastudio.google.com/embed/reporting/aab01789-f3a2-4ff3-9cba-c4c94c4a92e8/page/7zFD".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect("https://datastudio.google.com/u/0/".match(match)).toBe(null);
expect("https://datastudio.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
});
});

View File

@@ -0,0 +1,37 @@
// @flow
import * as React from "react";
import Frame from "./components/Frame";
import Image from "./components/Image";
const URL_REGEX = new RegExp("^https?://docs.google.com/document/(.*)$");
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class GoogleDocs extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href.replace("/edit", "/preview")}
icon={
<Image
src="/images/google-docs.png"
alt="Google Docs Icon"
width={16}
height={16}
/>
}
canonicalUrl={this.props.attrs.href}
title="Google Docs"
border
/>
);
}
}

View File

@@ -0,0 +1,34 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import GoogleDocs from "./GoogleDocs";
describe("GoogleDocs", () => {
const match = GoogleDocs.ENABLED[0];
test("to be enabled on share link", () => {
expect(
"https://docs.google.com/document/d/e/2PACX-1vTdddHPoZ5M_47wmSHCoigRIt2cj_Pd-kgtaNQY6H0Jzn0_CVGbxC1GcK5IoNzU615lzguexFwxasAW/pubhtml".match(
match
)
).toBeTruthy();
expect(
"https://docs.google.com/document/d/e/2PACX-1vTdddHPoZ5M_47wmSHCoigRIt2cj_Pd-kgtaNQY6H0Jzn0_CVGbxC1GcK5IoNzU615lzguexFwxasAW/pub".match(
match
)
).toBeTruthy();
expect(
"https://docs.google.com/document/d/1SsDfWzFFTjZM2LanvpyUzjKhqVQpwpTMeiPeYxhVqOg/edit".match(
match
)
).toBeTruthy();
expect(
"https://docs.google.com/document/d/1SsDfWzFFTjZM2LanvpyUzjKhqVQpwpTMeiPeYxhVqOg/preview".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect("https://docs.google.com/document".match(match)).toBe(null);
expect("https://docs.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
});
});

View File

@@ -0,0 +1,39 @@
// @flow
import * as React from "react";
import Frame from "./components/Frame";
import Image from "./components/Image";
const URL_REGEX = new RegExp(
"^https://docs.google.com/drawings/d/(.*)/(edit|preview)(.*)$"
);
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class GoogleDrawings extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href.replace("/edit", "/preview")}
icon={
<Image
src="/images/google-drawings.png"
alt="Google Drawings"
width={16}
height={16}
/>
}
canonicalUrl={this.props.attrs.href.replace("/preview", "/edit")}
title="Google Drawings"
border
/>
);
}
}

View File

@@ -0,0 +1,29 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import GoogleDrawings from "./GoogleDrawings";
describe("GoogleDrawings", () => {
const match = GoogleDrawings.ENABLED[0];
test("to be enabled on share link", () => {
expect(
"https://docs.google.com/drawings/d/1zDLtJ4HSCnjGCGSoCgqGe3F8p6o7R8Vjk8MDR6dKf-U/edit".match(
match
)
).toBeTruthy();
expect(
"https://docs.google.com/drawings/d/1zDLtJ4HSCnjGCGSoCgqGe3F8p6o7R8Vjk8MDR6dKf-U/edit?usp=sharing".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect(
"https://docs.google.com/drawings/d/e/2PACX-1vRtzIzEWN6svSrIYZq-kq2XZEN6WaOFXHbPKRLXNOFRlxLIdJg0Vo6RfretGqs9SzD-fUazLeS594Kw/pub?w=960&h=720".match(
match
)
).toBe(null);
expect("https://docs.google.com/drawings".match(match)).toBe(null);
expect("https://docs.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
});
});

View File

@@ -0,0 +1,36 @@
// @flow
import * as React from "react";
import Frame from "./components/Frame";
import Image from "./components/Image";
const URL_REGEX = new RegExp("^https?://drive.google.com/file/d/(.*)$");
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class GoogleDrive extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
src={this.props.attrs.href.replace("/view", "/preview")}
icon={
<Image
src="/images/google-drive.png"
alt="Google Drive Icon"
width={16}
height={16}
/>
}
title="Google Drive"
canonicalUrl={this.props.attrs.href}
border
/>
);
}
}

View File

@@ -0,0 +1,30 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import GoogleDrive from "./GoogleDrive";
describe("GoogleDrive", () => {
const match = GoogleDrive.ENABLED[0];
test("to be enabled on share link", () => {
expect(
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/view?usp=sharing".match(
match
)
).toBeTruthy();
expect(
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/preview?usp=sharing".match(
match
)
).toBeTruthy();
expect(
"https://drive.google.com/file/d/1ohkOgmE8MiNx68u6ynBfYkgjeKu_x3ZK/preview?usp=sharing&resourceKey=BG8k4dEt1p2gisnVdlaSpA".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect("https://drive.google.com/file".match(match)).toBe(null);
expect("https://drive.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
});
});

View File

@@ -0,0 +1,37 @@
// @flow
import * as React from "react";
import Frame from "./components/Frame";
import Image from "./components/Image";
const URL_REGEX = new RegExp("^https?://docs.google.com/spreadsheets/d/(.*)$");
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class GoogleSheets extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href.replace("/edit", "/preview")}
icon={
<Image
src="/images/google-sheets.png"
alt="Google Sheets Icon"
width={16}
height={16}
/>
}
canonicalUrl={this.props.attrs.href}
title="Google Sheets"
border
/>
);
}
}

View File

@@ -0,0 +1,24 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import GoogleSheets from "./GoogleSheets";
describe("GoogleSheets", () => {
const match = GoogleSheets.ENABLED[0];
test("to be enabled on share link", () => {
expect(
"https://docs.google.com/spreadsheets/d/e/2PACX-1vTdddHPoZ5M_47wmSHCoigRIt2cj_Pd-kgtaNQY6H0Jzn0_CVGbxC1GcK5IoNzU615lzguexFwxasAW/pub".match(
match
)
).toBeTruthy();
expect(
"https://docs.google.com/spreadsheets/d/e/2PACX-1vTdddHPoZ5M_47wmSHCoigR/edit".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect("https://docs.google.com/spreadsheets".match(match)).toBe(null);
expect("https://docs.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
});
});

View File

@@ -0,0 +1,39 @@
// @flow
import * as React from "react";
import Frame from "./components/Frame";
import Image from "./components/Image";
const URL_REGEX = new RegExp("^https?://docs.google.com/presentation/d/(.*)$");
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class GoogleSlides extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href
.replace("/edit", "/preview")
.replace("/pub", "/embed")}
icon={
<Image
src="/images/google-slides.png"
alt="Google Slides Icon"
width={16}
height={16}
/>
}
canonicalUrl={this.props.attrs.href}
title="Google Slides"
border
/>
);
}
}

View File

@@ -0,0 +1,29 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import GoogleSlides from "./GoogleSlides";
describe("GoogleSlides", () => {
const match = GoogleSlides.ENABLED[0];
test("to be enabled on share link", () => {
expect(
"https://docs.google.com/presentation/d/e/2PACX-1vTdddHPoZ5M_47wmSHCoigRIt2cj_Pd-kgtaNQY6H0Jzn0_CVGbxC1GcK5IoNzU615lzguexFwxasAW/pub?start=false&loop=false&delayms=3000".match(
match
)
).toBeTruthy();
expect(
"https://docs.google.com/presentation/d/e/2PACX-1vTdddHPoZ5M_47wmSHCoigRIt2cj_Pd-kgtaNQY6H0Jzn0_CVGbxC1GcK5IoNzU615lzguexFwxasAW/pub".match(
match
)
).toBeTruthy();
expect(
"https://docs.google.com/presentation/d/e/2PACX-1vTdddHPoZ5M_47wmSHCoigR/edit".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect("https://docs.google.com/presentation".match(match)).toBe(null);
expect("https://docs.google.com".match(match)).toBe(null);
expect("https://www.google.com".match(match)).toBe(null);
});
});

50
shared/embeds/InVision.js Normal file
View File

@@ -0,0 +1,50 @@
// @flow
import * as React from "react";
import ImageZoom from "react-medium-image-zoom";
import Frame from "./components/Frame";
const IFRAME_REGEX = new RegExp(
"^https://(invis.io/.*)|(projects.invisionapp.com/share/.*)$"
);
const IMAGE_REGEX = new RegExp(
"^https://(opal.invisionapp.com/static-signed/live-embed/.*)$"
);
type Props = {|
isSelected: boolean,
attrs: {|
href: string,
matches: string[],
|},
|};
export default class InVision extends React.Component<Props> {
static ENABLED = [IFRAME_REGEX, IMAGE_REGEX];
render() {
if (IMAGE_REGEX.test(this.props.attrs.href)) {
return (
<ImageZoom
className={this.props.isSelected ? "ProseMirror-selectednode" : ""}
image={{
src: this.props.attrs.href,
alt: "InVision Embed",
style: {
maxWidth: "100%",
maxHeight: "75vh",
},
}}
shouldRespectMaxDimension
/>
);
}
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="InVision Embed"
/>
);
}
}

View File

@@ -0,0 +1,21 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import InVision from "./InVision";
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);
});
});

22
shared/embeds/Loom.js Normal file
View File

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

View File

@@ -0,0 +1,32 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import Loom from "./Loom";
describe("Loom", () => {
const match = Loom.ENABLED[0];
test("to be enabled on share link", () => {
expect(
"https://www.loom.com/share/55327cbb265743f39c2c442c029277e0".match(match)
).toBeTruthy();
expect(
"https://www.useloom.com/share/55327cbb265743f39c2c442c029277e0".match(
match
)
).toBeTruthy();
});
test("to be enabled on embed link", () => {
expect(
"https://www.loom.com/embed/55327cbb265743f39c2c442c029277e0".match(match)
).toBeTruthy();
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);
});
});

View File

@@ -0,0 +1,29 @@
// @flow
import * as React from "react";
import Frame from "./components/Frame";
type Props = {|
attrs: {|
href: string,
matches: Object,
|},
|};
export default class Lucidchart extends React.Component<Props> {
static ENABLED = [
/^https?:\/\/(www\.|app\.)?lucidchart.com\/documents\/(embeddedchart|view)\/(?<chartId>[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})(?:\/.*)?$/,
/^https?:\/\/(www\.|app\.)?lucid.app\/lucidchart\/(?<chartId>[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})\/(embeddedchart|view)(?:\/.*)?$/,
];
render() {
const { matches } = this.props.attrs;
const { chartId } = matches.groups;
return (
<Frame
{...this.props}
src={`https://lucidchart.com/documents/embeddedchart/${chartId}`}
title="Lucidchart Embed"
/>
);
}
}

View File

@@ -0,0 +1,49 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import Lucidchart from "./Lucidchart";
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 root link", () => {
expect(
"https://lucidchart.com/documents/view/2f4a79cb-7637-433d-8ffb-27cce65a05e7".match(
match
)
).toBeTruthy();
});
test("to be enabled on app link", () => {
expect(
"https://app.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 be enabled on embedded link", () => {
expect(
"https://app.lucidchart.com/documents/embeddedchart/1af2bdfa-da7d-4ea1-aa1d-bec5677a9837".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect("https://lucidchart.com".match(match)).toBe(null);
expect("https://app.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);
});
});

27
shared/embeds/Marvel.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://marvelapp.com/([A-Za-z0-9-]{6})/?$");
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class Marvel extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="Marvel Embed"
border
/>
);
}
}

View File

@@ -0,0 +1,14 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import Marvel from "./Marvel";
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,34 @@
// @flow
import * as React from "react";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp(
"^https://([w.-]+.)?(mindmeister.com|mm.tt)(/maps/public_map_shell)?/(\\d+)(\\?t=.*)?(/.*)?$"
);
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class Mindmeister extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
const chartId =
this.props.attrs.matches[4] +
(this.props.attrs.matches[5] || "") +
(this.props.attrs.matches[6] || "");
return (
<Frame
{...this.props}
src={`https://www.mindmeister.com/maps/public_map_shell/${chartId}`}
title="Mindmeister Embed"
border
/>
);
}
}

View File

@@ -0,0 +1,47 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import Mindmeister from "./Mindmeister";
describe("Mindmeister", () => {
const match = Mindmeister.ENABLED[0];
test("to be enabled on mm.tt link", () => {
expect("https://mm.tt/326377934".match(match)).toBeTruthy();
});
test("to be enabled on mm.tt link with token parameter", () => {
expect("https://mm.tt/326377934?t=r9NcnTRr18".match(match)).toBeTruthy();
});
test("to be enabled on embed link", () => {
expect(
"https://www.mindmeister.com/maps/public_map_shell/326377934/paper-digital-or-online-mind-mapping".match(
match
)
).toBeTruthy();
});
test("to be enabled on public link", () => {
expect(
"https://www.mindmeister.com/326377934/paper-digital-or-online-mind-mapping".match(
match
)
).toBeTruthy();
});
test("to be enabled without www", () => {
expect(
"https://mindmeister.com/326377934/paper-digital-or-online-mind-mapping".match(
match
)
).toBeTruthy();
});
test("to be enabled without slug", () => {
expect("https://mindmeister.com/326377934".match(match)).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect("https://mindmeister.com".match(match)).toBe(null);
expect("https://www.mindmeister.com/pricing".match(match)).toBe(null);
});
});

31
shared/embeds/Miro.js Normal file
View File

@@ -0,0 +1,31 @@
// @flow
import * as React from "react";
import Frame from "./components/Frame";
const URL_REGEX = /^https:\/\/(realtimeboard|miro).com\/app\/board\/(.*)$/;
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class RealtimeBoard extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
const { matches } = this.props.attrs;
const domain = matches[1];
const boardId = matches[2];
const titleName = domain === "realtimeboard" ? "RealtimeBoard" : "Miro";
return (
<Frame
{...this.props}
src={`https://${domain}.com/app/embed/${boardId}`}
title={`${titleName} (${boardId})`}
/>
);
}
}

View File

@@ -0,0 +1,27 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import Miro from "./Miro";
describe("Miro", () => {
const match = Miro.ENABLED[0];
test("to be enabled on old domain share link", () => {
expect(
"https://realtimeboard.com/app/board/o9J_k0fwiss=".match(match)
).toBeTruthy();
});
test("to be enabled on share link", () => {
expect("https://miro.com/app/board/o9J_k0fwiss=".match(match)).toBeTruthy();
});
test("to extract the domain as part of the match for later use", () => {
expect(
"https://realtimeboard.com/app/board/o9J_k0fwiss=".match(match)[1]
).toBe("realtimeboard");
});
test("to not be enabled elsewhere", () => {
expect("https://miro.com".match(match)).toBe(null);
expect("https://realtimeboard.com".match(match)).toBe(null);
expect("https://realtimeboard.com/features".match(match)).toBe(null);
});
});

View File

@@ -0,0 +1,31 @@
// @flow
import * as React from "react";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp(
"^https://([w.-]+.)?modeanalytics.com/(.*)/reports/(.*)$"
);
type Props = {|
attrs: {|
href: string,
matches: 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.attrs.href.replace(/\/embed$/, "");
return (
<Frame
{...this.props}
src={`${normalizedUrl}/embed`}
title="Mode Analytics Embed"
/>
);
}
}

View File

@@ -0,0 +1,17 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import ModeAnalytics from "./ModeAnalytics";
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);
});
});

24
shared/embeds/Prezi.js Normal file
View File

@@ -0,0 +1,24 @@
// @flow
import * as React from "react";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp("^https://prezi.com/view/(.*)$");
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class Prezi extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
const url = this.props.attrs.href.replace(/\/embed$/, "");
return (
<Frame {...this.props} src={`${url}/embed`} title="Prezi Embed" border />
);
}
}

View File

@@ -0,0 +1,22 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import Prezi from "./Prezi";
describe("Prezi", () => {
const match = Prezi.ENABLED[0];
test("to be enabled on share link", () => {
expect(
"https://prezi.com/view/39mn8Rn1ZkoeEKQCgk5C".match(match)
).toBeTruthy();
});
test("to be enabled on embed link", () => {
expect(
"https://prezi.com/view/39mn8Rn1ZkoeEKQCgk5C/embed".match(match)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect("https://prezi.com".match(match)).toBe(null);
expect("https://prezi.com/pricing".match(match)).toBe(null);
});
});

49
shared/embeds/Spotify.js Normal file
View File

@@ -0,0 +1,49 @@
// @flow
import * as React from "react";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp("https?://open.spotify.com/(.*)$");
type Props = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class Spotify extends React.Component<Props> {
static ENABLED = [URL_REGEX];
get pathname() {
try {
const parsed = new URL(this.props.attrs.href);
return parsed.pathname;
} catch (err) {
return "";
}
}
render() {
const normalizedPath = this.pathname.replace(/^\/embed/, "/");
var height;
if (normalizedPath.includes("episode") || normalizedPath.includes("show")) {
height = 232;
} else if (normalizedPath.includes("track")) {
height = 80;
} else {
height = 380;
}
return (
<Frame
{...this.props}
width="100%"
height={`${height}px`}
src={`https://open.spotify.com/embed${normalizedPath}`}
title="Spotify Embed"
allow="encrypted-media"
/>
);
}
}

View File

@@ -0,0 +1,27 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import Spotify from "./Spotify";
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);
});
});

42
shared/embeds/Trello.js Normal file
View File

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

28
shared/embeds/Typeform.js Normal file
View File

@@ -0,0 +1,28 @@
// @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 = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class Typeform extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
return (
<Frame
{...this.props}
src={this.props.attrs.href}
title="Typeform Embed"
/>
);
}
}

View File

@@ -0,0 +1,17 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import Typeform from "./Typeform";
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);
});
});

29
shared/embeds/Vimeo.js Normal file
View File

@@ -0,0 +1,29 @@
// @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 = {|
attrs: {|
href: string,
matches: string[],
|},
|};
export default class Vimeo extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
const { matches } = this.props.attrs;
const videoId = matches[4];
return (
<Frame
{...this.props}
src={`https://player.vimeo.com/video/${videoId}?byline=0`}
title={`Vimeo Embed (${videoId})`}
/>
);
}
}

View File

@@ -0,0 +1,18 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import Vimeo from "./Vimeo";
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
);
});
});

30
shared/embeds/YouTube.js Normal file
View File

@@ -0,0 +1,30 @@
// @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 = {|
isSelected: boolean,
attrs: {|
href: string,
matches: string[],
|},
|};
export default class YouTube extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
const { matches } = this.props.attrs;
const videoId = matches[1];
return (
<Frame
{...this.props}
src={`https://www.youtube.com/embed/${videoId}?modestbranding=1`}
title={`YouTube (${videoId})`}
/>
);
}
}

View File

@@ -0,0 +1,31 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import YouTube from "./YouTube";
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,146 @@
// @flow
import { observable } from "mobx";
import { observer } from "mobx-react";
import { OpenIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
// This wrapper allows us to pass non-standard HTML attributes through to the DOM element
// https://www.styled-components.com/docs/basics#passed-props
const Iframe = (props) => <iframe title="Embed" {...props} />;
const StyledIframe = styled(Iframe)`
border-radius: ${(props) => (props.$withBar ? "3px 3px 0 0" : "3px")};
display: block;
`;
type Props = {
src?: string,
border?: boolean,
title?: string,
icon?: React.Node,
canonicalUrl?: string,
isSelected?: boolean,
width?: string,
height?: string,
};
type PropsWithRef = Props & {
forwardedRef: React.Ref<typeof StyledIframe>,
};
@observer
class Frame extends React.Component<PropsWithRef> {
mounted: boolean;
@observable isLoaded: boolean = false;
componentDidMount() {
this.mounted = true;
setImmediate(this.loadIframe);
}
componentWillUnmount() {
this.mounted = false;
}
loadIframe = () => {
if (!this.mounted) return;
this.isLoaded = true;
};
render() {
const {
border,
width = "100%",
height = "400px",
forwardedRef,
icon,
title,
canonicalUrl,
isSelected,
src,
} = this.props;
const Component = border ? StyledIframe : "iframe";
const withBar = !!(icon || canonicalUrl);
return (
<Rounded
width={width}
height={height}
$withBar={withBar}
className={isSelected ? "ProseMirror-selectednode" : ""}
>
{this.isLoaded && (
<Component
ref={forwardedRef}
$withBar={withBar}
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
width={width}
height={height}
type="text/html"
frameBorder="0"
title="embed"
loading="lazy"
src={src}
allowFullScreen
/>
)}
{withBar && (
<Bar align="center">
{icon} <Title>{title}</Title>
{canonicalUrl && (
<Open
href={canonicalUrl}
target="_blank"
rel="noopener noreferrer"
>
<OpenIcon color="currentColor" size={18} /> Open
</Open>
)}
</Bar>
)}
</Rounded>
);
}
}
const Rounded = styled.div`
border: 1px solid ${(props) => props.theme.embedBorder};
border-radius: 6px;
overflow: hidden;
width: ${(props) => props.width};
height: ${(props) => (props.$withBar ? props.height + 28 : props.height)};
`;
const Open = styled.a`
color: ${(props) => props.theme.textSecondary} !important;
font-size: 13px;
font-weight: 500;
align-items: center;
display: flex;
position: absolute;
right: 0;
padding: 0 8px;
`;
const Title = styled.span`
font-size: 13px;
font-weight: 500;
padding-left: 4px;
`;
const Bar = styled.div`
display: flex;
align-items: center;
border-top: 1px solid ${(props) => props.theme.embedBorder};
background: ${(props) => props.theme.secondaryBackground};
color: ${(props) => props.theme.textSecondary};
padding: 0 8px;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
user-select: none;
`;
export default React.forwardRef<Props, typeof Frame>((props, ref) => (
<Frame {...props} forwardedRef={ref} />
));

View File

@@ -0,0 +1,15 @@
// @flow
import * as React from "react";
import { cdnPath } from "../../utils/urls";
type Props = {|
alt: string,
src: string,
title?: string,
width?: number,
height?: number,
|};
export default function Image({ src, alt, ...rest }: Props) {
return <img src={cdnPath(src)} alt={alt} {...rest} />;
}

268
shared/embeds/index.js Normal file
View File

@@ -0,0 +1,268 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import Abstract from "./Abstract";
import Airtable from "./Airtable";
import Cawemo from "./Cawemo";
import ClickUp from "./ClickUp";
import Codepen from "./Codepen";
import Descript from "./Descript";
import Diagrams from "./Diagrams";
import Figma from "./Figma";
import Framer from "./Framer";
import Gist from "./Gist";
import GoogleCalendar from "./GoogleCalendar";
import GoogleDataStudio from "./GoogleDataStudio";
import GoogleDocs from "./GoogleDocs";
import GoogleDrawings from "./GoogleDrawings";
import GoogleDrive from "./GoogleDrive";
import GoogleSheets from "./GoogleSheets";
import GoogleSlides from "./GoogleSlides";
import InVision from "./InVision";
import Loom from "./Loom";
import Lucidchart from "./Lucidchart";
import Marvel from "./Marvel";
import Mindmeister from "./Mindmeister";
import Miro from "./Miro";
import ModeAnalytics from "./ModeAnalytics";
import Prezi from "./Prezi";
import Spotify from "./Spotify";
import Trello from "./Trello";
import Typeform from "./Typeform";
import Vimeo from "./Vimeo";
import YouTube from "./YouTube";
import Image from "./components/Image";
function matcher(Component) {
return (url: string) => {
const regexes = Component.ENABLED;
for (const regex of regexes) {
const result = url.match(regex);
if (result) {
return result;
}
}
};
}
const Img = styled(Image)`
margin: 4px;
width: 18px;
height: 18px;
`;
export default [
{
title: "Abstract",
keywords: "design",
icon: () => <Img src="/images/abstract.png" />,
component: Abstract,
matcher: matcher(Abstract),
},
{
title: "Airtable",
keywords: "spreadsheet",
icon: () => <Img src="/images/airtable.png" />,
component: Airtable,
matcher: matcher(Airtable),
},
{
title: "Cawemo",
keywords: "bpmn process",
defaultHidden: true,
icon: () => <Img src="/images/cawemo.png" />,
component: Cawemo,
matcher: matcher(Cawemo),
},
{
title: "ClickUp",
keywords: "project",
defaultHidden: true,
icon: () => <Img src="/images/clickup.png" />,
component: ClickUp,
matcher: matcher(ClickUp),
},
{
title: "Codepen",
keywords: "code editor",
icon: () => <Img src="/images/codepen.png" />,
component: Codepen,
matcher: matcher(Codepen),
},
{
title: "Descript",
keywords: "audio",
icon: () => <Img src="/images/descript.png" />,
component: Descript,
matcher: matcher(Descript),
},
{
title: "Figma",
keywords: "design svg vector",
icon: () => <Img src="/images/figma.png" />,
component: Figma,
matcher: matcher(Figma),
},
{
title: "Framer",
keywords: "design prototyping",
icon: () => <Img src="/images/framer.png" />,
component: Framer,
matcher: matcher(Framer),
},
{
title: "GitHub Gist",
keywords: "code",
icon: () => <Img src="/images/github-gist.png" />,
component: Gist,
matcher: matcher(Gist),
},
{
title: "Diagrams.net",
keywords: "diagrams drawio",
icon: () => <Img src="/images/diagrams.png" />,
component: Diagrams,
matcher: matcher(Diagrams),
},
{
title: "Google Drawings",
keywords: "drawings",
icon: () => <Img src="/images/google-drawings.png" />,
component: GoogleDrawings,
matcher: matcher(GoogleDrawings),
},
{
title: "Google Drive",
keywords: "drive",
icon: () => <Img src="/images/google-drive.png" />,
component: GoogleDrive,
matcher: matcher(GoogleDrive),
},
{
title: "Google Docs",
icon: () => <Img src="/images/google-docs.png" />,
component: GoogleDocs,
matcher: matcher(GoogleDocs),
},
{
title: "Google Sheets",
keywords: "excel spreadsheet",
icon: () => <Img src="/images/google-sheets.png" />,
component: GoogleSheets,
matcher: matcher(GoogleSheets),
},
{
title: "Google Slides",
keywords: "presentation slideshow",
icon: () => <Img src="/images/google-slides.png" />,
component: GoogleSlides,
matcher: matcher(GoogleSlides),
},
{
title: "Google Calendar",
keywords: "calendar",
icon: () => <Img src="/images/google-calendar.png" />,
component: GoogleCalendar,
matcher: matcher(GoogleCalendar),
},
{
title: "Google Data Studio",
keywords: "business intelligence",
icon: () => <Img src="/images/google-datastudio.png" />,
component: GoogleDataStudio,
matcher: matcher(GoogleDataStudio),
},
{
title: "InVision",
keywords: "design prototype",
defaultHidden: true,
icon: () => <Img src="/images/invision.png" />,
component: InVision,
matcher: matcher(InVision),
},
{
title: "Loom",
keywords: "video screencast",
icon: () => <Img src="/images/loom.png" />,
component: Loom,
matcher: matcher(Loom),
},
{
title: "Lucidchart",
keywords: "chart",
icon: () => <Img src="/images/lucidchart.png" />,
component: Lucidchart,
matcher: matcher(Lucidchart),
},
{
title: "Marvel",
keywords: "design prototype",
icon: () => <Img src="/images/marvel.png" />,
component: Marvel,
matcher: matcher(Marvel),
},
{
title: "Mindmeister",
keywords: "mindmap",
icon: () => <Img src="/images/mindmeister.png" />,
component: Mindmeister,
matcher: matcher(Mindmeister),
},
{
title: "Miro",
keywords: "whiteboard",
icon: () => <Img src="/images/miro.png" />,
component: Miro,
matcher: matcher(Miro),
},
{
title: "Mode",
keywords: "analytics",
defaultHidden: true,
icon: () => <Img src="/images/mode-analytics.png" />,
component: ModeAnalytics,
matcher: matcher(ModeAnalytics),
},
{
title: "Prezi",
keywords: "presentation",
icon: () => <Img src="/images/prezi.png" />,
component: Prezi,
matcher: matcher(Prezi),
},
{
title: "Spotify",
keywords: "music",
icon: () => <Img src="/images/spotify.png" />,
component: Spotify,
matcher: matcher(Spotify),
},
{
title: "Trello",
keywords: "kanban",
icon: () => <Img src="/images/trello.png" />,
component: Trello,
matcher: matcher(Trello),
},
{
title: "Typeform",
keywords: "form survey",
icon: () => <Img src="/images/typeform.png" />,
component: Typeform,
matcher: matcher(Typeform),
},
{
title: "Vimeo",
keywords: "video",
icon: () => <Img src="/images/vimeo.png" />,
component: Vimeo,
matcher: matcher(Vimeo),
},
{
title: "YouTube",
keywords: "google video",
icon: () => <Img src="/images/youtube.png" />,
component: YouTube,
matcher: matcher(YouTube),
},
];

View File

@@ -8,6 +8,8 @@
"Add a description": "Add a description",
"Collapse": "Collapse",
"Expand": "Expand",
"Server connection lost": "Server connection lost",
"Edits you make will sync once youre online": "Edits you make will sync once youre online",
"Submenu": "Submenu",
"Trash": "Trash",
"Archive": "Archive",
@@ -178,6 +180,7 @@
"Changelog": "Changelog",
"Send us feedback": "Send us feedback",
"Report a bug": "Report a bug",
"Development": "Development",
"Appearance": "Appearance",
"System": "System",
"Light": "Light",
@@ -350,6 +353,7 @@
"New from template": "New from template",
"Publish": "Publish",
"Publishing": "Publishing",
"Sorry, it looks like you dont have permission to access the document": "Sorry, it looks like you dont have permission to access the document",
"Nested documents": "Nested documents",
"Anyone with the link <1></1>can view this document": "Anyone with the link <1></1>can view this document",
"Share": "Share",

View File

@@ -40,6 +40,7 @@ const colors = {
blue: "#3633FF",
marine: "#2BC2FF",
green: "#42DED1",
yellow: "#F5BE31",
},
};

View File

@@ -102,6 +102,7 @@ export const RESERVED_SUBDOMAINS = [
"mail",
"marketing",
"mobile",
"multiplayer",
"new",
"news",
"newsletter",
@@ -111,6 +112,7 @@ export const RESERVED_SUBDOMAINS = [
"ns4",
"password",
"profile",
"realtime",
"sandbox",
"script",
"scripts",

6
shared/utils/urls.js Normal file
View File

@@ -0,0 +1,6 @@
// @flow
const env = typeof window !== "undefined" ? window.env : process.env;
export function cdnPath(path: string): string {
return `${env.CDN_URL}${path}`;
}