UNPKG

@ckeditor/ckeditor5-clipboard

Version:

Clipboard integration feature for CKEditor 5.

1,095 lines (1,087 loc) 101 kB
/** * @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 */ import { Plugin } from '@ckeditor/ckeditor5-core/dist/index.js'; import { EventInfo, getRangeFromMouseEvent, uid, toUnit, delay, DomEmitterMixin, global, Rect, ResizeObserver, env, createElement } from '@ckeditor/ckeditor5-utils/dist/index.js'; import { DomEventObserver, DataTransfer, Range, MouseObserver, LiveRange } from '@ckeditor/ckeditor5-engine/dist/index.js'; import { mapValues, throttle } from 'es-toolkit/compat'; import { Widget, isWidget } from '@ckeditor/ckeditor5-widget/dist/index.js'; import { View } from '@ckeditor/ckeditor5-ui/dist/index.js'; /** * Clipboard events observer. * * Fires the following events: * * * {@link module:engine/view/document~Document#event:clipboardInput}, * * {@link module:engine/view/document~Document#event:paste}, * * {@link module:engine/view/document~Document#event:copy}, * * {@link module:engine/view/document~Document#event:cut}, * * {@link module:engine/view/document~Document#event:drop}, * * {@link module:engine/view/document~Document#event:dragover}, * * {@link module:engine/view/document~Document#event:dragging}, * * {@link module:engine/view/document~Document#event:dragstart}, * * {@link module:engine/view/document~Document#event:dragend}, * * {@link module:engine/view/document~Document#event:dragenter}, * * {@link module:engine/view/document~Document#event:dragleave}. * * **Note**: This observer is not available by default (ckeditor5-engine does not add it on its own). * To make it available, it needs to be added to {@link module:engine/view/document~Document} by using * the {@link module:engine/view/view~View#addObserver `View#addObserver()`} method. Alternatively, you can load the * {@link module:clipboard/clipboard~Clipboard} plugin which adds this observer automatically (because it uses it). */ class ClipboardObserver extends DomEventObserver { domEventType = [ 'paste', 'copy', 'cut', 'drop', 'dragover', 'dragstart', 'dragend', 'dragenter', 'dragleave' ]; constructor(view){ super(view); const viewDocument = this.document; this.listenTo(viewDocument, 'paste', handleInput('clipboardInput'), { priority: 'low' }); this.listenTo(viewDocument, 'drop', handleInput('clipboardInput'), { priority: 'low' }); this.listenTo(viewDocument, 'dragover', handleInput('dragging'), { priority: 'low' }); function handleInput(type) { return (evt, data)=>{ data.preventDefault(); const targetRanges = data.dropRange ? [ data.dropRange ] : null; const eventInfo = new EventInfo(viewDocument, type); viewDocument.fire(eventInfo, { dataTransfer: data.dataTransfer, method: evt.name, targetRanges, target: data.target, domEvent: data.domEvent }); // If CKEditor handled the input, do not bubble the original event any further. // This helps external integrations recognize that fact and act accordingly. // https://github.com/ckeditor/ckeditor5-upload/issues/92 if (eventInfo.stop.called) { data.stopPropagation(); } }; } } onDomEvent(domEvent) { const nativeDataTransfer = 'clipboardData' in domEvent ? domEvent.clipboardData : domEvent.dataTransfer; const cacheFiles = domEvent.type == 'drop' || domEvent.type == 'paste'; const evtData = { dataTransfer: new DataTransfer(nativeDataTransfer, { cacheFiles }) }; if (domEvent.type == 'drop' || domEvent.type == 'dragover') { const domRange = getRangeFromMouseEvent(domEvent); evtData.dropRange = domRange && this.view.domConverter.domRangeToView(domRange); } this.fire(domEvent.type, domEvent, evtData); } } /** * @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/utils/plaintexttohtml */ /** * Converts plain text to its HTML-ized version. * * @param text The plain text to convert. * @returns HTML generated from the plain text. */ function plainTextToHtml(text) { text = text// Encode &. .replace(/&/g, '&amp;')// Encode <>. .replace(/</g, '&lt;').replace(/>/g, '&gt;')// Creates a paragraph for each double line break. .replace(/\r?\n\r?\n/g, '</p><p>')// Creates a line break for each single line break. .replace(/\r?\n/g, '<br>')// Replace tabs with four spaces. .replace(/\t/g, '&nbsp;&nbsp;&nbsp;&nbsp;')// Preserve trailing spaces (only the first and last one – the rest is handled below). .replace(/^\s/, '&nbsp;').replace(/\s$/, '&nbsp;')// Preserve other subsequent spaces now. .replace(/\s\s/g, ' &nbsp;'); if (text.includes('</p><p>') || text.includes('<br>')) { // If we created paragraphs above, add the trailing ones. text = `<p>${text}</p>`; } // TODO: // * What about '\nfoo' vs ' foo'? return text; } /** * @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/utils/normalizeclipboarddata */ /** * Removes some popular browser quirks out of the clipboard data (HTML). * Removes all HTML comments. These are considered an internal thing and it makes little sense if they leak into the editor data. * * @param data The HTML data to normalize. * @returns Normalized HTML. */ function normalizeClipboardData(data) { return data.replace(/<span(?: class="Apple-converted-space"|)>(\s+)<\/span>/g, (fullMatch, spaces)=>{ // Handle the most popular and problematic case when even a single space becomes an nbsp;. // Decode those to normal spaces. Read more in https://github.com/ckeditor/ckeditor5-clipboard/issues/2. if (spaces.length == 1) { return ' '; } return spaces; })// Remove all HTML comments. .replace(/<!--[\s\S]*?-->/g, ''); } /** * @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/utils/viewtoplaintext */ // Elements which should not have empty-line padding. // Most `view.ContainerElement` want to be separate by new-line, but some are creating one structure // together (like `<li>`) so it is better to separate them by only one "\n". const smallPaddingElements = [ 'figcaption', 'li' ]; const listElements = [ 'ol', 'ul' ]; /** * Converts {@link module:engine/view/item~Item view item} and all of its children to plain text. * * @param converter The converter instance. * @param viewItem View item to convert. * @returns Plain text representation of `viewItem`. */ function viewToPlainText(converter, viewItem) { if (viewItem.is('$text') || viewItem.is('$textProxy')) { return viewItem.data; } if (viewItem.is('element', 'img') && viewItem.hasAttribute('alt')) { return viewItem.getAttribute('alt'); } if (viewItem.is('element', 'br')) { return '\n'; // Convert soft breaks to single line break (#8045). } /** * Item is a document fragment, attribute element or container element. It doesn't * have it's own text value, so we need to convert its children elements. */ let text = ''; let prev = null; for (const child of viewItem.getChildren()){ text += newLinePadding(child, prev) + viewToPlainText(converter, child); prev = child; } // If item is a raw element, the only way to get its content is to render it and read the text directly from DOM. if (viewItem.is('rawElement')) { const tempElement = document.createElement('div'); viewItem.render(tempElement, converter); text += domElementToPlainText(tempElement); } return text; } /** * Recursively converts DOM element and all of its children to plain text. */ function domElementToPlainText(element) { let text = ''; if (element.nodeType === Node.TEXT_NODE) { return element.textContent; } else if (element.tagName === 'BR') { return '\n'; } for (const child of element.childNodes){ text += domElementToPlainText(child); } return text; } /** * Returns new line padding to prefix the given elements with. */ function newLinePadding(element, previous) { if (!previous) { // Don't add padding to first elements in a level. return ''; } if (element.is('element', 'li') && !element.isEmpty && element.getChild(0).is('containerElement')) { // Separate document list items with empty lines. return '\n\n'; } if (listElements.includes(element.name) && listElements.includes(previous.name)) { /** * Because `<ul>` and `<ol>` are AttributeElements, two consecutive lists will not have any padding between * them (see the `if` statement below). To fix this, we need to make an exception for this case. */ return '\n\n'; } if (!element.is('containerElement') && !previous.is('containerElement')) { // Don't add padding between non-container elements. return ''; } if (smallPaddingElements.includes(element.name) || smallPaddingElements.includes(previous.name)) { // Add small padding between selected container elements. return '\n'; } // Do not add padding around the elements that won't be rendered. if (element.is('element') && element.getCustomProperty('dataPipeline:transparentRendering') || previous.is('element') && previous.getCustomProperty('dataPipeline:transparentRendering')) { return ''; } // Add empty lines between container elements. return '\n\n'; } /** * Part of the clipboard logic. Responsible for collecting markers from selected fragments * and restoring them with proper positions in pasted elements. * * @internal */ class ClipboardMarkersUtils extends Plugin { /** * Map of marker names that can be copied. * * @internal */ _markersToCopy = new Map(); /** * @inheritDoc */ static get pluginName() { return 'ClipboardMarkersUtils'; } /** * @inheritDoc */ static get isOfficialPlugin() { return true; } /** * Registers marker name as copyable in clipboard pipeline. * * @param markerName Name of marker that can be copied. * @param config Configuration that describes what can be performed on specified marker. * @internal */ _registerMarkerToCopy(markerName, config) { this._markersToCopy.set(markerName, config); } /** * Performs copy markers on provided selection and paste it to fragment returned from `getCopiedFragment`. * * 1. Picks all markers in provided selection. * 2. Inserts fake markers to document. * 3. Gets copied selection fragment from document. * 4. Removes fake elements from fragment and document. * 5. Inserts markers in the place of removed fake markers. * * Due to selection modification, when inserting items, `getCopiedFragment` must *always* operate on `writer.model.document.selection'. * Do not use any other custom selection object within callback, as this will lead to out-of-bounds exceptions in rare scenarios. * * @param action Type of clipboard action. * @param selection Selection to be checked. * @param getCopiedFragment Callback that performs copy of selection and returns it as fragment. * @internal */ _copySelectedFragmentWithMarkers(action, selection, getCopiedFragment = (writer)=>writer.model.getSelectedContent(writer.model.document.selection)) { return this.editor.model.change((writer)=>{ const oldSelection = writer.model.document.selection; // In some scenarios, such like in drag & drop, passed `selection` parameter is not actually // the same `selection` as the `writer.model.document.selection` which means that `_insertFakeMarkersToSelection` // is not affecting passed `selection` `start` and `end` positions but rather modifies `writer.model.document.selection`. // // It is critical due to fact that when we have selection that starts [ 0, 0 ] and ends at [ 1, 0 ] // and after inserting fake marker it will point to such marker instead of new widget position at start: [ 1, 0 ] end: [2, 0 ]. // `writer.insert` modifies only original `writer.model.document.selection`. writer.setSelection(selection); const sourceSelectionInsertedMarkers = this._insertFakeMarkersIntoSelection(writer, writer.model.document.selection, action); const fragment = getCopiedFragment(writer); const fakeMarkersRangesInsideRange = this._removeFakeMarkersInsideElement(writer, fragment); // <fake-marker> [Foo] Bar</fake-marker> // ^ ^ // In `_insertFakeMarkersIntoSelection` call we inserted fake marker just before first element. // The problem is that the first element can be start position of selection so insertion fake-marker // before such element shifts selection (so selection that was at [0, 0] now is at [0, 1]). // It means that inserted fake-marker is no longer present inside such selection and is orphaned. // This function checks special case of such problem. Markers that are orphaned at the start position // and end position in the same time. Basically it means that they overlaps whole element. for (const [markerName, elements] of Object.entries(sourceSelectionInsertedMarkers)){ fakeMarkersRangesInsideRange[markerName] ||= writer.createRangeIn(fragment); for (const element of elements){ writer.remove(element); } } fragment.markers.clear(); for (const [markerName, range] of Object.entries(fakeMarkersRangesInsideRange)){ fragment.markers.set(markerName, range); } // Revert back selection to previous one. writer.setSelection(oldSelection); return fragment; }); } /** * Performs paste of markers on already pasted element. * * 1. Inserts fake markers that are present in fragment element (such fragment will be processed in `getPastedDocumentElement`). * 2. Calls `getPastedDocumentElement` and gets element that is inserted into root model. * 3. Removes all fake markers present in transformed element. * 4. Inserts new markers with removed fake markers ranges into pasted fragment. * * There are multiple edge cases that have to be considered before calling this function: * * * `markers` are inserted into the same element that must be later transformed inside `getPastedDocumentElement`. * * Fake marker elements inside `getPastedDocumentElement` can be cloned, but their ranges cannot overlap. * * If `duplicateOnPaste` is `true` in marker config then associated marker ID is regenerated before pasting. * * @param markers Object that maps marker name to corresponding range. * @param getPastedDocumentElement Getter used to get target markers element. * @internal */ _pasteMarkersIntoTransformedElement(markers, getPastedDocumentElement) { const pasteMarkers = this._getPasteMarkersFromRangeMap(markers); return this.editor.model.change((writer)=>{ // Inserts fake markers into source fragment / element that is later transformed inside `getPastedDocumentElement`. const sourceFragmentFakeMarkers = this._insertFakeMarkersElements(writer, pasteMarkers); // Modifies document fragment (for example, cloning table cells) and then inserts it into the document. const transformedElement = getPastedDocumentElement(writer); // Removes markers in pasted and transformed fragment in root document. const removedFakeMarkers = this._removeFakeMarkersInsideElement(writer, transformedElement); // Cleans up fake markers inserted into source fragment (that one before transformation which is not pasted). for (const element of Object.values(sourceFragmentFakeMarkers).flat()){ writer.remove(element); } // Inserts to root document fake markers. for (const [markerName, range] of Object.entries(removedFakeMarkers)){ if (!writer.model.markers.has(markerName)) { writer.addMarker(markerName, { usingOperation: true, affectsData: true, range }); } } return transformedElement; }); } /** * Pastes document fragment with markers to document. * If `duplicateOnPaste` is `true` in marker config then associated markers IDs * are regenerated before pasting to avoid markers duplications in content. * * @param fragment Document fragment that should contain already processed by pipeline markers. * @internal */ _pasteFragmentWithMarkers(fragment) { const pasteMarkers = this._getPasteMarkersFromRangeMap(fragment.markers); fragment.markers.clear(); for (const copyableMarker of pasteMarkers){ fragment.markers.set(copyableMarker.name, copyableMarker.range); } return this.editor.model.insertContent(fragment); } /** * In some situations we have to perform copy on selected fragment with certain markers. This function allows to temporarily bypass * restrictions on markers that we want to copy. * * This function executes `executor()` callback. For the duration of the callback, if the clipboard pipeline is used to copy * content, markers with the specified name will be copied to the clipboard as well. * * @param markerName Which markers should be copied. * @param executor Callback executed. * @param config Optional configuration flags used to copy (such like partial copy flag). * @internal */ _forceMarkersCopy(markerName, executor, config = { allowedActions: 'all', copyPartiallySelected: true, duplicateOnPaste: true }) { const before = this._markersToCopy.get(markerName); this._markersToCopy.set(markerName, config); executor(); if (before) { this._markersToCopy.set(markerName, before); } else { this._markersToCopy.delete(markerName); } } /** * Checks if marker can be copied. * * @param markerName Name of checked marker. * @param action Type of clipboard action. If null then checks only if marker is registered as copyable. * @internal */ _isMarkerCopyable(markerName, action) { const config = this._getMarkerClipboardConfig(markerName); if (!config) { return false; } // If there is no action provided then only presence of marker is checked. if (!action) { return true; } const { allowedActions } = config; return allowedActions === 'all' || allowedActions.includes(action); } /** * Checks if marker has any clipboard copy behavior configuration. * * @param markerName Name of checked marker. */ _hasMarkerConfiguration(markerName) { return !!this._getMarkerClipboardConfig(markerName); } /** * Returns marker's configuration flags passed during registration. * * @param markerName Name of marker that should be returned. * @internal */ _getMarkerClipboardConfig(markerName) { const [markerNamePrefix] = markerName.split(':'); return this._markersToCopy.get(markerNamePrefix) || null; } /** * First step of copying markers. It looks for markers intersecting with given selection and inserts `$marker` elements * at positions where document markers start or end. This way `$marker` elements can be easily copied together with * the rest of the content of the selection. * * @param writer An instance of the model writer. * @param selection Selection to be checked. * @param action Type of clipboard action. */ _insertFakeMarkersIntoSelection(writer, selection, action) { const copyableMarkers = this._getCopyableMarkersFromSelection(writer, selection, action); return this._insertFakeMarkersElements(writer, copyableMarkers); } /** * Returns array of markers that can be copied in specified selection. * * If marker cannot be copied partially (according to `copyPartiallySelected` configuration flag) and * is not present entirely in any selection range then it will be skipped. * * @param writer An instance of the model writer. * @param selection Selection which will be checked. * @param action Type of clipboard action. If null then checks only if marker is registered as copyable. */ _getCopyableMarkersFromSelection(writer, selection, action) { const selectionRanges = Array.from(selection.getRanges()); // Picks all markers in provided ranges. Ensures that there are no duplications if // there are multiple ranges that intersects with the same marker. const markersInRanges = new Set(selectionRanges.flatMap((selectionRange)=>Array.from(writer.model.markers.getMarkersIntersectingRange(selectionRange)))); const isSelectionMarkerCopyable = (marker)=>{ // Check if marker exists in configuration and provided action can be performed on it. const isCopyable = this._isMarkerCopyable(marker.name, action); if (!isCopyable) { return false; } // Checks if configuration disallows to copy marker only if part of its content is selected. // // Example: // <marker-a> Hello [ World ] </marker-a> // ^ selection // // In this scenario `marker-a` won't be copied because selection doesn't overlap its content entirely. const { copyPartiallySelected } = this._getMarkerClipboardConfig(marker.name); if (!copyPartiallySelected) { const markerRange = marker.getRange(); return selectionRanges.some((selectionRange)=>selectionRange.containsRange(markerRange, true)); } return true; }; return Array.from(markersInRanges).filter(isSelectionMarkerCopyable).map((copyableMarker)=>{ // During `dragstart` event original marker is still present in tree. // It is removed after the clipboard drop event, so none of the copied markers are inserted at the end. // It happens because there already markers with specified `marker.name` when clipboard is trying to insert data // and it aborts inserting. const name = action === 'dragstart' ? this._getUniqueMarkerName(copyableMarker.name) : copyableMarker.name; return { name, range: copyableMarker.getRange() }; }); } /** * Picks all markers from markers map that can be pasted. * If `duplicateOnPaste` is `true`, it regenerates their IDs to ensure uniqueness. * If marker is not registered, it will be kept in the array anyway. * * @param markers Object that maps marker name to corresponding range. * @param action Type of clipboard action. If null then checks only if marker is registered as copyable. */ _getPasteMarkersFromRangeMap(markers, action = null) { const { model } = this.editor; const entries = markers instanceof Map ? Array.from(markers.entries()) : Object.entries(markers); return entries.flatMap(([markerName, range])=>{ if (!this._hasMarkerConfiguration(markerName)) { return [ { name: markerName, range } ]; } if (this._isMarkerCopyable(markerName, action)) { const copyMarkerConfig = this._getMarkerClipboardConfig(markerName); const isInGraveyard = model.markers.has(markerName) && model.markers.get(markerName).getRange().root.rootName === '$graveyard'; if (copyMarkerConfig.duplicateOnPaste || isInGraveyard) { markerName = this._getUniqueMarkerName(markerName); } return [ { name: markerName, range } ]; } return []; }); } /** * Inserts specified array of fake markers elements to document and assigns them `type` and `name` attributes. * Fake markers elements are used to calculate position of markers on pasted fragment that were transformed during * steps between copy and paste. * * @param writer An instance of the model writer. * @param markers Array of markers that will be inserted. */ _insertFakeMarkersElements(writer, markers) { const mappedMarkers = {}; const sortedMarkers = markers.flatMap((marker)=>{ const { start, end } = marker.range; return [ { position: start, marker, type: 'start' }, { position: end, marker, type: 'end' } ]; })// Markers position is sorted backwards to ensure that the insertion of fake markers will not change // the position of the next markers. .sort(({ position: posA }, { position: posB })=>posA.isBefore(posB) ? 1 : -1); for (const { position, marker, type } of sortedMarkers){ const fakeMarker = writer.createElement('$marker', { 'data-name': marker.name, 'data-type': type }); if (!mappedMarkers[marker.name]) { mappedMarkers[marker.name] = []; } mappedMarkers[marker.name].push(fakeMarker); writer.insert(fakeMarker, position); } return mappedMarkers; } /** * Removes all `$marker` elements from the given document fragment. * * Returns an object where keys are marker names, and values are ranges corresponding to positions * where `$marker` elements were inserted. * * If the document fragment had only one `$marker` element for given marker (start or end) the other boundary is set automatically * (to the end or start of the document fragment, respectively). * * @param writer An instance of the model writer. * @param rootElement The element to be checked. */ _removeFakeMarkersInsideElement(writer, rootElement) { const fakeMarkersElements = this._getAllFakeMarkersFromElement(writer, rootElement); const fakeMarkersRanges = fakeMarkersElements.reduce((acc, fakeMarker)=>{ const position = fakeMarker.markerElement && writer.createPositionBefore(fakeMarker.markerElement); let prevFakeMarker = acc[fakeMarker.name]; // Handle scenario when tables clone cells with the same fake node. Example: // // <cell><fake-marker-a></cell> <cell><fake-marker-a></cell> <cell><fake-marker-a></cell> // ^ cloned ^ cloned // // The easiest way to bypass this issue is to rename already existing in map nodes and // set them new unique name. let skipAssign = false; if (prevFakeMarker?.start && prevFakeMarker?.end) { const config = this._getMarkerClipboardConfig(fakeMarker.name); if (config.duplicateOnPaste) { acc[this._getUniqueMarkerName(fakeMarker.name)] = acc[fakeMarker.name]; } else { skipAssign = true; } prevFakeMarker = null; } if (!skipAssign) { acc[fakeMarker.name] = { ...prevFakeMarker, [fakeMarker.type]: position }; } if (fakeMarker.markerElement) { writer.remove(fakeMarker.markerElement); } return acc; }, {}); // We cannot construct ranges directly in previous reduce because element ranges can overlap. // In other words lets assume we have such scenario: // <fake-marker-start /> <paragraph /> <fake-marker-2-start /> <fake-marker-end /> <fake-marker-2-end /> // // We have to remove `fake-marker-start` firstly and then remove `fake-marker-2-start`. // Removal of `fake-marker-2-start` affects `fake-marker-end` position so we cannot create // connection between `fake-marker-start` and `fake-marker-end` without iterating whole set firstly. return mapValues(fakeMarkersRanges, (range)=>new Range(range.start || writer.createPositionFromPath(rootElement, [ 0 ]), range.end || writer.createPositionAt(rootElement, 'end'))); } /** * Returns array that contains list of fake markers with corresponding `$marker` elements. * * For each marker, there can be two `$marker` elements or only one (if the document fragment contained * only the beginning or only the end of a marker). * * @param writer An instance of the model writer. * @param rootElement The element to be checked. */ _getAllFakeMarkersFromElement(writer, rootElement) { const foundFakeMarkers = Array.from(writer.createRangeIn(rootElement)).flatMap(({ item })=>{ if (!item.is('element', '$marker')) { return []; } const name = item.getAttribute('data-name'); const type = item.getAttribute('data-type'); return [ { markerElement: item, name, type } ]; }); const prependFakeMarkers = []; const appendFakeMarkers = []; for (const fakeMarker of foundFakeMarkers){ if (fakeMarker.type === 'end') { // <fake-marker> [ phrase</fake-marker> phrase ] // ^ // Handle case when marker is just before start of selection. // Only end marker is inside selection. const hasMatchingStartMarker = foundFakeMarkers.some((otherFakeMarker)=>otherFakeMarker.name === fakeMarker.name && otherFakeMarker.type === 'start'); if (!hasMatchingStartMarker) { prependFakeMarkers.push({ markerElement: null, name: fakeMarker.name, type: 'start' }); } } if (fakeMarker.type === 'start') { // [<fake-marker>phrase]</fake-marker> // ^ // Handle case when fake marker is after selection. // Only start marker is inside selection. const hasMatchingEndMarker = foundFakeMarkers.some((otherFakeMarker)=>otherFakeMarker.name === fakeMarker.name && otherFakeMarker.type === 'end'); if (!hasMatchingEndMarker) { appendFakeMarkers.unshift({ markerElement: null, name: fakeMarker.name, type: 'end' }); } } } return [ ...prependFakeMarkers, ...foundFakeMarkers, ...appendFakeMarkers ]; } /** * When copy of markers occurs we have to make sure that pasted markers have different names * than source markers. This functions helps with assigning unique part to marker name to * prevent duplicated markers error. * * @param name Name of marker */ _getUniqueMarkerName(name) { const parts = name.split(':'); const newId = uid().substring(1, 6); // It looks like the marker already is UID marker so in this scenario just swap // last part of marker name and assign new UID. // // example: comment:{ threadId }:{ id } => comment:{ threadId }:{ newId } if (parts.length === 3) { return `${parts.slice(0, 2).join(':')}:${newId}`; } // Assign new segment to marker name with id. // // example: comment => comment:{ newId } return `${parts.join(':')}:${newId}`; } } // Input pipeline events overview: // // ┌──────────────────────┐ ┌──────────────────────┐ // │ view.Document │ │ view.Document │ // │ paste │ │ drop │ // └───────────┬──────────┘ └───────────┬──────────┘ // │ │ // └────────────────┌────────────────┘ // │ // ┌─────────V────────┐ // │ view.Document │ Retrieves text/html or text/plain from data.dataTransfer // │ clipboardInput │ and processes it to view.DocumentFragment. // └─────────┬────────┘ // │ // ┌───────────V───────────┐ // │ ClipboardPipeline │ Converts view.DocumentFragment to model.DocumentFragment. // │ inputTransformation │ // └───────────┬───────────┘ // │ // ┌──────────V──────────┐ // │ ClipboardPipeline │ Calls model.insertContent(). // │ contentInsertion │ // └─────────────────────┘ // // // Output pipeline events overview: // // ┌──────────────────────┐ ┌──────────────────────┐ // │ view.Document │ │ view.Document │ Retrieves the selected model.DocumentFragment // │ copy │ │ cut │ and fires the `outputTransformation` event. // └───────────┬──────────┘ └───────────┬──────────┘ // │ │ // └────────────────┌────────────────┘ // │ // ┌───────────V───────────┐ // │ ClipboardPipeline │ Processes model.DocumentFragment and converts it to // │ outputTransformation │ view.DocumentFragment. // └───────────┬───────────┘ // │ // ┌─────────V────────┐ // │ view.Document │ Processes view.DocumentFragment to text/html and text/plain // │ clipboardOutput │ and stores the results in data.dataTransfer. // └──────────────────┘ // /** * The clipboard pipeline feature. It is responsible for intercepting the `paste` and `drop` events and * passing the pasted content through a series of events in order to insert it into the editor's content. * It also handles the `cut` and `copy` events to fill the native clipboard with the serialized editor's data. * * # Input pipeline * * The behavior of the default handlers (all at a `low` priority): * * ## Event: `paste` or `drop` * * 1. Translates the event data. * 2. Fires the {@link module:engine/view/document~Document#event:clipboardInput `view.Document#clipboardInput`} event. * * ## Event: `view.Document#clipboardInput` * * 1. If the `data.content` event field is already set (by some listener on a higher priority), it takes this content and fires the event * from the last point. * 2. Otherwise, it retrieves `text/html` or `text/plain` from `data.dataTransfer`. * 3. Normalizes the raw data by applying simple filters on string data. * 4. Processes the raw data to {@link module:engine/view/documentfragment~DocumentFragment `view.DocumentFragment`} with the * {@link module:engine/controller/datacontroller~DataController#htmlProcessor `DataController#htmlProcessor`}. * 5. Fires the {@link module:clipboard/clipboardpipeline~ClipboardPipeline#event:inputTransformation * `ClipboardPipeline#inputTransformation`} event with the view document fragment in the `data.content` event field. * * ## Event: `ClipboardPipeline#inputTransformation` * * 1. Converts {@link module:engine/view/documentfragment~DocumentFragment `view.DocumentFragment`} from the `data.content` field to * {@link module:engine/model/documentfragment~DocumentFragment `model.DocumentFragment`}. * 2. Fires the {@link module:clipboard/clipboardpipeline~ClipboardPipeline#event:contentInsertion `ClipboardPipeline#contentInsertion`} * event with the model document fragment in the `data.content` event field. * **Note**: The `ClipboardPipeline#contentInsertion` event is fired within a model change block to allow other handlers * to run in the same block without post-fixers called in between (i.e., the selection post-fixer). * * ## Event: `ClipboardPipeline#contentInsertion` * * 1. Calls {@link module:engine/model/model~Model#insertContent `model.insertContent()`} to insert `data.content` * at the current selection position. * * # Output pipeline * * The behavior of the default handlers (all at a `low` priority): * * ## Event: `copy`, `cut` or `dragstart` * * 1. Retrieves the selected {@link module:engine/model/documentfragment~DocumentFragment `model.DocumentFragment`} by calling * {@link module:engine/model/model~Model#getSelectedContent `model#getSelectedContent()`}. * 2. Converts the model document fragment to {@link module:engine/view/documentfragment~DocumentFragment `view.DocumentFragment`}. * 3. Fires the {@link module:engine/view/document~Document#event:clipboardOutput `view.Document#clipboardOutput`} event * with the view document fragment in the `data.content` event field. * * ## Event: `view.Document#clipboardOutput` * * 1. Processes `data.content` to HTML and plain text with the * {@link module:engine/controller/datacontroller~DataController#htmlProcessor `DataController#htmlProcessor`}. * 2. Updates the `data.dataTransfer` data for `text/html` and `text/plain` with the processed data. * 3. For the `cut` method, calls {@link module:engine/model/model~Model#deleteContent `model.deleteContent()`} * on the current selection. * * Read more about the clipboard integration in the {@glink framework/deep-dive/clipboard clipboard deep-dive} guide. */ class ClipboardPipeline extends Plugin { /** * @inheritDoc */ static get pluginName() { return 'ClipboardPipeline'; } /** * @inheritDoc */ static get isOfficialPlugin() { return true; } /** * @inheritDoc */ static get requires() { return [ ClipboardMarkersUtils ]; } /** * @inheritDoc */ init() { const editor = this.editor; const view = editor.editing.view; view.addObserver(ClipboardObserver); this._setupPasteDrop(); this._setupCopyCut(); } /** * Fires Clipboard `'outputTransformation'` event for given parameters. * * @internal */ _fireOutputTransformationEvent(dataTransfer, selection, method) { const clipboardMarkersUtils = this.editor.plugins.get('ClipboardMarkersUtils'); this.editor.model.enqueueChange({ isUndoable: method === 'cut' }, ()=>{ const documentFragment = clipboardMarkersUtils._copySelectedFragmentWithMarkers(method, selection); this.fire('outputTransformation', { dataTransfer, content: documentFragment, method }); }); } /** * The clipboard paste pipeline. */ _setupPasteDrop() { const editor = this.editor; const model = editor.model; const view = editor.editing.view; const viewDocument = view.document; const clipboardMarkersUtils = this.editor.plugins.get('ClipboardMarkersUtils'); // Pasting is disabled when selection is in non-editable place. // Dropping is disabled in drag and drop handler. this.listenTo(viewDocument, 'clipboardInput', (evt, data)=>{ if (data.method == 'paste' && !editor.model.canEditAt(editor.model.document.selection)) { evt.stop(); } }, { priority: 'highest' }); this.listenTo(viewDocument, 'clipboardInput', (evt, data)=>{ const dataTransfer = data.dataTransfer; let content; // Some feature could already inject content in the higher priority event handler (i.e., codeBlock). if (data.content) { content = data.content; } else { let contentData = ''; if (dataTransfer.getData('text/html')) { contentData = normalizeClipboardData(dataTransfer.getData('text/html')); } else if (dataTransfer.getData('text/plain')) { contentData = plainTextToHtml(dataTransfer.getData('text/plain')); } content = this.editor.data.htmlProcessor.toView(contentData); } const eventInfo = new EventInfo(this, 'inputTransformation'); const sourceEditorId = dataTransfer.getData('application/ckeditor5-editor-id') || null; this.fire(eventInfo, { content, dataTransfer, sourceEditorId, targetRanges: data.targetRanges, method: data.method }); // If CKEditor handled the input, do not bubble the original event any further. // This helps external integrations recognize this fact and act accordingly. // https://github.com/ckeditor/ckeditor5-upload/issues/92 if (eventInfo.stop.called) { evt.stop(); } view.scrollToTheSelection(); }, { priority: 'low' }); this.listenTo(this, 'inputTransformation', (evt, data)=>{ if (data.content.isEmpty) { return; } const dataController = this.editor.data; // Convert the pasted content into a model document fragment. // The conversion is contextual, but in this case an "all allowed" context is needed // and for that we use the $clipboardHolder item. const modelFragment = dataController.toModel(data.content, '$clipboardHolder'); if (modelFragment.childCount == 0) { return; } evt.stop(); // Fire content insertion event in a single change block to allow other handlers to run in the same block // without post-fixers called in between (i.e., the selection post-fixer). model.change(()=>{ this.fire('contentInsertion', { content: modelFragment, method: data.method, sourceEditorId: data.sourceEditorId, dataTransfer: data.dataTransfer, targetRanges: data.targetRanges }); }); }, { priority: 'low' }); this.listenTo(this, 'contentInsertion', (evt, data)=>{ data.resultRange = clipboardMarkersUtils._pasteFragmentWithMarkers(data.content); }, { priority: 'low' }); } /** * The clipboard copy/cut pipeline. */ _setupCopyCut() { const editor = this.editor; const modelDocument = editor.model.document; const view = editor.editing.view; const viewDocument = view.document; const onCopyCut = (evt, data)=>{ const dataTransfer = data.dataTransfer; data.preventDefault(); this._fireOutputTransformationEvent(dataTransfer, modelDocument.selection, evt.name); }; this.listenTo(viewDocument, 'copy', onCopyCut, { priority: 'low' }); this.listenTo(viewDocument, 'cut', (evt, data)=>{ // Cutting is disabled when selection is in non-editable place. // See: https://github.com/ckeditor/ckeditor5-clipboard/issues/26. if (!editor.model.canEditAt(editor.model.document.selection)) { data.preventDefault(); } else { onCopyCut(evt, data); } }, { priority: 'low' }); this.listenTo(this, 'outputTransformation', (evt, data)=>{ const content = editor.data.toView(data.content, { isClipboardPipeline: true }); viewDocument.fire('clipboardOutput', { dataTransfer: data.dataTransfer, content, method: data.method }); }, { priority: 'low' }); this.listenTo(viewDocument, 'clipboardOutput', (evt, data)=>{ if (!data.content.isEmpty) { data.dataTransfer.setData('text/html', this.editor.data.htmlProcessor.toData(data.content)); data.dataTransfer.setData('text/plain', viewToPlainText(editor.data.htmlProcessor.domConverter, data.content)); data.dataTransfer.setData('application/ckeditor5-editor-id', this.editor.id); } if (data.method == 'cut') { editor.model.deleteContent(modelDocument.selection); } }, { priority: 'low' }); } } const toPx = /* #__PURE__ */ toUnit('px'); /** * The horizontal drop target line view. */ class LineView extends View { /** * @inheritDoc */ constructor(){ super(); const bind = this.bindTemplate; this.set({ isVisible: false, left: null, top: null, width: null }); this.setTemplate({ tag: 'div', attributes: { class: [ 'ck', 'ck-clipboard-drop-target-line', bind.if('isVisible', 'ck-hidden', (value)=>!value) ], style: { left: bind.to('left', (left)=>toPx(left)), top: bind.to('top', (top)=>toPx(top)), width: bind.to('width', (width)=>toPx(width)) } } }); } } /** * Part of the Drag and Drop handling. Responsible for finding and displaying the drop target. * * @internal */ class DragDropTarget extends Plugin { /** * A delayed callback removing the drop marker. * * @internal */ removeDropMarkerDelayed = delay(()=>this.removeDropMarker(), 40); /** * A throttled callback updating the drop marker. */ _updateDropMarkerThrottled = throttle((targetRange)=>this._updateDropMarker(targetRange), 40); /** * A throttled callback reconverting the drop parker. */ _reconvertMarkerThrottled = throttle(()=>{ if (this.editor.model.markers.has('drop-target')) { this.editor.editing.reconvertMarker('drop-target'); } }, 0); /** * The horizontal drop target line view. */ _dropTargetLineView = new LineView(); /** * DOM Emitter. */ _domEmitter = new (DomEmitterMixin())(); /** * Map of document scrollable elements. */ _scrollables = new Map(); /** * @inheritDoc */ static get pluginName() { return 'DragDropTarget'; } /** * @inheritDoc */ static get isOfficialPlugin() { return true; } /** * @inheritDoc */ init() { this._setupDropMarker(); } /** * @inheritDoc */ destroy() { this._domEmitter.stopListening(); for (const { resizeObserver } of this._scrollables.values()){ resizeObserver.destroy(); } this._updateDropMarkerThrottled.cancel(); this.removeDropMarkerDelayed.cancel(); this._reconvertMarkerThrottled.cancel(); return super.destroy(); } /** * Finds the drop target range and updates the drop marker. * * @internal */ updateDropMarker(targetViewElement, targetViewRanges, clientX, clientY, blockMode, draggedRange) { this.removeDropMarkerDelayed.cancel(); const targetRange = findDropTargetRange(this.editor, targetViewElement, targetViewRanges, clientX, clientY, blockMode, draggedRange); /* istanbul ignore next -- @prese