diff --git a/includes/class-blocks.php b/includes/class-blocks.php index d0486951c3..c0766055d1 100644 --- a/includes/class-blocks.php +++ b/includes/class-blocks.php @@ -23,6 +23,9 @@ public static function init() { require_once NEWSPACK_ABSPATH . 'src/blocks/correction-box/class-correction-box-block.php'; require_once NEWSPACK_ABSPATH . 'src/blocks/correction-item/class-correction-item-block.php'; } + if ( wp_is_block_theme() ) { + require_once NEWSPACK_ABSPATH . 'src/blocks/byline/class-byline-block.php'; + } \add_action( 'enqueue_block_editor_assets', [ __CLASS__, 'enqueue_block_editor_assets' ] ); } diff --git a/src/blocks/byline/block.json b/src/blocks/byline/block.json new file mode 100644 index 0000000000..f7d76cd63b --- /dev/null +++ b/src/blocks/byline/block.json @@ -0,0 +1,64 @@ +{ + "name": "newspack/byline", + "category": "newspack", + "attributes": { + "customByline": { + "type": "string", + "default": "" + }, + "showAvatar": { + "type": "boolean", + "default": false + }, + "avatarSize": { + "type": "number", + "default": 24, + "enum": [ 16, 24, 32, 48 ] + }, + "linkToAuthorArchive": { + "type": "boolean", + "default": true + } + }, + "supports": { + "html": false, + "align": true, + "alignWide": false, + "spacing": { + "margin": true, + "padding": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } + }, + "__experimentalBorder": { + "color": true, + "radius": true, + "style": true, + "width": true + }, + "color": { + "text": true, + "background": true + }, + "filter": { + "duotone": true + }, + "typography": { + "fontSize": true, + "lineHeight": true, + "textAlign": true + } + }, + "selectors": { + "filter": { + "duotone": ".newspack-byline-avatar img" + } + }, + "usesContext": [ + "postId", + "postType" + ], + "textdomain": "newspack-plugin" +} diff --git a/src/blocks/byline/class-byline-block.php b/src/blocks/byline/class-byline-block.php new file mode 100644 index 0000000000..2ed11aa0f1 --- /dev/null +++ b/src/blocks/byline/class-byline-block.php @@ -0,0 +1,163 @@ + [ __CLASS__, 'render_block' ], + 'uses_context' => [ 'postId', 'postType' ], + ] + ); + } + + /** + * This function is used to get the duotone class name from the preset value. + * + * @param mixed $attributes Block attributes. + * @return string Constructed class name for duotone filter. + */ + public static function newspack_byline_get_duotone_class_name( $attributes ) { + $duotone_preset = isset( $attributes['style']['color']['duotone'] ) ? $attributes['style']['color']['duotone'] : null; + if ( str_starts_with( $duotone_preset, 'var:preset|duotone|' ) ) { + $slug = str_replace( 'var:preset|duotone|', '', $duotone_preset ); + return ' wp-duotone-' . sanitize_title( $slug ); + } + return ''; + } + + /** + * Parse byline content to convert custom tags ([Author][/Author]) to HTML. + * + * @param string $byline_content Value of byline as stored in attribute. + * @param bool $with_links Whether to include links to author archives. + * @param bool $with_avatars Whether to include author avatars. + * @param int $avatar_size Avatar size in pixels. + * @param string $duotone_class Duotone filter class for avatar images. + * @return string Parsed byline with author tags converted to HTML. + */ + public static function parse_byline( $byline_content, $with_links = true, $with_avatars = false, $avatar_size = 48, $duotone_class = '' ) { + if ( empty( $byline_content ) ) { + return ''; + } + + // Regex to find all author tags and replace them. + return preg_replace_callback( + '/\[Author id=(\d*)\](.*?)\[\/Author\]/s', + function ( $matches ) use ( $with_links, $with_avatars, $avatar_size, $duotone_class ) { + $author_id = $matches[1]; + $author_name = $matches[2]; + $author_url = get_author_posts_url( $author_id ); + $html = ''; + + // Add avatar before author if enabled. + if ( $with_avatars ) { + $avatar_url = get_avatar_url( $author_id, [ 'size' => $avatar_size * 2 ] ); + $class = 'avatar avatar-' . esc_attr( $avatar_size ) . ' photo wp-block-newspack-avatar__image '; + $avatar_html = '' . esc_attr( $author_name ) . ''; + + if ( ! empty( $duotone_class ) ) { + $html .= '' . $avatar_html . ''; + } else { + $html .= '' . $avatar_html . ''; + } + } + + // Add link to author archive if enabled. + if ( $with_links ) { + $html .= ''; + } else { + $html .= esc_html( $author_name ); + } + + return '' . $html . ''; + }, + $byline_content + ); + } + + /** + * Block render callback. + * + * @param array $attributes The block attributes. + * @param string $content The block content. + * @param object $block The block. + * + * @return string The block HTML. + */ + public static function render_block( array $attributes, string $content, $block ) { + $post_id = $block->context['postId'] ?? null; + + if ( empty( $post_id ) ) { + return ''; + } + + $custom_byline = $attributes['customByline'] ?? ''; + $show_avatar = $attributes['showAvatar'] ?? false; + $avatar_size = $attributes['avatarSize'] ?? 24; + $link_to_author = $attributes['linkToAuthorArchive'] ?? true; + $duotone_class = self::newspack_byline_get_duotone_class_name( $attributes ); + $wrapper_attributes = get_block_wrapper_attributes( [ 'class' => 'newspack-byline' ] ); + + // If custom byline is empty, generate a default one using post author(s). + if ( empty( $custom_byline ) ) { + $authors = []; + + if ( function_exists( 'get_coauthors' ) ) { + $authors = get_coauthors( $post_id ); + } else { + $authors[] = get_userdata( get_post_field( 'post_author', $post_id ) ); + } + + if ( ! empty( $authors ) ) { + $custom_byline = 'Published by '; + foreach ( $authors as $index => $author ) { + if ( $index > 0 ) { + $custom_byline .= ( $index === count( $authors ) - 1 ) ? ' and ' : ', '; + } + $custom_byline .= "[Author id={$author->ID}]{$author->display_name}[/Author]"; + } + } + } + + $parsed_byline = self::parse_byline( $custom_byline, $link_to_author, $show_avatar, $avatar_size, $duotone_class ); + + ob_start(); + ?> +
> + +
+ { + if ( !metaByline ) { return ''; } + + // Regex to convert [Author][/Author] tags to token markup + return metaByline.replace( + /\[Author id=(\d+)\](.*?)\[\/Author\]/g, + (match, id, name) => { + return ` + ${name} + + `; + } + ); +}; + +/** + * Parse byline meta for preview display. + * + * @param {string} metaByline Value of byline as stored in meta key. + * @param {boolean} showAvatar Whether to show author avatars. + * @param {number} avatarSize Size of author avatars. + * @param {boolean} linkToAuthorArchive Whether to link to author archives. + * @param {Array} authors List of authors to use for avatar and link generation. + * @return {string} Parsed byline for preview display. + */ +const parseForPreview = (metaByline, showAvatar = false, avatarSize = 24, linkToAuthorArchive = true, authors, duotoneClassName = '') => { + if ( !metaByline ) { return ''; } + + return metaByline.replace( + /\[Author id=(\d*)\](.*?)\[\/Author\]/g, + ( match, id, name ) => { + let avatar = ''; + let authorLink = name; + + const matchedAuthor = authors.find( author => author.id === Number( id ) ); + const baseUrl = matchedAuthor?.avatar_urls?.['96'] || ''; + const avatarUrl = addQueryArgs( removeQueryArgs(baseUrl, ['s'] ), { + s: avatarSize * 2, + } ); + + if ( showAvatar && avatarUrl && baseUrl ) { + avatar = ` + ${name} + `; + } + + if ( linkToAuthorArchive ) { + authorLink = `${name}`; + } + + return `${avatar}${authorLink}`; + } + ); +}; + +/** + * Transform the bylineElement innerHTML into the format that we expect to save. + * + * @param {Element} element Byline element reference. + * @return {string} Updated byline text, transformed into the save format. + */ +const transformByline = element => { + const clonebylineElement = element.cloneNode( true ); + + // Remove avatar elements first (if any) + const avatarElements = clonebylineElement.querySelectorAll( '.avatar-display' ); + avatarElements.forEach( el => el.remove() ); + + const tokenElements = + clonebylineElement.querySelectorAll( 'span[data-token]' ); + + tokenElements.forEach( tokenElement => { + const authorID = tokenElement.dataset.token; + const authorName = tokenElement.dataset.name || + ( tokenElement.querySelector('.components-form-token-field__token-text')?.innerText || '' ).trim(); + + if ( authorID && authorName ) { + tokenElement.replaceWith( + document.createTextNode( + `[Author id=${authorID}]${authorName}[/Author]` + ) + ); + } + } ); + + return clonebylineElement.innerHTML; +}; + +const Edit = ( { attributes, context, setAttributes, isSelected } ) => { + const { postId, postType } = context; + const { customByline, showAvatar, avatarSize, linkToAuthorArchive } = attributes; + const [ authors, setAuthors ] = useState( [] ); + const [ tokensInUse, setTokensInUse ] = useState( [] ); + + // Refs to manage states + const editableRef = useRef( null ); + const contentRef = useRef( customByline || '' ); + const isTypingRef = useRef( false ); + const typingTimeoutRef = useRef( null ); + const savingTimeoutRef = useRef( null ); + + const blockProps = useBlockProps( { + className: 'newspack-byline-block', + __unstableLayoutClassNames: [] + } ); + + const duotoneClassName = blockProps.className + ? blockProps.className.split(' ') + .filter( ( classes ) => classes.includes( 'wp-duotone' ) ) + .join(' ') + : ''; + + const postAuthors = usePostAuthors( { postId, postType } ); + + useEffect( () => { + if ( postAuthors?.length ) { + setAuthors( postAuthors ); + } + }, [ postAuthors ] ); + + // Set default byline if none exists (initialize byline) + useEffect( () => { + if ( ( !customByline || '' === customByline ) && authors.length > 0 ) { + let defaultByline = 'Published by: '; + + authors.forEach( ( author, index ) => { + if ( index > 0 ) { + defaultByline += ( index === authors.length - 1 ) ? ' and ' : ', '; + } + defaultByline += `[Author id=${author.id}]${author.display_name}[/Author]`; + } ); + + setAttributes( { customByline: defaultByline } ); + } + }, [ authors, customByline, setAttributes ] ); + + // Update avatar when duotone changes + useEffect(() => { + if ( editableRef.current && showAvatar ) { + const selectionData = saveSelection(); + updateAvatarDisplay(); + if ( selectionData ) { + restoreSelection( selectionData ); + } + } + }, [ duotoneClassName ] ); + + // Save cursor position + const saveSelection = () => { + const selection = editableRef.current?.ownerDocument.defaultView.getSelection(); + if (!selection.rangeCount) { return null; } + + return { + range: selection.getRangeAt(0).cloneRange(), + startContainer: selection.anchorNode, + startOffset: selection.anchorOffset, + endContainer: selection.focusNode, + endOffset: selection.focusOffset + }; + }; + + // Restore cursor position + const restoreSelection = ( selectionData ) => { + if ( !selectionData || !selectionData.range ) { return; } + + try { + const selection = editableRef.current?.ownerDocument.defaultView.getSelection(); + selection.removeAllRanges(); + selection.addRange( selectionData.range ); + } catch ( e ) {} + }; + + // Update tokens in use + const updateTokensInUse = ( element ) => { + if ( !element ) { return; } + + const tokenElements = element.querySelectorAll( 'span[data-token]' ); + const inUse = [ ...tokenElements ].map(span => Number( span.dataset.token ) ); + setTokensInUse( inUse ); + }; + // Update avatar displays in edit mode + const updateAvatarDisplay = () => { + if ( !editableRef.current ) { + return; + } + + // First, remove any existing avatar displays + const existingAvatars = editableRef.current.querySelectorAll( '.avatar-display' ); + existingAvatars.forEach( el => el.remove() ); + + if ( !showAvatar ) { + return; + } + + const authorTokens = editableRef.current.querySelectorAll( '.author-token' ); + + authorTokens.forEach( token => { + const authorId = token.dataset.token; + const authorName = token.dataset.name; + const matchedAuthor = authors.find( author => author.id === Number( authorId ) ); + + if ( matchedAuthor ) { + const baseUrl = matchedAuthor?.avatar_urls?.['96'] || ''; + const avatarUrl = addQueryArgs( removeQueryArgs( baseUrl, ['s'] ), { + s: avatarSize * 2, + } ); + + if ( avatarUrl ) { + const avatarEl = document.createElement( 'span' ); + avatarEl.className = `newspack-byline-avatar avatar-display avatar-display-${authorId}`; + + // Apply duotone classes from blockProps + if ( duotoneClassName ) { + avatarEl.className += ` ${duotoneClassName}`; + } + + avatarEl.innerHTML = ` + ${authorName} + `; + + // Insert avatar before the token + token.parentNode.insertBefore( avatarEl, token ); + } + } + }); + }; + + // Initialize contenteditable and set up event handlers + const setupContentEditable = useCallback( element => { + if ( !element || !authors.length ) { + return; + } + editableRef.current = element; + + // If contenteditable is empty or has changed, initialize it + if ( element.innerHTML !== parseForEdit( contentRef.current ) ) { + element.innerHTML = parseForEdit( contentRef.current ); + } + + const handleInput = () => { + // Mark as typing to prevent focus loss + isTypingRef.current = true; + + if ( typingTimeoutRef.current ) { + clearTimeout( typingTimeoutRef.current ); + } + + // Set timeout to detect when typing stops + typingTimeoutRef.current = setTimeout( () => { + isTypingRef.current = false; + }, 2000 ); + + // Save changes with debounce + if ( savingTimeoutRef.current ) { + clearTimeout( savingTimeoutRef.current ); + } + + savingTimeoutRef.current = setTimeout( () => { + const selectionData = saveSelection(); + + // Remove avatar displays - they shouldn't be saved + const avatarElements = element.querySelectorAll( '.avatar-display' ); + avatarElements.forEach( el => el.remove() ); + + // Update content ref + const transformedContent = transformByline( element ); + contentRef.current = transformedContent; + + // Update tokens in use + updateTokensInUse( element ); + + // Add back avatars if needed + if ( showAvatar ) { + updateAvatarDisplay(); + } + + // Update attributes (delayed to maintain focus) + setTimeout( () => { + setAttributes( { customByline: transformedContent } ); + + // Make sure we keep focus and restore cursor + if ( editableRef.current && editableRef.current.ownerDocument.activeElement !== editableRef.current ) { + editableRef.current.focus(); + } + + // Restore selection + if ( selectionData ) { + restoreSelection( selectionData ); + } + }, 10 ); + }, 1000 ); + }; + + // Click handler for token removal + const handleClick = ( e ) => { + if ( e.target.classList.contains( 'token-inline-block__remove' ) ) { + const tokenElement = e.target.closest( '.token-inline-block' ); + if ( tokenElement ) { + // Remove associated avatar if present + const authorId = tokenElement.dataset.token; + const avatarEl = element.querySelector( `.avatar-display-${authorId}` ); + if ( avatarEl ) { + avatarEl.remove(); + } + tokenElement.remove(); + handleInput(); + } + } + }; + + // Add event listeners + element.addEventListener( 'input', handleInput ); + element.addEventListener( 'click', handleClick ); + + // Set initial tokens + updateTokensInUse( element ); + + // Initialize avatars if needed + if ( showAvatar ) { + updateAvatarDisplay(); + } + + return () => { + // Cleanup event listeners + element.removeEventListener( 'input', handleInput ); + element.removeEventListener( 'click', handleClick ); + + // Clear timeouts + if ( typingTimeoutRef.current ) { + clearTimeout( typingTimeoutRef.current ); + } + if ( savingTimeoutRef.current ) { + clearTimeout( savingTimeoutRef.current ); + } + }; + }, [ setAttributes, showAvatar, avatarSize, authors, duotoneClassName ] ); + + // Function to insert token + const insertToken = ( token ) => { + const element = editableRef.current; + if ( !element ) { + return; + } + + // Save selection + const selection = editableRef.current?.ownerDocument.defaultView.getSelection(); + let range; + + if ( selection.rangeCount > 0 ) { + range = selection.getRangeAt( 0 ); + } else { + // Create a range at the end if none exists + range = document.createRange(); + const lastChild = element.lastChild; + if ( lastChild ) { + range.setStartAfter( lastChild ); + } else { + range.setStart( element, 0 ); + } + range.collapse( true ); + } + + // Delete any selected content + range.deleteContents(); + + // Create token element + const tokenElement = document.createElement( 'span' ); + tokenElement.id = `token-${token.id}`; + tokenElement.contentEditable = false; + tokenElement.draggable = true; + tokenElement.className = 'components-form-token-field__token token-inline-block author-token'; + tokenElement.dataset.token = token.id; + tokenElement.dataset.name = token.display_name; + tokenElement.innerHTML = ` + ${token.display_name} + + `; + + // Insert token + range.insertNode( tokenElement ); + + // Add avatar if enabled + if ( showAvatar && token ) { + const baseUrl = token?.avatar_urls?.['96'] || ''; + const avatarUrl = addQueryArgs( removeQueryArgs( baseUrl, [ 's' ] ), { + s: avatarSize * 2, + } ); + + if ( avatarUrl ) { + const avatarEl = document.createElement( 'span' ); + avatarEl.className = `newspack-byline-avatar avatar-display avatar-display-${token.id}`; + + // Apply duotone classes from blockProps + if ( duotoneClassName ) { + avatarEl.className += ` ${duotoneClassName}`; + } + + avatarEl.innerHTML = ` + ${token.display_name} + `; + + // Insert avatar before the token + tokenElement.parentNode.insertBefore( avatarEl, tokenElement ); + } + } + + // Add space after token + const spaceNode = document.createTextNode(' '); + tokenElement.parentNode.insertBefore( spaceNode, tokenElement.nextSibling ); + + // Move cursor after the space + const newRange = document.createRange(); + newRange.setStart( spaceNode, 1 ); + newRange.setEnd( spaceNode, 1 ); + selection.removeAllRanges(); + selection.addRange( newRange ); + + element.focus(); + + // Update content + const transformedContent = transformByline( element ); + contentRef.current = transformedContent; + updateTokensInUse( element ); + + // Update attributes + setAttributes( { customByline: transformedContent } ); + }; + + // Update content ref when block attributes change + useEffect( () => { + contentRef.current = customByline || ''; + + // Update editable content if we have a reference and content changed + if (editableRef.current && parseForEdit( customByline ) !== editableRef.current.innerHTML) { + // Only update if not currently typing to avoid cursor jumps + if ( !isTypingRef.current ) { + editableRef.current.innerHTML = parseForEdit( customByline ); + updateTokensInUse( editableRef.current ); + + // Update avatar display if needed + if ( showAvatar ) { + updateAvatarDisplay(); + } + } + } + }, [ customByline ]); + + // Effect to update avatar display when avatar settings change + useEffect( () => { + if ( editableRef.current ) { + const selectionData = saveSelection(); + + // Remove all existing avatars + const existingAvatars = editableRef.current.querySelectorAll( '.avatar-display' ); + existingAvatars.forEach( el => el.remove() ); + + // Add new avatars if needed + if ( showAvatar ) { + updateAvatarDisplay(); + } + + // Restore selection + if ( selectionData ) { + restoreSelection( selectionData ); + } + } + }, [ showAvatar, avatarSize ] ); + + // Focus handler + useEffect(() => { + if ( isSelected && editableRef.current ) { + // Focus the element and move cursor to end if not already focused + if ( editableRef.current.ownerDocument.activeElement !== editableRef.current ) { + editableRef.current.focus(); + + // Move cursor to end + const selection = editableRef.current?.ownerDocument.defaultView.getSelection(); + const range = document.createRange(); + + if ( editableRef.current.lastChild ) { + if ( editableRef.current.lastChild.nodeType === Node.TEXT_NODE ) { + range.setStart( editableRef.current.lastChild, editableRef.current.lastChild.length ); + } else { + range.setStartAfter( editableRef.current.lastChild ); + } + } else { + range.setStart( editableRef.current, 0 ); + } + + range.collapse( true ); + selection.removeAllRanges(); + selection.addRange( range ); + } + } + }, [ isSelected ] ); + + // Update available authors + const availableAuthors = authors.filter( author => !tokensInUse.includes( author.id ) ); + + + return ( + <> + + + setAttributes( { showAvatar: !showAvatar } ) } + /> + { showAvatar && ( + setAttributes( { avatarSize: Number( value ) } ) } + /> + )} + setAttributes( { linkToAuthorArchive: !linkToAuthorArchive } ) } + /> + + + { isSelected ? ( + <> + { customByline ? ( + <> +
isTypingRef.current = true } + onBlur={ () => { + setTimeout( () => { + isTypingRef.current = false; + }, 100 ); + } } + /> + { availableAuthors.length > 0 && ( +
+
+ { __('Add author:', 'newspack-plugin') } +
+
+ { availableAuthors.map( ( author ) => ( + + ) ) } +
+
+ ) } + + ) : ( +
+ { __( 'Loading byline…', 'newspack-plugin' ) } +
+ )} + + ) : ( +
+ ) } + + ); +}; + +export default Edit; diff --git a/src/blocks/byline/hooks.js b/src/blocks/byline/hooks.js new file mode 100644 index 0000000000..97be7ea07b --- /dev/null +++ b/src/blocks/byline/hooks.js @@ -0,0 +1,80 @@ +/** + * WordPress dependencies + */ +import { useSelect } from '@wordpress/data'; +import { useEffect, useState } from '@wordpress/element'; +import { store as coreStore } from '@wordpress/core-data'; +import apiFetch from '@wordpress/api-fetch'; + +/** + * Hook to get post authors (supports co-authors if available) + * + * @param {Object} options Options for fetching authors. + * @param {number} options.postId The post ID. + * @param {string} options.postType The post type. + * @return {Array} Array of authors. + */ +export function usePostAuthors({ postId, postType }) { + const [ authors, setAuthors ] = useState( [] ); + const [ coAuthorsLoaded, setCoAuthorsLoaded ] = useState( false ); + + // First try to get co-authors if the plugin is active + useEffect( () => { + if ( !postId ) { + return; + } + + const controller = new AbortController(); + const signal = controller.signal; + + apiFetch( { + path: `/coauthors/v1/coauthors?post_id=${postId}`, + signal, + } ) + .then( ( coauthors ) => { + if ( Array.isArray( coauthors ) && coauthors.length > 0) { + setAuthors( coauthors ); + } + setCoAuthorsLoaded( true ); + }) + .catch( () => { + // If co-authors API fails, fall back to the default author + setCoAuthorsLoaded( true ); + } ); + + return () => { + controller.abort(); + }; + }, [ postId ] ); + + // If co-authors isn't available or failed to load, use default WordPress author + const { defaultAuthor } = useSelect( + ( select ) => { + // Only fetch the default author if co-authors didn't return anything + if ( !coAuthorsLoaded ) { + return { defaultAuthor: null }; + } + + const { getEditedEntityRecord, getUser } = select( coreStore ); + const _authorId = getEditedEntityRecord( 'postType', postType, postId )?.author; + + return { + defaultAuthor: _authorId ? getUser( _authorId ) : null, + }; + }, + [ postType, postId, coAuthorsLoaded ] + ); + + // If co-authors API failed and we have a default author, use that + useEffect( () => { + if ( coAuthorsLoaded && authors.length === 0 && defaultAuthor ) { + setAuthors( [ { + id: defaultAuthor.id, + name: defaultAuthor.name, + avatar_urls: defaultAuthor.avatar_urls, + display_name: defaultAuthor.name, + } ] ); + } + }, [ coAuthorsLoaded, defaultAuthor, authors.length ] ); + return authors; +} diff --git a/src/blocks/byline/index.js b/src/blocks/byline/index.js new file mode 100644 index 0000000000..7743f95dbc --- /dev/null +++ b/src/blocks/byline/index.js @@ -0,0 +1,43 @@ +/** + * WordPress dependencies + */ +import { Path, SVG } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import './style.scss'; +import metadata from './block.json'; +import Edit from './edit'; + +export const title = __('Byline', 'newspack-plugin'); + +export const icon = ( + + + +); + +const { name } = metadata; + +export { metadata, name }; + +export const settings = { + title, + icon: { + src: icon, + foreground: '#36f', + }, + keywords: [ + __('author', 'newspack-plugin'), + __('byline', 'newspack-plugin'), + __('coauthor', 'newspack-plugin') + ], + description: __( + 'Display post author byline.', + 'newspack-plugin' + ), + usesContext: ['postId', 'postType'], + edit: Edit +}; diff --git a/src/blocks/byline/style.scss b/src/blocks/byline/style.scss new file mode 100644 index 0000000000..75eb878c50 --- /dev/null +++ b/src/blocks/byline/style.scss @@ -0,0 +1,167 @@ +// General styles (apply to both editor and frontend) +.wp-block-newspack-byline { + display: flex; + flex-wrap: wrap; + align-items: center; + margin-bottom: 1em; +} + +.newspack-byline-author { + display: inline-flex; + align-items: center; + margin-right: 0.5em; + white-space: nowrap; + + &:last-child { + margin-right: 0; + } +} + +.newspack-byline-avatar { + display: inline-block; + margin: 0 0.5rem; + position: relative; + z-index: 0; + + img { + border-radius: 50%; + vertical-align: middle; + } +} + +// Text nodes between author tokens +.wp-block-newspack-byline > span:not(.newspack-byline-author) { + display: inline-block; + white-space: nowrap; + margin-right: 0.25em; +} + +// Editor-only styles +.editor-styles-wrapper { + .newspack-byline-preview { + line-height: 1.5; + cursor: text; + display: flex; + flex-wrap: wrap; + align-items: center; + } + + .newspack-byline-author-tokens { + display: flex; + flex-direction: column; + gap: 8px; + } + + .newspack-byline-author-token { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px; + background: #f0f0f0; + border-radius: 4px; + } + + .newspack-byline-textarea { + min-height: 36px; + padding: 4px 0; + border: 1px solid transparent; + outline: none; + + &:focus { + border-color: #007cba; + } + } + + .token-inline-block { + display: inline-flex; + align-items: center; + padding: 2px 8px; + margin: 0 2px; + background-color: #e7f0fa; + border-radius: 3px; + font-size: 13px; + line-height: 1.4; + border: 1px solid #c0d6e8; + position: relative; + z-index: 1; + white-space: nowrap; + } + + .token-inline-block__remove { + margin-left: 4px; + padding: 0 !important; + min-width: 18px !important; + height: 18px !important; + + svg { + width: 18px; + height: 18px; + } + } + + .components-form-token-field__remove-token { + background-color: transparent !important; + } + + .components-form-token-field__token-text { + background-color: transparent; + } + + .newspack-author-selector { + display: flex; + align-items: center; + margin-top: 8px; + flex-wrap: wrap; + gap: 6px; + border-top: 1px solid #ddd; + padding-top: 8px; + } + + .newspack-author-selector-label { + margin-right: 8px; + font-size: 13px; + } + + .author-token-button { + margin: 0 !important; + display: inline-flex; + align-items: center; + padding: 4px 8px; + background-color: #f0f0f0; + border-radius: 3px; + font-size: 13px; + + svg { + margin-left: 4px; + } + + * { + pointer-events: none; + } + } + + .avatar-display { + display: inline-block; + margin-right: 4px; + vertical-align: middle; + position: relative; + z-index: 0; + + img { + border-radius: 50%; + vertical-align: middle; + } + } + + .components-form-token-field__token.token-inline-block.author-token { + display: inline-flex; + align-items: center; + background-color: #e0f0ff; + border-radius: 4px; + padding: 2px 8px; + margin: 0 4px; + color: #06c; + font-weight: 500; + white-space: nowrap; + } +} diff --git a/src/blocks/index.js b/src/blocks/index.js index fed4cc8776..8aa71a7b78 100644 --- a/src/blocks/index.js +++ b/src/blocks/index.js @@ -11,13 +11,14 @@ import { registerBlockType } from '@wordpress/blocks'; import * as readerRegistration from './reader-registration'; import * as correctionBox from './correction-box'; import * as correctionItem from './correction-item'; +import * as byline from './byline'; /** * Block Scripts */ import './core-image'; -export const blocks = [ readerRegistration, correctionBox, correctionItem ]; +export const blocks = [ readerRegistration, correctionBox, correctionItem, byline ]; const readerActivationBlocks = [ 'newspack/reader-registration' ]; const correctionBlocks = [ 'newspack/correction-box', 'newspack/correction-item' ]; diff --git a/webpack.config.js b/webpack.config.js index f7416c5ac6..c824fb4772 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -91,6 +91,13 @@ const entry = { 'correction-item', 'index.js' ), + 'byline-block': path.join( + __dirname, + 'src', + 'blocks', + 'byline', + 'index.js' + ), 'my-account': path.join( __dirname, 'includes',