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

image editor plugin #294

Merged
merged 6 commits into from
Oct 19, 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
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);
}
}