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 / admin / faq-builder / utils / TextEditor.js

TextEditor.js in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.5, at react-src/admin/faq-builder/utils/TextEditor.js

276 lines 10.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useEffect, useId, useRef, useState } from "react";
2 import "./rich-text-editor.scss";
3
4 // The TinyMCE iframe is a separate document and can't read the admin CSS vars,
5 // so the content palette is resolved here and re-applied when the admin theme
6 // toggles (see the MutationObserver effect below).
7 const isDarkMode = () =>
8 !!document.querySelector(".betterdocs-admin.betterdocs-dark-mode");
9
10 const getEditorPalette = (isDark) =>
11 isDark
12 ? { text: "#e6e8eb", bg: "#0c0f14", quoteBorder: "#3a4250", quoteText: "#aab0ba", codeBg: "#232a35", placeholder: "#6b7280" }
13 : { text: "#1e1e1e", bg: "#ffffff", quoteBorder: "#ccc", quoteText: "#555", codeBg: "#f0f0f1", placeholder: "#757575" };
14
15 const buildEditorContentStyle = (ed) =>
16 [
17 `body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;`,
18 `font-size:14px;line-height:1.65;color:${ed.text};background:${ed.bg};padding:14px 18px;margin:0;}`,
19 "body p{margin:0 0 1em;}body p:last-child{margin-bottom:0;}",
20 `body h1,body h2,body h3,body h4,body h5,body h6{color:${ed.text};font-weight:600;line-height:1.3;margin:1.2em 0 .6em;}`,
21 `body blockquote{border-left:4px solid ${ed.quoteBorder};margin:1em 0;padding:.25em 1em;color:${ed.quoteText};}`,
22 `body code,body pre{font-family:Menlo,Consolas,Monaco,monospace;font-size:13px;background:${ed.codeBg};border-radius:3px;}`,
23 "body code{padding:1px 6px;}body pre{padding:12px 14px;}",
24 "body ul,body ol{padding-left:1.6em;}",
25 `body[data-placeholder]:empty::before{content:attr(data-placeholder);color:${ed.placeholder};}`
26 ].join("");
27
28 // Re-theme an already-initialized editor's iframe by injecting/replacing a
29 // style tag in its document head (overrides the init-time content_style).
30 const applyEditorTheme = (editorId) => {
31 const tmce = window.tinymce && window.tinymce.get(editorId);
32 const doc = tmce && tmce.getDoc && tmce.getDoc();
33 const head = doc && (doc.head || doc.getElementsByTagName("head")[0]);
34 if (!head) return;
35 let styleEl = doc.getElementById("bd-faq-editor-theme");
36 if (!styleEl) {
37 styleEl = doc.createElement("style");
38 styleEl.id = "bd-faq-editor-theme";
39 head.appendChild(styleEl);
40 }
41 styleEl.textContent = buildEditorContentStyle(getEditorPalette(isDarkMode()));
42 };
43
44 const TextEditor = ({ aiValue = null, value, onChange, placeholder, onReady, resetKey }) => {
45 // useId() yields a stable, collision-free id per instance and is safe under
46 // React Strict Mode (a module-level counter can double-increment). Colons
47 // from useId() are stripped because TinyMCE/QuickTags use the id in CSS
48 // selectors where ":" is invalid.
49 const reactId = useId();
50 const editorIdRef = useRef(
51 `betterdocs-faq-editor-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`
52 );
53 const editorId = editorIdRef.current;
54 const onChangeRef = useRef(onChange);
55 const onReadyRef = useRef(onReady);
56 const initializedRef = useRef(false);
57 // The initial value is captured once at mount. Subsequent `value` updates
58 // from the parent are intentionally ignored — the live editor (TinyMCE /
59 // QuickTags) is the source of truth once mounted, and re-applying `value`
60 // on every keystroke would fight the user's cursor. AI insertions go
61 // through the separate `aiValue` effect below.
62 const initialValueRef = useRef(value || "");
63 // Latest `value`, kept fresh so a resetKey bump can reload the editor with the
64 // current (saved) content without making `value` a live-controlled prop.
65 const valueRef = useRef(value || "");
66 useEffect(() => {
67 valueRef.current = value || "";
68 }, [value]);
69
70 // The classic editor loads as two separate scripts: wp.editor (the API) and
71 // wp-tinymce (window.tinymce — the Visual-mode core, a larger file that lands
72 // later). When this component mounts early — e.g. the top-level "Add New FAQ"
73 // form rendered on page load — tinymce isn't ready yet, so wp.editor.initialize
74 // would produce only the plain textarea. Wait for BOTH before initializing,
75 // and re-run once they're available.
76 const isEditorReady = () =>
77 Boolean(window.wp && window.wp.editor && window.tinymce);
78 const [wpEditorReady, setWpEditorReady] = useState(isEditorReady);
79
80 useEffect(() => {
81 if (wpEditorReady) return;
82 const intervalId = setInterval(() => {
83 if (isEditorReady()) {
84 setWpEditorReady(true);
85 clearInterval(intervalId);
86 }
87 }, 150);
88 return () => clearInterval(intervalId);
89 }, [wpEditorReady]);
90
91 useEffect(() => {
92 onChangeRef.current = onChange;
93 }, [onChange]);
94
95 useEffect(() => {
96 onReadyRef.current = onReady;
97 }, [onReady]);
98
99 useEffect(() => {
100 if (initializedRef.current || !window.wp || !window.wp.editor || !window.tinymce) {
101 return;
102 }
103 initializedRef.current = true;
104
105 const emitChange = (content) => {
106 if (typeof onChangeRef.current === "function") {
107 onChangeRef.current(content);
108 }
109 };
110
111 window.wp.editor.initialize(editorId, {
112 tinymce: {
113 wpautop: true,
114 toolbar1:
115 "formatselect,bold,italic,underline,strikethrough,bullist,numlist,blockquote,alignleft,aligncenter,alignright,link,unlink,wp_more,wp_adv",
116 toolbar2:
117 "fontsizeselect,forecolor,backcolor,pastetext,removeformat,charmap,outdent,indent,undo,redo,code,wp_help",
118 height: 200,
119 menubar: false,
120 branding: false,
121 content_style: buildEditorContentStyle(getEditorPalette(isDarkMode())),
122 // Keep false: true would inline pasted images as base64 directly into
123 // post_content, which can balloon a single FAQ to multiple MB when
124 // pasting from Notion / Word. Users should use the Media Library button.
125 paste_data_images: false,
126 paste_as_text: false,
127 paste_remove_styles: true,
128 paste_remove_styles_if_webkit: true,
129 paste_strip_class_attributes: "all",
130 paste_merge_formats: true,
131 paste_webkit_styles: "none",
132 setup: function (editor) {
133 editor.on("init", function () {
134 if (initialValueRef.current) {
135 editor.setContent(initialValueRef.current);
136 }
137 // Placeholder is applied once on init. Later `placeholder` prop
138 // changes are not re-applied — it is a static label in practice.
139 if (placeholder) {
140 editor.getBody().setAttribute("data-placeholder", placeholder);
141 }
142 // Signal that the editor is fully initialized (final height exists).
143 // wp-tinymce loads late, so this is the only reliable point to run
144 // layout-dependent work like the dashboard "Add New FAQ" scroll.
145 if (typeof onReadyRef.current === "function") {
146 // In that flow the user should land on the top field — don't let
147 // the freshly-initialized editor keep focus and scroll ITSELF into
148 // view (which buries the form top, esp. on SPA navigation).
149 try {
150 editor.getBody().blur();
151 const win = editor.getWin && editor.getWin();
152 if (win && typeof win.blur === "function") win.blur();
153 } catch (e) {
154 // editor window not reachable; ignore
155 }
156 onReadyRef.current();
157 }
158 });
159 // Sync editor → React state. Includes ExecCommand so toolbar
160 // actions (Bold, Italic, lists, etc.) emit changes too — keyup
161 // alone misses mouse-click formatting and was dropping inline
162 // tags like <strong> from saved content.
163 editor.on(
164 "change keyup paste input undo redo execcommand",
165 function () {
166 emitChange(editor.getContent());
167 }
168 );
169 },
170 },
171 quicktags: { buttons: "strong,em,link,block,del,ins,img,ul,ol,li,code,more,close" },
172 mediaButtons: true,
173 });
174
175 const textarea = document.getElementById(editorId);
176 const handleTextareaInput = () => {
177 const tmce = window.tinymce && window.tinymce.get(editorId);
178 if (!tmce || tmce.isHidden()) {
179 emitChange(textarea ? textarea.value : "");
180 }
181 };
182 if (textarea) {
183 textarea.addEventListener("input", handleTextareaInput);
184 }
185
186 return () => {
187 if (textarea) {
188 textarea.removeEventListener("input", handleTextareaInput);
189 }
190 if (window.wp && window.wp.editor) {
191 try {
192 window.wp.editor.remove(editorId);
193 } catch (e) {
194 // editor already removed; safe to ignore
195 }
196 }
197 initializedRef.current = false;
198 };
199 // Re-run only when wp.editor transitions to ready (it initializes once via
200 // initializedRef). `value` and `placeholder` are read from refs captured at
201 // mount (see initialValueRef above), so they are deliberately excluded.
202 // eslint-disable-next-line react-hooks/exhaustive-deps
203 }, [wpEditorReady]);
204
205 // Re-theme the editor iframe when the admin light/dark mode toggles after
206 // init (the .betterdocs-dark-mode class flips on <body>). Without this the
207 // editor keeps the palette it was initialized with.
208 useEffect(() => {
209 const observer = new MutationObserver(() => applyEditorTheme(editorId));
210 observer.observe(document.body, {
211 attributes: true,
212 attributeFilter: ["class"],
213 });
214 return () => observer.disconnect();
215 }, [editorId, wpEditorReady]);
216
217 useEffect(() => {
218 if (!aiValue) {
219 return;
220 }
221 const tmce = window.tinymce && window.tinymce.get(editorId);
222 if (tmce) {
223 tmce.setContent(aiValue);
224 tmce.save();
225 if (typeof onChangeRef.current === "function") {
226 onChangeRef.current(tmce.getContent());
227 }
228 return;
229 }
230 const textarea = document.getElementById(editorId);
231 if (textarea) {
232 textarea.value = aiValue;
233 if (typeof onChangeRef.current === "function") {
234 onChangeRef.current(aiValue);
235 }
236 }
237 }, [aiValue, editorId]);
238
239 // QA-010: when the parent bumps `resetKey` (e.g. a FAQ row was reopened),
240 // reload the editor with the current saved value so stale/unsaved content from
241 // a previous edit doesn't linger. Skip the initial mount — init already seeds
242 // the content from initialValueRef.
243 const skipFirstResetRef = useRef(true);
244 useEffect(() => {
245 if (skipFirstResetRef.current) {
246 skipFirstResetRef.current = false;
247 return;
248 }
249 const tmce = window.tinymce && window.tinymce.get(editorId);
250 if (tmce) {
251 tmce.setContent(valueRef.current || "");
252 tmce.save();
253 } else {
254 const textarea = document.getElementById(editorId);
255 if (textarea) {
256 textarea.value = valueRef.current || "";
257 }
258 }
259 // eslint-disable-next-line react-hooks/exhaustive-deps
260 }, [resetKey]);
261
262 return (
263 <div className="bd-faq-editor bd-faq-editor-wp">
264 <textarea
265 id={editorId}
266 className="bd-faq-editor-textarea wp-editor-area"
267 defaultValue={value || ""}
268 placeholder={placeholder}
269 rows={8}
270 />
271 </div>
272 );
273 };
274
275 export default TextEditor;
276