@tnotesjs/ui 0.1.0

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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -0
  3. package/package.json +68 -0
  4. package/src/components/BilibiliVideo/BilibiliVideo.vue +55 -0
  5. package/src/components/Footprints/Footprints.vue +339 -0
  6. package/src/components/Footprints/parse.ts +122 -0
  7. package/src/components/Mermaid/Mermaid.vue +553 -0
  8. package/src/components/Mermaid/icons/icon__center_off.svg +1 -0
  9. package/src/components/Mermaid/icons/icon__center_on.svg +1 -0
  10. package/src/components/Mermaid/icons/icon__check.svg +3 -0
  11. package/src/components/Mermaid/icons/icon__clipboard.svg +8 -0
  12. package/src/components/Mermaid/icons/icon__fullscreen.svg +1 -0
  13. package/src/components/Mermaid/icons/icon__fullscreen_exit.svg +1 -0
  14. package/src/components/Mindmap/FocusBreadcrumbs.test.ts +265 -0
  15. package/src/components/Mindmap/FocusBreadcrumbs.vue +436 -0
  16. package/src/components/Mindmap/InlineRuns.ts +25 -0
  17. package/src/components/Mindmap/Mindmap.vue +1210 -0
  18. package/src/components/Mindmap/MindmapOutlineNode.vue +62 -0
  19. package/src/components/Mindmap/MindmapViewIcon.vue +41 -0
  20. package/src/components/Mindmap/editor/AppIcon.vue +107 -0
  21. package/src/components/Mindmap/editor/CanvasContextMenu.vue +157 -0
  22. package/src/components/Mindmap/editor/LinkPopover.vue +83 -0
  23. package/src/components/Mindmap/editor/MarkdownView.vue +163 -0
  24. package/src/components/Mindmap/editor/MindmapView.vue +253 -0
  25. package/src/components/Mindmap/editor/OutlineView.vue +2494 -0
  26. package/src/components/Mindmap/editor/RichInlineEditor.vue +393 -0
  27. package/src/components/Mindmap/editor/SelectionToolbar.vue +191 -0
  28. package/src/components/Mindmap/editor/canvasClipboard.ts +6 -0
  29. package/src/components/Mindmap/editor/imagePaste.ts +32 -0
  30. package/src/components/Mindmap/editor/mindmapClipboard.ts +93 -0
  31. package/src/components/Mindmap/editor/outlineDrag.ts +29 -0
  32. package/src/components/Mindmap/editor/platform.ts +18 -0
  33. package/src/components/Mindmap/expandLevel.ts +28 -0
  34. package/src/components/Mindmap/icons/icon__fullscreen.svg +1 -0
  35. package/src/components/Mindmap/icons/icon__fullscreen_exit.svg +1 -0
  36. package/src/components/Mindmap/icons/icon__zoom_fit.svg +1 -0
  37. package/src/components/Mindmap/markdown.ts +83 -0
  38. package/src/components/Mindmap/wheelInteraction.ts +7 -0
  39. package/src/components/NotesTable/NotesTable.vue +119 -0
  40. package/src/components/NotesTable/types.ts +6 -0
  41. package/src/components/WordList/RightClickMenu.vue +106 -0
  42. package/src/components/WordList/WordList.vue +692 -0
  43. package/src/components/WordList/wordListFeatures.ts +38 -0
  44. package/src/index.ts +30 -0
  45. package/src/styles/tokens.css +35 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) tnotesjs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # @tnotesjs/ui
