@ckeditor/ckeditor5-clipboard
Version:
Clipboard integration feature for CKEditor 5.
103 lines (102 loc) • 4.09 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module clipboard/pasteplaintext
*/
import { Plugin } from '@ckeditor/ckeditor5-core';
import ClipboardObserver from './clipboardobserver.js';
import ClipboardPipeline from './clipboardpipeline.js';
/**
* The plugin detects the user's intention to paste plain text.
*
* For example, it detects the <kbd>Ctrl/Cmd</kbd> + <kbd>Shift</kbd> + <kbd>V</kbd> keystroke.
*/
export default class PastePlainText extends Plugin {
/**
* @inheritDoc
*/
static get pluginName() {
return 'PastePlainText';
}
/**
* @inheritDoc
*/
static get isOfficialPlugin() {
return true;
}
/**
* @inheritDoc
*/
static get requires() {
return [ClipboardPipeline];
}
/**
* @inheritDoc
*/
init() {
const editor = this.editor;
const model = editor.model;
const view = editor.editing.view;
const selection = model.document.selection;
view.addObserver(ClipboardObserver);
editor.plugins.get(ClipboardPipeline).on('contentInsertion', (evt, data) => {
if (!isUnformattedInlineContent(data.content, model)) {
return;
}
model.change(writer => {
// Formatting attributes should be preserved.
const textAttributes = Array.from(selection.getAttributes())
.filter(([key]) => model.schema.getAttributeProperties(key).isFormatting);
if (!selection.isCollapsed) {
model.deleteContent(selection, { doNotAutoparagraph: true });
}
// Also preserve other attributes if they survived the content deletion (because they were not fully selected).
// For example linkHref is not a formatting attribute but it should be preserved if pasted text was in the middle
// of a link.
textAttributes.push(...selection.getAttributes());
const range = writer.createRangeIn(data.content);
for (const item of range.getItems()) {
for (const attribute of textAttributes) {
if (model.schema.checkAttribute(item, attribute[0])) {
writer.setAttribute(attribute[0], attribute[1], item);
}
}
}
});
});
}
}
/**
* Returns true if specified `documentFragment` represents the unformatted inline content.
*/
function isUnformattedInlineContent(documentFragment, model) {
let range = model.createRangeIn(documentFragment);
// We consider three scenarios here. The document fragment may include:
//
// 1. Only text and inline objects. Then it could be unformatted inline content.
// 2. Exactly one block element on top-level, eg. <p>Foobar</p> or <h2>Title</h2>.
// In this case, check this element content, it could be treated as unformatted inline content.
// 3. More block elements or block objects, then it is not unformatted inline content.
//
// We will check for scenario 2. specifically, and if it happens, we will unwrap it and follow with the regular algorithm.
//
if (documentFragment.childCount == 1) {
const child = documentFragment.getChild(0);
if (child.is('element') && model.schema.isBlock(child) && !model.schema.isObject(child) && !model.schema.isLimit(child)) {
// Scenario 2. as described above.
range = model.createRangeIn(child);
}
}
for (const child of range.getItems()) {
if (!model.schema.isInline(child)) {
return false;
}
const attributeKeys = Array.from(child.getAttributeKeys());
if (attributeKeys.find(key => model.schema.getAttributeProperties(key).isFormatting)) {
return false;
}
}
return true;
}