@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
|
@@ -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>
|