Mobile Support

Guide to supporting mobile devices in contenteditable editors.

Overview

Mobile devices present unique challenges: virtual keyboards, touch interactions, different IME behavior, and viewport constraints. This guide covers mobile-specific considerations.

Key challenges:

  • Virtual keyboard resizes viewport
  • Touch selection is less precise than mouse
  • IME composition events don't fire on iOS Safari for Korean
  • Text prediction and autocorrect interfere with input
  • Different keyboard apps have different behavior

Virtual Keyboard

Handle virtual keyboard appearance and viewport changes.

Keyboard Detection

class VirtualKeyboardDetector {
  #viewportHeight = window.visualViewport?.height || window.innerHeight;
  #isKeyboardVisible = false;

  constructor(editor: Editor) {
    this.#editor = editor;
    this.#setupDetection();
  }

  #setupDetection() {
    if (!window.visualViewport) {
      // Fallback for browsers without visualViewport
      window.addEventListener('resize', () => this.#detectKeyboard());
      return;
    }

    window.visualViewport.addEventListener('resize', () => {
      const newHeight = window.visualViewport.height;
      const heightDiff = this.#viewportHeight - newHeight;

      if (heightDiff > 150) {
        // Keyboard appeared
        this.#onKeyboardShow();
      } else if (heightDiff < -150) {
        // Keyboard hidden
        this.#onKeyboardHide();
      }

      this.#viewportHeight = newHeight;
    });
  }

  #onKeyboardShow() {
    this.#isKeyboardVisible = true;
    this.#scrollToCursor();
    this.#adjustLayout();
  }

  #onKeyboardHide() {
    this.#isKeyboardVisible = false;
    this.#restoreLayout();
  }

  #scrollToCursor() {
    const selection = window.getSelection();
    if (!selection || selection.rangeCount === 0) return;

    const range = selection.getRangeAt(0);
    const rect = range.getBoundingClientRect();
    const viewportHeight = window.visualViewport?.height || window.innerHeight;

    if (rect.bottom > viewportHeight) {
      range.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
    }
  }
}

Viewport Adjustment

// Adjust editor height when keyboard appears
function adjustEditorForKeyboard(editor: HTMLElement) {
  const viewport = window.visualViewport;
  if (!viewport) return;

  const keyboardHeight = window.innerHeight - viewport.height;
  
  if (keyboardHeight > 0) {
    // Keyboard is visible
    editor.style.maxHeight = (viewport.height - 100) + 'px';
    editor.style.overflowY = 'auto';
  } else {
    // Keyboard is hidden
    editor.style.maxHeight = '';
    editor.style.overflowY = '';
  }
}

Touch Selection

Handle touch-based text selection.

// Improve touch selection accuracy
class TouchSelectionHandler {
  constructor(editor: Editor) {
    this.#editor = editor;
    this.#setupTouchHandlers();
  }

  #setupTouchHandlers() {
    let touchStart: Touch | null = null;

    this.#editor.element.addEventListener('touchstart', (e) => {
      touchStart = e.touches[0];
    });

    this.#editor.element.addEventListener('touchend', (e) => {
      if (!touchStart) return;

      // Wait for selection to update
      setTimeout(() => {
        const selection = window.getSelection();
        if (selection && selection.rangeCount > 0) {
          this.#adjustSelection(selection.getRangeAt(0));
        }
      }, 100);
    });
  }

  #adjustSelection(range: Range) {
    // Adjust selection boundaries if needed
    // Touch selection can be imprecise
  }
}

Mobile Input Handling

Handle mobile-specific input behavior.

Text Prediction

// Handle mobile text prediction
element.addEventListener('beforeinput', (e) => {
  if (e.inputType === 'insertFromPredictiveText') {
    // Mobile keyboard text prediction
    e.preventDefault();
    this.#handlePredictiveText(e.data);
  }
});

// Disable prediction for code blocks
function disablePrediction(element: HTMLElement) {
  element.setAttribute('autocorrect', 'off');
  element.setAttribute('autocapitalize', 'off');
  element.setAttribute('spellcheck', 'false');
  element.setAttribute('inputmode', 'text');
}

Autocorrect

// Detect and handle autocorrect
class AutocorrectHandler {
  #lastInput = '';

  handleInput(e: InputEvent) {
    if (e.inputType === 'insertText') {
      // Check if this might be autocorrect
      if (this.#isLikelyAutocorrect(e.data)) {
        // Handle autocorrect
        this.#handleAutocorrect(e.data);
      } else {
        this.#lastInput = e.data;
      }
    }
  }

  #isLikelyAutocorrect(text: string): boolean {
    // Heuristic: If text is very different from what was typed
    // This is difficult to detect reliably
    return false;
  }
}

iOS-Specific Issues

iOS Safari has unique behaviors.

iOS Safari Issues:

  • Composition events don't fire for Korean IME
  • Selection can be lost when editor loses focus
  • Virtual keyboard behavior differs from Android
// iOS Safari workaround for composition
function isIOS() {
  return /iPad|iPhone|iPod/.test(navigator.userAgent);
}

function isSafari() {
  return /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent);
}

// Always allow default for certain keys on iOS Safari
if (isIOS() && isSafari()) {
  element.addEventListener('keydown', (e) => {
    if (['Enter', 'Backspace', 'Delete'].includes(e.key)) {
      // Allow browser default
      return;
    }
  });
}

Android-Specific Issues

Android browsers have their own quirks.

// Android Chrome specific handling
function isAndroid() {
  return /Android/.test(navigator.userAgent);
}

// Handle different keyboard apps
// Gboard, SwiftKey, Samsung Keyboard have different behaviors
function handleAndroidKeyboard() {
  // Some keyboards may interfere with input handling
  // Test with different keyboard apps
}

Best Practices

  • Test on real devices, not just emulators
  • Handle viewport changes when keyboard appears
  • Scroll to keep cursor visible
  • Disable autocorrect for code blocks
  • Test with different keyboard apps
  • Handle touch selection imprecision
  • Consider mobile performance constraints