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

View File

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

View File

@@ -0,0 +1,107 @@
import Token from "markdown-it/lib/token";
import { NodeSpec, Node as ProsemirrorNode, NodeType } from "prosemirror-model";
import {
splitListItem,
sinkListItem,
liftListItem,
} from "prosemirror-schema-list";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import checkboxRule from "../rules/checkboxes";
import Node from "./Node";
export default class CheckboxItem extends Node {
get name() {
return "checkbox_item";
}
get schema(): NodeSpec {
return {
attrs: {
checked: {
default: false,
},
},
content: "paragraph block*",
defining: true,
draggable: true,
parseDOM: [
{
tag: `li[data-type="${this.name}"]`,
getAttrs: (dom: HTMLLIElement) => ({
checked: dom.className.includes("checked"),
}),
},
],
toDOM: (node) => {
const input = document.createElement("span");
input.tabIndex = -1;
input.className = "checkbox";
input.ariaChecked = node.attrs.checked.toString();
input.setAttribute("role", "checkbox");
input.addEventListener("click", this.handleClick);
return [
"li",
{
"data-type": this.name,
class: node.attrs.checked ? "checked" : undefined,
},
[
"span",
{
contentEditable: "false",
},
input,
],
["div", 0],
];
},
};
}
get rulePlugins() {
return [checkboxRule];
}
handleClick = (event: Event) => {
if (!(event.target instanceof HTMLSpanElement)) {
return;
}
const { view } = this.editor;
const { tr } = view.state;
const { top, left } = event.target.getBoundingClientRect();
const result = view.posAtCoords({ top, left });
if (result) {
const transaction = tr.setNodeMarkup(result.inside, undefined, {
checked: event.target.ariaChecked !== "true",
});
view.dispatch(transaction);
}
};
keys({ type }: { type: NodeType }) {
return {
Enter: splitListItem(type),
Tab: sinkListItem(type),
"Shift-Tab": liftListItem(type),
"Mod-]": sinkListItem(type),
"Mod-[": liftListItem(type),
};
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
state.write(node.attrs.checked ? "[x] " : "[ ] ");
state.renderContent(node);
}
parseMarkdown() {
return {
block: "checkbox_item",
getAttrs: (tok: Token) => ({
checked: tok.attrGet("checked") ? true : undefined,
}),
};
}
}

View File

@@ -0,0 +1,51 @@
import { wrappingInputRule } from "prosemirror-inputrules";
import {
NodeSpec,
NodeType,
Schema,
Node as ProsemirrorNode,
} from "prosemirror-model";
import toggleList from "../commands/toggleList";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import Node from "./Node";
export default class CheckboxList extends Node {
get name() {
return "checkbox_list";
}
get schema(): NodeSpec {
return {
group: "block",
content: "checkbox_item+",
toDOM: () => ["ul", { class: this.name }, 0],
parseDOM: [
{
tag: `[class="${this.name}"]`,
},
],
};
}
keys({ type, schema }: { type: NodeType; schema: Schema }) {
return {
"Shift-Ctrl-7": toggleList(type, schema.nodes.checkbox_item),
};
}
commands({ type, schema }: { type: NodeType; schema: Schema }) {
return () => toggleList(type, schema.nodes.checkbox_item);
}
inputRules({ type }: { type: NodeType }) {
return [wrappingInputRule(/^-?\s*(\[ \])\s$/i, type)];
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
state.renderList(node, " ", () => "- ");
}
parseMarkdown() {
return { block: "checkbox_list" };
}
}

View File

@@ -0,0 +1,11 @@
import CodeFence from "./CodeFence";
export default class CodeBlock extends CodeFence {
get name() {
return "code_block";
}
get markdownToken() {
return "code_block";
}
}

View File

