display_builder-1.0.x-dev/src/Plugin/display_builder/Island/BuilderPanel.php
src/Plugin/display_builder/Island/BuilderPanel.php
<?php
declare(strict_types=1);
namespace Drupal\display_builder\Plugin\display_builder\Island;
use Drupal\Component\Utility\Html;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\display_builder\Attribute\Island;
use Drupal\display_builder\IslandPluginBase;
use Drupal\display_builder\IslandType;
use Drupal\ui_styles\Render\Element;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Builder island plugin implementation.
*/
#[Island(
id: 'builder',
label: new TranslatableMarkup('Builder'),
description: new TranslatableMarkup('The Display Builder main island, allow to build the display.'),
type: IslandType::View,
icon: 'tools',
keyboard_shortcuts: [
'b' => new TranslatableMarkup('Show builder view'),
],
)]
class BuilderPanel extends IslandPluginBase {
/**
* The renderer.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* Proxy for slot source operations.
*
* @var \Drupal\display_builder\SlotSourceProxy
*/
protected $slotSourceProxy;
/**
* The component element builder.
*
* @var \Drupal\ui_patterns\Element\ComponentElementBuilder
*/
protected $componentElementBuilder;
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
$instance = parent::create($container, $configuration, $plugin_id, $plugin_definition);
$instance->renderer = $container->get('renderer');
$instance->slotSourceProxy = $container->get('display_builder.slot_sources_proxy');
$instance->componentElementBuilder = $container->get('ui_patterns.component_element_builder');
return $instance;
}
/**
* {@inheritdoc}
*/
public function build(string $builder_id, array $data, array $options = []): array {
$build = [
'#type' => 'component',
'#component' => 'display_builder:dropzone',
'#props' => [
'variant' => 'root',
],
'#slots' => [
'content' => $this->digFromSlot($builder_id, $data),
],
'#attributes' => [
// Required for JavaScript @see components/dropzone/dropzone.js.
'data-db-id' => $builder_id,
'data-instance-title' => $this->t('Base container'),
'data-db-root' => TRUE,
],
];
return $this->htmxEvents->onRootDrop($build, $builder_id);
}
/**
* {@inheritdoc}
*/
public function onAttachToRoot(string $builder_id, string $instance_id): array {
return $this->reloadWithGlobalData($builder_id);
}
/**
* {@inheritdoc}
*/
public function onAttachToSlot(string $builder_id, string $instance_id, string $parent_id): array {
return $this->replaceInstance($builder_id, $parent_id);
}
/**
* {@inheritdoc}
*/
public function onMove(string $builder_id, string $instance_id): array {
return $this->reloadWithGlobalData($builder_id);
}
/**
* {@inheritdoc}
*/
public function onHistoryChange(string $builder_id): array {
return $this->reloadWithGlobalData($builder_id);
}
/**
* {@inheritdoc}
*/
public function onUpdate(string $builder_id, string $instance_id): array {
return $this->replaceInstance($builder_id, $instance_id);
}
/**
* {@inheritdoc}
*/
public function onDelete(string $builder_id, string $parent_id): array {
if (empty($parent_id)) {
return $this->reloadWithGlobalData($builder_id);
}
return $this->replaceInstance($builder_id, $parent_id);
}
/**
* {@inheritdoc}
*/
protected function buildSingleComponent(string $builder_id, string $instance_id, array $data, int $index = 0): ?array {
$component_id = $data['source']['component']['component_id'] ?? NULL;
$instance_id = $instance_id ?: $data['_instance_id'];
if (!$instance_id && !$component_id) {
return NULL;
}
$component = $this->sdcManager->getDefinition($component_id);
if (!$component) {
return NULL;
}
$build = $this->renderSource($data);
// Required for the context menu label.
// @see components/contextual_menu/contextual_menu.js
$build['#attributes']['data-instance-title'] = $component['label'];
$build['#attributes']['data-slot-position'] = $index;
foreach ($component['slots'] ?? [] as $slot_id => $definition) {
$build['#slots'][$slot_id] = $this->buildComponentSlot($builder_id, $slot_id, $definition, $data, $instance_id);
// Prevent the slot to be generated again.
unset($build['#ui_patterns']['slots'][$slot_id]);
}
if ($this->isEmpty($build)) {
// Keep the placeholder if the component is not renderable.
$message = $component['name'] . ': ' . $this->t('Empty by default. Configure it to make it visible');
$build = $this->buildPlaceholder($message);
}
if (!$this->useAttributesVariable($build)) {
$build = $this->wrapContent($build);
}
return $this->htmxEvents->onInstanceClick($build, $builder_id, $instance_id, $component['label'], $index);
}
/**
* Does the component use the attributes variable in template?
*
* @param array $renderable
* Component renderable.
*
* @return bool
* Use it or not.
*/
protected function useAttributesVariable(array $renderable): bool {
$random = \uniqid();
$renderable['#attributes'][$random] = $random;
$html = $this->renderer->renderInIsolation($renderable);
return \str_contains((string) $html, $random);
}
/**
* {@inheritdoc}
*/
protected function buildSingleBlock(string $builder_id, string $instance_id, array $data, int $index = 0): ?array {
$instance_id = $instance_id ?: $data['_instance_id'];
if (!$instance_id) {
return NULL;
}
$label = $data['source_id'] ?? $data['_instance_id'] ?? NULL;
$classes = ['db-block'];
if (isset($data['source']['plugin_id'])) {
$classes[] = 'db-block-' . \strtolower(Html::cleanCssIdentifier($data['source']['plugin_id']));
}
else {
$classes[] = 'db-block-' . \strtolower(Html::cleanCssIdentifier($data['source_id']));
}
$build = $this->renderSource($data, $classes);
$is_empty = FALSE;
if (isset($data['source_id']) && $data['source_id'] === 'token') {
if (isset($build['content']) && empty($build['content'])) {
$is_empty = TRUE;
}
}
// This is the placeholder without configuration or content yet.
if ($this->isEmpty($build) || $is_empty) {
// Keep the placeholder if the block is not renderable.
$label = $this->slotSourceProxy->getLabelWithSummary($data);
$build = $this->buildPlaceholderButton($label['summary']);
// Highlight in the view to show it's a temporary block waiting for
// configuration.
$build['#attributes']['class'][] = 'db-background';
}
elseif (!Element::isAcceptingAttributes($build)) {
$build = [
'#type' => 'html_tag',
'#tag' => 'div',
'#attributes' => ['class' => $classes],
'content' => $build,
];
}
// This label is used for contextual menu.
// @see components/contextual_menu/contextual_menu.js
$build['#attributes']['data-instance-title'] = $label['summary'] ?? $label;
$build['#attributes']['data-slot-position'] = $index;
return $this->htmxEvents->onInstanceClick($build, $builder_id, $instance_id, $label['label'] ?? $label, $index);
}
/**
* Get renderable array for a slot source.
*
* @param array $data
* The slot source data array containing:
* - source_id: The source ID
* - source: Array of source configuration.
* @param array $classes
* (Optional) Classes to use to wrap the rendered source if needed.
*
* @return array
* The renderable array for this slot source.
*/
protected function renderSource(array $data, array $classes = []): array {
$build = $this->componentElementBuilder->buildSource([], 'content', [], $data, []) ?? [];
$build = $build['#slots']['content'][0] ?? [];
// Fixes for token which is simple markup or html.
if (isset($data['source_id']) && $data['source_id'] !== 'token') {
return $build;
}
// If token is only markup, we don't have a wrapper, add it like ui_styles
// so the placeholder can be styled.
if (!isset($build['#type'])) {
$build = [
'#type' => 'html_tag',
'#tag' => 'div',
'#attributes' => ['class' => $classes],
'content' => $build,
];
}
// If a style is applied, we have a wrapper from ui_styles with classes, to
// avoid our placeholder classes to be replaced we need to wrap it.
elseif (isset($build['#attributes'])) {
$build = [
'#type' => 'html_tag',
'#tag' => 'div',
'#attributes' => ['class' => $classes],
'content' => $build,
];
}
return $build;
}
/**
* Build builder renderable, recursively.
*
* @param string $builder_id
* Builder ID.
* @param array $data
* The current 'slice' of data.
*
* @return array
* A renderable array.
*/
protected function digFromSlot(string $builder_id, array $data): array {
$renderable = [];
foreach ($data as $index => $source) {
if (!isset($source['source_id'])) {
continue;
}
if ($source['source_id'] === 'component') {
$component = $this->buildSingleComponent($builder_id, '', $source, $index);
if ($component) {
$renderable[$index] = $component;
}
continue;
}
$block = $this->buildSingleBlock($builder_id, '', $source, $index);
if ($block) {
$renderable[$index] = $block;
}
}
return $renderable;
}
/**
* Check if a renderable array is empty.
*
* @param array $renderable
* The renderable array to check.
*
* @return bool
* TRUE if the rendered output is empty, FALSE otherwise.
*/
private function isEmpty(array $renderable): bool {
$html = $this->renderer->renderInIsolation($renderable);
return empty(\trim((string) $html));
}
/**
* Build a component slot with dropzone.
*
* @param string $builder_id
* The builder ID.
* @param string $slot
* The slot ID.
* @param array $definition
* The slot definition.
* @param array $data
* The component data.
* @param string $instance_id
* The instance ID.
*
* @return array
* A renderable array for the slot.
*/
private function buildComponentSlot(string $builder_id, string $slot, array $definition, array $data, string $instance_id): array {
$dropzone = [
'#type' => 'component',
'#component' => 'display_builder:dropzone',
'#props' => [
'title' => $definition['title'],
'variant' => 'highlighted',
],
'#attributes' => [
// Required for JavaScript @see components/dropzone/dropzone.js.
'data-db-id' => $builder_id,
// Slot is needed for contextual menu paste.
// @see components/contextual_menu/contextual_menu.js
'data-slot-id' => $slot,
'data-slot-title' => \ucfirst($definition['title']),
'data-instance-id' => $instance_id,
],
];
if (isset($data['source']['component']['slots'][$slot])) {
$sources = $data['source']['component']['slots'][$slot]['sources'];
$dropzone['#slots']['content'] = $this->digFromSlot($builder_id, $sources);
}
return $this->htmxEvents->onSlotDrop($dropzone, $builder_id, $instance_id, $slot);
}
}
