^
Update this timeline entry
Limit use of TinyMCE
Source: Amy and ChatGPT #1356
Project ID
Chapter
No chapter
Timeline title
Start date
End date
Filename received
Filename assigned
Content
Enable editor
Use plain text
Code entry
You said: TinyMCE Editor says I've reached half my limit for free usage of the editor on my private database web page 'https://chronology.amyjohoward.com/pages/content_add.php'. I expect to enter much more data, but rarely do I need the editor. The editor is attached in a JS file by window.tinymce.init({ selector: '#content'. 'content' is the name of the form field, the php variable, and mysql table column. Can I duplicate the textarea form field to enter data with or without the editor but keep the 'content' name on each for php and mysql? ChatGPT said: Yes — you can offer two ways to enter the same “content” without changing your PHP/MySQL column name, but you cannot have two
elements with the same name="content" and expect sane, predictable results. Here’s the reality: In an HTML form, multiple inputs with the same name submit as multiple values. PHP will usually give you only one when you read $_POST['content'] (typically the last one), unless you make it an array like content[]. TinyMCE also expects one real textarea per selector; duplicates can cause it to bind wrong or overwrite. So don’t duplicate the field name. Instead, keep one canonical field named content, and provide an alternate UI that writes into it. Best setup: one hidden canonical textarea + two “modes” You keep name="content" on the real field, and you give yourself: a plain textarea (no TinyMCE) an editor textarea (TinyMCE) …but only one is active at a time, and before submit you copy the active one into the canonical content. Example HTML
Plain text
Rich editor
Use plain
Use editor
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
Provide a button “Enable editor” Only then run tinymce.init({ selector:'#content' ... }) That alone may prevent you from hitting the limit if the limit is tied to editor loads/initializations. One more important point If you’re seeing a “half your free limit” warning, you’re probably not using the open-source TinyMCE build the way you think you are. That message usually shows up when: you’re loading TinyMCE from Tiny Cloud with an API key, or you enabled premium/cloud features. If you want, paste the ChatGPT said: Yeah — you’re on Tiny Cloud. That URL (cdn.tiny.cloud/1/
/...) is the hosted service, and the “half your free limit” warning is coming from your API key’s usage quota, not from anything in your database page. You’ve got three real options: Option 1 (best): stop using Tiny Cloud, self-host TinyMCE If you “rarely need the editor” and you control the site, self-hosting is the cleanest way to permanently kill quotas and nags. What to do Download TinyMCE Community (free) from TinyMCE’s site, or install via npm if you’re building assets. Put it somewhere like: /assets/tinymce/tinymce.min.js Change your script tag to your local copy: Everything else (your tinymce.init({ selector:'#content' })) can stay basically the same. Result: no API key, no cloud quota, no “free limit” warnings. Option 2: keep Tiny Cloud, but only initialize it when you actually want it This reduces usage if the quota is based on editor loads/initializations (often it is). Change: remove auto-init on page load Instead of initializing immediately, add a button:
Enable editor
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:
Enable editor
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”:
Enable editor
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
ed.save(); // Remove editor instance and return to plain textarea ed.remove(); // Allow re-enable later window.__contentTinyInitStarted = false; setButtons(false); } enableBtn.addEventListener('click', enableEditor); disableBtn.addEventListener('click', disableEditor); // Default is plain text setButtons(false); // Safety: if user submits while editor is on, make sure textarea has the latest content const form = editorEl.closest('form'); if (form) { form.addEventListener('submit', () => { const ed = window.tinymce && window.tinymce.get('content'); if (ed) ed.save(); }); } }); That’s it. Your PHP still reads $_POST['content'] and your DB column still stores content. No duplication, no arrays, no surprises. If you want the best quota protection: load TinyMCE script only when clicked This means no Tiny Cloud request unless you actually turn it on. Replace the