@@ -0,0 +1,251 @@
import copy from "copy-to-clipboard";
import Token from "markdown-it/lib/token";
import { textblockTypeInputRule } from "prosemirror-inputrules";
import {
NodeSpec,
NodeType,
Schema,
Node as ProsemirrorNode,
} from "prosemirror-model";
import {
EditorState,
Selection,
TextSelection,
Transaction,
} from "prosemirror-state";
import refractor from "refractor/core";
import bash from "refractor/lang/bash";
import clike from "refractor/lang/clike";
import csharp from "refractor/lang/csharp";
import css from "refractor/lang/css";
import go from "refractor/lang/go";
import java from "refractor/lang/java";
import javascript from "refractor/lang/javascript";
import json from "refractor/lang/json";
import markup from "refractor/lang/markup";
import objectivec from "refractor/lang/objectivec";
import perl from "refractor/lang/perl";
import php from "refractor/lang/php";
import powershell from "refractor/lang/powershell";
import python from "refractor/lang/python";
import ruby from "refractor/lang/ruby";
import rust from "refractor/lang/rust";
import sql from "refractor/lang/sql";
import typescript from "refractor/lang/typescript";
import yaml from "refractor/lang/yaml";
import toggleBlockType from "../commands/toggleBlockType";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import Prism, { LANGUAGES } from "../plugins/Prism";
import isInCode from "../queries/isInCode";
import { ToastType } from "../types";
import Node from "./Node";
const PERSISTENCE_KEY = "rme-code-language";
const DEFAULT_LANGUAGE = "javascript";
[
bash,
css,
clike,
csharp,
go,
java,
javascript,
json,
markup,
objectivec,
perl,
php,
python,
powershell,
ruby,
rust,
sql,
typescript,
yaml,
].forEach(refractor.register);
export default class CodeFence extends Node {
get languageOptions() {
return Object.entries(LANGUAGES);
}
get name() {
return "code_fence";
}
get schema(): NodeSpec {
return {
attrs: {
language: {
default: DEFAULT_LANGUAGE,
},
},
content: "text*",
marks: "",
group: "block",
code: true,
defining: true,
draggable: false,
parseDOM: [
{ tag: "pre", preserveWhitespace: "full" },
{
tag: ".code-block",
preserveWhitespace: "full",
contentElement: "code",
getAttrs: (dom: HTMLDivElement) => {
return {
language: dom.dataset.language,
};
},
},
],
toDOM: (node) => {
const button = document.createElement("button");
button.innerText = "Copy";
button.type = "button";
button.addEventListener("click", this.handleCopyToClipboard);
const select = document.createElement("select");
select.addEventListener("change", this.handleLanguageChange);
this.languageOptions.forEach(([key, label]) => {
const option = document.createElement("option");
const value = key === "none" ? "" : key;
option.value = value;
option.innerText = label;
option.selected = node.attrs.language === value;
select.appendChild(option);
});
return [
"div",
{ class: "code-block", "data-language": node.attrs.language },
["div", { contentEditable: "false" }, select, button],
["pre", ["code", { spellCheck: "false" }, 0]],
];
},
};
}
commands({ type, schema }: { type: NodeType; schema: Schema }) {
return (attrs: Record<string, any>) =>
toggleBlockType(type, schema.nodes.paragraph, {
language: localStorage?.getItem(PERSISTENCE_KEY) || DEFAULT_LANGUAGE,
...attrs,
});
}
keys({ type, schema }: { type: NodeType; schema: Schema }) {
return {
"Shift-Ctrl-\\": toggleBlockType(type, schema.nodes.paragraph),
"Shift-Enter": (
state: EditorState,
dispatch: (tr: Transaction) => void
) => {
if (!isInCode(state)) return false;
const {
tr,
selection,
}: { tr: Transaction; selection: TextSelection } = state;
const text = selection?.$anchor?.nodeBefore?.text;
let newText = "\n";
if (text) {
const splitByNewLine = text.split("\n");
const numOfSpaces = splitByNewLine[splitByNewLine.length - 1].search(
/\S|$/
);
newText += " ".repeat(numOfSpaces);
}
dispatch(tr.insertText(newText, selection.from, selection.to));
return true;
},
Tab: (state: EditorState, dispatch: (tr: Transaction) => void) => {
if (!isInCode(state)) return false;
const { tr, selection } = state;
dispatch(tr.insertText(" ", selection.from, selection.to));
return true;
},
};
}
handleCopyToClipboard = (event: MouseEvent) => {
const { view } = this.editor;
const element = event.target;
if (!(element instanceof HTMLButtonElement)) {
return;
}
const { top, left } = element.getBoundingClientRect();
const result = view.posAtCoords({ top, left });
if (result) {
const node = view.state.doc.nodeAt(result.pos);
if (node) {
copy(node.textContent);
if (this.options.onShowToast) {
this.options.onShowToast(
this.options.dictionary.codeCopied,
ToastType.Info
);
}
}
}
};
handleLanguageChange = (event: InputEvent) => {
const { view } = this.editor;
const { tr } = view.state;
const element = event.currentTarget;
if (!(element instanceof HTMLSelectElement)) {
return;
}
const { top, left } = element.getBoundingClientRect();
const result = view.posAtCoords({ top, left });
if (result) {
const language = element.value;
const transaction = tr
.setSelection(Selection.near(view.state.doc.resolve(result.inside)))
.setNodeMarkup(result.inside, undefined, {
language,
});
view.dispatch(transaction);
localStorage?.setItem(PERSISTENCE_KEY, language);
}
};
get plugins() {
return [Prism({ name: this.name })];
}
inputRules({ type }: { type: NodeType }) {
return [textblockTypeInputRule(/^```$/, type)];
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
state.write("```" + (node.attrs.language || "") + "\n");
state.text(node.textContent, false);
state.ensureNewLine();
state.write("```");
state.closeBlock(node);
}
get markdownToken() {
return "fence";
}
parseMarkdown() {
return {
block: "code_block",
getAttrs: (tok: Token) => ({ language: tok.info }),
};
}
}

View File

@@ -0,0 +1,14 @@
import { NodeSpec } from "prosemirror-model";
import Node from "./Node";
export default class Doc extends Node {
get name() {
return "doc";
}
get schema(): NodeSpec {
return {
content: "block+",
};
}
}

View File

@@ -0,0 +1,126 @@
import Token from "markdown-it/lib/token";
import { NodeSpec, NodeType, Node as ProsemirrorNode } from "prosemirror-model";
import { EditorState, Transaction } from "prosemirror-state";
import * as React from "react";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import embedsRule from "../rules/embeds";
import { ComponentProps } from "../types";
import Node from "./Node";
const cache = {};
export default class Embed extends Node {
get name() {
return "embed";
}
get schema(): NodeSpec {
return {
content: "inline*",
group: "block",
atom: true,
attrs: {
href: {},
},
parseDOM: [
{
tag: "iframe[class=embed]",
getAttrs: (dom: HTMLIFrameElement) => {
const { embeds } = this.editor.props;
const href = dom.getAttribute("src") || "";
if (embeds) {
for (const embed of embeds) {
const matches = embed.matcher(href);
if (matches) {
return {
href,
};
}
}
}
return {};
},
},
],
toDOM: (node) => [
"iframe",
{ class: "embed", src: node.attrs.href, contentEditable: "false" },
0,
],
};
}
get rulePlugins() {
return [embedsRule(this.options.embeds)];
}
component({ isEditable, isSelected, theme, node }: ComponentProps) {
const { embeds } = this.editor.props;
// matches are cached in module state to avoid re running loops and regex
// here. Unfortuantely this function is not compatible with React.memo or
// we would use that instead.
const hit = cache[node.attrs.href];
let Component = hit ? hit.Component : undefined;
let matches = hit ? hit.matches : undefined;
if (!Component) {
for (const embed of embeds) {
const m = embed.matcher(node.attrs.href);
if (m) {
Component = embed.component;
matches = m;
cache[node.attrs.href] = { Component, matches };
}
}
}
if (!Component) {
return null;
}
return (
<Component
attrs={{ ...node.attrs, matches }}
isEditable={isEditable}
isSelected={isSelected}
theme={theme}
/>
);
}
commands({ type }: { type: NodeType }) {
return (attrs: Record<string, any>) => (
state: EditorState,
dispatch: (tr: Transaction) => void
) => {
dispatch(
state.tr.replaceSelectionWith(type.create(attrs)).scrollIntoView()
);
return true;
};
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
state.ensureNewLine();
state.write(
"[" +
state.esc(node.attrs.href, false) +
"](" +
state.esc(node.attrs.href, false) +
")"
);
state.write("\n\n");
}
parseMarkdown() {
return {
node: "embed",
getAttrs: (token: Token) => ({
href: token.attrGet("href"),
}),
};
}
}

View File

@@ -0,0 +1,120 @@
import nameToEmoji from "gemoji/name-to-emoji.json";
import Token from "markdown-it/lib/token";
import { InputRule } from "prosemirror-inputrules";
import { NodeSpec, Node as ProsemirrorNode, NodeType } from "prosemirror-model";
import { EditorState, TextSelection, Transaction } from "prosemirror-state";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import emojiRule from "../rules/emoji";
import Node from "./Node";
export default class Emoji extends Node {
get name() {
return "emoji";
}
get schema(): NodeSpec {
return {
attrs: {
style: {
default: "",
},
"data-name": {
default: undefined,
},
},
inline: true,
content: "text*",
marks: "",
group: "inline",
selectable: false,
parseDOM: [
{
tag: "span.emoji",
preserveWhitespace: "full",
getAttrs: (dom: HTMLDivElement) => ({
"data-name": dom.dataset.name,
}),
},
],
toDOM: (node) => {
if (nameToEmoji[node.attrs["data-name"]]) {
const text = document.createTextNode(
nameToEmoji[node.attrs["data-name"]]
);
return [
"span",
{
class: `emoji ${node.attrs["data-name"]}`,
"data-name": node.attrs["data-name"],
},
text,
];
}
const text = document.createTextNode(`:${node.attrs["data-name"]}:`);
return ["span", { class: "emoji" }, text];
},
};
}
get rulePlugins() {
return [emojiRule];
}
commands({ type }: { type: NodeType }) {
return (attrs: Record<string, string>) => (
state: EditorState,
dispatch: (tr: Transaction) => void
) => {
const { selection } = state;
const position =
selection instanceof TextSelection
? selection.$cursor?.pos
: selection.$to.pos;
if (position === undefined) {
return false;
}
const node = type.create(attrs);
const transaction = state.tr.insert(position, node);
dispatch(transaction);
return true;
};
}
inputRules({ type }: { type: NodeType }): InputRule[] {
return [
new InputRule(/^:([a-zA-Z0-9_+-]+):$/, (state, match, start, end) => {
const [okay, markup] = match;
const { tr } = state;
if (okay) {
tr.replaceWith(
start - 1,
end,
type.create({
"data-name": markup,
markup,
})
);
}
return tr;
}),
];
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
const name = node.attrs["data-name"];
if (name) {
state.write(`:${name}:`);
}
}
parseMarkdown() {
return {
node: "emoji",
getAttrs: (tok: Token) => {
return { "data-name": tok.markup.trim() };
},
};
}
}

View File

@@ -0,0 +1,56 @@
import { NodeSpec, NodeType } from "prosemirror-model";
import { EditorState, Transaction } from "prosemirror-state";
import { isInTable } from "prosemirror-tables";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import breakRule from "../rules/breaks";
import Node from "./Node";
export default class HardBreak extends Node {
get name() {
return "br";
}
get schema(): NodeSpec {
return {
inline: true,
group: "inline",
selectable: false,
parseDOM: [{ tag: "br" }],
toDOM() {
return ["br"];
},
};
}
get rulePlugins() {
return [breakRule];
}
commands({ type }: { type: NodeType }) {
return () => (state: EditorState, dispatch: (tr: Transaction) => void) => {
dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView());
return true;
};
}
keys({ type }: { type: NodeType }) {
return {
"Shift-Enter": (
state: EditorState,
dispatch: (tr: Transaction) => void
) => {
if (!isInTable(state)) return false;
dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView());
return true;
},
};
}
toMarkdown(state: MarkdownSerializerState) {
state.write(" \\n ");
}
parseMarkdown() {
return { node: "br" };
}
}

