import { __, sprintf } from "@wordpress/i18n"; import { normalizeAIError } from "../../utils/aiErrorMessage"; import { useSelect, useDispatch } from "@wordpress/data"; import { useState, useMemo, useCallback, useEffect } from "@wordpress/element"; import apiFetch from "@wordpress/api-fetch"; import { STORE_KEY } from "./store"; import { PRESETS as FALLBACK_PRESETS, TONE_OPTIONS, LANGUAGE_OPTIONS } from "./presets"; import { applyBlockReplace, applyInsertAfter, applyInlineReplace } from "./apply"; import { Modal, TextArea, Pick, Banner, PrimaryBtn, GhostBtn, TextBtn, Spinner, AIMarkWhite, IcCheck, IcArrowR, AIcon, AIBadgeIcon, IcSummary, IcPlus, } from "../ai-shared/kit"; import { PreviewAccept } from "../ai-shared/PreviewAccept"; import InstructionPicker from "../ai-shared/InstructionPicker"; const getConfig = () => (typeof window !== "undefined" && window.betterdocsAIEdit) || {}; const resolvePrompt = (preset, optionValue) => { if (!preset || !preset.prompt) { return ""; } if (preset.needsOption && optionValue) { return preset.prompt.split("{option}").join(optionValue); } return preset.prompt; }; // ── Action icons (keyed by preset id; custom/unknown actions fall back to a spark) const Stroke = (props) => ; const ACTION_ICONS = { improve: (p) => , shorten: (p) => , expand: (p) => , simplify: (p) => , change_tone: (p) => , translate: (p) => , summarize: (p) => , create_table: (p) => , }; const iconFor = (id, size = 16) => (ACTION_ICONS[id] || ((p) => ))({ size }); // Solid quotation-mark glyph — the leading cue on the selected-content excerpt. const IcQuote = ({ size = 16 }) => ( ); // Capitalized section label — mirrors the FieldLabel used across the Write // Documentation modal (13px / 700 / ink), not an all-caps eyebrow. const Eyebrow = ({ children, style }) => (
{children}
); // Muted right-aligned hint sitting on the same row as an Eyebrow. const HeaderHint = ({ children }) => ( {children} ); // Small rounded-square icon holder — solid green when active, light grey otherwise. const IconSquare = ({ active, children }) => ( {children} ); // Action card — icon square + label. The active state keeps green only as the // accent (border + icon square) over a subtle ash fill, so the grid doesn't read // as "all green". const ActionCard = ({ active, disabled, onClick, icon, children, style }) => ( ); const AIEditModal = () => { const state = useSelect((s) => s(STORE_KEY).getState(), []); const { closeModal, setStatus, setResult, resetResult } = useDispatch(STORE_KEY); const [selectedAction, setSelectedAction] = useState("__custom"); const [instruction, setInstruction] = useState(""); const [option, setOption] = useState(TONE_OPTIONS[0].value); const [prevOption, setPrevOption] = useState(""); const [selectedInstructionIds, setSelectedInstructionIds] = useState([]); const instructionChoices = useMemo(() => { const list = getConfig().instructions; return Array.isArray(list) ? list : []; }, []); // Actions come from settings (Edit AI Personalize) via the localized config; // fall back to the built-in presets if none were provided. const presets = useMemo(() => { const list = getConfig().actions; return Array.isArray(list) && list.length ? list : FALLBACK_PRESETS; }, []); const activePreset = useMemo( () => presets.find((p) => p.id === selectedAction), [presets, selectedAction] ); const onSelectPreset = useCallback((preset) => { setSelectedAction(preset.id); let nextOption = ""; if (preset.needsOption === "tone") { nextOption = TONE_OPTIONS[0].value; } else if (preset.needsOption === "language") { nextOption = LANGUAGE_OPTIONS[0].value; } if (preset.needsOption) { setOption(nextOption); } setInstruction(resolvePrompt(preset, nextOption)); setPrevOption(preset.needsOption ? nextOption : ""); }, []); const onSelectCustom = useCallback(() => { setSelectedAction("__custom"); setInstruction(""); setPrevOption(""); }, []); const onOptionChange = useCallback( (nextOption) => { setOption(nextOption); if (prevOption && nextOption !== prevOption) { setInstruction((current) => current ? current.split(prevOption).join(nextOption) : current ); } setPrevOption(nextOption); }, [prevOption] ); const onGenerate = useCallback(async () => { const config = getConfig(); if (!config.rest_url) { setStatus("error", __("AI Edit is not properly initialized.", "betterdocs")); return; } const body = { post_id: config.post_id, action: selectedAction === "__custom" ? "custom" : selectedAction, selection: state.selectionHtml || state.selectionText || "", selection_type: state.selectionType || "block", instruction: instruction, option: "", instruction_ids: selectedInstructionIds, }; setStatus("loading"); try { const response = await apiFetch({ url: config.rest_url, method: "POST", headers: { "X-WP-Nonce": config.rest_nonce }, data: body, }); if (response && response.success && response.data && response.data.content) { setResult(response.data.content); } else if (response && response.message) { setStatus("error", normalizeAIError(response)); } else { setStatus("error", __("Unexpected response from the server.", "betterdocs")); } } catch (e) { const message = normalizeAIError( e, __("Failed to reach the AI service. Please try again.", "betterdocs") ); setStatus("error", message); } }, [state, selectedAction, activePreset, instruction, option, selectedInstructionIds, setStatus, setResult]); const onReplace = useCallback(() => { if (!state.result) return; if (state.selectionType === "inline") { applyInlineReplace(state.blockClientId, state.inlineContext, state.result); } else { applyBlockReplace(state.blockClientId, state.result); } closeModal(); }, [state, closeModal]); const onInsertAfter = useCallback(() => { if (!state.result) return; applyInsertAfter(state.blockClientId, state.result); closeModal(); }, [state, closeModal]); const onDiscard = useCallback(() => { resetResult(); }, [resetResult]); // Reset instruction selection each time the modal opens. useEffect(() => { if (state.isOpen) { setSelectedInstructionIds([]); } }, [state.isOpen]); useEffect(() => { if (!state.isOpen || !state.blockClientId) { return; } const HIGHLIGHT_CLASS = "betterdocs-ai-edit-highlight"; const STYLE_ID = "betterdocs-ai-edit-highlight-style"; const STYLE_CSS = ` .${HIGHLIGHT_CLASS} { outline: 2px solid #00B884 !important; outline-offset: 2px; border-radius: 2px; box-shadow: 0 0 0 4px rgba(0, 184, 132, 0.15); transition: outline 0.15s ease-out, box-shadow 0.15s ease-out; } `; const selector = `[data-block="${state.blockClientId}"]`; const targets = []; const docs = new Set(); const collectFromDoc = (doc) => { try { doc.querySelectorAll(selector).forEach((el) => { el.classList.add(HIGHLIGHT_CLASS); targets.push(el); if (el.ownerDocument) { docs.add(el.ownerDocument); } }); } catch (e) { } }; collectFromDoc(document); document.querySelectorAll('iframe[name="editor-canvas"]').forEach((iframe) => { try { if (iframe.contentDocument) { collectFromDoc(iframe.contentDocument); } } catch (e) { } }); docs.forEach((doc) => { if (!doc.getElementById(STYLE_ID) && doc.head) { const style = doc.createElement("style"); style.id = STYLE_ID; style.textContent = STYLE_CSS; doc.head.appendChild(style); } }); return () => { targets.forEach((el) => el.classList.remove(HIGHLIGHT_CLASS)); }; }, [state.isOpen, state.blockClientId]); if (!state.isOpen) { return null; } const optionsForActive = activePreset && activePreset.needsOption === "tone" ? TONE_OPTIONS : activePreset && activePreset.needsOption === "language" ? LANGUAGE_OPTIONS : null; const optionLabel = activePreset && activePreset.needsOption === "tone" ? __("Tone", "betterdocs") : activePreset && activePreset.needsOption === "language" ? __("Language", "betterdocs") : ""; const isStructural = !!(activePreset && activePreset.structural); const canReplaceInline = !(isStructural && state.selectionType === "inline"); const selectionPreview = state.selectionText || __("(No text)", "betterdocs"); const truncated = selectionPreview.length > 300 ? selectionPreview.slice(0, 300) + "…" : selectionPreview; const isLoading = state.status === "loading"; const hasResult = state.status === "done" && state.result; const selectedActionLabel = selectedAction === "__custom" ? __("Custom Prompt", "betterdocs") : (activePreset ? activePreset.label : ""); return ( {/* Header */}
{__("Edit with BetterDocs AI", "betterdocs")}
{__("Refine your selected content", "betterdocs")}
{/* Body */}
{!hasResult && ( <> {/* Selected content — a quoted excerpt, not an input: an ash-filled callout with a leading quote glyph so it clearly reads as "the text you selected". */} {__("Selected content", "betterdocs")}
{truncated}
{/* Actions */}
{__("Choose an action", "betterdocs")} {selectedActionLabel && ( {sprintf(/* translators: %s: selected action name. */ __("Selected: %s", "betterdocs"), selectedActionLabel)} )}
{presets.map((preset) => ( onSelectPreset(preset)} icon={iconFor(preset.id)}> {preset.label} ))}
{/* Custom instruction — full-width card */} } style={{ marginTop: 10 }}> {__("Custom Prompt", "betterdocs")} {__("Tell AI exactly what you want", "betterdocs")} {optionsForActive && (
{optionLabel}
)} {__("Prompt", "betterdocs")}