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..f7b69bb
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,69 @@
+# 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.
+
+### Performance
+- **Cross-request config cache** — `BlockManager::getConfigs()` now stores
+ built block configs in the Laravel cache (default: file/database driver),
+ keyed by an md5 signature of every block file's mtime. On warm requests the
+ full config build (~17–97ms for ~100 blocks) is replaced by a cheap mtime
+ check (~0.1ms). The cache self-invalidates when any `.block` file or any
+ `include`d YAML file changes, so no manual `php artisan cache:clear` is
+ needed after editing blocks. A per-request in-memory memo prevents even the
+ mtime check from running more than once per request.
+
+### 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 full field data 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.
+- **Server-side copy/paste** — copying a block calls `onCopyItem`, which builds
+ the block's Form widget server-side and calls `getSaveData()`. This correctly
+ captures every field type — switches, mediafinders, and nested repeaters with
+ their own rows — which a client-side DOM scrape cannot. The clipboard payload
+ (`{group, config, data}`) is sent back to `onAddItem` as `_paste_data`, which
+ seeds the new item via `getValueFromIndex()` before rendering, so the pasted
+ block appears fully populated without a round-trip DOM fill step.
+
+### 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..f2cd093 100644
--- a/README.md
+++ b/README.md
@@ -8,8 +8,9 @@ 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.
-## Installation
+> **Block definition features:** **collapsible sections** (with persisted state), **shared field includes** (with nested includes), **recently used blocks** in the palette, and a **cross-request config cache** for fast backend page loads. Copy/Cut/Paste/Duplicate is fully server-side and captures all field types (switches, mediafinders, nested repeaters). See [Collapsible Sections](#collapsible-sections), [Including shared field definitions](#including-shared-field-definitions), [Recently used blocks](#recently-used-blocks), and [Cut, paste and duplicate blocks](#cut-paste-and-duplicate-blocks) below. Full list in [CHANGELOG.md](CHANGELOG.md).
+## Installation
This plugin is available for installation via [Composer](http://getcomposer.org/).
```bash
@@ -99,7 +100,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 +145,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.
+
+> **How copy/paste works:** Copying a block calls a server-side handler (`onCopyItem`) that builds the block's form widget and calls `getSaveData()`. This correctly captures every field type — including switches, mediafinders, and nested repeaters with their own rows — which a client-side DOM scrape cannot. The payload is stored in `sessionStorage` and sent back on paste, where the new item is rendered server-side pre-populated. Rich-text and code editor widgets are refreshed via their own APIs after the item is inserted into the DOM.
+
+---
+
## 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..0080a7c 100644
--- a/classes/BlockManager.php
+++ b/classes/BlockManager.php
@@ -2,11 +2,14 @@
namespace Winter\Blocks\Classes;
+use Cache;
use Cms\Classes\CmsObjectCollection;
use Cms\Classes\Controller;
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;
@@ -30,6 +33,36 @@ class BlockManager
*/
protected $blocks = [];
+ /**
+ * @var array Per-request memoization of getConfigs() results, keyed by tags.
+ *
+ * Building the configs re-reads and re-parses every block file (plus any
+ * `include`d YAML), which costs ~15ms for the full set. getConfigs() is
+ * called several times per backend request (plugin boot, each Blocks
+ * widget, and once per getConfig() during rendering), so the uncached cost
+ * compounds. Block definitions cannot change within a request, so the
+ * result is safe to memoize.
+ */
+ protected $configCache = [];
+
+ /**
+ * @var string|null Per-request memo of the block-set signature (file mtimes).
+ */
+ protected $signature = null;
+
+ /**
+ * @var array Included YAML files (path => mtime) touched during the last
+ * config build, used to invalidate the cross-request cache when an included
+ * file changes without its parent .block file changing.
+ */
+ protected $touchedIncludes = [];
+
+ /**
+ * Cross-request cache TTL (seconds) for built configs. The cache is keyed by
+ * a content signature and self-invalidates, so the TTL is only a safety net.
+ */
+ const CONFIG_CACHE_TTL = 86400;
+
public function init(): void
{
// @TODO: Find a better way to handle rendering blocks that doesn't require a "blocks" partial in the theme
@@ -86,6 +119,57 @@ public function getBlocks(): CmsObjectCollection
* Get an array of blocks and their configuration details in the form of ['key' => $config]
*/
public function getConfigs(string|array|null $tags = null): array
+ {
+ $cacheKey = json_encode($tags);
+
+ // In-request memoization.
+ if (isset($this->configCache[$cacheKey])) {
+ return $this->configCache[$cacheKey];
+ }
+
+ return $this->configCache[$cacheKey] = $this->rememberConfigs($cacheKey, $tags);
+ }
+
+ /**
+ * Returns the built configs for the given tags, served from the cross-request
+ * cache when the underlying block files (and any included YAML) are unchanged.
+ *
+ * Building the configs scans and parses every block file (~17ms for ~100
+ * blocks); the cache replaces that with a cheap mtime check (~0.1ms) on every
+ * request after the first. The cache self-invalidates: a content signature
+ * derived from block-file mtimes keys the entry, and the mtimes of any
+ * included YAML files are stored alongside and re-checked on read.
+ */
+ protected function rememberConfigs(string $cacheKey, string|array|null $tags): array
+ {
+ $store = 'winter.blocks.configs.' . md5($cacheKey);
+ $signature = $this->blocksSignature();
+
+ $cached = Cache::get($store);
+ if (
+ is_array($cached)
+ && ($cached['signature'] ?? null) === $signature
+ && $this->includesUnchanged($cached['includes'] ?? [])
+ ) {
+ return $cached['data'];
+ }
+
+ $this->touchedIncludes = [];
+ $data = $this->buildConfigs($tags);
+
+ Cache::put($store, [
+ 'signature' => $signature,
+ 'includes' => $this->touchedIncludes,
+ 'data' => $data,
+ ], static::CONFIG_CACHE_TTL);
+
+ return $data;
+ }
+
+ /**
+ * Builds the block configs by scanning and parsing the theme's block files.
+ */
+ protected function buildConfigs(string|array|null $tags = null): array
{
$configs = [];
foreach ($this->getBlocks() as $block) {
@@ -98,7 +182,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 +192,262 @@ public function getConfigs(string|array|null $tags = null): array
'code',
]
);
+
+ $config = $this->resolveIncludes($config);
+ $config = $this->resolveComponent($config);
+
+ $configs[pathinfo($block['fileName'])['filename']] = $config;
}
return $configs;
}
+ /**
+ * Computes a signature for the current block set from the mtimes (and paths)
+ * of every block file. Cheap (~0.1ms): it reads the in-memory list of
+ * plugin-registered block paths plus any theme-provided block files, and
+ * never triggers a Halcyon scan. Memoized per request.
+ */
+ protected function blocksSignature(): string
+ {
+ if ($this->signature !== null) {
+ return $this->signature;
+ }
+
+ $parts = [];
+
+ // Plugin-registered blocks (the bulk) — already resolved to real paths.
+ foreach ($this->getRegisteredBlocks() as $path) {
+ $parts[$path] = @filemtime($path) ?: 0;
+ }
+
+ // Theme-provided block files, if the active theme ships any.
+ if (($theme = Theme::getActiveTheme()) && is_dir($themeDir = $theme->getPath() . '/blocks')) {
+ foreach (glob($themeDir . '/*.' . static::BLOCK_EXTENSION) ?: [] as $path) {
+ $parts[$path] = @filemtime($path) ?: 0;
+ }
+ }
+
+ ksort($parts);
+
+ return $this->signature = md5(serialize($parts));
+ }
+
+ /**
+ * Returns true if every included YAML file recorded with a cached config
+ * still has the same mtime (i.e. nothing was edited under it).
+ */
+ protected function includesUnchanged(array $includes): bool
+ {
+ foreach ($includes as $path => $mtime) {
+ if ((@filemtime($path) ?: 0) !== $mtime) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * 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'];
+
+ // Capture the block's own definitions before the loop so that each
+ // include sees the original block values, not a previously merged result.
+ // This ensures the block always wins on collision regardless of include order,
+ // and that later includes correctly override earlier ones (not the merged state).
+ $ownByKey = [];
+ foreach ($mergeKeys as $key) {
+ $ownByKey[$key] = (isset($config[$key]) && is_array($config[$key])) ? $config[$key] : [];
+ }
+
+ 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}");
+ // Record even missing files so the cache invalidates if they are created later.
+ if ($realPath) {
+ $canonical = PathResolver::standardize($realPath);
+ $this->touchedIncludes[$canonical] = 0;
+ }
+ continue;
+ }
+
+ $canonical = PathResolver::standardize($realPath);
+ if (in_array($canonical, $visited, true)) {
+ Log::warning("Winter.Blocks: circular include detected, skipping: {$path}");
+ continue;
+ }
+
+ // Record the included file so the cross-request config cache can be
+ // invalidated when it changes independently of the parent .block file.
+ $this->touchedIncludes[$canonical] = @filemtime($realPath) ?: 0;
+
+ $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 = $ownByKey[$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 definitions always win.
+ // Later includes override earlier ones; the block overrides all.
+ $config[$key] = array_replace_recursive($included[$key], $config[$key] ?? [], $own);
+ }
+ }
+
+ return $config;
+ }
+
+ /**
+ * Resolves a `component:` key in a block config by merging the component's
+ * `defineProperties()` into the block's `fields:` as base definitions.
+ *
+ * A block may declare:
+ *
+ * component: mfaGateway
+ *
+ * The component's `defineProperties()` entries are converted to block field
+ * definitions and merged as defaults; the block's own `fields:` always win.
+ *
+ * Property-to-field mapping:
+ * title → label
+ * description → comment
+ * type → type (string → text, dropdown → dropdown, checkbox → checkbox)
+ * default → default
+ */
+ protected function resolveComponent(array $config): array
+ {
+ if (empty($config['component']) || !is_string($config['component'])) {
+ unset($config['component']);
+ return $config;
+ }
+
+ $componentName = $config['component'];
+ unset($config['component']);
+
+ try {
+ $class = \Cms\Classes\ComponentManager::instance()->resolve($componentName);
+ if (!$class) {
+ Log::warning("Winter.Blocks: component '{$componentName}' not found for block.");
+ return $config;
+ }
+
+ /** @var \Cms\Classes\ComponentBase $instance */
+ $instance = new $class();
+ $properties = method_exists($instance, 'defineProperties') ? $instance->defineProperties() : [];
+
+ // Map component type names to block/form field type names.
+ $typeMap = [
+ 'string' => 'text',
+ 'text' => 'text',
+ 'integer' => 'number',
+ 'float' => 'number',
+ 'checkbox' => 'checkbox',
+ 'dropdown' => 'dropdown',
+ 'set' => 'checkboxlist',
+ ];
+
+ $fromComponent = [];
+ foreach ($properties as $name => $def) {
+ $propType = $def['type'] ?? 'string';
+ $fieldType = $typeMap[$propType] ?? 'text';
+ $field = [
+ 'label' => $def['title'] ?? $name,
+ 'type' => $fieldType,
+ ];
+ if (isset($def['default'])) $field['default'] = $def['default'];
+ if (!empty($def['description'])) $field['comment'] = $def['description'];
+ if (!empty($def['placeholder'])) $field['placeholder'] = $def['placeholder'];
+ if (!empty($def['options'])) $field['options'] = $def['options'];
+
+ $fromComponent[$name] = $field;
+ }
+
+ // Block's own fields win; component fields fill in the rest.
+ $ownFields = (isset($config['fields']) && is_array($config['fields'])) ? $config['fields'] : [];
+ $config['fields'] = array_replace($fromComponent, $ownFields);
+
+ } catch (\Throwable $e) {
+ Log::warning("Winter.Blocks: could not resolve component '{$componentName}': " . $e->getMessage());
+ }
+
+ 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
+ {
+ foreach ($included as $name => $def) {
+ if (!isset($own[$name]) || !is_array($def) || !is_array($own[$name])) {
+ continue;
+ }
+
+ $includedType = $def['type'] ?? null;
+ $ownType = $own[$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..d628fe8 100644
--- a/formwidgets/Blocks.php
+++ b/formwidgets/Blocks.php
@@ -32,6 +32,14 @@ class Blocks extends Repeater
*/
public array $indexConfigMeta = [];
+ /**
+ * Block data to seed into a freshly added item, keyed by index. Used by the
+ * server-side paste flow so a pasted block renders fully populated (switches,
+ * mediafinders, nested repeaters) instead of relying on lossy client-side
+ * field scraping.
+ */
+ protected array $pasteData = [];
+
/**
* {@inheritDoc}
*/
@@ -53,6 +61,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.
}
/**
@@ -168,6 +179,51 @@ protected function processItems()
/**
* {@inheritDoc}
+ *
+ * Returns the data at a given index, preferring data seeded for the
+ * server-side paste flow (see onAddItem).
+ */
+ protected function getValueFromIndex($index)
+ {
+ if (array_key_exists($index, $this->pasteData)) {
+ return $this->pasteData[$index];
+ }
+
+ return parent::getValueFromIndex($index);
+ }
+
+ /**
+ * Returns the full saved data for a single block item, for the copy/cut/
+ * duplicate clipboard. Building the item's Form widget and calling
+ * getSaveData() captures every field type correctly — including switches,
+ * mediafinders and nested repeaters — which a client-side DOM scrape cannot.
+ */
+ public function onCopyItem()
+ {
+ $index = post('_repeater_index');
+ $groupCode = post('_repeater_group');
+
+ $this->prepareVars();
+ $widget = $this->makeItemFormWidget($index, $groupCode);
+
+ // The Inspector config is repeater meta, not a form field, so read it
+ // straight from the posted item value rather than getSaveData().
+ $config = array_get(parent::getValueFromIndex($index), '_config') ?: null;
+
+ return [
+ 'result' => json_encode([
+ 'group' => $groupCode,
+ 'config' => $config,
+ 'data' => $widget->getSaveData(),
+ ]),
+ ];
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Accepts optional `_paste_data` (a JSON-encoded block data array from
+ * onCopyItem) and `_paste_config` to render the new item pre-populated.
*/
public function onAddItem()
{
@@ -175,16 +231,50 @@ public function onAddItem()
$index = $this->getNextIndex();
+ $pasteConfig = null;
+ $isPaste = false;
+ if ($pasteRaw = post('_paste_data')) {
+ $decoded = json_decode($pasteRaw, true);
+ if (is_array($decoded)) {
+ $this->pasteData[$index] = $decoded;
+ $pasteConfig = post('_paste_config') ?: null;
+ $isPaste = true;
+ }
+ }
+
+ // Keep the static $onAddItemCalled flag set during prepareVars() so the
+ // OUTER repeater skips re-processing every existing item (Repeater.php:179)
+ // — we only render the new item here, so rebuilding the rest is wasted work.
$this->prepareVars();
- $this->vars['widget'] = $this->makeItemFormWidget($index, $groupCode);
- $this->vars['indexValue'] = $index;
+
+ // Repeater::$onAddItemCalled is a *static* flag set during init() of the
+ // repeater handling this AJAX request. While set, every repeater —
+ // including any nested repeater inside the new item — skips processItems()
+ // (Repeater.php:158,179), which normally stops a new empty item pulling a
+ // sibling's data. But when pasting we *want* the new item's nested
+ // repeaters to build their rows from the seeded data, so clear the flag
+ // only while building and rendering the new item, then restore it.
+ $restoreAddItemFlag = self::$onAddItemCalled;
+ if ($isPaste) {
+ self::$onAddItemCalled = false;
+ }
+
+ try {
+ $this->vars['widget'] = $this->makeItemFormWidget($index, $groupCode);
+ $this->vars['indexValue'] = $index;
+ $this->indexConfigMeta[$index] = $pasteConfig;
+
+ $html = $this->makePartial('block_item') . $this->makePartial('block_add_item');
+ } finally {
+ self::$onAddItemCalled = $restoreAddItemFlag;
+ }
$itemContainer = '@#' . $this->getId('items');
$addItemContainer = '#' . $this->getId('add-item');
return [
$addItemContainer => '',
- $itemContainer => $this->makePartial('block_item') . $this->makePartial('block_add_item')
+ $itemContainer => $html
];
}
@@ -203,12 +293,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 +309,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..985e028 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