diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml index ab9c2cc..ee5435e 100644 --- a/Configuration/Settings.yaml +++ b/Configuration/Settings.yaml @@ -4,4 +4,7 @@ Neos: resources: javascript: 'Breadlesscode.SimpleEditorExtend:UiPlugin': - resource: resource://Breadlesscode.SimpleEditorExtend/Public/UiPlugin/Plugin.js \ No newline at end of file + resource: resource://Breadlesscode.SimpleEditorExtend/Public/UiPlugin/Plugin.js + stylesheets: + 'Breadlesscode.SimpleEditorExtend:UiPlugin': + resource: resource://Breadlesscode.SimpleEditorExtend/Public/UiPlugin/Plugin.css diff --git a/README.md b/README.md index fc4146c..50cf961 100644 --- a/README.md +++ b/README.md @@ -6,22 +6,28 @@ [](https://github.com/breadlesscode/neos-simple-editor-extend/stargazers) [](https://github.com/breadlesscode/neos-simple-editor-extend/subscription) -This is a small plugin to simply add some buttons to the Neos CMS CKEditor, without writing any JavaScript code. You only need to compose a YAML-File. +This is a small plugin to simply add some buttons and dropdowns for formatting text to the Neos CMS CKEditor, without writing any JavaScript code. +You only need to compose a YAML-File. ## Installation -Most of the time you have to make small adjustments to a package (e.g., the configuration in Settings.yaml). Because of that, it is important to add the corresponding package to the composer from your theme package. Mostly this is the site package located under Packages/Sites/. To install it correctly go to your theme package (e.g.Packages/Sites/Foo.Bar) and run following command: +Most of the time you have to make small adjustments to a package (e.g., the configuration in `Settings.yaml`). +Because of that, it is important to add the corresponding package to the composer from your theme package. +Mostly this is the site package located under `Packages/Sites/`. +To install it correctly go to your theme package (e.g. `Packages/Sites/Foo.Bar`) and run following command: ```bash composer require breadlesscode/neos-simple-editor-extend --no-update ``` -The --no-update command prevent the automatic update of the dependencies. After the package was added to your theme composer.json, go back to the root of the Neos installation and run composer update. Your desired package is now installed correctly. +The --no-update command prevent the automatic update of the dependencies. +After the package was added to your theme `composer.json`, go back to the root of the Neos installation and run composer update. +Your desired package is now installed correctly. ## Demo  -## Example configuration +## Example configuration for applying a single style via a button ```yaml Neos: @@ -65,5 +71,62 @@ Now you can use your new formattings like this: 'Test.Test:MyCustomSpan2': true ``` +## Example configuration for applying one or more style via a dropdown + +```yaml +Neos: + Neos: + Ui: + frontendConfiguration: + 'Breadlesscode.SimpleEditorExtend:Dropdowns': + 'Breadlesscode.SimpleEditorExtend:Dropdowns.Colors': + extensionName: 'colorAndSizeDropdown' + icon: 'tint' + tooltip: 'Mark the text in different colors and sizes' + position: 'before strong' + formatting: + attributes: + color: + label: 'Color' + placeholder: 'Choose color' + placeholderIcon: 'tint' + options: + - label: 'None' + icon: 'eraser' + model: '' + class: '' + - label: 'Gelb' + model: 'yellow' + class: 'font-color--primary' + - label: 'Blau' + model: 'blue' + class: 'font-color--secondary' + size: + label: 'Size' + placeholder: 'Choose size' + placeholderIcon: 'arrows-alt-v' + options: + - label: 'None' + icon: 'eraser' + model: '' + class: '' + - label: 'Very small' + model: 'xxxsmall' + class: 'font-size--xxxsmall' +``` + +Now you can use your new formattings like this: + +```yaml +'Neos.NodeTypes.BaseMixins:TextMixin': + properties: + text: + ui: + inline: + editorOptions: + formatting: + 'Breadlesscode.SimpleEditorExtend:Dropdowns.Colors': true +``` + ## License The MIT License (MIT). Please see [License File](LICENSE) for more information. diff --git a/Resources/Private/UiPlugin/package.json b/Resources/Private/UiPlugin/package.json index 0322ff5..56de8a3 100644 --- a/Resources/Private/UiPlugin/package.json +++ b/Resources/Private/UiPlugin/package.json @@ -6,7 +6,9 @@ "watch": "neos-react-scripts watch" }, "devDependencies": { - "@neos-project/neos-ui-extensibility": "*" + "@neos-project/neos-ui-extensibility": "*", + "lodash.merge": "^4.6.2", + "lodash.omit": "^4.5.0" }, "neos": { "buildTargetDirectory": "../../Public/UiPlugin" diff --git a/Resources/Private/UiPlugin/src/CkeditorPluginUtils.js b/Resources/Private/UiPlugin/src/CkeditorPluginUtils.js index 4a9ab63..0e8207c 100644 --- a/Resources/Private/UiPlugin/src/CkeditorPluginUtils.js +++ b/Resources/Private/UiPlugin/src/CkeditorPluginUtils.js @@ -1,7 +1,6 @@ -import Plugin from '@ckeditor/ckeditor5-core/src/plugin'; +import {Plugin} from 'ckeditor5-exports'; import AttributeCommand from '@ckeditor/ckeditor5-basic-styles/src/attributecommand'; import {$add, $get} from 'plow-js'; -import ButtonComponent from './ButtonComponent'; const getCkeditorPlugin = function(extensionName, commandName, formatting) { const attributeName = extensionName + 'Attribute'; @@ -25,7 +24,7 @@ const getCkeditorPlugin = function(extensionName, commandName, formatting) { this.editor.commands.add(commandName, new AttributeCommand(this.editor, attributeName)); } } -} +}; const getCkeditorPluginConfig = function(formattingName, ckeditorPlugin) { return (ckEditorConfiguration, options) => { @@ -41,15 +40,15 @@ const getCkeditorPluginConfig = function(formattingName, ckeditorPlugin) { } }; -const getRichtextToolbarConfig = function(commandName, formattingName, icon, tooltip) { +const getRichtextToolbarConfig = function(commandName, formattingName, icon, tooltip, component, componentConfiguration) { return { commandName: commandName, isActive: $get(commandName), isVisible: $get(['formatting', formattingName]), - component: ButtonComponent(commandName), + component: component(commandName, componentConfiguration), icon: icon, tooltip: tooltip, }; -} +}; -export { getCkeditorPlugin, getCkeditorPluginConfig, getRichtextToolbarConfig}; +export {getCkeditorPlugin, getCkeditorPluginConfig, getRichtextToolbarConfig}; diff --git a/Resources/Private/UiPlugin/src/Commands/SelectFormatCommand.js b/Resources/Private/UiPlugin/src/Commands/SelectFormatCommand.js new file mode 100644 index 0000000..37fc8de --- /dev/null +++ b/Resources/Private/UiPlugin/src/Commands/SelectFormatCommand.js @@ -0,0 +1,85 @@ +import {Command} from 'ckeditor5-exports'; + +export default class SelectFormatCommand extends Command { + attributePrefix = 'textFormat-'; + + constructor(editor, attributeKeys) { + super(editor); + this.attributeKeys = attributeKeys; + } + + execute(options = {}) { + const editor = this.editor; + const model = editor.model; + const document = model.document; + const selection = document.selection; + + editor.model.change((writer) => { + for (const attributeSuffix of Object.keys(options)) { + const attributeName = this.attributePrefix + attributeSuffix; + const selectedClass = options[attributeSuffix]; + const ranges = model.schema.getValidRanges(selection.getRanges(), attributeName); + + if (selection.isCollapsed) { + const position = selection.getFirstPosition(); + + // When selection is inside text with `highlight` attribute. + if (selection.hasAttribute(attributeName)) { + // Find the full highlighted range. + const isSameHighlight = value => { + return value.item.hasAttribute(attributeName) && value.item.getAttribute(attributeName) === this.value; + }; + + const highlightStart = position.getLastMatchingPosition(isSameHighlight, {direction: 'backward'}); + const highlightEnd = position.getLastMatchingPosition(isSameHighlight); + const highlightRange = writer.createRange(highlightStart, highlightEnd); + + // Then depending on current value... + if (!selectedClass || this.value === selectedClass) { + // ...remove attribute when passing highlighter different then current or executing "eraser". + writer.removeAttribute(attributeName, highlightRange); + writer.removeSelectionAttribute(attributeName); + } else { + // ...update `highlight` value. + writer.setAttribute(attributeName, selectedClass, highlightRange); + writer.setSelectionAttribute(attributeName, selectedClass); + } + } else if (selectedClass) { + writer.setSelectionAttribute(attributeName, selectedClass); + } + } else { + for (const range of ranges) { + if (selectedClass) { + writer.setAttribute(attributeName, selectedClass, range); + } else { + writer.removeAttribute(attributeName, range); + } + } + } + } + }); + } + + refresh() { + const {model} = this.editor; + const {selection} = model.document; + + this.value = {}; + const attributes = selection.getAttributes(); + + for (let attribute of attributes) { + if (attribute[0].indexOf(this.attributePrefix) === 0) { + const suffix = attribute[0].substr(this.attributePrefix.length); + this.value[suffix] = attribute[1]; + } + } + + this.isEnabled = true; + for (let attributeName of this.attributeKeys) { + if (this.isEnabled) { + return; + } + this.isEnabled = this.isEnabled || model.schema.checkAttributeInSelection(selection, this.attributePrefix + attributeName); + } + } +} diff --git a/Resources/Private/UiPlugin/src/ButtonComponent.js b/Resources/Private/UiPlugin/src/Components/ButtonComponent.js similarity index 100% rename from Resources/Private/UiPlugin/src/ButtonComponent.js rename to Resources/Private/UiPlugin/src/Components/ButtonComponent.js diff --git a/Resources/Private/UiPlugin/src/Components/SelectFormatButton.css b/Resources/Private/UiPlugin/src/Components/SelectFormatButton.css new file mode 100644 index 0000000..9852c39 --- /dev/null +++ b/Resources/Private/UiPlugin/src/Components/SelectFormatButton.css @@ -0,0 +1,12 @@ +.selectFormatButton__flyout { + --dialog-width: 460px; + + background-color: var(--colors-ContrastDarker); + position: fixed; + z-index: var(--zIndex-SecondaryToolbar-LinkIconButtonFlyout); + width: var(--dialog-width); + left: 50%; + margin-left: calc(var(--dialog-width) * -.5); + border: var(--spacing-Half) solid var(--colors-ContrastDarker); +} + \ No newline at end of file diff --git a/Resources/Private/UiPlugin/src/Components/SelectFormatButtonComponent.js b/Resources/Private/UiPlugin/src/Components/SelectFormatButtonComponent.js new file mode 100644 index 0000000..9df5bdd --- /dev/null +++ b/Resources/Private/UiPlugin/src/Components/SelectFormatButtonComponent.js @@ -0,0 +1,118 @@ +import React, {PureComponent} from 'react'; +import {connect} from 'react-redux'; +import PropTypes from 'prop-types'; +import {$get, $transform} from 'plow-js'; +import {selectors} from '@neos-project/neos-ui-redux-store'; +import {IconButton, SelectBox} from '@neos-project/react-ui-components'; +import {neos} from '@neos-project/neos-ui-decorators'; +import {executeCommand} from '@neos-project/neos-ui-ckeditor5-bindings'; +import style from './SelectFormatButton.css'; + +export default (commandName, configuration) => { + @connect($transform({ + formattingUnderCursor: selectors.UI.ContentCanvas.formattingUnderCursor + })) + @neos(globalRegistry => ({ + i18nRegistry: globalRegistry.get('i18n') + })) + class SelectFormatButtonComponent extends PureComponent { + static propTypes = { + i18nRegistry: PropTypes.object, + tooltip: PropTypes.string, + formatOptions: PropTypes.object, + formattingUnderCursor: PropTypes.objectOf(PropTypes.oneOfType([ + PropTypes.number, + PropTypes.bool, + PropTypes.string, + PropTypes.object + ])), + }; + + constructor(props) { + super(props); + this.state = { + isOpen: false, + attributes: props.formattingUnderCursor[commandName], + }; + } + + componentWillReceiveProps = (nextProps) => { + // If the new selection doesn't have any formatting close the dialog + if (!$get(commandName, nextProps.formattingUnderCursor)) { + this.setState({isOpen: false}); + } else { + this.setState({ + isOpen: this.state.isOpen, + attributes: nextProps.formattingUnderCursor[commandName], + }); + } + }; + + handleClick = () => { + this.setState({isOpen: !this.state.isOpen}); + }; + + handleRemoveFormatClick = () => { + this.setState({ + attributes: Object.keys(configuration.formatting.attributes).reduce((map, attribute) => { + map[attribute] = ''; + return map; + }, {}) + }, this.handleSelectionChange); + }; + + handleSelectionChange = () => { + executeCommand(commandName, this.state.attributes); + }; + + handleAttributeChange = (attributeName, value) => { + this.setState({ + attributes: { + ...this.state.attributes, + [attributeName]: value, + } + }, this.handleSelectionChange); + }; + + render() { + const {icon, id, tooltip, i18nRegistry} = this.props; + const {isOpen} = this.state; + const {attributes} = configuration.formatting; + + return ( +