@tnotesjs/ui 0.1.1 → 0.1.3

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.
Files changed (41) hide show
  1. package/README.md +52 -29
  2. package/dist/code/highlight.d.ts +42 -0
  3. package/dist/code/highlight.js +138 -0
  4. package/dist/components/Footprints/parse.d.ts +17 -0
  5. package/dist/components/Footprints/parse.js +105 -0
  6. package/dist/components/Mindmap/markdown.d.ts +20 -0
  7. package/dist/components/Mindmap/markdown.js +60 -0
  8. package/dist/markdown/image.d.ts +33 -0
  9. package/dist/markdown/image.js +83 -0
  10. package/package.json +46 -6
  11. package/src/browser/clipboard.ts +32 -0
  12. package/src/browser/theme.ts +22 -0
  13. package/src/code/highlight.test.ts +55 -0
  14. package/src/code/highlight.ts +171 -0
  15. package/src/components/Badge/Badge.vue +43 -0
  16. package/src/components/CodeBlock/CodeBlock.vue +172 -0
  17. package/src/components/CodeGroup/CodeGroup.vue +96 -0
  18. package/src/components/ImagePreview/ImagePreview.vue +315 -0
  19. package/src/components/Mermaid/Mermaid.test.ts +54 -0
  20. package/src/components/Mermaid/Mermaid.vue +229 -56
  21. package/src/components/Mermaid/adoptLiveSvg.test.ts +16 -0
  22. package/src/components/Mermaid/adoptLiveSvg.ts +15 -0
  23. package/src/components/Mindmap/Mindmap.vue +2 -0
  24. package/src/components/sharedMedia.test.ts +139 -0
  25. package/src/entries/bilibili-video.ts +1 -0
  26. package/src/entries/footprints.ts +1 -0
  27. package/src/entries/mermaid.ts +1 -0
  28. package/src/entries/mindmap.ts +1 -0
  29. package/src/entries/notes-table.ts +2 -0
  30. package/src/entries/word-list.ts +1 -0
  31. package/src/icons/check.svg +3 -0
  32. package/src/icons/copy.svg +4 -0
  33. package/src/index.ts +31 -18
  34. package/src/markdown/image.test.ts +88 -0
  35. package/src/markdown/image.ts +115 -0
  36. package/src/styles/code.css +184 -0
  37. package/src/styles/prose.css +211 -0
  38. package/src/styles/swiper.css +151 -0
  39. package/src/styles/tokens.css +54 -18
  40. package/src/swiper/hydrate.test.ts +65 -0
  41. package/src/swiper/hydrate.ts +164 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tnotesjs/ui",
3
- "version": "0.1.1",
4
- "description": "Shared Vue UI for TNotes built-in blocks — consumed by VitePress (core) and Desk.",
3
+ "version": "0.1.3",
4
+ "description": "Shared Vue UI for TNotes built-in blocks — consumed by the TNotes SSG and Desk.",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {
@@ -11,12 +11,49 @@
11
11
  },
12
12
  "./footprints-parse": {
13
13
  "types": "./src/components/Footprints/parse.ts",
14
+ "node": "./dist/components/Footprints/parse.js",
14
15
  "import": "./src/components/Footprints/parse.ts",
15
16
  "default": "./src/components/Footprints/parse.ts"
16
17
  },
