feat: 站点单页、时间线导入与行级 BBCode 短代码
首页右栏接入单页入口与公告置顶/全部页;Markdown 编辑器支持可视化时间线与 Git 导入;隐藏块改为 [hide]/[timeline] 命名闭合,并修复单页/公告 GORM 更新只改 updated_at 的问题。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
205
frontend/lib/directiveBlocks.ts
Normal file
205
frontend/lib/directiveBlocks.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/** 统一拆分正文中的 [hide] / [timeline] 指令块(忽略代码围栏) */
|
||||
|
||||
import {
|
||||
splitHideSegments,
|
||||
type HideKind,
|
||||
type HideSegment,
|
||||
} from "@/lib/hideBlocks";
|
||||
import { parseTimelineBody, type TimelineItem } from "@/lib/timelineBlocks";
|
||||
|
||||
export type ContentSegment =
|
||||
| { type: "md"; text: string }
|
||||
| {
|
||||
type: "hide";
|
||||
kind: HideKind;
|
||||
points: number;
|
||||
locked: boolean;
|
||||
body: string;
|
||||
}
|
||||
| { type: "timeline"; items: TimelineItem[]; rawBody: string };
|
||||
|
||||
function isTimelineClose(line: string): boolean {
|
||||
return line.trim().toLowerCase() === "[/timeline]";
|
||||
}
|
||||
|
||||
function isTimelineOpen(trimmed: string): boolean {
|
||||
return trimmed.toLowerCase() === "[timeline]";
|
||||
}
|
||||
|
||||
/**
|
||||
* 先拆时间线,再对普通 md/hide 段做 hide 拆分。
|
||||
* 时间线与 hide 互不嵌套;解析失败时整段当普通 Markdown。
|
||||
*/
|
||||
export function splitContentSegments(content: string): ContentSegment[] {
|
||||
if (!content) return [{ type: "md", text: "" }];
|
||||
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 }> = [];
|
||||
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 = [];
|
||||
};
|
||||
|
||||
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)) {
|
||||
flushMd();
|
||||
const bodyLines: string[] = [];
|
||||
let j = i + 1;
|
||||
let innerCode = false;
|
||||
let found = 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 (isTimelineOpen(inner)) {
|
||||
// 嵌套视为失败:整段当 md
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
found = false;
|
||||
break;
|
||||
}
|
||||
if (isTimelineClose(inner)) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
bodyLines.push(lines[j]);
|
||||
j++;
|
||||
}
|
||||
if (!found) {
|
||||
buf.push(lines[i]);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
raw.push({ type: "timeline", body: bodyLines.join("\n") });
|
||||
i = j + 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user