Skip to content

Commit

Permalink
Merge pull request #192 from craftcms/bugfix/186-copy-paste-drag
Browse files Browse the repository at this point in the history
Handle copy/paste/drag of nested entries
  • Loading branch information
brandonkelly authored Mar 14, 2024
2 parents 5fd8ce8 + 90a0766 commit 8da0879
Show file tree
Hide file tree
Showing 7 changed files with 160 additions and 2 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Release Notes for CKEditor for Craft CMS

## Unreleased

- Copy/pasting nested entry cards now duplicates the nested entries. ([#186](https://github.com/craftcms/ckeditor/issues/186), [#192](https://github.com/craftcms/ckeditor/pull/192))
- Fixed a bug where it was possible to copy/paste nested entry cards between CKEditor fields. ([#192](https://github.com/craftcms/ckeditor/pull/192))

## 4.0.0-beta.10 - 2024-03-12

- CKEditor now requires Craft CMS 5.0.0-beta.7 or later.
Expand Down
36 changes: 36 additions & 0 deletions src/controllers/CkeditorController.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@
use craft\elements\Asset;
use craft\fieldlayoutelements\CustomField;
use craft\web\Controller;
use Throwable;
use yii\web\BadRequestHttpException;
use yii\web\NotFoundHttpException;
use yii\web\Response;
use yii\web\ServerErrorHttpException;

/**
* CKEditor controller
Expand Down Expand Up @@ -90,4 +92,38 @@ public function actionEntryCardHtml(): Response
'bodyHtml' => $view->getBodyHtml(),
]);
}

/**
* Duplicates a nested entry and returns the duplicate’s ID.
*
* @return Response
* @throws BadRequestHttpException
* @throws ServerErrorHttpException
* @since 4.0.0
*/
public function actionDuplicateNestedEntry(): Response
{
$entryId = $this->request->getRequiredBodyParam('entryId');
$siteId = $this->request->getBodyParam('siteId');
$entry = Craft::$app->getEntries()->getEntryById($entryId, $siteId, [
'status' => null,
'revisions' => null,
]);

if (!$entry) {
throw new BadRequestHttpException("Invalid entry ID: $entryId");
}

try {
$newEntry = Craft::$app->getElements()->duplicateElement($entry);
} catch (Throwable $e) {
return $this->asFailure(Craft::t('app', 'Couldn’t duplicate {type}.', [
'type' => $entry::lowerDisplayName(),
]), ['additionalMessage' => $e->getMessage()]);
}

return $this->asJson([
'newEntryId' => $newEntry->id,
]);
}
}
1 change: 1 addition & 0 deletions src/translations/en/ckeditor.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
'Disable this at your own risk!' => 'Disable this at your own risk!',
'Drag toolbar items into the editor.' => 'Drag toolbar items into the editor.',
'Edit CKEditor Config' => 'Edit CKEditor Config',
'Entries cannot be copied between CKEditor fields.' => 'Entries cannot be copied between CKEditor fields.',
'Entry toolbar' => 'Entry toolbar',
'Entry types list' => 'Entry types list',
'HTML Purifier Config' => 'HTML Purifier Config',
Expand Down
1 change: 1 addition & 0 deletions src/web/assets/ckeditor/CkeditorAsset.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ public function registerAssetFiles($view): void
'New {type}',
]);
$view->registerTranslations('ckeditor', [
'Entries cannot be copied between CKEditor fields.',
'Entry toolbar',
'Entry types list',
'Insert link',
Expand Down
2 changes: 1 addition & 1 deletion src/web/assets/ckeditor/dist/ckeditor5-craftcms.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/web/assets/ckeditor/dist/ckeditor5-craftcms.js.map

Large diffs are not rendered by default.

115 changes: 115 additions & 0 deletions src/web/assets/ckeditor/src/ckeditor5-craftcms.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import {Anchor} from '@northernco/ckeditor5-anchor-drupal';
const allPlugins = [
CKEditor5.paragraph.Paragraph,
CKEditor5.selectAll.SelectAll,
CKEditor5.clipboard.Clipboard,
Alignment,
Anchor,
AutoImage,
Expand Down Expand Up @@ -372,6 +373,118 @@ const headingShortcuts = function (editor, config) {
}
};

/**
* Handle cut, copy, paste, drag
* Prevents pasting/dragging nested entries to another editor instance.
* Duplicates nested entries on copy+paste
*
* @param editor
*/
const handleClipboard = function (editor) {
let copyFromEditorId = null;
const documentView = editor.editing.view.document;
const clipboardPipelinePlugin = editor.plugins.get('ClipboardPipeline');

// on cut/copy/drag start - get editor id
// https://ckeditor.com/docs/ckeditor5/latest/framework/deep-dive/clipboard.html
documentView.on('clipboardOutput', (event, data) => {
// get the editor ID so that we can compare it on paste/drag stop
copyFromEditorId = editor.id;
});

// https://ckeditor.com/docs/ckeditor5/latest/api/module_clipboard_clipboardpipeline-ClipboardPipeline.html
// handle pasting/dragging nested elements
documentView.on('clipboardInput', async (event, data) => {
let pasteContent = data.dataTransfer.getData('text/html');

// if it's not html content, abort and let the clipboard feature handle the input
if (!pasteContent) {
return;
}

// if what we're pasting contains nested element(s)
if (pasteContent.includes('<craft-entry')) {
// if the copyFromEditorId is different to editor.id we're pasting into,
if (copyFromEditorId != editor.id) {
// prevent and show message
Craft.cp.displayError(
Craft.t(
'ckeditor',
'Entries cannot be copied between CKEditor fields.',
),
);
event.stop();
} else {
// if we're dragging - carry on
// if we're pasting - maybe duplicate
if (data.method == 'paste') {
let duplicatedContent = pasteContent;
let errors = false;
const siteId = Craft.siteId;
const editorData = editor.getData();
const matches = [
...pasteContent.matchAll(/data-entry-id="([0-9]+)/g),
];

// Stop the event emitter from calling further callbacks for this event interaction
// we need to get duplicates and update the content snippet that's being pasted in
// before we can call further events
event.stop();

// for each nested entry ID we found
for (let i = 0; i < matches.length; i++) {
let entryId = null;
if (matches[i][1]) {
entryId = matches[i][1];
}

if (entryId !== null) {
// check if this entry ID is in the field already
const regex = new RegExp('data-entry-id="' + entryId + '"');
if (!regex.test(editorData)) {
// if it's not - carry on
} else {
// duplicate it and replace the string's ID with the new one
await Craft.sendActionRequest(
'POST',
'ckeditor/ckeditor/duplicate-nested-entry',
{
data: {
entryId: entryId,
siteId: siteId,
},
},
)
.then((response) => {
if (response.data.newEntryId) {
duplicatedContent = duplicatedContent.replace(
entryId,
response.data.newEntryId,
);
}
})
.catch((e) => {
errors = true;
Craft.cp.displayError(e?.response?.data?.message);
console.error(e?.response?.data?.additionalMessage);
});
}
}
}

// only update the data.content and fire further callbacks if we didn't encounter errors;
if (!errors) {
// data.content is what's passed down the chain to be pasted in
data.content = editor.data.htmlProcessor.toView(duplicatedContent);
// and now we can fire further callbacks for this event interaction
clipboardPipelinePlugin.fire('inputTransformation', data);
}
}
}
}
});
};

export const pluginNames = () => allPlugins.map((p) => p.pluginName);

export const create = async function (element, config) {
Expand Down Expand Up @@ -465,5 +578,7 @@ export const create = async function (element, config) {
headingShortcuts(editor, config);
}

handleClipboard(editor);

return editor;
};

0 comments on commit 8da0879

Please sign in to comment.