View File

@@ -0,0 +1,280 @@
import copy from "copy-to-clipboard";
import { textblockTypeInputRule } from "prosemirror-inputrules";
import {
Node as ProsemirrorNode,
NodeSpec,
NodeType,
Schema,
} from "prosemirror-model";
import { Plugin, Selection } from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import backspaceToParagraph from "../commands/backspaceToParagraph";
import splitHeading from "../commands/splitHeading";
import toggleBlockType from "../commands/toggleBlockType";
import { Command } from "../lib/Extension";
import headingToSlug, { headingToPersistenceKey } from "../lib/headingToSlug";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import { ToastType } from "../types";
import Node from "./Node";
export default class Heading extends Node {
className = "heading-name";
get name() {
return "heading";
}
get defaultOptions() {
return {
levels: [1, 2, 3, 4],
collapsed: undefined,
};
}
get schema(): NodeSpec {
return {
attrs: {
level: {
default: 1,
},
collapsed: {
default: undefined,
},
},
content: "inline*",
group: "block",
defining: true,
draggable: false,
parseDOM: this.options.levels.map((level: number) => ({
tag: `h${level}`,
attrs: { level },
contentElement: ".heading-content",
})),
toDOM: (node) => {
const anchor = document.createElement("button");
anchor.innerText = "#";
anchor.type = "button";
anchor.className = "heading-anchor";
anchor.addEventListener("click", (event) => this.handleCopyLink(event));
const fold = document.createElement("button");
fold.innerText = "";
fold.innerHTML =
'<svg fill="currentColor" width="12" height="24" viewBox="6 0 12 24" xmlns="http://www.w3.org/2000/svg"><path d="M8.23823905,10.6097108 L11.207376,14.4695888 L11.207376,14.4695888 C11.54411,14.907343 12.1719566,14.989236 12.6097108,14.652502 C12.6783439,14.5997073 12.7398293,14.538222 12.792624,14.4695888 L15.761761,10.6097108 L15.761761,10.6097108 C16.0984949,10.1719566 16.0166019,9.54410997 15.5788477,9.20737601 C15.4040391,9.07290785 15.1896811,9 14.969137,9 L9.03086304,9 L9.03086304,9 C8.47857829,9 8.03086304,9.44771525 8.03086304,10 C8.03086304,10.2205442 8.10377089,10.4349022 8.23823905,10.6097108 Z" /></svg>';
fold.type = "button";
fold.className = `heading-fold ${
node.attrs.collapsed ? "collapsed" : ""
}`;
fold.addEventListener("mousedown", (event) =>
this.handleFoldContent(event)
);
return [
`h${node.attrs.level + (this.options.offset || 0)}`,
[
"span",
{
contentEditable: "false",
class: `heading-actions ${
node.attrs.collapsed ? "collapsed" : ""
}`,
},
anchor,
fold,
],
[
"span",
{
class: "heading-content",
},
0,
],
];
},
};
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
state.write(state.repeat("#", node.attrs.level) + " ");
state.renderInline(node);
state.closeBlock(node);
}
parseMarkdown() {
return {
block: "heading",
getAttrs: (token: Record<string, any>) => ({
level: +token.tag.slice(1),
}),
};
}
commands({ type, schema }: { type: NodeType; schema: Schema }) {
return (attrs: Record<string, any>) => {
return toggleBlockType(type, schema.nodes.paragraph, attrs);
};
}
handleFoldContent = (event: MouseEvent) => {
event.preventDefault();
if (!(event.target instanceof HTMLButtonElement)) {
return;
}
const { view } = this.editor;
const hadFocus = view.hasFocus();
const { tr } = view.state;
const { top, left } = event.target.getBoundingClientRect();
const result = view.posAtCoords({ top, left });
if (result) {
const node = view.state.doc.nodeAt(result.inside);
if (node) {
const endOfHeadingPos = result.inside + node.nodeSize;
const $pos = view.state.doc.resolve(endOfHeadingPos);
const collapsed = !node.attrs.collapsed;
if (collapsed && view.state.selection.to > endOfHeadingPos) {
// move selection to the end of the collapsed heading
tr.setSelection(Selection.near($pos, -1));
}
const transaction = tr.setNodeMarkup(result.inside, undefined, {
...node.attrs,
collapsed,
});
const persistKey = headingToPersistenceKey(node, this.editor.props.id);
if (collapsed) {
localStorage?.setItem(persistKey, "collapsed");
} else {
localStorage?.removeItem(persistKey);
}
view.dispatch(transaction);
if (hadFocus) {
view.focus();
}
}
}
};
handleCopyLink = (event: MouseEvent) => {
// this is unfortunate but appears to be the best way to grab the anchor
// as it's added directly to the dom by a decoration.
const anchor =
event.currentTarget instanceof HTMLButtonElement &&
(event.currentTarget.parentNode?.parentNode
?.previousSibling as HTMLElement);
if (!anchor || !anchor.className.includes(this.className)) {
throw new Error("Did not find anchor as previous sibling of heading");
}
const hash = `#${anchor.id}`;
// the existing url might contain a hash already, lets make sure to remove
// that rather than appending another one.
const urlWithoutHash = window.location.href.split("#")[0];
copy(urlWithoutHash + hash);
if (this.options.onShowToast) {
this.options.onShowToast(
this.options.dictionary.linkCopied,
ToastType.Info
);
}
};
keys({ type, schema }: { type: NodeType; schema: Schema }) {
const options = this.options.levels.reduce(
(items: Record<string, Command>, level: number) => ({
...items,
...{
[`Shift-Ctrl-${level}`]: toggleBlockType(
type,
schema.nodes.paragraph,
{ level }
),
},
}),
{}
);
return {
...options,
Backspace: backspaceToParagraph(type),
Enter: splitHeading(type),
};
}
get plugins() {
const getAnchors = (doc: ProsemirrorNode) => {
const decorations: Decoration[] = [];
const previouslySeen = {};
doc.descendants((node, pos) => {
if (node.type.name !== this.name) return;
// calculate the optimal id
const slug = headingToSlug(node);
let id = slug;
// 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[slug] > 0) {
id = headingToSlug(node, previouslySeen[slug]);
}
// record that we've seen this slug for the next loop
previouslySeen[slug] =
previouslySeen[slug] !== undefined ? previouslySeen[slug] + 1 : 1;
decorations.push(
Decoration.widget(
pos,
() => {
const anchor = document.createElement("a");
anchor.id = id;
anchor.className = this.className;
return anchor;
},
{
side: -1,
key: id,
}
)
);
});
return DecorationSet.create(doc, decorations);
};
const plugin: Plugin = new Plugin({
state: {
init: (config, state) => {
return getAnchors(state.doc);
},
apply: (tr, oldState) => {
return tr.docChanged ? getAnchors(tr.doc) : oldState;
},
},
props: {
decorations: (state) => plugin.getState(state),
},
});
return [plugin];
}
inputRules({ type }: { type: NodeType }) {
return this.options.levels.map((level: number) =>
textblockTypeInputRule(new RegExp(`^(#{1,${level}})\\s$`), type, () => ({
level,
}))
);
}
}

