@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 +81 -0
- package/dist/code/highlight.d.ts +39 -1
- package/dist/code/highlight.js +62 -4
- package/package.json +23 -9
- package/src/code/collapse.ts +79 -0
- package/src/code/highlight.ts +29 -3
- package/src/components/CodeBlock/CodeBlock.vue +39 -5
- package/src/components/CodeGroup/CodeGroup.vue +70 -7
- package/src/components/Mindmap/Mindmap.vue +7 -6
- package/src/components/Mindmap/editor/CanvasContextMenu.vue +77 -68
- package/src/components/Mindmap/editor/CollapseMenu.vue +113 -0
- package/src/components/Mindmap/editor/IconButton.vue +63 -0
- package/src/components/Mindmap/editor/LinkPopover.vue +50 -46
- package/src/components/Mindmap/editor/MarkdownView.vue +5 -3
- package/src/components/Mindmap/editor/MindmapView.vue +11 -1
- package/src/components/Mindmap/editor/SearchBar.vue +672 -0
- package/src/components/Mindmap/editor/SelectionToolbar.vue +1 -1
- package/src/entries/excalidraw-view.ts +5 -0
- package/src/entries/mindmap-editor.ts +37 -0
- package/src/excalidraw/exporter.ts +1 -0
- package/src/excalidraw/fonts.ts +21 -1
- package/src/styles/code.css +82 -4
- package/src/styles/prose.css +45 -0
- package/src/styles/tokens.css +6 -0
- package/src/components/Mindmap/editor/canvasClipboard.ts +0 -2
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
|
+
改公共交互只改这里一处,三个宿主同时生效。
|
package/dist/code/highlight.d.ts
CHANGED
|
@@ -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 };
|
package/dist/code/highlight.js
CHANGED
|
@@ -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
|
|
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(
|
|
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.
|
|
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/
|
|
118
|
+
"url": "git+https://github.com/tnotesjs/tnotes.git",
|
|
108
119
|
"directory": "packages/ui"
|
|
109
120
|
},
|
|
110
|
-
"homepage": "https://github.com/tnotesjs/
|
|
121
|
+
"homepage": "https://github.com/tnotesjs/tnotes/tree/main/packages/ui#readme",
|
|
111
122
|
"bugs": {
|
|
112
|
-
"url": "https://github.com/tnotesjs/
|
|
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
|
+
}
|
package/src/code/highlight.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
139
|
-
|
|
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">
|