Skip to content

Commit

Permalink
[BUGFIX] Enable configuration passthrough for custom CKEditor5 plugins
Browse files Browse the repository at this point in the history
Use JavaScript object destructuring to "remove" all options from the
configuration object, that are consumed by our CKEditor5 wrapper.
Also cleanup RTE resource and config handling to not write
unused options into the configuration array and to avoid resolving
legacy (CKEditor4) resource paths which are dropped anyway.

As a drive-by, dots are now substituted with '_' in RTE field IDs to
avoid the following hassle:
Dots are interpreted as CSS classes when the ID value is used
in combination with a number sign (#) to create a CSS selector
for the respective field ID. That means the selector will not match.
The class selector additionally becomes invalid once there is a digit
after the dot, as CSS classes need to start with strings.
(Example: EXT:styleguide in_flex » tab » rte.2).

Resolves: #100784
Resolves: #101437
Releases: main, 12.4
Change-Id: I076b838c03588ad6eb8ad075a9df58501f146376
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/81406
Tested-by: Benjamin Franzke <ben@bnf.dev>
Tested-by: core-ci <typo3@b13.com>
Tested-by: Oliver Hader <oliver.hader@typo3.org>
Reviewed-by: Oliver Hader <oliver.hader@typo3.org>
Reviewed-by: Benjamin Franzke <ben@bnf.dev>
Reviewed-by: Benni Mack <benni@typo3.org>
Tested-by: Benni Mack <benni@typo3.org>
Reviewed-by: Andreas Kienast <a.fernandez@scripting-base.de>
  • Loading branch information
bnf committed Nov 4, 2023
1 parent 5e98c00 commit 34384bb
Show file tree
Hide file tree
Showing 6 changed files with 137 additions and 147 deletions.
222 changes: 115 additions & 107 deletions Build/Sources/TypeScript/rte_ckeditor/ckeditor5.ts
Expand Up @@ -3,37 +3,25 @@ import { customElement, property, query } from 'lit/decorators';
import AjaxRequest from '@typo3/core/ajax/ajax-request';
import { prefixAndRebaseCss } from '@typo3/rte-ckeditor/css-prefixer';
import { ClassicEditor } from '@ckeditor/ckeditor5-editor-classic';
import type { WordCount } from '@ckeditor/ckeditor5-word-count';
import type { Editor, EditorConfig, PluginConstructor } from '@ckeditor/ckeditor5-core';
import type { WordCount, WordCountConfig } from '@ckeditor/ckeditor5-word-count';
import type { SourceEditing } from '@ckeditor/ckeditor5-source-editing';
import type { Editor, PluginConstructor } from '@ckeditor/ckeditor5-core';
import type { GeneralHtmlSupportConfig } from '@ckeditor/ckeditor5-html-support';

type PluginModuleDescriptor = {
module: string,
exports: string[],
};

interface CKEditor5Config {
type CKEditor5Config = Omit<EditorConfig, 'toolbar'> & {
// in TYPO3 always `items` property is used, skipping `string[]`
toolbar?: { items: string[], shouldNotGroupWhenFull?: boolean };
extraPlugins?: string[];
removePlugins?: string[];
importModules?: Array<string|PluginModuleDescriptor>;
removeImportModules?: Array<string|PluginModuleDescriptor>;
contentsCss?: string[];
style?: any;
heading?: any;
alignment?: any;
width?: any;
height?: any;
width?: string|number;
height?: string|number;
readOnly?: boolean;
language?: any;
table?: any;
ui?: any;
htmlSupport?: GeneralHtmlSupportConfig;

wordCount?: any;
typo3link?: any;
debug?: boolean;
}

Expand All @@ -44,7 +32,7 @@ interface FormEngineConfig {
id?: string;
name?: string;
value?: string;
validationRules?: any;
validationRules?: string;
}

const defaultPlugins: PluginModuleDescriptor[] = [
Expand Down Expand Up @@ -109,10 +97,101 @@ export class CKEditor5Element extends LitElement {
throw new Error('No rich-text content target found.');
}

const removeImportModules: Array<PluginModuleDescriptor> = normalizeImportModules(this.options.removeImportModules || []);
const {
// options handled by this wrapper
importModules,
removeImportModules,
width,
height,
readOnly,
debug,

// options forwarded to CKEditor5
toolbar,
placeholder,
htmlSupport,
wordCount,
typo3link,
removePlugins,
...otherOptions
} = this.options;

if ('extraPlugins' in otherOptions) {
// Drop CKEditor4 style extraPlugins which we do not support for CKEditor5
// as this string-based list of plugin names works only for bundled plugins.
// `config.importModules` is used for CKEditor5 instead
delete otherOptions.extraPlugins;
}
if ('contentsCss' in otherOptions) {
// Consumed in connectedCallback
delete otherOptions.contentsCss;
}

const plugins = await this.resolvePlugins(defaultPlugins, importModules, removeImportModules);

const config: EditorConfig = {
...otherOptions,
// link.defaultProtocol: 'https://'
toolbar,
plugins,
placeholder,
wordCount,
typo3link: typo3link || null,
removePlugins: removePlugins || [],
};

if (htmlSupport !== undefined) {
config.htmlSupport = convertPseudoRegExp(htmlSupport) as GeneralHtmlSupportConfig;
}

ClassicEditor
.create(this.target, config)
.then((editor: ClassicEditor) => {
this.applyEditableElementStyles(editor, width, height);
this.handleWordCountPlugin(editor, wordCount);
this.applyReadOnly(editor, readOnly);
if (editor.plugins.has('SourceEditing')) {
const sourceEditingPlugin = editor.plugins.get('SourceEditing') as SourceEditing;
editor.model.document.on('change:data', (): void => {
if (!sourceEditingPlugin.isSourceEditingMode) {
editor.updateSourceElement()
}
this.target.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }));
});
}

if (debug) {
import('@ckeditor/ckeditor5-inspector').then(({ default: CKEditorInspector }) => CKEditorInspector.attach(editor, { isCollapsed: true }));
}
});
}

