From 36dea55d2118dc06a7a40766d37752567e941d5d Mon Sep 17 00:00:00 2001 From: Artemio Morales Date: Thu, 15 Jun 2023 08:59:07 -0500 Subject: [PATCH] Image block: Add animation toggle to lightbox behavior (#51357) * Remove reset button in behaviors, use dropdown option instead * Use default option reading from the theme, prevent autoupdate * Add zoom animation UI and styles * Add logic to use original image and scale its dimensions Because the image in the lightbox has absolute positioning and does not respect the padding of its parent container, we need to get references to both the parent
element and the element to set the right scale and smoothly animate the zoom. To accomplish that, since we don't have access to the img src or its natural width and height until it actually appears in the DOM, I needed to hoist the imgSrc up to the parent context to allow for retrieval of the target dimensions from an element created on the fly. * Remove extraneous help text * Manually center lightbox image to improve animation performance The previous method of centering the image was peforming poorly on mobile. By doing more manual calculation, the animation now performs much better. * Move and reenable class declaration for overflow The 'has-lightbox-open' class was previously causing the content to shift before the image animation occurred and looked like a mistake. I've now moved the declaration so that the class is added during the animation so it draws less attention. * Add prefers reduced motion accessibility styles * Modify fade styles to prevent image flashing * Simplify code for lightbox UI * Fix PHP error and linter syntax * Clean up code; fix bug, add comments Mostly moved code around, renamed variables for clarity, and add comments. Fixed a bug wherein the lightbox wouldn't close on scroll when using a fade animation. * Fix bug wherein newly placed images were not setting lightbox animation * Fix bug wherein vertical images were stretched on mobile Removed stylesheet padding declarations for the lightbox and cleaned up logic to ensure correct dimensions get set for vertical images on mobile devices. * Update e2e tests * Update e2e tests, fix selector showing when it should not --------- Co-authored-by: Carlos Bravo --- lib/block-supports/behaviors.php | 38 ++-- lib/theme.json | 5 +- packages/block-editor/src/hooks/behaviors.js | 121 +++++++++---- .../block-library/src/image/interactivity.js | 168 ++++++++++++++++-- packages/block-library/src/image/style.scss | 88 ++++++++- test/e2e/specs/editor/blocks/image.spec.js | 14 +- .../specs/editor/various/behaviors.spec.js | 102 ++--------- .../behaviors-enabled/theme.json | 5 +- .../behaviors-ui-disabled/theme.json | 4 +- 9 files changed, 374 insertions(+), 171 deletions(-) diff --git a/lib/block-supports/behaviors.php b/lib/block-supports/behaviors.php index 55a3419e466fb..27e5faeb8e8a4 100644 --- a/lib/block-supports/behaviors.php +++ b/lib/block-supports/behaviors.php @@ -48,18 +48,18 @@ function gutenberg_render_behaviors_support_lightbox( $block_content, $block ) { $link_destination = isset( $block['attrs']['linkDestination'] ) ? $block['attrs']['linkDestination'] : 'none'; // Get the lightbox setting from the block attributes. if ( isset( $block['attrs']['behaviors']['lightbox'] ) ) { - $lightbox = $block['attrs']['behaviors']['lightbox']; + $lightbox_settings = $block['attrs']['behaviors']['lightbox']; // If the lightbox setting is not set in the block attributes, get it from the theme.json file. } else { $theme_data = WP_Theme_JSON_Resolver_Gutenberg::get_merged_data()->get_data(); if ( isset( $theme_data['behaviors']['blocks'][ $block['blockName'] ]['lightbox'] ) ) { - $lightbox = $theme_data['behaviors']['blocks'][ $block['blockName'] ]['lightbox']; + $lightbox_settings = $theme_data['behaviors']['blocks'][ $block['blockName'] ]['lightbox']; } else { - $lightbox = false; + $lightbox_settings = null; } } - if ( ! $lightbox || 'none' !== $link_destination || empty( $experiments['gutenberg-interactivity-api-core-blocks'] ) ) { + if ( ! $lightbox_settings || 'none' !== $link_destination || empty( $experiments['gutenberg-interactivity-api-core-blocks'] ) ) { return $block_content; } @@ -75,11 +75,28 @@ function gutenberg_render_behaviors_support_lightbox( $block_content, $block ) { } $content = $processor->get_updated_html(); + $lightbox_animation = ''; + if ( isset( $lightbox_settings['animation'] ) ) { + $lightbox_animation = $lightbox_settings['animation']; + } + + // We want to store the src in the context so we can set it dynamically when the lightbox is opened. + $z = new WP_HTML_Tag_Processor( $content ); + $z->next_tag( 'img' ); + if ( isset( $block['attrs']['id'] ) ) { + $img_src = wp_get_attachment_url( $block['attrs']['id'] ); + } else { + $img_src = $z->get_attribute( 'src' ); + } + $w = new WP_HTML_Tag_Processor( $content ); $w->next_tag( 'figure' ); $w->add_class( 'wp-lightbox-container' ); $w->set_attribute( 'data-wp-interactive', true ); - $w->set_attribute( 'data-wp-context', '{ "core": { "image": { "initialized": false, "lightboxEnabled": false } } }' ); + $w->set_attribute( + 'data-wp-context', + sprintf( '{ "core":{ "image": { "initialized": false, "imageSrc": "%s", "lightboxEnabled": false, "lightboxAnimation": "%s", "hideAnimationEnabled": false } } }', $img_src, $lightbox_animation ) + ); $body_content = $w->get_updated_html(); // Wrap the image in the body content with a button. @@ -91,15 +108,9 @@ function gutenberg_render_behaviors_support_lightbox( $block_content, $block ) { ''; $body_content = preg_replace( '/]+>/', $button, $body_content ); - // Add directive to expand modal image if appropriate. + // Add src to the modal image. $m = new WP_HTML_Tag_Processor( $content ); $m->next_tag( 'img' ); - if ( isset( $block['attrs']['id'] ) ) { - $img_src = wp_get_attachment_url( $block['attrs']['id'] ); - } else { - $img_src = $m->get_attribute( 'src' ); - } - $m->set_attribute( 'data-wp-context', '{ "core": { "image": { "imageSrc": "' . $img_src . '"} } }' ); $m->set_attribute( 'data-wp-bind--src', 'selectors.core.image.imageSrc' ); $modal_content = $m->get_updated_html(); @@ -111,11 +122,12 @@ function gutenberg_render_behaviors_support_lightbox( $block_content, $block ) { $close_button_label = esc_attr__( 'Close', 'gutenberg' ); $lightbox_html = << - hasBlockSupport( blockName, 'behaviors.' + behaviorName ) && + hasBlockSupport( blockName, `behaviors.${ behaviorName }` ) && behaviorValue ) // Filter out behaviors that are disabled. .map( ( [ behaviorName ] ) => ( { value: behaviorName, - label: - // Capitalize the first letter of the behavior name. - behaviorName[ 0 ].toUpperCase() + - behaviorName.slice( 1 ).toLowerCase(), + // Capitalize the first letter of the behavior name. + label: `${ behaviorName.charAt( 0 ).toUpperCase() }${ behaviorName + .slice( 1 ) + .toLowerCase() }`, } ) ); - // If every behavior is disabled, do not show the behaviors inspector control. - if ( behaviorsOptions.length === 0 ) return null; - - const options = [ noBehaviorsOption, ...behaviorsOptions ]; + const options = [ + ...Object.values( defaultBehaviors ), + ...behaviorsOptions, + ]; + // If every behavior is disabled, do not show the behaviors inspector control. + if ( behaviorsOptions.length === 0 ) { + return null; + } // Block behaviors take precedence over theme behaviors. - const behaviors = merge( themeBehaviors, blockBehaviors || {} ); + const behaviors = { ...themeBehaviors, ...( blockBehaviors || {} ) }; const helpText = disabled ? __( 'The lightbox behavior is disabled for linked images.' ) - : __( 'Add behaviors.' ); + : ''; + + const value = () => { + if ( blockBehaviors === undefined ) { + return 'default'; + } + if ( behaviors?.lightbox.enabled ) { + return 'lightbox'; + } + return ''; + }; return ( @@ -81,24 +93,37 @@ function BehaviorsControl( { + { behaviors?.lightbox.enabled && ( + + ) } - - - ); } @@ -129,8 +154,8 @@ export const withBehaviors = createHigherOrderComponent( ( BlockEdit ) => { { - if ( nextValue === undefined ) { + onChangeBehavior={ ( nextValue ) => { + if ( nextValue === 'default' ) { props.setAttributes( { behaviors: undefined, } ); @@ -139,11 +164,29 @@ export const withBehaviors = createHigherOrderComponent( ( BlockEdit ) => { // change the default value (true) so we save it in the attributes. props.setAttributes( { behaviors: { - lightbox: nextValue === 'lightbox', + lightbox: { + enabled: nextValue === 'lightbox', + animation: + nextValue === 'lightbox' + ? 'zoom' + : '', + }, }, } ); } } } + onChangeAnimation={ ( nextValue ) => { + props.setAttributes( { + behaviors: { + lightbox: { + enabled: + props.attributes.behaviors.lightbox + .enabled, + animation: nextValue, + }, + }, + } ); + } } disabled={ blockHasLink } /> diff --git a/packages/block-library/src/image/interactivity.js b/packages/block-library/src/image/interactivity.js index 552bdf13a66ca..2ef370496a894 100644 --- a/packages/block-library/src/image/interactivity.js +++ b/packages/block-library/src/image/interactivity.js @@ -21,28 +21,70 @@ store( { actions: { core: { image: { - showLightbox: ( { context } ) => { + showLightbox: ( { context, event } ) => { context.core.image.initialized = true; - context.core.image.lightboxEnabled = true; context.core.image.lastFocusedElement = window.document.activeElement; - context.core.image.scrollPosition = window.scrollY; - document.documentElement.classList.add( - 'has-lightbox-open' - ); + context.core.image.scrollDelta = 0; + + // Since the img is hidden and its src not loaded until + // the lightbox is opened, let's create an img element on the fly + // so we can get the dimensions we need to calculate the styles + const imgDom = document.createElement( 'img' ); + + imgDom.onload = function () { + // Enable the lightbox only after the image + // is loaded to prevent flashing of unstyled content + context.core.image.lightboxEnabled = true; + if ( context.core.image.lightboxAnimation === 'zoom' ) { + setZoomStyles( imgDom, context, event ); + } + + // Hide overflow only when the animation is in progress, + // otherwise the removal of the scrollbars will draw attention + // to itself and look like an error + document.documentElement.classList.add( + 'has-lightbox-open' + ); + }; + imgDom.setAttribute( 'src', context.core.image.imageSrc ); }, hideLightbox: async ( { context, event } ) => { + context.core.image.hideAnimationEnabled = true; if ( context.core.image.lightboxEnabled ) { // If scrolling, wait a moment before closing the lightbox. - if ( - event.type === 'mousewheel' && - Math.abs( - window.scrollY - - context.core.image.scrollPosition - ) < 5 + if ( context.core.image.lightboxAnimation === 'fade' ) { + context.core.image.scrollDelta += event.deltaY; + if ( + event.type === 'mousewheel' && + Math.abs( + window.scrollY - + context.core.image.scrollDelta + ) < 10 + ) { + return; + } + } else if ( + context.core.image.lightboxAnimation === 'zoom' ) { - return; + // Disable scroll until the zoom animation ends. + // Get the current page scroll position + const scrollTop = + window.pageYOffset || + document.documentElement.scrollTop; + const scrollLeft = + window.pageXOffset || + document.documentElement.scrollLeft; + // if any scroll is attempted, set this to the previous value. + window.onscroll = function () { + window.scrollTo( scrollLeft, scrollTop ); + }; + // Enable scrolling after the animation finishes + setTimeout( function () { + window.onscroll = function () {}; + }, 400 ); } + document.documentElement.classList.remove( 'has-lightbox-open' ); @@ -101,6 +143,9 @@ store( { core: { image: { initLightbox: async ( { context, ref } ) => { + context.core.image.figureRef = + ref.querySelector( 'figure' ); + context.core.image.imageRef = ref.querySelector( 'img' ); if ( context.core.image.lightboxEnabled ) { const focusableElements = ref.querySelectorAll( focusableSelectors ); @@ -116,3 +161,100 @@ store( { }, }, } ); + +function setZoomStyles( imgDom, context, event ) { + let targetWidth = imgDom.naturalWidth; + let targetHeight = imgDom.naturalHeight; + + const verticalPadding = 40; + + // As per the design, let's allow the image to stretch + // to the full width of its containing figure, but for the height, + // constrain it with a fixed padding + const containerWidth = context.core.image.figureRef.clientWidth; + + // The lightbox image has `positione:absolute` and + // ignores its parent's padding, so let's set the padding here, + // to be used when calculating the image width and positioning + let horizontalPadding = 0; + if ( containerWidth > 480 ) { + horizontalPadding = 40; + } else if ( containerWidth > 1920 ) { + horizontalPadding = 80; + } + + const containerHeight = + context.core.image.figureRef.clientHeight - verticalPadding * 2; + + // Check difference between the image and figure dimensions + const widthOverflow = Math.abs( + Math.min( containerWidth - targetWidth, 0 ) + ); + const heightOverflow = Math.abs( + Math.min( containerHeight - targetHeight, 0 ) + ); + + // If image is larger than its container any dimension, resize along its largest axis. + // For vertically oriented devices, always maximize the width. + if ( widthOverflow > 0 || heightOverflow > 0 ) { + if ( + widthOverflow >= heightOverflow || + containerHeight >= containerWidth + ) { + targetWidth = containerWidth - horizontalPadding * 2; + targetHeight = + imgDom.naturalHeight * ( targetWidth / imgDom.naturalWidth ); + } else { + targetHeight = containerHeight; + targetWidth = + imgDom.naturalWidth * ( targetHeight / imgDom.naturalHeight ); + } + } + + // The reference img element lies adjacent to the event target button in the DOM + const { x: originLeft, y: originTop } = + event.target.nextElementSibling.getBoundingClientRect(); + const scaleWidth = + event.target.nextElementSibling.offsetWidth / targetWidth; + const scaleHeight = + event.target.nextElementSibling.offsetHeight / targetHeight; + + // Get values used to center the image + let targetLeft = 0; + if ( targetWidth >= containerWidth ) { + targetLeft = horizontalPadding; + } else { + targetLeft = ( containerWidth - targetWidth ) / 2; + } + let targetTop = 0; + if ( targetHeight >= containerHeight ) { + targetTop = verticalPadding; + } else { + targetTop = ( containerHeight - targetHeight ) / 2 + verticalPadding; + } + + const root = document.documentElement; + root.style.setProperty( '--lightbox-scale-width', scaleWidth ); + root.style.setProperty( '--lightbox-scale-height', scaleHeight ); + root.style.setProperty( '--lightbox-image-max-width', targetWidth + 'px' ); + root.style.setProperty( + '--lightbox-image-max-height', + targetHeight + 'px' + ); + root.style.setProperty( + '--lightbox-initial-left-position', + originLeft + 'px' + ); + root.style.setProperty( + '--lightbox-initial-top-position', + originTop + 'px' + ); + root.style.setProperty( + '--lightbox-target-left-position', + targetLeft + 'px' + ); + root.style.setProperty( + '--lightbox-target-top-position', + targetTop + 'px' + ); +} diff --git a/packages/block-library/src/image/style.scss b/packages/block-library/src/image/style.scss index 6de2bdd689859..563a91ad340b3 100644 --- a/packages/block-library/src/image/style.scss +++ b/packages/block-library/src/image/style.scss @@ -204,7 +204,6 @@ justify-content: center; align-items: center; flex-direction: column; - padding: 30px; figcaption { display: none; @@ -231,24 +230,69 @@ opacity: 0.9; } - &.initialized { - animation: both turn-off-visibility 300ms; + &.fade { + &.active { + visibility: visible; + animation: both turn-on-visibility 0.25s; + img { + animation: both turn-on-visibility 0.3s; + } + } + &.hideanimationenabled { + &:not(.active) { + animation: both turn-off-visibility 0.3s; + + img { + animation: both turn-off-visibility 0.25s; + } + } + } + } + + &.zoom { img { - animation: both turn-off-visibility 250ms; + position: absolute; + transform-origin: top left; + width: var(--lightbox-image-max-width); + height: var(--lightbox-image-max-height); } &.active { + opacity: 1; visibility: visible; - animation: both turn-on-visibility 250ms; + .wp-block-image img { + animation: lightbox-zoom-in 0.4s forwards; - img { - animation: both turn-on-visibility 300ms; + @media (prefers-reduced-motion) { + animation: both turn-on-visibility 0.4s; + } + } + .scrim { + animation: turn-on-visibility 0.4s forwards; + } + } + &.hideanimationenabled { + &:not(.active) { + .wp-block-image img { + animation: lightbox-zoom-out 0.4s forwards; + + @media (prefers-reduced-motion) { + animation: both turn-off-visibility 0.4s; + } + } + .scrim { + animation: turn-off-visibility 0.4s forwards; + } } } } } +html.has-lightbox-open { + overflow: hidden; +} + @keyframes turn-on-visibility { 0% { opacity: 0; @@ -273,6 +317,32 @@ } } -html.has-lightbox-open { - overflow: hidden; +@keyframes lightbox-zoom-in { + 0% { + left: var(--lightbox-initial-left-position); + top: var(--lightbox-initial-top-position); + transform: scale(var(--lightbox-scale-width), var(--lightbox-scale-height)); + } + 100% { + left: var(--lightbox-target-left-position); + top: var(--lightbox-target-top-position); + transform: scale(1, 1); + } +} + +@keyframes lightbox-zoom-out { + 0% { + visibility: visible; + left: var(--lightbox-target-left-position); + top: var(--lightbox-target-top-position); + transform: scale(1, 1); + } + 99% { + visibility: visible; + } + 100% { + left: var(--lightbox-initial-left-position); + top: var(--lightbox-initial-top-position); + transform: scale(var(--lightbox-scale-width), var(--lightbox-scale-height)); + } } diff --git a/test/e2e/specs/editor/blocks/image.spec.js b/test/e2e/specs/editor/blocks/image.spec.js index 2516a6d9c5cea..ad07a9b3914fd 100644 --- a/test/e2e/specs/editor/blocks/image.spec.js +++ b/test/e2e/specs/editor/blocks/image.spec.js @@ -811,7 +811,12 @@ test.describe( 'Image - interactivity', () => { let blocks = await editor.getBlocks(); expect( blocks[ 0 ].attributes ).toMatchObject( { - behaviors: { lightbox: true }, + behaviors: { + lightbox: { + animation: 'zoom', + enabled: true, + }, + }, linkDestination: 'none', } ); expect( blocks[ 0 ].attributes.url ).toContain( filename ); @@ -819,7 +824,12 @@ test.describe( 'Image - interactivity', () => { await page.getByLabel( 'Behaviors' ).selectOption( '' ); blocks = await editor.getBlocks(); expect( blocks[ 0 ].attributes ).toMatchObject( { - behaviors: { lightbox: false }, + behaviors: { + lightbox: { + animation: '', + enabled: false, + }, + }, linkDestination: 'none', } ); expect( blocks[ 0 ].attributes.url ).toContain( filename ); diff --git a/test/e2e/specs/editor/various/behaviors.spec.js b/test/e2e/specs/editor/various/behaviors.spec.js index dc03dd166b001..9fe1fedf175c3 100644 --- a/test/e2e/specs/editor/various/behaviors.spec.js +++ b/test/e2e/specs/editor/various/behaviors.spec.js @@ -49,43 +49,6 @@ test.describe( 'Testing behaviors functionality', () => { await page.waitForLoadState(); } ); - test( '`No Behaviors` should be the default as defined in the core theme.json', async ( { - admin, - editor, - requestUtils, - page, - behaviorUtils, - } ) => { - await requestUtils.activateTheme( 'twentytwentyone' ); - await admin.createNewPost(); - const media = await behaviorUtils.createMedia(); - await editor.insertBlock( { - name: 'core/image', - attributes: { - alt: filename, - id: media.id, - url: media.source_url, - }, - } ); - - await editor.openDocumentSettingsSidebar(); - const editorSettings = page.getByRole( 'region', { - name: 'Editor settings', - } ); - await editorSettings - .getByRole( 'button', { name: 'Advanced' } ) - .click(); - const select = editorSettings.getByRole( 'combobox', { - name: 'Behavior', - } ); - - // By default, no behaviors should be selected. - await expect( select ).toHaveValue( '' ); - - // By default, you should be able to select the Lightbox behavior. - await expect( select.getByRole( 'option' ) ).toHaveCount( 2 ); - } ); - test( 'Behaviors UI can be disabled in the `theme.json`', async ( { admin, editor, @@ -143,7 +106,12 @@ test.describe( 'Testing behaviors functionality', () => { id: media.id, url: media.source_url, // Explicitly set the value for behaviors to true. - behaviors: { lightbox: true }, + behaviors: { + lightbox: { + enabled: true, + animation: 'zoom', + }, + }, }, } ); @@ -162,8 +130,8 @@ test.describe( 'Testing behaviors functionality', () => { // attributes takes precedence over the theme's value. await expect( select ).toHaveValue( 'lightbox' ); - // There should be 2 options available: `No behaviors` and `Lightbox`. - await expect( select.getByRole( 'option' ) ).toHaveCount( 2 ); + // There should be 3 options available: `No behaviors` and `Lightbox`. + await expect( select.getByRole( 'option' ) ).toHaveCount( 3 ); // We can change the value of the behaviors dropdown to `No behaviors`. await select.selectOption( { label: 'No behaviors' } ); @@ -173,50 +141,6 @@ test.describe( 'Testing behaviors functionality', () => { // lightbox even though the theme.json has it set to false. } ); - test( 'You can set the default value for the behaviors in the theme.json', async ( { - admin, - editor, - requestUtils, - page, - behaviorUtils, - } ) => { - // In this theme, the default value for settings.behaviors.blocks.core/image.lightbox is `true`. - await requestUtils.activateTheme( 'behaviors-enabled' ); - await admin.createNewPost(); - const media = await behaviorUtils.createMedia(); - - await editor.insertBlock( { - name: 'core/image', - attributes: { - alt: filename, - id: media.id, - url: media.source_url, - }, - } ); - - await editor.openDocumentSettingsSidebar(); - const editorSettings = page.getByRole( 'region', { - name: 'Editor settings', - } ); - await editorSettings - .getByRole( 'button', { name: 'Advanced' } ) - .click(); - const select = editorSettings.getByRole( 'combobox', { - name: 'Behavior', - } ); - - // The behaviors dropdown should be present and the value should be set to - // `lightbox`. - await expect( select ).toHaveValue( 'lightbox' ); - - // There should be 2 options available: `No behaviors` and `Lightbox`. - await expect( select.getByRole( 'option' ) ).toHaveCount( 2 ); - - // We can change the value of the behaviors dropdown to `No behaviors`. - await select.selectOption( { label: 'No behaviors' } ); - await expect( select ).toHaveValue( '' ); - } ); - test( 'Lightbox behavior is disabled if the Image has a link', async ( { admin, editor, @@ -254,7 +178,7 @@ test.describe( 'Testing behaviors functionality', () => { await expect( select ).toBeDisabled(); } ); - test( 'Lightbox behavior control has a Reset button that removes the markup', async ( { + test( 'Lightbox behavior control has a default option that removes the markup', async ( { admin, editor, requestUtils, @@ -293,13 +217,11 @@ test.describe( 'Testing behaviors functionality', () => { .last() .click(); - const resetButton = editorSettings.getByRole( 'button', { - name: 'Reset', + const select = editorSettings.getByRole( 'combobox', { + name: 'Behavior', } ); - expect( resetButton ).toBeDefined(); - - await resetButton.last().click(); + await select.selectOption( { label: 'Default' } ); expect( await editor.getEditedPostContent() ) .toBe( `
1024x768_e2e_test_image_size.jpeg
diff --git a/test/gutenberg-test-themes/behaviors-enabled/theme.json b/test/gutenberg-test-themes/behaviors-enabled/theme.json index f49129622d9f6..8e7ce39023fd3 100644 --- a/test/gutenberg-test-themes/behaviors-enabled/theme.json +++ b/test/gutenberg-test-themes/behaviors-enabled/theme.json @@ -3,7 +3,10 @@ "behaviors": { "blocks": { "core/image": { - "lightbox": true + "lightbox": { + "enabled": true, + "animation": "zoom" + } } } } diff --git a/test/gutenberg-test-themes/behaviors-ui-disabled/theme.json b/test/gutenberg-test-themes/behaviors-ui-disabled/theme.json index a9f920f6dd0ab..cc4b0882fd22c 100644 --- a/test/gutenberg-test-themes/behaviors-ui-disabled/theme.json +++ b/test/gutenberg-test-themes/behaviors-ui-disabled/theme.json @@ -3,9 +3,7 @@ "settings": { "blocks": { "core/image": { - "behaviors": { - "lightbox": false - } + "behaviors": false } } }