diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index fc48ffc..89dd322 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -98,6 +98,11 @@ const isUploading = ref(false) const mediaPickerTarget = ref('image') const activeMediaPickerTab = ref('upload') const mediaSearchQuery = ref('') +const { + displayValue: mediaSearchInput, + onInput: onMediaSearchInput, + onCompositionEnd: onMediaSearchCompositionEnd +} = useImeSearchInput(mediaSearchQuery) const selectedMediaUrls = ref([]) const lastSelectionState = ref({ start: 0, @@ -3792,11 +3797,14 @@ const handleKeydown = (event) => { diff --git a/components/admin/AdminPageForm.vue b/components/admin/AdminPageForm.vue index f0f780d..ddf677d 100644 --- a/components/admin/AdminPageForm.vue +++ b/components/admin/AdminPageForm.vue @@ -812,6 +812,7 @@ defineExpose({ :disabled="pageSectionOptions.length === 0" autocomplete="off" @input="openPageSectionSuggestions" + @compositionupdate="pageSectionInput = $event.target.value; openPageSectionSuggestions()" @focus="openPageSectionSuggestions" @blur="handlePageSectionBlur" @keydown="handlePageSectionKeydown" diff --git a/components/admin/AdminPostForm.vue b/components/admin/AdminPostForm.vue index fc47d74..454e747 100644 --- a/components/admin/AdminPostForm.vue +++ b/components/admin/AdminPostForm.vue @@ -2041,6 +2041,7 @@ defineExpose({ aria-autocomplete="list" @focus="openTagSuggestions" @input="openTagSuggestions" + @compositionupdate="tagInput = $event.target.value; openTagSuggestions()" @blur="handleTagBlur" @keydown="handleTagKeydown" @compositionstart="isTagInputComposing = true" diff --git a/composables/useImeSearchInput.js b/composables/useImeSearchInput.js new file mode 100644 index 0000000..a4f6b5c --- /dev/null +++ b/composables/useImeSearchInput.js @@ -0,0 +1,84 @@ +import { ref, watch } from 'vue' + +/** + * 검색어를 NFC로 정규화한다. + * @param {string} value - 원본 입력값 + * @returns {string} 정규화된 문자열 + */ +const normalizeSearchValue = (value) => String(value ?? '').normalize('NFC') + +/** + * 한글 등 IME 조합 중에도 검색어가 즉시 반영되는 검색 입력 처리기를 만든다. + * + * Vue의 `v-model`은 IME 조합 중에는 값을 갱신하지 않아, 조합이 끝나야(스페이스·다음 글자) + * 검색이 시작되는 문제가 있다. 이 컴포저블은 `compositionupdate`까지 반영해 조합 도중에도 + * 검색이 되게 하고, 선택적으로 디바운스를 적용한다. + * + * @param {import('vue').Ref} targetRef - 검색/필터에 사용할 대상 ref(정규화된 값이 기록됨) + * @param {Object} [options] - 옵션 + * @param {number} [options.debounce=0] - 디바운스 지연(ms). 0이면 즉시 반영 + * @returns {{ displayValue: import('vue').Ref, onInput: (event: Event) => void, onCompositionEnd: (event: Event) => void }} + */ +export const useImeSearchInput = (targetRef, options = {}) => { + const debounce = Number(options.debounce) || 0 + const displayValue = ref(String(targetRef.value ?? '')) + + /** @type {ReturnType | null} */ + let debounceTimer = null + + /** + * 대상 ref에 정규화된 값을 반영한다(디바운스 적용). + * @param {string} value - 현재 입력값 + * @returns {void} + */ + const commit = (value) => { + if (debounce > 0) { + clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => { + targetRef.value = normalizeSearchValue(value) + }, debounce) + return + } + targetRef.value = normalizeSearchValue(value) + } + + /** + * 입력 이벤트에서 표시값과 검색어를 갱신한다(IME 조합 중 포함). + * @param {Event} event - `input`/`compositionupdate` 이벤트 + * @returns {void} + */ + const onInput = (event) => { + const target = event.target + if (!(target instanceof HTMLInputElement)) { + return + } + displayValue.value = target.value + commit(target.value) + } + + /** + * IME 조합 종료 시 최종 검색어를 즉시 반영한다. + * @param {Event} event - `compositionend` 이벤트 + * @returns {void} + */ + const onCompositionEnd = (event) => { + const target = event.target + if (target instanceof HTMLInputElement) { + displayValue.value = target.value + } + clearTimeout(debounceTimer) + targetRef.value = normalizeSearchValue(displayValue.value) + } + + watch(targetRef, (value) => { + if (normalizeSearchValue(displayValue.value) !== value) { + displayValue.value = String(value ?? '') + } + }) + + return { + displayValue, + onInput, + onCompositionEnd + } +} diff --git a/docs/map.md b/docs/map.md index d8418df..1e83a6c 100644 --- a/docs/map.md +++ b/docs/map.md @@ -20,6 +20,7 @@ | composables/formatPostDate.js | 공개 게시일 `YYYY.MM.DD`, 관리자·수정일 보조 `formatPostDateTime`, `wasPostUpdatedAfterPublish` | | composables/createPostSummary.js | 게시물 요약·본문에서 목록·SEO용 짧은 설명 텍스트 생성, 본문 대체 요약의 표시용 마크다운·HTML 토큰 제거 | | composables/useAdminToast.js | 관리자 우측 상단 토스트(성공·오류·안내; 모달보다 위 z-index) | +| composables/useImeSearchInput.js | 한글 등 IME 조합 중(`compositionupdate`)에도 검색어가 즉시 반영되는 검색 입력 처리기(표시값·`onInput`·`onCompositionEnd`, NFC 정규화·선택적 디바운스) | ## 공유 라이브러리(서버·클라이언트 공통) diff --git a/docs/update.md b/docs/update.md index 3708421..6b0471e 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,14 @@ # 업데이트 이력 +## v1.10.29 + +- 한글 IME 조합 중(`compositionupdate`)에도 검색이 즉시 반영되는 공통 컴포저블 `useImeSearchInput` 추가(NFC 정규화·선택적 디바운스). +- 관리자 글 목록·멤버 목록·미디어 목록·설정 검색·태그 필터·글 목록 태그 필터 콤보박스·글쓰기 미디어 모달 검색을 모두 IME 조합 중 검색되도록 수정. +- 관리자 글 목록 검색 200ms, 미디어 검색 250ms 디바운스 적용. +- 글쓰기 태그 입력·페이지 섹션 콤보박스가 조합 중에도 추천 목록을 갱신하도록 `compositionupdate` 보강(Enter 확정 로직 유지). +- 홈 추천글·페이지 섹션 가로 트랙의 `touch-action: pan-x` 제거로 모바일 세로 스크롤이 막히던 문제 수정. +- 홈 가로 캐러셀 커스텀 드래그를 마우스 전용으로 제한하고, 터치는 네이티브 가로 스크롤·스냅에 맡기도록 수정. + ## v1.10.28 - 공개 통합 검색용 `posts.search_text`, `pages.search_text` 컬럼과 `pg_trgm` GIN 인덱스 추가. diff --git a/package.json b/package.json index 69017b8..2d2dc44 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.10.28", + "version": "1.10.29", "private": true, "type": "module", "imports": { diff --git a/pages/admin/media/index.vue b/pages/admin/media/index.vue index 3274da4..074c3d0 100644 --- a/pages/admin/media/index.vue +++ b/pages/admin/media/index.vue @@ -42,6 +42,11 @@ const isThumbnailFolderPath = (folder) => folder === MEDIA_THUMBNAIL_ROOT || Str const activeTab = ref('library') const searchText = ref('') +const { + displayValue: searchTextInput, + onInput: onSearchTextInput, + onCompositionEnd: onSearchTextCompositionEnd +} = useImeSearchInput(searchText, { debounce: 250 }) const activeFolder = ref('') const activeMediaKind = ref('all') const showUnusedOnly = ref(false) @@ -1387,10 +1392,13 @@ watch(() => mediaPagination.value.page, (page) => { { aria-controls="admin-posts-tag-filter-list" :placeholder="tagFilter === 'all' ? '전체 태그' : '태그 검색'" autocomplete="off" - @input="openTagFilter" + @input="onTagFilterSearchInput($event); openTagFilter()" + @compositionupdate="onTagFilterSearchInput" + @compositionend="onTagFilterSearchCompositionEnd" @focus="openTagFilter" @keydown="handleTagFilterKeydown" > diff --git a/pages/admin/settings/index.vue b/pages/admin/settings/index.vue index af9ff7c..beb4966 100644 --- a/pages/admin/settings/index.vue +++ b/pages/admin/settings/index.vue @@ -87,6 +87,11 @@ const homeCoverDarkInputRef = ref(null) const postImportInputRef = ref(null) const mainScrollRef = ref(null) const navSearchQuery = ref('') +const { + displayValue: navSearchInput, + onInput: onNavSearchInput, + onCompositionEnd: onNavSearchCompositionEnd +} = useImeSearchInput(navSearchQuery) const activeSectionId = ref('admin-settings-section-title') const scrollSpySuspended = ref(false) /** 블로그 제목·설명 카드 편집 모드 여부 */ @@ -1991,11 +1996,14 @@ onBeforeUnmount(() => { diff --git a/pages/admin/tags/index.vue b/pages/admin/tags/index.vue index c373ea1..39ad1e1 100644 --- a/pages/admin/tags/index.vue +++ b/pages/admin/tags/index.vue @@ -15,6 +15,11 @@ const TAG_TOAST_STORAGE_KEY = 'SORI_ADMIN_TAG_TOAST' const { openMenuId, closeMenu } = useAdminRowMenu() let toastTimer = null const generalTagQuery = ref('') +const { + displayValue: generalTagQueryInput, + onInput: onGeneralTagQueryInput, + onCompositionEnd: onGeneralTagQueryCompositionEnd +} = useImeSearchInput(generalTagQuery) const generalTagSortMode = ref('recent') const { data: tags, refresh } = await useFetch('/admin/api/tags', { @@ -474,10 +479,13 @@ onBeforeUnmount(() => {
diff --git a/pages/index.vue b/pages/index.vue index e85b74a..80dcc5e 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -592,7 +592,11 @@ const bindCarouselDragDocumentEvents = () => { * @returns {void} */ const startCarouselDrag = (event, key) => { - if (event.pointerType === 'mouse' && event.button !== 0) { + // 터치/펜은 브라우저 네이티브 가로 스크롤에 맡겨 세로(페이지) 스크롤이 막히지 않게 한다. + if (event.pointerType !== 'mouse') { + return + } + if (event.button !== 0) { return } @@ -767,7 +771,7 @@ watch([latestPosts, hasHomeInfeedAd], () => {