@tnotesjs/ui 0.4.1 → 0.4.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.
@@ -0,0 +1,47 @@
1
+ /**
2
+ * kb-level line-break mode (`tnotes.json` → `markdown.lineBreaks`), shared by
3
+ * the SSG and Desk's card renderers so both draw the same note the same way.
4
+ *
5
+ * `visual` keeps what the editor shows: a single newline renders as `<br>`, and
6
+ * every blank line beyond the first between two top-level blocks becomes a
7
+ * one-line spacer; inside callout bodies (title and closing fence included)
8
+ * every blank line does. Lists and quotes keep CommonMark spacing; their inner blank lines
9
+ * decide loose/tight and have no stable visual meaning. Fences, tables and
10
+ * HTML are single tokens, so their contents are never touched.
11
+ */
12
+ type LineBreakMode = 'commonmark' | 'visual';
13
+ declare const BLANK_LINE_CLASS = "tn-blank-line";
14
+ declare const BLANK_LINE_HTML = "<div class=\"tn-blank-line\" aria-hidden=\"true\"></div>\n";
15
+ declare function resolveLineBreakMode(value: unknown): LineBreakMode;
16
+ /** Structural subset of markdown-it's Token, so this module stays dependency-free. */
17
+ interface BlockToken {
18
+ type: string;
19
+ block: boolean;
20
+ nesting: number;
21
+ level: number;
22
+ map: [number, number] | null;
23
+ content: string;
24
+ }
25
+ interface CoreState {
26
+ src: string;
27
+ tokens: BlockToken[];
28
+ Token: new (type: string, tag: string, nesting: -1 | 0 | 1) => BlockToken;
29
+ }
30
+ interface MarkdownLike {
31
+ set(options: {
32
+ breaks: boolean;
33
+ }): unknown;
34
+ core: {
35
+ ruler: {
36
+ push(name: string, rule: (state: CoreState) => void): void;
37
+ };
38
+ };
39
+ }
40
+ /** Callout bodies get spacers too; other containers render their own way. */
41
+ declare function isSpacedContainer(type: string): boolean;
42
+ /** Inserts `tn-blank-line` spacers for the blank lines `visual` keeps. */
43
+ declare function insertBlankLineSpacers(state: CoreState, spaced?: (type: string) => boolean): void;
44
+ /** Applies the mode to a markdown-it instance; `commonmark` leaves it untouched. */
45
+ declare function useLineBreaks(md: MarkdownLike, mode: LineBreakMode): void;
46
+
47
+ export { BLANK_LINE_CLASS, BLANK_LINE_HTML, type LineBreakMode, insertBlankLineSpacers, isSpacedContainer, resolveLineBreakMode, useLineBreaks };
@@ -0,0 +1,78 @@
1
+ // src/markdown/lineBreaks.ts
2
+ var BLANK_LINE_CLASS = "tn-blank-line";
3
+ var BLANK_LINE_HTML = `<div class="${BLANK_LINE_CLASS}" aria-hidden="true"></div>
4
+ `;
5
+ function resolveLineBreakMode(value) {
6
+ return value === "visual" ? "visual" : "commonmark";
7
+ }
8
+ var CALLOUT_OPEN = /^container_(?:info|tip|warning|danger|details)_open$/;
9
+ var CONTAINER_FENCE = /^\s*:{3,}\s*$/;
10
+ function isSpacedContainer(type) {
11
+ return CALLOUT_OPEN.test(type);
12
+ }
13
+ function blockEnd(token, lines) {
14
+ const [start, mapEnd] = token.map;
15
+ let end = mapEnd;
16
+ if (token.type.startsWith("container_") && CONTAINER_FENCE.test(lines[end] ?? "")) end += 1;
17
+ while (end > start + 1 && !(lines[end - 1] ?? "").trim()) end -= 1;
18
+ return end;
19
+ }
20
+ function insertBlankLineSpacers(state, spaced = isSpacedContainer) {
21
+ const lines = state.src.split("\n");
22
+ const out = [];
23
+ const stack = [{ level: 0, lastEnd: null, free: 1 }];
24
+ const pushSpacers = (count, level) => {
25
+ for (; count > 0; count -= 1) {
26
+ const spacer = new state.Token("html_block", "", 0);
27
+ spacer.block = true;
28
+ spacer.level = level;
29
+ spacer.content = BLANK_LINE_HTML;
30
+ out.push(spacer);
31
+ }
32
+ };
33
+ for (const token of state.tokens) {
34
+ const scope = stack[stack.length - 1];
35
+ if (scope.close && token.type === scope.close && token.level === scope.level - 1) {
36
+ if (scope.closeLine !== void 0 && scope.lastEnd !== null) {
37
+ pushSpacers(scope.closeLine - scope.lastEnd - scope.free, scope.level);
38
+ }
39
+ stack.pop();
40
+ out.push(token);
41
+ continue;
42
+ }
43
+ if (token.block && token.map && token.nesting !== -1 && token.level === scope.level) {
44
+ if (scope.lastEnd !== null) {
45
+ pushSpacers(token.map[0] - scope.lastEnd - scope.free, scope.level);
46
+ }
47
+ const end = blockEnd(token, lines);
48
+ scope.lastEnd = end;
49
+ out.push(token);
50
+ if (token.nesting === 1 && spaced(token.type)) {
51
+ const closed = CONTAINER_FENCE.test(lines[end - 1] ?? "") && end - 1 > token.map[0];
52
+ stack.push({
53
+ level: token.level + 1,
54
+ lastEnd: token.map[0] + 1,
55
+ close: token.type.replace(/_open$/, "_close"),
56
+ closeLine: closed ? end - 1 : void 0,
57
+ free: 0
58
+ });
59
+ }
60
+ continue;
61
+ }
62
+ out.push(token);
63
+ }
64
+ state.tokens = out;
65
+ }
66
+ function useLineBreaks(md, mode) {
67
+ if (mode !== "visual") return;
68
+ md.set({ breaks: true });
69
+ md.core.ruler.push("tn_blank_lines", (state) => insertBlankLineSpacers(state));
70
+ }
71
+ export {
72
+ BLANK_LINE_CLASS,
73
+ BLANK_LINE_HTML,
74
+ insertBlankLineSpacers,
75
+ isSpacedContainer,
76
+ resolveLineBreakMode,
77
+ useLineBreaks
78
+ };
@@ -0,0 +1,29 @@
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
+ declare function applySwiperTabsPadding(tabs: HTMLElement, hasNav: boolean): void;
7
+ declare function createSwiperTabNav(handlers: {
8
+ onPrev: () => void;
9
+ onNext: () => void;
10
+ }): {
11
+ prev: HTMLButtonElement;
12
+ line: HTMLSpanElement;
13
+ next: HTMLButtonElement;
14
+ };
15
+ declare function wrapSlideIndex(index: number, length: number, delta: -1 | 1): number;
16
+ declare const SWIPER_EMPTY_TEXT = "empty swiper";
17
+ /** 没有图片幻灯片时的壳。Desk 与站点用同一段结构。 */
18
+ declare function emptySwiperMarkup(): string;
19
+ /**
20
+ * Hydrate every `.tn-swiper` under `root` (or `document`). Idempotent:
21
+ * already-hydrated roots are skipped via `data-tn-swiper-ready`.
22
+ * When `root` itself is a `.tn-swiper`, it is included.
23
+ */
24
+ declare function hydrateTnSwipers(root?: ParentNode, options?: {
25
+ activeIndex?: number;
26
+ onActivate?: (index: number) => void;
27
+ }): number;
28
+
29
+ export { SWIPER_EMPTY_TEXT, applySwiperTabsPadding, createSwiperTabNav, emptySwiperMarkup, hydrateTnSwipers, wrapSlideIndex };
@@ -0,0 +1,131 @@
1
+ // src/swiper/hydrate.ts
2
+ function applySwiperTabsPadding(tabs, hasNav) {
3
+ tabs.style.padding = hasNav ? "0 0.8rem 0 3rem" : "0 0.8rem";
4
+ }
5
+ function createSwiperTabNav(handlers) {
6
+ const prev = document.createElement("button");
7
+ prev.type = "button";
8
+ prev.className = "tn-tab-nav tn-tab-prev";
9
+ prev.textContent = "<";
10
+ prev.title = "\u4E0A\u4E00\u9875";
11
+ prev.addEventListener("click", (event) => {
12
+ event.preventDefault();
13
+ event.stopPropagation();
14
+ handlers.onPrev();
15
+ });
16
+ const line = document.createElement("span");
17
+ line.className = "tn-tab-nav tab-tab-line";
18
+ line.textContent = "/";
19
+ const next = document.createElement("button");
20
+ next.type = "button";
21
+ next.className = "tn-tab-nav tn-tab-next";
22
+ next.textContent = ">";
23
+ next.title = "\u4E0B\u4E00\u9875";
24
+ next.addEventListener("click", (event) => {
25
+ event.preventDefault();
26
+ event.stopPropagation();
27
+ handlers.onNext();
28
+ });
29
+ return { prev, line, next };
30
+ }
31
+ function wrapSlideIndex(index, length, delta) {
32
+ if (length <= 0) return 0;
33
+ return (index + delta + length) % length;
34
+ }
35
+ var SWIPER_EMPTY_TEXT = "empty swiper";
36
+ function emptySwiperMarkup() {
37
+ return `<div class="tn-swiper is-empty"><div class="swiper-container"><p class="tn-swiper-empty">${SWIPER_EMPTY_TEXT}</p></div></div>`;
38
+ }
39
+ var READY = "tnSwiperReady";
40
+ function hydrateTnSwipers(root = document, options) {
41
+ const hosts = [];
42
+ if (root instanceof HTMLElement && root.classList.contains("tn-swiper")) {
43
+ hosts.push(root);
44
+ }
45
+ hosts.push(...root.querySelectorAll(".tn-swiper"));
46
+ let count = 0;
47
+ const seen = /* @__PURE__ */ new Set();
48
+ for (const host of hosts) {
49
+ if (seen.has(host)) continue;
50
+ seen.add(host);
51
+ if (host.dataset[READY] === "true") continue;
52
+ if (hydrateOne(host, options)) {
53
+ host.dataset[READY] = "true";
54
+ count += 1;
55
+ }
56
+ }
57
+ return count;
58
+ }
59
+ function hydrateOne(host, options) {
60
+ if (host.classList.contains("is-empty")) return true;
61
+ const tabs = host.querySelector(".tn-swiper-tabs");
62
+ const wrapper = host.querySelector(".swiper-wrapper");
63
+ if (!wrapper) return false;
64
+ const slides = [...wrapper.querySelectorAll(":scope > .swiper-slide")];
65
+ if (slides.length === 0) {
66
+ return true;
67
+ }
68
+ let tabsEl = tabs;
69
+ if (!tabsEl) {
70
+ tabsEl = document.createElement("div");
71
+ tabsEl.className = "tn-swiper-tabs";
72
+ host.prepend(tabsEl);
73
+ }
74
+ const useTabs = slides.length > 1;
75
+ tabsEl.replaceChildren();
76
+ applySwiperTabsPadding(tabsEl, useTabs);
77
+ const tabButtons = [];
78
+ const activate = (index) => {
79
+ tabButtons.forEach(
80
+ (button, buttonIndex) => button.classList.toggle("active", buttonIndex === index)
81
+ );
82
+ slides.forEach((slide, slideIndex) => {
83
+ const on = slideIndex === index;
84
+ slide.classList.toggle("is-active", on);
85
+ slide.hidden = !on;
86
+ });
87
+ options?.onActivate?.(index);
88
+ };
89
+ const activeIndex = () => {
90
+ const found = tabButtons.findIndex((button) => button.classList.contains("active"));
91
+ return found >= 0 ? found : 0;
92
+ };
93
+ if (useTabs) {
94
+ const nav = createSwiperTabNav({
95
+ onPrev: () => activate(wrapSlideIndex(activeIndex(), slides.length, -1)),
96
+ onNext: () => activate(wrapSlideIndex(activeIndex(), slides.length, 1))
97
+ });
98
+ tabsEl.append(nav.prev, nav.line);
99
+ slides.forEach((slide, index) => {
100
+ const title = slide.dataset.title?.trim() || slide.querySelector("img")?.getAttribute("alt")?.trim() || "img";
101
+ slide.dataset.title = title;
102
+ const tab = document.createElement("button");
103
+ tab.type = "button";
104
+ tab.className = "tn-tab";
105
+ tab.textContent = title;
106
+ tab.addEventListener("click", () => activate(index));
107
+ tabButtons.push(tab);
108
+ tabsEl.append(tab);
109
+ });
110
+ tabsEl.append(nav.next);
111
+ if (tabsEl.parentElement !== host || host.firstElementChild !== tabsEl) {
112
+ host.prepend(tabsEl);
113
+ }
114
+ } else {
115
+ tabsEl.remove();
116
+ }
117
+ const start = Math.min(
118
+ Math.max(0, options?.activeIndex ?? 0),
119
+ Math.max(0, slides.length - 1)
120
+ );
121
+ activate(start);
122
+ return true;
123
+ }
124
+ export {
125
+ SWIPER_EMPTY_TEXT,
126
+ applySwiperTabsPadding,
127
+ createSwiperTabNav,
128
+ emptySwiperMarkup,
129
+ hydrateTnSwipers,
130
+ wrapSlideIndex
131
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tnotesjs/ui",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
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": {
@@ -27,12 +27,19 @@
27
27
  "import": "./src/markdown/image.ts",
28
28
  "default": "./src/markdown/image.ts"
29
29
  },
