@tnotesjs/ui 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,6 +8,55 @@ TNotes 内置块的 Vue 组件。消费方:`@tnotesjs/ssg`、Desk。需要 `vu
8
8
  pnpm add @tnotesjs/ui
9
9
  ```
10
10
 
11
+ ### 代码块(`CodeBlock` / `CodeGroup`)
12
+
13
+ 默认行为跟 VitePress 一致,**任何情况下都不限制高度**:
14
+
15
+ - 宽度不超出正文容器,超长单行在代码块内横向滚动,页面不会被撑宽;
16
+ - 原始换行与缩进原样保留(`white-space: pre`),不强制换行;
17
+ - 高度一律由内容撑开:**没有 `max-height`、不会出现内部纵向滚动**,哪怕上万行也照直铺开。
18
+
19
+ **折叠是页面内的视图状态**:每个代码块标题左侧都有折叠 Icon,点一下把代码内容
20
+ 整体隐藏(只剩标题栏,跟折叠标题一样),再点展开。状态**只存在当前页面的内存里** ——
21
+ 不写进 markdown、不写 localStorage,刷新或重新进入页面一律回到展开。
22
+ 代码分组切换 tab 时,新露出来的面板若处于收起状态会自动展开。
23
+
24
+ ````md
25
+ ```js
26
+ console.log('每个代码块标题左侧都有折叠 Icon')
27
+ ```
28
+
29
+ ```ts:line-numbers {2} [multi.ts]
30
+ function add(a: number, b: number) {
31
+ const sum = a + b
32
+ return sum
33
+ }
34
+ ```
35
+
36
+ ```text [one-line.txt]
37
+ 这一行很长很长……它在代码块内横向滚动,不会把页面撑宽
38
+ ```
39
+
40
+ ```js [long.js]
41
+ // 多少行都直接铺开:没有 max-height,也没有内部纵向滚动条
42
+ ```
43
+
44
+ ::: code-group
45
+
46
+ ```js [a-short.js]
47
+ console.log('a')
48
+ ```
49
+
50
+ ```ts [b-long.ts]
51
+ // 每个 tab 的代码同样可折叠;切换 tab 会自动展开收起的面板
52
+ ```
53
+
54
+ :::
55
+ ````
56
+
57
+ 作者不需要写任何额外标记,也不需要“声明”某个代码块可折叠。站点读者与编辑器
58
+ (Desk)行为一致。
59
+
11
60
  ### `BilibiliVideo`
12
61
 
13
62
  | Prop | Default |
@@ -51,3 +100,35 @@ pnpm add @tnotesjs/ui
51
100
  | `enableCopy` | `true` |
52
101
  | `enableFullscreen` | `true` |
53
102
  | `enableCenterToggle` | `true` |
