feat: 品牌裁剪弹窗/Markdown 行内短代码与详情视频块等
新增: - BrandCropModal 品牌图裁剪弹窗 + cropImage 工具 - Markdown 行内短代码(inlineShortcodes + remark 插件) - MdDetails 折叠块、MdVideo 视频块组件 - directiveBlocks/inlineShortcodes 测试 其它: - 聊天后端(handler/service chat)调整 - Markdown 渲染链路、编辑器、全局样式与品牌相关面板更新
This commit is contained in:
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