chore: Move editor into codebase (#2930)
This commit is contained in:
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" };
|
||||
}
|
||||
}
|
||||
107
shared/editor/nodes/CheckboxItem.ts
Normal file
107
shared/editor/nodes/CheckboxItem.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
51
shared/editor/nodes/CheckboxList.ts
Normal file
51
shared/editor/nodes/CheckboxList.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
11
shared/editor/nodes/CodeBlock.ts
Normal file
11
shared/editor/nodes/CodeBlock.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
251
shared/editor/nodes/CodeFence.ts
Normal file
251
shared/editor/nodes/CodeFence.ts
Normal 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 }),
|
||||
};
|
||||
}
|
||||
}
|
||||
14
shared/editor/nodes/Doc.ts
Normal file
14
shared/editor/nodes/Doc.ts
Normal 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+",
|
||||
};
|
||||
}
|
||||
}
|
||||
126
shared/editor/nodes/Embed.tsx
Normal file
126
shared/editor/nodes/Embed.tsx
Normal 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"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
120
shared/editor/nodes/Emoji.tsx
Normal file
120
shared/editor/nodes/Emoji.tsx
Normal 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() };
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
56
shared/editor/nodes/HardBreak.ts
Normal file
56
shared/editor/nodes/HardBreak.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
280
shared/editor/nodes/Heading.ts
Normal file
280
shared/editor/nodes/Heading.ts
Normal 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,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
80
shared/editor/nodes/HorizontalRule.ts
Normal file
80
shared/editor/nodes/HorizontalRule.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
574
shared/editor/nodes/Image.tsx
Normal file
574
shared/editor/nodes/Image.tsx
Normal 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"]
|
||||
*  -> [, "", "image.jpg", "small"]
|
||||
*  -> [, "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 =
|
||||
" ;
|
||||
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;
|
||||
}
|
||||
`;
|
||||
283
shared/editor/nodes/ListItem.ts
Normal file
283
shared/editor/nodes/ListItem.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
47
shared/editor/nodes/Node.ts
Normal file
47
shared/editor/nodes/Node.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
132
shared/editor/nodes/Notice.tsx
Normal file
132
shared/editor/nodes/Notice.tsx
Normal 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 }),
|
||||
};
|
||||
}
|
||||
}
|
||||
86
shared/editor/nodes/OrderedList.ts
Normal file
86
shared/editor/nodes/OrderedList.ts
Normal 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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
48
shared/editor/nodes/Paragraph.ts
Normal file
48
shared/editor/nodes/Paragraph.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
10
shared/editor/nodes/ReactNode.ts
Normal file
10
shared/editor/nodes/ReactNode.ts
Normal 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;
|
||||
}
|
||||
188
shared/editor/nodes/Table.ts
Normal file
188
shared/editor/nodes/Table.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
114
shared/editor/nodes/TableCell.ts
Normal file
114
shared/editor/nodes/TableCell.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
89
shared/editor/nodes/TableHeadCell.ts
Normal file
89
shared/editor/nodes/TableHeadCell.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
23
shared/editor/nodes/TableRow.ts
Normal file
23
shared/editor/nodes/TableRow.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
19
shared/editor/nodes/Text.ts
Normal file
19
shared/editor/nodes/Text.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user