| 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 |
|