@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.
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/package.json +68 -0
- package/src/components/BilibiliVideo/BilibiliVideo.vue +55 -0
- package/src/components/Footprints/Footprints.vue +339 -0
- package/src/components/Footprints/parse.ts +122 -0
- package/src/components/Mermaid/Mermaid.vue +553 -0
- package/src/components/Mermaid/icons/icon__center_off.svg +1 -0
- package/src/components/Mermaid/icons/icon__center_on.svg +1 -0
- package/src/components/Mermaid/icons/icon__check.svg +3 -0
- package/src/components/Mermaid/icons/icon__clipboard.svg +8 -0
- package/src/components/Mermaid/icons/icon__fullscreen.svg +1 -0
- package/src/components/Mermaid/icons/icon__fullscreen_exit.svg +1 -0
- package/src/components/Mindmap/FocusBreadcrumbs.test.ts +265 -0
- package/src/components/Mindmap/FocusBreadcrumbs.vue +436 -0
- package/src/components/Mindmap/InlineRuns.ts +25 -0
- package/src/components/Mindmap/Mindmap.vue +1210 -0
- package/src/components/Mindmap/MindmapOutlineNode.vue +62 -0
- package/src/components/Mindmap/MindmapViewIcon.vue +41 -0
- package/src/components/Mindmap/editor/AppIcon.vue +107 -0
- package/src/components/Mindmap/editor/CanvasContextMenu.vue +157 -0
- package/src/components/Mindmap/editor/LinkPopover.vue +83 -0
- package/src/components/Mindmap/editor/MarkdownView.vue +163 -0
- package/src/components/Mindmap/editor/MindmapView.vue +253 -0
- package/src/components/Mindmap/editor/OutlineView.vue +2494 -0
- package/src/components/Mindmap/editor/RichInlineEditor.vue +393 -0
- package/src/components/Mindmap/editor/SelectionToolbar.vue +191 -0
- package/src/components/Mindmap/editor/canvasClipboard.ts +6 -0
- package/src/components/Mindmap/editor/imagePaste.ts +32 -0
- package/src/components/Mindmap/editor/mindmapClipboard.ts +93 -0
- package/src/components/Mindmap/editor/outlineDrag.ts +29 -0
- package/src/components/Mindmap/editor/platform.ts +18 -0
- package/src/components/Mindmap/expandLevel.ts +28 -0
- package/src/components/Mindmap/icons/icon__fullscreen.svg +1 -0
- package/src/components/Mindmap/icons/icon__fullscreen_exit.svg +1 -0
- package/src/components/Mindmap/icons/icon__zoom_fit.svg +1 -0
- package/src/components/Mindmap/markdown.ts +83 -0
- package/src/components/Mindmap/wheelInteraction.ts +7 -0
- package/src/components/NotesTable/NotesTable.vue +119 -0
- package/src/components/NotesTable/types.ts +6 -0
- package/src/components/WordList/RightClickMenu.vue +106 -0
- package/src/components/WordList/WordList.vue +692 -0
- package/src/components/WordList/wordListFeatures.ts +38 -0
- package/src/index.ts +30 -0
- 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">×</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(``, '')
|
|
116
|
+
}
|
|
117
|
+
if (payload.otherInfo.trim()) {
|
|
118
|
+
chunks.push('---', '', payload.otherInfo.trim(), '')
|
|
119
|
+
}
|
|
120
|
+
chunks.push(':::')
|
|
121
|
+
return `${chunks.join('\n')}\n`
|
|
122
|
+
}
|