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: , + }, } 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 - + ) } ) diff --git a/src/lazy-components/design-library/design-library-list/design-preview.js b/src/lazy-components/design-library/design-library-list/design-preview.js index 0d97058f18..1dbd45f1e6 100644 --- a/src/lazy-components/design-library/design-library-list/design-preview.js +++ b/src/lazy-components/design-library/design-library-list/design-preview.js @@ -13,7 +13,6 @@ export const DesignPreview = ( { blocks = '', shadowRoot, selectedTab, - designIndex, onMouseDown = NOOP, updateShadowBodySize = NOOP, setIsLoading, @@ -55,7 +54,7 @@ export const DesignPreview = ( { useEffect( () => { const container = ref.current - if ( ! container || selectedTab === 'patterns' ) { + if ( ! container || selectedTab !== 'pages' ) { return } @@ -88,30 +87,18 @@ export const DesignPreview = ( { return } - setIsLoading( true ) + // Prevent interaction and focus within the preview content + wrapper.setAttribute( 'inert', '' ) - const ric = window.requestIdleCallback ? ( cb => window.requestIdleCallback( cb, { timeout: 5000 } ) ) - : ( cb => setTimeout( cb, designIndex * 20 ) ) const sanitizedHTML = safeHTML( blocks ) - if ( selectedTab !== 'pages' || designIndex < 9 ) { - // insert HTML for patterns and for the first 9 pages - wrapper.innerHTML = sanitizedHTML - requestAnimationFrame( () => { - ric( () => setIsLoading( false ) ) - } ) - return + wrapper.innerHTML = sanitizedHTML + + if ( selectedTab === 'pages' ) { + updateShadowBodySize() } - requestAnimationFrame( () => { - ric( () => { - wrapper.innerHTML = sanitizedHTML - updateShadowBodySize() - requestAnimationFrame( () => { - ric( () => setIsLoading( false ) ) - } ) - } ) - } ) + setIsLoading( false ) }, [ blocks, shadowRoot ] ) // Only depend on blocks and shadowRoot; selectedTab and designIndex changes will cause blocks to update return createPortal( <> @@ -121,6 +108,7 @@ export const DesignPreview = ( { >
    diff --git a/src/lazy-components/design-library/design-library-list/editor.scss b/src/lazy-components/design-library/design-library-list/editor.scss index 6c5118ff02..4d3076c82f 100644 --- a/src/lazy-components/design-library/design-library-list/editor.scss +++ b/src/lazy-components/design-library/design-library-list/editor.scss @@ -25,6 +25,11 @@ .ugb-modal-design-library__designs { overflow: auto; background: #f4f4f5; + + &:has(.ugb-design-library-items--empty) { + display: flex; + flex-direction: column; + } } .ugb-design-library-item { width: 100%; @@ -54,13 +59,51 @@ opacity: 1; } } + .stk-block-design__edit-btn-container { + opacity: 0; + z-index: 2; + transition: opacity 0.4s cubic-bezier(0.2, 0.6, 0.4, 1), visibility 0.4s cubic-bezier(0.2, 0.6, 0.4, 1); + position: absolute; + padding: 60px 30px 30px; + background: linear-gradient(180deg, transparent, #fffe 40px, #fff) !important; + top: auto; + bottom: -1px; + left: 0; + right: 0; + display: flex; + gap: 24px; + visibility: hidden; + @media (hover: none) { + opacity: 1; + } + + .ugb-button-component { + width: 100%; + justify-content: center; + pointer-events: none; + @media (hover: none) { + pointer-events: auto; + } + } + } &:hover { // box-shadow: rgba(0, 0, 0, 0.25) 0px 25px 50px -12px; box-shadow: rgba(149, 157, 165, 0.2) 0px 8px 24px; .ugb-button-component { opacity: 1; } + + .stk-block-design__edit-btn-container { + visibility: visible; + opacity: 1; + transition-delay: 1s; + + .ugb-button-component { + pointer-events: initial; + } + } } + &.ugb--is-hidden { opacity: 0; pointer-events: none; @@ -215,12 +258,7 @@ // Full page previews .ugb-modal-design-library__full-pages { .stk--design-preview-large { - cursor: grab; - cursor: -webkit-grab; - &:active { - cursor: grabbing; - cursor: -webkit-grabbing; - } + cursor: default; } .ugb-design-control-pro-note { top: auto; @@ -236,7 +274,6 @@ } } - .ugb-design-library-items { .stk-spinner-container { height: 100%; @@ -257,3 +294,61 @@ } } } + +.stk-design-library__item-saved .ugb-design-library-item { + cursor: pointer; +} + +.stk-design-library__item-pages .ugb-design-library-item, +.ugb-design-library-item--pages { + cursor: default; + + .stk-block-design__design-container, + .stk--design-preview-large, + .stk-block-design__host-container, + .stk-block-design__host { + cursor: default; + } + + .ugb-modal-design-library__add-multi { + cursor: pointer; + } +} + +.stk-design-library-item__label-row { + display: flex; + align-items: center; + gap: 4px; + + h4 { + margin: 0; + } +} + +.stk-design-library-item__edit-button.ugb-button-component { + min-width: 32px; + width: 32px; + height: 32px; + padding: 0; + flex-shrink: 0; + .dashicons { + color: #000 !important; + } +} + +.stk-no-saved-designs { + max-width: 400px; + + span { + display: block; + margin: 30px auto; + } +} + +.ugb-design-library-items--empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} diff --git a/src/lazy-components/design-library/design-library-list/index.js b/src/lazy-components/design-library/design-library-list/index.js index 9a9b79a3f1..d60a0530da 100644 --- a/src/lazy-components/design-library/design-library-list/index.js +++ b/src/lazy-components/design-library/design-library-list/index.js @@ -7,8 +7,9 @@ import { useDesignLibraryContext } from '../context' /** * External dependencies */ -import { i18n } from 'stackable' +import { i18n, isPro } from 'stackable' import { usePresetControls } from '~stackable/hooks' +import { ProControl } from '~stackable/components' import classnames from 'classnames' /** @@ -25,47 +26,68 @@ const DesignLibraryList = memo( props => { className = '', designs, isBusy, + selectedTab, + selectedCategory, errors, } = props const containerRef = useRef( null ) + const canManageUserPatterns = useDesignLibraryContext()[ 8 ] + + const isEmpty = ! ( designs || [] ).length + const isSavedTabEmpty = selectedTab === 'saved' && isEmpty const listClasses = classnames( [ 'ugb-design-library-items', className, + { + 'ugb-design-library-items--empty': isSavedTabEmpty, + }, ] ) useEffect( () => { containerRef.current.scrollTop = 0 - }, [ designs ] ) + }, [ selectedTab, selectedCategory ] ) return
    - { isBusy && } - { ! isBusy && <> -
    - { ( designs || [] ).map( ( design, i ) => { - return ( - - ) - } ) } - - { ! ( designs || [] ).length && -

    { __( 'No designs found', i18n ) }

    - } - { typeof errors === 'object' && errors && Object.keys( errors ).length && -

    - { __( 'An error has occurred:', i18n ) } -
    - { Object.values( errors ).join( '; ' ) } -

    } -
    - } + { selectedTab === 'saved' && ! isPro + ? + : <> + { isBusy && } + { ! isBusy &&
    + { ( designs || [] ).map( ( design, i ) => { + return ( + + ) + } ) } + + { isSavedTabEmpty && +

    + { __( 'No designs saved yet', i18n ) } + { canManageUserPatterns && <> +
    + + { __( 'Tip: You can save your own section layouts to reuse them in your Stackable design library. Just click the "•••" (More) menu on a Stackable Columns block and choose "Save to Design Library".', i18n ) } + + } +

    + + } + { typeof errors === 'object' && errors && Object.keys( errors ).length && +

    + { __( 'An error has occurred:', i18n ) } +
    + { Object.values( errors ).join( '; ' ) } +

    } +
    } + + }
    } ) @@ -81,6 +103,7 @@ export default DesignLibraryList const DesignLibraryItem = memo( props => { const { design, designIndex } = props const wrapperRef = useRef( null ) + const hasRenderedRef = useRef( designIndex < 9 ) const [ shouldRender, setShouldRender ] = useState( designIndex < 9 ) const [ selectedTab, @@ -91,6 +114,7 @@ const DesignLibraryItem = memo( props => { containerScheme, backgroundScheme, enableBackground, + canManageUserPatterns, ] = useDesignLibraryContext() const { selectedNum, selectedData } = useMemo( () => { @@ -113,6 +137,7 @@ const DesignLibraryItem = memo( props => { selectedNum, selectedData, onClick: onSelectDesign, + canManageUserPatterns, } ), [ // Only track designId for memoization; other design properties will update when designId changes design.id || design.designId, @@ -123,6 +148,7 @@ const DesignLibraryItem = memo( props => { // selectedNum and selectedData are always in sync; updating selectedNum also updates selectedData selectedNum, onSelectDesign, + canManageUserPatterns, ] ) const { getPresetMarks } = usePresetControls( 'spacingSizes' ) @@ -131,41 +157,21 @@ const DesignLibraryItem = memo( props => { const presetMarks = useMemo( () => getPresetMarks() || null, [] ) useEffect( () => { - if ( selectedTab !== 'pages' ) { - return - } - let id - if ( typeof requestIdleCallback !== 'undefined' ) { - id = requestIdleCallback( () => ! shouldRender ? setShouldRender( true ) : {}, { timeout: ( designIndex + 1 ) * 500 } ) - } else { - // fallback, always render immediately the first design - id = setTimeout( () => setShouldRender( true ), designIndex * 500 ) - } - - return () => { - if ( typeof cancelIdleCallback !== 'undefined' ) { - cancelIdleCallback( id ) - } else { - clearTimeout( id ) - } - } - }, [ selectedTab ] ) - - useEffect( () => { - if ( selectedTab === 'pages' ) { - return - } - const rootEl = document.querySelector( '.ugb-modal-design-library__designs' ) if ( ! wrapperRef.current || ! rootEl ) { return } const observer = new IntersectionObserver( ( [ entry ] ) => { - // reduce flicker during rapid scrolls - requestAnimationFrame( () => { - requestAnimationFrame( () => setShouldRender( entry.isIntersecting || entry.intersectionRatio > 0 ) ) - } ) + if ( entry.isIntersecting || entry.intersectionRatio > 0 ) { + hasRenderedRef.current = true + setShouldRender( true ) + return + } + + if ( ! hasRenderedRef.current ) { + setShouldRender( false ) + } }, { root: rootEl, rootMargin: '500px', diff --git a/src/lazy-components/design-library/design-library-list/use-preview-renderer.js b/src/lazy-components/design-library/design-library-list/use-preview-renderer.js index 737ad6c805..a460cd40c0 100644 --- a/src/lazy-components/design-library/design-library-list/use-preview-renderer.js +++ b/src/lazy-components/design-library/design-library-list/use-preview-renderer.js @@ -34,6 +34,12 @@ import { cleanSerializedBlock } from '~stackable/util' const DEFAULT_CONTENT = { ...DEFAULT } +const previewParseCache = new Map() + +const getPreviewCacheKey = ( designId, selectedTab, isDesignLibraryDevMode ) => { + return `${ selectedTab }:${ designId }:${ isDesignLibraryDevMode ? 'dev' : 'prod' }` +} + export const usePreviewRenderer = ( props, shouldRender, spacingSize, ref, hostRef, shadowRoot, setIsLoading, stylesLoaded @@ -71,7 +77,7 @@ export const usePreviewRenderer = ( const siteTitle = useSelect( select => select( 'core' ).getEntityRecord( 'root', 'site' )?.title || 'InnovateCo', [] ) const isDesignLibraryDevMode = devMode && localStorage.getItem( 'stk__design_library__dev_mode' ) === '1' - const addHasBackground = selectedTab === 'patterns' + const addHasBackground = selectedTab !== 'pages' const updateShadowBodySize = _shadowBody => { const shadowBody = _shadowBody || shadowRoot?.querySelector( 'body' ) @@ -118,7 +124,7 @@ export const usePreviewRenderer = ( const scaleFactor = cardWidth > 0 ? cardWidth / 1300 : 1 // Divide by 1300, which is the width of preview in the shadow DOM let _bodyHeight = 1200 - if ( selectedTab === 'patterns' ) { + if ( selectedTab !== 'pages' ) { _bodyHeight = shadowBody.offsetHeight } @@ -216,6 +222,20 @@ export const usePreviewRenderer = ( return } + const cacheKey = getPreviewCacheKey( designId, selectedTab, isDesignLibraryDevMode ) + const cachedPreview = previewParseCache.get( cacheKey ) + + if ( cachedPreview ) { + categoriesRef.current = cachedPreview.categories + hasBackgroundTargetRef.current = cachedPreview.hasBackgroundTarget + blocksForSubstitutionRef.current = cachedPreview.blocksForSubstitution + setContent( cachedPreview.content ) + setContentForInsertion( cachedPreview.contentForInsertion ) + setIsLoading( false ) + renderedTemplate.current = template + return + } + // Reset per-template state and show spinner setIsLoading( true ) categoriesRef.current = [] @@ -225,13 +245,13 @@ export const usePreviewRenderer = ( let _parsedBlocksForInsertion = null const initialize = async () => { const _content = template - if ( selectedTab === 'patterns' ) { + if ( selectedTab !== 'pages' ) { const categorySlug = getCategorySlug( designId ) // For preview: always replace placeholders (ignore dev mode) - const _contentForPreview = replacePlaceholders( _content, categorySlug, false ) + const _contentForPreview = replacePlaceholders( _content, categorySlug, false, selectedTab ) // For insertion: only create separate content if dev mode is enabled - const _contentForInsertion = isDesignLibraryDevMode ? replacePlaceholders( _content, categorySlug, true ) : _contentForPreview + const _contentForInsertion = isDesignLibraryDevMode ? replacePlaceholders( _content, categorySlug, true, selectedTab ) : _contentForPreview categoriesRef.current.push( categorySlug ) @@ -249,12 +269,12 @@ export const usePreviewRenderer = ( // For preview: always replace placeholders (ignore dev mode) const designsContentForPreview = designs.map( ( design, i ) => - replacePlaceholders( design.template || design.content, categorySlugs[ i ], false ) + replacePlaceholders( design.template || design.content, categorySlugs[ i ], false, selectedTab ) ).join( '\n' ) // For insertion: only create separate content if dev mode is enabled const designsContentForInsertion = isDesignLibraryDevMode ? designs.map( ( design, i ) => - replacePlaceholders( design.template || design.content, categorySlugs[ i ], true ) + replacePlaceholders( design.template || design.content, categorySlugs[ i ], true, selectedTab ) ).join( '\n' ) : designsContentForPreview @@ -298,6 +318,16 @@ export const usePreviewRenderer = ( setContentForInsertion( parsedBlocksForInsertion ) setIsLoading( false ) renderedTemplate.current = template + + if ( cacheKey ) { + previewParseCache.set( cacheKey, { + categories: [ ...categoriesRef.current ], + hasBackgroundTarget: hasBackgroundTargetRef.current, + blocksForSubstitution, + content: parsedBlocks, + contentForInsertion: parsedBlocksForInsertion, + } ) + } } ) }, [ template, shouldRender ] ) diff --git a/src/lazy-components/design-library/design-library-list/use-shadow-root.js b/src/lazy-components/design-library/design-library-list/use-shadow-root.js index f27af95543..d6306ec57b 100644 --- a/src/lazy-components/design-library/design-library-list/use-shadow-root.js +++ b/src/lazy-components/design-library/design-library-list/use-shadow-root.js @@ -24,8 +24,14 @@ export const useShadowRoot = shouldRender => { ] ) useEffect( () => { - if ( shouldRender && hostRef.current ) { - const shadow = hostRef.current.shadowRoot || hostRef.current.attachShadow( { mode: 'open' } ) + if ( ! shouldRender || ! hostRef.current ) { + return + } + + const hadShadow = !! hostRef.current.shadowRoot + const shadow = hostRef.current.shadowRoot || hostRef.current.attachShadow( { mode: 'open' } ) + + if ( ! hadShadow ) { setStylesLoaded( 0 ) // Track existing style/link nodes in the shadow root to avoid duplicates @@ -95,9 +101,9 @@ export const useShadowRoot = shouldRender => { existingIds.add( node.id ) } } ) + } - setShadowRoot( shadow ) - } + setShadowRoot( shadow ) }, [ shouldRender ] ) return { diff --git a/src/lazy-components/design-library/editor.scss b/src/lazy-components/design-library/editor.scss index 3fadaa780e..0a10b9f0fb 100644 --- a/src/lazy-components/design-library/editor.scss +++ b/src/lazy-components/design-library/editor.scss @@ -102,6 +102,10 @@ padding: 24px; grid-column: 2 / 3; grid-row: 1 / 3; + + .ugb-design-control-pro-note { + height: 100%; + } } .ugb-modal-design-library__search { @@ -162,6 +166,17 @@ background: #fff; border-top: 1px solid #eee; } + .ugb-modal-design-library__footer-selection { + display: flex; + align-items: center; + gap: 12px; + .stk-design-library__delete-selected-button { + &[disabled] { + visibility: hidden; + pointer-events: none; + } + } + } // Make room for the footer buttons. &.ugb-modal-design-library--is-multiselect { .ugb-modal-design-library__sidebar { @@ -464,3 +479,9 @@ div.ugb-modal-design-library__enable-background { min-width: unset; } } + +body:has(.stk-design-library__item-saved) { + .components-snackbar-list { + z-index: 100001; + } +} diff --git a/src/lazy-components/design-library/header-actions.js b/src/lazy-components/design-library/header-actions.js index 3e87fb3f31..58b8aed93a 100644 --- a/src/lazy-components/design-library/header-actions.js +++ b/src/lazy-components/design-library/header-actions.js @@ -2,7 +2,7 @@ * External deprendencies */ import { - i18n, isPro, devMode, + i18n, isPro, devMode, showProNotice, } from 'stackable' import { AdvancedToolbarControl, Button } from '~stackable/components' @@ -28,21 +28,29 @@ export const HeaderActions = props => { setDoReset, onClose, } = props + + let controls = [ + { + value: 'patterns', + title: __( 'Patterns', i18n ), + }, + { + value: 'pages', + title: __( 'Pages', i18n ), + }, + ] + + controls = ! isPro && ! showProNotice ? controls : [ ...controls, { + value: 'saved', + title: __( 'Saved', i18n ), + } ] + return <> { /* DEV NOTE: hide for now */ } { // The sidebar designs are used to update the list of blocks in the sidebar. const [ sidebarDesigns, setSidebarDesigns ] = useState( [] ) - // The display designs are used to list the available designs the user can choose. - const [ displayDesigns, setDisplayDesigns ] = useState( [] ) const [ errors, setErrors ] = useState( null ) const [ enableBackground, setEnableBackground ] = useState( false ) const [ selectedContainerScheme, setSelectedContainerScheme ] = useState( '' ) const [ selectedBackgroundScheme, setSelectedBackgroundScheme ] = useState( '' ) + const canManageUserPatterns = useCanManageUserPatterns() // For version 4, the default tab is now 'patterns' and for category, we use '' instead of 'All'. // So we need to update the local storage values here. @@ -83,6 +85,27 @@ const ModalDesignLibrary = props => { } }, [] ) + // Selection is scoped to the active tab — clear when switching tabs. + useEffect( () => { + setSelectedDesignIds( [] ) + setSelectedDesignData( [] ) + }, [ selectedTab ] ) + + // Keep the saved tab in sync when patterns are created/updated/deleted elsewhere. + useEffect( () => { + const onSavedPatternsLoaded = patterns => { + if ( selectedTab === 'saved' ) { + setSidebarDesigns( patterns ) + } + } + + addAction( 'stackable.design-library.saved-patterns-loaded', 'stackable/design-library-modal', onSavedPatternsLoaded ) + + return () => { + removeAction( 'stackable.design-library.saved-patterns-loaded', 'stackable/design-library-modal' ) + } + }, [ selectedTab ] ) + // Update the designs on the sidebar. (this will trigger the display designs update next) useEffect( () => { setIsBusy( true ) @@ -95,32 +118,49 @@ const ModalDesignLibrary = props => { } ).then( designs => { let _designs = designs - if ( typeof designs === 'object' && designs.error ) { + if ( typeof designs === 'object' && ! Array.isArray( designs ) && designs.error ) { _designs = [] setErrors( designs.error ) } + if ( selectedTab === 'saved' ) { + doAction( 'stackable.design-library.saved-patterns-loaded', _designs ) + } + setSidebarDesigns( _designs ) setSelectedCategory( '' ) + } ).catch( () => { + setErrors( { message: __( 'Failed to load designs.', i18n ) } ) } ).finally( () => { setDoReset( false ) setIsBusy( false ) } ) }, [ doReset, selectedTab ] ) - // This updates the displayed designs the user can pick. - useEffect( () => { - filterDesigns( { - library: sidebarDesigns, - category: selectedCategory, - plan: selectedPlan.key, - } ).then( designs => { - setDisplayDesigns( designs ) - } ) - }, [ sidebarDesigns, selectedPlan, selectedCategory ] ) + const displayDesigns = useMemo( () => { + let library = sidebarDesigns + + if ( selectedPlan.key && selectedTab !== 'saved' ) { + library = library.filter( ( { plan } ) => plan === selectedPlan.key ) + } + + if ( selectedCategory ) { + library = library.filter( ( { category } ) => category === selectedCategory ) + } + + return library + }, [ sidebarDesigns, selectedPlan.key, selectedCategory, selectedTab ] ) + + const currentTabSelectedDesigns = useMemo( () => { + return selectedDesignData.filter( design => design.type === selectedTab ) + }, [ selectedDesignData, selectedTab ] ) + + const currentTabSelectedDesignIds = useMemo( () => { + return currentTabSelectedDesigns.map( design => design.designId ) + }, [ currentTabSelectedDesigns ] ) const colorSchemeHelpCallback = () => { - if ( selectedDesignIds.length ) { + if ( currentTabSelectedDesignIds.length ) { // eslint-disable-next-line no-alert const confirmClose = window.confirm( sprintf( __( 'You have one or more designs selected. Navigating to %s will close the Design Library and your current selection will be lost. Do you want to continue?', i18n ), __( 'Color Schemes', i18n ) ) ) if ( ! confirmClose ) { @@ -140,7 +180,7 @@ const ModalDesignLibrary = props => { const onSelectDesign = useCallback( ( designId, category, parsedBlocks, blocksForSubstitution, selectedPreviewSize ) => { if ( selectedTab === 'pages' ) { const selectedDesign = [ { - designId, category, designData: parsedBlocks, blocksForSubstitution, selectedPreviewSize, + designId, category, designData: parsedBlocks, blocksForSubstitution, selectedPreviewSize, type: selectedTab, } ] addDesign( selectedDesign ) @@ -170,6 +210,7 @@ const ModalDesignLibrary = props => { } else { newSelectedDesignData.push( { designId, category, designData: parsedBlocks, blocksForSubstitution, selectedPreviewSize, + type: selectedTab, } ) } @@ -199,6 +240,7 @@ const ModalDesignLibrary = props => { selectedContainerScheme, selectedBackgroundScheme, enableBackground, + canManageUserPatterns, ], [ selectedTab, @@ -209,6 +251,7 @@ const ModalDesignLibrary = props => { selectedContainerScheme, selectedBackgroundScheme, enableBackground, + canManageUserPatterns, ] ) @@ -248,7 +291,7 @@ const ModalDesignLibrary = props => {
    - { selectedTab === 'patterns' && { className={ `stk-design-library__item-${ selectedTab }` } isBusy={ isBusy } designs={ displayDesigns } + selectedTab={ selectedTab } + selectedCategory={ selectedCategory } errors={ errors } /> - { selectedTab === 'patterns' &&