@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
@@ -0,0 +1,151 @@
1
+ /* Shared swiper chrome for site preview and Desk (tokenized with --tn-*). */
2
+
3
+ .tn-swiper {
4
+ margin: 1rem 0;
5
+ overflow: visible;
6
+ border-radius: 8px;
7
+ box-shadow: 0 2px 8px rgb(0 0 0 / 0.18);
8
+ background: transparent;
9
+ padding: 0;
10
+ border: none;
11
+ }
12
+
13
+ .tn-swiper:hover {
14
+ box-shadow: 0 4px 16px rgb(0 0 0 / 0.28);
15
+ }
16
+
17
+ .tn-swiper-tabs {
18
+ position: relative;
19
+ z-index: 2;
20
+ display: flex;
21
+ gap: 0.5rem;
22
+ overflow-x: auto;
23
+ overflow-y: hidden;
24
+ border-bottom: 1px solid var(--tn-c-divider);
25
+ border-radius: 8px 8px 0 0;
26
+ background: var(--tn-c-bg-soft);
27
+ box-shadow: inset 0 -1px var(--tn-c-divider);
28
+ }
29
+
30
+ .tn-swiper-tabs::-webkit-scrollbar {
31
+ height: 4px;
32
+ }
33
+
34
+ .tn-tab-nav {
35
+ position: absolute;
36
+ top: 50%;
37
+ transform: translateY(-50%);
38
+ padding: 0 0.3rem;
39
+ border: none;
40
+ border-radius: 4px;
41
+ background: transparent;
42
+ color: var(--tn-c-text-2);
43
+ cursor: pointer;
44
+ font-size: 0.8rem;
45
+ line-height: 1;
46
+ user-select: none;
47
+ transition:
48
+ color 0.2s ease,
49
+ transform 0.2s ease,
50
+ font-size 0.2s ease;
51
+ }
52
+
53
+ .tn-tab-prev {
54
+ left: 0.5rem;
55
+ }
56
+
57
+ .tab-tab-line {
58
+ left: 1.1rem;
59
+ color: var(--tn-c-brand-strong, var(--tn-c-brand));
60
+ cursor: default;
61
+ opacity: 0.5;
62
+ }
63
+
64
+ .tn-tab-next {
65
+ left: 1.5rem;
66
+ }
67
+
68
+ .tn-tab-prev:hover,
69
+ .tn-tab-next:hover {
70
+ color: var(--tn-c-brand-strong, var(--tn-c-brand));
71
+ font-size: 1rem;
72
+ transform: translateY(-50%) scale(1.5);
73
+ }
74
+
75
+ .tn-tab-nav:active {
76
+ transform: translateY(-50%) scale(0.95);
77
+ }
78
+
79
+ .tn-swiper .tn-tab {
80
+ position: relative;
81
+ flex: none;
82
+ padding: 0.25rem 0.75rem;
83
+ border: 0;
84
+ border-bottom: 1px solid transparent;
85
+ background: transparent;
86
+ color: var(--tn-c-text-2);
87
+ cursor: pointer;
88
+ font-size: 0.875rem;
89
+ line-height: 2rem;
90
+ white-space: nowrap;
91
+ transition:
92
+ color 0.2s ease,
93
+ transform 0.2s ease;
94
+ }
95
+
96
+ .tn-swiper .tn-tab::after {
97
+ position: absolute;
98
+ right: 8px;
99
+ bottom: -1px;
100
+ left: 8px;
101
+ z-index: 1;
102
+ height: 2px;
103
+ border-radius: 2px;
104
+ content: "";
105
+ background-color: transparent;
106
+ transition: background-color 0.2s ease;
107
+ }
108
+
109
+ .tn-swiper .tn-tab:hover {
110
+ color: var(--tn-c-text);
111
+ transform: translateY(-1px);
112
+ }
113
+
114
+ .tn-swiper .tn-tab.active {
115
+ color: var(--tn-c-text);
116
+ font-weight: 500;
117
+ }
118
+
119
+ .tn-swiper .tn-tab.active::after {
120
+ background-color: var(--tn-c-brand-strong, var(--tn-c-brand));
121
+ }
122
+
123
+ .tn-swiper .swiper-container {
124
+ position: relative;
125
+ width: 100%;
126
+ overflow: hidden;
127
+ margin: 0;
128
+ border-radius: 0 0 8px 8px;
129
+ background: var(--tn-c-bg);
130
+ }
131
+
132
+ .tn-swiper .swiper-wrapper {
133
+ display: block;
134
+ }
135
+
136
+ .tn-swiper .swiper-slide {
137
+ display: none;
138
+ }
139
+
140
+ .tn-swiper .swiper-slide.is-active,
141
+ .tn-swiper .swiper-slide:not([hidden]).is-active {
142
+ display: block;
143
+ }
144
+
145
+ .tn-swiper .swiper-slide img {
146
+ display: block;
147
+ max-width: 100%;
148
+ height: auto;
149
+ margin: 0 auto;
150
+ object-fit: contain;
151
+ }
@@ -1,35 +1,71 @@
1
- /**
2
- * Host-neutral design tokens for @tnotesjs/ui.
3
- * VitePress themes should alias these to --vp-*; Desk aliases to --editor-*.
4
- */
1
+ /* Canonical palette: hosts map their variables TO these tokens, never back. */
5
2
  :root {
6
- --tn-c-brand: #3451b2;
7
- --tn-c-text: #213547;
8
- --tn-c-text-2: #476582;
3
+ --tn-c-brand: #646cff;
4
+ --tn-c-brand-hover: #747bff;
5
+ --tn-c-brand-strong: #3451b2;
6
+ --tn-c-text: #3c3c43;
7
+ --tn-c-text-2: #67676c;
9
8
  --tn-c-bg: #ffffff;
10
9
  --tn-c-bg-soft: #f6f6f7;
11
10
  --tn-c-bg-elv: #ffffff;
11
+ --tn-c-bg-chrome: #f6f6f7;
12
+ --tn-c-bg-input: #f6f6f7;
12
13
  --tn-c-divider: #e2e2e3;
13
- --tn-c-danger: #cb2a2a;
14
- --tn-c-success-soft: #e3f9e5;
15
- --tn-c-danger-soft: #fde8e8;
14
+ --tn-c-divider-strong: #c2c2c4;
15
+ --tn-c-hover: rgba(142, 150, 170, 0.14);
16
+ --tn-c-highlight: rgba(100, 108, 255, 0.16);
17
+ --tn-c-brand-soft: var(--tn-c-highlight);
18
+ --tn-c-success: #18794e;
19
+ --tn-c-warning: #915930;
20
+ --tn-c-danger: #b8272c;
21
+ --tn-c-success-soft: rgba(16, 185, 129, 0.14);
22
+ --tn-c-warning-soft: rgba(234, 179, 8, 0.14);
23
+ --tn-c-danger-soft: rgba(244, 63, 94, 0.14);
16
24
  --tn-c-default-soft: #e8e8e9;
17
25
  --tn-shadow-2: 0 4px 12px rgba(0, 0, 0, 0.12);
18
- --tn-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
26
+ --tn-font-sans:
27
+ "Punctuation SC", Inter, ui-sans-serif, system-ui, sans-serif,
28
+ "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
29
+ --tn-font-mono:
30
+ "SFMono-Regular", "Cascadia Code", "Roboto Mono", Consolas, monospace;
31
+ --tn-prose-size: 16px;
32
+ --tn-prose-line-height: 28px;
33
+ --tn-heading-weight: 600;
34
+ --tn-heading-1-size: 32px;
35
+ --tn-heading-1-line: 40px;
36
+ --tn-heading-2-size: 24px;
37
+ --tn-heading-2-line: 32px;
38
+ --tn-heading-3-size: 20px;
39
+ --tn-heading-3-line: 28px;
40
+ --tn-heading-4-size: 18px;
41
+ --tn-heading-4-line: 24px;
42
+ --tn-heading-5-size: 16px;
43
+ --tn-heading-5-line: 24px;
44
+ --tn-heading-6-size: 16px;
45
+ --tn-heading-6-line: 24px;
46
+ color-scheme: light;
19
47
  }