30
+ "./line-breaks": {
31
+ "types": "./src/markdown/lineBreaks.ts",
32
+ "node": "./dist/markdown/lineBreaks.js",
33
+ "import": "./src/markdown/lineBreaks.ts",
34
+ "default": "./src/markdown/lineBreaks.ts"
35
+ },
30
36
  "./styles/tokens.css": "./src/styles/tokens.css",
31
37
  "./styles/prose.css": "./src/styles/prose.css",
32
38
  "./styles/code.css": "./src/styles/code.css",
33
39
  "./styles/swiper.css": "./src/styles/swiper.css",
34
40
  "./swiper": {
35
41
  "types": "./src/swiper/hydrate.ts",
42
+ "node": "./dist/swiper/hydrate.js",
36
43
  "import": "./src/swiper/hydrate.ts",
37
44
  "default": "./src/swiper/hydrate.ts"
38
45
  },
@@ -43,6 +50,7 @@
43
50
  },
44
51
  "./code-block": "./src/components/CodeBlock/CodeBlock.vue",
45
52
  "./code-group": "./src/components/CodeGroup/CodeGroup.vue",
53
+ "./alerts": "./src/alerts/icons.ts",
46
54
  "./badge": "./src/components/Badge/Badge.vue",
47
55
  "./image-preview": "./src/components/ImagePreview/ImagePreview.vue",
