Skip to content

Commit 83fd5c0

Browse files
ralyodioclaude
andcommitted
Composer draws emoji with OpenEmoji while you type (0.2.2)
A textarea can only draw characters, so a picked emoji showed in the system font. The composer is now a contenteditable EmojiEditor: emoji are OpenEmoji <img>s (alt = the character), the value stays plain Unicode (what is encrypted is unchanged), typed/OS-keyboard/pasted emoji are swapped in place with the caret kept, Enter sends, Shift+Enter is a new line, paste is plain text only. A chunked web font (unicode-range WOFF2, CBDT/sbix/SVG) was tried first and dropped: ZWJ/VS16 sequences broke or fell back to the system font, any text with VS16 pulled every chunk (~16 MB), and Firefox renders neither CBDT nor sbix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent cd4457b commit 83fd5c0

6 files changed

Lines changed: 405 additions & 38 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "qryptchat-web",
33
"private": true,
4-
"version": "0.2.1",
4+
"version": "0.2.2",
55
"type": "module",
66
"bin": {
77
"qc": "./bin/qc.js",
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
'use client';
2+
3+
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
4+
import { hasRawEmoji, render, selectionOffsets, serialize, setCaret, spliceValue } from '@/lib/emoji/editor-dom.js';
5+
6+
/**
7+
* A one-field message editor that draws emoji with OpenEmoji images while its
8+
* value stays plain text. Drop-in for the composer's textarea:
9+
* value / onChange(value) the message text
10+
* onSubmit() Enter (Shift+Enter is a new line)
11+
* ref.insert(text) put text at the caret (the emoji picker)
12+
* ref.focus()
13+
*/
14+
const EmojiEditor = forwardRef(function EmojiEditor(
15+
{ value, onChange, onSubmit, placeholder = '', disabled = false, className = '', ariaLabel = 'Message' },
16+
ref
17+
) {
18+
const el = useRef(null);
19+
const last = useRef('');
20+
const composing = useRef(false);
21+
const caretRef = useRef(null);
22+
23+
// Keep the DOM in step when the value changes from outside (cleared after send).
24+
useEffect(() => {
25+
const root = el.current;
26+
if (!root || value === last.current) return;
27+
render(root, value);
28+
last.current = value;
29+
}, [value]);
30+
31+
function commit(next, caret) {
32+
const root = el.current;
33+
render(root, next);
34+
last.current = next;
35+
if (caret != null && root.ownerDocument.activeElement === root) setCaret(root, caret);
36+
onChange?.(next);
37+
}
38+
39+
useImperativeHandle(ref, () => ({
40+
focus() {
41+
el.current?.focus();
42+
},
43+
insert(text) {
44+
const root = el.current;
45+
if (!root) return;
46+
const sel = selectionOffsets(root) ?? caretRef.current;
47+
const { value: next, caret } = spliceValue(last.current, sel, text);
48+
root.focus();
49+
commit(next, caret);
50+
},
51+
}));
52+
53+
function handleInput() {
54+
const root = el.current;
55+
const next = serialize(root);
56+
if (!composing.current && hasRawEmoji(root)) {
57+
// Typed from the OS emoji keyboard or autocorrected: swap in our art, keep the caret.
58+
const sel = selectionOffsets(root);
59+
commit(next, sel?.end ?? next.length);
60+
return;
61+
}
62+
last.current = next;
63+
onChange?.(next);
64+
}
65+
66+
function handleKeyDown(e) {
67+
if (e.key === 'Enter' && !e.shiftKey && !composing.current) {
68+
e.preventDefault();
69+
onSubmit?.();
70+
return;
71+
}
72+
if (e.key === 'Enter' && e.shiftKey) {
73+
e.preventDefault();
74+
const { value: next, caret } = spliceValue(last.current, selectionOffsets(el.current), '\n');
75+
commit(next, caret);
76+
}
77+
}
78+
79+
function handlePaste(e) {
80+
// Plain text only: no foreign markup in an encrypted message.
81+
e.preventDefault();
82+
const text = e.clipboardData?.getData('text/plain') ?? '';
83+
if (!text) return;
84+
const { value: next, caret } = spliceValue(last.current, selectionOffsets(el.current), text.replace(/\r\n/g, '\n'));
85+
commit(next, caret);
86+
}
87+
88+
// Remember where the caret was, so the picker (which takes focus) inserts there.
89+
function rememberCaret() {
90+
const sel = el.current && selectionOffsets(el.current);
91+
if (sel) caretRef.current = sel;
92+
}
93+
94+
return (
95+
<div
96+
ref={el}
97+
className={`emoji-editor ${className}`}
98+
contentEditable={!disabled}
99+
suppressContentEditableWarning
100+
role="textbox"
101+
aria-multiline="true"
102+
aria-label={ariaLabel}
103+
aria-placeholder={placeholder}
104+
aria-disabled={disabled}
105+
data-placeholder={placeholder}
106+
data-empty={value ? 'false' : 'true'}
107+
spellCheck
108+
onInput={handleInput}
109+
onKeyDown={handleKeyDown}
110+
onKeyUp={rememberCaret}
111+
onMouseUp={rememberCaret}
112+
onBlur={rememberCaret}
113+
onPaste={handlePaste}
114+
onCompositionStart={() => (composing.current = true)}
115+
onCompositionEnd={() => {
116+
composing.current = false;
117+
handleInput();
118+
}}
119+
onDrop={(e) => e.preventDefault()}
120+
/>
121+
);
122+
});
123+
124+
export default EmojiEditor;

‎src/lib/components/chat/MessageInput.jsx‎

Lines changed: 14 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
'use client';
22

3-
import { useState, useRef, useEffect } from 'react';
3+
import { useState, useRef } from 'react';
44
import { useChatStore } from '@/lib/stores/chat.js';
55
import { useShallow } from 'zustand/react/shallow';
66
import { useAuthStore } from '@/lib/stores/auth.js';
77
import { detectTextFormat } from '@profullstack/text-type-detection';
88
import { trackMessageSent } from '@/lib/utils/analytics.js';
99
import { multiRecipientEncryption } from '@/lib/crypto/multi-recipient-encryption.js';
10-
import { insertAtCursor } from '@/lib/emoji/openemoji.js';
10+
import EmojiEditor from './EmojiEditor.jsx';
1111
import EmojiPicker, { Icon } from './EmojiPicker.jsx';
1212

1313
const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024; // 2GB
@@ -44,7 +44,7 @@ export default function MessageInput({ conversationId, disabled = false }) {
4444
const [selectedFiles, setSelectedFiles] = useState([]);
4545
const [uploadError, setUploadError] = useState('');
4646
const [showEmoji, setShowEmoji] = useState(false);
47-
const textareaRef = useRef(null);
47+
const editorRef = useRef(null);
4848
const fileInputRef = useRef(null);
4949
const typingTimeoutRef = useRef(null);
5050

@@ -58,37 +58,16 @@ export default function MessageInput({ conversationId, disabled = false }) {
5858
}))
5959
);
6060