20
48
 
21
- html.dark,
22
- html[data-theme='dark'] {
23
- --tn-c-brand: #a8b1ff;
49
+ :root.dark:not([data-theme="light"]),
50
+ :root[data-theme="dark"] {
51
+ --tn-c-brand-strong: #a8b1ff;
24
52
  --tn-c-text: #dfdfd6;
25
53
  --tn-c-text-2: #98989f;
26
54
  --tn-c-bg: #1b1b1f;
27
- --tn-c-bg-soft: #161618;
55
+ --tn-c-bg-soft: #202127;
28
56
  --tn-c-bg-elv: #202127;
57
+ --tn-c-bg-chrome: #161618;
58
+ --tn-c-bg-input: #161618;
29
59
  --tn-c-divider: #2e2e32;
60
+ --tn-c-divider-strong: #3c3f44;
61
+ --tn-c-hover: rgba(101, 117, 133, 0.16);
62
+ --tn-c-success: #3dd68c;
63
+ --tn-c-warning: #f9b44e;
30
64
  --tn-c-danger: #f66f81;
31
- --tn-c-success-soft: #1c3b2a;
32
- --tn-c-danger-soft: #3b1c1c;
65
+ --tn-c-success-soft: rgba(16, 185, 129, 0.16);
66
+ --tn-c-warning-soft: rgba(234, 179, 8, 0.16);
67
+ --tn-c-danger-soft: rgba(244, 63, 94, 0.14);
33
68
  --tn-c-default-soft: #2e2e32;
34
- --tn-shadow-2: 0 4px 12px rgba(0, 0, 0, 0.45);
69
+ --tn-shadow-2: 0 4px 12px rgb(0 0 0 / 0.35);
70
+ color-scheme: dark;
35
71
  }
@@ -0,0 +1,65 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import { hydrateTnSwipers } from "./hydrate";
6
+
7
+ function swiperShell(titles: string[]): HTMLElement {
8
+ const root = document.createElement("div");
9
+ root.innerHTML = `
10
+ <div class="tn-swiper">
11
+ <div class="tn-swiper-tabs"></div>
12
+ <div class="swiper-container">
13
+ <div class="swiper-wrapper">
14
+ ${titles
15
+ .map(
16
+ (title) =>
17
+ `<div class="swiper-slide" data-title="${title}"><img alt="${title}" src="/${title}.svg"></div>`,
18
+ )
19
+ .join("")}
20
+ </div>
21
+ </div>
22
+ </div>
23
+ `;
24
+ document.body.append(root);
25
+ return root;
26
+ }
27
+
28
+ describe("hydrateTnSwipers", () => {
29
+ it("builds tabs and shows only the active slide", () => {
30
+ const root = swiperShell(["A", "B", "C"]);
31
+ expect(hydrateTnSwipers(root)).toBe(1);
32
+
33
+ const host = root.querySelector(".tn-swiper")!;
34
+ const tabs = [...host.querySelectorAll(".tn-tab")].map(
35
+ (tab) => tab.textContent,
36
+ );
37
+ expect(tabs).toEqual(["A", "B", "C"]);
38
+ expect(host.querySelectorAll(".tn-tab-nav")).toHaveLength(3); // < / >
39
+
40
+ const slides = [...host.querySelectorAll(".swiper-slide")] as HTMLElement[];
41
+ expect(slides[0]!.classList.contains("is-active")).toBe(true);
42
+ expect(slides[0]!.hidden).toBe(false);
43
+ expect(slides[1]!.hidden).toBe(true);
44
+ expect(slides[2]!.hidden).toBe(true);
45
+
46
+ ;(host.querySelectorAll(".tn-tab")[1] as HTMLButtonElement).click();
47
+ expect(slides[0]!.hidden).toBe(true);
48
+ expect(slides[1]!.hidden).toBe(false);
49
+ expect(slides[1]!.classList.contains("is-active")).toBe(true);
50
+
51
+ // Idempotent
52
+ expect(hydrateTnSwipers(root)).toBe(0);
53
+ root.remove();
54
+ });
55
+
56
+ it("omits the tab bar for a single slide", () => {
57
+ const root = swiperShell(["solo"]);
58
+ hydrateTnSwipers(root);
59
+ expect(root.querySelector(".tn-swiper-tabs")).toBeNull();
60
+ expect(root.querySelector(".swiper-slide")!.classList.contains("is-active")).toBe(
61
+ true,
62
+ );
63
+ root.remove();
64
+ });
65
+ });
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Client-side tab activation for `.tn-swiper` shells emitted by the SSG
3
+ * markdown pipeline (empty `.tn-swiper-tabs`, all slides in the wrapper).
4
+ * Desk also uses this after building slide DOM so both hosts share one path.
5
+ */
6
+
7
+ export function applySwiperTabsPadding(tabs: HTMLElement, hasNav: boolean): void {
8
+ tabs.style.padding = hasNav ? "0 0.8rem 0 3rem" : "0 0.8rem";
9
+ }
10
+
11
+ export function createSwiperTabNav(handlers: {
12
+ onPrev: () => void;
13
+ onNext: () => void;
14
+ }): {
15
+ prev: HTMLButtonElement;
16
+ line: HTMLSpanElement;
17
+ next: HTMLButtonElement;
18
+ } {
19
+ const prev = document.createElement("button");
20
+ prev.type = "button";
21
+ prev.className = "tn-tab-nav tn-tab-prev";
22
+ prev.textContent = "<";
23
+ prev.title = "上一页";
24
+ prev.addEventListener("click", (event) => {
25
+ event.preventDefault();
26
+ event.stopPropagation();
27
+ handlers.onPrev();
28
+ });
29
+
30
+ const line = document.createElement("span");
31
+ line.className = "tn-tab-nav tab-tab-line";
32
+ line.textContent = "/";
33
+
34
+ const next = document.createElement("button");
35
+ next.type = "button";
36
+ next.className = "tn-tab-nav tn-tab-next";
37
+ next.textContent = ">";
38
+ next.title = "下一页";
39
+ next.addEventListener("click", (event) => {
40
+ event.preventDefault();
41
+ event.stopPropagation();
42
+ handlers.onNext();
43
+ });
44
+
45
+ return { prev, line, next };
46
+ }
47
+
48
+ export function wrapSlideIndex(
49
+ index: number,
50
+ length: number,
51
+ delta: -1 | 1,
52
+ ): number {
53
+ if (length <= 0) return 0;
54
+ return (index + delta + length) % length;
55
+ }
56
+
57
+ const READY = "tnSwiperReady";
58
+
59
+ /**
60
+ * Hydrate every `.tn-swiper` under `root` (or `document`). Idempotent:
61
+ * already-hydrated roots are skipped via `data-tn-swiper-ready`.
62
+ * When `root` itself is a `.tn-swiper`, it is included.
63
+ */
64
+ export function hydrateTnSwipers(root: ParentNode = document): number {
65
+ const hosts: HTMLElement[] = [];
66
+ if (root instanceof HTMLElement && root.classList.contains("tn-swiper")) {
67
+ hosts.push(root);
68
+ }
69
+ hosts.push(...root.querySelectorAll<HTMLElement>(".tn-swiper"));
70
+
71
+ let count = 0;
72
+ const seen = new Set<HTMLElement>();
73
+ for (const host of hosts) {
74
+ if (seen.has(host)) continue;
75
+ seen.add(host);
76
+ if (host.dataset[READY] === "true") continue;
77
+ if (hydrateOne(host)) {
78
+ host.dataset[READY] = "true";
79
+ count += 1;
80
+ }
81
+ }
82
+ return count;
83
+ }
84
+
85
+ function hydrateOne(host: HTMLElement): boolean {
86
+ const tabs = host.querySelector<HTMLElement>(".tn-swiper-tabs");
87
+ const wrapper = host.querySelector<HTMLElement>(".swiper-wrapper");
88
+ if (!wrapper) return false;
89
+
90
+ const slides = [
91
+ ...wrapper.querySelectorAll<HTMLElement>(":scope > .swiper-slide"),
92
+ ];
93
+ if (slides.length === 0) {
94
+ // Empty shell — still mark ready so we don't loop.
95
+ return true;
96
+ }
97
+
98
+ // Ensure tabs host exists (site always emits it; Desk may omit for 1 slide).
99
+ let tabsEl = tabs;
100
+ if (!tabsEl) {
101
+ tabsEl = document.createElement("div");
102
+ tabsEl.className = "tn-swiper-tabs";
103
+ host.prepend(tabsEl);
104
+ }
105
+
106
+ const useTabs = slides.length > 1;
107
+ tabsEl.replaceChildren();
108
+ applySwiperTabsPadding(tabsEl, useTabs);
109
+
110
+ const tabButtons: HTMLButtonElement[] = [];
111
+
112
+ const activate = (index: number): void => {
113
+ tabButtons.forEach((button, buttonIndex) =>
114
+ button.classList.toggle("active", buttonIndex === index),
115
+ );
116
+ slides.forEach((slide, slideIndex) => {
117
+ const on = slideIndex === index;
118
+ slide.classList.toggle("is-active", on);
119
+ slide.hidden = !on;
120
+ });
121
+ };
122
+
123
+ const activeIndex = (): number => {
124
+ const found = tabButtons.findIndex((button) =>
125
+ button.classList.contains("active"),
126
+ );
127
+ return found >= 0 ? found : 0;
128
+ };
129
+
130
+ if (useTabs) {
131
+ const nav = createSwiperTabNav({
132
+ onPrev: () => activate(wrapSlideIndex(activeIndex(), slides.length, -1)),
133
+ onNext: () => activate(wrapSlideIndex(activeIndex(), slides.length, 1)),
134
+ });
135
+ tabsEl.append(nav.prev, nav.line);
136
+
137
+ slides.forEach((slide, index) => {
138
+ const title =
139
+ slide.dataset.title?.trim() ||
140
+ slide.querySelector("img")?.getAttribute("alt")?.trim() ||
141
+ "img";
142
+ slide.dataset.title = title;
143
+ const tab = document.createElement("button");
144
+ tab.type = "button";
145
+ tab.className = "tn-tab";
146
+ tab.textContent = title;
147
+ tab.addEventListener("click", () => activate(index));
148
+ tabButtons.push(tab);
149
+ tabsEl!.append(tab);
150
+ });
151
+
152
+ tabsEl.append(nav.next);
153
+ // Multi-slide: tabs stay above the container.
154
+ if (tabsEl.parentElement !== host || host.firstElementChild !== tabsEl) {
155
+ host.prepend(tabsEl);
156
+ }
157
+ } else {
158
+ // Single slide: drop empty tab bar (Desk parity).
159
+ tabsEl.remove();
160
+ }
161
+
162
+ activate(0);
163
+ return true;
164
+ }