From b46d53e347c988ebba28c51bda1c701360e2c67a Mon Sep 17 00:00:00 2001 From: Codex Night Worker Date: Fri, 13 Nov 2026 12:36:02 +0900 Subject: [PATCH] AI: address issue #273 --- frontend/src/components/PostFormTagsArea.tsx | 71 ++------ frontend/src/components/TagSearch.tsx | 160 ++++--------------- frontend/src/components/common/TagInput.tsx | 132 ++------------- frontend/src/lib/tags.ts | 11 ++ frontend/src/lib/useTagAutocomplete.ts | 142 ++++++++++++++++ 5 files changed, 207 insertions(+), 309 deletions(-) create mode 100644 frontend/src/lib/useTagAutocomplete.ts diff --git a/frontend/src/components/PostFormTagsArea.tsx b/frontend/src/components/PostFormTagsArea.tsx index 6e72a11..83a4ee9 100644 --- a/frontend/src/components/PostFormTagsArea.tsx +++ b/frontend/src/components/PostFormTagsArea.tsx @@ -1,13 +1,10 @@ -// TODO: TagSearch と共通化する. +import { useRef, useState } from 'react' -import { useEffect, useRef, useState } from 'react' - -import { useLocale } from '@/lib/useLocale' +import { useTagAutocomplete } from '@/lib/useTagAutocomplete' import { useUITranslation } from '@/lib/useUITranslation' import TagSearchBox from '@/components/TagSearchBox' import FormField from '@/components/common/FormField' import TextArea from '@/components/common/TextArea' -import { apiGet } from '@/lib/api' import type { ComponentPropsWithoutRef, FC, ReactNode, SyntheticEvent } from 'react' @@ -41,24 +38,13 @@ type Props = Omit, 'value' | 'onChange' | ' const PostFormTagsArea: FC = ({ tags, setTags, errors, ...rest }) => { const { label } = useUITranslation ('navigation') - const locale = useLocale () const ref = useRef (null) const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) const [focused, setFocused] = useState (false) - const [request, setRequest] = useState ({ query: '' }) - const [result, setResult] = useState<{ - locale: string - request: { query: string } - tags: Tag[] }> ({ locale, request, tags: [] }) const currentToken = getTokenAt (tags, Math.min (bounds.start, tags.length)).token - const suggestions = (result.locale === locale && result.request === request - && request.query === currentToken) ? result.tags : [] - const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) const handleTagSelect = (tag: Tag) => { - setSuggestionsVsbl (false) - setRequest ({ query: '' }) const textarea = ref.current! const newValue = replaceToken (tags, bounds.start, bounds.end, tag.name + ' ') setTags (newValue) @@ -70,52 +56,18 @@ const PostFormTagsArea: FC = ({ tags, setTags, errors, ...rest }) => { }) } + const { inputProps, suggestionProps, requestSuggestions } = + useTagAutocomplete ({ token: currentToken, onSelect: handleTagSelect }) + const recompute = (pos: number, v: string = tags) => { const { start, end, token } = getTokenAt (v, pos) - setRequest ({ query: token }) - setSuggestionsVsbl (token.trim () !== '') + requestSuggestions (token) if (!(token.trim ())) - { - setSuggestionsVsbl (false) - return - } + return setBounds ({ start, end }) } - useEffect (() => { - if (request.query.trim () === '') - return - const controller = new AbortController () - let active = true - void (async () => { - try - { - const tags = await apiGet ('/tags/autocomplete', { - locale, signal: controller.signal, params: { q: request.query, nico: '0' } }) - if (active) - { - setResult ({ locale, request, - tags: tags.filter (tag => tag.postCount > 0) }) - } - } - catch - { - if (active) - setResult ({ locale, request, tags: [] }) - } - }) () - return () => { - active = false - controller.abort () - } - }, [request, locale]) - - useEffect (() => { - setSuggestionsVsbl (false) - setRequest ({ query: '' }) - }, [locale]) - return ( {({ describedBy, invalid }) => ( @@ -134,15 +86,10 @@ const PostFormTagsArea: FC = ({ tags, setTags, errors, ...rest }) => { onFocus={() => setFocused (true)} onBlur={() => { setFocused (false) - setSuggestionsVsbl (false) + inputProps.onBlur () }}/> {focused && ( - 0 - ? suggestions - : [] as Tag[]} - activeIndex={-1} - onSelect={handleTagSelect}/>)} + )} )} ) } diff --git a/frontend/src/components/TagSearch.tsx b/frontend/src/components/TagSearch.tsx index af5a686..434b1a8 100644 --- a/frontend/src/components/TagSearch.tsx +++ b/frontend/src/components/TagSearch.tsx @@ -1,82 +1,46 @@ -// TODO: タグ入力系すべてに同様の処理あるため共通化する. - -import { useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import { useLocation } from 'react-router-dom' -import { useLocale, useLocaleNavigate } from '@/lib/useLocale' +import TagSearchBox from '@/components/TagSearchBox' +import { useLocaleNavigate } from '@/lib/useLocale' +import { useTagAutocomplete } from '@/lib/useTagAutocomplete' import { useUITranslation } from '@/lib/useUITranslation' -import { apiGet } from '@/lib/api' import { inputClass } from '@/lib/utils' -import TagSearchBox from './TagSearchBox' - -import type { ChangeEvent, FC, FormEvent, KeyboardEvent } from 'react' +import type { ChangeEvent, FC, FormEvent } from 'react' import type { Tag } from '@/types' const TagSearch: FC = () => { const { message } = useUITranslation ('shared') - const locale = useLocale () const location = useLocation () const navigate = useLocaleNavigate () - const compositionFlgRef = useRef (false) - - const [activeIndex, setActiveIndex] = useState (-1) const [search, setSearch] = useState ('') - const [request, setRequest] = useState ({ query: '' }) - const [result, setResult] = useState<{ - locale: string - request: { query: string } - tags: Tag[] }> ({ locale, request, tags: [] }) - const suggestions = result.locale === locale && result.request === request - ? result.tags : [] - const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) - const whenChanged = (ev: ChangeEvent) => { - const value = ev.target.value - const query = value.trim ().split (' ').at (-1) ?? '' - setSearch (value) - setRequest ({ query }) - setActiveIndex (-1) - setSuggestionsVsbl (query !== '') + const handleTagSelect = (tag: Tag) => { + const parts = search.split (' ') + parts[parts.length - 1] = ( + (parts[parts.length - 1].slice (0, 4).toLowerCase () === 'not:') + ? ('not:' + tag.name) + : tag.name) + setSearch (parts.join (' ') + ' ') } - useEffect (() => { - if (request.query === '') - return - const controller = new AbortController () - let active = true - void (async () => { - try - { - const tags = await apiGet ('/tags/autocomplete', { - locale, signal: controller.signal, params: { q: request.query } }) - if (active) - { - setResult ({ locale, request, - tags: tags.filter (tag => tag.postCount > 0) }) - } - } - catch - { - if (active) - setResult ({ locale, request, tags: [] }) - } - }) () - return () => { - active = false - controller.abort () - } - }, [request, locale]) + const { inputProps, suggestionProps, requestSuggestions, + clearSuggestions, compositionFlgRef } = useTagAutocomplete ({ + token: search.trim ().split (' ').at (-1) ?? '', + nicoFlg: true, + onSelect: handleTagSelect }) - useEffect (() => { - setActiveIndex (-1) - setSuggestionsVsbl (false) - }, [locale]) + const whenChanged = (event: ChangeEvent) => { + const value = event.target.value + setSearch (value) + requestSuggestions (value.trim ().split (' ').at (-1) ?? '') + } - const handleSubmit = (ev: FormEvent) => { - ev.preventDefault () + const handleSubmit = (event: FormEvent) => { + event.preventDefault () if (compositionFlgRef.current) return @@ -89,61 +53,7 @@ const TagSearch: FC = () => { .filter (t => t !== 'type:or') .join (' '), match })).toString () }`) - setSuggestionsVsbl (false) - setRequest ({ query: '' }) - } - - const handleKeyDown = (ev: KeyboardEvent) => { - if (compositionFlgRef.current - || ev.nativeEvent.isComposing - || ev.nativeEvent.keyCode === 229) - { - if (ev.key === 'Enter') - ev.preventDefault () - return - } - - switch (ev.key) - { - case 'ArrowDown': - ev.preventDefault () - setActiveIndex (i => Math.min (i + 1, suggestions.length - 1)) - setSuggestionsVsbl (true) - break - - case 'ArrowUp': - ev.preventDefault () - setActiveIndex (i => Math.max (i - 1, -1)) - setSuggestionsVsbl (true) - break - - case 'Enter': - { - const selected = suggestionsVsbl ? suggestions[activeIndex] : null - if (selected == null) - break - ev.preventDefault () - handleTagSelect (selected) - } - break - - case 'Escape': - ev.preventDefault () - setSuggestionsVsbl (false) - break - } - } - - const handleTagSelect = (tag: Tag) => { - const parts = search.split (' ') - parts[parts.length - 1] = ( - (parts[parts.length - 1].slice(0, 4).toLowerCase () === 'not:') - ? ('not:' + tag.name) - : tag.name) - setSearch (parts.join (' ') + ' ') - setRequest ({ query: '' }) - setSuggestionsVsbl (false) - setActiveIndex (-1) + clearSuggestions () } useEffect (() => { @@ -151,33 +61,21 @@ const TagSearch: FC = () => { const anyFlg = query.get ('match') === 'any' const tagsQuery = `${ query.get ('tags') ?? '' }${ anyFlg ? ' type:or' : '' }` setSearch (tagsQuery) - setRequest ({ query: '' }) - setActiveIndex (-1) - setSuggestionsVsbl (false) - }, [location.search]) + clearSuggestions () + }, [location.search, clearSuggestions]) return (
setSuggestionsVsbl (true)} - onBlur={() => setSuggestionsVsbl (false)} - onKeyDown={handleKeyDown} - onCompositionStart={() => { - compositionFlgRef.current = true - }} - onCompositionEnd={() => { - compositionFlgRef.current = false - }} className={inputClass (false, 'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/> - + ) } diff --git a/frontend/src/components/common/TagInput.tsx b/frontend/src/components/common/TagInput.tsx index d2967ff..b519e62 100644 --- a/frontend/src/components/common/TagInput.tsx +++ b/frontend/src/components/common/TagInput.tsx @@ -1,11 +1,8 @@ -import { useEffect, useState } from 'react' - -import { useLocale } from '@/lib/useLocale' import TagSearchBox from '@/components/TagSearchBox' -import { apiGet } from '@/lib/api' +import { useTagAutocomplete } from '@/lib/useTagAutocomplete' import { inputClass } from '@/lib/utils' -import type { FC, ChangeEvent, KeyboardEvent } from 'react' +import type { FC, ChangeEvent } from 'react' import type { Tag } from '@/types' @@ -19,136 +16,39 @@ type Props = { setValue: (value: string) => void } -const TagInput: FC = ({ - includeNico = false, - includeUnused = false, - describedBy, - invalid, - value, - setValue, -}) => { - const locale = useLocale () - const [activeIndex, setActiveIndex] = useState (-1) - const [request, setRequest] = useState ({ query: '' }) - const [result, setResult] = useState<{ - locale: string - includeNico: boolean - request: { query: string } - tags: Tag[] }> ({ locale, includeNico, request, tags: [] }) - const suggestions = (result.locale === locale && result.includeNico === includeNico - && result.request === request - && request.query === value.trim ().split (' ').at (-1)) ? result.tags : [] - const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) - - // TODO: TagSearch からのコピペのため,共通化を考へる. - const whenChanged = (ev: ChangeEvent) => { - setValue (ev.target.value) - const query = ev.target.value.trim ().split (' ').at (-1) ?? '' - setRequest ({ query }) - setActiveIndex (-1) - setSuggestionsVsbl (query !== '') - } - - useEffect (() => { - if (request.query === '') - return - const controller = new AbortController () - let active = true - void (async () => { - try - { - const tags = await apiGet ('/tags/autocomplete', { - locale, signal: controller.signal, - params: { q: request.query.replace (/^not:/i, ''), nico: includeNico ? '1' : '0' } }) - if (active) - { - setResult ({ locale, includeNico, request, - tags: includeUnused ? tags : tags.filter (tag => tag.postCount > 0) }) - } - } - catch - { - if (active) - setResult ({ locale, includeNico, request, tags: [] }) - } - }) () - return () => { - active = false - controller.abort () - } - }, [request, locale, includeNico, includeUnused]) - - useEffect (() => { - setActiveIndex (-1) - setSuggestionsVsbl (false) - setRequest ({ query: '' }) - }, [locale, includeNico]) - - // TODO: TagSearch からのコピペのため,共通化を考へる. +const TagInput: FC = ( + { includeNico: nicoFlg = false, + includeUnused: unusedFlg = false, + describedBy, invalid, value, setValue }, +) => { const handleTagSelect = (tag: Tag) => { const parts = value?.split (' ') const negative = /^not:/i.test (parts[parts.length - 1]) parts[parts.length - 1] = `${negative ? 'not:' : ''}${tag.name}` setValue (parts.join (' ') + ' ') - setRequest ({ query: '' }) - setActiveIndex (-1) } - // TODO: TagSearch からのコピペのため,共通化を考へる. - const handleKeyDown = (ev: KeyboardEvent) => { - switch (ev.key) - { - case 'ArrowDown': - ev.preventDefault () - setActiveIndex (i => Math.min (i + 1, suggestions.length - 1)) - setSuggestionsVsbl (true) - break + const { inputProps, suggestionProps, requestSuggestions } = + useTagAutocomplete ({ token: value.trim ().split (' ').at (-1) ?? '', + nicoFlg, unusedFlg, onSelect: handleTagSelect }) - case 'ArrowUp': - ev.preventDefault () - setActiveIndex (i => Math.max (i - 1, -1)) - setSuggestionsVsbl (true) - break - - case 'Enter': - if (activeIndex < 0) - break - { - ev.preventDefault () - const selected = suggestions[activeIndex] - if (selected) - handleTagSelect (selected) - } - break - - case 'Escape': - ev.preventDefault () - setSuggestionsVsbl (false) - break - } - if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0)) - { - setSuggestionsVsbl (false) - } + const whenChanged = (event: ChangeEvent) => { + setValue (event.target.value) + const token = event.target.value.trim ().split (' ').at (-1) ?? '' + requestSuggestions (token, token.replace (/^not:/i, '')) } return (
setSuggestionsVsbl (true)} - onBlur={() => setSuggestionsVsbl (false)} - onKeyDown={handleKeyDown} className={inputClass (invalid)}/> - 0 ? suggestions : [] as Tag[]} - activeIndex={activeIndex} - onSelect={handleTagSelect}/> +
) } diff --git a/frontend/src/lib/tags.ts b/frontend/src/lib/tags.ts index 6096b49..9a17a10 100644 --- a/frontend/src/lib/tags.ts +++ b/frontend/src/lib/tags.ts @@ -11,6 +11,17 @@ import type { Deerjikist, TagVersion } from '@/types' +export const fetchTagAutocomplete = async ( + query: string, + nicoFlg: boolean, + locale: LocaleCode, + signal: AbortSignal, +): Promise => + await apiGet ('/tags/autocomplete', { + locale, signal, + params: { q: query, nico: nicoFlg ? '1' : '0' } }) + + export const fetchTags = async ( { post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo, updatedFrom, updatedTo, deprecated, diff --git a/frontend/src/lib/useTagAutocomplete.ts b/frontend/src/lib/useTagAutocomplete.ts new file mode 100644 index 0000000..a5e716b --- /dev/null +++ b/frontend/src/lib/useTagAutocomplete.ts @@ -0,0 +1,142 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { fetchTagAutocomplete } from '@/lib/tags' +import { useLocale } from '@/lib/useLocale' + +import type { KeyboardEvent } from 'react' + +import type { Tag } from '@/types' + + +export const useTagAutocomplete = ( + { token, onSelect, nicoFlg = false, unusedFlg = false }: { + token: string + onSelect: (tag: Tag) => void + nicoFlg?: boolean + unusedFlg?: boolean }, +) => { + const locale = useLocale () + const compositionFlgRef = useRef (false) + const [activeIndex, setActiveIndex] = useState (-1) + const [visible, setVisible] = useState (false) + const [request, setRequest] = useState ({ token: '', query: '' }) + const [result, setResult] = useState<{ + locale: string + nicoFlg: boolean + unusedFlg: boolean + request: { token: string; query: string } + tags: Tag[] }> ({ locale, nicoFlg, unusedFlg, request, tags: [] }) + const suggestions = (result.locale === locale && result.nicoFlg === nicoFlg + && result.unusedFlg === unusedFlg && result.request === request + && request.token === token) ? result.tags : [] + + const requestSuggestions = (token: string, query: string = token) => { + setRequest ({ token, query }) + setActiveIndex (-1) + setVisible (token.trim () !== '') + } + + const clearSuggestions = useCallback (() => { + setRequest ({ token: '', query: '' }) + setActiveIndex (-1) + setVisible (false) + }, []) + + useEffect (() => { + if (request.token.trim () === '') + return + const controller = new AbortController () + let active = true + void (async () => { + try + { + const tags = await fetchTagAutocomplete ( + request.query, nicoFlg, locale, controller.signal) + if (active) + { + setResult ({ locale, nicoFlg, unusedFlg, request, + tags: unusedFlg ? tags : tags.filter (tag => tag.postCount > 0) }) + } + } + catch + { + if (active) + setResult ({ locale, nicoFlg, unusedFlg, request, tags: [] }) + } + }) () + return () => { + active = false + controller.abort () + } + }, [request, locale, nicoFlg, unusedFlg]) + + useEffect (() => { + clearSuggestions () + }, [locale, nicoFlg, clearSuggestions]) + + const handleTagSelect = (tag: Tag) => { + clearSuggestions () + onSelect (tag) + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (compositionFlgRef.current + || event.nativeEvent.isComposing + || event.nativeEvent.keyCode === 229) + { + if (event.key === 'Enter') + event.preventDefault () + return + } + + switch (event.key) + { + case 'ArrowDown': + event.preventDefault () + setActiveIndex (index => Math.min (index + 1, suggestions.length - 1)) + setVisible (true) + break + + case 'ArrowUp': + event.preventDefault () + setActiveIndex (index => Math.max (index - 1, -1)) + setVisible (true) + break + + case 'Enter': + { + const selected = visible ? suggestions[activeIndex] : null + if (selected == null) + { + setVisible (false) + break + } + event.preventDefault () + handleTagSelect (selected) + } + break + + case 'Escape': + event.preventDefault () + setVisible (false) + break + } + } + + return { + requestSuggestions, clearSuggestions, compositionFlgRef, + inputProps: { + onFocus: () => setVisible (true), + onBlur: () => setVisible (false), + onKeyDown: handleKeyDown, + onCompositionStart: () => { + compositionFlgRef.current = true + }, + onCompositionEnd: () => { + compositionFlgRef.current = false + } }, + suggestionProps: { + suggestions: visible ? suggestions : [], + activeIndex, + onSelect: handleTagSelect } } +}