에디터 IME 구현 가이드
composition 이벤트 처리, 예외 케이스 대응, 완전한 구현 코드
이 문서는 웹 에디터에서 IME를 올바르게 처리하는 완전한 구현 가이드다.
1. 기본 구현
1.1 최소 구현 (필수)
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 구간 제거
}
}
1.2 처리 규칙
| 이벤트 | 처리 |
|---|---|
compositionstart | isComposing = true, preedit 구간 생성 |
compositionupdate | preedit 내용 갱신 (문서 반영 안 함) |
compositionend + data 있음 | isComposing = false, commit |
compositionend + data 없음 | isComposing = false, preedit 취소 |
input + !isComposing + insertText | commit |
2. 브라우저별 예외 처리
2.1 Safari: keyCode 229 보완
Safari에서는 isComposing이 올바르게 설정되지 않는 경우가 있다.
onKeyDown(e) {
// Safari 보완: keyCode 229면 IME가 처리 중
if (e.isComposing || e.keyCode === 229) {
return; // 단축키로 처리하지 않음
}
this.handleShortcut(e);
}
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 = '';
}
}
2.3 Firefox: Enter 시 isComposing 확인
onKeyDown(e) {
if (e.key === 'Enter') {
if (e.isComposing) {
// Enter를 IME에 넘김 (Firefox 조기 compositionend 버그 대응)
return;
}
this.submitOrNewLine();
}
}
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);
}
}
3. iOS Safari 대응
iOS Safari는 composition 이벤트가 불규칙하게 발생한다. composition에만 의존하면 안 된다.
3.1 inputType 기반 처리 추가
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;
}
}
3.2 딕테이션(음성 입력) 처리
iOS Safari 딕테이션은 composition 이벤트가 발생하지 않는다.
onInput(e) {
// composition 없이 insertText가 오면 바로 commit
// → 딕테이션, 또는 한글 입력이 composition 없이 오는 경우
if (!this.isComposing && e.inputType === 'insertText' && e.data) {
this.commit(e.data);
}
}
4. DOM/Selection 관리
4.1 preedit 구간 표시
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 });
}
4.2 Selection 주의사항
// 조합 중에는 selection을 임의로 바꾸지 않는다
// IME 상태가 꼬일 수 있음
onSelectionChange() {
if (this.isComposing) {
// 조합 중에는 selection 변경 무시하거나
// preedit 구간 내로 제한
return;
}
this.updateSelectionState();
}
5. Undo/Redo
5.1 규칙
| 상황 | undo 스택 |
|---|---|
| compositionupdate | 넣지 않음 |
| compositionend (commit) | 한 번 넣음 |
| compositionend (취소) | 넣지 않음 |
| insertText (composition 없이) | 넣음 |
5.2 구현
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();
}
}
6. 완전한 구현 예시
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 = '';
}
}
// ... 나머지 메서드 구현 ...
}
7. 체크리스트
에디터가 IME를 올바르게 지원하는지 확인:
-
compositionstart/compositionupdate/compositionend구독 - 조합 중 구간을 화면에만 표시 (문서 모델에 반영 안 함)
-
compositionend시점에만 commit -
compositionend.data빈 문자열이면 취소 처리 - composition 없이
insertText오면 commit (iOS Safari) -
keydown에서isComposing또는keyCode === 229확인 -
blur시 조합 중이면 강제 정리 - compositionend 직후 input 중복 방지
-
insertReplacementText처리 (모바일 자동 수정) -
deleteWordBackward/deleteBackwardWord처리