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 &&
}
+
+ {/* 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}
+}