Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
267 changes: 222 additions & 45 deletions apps/desktop/src/components/right-panel/views/transcript-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,18 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useMatch } from "@tanstack/react-router";
import { writeText as writeTextToClipboard } from "@tauri-apps/plugin-clipboard-manager";
import useDebouncedCallback from "beautiful-react-hooks/useDebouncedCallback";
import { AudioLinesIcon, CheckIcon, ClipboardIcon, CopyIcon, TextSearchIcon, UploadIcon } from "lucide-react";
import {
AudioLinesIcon,
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
ClipboardIcon,
CopyIcon,
ReplaceIcon,
TextSearchIcon,
UploadIcon,
XIcon,
} from "lucide-react";
import { memo, useCallback, useEffect, useRef, useState } from "react";

import { ParticipantsChipInner } from "@/components/editor-area/note-header/chips/participants-chip";
Expand Down Expand Up @@ -101,6 +112,7 @@ export function TranscriptView() {
</div>
)}
<div className="not-draggable flex items-center ">
{showActions && <SearchAndReplace editorRef={editorRef} />}
{(audioExist.data && showActions) && (
<Button
variant="ghost"
Expand All @@ -110,7 +122,6 @@ export function TranscriptView() {
<AudioLinesIcon size={14} className="text-neutral-600" />
</Button>
)}
{showActions && <SearchAndReplace editorRef={editorRef} />}
{showActions && <CopyButton onCopy={handleCopyAll} />}
</div>
</header>
Expand Down Expand Up @@ -328,22 +339,31 @@ function SpeakerRangeSelector({ value, onChange }: SpeakerRangeSelectorProps) {
);
}

function SearchAndReplace({ editorRef }: { editorRef: React.RefObject<any> }) {
const [expanded, setExpanded] = useState(false);
export function SearchAndReplace({ editorRef }: { editorRef: React.RefObject<any> }) {
const [isActive, setIsActive] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [replaceTerm, setReplaceTerm] = useState("");
const [resultCount, setResultCount] = useState(0);
const [currentIndex, setCurrentIndex] = useState(0);

// Add ref for the search container
const searchContainerRef = useRef<HTMLDivElement>(null);

// Debounced search term update
const debouncedSetSearchTerm = useDebouncedCallback(
(value: string) => {
if (editorRef.current) {
editorRef.current.editor.commands.setSearchTerm(value);

if (value.substring(0, value.length - 1) === replaceTerm) {
setReplaceTerm(value);
}
editorRef.current.editor.commands.resetIndex();
setTimeout(() => {
const storage = editorRef.current.editor.storage.searchAndReplace;
const results = storage.results || [];
setResultCount(results.length);
setCurrentIndex((storage.resultIndex ?? 0) + 1);
}, 100);
}
},
[editorRef, replaceTerm],
[editorRef],
300,
);

Expand All @@ -357,55 +377,212 @@ function SearchAndReplace({ editorRef }: { editorRef: React.RefObject<any> }) {
}
}, [replaceTerm]);

// Click outside handler
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (searchContainerRef.current && !searchContainerRef.current.contains(event.target as Node)) {
if (isActive) {
setIsActive(false);
setSearchTerm("");
setReplaceTerm("");
setResultCount(0);
setCurrentIndex(0);
if (editorRef.current) {
editorRef.current.editor.commands.setSearchTerm("");
}
}
}
};

if (isActive) {
document.addEventListener("mousedown", handleClickOutside);
}

return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isActive, editorRef]);

// Keyboard shortcut handler - only when transcript editor is focused
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "f") {
const isTranscriptFocused = editorRef.current?.editor?.isFocused;
if (isTranscriptFocused) {
e.preventDefault();
setIsActive(true);
}
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [editorRef]);

// Use extension's navigation commands
const handleNext = () => {
if (editorRef.current?.editor) {
editorRef.current.editor.commands.nextSearchResult();
setTimeout(() => {
const storage = editorRef.current.editor.storage.searchAndReplace;
setCurrentIndex((storage.resultIndex ?? 0) + 1);
scrollCurrentResultIntoView(editorRef);
}, 100);
}
};

const handlePrevious = () => {
if (editorRef.current?.editor) {
editorRef.current.editor.commands.previousSearchResult();
setTimeout(() => {
const storage = editorRef.current.editor.storage.searchAndReplace;
setCurrentIndex((storage.resultIndex ?? 0) + 1);
scrollCurrentResultIntoView(editorRef);
}, 100);
}
};

function scrollCurrentResultIntoView(editorRef: React.RefObject<any>) {
if (!editorRef.current) {
return;
}
const editorElement = editorRef.current.editor.view.dom;
const current = editorElement.querySelector(".search-result-current") as HTMLElement | null;
if (current) {
current.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "nearest",
});
}
}

const handleReplaceAll = () => {
if (editorRef.current && searchTerm) {
editorRef.current.editor.commands.replaceAll(replaceTerm);
setExpanded(false);
editorRef.current.editor.commands.replaceAll();
setTimeout(() => {
const storage = editorRef.current.editor.storage.searchAndReplace;
const results = storage.results || [];
setResultCount(results.length);
setCurrentIndex(results.length > 0 ? 1 : 0);
}, 100);
}
};

