EditContext API: A New Foundation for Custom Web Editors

Script Proofread And Sentence Grammar Spell Check
The experimental EditContext API gives developers a new foundation for building custom rich text editors by separating text input and selection from rendering. Instead of relying on contenteditable, you attach an EditContext to a focusable element and manage your own text model, selection state, and UI updates—while still receiving browser-grade events for typing, caret movement, and IME composition. This demo highlights the core event flow and why character bounds matter for accurate input UI, especially in custom-rendered editors.

Building a great text editor on the web has traditionally meant choosing between two imperfect options: rely on native DOM editing (like contenteditable) and accept limited control, or render everything yourself (often on <canvas>) and spend a lot of effort recreating input behavior—especially for selection, accessibility, and IME (Input Method Editor) support.

The experimental EditContext API aims to bridge that gap. It gives you a browser-supported pipeline for text input and selection—while letting you own the rendering.

What the EditContext API does

At a high level, EditContext acts like a model for editable text:

Instead of the browser directly mutating the DOM (as with contenteditable), the browser routes editing operations through your EditContext, and you decide how to update and draw the content.

Why this matters (especially beyond contenteditable)

If you’re building a custom editor (or anything that behaves like one), you often want:

The EditContext API is designed for exactly those “custom editor” cases—where native editing is either too opinionated or not available.

				
					<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Device Orientation API Demo</title>
    <link data-wphbdelayedstyle="style.css" rel="stylesheet" />
    <script type="wphb-delay-type" src="editContextAPI.js" defer></script>
</head>
    
<body>
     <h1>EditContext API Demonstration</h1>
    <p>
        Welcome to a demo of the experimental <a href="https://developer.mozilla.org/en-US/docs/Web/API/EditContext_API" target="_blank" rel="noopener noreferrer">EditContext API</a>.
        This API empowers web developers to build custom rich text editors by providing fine-grained control
        over text input, selection, and rendering, especially useful for elements like <code>&lt;canvas&gt;</code>.
    </p>
    <p>
        <strong>Note:</strong> The EditContext API is an experimental technology and might not be supported in all browsers
        without enabling experimental web platform features. This demo includes a basic support check.
    </p>
    <p>
        Try typing in the "Editable Area" below, selecting text, or using system-level input methods (like IME
        for East Asian languages, if configured on your system). Observe how the "Debug Output" reflects the internal
        state changes.
    </p>

    <h2>Editable Area</h2>
    <!--
        This div will serve as our custom editable region.
        It has a tabindex="0" to make it focusable by default.
        We will attach an EditContext instance to it in JavaScript.
    -->
    <div id="editable-area" tabindex="0"></div>

    <h2>Debug Output</h2>
    <!-- This section will display the current internal state managed by EditContext -->
    <div class="debug-output">
        <p><strong>Current Text:</strong> <span id="current-text"></span></p>
        <p><strong>Selection Start Index:</strong> <span id="selection-start"></span></p>
        <p><strong>Selection End Index:</strong> <span id="selection-end"></span></p>
        <p><em>(Console will show additional event details)</em></p>
    </div>
<script type="text/javascript" id="wphb-delayed-styles-js">
			(function () {
				const events = ["keydown", "mousemove", "wheel", "touchmove", "touchstart", "touchend"];
				function wphb_load_delayed_stylesheets() {
					document.querySelectorAll("link[data-wphbdelayedstyle]").forEach(function (element) {
						element.setAttribute("href", element.getAttribute("data-wphbdelayedstyle"));
					}),
						 events.forEach(function (event) {
						  window.removeEventListener(event, wphb_load_delayed_stylesheets, { passive: true });
						});
				}
			   events.forEach(function (event) {
				window.addEventListener(event, wphb_load_delayed_stylesheets, { passive: true });
			  });
			})();
		</script></body>

</html>
				
			
				
					body {
    font-family: Arial, sans-serif;
    margin: 20px;
    background-color: #f4f4f4;
    color: #333;
}

h1,
h2 {
    color: #0056b3;
}

p {
    line-height: 1.5;
}

