diff --git a/src/block/design-library/edit.js b/src/block/design-library/edit.js
index 2f4643b07c..71a300db50 100644
--- a/src/block/design-library/edit.js
+++ b/src/block/design-library/edit.js
@@ -116,7 +116,7 @@ const Edit = props => {
const spacingSize = ! presetMarks || ! Array.isArray( presetMarks ) ? 120 : presetMarks[ presetMarks.length - 2 ].value
// Replaces the current block with a block made out of attributes.
- const createBlockWithAttributes = async ( category, blockName, attributes, innerBlocks, substituteBlocks, parentClientId ) => {
+ const createBlockWithAttributes = async ( category, blockName, attributes, innerBlocks, substituteBlocks, parentClientId, type ) => {
const disabledBlocks = settings.stackable_block_states || {} // eslint-disable-line camelcase
// Recursively substitute core blocks to disabled Stackable blocks
@@ -207,7 +207,7 @@ const Edit = props => {
innerBlocks = block[ 0 ].innerBlocks
const isDesignLibraryDevMode = devMode && localStorage.getItem( 'stk__design_library__dev_mode' ) === '1'
- if ( ! isDesignLibraryDevMode ) {
+ if ( ! isDesignLibraryDevMode && type !== 'saved' ) {
if ( category !== 'Header' ) {
if ( ! parentClientId && attributes.hasBackground ) {
attributes.blockMargin = {
@@ -255,14 +255,16 @@ const Edit = props => {
const blocks = []
for ( const blockDesign of designs ) {
- const { designData, category } = blockDesign
+ const {
+ designData, category, type,
+ } = blockDesign
for ( const patterns of designData ) {
const {
name, attributes, innerBlocks,
} = patterns
if ( name && attributes ) {
- const block = await createBlockWithAttributes( category, name, applyFilters( 'stackable.design-library.attributes', attributes ), innerBlocks || [], substituteBlocks, parentClientId )
+ const block = await createBlockWithAttributes( category, name, applyFilters( 'stackable.design-library.attributes', attributes ), innerBlocks || [], substituteBlocks, parentClientId, type )
blocks.push( block )
} else {
console.error( 'Design library selection failed: No block data found' ) // eslint-disable-line no-console
@@ -336,14 +338,16 @@ const Edit = props => {
_designs.forEach( design => {
const {
- designData, blocksForSubstitution, category,
+ designData, blocksForSubstitution, category, type: designType,
} = design
if ( blocksForSubstitution.size ) {
disabledBlocks = disabledBlocks.union( blocksForSubstitution )
}
- designs.push( { designData, category } )
+ designs.push( {
+ designData, category, type: designType,
+ } )
} )
designsRef.current = designs
diff --git a/src/components/pro-control/index.js b/src/components/pro-control/index.js
index fce309e547..0d5278cb3b 100644
--- a/src/components/pro-control/index.js
+++ b/src/components/pro-control/index.js
@@ -150,6 +150,14 @@ const LABELS = {
{ __( 'Override styles while keeping them synced', i18n ) }
,
},
+ 'design-library-saved-patterns': {
+ title: __( 'Design Library Saved Patterns', i18n ),
+ description:
+ - { __( 'Save entire block layouts in a click', i18n ) }
+ - { __( 'Apply styling options to each saved pattern', i18n ) }
+ - { __( 'Import and export your custom patterns across sites', i18n ) }
+
,
+ },
}
const ProControl = props => {
diff --git a/src/design-library/index.js b/src/design-library/index.js
index 72c87d36cb..016f345da4 100644
--- a/src/design-library/index.js
+++ b/src/design-library/index.js
@@ -35,7 +35,7 @@ export const fetchDesignLibrary = async ( forceReset = false, version = '', type
}
}
- return designLibrary[ type ]?.[ version || LATEST_API_VERSION ] ?? designLibrary[ type ]
+ return designLibrary[ type ]?.[ version || LATEST_API_VERSION ] ?? designLibrary[ type ] ?? {}
}
export const fetchDesign = async designId => {
@@ -61,6 +61,16 @@ export const getDesigns = async ( {
reset = false,
type = 'patterns',
} ) => {
+ if ( type === 'saved' ) {
+ const result = applyFilters( 'stackable.design-library.get-saved-designs', [], { reset } )
+
+ if ( result && typeof result.then === 'function' ) {
+ return await result
+ }
+
+ return Array.isArray( result ) ? result : []
+ }
+
const designLibrary = await fetchDesignLibrary( reset, LATEST_API_VERSION, type )
if ( designLibrary.wp_remote_get_error || designLibrary.content_error ) {
@@ -82,8 +92,9 @@ export const filterDesigns = async ( {
library = [],
plan: isPlan = '',
category: isCategory = '',
+ type = 'patterns',
} ) => {
- if ( isPlan ) {
+ if ( isPlan && type !== 'saved' ) {
library = library.filter( ( { plan } ) => plan === isPlan )
}
diff --git a/src/design-library/init.php b/src/design-library/init.php
index e48bb1a6d3..8876f3a6e3 100644
--- a/src/design-library/init.php
+++ b/src/design-library/init.php
@@ -289,6 +289,10 @@ public function get_design_library( $request ) {
$this->delete_cache();
}
+ if ( $type === 'saved' ) {
+ return rest_ensure_response( apply_filters( 'stackable_design_library_saved_patterns', array() ) );
+ }
+
return rest_ensure_response( $this->get_design_library_from_cloud( $type ) );
}
diff --git a/src/design-library/saved-patterns.md b/src/design-library/saved-patterns.md
new file mode 100644
index 0000000000..7edd5ef329
--- /dev/null
+++ b/src/design-library/saved-patterns.md
@@ -0,0 +1,134 @@
+# Saved Patterns in the Design Library
+
+Premium-only feature that lets users save Stackable Columns layouts to a personal design library, then reuse them from the **Saved** tab in the Design Library modal.
+
+Free plugin provides the Saved tab UI and integration hooks. Premium plugin provides storage, REST API, save/edit/delete UI, and import/export.
+
+## Architecture
+
+```
+Editor (block ⋮ menu) Design Library modal (Saved tab) Stackable Settings (Import/Export)
+ │ │ │
+ ▼ ▼ ▼
+ SaveModal / edit UI lazy-components/design-library welcome/import-export
+ │ │ │
+ └──────────────┬─────────────────────┴──────────────────┬───────────────┘
+ ▼ ▼
+ pro__premium_only/src/design-library/user-patterns.js
+ │
+ ▼
+ REST stackable/v3/user_patterns → CPT stk-user-pattern (PHP)
+```
+
+## Storage
+
+Patterns are stored as a **private custom post type** `stk-user-pattern` (not public, hidden from admin UI and REST index).
+
+| Field | CPT mapping |
+|-------|-------------|
+| `id` (slug) | `post_name` |
+| `label` | `post_title` |
+| `description` | `post_excerpt` |
+| `category` | post meta `_stk_pattern_category` |
+| `template` | `post_content` (serialized block markup) |
+
+**Create** writes all fields including `post_content`.
+
+**Update** (`PUT /user_patterns/{slug}`) changes metadata only — label, slug, description, category. It does **not** replace `post_content`, so editing a pattern never overwrites the saved block markup.
+
+## REST API (`stackable/v3`)
+
+| Method | Route | Capability | Purpose |
+|--------|-------|------------|---------|
+| `GET` | `/user_patterns` | `edit_posts` | List patterns for the Saved tab |
+| `POST` | `/user_patterns` | `edit_theme_options` | Create pattern |
+| `PUT` | `/user_patterns/{slug}` | `edit_theme_options` | Update metadata |
+| `DELETE` | `/user_patterns` | `edit_theme_options` | Bulk delete by slug |
+| `POST` | `/user_patterns/import` | `manage_options` | Bulk import from export file |
+
+PHP: `pro__premium_only/src/design-library/user-patterns.php`
+
+JS client: `pro__premium_only/src/design-library/user-patterns.js` (session cache, invalidated on writes)
+
+## Capabilities & UI gating
+
+| Action | Capability | Where gated |
+|--------|------------|-------------|
+| View / insert saved patterns | `edit_posts` | REST `GET` |
+| Save, edit, delete | `edit_theme_options` | REST writes; block ⋮ menu; edit/delete buttons in Saved tab |
+| Import patterns (settings page) | `manage_options` | REST import endpoint |
+
+The Design Library modal calls `useCanManageUserPatterns()` once on open and passes `canManageUserPatterns` through `DesignLibraryContext` so tab switches do not re-check capability per card.
+
+Outside the modal (e.g. block ⋮ **Save to Design Library**), `useCanManageUserPatterns` in `src/hooks/use-can-manage-user-patterns.js` is used directly.
+
+## Saving a pattern
+
+1. User selects a **Stackable Columns** block and chooses **Save to Design Library** from the block ⋮ menu.
+2. `pro__premium_only/src/plugins/design-library-save-pattern-button/` opens `SaveModal`.
+3. On save, the modal serializes the selected blocks via `@wordpress/blocks` `serialize()` and `POST`s to `/user_patterns`.
+4. `stackable.design-library.pattern-saved` action fires; a notice points the user to the Design Library Saved tab.
+
+Only Columns blocks are allowed. Other Stackable blocks show the menu item disabled.
+
+## Using saved patterns
+
+1. User opens the Design Library modal and switches to the **Saved** tab.
+2. `getDesigns({ type: 'saved' })` in `src/design-library/index.js` calls the `stackable.design-library.get-saved-designs` filter (premium registers `fetchSavedPatterns`).
+3. Patterns are **lazy-loaded** on first visit to the Saved tab — not on editor boot.
+4. User selects patterns and clicks **Add Designs** to insert them like cloud patterns.
+
+Free users see a Pro upsell on the Saved tab instead of the list.
+
+## Edit & delete
+
+- **Edit** — pencil button on each saved item (premium). Opens `SaveModal` in `UPDATE` mode; only label, slug, category, and description are sent.
+- **Delete** — bulk delete in the modal footer when items are selected on the Saved tab.
+
+Both are hidden when `canManageUserPatterns` is false.
+
+## Import / export
+
+Stackable Settings → Import/Export → Design Library tab.
+
+- **Export** — patterns fetched via `stackable.design-library.fetch-saved-patterns` filter; user selects which to include in the JSON file.
+- **Import** — selected patterns from the JSON file are sent through `stackable.admin-settings.import-export.handle-import` → `importUserPatterns()` → `POST /user_patterns/import` with `ADD`, `SKIP`, or `OVERWRITE` duplicate handling.
+
+Free wiring: `src/welcome/import-export/design-library.js`
+Premium UI + import handler: `pro__premium_only/src/welcome/import-export/design-library.js`
+
+## Key files
+
+### Free plugin
+
+| File | Role |
+|------|------|
+| `src/design-library/index.js` | `getDesigns({ type: 'saved' })` filter hook |
+| `src/design-library/init.php` | `type=saved` REST route; `stackable_design_library_saved_patterns` filter |
+| `src/lazy-components/design-library/` | Modal, Saved tab, lazy fetch |
+| `src/hooks/use-can-manage-user-patterns.js` | Capability check hook |
+| `src/welcome/import-export/design-library.js` | Import/export tab loader |
+
+### Premium plugin (`pro__premium_only/`)
+
+| File | Role |
+|------|------|
+| `src/design-library/user-patterns.php` | CPT registration, REST routes, sanitization |
+| `src/design-library/user-patterns.js` | REST client + cache |
+| `src/design-library/index.js` | Registers `get-saved-designs` and `fetch-saved-patterns` filters |
+| `src/block-components/design-library/save-modal.js` | Save / edit modal |
+| `src/block-components/design-library/index.js` | Edit button, bulk delete filters |
+| `src/block-components/design-library/store.js` | `stackable/design-library-saved-patterns` data store |
+| `src/plugins/design-library-save-pattern-button/` | Block ⋮ menu entry |
+
+## WordPress hooks
+
+| Hook | Purpose |
+|------|---------|
+| `stackable.design-library.get-saved-designs` | Premium returns pattern list for `getDesigns()` |
+| `stackable.design-library.fetch-saved-patterns` | Premium returns promise for import/export lazy load |
+| `stackable_design_library_saved_patterns` | PHP filter; premium returns CPT data for `init.php` saved route |
+| `stackable.design-library.pattern-label-actions` | Premium renders edit button per saved item |
+| `stackable.design-library.footer-selection-actions` | Premium renders bulk delete button |
+| `stackable.design-library.saved-patterns-loaded` | Fired when patterns list updates (syncs store + modal) |
+| `stackable.design-library.pattern-saved` | Fired after a successful save from the block menu |
diff --git a/src/hooks/index.js b/src/hooks/index.js
index 1242cda5ff..6c9bf386de 100644
--- a/src/hooks/index.js
+++ b/src/hooks/index.js
@@ -21,3 +21,4 @@ export * from './use-global-block-layout-defaults'
export * from './use-block-color-schemes'
export * from './use-preset-controls'
export * from './use-block-style-context'
+export * from './use-can-manage-user-patterns'
diff --git a/src/hooks/use-can-manage-user-patterns.js b/src/hooks/use-can-manage-user-patterns.js
new file mode 100644
index 0000000000..2b6cbd04b7
--- /dev/null
+++ b/src/hooks/use-can-manage-user-patterns.js
@@ -0,0 +1,52 @@
+import { currentUserHasCapability } from '~stackable/util'
+
+import { useEffect, useState } from '@wordpress/element'
+import { useSelect } from '@wordpress/data'
+
+export const USER_PATTERN_MANAGE_CAPABILITY = 'edit_theme_options'
+
+let cachedCanManage = null
+let inflightPromise = null
+
+export const useCanManageUserPatterns = () => {
+ const userId = useSelect( select => select( 'core' ).getCurrentUser()?.id, [] )
+ const [ canManage, setCanManage ] = useState( () => cachedCanManage ?? false )
+
+ useEffect( () => {
+ if ( ! userId ) {
+ setCanManage( false )
+ return
+ }
+
+ if ( cachedCanManage !== null ) {
+ setCanManage( cachedCanManage )
+ return
+ }
+
+ if ( ! inflightPromise ) {
+ inflightPromise = currentUserHasCapability( USER_PATTERN_MANAGE_CAPABILITY ).then( hasCapability => {
+ cachedCanManage = !! hasCapability
+ inflightPromise = null
+ return cachedCanManage
+ } ).catch( () => {
+ cachedCanManage = false
+ inflightPromise = null
+ return false
+ } )
+ }
+
+ let isMounted = true
+
+ inflightPromise.then( hasCapability => {
+ if ( isMounted ) {
+ setCanManage( hasCapability )
+ }
+ } )
+
+ return () => {
+ isMounted = false
+ }
+ }, [ userId ] )
+
+ return canManage
+}
diff --git a/src/lazy-components/design-library/design-library-list/design-library-list-item.js b/src/lazy-components/design-library/design-library-list/design-library-list-item.js
index d4983380e8..033d1e9b37 100644
--- a/src/lazy-components/design-library/design-library-list/design-library-list-item.js
+++ b/src/lazy-components/design-library/design-library-list/design-library-list-item.js
@@ -24,6 +24,7 @@ import {
} from '@wordpress/element'
import { Dashicon, Spinner } from '@wordpress/components'
import { __ } from '@wordpress/i18n'
+import { applyFilters } from '@wordpress/hooks'
const DesignLibraryListItem = memo( props => {
const {
@@ -41,7 +42,7 @@ const DesignLibraryListItem = memo( props => {
? presetMarks[ presetMarks.length - 2 ].value
: 120
- const [ isLoading, setIsLoading ] = useState( true )
+ const [ isLoading, setIsLoading ] = useState( false )
const [ selected, setSelected ] = useState( false )
const {
@@ -82,27 +83,54 @@ const DesignLibraryListItem = memo( props => {
[ `ugb--is-${ plan }` ]: ! isPro && plan !== 'free',
'ugb--is-toggled': selectedNum,
'ugb--is-hidden': ! shouldRender,
+ 'ugb-design-library-item--pages': selectedTab === 'pages',
} )
const onClickHost = e => {
e.stopPropagation()
- if ( selectedTab === 'pages' ) {
- return
- }
onClickDesign()
}
+ const isInteractiveTarget = target => {
+ if ( ! target || ! target.tagName ) {
+ return false
+ }
+
+ const tag = target.tagName
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable
+ }
+
+ const buttonAttributes = {
+ tabIndex: 0,
+ role: 'button',
+ onClick: onClickHost,
+ onKeyDown: e => {
+ if ( isInteractiveTarget( e.target ) ) {
+ return
+ }
+
+ if ( e.key === 'Enter' || e.key === ' ' ) {
+ e.preventDefault()
+ onClickHost( e )
+ }
+ },
+ }
+
return (
- // eslint-disable-next-line jsx-a11y/mouse-events-have-key-events
-