@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.
- package/README.md +52 -29
- package/dist/code/highlight.d.ts +42 -0
- package/dist/code/highlight.js +138 -0
- package/dist/components/Footprints/parse.d.ts +17 -0
- package/dist/components/Footprints/parse.js +105 -0
- package/dist/components/Mindmap/markdown.d.ts +20 -0
- package/dist/components/Mindmap/markdown.js +60 -0
- package/dist/markdown/image.d.ts +33 -0
- package/dist/markdown/image.js +83 -0
- package/package.json +46 -6
- package/src/browser/clipboard.ts +32 -0
- package/src/browser/theme.ts +22 -0
- package/src/code/highlight.test.ts +55 -0
- package/src/code/highlight.ts +171 -0
- package/src/components/Badge/Badge.vue +43 -0
- package/src/components/CodeBlock/CodeBlock.vue +172 -0
- package/src/components/CodeGroup/CodeGroup.vue +96 -0
- package/src/components/ImagePreview/ImagePreview.vue +315 -0
- package/src/components/Mermaid/Mermaid.test.ts +54 -0
- package/src/components/Mermaid/Mermaid.vue +229 -56
- package/src/components/Mermaid/adoptLiveSvg.test.ts +16 -0
- package/src/components/Mermaid/adoptLiveSvg.ts +15 -0
- package/src/components/Mindmap/Mindmap.vue +2 -0
- package/src/components/sharedMedia.test.ts +139 -0
- package/src/entries/bilibili-video.ts +1 -0
- package/src/entries/footprints.ts +1 -0
- package/src/entries/mermaid.ts +1 -0
- package/src/entries/mindmap.ts +1 -0
- package/src/entries/notes-table.ts +2 -0
- package/src/entries/word-list.ts +1 -0
- package/src/icons/check.svg +3 -0
- package/src/icons/copy.svg +4 -0
- package/src/index.ts +31 -18
- package/src/markdown/image.test.ts +88 -0
- package/src/markdown/image.ts +115 -0
- package/src/styles/code.css +184 -0
- package/src/styles/prose.css +211 -0
- package/src/styles/swiper.css +151 -0
- package/src/styles/tokens.css +54 -18
- package/src/swiper/hydrate.test.ts +65 -0
- 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.
|
|
4
|
-
"description": "Shared Vue UI for TNotes built-in blocks — consumed by
|
|
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
|
-
"./
|
|
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
|
-
"
|
|
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>
|