Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle copy/paste/drag of nested entries #192

Merged
merged 6 commits into from
Mar 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
};
Loading