Skip to content

Commit 3a7d727

Browse files
authored
Transcript editor styling (#873)
1 parent 54d1c2c commit 3a7d727

File tree

4 files changed

+210
-39
lines changed

4 files changed

+210
-39
lines changed

apps/desktop/src/components/right-panel/views/transcript-view.tsx

Lines changed: 182 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { useQuery, useQueryClient } from "@tanstack/react-query";
22
import { useMatch } from "@tanstack/react-router";
33
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
4-
import { AudioLinesIcon, ClipboardIcon, Copy, UploadIcon } from "lucide-react";
4+
import { AudioLinesIcon, ClipboardIcon, Copy, TextSearchIcon, UploadIcon } from "lucide-react";
55
import { useEffect, useRef, useState } from "react";
66

77
import { ParticipantsChipInner } from "@/components/editor-area/note-header/chips/participants-chip";
88
import { commands as dbCommands, Human, Word } from "@hypr/plugin-db";
99
import { commands as miscCommands } from "@hypr/plugin-misc";
1010
import TranscriptEditor, { type SpeakerViewInnerProps, type TranscriptEditorRef } from "@hypr/tiptap/transcript";
1111
import { Button } from "@hypr/ui/components/ui/button";
12+
import { Input } from "@hypr/ui/components/ui/input";
1213
import { Popover, PopoverContent, PopoverTrigger } from "@hypr/ui/components/ui/popover";
1314
import { Spinner } from "@hypr/ui/components/ui/spinner";
1415
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@hypr/ui/components/ui/tooltip";
@@ -34,8 +35,9 @@ export function TranscriptView() {
3435
useEffect(() => {
3536
if (words && words.length > 0) {
3637
editorRef.current?.setWords(words);
38+
editorRef.current?.scrollToBottom();
3739
}
38-
}, [words]);
40+
}, [words, isLive]);
3941

