diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index 1bac37d554d1e7..6ad2bd1f22570c 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -326,6 +326,15 @@ Add a link to a downloadable file. ([Source](https://github.com/WordPress/gutenb - **Supports:** align, anchor, color (background, gradients, link, ~~text~~), interactivity, spacing (margin, padding) - **Attributes:** blob, displayPreview, downloadButtonText, fileId, fileName, href, id, previewHeight, showDownloadButton, textLinkHref, textLinkTarget +## Fit Text + +Add text that automatically scales to fit its container. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/fit-text)) + +- **Name:** core/fit-text +- **Category:** text +- **Supports:** __unstablePasteTextInline, align (full, wide), anchor, className, color (background, gradients, text), interactivity (clientNavigation), spacing (margin, padding), typography (lineHeight) +- **Attributes:** content, level, levelOptions + ## Footnotes Display footnotes added to the page. ([Source](https://github.com/WordPress/gutenberg/tree/trunk/packages/block-library/src/footnotes)) @@ -410,7 +419,7 @@ Introduce new sections and organize content to help visitors (and search engines - **Name:** core/heading - **Category:** text -- **Supports:** __unstablePasteTextInline, align (full, wide), anchor, className, color (background, gradients, link, text), interactivity (clientNavigation), spacing (margin, padding), splitting, typography (fitText, fontSize, lineHeight) +- **Supports:** __unstablePasteTextInline, align (full, wide), anchor, className, color (background, gradients, link, text), interactivity (clientNavigation), spacing (margin, padding), splitting, typography (fontSize, lineHeight) - **Attributes:** content, level, levelOptions, placeholder, textAlign ## Home Link @@ -590,7 +599,7 @@ Start with the basic building block of all narrative. ([Source](https://github.c - **Name:** core/paragraph - **Category:** text -- **Supports:** __unstablePasteTextInline, anchor, color (background, gradients, link, text), interactivity (clientNavigation), spacing (margin, padding), splitting, typography (fitText, fontSize, lineHeight), ~~className~~ +- **Supports:** __unstablePasteTextInline, anchor, color (background, gradients, link, text), interactivity (clientNavigation), spacing (margin, padding), splitting, typography (fontSize, lineHeight), ~~className~~ - **Attributes:** align, content, direction, dropCap, placeholder ## Pattern Placeholder diff --git a/lib/block-supports/typography.php b/lib/block-supports/typography.php index 8fdf11793511b7..a4719b7bdd4099 100644 --- a/lib/block-supports/typography.php +++ b/lib/block-supports/typography.php @@ -244,24 +244,6 @@ function gutenberg_typography_get_preset_inline_style_value( $style_value, $css_ * @return string Filtered block content. */ function gutenberg_render_typography_support( $block_content, $block ) { - if ( ! empty( $block['attrs']['fitText'] ) && ! is_admin() ) { - wp_enqueue_script_module( '@wordpress/block-editor/utils/fit-text-frontend' ); - - // Add Interactivity API directives for fit text to work with client-side navigation. - if ( ! empty( $block_content ) ) { - $processor = new WP_HTML_Tag_Processor( $block_content ); - if ( $processor->next_tag() ) { - if ( ! $processor->get_attribute( 'data-wp-interactive' ) ) { - $processor->set_attribute( 'data-wp-interactive', true ); - } - $processor->set_attribute( 'data-wp-context---core-fit-text', 'core/fit-text::{"fontSize":""}' ); - $processor->set_attribute( 'data-wp-init---core-fit-text', 'core/fit-text::callbacks.init' ); - $processor->set_attribute( 'data-wp-style--font-size', 'core/fit-text::context.fontSize' ); - $block_content = $processor->get_updated_html(); - } - } - } - if ( ! isset( $block['attrs']['style']['typography']['fontSize'] ) ) { return $block_content; } diff --git a/packages/block-editor/package.json b/packages/block-editor/package.json index a1c399315f60d1..52b816e9ba3d7d 100644 --- a/packages/block-editor/package.json +++ b/packages/block-editor/package.json @@ -35,9 +35,6 @@ }, "react-native": "src/index", "wpScript": true, - "wpScriptModuleExports": { - "./utils/fit-text-frontend": "./build-module/utils/fit-text-frontend.js" - }, "sideEffects": [ "build-style/**", "src/**/*.scss", diff --git a/packages/block-editor/src/hooks/fit-text.js b/packages/block-editor/src/hooks/fit-text.js deleted file mode 100644 index 356452035064ae..00000000000000 --- a/packages/block-editor/src/hooks/fit-text.js +++ /dev/null @@ -1,329 +0,0 @@ -/** - * WordPress dependencies - */ -import { addFilter } from '@wordpress/hooks'; -import { hasBlockSupport } from '@wordpress/blocks'; -import { useEffect, useCallback } from '@wordpress/element'; -import { useSelect } from '@wordpress/data'; -import { __ } from '@wordpress/i18n'; -import { - ToggleControl, - __experimentalToolsPanelItem as ToolsPanelItem, -} from '@wordpress/components'; - -/** - * Internal dependencies - */ -import { optimizeFitText } from '../utils/fit-text-utils'; -import { store as blockEditorStore } from '../store'; -import { useBlockElement } from '../components/block-list/use-block-props/use-block-refs'; -import InspectorControls from '../components/inspector-controls'; - -export const FIT_TEXT_SUPPORT_KEY = 'typography.fitText'; - -/** - * Filters registered block settings, extending attributes to include - * the `fitText` attribute. - * - * @param {Object} settings Original block settings. - * @return {Object} Filtered block settings. - */ -function addAttributes( settings ) { - if ( ! hasBlockSupport( settings, FIT_TEXT_SUPPORT_KEY ) ) { - return settings; - } - - // Allow blocks to specify their own attribute definition. - if ( settings.attributes?.fitText ) { - return settings; - } - - // Add fitText attribute. - return { - ...settings, - attributes: { - ...settings.attributes, - fitText: { - type: 'boolean', - }, - }, - }; -} - -/** - * Custom hook to handle fit text functionality in the editor. - * - * @param {Object} props Component props. - * @param {?boolean} props.fitText Fit text attribute. - * @param {string} props.name Block name. - * @param {string} props.clientId Block client ID. - */ -function useFitText( { fitText, name, clientId } ) { - const hasFitTextSupport = hasBlockSupport( name, FIT_TEXT_SUPPORT_KEY ); - const blockElement = useBlockElement( clientId ); - - // Monitor block attribute changes - // Any attribute may change the available space. - const blockAttributes = useSelect( - ( select ) => { - if ( ! clientId || ! hasFitTextSupport || ! fitText ) { - return; - } - return select( blockEditorStore ).getBlockAttributes( clientId ); - }, - [ clientId, hasFitTextSupport, fitText ] - ); - - const applyFitText = useCallback( () => { - if ( ! blockElement || ! hasFitTextSupport || ! fitText ) { - return; - } - - // Get or create style element with unique ID - const styleId = `fit-text-${ clientId }`; - let styleElement = blockElement.ownerDocument.getElementById( styleId ); - if ( ! styleElement ) { - styleElement = blockElement.ownerDocument.createElement( 'style' ); - styleElement.id = styleId; - blockElement.ownerDocument.head.appendChild( styleElement ); - } - - const blockSelector = `#block-${ clientId }`; - - const applyFontSize = ( fontSize ) => { - if ( fontSize === 0 ) { - styleElement.textContent = ''; - } else { - styleElement.textContent = `${ blockSelector } { font-size: ${ fontSize }px !important; }`; - } - }; - - optimizeFitText( blockElement, applyFontSize ); - }, [ blockElement, clientId, hasFitTextSupport, fitText ] ); - - useEffect( () => { - if ( - ! fitText || - ! blockElement || - ! clientId || - ! hasFitTextSupport - ) { - return; - } - - // Store current element value for cleanup - const currentElement = blockElement; - const previousVisibility = currentElement.style.visibility; - - // Store IDs for cleanup - let hideFrameId = null; - let calculateFrameId = null; - let showTimeoutId = null; - - // We are hiding the element doing the calculation of fit text - // and then showing it again to avoid the user noticing a flash of potentially - // big fitText while the binary search is happening. - hideFrameId = window.requestAnimationFrame( () => { - currentElement.style.visibility = 'hidden'; - // Wait for browser to render the hidden state - calculateFrameId = window.requestAnimationFrame( () => { - applyFitText(); - - // Using a timeout instead of requestAnimationFrame, because - // with requestAnimationFrame a flash of very high size - // can still occur although rare. - showTimeoutId = setTimeout( () => { - currentElement.style.visibility = previousVisibility; - }, 10 ); - } ); - } ); - - // Watch for size changes - let resizeObserver; - if ( window.ResizeObserver && currentElement.parentElement ) { - resizeObserver = new window.ResizeObserver( applyFitText ); - resizeObserver.observe( currentElement.parentElement ); - } - - // Cleanup function - return () => { - // Cancel pending async operations - if ( hideFrameId !== null ) { - window.cancelAnimationFrame( hideFrameId ); - } - if ( calculateFrameId !== null ) { - window.cancelAnimationFrame( calculateFrameId ); - } - if ( showTimeoutId !== null ) { - clearTimeout( showTimeoutId ); - } - - if ( resizeObserver ) { - resizeObserver.disconnect(); - } - - const styleId = `fit-text-${ clientId }`; - const styleElement = - currentElement.ownerDocument.getElementById( styleId ); - if ( styleElement ) { - styleElement.remove(); - } - }; - }, [ fitText, clientId, applyFitText, blockElement, hasFitTextSupport ] ); - - // Trigger fit text recalculation when content changes - useEffect( () => { - if ( fitText && blockElement && hasFitTextSupport ) { - // Wait for next frame to ensure DOM has updated after content changes - const frameId = window.requestAnimationFrame( () => { - if ( blockElement ) { - applyFitText(); - } - } ); - - return () => window.cancelAnimationFrame( frameId ); - } - }, [ - blockAttributes, - fitText, - applyFitText, - blockElement, - hasFitTextSupport, - ] ); -} - -/** - * Fit text control component for the typography panel. - * - * @param {Object} props Component props. - * @param {string} props.clientId Block client ID. - * @param {Function} props.setAttributes Function to set block attributes. - * @param {string} props.name Block name. - * @param {boolean} props.fitText Whether fit text is enabled. - * @param {string} props.fontSize Font size slug. - * @param {Object} props.style Block style object. - */ -export function FitTextControl( { - clientId, - fitText = false, - setAttributes, - name, - fontSize, - style, -} ) { - if ( ! hasBlockSupport( name, FIT_TEXT_SUPPORT_KEY ) ) { - return null; - } - return ( - - fitText } - label={ __( 'Fit text' ) } - onDeselect={ () => setAttributes( { fitText: undefined } ) } - resetAllFilter={ () => ( { fitText: undefined } ) } - panelId={ clientId } - > - { - const newFitText = ! fitText || undefined; - const updates = { fitText: newFitText }; - - // When enabling fit text, clear font size if it has a value - if ( newFitText ) { - if ( fontSize ) { - updates.fontSize = undefined; - } - if ( style?.typography?.fontSize ) { - updates.style = { - ...style, - typography: { - ...style?.typography, - fontSize: undefined, - }, - }; - } - } - - setAttributes( updates ); - } } - help={ - fitText - ? __( 'Text will resize to fit its container.' ) - : __( - 'The text will resize to fit its container, resetting other font size settings.' - ) - } - /> - - - ); -} - -/** - * Override props applied to the block element on save. - * - * @param {Object} props Additional props applied to the block element. - * @param {Object} blockType Block type. - * @param {Object} attributes Block attributes. - * @return {Object} Filtered props applied to the block element. - */ -function addSaveProps( props, blockType, attributes ) { - if ( ! hasBlockSupport( blockType, FIT_TEXT_SUPPORT_KEY ) ) { - return props; - } - - const { fitText } = attributes; - - if ( ! fitText ) { - return props; - } - - // Add CSS class for frontend detection and styling - const className = props.className - ? `${ props.className } has-fit-text` - : 'has-fit-text'; - - return { - ...props, - className, - }; -} -/** - * Override props applied to the block element in the editor. - * - * @param {Object} props Component props including block attributes. - * @param {string} props.name Block name. - * @param {boolean} props.fitText Whether fit text is enabled. - * @param {string} props.clientId Block client ID. - * @return {Object} Filtered props applied to the block element. - */ -function useBlockProps( { name, fitText, clientId } ) { - useFitText( { fitText, name, clientId } ); - if ( ! fitText || ! hasBlockSupport( name, FIT_TEXT_SUPPORT_KEY ) ) { - return {}; - } - return { - className: 'has-fit-text', - }; -} - -addFilter( - 'blocks.registerBlockType', - 'core/fit-text/addAttribute', - addAttributes -); - -const hasFitTextSupport = ( blockNameOrType ) => { - return hasBlockSupport( blockNameOrType, FIT_TEXT_SUPPORT_KEY ); -}; - -export default { - useBlockProps, - addSaveProps, - attributeKeys: [ 'fitText', 'fontSize', 'style' ], - hasSupport: hasFitTextSupport, - edit: FitTextControl, -}; diff --git a/packages/block-editor/src/hooks/index.js b/packages/block-editor/src/hooks/index.js index cc8339455fdd84..95c18e6a3f55a7 100644 --- a/packages/block-editor/src/hooks/index.js +++ b/packages/block-editor/src/hooks/index.js @@ -23,7 +23,6 @@ import duotone from './duotone'; import fontFamily from './font-family'; import fontSize from './font-size'; import textAlign from './text-align'; -import fitText from './fit-text'; import border from './border'; import position from './position'; import blockStyleVariation from './block-style-variation'; @@ -44,7 +43,6 @@ createBlockEditFilter( customClassName, style, duotone, - fitText, position, layout, contentLockUI, @@ -64,7 +62,6 @@ createBlockListBlockFilter( [ duotone, fontFamily, fontSize, - fitText, border, position, blockStyleVariation, @@ -77,7 +74,6 @@ createBlockSaveFilter( [ ariaLabel, customClassName, border, - fitText, color, style, fontFamily, diff --git a/packages/block-editor/src/hooks/typography.js b/packages/block-editor/src/hooks/typography.js index 5c551a4bb35761..1f381a3d0bec14 100644 --- a/packages/block-editor/src/hooks/typography.js +++ b/packages/block-editor/src/hooks/typography.js @@ -18,7 +18,6 @@ import { LINE_HEIGHT_SUPPORT_KEY } from './line-height'; import { FONT_FAMILY_SUPPORT_KEY } from './font-family'; import { FONT_SIZE_SUPPORT_KEY } from './font-size'; import { TEXT_ALIGN_SUPPORT_KEY } from './text-align'; -import { FIT_TEXT_SUPPORT_KEY } from './fit-text'; import { cleanEmptyObject } from './utils'; import { store as blockEditorStore } from '../store'; @@ -48,7 +47,6 @@ export const TYPOGRAPHY_SUPPORT_KEYS = [ WRITING_MODE_SUPPORT_KEY, TEXT_TRANSFORM_SUPPORT_KEY, LETTER_SPACING_SUPPORT_KEY, - FIT_TEXT_SUPPORT_KEY, ]; function styleToAttributes( style ) { @@ -116,13 +114,11 @@ function TypographyInspectorControl( { children, resetAllFilter } ) { export function TypographyPanel( { clientId, name, setAttributes, settings } ) { function selector( select ) { - const { style, fontFamily, fontSize, fitText } = + const { style, fontFamily, fontSize } = select( blockEditorStore ).getBlockAttributes( clientId ) || {}; - return { style, fontFamily, fontSize, fitText }; + return { style, fontFamily, fontSize }; } - const { style, fontFamily, fontSize, fitText } = useSelect( selector, [ - clientId, - ] ); + const { style, fontFamily, fontSize } = useSelect( selector, [ clientId ] ); const isEnabled = useHasTypographyPanel( settings ); const value = useMemo( () => attributesToStyle( { style, fontFamily, fontSize } ), @@ -131,14 +127,6 @@ export function TypographyPanel( { clientId, name, setAttributes, settings } ) { const onChange = ( newStyle ) => { const newAttributes = styleToAttributes( newStyle ); - - // If setting a font size and fitText is currently enabled, disable it - const hasFontSize = - newAttributes.fontSize || newAttributes.style?.typography?.fontSize; - if ( hasFontSize && fitText ) { - newAttributes.fitText = undefined; - } - setAttributes( newAttributes ); }; diff --git a/packages/block-library/package.json b/packages/block-library/package.json index f329bbd91da6ab..4a714df91f7c26 100644 --- a/packages/block-library/package.json +++ b/packages/block-library/package.json @@ -39,6 +39,7 @@ "wpScriptModuleExports": { "./accordion/view": "./build-module/accordion/view.js", "./file/view": "./build-module/file/view.js", + "./fit-text/view": "./build-module/fit-text/view.js", "./form/view": "./build-module/form/view.js", "./image/view": "./build-module/image/view.js", "./navigation/view": "./build-module/navigation/view.js", diff --git a/packages/block-library/src/common.scss b/packages/block-library/src/common.scss index 8bb0d00460f4a5..63d2337928c69c 100644 --- a/packages/block-library/src/common.scss +++ b/packages/block-library/src/common.scss @@ -49,11 +49,6 @@ text-align: right; } -// Fit Text -.has-fit-text { - white-space: nowrap !important; -} - // This tag marks the end of the styles that apply to editing canvas contents and need to be manipulated when we resize the editor. #end-resizable-editor-section { display: none; diff --git a/packages/block-library/src/fit-text/block.json b/packages/block-library/src/fit-text/block.json new file mode 100644 index 00000000000000..ca45e6c4252cdf --- /dev/null +++ b/packages/block-library/src/fit-text/block.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "core/fit-text", + "title": "Fit Text", + "category": "text", + "description": "Add text that automatically scales to fit its container.", + "keywords": [ "text", "resize", "scale" ], + "textdomain": "default", + "attributes": { + "content": { + "type": "rich-text", + "source": "rich-text", + "selector": "h1,h2,h3,h4,h5,h6,p", + "role": "content" + }, + "level": { + "type": "number", + "default": 0 + }, + "levelOptions": { + "type": "array", + "default": [ 0, 1, 2, 3, 4, 5, 6 ] + } + }, + "supports": { + "align": [ "wide", "full" ], + "anchor": true, + "className": true, + "__experimentalBorder": { + "color": true, + "radius": true, + "style": true, + "width": true + }, + "color": { + "gradients": true, + "__experimentalDefaultControls": { + "background": true, + "text": true + } + }, + "spacing": { + "margin": true, + "padding": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } + }, + "typography": { + "lineHeight": true, + "__experimentalFontFamily": true, + "__experimentalFontStyle": true, + "__experimentalFontWeight": true, + "__experimentalLetterSpacing": true, + "__experimentalTextTransform": true, + "__experimentalTextDecoration": true, + "__experimentalDefaultControls": {} + }, + "interactivity": { + "clientNavigation": true + }, + "__unstablePasteTextInline": true, + "__experimentalSlashInserter": true + }, + "editorStyle": "wp-block-fit-text-editor", + "style": "wp-block-fit-text" +} diff --git a/packages/block-library/src/fit-text/edit.js b/packages/block-library/src/fit-text/edit.js new file mode 100644 index 00000000000000..dca9a12406e550 --- /dev/null +++ b/packages/block-library/src/fit-text/edit.js @@ -0,0 +1,164 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + useEffect, + useLayoutEffect, + useCallback, + useRef, +} from '@wordpress/element'; +import { + RichText, + BlockControls, + useBlockProps, + HeadingLevelDropdown, + useBlockEditingMode, +} from '@wordpress/block-editor'; +import { createBlock, getDefaultBlockName } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import { optimizeFitText } from './utils'; + +export default function FitTextEdit( { + attributes, + setAttributes, + insertBlocksAfter, + clientId, +} ) { + const { level, levelOptions, content } = attributes; + const blockEditingMode = useBlockEditingMode(); + const blockRef = useRef(); + + const applyFitText = useCallback( () => { + if ( ! blockRef.current ) { + return; + } + + // Get or create style element with unique ID + const styleId = `fit-text-${ clientId }`; + let styleElement = + blockRef.current.ownerDocument.getElementById( styleId ); + if ( ! styleElement ) { + styleElement = + blockRef.current.ownerDocument.createElement( 'style' ); + styleElement.id = styleId; + blockRef.current.ownerDocument.head.appendChild( styleElement ); + } + + const blockSelector = `#block-${ clientId }`; + + const applyFontSize = ( fontSize ) => { + if ( fontSize === 0 ) { + styleElement.textContent = ''; + } else { + styleElement.textContent = `${ blockSelector } { font-size: ${ fontSize }px !important; }`; + } + }; + + optimizeFitText( blockRef.current, applyFontSize ); + }, [ clientId ] ); + + useEffect( () => { + if ( ! blockRef.current || ! clientId ) { + return; + } + + let calculateFrameId = null; + + // Wait for next animation frame for DOM to fully render and layout to settle + calculateFrameId = window.requestAnimationFrame( () => { + applyFitText(); + } ); + + // Watch for size changes + let resizeObserver; + if ( window.ResizeObserver && blockRef.current.parentElement ) { + resizeObserver = new window.ResizeObserver( applyFitText ); + resizeObserver.observe( blockRef.current.parentElement ); + } + + const blockRefToCleanup = blockRef.current; + + // Cleanup function + return () => { + if ( calculateFrameId !== null ) { + window.cancelAnimationFrame( calculateFrameId ); + } + + if ( resizeObserver ) { + resizeObserver.disconnect(); + } + + const styleId = `fit-text-${ clientId }`; + const styleElement = + blockRefToCleanup.ownerDocument.getElementById( styleId ); + if ( styleElement ) { + styleElement.remove(); + } + }; + }, [ clientId, applyFitText ] ); + + // Trigger fit text recalculation when attributes change + useLayoutEffect( () => { + if ( blockRef.current ) { + // Wait for two animation frames for DOM layout to settle. + // If we do it in a single frame, because of some reason when changing + // alignment from full to wide or non things don't recompute correctly. + let firstFrameId = null; + let secondFrameId = null; + + firstFrameId = window.requestAnimationFrame( () => { + secondFrameId = window.requestAnimationFrame( () => { + if ( blockRef.current ) { + applyFitText(); + } + } ); + } ); + + return () => { + if ( firstFrameId !== null ) { + window.cancelAnimationFrame( firstFrameId ); + } + if ( secondFrameId !== null ) { + window.cancelAnimationFrame( secondFrameId ); + } + }; + } + }, [ attributes, applyFitText ] ); + + const tagName = level === 0 ? 'p' : `h${ level }`; + const blockProps = useBlockProps( { + ref: blockRef, + } ); + + return ( + <> + { blockEditingMode === 'default' && ( + + + setAttributes( { level: newLevel } ) + } + /> + + ) } + setAttributes( { content: value } ) } + disableLineBreaks + __unstableOnSplitAtEnd={ () => + insertBlocksAfter( createBlock( getDefaultBlockName() ) ) + } + /> + + ); +} diff --git a/packages/block-library/src/fit-text/index.js b/packages/block-library/src/fit-text/index.js new file mode 100644 index 00000000000000..efefd7d4d5aab0 --- /dev/null +++ b/packages/block-library/src/fit-text/index.js @@ -0,0 +1,29 @@ +/** + * WordPress dependencies + */ +import { paragraph as icon } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import initBlock from '../utils/init-block'; +import metadata from './block.json'; +import edit from './edit'; +import save from './save'; + +const { name } = metadata; +export { metadata, name }; + +export const settings = { + icon, + example: { + attributes: { + content: 'Fit Text', + level: 2, + }, + }, + edit, + save, +}; + +export const init = () => initBlock( { name, metadata, settings } ); diff --git a/packages/block-library/src/fit-text/index.php b/packages/block-library/src/fit-text/index.php new file mode 100644 index 00000000000000..2e6d1c27dcb521 --- /dev/null +++ b/packages/block-library/src/fit-text/index.php @@ -0,0 +1,54 @@ +next_tag() ) { + if ( ! $processor->get_attribute( 'data-wp-interactive' ) ) { + $processor->set_attribute( 'data-wp-interactive', true ); + } + $processor->set_attribute( 'data-wp-context---core-fit-text', 'core/fit-text::{"fontSize":""}' ); + $processor->set_attribute( 'data-wp-init---core-fit-text', 'core/fit-text::callbacks.init' ); + $processor->set_attribute( 'data-wp-style--font-size', 'core/fit-text::context.fontSize' ); + $content = $processor->get_updated_html(); + } + + return $content; +} + +/** + * Registers the `core/fit-text` block on the server. + * + * @since 22.0.0 + */ +function register_block_core_fit_text() { + register_block_type_from_metadata( + __DIR__ . '/fit-text', + array( + 'render_callback' => 'render_block_core_fit_text', + ) + ); +} +add_action( 'init', 'register_block_core_fit_text' ); diff --git a/packages/block-library/src/fit-text/init.js b/packages/block-library/src/fit-text/init.js new file mode 100644 index 00000000000000..79f0492c2cb2f8 --- /dev/null +++ b/packages/block-library/src/fit-text/init.js @@ -0,0 +1,6 @@ +/** + * Internal dependencies + */ +import { init } from './'; + +export default init(); diff --git a/packages/block-library/src/fit-text/save.js b/packages/block-library/src/fit-text/save.js new file mode 100644 index 00000000000000..819a28886cc223 --- /dev/null +++ b/packages/block-library/src/fit-text/save.js @@ -0,0 +1,15 @@ +/** + * WordPress dependencies + */ +import { RichText, useBlockProps } from '@wordpress/block-editor'; + +export default function save( { attributes } ) { + const { content, level } = attributes; + const TagName = level === 0 ? 'p' : `h${ level }`; + + return ( + + + + ); +} diff --git a/packages/block-library/src/fit-text/style.scss b/packages/block-library/src/fit-text/style.scss new file mode 100644 index 00000000000000..45ae356081f4a3 --- /dev/null +++ b/packages/block-library/src/fit-text/style.scss @@ -0,0 +1,3 @@ +.wp-block-fit-text { + white-space: nowrap !important; +} diff --git a/packages/block-editor/src/utils/fit-text-utils.js b/packages/block-library/src/fit-text/utils.js similarity index 100% rename from packages/block-editor/src/utils/fit-text-utils.js rename to packages/block-library/src/fit-text/utils.js diff --git a/packages/block-editor/src/utils/fit-text-frontend.js b/packages/block-library/src/fit-text/view.js similarity index 89% rename from packages/block-editor/src/utils/fit-text-frontend.js rename to packages/block-library/src/fit-text/view.js index f6729e3c2585c4..27f7a674384939 100644 --- a/packages/block-editor/src/utils/fit-text-frontend.js +++ b/packages/block-library/src/fit-text/view.js @@ -1,6 +1,6 @@ /** * Frontend fit text functionality. - * Automatically detects and initializes fit text on blocks with the has-fit-text class. + * Automatically detects and initializes fit text on core/fit-text blocks. * Supports both initial page load and Interactivity API client-side navigation. */ @@ -12,7 +12,7 @@ import { store, getElement, getContext } from '@wordpress/interactivity'; /** * Internal dependencies */ -import { optimizeFitText } from './fit-text-utils'; +import { optimizeFitText } from './utils'; // Initialize via Interactivity API for client-side navigation store( 'core/fit-text', { diff --git a/packages/block-library/src/heading/block.json b/packages/block-library/src/heading/block.json index d9488a3156528d..2869ee85c55206 100644 --- a/packages/block-library/src/heading/block.json +++ b/packages/block-library/src/heading/block.json @@ -65,7 +65,6 @@ "__experimentalTextTransform": true, "__experimentalTextDecoration": true, "__experimentalWritingMode": true, - "fitText": true, "__experimentalDefaultControls": { "fontSize": true } diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js index e71d6d1b651ac7..151e86333c40b5 100644 --- a/packages/block-library/src/index.js +++ b/packages/block-library/src/index.js @@ -59,6 +59,7 @@ import * as cover from './cover'; import * as details from './details'; import * as embed from './embed'; import * as file from './file'; +import * as fitText from './fit-text'; import * as form from './form'; import * as formInput from './form-input'; import * as formSubmitButton from './form-submit-button'; @@ -178,6 +179,7 @@ const getAllBlocks = () => { details, embed, file, + fitText, group, html, math, diff --git a/packages/block-library/src/paragraph/block.json b/packages/block-library/src/paragraph/block.json index 9617627ef5d0da..7e004019cbf282 100644 --- a/packages/block-library/src/paragraph/block.json +++ b/packages/block-library/src/paragraph/block.json @@ -65,7 +65,6 @@ "__experimentalLetterSpacing": true, "__experimentalTextTransform": true, "__experimentalWritingMode": true, - "fitText": true, "__experimentalDefaultControls": { "fontSize": true } diff --git a/packages/block-library/src/style.scss b/packages/block-library/src/style.scss index 907c7105467259..13cc1fa427f213 100644 --- a/packages/block-library/src/style.scss +++ b/packages/block-library/src/style.scss @@ -23,6 +23,7 @@ @use "./details/style.scss" as *; @use "./embed/style.scss" as *; @use "./file/style.scss" as *; +@use "./fit-text/style.scss" as *; @use "./form-input/style.scss" as *; @use "./gallery/style.scss" as *; @use "./group/style.scss" as *; diff --git a/test/e2e/specs/editor/blocks/fit-text.spec.js b/test/e2e/specs/editor/blocks/fit-text.spec.js index c10e065155c4c7..9dc26ac07c52a4 100644 --- a/test/e2e/specs/editor/blocks/fit-text.spec.js +++ b/test/e2e/specs/editor/blocks/fit-text.spec.js @@ -9,123 +9,102 @@ test.describe( 'Fit Text', () => { } ); test.describe( 'Editor functionality', () => { - test( 'should enable fit text on a heading block', async ( { - editor, - page, - } ) => { + test( 'should insert a fit text block', async ( { editor } ) => { await editor.insertBlock( { - name: 'core/heading', + name: 'core/fit-text', attributes: { - content: 'Test Heading', + content: 'Test Fit Text', level: 2, }, } ); - await editor.openDocumentSettingsSidebar(); - - // Enable Fit text control via Typography options menu - await page - .getByRole( 'region', { name: 'Editor settings' } ) - .getByRole( 'button', { name: 'Typography options' } ) - .click(); - await page - .getByRole( 'menu', { name: 'Typography options' } ) - .getByRole( 'menuitemcheckbox', { name: 'Show Fit text' } ) - .click(); - - const fitTextToggle = page.getByRole( 'checkbox', { - name: 'Fit text', - } ); - - await fitTextToggle.click(); - await expect.poll( editor.getBlocks ).toMatchObject( [ { - name: 'core/heading', + name: 'core/fit-text', attributes: { - content: 'Test Heading', + content: 'Test Fit Text', level: 2, - fitText: true, }, }, ] ); - const headingBlock = editor.canvas.locator( - '[data-type="core/heading"]' + const fitTextBlock = editor.canvas.locator( + '[data-type="core/fit-text"]' ); - await expect( headingBlock ).toHaveClass( /has-fit-text/ ); + await expect( fitTextBlock ).toBeVisible(); } ); - test( 'should disable fit text when toggled off', async ( { + test( 'should allow changing heading level', async ( { editor, page, } ) => { await editor.insertBlock( { - name: 'core/heading', + name: 'core/fit-text', attributes: { - content: 'Test Heading', + content: 'Heading Level Test', level: 2, - fitText: true, }, } ); - await editor.openDocumentSettingsSidebar(); + const fitTextBlock = editor.canvas.locator( + '[data-type="core/fit-text"]' + ); + await fitTextBlock.click(); - const fitTextToggle = page.getByRole( 'checkbox', { - name: 'Fit text', - } ); + // Open heading level dropdown + await page.getByRole( 'button', { name: 'Change level' } ).click(); - await fitTextToggle.click(); + // Select H4 + await page + .getByRole( 'menuitemradio', { name: 'Heading 4' } ) + .click(); - const blocks = await editor.getBlocks(); - expect( blocks[ 0 ].attributes.fitText ).toBeUndefined(); + await expect.poll( editor.getBlocks ).toMatchObject( [ + { + name: 'core/fit-text', + attributes: { + content: 'Heading Level Test', + level: 4, + }, + }, + ] ); } ); - test( 'should enable fit text on a paragraph block', async ( { + test( 'should allow changing to paragraph (level 0)', async ( { editor, page, } ) => { await editor.insertBlock( { - name: 'core/paragraph', + name: 'core/fit-text', attributes: { - content: 'Test paragraph with fit text enabled', + content: 'Paragraph Test', + level: 2, }, } ); - await editor.openDocumentSettingsSidebar(); + const fitTextBlock = editor.canvas.locator( + '[data-type="core/fit-text"]' + ); + await fitTextBlock.click(); - // Enable Fit text control via Typography options menu - await page - .getByRole( 'region', { name: 'Editor settings' } ) - .getByRole( 'button', { name: 'Typography options' } ) - .click(); + // Open heading level dropdown + await page.getByRole( 'button', { name: 'Change level' } ).click(); + + // Select Paragraph await page - .getByRole( 'menu', { name: 'Typography options' } ) - .getByRole( 'menuitemcheckbox', { name: 'Show Fit text' } ) + .getByRole( 'menuitemradio', { name: 'Paragraph' } ) .click(); - const fitTextToggle = page.getByRole( 'checkbox', { - name: 'Fit text', - } ); - - await fitTextToggle.click(); - await expect.poll( editor.getBlocks ).toMatchObject( [ { - name: 'core/paragraph', + name: 'core/fit-text', attributes: { - content: 'Test paragraph with fit text enabled', - fitText: true, + content: 'Paragraph Test', + level: 0, }, }, ] ); - - const paragraphBlock = editor.canvas.locator( - '[data-type="core/paragraph"]' - ); - - await expect( paragraphBlock ).toHaveClass( /has-fit-text/ ); } ); test( 'should apply font size dynamically based on container width in editor', async ( { @@ -133,37 +112,49 @@ test.describe( 'Fit Text', () => { page, } ) => { await editor.insertBlock( { - name: 'core/heading', + name: 'core/fit-text', attributes: { content: 'Resizable Text', level: 2, - fitText: true, }, } ); - const headingBlock = editor.canvas.locator( - '[data-type="core/heading"]' + const fitTextBlock = editor.canvas.locator( + '[data-type="core/fit-text"]' ); // Wait for fit text to apply - await headingBlock.waitFor( { state: 'attached' } ); - await expect( headingBlock ).toHaveClass( /has-fit-text/ ); + await fitTextBlock.waitFor( { state: 'attached' } ); - const initialFontSize = await headingBlock.evaluate( ( el ) => { + const initialFontSize = await fitTextBlock.evaluate( ( el ) => { return window.getComputedStyle( el ).fontSize; } ); // Add more text to force smaller font size - await headingBlock.click(); + await fitTextBlock.click(); await page.keyboard.press( 'End' ); await page.keyboard.type( ' that is much longer and should have smaller font' ); - // Wait for DOM to update and fit text to recalculate - await headingBlock.waitFor( { state: 'attached' } ); + // Wait for font size to decrease after adding more text + await fitTextBlock.evaluate( ( el, prevSize ) => { + return new Promise( ( resolve ) => { + const checkSize = () => { + const currentSize = parseFloat( + window.getComputedStyle( el ).fontSize + ); + if ( currentSize < prevSize ) { + resolve(); + } else { + window.requestAnimationFrame( checkSize ); + } + }; + checkSize(); + } ); + }, parseFloat( initialFontSize ) ); - const newFontSize = await headingBlock.evaluate( ( el ) => { + const newFontSize = await fitTextBlock.evaluate( ( el ) => { return window.getComputedStyle( el ).fontSize; } ); @@ -174,46 +165,43 @@ test.describe( 'Fit Text', () => { expect( newSize ).toBeLessThan( initialSize ); } ); - test( 'should apply much larger font size with fit text compared to without fit text for a short text', async ( { + test( 'should apply much larger font size with fit text compared to a normal heading for short text', async ( { editor, } ) => { - // Insert two paragraphs with same content for comparison + // Insert a regular heading and a fit text block with same content await editor.insertBlock( { - name: 'core/paragraph', + name: 'core/heading', attributes: { content: 'Hello', + level: 2, }, } ); await editor.insertBlock( { - name: 'core/paragraph', + name: 'core/fit-text', attributes: { content: 'Hello', - fitText: true, + level: 2, }, } ); - const paragraphBlocks = editor.canvas.locator( - '[data-type="core/paragraph"]' + const headingBlock = editor.canvas.locator( + '[data-type="core/heading"]' + ); + const fitTextBlock = editor.canvas.locator( + '[data-type="core/fit-text"]' ); // Wait for fit text to apply - await paragraphBlocks.nth( 1 ).waitFor( { state: 'attached' } ); - await expect( paragraphBlocks.nth( 1 ) ).toHaveClass( - /has-fit-text/ - ); + await fitTextBlock.waitFor( { state: 'attached' } ); - const normalFontSize = await paragraphBlocks - .nth( 0 ) - .evaluate( ( el ) => { - return window.getComputedStyle( el ).fontSize; - } ); + const normalFontSize = await headingBlock.evaluate( ( el ) => { + return window.getComputedStyle( el ).fontSize; + } ); - const fitTextFontSize = await paragraphBlocks - .nth( 1 ) - .evaluate( ( el ) => { - return window.getComputedStyle( el ).fontSize; - } ); + const fitTextFontSize = await fitTextBlock.evaluate( ( el ) => { + return window.getComputedStyle( el ).fontSize; + } ); const normalSize = parseFloat( normalFontSize ); const fitTextSize = parseFloat( fitTextFontSize ); @@ -222,100 +210,16 @@ test.describe( 'Fit Text', () => { expect( fitTextSize ).toBeGreaterThan( normalSize * 2 ); } ); - test( 'should disable fit text when a font size is selected', async ( { - editor, - page, - } ) => { - await editor.insertBlock( { - name: 'core/heading', - attributes: { - content: 'Test Heading', - level: 2, - fitText: true, - }, - } ); - - await editor.openDocumentSettingsSidebar(); - - // Set a custom font size - await page.click( - 'role=region[name="Editor settings"i] >> role=button[name="Set custom size"i]' - ); - await page.click( 'role=spinbutton[name="Font size"i]' ); - await page.keyboard.type( '24' ); - - // fitText should be cleared - await expect.poll( editor.getBlocks ).toMatchObject( [ - { - name: 'core/heading', - attributes: expect.objectContaining( { - content: 'Test Heading', - level: 2, - style: { - typography: { - fontSize: '24px', - }, - }, - } ), - }, - ] ); - } ); - - test( 'should clear font size when fit text is enabled', async ( { - editor, - page, - } ) => { - await editor.insertBlock( { - name: 'core/heading', - attributes: { - content: 'Test Heading', - level: 2, - fontSize: 'large', - }, - } ); - - await editor.openDocumentSettingsSidebar(); - - // Enable Fit text control via Typography options menu - await page - .getByRole( 'region', { name: 'Editor settings' } ) - .getByRole( 'button', { name: 'Typography options' } ) - .click(); - await page - .getByRole( 'menu', { name: 'Typography options' } ) - .getByRole( 'menuitemcheckbox', { name: 'Show Fit text' } ) - .click(); - - const fitTextToggle = page.getByRole( 'checkbox', { - name: 'Fit text', - } ); - - await fitTextToggle.click(); - - // fontSize should be cleared - await expect.poll( editor.getBlocks ).toMatchObject( [ - { - name: 'core/heading', - attributes: expect.objectContaining( { - content: 'Test Heading', - level: 2, - fitText: true, - } ), - }, - ] ); - } ); - test( 'should not load frontend script when editing a saved post with fit text', async ( { admin, editor, page, } ) => { await editor.insertBlock( { - name: 'core/heading', + name: 'core/fit-text', attributes: { content: 'Test Heading', level: 2, - fitText: true, }, } ); @@ -323,18 +227,17 @@ test.describe( 'Fit Text', () => { await admin.editPost( postId ); - const headingBlock = editor.canvas.locator( - '[data-type="core/heading"]' + const fitTextBlock = editor.canvas.locator( + '[data-type="core/fit-text"]' ); - await expect( headingBlock ).toBeVisible(); + await expect( fitTextBlock ).toBeVisible(); await expect.poll( editor.getBlocks ).toMatchObject( [ { - name: 'core/heading', + name: 'core/fit-text', attributes: { content: 'Test Heading', level: 2, - fitText: true, }, }, ] ); @@ -344,8 +247,10 @@ test.describe( 'Fit Text', () => { const scripts = Array.from( document.querySelectorAll( 'script[type="module"]' ) ); - return scripts.some( ( script ) => - script.src.includes( 'fit-text-frontend' ) + return scripts.some( + ( script ) => + script.src.includes( 'fit-text' ) && + script.src.includes( 'view' ) ); } ); expect( frontendScriptLoaded ).toBe( false ); @@ -358,11 +263,10 @@ test.describe( 'Fit Text', () => { page, } ) => { await editor.insertBlock( { - name: 'core/heading', + name: 'core/fit-text', attributes: { content: 'Frontend Test', level: 2, - fitText: true, }, } ); @@ -374,16 +278,15 @@ test.describe( 'Fit Text', () => { await page.goto( postUrl ); - const heading = page.locator( 'h2.has-fit-text' ); + const fitText = page.locator( '.wp-block-fit-text' ); - await expect( heading ).toBeVisible(); - await expect( heading ).toHaveClass( /has-fit-text/ ); + await expect( fitText ).toBeVisible(); - const inlineStyle = await heading.getAttribute( 'style' ); + const inlineStyle = await fitText.getAttribute( 'style' ); expect( inlineStyle ).toContain( 'font-size' ); expect( inlineStyle ).toMatch( /font-size:\s*\d+px/ ); - const computedFontSize = await heading.evaluate( ( el ) => { + const computedFontSize = await fitText.evaluate( ( el ) => { return window.getComputedStyle( el ).fontSize; } ); @@ -398,11 +301,10 @@ test.describe( 'Fit Text', () => { page, } ) => { await editor.insertBlock( { - name: 'core/heading', + name: 'core/fit-text', attributes: { content: 'Resize Me', level: 2, - fitText: true, }, } ); @@ -414,33 +316,32 @@ test.describe( 'Fit Text', () => { await page.goto( postUrl ); - const heading = page.locator( 'h2.has-fit-text' ); + const fitText = page.locator( '.wp-block-fit-text' ); // Wait for fit text to initialize - await heading.waitFor( { state: 'visible' } ); - await expect( heading ).toHaveClass( /has-fit-text/ ); + await fitText.waitFor( { state: 'visible' } ); // Wait for inline style to be applied await page.waitForFunction( () => { - const el = document.querySelector( 'h2.has-fit-text' ); + const el = document.querySelector( '.wp-block-fit-text' ); return el && el.style.fontSize && el.style.fontSize !== ''; }, { timeout: 5000 } ); - const initialFontSize = await heading.evaluate( ( el ) => { + const initialFontSize = await fitText.evaluate( ( el ) => { return window.getComputedStyle( el ).fontSize; } ); - const initialInlineStyle = await heading.getAttribute( 'style' ); + const initialInlineStyle = await fitText.getAttribute( 'style' ); await page.setViewportSize( { width: 440, height: 720 } ); // Wait for inline font-size style to change after resize await page.waitForFunction( ( previousStyle ) => { - const el = document.querySelector( 'h2.has-fit-text' ); + const el = document.querySelector( '.wp-block-fit-text' ); return ( el && el.style.fontSize && @@ -451,7 +352,7 @@ test.describe( 'Fit Text', () => { { timeout: 5000 } ); - const newFontSize = await heading.evaluate( ( el ) => { + const newFontSize = await fitText.evaluate( ( el ) => { return window.getComputedStyle( el ).fontSize; } ); @@ -466,19 +367,20 @@ test.describe( 'Fit Text', () => { editor, page, } ) => { - // Insert two paragraphs with same content for comparison + // Insert two headings with same content for comparison await editor.insertBlock( { - name: 'core/paragraph', + name: 'core/heading', attributes: { content: 'Hello', + level: 2, }, } ); await editor.insertBlock( { - name: 'core/paragraph', + name: 'core/fit-text', attributes: { content: 'Hello', - fitText: true, + level: 2, }, } ); @@ -490,30 +392,27 @@ test.describe( 'Fit Text', () => { await page.goto( postUrl ); - const fitTextParagraph = page.locator( 'p.has-fit-text' ); + const fitText = page.locator( '.wp-block-fit-text' ); // Wait for fit text to initialize - await fitTextParagraph.waitFor( { state: 'visible' } ); - await expect( fitTextParagraph ).toHaveClass( /has-fit-text/ ); + await fitText.waitFor( { state: 'visible' } ); // Wait for inline style to be applied await page.waitForFunction( () => { - const el = document.querySelector( 'p.has-fit-text' ); + const el = document.querySelector( '.wp-block-fit-text' ); return el && el.style.fontSize && el.style.fontSize !== ''; }, { timeout: 5000 } ); - const paragraphs = page.locator( 'p' ); + const headings = page.locator( 'h2' ); - const normalFontSize = await paragraphs - .first() - .evaluate( ( el ) => { - return window.getComputedStyle( el ).fontSize; - } ); + const normalFontSize = await headings.first().evaluate( ( el ) => { + return window.getComputedStyle( el ).fontSize; + } ); - const fitTextFontSize = await fitTextParagraph.evaluate( ( el ) => { + const fitTextFontSize = await fitText.evaluate( ( el ) => { return window.getComputedStyle( el ).fontSize; } ); diff --git a/test/integration/fixtures/blocks/core__fit-text.html b/test/integration/fixtures/blocks/core__fit-text.html new file mode 100644 index 00000000000000..0bad0186b0b246 --- /dev/null +++ b/test/integration/fixtures/blocks/core__fit-text.html @@ -0,0 +1,3 @@ + +

Fit Text

+ diff --git a/test/integration/fixtures/blocks/core__fit-text.json b/test/integration/fixtures/blocks/core__fit-text.json new file mode 100644 index 00000000000000..24035c017e63ed --- /dev/null +++ b/test/integration/fixtures/blocks/core__fit-text.json @@ -0,0 +1,12 @@ +[ + { + "name": "core/fit-text", + "isValid": true, + "attributes": { + "content": "Fit Text", + "level": 0, + "levelOptions": [ 0, 1, 2, 3, 4, 5, 6 ] + }, + "innerBlocks": [] + } +] diff --git a/test/integration/fixtures/blocks/core__fit-text.parsed.json b/test/integration/fixtures/blocks/core__fit-text.parsed.json new file mode 100644 index 00000000000000..d988f7233d620e --- /dev/null +++ b/test/integration/fixtures/blocks/core__fit-text.parsed.json @@ -0,0 +1,9 @@ +[ + { + "blockName": "core/fit-text", + "attrs": {}, + "innerBlocks": [], + "innerHTML": "\n

Fit Text

\n", + "innerContent": [ "\n

Fit Text

\n" ] + } +] diff --git a/test/integration/fixtures/blocks/core__fit-text.serialized.html b/test/integration/fixtures/blocks/core__fit-text.serialized.html new file mode 100644 index 00000000000000..0bad0186b0b246 --- /dev/null +++ b/test/integration/fixtures/blocks/core__fit-text.serialized.html @@ -0,0 +1,3 @@ + +

Fit Text

+