Commit 90b02f9f authored by Benjamin Franzke's avatar Benjamin Franzke Committed by Oliver Hader
Browse files

[TASK] Implement t3editor as custom web component

Remove jQuery dependency, avoid inline javascript and
encapsulate initialization into a web component.

Also use "codemirror" as import name, as that's
what the npm package name is, and will eventually
allow to make use of TypeScript typings.

Releases: master
Resolves: #93149
Change-Id: Ia85784b21a90e1986ea6ba7a915e032aa7963d92
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/67185

Tested-by: default avatarTYPO3com <noreply@typo3.com>
Tested-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Tested-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
Reviewed-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Reviewed-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
parent 9821608a
......@@ -153,7 +153,14 @@
"selector-pseudo-element-colon-notation": "single",
"selector-pseudo-element-no-unknown": true,
"selector-type-case": "lower",
"selector-type-no-unknown": true,
"selector-type-no-unknown": [
true,
{
ignore: [
"custom-elements"
]
}
],
"shorthand-property-no-redundant-values": true,
"string-no-newline": true,
"unit-case": "lower",
......
......@@ -438,7 +438,7 @@ module.exports = function (grunt) {
{
expand: true,
cwd: '<%= paths.node_modules %>codemirror',
dest: '<%= paths.t3editor %>Public/JavaScript/Contrib/cm',
dest: '<%= paths.t3editor %>Public/JavaScript/Contrib/codemirror',
src: ['**/*', '!**/src/**', '!rollup.config.js']
}
]
......@@ -674,10 +674,10 @@ module.exports = function (grunt) {
{
expand: true,
src: [
'<%= paths.t3editor %>Public/JavaScript/Contrib/cm/**/*.js',
'!<%= paths.t3editor %>Public/JavaScript/Contrib/cm/**/*.min.js'
'<%= paths.t3editor %>Public/JavaScript/Contrib/codemirror/**/*.js',
'!<%= paths.t3editor %>Public/JavaScript/Contrib/codemirror/**/*.min.js'
],
dest: '<%= paths.t3editor %>Public/JavaScript/Contrib/cm',
dest: '<%= paths.t3editor %>Public/JavaScript/Contrib/codemirror',
cwd: '.',
rename: function (dest, src) {
return src;
......
......@@ -12,9 +12,17 @@ $panel-padding-vertical: 3px;
$panel-padding-horizontal: 6px;
$color-matching-bracket: #6ca52b;
.t3editor-wrapper {
typo3-t3editor-codemirror {
border: 1px solid transparent;
textarea {
width: 100%;
}
* + textarea {
display: none;
}
.CodeMirror-fullscreen {
top: $fullscreen-top !important;
}
......
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
import CodeMirror from 'codemirror';
import {LitElement, html, css, customElement, property, internalProperty, CSSResult} from 'lit-element';
import FormEngine = require('TYPO3/CMS/Backend/FormEngine');
import 'TYPO3/CMS/Backend/Element/SpinnerElement'
/**
* Module: TYPO3/CMS/T3editor/Element/CodeMirrorElement
* Renders CodeMirror into FormEngine
*/
@customElement('typo3-t3editor-codemirror')
export class CodeMirrorElement extends LitElement {
@property() mode: string;
@property() label: string;
@property({type: Array}) addons: string[] = [];
@property({type: Object}) options: { [key: string]: any[] } = {};
@internalProperty() loaded: boolean = false;
public static get styles(): CSSResult
{
return css`
:host {
display: block;
position: relative;
}
typo3-backend-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
`;
}
render() {
return html`
<slot></slot>
<slot name="codemirror"></slot>
${this.loaded ? '' : html`<typo3-backend-spinner size="large"></typo3-backend-spinner>`}
`;
}
firstUpdated(): void {
const observerOptions = {
root: document.body
};
let observer = new IntersectionObserver((entries: IntersectionObserverEntry[]): void => {
entries.forEach((entry: IntersectionObserverEntry): void => {
if (entry.intersectionRatio > 0) {
observer.unobserve(entry.target);
if (this.firstElementChild && this.firstElementChild.nodeName.toLowerCase() === 'textarea') {
this.initializeEditor(<HTMLTextAreaElement>this.firstElementChild);
}
}
});
}, observerOptions);
observer.observe(this);
}
private createPanelNode(position: string, label: string): HTMLElement {
const node = document.createElement('div');
node.setAttribute('class', 'CodeMirror-panel CodeMirror-panel-' + position);
node.setAttribute('id', 'panel-' + position);
const span = document.createElement('span');
span.textContent = label;
node.appendChild(span);
return node;
}
private initializeEditor(textarea: HTMLTextAreaElement): void {
const modeParts = this.mode.split('/');
const options = this.options;
// load mode + registered addons
require([this.mode, ...this.addons], (): void => {
const cm = CodeMirror((node: HTMLElement): void => {
const wrapper = document.createElement('div');
wrapper.setAttribute('slot', 'codemirror');
wrapper.appendChild(node);
this.insertBefore(wrapper, textarea);
}, {
value: textarea.value,
extraKeys: {
'Ctrl-F': 'findPersistent',
'Cmd-F': 'findPersistent',
'Ctrl-Alt-F': (codemirror: any): void => {
codemirror.setOption('fullScreen', !codemirror.getOption('fullScreen'));
},
'Ctrl-Space': 'autocomplete',
'Esc': (codemirror: any): void => {
if (codemirror.getOption('fullScreen')) {
codemirror.setOption('fullScreen', false);
}
},
},
fullScreen: false,
lineNumbers: true,
lineWrapping: true,
mode: modeParts[modeParts.length - 1],
});
// set options
Object.keys(options).map((key: string): void => {
cm.setOption(key, options[key]);
});
// Mark form as changed if code editor content has changed
cm.on('change', (): void => {
textarea.value = cm.getValue();
FormEngine.Validation.markFieldAsChanged(textarea);
});
const bottomPanel = this.createPanelNode('bottom', this.label);
cm.addPanel(
bottomPanel,
{
position: 'bottom',
stable: false,
},
);
// cm.addPanel() changes the height of the editor, thus we have to override it here again
if (textarea.getAttribute('rows')) {
const lineHeight = 18;
const paddingBottom = 4;
cm.setSize(null, parseInt(textarea.getAttribute('rows'), 10) * lineHeight + paddingBottom + bottomPanel.getBoundingClientRect().height);
} else {
// Textarea has no "rows" attribute configured, don't limit editor in space
cm.getWrapperElement().style.height = (document.body.getBoundingClientRect().height - cm.getWrapperElement().getBoundingClientRect().top - 80) + 'px';
cm.setOption('viewportMargin', Infinity);
}
this.loaded = true;
});
}
}
......@@ -11,14 +11,14 @@
* The TYPO3 project - inspiring people to share!
*/
import CodeMirror from 'cm/lib/codemirror';
import $ from 'jquery';
import FormEngine = require('TYPO3/CMS/Backend/FormEngine');
import './Element/CodeMirrorElement';
import DocumentService = require('TYPO3/CMS/Core/DocumentService');
/**
* Module: TYPO3/CMS/T3editor/T3editor
* Renders CodeMirror into FormEngine
* @exports TYPO3/CMS/T3editor/T3editor
* @deprecated since v11.1, will be removed in v12
*/
class T3editor {
......@@ -28,20 +28,23 @@ class T3editor {
* @returns {HTMLElement}
*/
public static createPanelNode(position: string, label: string): HTMLElement {
const $panelNode = $('<div />', {
class: 'CodeMirror-panel CodeMirror-panel-' + position,
id: 'panel-' + position,
}).append(
$('<span />').text(label),
);
const node = document.createElement('div');
node.setAttribute('class', 'CodeMirror-panel CodeMirror-panel-' + position);
node.setAttribute('id', 'panel-' + position);
return $panelNode.get(0);
const span = document.createElement('span');
span.textContent = label;
node.appendChild(span);
return node;
}
/**
* The constructor, set the class properties default values
*/
constructor() {
console.warn('TYPO3/CMS/T3editor/T3editor has been marked as deprecated. Please use TYPO3/CMS/T3editor/Element/CodeMirrorElement instead.');
this.initialize();
}
......@@ -49,7 +52,7 @@ class T3editor {
* Initialize the events
*/
public initialize(): void {
$((): void => {
DocumentService.ready().then((): void => {
this.observeEditorCandidates();
});
}
......@@ -58,87 +61,25 @@ class T3editor {
* Initializes CodeMirror on available texteditors
*/
public observeEditorCandidates(): void {
const observerOptions = {
root: document.body
};
let observer = new IntersectionObserver((entries: IntersectionObserverEntry[]): void => {
entries.forEach((entry: IntersectionObserverEntry): void => {
if (entry.intersectionRatio > 0) {
const $target = $(entry.target);
if (!$target.prop('is_t3editor')) {
this.initializeEditor($target);
}
}
})
}, observerOptions);
document.querySelectorAll('textarea.t3editor').forEach((textarea: HTMLTextAreaElement): void => {
observer.observe(textarea);
});
}
private initializeEditor($textarea: JQuery): void {
const config = $textarea.data('codemirror-config');
const modeParts = config.mode.split('/');
const addons = $.merge([modeParts.join('/')], JSON.parse(config.addons));
const options = JSON.parse(config.options);
// load mode + registered addons
require(addons, (): void => {
const cm = CodeMirror.fromTextArea($textarea.get(0), {
extraKeys: {
'Ctrl-F': 'findPersistent',
'Cmd-F': 'findPersistent',
'Ctrl-Alt-F': (codemirror: any): void => {
codemirror.setOption('fullScreen', !codemirror.getOption('fullScreen'));
},
'Ctrl-Space': 'autocomplete',
'Esc': (codemirror: any): void => {
if (codemirror.getOption('fullScreen')) {
codemirror.setOption('fullScreen', false);
}
},
},
fullScreen: false,
lineNumbers: true,
lineWrapping: true,
mode: modeParts[modeParts.length - 1],
});
// set options
$.each(options, (key: string, value: any): void => {
cm.setOption(key, value);
});
// Mark form as changed if code editor content has changed
cm.on('change', (): void => {
FormEngine.Validation.markFieldAsChanged($textarea);
});
const bottomPanel = T3editor.createPanelNode('bottom', config.label);
cm.addPanel(
bottomPanel,
{
position: 'bottom',
stable: false,
},
);
// cm.addPanel() changes the height of the editor, thus we have to override it here again
if ($textarea.attr('rows')) {
const lineHeight = 18;
const paddingBottom = 4;
cm.setSize(null, parseInt($textarea.attr('rows'), 10) * lineHeight + paddingBottom + bottomPanel.getBoundingClientRect().height);
} else {
// Textarea has no "rows" attribute configured, don't limit editor in space
cm.getWrapperElement().style.height = (document.body.getBoundingClientRect().height - cm.getWrapperElement().getBoundingClientRect().top - 80) + 'px';
cm.setOption('viewportMargin', Infinity);
if (textarea.parentElement.tagName.toLowerCase() === 'typo3-t3editor-codemirror') {
return;
}
const editor = document.createElement('typo3-t3editor-codemirror');
const config = JSON.parse(textarea.getAttribute('data-codemirror-config'));
editor.setAttribute('mode', config.mode);
editor.setAttribute('label', config.label);
editor.setAttribute('addons', config.addons);
editor.setAttribute('options', config.options);
this.wrap(textarea, editor);
});
$textarea.prop('is_t3editor', true);
}
private wrap(toWrap: HTMLElement, wrapper: HTMLElement) {
toWrap.parentElement.insertBefore(wrapper, toWrap);
wrapper.appendChild(toWrap);
};
}
// create an instance and return it
......
......@@ -130,7 +130,7 @@ interface Window {
* Needed type declarations for provided libs
*/
declare module 'muuri';
declare module 'cm/lib/codemirror';
declare module 'codemirror';
declare module 'flatpickr/flatpickr.min';
declare module 'moment';
declare module 'Sortable';
......
.. include:: ../../Includes.txt
==============================================================================
Deprecation: #93149 - T3Editor JavaScript module replaced by CodeMirrorElement
==============================================================================
See :issue:`93149`
Description
===========
The T3Editor - that offers code editing capabilities for TCA
:php:`renderType=t3editor` fields - has been refactored into a custom HTML
element :html:`<typo3-t3editor-codemirror>`.
The element is provided by the new JavaScript module
js:`TYPO3/CMS/T3editor/Element/CodeMirrorElement`.
Impact
======
Using :html:`<textarea class="t3editor">..</textarea>` will work as before.
The new custom element will automatically be used, but a deprecating warning
will be logged to the browser console.
Affected Installations
======================
TYPO3 installations that use the T3Editor library in custom extensions, which
is very unlikely.
Migration
=========
Use the new :js:`TYPO3/CMS/T3editor/Element/CodeMirrorElement` module and adapt
your markup to read:
.. block:: html
<typo3-t3editor-codemirror mode="..." addons="[..]" options="{..}">
<textarea name="foo">..</textarea>
</typo3-t3editor-codemirror>
Please make sure to drop the t3editor class from the textarea.
.. index:: Backend, JavaScript, NotScanned, ext:backend
......@@ -87,10 +87,10 @@ class T3editorElement extends AbstractFormElement
public function render(): array
{
$this->resultArray = $this->initializeResultArray();
$this->resultArray['stylesheetFiles'][] = 'EXT:t3editor/Resources/Public/JavaScript/Contrib/cm/lib/codemirror.css';
$this->resultArray['stylesheetFiles'][] = 'EXT:t3editor/Resources/Public/JavaScript/Contrib/codemirror/lib/codemirror.css';
$this->resultArray['stylesheetFiles'][] = 'EXT:t3editor/Resources/Public/Css/t3editor.css';
$this->resultArray['requireJsModules'][] = [
'TYPO3/CMS/T3editor/T3editor' => 'function(T3editor) {T3editor.observeEditorCandidates()}'
'TYPO3/CMS/T3editor/Element/CodeMirrorElement' => null
];
// Compile and register t3editor configuration
......@@ -111,15 +111,12 @@ class T3editorElement extends AbstractFormElement
}
$attributes['wrap'] = 'off';
$attributes['style'] = 'width:100%;';
$attributes['onchange'] = GeneralUtility::quoteJSvalue($parameterArray['fieldChangeFunc']['TBE_EDITOR_fieldChanged']);
$attributeString = GeneralUtility::implodeAttributes($attributes, true);
$editorHtml = $this->getHTMLCodeForEditor(
$parameterArray['itemFormElName'],
'text-monospace enable-tab',
$parameterArray['itemFormElValue'],
$attributeString,
$attributes,
$this->data['tableName'] . ' > ' . $this->data['fieldName'],
[
'target' => 0,
......@@ -145,9 +142,7 @@ class T3editorElement extends AbstractFormElement
$html[] = '<div class="form-control-wrap">';
$html[] = '<div class="form-wizards-wrap">';
$html[] = '<div class="form-wizards-element">';
$html[] = '<div class="t3editor-wrapper">';
$html[] = $editorHtml;
$html[] = '</div>';
$html[] = $editorHtml;
$html[] = '</div>';
if (!empty($fieldControlHtml)) {
$html[] = '<div class="form-wizards-items-aside">';
......@@ -176,7 +171,7 @@ class T3editorElement extends AbstractFormElement
* @param string $name Name attribute of HTML tag
* @param string $class Class attribute of HTML tag
* @param string $content Content of the editor
* @param string $additionalParams Any additional editor parameters
* @param array $attributes Any additional editor parameters
* @param string $label Codemirror panel label
* @param array $hiddenfields
*
......@@ -187,16 +182,15 @@ class T3editorElement extends AbstractFormElement
string $name,
string $class = '',
string $content = '',
string $additionalParams = '',
array $attributes = [],
string $label = '',
array $hiddenfields = []
): string {
$code = [];
$attributes = [];
$mode = $this->getMode();
$registeredAddons = AddonRegistry::getInstance()->getForMode($mode->getFormatCode());
$attributes['class'] = $class . ' t3editor';
$attributes['class'] = $class;
$attributes['id'] = 't3editor_' . md5($name);
$attributes['name'] = $name;
......@@ -205,27 +199,23 @@ class T3editorElement extends AbstractFormElement
foreach ($registeredAddons as $addon) {
$addons[] = $addon->getIdentifier();
}
$attributes['data-codemirror-config'] = json_encode([
$codeMirrorConfig = [
'mode' => $mode->getIdentifier(),
'label' => $label,
'addons' => json_encode($addons),
'options' => json_encode($settings)
]);
$attributesString = '';
foreach ($attributes as $attribute => $value) {
$attributesString .= $attribute . '="' . htmlspecialchars((string)$value) . '" ';
}
$attributesString .= $additionalParams;
'addons' => GeneralUtility::jsonEncodeForHtmlAttribute($addons, false),
'options' => GeneralUtility::jsonEncodeForHtmlAttribute($settings, false),
];
$code[] = '<textarea ' . $attributesString . '>' . htmlspecialchars($content) . '</textarea>';
$code[] = '<typo3-t3editor-codemirror ' . GeneralUtility::implodeAttributes($codeMirrorConfig, true) . '>';
$code[] = '<textarea ' . GeneralUtility::implodeAttributes($attributes, true) . '>' . htmlspecialchars($content) . '</textarea>';
if (!empty($hiddenfields)) {
foreach ($hiddenfields as $attributeName => $value) {
$code[] = '<input type="hidden" name="' . htmlspecialchars((string)$attributeName) . '" value="' . htmlspecialchars((string)$value) . '" />';
}
}
$code[] = '</typo3-t3editor-codemirror>';
return implode(LF, $code);
}
......
......@@ -38,13 +38,19 @@ final class PageRendererRenderPreProcess
$pageRenderer->addRequireJsConfiguration([
'packages' => [
[
'name' => 'cm',
'name' => 'codemirror',
'location' => PathUtility::getAbsoluteWebPath(
GeneralUtility::getFileAbsFileName('EXT:t3editor/Resources/Public/JavaScript/Contrib/cm')
GeneralUtility::getFileAbsFileName('EXT:t3editor/Resources/Public/JavaScript/Contrib/codemirror')
),
'main' => 'lib/codemirror',
],
],
// @deprecated since v11.1, will be removed in v12
'map' => [
'*' => [
'cm' => 'codemirror',
]
]
],
]);
}
}
......
......@@ -5,87 +5,87 @@
*/
return [
'dialog/dialog' => [
'module' => 'cm/addon/dialog/dialog',
'module' => 'codemirror/addon/dialog/dialog',
'cssFiles' => [
'EXT:t3editor/Resources/Public/JavaScript/Contrib/cm/addon/dialog/dialog.css',
'EXT:t3editor/Resources/Public/JavaScript/Contrib/codemirror/addon/dialog/dialog.css',
],
],
'display/fullscreen' => [