Skip to content
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## Unreleased

> [!NOTE]
> Elements that contain CKEditor fields must be resaved before the new deletion blocker can take effect. That can be done automatically by running the following command:
>
> ```sh
> php craft resave/all --with-fields=myCkeditorField1,myCkeditorField2
> ```

- CKEditor now requires Craft CMS 5.10 or later.
- Added a deletion blocker for elements that are referenced within CKEditor fields. ([#576](https://github.com/craftcms/ckeditor/pull/576))
- Fixed a bug where custom `removePlugins` config values weren’t being respected. ([#578](https://github.com/craftcms/ckeditor/issues/578))
- Fixed a bug where consecutive “Open in new tab?” and “Download” advanced link options were being displayed side-by-side. ([#575](https://github.com/craftcms/ckeditor/issues/575))
- Fixed a bug where CKEditor fields with list items could get marked as dirty before any changes were actually made to them. ([#554](https://github.com/craftcms/ckeditor/issues/554))
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
},
"require": {
"php": "^8.2",
"craftcms/cms": "^5.9.0",
"craftcms/cms": "^5.10.0",
"craftcms/html-field": "^3.5.0",
"embed/embed": "^4.4",
"nystudio107/craft-code-editor": ">=1.0.8 <=1.0.13 || ^1.0.16"
Expand Down
790 changes: 402 additions & 388 deletions composer.lock

Large diffs are not rendered by default.

109 changes: 109 additions & 0 deletions src/Field.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
use craft\models\Section;
use craft\models\Volume;
use craft\services\Drafts;
use craft\services\Elements;
use craft\services\ElementSources;
use craft\web\View;
use GraphQL\Type\Definition\Type;
Expand Down Expand Up @@ -1251,6 +1252,114 @@ public function serializeValue(mixed $value, ?ElementInterface $element): mixed
);
}

/**
* @inheritdoc
*/
public function afterElementSave(ElementInterface $element, bool $isNew): void
{
if ($element->duplicateOf || $element->isFieldDirty($this->handle)) {
$this->updateReferences($element, $isNew);
}

parent::afterElementSave($element, $isNew);
}

private function updateReferences(ElementInterface $element, bool $isNew): void
{
$value = $element->getFieldValue($this->handle);
$targetIds = array_flip($this->getRefTargetIds($value));
$db = Craft::$app->getDb();

// Get the old references
if (!$isNew) {
$oldRefs = (new Query())
->select(['id', 'targetId'])
->from([Plugin::TABLE_REFERENCES])
->where([
'fieldId' => $this->id,
'fieldInstanceUid' => $this->layoutElement->uid,
'sourceId' => $element->id,
'sourceSiteId' => $element->siteId,
])
->all($db);
} else {
$oldRefs = [];
}

$deleteIds = [];

foreach ($oldRefs as $ref) {
[$refId, $targetId] = [
$ref['id'],
$ref['targetId'],
];

// Does this reference still exist?
if (isset($targetIds[$targetId])) {
// Avoid re-inserting it
unset($targetIds[$targetId]);
} else {
$deleteIds[] = $refId;
}
}

if (empty($deleteIds) && empty($targetIds)) {
// Nothing to do here
return;
}

$db->transaction(function() use ($element, $deleteIds, $targetIds, $db) {
// Add the new ones
if (!empty($targetIds)) {
$values = [];
foreach (array_keys($targetIds) as $targetId) {
$values[] = [
$this->id,
$this->layoutElement->uid,
$element->id,
$element->siteId,
$targetId,
];
}
Db::batchInsert(Plugin::TABLE_REFERENCES, [
'fieldId',
'fieldInstanceUid',
'sourceId',
'sourceSiteId',
'targetId',
], $values, $db);
}

if (!empty($deleteIds)) {
Db::delete(Plugin::TABLE_REFERENCES, [
'id' => $deleteIds,
], [], $db);
}
});
}

private function getRefTargetIds(FieldData|string|null $value): array
{
if ($value instanceof FieldData) {
$value = $value->getRawContent();
}

if (!$value) {
return [];
}

$refIds = [];
preg_match_all(Elements::REF_TAG_PATTERN, $value, $matches);

foreach ($matches['ref'] as $ref) {
if (is_numeric($ref)) {
$refIds[] = (int)$ref;
}
}

return $refIds;
}

/**
* @inheritdoc
*/
Expand Down
10 changes: 9 additions & 1 deletion src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@

use Craft;
use craft\base\Element;
use craft\ckeditor\deletionblockers\ReferenceDeletionBlocker;
use craft\ckeditor\web\assets\BaseCkeditorPackageAsset;
use craft\ckeditor\web\assets\ckeditor\CkeditorAsset;
use craft\ckeditor\web\assets\fieldsettings\FieldSettingsAsset;
use craft\elements\NestedElementManager;
use craft\events\AssetBundleEvent;
use craft\events\DefineElementDeletionBlockersEvent;
use craft\events\ModelEvent;
use craft\events\RegisterComponentTypesEvent;
use craft\helpers\UrlHelper;
Expand All @@ -29,6 +31,8 @@
*/
class Plugin extends \craft\base\Plugin
{
public const TABLE_REFERENCES = '{{%ckeditor_references}}';

/**
* Registers an asset bundle for a CKEditor package.
*
Expand All @@ -45,7 +49,7 @@ public static function registerCkeditorPackage(string $name, string $entry = 'in
private static array $ckeditorPackages = [];
private static array $ckeditorImports = [];

public string $schemaVersion = '5.0.0.1';
public string $schemaVersion = '5.6.0.0';

public function init(): void
{
Expand Down Expand Up @@ -101,6 +105,10 @@ public function init(): void
}
});

Event::on(Element::class, Element::EVENT_DEFINE_DELETION_BLOCKERS, function(DefineElementDeletionBlockersEvent $event) {
$event->blockers[] = new ReferenceDeletionBlocker($event->elements, $event->hardDelete);
});

Event::on(Element::class, Element::EVENT_BEFORE_DELETE, function(ModelEvent $event) {
/** @var Element $element */
$element = $event->sender;
Expand Down
106 changes: 106 additions & 0 deletions src/controllers/ReplaceReferencesController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php
/**
* @link https://craftcms.com/
* @copyright Copyright (c) Pixel & Tonic, Inc.
* @license GPL-3.0-or-later
*/

namespace craft\ckeditor\controllers;

use Craft;
use craft\ckeditor\jobs\ReplaceReferences;
use craft\ckeditor\Plugin;
use craft\controllers\DeleteElementsController;
use craft\db\Query;
use craft\db\Table;
use craft\helpers\Cp;
use craft\helpers\Db;
use craft\helpers\Html;
use craft\helpers\Queue;
use yii\web\Response;

/**
* Replace References controller
*
* @author Pixel & Tonic, Inc. <support@pixelandtonic.com>
* @since 5.6.0
*/
class ReplaceReferencesController extends DeleteElementsController
{
public function actionModal(): Response
{
$this->requireAcceptsJson();

$targetElementIds = $this->elements->ids();

return $this->asCpModal()
->action('ckeditor/replace-references/replace')
->contentHtml(fn() =>
Cp::elementSelectFieldHtml([
'label' => Craft::t('app', 'Choose a new {type}', [
'type' => $this->elementType::lowerDisplayName(),
]),
'name' => 'newTargetId',
'elementType' => $this->elementType,
'criteria' => [
'id' => $targetElementIds->map(fn(int $id) => "not $id")->all(),
],
'single' => true,
]) .
Html::hiddenInput('elementType', $this->elementType) .
$targetElementIds->map(fn(int $id) => Html::hiddenInput('elementIds[]', (string)$id))->join('') .
Html::hiddenInput('hardDelete', $this->hardDelete ? '1' : '0')
)
->submitButtonLabel(Craft::t('app', 'Replace'));
}

public function actionReplace(): Response
{
$this->requirePostRequest();
$this->requireAcceptsJson();

$newTargetId = $this->request->getBodyParam('newTargetId');

if (!$newTargetId) {
return $this->asFailure(Craft::t('app', 'No new {type} selected.', [
'type' => $this->elementType::lowerDisplayName(),
]));
}

$oldTargetIds = $this->elements->ids()->all();

$refsQuery = (new Query())
->select(['r.fieldInstanceUid', 'r.sourceId', 'r.sourceSiteId', 'e.type'])
->from(['r' => Plugin::TABLE_REFERENCES])
->innerJoin(['e' => Table::ELEMENTS], '[[e.id]] = [[r.sourceId]]')
->where(['r.targetId' => $oldTargetIds]);

$groupedRefs = [];
$refCount = 0;

foreach (Db::each($refsQuery) as $ref) {
$groupedRefs[$ref['type']][$ref['sourceSiteId']][] = [
'fieldInstanceUid' => $ref['fieldInstanceUid'],
'sourceId' => (int)$ref['sourceId'],
];
$refCount++;
}

foreach ($groupedRefs as $sourceElementType => $typeRefs) {
foreach ($typeRefs as $sourceSiteId => $siteRefs) {
Queue::push(new ReplaceReferences([
'sourceElementType' => $sourceElementType,
'sourceSiteId' => $sourceSiteId,
'targetElementType' => $this->elementType,
'refs' => $siteRefs,
'oldTargetIds' => $oldTargetIds,
'newTargetId' => $newTargetId,
]));
}
}

return $this->asSuccess(Craft::t('ckeditor', '{numReferences, plural, =1{Reference} other{References}} queued to be replaced.', [
'numReferences' => $refCount,
]));
}
}
99 changes: 99 additions & 0 deletions src/deletionblockers/ReferenceDeletionBlocker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

/**
* @link https://craftcms.com/
* @copyright Copyright (c) Pixel & Tonic, Inc.
* @license MIT
*/

namespace craft\ckeditor\deletionblockers;

use Craft;
use craft\base\ElementInterface;
use craft\ckeditor\Plugin;
use craft\db\Query;
use craft\elements\deletionblockers\BaseDeletionBlocker;
use craft\helpers\Html;

/**
* @since 5.6.0
*/
class ReferenceDeletionBlocker extends BaseDeletionBlocker
{
private int $referenceCount;

public function init()
{
$this->referenceCount = (new Query())
->from(Plugin::TABLE_REFERENCES)
->where([
'targetId' => $this->elements->ids()->all(),
])
->count();

parent::init();
}

public function isActive(): bool
{
return $this->referenceCount !== 0;
}

public function getSummary(): string
{
/** @var class-string<ElementInterface> $targetElementType */
$targetElementType = $this->elements->first()::class;

return Craft::t('ckeditor', 'The {numTargets, plural, =1{{targetTypeSingular} is} other{{targetTypePlural} are}} referenced by CKEditor fields in {numReferences, number} other {numReferences, plural, =1{element} other{elements}}.', [
'targetTypeSingular' => $targetElementType::lowerDisplayName(),
'targetTypePlural' => $targetElementType::pluralLowerDisplayName(),
'numReferences' => $this->referenceCount,
'numTargets' => $this->elements->count(),
]);
}

public function getActions(): array
{
/** @var class-string<ElementInterface> $targetElementType */
$targetElementType = $this->elements->first()::class;

return [
[
'icon' => 'swap',
'label' => Craft::t('ckeditor', 'Replace {numReferences, plural, =1{reference} other{references}}', [
'numReferences' => $this->referenceCount,
]),
'callback' => Html::jsWithVars(fn(
$targetElementType,
$targetIds,
$hardDelete,
) => <<<JS
new Craft.CpModal('ckeditor/replace-references/modal', {
params: {
elementType: $targetElementType,
elementIds: $targetIds,
hardDelete: $hardDelete,
},
onSubmit: (ev) => {
resolve(ev.response.data.message);
},
onCancel: () => {
reject();
},
});
JS, [
$targetElementType,
$this->elements->ids()->all(),
$this->hardDelete,
]),
],
[
'icon' => 'xmark',
'label' => Craft::t('ckeditor', 'Ignore {numReferences, plural, =1{reference} other{references}}', [
'numReferences' => $this->referenceCount,
]),
'callback' => 'resolve();',
],
];
}
}
Loading