입력 처리 & IME

모델 기반 에디터에서 사용자 입력, IME 조합, 키보드 이벤트, 텍스트 처리를 다루는 포괄적인 가이드입니다.

개요

입력 처리는 contenteditable 에디터를 구축하는 가장 복잡한 측면 중 하나입니다. 브라우저 이벤트, IME 조합 상태, 키보드 단축키, 문서 모델 간의 조정이 필요합니다. 이 가이드는 견고한 입력 처리를 위한 도전과제와 해결책을 다룹니다.

주요 도전과제:

  • iOS Safari에서 한국어 IME의 경우 조합 이벤트가 발생하지 않음
  • 키보드 핸들러는 조합 중 브라우저 기본 동작을 허용해야 함
  • 활성 조합 중에 붙여넣기 작업이 발생할 수 있음
  • 모바일 키보드는 데스크톱과 다른 동작을 함
  • 텍스트 예측과 자동 수정이 사용자 정의 핸들러를 방해함

IME 조합 처리

입력기(IME)는 사용자가 간단한 구성 요소로부터 복잡한 문자(한국어, 일본어, 중국어 등)를 조합하여 입력할 수 있게 합니다. IME 조합을 올바르게 처리하는 것은 국제 사용자를 지원하는 데 중요합니다.

조합 이벤트

표준 조합 이벤트 생명주기:

class CompositionManager {
  #isComposing = false;
  #compositionData = '';

  constructor(editor) {
    this.#editor = editor;
    this.#setupCompositionHandlers();
  }

  #setupCompositionHandlers() {
    const element = this.#editor.element;

    element.addEventListener('compositionstart', (e) => {
      this.#isComposing = true;
      this.#compositionData = '';
      // 조합 중 사용자 정의 키보드 핸들러 방지
      this.#editor.setCompositionMode(true);
    });

    element.addEventListener('compositionupdate', (e) => {
      this.#compositionData = e.data;
      // 조합 텍스트로 모델 업데이트
      this.#updateCompositionText(e.data);
    });

    element.addEventListener('compositionend', (e) => {
      this.#isComposing = false;
      this.#compositionData = '';
      // 조합을 모델에 커밋
      this.#commitComposition(e.data);
      this.#editor.setCompositionMode(false);
    });
  }

  #updateCompositionText(data) {
    // 임시 조합 텍스트로 모델 업데이트
    // 이 텍스트는 변경되거나 취소될 수 있음
  }

  #commitComposition(data) {
    // 모델에서 조합 텍스트 최종화
    // 임시 조합 텍스트를 최종 텍스트로 교체
  }

  get isComposing() {
    return this.#isComposing;
  }
}

iOS Safari 문제: iOS Safari에서 한국어 IME의 경우 조합 이벤트가 발생하지 않습니다. isComposing 플래그는 항상 false입니다.

조합 상태 관리

사용자 정의 핸들러가 방해하지 않도록 조합 상태 추적:

class Editor {
  #isComposing = false;
  #compositionMode = false;

  setCompositionMode(enabled) {
    this.#compositionMode = enabled;
  }

  handleKeyDown(e) {
    // 조합 중에는 항상 브라우저 기본 동작 허용
    if (this.#isComposing || this.#compositionMode) {
      return; // 기본 동작 방지하지 않음
    }

    // 사용자 정의 키보드 핸들러
    if (e.key === 'Enter') {
      e.preventDefault();
      this.#handleEnter();
    } else if (e.key === 'Backspace') {
      e.preventDefault();
      this.#handleBackspace();
    }
  }

  // iOS Safari 해결책: 특정 키에 대해 항상 기본 동작 허용
  handleKeyDownIOS(e) {
    // iOS Safari의 경우 isComposing이 신뢰할 수 없으므로
    // Enter/Backspace/Delete에 대해 항상 기본 동작 허용
    if (this.#isIOS() && ['Enter', 'Backspace', 'Delete'].includes(e.key)) {
      return; // 브라우저 기본 동작 허용
    }

    // 다른 키에 대한 사용자 정의 핸들러
    this.handleKeyDown(e);
  }
}

iOS Safari 특수 케이스

iOS Safari는 한국어 IME에 대해 조합 이벤트가 발생하지 않으므로 특별한 처리가 필요합니다:

class IOSCompositionDetector {
  #isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
  #isSafari = /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent);
  #lastInputTime = 0;
  #inputPattern = /[가-힣]/; // 한국어 문자 패턴

