@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,133 @@
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
+
13
+ export type LineBreakMode = 'commonmark' | 'visual'
14
+
15
+ export const BLANK_LINE_CLASS = 'tn-blank-line'
16
+
17
+ export const BLANK_LINE_HTML = `<div class="${BLANK_LINE_CLASS}" aria-hidden="true"></div>\n`
18
+
19
+ export function resolveLineBreakMode(value: unknown): LineBreakMode {
20
+ return value === 'visual' ? 'visual' : 'commonmark'
21
+ }
22
+
23
+ /** Structural subset of markdown-it's Token, so this module stays dependency-free. */
24
+ interface BlockToken {
25
+ type: string
26
+ block: boolean
27
+ nesting: number
28
+ level: number
29
+ map: [number, number] | null
30
+ content: string
31
+ }
32
+
33
+ interface CoreState {
34
+ src: string
35
+ tokens: BlockToken[]
36
+ Token: new (type: string, tag: string, nesting: -1 | 0 | 1) => BlockToken
37
+ }
38
+
39
+ interface MarkdownLike {
40
+ set(options: { breaks: boolean }): unknown
41
+ core: { ruler: { push(name: string, rule: (state: CoreState) => void): void } }
42
+ }
43
+
44
+ const CALLOUT_OPEN = /^container_(?:info|tip|warning|danger|details)_open$/
45
+ const CONTAINER_FENCE = /^\s*:{3,}\s*$/
46
+
47
+ /** Callout bodies get spacers too; other containers render their own way. */
48
+ export function isSpacedContainer(type: string): boolean {
49
+ return CALLOUT_OPEN.test(type)
50
+ }
51
+
52
+ interface Scope {
53
+ level: number
54
+ /** Line after the previous block in this scope; null before the first block. */
55
+ lastEnd: number | null
56
+ close?: string
57
+ /** Callout bodies: the closing `:::` line, which spaces like a block. */
58
+ closeLine?: number
59
+ /**
60
+ * Callout paragraphs have no margins standing in for the first blank line,
61
+ * so every blank line there is a spacer; top-level runs drop one.
62
+ */
63
+ free: number
64
+ }
65
+
66
+ function blockEnd(token: BlockToken, lines: readonly string[]): number {
67
+ const [start, mapEnd] = token.map!
68
+ let end = mapEnd
69
+ // markdown-it-container ends its map on the closing `:::` line itself.
70
+ if (token.type.startsWith('container_') && CONTAINER_FENCE.test(lines[end] ?? '')) end += 1
71
+ // Lists and quotes swallow one trailing blank line.
72
+ while (end > start + 1 && !(lines[end - 1] ?? '').trim()) end -= 1
73
+ return end
74
+ }
75
+
76
+ /** Inserts `tn-blank-line` spacers for the blank lines `visual` keeps. */
77
+ export function insertBlankLineSpacers(
78
+ state: CoreState,
79
+ spaced: (type: string) => boolean = isSpacedContainer
80
+ ): void {
81
+ const lines = state.src.split('\n')
82
+ const out: BlockToken[] = []
83
+ const stack: Scope[] = [{ level: 0, lastEnd: null, free: 1 }]
84
+ const pushSpacers = (count: number, level: number): void => {
85
+ for (; count > 0; count -= 1) {
86
+ const spacer = new state.Token('html_block', '', 0)
87
+ spacer.block = true
88
+ spacer.level = level
89
+ spacer.content = BLANK_LINE_HTML
90
+ out.push(spacer)
91
+ }
92
+ }
93
+ for (const token of state.tokens) {
94
+ const scope = stack[stack.length - 1]!
95
+ if (scope.close && token.type === scope.close && token.level === scope.level - 1) {
96
+ if (scope.closeLine !== undefined && scope.lastEnd !== null) {
97
+ pushSpacers(scope.closeLine - scope.lastEnd - scope.free, scope.level)
98
+ }
99
+ stack.pop()
100
+ out.push(token)
101
+ continue
102
+ }
103
+ if (token.block && token.map && token.nesting !== -1 && token.level === scope.level) {
104
+ if (scope.lastEnd !== null) {
105
+ pushSpacers(token.map[0] - scope.lastEnd - scope.free, scope.level)
106
+ }
107
+ const end = blockEnd(token, lines)
108
+ scope.lastEnd = end
109
+ out.push(token)
110
+ if (token.nesting === 1 && spaced(token.type)) {
111
+ // The title line spaces like a block, so does a closing fence.
112
+ const closed = CONTAINER_FENCE.test(lines[end - 1] ?? '') && end - 1 > token.map[0]
113
+ stack.push({
114
+ level: token.level + 1,
115
+ lastEnd: token.map[0] + 1,
116
+ close: token.type.replace(/_open$/, '_close'),
117
+ closeLine: closed ? end - 1 : undefined,
118
+ free: 0
119
+ })
120
+ }
121
+ continue
122
+ }
123
+ out.push(token)
124
+ }
125
+ state.tokens = out
126
+ }
127
+
128
+ /** Applies the mode to a markdown-it instance; `commonmark` leaves it untouched. */
129
+ export function useLineBreaks(md: MarkdownLike, mode: LineBreakMode): void {
130
+ if (mode !== 'visual') return
131
+ md.set({ breaks: true })
132
+ md.core.ruler.push('tn_blank_lines', (state) => insertBlankLineSpacers(state))
133
+ }
@@ -14,6 +14,32 @@
14
14
  margin: 16px 0;
