Consistent link insertion and editing across browsers
How to create, edit, and remove links in contenteditable elements with consistent behavior across all browsers
Problem
When inserting or editing links in contenteditable elements, browser behavior varies significantly. Creating links from selected text, editing link text, and removing links can result in unexpected DOM structures, nested links (which are invalid HTML), or lost formatting. Firefox is more likely to create nested links, while Safari has the most inconsistent behavior.
Solution
1. Custom Link Creation Handler
Intercept the formatCreateLink input type to create links safely:
const editor = document.querySelector('div[contenteditable]');
editor.addEventListener('beforeinput', (e) => {
if (e.inputType === 'formatCreateLink') {
e.preventDefault();
const selection = window.getSelection();
if (selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const selectedText = range.toString();
if (!selectedText) {
// No selection, prompt for URL to create link
const url = prompt('Enter URL:');
if (url) {
insertLinkAtCursor(url, url);
}
return;
}
// Get URL from user
const url = prompt('Enter URL:', 'https://');
if (url) {
createLinkSafely(range, url, selectedText);
}
}
});
function createLinkSafely(range, url, text) {
// Check if selection is already inside a link
let ancestor = range.commonAncestorContainer;
if (ancestor.nodeType === Node.TEXT_NODE) {
ancestor = ancestor.parentNode;
}
const existingLink = ancestor.closest('a');
if (existingLink) {
// Remove existing link first to avoid nesting
unwrapLink(existingLink);
// Recalculate range after unwrapping
const selection = window.getSelection();
if (selection.rangeCount > 0) {
range = selection.getRangeAt(0);
}
}
// Extract selected content
const contents = range.extractContents();
// Create new link
const link = document.createElement('a');
link.href = url;
link.textContent = text || url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
// Insert link
range.insertNode(link);
// Move cursor after link
const newRange = document.createRange();
newRange.setStartAfter(link);
newRange.collapse(true);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(newRange);
}
function unwrapLink(link) {
const parent = link.parentNode;
while (link.firstChild) {
parent.insertBefore(link.firstChild, link);
}
parent.removeChild(link);
}
2. Safe Link Editing
Handle text editing within links to prevent structure breaking:
const editor = document.querySelector('div[contenteditable]');
editor.addEventListener('input', (e) => {
// Check if input occurred inside a link
const selection = window.getSelection();
if (selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
let container = range.commonAncestorContainer;
if (container.nodeType === Node.TEXT_NODE) {
container = container.parentNode;
}
const link = container.closest('a');
if (!link) return;
// Check if link is now empty or only whitespace
const linkText = link.textContent.trim();
if (!linkText) {
// Remove empty link
unwrapLink(link);
} else {
// Ensure link still has href
if (!link.href || link.href === '') {
link.href = linkText; // Use text as URL fallback
}
}
});
3. Link Removal Handler
Safely remove links while preserving text:
function removeLink() {
const selection = window.getSelection();
if (selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
let container = range.commonAncestorContainer;
if (container.nodeType === Node.TEXT_NODE) {
container = container.parentNode;
}
const link = container.closest('a');
if (link) {
unwrapLink(link);
// Restore cursor position
const newRange = document.createRange();
newRange.setStart(link.parentNode, 0);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
}
}
// Bind to keyboard shortcut (e.g., Ctrl+K or custom command)
editor.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
removeLink();
}
});
4. Comprehensive Link Manager
A complete solution that handles all link operations:
class LinkManager {
constructor(editor) {
this.editor = editor;
this.init();
}
init() {
this.editor.addEventListener('beforeinput', this.handleBeforeInput.bind(this));
this.editor.addEventListener('input', this.handleInput.bind(this));
this.editor.addEventListener('keydown', this.handleKeyDown.bind(this));
}
handleBeforeInput(e) {
if (e.inputType === 'formatCreateLink') {
e.preventDefault();
this.createLink();
}
}
handleInput(e) {
// Clean up empty links
this.cleanupEmptyLinks();
// Prevent nested links
this.preventNestedLinks();
}
handleKeyDown(e) {
// Remove link with Ctrl+K (or custom shortcut)
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
this.removeLink();
}
}
createLink() {
const selection = window.getSelection();
if (selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const selectedText = range.toString();
if (!selectedText) {
const url = prompt('Enter URL:');
if (url) {
this.insertLinkAtCursor(url, url);
}
return;
}
const url = prompt('Enter URL:', 'https://');
if (url) {
this.createLinkSafely(range, url, selectedText);
}
}
createLinkSafely(range, url, text) {
// Remove any existing link in selection
this.removeLinksInRange(range);
// Extract contents
const contents = range.extractContents();
// Create link
const link = document.createElement('a');
link.href = url;
link.textContent = text || url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
// Insert link
range.insertNode(link);
// Move cursor after link
this.setCursorAfter(link);
}
insertLinkAtCursor(url, text) {
const selection = window.getSelection();
if (selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
range.deleteContents();
const link = document.createElement('a');
link.href = url;
link.textContent = text || url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
range.insertNode(link);
this.setCursorAfter(link);
}
removeLink() {
const selection = window.getSelection();
if (selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const link = this.getLinkInRange(range);
if (link) {
this.unwrapLink(link);
}
}
removeLinksInRange(range) {
// Find all links in range and unwrap them
const contents = range.cloneContents();
const links = contents.querySelectorAll('a');
links.forEach(link => {
const actualLink = this.editor.querySelector(`a[href="${link.href}"]`);
if (actualLink) {
this.unwrapLink(actualLink);
}
});
}
unwrapLink(link) {
const parent = link.parentNode;
const nextSibling = link.nextSibling;
while (link.firstChild) {
parent.insertBefore(link.firstChild, nextSibling);
}
parent.removeChild(link);
}
getLinkInRange(range) {
let container = range.commonAncestorContainer;
if (container.nodeType === Node.TEXT_NODE) {
container = container.parentNode;
}
return container.closest('a');
}
cleanupEmptyLinks() {
const links = this.editor.querySelectorAll('a');
links.forEach(link => {
const text = link.textContent.trim();
if (!text) {
this.unwrapLink(link);
}
});
}
preventNestedLinks() {
const links = this.editor.querySelectorAll('a a');
links.forEach(nestedLink => {
// Unwrap inner link
this.unwrapLink(nestedLink);
});
}
setCursorAfter(node) {
const range = document.createRange();
range.setStartAfter(node);
range.collapse(true);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
dispose() {
this.editor.removeEventListener('beforeinput', this.handleBeforeInput);
this.editor.removeEventListener('input', this.handleInput);
this.editor.removeEventListener('keydown', this.handleKeyDown);
}
}
// Usage
const editor = document.querySelector('div[contenteditable]');
const linkManager = new LinkManager(editor);
Optional: <span> + data-href (or class) instead of <a> while editing
This site’s examples and the model-schema link use <a href> for links: native semantics, sensible copy/paste, and screen readers recognize real links when exported.
Some production editors still render links as <span data-href="...">, a styled span with URL in a data attribute (or a custom class + JS), only inside the editing surface, then convert to <a> (or Markdown) on save/publish. Reasons teams do this include:
- Avoiding default navigation when the user clicks to place the caret (you handle
click/Ctrl+Clickyourself). - Reducing caret-in-anchor quirks (selection stuck at boundaries, browser-specific behavior documented under link click editing).
- Keeping invalid nested
<a>out of the DOM until serialization merges adjacent link runs.
Trade-offs: a span is not a native link for accessibility until you export; clipboard HTML may not round-trip; you must sanitize data-href like any URL. This catalog does not treat span-based links as the default pattern—it is an intentional editor-internal representation, not a replacement for semantic <a> in published HTML.
For a full scenario write-up (including Lexical/Tiptap/Slate defaults and external references), see scenario-link-span-internal-representation and pattern case ce-0587.
Notes
- Nested links (
<a><a></a></a>) are invalid HTML and should always be prevented - Firefox is more prone to creating nested links, so extra care is needed
- Safari has the most inconsistent behavior, so comprehensive handling is essential
- Always check if a selection is already inside a link before creating a new one
- Empty links should be removed to keep the DOM clean
- Consider preserving link attributes like
title,rel, or custom data attributes when unwrapping - Test link operations in all major browsers to ensure consistency
- The
formatCreateLinkinput type is triggered by browser’s native link creation (Ctrl+K in some editors)