/* Styling for the custom editable area */
#editable-area {
    border: 2px solid #a0a0a0;
    /* Default border */
    min-height: 100px;
    padding: 15px;
    margin-top: 15px;
    margin-bottom: 25px;
    background-color: white;
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    cursor: text;
    /* Indicate it's a text input area */
    /* Crucial: remove default outline, EditContext handles focus visually */
    outline: none;
    /* Make it focusable programmatically and via tabindex */
    tabindex: 0;
    white-space: pre-wrap;
    /* Preserve whitespace and allow wrapping */
    font-size: 16px;
    line-height: 1.5;
}

/* Highlight when the editable area is focused */
#editable-area:focus {
    border-color: dodgerblue;
    box-shadow: 0 0 0 3px rgba(30, 144, 255, 0.4);
}

/* Styling for the debug output section */
.debug-output {
    background-color: #e9e9e9;
    padding: 15px;
    border: 1px solid #d0d0d0;
    border-radius: 5px;
    white-space: pre-wrap;
    /* Preserve whitespace in output */
    font-family: 'Courier New', monospace;
    font-size: 14px;
    color: #555;
    margin-top: 15px;
}

.debug-output strong {
    color: #222;
}
				
			
				
					// --- Feature Detection ---
// Always good practice to check if an experimental API is supported.
if (!('EditContext' in window)) {
	alert(
		'Your browser does not support the EditContext API. This demo will not function as expected.\n\nPlease try a browser that supports this experimental API (e.g., Chrome with "Experimental Web Platform features" enabled via chrome://flags).'
	);
	document.body.innerHTML = `
		<h1>EditContext API is not supported in your browser.</h1>
		<p>Please try a browser that supports this experimental API (e.g., Chrome with experimental web platform features enabled).</p>
		<p>You can find more information about its current status on the <a href="https://developer.mozilla.org/en-US/docs/Web/API/EditContext_API" target="_blank" rel="noopener noreferrer">MDN Web Docs</a>.</p>
	`;
	throw new Error('EditContext API not supported.');
}

// --- DOM Element References ---
const editableArea = document.querySelector('#editable-area');
const currentTextSpan = document.querySelector('#current-text');
const selectionStartSpan = document.querySelector('#selection-start');
const selectionEndSpan = document.querySelector('#selection-end');

// --- Step 1: Create a new EditContext instance ---
// The EditContext object acts as the "model" for your editable region.
// It manages the text content, selection, and formatting information.
const editContext = new EditContext();

// --- Step 2: Attach the EditContext to the desired DOM element ---
// This is the core step. By assigning an EditContext instance to an
// element's .editContext property, you inform the browser that this
// element should receive text input via EditContext.
// The browser will then route keyboard input and IME composition
// events to this EditContext instance, rather than directly to the DOM element's
// content if it were, for example, `contenteditable="true"`.
editableArea.editContext = editContext;

// --- Internal State Management ---
// These variables represent our editor's "model" derived from EditContext events.
// In a real editor, this might be a more complex data structure.
let currentContent = 'Start typing here...';
let currentSelectionStart = currentContent.length;
let currentSelectionEnd = currentContent.length;
// ADDITION: Add a flag to track if the initial placeholder content is still present.
let isInitialPlaceholder = true;

// --- Function to Update the Visuals ---
// This function is responsible for rendering the `currentContent` and
// `currentSelection` to the `editableArea` and updating the debug output.
// With EditContext, *you* are responsible for the rendering!
const updateDisplay = () => {
	// Update the debug output section
	// SUGGESTION: Changed JSON.stringify(currentContent) to currentContent for cleaner debug output.
	currentTextSpan.textContent = currentContent;
	selectionStartSpan.textContent = currentSelectionStart;
	selectionEndSpan.textContent = currentSelectionEnd;

	// Update the visual content of the editable div.
	// This will update the DOM and might remove textNode if content is empty
	editableArea.textContent = currentContent;

	const selection = window.getSelection();
	selection.removeAllRanges(); // Always clear existing selections first

	try {
		// ADDITION: Explicitly handle the case when content is empty to ensure caret placement
		if (currentContent.length === 0) {
			// If the content is empty, editableArea.textContent = '' will remove all child nodes.
			// We need to re-add an empty text node to allow for caret placement at index 0.
			const emptyTextNode = document.createTextNode('');
			editableArea.appendChild(emptyTextNode);
			const range = document.createRange();
			range.setStart(emptyTextNode, 0);
			range.setEnd(emptyTextNode, 0);
			selection.addRange(range);
		} else {
			// For non-empty content, proceed as usual
			// After editableArea.textContent = currentContent, firstChild will be the new TextNode
			const textNode = editableArea.firstChild;
			if (textNode && textNode.nodeType === Node.TEXT_NODE) {
				const range = document.createRange();
				// Ensure selection indices don't exceed the actual text node length
				const actualStart = Math.min(currentSelectionStart, textNode.length);
				const actualEnd = Math.min(currentSelectionEnd, textNode.length);

				range.setStart(textNode, actualStart);
				range.setEnd(textNode, actualEnd);
				selection.addRange(range);
			} else {
				console.warn(
					'Editable area has content but no valid textNode. Could not set selection properly.'
				);
				// Fallback: place cursor at the very beginning of the editableArea if no textNode is found
				const range = document.createRange();
				range.setStart(editableArea, 0);
				range.setEnd(editableArea, 0);
				selection.addRange(range);
			}
		}
	} catch (e) {
		console.error('Error setting DOM selection:', e);
		// If an error occurs, selection was already removed at the start of the function.
		// The browser might default caret placement.
	}
};