View File

@@ -0,0 +1,80 @@
import Token from "markdown-it/lib/token";
import { InputRule } from "prosemirror-inputrules";
import { NodeSpec, NodeType, Node as ProsemirrorNode } from "prosemirror-model";
import { EditorState, Transaction } from "prosemirror-state";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import Node from "./Node";
export default class HorizontalRule extends Node {
get name() {
return "hr";
}
get schema(): NodeSpec {
return {
attrs: {
markup: {
default: "---",
},
},
group: "block",
parseDOM: [{ tag: "hr" }],
toDOM: (node) => {
return [
"hr",
{ class: node.attrs.markup === "***" ? "page-break" : "" },
];
},
};
}
commands({ type }: { type: NodeType }) {
return (attrs: Record<string, any>) => (
state: EditorState,
dispatch: (tr: Transaction) => void
) => {
dispatch(
state.tr.replaceSelectionWith(type.create(attrs)).scrollIntoView()
);
return true;
};
}
keys({ type }: { type: NodeType }) {
return {
"Mod-_": (state: EditorState, dispatch: (tr: Transaction) => void) => {
dispatch(state.tr.replaceSelectionWith(type.create()).scrollIntoView());
return true;
},
};
}
inputRules({ type }: { type: NodeType }) {
return [
new InputRule(/^(?:---|___\s|\*\*\*\s)$/, (state, match, start, end) => {
const { tr } = state;
if (match[0]) {
const markup = match[0].trim();
tr.replaceWith(start - 1, end, type.create({ markup }));
}
return tr;
}),
];
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
state.write(`\n${node.attrs.markup}`);
state.closeBlock(node);
}
parseMarkdown() {
return {
node: "hr",
getAttrs: (tok: Token) => ({
markup: tok.markup,
}),
};
}
}

View File

