708 lines
27 KiB
TypeScript
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(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
|
|
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})`;
|
|
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 = ``;
|
|
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>
|
|
);
|
|
});
|