editor.js/src/components/modules/paste.ts
Peter Savchenko cd29c52e51
feat(ui): native-like tab behaviour, slash for toolbox (#2569)
* slash to open toolbox, tab for navigation

* tab, focus improvements

- remove "focused" block state
- tab navigation respects inputs
- allow to focus contentless blocks

* fix tests

* tests for Slash

* tab tests

* test for tabbing out of editor

* tests fixed

* review fixes
2023-12-22 23:15:35 +03:00

1007 lines
27 KiB
TypeScript

import Module from '../__module';
import $ from '../dom';
import * as _ from '../utils';
import {
BlockAPI,
PasteEvent,
PasteEventDetail,
SanitizerConfig,
SanitizerRule
} from '../../../types';
import Block from '../block';
import { SavedData } from '../../../types/data-formats';
import { clean, sanitizeBlocks } from '../utils/sanitizer';
import BlockTool from '../tools/block';
/**
* Tag substitute object.
*/
interface TagSubstitute {
/**
* Name of related Tool
*
*/
tool: BlockTool;
/**
* If a Tool specifies just a tag name, all the attributes will be sanitized.
* But Tool can explicitly specify sanitizer configuration for supported tags
*/
sanitizationConfig?: SanitizerRule;
}
/**
* Pattern substitute object.
*/
interface PatternSubstitute {
/**
* Pattern`s key
*/
key: string;
/**
* Pattern regexp
*/
pattern: RegExp;
/**
* Name of related Tool
*/
tool: BlockTool;
}
/**
* Files` types substitutions object.
*/
interface FilesSubstitution {
/**
* Array of file extensions Tool can handle
*
* @type {string[]}
*/
extensions: string[];
/**
* Array of MIME types Tool can handle
*
* @type {string[]}
*/
mimeTypes: string[];
}
/**
* Processed paste data object.
*
* @interface PasteData
*/
interface PasteData {
/**
* Name of related Tool
*
* @type {string}
*/
tool: string;
/**
* Pasted data. Processed and wrapped to HTML element
*
* @type {HTMLElement}
*/
content: HTMLElement;
/**
* Pasted data
*/
event: PasteEvent;
/**
* True if content should be inserted as new Block
*
* @type {boolean}
*/
isBlock: boolean;
}
/**
* @class Paste
* @classdesc Contains methods to handle paste on editor
* @module Paste
* @version 2.0.0
*/
export default class Paste extends Module {
/** If string`s length is greater than this number we don't check paste patterns */
public static readonly PATTERN_PROCESSING_MAX_LENGTH = 450;
/** Custom EditorJS mime-type to handle in-editor copy/paste actions */
public readonly MIME_TYPE = 'application/x-editor-js';
/**
* Tags` substitutions parameters
*/
private toolsTags: { [tag: string]: TagSubstitute } = {};
/**
* Store tags to substitute by tool name
*/
private tagsByTool: { [tools: string]: string[] } = {};
/** Patterns` substitutions parameters */
private toolsPatterns: PatternSubstitute[] = [];
/** Files` substitutions parameters */
private toolsFiles: {
[tool: string]: FilesSubstitution;
} = {};
/**
* List of tools which do not need a paste handling
*/
private exceptionList: string[] = [];
/**
* Set onPaste callback and collect tools` paste configurations
*/
public async prepare(): Promise<void> {
this.processTools();
}
/**
* Set read-only state
*
* @param {boolean} readOnlyEnabled - read only flag value
*/
public toggleReadOnly(readOnlyEnabled: boolean): void {
if (!readOnlyEnabled) {
this.setCallback();
} else {
this.unsetCallback();
}
}
/**
* Handle pasted or dropped data transfer object
*
* @param {DataTransfer} dataTransfer - pasted or dropped data transfer object
* @param {boolean} isDragNDrop - true if data transfer comes from drag'n'drop events
*/
public async processDataTransfer(dataTransfer: DataTransfer, isDragNDrop = false): Promise<void> {
const { Tools } = this.Editor;
const types = dataTransfer.types;
/**
* In Microsoft Edge types is DOMStringList. So 'contains' is used to check if 'Files' type included
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const includesFiles = types.includes ? types.includes('Files') : (types as any).contains('Files');
if (includesFiles && !_.isEmpty(this.toolsFiles)) {
await this.processFiles(dataTransfer.files);
return;
}
const editorJSData = dataTransfer.getData(this.MIME_TYPE);
const plainData = dataTransfer.getData('text/plain');
let htmlData = dataTransfer.getData('text/html');
/**
* If EditorJS json is passed, insert it
*/
if (editorJSData) {
try {
this.insertEditorJSData(JSON.parse(editorJSData));
return;
} catch (e) { } // Do nothing and continue execution as usual if error appears
}
/**
* If text was drag'n'dropped, wrap content with P tag to insert it as the new Block
*/
if (isDragNDrop && plainData.trim() && htmlData.trim()) {
htmlData = '<p>' + (htmlData.trim() ? htmlData : plainData) + '</p>';
}
/** Add all tags that can be substituted to sanitizer configuration */
const toolsTags = Object.keys(this.toolsTags).reduce((result, tag) => {
/**
* If Tool explicitly specifies sanitizer configuration for the tag, use it.
* Otherwise, remove all attributes
*/
result[tag.toLowerCase()] = this.toolsTags[tag].sanitizationConfig ?? {};
return result;
}, {});
const customConfig = Object.assign({}, toolsTags, Tools.getAllInlineToolsSanitizeConfig(), { br: {} });
const cleanData = clean(htmlData, customConfig);
/** If there is no HTML or HTML string is equal to plain one, process it as plain text */
if (!cleanData.trim() || cleanData.trim() === plainData || !$.isHTMLString(cleanData)) {
await this.processText(plainData);
} else {
await this.processText(cleanData, true);
}
}
/**
* Process pasted text and divide them into Blocks
*
* @param {string} data - text to process. Can be HTML or plain.
* @param {boolean} isHTML - if passed string is HTML, this parameter should be true
*/
public async processText(data: string, isHTML = false): Promise<void> {
const { Caret, BlockManager } = this.Editor;
const dataToInsert = isHTML ? this.processHTML(data) : this.processPlain(data);
if (!dataToInsert.length) {
return;
}
if (dataToInsert.length === 1) {
if (!dataToInsert[0].isBlock) {
this.processInlinePaste(dataToInsert.pop());
} else {
this.processSingleBlock(dataToInsert.pop());
}
return;
}
const isCurrentBlockDefault = BlockManager.currentBlock && BlockManager.currentBlock.tool.isDefault;
const needToReplaceCurrentBlock = isCurrentBlockDefault && BlockManager.currentBlock.isEmpty;
dataToInsert.map(
async (content, i) => this.insertBlock(content, i === 0 && needToReplaceCurrentBlock)
);
if (BlockManager.currentBlock) {
Caret.setToBlock(BlockManager.currentBlock, Caret.positions.END);
}
}
/**
* Set onPaste callback handler
*/
private setCallback(): void {
this.listeners.on(this.Editor.UI.nodes.holder, 'paste', this.handlePasteEvent);
}
/**
* Unset onPaste callback handler
*/
private unsetCallback(): void {
this.listeners.off(this.Editor.UI.nodes.holder, 'paste', this.handlePasteEvent);
}
/**
* Get and process tool`s paste configs
*/
private processTools(): void {
const tools = this.Editor.Tools.blockTools;
Array
.from(tools.values())
.forEach(this.processTool);
}
/**
* Process paste config for each tool
*
* @param tool - BlockTool object
*/
private processTool = (tool: BlockTool): void => {
try {
const toolInstance = tool.create({}, {} as BlockAPI, false);
if (tool.pasteConfig === false) {
this.exceptionList.push(tool.name);
return;
}
if (!_.isFunction(toolInstance.onPaste)) {
return;
}
this.getTagsConfig(tool);
this.getFilesConfig(tool);
this.getPatternsConfig(tool);
} catch (e) {
_.log(
`Paste handling for «${tool.name}» Tool hasn't been set up because of the error`,
'warn',
e
);
}
};
/**
* Get tags name list from either tag name or sanitization config.
*
* @param {string | object} tagOrSanitizeConfig - tag name or sanitize config object.
* @returns {string[]} array of tags.
*/
private collectTagNames(tagOrSanitizeConfig: string | SanitizerConfig): string[] {
/**
* If string, then it is a tag name.
*/
if (_.isString(tagOrSanitizeConfig)) {
return [ tagOrSanitizeConfig ];
}
/**
* If object, then its keys are tags.
*/
if (_.isObject(tagOrSanitizeConfig)) {
return Object.keys(tagOrSanitizeConfig);
}
/** Return empty tag list */
return [];
}
/**
* Get tags to substitute by Tool
*
* @param tool - BlockTool object
*/
private getTagsConfig(tool: BlockTool): void {
if (tool.pasteConfig === false) {
return;
}
const tagsOrSanitizeConfigs = tool.pasteConfig.tags || [];
const toolTags = [];
tagsOrSanitizeConfigs.forEach((tagOrSanitizeConfig) => {
const tags = this.collectTagNames(tagOrSanitizeConfig);
/**
* Add tags to toolTags array
*/
toolTags.push(...tags);
tags.forEach((tag) => {
if (Object.prototype.hasOwnProperty.call(this.toolsTags, tag)) {
_.log(
`Paste handler for «${tool.name}» Tool on «${tag}» tag is skipped ` +
`because it is already used by «${this.toolsTags[tag].tool.name}» Tool.`,
'warn'
);
return;
}
/**
* Get sanitize config for tag.
*/
const sanitizationConfig = _.isObject(tagOrSanitizeConfig) ? tagOrSanitizeConfig[tag] : null;
this.toolsTags[tag.toUpperCase()] = {
tool,
sanitizationConfig,
};
});
});
this.tagsByTool[tool.name] = toolTags.map((t) => t.toUpperCase());
}
/**
* Get files` types and extensions to substitute by Tool
*
* @param tool - BlockTool object
*/
private getFilesConfig(tool: BlockTool): void {
if (tool.pasteConfig === false) {
return;
}
const { files = {} } = tool.pasteConfig;
let { extensions, mimeTypes } = files;
if (!extensions && !mimeTypes) {
return;
}
if (extensions && !Array.isArray(extensions)) {
_.log(`«extensions» property of the onDrop config for «${tool.name}» Tool should be an array`);
extensions = [];
}
if (mimeTypes && !Array.isArray(mimeTypes)) {
_.log(`«mimeTypes» property of the onDrop config for «${tool.name}» Tool should be an array`);
mimeTypes = [];
}
if (mimeTypes) {
mimeTypes = mimeTypes.filter((type) => {
if (!_.isValidMimeType(type)) {
_.log(`MIME type value «${type}» for the «${tool.name}» Tool is not a valid MIME type`, 'warn');
return false;
}
return true;
});
}
this.toolsFiles[tool.name] = {
extensions: extensions || [],
mimeTypes: mimeTypes || [],
};
}
/**
* Get RegExp patterns to substitute by Tool
*
* @param tool - BlockTool object
*/
private getPatternsConfig(tool: BlockTool): void {
if (
tool.pasteConfig === false ||
!tool.pasteConfig.patterns ||
_.isEmpty(tool.pasteConfig.patterns)
) {
return;
}
Object.entries(tool.pasteConfig.patterns).forEach(([key, pattern]: [string, RegExp]) => {
/** Still need to validate pattern as it provided by user */
if (!(pattern instanceof RegExp)) {
_.log(
`Pattern ${pattern} for «${tool.name}» Tool is skipped because it should be a Regexp instance.`,
'warn'
);
}
this.toolsPatterns.push({
key,
pattern,
tool,
});
});
}
/**
* Check if browser behavior suits better
*
* @param {EventTarget} element - element where content has been pasted
* @returns {boolean}
*/
private isNativeBehaviour(element: EventTarget): boolean {
return $.isNativeInput(element);
}
/**
* Check if Editor should process pasted data and pass data transfer object to handler
*
* @param {ClipboardEvent} event - clipboard event
*/
private handlePasteEvent = async (event: ClipboardEvent): Promise<void> => {
const { BlockManager, Toolbar } = this.Editor;
/**
* When someone pasting into a block, its more stable to set current block by event target, instead of relying on current block set before
*/
const currentBlock = BlockManager.setCurrentBlockByChildNode(event.target as HTMLElement);
/** If target is native input or is not Block, use browser behaviour */
if (
!currentBlock || (this.isNativeBehaviour(event.target) && !event.clipboardData.types.includes('Files'))
) {
return;
}
/**
* If Tools is in list of errors, skip processing of paste event
*/
if (currentBlock && this.exceptionList.includes(currentBlock.name)) {
return;
}
event.preventDefault();
this.processDataTransfer(event.clipboardData);
Toolbar.close();
};
/**
* Get files from data transfer object and insert related Tools
*
* @param {FileList} items - pasted or dropped items
*/
private async processFiles(items: FileList): Promise<void> {
const { BlockManager } = this.Editor;
let dataToInsert: { type: string; event: PasteEvent }[];
dataToInsert = await Promise.all(
Array
.from(items)
.map((item) => this.processFile(item))
);
dataToInsert = dataToInsert.filter((data) => !!data);
const isCurrentBlockDefault = BlockManager.currentBlock.tool.isDefault;
const needToReplaceCurrentBlock = isCurrentBlockDefault && BlockManager.currentBlock.isEmpty;
dataToInsert.forEach(
(data, i) => {
BlockManager.paste(data.type, data.event, i === 0 && needToReplaceCurrentBlock);
}
);
}
/**
* Get information about file and find Tool to handle it
*
* @param {File} file - file to process
*/
private async processFile(file: File): Promise<{ event: PasteEvent; type: string }> {
const extension = _.getFileExtension(file);
const foundConfig = Object
.entries(this.toolsFiles)
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
.find(([toolName, { mimeTypes, extensions } ]) => {
const [fileType, fileSubtype] = file.type.split('/');
const foundExt = extensions.find((ext) => ext.toLowerCase() === extension.toLowerCase());
const foundMimeType = mimeTypes.find((mime) => {
const [type, subtype] = mime.split('/');
return type === fileType && (subtype === fileSubtype || subtype === '*');
});
return !!foundExt || !!foundMimeType;
});
if (!foundConfig) {
return;
}
const [ tool ] = foundConfig;
const pasteEvent = this.composePasteEvent('file', {
file,
});
return {
event: pasteEvent,
type: tool,
};
}
/**
* Split HTML string to blocks and return it as array of Block data
*
* @param {string} innerHTML - html string to process
* @returns {PasteData[]}
*/
private processHTML(innerHTML: string): PasteData[] {
const { Tools } = this.Editor;
/**
* @todo Research, do we really need to always wrap innerHTML to a div:
* - <img> tag could be processed separately, but for now it becomes div-wrapped
* and then .getNodes() returns strange: [document-fragment, img]
* (description of the method says that it should should return only block tags or fragments,
* but there are inline-block element along with redundant empty fragment)
* - probably this is a reason of bugs with unexpected new block creation instead of inline pasting:
* - https://github.com/codex-team/editor.js/issues/1427
* - https://github.com/codex-team/editor.js/issues/1244
* - https://github.com/codex-team/editor.js/issues/740
*/
const wrapper = $.make('DIV');
wrapper.innerHTML = innerHTML;
const nodes = this.getNodes(wrapper);
return nodes
.map((node) => {
let content, tool = Tools.defaultTool, isBlock = false;
switch (node.nodeType) {
/** If node is a document fragment, use temp wrapper to get innerHTML */
case Node.DOCUMENT_FRAGMENT_NODE:
content = $.make('div');
content.appendChild(node);
break;
/** If node is an element, then there might be a substitution */
case Node.ELEMENT_NODE:
content = node as HTMLElement;
isBlock = true;
if (this.toolsTags[content.tagName]) {
tool = this.toolsTags[content.tagName].tool;
}
break;
}
/**
* Returns empty array if there is no paste config
*/
const { tags: tagsOrSanitizeConfigs } = tool.pasteConfig || { tags: [] };
/**
* Reduce the tags or sanitize configs to a single array of sanitize config.
* For example:
* If sanitize config is
* [ 'tbody',
* {
* table: {
* width: true,
* height: true,
* },
* },
* {
* td: {
* colspan: true,
* rowspan: true,
* },
* tr: { // <-- the second tag
* height: true,
* },
* },
* ]
* then sanitize config will be
* [
* 'table':{},
* 'tbody':{width: true, height: true}
* 'td':{colspan: true, rowspan: true},
* 'tr':{height: true}
* ]
*/
const toolTags = tagsOrSanitizeConfigs.reduce((result, tagOrSanitizeConfig) => {
const tags = this.collectTagNames(tagOrSanitizeConfig);
tags.forEach((tag) => {
const sanitizationConfig = _.isObject(tagOrSanitizeConfig) ? tagOrSanitizeConfig[tag] : null;
result[tag.toLowerCase()] = sanitizationConfig || {};
});
return result;
}, {});
const customConfig = Object.assign({}, toolTags, tool.baseSanitizeConfig);
/**
* A workaround for the HTMLJanitor bug with Tables (incorrect sanitizing of table.innerHTML)
* https://github.com/guardian/html-janitor/issues/3
*/
if (content.tagName.toLowerCase() === 'table') {
const cleanTableHTML = clean(content.outerHTML, customConfig);
const tmpWrapper = $.make('div', undefined, {
innerHTML: cleanTableHTML,
});
content = tmpWrapper.firstChild;
} else {
content.innerHTML = clean(content.innerHTML, customConfig);
}
const event = this.composePasteEvent('tag', {
data: content,
});
return {
content,
isBlock,
tool: tool.name,
event,
};
})
.filter((data) => {
const isEmpty = $.isEmpty(data.content);
const isSingleTag = $.isSingleTag(data.content);
return !isEmpty || isSingleTag;
});
}
/**
* Split plain text by new line symbols and return it as array of Block data
*
* @param {string} plain - string to process
* @returns {PasteData[]}
*/
private processPlain(plain: string): PasteData[] {
const { defaultBlock } = this.config as { defaultBlock: string };
if (!plain) {
return [];
}
const tool = defaultBlock;
return plain
.split(/\r?\n/)
.filter((text) => text.trim())
.map((text) => {
const content = $.make('div');
content.textContent = text;
const event = this.composePasteEvent('tag', {
data: content,
});
return {
content,
tool,
isBlock: false,
event,
};
});
}
/**
* Process paste of single Block tool content
*
* @param {PasteData} dataToInsert - data of Block to insert
*/
private async processSingleBlock(dataToInsert: PasteData): Promise<void> {
const { Caret, BlockManager } = this.Editor;
const { currentBlock } = BlockManager;
/**
* If pasted tool isn`t equal current Block or if pasted content contains block elements, insert it as new Block
*/
if (
!currentBlock ||
dataToInsert.tool !== currentBlock.name ||
!$.containsOnlyInlineElements(dataToInsert.content.innerHTML)
) {
this.insertBlock(dataToInsert, currentBlock?.tool.isDefault && currentBlock.isEmpty);
return;
}
Caret.insertContentAtCaretPosition(dataToInsert.content.innerHTML);
}
/**
* Process paste to single Block:
* 1. Find patterns` matches
* 2. Insert new block if it is not the same type as current one
* 3. Just insert text if there is no substitutions
*
* @param {PasteData} dataToInsert - data of Block to insert
*/
private async processInlinePaste(dataToInsert: PasteData): Promise<void> {
const { BlockManager, Caret } = this.Editor;
const { content } = dataToInsert;
const currentBlockIsDefault = BlockManager.currentBlock && BlockManager.currentBlock.tool.isDefault;
if (currentBlockIsDefault && content.textContent.length < Paste.PATTERN_PROCESSING_MAX_LENGTH) {
const blockData = await this.processPattern(content.textContent);
if (blockData) {
const needToReplaceCurrentBlock = BlockManager.currentBlock &&
BlockManager.currentBlock.tool.isDefault &&
BlockManager.currentBlock.isEmpty;
const insertedBlock = BlockManager.paste(blockData.tool, blockData.event, needToReplaceCurrentBlock);
Caret.setToBlock(insertedBlock, Caret.positions.END);
return;
}
}
/** If there is no pattern substitute - insert string as it is */
if (BlockManager.currentBlock && BlockManager.currentBlock.currentInput) {
const currentToolSanitizeConfig = BlockManager.currentBlock.tool.baseSanitizeConfig;
document.execCommand(
'insertHTML',
false,
clean(content.innerHTML, currentToolSanitizeConfig)
);
} else {
this.insertBlock(dataToInsert);
}
}
/**
* Get patterns` matches
*
* @param {string} text - text to process
* @returns {Promise<{event: PasteEvent, tool: string}>}
*/
private async processPattern(text: string): Promise<{ event: PasteEvent; tool: string }> {
const pattern = this.toolsPatterns.find((substitute) => {
const execResult = substitute.pattern.exec(text);
if (!execResult) {
return false;
}
return text === execResult.shift();
});
if (!pattern) {
return;
}
const event = this.composePasteEvent('pattern', {
key: pattern.key,
data: text,
});
return {
event,
tool: pattern.tool.name,
};
}
/**
* Insert pasted Block content to Editor
*
* @param {PasteData} data - data to insert
* @param {boolean} canReplaceCurrentBlock - if true and is current Block is empty, will replace current Block
* @returns {void}
*/
private insertBlock(data: PasteData, canReplaceCurrentBlock = false): void {
const { BlockManager, Caret } = this.Editor;
const { currentBlock } = BlockManager;
let block: Block;
if (canReplaceCurrentBlock && currentBlock && currentBlock.isEmpty) {
block = BlockManager.paste(data.tool, data.event, true);
Caret.setToBlock(block, Caret.positions.END);
return;
}
block = BlockManager.paste(data.tool, data.event);
Caret.setToBlock(block, Caret.positions.END);
}
/**
* Insert data passed as application/x-editor-js JSON
*
* @param {Array} blocks — Blocks' data to insert
* @returns {void}
*/
private insertEditorJSData(blocks: Pick<SavedData, 'id' | 'data' | 'tool'>[]): void {
const { BlockManager, Caret, Tools } = this.Editor;
const sanitizedBlocks = sanitizeBlocks(blocks, (name) =>
Tools.blockTools.get(name).sanitizeConfig
);
sanitizedBlocks.forEach(({ tool, data }, i) => {
let needToReplaceCurrentBlock = false;
if (i === 0) {
const isCurrentBlockDefault = BlockManager.currentBlock && BlockManager.currentBlock.tool.isDefault;
needToReplaceCurrentBlock = isCurrentBlockDefault && BlockManager.currentBlock.isEmpty;
}
const block = BlockManager.insert({
tool,
data,
replace: needToReplaceCurrentBlock,
});
Caret.setToBlock(block, Caret.positions.END);
});
}
/**
* Fetch nodes from Element node
*
* @param {Node} node - current node
* @param {Node[]} nodes - processed nodes
* @param {Node} destNode - destination node
*/
private processElementNode(node: Node, nodes: Node[], destNode: Node): Node[] | void {
const tags = Object.keys(this.toolsTags);
const element = node as HTMLElement;
const { tool } = this.toolsTags[element.tagName] || {};
const toolTags = this.tagsByTool[tool?.name] || [];
const isSubstitutable = tags.includes(element.tagName);
const isBlockElement = $.blockElements.includes(element.tagName.toLowerCase());
const containsAnotherToolTags = Array
.from(element.children)
.some(
({ tagName }) => tags.includes(tagName) && !toolTags.includes(tagName)
);
const containsBlockElements = Array.from(element.children).some(
({ tagName }) => $.blockElements.includes(tagName.toLowerCase())
);
/** Append inline elements to previous fragment */
if (!isBlockElement && !isSubstitutable && !containsAnotherToolTags) {
destNode.appendChild(element);
return [...nodes, destNode];
}
if (
(isSubstitutable && !containsAnotherToolTags) ||
(isBlockElement && !containsBlockElements && !containsAnotherToolTags)
) {
return [...nodes, destNode, element];
}
}
/**
* Recursively divide HTML string to two types of nodes:
* 1. Block element
* 2. Document Fragments contained text and markup tags like a, b, i etc.
*
* @param {Node} wrapper - wrapper of paster HTML content
* @returns {Node[]}
*/
private getNodes(wrapper: Node): Node[] {
const children = Array.from(wrapper.childNodes);
let elementNodeProcessingResult: Node[] | void;
const reducer = (nodes: Node[], node: Node): Node[] => {
if ($.isEmpty(node) && !$.isSingleTag(node as HTMLElement)) {
return nodes;
}
const lastNode = nodes[nodes.length - 1];
let destNode: Node = new DocumentFragment();
if (lastNode && $.isFragment(lastNode)) {
destNode = nodes.pop();
}
switch (node.nodeType) {
/**
* If node is HTML element:
* 1. Check if it is inline element
* 2. Check if it contains another block or substitutable elements
*/
case Node.ELEMENT_NODE:
elementNodeProcessingResult = this.processElementNode(node, nodes, destNode);
if (elementNodeProcessingResult) {
return elementNodeProcessingResult;
}
break;
/**
* If node is text node, wrap it with DocumentFragment
*/
case Node.TEXT_NODE:
destNode.appendChild(node);
return [...nodes, destNode];
default:
return [...nodes, destNode];
}
return [...nodes, ...Array.from(node.childNodes).reduce(reducer, [])];
};
return children.reduce(reducer, []);
}
/**
* Compose paste event with passed type and detail
*
* @param {string} type - event type
* @param {PasteEventDetail} detail - event detail
*/
private composePasteEvent(type: string, detail: PasteEventDetail): PasteEvent {
return new CustomEvent(type, {
detail,
}) as PasteEvent;
}
}