Skip to content

에디터 IME 구현 가이드

composition 이벤트 처리, 예외 케이스 대응, 완전한 구현 코드

이 문서는 웹 에디터에서 IME를 올바르게 처리하는 완전한 구현 가이드다.


class IMEHandler {
constructor(element) {
this.el = element;
this.isComposing = false;
this.preeditText = '';
this.el.addEventListener('compositionstart', this.onCompositionStart.bind(this));
this.el.addEventListener('compositionupdate', this.onCompositionUpdate.bind(this));
this.el.addEventListener('compositionend', this.onCompositionEnd.bind(this));
this.el.addEventListener('input', this.onInput.bind(this));
}
onCompositionStart(e) {
this.isComposing = true;
this.preeditText = '';
}
onCompositionUpdate(e) {
this.preeditText = e.data;
this.updatePreeditDisplay(e.data);
}
onCompositionEnd(e) {
this.isComposing = false;
this.preeditText = '';
if (e.data) {
this.commit(e.data);
} else {
this.cancelPreedit();
}
}
onInput(e) {
// composition 없이 insertText가 오면 바로 commit
if (!this.isComposing && e.inputType === 'insertText' && e.data) {
this.commit(e.data);
}
}
updatePreeditDisplay(text) {
// 화면에만 표시, 문서 모델에는 반영하지 않음
}
commit(text) {
// 문서에 반영, undo 스택에 추가
}
cancelPreedit() {
// preedit 구간 제거
}
}
이벤트처리
compositionstartisComposing = true, preedit 구간 생성
compositionupdatepreedit 내용 갱신 (문서 반영 안 함)
compositionend + data 있음isComposing = false, commit
compositionend + data 없음isComposing = false, preedit 취소
input + !isComposing + insertTextcommit

Safari에서는 isComposing이 올바르게 설정되지 않는 경우가 있다.

onKeyDown(e) {
// Safari 보완: keyCode 229면 IME가 처리 중
if (e.isComposing || e.keyCode === 229) {
return; // 단축키로 처리하지 않음
}
this.handleShortcut(e);
}

2.2 Safari: blur 시 compositionend 미발생

Section titled “2.2 Safari: blur 시 compositionend 미발생”
constructor(element) {
// ... 기존 코드 ...
this.el.addEventListener('blur', this.onBlur.bind(this));
}
onBlur() {
if (this.isComposing) {
// Safari: compositionend가 안 옴
this.isComposing = false;
// 정책 선택 (둘 중 하나):
// A) preedit 버림
this.cancelPreedit();
// B) preedit commit
// if (this.preeditText) this.commit(this.preeditText);
this.preeditText = '';
}
}
onKeyDown(e) {
if (e.key === 'Enter') {
if (e.isComposing) {
// Enter를 IME에 넘김 (Firefox 조기 compositionend 버그 대응)
return;
}
this.submitOrNewLine();
}
}

2.4 compositionend 직후 input 중복 방지

Section titled “2.4 compositionend 직후 input 중복 방지”
constructor(element) {
// ... 기존 코드 ...
this.lastCommit = null;
this.lastCommitTime = 0;
}
onCompositionEnd(e) {
this.isComposing = false;
this.preeditText = '';
if (e.data) {
this.lastCommit = e.data;
this.lastCommitTime = Date.now();
this.commit(e.data);
} else {
this.cancelPreedit();
}
}
onInput(e) {
// compositionend 직후 100ms 이내 동일 내용 무시
if (Date.now() - this.lastCommitTime < 100 && e.data === this.lastCommit) {
return;
}
if (!this.isComposing && e.inputType === 'insertText' && e.data) {
this.commit(e.data);
}
}

iOS Safari는 composition 이벤트가 불규칙하게 발생한다. composition에만 의존하면 안 된다.

constructor(element) {
// ... 기존 코드 ...
this.el.addEventListener('beforeinput', this.onBeforeInput.bind(this));
}
onBeforeInput(e) {
// composition 중이면 무시 (compositionend에서 처리)
if (this.isComposing) return;
switch (e.inputType) {
case 'insertText':
// composition 없이 오는 입력 처리
// onInput에서도 처리하므로 여기서는 생략 가능
break;
case 'insertReplacementText':
// 자동 수정
e.preventDefault();
const ranges = e.getTargetRanges();
if (ranges.length > 0) {
this.replaceRange(ranges[0], e.data);
}
break;
case 'deleteContentBackward':
e.preventDefault();
this.deleteBackward(1);
break;
case 'deleteWordBackward':
case 'deleteBackwardWord':
e.preventDefault();
const deleteRanges = e.getTargetRanges();
if (deleteRanges.length > 0) {
this.deleteRange(deleteRanges[0]);
} else {
this.deleteWordBackward();
}
break;
}
}

iOS Safari 딕테이션은 composition 이벤트가 발생하지 않는다.

onInput(e) {
// composition 없이 insertText가 오면 바로 commit
// → 딕테이션, 또는 한글 입력이 composition 없이 오는 경우
if (!this.isComposing && e.inputType === 'insertText' && e.data) {
this.commit(e.data);
}
}

