Tips / Consistent link insertion and editing across browsers

Consistent link insertion and editing across browsers

How to create, edit, and remove links in contenteditable elements with consistent behavior across all browsers

Difficulty: Intermediate
Category: formatting
linkanchorhrefformattingbrowser-compatibilitynested-links

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

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);
}

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
    }
  }
});

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();
  }
});

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);

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 formatCreateLink input type is triggered by browser’s native link creation (Ctrl+K in some editors)
Edit on GitHub