@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,265 @@
1
+ // @vitest-environment happy-dom
2
+ import { createApp, defineComponent, h, nextTick, ref } from 'vue'
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
+ import type { MindmapNode } from '@tnotesjs/mindmap-core'
5
+ import { MindmapSession, resetNodeIdCounter } from '@tnotesjs/mindmap-core'
6
+ import FocusBreadcrumbs from './FocusBreadcrumbs.vue'
7
+
8
+ interface DeepFixture {
9
+ session: MindmapSession
10
+ current: MindmapNode[]
11
+ other: MindmapNode[]
12
+ }
13
+
14
+ let disposeMounted: (() => void) | null = null
15
+ let originalScrollIntoView: typeof HTMLElement.prototype.scrollIntoView | undefined
16
+
17
+ function deepMarkdown(depth: number): string {
18
+ const lines = ['# 根主题', '']
19
+ const appendLevel = (level: number) => {
20
+ const indent = ' '.repeat(level - 1)
21
+ lines.push(`${indent}- 当前-${level}`)
22
+ if (level < depth) appendLevel(level + 1)
23
+ lines.push(`${indent}- 其它-${level}`)
24
+ }
25
+ appendLevel(1)
26
+ return `${lines.join('\n')}\n`
27
+ }
28
+
29
+ function makeDeepFixture(depth: number): DeepFixture {
30
+ const session = new MindmapSession({ markdown: deepMarkdown(depth) })
31
+ const current: MindmapNode[] = []
32
+ const other: MindmapNode[] = []
33
+ let parent = session.document.root
34
+ for (let level = 0; level < depth; level++) {
35
+ current.push(parent.children[0])
36
+ other.push(parent.children[1])
37
+ parent = parent.children[0]
38
+ }
39
+ session.focusNode(current[current.length - 1].id)
40
+ return { session, current, other }
41
+ }
42
+
43
+ function mountBreadcrumbs(session: MindmapSession) {
44
+ const version = ref(0)
45
+ const bump = () => version.value++
46
+ session.on('focusChange', bump)
47
+ session.on('selectionChange', bump)
48
+ session.on('change', bump)
49
+ const Host = defineComponent({
50
+ setup() {
51
+ return () => h(FocusBreadcrumbs, { session, version: version.value })
52
+ },
53
+ })
54
+ const host = document.createElement('div')
55
+ document.body.append(host)
56
+ const app = createApp(Host)
57
+ app.mount(host)
58
+ disposeMounted = () => {
59
+ app.unmount()
60
+ session.off('focusChange', bump)
61
+ session.off('selectionChange', bump)
62
+ session.off('change', bump)
63
+ host.remove()
64
+ }
65
+ return host
66
+ }
67
+
68
+ async function settle() {
69
+ await nextTick()
70
+ await nextTick()
71
+ }
72
+
73
+ async function hoverOpen(button: HTMLButtonElement) {
74
+ button.dispatchEvent(new MouseEvent('mouseenter'))
75
+ await vi.advanceTimersByTimeAsync(181)
76
+ await settle()
77
+ return document.body.querySelector<HTMLElement>('.focus-sibling-menu')
78
+ }
79
+
80
+ beforeEach(() => {
81
+ resetNodeIdCounter()
82
+ vi.useFakeTimers()
83
+ originalScrollIntoView = HTMLElement.prototype.scrollIntoView
84
+ HTMLElement.prototype.scrollIntoView = vi.fn()
85
+ })
86
+
87
+ afterEach(() => {
88
+ disposeMounted?.()
89
+ disposeMounted = null
90
+ document.body.innerHTML = ''
91
+ if (originalScrollIntoView) HTMLElement.prototype.scrollIntoView = originalScrollIntoView
92
+ else delete (HTMLElement.prototype as Partial<HTMLElement>).scrollIntoView
93
+ vi.useRealTimers()
94
+ })
95
+
96
+ describe('FocusBreadcrumbs', () => {
97
+ it('完整渲染 12 层路径并自动把当前项滚入可视区域', async () => {
98
+ const { session, current } = makeDeepFixture(12)
99
+ const host = mountBreadcrumbs(session)
100
+ await settle()
101
+
102
+ const nav = host.querySelector('[aria-label="主题导航"]')
103
+ expect(nav).not.toBeNull()
104
+ const crumbs = [...host.querySelectorAll<HTMLButtonElement>('.focus-crumb')]
105
+ expect(crumbs).toHaveLength(13)
106
+ expect(crumbs.map((item) => item.textContent?.trim())).toEqual([
107
+ '全部',
108
+ ...current.map((node) => node.content.text),
109
+ ])
110
+ expect(crumbs.map((item) => item.dataset.nodeId)).toEqual([
111
+ session.document.root.id,
112
+ ...current.map((node) => node.id),
113
+ ])
114
+ expect(crumbs[crumbs.length - 1]?.getAttribute('aria-current')).toBe('page')
115
+ expect(crumbs.slice(0, -1).every((item) => !item.hasAttribute('aria-current'))).toBe(true)
116
+ expect(HTMLElement.prototype.scrollIntoView).toHaveBeenCalledWith({
117
+ block: 'nearest',
118
+ inline: 'nearest',
119
+ })
120
+ })
121
+
122
+ it('每一层 hover 都展示该节点的同层主题,并正确标记当前项', async () => {
123
+ const { session, current } = makeDeepFixture(4)
124
+ const host = mountBreadcrumbs(session)
125
+ await settle()
126
+ const crumbs = [...host.querySelectorAll<HTMLButtonElement>('.focus-crumb')].slice(1)
127
+
128
+ for (let depth = 0; depth < current.length; depth++) {
129
+ const menu = await hoverOpen(crumbs[depth])
130
+ expect(menu).not.toBeNull()
131
+ const expected = current[depth].parent!.children
132
+ const items = [...menu!.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]')]
133
+ expect(items.map((item) => item.dataset.nodeId)).toEqual(expected.map((node) => node.id))
134
+ expect(items.map((item) => item.textContent?.replace('✓', '').trim())).toEqual(
135
+ expected.map((node) => node.content.text),
136
+ )
137
+ expect(items.find((item) => item.getAttribute('aria-checked') === 'true')?.dataset.nodeId)
138
+ .toBe(current[depth].id)
139
+ document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
140
+ await settle()
141
+ expect(document.body.querySelector('.focus-sibling-menu')).toBeNull()
142
+ }
143
+ })
144
+
145
+ it('重名同层主题按节点 id 快速切换,并收敛聚焦路径和选择', async () => {
146
+ const { session, current, other } = makeDeepFixture(3)
147
+ session.updateNodeRaw(other[1].id, current[1].content.raw)
148
+ const host = mountBreadcrumbs(session)
149
+ await settle()
150
+ const secondLevel = [...host.querySelectorAll<HTMLButtonElement>('.focus-crumb')][2]
151
+ const menu = await hoverOpen(secondLevel)
152
+ const duplicateItems = [...menu!.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]')]
153
+ .filter((item) => item.textContent?.replace('✓', '').trim() === current[1].content.text)
154
+
155
+ expect(duplicateItems).toHaveLength(2)
156
+ menu!.querySelector<HTMLButtonElement>(`[data-node-id="${other[1].id}"]`)!.click()
157
+ await settle()
158
+
159
+ expect(session.focusRootNode.id).toBe(other[1].id)
160
+ expect(session.focusPath.map((node) => node.id)).toEqual([current[0].id, other[1].id])
161
+ expect([...session.selectionIds]).toEqual([other[1].id])
162
+ expect(document.body.querySelector('.focus-sibling-menu')).toBeNull()
163
+ const updated = [...host.querySelectorAll<HTMLButtonElement>('.focus-crumb')]
164
+ expect(updated.map((item) => item.dataset.nodeId)).toEqual([
165
+ session.document.root.id,
166
+ current[0].id,
167
+ other[1].id,
168
+ ])
169
+ })
170
+
171
+ it('点击祖先会选中该主题,点击全部会退出聚焦并清空选择', async () => {
172
+ const { session, current } = makeDeepFixture(3)
173
+ session.select(current[2].id)
174
+ const host = mountBreadcrumbs(session)
175
+ await settle()
176
+
177
+ const firstLevel = [...host.querySelectorAll<HTMLButtonElement>('.focus-crumb')][1]
178
+ firstLevel.click()
179
+ await settle()
180
+ expect(session.focusPath.map((node) => node.id)).toEqual([current[0].id])
181
+ expect(session.focusRootNode.id).toBe(current[0].id)
182
+ expect([...session.selectionIds]).toEqual([current[0].id])
183
+
184
+ host.querySelector<HTMLButtonElement>('.focus-crumb-root')!.click()
185
+ await settle()
186
+ expect(session.focusPath).toEqual([])
187
+ expect(session.focusRootNode).toBe(session.document.root)
188
+ expect(session.selectionIds.size).toBe(0)
189
+ expect(host.querySelector('[aria-label="主题导航"]')).toBeNull()
190
+ })
191
+
192
+ it('允许鼠标从主题跨入菜单,真正离开或点击外部后才关闭', async () => {
193
+ const { session } = makeDeepFixture(2)
194
+ const host = mountBreadcrumbs(session)
195
+ await settle()
196
+ const trigger = [...host.querySelectorAll<HTMLButtonElement>('.focus-crumb')][1]
197
+
198
+ trigger.dispatchEvent(new MouseEvent('mouseenter'))
199
+ await vi.advanceTimersByTimeAsync(179)
200
+ expect(document.body.querySelector('.focus-sibling-menu')).toBeNull()
201
+ await vi.advanceTimersByTimeAsync(2)
202
+ await settle()
203
+ const menu = document.body.querySelector<HTMLElement>('.focus-sibling-menu')!
204
+ expect(menu).not.toBeNull()
205
+ expect(trigger.getAttribute('aria-expanded')).toBe('true')
206
+
207
+ trigger.dispatchEvent(new MouseEvent('mouseleave'))
208
+ menu.dispatchEvent(new MouseEvent('mouseenter'))
209
+ await vi.advanceTimersByTimeAsync(300)
210
+ expect(document.body.querySelector('.focus-sibling-menu')).toBe(menu)
211
+
212
+ menu.dispatchEvent(new MouseEvent('mouseleave'))
213
+ await vi.advanceTimersByTimeAsync(181)
214
+ await settle()
215
+ expect(document.body.querySelector('.focus-sibling-menu')).toBeNull()
216
+ expect(trigger.getAttribute('aria-expanded')).toBe('false')
217
+
218
+ await hoverOpen(trigger)
219
+ document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
220
+ await settle()
221
+ expect(document.body.querySelector('.focus-sibling-menu')).toBeNull()
222
+ })
223
+
224
+ it('支持键盘打开和遍历菜单,Escape 关闭后把焦点还给主题', async () => {
225
+ const { session } = makeDeepFixture(2)
226
+ const host = mountBreadcrumbs(session)
227
+ await settle()
228
+ const trigger = [...host.querySelectorAll<HTMLButtonElement>('.focus-crumb')][1]
229
+ trigger.focus()
230
+ trigger.dispatchEvent(new KeyboardEvent('keydown', {
231
+ key: 'ArrowDown',
232
+ bubbles: true,
233
+ cancelable: true,
234
+ }))
235
+ await settle()
236
+
237
+ const menu = document.body.querySelector<HTMLElement>('[role="menu"]')!
238
+ const items = [...menu.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]')]
239
+ expect(trigger.getAttribute('aria-haspopup')).toBe('menu')
240
+ expect(trigger.getAttribute('aria-expanded')).toBe('true')
241
+ expect(document.activeElement).toBe(items[0])
242
+
243
+ items[0].dispatchEvent(new KeyboardEvent('keydown', {
244
+ key: 'End',
245
+ bubbles: true,
246
+ cancelable: true,
247
+ }))
248
+ expect(document.activeElement).toBe(items[items.length - 1])
249
+ items[items.length - 1]!.dispatchEvent(new KeyboardEvent('keydown', {
250
+ key: 'ArrowDown',
251
+ bubbles: true,
252
+ cancelable: true,
253
+ }))
254
+ expect(document.activeElement).toBe(items[0])
255
+
256
+ items[0].dispatchEvent(new KeyboardEvent('keydown', {
257
+ key: 'Escape',
258
+ bubbles: true,
259
+ cancelable: true,
260
+ }))
261
+ await settle()
262
+ expect(document.body.querySelector('.focus-sibling-menu')).toBeNull()
263
+ expect(document.activeElement).toBe(trigger)
264
+ })
265
+ })
@@ -0,0 +1,436 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
3
+ import type { MindmapNode, MindmapSession } from '@tnotesjs/mindmap-core'
4
+
5
+ const props = defineProps<{
6
+ session: MindmapSession
7
+ /** Session is not reactive; host bumps this when focus/doc changes. */
8
+ version: number
9
+ }>()
10
+
11
+ const OPEN_DELAY = 180
12
+ const CLOSE_DELAY = 180
13
+ const MENU_WIDTH = 248
14
+ const VIEWPORT_GAP = 8
15
+
16
+ interface OpenMenu {
17
+ nodeId: string
18
+ depth: number
19
+ }
20
+
21
+ const root = ref<HTMLElement>()
22
+ const scroller = ref<HTMLElement>()
23
+ const menu = ref<HTMLElement>()
24
+ const openMenu = ref<OpenMenu | null>(null)
25
+ const menuPosition = ref({ left: 0, top: 0 })
26
+ const menuId = `focus-siblings-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`
27
+
28
+ let trigger: HTMLButtonElement | null = null
29
+ let openTimer: ReturnType<typeof setTimeout> | null = null
30
+ let closeTimer: ReturnType<typeof setTimeout> | null = null
31
+ let pendingFocus: 'first' | 'last' | 'current' | null = null
32
+
33
+ const focusPath = computed(() => {
34
+ void props.version
35
+ return props.session.focusPath
36
+ })
37
+
38
+ const openNode = computed(() => {
39
+ void props.version
40
+ const id = openMenu.value?.nodeId
41
+ return id ? props.session.document.find(id) : null
42
+ })
43
+
44
+ const openSiblings = computed(() => openNode.value?.parent?.children ?? [])
45
+
46
+ const menuStyle = computed(() => ({
47
+ left: `${menuPosition.value.left}px`,
48
+ top: `${menuPosition.value.top}px`,
49
+ width: `${MENU_WIDTH}px`,
50
+ }))
51
+
52
+ function hasSiblingMenu(node: MindmapNode): boolean {
53
+ return (node.parent?.children.length ?? 0) > 1
54
+ }
55
+
56
+ function clearOpenTimer() {
57
+ if (openTimer) clearTimeout(openTimer)
58
+ openTimer = null
59
+ }
60
+
61
+ function clearCloseTimer() {
62
+ if (closeTimer) clearTimeout(closeTimer)
63
+ closeTimer = null
64
+ }
65
+
66
+ function updateMenuPosition() {
67
+ if (!trigger) return
68
+ const rect = trigger.getBoundingClientRect()
69
+ const viewportWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0)
70
+ const viewportHeight = Math.max(document.documentElement.clientHeight, window.innerHeight || 0)
71
+ const left = Math.max(VIEWPORT_GAP, Math.min(rect.left, viewportWidth - MENU_WIDTH - VIEWPORT_GAP))
72
+ const estimatedHeight = Math.min(360, Math.max(44, openSiblings.value.length * 40 + 12))
73
+ const below = rect.bottom + 6
74
+ const top = below + estimatedHeight <= viewportHeight - VIEWPORT_GAP
75
+ ? below
76
+ : Math.max(VIEWPORT_GAP, rect.top - estimatedHeight - 6)
77
+ menuPosition.value = { left, top }
78
+ }
79
+
80
+ function menuItems(): HTMLButtonElement[] {
81
+ return [...(menu.value?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]') ?? [])]
82
+ }
83
+
84
+ function focusMenuItem(where: 'first' | 'last' | 'current') {
85
+ const items = menuItems()
86
+ if (items.length === 0) return
87
+ const currentIndex = items.findIndex((item) => item.getAttribute('aria-checked') === 'true')
88
+ const index = where === 'first'
89
+ ? 0
90
+ : where === 'last'
91
+ ? items.length - 1
92
+ : Math.max(0, currentIndex)
93
+ items[index]?.focus()
94
+ }
95
+
96
+ async function showMenu(node: MindmapNode, depth: number, target: HTMLButtonElement) {
97
+ if (!hasSiblingMenu(node)) return
98
+ trigger = target
99
+ openMenu.value = { nodeId: node.id, depth }
100
+ await nextTick()
101
+ updateMenuPosition()
102
+ if (pendingFocus) focusMenuItem(pendingFocus)
103
+ pendingFocus = null
104
+ }
105
+
106
+ function requestHoverMenu(node: MindmapNode, depth: number, event: MouseEvent) {
107
+ if (!hasSiblingMenu(node)) return
108
+ clearOpenTimer()
109
+ clearCloseTimer()
110
+ const target = event.currentTarget as HTMLButtonElement
111
+ if (openMenu.value?.nodeId === node.id) {
112
+ trigger = target
113
+ return
114
+ }
115
+ openMenu.value = null
116
+ openTimer = setTimeout(() => {
117
+ openTimer = null
118
+ void showMenu(node, depth, target)
119
+ }, OPEN_DELAY)
120
+ }
121
+
122
+ function requestKeyboardMenu(
123
+ node: MindmapNode,
124
+ depth: number,
125
+ event: KeyboardEvent,
126
+ where: 'first' | 'last' | 'current',
127
+ ) {
128
+ if (!hasSiblingMenu(node)) return
129
+ event.preventDefault()
130
+ clearOpenTimer()
131
+ clearCloseTimer()
132
+ pendingFocus = where
133
+ void showMenu(node, depth, event.currentTarget as HTMLButtonElement)
134
+ }
135
+
136
+ function scheduleClose() {
137
+ clearOpenTimer()
138
+ clearCloseTimer()
139
+ if (!openMenu.value) return
140
+ closeTimer = setTimeout(() => {
141
+ closeTimer = null
142
+ openMenu.value = null
143
+ trigger = null
144
+ }, CLOSE_DELAY)
145
+ }
146
+
147
+ function closeMenu(restoreFocus = false) {
148
+ clearOpenTimer()
149
+ clearCloseTimer()
150
+ const target = trigger
151
+ openMenu.value = null
152
+ trigger = null
153
+ pendingFocus = null
154
+ if (restoreFocus) nextTick(() => target?.focus())
155
+ }
156
+
157
+ function onMenuEnter() {
158
+ clearCloseTimer()
159
+ }
160
+
161
+ function navigateAll() {
162
+ closeMenu()
163
+ props.session.select(null)
164
+ props.session.exitFocusTo(0)
165
+ }
166
+
167
+ function navigateAncestor(node: MindmapNode, depth: number) {
168
+ closeMenu()
169
+ props.session.select(node.id)
170
+ props.session.exitFocusTo(depth + 1)
171
+ }
172
+
173
+ function switchSibling(node: MindmapNode) {
174
+ closeMenu()
175
+ props.session.switchFocusNode(node.id)
176
+ }
177
+
178
+ function onTriggerKeydown(node: MindmapNode, depth: number, event: KeyboardEvent) {
179
+ if (event.key === 'ArrowDown') requestKeyboardMenu(node, depth, event, 'first')
180
+ else if (event.key === 'ArrowUp') requestKeyboardMenu(node, depth, event, 'last')
181
+ else if (event.key === 'Escape' && openMenu.value) {
182
+ event.preventDefault()
183
+ closeMenu(true)
184
+ }
185
+ }
186
+
187
+ function onMenuKeydown(event: KeyboardEvent) {
188
+ const items = menuItems()
189
+ const current = items.indexOf(document.activeElement as HTMLButtonElement)
190
+ let next: number
191
+ if (event.key === 'ArrowDown') next = current < 0 ? 0 : (current + 1) % items.length
192
+ else if (event.key === 'ArrowUp') next = current < 0 ? items.length - 1 : (current - 1 + items.length) % items.length
193
+ else if (event.key === 'Home') next = 0
194
+ else if (event.key === 'End') next = items.length - 1
195
+ else if (event.key === 'Escape') {
196
+ event.preventDefault()
197
+ event.stopPropagation()
198
+ closeMenu(true)
199
+ return
200
+ } else if (event.key === 'Tab') {
201
+ closeMenu()
202
+ return
203
+ } else {
204
+ return
205
+ }
206
+ if (items.length > 0) {
207
+ event.preventDefault()
208
+ items[next]?.focus()
209
+ }
210
+ }
211
+
212
+ function onDocumentPointerDown(event: PointerEvent) {
213
+ const target = event.target as Node
214
+ if (root.value?.contains(target) || menu.value?.contains(target)) return
215
+ closeMenu()
216
+ }
217
+
218
+ function onDocumentKeydown(event: KeyboardEvent) {
219
+ if (event.key === 'Escape' && openMenu.value) closeMenu(true)
220
+ }
221
+
222
+ function onViewportChange() {
223
+ if (openMenu.value) updateMenuPosition()
224
+ }
225
+
226
+ watch(focusPath, () => {
227
+ closeMenu()
228
+ nextTick(() => {
229
+ const current = scroller.value?.querySelector<HTMLElement>('[aria-current="page"]')
230
+ current?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })
231
+ })
232
+ }, { immediate: true, flush: 'post' })
233
+
234
+ onMounted(() => {
235
+ document.addEventListener('pointerdown', onDocumentPointerDown)
236
+ document.addEventListener('keydown', onDocumentKeydown)
237
+ window.addEventListener('resize', onViewportChange)
238
+ document.addEventListener('scroll', onViewportChange, true)
239
+ })
240
+
241
+ onBeforeUnmount(() => {
242
+ clearOpenTimer()
243
+ clearCloseTimer()
244
+ document.removeEventListener('pointerdown', onDocumentPointerDown)
245
+ document.removeEventListener('keydown', onDocumentKeydown)
246
+ window.removeEventListener('resize', onViewportChange)
247
+ document.removeEventListener('scroll', onViewportChange, true)
248
+ })
249
+ </script>
250
+
251
+ <template>
252
+ <nav
253
+ v-if="focusPath.length > 0"
254
+ ref="root"
255
+ class="focus-breadcrumbs"
256
+ aria-label="主题导航"
257
+ >
258
+ <div ref="scroller" class="focus-breadcrumbs-scroller">
259
+ <div class="focus-breadcrumbs-track">
260
+ <button
261
+ type="button"
262
+ class="focus-crumb focus-crumb-root"
263
+ :data-node-id="session.document.root.id"
264
+ title="返回全部主题"
265
+ @click="navigateAll"
266
+ >
267
+ 全部
268
+ </button>
269
+ <template v-for="(node, depth) in focusPath" :key="node.id">
270
+ <span class="focus-crumb-separator" aria-hidden="true">/</span>
271
+ <button
272
+ type="button"
273
+ class="focus-crumb"
274
+ :class="{ 'is-current': depth === focusPath.length - 1 }"
275
+ :data-node-id="node.id"
276
+ :aria-current="depth === focusPath.length - 1 ? 'page' : undefined"
277
+ :aria-haspopup="hasSiblingMenu(node) ? 'menu' : undefined"
278
+ :aria-expanded="hasSiblingMenu(node) ? openMenu?.nodeId === node.id : undefined"
279
+ :aria-controls="hasSiblingMenu(node) && openMenu?.nodeId === node.id ? menuId : undefined"
280
+ :title="node.content.text"
281
+ @click="navigateAncestor(node, depth)"
282
+ @mouseenter="requestHoverMenu(node, depth, $event)"
283
+ @mouseleave="scheduleClose"
284
+ @keydown="onTriggerKeydown(node, depth, $event)"
285
+ >
286
+ {{ node.content.text || '未命名主题' }}
287
+ </button>
288
+ </template>
289
+ </div>
290
+ </div>
291
+ </nav>
292
+
293
+ <Teleport to="body">
294
+ <div
295
+ v-if="openMenu && openNode && openSiblings.length > 1"
296
+ :id="menuId"
297
+ ref="menu"
298
+ class="focus-sibling-menu"
299
+ :style="menuStyle"
300
+ role="menu"
301
+ :aria-label="`${openNode.content.text || '未命名主题'}的同层主题`"
302
+ @mouseenter="onMenuEnter"
303
+ @mouseleave="scheduleClose"
304
+ @keydown="onMenuKeydown"
305
+ >
306
+ <button
307
+ v-for="node in openSiblings"
308
+ :key="node.id"
309
+ type="button"
310
+ role="menuitemradio"
311
+ :aria-checked="node.id === openNode.id"
312
+ :class="{ 'is-current': node.id === openNode.id }"
313
+ :data-node-id="node.id"
314
+ @click="switchSibling(node)"
315
+ >
316
+ <span>{{ node.content.text || '未命名主题' }}</span>
317
+ <span v-if="node.id === openNode.id" class="focus-menu-check" aria-hidden="true">✓</span>
318
+ </button>
319
+ </div>
320
+ </Teleport>
321
+ </template>
322
+
323
+ <style scoped>
324
+ .focus-breadcrumbs {
325
+ min-width: 0;
326
+ border-bottom: 1px solid var(--tn-c-divider);
327
+ background: var(--tn-c-bg-soft, var(--tn-c-bg));
328
+ flex: none;
329
+ }
330
+
331
+ .focus-breadcrumbs-scroller {
332
+ max-width: 100%;
333
+ overflow-x: auto;
334
+ overflow-y: hidden;
335
+ overscroll-behavior-inline: contain;
336
+ scrollbar-width: thin;
337
+ }
338
+
339
+ .focus-breadcrumbs-track {
340
+ display: flex;
341
+ width: max-content;
342
+ min-width: 100%;
343
+ align-items: center;
344
+ gap: 4px;
345
+ padding: 5px 12px;
346
+ white-space: nowrap;
347
+ }
348
+
349
+ .focus-crumb {
350
+ flex: none;
351
+ max-width: 260px;
352
+ overflow: hidden;
353
+ padding: 3px 6px;
354
+ border: 0;
355
+ border-radius: 5px;
356
+ background: transparent;
357
+ color: var(--tn-c-brand);
358
+ cursor: pointer;
359
+ font: inherit;
360
+ font-size: 12px;
361
+ text-overflow: ellipsis;
362
+ white-space: nowrap;
363
+ }
364
+
365
+ .focus-crumb:hover,
366
+ .focus-crumb:focus-visible,
367
+ .focus-crumb[aria-expanded="true"] {
368
+ background: var(--tn-c-default-soft);
369
+ outline: none;
370
+ }
371
+
372
+ .focus-crumb.is-current {
373
+ color: var(--tn-c-text);
374
+ font-weight: 600;
375
+ }
376
+
377
+ .focus-crumb-separator {
378
+ flex: none;
379
+ color: var(--tn-c-text-2);
380
+ font-size: 12px;
381
+ }
382
+
383
+ .focus-sibling-menu {
384
+ position: fixed;
385
+ /* Above Desk CSS fullscreen overlay (200000) and mindmap chrome. */
386
+ z-index: 200010;
387
+ max-height: min(360px, calc(100vh - 16px));
388
+ overflow-y: auto;
389
+ padding: 6px;
390
+ border: 1px solid var(--tn-c-divider);
391
+ border-radius: 10px;
392
+ background: var(--tn-c-bg-elv, var(--tn-c-bg));
393
+ box-shadow: var(--tn-shadow-2, 0 14px 38px rgb(0 0 0 / .24));
394
+ color: var(--tn-c-text);
395
+ }
396
+
397
+ .focus-sibling-menu button {
398
+ display: flex;
399
+ width: 100%;
400
+ min-height: 38px;
401
+ align-items: center;
402
+ justify-content: space-between;
403
+ gap: 12px;
404
+ overflow: hidden;
405
+ padding: 0 10px;
406
+ border: 0;
407
+ border-radius: 6px;
408
+ background: transparent;
409
+ color: inherit;
410
+ cursor: pointer;
411
+ font: inherit;
412
+ font-size: 13px;
413
+ text-align: left;
414
+ }
415
+
416
+ .focus-sibling-menu button > span:first-child {
417
+ overflow: hidden;
418
+ text-overflow: ellipsis;
419
+ white-space: nowrap;
420
+ }
421
+
422
+ .focus-sibling-menu button:hover,
423
+ .focus-sibling-menu button:focus-visible {
424
+ background: var(--tn-c-default-soft);
425
+ outline: none;
426
+ }
427
+
428
+ .focus-sibling-menu button.is-current {
429
+ color: var(--tn-c-brand);
430
+ }
431
+
432
+ .focus-menu-check {
433
+ flex: none;
434
+ font-size: 12px;
435
+ }
436
+ </style>