From 674123e6c9d040cd44dc65fbd3f494d810792c77 Mon Sep 17 00:00:00 2001 From: seaHi Date: Thu, 24 Sep 2026 08:48:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=96=87=E7=AB=A0?= =?UTF-8?q?=E5=A4=A7=E7=BA=B2=E8=A7=A3=E6=9E=90=E4=B8=8E=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=A0=B8=E5=BF=83=E9=80=BB=E8=BE=91=E5=8F=8A?= =?UTF-8?q?=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/editor/outline.svelte.ts | 8 +++++ src/lib/editor/outline.test.ts | 50 ++++++++++++++++++++++++++++++++ src/lib/editor/outline.ts | 47 ++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 src/lib/editor/outline.svelte.ts create mode 100644 src/lib/editor/outline.test.ts create mode 100644 src/lib/editor/outline.ts diff --git a/src/lib/editor/outline.svelte.ts b/src/lib/editor/outline.svelte.ts new file mode 100644 index 0000000..2c804ac --- /dev/null +++ b/src/lib/editor/outline.svelte.ts @@ -0,0 +1,8 @@ +import type { OutlineHeading } from './outline'; + +// Each editing page owns its state; never share article content across SSR requests. +export class EditorOutline { + headings = $state.raw([]); + active = $state(-1); + navigate: (heading: OutlineHeading) => void = () => {}; +} diff --git a/src/lib/editor/outline.test.ts b/src/lib/editor/outline.test.ts new file mode 100644 index 0000000..c5ba51c --- /dev/null +++ b/src/lib/editor/outline.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { activeHeadingAt, extractOutline } from './outline'; + +describe('editor outline', () => { + it('recognizes all heading levels and Setext headings with exact source positions', () => { + const source = + '# 一\n\n## 二 ##\n\n### 三\n\n#### 四\n\n##### 五\n\n###### 六\n\n下划线标题\n====\n'; + const headings = extractOutline(source); + expect(headings.map(({ level, text }) => [level, text])).toEqual([ + [1, '一'], + [2, '二'], + [3, '三'], + [4, '四'], + [5, '五'], + [6, '六'], + [1, '下划线标题'] + ]); + expect(headings.at(-1)?.from).toBe(source.indexOf('下划线标题')); + }); + it('ignores fenced and indented code, HTML blocks and escaped heading markers', () => { + const source = + '```md\n# 假标题\n```\n\n~~~\n## 假标题\n~~~\n\n # 代码\n\n\\# 普通文本\n\n
\n# HTML 内文本\n
\n\n## 真标题\n'; + expect(extractOutline(source).map((heading) => heading.text)).toEqual(['真标题']); + }); + it('keeps duplicate headings distinct and includes headings inside quotes and lists', () => { + const source = '## 重复\n\n> ### 引用\n\n- #### 列表\n\n## 重复\n'; + const headings = extractOutline(source); + expect(headings.map((heading) => heading.text)).toEqual(['重复', '引用', '列表', '重复']); + expect(headings[3].from).toBe(source.lastIndexOf('## 重复')); + }); + it('shows plain labels for formatted headings and handles empty headings', () => { + expect( + extractOutline('## **粗体** [链接](https://example.com) `code`\n\n#\n').map((h) => h.text) + ).toEqual(['粗体 链接 code', '未命名标题']); + }); + it('includes offscreen headings and refreshes offsets after insertion or deletion', () => { + const source = '正文\n\n'.repeat(4000) + '## 结尾\n'; + expect(extractOutline(source)[0].from).toBe(source.indexOf('##')); + expect(extractOutline('前言\n\n' + source)[0].from).toBe(source.indexOf('##') + 4); + expect(extractOutline(source.replace('## 结尾', '结尾'))).toEqual([]); + }); + it('finds the enclosing section for cursor or viewport positions', () => { + const headings = extractOutline('前言\n\n## 第一节\n内容\n\n## 第二节\n内容'); + expect(activeHeadingAt(headings, 0)).toBe(-1); + expect(activeHeadingAt(headings, headings[0].from)).toBe(0); + expect(activeHeadingAt(headings, headings[1].from - 1)).toBe(0); + expect(activeHeadingAt(headings, 100)).toBe(1); + expect(activeHeadingAt([], 100)).toBe(-1); + }); +}); diff --git a/src/lib/editor/outline.ts b/src/lib/editor/outline.ts new file mode 100644 index 0000000..895899d --- /dev/null +++ b/src/lib/editor/outline.ts @@ -0,0 +1,47 @@ +import { markdownLanguage } from '@codemirror/lang-markdown'; +import { Lexer, type Token } from 'marked'; + +export type OutlineHeading = { from: number; level: number; text: string }; + +function plainText(tokens: Token[]): string { + return tokens + .map((token) => { + if (token.type === 'html') return ''; + if ('tokens' in token && token.tokens) return plainText(token.tokens); + return 'text' in token ? token.text : token.raw; + }) + .join(''); +} + +/** Parse the whole document, including headings beyond CodeMirror's rendered viewport. */ +export function extractOutline(source: string): OutlineHeading[] { + const headings: OutlineHeading[] = []; + markdownLanguage.parser.parse(source).iterate({ + enter(node) { + const match = /^(?:ATX|Setext)Heading([1-6])$/.exec(node.name); + if (!match) return; + let start = node.from; + let raw = ''; + for (let child = node.node.firstChild; child; child = child.nextSibling) { + if (child.name === 'HeaderMark') { + raw += source.slice(start, child.from); + start = child.to; + } + } + raw += source.slice(start, node.to); + headings.push({ + from: node.from, + level: Number(match[1]), + text: plainText(Lexer.lexInline(raw.trim())).trim() || '未命名标题' + }); + return false; + } + }); + return headings; +} + +export function activeHeadingAt(headings: OutlineHeading[], position: number): number { + let active = -1; + for (let i = 0; i < headings.length && headings[i].from <= position; i++) active = i; + return active; +}