103
+
104
+ ### `MindmapEditor`(`@tnotesjs/ui/mindmap-editor`)
105
+
106
+ 思维导图编辑器的**唯一实现**。`@tnotesjs/ui/mindmap` 那个块(`Mindmap.vue`)是给 SSG / Desk 笔记
107
+ 内嵌用的「预览 + 可选编辑」;本入口导出它内部复用的编辑器组件,供自带页面外壳的宿主直接组装。
108
+ 当前宿主:`apps/mindmap-web`、`apps/mindmap-vscode`。
109
+
110
+ | 导出 | 说明 |
111
+ | ----------------------------------------------------------------------------- | ---------------------------------------------- |
112
+ | `MindmapView` | 脑图画布(含选区工具条 / 右键菜单 / 链接浮层) |
113
+ | `OutlineView` | 大纲视图(拖拽、行内富文本、折叠) |
114
+ | `MarkdownView` | 源码视图(防抖回流 + 诊断定位) |
115
+ | `SearchBar` | 三视图共用的搜索条 |
116
+ | `FocusBreadcrumbs` | 进入主题后的面包屑(与主 barrel 同源) |
117
+ | `AppIcon` / `IconButton` / `CollapseMenu` | 外壳按钮与图标 |
118
+ | `CanvasContextMenu` / `LinkPopover` / `RichInlineEditor` / `SelectionToolbar` | 上面几个组件的组成件,供未来宿主自行组装 |
119
+ | `insertImageIntoSource` | 源码视图的整节点图片插入 |
120
+ | `pasteCanvasOutline` / `readMindmapClipboard` / `writeMindmapClipboard` | 脑图剪贴板(含无权限时的进程内回退) |
121
+ | `resolveAfterDropLevel` / `DropLevel` | 大纲拖拽落点解析 |
122
+ | `primaryShortcut` / `altShortcut` / `isApplePlatform` | 平台快捷键文案 |
123
+
124
+ **浮层挂载点(`teleportTo`)**:`MindmapView`(并透传给 `CanvasContextMenu` / `LinkPopover`)接受
125
+ `teleportTo?: string`。默认 `undefined` = 就地渲染——浮层是 `position: fixed`,嵌在笔记里的宿主
126
+ (Desk / SSG)teleport 到 `body` 会脱离笔记的层叠上下文。整页宿主传 `'body'`。
127
+
128
+ **宿主职责(不属于本包)**:`MindmapSession` 的归属与生命周期、`--mm-*` 主题变量
129
+ (`--mm-canvas-bg` / `--mm-panel-bg` / `--mm-border` / `--mm-text` / `--mm-text-dim` / `--mm-hover` /
130
+ `--mm-selected-bg` / `--mm-accent` / `--mm-edit-bg`)、落盘、图片写入、宿主快捷键到组件
131
+ `defineExpose` 钩子的桥接(`OutlineView` 的 `locateNode` / `selectAllFromHost` / `undoFromHost` /
132
+ `redoFromHost`,`MarkdownView` 的 `selectAllFromHost` / `flushDraft`)。
133
+
134
+ 改公共交互只改这里一处,三个宿主同时生效。
@@ -1,5 +1,43 @@
1
1
  import { HighlighterCore } from 'shiki/core';
2
2
 
