Skip to content

Commit

Permalink
EZP-29554: As an editor, I want to be able to apply custom styles to …
Browse files Browse the repository at this point in the history
…part of my text (ezsystems#2427)

* EZP-29554: Added Custom Styles support for RichText Field Type

Added a way to declare and use custom "styles" in richtext (https://alloyeditor.com/docs/features/styles.html)

* EZP-29554: Added improvements after code review
  • Loading branch information
erdnaxelaweb authored and alongosz committed Sep 13, 2018
1 parent 4892d15 commit 36f372c
Show file tree
Hide file tree
Showing 24 changed files with 750 additions and 75 deletions.
Expand Up @@ -479,9 +479,10 @@ private function addRouterSection(ArrayNodeDefinition $rootNode)
*/
private function addRichTextSection(ArrayNodeDefinition $rootNode)
{
$this->addCustomTagsSection(
$rootNode->children()->arrayNode('ezrichtext')->children()
)->end()->end()->end();
$ezRichTextNode = $rootNode->children()->arrayNode('ezrichtext')->children();
$this->addCustomTagsSection($ezRichTextNode);
$this->addCustomStylesSection($ezRichTextNode);
$ezRichTextNode->end()->end()->end();
}

/**
Expand Down Expand Up @@ -571,6 +572,40 @@ function ($v) {
;
}

/**
* Define RichText Custom Styles Semantic Configuration.
*
* The configuration is available at:
* <code>
* ezpublish:
* ezrichtext:
* custom_styles:
* </code>
*
* @param \Symfony\Component\Config\Definition\Builder\NodeBuilder $ezRichTextNode
*
* @return \Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition
*/
private function addCustomStylesSection(NodeBuilder $ezRichTextNode)
{
return $ezRichTextNode
->arrayNode('custom_styles')
// workaround: take into account Custom Styles names when merging configs
->useAttributeAsKey('style')
->arrayPrototype()
->children()
->scalarNode('template')
->defaultNull()
->end()
->scalarNode('inline')
->defaultFalse()
->end()
->end()
->end()
->end()
;
}

/**
* Defines configuration the images placeholder generation.
*
Expand Down
Expand Up @@ -167,6 +167,13 @@ public function addFieldTypeSemanticConfig(NodeBuilder $nodeBuilder)
->info('List of RichText Custom Tags enabled for the current scope. The Custom Tags must be defined in ezpublish.ezrichtext.custom_tags Node.')
->scalarPrototype()->end()
->end();

// RichText Custom Styles configuration (list of Custom Styles enabled for current SiteAccess scope)
$nodeBuilder
->arrayNode('custom_styles')
->info('List of RichText Custom Styles enabled for the current scope. The Custom Styles must be defined in ezpublish.ezrichtext.custom_styles Node.')
->scalarPrototype()->end()
->end();
}

/**
Expand Down Expand Up @@ -217,6 +224,17 @@ public function mapConfig(array &$scopeSettings, $currentScope, ContextualizerIn
$scopeSettings['fieldtypes']['ezrichtext']['custom_tags']
);
}
if (isset($scopeSettings['fieldtypes']['ezrichtext']['custom_styles'])) {
$this->validateCustomStylesConfiguration(
$contextualizer->getContainer(),
$scopeSettings['fieldtypes']['ezrichtext']['custom_styles']
);
$contextualizer->setContextualParameter(
'fieldtypes.ezrichtext.custom_styles',
$currentScope,
$scopeSettings['fieldtypes']['ezrichtext']['custom_styles']
);
}

if (isset($scopeSettings['fieldtypes']['ezrichtext']['tags'])) {
foreach ($scopeSettings['fieldtypes']['ezrichtext']['tags'] as $name => $tagSettings) {
Expand Down Expand Up @@ -269,6 +287,28 @@ private function validateCustomTagsConfiguration(
}
}

/**
* Validate SiteAccess-defined Custom Styles configuration against global one.
*
* @param \Symfony\Component\DependencyInjection\ContainerInterface $container
* @param array $enabledCustomStyles List of Custom Styles enabled for the current scope/SiteAccess
*/
private function validateCustomStylesConfiguration(
ContainerInterface $container,
array $enabledCustomStyles
) {
$definedCustomStyles = array_keys(
$container->getParameter(EzPublishCoreExtension::RICHTEXT_CUSTOM_STYLES_PARAMETER)
);
foreach ($enabledCustomStyles as $customStyleName) {
if (!in_array($customStyleName, $definedCustomStyles)) {
throw new InvalidConfigurationException(
"Unknown RichText Custom Style '{$customStyleName}'"
);
}
}
}

/**
* Add BC setup for deprecated configuration.
*
Expand Down
Expand Up @@ -27,6 +27,7 @@

class EzPublishCoreExtension extends Extension
{
const RICHTEXT_CUSTOM_STYLES_PARAMETER = 'ezplatform.ezrichtext.custom_styles';
const RICHTEXT_CUSTOM_TAGS_PARAMETER = 'ezplatform.ezrichtext.custom_tags';

/**
Expand Down Expand Up @@ -297,6 +298,12 @@ private function registerRichTextConfiguration(array $config, ContainerBuilder $
$config['ezrichtext']['custom_tags']
);
}
if (isset($config['ezrichtext']['custom_styles'])) {
$container->setParameter(
static::RICHTEXT_CUSTOM_STYLES_PARAMETER,
$config['ezrichtext']['custom_styles']
);
}
}

/**
Expand Down
Expand Up @@ -26,6 +26,11 @@ parameters:
# Rich Text Custom Tags default scope (for SiteAccess) configuration
ezsettings.default.fieldtypes.ezrichtext.custom_tags: []

# Rich Text Custom Styles global configuration
ezplatform.ezrichtext.custom_styles: {}
# Rich Text Custom Styles default scope (for SiteAccess) configuration
ezsettings.default.fieldtypes.ezrichtext.custom_styles: []

# Image Asset mappings
ezsettings.default.fieldtypes.ezimageasset.mappings:
content_type_identifier: image
Expand Down Expand Up @@ -129,6 +134,13 @@ parameters:
ezsettings.default.fieldtypes.ezrichtext.tags.default_inline:
template: EzPublishCoreBundle:FieldType/RichText/tag:default_inline.html.twig

# RichText field type template style settings
# 'default' and 'default_inline' tag identifiers are reserved for fallback
ezsettings.default.fieldtypes.ezrichtext.styles.default:
template: EzPublishCoreBundle:FieldType/RichText/style:default.html.twig
ezsettings.default.fieldtypes.ezrichtext.styles.default_inline:
template: EzPublishCoreBundle:FieldType/RichText/style:default_inline.html.twig

# RichText field type embed settings
ezsettings.default.fieldtypes.ezrichtext.embed.content:
template: EzPublishCoreBundle:FieldType/RichText/embed:content.html.twig
Expand Down
Expand Up @@ -21,9 +21,11 @@ parameters:
ezpublish.fieldType.ezrichtext.converter.link.class: eZ\Publish\Core\FieldType\RichText\Converter\Link
ezpublish.fieldType.ezrichtext.converter.embed.class: eZ\Publish\Core\FieldType\RichText\Converter\Render\Embed
ezpublish.fieldType.ezrichtext.converter.template.class: eZ\Publish\Core\FieldType\RichText\Converter\Render\Template
ezpublish.fieldType.ezrichtext.converter.style.class: eZ\Publish\Core\FieldType\RichText\Converter\Render\Style
ezpublish.fieldType.ezrichtext.embed_renderer.class: eZ\Publish\Core\MVC\Symfony\FieldType\RichText\EmbedRenderer
ezpublish.fieldType.ezrichtext.renderer.class: eZ\Publish\Core\MVC\Symfony\FieldType\RichText\Renderer
ezpublish.fieldType.ezrichtext.tag.namespace: fieldtypes.ezrichtext.tags
ezpublish.fieldType.ezrichtext.style.namespace: fieldtypes.ezrichtext.styles
ezpublish.fieldType.ezrichtext.embed.namespace: fieldtypes.ezrichtext.embed
ezpublish.fieldType.ezrichtext.converter.dispatcher.class: eZ\Publish\Core\FieldType\RichText\ConverterDispatcher
ezpublish.fieldType.ezrichtext.validator.xml.class: eZ\Publish\Core\FieldType\RichText\Validator
Expand Down Expand Up @@ -152,9 +154,19 @@ services:
- "@ezpublish.config.resolver"
- "@templating"
- "%ezpublish.fieldType.ezrichtext.tag.namespace%"
- "%ezpublish.fieldType.ezrichtext.style.namespace%"
- "%ezpublish.fieldType.ezrichtext.embed.namespace%"
- "@?logger"
- "%ezplatform.ezrichtext.custom_tags%"
- "%ezplatform.ezrichtext.custom_styles%"

ezpublish.fieldType.ezrichtext.converter.style:
class: "%ezpublish.fieldType.ezrichtext.converter.style.class%"
arguments:
- "@ezpublish.fieldType.ezrichtext.renderer"
- "@ezpublish.fieldType.ezrichtext.converter.output.xhtml5"
tags:
- {name: ezpublish.ezrichtext.converter.output.xhtml5, priority: 10}

ezpublish.fieldType.ezrichtext.converter.template:
class: "%ezpublish.fieldType.ezrichtext.converter.template.class%"
Expand Down
@@ -0,0 +1 @@
<div class="{% if align is defined %}align-{{ align }}{% endif %} ezstyle-{{ name }}">{% spaceless %}{{ content|raw }}{% endspaceless %}</div>
@@ -0,0 +1 @@
<span class="ezstyle-{{ name }}">{% spaceless %}{{ content|raw }}{% endspaceless %}</span>
177 changes: 177 additions & 0 deletions eZ/Publish/Core/FieldType/RichText/Converter/Render/Style.php
@@ -0,0 +1,177 @@
<?php

/**
* File containing the eZ\Publish\Core\FieldType\RichText\Converter\Render\Style class.
*
* @copyright Copyright (C) eZ Systems AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
namespace eZ\Publish\Core\FieldType\RichText\Converter\Render;

use DOMDocument;
use DOMElement;
use DOMNode;
use DOMXPath;
use eZ\Publish\Core\FieldType\RichText\Converter;
use eZ\Publish\Core\FieldType\RichText\Converter\Render;
use eZ\Publish\Core\FieldType\RichText\RendererInterface;

/**
* RichText Style converter injects rendered style payloads into style elements.
*/
class Style extends Render implements Converter
{
/**
* @var Converter
*/
private $richTextConverter;

/**
* Style constructor.
*
* @param RendererInterface $renderer
* @param Converter $richTextConverter
*/
public function __construct(RendererInterface $renderer, Converter $richTextConverter)
{
$this->richTextConverter = $richTextConverter;
parent::__construct($renderer);
}

/**
* Injects rendered payloads into Custom Style elements.
*
* @param \DOMDocument $document
*
* @return \DOMDocument
*/
public function convert(DOMDocument $document)
{
$xpath = new DOMXPath($document);
$xpath->registerNamespace('docbook', 'http://docbook.org/ns/docbook');
$xpathExpression = '//docbook:ezstyle | //docbook:ezstyleinline';

$styles = $xpath->query($xpathExpression);
/** @var \DOMElement[] $stylesSorted */
$stylesSorted = [];
$maxDepth = 0;

foreach ($styles as $style) {
$depth = $this->getNodeDepth($style);
if ($depth > $maxDepth) {
$maxDepth = $depth;
}
$stylesSorted[$depth][] = $style;
}

ksort($stylesSorted, SORT_NUMERIC);
foreach ($stylesSorted as $styles) {
foreach ($styles as $style) {
$this->processStyle($document, $style);
}
}

return $document;
}

/**
* Processes given template $style in a given $document.
*
* @param \DOMDocument $document
* @param \DOMElement $style
*/
protected function processStyle(DOMDocument $document, DOMElement $style)
{
$content = null;
$styleName = $style->getAttribute('name');
$parameters = [
'name' => $styleName,
'content' => $this->saveNodeXML($style),
];

if ($style->hasAttribute('ezxhtml:align')) {
$parameters['align'] = $style->getAttribute('ezxhtml:align');
}

$content = $this->renderer->renderStyle(
$styleName,
$parameters,
$style->localName === 'ezstyleinline'
);

if (isset($content)) {
// If current tag is wrapped inside another Custom Style tag we can't use CDATA section
// for its content as these can't be nested.
// CDATA section will be used only for content of root wrapping tag, content of tags
// inside it will be added as XML fragments.
if ($this->isWrapped($style)) {
$fragment = $document->createDocumentFragment();
$fragment->appendXML($content);
$style->parentNode->replaceChild($fragment, $style);
} else {
$payload = $document->createElement('ezpayload');
$payload->appendChild($document->createCDATASection($content));
$style->appendChild($payload);
}
}
}

/**
* Returns if the given $node is wrapped inside another template node.
*
* @param \DOMNode $node
*
* @return bool
*/
protected function isWrapped(DomNode $node)
{
while ($node = $node->parentNode) {
if ($node->localName === 'ezstyle' || $node->localName === 'ezstyleinline') {
return true;
}
}

return false;
}

/**
* Returns depth of given $node in a DOMDocument.
*
* @param \DOMNode $node
*
* @return int
*/
protected function getNodeDepth(DomNode $node)
{
// initial depth for top level elements (to avoid "ifs")
$depth = -2;

while ($node) {
++$depth;
$node = $node->parentNode;
}

return $depth;
}

/**
* Returns XML fragment string for given $node.
*
* @param \DOMNode $node
*
* @return string
*/
protected function saveNodeXML(DOMNode $node)
{
$innerDoc = new DOMDocument();

/** @var \DOMNode $child */
foreach ($node->childNodes as $child) {
$innerDoc->appendChild($innerDoc->importNode($child, true));
}

$convertedInnerDoc = $this->richTextConverter->convert($innerDoc);

return trim($convertedInnerDoc ? $convertedInnerDoc->saveHTML() : $innerDoc->saveHTML());
}
}
11 changes: 11 additions & 0 deletions eZ/Publish/Core/FieldType/RichText/RendererInterface.php
Expand Up @@ -13,6 +13,17 @@
*/
interface RendererInterface
{
/**
* Renders template style.
*
* @param string $name
* @param array $parameters
* @param bool $isInline
*
* @return string
*/
public function renderStyle($name, array $parameters, $isInline);

/**
* Renders template tag.
*
Expand Down

0 comments on commit 36f372c

Please sign in to comment.