// --- EditContext Event Listeners ---
// These events are fired by the EditContext instance, informing our
// custom editor about user input and selection changes.

// --- EditContext Event Listeners ---
// These events are fired by the EditContext instance, informing our
// custom editor about user input and selection changes.

// Event: 'textupdate'
// Fired when the text content managed by the EditContext needs to be updated.
// This is where you receive the actual text typed by the user or composed by an IME.
editContext.addEventListener('textupdate', (event) => {
	console.log('EditContext: textupdate event received', event);

	let effectiveUpdateStart = event.updateRangeStart;
	let effectiveUpdateEnd = event.updateRangeEnd;
	const newText = event.text;

	// ADDITION: Re-incorporate the placeholder clearing logic
	if (isInitialPlaceholder) {
		currentContent = ''; // Clear the placeholder content
		// Adjust the effective update range to apply to the now empty string.
		// This ensures the new text is inserted correctly at the beginning,
		// effectively replacing the placeholder entirely.
		effectiveUpdateStart = 0;
		effectiveUpdateEnd = 0;
		isInitialPlaceholder = false; // The placeholder is no longer active
	}

	// Apply the text update to our internal `currentContent` model.
	// This logic correctly handles both insertions and deletions based on the effective range.
	currentContent =
		currentContent.substring(0, effectiveUpdateStart) +
		newText +
		currentContent.substring(effectiveUpdateEnd);

	// Adopt the suggested new selection position after the text update.
	// Use event.selectionStart and event.selectionEnd
	currentSelectionStart = event.selectionStart; // Corrected property name
	currentSelectionEnd = event.selectionEnd; // Corrected property name

	// --- Diagnostic Log ---
	console.log(
		`EditContext: TextUpdate - newSelectionStart: ${currentSelectionStart}, newSelectionEnd: ${currentSelectionEnd}`
	);

	// Re-render the editor's display to reflect the changes.
	updateDisplay();
});

// Event: 'selectionchange'
// Fired when the text selection within the editable content changes.
// This happens when the user clicks, drags, or uses arrow keys to move the cursor.
editContext.addEventListener('selectionchange', (event) => {
	console.log('EditContext: selectionchange event received', event);

	// `start` and `end`: The new start and end indices of the selection.
	currentSelectionStart = event.start;
	currentSelectionEnd = event.end;

	// --- Diagnostic Log ---
	console.log(
		`EditContext: SelectionChange - start: ${currentSelectionStart}, end: ${currentSelectionEnd}`
	);

	// Re-render the display to show the updated selection.
	updateDisplay();
});