3
+ /**
4
+ * 代码块折叠:**纯视图状态**,只存在于当前页面的内存里。
5
+ *
6
+ * - 不进 markdown,不写 localStorage:刷新 / 重新进入页面后一律**默认展开**;
7
+ * - 折叠 = 代码内容整体隐藏,只剩标题栏(跟折叠标题一样,不是「露出前几行」);
8
+ * - 默认(未折叠)不做任何高度限制:不管多少行都由内容撑开,没有 max-height、
9
+ * 没有内部纵向滚动,超长单行在代码块内横向滚动;
10
+ * - 每个代码块标题左侧都有折叠 Icon,点击在收起 / 展开之间切换。
11
+ *
12
+ * DOM 是唯一状态源(`is-collapsed` class + `aria-expanded`),因此 SSR 出来的静态
13
+ * HTML 和客户端补强(`hydrateIslands`)以及 Vue 组件本身共用一套契约。
14
+ */
15
+ /** 收起状态挂在代码块根节点上的 class。 */
16
+ declare const CODE_BLOCK_COLLAPSED_CLASS = "is-collapsed";
17
+ /** 代码块根节点(`.tn-code-block`)或它内部任意节点 → 根节点。 */
18
+ declare function codeBlockFrom(node: Element | null | undefined): HTMLElement | null;
19
+ declare function isCodeBlockCollapsed(block: Element | null | undefined): boolean;
20
+ /** 面板 / 容器里的第一个代码块(代码分组的面板就是这种结构)。 */
21
+ declare function codeBlockIn(root: ParentNode | null | undefined): HTMLElement | null;
22
+ declare function collapseButtonLabel(collapsed: boolean): string;
23
+ /**
24
+ * 同步折叠按钮的可访问状态。
25
+ *
26
+ * 代码分组把按钮放在 tab 行里(面板自带的那个会被 tab 盖住),两处按钮共用这套 chrome。
27
+ */
28
+ declare function applyCollapseChrome(button: Element | null, collapsed: boolean): void;
29
+ /** 设置某个代码块的收起状态(`is-collapsed` 决定内容是否隐藏,见 code.css)。 */
30
+ declare function setCodeBlockCollapsed(block: Element | null, collapsed: boolean): void;
31
+ /** 切换收起状态,返回切换后的状态。 */
32
+ declare function toggleCodeBlockCollapsed(block: Element | null): boolean;
33
+ /**
34
+ * 展开 root 里所有被收起的代码块。
35
+ *
36
+ * 代码分组切换 tab 时用它:新露出来的面板如果是收起状态,自动展开,
37
+ * 读者不会只看到一个标题栏。
38
+ */
39
+ declare function expandCollapsedCodeBlocks(root: ParentNode | null | undefined): void;
40
+
3
41
  interface CodeMeta {
4
42
  language: string;
5
43
  title: string;
@@ -39,4 +77,4 @@ declare function prepareCodeHighlighter(languages?: string[]): Promise<Highlight
39
77
  declare function highlightCodeSync(code: string, info?: string): string;
40
78
  declare function highlightCode(code: string, info?: string): Promise<string>;
41
79
 
42
- export { type CodeMeta, SHARED_CODE_GROUP_CONTRACT, highlightCode, highlightCodeSync, normalizeCodeLanguage, parseCodeMeta, prepareCodeHighlighter };
80
+ export { CODE_BLOCK_COLLAPSED_CLASS, type CodeMeta, SHARED_CODE_GROUP_CONTRACT, applyCollapseChrome, codeBlockFrom, codeBlockIn, collapseButtonLabel, expandCollapsedCodeBlocks, highlightCode, highlightCodeSync, isCodeBlockCollapsed, normalizeCodeLanguage, parseCodeMeta, prepareCodeHighlighter, setCodeBlockCollapsed, toggleCodeBlockCollapsed };
@@ -2,6 +2,50 @@
2
2
  import { createHighlighterCore } from "shiki/core";
3
3
  import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
4
4
  import { bundledLanguages, bundledLanguagesAlias } from "shiki/langs";
5
+
6
+ // src/code/collapse.ts
7
+ var CODE_BLOCK_COLLAPSED_CLASS = "is-collapsed";
8
+ function codeBlockFrom(node) {
9
+ if (!node) return null;
10
+ if (node.classList.contains("tn-code-block")) return node;
11
+ return node.closest(".tn-code-block") ?? null;
12
+ }
13
+ function isCodeBlockCollapsed(block) {
14
+ return Boolean(block?.classList.contains(CODE_BLOCK_COLLAPSED_CLASS));
15
+ }
16
+ function codeBlockIn(root) {
17
+ return root?.querySelector(".tn-code-block") ?? null;
18
+ }
19
+ function collapseButtonLabel(collapsed) {
20
+ return collapsed ? "\u5C55\u5F00\u4EE3\u7801" : "\u6536\u8D77\u4EE3\u7801";
21
+ }
22
+ function applyCollapseChrome(button, collapsed) {
23
+ if (!button) return;
24
+ button.classList.toggle(CODE_BLOCK_COLLAPSED_CLASS, collapsed);
25
+ button.setAttribute("aria-expanded", collapsed ? "false" : "true");
26
+ const label = collapseButtonLabel(collapsed);
27
+ button.setAttribute("aria-label", label);
28
+ if (button instanceof HTMLElement) button.title = label;
29
+ }
30
+ function setCodeBlockCollapsed(block, collapsed) {
31
+ if (!block) return;
32
+ block.classList.toggle(CODE_BLOCK_COLLAPSED_CLASS, collapsed);
33
+ applyCollapseChrome(block.querySelector(".tn-code-block__collapse-btn"), collapsed);
34
+ }
35
+ function toggleCodeBlockCollapsed(block) {
36
+ const next = !isCodeBlockCollapsed(block);
37
+ setCodeBlockCollapsed(block, next);
38
+ return next;
39
+ }
40
+ function expandCollapsedCodeBlocks(root) {
41
+ if (!root) return;
42
+ if ("classList" in root && isCodeBlockCollapsed(root)) {
43
+ setCodeBlockCollapsed(root, false);
44
+ }
45
+ root.querySelectorAll(`.tn-code-block.${CODE_BLOCK_COLLAPSED_CLASS}`).forEach((block) => setCodeBlockCollapsed(block, false));
46
+ }
47
+
48
+ // src/code/highlight.ts
5
49
  var SHARED_CODE_GROUP_CONTRACT = {
6
50
  source: [
7
51
  "::: code-group",
@@ -88,13 +132,17 @@ async function prepareCodeHighlighter(languages = []) {
88
132
  );
89
133
  return highlighter;
90
134
  }
91
- function transformers(meta) {
135
+ function lineNumberDigits(source, meta) {
136
+ return String(meta.startLine + source.split("\n").length - 1).length;
137
+ }
138
+ function transformers(meta, gutterDigits) {
92
139
  return [
93
140
  {
94
141
  name: "tnotes-code-meta",
95
142
  pre(node) {
96
143
  this.addClassToHast(node, "tn-code-highlight");
97
144
  node.properties.tabindex = 0;
145
+ node.properties.style = `--tn-line-digits: ${gutterDigits}`;
98
146
  },
99
147
  line(node, line) {
100
148
  node.properties["data-line"] = String(meta.startLine + line - 1);
@@ -106,13 +154,14 @@ function transformers(meta) {
106
154
  function highlightCodeSync(code, info = "") {
107
155
  if (!ready) throw new Error("Call prepareCodeHighlighter before rendering Markdown");
108
156
  const meta = parseCodeMeta(info);
157
+ const source = code.replace(/\n$/, "");
109
158
  const normalizedLanguage = normalizeCodeLanguage(meta.language);
110
159
  const language = ready.getLoadedLanguages().includes(normalizedLanguage) ? normalizedLanguage : "text";
111
- return ready.codeToHtml(code.replace(/\n$/, ""), {
160
+ return ready.codeToHtml(source, {
112
161
  lang: language,
113
162
  themes: { light: "github-light", dark: "github-dark" },
114
163
  defaultColor: false,
115
- transformers: transformers(meta)
164
+ transformers: transformers(meta, lineNumberDigits(source, meta))
116
165
  });
117
166
  }
118
167
  async function highlightCode(code, info = "") {
@@ -120,10 +169,19 @@ async function highlightCode(code, info = "") {
120
169
  return highlightCodeSync(code, info);
121
170
  }
122
171
  export {
172
+ CODE_BLOCK_COLLAPSED_CLASS,
123
173
  SHARED_CODE_GROUP_CONTRACT,
174
+ applyCollapseChrome,
175
+ codeBlockFrom,
176
+ codeBlockIn,
177
+ collapseButtonLabel,
178
+ expandCollapsedCodeBlocks,
124
179
  highlightCode,
125
180
  highlightCodeSync,
181
+ isCodeBlockCollapsed,
126
182
  normalizeCodeLanguage,
127
183
  parseCodeMeta,
128
- prepareCodeHighlighter
184
+ prepareCodeHighlighter,
185
+ setCodeBlockCollapsed,
186
+ toggleCodeBlockCollapsed
129
187
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tnotesjs/ui",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Shared Vue UI for TNotes built-in blocks — consumed by the TNotes SSG and Desk.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -60,6 +60,11 @@
60
60
  "types": "./src/entries/excalidraw-editor.ts",
61
61
  "import": "./src/entries/excalidraw-editor.ts",
62
62
  "default": "./src/entries/excalidraw-editor.ts"
63
+ },
64
+ "./mindmap-editor": {
65
+ "types": "./src/entries/mindmap-editor.ts",
66
+ "import": "./src/entries/mindmap-editor.ts",
67
+ "default": "./src/entries/mindmap-editor.ts"
63
68
  }
64
69
  },
65
70
  "files": [
@@ -77,23 +82,29 @@
77
82
  },
78
83
  "dependencies": {
79
84
  "@excalidraw/excalidraw": "0.18.1",
85
+ "@tnotesjs/mindmap-core": "workspace:^",
80
86
  "marked": "^15.0.11",
81
87
  "mermaid": "^11.17.0",
82
88
  "react": "18.3.1",
83
89
  "react-dom": "18.3.1",
84
90
  "sass": "^1.90.0",
85
- "shiki": "4.4.2",
86
- "@tnotesjs/mindmap-core": "^0.2.2"
91
+ "shiki": "4.4.2"
87
92
  },
88
93
  "devDependencies": {
94
+ "@eslint/js": "^10.0.1",
89
95
  "@types/react": "^18.3.31",
90
96
  "@types/react-dom": "^18.0.0",
91
97
  "@vitejs/plugin-vue": "^6.0.8",
98
+ "eslint": "^10.8.1",
99
+ "eslint-plugin-vue": "^10.10.0",
100
+ "globals": "^17.11.0",
92
101
  "happy-dom": "^20.12.0",
93
102
  "tsup": "8.5.1",
94
103
  "typescript": "^5.9.3",
104
+ "typescript-eslint": "^8.67.0",
95
105
  "vitest": "^4.1.11",
96
- "vue": "^3.5.25"
106
+ "vue": "^3.5.25",
107
+ "vue-eslint-parser": "^10.4.1"
97
108
  },
98
109
  "engines": {
99
110
  "node": ">=18.0.0"
@@ -104,12 +115,12 @@
104
115
  },
105
116
  "repository": {
106
117
  "type": "git",
107
- "url": "git+https://github.com/tnotesjs/tnotesjs.git",
118
+ "url": "git+https://github.com/tnotesjs/tnotes.git",
108
119
  "directory": "packages/ui"
109
120
  },
110
- "homepage": "https://github.com/tnotesjs/tnotesjs/tree/main/packages/ui#readme",
121
+ "homepage": "https://github.com/tnotesjs/tnotes/tree/main/packages/ui#readme",
111
122
  "bugs": {
112
- "url": "https://github.com/tnotesjs/tnotesjs/issues"
123
+ "url": "https://github.com/tnotesjs/tnotes/issues"
113
124
  },
114
125
  "keywords": [
115
126
  "tnotesjs",
@@ -121,6 +132,9 @@
121
132
  "scripts": {
122
133
  "build": "tsup src/code/highlight.ts src/components/Footprints/parse.ts src/components/Mindmap/markdown.ts src/markdown/image.ts --format esm --dts --out-dir dist --clean",
123
134
  "typecheck": "tsc --noEmit -p tsconfig.json",
124
- "test": "vitest run"
135
+ "test": "vitest run",
136
+ "lint": "eslint . --max-warnings=0",
137
+ "lint:fix": "eslint . --fix",
138
+ "prepublishOnly": "pnpm test && pnpm typecheck && pnpm build"
125
139
  }
126
- }
140
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * 代码块折叠:**纯视图状态**,只存在于当前页面的内存里。
3
+ *
4
+ * - 不进 markdown,不写 localStorage:刷新 / 重新进入页面后一律**默认展开**;
5
+ * - 折叠 = 代码内容整体隐藏,只剩标题栏(跟折叠标题一样,不是「露出前几行」);
6
+ * - 默认(未折叠)不做任何高度限制:不管多少行都由内容撑开,没有 max-height、
7
+ * 没有内部纵向滚动,超长单行在代码块内横向滚动;
8
+ * - 每个代码块标题左侧都有折叠 Icon,点击在收起 / 展开之间切换。
9
+ *
10
+ * DOM 是唯一状态源(`is-collapsed` class + `aria-expanded`),因此 SSR 出来的静态
11
+ * HTML 和客户端补强(`hydrateIslands`)以及 Vue 组件本身共用一套契约。
12
+ */
13
+
14
+ /** 收起状态挂在代码块根节点上的 class。 */
15
+ export const CODE_BLOCK_COLLAPSED_CLASS = 'is-collapsed'
16
+
17
+ /** 代码块根节点(`.tn-code-block`)或它内部任意节点 → 根节点。 */
18
+ export function codeBlockFrom(node: Element | null | undefined): HTMLElement | null {
19
+ if (!node) return null
20
+ if (node.classList.contains('tn-code-block')) return node as HTMLElement
21
+ return (node.closest('.tn-code-block') as HTMLElement | null) ?? null
22
+ }
23
+
24
+ export function isCodeBlockCollapsed(block: Element | null | undefined): boolean {
25
+ return Boolean(block?.classList.contains(CODE_BLOCK_COLLAPSED_CLASS))
26
+ }
27
+
28
+ /** 面板 / 容器里的第一个代码块(代码分组的面板就是这种结构)。 */
29
+ export function codeBlockIn(root: ParentNode | null | undefined): HTMLElement | null {
30
+ return root?.querySelector<HTMLElement>('.tn-code-block') ?? null
31
+ }
32
+
33
+ export function collapseButtonLabel(collapsed: boolean): string {
34
+ return collapsed ? '展开代码' : '收起代码'
35
+ }
36
+
37
+ /**
38
+ * 同步折叠按钮的可访问状态。
39
+ *
40
+ * 代码分组把按钮放在 tab 行里(面板自带的那个会被 tab 盖住),两处按钮共用这套 chrome。
41
+ */
42
+ export function applyCollapseChrome(button: Element | null, collapsed: boolean): void {
43
+ if (!button) return
44
+ button.classList.toggle(CODE_BLOCK_COLLAPSED_CLASS, collapsed)
45
+ button.setAttribute('aria-expanded', collapsed ? 'false' : 'true')
46
+ const label = collapseButtonLabel(collapsed)
47
+ button.setAttribute('aria-label', label)
48
+ if (button instanceof HTMLElement) button.title = label
49
+ }
50
+
51
+ /** 设置某个代码块的收起状态(`is-collapsed` 决定内容是否隐藏,见 code.css)。 */
52
+ export function setCodeBlockCollapsed(block: Element | null, collapsed: boolean): void {
53
+ if (!block) return
54
+ block.classList.toggle(CODE_BLOCK_COLLAPSED_CLASS, collapsed)
55
+ applyCollapseChrome(block.querySelector('.tn-code-block__collapse-btn'), collapsed)
56
+ }
57
+
58
+ /** 切换收起状态,返回切换后的状态。 */
59
+ export function toggleCodeBlockCollapsed(block: Element | null): boolean {
60
+ const next = !isCodeBlockCollapsed(block)
61
+ setCodeBlockCollapsed(block, next)
62
+ return next
63
+ }
64
+
65
+ /**
66
+ * 展开 root 里所有被收起的代码块。
67
+ *
68
+ * 代码分组切换 tab 时用它:新露出来的面板如果是收起状态,自动展开,
69
+ * 读者不会只看到一个标题栏。
70
+ */
71
+ export function expandCollapsedCodeBlocks(root: ParentNode | null | undefined): void {
72
+ if (!root) return
73
+ if ('classList' in root && isCodeBlockCollapsed(root as Element)) {
74
+ setCodeBlockCollapsed(root as Element, false)
75
+ }
76
+ root
77
+ .querySelectorAll<HTMLElement>(`.tn-code-block.${CODE_BLOCK_COLLAPSED_CLASS}`)
78
+ .forEach((block) => setCodeBlockCollapsed(block, false))
79
+ }
@@ -2,6 +2,18 @@ import { createHighlighterCore, type HighlighterCore, type ShikiTransformer } fr
2
2
  import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
3
3
  import { bundledLanguages, bundledLanguagesAlias } from 'shiki/langs'
4
4
 
5
+ export {
6
+ CODE_BLOCK_COLLAPSED_CLASS,
7
+ applyCollapseChrome,
8
+ codeBlockFrom,
9
+ codeBlockIn,
10
+ collapseButtonLabel,
11
+ expandCollapsedCodeBlocks,
12
+ isCodeBlockCollapsed,
13
+ setCodeBlockCollapsed,
14
+ toggleCodeBlockCollapsed
15
+ } from './collapse'
16
+
5
17
  export interface CodeMeta {
6
18
  language: string
7
19
  title: string
@@ -115,7 +127,18 @@ export async function prepareCodeHighlighter(languages: string[] = []): Promise<
115
127
  return highlighter
116
128
  }
117
129
 
118
- function transformers(meta: CodeMeta): ShikiTransformer[] {
130
+ /**
131
+ * How many characters the line-number gutter has to hold.
132
+ *
133
+ * Counted from the source up front because the width belongs on the root `<pre>`
134
+ * and has to be set before Shiki visits the lines: sizing each `::before` from
135
+ * its own number would let the code text jump between lines.
136
+ */
137
+ function lineNumberDigits(source: string, meta: CodeMeta): number {
138
+ return String(meta.startLine + source.split('\n').length - 1).length
139
+ }
140
+
141
+ function transformers(meta: CodeMeta, gutterDigits: number): ShikiTransformer[] {
119
142
  // Fence `{N}` line highlights only — match Desk CodeMirror. No Shiki
120
143
  // notation transformers (`[!code ++]` etc.); Desk has no write path for them.
121
144
  return [
@@ -124,6 +147,8 @@ function transformers(meta: CodeMeta): ShikiTransformer[] {
124
147
  pre(node) {
125
148
  this.addClassToHast(node, 'tn-code-highlight')
126
149
  node.properties.tabindex = 0
150
+ // Inherited by every line's `::before`, which is what sizes the gutter.
151
+ node.properties.style = `--tn-line-digits: ${gutterDigits}`
127
152
  },
128
153
  line(node, line) {
129
154
  node.properties['data-line'] = String(meta.startLine + line - 1)
@@ -137,15 +162,16 @@ function transformers(meta: CodeMeta): ShikiTransformer[] {
137
162
  export function highlightCodeSync(code: string, info = ''): string {
138
163
  if (!ready) throw new Error('Call prepareCodeHighlighter before rendering Markdown')
139
164
  const meta = parseCodeMeta(info)
165
+ const source = code.replace(/\n$/, '')
140
166
  const normalizedLanguage = normalizeCodeLanguage(meta.language)
141
167
  const language = ready.getLoadedLanguages().includes(normalizedLanguage)
142
168
  ? normalizedLanguage
143
169
  : 'text'
144
- return ready.codeToHtml(code.replace(/\n$/, ''), {
170
+ return ready.codeToHtml(source, {
145
171
  lang: language,
146
172
  themes: { light: 'github-light', dark: 'github-dark' },
147
173
  defaultColor: false,
148
- transformers: transformers(meta)
174
+ transformers: transformers(meta, lineNumberDigits(source, meta))
149
175
  })
150
176
  }
151
177
 
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, onBeforeUnmount, onServerPrefetch, ref, watch } from 'vue'
3
3
  import { highlightCode, parseCodeMeta } from '../../code/highlight'
4
+ import { toggleCodeBlockCollapsed } from '../../code/collapse'
4
5
  import { copyText } from '../../browser/clipboard'
5
6
 
6
7
  const props = withDefaults(
@@ -14,6 +15,11 @@ const props = withDefaults(
14
15
  { info: '', lineNumbers: true }
15
16
  )
16
17
  const meta = computed(() => parseCodeMeta(props.info, props.lineNumbers))
18
+ /**
19
+ * 折叠是**纯视图状态**:默认展开、不落库、不写 localStorage,刷新后回到展开。
20
+ * 每个代码块标题左侧都有折叠 Icon,点击把代码内容整体隐藏 / 显示。
21
+ */
22
+ const root = ref<HTMLElement>()
17
23
  const html = ref(props.highlightedHtml || '')
18
24
  const error = ref('')
19
25
  const copied = ref(false)
@@ -53,6 +59,9 @@ async function copy(): Promise<void> {
53
59
  error.value = '复制失败,请检查剪贴板权限'
54
60
  }
55
61
  }
62
+ function toggleCollapse(): void {
63
+ toggleCodeBlockCollapsed(root.value ?? null)
64
+ }
56
65
  function closeFullscreen(): void {
57
66
  fullscreen.value = false
58
67
  trigger.value?.focus({ preventScroll: true })
@@ -64,15 +73,38 @@ onBeforeUnmount(() => {
64
73
  </script>
65
74
 
66
75
  <template>
67
- <section class="tn-code-block" :class="{ 'has-line-numbers': meta.lineNumbers }">
76
+ <section ref="root" class="tn-code-block" :class="{ 'has-line-numbers': meta.lineNumbers }">
68
77
  <header class="tn-code-block__header">
78
+ <button
79
+ type="button"
80
+ class="tn-code-block__icon-btn tn-code-block__collapse-btn"
81
+ aria-expanded="true"
82
+ aria-label="收起代码"
83
+ title="收起代码"
84
+ @click="toggleCollapse"
85
+ >
86
+ <svg
87
+ xmlns="http://www.w3.org/2000/svg"
88
+ width="16"
89
+ height="16"
90
+ viewBox="0 0 24 24"
91
+ fill="none"
92
+ stroke="currentColor"
93
+ stroke-width="2"
94
+ stroke-linecap="round"
95
+ stroke-linejoin="round"
96
+ aria-hidden="true"
97
+ >
98
+ <path d="m6 9 6 6 6-6" />
99
+ </svg>
100
+ </button>
69
101
  <span class="tn-code-block__title">{{ title || meta.title }}</span>
70
102
  <slot name="language" :language="meta.language"
71
103
  ><span class="tn-code-block__language">{{ meta.language }}</span></slot
72
104
  >
73
105
  <button
74
106
  type="button"
75
- class="tn-code-block__icon-btn"
107
+ class="tn-code-block__icon-btn tn-code-block__copy-btn"
76
108
  :aria-label="copied ? '已复制' : '复制代码'"
77
109
  @click="copy"
78
110
  >
@@ -111,7 +143,7 @@ onBeforeUnmount(() => {
111
143
  <button
112
144
  ref="trigger"
113
145
  type="button"
114
- class="tn-code-block__icon-btn"
146
+ class="tn-code-block__icon-btn tn-code-block__fullscreen-btn"
115
147
  aria-label="全屏代码"
116
148
  @click="fullscreen = true"
117
149
  >
@@ -135,8 +167,10 @@ onBeforeUnmount(() => {
135
167
  </button>
136
168
  </header>
137
169
  <slot>
138
- <div v-if="html" class="tn-code-block__content" v-html="html" />
139
- <pre v-else class="tn-code-block__plain"><code>{{ code }}</code></pre>
170
+ <div class="tn-code-block__content">
171
+ <div v-if="html" v-html="html" />
172
+ <pre v-else class="tn-code-block__plain"><code>{{ code }}</code></pre>
173
+ </div>
140
174
  </slot>
141
175
  <p v-if="error" role="status" class="tn-code-block__error">{{ error }}</p>
142
176
  <Teleport v-if="fullscreen" to="body">