15
15
  line-height: var(--tn-prose-line-height);
16
16
  }
17
+ /* Visual line breaks: each blank line beyond the first adds one editor line.
18
+ The first spacer cancels the preceding block's 16px margin so N spacers add
19
+ exactly N line heights on top of the normal paragraph gap. */
20
+ :is(.tn-prose, .vp-doc) :where(.tn-blank-line) {
21
+ height: var(--tn-prose-line-height);
22
+ margin: 0;
23
+ }
24
+ :is(.tn-prose, .vp-doc) :where(:not(.tn-blank-line) + .tn-blank-line) {
25
+ margin-top: -16px;
26
+ }
27
+ /* Callout bodies keep every blank line as a spacer, so the spacer replaces the
28
+ neighbouring blocks' own margins instead of adding to them. */
29
+ :is(.tn-prose, .vp-doc) :where(.tn-custom-block > .tn-blank-line) {
30
+ margin-top: 0;
31
+ }
32
+ :is(.tn-prose, .vp-doc) .tn-custom-block > .tn-blank-line + *,
33
+ :is(.tn-prose, .vp-doc) .tn-custom-block > .tn-blank-line + * > :first-child,
34
+ :is(.tn-prose, .vp-doc) .tn-custom-block > .tn-blank-line + * > :first-child > :first-child {
35
+ margin-top: 0;
36
+ }
37
+ /* Inner margins collapse out through the block (nested lists, quoted paragraphs). */
38
+ :is(.tn-prose, .vp-doc) .tn-custom-block > :has(+ .tn-blank-line),
39
+ :is(.tn-prose, .vp-doc) .tn-custom-block > :has(+ .tn-blank-line) > :last-child,
40
+ :is(.tn-prose, .vp-doc) .tn-custom-block > :has(+ .tn-blank-line) > :last-child > :last-child {
41
+ margin-bottom: 0;
42
+ }
17
43
  :is(.tn-prose, .vp-doc) :where(h1, h2, h3, h4, h5, h6) {
18
44
  font-family: var(--tn-font-sans);
19
45
  font-weight: var(--tn-heading-weight);
@@ -57,17 +83,17 @@
57
83
  color: var(--tn-c-link);
58
84
  text-decoration: none;
59
85
  }
