From 85c280abf9805f8c0f01573c00f9e635eee541a3 Mon Sep 17 00:00:00 2001 From: AKSHAT2802 Date: Thu, 24 Apr 2025 23:39:17 +0530 Subject: [PATCH 1/7] feat: add byline block with customizable option for avatar and link archives --- includes/class-blocks.php | 3 + src/blocks/byline/block.json | 45 ++ src/blocks/byline/class-bylines-block.php | 139 ++++ src/blocks/byline/edit.jsx | 754 ++++++++++++++++++++++ src/blocks/byline/hooks.js | 81 +++ src/blocks/byline/index.js | 43 ++ src/blocks/byline/style.scss | 57 ++ src/blocks/index.js | 3 +- webpack.config.js | 7 + 9 files changed, 1131 insertions(+), 1 deletion(-) create mode 100644 src/blocks/byline/block.json create mode 100644 src/blocks/byline/class-bylines-block.php create mode 100644 src/blocks/byline/edit.jsx create mode 100644 src/blocks/byline/hooks.js create mode 100644 src/blocks/byline/index.js create mode 100644 src/blocks/byline/style.scss 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..afceca41a0 --- /dev/null +++ b/src/blocks/byline/block.json @@ -0,0 +1,45 @@ +{ + "name": "newspack/bylines", + "category": "newspack", + "attributes": { + "customByline": { + "type": "string", + "default": "" + }, + "showAvatar": { + "type": "boolean", + "default": false + }, + "avatarSize": { + "type": "number", + "default": 24, + "enum": [ "16 X 16", "24 X 24", "32 X 32", "48 X 48" ] + }, + "linkToAuthorArchive": { + "type": "boolean", + "default": true + } + }, + "supports": { + "html": false, + "align": true, + "alignWide": false, + "spacing": { + "margin": true, + "padding": true, + "__experimentalDefaultControls": { + "margin": false, + "padding": false + } + }, + "color": { + "text": true, + "background": true + }, + "filter": { + "duotone": true + } + }, + "usesContext": ["postId", "postType"], + "textdomain": "newspack-plugin" + } diff --git a/src/blocks/byline/class-bylines-block.php b/src/blocks/byline/class-bylines-block.php new file mode 100644 index 0000000000..0029b36a43 --- /dev/null +++ b/src/blocks/byline/class-bylines-block.php @@ -0,0 +1,139 @@ + [ __CLASS__, 'render_block' ], + 'uses_context' => [ 'postId', 'postType' ], + ] + ); + } + + /** + * 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. + * @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 ) { + if ( empty( $byline_content ) ) { + return ''; + } + + // Use 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 ) { + $author_id = $matches[1]; + $author_name = $matches[2]; + $author_url = get_author_posts_url( $author_id ); + $html = ''; + + // Add avatar if enabled. + if ( $with_avatars ) { + $avatar_html = get_avatar( $author_id, $avatar_size ); + $html .= '' . $avatar_html . ''; + } + + // Add author name with or without link. + 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'] ?? 48; + $link_to_author = $attributes['linkToAuthorArchive'] ?? true; + $wrapper_attributes = get_block_wrapper_attributes( [ 'class' => 'newspack-bylines' ] ); + + // If no custom byline is set, 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 = '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 ); + + ob_start(); + ?> +
> + +
+ { + if (!metaByline) {return '';} + + // Updated regex to use a function-based replacement for more control + return metaByline.replace( + /\[Author id=(\d+)\](.*?)\[\/Author\]/g, + (match, id, name) => { + // Ensure name is properly escaped for HTML + const safeName = name.trim(); + + return ` + ${safeName} + + `; + } + ); + }; + + /** + * 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) => { + if (!metaByline) { return ''; } + + return metaByline.replace( + /\[Author id=(\d*)\](\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'] || ''; // fallback base + const avatarUrl = addQueryArgs(removeQueryArgs(baseUrl, ['s']), { + s: avatarSize * 2, + }); + + if (showAvatar && avatarUrl) { + 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; + // Try to get the name from data-name attribute first (more reliable) + 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 state without re-renders + 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', + }); + + // Get post authors + const postAuthors = usePostAuthors({ postId, postType }); + + useEffect(() => { + if (postAuthors?.length) { + setAuthors(postAuthors); + } + }, [postAuthors]); + + // Set default byline if none exists + useEffect(() => { + if (!customByline && authors.length > 0) { + let defaultByline = '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]); + + // 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) { + // Handle error silently + } + }; + + // 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()); + + // Only proceed if avatars should be shown + if (!showAvatar) { + return; + } + + // Get all author tokens + const authorTokens = editableRef.current.querySelectorAll('.author-token'); + + // Add avatar for each 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) { + // Create avatar element + const avatarEl = document.createElement('span'); + avatarEl.className = `avatar-display avatar-display-${authorId}`; + avatarEl.style.cssText = ` + display: inline-block; + margin-right: 4px; + vertical-align: middle; + position: relative; + z-index: 0; + `; + + avatarEl.innerHTML = ` + ${authorName} + `; + + // Insert before the token + token.parentNode.insertBefore(avatarEl, token); + } + } + }); + }; + + // Initialize contenteditable and set up event handlers + const setupContentEditable = useCallback(element => { + if (!element) { + return; + } + editableRef.current = element; + + // If contenteditable is empty or has changed, initialize it + if (element.innerHTML !== parseForEdit(contentRef.current)) { + element.innerHTML = parseForEdit(contentRef.current); + } + + // Set up handlers + const handleInput = () => { + // Mark as typing to prevent focus loss + isTypingRef.current = true; + + // Clear previous timeout + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + + // Set timeout to detect when typing stops + typingTimeoutRef.current = setTimeout(() => { + isTypingRef.current = false; + }, 2000); // 2 seconds inactivity marks end of typing + + // Save changes with debounce + if (savingTimeoutRef.current) { + clearTimeout(savingTimeoutRef.current); + } + + savingTimeoutRef.current = setTimeout(() => { + // Save selection state + const selectionData = saveSelection(); + + // First remove any 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); + }, 500); // 500ms debounce for saving + }; + + // 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) { + // Also remove any associated avatar + const authorId = tokenElement.dataset.token; + const avatarEl = element.querySelector(`.avatar-display-${authorId}`); + if (avatarEl) { + avatarEl.remove(); + } + + // Remove token + tokenElement.remove(); + + // Update content + 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]); + + // 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 with additional data-name attribute + 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; // Add name to dataset for reliability + tokenElement.innerHTML = ` + ${token.display_name} + + `; + + // Insert token + range.insertNode(tokenElement); + + // Add avatar if needed + 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 = `avatar-display avatar-display-${token.id}`; + avatarEl.style.cssText = ` + display: inline-block; + margin-right: 4px; + vertical-align: middle; + position: relative; + z-index: 0; + `; + + avatarEl.innerHTML = ` + ${token.display_name} + `; + + // Insert 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); + + // Force focus + element.focus(); + + // Update content + const transformedContent = transformByline(element); + contentRef.current = transformedContent; + updateTokensInUse(element); + + // Update attributes + setAttributes({ customByline: transformedContent }); + }; + + // Update content ref when 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]); + + // Available authors + const availableAuthors = authors.filter(author => !tokensInUse.includes(author.id)); + + // CSS for contenteditable + const styles = ` + .newspack-byline-block { + position: relative; + } + + .newspack-byline-textarea { + min-height: 36px; + padding: 4px 0; + border: 1px solid transparent; + outline: none; + } + + .newspack-byline-textarea:focus { + border-color: #007cba; + } + + .newspack-byline-preview { + cursor: text; + } + + .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; + } + + .token-inline-block__remove { + margin-left: 4px; + padding: 0 !important; + min-width: 18px !important; + height: 18px !important; + } + + .token-inline-block__remove 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; + } + + .author-token-button svg { + margin-left: 4px; + } + + .author-token-button * { + pointer-events: none; + } + + .avatar-display { + display: inline-block; + margin-right: 4px; + vertical-align: middle; + } + + .avatar-display img { + border-radius: 50%; + vertical-align: middle; + } + `; + + return ( + <> + + + setAttributes({ showAvatar: !showAvatar })} + /> + {showAvatar && ( + setAttributes({ avatarSize: Number(value) })} + /> + )} + setAttributes({ linkToAuthorArchive: !linkToAuthorArchive })} + /> + + + +
+ + + {isSelected ? ( + <> +
isTypingRef.current = true} + onBlur={() => { + // Short delay to allow other operations to complete + setTimeout(() => { + isTypingRef.current = false; + }, 100); + }} + /> + {availableAuthors.length > 0 && ( +
+
+ {__('Add author:', 'newspack-plugin')} +
+
+ {availableAuthors.map((author) => ( + + ))} +
+
+ )} + + ) : ( +
+ )} +
+ + ); + }; + + export default Edit; diff --git a/src/blocks/byline/hooks.js b/src/blocks/byline/hooks.js new file mode 100644 index 0000000000..58923b5a9c --- /dev/null +++ b/src/blocks/byline/hooks.js @@ -0,0 +1,81 @@ +/** + * 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, we'll fall back to the default author + setCoAuthorsLoaded(true); + }); + + return () => { + controller.abort(); // Clean up if component unmounts + }; + }, [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; +} \ No newline at end of file diff --git a/src/blocks/byline/index.js b/src/blocks/byline/index.js new file mode 100644 index 0000000000..7b56ca2dfa --- /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 = __('Bylines', '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 customizable post bylines with author links and avatars.', + '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..3ea442b947 --- /dev/null +++ b/src/blocks/byline/style.scss @@ -0,0 +1,57 @@ +.wp-block-newspack-bylines { + display: flex; + align-items: center; + margin-bottom: 1em; +} + +/* Editor styles */ +.newspack-byline-preview { + line-height: 1.5; +} + +.newspack-byline-author { + display: inline-flex; + align-items: center; + margin-right: 0.5em; +} + +.newspack-byline-author:last-child { + margin-right: 0; +} + +.newspack-byline-avatar { + display: inline-block; + margin-right: 0.5em; +} + +.newspack-byline-avatar img { + border-radius: 50%; + vertical-align: middle; +} + +.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; +} + +/* Token styling in the editor */ +.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; +} 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', From 85fde5cbcf9519f3b22f58c10c691f35fcbb5c01 Mon Sep 17 00:00:00 2001 From: AKSHAT2802 Date: Mon, 28 Apr 2025 00:55:15 +0530 Subject: [PATCH 2/7] feat: update byline block to support border and typography, format edit and class file --- src/blocks/byline/block.json | 16 + ...lines-block.php => class-byline-block.php} | 10 +- src/blocks/byline/edit.jsx | 1229 +++++++++-------- 3 files changed, 658 insertions(+), 597 deletions(-) rename src/blocks/byline/{class-bylines-block.php => class-byline-block.php} (96%) diff --git a/src/blocks/byline/block.json b/src/blocks/byline/block.json index afceca41a0..f715c0ffdd 100644 --- a/src/blocks/byline/block.json +++ b/src/blocks/byline/block.json @@ -32,14 +32,30 @@ "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-bylines-block.php b/src/blocks/byline/class-byline-block.php similarity index 96% rename from src/blocks/byline/class-bylines-block.php rename to src/blocks/byline/class-byline-block.php index 0029b36a43..65c7fe3564 100644 --- a/src/blocks/byline/class-bylines-block.php +++ b/src/blocks/byline/class-byline-block.php @@ -14,7 +14,7 @@ /** * Bylines_Block Class */ -final class Bylines_Block { +final class Byline_Block { /** * Initializes the block. * @@ -32,7 +32,7 @@ public static function init() { public static function register_block() { register_block_type_from_metadata( __DIR__ . '/block.json', - [ + [ 'render_callback' => [ __CLASS__, 'render_block' ], 'uses_context' => [ 'postId', 'postType' ], ] @@ -56,7 +56,7 @@ public static function parse_byline( $byline_content, $with_links = true, $with_ // Use 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 ) { + function ( $matches ) use ( $with_links, $with_avatars, $avatar_size ) { $author_id = $matches[1]; $author_name = $matches[2]; $author_url = get_author_posts_url( $author_id ); @@ -106,7 +106,7 @@ public static function render_block( array $attributes, string $content, $block // If no custom byline is set, generate a default one using post author(s). if ( empty( $custom_byline ) ) { $authors = []; - + if ( function_exists( 'get_coauthors' ) ) { $authors = get_coauthors( $post_id ); } else { @@ -136,4 +136,4 @@ public static function render_block( array $attributes, string $content, $block } } -Bylines_Block::init(); \ No newline at end of file +Byline_Block::init(); diff --git a/src/blocks/byline/edit.jsx b/src/blocks/byline/edit.jsx index f20a7113a0..d3a3691b2b 100644 --- a/src/blocks/byline/edit.jsx +++ b/src/blocks/byline/edit.jsx @@ -2,42 +2,42 @@ * WordPress dependencies */ import { - InspectorControls, - useBlockProps, - } from '@wordpress/block-editor'; - import { __ } from '@wordpress/i18n'; - import { - PanelBody, - ToggleControl, - Button, - SelectControl, - } from '@wordpress/components'; - import { useEffect, useState, useRef, useCallback } from '@wordpress/element'; - import { Icon, plus } from '@wordpress/icons'; - import { addQueryArgs, removeQueryArgs } from '@wordpress/url'; - - /** - * Internal dependencies - */ - import { usePostAuthors } from './hooks'; - - /** - * Parse byline meta to convert custom tags ([Author][/Author]) to token markup. - * - * @param {string} metaByline Value of byline as stored in meta key. - * @return {string} Parsed byline with tokens for display in the editor. - */ - const parseForEdit = metaByline => { - if (!metaByline) {return '';} - - // Updated regex to use a function-based replacement for more control - return metaByline.replace( - /\[Author id=(\d+)\](.*?)\[\/Author\]/g, - (match, id, name) => { - // Ensure name is properly escaped for HTML - const safeName = name.trim(); - - return ` + InspectorControls, + useBlockProps, +} from '@wordpress/block-editor'; +import { __ } from '@wordpress/i18n'; +import { + PanelBody, + ToggleControl, + Button, + SelectControl, +} from '@wordpress/components'; +import { useEffect, useState, useRef, useCallback } from '@wordpress/element'; +import { Icon, plus } from '@wordpress/icons'; +import { addQueryArgs, removeQueryArgs } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import { usePostAuthors } from './hooks'; + +/** + * Parse byline meta to convert custom tags ([Author][/Author]) to token markup. + * + * @param {string} metaByline Value of byline as stored in meta key. + * @return {string} Parsed byline with tokens for display in the editor. + */ +const parseForEdit = metaByline => { + if (!metaByline) { return ''; } + + // Updated regex to use a function-based replacement for more control + return metaByline.replace( + /\[Author id=(\d+)\](.*?)\[\/Author\]/g, + (match, id, name) => { + // Ensure name is properly escaped for HTML + const safeName = name.trim(); + + return ` ${safeName} `; - } - ); - }; - - /** - * 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) => { - if (!metaByline) { return ''; } - - return metaByline.replace( - /\[Author id=(\d*)\](\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'] || ''; // fallback base - const avatarUrl = addQueryArgs(removeQueryArgs(baseUrl, ['s']), { - s: avatarSize * 2, - }); - - if (showAvatar && avatarUrl) { - avatar = `