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 }) => (
{icon}
{children}
);
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")}
{/* 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 && (
)}
{__("Prompt", "betterdocs")}
{selectedAction === "__custom"
? __("Describe exactly what you want the AI to do.", "betterdocs")
: __("Review and edit the prompt before generating.", "betterdocs")}
{instructionChoices.length > 0 && (
)}
{state.status === "error" && state.error && (
{state.error}
)}
{/* footer */}
{__("Close", "betterdocs")}
:
}
onClick={onGenerate} disabled={isLoading || !instruction.trim()}>
{isLoading ? __("Generating…", "betterdocs") : __("Generate", "betterdocs")}
>
)}
{hasResult && (
{__("Discard & Retry", "betterdocs")}
} onClick={onInsertAfter}>
{__("Insert After", "betterdocs")}
{canReplaceInline && (
} onClick={onReplace}>
{__("Replace Selection", "betterdocs")}
)}
>
}
/>
)}
);
};
export default AIEditModal;