@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
@@ -0,0 +1,62 @@
1
+ <script setup lang="ts">
2
+ import InlineRuns from './InlineRuns'
3
+
4
+ import type { MindmapNode } from '@tnotesjs/mindmap-core'
5
+
6
+ defineOptions({ name: 'MindmapOutlineNode' })
7
+
8
+ defineProps<{
9
+ node: MindmapNode
10
+ version: number
11
+ root?: boolean
12
+ }>()
13
+
14
+ defineEmits<{
15
+ toggle: [id: string]
16
+ }>()
17
+ </script>
18
+
19
+ <template>
20
+ <li class="mindmap-outline-node" :class="{ 'is-root': root, 'is-done': node.content.checked === true }">
21
+ <div class="mindmap-outline-row">
22
+ <button
23
+ v-if="node.children.length > 0"
24
+ type="button"
25
+ class="mindmap-outline-toggle"
26
+ :aria-label="node.collapsed ? '展开主题' : '折叠主题'"
27
+ :aria-expanded="!node.collapsed"
28
+ @click="$emit('toggle', node.id)"
29
+ >
30
+ {{ node.collapsed ? '›' : '⌄' }}
31
+ </button>
32
+ <span v-else class="mindmap-outline-leaf" aria-hidden="true">•</span>
33
+ <input
34
+ v-if="node.content.checked !== null && !root"
35
+ class="mindmap-outline-checkbox"
36
+ type="checkbox"
37
+ :checked="node.content.checked"
38
+ disabled
39
+ aria-label="只读待办状态"
40
+ />
41
+ <span class="mindmap-outline-label">
42
+ <InlineRuns :raw="node.content.image ? node.content.text : node.content.raw" />
43
+ </span>
44
+ </div>
45
+ <img
46
+ v-if="node.content.image"
47
+ class="mindmap-outline-image"
48
+ :src="node.content.image.src"
49
+ :alt="node.content.image.alt"
50
+ :style="node.content.image.width ? { width: `${node.content.image.width}px` } : undefined"
51
+ />
52
+ <ul v-if="node.children.length > 0 && !node.collapsed" class="mindmap-outline-children">
53
+ <MindmapOutlineNode
54
+ v-for="child in node.children"
55
+ :key="child.id"
56
+ :node="child"
57
+ :version="version"
58
+ @toggle="$emit('toggle', $event)"
59
+ />
60
+ </ul>
61
+ </li>
62
+ </template>
@@ -0,0 +1,41 @@
1
+ <script setup lang="ts">
2
+ defineProps<{
3
+ view: 'mindmap' | 'outline' | 'source'
4
+ }>()
5
+ </script>
6
+
7
+ <template>
8
+ <svg v-if="view === 'mindmap'" aria-hidden="true" viewBox="0 0 48 48">
9
+ <path d="M0 0h48v48H0z" fill="none" />
10
+ <path
11
+ d="M26 24h16M26 38h16M26 10h16M18 24H6h4m8 14c-6-2-2-14-8-14m8-14c-6 2-2 14-8 14"
12
+ fill="none"
13
+ stroke="#636cff"
14
+ stroke-linecap="round"
15
+ stroke-linejoin="round"
16
+ stroke-width="4"
17
+ />
18
+ </svg>
19
+ <svg v-else-if="view === 'outline'" aria-hidden="true" viewBox="0 0 48 48">
20
+ <path d="M0 0h48v48H0z" fill="none" />
21
+ <path
22
+ d="M26 24h18m-30 0h4m0 14h26M6 38h4m8-28h26M6 10h4"
23
+ fill="none"
24
+ stroke="#636cff"
25
+ stroke-linecap="round"
26
+ stroke-linejoin="round"
27
+ stroke-width="4"
28
+ />
29
+ </svg>
30
+ <svg v-else aria-hidden="true" viewBox="0 0 24 24">
31
+ <path d="M0 0h24v24H0z" fill="none" />
32
+ <path
33
+ d="m17 8 1.84 1.85c.773.778 1.16 1.167 1.16 1.65s-.387.872-1.16 1.65L17 15M7 8 5.16 9.85C4.387 10.628 4 11.017 4 11.5s.387.872 1.16 1.65L7 15m7.5-11-5 16"
34
+ fill="none"
35
+ stroke="#636cff"
36
+ stroke-linecap="round"
37
+ stroke-linejoin="round"
38
+ stroke-width="1.5"
39
+ />
40
+ </svg>
41
+ </template>
@@ -0,0 +1,107 @@
1
+ <script setup lang="ts">
2
+ defineProps<{ name: string; size?: number }>()
3
+ </script>
4
+
5
+ <template>
6
+ <svg
7
+ class="app-icon"
8
+ :width="size ?? 18"
9
+ :height="size ?? 18"
10
+ viewBox="0 0 24 24"
11
+ fill="none"
12
+ stroke="currentColor"
13
+ stroke-width="1.8"
14
+ stroke-linecap="round"
15
+ stroke-linejoin="round"
16
+ aria-hidden="true"
17
+ >
18
+ <template v-if="name === 'menu'">
19
+ <path d="M4 6h16M4 12h16M4 18h16" />
20
+ </template>
21
+ <template v-else-if="name === 'new'">
22
+ <path d="M6 3h9l3 3v15H6zM14 3v4h4M12 10v7M8.5 13.5h7" />
23
+ </template>
24
+ <template v-else-if="name === 'folder' || name === 'openProject'">
25
+ <path d="M3 6h7l2 2h9v11H3z" />
26
+ <path v-if="name === 'openProject'" d="m10 15 2 2 4-4" />
27
+ </template>
28
+ <template v-else-if="name === 'save'">
29
+ <path d="M5 3h12l2 2v16H5zM8 3v6h8V3M8 15h8" />
30
+ </template>
31
+ <template v-else-if="name === 'download'">
32
+ <path d="M12 3v12m-4-4 4 4 4-4M4 19h16" />
33
+ </template>
34
+ <template v-else-if="name === 'undo'">
35
+ <path d="m9 7-5 5 5 5M4 12h9a6 6 0 0 1 6 6" />
36
+ </template>
37
+ <template v-else-if="name === 'redo'">
38
+ <path d="m15 7 5 5-5 5M20 12h-9a6 6 0 0 0-6 6" />
39
+ </template>
40
+ <template v-else-if="name === 'search'">
41
+ <circle cx="10.5" cy="10.5" r="6.5" /><path d="m16 16 4.5 4.5" />
42
+ </template>
43
+ <template v-else-if="name === 'collapse'">
44
+ <path d="M5 5h6v6H5zM13 13h6v6h-6zM8 11v5h5M15 3v7m-3-3 3 3 3-3" />
45
+ </template>
46
+ <template v-else-if="name === 'focus'">
47
+ <path d="M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5" />
48
+ <circle cx="12" cy="12" r="3" />
49
+ </template>
50
+ <template v-else-if="name === 'zoomIn' || name === 'zoomOut'">
51
+ <circle cx="10.5" cy="10.5" r="6.5" /><path d="m16 16 4.5 4.5M7.5 10.5h6" />
52
+ <path v-if="name === 'zoomIn'" d="M10.5 7.5v6" />
53
+ </template>
54
+ <template v-else-if="name === 'fit'">
55
+ <path d="M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5M8 8h8v8H8z" />
56
+ </template>
57
+ <template v-else-if="name === 'more'">
58
+ <circle cx="5" cy="12" r="1" fill="currentColor" /><circle cx="12" cy="12" r="1" fill="currentColor" /><circle cx="19" cy="12" r="1" fill="currentColor" />
59
+ </template>
60
+ <template v-else-if="name === 'image'">
61
+ <rect x="3" y="4" width="18" height="16" rx="2" /><circle cx="9" cy="9" r="2" /><path d="m4 17 5-5 3 3 2-2 6 6" />
62
+ </template>
63
+ <template v-else-if="name === 'highlight'">
64
+ <path d="m14.5 4.5 5 5-8.2 8.2-5.6.6.6-5.6zM12.5 6.5l5 5M4 21h16" />
65
+ </template>
66
+ <template v-else-if="name === 'link'">
67
+ <path d="M10 13a5 5 0 0 0 7.5.5l2-2a5 5 0 0 0-7-7l-1.2 1.2M14 11a5 5 0 0 0-7.5-.5l-2 2a5 5 0 0 0 7 7l1.2-1.2" />
68
+ </template>
69
+ <template v-else-if="name === 'code'">
70
+ <path d="m8 5-5 7 5 7M16 5l5 7-5 7M14 3l-4 18" />
71
+ </template>
72
+ <template v-else-if="name === 'trash'">
73
+ <path d="M4 7h16M9 7V4h6v3M6 7l1 14h10l1-14M10 11v6M14 11v6" />
74
+ </template>
75
+ <template v-else-if="name === 'copy'">
76
+ <rect x="8" y="8" width="12" height="12" rx="2" /><path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3" />
77
+ </template>
78
+ <template v-else-if="name === 'check'">
79
+ <rect x="3" y="3" width="18" height="18" rx="3" /><path d="m7 12 3 3 7-7" />
80
+ </template>
81
+ <template v-else-if="name === 'removeLink'">
82
+ <path d="M9 15 7.5 16.5a5 5 0 0 1-7-7L3 7M15 9l1.5-1.5a5 5 0 0 1 7 7L21 17M3 3l18 18" />
83
+ </template>
84
+ <template v-else-if="name === 'edit'">
85
+ <path d="m4 20 4.2-1 10.6-10.6a2.1 2.1 0 0 0-3-3L5.2 16zM14.8 6.2l3 3" />
86
+ </template>
87
+ <template v-else-if="name === 'clearFormat'">
88
+ <path d="m4 17 7.5-11.5a2 2 0 0 1 2.8-.5l4.2 2.8a2 2 0 0 1 .5 2.8L12.2 21H7zM8.5 14.5l5.8 3.8M3 21h18" />
89
+ </template>
90
+ <template v-else-if="name === 'close'">
91
+ <path d="M5 5l14 14M19 5 5 19" />
92
+ </template>
93
+ <template v-else-if="name === 'outline'">
94
+ <circle cx="5" cy="6" r="1.2" fill="currentColor" /><circle cx="5" cy="12" r="1.2" fill="currentColor" /><circle cx="5" cy="18" r="1.2" fill="currentColor" /><path d="M9 6h11M9 12h8M9 18h10" />
95
+ </template>
96
+ <template v-else-if="name === 'mindmap'">
97
+ <circle cx="12" cy="5" r="2.5" /><circle cx="5" cy="18" r="2.5" /><circle cx="19" cy="18" r="2.5" /><path d="M12 7.5V12M5 15.5V12h14v3.5" />
98
+ </template>
99
+ <template v-else-if="name === 'source'">
100
+ <path d="m9 6-6 6 6 6M15 6l6 6-6 6M14 3l-4 18" />
101
+ </template>
102
+ </svg>
103
+ </template>
104
+
105
+ <style scoped>
106
+ .app-icon { display: block; flex: none; }
107
+ </style>
@@ -0,0 +1,157 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
3
+ import { isApplePlatform, primaryShortcut } from './platform'
4
+
5
+ const props = withDefaults(defineProps<{
6
+ position: { left: number; top: number }
7
+ multiple?: boolean
8
+ canInsertSibling?: boolean
9
+ canInsertParent?: boolean
10
+ canCut?: boolean
11
+ canDuplicate?: boolean
12
+ canDeleteOnly?: boolean
13
+ canDeleteTree?: boolean
14
+ canToggleSiblings?: boolean
15
+ canFocus?: boolean
16
+ }>(), {
17
+ multiple: false,
18
+ canInsertSibling: true,
19
+ canInsertParent: true,
20
+ canCut: true,
21
+ canDuplicate: true,
22
+ canDeleteOnly: true,
23
+ canDeleteTree: true,
24
+ canToggleSiblings: true,
25
+ canFocus: true,
26
+ })
27
+
28
+ const emit = defineEmits<{
29
+ close: []
30
+ insertSibling: []
31
+ insertChild: []
32
+ insertParent: []
33
+ copy: []
34
+ cut: []
35
+ paste: []
36
+ duplicate: []
37
+ deleteOnly: []
38
+ deleteTree: []
39
+ toggleSiblings: []
40
+ focus: []
41
+ }>()
42
+
43
+ const menu = ref<HTMLElement>()
44
+ const style = computed(() => {
45
+ const width = 310
46
+ const estimatedHeight = props.multiple ? 250 : 570
47
+ const viewportWidth = typeof window === 'undefined' ? 1440 : window.innerWidth
48
+ const viewportHeight = typeof window === 'undefined' ? 900 : window.innerHeight
49
+ return {
50
+ left: `${Math.max(8, Math.min(props.position.left, viewportWidth - width - 8))}px`,
51
+ top: `${Math.max(8, Math.min(props.position.top, viewportHeight - estimatedHeight - 8))}px`,
52
+ }
53
+ })
54
+ const shiftTab = computed(() => isApplePlatform() ? '⇧Tab' : 'Shift+Tab')
55
+
56
+ type MenuAction =
57
+ | 'close'
58
+ | 'insertSibling'
59
+ | 'insertChild'
60
+ | 'insertParent'
61
+ | 'copy'
62
+ | 'cut'
63
+ | 'paste'
64
+ | 'duplicate'
65
+ | 'deleteOnly'
66
+ | 'deleteTree'
67
+ | 'toggleSiblings'
68
+ | 'focus'
69
+
70
+ function act(event: MenuAction) {
71
+ // Vue 的 emit 类型不能从 keyof 精确缩窄,这里统一分发无参数菜单动作。
72
+ ;(emit as (name: string) => void)(event)
73
+ if (event !== 'close') emit('close')
74
+ }
75
+
76
+ function onPointerDown(event: PointerEvent) {
77
+ if (!menu.value?.contains(event.target as Node)) emit('close')
78
+ }
79
+
80
+ function onKeydown(event: KeyboardEvent) {
81
+ if (event.key === 'Escape') emit('close')
82
+ }
83
+
84
+ onMounted(() => {
85
+ document.addEventListener('pointerdown', onPointerDown)
86
+ document.addEventListener('keydown', onKeydown)
87
+ })
88
+
89
+ onBeforeUnmount(() => {
90
+ document.removeEventListener('pointerdown', onPointerDown)
91
+ document.removeEventListener('keydown', onKeydown)
92
+ })
93
+ </script>
94
+
95
+ <template>
96
+ <div ref="menu" class="canvas-context-menu" :style="style" role="menu" :aria-label="multiple ? '多主题右键菜单' : '主题右键菜单'" @contextmenu.prevent>
97
+ <template v-if="!multiple">
98
+ <button type="button" role="menuitem" :disabled="!canInsertSibling" @click="act('insertSibling')"><span>插入同级主题</span><kbd>Enter</kbd></button>
99
+ <button type="button" role="menuitem" @click="act('insertChild')"><span>插入下级主题</span><kbd>Tab</kbd></button>
100
+ <button type="button" role="menuitem" :disabled="!canInsertParent" @click="act('insertParent')"><span>插入上级主题</span><kbd>{{ shiftTab }}</kbd></button>
101
+ <span class="context-divider" />
102
+ </template>
103
+
104
+ <button type="button" role="menuitem" @click="act('copy')"><span>复制</span><kbd>{{ primaryShortcut('C') }}</kbd></button>
105
+ <button type="button" role="menuitem" :disabled="!canCut" @click="act('cut')"><span>剪切</span><kbd>{{ primaryShortcut('X') }}</kbd></button>
106
+
107
+ <template v-if="!multiple">
108
+ <button type="button" role="menuitem" @click="act('paste')"><span>粘贴</span><kbd>{{ primaryShortcut('V') }}</kbd></button>
109
+ <button type="button" role="menuitem" :disabled="!canDuplicate" @click="act('duplicate')"><span>创建副本</span><kbd>{{ primaryShortcut('D') }}</kbd></button>
110
+ <button type="button" role="menuitem" :disabled="!canDeleteOnly" @click="act('deleteOnly')"><span>仅删除当前主题</span></button>
111
+ </template>
112
+
113
+ <button type="button" role="menuitem" class="danger" :disabled="!canDeleteTree" @click="act('deleteTree')"><span>{{ multiple ? '删除所选主题及下级主题' : '删除当前主题及下级主题' }}</span><kbd>Delete</kbd></button>
114
+ <span class="context-divider" />
115
+ <button type="button" role="menuitem" :disabled="!canToggleSiblings" @click="act('toggleSiblings')"><span>展开/折叠同级主题</span><kbd>{{ primaryShortcut('.', { shift: true }) }}</kbd></button>
116
+ <button v-if="!multiple" type="button" role="menuitem" :disabled="!canFocus" @click="act('focus')"><span>进入此主题</span><kbd>{{ primaryShortcut(']') }}</kbd></button>
117
+ </div>
118
+ </template>
119
+
120
+ <style scoped>
121
+ .canvas-context-menu {
122
+ position: fixed;
123
+ z-index: 150;
124
+ display: grid;
125
+ width: 310px;
126
+ max-height: calc(100vh - 16px);
127
+ overflow: auto;
128
+ padding: 7px;
129
+ border: 1px solid var(--mm-border);
130
+ border-radius: 10px;
131
+ background: color-mix(in srgb, var(--mm-panel-bg) 96%, #3e424a 4%);
132
+ color: var(--mm-text);
133
+ box-shadow: 0 14px 42px rgb(0 0 0 / .28);
134
+ }
135
+ .canvas-context-menu button {
136
+ display: flex;
137
+ min-height: 39px;
138
+ align-items: center;
139
+ justify-content: space-between;
140
+ gap: 16px;
141
+ padding: 0 10px;
142
+ border: 0;
143
+ border-radius: 6px;
144
+ background: transparent;
145
+ color: inherit;
146
+ font: inherit;
147
+ font-size: 13px;
148
+ text-align: left;
149
+ cursor: pointer;
150
+ }
151
+ .canvas-context-menu button:hover:not(:disabled),
152
+ .canvas-context-menu button:focus-visible { background: var(--mm-hover); outline: none; }
153
+ .canvas-context-menu button:disabled { cursor: default; opacity: .38; }
154
+ .canvas-context-menu button.danger { color: #e25a62; }
155
+ .canvas-context-menu kbd { flex: none; color: var(--mm-text-dim); font: inherit; font-size: 12px; }
156
+ .context-divider { height: 1px; margin: 6px 2px; background: var(--mm-border); }
157
+ </style>
@@ -0,0 +1,83 @@
1
+ <script setup lang="ts">
2
+ import { nextTick, ref, watch } from 'vue'
3
+ import AppIcon from './AppIcon.vue'
4
+
5
+ const props = withDefaults(defineProps<{
6
+ url: string
7
+ position: { left: number; top: number }
8
+ startEditing?: boolean
9
+ }>(), { startEditing: false })
10
+ const emit = defineEmits<{ save: [url: string]; remove: []; keep: []; leave: []; close: [] }>()
11
+ const draft = ref(props.url)
12
+ const editing = ref(props.startEditing)
13
+ const input = ref<HTMLInputElement>()
14
+
15
+ watch([() => props.url, () => props.startEditing], ([url, startEditing]) => {
16
+ draft.value = url
17
+ editing.value = startEditing
18
+ })
19
+
20
+ function submit() {
21
+ const value = draft.value.trim()
22
+ if (value) emit('save', value)
23
+ }
24
+
25
+ function onKeydown(event: KeyboardEvent) {
26
+ if (event.key === 'Enter') {
27
+ event.preventDefault()
28
+ submit()
29
+ } else if (event.key === 'Escape') {
30
+ event.preventDefault()
31
+ emit('close')
32
+ }
33
+ }
34
+
35
+ function beginEdit() {
36
+ editing.value = true
37
+ nextTick(() => {
38
+ input.value?.focus()
39
+ input.value?.select()
40
+ })
41
+ }
42
+
43
+ function focusInput() { beginEdit() }
44
+ defineExpose({ focusInput })
45
+ </script>
46
+
47
+ <template>
48
+ <div
49
+ class="link-popover"
50
+ :style="{ left: `${position.left}px`, top: `${position.top}px` }"
51
+ @mouseenter="emit('keep')"
52
+ @mouseleave="emit('leave')"
53
+ >
54
+ <input ref="input" v-model="draft" class="link-input" :class="{ readonly: !editing }" :readonly="!editing" aria-label="链接地址" @dblclick="beginEdit" @keydown="onKeydown" />
55
+ <button v-if="editing" type="button" class="link-action primary" title="更新链接 (Enter)" aria-label="更新链接" @click="submit">更新</button>
56
+ <button v-else type="button" class="icon-action" title="编辑链接地址" aria-label="编辑链接地址" @click="beginEdit"><AppIcon name="edit" :size="18" /></button>
57
+ <button type="button" class="icon-action" title="移除链接,保留文案" aria-label="移除链接" @click="emit('remove')"><AppIcon name="removeLink" :size="18" /></button>
58
+ </div>
59
+ </template>
60
+
61
+ <style scoped>
62
+ .link-popover {
63
+ position: fixed;
64
+ z-index: 125;
65
+ display: flex;
66
+ align-items: center;
67
+ gap: 6px;
68
+ width: min(430px, calc(100vw - 24px));
69
+ padding: 7px;
70
+ border: 1px solid var(--mm-border);
71
+ border-radius: 9px;
72
+ background: var(--mm-panel-bg);
73
+ box-shadow: 0 10px 28px rgb(0 0 0 / .2);
74
+ transform: translateX(-50%);
75
+ }
76
+ .link-input { flex: 1; min-width: 0; height: 32px; padding: 0 9px; border: 1px solid var(--mm-border); border-radius: 6px; outline: 0; background: var(--mm-canvas-bg); color: var(--mm-text); font-size: 13px; }
77
+ .link-input:focus { border-color: var(--mm-accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--mm-accent) 20%, transparent); }
78
+ .link-input.readonly { border-color: transparent; color: var(--mm-text-dim); cursor: default; }
79
+ .link-action, .icon-action { height: 32px; border: 0; border-radius: 6px; cursor: pointer; }
80
+ .link-action { padding: 0 11px; background: var(--mm-accent); color: white; }
81
+ .icon-action { display: inline-flex; width: 32px; align-items: center; justify-content: center; background: transparent; color: var(--mm-text-dim); }
82
+ .icon-action:hover { background: var(--mm-hover); color: var(--mm-text); }
83
+ </style>
@@ -0,0 +1,163 @@
1
+ <script setup lang="ts">
2
+ import { onBeforeUnmount, ref, watch } from 'vue'
3
+ import type { MarkdownDiagnostic } from '@tnotesjs/mindmap-core'
4
+
5
+ const props = withDefaults(defineProps<{ modelValue: string; diagnostics?: readonly MarkdownDiagnostic[] }>(), {
6
+ diagnostics: () => [],
7
+ })
8
+ const emit = defineEmits<{
9
+ 'update:modelValue': [value: string]
10
+ pasteImage: [blob: Blob, selectionStart: number, selectionEnd: number]
11
+ }>()
12
+
13
+ const draft = ref(props.modelValue)
14
+ const textarea = ref<HTMLTextAreaElement>()
15
+ let timer: ReturnType<typeof setTimeout> | null = null
16
+ let typing = false
17
+
18
+ watch(
19
+ () => props.modelValue,
20
+ (md) => {
21
+ // 正在输入时不打断用户,输入停顿后由其它视图变更回流
22
+ if (!typing) draft.value = md
23
+ },
24
+ )
25
+
26
+ function onInput(e: Event) {
27
+ const value = (e.target as HTMLTextAreaElement).value
28
+ draft.value = value
29
+ typing = true
30
+ if (timer) clearTimeout(timer)
31
+ timer = setTimeout(() => {
32
+ typing = false
33
+ emit('update:modelValue', value)
34
+ }, 300)
35
+ }
36
+
37
+ function flushDraft() {
38
+ if (!typing) return
39
+ if (timer) clearTimeout(timer)
40
+ timer = null
41
+ typing = false
42
+ emit('update:modelValue', draft.value)
43
+ }
44
+
45
+ function goToDiagnostic(item: MarkdownDiagnostic) {
46
+ const input = textarea.value
47
+ if (!input) return
48
+ const lines = draft.value.split(/\r?\n/)
49
+ let offset = 0
50
+ for (let i = 0; i < item.line - 1; i++) offset += (lines[i]?.length ?? 0) + 1
51
+ const start = Math.min(draft.value.length, offset + Math.max(0, item.column - 1))
52
+ input.focus()
53
+ input.setSelectionRange(start, Math.min(draft.value.length, start + 1))
54
+ }
55
+
56
+ function selectAllFromHost(): void {
57
+ const input = textarea.value
58
+ if (!input) return
59
+ input.focus()
60
+ input.setSelectionRange(0, input.value.length)
61
+ }
62
+
63
+ defineExpose({ selectAllFromHost })
64
+
65
+ function onPaste(e: ClipboardEvent) {
66
+ const image = [...(e.clipboardData?.items ?? [])]
67
+ .find((item) => item.kind === 'file' && item.type.startsWith('image/'))
68
+ ?.getAsFile()
69
+ if (!image) return
70
+ e.preventDefault()
71
+ const input = e.currentTarget as HTMLTextAreaElement
72
+ draft.value = input.value
73
+ flushDraft()
74
+ emit('pasteImage', image, input.selectionStart, input.selectionEnd)
75
+ }
76
+
77
+ onBeforeUnmount(() => {
78
+ flushDraft()
79
+ })
80
+ </script>
81
+
82
+ <template>
83
+ <div class="markdown-view">
84
+ <div v-if="diagnostics.length > 0" class="source-diagnostics" role="alert">
85
+ <div class="diagnostic-title">源码格式不合法;修复前只能停留在源码视图</div>
86
+ <button
87
+ v-for="(item, index) in diagnostics"
88
+ :key="`${item.line}:${item.column}:${item.code}:${index}`"
89
+ class="diagnostic-item"
90
+ type="button"
91
+ @click="goToDiagnostic(item)"
92
+ >
93
+ 第 {{ item.line }} 行,第 {{ item.column }} 列:{{ item.message }}
94
+ </button>
95
+ </div>
96
+ <textarea
97
+ ref="textarea"
98
+ class="md-textarea"
99
+ :value="draft"
100
+ spellcheck="false"
101
+ placeholder="# 根节点&#10;&#10;- 子节点&#10; - 孙节点"
102
+ @input="onInput"
103
+ @paste="onPaste"
104
+ />
105
+ </div>
106
+ </template>
107
+
108
+ <style scoped>
109
+ .markdown-view {
110
+ height: 100%;
111
+ background: var(--mm-panel-bg);
112
+ overflow: hidden;
113
+ display: flex;
114
+ flex-direction: column;
115
+ }
116
+ .source-diagnostics {
117
+ flex: none;
118
+ display: flex;
119
+ flex-direction: column;
120
+ gap: 4px;
121
+ max-height: 34%;
122
+ overflow: auto;
123
+ padding: 10px 16px;
124
+ border-bottom: 1px solid rgb(207 76 76 / 0.28);
125
+ background: rgb(207 76 76 / 0.08);
126
+ }
127
+ .diagnostic-title {
128
+ color: #b33b3b;
129
+ font-size: 13px;
130
+ font-weight: 650;
131
+ }
132
+ .diagnostic-item {
133
+ width: fit-content;
134
+ max-width: 100%;
135
+ padding: 0;
136
+ border: none;
137
+ background: transparent;
138
+ color: var(--mm-text);
139
+ font: inherit;
140
+ font-size: 12px;
141
+ text-align: left;
142
+ cursor: pointer;
143
+ }
144
+ .diagnostic-item:hover {
145
+ color: #b33b3b;
146
+ text-decoration: underline;
147
+ }
148
+ .md-textarea {
149
+ flex: 1;
150
+ resize: none;
151
+ border: none;
152
+ outline: none;
153
+ padding: 16px 20px;
154
+ font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
155
+ font-size: 13px;
156
+ line-height: 1.7;
157
+ background: transparent;
158
+ color: var(--mm-text);
159
+ /* Nested under ProseMirror.virtual-cursor-enabled (caret-color: transparent). */
160
+ caret-color: var(--mm-accent, var(--mm-text, #3b82f6));
161
+ min-height: 0;
162
+ }
163
+ </style>