@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,2494 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
3
|
+
import {
|
|
4
|
+
caretOffsetFromPoint,
|
|
5
|
+
cloneSubtree,
|
|
6
|
+
parseMarkdown,
|
|
7
|
+
richSelectionRect,
|
|
8
|
+
serializeSubtree,
|
|
9
|
+
wrapTextLines,
|
|
10
|
+
} from '@tnotesjs/mindmap-core'
|
|
11
|
+
import type { InlineFormat, InlineLink, MindmapNode, MindmapSession, RichInlineEditorElement } from '@tnotesjs/mindmap-core'
|
|
12
|
+
import LinkPopover from './LinkPopover.vue'
|
|
13
|
+
import RichInlineEditor from './RichInlineEditor.vue'
|
|
14
|
+
import SelectionToolbar from './SelectionToolbar.vue'
|
|
15
|
+
import { resolveAfterDropLevel } from './outlineDrag'
|
|
16
|
+
|
|
17
|
+
const props = defineProps<{
|
|
18
|
+
session: MindmapSession
|
|
19
|
+
/** 每次文档/选中/折叠/聚焦变化时 +1,驱动本组件重算 */
|
|
20
|
+
version: number
|
|
21
|
+
resolveImageSrc?: (src: string) => string
|
|
22
|
+
}>()
|
|
23
|
+
|
|
24
|
+
function resolvedImageSrc(src: string): string {
|
|
25
|
+
return props.resolveImageSrc?.(src) ?? src
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const emit = defineEmits<{
|
|
29
|
+
imagePreview: [src: string]
|
|
30
|
+
requestSearch: []
|
|
31
|
+
pasteImage: [anchorId: string, blob: Blob]
|
|
32
|
+
}>()
|
|
33
|
+
|
|
34
|
+
const ROW_HEIGHT = 32
|
|
35
|
+
const TITLE_LINE = 36
|
|
36
|
+
/** 与幕布一致:每层水平步进 */
|
|
37
|
+
const INDENT = 28
|
|
38
|
+
/** 与 .outline-view padding-top / .outline-title margin-bottom 保持一致 */
|
|
39
|
+
const VIEW_PADDING_TOP = 12
|
|
40
|
+
const TITLE_MARGIN_BOTTOM = 8
|
|
41
|
+
const TEXT_LINE = 22
|
|
42
|
+
/** 折叠按钮占位(叠在父级圆点列,不额外把圆点挤开) */
|
|
43
|
+
const COLLAPSE_LEAD = 18
|
|
44
|
+
const BULLET_SIZE = 22
|
|
45
|
+
/** 深度 0 圆点中心 X;装饰线与祖先圆点对齐 */
|
|
46
|
+
const BULLET_CENTER = COLLAPSE_LEAD + BULLET_SIZE / 2
|
|
47
|
+
/** 大纲行左侧控件占位(折叠列 + 圆点 + 间隙) */
|
|
48
|
+
const ROW_CHROME = COLLAPSE_LEAD + BULLET_SIZE + 8
|
|
49
|
+
/** 大纲图片默认/边界宽度(与 md `|宽度` 一致) */
|
|
50
|
+
const DEFAULT_OUTLINE_IMG_W = 240
|
|
51
|
+
const MIN_IMG_W = 80
|
|
52
|
+
const MAX_IMG_W = 720
|
|
53
|
+
const IMG_BLOCK_PAD = 10
|
|
54
|
+
const IMG_MAX_H = 480
|
|
55
|
+
|
|
56
|
+
function gutterWidth(depth: number): number {
|
|
57
|
+
return COLLAPSE_LEAD + depth * INDENT + BULLET_SIZE
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface Row {
|
|
61
|
+
node: MindmapNode
|
|
62
|
+
depth: number
|
|
63
|
+
index: number
|
|
64
|
+
top: number
|
|
65
|
+
height: number
|
|
66
|
+
textHeight: number
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** src → 宽/高比;加载后更新以正确计算行高 */
|
|
70
|
+
const imageAspects = ref(new Map<string, number>())
|
|
71
|
+
/** 拖拽调宽时的即时预览宽度 */
|
|
72
|
+
const liveImageWidth = ref<Map<string, number>>(new Map())
|
|
73
|
+
/** 编辑中未提交文案(数据源仍为一行;用于避免 :value 重渲染冲掉输入) */
|
|
74
|
+
const draftTexts = ref(new Map<string, string>())
|
|
75
|
+
/** 编辑中未提交文案的折行高度 */
|
|
76
|
+
const draftTextHeights = ref(new Map<string, number>())
|
|
77
|
+
/** 外层内容区宽度,驱动折行 */
|
|
78
|
+
const containerWidth = ref(800)
|
|
79
|
+
|
|
80
|
+
let measureCtx: CanvasRenderingContext2D | null = null
|
|
81
|
+
function measureOutlineText(text: string, fontSize: number): number {
|
|
82
|
+
if (typeof document === 'undefined') return text.length * fontSize * 0.6
|
|
83
|
+
if (!measureCtx) {
|
|
84
|
+
const c = document.createElement('canvas')
|
|
85
|
+
measureCtx = c.getContext('2d')
|
|
86
|
+
}
|
|
87
|
+
if (!measureCtx) return text.length * fontSize * 0.6
|
|
88
|
+
measureCtx.font = `${fontSize}px system-ui, -apple-system, "Segoe UI", sans-serif`
|
|
89
|
+
return measureCtx.measureText(text).width
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function contentAreaWidth(): number {
|
|
93
|
+
// .outline-view padding: 12px 24px
|
|
94
|
+
return Math.max(80, containerWidth.value - 48)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function outlineTextMaxWidth(depth: number, hasCheckbox: boolean, hasLink: boolean): number {
|
|
98
|
+
const chrome = depth * INDENT + ROW_CHROME + (hasCheckbox ? 20 : 0) + (hasLink ? 24 : 0) + 12
|
|
99
|
+
return Math.max(60, contentAreaWidth() - chrome)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function measureTextBlockHeight(
|
|
103
|
+
text: string,
|
|
104
|
+
maxWidth: number,
|
|
105
|
+
fontSize: number,
|
|
106
|
+
lineHeight: number,
|
|
107
|
+
minHeight: number,
|
|
108
|
+
): number {
|
|
109
|
+
const lines = wrapTextLines(text || ' ', maxWidth, (s) => measureOutlineText(s, fontSize))
|
|
110
|
+
return Math.max(minHeight, lines.length * lineHeight + 8)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function displayImageWidth(node: MindmapNode): number {
|
|
114
|
+
const live = liveImageWidth.value.get(node.id)
|
|
115
|
+
if (live != null) return live
|
|
116
|
+
return node.content.image?.width ?? DEFAULT_OUTLINE_IMG_W
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function imageBlockHeight(node: MindmapNode): number {
|
|
120
|
+
if (!node.content.image) return 0
|
|
121
|
+
const w = displayImageWidth(node)
|
|
122
|
+
const aspect = imageAspects.value.get(node.content.image.src) ?? 1
|
|
123
|
+
const h = Math.round(w / Math.max(aspect, 0.15))
|
|
124
|
+
return Math.min(h, IMG_MAX_H) + IMG_BLOCK_PAD
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function layoutDisplayText(node: MindmapNode): string {
|
|
128
|
+
// 大纲始终按用户看到的文案排版,Markdown 标记只存在于源码层。
|
|
129
|
+
return node.content.text
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function measureRowTextHeight(node: MindmapNode, depth: number): number {
|
|
133
|
+
const draft = draftTextHeights.value.get(node.id)
|
|
134
|
+
if (draft != null) return draft
|
|
135
|
+
const maxW = outlineTextMaxWidth(depth, node.content.checked !== null, !!node.content.link)
|
|
136
|
+
return measureTextBlockHeight(layoutDisplayText(node), maxW, 15, TEXT_LINE, ROW_HEIGHT)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 列表行 = 聚焦根的后代(不含根本身;根作为上方标题渲染,对齐幕布) */
|
|
140
|
+
const rows = computed<Row[]>(() => {
|
|
141
|
+
if (props.version < 0) return []
|
|
142
|
+
const root = props.session.focusRootNode
|
|
143
|
+
const out: Row[] = []
|
|
144
|
+
let top = 0
|
|
145
|
+
const walk = (n: MindmapNode, depth: number) => {
|
|
146
|
+
if (n.collapsed) return
|
|
147
|
+
for (const c of n.children) {
|
|
148
|
+
const textHeight = measureRowTextHeight(c, depth)
|
|
149
|
+
const height = textHeight + imageBlockHeight(c)
|
|
150
|
+
out.push({ node: c, depth, index: out.length, top, height, textHeight })
|
|
151
|
+
top += height
|
|
152
|
+
walk(c, depth + 1)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
walk(root, 0)
|
|
156
|
+
return out
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
const titleHeight = computed(() => {
|
|
160
|
+
if (props.version < 0) return 56
|
|
161
|
+
const root = props.session.focusRootNode
|
|
162
|
+
const draft = draftTextHeights.value.get(root.id)
|
|
163
|
+
const textH =
|
|
164
|
+
draft ??
|
|
165
|
+
measureTextBlockHeight(layoutDisplayText(root), contentAreaWidth(), 28, TITLE_LINE, 56)
|
|
166
|
+
return textH + imageBlockHeight(root)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
const totalListHeight = computed(() => {
|
|
170
|
+
const list = rows.value
|
|
171
|
+
if (list.length === 0) return 0
|
|
172
|
+
const last = list[list.length - 1]
|
|
173
|
+
return last.top + last.height
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const focusRoot = computed(() => {
|
|
177
|
+
if (props.version < 0) return null
|
|
178
|
+
return props.session.focusRootNode
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const selectedId = computed(() => {
|
|
182
|
+
if (props.version < 0) return null
|
|
183
|
+
return props.session.selectedNode?.id ?? null
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
const selectedIds = computed(() => {
|
|
187
|
+
if (props.version < 0) return new Set<string>()
|
|
188
|
+
return props.session.selectionIds
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
const matches = computed(() => {
|
|
192
|
+
if (props.version < 0) return new Set<string>()
|
|
193
|
+
return props.session.matches
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// ---------- 虚拟滚动 ----------
|
|
197
|
+
|
|
198
|
+
const containerRef = ref<HTMLElement>()
|
|
199
|
+
const scrollTop = ref(0)
|
|
200
|
+
const viewportH = ref(600)
|
|
201
|
+
|
|
202
|
+
/** 列表(spacer)相对滚动内容顶部的偏移:padding + 标题 + 标题下边距 */
|
|
203
|
+
function listChromeHeight(): number {
|
|
204
|
+
return VIEW_PADDING_TOP + titleHeight.value + TITLE_MARGIN_BOTTOM
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const listScrollTop = computed(() => Math.max(0, scrollTop.value - listChromeHeight()))
|
|
208
|
+
|
|
209
|
+
const startIndex = computed(() => {
|
|
210
|
+
const list = rows.value
|
|
211
|
+
const y = listScrollTop.value
|
|
212
|
+
let lo = 0
|
|
213
|
+
let hi = list.length
|
|
214
|
+
while (lo < hi) {
|
|
215
|
+
const mid = (lo + hi) >> 1
|
|
216
|
+
if (list[mid].top + list[mid].height < y) lo = mid + 1
|
|
217
|
+
else hi = mid
|
|
218
|
+
}
|
|
219
|
+
return Math.max(0, lo - 2)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
const endIndex = computed(() => {
|
|
223
|
+
const list = rows.value
|
|
224
|
+
const y = listScrollTop.value + viewportH.value
|
|
225
|
+
let i = startIndex.value
|
|
226
|
+
while (i < list.length && list[i].top < y) i++
|
|
227
|
+
return Math.min(list.length, i + 2)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
const visibleRows = computed(() => rows.value.slice(startIndex.value, endIndex.value))
|
|
231
|
+
|
|
232
|
+
function onScroll() {
|
|
233
|
+
scrollTop.value = containerRef.value?.scrollTop ?? 0
|
|
234
|
+
textSelection.value = null
|
|
235
|
+
if (linkEditor.value?.mode === 'existing') linkEditor.value = null
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let resizeObserver: ResizeObserver | null = null
|
|
239
|
+
onMounted(() => {
|
|
240
|
+
if (containerRef.value) {
|
|
241
|
+
viewportH.value = containerRef.value.clientHeight || 600
|
|
242
|
+
containerWidth.value = containerRef.value.clientWidth || 800
|
|
243
|
+
resizeObserver = new ResizeObserver(() => {
|
|
244
|
+
const el = containerRef.value
|
|
245
|
+
if (!el) return
|
|
246
|
+
viewportH.value = el.clientHeight || 600
|
|
247
|
+
containerWidth.value = el.clientWidth || 800
|
|
248
|
+
})
|
|
249
|
+
resizeObserver.observe(containerRef.value)
|
|
250
|
+
}
|
|
251
|
+
})
|
|
252
|
+
onBeforeUnmount(() => {
|
|
253
|
+
resizeObserver?.disconnect()
|
|
254
|
+
stopRangeSelect?.()
|
|
255
|
+
if (linkLeaveTimer) clearTimeout(linkLeaveTimer)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
const focusPathKey = computed(() => {
|
|
259
|
+
if (props.version < 0) return ''
|
|
260
|
+
return props.session.focusPath.map((node) => node.id).join('/')
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
// ---------- 行焦点(焦点 = 选中 = 编辑态,幕布式) ----------
|
|
264
|
+
|
|
265
|
+
const focusedId = ref<string | null>(null)
|
|
266
|
+
const hoveredId = ref<string | null>(null)
|
|
267
|
+
|
|
268
|
+
interface TextSelectionState {
|
|
269
|
+
nodeId: string
|
|
270
|
+
start: number
|
|
271
|
+
end: number
|
|
272
|
+
position: { left: number; top: number }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
interface LinkEditorState {
|
|
276
|
+
mode: 'selection' | 'existing'
|
|
277
|
+
nodeId: string
|
|
278
|
+
url: string
|
|
279
|
+
position: { left: number; top: number }
|
|
280
|
+
start?: number
|
|
281
|
+
end?: number
|
|
282
|
+
rawStart?: number
|
|
283
|
+
rawEnd?: number
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const textSelection = ref<TextSelectionState | null>(null)
|
|
287
|
+
const linkEditor = ref<LinkEditorState | null>(null)
|
|
288
|
+
const linkPopoverRef = ref<InstanceType<typeof LinkPopover> | null>(null)
|
|
289
|
+
const imagePickerRef = ref<HTMLInputElement>()
|
|
290
|
+
const pendingImageAnchorId = ref<string | null>(null)
|
|
291
|
+
let linkLeaveTimer: ReturnType<typeof setTimeout> | null = null
|
|
292
|
+
|
|
293
|
+
watch(focusPathKey, () => {
|
|
294
|
+
// 进入/退出子树聚焦时,聚焦根应先以富文本标题展示,而不是继续停留在
|
|
295
|
+
// 编辑态;先提交当前受控草稿,再清理局部选区与浮层。
|
|
296
|
+
const active = document.activeElement
|
|
297
|
+
if (isInlineEditorElement(active)) {
|
|
298
|
+
const activeNode = props.session.document.find(active.dataset.id ?? '')
|
|
299
|
+
if (activeNode) commitRow(activeNode, active)
|
|
300
|
+
}
|
|
301
|
+
focusedId.value = null
|
|
302
|
+
textSelection.value = null
|
|
303
|
+
linkEditor.value = null
|
|
304
|
+
draftTexts.value = new Map()
|
|
305
|
+
draftTextHeights.value = new Map()
|
|
306
|
+
scrollTop.value = 0
|
|
307
|
+
if (containerRef.value) containerRef.value.scrollTop = 0
|
|
308
|
+
nextTick(() => containerRef.value?.focus())
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
function inputOf(id: string): RichInlineEditorElement | null {
|
|
312
|
+
return containerRef.value?.querySelector(`.rich-inline-editor[data-id="${id}"]`) ?? null
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function isInlineEditorElement(value: unknown): value is RichInlineEditorElement {
|
|
316
|
+
return value instanceof HTMLDivElement && value.classList.contains('rich-inline-editor')
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** 聚焦某行并放置光标(col 省略时到末尾);DOM 更新后可能需多拍再试 */
|
|
320
|
+
function focusRow(id: string, col?: number) {
|
|
321
|
+
focusedId.value = id
|
|
322
|
+
const attempt = (left: number) => {
|
|
323
|
+
nextTick(() => {
|
|
324
|
+
const input = inputOf(id)
|
|
325
|
+
if (!input) {
|
|
326
|
+
if (left > 0) attempt(left - 1)
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
input.focus()
|
|
330
|
+
const pos = col === undefined ? input.value.length : Math.min(col, input.value.length)
|
|
331
|
+
input.setSelectionRange(pos, pos)
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
attempt(3)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function focusRowRange(id: string, start: number, end: number) {
|
|
338
|
+
focusedId.value = id
|
|
339
|
+
nextTick(() => {
|
|
340
|
+
const input = inputOf(id)
|
|
341
|
+
if (!input) return
|
|
342
|
+
input.focus()
|
|
343
|
+
input.setSelectionRange(Math.min(start, input.value.length), Math.min(end, input.value.length))
|
|
344
|
+
syncTextSelection(id, input)
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function selectionPosition(input: RichInlineEditorElement): { left: number; top: number } {
|
|
349
|
+
const rect = richSelectionRect(input)
|
|
350
|
+
return {
|
|
351
|
+
left: Math.max(190, Math.min(window.innerWidth - 190, rect.left + rect.width / 2)),
|
|
352
|
+
top: Math.max(58, rect.top - 8),
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function syncTextSelection(nodeId: string, input: RichInlineEditorElement) {
|
|
357
|
+
const start = input.selectionStart ?? 0
|
|
358
|
+
const end = input.selectionEnd ?? 0
|
|
359
|
+
if (start === end) {
|
|
360
|
+
if (textSelection.value?.nodeId === nodeId) textSelection.value = null
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
textSelection.value = { nodeId, start, end, position: selectionPosition(input) }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function onTextSelection(node: MindmapNode, event: Event) {
|
|
367
|
+
syncTextSelection(node.id, event.currentTarget as RichInlineEditorElement)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const selectedTextFormats = computed<Partial<Record<InlineFormat, boolean>>>(() => {
|
|
371
|
+
if (props.version < 0) return {}
|
|
372
|
+
const selection = textSelection.value
|
|
373
|
+
if (!selection) return {}
|
|
374
|
+
const formats: InlineFormat[] = ['bold', 'italic', 'underline', 'strike', 'highlight', 'code']
|
|
375
|
+
return Object.fromEntries(formats.map((format) => [
|
|
376
|
+
format,
|
|
377
|
+
props.session.inlineFormatActive(selection.nodeId, selection.start, selection.end, format),
|
|
378
|
+
]))
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
const multiSelectionPosition = computed(() => {
|
|
382
|
+
if (props.version < 0 || props.session.selectionIds.size <= 1 || textSelection.value) return null
|
|
383
|
+
const id = props.session.selectedNode?.id
|
|
384
|
+
if (!id) return null
|
|
385
|
+
const row = containerRef.value?.querySelector(`.outline-row[data-node-id="${id}"]`) as HTMLElement | null
|
|
386
|
+
if (!row) return null
|
|
387
|
+
const rect = row.getBoundingClientRect()
|
|
388
|
+
if (rect.width === 0 && rect.height === 0) {
|
|
389
|
+
return { left: Math.max(180, window.innerWidth / 2), top: 60 }
|
|
390
|
+
}
|
|
391
|
+
if (rect.bottom < 48 || rect.top > window.innerHeight) return null
|
|
392
|
+
return {
|
|
393
|
+
left: Math.max(180, Math.min(window.innerWidth - 180, rect.left + rect.width / 2)),
|
|
394
|
+
top: Math.max(58, rect.top - 8),
|
|
395
|
+
}
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
function restoreTextSelection(selection: TextSelectionState) {
|
|
399
|
+
nextTick(() => focusRowRange(selection.nodeId, selection.start, selection.end))
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function applyTextFormat(format: InlineFormat) {
|
|
403
|
+
const selection = textSelection.value
|
|
404
|
+
if (!selection) return
|
|
405
|
+
const node = props.session.document.find(selection.nodeId)
|
|
406
|
+
const input = inputOf(selection.nodeId)
|
|
407
|
+
if (!node || !input) return
|
|
408
|
+
commitRow(node, input)
|
|
409
|
+
props.session.toggleNodeInlineFormat(selection.nodeId, selection.start, selection.end, format)
|
|
410
|
+
restoreTextSelection(selection)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function applyNodeFormat(format: InlineFormat) {
|
|
414
|
+
props.session.formatSelectedNodes(format)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function clearTextFormat() {
|
|
418
|
+
const selection = textSelection.value
|
|
419
|
+
if (!selection) return
|
|
420
|
+
const node = props.session.document.find(selection.nodeId)
|
|
421
|
+
const input = inputOf(selection.nodeId)
|
|
422
|
+
if (!node || !input) return
|
|
423
|
+
commitRow(node, input)
|
|
424
|
+
props.session.clearNodeInlineFormats(selection.nodeId, selection.start, selection.end)
|
|
425
|
+
restoreTextSelection(selection)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function clearNodeFormats() {
|
|
429
|
+
props.session.clearSelectedNodeFormats()
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function toggleSelectionTask() {
|
|
433
|
+
const selection = textSelection.value
|
|
434
|
+
if (selection) props.session.toggleTask(selection.nodeId)
|
|
435
|
+
else props.session.toggleTaskSelectedNodes()
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function removeToolbarSelection() {
|
|
439
|
+
textSelection.value = null
|
|
440
|
+
linkEditor.value = null
|
|
441
|
+
props.session.removeSelectedNodes()
|
|
442
|
+
containerRef.value?.focus()
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function copySelectedFromToolbar() {
|
|
446
|
+
const text = serializeSelection()
|
|
447
|
+
if (!text) return
|
|
448
|
+
try {
|
|
449
|
+
await navigator.clipboard.writeText(text)
|
|
450
|
+
} catch {
|
|
451
|
+
// 浏览器拒绝异步剪贴板时,仍可使用系统 Cmd/Ctrl+C 的既有路径。
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function cutSelectedToClipboard() {
|
|
456
|
+
const text = serializeSelection()
|
|
457
|
+
if (!text) return
|
|
458
|
+
const ids = [...props.session.selectionIds]
|
|
459
|
+
try {
|
|
460
|
+
await navigator.clipboard.writeText(text)
|
|
461
|
+
props.session.removeNodesByIds(ids)
|
|
462
|
+
containerRef.value?.focus()
|
|
463
|
+
} catch {
|
|
464
|
+
// 剪贴板写入失败时不能删除节点,避免剪切造成数据丢失。
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function openSelectionLinkEditor() {
|
|
469
|
+
const selection = textSelection.value
|
|
470
|
+
if (!selection) return
|
|
471
|
+
linkEditor.value = {
|
|
472
|
+
mode: 'selection',
|
|
473
|
+
nodeId: selection.nodeId,
|
|
474
|
+
start: selection.start,
|
|
475
|
+
end: selection.end,
|
|
476
|
+
url: 'https://',
|
|
477
|
+
position: { left: selection.position.left, top: selection.position.top + 12 },
|
|
478
|
+
}
|
|
479
|
+
nextTick(() => linkPopoverRef.value?.focusInput())
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function requestSelectionImage() {
|
|
483
|
+
pendingImageAnchorId.value = textSelection.value?.nodeId ?? props.session.selectedNode?.id ?? null
|
|
484
|
+
imagePickerRef.value?.click()
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function onSelectionImagePicked(event: Event) {
|
|
488
|
+
const input = event.target as HTMLInputElement
|
|
489
|
+
const file = input.files?.[0]
|
|
490
|
+
input.value = ''
|
|
491
|
+
const anchorId = pendingImageAnchorId.value ?? textSelection.value?.nodeId
|
|
492
|
+
pendingImageAnchorId.value = null
|
|
493
|
+
if (file && anchorId) emit('pasteImage', anchorId, file)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function focusTitle(col?: number) {
|
|
497
|
+
const root = focusRoot.value
|
|
498
|
+
if (!root) return
|
|
499
|
+
focusRow(root.id, col)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function scrollRowIntoView(index: number) {
|
|
503
|
+
const el = containerRef.value
|
|
504
|
+
const row = rows.value[index]
|
|
505
|
+
if (!el || !row) return
|
|
506
|
+
const target = listChromeHeight() + row.top
|
|
507
|
+
const bottom = target + row.height
|
|
508
|
+
if (target < el.scrollTop) el.scrollTop = Math.max(0, target - 8)
|
|
509
|
+
else if (bottom > el.scrollTop + el.clientHeight) el.scrollTop = bottom - el.clientHeight + 8
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function locateNode(id: string) {
|
|
513
|
+
const session = props.session
|
|
514
|
+
session.expandAncestors(id)
|
|
515
|
+
session.select(id)
|
|
516
|
+
nextTick(() => {
|
|
517
|
+
if (id === session.focusRootNode.id) {
|
|
518
|
+
focusTitle()
|
|
519
|
+
return
|
|
520
|
+
}
|
|
521
|
+
const idx = rows.value.findIndex((r) => r.node.id === id)
|
|
522
|
+
if (idx >= 0) scrollRowIntoView(idx)
|
|
523
|
+
})
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
defineExpose({ locateNode, selectAllFromHost, undoFromHost, redoFromHost })
|
|
527
|
+
|
|
528
|
+
// ---------- 行内编辑行为(光标感知,对齐幕布) ----------
|
|
529
|
+
|
|
530
|
+
function imageMarkdown(alt: string, src: string, width: number | null): string {
|
|
531
|
+
const w = width != null && width > 0 ? `|${Math.round(width)}` : ''
|
|
532
|
+
return ``
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function commitRow(node: MindmapNode, input: RichInlineEditorElement): boolean {
|
|
536
|
+
const session = props.session
|
|
537
|
+
const typed = input.value.trim().replace(/\n/g, '')
|
|
538
|
+
clearDraft(node.id)
|
|
539
|
+
// 图片节点:行内文案 = 描述(alt),保留 src / 宽度
|
|
540
|
+
if (node.content.image) {
|
|
541
|
+
const img = node.content.image
|
|
542
|
+
if (typed !== img.alt) {
|
|
543
|
+
session.updateNodeRaw(node.id, imageMarkdown(typed, img.src, img.width))
|
|
544
|
+
input.markCommitted()
|
|
545
|
+
return true
|
|
546
|
+
}
|
|
547
|
+
input.markCommitted()
|
|
548
|
+
return false
|
|
549
|
+
}
|
|
550
|
+
// contenteditable 草稿已经是合法 Markdown;直接提交 raw,保留全部行内 marks / href。
|
|
551
|
+
const raw = typed === '' ? '' : input.rawValue.trim()
|
|
552
|
+
if (raw !== node.content.raw) {
|
|
553
|
+
session.updateNodeRaw(node.id, raw)
|
|
554
|
+
input.markCommitted()
|
|
555
|
+
return true
|
|
556
|
+
}
|
|
557
|
+
input.markCommitted()
|
|
558
|
+
return false
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function commitEditorPayload(node: MindmapNode, payload: { raw: string; text: string }) {
|
|
562
|
+
clearDraft(node.id)
|
|
563
|
+
if (node.content.image) {
|
|
564
|
+
const image = node.content.image
|
|
565
|
+
if (payload.text !== image.alt) {
|
|
566
|
+
props.session.updateNodeRaw(node.id, imageMarkdown(payload.text, image.src, image.width))
|
|
567
|
+
}
|
|
568
|
+
} else if (payload.raw !== node.content.raw) {
|
|
569
|
+
props.session.updateNodeRaw(node.id, payload.raw)
|
|
570
|
+
}
|
|
571
|
+
inputOf(node.id)?.markCommitted()
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function clearDraft(id: string) {
|
|
575
|
+
let changed = false
|
|
576
|
+
if (draftTextHeights.value.has(id)) {
|
|
577
|
+
const next = new Map(draftTextHeights.value)
|
|
578
|
+
next.delete(id)
|
|
579
|
+
draftTextHeights.value = next
|
|
580
|
+
changed = true
|
|
581
|
+
}
|
|
582
|
+
if (draftTexts.value.has(id)) {
|
|
583
|
+
const next = new Map(draftTexts.value)
|
|
584
|
+
next.delete(id)
|
|
585
|
+
draftTexts.value = next
|
|
586
|
+
changed = true
|
|
587
|
+
}
|
|
588
|
+
return changed
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function syncDraftText(node: MindmapNode, depth: number | null, text: string) {
|
|
592
|
+
if (draftTexts.value.get(node.id) !== text) {
|
|
593
|
+
const next = new Map(draftTexts.value)
|
|
594
|
+
next.set(node.id, text)
|
|
595
|
+
draftTexts.value = next
|
|
596
|
+
}
|
|
597
|
+
const isTitle = depth == null
|
|
598
|
+
const maxW = isTitle
|
|
599
|
+
? contentAreaWidth()
|
|
600
|
+
: outlineTextMaxWidth(depth, node.content.checked !== null, !!node.content.link)
|
|
601
|
+
const h = measureTextBlockHeight(
|
|
602
|
+
text,
|
|
603
|
+
maxW,
|
|
604
|
+
isTitle ? 28 : 15,
|
|
605
|
+
isTitle ? TITLE_LINE : TEXT_LINE,
|
|
606
|
+
isTitle ? 56 : ROW_HEIGHT,
|
|
607
|
+
)
|
|
608
|
+
if (draftTextHeights.value.get(node.id) !== h) {
|
|
609
|
+
const next = new Map(draftTextHeights.value)
|
|
610
|
+
next.set(node.id, h)
|
|
611
|
+
draftTextHeights.value = next
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function onRowDraftChange(node: MindmapNode, depth: number | null, payload: { text: string }) {
|
|
616
|
+
syncDraftText(node, depth, payload.text)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* textarea 没有原生 caretClientRect;用同样排版样式的隐藏镜像判断光标是否位于首/末视觉行。
|
|
621
|
+
* 仅在跨节点导航前调用,行内上下移动仍交给浏览器原生处理。
|
|
622
|
+
*/
|
|
623
|
+
function caretVisualBoundary(input: RichInlineEditorElement): { first: boolean; last: boolean } {
|
|
624
|
+
const value = input.value
|
|
625
|
+
const position = input.selectionStart ?? 0
|
|
626
|
+
if (value.length === 0 || input.clientWidth === 0) return { first: true, last: true }
|
|
627
|
+
|
|
628
|
+
const style = getComputedStyle(input)
|
|
629
|
+
const mirror = document.createElement('div')
|
|
630
|
+
const copied = [
|
|
631
|
+
'boxSizing',
|
|
632
|
+
'width',
|
|
633
|
+
'borderTopWidth',
|
|
634
|
+
'borderRightWidth',
|
|
635
|
+
'borderBottomWidth',
|
|
636
|
+
'borderLeftWidth',
|
|
637
|
+
'paddingTop',
|
|
638
|
+
'paddingRight',
|
|
639
|
+
'paddingBottom',
|
|
640
|
+
'paddingLeft',
|
|
641
|
+
'fontFamily',
|
|
642
|
+
'fontSize',
|
|
643
|
+
'fontStyle',
|
|
644
|
+
'fontWeight',
|
|
645
|
+
'fontVariant',
|
|
646
|
+
'lineHeight',
|
|
647
|
+
'letterSpacing',
|
|
648
|
+
'textTransform',
|
|
649
|
+
'textIndent',
|
|
650
|
+
'textAlign',
|
|
651
|
+
'tabSize',
|
|
652
|
+
'whiteSpace',
|
|
653
|
+
'wordBreak',
|
|
654
|
+
'overflowWrap',
|
|
655
|
+
] as const
|
|
656
|
+
for (const property of copied) {
|
|
657
|
+
mirror.style.setProperty(property.replace(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`), style[property] as string)
|
|
658
|
+
}
|
|
659
|
+
mirror.style.position = 'fixed'
|
|
660
|
+
mirror.style.left = '-10000px'
|
|
661
|
+
mirror.style.top = '0'
|
|
662
|
+
mirror.style.height = 'auto'
|
|
663
|
+
mirror.style.minHeight = '0'
|
|
664
|
+
mirror.style.maxHeight = 'none'
|
|
665
|
+
mirror.style.overflow = 'hidden'
|
|
666
|
+
mirror.style.visibility = 'hidden'
|
|
667
|
+
mirror.style.pointerEvents = 'none'
|
|
668
|
+
document.body.append(mirror)
|
|
669
|
+
|
|
670
|
+
const topAt = (offset: number) => {
|
|
671
|
+
mirror.textContent = value.slice(0, offset)
|
|
672
|
+
const marker = document.createElement('span')
|
|
673
|
+
marker.textContent = value.slice(offset, offset + 1) || '\u200b'
|
|
674
|
+
mirror.append(marker)
|
|
675
|
+
const top = marker.offsetTop
|
|
676
|
+
marker.remove()
|
|
677
|
+
return top
|
|
678
|
+
}
|
|
679
|
+
const firstTop = topAt(0)
|
|
680
|
+
const currentTop = topAt(position)
|
|
681
|
+
const lastTop = topAt(value.length)
|
|
682
|
+
mirror.remove()
|
|
683
|
+
return { first: currentTop <= firstTop + 1, last: currentTop >= lastTop - 1 }
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function inlineFormatShortcut(event: KeyboardEvent, hasSelection = true): InlineFormat | null {
|
|
687
|
+
const key = event.key.toLowerCase()
|
|
688
|
+
if (!(event.metaKey || event.ctrlKey) || event.altKey) return null
|
|
689
|
+
if (key === 'b' && !event.shiftKey) return 'bold'
|
|
690
|
+
if (key === 'i' && !event.shiftKey) return 'italic'
|
|
691
|
+
if (key === 'u' && !event.shiftKey) return 'underline'
|
|
692
|
+
if (key === 'enter' && !event.shiftKey && hasSelection) return 'strike'
|
|
693
|
+
if ((key === 's' || key === 'x') && event.shiftKey) return 'strike'
|
|
694
|
+
if (key === 'h' && event.shiftKey) return 'highlight'
|
|
695
|
+
if (key === 'e' && !event.shiftKey) return 'code'
|
|
696
|
+
return null
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function applyInputFormatShortcut(node: MindmapNode, input: RichInlineEditorElement, format: InlineFormat) {
|
|
700
|
+
const caretStart = input.selectionStart ?? 0
|
|
701
|
+
const caretEnd = input.selectionEnd ?? 0
|
|
702
|
+
if (input.value.length === 0) return
|
|
703
|
+
const collapsed = caretStart === caretEnd
|
|
704
|
+
const start = collapsed ? 0 : caretStart
|
|
705
|
+
const end = collapsed ? input.value.length : caretEnd
|
|
706
|
+
commitRow(node, input)
|
|
707
|
+
props.session.toggleNodeInlineFormat(node.id, start, end, format)
|
|
708
|
+
if (collapsed) {
|
|
709
|
+
textSelection.value = null
|
|
710
|
+
nextTick(() => {
|
|
711
|
+
const editor = inputOf(node.id)
|
|
712
|
+
editor?.focus()
|
|
713
|
+
editor?.setSelectionRange(caretStart, caretStart)
|
|
714
|
+
})
|
|
715
|
+
} else {
|
|
716
|
+
const selection: TextSelectionState = { nodeId: node.id, start, end, position: selectionPosition(input) }
|
|
717
|
+
textSelection.value = selection
|
|
718
|
+
restoreTextSelection(selection)
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function applyInputClearFormats(node: MindmapNode, input: RichInlineEditorElement) {
|
|
723
|
+
const caretStart = input.selectionStart ?? 0
|
|
724
|
+
const caretEnd = input.selectionEnd ?? 0
|
|
725
|
+
if (input.value.length === 0) return
|
|
726
|
+
const collapsed = caretStart === caretEnd
|
|
727
|
+
const start = collapsed ? 0 : caretStart
|
|
728
|
+
const end = collapsed ? input.value.length : caretEnd
|
|
729
|
+
commitRow(node, input)
|
|
730
|
+
props.session.clearNodeInlineFormats(node.id, start, end)
|
|
731
|
+
if (collapsed) {
|
|
732
|
+
textSelection.value = null
|
|
733
|
+
nextTick(() => {
|
|
734
|
+
const editor = inputOf(node.id)
|
|
735
|
+
editor?.focus()
|
|
736
|
+
editor?.setSelectionRange(caretStart, caretStart)
|
|
737
|
+
})
|
|
738
|
+
} else {
|
|
739
|
+
const selection: TextSelectionState = { nodeId: node.id, start, end, position: selectionPosition(input) }
|
|
740
|
+
textSelection.value = selection
|
|
741
|
+
restoreTextSelection(selection)
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function onTitleKeydown(e: KeyboardEvent) {
|
|
746
|
+
const root = focusRoot.value
|
|
747
|
+
if (!root) return
|
|
748
|
+
if (focusedId.value !== root.id) return
|
|
749
|
+
const session = props.session
|
|
750
|
+
const input = e.currentTarget as RichInlineEditorElement
|
|
751
|
+
if (e.isComposing || input.isComposing) return
|
|
752
|
+
e.stopPropagation()
|
|
753
|
+
|
|
754
|
+
const mod = e.metaKey || e.ctrlKey
|
|
755
|
+
if ((e.key === '.' || e.key === '>') && (mod || e.altKey)) {
|
|
756
|
+
e.preventDefault()
|
|
757
|
+
commitRow(root, input)
|
|
758
|
+
if (mod && e.altKey && e.shiftKey) session.toggleCollapseAll()
|
|
759
|
+
else session.toggleCollapse(root.id)
|
|
760
|
+
focusTitle(input.selectionStart ?? 0)
|
|
761
|
+
return
|
|
762
|
+
}
|
|
763
|
+
if (mod) {
|
|
764
|
+
const key = e.key.toLowerCase()
|
|
765
|
+
if (key === '\\') {
|
|
766
|
+
e.preventDefault()
|
|
767
|
+
applyInputClearFormats(root, input)
|
|
768
|
+
return
|
|
769
|
+
}
|
|
770
|
+
const format = inlineFormatShortcut(e, input.selectionStart !== input.selectionEnd)
|
|
771
|
+
if (format) {
|
|
772
|
+
e.preventDefault()
|
|
773
|
+
applyInputFormatShortcut(root, input, format)
|
|
774
|
+
return
|
|
775
|
+
}
|
|
776
|
+
if (key === 'z' || key === 'y') {
|
|
777
|
+
e.preventDefault()
|
|
778
|
+
commitRow(root, input)
|
|
779
|
+
if (key === 'z' && !e.shiftKey) session.undo()
|
|
780
|
+
else session.redo()
|
|
781
|
+
} else if (key === 'f') {
|
|
782
|
+
e.preventDefault()
|
|
783
|
+
emit('requestSearch')
|
|
784
|
+
} else if (key === '[') {
|
|
785
|
+
e.preventDefault()
|
|
786
|
+
commitRow(root, input)
|
|
787
|
+
if (session.focusPath.length > 0) {
|
|
788
|
+
session.exitFocusTo(session.focusPath.length - 1)
|
|
789
|
+
focusRow(root.id)
|
|
790
|
+
}
|
|
791
|
+
} else if (key === 'a') {
|
|
792
|
+
// Nested under ProseMirror: native Cmd+A selects the whole note. Select
|
|
793
|
+
// within this field first; escalate to all outline rows when already full.
|
|
794
|
+
e.preventDefault()
|
|
795
|
+
const fullySelected = input.selectionStart === 0 && input.selectionEnd === input.value.length
|
|
796
|
+
if (fullySelected) {
|
|
797
|
+
commitRow(root, input)
|
|
798
|
+
selectAllRows()
|
|
799
|
+
} else {
|
|
800
|
+
input.setSelectionRange?.(0, input.value.length)
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
switch (e.key) {
|
|
807
|
+
case 'Enter': {
|
|
808
|
+
e.preventDefault()
|
|
809
|
+
commitRow(root, input)
|
|
810
|
+
// 幕布:标题行尾 Enter → 聚焦首个子节点;无子节点则新建
|
|
811
|
+
if (root.children.length > 0) {
|
|
812
|
+
focusRow(root.children[0].id, 0)
|
|
813
|
+
} else {
|
|
814
|
+
const created = session.insertChildOf(root.id, 0)
|
|
815
|
+
if (created) focusRow(created.id, 0)
|
|
816
|
+
}
|
|
817
|
+
break
|
|
818
|
+
}
|
|
819
|
+
case 'ArrowDown': {
|
|
820
|
+
if (input.selectionStart !== input.selectionEnd || !caretVisualBoundary(input).last) break
|
|
821
|
+
e.preventDefault()
|
|
822
|
+
commitRow(root, input)
|
|
823
|
+
if (rows.value.length > 0) focusRow(rows.value[0].node.id, input.selectionStart ?? 0)
|
|
824
|
+
break
|
|
825
|
+
}
|
|
826
|
+
case 'Escape': {
|
|
827
|
+
e.preventDefault()
|
|
828
|
+
commitRow(root, input)
|
|
829
|
+
containerRef.value?.focus()
|
|
830
|
+
break
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function onEditKeydown(node: MindmapNode, e: KeyboardEvent) {
|
|
836
|
+
if (focusedId.value !== node.id) return
|
|
837
|
+
const session = props.session
|
|
838
|
+
const input = e.currentTarget as RichInlineEditorElement
|
|
839
|
+
if (e.isComposing || input.isComposing) return
|
|
840
|
+
e.stopPropagation()
|
|
841
|
+
|
|
842
|
+
const mod = e.metaKey || e.ctrlKey
|
|
843
|
+
if ((e.key === '.' || e.key === '>') && (mod || e.altKey)) {
|
|
844
|
+
e.preventDefault()
|
|
845
|
+
commitRow(node, input)
|
|
846
|
+
if (mod && e.altKey && e.shiftKey) session.toggleCollapseAll()
|
|
847
|
+
else if (mod && e.shiftKey) session.toggleCollapseSiblings(node.id)
|
|
848
|
+
else session.toggleCollapse(node.id)
|
|
849
|
+
focusRow(node.id, input.selectionStart ?? 0)
|
|
850
|
+
return
|
|
851
|
+
}
|
|
852
|
+
if (e.altKey && !mod && e.key === 'Enter') {
|
|
853
|
+
e.preventDefault()
|
|
854
|
+
pendingImageAnchorId.value = node.id
|
|
855
|
+
imagePickerRef.value?.click()
|
|
856
|
+
return
|
|
857
|
+
}
|
|
858
|
+
if (mod) {
|
|
859
|
+
const key = e.key.toLowerCase()
|
|
860
|
+
const col = input.selectionStart ?? 0
|
|
861
|
+
if (key === '\\') {
|
|
862
|
+
e.preventDefault()
|
|
863
|
+
applyInputClearFormats(node, input)
|
|
864
|
+
return
|
|
865
|
+
}
|
|
866
|
+
const format = inlineFormatShortcut(e, input.selectionStart !== input.selectionEnd)
|
|
867
|
+
if (format) {
|
|
868
|
+
e.preventDefault()
|
|
869
|
+
applyInputFormatShortcut(node, input, format)
|
|
870
|
+
} else if (key === 'k' && !e.shiftKey && input.selectionStart !== input.selectionEnd) {
|
|
871
|
+
e.preventDefault()
|
|
872
|
+
syncTextSelection(node.id, input)
|
|
873
|
+
openSelectionLinkEditor()
|
|
874
|
+
} else if (key === 'z' || key === 'y') {
|
|
875
|
+
e.preventDefault()
|
|
876
|
+
commitRow(node, input)
|
|
877
|
+
if (key === 'z' && !e.shiftKey) session.undo()
|
|
878
|
+
else session.redo()
|
|
879
|
+
} else if (key === 'f') {
|
|
880
|
+
e.preventDefault()
|
|
881
|
+
emit('requestSearch')
|
|
882
|
+
} else if (key === ']') {
|
|
883
|
+
e.preventDefault()
|
|
884
|
+
commitRow(node, input)
|
|
885
|
+
session.focusNode(node.id)
|
|
886
|
+
} else if (key === '[') {
|
|
887
|
+
e.preventDefault()
|
|
888
|
+
commitRow(node, input)
|
|
889
|
+
if (session.focusPath.length > 0) {
|
|
890
|
+
session.exitFocusTo(session.focusPath.length - 1)
|
|
891
|
+
focusRow(node.id, col)
|
|
892
|
+
}
|
|
893
|
+
} else if (e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
|
894
|
+
e.preventDefault()
|
|
895
|
+
commitRow(node, input)
|
|
896
|
+
session.moveSelectedNodes(e.key === 'ArrowUp' ? -1 : 1)
|
|
897
|
+
focusRow(node.id, col)
|
|
898
|
+
} else if (e.shiftKey && (key === 'backspace' || key === 'd')) {
|
|
899
|
+
e.preventDefault()
|
|
900
|
+
commitRow(node, input)
|
|
901
|
+
session.removeSelectedNodes()
|
|
902
|
+
containerRef.value?.focus()
|
|
903
|
+
} else if (e.shiftKey && key === 'l') {
|
|
904
|
+
e.preventDefault()
|
|
905
|
+
commitRow(node, input)
|
|
906
|
+
session.toggleTaskSelectedNodes()
|
|
907
|
+
focusRow(node.id, col)
|
|
908
|
+
} else if (e.shiftKey && key === 'k') {
|
|
909
|
+
e.preventDefault()
|
|
910
|
+
commitRow(node, input)
|
|
911
|
+
session.toggleCheckedSelectedNodes()
|
|
912
|
+
focusRow(node.id, col)
|
|
913
|
+
} else if (key === 'd') {
|
|
914
|
+
e.preventDefault()
|
|
915
|
+
commitRow(node, input)
|
|
916
|
+
session.duplicateSelectedNodes()
|
|
917
|
+
containerRef.value?.focus()
|
|
918
|
+
} else if (key === 'enter') {
|
|
919
|
+
// Cmd/Ctrl+Enter:新建子节点(脑图视图同款)
|
|
920
|
+
e.preventDefault()
|
|
921
|
+
commitRow(node, input)
|
|
922
|
+
const created = session.insertChildOf(node.id)
|
|
923
|
+
if (created) focusRow(created.id, 0)
|
|
924
|
+
} else if (key === 'a') {
|
|
925
|
+
// Nested under ProseMirror: native Cmd+A selects the whole note. Select
|
|
926
|
+
// within this field first; escalate to all outline rows when already full.
|
|
927
|
+
e.preventDefault()
|
|
928
|
+
const fullySelected = input.selectionStart === 0 && input.selectionEnd === input.value.length
|
|
929
|
+
if (fullySelected) {
|
|
930
|
+
commitRow(node, input)
|
|
931
|
+
selectAllRows()
|
|
932
|
+
} else {
|
|
933
|
+
input.setSelectionRange?.(0, input.value.length)
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
return
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
if (e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
|
940
|
+
e.preventDefault()
|
|
941
|
+
commitRow(node, input)
|
|
942
|
+
extendSelection(e.key === 'ArrowUp' ? -1 : 1)
|
|
943
|
+
containerRef.value?.focus()
|
|
944
|
+
return
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const start = input.selectionStart ?? 0
|
|
948
|
+
const end = input.selectionEnd ?? 0
|
|
949
|
+
const collapsedSelection = start === end
|
|
950
|
+
const value = input.value
|
|
951
|
+
const list = rows.value
|
|
952
|
+
const rowIndex = list.findIndex((r) => r.node.id === node.id)
|
|
953
|
+
const prevRow = rowIndex > 0 ? list[rowIndex - 1] : null
|
|
954
|
+
const nextRow = rowIndex >= 0 && rowIndex < list.length - 1 ? list[rowIndex + 1] : null
|
|
955
|
+
const root = session.focusRootNode
|
|
956
|
+
|
|
957
|
+
switch (e.key) {
|
|
958
|
+
case 'Enter': {
|
|
959
|
+
e.preventDefault()
|
|
960
|
+
if (start === 0 && end === 0 && value.length > 0) {
|
|
961
|
+
const created = session.insertBeforeOf(node.id)
|
|
962
|
+
if (created) focusRow(created.id, 0)
|
|
963
|
+
} else if (end < value.length) {
|
|
964
|
+
const before = value.slice(0, start)
|
|
965
|
+
const after = value.slice(end)
|
|
966
|
+
clearDraft(node.id)
|
|
967
|
+
input.value = before
|
|
968
|
+
let created: MindmapNode | null = null
|
|
969
|
+
session.transact((doc) => {
|
|
970
|
+
if (node.content.image) {
|
|
971
|
+
const img = node.content.image
|
|
972
|
+
doc.updateRaw(node, imageMarkdown(before, img.src, img.width))
|
|
973
|
+
} else {
|
|
974
|
+
doc.updateDisplayText(node, before)
|
|
975
|
+
}
|
|
976
|
+
created = doc.insertAfter(node, after)
|
|
977
|
+
})
|
|
978
|
+
if (created) {
|
|
979
|
+
session.select((created as MindmapNode).id)
|
|
980
|
+
focusRow((created as MindmapNode).id, 0)
|
|
981
|
+
}
|
|
982
|
+
} else {
|
|
983
|
+
commitRow(node, input)
|
|
984
|
+
const created = session.insertSiblingOf(node.id)
|
|
985
|
+
if (created) {
|
|
986
|
+
session.select(created.id)
|
|
987
|
+
focusRow(created.id, 0)
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
break
|
|
991
|
+
}
|
|
992
|
+
case 'Backspace': {
|
|
993
|
+
if (!collapsedSelection || start > 0) break
|
|
994
|
+
e.preventDefault()
|
|
995
|
+
if (!prevRow) {
|
|
996
|
+
// 列表首行:空行删除后回到标题;有内容则不与标题合并(幕布行为)
|
|
997
|
+
if (value.length === 0) {
|
|
998
|
+
if (node.children.length > 0) break
|
|
999
|
+
session.removeNode(node.id)
|
|
1000
|
+
focusTitle()
|
|
1001
|
+
}
|
|
1002
|
+
break
|
|
1003
|
+
}
|
|
1004
|
+
if (value.length === 0) {
|
|
1005
|
+
if (node.children.length > 0) break
|
|
1006
|
+
const prevId = prevRow.node.id
|
|
1007
|
+
const prevLen = prevRow.node.content.text.length
|
|
1008
|
+
session.removeNode(node.id)
|
|
1009
|
+
focusRow(prevId, prevLen)
|
|
1010
|
+
} else if (node.parent && node.parent.children[0] === node) {
|
|
1011
|
+
// 首个子节点行首 Backspace:升级(聚焦根/文档根的直接子节点除外)
|
|
1012
|
+
if (node.parent === session.document.root || node.parent === root) break
|
|
1013
|
+
session.outdentNode(node.id)
|
|
1014
|
+
focusRow(node.id, 0)
|
|
1015
|
+
} else {
|
|
1016
|
+
const prevId = prevRow.node.id
|
|
1017
|
+
const prevLen = prevRow.node.content.text.length
|
|
1018
|
+
session.transact((doc) => {
|
|
1019
|
+
doc.updateDisplayText(prevRow.node, prevRow.node.content.text + value)
|
|
1020
|
+
for (const c of [...node.children]) doc.move(c, prevRow.node, prevRow.node.children.length)
|
|
1021
|
+
doc.remove(node)
|
|
1022
|
+
})
|
|
1023
|
+
session.select(prevId)
|
|
1024
|
+
focusRow(prevId, prevLen)
|
|
1025
|
+
}
|
|
1026
|
+
break
|
|
1027
|
+
}
|
|
1028
|
+
case 'Delete': {
|
|
1029
|
+
if (!collapsedSelection || end < value.length) break
|
|
1030
|
+
if (!nextRow || nextRow.node.children.length > 0) break
|
|
1031
|
+
e.preventDefault()
|
|
1032
|
+
const nextText = nextRow.node.content.text
|
|
1033
|
+
session.transact((doc) => {
|
|
1034
|
+
doc.updateDisplayText(node, value + nextText)
|
|
1035
|
+
doc.remove(nextRow.node)
|
|
1036
|
+
})
|
|
1037
|
+
focusRow(node.id, value.length)
|
|
1038
|
+
break
|
|
1039
|
+
}
|
|
1040
|
+
case 'Tab': {
|
|
1041
|
+
e.preventDefault()
|
|
1042
|
+
const col = input.selectionStart ?? 0
|
|
1043
|
+
commitRow(node, input)
|
|
1044
|
+
if (e.shiftKey) session.outdentNode(node.id)
|
|
1045
|
+
else session.indentNode(node.id)
|
|
1046
|
+
focusRow(node.id, col)
|
|
1047
|
+
break
|
|
1048
|
+
}
|
|
1049
|
+
case 'Escape': {
|
|
1050
|
+
e.preventDefault()
|
|
1051
|
+
commitRow(node, input)
|
|
1052
|
+
containerRef.value?.focus()
|
|
1053
|
+
break
|
|
1054
|
+
}
|
|
1055
|
+
case 'ArrowUp': {
|
|
1056
|
+
if (!collapsedSelection || !caretVisualBoundary(input).first) break
|
|
1057
|
+
e.preventDefault()
|
|
1058
|
+
commitRow(node, input)
|
|
1059
|
+
if (prevRow) focusRow(prevRow.node.id, start)
|
|
1060
|
+
else focusTitle(start)
|
|
1061
|
+
break
|
|
1062
|
+
}
|
|
1063
|
+
case 'ArrowDown': {
|
|
1064
|
+
if (!collapsedSelection || !caretVisualBoundary(input).last) break
|
|
1065
|
+
e.preventDefault()
|
|
1066
|
+
commitRow(node, input)
|
|
1067
|
+
if (nextRow) focusRow(nextRow.node.id, start)
|
|
1068
|
+
break
|
|
1069
|
+
}
|
|
1070
|
+
case 'ArrowLeft': {
|
|
1071
|
+
if (!collapsedSelection || start > 0) break
|
|
1072
|
+
e.preventDefault()
|
|
1073
|
+
commitRow(node, input)
|
|
1074
|
+
if (prevRow) focusRow(prevRow.node.id)
|
|
1075
|
+
else focusTitle()
|
|
1076
|
+
break
|
|
1077
|
+
}
|
|
1078
|
+
case 'ArrowRight': {
|
|
1079
|
+
if (!collapsedSelection || end < value.length) break
|
|
1080
|
+
e.preventDefault()
|
|
1081
|
+
commitRow(node, input)
|
|
1082
|
+
if (nextRow) focusRow(nextRow.node.id, 0)
|
|
1083
|
+
break
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function onRowInputBlur(node: MindmapNode, e: FocusEvent) {
|
|
1089
|
+
const input = e.currentTarget as RichInlineEditorElement
|
|
1090
|
+
commitRow(node, input)
|
|
1091
|
+
if (focusedId.value === node.id) focusedId.value = null
|
|
1092
|
+
if (!linkEditor.value && textSelection.value?.nodeId === node.id) textSelection.value = null
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function onRowInputFocus(node: MindmapNode, e?: FocusEvent) {
|
|
1096
|
+
focusedId.value = node.id
|
|
1097
|
+
props.session.select(node.id)
|
|
1098
|
+
const input = (e?.currentTarget as RichInlineEditorElement | undefined) ?? inputOf(node.id)
|
|
1099
|
+
if (input && !draftTexts.value.has(node.id)) {
|
|
1100
|
+
const next = new Map(draftTexts.value)
|
|
1101
|
+
next.set(node.id, input.value)
|
|
1102
|
+
draftTexts.value = next
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// ---------- 复制 / 剪切 / 粘贴 ----------
|
|
1107
|
+
|
|
1108
|
+
const LIST_LINE_RE = /^\s*[-*+]\s+/
|
|
1109
|
+
|
|
1110
|
+
function selectedRoots(): MindmapNode[] {
|
|
1111
|
+
const ids = props.session.selectionIds
|
|
1112
|
+
return props.session.selectedNodes.filter((node) => {
|
|
1113
|
+
if (node === props.session.focusRootNode || node === props.session.document.root) return false
|
|
1114
|
+
let parent = node.parent
|
|
1115
|
+
while (parent) {
|
|
1116
|
+
if (ids.has(parent.id)) return false
|
|
1117
|
+
parent = parent.parent
|
|
1118
|
+
}
|
|
1119
|
+
return true
|
|
1120
|
+
})
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function serializeSelection(): string {
|
|
1124
|
+
return selectedRoots().map((node) => serializeSubtree(node)).join('\n')
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function selectAllRows() {
|
|
1128
|
+
const list = rows.value
|
|
1129
|
+
if (list.length === 0) return
|
|
1130
|
+
props.session.selectMany(
|
|
1131
|
+
list.map((row) => row.node.id),
|
|
1132
|
+
list[list.length - 1].node.id,
|
|
1133
|
+
list[0].node.id,
|
|
1134
|
+
)
|
|
1135
|
+
containerRef.value?.focus()
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/** Desk host Mod+A when focus isn't on a row editor target. */
|
|
1139
|
+
function selectAllFromHost(): void {
|
|
1140
|
+
const active = document.activeElement
|
|
1141
|
+
if (active && containerRef.value?.contains(active) && 'selectionStart' in active) {
|
|
1142
|
+
const input = active as RichInlineEditorElement
|
|
1143
|
+
const len = input.value?.length ?? 0
|
|
1144
|
+
const fullySelected = (input.selectionStart ?? 0) === 0 && (input.selectionEnd ?? 0) === len
|
|
1145
|
+
if (!fullySelected && typeof input.setSelectionRange === 'function') {
|
|
1146
|
+
input.setSelectionRange(0, len)
|
|
1147
|
+
return
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
selectAllRows()
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function commitFocusedRowIfAny(): void {
|
|
1154
|
+
const id = focusedId.value
|
|
1155
|
+
if (!id) return
|
|
1156
|
+
const node = props.session.document.find(id)
|
|
1157
|
+
const active = document.activeElement
|
|
1158
|
+
if (!node || !active || !containerRef.value?.contains(active)) return
|
|
1159
|
+
if (!('selectionStart' in active)) return
|
|
1160
|
+
commitRow(node, active as RichInlineEditorElement)
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/** Desk host Mod+Z — commit in-flight row edit then undo session history. */
|
|
1164
|
+
function undoFromHost(): void {
|
|
1165
|
+
commitFocusedRowIfAny()
|
|
1166
|
+
props.session.undo()
|
|
1167
|
+
containerRef.value?.focus()
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/** Desk host Mod+Shift+Z / Mod+Y. */
|
|
1171
|
+
function redoFromHost(): void {
|
|
1172
|
+
commitFocusedRowIfAny()
|
|
1173
|
+
props.session.redo()
|
|
1174
|
+
containerRef.value?.focus()
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
function extendSelectionTo(index: number) {
|
|
1178
|
+
const list = rows.value
|
|
1179
|
+
const target = list[index]
|
|
1180
|
+
if (!target) return
|
|
1181
|
+
const anchorId = props.session.selectionAnchor?.id ?? props.session.selectedNode?.id ?? target.node.id
|
|
1182
|
+
const anchorIndex = list.findIndex((row) => row.node.id === anchorId)
|
|
1183
|
+
const normalizedAnchor = anchorIndex >= 0 ? anchorIndex : index
|
|
1184
|
+
const from = Math.min(normalizedAnchor, index)
|
|
1185
|
+
const to = Math.max(normalizedAnchor, index)
|
|
1186
|
+
props.session.selectMany(
|
|
1187
|
+
list.slice(from, to + 1).map((row) => row.node.id),
|
|
1188
|
+
target.node.id,
|
|
1189
|
+
list[normalizedAnchor].node.id,
|
|
1190
|
+
)
|
|
1191
|
+
scrollRowIntoView(index)
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function extendSelection(direction: -1 | 1) {
|
|
1195
|
+
const list = rows.value
|
|
1196
|
+
if (list.length === 0) return
|
|
1197
|
+
const selected = props.session.selectedNode
|
|
1198
|
+
const current = selected ? list.findIndex((row) => row.node.id === selected.id) : -1
|
|
1199
|
+
const next = current < 0 ? (direction > 0 ? 0 : list.length - 1) : Math.max(0, Math.min(list.length - 1, current + direction))
|
|
1200
|
+
extendSelectionTo(next)
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function onEditorPasteImage(node: MindmapNode, image: Blob) {
|
|
1204
|
+
const input = inputOf(node.id)
|
|
1205
|
+
if (input) commitRow(node, input)
|
|
1206
|
+
emit('pasteImage', node.id, image)
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function onEditorPasteMultiline(node: MindmapNode, text: string) {
|
|
1210
|
+
const session = props.session
|
|
1211
|
+
const input = inputOf(node.id)
|
|
1212
|
+
if (input) commitRow(node, input)
|
|
1213
|
+
|
|
1214
|
+
const fragment = text
|
|
1215
|
+
.split(/\r?\n/)
|
|
1216
|
+
.filter((l) => l.trim() !== '')
|
|
1217
|
+
.map((l) => {
|
|
1218
|
+
if (LIST_LINE_RE.test(l)) return l
|
|
1219
|
+
const m = /^(\s*)(.*)$/.exec(l)!
|
|
1220
|
+
return `${m[1]}- ${m[2]}`
|
|
1221
|
+
})
|
|
1222
|
+
.join('\n')
|
|
1223
|
+
const { doc: tmp } = parseMarkdown(`# _\n\n${fragment}\n`)
|
|
1224
|
+
const nodes = tmp.root.children.map((c) => cloneSubtree(c))
|
|
1225
|
+
if (nodes.length === 0) return
|
|
1226
|
+
|
|
1227
|
+
const currentText = node.content.text.trim()
|
|
1228
|
+
session.transact((doc) => {
|
|
1229
|
+
let anchor = node
|
|
1230
|
+
if (currentText === '' && node !== doc.root && node !== session.focusRootNode) {
|
|
1231
|
+
const first = nodes.shift()!
|
|
1232
|
+
doc.updateRaw(node, first.content.raw)
|
|
1233
|
+
for (const c of [...first.children]) doc.move(c, node, node.children.length)
|
|
1234
|
+
} else if (node.content.image) {
|
|
1235
|
+
const img = node.content.image
|
|
1236
|
+
if (currentText !== img.alt) {
|
|
1237
|
+
doc.updateRaw(node, imageMarkdown(currentText, img.src, img.width))
|
|
1238
|
+
}
|
|
1239
|
+
} else if (currentText !== node.content.text) {
|
|
1240
|
+
doc.updateDisplayText(node, currentText)
|
|
1241
|
+
}
|
|
1242
|
+
// 粘贴到标题:插入为标题的子节点
|
|
1243
|
+
if (node === session.focusRootNode) {
|
|
1244
|
+
let index = node.children.length
|
|
1245
|
+
for (const n of nodes) {
|
|
1246
|
+
const inserted = doc.addNode(node, n.content, index++)
|
|
1247
|
+
inserted.collapsed = n.collapsed
|
|
1248
|
+
for (const c of [...n.children]) doc.move(c, inserted, inserted.children.length)
|
|
1249
|
+
}
|
|
1250
|
+
return
|
|
1251
|
+
}
|
|
1252
|
+
const parent = anchor.parent ?? doc.root
|
|
1253
|
+
let index = parent.children.indexOf(anchor) + 1
|
|
1254
|
+
for (const n of nodes) {
|
|
1255
|
+
const inserted = doc.addNode(parent, n.content, index++)
|
|
1256
|
+
inserted.collapsed = n.collapsed
|
|
1257
|
+
for (const c of [...n.children]) doc.move(c, inserted, inserted.children.length)
|
|
1258
|
+
}
|
|
1259
|
+
})
|
|
1260
|
+
session.select(node.id)
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function onCopy(node: MindmapNode, e: ClipboardEvent) {
|
|
1264
|
+
const input = e.currentTarget as RichInlineEditorElement
|
|
1265
|
+
if (input.selectionStart !== input.selectionEnd) return
|
|
1266
|
+
e.preventDefault()
|
|
1267
|
+
const text = props.session.selectionIds.size > 1 && props.session.selectionIds.has(node.id)
|
|
1268
|
+
? serializeSelection()
|
|
1269
|
+
: serializeSubtree(node)
|
|
1270
|
+
e.clipboardData?.setData('text/plain', text)
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function onCut(node: MindmapNode, e: ClipboardEvent) {
|
|
1274
|
+
const input = e.currentTarget as RichInlineEditorElement
|
|
1275
|
+
if (input.selectionStart !== input.selectionEnd) return
|
|
1276
|
+
e.preventDefault()
|
|
1277
|
+
if (props.session.selectionIds.size > 1 && props.session.selectionIds.has(node.id)) {
|
|
1278
|
+
e.clipboardData?.setData('text/plain', serializeSelection())
|
|
1279
|
+
props.session.removeSelectedNodes()
|
|
1280
|
+
} else {
|
|
1281
|
+
e.clipboardData?.setData('text/plain', serializeSubtree(node))
|
|
1282
|
+
if (node !== props.session.document.root && node !== props.session.focusRootNode) {
|
|
1283
|
+
props.session.removeNode(node.id)
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function onContainerCopy(e: ClipboardEvent) {
|
|
1289
|
+
if (props.session.selectionIds.size === 0) return
|
|
1290
|
+
const text = serializeSelection()
|
|
1291
|
+
if (!text) return
|
|
1292
|
+
e.preventDefault()
|
|
1293
|
+
e.clipboardData?.setData('text/plain', text)
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function onContainerCut(e: ClipboardEvent) {
|
|
1297
|
+
if (props.session.selectionIds.size === 0) return
|
|
1298
|
+
const text = serializeSelection()
|
|
1299
|
+
if (!text) return
|
|
1300
|
+
e.preventDefault()
|
|
1301
|
+
e.clipboardData?.setData('text/plain', text)
|
|
1302
|
+
props.session.removeSelectedNodes()
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// ---------- 行点击 / bullet / 折叠 ----------
|
|
1306
|
+
|
|
1307
|
+
let suppressRowClick = false
|
|
1308
|
+
|
|
1309
|
+
function onBulletClick(node: MindmapNode, e: MouseEvent) {
|
|
1310
|
+
e.stopPropagation()
|
|
1311
|
+
if (suppressRowClick) return
|
|
1312
|
+
props.session.focusNode(node.id)
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function onArrowClick(node: MindmapNode, e: MouseEvent) {
|
|
1316
|
+
e.stopPropagation()
|
|
1317
|
+
if (suppressRowClick) return
|
|
1318
|
+
props.session.toggleCollapse(node.id)
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function onCheckboxClick(node: MindmapNode, e: MouseEvent) {
|
|
1322
|
+
e.stopPropagation()
|
|
1323
|
+
props.session.toggleChecked(node.id)
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function onInlineDisplayClick(node: MindmapNode, e: MouseEvent) {
|
|
1327
|
+
e.stopPropagation()
|
|
1328
|
+
if ((e.target as HTMLElement | null)?.closest('.inline-run.link')) return
|
|
1329
|
+
if (focusedId.value === node.id) return
|
|
1330
|
+
const editor = e.currentTarget as HTMLElement
|
|
1331
|
+
focusRow(node.id, caretOffsetFromPoint(editor, e.clientX, e.clientY))
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function navigableUrl(raw: string): string | null {
|
|
1335
|
+
const url = raw.trim()
|
|
1336
|
+
if (!url || /^(?:javascript|data|vbscript):/i.test(url)) return null
|
|
1337
|
+
if (/^(?:https?|mailto|tel):/i.test(url) || /^(?:[./#]|\/)/.test(url)) return url
|
|
1338
|
+
return `https://${url}`
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
function onInlineLinkClick(link: InlineLink, event: Event) {
|
|
1342
|
+
event.preventDefault()
|
|
1343
|
+
event.stopPropagation()
|
|
1344
|
+
const url = navigableUrl(link.url)
|
|
1345
|
+
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
function keepLinkPopover() {
|
|
1349
|
+
if (linkLeaveTimer) clearTimeout(linkLeaveTimer)
|
|
1350
|
+
linkLeaveTimer = null
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function closeLinkPopoverSoon() {
|
|
1354
|
+
keepLinkPopover()
|
|
1355
|
+
linkLeaveTimer = setTimeout(() => {
|
|
1356
|
+
if (linkEditor.value?.mode === 'existing') linkEditor.value = null
|
|
1357
|
+
}, 180)
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
function onInlineLinkEnter(node: MindmapNode, link: InlineLink, event: MouseEvent) {
|
|
1361
|
+
if (linkEditor.value?.mode === 'selection') return
|
|
1362
|
+
keepLinkPopover()
|
|
1363
|
+
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
|
1364
|
+
linkEditor.value = {
|
|
1365
|
+
mode: 'existing',
|
|
1366
|
+
nodeId: node.id,
|
|
1367
|
+
rawStart: link.rawStart,
|
|
1368
|
+
rawEnd: link.rawEnd,
|
|
1369
|
+
url: link.url,
|
|
1370
|
+
position: {
|
|
1371
|
+
left: Math.max(220, Math.min(window.innerWidth - 220, rect.left + rect.width / 2)),
|
|
1372
|
+
top: Math.min(window.innerHeight - 54, rect.bottom + 6),
|
|
1373
|
+
},
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function onTitleLinkEnter(link: InlineLink, event: MouseEvent) {
|
|
1378
|
+
const root = focusRoot.value
|
|
1379
|
+
if (root) onInlineLinkEnter(root, link, event)
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
function saveLink(url: string) {
|
|
1383
|
+
const editor = linkEditor.value
|
|
1384
|
+
if (!editor) return
|
|
1385
|
+
if (editor.mode === 'existing') {
|
|
1386
|
+
props.session.updateNodeInlineLink(editor.nodeId, editor.rawStart!, editor.rawEnd!, url)
|
|
1387
|
+
} else {
|
|
1388
|
+
props.session.setNodeInlineLink(editor.nodeId, editor.start!, editor.end!, url)
|
|
1389
|
+
}
|
|
1390
|
+
linkEditor.value = null
|
|
1391
|
+
const selection = textSelection.value
|
|
1392
|
+
if (selection) restoreTextSelection(selection)
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
function removeLink() {
|
|
1396
|
+
const editor = linkEditor.value
|
|
1397
|
+
if (!editor) return
|
|
1398
|
+
if (editor.mode === 'existing') {
|
|
1399
|
+
props.session.updateNodeInlineLink(editor.nodeId, editor.rawStart!, editor.rawEnd!, null)
|
|
1400
|
+
} else {
|
|
1401
|
+
props.session.setNodeInlineLink(editor.nodeId, editor.start!, editor.end!, null)
|
|
1402
|
+
}
|
|
1403
|
+
linkEditor.value = null
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function onImageClick(node: MindmapNode, e: MouseEvent) {
|
|
1407
|
+
e.stopPropagation()
|
|
1408
|
+
if (node.content.image) emit('imagePreview', resolvedImageSrc(node.content.image.src))
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
function onOutlineImageLoad(src: string, e: Event) {
|
|
1412
|
+
const el = e.target as HTMLImageElement
|
|
1413
|
+
if (!el.naturalWidth || !el.naturalHeight) return
|
|
1414
|
+
const aspect = el.naturalWidth / el.naturalHeight
|
|
1415
|
+
if (imageAspects.value.get(src) === aspect) return
|
|
1416
|
+
const next = new Map(imageAspects.value)
|
|
1417
|
+
next.set(src, aspect)
|
|
1418
|
+
imageAspects.value = next
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
interface ImgResizeState {
|
|
1422
|
+
id: string
|
|
1423
|
+
startX: number
|
|
1424
|
+
startW: number
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
let imgResize: ImgResizeState | null = null
|
|
1428
|
+
|
|
1429
|
+
function onImageResizePointerDown(node: MindmapNode, e: PointerEvent) {
|
|
1430
|
+
if (e.button !== 0 || !node.content.image) return
|
|
1431
|
+
e.preventDefault()
|
|
1432
|
+
e.stopPropagation()
|
|
1433
|
+
imgResize = {
|
|
1434
|
+
id: node.id,
|
|
1435
|
+
startX: e.clientX,
|
|
1436
|
+
startW: node.content.image.width ?? DEFAULT_OUTLINE_IMG_W,
|
|
1437
|
+
}
|
|
1438
|
+
suppressRowClick = true
|
|
1439
|
+
window.addEventListener('pointermove', onImageResizeMove)
|
|
1440
|
+
window.addEventListener('pointerup', onImageResizeUp, { once: true })
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
function onImageResizeMove(e: PointerEvent) {
|
|
1444
|
+
if (!imgResize) return
|
|
1445
|
+
const next = Math.max(MIN_IMG_W, Math.min(MAX_IMG_W, Math.round(imgResize.startW + (e.clientX - imgResize.startX))))
|
|
1446
|
+
const map = new Map(liveImageWidth.value)
|
|
1447
|
+
map.set(imgResize.id, next)
|
|
1448
|
+
liveImageWidth.value = map
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
function onImageResizeUp() {
|
|
1452
|
+
window.removeEventListener('pointermove', onImageResizeMove)
|
|
1453
|
+
const state = imgResize
|
|
1454
|
+
imgResize = null
|
|
1455
|
+
setTimeout(() => (suppressRowClick = false), 0)
|
|
1456
|
+
if (!state) return
|
|
1457
|
+
const w = liveImageWidth.value.get(state.id) ?? state.startW
|
|
1458
|
+
const map = new Map(liveImageWidth.value)
|
|
1459
|
+
map.delete(state.id)
|
|
1460
|
+
liveImageWidth.value = map
|
|
1461
|
+
if (w !== state.startW) props.session.setImageWidth(state.id, w)
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// ---------- 键盘导航(焦点在容器、非编辑态时) ----------
|
|
1465
|
+
|
|
1466
|
+
function onKeydown(e: KeyboardEvent) {
|
|
1467
|
+
const session = props.session
|
|
1468
|
+
const sel = session.selectedNode
|
|
1469
|
+
const mod = e.metaKey || e.ctrlKey
|
|
1470
|
+
const key = e.key.toLowerCase()
|
|
1471
|
+
|
|
1472
|
+
if (mod && key === '\\' && session.selectionIds.size > 0) {
|
|
1473
|
+
e.preventDefault()
|
|
1474
|
+
session.clearSelectedNodeFormats()
|
|
1475
|
+
return
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
const format = inlineFormatShortcut(e, session.selectionIds.size > 1)
|
|
1479
|
+
if (format && session.selectionIds.size > 0) {
|
|
1480
|
+
e.preventDefault()
|
|
1481
|
+
session.formatSelectedNodes(format)
|
|
1482
|
+
return
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
if (mod && key === 'a') {
|
|
1486
|
+
e.preventDefault()
|
|
1487
|
+
selectAllRows()
|
|
1488
|
+
return
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
if (mod && key === 'z') {
|
|
1492
|
+
e.preventDefault()
|
|
1493
|
+
if (e.shiftKey) session.redo()
|
|
1494
|
+
else session.undo()
|
|
1495
|
+
return
|
|
1496
|
+
}
|
|
1497
|
+
if (mod && key === 'y') {
|
|
1498
|
+
e.preventDefault()
|
|
1499
|
+
session.redo()
|
|
1500
|
+
return
|
|
1501
|
+
}
|
|
1502
|
+
if (mod && key === 'f') {
|
|
1503
|
+
e.preventDefault()
|
|
1504
|
+
emit('requestSearch')
|
|
1505
|
+
return
|
|
1506
|
+
}
|
|
1507
|
+
if (mod && !e.shiftKey && key === 'x' && session.selectionIds.size > 0) {
|
|
1508
|
+
e.preventDefault()
|
|
1509
|
+
void cutSelectedToClipboard()
|
|
1510
|
+
return
|
|
1511
|
+
}
|
|
1512
|
+
if ((e.key === '.' || e.key === '>') && (mod || e.altKey)) {
|
|
1513
|
+
e.preventDefault()
|
|
1514
|
+
if (mod && e.altKey && e.shiftKey) session.toggleCollapseAll()
|
|
1515
|
+
else if (mod && e.shiftKey && sel) session.toggleCollapseSiblings(sel.id)
|
|
1516
|
+
else if (sel) session.toggleCollapse(sel.id)
|
|
1517
|
+
return
|
|
1518
|
+
}
|
|
1519
|
+
if (mod && e.key === ']') {
|
|
1520
|
+
e.preventDefault()
|
|
1521
|
+
if (sel) {
|
|
1522
|
+
session.focusNode(sel.id)
|
|
1523
|
+
focusTitle()
|
|
1524
|
+
}
|
|
1525
|
+
return
|
|
1526
|
+
}
|
|
1527
|
+
if (mod && e.key === '[') {
|
|
1528
|
+
e.preventDefault()
|
|
1529
|
+
if (session.focusPath.length > 0) session.exitFocusTo(session.focusPath.length - 1)
|
|
1530
|
+
return
|
|
1531
|
+
}
|
|
1532
|
+
if (mod && e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
|
1533
|
+
e.preventDefault()
|
|
1534
|
+
session.moveSelectedNodes(e.key === 'ArrowUp' ? -1 : 1)
|
|
1535
|
+
return
|
|
1536
|
+
}
|
|
1537
|
+
if (mod && e.shiftKey && (key === 'backspace' || key === 'd')) {
|
|
1538
|
+
e.preventDefault()
|
|
1539
|
+
session.removeSelectedNodes()
|
|
1540
|
+
return
|
|
1541
|
+
}
|
|
1542
|
+
if (mod && e.shiftKey && key === 'l') {
|
|
1543
|
+
e.preventDefault()
|
|
1544
|
+
session.toggleTaskSelectedNodes()
|
|
1545
|
+
return
|
|
1546
|
+
}
|
|
1547
|
+
if (mod && e.shiftKey && key === 'k') {
|
|
1548
|
+
e.preventDefault()
|
|
1549
|
+
session.toggleCheckedSelectedNodes()
|
|
1550
|
+
return
|
|
1551
|
+
}
|
|
1552
|
+
if (mod && key === 'd') {
|
|
1553
|
+
e.preventDefault()
|
|
1554
|
+
session.duplicateSelectedNodes()
|
|
1555
|
+
return
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
const list = rows.value
|
|
1559
|
+
const idx = sel ? list.findIndex((r) => r.node.id === sel.id) : -1
|
|
1560
|
+
const root = session.focusRootNode
|
|
1561
|
+
|
|
1562
|
+
switch (e.key) {
|
|
1563
|
+
case 'Enter':
|
|
1564
|
+
e.preventDefault()
|
|
1565
|
+
if (sel) focusRow(sel.id)
|
|
1566
|
+
else focusTitle()
|
|
1567
|
+
break
|
|
1568
|
+
case 'Tab':
|
|
1569
|
+
e.preventDefault()
|
|
1570
|
+
if (sel && sel !== root) {
|
|
1571
|
+
if (e.shiftKey) session.outdentSelectedNodes()
|
|
1572
|
+
else session.indentSelectedNodes()
|
|
1573
|
+
}
|
|
1574
|
+
break
|
|
1575
|
+
case 'Delete':
|
|
1576
|
+
case 'Backspace':
|
|
1577
|
+
e.preventDefault()
|
|
1578
|
+
if (sel && sel !== root) session.removeSelectedNodes()
|
|
1579
|
+
break
|
|
1580
|
+
case 'ArrowUp':
|
|
1581
|
+
case 'ArrowDown': {
|
|
1582
|
+
e.preventDefault()
|
|
1583
|
+
if (e.shiftKey) {
|
|
1584
|
+
extendSelection(e.key === 'ArrowDown' ? 1 : -1)
|
|
1585
|
+
break
|
|
1586
|
+
}
|
|
1587
|
+
if (list.length === 0) {
|
|
1588
|
+
session.select(root.id)
|
|
1589
|
+
break
|
|
1590
|
+
}
|
|
1591
|
+
if (sel?.id === root.id && e.key === 'ArrowDown') {
|
|
1592
|
+
session.select(list[0].node.id)
|
|
1593
|
+
scrollRowIntoView(0)
|
|
1594
|
+
break
|
|
1595
|
+
}
|
|
1596
|
+
const next =
|
|
1597
|
+
idx < 0
|
|
1598
|
+
? list[0]
|
|
1599
|
+
: list[Math.max(0, Math.min(list.length - 1, idx + (e.key === 'ArrowDown' ? 1 : -1)))]
|
|
1600
|
+
session.select(next.node.id)
|
|
1601
|
+
scrollRowIntoView(next.index)
|
|
1602
|
+
break
|
|
1603
|
+
}
|
|
1604
|
+
case 'ArrowLeft':
|
|
1605
|
+
e.preventDefault()
|
|
1606
|
+
if (sel && sel !== root) {
|
|
1607
|
+
if (sel.children.length > 0 && !sel.collapsed) session.toggleCollapse(sel.id)
|
|
1608
|
+
else if (sel.parent) session.select(sel.parent.id)
|
|
1609
|
+
}
|
|
1610
|
+
break
|
|
1611
|
+
case 'ArrowRight':
|
|
1612
|
+
e.preventDefault()
|
|
1613
|
+
if (sel) {
|
|
1614
|
+
if (sel.collapsed) session.toggleCollapse(sel.id)
|
|
1615
|
+
else if (sel.children.length > 0) session.select(sel.children[0].id)
|
|
1616
|
+
}
|
|
1617
|
+
break
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// ---------- 鼠标跨节点连续选择 ----------
|
|
1622
|
+
|
|
1623
|
+
const rangeSelecting = ref(false)
|
|
1624
|
+
let stopRangeSelect: (() => void) | null = null
|
|
1625
|
+
|
|
1626
|
+
function onRowPointerDown(row: Row, e: PointerEvent) {
|
|
1627
|
+
if (e.button !== 0) return
|
|
1628
|
+
const target = e.target as HTMLElement | null
|
|
1629
|
+
if (target?.closest('.row-gutter, .row-checkbox, .row-badge, .row-image, .row-image-handle')) return
|
|
1630
|
+
|
|
1631
|
+
if (e.shiftKey) {
|
|
1632
|
+
e.preventDefault()
|
|
1633
|
+
const active = document.activeElement
|
|
1634
|
+
if (isInlineEditorElement(active)) active.blur()
|
|
1635
|
+
extendSelectionTo(row.index)
|
|
1636
|
+
containerRef.value?.focus()
|
|
1637
|
+
return
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
stopRangeSelect?.()
|
|
1641
|
+
const pointerId = e.pointerId
|
|
1642
|
+
const startIndex = row.index
|
|
1643
|
+
const startY = e.clientY
|
|
1644
|
+
let dragging = false
|
|
1645
|
+
|
|
1646
|
+
const cleanup = () => {
|
|
1647
|
+
document.removeEventListener('pointermove', onMove)
|
|
1648
|
+
document.removeEventListener('pointerup', onUp)
|
|
1649
|
+
document.removeEventListener('pointercancel', onUp)
|
|
1650
|
+
if (dragging) {
|
|
1651
|
+
document.body.style.removeProperty('user-select')
|
|
1652
|
+
document.body.style.removeProperty('-webkit-user-select')
|
|
1653
|
+
setTimeout(() => (rangeSelecting.value = false), 0)
|
|
1654
|
+
}
|
|
1655
|
+
stopRangeSelect = null
|
|
1656
|
+
}
|
|
1657
|
+
const onMove = (ev: PointerEvent) => {
|
|
1658
|
+
if (ev.isTrusted && ev.pointerId !== pointerId) return
|
|
1659
|
+
const index = rowIndexAt(ev.clientY)
|
|
1660
|
+
if (index < 0) return
|
|
1661
|
+
if (!dragging) {
|
|
1662
|
+
if (index === startIndex || Math.abs(ev.clientY - startY) < 5) return
|
|
1663
|
+
dragging = true
|
|
1664
|
+
rangeSelecting.value = true
|
|
1665
|
+
document.body.style.setProperty('user-select', 'none')
|
|
1666
|
+
document.body.style.setProperty('-webkit-user-select', 'none')
|
|
1667
|
+
const active = document.activeElement
|
|
1668
|
+
if (isInlineEditorElement(active)) active.blur()
|
|
1669
|
+
window.getSelection()?.removeAllRanges()
|
|
1670
|
+
}
|
|
1671
|
+
ev.preventDefault()
|
|
1672
|
+
const from = Math.min(startIndex, index)
|
|
1673
|
+
const to = Math.max(startIndex, index)
|
|
1674
|
+
const selected = rows.value.slice(from, to + 1)
|
|
1675
|
+
props.session.selectMany(
|
|
1676
|
+
selected.map((item) => item.node.id),
|
|
1677
|
+
rows.value[index]?.node.id ?? null,
|
|
1678
|
+
rows.value[startIndex]?.node.id ?? null,
|
|
1679
|
+
)
|
|
1680
|
+
}
|
|
1681
|
+
const onUp = (ev: PointerEvent) => {
|
|
1682
|
+
if (ev.isTrusted && ev.pointerId !== pointerId) return
|
|
1683
|
+
cleanup()
|
|
1684
|
+
if (dragging) containerRef.value?.focus()
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
stopRangeSelect = cleanup
|
|
1688
|
+
document.addEventListener('pointermove', onMove, { passive: false })
|
|
1689
|
+
document.addEventListener('pointerup', onUp)
|
|
1690
|
+
document.addEventListener('pointercancel', onUp)
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
// ---------- 拖拽移动(幕布口径:圆点发起;横线 + 层级线高亮) ----------
|
|
1694
|
+
|
|
1695
|
+
interface DropIndicator {
|
|
1696
|
+
type: 'before' | 'after' | 'child'
|
|
1697
|
+
targetId: string
|
|
1698
|
+
/** 相对 .outline-spacer 的横线位置 */
|
|
1699
|
+
top: number
|
|
1700
|
+
left: number
|
|
1701
|
+
width: number
|
|
1702
|
+
/** child:高亮与父级圆点对齐的那一列引导线(depth 值) */
|
|
1703
|
+
guideDepth: number | null
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
interface DragState {
|
|
1707
|
+
id: string
|
|
1708
|
+
startY: number
|
|
1709
|
+
dragging: boolean
|
|
1710
|
+
pointerX: number
|
|
1711
|
+
pointerY: number
|
|
1712
|
+
indicator: DropIndicator | null
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
const drag = ref<DragState | null>(null)
|
|
1716
|
+
|
|
1717
|
+
function bulletCenterX(depth: number): number {
|
|
1718
|
+
return COLLAPSE_LEAD + depth * INDENT + BULLET_SIZE / 2
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
function isUnderAncestor(node: MindmapNode, ancestorId: string): boolean {
|
|
1722
|
+
let p = node.parent
|
|
1723
|
+
while (p) {
|
|
1724
|
+
if (p.id === ancestorId) return true
|
|
1725
|
+
p = p.parent
|
|
1726
|
+
}
|
|
1727
|
+
return false
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
function isDropGuideHighlighted(row: Row, guideDepth: number): boolean {
|
|
1731
|
+
const ind = drag.value?.indicator
|
|
1732
|
+
if (!ind || ind.type !== 'child' || ind.guideDepth !== guideDepth) return false
|
|
1733
|
+
return row.node.id === ind.targetId || isUnderAncestor(row.node, ind.targetId)
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
function onGripPointerDown(node: MindmapNode, e: PointerEvent) {
|
|
1737
|
+
if (e.button !== 0) return
|
|
1738
|
+
if (node === props.session.focusRootNode) return
|
|
1739
|
+
e.preventDefault()
|
|
1740
|
+
const grip = e.currentTarget as HTMLElement | null
|
|
1741
|
+
try {
|
|
1742
|
+
grip?.setPointerCapture?.(e.pointerId)
|
|
1743
|
+
} catch {
|
|
1744
|
+
/* ignore */
|
|
1745
|
+
}
|
|
1746
|
+
document.body.style.setProperty('user-select', 'none')
|
|
1747
|
+
document.body.style.setProperty('-webkit-user-select', 'none')
|
|
1748
|
+
window.getSelection()?.removeAllRanges()
|
|
1749
|
+
const pointerId = e.pointerId
|
|
1750
|
+
// 阈值前不写 drag ref,避免重渲染拆掉 grip 上的 capture/监听
|
|
1751
|
+
const pending: DragState = {
|
|
1752
|
+
id: node.id,
|
|
1753
|
+
startY: e.clientY,
|
|
1754
|
+
dragging: false,
|
|
1755
|
+
pointerX: e.clientX,
|
|
1756
|
+
pointerY: e.clientY,
|
|
1757
|
+
indicator: null,
|
|
1758
|
+
}
|
|
1759
|
+
let finished = false
|
|
1760
|
+
const startX = e.clientX
|
|
1761
|
+
const onMove = (ev: PointerEvent) => {
|
|
1762
|
+
if (ev.isTrusted && ev.pointerId !== pointerId) return
|
|
1763
|
+
pending.pointerX = ev.clientX
|
|
1764
|
+
pending.pointerY = ev.clientY
|
|
1765
|
+
const moved =
|
|
1766
|
+
pending.dragging ||
|
|
1767
|
+
Math.abs(ev.clientY - pending.startY) >= 5 ||
|
|
1768
|
+
Math.abs(ev.clientX - startX) >= 5
|
|
1769
|
+
if (!moved) return
|
|
1770
|
+
if (!pending.dragging) {
|
|
1771
|
+
pending.dragging = true
|
|
1772
|
+
suppressRowClick = true
|
|
1773
|
+
window.getSelection()?.removeAllRanges()
|
|
1774
|
+
}
|
|
1775
|
+
pending.indicator = calcDropIndicator(ev.clientX, ev.clientY, pending.id)
|
|
1776
|
+
drag.value = { ...pending }
|
|
1777
|
+
}
|
|
1778
|
+
const onUp = (ev: PointerEvent) => {
|
|
1779
|
+
if (ev.isTrusted && ev.pointerId !== pointerId) return
|
|
1780
|
+
if (finished) return
|
|
1781
|
+
finished = true
|
|
1782
|
+
document.removeEventListener('pointermove', onMove)
|
|
1783
|
+
document.removeEventListener('pointerup', onUp)
|
|
1784
|
+
document.removeEventListener('pointercancel', onUp)
|
|
1785
|
+
try {
|
|
1786
|
+
if (grip?.hasPointerCapture?.(pointerId)) grip.releasePointerCapture(pointerId)
|
|
1787
|
+
} catch {
|
|
1788
|
+
/* ignore */
|
|
1789
|
+
}
|
|
1790
|
+
onDragUp()
|
|
1791
|
+
}
|
|
1792
|
+
// 只挂 document:setPointerCapture 后事件仍冒泡到 document;避免 grip 重渲染丢监听
|
|
1793
|
+
document.addEventListener('pointermove', onMove)
|
|
1794
|
+
document.addEventListener('pointerup', onUp)
|
|
1795
|
+
document.addEventListener('pointercancel', onUp)
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
/** 用 spacer 可视坐标命中行,避免 padding/标题 margin 造成的偏移 */
|
|
1799
|
+
function rowIndexAt(clientY: number): number {
|
|
1800
|
+
const el = containerRef.value
|
|
1801
|
+
const spacer = el?.querySelector('.outline-spacer') as HTMLElement | null
|
|
1802
|
+
if (!el || !spacer) return -1
|
|
1803
|
+
const y = clientY - spacer.getBoundingClientRect().top
|
|
1804
|
+
const list = rows.value
|
|
1805
|
+
if (list.length === 0) return -1
|
|
1806
|
+
if (y < 0) return 0
|
|
1807
|
+
for (let i = 0; i < list.length; i++) {
|
|
1808
|
+
const row = list[i]
|
|
1809
|
+
if (y < row.top + row.height) return i
|
|
1810
|
+
}
|
|
1811
|
+
return list.length - 1
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
function descendantCount(node: MindmapNode): number {
|
|
1815
|
+
return node.children.reduce((total, child) => total + 1 + descendantCount(child), 0)
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
/** 幕布式落点:下半区锚定当前行,偏右/有子则收为子节点,否则插到同级之后(可左移升层) */
|
|
1819
|
+
function calcDropIndicator(clientX: number, clientY: number, dragId: string): DropIndicator | null {
|
|
1820
|
+
const el = containerRef.value
|
|
1821
|
+
const spacer = el?.querySelector('.outline-spacer') as HTMLElement | null
|
|
1822
|
+
if (!el || !spacer) return null
|
|
1823
|
+
const list = rows.value
|
|
1824
|
+
if (list.length === 0) return null
|
|
1825
|
+
|
|
1826
|
+
const idx = rowIndexAt(clientY)
|
|
1827
|
+
if (idx < 0) return null
|
|
1828
|
+
const hit = list[idx]
|
|
1829
|
+
const hitEl = el.querySelector(`.outline-row[data-node-id="${hit.node.id}"]`) as HTMLElement | null
|
|
1830
|
+
if (!hitEl) return null
|
|
1831
|
+
const hitRect = hitEl.getBoundingClientRect()
|
|
1832
|
+
const midY = (hitRect.top + hitRect.bottom) / 2
|
|
1833
|
+
|
|
1834
|
+
// 锚定节点:指针在行下半 → 本行;上半 → 上一行(幕布 _calcDropOperation)
|
|
1835
|
+
let anchorIdx = idx
|
|
1836
|
+
if (clientY <= midY) {
|
|
1837
|
+
if (idx === 0) {
|
|
1838
|
+
// 插到列表最前:作为聚焦根的第一个子节点之前 → before 首行
|
|
1839
|
+
if (hit.node.id === dragId || isUnderAncestor(hit.node, dragId)) return null
|
|
1840
|
+
return lineIndicator('before', hit, 0, spacer)
|
|
1841
|
+
}
|
|
1842
|
+
anchorIdx = idx - 1
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
// 落在被拖节点或其子树上时,改锚定到最近的合法邻居
|
|
1846
|
+
while (
|
|
1847
|
+
anchorIdx >= 0 &&
|
|
1848
|
+
(list[anchorIdx].node.id === dragId || isUnderAncestor(list[anchorIdx].node, dragId))
|
|
1849
|
+
) {
|
|
1850
|
+
anchorIdx -= 1
|
|
1851
|
+
}
|
|
1852
|
+
if (anchorIdx < 0) {
|
|
1853
|
+
const first = list.find((r) => r.node.id !== dragId && !isUnderAncestor(r.node, dragId))
|
|
1854
|
+
if (!first) return null
|
|
1855
|
+
return lineIndicator('before', first, first.depth, spacer)
|
|
1856
|
+
}
|
|
1857
|
+
const anchor = list[anchorIdx]
|
|
1858
|
+
|
|
1859
|
+
const anchorEl = el.querySelector(`.outline-row[data-node-id="${anchor.node.id}"]`) as HTMLElement | null
|
|
1860
|
+
const anchorRect = (anchorEl ?? hitEl).getBoundingClientRect()
|
|
1861
|
+
const indentEdge = anchorRect.left + anchor.depth * INDENT
|
|
1862
|
+
const hasVisibleKids = anchor.node.children.some((c) => c.id !== dragId) && !anchor.node.collapsed
|
|
1863
|
+
// 偏右超过一层缩进,或目标已有展开子节点 → 收为第一个子节点(幕布口径)
|
|
1864
|
+
if ((hasVisibleKids || clientX > indentEdge + INDENT) && !anchor.node.collapsed) {
|
|
1865
|
+
return lineIndicator('child', anchor, anchor.depth + 1, spacer)
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
// 向左拖:按指针所在缩进层级逐级提升。不能只允许“末子”提升,
|
|
1869
|
+
// 否则有后续兄弟的节点永远无法拖回顶层。
|
|
1870
|
+
const resolved = resolveAfterDropLevel(
|
|
1871
|
+
anchor.node,
|
|
1872
|
+
props.session.focusRootNode,
|
|
1873
|
+
clientX,
|
|
1874
|
+
indentEdge,
|
|
1875
|
+
anchor.depth,
|
|
1876
|
+
INDENT,
|
|
1877
|
+
)
|
|
1878
|
+
const place = resolved.node
|
|
1879
|
+
const depth = resolved.depth
|
|
1880
|
+
if (place.id === dragId || isUnderAncestor(place, dragId)) return null
|
|
1881
|
+
|
|
1882
|
+
const placeRow = list.find((r) => r.node.id === place.id) ?? anchor
|
|
1883
|
+
// 横线画在指针锚定行附近(幕布画在 curNode);提交时插到 place 之后
|
|
1884
|
+
const visual = lineIndicator('after', anchor, depth, spacer)
|
|
1885
|
+
visual.targetId = placeRow.node.id
|
|
1886
|
+
return visual
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
function lineIndicator(
|
|
1890
|
+
type: 'before' | 'after' | 'child',
|
|
1891
|
+
row: Row,
|
|
1892
|
+
lineDepth: number,
|
|
1893
|
+
spacer: HTMLElement,
|
|
1894
|
+
): DropIndicator {
|
|
1895
|
+
const top =
|
|
1896
|
+
type === 'before'
|
|
1897
|
+
? row.top
|
|
1898
|
+
: type === 'after'
|
|
1899
|
+
? row.top + row.height
|
|
1900
|
+
: row.top + Math.min(row.textHeight, ROW_HEIGHT)
|
|
1901
|
+
const lineLeft = Math.max(0, COLLAPSE_LEAD + Math.max(0, lineDepth) * INDENT)
|
|
1902
|
+
const width = Math.max(120, spacer.clientWidth - lineLeft - 8)
|
|
1903
|
+
return {
|
|
1904
|
+
type,
|
|
1905
|
+
targetId: row.node.id,
|
|
1906
|
+
top,
|
|
1907
|
+
left: lineLeft,
|
|
1908
|
+
width,
|
|
1909
|
+
guideDepth: type === 'child' ? row.depth : null,
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
function onDragUp() {
|
|
1914
|
+
document.body.style.removeProperty('user-select')
|
|
1915
|
+
document.body.style.removeProperty('-webkit-user-select')
|
|
1916
|
+
window.getSelection()?.removeAllRanges()
|
|
1917
|
+
const d = drag.value
|
|
1918
|
+
drag.value = null
|
|
1919
|
+
setTimeout(() => (suppressRowClick = false), 0)
|
|
1920
|
+
if (!d?.dragging || !d.indicator) return
|
|
1921
|
+
const session = props.session
|
|
1922
|
+
const target = session.document.find(d.indicator.targetId)
|
|
1923
|
+
if (!target) return
|
|
1924
|
+
if (d.indicator.type === 'child') {
|
|
1925
|
+
session.moveNode(d.id, target.id, 0)
|
|
1926
|
+
} else if (target.parent) {
|
|
1927
|
+
const idx = target.parent.children.indexOf(target)
|
|
1928
|
+
session.moveNode(d.id, target.parent.id, d.indicator.type === 'before' ? idx : idx + 1)
|
|
1929
|
+
}
|
|
1930
|
+
session.select(d.id)
|
|
1931
|
+
}
|
|
1932
|
+
</script>
|
|
1933
|
+
|
|
1934
|
+
<template>
|
|
1935
|
+
<div
|
|
1936
|
+
ref="containerRef"
|
|
1937
|
+
class="outline-view"
|
|
1938
|
+
:class="{ 'is-dragging': !!drag?.dragging, 'is-range-selecting': rangeSelecting }"
|
|
1939
|
+
tabindex="0"
|
|
1940
|
+
@scroll="onScroll"
|
|
1941
|
+
@keydown="onKeydown"
|
|
1942
|
+
@copy="onContainerCopy"
|
|
1943
|
+
@cut="onContainerCut"
|
|
1944
|
+
>
|
|
1945
|
+
<div v-if="focusRoot" class="outline-title" :class="{ 'is-selected': focusRoot.id === selectedId, 'has-image': !!focusRoot.content.image }" :style="{ minHeight: `${titleHeight}px` }">
|
|
1946
|
+
<RichInlineEditor
|
|
1947
|
+
class="title-input"
|
|
1948
|
+
:class="{ 'is-view-mode': focusedId !== focusRoot.id }"
|
|
1949
|
+
:editor-id="focusRoot.id"
|
|
1950
|
+
:raw="focusRoot.content.image ? focusRoot.content.text : focusRoot.content.raw"
|
|
1951
|
+
:active="focusedId === focusRoot.id"
|
|
1952
|
+
:placeholder="focusRoot.content.image ? '图片描述' : '标题'"
|
|
1953
|
+
paste-mode="outline"
|
|
1954
|
+
:style="{ height: `${Math.max(36, titleHeight - imageBlockHeight(focusRoot))}px` }"
|
|
1955
|
+
@click="onInlineDisplayClick(focusRoot, $event)"
|
|
1956
|
+
@focus="onRowInputFocus(focusRoot, $event)"
|
|
1957
|
+
@blur="onRowInputBlur(focusRoot, $event)"
|
|
1958
|
+
@draft-change="onRowDraftChange(focusRoot, null, $event)"
|
|
1959
|
+
@commit="commitEditorPayload(focusRoot, $event)"
|
|
1960
|
+
@keydown="onTitleKeydown"
|
|
1961
|
+
@keyup="onTextSelection(focusRoot, $event)"
|
|
1962
|
+
@mouseup="onTextSelection(focusRoot, $event)"
|
|
1963
|
+
@select="onTextSelection(focusRoot, $event)"
|
|
1964
|
+
@copy="onCopy(focusRoot, $event)"
|
|
1965
|
+
@cut="onCut(focusRoot, $event)"
|
|
1966
|
+
@paste-image="onEditorPasteImage(focusRoot, $event)"
|
|
1967
|
+
@paste-multiline="onEditorPasteMultiline(focusRoot, $event)"
|
|
1968
|
+
@link-enter="onTitleLinkEnter"
|
|
1969
|
+
@link-leave="closeLinkPopoverSoon"
|
|
1970
|
+
@link-click="onInlineLinkClick"
|
|
1971
|
+
/>
|
|
1972
|
+
<div v-if="focusRoot.content.image" class="title-image">
|
|
1973
|
+
<div class="row-image-frame" :style="{ width: `${displayImageWidth(focusRoot)}px` }">
|
|
1974
|
+
<img
|
|
1975
|
+
class="row-image-img"
|
|
1976
|
+
:src="resolvedImageSrc(focusRoot.content.image.src)"
|
|
1977
|
+
:alt="focusRoot.content.image.alt"
|
|
1978
|
+
draggable="false"
|
|
1979
|
+
@load="onOutlineImageLoad(focusRoot.content.image.src, $event)"
|
|
1980
|
+
@click="onImageClick(focusRoot, $event)"
|
|
1981
|
+
/>
|
|
1982
|
+
<span
|
|
1983
|
+
class="row-image-handle"
|
|
1984
|
+
title="拖动调节宽度"
|
|
1985
|
+
@pointerdown="onImageResizePointerDown(focusRoot, $event)"
|
|
1986
|
+
/>
|
|
1987
|
+
</div>
|
|
1988
|
+
</div>
|
|
1989
|
+
</div>
|
|
1990
|
+
|
|
1991
|
+
<div class="outline-spacer" :style="{ height: `${totalListHeight}px` }">
|
|
1992
|
+
<div
|
|
1993
|
+
v-for="row in visibleRows"
|
|
1994
|
+
:key="row.node.id"
|
|
1995
|
+
class="outline-row"
|
|
1996
|
+
:data-node-id="row.node.id"
|
|
1997
|
+
:class="{
|
|
1998
|
+
'is-selected': selectedIds.has(row.node.id),
|
|
1999
|
+
'is-matched': matches.has(row.node.id),
|
|
2000
|
+
'is-hovered': hoveredId === row.node.id,
|
|
2001
|
+
'has-image': !!row.node.content.image,
|
|
2002
|
+
'is-drag-source': drag?.dragging && drag.id === row.node.id,
|
|
2003
|
+
}"
|
|
2004
|
+
:style="{ top: `${row.top}px`, height: `${row.height}px` }"
|
|
2005
|
+
@mouseenter="hoveredId = row.node.id"
|
|
2006
|
+
@mouseleave="hoveredId = null"
|
|
2007
|
+
@pointerdown.capture="onRowPointerDown(row, $event)"
|
|
2008
|
+
>
|
|
2009
|
+
<!-- 引导线与祖先圆点中心对齐(幕布 indent-item 口径) -->
|
|
2010
|
+
<span class="row-indents" :style="{ width: `${gutterWidth(row.depth)}px` }">
|
|
2011
|
+
<i
|
|
2012
|
+
v-for="i in row.depth"
|
|
2013
|
+
:key="i"
|
|
2014
|
+
class="indent-guide"
|
|
2015
|
+
:class="{ 'is-drop-highlight': isDropGuideHighlighted(row, i - 1) }"
|
|
2016
|
+
:style="{ left: `${(i - 1) * INDENT + BULLET_CENTER}px` }"
|
|
2017
|
+
/>
|
|
2018
|
+
<!-- 收为子节点时:在目标行自身圆点列补一条高亮层级线 -->
|
|
2019
|
+
<i
|
|
2020
|
+
v-if="drag?.indicator?.type === 'child' && drag.indicator.targetId === row.node.id"
|
|
2021
|
+
class="indent-guide is-drop-highlight is-drop-parent"
|
|
2022
|
+
:style="{ left: `${bulletCenterX(row.depth)}px` }"
|
|
2023
|
+
/>
|
|
2024
|
+
</span>
|
|
2025
|
+
|
|
2026
|
+
<div class="row-main">
|
|
2027
|
+
<span
|
|
2028
|
+
class="row-gutter"
|
|
2029
|
+
:style="{ width: `${gutterWidth(row.depth)}px` }"
|
|
2030
|
+
@pointerdown="onGripPointerDown(row.node, $event)"
|
|
2031
|
+
>
|
|
2032
|
+
<button
|
|
2033
|
+
v-if="row.node.children.length > 0"
|
|
2034
|
+
type="button"
|
|
2035
|
+
class="row-collapse"
|
|
2036
|
+
:class="{
|
|
2037
|
+
collapsed: row.node.collapsed,
|
|
2038
|
+
visible: hoveredId === row.node.id || row.node.collapsed,
|
|
2039
|
+
}"
|
|
2040
|
+
:style="{ left: `${row.depth > 0 ? (row.depth - 1) * INDENT + BULLET_CENTER - COLLAPSE_LEAD / 2 : 0}px` }"
|
|
2041
|
+
title="折叠/展开"
|
|
2042
|
+
@click="onArrowClick(row.node, $event)"
|
|
2043
|
+
@pointerdown.stop="onGripPointerDown(row.node, $event)"
|
|
2044
|
+
/>
|
|
2045
|
+
<span
|
|
2046
|
+
class="row-bullet"
|
|
2047
|
+
:class="{ 'has-children': row.node.children.length > 0, collapsed: row.node.collapsed }"
|
|
2048
|
+
:style="{ left: `${COLLAPSE_LEAD + row.depth * INDENT}px` }"
|
|
2049
|
+
title="点击进入主题"
|
|
2050
|
+
@click="onBulletClick(row.node, $event)"
|
|
2051
|
+
@pointerdown.stop="onGripPointerDown(row.node, $event)"
|
|
2052
|
+
>
|
|
2053
|
+
<i class="bullet-dot" />
|
|
2054
|
+
<span v-if="row.node.collapsed && row.node.children.length > 0" class="bullet-count">{{
|
|
2055
|
+
descendantCount(row.node)
|
|
2056
|
+
}}</span>
|
|
2057
|
+
</span>
|
|
2058
|
+
</span>
|
|
2059
|
+
|
|
2060
|
+
<button
|
|
2061
|
+
v-if="row.node.content.checked !== null"
|
|
2062
|
+
type="button"
|
|
2063
|
+
class="row-checkbox"
|
|
2064
|
+
:class="{ checked: row.node.content.checked }"
|
|
2065
|
+
role="checkbox"
|
|
2066
|
+
:aria-checked="row.node.content.checked"
|
|
2067
|
+
:title="row.node.content.checked ? '标记为未完成' : '标记为已完成'"
|
|
2068
|
+
@click="onCheckboxClick(row.node, $event)"
|
|
2069
|
+
>
|
|
2070
|
+
<svg v-if="row.node.content.checked" viewBox="0 0 16 16" aria-hidden="true"><path d="m3.5 8 3 3 6-6" /></svg>
|
|
2071
|
+
</button>
|
|
2072
|
+
|
|
2073
|
+
<RichInlineEditor
|
|
2074
|
+
class="row-input"
|
|
2075
|
+
:class="{
|
|
2076
|
+
'is-task-done': row.node.content.checked === true,
|
|
2077
|
+
'is-view-mode': focusedId !== row.node.id,
|
|
2078
|
+
}"
|
|
2079
|
+
:editor-id="row.node.id"
|
|
2080
|
+
:raw="row.node.content.image ? row.node.content.text : row.node.content.raw"
|
|
2081
|
+
:active="focusedId === row.node.id"
|
|
2082
|
+
:done="row.node.content.checked === true"
|
|
2083
|
+
:placeholder="row.node.content.image ? '图片描述' : undefined"
|
|
2084
|
+
paste-mode="outline"
|
|
2085
|
+
:style="{ height: `${row.textHeight - 8}px` }"
|
|
2086
|
+
@click="onInlineDisplayClick(row.node, $event)"
|
|
2087
|
+
@focus="onRowInputFocus(row.node, $event)"
|
|
2088
|
+
@blur="onRowInputBlur(row.node, $event)"
|
|
2089
|
+
@draft-change="onRowDraftChange(row.node, row.depth, $event)"
|
|
2090
|
+
@commit="commitEditorPayload(row.node, $event)"
|
|
2091
|
+
@keydown="onEditKeydown(row.node, $event)"
|
|
2092
|
+
@keyup="onTextSelection(row.node, $event)"
|
|
2093
|
+
@mouseup="onTextSelection(row.node, $event)"
|
|
2094
|
+
@select="onTextSelection(row.node, $event)"
|
|
2095
|
+
@copy="onCopy(row.node, $event)"
|
|
2096
|
+
@cut="onCut(row.node, $event)"
|
|
2097
|
+
@paste-image="onEditorPasteImage(row.node, $event)"
|
|
2098
|
+
@paste-multiline="onEditorPasteMultiline(row.node, $event)"
|
|
2099
|
+
@link-enter="(link, event) => onInlineLinkEnter(row.node, link, event)"
|
|
2100
|
+
@link-leave="closeLinkPopoverSoon"
|
|
2101
|
+
@link-click="onInlineLinkClick"
|
|
2102
|
+
/>
|
|
2103
|
+
</div>
|
|
2104
|
+
|
|
2105
|
+
<div
|
|
2106
|
+
v-if="row.node.content.image"
|
|
2107
|
+
class="row-image"
|
|
2108
|
+
:style="{ marginLeft: `${gutterWidth(row.depth)}px` }"
|
|
2109
|
+
>
|
|
2110
|
+
<div class="row-image-frame" :style="{ width: `${displayImageWidth(row.node)}px` }">
|
|
2111
|
+
<img
|
|
2112
|
+
class="row-image-img"
|
|
2113
|
+
:src="resolvedImageSrc(row.node.content.image.src)"
|
|
2114
|
+
:alt="row.node.content.image.alt"
|
|
2115
|
+
draggable="false"
|
|
2116
|
+
@load="onOutlineImageLoad(row.node.content.image.src, $event)"
|
|
2117
|
+
@click="onImageClick(row.node, $event)"
|
|
2118
|
+
/>
|
|
2119
|
+
<span
|
|
2120
|
+
class="row-image-handle"
|
|
2121
|
+
title="拖动调节宽度"
|
|
2122
|
+
@pointerdown="onImageResizePointerDown(row.node, $event)"
|
|
2123
|
+
/>
|
|
2124
|
+
</div>
|
|
2125
|
+
</div>
|
|
2126
|
+
</div>
|
|
2127
|
+
|
|
2128
|
+
<div
|
|
2129
|
+
v-if="drag?.dragging && drag.indicator"
|
|
2130
|
+
class="drop-line"
|
|
2131
|
+
:data-drop-type="drag.indicator.type"
|
|
2132
|
+
:data-drop-target="drag.indicator.targetId"
|
|
2133
|
+
:data-guide-depth="drag.indicator.guideDepth ?? ''"
|
|
2134
|
+
:style="{
|
|
2135
|
+
top: `${drag.indicator.top}px`,
|
|
2136
|
+
left: `${drag.indicator.left}px`,
|
|
2137
|
+
width: `${drag.indicator.width}px`,
|
|
2138
|
+
}"
|
|
2139
|
+
/>
|
|
2140
|
+
</div>
|
|
2141
|
+
|
|
2142
|
+
<div
|
|
2143
|
+
v-if="drag?.dragging"
|
|
2144
|
+
class="outline-drag-widget"
|
|
2145
|
+
:style="{ left: `${drag.pointerX}px`, top: `${drag.pointerY}px` }"
|
|
2146
|
+
>
|
|
2147
|
+
<i class="bullet-dot" />
|
|
2148
|
+
</div>
|
|
2149
|
+
|
|
2150
|
+
<input ref="imagePickerRef" type="file" accept="image/*" hidden @change="onSelectionImagePicked" />
|
|
2151
|
+
|
|
2152
|
+
<SelectionToolbar
|
|
2153
|
+
v-if="textSelection && !linkEditor"
|
|
2154
|
+
mode="text"
|
|
2155
|
+
:position="textSelection.position"
|
|
2156
|
+
:active-formats="selectedTextFormats"
|
|
2157
|
+
@format="applyTextFormat"
|
|
2158
|
+
@task="toggleSelectionTask"
|
|
2159
|
+
@image="requestSelectionImage"
|
|
2160
|
+
@link="openSelectionLinkEditor"
|
|
2161
|
+
@clear="clearTextFormat"
|
|
2162
|
+
@delete="removeToolbarSelection"
|
|
2163
|
+
/>
|
|
2164
|
+
<SelectionToolbar
|
|
2165
|
+
v-else-if="multiSelectionPosition && !linkEditor"
|
|
2166
|
+
mode="nodes"
|
|
2167
|
+
:position="multiSelectionPosition"
|
|
2168
|
+
@format="applyNodeFormat"
|
|
2169
|
+
@task="toggleSelectionTask"
|
|
2170
|
+
@copy="copySelectedFromToolbar"
|
|
2171
|
+
@clear="clearNodeFormats"
|
|
2172
|
+
@delete="removeToolbarSelection"
|
|
2173
|
+
/>
|
|
2174
|
+
<LinkPopover
|
|
2175
|
+
v-if="linkEditor"
|
|
2176
|
+
ref="linkPopoverRef"
|
|
2177
|
+
:url="linkEditor.url"
|
|
2178
|
+
:position="linkEditor.position"
|
|
2179
|
+
:start-editing="linkEditor.mode === 'selection'"
|
|
2180
|
+
@save="saveLink"
|
|
2181
|
+
@remove="removeLink"
|
|
2182
|
+
@keep="keepLinkPopover"
|
|
2183
|
+
@leave="closeLinkPopoverSoon"
|
|
2184
|
+
@close="linkEditor = null"
|
|
2185
|
+
/>
|
|
2186
|
+
</div>
|
|
2187
|
+
</template>
|
|
2188
|
+
|
|
2189
|
+
<style scoped>
|
|
2190
|
+
.outline-view {
|
|
2191
|
+
position: relative;
|
|
2192
|
+
height: 100%;
|
|
2193
|
+
overflow: auto;
|
|
2194
|
+
outline: none;
|
|
2195
|
+
background: var(--mm-panel-bg);
|
|
2196
|
+
padding: 12px 24px 48px;
|
|
2197
|
+
}
|
|
2198
|
+
.outline-view.is-dragging,
|
|
2199
|
+
.outline-view.is-dragging *,
|
|
2200
|
+
.outline-view.is-range-selecting,
|
|
2201
|
+
.outline-view.is-range-selecting * {
|
|
2202
|
+
user-select: none !important;
|
|
2203
|
+
-webkit-user-select: none !important;
|
|
2204
|
+
caret-color: transparent;
|
|
2205
|
+
}
|
|
2206
|
+
.outline-title {
|
|
2207
|
+
position: relative;
|
|
2208
|
+
min-height: 56px;
|
|
2209
|
+
display: flex;
|
|
2210
|
+
flex-direction: column;
|
|
2211
|
+
align-items: stretch;
|
|
2212
|
+
justify-content: flex-start;
|
|
2213
|
+
margin-bottom: 8px;
|
|
2214
|
+
gap: 8px;
|
|
2215
|
+
}
|
|
2216
|
+
.outline-title.has-image {
|
|
2217
|
+
min-height: auto;
|
|
2218
|
+
}
|
|
2219
|
+
.title-input {
|
|
2220
|
+
width: 100%;
|
|
2221
|
+
border: none;
|
|
2222
|
+
outline: none;
|
|
2223
|
+
background: transparent;
|
|
2224
|
+
color: var(--mm-text);
|
|
2225
|
+
font-size: 28px;
|
|
2226
|
+
font-weight: 600;
|
|
2227
|
+
line-height: 1.3;
|
|
2228
|
+
padding: 4px 0;
|
|
2229
|
+
font-family: inherit;
|
|
2230
|
+
resize: none;
|
|
2231
|
+
overflow: hidden;
|
|
2232
|
+
white-space: pre-wrap;
|
|
2233
|
+
word-break: break-word;
|
|
2234
|
+
}
|
|
2235
|
+
.title-input::placeholder {
|
|
2236
|
+
color: var(--mm-text-dim);
|
|
2237
|
+
font-weight: 500;
|
|
2238
|
+
}
|
|
2239
|
+
.title-input.is-view-mode { cursor: text; }
|
|
2240
|
+
.title-image {
|
|
2241
|
+
flex: none;
|
|
2242
|
+
padding: 0 0 8px;
|
|
2243
|
+
}
|
|
2244
|
+
.outline-spacer {
|
|
2245
|
+
position: relative;
|
|
2246
|
+
}
|
|
2247
|
+
.outline-row {
|
|
2248
|
+
position: absolute;
|
|
2249
|
+
left: 0;
|
|
2250
|
+
right: 0;
|
|
2251
|
+
display: flex;
|
|
2252
|
+
flex-direction: column;
|
|
2253
|
+
justify-content: flex-start;
|
|
2254
|
+
gap: 0;
|
|
2255
|
+
padding-right: 12px;
|
|
2256
|
+
font-size: 15px;
|
|
2257
|
+
color: var(--mm-text);
|
|
2258
|
+
overflow: hidden;
|
|
2259
|
+
}
|
|
2260
|
+
.row-main {
|
|
2261
|
+
display: flex;
|
|
2262
|
+
align-items: flex-start;
|
|
2263
|
+
gap: 2px;
|
|
2264
|
+
min-height: 32px;
|
|
2265
|
+
flex: none;
|
|
2266
|
+
}
|
|
2267
|
+
.row-image {
|
|
2268
|
+
flex: none;
|
|
2269
|
+
padding: 0 0 8px;
|
|
2270
|
+
}
|
|
2271
|
+
.row-image-frame {
|
|
2272
|
+
position: relative;
|
|
2273
|
+
max-width: 100%;
|
|
2274
|
+
line-height: 0;
|
|
2275
|
+
border-radius: 6px;
|
|
2276
|
+
overflow: hidden;
|
|
2277
|
+
background: color-mix(in srgb, var(--mm-text-dim) 12%, transparent);
|
|
2278
|
+
box-shadow: 0 0 0 1px color-mix(in srgb, var(--mm-text-dim) 25%, transparent);
|
|
2279
|
+
}
|
|
2280
|
+
.row-image-img {
|
|
2281
|
+
display: block;
|
|
2282
|
+
width: 100%;
|
|
2283
|
+
height: auto;
|
|
2284
|
+
max-height: 480px;
|
|
2285
|
+
object-fit: contain;
|
|
2286
|
+
object-position: left top;
|
|
2287
|
+
cursor: zoom-in;
|
|
2288
|
+
user-select: none;
|
|
2289
|
+
}
|
|
2290
|
+
.row-image-handle {
|
|
2291
|
+
position: absolute;
|
|
2292
|
+
right: 0;
|
|
2293
|
+
bottom: 0;
|
|
2294
|
+
width: 14px;
|
|
2295
|
+
height: 14px;
|
|
2296
|
+
cursor: ew-resize;
|
|
2297
|
+
background: linear-gradient(135deg, transparent 50%, var(--mm-accent) 50%);
|
|
2298
|
+
opacity: 0.85;
|
|
2299
|
+
}
|
|
2300
|
+
.row-image-frame:hover .row-image-handle {
|
|
2301
|
+
opacity: 1;
|
|
2302
|
+
}
|
|
2303
|
+
.outline-row.is-selected {
|
|
2304
|
+
background: var(--mm-selected-bg);
|
|
2305
|
+
border-radius: 4px;
|
|
2306
|
+
}
|
|
2307
|
+
.outline-row:hover:not(.is-selected) {
|
|
2308
|
+
background: transparent;
|
|
2309
|
+
}
|
|
2310
|
+
.outline-row.is-matched .row-input {
|
|
2311
|
+
background: rgb(255 213 79 / 0.35);
|
|
2312
|
+
border-radius: 3px;
|
|
2313
|
+
}
|
|
2314
|
+
.row-indents {
|
|
2315
|
+
position: absolute;
|
|
2316
|
+
left: 0;
|
|
2317
|
+
top: 0;
|
|
2318
|
+
bottom: 0;
|
|
2319
|
+
z-index: 0;
|
|
2320
|
+
pointer-events: none;
|
|
2321
|
+
}
|
|
2322
|
+
.row-gutter {
|
|
2323
|
+
position: relative;
|
|
2324
|
+
flex: none;
|
|
2325
|
+
height: 32px;
|
|
2326
|
+
cursor: grab;
|
|
2327
|
+
}
|
|
2328
|
+
.indent-guide {
|
|
2329
|
+
position: absolute;
|
|
2330
|
+
top: 0;
|
|
2331
|
+
bottom: 0;
|
|
2332
|
+
width: 0;
|
|
2333
|
+
border-left: 1px solid color-mix(in srgb, var(--mm-text-dim) 70%, transparent);
|
|
2334
|
+
pointer-events: none;
|
|
2335
|
+
}
|
|
2336
|
+
.indent-guide.is-drop-highlight {
|
|
2337
|
+
border-left-width: 2px;
|
|
2338
|
+
border-left-color: var(--mm-accent);
|
|
2339
|
+
}
|
|
2340
|
+
.outline-row.is-drag-source {
|
|
2341
|
+
opacity: 0.45;
|
|
2342
|
+
}
|
|
2343
|
+
.outline-row.is-drag-source .row-input {
|
|
2344
|
+
pointer-events: none;
|
|
2345
|
+
}
|
|
2346
|
+
.drop-line {
|
|
2347
|
+
position: absolute;
|
|
2348
|
+
height: 2px;
|
|
2349
|
+
border-radius: 1px;
|
|
2350
|
+
background: var(--mm-accent);
|
|
2351
|
+
pointer-events: none;
|
|
2352
|
+
z-index: 10;
|
|
2353
|
+
box-shadow: 0 0 0 1px color-mix(in srgb, var(--mm-accent) 35%, transparent);
|
|
2354
|
+
}
|
|
2355
|
+
.outline-drag-widget {
|
|
2356
|
+
position: fixed;
|
|
2357
|
+
z-index: 40;
|
|
2358
|
+
width: 22px;
|
|
2359
|
+
height: 22px;
|
|
2360
|
+
margin: -11px 0 0 -11px;
|
|
2361
|
+
display: flex;
|
|
2362
|
+
align-items: center;
|
|
2363
|
+
justify-content: center;
|
|
2364
|
+
pointer-events: none;
|
|
2365
|
+
border-radius: 4px;
|
|
2366
|
+
background: color-mix(in srgb, var(--mm-panel-bg) 85%, var(--mm-accent));
|
|
2367
|
+
box-shadow: 0 2px 8px rgb(0 0 0 / 0.35);
|
|
2368
|
+
}
|
|
2369
|
+
.outline-drag-widget .bullet-dot {
|
|
2370
|
+
width: 6px;
|
|
2371
|
+
height: 6px;
|
|
2372
|
+
border-radius: 50%;
|
|
2373
|
+
background: var(--mm-accent);
|
|
2374
|
+
}
|
|
2375
|
+
.row-collapse {
|
|
2376
|
+
position: absolute;
|
|
2377
|
+
top: 9px;
|
|
2378
|
+
width: 18px;
|
|
2379
|
+
height: 14px;
|
|
2380
|
+
padding: 0;
|
|
2381
|
+
border: none;
|
|
2382
|
+
background: transparent;
|
|
2383
|
+
cursor: pointer;
|
|
2384
|
+
/* Always show for parents — hover-only left an empty gutter that looked
|
|
2385
|
+
like a missing drag handle (dots stay on the bullet column). */
|
|
2386
|
+
opacity: 1;
|
|
2387
|
+
z-index: 1;
|
|
2388
|
+
}
|
|
2389
|
+
.row-collapse.visible,
|
|
2390
|
+
.outline-row:hover .row-collapse {
|
|
2391
|
+
opacity: 1;
|
|
2392
|
+
}
|
|
2393
|
+
.row-collapse::before {
|
|
2394
|
+
content: '';
|
|
2395
|
+
position: absolute;
|
|
2396
|
+
left: 5px;
|
|
2397
|
+
top: 3px;
|
|
2398
|
+
border-style: solid;
|
|
2399
|
+
border-width: 4px 0 4px 6px;
|
|
2400
|
+
border-color: transparent transparent transparent var(--mm-text-dim);
|
|
2401
|
+
transform: rotate(90deg);
|
|
2402
|
+
transform-origin: 3px 4px;
|
|
2403
|
+
transition: transform 0.12s;
|
|
2404
|
+
}
|
|
2405
|
+
.row-collapse.collapsed::before {
|
|
2406
|
+
transform: rotate(0deg);
|
|
2407
|
+
}
|
|
2408
|
+
.row-bullet {
|
|
2409
|
+
position: absolute;
|
|
2410
|
+
top: 5px;
|
|
2411
|
+
display: inline-flex;
|
|
2412
|
+
align-items: center;
|
|
2413
|
+
justify-content: center;
|
|
2414
|
+
gap: 2px;
|
|
2415
|
+
width: 22px;
|
|
2416
|
+
height: 22px;
|
|
2417
|
+
border-radius: 4px;
|
|
2418
|
+
cursor: pointer;
|
|
2419
|
+
color: var(--mm-text-dim);
|
|
2420
|
+
font-size: 11px;
|
|
2421
|
+
z-index: 1;
|
|
2422
|
+
}
|
|
2423
|
+
.row-bullet:hover {
|
|
2424
|
+
background: color-mix(in srgb, var(--mm-text-dim) 22%, transparent);
|
|
2425
|
+
}
|
|
2426
|
+
.bullet-dot {
|
|
2427
|
+
display: block;
|
|
2428
|
+
flex: none;
|
|
2429
|
+
width: 6px;
|
|
2430
|
+
height: 6px;
|
|
2431
|
+
border-radius: 50%;
|
|
2432
|
+
background: var(--mm-text-dim, #9aa0a6);
|
|
2433
|
+
}
|
|
2434
|
+
.row-bullet.has-children .bullet-dot {
|
|
2435
|
+
background: var(--mm-text, #e8eaed);
|
|
2436
|
+
}
|
|
2437
|
+
.bullet-count {
|
|
2438
|
+
font-size: 10px;
|
|
2439
|
+
color: var(--mm-text-dim);
|
|
2440
|
+
line-height: 1;
|
|
2441
|
+
}
|
|
2442
|
+
.row-checkbox {
|
|
2443
|
+
display: inline-flex;
|
|
2444
|
+
width: 17px;
|
|
2445
|
+
height: 17px;
|
|
2446
|
+
flex: none;
|
|
2447
|
+
align-items: center;
|
|
2448
|
+
justify-content: center;
|
|
2449
|
+
margin: 7px 3px 0 0;
|
|
2450
|
+
padding: 0;
|
|
2451
|
+
border: 1.5px solid color-mix(in srgb, var(--mm-text-dim) 86%, transparent);
|
|
2452
|
+
border-radius: 4px;
|
|
2453
|
+
outline: 0;
|
|
2454
|
+
background: transparent;
|
|
2455
|
+
cursor: pointer;
|
|
2456
|
+
color: var(--mm-text-dim);
|
|
2457
|
+
transition: border-color .12s, background .12s, box-shadow .12s;
|
|
2458
|
+
}
|
|
2459
|
+
.row-checkbox.checked {
|
|
2460
|
+
border-color: var(--mm-accent);
|
|
2461
|
+
background: var(--mm-accent);
|
|
2462
|
+
color: white;
|
|
2463
|
+
}
|
|
2464
|
+
.row-checkbox:hover { border-color: var(--mm-accent); }
|
|
2465
|
+
.row-checkbox:focus-visible { box-shadow: 0 0 0 2px color-mix(in srgb, var(--mm-accent) 28%, transparent); }
|
|
2466
|
+
.row-checkbox svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
|
2467
|
+
.row-input {
|
|
2468
|
+
flex: 1;
|
|
2469
|
+
min-width: 60px;
|
|
2470
|
+
border: none;
|
|
2471
|
+
outline: none;
|
|
2472
|
+
background: transparent;
|
|
2473
|
+
font-size: inherit;
|
|
2474
|
+
font-family: inherit;
|
|
2475
|
+
color: inherit;
|
|
2476
|
+
padding: 4px 2px;
|
|
2477
|
+
border-radius: 0;
|
|
2478
|
+
caret-color: var(--mm-text);
|
|
2479
|
+
resize: none;
|
|
2480
|
+
overflow: hidden;
|
|
2481
|
+
white-space: pre-wrap;
|
|
2482
|
+
word-break: break-word;
|
|
2483
|
+
line-height: 1.45;
|
|
2484
|
+
}
|
|
2485
|
+
.row-input:focus {
|
|
2486
|
+
background: transparent;
|
|
2487
|
+
box-shadow: none;
|
|
2488
|
+
}
|
|
2489
|
+
.row-input.is-view-mode { cursor: text; }
|
|
2490
|
+
.row-input.is-task-done {
|
|
2491
|
+
text-decoration: line-through;
|
|
2492
|
+
color: var(--mm-text-dim);
|
|
2493
|
+
}
|
|
2494
|
+
</style>
|