diff --git a/components/site/SiteSearchModal.vue b/components/site/SiteSearchModal.vue index d55a38e..6a5e1da 100644 --- a/components/site/SiteSearchModal.vue +++ b/components/site/SiteSearchModal.vue @@ -2,6 +2,7 @@ /** * @typedef {{ name: string, slug: string }} SearchTagHit * @typedef {{ slug: string, title: string, excerpt: string }} SearchPostHit + * @typedef {{ slug: string, title: string, excerpt: string }} SearchPageHit */ const open = defineModel({ type: Boolean, default: false }) @@ -14,6 +15,16 @@ const isComposing = ref(false) /** @type {ReturnType | null} */ let debounceTimer = null +/** + * 검색에 사용할 입력값을 정규화한다. + * @param {string} value - 원본 입력값 + * @returns {string} 정규화된 검색어 + */ +const normalizeSearchQuery = (value) => String(value ?? '') + .normalize('NFC') + .replace(/\s+/g, ' ') + .trim() + /** * 입력값을 디바운스로 검색어에 반영한다. * @param {string} value - 현재 입력값 @@ -22,11 +33,14 @@ let debounceTimer = null const scheduleDebouncedQuery = (value) => { clearTimeout(debounceTimer) debounceTimer = setTimeout(() => { - debouncedQuery.value = value + debouncedQuery.value = normalizeSearchQuery(value) }, 200) } watch(query, (value) => { + if (isComposing.value) { + return + } scheduleDebouncedQuery(value) }) @@ -36,7 +50,11 @@ const { data, pending } = await useFetch('/api/search', { key: fetchKey, query: { q: debouncedQuery }, watch: [debouncedQuery], - default: () => ({ tags: /** @type {SearchTagHit[]} */ ([]), posts: /** @type {SearchPostHit[]} */ ([]) }) + default: () => ({ + tags: /** @type {SearchTagHit[]} */ ([]), + posts: /** @type {SearchPostHit[]} */ ([]), + pages: /** @type {SearchPageHit[]} */ ([]) + }) }) /** @@ -54,7 +72,7 @@ const escapeRegExp = (value) => value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&') */ const highlightParts = (text, needle) => { const base = String(text ?? '') - const q = String(needle ?? '').trim() + const q = normalizeSearchQuery(needle) if (!q) { return [{ text: base, hit: false }] } @@ -110,32 +128,40 @@ const clearQuery = () => { */ const onCompositionStart = () => { isComposing.value = true + clearTimeout(debounceTimer) } /** - * IME 조합 중에도 입력창 value를 기반으로 검색어를 갱신한다. - * 일부 환경에서는 v-model 업데이트가 조합 종료까지 지연될 수 있다. - * @param {CompositionEvent} event - 조합 업데이트 이벤트 + * 입력 이벤트에서 최종 검색어를 갱신한다. + * @param {InputEvent} event - 입력 이벤트 * @returns {void} */ -const onCompositionUpdate = (event) => { +const onInput = (event) => { const target = event.target if (!(target instanceof HTMLInputElement)) { return } - const value = target.value - query.value = value - scheduleDebouncedQuery(value) + query.value = target.value + if (isComposing.value || event.isComposing) { + clearTimeout(debounceTimer) + return + } + scheduleDebouncedQuery(target.value) } /** * 한글/일본어 등 IME 조합 종료 처리(종료 시점에만 검색 갱신) + * @param {CompositionEvent} event - 조합 종료 이벤트 * @returns {void} */ -const onCompositionEnd = () => { +const onCompositionEnd = (event) => { isComposing.value = false + const target = event.target + if (target instanceof HTMLInputElement) { + query.value = target.value + } clearTimeout(debounceTimer) - debouncedQuery.value = query.value + debouncedQuery.value = normalizeSearchQuery(query.value) } /** @@ -208,8 +234,8 @@ onBeforeUnmount(() => { class="site-search-modal__input min-w-0 flex-1 bg-transparent py-2 text-lg outline-none placeholder:text-[var(--site-soft)] focus-visible:ring-0 sm:text-[1.35rem]" placeholder="글 제목, 본문, 태그 검색" @compositionstart="onCompositionStart" - @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd" + @input="onInput" />