chore: Move editor into codebase (#2930)

This commit is contained in:
Tom Moor
2022-01-19 18:43:15 -08:00
committed by GitHub
parent 266f8c96c4
commit 062016b164
216 changed files with 12417 additions and 382 deletions

View File

@@ -0,0 +1,5 @@
https://prosemirror.net/docs/ref/#commands
Commands are building block functions that encapsulate an editing action. A command function takes an editor state, optionally a dispatch function that it can use to dispatch a transaction and optionally an EditorView instance. It should return a boolean that indicates whether it could perform any action.
Additional commands that are not included as part of prosemirror-commands, but are often reused can be found in this folder.

View File

@@ -0,0 +1,26 @@
import { NodeType } from "prosemirror-model";
import { EditorState, Transaction } from "prosemirror-state";
export default function backspaceToParagraph(type: NodeType) {
return (state: EditorState, dispatch: (tr: Transaction) => void) => {
const { $from, from, to, empty } = state.selection;
// if the selection has anything in it then use standard delete behavior
if (!empty) return null;
// check we're in a matching node
if ($from.parent.type !== type) return null;
// check if we're at the beginning of the heading
const $pos = state.doc.resolve(from - 1);
if ($pos.parent === $from.parent) return null;
// okay, replace it with a paragraph
dispatch(
state.tr
.setBlockType(from, to, type.schema.nodes.paragraph)
.scrollIntoView()
);
return true;
};
}

View File

@@ -0,0 +1,85 @@
import { Node } from "prosemirror-model";
import { EditorView } from "prosemirror-view";
import { ToastType } from "../types";
function findPlaceholderLink(doc: Node, href: string) {
let result: { pos: number; node: Node } | undefined;
function findLinks(node: Node, pos = 0) {
// get text nodes
if (node.type.name === "text") {
// get marks for text nodes
node.marks.forEach((mark) => {
// any of the marks links?
if (mark.type.name === "link") {
// any of the links to other docs?
if (mark.attrs.href === href) {
result = { node, pos };
}
}
});
}
if (!node.content.size) {
return;
}
node.descendants(findLinks);
}
findLinks(doc);
return result;
}
const createAndInsertLink = async function (
view: EditorView,
title: string,
href: string,
options: {
dictionary: any;
onCreateLink: (title: string) => Promise<string>;
onShowToast?: (message: string, code: string) => void;
}
) {
const { dispatch, state } = view;
const { onCreateLink, onShowToast } = options;
try {
const url = await onCreateLink(title);
const result = findPlaceholderLink(view.state.doc, href);
if (!result) return;
dispatch(
view.state.tr
.removeMark(
result.pos,
result.pos + result.node.nodeSize,
state.schema.marks.link
)
.addMark(
result.pos,
result.pos + result.node.nodeSize,
state.schema.marks.link.create({ href: url })
)
);
} catch (err) {
const result = findPlaceholderLink(view.state.doc, href);
if (!result) return;
dispatch(
view.state.tr.removeMark(
result.pos,
result.pos + result.node.nodeSize,
state.schema.marks.link
)
);
// let the user know
if (onShowToast) {
onShowToast(options.dictionary.createLinkError, ToastType.Error);
}
}
};
export default createAndInsertLink;

View File

@@ -0,0 +1,143 @@
import { NodeSelection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import uploadPlaceholderPlugin, {
findPlaceholder,
} from "../lib/uploadPlaceholder";
import { ToastType } from "../types";
let uploadId = 0;
export type Options = {
dictionary: any;
replaceExisting?: boolean;
uploadImage: (file: File) => Promise<string>;
onImageUploadStart?: () => void;
onImageUploadStop?: () => void;
onShowToast?: (message: string, code: string) => void;
};
const insertFiles = function (
view: EditorView,
event: Event | React.ChangeEvent<HTMLInputElement>,
pos: number,
files: File[],
options: Options
): void {
// filter to only include image files
const images = files.filter((file) => /image/i.test(file.type));
if (images.length === 0) return;
const {
dictionary,
uploadImage,
onImageUploadStart,
onImageUploadStop,
onShowToast,
} = options;
if (!uploadImage) {
console.warn(
"uploadImage callback must be defined to handle image uploads."
);
return;
}
// okay, we have some dropped images and a handler lets stop this
// event going any further up the stack
event.preventDefault();
// let the user know we're starting to process the images
if (onImageUploadStart) onImageUploadStart();
const { schema } = view.state;
// we'll use this to track of how many images have succeeded or failed
let complete = 0;
// the user might have dropped multiple images at once, we need to loop
for (const file of images) {
const id = `upload-${uploadId++}`;
const { tr } = view.state;
// insert a placeholder at this position, or mark an existing image as being
// replaced
tr.setMeta(uploadPlaceholderPlugin, {
add: {
id,
file,
pos,
replaceExisting: options.replaceExisting,
},
});
view.dispatch(tr);
// start uploading the image file to the server. Using "then" syntax
// to allow all placeholders to be entered at once with the uploads
// happening in the background in parallel.
uploadImage(file)
.then((src) => {
// otherwise, insert it at the placeholder's position, and remove
// the placeholder itself
const newImg = new Image();
newImg.onload = () => {
const result = findPlaceholder(view.state, id);
// if the content around the placeholder has been deleted
// then forget about inserting this image
if (result === null) {
return;
}
const [from, to] = result;
view.dispatch(
view.state.tr
.replaceWith(from, to || from, schema.nodes.image.create({ src }))
.setMeta(uploadPlaceholderPlugin, { remove: { id } })
);
// If the users selection is still at the image then make sure to select
// the entire node once done. Otherwise, if the selection has moved
// elsewhere then we don't want to modify it
if (view.state.selection.from === from) {
view.dispatch(
view.state.tr.setSelection(
new NodeSelection(view.state.doc.resolve(from))
)
);
}
};
newImg.onerror = (error) => {
throw error;
};
newImg.src = src;
})
.catch((error) => {
console.error(error);
// cleanup the placeholder if there is a failure
const transaction = view.state.tr.setMeta(uploadPlaceholderPlugin, {
remove: { id },
});
view.dispatch(transaction);
// let the user know
if (onShowToast) {
onShowToast(dictionary.imageUploadError, ToastType.Error);
}
})
.finally(() => {
complete++;
// once everything is done, let the user know
if (complete === images.length && onImageUploadStop) {
onImageUploadStop();
}
});
}
};
export default insertFiles;

View File

@@ -0,0 +1,111 @@
/*
Copyright 2020 Atlassian Pty Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// This file is based on the implementation found here:
// https://bitbucket.org/atlassian/design-system-mirror/src/master/editor/editor-core/src/plugins/text-formatting/commands/text-formatting.ts
import {
Selection,
EditorState,
Transaction,
TextSelection,
} from "prosemirror-state";
import isMarkActive from "../queries/isMarkActive";
function hasCode(state: EditorState, pos: number) {
const { code_inline } = state.schema.marks;
const node = pos >= 0 && state.doc.nodeAt(pos);
return node
? !!node.marks.filter((mark) => mark.type === code_inline).length
: false;
}
export default function moveLeft() {
return (state: EditorState, dispatch: (tr: Transaction) => void): boolean => {
const { code_inline } = state.schema.marks;
const { empty, $cursor } = state.selection as TextSelection;
if (!empty || !$cursor) {
return false;
}
const { storedMarks } = state.tr;
if (code_inline) {
const insideCode = code_inline && isMarkActive(code_inline)(state);
const currentPosHasCode = hasCode(state, $cursor.pos);
const nextPosHasCode = hasCode(state, $cursor.pos - 1);
const nextNextPosHasCode = hasCode(state, $cursor.pos - 2);
const exitingCode =
currentPosHasCode && !nextPosHasCode && Array.isArray(storedMarks);
const atLeftEdge =
nextPosHasCode &&
!nextNextPosHasCode &&
(storedMarks === null ||
(Array.isArray(storedMarks) && !!storedMarks.length));
const atRightEdge =
((exitingCode && Array.isArray(storedMarks) && !storedMarks.length) ||
(!exitingCode && storedMarks === null)) &&
!nextPosHasCode &&
nextNextPosHasCode;
const enteringCode =
!currentPosHasCode &&
nextPosHasCode &&
Array.isArray(storedMarks) &&
!storedMarks.length;
// at the right edge: remove code mark and move the cursor to the left
if (!insideCode && atRightEdge) {
const tr = state.tr.setSelection(
Selection.near(state.doc.resolve($cursor.pos - 1))
);
dispatch(tr.removeStoredMark(code_inline));
return true;
}
// entering code mark (from right edge): don't move the cursor, just add the mark
if (!insideCode && enteringCode) {
dispatch(state.tr.addStoredMark(code_inline.create()));
return true;
}
// at the left edge: add code mark and move the cursor to the left
if (insideCode && atLeftEdge) {
const tr = state.tr.setSelection(
Selection.near(state.doc.resolve($cursor.pos - 1))
);
dispatch(tr.addStoredMark(code_inline.create()));
return true;
}
// exiting code mark (or at the beginning of the line): don't move the cursor, just remove the mark
const isFirstChild = $cursor.index($cursor.depth - 1) === 0;
if (
insideCode &&
(exitingCode || (!$cursor.nodeBefore && isFirstChild))
) {
dispatch(state.tr.removeStoredMark(code_inline));
return true;
}
}
return false;
};
}

View File

@@ -0,0 +1,71 @@
/*
Copyright 2020 Atlassian Pty Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// This file is based on the implementation found here:
// https://bitbucket.org/atlassian/design-system-mirror/src/master/editor/editor-core/src/plugins/text-formatting/commands/text-formatting.ts
import { EditorState, Transaction, TextSelection } from "prosemirror-state";
import isMarkActive from "../queries/isMarkActive";
export default function moveRight() {
return (state: EditorState, dispatch: (tr: Transaction) => void): boolean => {
const { code_inline } = state.schema.marks;
const { empty, $cursor } = state.selection as TextSelection;
if (!empty || !$cursor) {
return false;
}
const { storedMarks } = state.tr;
if (code_inline) {
const insideCode = isMarkActive(code_inline)(state);
const currentPosHasCode = state.doc.rangeHasMark(
$cursor.pos,
$cursor.pos,
code_inline
);
const nextPosHasCode = state.doc.rangeHasMark(
$cursor.pos,
$cursor.pos + 1,
code_inline
);
const exitingCode =
!currentPosHasCode &&
!nextPosHasCode &&
(!storedMarks || !!storedMarks.length);
const enteringCode =
!currentPosHasCode &&
nextPosHasCode &&
(!storedMarks || !storedMarks.length);
// entering code mark (from the left edge): don't move the cursor, just add the mark
if (!insideCode && enteringCode) {
dispatch(state.tr.addStoredMark(code_inline.create()));
return true;
}
// exiting code mark: don't move the cursor, just remove the mark
if (insideCode && exitingCode) {
dispatch(state.tr.removeStoredMark(code_inline));
return true;
}
}
return false;
};
}

View File

@@ -0,0 +1,54 @@
import { NodeType } from "prosemirror-model";
import { EditorState, TextSelection, Transaction } from "prosemirror-state";
import { findBlockNodes } from "prosemirror-utils";
import findCollapsedNodes from "../queries/findCollapsedNodes";
export default function splitHeading(type: NodeType) {
return (state: EditorState, dispatch: (tr: Transaction) => void): boolean => {
const { $from, from, $to, to } = state.selection;
// check we're in a matching heading node
if ($from.parent.type !== type) return false;
// check that the caret is at the end of the content, if it isn't then
// standard node splitting behaviour applies
const endPos = $to.after() - 1;
if (endPos !== to) return false;
// If the node isn't collapsed standard behavior applies
if (!$from.parent.attrs.collapsed) return false;
// Find the next visible block after this one. It takes into account nested
// collapsed headings and reaching the end of the document
const allBlocks = findBlockNodes(state.doc);
const collapsedBlocks = findCollapsedNodes(state.doc);
const visibleBlocks = allBlocks.filter(
(a) => !collapsedBlocks.find((b) => b.pos === a.pos)
);
const nextVisibleBlock = visibleBlocks.find((a) => a.pos > from);
const pos = nextVisibleBlock
? nextVisibleBlock.pos
: state.doc.content.size;
// Insert our new heading directly before the next visible block
const transaction = state.tr.insert(
pos,
type.create({ ...$from.parent.attrs, collapsed: false })
);
// Move the selection into the new heading node and make sure it's on screen
dispatch(
transaction
.setSelection(
TextSelection.near(
transaction.doc.resolve(
Math.min(pos + 1, transaction.doc.content.size)
)
)
)
.scrollIntoView()
);
return true;
};
}

View File

@@ -0,0 +1,20 @@
import { setBlockType } from "prosemirror-commands";
import { NodeType } from "prosemirror-model";
import { EditorState, Transaction } from "prosemirror-state";
import isNodeActive from "../queries/isNodeActive";
export default function toggleBlockType(
type: NodeType,
toggleType: NodeType,
attrs = {}
) {
return (state: EditorState, dispatch: (tr: Transaction) => void) => {
const isActive = isNodeActive(type, attrs)(state);
if (isActive) {
return setBlockType(toggleType)(state, dispatch);
}
return setBlockType(type, attrs)(state, dispatch);
};
}

View File

@@ -0,0 +1,43 @@
import { NodeType } from "prosemirror-model";
import { wrapInList, liftListItem } from "prosemirror-schema-list";
import { EditorState, Transaction } from "prosemirror-state";
import { findParentNode } from "prosemirror-utils";
import isList from "../queries/isList";
export default function toggleList(listType: NodeType, itemType: NodeType) {
return (state: EditorState, dispatch: (tr: Transaction) => void) => {
const { schema, selection } = state;
const { $from, $to } = selection;
const range = $from.blockRange($to);
if (!range) {
return false;
}
const parentList = findParentNode((node) => isList(node, schema))(
selection
);
if (range.depth >= 1 && parentList && range.depth - parentList.depth <= 1) {
if (parentList.node.type === listType) {
return liftListItem(itemType)(state, dispatch);
}
if (
isList(parentList.node, schema) &&
listType.validContent(parentList.node.content)
) {
const { tr } = state;
tr.setNodeMarkup(parentList.pos, listType);
if (dispatch) {
dispatch(tr);
}
return false;
}
}
return wrapInList(listType)(state, dispatch);
};
}

View File

@@ -0,0 +1,19 @@
import { wrapIn, lift } from "prosemirror-commands";
import { NodeType } from "prosemirror-model";
import { EditorState, Transaction } from "prosemirror-state";
import isNodeActive from "../queries/isNodeActive";
export default function toggleWrap(
type: NodeType,
attrs?: Record<string, any>
) {
return (state: EditorState, dispatch: (tr: Transaction) => void) => {
const isActive = isNodeActive(type)(state);
if (isActive) {
return lift(state, dispatch);
}
return wrapIn(type, attrs)(state, dispatch);
};
}

View File

@@ -0,0 +1,56 @@
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);
});
});

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import Frame from "./components/Frame";
import { EmbedProps as Props } from ".";
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,21 @@
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);
});
});

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import Frame from "./components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp("https://airtable.com/(?:embed/)?(shr.*)$");
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,23 @@
import Bilibili from "./Bilibili";
describe("Bilibili", () => {
const match = Bilibili.ENABLED[0];
test("to be enabled on video link", () => {
expect(
"https://www.bilibili.com/video/BV1CV411s7jd?spm_id_from=333.999.0.0".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect("https://youtu.be".match(match)).toBe(null);
expect("https://bilibili.com".match(match)).toBe(null);
expect("https://www.bilibili.com".match(match)).toBe(null);
expect("https://www.bilibili.com/logout".match(match)).toBe(null);
expect("https://www.bilibili.com/feed/subscriptions".match(match)).toBe(
null
);
});
});

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import Frame from "./components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = /(?:https?:\/\/)?(www\.bilibili\.com)\/video\/([\w\d]+)?(\?\S+)?/i;
export default class Vimeo extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
const { matches } = this.props.attrs;
const videoId = matches[2];
return (
<Frame
{...this.props}
src={`https://player.bilibili.com/player.html?bvid=${videoId}&page=1&high_quality=1`}
title={`Bilibili Embed (${videoId})`}
/>
);
}
}

View File

@@ -0,0 +1,22 @@
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);
});
});

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import Frame from "./components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp("https?://cawemo.com/(?:share|embed)/(.*)$");
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
/>
);
}
}

View File

@@ -0,0 +1,17 @@
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);
});
});

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import Frame from "./components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp(
"^https?://share.clickup.com/[a-z]/[a-z]/(.*)/(.*)$"
);
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,22 @@
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);
});
});

View File

@@ -0,0 +1,14 @@
import * as React from "react";
import Frame from "./components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp("^https://codepen.io/(.*?)/(pen|embed)/(.*)$");
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,20 @@
import * as React from "react";
import Frame from "./components/Frame";
import { EmbedProps as Props } from ".";
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"
/>
);
}
}

View File

@@ -0,0 +1,19 @@
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
);
});
});

View File

@@ -0,0 +1,45 @@
import * as React from "react";
import Frame from "./components/Frame";
import Image from "./components/Image";
import { EmbedProps as Props } from ".";
const URL_REGEX = /^https:\/\/viewer\.diagrams\.net\/.*(title=\\w+)?/;
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,22 @@
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);
});
});

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import Frame from "./components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp(
"https://([w.-]+.)?figma.com/(file|proto)/([0-9a-zA-Z]{22,128})(?:/.*)?$"
);
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,13 @@
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);
});
});

View File

@@ -0,0 +1,20 @@
import * as React from "react";
import Frame from "./components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp("^https://framer.cloud/(.*)$");
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,27 @@
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();
expect(
"https://gist.github.com/ShubhanjanMedhi-dev/900c9c14093611898a4a085938bb90d9".match(
match
)
).toBeTruthy();
});
test("to not be enabled elsewhere", () => {
expect("https://gist.github.com/tommoor".match(match)).toBe(null);
});
});

View File

@@ -0,0 +1,44 @@
import * as React from "react";
import styled from "styled-components";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp(
"^https://gist.github.com/([a-zA-Z\\d](?:[a-zA-Z\\d]|-(?=[a-zA-Z\\d])){0,38})/(.*)$"
);
class Gist extends React.Component<Props> {
static ENABLED = [URL_REGEX];
get id() {
const gistUrl = new URL(this.props.attrs.href);
return gistUrl.pathname.split("/")[2];
}
render() {
const id = this.id;
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>`;
return (
<Iframe
src={`data:text/html;base64,${btoa(iframeHtml)}`}
className={this.props.isSelected ? "ProseMirror-selectednode" : ""}
frameBorder="0"
width="100%"
height="200px"
scrolling="no"
id={`gist-${id}`}
title="Github Gist"
/>
);
}
}
const Iframe = styled.iframe`
margin-top: 8px;
`;
export default Gist;

View File

@@ -0,0 +1,19 @@
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,22 @@
import * as React from "react";
import Frame from "./components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp(
"^https?://calendar.google.com/calendar/embed\\?src=(.*)$"
);
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 @@
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,32 @@
import * as React from "react";
import Frame from "./components/Frame";
import Image from "./components/Image";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp(
"^https?://datastudio.google.com/(embed|u/0)/reporting/(.*)/page/(.*)(/edit)?$"
);
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,34 @@
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,30 @@
import * as React from "react";
import Frame from "./components/Frame";
import Image from "./components/Image";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp("^https?://docs.google.com/document/(.*)$");
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,29 @@
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,32 @@
import * as React from "react";
import Frame from "./components/Frame";
import Image from "./components/Image";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp(
"^https://docs.google.com/drawings/d/(.*)/(edit|preview)(.*)$"
);
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 @@
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,29 @@
import * as React from "react";
import Frame from "./components/Frame";
import Image from "./components/Image";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp("^https?://drive.google.com/file/d/(.*)$");
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,24 @@
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,30 @@
import * as React from "react";
import Frame from "./components/Frame";
import Image from "./components/Image";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp("^https?://docs.google.com/spreadsheets/d/(.*)$");
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,29 @@
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);
});
});

View File

@@ -0,0 +1,32 @@
import * as React from "react";
import Frame from "./components/Frame";
import Image from "./components/Image";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp("^https?://docs.google.com/presentation/d/(.*)$");
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,21 @@
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);
});
});

View File

@@ -0,0 +1,39 @@
import * as React from "react";
import ImageZoom from "react-medium-image-zoom";
import Frame from "./components/Frame";
import { EmbedProps as Props } from ".";
const IFRAME_REGEX = /^https:\/\/(invis\.io\/.*)|(projects\.invisionapp\.com\/share\/.*)$/;
const IMAGE_REGEX = /^https:\/\/(opal\.invisionapp\.com\/static-signed\/live-embed\/.*)$/;
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
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
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,32 @@
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,14 @@
import * as React from "react";
import Frame from "./components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = /^https:\/\/(www\.)?(use)?loom.com\/(embed|share)\/(.*)$/;
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,53 @@
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);
});
});

View File

@@ -0,0 +1,23 @@
import * as React from "react";
import Frame from "./components/Frame";
import { EmbedProps as Props } from ".";
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?.chartId;
return (
<Frame
{...this.props}
src={`https://lucidchart.com/documents/embeddedchart/${chartId}`}
title="Lucidchart Embed"
/>
);
}
}

View File

@@ -0,0 +1,14 @@
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,20 @@
import * as React from "react";
import Frame from "./components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp("^https://marvelapp.com/([A-Za-z0-9-]{6})/?$");
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,46 @@
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);
});
});

View File

@@ -0,0 +1,26 @@
import * as React from "react";
import Frame from "./components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp(
"^https://([w.-]+.)?(mindmeister.com|mm.tt)(/maps/public_map_shell)?/(\\d+)(\\?t=.*)?(/.*)?$"
);
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,27 @@
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,24 @@
import * as React from "react";
import Frame from "./components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = /^https:\/\/(realtimeboard|miro).com\/app\/board\/(.*)$/;
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,17 @@
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);
});
});

View File

@@ -0,0 +1,23 @@
import * as React from "react";
import Frame from "./components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp(
"^https://([w.-]+.)?modeanalytics.com/(.*)/reports/(.*)$"
);
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,23 @@
import * as React from "react";
import Frame from "./components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp(
"^https?://app.pitch.com/app/(?:presentation/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|public/player)/(.*)$"
);
export default class Pitch extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
const shareId = this.props.attrs.matches[1];
return (
<Frame
{...this.props}
src={`https://pitch.com/embed/${shareId}`}
title="Pitch Embed"
height="414px"
/>
);
}
}

View File

@@ -0,0 +1,22 @@
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);
});
});

View File

@@ -0,0 +1,16 @@
import * as React from "react";
import Frame from "./components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp("^https://prezi.com/view/(.*)$");
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,27 @@
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);
});
});

View File

@@ -0,0 +1,42 @@
import * as React from "react";
import Frame from "./components/Frame";
const URL_REGEX = new RegExp("https?://open.spotify.com/(.*)$");
import { EmbedProps as Props } from ".";
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/, "/");
let 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,35 @@
import * as React from "react";
import Frame from "./components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = /^https:\/\/trello.com\/(c|b)\/([^/]*)(.*)?$/;
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})`}
/>
);
}
}

View File

@@ -0,0 +1,17 @@
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);
});
});

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import Frame from "./components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = new RegExp(
"^https://([A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?).typeform.com/to/(.*)$"
);
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,18 @@
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
);
});
});

View File

@@ -0,0 +1,27 @@
import * as React from "react";
import Frame from "./components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = /(http|https)?:\/\/(www\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^/]*)\/videos\/|)(\d\w+)(?:\/|\?)?(\d\w+)?/;
export default class Vimeo extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
const { matches } = this.props.attrs;
const videoId = matches[4];
const hId = matches[5];
return (
<Frame
{...this.props}
src={`https://player.vimeo.com/video/${videoId}?byline=0${
hId ? `&h=${hId}` : ""
}`}
title={`Vimeo Embed (${videoId})`}
height="412px"
border={false}
/>
);
}
}

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import Frame from "./components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = /^https?:\/\/whimsical.com\/[0-9a-zA-Z-_~]*-([a-zA-Z0-9]+)\/?$/;
export default class Whimsical extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
const { matches } = this.props.attrs;
const boardId = matches[1];
return (
<Frame
{...this.props}
src={`https://whimsical.com/embed/${boardId}`}
title="Whimsical"
/>
);
}
}

View File

@@ -0,0 +1,31 @@
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,21 @@
import * as React from "react";
import Frame from "./components/Frame";
import { EmbedProps as Props } from ".";
const URL_REGEX = /(?:https?:\/\/)?(?:www\.)?youtu\.?be(?:\.com)?\/?.*(?:watch|embed)?(?:.*v=|v\/|\/)([a-zA-Z0-9_-]{11})$/i;
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,149 @@
import { observable } from "mobx";
import { observer } from "mobx-react";
import { OpenIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
type Props = React.HTMLAttributes<HTMLIFrameElement> & {
src?: string;
border?: boolean;
title?: string;
icon?: React.ReactNode;
canonicalUrl?: string;
isSelected?: boolean;
width?: string;
height?: string;
allow?: string;
};
type PropsWithRef = Props & {
forwardedRef: React.Ref<HTMLIFrameElement>;
};
@observer
class Frame extends React.Component<PropsWithRef> {
mounted: boolean;
@observable
isLoaded = 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 withBar = !!(icon || canonicalUrl);
return (
<Rounded
width={width}
height={height}
$withBar={withBar}
$border={border}
className={isSelected ? "ProseMirror-selectednode" : ""}
>
{this.isLoaded && (
<Iframe
ref={forwardedRef}
$withBar={withBar}
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
width={width}
height={height}
frameBorder="0"
title="embed"
loading="lazy"
src={src}
allowFullScreen
/>
)}
{withBar && (
<Bar>
{icon} <Title>{title}</Title>
{canonicalUrl && (
<Open
href={canonicalUrl}
target="_blank"
rel="noopener noreferrer"
>
<OpenIcon color="currentColor" size={18} /> Open
</Open>
)}
</Bar>
)}
</Rounded>
);
}
}
const Iframe = styled.iframe<{ $withBar: boolean }>`
border-radius: ${(props) => (props.$withBar ? "3px 3px 0 0" : "3px")};
display: block;
`;
const Rounded = styled.div<{
width: string;
height: string;
$withBar: boolean;
$border?: boolean;
}>`
border: 1px solid
${(props) => (props.$border ? props.theme.embedBorder : "transparent")};
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<HTMLIFrameElement, Props>((props, ref) => (
<Frame {...props} forwardedRef={ref} />
));

View File

@@ -0,0 +1,14 @@
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} />;
}

View File

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

View File

@@ -0,0 +1,76 @@
import { PluginSimple } from "markdown-it";
import { InputRule } from "prosemirror-inputrules";
import { NodeType, MarkType, Schema } from "prosemirror-model";
import { EditorState, Plugin, Transaction } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { Editor } from "../../../app/editor";
export type Command = (
state: EditorState,
dispatch: (tr: Transaction) => void
) => boolean;
export type CommandFactory = (
attrs?: Record<string, any>
) => (
state: EditorState,
dispatch: (tr: Transaction) => void,
view: EditorView
) => boolean;
export default class Extension {
options: any;
editor: Editor;
constructor(options: Record<string, any> = {}) {
this.options = {
...this.defaultOptions,
...options,
};
}
bindEditor(editor: Editor) {
this.editor = editor;
}
get type() {
return "extension";
}
get name() {
return "";
}
get plugins(): Plugin[] {
return [];
}
get rulePlugins(): PluginSimple[] {
return [];
}
get defaultOptions() {
return {};
}
keys(_options: {
type?: NodeType | MarkType;
schema: Schema;
}): Record<string, Command> {
return {};
}
inputRules(_options: {
type?: NodeType | MarkType;
schema: Schema;
}): InputRule[] {
return [];
}
commands(_options: {
type?: NodeType | MarkType;
schema: Schema;
}): Record<string, CommandFactory> | CommandFactory {
return {};
}
}

View File

@@ -0,0 +1,213 @@
import { PluginSimple } from "markdown-it";
import { keymap } from "prosemirror-keymap";
import { MarkdownParser, TokenConfig } from "prosemirror-markdown";
import { Schema } from "prosemirror-model";
import { EditorView } from "prosemirror-view";
import Mark from "../marks/Mark";
import Node from "../nodes/Node";
import Extension, { CommandFactory } from "./Extension";
import makeRules from "./markdown/rules";
import { MarkdownSerializer } from "./markdown/serializer";
export default class ExtensionManager {
extensions: (Node | Mark | Extension)[];
constructor(extensions: (Node | Mark | Extension)[] = [], editor?: any) {
if (editor) {
extensions.forEach((extension) => {
extension.bindEditor(editor);
});
}
this.extensions = extensions;
}
get nodes() {
return this.extensions
.filter((extension) => extension.type === "node")
.reduce(
(nodes, node: Node) => ({
...nodes,
[node.name]: node.schema,
}),
{}
);
}
serializer() {
const nodes = this.extensions
.filter((extension) => extension.type === "node")
.reduce(
(nodes, extension: Node) => ({
...nodes,
[extension.name]: extension.toMarkdown,
}),
{}
);
const marks = this.extensions
.filter((extension) => extension.type === "mark")
.reduce(
(marks, extension: Mark) => ({
...marks,
[extension.name]: extension.toMarkdown,
}),
{}
);
return new MarkdownSerializer(nodes, marks);
}
parser({
schema,
rules,
plugins,
}: {
schema: Schema;
rules?: Record<string, any>;
plugins?: PluginSimple[];
}): MarkdownParser {
const tokens: Record<string, TokenConfig> = this.extensions
.filter(
(extension) => extension.type === "mark" || extension.type === "node"
)
.reduce((nodes, extension: Node | Mark) => {
const md = extension.parseMarkdown();
if (!md) return nodes;
return {
...nodes,
[extension.markdownToken || extension.name]: md,
};
}, {});
return new MarkdownParser(schema, makeRules({ rules, plugins }), tokens);
}
get marks() {
return this.extensions
.filter((extension) => extension.type === "mark")
.reduce(
(marks, { name, schema }: Mark) => ({
...marks,
[name]: schema,
}),
{}
);
}
get plugins() {
return this.extensions
.filter((extension) => "plugins" in extension)
.reduce((allPlugins, { plugins }) => [...allPlugins, ...plugins], []);
}
get rulePlugins() {
return this.extensions
.filter((extension) => "rulePlugins" in extension)
.reduce(
(allRulePlugins, { rulePlugins }) => [
...allRulePlugins,
...rulePlugins,
],
[]
);
}
keymaps({ schema }: { schema: Schema }) {
const extensionKeymaps = this.extensions
.filter((extension) => ["extension"].includes(extension.type))
.filter((extension) => extension.keys)
.map((extension: Extension) => extension.keys({ schema }));
const nodeKeymaps = this.extensions
.filter((extension) => ["node", "mark"].includes(extension.type))
.filter((extension) => extension.keys)
.map((extension: Node | Mark) =>
extension.keys({
type: schema[`${extension.type}s`][extension.name],
schema,
})
);
return [
...extensionKeymaps,
...nodeKeymaps,
].map((keys: Record<string, any>) => keymap(keys));
}
inputRules({ schema }: { schema: Schema }) {
const extensionInputRules = this.extensions
.filter((extension) => ["extension"].includes(extension.type))
.filter((extension) => extension.inputRules)
.map((extension: Extension) => extension.inputRules({ schema }));
const nodeMarkInputRules = this.extensions
.filter((extension) => ["node", "mark"].includes(extension.type))
.filter((extension) => extension.inputRules)
.map((extension) =>
extension.inputRules({
type: schema[`${extension.type}s`][extension.name],
schema,
})
);
return [...extensionInputRules, ...nodeMarkInputRules].reduce(
(allInputRules, inputRules) => [...allInputRules, ...inputRules],
[]
);
}
commands({ schema, view }: { schema: Schema; view: EditorView }) {
return this.extensions
.filter((extension) => extension.commands)
.reduce((allCommands, extension) => {
const { name, type } = extension;
const commands = {};
// @ts-expect-error FIXME
const value = extension.commands({
schema,
...(["node", "mark"].includes(type)
? {
type: schema[`${type}s`][name],
}
: {}),
});
const apply = (
callback: CommandFactory,
attrs: Record<string, any>
) => {
if (!view.editable) {
return false;
}
view.focus();
return callback(attrs)(view.state, view.dispatch, view);
};
const handle = (_name: string, _value: CommandFactory) => {
if (Array.isArray(_value)) {
commands[_name] = (attrs: Record<string, any>) =>
_value.forEach((callback) => apply(callback, attrs));
} else if (typeof _value === "function") {
commands[_name] = (attrs: Record<string, any>) =>
apply(_value, attrs);
}
};
if (typeof value === "object") {
Object.entries(value).forEach(([commandName, commandValue]) => {
handle(commandName, commandValue);
});
} else {
handle(name, value);
}
return {
...allCommands,
...commands,
};
}, {});
}
}

View File

@@ -0,0 +1,23 @@
import { EmbedDescriptor, MenuItem } from "../types";
export default function filterExcessSeparators(
items: (MenuItem | EmbedDescriptor)[]
): (MenuItem | EmbedDescriptor)[] {
return items.reduce((acc, item, index) => {
// trim separators from start / end
if (item.name === "separator" && index === 0) return acc;
if (item.name === "separator" && index === items.length - 1) return acc;
// trim double separators looking ahead / behind
const prev = items[index - 1];
if (prev && prev.name === "separator" && item.name === "separator")
return acc;
const next = items[index + 1];
if (next && next.name === "separator" && item.name === "separator")
return acc;
// otherwise, continue
return [...acc, item];
}, []);
}

View File

@@ -0,0 +1,33 @@
import { EditorView } from "prosemirror-view";
import headingToSlug from "./headingToSlug";
export default function getHeadings(view: EditorView) {
const headings: { title: string; level: number; id: string }[] = [];
const previouslySeen = {};
view.state.doc.forEach((node) => {
if (node.type.name === "heading") {
// calculate the optimal id
const id = headingToSlug(node);
let name = id;
// check if we've already used it, and if so how many times?
// Make the new id based on that number ensuring that we have
// unique ID's even when headings are identical
if (previouslySeen[id] > 0) {
name = headingToSlug(node, previouslySeen[id]);
}
// record that we've seen this id for the next loop
previouslySeen[id] =
previouslySeen[id] !== undefined ? previouslySeen[id] + 1 : 1;
headings.push({
title: node.textContent,
level: node.attrs.level,
id: name,
});
}
});
return headings;
}

View File

@@ -0,0 +1,26 @@
import { Node as ProsemirrorNode, Mark } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
import Node from "../nodes/Node";
export default function getMarkAttrs(state: EditorState, type: Node) {
const { from, to } = state.selection;
let marks: Mark[] = [];
state.doc.nodesBetween(from, to, (node: ProsemirrorNode) => {
marks = [...marks, ...node.marks];
if (node.content) {
node.content.forEach((content) => {
marks = [...marks, ...content.marks];
});
}
});
const mark = marks.find((markItem) => markItem.type.name === type.name);
if (mark) {
return mark.attrs;
}
return {};
}

View File

@@ -0,0 +1,28 @@
import { escape } from "lodash";
import { Node } from "prosemirror-model";
import slugify from "slugify";
// Slugify, escape, and remove periods from headings so that they are
// compatible with both url hashes AND dom ID's (querySelector does not like
// ID's that begin with a number or a period, for example).
function safeSlugify(text: string) {
return `h-${escape(
slugify(text, {
remove: /[!"#$%&'\.()*+,\/:;<=>?@\[\]\\^_`{|}~]/g,
lower: true,
})
)}`;
}
// calculates a unique slug for this heading based on it's text and position
// in the document that is as stable as possible
export default function headingToSlug(node: Node, index = 0) {
const slugified = safeSlugify(node.textContent);
if (index === 0) return slugified;
return `${slugified}-${index}`;
}
export function headingToPersistenceKey(node: Node, id?: string) {
const slug = headingToSlug(node);
return `rme-${id || window?.location.pathname}${slug}`;
}

View File

@@ -0,0 +1,71 @@
import isMarkdown from "./isMarkdown";
test("returns false for an empty string", () => {
expect(isMarkdown("")).toBe(false);
});
test("returns false for plain text", () => {
expect(isMarkdown("plain text")).toBe(false);
});
test("returns true for bullet list", () => {
expect(
isMarkdown(`- item one
- item two
- nested item`)
).toBe(true);
});
test("returns true for numbered list", () => {
expect(
isMarkdown(`1. item one
1. item two`)
).toBe(true);
expect(
isMarkdown(`1. item one
2. item two`)
).toBe(true);
});
test("returns true for code fence", () => {
expect(
isMarkdown(`\`\`\`javascript
this is code
\`\`\``)
).toBe(true);
});
test("returns false for non-closed fence", () => {
expect(
isMarkdown(`\`\`\`
this is not code
`)
).toBe(false);
});
test("returns true for heading", () => {
expect(isMarkdown(`# Heading 1`)).toBe(true);
expect(isMarkdown(`## Heading 2`)).toBe(true);
expect(isMarkdown(`### Heading 3`)).toBe(true);
});
test("returns false for hashtag", () => {
expect(isMarkdown(`Test #hashtag`)).toBe(false);
expect(isMarkdown(` #hashtag`)).toBe(false);
});
test("returns true for absolute link", () => {
expect(isMarkdown(`[title](http://www.google.com)`)).toBe(true);
});
test("returns true for relative link", () => {
expect(isMarkdown(`[title](/doc/mydoc-234tnes)`)).toBe(true);
});
test("returns true for relative image", () => {
expect(isMarkdown(`![alt](/coolimage.png)`)).toBe(true);
});
test("returns true for absolute image", () => {
expect(isMarkdown(`![alt](https://www.google.com/coolimage.png)`)).toBe(true);
});

View File

@@ -0,0 +1,18 @@
export default function isMarkdown(text: string): boolean {
// code-ish
const fences = text.match(/^```/gm);
if (fences && fences.length > 1) return true;
// link-ish
if (text.match(/\[[^]+\]\(https?:\/\/\S+\)/gm)) return true;
if (text.match(/\[[^]+\]\(\/\S+\)/gm)) return true;
// heading-ish
if (text.match(/^#{1,6}\s+\S+/gm)) return true;
// list-ish
const listItems = text.match(/^[\d-*].?\s\S+/gm);
if (listItems && listItems.length > 1) return true;
return false;
}

View File

@@ -0,0 +1,6 @@
const SSR = typeof window === "undefined";
const isMac = !SSR && window.navigator.platform === "MacIntel";
export default function isModKey(event: KeyboardEvent | MouseEvent): boolean {
return isMac ? event.metaKey : event.ctrlKey;
}

View File

@@ -0,0 +1,12 @@
export default function isUrl(text: string) {
if (text.match(/\n/)) {
return false;
}
try {
const url = new URL(text);
return url.hostname !== "";
} catch (err) {
return false;
}
}

View File

@@ -0,0 +1,65 @@
import { InputRule } from "prosemirror-inputrules";
import { MarkType, Mark } from "prosemirror-model";
import { EditorState } from "prosemirror-state";
function getMarksBetween(start: number, end: number, state: EditorState) {
let marks: { start: number; end: number; mark: Mark }[] = [];
state.doc.nodesBetween(start, end, (node, pos) => {
marks = [
...marks,
...node.marks.map((mark) => ({
start: pos,
end: pos + node.nodeSize,
mark,
})),
];
});
return marks;
}
export default function (
regexp: RegExp,
markType: MarkType,
getAttrs?: (match: string[]) => Record<string, unknown>
): InputRule {
return new InputRule(
regexp,
(state: EditorState, match: string[], start: number, end: number) => {
const attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs;
const { tr } = state;
const m = match.length - 1;
let markEnd = end;
let markStart = start;
if (match[m]) {
const matchStart = start + match[0].indexOf(match[m - 1]);
const matchEnd = matchStart + match[m - 1].length - 1;
const textStart = matchStart + match[m - 1].lastIndexOf(match[m]);
const textEnd = textStart + match[m].length;
const excludedMarks = getMarksBetween(start, end, state)
.filter((item) => item.mark.type.excludes(markType))
.filter((item) => item.end > matchStart);
if (excludedMarks.length) {
return null;
}
if (textEnd < matchEnd) {
tr.delete(textEnd, matchEnd);
}
if (textStart > matchStart) {
tr.delete(matchStart, textStart);
}
markStart = matchStart;
markEnd = markStart + match[m].length;
}
tr.addMark(markStart, markEnd, markType.create(attrs));
tr.removeStoredMark(markType);
return tr;
}
);
}

View File

@@ -0,0 +1,18 @@
import markdownit, { PluginSimple } from "markdown-it";
export default function rules({
rules = {},
plugins = [],
}: {
rules?: Record<string, any>;
plugins?: PluginSimple[];
}) {
const markdownIt = markdownit("default", {
breaks: false,
html: false,
linkify: false,
...rules,
});
plugins.forEach((plugin) => markdownIt.use(plugin));
return markdownIt;
}

View File

@@ -0,0 +1,412 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
// https://raw.githubusercontent.com/ProseMirror/prosemirror-markdown/master/src/to_markdown.js
// forked for table support
// ::- A specification for serializing a ProseMirror document as
// Markdown/CommonMark text.
export class MarkdownSerializer {
// :: (Object<(state: MarkdownSerializerState, node: Node, parent: Node, index: number)>, Object)
// Construct a serializer with the given configuration. The `nodes`
// object should map node names in a given schema to function that
// take a serializer state and such a node, and serialize the node.
//
// The `marks` object should hold objects with `open` and `close`
// properties, which hold the strings that should appear before and
// after a piece of text marked that way, either directly or as a
// function that takes a serializer state and a mark, and returns a
// string. `open` and `close` can also be functions, which will be
// called as
//
// (state: MarkdownSerializerState, mark: Mark,
// parent: Fragment, index: number) → string
//
// Where `parent` and `index` allow you to inspect the mark's
// context to see which nodes it applies to.
//
// Mark information objects can also have a `mixable` property
// which, when `true`, indicates that the order in which the mark's
// opening and closing syntax appears relative to other mixable
// marks can be varied. (For example, you can say `**a *b***` and
// `*a **b***`, but not `` `a *b*` ``.)
//
// To disable character escaping in a mark, you can give it an
// `escape` property of `false`. Such a mark has to have the highest
// precedence (must always be the innermost mark).
//
// The `expelEnclosingWhitespace` mark property causes the
// serializer to move enclosing whitespace from inside the marks to
// outside the marks. This is necessary for emphasis marks as
// CommonMark does not permit enclosing whitespace inside emphasis
// marks, see: http://spec.commonmark.org/0.26/#example-330
constructor(nodes, marks) {
// :: Object<(MarkdownSerializerState, Node)> The node serializer
// functions for this serializer.
this.nodes = nodes;
// :: Object The mark serializer info.
this.marks = marks;
}
// :: (Node, ?Object) → string
// Serialize the content of the given node to
// [CommonMark](http://commonmark.org/).
serialize(content, options?: { tightLists?: boolean }) {
const state = new MarkdownSerializerState(this.nodes, this.marks, options);
state.renderContent(content);
return state.out;
}
}
// ::- This is an object used to track state and expose
// methods related to markdown serialization. Instances are passed to
// node and mark serialization methods (see `toMarkdown`).
export class MarkdownSerializerState {
inTable = false;
inTightList = false;
closed = false;
delim = "";
constructor(nodes, marks, options) {
this.nodes = nodes;
this.marks = marks;
this.delim = this.out = "";
this.closed = false;
this.inTightList = false;
this.inTable = false;
// :: Object
// The options passed to the serializer.
// tightLists:: ?bool
// Whether to render lists in a tight style. This can be overridden
// on a node level by specifying a tight attribute on the node.
// Defaults to false.
this.options = options || {};
if (typeof this.options.tightLists === "undefined")
this.options.tightLists = true;
}
flushClose(size) {
if (this.closed) {
if (!this.atBlank()) this.out += "\n";
if (size === null || size === undefined) size = 2;
if (size > 1) {
let delimMin = this.delim;
const trim = /\s+$/.exec(delimMin);
if (trim)
delimMin = delimMin.slice(0, delimMin.length - trim[0].length);
for (let i = 1; i < size; i++) this.out += delimMin + "\n";
}
this.closed = false;
}
}
// :: (string, ?string, Node, ())
// Render a block, prefixing each line with `delim`, and the first
// line in `firstDelim`. `node` should be the node that is closed at
// the end of the block, and `f` is a function that renders the
// content of the block.
wrapBlock(delim, firstDelim, node, f) {
const old = this.delim;
this.write(firstDelim || delim);
this.delim += delim;
f();
this.delim = old;
this.closeBlock(node);
}
atBlank() {
return /(^|\n)$/.test(this.out);
}
// :: ()
// Ensure the current content ends with a newline.
ensureNewLine() {
if (!this.atBlank()) this.out += "\n";
}
// :: (?string)
// Prepare the state for writing output (closing closed paragraphs,
// adding delimiters, and so on), and then optionally add content
// (unescaped) to the output.
write(content) {
this.flushClose();
if (this.delim && this.atBlank()) this.out += this.delim;
if (content) this.out += content;
}
// :: (Node)
// Close the block for the given node.
closeBlock(node) {
this.closed = node;
}
// :: (string, ?bool)
// Add the given text to the document. When escape is not `false`,
// it will be escaped.
text(text, escape) {
const lines = text.split("\n");
for (let i = 0; i < lines.length; i++) {
const startOfLine = this.atBlank() || this.closed;
this.write();
this.out += escape !== false ? this.esc(lines[i], startOfLine) : lines[i];
if (i !== lines.length - 1) this.out += "\n";
}
}
// :: (Node)
// Render the given node as a block.
render(node, parent, index) {
if (typeof parent === "number") throw new Error("!");
this.nodes[node.type.name](this, node, parent, index);
}
// :: (Node)
// Render the contents of `parent` as block nodes.
renderContent(parent) {
parent.forEach((node, _, i) => this.render(node, parent, i));
}
// :: (Node)
// Render the contents of `parent` as inline content.
renderInline(parent) {
const active = [];
let trailing = "";
const progress = (node, _, index) => {
let marks = node ? node.marks : [];
// Remove marks from `hard_break` that are the last node inside
// that mark to prevent parser edge cases with new lines just
// before closing marks.
// (FIXME it'd be nice if we had a schema-agnostic way to
// identify nodes that serialize as hard breaks)
if (node && node.type.name === "hard_break")
marks = marks.filter((m) => {
if (index + 1 === parent.childCount) return false;
const next = parent.child(index + 1);
return (
m.isInSet(next.marks) && (!next.isText || /\S/.test(next.text))
);
});
let leading = trailing;
trailing = "";
// If whitespace has to be expelled from the node, adjust
// leading and trailing accordingly.
if (
node &&
node.isText &&
marks.some((mark) => {
const info = this.marks[mark.type.name]();
return info && info.expelEnclosingWhitespace;
})
) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
const [_, lead, inner, trail] = /^(\s*)(.*?)(\s*)$/m.exec(node.text);
leading += lead;
trailing = trail;
if (lead || trail) {
node = inner ? node.withText(inner) : null;
if (!node) marks = active;
}
}
const inner = marks.length && marks[marks.length - 1],
noEsc = inner && this.marks[inner.type.name]().escape === false;
const len = marks.length - (noEsc ? 1 : 0);
// Try to reorder 'mixable' marks, such as em and strong, which
// in Markdown may be opened and closed in different order, so
// that order of the marks for the token matches the order in
// active.
outer: for (let i = 0; i < len; i++) {
const mark = marks[i];
if (!this.marks[mark.type.name]().mixable) break;
for (let j = 0; j < active.length; j++) {
const other = active[j];
if (!this.marks[other.type.name]().mixable) break;
if (mark.eq(other)) {
if (i > j)
marks = marks
.slice(0, j)
.concat(mark)
.concat(marks.slice(j, i))
.concat(marks.slice(i + 1, len));
else if (j > i)
marks = marks
.slice(0, i)
.concat(marks.slice(i + 1, j))
.concat(mark)
.concat(marks.slice(j, len));
continue outer;
}
}
}
// Find the prefix of the mark set that didn't change
let keep = 0;
while (
keep < Math.min(active.length, len) &&
marks[keep].eq(active[keep])
)
++keep;
// Close the marks that need to be closed
while (keep < active.length)
this.text(this.markString(active.pop(), false, parent, index), false);
// Output any previously expelled trailing whitespace outside the marks
if (leading) this.text(leading);
// Open the marks that need to be opened
if (node) {
while (active.length < len) {
const add = marks[active.length];
active.push(add);
this.text(this.markString(add, true, parent, index), false);
}
// Render the node. Special case code marks, since their content
// may not be escaped.
if (noEsc && node.isText)
this.text(
this.markString(inner, true, parent, index) +
node.text +
this.markString(inner, false, parent, index + 1),
false
);
else this.render(node, parent, index);
}
};
parent.forEach(progress);
progress(null, null, parent.childCount);
}
// :: (Node, string, (number) → string)
// Render a node's content as a list. `delim` should be the extra
// indentation added to all lines except the first in an item,
// `firstDelim` is a function going from an item index to a
// delimiter for the first line of the item.
renderList(node, delim, firstDelim) {
if (this.closed && this.closed.type === node.type) this.flushClose(3);
else if (this.inTightList) this.flushClose(1);
const isTight =
typeof node.attrs.tight !== "undefined"
? node.attrs.tight
: this.options.tightLists;
const prevTight = this.inTightList;
const prevList = this.inList;
this.inList = true;
this.inTightList = isTight;
node.forEach((child, _, i) => {
if (i && isTight) this.flushClose(1);
this.wrapBlock(delim, firstDelim(i), node, () =>
this.render(child, node, i)
);
});
this.inList = prevList;
this.inTightList = prevTight;
}
renderTable(node) {
this.flushClose(1);
let headerBuffer = "";
const prevTable = this.inTable;
this.inTable = true;
// ensure there is an empty newline above all tables
this.out += "\n";
// rows
node.forEach((row, _, i) => {
// cols
row.forEach((cell, _, j) => {
this.out += j === 0 ? "| " : " | ";
cell.forEach((para) => {
// just padding the output so that empty cells take up the same space
// as headings.
// TODO: Ideally we'd calc the longest cell length and use that
// to pad all the others.
if (para.textContent === "" && para.content.size === 0) {
this.out += " ";
} else {
this.closed = false;
this.render(para, row, j);
}
});
if (i === 0) {
if (cell.attrs.alignment === "center") {
headerBuffer += "|:---:";
} else if (cell.attrs.alignment === "left") {
headerBuffer += "|:---";
} else if (cell.attrs.alignment === "right") {
headerBuffer += "|---:";
} else {
headerBuffer += "|----";
}
}
});
this.out += " |\n";
if (headerBuffer) {
this.out += `${headerBuffer}|\n`;
headerBuffer = undefined;
}
});
this.inTable = prevTable;
}
// :: (string, ?bool) → string
// Escape the given string so that it can safely appear in Markdown
// content. If `startOfLine` is true, also escape characters that
// has special meaning only at the start of the line.
esc(str = "", startOfLine) {
str = str.replace(/[`*\\~[\]]/g, "\\$&");
if (startOfLine) {
str = str.replace(/^[:#\-*+]/, "\\$&").replace(/^(\d+)\./, "$1\\.");
}
if (this.inTable) {
str = str.replace(/\|/gi, "\\$&");
}
return str;
}
quote(str) {
const wrap =
str.indexOf('"') === -1 ? '""' : str.indexOf("'") === -1 ? "''" : "()";
return wrap[0] + str + wrap[1];
}
// :: (string, number) → string
// Repeat the given string `n` times.
repeat(str, n) {
let out = "";
for (let i = 0; i < n; i++) out += str;
return out;
}
// : (Mark, bool, string?) → string
// Get the markdown string for a given opening or closing mark.
markString(mark, open, parent, index) {
const info = this.marks[mark.type.name]();
const value = open ? info.open : info.close;
return typeof value === "string" ? value : value(this, mark, parent, index);
}
// :: (string) → { leading: ?string, trailing: ?string }
// Get leading and trailing whitespace from a string. Values of
// leading or trailing property of the return object will be undefined
// if there is no match.
getEnclosingWhitespace(text) {
return {
leading: (text.match(/^(\s+)/) || [])[0],
trailing: (text.match(/(\s+)$/) || [])[0],
};
}
}

View File

@@ -0,0 +1,74 @@
import { EditorState, Plugin } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
// based on the example at: https://prosemirror.net/examples/upload/
const uploadPlaceholder = new Plugin({
state: {
init() {
return DecorationSet.empty;
},
apply(tr, set: DecorationSet) {
// Adjust decoration positions to changes made by the transaction
set = set.map(tr.mapping, tr.doc);
// See if the transaction adds or removes any placeholders
const action = tr.getMeta(this);
if (action?.add) {
if (action.add.replaceExisting) {
const $pos = tr.doc.resolve(action.add.pos);
if ($pos.nodeAfter?.type.name === "image") {
const deco = Decoration.node(
$pos.pos,
$pos.pos + $pos.nodeAfter.nodeSize,
{
class: "image-replacement-uploading",
},
{
id: action.add.id,
}
);
set = set.add(tr.doc, [deco]);
}
} else {
const element = document.createElement("div");
element.className = "image placeholder";
const img = document.createElement("img");
img.src = URL.createObjectURL(action.add.file);
element.appendChild(img);
const deco = Decoration.widget(action.add.pos, element, {
id: action.add.id,
});
set = set.add(tr.doc, [deco]);
}
}
if (action?.remove) {
set = set.remove(
set.find(undefined, undefined, (spec) => spec.id === action.remove.id)
);
}
return set;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
});
export default uploadPlaceholder;
export function findPlaceholder(
state: EditorState,
id: string
): [number, number] | null {
const decos: DecorationSet = uploadPlaceholder.getState(state);
const found = decos.find(undefined, undefined, (spec) => spec.id === id);
return found.length ? [found[0].from, found[0].to] : null;
}

View File

@@ -0,0 +1,42 @@
import { toggleMark } from "prosemirror-commands";
import { InputRule } from "prosemirror-inputrules";
import { MarkSpec, MarkType } from "prosemirror-model";
import markInputRule from "../lib/markInputRule";
import Mark from "./Mark";
export default class Bold extends Mark {
get name() {
return "strong";
}
get schema(): MarkSpec {
return {
parseDOM: [{ tag: "b" }, { tag: "strong" }],
toDOM: () => ["strong"],
};
}
inputRules({ type }: { type: MarkType }): InputRule[] {
return [markInputRule(/(?:\*\*)([^*]+)(?:\*\*)$/, type)];
}
keys({ type }: { type: MarkType }) {
return {
"Mod-b": toggleMark(type),
"Mod-B": toggleMark(type),
};
}
toMarkdown() {
return {
open: "**",
close: "**",
mixable: true,
expelEnclosingWhitespace: true,
};
}
parseMarkdown() {
return { mark: "strong" };
}
}

View File

@@ -0,0 +1,87 @@
import { toggleMark } from "prosemirror-commands";
import {
MarkSpec,
MarkType,
Node as ProsemirrorNode,
Mark as ProsemirrorMark,
} from "prosemirror-model";
import moveLeft from "../commands/moveLeft";
import moveRight from "../commands/moveRight";
import markInputRule from "../lib/markInputRule";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import Mark from "./Mark";
function backticksFor(node: ProsemirrorNode, side: -1 | 1) {
const ticks = /`+/g;
let match: RegExpMatchArray | null;
let len = 0;
if (node.isText) {
while ((match = ticks.exec(node.text || ""))) {
len = Math.max(len, match[0].length);
}
}
let result = len > 0 && side > 0 ? " `" : "`";
for (let i = 0; i < len; i++) {
result += "`";
}
if (len > 0 && side < 0) {
result += " ";
}
return result;
}
export default class Code extends Mark {
get name() {
return "code_inline";
}
get schema(): MarkSpec {
return {
excludes: "_",
parseDOM: [{ tag: "code", preserveWhitespace: true }],
toDOM: () => ["code", { spellCheck: "false" }],
};
}
inputRules({ type }: { type: MarkType }) {
return [markInputRule(/(?:^|[^`])(`([^`]+)`)$/, type)];
}
keys({ type }: { type: MarkType }) {
// Note: This key binding only works on non-Mac platforms
// https://github.com/ProseMirror/prosemirror/issues/515
return {
"Mod`": toggleMark(type),
ArrowLeft: moveLeft(),
ArrowRight: moveRight(),
};
}
toMarkdown() {
return {
open(
_state: MarkdownSerializerState,
_mark: ProsemirrorMark,
parent: ProsemirrorNode,
index: number
) {
return backticksFor(parent.child(index), -1);
},
close(
_state: MarkdownSerializerState,
_mark: ProsemirrorMark,
parent: ProsemirrorNode,
index: number
) {
return backticksFor(parent.child(index - 1), 1);
},
escape: false,
};
}
parseMarkdown() {
return { mark: "code_inline" };
}
}

View File

@@ -0,0 +1,45 @@
import { toggleMark } from "prosemirror-commands";
import { MarkSpec, MarkType } from "prosemirror-model";
import markInputRule from "../lib/markInputRule";
import markRule from "../rules/mark";
import Mark from "./Mark";
export default class Highlight extends Mark {
get name() {
return "highlight";
}
get schema(): MarkSpec {
return {
parseDOM: [{ tag: "mark" }],
toDOM: () => ["mark"],
};
}
inputRules({ type }: { type: MarkType }) {
return [markInputRule(/(?:==)([^=]+)(?:==)$/, type)];
}
keys({ type }: { type: MarkType }) {
return {
"Mod-Ctrl-h": toggleMark(type),
};
}
get rulePlugins() {
return [markRule({ delim: "==", mark: "highlight" })];
}
toMarkdown() {
return {
open: "==",
close: "==",
mixable: true,
expelEnclosingWhitespace: true,
};
}
parseMarkdown() {
return { mark: "highlight" };
}
}

View File

@@ -0,0 +1,46 @@
import { toggleMark } from "prosemirror-commands";
import { InputRule } from "prosemirror-inputrules";
import { MarkSpec, MarkType } from "prosemirror-model";
import { Command } from "../lib/Extension";
import markInputRule from "../lib/markInputRule";
import Mark from "./Mark";
export default class Italic extends Mark {
get name() {
return "em";
}
get schema(): MarkSpec {
return {
parseDOM: [{ tag: "i" }, { tag: "em" }],
toDOM: () => ["em"],
};
}
inputRules({ type }: { type: MarkType }): InputRule[] {
return [
markInputRule(/(?:^|[\s])(_([^_]+)_)$/, type),
markInputRule(/(?:^|[^*])(\*([^*]+)\*)$/, type),
];
}
keys({ type }: { type: MarkType }): Record<string, Command> {
return {
"Mod-i": toggleMark(type),
"Mod-I": toggleMark(type),
};
}
toMarkdown() {
return {
open: "*",
close: "*",
mixable: true,
expelEnclosingWhitespace: true,
};
}
parseMarkdown() {
return { mark: "em" };
}
}

194
shared/editor/marks/Link.ts Normal file
View File

@@ -0,0 +1,194 @@
import Token from "markdown-it/lib/token";
import { toggleMark } from "prosemirror-commands";
import { InputRule } from "prosemirror-inputrules";
import { MarkdownSerializerState } from "prosemirror-markdown";
import {
MarkSpec,
MarkType,
Node,
Mark as ProsemirrorMark,
} from "prosemirror-model";
import { Transaction, EditorState, Plugin } from "prosemirror-state";
import Mark from "./Mark";
const LINK_INPUT_REGEX = /\[([^[]+)]\((\S+)\)$/;
function isPlainURL(
link: ProsemirrorMark,
parent: Node,
index: number,
side: -1 | 1
) {
if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) {
return false;
}
const content = parent.child(index + (side < 0 ? -1 : 0));
if (
!content.isText ||
content.text !== link.attrs.href ||
content.marks[content.marks.length - 1] !== link
) {
return false;
}
if (index === (side < 0 ? 1 : parent.childCount - 1)) {
return true;
}
const next = parent.child(index + (side < 0 ? -2 : 1));
return !link.isInSet(next.marks);
}
export default class Link extends Mark {
get name() {
return "link";
}
get schema(): MarkSpec {
return {
attrs: {
href: {
default: "",
},
},
inclusive: false,
parseDOM: [
{
tag: "a[href]",
getAttrs: (dom: HTMLElement) => ({
href: dom.getAttribute("href"),
}),
},
],
toDOM: (node) => [
"a",
{
...node.attrs,
rel: "noopener noreferrer nofollow",
},
0,
],
};
}
inputRules({ type }: { type: MarkType }) {
return [
new InputRule(LINK_INPUT_REGEX, (state, match, start, end) => {
const [okay, alt, href] = match;
const { tr } = state;
if (okay) {
tr.replaceWith(start, end, this.editor.schema.text(alt)).addMark(
start,
start + alt.length,
type.create({ href })
);
}
return tr;
}),
];
}
commands({ type }: { type: MarkType }) {
return ({ href } = { href: "" }) => toggleMark(type, { href });
}
keys({ type }: { type: MarkType }) {
return {
"Mod-k": (state: EditorState, dispatch: (tr: Transaction) => void) => {
if (state.selection.empty) {
this.options.onKeyboardShortcut();
return true;
}
return toggleMark(type, { href: "" })(state, dispatch);
},
};
}
get plugins() {
return [
new Plugin({
props: {
handleDOMEvents: {
mouseover: (_view, event: MouseEvent) => {
if (
event.target instanceof HTMLAnchorElement &&
!event.target.className.includes("ProseMirror-widget")
) {
if (this.options.onHoverLink) {
return this.options.onHoverLink(event);
}
}
return false;
},
click: (_view, event: MouseEvent) => {
if (event.target instanceof HTMLAnchorElement) {
const href =
event.target.href ||
(event.target.parentNode instanceof HTMLAnchorElement
? event.target.parentNode.href
: "");
const isHashtag = href.startsWith("#");
if (isHashtag && this.options.onClickHashtag) {
event.stopPropagation();
event.preventDefault();
this.options.onClickHashtag(href, event);
return true;
}
if (this.options.onClickLink) {
event.stopPropagation();
event.preventDefault();
this.options.onClickLink(href, event);
return true;
}
}
return false;
},
},
},
}),
];
}
toMarkdown() {
return {
open(
_state: MarkdownSerializerState,
mark: ProsemirrorMark,
parent: Node,
index: number
) {
return isPlainURL(mark, parent, index, 1) ? "<" : "[";
},
close(
state: MarkdownSerializerState,
mark: ProsemirrorMark,
parent: Node,
index: number
) {
return isPlainURL(mark, parent, index, -1)
? ">"
: "](" +
state.esc(mark.attrs.href) +
(mark.attrs.title ? " " + state.quote(mark.attrs.title) : "") +
")";
},
};
}
parseMarkdown() {
return {
mark: "link",
getAttrs: (tok: Token) => ({
href: tok.attrGet("href"),
title: tok.attrGet("title") || null,
}),
};
}
}

View File

@@ -0,0 +1,45 @@
import { toggleMark } from "prosemirror-commands";
import { InputRule } from "prosemirror-inputrules";
import { TokenConfig } from "prosemirror-markdown";
import {
MarkSpec,
MarkType,
Node as ProsemirrorNode,
Schema,
} from "prosemirror-model";
import Extension, { Command, CommandFactory } from "../lib/Extension";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
export default abstract class Mark extends Extension {
get type() {
return "mark";
}
get schema(): MarkSpec {
return {};
}
get markdownToken(): string {
return "";
}
keys(_options: { type: MarkType; schema: Schema }): Record<string, Command> {
return {};
}
inputRules(_options: { type: MarkType; schema: Schema }): InputRule[] {
return [];
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
console.error("toMarkdown not implemented", state, node);
}
parseMarkdown(): TokenConfig | void {
return undefined;
}
commands({ type }: { type: MarkType; schema: Schema }): CommandFactory {
return () => toggleMark(type);
}
}

View File

@@ -0,0 +1,163 @@
import { MarkSpec } from "prosemirror-model";
import { Plugin, TextSelection } from "prosemirror-state";
import getMarkRange from "../queries/getMarkRange";
import markRule from "../rules/mark";
import Mark from "./Mark";
export default class Placeholder extends Mark {
get name() {
return "placeholder";
}
get schema(): MarkSpec {
return {
parseDOM: [{ tag: "span.template-placeholder" }],
toDOM: () => ["span", { class: "template-placeholder" }],
};
}
get rulePlugins() {
return [markRule({ delim: "!!", mark: "placeholder" })];
}
toMarkdown() {
return {
open: "!!",
close: "!!",
mixable: true,
expelEnclosingWhitespace: true,
};
}
parseMarkdown() {
return { mark: "placeholder" };
}
get plugins() {
return [
new Plugin({
props: {
handleTextInput: (view, from, to, text) => {
if (this.editor.props.template) {
return false;
}
const { state, dispatch } = view;
const $from = state.doc.resolve(from);
const range = getMarkRange($from, state.schema.marks.placeholder);
if (!range) return false;
const selectionStart = Math.min(from, range.from);
const selectionEnd = Math.max(to, range.to);
dispatch(
state.tr
.removeMark(
range.from,
range.to,
state.schema.marks.placeholder
)
.insertText(text, selectionStart, selectionEnd)
);
const $to = view.state.doc.resolve(selectionStart + text.length);
dispatch(view.state.tr.setSelection(TextSelection.near($to)));
return true;
},
handleKeyDown: (view, event: KeyboardEvent) => {
if (!view.props.editable || !view.props.editable(view.state)) {
return false;
}
if (this.editor.props.template) {
return false;
}
if (
event.key !== "ArrowLeft" &&
event.key !== "ArrowRight" &&
event.key !== "Backspace"
) {
return false;
}
const { state, dispatch } = view;
if (event.key === "Backspace") {
const range = getMarkRange(
state.doc.resolve(Math.max(0, state.selection.from - 1)),
state.schema.marks.placeholder
);
if (!range) return false;
dispatch(
state.tr
.removeMark(
range.from,
range.to,
state.schema.marks.placeholder
)
.insertText("", range.from, range.to)
);
return true;
}
if (event.key === "ArrowLeft") {
const range = getMarkRange(
state.doc.resolve(Math.max(0, state.selection.from - 1)),
state.schema.marks.placeholder
);
if (!range) return false;
const startOfMark = state.doc.resolve(range.from);
dispatch(state.tr.setSelection(TextSelection.near(startOfMark)));
return true;
}
if (event.key === "ArrowRight") {
const range = getMarkRange(
state.selection.$from,
state.schema.marks.placeholder
);
if (!range) return false;
const endOfMark = state.doc.resolve(range.to);
dispatch(state.tr.setSelection(TextSelection.near(endOfMark)));
return true;
}
return false;
},
handleClick: (view, pos, event: MouseEvent) => {
if (!view.props.editable || !view.props.editable(view.state)) {
return false;
}
if (this.editor.props.template) {
return false;
}
if (
event.target instanceof HTMLSpanElement &&
event.target.className.includes("template-placeholder")
) {
const { state, dispatch } = view;
const range = getMarkRange(
state.selection.$from,
state.schema.marks.placeholder
);
if (!range) return false;
event.stopPropagation();
event.preventDefault();
const startOfMark = state.doc.resolve(range.from);
dispatch(state.tr.setSelection(TextSelection.near(startOfMark)));
return true;
}
return false;
},
},
}),
];
}
}

View File

@@ -0,0 +1,54 @@
import { toggleMark } from "prosemirror-commands";
import { MarkSpec, MarkType } from "prosemirror-model";
import markInputRule from "../lib/markInputRule";
import Mark from "./Mark";
export default class Strikethrough extends Mark {
get name() {
return "strikethrough";
}
get schema(): MarkSpec {
return {
parseDOM: [
{
tag: "s",
},
{
tag: "del",
},
{
tag: "strike",
},
],
toDOM: () => ["del", 0],
};
}
keys({ type }: { type: MarkType }) {
return {
"Mod-d": toggleMark(type),
};
}
inputRules({ type }: { type: MarkType }) {
return [markInputRule(/~([^~]+)~$/, type)];
}
toMarkdown() {
return {
open: "~~",
close: "~~",
mixable: true,
expelEnclosingWhitespace: true,
};
}
get markdownToken() {
return "s";
}
parseMarkdown() {
return { mark: "strikethrough" };
}
}

View File

@@ -0,0 +1,51 @@
import { toggleMark } from "prosemirror-commands";
import { MarkSpec, MarkType } from "prosemirror-model";
import markInputRule from "../lib/markInputRule";
import underlinesRule from "../rules/underlines";
import Mark from "./Mark";
export default class Underline extends Mark {
get name() {
return "underline";
}
get schema(): MarkSpec {
return {
parseDOM: [
{ tag: "u" },
{
style: "text-decoration",
getAttrs: (value) => (value === "underline" ? {} : undefined),
},
],
toDOM: () => ["u", 0],
};
}
get rulePlugins() {
return [underlinesRule];
}
inputRules({ type }: { type: MarkType }) {
return [markInputRule(/(?:__)([^_]+)(?:__)$/, type)];
}
keys({ type }: { type: MarkType }) {
return {
"Mod-u": toggleMark(type),
};
}
toMarkdown() {
return {
open: "__",
close: "__",
mixable: true,
expelEnclosingWhitespace: true,
};
}
parseMarkdown() {
return { mark: "underline" };
}
}

View File

@@ -0,0 +1,58 @@
import { wrappingInputRule } from "prosemirror-inputrules";
import { NodeSpec, Node as ProsemirrorNode, NodeType } from "prosemirror-model";
import { EditorState, Transaction } from "prosemirror-state";
import toggleWrap from "../commands/toggleWrap";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import isNodeActive from "../queries/isNodeActive";
import Node from "./Node";
export default class Blockquote extends Node {
get name() {
return "blockquote";
}
get schema(): NodeSpec {
return {
content: "block+",
group: "block",
defining: true,
parseDOM: [{ tag: "blockquote" }],
toDOM: () => ["blockquote", 0],
};
}
inputRules({ type }: { type: NodeType }) {
return [wrappingInputRule(/^\s*>\s$/, type)];
}
commands({ type }: { type: NodeType }) {
return () => toggleWrap(type);
}
keys({ type }: { type: NodeType }) {
return {
"Ctrl->": toggleWrap(type),
"Mod-]": toggleWrap(type),
"Shift-Enter": (
state: EditorState,
dispatch: (tr: Transaction) => void
) => {
if (!isNodeActive(type)(state)) {
return false;
}
const { tr, selection } = state;
dispatch(tr.split(selection.to));
return true;
},
};
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
state.wrapBlock("> ", undefined, node, () => state.renderContent(node));
}
parseMarkdown() {
return { block: "blockquote" };
}
}

View File

@@ -0,0 +1,47 @@
import { wrappingInputRule } from "prosemirror-inputrules";
import {
Schema,
NodeType,
NodeSpec,
Node as ProsemirrorModel,
} from "prosemirror-model";
import toggleList from "../commands/toggleList";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import Node from "./Node";
export default class BulletList extends Node {
get name() {
return "bullet_list";
}
get schema(): NodeSpec {
return {
content: "list_item+",
group: "block",
parseDOM: [{ tag: "ul" }],
toDOM: () => ["ul", 0],
};
}
commands({ type, schema }: { type: NodeType; schema: Schema }) {
return () => toggleList(type, schema.nodes.list_item);
}
keys({ type, schema }: { type: NodeType; schema: Schema }) {
return {
"Shift-Ctrl-8": toggleList(type, schema.nodes.list_item),
};
}
inputRules({ type }: { type: NodeType }) {
return [wrappingInputRule(/^\s*([-+*])\s$/, type)];
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorModel) {
state.renderList(node, " ", () => (node.attrs.bullet || "*") + " ");
}
parseMarkdown() {
return { block: "bullet_list" };
}
}

Some files were not shown because too many files have changed in this diff Show More