From 8d1bac4623a1f16d8057991db479cb3ee8ef0773 Mon Sep 17 00:00:00 2001 From: IndieKKY Date: Tue, 28 Nov 2023 13:53:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=AD=97=E5=B9=95=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pnpm-lock.yaml | 10 +++ src/App.tsx | 2 + src/biz/Body.tsx | 45 +++++++++++- src/biz/CompactSegmentItem.tsx | 8 +-- src/biz/NormalSegmentItem.tsx | 6 +- src/biz/SegmentItem.tsx | 24 ++++--- src/biz/Settings.tsx | 18 ++++- src/const.tsx | 1 + src/hooks/useSearchService.ts | 58 +++++++++++++++ src/redux/envReducer.ts | 66 +++++++++++++++-- src/typings.d.ts | 4 ++ src/util/pinyin_util.ts | 125 +++++++++++++++++++++++++++++++++ src/util/search.ts | 65 +++++++++++++++++ 13 files changed, 403 insertions(+), 29 deletions(-) create mode 100644 src/hooks/useSearchService.ts create mode 100644 src/util/pinyin_util.ts create mode 100644 src/util/search.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30e26d0..5f5c015 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,5 +1,9 @@ lockfileVersion: '6.0' +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + dependencies: '@crxjs/vite-plugin': specifier: ^1.0.14 @@ -2386,6 +2390,7 @@ packages: /iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + requiresBuild: true dependencies: safer-buffer: 2.1.2 optional: true @@ -3392,6 +3397,7 @@ packages: /pify@4.0.1: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + requiresBuild: true optional: true /postcss-import@14.1.0(postcss@8.4.19): @@ -3475,6 +3481,7 @@ packages: /prr@1.0.1: resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} + requiresBuild: true optional: true /punycode@2.1.1: @@ -3802,10 +3809,12 @@ packages: /safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + requiresBuild: true optional: true /sax@1.2.4: resolution: {integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==} + requiresBuild: true optional: true /scheduler@0.23.0: @@ -3822,6 +3831,7 @@ packages: /semver@5.7.1: resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==} hasBin: true + requiresBuild: true optional: true /semver@6.3.0: diff --git a/src/App.tsx b/src/App.tsx index b192a7e..af6d03d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import {handleJson} from '@kky002/kky-util' import {useLocalStorage} from '@kky002/kky-hooks' import {Toaster} from 'react-hot-toast' import {setTheme} from './util/biz_util' +import useSearchService from './hooks/useSearchService' function App() { const dispatch = useAppDispatch() @@ -72,6 +73,7 @@ function App() { // services useSubtitleService() useTranslateService() + useSearchService() return
{ const totalHeight = useAppSelector(state => state.env.totalHeight) const curSummaryType = useAppSelector(state => state.env.tempData.curSummaryType) const title = useAppSelector(state => state.env.title) + const searchText = useAppSelector(state => state.env.searchText) const normalCallback = useCallback(() => { dispatch(setCompact(false)) @@ -118,6 +135,15 @@ const Body = () => { } }, [curSummaryType, segments, title]) + const onSearchTextChange = useCallback((e: any) => { + const searchText = e.target.value + dispatch(setSearchText(searchText)) + }, [dispatch]) + + const onClearSearchText = useCallback(() => { + dispatch(setSearchText('')) + }, [dispatch]) + // 自动滚动 useEffect(() => { if (checkAutoScroll && curOffsetTop && autoScroll && !needScroll) { @@ -132,6 +158,7 @@ const Body = () => { }, [autoScroll, checkAutoScroll, curOffsetTop, dispatch, floatKeyPointsSegIdx, needScroll, totalHeight]) return
+ {/* title */}
{segments != null && segments.length > 0 && @@ -159,19 +186,31 @@ const Body = () => {
}
+ + {/* search */} + {envData.searchEnabled &&
+ + {searchText && } +
} + + {/* auto scroll btn */} {!autoScroll &&
} + + {/* body */}
{segments?.map((segment, segmentIdx) => )} + + {/* tip */}
💡提示💡
可以尝试将概览生成的内容粘贴到视频评论里,发布后看看有什么效果🥳
diff --git a/src/biz/CompactSegmentItem.tsx b/src/biz/CompactSegmentItem.tsx index b85d802..286f178 100644 --- a/src/biz/CompactSegmentItem.tsx +++ b/src/biz/CompactSegmentItem.tsx @@ -4,11 +4,7 @@ import {getDisplay, getTransText} from '../util/biz_util' import classNames from 'classnames' const CompactSegmentItem = (props: { - item: { - from: number - to: number - content: string - } + item: TranscriptItem idx: number isIn: boolean last: boolean @@ -26,7 +22,7 @@ const CompactSegmentItem = (props: { {display.main} {display.sub && ({display.sub})} - {!last && ', '} + {!last && ' '}
} diff --git a/src/biz/NormalSegmentItem.tsx b/src/biz/NormalSegmentItem.tsx index e5a21a2..d984154 100644 --- a/src/biz/NormalSegmentItem.tsx +++ b/src/biz/NormalSegmentItem.tsx @@ -5,11 +5,7 @@ import {getDisplay, getTransText} from '../util/biz_util' import classNames from 'classnames' const NormalSegmentItem = (props: { - item: { - from: number - to: number - content: string - } + item: TranscriptItem idx: number isIn: boolean moveCallback: (event: any) => void diff --git a/src/biz/SegmentItem.tsx b/src/biz/SegmentItem.tsx index 67fea73..d261281 100644 --- a/src/biz/SegmentItem.tsx +++ b/src/biz/SegmentItem.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useRef} from 'react' +import React, {useCallback, useEffect, useMemo, useRef} from 'react' import {useAppDispatch, useAppSelector} from '../hooks/redux' import useSubtitle from '../hooks/useSubtitle' import {setCheckAutoScroll, setCurOffsetTop, setNeedScroll} from '../redux/envReducer' @@ -7,21 +7,27 @@ import CompactSegmentItem from './CompactSegmentItem' const SegmentItem = (props: { bodyRef: any - item: { - from: number - to: number - content: string - } + item: TranscriptItem idx: number isIn: boolean needScroll?: boolean last: boolean }) => { - const dispatch = useAppDispatch() const {bodyRef, item, idx, isIn, needScroll, last} = props + const dispatch = useAppDispatch() const ref = useRef() const {move} = useSubtitle() + const compact = useAppSelector(state => state.env.compact) + const searchText = useAppSelector(state => state.env.searchText) + const searchResult = useAppSelector(state => state.env.searchResult) + const display = useMemo(() => { + if (searchText) { + return searchResult.has(item.idx) ? 'inline' : 'none' + } else { + return 'inline' + } + }, [item.idx, searchResult, searchText]) const moveCallback = useCallback((event: any) => { if (event.altKey) { // 复制 @@ -47,7 +53,9 @@ const SegmentItem = (props: { } }, [dispatch, isIn]) - return + return {compact ? { // const {value: autoScrollValue, onChange: setAutoScrollValue} = useEventChecked(envData.autoScroll) const {value: translateEnableValue, onChange: setTranslateEnableValue} = useEventChecked(envData.translateEnable) const {value: summarizeEnableValue, onChange: setSummarizeEnableValue} = useEventChecked(envData.summarizeEnable) + const {value: searchEnabledValue, onChange: setSearchEnabledValue} = useEventChecked(envData.searchEnabled) + const {value: cnSearchEnabledValue, onChange: setCnSearchEnabledValue} = useEventChecked(envData.cnSearchEnabled) const {value: summarizeFloatValue, onChange: setSummarizeFloatValue} = useEventChecked(envData.summarizeFloat) const [apiKeyValue, { onChange: onChangeApiKeyValue }] = useEventTarget({initialValue: envData.apiKey??''}) const [serverUrlValue, setServerUrlValue] = useState(envData.serverUrl) @@ -112,10 +114,12 @@ const Settings = () => { fetchAmount: fetchAmountValue, fontSize: fontSizeValue, prompts: promptsValue, + searchEnabled: searchEnabledValue, + cnSearchEnabled: cnSearchEnabledValue, })) dispatch(setPage(PAGE_MAIN)) toast.success('保存成功') - }, [modelValue, promptsValue, fontSizeValue, apiKeyValue, autoExpandValue, dispatch, fetchAmountValue, hideOnDisableAutoTranslateValue, languageValue, serverUrlValue, summarizeEnableValue, summarizeFloatValue, summarizeLanguageValue, themeValue, transDisplayValue, translateEnableValue, wordsValue]) + }, [dispatch, autoExpandValue, apiKeyValue, serverUrlValue, modelValue, translateEnableValue, languageValue, hideOnDisableAutoTranslateValue, themeValue, transDisplayValue, summarizeEnableValue, summarizeFloatValue, summarizeLanguageValue, wordsValue, fetchAmountValue, fontSizeValue, promptsValue, searchEnabledValue, cnSearchEnabledValue]) const onCancel = useCallback(() => { dispatch(setPage(PAGE_MAIN)) @@ -300,6 +304,18 @@ const Settings = () => {
+
+ 搜索配置 +
}> + + + + + + +
diff --git a/src/const.tsx b/src/const.tsx index fdf4540..41fe5ea 100644 --- a/src/const.tsx +++ b/src/const.tsx @@ -141,6 +141,7 @@ export const TOTAL_HEIGHT_DEF = 520 export const TOTAL_HEIGHT_MAX = 800 export const HEADER_HEIGHT = 44 export const TITLE_HEIGHT = 24 +export const SEARCH_BAR_HEIGHT = 32 export const WORDS_DEFAULT = import.meta.env.VITE_ENV === 'web-dev'?500:2000 export const WORDS_MIN = 500 diff --git a/src/hooks/useSearchService.ts b/src/hooks/useSearchService.ts new file mode 100644 index 0000000..b549ef8 --- /dev/null +++ b/src/hooks/useSearchService.ts @@ -0,0 +1,58 @@ +import {useAppDispatch, useAppSelector} from './redux' +import {useEffect, useMemo} from 'react' +import {setSearchResult, setSearchText, } from '../redux/envReducer' +import {Search} from '../util/search' + +interface Document { + idx: number + s: string // searchKeys +} + +const useSearchService = () => { + const dispatch = useAppDispatch() + + const envData = useAppSelector(state => state.env.envData) + const data = useAppSelector(state => state.env.data) + const searchText = useAppSelector(state => state.env.searchText) + + const {reset, search} = useMemo(() => Search('idx', 's', 256, { + cnSearchEnabled: envData.cnSearchEnabled + }), [envData.cnSearchEnabled]) // 搜索实例 + + // reset search + useEffect(() => { + const startTime = Date.now() + const docs: Document[] = [] + for (const item of data?.body??[]) { + docs.push({ + idx: item.idx, + s: item.content, + }) + } + reset(docs) + // 清空搜索文本 + dispatch(setSearchText('')) + // 日志 + const endTime = Date.now() + console.debug(`[Search]reset ${docs.length} docs, cost ${endTime-startTime}ms`) + }, [data?.body, dispatch, reset]) + + // search text + useEffect(() => { + const searchResult: Set = new Set() + + if (searchText) { + // @ts-expect-error + const documents: Document[] | undefined = search(searchText) + if (documents != null) { + for (const document of documents) { + searchResult.add(document.idx) + } + } + } + + dispatch(setSearchResult(searchResult)) + }, [dispatch, search, searchText]) +} + +export default useSearchService diff --git a/src/redux/envReducer.ts b/src/redux/envReducer.ts index 41ba54c..2ca7f10 100644 --- a/src/redux/envReducer.ts +++ b/src/redux/envReducer.ts @@ -35,9 +35,12 @@ interface EnvState { title?: string taskIds?: string[] - transResults: {[key: number]: TransResult} + transResults: { [key: number]: TransResult } lastTransTime?: number lastSummarizeTime?: number + + searchText: string + searchResult: Set } const initialState: EnvState = { @@ -45,19 +48,24 @@ const initialState: EnvState = { serverUrl: SERVER_URL_OPENAI, translateEnable: true, summarizeEnable: true, + autoExpand: true, theme: 'light', + searchEnabled: true, }, tempData: { curSummaryType: 'overview', }, totalHeight: TOTAL_HEIGHT_DEF, autoScroll: true, - currentTime: import.meta.env.VITE_ENV === 'web-dev'? 30: undefined, + currentTime: import.meta.env.VITE_ENV === 'web-dev' ? 30 : undefined, envReady: false, tempReady: false, fold: true, - data: import.meta.env.VITE_ENV === 'web-dev'? getDevData(): undefined, + data: import.meta.env.VITE_ENV === 'web-dev' ? getDevData() : undefined, transResults: {}, + + searchText: '', + searchResult: new Set(), } export const slice = createSlice({ @@ -82,6 +90,12 @@ export const slice = createSlice({ setTempReady: (state) => { state.tempReady = true }, + setSearchText: (state, action: PayloadAction) => { + state.searchText = action.payload + }, + setSearchResult: (state, action: PayloadAction>) => { + state.searchResult = action.payload + }, setFloatKeyPointsSegIdx: (state, action: PayloadAction) => { state.floatKeyPointsSegIdx = action.payload }, @@ -107,12 +121,12 @@ export const slice = createSlice({ state.lastSummarizeTime = action.payload }, addTaskId: (state, action: PayloadAction) => { - state.taskIds = [...(state.taskIds??[]), action.payload] + state.taskIds = [...(state.taskIds ?? []), action.payload] }, delTaskId: (state, action: PayloadAction) => { state.taskIds = state.taskIds?.filter(id => id !== action.payload) }, - addTransResults: (state, action: PayloadAction<{[key: number]: TransResult}>) => { + addTransResults: (state, action: PayloadAction<{ [key: number]: TransResult }>) => { // 不要覆盖TransResult里code为200的 for (const payloadKey in action.payload) { const payloadItem = action.payload[payloadKey] @@ -252,6 +266,46 @@ export const slice = createSlice({ }, }) -export const { setTempReady, setTempData, setUploadedTranscript, setTotalHeight, setCheckAutoScroll, setCurOffsetTop, setFloatKeyPointsSegIdx, setFoldAll, setCompact, setSegmentFold, setSummaryContent, setSummaryStatus, setSummaryError, setTitle, setSegments, setLastSummarizeTime, setPage, setLastTransTime, clearTransResults, addTransResults, addTaskId, delTaskId, setTaskIds, setDownloadType, setAutoTranslate, setAutoScroll, setNoVideo, setNeedScroll, setCurIdx, setEnvData, setEnvReady, setCurrentTime, setInfos, setCurInfo, setCurFetched, setData, setFold } = slice.actions +export const { + setTempReady, + setTempData, + setUploadedTranscript, + setTotalHeight, + setCheckAutoScroll, + setCurOffsetTop, + setFloatKeyPointsSegIdx, + setFoldAll, + setCompact, + setSegmentFold, + setSummaryContent, + setSummaryStatus, + setSummaryError, + setTitle, + setSegments, + setLastSummarizeTime, + setPage, + setLastTransTime, + clearTransResults, + addTransResults, + addTaskId, + delTaskId, + setTaskIds, + setDownloadType, + setAutoTranslate, + setAutoScroll, + setNoVideo, + setNeedScroll, + setCurIdx, + setEnvData, + setEnvReady, + setCurrentTime, + setInfos, + setCurInfo, + setCurFetched, + setData, + setFold, + setSearchText, + setSearchResult, +} = slice.actions export default slice.reducer diff --git a/src/typings.d.ts b/src/typings.d.ts index cd72108..06e4808 100644 --- a/src/typings.d.ts +++ b/src/typings.d.ts @@ -16,6 +16,10 @@ interface EnvData { theme?: 'system' | 'light' | 'dark' fontSize?: 'normal' | 'large' + // search + searchEnabled?: boolean + cnSearchEnabled?: boolean + prompts?: { [key: string]: string } diff --git a/src/util/pinyin_util.ts b/src/util/pinyin_util.ts new file mode 100644 index 0000000..79b3873 --- /dev/null +++ b/src/util/pinyin_util.ts @@ -0,0 +1,125 @@ +import pinyin from 'tiny-pinyin' +import {uniq} from 'lodash-es' + +/** + * pinyin的返回结果 + */ +interface Ret { + type: 1 | 2 | 3 + source: string + target: string +} + +interface Phase { + pinyin: boolean + list: Ret[] +} + +/** + * 获取Phase列表(中英文分离列表) + */ +export const getPhases = (str: string) => { + const rets = pinyin.parse(str) + + const phases: Phase[] = [] + let curPinyin_ = false + let curPhase_: Ret[] = [] + const addCurrentPhase = () => { + if (curPhase_.length > 0) { + phases.push({ + pinyin: curPinyin_, + list: curPhase_, + }) + } + } + + // 遍历rets + for (const ret of rets) { + const newPinyin = ret.type === 2 + // 如果跟旧的pinyin类型不同,先保存旧的 + if (newPinyin !== curPinyin_) { + addCurrentPhase() + // 重置 + curPinyin_ = newPinyin + curPhase_ = [] + } + // 添加新的 + curPhase_.push(ret) + } + // 最后一个 + addCurrentPhase() + + return phases +} + +/** + * 获取原子字符列表,如 tool tab 汉 字 + */ +export const getAtoms = (str: string) => { + const phases = getPhases(str) + + const atoms = [] + for (const phase of phases) { + if (phase.pinyin) { // all words + atoms.push(...phase.list.map(e => e.source).filter(e => e)) + } else { // split + atoms.push(...(phase.list.map((e: any) => e.source).join('').match(/\w+/g)??[]).filter((e: string) => e)) + } + } + + return atoms +} + +const fixStrs = (atoms: string[]) => { + // 小写 + atoms = atoms.map(e => e.toLowerCase()) + + // 去重 + atoms = uniq(atoms) + + // 返回 + return atoms +} + +export const getWords = (str: string) => { + // 获取全部原子字符 + const atoms = getAtoms(str) + // fix + return fixStrs(atoms) +} + +/** + * 我的世界Minecraft => ['wodeshijie', 'deshijie', 'shijie', 'jie'] + ['wdsj', 'dsj', 'sj', 'j'] + * + * 1. only handle pinyin, other is ignored + */ +export const getWordsPinyin = (str: string) => { + let result: string[] = [] + + for (const phase of getPhases(str)) { + // only handle pinyin + if (phase.pinyin) { // 我的世界 + // 获取全部原子字符 + // 我的世界 => [我, 的, 世, 界] + const atoms: string[] = [] + atoms.push(...phase.list.map(e => e.source).filter(e => e)) + // 获取全部子串 + // [我, 的, 世, 界] => [我的世界, 的世界, 世界, 界] + const allSubStr = [] + for (let i = 0; i < atoms.length; i++) { + allSubStr.push(atoms.slice(i).join('')) + } + // pinyin version + const pinyinList = allSubStr.map((e: string) => pinyin.convertToPinyin(e)) + result.push(...pinyinList) + // pinyin first version + const pinyinFirstList = allSubStr.map((e: string) => pinyin.parse(e).map((e: any) => e.type === 2?e.target[0]:null).filter(e => !!e).join('')) + result.push(...pinyinFirstList) + } + } + + // fix + result = fixStrs(result) + + return result +} diff --git a/src/util/search.ts b/src/util/search.ts new file mode 100644 index 0000000..0d480ce --- /dev/null +++ b/src/util/search.ts @@ -0,0 +1,65 @@ +import * as JsSearch from 'js-search' +import {uniq} from 'lodash-es' +import {getWords, getWordsPinyin} from './pinyin_util' + +const tokenize = (maxLength: number, content: string, options?: SearchOptions) => { + const result: string[] = [] + + // 最大长度 + if (content.length > maxLength) { + content = content.substring(0, maxLength) + } + result.push(...getWords(content)) + // check cn + if (options?.cnSearchEnabled) { + result.push(...getWordsPinyin(content)) + } + + // console.debug('[Search] tokenize:', str, '=>', result) + + return uniq(result) +} + +export interface SearchOptions { + cnSearchEnabled?: boolean +} + +export const Search = (uidFieldName: string, index: string, maxLength: number, options?: SearchOptions) => { + let searchRef: JsSearch.Search | undefined// 搜索器 + + /** + * 重置索引 + */ + const reset = (documents?: Object[]) => { + // 搜索器 + searchRef = new JsSearch.Search(uidFieldName) + searchRef.tokenizer = { + tokenize: (str) => { + return tokenize(maxLength, str, options) + } + } + searchRef.addIndex(index) + + // 检测添加文档 + if (documents != null) { + searchRef.addDocuments(documents) + } + } + + /** + * 添加文档 + */ + const add = (document: Object) => { + searchRef?.addDocument(document) + } + + /** + * 搜索 + * @return 未去重 + */ + const search = (text: string) => { + return searchRef?.search(text.toLowerCase()) + } + + return {reset, add, search} +}