Skip to content

Commit 43b8401

Browse files
Sebobomarkusguenther
authored andcommitted
FEATURE: Add configurable dropdowns for selecting styles
1 parent 9b2b4fa commit 43b8401

17 files changed

+2337
-1827
lines changed

Configuration/Settings.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,7 @@ Neos:
44
resources:
55
javascript:
66
'Breadlesscode.SimpleEditorExtend:UiPlugin':
7-
resource: resource://Breadlesscode.SimpleEditorExtend/Public/UiPlugin/Plugin.js
7+
resource: resource://Breadlesscode.SimpleEditorExtend/Public/UiPlugin/Plugin.js
8+
stylesheets:
9+
'Breadlesscode.SimpleEditorExtend:UiPlugin':
10+
resource: resource://Breadlesscode.SimpleEditorExtend/Public/UiPlugin/Plugin.css

Resources/Private/UiPlugin/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66
"watch": "neos-react-scripts watch"
77
},
88
"devDependencies": {
9-
"@neos-project/neos-ui-extensibility": "*"
9+
"@neos-project/neos-ui-extensibility": "*",
10+
"lodash.merge": "^4.6.2",
11+
"lodash.omit": "^4.5.0"
1012
},
1113
"neos": {
1214
"buildTargetDirectory": "../../Public/UiPlugin"
1315
},
1416
"dependencies": {
15-
"@ckeditor/ckeditor5-basic-styles": "^10.0.0"
17+
"@ckeditor/ckeditor5-basic-styles": "^11.1.1"
1618
}
1719
}

Resources/Private/UiPlugin/src/CkeditorPluginUtils.js

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
2-
import AttributeCommand from '@ckeditor/ckeditor5-basic-styles/src/attributecommand';
1+
import {Plugin} from 'ckeditor5-exports';
2+
// import AttributeCommand from '@ckeditor/ckeditor5-basic-styles/src/attributecommand';
33
import {$add, $get} from 'plow-js';
4-
import ButtonComponent from './ButtonComponent';
54

65
const getCkeditorPlugin = function(extensionName, commandName, formatting) {
76
const attributeName = extensionName + 'Attribute';
@@ -25,7 +24,7 @@ const getCkeditorPlugin = function(extensionName, commandName, formatting) {
2524
this.editor.commands.add(commandName, new AttributeCommand(this.editor, attributeName));
2625
}
2726
}
28-
}
27+
};
2928

