新增: - BrandCropModal 品牌图裁剪弹窗 + cropImage 工具 - Markdown 行内短代码(inlineShortcodes + remark 插件) - MdDetails 折叠块、MdVideo 视频块组件 - directiveBlocks/inlineShortcodes 测试 其它: - 聊天后端(handler/service chat)调整 - Markdown 渲染链路、编辑器、全局样式与品牌相关面板更新
363 lines
11 KiB
TypeScript
363 lines
11 KiB
TypeScript
/** 统一拆分正文中的 [hide] / [timeline] / [entries] 指令块(忽略代码围栏) */
|
||
|
||
import {
|
||
splitHideSegments,
|
||
type HideKind,
|
||
type HideSegment,
|
||
} from "./hideBlocks.ts";
|
||
import { parseTimelineBody, type TimelineItem } from "./timelineBlocks.ts";
|
||
import {
|
||
isEntriesClose,
|
||
isEntriesOffOpen,
|
||
isEntriesOpen,
|
||
parseEntriesBody,
|
||
wrapImplicitEntries,
|
||
type EntryItem,
|
||
} from "./entryBlocks.ts";
|
||
|
||
export type ContentSegment =
|
||
| { type: "md"; text: string }
|
||
| {
|
||
type: "hide";
|
||
kind: HideKind;
|
||
points: number;
|
||
locked: boolean;
|
||
body: string;
|
||
}
|
||
| { type: "timeline"; items: TimelineItem[]; 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]";
|
||
}
|
||
|
||
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。
|
||
* opts.entriesAuto:书级「全章节条目卡模式」开关——开启时按 wrapImplicitEntries
|
||
* 把「## 标题 + @tags/@summary/@source/字段::值」写法包上 [entries] 壳再走统一拆分;
|
||
* 关闭(含帖子/评论等非书场景)时均按普通 Markdown 渲染,条目卡只能用显式 [entries] 块。
|
||
* [entries:off] 块内始终保持普通 Markdown。
|
||
*/
|
||
export function splitContentSegments(
|
||
content: string,
|
||
opts?: { entriesAuto?: boolean }
|
||
): ContentSegment[] {
|
||
if (!content) return [{ type: "md", text: "" }];
|
||
if (opts?.entriesAuto) {
|
||
content = wrapImplicitEntries(content);
|
||
}
|
||
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: "details"; title: string; body: string }
|
||
| { type: "video"; provider: "bilibili" | "youtube"; id: string }
|
||
> = [];
|
||
let buf: string[] = [];
|
||
let inCode = false;
|
||
let i = 0;
|
||
|
||
const flushMd = () => {
|
||
if (buf.length === 0) return;
|
||
raw.push({ type: "md", text: buf.join("\n") });
|
||
buf = [];
|
||
};
|
||
|
||
/** 从 i 行的开标签扫到匹配闭标签;忽略代码围栏,嵌套开标签视为失败(返回 null,调用方按 md 处理) */
|
||
const scanBlock = (
|
||
isOpen: (t: string) => boolean,
|
||
isClose: (t: string) => boolean
|
||
): { body: string; end: number } | null => {
|
||
const bodyLines: string[] = [];
|
||
let j = i + 1;
|
||
let innerCode = false;
|
||
while (j < lines.length) {
|
||
const inner = lines[j].trim();
|
||
if (inner.startsWith("```")) {
|
||
innerCode = !innerCode;
|
||
bodyLines.push(lines[j]);
|
||
j++;
|
||
continue;
|
||
}
|
||
if (innerCode) {
|
||
bodyLines.push(lines[j]);
|
||
j++;
|
||
continue;
|
||
}
|
||
if (isOpen(inner)) return null;
|
||
if (isClose(inner)) return { body: bodyLines.join("\n"), end: j };
|
||
bodyLines.push(lines[j]);
|
||
j++;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
/** 开闭标签块(timeline/entries)公共处理:失败按 md,成功推进 i */
|
||
const takeBlock = (
|
||
line: string,
|
||
isOpen: (t: string) => boolean,
|
||
isClose: (t: string) => boolean,
|
||
type: "timeline" | "entries"
|
||
): boolean => {
|
||
const hit = scanBlock(isOpen, isClose);
|
||
if (!hit) {
|
||
buf.push(line);
|
||
i++;
|
||
return true;
|
||
}
|
||
flushMd();
|
||
raw.push({ type, body: hit.body });
|
||
i = hit.end + 1;
|
||
return true;
|
||
};
|
||
|
||
while (i < lines.length) {
|
||
const trimmed = lines[i].trim();
|
||
if (trimmed.startsWith("```")) {
|
||
inCode = !inCode;
|
||
buf.push(lines[i]);
|
||
i++;
|
||
continue;
|
||
}
|
||
if (inCode) {
|
||
buf.push(lines[i]);
|
||
i++;
|
||
continue;
|
||
}
|
||
|
||
if (isTimelineOpen(trimmed)) {
|
||
takeBlock(lines[i], isTimelineOpen, isTimelineClose, "timeline");
|
||
continue;
|
||
}
|
||
|
||
if (isEntriesOpen(trimmed)) {
|
||
takeBlock(lines[i], isEntriesOpen, isEntriesClose, "entries");
|
||
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)) {
|
||
const hit = scanBlock(isEntriesOffOpen, isEntriesClose);
|
||
if (!hit) {
|
||
buf.push(lines[i]);
|
||
i++;
|
||
continue;
|
||
}
|
||
buf.push(hit.body);
|
||
i = hit.end + 1;
|
||
continue;
|
||
}
|
||
|
||
buf.push(lines[i]);
|
||
i++;
|
||
}
|
||
flushMd();
|
||
|
||
const out: ContentSegment[] = [];
|
||
for (const part of raw) {
|
||
if (part.type === "timeline") {
|
||
out.push({
|
||
type: "timeline",
|
||
items: parseTimelineBody(part.body),
|
||
rawBody: part.body,
|
||
});
|
||
continue;
|
||
}
|
||
if (part.type === "entries") {
|
||
out.push({
|
||
type: "entries",
|
||
items: parseEntriesBody(part.body),
|
||
rawBody: part.body,
|
||
});
|
||
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") {
|
||
out.push({ type: "md", text: s.text });
|
||
} else {
|
||
out.push({
|
||
type: "hide",
|
||
kind: s.kind,
|
||
points: s.points,
|
||
locked: s.locked,
|
||
body: s.body,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
return out.length > 0 ? out : [{ type: "md", text: content }];
|
||
}
|
||
|
||
/** 光标是否落在某个 [timeline]…[/timeline] 块内;返回块起止与正文 */
|
||
export function findTimelineBlockAt(
|
||
content: string,
|
||
cursor: number
|
||
): { start: number; end: number; body: string } | null {
|
||
const blocks = listTimelineBlocks(content);
|
||
for (const b of blocks) {
|
||
if (cursor >= b.start && cursor <= b.end) return b;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 编辑器打开时间线面板时的目标块:
|
||
* 1) 光标落在某块内 → 该块
|
||
* 2) 正文恰有一块 → 该块(changelog 页常见:光标在标题区仍应更新已有时间线)
|
||
* 3) 否则 null(新建插入)
|
||
*/
|
||
export function resolveTimelineBlockForEdit(
|
||
content: string,
|
||
cursor: number
|
||
): { start: number; end: number; body: string } | null {
|
||
const at = findTimelineBlockAt(content, cursor);
|
||
if (at) return at;
|
||
const blocks = listTimelineBlocks(content);
|
||
if (blocks.length === 1) return blocks[0];
|
||
return null;
|
||
}
|
||
|
||
function listTimelineBlocks(
|
||
content: string
|
||
): Array<{ start: number; end: number; body: string }> {
|
||
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||
const out: Array<{ start: number; end: number; body: string }> = [];
|
||
let offset = 0;
|
||
let inCode = false;
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i];
|
||
const lineStart = offset;
|
||
const lineEnd = offset + line.length;
|
||
const trimmed = line.trim();
|
||
if (trimmed.startsWith("```")) inCode = !inCode;
|
||
if (!inCode && isTimelineOpen(trimmed)) {
|
||
const openStart = lineStart;
|
||
let j = i + 1;
|
||
let innerCode = false;
|
||
let closeEnd = -1;
|
||
const bodyLines: string[] = [];
|
||
let pos = lineEnd + 1;
|
||
while (j < lines.length) {
|
||
const l = lines[j];
|
||
const t = l.trim();
|
||
if (t.startsWith("```")) innerCode = !innerCode;
|
||
if (!innerCode && isTimelineClose(t)) {
|
||
closeEnd = pos + l.length;
|
||
break;
|
||
}
|
||
bodyLines.push(l);
|
||
pos += l.length + 1;
|
||
j++;
|
||
}
|
||
if (closeEnd >= 0) {
|
||
out.push({ start: openStart, end: closeEnd, body: bodyLines.join("\n") });
|
||
}
|
||
}
|
||
offset = lineEnd + 1;
|
||
}
|
||
return out;
|
||
}
|