60
- :is(.tn-prose, .vp-doc) :where(ul, ol) {
86
+ :is(.tn-prose, .vp-doc, .cm-lp-card .tn-custom-block) :where(ul, ol) {
61
87
  padding-left: 1.5rem;
62
88
  margin: 16px 0;
63
89
  }
64
- :is(.tn-prose, .vp-doc) :where(li) {
90
+ :is(.tn-prose, .vp-doc, .cm-lp-card .tn-custom-block) :where(li) {
65
91
  line-height: var(--tn-prose-line-height);
66
92
  }
67
- :is(.tn-prose, .vp-doc) :where(li::marker) {
93
+ :is(.tn-prose, .vp-doc, .cm-lp-card .tn-custom-block) :where(li::marker) {
68
94
  color: var(--tn-c-text);
69
95
  }
70
- :is(.tn-prose, .vp-doc) :where(li > ul, li > ol) {
96
+ :is(.tn-prose, .vp-doc, .cm-lp-card .tn-custom-block) :where(li > ul, li > ol) {
71
97
  margin: 0;
72
98
  }
73
99
  /* 任务列表复选框:对齐语雀的方框(16px / 圆角 4px / 选中填充品牌绿 + 白色对勾 /
@@ -75,10 +101,10 @@
75
101
  `appearance: none` 自绘;同时去掉 `.task-list-item` 的项目符号,避免出现
76
102
  「◦ ☐ 文本」两层符号。对勾用 data-URI 而不是 `::after`:`input` 是替换元素,
77
103
  Firefox 不生成伪元素。 */
78
- :is(.tn-prose, .vp-doc) :where(.task-list-item) {
104
+ :is(.tn-prose, .vp-doc, .cm-lp-card .tn-custom-block) :where(.task-list-item) {
79
105
  list-style: none;
80
106
  }
81
- :is(.tn-prose, .vp-doc) :where(.task-list-item-checkbox) {
107
+ :is(.tn-prose, .vp-doc, .cm-lp-card .tn-custom-block) :where(.task-list-item-checkbox) {
82
108
  appearance: none;
83
109
  -webkit-appearance: none;
84
110
  box-sizing: border-box;
@@ -97,15 +123,15 @@
97
123
  transition: all 0.3s;
98
124
  cursor: pointer;
99
125
  }
100
- :is(.tn-prose, .vp-doc) :where(.task-list-item-checkbox:hover) {
126
+ :is(.tn-prose, .vp-doc, .cm-lp-card .tn-custom-block) :where(.task-list-item-checkbox:hover) {
101
127
  border-color: var(--tn-c-task-check);
102
128
  }
103
- :is(.tn-prose, .vp-doc) :where(.task-list-item-checkbox:checked) {
129
+ :is(.tn-prose, .vp-doc, .cm-lp-card .tn-custom-block) :where(.task-list-item-checkbox:checked) {
104
130
  border-color: var(--tn-c-task-check);
105
131
  background-color: var(--tn-c-task-check);
106
132
  background-size: 12px 12px;
107
133
  }
108
- :is(.tn-prose, .vp-doc) :where(:not(pre) > code) {
134
+ :is(.tn-prose, .vp-doc, .cm-lp-card .tn-custom-block) :where(:not(pre) > code) {
109
135
  color: inherit;
110
136
  background: var(--tn-c-inline-code-bg);
111
137
  border: 1px solid var(--tn-c-inline-code-border);
@@ -116,19 +142,19 @@
116
142
  font-size: 1em;
117
143
  font-weight: 400;
118
144
  }
119
- :is(.tn-prose, .vp-doc) :where(mark) {
145
+ :is(.tn-prose, .vp-doc, .cm-lp-card .tn-custom-block) :where(mark) {
120
146
  color: inherit;
121
147
  background: var(--tn-c-mark-bg);
122
148
  border-radius: 2px;
123
149
  padding: 0 2px;
124
150
  }
125
- :is(.tn-prose, .vp-doc) :where(blockquote) {
151
+ :is(.tn-prose, .vp-doc, .cm-lp-card .tn-custom-block) :where(blockquote) {
126
152
  margin: 16px 0;
127
153
  border-left: 2px solid var(--tn-c-divider);
128
154
  padding: 0 0 0 16px;
129
155
  color: var(--tn-c-text-2);
130
156
  }
131
- :is(.tn-prose, .vp-doc) :where(table) {
157
+ :is(.tn-prose, .vp-doc, .cm-lp-card .tn-custom-block) :where(table) {
132
158
  border-collapse: collapse;
133
159
  margin: 16px 0;
134
160
  /* 铺满内容列:与 Desk 可视化编辑器里的表格宽度一致(编辑器里表格宽度 = 内容列宽)。
@@ -137,13 +163,13 @@
137
163
  width: 100%;
138
164
  max-width: 100%;
139
165
  }
140
- :is(.tn-prose, .vp-doc) :where(th, td) {
166
+ :is(.tn-prose, .vp-doc, .cm-lp-card .tn-custom-block) :where(th, td) {
141
167
  border: 1px solid var(--tn-c-divider);
142
168
  padding: 8px 16px;
143
169
  font-size: 14px;
144
170
  line-height: 24px;
145
171
  }
146
- :is(.tn-prose, .vp-doc) :where(th) {
172
+ :is(.tn-prose, .vp-doc, .cm-lp-card .tn-custom-block) :where(th) {
147
173
  font-weight: 600;
148
174
  background: var(--tn-c-bg-soft);
149
175
  /* 表头默认左对齐:浏览器对 <th> 的 UA 默认是居中,而编辑器里 Milkdown 给单元格写了
@@ -152,11 +178,11 @@
152
178
  因此显式对齐不受影响(实测保留 left / center / right)。 */
153
179
  text-align: left;
154
180
  }
155
- :is(.tn-prose, .vp-doc) :where(td p, th p) {
181
+ :is(.tn-prose, .vp-doc, .cm-lp-card .tn-custom-block) :where(td p, th p) {
156
182
  margin: 0;
157
183
  line-height: 24px;
158
184
  }
159
- :is(.tn-prose, .vp-doc) :where(hr) {
185
+ :is(.tn-prose, .vp-doc, .cm-lp-card .tn-custom-block) :where(hr) {
160
186
  margin: 16px 0;
161
187
  border: 0;
162
188
  border-top: 1px solid var(--tn-c-divider);
@@ -209,37 +235,67 @@
209
235
  text-align: center;
210
236
  }
211
237
 
212
- /* Callouts — same soft-tint cards as Desk visual (no left accent bar). */
238
+ /* Callouts — GitHub Alerts:左侧色条 + 图标/标题,正文与周围连续,没有整块底色。 */
213
239
  :is(.tn-prose, .vp-doc, .cm-lp-card) :where(.tn-custom-block) {
240
+ --tn-alert: var(--tn-c-text-2);
214
241
  box-sizing: border-box;
215
242
  margin: 16px 0;
216
- padding: 12px 16px;
217
- border: 1px solid transparent;
218
- border-radius: 8px;
243
+ padding: 2px 0 2px 16px;
244
+ border: none;
245
+ border-left: 0.25em solid var(--tn-alert);
246
+ border-radius: 0;
247
+ background: none;
219
248
  color: var(--tn-c-text);
220
249
  font-size: inherit;
221
250
  line-height: 1.5;
222
251
  }
223
252
  :is(.tn-prose, .vp-doc, .cm-lp-card) :where(.tn-custom-block.tip) {
224
- background-color: var(--tn-c-highlight);
253
+ --tn-alert: var(--tn-c-success);
225
254
  }
226
- :is(.tn-prose, .vp-doc, .cm-lp-card) :where(.tn-custom-block.info),
227
- :is(.tn-prose, .vp-doc, .cm-lp-card) :where(.tn-custom-block.note),
228
- :is(.tn-prose, .vp-doc, .cm-lp-card) :where(.tn-custom-block.details) {
229
- background-color: var(--tn-c-hover);
255
+ :is(.tn-prose, .vp-doc, .cm-lp-card) :where(.tn-custom-block.info) {
256
+ --tn-alert: var(--tn-c-link);
230
257
  }
231
258
  :is(.tn-prose, .vp-doc, .cm-lp-card) :where(.tn-custom-block.warning) {
232
- background-color: var(--tn-c-warning-soft);
259
+ --tn-alert: var(--tn-c-warning);
233
260
  }
234
261
  :is(.tn-prose, .vp-doc, .cm-lp-card) :where(.tn-custom-block.danger) {
235
- background-color: var(--tn-c-danger-soft);
262
+ --tn-alert: var(--tn-c-danger);
263
+ }
264
+ :is(.tn-prose, .vp-doc, .cm-lp-card) :where(.tn-custom-block.details) {
265
+ --tn-alert: var(--tn-c-text-2);
266
+ }
267
+ :is(.tn-prose, .vp-doc, .cm-lp-card) :where(.tn-alert-icon) {
268
+ display: inline-flex;
269
+ flex: none;
270
+ color: var(--tn-alert);
271
+ line-height: 0;
272
+ }
273
+ :is(.tn-prose, .vp-doc, .cm-lp-card) :where(.tn-alert-icon svg) {
274
+ width: 16px;
275
+ height: 16px;
276
+ display: block;
236
277
  }
237
278
  :is(.tn-prose, .vp-doc, .cm-lp-card) :where(.tn-custom-block-title) {
238
- margin: 0 0 2px;
279
+ display: flex;
280
+ align-items: center;
281
+ gap: 8px;
282
+ margin: 0 0 4px;
239
283
  font-size: inherit;
240
284
  font-weight: 600;
241
285
  line-height: 1.5;
286
+ color: var(--tn-alert);
287
+ }
288
+ :is(.tn-prose, .vp-doc, .cm-lp-card) :where(.tn-container-source) {
289
+ margin: 8px 0;
290
+ padding: 8px 12px;
291
+ border-radius: 6px;
292
+ background: var(--tn-c-bg-soft);
242
293
  color: var(--tn-c-text);
294
+ font-family: var(--tn-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
295
+ font-size: 13px;
296
+ line-height: 1.5;
297
+ white-space: pre-wrap;
298
+ overflow-x: auto;
243
299
  }
244
300
  :is(.tn-prose, .vp-doc, .cm-lp-card) :where(.tn-custom-block > p:not(.tn-custom-block-title)) {
245
301
  margin: 0;
@@ -251,6 +307,7 @@
251
307
  margin-bottom: 0;
252
308
  }
253
309
  :is(.tn-prose, .vp-doc, .cm-lp-card) :where(details.tn-custom-block > summary) {
310
+ position: relative;
254
311
  display: flex;
255
312
  align-items: center;
256
313
  gap: 8px;
@@ -268,7 +325,57 @@
268
325
  :where(details.tn-custom-block > summary::-webkit-details-marker) {
269
326
  display: none;
270
327
  }
271
- :is(.tn-prose, .vp-doc, .cm-lp-card) :where(details.tn-custom-block > summary::before) {
328
+ /* 折叠折线和标题折叠一样挂在色条左外侧(悬停或收起时显示),图标和标题留在其它提示块的位置。
329
+ right 要越过 details 的左内边距 16px 和 0.25em 色条。 */
330
+ :is(.tn-prose, .vp-doc, .cm-lp-card) :where(details.tn-custom-block > summary > .tn-details-fold) {
331
+ position: absolute;
332
+ top: 0;
333
+ bottom: 0;
334
+ right: calc(100% + 16px + 0.25em);
335
+ display: inline-flex;
336
+ align-items: center;
337
+ justify-content: center;
338
+ width: 20px;
339
+ border-radius: 4px;
340
+ color: var(--tn-c-text-2);
341
+ line-height: 0;
342
+ opacity: 0;
343
+ transition: opacity 120ms ease;
344
+ }
345
+ :is(.tn-prose, .vp-doc, .cm-lp-card)
346
+ :where(details.tn-custom-block > summary:hover > .tn-details-fold) {
347
+ color: var(--tn-c-brand);
348
+ background: var(--tn-c-hover);
349
+ }
350
+ :is(.tn-prose, .vp-doc, .cm-lp-card)
351
+ :where(
352
+ details.tn-custom-block:not([open]) > summary > .tn-details-fold,
353
+ details.tn-custom-block:hover > summary > .tn-details-fold,
354
+ details.tn-custom-block > summary:focus-visible > .tn-details-fold
355
+ ) {
356
+ opacity: 1;
357
+ }
358
+ @media (hover: none) {
359
+ :is(.tn-prose, .vp-doc, .cm-lp-card)
360
+ :where(details.tn-custom-block > summary > .tn-details-fold) {
361
+ opacity: 1;
362
+ }
363
+ }
364
+ :is(.tn-prose, .vp-doc, .cm-lp-card)
365
+ :where(details.tn-custom-block > summary > .tn-details-fold svg) {
366
+ width: 12px;
367
+ height: 12px;
368
+ transform: none;
369
+ transform-origin: 50% 50%;
370
+ transition: transform 120ms ease;
371
+ }
372
+ :is(.tn-prose, .vp-doc, .cm-lp-card)
373
+ :where(details.tn-custom-block[open] > summary > .tn-details-fold svg) {
374
+ transform: rotate(90deg);
375
+ }
376
+ /* 旧版无 SVG 的 summary(缓存页等)仍用 CSS 折线,避免裸标题 */
377
+ :is(.tn-prose, .vp-doc, .cm-lp-card)
378
+ :where(details.tn-custom-block > summary:not(:has(.tn-details-fold))::before) {
272
379
  content: '';
273
380
  flex: none;
274
381
  box-sizing: border-box;
@@ -282,7 +389,8 @@
282
389
  transform-origin: 50% 50%;
283
390
  transition: transform 120ms ease;
284
391
  }
285
- :is(.tn-prose, .vp-doc, .cm-lp-card) :where(details.tn-custom-block[open] > summary::before) {
392
+ :is(.tn-prose, .vp-doc, .cm-lp-card)
393
+ :where(details.tn-custom-block[open] > summary:not(:has(.tn-details-fold))::before) {
286
394
  margin-top: -0.05em;
287
395
  transform: rotate(45deg);
288
396
  }
@@ -61,7 +61,10 @@ const READY = 'tnSwiperReady'
61
61
  * already-hydrated roots are skipped via `data-tn-swiper-ready`.
62
62
  * When `root` itself is a `.tn-swiper`, it is included.
63
63
  */
64
- export function hydrateTnSwipers(root: ParentNode = document): number {
64
+ export function hydrateTnSwipers(
65
+ root: ParentNode = document,
66
+ options?: { activeIndex?: number; onActivate?: (index: number) => void }
67
+ ): number {
65
68
  const hosts: HTMLElement[] = []
66
69
  if (root instanceof HTMLElement && root.classList.contains('tn-swiper')) {
67
70
  hosts.push(root)
@@ -74,7 +77,7 @@ export function hydrateTnSwipers(root: ParentNode = document): number {
74
77
  if (seen.has(host)) continue
75
78
  seen.add(host)
76
79
  if (host.dataset[READY] === 'true') continue
77
- if (hydrateOne(host)) {
80
+ if (hydrateOne(host, options)) {
78
81
  host.dataset[READY] = 'true'
79
82
  count += 1
80
83
  }
@@ -82,7 +85,10 @@ export function hydrateTnSwipers(root: ParentNode = document): number {
82
85
  return count
83
86
  }
84
87
 
85
- function hydrateOne(host: HTMLElement): boolean {
88
+ function hydrateOne(
89
+ host: HTMLElement,
90
+ options?: { activeIndex?: number; onActivate?: (index: number) => void }
91
+ ): boolean {
86
92
  if (host.classList.contains('is-empty')) return true
87
93
  const tabs = host.querySelector<HTMLElement>('.tn-swiper-tabs')
88
94
  const wrapper = host.querySelector<HTMLElement>('.swiper-wrapper')
@@ -117,6 +123,7 @@ function hydrateOne(host: HTMLElement): boolean {
117
123
  slide.classList.toggle('is-active', on)
118
124
  slide.hidden = !on
119
125
  })
126
+ options?.onActivate?.(index)
120
127
  }
121
128
 
122
129
  const activeIndex = (): number => {
@@ -156,6 +163,10 @@ function hydrateOne(host: HTMLElement): boolean {
156
163
  tabsEl.remove()
157
164
  }
158
165
 
159
- activate(0)
166
+ const start = Math.min(
167
+ Math.max(0, options?.activeIndex ?? 0),
168
+ Math.max(0, slides.length - 1)
169
+ )
170
+ activate(start)
160
171
  return true
161
172
  }