3029
const getCkeditorPluginConfig = function(formattingName, ckeditorPlugin) {
3130
return (ckEditorConfiguration, options) => {
@@ -41,15 +40,15 @@ const getCkeditorPluginConfig = function(formattingName, ckeditorPlugin) {
4140
}
4241
};
4342

44-
const getRichtextToolbarConfig = function(commandName, formattingName, icon, tooltip) {
43+
const getRichtextToolbarConfig = function(commandName, formattingName, icon, tooltip, component, componentConfiguration) {
4544
return {
4645
commandName: commandName,
4746
isActive: $get(commandName),
4847
isVisible: $get(['formatting', formattingName]),
49-
component: ButtonComponent(commandName),
48+
component: component(commandName, componentConfiguration),
5049
icon: icon,
5150
tooltip: tooltip,
5251
};
53-
}
52+
};
5453

55-
export { getCkeditorPlugin, getCkeditorPluginConfig, getRichtextToolbarConfig};
54+
export {getCkeditorPlugin, getCkeditorPluginConfig, getRichtextToolbarConfig};
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import {Command} from 'ckeditor5-exports';
2+
3+
export default class SelectFormatCommand extends Command {
4+
attributePrefix = 'textFormat-';
5+
6+
constructor(editor, attributeKeys) {
7+
super(editor);
8+
this.attributeKeys = attributeKeys;
9+
}
10+
11+
execute(options = {}) {
12+
const editor = this.editor;
13+
const model = editor.model;
14+
const document = model.document;
15+
const selection = document.selection;
16+
17+
editor.model.change((writer) => {
18+
for (const attributeSuffix of Object.keys(options)) {
19+
const attributeName = this.attributePrefix + attributeSuffix;
20+
const selectedClass = options[attributeSuffix];
21+
const ranges = model.schema.getValidRanges(selection.getRanges(), attributeName);
22+
23+
if (selection.isCollapsed) {
24+
const position = selection.getFirstPosition();
25+
26+
// When selection is inside text with `highlight` attribute.
27+
if (selection.hasAttribute(attributeName)) {
28+
// Find the full highlighted range.
29+
const isSameHighlight = value => {
30+
return value.item.hasAttribute(attributeName) && value.item.getAttribute(attributeName) === this.value;
31+
};
32+
33+
const highlightStart = position.getLastMatchingPosition(isSameHighlight, {direction: 'backward'});
34+
const highlightEnd = position.getLastMatchingPosition(isSameHighlight);
35+
const highlightRange = writer.createRange(highlightStart, highlightEnd);
36+
37+
// Then depending on current value...
38+
if (!selectedClass || this.value === selectedClass) {
39+
// ...remove attribute when passing highlighter different then current or executing "eraser".
40+
writer.removeAttribute(attributeName, highlightRange);
41+
writer.removeSelectionAttribute(attributeName);
42+
} else {
43+
// ...update `highlight` value.
44+
writer.setAttribute(attributeName, selectedClass, highlightRange);
45+
writer.setSelectionAttribute(attributeName, selectedClass);
46+
}
47+
} else if (selectedClass) {
48+
writer.setSelectionAttribute(attributeName, selectedClass);
49+
}
50+
} else {
51+
for (const range of ranges) {
52+
if (selectedClass) {
53+
writer.setAttribute(attributeName, selectedClass, range);
54+
} else {
55+
writer.removeAttribute(attributeName, range);
56+
}
57+
}
58+
}
59+
}
60+
});
61+
}
62+
63+
refresh() {
64+
const {model} = this.editor;
65+
const {selection} = model.document;
66+
67+
this.value = {};
68+
const attributes = selection.getAttributes();
69+
70+
for (let attribute of attributes) {
71+
if (attribute[0].indexOf(this.attributePrefix) === 0) {
72+
const suffix = attribute[0].substr(this.attributePrefix.length);
73+
this.value[suffix] = attribute[1];
74+
}
75+
}
76+
77+
this.isEnabled = true;
78+
for (let attributeName of this.attributeKeys) {
79+
if (this.isEnabled) {
80+
return;
81+
}
82+
this.isEnabled = this.isEnabled || model.schema.checkAttributeInSelection(selection, this.attributePrefix + attributeName);
83+
}
84+
}
85+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
.selectFormatButton__flyout {
2+
--dialog-width: 460px;
3+
4+
background-color: var(--colors-ContrastDarker);
5+
position: fixed;
6+
z-index: var(--zIndex-SecondaryToolbar-LinkIconButtonFlyout);
7+
width: var(--dialog-width);
8+
left: 50%;
9+
margin-left: calc(var(--dialog-width) * -.5);
10+
border: var(--spacing-Half) solid var(--colors-ContrastDarker);
11+
}
12+
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import React, {PureComponent} from 'react';
2+
import {connect} from 'react-redux';
3+
import PropTypes from 'prop-types';
4+
import {$get, $transform} from 'plow-js';
5+
import {selectors} from '@neos-project/neos-ui-redux-store';
6+
import {IconButton, SelectBox} from '@neos-project/react-ui-components';
7+
import {neos} from '@neos-project/neos-ui-decorators';
8+
import {executeCommand} from '@neos-project/neos-ui-ckeditor5-bindings';
9+
import style from './SelectFormatButton.css';
10+
11+
export default (commandName, configuration) => {
12+
@connect($transform({
13+
formattingUnderCursor: selectors.UI.ContentCanvas.formattingUnderCursor
14+
}))
15+
@neos(globalRegistry => ({
16+
i18nRegistry: globalRegistry.get('i18n')
17+
}))
18+
class SelectFormatButtonComponent extends PureComponent {
19+
static propTypes = {
20+
i18nRegistry: PropTypes.object,
21+
tooltip: PropTypes.string,
22+
formatOptions: PropTypes.object,
23+
formattingUnderCursor: PropTypes.objectOf(PropTypes.oneOfType([
24+
PropTypes.number,
25+
PropTypes.bool,
26+
PropTypes.string,
27+
PropTypes.object
28+
])),
29+
};
30+
31+
constructor(props) {
32+
super(props);
33+
this.state = {
34+
isOpen: false,
35+
attributes: props.formattingUnderCursor[commandName],
36+
};
37+
}
38+
39+
componentWillReceiveProps = (nextProps) => {
40+
// If the new selection doesn't have any formatting close the dialog
41+
if (!$get(commandName, nextProps.formattingUnderCursor)) {
42+
this.setState({isOpen: false});
43+
} else {
44+
this.setState({
45+
isOpen: this.state.isOpen,
46+
attributes: nextProps.formattingUnderCursor[commandName],
47+
});
48+
}
49+
};
50+
51+
handleClick = () => {
52+
this.setState({isOpen: !this.state.isOpen});
53+
};
54+
55+
handleRemoveFormatClick = () => {
56+
this.setState({
57+
attributes: Object.keys(configuration.formatting.attributes).reduce((map, attribute) => {
58+
map[attribute] = '';
59+
return map;
60+
}, {})
61+
}, this.handleSelectionChange);
62+
};
63+
64+
handleSelectionChange = () => {
65+
executeCommand(commandName, this.state.attributes);
66+
};
67+
68+
handleAttributeChange = (attributeName, value) => {
69+
this.setState({
70+
attributes: {
71+
...this.state.attributes,
72+
[attributeName]: value,
73+
}
74+
}, this.handleSelectionChange);
75+
};
76+
77+
render() {
78+
const {icon, id, tooltip, i18nRegistry} = this.props;
79+
const {isOpen} = this.state;
80+
const {attributes} = configuration.formatting;
81+
82+
return (
83+
<div>
84+
<IconButton
85+
icon={icon}
86+
id={id}
87+
isActive={isOpen}
88+
onClick={this.handleClick}
89+
title={i18nRegistry.translate(tooltip)}
90+
/>
91+
{isOpen ? (
92+
<div className={style.selectFormatButton__flyout}>
93+
{Object.keys(attributes).map((attributeName) => {
94+
const attribute = attributes[attributeName];
95+
return (
96+
<div key={attributeName}>
97+
<label htmlFor={"selectFormat-" + attributeName}>{i18nRegistry.translate(attribute.label)}</label>
98+
<SelectBox id={"selectFormat-" + attributeName}
99+
placeholder={i18nRegistry.translate(attribute.placeholder)}
100+
placeholderIcon={i18nRegistry.translate(attribute.placeholderIcon)}
101+
options={attribute.options}
102+
value={this.state.attributes[attributeName]}
103+
onValueChange={(value) => this.handleAttributeChange(attributeName, value)}
104+
optionValueField="model"/>
105+
</div>
106+
);
107+
})}
108+
<IconButton icon="eraser" hoverStyle="brand"
109+
onClick={this.handleRemoveFormatClick}/>
110+
</div>
111+
) : null}
112+
</div>
113+
)
114+
}
115+
}
116+
117+
return SelectFormatButtonComponent;
118+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import {Plugin} from 'ckeditor5-exports';
2+
import SelectFormatCommand from '../Commands/SelectFormatCommand';
3+
4+
export default class SelectFormatPlugin extends Plugin {
5+
static get pluginName() {
6+
return 'Breadlesscode.SimpleEditorExtend:Dropdowns';
7+
}
8+
9+
static get pluginPrefix() {
10+
return 'selectFormat';
11+
}
12+
13+
init() {
14+
const {editor} = this;
15+
const {schema} = editor.model;
16+
const {conversion} = editor;
17+
18+
// Extend the schema for each attribute type found in the merged options
19+
const options = editor.config.get(SelectFormatPlugin.pluginPrefix + '.options');
20+
if (options) {
21+
for (const attributeName of Object.keys(options)) {
22+
schema.extend('$text', {allowAttributes: SelectFormatPlugin.pluginPrefix + '-' + attributeName});
23+
conversion.attributeToElement(_buildDefinition(attributeName, options[attributeName]['options']));
24+
}
25+
editor.commands.add(SelectFormatPlugin.pluginPrefix + 'Command', new SelectFormatCommand(editor, Object.keys(options)));
26+
}
27+
}
28+
}
29+
30+
function _buildDefinition(name, options) {
31+
const definition = {
32+
model: {
33+
key: SelectFormatPlugin.pluginPrefix + '-' + name,
34+
values: []
35+
},
36+
view: {}
37+
};
38+
39+
for (const option of options) {
40+
const modelName = option.model;
41+
definition.model.values.push(modelName);
42+
definition.view[modelName] = {
43+
name: 'span',
44+
classes: option.class,
45+
// A fixed priority for all definitions is needed, so they get merged
46+
priority: 10
47+
};
48+
}
49+
50+
return definition;
51+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import manifest from '@neos-project/neos-ui-extensibility';
2+
import {getCkeditorPlugin, getCkeditorPluginConfig, getRichtextToolbarConfig} from './CkeditorPluginUtils';
3+
import ButtonComponent from './Components/ButtonComponent';
4+
5+
manifest('Breadlesscode.SimpleEditorExtend:UiPlugin.Buttons', {}, (globalRegistry, { frontendConfiguration }) => {
6+
const richtextToolbar = globalRegistry.get('ckEditor5').get('richtextToolbar');
7+
const ckEditorConfig = globalRegistry.get('ckEditor5').get('config');
8+
const buttonConfig = frontendConfiguration['Breadlesscode.SimpleEditorExtend:Buttons'];
9+
10+
// Configure individual single format buttons
11+
Object.keys(buttonConfig).forEach((formattingName) => {
12+
const options = buttonConfig[formattingName];
13+
const commandName = options.extensionName + 'Command';
14+
15+
richtextToolbar.set(
16+
options.extensionName,
17+
getRichtextToolbarConfig(commandName, formattingName, options.icon, options.tooltip, ButtonComponent),
18+
options.position
19+
);
20+
21+
ckEditorConfig.set(
22+
options.extensionName,
23+
getCkeditorPluginConfig(
24+
formattingName,
25+
getCkeditorPlugin(options.extensionName, commandName, options.formatting)
26+
)
27+
);
28+
});
29+
});

0 commit comments

Comments
 (0)