Files
jiang13-bbs/frontend/lib/directiveBlocks.ts
freefire 99c0f0319d feat: 品牌裁剪弹窗/Markdown 行内短代码与详情视频块等
新增:
- BrandCropModal 品牌图裁剪弹窗 + cropImage 工具
- Markdown 行内短代码(inlineShortcodes + remark 插件)
- MdDetails 折叠块、MdVideo 视频块组件
- directiveBlocks/inlineShortcodes 测试

其它:
- 聊天后端(handler/service chat)调整
- Markdown 渲染链路、编辑器、全局样式与品牌相关面板更新
2026-10-02 01:40:06 +08:00

363 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 统一拆分正文中的 [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;
}