feat: 品牌裁剪弹窗/Markdown 行内短代码与详情视频块等
新增: - BrandCropModal 品牌图裁剪弹窗 + cropImage 工具 - Markdown 行内短代码(inlineShortcodes + remark 插件) - MdDetails 折叠块、MdVideo 视频块组件 - directiveBlocks/inlineShortcodes 测试 其它: - 聊天后端(handler/service chat)调整 - Markdown 渲染链路、编辑器、全局样式与品牌相关面板更新
This commit is contained in:
368
frontend/components/BrandCropModal.tsx
Normal file
368
frontend/components/BrandCropModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />
|
||||
))}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
24
frontend/components/markdown/MdDetails.tsx
Normal file
24
frontend/components/markdown/MdDetails.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
frontend/components/markdown/MdVideo.tsx
Normal file
35
frontend/components/markdown/MdVideo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
frontend/components/markdown/remarkInlineShortcodes.ts
Normal file
58
frontend/components/markdown/remarkInlineShortcodes.ts
Normal 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];
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user