class TypingPractice extends HTMLElement { constructor() { super(); this.dict = {} this.dict_display = null } async connectedCallback() { await this.render(); } async render() { const raw = await fetch('https://korean-advice-open-api.vercel.app/api/advice') const res = await raw.json() const author = `- ${res['author']} (${res['authorProfile']})` const sentence = res["message"] const sentence_jamo = decompose(sentence) let progress = 0 let recording_mistake = false const dict = this.dict if (!this.dict_display) { this.dict_display = document.createElement('div') this.dict_display.className = 'dict-display' document.body.append(this.dict_display) } const updateDict = () => { const sorted = Object.entries(dict).sort((a, b) => b[1] - a[1]) this.dict_display.innerHTML = sorted.length ? sorted.map(([ch, cnt]) => `${ch}${cnt}`).join('') : '' } const sentence_display = document.createElement('div') sentence_display.className = 'sentence' ;[...sentence].forEach(ch => { const span = document.createElement('span') span.textContent = ch span.className = 'pending' sentence_display.append(span) }) const author_info = document.createElement('p') author_info.className = 'author' author_info.textContent = author const updateColors = (has_error) => { ;[...sentence].forEach((_, idx) => { const span = sentence_display.children[idx] if (idx < progress) { span.className = 'correct' } else if (idx === progress && has_error) { span.className = 'wrong' } else { span.className = 'pending' } }) } const input_box = document.createElement('input') input_box.setAttribute('autofocus', '') input_box.addEventListener('input', (e) => { let cur = e.target.value let cur_jamo = decompose(cur) if (cur.length < progress) { e.target.value = sentence.slice(0, progress) updateColors(false) } else { let i = 0 let found_mistake = false while (i < Math.min(sentence_jamo.length, cur_jamo.length)) { if (sentence_jamo[i] !== cur_jamo[i]) { found_mistake = true if (!recording_mistake) { recording_mistake = true dict[sentence_jamo[i]] = (dict[sentence_jamo[i]] || 0) + 1 updateDict() } break } i += 1 } if (!found_mistake) recording_mistake = false const sentence_sliced = sentence.slice(progress, cur.length) const cur_sliced = cur.slice(progress) let j = 0 while (j < Math.min(sentence_sliced.length, cur_sliced.length)) { if (sentence_sliced[j] !== cur_sliced[j]) { break } j += 1 } progress += j updateColors(found_mistake) if (progress === sentence.length) { this.innerHTML = '' this.render().then(() => { this.querySelector('input')?.focus() }) } } }) this.append(sentence_display) this.append(author_info) this.append(input_box) } } customElements.define("typing-practice", TypingPractice) const JONG_MAP = { 'ㄱ': 'ㄱ', 'ㄲ': 'ㄲ', 'ㄳ': 'ㄱㅅ', 'ㄴ': 'ㄴ', 'ㄵ': 'ㄴㅈ', 'ㄶ': 'ㄴㅎ', 'ㄷ': 'ㄷ', 'ㄹ': 'ㄹ', 'ㄺ': 'ㄹㄱ', 'ㄻ': 'ㄹㅁ', 'ㄼ': 'ㄹㅂ', 'ㄽ': 'ㄹㅅ', 'ㄾ': 'ㄹㅌ', 'ㄿ': 'ㄹㅍ', 'ㅀ': 'ㄹㅎ', 'ㅁ': 'ㅁ', 'ㅂ': 'ㅂ', 'ㅄ': 'ㅂㅅ', 'ㅅ': 'ㅅ', 'ㅆ': 'ㅆ', 'ㅇ': 'ㅇ', 'ㅈ': 'ㅈ', 'ㅊ': 'ㅊ', 'ㅋ': 'ㅋ', 'ㅌ': 'ㅌ', 'ㅍ': 'ㅍ', 'ㅎ': 'ㅎ' }; const CHO = ['ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ','ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ']; const JUNG = ['ㅏ','ㅐ','ㅑ','ㅒ','ㅓ','ㅔ','ㅕ','ㅖ','ㅗ','ㅘ','ㅙ','ㅚ','ㅛ','ㅜ','ㅝ','ㅞ','ㅟ','ㅠ','ㅡ','ㅢ','ㅣ']; const JONG = ['','ㄱ','ㄲ','ㄳ','ㄴ','ㄵ','ㄶ','ㄷ','ㄹ','ㄺ','ㄻ','ㄼ','ㄽ','ㄾ','ㄿ','ㅀ','ㅁ','ㅂ','ㅄ','ㅅ','ㅆ','ㅇ','ㅈ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ']; const COMPOUND_JUNG = { 'ㅘ': 'ㅗㅏ', 'ㅙ': 'ㅗㅐ', 'ㅚ': 'ㅗㅣ', 'ㅝ': 'ㅜㅓ', 'ㅞ': 'ㅜㅔ', 'ㅟ': 'ㅜㅣ', 'ㅢ': 'ㅡㅣ' } function decompose(str) { return [...str].map(ch => { const code = ch.charCodeAt(0); if (code >= 0xAC00 && code <= 0xD7A3) { const offset = code - 0xAC00; const cho = CHO[Math.floor(offset / (21 * 28))]; const jung = JUNG[Math.floor((offset % (21 * 28)) / 28)]; const jong = JONG[offset % 28]; const jung_expanded = COMPOUND_JUNG[jung] || jung return cho + jung_expanded + (jong ? JONG_MAP[jong] : ''); } return ch; }).join(''); }