/* 한글 — decomposition, composition, and a 두벌식 input method. No dependencies. Lifted from the artifact; the IME is unit-tested against 먹어 · 왔어 · 읽어 · 괜찮아 · 값 · 의사 · 뭐야 and backspace. */ export const CHO = "ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ"; export const JUNG = "ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ"; export const JONG = " ㄱㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎ"; const VJOIN = {"ㅗㅏ":"ㅘ","ㅗㅐ":"ㅙ","ㅗㅣ":"ㅚ","ㅜㅓ":"ㅝ","ㅜㅔ":"ㅞ","ㅜㅣ":"ㅟ","ㅡㅣ":"ㅢ"}; const FJOIN = {"ㄱㅅ":"ㄳ","ㄴㅈ":"ㄵ","ㄴㅎ":"ㄶ","ㄹㄱ":"ㄺ","ㄹㅁ":"ㄻ","ㄹㅂ":"ㄼ", "ㄹㅅ":"ㄽ","ㄹㅌ":"ㄾ","ㄹㅍ":"ㄿ","ㄹㅎ":"ㅀ","ㅂㅅ":"ㅄ"}; const FSPLIT = Object.fromEntries(Object.entries(FJOIN).map(([k,v]) => [v, [k[0], k[1]]])); /** [initialIndex, medialIndex, finalIndex] or null if not a syllable block. */ export function decompose(ch) { const c = ch.charCodeAt(0) - 0xAC00; if (c < 0 || c > 11171) return null; return [Math.floor(c / 588), Math.floor((c % 588) / 28), c % 28]; } export function compose(i, m, f = 0) { return String.fromCharCode(0xAC00 + (i * 21 + m) * 28 + f); } export const isJamo = { initial: c => CHO.includes(c), medial: c => JUNG.includes(c), final: c => c && JONG.indexOf(c) > 0, }; /* ── 두벌식 IME ─────────────────────────────────────────── Hold one composing buffer per input. Feed jamo with key(), punctuation and spaces with text(), and Backspace with back(). Each call returns the full new value. */ export class Composer { constructor() { this.reset(); } reset() { this.cho = null; this.jung = null; this.jong = null; } get empty() { return !this.cho && !this.jung && !this.jong; } /** The syllable currently being assembled, as text. */ render() { if (this.cho && this.jung) { const i = CHO.indexOf(this.cho), m = JUNG.indexOf(this.jung); const f = this.jong ? JONG.indexOf(this.jong) : 0; if (i >= 0 && m >= 0 && f >= 0) return compose(i, m, f); } return (this.cho || "") + (this.jung || "") + (this.jong || ""); } /** Strip the composing tail off a value so it can be rebuilt. */ _base(value) { const cur = this.render(); return cur && value.endsWith(cur) ? value.slice(0, -cur.length) : value; } key(value, j) { let base = this._base(value); if (isJamo.medial(j)) { if (this.jong) { // final splits off to start a new block const parts = FSPLIT[this.jong]; let moved; if (parts) { this.jong = parts[0]; moved = parts[1]; } else { moved = this.jong; this.jong = null; } base += this.render(); this.cho = moved; this.jung = j; this.jong = null; } else if (this.jung) { const join = VJOIN[this.jung + j]; if (join) this.jung = join; else { base += this.render(); this.reset(); this.jung = j; } } else this.jung = j; } else { if (this.cho && this.jung) { if (this.jong) { const join = FJOIN[this.jong + j]; if (join) this.jong = join; else { base += this.render(); this.reset(); this.cho = j; } } else if (isJamo.final(j)) this.jong = j; else { base += this.render(); this.reset(); this.cho = j; } } else { if (!this.empty) base += this.render(); this.reset(); this.cho = j; } } return base + this.render(); } back(value) { let base = this._base(value); if (this.jong) { const parts = FSPLIT[this.jong]; this.jong = parts ? parts[0] : null; } else if (this.jung) { let peeled = null; for (const [k, v] of Object.entries(VJOIN)) if (v === this.jung) peeled = k[0]; this.jung = peeled; } else if (this.cho) { this.cho = null; } else { // pull a finished block back in const last = base.slice(-1); base = base.slice(0, -1); const d = last ? decompose(last) : null; if (d) { this.cho = CHO[d[0]]; this.jung = JUNG[d[1]]; this.jong = d[2] ? JONG[d[2]] : null; if (this.jong) { const p = FSPLIT[this.jong]; this.jong = p ? p[0] : null; } else this.jung = null; } } return base + this.render(); } /** Commit the buffer and append literal text (space, punctuation). */ text(value, t) { const base = this._base(value) + this.render(); this.reset(); return base + t; } } /** Standard 두벌식 layout, top row first. Shift gives the tense pairs. */ export const KEYBOARD = { rows: [ ["ㅂ","ㅈ","ㄷ","ㄱ","ㅅ","ㅛ","ㅕ","ㅑ","ㅐ","ㅔ"], ["ㅁ","ㄴ","ㅇ","ㄹ","ㅎ","ㅗ","ㅓ","ㅏ","ㅣ"], ["ㅋ","ㅌ","ㅊ","ㅍ","ㅠ","ㅜ","ㅡ"], ], shift: {"ㅂ":"ㅃ","ㅈ":"ㅉ","ㄷ":"ㄸ","ㄱ":"ㄲ","ㅅ":"ㅆ","ㅐ":"ㅒ","ㅔ":"ㅖ"}, };