diff --git a/apps/desktop/src/components/main/body/index.tsx b/apps/desktop/src/components/main/body/index.tsx index d34c7b8e81..4493f80d09 100644 --- a/apps/desktop/src/components/main/body/index.tsx +++ b/apps/desktop/src/components/main/body/index.tsx @@ -189,10 +189,7 @@ function Header({ tabs }: { tabs: Tab[] }) { - + Toggle sidebar ⌘ \ diff --git a/apps/desktop/src/components/main/body/sessions/index.tsx b/apps/desktop/src/components/main/body/sessions/index.tsx index d6733af21e..4395b3cb04 100644 --- a/apps/desktop/src/components/main/body/sessions/index.tsx +++ b/apps/desktop/src/components/main/body/sessions/index.tsx @@ -23,11 +23,7 @@ import { type TabItem, TabItemBase } from "../shared"; import { CaretPositionProvider } from "./caret-position-context"; import { FloatingActionButton } from "./floating"; import { NoteInput } from "./note-input"; -import { SearchBar } from "./note-input/transcript/search-bar"; -import { - SearchProvider, - useTranscriptSearch, -} from "./note-input/transcript/search-context"; +import { SearchProvider } from "./note-input/transcript/search-context"; import { OuterHeader } from "./outer-header"; import { useCurrentNoteTab, useHasTranscript } from "./shared"; import { TitleInput } from "./title-input"; @@ -170,7 +166,7 @@ export function TabContentNote({ return ( - + @@ -186,8 +182,6 @@ function TabContentNoteInner({ tab: Extract; showTimeline: boolean; }) { - const search = useTranscriptSearch(); - const showSearchBar = search?.isVisible ?? false; const titleInputRef = React.useRef(null); const noteInputRef = React.useRef<{ editor: import("@hypr/tiptap/editor").TiptapEditor | null; @@ -238,11 +232,7 @@ function TabContentNoteInner({ >
- {showSearchBar ? ( - - ) : ( - - )} +
>; +type Indexes = ReturnType; +type Checkpoints = ReturnType; + +function isWordBoundary(text: string, index: number): boolean { + if (index < 0 || index >= text.length) return true; + return !/\w/.test(text[index]); +} + +function replaceInText( + text: string, + query: string, + replacement: string, + caseSensitive: boolean, + wholeWord: boolean, + all: boolean, + nth: number, +): string { + let searchText = caseSensitive ? text : text.toLowerCase(); + const searchQuery = caseSensitive ? query : query.toLowerCase(); + let count = 0; + let from = 0; + + while (from <= searchText.length - searchQuery.length) { + const idx = searchText.indexOf(searchQuery, from); + if (idx === -1) break; + + if (wholeWord) { + const beforeOk = isWordBoundary(searchText, idx - 1); + const afterOk = isWordBoundary(searchText, idx + searchQuery.length); + if (!beforeOk || !afterOk) { + from = idx + 1; + continue; + } + } + + if (all || count === nth) { + const before = text.slice(0, idx); + const after = text.slice(idx + query.length); + if (all) { + text = before + replacement + after; + searchText = caseSensitive ? text : text.toLowerCase(); + from = idx + replacement.length; + continue; + } + return before + replacement + after; + } + + count++; + from = idx + 1; + } + + return text; +} + +function handleTranscriptReplace( + detail: SearchReplaceDetail, + store: Store | undefined, + indexes: Indexes, + checkpoints: Checkpoints, + sessionId: string, +) { + if (!store || !indexes || !checkpoints) return; + + const transcriptIds = indexes.getSliceRowIds( + main.INDEXES.transcriptBySession, + sessionId, + ); + if (!transcriptIds) return; + + const normalizedQuery = detail.query.trim().normalize("NFC"); + const searchQuery = detail.caseSensitive + ? normalizedQuery + : normalizedQuery.toLowerCase(); + + let globalMatchIndex = 0; + + for (const transcriptId of transcriptIds) { + const words = parseTranscriptWords(store, transcriptId); + if (words.length === 0) continue; + + type WordPosition = { start: number; end: number; wordIndex: number }; + const wordPositions: WordPosition[] = []; + let fullText = ""; + + for (let i = 0; i < words.length; i++) { + const text = (words[i].text ?? "").normalize("NFC"); + if (i > 0) fullText += " "; + const start = fullText.length; + fullText += text; + wordPositions.push({ start, end: fullText.length, wordIndex: i }); + } + + const searchText = detail.caseSensitive ? fullText : fullText.toLowerCase(); + let from = 0; + + type Match = { textPos: number; wordIndex: number; offsetInWord: number }; + const matches: Match[] = []; + + while (from <= searchText.length - searchQuery.length) { + const idx = searchText.indexOf(searchQuery, from); + if (idx === -1) break; + + if (detail.wholeWord) { + const beforeOk = isWordBoundary(searchText, idx - 1); + const afterOk = isWordBoundary(searchText, idx + searchQuery.length); + if (!beforeOk || !afterOk) { + from = idx + 1; + continue; + } + } + + for (let i = 0; i < wordPositions.length; i++) { + const { start, end, wordIndex } = wordPositions[i]; + if (idx >= start && idx < end) { + matches.push({ + textPos: idx, + wordIndex, + offsetInWord: idx - start, + }); + break; + } + if ( + i < wordPositions.length - 1 && + idx >= end && + idx < wordPositions[i + 1].start + ) { + matches.push({ + textPos: idx, + wordIndex: wordPositions[i + 1].wordIndex, + offsetInWord: 0, + }); + break; + } + } + + from = idx + 1; + } + + let changed = false; + + if (detail.all) { + const processedWords = new Set(); + for (const match of matches) { + if (processedWords.has(match.wordIndex)) continue; + processedWords.add(match.wordIndex); + const word = words[match.wordIndex]; + const originalText = word.text ?? ""; + word.text = replaceInText( + originalText, + normalizedQuery, + detail.replacement, + detail.caseSensitive, + detail.wholeWord, + true, + 0, + ); + if (word.text !== originalText) changed = true; + } + } else { + for (const match of matches) { + if (globalMatchIndex === detail.matchIndex) { + const word = words[match.wordIndex]; + const originalText = word.text ?? ""; + const searchTextInWord = detail.caseSensitive + ? originalText + : originalText.toLowerCase(); + const searchQueryInWord = detail.caseSensitive + ? normalizedQuery + : normalizedQuery.toLowerCase(); + + let nthInWord = 0; + let pos = 0; + while (pos <= searchTextInWord.length - searchQueryInWord.length) { + const foundIdx = searchTextInWord.indexOf(searchQueryInWord, pos); + if (foundIdx === -1) break; + + if (detail.wholeWord) { + const beforeOk = isWordBoundary(searchTextInWord, foundIdx - 1); + const afterOk = isWordBoundary( + searchTextInWord, + foundIdx + searchQueryInWord.length, + ); + if (!beforeOk || !afterOk) { + pos = foundIdx + 1; + continue; + } + } + + if (foundIdx === match.offsetInWord) { + break; + } + nthInWord++; + pos = foundIdx + 1; + } + + word.text = replaceInText( + originalText, + normalizedQuery, + detail.replacement, + detail.caseSensitive, + detail.wholeWord, + false, + nthInWord, + ); + changed = true; + break; + } + globalMatchIndex++; + } + } + + if (changed) { + updateTranscriptWords(store, transcriptId, words); + checkpoints.addCheckpoint("replace_word"); + if (!detail.all) return; + } + } +} + +function handleEditorReplace( + detail: SearchReplaceDetail, + editor: TiptapEditor | null, +) { + if (!editor) return; + + const doc = editor.state.doc; + const normalizedQuery = detail.query.trim().normalize("NFC"); + const searchQuery = detail.caseSensitive + ? normalizedQuery + : normalizedQuery.toLowerCase(); + + type Hit = { from: number; to: number }; + const hits: Hit[] = []; + + type TextSegment = { textStart: number; pmPos: number; length: number }; + + doc.descendants((node, pos) => { + if (!node.isBlock) return false; + + let hasChildBlock = false; + node.forEach((child) => { + if (child.isBlock) hasChildBlock = true; + }); + + if (hasChildBlock) return true; + + let blockText = ""; + const segments: TextSegment[] = []; + + node.descendants((child, childPos) => { + if (child.isText && child.text) { + segments.push({ + textStart: blockText.length, + pmPos: pos + 1 + childPos, + length: child.text.length, + }); + blockText += child.text; + } + }); + + if (!blockText) return false; + + const searchText = detail.caseSensitive + ? blockText + : blockText.toLowerCase(); + let from = 0; + + while (from <= searchText.length - searchQuery.length) { + const idx = searchText.indexOf(searchQuery, from); + if (idx === -1) break; + + if (detail.wholeWord) { + const beforeOk = isWordBoundary(searchText, idx - 1); + const afterOk = isWordBoundary(searchText, idx + searchQuery.length); + if (!beforeOk || !afterOk) { + from = idx + 1; + continue; + } + } + + let pmFrom = -1; + let pmTo = -1; + + for (const seg of segments) { + if ( + pmFrom < 0 && + idx >= seg.textStart && + idx < seg.textStart + seg.length + ) { + pmFrom = seg.pmPos + (idx - seg.textStart); + } + const endOffset = idx + normalizedQuery.length; + if ( + endOffset > seg.textStart && + endOffset <= seg.textStart + seg.length + ) { + pmTo = seg.pmPos + (endOffset - seg.textStart); + } + } + + if (pmFrom >= 0 && pmTo >= 0) { + hits.push({ from: pmFrom, to: pmTo }); + } + + from = idx + 1; + } + + return false; + }); + + if (hits.length === 0) return; + + const toReplace = detail.all ? hits : [hits[detail.matchIndex]]; + if (!toReplace[0]) return; + + let offset = 0; + const tr = editor.state.tr; + + for (const hit of toReplace) { + const adjustedFrom = hit.from + offset; + const adjustedTo = hit.to + offset; + if (detail.replacement) { + tr.replaceWith( + adjustedFrom, + adjustedTo, + editor.state.schema.text(detail.replacement), + ); + } else { + tr.delete(adjustedFrom, adjustedTo); + } + offset += detail.replacement.length - normalizedQuery.length; + } + + editor.view.dispatch(tr); +} export const NoteInput = forwardRef< { editor: TiptapEditor | null }, @@ -215,6 +562,31 @@ export const NoteInput = forwardRef< } }; + const search = useTranscriptSearch(); + const showSearchBar = search?.isVisible ?? false; + + useEffect(() => { + search?.close(); + }, [currentTab]); + + const store = main.UI.useStore(main.STORE_ID); + const indexes = main.UI.useIndexes(main.STORE_ID); + const checkpoints = main.UI.useCheckpoints(main.STORE_ID); + + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail.sessionId !== sessionId) return; + if (currentTab.type === "transcript") { + handleTranscriptReplace(detail, store, indexes, checkpoints, sessionId); + } else { + handleEditorReplace(detail, internalEditorRef.current?.editor ?? null); + } + }; + window.addEventListener("search-replace", handler); + return () => window.removeEventListener("search-replace", handler); + }, [currentTab, store, indexes, checkpoints, sessionId]); + return (
@@ -228,6 +600,12 @@ export const NoteInput = forwardRef< />
+ {showSearchBar && ( +
+ +
+ )} +
{ diff --git a/apps/desktop/src/components/main/body/sessions/note-input/transcript/search-bar.tsx b/apps/desktop/src/components/main/body/sessions/note-input/transcript/search-bar.tsx index e1a36d3439..e671bfb8d2 100644 --- a/apps/desktop/src/components/main/body/sessions/note-input/transcript/search-bar.tsx +++ b/apps/desktop/src/components/main/body/sessions/note-input/transcript/search-bar.tsx @@ -1,17 +1,109 @@ -import { ChevronDownIcon, ChevronUpIcon, XIcon } from "lucide-react"; +import { + ALargeSmallIcon, + ChevronDownIcon, + ChevronUpIcon, + ReplaceAllIcon, + ReplaceIcon, + WholeWordIcon, + XIcon, +} from "lucide-react"; import { useEffect, useRef } from "react"; +import { Kbd } from "@hypr/ui/components/ui/kbd"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@hypr/ui/components/ui/tooltip"; import { cn } from "@hypr/utils"; import { useTranscriptSearch } from "./search-context"; +function ToggleButton({ + active, + onClick, + tooltip, + children, +}: { + active: boolean; + onClick: () => void; + tooltip: React.ReactNode; + children: React.ReactNode; +}) { + return ( + + + + + + {tooltip} + + + ); +} + +function IconButton({ + onClick, + disabled, + tooltip, + children, +}: { + onClick: () => void; + disabled?: boolean; + tooltip: React.ReactNode; + children: React.ReactNode; +}) { + const btn = ( + + ); + + if (disabled) return btn; + + return ( + + {btn} + + {tooltip} + + + ); +} + export function SearchBar() { const search = useTranscriptSearch(); - const inputRef = useRef(null); + const searchInputRef = useRef(null); + const replaceInputRef = useRef(null); useEffect(() => { - inputRef.current?.focus(); - }, [inputRef]); + searchInputRef.current?.focus(); + }, []); + + useEffect(() => { + if (search?.showReplace) { + replaceInputRef.current?.focus(); + } + }, [search?.showReplace]); if (!search) { return null; @@ -25,9 +117,19 @@ export function SearchBar() { onNext, onPrev, close, + caseSensitive, + wholeWord, + showReplace, + replaceQuery, + toggleCaseSensitive, + toggleWholeWord, + toggleReplace, + setReplaceQuery, + replaceCurrent, + replaceAll, } = search; - const handleKeyDown = (e: React.KeyboardEvent) => { + const handleSearchKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); if (e.shiftKey) { @@ -38,67 +140,139 @@ export function SearchBar() { } }; + const handleReplaceKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + if (e.metaKey || e.ctrlKey) { + replaceAll(); + } else { + replaceCurrent(); + } + } + }; + const displayCount = totalMatches > 0 ? `${currentMatchIndex + 1}/${totalMatches}` : "0/0"; return ( -
-
+
+
setQuery(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Search in transcript..." - className={cn([ - "flex-1 h-full px-2 text-sm", - "bg-neutral-100 border border-neutral-200 rounded-xs", - "focus:outline-hidden focus:border-neutral-400", - ])} + onKeyDown={handleSearchKeyDown} + placeholder="Search..." + className="flex-1 min-w-0 h-full bg-transparent text-xs placeholder:text-neutral-400 focus:outline-hidden" /> - +
+ + + + + + + + Replace + ⌘ H + + } + > + + +
+ {displayCount} - - -
+ + Close + Esc + + } > - - + +
+ + {showReplace && ( +
+ setReplaceQuery(e.target.value)} + onKeyDown={handleReplaceKeyDown} + placeholder="Replace with..." + className="flex-1 min-w-0 h-full bg-transparent text-xs placeholder:text-neutral-400 focus:outline-hidden" + /> +
+ + Replace + + + } + > + + + + Replace all + ⌘ ↵ + + } + > + + +
+
+ )}
); } diff --git a/apps/desktop/src/components/main/body/sessions/note-input/transcript/search-context.tsx b/apps/desktop/src/components/main/body/sessions/note-input/transcript/search-context.tsx index ebb80d9fd4..359444333a 100644 --- a/apps/desktop/src/components/main/body/sessions/note-input/transcript/search-context.tsx +++ b/apps/desktop/src/components/main/body/sessions/note-input/transcript/search-context.tsx @@ -9,16 +9,31 @@ import { } from "react"; import { useHotkeys } from "react-hotkeys-hook"; +export interface SearchOptions { + caseSensitive: boolean; + wholeWord: boolean; +} + interface SearchContextValue { query: string; isVisible: boolean; currentMatchIndex: number; totalMatches: number; activeMatchId: string | null; + caseSensitive: boolean; + wholeWord: boolean; + showReplace: boolean; + replaceQuery: string; onNext: () => void; onPrev: () => void; close: () => void; setQuery: (query: string) => void; + toggleCaseSensitive: () => void; + toggleWholeWord: () => void; + toggleReplace: () => void; + setReplaceQuery: (query: string) => void; + replaceCurrent: () => void; + replaceAll: () => void; } const SearchContext = createContext(null); @@ -27,23 +42,83 @@ export function useTranscriptSearch() { return useContext(SearchContext); } +interface MatchResult { + element: HTMLElement; + id: string | null; +} + +function prepareQuery(query: string, caseSensitive: boolean): string { + const trimmed = query.trim().normalize("NFC"); + return caseSensitive ? trimmed : trimmed.toLowerCase(); +} + +function prepareText(text: string, caseSensitive: boolean): string { + const normalized = text.normalize("NFC"); + return caseSensitive ? normalized : normalized.toLowerCase(); +} + +function isWordBoundary(text: string, index: number): boolean { + if (index < 0 || index >= text.length) return true; + return !/\w/.test(text[index]); +} + +function findOccurrences( + text: string, + query: string, + wholeWord: boolean, +): number[] { + const indices: number[] = []; + let from = 0; + while (from <= text.length - query.length) { + const idx = text.indexOf(query, from); + if (idx === -1) break; + if (wholeWord) { + const beforeOk = isWordBoundary(text, idx - 1); + const afterOk = isWordBoundary(text, idx + query.length); + if (beforeOk && afterOk) { + indices.push(idx); + } + } else { + indices.push(idx); + } + from = idx + 1; + } + return indices; +} + function getMatchingElements( container: HTMLElement | null, query: string, -): HTMLElement[] { - if (!container || !query) { - return []; - } + opts: SearchOptions, +): MatchResult[] { + if (!container || !query) return []; - const normalizedQuery = query.trim().toLowerCase().normalize("NFC"); - if (!normalizedQuery) return []; + const prepared = prepareQuery(query, opts.caseSensitive); + if (!prepared) return []; - const allSpans = Array.from( + const wordSpans = Array.from( container.querySelectorAll("[data-word-id]"), ); - if (allSpans.length === 0) return []; - // Build concatenated text from all spans, tracking each span's position + if (wordSpans.length > 0) { + return getTranscriptMatches(wordSpans, prepared, opts); + } + + const proseMirror = + container.querySelector(".ProseMirror") ?? + (container.classList.contains("ProseMirror") ? container : null); + if (proseMirror) { + return getEditorMatches(proseMirror, prepared, opts); + } + + return []; +} + +function getTranscriptMatches( + allSpans: HTMLElement[], + prepared: string, + opts: SearchOptions, +): MatchResult[] { const spanPositions: { start: number; end: number }[] = []; let fullText = ""; @@ -55,77 +130,135 @@ function getMatchingElements( spanPositions.push({ start, end: fullText.length }); } - const lowerFullText = fullText.toLowerCase(); - const result: HTMLElement[] = []; - let searchFrom = 0; + const searchText = prepareText(fullText, opts.caseSensitive); + const indices = findOccurrences(searchText, prepared, opts.wholeWord); + const result: MatchResult[] = []; - while (searchFrom <= lowerFullText.length - normalizedQuery.length) { - const idx = lowerFullText.indexOf(normalizedQuery, searchFrom); - if (idx === -1) break; - - // Find the span containing the start of this match + for (const idx of indices) { for (let i = 0; i < spanPositions.length; i++) { const { start, end } = spanPositions[i]; if (idx >= start && idx < end) { - result.push(allSpans[i]); + result.push({ + element: allSpans[i], + id: allSpans[i].dataset.wordId || null, + }); break; } - // Match starts in the space between spans if ( i < spanPositions.length - 1 && idx >= end && idx < spanPositions[i + 1].start ) { - result.push(allSpans[i + 1]); + result.push({ + element: allSpans[i + 1], + id: allSpans[i + 1].dataset.wordId || null, + }); break; } } + } + + return result; +} - searchFrom = idx + 1; +function getEditorMatches( + proseMirror: HTMLElement, + prepared: string, + opts: SearchOptions, +): MatchResult[] { + const allBlocks = Array.from( + proseMirror.querySelectorAll( + "p, h1, h2, h3, h4, h5, h6, li, blockquote, td, th", + ), + ); + + const blocks = allBlocks.filter( + (el) => !allBlocks.some((other) => other !== el && other.contains(el)), + ); + + const result: MatchResult[] = []; + + for (const block of blocks) { + const text = prepareText(block.textContent || "", opts.caseSensitive); + const indices = findOccurrences(text, prepared, opts.wholeWord); + for (const _ of indices) { + result.push({ element: block, id: null }); + } } return result; } -export function SearchProvider({ children }: { children: React.ReactNode }) { +function findSearchContainer(): HTMLElement | null { + if (typeof document === "undefined") return null; + + const transcript = document.querySelector( + "[data-transcript-container]", + ); + if (transcript) return transcript; + + const proseMirror = document.querySelector(".ProseMirror"); + if (proseMirror) { + return proseMirror.parentElement ?? proseMirror; + } + + return null; +} + +export interface SearchReplaceDetail { + query: string; + replacement: string; + caseSensitive: boolean; + wholeWord: boolean; + all: boolean; + matchIndex: number; + sessionId: string; +} + +export function SearchProvider({ + children, + sessionId, +}: { + children: React.ReactNode; + sessionId: string; +}) { const [isVisible, setIsVisible] = useState(false); const [query, setQuery] = useState(""); const [currentMatchIndex, setCurrentMatchIndex] = useState(0); const [totalMatches, setTotalMatches] = useState(0); const [activeMatchId, setActiveMatchId] = useState(null); - const containerRef = useRef(null); + const [caseSensitive, setCaseSensitive] = useState(false); + const [wholeWord, setWholeWord] = useState(false); + const [showReplace, setShowReplace] = useState(false); + const [replaceQuery, setReplaceQuery] = useState(""); + const matchesRef = useRef([]); + + const opts: SearchOptions = useMemo( + () => ({ caseSensitive, wholeWord }), + [caseSensitive, wholeWord], + ); - const ensureContainer = useCallback(() => { - if (typeof document === "undefined") { - containerRef.current = null; - return null; - } + const close = useCallback(() => { + setIsVisible(false); + setShowReplace(false); + }, []); - const current = containerRef.current; - if (current && document.body.contains(current)) { - return current; - } + const toggleCaseSensitive = useCallback(() => { + setCaseSensitive((prev) => !prev); + }, []); - const next = document.querySelector( - "[data-transcript-container]", - ); - containerRef.current = next; - return next; + const toggleWholeWord = useCallback(() => { + setWholeWord((prev) => !prev); }, []); - const close = useCallback(() => { - setIsVisible(false); + const toggleReplace = useCallback(() => { + setShowReplace((prev) => !prev); }, []); useHotkeys( "mod+f", (event) => { event.preventDefault(); - const container = ensureContainer(); - if (!container) { - return; - } - setIsVisible((prev) => !prev); }, { @@ -133,7 +266,22 @@ export function SearchProvider({ children }: { children: React.ReactNode }) { enableOnFormTags: true, enableOnContentEditable: true, }, - [ensureContainer], + [], + ); + + useHotkeys( + "mod+h", + (event) => { + event.preventDefault(); + setIsVisible(true); + setShowReplace((prev) => !prev); + }, + { + preventDefault: true, + enableOnFormTags: true, + enableOnContentEditable: true, + }, + [], ); useHotkeys( @@ -152,78 +300,111 @@ export function SearchProvider({ children }: { children: React.ReactNode }) { useEffect(() => { if (!isVisible) { setQuery(""); + setReplaceQuery(""); setCurrentMatchIndex(0); setActiveMatchId(null); + setShowReplace(false); + matchesRef.current = []; } }, [isVisible]); - useEffect(() => { - const container = ensureContainer(); + const runSearch = useCallback(() => { + const container = findSearchContainer(); if (!container || !query) { setTotalMatches(0); setCurrentMatchIndex(0); setActiveMatchId(null); + matchesRef.current = []; return; } - const matches = getMatchingElements(container, query); + const matches = getMatchingElements(container, query, opts); + matchesRef.current = matches; setTotalMatches(matches.length); setCurrentMatchIndex(0); - setActiveMatchId(matches[0]?.dataset.wordId || null); - }, [query, ensureContainer]); + setActiveMatchId(matches[0]?.id || null); - const onNext = useCallback(() => { - const container = ensureContainer(); - if (!container) { - return; + if (matches.length > 0 && !matches[0].id) { + matches[0].element.scrollIntoView({ + behavior: "smooth", + block: "center", + }); } + }, [query, opts]); - const matches = getMatchingElements(container, query); - if (matches.length === 0) { - return; - } + useEffect(() => { + runSearch(); + }, [runSearch]); + + const onNext = useCallback(() => { + const matches = matchesRef.current; + if (matches.length === 0) return; const nextIndex = (currentMatchIndex + 1) % matches.length; setCurrentMatchIndex(nextIndex); - setActiveMatchId(matches[nextIndex]?.dataset.wordId || null); - }, [ensureContainer, query, currentMatchIndex]); + setActiveMatchId(matches[nextIndex]?.id || null); + matches[nextIndex]?.element.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + }, [currentMatchIndex]); const onPrev = useCallback(() => { - const container = ensureContainer(); - if (!container) { - return; - } - - const matches = getMatchingElements(container, query); - if (matches.length === 0) { - return; - } + const matches = matchesRef.current; + if (matches.length === 0) return; const prevIndex = (currentMatchIndex - 1 + matches.length) % matches.length; setCurrentMatchIndex(prevIndex); - setActiveMatchId(matches[prevIndex]?.dataset.wordId || null); - }, [ensureContainer, query, currentMatchIndex]); - - useEffect(() => { - if (!isVisible) { - return; - } - - const container = ensureContainer(); - if (!container) { - setIsVisible(false); - } - }, [isVisible, ensureContainer]); + setActiveMatchId(matches[prevIndex]?.id || null); + matches[prevIndex]?.element.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + }, [currentMatchIndex]); + + const replaceCurrent = useCallback(() => { + if (!query || matchesRef.current.length === 0) return; + const detail: SearchReplaceDetail = { + query, + replacement: replaceQuery, + caseSensitive, + wholeWord, + all: false, + matchIndex: currentMatchIndex, + sessionId, + }; + window.dispatchEvent(new CustomEvent("search-replace", { detail })); + setTimeout(runSearch, 50); + }, [ + query, + replaceQuery, + caseSensitive, + wholeWord, + currentMatchIndex, + runSearch, + sessionId, + ]); + + const replaceAllFn = useCallback(() => { + if (!query) return; + const detail: SearchReplaceDetail = { + query, + replacement: replaceQuery, + caseSensitive, + wholeWord, + all: true, + matchIndex: 0, + sessionId, + }; + window.dispatchEvent(new CustomEvent("search-replace", { detail })); + setTimeout(runSearch, 50); + }, [query, replaceQuery, caseSensitive, wholeWord, runSearch, sessionId]); useEffect(() => { - if (!isVisible || !activeMatchId) { - return; - } + if (!isVisible || !activeMatchId) return; - const container = ensureContainer(); - if (!container) { - return; - } + const container = findSearchContainer(); + if (!container) return; const element = container.querySelector( `[data-word-id="${activeMatchId}"]`, @@ -232,7 +413,7 @@ export function SearchProvider({ children }: { children: React.ReactNode }) { if (element) { element.scrollIntoView({ behavior: "smooth", block: "center" }); } - }, [isVisible, activeMatchId, ensureContainer]); + }, [isVisible, activeMatchId]); const value = useMemo( () => ({ @@ -241,10 +422,20 @@ export function SearchProvider({ children }: { children: React.ReactNode }) { currentMatchIndex, totalMatches, activeMatchId, + caseSensitive, + wholeWord, + showReplace, + replaceQuery, onNext, onPrev, close, setQuery, + toggleCaseSensitive, + toggleWholeWord, + toggleReplace, + setReplaceQuery, + replaceCurrent, + replaceAll: replaceAllFn, }), [ query, @@ -252,9 +443,18 @@ export function SearchProvider({ children }: { children: React.ReactNode }) { currentMatchIndex, totalMatches, activeMatchId, + caseSensitive, + wholeWord, + showReplace, + replaceQuery, onNext, onPrev, close, + toggleCaseSensitive, + toggleWholeWord, + toggleReplace, + replaceCurrent, + replaceAllFn, ], ); diff --git a/apps/desktop/src/components/main/body/sessions/note-input/transcript/shared/word-span.tsx b/apps/desktop/src/components/main/body/sessions/note-input/transcript/shared/word-span.tsx index 5dfb86e356..1bea930f51 100644 --- a/apps/desktop/src/components/main/body/sessions/note-input/transcript/shared/word-span.tsx +++ b/apps/desktop/src/components/main/body/sessions/note-input/transcript/shared/word-span.tsx @@ -61,6 +61,8 @@ function useTranscriptSearchHighlights(word: SegmentWord) { const query = search?.query?.trim() ?? ""; const isVisible = Boolean(search?.isVisible); const activeMatchId = search?.activeMatchId ?? null; + const caseSensitive = search?.caseSensitive ?? false; + const wholeWord = search?.wholeWord ?? false; const segments = useMemo(() => { const text = word.text ?? ""; @@ -73,8 +75,8 @@ function useTranscriptSearchHighlights(word: SegmentWord) { return [{ text, isMatch: false }]; } - return createSearchHighlightSegments(text, query); - }, [isVisible, query, word.text]); + return createSearchHighlightSegments(text, query, caseSensitive, wholeWord); + }, [isVisible, query, word.text, caseSensitive, wholeWord]); const isActive = word.id === activeMatchId; diff --git a/apps/desktop/src/components/main/body/sessions/outer-header/listen.tsx b/apps/desktop/src/components/main/body/sessions/outer-header/listen.tsx index 31f3793530..145daac129 100644 --- a/apps/desktop/src/components/main/body/sessions/outer-header/listen.tsx +++ b/apps/desktop/src/components/main/body/sessions/outer-header/listen.tsx @@ -56,8 +56,6 @@ function StartButton({ sessionId }: { sessionId: string }) { "w-20 h-7", "disabled:pointer-events-none disabled:opacity-50", ])} - title={warningMessage || "Listen"} - aria-label="Listen" > Listen @@ -65,7 +63,16 @@ function StartButton({ sessionId }: { sessionId: string }) { ); if (!warningMessage) { - return button; + return ( + + + {button} + + + Make Char listen to your meeting + + + ); } return ( @@ -104,56 +111,65 @@ function InMeetingIndicator({ sessionId }: { sessionId: string }) { } return ( - + + + + + + {finalizing ? "Finalizing..." : "Stop listening"} + + ); } diff --git a/apps/desktop/src/components/main/body/sessions/title-input.tsx b/apps/desktop/src/components/main/body/sessions/title-input.tsx index 87c713fa9b..85c63ee683 100644 --- a/apps/desktop/src/components/main/body/sessions/title-input.tsx +++ b/apps/desktop/src/components/main/body/sessions/title-input.tsx @@ -10,6 +10,11 @@ import { useState, } from "react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@hypr/ui/components/ui/tooltip"; import { cn } from "@hypr/utils"; import { useTitleGenerating } from "../../../../hooks/useTitleGenerating"; @@ -326,20 +331,25 @@ const GenerateButton = memo(function GenerateButton({ onGenerateTitle: () => void; }) { return ( - + + + + + Regenerate title + ); }); diff --git a/apps/desktop/src/components/main/sidebar/index.tsx b/apps/desktop/src/components/main/sidebar/index.tsx index 167b52e220..ea5ef8d296 100644 --- a/apps/desktop/src/components/main/sidebar/index.tsx +++ b/apps/desktop/src/components/main/sidebar/index.tsx @@ -71,10 +71,7 @@ export function LeftSidebar() { - + Toggle sidebar ⌘ \ diff --git a/packages/transcript/src/ui/utils.ts b/packages/transcript/src/ui/utils.ts index 53a21082a2..505c5ec074 100644 --- a/packages/transcript/src/ui/utils.ts +++ b/packages/transcript/src/ui/utils.ts @@ -105,28 +105,43 @@ export function useSegmentColor(key: SegmentKey): string { return useMemo(() => getSegmentColor(key), [key]); } +function isWordBoundaryChar(text: string, index: number): boolean { + if (index < 0 || index >= text.length) return true; + return !/\w/.test(text[index]); +} + export function createSearchHighlightSegments( rawText: string, query: string, + caseSensitive = false, + wholeWord = false, ): HighlightSegment[] { const text = rawText.normalize("NFC"); - const lowerText = text.toLowerCase(); + const searchText = caseSensitive ? text : text.toLowerCase(); const tokens = query .normalize("NFC") - .toLowerCase() .split(/\s+/) - .filter(Boolean); + .filter(Boolean) + .map((t) => (caseSensitive ? t : t.toLowerCase())); if (tokens.length === 0) return [{ text, isMatch: false }]; const ranges: { start: number; end: number }[] = []; for (const token of tokens) { let cursor = 0; - let index = lowerText.indexOf(token, cursor); + let index = searchText.indexOf(token, cursor); while (index !== -1) { - ranges.push({ start: index, end: index + token.length }); + if (wholeWord) { + const beforeOk = isWordBoundaryChar(searchText, index - 1); + const afterOk = isWordBoundaryChar(searchText, index + token.length); + if (beforeOk && afterOk) { + ranges.push({ start: index, end: index + token.length }); + } + } else { + ranges.push({ start: index, end: index + token.length }); + } cursor = index + 1; - index = lowerText.indexOf(token, cursor); + index = searchText.indexOf(token, cursor); } } diff --git a/packages/ui/src/components/ui/tooltip.tsx b/packages/ui/src/components/ui/tooltip.tsx index 0f053468a5..c2506a0ae1 100644 --- a/packages/ui/src/components/ui/tooltip.tsx +++ b/packages/ui/src/components/ui/tooltip.tsx @@ -52,7 +52,8 @@ const TooltipContent = React.forwardRef< ease: [0.16, 1, 0.3, 1], }} className={cn([ - "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground", + "z-50 overflow-hidden rounded-md px-3 py-1.5 text-xs", + "bg-white/80 backdrop-blur-sm text-neutral-700 border border-neutral-200/50 shadow-lg", "origin-(--radix-tooltip-content-transform-origin)", className, ])}