Both features show a list of the document’s headings. Both let you click a heading to jump to it. Both update as you write. Ask for “a table of contents” in a spec review and half the room pictures a sidebar and the other half pictures the second page of a contract.
They are not variants of one feature. They sit on opposite sides of the most important line in an editor: whether a thing is part of the document or part of the tool looking at it.
Chrome versus content
An outline panel is chrome. It belongs to the editing session. It renders beside the editable area, it is a navigation aid for the person writing, and it must never appear in getHTMLCode(). If it leaks into saved output you have put your editor’s furniture into the customer’s published page.
A table of contents is content. It is the contents page of a contract, a manual, a report. It is printed, exported to PDF, published to the web. It has to survive serialisation and read correctly in a context where none of your JavaScript is running.
Once stated that way the design decisions stop being arguable. Which one persists in saved HTML: the contents block, always; the panel, never. Which one is affected by tocMaxLevel: both, but they are separate settings, because how deep you want to navigate while writing has nothing to do with how deep the printed contents page should go.
This is the same distinction that governs a paginated page view. Page boundaries, headers and page numbers drawn on screen are presentational — the saved HTML is byte-identical whether page view is on or off. A contents block is the opposite kind of thing, and conflating the two is how you end up with either an editor that pollutes output or a contents page that vanishes on save.
Generated content should not be editable
A contents block is content, but it is derived content: every entry is a restatement of a heading elsewhere in the document. That makes hand-editing it a trap. If a user retitles an entry in the contents, either their edit is destroyed by the next rebuild, or it survives and the contents now disagrees with the document. Both outcomes are worse than not allowing the edit.
So the block is contenteditable="false" and rebuilt wholesale from the headings on every mutation. It becomes an atomic object: select it, delete it, re-insert it somewhere else. Because there is no caret inside it, the rebuild can be a blunt teardown-and-recreate, with no selection to preserve and no diffing to get subtly wrong.
The list of headings needs one filter that is easy to forget. The block contains a heading of its own — the word “Contents” — and if the document also has a generated footnotes section, that has a heading too. Collect headings naively and the contents page lists itself:
for (const h of editable.querySelectorAll("h1,h2,h3")) {
if (h.closest("nav.toc") || h.closest("section.footnotes")) continue; // generated
if (!h.textContent.trim()) continue; // empty heading
headings.push(h);
}The entry text is written as real text, and each heading gets a stable id that the entry links to. Same reasoning as footnote numbers: the artefact has to work once it leaves the editor, and nothing will regenerate it there.
Page numbers need a source of truth
A printed contents page has page numbers. A browser editor normally cannot offer them, because a contenteditableis a continuous scroll with no pages in it. If the editor has a real pagination engine, though, it already knows where every page break falls — and that is the only acceptable source for the number.
The important discipline is to derive the number from the same computation that draws the page boundaries the user can see. Not a parallel estimate from element heights, not a guess from scroll position. If the overlay draws a break at a given offset, the contents entry for the heading below it says the next page, by construction:
function pageOfElement(el) {
if (!pageViewActive) return null; // no pages: no page number
let block = el;
while (block.parentNode !== editable) block = block.parentNode;
const breaks = computeBreaks(editable, geometry()); // the same call the overlay uses
let page = 1;
for (const b of breaks) if (b <= block.offsetTop) page++;
return page;
}Note the walk up to the editable’s own child. Page breaks are computed between top-level blocks, so a heading nested inside a container has to be resolved to the block that actually sits in the flow, or itsoffsetTop is measured against the wrong offset parent.
Note also the null. Outside page view there are no pages, so there is no honest number to write. Inventing one — from an assumed page height, say — produces a contents page that is confidently wrong, which is worse than one that is silent.
Never delete what you cannot recompute
That last rule has a sharp edge, and we walked straight into it.
If page numbers are only written when page view is on, then the rebuild that runs when page view is offwrites none. Which means: author a document in page view, save it with a complete contents page, reopen it later with page view off — and the first rebuild silently strips every page number out of it. Loading a document destroyed part of it. The user did nothing but open the file.
The refusal to fabricate a number was right. Deleting the ones that had already been computed for real was not. The rebuild now carries forward any number it finds and cannot currently recompute:
// snapshot before the teardown
const priorPages = {};
for (const item of nav.querySelectorAll("li.toc-item")) {
const n = parseInt(item.querySelector(".toc-page")?.textContent, 10);
if (n) priorPages[item.querySelector("a").dataset.tocTarget] = n;
}
// ...rebuild...
if (!page && priorPages[id]) page = priorPages[id]; // keep, do not discardThe numbers can now be stale until the user re-enters page view. That is precisely how a word processor behaves — a contents field holds its last computed values until you tell it to update — and it is a far better failure mode than silent data loss. Staleness is visible and fixable. Deletion is neither.
Worth generalising, because it applies well beyond a contents block: a regeneration pass should treat data it cannot currently produce as data to preserve, not as data to remove. “I can’t compute this right now” and “this should not exist” are different conclusions, and code that conflates them destroys user work.
Ship both
None of this is an argument for choosing between them. A long document wants both: a panel for the author moving around a fifty-page contract, and a contents block for the reader who receives the PDF. They serve different people at different times, and because one is chrome and one is content they do not overlap at all.
RichTextEditor ships both in the base licence — the outline panel and an inserted contents block with page numbers in page view. CKEditor 5 documents table of contents under “Unlock this feature with selected CKEditor Plans”; TinyMCE’s Table of Contents plugin is “only available for paid TinyMCE subscriptions”. See the documentation or the live demo.
RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.