@@ -0,0 +1,574 @@
import Token from "markdown-it/lib/token";
import { DownloadIcon } from "outline-icons";
import { InputRule } from "prosemirror-inputrules";
import { Node as ProsemirrorNode, NodeSpec, NodeType } from "prosemirror-model";
import {
Plugin,
TextSelection,
NodeSelection,
EditorState,
Transaction,
} from "prosemirror-state";
import * as React from "react";
import ImageZoom from "react-medium-image-zoom";
import styled from "styled-components";
import getDataTransferFiles from "../../utils/getDataTransferFiles";
import insertFiles, { Options } from "../commands/insertFiles";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import uploadPlaceholderPlugin from "../lib/uploadPlaceholder";
import { ComponentProps } from "../types";
import Node from "./Node";
/**
* Matches following attributes in Markdown-typed image: [, alt, src, class]
*
* Example:
* ![Lorem](image.jpg) -> [, "Lorem", "image.jpg"]
* ![](image.jpg "class") -> [, "", "image.jpg", "small"]
* ![Lorem](image.jpg "class") -> [, "Lorem", "image.jpg", "small"]
*/
const IMAGE_INPUT_REGEX = /!\[(?<alt>[^\][]*?)]\((?<filename>[^\][]*?)(?=“|\))“?(?<layoutclass>[^\][”]+)?”?\)$/;
const uploadPlugin = (options: Options) =>
new Plugin({
props: {
handleDOMEvents: {
paste(view, event: ClipboardEvent): boolean {
if (
(view.props.editable && !view.props.editable(view.state)) ||
!options.uploadImage
) {
return false;
}
if (!event.clipboardData) return false;
// check if we actually pasted any files
const files = Array.prototype.slice
.call(event.clipboardData.items)
.map((dt: any) => dt.getAsFile())
.filter((file: File) => file);
if (files.length === 0) return false;
const { tr } = view.state;
if (!tr.selection.empty) {
tr.deleteSelection();
}
const pos = tr.selection.from;
insertFiles(view, event, pos, files, options);
return true;
},
drop(view, event: DragEvent): boolean {
if (
(view.props.editable && !view.props.editable(view.state)) ||
!options.uploadImage
) {
return false;
}
// filter to only include image files
const files = getDataTransferFiles(event).filter((file) =>
/image/i.test(file.type)
);
if (files.length === 0) {
return false;
}
// grab the position in the document for the cursor
const result = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (result) {
insertFiles(view, event, result.pos, files, options);
return true;
}
return false;
},
},
},
});
const IMAGE_CLASSES = ["right-50", "left-50"];
const getLayoutAndTitle = (tokenTitle: string | null) => {
if (!tokenTitle) return {};
if (IMAGE_CLASSES.includes(tokenTitle)) {
return {
layoutClass: tokenTitle,
};
} else {
return {
title: tokenTitle,
};
}
};
const downloadImageNode = async (node: ProsemirrorNode) => {
const image = await fetch(node.attrs.src);
const imageBlob = await image.blob();
const imageURL = URL.createObjectURL(imageBlob);
const extension = imageBlob.type.split("/")[1];
const potentialName = node.attrs.alt || "image";
// create a temporary link node and click it with our image data
const link = document.createElement("a");
link.href = imageURL;
link.download = `${potentialName}.${extension}`;
document.body.appendChild(link);
link.click();
// cleanup
document.body.removeChild(link);
};
export default class Image extends Node {
options: Options;
get name() {
return "image";
}
get schema(): NodeSpec {
return {
inline: true,
attrs: {
src: {},
alt: {
default: null,
},
layoutClass: {
default: null,
},
title: {
default: null,
},
},
content: "text*",
marks: "",
group: "inline",
selectable: true,
draggable: true,
parseDOM: [
{
tag: "div[class~=image]",
getAttrs: (dom: HTMLDivElement) => {
const img = dom.getElementsByTagName("img")[0];
const className = dom.className;
const layoutClassMatched =
className && className.match(/image-(.*)$/);
const layoutClass = layoutClassMatched
? layoutClassMatched[1]
: null;
return {
src: img?.getAttribute("src"),
alt: img?.getAttribute("alt"),
title: img?.getAttribute("title"),
layoutClass: layoutClass,
};
},
},
{
tag: "img",
getAttrs: (dom: HTMLImageElement) => {
return {
src: dom.getAttribute("src"),
alt: dom.getAttribute("alt"),
title: dom.getAttribute("title"),
};
},
},
],
toDOM: (node) => {
const className = node.attrs.layoutClass
? `image image-${node.attrs.layoutClass}`
: "image";
return [
"div",
{
class: className,
},
["img", { ...node.attrs, contentEditable: "false" }],
["p", { class: "caption" }, 0],
];
},
};
}
handleKeyDown = ({
node,
getPos,
}: {
node: ProsemirrorNode;
getPos: () => number;
}) => (event: React.KeyboardEvent<HTMLSpanElement>) => {
// Pressing Enter in the caption field should move the cursor/selection
// below the image
if (event.key === "Enter") {
event.preventDefault();
const { view } = this.editor;
const $pos = view.state.doc.resolve(getPos() + node.nodeSize);
view.dispatch(
view.state.tr.setSelection(new TextSelection($pos)).split($pos.pos)
);
view.focus();
return;
}
// Pressing Backspace in an an empty caption field should remove the entire
// image, leaving an empty paragraph
if (event.key === "Backspace" && event.currentTarget.innerText === "") {
const { view } = this.editor;
const $pos = view.state.doc.resolve(getPos());
const tr = view.state.tr.setSelection(new NodeSelection($pos));
view.dispatch(tr.deleteSelection());
view.focus();
return;
}
};
handleBlur = ({
node,
getPos,
}: {
node: ProsemirrorNode;
getPos: () => number;
}) => (event: React.FocusEvent<HTMLSpanElement>) => {
const alt = event.currentTarget.innerText;
const { src, title, layoutClass } = node.attrs;
if (alt === node.attrs.alt) return;
const { view } = this.editor;
const { tr } = view.state;
// update meta on object
const pos = getPos();
const transaction = tr.setNodeMarkup(pos, undefined, {
src,
alt,
title,
layoutClass,
});
view.dispatch(transaction);
};
handleSelect = ({ getPos }: { getPos: () => number }) => (
event: React.MouseEvent
) => {
event.preventDefault();
const { view } = this.editor;
const $pos = view.state.doc.resolve(getPos());
const transaction = view.state.tr.setSelection(new NodeSelection($pos));
view.dispatch(transaction);
};
handleDownload = ({ node }: { node: ProsemirrorNode }) => (
event: React.MouseEvent
) => {
event.preventDefault();
event.stopPropagation();
downloadImageNode(node);
};
component = (props: ComponentProps) => {
const { theme, isSelected } = props;
const { alt, src, layoutClass } = props.node.attrs;
const className = layoutClass ? `image image-${layoutClass}` : "image";
return (
<div contentEditable={false} className={className}>
<ImageWrapper
className={isSelected ? "ProseMirror-selectednode" : ""}
onClick={this.handleSelect(props)}
>
<Button>
<DownloadIcon
color="currentColor"
onClick={this.handleDownload(props)}
/>
</Button>
<ImageZoom
image={{
src,
alt,
}}
defaultStyles={{
overlay: {
backgroundColor: theme.background,
},
}}
shouldRespectMaxDimension
/>
</ImageWrapper>
<Caption
onKeyDown={this.handleKeyDown(props)}
onBlur={this.handleBlur(props)}
className="caption"
tabIndex={-1}
role="textbox"
contentEditable
suppressContentEditableWarning
data-caption={this.options.dictionary.imageCaptionPlaceholder}
>
{alt}
</Caption>
</div>
);
};
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
let markdown =
" ![" +
state.esc((node.attrs.alt || "").replace("\n", "") || "", false) +
"](" +
state.esc(node.attrs.src, false);
if (node.attrs.layoutClass) {
markdown += ' "' + state.esc(node.attrs.layoutClass, false) + '"';
} else if (node.attrs.title) {
markdown += ' "' + state.esc(node.attrs.title, false) + '"';
}
markdown += ")";
state.write(markdown);
}
parseMarkdown() {
return {
node: "image",
getAttrs: (token: Token) => {
return {
src: token.attrGet("src"),
alt:
(token?.children &&
token.children[0] &&
token.children[0].content) ||
null,
...getLayoutAndTitle(token?.attrGet("title")),
};
},
};
}
commands({ type }: { type: NodeType }) {
return {
downloadImage: () => (state: EditorState) => {
if (!(state.selection instanceof NodeSelection)) {
return false;
}
const { node } = state.selection;
if (node.type.name !== "image") {
return false;
}
downloadImageNode(node);
return true;
},
deleteImage: () => (
state: EditorState,
dispatch: (tr: Transaction) => void
) => {
dispatch(state.tr.deleteSelection());
return true;
},
alignRight: () => (
state: EditorState,
dispatch: (tr: Transaction) => void
) => {
if (!(state.selection instanceof NodeSelection)) {
return false;
}
const attrs = {
...state.selection.node.attrs,
title: null,
layoutClass: "right-50",
};
const { selection } = state;
dispatch(state.tr.setNodeMarkup(selection.from, undefined, attrs));
return true;
},
alignLeft: () => (
state: EditorState,
dispatch: (tr: Transaction) => void
) => {
if (!(state.selection instanceof NodeSelection)) {
return false;
}
const attrs = {
...state.selection.node.attrs,
title: null,
layoutClass: "left-50",
};
const { selection } = state;
dispatch(state.tr.setNodeMarkup(selection.from, undefined, attrs));
return true;
},
replaceImage: () => (state: EditorState) => {
const { view } = this.editor;
const {
uploadImage,
onImageUploadStart,
onImageUploadStop,
onShowToast,
} = this.editor.props;
if (!uploadImage) {
throw new Error("uploadImage prop is required to replace images");
}
// create an input element and click to trigger picker
const inputElement = document.createElement("input");
inputElement.type = "file";
inputElement.accept = "image/*";
inputElement.onchange = (event: Event) => {
const files = getDataTransferFiles(event);
insertFiles(view, event, state.selection.from, files, {
uploadImage,
onImageUploadStart,
onImageUploadStop,
onShowToast,
dictionary: this.options.dictionary,
replaceExisting: true,
});
};
inputElement.click();
return true;
},
alignCenter: () => (
state: EditorState,
dispatch: (tr: Transaction) => void
) => {
if (!(state.selection instanceof NodeSelection)) {
return false;
}
const attrs = { ...state.selection.node.attrs, layoutClass: null };
const { selection } = state;
dispatch(state.tr.setNodeMarkup(selection.from, undefined, attrs));
return true;
},
createImage: (attrs: Record<string, any>) => (
state: EditorState,
dispatch: (tr: Transaction) => void
) => {
const { selection } = state;
const position =
selection instanceof TextSelection
? selection.$cursor?.pos
: selection.$to.pos;
if (position === undefined) {
return false;
}
const node = type.create(attrs);
const transaction = state.tr.insert(position, node);
dispatch(transaction);
return true;
},
};
}
inputRules({ type }: { type: NodeType }) {
return [
new InputRule(IMAGE_INPUT_REGEX, (state, match, start, end) => {
const [okay, alt, src, matchedTitle] = match;
const { tr } = state;
if (okay) {
tr.replaceWith(
start - 1,
end,
type.create({
src,
alt,
...getLayoutAndTitle(matchedTitle),
})
);
}
return tr;
}),
];
}
get plugins() {
return [uploadPlaceholderPlugin, uploadPlugin(this.options)];
}
}
const Button = styled.button`
position: absolute;
top: 8px;
right: 8px;
border: 0;
margin: 0;
padding: 0;
border-radius: 4px;
background: ${(props) => props.theme.background};
color: ${(props) => props.theme.textSecondary};
width: 24px;
height: 24px;
display: inline-block;
cursor: pointer;
opacity: 0;
transition: opacity 100ms ease-in-out;
&:active {
transform: scale(0.98);
}
&:hover {
color: ${(props) => props.theme.text};
opacity: 1;
}
`;
const Caption = styled.p`
border: 0;
display: block;
font-size: 13px;
font-style: italic;
font-weight: normal;
color: ${(props) => props.theme.textSecondary};
padding: 2px 0;
line-height: 16px;
text-align: center;
min-height: 1em;
outline: none;
background: none;
resize: none;
user-select: text;
cursor: text;
&:empty:not(:focus) {
visibility: hidden;
}
&:empty:before {
color: ${(props) => props.theme.placeholder};
content: attr(data-caption);
pointer-events: none;
}
`;
const ImageWrapper = styled.span`
line-height: 0;
display: inline-block;
position: relative;
&:hover {
${Button} {
opacity: 0.9;
}
}
&.ProseMirror-selectednode + ${Caption} {
visibility: visible;
}
`;