// Event: 'characterboundsupdate'
// Fired when the browser needs to know the precise pixel bounds of characters
// within a given range of text. This is critical for positioning things like
// IME composition windows, spell-check underlines, or selection handles,
// especially when rendering text on a <canvas> or with complex CSS.
editContext.addEventListener('characterboundsupdate', (event) => {
	console.log('EditContext: characterboundsupdate event received', event);

	// `range`: The range of characters for which bounds are requested.
	// In a canvas-based editor, you would compute and return actual
	// DOMRect objects for each character's position.
	// For this <div> example, we'll log it, but the browser usually
	// handles basic character bounds for native DOM elements.
	const requestedStart = event.range.start;
	const requestedEnd = event.range.end;

	const charBounds = [];
	const textNode = editableArea.firstChild;
	if (textNode && textNode.nodeType === Node.TEXT_NODE) {
		for (let i = requestedStart; i < requestedEnd; i++) {
			// Create a temporary range for each character to get its bounding box.
			// This is a simplified approach; a robust solution would pre-calculate
			// these or use a text layout engine.
			const tempRange = document.createRange();
			tempRange.setStart(textNode, i);
			tempRange.setEnd(textNode, i + 1);
			const rect = tempRange.getBoundingClientRect();

			// Push a DOMRect-like object.
			charBounds.push({
				x: rect.x,
				y: rect.y,
				width: rect.width,
				height: rect.height,
				left: rect.left,
				top: rect.top,
				right: rect.right,
				bottom: rect.bottom
			});
		}
	}

	// If you were explicitly providing bounds (e.g., for canvas), you would call:
	// event.updateCharacterBounds(requestedStart, charBounds);
	// Since we're using a div, we let the browser handle the actual rendering implicitly,
	// but logging shows the event is active.
	console.log(
		`  Requested bounds for characters ${requestedStart} to ${requestedEnd}:`,
		charBounds
	);
});

// --- IME Composition Events (Informational) ---
// These events provide more granular control during IME composition,
// allowing you to render the interim composition string (e.g., underlined text).
// Fully implementing composition rendering is complex but these events are key.
editContext.addEventListener('compositionstart', (event) => {
	console.log('EditContext: compositionstart event received', event);
	// You might change the editor's visual state to indicate composition.
});

editContext.addEventListener('compositionupdate', (event) => {
	console.log('EditContext: compositionupdate event received', event);
	// `event.text` will contain the current composition string.
	// `event.textFormatUpdates` describes how to style parts of the composition.
	// This is where you would typically render the composition text.
});

editContext.addEventListener('compositionend', (event) => {
	console.log('EditContext: compositionend event received', event);
	// Composition has finished. A 'textupdate' event will follow with the final text.
});

// Event: 'textformatupdate'
// Fired when text formatting information changes, usually related to IME composition
// (e.g., applying underlines to candidate characters).
editContext.addEventListener('textformatupdate', (event) => {
	console.log('EditContext: textformatupdate event received', event);
	// `event.formatRange` indicates the range of text whose format changed.
	// `event.textFormatUpdates` is an array of `TextFormat` objects to apply.
	// Implementing this visually requires complex text rendering logic.
});

// --- Initial Setup ---
// Perform the initial display update to show the starting content.
updateDisplay();

// Focus the editable area when the page loads, for convenience in the demo.
editableArea.focus();

				
			

Demo Highlights

The EditContext API code above centers around a handful of events that drive your editor’s behavior: textupdate delivers the actual text edits (insertions/deletions or committed IME text) along with the range to replace and the suggested post-update selection, selectionchange reports caret/selection movement from mouse or keyboard navigation, composition events (compositionstart, compositionupdate, compositionend) expose the lifecycle of IME input so you can display and style in-progress composition text, and characterboundsupdate requests pixel-accurate character rectangles so the browser can correctly position IME candidate windows and related UI—especially important when you render text yourself (e.g., on <canvas>).

The experimental EditContext API gives web developers a new way to build fully custom text editors by separating input/selection handling from rendering: the browser routes typing, selection changes, and IME composition through an EditContext, while your code maintains the text/selection state and renders it however you want (including in non-DOM scenarios like <canvas>).

More To Explore

Script Proofread And Sentence Grammar Spell Check
Code

EditContext API: A New Foundation for Custom Web Editors

The experimental EditContext API gives developers a new foundation for building custom rich text editors by separating text input and selection from rendering. Instead of relying on contenteditable, you attach an EditContext to a focusable element and manage your own text model, selection state, and UI updates—while still receiving browser-grade events for typing, caret movement, and IME composition. This demo highlights the core event flow and why character bounds matter for accurate input UI, especially in custom-rendered editors.

Share This Post

small_c_popup.png

Need help?

Let's have a chat...


Login

Jump Back In!

Here at Webolution Designs, we love to learn. This includes sharing things we have learned with you. 

Register

Begin Your Learning Journey Today!

Come back inside to continue your learning journey.