Files
typing-practice/main.js
T
2026-06-14 08:44:10 +09:00

150 lines
5.3 KiB
JavaScript

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 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]) => `<span class="dict-item"><span class="dict-char">${ch}</span><span class="dict-count">${cnt}</span></span>`).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 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(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('');
}