17
- "./styles/tokens.css": "./src/styles/tokens.css"
18
+ "./mindmap-parse": {
19
+ "types": "./src/components/Mindmap/markdown.ts",
20
+ "node": "./dist/components/Mindmap/markdown.js",
21
+ "import": "./src/components/Mindmap/markdown.ts",
22
+ "default": "./src/components/Mindmap/markdown.ts"
23
+ },
24
+ "./image-markdown": {
25
+ "types": "./src/markdown/image.ts",
26
+ "node": "./dist/markdown/image.js",
27
+ "import": "./src/markdown/image.ts",
28
+ "default": "./src/markdown/image.ts"
29
+ },
30
+ "./styles/tokens.css": "./src/styles/tokens.css",
31
+ "./styles/prose.css": "./src/styles/prose.css",
32
+ "./styles/code.css": "./src/styles/code.css",
33
+ "./styles/swiper.css": "./src/styles/swiper.css",
34
+ "./swiper": {
35
+ "types": "./src/swiper/hydrate.ts",
36
+ "import": "./src/swiper/hydrate.ts",
37
+ "default": "./src/swiper/hydrate.ts"
38
+ },
39
+ "./code": {
40
+ "types": "./src/code/highlight.ts",
41
+ "node": "./dist/code/highlight.js",
42
+ "import": "./src/code/highlight.ts"
43
+ },
44
+ "./code-block": "./src/components/CodeBlock/CodeBlock.vue",
45
+ "./code-group": "./src/components/CodeGroup/CodeGroup.vue",
46
+ "./badge": "./src/components/Badge/Badge.vue",
47
+ "./image-preview": "./src/components/ImagePreview/ImagePreview.vue",
48
+ "./bilibili-video": "./src/entries/bilibili-video.ts",
49
+ "./footprints": "./src/entries/footprints.ts",
50
+ "./mermaid": "./src/entries/mermaid.ts",
51
+ "./mindmap": "./src/entries/mindmap.ts",
52
+ "./notes-table": "./src/entries/notes-table.ts",
53
+ "./word-list": "./src/entries/word-list.ts"
18
54
  },
