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 / public / betterdocs.js

betterdocs.js in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.5, at react-src/public/betterdocs.js

2,643 lines 101.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * macOS ships these alongside its real voices, and `getVoices()` returns them in
3 * the same list with the same `lang`. They sing, whisper, or run the text through
4 * a robot filter — none of them can read an article, and picking one is the single
5 * worst thing this feature can do. "Albert" is the one that bites: it sorts near
6 * the top of an `en-US` list and sounds like a cartoon.
7 */
8 const BD_NOVELTY_VOICES = /^(albert|bad news|bahh|bells|boing|bubbles|cellos|deranged|good news|jester|organ|pipe organ|superstar|trinoids|whisper|wobble|zarvox)\b/i;
9
10 /**
11 * The older compact macOS voices. Intelligible, unlike the novelty set, but flat
12 * and buzzy next to anything modern — so they are pushed down the ranking rather
13 * than excluded, and still get used on a machine that has nothing else.
14 */
15 const BD_LEGACY_VOICES = /^(agnes|bruce|fred|junior|kathy|princess|ralph|vicki|victoria)\b/i;
16
17 /**
18 * Voices that are known to be both decent and *local*, by platform.
19 *
20 * The local part is the point. A remote voice sounds better when it works, but
21 * when it does not it is silent rather than loud about it — the fetch fails, the
22 * engine fires `end`, and the reader watches a progress bar advance in silence.
23 * There is no API that reports whether audio is actually coming out, so the only
24 * defence is to prefer voices that cannot fail that way.
25 */
26 const BD_GOOD_VOICES = /^(samantha|alex|ava|allison|susan|tom|karen|daniel|serena|moira|fiona|tessa|veena|rishi)\b|google|microsoft/i;
27
28 /**
29 * Listen — one instance of the pill that morphs into an audio player.
30 *
31 * The voice is the browser's: `window.speechSynthesis`. There is no audio file,
32 * no server round trip and no API key, which is what makes this shippable in
33 * Free — and which dictates almost everything below, because the Web Speech API
34 * gives you "start talking" and "stop talking" and nothing else:
35 *
36 * - **No duration.** Nothing can be measured before playback, so the total is
37 * estimated from the doc's word count (rendered into `data-bd-total` by
38 * Core\Listen) and rescaled whenever the rate changes.
39 * - **No seeking.** The only way to move is to cancel and start speaking from
40 * somewhere else, so the text is split into chunks up front and a seek
41 * restarts the chunk containing the target offset.
42 * - **No rate change mid-utterance.** `SpeechSynthesisUtterance.rate` is fixed
43 * once `speak()` has been called, so the speed control also restarts the
44 * current chunk.
45 * - **Long utterances are unreliable.** Chrome stalls or truncates one that runs
46 * much past ~15 seconds. Chunking fixes that too, which is the main reason the
47 * text is chunked rather than spoken in one go.
48 *
49 * Position is tracked in seconds and corrected from `boundary` events wherever
50 * the browser fires them (Chrome, Firefox and Safari 14+ all do, per word). The
51 * 250ms timer is not a fallback for the display — it is what keeps the bar moving
52 * smoothly *between* those events, and what keeps it moving at all on an engine
53 * that fires none.
54 */
55 class BetterDocsListen {
56 /**
57 * @param {HTMLElement} el The `.betterdocs-listen` wrapper.
58 * @param {Object} config `betterdocsConfig.listen`.
59 */
60 constructor(el, config) {
61 this.el = el;
62 this.config = config || {};
63 this.i18n = this.config.i18n || {};
64
65 this.trigger = el.querySelector(".betterdocs-listen-trigger");
66 this.player = el.querySelector(".betterdocs-listen-player");
67 this.playButton = el.querySelector(".betterdocs-listen-play");
68 this.seek = el.querySelector(".betterdocs-listen-seek");
69 this.fill = el.querySelector(".betterdocs-listen-fill");
70 this.thumb = el.querySelector(".betterdocs-listen-thumb");
71 this.elapsedEl = el.querySelector(".betterdocs-listen-elapsed");
72 this.totalEl = el.querySelector(".betterdocs-listen-total");
73 this.speedButton = el.querySelector(".betterdocs-listen-speed");
74 this.status = el.querySelector(".betterdocs-listen-status");
75
76 const rates = Array.isArray(this.config.rates) && this.config.rates.length
77 ? this.config.rates
78 : [1, 1.25, 1.5, 2, 0.75];
79 this.rates = rates.map(Number).filter((rate) => rate > 0);
80 this.rateIndex = 0;
81
82 // The estimate Core\Listen rendered, at rate 1. `duration` is this divided
83 // by the current rate; `base` is kept so the division is never applied twice.
84 this.base = parseFloat(el.getAttribute("data-bd-total")) || 0;
85 this.duration = this.base;
86 this.position = 0;
87
88 this.text = null;
89 this.pending = null;
90 this.chunks = [];
91 this.offsets = [];
92 this.totalChars = 0;
93 this.index = 0;
94
95 this.active = false;
96 this.playing = false;
97 // Bumped on every cancel. Utterance callbacks carry the generation they were
98 // created in and return early if it has moved on — `speechSynthesis.cancel()`
99 // fires `end` and `error` on whatever was speaking, and without this a
100 // cancelled chunk's `end` handler would queue the next one over the top of
101 // the chunk the seek had just started.
102 this.generation = 0;
103 // Set when pause() turned out not to work (some Android builds), so resume()
104 // knows to re-speak rather than call an API that will do nothing.
105 this.pauseUnsupported = false;
106 // Whether THIS pill is paused. Deliberately not read off
107 // `speechSynthesis.paused`: there is one speech engine per browser, shared by
108 // every pill on the page, so the global flag answers "is anything paused"
109 // rather than "is this paused" — and it stays set after a cancel (see
110 // cancel()). Reading it per-instance made a second pill report itself as
111 // playing while resuming an empty queue, in silence.
112 this.paused = false;
113
114 // Resolved once per pill rather than per utterance: the pick scans every
115 // installed voice, and a long doc is a few hundred utterances. Null until
116 // resolved, and only ever memoized once it is truthy — `getVoices()` is
117 // empty until Chrome has loaded the list, and a null cached then would
118 // stick for the life of the page.
119 this.voiceResolved = null;
120 // Set when a chosen voice failed to speak, so the retry uses the engine's
121 // own default instead of picking the same broken one again.
122 this.voiceBlocked = false;
123 // Fires if the engine accepts an utterance and then produces nothing at
124 // all — see enqueue(). Cleared by the first sign of life.
125 this.watchdog = null;
126 // The utterance currently queued. Kept alive deliberately — see enqueue().
127 this.utterance = null;
128 // The fast-finish check below is worth making once per playback, on the
129 // first chunk; a short trailing chunk legitimately ends quickly.
130 this.silenceChecked = false;
131
132 this.onTick = this.onTick.bind(this);
133 this.reserveClockWidth();
134 this.render();
135 }
136
137 get synth() {
138 return window.speechSynthesis;
139 }
140
141 // ── Text ────────────────────────────────────────────────────────────────
142
143 /**
144 * The words to speak, resolved once and kept.
145 *
146 * The page the reader is already looking at comes first: it needs no request,
147 * it cannot disagree with what is on screen, and it works with the Markdown
148 * endpoint switched off. The `.md` address is the fallback for a placement
149 * with no doc body around it — a shortcode in a sidebar, say.
150 *
151 * @return {Promise<string>}
152 */
153 resolveText() {
154 if (this.text) {
155 return Promise.resolve(this.text);
156 }
157 if (this.pending) {
158 return this.pending;
159 }
160
161 // Only when this pill's doc is the page being viewed (Core\Listen decides,
162 // see `data-bd-read-page`). A `[betterdocs_listen post_id=…]` placed on
163 // any other page would otherwise read that page's content aloud instead.
164 const fromPage = this.el.getAttribute("data-bd-read-page") === "1"
165 ? this.textFromPage()
166 : "";
167 if (fromPage) {
168 this.text = fromPage;
169 return Promise.resolve(fromPage);
170 }
171
172 const url = this.el.getAttribute("data-bd-listen-url");
173 if (!url) {
174 return Promise.reject(new Error("No text to read"));
175 }
176
177 this.pending = fetch(url, {
178 credentials: "same-origin",
179 headers: { Accept: "text/markdown" },
180 })
181 .then((res) => {
182 const type = res.headers.get("content-type") || "";
183 // A 403 refusal or a themed HTML fallback must never be read aloud.
184 if (!res.ok || type.indexOf("text/markdown") !== 0) {
185 throw new Error(`Unexpected response: ${res.status} ${type}`);
186 }
187 return res.text();
188 })
189 .then((markdown) => {
190 this.text = BetterDocsListen.fromMarkdown(markdown);
191 this.pending = null;
192
193 if (!this.text) {
194 throw new Error("Nothing to read");
195 }
196 return this.text;
197 })
198 .catch((error) => {
199 this.pending = null;
200 throw error;
201 });
202
203 return this.pending;
204 }
205
206 /**
207 * Spoken text lifted out of the rendered doc body.
208 *
209 * @return {string} "" when no configured container is on this page.
210 */
211 textFromPage() {
212 const selectors = Array.isArray(this.config.read) ? this.config.read : [];
213
214 for (let i = 0; i < selectors.length; i++) {
215 const root = document.querySelector(selectors[i]);
216 if (!root) {
217 continue;
218 }
219
220 const text = this.extract(root);
221 // A container that yielded almost nothing is the wrong container — an
222 // empty `.entry-content` wrapper around the real one, most often — so
223 // fall through to the next selector rather than reading two words.
224 if (text.length > 120) {
225 return text;
226 }
227 }
228
229 return "";
230 }
231
232 /**
233 * @param {HTMLElement} root
234 * @return {string}
235 */
236 extract(root) {
237 const clone = root.cloneNode(true);
238 const skip = this.config.skip;
239
240 if (skip) {
241 // Code above all: a synthetic voice reading a shell snippet one
242 // character at a time is unusable, and skipping the block is what a
243 // person reading aloud would do too.
244 clone.querySelectorAll(skip).forEach((node) => node.remove());
245 }
246
247 // Block boundaries become full stops. Without them the voice runs a heading
248 // straight into the paragraph under it with no pause at all, which is the
249 // single biggest thing that makes machine reading sound wrong.
250 clone
251 .querySelectorAll("h1,h2,h3,h4,h5,h6,p,li,blockquote,tr,th,td,dt,dd,summary,figcaption,br,div")
252 .forEach((node) => {
253 node.insertAdjacentText("afterend", ". ");
254 });
255
256 // textContent, not innerText: the clone is detached, so innerText has no
257 // layout to consult and degrades to textContent anyway — with a per-browser
258 // difference in whether it bothers. The separators above do the job
259 // innerText would have done.
260 return BetterDocsListen.tidy(clone.textContent || "");
261 }
262
263 /**
264 * Markdown reduced to speakable prose.
265 *
266 * Not a parser and not trying to be: every rule here removes syntax that would
267 * otherwise be pronounced. The `copy` profile this fetches has no YAML front
268 * matter, but it does carry a `Source:` line, and a voice reading a URL aloud
269 * character by character is the worst thing this feature could do.
270 *
271 * @param {string} markdown
272 * @return {string}
273 */
274 static fromMarkdown(markdown) {
275 return BetterDocsListen.tidy(
276 String(markdown || "")
277 // YAML front matter, in case a filter put the `endpoint` profile here.
278 .replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "")
279 // The `copy` profile puts a one-line summary under the title as a
280 // blockquote. It is the opening of the doc again, flattened and cut
281 // off with an ellipsis — table pipes and `##` included — so reading
282 // it means hearing the first paragraph twice, the second time garbled.
283 .replace(/^(#[^\n]*\r?\n(?:[ \t]*\r?\n)*)>[^\n]*\r?\n/, "$1")
284 .replace(/^Source:\s*\S+\s*$/gm, "")
285 .replace(/```[\s\S]*?```/g, " ")
286 .replace(/~~~[\s\S]*?~~~/g, " ")
287 .replace(/`([^`]*)`/g, "$1")
288 .replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
289 .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
290 .replace(/^[ \t]{0,3}>[ \t]?/gm, "")
291 .replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, "")
292 .replace(/^[ \t]{0,3}([-*_][ \t]*){3,}$/gm, " ")
293 .replace(/^[ \t]{0,3}[-*+][ \t]+/gm, "")
294 .replace(/^[ \t]{0,3}\d+\.[ \t]+/gm, "")
295 .replace(/(\*\*|__)([\s\S]*?)\1/g, "$2")
296 .replace(/(\*|_)([^*_\n]+?)\1/g, "$2")
297 // Table rows. A voice reading pipes and dashes is noise, and a table
298 // read cell-by-cell without its headers is worse than silence.
299 .replace(/^[ \t]*\|.*$/gm, " ")
300 .replace(/<[^>]+>/g, " ")
301 // A blank line is a paragraph break, which should sound like one.
302 .replace(/\r?\n[ \t]*\r?\n/g, ". ")
303 );
304 }
305
306 /**
307 * Collapse whitespace and the punctuation pile-ups the separators above leave
308 * behind, so a heading that already ended in "?" is not followed by a stop.
309 *
310 * @param {string} text
311 * @return {string}
312 */
313 static tidy(text) {
314 return String(text || "")
315 .replace(/\s+/g, " ")
316 .replace(/\s+([.,!?;:…])/g, "$1")
317 .replace(/([.!?…])(?:\s*\.)+/g, "$1")
318 .replace(/(\.\s*){2,}/g, ". ")
319 // The separators are appended *after* each block, so a container whose
320 // first child is one leaves a stop with nothing in front of it.
321 .replace(/^[\s.]+/, "")
322 .trim();
323 }
324
325 /**
326 * Split the text into utterance-sized pieces, on sentence boundaries where
327 * there are any, and record where each one starts.
328 *
329 * The offsets are what make seeking possible: a fraction of the bar maps to a
330 * character offset, which maps to the chunk that contains it.
331 *
332 * @param {string} text
333 */
334 prepare(text) {
335 // A capturing match rather than a lookbehind split: lookbehind only reached
336 // Safari in 16.4, and this has to work further back than that.
337 const sentences = text.match(/[^.!?…]+[.!?…]*\s*/g) || [text];
338 const max = 200;
339 const chunks = [];
340 let current = "";
341
342 const flush = () => {
343 const trimmed = current.trim();
344 if (trimmed) {
345 chunks.push(trimmed);
346 }
347 current = "";
348 };
349
350 sentences.forEach((sentence) => {
351 // One sentence longer than a whole chunk — a list rendered as prose, or
352 // text with no punctuation at all. Break it at spaces so a word is never
353 // cut in half, which the voice would pronounce as two nonsense words.
354 if (sentence.length > max) {
355 flush();
356 let rest = sentence;
357 while (rest.length > max) {
358 let cut = rest.lastIndexOf(" ", max);
359 if (cut <= 0) {
360 cut = max;
361 }
362 chunks.push(rest.slice(0, cut).trim());
363 rest = rest.slice(cut);
364 }
365 current = rest;
366 return;
367 }
368
369 if (current.length + sentence.length > max) {
370 flush();
371 }
372 current += sentence;
373 });
374 flush();
375
376 this.chunks = chunks.filter((chunk) => chunk.length > 0);
377 this.offsets = [];
378 this.totalChars = 0;
379
380 this.chunks.forEach((chunk) => {
381 this.offsets.push(this.totalChars);
382 // +1 for the space the split dropped, so an offset never drifts behind
383 // the real position as the chunk count grows.
384 this.totalChars += chunk.length + 1;
385 });
386
387 // The word count Core\Listen rendered came from `post_content`, which on a
388 // doc full of code blocks or shortcodes can be a long way from what is
389 // actually being spoken. Once the real text is in hand, prefer it.
390 const words = this.chunks.join(" ").split(/\s+/).filter(Boolean).length;
391 const wpm = parseFloat(this.el.getAttribute("data-bd-wpm")) || 180;
392
393 if (words > 0 && wpm > 0) {
394 this.base = (words / wpm) * 60;
395 }
396
397 this.applyRate();
398 }
399
400 // ── Playback ────────────────────────────────────────────────────────────
401
402 /**
403 * Open the player and start speaking. The pill's own click.
404 */
405 activate() {
406 this.open();
407
408 if (this.chunks.length) {
409 this.play();
410 return;
411 }
412
413 this.el.classList.add("is-loading");
414 this.announce(this.i18n.preparing);
415
416 this.resolveText().then(
417 (text) => {
418 this.el.classList.remove("is-loading");
419
420 // The reader can close the player while the text is still being
421 // gathered — Escape, or the close button. Without this the article
422 // started being read aloud into a pill that had already collapsed,
423 // with no visible way to stop it.
424 if (!this.active) {
425 return;
426 }
427
428 this.prepare(text);
429
430 if (!this.chunks.length) {
431 this.fail();
432 return;
433 }
434 this.play();
435 },
436 () => {
437 this.el.classList.remove("is-loading");
438
439 if (!this.active) {
440 return;
441 }
442 this.fail();
443 },
444 );
445 }
446
447 /**
448 * Pre-resolve the text on hover or focus, so the common case — the reader
449 * moves the pointer to the pill and then clicks it — has nothing to wait for.
450 * Failures are swallowed: this is speculative, and activate() will report the
451 * same failure properly if the reader goes on to click.
452 */
453 prefetch() {
454 this.resolveText().catch(() => {});
455 }
456
457 play() {
458 if (!this.chunks.length) {
459 return;
460 }
461
462 // Every pill on the page shares one `speechSynthesis`, so starting here has
463 // to stop everywhere else — otherwise two players run at once, both showing
464 // progress, one of them silent.
465 BetterDocsListen.stopOthers(this);
466
467 if (this.paused && !this.pauseUnsupported) {
468 this.synth.resume();
469 this.paused = false;
470 this.setPlaying(true);
471 return;
472 }
473
474 this.speakFrom(this.index);
475 }
476
477 pause() {
478 if (!this.playing) {
479 return;
480 }
481
482 this.synth.pause();
483 this.paused = true;
484 this.setPlaying(false);
485 this.announce(this.i18n.paused);
486
487 // Some Android builds accept pause() and carry on talking. Verified on the
488 // next frame rather than immediately: `paused` is not guaranteed to have
489 // flipped synchronously.
490 window.setTimeout(() => {
491 if (this.playing || this.synth.paused) {
492 return;
493 }
494 this.pauseUnsupported = true;
495 this.cancel();
496 }, 60);
497 }
498
499 /**
500 * Was this pill mid-playback — either speaking or paused part-way through?
501 *
502 * The question `speechSynthesis.speaking` cannot answer, because it is true
503 * whenever ANY pill on the page is speaking.
504 *
505 * @return {boolean}
506 */
507 midPlayback() {
508 return this.playing || this.paused;
509 }
510
511 toggle() {
512 if (this.playing) {
513 this.pause();
514 } else {
515 this.play();
516 }
517 }
518
519 /**
520 * Speak from a chunk index, cancelling whatever was in flight.
521 *
522 * @param {number} from
523 */
524 speakFrom(from) {
525 this.cancel();
526
527 this.index = Math.max(0, Math.min(from, this.chunks.length - 1));
528 this.silenceChecked = false;
529 // The bar belongs to the chunk about to be spoken, always. finish() leaves
530 // the position at the end and the index at 0, so without this a replay ran
531 // from the beginning with the bar pinned at 100% — corrected by the first
532 // `boundary` event on engines that fire them, and never on the ones the
533 // 250ms timer exists for.
534 this.position = this.totalChars > 0
535 ? (this.offsets[this.index] / this.totalChars) * this.duration
536 : 0;
537 this.render();
538
539 this.setPlaying(true);
540 this.announce(this.i18n.playing);
541
542 // Deferred by a tick rather than called straight through. Chrome drops an
543 // utterance queued in the same task as the `cancel()` that preceded it —
544 // the speak is accepted, `speaking` goes true, and nothing is ever spoken.
545 // Every path into here cancels first, so every path needs the gap.
546 //
547 // A generation check inside enqueue() makes a cancel arriving during the
548 // gap a no-op, and sticky user activation is unaffected by the delay.
549 const generation = this.generation;
550 window.setTimeout(() => this.enqueue(generation), 60);
551 }
552
553 /**
554 * @param {number} generation
555 */
556 enqueue(generation) {
557 if (generation !== this.generation) {
558 return;
559 }
560
561 if (this.index >= this.chunks.length) {
562 this.finish();
563 return;
564 }
565
566 const utterance = new SpeechSynthesisUtterance(this.chunks[this.index]);
567 const chunk = this.index;
568
569 utterance.rate = this.rate();
570 utterance.lang = BetterDocsListen.lang();
571
572 const voice = this.resolveVoice();
573 if (voice) {
574 utterance.voice = voice;
575 }
576
577 // `speechSynthesis` has no way to report "I am producing audio", and a
578 // wedged engine is indistinguishable from a working one: `speaking` reads
579 // true, the timer keeps the progress bar moving, and the reader hears
580 // nothing. These two checks are the only signals available.
581 const startedAt = Date.now();
582 let alive = false;
583
584 const sawLife = () => {
585 alive = true;
586 this.clearWatchdog();
587 };
588
589 utterance.onstart = () => {
590 if (generation === this.generation) {
591 sawLife();
592 }
593 };
594
595 utterance.onboundary = (event) => {
596 if (generation !== this.generation) {
597 return;
598 }
599 sawLife();
600 // The authoritative position. `charIndex` is an offset into this chunk,
601 // so it has to be added to where the chunk starts.
602 this.seekTo((this.offsets[chunk] + (event.charIndex || 0)) / this.totalChars, false);
603 };
604
605 utterance.onend = () => {
606 if (generation !== this.generation) {
607 return;
608 }
609
610 this.clearWatchdog();
611
612 // Two hundred characters cannot be spoken in a quarter of a second at
613 // any rate this offers. An `end` that fast means the engine took the
614 // utterance and discarded it — which is what a remote voice does when
615 // its fetch fails without raising an error. Only the first chunk of a
616 // playback is judged: a short trailing chunk can legitimately be quick.
617 if (!this.silenceChecked) {
618 this.silenceChecked = true;
619
620 if (Date.now() - startedAt < 250) {
621 this.recoverFromSilence(chunk);
622 return;
623 }
624 }
625
626 this.index = chunk + 1;
627 this.enqueue(generation);
628 };
629
630 utterance.onerror = (event) => {
631 // `cancel()` reports itself as an error on whatever was speaking. That
632 // is not a failure, and the generation check above usually catches it —
633 // but not when cancel() runs before the generation is bumped.
634 const error = event ? event.error : "";
635 const benign = error === "interrupted" || error === "canceled";
636
637 if (generation !== this.generation || benign) {
638 return;
639 }
640
641 // The good voices are the remote ones, and a remote voice is a fetch
642 // that can fail. Rather than killing the article over it, drop back to
643 // whatever the engine picks for itself and replay the same chunk. Once
644 // only — `voiceBlocked` makes a second failure a real one.
645 if (!this.voiceBlocked && (error === "network" || error === "synthesis-failed")) {
646 this.voiceBlocked = true;
647 this.speakFrom(chunk);
648 return;
649 }
650
651 this.fail();
652 };
653
654 // Held on the instance, and this is load-bearing rather than tidiness.
655 // `speak()` does not keep a strong reference an engine can rely on: once
656 // the only reference is Chrome's internal queue, the utterance can be
657 // garbage-collected while it is still queued — at which point speech stops
658 // dead and NO event ever fires, not `start`, not `end`, not `error`. It is
659 // non-deterministic, which is why it presents as "it worked yesterday".
660 this.utterance = utterance;
661
662 this.synth.speak(utterance);
663 this.startTicking();
664
665 // …and the other direction: an engine that never reports anything at all.
666 // Four seconds is comfortably longer than the gap to the first `start` on a
667 // slow remote voice, and far shorter than a reader's patience with a
668 // progress bar that is lying to them.
669 const arm = () => {
670 this.clearWatchdog();
671 this.watchdog = window.setTimeout(() => {
672 if (generation !== this.generation || alive) {
673 return;
674 }
675
676 // Chrome DEFERS speech in a hidden tab: the utterance sits queued
677 // with `speaking` true and fires nothing at all until the tab is
678 // shown again. That is not a wedged engine and must not be treated
679 // as one — a reader who switches tabs mid-article would come back
680 // to a pill that had given up. Wait instead of accusing.
681 if (document.hidden) {
682 arm();
683 return;
684 }
685
686 this.recoverFromSilence(chunk);
687 }, 4000);
688 };
689
690 arm();
691 }
692
693 clearWatchdog() {
694 if (this.watchdog) {
695 window.clearTimeout(this.watchdog);
696 this.watchdog = null;
697 }
698 }
699
700 /**
701 * The engine accepted an utterance and said nothing.
702 *
703 * One retry on the engine's own default voice — the failure is nearly always
704 * the chosen voice rather than the engine — and then an honest failure. Better
705 * a pill that goes red and says it could not read the article than a player
706 * that runs silently to the end.
707 *
708 * @param {number} chunk Where to resume from.
709 */
710 recoverFromSilence(chunk) {
711 /* eslint-disable-next-line no-console */
712 console.warn(
713 "BetterDocs Listen: the speech engine accepted the text but produced no audio" +
714 (this.voiceBlocked
715 ? " on the browser's default voice either. Giving up."
716 : ` using "${this.voiceResolved ? this.voiceResolved.name : "the default voice"}". Retrying on the browser's default.`) +
717 " If this persists, quit and reopen the browser — Chrome's speech engine can wedge" +
718 " browser-wide and a reload does not clear it.",
719 );
720
721 if (!this.voiceBlocked) {
722 this.voiceBlocked = true;
723 this.voiceResolved = null;
724 this.speakFrom(chunk);
725 return;
726 }
727
728 this.fail();
729 }
730
731 /**
732 * Playback finished of its own accord.
733 */
734 finish() {
735 this.stopTicking();
736 this.setPlaying(false);
737 this.position = this.duration;
738 this.index = 0;
739 this.render();
740 this.announce(this.i18n.ended);
741 }
742
743 /**
744 * Stop speaking, keep the player open and keep the position.
745 */
746 cancel() {
747 this.generation += 1;
748 this.stopTicking();
749 this.clearWatchdog();
750 this.paused = false;
751
752 try {
753 this.synth.cancel();
754
755 // `cancel()` empties the queue but leaves `speechSynthesis.paused` set,
756 // and a paused engine silently swallows every later `speak()`. So a
757 // pause followed by a close — or by another pill starting — left the
758 // next playback showing a ticking progress bar and making no sound.
759 //
760 // Guarded rather than called unconditionally: `resume()` on an engine
761 // that was never paused is not a documented no-op everywhere, and this
762 // runs on every single playback. Only clear the flag when it is set.
763 if (this.synth.paused) {
764 this.synth.resume();
765 }
766 } catch (e) {
767 /* A cancel on a synth that never started throws in older Safari. */
768 }
769 }
770
771 /**
772 * Stop speaking and forget where we were.
773 */
774 stop() {
775 this.cancel();
776 this.setPlaying(false);
777 this.index = 0;
778 this.position = 0;
779 this.paused = false;
780 this.pauseUnsupported = false;
781 // A network voice that failed once may well work on the next attempt, so
782 // the downgrade lasts for one playback rather than for the page.
783 this.voiceBlocked = false;
784 this.render();
785 }
786
787 fail() {
788 this.stop();
789 this.el.classList.add("is-failed");
790 this.announce(this.i18n.failed);
791 this.collapse();
792 }
793
794 // ── Position ────────────────────────────────────────────────────────────
795
796 /**
797 * @param {number} fraction 0-1.
798 * @param {boolean} restart Whether to start speaking from there. False for a
799 * boundary event, which is only reporting where the
800 * voice already is.
801 */
802 seekTo(fraction, restart) {
803 const clamped = Math.max(0, Math.min(1, fraction || 0));
804
805 this.position = clamped * this.duration;
806 this.render();
807
808 if (!restart || !this.chunks.length) {
809 return;
810 }
811
812 const target = clamped * this.totalChars;
813 let index = 0;
814
815 // Linear rather than a binary search: a long doc is a few hundred chunks,
816 // and this runs on a click.
817 for (let i = 0; i < this.offsets.length; i++) {
818 if (this.offsets[i] <= target) {
819 index = i;
820 } else {
821 break;
822 }
823 }
824
825 // Snap the readout to the chunk that will actually be spoken. The thumb
826 // moves back by up to a sentence from where it was dropped, which is honest
827 // — the voice cannot start mid-word — and is less confusing than a bar that
828 // claims a position the audio does not match.
829 this.position = (this.offsets[index] / this.totalChars) * this.duration;
830 this.render();
831
832 // `midPlayback()`, not `synth.speaking`: the engine is shared, so that would
833 // start an idle pill speaking merely because a different one was.
834 if (this.midPlayback()) {
835 this.speakFrom(index);
836 } else {
837 this.index = index;
838 }
839 }
840
841 startTicking() {
842 this.stopTicking();
843 // 250ms: four updates a second is smooth enough for a 4px bar, and cheap
844 // enough to leave running for the length of an article.
845 this.timer = window.setInterval(this.onTick, 250);
846 }
847
848 stopTicking() {
849 if (this.timer) {
850 window.clearInterval(this.timer);
851 this.timer = null;
852 }
853 }
854
855 onTick() {
856 if (!this.playing) {
857 return;
858 }
859
860 // Wall clock, not rate-scaled: `duration` already accounts for the rate, so
861 // a second of real time is a second of the bar whatever speed it is set to.
862 this.position = Math.min(this.duration, this.position + 0.25);
863 this.render();
864 }
865
866 // ── Rate ────────────────────────────────────────────────────────────────
867
868 rate() {
869 return this.rates[this.rateIndex] || 1;
870 }
871
872 /**
873 * Rescale the estimated duration for the current rate, keeping the position's
874 * *fraction* rather than its seconds — half way through at 1× is still half way
875 * through at 2×.
876 */
877 applyRate() {
878 const fraction = this.duration > 0 ? this.position / this.duration : 0;
879
880 this.duration = this.base / this.rate();
881 this.position = fraction * this.duration;
882 this.reserveClockWidth();
883 this.render();
884 }
885
886 cycleRate() {
887 this.rateIndex = (this.rateIndex + 1) % this.rates.length;
888
889 if (this.speedButton) {
890 const label = BetterDocsListen.rateLabel(this.rate());
891
892 this.speedButton.textContent = label;
893 this.speedButton.setAttribute("data-bd-rate", String(this.rate()));
894 // The accessible name starts with what is on screen (WCAG 2.5.3), so a
895 // voice-control user can say "click 1.5×".
896 this.speedButton.setAttribute("aria-label", `${label} ${this.i18n.speed || "Playback speed"}`);
897 }
898
899 this.applyRate();
900 // "1×" is two characters, "1.25×" is five. The pill is held at an explicit
901 // pixel width measured when it opened, and it clips its contents — so
902 // without this the extra characters push the close button under the clip
903 // and it simply disappears.
904 this.resize();
905
906 // `SpeechSynthesisUtterance.rate` is fixed once speak() has been called, so
907 // the only way to apply a new one is to start the current chunk again.
908 if (this.playing) {
909 this.speakFrom(this.index);
910 }
911 }
912
913 /**
914 * "1×", "1.25×". Matches views/templates/parts/listen.php, which formats the
915 * rendered label the same way and for the same reason: a multiplier is a
916 * technical value that reads identically in every locale, so it must not go
917 * through a comma-decimal number formatter.
918 *
919 * @param {number} rate
920 * @return {string}
921 */
922 static rateLabel(rate) {
923 return `${String(Number(rate).toFixed(2)).replace(/\.?0+$/, "")}×`;
924 }
925
926 // ── Morph ───────────────────────────────────────────────────────────────
927
928 open() {
929 this.el.classList.remove("is-failed");
930
931 // An editor placeholder renders the pill without a player (see
932 // views/widgets/reading-time.php). The delegated handlers bind to
933 // `button.…` and a placeholder's parts are spans, so this should be
934 // unreachable — but a null here would throw out of a click handler, and a
935 // pill that cannot open is a better outcome than a broken preview.
936 if (!this.player || this.active) {
937 return;
938 }
939
940 // Measured, not hard-coded: the pill is as wide as its label in whatever
941 // language the site runs in, and the player is as wide as its own contents
942 // at whatever font size the Customizer set.
943 this.collapsedWidth = this.el.offsetWidth;
944 this.el.style.width = `${this.collapsedWidth}px`;
945
946 // Reflow, so the browser has the start width committed before the class
947 // lands. Without it the two are batched into one style resolution and the
948 // pill jumps to its target instead of growing into it.
949 void this.el.offsetWidth;
950
951 this.el.classList.add("is-active");
952 this.el.style.width = `${Math.ceil(this.player.getBoundingClientRect().width)}px`;
953
954 // Read before `inert` lands: making the focused trigger inert drops focus
955 // to <body>, and a keyboard user would have to tab back in from the top of
956 // the page to reach Pause.
957 const hadFocus = document.activeElement === this.trigger;
958
959 this.player.removeAttribute("inert");
960 this.trigger.setAttribute("inert", "");
961 this.active = true;
962
963 if (hadFocus && this.playButton) {
964 this.playButton.focus({ preventScroll: true });
965 }
966 }
967
968 /**
969 * Re-fit the open pill to whatever its player now measures.
970 *
971 * The speed chip is why this exists: "1×" is two characters and "1.25×" is
972 * five, and the pill is held at an explicit pixel width measured when it
973 * opened — so without this the extra characters go under the `overflow:
974 * hidden` clip and the close button simply disappears.
975 *
976 * Measuring is the right mechanism here rather than reserving the widest label
977 * up front: the rates are filterable, so the label set is not fixed, and a
978 * reservation in `ch` cannot account for the chip's padding or for `×` being
979 * narrower than a digit — it under-reserves, which is the failure that looks
980 * like it works. The clock IS reserved (see reserveClockWidth) because it
981 * changes width *during* playback, where re-fitting would animate the pill
982 * mid-sentence.
983 */
984 resize() {
985 if (!this.active || !this.player) {
986 return;
987 }
988
989 this.el.style.width = `${Math.ceil(this.player.getBoundingClientRect().width)}px`;
990 }
991
992 /**
993 * Hold the elapsed readout at the width its final value will need.
994 *
995 * The clock grows a character when playback passes ten minutes — "9:59" to
996 * "10:00" — and on a doc that long the close button would vanish under the
997 * clip exactly once, partway through, for no reason the reader could see.
998 *
999 * Reserving the space is better than resizing on the tick: the total is known
1000 * up front and the elapsed can never exceed it, so one `min-width` settles it
1001 * with no layout work while the voice is running. `ch` is the width of a
1002 * digit, and the readout is already `tabular-nums`, so every digit is exactly
1003 * that — the colon is narrower, which over-reserves by a hair and never clips.
1004 */
1005 reserveClockWidth() {
1006 if (!this.elapsedEl) {
1007 return;
1008 }
1009
1010 this.elapsedEl.style.minWidth = `${BetterDocsListen.clock(this.duration).length}ch`;
1011 }
1012
1013 /**
1014 * Back to a pill.
1015 *
1016 * @param {boolean} focusTrigger Hand focus back — correct after Escape or the
1017 * close button, wrong after a failure nobody
1018 * asked for.
1019 */
1020 collapse(focusTrigger) {
1021 if (!this.active) {
1022 return;
1023 }
1024
1025 // Focus inside the player is about to be dropped by `inert` — after a
1026 // failure as much as after Escape — so it goes back to the pill. Focus
1027 // that was anywhere else on the page is left where it is.
1028 const hadFocus = this.player.contains(document.activeElement);
1029
1030 this.active = false;
1031 this.el.classList.remove("is-active", "is-playing", "is-loading");
1032 this.player.setAttribute("inert", "");
1033 this.trigger.removeAttribute("inert");
1034
1035 if (this.collapsedWidth) {
1036 this.el.style.width = `${this.collapsedWidth}px`;
1037 }
1038
1039 // Back to `auto` once the pill has finished shrinking, so a later font-size
1040 // or label change still resizes it. A timer rather than `transitionend`
1041 // alone: under `prefers-reduced-motion` there is no transition and therefore
1042 // no event, and the width would stay pinned at a stale measurement forever.
1043 window.clearTimeout(this.widthReset);
1044 this.widthReset = window.setTimeout(() => {
1045 if (!this.active) {
1046 this.el.style.width = "";
1047 }
1048 }, 500);
1049
1050 if (focusTrigger || hadFocus) {
1051 this.trigger.focus({ preventScroll: true });
1052 }
1053 }
1054
1055 close() {
1056 this.stop();
1057 this.collapse(true);
1058 }
1059
1060 // ── Output ──────────────────────────────────────────────────────────────
1061
1062 setPlaying(playing) {
1063 this.playing = !!playing;
1064 this.el.classList.toggle("is-playing", this.playing);
1065
1066 if (this.playButton) {
1067 this.playButton.setAttribute(
1068 "aria-label",
1069 this.playing ? this.i18n.pause || "Pause" : this.i18n.play || "Play",
1070 );
1071 }
1072
1073 if (this.playing) {
1074 this.startTicking();
1075 } else {
1076 this.stopTicking();
1077 }
1078 }
1079
1080 render() {
1081 const percent = this.duration > 0
1082 ? Math.max(0, Math.min(100, (this.position / this.duration) * 100))
1083 : 0;
1084
1085 if (this.fill) {
1086 this.fill.style.width = `${percent}%`;
1087 }
1088 if (this.thumb) {
1089 // setProperty rather than the camelCase alias: `insetInlineStart` is
1090 // spelled inconsistently across older engines, and the string form is
1091 // what every one of them understands.
1092 this.thumb.style.setProperty("inset-inline-start", `${percent}%`);
1093 }
1094 if (this.seek) {
1095 this.seek.setAttribute("aria-valuenow", String(Math.round(percent)));
1096 this.seek.setAttribute("aria-valuetext", BetterDocsListen.clock(this.position));
1097 }
1098 if (this.elapsedEl) {
1099 this.elapsedEl.textContent = BetterDocsListen.clock(this.position);
1100 }
1101 if (this.totalEl) {
1102 this.totalEl.textContent = BetterDocsListen.clock(this.duration);
1103 }
1104 }
1105
1106 /**
1107 * Write to the live region. Cleared first because most screen readers ignore a
1108 * repeated identical string, so a second pause would go unannounced.
1109 *
1110 * @param {string} message
1111 */
1112 announce(message) {
1113 if (!message || !this.status) {
1114 return;
1115 }
1116
1117 this.status.textContent = "";
1118 window.setTimeout(() => {
1119 this.status.textContent = message;
1120 }, 50);
1121 }
1122
1123 /**
1124 * @param {number} seconds
1125 * @return {string} m:ss
1126 */
1127 static clock(seconds) {
1128 const total = Math.max(0, Math.round(seconds || 0));
1129
1130 return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
1131 }
1132
1133 // ── Statics ─────────────────────────────────────────────────────────────
1134
1135 /**
1136 * The voice this pill speaks with.
1137 *
1138 * @return {SpeechSynthesisVoice|null} Null hands the choice back to the engine.
1139 */
1140 resolveVoice() {
1141 if (this.voiceBlocked) {
1142 return null;
1143 }
1144
1145 if (!this.voiceResolved) {
1146 this.voiceResolved = BetterDocsListen.voice(
1147 BetterDocsListen.lang(),
1148 this.config.voice,
1149 );
1150 }
1151
1152 return this.voiceResolved || null;
1153 }
1154
1155 /**
1156 * The document's language, for voice selection. `speechSynthesis` picks the
1157 * platform default when this is wrong, which on an English-default machine
1158 * means a French doc read in an English accent — so it is worth getting from
1159 * the page rather than from the browser.
1160 *
1161 * @return {string}
1162 */
1163 static lang() {
1164 const lang = document.documentElement.getAttribute("lang");
1165
1166 return lang ? lang.replace("_", "-") : "";
1167 }
1168
1169 /**
1170 * Best voice for a language tag.
1171 *
1172 * `getVoices()` is empty until the engine has loaded them in Chrome, which is
1173 * why nothing here caches a miss: a later call gets the real list.
1174 *
1175 * @param {string} lang
1176 * @return {SpeechSynthesisVoice|null}
1177 */
1178 static voice(lang, preferred) {
1179 const voices = window.speechSynthesis.getVoices() || [];
1180
1181 if (!voices.length) {
1182 return null;
1183 }
1184
1185 const tag = (voice) => String(voice.lang || "").toLowerCase().replace("_", "-");
1186 const wanted = String(lang || "").toLowerCase().replace("_", "-");
1187 const base = wanted.split("-")[0];
1188
1189 // A site can pin one by name through `betterdocs_listen_voice`. Checked
1190 // first and deliberately not language-filtered: somebody who named a voice
1191 // has already made this decision.
1192 if (preferred) {
1193 const needle = String(preferred).toLowerCase();
1194 const pinned = voices.find(
1195 (voice) => String(voice.name || "").toLowerCase().indexOf(needle) !== -1,
1196 );
1197
1198 if (pinned) {
1199 return pinned;
1200 }
1201 }
1202
1203 if (!base) {
1204 return null;
1205 }
1206
1207 const speakable = voices.filter((voice) => !BD_NOVELTY_VOICES.test(voice.name || ""));
1208 // The language match is the one hard filter. Reading English prose in a
1209 // Russian voice is not a degraded experience, it is gibberish — so a miss
1210 // here returns null and lets the engine fall back to its own default.
1211 const pool = (speakable.length ? speakable : voices).filter(
1212 (voice) => tag(voice).split("-")[0] === base,
1213 );
1214
1215 if (!pool.length) {
1216 return null;
1217 }
1218
1219 return pool
1220 .slice()
1221 .sort((a, b) => BetterDocsListen.rank(b, wanted) - BetterDocsListen.rank(a, wanted))[0];
1222 }
1223
1224 /**
1225 * How good is this voice likely to sound?
1226 *
1227 * There is no quality field on `SpeechSynthesisVoice`, so this reads the two
1228 * signals that do exist — the name, and whether the voice is local — against
1229 * what each platform calls its good voices. It is a heuristic, but a stable
1230 * one: these naming conventions are how the vendors themselves distinguish
1231 * their neural voices from their old formant ones.
1232 *
1233 * @param {SpeechSynthesisVoice} voice
1234 * @param {string} wanted Full language tag being matched.
1235 * @return {number}
1236 */
1237 static rank(voice, wanted) {
1238 const name = String(voice.name || "").toLowerCase();
1239 let score = 0;
1240
1241 // One quality tier, by what each vendor calls its own good voices.
1242 if (/natural|neural/.test(name)) {
1243 // Microsoft's neural voices, in Edge and on Windows 11. The best thing
1244 // the Web Speech API can produce.
1245 score = 60;
1246 } else if (/\((enhanced|premium)\)/.test(name)) {
1247 // macOS downloads these on demand. Excellent, and local.
1248 score = 55;
1249 } else if (name.indexOf("siri") !== -1) {
1250 score = 45;
1251 } else if (BD_GOOD_VOICES.test(name)) {
1252 score = 40;
1253 }
1254
1255 // A remote voice is penalised, not rewarded, and this is the one ranking
1256 // decision worth arguing about. Chrome's "Google …" voices genuinely sound
1257 // better than the macOS system voices — but they stream from a Google
1258 // endpoint, and when that call fails the utterance ends immediately with no
1259 // error and no sound. A voice that is merely good and always audible beats
1260 // one that is excellent and sometimes silent.
1261 //
1262 // The tiers above still outrank this, so a remote neural voice is chosen
1263 // where nothing local comes close, which is the situation on Windows.
1264 if (voice.localService === false) {
1265 score -= 25;
1266 }
1267
1268 if (voice.default) {
1269 score += 5;
1270 }
1271
1272 // Region is a tiebreaker, never a reason to take a worse voice: an en-GB
1273 // neural voice beats an en-US formant one, and the accent is the smaller
1274 // problem by a wide margin.
1275 if (String(voice.lang || "").toLowerCase().replace("_", "-") === wanted) {
1276 score += 10;
1277 }
1278
1279 if (BD_LEGACY_VOICES.test(voice.name || "")) {
1280 score -= 30;
1281 }
1282
1283 return score;
1284 }
1285
1286 /**
1287 * Every live instance, so one player can silence the others and so a page
1288 * leaving the viewport can silence them all.
1289 *
1290 * @type {Set<BetterDocsListen>}
1291 */
1292 static get registry() {
1293 if (!BetterDocsListen._registry) {
1294 BetterDocsListen._registry = new Set();
1295 }
1296 return BetterDocsListen._registry;
1297 }
1298
1299 /**
1300 * @param {BetterDocsListen} except
1301 */
1302 static stopOthers(except) {
1303 BetterDocsListen.registry.forEach((instance) => {
1304 if (instance !== except && instance.active) {
1305 instance.stop();
1306 instance.collapse(false);
1307 }
1308 });
1309 }
1310
1311 static stopAll() {
1312 BetterDocsListen.registry.forEach((instance) => instance.stop());
1313 }
1314
1315 /**
1316 * The instance for a wrapper, created on first use.
1317 *
1318 * Lazily, and stored on the element: the pill can arrive after this script ran
1319 * — the Elementor editor re-renders widgets over AJAX — so nothing may assume
1320 * a fixed set of them exists at boot.
1321 *
1322 * @param {HTMLElement} el
1323 * @param {Object} config
1324 * @return {BetterDocsListen}
1325 */
1326 static for(el, config) {
1327 if (!el.betterdocsListen) {
1328 el.betterdocsListen = new BetterDocsListen(el, config);
1329 BetterDocsListen.registry.add(el.betterdocsListen);
1330 }
1331
1332 return el.betterdocsListen;
1333 }
1334 }
1335
1336 class BetterDocs {
1337 constructor(config) {
1338 this.config = config;
1339 this.initialize();
1340 this.init();
1341 }
1342
1343 init() {
1344 // First, and guarded: init() runs eleven methods in sequence and several of
1345 // them dereference single-doc-only nodes without checking, so a throw further
1346 // down would otherwise leave the AI Actions button inert.
1347 try {
1348 this.aiActions();
1349 } catch (e) {
1350 /* eslint-disable-next-line no-console */
1351 console.error("BetterDocs AI Actions failed to initialise", e);
1352 }
1353
1354 // Guarded separately rather than folded into the block above: the two
1355 // features are independent, and a throw in one must not leave the other
1356 // inert.
1357 try {
1358 this.listen();
1359 } catch (e) {
1360 /* eslint-disable-next-line no-console */
1361 console.error("BetterDocs Listen failed to initialise", e);
1362 }
1363
1364 this.categoryListAccordion();
1365 this.feedbackForm();
1366
1367 // Single Docs
1368 this.printToc();
1369 this.stickyTocContainer();
1370 this.tocClose();
1371 this.cloneTocContentToAfter();
1372 this.copyToClipboard();
1373 this.subCategoryExpand();
1374
1375 this.tocSmallCollapisble();
1376 this.sidebarOpen();
1377 this.tocOpen();
1378 this.articleSummary();
1379 }
1380
1381 tocSmallCollapisble() {
1382 $ = jQuery;
1383 var docTocTitle = $(".betterdocs-toc.collapsible-sm .toc-title");
1384 docTocTitle.each(function () {
1385 $(this).click(function (e) {
1386 e.preventDefault();
1387 $(this).children(".angle-icon").toggle();
1388 $(this).next(".toc-list").slideToggle();
1389 });
1390 });
1391 }
1392
1393 initialize() {
1394 var $ = jQuery;
1395
1396 this.body = $("body");
1397
1398 this.catTitleList = $(
1399 ".betterdocs-sidebar-content .betterdocs-category-grid-wrapper .betterdocs-single-category-wrapper .betterdocs-category-header",
1400 );
1401 this.currentActiveCat = $(
1402 ".betterdocs-sidebar-content .betterdocs-category-grid-wrapper .betterdocs-single-category-wrapper.active",
1403 );
1404 this.listSidebarCatTitles = $(
1405 ".betterdocs-sidebar-content .betterdocs-sidebar-list-wrapper .betterdocs-sidebar-list .betterdocs-category-header",
1406 );
1407 this.listSidebarCurrentCat = $(
1408 ".betterdocs-sidebar-content .betterdocs-sidebar-list-wrapper .betterdocs-sidebar-list.active",
1409 );
1410 // single page feedback form selectors
1411 this.formLink = $("a[name=betterdocs-form-modal]");
1412 this.feedbackFormWrap = $("#betterdocs-feedback-form");
1413 this.feedbackFormFields = $(
1414 "#betterdocs-feedback-form input, #betterdocs-feedback-form textarea",
1415 );
1416 }
1417
1418 categoryListAccordion() {
1419 let $parentThis = this;
1420 $parentThis.listSidebarCurrentCat
1421 .find(".betterdocs-body")
1422 .css("display", "block");
1423 $parentThis.listSidebarCurrentCat
1424 .siblings()
1425 .find(".betterdocs-body")
1426 .css("display", "none");
1427 this.listSidebarCatTitles.on("click", function (e) {
1428 e.preventDefault();
1429 let $parentCat = jQuery(e.target).closest(
1430 ".betterdocs-sidebar-list",
1431 );
1432 $parentCat.find(".betterdocs-body").slideToggle();
1433 $parentCat
1434 .toggleClass("active")
1435 .siblings()
1436 .removeClass("active")
1437 .find(".betterdocs-body")
1438 .slideUp();
1439 });
1440 }
1441
1442 // function to add link active class on scroll
1443 onScroll() {
1444 var $ = jQuery;
1445 let getLastElementId;
1446
1447 $(this).find('.betterdocs-content-heading')?.map((index, value) => {
1448 if(( value?.getBoundingClientRect()?.top - 140 ) < 0){
1449 getLastElementId = $(value)?.attr('id');
1450 }
1451 });
1452
1453 if(getLastElementId != undefined){
1454 $(
1455 ".sticky-toc-container .betterdocs-toc .toc-list a, .betterdocs-full-sidebar-right .betterdocs-toc .toc-list a, .wp-block-group.is-position-sticky .betterdocs-toc .toc-list a, .betterdocs-elementor .betterdocs-toc .toc-list a",
1456 ).each(function () {
1457 var currLink = $(this);
1458 if($(currLink).attr('href') == '#'+getLastElementId ){
1459 $(".betterdocs-toc .toc-list a").removeClass("active");
1460 currLink.addClass("active");
1461 }
1462 });
1463 }
1464 }
1465
1466 stickyTocContainer() {
1467 var $ = jQuery;
1468 let $parentThis = this;
1469 var betterdocsSidebar = $("#betterdocs-sidebar");
1470 var betterdocsToc = $(".betterdocs-toc");
1471 if (betterdocsToc.length > 0 && betterdocsSidebar.length) {
1472 // create an instance of TOC
1473 var stickyTocContent = $(".betterdocs-toc").clone();
1474 $(".sticky-toc-container").append(stickyTocContent);
1475 }
1476
1477 $(window).on("scroll", function () {
1478 var tocSidebar = document.querySelector(
1479 ".betterdocs-sidebar-content",
1480 );
1481 var mainTocEligibleForScrolling = ( $('.sticky-toc-container .betterdocs-toc .toc-list li a')?.length > 0 ); // check if toc items exists, based on this return 'true' or 'false'
1482 if (tocSidebar === null) {
1483 return;
1484 }
1485
1486 var stickyToc = $(".sticky-toc-container");
1487 var tocHeight = $(".betterdocs-sidebar-content").outerHeight();
1488
1489 var tocSidebarRect = tocSidebar.getBoundingClientRect();
1490 var tocSidebarTop = Math.abs(tocSidebarRect?.top);
1491 if (tocSidebarRect?.top < 0 && tocHeight <= tocSidebarTop && mainTocEligibleForScrolling) {
1492 stickyToc.addClass("toc-sticky");
1493 } else {
1494 stickyToc.removeClass("toc-sticky");
1495 }
1496 //for elementor single doc template
1497 if (
1498 betterdocsSidebar.closest(".elementor-widget-wrap")?.length > 0
1499 ) {
1500 if (
1501 $(window).scrollTop() >=
1502 betterdocsSidebar
1503 .closest(".elementor-widget-wrap")
1504 .offset()?.top +
1505 betterdocsSidebar
1506 .closest(".elementor-widget-wrap")
1507 .outerHeight() -
1508 window.innerHeight &&
1509 !betterdocsSidebar.hasClass("betterdocs-el-single-sidebar")
1510 ) {
1511 stickyToc?.removeClass("toc-sticky");
1512 }
1513 } else if (
1514 betterdocsSidebar.closest(".wp-block-column")?.length > 0
1515 ) {
1516 //for blocks fse templates single doc
1517 if (
1518 $(window).scrollTop() >=
1519 betterdocsSidebar.closest(".wp-block-column").offset()
1520 ?.top +
1521 betterdocsSidebar
1522 .closest(".wp-block-column")
1523 .outerHeight() -
1524 window.innerHeight &&
1525 !betterdocsSidebar.hasClass("betterdocs-el-single-sidebar")
1526 ) {
1527 stickyToc?.removeClass("toc-sticky");
1528 }
1529 } else if (
1530 //for normal template single doc rendered by betterdocs itself, betterdocs native single doc Sticky TOC template
1531 $(window).scrollTop() >=
1532 betterdocsSidebar.offset()?.top +
1533 betterdocsSidebar.outerHeight() -
1534 window.innerHeight &&
1535 !betterdocsSidebar.hasClass("betterdocs-el-single-sidebar")
1536 ) {
1537 stickyToc?.removeClass("toc-sticky");
1538 }
1539 });
1540
1541 // Add smooth scrolling to links
1542 $(document).on("scroll", $parentThis?.onScroll);
1543 var toc_links = $(".betterdocs-toc .toc-list a");
1544 toc_links.on("click", function (e) {
1545 e.preventDefault();
1546 $(document).off("scroll");
1547 toc_links.each(function () {
1548 $(this).removeClass("active");
1549 });
1550 $(this).addClass("active");
1551 var target = decodeURIComponent(this.hash),
1552 $target = $(target);
1553 var scrollTopOffset = $target.offset()?.top;
1554 $("html, body")
1555 .stop()
1556 .animate(
1557 {
1558 scrollTop:
1559 scrollTopOffset -
1560 betterdocsConfig?.sticky_toc_offset,
1561 },
1562 "slow",
1563 function () {
1564 $(document).on("scroll", $parentThis?.onScroll);
1565 },
1566 );
1567 });
1568 }
1569
1570 tocClose() {
1571 var $ = jQuery;
1572 // close sticky toc
1573 $(".close-toc").on("click", function (event) {
1574 event.preventDefault();
1575 $(".sticky-toc-container").remove(".sticky-toc-container");
1576 });
1577 }
1578
1579 sidebarOpen() {
1580 var $ = jQuery;
1581 if ($(window).width() <= 768) {
1582 // Click to open the left sidebar
1583 $('.betterdocs-sidebar-icon').on('click', function(e) {
1584 e.stopPropagation(); // Prevent click propagation to the document
1585
1586 // Check if the wrapper doesn't already exist to avoid duplicates
1587 if (!$('.betterdocs-mobile-sidebar-wrapper').length) {
1588 // Wrap sidebar elements with the wrapper div
1589 $('#betterdocs-sidebar, #betterdocs-sidebar-left, #betterdocs-full-sidebar-left').wrap('<div class="betterdocs-mobile-sidebar-wrapper"></div>');
1590 }
1591
1592 // Append the wrapper to the `.betterdocs-content-wrapper` instead of body
1593 if (!$('.betterdocs-mobile-sidebar-wrapper').parent().is('.betterdocs-content-wrapper')) {
1594 $('.betterdocs-content-wrapper').append($('.betterdocs-mobile-sidebar-wrapper'));
1595 }
1596
1597 // Slide in the sidebar by animating its left position
1598 $('.betterdocs-mobile-sidebar-wrapper').fadeIn();
1599 $('#betterdocs-sidebar, #betterdocs-sidebar-left, #betterdocs-full-sidebar-left').css({
1600 'left': '-300px',
1601 'display': 'block'
1602 }).animate({
1603 left: '0'
1604 }, 300); // Adjust time for the sliding effect
1605 });
1606
1607 // Close the left sidebar on click outside
1608 $(document).on('click.betterdocs-left-sidebar', function(e) {
1609 if (!$(e.target).closest('#betterdocs-sidebar, #betterdocs-sidebar-left, #betterdocs-full-sidebar-left, .betterdocs-sidebar-icon').length) {
1610 $('#betterdocs-sidebar, #betterdocs-sidebar-left, #betterdocs-full-sidebar-left').animate({
1611 left: '-300px'
1612 }, 300, function() {
1613 $('.betterdocs-mobile-sidebar-wrapper').fadeOut();
1614 });
1615 }
1616 });
1617
1618 // Prevent closing the left sidebar when clicked inside
1619 $('#betterdocs-sidebar, #betterdocs-sidebar-left, #betterdocs-full-sidebar-left').on('click', function(e) {
1620 e.stopPropagation(); // Prevent sidebar clicks from propagating to the document click handler
1621 });
1622 }
1623 }
1624
1625 tocOpen() {
1626 var $ = jQuery;
1627 if ($(window).width() <= 768) {
1628 // Click to open the right sidebar
1629 $('.betterdocs-toc-icon').on('click', function(e) {
1630 e.stopPropagation(); // Prevent click propagation to the document
1631
1632 // Check if the wrapper doesn't already exist to avoid duplicates
1633 if (!$('.betterdocs-mobile-toc-wrapper').length) {
1634 // Wrap sidebar elements with the wrapper div
1635 $('#betterdocs-sidebar-right').wrap('<div class="betterdocs-mobile-toc-wrapper"></div>');
1636 }
1637
1638 // Append the wrapper to the `.betterdocs-content-wrapper` instead of body
1639 if (!$('.betterdocs-mobile-toc-wrapper').parent().is('.betterdocs-content-wrapper')) {
1640 $('.betterdocs-content-wrapper').append($('.betterdocs-mobile-toc-wrapper'));
1641 }
1642
1643 // Slide in the sidebar by animating its right position
1644 $('.betterdocs-mobile-toc-wrapper').fadeIn();
1645 $('#betterdocs-sidebar-right').css({
1646 'right': '-300px',
1647 'display': 'block'
1648 }).animate({
1649 right: '0'
1650 }, 300); // Adjust time for the sliding effect
1651 });
1652
1653 // Close the right sidebar on click outside
1654 $(document).on('click.betterdocs-right-sidebar', function(e) {
1655 if (!$(e.target).closest('#betterdocs-sidebar-right, .betterdocs-toc-icon').length) {
1656 $('#betterdocs-sidebar-right').animate({
1657 right: '-300px'
1658 }, 300, function() {
1659 $('.betterdocs-mobile-toc-wrapper').fadeOut();
1660 });
1661 }
1662 });
1663
1664 // Prevent closing the right sidebar when clicked inside
1665 $('#betterdocs-sidebar-right').on('click', function(e) {
1666 e.stopPropagation(); // Prevent sidebar clicks from propagating to the document click handler
1667 });
1668 }
1669 }
1670
1671 delay(callback, time) {
1672 setTimeout(callback, time);
1673 }
1674
1675 printToc() {
1676 this.reusablePrintFunction('.betterdocs-print-btn');
1677 this.reusablePrintFunction('.betterdocs-print-pdf-2');
1678 }
1679
1680 cloneTocContentToAfter() {
1681 let $ = jQuery;
1682 let tocContent = $(
1683 ".betterdocs-single-layout-3 #betterdocs-sidebar-right .betterdocs-toc .toc-list a",
1684 );
1685 $.each(tocContent, function (index, item) {
1686 $(this).addClass(`toc-item-link-${index + 1}`);
1687 $(
1688 `<style>.toc-item-link-${index + 1}:after {height: ${
1689 item.offsetHeight + 10
1690 }px}</style>`,
1691 ).appendTo("head");
1692 });
1693 }
1694
1695 reusablePrintFunction(selector) {
1696 var $ = jQuery;
1697 // close sticky toc
1698 $("body").on("click", selector, function (event) {
1699 let entryTitle = "";
1700 if ($("#betterdocs-entry-title").length) {
1701 entryTitle = document.getElementById(
1702 "betterdocs-entry-title",
1703 ).innerHTML;
1704 } else if( $('.wp-block-post-title').length) { //for blocks single doc title
1705 entryTitle = document.getElementsByClassName(
1706 "wp-block-post-title",
1707 )[0]?.innerHTML;
1708 }
1709
1710 // Clone the content to avoid modifying the original
1711 var contentElement = document.getElementById("betterdocs-single-content");
1712 var contentClone = contentElement.cloneNode(true);
1713
1714 // Remove all glossary tooltip overlays from the cloned content
1715 var tooltipOverlays = contentClone.querySelectorAll('.glossary-tooltip-overlay');
1716 tooltipOverlays.forEach(function(overlay) {
1717 overlay.remove();
1718 });
1719
1720 var printContents = contentClone.innerHTML;
1721
1722 // Print template config (logo + footer) localised via betterdocsConfig.print.
1723 // Each part is gated by its Customizer toggle; an empty value renders nothing.
1724 var printConfig = (window.betterdocsConfig && betterdocsConfig.print) || {};
1725 var logoHeader = printConfig.logo
1726 ? '<div class="betterdocs-print-header"><img src="' + printConfig.logo + '" alt="" /></div>'
1727 : "";
1728 var body = logoHeader + "<h1>" + entryTitle + "</h1>" + " " + printContents;
1729
1730 // A repeating footer has to reserve its own space on *every* printed
1731 // page, so it is emitted as a <tfoot> of a full-width layout table:
1732 // browsers repeat thead/tfoot on each page break and keep the flowing
1733 // content clear of them. A fixed-position footer would instead paint
1734 // over the last lines of every page but the final one, and any fixed
1735 // padding reserved for it would be wrong the moment the footer (which
1736 // accepts HTML) wraps to more than one line.
1737 var combined = document.createElement("div");
1738 combined.innerHTML = printConfig.footer
1739 ? '<table class="betterdocs-print-layout">'
1740 + '<tfoot><tr><td><div class="betterdocs-print-footer">'
1741 + printConfig.footer
1742 + "</div></td></tr></tfoot>"
1743 + "<tbody><tr><td>" + body + "</td></tr></tbody>"
1744 + "</table>"
1745 : body;
1746 combined.id = "new-doc-print";
1747
1748 // Title for the print window so the browser header shows the doc name
1749 // instead of "about:blank".
1750 var printTitle =
1751 (entryTitle || "").replace(/<[^>]*>/g, "").trim() || document.title;
1752 var pwidth = document.getElementById(
1753 "betterdocs-single-content",
1754 ).offsetWidth;
1755 var wheight = $(window).height();
1756 var winPrint = window.open(
1757 "",
1758 "",
1759 "left=50%,top=10%,width=" +
1760 pwidth +
1761 ",height=" +
1762 wheight +
1763 ",toolbar=0,scrollbars=0,status=0",
1764 );
1765
1766 // Add the stylesheet to the new window
1767 var styles = Array.from(document.styleSheets).map(styleSheet => {
1768 try {
1769 return Array.from(styleSheet.cssRules).map(rule => rule.cssText).join('');
1770 } catch (e) {
1771 console.log('Access to stylesheet blocked by CORS policy:', styleSheet.href);
1772 return '';
1773 }
1774 }).join('\n');
1775
1776 // Print-template styles: page margins, centred logo header, and a
1777 // footer pinned to the bottom of every printed page.
1778 styles += '@page{margin:16mm 12mm}'
1779 + '.betterdocs-print-layout{width:100%;border-collapse:collapse}'
1780 + '.betterdocs-print-layout>tbody>tr>td,.betterdocs-print-layout>tfoot>tr>td{padding:0;border:0}'
1781 + '.betterdocs-print-layout>tfoot{display:table-footer-group}'
1782 + '.betterdocs-print-header{text-align:center;margin:0 0 16px;padding-bottom:12px;border-bottom:1px solid #e3e3e3}'
1783 + '.betterdocs-print-header img{max-height:72px;max-width:100%;width:auto;height:auto}'
1784 + '.betterdocs-print-footer{text-align:center;font-size:11px;line-height:1.4;color:#555;padding:6px 8px;margin-top:12px;border-top:1px solid #e3e3e3}';
1785
1786 var styleElement = winPrint.document.createElement('style');
1787 styleElement.textContent = styles;
1788
1789 winPrint.document.head.appendChild(styleElement);
1790 winPrint.document.body.innerHTML = combined.outerHTML;
1791 winPrint.document.close();
1792 winPrint.document.title = printTitle;
1793
1794 // print() snapshots the window immediately, so any image still
1795 // loading (e.g. the logo, fetched fresh in this new window) prints
1796 // blank. Wait for every pending image, with a timeout so a broken
1797 // image can never block printing.
1798 var pendingImages = Array.prototype.filter
1799 .call(winPrint.document.images, function (img) {
1800 return !img.complete;
1801 })
1802 .map(function (img) {
1803 return new Promise(function (resolve) {
1804 img.onload = img.onerror = resolve;
1805 });
1806 });
1807
1808 var openPrintDialog = function () {
1809 winPrint.focus();
1810 winPrint.print();
1811
1812 // Delay the close action to avoid malfunction of the print button
1813 setTimeout(function() {
1814 winPrint.close();
1815 }, 300);
1816 };
1817
1818 if (pendingImages.length) {
1819 Promise.race([
1820 Promise.all(pendingImages),
1821 new Promise(function (resolve) {
1822 setTimeout(resolve, 3000);
1823 })
1824 ]).then(openPrintDialog);
1825 } else {
1826 openPrintDialog();
1827 }
1828 });
1829 }
1830
1831 /**
1832 * AI Actions — the "Copy page" split button.
1833 *
1834 * Every handler is bound by delegation on <body> with no up-front element
1835 * check: the Elementor editor re-renders widgets over AJAX after
1836 * document.ready, so a `.length === 0` guard here would permanently disable the
1837 * dropdown in the editor preview (this is why printToc() delegates unguarded).
1838 *
1839 * Only `type: 'copy'` actions reach JavaScript at all. Every AI-assistant entry
1840 * is a plain <a target="_blank"> so middle-click, cmd-click and "open in new
1841 * tab" work, and so no popup blocker ever sees a programmatic window.open().
1842 */
1843 aiActions() {
1844 const $ = jQuery;
1845 const root = ".betterdocs-ai-actions";
1846 const i18n =
1847 (window.betterdocsConfig &&
1848 betterdocsConfig.ai_actions &&
1849 betterdocsConfig.ai_actions.i18n) ||
1850 {};
1851
1852 // Public handler registry. BetterDocs Pro, a future MCP item or a PDF export
1853 // registers here; Free's own JavaScript never has to change.
1854 window.betterdocsAIActionHandlers =
1855 window.betterdocsAIActionHandlers || {};
1856 window.betterdocsAIActionHandlers["copy-markdown"] = (el) =>
1857 this.copyMarkdown(el);
1858
1859 const closeAll = (except) => {
1860 $(root).each(function () {
1861 if (except && this === except) {
1862 return;
1863 }
1864 $(this)
1865 .find(".betterdocs-ai-actions-toggle")
1866 .attr("aria-expanded", "false");
1867 $(this).find(".betterdocs-ai-actions-menu").prop("hidden", true);
1868 });
1869 };
1870
1871 $("body").on("click.betterdocs-ai-actions", `${root}-toggle`, function (e) {
1872 e.preventDefault();
1873 const $toggle = $(this);
1874 const $wrap = $toggle.closest(root);
1875 const $menu = $wrap.find(".betterdocs-ai-actions-menu");
1876 const isOpen = $toggle.attr("aria-expanded") === "true";
1877
1878 closeAll(isOpen ? null : $wrap[0]);
1879 $toggle.attr("aria-expanded", isOpen ? "false" : "true");
1880 $menu.prop("hidden", isOpen);
1881
1882 // Keyboard only. A click event from Enter or Space carries
1883 // `detail: 0`, a real pointer click carries the click count, so this
1884 // gives keyboard users the entry point the disclosure pattern wants
1885 // without painting a focus ring on the first row every time somebody
1886 // opens the menu with a mouse — which reads as a rendering fault.
1887 if (!isOpen && e.detail === 0) {
1888 $menu.find(".betterdocs-ai-actions-item").first().trigger("focus");
1889 }
1890 });
1891
1892 // Escape closes and hands focus back to the control that opened the menu.
1893 $("body").on("keydown.betterdocs-ai-actions", root, function (e) {
1894 if (e.key !== "Escape" && e.key !== "Esc") {
1895 return;
1896 }
1897 const $wrap = $(this);
1898 if ($wrap.find(".betterdocs-ai-actions-menu").prop("hidden")) {
1899 return;
1900 }
1901 e.stopPropagation();
1902 closeAll();
1903 $wrap.find(".betterdocs-ai-actions-toggle").trigger("focus");
1904 });
1905
1906 $(document).on("click.betterdocs-ai-actions", (e) => {
1907 if (!$(e.target).closest(root).length) {
1908 closeAll();
1909 }
1910 });
1911
1912 $("body").on(
1913 "click.betterdocs-ai-actions",
1914 "button.betterdocs-ai-actions-item, button.betterdocs-ai-actions-primary",
1915 function (e) {
1916 e.preventDefault();
1917 const handler =
1918 window.betterdocsAIActionHandlers[$(this).data("bd-handler")];
1919 if (typeof handler === "function") {
1920 handler(this);
1921 }
1922 if ($(this).hasClass("betterdocs-ai-actions-item")) {
1923 const $wrap = $(this).closest(root);
1924 closeAll();
1925
1926 // Keyboard only — the same `detail` test, for the same reason,
1927 // as the open path above. Handing focus back to the toggle is
1928 // correct for the disclosure pattern, but after a *mouse* click
1929 // it is a programmatic focus, and Chrome counts programmatic
1930 // focus as focus-visible. That lit the 2px focus ring on the
1931 // toggle every time an item was picked with the mouse, which
1932 // reads as a stuck button rather than a focus cue.
1933 if (e.detail === 0) {
1934 $wrap.find(".betterdocs-ai-actions-toggle").trigger("focus");
1935 }
1936 }
1937 },
1938 );
1939
1940 // A link click should close the menu it was opened from.
1941 $("body").on(
1942 "click.betterdocs-ai-actions",
1943 "a.betterdocs-ai-actions-item",
1944 () => closeAll(),
1945 );
1946
1947 this.aiActionsI18n = i18n;
1948 }
1949
1950 /**
1951 * Fetch the doc's Markdown and put it on the clipboard.
1952 */
1953 copyMarkdown(el) {
1954 const $ = jQuery;
1955 const $wrap = $(el).closest(".betterdocs-ai-actions");
1956 // The `copy` profile: no YAML front matter, a Source: line instead. What a
1957 // human wants when they paste this into a chat.
1958 const url = $wrap.data("bd-copy-url") || $wrap.data("bd-md-url");
1959 const i18n = this.aiActionsI18n || {};
1960
1961 if (!url) {
1962 this.aiActionsFeedback($wrap, false);
1963 return;
1964 }
1965
1966 this.aiActionsAnnounce($wrap, i18n.copying);
1967
1968 const request = fetch(url, {
1969 credentials: "same-origin",
1970 headers: { Accept: "text/markdown" },
1971 }).then((res) => {
1972 const type = res.headers.get("content-type") || "";
1973 // A 403 refusal or a themed HTML fallback must never reach the clipboard.
1974 if (!res.ok || type.indexOf("text/markdown") !== 0) {
1975 throw new Error(`Unexpected response: ${res.status} ${type}`);
1976 }
1977 return res.text();
1978 });
1979
1980 const done = () => this.aiActionsFeedback($wrap, true);
1981 const failed = () => this.aiActionsFeedback($wrap, false);
1982
1983 // Safari drops user activation across an await, so writeText() after the
1984 // fetch resolves is rejected there. It does honour a ClipboardItem whose
1985 // value is a Promise, which keeps the write inside the original gesture.
1986 if (
1987 window.ClipboardItem &&
1988 navigator.clipboard &&
1989 navigator.clipboard.write
1990 ) {
1991 const blob = request.then(
1992 (text) => new Blob([text], { type: "text/plain" }),
1993 );
1994 navigator.clipboard
1995 .write([new ClipboardItem({ "text/plain": blob })])
1996 .then(done)
1997 .catch(() => request.then((t) => this.aiActionsWrite(t)).then(done, failed));
1998 return;
1999 }
2000
2001 request.then((text) => this.aiActionsWrite(text)).then(done, failed);
2002 }
2003
2004 /**
2005 * Clipboard write with a non-secure-context fallback: navigator.clipboard is
2006 * undefined on plain-HTTP sites, which is a real slice of self-hosted WordPress.
2007 */
2008 aiActionsWrite(text) {
2009 if (navigator.clipboard && navigator.clipboard.writeText) {
2010 return navigator.clipboard.writeText(text);
2011 }
2012
2013 if (typeof ClipboardJS !== "undefined" && ClipboardJS.copy) {
2014 return ClipboardJS.copy(text)
2015 ? Promise.resolve()
2016 : Promise.reject(new Error("ClipboardJS copy failed"));
2017 }
2018
2019 const area = document.createElement("textarea");
2020 area.value = text;
2021 area.setAttribute("readonly", "");
2022 area.style.position = "fixed";
2023 area.style.top = "-9999px";
2024 document.body.appendChild(area);
2025 area.select();
2026
2027 let ok = false;
2028 try {
2029 ok = document.execCommand("copy");
2030 } catch (e) {
2031 ok = false;
2032 }
2033 document.body.removeChild(area);
2034
2035 return ok ? Promise.resolve() : Promise.reject(new Error("execCommand copy failed"));
2036 }
2037
2038 /**
2039 * Swap the button label for a couple of seconds. The live region alone is not
2040 * feedback a sighted user can act on.
2041 */
2042 aiActionsFeedback($wrap, success) {
2043 const $ = jQuery;
2044 const i18n = this.aiActionsI18n || {};
2045 const $label = $wrap.find(".betterdocs-ai-actions-label");
2046 const original = $label.data("bd-original") || $label.text();
2047
2048 $label.data("bd-original", original);
2049 $label.text(success ? i18n.copied : i18n.copy_failed);
2050 $wrap
2051 .removeClass("is-copied is-failed")
2052 .addClass(success ? "is-copied" : "is-failed");
2053
2054 this.aiActionsAnnounce($wrap, success ? i18n.copied : i18n.copy_error);
2055
2056 clearTimeout($wrap.data("bd-reset"));
2057 $wrap.data(
2058 "bd-reset",
2059 setTimeout(() => {
2060 $label.text(original);
2061 $wrap.removeClass("is-copied is-failed");
2062 }, 2000),
2063 );
2064 }
2065
2066 /**
2067 * Write to the live region. Cleared first because most screen readers ignore a
2068 * repeated identical string, so a second copy would go unannounced.
2069 */
2070 aiActionsAnnounce($wrap, message) {
2071 if (!message) {
2072 return;
2073 }
2074 const $status = $wrap.find(".betterdocs-ai-actions-status");
2075 $status.text("");
2076 setTimeout(() => $status.text(message), 50);
2077 }
2078
2079
2080 /**
2081 * Listen — the pill that morphs in place into an audio player.
2082 *
2083 * Bound by delegation on <body> with no up-front element check, for the reason
2084 * aiActions() gives: the Elementor editor re-renders widgets over AJAX after
2085 * document.ready, so a `.length === 0` guard here would permanently disable
2086 * the player in the editor preview.
2087 *
2088 * All the state lives in BetterDocsListen, one instance per pill, created
2089 * lazily on first interaction — which is also what makes a pill that arrived
2090 * after this ran work without an observer watching for it.
2091 */
2092 listen() {
2093 const $ = jQuery;
2094 const root = ".betterdocs-listen";
2095 const config =
2096 (window.betterdocsConfig && betterdocsConfig.listen) || {};
2097
2098 // No speech engine, no feature. The class goes on <html> rather than on
2099 // each pill so that a pill rendered later is covered by a decision already
2100 // made — see the gate in scss/template-parts/listen.scss.
2101 if (
2102 !("speechSynthesis" in window) ||
2103 typeof window.SpeechSynthesisUtterance !== "function"
2104 ) {
2105 document.documentElement.classList.add("betterdocs-no-speech");
2106 return;
2107 }
2108
2109 const player = (el) =>
2110 BetterDocsListen.for($(el).closest(root)[0], config);
2111
2112 // Every selector below names `button`, the way the AI Actions handlers name
2113 // `button.betterdocs-ai-actions-primary`. The editor placeholders render the
2114 // same classes on spans precisely so they stay inert — a placeholder has no
2115 // player to open, and a click on one would otherwise reach a real handler.
2116 $("body")
2117 .on("click.betterdocs-listen", `button${root}-trigger`, function (e) {
2118 e.preventDefault();
2119 player(this).activate();
2120 })
2121 // Speculative, so the common case — pointer travels to the pill, then
2122 // clicks it — has nothing left to wait for. `focusin` rather than
2123 // `focus` because only the former delegates.
2124 .on(
2125 "mouseenter.betterdocs-listen focusin.betterdocs-listen",
2126 `button${root}-trigger`,
2127 function () {
2128 player(this).prefetch();
2129 },
2130 )
2131 .on("click.betterdocs-listen", `button${root}-play`, function (e) {
2132 e.preventDefault();
2133 player(this).toggle();
2134 })
2135 .on("click.betterdocs-listen", `button${root}-speed`, function (e) {
2136 e.preventDefault();
2137 player(this).cycleRate();
2138 })
2139 .on("click.betterdocs-listen", `button${root}-close`, function (e) {
2140 e.preventDefault();
2141 player(this).close();
2142 })
2143 .on("click.betterdocs-listen", `${root}-seek`, function (e) {
2144 const rect = this.getBoundingClientRect();
2145
2146 if (!rect.width) {
2147 return;
2148 }
2149
2150 const offset = (e.clientX || 0) - rect.left;
2151 // The bar fills from the inline start, which on an RTL site is the
2152 // right edge — so the fraction has to be read from the other end.
2153 const rtl =
2154 window.getComputedStyle(this).direction === "rtl";
2155 const fraction = rtl
2156 ? 1 - offset / rect.width
2157 : offset / rect.width;
2158
2159 player(this).seekTo(fraction, true);
2160 })
2161 .on("keydown.betterdocs-listen", `${root}-seek`, function (e) {
2162 const instance = player(this);
2163 const current =
2164 instance.duration > 0
2165 ? instance.position / instance.duration
2166 : 0;
2167 // Shift for a coarse jump, which on a 20-minute doc is the
2168 // difference between scrubbing and arriving.
2169 const step = e.shiftKey ? 0.1 : 0.02;
2170 let fraction = null;
2171
2172 switch (e.key) {
2173 case "ArrowRight":
2174 case "Right":
2175 case "ArrowUp":
2176 case "Up":
2177 fraction = current + step;
2178 break;
2179 case "ArrowLeft":
2180 case "Left":
2181 case "ArrowDown":
2182 case "Down":
2183 fraction = current - step;
2184 break;
2185 case "Home":
2186 fraction = 0;
2187 break;
2188 case "End":
2189 // Not 1: a seek to the very end has no chunk left to speak,
2190 // and would read as the player refusing to move.
2191 fraction = 0.99;
2192 break;
2193 case " ":
2194 case "Spacebar":
2195 case "Enter":
2196 e.preventDefault();
2197 instance.toggle();
2198 return;
2199 default:
2200 return;
2201 }
2202
2203 // Only now: the keys this does not handle — Tab above all — have to
2204 // reach the browser.
2205 e.preventDefault();
2206 instance.seekTo(fraction, true);
2207 })
2208 // Escape closes the player and hands focus back to the pill, matching
2209 // the AI Actions menu beside it.
2210 .on("keydown.betterdocs-listen", root, function (e) {
2211 if (e.key !== "Escape" && e.key !== "Esc") {
2212 return;
2213 }
2214
2215 const instance = player(this);
2216
2217 if (!instance.active) {
2218 return;
2219 }
2220
2221 e.stopPropagation();
2222 instance.close();
2223 });
2224
2225 // Chrome returns an empty list from `getVoices()` until the engine has
2226 // loaded it, which is usually after this runs. Any pill that resolved a
2227 // voice in that window got nothing, so let them look again — skipping the
2228 // ones mid-playback, which would otherwise change voice between sentences.
2229 if (typeof window.speechSynthesis.addEventListener === "function") {
2230 window.speechSynthesis.addEventListener("voiceschanged", () => {
2231 BetterDocsListen.registry.forEach((instance) => {
2232 if (!instance.playing) {
2233 instance.voiceResolved = null;
2234 }
2235 });
2236 });
2237 }
2238
2239 // Chrome keeps speaking after a navigation: `speechSynthesis` belongs to the
2240 // browser, not to the document, so nothing tears it down on its own.
2241 $(window).on("pagehide.betterdocs-listen", () =>
2242 BetterDocsListen.stopAll(),
2243 );
2244 }
2245
2246 copyToClipboard() {
2247 let $ = jQuery;
2248 if ($(".batterdocs-anchor").length) {
2249 const getURLClipboard = function (e) {
2250 // Clipboard
2251 e.preventDefault();
2252 e.stopPropagation();
2253
2254
2255 let clipboardJS = new ClipboardJS(e.target);
2256
2257 clipboardJS.on("success", function (e) {
2258 $(document)
2259 .find("div.tooltip-box")
2260 .text(betterdocsConfig.copy_text);
2261 e.clearSelection();
2262 $(e.trigger).addClass("copied");
2263 setTimeout(function () {
2264 $(e.trigger).removeClass("copied");
2265 }, 2000);
2266
2267 clipboardJS.destroy();
2268 });
2269
2270 // Triggering the First Click Here. and also emitting the event to work above code.
2271 let text = ClipboardJS.copy(e.target.dataset.clipboardText);
2272 clipboardJS.emit(text ? 'success' : 'error', {
2273 action: 'copy',
2274 text,
2275 trigger: e.target,
2276 clearSelection() {
2277 if (e.target) {
2278 e.target.focus();
2279 }
2280 window.getSelection().removeAllRanges();
2281 },
2282 });
2283 };
2284
2285 // tooltips
2286 $('a.batterdocs-anchor[href*="#"]').hover(
2287 function () {
2288 var title = $(this).attr("data-title");
2289 $("<div/>", {
2290 text: title,
2291 class: "tooltip-box",
2292 }).appendTo(this);
2293 },
2294 function () {
2295 $(this).find("div.tooltip-box").remove();
2296 },
2297 ).on('click', getURLClipboard);
2298
2299
2300 (function () {
2301 if (
2302 typeof self === "undefined" ||
2303 !self.Prism ||
2304 !self.document
2305 ) {
2306 return;
2307 }
2308
2309 if (!Prism.plugins.toolbar) {
2310 console.warn(
2311 "Copy to Clipboard plugin loaded before Toolbar plugin.",
2312 );
2313
2314 return;
2315 }
2316
2317 var ClipboardJS = window.ClipboardJS || undefined;
2318
2319 if (!ClipboardJS && typeof require === "function") {
2320 ClipboardJS = require("clipboard");
2321 }
2322
2323 // ClipboardJS comes from the bundled `clipboard` handle, which the
2324 // `betterdocs` script depends on. Never load it from a CDN.
2325 var callbacks = [];
2326
2327 Prism.plugins.toolbar.registerButton(
2328 "copy-to-clipboard",
2329 function (env) {
2330 var linkCopy = document.createElement("button");
2331 linkCopy.textContent = "Copy";
2332
2333 if (!ClipboardJS) {
2334 callbacks.push(registerClipboard);
2335 } else {
2336 registerClipboard();
2337 }
2338
2339 return linkCopy;
2340
2341 function registerClipboard() {
2342 var clip = new ClipboardJS(linkCopy, {
2343 text: function () {
2344 return env.code;
2345 },
2346 });
2347
2348 clip.on("success", function () {
2349 linkCopy.textContent = "Copied!";
2350
2351 resetText();
2352 });
2353 clip.on("error", function () {
2354 linkCopy.textContent = "Press Ctrl+C to copy";
2355
2356 resetText();
2357 });
2358 }
2359
2360 function resetText() {
2361 setTimeout(function () {
2362 linkCopy.textContent = "Copy";
2363 }, 5000);
2364 }
2365 },
2366 );
2367 })();
2368 }
2369 }
2370
2371 feedbackForm() {
2372 var $ = jQuery;
2373 var $this = this;
2374 let formModal = $("#betterdocs-form-modal");
2375 let formModalContent = $("#betterdocs-form-modal .modal-content");
2376 //select all the a tag with name equal to modal
2377 this.formLink.click(function (e) {
2378 e.preventDefault();
2379 formModal.fadeIn(500);
2380 });
2381
2382 //if outside of modal content is clicked
2383 $(document).mouseup(function (e) {
2384 if (
2385 !formModalContent.is(e.target) &&
2386 formModalContent.has(e.target).length === 0
2387 ) {
2388 formModal.fadeOut();
2389 }
2390 });
2391
2392 //if close button is clicked
2393 $(".betterdocs-modalwindow .close").click(function (e) {
2394 e.preventDefault();
2395 formModal.fadeOut(500);
2396 });
2397
2398 this.feedbackFormFields.on("keyup", function () {
2399 $(this).removeClass("val-error");
2400 $(this).siblings(".error-message").remove();
2401 });
2402
2403 this.feedbackFormWrap.on("submit", function (e) {
2404 e.preventDefault();
2405 var form = $(this);
2406 var message_name = $("#message_name");
2407 var message_email = $("#message_email");
2408 var message_subject = $("#message_subject");
2409 var message_text = $("#message_text");
2410 $this.betterdocsFeedbackFormSubmit(
2411 form,
2412 message_name,
2413 message_email,
2414 message_subject,
2415 message_text,
2416 );
2417 });
2418
2419 this.betterdocsFeedbackFormSubmit = function (
2420 form,
2421 message_name,
2422 message_email,
2423 message_subject,
2424 message_text,
2425 ) {
2426 let request;
2427 if (request) {
2428 request.abort();
2429 }
2430 request = $.ajax({
2431 url: betterdocsSubmitFormConfig.ajax_url,
2432 type: "post",
2433 data: {
2434 action: "betterdocs_feedback_form_submit",
2435 form: form.serializeArray(),
2436 postID: betterdocsSubmitFormConfig.post_id,
2437 message_name: message_name.val(),
2438 message_email: message_email.val(),
2439 message_subject: message_subject.val(),
2440 message_text: message_text.val(),
2441 security: betterdocsSubmitFormConfig.nonce,
2442 },
2443 beforeSend: function () {
2444 },
2445 success: function (data) {
2446 if (data.success) {
2447 if (data.success == true) {
2448 $(".response").html(
2449 '<span class="success-message">' +
2450 data.data.message +
2451 "</span>",
2452 );
2453 form[0].reset();
2454 $this.delay(function () {
2455 $(".betterdocs-modalwindow").fadeOut(500);
2456 $(".response .success-message").remove();
2457 }, 3000);
2458 } else {
2459 $(".response").html(
2460 '<span class="error-message">' +
2461 data.sentMessage +
2462 "</span>",
2463 );
2464 }
2465 } else if (data.success == false) {
2466 if (data.data.message.name) {
2467 if (message_name.hasClass("val-error") == false) {
2468 message_name.addClass("val-error");
2469 $(".form-name").append(
2470 '<span class="error-message">' +
2471 data.data.message.name +
2472 "</span>",
2473 );
2474 }
2475 }
2476 if (data.data.message.email) {
2477 if (message_email.hasClass("val-error") == false) {
2478 message_email.addClass("val-error");
2479 $(".form-email").append(
2480 '<span class="error-message">' +
2481 data.data.message.email +
2482 "</span>",
2483 );
2484 }
2485 }
2486 if (data.data.message.message) {
2487 if (message_text.hasClass("val-error") == false) {
2488 message_text.addClass("val-error");
2489 $(".form-message").append(
2490 '<span class="error-message">' +
2491 data.data.message.message +
2492 "</span>",
2493 );
2494 }
2495 }
2496 }
2497 },
2498 });
2499 };
2500 }
2501
2502 subCategoryExpand() {
2503 var $ = jQuery;
2504 let subCatActive = $(
2505 ".betterdocs-nested-category-list.betterdocs-current-category.active",
2506 );
2507
2508 /**
2509 * Subcategory Expand On Load Including Nested Subcategories
2510 */
2511 if (subCatActive.length > 0) {
2512 subCatActive.each(function (index) {
2513 $(this).prev().children(".toggle-arrow").toggle();
2514 });
2515 }
2516 }
2517
2518 articleSummary() {
2519 var $ = jQuery;
2520 var summaryContainer = $("#betterdocs-article-summary");
2521 var summaryToggle = $("#betterdocs-summary-toggle");
2522 var summaryContent = $("#betterdocs-summary-content");
2523 var summaryLoading = $("#betterdocs-summary-loading");
2524 var summaryText = $("#betterdocs-summary-text");
2525 var summaryLoaded = false;
2526
2527 if (summaryContainer.length === 0) {
2528 return; // Exit if summary container is not found
2529 }
2530
2531 function loadArticleSummary() {
2532 // Show loading state
2533 summaryLoading.show();
2534 summaryText.hide();
2535
2536 // Get post data from DOM attributes
2537 var postId = parseInt(summaryContainer.data('post-id')) || 0;
2538 var postType = summaryContainer.data('post-type') || '';
2539
2540 // Get post content for summary generation
2541 var postContent = $("#betterdocs-single-content").text() || $(".betterdocs-content").text() || "";
2542 var postTitle = $("#betterdocs-entry-title").text() || $(".betterdocs-entry-heading").text() || document.title;
2543
2544 if (!postContent.trim()) {
2545 summaryLoading.hide();
2546 summaryText.html('<p>' + (betterdocsConfig.summary_error || 'Unable to generate summary for this article.') + '</p>').show();
2547 return;
2548 }
2549
2550 // Validate post ID and post type
2551 if (postId <= 0) {
2552 summaryLoading.hide();
2553 summaryText.html('<p class="summary-error">Invalid post ID for summary generation.</p>').show();
2554 return;
2555 }
2556
2557 if (postType !== 'docs') {
2558 summaryLoading.hide();
2559 summaryText.html('<p class="summary-error">Doc summary is only available for documentation posts.</p>').show();
2560 return;
2561 }
2562
2563 // Make AJAX request to generate summary
2564 $.ajax({
2565 url: betterdocsConfig.ajax_url,
2566 type: "POST",
2567 data: {
2568 action: "betterdocs_generate_article_summary",
2569 post_id: postId,
2570 post_title: postTitle,
2571 post_content: postContent.substring(0, 4000), // Limit content length
2572 nonce: betterdocsConfig.summary_nonce
2573 },
2574 success: function(response) {
2575 summaryLoading.hide();
2576
2577 if (response.success && response.data) {
2578 var contentHtml = '<div class="summary-content">' + response.data + '</div>';
2579 summaryText.html(contentHtml);
2580
2581 // Apply custom styles if available (from blocks/elementor)
2582 var customStyle = summaryText.data('style');
2583 if (customStyle) {
2584 summaryText.attr('style', customStyle + '; display: block;');
2585 } else {
2586 summaryText.show();
2587 }
2588
2589 summaryLoaded = true;
2590 } else {
2591 var errorMsg = response.data || betterdocsConfig.summary_error || 'Failed to generate doc summary.';
2592 summaryText.html('<p class="summary-error">' + errorMsg + '</p>').show();
2593 }
2594 },
2595 error: function() {
2596 summaryLoading.hide();
2597 summaryText.html('<p class="summary-error">' + (betterdocsConfig.summary_error || 'Failed to generate doc summary.') + '</p>').show();
2598 }
2599 });
2600 }
2601
2602 // If no toggle exists (title hidden), show content immediately and load summary
2603 if (summaryToggle.length === 0) {
2604 summaryContent.show();
2605 if (!summaryLoaded) {
2606 loadArticleSummary();
2607 }
2608 return;
2609 }
2610
2611 // Handle toggle click when title is shown
2612 summaryToggle.on("click", function(e) {
2613 e.preventDefault();
2614
2615 if (summaryContent.is(":visible")) {
2616 // Close summary
2617 summaryContent.slideUp();
2618 // Show angle-right, hide angle-down
2619 $(this).find(".angle-right").show();
2620 $(this).find(".angle-down").hide();
2621 } else {
2622 // Open summary
2623 summaryContent.slideDown();
2624 // Hide angle-right, show angle-down
2625 $(this).find(".angle-right").hide();
2626 $(this).find(".angle-down").show();
2627
2628 // Load summary if not already loaded
2629 if (!summaryLoaded) {
2630 loadArticleSummary();
2631 }
2632 }
2633 });
2634 }
2635 }
2636
2637 (function ($) {
2638 "use strict";
2639 $(document).ready(function () {
2640 new BetterDocs(window?.betterdocsConfig);
2641 })
2642 })(jQuery);
2643