diff --git a/.gitignore b/.gitignore
index 6d42620..d7c8c38 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
.phpunit.result.cache
mix.webpack.js
+.DS_Store
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..a048144
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,52 @@
+# Changelog
+
+## Unreleased
+
+### Collapsible sections
+- `.block` `type: section` fields support a `collapsible: true` shorthand, with
+ `collapsed: true|false` controlling the initial state.
+- Handled via the `data-block-collapsible` attribute and an inline bootstrap in
+ the block widget partial, independent of core's collapsible-section JS. This
+ fixes the core behaviour where adding an item to a repeater nested inside a
+ section re-collapsed it and double-bound its click handler (causing the
+ "Add item" stall).
+- **Open/closed state now persists** per section across page reloads
+ (`localStorage`, keyed by field name).
+
+### Shared field includes
+- `.block` definitions may declare a top-level `include:` (string or list) to
+ merge `fields` and `config` from external plain-YAML files.
+- Included definitions form the base; the block's own definitions override on
+ collision. Paths resolve via `File::symbolizePath()` (`$/`, `~/`, `#/`).
+- **Nested includes** are resolved recursively, guarded against circular
+ references.
+- A **schema guard** logs a warning when an include would redefine a field with
+ a different `type`.
+- Missing include files are skipped and logged as a warning.
+
+### Editor UX
+- **Recently used blocks** are pinned to the top of the "add block" palette
+ (tracked in `localStorage`, most-recent first).
+- **Copy / Cut / Paste / Duplicate blocks** — each block has one horizontal
+ toolbar (collapse, copy, cut, paste, duplicate, config, delete). Copy/cut/
+ duplicate place the block's field values on the clipboard (`sessionStorage`);
+ paste inserts after a block, or appends via a "Paste block" entry at the top
+ of the *+ Add New Item* palette (for empty widgets). Duplicate also clones in
+ place. Paste affordances appear only where the copied block type is offered
+ (respects `allow`/`ignore`/`tags`) and survive navigation within the same
+ browser tab. Direct add/paste/duplicate requests run the same empty-add-item
+ cleanup as the core popover flow, so "Add new item" rows no longer pile up.
+
+### Tests
+- `BlockManagerTest`: include merging, block-overrides-include precedence,
+ nested includes, circular-include guard, missing-file skip, multiple includes,
+ and the no-include no-op.
+- `BlocksTest`: `collapsible`/`collapsed` shorthand translation to
+ `data-block-collapsible` / `data-block-collapsible-open`, and that non-section
+ / plain-section fields are left untouched.
+- Fixtures under `tests/fixtures/blocks/includes/`.
+
+### Housekeeping
+- Documented the two distinct `blocks.js` files (frontend Snowboard build vs.
+ backend FormWidget script) in `winter.mix.js` to prevent accidental merging.
+- Stopped tracking `.DS_Store` files.
diff --git a/README.md b/README.md
index ba775ce..f4c48d2 100644
--- a/README.md
+++ b/README.md
@@ -8,8 +8,19 @@ Provides a "block based" content management experience in Winter CMS
>**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.
+> **Block definition features:** **collapsible sections** (with persisted state), **shared field includes** (with nested includes), and **recently used blocks** in the palette. See [Collapsible Sections](#collapsible-sections), [Including shared field definitions](#including-shared-field-definitions), and [Recently used blocks](#recently-used-blocks) below. Full list in [CHANGELOG.md](CHANGELOG.md).
+
## Installation
+> **Using this fork?** Clone it directly into your Winter CMS plugins directory:
+> ```bash
+> git clone https://github.com/helmutkaufmann/wn-blocks-plugin-1.git plugins/winter/blocks
+> ```
+> To update to the latest version:
+> ```bash
+> git -C plugins/winter/blocks pull
+> ```
+
This plugin is available for installation via [Composer](http://getcomposer.org/).
```bash
@@ -99,7 +110,7 @@ For example, let's say you have a **Title** block which can display a heading ta
**Example:**
-```
+```yaml
name: Title
description: Adds a title
icon: icon-heading
@@ -144,6 +155,123 @@ config:
{{ config.size }}>
```
+## Collapsible Sections
+
+Block fields that use `type: section` can be made collapsible directly in the block YAML.
+
+```yaml
+fields:
+ section_advanced:
+ label: Advanced settings
+ type: section
+ collapsible: true # makes the section click-to-collapse
+ collapsed: true # initial state: true = start collapsed (default), false = start open
+ some_field:
+ label: Some field
+ type: text
+```
+
+**Shorthand rules:**
+
+| Key | Type | Default | Description |
+|---|---|---|---|
+| `collapsible` | bool | — | Set to `true` to enable the collapse toggle |
+| `collapsed` | bool | `true` | Initial state. `false` = section starts open |
+
+When `collapsible: true` is set, the section header becomes a click target. Sections start collapsed by default; set `collapsed: false` to have the section open on first load. Each section's open/closed state is **remembered across page reloads** (stored in `localStorage`, keyed by field name), so the editor returns to the state you left it in.
+
+> **Note:** Collapsible behaviour is handled via the `data-block-collapsible` attribute, bootstrapped inline in the block widget partial (`formwidgets/blocks/partials/_block.php`), independent of WinterCMS's core collapsible-section JS. This is deliberate: core re-collapses and re-binds every section on each form-widget init — including when a nested repeater adds an item — which broke manually-opened sections and stalled repeater "Add item" clicks. Owning the behaviour avoids that entirely, so collapsible sections work correctly even with repeater fields nested inside them.
+
+---
+
+## Including shared field definitions
+
+To avoid repeating the same fields (or sections/tabs) across many blocks, a block can pull them in from one or more external YAML files via the top-level `include` key.
+
+```yaml
+name: Article
+description: An article block
+icon: icon-newspaper
+
+include: $/myauthor/myplugin/blocks/_seo.yaml
+
+fields:
+ title:
+ label: Title
+ type: text
+==
+{{ title }}
+```
+
+`_seo.yaml` is a plain YAML file (no `==` markup, no block metadata) containing any of `fields` or `config`:
+
+```yaml
+# blocks/_seo.yaml
+fields:
+ meta_title:
+ label: Meta title
+ type: text
+ meta_description:
+ label: Meta description
+ type: textarea
+```
+
+**Multiple includes** — pass a list; they are merged in order:
+
+```yaml
+include:
+ - $/myauthor/myplugin/blocks/_seo.yaml
+ - ~/app/blocks/_tracking.yaml
+```
+
+**Merge rules:**
+
+| | |
+|---|---|
+| Merged keys | `fields`, `config` |
+| Precedence | Included files form the base; the block's own definitions **override** on key collision |
+| Order | Multiple includes merge top-to-bottom (later files override earlier ones, the block still wins overall) |
+| Nested includes | An included file may itself declare `include:` — resolved recursively, with a circular-reference guard |
+| Type guard | Redefining a field with a different `type` than the include logs a warning |
+| Missing files | Skipped, and logged as a warning |
+
+**Path resolution** uses the standard Winter path symbols via `File::symbolizePath()`:
+
+| Symbol | Resolves to |
+|---|---|
+| `$/author/plugin/...` | `plugins/author/plugin/...` |
+| `~/...` | application root |
+| `#/...` | `storage/app/...` |
+
+This works for `collapsible` sections too — an included file can define a complete collapsible section that every block reuses without copy-paste.
+
+---
+
+## Recently used blocks
+
+When adding a block from the palette, the blocks you use most recently are pinned to the top of the list (tracked per browser in `localStorage`, most-recent first). This speeds up repetitive content building where the same few block types are added over and over. No configuration is required.
+
+---
+
+## Cut, paste and duplicate blocks
+
+Every block has a single horizontal toolbar (top-right) with, in order:
+**collapse**, **copy**, **cut**, **paste**, **duplicate**, *(config, if the
+block has an inspector)* and **delete**.
+
+- **Copy** — places the block's field values on the clipboard (non-destructive).
+- **Cut** — places the block's field values on the clipboard, then removes it (with the usual confirmation prompt).
+- **Paste after** — once the clipboard holds a block, a per-block paste icon inserts the copied block immediately **after** that block. A **Paste block** entry also appears at the top of the "Add Block" palette (the popover opened by *+ Add New Item*), which is handy for inserting into an empty widget or at the end of a list.
+- **Duplicate** — one-step clone: serialises the current block, saves it to the clipboard, and immediately inserts a filled copy right after it.
+
+Use **Duplicate** for a quick in-place clone. Use **Paste after** (or **Paste block** in the palette) when you want to insert a previously copied block at a specific position or into a different widget.
+
+Paste/duplicate respect the widget's `allow` / `ignore` / `tags` constraints: the paste affordances only appear where the copied block type is actually offered. The clipboard persists for the duration of the browser session (`sessionStorage`), so you can paste across different pages in the same tab.
+
+> **Note on nested blocks:** Field values — including nested `blocks` fields, which store their content as JSON — are captured and restored correctly. Rich-text and code editor widgets are refreshed automatically via their own APIs after paste. Field matching uses the trailing name segment (e.g. `content` from `Blocks[0][content]`), so two fields in the same block that share the same trailing key would collide — avoid duplicate field keys within a single block definition.
+
+---
+
## Using the `blocks` FormWidget
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.
diff --git a/classes/BlockManager.php b/classes/BlockManager.php
index e3d2f07..adec9ce 100644
--- a/classes/BlockManager.php
+++ b/classes/BlockManager.php
@@ -7,6 +7,8 @@
use Cms\Classes\Theme;
use Event;
use File;
+use Log;
+use Yaml;
use System\Classes\PluginManager;
use Winter\Storm\Support\Traits\Singleton;
use Winter\Storm\Support\Str;
@@ -98,7 +100,7 @@ public function getConfigs(string|array|null $tags = null): array
}
}
- $configs[pathinfo($block['fileName'])['filename']] = array_except(
+ $config = array_except(
$block->getAttributes(),
[
'fileName',
@@ -108,11 +110,127 @@ public function getConfigs(string|array|null $tags = null): array
'code',
]
);
+
+ $config = $this->resolveIncludes($config);
+
+ $configs[pathinfo($block['fileName'])['filename']] = $config;
}
return $configs;
}
+ /**
+ * Resolves an `include` directive in a block definition by merging field
+ * definitions from one or more external YAML files.
+ *
+ * A block may declare:
+ *
+ * include: $/author/plugin/blocks/_shared.yaml
+ * # or
+ * include:
+ * - $/author/plugin/blocks/_seo.yaml
+ * - ~/app/blocks/_tracking.yaml
+ *
+ * Each included file is a plain YAML file that may contain any of the keys
+ * `fields` and `config`. Included definitions are
+ * merged in order and act as a base; the block's own definitions take
+ * precedence on key collisions.
+ *
+ * Included files may themselves declare an `include` key — nested includes
+ * are resolved recursively, guarded against circular references.
+ *
+ * Paths are resolved with File::symbolizePath(), so the usual Winter symbols
+ * are supported ($ = plugins, ~ = app, # = app/storage/...).
+ *
+ * @param string[] $visited Canonical paths already being resolved (cycle guard).
+ */
+ protected function resolveIncludes(array $config, array $visited = []): array
+ {
+ if (empty($config['include'])) {
+ unset($config['include']);
+ return $config;
+ }
+
+ $paths = (array) $config['include'];
+ unset($config['include']);
+
+ $mergeKeys = ['fields', 'config'];
+
+ foreach ($paths as $path) {
+ if (!is_string($path) || $path === '') {
+ continue;
+ }
+
+ $realPath = File::symbolizePath($path);
+ if (!$realPath || !File::exists($realPath)) {
+ Log::warning("Winter.Blocks: included file not found: {$path}");
+ continue;
+ }
+
+ $canonical = PathResolver::standardize($realPath);
+ if (in_array($canonical, $visited, true)) {
+ Log::warning("Winter.Blocks: circular include detected, skipping: {$path}");
+ continue;
+ }
+
+ $included = Yaml::parse(File::get($realPath));
+ if (!is_array($included)) {
+ continue;
+ }
+
+ // Resolve nested includes first so they form the deepest base layer.
+ $included = $this->resolveIncludes($included, array_merge($visited, [$canonical]));
+
+ foreach ($mergeKeys as $key) {
+ if (!isset($included[$key]) || !is_array($included[$key])) {
+ continue;
+ }
+
+ $own = (isset($config[$key]) && is_array($config[$key])) ? $config[$key] : [];
+
+ // Warn when a field is redefined with a different type.
+ $this->warnOnTypeCollisions($key, $included[$key], $own);
+
+ // Included definitions form the base; the block's own win on collision.
+ $config[$key] = array_replace_recursive($included[$key], $own);
+ }
+ }
+
+ return $config;
+ }
+
+ /**
+ * Logs a warning when merging an include would redefine a field with a
+ * different `type`, which is almost always a mistake. Field definitions live
+ * directly under `fields`/`config`, and under a `fields` sub-key for
+ * `tabs`/`secondaryTabs`.
+ */
+ protected function warnOnTypeCollisions(string $key, array $included, array $own): void
+ {
+ $includedFields = $included;
+ $ownFields = $own;
+
+ if (!is_array($includedFields) || !is_array($ownFields)) {
+ return;
+ }
+
+ foreach ($includedFields as $name => $def) {
+ if (!isset($ownFields[$name]) || !is_array($def) || !is_array($ownFields[$name])) {
+ continue;
+ }
+
+ $includedType = $def['type'] ?? null;
+ $ownType = $ownFields[$name]['type'] ?? null;
+
+ if ($includedType && $ownType && $includedType !== $ownType) {
+ Log::warning(
+ "Winter.Blocks: field '{$name}' redefined with a different type " .
+ "('{$ownType}' overrides included '{$includedType}') in '{$key}'."
+ );
+ }
+ }
+ }
+
/**
* Get the configuration of the provided block type
*/
diff --git a/composer.json b/composer.json
index afb381d..237205e 100644
--- a/composer.json
+++ b/composer.json
@@ -14,6 +14,12 @@
"name": "Winter CMS Maintainers",
"homepage": "https://wintercms.com",
"role": "Maintainer"
+ },
+ {
+ "name": "Helmut Kaufmann",
+ "homepage": "https://mercator.li",
+ "email": "software@mercator.li",
+ "role": "Contributor"
}
],
"support": {
diff --git a/formwidgets/Blocks.php b/formwidgets/Blocks.php
index 04b2391..1461e16 100644
--- a/formwidgets/Blocks.php
+++ b/formwidgets/Blocks.php
@@ -53,6 +53,9 @@ protected function loadAssets()
{
$this->addCss('css/blocks.css', 'Winter.Blocks');
$this->addJs('js/blocks.js', 'Winter.Blocks');
+ // Collapsible-section behaviour is bootstrapped inline in the block
+ // widget partial (formwidgets/blocks/partials/_block.php) so it loads
+ // reliably regardless of asset-path resolution or the asset combiner.
}
/**
@@ -203,12 +206,12 @@ protected function processGroupMode(): void
}
$definitions[$code] = [
- 'code' => $code,
- 'name' => array_get($config, 'name'),
- 'icon' => array_get($config, 'icon', 'icon-square-o'),
- 'description' => array_get($config, 'description'),
- 'fields' => array_get($config, 'fields'),
- 'config' => array_get($config, 'config', null),
+ 'code' => $code,
+ 'name' => array_get($config, 'name'),
+ 'icon' => array_get($config, 'icon', 'icon-square-o'),
+ 'description' => array_get($config, 'description'),
+ 'fields' => $this->normalizeBlockFields((array) array_get($config, 'fields', [])),
+ 'config' => array_get($config, 'config', null),
];
}
@@ -219,6 +222,50 @@ protected function processGroupMode(): void
$this->useGroups = true;
}
+ /**
+ * Translates block YAML shorthands before the fields are handed to the form widget.
+ *
+ * Supported shorthands on `type: section` fields:
+ * collapsible: true — makes the section click-to-collapse
+ * collapsed: true|false — initial state (true = start collapsed, false = start open)
+ * defaults to true when collapsible is set
+ */
+ protected function normalizeBlockFields(array $fields): array
+ {
+ foreach ($fields as &$field) {
+ if (($field['type'] ?? '') !== 'section') {
+ continue;
+ }
+
+ if (!array_key_exists('collapsible', $field)) {
+ continue;
+ }
+
+ if ($field['collapsible']) {
+ $startCollapsed = $field['collapsed'] ?? true;
+
+ // Use our own data attribute (NOT data-field-collapsible) so the
+ // core form widget's bindCollapsibleSections() never touches these
+ // sections. Core re-runs that on every FormWidget init — including
+ // when a nested repeater adds an item — which would re-collapse and
+ // double-bind handlers on sections the user manually opened. We own
+ // the behaviour entirely in collapsible.js instead.
+ $field['containerAttributes'] = array_merge(
+ $field['containerAttributes'] ?? [],
+ ['data-block-collapsible' => 1]
+ );
+
+ if (!$startCollapsed) {
+ $field['containerAttributes']['data-block-collapsible-open'] = 1;
+ }
+ }
+
+ unset($field['collapsible'], $field['collapsed']);
+ }
+
+ return $fields;
+ }
+
/**
* Determines if a block is allowed according to the widget's ignore/allow list.
*/
diff --git a/formwidgets/blocks/assets/less/blocks.less b/formwidgets/blocks/assets/less/blocks.less
index 4d6f7a0..386aaf8 100644
--- a/formwidgets/blocks/assets/less/blocks.less
+++ b/formwidgets/blocks/assets/less/blocks.less
@@ -5,6 +5,68 @@
.field-blocks {
padding-top: 5px;
+ // Per-block toolbar holding every item control in one horizontal row:
+ // collapse, cut, paste, duplicate, config, delete. Overrides the core
+ // fixed 20px width on .repeater-item-remove so the icons never overflow.
+ // Mirrored as an inline-injected