Skip to content

Commit

Permalink
Merge pull request #294 from craftcms/feature/pt-1892-image-editor
Browse files Browse the repository at this point in the history
image editor plugin
  • Loading branch information
brandonkelly authored Oct 19, 2024
2 parents 1f625bc + 48dd22f commit 6010bb7
Show file tree
Hide file tree
Showing 10 changed files with 330 additions and 2 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- Image toolbars now include an “Edit Image” button. ([#253](https://github.com/craftcms/ckeditor/issues/253))
- The `ckeditor/convert/redactor` command now ensures that it’s being run interactively.
- CKEditor container divs now have `data-config` attributes, set to the CKEditor config’s handle. ([#284](https://github.com/craftcms/ckeditor/issues/284))
- Fixed a bug where page breaks were being lost.
Expand Down
2 changes: 2 additions & 0 deletions src/Field.php
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,8 @@ protected function inputHtml(mixed $value, ElementInterface $element = null): st
...(!empty($transforms) ? ['transformImage', '|'] : []),
'toggleImageCaption',
'imageTextAlternative',
'|',
'imageEditor',
],
],
'assetSources' => $this->_assetSources(),
Expand Down
38 changes: 38 additions & 0 deletions src/controllers/CkeditorController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

namespace craft\ckeditor\controllers;

use Craft;
use craft\elements\Asset;
use craft\web\Controller;
use yii\web\NotFoundHttpException;
Expand Down Expand Up @@ -50,4 +51,41 @@ public function actionImageUrl(): Response
'height' => $asset->getHeight($transform),
]);
}

/**
* Returns image permissions.
*
* @return Response
* @throws NotFoundHttpException
* @throws \yii\base\InvalidConfigException
* @throws \yii\web\BadRequestHttpException
*/
public function actionImagePermissions(): Response
{
$assetId = $this->request->getRequiredBodyParam('assetId');

$asset = Asset::find()
->id($assetId)
->kind('image')
->one();

if (!$asset) {
throw new NotFoundHttpException('Image not found');
}

$userSession = Craft::$app->getUser();
$volume = $asset->getVolume();

$previewable = Craft::$app->getAssets()->getAssetPreviewHandler($asset) !== null;
$editable = (
$asset->getSupportsImageEditor() &&
$userSession->checkPermission("editImages:$volume->uid") &&
($userSession->getId() == $asset->uploaderId || $userSession->checkPermission("editPeerImages:$volume->uid"))
);

return $this->asJson([
'previewable' => $previewable,
'editable' => $editable,
]);
}
}
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.

3 changes: 3 additions & 0 deletions src/web/assets/ckeditor/src/ckeditor5-craftcms.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {WordCount} from '@ckeditor/ckeditor5-word-count';
import {default as CraftImageInsertUI} from './image/imageinsert/imageinsertui';
import {default as CraftLinkUI} from './link/linkui';
import ImageTransform from './image/imagetransform';
import ImageEditor from './image/imageeditor';
import {TextPartLanguage} from '@ckeditor/ckeditor5-language';
import {Anchor} from '@northernco/ckeditor5-anchor-drupal';

Expand Down Expand Up @@ -113,6 +114,7 @@ const allPlugins = [
WordCount,
CraftImageInsertUI,
ImageTransform,
ImageEditor,
CraftLinkUI,
];

Expand Down Expand Up @@ -180,6 +182,7 @@ const pluginButtonMap = [
'ImageStyle',
'ImageToolbar',
'ImageTransform',
'ImageEditor',
'LinkImage',
],
buttons: ['insertImage'],
Expand Down
19 changes: 19 additions & 0 deletions src/web/assets/ckeditor/src/image/imageeditor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* @link https://craftcms.com/
* @copyright Copyright (c) Pixel & Tonic, Inc.
* @license GPL-3.0-or-later
*/

import {Plugin} from 'ckeditor5/src/core';
import ImageEditorEditing from './imageeditor/imageeditorediting';
import ImageEditorUI from './imageeditor/imageeditorui';

export default class ImageEditor extends Plugin {
static get requires() {
return [ImageEditorEditing, ImageEditorUI];
}

static get pluginName() {
return 'ImageEditor';
}
}
184 changes: 184 additions & 0 deletions src/web/assets/ckeditor/src/image/imageeditor/imageeditorcommand.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/**
* @link https://craftcms.com/
* @copyright Copyright (c) Pixel & Tonic, Inc.
* @license GPL-3.0-or-later
*/

import {Command} from 'ckeditor5/src/core';

