feat: 品牌裁剪弹窗/Markdown 行内短代码与详情视频块等
新增: - BrandCropModal 品牌图裁剪弹窗 + cropImage 工具 - Markdown 行内短代码(inlineShortcodes + remark 插件) - MdDetails 折叠块、MdVideo 视频块组件 - directiveBlocks/inlineShortcodes 测试 其它: - 聊天后端(handler/service chat)调整 - Markdown 渲染链路、编辑器、全局样式与品牌相关面板更新
This commit is contained in:
@@ -25,7 +25,9 @@ export type ContentSegment =
|
||||
body: string;
|
||||
}
|
||||
| { type: "timeline"; items: TimelineItem[]; rawBody: string }
|
||||
| { type: "entries"; items: EntryItem[]; rawBody: string };
|
||||
| { type: "entries"; items: EntryItem[]; rawBody: string }
|
||||
| { type: "details"; title: string; body: string }
|
||||
| { type: "video"; provider: "bilibili" | "youtube"; id: string };
|
||||
|
||||
function isTimelineClose(line: string): boolean {
|
||||
return line.trim().toLowerCase() === "[/timeline]";
|
||||
@@ -35,6 +37,68 @@ function isTimelineOpen(trimmed: string): boolean {
|
||||
return trimmed.toLowerCase() === "[timeline]";
|
||||
}
|
||||
|
||||
/* ---------- [details] 折叠面板 ---------- */
|
||||
|
||||
const DETAILS_OPEN_RE = /^\[details(?:\s+([^\]]*))?\]$/i;
|
||||
export const DETAILS_DEFAULT_TITLE = "点击展开";
|
||||
|
||||
function isDetailsClose(line: string): boolean {
|
||||
return line.trim().toLowerCase() === "[/details]";
|
||||
}
|
||||
|
||||
/** 开标记整行匹配:[details 标题文字];标题缺省「点击展开」 */
|
||||
export function isDetailsOpen(trimmed: string): boolean {
|
||||
return DETAILS_OPEN_RE.test(trimmed);
|
||||
}
|
||||
|
||||
export function parseDetailsOpen(trimmed: string): string {
|
||||
const m = trimmed.match(DETAILS_OPEN_RE);
|
||||
const title = (m?.[1] ?? "").trim();
|
||||
return title || DETAILS_DEFAULT_TITLE;
|
||||
}
|
||||
|
||||
/* ---------- [bilibili] / [youtube] 视频嵌入(整行单标记) ---------- */
|
||||
|
||||
const VIDEO_LINE_RE = /^\[(bilibili|youtube)\]([^\[\]]+)\[\/\1\]$/i;
|
||||
const BILI_BV_RE = /^BV[0-9A-Za-z]{8,12}$/;
|
||||
const BILI_URL_BV_RE = /\/video\/(BV[0-9A-Za-z]{8,12})/;
|
||||
const YT_ID_RE = /^[0-9A-Za-z_-]{6,20}$/;
|
||||
|
||||
/**
|
||||
* 裸输入解析:BV 号 / B站视频链接 / YouTube ID / youtu.be、watch?v= 链接。
|
||||
* 短代码行与编辑器「插入视频」表单共用;解析失败返回 null。
|
||||
*/
|
||||
export function parseVideoInput(
|
||||
raw: string
|
||||
): { provider: "bilibili" | "youtube"; id: string } | null {
|
||||
const s = raw.trim();
|
||||
if (!s || /[\s<>"']/.test(s)) return null;
|
||||
const bvFromUrl = s.match(BILI_URL_BV_RE);
|
||||
if (bvFromUrl) return { provider: "bilibili", id: bvFromUrl[1] };
|
||||
if (BILI_BV_RE.test(s)) return { provider: "bilibili", id: s };
|
||||
if (/youtu\.?be/i.test(s)) {
|
||||
const m = s.match(/(?:youtu\.be\/|[?&]v=)([0-9A-Za-z_-]{6,20})/);
|
||||
return m ? { provider: "youtube", id: m[1] } : null;
|
||||
}
|
||||
if (YT_ID_RE.test(s)) return { provider: "youtube", id: s };
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 整行 `[bilibili]…[/bilibili]` / `[youtube]…[/youtube]`。
|
||||
* 标签名与内容平台不一致、或内容解析失败时返回 null(该行按普通 Markdown 处理)。
|
||||
*/
|
||||
export function parseVideoLine(
|
||||
trimmed: string
|
||||
): { provider: "bilibili" | "youtube"; id: string } | null {
|
||||
const m = trimmed.match(VIDEO_LINE_RE);
|
||||
if (!m) return null;
|
||||
const provider = m[1].toLowerCase() as "bilibili" | "youtube";
|
||||
const parsed = parseVideoInput(m[2]);
|
||||
if (!parsed || parsed.provider !== provider) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 先拆时间线,再对普通 md/hide 段做 hide 拆分。
|
||||
* 时间线与 hide 互不嵌套;解析失败时整段当普通 Markdown。
|
||||
@@ -53,7 +117,11 @@ export function splitContentSegments(
|
||||
}
|
||||
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
const raw: Array<
|
||||
{ type: "md"; text: string } | { type: "timeline"; body: string } | { type: "entries"; body: string }
|
||||
| { type: "md"; text: string }
|
||||
| { type: "timeline"; body: string }
|
||||
| { type: "entries"; body: string }
|
||||
| { type: "details"; title: string; body: string }
|
||||
| { type: "video"; provider: "bilibili" | "youtube"; id: string }
|
||||
> = [];
|
||||
let buf: string[] = [];
|
||||
let inCode = false;
|
||||
@@ -137,6 +205,29 @@ export function splitContentSegments(
|
||||
continue;
|
||||
}
|
||||
|
||||
// [details 标题]…[/details] 折叠面板:嵌套开标签视为失败按 md 处理
|
||||
if (isDetailsOpen(trimmed)) {
|
||||
const hit = scanBlock(isDetailsOpen, isDetailsClose);
|
||||
if (!hit) {
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
flushMd();
|
||||
raw.push({ type: "details", title: parseDetailsOpen(trimmed), body: hit.body });
|
||||
i = hit.end + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// [bilibili]…[/bilibili] / [youtube]…[/youtube] 整行视频嵌入
|
||||
const video = parseVideoLine(trimmed);
|
||||
if (video) {
|
||||
flushMd();
|
||||
raw.push({ type: "video", ...video });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// [entries:off] 例外块:body 原样进 md 缓冲(标记行丢弃),
|
||||
// 不再参与 entries/timeline 拆分;未闭合时整块当普通 Markdown
|
||||
if (isEntriesOffOpen(trimmed)) {
|
||||
@@ -174,6 +265,14 @@ export function splitContentSegments(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (part.type === "details") {
|
||||
out.push({ type: "details", title: part.title, body: part.body });
|
||||
continue;
|
||||
}
|
||||
if (part.type === "video") {
|
||||
out.push({ type: "video", provider: part.provider, id: part.id });
|
||||
continue;
|
||||
}
|
||||
const hideSegs = splitHideSegments(part.text) as HideSegment[];
|
||||
for (const s of hideSegs) {
|
||||
if (s.type === "md") {
|
||||
|
||||
Reference in New Issue
Block a user