48
56
  "./bilibili-video": "./src/entries/bilibili-video.ts",
@@ -81,24 +89,24 @@
81
89
  "mermaid": "^11.17.0",
82
90
  "react": "18.3.1",
83
91
  "react-dom": "18.3.1",
84
- "sass": "^1.90.0",
85
- "shiki": "4.4.2",
86
- "@tnotesjs/mindmap-core": "^0.2.4"
92
+ "sass": "^1.107.1",
93
+ "shiki": "4.6.0",
94
+ "@tnotesjs/mindmap-core": "^0.2.5"
87
95
  },
88
96
  "devDependencies": {
89
97
  "@eslint/js": "^10.0.1",
90
98
  "@types/react": "^18.3.31",
91
99
  "@types/react-dom": "^18.0.0",
92
- "@vitejs/plugin-vue": "^6.0.8",
93
- "eslint": "^10.8.1",
94
- "eslint-plugin-vue": "^10.10.0",
95
- "globals": "^17.11.0",
100
+ "@vitejs/plugin-vue": "^6.0.10",
101
+ "eslint": "^10.14.0",
102
+ "eslint-plugin-vue": "^10.12.0",
103
+ "globals": "^17.14.0",
96
104
  "happy-dom": "^20.12.0",
97
105
  "tsup": "8.5.1",
98
106
  "typescript": "^5.9.3",
99
- "typescript-eslint": "^8.67.0",
107
+ "typescript-eslint": "^8.72.1",
100
108
  "vitest": "^4.1.11",
101
- "vue": "^3.5.25",
109
+ "vue": "^3.5.44",
102
110
  "vue-eslint-parser": "^10.4.1"
103
111
  },