View File

@@ -0,0 +1,283 @@
import { NodeSpec, NodeType, Node as ProsemirrorNode } from "prosemirror-model";
import {
splitListItem,
sinkListItem,
liftListItem,
} from "prosemirror-schema-list";
import {
Transaction,
EditorState,
Plugin,
TextSelection,
} from "prosemirror-state";
import { findParentNodeClosestToPos } from "prosemirror-utils";
import { DecorationSet, Decoration } from "prosemirror-view";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import getParentListItem from "../queries/getParentListItem";
import isInList from "../queries/isInList";
import isList from "../queries/isList";
import Node from "./Node";
export default class ListItem extends Node {
get name() {
return "list_item";
}
get schema(): NodeSpec {
return {
content: "paragraph block*",
defining: true,
draggable: true,
parseDOM: [{ tag: "li" }],
toDOM: () => ["li", 0],
};
}
get plugins() {
return [
new Plugin({
state: {
init() {
return DecorationSet.empty;
},
apply: (
tr: Transaction,
set: DecorationSet,
oldState: EditorState,
newState: EditorState
) => {
const action = tr.getMeta("li");
if (!action && !tr.docChanged) {
return set;
}
// Adjust decoration positions to changes made by the transaction
set = set.map(tr.mapping, tr.doc);
switch (action?.event) {
case "mouseover": {
const result = findParentNodeClosestToPos(
newState.doc.resolve(action.pos),
(node) =>
node.type.name === this.name ||
node.type.name === "checkbox_item"
);
if (!result) {
return set;
}
const list = findParentNodeClosestToPos(
newState.doc.resolve(action.pos),
(node) => isList(node, this.editor.schema)
);
if (!list) {
return set;
}
const start = list.node.attrs.order || 1;
let listItemNumber = 0;
list.node.content.forEach((li, _, index) => {
if (li === result.node) {
listItemNumber = index;
}
});
const counterLength = String(start + listItemNumber).length;
return set.add(tr.doc, [
Decoration.node(
result.pos,
result.pos + result.node.nodeSize,
{
class: `hovering`,
},
{
hover: true,
}
),
Decoration.node(
result.pos,
result.pos + result.node.nodeSize,
{
class: `counter-${counterLength}`,
}
),
]);
}
case "mouseout": {
const result = findParentNodeClosestToPos(
newState.doc.resolve(action.pos),
(node) =>
node.type.name === this.name ||
node.type.name === "checkbox_item"
);
if (!result) {
return set;
}
return set.remove(
set.find(
result.pos,
result.pos + result.node.nodeSize,
(spec) => spec.hover
)
);
}
default:
}
return set;
},
},
props: {
decorations(state) {
return this.getState(state);
},
handleDOMEvents: {
mouseover: (view, event) => {
const { state, dispatch } = view;
const target = event.target as HTMLElement;
const li = target?.closest("li");
if (!li) {
return false;
}
if (!view.dom.contains(li)) {
return false;
}
const pos = view.posAtDOM(li, 0);
if (!pos) {
return false;
}
dispatch(
state.tr.setMeta("li", {
event: "mouseover",
pos,
})
);
return false;
},
mouseout: (view, event) => {
const { state, dispatch } = view;
const target = event.target as HTMLElement;
const li = target?.closest("li");
if (!li) {
return false;
}
if (!view.dom.contains(li)) {
return false;
}
const pos = view.posAtDOM(li, 0);
if (!pos) {
return false;
}
dispatch(
state.tr.setMeta("li", {
event: "mouseout",
pos,
})
);
return false;
},
},
},
}),
];
}
keys({ type }: { type: NodeType }) {
return {
Enter: splitListItem(type),
Tab: sinkListItem(type),
"Shift-Tab": liftListItem(type),
"Mod-]": sinkListItem(type),
"Mod-[": liftListItem(type),
"Shift-Enter": (
state: EditorState,
dispatch: (tr: Transaction) => void
) => {
if (!isInList(state)) return false;
if (!state.selection.empty) return false;
const { tr, selection } = state;
dispatch(tr.split(selection.to));
return true;
},
"Alt-ArrowUp": (
state: EditorState,
dispatch: (tr: Transaction) => void
) => {
if (!state.selection.empty) return false;
const result = getParentListItem(state);
if (!result) return false;
const [li, pos] = result;
const $pos = state.doc.resolve(pos);
if (
!$pos.nodeBefore ||
!["list_item", "checkbox_item"].includes($pos.nodeBefore.type.name)
) {
console.log("Node before not a list item");
return false;
}
const { tr } = state;
const newPos = pos - $pos.nodeBefore.nodeSize;
dispatch(
tr
.delete(pos, pos + li.nodeSize)
.insert(newPos, li)
.setSelection(TextSelection.near(tr.doc.resolve(newPos)))
);
return true;
},
"Alt-ArrowDown": (
state: EditorState,
dispatch: (tr: Transaction) => void
) => {
if (!state.selection.empty) return false;
const result = getParentListItem(state);
if (!result) return false;
const [li, pos] = result;
const $pos = state.doc.resolve(pos + li.nodeSize);
if (
!$pos.nodeAfter ||
!["list_item", "checkbox_item"].includes($pos.nodeAfter.type.name)
) {
console.log("Node after not a list item");
return false;
}
const { tr } = state;
const newPos = pos + li.nodeSize + $pos.nodeAfter.nodeSize;
dispatch(
tr
.insert(newPos, li)
.setSelection(TextSelection.near(tr.doc.resolve(newPos)))
.delete(pos, pos + li.nodeSize)
);
return true;
},
};
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
state.renderContent(node);
}
parseMarkdown() {
return { block: "list_item" };
}
}

View File

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

View File

