@tnotesjs/ui 0.2.1 → 0.3.0
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 +54 -1
- package/package.json +19 -6
- package/src/code/collapse.ts +79 -0
- package/src/code/highlight.ts +12 -0
- 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 +74 -2
- 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",
|
|
@@ -120,10 +164,19 @@ async function highlightCode(code, info = "") {
|
|
|
120
164
|
return highlightCodeSync(code, info);
|
|
121
165
|
}
|
|
122
166
|
export {
|
|
167
|
+
CODE_BLOCK_COLLAPSED_CLASS,
|
|
123
168
|
SHARED_CODE_GROUP_CONTRACT,
|
|
169
|
+
applyCollapseChrome,
|
|
170
|
+
codeBlockFrom,
|
|
171
|
+
codeBlockIn,
|
|
172
|
+
collapseButtonLabel,
|
|
173
|
+
expandCollapsedCodeBlocks,
|
|
124
174
|
highlightCode,
|
|
125
175
|
highlightCodeSync,
|
|
176
|
+
isCodeBlockCollapsed,
|
|
126
177
|
normalizeCodeLanguage,
|
|
127
178
|
parseCodeMeta,
|
|
128
|
-
prepareCodeHighlighter
|
|
179
|
+
prepareCodeHighlighter,
|
|
180
|
+
setCodeBlockCollapsed,
|
|
181
|
+
toggleCodeBlockCollapsed
|
|
129
182
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tnotesjs/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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": [
|
|
@@ -86,14 +91,20 @@
|
|
|
86
91
|
"@tnotesjs/mindmap-core": "^0.2.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,8 @@
|
|
|
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"
|
|
125
138
|
}
|
|
126
139
|
}
|
|
@@ -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
|
|
@@ -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">
|
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
import { nextTick, onMounted, ref, watch } from 'vue'
|
|
3
3
|
import CodeBlock from '../CodeBlock/CodeBlock.vue'
|
|
4
4
|
import { parseCodeMeta } from '../../code/highlight'
|
|
5
|
+
import {
|
|
6
|
+
applyCollapseChrome,
|
|
7
|
+
codeBlockIn,
|
|
8
|
+
expandCollapsedCodeBlocks,
|
|
9
|
+
isCodeBlockCollapsed,
|
|
10
|
+
toggleCodeBlockCollapsed
|
|
11
|
+
} from '../../code/collapse'
|
|
5
12
|
|
|
6
13
|
export interface CodeGroupItem {
|
|
7
14
|
code?: string
|
|
@@ -11,16 +18,47 @@ export interface CodeGroupItem {
|
|
|
11
18
|
}
|
|
12
19
|
const props = defineProps<{ items: CodeGroupItem[] }>()
|
|
13
20
|
const root = ref<HTMLElement>()
|
|
21
|
+
const collapseButton = ref<HTMLButtonElement>()
|
|
14
22
|
const active = ref(0)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
23
|
+
|
|
24
|
+
function panels(): HTMLElement[] {
|
|
25
|
+
return [
|
|
26
|
+
...(root.value?.querySelectorAll<HTMLElement>(
|
|
18
27
|
':scope > .tn-code-group__panels > .tn-code-group__panel'
|
|
19
|
-
) ?? []
|
|
20
|
-
|
|
28
|
+
) ?? [])
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function activePanel(): HTMLElement | null {
|
|
33
|
+
return panels()[active.value] ?? null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function activeBlock(): HTMLElement | null {
|
|
37
|
+
return codeBlockIn(activePanel())
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function toggleCollapse(): void {
|
|
41
|
+
const block = activeBlock()
|
|
42
|
+
if (!block) return
|
|
43
|
+
toggleCodeBlockCollapsed(block)
|
|
44
|
+
applyCollapseChrome(collapseButton.value ?? null, isCodeBlockCollapsed(block))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 面板自带的那颗折叠按钮在分组里会被 tab 行盖住,所以 chrome 挂在 tab 行这颗上。 */
|
|
48
|
+
function syncCollapseButton(): void {
|
|
49
|
+
applyCollapseChrome(collapseButton.value ?? null, isCodeBlockCollapsed(activeBlock()))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function syncPanels(): void {
|
|
53
|
+
const list = panels()
|
|
54
|
+
list.forEach((panel, index) => {
|
|
21
55
|
panel.hidden = index !== active.value
|
|
22
56
|
})
|
|
57
|
+
// 切到的面板如果处于收起状态就自动展开:读者不该看到一片被裁掉的代码。
|
|
58
|
+
expandCollapsedCodeBlocks(activePanel())
|
|
59
|
+
syncCollapseButton()
|
|
23
60
|
}
|
|
61
|
+
|
|
24
62
|
watch(active, syncPanels, { flush: 'post' })
|
|
25
63
|
watch(
|
|
26
64
|
() => props.items.length,
|
|
@@ -30,6 +68,7 @@ watch(
|
|
|
30
68
|
}
|
|
31
69
|
)
|
|
32
70
|
onMounted(syncPanels)
|
|
71
|
+
|
|
33
72
|
function navigate(event: KeyboardEvent, index: number): void {
|
|
34
73
|
let next = index
|
|
35
74
|
if (event.key === 'ArrowRight') next = (index + 1) % props.items.length
|
|
@@ -40,19 +79,43 @@ function navigate(event: KeyboardEvent, index: number): void {
|
|
|
40
79
|
event.preventDefault()
|
|
41
80
|
active.value = next
|
|
42
81
|
;(event.currentTarget as HTMLElement).parentElement
|
|
43
|
-
?.querySelectorAll<HTMLButtonElement>('button')
|
|
82
|
+
?.querySelectorAll<HTMLButtonElement>('button[role="tab"]')
|
|
44
83
|
[next]?.focus()
|
|
45
84
|
}
|
|
46
85
|
</script>
|
|
47
86
|
|
|
48
87
|
<template>
|
|
49
|
-
<section ref="root" class="tn-code-group">
|
|
88
|
+
<section ref="root" class="tn-code-group" :class="{ 'has-tabs': items.length > 1 }">
|
|
50
89
|
<div
|
|
51
90
|
v-if="items.length > 1"
|
|
52
91
|
class="tn-code-group__tabs code-group-tabs"
|
|
53
92
|
role="tablist"
|
|
54
93
|
aria-label="代码分组"
|
|
55
94
|
>
|
|
95
|
+
<button
|
|
96
|
+
ref="collapseButton"
|
|
97
|
+
type="button"
|
|
98
|
+
class="tn-code-block__icon-btn tn-code-group__collapse-btn"
|
|
99
|
+
aria-expanded="true"
|
|
100
|
+
aria-label="收起代码"
|
|
101
|
+
title="收起代码"
|
|
102
|
+
@click="toggleCollapse"
|
|
103
|
+
>
|
|
104
|
+
<svg
|
|
105
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
106
|
+
width="16"
|
|
107
|
+
height="16"
|
|
108
|
+
viewBox="0 0 24 24"
|
|
109
|
+
fill="none"
|
|
110
|
+
stroke="currentColor"
|
|
111
|
+
stroke-width="2"
|
|
112
|
+
stroke-linecap="round"
|
|
113
|
+
stroke-linejoin="round"
|
|
114
|
+
aria-hidden="true"
|
|
115
|
+
>
|
|
116
|
+
<path d="m6 9 6 6 6-6" />
|
|
117
|
+
</svg>
|
|
118
|
+
</button>
|
|
56
119
|
<button
|
|
57
120
|
v-for="(item, index) in items"
|
|
58
121
|
:key="item.key || index"
|
|
@@ -786,19 +786,20 @@ onBeforeUnmount(() => {
|
|
|
786
786
|
transition: opacity 0.2s;
|
|
787
787
|
}
|
|
788
788
|
|
|
789
|
-
/*
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
789
|
+
/* 工具条按需出现:hover、聚焦(键盘 Tab 进来也算)、画布交互中、全屏。
|
|
790
|
+
Desk 的可编辑岛原本额外钉了 `opacity: 1`(旧注释:“chrome must stay hittable”),
|
|
791
|
+
结果每张脑图右上角常驻一排按钮,既像噪声又占掉画布右上角的拖拽区;
|
|
792
|
+
可点击性并没丢 —— 指针进入即 hover 显现,`:focus-within` 保住键盘路径。
|
|
793
|
+
只读岛(站点)本来就是这套行为,这里让两边一致。 */
|
|
795
794
|
.mindmap-preview-tabs {
|
|
796
795
|
display: flex;
|
|
797
796
|
gap: 4px;
|
|
798
797
|
}
|
|
799
798
|
|
|
800
799
|
.mindmap-preview:hover > .mindmap-preview-actions,
|
|
800
|
+
.mindmap-preview:focus-within > .mindmap-preview-actions,
|
|
801
801
|
.mindmap-preview-actions:focus-within,
|
|
802
|
+
.mindmap-preview.is-interaction-active > .mindmap-preview-actions,
|
|
802
803
|
.mindmap-preview.is-fullscreen > .mindmap-preview-actions,
|
|
803
804
|
.mindmap-preview:fullscreen > .mindmap-preview-actions {
|
|
804
805
|
opacity: 1;
|