chore: Move editor into codebase (#2930)

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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