updatePreeditDisplay(text) {
if (!this.preeditNode) {
// preedit 구간 생성
this.preeditNode = document.createElement('span');
this.preeditNode.className = 'ime-preedit';
this.preeditNode.style.textDecoration = 'underline';
// 커서 위치에 삽입
}
this.preeditNode.textContent = text;
}
cancelPreedit() {
if (this.preeditNode) {
this.preeditNode.remove();
this.preeditNode = null;
}
}
commit(text) {
if (this.preeditNode) {
// preedit 노드를 일반 텍스트로 교체
const textNode = document.createTextNode(text);
this.preeditNode.replaceWith(textNode);
this.preeditNode = null;
} else {
// preedit 없이 commit이 온 경우 (iOS Safari 등)
this.insertAtCursor(text);
}
this.addToUndoStack({ type: 'insert', text });
}
// 조합 중에는 selection을 임의로 바꾸지 않는다
// IME 상태가 꼬일 수 있음
onSelectionChange() {
if (this.isComposing) {
// 조합 중에는 selection 변경 무시하거나
// preedit 구간 내로 제한
return;
}
this.updateSelectionState();
}

상황undo 스택
compositionupdate넣지 않음
compositionend (commit)한 번 넣음
compositionend (취소)넣지 않음
insertText (composition 없이)넣음
commit(text) {
// 문서에 반영
this.insertAtCursor(text);
// undo 스택에 한 번만
this.undoStack.push({
type: 'insert',
text: text,
position: this.cursorPosition - text.length
});
}
// 조합 중 Undo 키 처리
onKeyDown(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
if (this.isComposing) {
// 조합 중에는 Undo를 IME에 맡기거나 무시
return;
}
e.preventDefault();
this.undo();
}
}

class CompleteIMEHandler {
constructor(element) {
this.el = element;
this.isComposing = false;
this.preeditText = '';
this.preeditNode = null;
this.lastCommit = null;
this.lastCommitTime = 0;
this.undoStack = [];
// 이벤트 리스너
this.el.addEventListener('compositionstart', this.onCompositionStart.bind(this));
this.el.addEventListener('compositionupdate', this.onCompositionUpdate.bind(this));
this.el.addEventListener('compositionend', this.onCompositionEnd.bind(this));
this.el.addEventListener('beforeinput', this.onBeforeInput.bind(this));
this.el.addEventListener('input', this.onInput.bind(this));
this.el.addEventListener('keydown', this.onKeyDown.bind(this));
this.el.addEventListener('blur', this.onBlur.bind(this));
}
onCompositionStart(e) {
this.isComposing = true;
this.preeditText = '';
}
onCompositionUpdate(e) {
this.preeditText = e.data;
this.updatePreeditDisplay(e.data);
}
onCompositionEnd(e) {
this.isComposing = false;
if (e.data) {
this.lastCommit = e.data;
this.lastCommitTime = Date.now();
this.commit(e.data);
} else {
this.cancelPreedit();
}
this.preeditText = '';
}
onBeforeInput(e) {
if (this.isComposing) return;
switch (e.inputType) {
case 'insertReplacementText':
e.preventDefault();
const ranges = e.getTargetRanges();
if (ranges.length > 0) {
this.replaceRange(ranges[0], e.data);
}
break;
case 'deleteContentBackward':
e.preventDefault();
this.deleteBackward(1);
break;
case 'deleteWordBackward':
case 'deleteBackwardWord':
e.preventDefault();
this.deleteWordBackward();
break;
}
}
onInput(e) {
// 중복 방지
if (Date.now() - this.lastCommitTime < 100 && e.data === this.lastCommit) {
return;
}
// composition 없이 오는 입력 (iOS Safari 등)
if (!this.isComposing && e.inputType === 'insertText' && e.data) {
this.commit(e.data);
}
}
onKeyDown(e) {
// IME 조합 중이면 단축키 무시
if (e.isComposing || e.keyCode === 229) {
return;
}
// Enter 처리
if (e.key === 'Enter') {
this.handleEnter(e);
return;
}
// 단축키 처리
this.handleShortcut(e);
}
onBlur() {
if (this.isComposing) {
// Safari: compositionend가 안 옴
this.isComposing = false;
this.cancelPreedit();
this.preeditText = '';
}
}
// ... 나머지 메서드 구현 ...
}

에디터가 IME를 올바르게 지원하는지 확인:

  • compositionstart / compositionupdate / compositionend 구독
  • 조합 중 구간을 화면에만 표시 (문서 모델에 반영 안 함)
  • compositionend 시점에만 commit
  • compositionend.data 빈 문자열이면 취소 처리
  • composition 없이 insertText 오면 commit (iOS Safari)
  • keydown에서 isComposing 또는 keyCode === 229 확인
  • blur 시 조합 중이면 강제 정리
  • compositionend 직후 input 중복 방지
  • insertReplacementText 처리 (모바일 자동 수정)
  • deleteWordBackward / deleteBackwardWord 처리