2
+
3
+ Shared Vue UI for TNotes built-in blocks. Consumed by:
4
+
5
+ - `@tnotesjs/core` VitePress theme (`tn:dev`)
6
+ - TNotes Desk (Electron visual editor)
7
+
8
+ ## Package rules
9
+
10
+ - No direct `vitepress` imports in components
11
+ - Styles use `--tn-*` tokens (`src/styles/tokens.css`)
12
+ - Built-ins use short semantic names in markdown (`BilibiliVideo`, `WordList`); third-party components must use a vendor prefix
13
+ - Short one-letter tags (`B` / `E` / `N` / `F`) are **not** supported
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pnpm add @tnotesjs/ui
19
+ ```
20
+
21
+ Requires `vue` `^3.5` (peer).
22
+
23
+ ## Local development
24
+
25
+ Sibling checkouts under `tnotesjs/` (optional `file:` while iterating):
26
+
27
+ ```text
28
+ tnotesjs/ui
29
+ tnotesjs/core # production: "@tnotesjs/ui": "^0.1.0"
30
+ tnotesjs/desk # production: "@tnotesjs/ui": "^0.1.0"
31
+ ```
32
+
33
+ ```bash
34
+ cd ui && pnpm install
35
+ cd ../core && pnpm install
36
+ cd ../desk && pnpm install
37
+ ```
38
+
39
+ Published packages should depend on the npm version of `@tnotesjs/ui`, not `file:../ui`.
40
+
41
+ ## Components
42
+
43
+ ### `BilibiliVideo` — `<BilibiliVideo id="BVxxxx" />`
44
+
45
+ | Prop | Default | Notes |
46
+ |------|---------|--------|
47
+ | `id` | required | BV id |
48
+ | `autoplay` | `false` | Embed URL sends `autoplay=0` unless enabled |
49
+ | `muted` | `false` | Embed URL sends `muted=0\|1` |
50
+
51
+ ```md
52
+ <BilibiliVideo id="BV1QR4y1y7GG" :autoplay="true" :muted="true" />
53
+ ```
54
+
55
+ ### `WordList` — `<WordList :words="[…]" />`
56
+
57
+ | Prop | Default | Notes |
58
+ |------|---------|--------|
59
+ | `words` | `[]` | Word strings |
60
+ | `needSort` | `false` | Sort A→Z by first letter |
61
+ | `wordsBaseUrl` | TNotes.en-words blob URL | Optional host override |
62
+ | `wordsRawBaseUrl` | TNotes.en-words raw URL | Optional host override |
63
+ | `features` | `WORD_LIST_FEATURES_FULL` | Capability flags (see below) |
64
+
65
+ ```md
66
+ <WordList :words="['cancel', 'salary']" :needSort="true" />
67
+ ```
68
+
69
+ **Features presets** (no consumer/`host` field — capability-based):
70
+
71
+ | Preset | Cards | Fetch word data | Menu Pin | Menu Auto Show Card |
72
+ |--------|-------|-----------------|----------|---------------------|
73
+ | `WORD_LIST_FEATURES_FULL` (default) | ✓ | ✓ | ✓ | ✓ |
74
+ | `WORD_LIST_FEATURES_STATIC` | — | — | — | — |
75
+
76
+ ### `Mermaid`
77
+
78
+ Shared diagram preview for core (`tn:dev`) and Desk. Markdown fence language: `mermaid`.
79
+
80
+ | Prop | Default | Notes |
81
+ |------|---------|--------|
82
+ | `source` | `''` | Plain Mermaid text (Desk) |
83
+ | `graph` | `''` | URI-encoded source (VitePress fence) |
84
+ | `id` | auto | Mermaid render id |
85
+ | `center` | `false` | From fence keyword `center`; omit → not centered |
86
+ | `isDark` | auto | Or pass explicitly |
87
+ | `securityLevel` | `'loose'` | Desk preview uses `'strict'` |
88
+ | `enableCopy` | `true` | Hover copy action |
89
+ | `enableFullscreen` | `true` | Hover fullscreen action |
90
+ | `enableCenterToggle` | `true` | Hover center toggle (left of fullscreen) |
91
+
92
+ Fence:
93
+
94
+ - `` ```mermaid `` → not centered
95
+ - `` ```mermaid center `` → centered
96
+
97
+ Icons for the toggle live in `src/components/Mermaid/icons/` (`icon__center_on.svg` / `icon__center_off.svg`).
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@tnotesjs/ui",
3
+ "version": "0.1.0",
4
+ "description": "Shared Vue UI for TNotes built-in blocks — consumed by VitePress (core) and Desk.",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./src/index.ts",
9
+ "import": "./src/index.ts",
10
+ "default": "./src/index.ts"
11
+ },
12
+ "./footprints-parse": {
13
+ "types": "./src/components/Footprints/parse.ts",
14
+ "import": "./src/components/Footprints/parse.ts",
15
+ "default": "./src/components/Footprints/parse.ts"
16
+ },
17
+ "./styles/tokens.css": "./src/styles/tokens.css"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "sideEffects": [
25
+ "**/*.css"
26
+ ],
27
+ "peerDependencies": {
28
+ "vue": "^3.5.0"
29
+ },
30
+ "dependencies": {
31
+ "@tnotesjs/mindmap-core": "^0.2.2",
32
+ "marked": "^15.0.11",
33
+ "mermaid": "^11.17.0"
34
+ },
35
+ "devDependencies": {
36
+ "@vitejs/plugin-vue": "^6.0.8",
37
+ "happy-dom": "^20.12.0",
38
+ "sass": "^1.90.0",
39
+ "typescript": "^5.9.3",
40
+ "vitest": "^4.1.11",
41
+ "vue": "^3.5.25"
42
+ },
43
+ "engines": {
44
+ "node": ">=18.0.0"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/tnotesjs/ui.git"
52
+ },
53
+ "homepage": "https://github.com/tnotesjs/ui#readme",
54
+ "bugs": {
55
+ "url": "https://github.com/tnotesjs/ui/issues"
56
+ },
57
+ "keywords": [
58
+ "tnotesjs",
59
+ "vue",
60
+ "vitepress",
61
+ "desk"
62
+ ],
63
+ "license": "MIT",
64
+ "scripts": {
65
+ "typecheck": "tsc --noEmit -p tsconfig.json",
66
+ "test": "vitest run"
67
+ }
68
+ }
@@ -0,0 +1,55 @@
1
+ <template>
2
+ <iframe
3
+ class="tn-bilibili-video"
4
+ :src="playerSrc"
5
+ scrolling="no"
6
+ border="0"
7
+ frameborder="no"
8
+ framespacing="0"
9
+ allowfullscreen="true"
10
+ title="Bilibili video"
11
+ />
12
+ </template>
13
+
14
+ <script setup lang="ts">
15
+ import { computed } from 'vue'
16
+
17
+ const props = withDefaults(
18
+ defineProps<{
19
+ /** Bilibili BV id, e.g. BV1QR4y1y7GG */
20
+ id: string
21
+ /** Start playback when the iframe loads. Default off — embeds must not surprise readers. */
22
+ autoplay?: boolean
23
+ /** Start muted. Useful if autoplay is enabled (browser policies often require mute). */
24
+ muted?: boolean
25
+ }>(),
26
+ {
27
+ autoplay: false,
28
+ muted: false
29
+ }
30
+ )
31
+
32
+ const playerSrc = computed(() => {
33
+ const id = props.id?.trim() ?? ''
34
+ if (!id) return ''
35
+ const params = new URLSearchParams({
36
+ isOutside: 'true',
37
+ bvid: id,
38
+ // Official embed docs: boolean query values are 0 | 1.
39
+ autoplay: props.autoplay ? '1' : '0',
40
+ muted: props.muted ? '1' : '0'
41
+ })
42
+ return `https://player.bilibili.com/player.html?${params.toString()}`
43
+ })
44
+ </script>
45
+
46
+ <style scoped>
47
+ .tn-bilibili-video {
48
+ display: block;
49
+ width: 100%;
50
+ aspect-ratio: 16 / 9;
51
+ margin: 1rem 0;
52
+ border: 0;
53
+ background: var(--tn-c-divider, #e2e2e3);
54
+ }
55
+ </style>
@@ -0,0 +1,339 @@
1
+ <script setup lang="ts">
2
+ import {
3
+ computed,
4
+ nextTick,
5
+ onBeforeUnmount,
6
+ onMounted,
7
+ ref,
8
+ useSlots,
9
+ watch
10
+ } from 'vue'
11
+
12
+ const props = withDefaults(
13
+ defineProps<{
14
+ /** ISO-ish datetime parts: [y, m, d?, h?, min?, s?] */
15
+ times?: number[]
16
+ /** Text paragraphs when not using `#text-area` slot. */
17
+ paragraphs?: string[]
18
+ /** Image URLs when not using `#image-list` slot. */
19
+ images?: string[]
20
+ /** Footer / other info plain text when not using `#other-info` slot. */
21
+ otherInfo?: string
22
+ }>(),
23
+ {
24
+ times: () => [],
25
+ paragraphs: () => [],
26
+ images: () => [],
27
+ otherInfo: ''
28
+ }
29
+ )
30
+
31
+ const slots = useSlots()
32
+ const textEl = ref<HTMLElement | null>(null)
33
+ const imageEl = ref<HTMLElement | null>(null)
34
+
35
+ const isCollapsed = ref(true)
36
+ const isExpandable = ref(false)
37
+ const isModalVisible = ref(false)
38
+ const currentIndex = ref(0)
39
+ const slottedImages = ref<string[]>([])
40
+
41
+ const useTextSlot = computed(() => !!slots['text-area'])
42
+ const useImageSlot = computed(() => !!slots['image-list'])
43
+ const useOtherSlot = computed(() => !!slots['other-info'])
44
+
45
+ const resolvedImages = computed(() =>
46
+ useImageSlot.value ? slottedImages.value : (props.images ?? [])
47
+ )
48
+ const currentImage = computed(() => resolvedImages.value[currentIndex.value] ?? '')
49
+
50
+ const imageLayoutClass = computed(() => {
51
+ const n = resolvedImages.value.length
52
+ if (n === 1) return 'fp-layout-1'
53
+ if (n === 2) return 'fp-layout-2'
54
+ if (n === 4) return 'fp-layout-4'
55
+ if (n >= 3) return 'fp-layout-grid'
56
+ return ''
57
+ })
58
+
59
+ const formattedTime = computed(() => {
60
+ const times = props.times ?? []
61
+ if (times.length < 2) return ''
62
+ const [year, month, day, hour, minute, second] = times
63
+ const datePart = `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}`
64
+ const dayPart = day !== undefined ? `-${String(day).padStart(2, '0')}` : ''
65
+ let timePart = ''
66
+ if (hour !== undefined && minute !== undefined && second !== undefined) {
67
+ timePart = ` ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}`
68
+ } else if (hour !== undefined && minute !== undefined) {
69
+ timePart = ` ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
70
+ }
71
+ let daysSinceBirthday = ''
72
+ if (day !== undefined) {
73
+ const birthday = new Date(Date.UTC(1999, 5, 29))
74
+ const currentDate = new Date(Date.UTC(year, month - 1, day))
75
+ const diffInDays =
76
+ Math.floor((currentDate.getTime() - birthday.getTime()) / (1000 * 60 * 60 * 24)) + 1
77
+ daysSinceBirthday = `👣 ${diffInDays} | `
78
+ }
79
+ return daysSinceBirthday + datePart + dayPart + timePart
80
+ })
81
+
82
+ function openModal(index: number) {
83
+ currentIndex.value = index
84
+ isModalVisible.value = true
85
+ }
86
+ function closeModal() {
87
+ isModalVisible.value = false
88
+ }
89
+ function toggleCollapse() {
90
+ isCollapsed.value = !isCollapsed.value
91
+ }
92
+
93
+ function refreshTextExpandable() {
94
+ if (useTextSlot.value) {
95
+ isExpandable.value = (textEl.value?.children.length ?? 0) > 2
96
+ return
97
+ }
98
+ isExpandable.value = (props.paragraphs?.length ?? 0) > 2
99
+ }
100
+
101
+ function refreshSlottedImages() {
102
+ if (!useImageSlot.value || !imageEl.value) {
103
+ slottedImages.value = []
104
+ return
105
+ }
106
+ const imgs = imageEl.value.querySelectorAll('img')
107
+ slottedImages.value = Array.from(imgs).map((img) => img.currentSrc || img.src)
108
+ }
109
+
110
+ function handleKeyDown(event: KeyboardEvent) {
111
+ if (!isModalVisible.value) return
112
+ if (event.key === 'ArrowLeft' && currentIndex.value > 0) currentIndex.value -= 1
113
+ if (event.key === 'ArrowRight' && currentIndex.value < resolvedImages.value.length - 1)
114
+ currentIndex.value += 1
115
+ if (event.key === 'Escape') closeModal()
116
+ }
117
+
118
+ function refresh() {
119
+ nextTick(() => {
120
+ refreshTextExpandable()
121
+ refreshSlottedImages()
122
+ })
123
+ }
124
+
125
+ onMounted(() => {
126
+ window.addEventListener('keydown', handleKeyDown)
127
+ refresh()
128
+ })
129
+ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeyDown))
130
+
131
+ watch(
132
+ () => [props.paragraphs, props.images, slots['text-area'], slots['image-list']],
133
+ () => {
134
+ isCollapsed.value = true
135
+ refresh()
136
+ }
137
+ )
138
+ </script>
139
+
140
+ <template>
141
+ <!-- tn-preview-ignore: opt out of Layout ImagePreview so only the in-component modal opens -->
142
+ <div class="tn-footprints tn-preview-ignore">
143
+ <div class="tn-footprints__text">
144
+ <div
145
+ ref="textEl"
146
+ class="tn-footprints__text-body"
147
+ :class="{ 'is-collapsed': isCollapsed }"
148
+ >
149
+ <slot name="text-area">
150
+ <p v-for="(line, i) in paragraphs" :key="i">{{ line }}</p>
151
+ </slot>
152
+ </div>
153
+ <button
154
+ v-if="isExpandable"
155
+ type="button"
156
+ class="tn-footprints__toggle"
157
+ @click="toggleCollapse"
158
+ >
159
+ {{ isCollapsed ? '全文' : '收起' }}
160
+ </button>
161
+ </div>
162
+
163
+ <div
164
+ v-if="useImageSlot || images.length"
165
+ ref="imageEl"
166
+ class="tn-footprints__images"
167
+ :class="imageLayoutClass"
168
+ >
169
+ <slot name="image-list" :open-modal="openModal" :openModal="openModal">
170
+ <button
171
+ v-for="(src, index) in images"
172
+ :key="`${src}-${index}`"
173
+ type="button"
174
+ class="tn-footprints__image-btn"
175
+ @click="openModal(index)"
176
+ >
177
+ <img :src="src" alt="" />
178
+ </button>
179
+ </slot>
180
+ </div>
181
+
182
+ <div v-show="isModalVisible" class="tn-footprints__modal" @click.self="closeModal">
183
+ <button type="button" class="tn-footprints__modal-close" @click="closeModal">&times;</button>
184
+ <img class="tn-footprints__modal-img" :src="currentImage" alt="Preview" />
185
+ </div>
186
+
187
+ <div v-if="formattedTime" class="tn-footprints__time">
188
+ <p>{{ formattedTime }}</p>
189
+ </div>
190
+ <div v-if="useOtherSlot || otherInfo" class="tn-footprints__other">
191
+ <slot name="other-info">
192
+ <p>{{ otherInfo }}</p>
193
+ </slot>
194
+ </div>
195
+ </div>
196
+ </template>
197
+
198
+ <style scoped lang="scss">
199
+ .tn-footprints {
200
+ margin: 1rem 0;
201
+ color: var(--tn-c-text);
202
+ }
203
+
204
+ .tn-footprints__text {
205
+ position: relative;
206
+ margin-bottom: 1rem;
207
+ }
208
+
209
+ .tn-footprints__text-body {
210
+ overflow: hidden;
211
+
212
+ :deep(p) {
213
+ display: block;
214
+ margin: 0 0 0.5rem;
215
+ }
216
+
217
+ &.is-collapsed > :nth-child(n + 3) {
218
+ display: none;
219
+ }
220
+ }
221
+
222
+ .tn-footprints__toggle {
223
+ display: block;
224
+ margin-top: 0.5rem;
225
+ padding: 0;
226
+ border: none;
227
+ background: transparent;
228
+ color: var(--tn-c-brand, #007bff);
229
+ cursor: pointer;
230
+ font-size: 1rem;
231
+ text-align: left;
232
+
233
+ &:hover {
234
+ text-decoration: underline;
235
+ }
236
+ }
237
+
238
+ /* 朋友圈式网格:slot 里的 img 常被 Markdown 包在 p/a 中 */
239
+ .tn-footprints__images {
240
+ display: grid;
241
+ gap: 4px;
242
+ width: 100%;
243
+
244
+ :deep(> *) {
245
+ margin: 0;
246
+ min-width: 0;
247
+ }
248
+
249
+ :deep(p) {
250
+ margin: 0;
251
+ line-height: 0;
252
+ }
253
+
254
+ :deep(a) {
255
+ display: block;
256
+ line-height: 0;
257
+ }
258
+
259
+ :deep(img) {
260
+ width: 100%;
261
+ aspect-ratio: 1;
262
+ object-fit: cover;
263
+ display: block;
264
+ margin: 0;
265
+ cursor: pointer;
266
+ vertical-align: top;
267
+ border-radius: 4px;
268
+ }
269
+ }
270
+
271
+ .tn-footprints__image-btn {
272
+ margin: 0;
273
+ padding: 0;
274
+ border: none;
275
+ background: transparent;
276
+ cursor: pointer;
277
+ }
278
+
279
+ .fp-layout-1 {
280
+ grid-template-columns: 1fr;
281
+ max-width: 280px;
282
+
283
+ :deep(img) {
284
+ width: auto;
285
+ max-width: 100%;
286
+ max-height: 360px;
287
+ aspect-ratio: auto;
288
+ }
289
+ }
290
+ .fp-layout-2 {
291
+ grid-template-columns: repeat(2, 1fr);
292
+ max-width: 360px;
293
+ }
294
+ .fp-layout-4 {
295
+ grid-template-columns: repeat(2, 1fr);
296
+ max-width: 360px;
297
+ }
298
+ .fp-layout-grid {
299
+ grid-template-columns: repeat(3, 1fr);
300
+ max-width: 480px;
301
+ }
302
+
303
+ .tn-footprints__modal {
304
+ position: fixed;
305
+ inset: 0;
306
+ z-index: 9999;
307
+ display: flex;
308
+ align-items: center;
309
+ justify-content: center;
310
+ background: rgba(0, 0, 0, 0.85);
311
+ }
312
+
313
+ .tn-footprints__modal-close {
314
+ position: absolute;
315
+ top: 16px;
316
+ right: 24px;
317
+ border: none;
318
+ background: transparent;
319
+ color: #fff;
320
+ font-size: 2rem;
321
+ cursor: pointer;
322
+ }
323
+
324
+ .tn-footprints__modal-img {
325
+ max-width: min(92vw, 960px);
326
+ max-height: 88vh;
327
+ object-fit: contain;
328
+ }
329
+
330
+ .tn-footprints__time,
331
+ .tn-footprints__other {
332
+ font-size: 0.8rem;
333
+ color: var(--tn-c-text-2, gray);
334
+
335
+ :deep(p) {
336
+ margin: 0.25rem 0;
337
+ }
338
+ }
339
+ </style>
@@ -0,0 +1,122 @@
1
+ /** Parse `::: footprints` containers into props for the shared Footprints UI. */
2
+
3
+ export interface FootprintsPayload {
4
+ times: number[]
5
+ paragraphs: string[]
6
+ images: string[]
7
+ otherInfo: string
8
+ }
9
+
10
+ /** `2025-01-22 23:47` / `2025-01-22` → [y,m,d,h?,min?,s?] */
11
+ export function parseFootprintsDatetime(meta: string): number[] {
12
+ const m = meta
13
+ .trim()
14
+ .match(
15
+ /^(\d{4})-(\d{1,2})-(\d{1,2})(?:\s+(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?$/
16
+ )
17
+ if (!m) return []
18
+ const parts = [Number(m[1]), Number(m[2]), Number(m[3])]
19
+ if (m[4] !== undefined) {
20
+ parts.push(Number(m[4]), Number(m[5]))
21
+ if (m[6] !== undefined) parts.push(Number(m[6]))
22
+ }
23
+ return parts
24
+ }
25
+
26
+ function formatDatetime(times: number[]): string {
27
+ if (times.length < 2) return ''
28
+ const [y, m, d, h, min, s] = times
29
+ let out = `${String(y).padStart(4, '0')}-${String(m).padStart(2, '0')}`
30
+ if (d !== undefined) out += `-${String(d).padStart(2, '0')}`
31
+ if (h !== undefined && min !== undefined) {
32
+ out += ` ${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`
33
+ if (s !== undefined) out += `:${String(s).padStart(2, '0')}`
34
+ }
35
+ return out
36
+ }
37
+
38
+ /**
39
+ * Parse a full container source (`::: footprints …` … `:::`).
40
+ */
41
+ export function parseFootprintsSource(source: string): FootprintsPayload {
42
+ const normalized = source.replace(/\r\n?/g, '\n')
43
+ const lines = normalized.split('\n')
44
+ const open = lines[0]?.match(/^ {0,3}:{3,}\s*footprints(?:\s+(.*))?$/i)
45
+ const times = parseFootprintsDatetime((open?.[1] ?? '').trim())
46
+
47
+ let end = lines.length - 1
48
+ while (end > 0 && !/^ {0,3}:{3,}\s*$/.test(lines[end])) end -= 1
49
+ const bodyLines = lines.slice(1, end)
50
+
51
+ const paragraphs: string[] = []
52
+ const images: string[] = []
53
+ let otherInfo = ''
54
+ let buf: string[] = []
55
+ let inOther = false
56
+
57
+ const flush = () => {
58
+ const text = buf.join('\n').trim()
59
+ buf = []
60
+ if (!text) return
61
+ if (inOther) {
62
+ otherInfo = otherInfo ? `${otherInfo}\n${text}` : text
63
+ return
64
+ }
65
+ const imgOnly = text.match(/^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)$/)
66
+ if (imgOnly) {
67
+ images.push(imgOnly[2])
68
+ return
69
+ }
70
+ // Multiple images on separate lines in one block
71
+ const parts = text.split('\n')
72
+ let allImg = true
73
+ const blockImgs: string[] = []
74
+ for (const line of parts) {
75
+ const m = line.trim().match(/^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)$/)
76
+ if (m) blockImgs.push(m[2])
77
+ else if (line.trim()) {
78
+ allImg = false
79
+ break
80
+ }
81
+ }
82
+ if (allImg && blockImgs.length) {
83
+ images.push(...blockImgs)
84
+ return
85
+ }
86
+ paragraphs.push(text)
87
+ }
88
+
89
+ for (const line of bodyLines) {
90
+ if (line.trim() === '---') {
91
+ flush()
92
+ inOther = true
93
+ continue
94
+ }
95
+ if (line.trim() === '') {
96
+ flush()
97
+ continue
98
+ }
99
+ buf.push(line)
100
+ }
101
+ flush()
102
+
103
+ return { times, paragraphs, images, otherInfo }
104
+ }
105
+
106
+ /** Rebuild canonical `::: footprints` source from structured fields. */
107
+ export function rebuildFootprintsSource(payload: FootprintsPayload): string {
108
+ const meta = formatDatetime(payload.times)
109
+ const open = meta ? `::: footprints ${meta}` : '::: footprints'
110
+ const chunks: string[] = [open, '']
111
+ for (const p of payload.paragraphs) {
112
+ chunks.push(p, '')
113
+ }
114
+ for (const src of payload.images) {
115
+ chunks.push(`![](${src})`, '')
116
+ }
117
+ if (payload.otherInfo.trim()) {
118
+ chunks.push('---', '', payload.otherInfo.trim(), '')
119
+ }
120
+ chunks.push(':::')
121
+ return `${chunks.join('\n')}\n`
122
+ }