Skip to content

에디터 IME 처리 코드 예시

contenteditable, React, ProseMirror/Slate/Lexical 구현 예시


let isComposing = false;
let compositionStart = 0;
editorEl.addEventListener('compositionstart', () => {
isComposing = true;
compositionStart = getCursorOffset();
});
editorEl.addEventListener('compositionupdate', (e) => {
// DOM에만 preedit 표시 (문서 모델에는 반영하지 않음)
showPreeditInDOM(compositionStart, e.data);
});
editorEl.addEventListener('compositionend', (e) => {
isComposing = false;
clearPreeditFromDOM();
if (e.data) {
insertToDocument(compositionStart, e.data);
pushUndoStack();
}
});
// composition 없이 오는 입력 처리 (iOS Safari 등)
editorEl.addEventListener('input', (e) => {
if (!isComposing && e.inputType === 'insertText' && e.data) {
insertToDocument(getCursorOffset(), e.data);
pushUndoStack();
}
});
// 단축키에서 조합 중 제외
editorEl.addEventListener('keydown', (e) => {
if (e.isComposing || e.keyCode === 229) return;
if (e.key === 'Enter') handleEnter();
if (e.ctrlKey && e.key === 'b') toggleBold();
});
// Safari blur 대응
editorEl.addEventListener('blur', () => {
if (isComposing) {
isComposing = false;
clearPreeditFromDOM();
}
});

2. compositionend 직후 input 중복 방지

Section titled “2. compositionend 직후 input 중복 방지”
let lastCommit = null;
let lastCommitTime = 0;
editorEl.addEventListener('compositionend', (e) => {
isComposing = false;
if (e.data) {
lastCommit = e.data;
lastCommitTime = Date.now();
insertToDocument(compositionStart, e.data);
pushUndoStack();
}
});
editorEl.addEventListener('input', (e) => {
// 100ms 이내 동일 내용 무시
if (Date.now() - lastCommitTime < 100 && e.data === lastCommit) {
return;
}
if (!isComposing && e.inputType === 'insertText' && e.data) {
insertToDocument(getCursorOffset(), e.data);
}
});

function IMEInput() {
const [value, setValue] = useState('');
const [isComposing, setIsComposing] = useState(false);
const composingRef = useRef(false);
const handleCompositionStart = () => {
composingRef.current = true;
setIsComposing(true);
};
const handleCompositionEnd = (e) => {
composingRef.current = false;
setIsComposing(false);
// React의 onChange가 실제 값을 처리함
};
const handleChange = (e) => {
// 조합 중이든 아니든 값을 반영
// React는 controlled input에서 조합 중에도 값을 업데이트해야 함
setValue(e.target.value);
};
return (
<input
value={value}
onChange={handleChange}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
/>
);
}

주의: React controlled input에서는 조합 중에도 value를 업데이트해야 한다. 그렇지 않으면 IME 동작이 깨진다.


// view.composing으로 조합 중 여부 확인
if (view.composing) {
// 조합 중에는 특정 처리 생략
}
// 플러그인에서 composition 이벤트 활용
const plugin = new Plugin({
props: {
handleDOMEvents: {
compositionstart(view) {
// 조합 시작 처리
return false; // ProseMirror 기본 처리 유지
},
compositionend(view, event) {
// 조합 종료 후 처리
return false;
}
}
}
});

Safari 주의사항: MutationObserver가 compositionend보다 먼저 fire하는 버그가 있다 (ProseMirror issue #1190).


// Slate의 beforeinput 처리
const withIME = (editor) => {
const { insertText } = editor;
editor.insertText = (text) => {
// 조합 중 여부에 따라 처리 분기
if (editor.composition) {
// preedit으로만 처리
} else {
insertText(text);
}
};
return editor;
};

// 조합 중: undo 스택에 넣지 않음
// compositionend: 한 번만 넣음
editorEl.addEventListener('compositionupdate', (e) => {
// pushUndoStack() 호출하지 않음
showPreedit(e.data);
});
editorEl.addEventListener('compositionend', (e) => {
if (e.data) {
insertToDocument(e.data);
pushUndoStack(); // 여기서만 한 번
}
});