  detectComposition(inputText) {
    if (!this.#isIOS || !this.#isSafari) {
      return false; // 표준 조합 이벤트 사용
    }

    // 휴리스틱: 입력에 한국어 문자가 포함되고
    // 입력 이벤트가 빠르게 발생하면 조합일 가능성이 높음
    const hasKorean = this.#inputPattern.test(inputText);
    const timeSinceLastInput = Date.now() - this.#lastInputTime;
    this.#lastInputTime = Date.now();

    // 한국어 입력과 빠른 이벤트(< 100ms)인 경우 조합일 가능성
    return hasKorean && timeSinceLastInput < 100;
  }

  // 대안: iOS Safari에서 항상 브라우저 기본 동작 허용
  shouldAllowDefault(key) {
    if (this.#isIOS && this.#isSafari) {
      // Enter/Backspace/Delete에 대해 항상 기본 동작 허용
      return ['Enter', 'Backspace', 'Delete'].includes(key);
    }
    return false;
  }
}

키보드 이벤트 처리

키보드 이벤트는 조합 중 사용자 정의 단축키와 브라우저 기본 동작을 모두 지원하도록 주의 깊게 처리해야 합니다.

beforeinput API

beforeinput 이벤트는 입력 작업에 대한 구조화된 정보를 제공합니다:

element.addEventListener('beforeinput', (e) => {
  // 조합이 활성화되어 있는지 확인
  if (e.isComposing) {
    // 조합 중에는 브라우저 기본 동작 허용
    return;
  }

  // 다양한 입력 타입 처리
  switch (e.inputType) {
    case 'insertText':
      e.preventDefault();
      this.#handleInsertText(e.data);
      break;

    case 'insertParagraph':
      e.preventDefault();
      this.#handleInsertParagraph();
      break;

    case 'deleteContentBackward':
      e.preventDefault();
      this.#handleDeleteBackward();
      break;

    case 'formatBold':
      e.preventDefault();
      this.#handleFormatBold();
      break;

    // ... 기타 입력 타입
  }
});

참고: iOS Safari에서 한국어 IME의 경우 beforeinputisComposing은 신뢰할 수 없습니다. 항상 조합 상태를 별도로 확인하세요.

키보드 단축키

조합 상태를 존중하면서 키보드 단축키 처리:

class KeyboardShortcutHandler {
  handleKeyDown(e) {
    // 수정자 키 확인
    const isModifier = e.ctrlKey || e.metaKey || e.altKey;

    // 조합 중에는 단축키 처리하지 않음
    if (this.#isComposing) {
      return;
    }

    // 단축키 처리
    if (isModifier && e.key === 'b') {
      e.preventDefault();
      this.#toggleBold();
    } else if (isModifier && e.key === 'i') {
      e.preventDefault();
      this.#toggleItalic();
    } else if (isModifier && e.key === 'z') {
      e.preventDefault();
      if (e.shiftKey) {
        this.#redo();
      } else {
        this.#undo();
      }
    }
  }
}

특수 키 처리

특수 키(Enter, Backspace, Delete, Tab)는 주의 깊은 처리가 필요합니다:

class SpecialKeyHandler {
  handleEnter(e) {
    if (this.#isComposing) {
      return; // 브라우저 기본 동작 허용
    }

    e.preventDefault();
    
    // Shift가 눌렸는지 확인
    if (e.shiftKey) {
      this.#insertLineBreak();
    } else {
      this.#insertParagraph();
    }
  }

  handleBackspace(e) {
    if (this.#isComposing) {
      return; // 브라우저 기본 동작 허용
    }

    e.preventDefault();
    
    const selection = this.#getSelection();
    if (selection.isCollapsed) {
      // 커서 앞의 문자 삭제
      this.#deleteBackward();
    } else {
      // 선택 영역 삭제
      this.#deleteSelection();
    }
  }

  handleTab(e) {
    if (this.#isComposing) {
      return; // 브라우저 기본 동작 허용 (IME에서 사용될 수 있음)
    }

    e.preventDefault();
    
    if (e.shiftKey) {
      this.#outdent();
    } else {
      this.#indent();
    }
  }
}

텍스트 입력 처리

텍스트 입력을 처리하고 문서 모델 요구사항에 따라 정규화합니다.

입력 타입 감지

다양한 입력 타입은 다른 처리가 필요합니다:

class InputProcessor {
  processInput(e) {
    switch (e.inputType) {
      case 'insertText':
        this.#handleInsertText(e.data);
        break;

      case 'insertCompositionText':
        // IME 조합 텍스트
        this.#handleCompositionText(e.data);
        break;

      case 'insertFromPaste':
        // 붙여넣기 작업
        this.#handlePaste();
        break;

      case 'insertFromDrop':
        // 드래그 앤 드롭
        this.#handleDrop();
        break;

      case 'insertFromPredictiveText':
        // 모바일 텍스트 예측
        this.#handlePredictiveText(e.data);
        break;

      default:
        // 알 수 없는 입력 타입
        this.#handleUnknownInput(e);
    }
  }
}

