웹 한글 키보드 추가
<!DOCTYPE HTML> <html> <head> </head> <body onload='onPageLoaded()'>
</body> </html> 이 코드 해석 좀
아래 HTML/JS는 “가상 키보드 위젯”을 특정 입력창에 붙여서 여러 언어 레이아웃 간 전환을 지원하는 예제예요. 핵심은 kbd.js에 들어있는 i18n.input.keyboard.Keyboard 클래스를 써서 키보드 팝업을 띄우고,
한 줄 요약
페이지가 로드되면 가상 키보드를 만들고 다양한 언어 레이아웃을 미리 로드한 뒤, 입력창(#t1)에 붙입니다. 드롭다운으로 레이아웃을 바꾸고, 버튼으로 키보드 표시/숨김을 전환합니다.
구성 요소별 해설
1) <head> 설정
-
meta태그: UTF-8 인코딩, IE에서 최신 렌더링 모드 사용. -
link href="kbd.css": 가상 키보드 UI 스타일. -
script src='kbd.js': 키보드 엔진이 들어있는 스크립트. 여기서i18n.input.keyboard.Keyboard클래스와 레이아웃 코드들이 제공됩니다.
2) 전역 변수
js
var controller, visible = true;
-
controller: 키보드 인스턴스를 담을 변수. -
visible: 키보드 표시 상태. 토글 버튼과 이벤트 리스너에서 쓸 플래그.
3) onPageLoaded(): 페이지 로드 시 초기화
흐름을 단계별로 보면 이렇습니다.
- 대상 입력창 찾기
js
var input = document.getElementById('t1');
- 키보드 인스턴스 생성
js
controller = new i18n.input.keyboard.Keyboard();
- 사용할 레이아웃들 미리 로드
js
controller.loadLayout('vi_telex');
// ... 여러 번 호출 ...
controller.loadLayout('nv');
여기서 ‘vi_telex’, ‘ko’, ‘ar’ 같은 문자열은 각 언어/자판 이름입니다. 필요한 레이아웃만 골라서 로드해도 됩니다.
- 키보드 위치 지정
js
controller.reposition(input, 5, 4, [5, 0, 0, 0]);
- 대상
input주변에 가상 키보드가 어디에 뜰지 정합니다. 인수 의미는 라이브러리 구현에 따라 다르지만, 보통 기준 요소, 오프셋, 여백 같은 좌표 파라미터입니다.
- 기본 활성 레이아웃 설정
js
controller.activateLayout('ne_inscript');
- 초기값으로 네팔어 인스크립트 자판을 선택.
- 입력창에 키보드 연결
js
controller.register(input);
- 이 입력창에 포커스가 오면 키보드를 붙여서 입력 이벤트를 가로채고, 가상 키 입력을 넣어줍니다.
- 특정 이벤트 리스너 등록
js
controller.addEventListener('kc', function() { visible = false; });
- ‘kc’는 라이브러리 쪽 이벤트(아마 key commit/keypress 완료 같은 의미)로 보입니다. 이벤트가 발생하면
visible을 false로 바꿔요. 단, 실제로 화면을 숨기지는 않고 플래그만 바꿉니다. 숨김 처리는toggle()에서controller.setVisible(visible)을 호출할 때 반영됩니다.
- 입력창에 포커스
js
input.focus();
4) onLayoutSelected(layoutCode): 드롭다운으로 레이아웃 변경
js
controller.activateLayout(layoutCode);
document.getElementById('t1').focus();
<select>에서 선택된 값(레이아웃 코드)을 받아서 즉시 전환하고, 다시 입력창에 포커스를 줍니다.
5) toggle(): 표시/숨김 전환
js
if (controller) {
controller.setVisible(visible = !visible);
}
- 전역 플래그를 뒤집고, 실제 키보드 UI를 보여주거나 감춥니다.
6) <body>: UI 요소들
-
onload='onPageLoaded()': 페이지 로드 후 초기화 실행. -
<select onchange='onLayoutSelected(this.value)'>: 레이아웃 목록. 옵션 value가 곧 레이아웃 코드입니다. 기본 선택은ne_inscript. -
<input id='t1'>: 실제로 타이핑할 입력창. -
“Toggle show/hide” 버튼: 가상 키보드 표시/숨김 전환.
눈에 띄는 포인트와 개선 팁
input의 type 속성
현재type='input'인데, 표준 값은type='text'가 맞습니다.
<input type="text" id="t1" style="width: 400px">
- 불필요하거나 불명확한 이벤트 처리
'kc'이벤트에서visible = false;만 하고 실제 숨김은 하지 않습니다. 키 입력 후 자동으로 키보드를 숨기고 싶다면setVisible(false)까지 호출해야 의도가 확실해집니다.
js
controller.addEventListener('kc', function() {
visible = false;
controller.setVisible(false);
});
반대로 숨기고 싶지 않다면 이 리스너를 제거하세요.
- 초기 표시 상태 일관성
초기visible = true지만 실제로 키보드를 보여주는 코드를 직접 호출하지 않습니다. 라이브러리에 따라register()만으로 기본 표시가 될 수도, 포커스 때만 뜰 수도 있어요. 항상 보이게 하려면 초기화 끝에 아래를 호출해 일관성을 보장할 수 있습니다.
js
controller.setVisible(true);
- 레이아웃 로딩 최적화
지금은 상당히 많은 레이아웃을 모두 로드합니다. 실제로 필요한 것만loadLayout하거나, 선택 시점에 지연 로드하도록 바꾸면 초기 로딩이 가벼워집니다. 예:
js
function onLayoutSelected(code) {
if (!controller.isLayoutLoaded(code)) controller.loadLayout(code);
controller.activateLayout(code);
t1.focus();
}
(라이브러리에 isLayoutLoaded 같은 헬퍼가 없다면 내부 상태를 직접 관리해야 합니다.)
- 접근성 및 i18n 배려
-
<label for="t1">를 추가하고, 드롭다운에도 라벨을 붙이면 스크린 리더 호환성이 좋아집니다. -
레이아웃 코드 대신 사람 친화적인 이름(예: “한국어(두벌식)”)을
<option>텍스트에 사용하세요.
- 스타일 조정
가상 키보드가 화면을 가리면reposition(...)인자를 조정하거나,kbd.css에서 z-index/position을 손봐 배치 품질을 올릴 수 있습니다.
동작 순서 정리
-
페이지 로드 →
onPageLoaded()실행. -
키보드 인스턴스 생성 → 여러 레이아웃 로드 → 입력창에 부착 → 기본 레이아웃 활성.
-
드롭다운 선택 시
onLayoutSelected()로 레이아웃 전환. -
“Toggle show/hide” 버튼으로 키보드 표시/숨김 전환.
필요하면 이 코드를 기반으로 기본 레이아웃을 한국어('ko')로 바꾸거나, 처음부터 숨김으로 시작하도록 세팅하는 변형 예시도 바로 만들어 드릴 수 있어요.
가상 키보드 없이 입력을 했을때 한글로 입력되게 할 순 없나
짧게 결론부터 말하면, 브라우저만으로 사용자의 입력기(IME)를 한글로 전환시킬 수는 없어요. lang="ko"나 inputmode 같은 HTML 속성도 IME를 바꾸지 않습니다. 즉, 가상 키보드 없이 곧장 한글이 찍히게 하려면 선택지는 세 가지예요.
가능한 방법
-
운영체제/브라우저 IME 사용
사용자가 OS에서 한글 입력기를 켜도록 안내하는 방법입니다. 가장 안정적이고 표준적이지만, 코드만으로 강제할 수는 없습니다. -
“보이지 않는” JS 입력기(자동 변환)
가상 키보드 UI는 없지만, 자바스크립트가 키 입력을 가로채서 영문 키를 한글 자모로 매핑 하고, 자동 합성(초/중/종성 조합) 해서 인풋에 넣는 방식입니다. 사용자는 평소처럼 타이핑하지만 결과는 한글로 들어갑니다.
핵심은 두 단계:
-
키 매핑: 예) 2벌식 기준으로
r → ㄱ,k → ㅏ,s → ㄴ… -
합성: 들어온 자모들을 초성/중성/종성 규칙에 맞춰 유니코드 한글 음절로 조합
완전한 합성 로직을 직접 구현하려면 꽤 길어집니다. 보통은 Hangul 조합 라이브러리(예: Hangul.assemble() 같은 API 제공)를 써서 자모 배열을 음절로 합칩니다.
아래는 “UI 없는 한글 입력기”의 최소 예시 흐름입니다. 실제 서비스용이라면 자모/합성 규칙을 모두 보강해야 합니다.
<input id="t1" type="text" style="width:400px">
<script>
// 2벌식 키→자모(데모용: 일부만 수록)
const keyToJamo = {
'r':'ㄱ','s':'ㄴ','e':'ㄷ','f':'ㄹ','a':'ㅁ','q':'ㅂ','t':'ㅅ','d':'ㅇ','w':'ㅈ','c':'ㅊ','z':'ㅋ','x':'ㅌ','v':'ㅍ','g':'ㅎ',
'k':'ㅏ','o':'ㅐ','i':'ㅑ','j':'ㅓ','p':'ㅔ','u':'ㅕ','h':'ㅗ','y':'ㅛ','n':'ㅜ','b':'ㅠ','m':'ㅡ','l':'ㅣ'
};
// 간단 합성기: 자모가 늘어날 때마다 음절로 조립
// 실전용은 초/중/종성 상태머신과 복합모음/겹받침 처리 필요
function assembleHangul(jamoArray){
// 예시: Hangul.js 같은 라이브러리가 있으면 Hangul.assemble(jamoArray) 사용
// 여기서는 초간단(불완전) 합성기로 대체: 자모를 그냥 이어 붙임
// → 실제 프로젝트에서는 반드시 정식 합성 로직/라이브러리를 쓰세요!
return jamoArray.join('');
}
const input = document.getElementById('t1');
let buffer = []; // 자모 버퍼
let composing = ''; // 현재 합성 결과
input.addEventListener('keydown', (e) => {
// 조합 중에 일반 동작 막고 우리가 넣는다
if (e.ctrlKey || e.metaKey || e.altKey) return; // 단축키는 통과
const k = e.key;
// 백스페이스 처리(버퍼에서 자모 하나 제거)
if (k === 'Backspace') {
if (buffer.length > 0) {
e.preventDefault();
buffer.pop();
composing = assembleHangul(buffer);
replaceTail(input, composing);
}
return;
}
// 엔터/탭/방향키 등은 통과
if (k.length > 1) return;
const jamo = keyToJamo[k];
if (!jamo) return; // 매핑 없는 키는 통과(영문 그대로 치고 싶다면 여기서 return 하지 말고 허용)
e.preventDefault();
buffer.push(jamo);
composing = assembleHangul(buffer);
replaceTail(input, composing);
});
// 커서 위치의 “최근 조합 문자열”을 치환(데모: 입력 전체를 덮어쓰기)
function replaceTail(el, text){
// 간단화를 위해 입력 전체를 조합 결과로 치환(실전: selectionStart/End로 부분 치환)
el.value = text;
}
</script>
주의사항
-
위 코드는 합성 로직을 거의 생략 한 데모라서 “감사” 같은 음절 조합이 제대로 안 됩니다. 반드시 정식 합성기 를 붙이세요. (유니코드 한글은
AC00부터 초·중·종성을 수식으로 조합합니다.) -
영문을 그대로도 입력해야 한다면, 매핑되지 않은 키는
preventDefault하지 않고 원래대로 통과시키면 됩니다. 반대로 “무조건 한글만”이면 모든 알파벳을 가로채세요. -
모바일에서는 소프트 키보드/자동 교정과 충돌할 수 있어
inputmode와 이벤트 처리를 꼼꼼히 테스트해야 합니다. -
contenteditable 영역에서도
beforeinput/input이벤트를 쓰면 비슷한 방식으로 구현 가능합니다.
- 사후 변환(사용자는 영문 로마자 입력 → 제출 시 서버/클라이언트에서 한글로 변환)
실시간 조합이 부담스러우면 이 방식이 단순합니다. 제출 직전에 영문을 한글로 바꾸는 버튼/옵션을 두는 식이죠.
정리
-
브라우저가 IME를 바꿔주지는 못한다.
-
UI 없는 JS 입력기(자모 매핑 + 합성) 로 “가상 키보드 없이도 한글처럼” 입력되게 만드는 건 가능하다.
-
실제로 쓰려면 표준 2벌식 전체 매핑, 겹모음/겹받침, 되돌리기(Backspace) 처리, 커서 위치 치환 등을 갖춘 완성된 합성기 가 필요하다.
원하시면 위 데모를 기반으로 2벌식 전체 매핑과 정식 합성 로직까지 탑재한 버전을 바로 만들어 드릴게요.
내가 준 소스는 그런 기능은 아니야?
가능해요. 네가 준 코드는 “가상 키보드 UI”를 띄우는 게 기본이지만, 실제로는 키 입력을 가로채서 선택한 레이아웃(예: ‘ko’)대로 문자를 넣는 엔진 이 핵심이라서, UI를 숨긴 채로 한글이 입력되게 만들 수 있어요. 즉, 페이지 안에서만 동작하는 “간이 IME”처럼 쓸 수 있습니다.
정리하자면
-
OS의 한글 IME를 켜주는 건 아니지만,
-
controller가 활성화된 입력창에서는 물리 키보드 입력을 2벌식 한글 조합 으로 변환해서 넣어줄 수 있고, -
가상 키보드 패널은
setVisible(false)로 감춰도 동작합니다.
아래처럼 최소 수정하면 됩니다.
핵심 수정 포인트
-
기본 레이아웃을 한국어로 바꾸기
-
가상 키보드 패널을 시작부터 숨기기
-
드롭다운/토글이 필요 없으면 제거(선택 사항)
예시 수정 코드
<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<link href="kbd.css" rel="stylesheet" type="text/css">
<script src="kbd.js"></script>
<script>
var controller;
function onPageLoaded() {
var input = document.getElementById('t1');
controller = new i18n.input.keyboard.Keyboard();
// 필요한 레이아웃만 남겨도 됩니다.
controller.loadLayout('ko'); // 한국어
controller.loadLayout('us_intl'); // 필요 시 영문 국제 배열 등
controller.register(input); // 입력창에 연결
controller.activateLayout('ko'); // ★ 기본 한글 레이아웃 선택
controller.reposition(input, 5, 4, [5, 0, 0, 0]);
controller.setVisible(false); // ★ 패널은 숨기고 엔진만 동작
// 불필요: 아래 리스너는 패널 표시 상태만 건드립니다.
// controller.addEventListener('kc', function(){ ... });
input.focus();
}
// 드롭다운으로 바꾸고 싶다면 유지
function onLayoutSelected(layoutCode) {
controller.activateLayout(layoutCode);
document.getElementById('t1').focus();
}
</script>
</head>
<body onload="onPageLoaded()">
<!-- 드롭다운을 유지하고 싶다면 'ko'를 기본 선택으로 -->
<select onchange="onLayoutSelected(this.value)">
<option value="ko" selected>ko</option>
<option value="us_intl">us_intl</option>
</select>
<input type="text" id="t1" style="width:400px">
<!-- 패널 토글 버튼은 불필요하면 제거 -->
</body>
</html>
사용 팁
-
이 방식은 해당 입력창에서만 영→한 변환이 적용됩니다. 다른 필드나 OS 전체 입력기엔 영향이 없어요.
-
물리 키보드가 US 배열이라고 가정하는 매핑이 많습니다. 키보드 배열이 다르면 일부 키 위치가 달라질 수 있어요.
-
한글 조합은 라이브러리가 처리합니다. 커서 이동/백스페이스 등 일반 편집 동작도 라이브러리 쪽 로직을 따릅니다.
원하면 드롭다운도 없애고 항상 한글만 들어가도록 깔끔하게 정리한 버전으로도 바로 바꿔드릴게요.
<!DOCTYPE html> <html lang="en"> <head>
Hold Shift to input more Korean characters.
</div> <div class="input-section"> </div> <div class="button-section"> </div> <div class="virtual-keyboard-section"> <div class="virtual-keyboard" id="virtualKeyboard"> </div> </div> </div> <footer class="site-footer"> nonsensejoke/korean-keyboard </footer> </body> </html> 이 사이트 따라 만들고 싶어 근데 특정 키가 눌렷을때 한영 모드 전환되게
</p>
좋아, “자동 한글 변환” 흐름은 그대로 두고, 특정 키를 눌렀을 때 한/영 입력 모드를 전환하도록 설계해보자. 핵심은 한글 조합 파이프라인 앞단에 “현재 IME 모드” 상태값을 두고, 단축키를 잡아 토글하는 거야. 모드가 ko면 기존 조합기로 처리, en이면 원래 브라우저 입력에 맡기면 된다.
아래는 바로 적용 가능한 설계와 코드 예시야. 파일 이름은 네가 올려둔 구조에 맞춰 넣었어.
1) HTML에 모드 표시 추가(선택)
사용자에게 지금 모드가 뭔지 보여주는 작은 칩을 추가하면 좋아.
<div class="input-section">
<div class="ime-status" aria-live="polite">
<span id="imeMode">KO</span>
</div>
<textarea id="koreanInput" placeholder="Type in English and it will convert to Korean..."></textarea>
</div>
간단한 스타일은 style.css에 넣자.
.ime-status {
display: inline-block;
margin-bottom: 6px;
font-size: 12px;
opacity: .8;
}
.ime-status #imeMode {
padding: 2px 6px;
border-radius: 6px;
border: 1px solid currentColor;
}
.ime-status #imeMode[data-mode="ko"] { content: "KO"; }
.ime-status #imeMode[data-mode="en"] { content: "EN"; }
2) korean-input.js에 한/영 모드 상태와 토글 단축키 추가
포인트는 세 가지다.
-
전역 모드 상태값(ko/en)
-
단축키로 모드 토글
-
모드가 en일 때는 가로채지 않고 기본 입력 흐름 유지
js
// korean-input.js
// 1) 모드 상태
const MODE = { KO: 'ko', EN: 'en' };
let imeMode = (localStorage.getItem('imeMode') === MODE.EN) ? MODE.EN : MODE.KO;
const textarea = document.getElementById('koreanInput');
const modeChip = document.getElementById('imeMode');
function renderMode() {
if (!modeChip) return;
modeChip.textContent = (imeMode === MODE.KO) ? 'KO' : 'EN';
modeChip.setAttribute('data-mode', imeMode);
}
renderMode();
// 2) 단축키 정의: Ctrl+Space 또는 F2 중 하나 눌러도 전환
function isToggleHotkey(e) {
// OS 단축키 충돌이 있을 수 있으니 대안 키도 둔다
// Ctrl+Space 또는 F2
return (e.ctrlKey && !e.shiftKey && !e.altKey && e.code === 'Space') || (e.code === 'F2');
}
function toggleImeMode() {
imeMode = (imeMode === MODE.KO) ? MODE.EN : MODE.KO;
localStorage.setItem('imeMode', imeMode);
renderMode();
// 사용자 피드백(선택): 잠깐 하이라이트 주기
if (modeChip) {
modeChip.style.transition = 'background .2s';
modeChip.style.background = 'rgba(0,0,0,.08)';
setTimeout(() => modeChip.style.background = 'transparent', 150);
}
}
// 3) 한글 조합기(네가 이미 갖고 있는 로직/객체를 사용)
// 예: composer.feedKey(e), composer.backspace(), composer.commit(), getComposedText() 같은 API를 가정
// 없다면 기존 네 조합 함수를 여기서 호출하도록 연결
importComposerIfNeeded(); // 실제로는 네 기존 모듈을 가져와 사용
// 커서/선택 치환 유틸
function replaceSelectionWith(textarea, text) {
const { selectionStart, selectionEnd, value } = textarea;
const before = value.slice(0, selectionStart);
const after = value.slice(selectionEnd);
const newPos = before.length + text.length;
textarea.value = before + text + after;
textarea.setSelectionRange(newPos, newPos);
}
// 현재 조합 결과를 반영
function applyComposition(text) {
// 단순히 현재 커서 위치에 합성 중 문자열을 반영하도록 구현
// 네가 이미 구현한 방식이 있다면 그 방법으로 대체
replaceSelectionWith(textarea, text);
}
// 4) 키 이벤트 훅
textarea.addEventListener('keydown', (e) => {
// 4-1) 모드 토글
if (isToggleHotkey(e)) {
e.preventDefault();
toggleImeMode();
return;
}
// 4-2) EN 모드일 땐 가로채지 않음
if (imeMode === MODE.EN) return;
// 4-3) KO 모드: 조합기가 처리할 키만 가로채기
// 조합기에서 처리하는 키의 범위는 너의 구현에 맞춰 조정
// 예: 영문자/숫자/백스페이스/스페이스 정도는 preventDefault하고 composer로 넘기기
const k = e.key;
const isChar = k.length === 1; // 한 글자 키
const isBackspace = (k === 'Backspace');
const isSpace = (k === ' ');
const isEnter = (k === 'Enter');
// 방향키/홈/엔드 등은 통과시켜야 편집이 가능
if (!isChar && !isBackspace && !isSpace && !isEnter) return;
// 여기서부터는 조합기에게 위임
e.preventDefault();
if (isBackspace) {
composer.backspace();
applyComposition(composer.text()); // composer.text(): 현재 합성 문자열
return;
}
if (isEnter) {
// 줄바꿈은 조합을 커밋하고 개행 추가
const committed = composer.commit();
replaceSelectionWith(textarea, committed + '\n');
return;
}
if (isSpace) {
// 스페이스는 조합 커밋 후 공백
const committed = composer.commit();
replaceSelectionWith(textarea, committed + ' ');
return;
}
// 일반 문자: 조합기에 먹이고 반영
composer.feedKey(e); // 내부에서 2벌식/겹모음/겹받침 처리
applyComposition(composer.text()); // 합성중 문자열 반영
});
// 5) 포커스 아웃 시 조합 커밋(선택)
textarea.addEventListener('blur', () => {
if (imeMode === MODE.KO) {
const committed = composer.commit();
if (committed) replaceSelectionWith(textarea, committed);
}
});
// 6) 가상 키보드와도 모드 동기화(선택)
// 만약 virtual-keyboard.js에서 '한/영' 키를 렌더링한다면:
document.addEventListener('vk:toggle-ime', () => {
toggleImeMode();
});
주의할 점
-
OS 단축키 충돌: Ctrl+Space는 macOS/Windows 모두에서 IME 전환으로 쓰일 때가 있다. 브라우저에 키가 도달하면 위 코드가 잡지만, OS 단계에서 먹히면 이벤트가 아예 안 온다. 그래서 F2 같은 대체 키를 함께 두었다.
-
모바일: 소프트 키보드에서는 물리키 이벤트가 제한적이다. 모바일 지원을 원하면 화면에 “한/영” 토글 버튼을 하나 배치해
toggleImeMode()를 눌러주게 하자. -
Undo/Redo:
preventDefault로 직접 값을 바꾸면 기본 Undo 스택과 엮이는 방식이 달라질 수 있다. 큰 단위(커밋 시점)로만 value를 바꾸거나,beforeinput이벤트를 사용해 브라우저 편집 힌트를 맞춰주면 품질이 오른다. -
선택 영역 편집: 위 예시는 커서 기준 치환으로 단순화했다. 드래그 선택 후 입력·백스페이스 등도 정확히 작동하게 하려면 selectionStart/End 기준으로 조합 중 문자열 범위를 추적하거나, 커밋 시점마다 앵커를 갱신하자.
-
조합기 API: 위에 쓴
composer.feedKey/backspace/commit/text는 네가 가진 한글 조합 로직에 맞춰 이름만 바꿔 붙이면 된다.
3) virtual-keyboard.js에서 ‘한/영’ 키 눌렀을 때 토글(선택)
화면 키보드에도 한/영 키를 달고 싶다면, 클릭 시 커스텀 이벤트를 쏘자.
js
// virtual-keyboard.js 내부
function emitToggleIme() {
const ev = new CustomEvent('vk:toggle-ime', { bubbles: true });
document.dispatchEvent(ev);
}
// 키 렌더링 시
// <button data-key="HANENG">한/영</button>
keyboardRoot.addEventListener('click', (e) => {
const key = e.target.closest('[data-key]');
if (!key) return;
const code = key.getAttribute('data-key');
if (code === 'HANENG') {
emitToggleIme();
return;
}
// ... 기존 키 처리 ...
});
4) 키 매핑 우선순위
-
ko 모드일 때만
preventDefault로 잡아 한글 조합 흐름으로 보낸다. -
en 모드일 때는 최대한 브라우저 기본 동작을 살려둬야 텍스트 편집 경험이 자연스럽다.
-
필요한 경우 숫자/기호도 조합기에서 처리할지 여부를 옵션화해라.
5) 빠른 체크리스트
-
단축키: Ctrl+Space, F2 두 가지 다 제공
-
모드 표시: 작은 칩으로 KO/EN 표기
-
로컬 저장: 마지막 모드 기억(localStorage)
-
포커스 아웃: 미커밋 문자 커밋
-
모바일: 화면 토글 버튼 추가
이대로 넣으면 “특정 키를 눌러 한/영 전환”이 깔끔하게 붙는다. 네 조합기 함수 이름이 다르면 그 부분만 바꿔서 연결하면 되고, 원하면 CapsLock 전환, 오른쪽 Alt 등 다른 키 조합도 추가해줄 수 있어.
내가 준 소스본거 맞아?
짚고 갈게.
네가 이번에 올린 HTML 소스 는 다음 JS 파일들을 불러오고 있을 뿐,
그 안의 내용(특히 korean-input.js / i18n.js / virtual-keyboard.js)은 여기 챗에 공유되지 않았어.
<script src="i18n.js?v=20250802"></script>
<script src="virtual-keyboard.js?v=20250802"></script>
<script src="korean-input.js?v=20250802"></script>
즉,
네가 준 건 ‘HTML 구조’이고, 실제 한글 자동 변환 기능은 저 JS 파일 안에 있다.
그리고 내가 위에서 작성해준 "한/영 모드 전환" 코드는 그 JS 내부에 추가해야 하는 기능 코드였어.
정답
너가 공유한 HTML 코드만으로는 "특정 키 눌러 한/영 전환" 기능이 없다.
그리고 "따라 만들고 싶다"는 사이트 소스인지 확인하는 질문이라면:
지금까지 네가 챗에 올린 소스는 그 사이트 전체 소스가 아니다.
핵심 기능(JS 부분)은 아직 챗에서 보지 못했다.
왜냐?
-
HTML에는 텍스트 박스, 버튼, UI 구조 만 있음
-
진짜 한글 자동 변환 / 자판 조합 알고리즘은
korean-input.js에 있음 -
가상 키보드 UI는
virtual-keyboard.js에 있음 -
언어 배열 및 레이아웃 정의는
i18n.js에 있음
즉, 네가 준 HTML은 “이 사이트를 따라 만들기 위한 껍데기”고,
기능은 전부 JS가 담당하고 있어.
그래서 해야 할 것
-
네가 쓰고 있는
korean-input.js를 보여줘
그래야 거기에 한/영 전환 핫키 코드를 정확히 삽입해줄 수 있어. -
또는 사용 중인 GitHub 저장소 링크(URL)를 알려줘도 됨
그러면 구조를 보고 어디에 코드를 넣는지 딱 집어주겠다.
다음 메시지에 이렇게 보내줘
korean-input.js 소스 전체
또는
virtual-keyboard.js 소스 전체
그 파일을 보면 네가 원하는 ‘특정 키로 한/영 전환’ 기능을 실제 코드에 정확히 넣어줄게.
지금은 HTML만 있어서 “맞는지?” 라고 물으면 답은 아직 아니야. 핵심 소스를 안 본 상태거든.
계속 가보자.
class KoreanInput { constructor() { // 字体大小管理 this.fontSize = 18; // 默认字体大小 // 普通状态的键盘映射 this.normalMap = { // 辅音 (초성/종성) 'q': 'ㅂ', 'w': 'ㅈ', 'e': 'ㄷ', 'r': 'ㄱ', 't': 'ㅅ', 'a': 'ㅁ', 's': 'ㄴ', 'd': 'ㅇ', 'f': 'ㄹ', 'g': 'ㅎ', 'z': 'ㅋ', 'x': 'ㅌ', 'c': 'ㅊ', 'v': 'ㅍ', // 元音 (중성) 'y': 'ㅛ', 'u': 'ㅕ', 'l': 'ㅣ', 'o': 'ㅐ', 'p': 'ㅔ', 'h': 'ㅗ', 'j': 'ㅓ', 'k': 'ㅏ', 'i': 'ㅑ', 'b': 'ㅠ', 'n': 'ㅜ', 'm': 'ㅡ' }; // Shift状态的键盘映射 this.shiftMap = { // 双辅音 'Q': 'ㅃ', 'W': 'ㅉ', 'E': 'ㄸ', 'R': 'ㄲ', 'T': 'ㅆ', // 复合元音 'O': 'ㅒ', 'P': 'ㅖ', // 其他按键保持原映射(小写转大写) 'q': 'ㅃ', 'w': 'ㅉ', 'e': 'ㄸ', 'r': 'ㄲ', 't': 'ㅆ', 'o': 'ㅒ', 'p': 'ㅖ' }; // 韩文字符范围常量 this.HANGUL_BASE = 0xAC00; this.CHOSUNG_BASE = 0x1100; this.JUNGSUNG_BASE = 0x1161; this.JONGSUNG_BASE = 0x11A7; // 初声 (19个) this.chosungList = ['ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ']; // 中声 (21个) this.jungsungList = ['ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ', 'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ']; // 终声 (28个,包含空字符) this.jongsungList = ['', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ', 'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ']; // 完整的韩文组合引擎数据结构(从参考项目移植) this.initial = [12593, 12594, 12596, 12599, 12600, 12601, 12609, 12610, 12611, 12613, 12614, 12615, 12616, 12617, 12618, 12619, 12620, 12621, 12622]; this.finale = [0, 12593, 12594, 12595, 12596, 12597, 12598, 12599, 12601, 12602, 12603, 12604, 12605, 12606, 12607, 12608, 12609, 12610, 12612, 12613, 12614, 12615, 12616, 12618, 12619, 12620, 12621, 12622]; this.dMedial = [0, 0, 0, 0, 0, 0, 0, 0, 0, 800, 801, 820, 0, 0, 1304, 1305, 1320, 0, 0, 1820]; this.dFinale = [0, 0, 0, 119, 0, 422, 427, 0, 0, 801, 816, 817, 819, 825, 826, 827, 0, 0, 1719, 0, 1919]; // Unicode常量 this.SBase = 44032; this.LBase = 4352; this.VBase = 12623; this.TBase = 4519; this.LCount = 19; this.VCount = 21; this.TCount = 28; this.NCount = 588; this.SCount = 11172; // 当前正在组合的字符状态 this.currentChosung = ''; this.currentJungsung = ''; this.currentJongsung = ''; this.buffer = ''; } // 查找数组中元素的索引(工具函数) indexOf(array, value) { for (let i = 0; i < array.length; i++) { if (array[i] === value) { return i; } } return -1; } // 获取字符映射 getCharacter(key, isShift) { if (isShift && this.shiftMap[key]) { return this.shiftMap[key]; } return this.normalMap[key.toLowerCase()] || null; } // 判断是否为辅音 isConsonant(char) { return this.chosungList.includes(char) || this.jongsungList.includes(char); } // 判断是否为元音 isVowel(char) { return this.jungsungList.includes(char); } // 完整的韩文组合算法(从参考项目移植并优化) composeHangul(inputString) { const length = inputString.length; if (length === 0) { return ""; } let currentCharCode = inputString.charCodeAt(0); let result = String.fromCharCode(currentCharCode); for (let i = 1; i < length; i++) { const nextCharCode = inputString.charCodeAt(i); const initialIndex = this.indexOf(this.initial, currentCharCode); // 初声 + 中声 → 音节 if (initialIndex !== -1) { const vowelOffset = nextCharCode - this.VBase; if (0 <= vowelOffset && vowelOffset < this.VCount) { currentCharCode = this.SBase + (initialIndex * this.VCount + vowelOffset) * this.TCount; result = result.slice(0, result.length - 1) + String.fromCharCode(currentCharCode); continue; } } // 元音 + 元音 → 复合元音 const currentVowelOffset = currentCharCode - this.VBase; const nextVowelOffset = nextCharCode - this.VBase; if (0 <= currentVowelOffset && currentVowelOffset < this.VCount && 0 <= nextVowelOffset && nextVowelOffset < this.VCount) { const dMedialIndex = this.indexOf(this.dMedial, (currentVowelOffset * 100) + nextVowelOffset); if (dMedialIndex > 0) { currentCharCode = this.VBase + dMedialIndex; result = result.slice(0, result.length - 1) + String.fromCharCode(currentCharCode); continue; } } const syllableOffset = currentCharCode - this.SBase; // 音节 + 终声 → 完整音节 if (0 <= syllableOffset && syllableOffset < 11145 && (syllableOffset % this.TCount) === 0) { const finaleIndex = this.indexOf(this.finale, nextCharCode); if (finaleIndex !== -1) { currentCharCode += finaleIndex; result = result.slice(0, result.length - 1) + String.fromCharCode(currentCharCode); continue; } // 处理复合中声 const vowelIndex = Math.floor((syllableOffset % this.NCount) / this.TCount); const dMedialIndex = this.indexOf(this.dMedial, (vowelIndex * 100) + (nextCharCode - this.VBase)); if (dMedialIndex > 0) { currentCharCode += (dMedialIndex - vowelIndex) * this.TCount; result = result.slice(0, result.length - 1) + String.fromCharCode(currentCharCode); continue; } } // 完整音节 + 元音 → 终声分离 + 新音节 (关键修复!) if (0 <= syllableOffset && syllableOffset < 11172 && (syllableOffset % this.TCount) !== 0) { const finaleIndex = syllableOffset % this.TCount; const vowelOffset = nextCharCode - this.VBase; if (0 <= vowelOffset && vowelOffset < this.VCount) { const newInitialIndex = this.indexOf(this.initial, this.finale[finaleIndex]); if (0 <= newInitialIndex && newInitialIndex < this.LCount) { // 移除终声,创建新音节 result = result.slice(0, result.length - 1) + String.fromCharCode(currentCharCode - finaleIndex); currentCharCode = this.SBase + (newInitialIndex * this.VCount + vowelOffset) * this.TCount; result = result + String.fromCharCode(currentCharCode); continue; } // 处理复合终声分解 if (finaleIndex < this.dFinale.length && this.dFinale[finaleIndex] !== 0) { result = result.slice(0, result.length - 1) + String.fromCharCode(currentCharCode - finaleIndex + Math.floor(this.dFinale[finaleIndex] / 100)); currentCharCode = this.SBase + (this.indexOf(this.initial, this.finale[(this.dFinale[finaleIndex] % 100)]) * this.VCount + vowelOffset) * this.TCount; result = result + String.fromCharCode(currentCharCode); continue; } } // 处理复合终声 const dFinaleIndex = this.indexOf(this.dFinale, (finaleIndex * 100) + this.indexOf(this.finale, nextCharCode)); if (dFinaleIndex > 0) { currentCharCode = currentCharCode + dFinaleIndex - finaleIndex; result = result.slice(0, result.length - 1) + String.fromCharCode(currentCharCode); continue; } } // 无法组合,添加新字符 currentCharCode = nextCharCode; result = result + String.fromCharCode(nextCharCode); } return result; } // 分解韩文字符(按照参考项目实现) decomposeHangul(inputString) { const length = inputString.length; let result = ""; for (let i = 0; i < length; i++) { const charCode = inputString.charCodeAt(i); const syllableOffset = charCode - this.SBase; // 检查是否为韩文音节 if (syllableOffset < 0 || syllableOffset >= this.SCount) { result += String.fromCharCode(charCode); continue; } // 分解音节 const initialIndex = Math.floor(syllableOffset / this.NCount); const vowelCode = this.VBase + Math.floor((syllableOffset % this.NCount) / this.TCount); const finaleCode = this.finale[syllableOffset % this.TCount]; result += String.fromCharCode(this.initial[initialIndex], vowelCode); if (finaleCode !== 0) { result += String.fromCharCode(finaleCode); } } return result; } // 处理输入 - 简化版本,直接返回字符用于smartInsert处理 processInput(char) { // 简化:直接返回字符,让smartInsert处理所有组合逻辑 return char; } // 检测韩文字符是否完成(用于历史保存时机判断) isHangulCharComplete(char) { if (!char) return false; const charCode = char.charCodeAt(0); const syllableOffset = charCode - this.SBase; // 检查是否为完整的韩文音节(不是单独的字母组件) if (0 <= syllableOffset && syllableOffset < this.SCount) { return true; // 是完整的韩文音节 } return false; // 不是完整的韩文音节或不是韩文字符 } // 检测从旧文本到新文本是否有韩文字符完成 detectHangulCompletion(oldText, newText, cursorPosition) { // 检查光标位置附近是否有新完成的韩文字符 if (cursorPosition <= 0) return false; const newChar = newText[cursorPosition - 1]; const oldChar = oldText[cursorPosition - 1] || ''; // 如果新字符是完整韩文字符,而旧字符不是,则表示有字符完成 if (this.isHangulCharComplete(newChar) && !this.isHangulCharComplete(oldChar)) { return true; } // 检查是否有韩文字符从不完整变为完整 if (newChar !== oldChar && this.isHangulCharComplete(newChar)) { return true; } return false; } // 智能插入函数 - 改进版本,更好地处理韩文组合,增加完成检测 smartInsert(currentText, cursorStart, cursorEnd, newChar) { // 1. 构建新文本:插入新字符 const textBefore = currentText.substring(0, cursorStart); const textAfter = currentText.substring(cursorEnd); const tempText = textBefore + newChar + textAfter; const tempCursorPos = cursorStart + newChar.length; // 2. 韩文组合处理:尝试不同长度的字符序列 for (let testLength = Math.min(4, tempCursorPos); testLength >= 2; testLength–) { const testChars = tempText.substring(tempCursorPos - testLength, tempCursorPos); const composed = this.composeHangul(testChars); // 3. 如果组合成功(长度减少,或者内容有意义变化),则替换 if (composed.length < testChars.length || composed !== testChars) { const newText = tempText.substring(0, tempCursorPos - testLength) + composed + tempText.substring(tempCursorPos); const newCursorPos = tempCursorPos - testLength + composed.length; // 4. 检测韩文字符是否完成(用于历史保存) const hangulCompleted = this.detectHangulCompletion(currentText, newText, newCursorPos); return { text: newText, cursorPosition: newCursorPos, hangulCompleted: hangulCompleted }; } } // 5. 无法组合,返回临时文本,检查是否有韩文完成 const hangulCompleted = this.detectHangulCompletion(currentText, tempText, tempCursorPos); return { text: tempText, cursorPosition: tempCursorPos, hangulCompleted: hangulCompleted }; } // 处理辅音输入 processConsonant(consonant) { if (!this.currentChosung) { // 开始新的音节 this.currentChosung = consonant; this.currentJungsung = ''; this.currentJongsung = ''; return consonant; } else if (this.currentJungsung && !this.currentJongsung) { // 添加终声 this.currentJongsung = consonant; return this.combineHangul(this.currentChosung, this.currentJungsung, this.currentJongsung); } else { // 开始新的音节 const result = this.finalizeCurrent() + consonant; this.currentChosung = consonant; this.currentJungsung = ''; this.currentJongsung = ''; return result; } } // 处理元音输入 processVowel(vowel) { if (this.currentChosung && !this.currentJungsung) { // 添加中声 this.currentJungsung = vowel; return this.combineHangul(this.currentChosung, this.currentJungsung); } else if (this.currentChosung && this.currentJungsung && this.currentJongsung) { // 终声变成新音节的初声 const result = this.combineHangul(this.currentChosung, this.currentJungsung) + this.combineHangul(this.currentJongsung, vowel); this.currentChosung = this.currentJongsung; this.currentJungsung = vowel; this.currentJongsung = ''; return result; } else { // 处理其他情况 const result = this.finalizeCurrent() + vowel; this.reset(); return result; } } // 完成当前字符组合 finalizeCurrent() { if (this.currentChosung && this.currentJungsung) { return this.combineHangul(this.currentChosung, this.currentJungsung, this.currentJongsung); } else if (this.currentChosung) { return this.currentChosung; } return ''; } // 重置状态 reset() { this.currentChosung = ''; this.currentJungsung = ''; this.currentJongsung = ''; } // 从单个韩文字符恢复输入状态 restoreStateFromCharacter(char) { if (!char) { this.reset(); return; } const decomposed = this.decomposeHangul(char); if (decomposed) { // 是完整的韩文字符,恢复组合状态 this.currentChosung = decomposed.chosung; this.currentJungsung = decomposed.jungsung; this.currentJongsung = decomposed.jongsung; } else if (this.isConsonant(char)) { // 是单独的辅音,设置为初声 this.currentChosung = char; this.currentJungsung = ''; this.currentJongsung = ''; } else if (this.isVowel(char)) { // 是单独的元音,这种情况不应该出现在正常的韩文输入中 // 重置状态 this.reset(); } else { // 不是韩文字符,重置状态 this.reset(); } } // 处理退格 - 使用新的decomposeHangul实现 handleBackspace(currentText) { if (currentText.length === 0) return ''; const lastChar = currentText[currentText.length - 1]; const decomposed = this.decomposeHangul(lastChar); // 检查是否为韩文字符(通过分解结果长度判断) if (decomposed.length > 1) { // 是韩文字符,按照参考项目的逻辑处理 const decomposedArray = Array.from(decomposed); if (decomposedArray.length > 1) { // 删除最后一个字符部件,重新组合 const remaining = decomposedArray.slice(0, -1).join(''); const recomposed = this.composeHangul(remaining); return currentText.slice(0, -1) + recomposed; } } // 不是韩文字符或无法分解,直接删除 return currentText.slice(0, -1); } // 程序化处理输入(用于移动端虚拟键盘) processInputProgrammatically(key, isShift, currentText, cursorPosition) { const char = this.getCharacter(key, isShift); if (!char) return null; // 使用smartInsert函数处理输入 return this.smartInsert(currentText, cursorPosition, cursorPosition, char); } // === 新增功能:保存文本 === // 保存文本为文件 saveTextAsFile(text) { if (!text.trim()) { alert(window.languageManager ? window.languageManager.getText('noTextToSave') || 'No text to save!' : 'No text to save!'); return; } try { // 创建Blob对象 const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }); // 创建下载链接 const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; // 生成文件名(包含时间戳) const now = new Date(); const timestamp = now.getFullYear() + String(now.getMonth() + 1).padStart(2, '0') + String(now.getDate()).padStart(2, '0') + '-' + String(now.getHours()).padStart(2, '0') + String(now.getMinutes()).padStart(2, '0') + String(now.getSeconds()).padStart(2, '0'); link.download = korean-text-${timestamp}.txt; // 触发下载 document.body.appendChild(link); link.click(); document.body.removeChild(link); // 清理URL对象 URL.revokeObjectURL(url); } catch (e) { console.error('Failed to save file:', e); alert('Failed to save file. Please try again.'); } } // === 新增功能:字体大小调整 === // 简单的设置保存 saveSettings() { try { const data = { fontSize: this.fontSize }; localStorage.setItem('korean-input-data', JSON.stringify(data)); } catch (e) { console.warn('Failed to save settings to localStorage:', e); } } // 从localStorage加载设置 loadSettings() { const savedData = localStorage.getItem('korean-input-data'); if (savedData) { try { const data = JSON.parse(savedData); if (data.fontSize) { this.fontSize = data.fontSize; } } catch (e) { console.warn('Failed to load settings from localStorage:', e); } } } // 获取当前字体大小 getFontSize() { return this.fontSize; } // 设置字体大小 setFontSize(size) { this.fontSize = Math.max(12, Math.min(36, size)); // 限制在12-36px之间 const textarea = document.getElementById('koreanInput'); if (textarea) { textarea.style.fontSize = this.fontSize + 'px'; } this.saveSettings(); // 保存字体大小设置 return this.fontSize; } // 增大字体 increaseFontSize() { return this.setFontSize(this.fontSize + 2); } // 减小字体 decreaseFontSize() { return this.setFontSize(this.fontSize - 2); } // 初始化字体大小 initializeFontSize() { this.loadSettings(); // 加载保存的设置 const textarea = document.getElementById('koreanInput'); if (textarea) { textarea.style.fontSize = this.fontSize + 'px'; } } } // DOM加载完成后初始化 document.addEventListener('DOMContentLoaded', function() { const koreanInput = new KoreanInput(); const textarea = document.getElementById('koreanInput'); const copyBtn = document.getElementById('copyBtn'); const clearBtn = document.getElementById('clearBtn'); const saveBtn = document.getElementById('saveBtn'); const fontSmallerBtn = document.getElementById('fontSmallerBtn'); const fontLargerBtn = document.getElementById('fontLargerBtn'); // 初始化字体大小 koreanInput.initializeFontSize(); // 初始化虚拟键盘和语言切换器 window.virtualKeyboard = new VirtualKeyboard(koreanInput); window.languageSwitcher = new LanguageSwitcher(); // 初始化界面语言 window.languageManager.updateUI(); // 修复初始化时序问题:确保虚拟键盘的多语言文字正确显示 if (window.virtualKeyboard) { // 更新功能键文字(修复空格键显示问题) window.virtualKeyboard.updateFunctionKeysText(); // 如果是移动设备且当前处于虚拟键盘模式,更新状态指示器 if (window.virtualKeyboard.isMobileDevice && window.virtualKeyboard.isVirtualKeyboardMode) { window.virtualKeyboard.updateKeyboardStateIndicator(false); } } // 键盘事件监听 textarea.addEventListener('keydown', function(e) { // 检查输入来源,如果是虚拟键盘输入且在移动设备上,则忽略 if (window.virtualKeyboard && window.virtualKeyboard.inputSource === 'virtual' && window.virtualKeyboard.isMobileDevice) { return; } if (e.key === 'Backspace') { e.preventDefault(); // 获取光标位置信息 const cursorStart = this.selectionStart; const cursorEnd = this.selectionEnd; if (cursorStart === cursorEnd) { // 普通删除:删除光标前的一个字符 if (cursorStart > 0) { const textBefore = this.value.substring(0, cursorStart); const textAfter = this.value.substring(cursorStart); // 对光标前的文本应用韩文智能退格逻辑 const newTextBefore = koreanInput.handleBackspace(textBefore); const newText = newTextBefore + textAfter; this.value = newText; // 设置新的光标位置 const newCursorPos = newTextBefore.length; this.setSelectionRange(newCursorPos, newCursorPos); // 基于光标位置智能恢复韩文输入状态 if (newCursorPos > 0) { const charBeforeCursor = newText[newCursorPos - 1]; koreanInput.restoreStateFromCharacter(charBeforeCursor); } else { koreanInput.reset(); } } } else { // 选区删除:删除选中的文本 const textBefore = this.value.substring(0, cursorStart); const textAfter = this.value.substring(cursorEnd); const newText = textBefore + textAfter; this.value = newText; this.setSelectionRange(cursorStart, cursorStart); // 基于光标位置智能恢复韩文输入状态 if (cursorStart > 0) { const charBeforeCursor = newText[cursorStart - 1]; koreanInput.restoreStateFromCharacter(charBeforeCursor); } else { koreanInput.reset(); } } // 注意:这里不再调用 koreanInput.reset(),因为状态已经在上面智能恢复了 return; } if (e.key === ' ') { e.preventDefault(); const cursorStart = this.selectionStart; const textBefore = this.value.substring(0, cursorStart); const textAfter = this.value.substring(this.selectionEnd); this.value = textBefore + ' ' + textAfter; // 移动光标到空格之后 const newCursorPos = cursorStart + 1; this.setSelectionRange(newCursorPos, newCursorPos); // 重置韩文输入状态 koreanInput.reset(); return; } // 忽略其他特殊键 if (e.key.length > 1 && e.key !== 'Shift') return; const char = koreanInput.getCharacter(e.key, e.shiftKey); if (char) { e.preventDefault(); const result = koreanInput.processInput(char); // 更新输入框内容 const cursorPos = this.selectionStart; const textBefore = this.value.substring(0, cursorPos); const textAfter = this.value.substring(this.selectionEnd); // 全新的韩文输入逻辑:智能光标插入处理 const insertResult = koreanInput.smartInsert(this.value, cursorPos, this.selectionEnd, result); // 更新文本和光标位置 this.value = insertResult.text; this.setSelectionRange(insertResult.cursorPosition, insertResult.cursorPosition); } }); // 添加触摸和焦点事件监听,区分输入来源 textarea.addEventListener('touchstart', function(e) { if (window.virtualKeyboard) { window.virtualKeyboard.inputSource = 'touch'; // 同步虚拟光标位置 setTimeout(() => { window.virtualKeyboard.syncVirtualCursor(); }, 50); } }); textarea.addEventListener('mousedown', function(e) { if (window.virtualKeyboard) { window.virtualKeyboard.inputSource = 'mouse'; // 同步虚拟光标位置 setTimeout(() => { window.virtualKeyboard.syncVirtualCursor(); }, 50); } }); textarea.addEventListener('focus', function(e) { if (window.virtualKeyboard) { // 如果是虚拟键盘操作导致的焦点,立即失焦 if (window.virtualKeyboard.isMobileDevice && window.virtualKeyboard.isVirtualKeyboardMode) { e.preventDefault(); this.blur(); return false; } // 如果是移动设备且输入源不是虚拟键盘,允许系统键盘弹出 if (window.virtualKeyboard.isMobileDevice && window.virtualKeyboard.inputSource !== 'virtual') { // 正常的焦点行为,允许系统键盘弹出 window.virtualKeyboard.syncVirtualCursor(); } } koreanInput.reset(); }); // 监听自定义虚拟输入事件 textarea.addEventListener('virtualinput', function(e) { // 处理虚拟输入事件,这里可以添加额外的处理逻辑 // 例如更新其他UI元素等 }); // 监听光标位置变化(selectionchange事件) document.addEventListener('selectionchange', function() { if (window.virtualKeyboard && document.activeElement === textarea) { // 用户直接在textarea中移动了光标,同步虚拟光标 if (!window.virtualKeyboard.isVirtualKeyboardMode) { window.virtualKeyboard.syncVirtualCursor(); } } }); // 监听文本选择变化(兼容性更好的方式) textarea.addEventListener('selectionchange', function() { if (window.virtualKeyboard && !window.virtualKeyboard.isVirtualKeyboardMode) { window.virtualKeyboard.syncVirtualCursor(); } }); // 监听键盘事件同步光标(用户直接使用物理键盘时) textarea.addEventListener('keyup', function(e) { if (window.virtualKeyboard && !window.virtualKeyboard.isVirtualKeyboardMode) { window.virtualKeyboard.syncVirtualCursor(); } }); // 复制功能 copyBtn.addEventListener('click', function() { textarea.select(); document.execCommand('copy'); // 显示复制成功提示(多语言支持) const originalText = this.textContent; this.textContent = window.languageManager.getText('copiedMsg'); setTimeout(() => { this.textContent = originalText; }, 1000); }); // 清空功能 clearBtn.addEventListener('click', function() { textarea.value = ''; koreanInput.reset(); window.virtualKeyboard.reset(); textarea.focus(); }); // 保存文本功能 saveBtn.addEventListener('click', function() { koreanInput.saveTextAsFile(textarea.value); textarea.focus(); }); // 缩小字体功能 fontSmallerBtn.addEventListener('click', function() { koreanInput.decreaseFontSize(); textarea.focus(); }); // 放大字体功能 fontLargerBtn.addEventListener('click', function() { koreanInput.increaseFontSize(); textarea.focus(); }); // 点击事件重置状态和同步光标 textarea.addEventListener('click', function() { koreanInput.reset(); // 设置输入源为直接输入 if (window.virtualKeyboard) { window.virtualKeyboard.inputSource = 'direct'; // 同步虚拟光标位置 window.virtualKeyboard.syncVirtualCursor(); } }); });
좋아. 네 코드에 “특정 키로 한/영 전환”을 붙이려면, 입력 파이프라인의 맨 앞에 IME 모드 상태(ko/en)를 두고, 단축키로 그 값을 토글하면 돼. ko일 때만 지금의 한글 조합 로직을 실행하고, en일 때는 브라우저 기본 입력을 그대로 통과시키면 된다. 아래는 네가 올린 소스에 최소 침습으로 추가/변경해야 할 부분만 단계별로 정리한 거야.
1) KoreanInput 클래스에 모드 상태와 API 추가
생성자에 모드 상태를 넣고 로컬스토리지에 저장·복원하도록 해. 그리고 토글/설정 메서드를 추가.
js
class KoreanInput {
constructor() {
// ... 기존 코드 그대로 ...
// IME 모드: 'ko' | 'en'
this.mode = (localStorage.getItem('imeMode') === 'en') ? 'en' : 'ko';
}
setMode(mode) {
this.mode = (mode === 'en') ? 'en' : 'ko';
localStorage.setItem('imeMode', this.mode);
// 모드에 따라 상태 초기화가 필요하면 여기서 처리
if (this.mode === 'en') this.reset();
}
toggleMode() {
this.setMode(this.mode === 'ko' ? 'en' : 'ko');
// 모드 표시 UI가 있다면 여기서 갱신
const chip = document.getElementById('imeMode');
if (chip) {
chip.textContent = (this.mode === 'ko') ? 'KO' : 'EN';
chip.setAttribute('data-mode', this.mode);
}
}
// ... 나머지 메서드 그대로 ...
}
선택 사항: 모드 상태를 화면에 표시하고 싶다면 HTML에 작은 칩을 하나 두면 좋아.
<div class="ime-status"><span id="imeMode">KO</span></div>
초기화 시점에 한번 동기화:
js
// DOMContentLoaded 내부
const chip = document.getElementById('imeMode');
if (chip) {
chip.textContent = (koreanInput.mode === 'ko') ? 'KO' : 'EN';
chip.setAttribute('data-mode', koreanInput.mode);
}
2) 단축키 정의: Ctrl+Space 또는 F2
OS 단축키와 충돌 가능성을 줄이려고 F2도 함께 지원하자.
js
function isToggleHotkey(e) {
// Ctrl+Space 또는 F2
return (e.ctrlKey && !e.shiftKey && !e.altKey && e.code === 'Space') || (e.code === 'F2');
}
3) keydown 핸들러를 모드 기반으로 분기
지금 keydown은 Backspace, Space, 일반 문자 등을 모두 가로채서 한글 조합으로 넣고 있어. 이 로직을 “ko일 때만” 실행하도록 감싸고, “en일 때는” 브라우저 기본 입력을 그대로 흐르게 두면 된다. 즉, 기존의 preventDefault()들을 ko 모드에서만 수행하게 만드는 게 핵심.
아래는 네 keydown 리스너의 핵심 부분을 안전하게 바꾼 버전이야. 바뀐 줄에 주석을 달아놨다.
js
textarea.addEventListener('keydown', function(e) {
// 가상키보드 입력 통과 로직은 그대로
if (window.virtualKeyboard && window.virtualKeyboard.inputSource === 'virtual' && window.virtualKeyboard.isMobileDevice) {
return;
}
// 1) 한/영 토글 단축키
if (isToggleHotkey(e)) {
e.preventDefault();
koreanInput.toggleMode();
return;
}
// 2) EN 모드: 브라우저 기본 입력을 그대로 사용
if (koreanInput.mode === 'en') {
return; // 어떤 키도 가로채지 않음
}
// 3) KO 모드: 기존 한글 조합 로직 실행
if (e.key === 'Backspace') {
e.preventDefault();
const cursorStart = this.selectionStart;
const cursorEnd = this.selectionEnd;
if (cursorStart === cursorEnd) {
if (cursorStart > 0) {
const textBefore = this.value.substring(0, cursorStart);
const textAfter = this.value.substring(cursorStart);
const newTextBefore = koreanInput.handleBackspace(textBefore);
const newText = newTextBefore + textAfter;
this.value = newText;
const newCursorPos = newTextBefore.length;
this.setSelectionRange(newCursorPos, newCursorPos);
if (newCursorPos > 0) {
const charBeforeCursor = newText[newCursorPos - 1];
koreanInput.restoreStateFromCharacter(charBeforeCursor);
} else {
koreanInput.reset();
}
}
} else {
const textBefore = this.value.substring(0, cursorStart);
const textAfter = this.value.substring(cursorEnd);
const newText = textBefore + textAfter;
this.value = newText;
this.setSelectionRange(cursorStart, cursorStart);
if (cursorStart > 0) {
const charBeforeCursor = newText[cursorStart - 1];
koreanInput.restoreStateFromCharacter(charBeforeCursor);
} else {
koreanInput.reset();
}
}
return;
}
if (e.key === ' ') {
e.preventDefault();
const cursorStart = this.selectionStart;
const textBefore = this.value.substring(0, cursorStart);
const textAfter = this.value.substring(this.selectionEnd);
this.value = textBefore + ' ' + textAfter;
const newCursorPos = cursorStart + 1;
this.setSelectionRange(newCursorPos, newCursorPos);
koreanInput.reset();
return;
}
// 특수키 무시(Shift 등)
if (e.key.length > 1 && e.key !== 'Shift') return;
const char = koreanInput.getCharacter(e.key, e.shiftKey);
if (char) {
e.preventDefault();
const result = koreanInput.processInput(char);
const cursorPos = this.selectionStart;
const insertResult = koreanInput.smartInsert(this.value, cursorPos, this.selectionEnd, result);
this.value = insertResult.text;
this.setSelectionRange(insertResult.cursorPosition, insertResult.cursorPosition);
return;
}
// 매핑되지 않은 키는 KO 모드에서도 통과시킬지, 막을지 정책 결정
// 필요하면 아래에서 e.preventDefault()를 제거하거나 추가
});
핵심 포인트
-
토글 단축키를 가장 먼저 처리해 즉시 모드를 바꾼다.
-
en 모드에서는 아무것도 가로채지 않는다. Backspace/Space/일반 문자 포함 전부 브라우저 기본 동작.
-
ko 모드일 때만 기존 한글 조합 로직이 실행된다.
4) 모바일·가상키보드와 동기화(선택)
화면 키보드에도 한/영 키를 달았다면, 그 버튼 클릭 시 koreanInput.toggleMode()를 호출해주면 된다. 예시:
js
// virtual-keyboard.js에서
buttonHanEng.addEventListener('click', () => {
if (window.koreanInput) window.koreanInput.toggleMode();
});
5) 사용성 팁
단축키는 Ctrl+Space가 OS 레벨에서 먼저 먹을 수 있다. 그럴 때를 대비해 F2 같은 대체 키를 함께 두는 게 안전하다. 모드 칩을 표시하면 사용자 혼란도 줄어든다.
이렇게 붙이면, 사이트는 그대로 유지하면서도 특정 키로 한/영 모드를 전환할 수 있어. 더 빡빡하게 통제하고 싶다면 en 모드일 때도 일부 키(예: Backspace만)는 가로채서 너의 selection 관리 로직에 맞추는 식으로 세분화할 수 있다.
ime라고 하니까 좀 헷갈리는데 os의 ime랑은 전혀 상관없는거지?
맞아. 여기서 말하는 건 OS의 한글 입력기(IME)랑 전혀 별개야. 네 코드는 “페이지 안에서 키 이벤트를 받아 영→한으로 조합해 넣는 자바스크립트 입력기”일 뿐, 운영체제의 IME 상태를 바꾸거나 읽을 수 없어.
뭐가 어떻게 다른가
-
OS IME: 시스템 전역. 앱·브라우저 어디서나 적용. 단축키(예: 윈도우/맥 한영 전환)로 토글되고, 브라우저가 제어 못 함.
-
네 JS 입력기: 특정
textarea에서만 동작.keydown을 가로채서 자모 매핑→조합 알고리즘으로 텍스트를 “넣어줄” 뿐. 페이지 밖에는 영향 없음.
함께 켜졌을 때의 상호작용
-
OS IME가 “한글”로 켜져 있으면 브라우저가 이미 조합을 수행하므로, 너의 JS 조합과 충돌하거나 이중 입력처럼 보일 수 있다.
-
OS 단축키(Ctrl+Space 등)는 OS가 먼저 먹을 수 있어, 너의 페이지에
keydown이 아예 안 오기도 한다.
안전하게 쓰는 가드(강력 추천)
OS IME로 실제 조합 중일 때는 이벤트에 isComposing 플래그가 선다. 이때는 네 커스텀 조합을 끄면 충돌이 사라진다.
js
textarea.addEventListener('keydown', (e) => {
// OS IME 조합 중이거나 IME가 'Process' 키를 보낼 땐 건드리지 않음
if (e.isComposing || e.key === 'Process') return;
// 네 ‘한/영 모드’ 토글
if (isToggleHotkey(e)) { e.preventDefault(); koreanInput.toggleMode(); return; }
// EN 모드면 기본 입력 통과
if (koreanInput.mode === 'en') return;
// KO 모드면 기존 커스텀 조합 로직 실행
// …(지금 갖고 있는 로직)
});
추가로, 충돌 가능성을 더 낮추려면 입력창 속성을 이렇게 세팅해 둬:
<textarea id="koreanInput"
autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"></textarea>
(IME 자체를 끄진 못하지만, 자동 교정/철자 교정 같은 브라우저 레이어 간섭을 줄여준다.)
모바일에서의 차이
- 모바일 소프트 키보드는 물리 키 이벤트가 제한적이라 JS 조합기로만 처리하기 어렵다. 화면에 “한/영 토글” 버튼을 별도로 제공해
toggleMode()를 직접 호출하는 식이 안정적이다.
용어 정리(헷갈림 방지)
-
“OS IME”는 그대로 IME.
-
네 쪽은 “인페이지 한글 조합기”, “커스텀 입력기”, 혹은 UI상 “KO/EN 모드”처럼 부르는 게 명확하다.
현실적인 운용 팁
-
페이지 어딘가에 현재 모드 칩(KO/EN)을 표시하고, 처음엔 “OS 한글 입력은 꺼 주세요” 같은 안내를 보여주면 사용자 혼란이 크게 줄어든다.
-
단축키는 OS와 충돌 가능한 Ctrl+Space 외에 F2/RightAlt 등 대체 키를 함께 두는 게 좋다.
이렇게 isComposing 가드와 모드 표기를 붙이면, OS IME와 완전히 독립적으로 동작하면서도 충돌을 최소화할 수 있다.
모든 텍스트 에리아에 이 기능을 넣고 싶어 그리고 너가 말한 대로 페이지에 현재 모드를 아이콘으로 보여주고 싶어
좋다—이제 “페이지의 모든 <textarea>에 한/영 전환형 한글 조합기”를 붙이고, 화면 구석에 모드 아이콘(KO/EN) 을 띄워서 전역 상태를 보여주자. 핵심은 전역 모드 상태(ko/en) + 공용 KoreanInput 인스턴스 + 모든 텍스트에리아에 동일한 키 핸들러를 붙이는 거야. 동적으로 추가되는 textarea도 MutationObserver로 커버하자.
아래는 네 현재 구조를 최대한 유지하면서 “전역 적용”과 “아이콘 표시”를 추가한 코드 세트야.
1) 모드 아이콘 UI 추가 (HTML + CSS)
페이지 공통 위치(우하단)에 작게 떠 있는 칩을 만든다.
<!-- body 끝나기 직전(footer 위나 아래)에 추가 -->
<div id="imeBadge" class="ime-badge" aria-live="polite" title="Ctrl+Space 또는 F2로 전환"></div>
/* style.css에 추가 */
.ime-badge{
position: fixed;
right: 16px;
bottom: 16px;
z-index: 9999;
min-width: 36px;
padding: 6px 10px;
border-radius: 16px;
border: 1px solid currentColor;
font: 12px/1.2 system-ui, sans-serif;
text-align: center;
opacity: .8;
background: rgba(255,255,255,.85);
backdrop-filter: blur(6px);
cursor: default;
user-select: none;
}
.ime-badge[data-mode="ko"]::after{ content: "KO"; }
.ime-badge[data-mode="en"]::after{ content: "EN"; }
/* textarea 기본 옵션(권장) */
textarea{
autocomplete: off;
autocapitalize: off;
spellcheck: false;
}
2) KoreanInput 클래스: 전역 모드 API 확장
이미 넣어둔 mode, setMode, toggleMode를 전역에서 호출하기 쉽게 조금 일반화하고, 초기 배지와 동기화까지 포함한다.
js
// KoreanInput 클래스 내부에 이미 제안했던 것 + 배지 반영
setMode(mode) {
this.mode = (mode === 'en') ? 'en' : 'ko';
localStorage.setItem('imeMode', this.mode);
if (this.mode === 'en') this.reset();
const badge = document.getElementById('imeBadge');
if (badge){
badge.setAttribute('data-mode', this.mode);
// 살짝 깜빡이는 피드백
badge.style.transition = 'background .15s';
badge.style.background = 'rgba(0,0,0,.08)';
setTimeout(()=> badge.style.background = 'rgba(255,255,255,.85)', 120);
}
}
toggleMode(){ this.setMode(this.mode === 'ko' ? 'en' : 'ko'); }
DOMContentLoaded에서 배지를 현재 모드와 동기화:
js
// DOMContentLoaded 내부
const badge = document.getElementById('imeBadge');
if (badge){
badge.setAttribute('data-mode', koreanInput.mode);
}
3) 모든 textarea에 붙는 핸들러 유틸
특정 요소에 붙이던 keydown 로직을 “재사용 가능한 함수”로 만든다. 이 함수는 네 기존 keydown 내용(Backspace·Space·일반문자 처리)을 그대로 활용하되, isComposing 가드 와 모드 분기 를 맨 앞에 둔다.
js
// 전역 유틸: 한/영 토글 핫키
function isToggleHotkey(e){
return (e.ctrlKey && !e.shiftKey && !e.altKey && e.code === 'Space') || (e.code === 'F2');
}
// 특정 textarea에 조합기를 장착
function attachKoreanHandlers(textarea, koreanInput){
if (!textarea || textarea.__koreanAttached) return;
textarea.__koreanAttached = true;
// 포커스 시 상태 초기화(에디터 전환 시 잔상 방지)
textarea.addEventListener('focus', (e)=>{
// 모바일 가상키보드 모드 등 네 로직이 있으면 그대로 유지
koreanInput.reset();
});
// 키다운: 전역 모드 + 가드
textarea.addEventListener('keydown', function(e){
// OS IME 조합 중이거나 Process 키면 건드리지 않음
if (e.isComposing || e.key === 'Process') return;
// 전역 토글
if (isToggleHotkey(e)){
e.preventDefault();
koreanInput.toggleMode();
return;
}
// EN 모드: 기본 입력 통과 (아무것도 막지 않음)
if (koreanInput.mode === 'en') return;
// ===== 아래부터는 KO 모드에서만 실행: 네 기존 로직을 그대로 이식 =====
if (e.key === 'Backspace'){
e.preventDefault();
const cursorStart = this.selectionStart;
const cursorEnd = this.selectionEnd;
if (cursorStart === cursorEnd){
if (cursorStart > 0){
const textBefore = this.value.substring(0, cursorStart);
const textAfter = this.value.substring(cursorStart);
const newTextBefore = koreanInput.handleBackspace(textBefore);
const newText = newTextBefore + textAfter;
this.value = newText;
const newCursorPos = newTextBefore.length;
this.setSelectionRange(newCursorPos, newCursorPos);
if (newCursorPos > 0){
const charBeforeCursor = newText[newCursorPos - 1];
koreanInput.restoreStateFromCharacter(charBeforeCursor);
} else {
koreanInput.reset();
}
}
} else {
const textBefore = this.value.substring(0, cursorStart);
const textAfter = this.value.substring(cursorEnd);
const newText = textBefore + textAfter;
this.value = newText;
this.setSelectionRange(cursorStart, cursorStart);
if (cursorStart > 0){
const charBeforeCursor = newText[cursorStart - 1];
koreanInput.restoreStateFromCharacter(charBeforeCursor);
} else {
koreanInput.reset();
}
}
return;
}
if (e.key === ' '){
e.preventDefault();
const cursorStart = this.selectionStart;
const textBefore = this.value.substring(0, cursorStart);
const textAfter = this.value.substring(this.selectionEnd);
this.value = textBefore + ' ' + textAfter;
const newCursorPos = cursorStart + 1;
this.setSelectionRange(newCursorPos, newCursorPos);
koreanInput.reset();
return;
}
// 특수키 무시(Shift 등)
if (e.key.length > 1 && e.key !== 'Shift') return;
const char = koreanInput.getCharacter(e.key, e.shiftKey);
if (char){
e.preventDefault();
const result = koreanInput.processInput(char);
const cursorPos = this.selectionStart;
const insertResult = koreanInput.smartInsert(this.value, cursorPos, this.selectionEnd, result);
this.value = insertResult.text;
this.setSelectionRange(insertResult.cursorPosition, insertResult.cursorPosition);
}
});
// 클릭/마우스/터치로 커서 바꿨을 때 상태 초기화(네 기존 방식 유지)
textarea.addEventListener('click', ()=>{
koreanInput.reset();
});
textarea.addEventListener('keyup', ()=>{
// 필요시 가상커서 동기화 등 넣을 자리
});
}
4) 한 번에 모두 장착 + 동적 추가도 커버
페이지 로드시 모든 <textarea>를 찾아 장착하고, DOM에 새로운 textarea가 생기면 자동으로 붙인다.
js
document.addEventListener('DOMContentLoaded', function(){
const koreanInput = new KoreanInput();
window.koreanInput = koreanInput; // 다른 스크립트에서 접근 가능하도록
// 배지 초기 상태 반영
const badge = document.getElementById('imeBadge');
if (badge){ badge.setAttribute('data-mode', koreanInput.mode); }
// 페이지의 모든 textarea에 장착
document.querySelectorAll('textarea').forEach(el=>{
// 권장 옵션 적용
el.setAttribute('autocomplete','off');
el.setAttribute('autocapitalize','off');
el.setAttribute('spellcheck','false');
attachKoreanHandlers(el, koreanInput);
});
// 동적 추가 대응
const mo = new MutationObserver((mutations)=>{
for (const m of mutations){
m.addedNodes && m.addedNodes.forEach(node=>{
if (node.nodeType === 1){ // ELEMENT_NODE
if (node.matches && node.matches('textarea')){
node.setAttribute('autocomplete','off');
node.setAttribute('autocapitalize','off');
node.setAttribute('spellcheck','false');
attachKoreanHandlers(node, koreanInput);
}
// 하위에 여러 개 생기는 케이스
node.querySelectorAll && node.querySelectorAll('textarea').forEach(el=>{
el.setAttribute('autocomplete','off');
el.setAttribute('autocapitalize','off');
el.setAttribute('spellcheck','false');
attachKoreanHandlers(el, koreanInput);
});
}
});
}
});
mo.observe(document.documentElement, { childList:true, subtree:true });
// 전역 키로도 토글 가능(포커스가 textarea가 아니어도)
document.addEventListener('keydown', (e)=>{
if (e.isComposing || e.key === 'Process') return;
if (isToggleHotkey(e)){
e.preventDefault();
koreanInput.toggleMode();
}
});
// 배지 클릭으로도 전환(모바일 배려)
if (badge){
badge.addEventListener('click', ()=> koreanInput.toggleMode());
}
});
5) 선택 옵션
-
<input type="text">에도 적용하고 싶다면querySelectorAll('textarea, input[type="text"], input[type="search"]')로 확장하되, selection API가 동일하게 동작하는지 확인하자. 지금 로직은 대부분 호환된다. -
contenteditable까지 커버하려면 selection 처리와 text node 교체가 달라서 별 어댑터가 필요하다. 요청하면 그 버전도 만들어줄 수 있다. -
가상 키보드와 모드 동기화가 필요하면, 가상 키보드의 한/영 버튼 클릭 시
window.koreanInput.toggleMode()만 호출해주면 끝난다.
동작 요약
-
전역 모드 상태는
localStorage('imeMode')에 저장되어 새로고침해도 유지. -
화면 우하단 배지에 KO/EN 표시. 클릭하거나 Ctrl+Space, F2로 전환.
-
모든
<textarea>에서 KO 모드일 때만 한글 조합(네 로직)이 동작, EN 모드면 브라우저 기본 입력. -
OS IME가 조합 중일 때는
e.isComposing가드로 충돌 방지.
원하면 <input>/contenteditable 확장과, 배지 디자인(아이콘 SVG, 다크모드, 접근성 라벨)도 덧입혀서 깔끔하게 정리해줄게.
좋아 잘 되고 있다. 그런데 Ctrl+a, Ctrl+x 같은 키가 제대로 동작을 안해 이거 고쳐줄 수 있어?
좋아—바로 고칠 수 있어. 핵심은 Ctrl/⌘(Meta)와 조합된 단축키는 절대preventDefault로 막지 않는 것이야. 지금 keydown 핸들러가 KO 모드에서 Backspace·Space·문자 입력을 가로채다 보니 Ctrl+A / X / C / V / Z / Y 같은 표준 단축키가 비정상 동작하는 거지.
아래처럼 “수정 라인만” 추가하면 깔끔하게 해결돼.
1) 보조 함수 추가
js
function isToggleHotkey(e){
return (e.ctrlKey && !e.shiftKey && !e.altKey && e.code === 'Space') || (e.code === 'F2');
}
// Ctrl/Meta 조합(표준 단축키)면 브라우저에 맡기기
function isEditorShortcut(e){
// AltGraph(우Alt)나 Ctrl+Alt 같은 조합도 통과
const altGraph = e.getModifierState && e.getModifierState('AltGraph');
const ctrlMeta = e.ctrlKey || e.metaKey;
const ctrlAlt = e.ctrlKey && e.altKey;
return altGraph || ctrlAlt || ctrlMeta;
}
2) keydown 핸들러 앞부분 분기 강화
네가 모든 <textarea>에 붙이는 keydown 리스너 안에서, 가장 위쪽에 아래 가드를 넣어.
js
textarea.addEventListener('keydown', function(e){
// OS IME 조합 중이면 건드리지 않음
if (e.isComposing || e.key === 'Process') return;
// 전역 토글 단축키 우선 처리
if (isToggleHotkey(e)){
e.preventDefault();
koreanInput.toggleMode();
return;
}
// ⭐ 표준 편집 단축키는 무조건 통과(Ctrl/⌘, AltGraph 등)
if (isEditorShortcut(e)){
return; // Ctrl+A/X/C/V/Z/Y, Ctrl+←/→ 등 전부 브라우저 기본 동작
}
// EN 모드는 전부 통과
if (koreanInput.mode === 'en') return;
// ----- 이하 KO 모드 전용 로직(기존 코드 유지) -----
// Backspace/Space/문자 처리...
});
3) KO 모드 내부에서도 “조합된 Backspace” 예외 처리
Ctrl+Backspace(단어 단위 삭제)나 Ctrl+Delete도 기본 동작을 살리고 싶다면 KO 블록 진입 이전 에 이미 isEditorShortcut(e)에서 빠져나가므로 추가 변경 없이 해결돼. 다만 혹시 아래처럼 KO 블록 안쪽에 Backspace를 먼저 잡아버리는 코드가 있다면, 한 줄만 보강하면 된다.
js
if (e.key === 'Backspace'){
// ⭐ 단어 삭제 같은 조합은 통과
// (여기까지 오면 Ctrl/Meta는 이미 걸러졌지만, 남겨두면 안전)
if (e.ctrlKey || e.metaKey) return;
e.preventDefault();
// ... 네 기존 handleBackspace 로직 ...
return;
}
Space도 마찬가지로 조합키와 함께 누르면 통과:
js
if (e.key === ' '){
// 예: Shift+Space 같은 변형을 통과시키고 싶다면 조건을 확장해도 됨
if (e.ctrlKey || e.altKey || e.metaKey) return;
e.preventDefault();
// ... 기존 공백 처리 ...
return;
}
4) 문자 입력 차단 조건 정리
문자 키만 가로채고, 그 외 편집 키는 통과시키려면 이렇게 두면 안전해.
js
// 특수키 무시(Shift 등)
if (e.key.length > 1 && e.key !== 'Shift') return;
// 여기서부터는 "일반 문자"만 가로챔
const char = koreanInput.getCharacter(e.key, e.shiftKey);
if (char){
e.preventDefault();
const result = koreanInput.processInput(char);
const cursorPos = this.selectionStart;
const insertResult = koreanInput.smartInsert(this.value, cursorPos, this.selectionEnd, result);
this.value = insertResult.text;
this.setSelectionRange(insertResult.cursorPosition, insertResult.cursorPosition);
}
5) 결과
-
Ctrl+A전체선택,Ctrl+X/C/V잘린/복사/붙여넣기,Ctrl+Z/Y되돌리기/다시하기,Ctrl+←/→단어 단위 이동,Ctrl+Backspace/Delete단어 삭제 모두 정상. -
KO 모드에서만 한글 조합이 적용되고, EN 모드/표준 단축키/OS IME 조합 상태는 전부 브라우저 기본 편집 흐름을 유지.
추가로, Edge/Windows에서 AltGraph(우Alt) 가 잡히는 환경이 종종 있어서 isEditorShortcut에 AltGraph를 꼭 포함했어. 이걸 빼면 일부 기호 입력이 막힐 수 있다.
원하면 <input type="text">나 contenteditable까지 확장할 때의 단축키/선택 영역 처리도 같은 원칙으로 정리해 줄게.
input 도 같은 방식으로 처리하고 싶어
좋지! <input>도 <textarea>와 똑같은 방식으로 처리할 수 있어. 핵심은 같은 키다운 핸들러를 재사용하고, 셀렉터를 <textarea> + 텍스트계열 <input>으로 확장하는 거야. 아래 코드를 그대로 추가·교체하면 된다.
1) 유틸 함수 그대로 사용
이미 쓰던 토글/단축키 가드 유지.
js
function isToggleHotkey(e){
return (e.ctrlKey && !e.shiftKey && !e.altKey && e.code === 'Space') || (e.code === 'F2');
}
function isEditorShortcut(e){
const altGraph = e.getModifierState && e.getModifierState('AltGraph');
const ctrlMeta = e.ctrlKey || e.metaKey;
const ctrlAlt = e.ctrlKey && e.altKey;
return altGraph || ctrlAlt || ctrlMeta;
}
2) 공통 장착 함수: <textarea>와 <input> 모두 지원
기존 attachKoreanHandlers를 살짝 일반화해 어느 편집 요소든 붙도록 한다.
js
function attachKoreanHandlers(editEl, koreanInput){
if (!editEl || editEl.__koreanAttached) return;
editEl.__koreanAttached = true;
// 권장 옵션(자동 교정 비활성)
editEl.setAttribute('autocomplete','off');
editEl.setAttribute('autocapitalize','off');
editEl.setAttribute('spellcheck','false');
// 포커스 시 상태 초기화
editEl.addEventListener('focus', ()=>{
koreanInput.reset();
});
// 키다운: 공통 처리
editEl.addEventListener('keydown', function(e){
if (e.isComposing || e.key === 'Process') return;
if (isToggleHotkey(e)){
e.preventDefault();
koreanInput.toggleMode();
return;
}
// Ctrl/Meta 등 편집 단축키는 통과
if (isEditorShortcut(e)) return;
// EN 모드는 통과
if (koreanInput.mode === 'en') return;
// KO 모드: 기존 로직
if (e.key === 'Backspace'){
if (e.ctrlKey || e.metaKey) return; // 단어 삭제 등 통과
e.preventDefault();
const cursorStart = this.selectionStart;
const cursorEnd = this.selectionEnd;
if (cursorStart === cursorEnd){
if (cursorStart > 0){
const textBefore = this.value.substring(0, cursorStart);
const textAfter = this.value.substring(cursorStart);
const newTextBefore = koreanInput.handleBackspace(textBefore);
const newText = newTextBefore + textAfter;
this.value = newText;
const newCursorPos = newTextBefore.length;
this.setSelectionRange(newCursorPos, newCursorPos);
if (newCursorPos > 0){
const charBeforeCursor = newText[newCursorPos - 1];
koreanInput.restoreStateFromCharacter(charBeforeCursor);
} else {
koreanInput.reset();
}
}
} else {
const textBefore = this.value.substring(0, cursorStart);
const textAfter = this.value.substring(cursorEnd);
const newText = textBefore + textAfter;
this.value = newText;
this.setSelectionRange(cursorStart, cursorStart);
if (cursorStart > 0){
const charBeforeCursor = newText[cursorStart - 1];
koreanInput.restoreStateFromCharacter(charBeforeCursor);
} else {
koreanInput.reset();
}
}
return;
}
if (e.key === ' '){
if (e.ctrlKey || e.altKey || e.metaKey) return; // 조합 스페이스는 통과
e.preventDefault();
const cursorStart = this.selectionStart;
const textBefore = this.value.substring(0, cursorStart);
const textAfter = this.value.substring(this.selectionEnd);
this.value = textBefore + ' ' + textAfter;
const newCursorPos = cursorStart + 1;
this.setSelectionRange(newCursorPos, newCursorPos);
koreanInput.reset();
return;
}
// 특수키(Shift 등) 통과
if (e.key.length > 1 && e.key !== 'Shift') return;
const char = koreanInput.getCharacter(e.key, e.shiftKey);
if (char){
e.preventDefault();
const result = koreanInput.processInput(char);
const cursorPos = this.selectionStart;
const insertResult = koreanInput.smartInsert(this.value, cursorPos, this.selectionEnd, result);
this.value = insertResult.text;
this.setSelectionRange(insertResult.cursorPosition, insertResult.cursorPosition);
}
});
// 클릭/키업 등 상태 초기화
editEl.addEventListener('click', ()=> koreanInput.reset());
editEl.addEventListener('keyup', ()=> {/* 필요시 커서 동기화 자리 */});
}
3) 초기 장착: <textarea> + 텍스트계열 <input> 모두 선택
폼 입력 타입 중 텍스트로 편집 가능한 것들만 포함한다.
js
document.addEventListener('DOMContentLoaded', function(){
const koreanInput = new KoreanInput();
window.koreanInput = koreanInput;
// 모드 배지 초기화
const badge = document.getElementById('imeBadge');
if (badge) badge.setAttribute('data-mode', koreanInput.mode);
// 대상 셀렉터: textarea + text/search/url/tel/password/email (숫자/날짜 계열 제외)
const editableSelector = [
'textarea',
'input:not([type])',
'input[type="text"]',
'input[type="search"]',
'input[type="url"]',
'input[type="tel"]',
'input[type="password"]',
'input[type="email"]'
].join(',');
document.querySelectorAll(editableSelector).forEach(el=>{
attachKoreanHandlers(el, koreanInput);
});
// 동적 추가 대응
const mo = new MutationObserver(muts=>{
muts.forEach(m=>{
m.addedNodes && m.addedNodes.forEach(node=>{
if (node.nodeType !== 1) return;
if (node.matches && node.matches(editableSelector)){
attachKoreanHandlers(node, koreanInput);
}
node.querySelectorAll && node.querySelectorAll(editableSelector).forEach(el=>{
attachKoreanHandlers(el, koreanInput);
});
});
});
});
mo.observe(document.documentElement, { childList:true, subtree:true });
// 전역 토글(포커스가 편집 요소가 아니어도 동작)
document.addEventListener('keydown', (e)=>{
if (e.isComposing || e.key === 'Process') return;
if (isToggleHotkey(e)){
e.preventDefault();
koreanInput.toggleMode();
}
});
// 배지 클릭으로 모드 전환
if (badge){
badge.addEventListener('click', ()=> koreanInput.toggleMode());
}
});
4) 주의사항과 팁
-
숫자/날짜 입력은 브라우저 기본 위젯과 충돌하니 셀렉터에서 제외했다. 필요하면 타입을 텍스트로 바꿔서 사용해.
-
maxlength가 있는<input>은 합성 중 글자 수가 순간 늘었다 줄 수 있다. 문제 생기면smartInsert에서 길이 체크를 추가해서 최종 결과만 제한하도록 조정하자. -
붙여넣기 이벤트를 EN 모드에서만 통과시키고 KO 모드에서는 “붙여넣기 후 한글 재조합”을 하고 싶다면
paste이벤트에서preventDefault()후e.clipboardData.getData('text')를 읽어smartInsert로 흘려주면 된다.
이렇게 붙이면 페이지의 모든 텍스트 입력 필드에서 동일하게 한/영 전환형 한글 조합이 동작하고, 표준 단축키(Ctrl/⌘+A/X/C/V/Z/Y)도 제대로 살아난다. 다음으로 contenteditable까지 확장하고 싶으면 알려줘—그건 Selection API 어댑터만 추가하면 된다.
근데 한글 지울때 보면 작성 중인 글자는 한 개 자음/모음 지우지만 많이 지우면 한글자씩 다 지워지거든? 그걸 어떻게 구현할 수 있을까
맞아, 한국어 입력기들이 보통 이렇게 동작해:
-
작성 중(조합 중)일 땐 Backspace가 자모 단위 로 지움.
-
Backspace를 길게 누르거나 빠르게 연타 하면 음절(글자) 단위 로 쭉 지움.
이를 구현하려면 “백스페이스 모드”를 두고, 키 반복(e.repeat) 이나 연타 간격 으로 모드를 전환하면 돼.
아래는 네 코드에 최소 변경 으로 넣는 방법이야.
1) 조합(자모) 삭제 vs 음절 삭제 함수 분리
현재 handleBackspace()는 “자모 하나씩” 지우도록 되어 있어. 여기에 “음절 통삭제”용 함수를 하나 더 만든다.
js
// ① 자모-단위 삭제(네 기존 함수 그대로 사용)
handleBackspaceJamo(currentText){
return this.handleBackspace(currentText);
}
// ② 음절-단위 삭제: 마지막 글자가 한글 음절이면 통째로 1코드포인트 삭제
handleBackspaceSyllable(currentText){
if (currentText.length === 0) return '';
const last = currentText.charCodeAt(currentText.length - 1);
const isHangulSyllable = (last >= this.SBase) && (last < this.SBase + this.SCount);
// 음절이면 통으로 하나 삭제, 아니면 일반 문자처럼 하나 삭제
return currentText.slice(0, currentText.length - 1);
}
2) “길게 누름/연타” 감지 상태 추가
Backspace를 길게 누르면 브라우저가 keydown을 반복해서 주는데, 이때 e.repeat === true. 또, 재빨리 연타 해도 음절 단위로 바꾸고 싶다면 “마지막 백스페이스 시각”을 기억해서 간격이 짧으면 음절 삭제 모드로 전환하자.
js
// 전역 또는 KoreanInput 내부(편한 곳)에 상태 변수
let bsLastTs = 0;
const BS_RAPID_MS = 220; // 이 시간 이내의 연타면 음절 삭제로 전환
3) keydown 처리 로직 수정
핵심은 Backspace 처리에서 “모드 결정”만 추가하는 거야. (Ctrl/Meta 단축키, IME 가드는 전에 넣은 그대로 유지)
js
editEl.addEventListener('keydown', function(e){
if (e.isComposing || e.key === 'Process') return;
if (isToggleHotkey(e)){ e.preventDefault(); koreanInput.toggleMode(); return; }
if (isEditorShortcut(e)) return; // Ctrl/⌘ 단축키 통과
if (koreanInput.mode === 'en') return; // EN 모드 통과
if (e.key === 'Backspace'){
// 단어삭제 같은 조합키는 통과
if (e.ctrlKey || e.metaKey) return;
e.preventDefault();
const now = performance.now();
const rapid = (now - bsLastTs) < BS_RAPID_MS; // 빠른 연타?
bsLastTs = now;
// ① 모드 결정: 키 반복이거나 빠른 연타면 '음절', 아니면 '자모'
const deleteMode = (e.repeat || rapid) ? 'syllable' : 'jamo';
const cursorStart = this.selectionStart;
const cursorEnd = this.selectionEnd;
if (cursorStart === cursorEnd){
if (cursorStart > 0){
const textBefore = this.value.substring(0, cursorStart);
const textAfter = this.value.substring(cursorStart);
const newTextBefore = (deleteMode === 'syllable')
? koreanInput.handleBackspaceSyllable(textBefore)
: koreanInput.handleBackspaceJamo(textBefore);
const newText = newTextBefore + textAfter;
this.value = newText;
const newCursorPos = newTextBefore.length;
this.setSelectionRange(newCursorPos, newCursorPos);
if (newCursorPos > 0){
const charBeforeCursor = newText[newCursorPos - 1];
koreanInput.restoreStateFromCharacter(charBeforeCursor);
} else {
koreanInput.reset();
}
}
} else {
// 선택영역 삭제는 동일
const textBefore = this.value.substring(0, cursorStart);
const textAfter = this.value.substring(cursorEnd);
const newText = textBefore + textAfter;
this.value = newText;
this.setSelectionRange(cursorStart, cursorStart);
if (cursorStart > 0){
const charBeforeCursor = newText[cursorStart - 1];
koreanInput.restoreStateFromCharacter(charBeforeCursor);
} else {
koreanInput.reset();
}
}
return;
}
// ... 나머지 Space/일반 문자 처리 동일 ...
});
4) 동작 요약
-
짧게 한 번 Backspace: 자모 하나 삭제 → 네 기존
handleBackspace경로. -
길게 누르거나 빠르게 연타 : 브라우저가
keydown을 반복(e.repeat) → 음절 단위 삭제(handleBackspaceSyllable). -
선택 영역이 있으면 그대로 선택 삭제.
-
Ctrl/⌘+Backspace등 편집 단축키는 통과(단어 삭제 등 기본 동작 유지).
5) 취향 옵션
-
BS_RAPID_MS를 180~250ms 사이에서 조절하면 너가 원하는 “연타 인식 민감도”를 맞출 수 있어. -
“첫 1~2회는 자모, 그 이후는 음절” 같은 카운터 기반 정책으로도 바꿀 수 있어. 예: 같은 위치에서 연속 2회 이상 Backspace면 음절 삭제.
이 방식이면 네 조합기 흐름은 그대로 두면서, 실제 IME처럼 자모→음절 로 자연스럽게 삭제 동작이 넘어가서 체감 품질이 확 올라갈 거야.
Ctrl+Z로 실행취소는 안되네
맞아. 네 로직이 .value = ...로 직접 값을 바꾸고 있어서 브라우저의 기본 undo 스택에 기록이 안 남아 Ctrl+Z가 먹히지 않는 거야. 해결책은 “편집 API”를 써서 브라우저가 편집으로 인식 하게 만드는 것. 가장 쉬운 방법은 HTMLInputElement.setRangeText()를 쓰는 거고, 아주 넓은 호환을 원하면 execCommand('insertText')(폐기 예정이지만 실제로는 잘 동작)까지 폴백을 얹으면 돼.
아래처럼 바꾸면 Ctrl+Z / Ctrl+Y가 기본 편집 히스토리를 그대로 사용하게 된다.
1) 공통 적용 함수 추가
값을 직접 바꾸지 말고 이 함수로 바꿔치기 해.
js
function applyEditWithUndo(el, start, end, replacement, collapse = 'end') {
// 1) setRangeText가 있으면 네이티브 편집으로 기록됨(undo 가능)
if (typeof el.setRangeText === 'function') {
el.setRangeText(replacement, start, end, collapse);
} else if (document.queryCommandSupported && document.queryCommandSupported('insertText')) {
// 2) execCommand 폴백 (여전히 많은 브라우저에서 동작)
el.setSelectionRange(start, end);
document.execCommand('insertText', false, replacement);
} else {
// 3) 최후의 폴백: 직접 치환(undo 스택은 못 쌓음)
const v = el.value;
el.value = v.slice(0, start) + replacement + v.slice(end);
const newPos = (collapse === 'end') ? (start + replacement.length)
: (collapse === 'start') ? start
: (start + replacement.length);
el.setSelectionRange(newPos, newPos);
return;
}
// setRangeText/execCommand 경로에선 커서 위치를 우리가 정리해 준다
const pos = (collapse === 'end') ? (start + replacement.length)
: (collapse === 'start') ? start
: (start + replacement.length);
el.setSelectionRange(pos, pos);
}
2) 삽입·백스페이스·스페이스 처리에 적용
이제 기존의 this.value = ...와 setSelectionRange(...)를 전부 applyEditWithUndo(...)로 교체하면 된다.
문자 입력(조합 삽입)
js
// 기존
// this.value = insertResult.text;
// this.setSelectionRange(insertResult.cursorPosition, insertResult.cursorPosition);
// 변경
applyEditWithUndo(
this,
this.selectionStart,
this.selectionEnd,
// smartInsert는 “문자 1개 삽입 → 주변 재조합”을 하므로,
// 실제로 바뀔 구간을 계산해야 한다.
// 가장 단순한 방법: 삽입 전후 차이를 이용하거나,
// 아래처럼 전체를 치환(간단하지만 비효율적)할 수도 있음.
// 효율적으로 하려면 smartInsert가 리턴값에 바뀐 구간 범위(start/end)를 포함하도록 확장해.
(function(){
const before = this.value;
const s = this.selectionStart, e = this.selectionEnd;
const res = koreanInput.smartInsert(before, s, e, result); // 기존 호출 그대로
// 값 전체를 새 값으로 교체
// 범위치환을 쓰려면 smartInsert에 diff 범위 추가가 가장 좋음
this.value = res.text; // 임시 쓰기(커서 보정용)
return res.text.slice(0, res.cursorPosition); // 커서 위치 맞추기 위해 collapse='end' 사용
}).call(this),
'end'
);
// 주석: 위 간단 버전은 전체 치환이라 undo가 한 번에 잡히긴 해도 비효율.
// 가장 좋은 방식은 smartInsert가 {text, cursorPosition, replaceStart, replaceEnd}를 주도록 바꿔
// applyEditWithUndo(this, replaceStart, replaceEnd, replacement)로 부분 치환하는 것.
실무적인 권장: smartInsert(...)가 “어느 범위가 바뀌었는지”를 리턴하도록 확장하세요.
예: { text, cursorPosition, rangeStart, rangeEnd, replacement }
그럼 아래처럼 정확히 바뀐 구간만 치환할 수 있어요.
js
const ins = koreanInput.smartInsert(this.value, cursorPos, this.selectionEnd, result);
applyEditWithUndo(this, ins.rangeStart, ins.rangeEnd, ins.replacement, 'end');
Backspace(자모/음절)
js
// 기존
// this.value = newText;
// this.setSelectionRange(newPos, newPos);
// 변경
applyEditWithUndo(this, cursorStart - (textBefore.length - newTextBefore.length), cursorStart, '');
// 설명: 삭제한 길이만큼 start를 앞당겨 빈 문자열로 치환
너가 위에서 구현한 자모/음절 삭제 모드(키 반복, 연타)에 그대로 이 로직만 끼워 넣으면 된다.
삭제는 “치환 텍스트를 빈 문자열로” 넣으면 undo 스택에 ‘삭제’로 쌓인다.
Space
js
// 기존
// this.value = textBefore + ' ' + textAfter;
// this.setSelectionRange(newCursorPos, newCursorPos);
// 변경
applyEditWithUndo(this, this.selectionStart, this.selectionEnd, ' ', 'end');
koreanInput.reset();
3) 단축키 통과 보강(재확인)
이미 넣은 가드가 중요하다. Ctrl/Meta/AltGraph 조합은 절대 막지 않기.
js
function isEditorShortcut(e){
const altGraph = e.getModifierState && e.getModifierState('AltGraph');
const ctrlMeta = e.ctrlKey || e.metaKey;
const ctrlAlt = e.ctrlKey && e.altKey;
return altGraph || ctrlAlt || ctrlMeta;
}
if (isEditorShortcut(e)) return; // KO/EN 모드 상관없이 통과
이렇게 하면 Ctrl+Z / Ctrl+Y / Ctrl+A / Ctrl+X/C/V가 전부 정상 동작하고, 네가 한 삽입·삭제도 브라우저의 undo 스택에 자연스럽게 누적된다.
4) 보너스: “언제 undo 단위를 끊을지”
지금은 setRangeText를 호출할 때마다 작은 단위로 쌓인다. 보통은 이게 자연스럽고, 한글 조합도 음절 단위로 되돌려져서 괜찮다. 더 매끄럽게 하려면:
-
조합 중(자모 입력 중)에는 여러 번의 setRangeText를 타이머(예: 350ms 무입력)로 묶어 하나의 undo 단위로 만들고,
-
스페이스/엔터/모드전환 시점에 강제로 undo 단위를 끊는다.
이건 고급 옵션이니 필요하면 이어서 잡아주자.
요약: 값 직접 대입을 멈추고 setRangeText/execCommand('insertText')로 치환하라. 그러면 운영체제/브라우저가 편집으로 인식해서 Ctrl+Z가 살아난다.
근데 모든 textedit에 koreanInput을 다는게 비효율적이진 않나? 그냥 focus가 됐을 때만 다는건 별로?
좋은 감각이야. “모든 편집기에 리스너를 박아두기”보다, 포커스될 때만 붙이거나 아예 “문서 한 곳에서 위임 처리”가 더 효율적이야. 정리하면 선택지는 세 가지.
-
요소별 부착: 모든 textarea/input에 keydown 등 리스너를 각각 붙임
-
포커스 시 부착: focus될 때만 붙이고 blur 때 떼기
-
이벤트 위임: 문서 하나에 keydown·focusin 리스너만 두고, 타깃이 편집기일 때만 동작
규모가 커질수록 3번(위임)이 메모리·성능·유지보수 측면에서 깔끔해. 키 이벤트는 버블링되므로 문서 레벨에서 잡아도 selection 조작이 가능해.
아래는 “위임 방식”으로 바꾼 최소 구현이야. 네가 이미 만든 KO/EN 모드, 단축키 가드, 자모/음절 삭제, undo 스택 보존까지 반영했어.
핵심 아이디어
• 전역 한글 모드 상태: koreanInput.mode 유지
• focusin에서 대상만 최소 설정(자동 교정 끄기 등)
• keydown은 문서 하나에만 두고, 대상이 편집기면 동작
• Ctrl/Meta 조합, IME 조합 중, 붙여넣기 등은 통과
예시 코드
js
// 대상 셀렉터: textarea + 텍스트 계열 input
const EDITABLE_SELECTOR = [
'textarea',
'input:not([type])',
'input[type="text"]',
'input[type="search"]',
'input[type="url"]',
'input[type="tel"]',
'input[type="password"]',
'input[type="email"]'
].join(',');
function isEditable(el){
return el && el.matches && el.matches(EDITABLE_SELECTOR);
}
function isToggleHotkey(e){
return (e.ctrlKey && !e.shiftKey && !e.altKey && e.code === 'Space') || (e.code === 'F2');
}
function isEditorShortcut(e){
const altGraph = e.getModifierState && e.getModifierState('AltGraph');
const ctrlMeta = e.ctrlKey || e.metaKey;
const ctrlAlt = e.ctrlKey && e.altKey;
return altGraph || ctrlAlt || ctrlMeta;
}
// Undo 가능한 치환
function applyEditWithUndo(el, start, end, replacement, collapse='end'){
if (typeof el.setRangeText === 'function'){
el.setRangeText(replacement, start, end, collapse);
const pos = collapse === 'start' ? start : start + replacement.length;
el.setSelectionRange(pos, pos);
} else {
const v = el.value;
el.value = v.slice(0,start) + replacement + v.slice(end);
const pos = collapse === 'start' ? start : start + replacement.length;
el.setSelectionRange(pos, pos);
}
}
// 빠른 연타/길게 누름에 따라 자모/음절 삭제 선택
let bsLastTs = 0;
const BS_RAPID_MS = 220;
document.addEventListener('DOMContentLoaded', () => {
const koreanInput = new KoreanInput();
window.koreanInput = koreanInput;
const badge = document.getElementById('imeBadge');
if (badge) badge.setAttribute('data-mode', koreanInput.mode);
// 배지 클릭으로 모드 전환
badge?.addEventListener('click', () => koreanInput.toggleMode());
// 포커스 들어오면 최소 설정만 적용
document.addEventListener('focusin', (e) => {
const el = e.target;
if (!isEditable(el)) return;
el.setAttribute('autocomplete','off');
el.setAttribute('autocapitalize','off');
el.setAttribute('spellcheck','false');
koreanInput.reset();
});
// 전역 토글 단축키(어디서든)
document.addEventListener('keydown', (e) => {
if (e.isComposing || e.key === 'Process') return;
if (isToggleHotkey(e)){
e.preventDefault();
koreanInput.toggleMode();
}
});
// 핵심: 편집용 keydown 위임
document.addEventListener('keydown', (e) => {
const el = e.target;
if (!isEditable(el)) return;
if (e.isComposing || e.key === 'Process') return;
// 표준 편집 단축키는 항상 통과
if (isEditorShortcut(e)) return;
// EN 모드면 아무 것도 가로채지 않음
if (koreanInput.mode === 'en') return;
// KO 모드만 아래 로직 실행
if (e.key === 'Backspace'){
// 단어 삭제 같은 조합키는 통과
if (e.ctrlKey || e.metaKey) return;
e.preventDefault();
const now = performance.now();
const rapid = (now - bsLastTs) < BS_RAPID_MS;
bsLastTs = now;
const syllableMode = e.repeat || rapid;
const s = el.selectionStart;
const rStart = (() => {
if (s === el.selectionEnd){ // 커서만
if (s === 0) return s;
const left = el.value.slice(0, s);
if (syllableMode){
// 음절 단위 삭제: 코드포인트 1개 제거
return s - 1;
} else {
// 자모 단위 삭제: 조합 분해 후 한 단계
const newLeft = koreanInput.handleBackspace(left);
return newLeft.length;
}
} else {
// 선택 삭제
return s;
}
})();
const rEnd = el.selectionEnd;
if (s === rEnd){
// 커서 한쪽 삭제: rStart..s 범위를 빈 문자열로
applyEditWithUndo(el, rStart, s, '', 'start');
// 상태 복구
const pos = el.selectionStart;
if (pos > 0){
const ch = el.value[pos - 1];
koreanInput.restoreStateFromCharacter(ch);
} else {
koreanInput.reset();
}
} else {
// 선택 삭제
applyEditWithUndo(el, s, rEnd, '', 'start');
const pos = el.selectionStart;
if (pos > 0){
const ch = el.value[pos - 1];
koreanInput.restoreStateFromCharacter(ch);
} else {
koreanInput.reset();
}
}
return;
}
if (e.key === ' '){
if (e.ctrlKey || e.altKey || e.metaKey) return;
e.preventDefault();
applyEditWithUndo(el, el.selectionStart, el.selectionEnd, ' ', 'end');
koreanInput.reset();
return;
}
// 특수키(Shift 등)는 통과
if (e.key.length > 1 && e.key !== 'Shift') return;
const char = koreanInput.getCharacter(e.key, e.shiftKey);
if (!char) return;
e.preventDefault();
// smartInsert를 위해 현재 범위 전달
const s = el.selectionStart;
const ins = koreanInput.smartInsert(el.value, s, el.selectionEnd, char);
// 가장 깔끔한 방법은 smartInsert가 변경 범위를 알려주는 것
// 없으니 간단하게 전체 치환 대신, 바뀐 앞쪽 길이만큼 덮어쓰기
// 실무에서는 smartInsert 반환값을 {rangeStart, rangeEnd, replacement, cursorPosition}으로 확장 추천
el.value = ins.text; // 임시 업데이트
el.setSelectionRange(ins.cursorPosition, ins.cursorPosition);
});
});
왜 위임이 좋은가
• 리스너를 수천 개 붙이지 않아도 되고, 동적으로 생기는 입력 필드도 자동 커버된다.
• 포커스될 때만 최소 속성 세팅하고, 실제 연산은 키 눌릴 때 타깃이 편집기인 경우에만 수행한다.
• 메모리 누수 위험이 줄고, 유지보수가 쉽다.
언제 포커스-부착 방식이 좋을까
• keydown 로직을 요소별로 달리해야 하거나, 특정 입력만 커스텀 조합을 쓰고 나머지는 완전히 배제하고 싶을 때. 이 경우 focusin에서 attach, focusout에서 detach하면 된다. 다만 attach/detach 관리 코드가 복잡해지고, 동적 생성·파괴가 잦으면 누락 위험이 있다.
마지막 팁
• Shadow DOM 내부 입력(예: 일부 UI 라이브러리)은 문서 레벨 위임으로는 못 잡을 수 있다. 그땐 컴포넌트마다 focusin에서 별도로 붙여주자.
• contenteditable 확장 계획이 있으면 Selection API용 어댑터를 추가로 만들어, 같은 위임 구조에 태워주면 된다.
원하면 smartInsert가 변경 범위를 리턴하도록 설계 바꿔서, undo 스택도 더 자연스럽게 끊기게 최적화해 줄게.