104
112
  "engines": {
@@ -126,7 +134,7 @@
126
134
  ],
127
135
  "license": "MIT",
128
136
  "scripts": {
129
- "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",
137
+ "build": "tsup src/code/highlight.ts src/components/Footprints/parse.ts src/components/Mindmap/markdown.ts src/markdown/image.ts src/markdown/lineBreaks.ts src/swiper/hydrate.ts --format esm --dts --out-dir dist --clean",
130
138
  "typecheck": "tsc --noEmit -p tsconfig.json",
131
139
  "test": "vitest run",
132
140
  "lint": "eslint . --max-warnings=0",
@@ -0,0 +1,32 @@
1
+ /** GitHub Alerts 风格的容器图标。`currentColor`,颜色由侧边色条同一变量决定。 */
2
+ export const ALERT_TYPES = ['tip', 'info', 'warning', 'danger', 'details'] as const
3
+
4
+ export type AlertType = (typeof ALERT_TYPES)[number]
5
+
6
+ const PATHS: Record<AlertType, string> = {
7
+ tip: '<path fill="currentColor" d="M8 1.5a4.5 4.5 0 0 0-2.6 8.17c.3.22.5.5.55.84l.2 1.24h3.7l.2-1.24c.05-.34.25-.62.55-.84A4.5 4.5 0 0 0 8 1.5Zm1.9 11.25H6.1l.15.9c.05.3.3.5.6.5h1.5c.3 0 .55-.2.6-.5l.15-.9ZM8 0a6 6 0 0 1 3.5 10.87l-.35 2.1A2.1 2.1 0 0 1 9.1 15H6.9a2.1 2.1 0 0 1-2.05-1.53l-.35-2.1A6 6 0 0 1 8 0Z"/>',
8
+ info: '<path fill="currentColor" d="M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-3.25a.75.75 0 0 1 .75.75v.01a.75.75 0 0 1-1.5 0V5.5A.75.75 0 0 1 8 4.75ZM7.25 7.5a.75.75 0 0 1 1.5 0v4a.75.75 0 0 1-1.5 0v-4Z"/>',
9
+ warning:
10
+ '<path fill="currentColor" d="M6.46 1.96a1.8 1.8 0 0 1 3.08 0l5.9 9.92A1.8 1.8 0 0 1 13.9 14.5H2.1a1.8 1.8 0 0 1-1.54-2.62l5.9-9.92ZM8 3.15 2.1 13h11.8L8 3.15Zm-.75 3.6a.75.75 0 0 1 1.5 0v2.5a.75.75 0 0 1-1.5 0V6.75Zm.75 5.5a.9.9 0 1 1 0-1.8.9.9 0 0 1 0 1.8Z"/>',
11
+ danger:
12
+ '<path fill="currentColor" d="M8 0 15.5 4.3v7.4L8 16 0.5 11.7V4.3L8 0Zm0 1.7L1.9 5.1v5.8L8 14.3l6.1-3.4V5.1L8 1.7ZM7.25 4.75a.75.75 0 0 1 1.5 0v3.5a.75.75 0 0 1-1.5 0v-3.5Zm.75 6.5a.9.9 0 1 1 0-1.8.9.9 0 0 1 0 1.8Z"/>',
13
+ details:
14
+ '<path fill="currentColor" d="M2 3.75A.75.75 0 0 1 2.75 3h10.5a.75.75 0 0 1 0 1.5H2.75A.75.75 0 0 1 2 3.75ZM2 8a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H2.75A.75.75 0 0 1 2 8Zm.75 3.5h6.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1 0-1.5Z"/>'
15
+ }
16
+
17
+ export function isAlertType(name: string): name is AlertType {
18
+ return (ALERT_TYPES as readonly string[]).includes(name)
19
+ }
20
+
21
+ export function alertIcon(name: string): string {
22
+ const type = isAlertType(name) ? name : 'info'
23
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true">${PATHS[type]}</svg>`
24
+ }
25
+
26
+ export const ALERT_LABELS: Record<AlertType, string> = {
27
+ tip: '提示',
28
+ info: '信息',
29
+ warning: '警告',
30
+ danger: '错误',
31
+ details: '详情'
32
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * CSS 全屏层的文档级标记。
3
+ *
4
+ * 全屏层(mindmap / mermaid / Desk 代码块)往往嵌在编辑器的层叠上下文里,z-index
5
+ * 盖不过应用壳。宿主据 `body[data-tn-fullscreen]` 隐藏壳层,再用
6
+ * `[data-tn-fullscreen-layer]` 把全屏层本身显示回来;DOM 不搬家,编辑器 widget 不会重建。
7
+ */
8
+ export function setFullscreenLayer(el: HTMLElement | null | undefined, on: boolean): void {
9
+ if (typeof document === 'undefined') return
10
+ if (el) {
11
+ if (on) el.dataset.tnFullscreenLayer = ''
12
+ else delete el.dataset.tnFullscreenLayer
13
+ }
14
+ if (document.querySelector('[data-tn-fullscreen-layer]')) document.body.dataset.tnFullscreen = ''
15
+ else delete document.body.dataset.tnFullscreen
16
+ }