protected createRenderRoot(): HTMLElement | ShadowRoot {
// const renderRoot = this.attachShadow({mode: 'open'});
return this;
}

protected render(): TemplateResult {
return html`
<textarea
id="${this.formEngine.id}"
name="${this.formEngine.name}"
class="form-control"
rows="18"
data-formengine-validation-rules="${this.formEngine.validationRules}"
>${this.formEngine.value}</textarea>
`;
}

private async resolvePlugins(
defaultPlugins: Array<PluginModuleDescriptor>,
importModulesOption: Array<string|PluginModuleDescriptor>|undefined,
removeImportModulesOption: Array<string|PluginModuleDescriptor>|undefined
): Promise<Array<PluginConstructor<Editor>>> {
const removeImportModules: Array<PluginModuleDescriptor> = normalizeImportModules(removeImportModulesOption || []);
const importModules: Array<PluginModuleDescriptor> = normalizeImportModules([
...defaultPlugins,
...(this.options.importModules || []),
...(importModulesOption || []),
]).map((moduleDescriptor: PluginModuleDescriptor) => {
const { module } = moduleDescriptor;
let { exports } = moduleDescriptor;
Expand Down Expand Up @@ -160,81 +239,8 @@ export class CKEditor5Element extends LitElement {
.flat(1);

// plugins, without those that have been overridden
const plugins: Array<PluginConstructor<Editor>> = declaredPlugins
return declaredPlugins
.filter(plugin => !overriddenPlugins.includes(plugin as PluginConstructor<Editor>));

const toolbar = this.options.toolbar;

const config = {
// link.defaultProtocol: 'https://'
// @todo use complete `config` later - currently step-by-step only
toolbar,
plugins,
typo3link: this.options.typo3link || null,
removePlugins: this.options.removePlugins || [],
} as any;
if (this.options.language) {
config.language = this.options.language;
}
if (this.options.style) {
config.style = this.options.style;
}
if (this.options.wordCount) {
config.wordCount = this.options.wordCount;
}
if (this.options.table) {
config.table = this.options.table;
}
if (this.options.heading) {
config.heading = this.options.heading;
}
if (this.options.alignment) {
config.alignment = this.options.alignment;
}
if (this.options.ui) {
config.ui = this.options.ui;
}
if (this.options.htmlSupport) {
config.htmlSupport = convertPseudoRegExp(this.options.htmlSupport) as GeneralHtmlSupportConfig;
}

ClassicEditor
.create(this.target, config)
.then((editor: ClassicEditor) => {
this.applyEditableElementStyles(editor);
this.handleWordCountPlugin(editor);
this.applyReadOnly(editor);
if (editor.plugins.has('SourceEditing')) {
const sourceEditingPlugin = editor.plugins.get('SourceEditing') as SourceEditing;
editor.model.document.on('change:data', (): void => {
if (!sourceEditingPlugin.isSourceEditingMode) {
editor.updateSourceElement()
}
this.target.dispatchEvent(new Event('change', { bubbles: true, cancelable: true }));
});
}

if (this.options.debug) {
import('@ckeditor/ckeditor5-inspector').then(({ default: CKEditorInspector }) => CKEditorInspector.attach(editor, { isCollapsed: true }));
}
});
}

protected createRenderRoot(): HTMLElement | ShadowRoot {
// const renderRoot = this.attachShadow({mode: 'open'});
return this;
}

protected render(): TemplateResult {
return html`
<textarea
id="${this.formEngine.id}"
name="${this.formEngine.name}"
class="form-control"
rows="18"
data-formengine-validation-rules="${this.formEngine.validationRules}"
>${this.formEngine.value}</textarea>
`;
}

private async prefixAndLoadContentsCss(url: string, fieldId: string): Promise<void> {
Expand All @@ -258,19 +264,22 @@ export class CKEditor5Element extends LitElement {
document.adoptedStyleSheets = [...document.adoptedStyleSheets, styleSheet];
}

private applyEditableElementStyles(editor: Editor): void {
private applyEditableElementStyles(editor: Editor, width: string|number|undefined, height: string|number|undefined): void {
const view = editor.editing.view;
const styles: Record<string, any> = {
'min-height': this.options.height,
'min-width': this.options.width,
const styles: Record<string, string|number|undefined> = {
'min-height': height,
'min-width': width,
};
Object.keys(styles).forEach((key) => {
let assignment: any = styles[key];
if (!assignment) {
const _assignment: string|number = styles[key];
if (!_assignment) {
return;
}
if (isFinite(assignment) && !Number.isNaN(parseFloat(assignment))) {
assignment += 'px';
let assignment: string;
if (typeof _assignment === 'number' || !Number.isNaN(Number(assignment))) {
assignment = `${_assignment}px`;
} else {
assignment = _assignment
}
view.change((writer) => {
writer.setStyle(key, assignment, view.document.getRoot());
Expand All @@ -281,19 +290,18 @@ export class CKEditor5Element extends LitElement {
/**
* see https://ckeditor.com/docs/ckeditor5/latest/features/word-count.html
*/
private handleWordCountPlugin(editor: Editor): void {
if (editor.plugins.has('WordCount') && (this.options?.wordCount?.displayWords || this.options?.wordCount?.displayCharacters)) {
private handleWordCountPlugin(editor: Editor, wordCount: WordCountConfig|undefined): void {
if (editor.plugins.has('WordCount') && (wordCount?.displayWords || wordCount?.displayCharacters)) {
const wordCountPlugin = editor.plugins.get('WordCount') as WordCount;
this.renderRoot.appendChild(wordCountPlugin.wordCountContainer);
}
}

/**
* see https://ckeditor.com/docs/ckeditor5/latest/features/read-only.html
* does not work with types yet. so the editor is added with "any".
*/
private applyReadOnly(editor: any): void {
if (this.options.readOnly) {
private applyReadOnly(editor: Editor, readOnly: boolean): void {
if (readOnly) {
editor.enableReadOnlyMode('typo3-lock');
}
}
Expand Down
12 changes: 11 additions & 1 deletion Build/Sources/TypeScript/rte_ckeditor/plugin/typo3-link.ts
Expand Up @@ -27,6 +27,10 @@ export function removeLinkPrefix(attribute: string): string {
return attribute;
}

export interface Typo3LinkConfig {
routeUrl: string;
}

export interface Typo3LinkDict {
attrs?: {
linkTitle?: string;
Expand Down Expand Up @@ -689,7 +693,7 @@ export class Typo3LinkUI extends Core.Plugin {
'Link',
this.makeUrlFromModulePath(
editor,
(editor.config.get('typo3link') as any)?.routeUrl,
editor.config.get('typo3link')?.routeUrl,
additionalParameters
));
}
Expand Down Expand Up @@ -728,5 +732,11 @@ export class Typo3Link extends Core.Plugin {
static readonly overrides?: Array<typeof Core.Plugin> = [Link.Link];
}

declare module '@ckeditor/ckeditor5-core' {
interface EditorConfig {
typo3link?: Typo3LinkConfig;
}
}

// Provided for backwards compatibility
export default Typo3Link;
3 changes: 0 additions & 3 deletions typo3/sysext/core/Configuration/RTE/SysNews.yaml
Expand Up @@ -20,6 +20,3 @@ editor:
- Style
- Underline
- Strike

extraPlugins:
- autolink

0 comments on commit 34384bb

Please sign in to comment.