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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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