feat: 品牌裁剪弹窗/Markdown 行内短代码与详情视频块等

新增:
- BrandCropModal 品牌图裁剪弹窗 + cropImage 工具
- Markdown 行内短代码(inlineShortcodes + remark 插件)
- MdDetails 折叠块、MdVideo 视频块组件
- directiveBlocks/inlineShortcodes 测试

其它:
- 聊天后端(handler/service chat)调整
- Markdown 渲染链路、编辑器、全局样式与品牌相关面板更新
This commit is contained in:
2026-10-02 01:40:06 +08:00
parent 3e98052c16
commit 99c0f0319d
23 changed files with 2012 additions and 119 deletions

View File

@@ -0,0 +1,368 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import Cropper, { type Area } from "react-easy-crop";
import "react-easy-crop/react-easy-crop.css";
import {
UploadCloud, ZoomIn, Check, Loader2, Images, RotateCcw, Crop,
} from "lucide-react";
import {
apiMyMedia, apiUploadBrand,
type MediaAttachment,
} from "@/lib/api";
import { getCroppedBrandWebp } from "@/lib/cropImage";
import Modal from "@/components/Modal";
import { brandFileOk, type BrandSlot, type BrandLogoSize } from "@/lib/brand";
// 源文件大小保护(防止手机相册原图撑爆浏览器内存;最终限制以裁剪后 2MB 为准)
const SOURCE_MAX_BYTES = 15 * 1024 * 1024;
// 品牌图槽位到默认裁剪比例的映射
const SLOT_ASPECT: Record<BrandSlot, number> = {
logo_light: 1, // 方标 1:1
logo_dark: 1, // 方标 1:1
favicon: 1, // Favicon 1:1
};
// 品牌图比例选项(与 BRAND_LOGO_SIZES 对应)
const ASPECT_OPTIONS: { id: BrandLogoSize; label: string; aspect: number }[] = [
{ id: "sq", label: "1:1", aspect: 1 },
{ id: "2x1", label: "2:1", aspect: 2 },
{ id: "3x1", label: "3:1", aspect: 3 },
{ id: "4x1", label: "4:1", aspect: 4 },
{ id: "16x9", label: "16:9", aspect: 16 / 9 },
];
interface BrandCropModalProps {
open: boolean;
slot: BrandSlot;
onClose: () => void;
/** 裁剪完成后返回新 URL */
onCropped: (url: string) => void;
/** 外部传入的文件:打开后直接进入裁剪 */
seedFile?: File | null;
/** 外部传入的 URL(如媒体库图片):打开后直接进入裁剪 */
seedUrl?: string;
/** 已消费 seedFile / seedUrl */
onSeedConsumed?: () => void;
}
export default function BrandCropModal({
open, slot, onClose, onCropped, seedFile, seedUrl, onSeedConsumed,
}: BrandCropModalProps) {
const [media, setMedia] = useState<MediaAttachment[]>([]);
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [srcIsBlob, setSrcIsBlob] = useState(false);
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [pixelCrop, setPixelCrop] = useState<Area | null>(null);
const [aspect, setAspect] = useState<number>(SLOT_ASPECT[slot] || 1);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// 打开时拉取媒体库
useEffect(() => {
if (!open) return;
setError("");
apiMyMedia()
.then((res) => setMedia(res.attachments || []))
.catch(() => setMedia([]));
}, [open]);
// 关闭时复位并释放本地 blob URL
useEffect(() => {
if (!open) {
setImageSrc((prev) => {
if (prev && prev.startsWith("blob:")) URL.revokeObjectURL(prev);
return null;
});
setSrcIsBlob(false);
setCrop({ x: 0, y: 0 });
setZoom(1);
setPixelCrop(null);
setAspect(SLOT_ASPECT[slot] || 1);
setError("");
setBusy(false);
setDragOver(false);
}
}, [open, slot]);
const onCropComplete = useCallback((_: Area, areaPixels: Area) => {
setPixelCrop(areaPixels);
}, []);
const enterCrop = useCallback((src: string, isBlob: boolean) => {
setError("");
setImageSrc((prev) => {
if (prev && prev.startsWith("blob:")) URL.revokeObjectURL(prev);
return src;
});
setSrcIsBlob(isBlob);
setCrop({ x: 0, y: 0 });
setZoom(1);
}, []);
/** 校验并进入裁剪;返回错误文案,成功返回空串 */
const acceptImageFile = useCallback((file: File): string => {
const err = brandFileOk(slot, file);
if (err) return err;
if (file.size > SOURCE_MAX_BYTES) {
return "原图不能超过 15MB,裁剪后的品牌图需在 2MB 以内";
}
enterCrop(URL.createObjectURL(file), true);
return "";
}, [enterCrop, slot]);
// 外部传入:弹窗打开后立刻进入裁剪
useEffect(() => {
if (!open) return;
if (seedFile) {
const errMsg = acceptImageFile(seedFile);
if (errMsg) setError(errMsg);
onSeedConsumed?.();
return;
}
if (seedUrl) {
enterCrop(seedUrl, false);
onSeedConsumed?.();
}
}, [open, seedFile, seedUrl, acceptImageFile, enterCrop, onSeedConsumed]);
/**
* 点击媒体库图片:直接进入裁剪界面
* 品牌图不做"直接选用",统一走裁剪后上传,保证比例一致
*/
const handlePick = (item: MediaAttachment) => {
if (busy) return;
enterCrop(item.url, false);
};
const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
const errMsg = acceptImageFile(file);
if (errMsg) setError(errMsg);
};
const onDropZoneDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!busy) setDragOver(true);
};
const onDropZoneDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
};
const onDropZoneDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
if (busy) return;
const file = e.dataTransfer.files?.[0];
if (!file) return;
const errMsg = acceptImageFile(file);
if (errMsg) setError(errMsg);
};
const backToLibrary = () => {
if (imageSrc && srcIsBlob) URL.revokeObjectURL(imageSrc);
setImageSrc(null);
setSrcIsBlob(false);
setError("");
};
const confirmUpload = async () => {
if (!imageSrc || !pixelCrop || busy) return;
setBusy(true);
setError("");
try {
// 浏览器端裁剪并以 WebP 导出(质量自适应,直到 ≤ 2MB)
const blob = await getCroppedBrandWebp(imageSrc, pixelCrop, aspect);
const file = new File([blob], "brand.webp", { type: "image/webp" });
const url = await apiUploadBrand(slot, file);
onCropped(url);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "上传失败,请重试");
} finally {
setBusy(false);
}
};
const slotLabel = slot === "logo_light" ? "浅色 Logo" : slot === "logo_dark" ? "深色 Logo" : "Favicon";
// 裁剪区底色与槽位主题相反:浅色主题 Logo(通常为深色图)用浅底,深色主题 Logo 用深底
const cropBg = slot === "logo_dark" ? "#0c0d12" : "#f2f3f5";
return (
<Modal
open={open}
title={imageSrc ? `裁剪${slotLabel}` : `上传${slotLabel}`}
onClose={onClose}
busy={busy}
dismissOnOverlay={!busy}
maxWidthClass="max-w-[440px]"
panelClassName="p-5"
ariaLabel={`上传${slotLabel}`}
>
{error && <div className="alert-error mb-4">{error}</div>}
{!imageSrc ? (
<>
{/* 选择新图片:点击或拖放 */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
onDragOver={onDropZoneDragOver}
onDragLeave={onDropZoneDragLeave}
onDrop={onDropZoneDrop}
className="w-full flex flex-col items-center justify-center gap-2 rounded-xl py-7 px-4 text-center transition-colors hover:border-[var(--accent)]"
style={{
border: `1.5px dashed ${dragOver ? "var(--accent)" : "var(--line-2)"}`,
background: dragOver
? "color-mix(in srgb, var(--accent) 10%, var(--panel-2))"
: "var(--panel-2)",
color: "var(--ink-2)",
}}
>
<span
className="inline-flex w-11 h-11 items-center justify-center rounded-full"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<UploadCloud size={20} />
</span>
<span className="text-[13.5px] font-bold" style={{ color: "var(--ink)" }}>
{dragOver ? "松开即可开始裁剪" : "点击选择或拖放图片"}
</span>
<span className="text-[12px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
支持 PNG / WebP / SVG{slot === "favicon" ? " / ICO" : ""},裁剪后统一转为 WebP,大小不超过 2MB
</span>
</button>
<input
ref={fileInputRef}
type="file"
accept={slot === "favicon" ? "image/png,image/webp,image/svg+xml,.ico,image/x-icon" : "image/png,image/webp,image/svg+xml"}
className="hidden"
onChange={onFileChange}
/>
{/* 媒体库 */}
{media.length > 0 && (
<div className="mt-5">
<p
className="flex items-center gap-1.5 text-[12px] font-bold tracking-[0.04em] mb-2.5"
style={{ color: "var(--ink-3)" }}
>
<Images size={13} /> 我的图片(点击后进入裁剪)
</p>
<div className="grid grid-cols-6 gap-2">
{media.map((item) => (
<button
key={item.id}
type="button"
onClick={() => handlePick(item)}
disabled={busy}
className="relative group aspect-square rounded-lg overflow-hidden transition-transform hover:scale-105 disabled:cursor-default"
style={{ boxShadow: "0 0 0 1px var(--line)" }}
data-tip="裁剪后设为品牌图"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.url}
alt="媒体库图片"
className="w-full h-full object-cover"
loading="lazy"
/>
<span
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
style={{ background: "color-mix(in srgb, var(--ink) 45%, transparent)", color: "#fff" }}
>
<Crop size={13} />
</span>
</button>
))}
</div>
</div>
)}
</>
) : (
<>
{/* 比例选择 */}
<div className="flex items-center gap-2 mb-3 flex-wrap">
<span className="text-[12px]" style={{ color: "var(--ink-3)" }}>比例</span>
{ASPECT_OPTIONS.map((opt) => (
<button
key={opt.id}
type="button"
onClick={() => setAspect(opt.aspect)}
className={`px-2.5 py-1 rounded-md text-[12px] font-medium transition-colors ${
aspect === opt.aspect
? "bg-[var(--accent)] text-white"
: "bg-[var(--panel-2)] text-[var(--ink-2)] hover:bg-[var(--panel-3)]"
}`}
>
{opt.label}
</button>
))}
</div>
{/* 裁剪区 */}
<div
className="relative w-full h-72 rounded-xl overflow-hidden"
style={{ background: cropBg }}
>
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={aspect}
cropShape="rect"
showGrid={true}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>
</div>
{/* 缩放 */}
<div className="flex items-center gap-3 mt-4">
<span style={{ color: "var(--ink-3)" }}><ZoomIn size={15} /></span>
<input
type="range"
min={1}
max={3}
step={0.01}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
className="flex-1 h-1.5 rounded-full appearance-none cursor-pointer"
style={{ accentColor: "var(--accent)", background: "var(--panel-3)" }}
aria-label="缩放"
/>
<span style={{ color: "var(--ink-3)" }}><ZoomIn size={19} /></span>
</div>
{/* 输出说明 + 操作 */}
<div className="flex items-center justify-between mt-5">
<span className="text-[12px]" style={{ color: "var(--ink-3)" }}>
输出 WebP,高度 ≤ 2048px
</span>
<div className="flex items-center gap-2">
<button type="button" onClick={backToLibrary} disabled={busy} className="btn btn-line btn-sm">
<RotateCcw size={13.5} /> 重选
</button>
<button type="button" onClick={confirmUpload} disabled={busy} className="btn btn-primary btn-sm">
{busy ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
{busy ? "上传中" : "确认上传"}
</button>
</div>
</div>
</>
)}
</Modal>
);
}

