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,5 @@
https://prosemirror.net/docs/ref/#commands
Commands are building block functions that encapsulate an editing action. A command function takes an editor state, optionally a dispatch function that it can use to dispatch a transaction and optionally an EditorView instance. It should return a boolean that indicates whether it could perform any action.
Additional commands that are not included as part of prosemirror-commands, but are often reused can be found in this folder.

View File

@@ -0,0 +1,26 @@
import { NodeType } from "prosemirror-model";
import { EditorState, Transaction } from "prosemirror-state";
export default function backspaceToParagraph(type: NodeType) {
return (state: EditorState, dispatch: (tr: Transaction) => void) => {
const { $from, from, to, empty } = state.selection;
// if the selection has anything in it then use standard delete behavior
if (!empty) return null;
// check we're in a matching node
if ($from.parent.type !== type) return null;
// check if we're at the beginning of the heading
const $pos = state.doc.resolve(from - 1);
if ($pos.parent === $from.parent) return null;
// okay, replace it with a paragraph
dispatch(
state.tr
.setBlockType(from, to, type.schema.nodes.paragraph)
.scrollIntoView()
);
return true;
};
}

View File

@@ -0,0 +1,85 @@
import { Node } from "prosemirror-model";
import { EditorView } from "prosemirror-view";
import { ToastType } from "../types";
function findPlaceholderLink(doc: Node, href: string) {
let result: { pos: number; node: Node } | undefined;
function findLinks(node: Node, pos = 0) {
// get text nodes
if (node.type.name === "text") {
// get marks for text nodes
node.marks.forEach((mark) => {
// any of the marks links?
if (mark.type.name === "link") {
// any of the links to other docs?
if (mark.attrs.href === href) {
result = { node, pos };
}
}
});
}
if (!node.content.size) {
return;
}
node.descendants(findLinks);
}
findLinks(doc);
return result;
}
const createAndInsertLink = async function (
view: EditorView,
title: string,
href: string,
options: {
dictionary: any;
onCreateLink: (title: string) => Promise<string>;
onShowToast?: (message: string, code: string) => void;
}
) {
const { dispatch, state } = view;
const { onCreateLink, onShowToast } = options;
try {
const url = await onCreateLink(title);
const result = findPlaceholderLink(view.state.doc, href);
if (!result) return;
dispatch(
view.state.tr
.removeMark(
result.pos,
result.pos + result.node.nodeSize,
state.schema.marks.link
)
.addMark(
result.pos,
result.pos + result.node.nodeSize,
state.schema.marks.link.create({ href: url })
)
);
} catch (err) {
const result = findPlaceholderLink(view.state.doc, href);
if (!result) return;
dispatch(
view.state.tr.removeMark(
result.pos,
result.pos + result.node.nodeSize,
state.schema.marks.link
)
);
// let the user know
if (onShowToast) {
onShowToast(options.dictionary.createLinkError, ToastType.Error);
}
}
};
export default createAndInsertLink;

View File

@@ -0,0 +1,143 @@
import { NodeSelection } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import uploadPlaceholderPlugin, {
findPlaceholder,
} from "../lib/uploadPlaceholder";
import { ToastType } from "../types";
let uploadId = 0;
export type Options = {
dictionary: any;
replaceExisting?: boolean;
uploadImage: (file: File) => Promise<string>;
onImageUploadStart?: () => void;
onImageUploadStop?: () => void;
onShowToast?: (message: string, code: string) => void;
};
const insertFiles = function (
view: EditorView,
event: Event | React.ChangeEvent<HTMLInputElement>,
pos: number,
files: File[],
options: Options
): void {
// filter to only include image files
const images = files.filter((file) => /image/i.test(file.type));
if (images.length === 0) return;
const {
dictionary,
uploadImage,
onImageUploadStart,
onImageUploadStop,
onShowToast,
} = options;
if (!uploadImage) {
console.warn(
"uploadImage callback must be defined to handle image uploads."
);
return;
}
// okay, we have some dropped images and a handler lets stop this
// event going any further up the stack
event.preventDefault();
// let the user know we're starting to process the images
if (onImageUploadStart) onImageUploadStart();
const { schema } = view.state;
// we'll use this to track of how many images have succeeded or failed
let complete = 0;
// the user might have dropped multiple images at once, we need to loop
for (const file of images) {
const id = `upload-${uploadId++}`;
const { tr } = view.state;
// insert a placeholder at this position, or mark an existing image as being
// replaced
tr.setMeta(uploadPlaceholderPlugin, {
add: {
id,
file,
pos,
replaceExisting: options.replaceExisting,
},
});
view.dispatch(tr);
// start uploading the image file to the server. Using "then" syntax
// to allow all placeholders to be entered at once with the uploads
// happening in the background in parallel.
uploadImage(file)
.then((src) => {
// otherwise, insert it at the placeholder's position, and remove
// the placeholder itself
const newImg = new Image();
newImg.onload = () => {
const result = findPlaceholder(view.state, id);
// if the content around the placeholder has been deleted
// then forget about inserting this image
if (result === null) {
return;
}
const [from, to] = result;
view.dispatch(
view.state.tr
.replaceWith(from, to || from, schema.nodes.image.create({ src }))
.setMeta(uploadPlaceholderPlugin, { remove: { id } })
);
// If the users selection is still at the image then make sure to select
// the entire node once done. Otherwise, if the selection has moved
// elsewhere then we don't want to modify it
if (view.state.selection.from === from) {
view.dispatch(
view.state.tr.setSelection(
new NodeSelection(view.state.doc.resolve(from))
)
);
}
};
newImg.onerror = (error) => {
throw error;
};
newImg.src = src;
})
.catch((error) => {
console.error(error);
// cleanup the placeholder if there is a failure
const transaction = view.state.tr.setMeta(uploadPlaceholderPlugin, {
remove: { id },
});
view.dispatch(transaction);
// let the user know
if (onShowToast) {
onShowToast(dictionary.imageUploadError, ToastType.Error);
}
})
.finally(() => {
complete++;
// once everything is done, let the user know
if (complete === images.length && onImageUploadStop) {
onImageUploadStop();
}
});
}
};
export default insertFiles;