@@ -0,0 +1,132 @@
import Token from "markdown-it/lib/token";
import { WarningIcon, InfoIcon, StarredIcon } from "outline-icons";
import { wrappingInputRule } from "prosemirror-inputrules";
import { NodeSpec, Node as ProsemirrorNode, NodeType } from "prosemirror-model";
import * as React from "react";
import ReactDOM from "react-dom";
import toggleWrap from "../commands/toggleWrap";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import noticesRule from "../rules/notices";
import Node from "./Node";
export default class Notice extends Node {
get styleOptions() {
return Object.entries({
info: this.options.dictionary.info,
warning: this.options.dictionary.warning,
tip: this.options.dictionary.tip,
});
}
get name() {
return "container_notice";
}
get rulePlugins() {
return [noticesRule];
}
get schema(): NodeSpec {
return {
attrs: {
style: {
default: "info",
},
},
content: "block+",
group: "block",
defining: true,
draggable: true,
parseDOM: [
{
tag: "div.notice-block",
preserveWhitespace: "full",
contentElement: "div:last-child",
getAttrs: (dom: HTMLDivElement) => ({
style: dom.className.includes("tip")
? "tip"
: dom.className.includes("warning")
? "warning"
: undefined,
}),
},
],
toDOM: (node) => {
const select = document.createElement("select");
select.addEventListener("change", this.handleStyleChange);
this.styleOptions.forEach(([key, label]) => {
const option = document.createElement("option");
option.value = key;
option.innerText = label;
option.selected = node.attrs.style === key;
select.appendChild(option);
});
let component;
if (node.attrs.style === "tip") {
component = <StarredIcon color="currentColor" />;
} else if (node.attrs.style === "warning") {
component = <WarningIcon color="currentColor" />;
} else {
component = <InfoIcon color="currentColor" />;
}
const icon = document.createElement("div");
icon.className = "icon";
ReactDOM.render(component, icon);
return [
"div",
{ class: `notice-block ${node.attrs.style}` },
icon,
["div", { contentEditable: "false" }, select],
["div", { class: "content" }, 0],
];
},
};
}
commands({ type }: { type: NodeType }) {
return (attrs: Record<string, any>) => toggleWrap(type, attrs);
}
handleStyleChange = (event: InputEvent) => {
const { view } = this.editor;
const { tr } = view.state;
const element = event.target;
if (!(element instanceof HTMLSelectElement)) {
return;
}
const { top, left } = element.getBoundingClientRect();
const result = view.posAtCoords({ top, left });
if (result) {
const transaction = tr.setNodeMarkup(result.inside, undefined, {
style: element.value,
});
view.dispatch(transaction);
}
};
inputRules({ type }: { type: NodeType }) {
return [wrappingInputRule(/^:::$/, type)];
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
state.write("\n:::" + (node.attrs.style || "info") + "\n");
state.renderContent(node);
state.ensureNewLine();
state.write(":::");
state.closeBlock(node);
}
parseMarkdown() {
return {
block: "container_notice",
getAttrs: (tok: Token) => ({ style: tok.info }),
};
}
}

View File

@@ -0,0 +1,86 @@
import Token from "markdown-it/lib/token";
import { wrappingInputRule } from "prosemirror-inputrules";
import {
NodeSpec,
NodeType,
Schema,
Node as ProsemirrorNode,
} from "prosemirror-model";
import toggleList from "../commands/toggleList";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import Node from "./Node";
export default class OrderedList extends Node {
get name() {
return "ordered_list";
}
get schema(): NodeSpec {
return {
attrs: {
order: {
default: 1,
},
},
content: "list_item+",
group: "block",
parseDOM: [
{
tag: "ol",
getAttrs: (dom: HTMLOListElement) => ({
order: dom.hasAttribute("start")
? parseInt(dom.getAttribute("start") || "1", 10)
: 1,
}),
},
],
toDOM: (node) =>
node.attrs.order === 1
? ["ol", 0]
: ["ol", { start: node.attrs.order }, 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-9": toggleList(type, schema.nodes.list_item),
};
}
inputRules({ type }: { type: NodeType }) {
return [
wrappingInputRule(
/^(\d+)\.\s$/,
type,
(match) => ({ order: +match[1] }),
(match, node) => node.childCount + node.attrs.order === +match[1]
),
];
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
state.write("\n");
const start = node.attrs.order !== undefined ? node.attrs.order : 1;
const maxW = `${start + node.childCount - 1}`.length;
const space = state.repeat(" ", maxW + 2);
state.renderList(node, space, (index: number) => {
const nStr = `${start + index}`;
return state.repeat(" ", maxW - nStr.length) + nStr + ". ";
});
}
parseMarkdown() {
return {
block: "ordered_list",
getAttrs: (tok: Token) => ({
order: parseInt(tok.attrGet("start") || "1", 10),
}),
};
}
}

View File

@@ -0,0 +1,48 @@
import { setBlockType } from "prosemirror-commands";
import { NodeSpec, NodeType, Node as ProsemirrorNode } from "prosemirror-model";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import Node from "./Node";
export default class Paragraph extends Node {
get name() {
return "paragraph";
}
get schema(): NodeSpec {
return {
content: "inline*",
group: "block",
parseDOM: [{ tag: "p" }],
toDOM: () => ["p", 0],
};
}
keys({ type }: { type: NodeType }) {
return {
"Shift-Ctrl-0": setBlockType(type),
};
}
commands({ type }: { type: NodeType }) {
return () => setBlockType(type);
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
// render empty paragraphs as hard breaks to ensure that newlines are
// persisted between reloads (this breaks from markdown tradition)
if (
node.textContent.trim() === "" &&
node.childCount === 0 &&
!state.inTable
) {
state.write("\\\n");
} else {
state.renderInline(node);
state.closeBlock(node);
}
}
parseMarkdown() {
return { block: "paragraph" };
}
}

View File

@@ -0,0 +1,10 @@
import { ComponentProps } from "../types";
import Node from "./Node";
export default abstract class ReactNode extends Node {
abstract component({
node,
isSelected,
isEditable,
}: Omit<ComponentProps, "theme">): React.ReactElement;
}

View File

@@ -0,0 +1,188 @@
import { NodeSpec, Node as ProsemirrorNode, Schema } from "prosemirror-model";
import {
EditorState,
Plugin,
TextSelection,
Transaction,
} from "prosemirror-state";
import {
addColumnAfter,
addColumnBefore,
deleteColumn,
deleteRow,
deleteTable,
goToNextCell,
isInTable,
tableEditing,
toggleHeaderCell,
toggleHeaderColumn,
toggleHeaderRow,
} from "prosemirror-tables";
import {
addRowAt,
createTable,
getCellsInColumn,
moveRow,
} from "prosemirror-utils";
import { Decoration, DecorationSet } from "prosemirror-view";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import tablesRule from "../rules/tables";
import Node from "./Node";
export default class Table extends Node {
get name() {
return "table";
}
get schema(): NodeSpec {
return {
content: "tr+",
tableRole: "table",
isolating: true,
group: "block",
parseDOM: [{ tag: "table" }],
toDOM() {
return [
"div",
{ class: "scrollable-wrapper" },
[
"div",
{ class: "scrollable" },
["table", { class: "rme-table" }, ["tbody", 0]],
],
];
},
};
}
get rulePlugins() {
return [tablesRule];
}
commands({ schema }: { schema: Schema }) {
return {
createTable: ({
rowsCount,
colsCount,
}: {
rowsCount: number;
colsCount: number;
}) => (state: EditorState, dispatch: (tr: Transaction) => void) => {
const offset = state.tr.selection.anchor + 1;
const nodes = createTable(schema, rowsCount, colsCount);
const tr = state.tr.replaceSelectionWith(nodes).scrollIntoView();
const resolvedPos = tr.doc.resolve(offset);
tr.setSelection(TextSelection.near(resolvedPos));
dispatch(tr);
return true;
},
setColumnAttr: ({
index,
alignment,
}: {
index: number;
alignment: string;
}) => (state: EditorState, dispatch: (tr: Transaction) => void) => {
const cells = getCellsInColumn(index)(state.selection) || [];
let transaction = state.tr;
cells.forEach(({ pos }) => {
transaction = transaction.setNodeMarkup(pos, undefined, {
alignment,
});
});
dispatch(transaction);
return true;
},
addColumnBefore: () => addColumnBefore,
addColumnAfter: () => addColumnAfter,
deleteColumn: () => deleteColumn,
addRowAfter: ({ index }: { index: number }) => (
state: EditorState,
dispatch: (tr: Transaction) => void
) => {
if (index === 0) {
// A little hack to avoid cloning the heading row by cloning the row
// beneath and then moving it to the right index.
const tr = addRowAt(index + 2, true)(state.tr);
dispatch(moveRow(index + 2, index + 1)(tr));
} else {
dispatch(addRowAt(index + 1, true)(state.tr));
}
return true;
},
deleteRow: () => deleteRow,
deleteTable: () => deleteTable,
toggleHeaderColumn: () => toggleHeaderColumn,
toggleHeaderRow: () => toggleHeaderRow,
toggleHeaderCell: () => toggleHeaderCell,
};
}
keys() {
return {
Tab: goToNextCell(1),
"Shift-Tab": goToNextCell(-1),
Enter: (state: EditorState, dispatch: (tr: Transaction) => void) => {
if (!isInTable(state)) return false;
// TODO: Adding row at the end for now, can we find the current cell
// row index and add the row below that?
const cells = getCellsInColumn(0)(state.selection) || [];
dispatch(addRowAt(cells.length, true)(state.tr));
return true;
},
};
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
state.renderTable(node);
state.closeBlock(node);
}
parseMarkdown() {
return { block: "table" };
}
get plugins() {
return [
tableEditing(),
new Plugin({
props: {
decorations: (state) => {
const { doc } = state;
const decorations: Decoration[] = [];
let index = 0;
doc.descendants((node, pos) => {
if (node.type.name !== this.name) return;
const elements = document.getElementsByClassName("rme-table");
const table = elements[index];
if (!table) return;
const element = table.parentElement;
const shadowRight = !!(
element && element.scrollWidth > element.clientWidth
);
if (shadowRight) {
decorations.push(
Decoration.widget(pos + 1, () => {
const shadow = document.createElement("div");
shadow.className = "scrollable-shadow right";
return shadow;
})
);
}
index++;
});
return DecorationSet.create(doc, decorations);
},
},
}),
];
}
}

View File

@@ -0,0 +1,114 @@
import Token from "markdown-it/lib/token";
import { NodeSpec } from "prosemirror-model";
import { Plugin } from "prosemirror-state";
import {
isTableSelected,
isRowSelected,
getCellsInColumn,
} from "prosemirror-utils";
import { DecorationSet, Decoration } from "prosemirror-view";
import Node from "./Node";
export default class TableCell extends Node {
get name() {
return "td";
}
get schema(): NodeSpec {
return {
content: "paragraph+",
tableRole: "cell",
isolating: true,
parseDOM: [{ tag: "td" }],
toDOM(node) {
return [
"td",
node.attrs.alignment
? { style: `text-align: ${node.attrs.alignment}` }
: {},
0,
];
},
attrs: {
colspan: { default: 1 },
rowspan: { default: 1 },
alignment: { default: null },
},
};
}
toMarkdown() {
// see: renderTable
}
parseMarkdown() {
return {
block: "td",
getAttrs: (tok: Token) => ({ alignment: tok.info }),
};
}
get plugins() {
return [
new Plugin({
props: {
decorations: (state) => {
const { doc, selection } = state;
const decorations: Decoration[] = [];
const cells = getCellsInColumn(0)(selection);
if (cells) {
cells.forEach(({ pos }, index) => {
if (index === 0) {
decorations.push(
Decoration.widget(pos + 1, () => {
let className = "grip-table";
const selected = isTableSelected(selection);
if (selected) {
className += " selected";
}
const grip = document.createElement("a");
grip.className = className;
grip.addEventListener("mousedown", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
this.options.onSelectTable(state);
});
return grip;
})
);
}
decorations.push(
Decoration.widget(pos + 1, () => {
const rowSelected = isRowSelected(index)(selection);
let className = "grip-row";
if (rowSelected) {
className += " selected";
}
if (index === 0) {
className += " first";
}
if (index === cells.length - 1) {
className += " last";
}
const grip = document.createElement("a");
grip.className = className;
grip.addEventListener("mousedown", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
this.options.onSelectRow(index, state);
});
return grip;
})
);
});
}
return DecorationSet.create(doc, decorations);
},
},
}),
];
}
}

