import { useCallback, useMemo } from 'react'; import { useEntityProp } from '@wordpress/core-data'; import { useSelect } from '@wordpress/data'; import { seoEditorEnv } from '../env'; /** * Attachments as a visitor reads them: in the edit context an author was * refused (403) an image another user uploaded, and the share preview said * there was none. */ const VIEW = { context: 'view' }; export type SeoMeta = Record; /** * The post's meta as the block editor edits it: reads come from the entity * record, writes go into the same edit and are saved with the post. */ export const usePostMeta = (): { postType: string; meta: SeoMeta; update: (changes: SeoMeta) => void; } => { const postType = useSelect( // eslint-disable-next-line @typescript-eslint/no-explicit-any (select: any) => select('core/editor').getCurrentPostType() as string, [] ); const [rawMeta, setMeta] = useEntityProp('postType', postType, 'meta'); const meta = (rawMeta ?? {}) as SeoMeta; const update = useCallback( (changes: SeoMeta): void => (setMeta as (next: SeoMeta) => void)({ ...meta, ...changes }), [meta, setMeta] ); return { postType, meta, update }; }; /** A product's own data as its edit form holds it. */ export interface ProductFacts { /** simple, variable, grouped or external. */ type: string; /** The sale or regular price typed; empty when neither is. */ price: string; gtin: string; /** Pictures in the gallery. */ gallery: number; /** Terms ticked in the brand taxonomy. */ brands: number; } export interface EditedPost { id: number; title: string; content: string; excerpt: string; slug: string; link: string; /** The saved status: publish, draft, pending…; empty for terms and users. */ status: string; featuredImageId: number; /** Alt texts of the images outside the content: the featured image, a product's gallery. */ imageAlts?: string[]; /** Assigned term IDs per taxonomy slug. */ terms: Record; /** On a product's classic screen, its data as typed. */ product?: ProductFacts; } /** The post as it stands in the editor, saved or not. */ export const useEditedPost = (): EditedPost => { // The select returns plain values only (the terms as a string): a new // object on every call made @wordpress/data re-render and warn. const { termsKey, featuredAlt, ...post } = useSelect( // eslint-disable-next-line @typescript-eslint/no-explicit-any (select: any) => { const editor = select('core/editor'); const featuredId = Number( editor.getEditedPostAttribute('featured_media') ?? 0 ); const terms: Record = {}; for (const taxonomy of seoEditorEnv?.taxonomies ?? []) { const assigned = editor.getEditedPostAttribute(taxonomy.restBase); terms[taxonomy.slug] = Array.isArray(assigned) ? assigned : []; } return { id: Number(editor.getCurrentPostId() ?? 0), title: String(editor.getEditedPostAttribute('title') ?? ''), content: String(editor.getEditedPostContent() ?? ''), excerpt: String(editor.getEditedPostAttribute('excerpt') ?? ''), slug: String( editor.getEditedPostSlug?.() ?? editor.getEditedPostAttribute('slug') ?? '' ), link: String(editor.getPermalink?.() ?? ''), status: String(editor.getCurrentPostAttribute?.('status') ?? ''), featuredImageId: featuredId, featuredAlt: featuredId ? String( select('core').getEntityRecord( 'postType', 'attachment', featuredId, VIEW )?.alt_text ?? '' ) : '', termsKey: JSON.stringify(terms), }; }, [] ); const terms = useMemo( () => JSON.parse(termsKey) as Record, [termsKey] ); return useMemo( () => ({ ...post, imageAlts: featuredAlt ? [featuredAlt] : [], terms }), [ post.id, post.title, post.content, post.excerpt, post.slug, post.link, post.status, post.featuredImageId, featuredAlt, terms, ] ); }; export interface TermOption { id: number; name: string; } /** The names of the terms assigned to the post in one taxonomy. */ export const useAssignedTerms = ( taxonomy: string, ids: number[] ): TermOption[] => { const key = ids.join(','); // One query object per set of ids: the records selector memoizes on the // object, and a fresh literal on every call would hand back a new array // each time, which useSelect reports as an unstable selector. const query = useMemo( () => ({ include: key === '' ? [] : key.split(',').map(Number), per_page: 100, context: 'view', }), [key] ); const records = useSelect( // eslint-disable-next-line @typescript-eslint/no-explicit-any (select: any): Array<{ id: number; name: string }> | null => ids.length === 0 ? null : select('core').getEntityRecords('taxonomy', taxonomy, query), [taxonomy, query] ); return useMemo( () => (records ?? []).map(record => ({ id: record.id, name: record.name })), [records] ); }; interface AttachmentRecord { source_url?: string; media_details?: { width?: number; height?: number; sizes?: Record; }; } /** The size of an attachment's original, null while unknown. */ export const useAttachmentSize = ( id: number ): { width: number; height: number } | null => { // A string from the store: an object would be new on every call. const size = useSelect( // eslint-disable-next-line @typescript-eslint/no-explicit-any (select: any): string => { if (!id) { return ''; } const media = select('core').getEntityRecord( 'postType', 'attachment', id, VIEW ) as AttachmentRecord | undefined; const { width, height } = media?.media_details ?? {}; return width && height ? `${width}x${height}` : ''; }, [id] ); if (size === '') { return null; } const [width, height] = size.split('x').map(Number); return { width, height }; }; /** The URL of an attachment, for previews. */ export const useAttachmentUrl = (id: number): string => useSelect( // eslint-disable-next-line @typescript-eslint/no-explicit-any (select: any): string => { if (!id) { return ''; } const media = select('core').getEntityRecord( 'postType', 'attachment', id, VIEW ) as AttachmentRecord | undefined; return ( media?.media_details?.sizes?.large?.source_url ?? media?.source_url ?? '' ); }, [id] );