document.execCommand('indent') silently does nothing outside a list
TLDR
Executingdocument.execCommand('indent') on standard paragraph text (<p>) fails silently in most modern browsers without throwing an error or returning false. The browser implementation has defined behavior only for list items (<li>), where it nests elements into sub-lists (<ul>/<ol>). To support paragraph indentation, inspect selection DOM nodes before calling execCommand and insert a pre-formatted tab span (<span class="tab-char">\t</span>) for non-list selections.
| Selection Context | Native execCommand('indent') Behavior | Custom Fallback Strategy | Rendered Output |
|---|---|---|---|
List Item (<li>) | Nests <li> inside child <ul> | Native execCommand('indent') | Nested list structure |
Paragraph (<p>) | Silent No-Op (No DOM change) | Insert <span class="tab-char">\t</span> | Visual tab indent preserved |
Technical defect analysis
Rich text editor toolbars frequently bind UI indent buttons directly to browser DOM APIs:
// DEFECTIVE IMPLEMENTATION: Fails silently outside list contexts
function onIndentButtonClicked() {
document.execCommand('indent'); // Silent no-op when selection is in <p> or <div>!
}
Because execCommand('indent') lacks standardized DOM transformation behavior for non-list block elements, browsers exit the execution loop without mutating the DOM or indicating failure.
Inserting a raw \t text node into standard HTML flow collapses into a single space unless styled explicitly.
Remediation: context-aware indentation logic
Inspect selection context and insert a styled tab span (white-space: pre) when executing outside list structures:
export function executeContextAwareIndent() {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0); const containerNode = range.commonAncestorContainer; const element = containerNode.nodeType === Node.ELEMENT_NODE ? containerNode : containerNode.parentElement;
// Case 1: Selection lives inside a list item -> Native execCommand works if (element && element.closest('li')) { document.execCommand('indent'); return; }
// Case 2: Selection lives inside paragraph/block copy -> Insert explicit tab span const tabSpan = document.createElement('span'); tabSpan.className = 'tab-char'; tabSpan.textContent = '\t';
range.collapse(true); range.insertNode(tabSpan);
// Move cursor position immediately after inserted tab span range.setStartAfter(tabSpan); range.setEndAfter(tabSpan); selection.removeAllRanges(); selection.addRange(range); }
Required CSS rule
/ Ensure browser renders tab character width instead of collapsing to single space /
.tab-char {
white-space: pre;
}
Rule of thumb: Inspect selection node context before calling execCommand and provide explicit DOM fallback transformations for non-list selections.