71 |
--------------------------------------------------------------------------------
/blocks/button.block:
--------------------------------------------------------------------------------
1 | name: winter.blocks::lang.blocks.button.name
2 | description: winter.blocks::lang.blocks.button.description
3 | icon: icon-caret-square-o-right
4 | tags: ["pages"]
5 | fields:
6 | config:
7 | type: nestedform
8 | usePanelStyles: false
9 | form:
10 | fields:
11 | label:
12 | label: winter.blocks::lang.fields.label
13 | span: full
14 | type: text
15 | tabs:
16 | icons:
17 | winter.blocks::lang.fields.actions: 'icon-arrow-pointer'
18 | winter.blocks::lang.tabs.display: 'icon-brush'
19 |
20 | fields:
21 | actions:
22 | type: repeater
23 | tab: winter.blocks::lang.fields.actions
24 | prompt: winter.blocks::lang.fields.actions_prompt
25 | groups: $/winter/blocks/meta/actions.yaml
26 | color:
27 | label: winter.blocks::lang.fields.color
28 | tab: winter.blocks::lang.tabs.display
29 | span: auto
30 | type: colorpicker
31 | icon:
32 | label: winter.blocks::lang.fields.icon
33 | tab: winter.blocks::lang.tabs.display
34 | span: auto
35 | type: iconpicker
36 | ==
37 | controller->addJs(Url::asset('/plugins/winter/blocks/assets/dist/js/blocks.js'), 'Winter.Blocks');
43 |
44 | $data = $this['data']['config'];
45 |
46 | // Ensure actions are 0 indexed
47 | $data['actions'] = array_values($data['actions'] ?? []);
48 |
49 | if (!empty($data['actions'])) {
50 | foreach ($data['actions'] as &$config) {
51 | $action = $config['_group'] ?? '';
52 | unset($config['_group']);
53 |
54 | switch ($action) {
55 | case 'open_media':
56 | $config['href'] = MediaLibrary::url($config['media_file']);
57 | $action = 'open_url';
58 | break;
59 | }
60 |
61 | $config = [
62 | 'data' => $config,
63 | 'action' => $action,
64 | ];
65 | }
66 | }
67 |
68 | $this['data'] = array_merge($this['data'], [
69 | 'config' => $data
70 | ]);
71 | }
72 | ?>
73 | ==
74 |
89 |
--------------------------------------------------------------------------------
/lang/en/lang.php:
--------------------------------------------------------------------------------
1 | [
5 | 'name' => 'Blocks',
6 | 'description' => 'Block based content management plugin for Winter CMS.',
7 | ],
8 | 'actions' => [
9 | 'open_url' => [
10 | 'name' => 'Open URL',
11 | 'description' => 'Open the provided URL',
12 | 'href' => 'URL',
13 | 'target' => 'Open URL in',
14 | 'target_self' => 'Same tab',
15 | 'target_blank' => 'New tab',
16 | ],
17 | 'open_media' => [
18 | 'name' => 'Download Media File',
19 | 'description' => 'Download the provided file from the Media Library',
20 | 'media_file' => 'File',
21 | ],
22 | ],
23 | 'tabs' => [
24 | 'display' => 'Display',
25 | ],
26 | 'blocks' => [
27 | 'button' => [
28 | 'name' => 'Button',
29 | 'description' => 'A clickable button',
30 | ],
31 | 'button_group' => [
32 | 'name' => 'Button Group',
33 | 'description' => 'Group of clickable buttons',
34 | 'buttons' => 'Buttons',
35 | 'position_center' => 'Center',
36 | 'position_left' => 'Left',
37 | 'position_right' => 'Right',
38 | 'position' => 'Position',
39 | 'width_auto' => 'Auto',
40 | 'width_full' => 'Full',
41 | 'width' => 'Width',
42 | ],
43 | 'cards' => [
44 | 'name' => 'Cards',
45 | 'description' => 'Content in card format',
46 | ],
47 | 'code' => [
48 | 'name' => 'Code',
49 | 'description' => 'Custom HTML content',
50 | ],
51 | 'columns_two' => [
52 | 'name' => 'Two Columns',
53 | 'description' => 'Two columns of content',
54 | 'left' => 'Left Column',
55 | 'right' => 'Right Column',
56 | ],
57 | 'divider' => [
58 | 'name' => 'Divider',
59 | 'description' => 'Horizontal dividing line',
60 | ],
61 | 'image' => [
62 | 'name' => 'Image',
63 | 'description' => 'Single image from Media Library',
64 | 'alt_text' => 'Description (for screen readers)',
65 | 'size' => [
66 | 'w-full' => 'Full',
67 | 'w-2/3' => 'Two Thirds',
68 | 'w-1/2' => 'Half',
69 | 'w-1/3' => 'Third',
70 | 'w-1/4' => 'Quarter',
71 | ],
72 | ],
73 | 'plaintext' => [
74 | 'name' => 'Plain text',
75 | 'description' => 'Content with no formatting',
76 | ],
77 | 'richtext' => [
78 | 'name' => 'Rich Text',
79 | 'description' => 'Content with basic formatting',
80 | ],
81 | 'title' => [
82 | 'name' => 'Title',
83 | 'description' => 'Large text with size options',
84 | 'size' => [
85 | 'h4' => 'Small',
86 | 'h3' => 'Medium',
87 | 'h2' => 'Large',
88 | ],
89 | ],
90 | 'video' => [
91 | 'name' => 'Video',
92 | 'description' => 'Embed a Media Library video',
93 | ],
94 | 'vimeo' => [
95 | 'name' => 'Vimeo',
96 | 'description' => 'Embed a Vimeo video',
97 | 'vimeo_id' => 'Vimeo Video ID',
98 | ],
99 | 'youtube' => [
100 | 'name' => 'YouTube',
101 | 'description' => 'Embed a YouTube video',
102 | 'youtube_id' => 'YouTube Video ID',
103 | ],
104 | ],
105 | 'fields' => [
106 | 'actions_prompt' => 'Add action',
107 | 'actions' => 'Actions',
108 | 'blocks_prompt' => 'Add block',
109 | 'blocks' => 'Blocks',
110 | 'color' => 'Color',
111 | 'content' => 'Content',
112 | 'icon' => 'Icon',
113 | 'label' => 'Label',
114 | 'size' => 'Size',
115 | 'default' => 'Default',
116 | 'alignment_x' => [
117 | 'label' => 'Horizontal Alignment',
118 | 'left' => 'Left',
119 | 'center' => 'Center',
120 | 'right' => 'Right',
121 | ],
122 | ],
123 | ];
124 |
--------------------------------------------------------------------------------
/lang/fr/lang.php:
--------------------------------------------------------------------------------
1 | [
5 | 'name' => 'Blocks',
6 | 'description' => 'Plugin de gestion de contenu basé sur des blocs pour Winter CMS.',
7 | ],
8 | 'actions' => [
9 | 'open_url' => [
10 | 'name' => 'Ouvrir l\'URL',
11 | 'description' => 'Ouvrez l\'URL fournie',
12 | 'href' => 'URL',
13 | 'target' => 'Ouvrir l\'URL dans',
14 | 'target_self' => 'Même onglet',
15 | 'target_blank' => 'Nouvel onglet',
16 | ],
17 | 'open_media' => [
18 | 'name' => 'Télécharger un fichier média',
19 | 'description' => 'Télécharger le fichier spécifié à partir de la médiathèque',
20 | 'media_file' => 'Fichier',
21 | ],
22 | ],
23 | 'tabs' => [
24 | 'display' => 'Apparence',
25 | ],
26 | 'blocks' => [
27 | 'button' => [
28 | 'name' => 'Bouton',
29 | 'description' => 'Un bouton cliquable',
30 | ],
31 | 'button_group' => [
32 | 'name' => 'Groupe de boutons',
33 | 'description' => 'Groupe de boutons cliquables',
34 | 'buttons' => 'Boutons',
35 | 'position_center' => 'Centré',
36 | 'position_left' => 'A gauche',
37 | 'position_right' => 'A droite',
38 | 'position' => 'Position',
39 | 'width_auto' => 'Auto',
40 | 'width_full' => 'complète',
41 | 'width' => 'Largeur',
42 | ],
43 | 'cards' => [
44 | 'name' => 'Cartes',
45 | 'description' => 'Contenu au format carte',
46 | ],
47 | 'code' => [
48 | 'name' => 'Code',
49 | 'description' => 'Contenu HTML personnalisé',
50 | ],
51 | 'columns_two' => [
52 | 'name' => 'Deux colonnes',
53 | 'description' => 'Deux colonnes de contenu',
54 | 'left' => 'Colonne de gauche',
55 | 'right' => 'Colonne de droite',
56 | ],
57 | 'divider' => [
58 | 'name' => 'Séparateur',
59 | 'description' => 'Ligne de séparation horizontale',
60 | ],
61 | 'image' => [
62 | 'name' => 'Image',
63 | 'description' => 'Image unique de la médiathèque',
64 | 'alt_text' => 'Description (pour les lecteurs d\'écran) ',
65 | 'size' => [
66 | 'w-full' => 'Complète',
67 | 'w-2/3' => '2/3',
68 | 'w-1/2' => 'Moitié',
69 | 'w-1/3' => '1/3',
70 | 'w-1/4' => '1/4',
71 | ],
72 | ],
73 | 'plaintext' => [
74 | 'name' => 'Texte simple',
75 | 'description' => 'Contenu sans mise en forme',
76 | ],
77 | 'richtext' => [
78 | 'name' => 'Contenu enrichi',
79 | 'description' => 'Contenu avec mise en forme de base',
80 | ],
81 | 'title' => [
82 | 'name' => 'Titre',
83 | 'description' => 'Titre de page avec options de taille',
84 | 'size' => [
85 | 'h4' => 'Petit',
86 | 'h3' => 'Moyen',
87 | 'h2' => 'Grand',
88 | ],
89 | ],
90 | 'video' => [
91 | 'name' => 'Vidéo',
92 | 'description' => 'Intégrer une vidéo de la médiathèque',
93 | ],
94 | 'vimeo' => [
95 | 'name' => 'Vimeo',
96 | 'description' => 'Intégrer une vidéo Vimeo',
97 | 'vimeo_id' => 'ID de la vidéo Vimeo',
98 | ],
99 | 'youtube' => [
100 | 'name' => 'YouTube',
101 | 'description' => 'Intégrer une vidéo YouTube',
102 | 'youtube_id' => 'ID de la vidéo YouTube',
103 | ],
104 | ],
105 | 'fields' => [
106 | 'actions_prompt' => 'Ajouter une action',
107 | 'actions' => 'Actions',
108 | 'blocks_prompt' => 'Ajouter un bloc',
109 | 'blocks' => 'Blocs',
110 | 'color' => 'Couleur',
111 | 'content' => 'Contenu',
112 | 'icon' => 'Icône',
113 | 'label' => 'Label/texte',
114 | 'size' => 'Taille',
115 | 'default' => 'Défaut',
116 | 'alignment_x' => [
117 | 'label' => 'Alignement horizontal',
118 | 'left' => 'À gauche',
119 | 'center' => 'Centré',
120 | 'right' => 'À droite',
121 | ],
122 | ],
123 | ];
124 |
--------------------------------------------------------------------------------
/classes/BlockManager.php:
--------------------------------------------------------------------------------
1 | executeLifecycle($controller);
42 | } else {
43 | throw new SystemException("The block '$partialName' can not found.");
44 | }
45 | }
46 | });
47 |
48 | foreach (PluginManager::instance()->getRegistrationMethodValues('registerBlocks') as $plugin => $blocks) {
49 | foreach ($blocks as $key => $path) {
50 | $this->registerBlock($key, $path);
51 | }
52 | }
53 | }
54 |
55 | /**
56 | * Get the list of registered blocks in the form of ['key' => '$/path/to/block.block']
57 | */
58 | public function getRegisteredBlocks(): array
59 | {
60 | return $this->blocks;
61 | }
62 |
63 | /**
64 | * Register the provided key & path as a block
65 | */
66 | public function registerBlock(string $key, string $path): void
67 | {
68 | $realPath = File::symbolizePath($path);
69 |
70 | if (!File::exists($realPath)) {
71 | return;
72 | }
73 |
74 | $this->blocks[$key] = PathResolver::standardize($realPath);
75 | }
76 |
77 | /**
78 | * Get a collection of Block instances using the active theme
79 | */
80 | public function getBlocks(): CmsObjectCollection
81 | {
82 | return Block::listInTheme(Theme::getActiveTheme());
83 | }
84 |
85 | /**
86 | * Get an array of blocks and their configuration details in the form of ['key' => $config]
87 | */
88 | public function getConfigs(string|array|null $tags = null): array
89 | {
90 | $configs = [];
91 | foreach ($this->getBlocks() as $block) {
92 | if (isset($tags)) {
93 | $tags = (is_array($tags)) ? $tags : [$tags];
94 | $blockTags = (isset($block->tags) && is_array($block->tags)) ? $block->tags : [];
95 |
96 | if (count(array_intersect($tags, $blockTags)) === 0) {
97 | continue;
98 | }
99 | }
100 |
101 | $configs[pathinfo($block['fileName'])['filename']] = array_except(
102 | $block->getAttributes(),
103 | [
104 | 'fileName',
105 | 'content',
106 | 'mtime',
107 | 'markup',
108 | 'code',
109 | ]
110 | );
111 | }
112 |
113 | return $configs;
114 | }
115 |
116 | /**
117 | * Get the configuration of the provided block type
118 | */
119 | public function getConfig(string $type): ?array
120 | {
121 | return $this->getConfigs()[$type] ?? null;
122 | }
123 |
124 | /**
125 | * Check if the provided string is a valid block type
126 | */
127 | public function isBlock(string $type): bool
128 | {
129 | return !!$this->getConfig($type);
130 | }
131 |
132 | /**
133 | * Remove a block by key
134 | */
135 | public function removeBlock(string|array $key): void
136 | {
137 | if (is_array($key)) {
138 | foreach ($key as $k) {
139 | $this->removeBlock($k);
140 | }
141 |
142 | return;
143 | }
144 |
145 | unset($this->blocks[$key]);
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/classes/BlocksDatasource.php:
--------------------------------------------------------------------------------
1 | path] List of blocks managed by the BlockManager
12 | */
13 | protected array $blocks;
14 |
15 | public function __construct()
16 | {
17 | $this->processor = new BlockProcessor();
18 | $this->blocks = array_merge(
19 | // Get blocks registered via plugins
20 | BlockManager::instance()->getRegisteredBlocks(),
21 | // Get blocks existing in the autodatasource
22 | BlockManager::instance()->getBlocks()->map(function ($block) {
23 | return ['name' => $block->id, 'path' => $block->getFilePath()];
24 | })->pluck('path', 'name')->toArray()
25 | );
26 | }
27 |
28 | /**
29 | * @inheritDoc
30 | */
31 | public function selectOne(string $dirName, string $fileName, string $extension): ?array
32 | {
33 | if ($dirName !== 'blocks' || $extension !== 'block' || !isset($this->blocks[$fileName])) {
34 | return null;
35 | }
36 |
37 | return [
38 | 'fileName' => $fileName . '.' . $extension,
39 | 'content' => file_get_contents($this->blocks[$fileName]),
40 | 'mtime' => filemtime($this->blocks[$fileName]),
41 | ];
42 | }
43 |
44 | /**
45 | * @inheritDoc
46 | */
47 | public function select(string $dirName, array $options = []): array
48 | {
49 | // Prepare query options
50 | $queryOptions = array_merge([
51 | 'columns' => null, // Only return specific columns (fileName, mtime, content)
52 | 'extensions' => null, // Match specified extensions
53 | 'fileMatch' => null, // Match the file name using fnmatch()
54 | 'orders' => null, // @todo
55 | 'limit' => null, // @todo
56 | 'offset' => null // @todo
57 | ], $options);
58 | extract($queryOptions);
59 |
60 | if (isset($columns)) {
61 | if ($columns === ['*'] || !is_array($columns)) {
62 | $columns = null;
63 | } else {
64 | $columns = array_flip($columns);
65 | }
66 | }
67 |
68 | if ($dirName !== 'blocks' || (isset($extensions) && !in_array('block', $extensions))) {
69 | return [];
70 | }
71 |
72 | $result = [];
73 | foreach ($this->blocks as $fileName => $path) {
74 | $item = [
75 | 'fileName' => $fileName . '.block',
76 | ];
77 |
78 | if (!isset($columns) || array_key_exists('content', $columns)) {
79 | $item['content'] = file_get_contents($path);
80 | }
81 |
82 | if (!isset($columns) || array_key_exists('mtime', $columns)) {
83 | $item['mtime'] = filemtime($path);
84 | }
85 |
86 | $result[] = $item;
87 | }
88 |
89 | return $result;
90 | }
91 |
92 | /**
93 | * @inheritDoc
94 | */
95 | public function insert(string $dirName, string $fileName, string $extension, string $content): int
96 | {
97 | throw new SystemException('insert() is not implemented on the BlocksDatasource');
98 | }
99 |
100 | /**
101 | * @inheritDoc
102 | */
103 | public function update(string $dirName, string $fileName, string $extension, string $content, ?string $oldFileName = null, ?string $oldExtension = null): int
104 | {
105 | throw new SystemException('update() is not implemented on the BlocksDatasource');
106 | }
107 |
108 | /**
109 | * @inheritDoc
110 | */
111 | public function delete(string $dirName, string $fileName, string $extension): bool
112 | {
113 | throw new SystemException('delete() is not implemented on the BlocksDatasource');
114 | }
115 |
116 | /**
117 | * @inheritDoc
118 | */
119 | public function lastModified(string $dirName, string $fileName, string $extension): ?int
120 | {
121 | return $this->selectOne($dirName, $fileName, $extension)['mtime'] ?? null;
122 | }
123 |
124 | /**
125 | * @inheritDoc
126 | */
127 | public function makeCacheKey(string $name = ''): string
128 | {
129 | return hash('crc32b', $name);
130 | }
131 |
132 | /**
133 | * @inheritDoc
134 | */
135 | public function getPathsCacheKey(): string
136 | {
137 | return 'halcyon-datastore-blocks-' . md5(json_encode($this->getAvailablePaths()));
138 | }
139 |
140 | /**
141 | * @inheritDoc
142 | */
143 | public function getAvailablePaths(): array
144 | {
145 | $paths = [];
146 | foreach ($this->blocks as $block => $path) {
147 | $paths["blocks/$block.block"] = true;
148 | }
149 | return $paths;
150 | }
151 | }
152 |
--------------------------------------------------------------------------------
/Plugin.php:
--------------------------------------------------------------------------------
1 | 'winter.blocks::lang.plugin.name',
31 | 'description' => 'winter.blocks::lang.plugin.description',
32 | 'author' => 'Winter CMS',
33 | 'icon' => 'icon-cubes',
34 | ];
35 | }
36 |
37 | /**
38 | * Registers the custom Blocks provided by this plugin
39 | */
40 | public function registerBlocks(): array
41 | {
42 | return [
43 | 'button' => '$/winter/blocks/blocks/button.block',
44 | 'button_group' => '$/winter/blocks/blocks/button_group.block',
45 | 'cards' => '$/winter/blocks/blocks/cards.block',
46 | 'code' => '$/winter/blocks/blocks/code.block',
47 | 'columns_two' => '$/winter/blocks/blocks/columns_two.block',
48 | 'divider' => '$/winter/blocks/blocks/divider.block',
49 | 'image' => '$/winter/blocks/blocks/image.block',
50 | 'plaintext' => '$/winter/blocks/blocks/plaintext.block',
51 | 'richtext' => '$/winter/blocks/blocks/richtext.block',
52 | 'title' => '$/winter/blocks/blocks/title.block',
53 | 'video' => '$/winter/blocks/blocks/video.block',
54 | 'vimeo' => '$/winter/blocks/blocks/vimeo.block',
55 | 'youtube' => '$/winter/blocks/blocks/youtube.block',
56 | ];
57 | }
58 |
59 | /**
60 | * Registers the custom FormWidgets provided by this plugin
61 | */
62 | public function registerFormWidgets(): array
63 | {
64 | return [
65 | \Winter\Blocks\FormWidgets\Blocks::class => 'blocks'
66 | ];
67 | }
68 |
69 | /**
70 | * Registers the custom twig markups provided by this plugin
71 | */
72 | public function registerMarkupTags()
73 | {
74 | return [
75 | 'functions' => [
76 | 'renderBlock' => [
77 | function (array $context, string|array $block, array $data = []) {
78 | return BlockModel::render(
79 | $block,
80 | $data,
81 | $context['this']['controller'] ?? null
82 | );
83 | },
84 | 'options' => ['needs_context' => true]
85 | ],
86 | 'renderBlocks' => [
87 | function (array $context, array $blocks) {
88 | return BlockModel::renderAll(
89 | $blocks,
90 | $context['this']['controller'] ?? null
91 | );
92 | },
93 | 'options' => ['needs_context' => true]
94 | ],
95 | ],
96 | ];
97 | }
98 |
99 | /**
100 | * Boot method, called right before the request route.
101 | */
102 | public function boot(): void
103 | {
104 | $this->extendThemeDatasource();
105 | $this->extendControlLibraryBlocks();
106 | }
107 |
108 | /**
109 | * Extend the theme's datasource to include the BlocksDatasource for loading blocks from
110 | */
111 | protected function extendThemeDatasource(): void
112 | {
113 | // Register the block manager instance
114 | BlockManager::instance();
115 | Event::listen('cms.theme.registerHalcyonDatasource', function (Theme $theme, $resolver) {
116 | $source = $theme->getDatasource();
117 | if ($source instanceof AutoDatasource) {
118 | /* @var AutoDatasource $source */
119 | $source->appendDatasource('blocks', new BlocksDatasource());
120 | return;
121 | } else {
122 | $resolver->addDatasource($theme->getDirName(), new AutoDatasource([
123 | 'theme' => $source,
124 | 'blocks' => new BlocksDatasource(),
125 | ], 'blocks-autodatasource'));
126 | }
127 | });
128 | }
129 |
130 | /**
131 | * Extend the ControlLibrary provided by Winter.Builder to register blocks as Form Controls
132 | */
133 | protected function extendControlLibraryBlocks(): void
134 | {
135 | // Register blocks as custom controls
136 | Event::listen('pages.builder.registerControls', function (\Winter\Builder\Classes\ControlLibrary $controlLibrary) {
137 | foreach (BlockManager::instance()->getConfigs('forms') as $key => $config) {
138 | // Map custom fields into standard properties, while ignoring irrelevant properties
139 | $properties = $controlLibrary->getStandardProperties([
140 | 'label', 'required', 'comment', 'placeholder', 'default', 'defaultFrom', 'stretch'
141 | ], array_combine(
142 | array_map(
143 | fn($field) => sprintf('data[%s]', $field),
144 | array_keys($config['fields'] ?? [])
145 | ),
146 | array_values(
147 | array_map(
148 | fn ($field) => array_merge($field, [
149 | 'title' => $field['label'] ?? '',
150 | 'tab' => 'Field Options'
151 | ]),
152 | $config['fields'] ?? []
153 | )
154 | )
155 | ));
156 |
157 | // Sort custom fields to the top
158 | uksort($properties, fn ($a, $b) => str_contains($key, 'data[') ? 1 : $a <=> $b);
159 |
160 | $controlLibrary->registerControl(
161 | Block::TYPE_PREFIX . $key,
162 | $config['name'],
163 | $config['description'],
164 | Block::GROUP_BLOCKS,
165 | $config['icon'],
166 | $properties,
167 | null
168 | );
169 | }
170 | }, PHP_INT_MIN);
171 |
172 | // Register a Winter\Blocks\FormWidgets\Block FormWidget under each block's key
173 | WidgetManager::instance()->registerFormWidgets(function ($manager) {
174 | foreach (BlockManager::instance()->getConfigs() as $key => $config) {
175 | $manager->registerFormWidget(Block::class, Block::TYPE_PREFIX . $key);
176 | }
177 | });
178 | }
179 | }
180 |
--------------------------------------------------------------------------------
/classes/Block.php:
--------------------------------------------------------------------------------
1 | partialStack = new PartialStack();
36 | parent::__construct($attributes);
37 | }
38 |
39 | /**
40 | * Renders the provided block
41 | */
42 | public static function render(string|array $block, array $data = [], ?Controller $controller = null): string
43 | {
44 | if (!$controller) {
45 | $controller = new Controller();
46 | }
47 |
48 | if (is_array($block)) {
49 | $data = $block;
50 | $block = $data['_group'] ?? false;
51 | }
52 |
53 | if (empty($block)) {
54 | throw new SystemException("The block name was not provided");
55 | }
56 |
57 | $partialData = [];
58 |
59 | foreach ($data as $key => $value) {
60 | if (in_array($key, ['_group', '_config'])) {
61 | continue;
62 | }
63 |
64 | $partialData[$key] = $value;
65 | }
66 |
67 | // Allow data to be accessed via "data" key, for backwards compatibility.
68 | $partialData['data'] = $partialData;
69 |
70 | if (!empty($data['_config'])) {
71 | $partialData['config'] = json_decode($data['_config']);
72 | } else {
73 | $partialData['config'] = static::getDefaultConfig($block);
74 | }
75 |
76 | return $controller->renderPartial($block . '.block', $partialData);
77 | }
78 |
79 | /**
80 | * Renders the provided blocks
81 | */
82 | public static function renderAll(array $blocks, ?Controller $controller = null): string
83 | {
84 | $content = '';
85 | $controller ??= (new Controller());
86 |
87 | foreach ($blocks as $i => $block) {
88 | if (!array_key_exists('_group', $block)) {
89 | throw new SystemException("The block definition at index $i must contain a `_group` key.");
90 | }
91 |
92 | $partialData = [];
93 |
94 | foreach ($block as $key => $value) {
95 | if (in_array($key, ['_group', '_config'])) {
96 | continue;
97 | }
98 |
99 | $partialData[$key] = $value;
100 | }
101 |
102 | // Allow data to be accessed via "data" key, for backwards compatibility.
103 | $partialData['data'] = $partialData;
104 |
105 | if (!empty($block['_config'])) {
106 | $config = json_decode($block['_config']);
107 | } else {
108 | $config = static::getDefaultConfig($block['_group']);
109 | }
110 |
111 | $partialData['config'] = json_decode(json_encode($config), true);
112 |
113 | $content .= $controller->renderPartial($block['_group'] . '.block', $partialData);
114 | }
115 |
116 | return $content;
117 | }
118 |
119 | /**
120 | * Returns name of a PHP class to us a parent for the PHP class created for the object's PHP section.
121 | */
122 | public function getCodeClassParent(): string
123 | {
124 | return BlockCode::class;
125 | }
126 |
127 | /**
128 | * Get a new query builder for the object
129 | * @return \Winter\Storm\Halcyon\Builder
130 | */
131 | public function newQuery()
132 | {
133 | $datasource = $this->getDatasource();
134 |
135 | $query = new BlockBuilder($datasource, new BlockProcessor());
136 |
137 | return $query->setModel($this);
138 | }
139 |
140 | /**
141 | * Execute the lifecycle of the partial manually. Usually this would only happen for cms partials (i.e. component
142 | * partials), but this method enables this functionality for blocks
143 | */
144 | public function executeLifecycle(Controller $controller): static
145 | {
146 | $this->partialStack->stackPartial();
147 |
148 | $manager = ComponentManager::instance();
149 |
150 | foreach ($this->components as $component => $properties) {
151 | // Do not inject the viewBag component to the environment.
152 | // Not sure if they're needed there by the requirements,
153 | // but there were problems with array-typed properties used by Static Pages
154 | // snippets and setComponentPropertiesFromParams(). --ab
155 | if ($component == 'viewBag') {
156 | continue;
157 | }
158 |
159 | list($name, $alias) = strpos($component, ' ')
160 | ? explode(' ', $component)
161 | : [$component, $component];
162 |
163 | if (!$componentObj = $manager->makeComponent($name, $this, $properties)) {
164 | throw new SystemException(Lang::get('cms::lang.component.not_found', ['name'=>$name]));
165 | }
166 |
167 | $componentObj->alias = $alias;
168 | $parameters[$alias] = $this->components[$alias] = $componentObj;
169 |
170 | $this->partialStack->addComponent($alias, $componentObj);
171 |
172 | $this->setComponentPropertiesFromParams($componentObj, $parameters);
173 | $componentObj->init();
174 | }
175 |
176 | CmsException::mask($this->page, 300);
177 | $parser = new CodeParser($this);
178 | $partialObj = $parser->source(
179 | $controller->getPage() ?: new Page(),
180 | $controller->getLayout() ?: new Layout(),
181 | $controller
182 | );
183 | CmsException::unmask();
184 |
185 | CmsException::mask($this, 300);
186 | $partialObj->onStart();
187 | $this->runComponents();
188 | $partialObj->onEnd();
189 | CmsException::unmask();
190 |
191 | return $this;
192 | }
193 |
194 | /**
195 | * Gets the default config for the provided block, if no user-defined config is available.
196 | */
197 | private static function getDefaultConfig(string $block): ?array
198 | {
199 | $config = BlockManager::instance()->getConfig($block);
200 |
201 | if (!is_array($config) || !array_key_exists('config', $config)) {
202 | return null;
203 | }
204 |
205 | $defaults = [];
206 |
207 | foreach ($config['config'] as $configKey => $configData) {
208 | $defaults[$configKey] = $configData['default'] ?? null;
209 | }
210 |
211 | return $defaults;
212 | }
213 | }
214 |
--------------------------------------------------------------------------------
/formwidgets/blocks/assets/css/blocks.css:
--------------------------------------------------------------------------------
1 | .field-blocks{padding-top:5px}.field-blocks .field-repeater-items{counter-reset:repeater-index-counter}.field-blocks li.field-repeater-item,.field-blocks ul.field-repeater-items{list-style:none;margin:0;padding:0}.field-blocks ul.field-repeater-items>li.dragged{background-color:#f9f9f9;border:1px dashed #dbdee0;opacity:.7;padding-right:15px;padding-top:15px;position:absolute;z-index:2000}.field-blocks ul.field-repeater-items>li.dragged .repeater-item-remove{opacity:0}.field-blocks ul.field-repeater-items>li.dragged .repeater-item-collapsed-title{top:5px}.field-blocks ul.field-repeater-items>li.placeholder{display:block;height:25px;margin-bottom:5px;position:relative}.field-blocks ul.field-repeater-items>li.placeholder:before{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;color:#d35714;content:"\f054";display:block;font-family:Font Awesome\ 6 Free;font-style:normal;font-variant:normal;font-weight:900;left:-10px;position:absolute;text-rendering:auto;top:8px;z-index:2000}.field-blocks li.field-repeater-item{background:#f9f9f9;border:1px solid #d1d6d9;border-radius:3px;margin:0 0 1em!important;min-height:30px;padding:3.5em 1.25em 0!important;position:relative}.field-blocks li.field-repeater-item.collapsed,.field-blocks li.field-repeater-item.empty{padding:0!important}.field-blocks li.field-repeater-item.collapsed .field-repeater-form,.field-blocks li.field-repeater-item.empty .field-repeater-form{display:none}.field-blocks li.field-repeater-item.collapsed .repeater-item-collapse .repeater-item-collapse-one,.field-blocks li.field-repeater-item.empty .repeater-item-collapse .repeater-item-collapse-one{transform:rotate(180deg)}.field-blocks li.field-repeater-item.collapsed .repeater-item-title,.field-blocks li.field-repeater-item.empty .repeater-item-title{border-bottom:none;border-bottom-left-radius:3px;border-bottom-right-radius:0;display:inline-block;height:100%}.field-blocks li.field-repeater-item.collapsed>.repeater-item-collapse,.field-blocks li.field-repeater-item.collapsed>.repeater-item-remove,.field-blocks li.field-repeater-item.empty>.repeater-item-collapse,.field-blocks li.field-repeater-item.empty>.repeater-item-remove{opacity:1}.field-blocks li.field-repeater-item .repeater-item-collapse{opacity:0;position:absolute;right:30px;top:5px;transition:opacity .5s;z-index:90}.field-blocks li.field-repeater-item .repeater-item-collapse a,.field-blocks li.field-repeater-item .repeater-item-collapse button{color:#bdc3c7;display:block;font-size:12px;line-height:20px;transition:transform .3s}.field-blocks li.field-repeater-item .repeater-item-collapse a:focus,.field-blocks li.field-repeater-item .repeater-item-collapse a:hover,.field-blocks li.field-repeater-item .repeater-item-collapse button:focus,.field-blocks li.field-repeater-item .repeater-item-collapse button:hover{color:#999;text-decoration:none}.field-blocks li.field-repeater-item .repeater-item-remove{opacity:0;position:absolute;right:5px;top:4px;transition:opacity .5s;z-index:90}.field-blocks li.field-repeater-item .repeater-item-remove.disabled{display:none}.field-blocks li.field-repeater-item .repeater-item-remove.disabled+.repeater-item-collapse{right:7px}.field-blocks li.field-repeater-item .repeater-item-remove .close{display:inline-block;float:none}.field-blocks li.field-repeater-item .block-config{color:#bdc3c7;display:block;font-size:12px;line-height:20px;opacity:0;position:absolute;right:60px;top:4px;transition:opacity .5s;z-index:90}.field-blocks li.field-repeater-item .block-config.inspector-open,.field-blocks li.field-repeater-item .block-config:focus,.field-blocks li.field-repeater-item .block-config:hover{color:#999;text-decoration:none}.field-blocks li.field-repeater-item .repeater-item-collapse,.field-blocks li.field-repeater-item .repeater-item-remove{height:20px;text-align:center;width:20px}.field-blocks li.field-repeater-item .repeater-item-collapse>a,.field-blocks li.field-repeater-item .repeater-item-collapse>button,.field-blocks li.field-repeater-item .repeater-item-remove>a,.field-blocks li.field-repeater-item .repeater-item-remove>button{outline:none}.field-blocks li.field-repeater-item .repeater-item-collapsed-handle{position:absolute;top:0;inset-inline:0}.field-blocks li.field-repeater-item .repeater-item-title{background:#fff;border-bottom:1px solid #d1d6d9;border-bottom-right-radius:3px;border-right:1px solid #d1d6d9;border-top-left-radius:3px;color:rgba(56,84,135,.5);font-size:13px;left:0;padding:4px 8px;position:absolute;top:0}.field-blocks li.field-repeater-item .repeater-item-title>.icon{margin-right:4px}.field-blocks li.field-repeater-item .repeater-item-handle{cursor:move}.field-blocks li.field-repeater-item.hover{border:1px solid #999}.field-blocks li.field-repeater-item.hover>.repeater-item-title{border-color:#999;color:#999}.field-blocks li.field-repeater-item.focus{border:1px solid #4ea5e0!important}.field-blocks li.field-repeater-item.focus>.repeater-item-title{border-color:#4ea5e0!important;color:#4ea5e0!important}.field-blocks li.field-repeater-item.focus>.block-config,.field-blocks li.field-repeater-item.focus>.repeater-item-collapse,.field-blocks li.field-repeater-item.focus>.repeater-item-handle,.field-blocks li.field-repeater-item.focus>.repeater-item-remove,.field-blocks li.field-repeater-item.hover>.block-config,.field-blocks li.field-repeater-item.hover>.repeater-item-collapse,.field-blocks li.field-repeater-item.hover>.repeater-item-handle,.field-blocks li.field-repeater-item.hover>.repeater-item-remove{opacity:1}@media (hover:none){.field-blocks li.field-repeater-item>.block-config,.field-blocks li.field-repeater-item>.repeater-item-collapse,.field-blocks li.field-repeater-item>.repeater-item-handle,.field-blocks li.field-repeater-item>.repeater-item-remove{opacity:1!important}}.field-blocks li.field-repeater-item .field-repeater-form{position:relative;top:-7px}.field-blocks li.field-repeater-item .field-repeater-form:after,.field-blocks li.field-repeater-item .field-repeater-form:before{content:" ";display:table}.field-blocks li.field-repeater-item .field-repeater-form:after{clear:both}.field-blocks li.field-repeater-item .field-repeater-form .form-group.span-left,.field-blocks li.field-repeater-item .field-repeater-form .form-group.span-right{width:49.5%}.field-blocks .field-repeater-add-item{margin-top:10px;position:relative}.field-blocks .field-repeater-add-item>a{border:1px dashed #bdc3c7;border-radius:5px;color:#bdc3c7;display:block;font-size:12px;font-weight:600;outline:none;padding:13px 15px;text-align:center;text-decoration:none;text-transform:uppercase;transition:border-color .5s,color .5s}.field-blocks .field-repeater-add-item>a:focus,.field-blocks .field-repeater-add-item>a:hover{border-color:#4ea5e0;color:#4ea5e0}.field-blocks .field-repeater-add-item>a:active{border-color:#3498db;color:#3498db}.field-blocks .field-repeater-add-item.in-progress>a{background:transparent!important;border-color:#e0e0e0!important}.field-blocks[data-mode=grid]{container-type:inline-size}.field-blocks[data-mode=grid] ul.field-repeater-items{display:grid;gap:20px}.field-blocks[data-mode=grid] ul.field-repeater-items .field-repeater-item{margin-bottom:0!important}.field-blocks[data-mode=grid] ul.field-repeater-items .field-repeater-add-item{margin-top:0}.field-blocks[data-mode=grid] ul.field-repeater-items .field-repeater-add-item a{display:flex;flex-direction:column;height:100%;justify-content:center}.field-blocks[data-mode=grid] ul.field-repeater-items .field-repeater-add-item:before{display:none}.field-blocks[data-mode=grid] ul.field-repeater-items .block-config{right:30px}.field-blocks[data-mode=grid][data-columns="2"] ul.field-repeater-items{grid-template-columns:repeat(2,1fr)}.field-blocks[data-mode=grid][data-columns="3"] ul.field-repeater-items{grid-template-columns:repeat(3,1fr)}.field-blocks[data-mode=grid][data-columns="4"] ul.field-repeater-items{grid-template-columns:repeat(4,1fr)}@media (max-width:1600px){.field-blocks[data-mode=grid][data-columns="4"] ul.field-repeater-items{grid-template-columns:repeat(3,1fr)}}.field-blocks[data-mode=grid][data-columns="5"] ul.field-repeater-items{grid-template-columns:repeat(5,1fr)}@media (max-width:1600px){.field-blocks[data-mode=grid][data-columns="5"] ul.field-repeater-items{grid-template-columns:repeat(4,1fr)}}.field-blocks[data-mode=grid][data-columns="6"] ul.field-repeater-items{grid-template-columns:repeat(6,1fr)}@media (max-width:1600px){.field-blocks[data-mode=grid][data-columns="6"] ul.field-repeater-items{grid-template-columns:repeat(4,1fr)}}@media (min-width:768px) and (max-width:1199px){.field-blocks[data-mode=grid][data-columns="3"] ul.field-repeater-items,.field-blocks[data-mode=grid][data-columns="4"] ul.field-repeater-items,.field-blocks[data-mode=grid][data-columns="5"] ul.field-repeater-items,.field-blocks[data-mode=grid][data-columns="6"] ul.field-repeater-items{grid-template-columns:repeat(2,1fr)}}@media (max-width:767px){.field-blocks[data-mode=grid] ul.field-repeater-items{grid-template-columns:1fr!important}.field-blocks[data-mode=grid] ul.field-repeater-items .field-repeater-add-item,.field-blocks[data-mode=grid] ul.field-repeater-items .field-repeater-item{min-height:0!important}.field-blocks[data-mode=grid] ul.field-repeater-items .field-repeater-add-item{margin-top:10px}.field-blocks[data-mode=grid] ul.field-repeater-items .field-repeater-add-item:before{display:block}}@container (width < 800px){[data-columns="2"] ul.field-repeater-items,[data-columns="3"] ul.field-repeater-items,[data-columns="4"] ul.field-repeater-items,[data-columns="5"] ul.field-repeater-items,[data-columns="6"] ul.field-repeater-items{grid-template-columns:1fr!important}}@container (width > 800px) and (width < 1200px){[data-columns="3"] ul.field-repeater-items,[data-columns="4"] ul.field-repeater-items,[data-columns="5"] ul.field-repeater-items,[data-columns="6"] ul.field-repeater-items{grid-template-columns:repeat(2,1fr)!important}}@container (width >= 1200px) and (width < 1600px){[data-columns="4"] ul.field-repeater-items,[data-columns="5"] ul.field-repeater-items,[data-columns="6"] ul.field-repeater-items{grid-template-columns:repeat(3,1fr)!important}}
2 |
--------------------------------------------------------------------------------
/assets/dist/js/blocks.js:
--------------------------------------------------------------------------------
1 | /*! For license information please see blocks.js.LICENSE.txt */
2 | (()=>{"use strict";var t,r={331:()=>{function t(r){return t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},t(r)}function r(){r=function(){return e};var e={},n=Object.prototype,o=n.hasOwnProperty,i=Object.defineProperty||function(t,r,e){t[r]=e.value},a="function"==typeof Symbol?Symbol:{},c=a.iterator||"@@iterator",u=a.asyncIterator||"@@asyncIterator",f=a.toStringTag||"@@toStringTag";function l(t,r,e){return Object.defineProperty(t,r,{value:e,enumerable:!0,configurable:!0,writable:!0}),t[r]}try{l({},"")}catch(t){l=function(t,r,e){return t[r]=e}}function s(t,r,e,n){var o=r&&r.prototype instanceof y?r:y,a=Object.create(o.prototype),c=new _(n||[]);return i(a,"_invoke",{value:x(t,e,c)}),a}function p(t,r,e){try{return{type:"normal",arg:t.call(r,e)}}catch(t){return{type:"throw",arg:t}}}e.wrap=s;var h={};function y(){}function v(){}function d(){}var w={};l(w,c,(function(){return this}));var b=Object.getPrototypeOf,g=b&&b(b(S([])));g&&g!==n&&o.call(g,c)&&(w=g);var m=d.prototype=y.prototype=Object.create(w);function O(t){["next","throw","return"].forEach((function(r){l(t,r,(function(t){return this._invoke(r,t)}))}))}function j(r,e){function n(i,a,c,u){var f=p(r[i],r,a);if("throw"!==f.type){var l=f.arg,s=l.value;return s&&"object"==t(s)&&o.call(s,"__await")?e.resolve(s.__await).then((function(t){n("next",t,c,u)}),(function(t){n("throw",t,c,u)})):e.resolve(s).then((function(t){l.value=t,c(l)}),(function(t){return n("throw",t,c,u)}))}u(f.arg)}var a;i(this,"_invoke",{value:function(t,r){function o(){return new e((function(e,o){n(t,r,e,o)}))}return a=a?a.then(o,o):o()}})}function x(t,r,e){var n="suspendedStart";return function(o,i){if("executing"===n)throw new Error("Generator is already running");if("completed"===n){if("throw"===o)throw i;return k()}for(e.method=o,e.arg=i;;){var a=e.delegate;if(a){var c=E(a,e);if(c){if(c===h)continue;return c}}if("next"===e.method)e.sent=e._sent=e.arg;else if("throw"===e.method){if("suspendedStart"===n)throw n="completed",e.arg;e.dispatchException(e.arg)}else"return"===e.method&&e.abrupt("return",e.arg);n="executing";var u=p(t,r,e);if("normal"===u.type){if(n=e.done?"completed":"suspendedYield",u.arg===h)continue;return{value:u.arg,done:e.done}}"throw"===u.type&&(n="completed",e.method="throw",e.arg=u.arg)}}}function E(t,r){var e=r.method,n=t.iterator[e];if(void 0===n)return r.delegate=null,"throw"===e&&t.iterator.return&&(r.method="return",r.arg=void 0,E(t,r),"throw"===r.method)||"return"!==e&&(r.method="throw",r.arg=new TypeError("The iterator does not provide a '"+e+"' method")),h;var o=p(n,t.iterator,r.arg);if("throw"===o.type)return r.method="throw",r.arg=o.arg,r.delegate=null,h;var i=o.arg;return i?i.done?(r[t.resultName]=i.value,r.next=t.nextLoc,"return"!==r.method&&(r.method="next",r.arg=void 0),r.delegate=null,h):i:(r.method="throw",r.arg=new TypeError("iterator result is not an object"),r.delegate=null,h)}function P(t){var r={tryLoc:t[0]};1 in t&&(r.catchLoc=t[1]),2 in t&&(r.finallyLoc=t[2],r.afterLoc=t[3]),this.tryEntries.push(r)}function L(t){var r=t.completion||{};r.type="normal",delete r.arg,t.completion=r}function _(t){this.tryEntries=[{tryLoc:"root"}],t.forEach(P,this),this.reset(!0)}function S(t){if(t){var r=t[c];if(r)return r.call(t);if("function"==typeof t.next)return t;if(!isNaN(t.length)){var e=-1,n=function r(){for(;++e=0;--n){var i=this.tryEntries[n],a=i.completion;if("root"===i.tryLoc)return e("end");if(i.tryLoc<=this.prev){var c=o.call(i,"catchLoc"),u=o.call(i,"finallyLoc");if(c&&u){if(this.prev=0;--e){var n=this.tryEntries[e];if(n.tryLoc<=this.prev&&o.call(n,"finallyLoc")&&this.prev=0;--r){var e=this.tryEntries[r];if(e.finallyLoc===t)return this.complete(e.completion,e.afterLoc),L(e),h}},catch:function(t){for(var r=this.tryEntries.length-1;r>=0;--r){var e=this.tryEntries[r];if(e.tryLoc===t){var n=e.completion;if("throw"===n.type){var o=n.arg;L(e)}return o}}throw new Error("illegal catch attempt")},delegateYield:function(t,r,e){return this.delegate={iterator:S(t),resultName:r,nextLoc:e},"next"===this.method&&(this.arg=void 0),h}},e}function e(t,r,e,n,o,i,a){try{var c=t[i](a),u=c.value}catch(t){return void e(t)}c.done?r(u):Promise.resolve(u).then(n,o)}function n(t,r){var e=Object.keys(t);if(Object.getOwnPropertySymbols){var n=Object.getOwnPropertySymbols(t);r&&(n=n.filter((function(r){return Object.getOwnPropertyDescriptor(t,r).enumerable}))),e.push.apply(e,n)}return e}function o(t){for(var r=1;r{}},e={};function n(t){var o=e[t];if(void 0!==o)return o.exports;var i=e[t]={exports:{}};return r[t](i,i.exports,n),i.exports}n.m=r,t=[],n.O=(r,e,o,i)=>{if(!e){var a=1/0;for(l=0;l=i)&&Object.keys(n.O).every((t=>n.O[t](e[u])))?e.splice(u--,1):(c=!1,i0&&t[l-1][2]>i;l--)t[l]=t[l-1];t[l]=[e,o,i]},n.o=(t,r)=>Object.prototype.hasOwnProperty.call(t,r),(()=>{var t={983:0,488:0};n.O.j=r=>0===t[r];var r=(r,e)=>{var o,i,[a,c,u]=e,f=0;if(a.some((r=>0!==t[r]))){for(o in c)n.o(c,o)&&(n.m[o]=c[o]);if(u)var l=u(n)}for(r&&r(e);fn(331)));var o=n.O(void 0,[488],(()=>n(60)));o=n.O(o)})();
--------------------------------------------------------------------------------
/formwidgets/blocks/assets/less/blocks.less:
--------------------------------------------------------------------------------
1 | // out: false
2 |
3 | @import "../../../../../../../modules/backend/assets/less/core/boot.less";
4 |
5 | .field-blocks {
6 | padding-top: 5px;
7 |
8 | .field-repeater-items {
9 | counter-reset: repeater-index-counter;
10 | }
11 |
12 | ul.field-repeater-items,
13 | li.field-repeater-item {
14 | padding: 0;
15 | margin: 0;
16 | list-style: none;
17 | }
18 |
19 | ul.field-repeater-items > li {
20 | &.dragged {
21 | opacity: .7;
22 | position: absolute;
23 | padding-top: 15px;
24 | padding-right: 15px;
25 | z-index: 2000;
26 | background-color: @body-bg;
27 | border: 1px dashed #dbdee0;
28 |
29 | .repeater-item-remove {
30 | opacity: 0;
31 | }
32 |
33 | .repeater-item-collapsed-title {
34 | top: 5px;
35 | }
36 | }
37 |
38 | &.placeholder {
39 | display: block;
40 | position: relative;
41 | height: 25px;
42 | margin-bottom: 5px;
43 | &:before {
44 | display: block;
45 | position: absolute;
46 | .icon(@chevron-right);
47 | color: #d35714;
48 | left: -10px;
49 | top: 8px;
50 | z-index: 2000;
51 | }
52 | }
53 | }
54 |
55 | li.field-repeater-item {
56 | position: relative;
57 | margin: 0 0 1em !important;
58 | padding: 3.5em 1.25em 0 1.25em !important;
59 | border: 1px solid @input-border;
60 | border-radius: @border-radius-base;
61 | background: @body-bg;
62 | min-height: 30px;
63 |
64 | &.collapsed,
65 | &.empty {
66 | padding: 0 !important;
67 |
68 | .field-repeater-form {
69 | display:none;
70 | }
71 |
72 | .repeater-item-collapse {
73 | .repeater-item-collapse-one {
74 | .transform(rotate(180deg));
75 | }
76 | }
77 |
78 | .repeater-item-title {
79 | display: inline-block;
80 | border-bottom: none;
81 | border-bottom-right-radius: 0;
82 | border-bottom-left-radius: @border-radius-base;
83 | height: 100%;
84 | }
85 |
86 | > .repeater-item-collapse,
87 | > .repeater-item-remove {
88 | opacity: 1;
89 | }
90 | }
91 |
92 | .repeater-item-collapse {
93 | position: absolute;
94 | top: 5px;
95 | right: 30px;
96 | z-index: 90;
97 | opacity: 0;
98 | .transition(~'opacity 0.5s');
99 |
100 | a, button {
101 | .transition(~'transform 0.3s');
102 | color: #bdc3c7;
103 | line-height: 20px;
104 | display: block;
105 | font-size: 12px;
106 |
107 | &:hover,
108 | &:focus {
109 | color: #999;
110 | text-decoration: none;
111 | }
112 | }
113 | }
114 |
115 | .repeater-item-remove {
116 | position: absolute;
117 | top: 4px;
118 | right: 5px;
119 | z-index: 90;
120 | opacity: 0;
121 | .transition(~'opacity 0.5s');
122 |
123 | &.disabled {
124 | display: none;
125 |
126 | + .repeater-item-collapse {
127 | right: 7px;
128 | }
129 | }
130 |
131 | .close {
132 | float: none;
133 | display: inline-block;
134 | }
135 | }
136 |
137 | .block-config {
138 | position: absolute;
139 | top: 4px;
140 | right: 60px;
141 | z-index: 90;
142 | opacity: 0;
143 | .transition(~'opacity 0.5s');
144 |
145 | color: #bdc3c7;
146 | line-height: 20px;
147 | display: block;
148 | font-size: 12px;
149 |
150 | &:hover,
151 | &:focus,
152 | &.inspector-open {
153 | color: #999;
154 | text-decoration: none;
155 | }
156 | }
157 |
158 | .repeater-item-collapse,
159 | .repeater-item-remove {
160 | width: 20px;
161 | height: 20px;
162 | text-align: center;
163 |
164 | > button,
165 | > a {
166 | outline: none;
167 | }
168 | }
169 |
170 | .repeater-item-collapsed-handle {
171 | position: absolute;
172 | top: 0;
173 | inset-inline: 0;
174 | }
175 |
176 | .repeater-item-title {
177 | position: absolute;
178 | font-size: 13px;
179 | top: 0px;
180 | left: 0px;
181 | padding: 4px 8px;
182 | border-bottom: 1px solid @input-border;
183 | border-right: 1px solid @input-border;
184 | border-top-left-radius: @border-radius-base;
185 | border-bottom-right-radius: @border-radius-base;
186 | background: #fff;
187 | color: fadeout(@input-color, 50);
188 |
189 | > .icon {
190 | margin-right: 4px;
191 | }
192 | }
193 |
194 | .repeater-item-handle {
195 | cursor: move;
196 | }
197 |
198 | &.hover {
199 | border: 1px solid #999;
200 |
201 | > .repeater-item-title {
202 | color: #999;
203 | border-color: #999;
204 | }
205 | }
206 |
207 | &.focus {
208 | border: 1px solid @highlight-hover-bg !important;
209 |
210 | > .repeater-item-title {
211 | color: @highlight-hover-bg !important;
212 | border-color: @highlight-hover-bg !important;
213 | }
214 | }
215 |
216 | &.hover,
217 | &.focus {
218 | > .block-config,
219 | > .repeater-item-collapse,
220 | > .repeater-item-handle,
221 | > .repeater-item-remove {
222 | opacity: 1;
223 | }
224 | }
225 |
226 | @media (hover: none) {
227 | > .block-config,
228 | > .repeater-item-collapse,
229 | > .repeater-item-handle,
230 | > .repeater-item-remove {
231 | opacity: 1 !important;
232 | }
233 | }
234 |
235 | .field-repeater-form {
236 | position: relative;
237 | top: -7px;
238 | .clearfix;
239 |
240 | .form-group.span-left,
241 | .form-group.span-right {
242 | width: 49.5%;
243 | }
244 | }
245 | }
246 |
247 | .field-repeater-add-item {
248 | position: relative;
249 | margin-top: 10px;
250 |
251 | > a {
252 | border: 1px dashed #bdc3c7;
253 | border-radius: 5px;
254 | color: #bdc3c7;
255 | text-align: center;
256 | display: block;
257 | text-decoration: none;
258 | padding: 13px 15px;
259 | text-transform: uppercase;
260 | font-weight: 600;
261 | font-size: @font-size-base - 2;
262 | outline: none;
263 | .transition(~'border-color 0.5s, color 0.5s');
264 |
265 | &:hover, &:focus {
266 | border-color: @highlight-hover-bg;
267 | color: @highlight-hover-bg;
268 | }
269 |
270 | &:active {
271 | border-color: @highlight-active-bg;
272 | color: @highlight-active-bg;
273 | }
274 | }
275 |
276 | &.in-progress > a {
277 | border-color: #e0e0e0 !important;
278 | background: transparent !important;
279 | }
280 | }
281 |
282 | &[data-mode="grid"] {
283 | container-type: inline-size;
284 |
285 | ul.field-repeater-items {
286 | display: grid;
287 | gap: 20px;
288 |
289 | .field-repeater-item {
290 | margin-bottom: 0 !important;
291 | }
292 |
293 | .field-repeater-add-item {
294 | margin-top: 0;
295 |
296 | a {
297 | display: flex;
298 | flex-direction: column;
299 | justify-content: center;
300 | height: 100%;
301 | }
302 |
303 | &:before {
304 | display: none;
305 | }
306 | }
307 |
308 | .block-config {
309 | right: 30px;
310 | }
311 | }
312 |
313 | &[data-columns="2"] ul.field-repeater-items {
314 | grid-template-columns: repeat(2, 1fr);
315 | }
316 | &[data-columns="3"] ul.field-repeater-items {
317 | grid-template-columns: repeat(3, 1fr);
318 | }
319 | &[data-columns="4"] ul.field-repeater-items {
320 | grid-template-columns: repeat(4, 1fr);
321 |
322 | @media (max-width: 1600px) {
323 | grid-template-columns: repeat(3, 1fr);
324 | }
325 | }
326 | &[data-columns="5"] ul.field-repeater-items {
327 | grid-template-columns: repeat(5, 1fr);
328 |
329 | @media (max-width: 1600px) {
330 | grid-template-columns: repeat(4, 1fr);
331 | }
332 | }
333 | &[data-columns="6"] ul.field-repeater-items {
334 | grid-template-columns: repeat(6, 1fr);
335 |
336 | @media (max-width: 1600px) {
337 | grid-template-columns: repeat(4, 1fr);
338 | }
339 | }
340 |
341 | @media (min-width: @screen-sm-min) and (max-width: @screen-md-max) {
342 | &[data-columns="3"] ul.field-repeater-items,
343 | &[data-columns="4"] ul.field-repeater-items,
344 | &[data-columns="5"] ul.field-repeater-items,
345 | &[data-columns="6"] ul.field-repeater-items {
346 | grid-template-columns: repeat(2, 1fr);
347 | }
348 | }
349 |
350 | @media (max-width: @screen-xs-max) {
351 | ul.field-repeater-items {
352 | grid-template-columns: 1fr !important;
353 |
354 | .field-repeater-item,
355 | .field-repeater-add-item {
356 | min-height: 0 !important;
357 | }
358 |
359 | .field-repeater-add-item {
360 | margin-top: 10px;
361 |
362 | &::before {
363 | display: block;
364 | }
365 | }
366 | }
367 | }
368 | }
369 | }
370 |
371 | @container (width < 800px) {
372 | &[data-columns="2"] ul.field-repeater-items,
373 | &[data-columns="3"] ul.field-repeater-items,
374 | &[data-columns="4"] ul.field-repeater-items,
375 | &[data-columns="5"] ul.field-repeater-items,
376 | &[data-columns="6"] ul.field-repeater-items {
377 | grid-template-columns: 1fr !important;
378 | }
379 | }
380 |
381 | @container (width > 800px) and (width < 1200px) {
382 | &[data-columns="3"] ul.field-repeater-items,
383 | &[data-columns="4"] ul.field-repeater-items,
384 | &[data-columns="5"] ul.field-repeater-items,
385 | &[data-columns="6"] ul.field-repeater-items {
386 | grid-template-columns: repeat(2, 1fr) !important;
387 | }
388 | }
389 |
390 | @container (width >= 1200px) and (width < 1600px) {
391 | &[data-columns="4"] ul.field-repeater-items,
392 | &[data-columns="5"] ul.field-repeater-items,
393 | &[data-columns="6"] ul.field-repeater-items {
394 | grid-template-columns: repeat(3, 1fr) !important;
395 | }
396 | }
397 |
--------------------------------------------------------------------------------
/formwidgets/Blocks.php:
--------------------------------------------------------------------------------
1 | fillFromConfig([
41 | 'ignore',
42 | 'allow',
43 | 'tags',
44 | ]);
45 |
46 | parent::init();
47 | }
48 |
49 | /**
50 | * {@inheritDoc}
51 | */
52 | protected function loadAssets()
53 | {
54 | $this->addCss('css/blocks.css', 'Winter.Blocks');
55 | $this->addJs('js/blocks.js', 'Winter.Blocks');
56 | }
57 |
58 | /**
59 | * {@inheritDoc}
60 | */
61 | public function render()
62 | {
63 | $this->prepareVars();
64 | return $this->makePartial('block');
65 | }
66 |
67 | /**
68 | * Splices in some meta data (group and index values) to the dataset.
69 | * @param array|mixed $value
70 | * @return array|mixed
71 | */
72 | protected function processSaveValue($value)
73 | {
74 | if (!is_array($value) || !$value) {
75 | return null;
76 | }
77 |
78 | $count = count($value);
79 |
80 | if ($this->minItems && $count < $this->minItems) {
81 | throw new ApplicationException(Lang::get('backend::lang.repeater.min_items_failed', [
82 | 'name' => $this->fieldName,
83 | 'min' => $this->minItems,
84 | 'items' => $count,
85 | ]));
86 | }
87 | if ($this->maxItems && $count > $this->maxItems) {
88 | throw new ApplicationException(Lang::get('backend::lang.repeater.max_items_failed', [
89 | 'name' => $this->fieldName,
90 | 'max' => $this->maxItems,
91 | 'items' => $count,
92 | ]));
93 | }
94 |
95 | /*
96 | * Give repeated form field widgets an opportunity to process the data.
97 | */
98 | foreach ($value as $index => $data) {
99 | if (isset($this->formWidgets[$index])) {
100 | $value[$index] = array_merge($this->formWidgets[$index]->getSaveData(), [
101 | '_group' => $data['_group'],
102 | '_config' => (!empty($data['_config'])) ? $data['_config'] : null,
103 | ]);
104 | }
105 | }
106 |
107 | return array_values($value);
108 | }
109 |
110 | /**
111 | * {@inheritDoc}
112 | */
113 | protected function processItems()
114 | {
115 | $currentValue = ($this->loaded === true)
116 | ? post($this->formField->getName())
117 | : $this->getLoadValue();
118 |
119 | // Detect when a child widget is trying to run an AJAX handler
120 | // outside of the form element that contains all the repeater
121 | // fields that would normally be used to identify that case
122 | $handler = $this->controller->getAjaxHandler();
123 | if (!$this->loaded && starts_with($handler, $this->alias . 'Form')) {
124 | // Attempt to get the index of the repeater
125 | $handler = str_after($handler, $this->alias . 'Form');
126 | preg_match("~^(\d+)~", $handler, $matches);
127 |
128 | if (isset($matches[1])) {
129 | $index = $matches[1];
130 | $this->makeItemFormWidget($index);
131 | }
132 | }
133 |
134 | // Ensure that the minimum number of items are preinitialized
135 | // ONLY DONE WHEN NOT IN GROUP MODE
136 | if (!$this->useGroups && $this->minItems > 0) {
137 | if (!is_array($currentValue)) {
138 | $currentValue = [];
139 | for ($i = 0; $i < $this->minItems; $i++) {
140 | $currentValue[$i] = [];
141 | }
142 | } elseif (count($currentValue) < $this->minItems) {
143 | for ($i = 0; $i < ($this->minItems - count($currentValue)); $i++) {
144 | $currentValue[] = [];
145 | }
146 | }
147 | }
148 |
149 | if (!$this->childAddItemCalled && $currentValue === null) {
150 | $this->formWidgets = [];
151 | return;
152 | }
153 |
154 | if ($this->childAddItemCalled && !isset($currentValue[$this->childIndexCalled])) {
155 | // If no value is available but a child repeater has added an item, add a "stub" repeater item
156 | $this->makeItemFormWidget($this->childIndexCalled);
157 | }
158 |
159 | if (!is_array($currentValue)) {
160 | return;
161 | }
162 |
163 | collect($currentValue)->each(function ($value, $index) {
164 | $this->makeItemFormWidget($index, array_get($value, '_group', null));
165 | $this->indexConfigMeta[$index] = array_get($value, '_config', null);
166 | });
167 | }
168 |
169 | /**
170 | * {@inheritDoc}
171 | */
172 | public function onAddItem()
173 | {
174 | $groupCode = post('_repeater_group');
175 |
176 | $index = $this->getNextIndex();
177 |
178 | $this->prepareVars();
179 | $this->vars['widget'] = $this->makeItemFormWidget($index, $groupCode);
180 | $this->vars['indexValue'] = $index;
181 |
182 | $itemContainer = '@#' . $this->getId('items');
183 | $addItemContainer = '#' . $this->getId('add-item');
184 |
185 | return [
186 | $addItemContainer => '',
187 | $itemContainer => $this->makePartial('block_item') . $this->makePartial('block_add_item')
188 | ];
189 | }
190 |
191 | /**
192 | * {@inheritDoc}
193 | *
194 | * This method overrides the base repeater processGroupMode to implement block functionality without pre-defining a
195 | * group.
196 | */
197 | protected function processGroupMode(): void
198 | {
199 | $definitions = [];
200 | foreach (BlockManager::instance()->getConfigs($this->tags) as $code => $config) {
201 | if (!empty($config['tags']) && !$this->isBlockAllowed($code, $config['tags'])) {
202 | continue;
203 | }
204 |
205 | $definitions[$code] = [
206 | 'code' => $code,
207 | 'name' => array_get($config, 'name'),
208 | 'icon' => array_get($config, 'icon', 'icon-square-o'),
209 | 'description' => array_get($config, 'description'),
210 | 'fields' => array_get($config, 'fields'),
211 | 'config' => array_get($config, 'config', null),
212 | ];
213 | }
214 |
215 | // Sort the builder blocks by translated name label
216 | uasort($definitions, fn ($a, $b) => trans($a['name']) <=> trans($b['name']));
217 |
218 | $this->groupDefinitions = $definitions;
219 | $this->useGroups = true;
220 | }
221 |
222 | /**
223 | * Determines if a block is allowed according to the widget's ignore/allow list.
224 | */
225 | protected function isBlockAllowed(string $code, array|string $blockTags): bool
226 | {
227 | $blockTags = is_array($blockTags) ? $blockTags : [$blockTags];
228 |
229 | if (isset($this->ignore['blocks']) || isset($this->ignore['tags'])) {
230 | $ignoredBlocks = isset($this->ignore['blocks']) ? $this->ignore['blocks'] : [];
231 | $ignoredTags = isset($this->ignore['tags']) ? $this->ignore['tags'] : [];
232 | } else {
233 | $ignoredBlocks = $this->ignore;
234 | $ignoredTags = [];
235 | }
236 | if (isset($this->allow['blocks']) || isset($this->allow['tags'])) {
237 | $allowedBlocks = isset($this->allow['blocks']) ? $this->allow['blocks'] : [];
238 | $allowedTags = isset($this->allow['tags']) ? $this->allow['tags'] : [];
239 | } else {
240 | $allowedBlocks = $this->allow;
241 | $allowedTags = [];
242 | }
243 |
244 | // Reject explicitly ignored blocks
245 | if (count($ignoredBlocks) && in_array($code, $ignoredBlocks)) {
246 | return false;
247 | }
248 |
249 | // Reject blocks that have any ignored tags
250 | if (count($ignoredTags) && array_intersect($blockTags, $ignoredTags)) {
251 | return false;
252 | }
253 |
254 | // Reject blocks that are not explicitly allowed
255 | if (count($allowedBlocks) && !in_array($code, $allowedBlocks)) {
256 | return false;
257 | }
258 |
259 | // Reject blocks that do not have any allowed tags
260 | if (count($allowedTags) && !array_intersect($blockTags, $allowedTags)) {
261 | return false;
262 | }
263 |
264 | return true;
265 | }
266 |
267 | /**
268 | * Gets the configuration of a block.
269 | */
270 | public function getGroupConfigFromIndex(int $index)
271 | {
272 | return $this->indexConfigMeta[$index] ?? null;
273 | }
274 |
275 | /**
276 | * Returns the group description from its unique code.
277 | */
278 | public function getGroupDescription(string $groupCode): ?string
279 | {
280 | return array_get($this->groupDefinitions, $groupCode . '.description');
281 | }
282 |
283 | /**
284 | * Returns the group icon from its unique code.
285 | */
286 | public function getGroupIcon(string $groupCode): ?string
287 | {
288 | return array_get($this->groupDefinitions, $groupCode . '.icon');
289 | }
290 |
291 | /**
292 | * Determines if the given block has an Inspector config.
293 | */
294 | public function hasInspectorConfig(string $groupCode): bool
295 | {
296 | return isset($this->groupDefinitions[$groupCode]['config']);
297 | }
298 |
299 | /**
300 | * Returns the Inspector config, as a JSON string, for the given group code.
301 | */
302 | public function getInspectorConfig(string $groupCode): string
303 | {
304 | return json_encode($this->processInspectorConfig(array_get($this->groupDefinitions, $groupCode . '.config', [])));
305 | }
306 |
307 | /**
308 | * Converts a Form widget configuration into an Inspector configuration.
309 | */
310 | protected function processInspectorConfig(array $config): array
311 | {
312 | $properties = [];
313 |
314 | foreach ($config as $property => $schema) {
315 | $defined = [
316 | 'property' => $property,
317 | 'title' => Lang::get(array_get($schema, 'title', array_get($schema, 'label'))),
318 | 'description' => Lang::get(array_get($schema, 'description', array_get($schema, 'comment', array_get($schema, 'commentAbove')))),
319 | 'type' => $this->getBestInspectorField(array_get($schema, 'type', 'string')),
320 | 'group' => array_get($schema, 'group', array_get($schema, 'tab')),
321 | ];
322 |
323 | $defined = array_merge($defined, array_except($schema, [
324 | 'title',
325 | 'label',
326 | 'description',
327 | 'comment',
328 | 'commentAbove',
329 | 'type',
330 | 'group',
331 | 'span',
332 | ]));
333 |
334 | if (isset($defined['options']) && is_array($defined['options'])) {
335 | foreach ($defined['options'] as $key => &$value) {
336 | $value = Lang::get($value);
337 | }
338 | }
339 |
340 | $properties[] = array_filter($defined);
341 | }
342 |
343 | return $properties;
344 | }
345 |
346 | /**
347 | * Converts a Form widget field type into the best Inspector field type.
348 | *
349 | * If it cannot convert the type, it is returned as-is.
350 | */
351 | protected function getBestInspectorField(string $type): string
352 | {
353 | switch ($type) {
354 | case 'text':
355 | return 'string';
356 | case 'textarea':
357 | return 'text';
358 | case 'checkboxlist':
359 | return 'set';
360 | case 'balloon-selector':
361 | case 'radio':
362 | return 'dropdown';
363 | }
364 |
365 | return $type;
366 | }
367 | }
368 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Blocks Plugin
2 |
3 | 
4 |
5 | [](https://github.com/wintercms/wn-blocks-plugin/blob/main/LICENSE)
6 |
7 | Provides a "block based" content management experience in Winter CMS
8 |
9 | >**NOTE:** This plugin is still in development and is likely to undergo changes. Do not use in production environments without using a version constraint in your composer.json file and carefully monitoring for breaking changes.
10 |
11 | ## Installation
12 |
13 | This plugin is available for installation via [Composer](http://getcomposer.org/).
14 |
15 | ```bash
16 | composer require winter/wn-blocks-plugin
17 | ```
18 |
19 | After installing the plugin you will need to run the migrations and (if you are using a [public folder](https://wintercms.com/docs/develop/docs/setup/configuration#using-a-public-folder)) [republish your public directory](https://wintercms.com/docs/develop/docs/console/setup-maintenance#mirror-public-files).
20 |
21 | ```bash
22 | php artisan migrate
23 | ```
24 |
25 | >**NOTE:** In order to have the `actions` support function correctly, you need to load `/plugins/winter/blocks/assets/dist/js/blocks.js` after the Snowboard framework has been loaded.
26 |
27 | ## Core Concepts
28 |
29 | ### Blocks
30 |
31 | This plugin manages the concept of "blocks" in Winter CMS. Blocks are self contained pieces of structured content that can be managed and rendered in a variety of ways.
32 |
33 | Blocks can be provided by both plugins and themes and can be overridden by themes.
34 |
35 | ### Actions
36 |
37 | This plugin also introduces the concepts of "actions"; a way to define and execute client side actions that can be triggered by various events. Currently, actions are only defined in the `$/winter/blocks/meta/actions.yaml` file and must exist as a function on the `window.actions` object in the frontend keyed by the action's identifier that receives the `data` object as the first argument and (optionally) the `event` object that triggered the action as the second argument.
38 |
39 | >**NOTE:** This is very much a WIP API and is subject to change. Feedback very much welcome here for ideas around how to register, manage, extend, and provide actions to the frontend.
40 |
41 | ### Tags
42 |
43 | Blocks may have one or more tags, which is a way of defining and grouping blocks. For example, you may have a Gallery block which allows only "image" tagged blocks to be used, or a container block which allows all "content" tagged blocks but does not allow another "container" tagged block within.
44 |
45 | Tags are defined in the blocks, and can be used to filter the available blocks in the Blocks form widget.
46 |
47 |
48 | ## Registering Blocks
49 |
50 | Themes can have their blocks automatically registered by placing `.block` files in the `/blocks` folder and subfolders.
51 |
52 | Plugins can register blocks by providing a `registerBlocks()` method in their Plugin.php file. The method should return an array of block definitions in the following format:
53 |
54 | ```php
55 | public function registerBlocks(): array
56 | {
57 | return [
58 | 'example' => '$/myauthor/myplugin/blocks/example.block',
59 | ];
60 | }
61 | ```
62 |
63 |
68 |
69 |
70 | ## Block Definition
71 |
72 | Blocks are defined as `.block` files that consist of 2 to 3 parts:
73 |
74 | - A YAML configuration section that defines the block's name, description, and other metadata as well as the block's properties and the form used to edit those properties.
75 | - A PHP code section that allows for basic code to be executed when the block is rendered, similar to a partial.
76 | - A Twig template section that defines the HTML markup template of the block.
77 |
78 | When there are two parts, they are the Settings (YAML) & Markup (Twig) sections.
79 |
80 | The following property values (name, description, etc) can be defined in the Settings (YAML) section of the `.block` files:
81 |
82 | ```yaml
83 | name: Example
84 | description: Example Block Description
85 | icon: icon-name
86 | tags: [] # Defines the tags that this block is associated with
87 | permissions: [] # List of permissions required to interact with the block
88 | fields: # The form fields used to populate the block's content
89 | config: # The block configuration options
90 | ```
91 |
92 | Blocks can use components in them, although they may face lifecycle limitations with complex AJAX handlers similar to component support in partials.
93 |
94 | ### Fields and Configuration
95 |
96 | Blocks may define both `fields` as well as a `config` property in the Settings. Both of these parameters accept a [form schema](https://wintercms.com/docs/backend/forms#form-fields), but serve different purposes. In general, `fields` should contain the fields that actually fill in the content of the block, whereas the `config` should contain the fields that define the appearance or structure of the block itself. Fields are displayed within the block in the `blocks` form widget and configuration is displayed in an Inspector which can be shown by clicking on the "cogwheel" icon of a block in the `blocks` form widget.
97 |
98 | For example, let's say you have a **Title** block which can display a heading tag in your content. You may optionally want to align it to left, center or right, and define which heading tag to use. The best practice would be to have a `content` field in the `fields` definition, because it's the actual content being displayed. The `alignment` and `tag` would become part of the `config` configuration.
99 |
100 | **Example:**
101 |
102 | ```
103 | name: Title
104 | description: Adds a title
105 | icon: icon-heading
106 | tags: ["content"]
107 | fields:
108 | content:
109 | label: false
110 | span: full
111 | type: text
112 | config:
113 | size:
114 | label: Size
115 | span: auto
116 | type: dropdown
117 | default: h2
118 | options:
119 | h1: H1
120 | h2: H2
121 | h3: H3
122 | h4: H4
123 | h5: H5
124 | alignment_x:
125 | label: Alignment
126 | span: auto
127 | type: dropdown
128 | default: center
129 | options:
130 | left: Left
131 | center: Centre
132 | right: Right
133 | ==
134 | {% if config.alignment_x == 'left' %}
135 | {% set alignment = 'text-left' %}
136 | {% elseif config.alignment_x == 'center' or not config.alignment_x %}
137 | {% set alignment = 'text-center' %}
138 | {% elseif config.alignment_x == 'right' %}
139 | {% set alignment = 'text-right' %}
140 | {% endif %}
141 |
142 | <{{ config.size }} class="{{ alignment }}">
143 | {{ content }}
144 | {{ config.size }}>
145 | ```
146 |
147 | ## Using the `blocks` FormWidget
148 |
149 | In order to provide an interface for managing block-based content, this plugin provides the `blocks` FormWidget. This widget can be used in the backend as a form field to manage blocks.
150 |
151 | The `blocks` FormWidget supports the following additional properties:
152 |
153 | - `allow`: An array of block types that are allowed to be added to the widget. If specified, only those block types listed will be available to add to the current instance of the field. You can define either a straight array of individual blocks to allow, or define an object with `tags` and/or `blocks` to allow whole tags or individual blocks.
154 | - `ignore`: A list of block types that are not allowed to be added to the widget. If not specified, all block types will be available to add to the current instance of the field. You can define either a straight array of individual blocks to ignore, or define an object with `tags` and/or `blocks` to ignore whole tags or individual blocks.
155 | - `tags`: A list of block tags that are allowed to be added to the widget. If specified, only block types that have at least one of the listed tags will be available to add to the current instance of the field.
156 |
157 | Those properties allow you to limit the block types that can be added to a specific instance of the widget, which can be very helpful when building "container" type blocks that need to avoid including themselves or only support a specific set of blocks as "children".
158 |
159 | ### Examples
160 |
161 | The `button_group` block type only allows a `button` block to be added to it:
162 |
163 | ```yaml
164 | buttons:
165 | label: Buttons
166 | span: full
167 | type: blocks
168 | allow:
169 | - button
170 | ```
171 |
172 | The `container` block type allows any block called `title`, or has a tag of `content`, to be added to it:
173 |
174 | ```yaml
175 | container:
176 | label: Container
177 | span: full
178 | type: blocks
179 | allow:
180 | blocks:
181 | - title
182 | tags:
183 | - content
184 | ```
185 |
186 | The `columns_two` block type allows every block except for itself to be added to it:
187 |
188 | ```yaml
189 | left:
190 | label: Left Column
191 | span: left
192 | type: blocks
193 | ignore:
194 | - columns_two
195 | right:
196 | label: Right Column
197 | span: right
198 | type: blocks
199 | ignore:
200 | - columns_two
201 | ```
202 |
203 | ### Integration with the Winter.Pages plugin:
204 |
205 | Include the following line in your layout file to include the blocks FormWidget on a Winter.Pages page:
206 |
207 | ```twig
208 | {variable type="blocks" name="blocks" tags="pages" tab="winter.pages::lang.editor.content"}{/variable}
209 | ```
210 |
211 |
212 | ## Rendering Blocks
213 |
214 | ### Using Twig
215 |
216 | Twig functions are provided by this plugin for rendering blocks.
217 | You can then use the following Twig snippet to render the blocks data in your layout:
218 |
219 | ```twig
220 | {{ renderBlocks(blocks) }}
221 | ```
222 |
223 | You can use it anywhere an expression is accepted:
224 |
225 | ```twig
226 | {{ ('
Some text
' ~ renderBlocks(blocks) ~ '
Some more text
') | raw }}
227 |
228 | {% set myContent = renderBlocks(blocks) %}
229 | ```
230 |
231 | If you need to render a single block, you can use the `renderBlock` function:
232 |
233 | ```twig
234 | {{ renderBlock({
235 | '_group':'title',
236 | 'content':'Lorem ipsum dolor sit amet.',
237 | 'alignment_x':'left',
238 | 'size':'h1',
239 | }) }}
240 |
241 | {{ renderBlock('title', {
242 | 'content':'Lorem ipsum dolor sit amet.',
243 | 'alignment_x':'left',
244 | 'size':'h1',
245 | }) }}
246 | ```
247 |
248 | ### Using a partial
249 |
250 | If you need to customize the rendering of blocks according to their group, you can use a special `blocks.htm` partial in your theme:
251 |
252 | ```twig
253 | {% for blockIndex, block in blocks %}
254 | {# Adding blocks to the following array allows them to implement their own containers #}
255 | {% if block._group in ["hero", "section"] %}
256 | {{ renderBlock(block) }}
257 | {% else %}
258 |
259 |