View File

@@ -0,0 +1,89 @@
import Token from "markdown-it/lib/token";
import { NodeSpec } from "prosemirror-model";
import { Plugin } from "prosemirror-state";
import { isColumnSelected, getCellsInRow } from "prosemirror-utils";
import { DecorationSet, Decoration } from "prosemirror-view";
import Node from "./Node";
export default class TableHeadCell extends Node {
get name() {
return "th";
}
get schema(): NodeSpec {
return {
content: "paragraph+",
tableRole: "header_cell",
isolating: true,
parseDOM: [{ tag: "th" }],
toDOM(node) {
return [
"th",
node.attrs.alignment
? { style: `text-align: ${node.attrs.alignment}` }
: {},
0,
];
},
attrs: {
colspan: { default: 1 },
rowspan: { default: 1 },
alignment: { default: null },
},
};
}
toMarkdown() {
// see: renderTable
}
parseMarkdown() {
return {
block: "th",
getAttrs: (tok: Token) => ({ alignment: tok.info }),
};
}
get plugins() {
return [
new Plugin({
props: {
decorations: (state) => {
const { doc, selection } = state;
const decorations: Decoration[] = [];
const cells = getCellsInRow(0)(selection);
if (cells) {
cells.forEach(({ pos }, index) => {
decorations.push(
Decoration.widget(pos + 1, () => {
const colSelected = isColumnSelected(index)(selection);
let className = "grip-column";
if (colSelected) {
className += " selected";
}
if (index === 0) {
className += " first";
} else if (index === cells.length - 1) {
className += " last";
}
const grip = document.createElement("a");
grip.className = className;
grip.addEventListener("mousedown", (event) => {
event.preventDefault();
event.stopImmediatePropagation();
this.options.onSelectColumn(index, state);
});
return grip;
})
);
});
}
return DecorationSet.create(doc, decorations);
},
},
}),
];
}
}

View File

@@ -0,0 +1,23 @@
import { NodeSpec } from "prosemirror-model";
import Node from "./Node";
export default class TableRow extends Node {
get name() {
return "tr";
}
get schema(): NodeSpec {
return {
content: "(th | td)*",
tableRole: "row",
parseDOM: [{ tag: "tr" }],
toDOM() {
return ["tr", 0];
},
};
}
parseMarkdown() {
return { block: "tr" };
}
}

View File

@@ -0,0 +1,19 @@
import { Node as ProsemirrorNode, NodeSpec } from "prosemirror-model";
import { MarkdownSerializerState } from "../lib/markdown/serializer";
import Node from "./Node";
export default class Text extends Node {
get name() {
return "text";
}
get schema(): NodeSpec {
return {
group: "inline",
};
}
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
state.text(node.text || "", undefined);
}
}