Skip to content

Commit 30b3033

Browse files
authored
Fix transcript editor performance problem (#913)
1 parent 006ca5b commit 30b3033

File tree

8 files changed

+135
-308
lines changed

8 files changed

+135
-308
lines changed

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

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
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 useDebouncedCallback from "beautiful-react-hooks/useDebouncedCallback";
45
import { AudioLinesIcon, CheckIcon, ClipboardIcon, CopyIcon, TextSearchIcon, UploadIcon } from "lucide-react";
56
import { memo, useCallback, useEffect, useRef, useState } from "react";
67

@@ -332,14 +333,22 @@ function SearchAndReplace({ editorRef }: { editorRef: React.RefObject<any> }) {
332333
const [searchTerm, setSearchTerm] = useState("");
333334
const [replaceTerm, setReplaceTerm] = useState("");
334335

335-
useEffect(() => {
336-
if (editorRef.current) {
337-
editorRef.current.editor.commands.setSearchTerm(searchTerm);
336+
const debouncedSetSearchTerm = useDebouncedCallback(
337+
(value: string) => {
338+
if (editorRef.current) {
339+
editorRef.current.editor.commands.setSearchTerm(value);
338340

339-
if (searchTerm.substring(0, searchTerm.length - 1) === replaceTerm) {
340-
setReplaceTerm(searchTerm);
341+
if (value.substring(0, value.length - 1) === replaceTerm) {
342+
setReplaceTerm(value);
343+
}
341344
}
342-
}
345+
},
346+
[editorRef, replaceTerm],
347+
300,
348+
);
349+
350+
useEffect(() => {
351+
debouncedSetSearchTerm(searchTerm);
343352
}, [searchTerm]);
344353

345354
useEffect(() => {

crates/db-user/src/init.rs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -319,10 +319,17 @@ pub async fn seed(db: &UserDatabase, user_id: impl Into<String>) -> Result<(), c
319319
)
320320
.unwrap(),
321321
),
322-
words: serde_json::from_str::<Vec<hypr_listener_interface::Word>>(
323-
&hypr_data::english_4::WORDS_JSON,
324-
)
325-
.unwrap(),
322+
words: {
323+
let words = serde_json::from_str::<Vec<hypr_listener_interface::Word>>(
324+
&hypr_data::english_4::WORDS_JSON,
325+
)
326+
.unwrap();
327+
let mut repeated = Vec::with_capacity(words.len() * 100);
328+
for _ in 0..100 {
329+
repeated.extend(words.clone());
330+
}
331+
repeated
332+
},
326333
..new_default_session(&user.id)
327334
},
328335
// Last week, not linked, untitled
@@ -335,6 +342,10 @@ pub async fn seed(db: &UserDatabase, user_id: impl Into<String>) -> Result<(), c
335342
"Just some random notes from last week.",
336343
)
337344
.unwrap(),
345+
words: serde_json::from_str::<Vec<hypr_listener_interface::Word>>(
346+
&hypr_data::english_4::WORDS_JSON,
347+
)
348+
.unwrap(),
338349
..new_default_session(&user.id)
339350
},
340351
// Last month, linked to past Project Kickoff event

packages/tiptap/src/styles/transcript.css

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -31,34 +31,6 @@
3131
text-align: left;
3232
}
3333

34-
.transcript-word {
35-
position: relative;
36-
display: inline;
37-
border-radius: 3px;
38-
background-color: transparent;
39-
transition: background-color 0.15s ease, box-shadow 0.15s ease;
40-
line-height: 1;
41-
}
42-
43-
.transcript-word::after {
44-
content: " ";
45-
}
46-
47-
.transcript-speaker > .transcript-word:last-child::after {
48-
content: "";
49-
}
50-
51-
.transcript-word:hover {
52-
background-color: rgba(217, 232, 251, 0.7);
53-
box-shadow: 0 0 0 1px #b8d5fa;
54-
}
55-
56-
.ProseMirror[contenteditable="false"] .transcript-word:hover {
57-
background-color: transparent;
58-
box-shadow: none;
59-
cursor: default;
60-
}
61-
6234
.ProseMirror {
6335
outline: none;
6436
}

packages/tiptap/src/transcript/extensions.ts

Lines changed: 22 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -1,123 +1,6 @@
11
import { Extension } from "@tiptap/core";
2-
import { splitBlock } from "prosemirror-commands";
32
import { Plugin, PluginKey, TextSelection } from "prosemirror-state";
43

5-
import { WordNode } from "./nodes";
6-
7-
const ZERO_WIDTH_SPACE = "\u200B";
8-
9-
export const WordSplit = Extension.create({
10-
name: "wordSplit",
11-
12-
addProseMirrorPlugins() {
13-
return [
14-
new Plugin({
15-
key: new PluginKey("hypr-word-split"),
16-
props: {
17-
handleKeyDown(view, event) {
18-
if (checkKey(" ")(event)) {
19-
const { state, dispatch } = view;
20-
const { selection } = state;
21-
22-
if (!selection.empty) {
23-
return false;
24-
}
25-
26-
const $pos = selection.$from;
27-
const WORD_NODE_TYPE = state.schema.nodes[WordNode.name];
28-
29-
if ($pos.parent.type !== WORD_NODE_TYPE) {
30-
return false;
31-
}
32-
33-
if ($pos.parent.textContent === ZERO_WIDTH_SPACE) {
34-
event.preventDefault();
35-
return true;
36-
}
37-
38-
event.preventDefault();
39-
40-
const posAfter = $pos.after();
41-
42-
let transaction = state.tr.insert(
43-
posAfter,
44-
WORD_NODE_TYPE.create(
45-
null,
46-
state.schema.text(ZERO_WIDTH_SPACE),
47-
),
48-
);
49-
const cursor = TextSelection.create(transaction.doc, posAfter + 2);
50-
transaction = transaction.setSelection(cursor);
51-
52-
dispatch(transaction.scrollIntoView());
53-
return true;
54-
}
55-
56-
if (checkKey("Backspace")(event)) {
57-
const { state, dispatch } = view;
58-
const { selection } = state;
59-
60-
if (!selection.empty) {
61-
return false;
62-
}
63-
64-
const $from = selection.$from;
65-
const WORD_NODE_TYPE = state.schema.nodes[WordNode.name];
66-
67-
if ($from.parent.type !== WORD_NODE_TYPE) {
68-
return false;
69-
}
70-
71-
if ($from.parentOffset > 0) {
72-
event.preventDefault();
73-
74-
dispatch(
75-
state.tr
76-
.delete($from.pos - 1, $from.pos)
77-
.scrollIntoView(),
78-
);
79-
80-
return true;
81-
}
82-
83-
return false;
84-
}
85-
86-
return false;
87-
},
88-
89-
handlePaste(view, event) {
90-
const text = event.clipboardData?.getData("text/plain")?.trim() ?? "";
91-
if (!text) {
92-
return false;
93-
}
94-
95-
const words = text.split(/\s+/).filter(Boolean);
96-
if (words.length <= 1) {
97-
return false;
98-
}
99-
100-
const { state, dispatch } = view;
101-
const wordType = state.schema.nodes.word;
102-
103-
const nodes = words.map((w) => wordType.create(null, state.schema.text(w)));
104-
105-
let tr = state.tr.deleteSelection();
106-
let insertPos = tr.selection.from;
107-
nodes.forEach((node) => {
108-
tr.insert(insertPos, node);
109-
insertPos += node.nodeSize;
110-
});
111-
112-
dispatch(tr.scrollIntoView());
113-
return true;
114-
},
115-
},
116-
}),
117-
];
118-
},
119-
});
120-
1214
export const SpeakerSplit = Extension.create({
1225
name: "speakerSplit",
1236

@@ -135,34 +18,36 @@ export const SpeakerSplit = Extension.create({
13518
return false;
13619
}
13720

138-
event.preventDefault();
139-
140-
const WORD = state.schema.nodes[WordNode.name];
14121
const $from = selection.$from;
14222

143-
if ($from.parent.type === WORD) {
144-
const isFirstWord = $from.index(1) === 0;
145-
const isLastWord = $from.index(1) === $from.node(1).childCount - 1;
146-
const isAtEndOfWord = $from.parentOffset === $from.parent.content.size;
147-
148-
if ((isFirstWord && !isAtEndOfWord) || isLastWord) {
149-
return true;
150-
}
23+
const parentOffset = $from.parentOffset;
24+
const textContent = $from.parent.textContent;
15125

152-
const splitPos = isAtEndOfWord ? $from.after() : $from.before();
153-
const tr = state.tr.split(splitPos);
26+
let splitPos = $from.before() + parentOffset + 1;
15427

155-
const newPos = isAtEndOfWord
156-
? tr.mapping.map(splitPos + 1)
157-
: tr.mapping.map($from.pos);
28+
if (parentOffset < textContent.length) {
29+
const beforeChar = parentOffset > 0 ? textContent[parentOffset - 1] : " ";
30+
const afterChar = parentOffset < textContent.length ? textContent[parentOffset] : " ";
15831

159-
const selection = TextSelection.create(tr.doc, newPos);
160-
dispatch(tr.setSelection(selection).scrollIntoView());
32+
if (beforeChar !== " " && afterChar !== " ") {
33+
let wordStart = parentOffset;
34+
while (wordStart > 0 && textContent[wordStart - 1] !== " ") {
35+
wordStart--;
36+
}
16137

162-
return true;
38+
splitPos = $from.before() + wordStart + 1;
39+
}
16340
}
16441

165-
return splitBlock(state, dispatch);
42+
const tr = state.tr.split(splitPos, 1, [
43+
{ type: state.schema.nodes.speaker },
44+
]);
45+
46+
const newPos = tr.mapping.map(splitPos);
47+
const newSelection = TextSelection.create(tr.doc, newPos);
48+
49+
dispatch(tr.setSelection(newSelection).scrollIntoView());
50+
return true;
16651
}
16752

16853
return false;

packages/tiptap/src/transcript/index.tsx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import Text from "@tiptap/extension-text";
99
import { EditorContent, useEditor } from "@tiptap/react";
1010
import { forwardRef, useEffect, useRef } from "react";
1111

12-
import { SpeakerSplit, WordSplit } from "./extensions";
13-
import { SpeakerNode, WordNode } from "./nodes";
12+
import { SpeakerSplit } from "./extensions";
13+
import { SpeakerNode } from "./nodes";
1414
import { fromEditorToWords, fromWordsToEditor, type Word } from "./utils";
1515
import type { SpeakerChangeRange, SpeakerViewInnerComponent, SpeakerViewInnerProps } from "./views";
1616

@@ -28,6 +28,7 @@ export interface TranscriptEditorRef {
2828
getWords: () => Word[] | null;
2929
setWords: (words: Word[]) => void;
3030
scrollToBottom: () => void;
31+
appendWords: (newWords: Word[]) => void;
3132
}
3233

3334
const TranscriptEditor = forwardRef<TranscriptEditorRef, TranscriptEditorProps>(
@@ -38,9 +39,7 @@ const TranscriptEditor = forwardRef<TranscriptEditorRef, TranscriptEditorProps>(
3839
Document.configure({ content: "speaker+" }),
3940
History,
4041
Text,
41-
WordNode,
4242
SpeakerNode(c),
43-
WordSplit,
4443
SpeakerSplit,
4544
SearchAndReplace.configure({
4645
searchResultClass: "search-result",
@@ -90,6 +89,24 @@ const TranscriptEditor = forwardRef<TranscriptEditorRef, TranscriptEditorProps>(
9089
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
9190
}
9291
},
92+
appendWords: (newWords: Word[]) => {
93+
if (!editor || !newWords.length) {
94+
return;
95+
}
96+
97+
const jsonFragment = fromWordsToEditor(newWords).content;
98+
99+
if (!jsonFragment?.length) {
100+
return;
101+
}
102+
103+
const endPos = editor.state.doc.content.size;
104+
105+
editor
106+
.chain()
107+
.insertContentAt(endPos, jsonFragment)
108+
.run();
109+
},
93110
};
94111
}
95112
}, [editor]);

0 commit comments

Comments
 (0)