61-
useEffect(() => {
62-
if (textareaRef.current) {
63-
textareaRef.current.style.height = 'auto';
64-
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 120) + 'px';
65-
}
66-
}, [messageText]);
67-
68-
function handleKeyDown(e) {
69-
if (e.key === 'Enter' && !e.shiftKey) {
70-
e.preventDefault();
71-
handleSend();
72-
}
73-
}
74-
75-
function handleInput(e) {
76-
const val = e.target.value;
61+
function handleChange(val) {
7762
setMessageText(val);
7863
if (conversationId && user?.id) {
7964
setTyping(conversationId);
8065
}
8166
}
8267

68+
// The picker hands back a character; the editor draws it as our artwork.
8369
function insertEmoji(char) {
84-
const el = textareaRef.current;
85-
const { value, caret } = insertAtCursor(messageText, el?.selectionStart, el?.selectionEnd, char);
86-
setMessageText(value);
87-
requestAnimationFrame(() => {
88-
if (!textareaRef.current) return;
89-
textareaRef.current.focus();
90-
textareaRef.current.setSelectionRange(caret, caret);
91-
});
70+
editorRef.current?.insert(char);
9271
}
9372

9473
function handleFileInput(e) {
@@ -292,15 +271,14 @@ export default function MessageInput({ conversationId, disabled = false }) {
292271
<Icon name="smile" size={20} />
293272
</button>
294273
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={handleFileInput} />
295-
<textarea
296-
ref={textareaRef}
274+
<EmojiEditor
275+
ref={editorRef}
297276
className="message-textarea"
298277
value={messageText}
299-
onChange={handleInput}
300-
onKeyDown={handleKeyDown}
278+
onChange={handleChange}
279+
onSubmit={handleSend}
301280
placeholder="Type a message..."
302281
disabled={disabled || isSending}
303-
rows={1}
304282
/>
305283
<button
306284
className="send-btn"
@@ -332,7 +310,10 @@ export default function MessageInput({ conversationId, disabled = false }) {
332310
.send-spinner { border-top-color: white; }
333311
.message-input-wrapper { display: flex; align-items: flex-end; gap: .5rem; background: var(--color-bg-secondary); border: 1px solid var(--color-border-primary); border-radius: 1.5rem; padding: .375rem .375rem .375rem .75rem; }
334312
.message-textarea { flex: 1; border: none; background: transparent; resize: none; font-size: .9375rem; line-height: 1.5; max-height: 120px; padding: .25rem 0; color: var(--color-text-primary); outline: none; font-family: inherit; }
335-
.message-textarea::placeholder { color: var(--color-text-muted); }
313+
.message-textarea { overflow-y: auto; white-space: pre-wrap; word-break: break-word; min-height: 1.5em; cursor: text; }
314+
.message-textarea[data-empty="true"]::before { content: attr(data-placeholder); color: var(--color-text-muted); pointer-events: none; }
315+
.message-textarea[aria-disabled="true"] { opacity: .6; cursor: not-allowed; }
316+
.message-textarea img.openemoji { width: 1.35em; height: 1.35em; vertical-align: -0.3em; margin: 0 .05em; }
336317
.attach-btn, .emoji-btn, .send-btn { flex-shrink: 0; width: 36px; height: 36px; border: none; border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all .2s; }
337318
.attach-btn, .emoji-btn { background: transparent; color: var(--color-text-secondary); }
338319
.attach-btn:hover:not(:disabled), .emoji-btn:hover:not(:disabled), .emoji-btn.active { background: var(--color-bg-tertiary); color: var(--color-text-primary); }

0 commit comments

Comments
 (0)