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,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 });