diff --git a/.stylelintrc.js b/.stylelintrc.js index 4f84ca8829..9d88b8ae47 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -36,6 +36,23 @@ module.exports = { "selector-class-pattern": null, "value-keyword-case": null, "length-zero-no-unit": null, - "font-family-no-missing-generic-family-keyword": null + "font-family-no-missing-generic-family-keyword": null, + "property-no-unknown": [ + true, + { + ignoreProperties: [ + "container-type", + "container-name", + ], + }, + ], + "scss/at-rule-no-unknown": [ + true, + { + ignoreAtRules: [ + "container", + ], + }, + ], } } diff --git a/src/block-components/columns/style.js b/src/block-components/columns/style.js index 4dc4a3a17a..3b72cbde42 100644 --- a/src/block-components/columns/style.js +++ b/src/block-components/columns/style.js @@ -181,5 +181,5 @@ export const addStyles = ( blockStyleGenerator, props = {} ) => { }, } ) } ) - } ) + }, [ 'columnArrangement' ] ) } diff --git a/src/block-components/image/editor.scss b/src/block-components/image/editor.scss index 2feb34262c..e0992197c6 100644 --- a/src/block-components/image/editor.scss +++ b/src/block-components/image/editor.scss @@ -84,6 +84,17 @@ } } +// Hide the size tooltip when the image resizer is too narrow. +.stk-img-resizer { + container-type: inline-size; +} + +@container (max-width: 139px) { + .stk-resizer-tooltip { + display: none !important; + } +} + .stk-img-resizer-tooltip { background: rgba(0, 0, 0, 0.75); border-radius: 2px; @@ -136,11 +147,6 @@ } } -// If the image its too small, don't show the tooltip. -.stk--too-small .stk-img-resizer-tooltip { - display: none !important; -} - .stk-image-size-popup { .components-popover__content { padding: 16px; diff --git a/src/block-components/image/image.js b/src/block-components/image/image.js index 7f7d98036b..6d506f142a 100644 --- a/src/block-components/image/image.js +++ b/src/block-components/image/image.js @@ -80,7 +80,6 @@ const Image = memo( props => { const [ currentHeight, setCurrentHeight ] = useState() const [ currentWidth, setCurrentWidth ] = useState() - const [ imageWidthIsTooSmall, setImageWidthIsTooSmall ] = useState( false ) const imageRef = useRef() const wrapperRef = useRef() @@ -109,24 +108,8 @@ const Image = memo( props => { 'stk--never-resized': ( ! src || hasImageError ) && neverResized, 'stk--is-resizing': isResizing, 'stk--no-click-to-edit': ! props.enableClickToEdit, - // If the image is too small, hide the size tooltip. - 'stk--too-small': imageWidthIsTooSmall, } ) - // Observe the size of the image, if it's too small, we shouldn't show the - // size tooltip. - useEffect( () => { - if ( imageRef.current ) { - const resizeObserver = new ResizeObserver( entries => { // eslint-disable-line compat/compat - for ( const entry of entries ) { - setImageWidthIsTooSmall( entry.contentRect.width < 140 ) - } - } ) - resizeObserver.observe( imageRef.current ) - return () => resizeObserver.disconnect() - } - }, [ imageRef.current ] ) - const imageClasses = getImageClasses( props ) return ( diff --git a/src/block-components/typography/edit.js b/src/block-components/typography/edit.js index 61fe7b3cc4..5d5055cd07 100644 --- a/src/block-components/typography/edit.js +++ b/src/block-components/typography/edit.js @@ -25,9 +25,7 @@ import { escapeHTMLIfInvalid } from './util' /** * WordPress dependencies */ -import { - useEffect, useState, useCallback, memo, -} from '@wordpress/element' +import { useCallback, memo } from '@wordpress/element' import { __ } from '@wordpress/i18n' import { applyFilters } from '@wordpress/hooks' import { useSelect } from '@wordpress/data' @@ -80,29 +78,13 @@ export const Controls = props => { } = useAttributeEditHandlers( attrNameTemplate ) const attributeName = getAttrNameFunction( attrNameTemplate ) const text = getAttribute( 'text' ) - const [ debouncedText, setDebouncedText ] = useState( text ) const useTypographyAsPresets = useSelect( select => select( 'stackable/global-preset-controls.custom' )?.getUseTypographyAsPresets() ?? false ) - useEffect( () => { - if ( text !== debouncedText ) { - setDebouncedText( text ) - } - }, [ text ] ) - - useEffect( () => { - let timeout - if ( debouncedText !== text ) { - timeout = setTimeout( () => { - updateAttribute( 'text', debouncedText ) - }, 300 ) - } - - return () => clearTimeout( timeout ) - }, [ updateAttribute, debouncedText, text ] ) - - const onChangeContent = useCallback( text => setDebouncedText( escapeHTMLIfInvalid( text ) ), [] ) + const onChangeContent = useCallback( newText => { + updateAttribute( 'text', escapeHTMLIfInvalid( newText ) ) + }, [ updateAttribute ] ) const presetMarks = usePresetControls( 'fontSizes' ) ?.getPresetMarks( { customOnly: useTypographyAsPresets } ) || null @@ -115,14 +97,14 @@ export const Controls = props => { label={ __( 'Content', i18n ) } hasPanelModifiedIndicator={ false } isMultiline={ isMultiline } - value={ unescape( debouncedText ) } + value={ unescape( text ) } onChange={ onChangeContent } /** * Pass the unescaped Dynamic Content `onChange` function. * * @param {string} text Text with dynamic content. */ - changeDynamicContent={ setDebouncedText } + changeDynamicContent={ onChangeContent } isDynamic={ true } /> ) } diff --git a/src/block-components/typography/index.js b/src/block-components/typography/index.js index 664ca2e384..990be75354 100644 --- a/src/block-components/typography/index.js +++ b/src/block-components/typography/index.js @@ -46,7 +46,7 @@ export const Typography = memo( forwardRef( ( props, ref ) => { defaultValue, withoutInteractiveFormatting = false, allowedFormats = null, - enableDebounce = true, // If false, onChange will be called immediately. + enableDebounce = false, // Set true to batch setAttributes (e.g. very large pages). ...propsToPass } = props @@ -105,13 +105,12 @@ export const Typography = memo( forwardRef( ( props, ref ) => { className={ className } tagName={ TagName } value={ dynamicContentText } - onChange={ value => { - if ( enableDebounce ) { - setDebouncedText( value ) - } else { - onChange( value ) + onChange={ newValue => { + setDebouncedText( newValue ) + if ( ! enableDebounce ) { + onChange( newValue ) } - } } + } } ref={ ref } withoutInteractiveFormatting={ withoutInteractiveFormatting } allowedFormats={ allowedFormats } diff --git a/src/block/icon-list-item/edit.js b/src/block/icon-list-item/edit.js index 4e78765fec..ee4f79bc0f 100644 --- a/src/block/icon-list-item/edit.js +++ b/src/block/icon-list-item/edit.js @@ -180,7 +180,6 @@ const Edit = props => { ) } : undefined } - enableDebounce={ false } /> diff --git a/src/components/block-css/block-style-generator-class.js b/src/components/block-css/block-style-generator-class.js index c2adb4f6eb..46f41033db 100644 --- a/src/components/block-css/block-style-generator-class.js +++ b/src/components/block-css/block-style-generator-class.js @@ -18,6 +18,11 @@ import { getAttrName, getUniqueBlockClass } from '~stackable/util' import { getDynamicContentEdit } from '../dynamic-content-control' import { applyFilters } from '@wordpress/hooks' import { BlockCssFunc } from '.' +import { + formAllPossibleAttributeNames, + getCallbackAttributeDependencies, + getDependencyAttrnamesFast, +} from './util' import { pickBy } from 'lodash' export class BlockStyleGenerator { @@ -25,6 +30,7 @@ export class BlockStyleGenerator { this.commonProps = commonProps this._blockStyles = {} // This holds all the blockStyles indices, keys are the attrName this._dynamicBlockStyles = [] // Holds functions that will be called when generating blocks styles. + this._dynamicStyleDependencyRootNames = [] // Root attr names for addBlockStyleConditionally styles. this._blockStyleNamesWithValuePreCallbacks = [] // This holds all block style keys that have valuePreCallbacks, becuase these will need to be run even if the attribute is blank. this._orderedStyles = [] // This holds all the blockStyles added in order } @@ -89,11 +95,14 @@ export class BlockStyleGenerator { * this is a less performant way to add block styles. * * @param {Function} fn function that's called when generating block styles + * @param {Array} dependencyAttrNames Root attribute names that affect + * the conditional styles (e.g. columnArrangement for column order CSS). */ - addBlockStyleConditionally( fn ) { + addBlockStyleConditionally( fn, dependencyAttrNames = [] ) { this._orderedStyles.push( fn ) const blockStyleIndex = this._orderedStyles.length - 1 this._dynamicBlockStyles.push( blockStyleIndex ) + this._dynamicStyleDependencyRootNames.push( ...dependencyAttrNames ) } /** @@ -172,6 +181,56 @@ export class BlockStyleGenerator { return Object.keys( pickBy( attributes, test ) ) } + /** + * Returns all attribute names that can affect editor/save CSS for this block. + * Cached per BlockStyleGenerator instance (styles are registered at load time). + * + * @return {Array} Sorted, deduplicated attribute names + */ + getStyleDependencyAttributeNames() { + if ( this._styleDependencyAttributeNames ) { + return this._styleDependencyAttributeNames + } + + const attrNameSet = new Set() + + const addNames = names => { + names.forEach( name => attrNameSet.add( name ) ) + } + + this._orderedStyles.forEach( blockStyle => { + if ( typeof blockStyle === 'function' ) { + return + } + + addNames( getDependencyAttrnamesFast( { + ...this.commonProps, + ...blockStyle, + } ) ) + + if ( typeof blockStyle.renderCondition === 'string' && blockStyle.renderCondition ) { + addNames( formAllPossibleAttributeNames( [ blockStyle.renderCondition ] ) ) + } + + if ( typeof blockStyle.enabledCallback === 'function' ) { + addNames( formAllPossibleAttributeNames( + getCallbackAttributeDependencies( blockStyle.enabledCallback ) + ) ) + } + } ) + + this._dynamicStyleDependencyRootNames.forEach( attrName => { + addNames( formAllPossibleAttributeNames( [ attrName ] ) ) + } ) + + this._blockStyleNamesWithValuePreCallbacks.forEach( attrName => { + addNames( formAllPossibleAttributeNames( [ attrName ] ) ) + } ) + + this._styleDependencyAttributeNames = Array.from( attrNameSet ).sort() + return this._styleDependencyAttributeNames + } + /** * Checks the `renderCondition` property in blockStyle if it matches the * condition to render this, if present. diff --git a/src/components/block-css/use-block-style-generator.js b/src/components/block-css/use-block-style-generator.js index 5118f7714c..c3b43c3baf 100644 --- a/src/components/block-css/use-block-style-generator.js +++ b/src/components/block-css/use-block-style-generator.js @@ -1,8 +1,11 @@ import { useQueryLoopInstanceId } from '~stackable/util' -import { useMemo, useRef } from '@wordpress/element' +import { + useLayoutEffect, useMemo, useRef, +} from '@wordpress/element' +import { dispatch, select } from '@wordpress/data' import { useRafEffect } from '~stackable/hooks' -import { dispatch } from '@wordpress/data' import CssSaveCompiler from './css-save-compiler' +import { createStyleDependencyFingerprint } from './util' export const useBlockCssGenerator = props => { const { @@ -21,24 +24,26 @@ export const useBlockCssGenerator = props => { // Generate the CSS styles. const instanceId = useQueryLoopInstanceId( attributes.uniqueId ) - // Keep the old text attribute for comparison to prevent block style generation when only the text attribute has changed. - const oldText = useRef( attributes.text ) + const styleDependencyAttrNames = useMemo( + () => blockStyles.getStyleDependencyAttributeNames(), + [ blockStyles ] + ) - // Keep the generated CSS for editor and return it when only the text attribute has changed. - const oldCss = useRef( null ) + // Cheap fingerprint of style-related attributes only. Recomputed when + // attributes change, but editCss only regenerates when the fingerprint changes. + const styleFingerprint = useMemo( + () => createStyleDependencyFingerprint( attributes, styleDependencyAttrNames ), + [ attributes, styleDependencyAttrNames ] + ) const editCss = useMemo( () => { - if ( oldText.current !== attributes.text ) { - oldText.current = attributes.text - return oldCss.current - } // Gather only the attributes that have values and all their // corresponding block style definitions. const attrNamesWithValues = blockStyles.getAttributesWithValues( attributes ) blockStyleDefsRef.current = blockStyles.getBlockStyles( attrNamesWithValues ) // These are the styles to be displayed in the editor. - const css = blockStyles.generateBlockStylesForEditor( attributes, blockStyleDefsRef.current, { + return blockStyles.generateBlockStylesForEditor( attributes, blockStyleDefsRef.current, { version, blockState, uniqueId: attributes.uniqueId, @@ -46,16 +51,9 @@ export const useBlockCssGenerator = props => { clientId, context, // This is used for dynamic content. } ) - oldCss.current = css - return css - }, [ attributes, version, blockState, clientId, attributes.uniqueId, instanceId, context ] ) + }, [ styleFingerprint, version, blockState, clientId, attributes.uniqueId, instanceId, context, blockStyles ] ) useRafEffect( () => { - if ( oldText.current !== attributes.text ) { - oldText.current = attributes.text - return - } - const cssCompiler = new CssSaveCompiler() // Generate the styles that are to be saved with the actual block. @@ -73,12 +71,28 @@ export const useBlockCssGenerator = props => { return } - // Use setAttributes to realiably update the generated CSS. + // Use setAttributes to reliably update the generated CSS. // Mutating the attributes directly will not trigger a re-render, // but might not properly save the changes. dispatch( 'core/block-editor' ).__unstableMarkNextChangeAsNotPersistent() setAttributes( { generatedCss: saveCss } ) - }, [ attributes, version, setAttributes ] ) + }, [ styleFingerprint, version, blockStyles, setAttributes ] ) + + const styleKey = `${ clientId }-${ instanceId }` + + useLayoutEffect( () => { + dispatch( 'stackable/editor-block-css' ).setBlockCss( styleKey, editCss || '' ) + return () => { + // Keep CSS in the store across preview remounts. Only remove when the + // block was actually deleted from the editor. + const block = select( 'core/block-editor' )?.getBlock( clientId ) + if ( ! block ) { + dispatch( 'stackable/editor-block-css' ).removeBlockCss( styleKey ) + } + } + }, [ styleKey, editCss, clientId ] ) - return editCss + // We used to return the CSS here, but for optimization, now + // CSS is injected via the unified editor stylesheet plugin. + return null } diff --git a/src/components/block-css/util.js b/src/components/block-css/util.js index 0f11e0ab5f..475d8968f9 100644 --- a/src/components/block-css/util.js +++ b/src/components/block-css/util.js @@ -156,6 +156,62 @@ export const formAllPossibleAttributeNames = attrNames => { }, [] ) } +/** + * Extract attribute names referenced via getAttribute() or attributes[...] in a + * callback. Used for style fingerprinting when dependencies are incomplete. + * + * @param {Function} callback + * @return {Array} Root attribute names (without device/state suffixes) + */ +export const getCallbackAttributeDependencies = callback => { + if ( typeof callback !== 'function' ) { + return [] + } + + const names = new Set() + const source = callback.toString() + + const getAttributeRegex = /getAttribute\s*\(\s*['"]([^'"]+)['"]/g + let match = getAttributeRegex.exec( source ) + while ( match ) { + names.add( match[ 1 ] ) + match = getAttributeRegex.exec( source ) + } + + const attributesAccessRegex = /attributes\s*\[\s*['"]([^'"]+)['"]\s*\]/g + match = attributesAccessRegex.exec( source ) + while ( match ) { + names.add( match[ 1 ] ) + match = attributesAccessRegex.exec( source ) + } + + return Array.from( names ) +} + +/** + * Serializes only the attribute values that can affect generated block CSS. + * Used to avoid regenerating CSS when unrelated attributes change (e.g. text). + * + * @param {Object} attributes Block attributes + * @param {Array} attrNames Style dependency attribute names + * @return {string} Fingerprint string + */ +export const createStyleDependencyFingerprint = ( attributes, attrNames ) => { + if ( ! attrNames?.length ) { + return '' + } + + const values = {} + for ( let i = 0; i < attrNames.length; i++ ) { + const attrName = attrNames[ i ] + if ( attrName in attributes ) { + values[ attrName ] = attributes[ attrName ] + } + } + + return JSON.stringify( values ) +} + /** * Prepends a class * diff --git a/src/higher-order/with-block-wrapper/index.js b/src/higher-order/with-block-wrapper/index.js index 46abbc98bd..e75e57cf2b 100644 --- a/src/higher-order/with-block-wrapper/index.js +++ b/src/higher-order/with-block-wrapper/index.js @@ -57,6 +57,15 @@ let firstLoad = true // for speed. let selectedBlock = null +// Stagger root block mounts after preview remount to spread React/CSS work. +// Editor block CSS is persisted in the store and re-adopted on preview switch. +const ROOT_BLOCK_MOUNT_DELAY_BASE_MS = 50 +const ROOT_BLOCK_MOUNT_DELAY_SPREAD_MS = 100 + +const getRootBlockMountDelay = () => { + return ROOT_BLOCK_MOUNT_DELAY_BASE_MS + Math.floor( Math.random() * ROOT_BLOCK_MOUNT_DELAY_SPREAD_MS ) +} + /** * This optimizes the preview device switching. Without this, there will be a * 4-5 second delay when switching preview devices. With this, it's @@ -111,15 +120,18 @@ export const useDevicePreviewOptimization = blockProps => { const [ isDisplayed, setIsDisplayed ] = useState( displayedByDefault ) - // If the block isn't displayed, display it after a delay, this trick - // apparently makes Desktop -> Tablet/Mobile previews fast. + // Delay mounting root blocks to spread work across frames after preview remount. + // Each root block gets a random delay in [base, base + spread) so they don't + // all commit in the same frame. useEffect( () => { if ( ! isDisplayed ) { const t = setTimeout( () => { setIsDisplayed( true ) - }, 300 ) + }, getRootBlockMountDelay() ) return () => clearTimeout( t ) } + + return undefined }, [ isDisplayed ] ) return isDisplayed diff --git a/src/plugins/editor-block-css/block-style-sheets.js b/src/plugins/editor-block-css/block-style-sheets.js new file mode 100644 index 0000000000..5aa3dc7d83 --- /dev/null +++ b/src/plugins/editor-block-css/block-style-sheets.js @@ -0,0 +1,254 @@ +/** + * Per-block editor styles via Constructable Stylesheets, with a per-block + *