Command history: - Up/Down arrow keys browse sent command history (like bash/console) - 50 commands stored per character in localStorage - Persists across page reloads and browser sessions - Current input preserved when browsing (restored on Down past end) - Duplicates kept (matches user preference) Smart auto-scroll: - New messages only auto-scroll if user is already at the bottom - If user has scrolled up to read history, it stays put - Sending a message snaps back to bottom - 30px threshold for "at bottom" detection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
127 lines
4.2 KiB
TypeScript
127 lines
4.2 KiB
TypeScript
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
|
import { DraggableWindow } from './DraggableWindow';
|
|
|
|
interface ChatMsg {
|
|
text: string;
|
|
color?: number;
|
|
timestamp: string;
|
|
}
|
|
|
|
const CHAT_COLORS: Record<number, string> = {
|
|
0:'#00FF00', 2:'#FFFFFF', 3:'#FF0000', 4:'#FFFFFF', 5:'#33CCFF', 6:'#CCFF99',
|
|
7:'#00FFFF', 14:'#FFD700', 15:'#FF69B4', 17:'#AAAAFF', 18:'#88FF88',
|
|
21:'#FF8888', 22:'#FFAA66',
|
|
};
|
|
|
|
const MAX_HISTORY = 50;
|
|
const HISTORY_KEY = (name: string) => `mo-chat-history-${name}`;
|
|
|
|
function loadHistory(charName: string): string[] {
|
|
try {
|
|
const raw = localStorage.getItem(HISTORY_KEY(charName));
|
|
return raw ? JSON.parse(raw) : [];
|
|
} catch { return []; }
|
|
}
|
|
|
|
function saveHistory(charName: string, history: string[]) {
|
|
try {
|
|
localStorage.setItem(HISTORY_KEY(charName), JSON.stringify(history.slice(-MAX_HISTORY)));
|
|
} catch { /* quota exceeded — ignore */ }
|
|
}
|
|
|
|
interface Props {
|
|
id: string;
|
|
charName: string;
|
|
zIndex: number;
|
|
messages: ChatMsg[];
|
|
socket: WebSocket | null;
|
|
}
|
|
|
|
export const ChatWindow: React.FC<Props> = ({ id, charName, zIndex, messages, socket }) => {
|
|
const msgsRef = useRef<HTMLDivElement>(null);
|
|
const [input, setInput] = useState('');
|
|
const historyRef = useRef<string[]>(loadHistory(charName));
|
|
const historyIndexRef = useRef(-1); // -1 = not browsing history
|
|
const savedInputRef = useRef(''); // preserves current input when browsing history
|
|
const userScrolledRef = useRef(false);
|
|
|
|
// Auto-scroll only if user is already at the bottom
|
|
useEffect(() => {
|
|
const el = msgsRef.current;
|
|
if (!el) return;
|
|
if (!userScrolledRef.current) {
|
|
el.scrollTop = el.scrollHeight;
|
|
}
|
|
}, [messages.length]);
|
|
|
|
// Track whether user has scrolled up from bottom
|
|
const handleScroll = useCallback(() => {
|
|
const el = msgsRef.current;
|
|
if (!el) return;
|
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 30;
|
|
userScrolledRef.current = !atBottom;
|
|
}, []);
|
|
|
|
const handleSend = useCallback((e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const text = input.trim();
|
|
if (!text || !socket || socket.readyState !== WebSocket.OPEN) return;
|
|
socket.send(JSON.stringify({ player_name: charName, command: text }));
|
|
|
|
// Add to history
|
|
historyRef.current.push(text);
|
|
if (historyRef.current.length > MAX_HISTORY) historyRef.current.shift();
|
|
saveHistory(charName, historyRef.current);
|
|
historyIndexRef.current = -1;
|
|
savedInputRef.current = '';
|
|
|
|
setInput('');
|
|
|
|
// Snap back to bottom on send
|
|
userScrolledRef.current = false;
|
|
}, [input, socket, charName]);
|
|
|
|
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
const history = historyRef.current;
|
|
if (history.length === 0) return;
|
|
|
|
if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
if (historyIndexRef.current === -1) {
|
|
// Starting to browse — save current input
|
|
savedInputRef.current = input;
|
|
historyIndexRef.current = history.length - 1;
|
|
} else if (historyIndexRef.current > 0) {
|
|
historyIndexRef.current--;
|
|
}
|
|
setInput(history[historyIndexRef.current]);
|
|
} else if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
if (historyIndexRef.current === -1) return; // not browsing
|
|
if (historyIndexRef.current < history.length - 1) {
|
|
historyIndexRef.current++;
|
|
setInput(history[historyIndexRef.current]);
|
|
} else {
|
|
// Past the end — restore saved input
|
|
historyIndexRef.current = -1;
|
|
setInput(savedInputRef.current);
|
|
}
|
|
}
|
|
}, [input]);
|
|
|
|
return (
|
|
<DraggableWindow id={id} title={`Chat: ${charName}`} zIndex={zIndex} width={600} height={300}>
|
|
<div className="ml-chat-messages" ref={msgsRef} onScroll={handleScroll}>
|
|
{messages.map((m, i) => (
|
|
<div key={i} className="ml-chat-line" style={{ color: CHAT_COLORS[m.color ?? 2] ?? '#ddd' }}>
|
|
{m.text}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<form className="ml-chat-form" onSubmit={handleSend}>
|
|
<input className="ml-chat-input" value={input} onChange={e => setInput(e.target.value)}
|
|
onKeyDown={handleKeyDown} placeholder="Enter chat..." />
|
|
</form>
|
|
</DraggableWindow>
|
|
);
|
|
};
|