View File

@@ -0,0 +1,111 @@
/*
Copyright 2020 Atlassian Pty Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// This file is based on the implementation found here:
// https://bitbucket.org/atlassian/design-system-mirror/src/master/editor/editor-core/src/plugins/text-formatting/commands/text-formatting.ts
import {
Selection,
EditorState,
Transaction,
TextSelection,
} from "prosemirror-state";
import isMarkActive from "../queries/isMarkActive";
function hasCode(state: EditorState, pos: number) {
const { code_inline } = state.schema.marks;
const node = pos >= 0 && state.doc.nodeAt(pos);
return node
? !!node.marks.filter((mark) => mark.type === code_inline).length
: false;
}
export default function moveLeft() {
return (state: EditorState, dispatch: (tr: Transaction) => void): boolean => {
const { code_inline } = state.schema.marks;
const { empty, $cursor } = state.selection as TextSelection;
if (!empty || !$cursor) {
return false;
}
const { storedMarks } = state.tr;
if (code_inline) {
const insideCode = code_inline && isMarkActive(code_inline)(state);
const currentPosHasCode = hasCode(state, $cursor.pos);
const nextPosHasCode = hasCode(state, $cursor.pos - 1);
const nextNextPosHasCode = hasCode(state, $cursor.pos - 2);
const exitingCode =
currentPosHasCode && !nextPosHasCode && Array.isArray(storedMarks);
const atLeftEdge =
nextPosHasCode &&
!nextNextPosHasCode &&
(storedMarks === null ||
(Array.isArray(storedMarks) && !!storedMarks.length));
const atRightEdge =
((exitingCode && Array.isArray(storedMarks) && !storedMarks.length) ||
(!exitingCode && storedMarks === null)) &&
!nextPosHasCode &&
nextNextPosHasCode;
const enteringCode =
!currentPosHasCode &&
nextPosHasCode &&
Array.isArray(storedMarks) &&
!storedMarks.length;
// at the right edge: remove code mark and move the cursor to the left
if (!insideCode && atRightEdge) {
const tr = state.tr.setSelection(
Selection.near(state.doc.resolve($cursor.pos - 1))
);
dispatch(tr.removeStoredMark(code_inline));
return true;
}
// entering code mark (from right edge): don't move the cursor, just add the mark
if (!insideCode && enteringCode) {
dispatch(state.tr.addStoredMark(code_inline.create()));
return true;
}
// at the left edge: add code mark and move the cursor to the left
if (insideCode && atLeftEdge) {
const tr = state.tr.setSelection(
Selection.near(state.doc.resolve($cursor.pos - 1))
);
dispatch(tr.addStoredMark(code_inline.create()));
return true;
}
// exiting code mark (or at the beginning of the line): don't move the cursor, just remove the mark
const isFirstChild = $cursor.index($cursor.depth - 1) === 0;
if (
insideCode &&
(exitingCode || (!$cursor.nodeBefore && isFirstChild))
) {
dispatch(state.tr.removeStoredMark(code_inline));
return true;
}
}
return false;
};
}

View File

@@ -0,0 +1,71 @@
/*
Copyright 2020 Atlassian Pty Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// This file is based on the implementation found here:
// https://bitbucket.org/atlassian/design-system-mirror/src/master/editor/editor-core/src/plugins/text-formatting/commands/text-formatting.ts
import { EditorState, Transaction, TextSelection } from "prosemirror-state";
import isMarkActive from "../queries/isMarkActive";
export default function moveRight() {
return (state: EditorState, dispatch: (tr: Transaction) => void): boolean => {
const { code_inline } = state.schema.marks;
const { empty, $cursor } = state.selection as TextSelection;
if (!empty || !$cursor) {
return false;
}
const { storedMarks } = state.tr;
if (code_inline) {
const insideCode = isMarkActive(code_inline)(state);
const currentPosHasCode = state.doc.rangeHasMark(
$cursor.pos,
$cursor.pos,
code_inline
);
const nextPosHasCode = state.doc.rangeHasMark(
$cursor.pos,
$cursor.pos + 1,
code_inline
);
const exitingCode =
!currentPosHasCode &&
!nextPosHasCode &&
(!storedMarks || !!storedMarks.length);
const enteringCode =
!currentPosHasCode &&
nextPosHasCode &&
(!storedMarks || !storedMarks.length);
// entering code mark (from the left edge): don't move the cursor, just add the mark
if (!insideCode && enteringCode) {
dispatch(state.tr.addStoredMark(code_inline.create()));
return true;
}
// exiting code mark: don't move the cursor, just remove the mark
if (insideCode && exitingCode) {
dispatch(state.tr.removeStoredMark(code_inline));
return true;
}
}
return false;
};
}

View File

@@ -0,0 +1,54 @@
import { NodeType } from "prosemirror-model";
import { EditorState, TextSelection, Transaction } from "prosemirror-state";
import { findBlockNodes } from "prosemirror-utils";
import findCollapsedNodes from "../queries/findCollapsedNodes";
export default function splitHeading(type: NodeType) {
return (state: EditorState, dispatch: (tr: Transaction) => void): boolean => {
const { $from, from, $to, to } = state.selection;
// check we're in a matching heading node
if ($from.parent.type !== type) return false;
// check that the caret is at the end of the content, if it isn't then
// standard node splitting behaviour applies
const endPos = $to.after() - 1;
if (endPos !== to) return false;
// If the node isn't collapsed standard behavior applies
if (!$from.parent.attrs.collapsed) return false;
// Find the next visible block after this one. It takes into account nested
// collapsed headings and reaching the end of the document
const allBlocks = findBlockNodes(state.doc);
const collapsedBlocks = findCollapsedNodes(state.doc);
const visibleBlocks = allBlocks.filter(
(a) => !collapsedBlocks.find((b) => b.pos === a.pos)
);
const nextVisibleBlock = visibleBlocks.find((a) => a.pos > from);
const pos = nextVisibleBlock
? nextVisibleBlock.pos
: state.doc.content.size;
// Insert our new heading directly before the next visible block
const transaction = state.tr.insert(
pos,
type.create({ ...$from.parent.attrs, collapsed: false })
);
// Move the selection into the new heading node and make sure it's on screen
dispatch(
transaction
.setSelection(
TextSelection.near(
transaction.doc.resolve(
Math.min(pos + 1, transaction.doc.content.size)
)
)
)
.scrollIntoView()
);
return true;
};
}

View File

@@ -0,0 +1,20 @@
import { setBlockType } from "prosemirror-commands";
import { NodeType } from "prosemirror-model";
import { EditorState, Transaction } from "prosemirror-state";
import isNodeActive from "../queries/isNodeActive";
export default function toggleBlockType(
type: NodeType,
toggleType: NodeType,
attrs = {}
) {
return (state: EditorState, dispatch: (tr: Transaction) => void) => {
const isActive = isNodeActive(type, attrs)(state);
if (isActive) {
return setBlockType(toggleType)(state, dispatch);
}
return setBlockType(type, attrs)(state, dispatch);
};
}

View File

@@ -0,0 +1,43 @@
import { NodeType } from "prosemirror-model";
import { wrapInList, liftListItem } from "prosemirror-schema-list";
import { EditorState, Transaction } from "prosemirror-state";
import { findParentNode } from "prosemirror-utils";
import isList from "../queries/isList";
export default function toggleList(listType: NodeType, itemType: NodeType) {
return (state: EditorState, dispatch: (tr: Transaction) => void) => {
const { schema, selection } = state;
const { $from, $to } = selection;
const range = $from.blockRange($to);
if (!range) {
return false;
}
const parentList = findParentNode((node) => isList(node, schema))(
selection
);
if (range.depth >= 1 && parentList && range.depth - parentList.depth <= 1) {
if (parentList.node.type === listType) {
return liftListItem(itemType)(state, dispatch);
}
if (
isList(parentList.node, schema) &&
listType.validContent(parentList.node.content)
) {
const { tr } = state;
tr.setNodeMarkup(parentList.pos, listType);
if (dispatch) {
dispatch(tr);
}
return false;
}
}
return wrapInList(listType)(state, dispatch);
};
}

View File

@@ -0,0 +1,19 @@
import { wrapIn, lift } from "prosemirror-commands";
import { NodeType } from "prosemirror-model";
import { EditorState, Transaction } from "prosemirror-state";
import isNodeActive from "../queries/isNodeActive";
export default function toggleWrap(
type: NodeType,
attrs?: Record<string, any>
) {
return (state: EditorState, dispatch: (tr: Transaction) => void) => {
const isActive = isNodeActive(type)(state);
if (isActive) {
return lift(state, dispatch);
}
return wrapIn(type, attrs)(state, dispatch);
};
}

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { cdnPath } from "../../utils/urls";
import { cdnPath } from "../../../utils/urls";
type Props = {
alt: string;

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import { EmbedDescriptor } from "rich-markdown-editor/dist/types";
import styled from "styled-components";
import { EmbedDescriptor } from "@shared/editor/types";
import Abstract from "./Abstract";
import Airtable from "./Airtable";
import Bilibili from "./Bilibili";

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;
}

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More