"use client"; import { useRef, useState, useCallback, useEffect, useDeferredValue, type ReactNode, type KeyboardEvent as ReactKeyboardEvent, } from "react"; import { Bold, Italic, Strikethrough, Code, Code2, Quote, List, ListOrdered, Heading2, Heading3, Link as LinkIcon, Image as ImageIcon, Table, Minus, Eye, Pencil, Maximize2, Minimize2, Lock, ChevronDown, History, Ellipsis, Smile, LayoutList, Highlighter, Keyboard, Palette, MonitorPlay, Pipette, } from "lucide-react"; import { apiSearchPosts, apiUploadPostImage, type PostListItem } from "@/lib/api"; import { toast } from "@/lib/toast"; import MarkdownBodyClient from "@/components/MarkdownBodyClient"; import ImageInsertModal from "@/components/ImageInsertModal"; import TimelineEditor from "@/components/TimelineEditor"; import { ENTRIES_TEMPLATE, ENTRIES_TEMPLATE_AUTO } from "@/lib/entryBlocks"; import EmojiPicker from "@/components/EmojiPicker"; import { resolveTimelineBlockForEdit, parseVideoInput } from "@/lib/directiveBlocks"; import { isValidColorValue } from "@/lib/inlineShortcodes"; import { SHIKI_LANG_IDS } from "@/components/markdown/shikiHighlighter"; import { postPath } from "@/lib/urlStyle"; import { useUrlStyle } from "@/components/SiteBrandProvider"; type MarkdownEditorProps = { value: string; onChange: (value: string) => void; placeholder?: string; rows?: number; disabled?: boolean; onSubmitShortcut?: () => void; id?: string; /** 挂载后聚焦 textarea(回复/编辑评论等) */ autoFocus?: boolean; /** 正文插图体积上限(MB),缺省 5 */ imageMaxMB?: number; /** 是否提供隐藏可见([hide]) */ allowHide?: boolean; /** * 是否允许「回复可见」。 * 仅当 allowHide 为 true 时生效;不可评论的内容(如公告)应传 false,菜单项置灰。 */ allowReplyHide?: boolean; /** default=发帖完整工具栏;comment=评论紧凑栏,低频工具进「更多」 */ density?: "default" | "comment"; /** 输入框可访问名称(占位文案不能代替) */ ariaLabel?: string; /** 底栏左侧提示;缺省按模式生成 */ hint?: string; /** 评论模式快捷键文案,例如「发表」「回复」「保存」 */ shortcutName?: string; /** 底栏右侧操作(发表 / 保存),纳入编辑器边框内 */ footer?: ReactNode; /** 正文字符数上限;设置后计数器显示「已输入 / 上限」并限制输入 */ maxChars?: number; /** 提交失败等原因,显示在编辑器内 */ error?: string; onUploadingChange?: (uploading: boolean) => void; /** 书级「全章节条目卡模式」:插入免壳模板、预览按隐式条目渲染 */ entriesAuto?: boolean; /** 图片上传时标记的 source(如 library_content),默认 post */ uploadSource?: string; }; type EditorMode = "write" | "preview"; type WrapOpts = { before: string; after?: string; placeholder?: string; linePrefix?: string; }; type PopoverKind = "link" | "code" | "hide" | "heading" | "more" | "emoji" | "color" | "video" | null; /** 文字颜色弹窗预设色板(均为合法短代码颜色值) */ const COLOR_FG_PRESETS = [ "#e03131", "#e8590c", "#f08c00", "#2f9e44", "#0c8599", "#1971c2", "#6741d9", "#c2255c", "#1f2328", "#5f6b7a", ]; const COLOR_BG_PRESETS = [ "#fff3bf", "#ffe3e3", "#d3f9d8", "#d0ebff", "#e5dbff", "#ffd8a8", "#ffff8f", "#e9ecef", ]; type HSV = { h: number; s: number; v: number }; /** #rgb/#rrggbb → HSV;非 hex(如颜色名)返回 null */ function hexToHsv(hex: string): HSV | null { const m = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(hex.trim()); if (!m) return null; let x = m[1]; if (x.length === 3) x = x.split("").map((c) => c + c).join(""); const r = parseInt(x.slice(0, 2), 16) / 255; const g = parseInt(x.slice(2, 4), 16) / 255; const b = parseInt(x.slice(4, 6), 16) / 255; const max = Math.max(r, g, b); const min = Math.min(r, g, b); const d = max - min; let h = 0; if (d !== 0) { if (max === r) h = ((g - b) / d) % 6; else if (max === g) h = (b - r) / d + 2; else h = (r - g) / d + 4; h *= 60; if (h < 0) h += 360; } return { h, s: max === 0 ? 0 : d / max, v: max }; } /** HSV → #rrggbb */ function hsvToHex({ h, s, v }: HSV): string { const c = v * s; const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); const m = v - c; let r = 0, g = 0, b = 0; if (h < 60) [r, g, b] = [c, x, 0]; else if (h < 120) [r, g, b] = [x, c, 0]; else if (h < 180) [r, g, b] = [0, c, x]; else if (h < 240) [r, g, b] = [0, x, c]; else if (h < 300) [r, g, b] = [x, 0, c]; else [r, g, b] = [c, 0, x]; const to = (n: number) => Math.max(0, Math.min(255, Math.round((n + m) * 255))) .toString(16) .padStart(2, "0"); return `#${to(r)}${to(g)}${to(b)}`; } const CODE_LANGS = ["plaintext", "aardio", ...SHIKI_LANG_IDS.filter((l) => l !== "plaintext")] as const; function applyWrap( el: HTMLTextAreaElement, value: string, opts: WrapOpts ): { next: string; selStart: number; selEnd: number } { const start = el.selectionStart; const end = el.selectionEnd; const selected = value.slice(start, end); const after = opts.after ?? ""; if (opts.linePrefix) { const block = selected || opts.placeholder || "列表项"; const lines = block.split("\n").map((l) => l.startsWith(opts.linePrefix!) ? l : `${opts.linePrefix}${l}` ); const inserted = lines.join("\n"); const next = value.slice(0, start) + inserted + value.slice(end); return { next, selStart: start, selEnd: start + inserted.length }; } const inner = selected || opts.placeholder || ""; const inserted = opts.before + inner + after; const next = value.slice(0, start) + inserted + value.slice(end); const selStart = start + opts.before.length; const selEnd = selStart + inner.length; return { next, selStart, selEnd }; } function isHttpUrl(raw: string): boolean { try { const u = new URL(raw.trim()); return u.protocol === "http:" || u.protocol === "https:"; } catch { return false; } } /** 链接弹窗允许的目标:http(s) 完整链接,或站内相对路径(如 /post/123) */ function isLinkTargetUrl(raw: string): boolean { const u = raw.trim(); if (!u) return false; if (isHttpUrl(u)) return true; return u.startsWith("/") && !u.startsWith("//"); } export default function MarkdownEditor({ value, onChange, placeholder, rows = 16, disabled, onSubmitShortcut, id, autoFocus = false, imageMaxMB = 5, allowHide = false, allowReplyHide = true, density = "default", ariaLabel, hint, shortcutName, footer, maxChars, error, onUploadingChange, entriesAuto = false, uploadSource, }: MarkdownEditorProps) { const taRef = useRef(null); const urlStyle = useUrlStyle(); const fileRef = useRef(null); const toolbarRef = useRef(null); const [mode, setMode] = useState("write"); const deferredValue = useDeferredValue(value); const [uploading, setUploading] = useState(false); const [fullscreen, setFullscreen] = useState(false); const [dragOver, setDragOver] = useState(false); const [popover, setPopover] = useState(null); const [linkText, setLinkText] = useState(""); const [linkUrl, setLinkUrl] = useState("https://"); // 链接弹窗:站内帖子搜索(空关键词=最近发布),折叠在「高级选项」内 const [postQuery, setPostQuery] = useState(""); const [postResults, setPostResults] = useState([]); const [postLoading, setPostLoading] = useState(false); const [linkAdvanced, setLinkAdvanced] = useState(false); const [imageModalOpen, setImageModalOpen] = useState(false); const [codeQuery, setCodeQuery] = useState(""); const [hidePoints, setHidePoints] = useState(10); const [hidePassword, setHidePassword] = useState(""); const [hideStep, setHideStep] = useState<"menu" | "points" | "password">("menu"); /** 颜色弹窗:前景/背景独立选择,可同时设置;null=不设置该侧 */ const [colorFg, setColorFg] = useState("#e03131"); const [colorBg, setColorBg] = useState(null); /** 色轮/自定义输入框的当前值(始终 #hex,供 HSV 取色器使用) */ const [colorFgWheel, setColorFgWheel] = useState("#e03131"); const [colorBgWheel, setColorBgWheel] = useState("#fff3bf"); /** 内联 HSV 取色器展开侧;打开期间 colorHsv 为该侧当前色 */ const [activeWheel, setActiveWheel] = useState<"fg" | "bg" | null>(null); const [colorHsv, setColorHsv] = useState({ h: 0, s: 1, v: 1 }); const [videoInput, setVideoInput] = useState(""); const [toolNarrow, setToolNarrow] = useState(false); const [uploadError, setUploadError] = useState(""); const [modKey, setModKey] = useState("Ctrl"); const retryFile = useRef(null); const moreBtnRef = useRef(null); const [timelineOpen, setTimelineOpen] = useState(false); const [timelineBody, setTimelineBody] = useState(""); const [timelineRange, setTimelineRange] = useState<{ start: number; end: number } | null>( null ); const savedSel = useRef<{ start: number; end: number } | null>(null); const fullscreenMounted = useRef(false); const modeMounted = useRef(false); const isComment = density === "comment"; useEffect(() => { const ua = navigator.userAgent || ""; const platform = navigator.platform || ""; if (/Mac|iPhone|iPad|iPod/i.test(platform) || /Mac OS|iPhone|iPad/i.test(ua)) { setModKey("⌘"); } }, []); const onUploadingChangeRef = useRef(onUploadingChange); onUploadingChangeRef.current = onUploadingChange; useEffect(() => { onUploadingChangeRef.current?.(uploading); }, [uploading]); useEffect(() => { const el = toolbarRef.current; if (!el) return; const apply = () => setToolNarrow(el.clientWidth < (isComment ? 520 : 640)); apply(); const ro = new ResizeObserver(apply); ro.observe(el); return () => ro.disconnect(); }, [isComment]); useEffect(() => { if (!isComment) return; const el = taRef.current; if (!el) return; if (fullscreen || mode !== "write") { el.style.height = ""; return; } const prev = el.scrollTop; el.style.height = "auto"; const next = Math.min(Math.max(el.scrollHeight, 96), 320); el.style.height = `${next}px`; el.scrollTop = prev; }, [value, isComment, fullscreen, mode]); const rememberCaret = () => { const el = taRef.current; if (!el) return; savedSel.current = { start: el.selectionStart, end: el.selectionEnd }; }; const toggleFullscreen = () => { rememberCaret(); setFullscreen((v) => !v); }; useEffect(() => { if (!fullscreenMounted.current) { fullscreenMounted.current = true; return; } if (mode !== "write") return; const sel = savedSel.current; const el = taRef.current; if (!el || !sel) return; // 进出全屏后把焦点和光标放回退出前的位置 const frame = requestAnimationFrame(() => { el.focus(); const max = el.value.length; el.setSelectionRange(Math.min(sel.start, max), Math.min(sel.end, max)); }); return () => cancelAnimationFrame(frame); }, [fullscreen]); useEffect(() => { if (!modeMounted.current) { modeMounted.current = true; return; } if (mode !== "write") return; const sel = savedSel.current; const el = taRef.current; if (!el || !sel) return; const frame = requestAnimationFrame(() => { el.focus(); const max = el.value.length; el.setSelectionRange(Math.min(sel.start, max), Math.min(sel.end, max)); }); return () => cancelAnimationFrame(frame); }, [mode]); useEffect(() => { if (!fullscreen) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); // 插图弹窗自管 ESC,不连退全屏 if (imageModalOpen) return; if (popover) setPopover(null); else { rememberCaret(); setFullscreen(false); } } }; const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; window.addEventListener("keydown", onKey); return () => { document.body.style.overflow = prev; window.removeEventListener("keydown", onKey); }; }, [fullscreen, popover, imageModalOpen]); useEffect(() => { if (!popover) return; const onDoc = (e: MouseEvent) => { const t = e.target as Node; if (toolbarRef.current && !toolbarRef.current.contains(t)) { setPopover(null); } }; const onEsc = (e: KeyboardEvent) => { if (e.key !== "Escape") return; e.preventDefault(); const kind = popover; setPopover(null); if (kind === "more") moreBtnRef.current?.focus(); }; document.addEventListener("mousedown", onDoc); document.addEventListener("keydown", onEsc); return () => { document.removeEventListener("mousedown", onDoc); document.removeEventListener("keydown", onEsc); }; }, [popover]); // 链接弹窗打开且展开高级选项时,搜索站内帖子(输入防抖;空关键词给最近发布) useEffect(() => { if (popover !== "link" || !linkAdvanced) return; let cancelled = false; const kw = postQuery.trim(); setPostLoading(true); const t = setTimeout(() => { void apiSearchPosts(kw, 8) .then((res) => { if (cancelled) return; setPostResults(res.posts || []); }) .catch(() => { if (!cancelled) setPostResults([]); }) .finally(() => { if (!cancelled) setPostLoading(false); }); }, kw ? 250 : 0); return () => { cancelled = true; clearTimeout(t); }; }, [popover, postQuery, linkAdvanced]); const rememberSel = () => { const el = taRef.current; if (!el) return; savedSel.current = { start: el.selectionStart, end: el.selectionEnd }; }; const restoreSel = () => { const el = taRef.current; const sel = savedSel.current; if (!el || !sel) return; el.focus(); el.setSelectionRange(sel.start, sel.end); }; const runWrap = useCallback( (opts: WrapOpts) => { const el = taRef.current; if (!el || disabled) return; if (document.activeElement !== el && savedSel.current) { el.setSelectionRange(savedSel.current.start, savedSel.current.end); } const { next, selStart, selEnd } = applyWrap(el, value, opts); onChange(next); requestAnimationFrame(() => { el.focus(); el.setSelectionRange(selStart, selEnd); }); }, [value, onChange, disabled] ); const insertAtCursor = useCallback( (text: string, selectInner?: { offset: number; length: number }) => { const el = taRef.current; if (!el) { onChange(value + (value.endsWith("\n") || !value ? "" : "\n") + text); return; } const start = savedSel.current?.start ?? el.selectionStart; const end = savedSel.current?.end ?? el.selectionEnd; const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : ""; const padAfter = end < value.length && value[end] !== "\n" ? "\n" : ""; const inserted = padBefore + text + padAfter; const next = value.slice(0, start) + inserted + value.slice(end); onChange(next); requestAnimationFrame(() => { el.focus(); if (selectInner) { const a = start + padBefore.length + selectInner.offset; el.setSelectionRange(a, a + selectInner.length); } else { const caret = start + inserted.length; el.setSelectionRange(caret, caret); } }); savedSel.current = null; }, [value, onChange] ); /** 行内插入(无换行 padding):表情等短代码用 */ const insertInline = useCallback( (text: string) => { const el = taRef.current; if (!el) { onChange(value + text); return; } const start = savedSel.current?.start ?? el.selectionStart; const end = savedSel.current?.end ?? el.selectionEnd; const next = value.slice(0, start) + text + value.slice(end); onChange(next); requestAnimationFrame(() => { el.focus(); const caret = start + text.length; el.setSelectionRange(caret, caret); }); savedSel.current = null; }, [value, onChange] ); const pickEmoji = useCallback( (key: string) => { insertInline(`[:${key}:]`); setPopover(null); }, [insertInline] ); const uploadImageFile = async (file: File | null) => { if (!file || disabled) return; retryFile.current = file; setUploadError(""); if (!/^image\/(jpeg|png|webp|gif)$/i.test(file.type)) { setUploadError("仅支持 JPEG / PNG / WebP / GIF"); toast("仅支持 JPEG / PNG / WebP / GIF", "error"); return; } const maxBytes = Math.max(1, imageMaxMB) * 1024 * 1024; if (file.size > maxBytes) { const msg = `图片不能超过 ${imageMaxMB}MB`; setUploadError(msg); toast(msg, "error"); return; } setUploading(true); try { const res = await apiUploadPostImage(file, undefined, uploadSource); if (!res.url) { const msg = res.error || "上传失败"; setUploadError(msg); toast(msg, "error"); return; } const alt = file.name.replace(/\.[^.]+$/, "") || "图片"; insertAtCursor(`![${alt}](${res.url})`); setMode("write"); setUploadError(""); retryFile.current = null; setImageModalOpen(false); toast("图片已插入", "ok"); } catch { setUploadError("上传失败"); toast("上传失败", "error"); } finally { setUploading(false); if (fileRef.current) fileRef.current.value = ""; } }; const openPopover = (kind: PopoverKind) => { rememberSel(); if (kind === "link") { const el = taRef.current; const selected = el ? value.slice(el.selectionStart, el.selectionEnd) : ""; setLinkText(selected || "链接文字"); setLinkUrl("https://"); setPostQuery(""); setPostResults([]); setLinkAdvanced(false); } if (kind === "code") setCodeQuery(""); if (kind === "color") { setColorFg("#e03131"); setColorBg(null); setColorFgWheel("#e03131"); setColorBgWheel("#fff3bf"); setActiveWheel(null); } if (kind === "video") setVideoInput(""); if (kind === "hide") { setHideStep("menu"); setHidePoints(10); setHidePassword(""); } setPopover((prev) => (prev === kind ? null : kind)); }; /** 选中站内帖子:回填 URL 与文字,用户可再编辑后点「插入」 */ const pickPostLink = (p: PostListItem) => { setLinkUrl(postPath(urlStyle, p.id)); setLinkText(p.title); }; const applyLink = () => { const url = linkUrl.trim(); if (!isLinkTargetUrl(url)) { toast("请输入 http(s) 链接或站内路径(如 /post/123)", "error"); return; } const el = taRef.current; restoreSel(); const start = el?.selectionStart ?? 0; const end = el?.selectionEnd ?? 0; const selected = value.slice(start, end); const label = (selected || linkText.trim() || url).trim() || url; const md = `[${label}](${url})`; const next = value.slice(0, start) + md + value.slice(end); onChange(next); requestAnimationFrame(() => { if (!el) return; el.focus(); el.setSelectionRange(start + md.length, start + md.length); }); setPopover(null); }; /** 打开插图弹窗(上传 / 链接 / 媒体库) */ const openImageModal = () => { rememberSel(); setImageModalOpen(true); }; /** 链接 tab 提交:校验后在光标处插入,失败 toast 并留在弹窗 */ const insertImageUrl = (rawUrl: string, rawAlt: string) => { const url = rawUrl.trim(); if (!isHttpUrl(url)) { toast("请输入有效的图片 URL(http/https)", "error"); return; } const alt = rawAlt.trim() || "图片"; insertAtCursor(`![${alt}](${url})`); setImageModalOpen(false); }; const insertCodeBlock = (lang: string) => { const el = taRef.current; restoreSel(); const start = el?.selectionStart ?? 0; const end = el?.selectionEnd ?? 0; const selected = value.slice(start, end); const body = selected || "代码"; const fence = "```" + lang + "\n" + body + "\n```"; const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : ""; const padAfter = end < value.length && value[end] !== "\n" ? "\n" : ""; const inserted = padBefore + fence + padAfter; const next = value.slice(0, start) + inserted + value.slice(end); onChange(next); requestAnimationFrame(() => { if (!el) return; el.focus(); const bodyStart = start + padBefore.length + 3 + lang.length + 1; el.setSelectionRange(bodyStart, bodyStart + body.length); }); setPopover(null); }; const insertHide = ( kind: "login" | "reply" | "points" | "password", points?: number, password?: string ) => { if (!allowHide) return; restoreSel(); const el = taRef.current; const start = el?.selectionStart ?? 0; const end = el?.selectionEnd ?? 0; const selected = value.slice(start, end); const body = selected || "在此填写隐藏内容"; let open = `[hide ${kind}]`; if (kind === "points") { open = `[hide points=${points || 10}]`; } else if (kind === "password") { open = `[hide password=${password || "password"}]`; } const block = `${open}\n${body}\n[/hide]`; const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : ""; const padAfter = end < value.length && value[end] !== "\n" ? "\n" : ""; const inserted = padBefore + block + padAfter; const next = value.slice(0, start) + inserted + value.slice(end); onChange(next); requestAnimationFrame(() => { if (!el) return; el.focus(); const bodyStart = start + padBefore.length + open.length + 1; el.setSelectionRange(bodyStart, bodyStart + body.length); }); setPopover(null); }; /** * 颜色短代码插入:前景/背景可独立或组合。 * 两侧都有 → [color=前景 bg=背景]…[/color];仅一侧 → [color] / [bg]。 */ const applyColor = () => { const fg = colorFg?.trim(); const bg = colorBg?.trim(); if (!fg && !bg) { toast("请至少选择前景色或背景色", "error"); return; } if ((fg && !isValidColorValue(fg)) || (bg && !isValidColorValue(bg))) { toast("颜色仅支持 #RGB/#RRGGBB/#RRGGBBAA 或英文颜色名", "error"); return; } restoreSel(); const el = taRef.current; const start = el?.selectionStart ?? 0; const end = el?.selectionEnd ?? 0; const selected = value.slice(start, end); const body = selected || "彩色文字"; let md: string; if (fg && bg) md = `[color=${fg} bg=${bg}]${body}[/color]`; else if (fg) md = `[color=${fg}]${body}[/color]`; else md = `[bg=${bg}]${body}[/bg]`; const next = value.slice(0, start) + md + value.slice(end); onChange(next); requestAnimationFrame(() => { if (!el) return; el.focus(); const caret = start + md.length; el.setSelectionRange(caret, caret); }); setPopover(null); }; /** 内联 HSV 取色器开关:打开时以该侧当前 hex(或上次色轮值)初始化 */ const toggleColorWheel = (side: "fg" | "bg") => { if (activeWheel === side) { setActiveWheel(null); return; } const wheelHex = side === "fg" ? colorFgWheel : colorBgWheel; setColorHsv(hexToHsv(wheelHex) ?? { h: 0, s: 1, v: 1 }); setActiveWheel(side); }; /** HSV 拖动 → 同步该侧 wheel hex 与选中色 */ const commitHsv = (next: HSV) => { setColorHsv(next); const hex = hsvToHex(next); if (activeWheel === "fg") { setColorFgWheel(hex); setColorFg(hex); } else if (activeWheel === "bg") { setColorBgWheel(hex); setColorBg(hex); } }; /** * 饱和度/明度面板拖拽:坐标以真实渲染元素 rect 映射(pointer capture 保证移出仍跟手)。 * 注意:必须在同步阶段取出 currentTarget 存入 el——React 在事件派发结束后会把 * e.currentTarget 置空,延迟回调里再读会拿到 null 导致拖拽中断。 */ const onSvPointer = (e: React.PointerEvent) => { e.preventDefault(); const el = e.currentTarget; el.setPointerCapture(e.pointerId); const update = (clientX: number, clientY: number) => { const rect = el.getBoundingClientRect(); const s = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); const v = 1 - Math.max(0, Math.min(1, (clientY - rect.top) / rect.height)); setColorHsv((prev) => { const next = { ...prev, s, v }; const hex = hsvToHex(next); if (activeWheel === "fg") { setColorFgWheel(hex); setColorFg(hex); } else if (activeWheel === "bg") { setColorBgWheel(hex); setColorBg(hex); } return next; }); }; update(e.clientX, e.clientY); const onMove = (ev: PointerEvent) => update(ev.clientX, ev.clientY); const cleanup = () => { el.removeEventListener("pointermove", onMove); el.removeEventListener("pointerup", cleanup); el.removeEventListener("pointercancel", cleanup); }; el.addEventListener("pointermove", onMove); el.addEventListener("pointerup", cleanup); el.addEventListener("pointercancel", cleanup); }; /** 色相条拖拽(水平);同样在同步阶段缓存元素 */ const onHuePointer = (e: React.PointerEvent) => { e.preventDefault(); const el = e.currentTarget; el.setPointerCapture(e.pointerId); const update = (clientX: number) => { const rect = el.getBoundingClientRect(); const h = Math.max(0, Math.min(360, ((clientX - rect.left) / rect.width) * 360)); commitHsv({ ...colorHsv, h }); }; update(e.clientX); const onMove = (ev: PointerEvent) => update(ev.clientX); const cleanup = () => { el.removeEventListener("pointermove", onMove); el.removeEventListener("pointerup", cleanup); el.removeEventListener("pointercancel", cleanup); }; el.addEventListener("pointermove", onMove); el.addEventListener("pointerup", cleanup); el.addEventListener("pointercancel", cleanup); }; /** [bilibili]/[youtube] 整行插入:表单裸输入统一走 parseVideoInput */ const applyVideo = () => { const parsed = parseVideoInput(videoInput); if (!parsed) { toast("请输入 B站 BV 号/链接 或 YouTube 视频 ID/链接", "error"); return; } restoreSel(); const el = taRef.current; const start = el?.selectionStart ?? 0; const end = el?.selectionEnd ?? 0; const tag = `[${parsed.provider}]${parsed.id}[/${parsed.provider}]`; const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : ""; const padAfter = end < value.length && value[end] !== "\n" ? "\n" : ""; const inserted = padBefore + tag + padAfter; const next = value.slice(0, start) + inserted + value.slice(end); onChange(next); requestAnimationFrame(() => { if (!el) return; el.focus(); const caret = start + inserted.length; el.setSelectionRange(caret, caret); }); setPopover(null); }; /** [details 标题]…[/details] 折叠面板:包住选区或占位正文 */ const insertDetails = () => { restoreSel(); const el = taRef.current; const start = el?.selectionStart ?? 0; const end = el?.selectionEnd ?? 0; const selected = value.slice(start, end); const body = selected || "折叠内容"; const open = "[details 点击展开]"; const block = `${open}\n${body}\n[/details]`; const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : ""; const padAfter = end < value.length && value[end] !== "\n" ? "\n" : ""; const inserted = padBefore + block + padAfter; const next = value.slice(0, start) + inserted + value.slice(end); onChange(next); requestAnimationFrame(() => { if (!el) return; el.focus(); const bodyStart = start + padBefore.length + open.length + 1; el.setSelectionRange(bodyStart, bodyStart + body.length); }); setPopover(null); }; const openTimelineEditor = () => { rememberSel(); const el = taRef.current; const cursor = el?.selectionStart ?? savedSel.current?.start ?? 0; const found = resolveTimelineBlockForEdit(value, cursor); if (found) { setTimelineBody(found.body); setTimelineRange({ start: found.start, end: found.end }); } else { setTimelineBody(""); setTimelineRange(null); } setPopover(null); setTimelineOpen(true); }; const applyTimelineBlock = (block: string) => { const el = taRef.current; if (timelineRange) { const next = value.slice(0, timelineRange.start) + block + value.slice(timelineRange.end); onChange(next); requestAnimationFrame(() => { if (!el) return; el.focus(); const pos = timelineRange.start + block.length; el.setSelectionRange(pos, pos); }); return; } restoreSel(); const start = el?.selectionStart ?? 0; const end = el?.selectionEnd ?? start; const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : ""; const padAfter = end < value.length && value[end] !== "\n" ? "\n" : ""; const inserted = padBefore + block + padAfter; const next = value.slice(0, start) + inserted + value.slice(end); onChange(next); requestAnimationFrame(() => { if (!el) return; el.focus(); const pos = start + inserted.length; el.setSelectionRange(pos, pos); }); }; const insertOrderedList = () => { const el = taRef.current; if (!el || disabled) return; const start = el.selectionStart; const end = el.selectionEnd; const selected = value.slice(start, end) || "列表项"; const lines = selected.split("\n").map((l, i) => { if (/^\d+\.\s/.test(l)) return l; return `${i + 1}. ${l}`; }); const inserted = lines.join("\n"); const next = value.slice(0, start) + inserted + value.slice(end); onChange(next); requestAnimationFrame(() => { el.focus(); el.setSelectionRange(start, start + inserted.length); }); }; const insertTable = () => { insertAtCursor( "| 列1 | 列2 | 列3 |\n| --- | --- | --- |\n| | | |\n| | | |" ); }; const onTextareaKeyDown = (e: ReactKeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { // 输入法组字期间的 Enter 不是提交 if (e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return; e.preventDefault(); onSubmitShortcut?.(); return; } if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === "b") { e.preventDefault(); runWrap({ before: "**", after: "**", placeholder: "粗体" }); return; } if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === "i") { e.preventDefault(); runWrap({ before: "*", after: "*", placeholder: "斜体" }); return; } if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === "k") { e.preventDefault(); openPopover("link"); return; } if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "c") { e.preventDefault(); openPopover("code"); return; } }; const onPaste = async (e: React.ClipboardEvent) => { const items = e.clipboardData?.items; if (items) { for (const item of items) { if (item.type.startsWith("image/")) { e.preventDefault(); const file = item.getAsFile(); await uploadImageFile(file); return; } } } const text = e.clipboardData?.getData("text/plain")?.trim() || ""; if (text && isHttpUrl(text)) { const el = taRef.current; if (el && el.selectionStart !== el.selectionEnd) { e.preventDefault(); const start = el.selectionStart; const end = el.selectionEnd; const selected = value.slice(start, end); const md = `[${selected}](${text})`; const next = value.slice(0, start) + md + value.slice(end); onChange(next); requestAnimationFrame(() => { el.focus(); el.setSelectionRange(start + md.length, start + md.length); }); } } }; const filteredLangs = CODE_LANGS.filter((l) => l.toLowerCase().includes(codeQuery.trim().toLowerCase()) ); // 代码块语言弹层:常规时挂在行内按钮下,窄屏收进「更多」后挂在其锚点下(居中防溢出) const codePopover = popover === "code" ? (
setCodeQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && filteredLangs[0]) { e.preventDefault(); insertCodeBlock(filteredLangs[0]); } }} />
{filteredLangs.map((lang) => ( ))} {filteredLangs.length === 0 && (

无匹配语言

)}
) : null; // 隐藏内容弹层:同上,窄屏时改挂在「更多」锚点下 const hidePopover = allowHide && popover === "hide" ? (
{hideStep === "menu" ? ( <> ) : hideStep === "points" ? ( <>
) : ( <>
)}
) : null; // 文字颜色弹层:前景/背景两行独立选择,可同时设置;每行含预设、内联 HSV 取色器与自定义输入。 // 用普通函数调用而非 组件:避免内联组件每次渲染重挂载导致输入框失焦。 const renderColorRow = ( side: "fg" | "bg", label: string, presets: readonly string[], picked: string | null ) => { const setPicked = (v: string | null) => side === "fg" ? setColorFg(v) : setColorBg(v); const wheelOpen = activeWheel === side; return (
{label}
{presets.map((c) => ( { const v = e.target.value; setPicked(v); if (/^#[0-9a-fA-F]{6}$/.test(v)) { if (side === "fg") setColorFgWheel(v); else setColorBgWheel(v); if (wheelOpen) setColorHsv(hexToHsv(v) ?? colorHsv); } }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyColor(); } }} />
{wheelOpen && (
)}
); }; const colorPopover = popover === "color" ? (
Aa 示例文字
{renderColorRow("fg", "前景", COLOR_FG_PRESETS, colorFg)} {renderColorRow("bg", "背景", COLOR_BG_PRESETS, colorBg)}
) : null; // 视频嵌入弹层:裸输入统一交给 parseVideoInput 归一化 const videoPopover = popover === "video" ? (
) : null; const ToolBtn = ({ label, icon, onClick, active, disabled: dis, }: { label: string; icon: ReactNode; onClick: () => void; active?: boolean; disabled?: boolean; }) => ( ); const Sep = () => ; return (
} onClick={() => runWrap({ before: "**", after: "**", placeholder: "粗体" })} /> {!isComment && !toolNarrow && ( } onClick={() => runWrap({ before: "*", after: "*", placeholder: "斜体" })} /> )} {!isComment && !toolNarrow && ( } onClick={() => runWrap({ before: "~~", after: "~~", placeholder: "删除线" })} /> )} {!isComment && !toolNarrow && ( } onClick={() => runWrap({ before: "==", after: "==", placeholder: "高亮" })} /> )} {!isComment && !toolNarrow && ( } onClick={() => runWrap({ before: "[kbd]", after: "[/kbd]", placeholder: "Ctrl" })} /> )} {!toolNarrow && (
} active={popover === "color"} onClick={() => openPopover("color")} /> {popover === "color" && colorPopover}
)} {!isComment && !toolNarrow && } {!isComment && (
} active={popover === "heading"} onClick={() => openPopover("heading")} /> {popover === "heading" && (
)}
)} {!toolNarrow && } {!toolNarrow && ( } onClick={() => runWrap({ before: "", linePrefix: "> ", placeholder: "引用内容" }) } /> )} {!toolNarrow && ( } onClick={() => runWrap({ before: "", linePrefix: "- ", placeholder: "列表项" }) } /> )} {!isComment && !toolNarrow && ( } onClick={insertOrderedList} /> )} {!isComment && !toolNarrow && } onClick={insertTable} />} {!isComment && !toolNarrow && ( } onClick={() => insertAtCursor("---")} /> )} } onClick={() => runWrap({ before: "`", after: "`", placeholder: "code" })} /> {!toolNarrow && (
} active={popover === "code"} onClick={() => openPopover("code")} /> {codePopover}
)}
} active={popover === "link"} onClick={() => openPopover("link")} /> {popover === "link" && (
{linkAdvanced && (
setPostQuery(e.target.value)} />
{postLoading ? (

搜索中…

) : postResults.length === 0 ? (

{postQuery.trim() ? "无匹配帖子" : "暂无最近帖子"}

) : ( postResults.map((p) => ( )) )}
)}
)}
{(isComment || !toolNarrow) && (
} active={popover === "emoji"} onClick={() => openPopover("emoji")} /> {popover === "emoji" && (
)}
)}
} active={imageModalOpen} disabled={uploading} onClick={openImageModal} />
{(isComment || toolNarrow) && (
{popover === "more" && (
{toolNarrow && ( <> )} {!isComment && ( )} {!isComment && allowHide && ( )} {!isComment && ( )} {!isComment && ( )} {toolNarrow && ( )}
)} {toolNarrow && codePopover} {toolNarrow && popover === "color" && colorPopover} {toolNarrow && popover === "video" && videoPopover} {toolNarrow && popover === "emoji" && !isComment && (
)} {toolNarrow && hidePopover}
)} {!isComment && !toolNarrow && } {!isComment && !toolNarrow && ( } onClick={openTimelineEditor} /> )} {!isComment && !toolNarrow && ( } onClick={() => insertAtCursor(entriesAuto ? ENTRIES_TEMPLATE_AUTO : ENTRIES_TEMPLATE)} /> )} {!isComment && !toolNarrow && (
} active={popover === "video"} onClick={() => openPopover("video")} /> {popover === "video" && videoPopover}
)} {allowHide && !toolNarrow ? (
} active={popover === "hide"} onClick={() => openPopover("hide")} /> {hidePopover}
) : null}
{!toolNarrow && ( )}
void uploadImageFile(e.target.files?.[0] ?? null)} />