Skip to content

Commit 6b560e2

Browse files
committed
* wip
1 parent 5b6ee1f commit 6b560e2

File tree

3 files changed

+123
-1
lines changed

3 files changed

+123
-1
lines changed

apps/chat/src/app/chat/mention-input.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from 'react';
22
import { getFileIconInfo, type FileIconId } from '../file-extension-icons';
33
import { parseMessageBodyParts, pathDisplayName } from './mention-utils';
44
import { readDomFromEl, segmentsToStr, type ContentEditableSegment } from './contenteditable-serialize';
5+
import { getClipboardTextForContentEditablePaste } from './use-chat-attachments';
56

67
type Segment = ContentEditableSegment;
78

@@ -221,6 +222,27 @@ export function MentionInput({
221222
}
222223
}, [onCursorChange, ref]);
223224

225+
const handlePaste = useCallback(
226+
(e: React.ClipboardEvent) => {
227+
const text = getClipboardTextForContentEditablePaste(e.clipboardData);
228+
if (text === null) {
229+
onPaste?.(e);
230+
return;
231+
}
232+
const root = ref.current;
233+
const sel = window.getSelection();
234+
if (!root || !sel?.rangeCount || !sel.anchorNode || !root.contains(sel.anchorNode)) {
235+
onPaste?.(e);
236+
return;
237+
}
238+
e.preventDefault();
239+
e.stopPropagation();
240+
document.execCommand('insertText', false, text);
241+
handleInput();
242+
},
243+
[onPaste, handleInput, ref]
244+
);
245+
224246
const removeChipAtPath = useCallback(
225247
(path: string) => {
226248
const root = ref.current;
@@ -319,7 +341,7 @@ export function MentionInput({
319341
onInput={handleInput}
320342
onSelect={handleSelect}
321343
onKeyDown={handleKeyDown}
322-
onPaste={onPaste}
344+
onPaste={handlePaste}
323345
/>
324346
);
325347
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
getClipboardTextForContentEditablePaste,
4+
hasNonEmptyPlainTextOnClipboard,
5+
} from './use-chat-attachments';
6+
7+
function mockClipboard(getData: (type: string) => string) {
8+
return { getData } as unknown as ClipboardEvent['clipboardData'];
9+
}
10+
11+
describe('getClipboardTextForContentEditablePaste', () => {
12+
it('returns null when clipboardData is null', () => {
13+
expect(getClipboardTextForContentEditablePaste(null)).toBe(null);
14+
});
15+
16+
it('returns plain text when text/plain is non-empty', () => {
17+
const body = "const x = 1\n";
18+
expect(
19+
getClipboardTextForContentEditablePaste(mockClipboard((t) => (t === 'text/plain' ? body : '')))
20+
).toBe(body);
21+
});
22+
23+
it('returns null when text/plain is only whitespace and html is empty', () => {
24+
expect(
25+
getClipboardTextForContentEditablePaste(
26+
mockClipboard((t) => (t === 'text/plain' ? ' \n' : t === 'text/html' ? '' : ''))
27+
)
28+
).toBe(null);
29+
});
30+
31+
it('returns innerText when text/plain is empty but html has a pre block', () => {
32+
const html = '<html><body><pre>line1\nline2</pre></body></html>';
33+
expect(
34+
getClipboardTextForContentEditablePaste(
35+
mockClipboard((t) => (t === 'text/plain' ? '' : t === 'text/html' ? html : ''))
36+
)
37+
).toBe('line1\nline2');
38+
});
39+
40+
it('returns null when html is only an image', () => {
41+
const html = '<html><body><img src="data:image/png;base64,xx" /></body></html>';
42+
expect(
43+
getClipboardTextForContentEditablePaste(
44+
mockClipboard((t) => (t === 'text/plain' ? '' : t === 'text/html' ? html : ''))
45+
)
46+
).toBe(null);
47+
});
48+
});
49+
50+
describe('hasNonEmptyPlainTextOnClipboard', () => {
51+
it('returns false when clipboardData is null', () => {
52+
expect(hasNonEmptyPlainTextOnClipboard(null)).toBe(false);
53+
});
54+
55+
it('returns true when text/plain has code from an IDE', () => {
56+
const body = "function x() {\n return 1;\n}\n";
57+
expect(hasNonEmptyPlainTextOnClipboard(mockClipboard((t) => (t === 'text/plain' ? body : '')))).toBe(
58+
true
59+
);
60+
});
61+
62+
it('returns true when only html provides text', () => {
63+
const html = '<pre>abc</pre>';
64+
expect(
65+
hasNonEmptyPlainTextOnClipboard(
66+
mockClipboard((t) => (t === 'text/plain' ? '' : t === 'text/html' ? html : ''))
67+
)
68+
).toBe(true);
69+
});
70+
});

apps/chat/src/app/chat/use-chat-attachments.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,35 @@ const MAX_PENDING_IMAGES = 5;
66
const MAX_PENDING_ATTACHMENTS = 5;
77
export const MAX_PENDING_TOTAL = MAX_PENDING_IMAGES + MAX_PENDING_ATTACHMENTS;
88

9+
/**
10+
* Text to insert when pasting into the composer. Uses text/plain first; some apps only expose
11+
* HTML (still readable via innerText). Returns null for image-only clipboards so image paste can run.
12+
*/
13+
export function getClipboardTextForContentEditablePaste(
14+
data: React.ClipboardEvent['clipboardData']
15+
): string | null {
16+
if (!data) return null;
17+
const plain = data.getData('text/plain');
18+
if (plain.trim().length > 0) return plain;
19+
const html = data.getData('text/html');
20+
if (!html.trim()) return null;
21+
try {
22+
const doc = new DOMParser().parseFromString(html, 'text/html');
23+
const raw = doc.body.textContent ?? '';
24+
const text = raw.replace(/\r\n/g, '\n');
25+
if (text.trim().length === 0) return null;
26+
return text;
27+
} catch {
28+
return null;
29+
}
30+
}
31+
32+
export function hasNonEmptyPlainTextOnClipboard(
33+
data: React.ClipboardEvent['clipboardData']
34+
): boolean {
35+
return getClipboardTextForContentEditablePaste(data) !== null;
36+
}
37+
938
export interface UseChatAttachmentsParams {
1039
isAuthenticated: boolean;
1140
}
@@ -136,6 +165,7 @@ export function useChatAttachments({ isAuthenticated }: UseChatAttachmentsParams
136165

137166
const handlePaste = useCallback(
138167
(e: React.ClipboardEvent) => {
168+
if (getClipboardTextForContentEditablePaste(e.clipboardData) !== null) return;
139169
const items = e.clipboardData?.items;
140170
const item = items ? Array.from(items).find((it) => it.type.startsWith('image/')) : undefined;
141171
if (!item || pendingImages.length >= MAX_PENDING_IMAGES) return;

0 commit comments

Comments
 (0)