feat(messaging-ui): message formatting + native spellcheck in the composer
Composer becomes a <textarea> so the PLATFORM supplies text services: red spellcheck squiggles, right-click suggestions / add-to-dictionary, and mobile autocorrect (spellCheck + autoCorrect + autoCapitalize). Nothing shipped for it. Previously a single-line <input>, which Firefox does not spellcheck by default (layout.spellcheckDefault=1 checks multi-line only) and which could not hold a multi-line message at all. Enter sends, Shift+Enter (and IME composition) makes a newline, and the box grows with content. WhatsApp-style markup: *bold*, _italic_, ~strike~, `code`, ```fenced blocks```, plus bare URLs. Cmd/Ctrl+B/I/E and a small toolbar wrap the selection in markers. Messages stay PLAIN TEXT on the wire, so stored history and older clients are unaffected — formatting is purely a render concern. renderRichText() returns a ReactNode tree and never uses dangerouslySetInnerHTML, so message text cannot inject markup; links are restricted to http/https/mailto (safeHref) to close the javascript: vector. Code is tokenized first and its contents stay literal; markers require word boundaries so snake_case_name and "5 * 3" are not mangled. Bumped to 0.1.9. SDK suite: 17 files / 95 tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, type KeyboardEvent } from 'react';
|
||||
import {
|
||||
SPECIAL_MENTIONS,
|
||||
insertMention,
|
||||
@@ -13,6 +13,17 @@ interface Suggestion {
|
||||
insert: string;
|
||||
}
|
||||
|
||||
/** Keyboard shortcut → the marker it wraps the selection in. */
|
||||
const SHORTCUTS: Record<string, string> = { b: '*', i: '_', e: '`' };
|
||||
|
||||
/** Toolbar affordances for the same markers (strikethrough is button-only — no common shortcut). */
|
||||
const FORMAT_BUTTONS: Array<{ marker: string; label: string; title: string }> = [
|
||||
{ marker: '*', label: 'B', title: 'Bold (⌘B)' },
|
||||
{ marker: '_', label: 'I', title: 'Italic (⌘I)' },
|
||||
{ marker: '~', label: 'S', title: 'Strikethrough' },
|
||||
{ marker: '`', label: '‹›', title: 'Code (⌘E)' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The message input: draft, @mention autocomplete, and attachment staging. Shared by the main
|
||||
* Thread and the ThreadPane (which passes a parentInteractionId so a reply lands in the thread).
|
||||
@@ -24,7 +35,7 @@ export function Composer({
|
||||
onSend,
|
||||
onTyping,
|
||||
parentInteractionId,
|
||||
placeholder = 'Type a message… @ to mention',
|
||||
placeholder = 'Type a message… @ to mention, *bold*',
|
||||
}: {
|
||||
members: Person[];
|
||||
canUpload: boolean;
|
||||
@@ -40,6 +51,36 @@ export function Composer({
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadingName, setUploadingName] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Grow with the content up to the CSS max-height, then scroll — a chat box, not a fixed field.
|
||||
useEffect(() => {
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, [draft]);
|
||||
|
||||
/**
|
||||
* Wrap the current selection in a marker (or, with nothing selected, drop in an empty pair and
|
||||
* park the caret inside). Text stays plain — the marker characters ARE the format, so this never
|
||||
* needs a rich-text model and the native spellchecker keeps working on the raw string.
|
||||
*/
|
||||
function wrapSelection(marker: string): void {
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
const start = el.selectionStart ?? draft.length;
|
||||
const end = el.selectionEnd ?? start;
|
||||
const selected = draft.slice(start, end);
|
||||
const next = `${draft.slice(0, start)}${marker}${selected}${marker}${draft.slice(end)}`;
|
||||
setDraft(next);
|
||||
// Restore a sensible selection after React re-renders the value.
|
||||
const caret = selected ? start + marker.length + selected.length + marker.length : start + marker.length;
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
el.setSelectionRange(selected ? start + marker.length : caret, selected ? caret - marker.length : caret);
|
||||
});
|
||||
}
|
||||
|
||||
const query = trailingMentionQuery(draft);
|
||||
const suggestions = useMemo<Suggestion[]>(() => {
|
||||
@@ -93,10 +134,26 @@ export function Composer({
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent<HTMLInputElement>): void {
|
||||
if (showSuggest && e.key === 'Enter') {
|
||||
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>): void {
|
||||
// Formatting shortcuts, matching the toolbar. Cmd on macOS, Ctrl elsewhere.
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
const marker = SHORTCUTS[e.key.toLowerCase()];
|
||||
if (marker) {
|
||||
e.preventDefault();
|
||||
wrapSelection(marker);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key !== 'Enter') return;
|
||||
if (showSuggest) {
|
||||
e.preventDefault();
|
||||
pick(suggestions[0]!.insert);
|
||||
return;
|
||||
}
|
||||
// Enter sends; Shift+Enter (and IME composition) inserts a newline.
|
||||
if (!e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +182,13 @@ export function Composer({
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="miu-format-bar" role="group" aria-label="Formatting">
|
||||
{FORMAT_BUTTONS.map((b) => (
|
||||
<button key={b.marker} type="button" className="miu-format-btn" title={b.title} aria-label={b.title} onClick={() => wrapSelection(b.marker)}>
|
||||
{b.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="miu-composer-row">
|
||||
{canUpload ? (
|
||||
<>
|
||||
@@ -134,11 +198,18 @@ export function Composer({
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<input
|
||||
className="miu-input"
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="miu-input miu-textarea"
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
aria-label="Message"
|
||||
rows={1}
|
||||
// Native platform text services: the browser/OS supplies the red squiggle, the right-click
|
||||
// suggestions and "Add to dictionary", and mobile keyboards add autocorrect. Nothing to ship.
|
||||
spellCheck
|
||||
autoCorrect="on"
|
||||
autoCapitalize="sentences"
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
onTyping?.();
|
||||
|
||||
Reference in New Issue
Block a user