chore: Move editor into codebase (#2930)
This commit is contained in:
5
shared/editor/commands/README.md
Normal file
5
shared/editor/commands/README.md
Normal 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.
|
||||
26
shared/editor/commands/backspaceToParagraph.ts
Normal file
26
shared/editor/commands/backspaceToParagraph.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
85
shared/editor/commands/createAndInsertLink.ts
Normal file
85
shared/editor/commands/createAndInsertLink.ts
Normal 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;
|
||||
143
shared/editor/commands/insertFiles.ts
Normal file
143
shared/editor/commands/insertFiles.ts
Normal 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;
|
||||
111
shared/editor/commands/moveLeft.ts
Normal file
111
shared/editor/commands/moveLeft.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
71
shared/editor/commands/moveRight.ts
Normal file
71
shared/editor/commands/moveRight.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
54
shared/editor/commands/splitHeading.ts
Normal file
54
shared/editor/commands/splitHeading.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
20
shared/editor/commands/toggleBlockType.ts
Normal file
20
shared/editor/commands/toggleBlockType.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
43
shared/editor/commands/toggleList.ts
Normal file
43
shared/editor/commands/toggleList.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
19
shared/editor/commands/toggleWrap.ts
Normal file
19
shared/editor/commands/toggleWrap.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
56
shared/editor/embeds/Abstract.test.ts
Normal file
56
shared/editor/embeds/Abstract.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
22
shared/editor/embeds/Abstract.tsx
Normal file
22
shared/editor/embeds/Abstract.tsx
Normal 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})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
21
shared/editor/embeds/Airtable.test.ts
Normal file
21
shared/editor/embeds/Airtable.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
22
shared/editor/embeds/Airtable.tsx
Normal file
22
shared/editor/embeds/Airtable.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
23
shared/editor/embeds/Bilibili.test.ts
Normal file
23
shared/editor/embeds/Bilibili.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
21
shared/editor/embeds/Bilibili.tsx
Normal file
21
shared/editor/embeds/Bilibili.tsx
Normal 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})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
22
shared/editor/embeds/Cawemo.test.ts
Normal file
22
shared/editor/embeds/Cawemo.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
22
shared/editor/embeds/Cawemo.tsx
Normal file
22
shared/editor/embeds/Cawemo.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
17
shared/editor/embeds/ClickUp.test.ts
Normal file
17
shared/editor/embeds/ClickUp.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
21
shared/editor/embeds/ClickUp.tsx
Normal file
21
shared/editor/embeds/ClickUp.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
22
shared/editor/embeds/Codepen.test.ts
Normal file
22
shared/editor/embeds/Codepen.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
14
shared/editor/embeds/Codepen.tsx
Normal file
14
shared/editor/embeds/Codepen.tsx
Normal 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" />;
|
||||
}
|
||||
}
|
||||
20
shared/editor/embeds/Descript.tsx
Normal file
20
shared/editor/embeds/Descript.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
19
shared/editor/embeds/Diagrams.test.ts
Normal file
19
shared/editor/embeds/Diagrams.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
45
shared/editor/embeds/Diagrams.tsx
Normal file
45
shared/editor/embeds/Diagrams.tsx
Normal 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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
22
shared/editor/embeds/Figma.test.ts
Normal file
22
shared/editor/embeds/Figma.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
22
shared/editor/embeds/Figma.tsx
Normal file
22
shared/editor/embeds/Figma.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
13
shared/editor/embeds/Framer.test.ts
Normal file
13
shared/editor/embeds/Framer.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
20
shared/editor/embeds/Framer.tsx
Normal file
20
shared/editor/embeds/Framer.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
27
shared/editor/embeds/Gist.test.ts
Normal file
27
shared/editor/embeds/Gist.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
44
shared/editor/embeds/Gist.tsx
Normal file
44
shared/editor/embeds/Gist.tsx
Normal 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;
|
||||
19
shared/editor/embeds/GoogleCalendar.test.ts
Normal file
19
shared/editor/embeds/GoogleCalendar.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
22
shared/editor/embeds/GoogleCalendar.tsx
Normal file
22
shared/editor/embeds/GoogleCalendar.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
19
shared/editor/embeds/GoogleDataStudio.test.ts
Normal file
19
shared/editor/embeds/GoogleDataStudio.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
32
shared/editor/embeds/GoogleDataStudio.tsx
Normal file
32
shared/editor/embeds/GoogleDataStudio.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
34
shared/editor/embeds/GoogleDocs.test.ts
Normal file
34
shared/editor/embeds/GoogleDocs.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
30
shared/editor/embeds/GoogleDocs.tsx
Normal file
30
shared/editor/embeds/GoogleDocs.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
29
shared/editor/embeds/GoogleDrawings.test.ts
Normal file
29
shared/editor/embeds/GoogleDrawings.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
32
shared/editor/embeds/GoogleDrawings.tsx
Normal file
32
shared/editor/embeds/GoogleDrawings.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
29
shared/editor/embeds/GoogleDrive.test.ts
Normal file
29
shared/editor/embeds/GoogleDrive.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
29
shared/editor/embeds/GoogleDrive.tsx
Normal file
29
shared/editor/embeds/GoogleDrive.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
24
shared/editor/embeds/GoogleSheets.test.ts
Normal file
24
shared/editor/embeds/GoogleSheets.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
30
shared/editor/embeds/GoogleSheets.tsx
Normal file
30
shared/editor/embeds/GoogleSheets.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
29
shared/editor/embeds/GoogleSlides.test.ts
Normal file
29
shared/editor/embeds/GoogleSlides.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
32
shared/editor/embeds/GoogleSlides.tsx
Normal file
32
shared/editor/embeds/GoogleSlides.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
21
shared/editor/embeds/InVision.test.ts
Normal file
21
shared/editor/embeds/InVision.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
39
shared/editor/embeds/InVision.tsx
Normal file
39
shared/editor/embeds/InVision.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
32
shared/editor/embeds/Loom.test.ts
Normal file
32
shared/editor/embeds/Loom.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
14
shared/editor/embeds/Loom.tsx
Normal file
14
shared/editor/embeds/Loom.tsx
Normal 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" />;
|
||||
}
|
||||
}
|
||||
53
shared/editor/embeds/Lucidchart.test.ts
Normal file
53
shared/editor/embeds/Lucidchart.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
23
shared/editor/embeds/Lucidchart.tsx
Normal file
23
shared/editor/embeds/Lucidchart.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
14
shared/editor/embeds/Marvel.test.ts
Normal file
14
shared/editor/embeds/Marvel.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
20
shared/editor/embeds/Marvel.tsx
Normal file
20
shared/editor/embeds/Marvel.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
46
shared/editor/embeds/Mindmeister.test.ts
Normal file
46
shared/editor/embeds/Mindmeister.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
26
shared/editor/embeds/Mindmeister.tsx
Normal file
26
shared/editor/embeds/Mindmeister.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
27
shared/editor/embeds/Miro.test.ts
Normal file
27
shared/editor/embeds/Miro.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
24
shared/editor/embeds/Miro.tsx
Normal file
24
shared/editor/embeds/Miro.tsx
Normal 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})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
17
shared/editor/embeds/ModeAnalytics.test.ts
Normal file
17
shared/editor/embeds/ModeAnalytics.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
23
shared/editor/embeds/ModeAnalytics.tsx
Normal file
23
shared/editor/embeds/ModeAnalytics.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
23
shared/editor/embeds/Pitch.tsx
Normal file
23
shared/editor/embeds/Pitch.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
22
shared/editor/embeds/Prezi.test.ts
Normal file
22
shared/editor/embeds/Prezi.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
16
shared/editor/embeds/Prezi.tsx
Normal file
16
shared/editor/embeds/Prezi.tsx
Normal 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 />
|
||||
);
|
||||
}
|
||||
}
|
||||
27
shared/editor/embeds/Spotify.test.ts
Normal file
27
shared/editor/embeds/Spotify.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
42
shared/editor/embeds/Spotify.tsx
Normal file
42
shared/editor/embeds/Spotify.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
35
shared/editor/embeds/Trello.tsx
Normal file
35
shared/editor/embeds/Trello.tsx
Normal 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})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
17
shared/editor/embeds/Typeform.test.ts
Normal file
17
shared/editor/embeds/Typeform.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
21
shared/editor/embeds/Typeform.tsx
Normal file
21
shared/editor/embeds/Typeform.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
18
shared/editor/embeds/Vimeo.test.ts
Normal file
18
shared/editor/embeds/Vimeo.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
27
shared/editor/embeds/Vimeo.tsx
Normal file
27
shared/editor/embeds/Vimeo.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
22
shared/editor/embeds/Whimsical.tsx
Normal file
22
shared/editor/embeds/Whimsical.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
31
shared/editor/embeds/YouTube.test.ts
Normal file
31
shared/editor/embeds/YouTube.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
21
shared/editor/embeds/YouTube.tsx
Normal file
21
shared/editor/embeds/YouTube.tsx
Normal 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})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
149
shared/editor/embeds/components/Frame.tsx
Normal file
149
shared/editor/embeds/components/Frame.tsx
Normal 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} />
|
||||
));
|
||||
14
shared/editor/embeds/components/Image.tsx
Normal file
14
shared/editor/embeds/components/Image.tsx
Normal 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} />;
|
||||
}
|
||||
312
shared/editor/embeds/index.tsx
Normal file
312
shared/editor/embeds/index.tsx
Normal 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;
|
||||
76
shared/editor/lib/Extension.ts
Normal file
76
shared/editor/lib/Extension.ts
Normal 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 {};
|
||||
}
|
||||
}
|
||||
213
shared/editor/lib/ExtensionManager.ts
Normal file
213
shared/editor/lib/ExtensionManager.ts
Normal 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,
|
||||
};
|
||||
}, {});
|
||||
}
|
||||
}
|
||||
23
shared/editor/lib/filterExcessSeparators.ts
Normal file
23
shared/editor/lib/filterExcessSeparators.ts
Normal 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];
|
||||
}, []);
|
||||
}
|
||||
33
shared/editor/lib/getHeadings.ts
Normal file
33
shared/editor/lib/getHeadings.ts
Normal 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;
|
||||
}
|
||||
26
shared/editor/lib/getMarkAttrs.ts
Normal file
26
shared/editor/lib/getMarkAttrs.ts
Normal 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 {};
|
||||
}
|
||||
28
shared/editor/lib/headingToSlug.ts
Normal file
28
shared/editor/lib/headingToSlug.ts
Normal 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}`;
|
||||
}
|
||||
71
shared/editor/lib/isMarkdown.test.ts
Normal file
71
shared/editor/lib/isMarkdown.test.ts
Normal 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(``)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for absolute image", () => {
|
||||
expect(isMarkdown(``)).toBe(true);
|
||||
});
|
||||
18
shared/editor/lib/isMarkdown.ts
Normal file
18
shared/editor/lib/isMarkdown.ts
Normal 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;
|
||||
}
|
||||
6
shared/editor/lib/isModKey.ts
Normal file
6
shared/editor/lib/isModKey.ts
Normal 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;
|
||||
}
|
||||
12
shared/editor/lib/isUrl.ts
Normal file
12
shared/editor/lib/isUrl.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
65
shared/editor/lib/markInputRule.ts
Normal file
65
shared/editor/lib/markInputRule.ts
Normal 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;
|
||||
}
|
||||
);
|
||||
}
|
||||
18
shared/editor/lib/markdown/rules.ts
Normal file
18
shared/editor/lib/markdown/rules.ts
Normal 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;
|
||||
}
|
||||
412
shared/editor/lib/markdown/serializer.ts
Normal file
412
shared/editor/lib/markdown/serializer.ts
Normal 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],
|
||||
};
|
||||
}
|
||||
}
|
||||
74
shared/editor/lib/uploadPlaceholder.ts
Normal file
74
shared/editor/lib/uploadPlaceholder.ts
Normal 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;
|
||||
}
|
||||
42
shared/editor/marks/Bold.ts
Normal file
42
shared/editor/marks/Bold.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
87
shared/editor/marks/Code.ts
Normal file
87
shared/editor/marks/Code.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
45
shared/editor/marks/Highlight.ts
Normal file
45
shared/editor/marks/Highlight.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
46
shared/editor/marks/Italic.ts
Normal file
46
shared/editor/marks/Italic.ts
Normal 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
194
shared/editor/marks/Link.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
45
shared/editor/marks/Mark.ts
Normal file
45
shared/editor/marks/Mark.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
163
shared/editor/marks/Placeholder.ts
Normal file
163
shared/editor/marks/Placeholder.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
54
shared/editor/marks/Strikethrough.ts
Normal file
54
shared/editor/marks/Strikethrough.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
51
shared/editor/marks/Underline.ts
Normal file
51
shared/editor/marks/Underline.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
58
shared/editor/nodes/Blockquote.ts
Normal file
58
shared/editor/nodes/Blockquote.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
47
shared/editor/nodes/BulletList.ts
Normal file
47
shared/editor/nodes/BulletList.ts
Normal 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
Reference in New Issue
Block a user