packages/prosemirror-paste-rules/src/paste-rules-plugin.ts
import {
Fragment,
Mark,
MarkType,
Node as ProsemirrorNode,
NodeType,
ResolvedPos,
Schema as EditorSchema,
Slice,
} from 'prosemirror-model';
import { Plugin, PluginKey, Selection } from 'prosemirror-state';
import type { EditorView } from 'prosemirror-view';
import { ExtensionPriority } from '@remirror/core-constants';
import { findMatches, includes, isFunction, range, sort } from '@remirror/core-helpers';
/**
* Create the paste plugin handler.
*/
export function pasteRules(pasteRules: PasteRule[]): Plugin {
const sortedPasteRules = sort(
pasteRules,
(a, z) => (z.priority ?? ExtensionPriority.Low) - (a.priority ?? ExtensionPriority.Low),
);
// Container for the regex based paste rules.
const regexPasteRules: RegexPasteRule[] = [];
// Container for the file based paste rules.
const filePasteRules: FilePasteRule[] = [];
for (const rule of sortedPasteRules) {
if (isRegexPastRule(rule)) {
regexPasteRules.push(rule);
} else {
filePasteRules.push(rule);
}
}
let view: EditorView;
return new Plugin({
key: pastePluginKey,
view: (editorView) => {
view = editorView;
return {};
},
props: {
// The regex based paste rules are passed into this function to take care of.
transformPasted: (slice) => {
const $pos = view.state.selection.$from;
const nodeName = $pos.node().type.name;
const markNames = new Set($pos.marks().map((mark) => mark.type.name));
// Iterate over each rule by order of priority and update the slice each time.
for (const rule of regexPasteRules) {
if (
// The parent node is ignored.
rule.ignoredNodes?.includes(nodeName) ||
// The current position contains ignored marks.
rule.ignoredMarks?.some((ignored) => markNames.has(ignored))
) {
continue;
}
const textContent = slice.content.firstChild?.textContent ?? '';
const canBeReplaced =
!view.state.selection.empty && slice.content.childCount === 1 && textContent;
const match = findMatches(textContent, rule.regexp)[0];
if (canBeReplaced && match && rule.type === 'mark' && rule.replaceSelection) {
const { from, to } = view.state.selection;
const textSlice = view.state.doc.slice(from, to);
const textContent = textSlice.content.textBetween(0, textSlice.content.size);
if (
typeof rule.replaceSelection !== 'boolean'
? rule.replaceSelection(textContent)
: rule.replaceSelection
) {
const newTextNodes: ProsemirrorNode[] = [];
const { getAttributes, markType } = rule;
const attributes = isFunction(getAttributes)
? getAttributes(match, true)
: getAttributes;
const mark = markType.create(attributes);
textSlice.content.forEach((textNode) => {
if (textNode.isText) {
const marks = mark.addToSet(textNode.marks);
newTextNodes.push(textNode.mark(marks));
}
});
return Slice.maxOpen(Fragment.fromArray(newTextNodes));
}
}
const { nodes: transformedNodes, transformed } = regexPasteRuleHandler(
slice.content,
rule,
view.state.schema,
);
if (transformed) {
// If we have created a block node, we don't want to keep the slice's open depth for both side.
slice =
rule.type === 'node' && rule.nodeType.isBlock
? new Slice(Fragment.fromArray(transformedNodes), 0, 0)
: new Slice(Fragment.fromArray(transformedNodes), slice.openStart, slice.openEnd);
}
}
// The `slice` passed to `transformPasted` might contain a invalid
// fragment (this is fine since slice has openStart and openEnd). The
// paste rule might remove some other node from the fragment because of
// its invalidity. So we need to check the opening to make sure that the
// slice is still valid.
return fixSliceOpening(slice);
},
handleDOMEvents: {
// Handle paste for pasting content.
paste: (view, clipboardEvent: Event) => {
const event = clipboardEvent as ClipboardEvent;
if (!view.props.editable?.(view.state)) {
return false;
}
const { clipboardData } = event;
if (!clipboardData) {
return false;
}
const allFiles: File[] = [...clipboardData.items]
.map((data) => data.getAsFile())
.filter((file): file is File => !!file);
if (allFiles.length === 0) {
return false;
}
const { selection } = view.state;
for (const { fileHandler, regexp } of filePasteRules) {
const files = regexp ? allFiles.filter((file) => regexp.test(file.type)) : allFiles;
if (files.length === 0) {
continue;
}
if (fileHandler({ event, files, selection, view, type: 'paste' })) {
event.preventDefault();
return true;
}
}
return false;
},
// Handle drop for pasting content.
drop: (view, dragEvent: Event) => {
const event = dragEvent as DragEvent;
if (!view.props.editable?.(view.state)) {
return false;
}
const { dataTransfer, clientX, clientY } = event;
if (!dataTransfer) {
return false;
}
const allFiles = getDataTransferFiles(event);
if (allFiles.length === 0) {
return false;
}
const pos =
view.posAtCoords({ left: clientX, top: clientY })?.pos ?? view.state.selection.anchor;
for (const { fileHandler, regexp } of filePasteRules) {
const files = regexp ? allFiles.filter((file) => regexp.test(file.type)) : allFiles;
if (files.length === 0) {
continue;
}
if (fileHandler({ event, files, pos, view, type: 'drop' })) {
event.preventDefault();
return true;
}
}
return false;
},
},
},
});
}
interface BasePasteRule {
/**
* The priority for the extension. Can be a number, or if you're using it with
* `remirror` then use the `ExtensionPriority` enum.
*
* @defaultValue 10
*/
priority?: ExtensionPriority;
}
interface BaseRegexPasteRule extends BasePasteRule {
/**
* The regular expression to test against.
*/
regexp: RegExp;
/**
* Only match at the start of the text block.
*/
startOfTextBlock?: boolean;
/**
* Ignore the match when all characters in the capture group are whitespace.
*
* This helps stop situations from occurring where the a capture group matches
* but you don't want an update if it's all whitespace.
*
* @defaultValue false
*/
ignoreWhitespace?: boolean;
/**
* The names of nodes for which this paste rule can be ignored. This means
* that if content is within any of the nodes provided the transformation will
* be ignored.
*/
ignoredNodes?: string[];
/**
* The names of marks for which this paste rule can be ignored. This means
* that if the matched content contains this mark it will be ignored.
*/
ignoredMarks?: string[];
}
interface BaseContentPasteRule extends BaseRegexPasteRule {
/**
* A helper function for setting the attributes for a transformation.
*
* The second parameter is `true` when the attributes are retrieved for a replacement.
*/
getAttributes?:
| Record<string, unknown>
| ((match: RegExpExecArray, isReplacement: boolean) => Record<string, unknown> | undefined);
}
/**
* For adding marks to text when a paste rule is activated.
*/
export interface MarkPasteRule extends BaseContentPasteRule {
/**
* The type of rule.
*/
type: 'mark';
/**
* The prosemirror mark type instance.
*/
markType: MarkType;
/**
* Set to `true` to replace the selection. When the regex matches for the
* selected text.
*
* Can be a function which receives the text that will be replaced.
*/
replaceSelection?: boolean | ((replacedText: string) => boolean);
/**
* A function that transforms the match into the desired text value.
*
* Return an empty string to delete all content.
*
* Return `false` to invalidate the match.
*/
transformMatch?: (match: RegExpExecArray) => string | null | undefined | false;
}
export interface NodePasteRule extends BaseContentPasteRule {
/**
* The type of rule.
*/
type: 'node';
/**
* The node type to create.
*/
nodeType: NodeType;
/**
* A function that transforms the match into the content to use when creating
* a node.
*
* Pass `() => {}` to remove the matched text.
*
* If this function is undefined, then the text node that is cut from the match
* will be used as the content.
*/
getContent?: (
match: RegExpExecArray,
) => Fragment | ProsemirrorNode | ProsemirrorNode[] | undefined | void;
}
/**
* For handling simpler text updates.
*/
export interface TextPasteRule extends BaseRegexPasteRule {
/**
* The type of rule.
*/
type: 'text';
/**
* A function that transforms the match into the desired text value.
*
* Return an empty string to delete all content.
*
* Return `false` to invalidate the match.
*/
transformMatch?: (match: RegExpExecArray) => string | null | undefined | false;
}
export type FileHandlerProps = FilePasteHandlerProps | FileDropHandlerProps;
export interface FilePasteHandlerProps {
type: 'paste';
/** All the matching files */
files: File[];
event: ClipboardEvent;
view: EditorView;
selection: Selection;
}
export interface FileDropHandlerProps {
type: 'drop';
/** All the matching files */
files: File[];
event: DragEvent;
view: EditorView;
pos: number;
}
/**
* For handling pasting files and also file drops.
*/
export interface FilePasteRule extends BasePasteRule {
type: 'file';
/**
* A regex test for the file type.
*/
regexp?: RegExp;
/**
* The names of nodes for which this paste rule can be ignored. This means
* that if content is within any of the nodes provided the transformation will
* be ignored.
*/
ignoredNodes?: string[];
/**
* Return `false` to defer to the next image handler.
*
* The file
*/
fileHandler: (props: FileHandlerProps) => boolean;
}
export type PasteRule = FilePasteRule | TextPasteRule | NodePasteRule | MarkPasteRule;
const pastePluginKey = new PluginKey('pasteRule');
/**
* @typeParam RegexPasteRule
*/
interface PasteRuleHandler<Rule extends RegexPasteRule> {
/** The fragment to use */
fragment: Fragment;
/** The type of the rule passed */
rule: Rule;
/** The nodes provided */
nodes: ProsemirrorNode[];
}
interface TransformerProps<Rule extends RegexPasteRule> {
rule: Rule;
textNode: ProsemirrorNode;
nodes: ProsemirrorNode[];
match: RegExpExecArray;
schema: EditorSchema;
}
type Transformer<Rule extends RegexPasteRule> = (props: TransformerProps<Rule>) => void;
/**
* Factory for creating paste rules.
*/
function createPasteRuleHandler<Rule extends RegexPasteRule>(
transformer: Transformer<Rule>,
schema: EditorSchema,
) {
return function handler(props: PasteRuleHandler<Rule>): {
nodes: ProsemirrorNode[];
transformed: boolean;
} {
const { fragment, rule, nodes } = props;
const { regexp, ignoreWhitespace, ignoredMarks, ignoredNodes } = rule;
let transformed = false;
fragment.forEach((child) => {
// Check if this node should be ignored.
if (ignoredNodes?.includes(child.type.name) || isCodeNode(child)) {
nodes.push(child);
return;
}
// When the current node is not a text node, recursively dive into it's child nodes.
if (!child.isText) {
const childResult = handler({ fragment: child.content, rule, nodes: [] });
transformed ||= childResult.transformed;
const content = Fragment.fromArray(childResult.nodes);
if (child.type.validContent(content)) {
nodes.push(child.copy(content));
} else {
nodes.push(...childResult.nodes);
}
return;
}
// When this is a text node ignore this child if it is wrapped by an ignored
// mark or a code mark.
if (child.marks.some((mark) => isCodeMark(mark) || ignoredMarks?.includes(mark.type.name))) {
nodes.push(child);
return;
}
const text = child.text ?? '';
let pos = 0;
// Find all matches and add the defined mark.
for (const match of findMatches(text, regexp)) {
// The captured value from the regex.
const capturedValue = match[1];
const fullValue = match[0];
if (
// This helps prevent matches which are only whitespace from triggering
// an update.
(ignoreWhitespace && capturedValue?.trim() === '') ||
!fullValue
) {
return;
}
const start = match.index;
const end = start + fullValue.length;
if (start > pos) {
nodes.push(child.cut(pos, start));
}
let textNode = child.cut(start, end);
// When a capture value is provided use it.
if (fullValue && capturedValue) {
const startSpaces = fullValue.search(/\S/);
const textStart = start + fullValue.indexOf(capturedValue);
const textEnd = textStart + capturedValue.length;
if (startSpaces) {
nodes.push(child.cut(start, start + startSpaces));
}
textNode = child.cut(textStart, textEnd);
}
// A transformer to push the required nodes.
transformer({ nodes, rule, textNode, match, schema });
transformed = true;
pos = end;
}
// Add the rest of the node to the gathered nodes if any characters are
// remaining.
if (text && pos < text.length) {
nodes.push(child.cut(pos));
}
});
return { nodes, transformed };
};
}
/**
* Mark rule transformer which pushes the transformed mark into the provided
* nodes.
*/
function markRuleTransformer(props: TransformerProps<MarkPasteRule>) {
const { nodes, rule, textNode, match, schema } = props;
const { transformMatch, getAttributes, markType } = rule;
const attributes = isFunction(getAttributes) ? getAttributes(match, false) : getAttributes;
const text = textNode.text ?? '';
const mark = markType.create(attributes);
const transformedCapturedValue = transformMatch?.(match);
// remove the text if transformMatch returns empty text
if (transformedCapturedValue === '') {
return;
}
// remove the mark if transformMatch returns false
if (transformedCapturedValue === false) {
nodes.push(schema.text(text, textNode.marks));
return;
}
const marks = mark.addToSet(textNode.marks);
nodes.push(schema.text(transformedCapturedValue ?? text, marks));
}
/**
* Support for pasting and transforming text content into the editor.
*/
function textRuleTransformer(props: TransformerProps<TextPasteRule>) {
const { nodes, rule, textNode, match, schema } = props;
const { transformMatch } = rule;
const transformedCapturedValue = transformMatch?.(match);
// remove the text if transformMatch returns empty string or false
if (transformedCapturedValue === '' || transformedCapturedValue === false) {
return;
}
const text = transformedCapturedValue ?? textNode.text ?? '';
nodes.push(schema.text(text, textNode.marks));
}
/**
* Support for pasting node content into the editor.
*/
function nodeRuleTransformer(props: TransformerProps<NodePasteRule>) {
const { nodes, rule, textNode, match } = props;
const { getAttributes, nodeType, getContent } = rule;
const attributes = isFunction(getAttributes) ? getAttributes(match, false) : getAttributes;
const content = (getContent ? getContent(match) : textNode) || undefined;
nodes.push(nodeType.createChecked(attributes, content));
}
/**
* The run the handlers for the regex paste rules on the content which has been transformed by prosemirror.
*/
function regexPasteRuleHandler(
fragment: Fragment,
rule: RegexPasteRule,
schema: EditorSchema,
): { nodes: ProsemirrorNode[]; transformed: boolean } {
const nodes: ProsemirrorNode[] = [];
switch (rule.type) {
case 'mark':
return createPasteRuleHandler(markRuleTransformer, schema)({ fragment, nodes, rule });
case 'node':
return createPasteRuleHandler(nodeRuleTransformer, schema)({ fragment, nodes, rule });
default:
return createPasteRuleHandler(textRuleTransformer, schema)({ fragment, nodes, rule });
}
}
const regexPasteRules = ['mark', 'node', 'text'] as const;
type RegexPasteRule = MarkPasteRule | NodePasteRule | TextPasteRule;
/**
* Check if the paste rule is regex based.
*/
function isRegexPastRule(rule: PasteRule): rule is RegexPasteRule {
return includes(regexPasteRules, rule.type);
}
export interface IsInCodeOptions {
/**
* When this is set to true ensure the selection is fully contained within a code block. This means that selections that span multiple characters must all be within a code region for it to return true.
*
* @defaultValue true
*/
contained?: boolean;
}
/**
* Check whether the current selection is completely contained within a code block or mark.
*/
export function isInCode(
selection: Selection,
{ contained = true }: IsInCodeOptions = {},
): boolean {
if (selection.empty) {
return resolvedPosInCode(selection.$head);
}
if (contained) {
return resolvedPosInCode(selection.$head) && resolvedPosInCode(selection.$anchor);
}
return resolvedPosInCode(selection.$head) || resolvedPosInCode(selection.$anchor);
}
/**
* Check if the provided position is within a code mark or node.
*/
function resolvedPosInCode($pos: ResolvedPos): boolean {
// Start at the current depth and work down until a depth of 1.
for (const depth of range($pos.depth, 1)) {
if (isCodeNode($pos.node(depth))) {
return true;
}
}
for (const mark of $pos.marks()) {
if (isCodeMark(mark)) {
return true;
}
}
return false;
}
/**
* Check if the current node is a code node.
*/
function isCodeNode(node: ProsemirrorNode) {
return node.type.spec.code || node.type.spec.group?.split(' ').includes('code');
}
/**
* Check if the current mark is a code mark.
*/
function isCodeMark(mark: Mark) {
return mark.type.name === 'code' || mark.type.spec.group?.split(' ').includes('code');
}
function getDataTransferFiles(event: DragEvent): File[] {
const { dataTransfer } = event;
if (!dataTransfer) {
return [];
}
if (dataTransfer.files?.length > 0) {
return [...dataTransfer.files];
}
if (dataTransfer.items?.length) {
// During the drag even the dataTransfer.files is null
// but Chrome implements some drag store, which is accesible via dataTransfer.items
return [...dataTransfer.items]
.map((item) => item.getAsFile())
.filter((item): item is File => !!item);
}
return [];
}
function fixSliceOpening(slice: Slice): Slice {
const max = Slice.maxOpen(slice.content);
return max.openStart < slice.openStart || max.openEnd < slice.openEnd ? max : slice;
}