import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { TooltipProvider } from '../components/ui/tooltip'; import type { SeoEditorEnv } from './env'; import type { EditedPost, SeoMeta } from './hooks/use-seo-meta'; import { Panel, type AppliedPost } from './Panel'; type FormField = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; const field = (id: string): FormField | null => document.getElementById(id) as FormField | null; /** The text of a TinyMCE-backed field, or of the plain one behind it. */ const richText = (id: string): string => { const editor = window.tinymce?.get?.(id); return editor && !editor.isHidden() ? editor.getContent() : (field(id)?.value ?? ''); }; /** Set the text of a TinyMCE-backed field and of the plain one behind it, whichever is showing. */ const setRichText = (id: string, html: string): void => { const editor = window.tinymce?.get?.(id); if (editor && !editor.isHidden()) { editor.setContent?.(html); } const plain = field(id); if (plain) { plain.value = html; } }; /** * What a fix wrote, put into the classic form's own fields: the title * input, the content and excerpt editors, the tag box, so that Update * saves the same values and the form reads as edited. */ const writeForm = (changes: AppliedPost): void => { if (changes.title !== undefined) { const title = field('title'); if (title) { title.value = changes.title; title.dispatchEvent(new Event('input', { bubbles: true })); } } if (changes.content !== undefined) { setRichText('content', changes.content); } if (changes.excerpt !== undefined) { setRichText('excerpt', changes.excerpt); } if (changes.slug !== undefined) { const slug = field('post_name'); if (slug) { slug.value = changes.slug; } const sample = document.getElementById('editable-post-name-full'); if (sample) { sample.textContent = changes.slug; } } if (changes.tags) { // The tag box keeps its tags in a hidden textarea and redraws the chips from it. const tags = field('tax-input-product_tag'); if (tags) { tags.value = changes.tags.map(tag => tag.name).join(','); const box = window.jQuery?.('#product_tag'); if (box) { window.tagBox?.quickClicks?.(box); } } } document .getElementById('post') ?.dispatchEvent(new Event('input', { bubbles: true })); }; /** The form each classic screen submits, where the panel listens for edits. */ const FORM_IDS: Record = { classic: 'post', term: 'edittag', user: 'your-profile', }; /** The classic post form as it stands: title, content, excerpt, slug, terms. */ const readPost = (env: SeoEditorEnv): EditedPost => { const terms: Record = {}; for (const taxonomy of env.taxonomies) { const selector = taxonomy.slug === 'category' ? 'input[name="post_category[]"]:checked' : `input[name="tax_input[${taxonomy.slug}][]"]:checked`; terms[taxonomy.slug] = Array.from( document.querySelectorAll(selector), input => Number(input.value) ).filter(id => id > 0); } const thumbnail = Number(field('_thumbnail_id')?.value ?? 0); return { id: env.objectId, title: field('title')?.value ?? '', content: richText('content'), // WooCommerce's short description is a visual editor of its own. excerpt: richText('excerpt'), slug: field('post_name')?.value || document.getElementById('editable-post-name-full')?.textContent || env.objectSlug, // The address as the form shows it: a draft's link is its preview // (?preview=true), while its text is the address it will have. link: document .querySelector('#sample-permalink a') ?.textContent?.trim() || env.objectLink, status: field('original_post_status')?.value || field('post_status')?.value || '', featuredImageId: thumbnail > 0 ? thumbnail : 0, // The product image and gallery count for the alt text test, as the // images of the content do. imageAlts: Array.from( document.querySelectorAll( '#postimagediv img, #product_images_container img' ), image => image.alt ).filter(alt => alt.trim() !== ''), terms, product: env.type === 'product' ? { type: field('product-type')?.value || env.product?.type || 'simple', price: field('_sale_price')?.value || field('_regular_price')?.value || '', gtin: field('_global_unique_id')?.value ?? '', gallery: document.querySelectorAll( '#product_images_container li.image' ).length, brands: env.product?.brandTaxonomy ? document.querySelectorAll( `input[name="tax_input[${env.product.brandTaxonomy}][]"]:checked` ).length : 0, } : undefined, }; }; /** The term edit form: name, description and slug; the WooCommerce thumbnail when there is one. */ const readTerm = (env: SeoEditorEnv): EditedPost => ({ id: env.objectId, title: field('name')?.value ?? '', content: '', excerpt: richText('description'), slug: field('slug')?.value || env.objectSlug, link: env.objectLink, status: '', featuredImageId: Number(field('product_cat_thumbnail_id')?.value ?? 0) || 0, terms: {}, }); /** The profile form: the display name and the bio. */ const readUser = (env: SeoEditorEnv): EditedPost => ({ id: env.objectId, title: field('display_name')?.value || env.objectSlug, content: '', excerpt: field('description')?.value ?? '', slug: env.objectSlug, link: env.objectLink, status: '', featuredImageId: 0, terms: {}, }); const readForm = (env: SeoEditorEnv): EditedPost => { switch (env.screen) { case 'term': return readTerm(env); case 'user': return readUser(env); default: return readPost(env); } }; /** * The panel inside a classic form: the fields live in React state seeded * from the saved values, and every change is mirrored into a hidden input * the form submits for the save hook. */ export const ClassicPanel = ({ env, input, }: { env: SeoEditorEnv; input: HTMLInputElement; }): JSX.Element => { const [meta, setMeta] = useState(env.initialMeta ?? {}); const [post, setPost] = useState(() => readForm(env)); const update = useCallback((changes: SeoMeta): void => { setMeta(current => ({ ...current, ...changes })); }, []); // The saved values are written only once something changes them: the // profile screen compares its form with how it loaded, and a field filled // on mount made it warn "changes will be lost" on every visit. Left // empty, the save hook stores nothing. const loaded = useRef(meta); useEffect(() => { if (meta !== loaded.current) { input.value = JSON.stringify(meta); } }, [meta, input]); useEffect(() => { let last = JSON.stringify(post); const refresh = (): void => { const next = readForm(env); const serialized = JSON.stringify(next); if (serialized !== last) { last = serialized; setPost(next); } }; const form = document.getElementById(FORM_IDS[env.screen] ?? 'post'); form?.addEventListener('input', refresh); form?.addEventListener('change', refresh); const timer = window.setInterval(refresh, 1500); return () => { form?.removeEventListener('input', refresh); form?.removeEventListener('change', refresh); window.clearInterval(timer); }; }, [env]); const applyPost = useCallback( (changes: AppliedPost): void => { writeForm(changes); setPost(readForm(env)); }, [env] ); const data = useMemo( () => ({ meta, update, post, applyPost }), [meta, update, post, applyPost] ); return ( ); };