View File

@@ -36,17 +36,17 @@ function TextCover({ title }: { title: string }) {
style={{ "--cover-h": coverHue(name) } as CSSProperties}
aria-hidden
>
<span className="j13-library-title text-[19px] font-bold leading-snug text-center line-clamp-4 break-all tracking-wide">
<span className="j13-library-title text-[17px] font-bold leading-snug text-center line-clamp-3 break-words tracking-wide">
{name}
</span>
</div>
);
}
function ExtChip({ ext }: { ext: string }) {
function ExtChip({ ext, className = "" }: { ext: string; className?: string }) {
return (
<span
className="text-[10.5px] font-semibold px-1.5 py-0.5 rounded-md uppercase tracking-wide"
className={`text-[10.5px] font-semibold px-1.5 py-0.5 rounded-md uppercase tracking-wide ${className}`}
style={{
color: "var(--accent)",
background: "var(--accent-soft)",
@@ -65,33 +65,45 @@ function DocCard({ doc, href }: { doc: LibraryDocListItem; href: string }) {
const excerpt = plainExcerpt(doc.description ?? "");
return (
<Link href={href} className="group flex flex-col min-w-0">
{/* 竖版书形封面:书脊光影 + 悬停浮出简介 */}
{/* 横版封面框(宽大于高):模糊底衬完整显示任意比例图 + 悬停浮出简介 */}
<div
className="relative aspect-[3/4] overflow-hidden rounded-[10px] shadow-[var(--shadow-sm)] transition-[transform,box-shadow] duration-200 group-hover:-translate-y-1.5 group-hover:shadow-[var(--shadow-lg)]"
className="relative w-full aspect-[3/2] overflow-hidden rounded-[10px] shadow-[var(--shadow-sm)] transition-[transform,box-shadow] duration-200 group-hover:-translate-y-1.5 group-hover:shadow-[var(--shadow-lg)]"
style={{ background: "var(--panel-2)" }}
>
{doc.cover_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={variantURL(doc.cover_url, 384)}
srcSet={widthSrcSet(doc.cover_url, [384, 512, 768])}
sizes="(max-width:640px) 46vw, (max-width:1024px) 30vw, (max-width:1280px) 22vw, 220px"
alt={doc.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.04]"
loading="lazy"
decoding="async"
draggable={false}
/>
<>
{/* 底层:同图放大模糊填充,竖版/非 3:2 封面不露黑边 */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={variantURL(doc.cover_url, 384)}
alt=""
aria-hidden
className="absolute inset-0 w-full h-full object-cover scale-125 blur-lg opacity-70"
loading="lazy"
decoding="async"
draggable={false}
/>
{/* 上层:完整封面居中显示 */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={variantURL(doc.cover_url, 384)}
srcSet={widthSrcSet(doc.cover_url, [384, 512, 768])}
sizes="(max-width:640px) 100vw, (max-width:1024px) 46vw, (max-width:1280px) 30vw, 300px"
alt={doc.title}
className="absolute inset-0 w-full h-full object-contain transition-transform duration-300 group-hover:scale-[1.04]"
loading="lazy"
decoding="async"
draggable={false}
/>
</>
) : (
<TextCover title={doc.title} />
)}
{/* 书脊/书口光影,营造实体书体积感 */}
{/* 边缘内阴影,营造卡片体积感 */}
<div
aria-hidden
className="absolute inset-0 rounded-[10px] pointer-events-none"
className="absolute inset-0 rounded-[inherit] pointer-events-none"
style={{
background:
"linear-gradient(90deg, rgba(0,0,0,0.16) 0%, rgba(0,0,0,0.05) 2.5%, transparent 5%, transparent 96%, rgba(0,0,0,0.05) 98.5%, rgba(0,0,0,0.1) 100%)",
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.06)",
}}
/>
@@ -103,7 +115,7 @@ function DocCard({ doc, href }: { doc: LibraryDocListItem; href: string }) {
"linear-gradient(to top, rgba(18,20,26,0.92) 0%, rgba(18,20,26,0.55) 55%, rgba(18,20,26,0.15) 100%)",
}}
>
<p className="text-[12px] leading-relaxed text-white/90 line-clamp-5">{excerpt}</p>
<p className="text-[12px] leading-relaxed text-white/90 line-clamp-4">{excerpt}</p>
</div>
) : null}
</div>
@@ -153,7 +165,7 @@ function DocCard({ doc, href }: { doc: LibraryDocListItem; href: string }) {
}
/**
* 书库目录:书架式竖版封面网格 + 书名/作者即时过滤(目录接口一次返回全部已发布
* 书库目录:横版封面卡片网格(手机端单列全宽)+ 书名/作者即时过滤(目录接口一次返回全部已发布
* 条目,纯客户端过滤;上限与后端 ListPublished 的 100 条一致)。
*/
export default function LibrarySearchGrid({
@@ -230,7 +242,7 @@ export default function LibrarySearchGrid({
<p className="meta text-[12.5px] mt-2">换个书名或作者关键词试试</p>
</div>
) : (
<div className="mt-8 grid gap-x-5 gap-y-9 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6">
<div className="mt-8 grid grid-cols-1 gap-x-5 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{filtered.map((d) => (
<DocCard key={d.id} doc={d} href={libraryDocPath(urlStyle, d.slug)} />
))}

View File

@@ -7,6 +7,8 @@ import type { HideKind } from "@/lib/hideBlocks";
import MdHideBlock from "@/components/markdown/MdHideBlock";
import MdTimeline from "@/components/markdown/MdTimeline";
import MdEntries from "@/components/markdown/MdEntries";
import MdDetails from "@/components/markdown/MdDetails";
import MdVideo from "@/components/markdown/MdVideo";
type MarkdownBodyProps = {
content: string;
@@ -75,6 +77,21 @@ export default async function MarkdownBody({
);
continue;
}
if (seg.type === "details") {
const inner = seg.body.trim()
? await renderMarkdownToReact(expandDyEmojiShortcodes(seg.body))
: null;
nodes.push(
<MdDetails key={`details-${i}`} title={seg.title}>
{inner}
</MdDetails>
);
continue;
}
if (seg.type === "video") {
nodes.push(<MdVideo key={`video-${i}`} provider={seg.provider} id={seg.id} />);
continue;
}
const kind = seg.kind as HideKind;
if (seg.locked) {
nodes.push(

View File

@@ -3,11 +3,14 @@
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { mergeMarkdownComponents } from "@/components/markdown/mergeComponents";
import { remarkInlineShortcodes } from "@/components/markdown/remarkInlineShortcodes";
import { splitContentSegments } from "@/lib/directiveBlocks";
import { expandDyEmojiShortcodes } from "@/lib/dyEmoji";
import type { HideKind } from "@/lib/hideBlocks";
import MdHideBlock from "@/components/markdown/MdHideBlock";
import MdTimeline from "@/components/markdown/MdTimeline";
import MdDetails from "@/components/markdown/MdDetails";
import MdVideo from "@/components/markdown/MdVideo";
import { MdEntriesView } from "@/components/markdown/MdEntries";
type MarkdownBodyClientProps = {
@@ -49,7 +52,11 @@ export default function MarkdownBodyClient({
if (seg.type === "md") {
if (!seg.text.trim()) return null;
return (
<ReactMarkdown key={`md-${i}`} remarkPlugins={[remarkGfm]} components={mdComponents}>
<ReactMarkdown
key={`md-${i}`}
remarkPlugins={[remarkGfm, remarkInlineShortcodes]}
components={mdComponents}
>
{seg.text}
</ReactMarkdown>
);
@@ -61,7 +68,10 @@ export default function MarkdownBodyClient({
items={seg.items}
renderBody={(it) =>
it.body.trim() ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={nestedMd}>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkInlineShortcodes]}
components={nestedMd}
>
{expandDyEmojiShortcodes(it.body)}
</ReactMarkdown>
) : null
@@ -76,13 +86,33 @@ export default function MarkdownBodyClient({
items={seg.items}
titleIds={entriesTitleIds?.[i]}
renderMd={(t) => (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={nestedMd}>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkInlineShortcodes]}
components={nestedMd}
>
{expandDyEmojiShortcodes(t)}
</ReactMarkdown>
)}
/>
);
}
if (seg.type === "details") {
return (
<MdDetails key={`details-${i}`} title={seg.title}>
{seg.body.trim() ? (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkInlineShortcodes]}
components={nestedMd}
>
{expandDyEmojiShortcodes(seg.body)}
</ReactMarkdown>
) : null}
</MdDetails>
);
}
if (seg.type === "video") {
return <MdVideo key={`video-${i}`} provider={seg.provider} id={seg.id} />;
}
const kind = seg.kind as HideKind;
if (seg.locked) {
return (
@@ -100,7 +130,10 @@ export default function MarkdownBodyClient({
return (
<MdHideBlock key={`hide-${i}`} kind={kind} points={seg.points}>
{seg.body.trim() ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={nestedMd}>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkInlineShortcodes]}
components={nestedMd}
>
{expandDyEmojiShortcodes(seg.body)}
</ReactMarkdown>
) : null}

View File

@@ -34,6 +34,11 @@ import {
Ellipsis,
Smile,
LayoutList,
Highlighter,
Keyboard,
Palette,
MonitorPlay,
Pipette,
} from "lucide-react";
import { apiSearchPosts, apiUploadPostImage, type PostListItem } from "@/lib/api";
import { toast } from "@/lib/toast";
@@ -42,7 +47,8 @@ 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 } from "@/lib/directiveBlocks";
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";
@@ -96,7 +102,61 @@ type WrapOpts = {
linePrefix?: string;
};
type PopoverKind = "link" | "code" | "hide" | "heading" | "more" | "emoji" | null;
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;
@@ -190,6 +250,16 @@ export default function MarkdownEditor({
const [hidePoints, setHidePoints] = useState(10);
const [hidePassword, setHidePassword] = useState("");
const [hideStep, setHideStep] = useState<"menu" | "points" | "password">("menu");
/** 颜色弹窗:前景/背景独立选择,可同时设置;null=不设置该侧 */
const [colorFg, setColorFg] = useState<string | null>("#e03131");
const [colorBg, setColorBg] = useState<string | null>(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<HSV>({ h: 0, s: 1, v: 1 });
const [videoInput, setVideoInput] = useState("");
const [toolNarrow, setToolNarrow] = useState(false);
const [uploadError, setUploadError] = useState("");
const [modKey, setModKey] = useState("Ctrl");
@@ -504,6 +574,14 @@ export default function MarkdownEditor({
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);
@@ -614,6 +692,176 @@ export default function MarkdownEditor({
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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;
@@ -923,6 +1171,164 @@ export default function MarkdownEditor({
</div>
) : null;
// 文字颜色弹层:前景/背景两行独立选择,可同时设置;每行含预设、内联 HSV 取色器与自定义输入。
// 用普通函数调用而非 <ColorPickRow/> 组件:避免内联组件每次渲染重挂载导致输入框失焦。
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 (
<div className="j13-md-color-row">
<span className="j13-md-color-row-label">{label}</span>
<div className="j13-md-color-row-items">
<button
type="button"
className={`j13-md-color-none${picked === null ? " is-active" : ""}`}
onClick={() => setPicked(null)}
>
无
</button>
{presets.map((c) => (
<button
key={c}
type="button"
className={`j13-md-color-dot${picked?.toLowerCase() === c.toLowerCase() ? " is-active" : ""}`}
style={{ background: c }}
data-tip={c}
aria-label={`使用 ${c}`}
onClick={() => setPicked(c)}
/>
))}
<button
type="button"
className={`j13-md-color-wheel${wheelOpen ? " is-active" : ""}`}
data-tip="自定义颜色"
aria-label="自定义颜色"
aria-expanded={wheelOpen}
onClick={() => toggleColorWheel(side)}
>
<Pipette size={12} />
</button>
<input
className="j13-md-color-hex j13-md-popover-input"
value={picked ?? ""}
placeholder="#e03131 或 tomato"
onChange={(e) => {
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();
}
}}
/>
</div>
{wheelOpen && (
<div className="j13-md-hsv">
<div
className="j13-md-hsv-sv"
style={{
background: `linear-gradient(to top, #000, rgba(0,0,0,0)), linear-gradient(to right, #fff, ${hsvToHex({ h: colorHsv.h, s: 1, v: 1 })})`,
}}
onPointerDown={onSvPointer}
>
<span
className="j13-md-hsv-sv-thumb"
style={{
left: `${colorHsv.s * 100}%`,
top: `${(1 - colorHsv.v) * 100}%`,
}}
/>
</div>
<div className="j13-md-hsv-hue" onPointerDown={onHuePointer}>
<span
className="j13-md-hsv-hue-thumb"
style={{ left: `${(colorHsv.h / 360) * 100}%` }}
/>
</div>
</div>
)}
</div>
);
};
const colorPopover =
popover === "color" ? (
<div
className={`j13-md-popover j13-md-popover-form j13-md-color-popover${toolNarrow ? " j13-md-popover-center" : ""}`}
role="dialog"
aria-label="文字颜色"
>
<div className="j13-md-color-preview">
<span
style={{
color: colorFg ?? "inherit",
backgroundColor: colorBg ?? "transparent",
}}
>
Aa 示例文字
</span>
</div>
{renderColorRow("fg", "前景", COLOR_FG_PRESETS, colorFg)}
{renderColorRow("bg", "背景", COLOR_BG_PRESETS, colorBg)}
<div className="j13-md-popover-footer">
<button type="button" className="btn btn-line btn-sm" onClick={() => setPopover(null)}>
取消
</button>
<button type="button" className="btn btn-primary btn-sm" onClick={() => applyColor()}>
插入
</button>
</div>
</div>
) : null;
// 视频嵌入弹层:裸输入统一交给 parseVideoInput 归一化
const videoPopover =
popover === "video" ? (
<div
className={`j13-md-popover j13-md-popover-form${toolNarrow ? " j13-md-popover-center" : ""}`}
role="dialog"
aria-label="插入视频"
>
<label className="j13-md-field">
<span>B站 / YouTube</span>
<input
autoFocus
value={videoInput}
onChange={(e) => setVideoInput(e.target.value)}
className="j13-md-popover-input"
placeholder="BV1… / 视频链接 / YouTube ID"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applyVideo();
}
}}
/>
</label>
<div className="j13-md-popover-footer">
<button type="button" className="btn btn-line btn-sm" onClick={() => setPopover(null)}>
取消
</button>
<button type="button" className="btn btn-primary btn-sm" onClick={applyVideo}>
插入
</button>
</div>
</div>
) : null;
const ToolBtn = ({
label,
icon,
@@ -981,6 +1387,31 @@ export default function MarkdownEditor({
onClick={() => runWrap({ before: "~~", after: "~~", placeholder: "删除线" })}
/>
)}
{!isComment && !toolNarrow && (
<ToolBtn
label="高亮 =="
icon={<Highlighter size={15} />}
onClick={() => runWrap({ before: "==", after: "==", placeholder: "高亮" })}
/>
)}
{!isComment && !toolNarrow && (
<ToolBtn
label="键帽"
icon={<Keyboard size={15} />}
onClick={() => runWrap({ before: "[kbd]", after: "[/kbd]", placeholder: "Ctrl" })}
/>
)}
{!toolNarrow && (
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="文字颜色"
icon={<Palette size={15} />}
active={popover === "color"}
onClick={() => openPopover("color")}
/>
{popover === "color" && colorPopover}
</div>
)}
{!isComment && !toolNarrow && <Sep />}
{!isComment && (
<div className="j13-md-popover-anchor relative">
@@ -1217,6 +1648,9 @@ export default function MarkdownEditor({
)}
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "*", after: "*", placeholder: "斜体" }); setPopover(null); }}>斜体</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "~~", after: "~~", placeholder: "删除线" }); setPopover(null); }}>删除线</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "==", after: "==", placeholder: "高亮" }); setPopover(null); }}>高亮</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "[kbd]", after: "[/kbd]", placeholder: "Ctrl" }); setPopover(null); }}>键帽</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("color")}>颜色…</button>
{!isComment && (
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("emoji")}>表情</button>
)}
@@ -1230,12 +1664,20 @@ export default function MarkdownEditor({
)}
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); openTimelineEditor(); }}>时间线</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertAtCursor(entriesAuto ? ENTRIES_TEMPLATE_AUTO : ENTRIES_TEMPLATE); setPopover(null); }}>条目卡</button>
{!isComment && (
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => insertDetails()}>折叠面板</button>
)}
{!isComment && (
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("video")}>视频…</button>
)}
{toolNarrow && (
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); toggleFullscreen(); }}>{fullscreen ? "退出全屏" : "全屏写作"}</button>
)}
</div>
)}
{toolNarrow && codePopover}
{toolNarrow && popover === "color" && colorPopover}
{toolNarrow && popover === "video" && videoPopover}
{toolNarrow && popover === "emoji" && !isComment && (
<div className="j13-md-popover j13-md-emoji-popover j13-md-popover-center" role="dialog" aria-label="插入表情">
<EmojiPicker onPick={pickEmoji} />
@@ -1259,6 +1701,17 @@ export default function MarkdownEditor({
onClick={() => insertAtCursor(entriesAuto ? ENTRIES_TEMPLATE_AUTO : ENTRIES_TEMPLATE)}
/>
)}
{!isComment && !toolNarrow && (
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="视频"
icon={<MonitorPlay size={15} />}
active={popover === "video"}
onClick={() => openPopover("video")}
/>
{popover === "video" && videoPopover}
</div>
)}
{allowHide && !toolNarrow ? (
<div className="j13-md-popover-anchor relative">
<ToolBtn

View File

@@ -0,0 +1,24 @@
import type { ReactNode } from "react";
import { ChevronDown } from "lucide-react";
type MdDetailsProps = {
title: string;
/** 正文已渲染的 React 节点(markdown);空正文可不传 */
children?: ReactNode;
};
/**
* [details 标题]…[/details] 折叠面板:原生 details/summary,无 JS、SSR 友好。
* 正文 markdown 由调用方(MarkdownBody / MarkdownBodyClient)渲染后传入。
*/
export default function MdDetails({ title, children }: MdDetailsProps) {
return (
<details className="j13-md-details">
<summary>
<ChevronDown size={15} className="j13-md-details-caret" aria-hidden />
<span className="j13-md-details-title">{title}</span>
</summary>
{children ? <div className="j13-md-details-body">{children}</div> : null}
</details>
);
}

View File

@@ -0,0 +1,35 @@
type VideoProvider = "bilibili" | "youtube";
type MdVideoProps = {
provider: VideoProvider;
/** bilibili 为 BV 号;youtube 为视频 ID(parseVideoLine 已校验) */
id: string;
};
function embedUrl(provider: VideoProvider, id: string): string {
if (provider === "bilibili") {
return `https://player.bilibili.com/player.html?bvid=${encodeURIComponent(
id
)}&autoplay=0&danmaku=0&high_quality=1`;
}
return `https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}`;
}
/**
* [bilibili]…[/bilibili] / [youtube]…[/youtube] 视频嵌入:
* 16:9 容器 + 懒加载 iframe(bilibili 官方播放器 / youtube 隐私域名),纯静态无 JS。
*/
export default function MdVideo({ provider, id }: MdVideoProps) {
return (
<div className="j13-md-video">
<iframe
src={embedUrl(provider, id)}
title={provider === "bilibili" ? `bilibili 视频 ${id}` : `YouTube 视频 ${id}`}
loading="lazy"
allowFullScreen
referrerPolicy="no-referrer"
allow="accelerometer; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
/>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import type { Root as MdastRoot } from "mdast";
import type { Text } from "mdast";
import { visit, SKIP } from "unist-util-visit";
import { splitInlineShortcodes } from "@/lib/inlineShortcodes";
const QUICK_RE = /==|\[(?:kbd|color|bg)[=\]]/i;
type MdastParent = { children: Array<Record<string, unknown>>; type: string };
/** 行内短代码 → mdast 节点(mdast-util-to-hast 支持 data.hName / data.hProperties) */
function segToNodes(
seg: Exclude<ReturnType<typeof splitInlineShortcodes>, null>[number]
): Record<string, unknown>[] {
if (seg.type === "text") return [{ type: "text", value: seg.value }];
if (seg.type === "mark" || seg.type === "kbd") {
return [
{
type: "emphasis",
data: { hName: seg.type },
children: [{ type: "text", value: seg.value }],
},
];
}
const style = [
seg.fg ? `color:${seg.fg}` : null,
seg.bg ? `background-color:${seg.bg}` : null,
]
.filter(Boolean)
.join(";");
return [
{
type: "emphasis",
data: { hName: "span", hProperties: { style } },
children: [{ type: "text", value: seg.value }],
},
];
}
/**
* remark 插件:把文本节点中的 ==高亮== / [kbd] / [color] / [bg] 展开为
* mark/kbd/span 节点。code/inlineCode 是字面节点没有子文本,天然免疫;
* 未命中短代码的文本原样保留。
*/
export function remarkInlineShortcodes() {
return (tree: MdastRoot) => {
visit(tree, "text", (node: Text, index, parent) => {
const p = parent as unknown as MdastParent | null;
if (!p || index === undefined) return;
const value = node.value;
if (!QUICK_RE.test(value)) return;
const segs = splitInlineShortcodes(value);
if (!segs) return;
const nodes = segs.flatMap(segToNodes);
p.children.splice(index, 1, ...nodes);
return [SKIP, index + nodes.length];
});
};
}

View File

@@ -11,6 +11,7 @@ import type { Root } from "hast";
import type { Root as MdastRoot } from "mdast";
import { visit } from "unist-util-visit";
import { mergeMarkdownComponents } from "./mergeComponents";
import { remarkInlineShortcodes } from "./remarkInlineShortcodes";
import {
getPrettyCodeHighlighter,
resolveShikiLang,
@@ -54,6 +55,7 @@ export async function renderMarkdownToReact(
const processor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkInlineShortcodes)
.use(remarkNormalizeCodeLang)
.use(remarkRehype)
.use(rehypePrettyCode, { ...prettyCodeOptions });