PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.5
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.5
4.9.5 4.9.4 4.9.3 4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 All 203 releases
betterdocs / react-src / gutenberg / ai-edit / AIEditModal.js

AIEditModal.js in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.5, at react-src/gutenberg/ai-edit/AIEditModal.js

467 lines 20.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { __, sprintf } from "@wordpress/i18n";
2 import { normalizeAIError } from "../../utils/aiErrorMessage";
3 import { useSelect, useDispatch } from "@wordpress/data";
4 import { useState, useMemo, useCallback, useEffect } from "@wordpress/element";
5 import apiFetch from "@wordpress/api-fetch";
6
7 import { STORE_KEY } from "./store";
8 import { PRESETS as FALLBACK_PRESETS, TONE_OPTIONS, LANGUAGE_OPTIONS } from "./presets";
9 import { applyBlockReplace, applyInsertAfter, applyInlineReplace } from "./apply";
10
11 import {
12 Modal, TextArea, Pick, Banner,
13 PrimaryBtn, GhostBtn, TextBtn, Spinner, AIMarkWhite, IcCheck, IcArrowR,
14 AIcon, AIBadgeIcon, IcSummary, IcPlus,
15 } from "../ai-shared/kit";
16 import { PreviewAccept } from "../ai-shared/PreviewAccept";
17 import InstructionPicker from "../ai-shared/InstructionPicker";
18
19 const getConfig = () => (typeof window !== "undefined" && window.betterdocsAIEdit) || {};
20
21 const resolvePrompt = (preset, optionValue) => {
22 if (!preset || !preset.prompt) {
23 return "";
24 }
25 if (preset.needsOption && optionValue) {
26 return preset.prompt.split("{option}").join(optionValue);
27 }
28 return preset.prompt;
29 };
30
31 // ── Action icons (keyed by preset id; custom/unknown actions fall back to a spark)
32 const Stroke = (props) => <AIcon sw={1.8} {...props} />;
33 const ACTION_ICONS = {
34 improve: (p) => <AIBadgeIcon size={p.size} />,
35 shorten: (p) => <Stroke {...p}><path d="M20 4l-5 5m0 0V5m0 4h4" /><path d="M4 20l5-5m0 0v4m0-4H5" /></Stroke>,
36 expand: (p) => <Stroke {...p}><path d="M15 9l5-5m0 0h-4m4 0v4" /><path d="M9 15l-5 5m0 0h4m-4 0v-4" /></Stroke>,
37 simplify: (p) => <Stroke {...p}><path d="M3 21l9-9" /><path d="M15 3l1.3 2.7L19 7l-2.7 1.3L15 11l-1.3-2.7L11 7z" /></Stroke>,
38 change_tone: (p) => <Stroke {...p}><path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h11M19 18h1" /><circle cx="15" cy="6" r="2" /><circle cx="9" cy="12" r="2" /><circle cx="17" cy="18" r="2" /></Stroke>,
39 translate: (p) => <Stroke {...p}><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3c2.6 2.6 2.6 15.4 0 18M12 3c-2.6 2.6-2.6 15.4 0 18" /></Stroke>,
40 summarize: (p) => <IcSummary size={p.size} sw={1.8} />,
41 create_table: (p) => <Stroke {...p}><rect x="3" y="4" width="18" height="16" rx="2" /><path d="M3 9h18M3 14h18M9 4v16M15 4v16" /></Stroke>,
42 };
43 const iconFor = (id, size = 16) => (ACTION_ICONS[id] || ((p) => <AIBadgeIcon size={p.size} />))({ size });
44
45 // Solid quotation-mark glyph — the leading cue on the selected-content excerpt.
46 const IcQuote = ({ size = 16 }) => (
47 <svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" stroke="none" xmlns="http://www.w3.org/2000/svg">
48 <path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
49 </svg>
50 );
51
52 // Capitalized section label — mirrors the FieldLabel used across the Write
53 // Documentation modal (13px / 700 / ink), not an all-caps eyebrow.
54 const Eyebrow = ({ children, style }) => (
55 <div style={{ fontSize: 13, fontWeight: 700, textTransform: "capitalize", color: "var(--ink-2)", ...style }}>
56 {children}
57 </div>
58 );
59
60 // Muted right-aligned hint sitting on the same row as an Eyebrow.
61 const HeaderHint = ({ children }) => (
62 <span style={{ fontSize: 12, fontWeight: 500, color: "var(--muted)" }}>{children}</span>
63 );
64
65 // Small rounded-square icon holder — solid green when active, light grey otherwise.
66 const IconSquare = ({ active, children }) => (
67 <span style={{
68 width: 30, height: 30, borderRadius: 8, flexShrink: 0,
69 display: "flex", alignItems: "center", justifyContent: "center",
70 background: active ? "var(--green)" : "#f1f3f5",
71 color: active ? "#fff" : "var(--ink-2)",
72 transition: "background .12s, color .12s",
73 }}>
74 {children}
75 </span>
76 );
77
78 // Action card — icon square + label. The active state keeps green only as the
79 // accent (border + icon square) over a subtle ash fill, so the grid doesn't read
80 // as "all green".
81 const ActionCard = ({ active, disabled, onClick, icon, children, style }) => (
82 <button type="button" onClick={disabled ? undefined : onClick} disabled={disabled} style={{
83 display: "flex", alignItems: "center", gap: 10, textAlign: "left",
84 padding: "11px 12px", borderRadius: 12, width: "100%",
85 cursor: disabled ? "not-allowed" : "pointer",
86 background: active ? "var(--text-color-50, #f9fafb)" : "#fff",
87 border: `1.5px solid ${active ? "var(--ai)" : "var(--border)"}`,
88 color: "var(--ink-2)",
89 fontSize: 13, fontWeight: 600,
90 opacity: disabled ? 0.55 : 1, transition: "background .12s, border-color .12s, color .12s",
91 ...style,
92 }}>
93 <IconSquare active={active}>{icon}</IconSquare>
94 {children}
95 </button>
96 );
97
98 const AIEditModal = () => {
99 const state = useSelect((s) => s(STORE_KEY).getState(), []);
100 const { closeModal, setStatus, setResult, resetResult } = useDispatch(STORE_KEY);
101
102 const [selectedAction, setSelectedAction] = useState("__custom");
103 const [instruction, setInstruction] = useState("");
104 const [option, setOption] = useState(TONE_OPTIONS[0].value);
105 const [prevOption, setPrevOption] = useState("");
106 const [selectedInstructionIds, setSelectedInstructionIds] = useState([]);
107
108 const instructionChoices = useMemo(() => {
109 const list = getConfig().instructions;
110 return Array.isArray(list) ? list : [];
111 }, []);
112
113 // Actions come from settings (Edit AI Personalize) via the localized config;
114 // fall back to the built-in presets if none were provided.
115 const presets = useMemo(() => {
116 const list = getConfig().actions;
117 return Array.isArray(list) && list.length ? list : FALLBACK_PRESETS;
118 }, []);
119
120 const activePreset = useMemo(
121 () => presets.find((p) => p.id === selectedAction),
122 [presets, selectedAction]
123 );
124
125 const onSelectPreset = useCallback((preset) => {
126 setSelectedAction(preset.id);
127 let nextOption = "";
128 if (preset.needsOption === "tone") {
129 nextOption = TONE_OPTIONS[0].value;
130 } else if (preset.needsOption === "language") {
131 nextOption = LANGUAGE_OPTIONS[0].value;
132 }
133 if (preset.needsOption) {
134 setOption(nextOption);
135 }
136 setInstruction(resolvePrompt(preset, nextOption));
137 setPrevOption(preset.needsOption ? nextOption : "");
138 }, []);
139
140 const onSelectCustom = useCallback(() => {
141 setSelectedAction("__custom");
142 setInstruction("");
143 setPrevOption("");
144 }, []);
145
146 const onOptionChange = useCallback(
147 (nextOption) => {
148 setOption(nextOption);
149 if (prevOption && nextOption !== prevOption) {
150 setInstruction((current) =>
151 current ? current.split(prevOption).join(nextOption) : current
152 );
153 }
154 setPrevOption(nextOption);
155 },
156 [prevOption]
157 );
158
159 const onGenerate = useCallback(async () => {
160 const config = getConfig();
161 if (!config.rest_url) {
162 setStatus("error", __("AI Edit is not properly initialized.", "betterdocs"));
163 return;
164 }
165
166 const body = {
167 post_id: config.post_id,
168 action: selectedAction === "__custom" ? "custom" : selectedAction,
169 selection: state.selectionHtml || state.selectionText || "",
170 selection_type: state.selectionType || "block",
171 instruction: instruction,
172 option: "",
173 instruction_ids: selectedInstructionIds,
174 };
175
176 setStatus("loading");
177
178 try {
179 const response = await apiFetch({
180 url: config.rest_url,
181 method: "POST",
182 headers: { "X-WP-Nonce": config.rest_nonce },
183 data: body,
184 });
185
186 if (response && response.success && response.data && response.data.content) {
187 setResult(response.data.content);
188 } else if (response && response.message) {
189 setStatus("error", normalizeAIError(response));
190 } else {
191 setStatus("error", __("Unexpected response from the server.", "betterdocs"));
192 }
193 } catch (e) {
194 const message = normalizeAIError(
195 e,
196 __("Failed to reach the AI service. Please try again.", "betterdocs")
197 );
198 setStatus("error", message);
199 }
200 }, [state, selectedAction, activePreset, instruction, option, selectedInstructionIds, setStatus, setResult]);
201
202 const onReplace = useCallback(() => {
203 if (!state.result) return;
204 if (state.selectionType === "inline") {
205 applyInlineReplace(state.blockClientId, state.inlineContext, state.result);
206 } else {
207 applyBlockReplace(state.blockClientId, state.result);
208 }
209 closeModal();
210 }, [state, closeModal]);
211
212 const onInsertAfter = useCallback(() => {
213 if (!state.result) return;
214 applyInsertAfter(state.blockClientId, state.result);
215 closeModal();
216 }, [state, closeModal]);
217
218 const onDiscard = useCallback(() => {
219 resetResult();
220 }, [resetResult]);
221
222 // Reset instruction selection each time the modal opens.
223 useEffect(() => {
224 if (state.isOpen) {
225 setSelectedInstructionIds([]);
226 }
227 }, [state.isOpen]);
228
229 useEffect(() => {
230 if (!state.isOpen || !state.blockClientId) {
231 return;
232 }
233
234 const HIGHLIGHT_CLASS = "betterdocs-ai-edit-highlight";
235 const STYLE_ID = "betterdocs-ai-edit-highlight-style";
236 const STYLE_CSS = `
237 .${HIGHLIGHT_CLASS} {
238 outline: 2px solid #00B884 !important;
239 outline-offset: 2px;
240 border-radius: 2px;
241 box-shadow: 0 0 0 4px rgba(0, 184, 132, 0.15);
242 transition: outline 0.15s ease-out, box-shadow 0.15s ease-out;
243 }
244 `;
245 const selector = `[data-block="${state.blockClientId}"]`;
246 const targets = [];
247 const docs = new Set();
248
249 const collectFromDoc = (doc) => {
250 try {
251 doc.querySelectorAll(selector).forEach((el) => {
252 el.classList.add(HIGHLIGHT_CLASS);
253 targets.push(el);
254 if (el.ownerDocument) {
255 docs.add(el.ownerDocument);
256 }
257 });
258 } catch (e) { }
259 };
260
261 collectFromDoc(document);
262 document.querySelectorAll('iframe[name="editor-canvas"]').forEach((iframe) => {
263 try {
264 if (iframe.contentDocument) {
265 collectFromDoc(iframe.contentDocument);
266 }
267 } catch (e) { }
268 });
269
270 docs.forEach((doc) => {
271 if (!doc.getElementById(STYLE_ID) && doc.head) {
272 const style = doc.createElement("style");
273 style.id = STYLE_ID;
274 style.textContent = STYLE_CSS;
275 doc.head.appendChild(style);
276 }
277 });
278
279 return () => {
280 targets.forEach((el) => el.classList.remove(HIGHLIGHT_CLASS));
281 };
282 }, [state.isOpen, state.blockClientId]);
283
284 if (!state.isOpen) {
285 return null;
286 }
287
288 const optionsForActive =
289 activePreset && activePreset.needsOption === "tone"
290 ? TONE_OPTIONS
291 : activePreset && activePreset.needsOption === "language"
292 ? LANGUAGE_OPTIONS
293 : null;
294
295 const optionLabel =
296 activePreset && activePreset.needsOption === "tone"
297 ? __("Tone", "betterdocs")
298 : activePreset && activePreset.needsOption === "language"
299 ? __("Language", "betterdocs")
300 : "";
301
302 const isStructural = !!(activePreset && activePreset.structural);
303 const canReplaceInline = !(isStructural && state.selectionType === "inline");
304
305 const selectionPreview = state.selectionText || __("(No text)", "betterdocs");
306 const truncated = selectionPreview.length > 300
307 ? selectionPreview.slice(0, 300) + "…"
308 : selectionPreview;
309
310 const isLoading = state.status === "loading";
311 const hasResult = state.status === "done" && state.result;
312
313 const selectedActionLabel = selectedAction === "__custom"
314 ? __("Custom Prompt", "betterdocs")
315 : (activePreset ? activePreset.label : "");
316
317 return (
318 <Modal open={state.isOpen} onClose={closeModal} width={600} labelledBy="bd-ai-edit-title">
319 {/* Header */}
320 <div style={{ padding: "22px 26px 0" }}>
321 <div style={{ display: "flex", alignItems: "center", gap: 13 }}>
322 <span style={{
323 width: 46, height: 46, borderRadius: 13, flexShrink: 0,
324 display: "flex", alignItems: "center", justifyContent: "center",
325 background: "var(--green)", boxShadow: "0 2px 6px rgba(0,184,132,.35)",
326 }}>
327 <AIMarkWhite size={22} />
328 </span>
329 <div style={{ paddingRight: 30 }}>
330 <div id="bd-ai-edit-title" style={{ fontSize: 18, fontWeight: 800, color: "var(--ink)", letterSpacing: -0.3 }}>
331 {__("Edit with BetterDocs AI", "betterdocs")}
332 </div>
333 <div style={{ fontSize: 13, color: "var(--muted)", marginTop: 2 }}>
334 {__("Refine your selected content", "betterdocs")}
335 </div>
336 </div>
337 </div>
338 </div>
339
340 {/* Body */}
341 <div style={{ padding: "18px 26px 24px", overflowY: "auto" }}>
342 {!hasResult && (
343 <>
344 {/* Selected content — a quoted excerpt, not an input: an
345 ash-filled callout with a leading quote glyph so it
346 clearly reads as "the text you selected". */}
347 <Eyebrow style={{ marginBottom: 8 }}>{__("Selected content", "betterdocs")}</Eyebrow>
348 <div style={{
349 display: "flex", gap: 11, alignItems: "flex-start",
350 background: "var(--text-color-50, #f9fafb)",
351 border: "1px solid var(--text-color-200, #eaecf0)",
352 borderRadius: 10, padding: "13px 15px", marginBottom: 20,
353 }}>
354 <span style={{ flexShrink: 0, marginTop: 1, color: "var(--muted-2)", display: "flex" }}>
355 <IcQuote size={16} />
356 </span>
357 <div style={{
358 flex: 1, minWidth: 0,
359 fontSize: 13, lineHeight: 1.6, color: "var(--ink-2)",
360 maxHeight: 120, overflow: "auto", whiteSpace: "pre-wrap", wordBreak: "break-word",
361 }}>
362 {truncated}
363 </div>
364 </div>
365
366 {/* Actions */}
367 <div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 12, marginBottom: 10 }}>
368 <Eyebrow>{__("Choose an action", "betterdocs")}</Eyebrow>
369 {selectedActionLabel && (
370 <HeaderHint>
371 {sprintf(/* translators: %s: selected action name. */ __("Selected: %s", "betterdocs"), selectedActionLabel)}
372 </HeaderHint>
373 )}
374 </div>
375 <div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 10 }}>
376 {presets.map((preset) => (
377 <ActionCard key={preset.id} active={selectedAction === preset.id}
378 disabled={isLoading} onClick={() => onSelectPreset(preset)}
379 icon={iconFor(preset.id)}>
380 {preset.label}
381 </ActionCard>
382 ))}
383 </div>
384
385 {/* Custom instruction — full-width card */}
386 <ActionCard active={selectedAction === "__custom"} disabled={isLoading}
387 onClick={onSelectCustom} icon={<IcPlus size={16} sw={2} />} style={{ marginTop: 10 }}>
388 <span style={{ flex: 1 }}>{__("Custom Prompt", "betterdocs")}</span>
389 <span style={{ fontSize: 12, fontWeight: 500, color: "var(--muted)" }}>
390 {__("Tell AI exactly what you want", "betterdocs")}
391 </span>
392 </ActionCard>
393
394 {optionsForActive && (
395 <div style={{ marginTop: 18 }}>
396 <Eyebrow style={{ marginBottom: 8 }}>{optionLabel}</Eyebrow>
397 <Pick value={option} onChange={onOptionChange} disabled={isLoading}
398 options={optionsForActive} style={{ width: "100%" }} />
399 </div>
400 )}
401
402 <Eyebrow style={{ marginTop: 20, marginBottom: 8 }}>{__("Prompt", "betterdocs")}</Eyebrow>
403 <TextArea value={instruction} onChange={setInstruction} rows={5} disabled={isLoading}
404 placeholder={__("Describe exactly what you want the AI to do.", "betterdocs")} />
405 <div style={{ fontSize: 12.5, color: "var(--muted)", marginTop: 8, lineHeight: 1.5 }}>
406 {selectedAction === "__custom"
407 ? __("Describe exactly what you want the AI to do.", "betterdocs")
408 : __("Review and edit the prompt before generating.", "betterdocs")}
409 </div>
410
411 {instructionChoices.length > 0 && (
412 <InstructionPicker
413 instructions={instructionChoices}
414 selected={selectedInstructionIds}
415 onChange={setSelectedInstructionIds}
416 disabled={isLoading}
417 style={{ marginTop: 20 }}
418 />
419 )}
420
421 {state.status === "error" && state.error && (
422 <Banner tone="error" style={{ marginTop: 18 }}>{state.error}</Banner>
423 )}
424
425 {/* footer */}
426 <div style={{
427 display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 10,
428 marginTop: 22, paddingTop: 18, borderTop: "1px solid var(--border-soft)",
429 }}>
430 <TextBtn onClick={closeModal}>{__("Close", "betterdocs")}</TextBtn>
431 <PrimaryBtn icon={isLoading ? <Spinner size={15} color="#fff" /> : <AIMarkWhite size={16} />}
432 onClick={onGenerate} disabled={isLoading || !instruction.trim()}>
433 {isLoading ? __("Generating…", "betterdocs") : __("Generate", "betterdocs")}
434 </PrimaryBtn>
435 </div>
436 </>
437 )}
438
439 {hasResult && (
440 <PreviewAccept
441 mode="replace"
442 status="ready"
443 suggestion={state.result}
444 actions={
445 <>
446 <TextBtn onClick={onDiscard} style={{ marginRight: "auto" }}>
447 {__("Discard & Retry", "betterdocs")}
448 </TextBtn>
449 <GhostBtn icon={<IcArrowR size={15} />} onClick={onInsertAfter}>
450 {__("Insert After", "betterdocs")}
451 </GhostBtn>
452 {canReplaceInline && (
453 <PrimaryBtn icon={<IcCheck size={16} sw={2.4} />} onClick={onReplace}>
454 {__("Replace Selection", "betterdocs")}
455 </PrimaryBtn>
456 )}
457 </>
458 }
459 />
460 )}
461 </div>
462 </Modal>
463 );
464 };
465
466 export default AIEditModal;
467