@ckeditor/ckeditor5-clipboard
Version:
Clipboard integration feature for CKEditor 5.
501 lines (500 loc) • 24 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/clipboardmarkersutils
*/
import { mapValues } from 'es-toolkit/compat';
import { uid } from '@ckeditor/ckeditor5-utils';
import { Plugin } from '@ckeditor/ckeditor5-core';
import { Range } from '@ckeditor/ckeditor5-engine';
/**
* Part of the clipboard logic. Responsible for collecting markers from selected fragments
* and restoring them with proper positions in pasted elements.
*
* @internal
*/
export default 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}`;
}
}