Files
dumpsterChat/web/src/components/MessageInput.tsx
T

708 lines
27 KiB
TypeScript

import { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from "react";
import { GiphyPicker, type Gif } from "./GiphyPicker";
import { EmojiPicker as KaomojiPicker } from "./EmojiPicker";
import Picker, { Theme } from "emoji-picker-react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faBold,
faItalic,
faStrikethrough,
faCode,
faEyeSlash,
faFilm,
faSmile,
faGrin,
faPaperPlane,
faPlus,
faParagraph,
faAlignLeft,
faListUl,
faListOl,
faQuoteRight,
faLink,
faHeading,
} from "@fortawesome/free-solid-svg-icons";
// ponytail: mirrors backend allowlist; real guard is server-side content-type detection
const SAFE_EXTS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".mp3", ".wav", ".ogg", ".flac", ".mp4", ".webm", ".mov", ".pdf", ".txt", ".md", ".json", ".csv", ".zip", ".tar", ".gz"]);
// ponytail: minimal round-trip converters for the formatting our toolbar produces
function htmlToMarkdown(html: string): string {
let md = html;
// code blocks: <pre><code>...</code></pre>
md = md.replace(/<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, (_, code) => {
const inner = code.replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]*>/g, "");
return "\n```\n" + inner + "\n```\n";
});
// links
md = md.replace(/<a [^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, "[$2]($1)");
// headings
md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gi, "\n# $1\n");
md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gi, "\n## $1\n");
md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gi, "\n### $1\n");
// blockquotes: handle nested <br> inside
md = md.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, (_, inner) => {
const lines = inner.replace(/<br\s*\/?>/gi, "\n").replace(/<[^>]*>/g, "").split("\n");
return "\n" + lines.map((l: string) => "> " + l.trim()).join("\n") + "\n";
});
// unordered lists
md = md.replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, (_, inner) => {
const items = inner.match(/<li[^>]*>([\s\S]*?)<\/li>/gi) || [];
return "\n" + items.map((li: string) => "- " + li.replace(/<\/?li[^>]*>/gi, "").replace(/<br\s*\/?>/gi, " ").replace(/<[^>]*>/g, "").trim()).join("\n") + "\n";
});
// ordered lists
md = md.replace(/<ol[^>]*>([\s\S]*?)<\/ol>/gi, (_, inner) => {
const items = inner.match(/<li[^>]*>([\s\S]*?)<\/li>/gi) || [];
return "\n" + items.map((li: string, i: number) => `${i + 1}. ` + li.replace(/<\/?li[^>]*>/gi, "").replace(/<br\s*\/?>/gi, " ").replace(/<[^>]*>/g, "").trim()).join("\n") + "\n";
});
// remaining block elements → newlines
md = md.replace(/<div[^>]*>/gi, "\n").replace(/<\/div>/gi, "");
md = md.replace(/<br\s*\/?>/gi, "\n");
md = md.replace(/<p[^>]*>/gi, "").replace(/<\/p>/gi, "\n");
// inline formatting (innermost first)
md = md.replace(/<span class="spoiler"[^>]*>(.*?)<\/span>/gi, "||$1||");
md = md.replace(/<(?:b|strong)>(.*?)<\/(?:b|strong)>/gi, "**$1**");
md = md.replace(/<(?:i|em)>(.*?)<\/(?:i|em)>/gi, "*$1*");
md = md.replace(/<(?:s|strike|del)>(.*?)<\/(?:s|strike|del)>/gi, "~~$1~~");
md = md.replace(/<code>(.*?)<\/code>/gi, "`$1`");
// strip remaining tags
md = md.replace(/<[^>]*>/g, "");
// decode entities
md = md.replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">");
// collapse ≥3 consecutive newlines
md = md.replace(/\n{3,}/g, "\n\n");
return md.trim();
}
function markdownToHtml(md: string): string {
// split into lines, process block structures
const lines = md.split("\n");
const result: string[] = [];
let inCodeBlock = false;
let codeBuf: string[] = [];
let inList: "ul" | "ol" | null = null;
const flushList = () => {
if (inList) {
result.push(`</${inList}>`);
inList = null;
}
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// code block fences
if (/^```/.test(line.trim())) {
if (inCodeBlock) {
result.push(`<pre><code>${escapeHtml(codeBuf.join("\n"))}</code></pre>`);
codeBuf = [];
inCodeBlock = false;
} else {
flushList();
inCodeBlock = true;
}
continue;
}
if (inCodeBlock) { codeBuf.push(line); continue; }
// headings
const hMatch = line.match(/^(#{1,6})\s+(.+)/);
if (hMatch) {
flushList();
const level = hMatch[1].length;
result.push(`<h${level}>${inlineMarkdownToHtml(hMatch[2])}</h${level}>`);
continue;
}
// blockquote
if (/^>\s?/.test(line)) {
flushList();
const content = line.replace(/^>\s?/, "");
result.push(`<blockquote>${inlineMarkdownToHtml(content)}</blockquote>`);
continue;
}
// unordered list
const ulMatch = line.match(/^[-*+]\s+(.+)/);
if (ulMatch) {
if (inList !== "ul") { flushList(); result.push("<ul>"); inList = "ul"; }
result.push(`<li>${inlineMarkdownToHtml(ulMatch[1])}</li>`);
continue;
}
// ordered list
const olMatch = line.match(/^\d+\.\s+(.+)/);
if (olMatch) {
if (inList !== "ol") { flushList(); result.push("<ol>"); inList = "ol"; }
result.push(`<li>${inlineMarkdownToHtml(olMatch[1])}</li>`);
continue;
}
// blank line: flush list
if (line.trim() === "") {
flushList();
result.push("<br>");
continue;
}
// regular paragraph
flushList();
result.push(`<div>${inlineMarkdownToHtml(line)}</div>`);
}
flushList();
if (inCodeBlock) result.push(`<pre><code>${escapeHtml(codeBuf.join("\n"))}</code></pre>`);
return result.join("");
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function inlineMarkdownToHtml(text: string): string {
let html = escapeHtml(text);
// links [text](url)
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
html = html.replace(/\|\|(.+?)\|\|/g, '<span class="spoiler">$1</span>');
html = html.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
html = html.replace(/\*(.+?)\*/g, "<i>$1</i>");
html = html.replace(/~~(.+?)~~/g, "<s>$1</s>");
html = html.replace(/`(.+?)`/g, "<code>$1</code>");
return html;
}
type EditorMode = "md" | "rt";
interface MessageInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
onPaste?: (e: React.ClipboardEvent<HTMLTextAreaElement>) => void;
onGifSelect?: (gif: Gif) => void;
placeholder?: string;
disabled?: boolean;
}
export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(function MessageInput({
value,
onChange,
onSubmit,
onPaste,
onGifSelect,
placeholder = "Message...",
disabled = false,
}, ref) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const richRef = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => textareaRef.current!, []);
const [showGif, setShowGif] = useState(false);
const [showKaomoji, setShowKaomoji] = useState(false);
const [showEmoji, setShowEmoji] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [isDragOver, setIsDragOver] = useState(false);
const [mode, setMode] = useState<EditorMode>("md");
const fileInputRef = useRef<HTMLInputElement>(null);
const dragCounterRef = useRef(0);
const richSyncing = useRef(false);
// sync rich text div when value changes externally
useEffect(() => {
if (mode === "rt" && richRef.current && !richSyncing.current) {
richRef.current.innerHTML = markdownToHtml(value);
}
}, [value, mode]);
// --- textarea helper ---
const wrapTextarea = useCallback((prefix: string, suffix: string, defaultText = "text") => {
const ta = textareaRef.current;
if (!ta) return;
const start = ta.selectionStart ?? 0;
const end = ta.selectionEnd ?? 0;
const selected = ta.value.slice(start, end) || defaultText;
const result = ta.value.slice(0, start) + prefix + selected + suffix + ta.value.slice(end);
onChange(result);
requestAnimationFrame(() => {
ta.focus();
ta.setSelectionRange(start + prefix.length, start + prefix.length + selected.length);
});
}, [onChange]);
// --- inline formatting ---
const execInline = useCallback((prefix: string, suffix: string) => {
if (mode === "rt") {
document.execCommand("styleWithCSS", false, "false");
if (prefix === "**") document.execCommand("bold");
else if (prefix === "*") document.execCommand("italic");
else if (prefix === "~~") document.execCommand("strikeThrough");
else if (prefix === "`") {
const sel = window.getSelection();
if (sel && !sel.isCollapsed) {
const range = sel.getRangeAt(0);
const code = document.createElement("code");
code.textContent = range.toString();
range.deleteContents();
range.insertNode(code);
}
} else if (prefix === "||") {
const sel = window.getSelection();
if (sel && !sel.isCollapsed) {
const range = sel.getRangeAt(0);
const span = document.createElement("span");
span.className = "spoiler";
span.textContent = range.toString();
range.deleteContents();
range.insertNode(span);
}
}
richRef.current?.focus();
} else {
wrapTextarea(prefix, suffix);
}
}, [mode, wrapTextarea]);
// --- block-level formatting (rich text uses execCommand, markdown wraps) ---
const execBlock = useCallback((cmd: string) => {
if (mode === "rt") {
if (cmd === "ul") document.execCommand("insertUnorderedList");
else if (cmd === "ol") document.execCommand("insertOrderedList");
else if (cmd === "blockquote") {
// execCommand quote is finicky; wrap selected blocks manually
const sel = window.getSelection();
if (sel && !sel.isCollapsed) {
document.execCommand("formatBlock", false, "<blockquote>");
} else {
// insert empty blockquote
const bq = document.createElement("blockquote");
bq.innerHTML = "<br>";
richRef.current?.appendChild(bq);
}
}
else if (cmd === "h1") document.execCommand("formatBlock", false, "<h1>");
else if (cmd === "h2") document.execCommand("formatBlock", false, "<h2>");
else if (cmd === "h3") document.execCommand("formatBlock", false, "<h3>");
richRef.current?.focus();
} else {
switch (cmd) {
case "ul": wrapTextarea("- ", ""); break;
case "ol": wrapTextarea("1. ", ""); break;
case "blockquote": {
const ta = textareaRef.current;
if (!ta) return;
const start = ta.selectionStart ?? 0;
const end = ta.selectionEnd ?? 0;
const text = ta.value.slice(start, end) || ta.value;
const lines = text.split("\n").map((l) => "> " + l).join("\n");
onChange(lines);
requestAnimationFrame(() => ta.focus());
break;
}
case "h1": wrapTextarea("# ", "", "Heading 1"); break;
case "h2": wrapTextarea("## ", "", "Heading 2"); break;
case "h3": wrapTextarea("### ", "", "Heading 3"); break;
}
}
}, [mode, onChange, wrapTextarea]);
// --- link insertion ---
const execLink = useCallback(() => {
const url = prompt("Enter URL:", "https://");
if (!url) return;
if (mode === "rt") {
const sel = window.getSelection();
if (sel && !sel.isCollapsed) {
document.execCommand("createLink", false, url);
} else {
const a = document.createElement("a");
a.href = url;
a.target = "_blank";
a.rel = "noreferrer";
a.textContent = url;
richRef.current?.appendChild(a);
}
richRef.current?.focus();
} else {
const ta = textareaRef.current;
if (!ta) return;
const start = ta.selectionStart ?? 0;
const end = ta.selectionEnd ?? 0;
const selected = ta.value.slice(start, end) || url;
const md = `[${selected}](${url})`;
const result = ta.value.slice(0, start) + md + ta.value.slice(end);
onChange(result);
requestAnimationFrame(() => {
ta.focus();
ta.setSelectionRange(start + md.length, start + md.length);
});
}
}, [mode, onChange]);
// --- insert text/emoji at cursor ---
const insert = useCallback((text: string) => {
if (mode === "rt" && richRef.current) {
richRef.current.focus();
document.execCommand("insertText", false, text);
} else {
onChange(value + text);
textareaRef.current?.focus();
}
}, [mode, value, onChange]);
const insertMarkdown = useCallback((md: string) => {
if (mode === "rt" && richRef.current) {
richRef.current.focus();
const html = markdownToHtml(md);
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
range.deleteContents();
const frag = range.createContextualFragment(html);
range.insertNode(frag);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
}
} else {
onChange(value + (value && !value.endsWith(" ") ? " " : "") + md);
textareaRef.current?.focus();
}
}, [mode, value, onChange]);
// --- file upload ---
const validateFile = useCallback((file: File): string | null => {
const ext = file.name.slice(file.name.lastIndexOf(".")).toLowerCase();
if (!SAFE_EXTS.has(ext)) return `File type "${ext}" not allowed.`;
if (file.size > 1024 * 1024 * 1024) return "File too large (max 1 GB).";
return null;
}, []);
const uploadFile = useCallback((file: File) => {
const err = validateFile(file);
if (err) { alert(err); return; }
setUploading(true);
setUploadProgress(0);
const formData = new FormData();
formData.append("file", file);
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) setUploadProgress(Math.round((e.loaded / e.total) * 100));
};
xhr.onload = () => {
setUploading(false);
if (xhr.status >= 200 && xhr.status < 300) {
const data = JSON.parse(xhr.responseText);
const isImage = /\.(jpg|jpeg|png|gif|webp)$/i.test(file.name);
const md = isImage ? `![${file.name}](${data.url})` : `[${file.name}](${data.url})`;
insertMarkdown(md);
} else {
const msg = (() => { try { return JSON.parse(xhr.responseText).error; } catch { return "Upload failed"; } })();
alert(msg);
}
};
xhr.onerror = () => { setUploading(false); alert("Upload failed"); };
xhr.open("POST", "/api/v1/upload");
xhr.withCredentials = true;
xhr.send(formData);
}, [validateFile, insertMarkdown]);
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) uploadFile(file);
e.target.value = "";
}, [uploadFile]);
// --- drag-and-drop ---
useEffect(() => {
const el = mode === "rt" ? richRef.current : textareaRef.current;
if (!el) return;
const onDragEnter = (e: Event) => {
e.preventDefault(); e.stopPropagation();
dragCounterRef.current++;
if (dragCounterRef.current === 1) setIsDragOver(true);
};
const onDragOver = (e: Event) => { e.preventDefault(); };
const onDragLeave = (e: Event) => {
e.preventDefault(); e.stopPropagation();
dragCounterRef.current--;
if (dragCounterRef.current === 0) setIsDragOver(false);
};
const onDrop = (e: Event) => {
e.preventDefault(); e.stopPropagation();
setIsDragOver(false);
dragCounterRef.current = 0;
const file = (e as DragEvent).dataTransfer?.files?.[0];
if (file) uploadFile(file);
};
el.addEventListener("dragenter", onDragEnter);
el.addEventListener("dragover", onDragOver);
el.addEventListener("dragleave", onDragLeave);
el.addEventListener("drop", onDrop);
return () => {
el.removeEventListener("dragenter", onDragEnter);
el.removeEventListener("dragover", onDragOver);
el.removeEventListener("dragleave", onDragLeave);
el.removeEventListener("drop", onDrop);
};
}, [uploadFile, mode]);
// --- Ctrl+E kaomoji ---
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === "e" && !disabled) {
e.preventDefault();
setShowKaomoji((p) => !p);
setShowEmoji(false);
setShowGif(false);
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [disabled]);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
let finalValue = value;
if (mode === "rt" && richRef.current) {
finalValue = htmlToMarkdown(richRef.current.innerHTML);
}
if (!disabled && finalValue.trim()) {
if (mode === "rt") onChange(finalValue);
onSubmit();
}
},
[disabled, value, mode, onChange, onSubmit],
);
const handleRichInput = useCallback(() => {
if (!richRef.current) return;
richSyncing.current = true;
const md = htmlToMarkdown(richRef.current.innerHTML);
onChange(md);
requestAnimationFrame(() => { richSyncing.current = false; });
}, [onChange]);
const toggleMode = useCallback(() => {
setMode((prev) => {
const next: EditorMode = prev === "md" ? "rt" : "md";
if (next === "rt") {
requestAnimationFrame(() => {
if (richRef.current) {
richRef.current.innerHTML = markdownToHtml(value || "");
}
});
}
return next;
});
}, [value]);
const toggleGif = () => { setShowGif((p) => !p); setShowKaomoji(false); setShowEmoji(false); };
const toggleEmoji = () => { setShowEmoji((p) => !p); setShowKaomoji(false); setShowGif(false); };
const toggleKaomoji = () => { setShowKaomoji((p) => !p); setShowEmoji(false); setShowGif(false); };
const handleGif = (gif: Gif) => {
if (onGifSelect) {
onGifSelect(gif);
} else {
const md = `![${gif.title || "GIF"}](${gif.images.fixed_height.url})`;
insertMarkdown(md);
}
setShowGif(false);
};
return (
<div className="relative">
{/* picker overlays */}
{showGif && (
<div className="mb-1">
<GiphyPicker onSelect={handleGif} onClose={() => setShowGif(false)} />
</div>
)}
{showKaomoji && (
<KaomojiPicker onSelect={insert} onClose={() => setShowKaomoji(false)} />
)}
{showEmoji && (
<div className="absolute bottom-full right-0 mb-2 z-50">
<Picker
theme={Theme.DARK}
onEmojiClick={(e) => { insert(e.emoji); setShowEmoji(false); }}
/>
</div>
)}
<form onSubmit={handleSubmit}>
<div className="bg-gb-bg-s terminal-border px-2 py-1">
{mode === "md" ? (
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onPaste={onPaste}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmit();
}
}}
placeholder={isDragOver ? "Drop file here..." : placeholder}
rows={1}
disabled={disabled}
className={`w-full bg-transparent outline-none border-none resize-none overflow-y-auto max-h-32 text-[14px] leading-snug py-1 font-mono ${isDragOver ? "bg-gb-bg-t" : ""}`}
/>
) : (
<div
ref={richRef}
contentEditable={!disabled}
suppressContentEditableWarning
onInput={handleRichInput}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmit();
}
}}
data-placeholder={isDragOver ? "Drop file here..." : placeholder}
className={`w-full outline-none resize-none overflow-y-auto max-h-32 text-[14px] leading-snug py-1 font-mono
before:content-[attr(data-placeholder)] before:text-gb-fg-f before:opacity-60
empty:before:block before:hidden
${isDragOver ? "bg-gb-bg-t" : ""}
[&_b]:text-gb-fg [&_b]:font-bold [&_strong]:font-bold [&_i]:text-gb-fg [&_i]:italic [&_em]:italic [&_s]:text-gb-fg [&_s]:line-through [&_del]:line-through [&_strike]:line-through
[&_code]:bg-gb-bg-t [&_code]:px-1 [&_code]:rounded
[&_.spoiler]:bg-gb-bg-t [&_.spoiler]:px-1 [&_.spoiler]:rounded
[&_a]:text-gb-aqua [&_a]:underline
[&_h1]:text-lg [&_h1]:font-bold [&_h2]:text-base [&_h2]:font-bold
[&_h3]:text-sm [&_h3]:font-bold
[&_blockquote]:border-l-2 [&_blockquote]:border-gb-orange [&_blockquote]:pl-2 [&_blockquote]:text-gb-fg-s
[&_ul]:list-disc [&_ul]:ml-4 [&_ol]:list-decimal [&_ol]:ml-4
[&_pre]:bg-gb-bg-t [&_pre]:p-2 [&_pre]:my-1 [&_pre]:overflow-x-auto
[&_pre_code]:bg-transparent [&_pre_code]:px-0`}
/>
)}
{/* upload progress bar */}
{uploading && (
<div className="flex items-center gap-2 px-1 pb-1">
<span className="text-gb-fg-f text-xs font-mono shrink-0">uploading</span>
<div className="flex-1 h-1.5 bg-gb-bg-t rounded-full overflow-hidden">
<div
className="h-full bg-gb-aqua transition-all duration-150 rounded-full"
style={{ width: `${uploadProgress || 5}%` }}
/>
</div>
<span className="text-gb-fg-f text-xs font-mono shrink-0 w-8 text-right">{uploadProgress}%</span>
</div>
)}
{/* toolbar row */}
<div className="flex items-center gap-0.5 pt-0.5 border-t border-gb-bg-t">
{/* + file upload */}
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileInput}
accept=".jpg,.jpeg,.png,.gif,.webp,.mp3,.wav,.ogg,.flac,.mp4,.webm,.mov,.pdf,.txt,.md,.json,.csv,.zip,.tar,.gz"
/>
<button type="button" disabled={disabled || uploading}
onClick={() => fileInputRef.current?.click()}
title="Upload file"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faPlus} className={`w-3.5 h-3.5 ${uploading ? "animate-pulse" : ""}`} />
</button>
<span className="text-gb-bg-t mx-1"></span>
{/* block formatting */}
<button type="button" disabled={disabled}
onClick={() => execBlock("ul")} title="Unordered list"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faListUl} className="w-3 h-3" />
</button>
<button type="button" disabled={disabled}
onClick={() => execBlock("ol")} title="Ordered list"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faListOl} className="w-3 h-3" />
</button>
<button type="button" disabled={disabled}
onClick={() => execBlock("blockquote")} title="Blockquote"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faQuoteRight} className="w-3 h-3" />
</button>
<button type="button" disabled={disabled}
onClick={execLink} title="Insert link"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faLink} className="w-3 h-3" />
</button>
<button type="button" disabled={disabled}
onClick={() => execBlock("h2")} title="Heading"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faHeading} className="w-3 h-3" />
</button>
<span className="text-gb-bg-t mx-1"></span>
{/* inline formatting */}
<button type="button" disabled={disabled}
onClick={() => execInline("**", "**")} title="Bold"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faBold} className="w-3 h-3" />
</button>
<button type="button" disabled={disabled}
onClick={() => execInline("*", "*")} title="Italic"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faItalic} className="w-3 h-3" />
</button>
<button type="button" disabled={disabled}
onClick={() => execInline("~~", "~~")} title="Strikethrough"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faStrikethrough} className="w-3 h-3" />
</button>
<button type="button" disabled={disabled}
onClick={() => execInline("`", "`")} title="Code"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faCode} className="w-3 h-3" />
</button>
<button type="button" disabled={disabled}
onClick={() => execInline("||", "||")} title="Spoiler"
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
<FontAwesomeIcon icon={faEyeSlash} className="w-3 h-3" />
</button>
<span className="text-gb-bg-t mx-1"></span>
{/* emoji / kaomoji / gif */}
<button type="button" disabled={disabled} onClick={toggleEmoji} title="Emoji"
className={`p-1 disabled:opacity-50 ${showEmoji ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
<FontAwesomeIcon icon={faSmile} className="w-4 h-4" />
</button>
<button type="button" disabled={disabled} onClick={toggleKaomoji} title="Kaomoji"
className={`p-1 disabled:opacity-50 ${showKaomoji ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
<FontAwesomeIcon icon={faGrin} className="w-4 h-4" />
</button>
<button type="button" disabled={disabled} onClick={toggleGif} title="GIF"
className={`p-1 disabled:opacity-50 ${showGif ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
<FontAwesomeIcon icon={faFilm} className="w-4 h-4" />
</button>
<span className="flex-1" />
{/* mode toggle */}
<button type="button" disabled={disabled} onClick={toggleMode}
title={mode === "md" ? "Switch to rich text" : "Switch to markdown"}
className={`p-1 disabled:opacity-50 ${mode === "rt" ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
<FontAwesomeIcon icon={mode === "md" ? faParagraph : faAlignLeft} className="w-3.5 h-3.5" />
</button>
{/* send */}
<button type="submit"
disabled={disabled || !value.trim()}
className="text-gb-aqua hover:text-gb-orange disabled:text-gb-fg-f p-1 disabled:opacity-40 transition-colors"
title="Send (Enter)">
<FontAwesomeIcon icon={faPaperPlane} className="w-4 h-4" />
</button>
</div>
</div>
</form>
</div>
);
});