19
55
  "files": [
56
+ "dist",
20
57
  "src",
21
58
  "README.md",
22
59
  "LICENSE"
@@ -30,12 +67,14 @@
30
67
  "dependencies": {
31
68
  "@tnotesjs/mindmap-core": "^0.2.2",
32
69
  "marked": "^15.0.11",
33
- "mermaid": "^11.17.0"
70
+ "mermaid": "^11.17.0",
71
+ "shiki": "4.4.2"
34
72
  },
35
73
  "devDependencies": {
36
74
  "@vitejs/plugin-vue": "^6.0.8",
37
75
  "happy-dom": "^20.12.0",
38
76
  "sass": "^1.90.0",
77
+ "tsup": "8.5.1",
39
78
  "typescript": "^5.9.3",
40
79
  "vitest": "^4.1.11",
41
80
  "vue": "^3.5.25"
@@ -57,14 +96,15 @@
57
96
  "keywords": [
58
97
  "tnotesjs",
59
98
  "vue",
60
- "vitepress",
99
+ "ssg",
61
100
  "desk"
62
101
  ],
63
102
  "license": "MIT",
64
103
  "packageManager": "pnpm@11.10.0",
65
104
  "scripts": {
105
+ "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",
66
106
  "typecheck": "tsc --noEmit -p tsconfig.json",
67
107
  "test": "vitest run",
68
- "prepublishOnly": "pnpm test && pnpm typecheck"
108
+ "prepublishOnly": "pnpm test && pnpm typecheck && pnpm build"
69
109
  }
70
110
  }
@@ -0,0 +1,32 @@
1
+ /** Clipboard fallback for Electron and non-secure local previews. */
2
+ export async function copyText(text: string): Promise<void> {
3
+ try {
4
+ if (navigator.clipboard?.writeText) {
5
+ await navigator.clipboard.writeText(text);
6
+ return;
7
+ }
8
+ } catch {
9
+ /* use the synchronous user-gesture fallback */
10
+ }
11
+ const selection = document.getSelection();
12
+ const ranges = selection
13
+ ? Array.from({ length: selection.rangeCount }, (_, i) =>
14
+ selection.getRangeAt(i),
15
+ )
16
+ : [];
17
+ const active = document.activeElement;
18
+ const field = document.createElement("textarea");
19
+ field.value = text;
20
+ field.style.cssText = "position:fixed;left:-9999px;opacity:0";
21
+ document.body.append(field);
22
+ try {
23
+ field.select();
24
+ if (!document.execCommand("copy"))
25
+ throw new Error("Clipboard access denied");
26
+ } finally {
27
+ field.remove();
28
+ if (active instanceof HTMLElement) active.focus({ preventScroll: true });
29
+ selection?.removeAllRanges();
30
+ ranges.forEach((range) => selection?.addRange(range));
31
+ }
32
+ }
@@ -0,0 +1,22 @@
1
+ export type ThemePreference = "light" | "dark" | "system";
2
+
3
+ /** Explicit data-theme wins; safe during static/SSR rendering. */
4
+ export function isDarkTheme(root?: HTMLElement): boolean {
5
+ root ??=
6
+ typeof document === "undefined" ? undefined : document.documentElement;
7
+ if (!root) return false;
8
+ if (root.dataset.theme === "light") return false;
9
+ return root.dataset.theme === "dark" || root.classList.contains("dark");
10
+ }
11
+
12
+ export function applyTheme(
13
+ preference: ThemePreference,
14
+ root = document.documentElement,
15
+ ): void {
16
+ const dark =
17
+ preference === "dark" ||
18
+ (preference === "system" &&
19
+ matchMedia("(prefers-color-scheme: dark)").matches);
20
+ root.dataset.theme = dark ? "dark" : "light";
21
+ root.classList.toggle("dark", dark);
22
+ }
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { highlightCode, highlightCodeSync, parseCodeMeta, prepareCodeHighlighter } from './highlight'
3
+
4
+ describe('shared code rendering', () => {
5
+ it('parses titles, line offsets, highlight ranges and per-block overrides', () => {
6
+ expect(parseCodeMeta('ts:line-numbers=12 {1,3-5} [demo.ts]')).toEqual({
7
+ language: 'ts', title: 'demo.ts', startLine: 12, lineNumbers: true,
8
+ highlightedLines: [1, 3, 4, 5]
9
+ })
10
+ expect(parseCodeMeta('js {1-2} [setup.js]')).toEqual({
11
+ language: 'js', title: 'setup.js', startLine: 1, lineNumbers: true,
12
+ highlightedLines: [1, 2]
13
+ })
14
+ expect(parseCodeMeta('txt {3}')).toEqual({
15
+ language: 'txt', title: '', startLine: 1, lineNumbers: true,
16
+ highlightedLines: [3]
17
+ })
18
+ expect(parseCodeMeta('text:no-line-numbers').lineNumbers).toBe(false)
19
+ expect(parseCodeMeta('text {0,-1,8-3,1-9999999}').highlightedLines).toEqual([])
20
+ })
21
+ it('shares one lazy engine and matches build-time/browser output byte for byte', async () => {
22
+ const [a, b] = await Promise.all([prepareCodeHighlighter(['js']), prepareCodeHighlighter(['typescript'])])
23
+ expect(a).toBe(b)
24
+ const source = 'const answer = 42\nconsole.log(answer)\n'
25
+ const runtime = await highlightCode(source, 'js {2}')
26
+ expect(runtime).toBe(highlightCodeSync(source, 'js {2}'))
27
+ expect(runtime).toContain('--shiki-light:')
28
+ expect(runtime).toContain('--shiki-dark:')
29
+ expect(runtime).toContain('class="line highlighted"')
30
+ expect(runtime).toContain('data-line="2"')
31
+ expect(a.getLoadedLanguages()).not.toContain('cobol')
32
+ })
33
+ it('escapes unknown-language code instead of executing HTML', async () => {
34
+ const html = await highlightCode('<script>alert(1)</script> {{ injected }}', 'unknown-label')
35
+ expect(html).not.toContain('<script>')
36
+ const host = document.createElement('div')
37
+ host.innerHTML = html
38
+ expect(host.querySelector('script')).toBeNull()
39
+ expect(host.textContent).toBe('<script>alert(1)</script> {{ injected }}')
40
+ expect(html).toContain('{{ injected }}')
41
+ })
42
+ it('applies fence line highlights and offsets without Shiki notation', async () => {
43
+ const withNotation = await highlightCode(
44
+ 'const x = 1 // [!code ++]\nconst y = 2 // [!code focus]',
45
+ 'js:line-numbers=5 {2}',
46
+ )
47
+ // Magic comments stay as source text; Desk has no annotation write path.
48
+ expect(withNotation).toContain('[!code ++]')
49
+ expect(withNotation).not.toContain('diff add')
50
+ expect(withNotation).not.toContain('focused')
51
+ expect(withNotation).toContain('data-line="5"')
52
+ expect(withNotation).toContain('data-line="6"')
53
+ expect(withNotation).toContain('class="line highlighted"')
54
+ })
55
+ })
@@ -0,0 +1,171 @@
1
+ import {
2
+ createHighlighterCore,
3
+ type HighlighterCore,
4
+ type ShikiTransformer,
5
+ } from "shiki/core";
6
+ import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
7
+ import { bundledLanguages, bundledLanguagesAlias } from "shiki/langs";
8
+
9
+ export interface CodeMeta {
10
+ language: string;
11
+ title: string;
12
+ lineNumbers: boolean;
13
+ startLine: number;
14
+ highlightedLines: number[];
15
+ }
16
+
17
+ /**
18
+ * A single cross-repository fixture. Core and Desk both consume this contract in
19
+ * their own test suites, so a parser or DOM change cannot silently diverge.
20
+ */
21
+ export const SHARED_CODE_GROUP_CONTRACT = {
22
+ source: [
23
+ "::: code-group",
24
+ "```js:line-numbers=4 {2} [one.js]",
25
+ "console.log(1)",
26
+ "console.log(2)",
27
+ "```",
28
+ "```ts:no-line-numbers [two.ts]",
29
+ "const value: number = 2",
30
+ "```",
31
+ ":::",
32
+ ].join("\n"),
33
+ items: [
34
+ {
35
+ info: "js:line-numbers=4 {2} [one.js]",
36
+ code: "console.log(1)\nconsole.log(2)",
37
+ title: "one.js",
38
+ language: "js",
39
+ lineNumbers: true,
40
+ startLine: 4,
41
+ highlightedLines: [2],
42
+ },
43
+ {
44
+ info: "ts:no-line-numbers [two.ts]",
45
+ code: "const value: number = 2",
46
+ title: "two.ts",
47
+ language: "ts",
48
+ lineNumbers: false,
49
+ startLine: 1,
50
+ highlightedLines: [],
51
+ },
52
+ ],
53
+ } as const;
54
+
55
+ export function parseCodeMeta(info = "", defaultLineNumbers = true): CodeMeta {
56
+ const language =
57
+ info
58
+ .trim()
59
+ .match(/^[^\s{\[]+/)?.[0]
60
+ ?.split(":")[0] || "text";
61
+ const highlightedLines = new Set<number>();
62
+ const ranges = info.match(/\{([\d,\s-]+)\}/)?.[1] || "";
63
+ for (const range of ranges.split(",")) {
64
+ const match = range.trim().match(/^(\d+)(?:-(\d+))?$/);
65
+ if (!match) continue;
66
+ const start = Number(match[1]);
67
+ const end = Number(match[2] ?? start);
68
+ if (start < 1 || end < start || end - start > 10000) continue;
69
+ for (let line = start; line <= end; line++) highlightedLines.add(line);
70
+ }
71
+ return {
72
+ language,
73
+ title: info.match(/\[([^\]]*)\]/)?.[1]?.trim() || "",
74
+ lineNumbers: /:no-line-numbers\b/.test(info)
75
+ ? false
76
+ : /:line-numbers\b/.test(info) || defaultLineNumbers,
77
+ startLine: Math.max(1, Number(info.match(/:line-numbers=(\d+)/)?.[1] || 1)),
78
+ highlightedLines: [...highlightedLines].sort((a, b) => a - b),
79
+ };
80
+ }
81
+
82
+ let singleton: Promise<HighlighterCore> | undefined;
83
+ let ready: HighlighterCore | undefined;
84
+ const loading = new Map<string, Promise<void>>();
85
+
86
+ export function normalizeCodeLanguage(language: string): string {
87
+ const id = language.toLowerCase();
88
+ if (id === "plain" || id === "txt" || id === "plaintext" || !id)
89
+ return "text";
90
+ if (id in bundledLanguages || id in bundledLanguagesAlias) return id;
91
+ // Unknown labels retain their caption, but display safely as plain text.
92
+ return "text";
93
+ }
94
+
95
+ /** One engine and one grammar cache for build-time and browser consumers. */
96
+ export async function prepareCodeHighlighter(
97
+ languages: string[] = [],
98
+ ): Promise<HighlighterCore> {
99
+ singleton ??= createHighlighterCore({
100
+ engine: createJavaScriptRegexEngine(),
101
+ themes: [
102
+ import("shiki/themes/github-light.mjs"),
103
+ import("shiki/themes/github-dark.mjs"),
104
+ ],
105
+ langs: [],
106
+ }).then((highlighter) => (ready = highlighter));
107
+ const highlighter = await singleton;
108
+ await Promise.all(
109
+ languages.map(async (requested) => {
110
+ const language = normalizeCodeLanguage(requested);
111
+ if (
112
+ language === "text" ||
113
+ highlighter.getLoadedLanguages().includes(language)
114
+ )
115
+ return;
116
+ let pending = loading.get(language);
117
+ if (!pending) {
118
+ const loader =
119
+ bundledLanguages[language as keyof typeof bundledLanguages] ??
120
+ bundledLanguagesAlias[language as keyof typeof bundledLanguagesAlias];
121
+ if (!loader) return;
122
+ pending = highlighter.loadLanguage(loader).then(() => undefined);
123
+ loading.set(language, pending);
124
+ pending.catch(() => loading.delete(language));
125
+ }
126
+ await pending;
127
+ }),
128
+ );
129
+ return highlighter;
130
+ }
131
+
132
+ function transformers(meta: CodeMeta): ShikiTransformer[] {
133
+ // Fence `{N}` line highlights only — match Desk CodeMirror. No Shiki
134
+ // notation transformers (`[!code ++]` etc.); Desk has no write path for them.
135
+ return [
136
+ {
137
+ name: "tnotes-code-meta",
138
+ pre(node) {
139
+ this.addClassToHast(node, "tn-code-highlight");
140
+ node.properties.tabindex = 0;
141
+ },
142
+ line(node, line) {
143
+ node.properties["data-line"] = String(meta.startLine + line - 1);
144
+ if (meta.highlightedLines.includes(line))
145
+ this.addClassToHast(node, "highlighted");
146
+ },
147
+ },
148
+ ];
149
+ }
150
+
151
+ /** Markdown renderers prewarm document languages before their synchronous render pass. */
152
+ export function highlightCodeSync(code: string, info = ""): string {
153
+ if (!ready)
154
+ throw new Error("Call prepareCodeHighlighter before rendering Markdown");
155
+ const meta = parseCodeMeta(info);
156
+ const normalizedLanguage = normalizeCodeLanguage(meta.language);
157
+ const language = ready.getLoadedLanguages().includes(normalizedLanguage)
158
+ ? normalizedLanguage
159
+ : "text";
160
+ return ready.codeToHtml(code.replace(/\n$/, ""), {
161
+ lang: language,
162
+ themes: { light: "github-light", dark: "github-dark" },
163
+ defaultColor: false,
164
+ transformers: transformers(meta),
165
+ });
166
+ }
167
+
168
+ export async function highlightCode(code: string, info = ""): Promise<string> {
169
+ await prepareCodeHighlighter([parseCodeMeta(info).language]);
170
+ return highlightCodeSync(code, info);
171
+ }
@@ -0,0 +1,43 @@
1
+ <script setup lang="ts">
2
+ withDefaults(
3
+ defineProps<{
4
+ text?: string;
5
+ type?: "info" | "tip" | "warning" | "danger";
6
+ }>(),
7
+ { text: "", type: "info" },
8
+ );
9
+ </script>
10
+
11
+ <template>
12
+ <span class="tn-badge" :class="`tn-badge--${type}`"
13
+ ><slot>{{ text }}</slot></span
14
+ >
15
+ </template>
16
+
17
+ <style scoped>
18
+ .tn-badge {
19
+ display: inline-block;
20
+ border: 1px solid transparent;
21
+ border-radius: 5px;
22
+ padding: 1px 6px;
23
+ font: 600 12px/18px var(--tn-font-sans);
24
+ vertical-align: 1px;
25
+ }
26
+
27
+ .tn-badge--info,
28
+ .tn-badge--tip {
29
+ border-color: color-mix(in srgb, var(--tn-c-brand) 40%, transparent);
30
+ background: color-mix(in srgb, var(--tn-c-brand) 12%, transparent);
31
+ color: var(--tn-c-brand-strong);
32
+ }
33
+
34
+ .tn-badge--warning {
35
+ background: var(--tn-c-warning-soft);
36
+ color: var(--tn-c-warning);
37
+ }
38
+
39
+ .tn-badge--danger {
40
+ background: var(--tn-c-danger-soft);
41
+ color: var(--tn-c-danger);
42
+ }
43
+ </style>
@@ -0,0 +1,172 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, onServerPrefetch, ref, watch } from "vue";
3
+ import { highlightCode, parseCodeMeta } from "../../code/highlight";
4
+ import { copyText } from "../../browser/clipboard";
5
+
6
+ const props = withDefaults(
7
+ defineProps<{
8
+ code: string;
9
+ info?: string;
10
+ highlightedHtml?: string;
11
+ lineNumbers?: boolean;
12
+ title?: string;
13
+ }>(),
14
+ { info: "", lineNumbers: true },
15
+ );
16
+ const meta = computed(() => parseCodeMeta(props.info, props.lineNumbers));
17
+ const html = ref(props.highlightedHtml || "");
18
+ const error = ref("");
19
+ const copied = ref(false);
20
+ const fullscreen = ref(false);
21
+ const trigger = ref<HTMLButtonElement>();
22
+ let generation = 0;
23
+ let copyTimer: ReturnType<typeof setTimeout> | undefined;
24
+
25
+ async function renderCode(): Promise<void> {
26
+ const version = ++generation;
27
+ error.value = "";
28
+ if (props.highlightedHtml !== undefined) {
29
+ html.value = props.highlightedHtml;
30
+ return;
31
+ }
32
+ try {
33
+ const output = await highlightCode(props.code, props.info);
34
+ if (version === generation) html.value = output;
35
+ } catch (cause) {
36
+ if (version === generation) {
37
+ html.value = "";
38
+ error.value = `高亮失败:${cause instanceof Error ? cause.message : String(cause)}`;
39
+ }
40
+ }
41
+ }
42
+ watch(() => [props.code, props.info, props.highlightedHtml], renderCode, {
43
+ immediate: true,
44
+ });
45
+ onServerPrefetch(renderCode);
46
+ async function copy(): Promise<void> {
47
+ try {
48
+ await copyText(props.code.replace(/\n$/, ""));
49
+ copied.value = true;
50
+ clearTimeout(copyTimer);
51
+ copyTimer = setTimeout(() => (copied.value = false), 1500);
52
+ } catch {
53
+ error.value = "复制失败,请检查剪贴板权限";
54
+ }
55
+ }
56
+ function closeFullscreen(): void {
57
+ fullscreen.value = false;
58
+ trigger.value?.focus({ preventScroll: true });
59
+ }
60
+ onBeforeUnmount(() => {
61
+ generation++;
62
+ clearTimeout(copyTimer);
63
+ });
64
+ </script>
65
+
66
+ <template>
67
+ <section
68
+ class="tn-code-block"
69
+ :class="{ 'has-line-numbers': meta.lineNumbers }"
70
+ >
71
+ <header class="tn-code-block__header">
72
+ <span class="tn-code-block__title">{{ title || meta.title }}</span>
73
+ <slot name="language" :language="meta.language"
74
+ ><span class="tn-code-block__language">{{ meta.language }}</span></slot
75
+ >
76
+ <button
77
+ type="button"
78
+ class="tn-code-block__icon-btn"
79
+ :aria-label="copied ? '已复制' : '复制代码'"
80
+ @click="copy"
81
+ >
82
+ <svg
83
+ v-if="copied"
84
+ xmlns="http://www.w3.org/2000/svg"
85
+ width="16"
86
+ height="16"
87
+ viewBox="0 0 24 24"
88
+ fill="none"
89
+ stroke="currentColor"
90
+ stroke-width="2"
91
+ stroke-linecap="round"
92
+ stroke-linejoin="round"
93
+ aria-hidden="true"
94
+ >
95
+ <polyline points="20 6 9 17 4 12" />
96
+ </svg>
97
+ <svg
98
+ v-else
99
+ xmlns="http://www.w3.org/2000/svg"
100
+ width="16"
101
+ height="16"
102
+ viewBox="0 0 24 24"
103
+ fill="none"
104
+ stroke="currentColor"
105
+ stroke-width="2"
106
+ stroke-linecap="round"
107
+ stroke-linejoin="round"
108
+ aria-hidden="true"
109
+ >
110
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
111
+ <path
112
+ d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
113
+ />
114
+ </svg>
115
+ </button>
116
+ <button
117
+ ref="trigger"
118
+ type="button"
119
+ class="tn-code-block__icon-btn"
120
+ aria-label="全屏代码"
121
+ @click="fullscreen = true"
122
+ >
123
+ <svg
124
+ xmlns="http://www.w3.org/2000/svg"
125
+ width="16"
126
+ height="16"
127
+ viewBox="0 0 24 24"
128
+ fill="none"
129
+ stroke="currentColor"
130
+ stroke-width="2"
131
+ stroke-linecap="round"
132
+ stroke-linejoin="round"
133
+ aria-hidden="true"
134
+ >
135
+ <path d="M8 3H5a2 2 0 0 0-2 2v3" />
136
+ <path d="M21 8V5a2 2 0 0 0-2-2h-3" />
137
+ <path d="M3 16v3a2 2 0 0 0 2 2h3" />
138
+ <path d="M16 21h3a2 2 0 0 0 2-2v-3" />
139
+ </svg>
140
+ </button>
141
+ </header>
142
+ <slot>
143
+ <div v-if="html" class="tn-code-block__content" v-html="html" />
144
+ <pre v-else class="tn-code-block__plain"><code>{{ code }}</code></pre>
145
+ </slot>
146
+ <p v-if="error" role="status" class="tn-code-block__error">{{ error }}</p>
147
+ <Teleport v-if="fullscreen" to="body">
148
+ <div
149
+ class="tn-code-fullscreen"
150
+ role="dialog"
151
+ aria-modal="true"
152
+ aria-label="代码全屏预览"
153
+ tabindex="-1"
154
+ @keydown.esc.stop.prevent="closeFullscreen"
155
+ >
156
+ <header>
157
+ <span>{{ title || meta.title || meta.language }}</span
158
+ ><button type="button" autofocus @click="closeFullscreen">
159
+ 关闭(Esc)
160
+ </button>
161
+ </header>
162
+ <div
163
+ class="tn-code-block"
164
+ :class="{ 'has-line-numbers': meta.lineNumbers }"
165
+ >
166
+ <div v-if="html" class="tn-code-block__content" v-html="html" />
167
+ <pre v-else><code>{{ code }}</code></pre>
168
+ </div>
169
+ </div>
170
+ </Teleport>
171
+ </section>
172
+ </template>
@@ -0,0 +1,96 @@
1
+ <script setup lang="ts">
2
+ import { nextTick, onMounted, ref, watch } from "vue";
3
+ import CodeBlock from "../CodeBlock/CodeBlock.vue";
4
+ import { parseCodeMeta } from "../../code/highlight";
5
+
6
+ export interface CodeGroupItem {
7
+ code?: string;
8
+ info?: string;
9
+ highlightedHtml?: string;
10
+ key?: string;
11
+ }
12
+ const props = defineProps<{ items: CodeGroupItem[] }>();
13
+ const root = ref<HTMLElement>();
14
+ const active = ref(0);
15
+ function syncPanels(): void {
16
+ const panels =
17
+ root.value?.querySelectorAll<HTMLElement>(
18
+ ":scope > .tn-code-group__panels > .tn-code-group__panel",
19
+ ) ?? [];
20
+ panels.forEach((panel, index) => {
21
+ panel.hidden = index !== active.value;
22
+ });
23
+ }
24
+ watch(active, syncPanels, { flush: "post" });
25
+ watch(
26
+ () => props.items.length,
27
+ (length) => {
28
+ active.value = Math.max(0, Math.min(active.value, length - 1));
29
+ nextTick(syncPanels);
30
+ },
31
+ );
32
+ onMounted(syncPanels);
33
+ function navigate(event: KeyboardEvent, index: number): void {
34
+ let next = index;
35
+ if (event.key === "ArrowRight") next = (index + 1) % props.items.length;
36
+ else if (event.key === "ArrowLeft")
37
+ next = (index - 1 + props.items.length) % props.items.length;
38
+ else if (event.key === "Home") next = 0;
39
+ else if (event.key === "End") next = props.items.length - 1;
40
+ else return;
41
+ event.preventDefault();
42
+ active.value = next;
43
+ (event.currentTarget as HTMLElement).parentElement
44
+ ?.querySelectorAll<HTMLButtonElement>("button")
45
+ [next]?.focus();
46
+ }
47
+ </script>
48
+
49
+ <template>
50
+ <section ref="root" class="tn-code-group">
51
+ <div
52
+ v-if="items.length > 1"
53
+ class="tn-code-group__tabs code-group-tabs"
54
+ role="tablist"
55
+ aria-label="代码分组"
56
+ >
57
+ <button
58
+ v-for="(item, index) in items"
59
+ :key="item.key || index"
60
+ type="button"
61
+ role="tab"
62
+ class="code-group-tab"
63
+ :class="{ active: active === index }"
64
+ :aria-selected="active === index"
65
+ :tabindex="active === index ? 0 : -1"
66
+ @click="active = index"
67
+ @keydown="navigate($event, index)"
68
+ >
69
+ {{
70
+ parseCodeMeta(item.info).title ||
71
+ parseCodeMeta(item.info).language ||
72
+ `代码 ${index + 1}`
73
+ }}
74
+ </button>
75
+ </div>
76
+ <div class="tn-code-group__panels code-group-panels">
77
+ <slot>
78
+ <div
79
+ v-for="(item, index) in items"
80
+ v-show="active === index"
81
+ :key="item.key || index"
82
+ class="tn-code-group__panel code-group-panel"
83
+ :class="{ active: active === index }"
84
+ role="tabpanel"
85
+ >
86
+ <slot name="panel" :item="item" :index="index"
87
+ ><CodeBlock
88
+ :code="item.code || ''"
89
+ :info="item.info"
90
+ :highlighted-html="item.highlightedHtml"
91
+ /></slot>
92
+ </div>
93
+ </slot>
94
+ </div>
95
+ </section>
96
+ </template>