Update this timeline entry

Limit use of TinyMCE

Source: Amy and ChatGPT #1356

Example JS (mode toggle + submit sync) let mode = "plain"; // or "rich" function showMode(next) { mode = next; document.getElementById("plainWrap").style.display = (mode === "plain") ? "" : "none"; document.getElementById("richWrap").style.display = (mode === "rich") ? "" : "none"; } document.getElementById("usePlain").addEventListener("click", () => showMode("plain")); document.getElementById("useRich").addEventListener("click", async () => { showMode("rich"); // init TinyMCE only when needed (saves usage) if (!tinymce.get("content_rich")) { tinymce.init({ selector: "#content_rich", menubar: false, toolbar: "undo redo | bold italic underline | alignleft aligncenter alignright | bullist numlist | link | removeformat | table", plugins: "lists link table", branding: false, height: 400 }); } }); // before submit, copy active mode into canonical #content document.querySelector("form").addEventListener("submit", () => { const canonical = document.getElementById("content"); if (mode === "plain") { canonical.value = document.getElementById("content_plain").value; } else { canonical.value = tinymce.get("content_rich") ? tinymce.get("content_rich").getContent() : ""; } }); Why this is the best answer for your situation Your backend stays exactly the same: $_POST['content'] → MySQL content column. You avoid duplicate name="content" chaos. You avoid initializing TinyMCE most of the time, which is likely what triggers your “usage” warning. Even simpler: keep one textarea and only load TinyMCE on demand If you don’t actually need two separate fields visually, do this: Start with a normal This way, normal data entry never touches Tiny Cloud. Option 3: pay or rotate keys (worst) Paying is fine if you need cloud features. Rotating keys is just kicking the can and can violate terms depending on what you’re doing. About your original “two textareas named content” idea Don’t do that. It will submit two values, and PHP will pick one unpredictably unless you convert to content[], which breaks your backend expectation. If you want two input modes, do it the sane way: keep one name="content" field optionally show a second “plain” box, but copy its value into content before submit (as I showed earlier) If you want the fastest fix with minimal changes Do this: Keep your existing textarea: Remove your current auto-init script, and replace with a button-trigger init (Option 2). That’s the smallest change that can cut usage massively. If you paste your current tinymce.init({...}) block (plugins/toolbar etc.), I’ll tweak it so it still works when initialized on-demand and doesn’t accidentally double-init or lose content on submit. You said: I looked at my TinyMCE account and see that the billing month started on the same day as the 50% usage notice, which is today. There is no usage in the current month, which starts today. I get 1000 editor loads per month for free. I would like to see if I exceed that usage volume in one month. If I do, I will install the self-hosted copy. ChatGPT said: Thought for 12s You’re reading the dashboard correctly — and the warning can still be real. Tiny Cloud counts an “editor load” every time TinyMCE is initialized (successful tinymce.init() → it fires the init event). It’s not “how many posts you saved.” It’s “how many times the editor spun up.” So here’s what’s likely happening: Why you got a 50% warning on day 1 while the month shows 0 The warning can be based on usage data that hasn’t populated into the dashboard yet (usage reporting lag). This is common in SaaS metering: notifications trigger off backend counters; dashboards lag behind. You might be initializing more than once per visit without realizing it: the page includes your init JS twice partial reloads / PJAX / HTMX / Turbo / AJAX navigations re-run your init you have multiple matching textareas on the page you’re calling tinymce.init() again after form validation errors (postback) You could be loading multiple editors per page. If you ever add more than one instance (or your selector matches more than one element), loads multiply fast. None of those require “heavy use” to burn 500 loads. The smartest “wait and see” plan (without blowing the quota) Do two small changes now. If you exceed 1000 in a month, then self-host. If not, you’re fine. 1) Make init idempotent (so it can’t double-count) Only init if it’s not already there: 2) Add a “manual enable” switch (optional but powerful) Default to plain textarea, and only initialize when you click: This doesn’t change your backend at all, and it slashes loads because most visits won’t initialize TinyMCE (which is exactly what you want). How to tell if you’re double-initializing right now Drop this in temporarily: document.addEventListener('DOMContentLoaded', () => { console.log('DOMContentLoaded fired'); }); if (window.tinymce) { tinymce.on('AddEditor', () => console.log('TinyMCE AddEditor')); } Then load the page once. If you see multiple AddEditor logs on one visit, you’ve found the culprit. Bottom line If your goal is “see if I exceed 1000/month”, fine — but don’t run blind. Make init idempotent (so you don’t leak loads through double-init), and consider click-to-enable so your usage reflects real need. If you paste the part of your page that includes: the That solves: “tinymce undefined” errors double-init if the code runs more than once the common “script at bottom vs top” timing mismatch One more thing: make sure the selector can’t match multiple elements If you ever accidentally end up with two elements using id="content" (copy/paste happens), TinyMCE will create multiple editors and burn loads fast. IDs must be unique. If you want to reduce loads dramatically (while you test your monthly usage) Switch to “click-to-enable”: Now you only “spend” editor loads on days you actually need the editor. If you paste the line right above this snippet (the part that starts the event handler you cut off), I can tell you whether you’ve currently got a double-run risk. You said: I use TinyMCE in tandem with a collapsible section feature. Here are the full contents of my script.js file: /* Collapsible sections per Josh Howard */ const collapsibleItems = document.getElementsByClassName("collapsible"); for (let i = 0; i < collapsibleItems.length; i++) { const collapsibleItem = collapsibleItems[i]; collapsibleItem.addEventListener("click", function() { this.classList.toggle("active"); const content = this.nextElementSibling; content.classList.toggle('expanded'); }); } /* Expand/collapse all by Josh */ const expandAll = () => { const collapsibleItems = document.getElementsByClassName("collapsible"); for (let i = 0; i < collapsibleItems.length; i++) { const content = collapsibleItems[i].nextElementSibling; if (!content.classList.contains('expanded')) { content.classList.add('expanded'); } } }; const collapseAll = () => { const collapsibleItems = document.getElementsByClassName("collapsible"); for (let i = 0; i < collapsibleItems.length; i++) { const content = collapsibleItems[i].nextElementSibling; if (content.classList.contains('expanded')) { content.classList.remove('expanded'); } } }; /* ==================================================== */ /* TinyMCE from CDN for WYSIWYG editor from MIT open source */ /* TinyMCE init (only when #content exists) by ChatGPT 1/12/2026 */ document.addEventListener('DOMContentLoaded', function () { // Keep your buttons wiring here (fine) const expandAllBtn = document.querySelector('#expand-all-button'); const collapseAllBtn = document.querySelector('#collapse-all-button'); if (expandAllBtn) expandAllBtn.addEventListener('click', expandAll); if (collapseAllBtn) collapseAllBtn.addEventListener('click', collapseAll); // TinyMCE should ONLY run on pages that actually have the editor element const editorEl = document.querySelector('#content'); if (!editorEl) return; // TinyMCE library must be loaded in the page before scripts.js if (typeof window.tinymce === 'undefined') { console.error('TinyMCE failed to load. Include tinymce.min.js before scripts.js on this page.'); return; } // Prevent double init if scripts load twice if (window.tinymce.get('content')) return; window.tinymce.init({ selector: '#content', menubar: false, toolbar: 'undo redo | bold italic underline | alignleft aligncenter alignright | bullist numlist | link | removeformat | table', plugins: 'lists link table', branding: false, height: 400, link_default_target: '_blank' }); }); /* ========================================== Author Jump + Arrow Navigation (authors.php) by ChatGPT 1/12/2026 ========================================== */ (() => { const input = document.getElementById('authorJump'); const list = document.getElementById('authorList'); if (!input || !list) return; const norm = (s) => (s || '') .toString() .toLowerCase() .replace(/['".,()]/g, '') .replace(/\s+/g, ' ') .trim(); let matches = []; let matchIndex = -1; let lastQuery = ''; let lastHit = null; function clearHighlights() { list.querySelectorAll('.author-hit') .forEach(a => a.classList.remove('author-hit')); } function getLinks() { return Array.from(list.querySelectorAll('a.author-link')); } function computeMatches(rawQuery) { // Keep commas for "Last, First" intent const cleaned = (rawQuery || '') .toString() .toLowerCase() .replace(/['".()]/g, '') // NOTE: comma is NOT removed here .replace(/\s+/g, ' ') .trim(); if (!cleaned) return []; const hasComma = cleaned.includes(','); const [left, right = ''] = cleaned.split(',', 2); const qLast = norm(left); const qFirst = norm(right); const items = getLinks().map(a => { const fullRaw = (a.textContent || '').toString(); const full = norm(fullRaw); // last is authoritative from data-last const last = norm(a.dataset.last || ''); // first name chunk: // - if "Last, First" → after comma // - else "First Last" → everything except last word let first = ''; if (fullRaw.includes(',')) { first = norm(fullRaw.split(',')[1] || ''); } else { const parts = fullRaw.trim().split(/\s+/); if (parts.length > 1) first = norm(parts.slice(0, -1).join(' ')); } return { a, last, first, full }; }); // helper: rank matches by exact → startsWith → contains const rank = (arr, field, q) => { const exact = arr.filter(x => x[field] === q).map(x => x.a); const starts = arr.filter(x => x[field].startsWith(q) && x[field] !== q).map(x => x.a); const contains = arr.filter(x => x[field].includes(q) && !x[field].startsWith(q)).map(x => x.a); return [...exact, ...starts, ...contains]; }; // If user typed a comma, they’re saying: "last, first" // Still: last must match FIRST; then narrow by first. if (hasComma && qLast) { const lastCandidates = items.filter(x => x.last.includes(qLast)); if (!lastCandidates.length) return []; // no last-name match at all → no match // If no first part, just rank by last if (!qFirst) return rank(lastCandidates, 'last', qLast); // Narrow within last matches by first const firstRanked = rank(lastCandidates, 'first', qFirst); return firstRanked.length ? firstRanked : []; // only return if first actually matches } // 1) LAST-NAME SEARCH ONLY (the priority you asked for) const lastRanked = rank(items, 'last', qLast); if (lastRanked.length) return lastRanked; // 2) ONLY IF ZERO last-name matches exist, then search FIRST names const firstRanked = rank(items, 'first', qLast); if (firstRanked.length) return firstRanked; // 3) (optional fallback) full-string contains, *after* last+first fail // If you want to be stricter, delete this. const fullRanked = rank(items, 'full', qLast); return fullRanked; } function setHit(el, {behavior = 'auto', fromKeys = false} = {}) { if (!el) return; clearHighlights(); el.classList.add('author-hit'); lastHit = el; // Scroll behavior: // - typing: auto (prevents snap-back flicker) // - arrow keys: smooth (feels nice) requestAnimationFrame(() => { el.scrollIntoView({ behavior, block: 'center', inline: 'nearest' }); }); // Don’t refocus input here—you're already typing in it. // Refocusing can trigger browser “caret visibility” scroll snaps. } function jump(fromKeys = false) { const q = norm(input.value); if (!q) { input.classList.remove('no-match'); matches = []; matchIndex = -1; lastQuery = ''; lastHit = null; clearHighlights(); return; } // Recompute matches only if query changed if (q !== lastQuery) { matches = computeMatches(q); matchIndex = matches.length ? 0 : -1; lastQuery = q; } if (!matches.length) { input.classList.add('no-match'); lastHit = null; clearHighlights(); // Only return to the input when there is truly no match input.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; } input.classList.remove('no-match'); const hit = matches[matchIndex] || matches[0]; // Don’t re-scroll if nothing changed if (hit === lastHit && !fromKeys) return; setHit(hit, { behavior: fromKeys ? 'smooth' : 'auto', fromKeys }); } // While typing: jump to best match (instant scroll, no flicker) input.addEventListener('input', () => jump(false)); // Arrow navigation + Enter to open input.addEventListener('keydown', (e) => { if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); // Ensure matches are current for the existing text const q = norm(input.value); if (q !== lastQuery) { matches = computeMatches(q); matchIndex = matches.length ? 0 : -1; lastQuery = q; } if (!matches.length) return; if (e.key === 'ArrowDown') { matchIndex = (matchIndex + 1) % matches.length; } else { matchIndex = (matchIndex - 1 + matches.length) % matches.length; } jump(true); return; } if (e.key === 'Enter') { e.preventDefault(); const hit = list.querySelector('a.author-link.author-hit'); if (hit) window.location.href = hit.href; } }); })(); ChatGPT said: Your TinyMCE block in that scripts.js is not the reason you’re burning loads by itself. It’s sane: it waits for DOMContentLoaded, checks #content exists, checks tinymce exists, and prevents double-init with tinymce.get('content'). So if you’re still getting quota nags, the load inflation is almost certainly coming from one of these: The 3 real ways you accidentally rack up “editor loads” 1) You include TinyMCE on pages that don’t need it Even if your init doesn’t run, you’re still loading the cloud script. That usually shouldn’t count as an “editor load,” but it’s still pointless and makes debugging harder. Do this: only include the Tiny Cloud If you want to be extra aggressive about reducing any footprint, you can skip that tag and load TinyMCE dynamically on click (I’ll show that after the simple version). 3) Add this to scripts.js (replaces the auto-init block) Delete your current DOMContentLoaded TinyMCE init block and replace with: /* ==================================================== */ /* TinyMCE on-demand (button click) */ document.addEventListener('DOMContentLoaded', function () { // Keep your expand/collapse buttons wiring here (fine) const expandAllBtn = document.querySelector('#expand-all-button'); const collapseAllBtn = document.querySelector('#collapse-all-button'); if (expandAllBtn) expandAllBtn.addEventListener('click', expandAll); if (collapseAllBtn) collapseAllBtn.addEventListener('click', collapseAll); // On-demand editor wiring only if textarea exists const editorEl = document.querySelector('#content'); if (!editorEl) return; const enableBtn = document.getElementById('enableEditorBtn'); const disableBtn = document.getElementById('disableEditorBtn'); if (!enableBtn || !disableBtn) return; function setButtons(isEditorOn) { enableBtn.style.display = isEditorOn ? 'none' : ''; disableBtn.style.display = isEditorOn ? '' : 'none'; } async function enableEditor() { // TinyMCE must already be loaded via script tag on this page if (!window.tinymce) { console.error('TinyMCE library not loaded. Include tinymce.min.js on this page.'); return; } // Prevent double init (including the "in-flight" race) if (window.__contentTinyInitStarted) return; if (window.tinymce.get('content')) { setButtons(true); return; } window.__contentTinyInitStarted = true; await window.tinymce.init({ selector: '#content', menubar: false, toolbar: 'undo redo | bold italic underline | alignleft aligncenter alignright | bullist numlist | link | removeformat | table', plugins: 'lists link table', branding: false, height: 400, link_default_target: '_blank' }); setButtons(true); } function disableEditor() { if (!window.tinymce) return; const ed = window.tinymce.get('content'); if (!ed) { setButtons(false); return; } // This pushes the edited HTML back into the underlying editor.js: document.addEventListener('DOMContentLoaded', () => { const textarea = document.getElementById('content'); const enableBtn = document.getElementById('enableEditorBtn'); const disableBtn = document.getElementById('disableEditorBtn'); if (!textarea || !enableBtn || !disableBtn) return; const tinySrc = 'https://cdn.tiny.cloud/1/fqnv67bzomwbq9c2746wnv4qgsi11vvump77vnetery2cq62/tinymce/7/tinymce.min.js'; function setButtons(editorOn) { enableBtn.style.display = editorOn ? 'none' : ''; disableBtn.style.display = editorOn ? '' : 'none'; } function loadTinyOnce() { if (window.tinymce) return Promise.resolve(); if (window.__tinyLoading) return window.__tinyLoading; window.__tinyLoading = new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = tinySrc; s.referrerPolicy = 'origin'; s.onload = () => resolve(); s.onerror = () => reject(new Error('Failed to load TinyMCE script')); document.head.appendChild(s); }); return window.__tinyLoading; } async function enableEditor() { try { await loadTinyOnce(); // prevent double init (including "in flight" clicks) if (window.__contentTinyInitStarted) return; if (tinymce.get('content')) { setButtons(true); return; } window.__contentTinyInitStarted = true; await tinymce.init({ selector: '#content', menubar: false, toolbar: 'undo redo | bold italic underline | alignleft aligncenter alignright | bullist numlist | link | removeformat | table', plugins: 'lists link table', branding: false, height: 400, link_default_target: '_blank' }); setButtons(true); } catch (e) { console.error(e); } } function disableEditor() { if (!window.tinymce) return; const ed = tinymce.get('content'); if (!ed) { setButtons(false); return; } ed.save(); // push HTML back into the textarea ed.remove(); // destroy editor window.__contentTinyInitStarted = false; setButtons(false); } enableBtn.addEventListener('click', enableEditor); disableBtn.addEventListener('click', disableEditor); // ensure latest editor content posts back const form = textarea.closest('form'); if (form) { form.addEventListener('submit', () => { const ed = window.tinymce && tinymce.get('content'); if (ed) ed.save(); }); } setButtons(false); // default: plain text }); Result: TinyMCE script is not even fetched until you click Enable editor, so your “editor loads” only happen when you actually use it. 3) Stop including TinyMCE CDN globally Do not include that Tiny Cloud That’s it. Nothing in . Nothing earlier. Important: do NOT include the Tiny Cloud script tag anymore Since your editor.js dynamically loads TinyMCE when you click the button, you should NOT include this: If you include it manually, it defeats the whole “only load when needed” design. Why bottom-of-body is superior to head If you put scripts in , the browser runs them before your textarea exists. That’s how people end up with: TinyMCE not attaching duplicate init attempts wasted editor loads Bottom-of-body avoids all of that cleanly. If you want, I can also show how to remember your last choice (“plain text vs editor”) so it automatically enables TinyMCE next time you open the form. You said: I have a separate 'content_add_code.php' for when my entries include code. I'd like a button to invoke that page with the current source_id; I'd like to place the button directly after the "enableEditorBtn". ChatGPT said: You just need a normal link styled as a button that carries the current source_id as a GET parameter. Don’t overcomplicate it. Assuming content_add.php already knows the current $source_id, do this: Simple, reliable solution (PHP renders the link) Place this immediately after your enableEditorBtn: Code entry Add CSS so it looks like your other buttons .button-link { display: inline-block; padding: 4px 10px; margin-left: 6px; font-size: 14px; font-family: inherit; color: black; background-color: #e0e0e0; border: 1px solid #999; border-radius: 3px; text-decoration: none; cursor: pointer; } .button-link:hover { background-color: #d0d0d0; } Now it behaves visually like a button but navigates properly. If source_id might not exist yet Guard it: Code entry If source_id comes from GET instead of a PHP variable Use: Code entry Best version (future-proof): preserve ALL context If your form also uses other parameters (author_id, chronology_id, etc.), preserve them: Code entry That way nothing gets lost when switching entry modes. If you want, I can also show how to add a “return to normal editor” button on content_add_code.php so switching modes is seamless. You said: These buttons are presenting differently on the page. I'd like to streamline them. Convert the "content_add_code.php" link to a button like the others. Code entry ChatGPT said: Use a real Now all three controls are actual Why this is the correct fix editor.js const codeBtn = document.getElementById('codeEntryBtn'); if (codeBtn) { codeBtn.addEventListener('click', () => { window.location.href = 'content_add_code.php?' + window.location.search.substring(1); }); } This avoids inline JS and keeps everything centralized. If you want, I can also make the “Code entry” button automatically hide when TinyMCE is enabled, since code entry and WYSIWYG editing usually conflict philosophically.