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 <textarea> 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
<!-- Canonical field that PHP/MySQL uses -->
<textarea id="content" name="content" style="display:none;"></textarea>
<!-- Plain mode -->
<div id="plainWrap">
<label>Plain text</label>
<textarea id="content_plain"></textarea>
</div>
<!-- Rich mode -->
<div id="richWrap" style="display:none;">
<label>Rich editor</label>
<textarea id="content_rich"></textarea>
</div>
<button type="button" id="usePlain">Use plain</button>
<button type="button" id="useRich">Use editor</button>
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 <textarea name="content" id="content">
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 <script src="...tinymce..."> line (or where you include TinyMCE) and I’ll tell you immediately whether you’re on Tiny Cloud and what to change so this never nags you again.
You said:
<script src="https://cdn.tiny.cloud/1/fqnv67bzomwbq9c2746wnv4qgsi11vvump77vnetery2cq62/tinymce/7/tinymce.min.js" referrerpolicy="origin"></script>
ChatGPT said:
Yeah — you’re on Tiny Cloud. That URL (cdn.tiny.cloud/1/<APIKEY>/...) 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:
<script src="/assets/tinymce/tinymce.min.js"></script>
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:
<textarea id="content" name="content"></textarea>
<button type="button" id="enableEditor">Enable editor</button>
<script>
document.getElementById("enableEditor").addEventListener("click", () => {
if (!tinymce.get("content")) {
tinymce.init({ selector: "#content" });
}
});
</script>
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:
<textarea id="content" name="content"></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:
<script>
function initEditorOnce() {
if (window.tinymce && !tinymce.get('content')) {
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
});
}
}
document.addEventListener('DOMContentLoaded', initEditorOnce);
</script>
2) Add a “manual enable” switch (optional but powerful)
Default to plain textarea, and only initialize when you click:
<textarea id="content" name="content"></textarea>
<button type="button" id="enableEditor">Enable editor</button>
<script>
document.getElementById('enableEditor').addEventListener('click', () => {
if (!tinymce.get('content')) {
tinymce.init({ selector:'#content' });
}
});
</script>
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 <script src=...tiny.cloud...>
your tinymce.init(...)
and whether the page ever reloads via JS
…I’ll point to the exact reason you got the warning on day 1.
You said:
This is my current script:
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'
});
});
ChatGPT said:
You’re very close, but your guard is in the wrong place and it can throw on first run depending on timing.
What’s wrong with the current snippet
window.tinymce.get('content') can run before TinyMCE has loaded, which means window.tinymce might be undefined → boom.
Your snippet ends with }); which implies you meant to wrap this in an event handler (like DOMContentLoaded), but the wrapper isn’t shown. If that wrapper exists and the script is included twice, you still might init twice unless the guard is safe.
You’re using window.tinymce.get('content') as the guard. That’s fine only after TinyMCE exists.
Use this instead (safe + idempotent)
Drop this in exactly as-is:
<script>
(function () {
function initTinyOnce() {
// TinyMCE not loaded yet? bail safely.
if (!window.tinymce) return;
// Already initialized? don't do it again.
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'
});
}
// Run after DOM is ready
document.addEventListener('DOMContentLoaded', initTinyOnce);
// Also run immediately in case this script is placed at end of body
initTinyOnce();
})();
</script>
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”:
<textarea id="content" name="content"></textarea>
<button type="button" id="enableEditor">Enable editor</button>
<script>
document.getElementById('enableEditor').addEventListener('click', () => {
if (!window.tinymce) return;
if (tinymce.get('content')) return;
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'
});
});
</script>
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 <script src=...tinymce.min.js> and the init block on pages that actually have #content.
2) You’re initializing, then destroying, then initializing again
This happens a lot with collapsibles because TinyMCE does layout work and people “toggle” the container, then some code tries to “refresh” by re-initing.
I don’t see that in your file, but it could be elsewhere:
another JS file
inline scripts in the PHP page
a second include of scripts.js
3) Your page is being “reloaded” without a full browser reload
If you’re using any partial navigation (Turbo, PJAX, HTMX swaps, etc.), DOMContentLoaded might fire differently or your init code might be run multiple times by the framework. Again: not shown here, but common.
One concrete improvement you SHOULD make anyway
Right now your init is guarded by tinymce.get('content'). That prevents a second init only if the editor already exists.
But if the script runs twice before TinyMCE finishes creating the editor, both runs can pass the guard and both call tinymce.init().
That race is rare but absolutely real.
Fix: add a simple “init in progress” flag
Replace just this part:
// Prevent double init if scripts load twice
if (window.tinymce.get('content')) return;
window.tinymce.init({
...
});
with this:
// Prevent double init (even if init is still in-flight)
if (window.__contentTinyInitStarted) return;
if (window.tinymce.get('content')) return;
window.__contentTinyInitStarted = true;
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',
setup: (editor) => {
editor.on('init', () => {
// init succeeded; keep flag set or clear it—either is fine.
// keeping it set prevents any future re-init attempts.
console.log('TinyMCE initialized once.');
});
}
});
This kills the “double-init before get() returns true” problem.
If TinyMCE is inside a collapsed container: do THIS or you’ll suffer
TinyMCE doesn’t love being initialized while hidden (height/iframe sizing glitches). If your #content is inside a collapsed section, you have two sane choices:
Choice A (best): only init when the section is expanded
You already toggle expanded. So: when user opens the panel containing #content, then init.
You can do this without rewriting everything:
function initTinyOnce() {
const editorEl = document.querySelector('#content');
if (!editorEl) return;
if (!window.tinymce) return;
if (window.__contentTinyInitStarted) return;
if (window.tinymce.get('content')) return;
window.__contentTinyInitStarted = true;
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'
});
}
// inside your collapsible click handler, after toggling expanded:
collapsibleItem.addEventListener("click", function() {
this.classList.toggle("active");
const content = this.nextElementSibling;
content.classList.toggle('expanded');
// if the editor is in the expanded panel, init now
if (content.classList.contains('expanded') && content.querySelector('#content')) {
initTinyOnce();
}
});
Bonus: This also reduces editor loads because you only initialize when you actually open that section.
Choice B: keep init on load, but refresh sizing when expanded
If you insist on initializing while hidden, you’ll often need:
const ed = tinymce.get('content');
if (ed) ed.execCommand('mceAutoResize'); // or ed.theme.resizeTo(...)
…but honestly, Choice A is cleaner.
How to prove where the loads are coming from (no guessing)
Add this once, temporarily, right before tinymce.init(...):
console.log('Calling tinymce.init for #content', new Date().toISOString(), location.href);
If you see that log more than once per real page load, you’ve found the issue. Then search your codebase for tinymce.init and for repeated includes of scripts.js.
My blunt recommendation
Keep Tiny Cloud for the month only if you implement the __contentTinyInitStarted flag and (ideally) init-on-expand. Otherwise you’re doing a “wait and see” test with a leaky bucket and the results will be garbage.
If you paste the relevant HTML from content_add.php (the script tags at bottom/top and where the collapsible wraps #content), I can point to the exact likely double-load path in one shot.
You said:
I'd rather load the TinyMCE with a button click when I need it, and default to plain text entry on the content_add form.
ChatGPT said:
Do it. That’s the cleanest way to keep your editor-load count honest (and it avoids the “initialized while hidden” headache with collapsibles).
Here’s a drop-in pattern that:
Defaults to plain <textarea> (your existing name="content" stays untouched for PHP/MySQL)
Loads TinyMCE only when you click a button
Lets you disable the editor and go back to plain text
Prevents double-init, even if buttons get clicked twice
Works fine inside your collapsible setup
1) In content_add.php add the buttons (near the textarea)
Keep your existing textarea exactly as your backend expects:
<label for="content">Content</label>
<div class="editor-controls">
<button type="button" id="enableEditorBtn">Enable editor</button>
<button type="button" id="disableEditorBtn" style="display:none;">Use plain text</button>
</div>
<textarea id="content" name="content"></textarea>
2) Only include Tiny Cloud script on this page (but it won’t “run” until clicked)
Put this near the bottom of content_add.php (before your scripts.js ideally):
<script src="https://cdn.tiny.cloud/1/fqnv67bzomwbq9c2746wnv4qgsi11vvump77vnetery2cq62/tinymce/7/tinymce.min.js" referrerpolicy="origin"></script>
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 <textarea>
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 <script src="https://cdn.tiny.cloud/..."> tag with nothing, and swap enableEditor() to dynamically load it:
function loadScriptOnce(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[data-src="${src}"]`)) return resolve();
const s = document.createElement('script');
s.src = src;
s.referrerPolicy = 'origin';
s.dataset.src = src;
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
}
async function enableEditor() {
const src = 'https://cdn.tiny.cloud/1/fqnv67bzomwbq9c2746wnv4qgsi11vvump77vnetery2cq62/tinymce/7/tinymce.min.js';
await loadScriptOnce(src);
// ...same init code as above...
}
This version is the “I only pay when I actually use it” model.
If your textarea is inside a collapsed panel, this on-click approach also avoids the common TinyMCE “wrong height/blank iframe” nonsense because you’ll naturally click after you’ve opened the section.
You said:
I don't see any reason to blend the collapsible function with TinyMCE. The collapsible sections are on read-only pages.
ChatGPT said:
Fair. If TinyMCE is only on content_add.php (data-entry) and collapsibles are only on read-only pages, then separate them. Mixing them just increases the chance you accidentally double-run scripts and burn editor loads.
Do this cleanly:
1) Split your JS into two files
collapsible.js (read-only pages only)
Put your collapsible + expand/collapse-all code here (unchanged).
editor.js (content_add.php only)
Put TinyMCE on-demand code here.
Then only include what each page needs.
2) editor.js (TinyMCE only loads on button click)
This version does not auto-init, and it also ensures the textarea is synced on submit.
HTML on content_add.php:
<button type="button" id="enableEditorBtn">Enable editor</button>
<button type="button" id="disableEditorBtn" style="display:none;">Use plain text</button>
<textarea id="content" name="content"></textarea>
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 <script src=...> in a sitewide header/footer. Only load it on content_add.php (or not at all, since editor.js dynamically loads it).
If you tell me how you include JS today (common header include vs per-page), I’ll tell you exactly where to cut so TinyMCE never loads anywhere except the one form page.
You said:
I have no common header. I separated the scripts file and put the editor buttons on content_add.php. Where do I put the <script> tag on that page?
ChatGPT said:
Put your <script> tag at the very end of content_add.php, just before </body>.
That’s the correct place because:
The <textarea id="content"> and buttons already exist in the DOM when the script runs
No need for hacks, delays, or race-condition guards
It avoids accidental double-init
It keeps page load fast
Exact structure example
<!DOCTYPE html>
<html>
<head>
<title>Add Content</title>
</head>
<body>
<form method="post" action="content_add.php">
<button type="button" id="enableEditorBtn">Enable editor</button>
<button type="button" id="disableEditorBtn" style="display:none;">Use plain text</button>
<textarea id="content" name="content"></textarea>
<input type="submit" value="Save">
</form>
<!-- Your editor script goes HERE, at the very end -->
<script src="/js/editor.js"></script>
</body>
</html>
That’s it. Nothing in <head>. 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:
<script src="https://cdn.tiny.cloud/.../tinymce.min.js"></script>
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 <head>, 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:
<button type="button" id="enableEditorBtn">Enable editor</button>
<a href="content_add_code.php?source_id=<?php echo urlencode($source_id); ?>"
class="button-link"
id="codeEntryBtn">
Code entry
</a>
<button type="button" id="disableEditorBtn" style="display:none;">Use plain text</button>
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:
<?php if (!empty($source_id)): ?>
<a href="content_add_code.php?source_id=<?php echo urlencode($source_id); ?>"
class="button-link"
id="codeEntryBtn">
Code entry
</a>
<?php endif; ?>
If source_id comes from GET instead of a PHP variable
Use:
<?php $source_id = $_GET['source_id'] ?? ''; ?>
<a href="content_add_code.php?source_id=<?php echo urlencode($source_id); ?>"
class="button-link">
Code entry
</a>
Best version (future-proof): preserve ALL context
If your form also uses other parameters (author_id, chronology_id, etc.), preserve them:
<a href="content_add_code.php?<?php echo http_build_query($_GET); ?>"
class="button-link">
Code entry
</a>
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.
<button type="button" id="enableEditorBtn" class="button-link">Enable editor</button>
<button type="button" id="disableEditorBtn" class="button-link" style="display:none;">Use plain text</button>
<a href="content_add_code.php?<?php echo http_build_query($_GET); ?>"class="button-link">Code entry</a>
ChatGPT said:
Use a real <button> and navigate with JavaScript. That keeps styling and behavior consistent with your other buttons.
Replace your <a> with this:
<button type="button"
id="codeEntryBtn"
class="button-link"
onclick="window.location.href='content_add_code.php?<?php echo http_build_query($_GET); ?>'">
Code entry
</button>
Now all three controls are actual <button> elements:
<button type="button" id="enableEditorBtn" class="button-link">Enable editor</button>
<button type="button" id="disableEditorBtn" class="button-link" style="display:none;">
Use plain text
</button>
<button type="button"
id="codeEntryBtn"
class="button-link"
onclick="window.location.href='content_add_code.php?<?php echo http_build_query($_GET); ?>'">
Code entry
</button>
Why this is the correct fix
<button> elements share identical rendering rules
Your .button-link class applies consistently
No anchor-specific styling quirks
No form submission (because type="button" prevents submit)
Keeps the full query string intact via http_build_query($_GET)
Even cleaner (recommended): move navigation to editor.js
This keeps behavior out of HTML:
HTML
<button type="button" id="codeEntryBtn" class="button-link">Code entry</button>
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.