텍스트 정규화

모델 요구사항에 맞게 텍스트 입력 정규화:

class TextNormalizer {
  normalize(text) {
    // 제로 너비 문자 제거
    text = text.replace(/[\u200B-\u200D\uFEFF]/g, '');

    // 줄바꿈 정규화
    text = text.replace(/\r\n/g, '\n');
    text = text.replace(/\r/g, '\n');

    // 공백 정규화 (선택사항, 요구사항에 따라 다름)
    // text = text.replace(/[ \t]+/g, ' ');

    // 제어 문자 제거 (줄바꿈, 탭 제외)
    text = text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');

    return text;
  }

  // 브라우저가 삽입하는 보이지 않는 문자 처리
  cleanInvisibleChars(text) {
    // 제로 너비 비분리 공백
    text = text.replace(/\uFEFF/g, '');
    
    // 제로 너비 공백
    text = text.replace(/\u200B/g, '');
    
    // 제로 너비 조인/비조인
    text = text.replace(/[\u200C\u200D]/g, '');

    return text;
  }
}

붙여넣기 작업 처리

붙여넣기 작업에는 문서 모델로 변환해야 하는 서식, 이미지 및 기타 콘텐츠가 포함될 수 있습니다.

붙여넣기 이벤트

붙여넣기 이벤트 처리 및 클립보드 데이터 추출:

class PasteHandler {
  constructor(editor) {
    this.#editor = editor;
    this.#setupPasteHandlers();
  }

  #setupPasteHandlers() {
    this.#editor.element.addEventListener('paste', async (e) => {
      e.preventDefault();

      // 조합이 활성화되어 있는지 확인
      if (this.#editor.isComposing) {
        // 조합이 끝날 때까지 대기
        await this.#waitForCompositionEnd();
      }

      // 클립보드 데이터 가져오기
      const clipboardData = e.clipboardData || window.clipboardData;
      const items = clipboardData.items;

      // 다양한 데이터 타입 처리
      for (const item of items) {
        if (item.type.startsWith('text/')) {
          const text = await this.#getTextFromItem(item);
          this.#handleTextPaste(text);
        } else if (item.type.startsWith('image/')) {
          const file = item.getAsFile();
          this.#handleImagePaste(file);
        }
      }
    });
  }

  async #getTextFromItem(item) {
    return new Promise((resolve) => {
      item.getAsString(resolve);
    });
  }

  #handleTextPaste(text) {
    // HTML/텍스트를 모델로 변환
    const model = this.#parsePastedContent(text);
    this.#editor.insertModel(model);
  }
}

붙여넣기 필터링

붙여넣은 콘텐츠 필터링 및 정리:

class PasteFilter {
  filterHTML(html) {
    // 원하지 않는 태그 제거
    const allowedTags = ['p', 'br', 'strong', 'em', 'u', 'code'];
    const cleaned = this.#removeDisallowedTags(html, allowedTags);

    // 속성 제거
    const sanitized = this.#removeAttributes(cleaned);

    // 구조 정규화
    const normalized = this.#normalizeStructure(sanitized);

    return normalized;
  }

  #removeDisallowedTags(html, allowed) {
    // DOMParser를 사용하여 안전하게 파싱 및 필터링
    const parser = new DOMParser();
    const doc = parser.parseFromString(html, 'text/html');
    
    // 허용되지 않은 태그 제거
    const allElements = doc.querySelectorAll('*');
    allElements.forEach((el) => {
      if (!allowed.includes(el.tagName.toLowerCase())) {
        // 텍스트 콘텐츠로 교체
        const text = document.createTextNode(el.textContent);
        el.parentNode?.replaceChild(text, el);
      }
    });

    return doc.body.innerHTML;
  }
}

모바일 입력 처리

모바일 기기는 가상 키보드, 텍스트 예측, 터치 상호작용으로 인해 고유한 도전과제를 제시합니다.

가상 키보드

가상 키보드 표시 및 뷰포트 변경 처리:

class MobileKeyboardHandler {
  constructor(editor) {
    this.#editor = editor;
    this.#setupKeyboardHandlers();
  }

  #setupKeyboardHandlers() {
    // 뷰포트 크기 조정 감지 (키보드 표시)
    let viewportHeight = window.visualViewport?.height || window.innerHeight;
    