/**
* The transform image command.
*/
export default class ImageEditorCommand extends Command {
refresh() {
const element = this._element();
const srcInfo = this._srcInfo(element);
this.isEnabled = !!srcInfo;

// if the command is still enabled - check permissions too
if (this.isEnabled) {
let data = {
assetId: srcInfo.assetId,
};

Craft.sendActionRequest('POST', 'ckeditor/ckeditor/image-permissions', {
data,
}).then((response) => {
if (response.data.editable === false) {
this.isEnabled = false;
}
});
}
}

/**
* Returns the selected image element.
*/
_element() {
const editor = this.editor;
const imageUtils = editor.plugins.get('ImageUtils');
return imageUtils.getClosestSelectedImageElement(
editor.model.document.selection,
);
}

/**
* Checks if element has a src attribute and at least an asset id.
* Returns null if not and array containing src, baseSrc, asset id and transform (if used).
*
* @param element
* @returns {{transform: *, src: *, assetId: *, baseSrc: *}|null}
* @private
*/
_srcInfo(element) {
if (!element || !element.hasAttribute('src')) {
return null;
}

const src = element.getAttribute('src');
const match = src.match(
/(.*)#asset:(\d+)(?::transform:([a-zA-Z][a-zA-Z0-9_]*))?/,
);
if (!match) {
return null;
}

return {
src,
baseSrc: match[1],
assetId: match[2],
transform: match[3],
};
}

/**
* Executes the command.
*
* @fires execute
*/
execute() {
const editor = this.editor;
const model = editor.model;
const element = this._element();
const srcInfo = this._srcInfo(element);

if (srcInfo) {
let settings = {
allowSavingAsNew: false, // todo: we might want to change that, but currently we're doing the same functionality as in Redactor
onSave: (data) => {
this._reloadImage(srcInfo.assetId, data);
},
allowDegreeFractions: Craft.isImagick,
};

new Craft.AssetImageEditor(srcInfo.assetId, settings);
}
}

/**
* Reloads the matching images after save was triggered from the Image Editor.
*
* @param data
*/
_reloadImage(assetId, data) {
let editor = this.editor;
let model = editor.model;

// get all images that are Craft Assets
let images = this._getAllImageAssets();

// go through them all and get the ones with matching asset id
images.forEach((image) => {
// if it's the image we just edited
if (image.srcInfo.assetId == assetId) {
// if it doesn't have a transform
if (!image.srcInfo.transform) {
// get new src
let newSrc =
image.srcInfo.baseSrc +
'?' +
new Date().getTime() +
'#asset:' +
image.srcInfo.assetId;

// and replace
model.change((writer) => {
writer.setAttribute('src', newSrc, image.element);
});
} else {
let data = {
assetId: image.srcInfo.assetId,
handle: image.srcInfo.transform,
};

// get the new url
Craft.sendActionRequest('POST', 'assets/generate-transform', {
data,
}).then((response) => {
// get new src
let newSrc =
response.data.url +
'?' +
new Date().getTime() +
'#asset:' +
image.srcInfo.assetId +
':transform:' +
image.srcInfo.transform;

// and replace
model.change((writer) => {
writer.setAttribute('src', newSrc, image.element);
});
});
}
}
});
}

/**
* Returns all images present in the editor that are Craft Assets.
*
* @returns {*[]}
* @private
*/
_getAllImageAssets() {
const editor = this.editor;
const model = editor.model;
const range = model.createRangeIn(model.document.getRoot());

let images = [];
for (const value of range.getWalker({ignoreElementEnd: true})) {
if (value.item.is('element') && value.item.name === 'imageBlock') {
let srcInfo = this._srcInfo(value.item);
if (srcInfo) {
images.push({
element: value.item,
srcInfo: srcInfo,
});
}
}
}

return images;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* @link https://craftcms.com/
* @copyright Copyright (c) Pixel & Tonic, Inc.
* @license GPL-3.0-or-later
*/

import {Plugin} from 'ckeditor5/src/core';
import ImageUtils from '@ckeditor/ckeditor5-image/src/imageutils';
import ImageEditorCommand from './imageeditorcommand';

export default class ImageEditorEditing extends Plugin {
static get requires() {
return [ImageUtils];
}

static get pluginName() {
return 'ImageEditorEditing';
}

init() {
const editor = this.editor;
const imageEditorCommand = new ImageEditorCommand(editor);
editor.commands.add('imageEditor', imageEditorCommand);
}
}
56 changes: 56 additions & 0 deletions src/web/assets/ckeditor/src/image/imageeditor/imageeditorui.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* @link https://craftcms.com/
* @copyright Copyright (c) Pixel & Tonic, Inc.
* @license GPL-3.0-or-later
*/

import {Plugin, icons} from 'ckeditor5/src/core';
import {ButtonView} from 'ckeditor5/src/ui';
import ImageEditorEditing from './imageeditorediting';

export default class ImageEditorUI extends Plugin {
static get requires() {
return [ImageEditorEditing];
}

static get pluginName() {
return 'ImageEditorUI';
}

init() {
const editor = this.editor;
const command = editor.commands.get('imageEditor');
this.bind('isEnabled').to(command);
this._registerImageEditorButton();
}

/**
* A helper function that creates a button component for the plugin that triggers launch of the Image Editor.
*/
_registerImageEditorButton() {
const editor = this.editor;
const t = editor.t;
const command = editor.commands.get('imageEditor');

const componentCreator = () => {
const buttonView = new ButtonView();

buttonView.set({
label: t('Edit Image'),
withText: true,
});

buttonView.bind('isEnabled').to(command);

// Execute command when a button is clicked.
this.listenTo(buttonView, 'execute', (evt) => {
editor.execute('imageEditor');
editor.editing.view.focus();
});

return buttonView;
};

editor.ui.componentFactory.add('imageEditor', componentCreator);
}
}

0 comments on commit 6010bb7

Please sign in to comment.