useEffect(() => {
if (!expanded) {
const handleToggle = () => {
setIsActive(!isActive);
if (isActive && editorRef.current) {
setSearchTerm("");
setReplaceTerm("");
setResultCount(0);
setCurrentIndex(0);
editorRef.current.editor.commands.setSearchTerm("");
}
};

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
handleToggle();
} else if (e.key === "Enter") {
e.preventDefault();
if (e.shiftKey) {
handlePrevious();
} else {
handleNext();
}
} else if (e.key === "F3") {
e.preventDefault();
if (e.shiftKey) {
handlePrevious();
} else {
handleNext();
}
}
}, [expanded]);
};

return (
<Popover open={expanded} onOpenChange={setExpanded}>
<PopoverTrigger asChild>
<Button
className="w-8"
variant="ghost"
size="icon"
>
<TextSearchIcon size={14} className="text-neutral-600" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-2" align="start" side="left">
<div className="flex flex-row gap-2">
<Input
className="h-5 w-32"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search"
/>
<Input
className="h-5 w-32"
value={replaceTerm}
onChange={(e) => setReplaceTerm(e.target.value)}
placeholder="Replace"
/>
<div className="flex items-center hidden min-[1370px]:flex" ref={searchContainerRef}>
{!isActive
? (
<Button
className="h-5"
variant="default"
onClick={handleReplaceAll}
className="w-8 h-8"
variant="ghost"
size="icon"
onClick={handleToggle}
>
Replace
<TextSearchIcon size={14} className="text-neutral-600" />
</Button>
</div>
</PopoverContent>
</Popover>
)
: (
<div className="flex items-center gap-2 bg-neutral-50 border border-neutral-200 rounded-md p-1.5 h-8">
<div className="flex items-center gap-1">
<Input
className="h-6 w-24 text-xs border-0 focus-visible:ring-0 focus-visible:ring-offset-0 px-2 bg-transparent"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search..."
autoFocus
/>
<div className="h-4 w-px bg-neutral-300" />
<Input
className="h-6 w-24 text-xs border-0 focus-visible:ring-0 focus-visible:ring-offset-0 px-2 bg-transparent"
value={replaceTerm}
onChange={(e) => setReplaceTerm(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Replace..."
/>
</div>
{searchTerm && (
<div className="flex items-center gap-1 text-xs text-neutral-500">
<span className="whitespace-nowrap">
{resultCount > 0 ? `${currentIndex}/${resultCount}` : "0/0"}
</span>
<div className="flex items-center">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handlePrevious}
disabled={resultCount === 0}
title="Previous result (Shift+Enter, Shift+F3)"
>
<ChevronUpIcon size={12} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleNext}
disabled={resultCount === 0}
title="Next result (Enter, F3)"
>
<ChevronDownIcon size={12} />
</Button>
</div>
</div>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0"
onClick={handleReplaceAll}
disabled={!searchTerm}
title="Replace All"
style={{ pointerEvents: "auto" }}
>
<ReplaceIcon size={12} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleToggle}
>
<XIcon size={12} />
</Button>
</div>
)}
</div>
);
}

Expand Down
3 changes: 2 additions & 1 deletion crates/llama/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ thiserror = { workspace = true }
llama-cpp-2 = { git = "https://github.com/utilityai/llama-cpp-rs", default-features = false, features = ["openmp"], branch = "update-llama-cpp-2025-06-04" }

[target.'cfg(all(target_os = "macos", target_arch = "aarch64"))'.dependencies]
llama-cpp-2 = { git = "https://github.com/utilityai/llama-cpp-rs", features = ["openmp", "metal"], branch = "update-llama-cpp-2025-06-04" }

llama-cpp-2 = { git = "https://github.com/utilityai/llama-cpp-rs", features = ["openmp", "metal"], branch = "update-llama-cpp-2025-05-28" }

[target.'cfg(all(target_os = "macos", target_arch = "x86_64"))'.dependencies]
llama-cpp-2 = { git = "https://github.com/utilityai/llama-cpp-rs", features = ["native"], branch = "update-llama-cpp-2025-06-04" }
Expand Down
3 changes: 2 additions & 1 deletion crates/whisper/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ regex = { workspace = true, optional = true }
whisper-rs = { git = "https://github.com/tazz4843/whisper-rs", rev = "e3d67d5", features = ["raw-api", "tracing_backend"], optional = true }

[target.'cfg(all(target_os = "macos", target_arch = "aarch64"))'.dependencies]
whisper-rs = { git = "https://github.com/tazz4843/whisper-rs", rev = "e3d67d5", features = ["raw-api", "tracing_backend", "metal"], optional = true }

whisper-rs = { git = "https://github.com/tazz4843/whisper-rs", rev = "e3d67d5", features = ["raw-api", "tracing_backend"], optional = true }

[target.'cfg(all(target_os = "macos", target_arch = "x86_64"))'.dependencies]
whisper-rs = { git = "https://github.com/tazz4843/whisper-rs", rev = "e3d67d5", features = ["raw-api", "tracing_backend"], optional = true }
6 changes: 6 additions & 0 deletions packages/tiptap/src/styles/transcript.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
padding: 1px 0;
}

.search-result-current {
background-color: #31e054 !important;
border-radius: 2px;
padding: 1px 0;
}

.transcript-speaker {
margin-bottom: 14px;
line-height: 1.6;
Expand Down
Loading