4042
const handleCopyAll = () => {
4143
if (words && words.length > 0) {
@@ -76,29 +78,35 @@ export function TranscriptView() {
7678

7779
return (
7880
<div className="w-full h-full flex flex-col">
79-
<div className="p-4 pb-0">
80-
<header className="flex items-center gap-2 w-full">
81+
<div className="px-4 py-1 border-b border-neutral-100">
82+
<header className="flex items-center justify-between w-full">
8183
{!showEmptyMessage && (
82-
<div className="flex-1 text-md font-medium">
83-
<div className="flex text-md items-center gap-2">
84-
Transcript
85-
{isLive
86-
&& (
87-
<div className="relative h-2 w-2">
88-
<div className="absolute inset-0 rounded-full bg-red-500/30"></div>
89-
<div className="absolute inset-0 rounded-full bg-red-500 animate-ping"></div>
90-
</div>
91-
)}
92-
</div>
84+
<div className="flex items-center gap-2">
85+
<h2 className="text-sm font-semibold text-neutral-900">Transcript</h2>
86+
{isLive && (
87+
<div className="flex items-center gap-1.5">
88+
<div className="relative h-1.5 w-1.5">
89+
<div className="absolute inset-0 rounded-full bg-red-500/30"></div>
90+
<div className="absolute inset-0 rounded-full bg-red-500 animate-ping"></div>
91+
</div>
92+
<span className="text-xs font-medium text-red-600">Live</span>
93+
</div>
94+
)}
9395
</div>
9496
)}
95-
<div className="not-draggable flex items-center gap-2">
97+
<div className="not-draggable flex items-center gap-1">
98+
{(hasTranscript && sessionId) && <SearchAndReplace editorRef={editorRef} />}
9699
{(audioExist.data && ongoingSession.isInactive && hasTranscript && sessionId) && (
97100
<TooltipProvider key="listen-recording-tooltip">
98101
<Tooltip>
99102
<TooltipTrigger asChild>
100-
<Button variant="ghost" size="icon" className="p-0" onClick={handleOpenSession}>
101-
<AudioLinesIcon size={16} className="text-black" />
103+
<Button
104+
variant="ghost"
105+
size="sm"
106+
className="h-7 w-7 p-0 hover:bg-neutral-100"
107+
onClick={handleOpenSession}
108+
>
109+
<AudioLinesIcon size={14} className="text-neutral-600" />
102110
</Button>
103111
</TooltipTrigger>
104112
<TooltipContent side="bottom">
@@ -111,8 +119,13 @@ export function TranscriptView() {
111119
<TooltipProvider key="copy-all-tooltip">
112120
<Tooltip>
113121
<TooltipTrigger asChild>
114-
<Button variant="ghost" size="icon" className="p-0" onClick={handleCopyAll}>
115-
<Copy size={16} className="text-black" />
122+
<Button
123+
variant="ghost"
124+
size="sm"
125+
className="h-7 w-7 p-0 hover:bg-neutral-100"
126+
onClick={handleCopyAll}
127+
>
128+
<Copy size={14} className="text-neutral-600" />
116129
</Button>
117130
</TooltipTrigger>
118131
<TooltipContent side="bottom">
@@ -195,12 +208,14 @@ function RenderEmpty({ sessionId }: { sessionId: string }) {
195208
}
196209

197210
const SpeakerSelector = ({
198-
onSpeakerIdChange,
211+
onSpeakerChange,
199212
speakerId,
200213
speakerIndex,
201214
}: SpeakerViewInnerProps) => {
202215
const [isOpen, setIsOpen] = useState(false);
216+
const [speakerRange, setSpeakerRange] = useState<SpeakerChangeRange>("current");
203217
const inactive = useOngoingSession(s => s.status === "inactive");
218+
const [human, setHuman] = useState<Human | null>(null);
204219

205220
const noteMatch = useMatch({ from: "/app/note/$id", shouldThrow: false });
206221
const sessionId = noteMatch?.params.id;
@@ -211,24 +226,39 @@ const SpeakerSelector = ({
211226
queryFn: () => dbCommands.sessionListParticipants(sessionId!),
212227
});
213228

229+
useEffect(() => {
230+
if (human) {
231+
onSpeakerChange(human);
232+
}
233+
}, [human]);
234+
235+
useEffect(() => {
236+
if (participants.length === 1 && participants[0]) {
237+
setHuman(participants[0]);
238+
return;
239+
}
240+
241+
const foundHuman = participants.find((s) => s.id === speakerId);
242+
if (foundHuman) {
243+
setHuman(foundHuman);
244+
}
245+
}, [participants, speakerId]);
246+
214247
const handleClickHuman = (human: Human) => {
215-
onSpeakerIdChange(human.id);
248+
setHuman(human);
216249
setIsOpen(false);
217250
};
218251

219-
const foundSpeaker = participants.length === 1 ? participants[0] : participants.find((s) => s.id === speakerId);
220-
const displayName = foundSpeaker?.full_name ?? `Speaker ${speakerIndex ?? 0}`;
221-
222252
if (!sessionId) {
223253
return <p></p>;
224254
}
225255

226-
if (!inactive && !foundSpeaker) {
256+
if (!inactive && !human) {
227257
return <p></p>;
228258
}
229259

230260
return (
231-
<div className="mt-2">
261+
<div className="mt-2 sticky top-0 z-10 bg-neutral-50">
232262
<Popover open={isOpen} onOpenChange={setIsOpen}>
233263
<PopoverTrigger
234264
onMouseDown={(e) => {
@@ -237,13 +267,136 @@ const SpeakerSelector = ({
237267
}}
238268
>
239269
<span className="underline py-1 font-semibold">
240-
{displayName}
270+
{human ? (human.full_name ?? "You") : `Speaker ${speakerIndex ?? 0}`}
241271
</span>
242272
</PopoverTrigger>
243273
<PopoverContent align="start" side="bottom">
244-
<ParticipantsChipInner sessionId={sessionId} handleClickHuman={handleClickHuman} />
274+
<div className="space-y-4">
275+
{!speakerId && (
276+
<div className="border-b border-neutral-100 pb-3">
277+
<SpeakerRangeSelector
278+
value={speakerRange}
279+
onChange={setSpeakerRange}
280+
/>
281+
</div>
282+
)}
283+
284+
<ParticipantsChipInner sessionId={sessionId} handleClickHuman={handleClickHuman} />
285+
</div>
245286
</PopoverContent>
246287
</Popover>
247288
</div>
248289
);
249290
};
291+
292+
type SpeakerChangeRange = "current" | "all" | "fromHere";
293+
294+
interface SpeakerRangeSelectorProps {
295+
value: SpeakerChangeRange;
296+
onChange: (value: SpeakerChangeRange) => void;
297+
}
298+
299+
function SpeakerRangeSelector({ value, onChange }: SpeakerRangeSelectorProps) {
300+
const options = [
301+
{ value: "current" as const, label: "Just this" },
302+
{ value: "all" as const, label: "Replace all" },
303+
{ value: "fromHere" as const, label: "From here" },
304+
];
305+
306+
return (
307+
<div className="space-y-1.5">
308+
<p className="text-sm font-medium text-neutral-700">Apply speaker change to:</p>
309+
<div className="flex rounded-md border border-neutral-200 p-0.5 bg-neutral-50">
310+
{options.map((option) => (
311+
<label key={option.value} className="flex-1 cursor-pointer">
312+
<input
313+
type="radio"
314+
name="speaker-range"
315+
value={option.value}
316+
className="sr-only"
317+
checked={value === option.value}
318+
onChange={() => onChange(option.value)}
319+
/>
320+
<div
321+
className={`px-2 py-1 text-xs font-medium text-center rounded transition-colors ${
322+
value === option.value
323+
? "bg-white text-neutral-900 shadow-sm"
324+
: "text-neutral-600 hover:text-neutral-900 hover:bg-white/50"
325+
}`}
326+
>
327+
{option.label}
328+
</div>
329+
</label>
330+
))}
331+
</div>
332+
</div>
333+
);
334+
}
335+
336+
function SearchAndReplace({ editorRef }: { editorRef: React.RefObject<any> }) {
337+
const [expanded, setExpanded] = useState(false);
338+
const [searchTerm, setSearchTerm] = useState("");
339+
const [replaceTerm, setReplaceTerm] = useState("");
340+
341+
useEffect(() => {
342+
if (editorRef.current) {
343+
editorRef.current.editor.commands.setSearchTerm(searchTerm);
344+
345+
if (searchTerm.substring(0, searchTerm.length - 1) === replaceTerm) {
346+
setReplaceTerm(searchTerm);
347+
}
348+
}
349+
}, [searchTerm]);
350+
351+
useEffect(() => {
352+
if (editorRef.current) {
353+
editorRef.current.editor.commands.setReplaceTerm(replaceTerm);
354+
}
355+
}, [replaceTerm]);
356+
357+
const handleReplaceAll = () => {
358+
if (editorRef.current && searchTerm) {
359+
editorRef.current.editor.commands.replaceAll(replaceTerm);
360+
setExpanded(false);
361+
setSearchTerm("");
362+
setReplaceTerm("");
363+
}
364+
};
365+
366+
return (
367+
<Popover open={expanded} onOpenChange={setExpanded}>
368+
<PopoverTrigger asChild>
369+
<Button
370+
className="w-8"
371+
variant="ghost"
372+
size="icon"
373+
>
374+
<TextSearchIcon size={14} className="text-neutral-600" />
375+
</Button>
376+
</PopoverTrigger>
377+
<PopoverContent className="w-full p-2" align="start" side="left">
378+
<div className="flex flex-row gap-2">
379+
<Input
380+
className="h-5 w-32"
381+
value={searchTerm}
382+
onChange={(e) => setSearchTerm(e.target.value)}
383+
placeholder="Search"
384+
/>
385+
<Input
386+
className="h-5 w-32"
387+
value={replaceTerm}
388+
onChange={(e) => setReplaceTerm(e.target.value)}
389+
placeholder="Replace"
390+
/>
391+
<Button
392+
className="h-5"
393+
variant="default"
394+
onClick={handleReplaceAll}
395+
>
396+
Replace
397+
</Button>
398+
</div>
399+
</PopoverContent>
400+
</Popover>
401+
);
402+
}

packages/tiptap/src/transcript/index.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import Document from "@tiptap/extension-document";
77
import History from "@tiptap/extension-history";
88
import Text from "@tiptap/extension-text";
99
import { EditorContent, useEditor } from "@tiptap/react";
10-
import { forwardRef, useEffect } from "react";
10+
import { forwardRef, useEffect, useRef } from "react";
1111

1212
import { SpeakerSplit, WordSplit } from "./extensions";
1313
import { SpeakerNode, WordNode } from "./nodes";
@@ -27,10 +27,13 @@ export interface TranscriptEditorRef {
2727
editor: TiptapEditor | null;
2828
getWords: () => Word[] | null;
2929
setWords: (words: Word[]) => void;
30+
scrollToBottom: () => void;
3031
}
3132

3233
const TranscriptEditor = forwardRef<TranscriptEditorRef, TranscriptEditorProps>(
3334
({ editable = true, c, onUpdate, initialWords }, ref) => {
35+
const scrollContainerRef = useRef<HTMLDivElement>(null);
36+
3437
const extensions = [
3538
Document.configure({ content: "speaker+" }),
3639
History,
@@ -41,7 +44,7 @@ const TranscriptEditor = forwardRef<TranscriptEditorRef, TranscriptEditorProps>(
4144
SpeakerSplit,
4245
SearchAndReplace.configure({
4346
searchResultClass: "search-result",
44-
disableRegex: false,
47+
disableRegex: true,
4548
}),
4649
BubbleMenu,
4750
];
@@ -80,6 +83,11 @@ const TranscriptEditor = forwardRef<TranscriptEditorRef, TranscriptEditorProps>(
8083
}
8184
return fromEditorToWords(editor.getJSON() as any);
8285
},
86+
scrollToBottom: () => {
87+
if (scrollContainerRef.current) {
88+
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
89+
}
90+
},
8391
};
8492
}
8593
}, [editor]);
@@ -92,7 +100,10 @@ const TranscriptEditor = forwardRef<TranscriptEditorRef, TranscriptEditorProps>(
92100

93101
return (
94102
<div role="textbox" className="h-full flex flex-col overflow-hidden">
95-
<div className="flex-1 overflow-y-auto">
103+
<div
104+
ref={scrollContainerRef}
105+
className="flex-1 overflow-y-auto"
106+
>
96107
<EditorContent editor={editor} className="h-full" />
97108
</div>
98109
</div>

packages/tiptap/src/transcript/nodes.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1-
import { mergeAttributes, Node } from "@tiptap/core";
2-
import { CommandProps } from "@tiptap/core";
1+
import { type CommandProps, mergeAttributes, Node } from "@tiptap/core";
32
import { ReactNodeViewRenderer } from "@tiptap/react";
43
import { Node as ProseNode } from "prosemirror-model";
54

65
import { createSpeakerView, SpeakerViewInnerComponent } from "./views";
76

87
declare module "@tiptap/core" {
98
interface Commands<ReturnType> {
9+
searchAndReplace: {
10+
setSearchTerm: (s: string) => ReturnType;
11+
setReplaceTerm: (s: string) => ReturnType;
12+
replaceAll: () => ReturnType;
13+
};
1014
speaker: {
1115
updateSpeakerIndexToId: (
1216
speakerIndex: number,

0 commit comments

Comments
 (0)