chore: Move editor into codebase (#2930)
This commit is contained in:
76
shared/editor/lib/Extension.ts
Normal file
76
shared/editor/lib/Extension.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { PluginSimple } from "markdown-it";
|
||||
import { InputRule } from "prosemirror-inputrules";
|
||||
import { NodeType, MarkType, Schema } from "prosemirror-model";
|
||||
import { EditorState, Plugin, Transaction } from "prosemirror-state";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import { Editor } from "../../../app/editor";
|
||||
|
||||
export type Command = (
|
||||
state: EditorState,
|
||||
dispatch: (tr: Transaction) => void
|
||||
) => boolean;
|
||||
|
||||
export type CommandFactory = (
|
||||
attrs?: Record<string, any>
|
||||
) => (
|
||||
state: EditorState,
|
||||
dispatch: (tr: Transaction) => void,
|
||||
view: EditorView
|
||||
) => boolean;
|
||||
|
||||
export default class Extension {
|
||||
options: any;
|
||||
editor: Editor;
|
||||
|
||||
constructor(options: Record<string, any> = {}) {
|
||||
this.options = {
|
||||
...this.defaultOptions,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
bindEditor(editor: Editor) {
|
||||
this.editor = editor;
|
||||
}
|
||||
|
||||
get type() {
|
||||
return "extension";
|
||||
}
|
||||
|
||||
get name() {
|
||||
return "";
|
||||
}
|
||||
|
||||
get plugins(): Plugin[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
get rulePlugins(): PluginSimple[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
get defaultOptions() {
|
||||
return {};
|
||||
}
|
||||
|
||||
keys(_options: {
|
||||
type?: NodeType | MarkType;
|
||||
schema: Schema;
|
||||
}): Record<string, Command> {
|
||||
return {};
|
||||
}
|
||||
|
||||
inputRules(_options: {
|
||||
type?: NodeType | MarkType;
|
||||
schema: Schema;
|
||||
}): InputRule[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
commands(_options: {
|
||||
type?: NodeType | MarkType;
|
||||
schema: Schema;
|
||||
}): Record<string, CommandFactory> | CommandFactory {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
213
shared/editor/lib/ExtensionManager.ts
Normal file
213
shared/editor/lib/ExtensionManager.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { PluginSimple } from "markdown-it";
|
||||
import { keymap } from "prosemirror-keymap";
|
||||
import { MarkdownParser, TokenConfig } from "prosemirror-markdown";
|
||||
import { Schema } from "prosemirror-model";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import Mark from "../marks/Mark";
|
||||
import Node from "../nodes/Node";
|
||||
import Extension, { CommandFactory } from "./Extension";
|
||||
import makeRules from "./markdown/rules";
|
||||
import { MarkdownSerializer } from "./markdown/serializer";
|
||||
|
||||
export default class ExtensionManager {
|
||||
extensions: (Node | Mark | Extension)[];
|
||||
|
||||
constructor(extensions: (Node | Mark | Extension)[] = [], editor?: any) {
|
||||
if (editor) {
|
||||
extensions.forEach((extension) => {
|
||||
extension.bindEditor(editor);
|
||||
});
|
||||
}
|
||||
|
||||
this.extensions = extensions;
|
||||
}
|
||||
|
||||
get nodes() {
|
||||
return this.extensions
|
||||
.filter((extension) => extension.type === "node")
|
||||
.reduce(
|
||||
(nodes, node: Node) => ({
|
||||
...nodes,
|
||||
[node.name]: node.schema,
|
||||
}),
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
serializer() {
|
||||
const nodes = this.extensions
|
||||
.filter((extension) => extension.type === "node")
|
||||
.reduce(
|
||||
(nodes, extension: Node) => ({
|
||||
...nodes,
|
||||
[extension.name]: extension.toMarkdown,
|
||||
}),
|
||||
{}
|
||||
);
|
||||
|
||||
const marks = this.extensions
|
||||
.filter((extension) => extension.type === "mark")
|
||||
.reduce(
|
||||
(marks, extension: Mark) => ({
|
||||
...marks,
|
||||
[extension.name]: extension.toMarkdown,
|
||||
}),
|
||||
{}
|
||||
);
|
||||
|
||||
return new MarkdownSerializer(nodes, marks);
|
||||
}
|
||||
|
||||
parser({
|
||||
schema,
|
||||
rules,
|
||||
plugins,
|
||||
}: {
|
||||
schema: Schema;
|
||||
rules?: Record<string, any>;
|
||||
plugins?: PluginSimple[];
|
||||
}): MarkdownParser {
|
||||
const tokens: Record<string, TokenConfig> = this.extensions
|
||||
.filter(
|
||||
(extension) => extension.type === "mark" || extension.type === "node"
|
||||
)
|
||||
.reduce((nodes, extension: Node | Mark) => {
|
||||
const md = extension.parseMarkdown();
|
||||
if (!md) return nodes;
|
||||
|
||||
return {
|
||||
...nodes,
|
||||
[extension.markdownToken || extension.name]: md,
|
||||
};
|
||||
}, {});
|
||||
|
||||
return new MarkdownParser(schema, makeRules({ rules, plugins }), tokens);
|
||||
}
|
||||
|
||||
get marks() {
|
||||
return this.extensions
|
||||
.filter((extension) => extension.type === "mark")
|
||||
.reduce(
|
||||
(marks, { name, schema }: Mark) => ({
|
||||
...marks,
|
||||
[name]: schema,
|
||||
}),
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
get plugins() {
|
||||
return this.extensions
|
||||
.filter((extension) => "plugins" in extension)
|
||||
.reduce((allPlugins, { plugins }) => [...allPlugins, ...plugins], []);
|
||||
}
|
||||
|
||||
get rulePlugins() {
|
||||
return this.extensions
|
||||
.filter((extension) => "rulePlugins" in extension)
|
||||
.reduce(
|
||||
(allRulePlugins, { rulePlugins }) => [
|
||||
...allRulePlugins,
|
||||
...rulePlugins,
|
||||
],
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
keymaps({ schema }: { schema: Schema }) {
|
||||
const extensionKeymaps = this.extensions
|
||||
.filter((extension) => ["extension"].includes(extension.type))
|
||||
.filter((extension) => extension.keys)
|
||||
.map((extension: Extension) => extension.keys({ schema }));
|
||||
|
||||
const nodeKeymaps = this.extensions
|
||||
.filter((extension) => ["node", "mark"].includes(extension.type))
|
||||
.filter((extension) => extension.keys)
|
||||
.map((extension: Node | Mark) =>
|
||||
extension.keys({
|
||||
type: schema[`${extension.type}s`][extension.name],
|
||||
schema,
|
||||
})
|
||||
);
|
||||
|
||||
return [
|
||||
...extensionKeymaps,
|
||||
...nodeKeymaps,
|
||||
].map((keys: Record<string, any>) => keymap(keys));
|
||||
}
|
||||
|
||||
inputRules({ schema }: { schema: Schema }) {
|
||||
const extensionInputRules = this.extensions
|
||||
.filter((extension) => ["extension"].includes(extension.type))
|
||||
.filter((extension) => extension.inputRules)
|
||||
.map((extension: Extension) => extension.inputRules({ schema }));
|
||||
|
||||
const nodeMarkInputRules = this.extensions
|
||||
.filter((extension) => ["node", "mark"].includes(extension.type))
|
||||
.filter((extension) => extension.inputRules)
|
||||
.map((extension) =>
|
||||
extension.inputRules({
|
||||
type: schema[`${extension.type}s`][extension.name],
|
||||
schema,
|
||||
})
|
||||
);
|
||||
|
||||
return [...extensionInputRules, ...nodeMarkInputRules].reduce(
|
||||
(allInputRules, inputRules) => [...allInputRules, ...inputRules],
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
commands({ schema, view }: { schema: Schema; view: EditorView }) {
|
||||
return this.extensions
|
||||
.filter((extension) => extension.commands)
|
||||
.reduce((allCommands, extension) => {
|
||||
const { name, type } = extension;
|
||||
const commands = {};
|
||||
|
||||
// @ts-expect-error FIXME
|
||||
const value = extension.commands({
|
||||
schema,
|
||||
...(["node", "mark"].includes(type)
|
||||
? {
|
||||
type: schema[`${type}s`][name],
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const apply = (
|
||||
callback: CommandFactory,
|
||||
attrs: Record<string, any>
|
||||
) => {
|
||||
if (!view.editable) {
|
||||
return false;
|
||||
}
|
||||
view.focus();
|
||||
return callback(attrs)(view.state, view.dispatch, view);
|
||||
};
|
||||
|
||||
const handle = (_name: string, _value: CommandFactory) => {
|
||||
if (Array.isArray(_value)) {
|
||||
commands[_name] = (attrs: Record<string, any>) =>
|
||||
_value.forEach((callback) => apply(callback, attrs));
|
||||
} else if (typeof _value === "function") {
|
||||
commands[_name] = (attrs: Record<string, any>) =>
|
||||
apply(_value, attrs);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof value === "object") {
|
||||
Object.entries(value).forEach(([commandName, commandValue]) => {
|
||||
handle(commandName, commandValue);
|
||||
});
|
||||
} else {
|
||||
handle(name, value);
|
||||
}
|
||||
|
||||
return {
|
||||
...allCommands,
|
||||
...commands,
|
||||
};
|
||||
}, {});
|
||||
}
|
||||
}
|
||||
23
shared/editor/lib/filterExcessSeparators.ts
Normal file
23
shared/editor/lib/filterExcessSeparators.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { EmbedDescriptor, MenuItem } from "../types";
|
||||
|
||||
export default function filterExcessSeparators(
|
||||
items: (MenuItem | EmbedDescriptor)[]
|
||||
): (MenuItem | EmbedDescriptor)[] {
|
||||
return items.reduce((acc, item, index) => {
|
||||
// trim separators from start / end
|
||||
if (item.name === "separator" && index === 0) return acc;
|
||||
if (item.name === "separator" && index === items.length - 1) return acc;
|
||||
|
||||
// trim double separators looking ahead / behind
|
||||
const prev = items[index - 1];
|
||||
if (prev && prev.name === "separator" && item.name === "separator")
|
||||
return acc;
|
||||
|
||||
const next = items[index + 1];
|
||||
if (next && next.name === "separator" && item.name === "separator")
|
||||
return acc;
|
||||
|
||||
// otherwise, continue
|
||||
return [...acc, item];
|
||||
}, []);
|
||||
}
|
||||
33
shared/editor/lib/getHeadings.ts
Normal file
33
shared/editor/lib/getHeadings.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import headingToSlug from "./headingToSlug";
|
||||
|
||||
export default function getHeadings(view: EditorView) {
|
||||
const headings: { title: string; level: number; id: string }[] = [];
|
||||
const previouslySeen = {};
|
||||
|
||||
view.state.doc.forEach((node) => {
|
||||
if (node.type.name === "heading") {
|
||||
// calculate the optimal id
|
||||
const id = headingToSlug(node);
|
||||
let name = id;
|
||||
|
||||
// check if we've already used it, and if so how many times?
|
||||
// Make the new id based on that number ensuring that we have
|
||||
// unique ID's even when headings are identical
|
||||
if (previouslySeen[id] > 0) {
|
||||
name = headingToSlug(node, previouslySeen[id]);
|
||||
}
|
||||
|
||||
// record that we've seen this id for the next loop
|
||||
previouslySeen[id] =
|
||||
previouslySeen[id] !== undefined ? previouslySeen[id] + 1 : 1;
|
||||
|
||||
headings.push({
|
||||
title: node.textContent,
|
||||
level: node.attrs.level,
|
||||
id: name,
|
||||
});
|
||||
}
|
||||
});
|
||||
return headings;
|
||||
}
|
||||
26
shared/editor/lib/getMarkAttrs.ts
Normal file
26
shared/editor/lib/getMarkAttrs.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Node as ProsemirrorNode, Mark } from "prosemirror-model";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import Node from "../nodes/Node";
|
||||
|
||||
export default function getMarkAttrs(state: EditorState, type: Node) {
|
||||
const { from, to } = state.selection;
|
||||
let marks: Mark[] = [];
|
||||
|
||||
state.doc.nodesBetween(from, to, (node: ProsemirrorNode) => {
|
||||
marks = [...marks, ...node.marks];
|
||||
|
||||
if (node.content) {
|
||||
node.content.forEach((content) => {
|
||||
marks = [...marks, ...content.marks];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const mark = marks.find((markItem) => markItem.type.name === type.name);
|
||||
|
||||
if (mark) {
|
||||
return mark.attrs;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
28
shared/editor/lib/headingToSlug.ts
Normal file
28
shared/editor/lib/headingToSlug.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { escape } from "lodash";
|
||||
import { Node } from "prosemirror-model";
|
||||
import slugify from "slugify";
|
||||
|
||||
// Slugify, escape, and remove periods from headings so that they are
|
||||
// compatible with both url hashes AND dom ID's (querySelector does not like
|
||||
// ID's that begin with a number or a period, for example).
|
||||
function safeSlugify(text: string) {
|
||||
return `h-${escape(
|
||||
slugify(text, {
|
||||
remove: /[!"#$%&'\.()*+,\/:;<=>?@\[\]\\^_`{|}~]/g,
|
||||
lower: true,
|
||||
})
|
||||
)}`;
|
||||
}
|
||||
|
||||
// calculates a unique slug for this heading based on it's text and position
|
||||
// in the document that is as stable as possible
|
||||
export default function headingToSlug(node: Node, index = 0) {
|
||||
const slugified = safeSlugify(node.textContent);
|
||||
if (index === 0) return slugified;
|
||||
return `${slugified}-${index}`;
|
||||
}
|
||||
|
||||
export function headingToPersistenceKey(node: Node, id?: string) {
|
||||
const slug = headingToSlug(node);
|
||||
return `rme-${id || window?.location.pathname}–${slug}`;
|
||||
}
|
||||
71
shared/editor/lib/isMarkdown.test.ts
Normal file
71
shared/editor/lib/isMarkdown.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import isMarkdown from "./isMarkdown";
|
||||
|
||||
test("returns false for an empty string", () => {
|
||||
expect(isMarkdown("")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for plain text", () => {
|
||||
expect(isMarkdown("plain text")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true for bullet list", () => {
|
||||
expect(
|
||||
isMarkdown(`- item one
|
||||
- item two
|
||||
- nested item`)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for numbered list", () => {
|
||||
expect(
|
||||
isMarkdown(`1. item one
|
||||
1. item two`)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isMarkdown(`1. item one
|
||||
2. item two`)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for code fence", () => {
|
||||
expect(
|
||||
isMarkdown(`\`\`\`javascript
|
||||
this is code
|
||||
\`\`\``)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for non-closed fence", () => {
|
||||
expect(
|
||||
isMarkdown(`\`\`\`
|
||||
this is not code
|
||||
`)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true for heading", () => {
|
||||
expect(isMarkdown(`# Heading 1`)).toBe(true);
|
||||
expect(isMarkdown(`## Heading 2`)).toBe(true);
|
||||
expect(isMarkdown(`### Heading 3`)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for hashtag", () => {
|
||||
expect(isMarkdown(`Test #hashtag`)).toBe(false);
|
||||
expect(isMarkdown(` #hashtag`)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true for absolute link", () => {
|
||||
expect(isMarkdown(`[title](http://www.google.com)`)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for relative link", () => {
|
||||
expect(isMarkdown(`[title](/doc/mydoc-234tnes)`)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for relative image", () => {
|
||||
expect(isMarkdown(``)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for absolute image", () => {
|
||||
expect(isMarkdown(``)).toBe(true);
|
||||
});
|
||||
18
shared/editor/lib/isMarkdown.ts
Normal file
18
shared/editor/lib/isMarkdown.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export default function isMarkdown(text: string): boolean {
|
||||
// code-ish
|
||||
const fences = text.match(/^```/gm);
|
||||
if (fences && fences.length > 1) return true;
|
||||
|
||||
// link-ish
|
||||
if (text.match(/\[[^]+\]\(https?:\/\/\S+\)/gm)) return true;
|
||||
if (text.match(/\[[^]+\]\(\/\S+\)/gm)) return true;
|
||||
|
||||
// heading-ish
|
||||
if (text.match(/^#{1,6}\s+\S+/gm)) return true;
|
||||
|
||||
// list-ish
|
||||
const listItems = text.match(/^[\d-*].?\s\S+/gm);
|
||||
if (listItems && listItems.length > 1) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
6
shared/editor/lib/isModKey.ts
Normal file
6
shared/editor/lib/isModKey.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
const SSR = typeof window === "undefined";
|
||||
const isMac = !SSR && window.navigator.platform === "MacIntel";
|
||||
|
||||
export default function isModKey(event: KeyboardEvent | MouseEvent): boolean {
|
||||
return isMac ? event.metaKey : event.ctrlKey;
|
||||
}
|
||||
12
shared/editor/lib/isUrl.ts
Normal file
12
shared/editor/lib/isUrl.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export default function isUrl(text: string) {
|
||||
if (text.match(/\n/)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(text);
|
||||
return url.hostname !== "";
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
65
shared/editor/lib/markInputRule.ts
Normal file
65
shared/editor/lib/markInputRule.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { InputRule } from "prosemirror-inputrules";
|
||||
import { MarkType, Mark } from "prosemirror-model";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
|
||||
function getMarksBetween(start: number, end: number, state: EditorState) {
|
||||
let marks: { start: number; end: number; mark: Mark }[] = [];
|
||||
|
||||
state.doc.nodesBetween(start, end, (node, pos) => {
|
||||
marks = [
|
||||
...marks,
|
||||
...node.marks.map((mark) => ({
|
||||
start: pos,
|
||||
end: pos + node.nodeSize,
|
||||
mark,
|
||||
})),
|
||||
];
|
||||
});
|
||||
|
||||
return marks;
|
||||
}
|
||||
|
||||
export default function (
|
||||
regexp: RegExp,
|
||||
markType: MarkType,
|
||||
getAttrs?: (match: string[]) => Record<string, unknown>
|
||||
): InputRule {
|
||||
return new InputRule(
|
||||
regexp,
|
||||
(state: EditorState, match: string[], start: number, end: number) => {
|
||||
const attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs;
|
||||
const { tr } = state;
|
||||
const m = match.length - 1;
|
||||
let markEnd = end;
|
||||
let markStart = start;
|
||||
|
||||
if (match[m]) {
|
||||
const matchStart = start + match[0].indexOf(match[m - 1]);
|
||||
const matchEnd = matchStart + match[m - 1].length - 1;
|
||||
const textStart = matchStart + match[m - 1].lastIndexOf(match[m]);
|
||||
const textEnd = textStart + match[m].length;
|
||||
|
||||
const excludedMarks = getMarksBetween(start, end, state)
|
||||
.filter((item) => item.mark.type.excludes(markType))
|
||||
.filter((item) => item.end > matchStart);
|
||||
|
||||
if (excludedMarks.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (textEnd < matchEnd) {
|
||||
tr.delete(textEnd, matchEnd);
|
||||
}
|
||||
if (textStart > matchStart) {
|
||||
tr.delete(matchStart, textStart);
|
||||
}
|
||||
markStart = matchStart;
|
||||
markEnd = markStart + match[m].length;
|
||||
}
|
||||
|
||||
tr.addMark(markStart, markEnd, markType.create(attrs));
|
||||
tr.removeStoredMark(markType);
|
||||
return tr;
|
||||
}
|
||||
);
|
||||
}
|
||||
18
shared/editor/lib/markdown/rules.ts
Normal file
18
shared/editor/lib/markdown/rules.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import markdownit, { PluginSimple } from "markdown-it";
|
||||
|
||||
export default function rules({
|
||||
rules = {},
|
||||
plugins = [],
|
||||
}: {
|
||||
rules?: Record<string, any>;
|
||||
plugins?: PluginSimple[];
|
||||
}) {
|
||||
const markdownIt = markdownit("default", {
|
||||
breaks: false,
|
||||
html: false,
|
||||
linkify: false,
|
||||
...rules,
|
||||
});
|
||||
plugins.forEach((plugin) => markdownIt.use(plugin));
|
||||
return markdownIt;
|
||||
}
|
||||
412
shared/editor/lib/markdown/serializer.ts
Normal file
412
shared/editor/lib/markdown/serializer.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
// @ts-nocheck
|
||||
// https://raw.githubusercontent.com/ProseMirror/prosemirror-markdown/master/src/to_markdown.js
|
||||
// forked for table support
|
||||
|
||||
// ::- A specification for serializing a ProseMirror document as
|
||||
// Markdown/CommonMark text.
|
||||
export class MarkdownSerializer {
|
||||
// :: (Object<(state: MarkdownSerializerState, node: Node, parent: Node, index: number)>, Object)
|
||||
// Construct a serializer with the given configuration. The `nodes`
|
||||
// object should map node names in a given schema to function that
|
||||
// take a serializer state and such a node, and serialize the node.
|
||||
//
|
||||
// The `marks` object should hold objects with `open` and `close`
|
||||
// properties, which hold the strings that should appear before and
|
||||
// after a piece of text marked that way, either directly or as a
|
||||
// function that takes a serializer state and a mark, and returns a
|
||||
// string. `open` and `close` can also be functions, which will be
|
||||
// called as
|
||||
//
|
||||
// (state: MarkdownSerializerState, mark: Mark,
|
||||
// parent: Fragment, index: number) → string
|
||||
//
|
||||
// Where `parent` and `index` allow you to inspect the mark's
|
||||
// context to see which nodes it applies to.
|
||||
//
|
||||
// Mark information objects can also have a `mixable` property
|
||||
// which, when `true`, indicates that the order in which the mark's
|
||||
// opening and closing syntax appears relative to other mixable
|
||||
// marks can be varied. (For example, you can say `**a *b***` and
|
||||
// `*a **b***`, but not `` `a *b*` ``.)
|
||||
//
|
||||
// To disable character escaping in a mark, you can give it an
|
||||
// `escape` property of `false`. Such a mark has to have the highest
|
||||
// precedence (must always be the innermost mark).
|
||||
//
|
||||
// The `expelEnclosingWhitespace` mark property causes the
|
||||
// serializer to move enclosing whitespace from inside the marks to
|
||||
// outside the marks. This is necessary for emphasis marks as
|
||||
// CommonMark does not permit enclosing whitespace inside emphasis
|
||||
// marks, see: http://spec.commonmark.org/0.26/#example-330
|
||||
constructor(nodes, marks) {
|
||||
// :: Object<(MarkdownSerializerState, Node)> The node serializer
|
||||
// functions for this serializer.
|
||||
this.nodes = nodes;
|
||||
// :: Object The mark serializer info.
|
||||
this.marks = marks;
|
||||
}
|
||||
|
||||
// :: (Node, ?Object) → string
|
||||
// Serialize the content of the given node to
|
||||
// [CommonMark](http://commonmark.org/).
|
||||
serialize(content, options?: { tightLists?: boolean }) {
|
||||
const state = new MarkdownSerializerState(this.nodes, this.marks, options);
|
||||
state.renderContent(content);
|
||||
return state.out;
|
||||
}
|
||||
}
|
||||
|
||||
// ::- This is an object used to track state and expose
|
||||
// methods related to markdown serialization. Instances are passed to
|
||||
// node and mark serialization methods (see `toMarkdown`).
|
||||
export class MarkdownSerializerState {
|
||||
inTable = false;
|
||||
inTightList = false;
|
||||
closed = false;
|
||||
delim = "";
|
||||
|
||||
constructor(nodes, marks, options) {
|
||||
this.nodes = nodes;
|
||||
this.marks = marks;
|
||||
this.delim = this.out = "";
|
||||
this.closed = false;
|
||||
this.inTightList = false;
|
||||
this.inTable = false;
|
||||
// :: Object
|
||||
// The options passed to the serializer.
|
||||
// tightLists:: ?bool
|
||||
// Whether to render lists in a tight style. This can be overridden
|
||||
// on a node level by specifying a tight attribute on the node.
|
||||
// Defaults to false.
|
||||
this.options = options || {};
|
||||
if (typeof this.options.tightLists === "undefined")
|
||||
this.options.tightLists = true;
|
||||
}
|
||||
|
||||
flushClose(size) {
|
||||
if (this.closed) {
|
||||
if (!this.atBlank()) this.out += "\n";
|
||||
if (size === null || size === undefined) size = 2;
|
||||
if (size > 1) {
|
||||
let delimMin = this.delim;
|
||||
const trim = /\s+$/.exec(delimMin);
|
||||
if (trim)
|
||||
delimMin = delimMin.slice(0, delimMin.length - trim[0].length);
|
||||
for (let i = 1; i < size; i++) this.out += delimMin + "\n";
|
||||
}
|
||||
this.closed = false;
|
||||
}
|
||||
}
|
||||
|
||||
// :: (string, ?string, Node, ())
|
||||
// Render a block, prefixing each line with `delim`, and the first
|
||||
// line in `firstDelim`. `node` should be the node that is closed at
|
||||
// the end of the block, and `f` is a function that renders the
|
||||
// content of the block.
|
||||
wrapBlock(delim, firstDelim, node, f) {
|
||||
const old = this.delim;
|
||||
this.write(firstDelim || delim);
|
||||
this.delim += delim;
|
||||
f();
|
||||
this.delim = old;
|
||||
this.closeBlock(node);
|
||||
}
|
||||
|
||||
atBlank() {
|
||||
return /(^|\n)$/.test(this.out);
|
||||
}
|
||||
|
||||
// :: ()
|
||||
// Ensure the current content ends with a newline.
|
||||
ensureNewLine() {
|
||||
if (!this.atBlank()) this.out += "\n";
|
||||
}
|
||||
|
||||
// :: (?string)
|
||||
// Prepare the state for writing output (closing closed paragraphs,
|
||||
// adding delimiters, and so on), and then optionally add content
|
||||
// (unescaped) to the output.
|
||||
write(content) {
|
||||
this.flushClose();
|
||||
if (this.delim && this.atBlank()) this.out += this.delim;
|
||||
if (content) this.out += content;
|
||||
}
|
||||
|
||||
// :: (Node)
|
||||
// Close the block for the given node.
|
||||
closeBlock(node) {
|
||||
this.closed = node;
|
||||
}
|
||||
|
||||
// :: (string, ?bool)
|
||||
// Add the given text to the document. When escape is not `false`,
|
||||
// it will be escaped.
|
||||
text(text, escape) {
|
||||
const lines = text.split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const startOfLine = this.atBlank() || this.closed;
|
||||
this.write();
|
||||
this.out += escape !== false ? this.esc(lines[i], startOfLine) : lines[i];
|
||||
if (i !== lines.length - 1) this.out += "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// :: (Node)
|
||||
// Render the given node as a block.
|
||||
render(node, parent, index) {
|
||||
if (typeof parent === "number") throw new Error("!");
|
||||
this.nodes[node.type.name](this, node, parent, index);
|
||||
}
|
||||
|
||||
// :: (Node)
|
||||
// Render the contents of `parent` as block nodes.
|
||||
renderContent(parent) {
|
||||
parent.forEach((node, _, i) => this.render(node, parent, i));
|
||||
}
|
||||
|
||||
// :: (Node)
|
||||
// Render the contents of `parent` as inline content.
|
||||
renderInline(parent) {
|
||||
const active = [];
|
||||
let trailing = "";
|
||||
const progress = (node, _, index) => {
|
||||
let marks = node ? node.marks : [];
|
||||
|
||||
// Remove marks from `hard_break` that are the last node inside
|
||||
// that mark to prevent parser edge cases with new lines just
|
||||
// before closing marks.
|
||||
// (FIXME it'd be nice if we had a schema-agnostic way to
|
||||
// identify nodes that serialize as hard breaks)
|
||||
if (node && node.type.name === "hard_break")
|
||||
marks = marks.filter((m) => {
|
||||
if (index + 1 === parent.childCount) return false;
|
||||
const next = parent.child(index + 1);
|
||||
return (
|
||||
m.isInSet(next.marks) && (!next.isText || /\S/.test(next.text))
|
||||
);
|
||||
});
|
||||
|
||||
let leading = trailing;
|
||||
trailing = "";
|
||||
// If whitespace has to be expelled from the node, adjust
|
||||
// leading and trailing accordingly.
|
||||
if (
|
||||
node &&
|
||||
node.isText &&
|
||||
marks.some((mark) => {
|
||||
const info = this.marks[mark.type.name]();
|
||||
return info && info.expelEnclosingWhitespace;
|
||||
})
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||
const [_, lead, inner, trail] = /^(\s*)(.*?)(\s*)$/m.exec(node.text);
|
||||
leading += lead;
|
||||
trailing = trail;
|
||||
if (lead || trail) {
|
||||
node = inner ? node.withText(inner) : null;
|
||||
if (!node) marks = active;
|
||||
}
|
||||
}
|
||||
|
||||
const inner = marks.length && marks[marks.length - 1],
|
||||
noEsc = inner && this.marks[inner.type.name]().escape === false;
|
||||
const len = marks.length - (noEsc ? 1 : 0);
|
||||
|
||||
// Try to reorder 'mixable' marks, such as em and strong, which
|
||||
// in Markdown may be opened and closed in different order, so
|
||||
// that order of the marks for the token matches the order in
|
||||
// active.
|
||||
outer: for (let i = 0; i < len; i++) {
|
||||
const mark = marks[i];
|
||||
if (!this.marks[mark.type.name]().mixable) break;
|
||||
for (let j = 0; j < active.length; j++) {
|
||||
const other = active[j];
|
||||
if (!this.marks[other.type.name]().mixable) break;
|
||||
if (mark.eq(other)) {
|
||||
if (i > j)
|
||||
marks = marks
|
||||
.slice(0, j)
|
||||
.concat(mark)
|
||||
.concat(marks.slice(j, i))
|
||||
.concat(marks.slice(i + 1, len));
|
||||
else if (j > i)
|
||||
marks = marks
|
||||
.slice(0, i)
|
||||
.concat(marks.slice(i + 1, j))
|
||||
.concat(mark)
|
||||
.concat(marks.slice(j, len));
|
||||
continue outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find the prefix of the mark set that didn't change
|
||||
let keep = 0;
|
||||
while (
|
||||
keep < Math.min(active.length, len) &&
|
||||
marks[keep].eq(active[keep])
|
||||
)
|
||||
++keep;
|
||||
|
||||
// Close the marks that need to be closed
|
||||
while (keep < active.length)
|
||||
this.text(this.markString(active.pop(), false, parent, index), false);
|
||||
|
||||
// Output any previously expelled trailing whitespace outside the marks
|
||||
if (leading) this.text(leading);
|
||||
|
||||
// Open the marks that need to be opened
|
||||
if (node) {
|
||||
while (active.length < len) {
|
||||
const add = marks[active.length];
|
||||
active.push(add);
|
||||
this.text(this.markString(add, true, parent, index), false);
|
||||
}
|
||||
|
||||
// Render the node. Special case code marks, since their content
|
||||
// may not be escaped.
|
||||
if (noEsc && node.isText)
|
||||
this.text(
|
||||
this.markString(inner, true, parent, index) +
|
||||
node.text +
|
||||
this.markString(inner, false, parent, index + 1),
|
||||
false
|
||||
);
|
||||
else this.render(node, parent, index);
|
||||
}
|
||||
};
|
||||
parent.forEach(progress);
|
||||
progress(null, null, parent.childCount);
|
||||
}
|
||||
|
||||
// :: (Node, string, (number) → string)
|
||||
// Render a node's content as a list. `delim` should be the extra
|
||||
// indentation added to all lines except the first in an item,
|
||||
// `firstDelim` is a function going from an item index to a
|
||||
// delimiter for the first line of the item.
|
||||
renderList(node, delim, firstDelim) {
|
||||
if (this.closed && this.closed.type === node.type) this.flushClose(3);
|
||||
else if (this.inTightList) this.flushClose(1);
|
||||
|
||||
const isTight =
|
||||
typeof node.attrs.tight !== "undefined"
|
||||
? node.attrs.tight
|
||||
: this.options.tightLists;
|
||||
const prevTight = this.inTightList;
|
||||
const prevList = this.inList;
|
||||
this.inList = true;
|
||||
this.inTightList = isTight;
|
||||
node.forEach((child, _, i) => {
|
||||
if (i && isTight) this.flushClose(1);
|
||||
this.wrapBlock(delim, firstDelim(i), node, () =>
|
||||
this.render(child, node, i)
|
||||
);
|
||||
});
|
||||
this.inList = prevList;
|
||||
this.inTightList = prevTight;
|
||||
}
|
||||
|
||||
renderTable(node) {
|
||||
this.flushClose(1);
|
||||
|
||||
let headerBuffer = "";
|
||||
const prevTable = this.inTable;
|
||||
this.inTable = true;
|
||||
|
||||
// ensure there is an empty newline above all tables
|
||||
this.out += "\n";
|
||||
|
||||
// rows
|
||||
node.forEach((row, _, i) => {
|
||||
// cols
|
||||
row.forEach((cell, _, j) => {
|
||||
this.out += j === 0 ? "| " : " | ";
|
||||
|
||||
cell.forEach((para) => {
|
||||
// just padding the output so that empty cells take up the same space
|
||||
// as headings.
|
||||
// TODO: Ideally we'd calc the longest cell length and use that
|
||||
// to pad all the others.
|
||||
if (para.textContent === "" && para.content.size === 0) {
|
||||
this.out += " ";
|
||||
} else {
|
||||
this.closed = false;
|
||||
this.render(para, row, j);
|
||||
}
|
||||
});
|
||||
|
||||
if (i === 0) {
|
||||
if (cell.attrs.alignment === "center") {
|
||||
headerBuffer += "|:---:";
|
||||
} else if (cell.attrs.alignment === "left") {
|
||||
headerBuffer += "|:---";
|
||||
} else if (cell.attrs.alignment === "right") {
|
||||
headerBuffer += "|---:";
|
||||
} else {
|
||||
headerBuffer += "|----";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.out += " |\n";
|
||||
|
||||
if (headerBuffer) {
|
||||
this.out += `${headerBuffer}|\n`;
|
||||
headerBuffer = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
this.inTable = prevTable;
|
||||
}
|
||||
|
||||
// :: (string, ?bool) → string
|
||||
// Escape the given string so that it can safely appear in Markdown
|
||||
// content. If `startOfLine` is true, also escape characters that
|
||||
// has special meaning only at the start of the line.
|
||||
esc(str = "", startOfLine) {
|
||||
str = str.replace(/[`*\\~[\]]/g, "\\$&");
|
||||
if (startOfLine) {
|
||||
str = str.replace(/^[:#\-*+]/, "\\$&").replace(/^(\d+)\./, "$1\\.");
|
||||
}
|
||||
|
||||
if (this.inTable) {
|
||||
str = str.replace(/\|/gi, "\\$&");
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
quote(str) {
|
||||
const wrap =
|
||||
str.indexOf('"') === -1 ? '""' : str.indexOf("'") === -1 ? "''" : "()";
|
||||
return wrap[0] + str + wrap[1];
|
||||
}
|
||||
|
||||
// :: (string, number) → string
|
||||
// Repeat the given string `n` times.
|
||||
repeat(str, n) {
|
||||
let out = "";
|
||||
for (let i = 0; i < n; i++) out += str;
|
||||
return out;
|
||||
}
|
||||
|
||||
// : (Mark, bool, string?) → string
|
||||
// Get the markdown string for a given opening or closing mark.
|
||||
markString(mark, open, parent, index) {
|
||||
const info = this.marks[mark.type.name]();
|
||||
const value = open ? info.open : info.close;
|
||||
return typeof value === "string" ? value : value(this, mark, parent, index);
|
||||
}
|
||||
|
||||
// :: (string) → { leading: ?string, trailing: ?string }
|
||||
// Get leading and trailing whitespace from a string. Values of
|
||||
// leading or trailing property of the return object will be undefined
|
||||
// if there is no match.
|
||||
getEnclosingWhitespace(text) {
|
||||
return {
|
||||
leading: (text.match(/^(\s+)/) || [])[0],
|
||||
trailing: (text.match(/(\s+)$/) || [])[0],
|
||||
};
|
||||
}
|
||||
}
|
||||
74
shared/editor/lib/uploadPlaceholder.ts
Normal file
74
shared/editor/lib/uploadPlaceholder.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { EditorState, Plugin } from "prosemirror-state";
|
||||
import { Decoration, DecorationSet } from "prosemirror-view";
|
||||
|
||||
// based on the example at: https://prosemirror.net/examples/upload/
|
||||
const uploadPlaceholder = new Plugin({
|
||||
state: {
|
||||
init() {
|
||||
return DecorationSet.empty;
|
||||
},
|
||||
apply(tr, set: DecorationSet) {
|
||||
// Adjust decoration positions to changes made by the transaction
|
||||
set = set.map(tr.mapping, tr.doc);
|
||||
|
||||
// See if the transaction adds or removes any placeholders
|
||||
const action = tr.getMeta(this);
|
||||
|
||||
if (action?.add) {
|
||||
if (action.add.replaceExisting) {
|
||||
const $pos = tr.doc.resolve(action.add.pos);
|
||||
|
||||
if ($pos.nodeAfter?.type.name === "image") {
|
||||
const deco = Decoration.node(
|
||||
$pos.pos,
|
||||
$pos.pos + $pos.nodeAfter.nodeSize,
|
||||
{
|
||||
class: "image-replacement-uploading",
|
||||
},
|
||||
{
|
||||
id: action.add.id,
|
||||
}
|
||||
);
|
||||
set = set.add(tr.doc, [deco]);
|
||||
}
|
||||
} else {
|
||||
const element = document.createElement("div");
|
||||
element.className = "image placeholder";
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = URL.createObjectURL(action.add.file);
|
||||
|
||||
element.appendChild(img);
|
||||
|
||||
const deco = Decoration.widget(action.add.pos, element, {
|
||||
id: action.add.id,
|
||||
});
|
||||
set = set.add(tr.doc, [deco]);
|
||||
}
|
||||
}
|
||||
|
||||
if (action?.remove) {
|
||||
set = set.remove(
|
||||
set.find(undefined, undefined, (spec) => spec.id === action.remove.id)
|
||||
);
|
||||
}
|
||||
return set;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return this.getState(state);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default uploadPlaceholder;
|
||||
|
||||
export function findPlaceholder(
|
||||
state: EditorState,
|
||||
id: string
|
||||
): [number, number] | null {
|
||||
const decos: DecorationSet = uploadPlaceholder.getState(state);
|
||||
const found = decos.find(undefined, undefined, (spec) => spec.id === id);
|
||||
return found.length ? [found[0].from, found[0].to] : null;
|
||||
}
|
||||
Reference in New Issue
Block a user