    window.visualViewport?.addEventListener('resize', () => {
      const newHeight = window.visualViewport.height;
      const heightDiff = viewportHeight - newHeight;

      if (heightDiff > 150) {
        // 키보드 표시됨
        this.#onKeyboardShow();
      } else if (heightDiff < -150) {
        // 키보드 숨김
        this.#onKeyboardHide();
      }

      viewportHeight = newHeight;
    });
  }

  #onKeyboardShow() {
    // 커서를 보이게 스크롤
    this.#scrollToCursor();
    
    // 에디터 레이아웃 조정
    this.#adjustLayoutForKeyboard();
  }

  #onKeyboardHide() {
    // 레이아웃 복원
    this.#restoreLayout();
  }

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

    const range = selection.getRangeAt(0);
    const rect = range.getBoundingClientRect();
    
    // 커서가 보이는 영역 아래에 있으면 스크롤
    if (rect.bottom > window.visualViewport.height) {
      range.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
    }
  }
}

텍스트 예측

모바일 텍스트 예측 및 자동 수정 처리:

class MobileTextPredictionHandler {
  handleInput(e) {
    if (e.inputType === 'insertFromPredictiveText') {
      // 모바일 키보드 텍스트 예측
      this.#handlePredictiveText(e.data);
    } else if (e.inputType === 'insertText') {
      // 이것이 자동 수정일 수 있는지 확인
      if (this.#isLikelyAutocorrect(e.data)) {
        this.#handleAutocorrect(e.data);
      } else {
        this.#handleNormalInput(e.data);
      }
    }
  }

  #isLikelyAutocorrect(text) {
    // 휴리스틱: 텍스트가 입력한 것과 매우 다르면
    // 자동 수정일 수 있음
    // 이를 신뢰할 수 있게 감지하는 것은 어려움
    return false;
  }

  // 코드 블록에 대한 자동 수정 비활성화
  disableAutocorrect(element) {
    element.setAttribute('autocorrect', 'off');
    element.setAttribute('autocapitalize', 'off');
    element.setAttribute('spellcheck', 'false');
  }
}

엣지 케이스와 함정

입력 처리를 깨뜨릴 수 있는 일반적인 엣지 케이스:

붙여넣기 중 조합

활성 조합 중에 붙여넣기가 발생할 수 있습니다:

class CompositionPasteHandler {
  async handlePaste(e) {
    // 조합이 활성화되어 있는지 확인
    if (this.#isComposing) {
      // 먼저 조합 취소
      await this.#cancelComposition();
    }

    // 그런 다음 붙여넣기 처리
    this.#processPaste(e);
  }

  async #cancelComposition() {
    // 조합 종료 강제
    // 이것은 조합 텍스트를 잃을 수 있지만 붙여넣기가 우선순위
    const event = new CompositionEvent('compositionend', {
      bubbles: true,
      cancelable: true,
      data: ''
    });
    this.#editor.element.dispatchEvent(event);
    
    // 조합이 완전히 끝날 때까지 틱 대기
    await new Promise(resolve => setTimeout(resolve, 0));
  }
}

실행 취소 중 조합

조합 중에 실행 취소/다시 실행이 발생할 수 있습니다:

class CompositionUndoHandler {
  handleUndo(e) {
    // 조합 중에는 실행 취소 허용하지 않음
    if (this.#isComposing) {
      e.preventDefault();
      return;
    }

    // 일반 실행 취소 처리
    this.#performUndo();
  }

  handleRedo(e) {
    // 조합 중에는 다시 실행 허용하지 않음
    if (this.#isComposing) {
      e.preventDefault();
      return;
    }

    // 일반 다시 실행 처리
    this.#performRedo();
  }
}

모범 사례

견고한 입력 처리를 위한 핵심 원칙:

  • 항상 조합 상태 확인: 활성 조합 중에는 기본 동작을 방지하지 마세요
  • iOS Safari 특별 처리: 한국어 IME에 대해 조합 이벤트가 발생하지 않음
  • 가능하면 beforeinput 사용: keydown/keypress보다 더 신뢰할 수 있음
  • 텍스트 입력 정규화: 보이지 않는 문자 제거 및 공백 정규화
  • 붙여넣은 콘텐츠 필터링: HTML 정리 및 모델 형식으로 변환
  • 모바일 별도 처리: 가상 키보드와 텍스트 예측은 특별한 처리가 필요함
  • 실제 IME로 테스트: 다양한 플랫폼에서 한국어, 일본어, 중국어 IME로 테스트

Related Pages

에디터 아키텍처

에디터 아키텍처 패턴 개요

모델-DOM 동기화

모델과 DOM 동기화

히스토리 관리

실행 취소/다시 실행 히스토리 관리

HTML 매핑

HTML 직렬화 및 파싱