@tnotesjs/core 0.1.28 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +2 -1
  2. package/package.json +3 -1
  3. package/templates/sub-repo/.gitattributes +30 -0
  4. package/templates/sub-repo/.github/workflows/deploy.yml +87 -0
  5. package/templates/sub-repo/.vitepress/config.mts +12 -0
  6. package/templates/sub-repo/.vitepress/env.d.ts +10 -0
  7. package/templates/sub-repo/.vitepress/theme/index.ts +12 -0
  8. package/templates/sub-repo/.vscode/settings.json +330 -0
  9. package/templates/sub-repo/.vscode/tnotes.code-snippets +110 -0
  10. package/templates/sub-repo/public/favicon.ico +0 -0
  11. package/templates/sub-repo/public/logo.png +0 -0
  12. package/templates/sub-repo/tsconfig.json +45 -0
  13. package/vitepress/assets/icons/icon__setting_dark.svg +4 -0
  14. package/vitepress/assets/icons/icon__setting_light.svg +4 -0
  15. package/vitepress/assets/icons/index.ts +2 -2
  16. package/vitepress/client/localSearchIndexBridge.ts +24 -0
  17. package/vitepress/components/Discussions/Discussions.vue +1 -1
  18. package/vitepress/components/Footprints/Footprints.vue +90 -25
  19. package/vitepress/components/Layout/CustomSidebar.vue +489 -117
  20. package/vitepress/components/Layout/Layout.vue +5 -0
  21. package/vitepress/components/Layout/NavBarSettingsTrigger.vue +10 -2
  22. package/vitepress/components/Layout/SidebarItems.vue +517 -342
  23. package/vitepress/components/Layout/SidebarNavBefore.vue +1 -50
  24. package/vitepress/components/Layout/SidebarResizeHandle.vue +6 -23
  25. package/vitepress/components/Layout/composables/docLayoutLogic.ts +59 -0
  26. package/vitepress/components/Layout/composables/useDocLayout.test.ts +69 -0
  27. package/vitepress/components/Layout/composables/useDocLayout.ts +128 -0
  28. package/vitepress/components/Layout/composables/useSidebarDrag.ts +514 -0
  29. package/vitepress/components/Layout/composables/useSidebarLayout.ts +25 -8
  30. package/vitepress/components/Layout/composables/useVSCodeIntegration.ts +8 -3
  31. package/vitepress/components/Layout/sidebarCollapsedState.test.ts +183 -0
  32. package/vitepress/components/Layout/sidebarCollapsedState.ts +241 -0
  33. package/vitepress/components/Layout/sidebarDragLogic.test.ts +1146 -0
  34. package/vitepress/components/Layout/sidebarDragLogic.ts +1307 -0
  35. package/vitepress/components/Layout/sidebarHitTest.test.ts +572 -0
  36. package/vitepress/components/Layout/sidebarHitTest.ts +801 -0
  37. package/vitepress/components/Layout/sidebarItemRenderLogic.test.ts +53 -0
  38. package/vitepress/components/Layout/sidebarItemRenderLogic.ts +50 -0
  39. package/vitepress/components/MarkMap/MarkMap.vue +67 -10
  40. package/vitepress/components/Settings/Settings.vue +78 -74
  41. package/vitepress/components/SidebarCard/FolderTreeItems.vue +268 -0
  42. package/vitepress/components/SidebarCard/SidebarCard.vue +125 -274
  43. package/vitepress/components/constants.ts +18 -6
  44. package/vitepress/components/sidebar.data.ts +13 -11
  45. package/vitepress/components/sidebarTreeHelpers.test.ts +142 -0
  46. package/vitepress/components/sidebarTreeHelpers.ts +168 -0
  47. package/vitepress/components/utils/vscodePaths.test.ts +41 -0
  48. package/vitepress/components/utils/vscodePaths.ts +52 -0
  49. package/vitepress/config/index.ts +2 -0
  50. package/vitepress/plugins/localSearchIndexBuilder.ts +209 -0
  51. package/vitepress/plugins/localSearchReindex.test.ts +46 -0
  52. package/vitepress/plugins/localSearchReindexLogic.ts +50 -0
  53. package/vitepress/plugins/localSearchReindexPatch.test.ts +30 -0
  54. package/vitepress/plugins/localSearchReindexPatch.ts +37 -0
  55. package/vitepress/plugins/localSearchReindexPlugin.ts +318 -0
  56. package/vitepress/plugins/sidebarStructurePlugin.ts +209 -63
  57. package/vitepress/theme/index.ts +8 -0
  58. package/vitepress/theme/styles/base.scss +0 -8
  59. package/vitepress/theme/styles/components/markmap.scss +23 -2
  60. package/vitepress/theme/styles/components/swiper.scss +1 -1
  61. package/vitepress/theme/styles/doc-layout.scss +212 -0
  62. package/vitepress/theme/styles/index.scss +3 -0
  63. package/vitepress/theme/styles/layout.scss +60 -10
  64. package/dist/chunk-4KG6RXFS.js +0 -4637
  65. package/dist/chunk-AGSL3VZE.js +0 -193
  66. package/dist/cli/index.js +0 -683
  67. package/dist/index.js +0 -8
  68. package/dist/vitepress/config/index.js +0 -1178
  69. package/vitepress/assets/icons/icon__mindmap.svg +0 -1
  70. package/vitepress/assets/icons/icon__setting.svg +0 -1
  71. package/vitepress/components/SidebarCard/MindMapView.vue +0 -488
@@ -0,0 +1,1307 @@
1
+ /**
2
+ * vitepress/components/Layout/sidebarDragLogic.ts
3
+ *
4
+ * 侧边栏树形拖拽纯逻辑:flat 列表、落点投影、树移动。
5
+ */
6
+
7
+ import { computeSidebarNodeId } from '../../../utils/tocNodeId'
8
+
9
+ import type { DropIntent } from './sidebarHitTest'
10
+
11
+ export interface SidebarTreeItem {
12
+ text: string
13
+ link?: string
14
+ items?: SidebarTreeItem[]
15
+ collapsed?: boolean
16
+ folderPath?: string[]
17
+ tocLineIndex?: number
18
+ /** dev 拖拽 nodeId */
19
+ nodeId?: string
20
+ /** 拖拽预览占位槽(非真实 TOC 项) */
21
+ isDragPlaceholder?: boolean
22
+ /** 拖拽中:源项原位保留空间(语雀式) */
23
+ isDragSourceSlot?: boolean
24
+ }
25
+
26
+ export interface FlatSidebarRow {
27
+ kind: 'folder' | 'note'
28
+ noteIndex: string | null
29
+ folderPath: string[] | null
30
+ tocLineIndex: number | null
31
+ nodeId: string | null
32
+ depth: number
33
+ parentNoteIndex: string | null
34
+ parentFolderPath: string[] | null
35
+ hasChildren: boolean
36
+ collapsed: boolean
37
+ }
38
+
39
+ export type DropPlacement = 'before' | 'after' | 'inside'
40
+
41
+ export interface DropTarget {
42
+ targetNoteIndex?: string
43
+ targetFolderPath?: string[]
44
+ targetTocLineIndex?: number
45
+ placement: DropPlacement
46
+ projectedDepth: number
47
+ }
48
+
49
+ export type DropIndicatorMode = 'line' | 'inside' | 'tail-rail' | 'root-prepend'
50
+
51
+ export interface DropIndicatorMeta {
52
+ mode: DropIndicatorMode
53
+ depthLevels: number[]
54
+ activeDepth: number
55
+ }
56
+
57
+ export interface DropProjectionResult {
58
+ target: DropTarget
59
+ indicator: DropIndicatorMeta
60
+ }
61
+
62
+ export interface DropProjectionConfig {
63
+ maxDepth: number
64
+ indentSize: number
65
+ draggedSubtreeDepthSpan: number
66
+ }
67
+
68
+ export const SIDEBAR_ROW_BASE_PADDING = 6
69
+ export const SIDEBAR_ARROW_WIDTH = 20
70
+ /** 语雀式侧边栏固定行高(px) */
71
+ export const SIDEBAR_ROW_HEIGHT = 36
72
+ /** 语雀式侧边栏固定层级缩进(px) */
73
+ export const SIDEBAR_INDENT_SIZE = 28
74
+
75
+ export interface ReorderPayload {
76
+ dragType: 'note' | 'folder'
77
+ dragTocLineIndex: number
78
+ noteIndex?: string
79
+ targetType: 'group' | 'note'
80
+ targetNoteIndex?: string
81
+ targetFolderPath?: string[]
82
+ targetTocLineIndex?: number
83
+ placement: DropPlacement
84
+ }
85
+
86
+ export interface YuqueReorderPayload {
87
+ node_uuid: string
88
+ action: 'moveAfter' | 'prependChild'
89
+ /** 拖入文件夹时必填;拖到列表顶(prepend 到根容器)时省略,与语雀一致 */
90
+ target_uuid?: string
91
+ /** legacy fallback */
92
+ dragTocLineIndex?: number
93
+ placement?: DropPlacement
94
+ targetTocLineIndex?: number
95
+ }
96
+
97
+ export interface DragSource {
98
+ kind: 'note' | 'folder'
99
+ tocLineIndex: number
100
+ noteIndex?: string
101
+ }
102
+
103
+ export function extractNoteIndexFromLink(link?: string): string | null {
104
+ if (!link) return null
105
+ const match = link.match(/\/notes\/(\d{4})\./)
106
+ return match ? match[1] : null
107
+ }
108
+
109
+ export interface FlattenVisibleOptions {
110
+ /** 拖拽预览:将该 TOC 行子树在 flat 中视为折叠(仅显示一行) */
111
+ forceCollapsedTocLineIndex?: number | null
112
+ }
113
+
114
+ export function flattenVisibleSidebar(
115
+ items: SidebarTreeItem[],
116
+ depth = 0,
117
+ parentNoteIndex: string | null = null,
118
+ parentFolderPath: string[] | null = null,
119
+ options: FlattenVisibleOptions = {},
120
+ ): FlatSidebarRow[] {
121
+ const result: FlatSidebarRow[] = []
122
+ const forceCollapsed = options.forceCollapsedTocLineIndex
123
+
124
+ for (const item of items) {
125
+ if (item.isDragPlaceholder) continue
126
+
127
+ const noteIndex = extractNoteIndexFromLink(item.link)
128
+ const hasChildren = !!(item.items && item.items.length > 0)
129
+ const treatAsCollapsed =
130
+ !!item.collapsed ||
131
+ (forceCollapsed !== undefined &&
132
+ forceCollapsed !== null &&
133
+ item.tocLineIndex === forceCollapsed)
134
+
135
+ const isFolderLine =
136
+ !noteIndex && (hasChildren || !!(item.folderPath && item.folderPath.length > 0))
137
+
138
+ if (isFolderLine) {
139
+ const folderPath =
140
+ item.folderPath ??
141
+ [...(parentFolderPath ?? []), item.text.replace(/^[✅⏰]\s*/, '')]
142
+ result.push({
143
+ kind: 'folder',
144
+ noteIndex: null,
145
+ folderPath,
146
+ tocLineIndex: item.tocLineIndex ?? null,
147
+ nodeId: item.nodeId ?? computeSidebarNodeId(item),
148
+ depth,
149
+ parentNoteIndex,
150
+ parentFolderPath,
151
+ hasChildren,
152
+ collapsed: !!item.collapsed,
153
+ })
154
+
155
+ if (hasChildren && !treatAsCollapsed) {
156
+ result.push(
157
+ ...flattenVisibleSidebar(
158
+ item.items!,
159
+ depth + 1,
160
+ null,
161
+ folderPath,
162
+ options,
163
+ ),
164
+ )
165
+ }
166
+ continue
167
+ }
168
+
169
+ if (!noteIndex) continue
170
+
171
+ result.push({
172
+ kind: 'note',
173
+ noteIndex,
174
+ folderPath: null,
175
+ tocLineIndex: item.tocLineIndex ?? null,
176
+ nodeId: item.nodeId ?? computeSidebarNodeId(item),
177
+ depth,
178
+ parentNoteIndex,
179
+ parentFolderPath,
180
+ hasChildren,
181
+ collapsed: !!item.collapsed,
182
+ })
183
+
184
+ if (hasChildren && !treatAsCollapsed) {
185
+ result.push(
186
+ ...flattenVisibleSidebar(
187
+ item.items!,
188
+ depth + 1,
189
+ noteIndex,
190
+ parentFolderPath,
191
+ options,
192
+ ),
193
+ )
194
+ }
195
+ }
196
+
197
+ return result
198
+ }
199
+
200
+ export function getSubtreeDepthSpan(item: SidebarTreeItem): number {
201
+ if (!item.items?.length) return 0
202
+ return 1 + Math.max(...item.items.map(getSubtreeDepthSpan))
203
+ }
204
+
205
+ export function findTreeItem(
206
+ items: SidebarTreeItem[],
207
+ noteIndex: string,
208
+ ): SidebarTreeItem | null {
209
+ for (const item of items) {
210
+ if (extractNoteIndexFromLink(item.link) === noteIndex) return item
211
+ if (item.items?.length) {
212
+ const found = findTreeItem(item.items, noteIndex)
213
+ if (found) return found
214
+ }
215
+ }
216
+ return null
217
+ }
218
+
219
+ export function isDescendantInTree(
220
+ items: SidebarTreeItem[],
221
+ ancestorNoteIndex: string,
222
+ candidateNoteIndex: string,
223
+ ): boolean {
224
+ const ancestor = findTreeItem(items, ancestorNoteIndex)
225
+ if (!ancestor?.items?.length) return false
226
+
227
+ function walk(nodes: SidebarTreeItem[]): boolean {
228
+ for (const node of nodes) {
229
+ const id = extractNoteIndexFromLink(node.link)
230
+ if (id === candidateNoteIndex) return true
231
+ if (node.items?.length && walk(node.items)) return true
232
+ }
233
+ return false
234
+ }
235
+
236
+ return walk(ancestor.items)
237
+ }
238
+
239
+ export function isInvalidDrop(
240
+ tree: SidebarTreeItem[],
241
+ flat: FlatSidebarRow[],
242
+ dragSource: DragSource,
243
+ target: DropTarget,
244
+ config: DropProjectionConfig,
245
+ ): boolean {
246
+ if (
247
+ target.targetTocLineIndex !== undefined &&
248
+ target.targetTocLineIndex === dragSource.tocLineIndex
249
+ ) {
250
+ return true
251
+ }
252
+
253
+ const dragFlatIndex = flat.findIndex(
254
+ (row) => row.tocLineIndex === dragSource.tocLineIndex,
255
+ )
256
+ if (target.targetTocLineIndex !== undefined && dragFlatIndex >= 0) {
257
+ const targetFlatIndex = flat.findIndex(
258
+ (row) => row.tocLineIndex === target.targetTocLineIndex,
259
+ )
260
+ if (
261
+ targetFlatIndex >= 0 &&
262
+ isFlatRowInDragSubtree(flat, dragFlatIndex, targetFlatIndex)
263
+ ) {
264
+ return true
265
+ }
266
+ }
267
+
268
+ if (
269
+ dragSource.noteIndex &&
270
+ target.targetNoteIndex &&
271
+ dragSource.noteIndex === target.targetNoteIndex
272
+ ) {
273
+ return true
274
+ }
275
+ if (
276
+ dragSource.noteIndex &&
277
+ target.targetNoteIndex &&
278
+ isDescendantInTree(tree, dragSource.noteIndex, target.targetNoteIndex)
279
+ ) {
280
+ return true
281
+ }
282
+
283
+ if (config.maxDepth > 0 && Number.isFinite(config.maxDepth)) {
284
+ if (target.placement === 'inside') {
285
+ const deepestIndent =
286
+ target.projectedDepth + config.draggedSubtreeDepthSpan
287
+ if (deepestIndent >= config.maxDepth) return true
288
+ } else {
289
+ const maxRootDepth = config.maxDepth - 1 - config.draggedSubtreeDepthSpan
290
+ if (target.projectedDepth > maxRootDepth) return true
291
+ }
292
+ }
293
+
294
+ if (target.projectedDepth < 0) return true
295
+
296
+ return false
297
+ }
298
+
299
+ function isFlatRowInDragSubtree(
300
+ flat: FlatSidebarRow[],
301
+ dragFlatIndex: number,
302
+ candidateFlatIndex: number,
303
+ ): boolean {
304
+ if (candidateFlatIndex === dragFlatIndex) return true
305
+ const dragRow = flat[dragFlatIndex]
306
+ if (!dragRow || candidateFlatIndex < dragFlatIndex) return false
307
+
308
+ for (let i = dragFlatIndex + 1; i <= candidateFlatIndex; i++) {
309
+ if (flat[i].depth <= dragRow.depth) {
310
+ return false
311
+ }
312
+ }
313
+
314
+ return candidateFlatIndex > dragFlatIndex
315
+ }
316
+
317
+ function hasDepthLimit(config: DropProjectionConfig): boolean {
318
+ return config.maxDepth > 0 && Number.isFinite(config.maxDepth)
319
+ }
320
+
321
+ function getMaxRootDepth(
322
+ config: DropProjectionConfig,
323
+ overDepth: number,
324
+ ): number {
325
+ if (!hasDepthLimit(config)) return overDepth
326
+ return Math.max(
327
+ 0,
328
+ config.maxDepth - 1 - config.draggedSubtreeDepthSpan,
329
+ )
330
+ }
331
+
332
+ function clamp(value: number, min: number, max: number): number {
333
+ return Math.min(max, Math.max(min, value))
334
+ }
335
+
336
+ function findRowAtDepth(
337
+ flat: FlatSidebarRow[],
338
+ startIndex: number,
339
+ depth: number,
340
+ ): FlatSidebarRow | null {
341
+ for (let i = startIndex; i >= 0; i--) {
342
+ if (flat[i].depth === depth) return flat[i]
343
+ if (flat[i].depth < depth) break
344
+ }
345
+ return null
346
+ }
347
+
348
+ /** 是否为同级块末行 after 落点(语雀:仅此时展示上级导轨) */
349
+ export function isTailAfterDrop(
350
+ flat: FlatSidebarRow[],
351
+ overIndex: number,
352
+ verticalPlacement: 'before' | 'after',
353
+ dragNoteIndex?: string | null,
354
+ dragTocLineIndex?: number | null,
355
+ ): boolean {
356
+ if (verticalPlacement !== 'after') return false
357
+
358
+ const overRow = flat[overIndex]
359
+ if (!overRow) return false
360
+
361
+ const next = flat[overIndex + 1]
362
+ if (!next) return true
363
+
364
+ if (next.depth < overRow.depth) return true
365
+
366
+ if (
367
+ dragTocLineIndex !== undefined &&
368
+ dragTocLineIndex !== null &&
369
+ next.tocLineIndex === dragTocLineIndex
370
+ ) {
371
+ const dragRow = findFlatRowByTocLineIndex(flat, dragTocLineIndex)
372
+ if (
373
+ dragRow &&
374
+ dragRow.depth === overRow.depth &&
375
+ dragRow.parentNoteIndex === overRow.parentNoteIndex &&
376
+ folderPathsEqual(dragRow.parentFolderPath, overRow.parentFolderPath)
377
+ ) {
378
+ return !overRow.hasChildren
379
+ }
380
+ return (
381
+ overRow.depth > 0 &&
382
+ dragRow?.parentNoteIndex === overRow.parentNoteIndex &&
383
+ folderPathsEqual(dragRow.parentFolderPath, overRow.parentFolderPath)
384
+ )
385
+ }
386
+
387
+ if (dragNoteIndex && next.noteIndex === dragNoteIndex) {
388
+ const dragRow = findFlatRowByNoteIndex(flat, dragNoteIndex)
389
+ if (
390
+ dragRow &&
391
+ dragRow.depth === overRow.depth &&
392
+ dragRow.parentNoteIndex === overRow.parentNoteIndex
393
+ ) {
394
+ // 紧邻同级:hover 文件夹父行 → 不算 tail;hover 前一条子笔记 → 仍走 tail 导轨
395
+ return !overRow.hasChildren
396
+ }
397
+ return (
398
+ overRow.depth > 0 &&
399
+ dragRow?.parentNoteIndex === overRow.parentNoteIndex
400
+ )
401
+ }
402
+
403
+ return false
404
+ }
405
+
406
+ /** 拖拽项是否为 flat 列表中紧邻的下一行同级笔记 */
407
+ function isImmediateNextSibling(
408
+ flat: FlatSidebarRow[],
409
+ overIndex: number,
410
+ dragSource: DragSource,
411
+ ): boolean {
412
+ const overRow = flat[overIndex]
413
+ const next = flat[overIndex + 1]
414
+ if (!overRow || !next) return false
415
+
416
+ const dragRow = findFlatRowByTocLineIndex(flat, dragSource.tocLineIndex)
417
+ if (!dragRow || next.tocLineIndex !== dragSource.tocLineIndex) return false
418
+
419
+ return (
420
+ dragRow.depth === overRow.depth &&
421
+ dragRow.parentNoteIndex === overRow.parentNoteIndex &&
422
+ folderPathsEqual(dragRow.parentFolderPath, overRow.parentFolderPath)
423
+ )
424
+ }
425
+
426
+ function tryInsideFirstForExternalDrag(
427
+ tree: SidebarTreeItem[],
428
+ flat: FlatSidebarRow[],
429
+ dragSource: DragSource,
430
+ parentRow: FlatSidebarRow,
431
+ config: DropProjectionConfig,
432
+ ): DropProjectionResult | null {
433
+ const dragRow = findFlatRowByTocLineIndex(flat, dragSource.tocLineIndex)
434
+ if (!dragRow) return null
435
+
436
+ const isExternal = isDragExternalToTargetParent(flat, dragRow, parentRow)
437
+ if (!isExternal) return null
438
+
439
+ const target = buildInsideTarget(parentRow)
440
+ return finalizeProjection(
441
+ tree,
442
+ flat,
443
+ dragSource,
444
+ target,
445
+ buildInsideIndicator(target.projectedDepth),
446
+ config,
447
+ )
448
+ }
449
+
450
+ /** 语雀:外部拖入折叠父级整行 → prependChild(不区分 before/after 半区) */
451
+ function tryPrependInsideExternalCollapsedParent(
452
+ tree: SidebarTreeItem[],
453
+ flat: FlatSidebarRow[],
454
+ dragSource: DragSource,
455
+ parentRow: FlatSidebarRow,
456
+ config: DropProjectionConfig,
457
+ verticalPlacement: 'before' | 'after',
458
+ ): DropProjectionResult | null {
459
+ if (parentRow.kind !== 'folder' || !parentRow.collapsed) return null
460
+ if (verticalPlacement === 'before' && !parentRow.hasChildren) return null
461
+ return tryInsideFirstForExternalDrag(
462
+ tree,
463
+ flat,
464
+ dragSource,
465
+ parentRow,
466
+ config,
467
+ )
468
+ }
469
+
470
+ /** 语雀:外部笔记/分组拖入笔记行 → prependChild(整行有效,不要求右缩进) */
471
+ function tryPrependInsideExternalNoteRow(
472
+ tree: SidebarTreeItem[],
473
+ flat: FlatSidebarRow[],
474
+ dragSource: DragSource,
475
+ overIndex: number,
476
+ overRow: FlatSidebarRow,
477
+ verticalPlacement: 'before' | 'after',
478
+ config: DropProjectionConfig,
479
+ ): DropProjectionResult | null {
480
+ if (
481
+ !shouldPrependInsideExternalNoteRow(
482
+ flat,
483
+ dragSource,
484
+ overIndex,
485
+ overRow,
486
+ verticalPlacement,
487
+ )
488
+ ) {
489
+ return null
490
+ }
491
+
492
+ const dragRow = findFlatRowByTocLineIndex(flat, dragSource.tocLineIndex)
493
+ if (!dragRow) return null
494
+
495
+ const target = buildInsideTarget(overRow)
496
+ return finalizeProjection(
497
+ tree,
498
+ flat,
499
+ dragSource,
500
+ target,
501
+ buildInsideIndicator(target.projectedDepth),
502
+ config,
503
+ )
504
+ }
505
+
506
+ export function isDragExternalToTargetParent(
507
+ flat: FlatSidebarRow[],
508
+ dragRow: FlatSidebarRow,
509
+ parentRow: FlatSidebarRow,
510
+ ): boolean {
511
+ if (parentRow.kind === 'folder' && parentRow.folderPath) {
512
+ return !folderPathsEqual(dragRow.parentFolderPath, parentRow.folderPath)
513
+ }
514
+ if (parentRow.noteIndex) {
515
+ return dragRow.parentNoteIndex !== parentRow.noteIndex
516
+ }
517
+ return true
518
+ }
519
+
520
+ /** 是否应将外部拖拽整行判为拖入笔记行(prependChild / inside) */
521
+ export function shouldPrependInsideExternalNoteRow(
522
+ flat: FlatSidebarRow[],
523
+ dragSource: DragSource,
524
+ overIndex: number,
525
+ overRow: FlatSidebarRow,
526
+ verticalPlacement: 'before' | 'after',
527
+ ): boolean {
528
+ if (!overRow.noteIndex) return false
529
+
530
+ const dragIndex = flat.findIndex(
531
+ (row) => row.tocLineIndex === dragSource.tocLineIndex,
532
+ )
533
+ if (dragIndex < 0) return false
534
+
535
+ const dragRow = flat[dragIndex]
536
+ if (!isDragExternalToTargetParent(flat, dragRow, overRow)) return false
537
+
538
+ if (dragSource.kind === 'folder') return true
539
+
540
+ if (verticalPlacement === 'before') return false
541
+
542
+ const nextRow = flat[overIndex + 1]
543
+ if (nextRow?.tocLineIndex === dragSource.tocLineIndex) {
544
+ return overRow.depth === 0 && dragRow.depth === 0
545
+ }
546
+
547
+ if (
548
+ isTailAfterDrop(
549
+ flat,
550
+ overIndex,
551
+ verticalPlacement,
552
+ dragSource.noteIndex,
553
+ dragSource.tocLineIndex,
554
+ )
555
+ ) {
556
+ return false
557
+ }
558
+
559
+ return true
560
+ }
561
+
562
+ /** 由指针绝对 X 计算目标 depth(0..maxSelectableDepth) */
563
+ export function depthFromClientX(
564
+ clientX: number,
565
+ navLeft: number,
566
+ indentSize: number,
567
+ maxSelectableDepth: number,
568
+ ): number {
569
+ const base = navLeft + SIDEBAR_ROW_BASE_PADDING + SIDEBAR_ARROW_WIDTH
570
+ const raw = Math.round((clientX - base) / indentSize)
571
+ return clamp(raw, 0, maxSelectableDepth)
572
+ }
573
+
574
+ export function getTailDepthLevels(overDepth: number): number[] {
575
+ return Array.from({ length: overDepth + 1 }, (_, index) => index)
576
+ }
577
+
578
+ export function getDepthContentLeft(depth: number, indentSize: number): number {
579
+ return SIDEBAR_ROW_BASE_PADDING + depth * indentSize + SIDEBAR_ARROW_WIDTH
580
+ }
581
+
582
+ /** 尾行 after:在选定 depth 对应 ancestor 子树末尾插入 */
583
+ export function resolveTailDropTarget(
584
+ flat: FlatSidebarRow[],
585
+ overIndex: number,
586
+ targetDepth: number,
587
+ ): DropTarget {
588
+ const overRow = flat[overIndex]
589
+ const anchor =
590
+ targetDepth < overRow.depth
591
+ ? findRowAtDepth(flat, overIndex, targetDepth) ?? overRow
592
+ : overRow
593
+
594
+ return {
595
+ targetNoteIndex: anchor.noteIndex ?? undefined,
596
+ targetFolderPath:
597
+ anchor.kind === 'folder' ? anchor.folderPath ?? undefined : undefined,
598
+ targetTocLineIndex: anchor.tocLineIndex ?? undefined,
599
+ placement: 'after',
600
+ projectedDepth: targetDepth,
601
+ }
602
+ }
603
+
604
+ function findFlatRowByTocLineIndex(
605
+ flat: FlatSidebarRow[],
606
+ tocLineIndex: number,
607
+ ): FlatSidebarRow | undefined {
608
+ return flat.find((row) => row.tocLineIndex === tocLineIndex)
609
+ }
610
+
611
+ function findFlatRowByNoteIndex(
612
+ flat: FlatSidebarRow[],
613
+ noteIndex: string,
614
+ ): FlatSidebarRow | undefined {
615
+ return flat.find((row) => row.noteIndex === noteIndex)
616
+ }
617
+
618
+ function findFlatRowByFolderPath(
619
+ flat: FlatSidebarRow[],
620
+ folderPath: string[],
621
+ ): FlatSidebarRow | undefined {
622
+ const key = folderPath.join('/')
623
+ return flat.find(
624
+ (row) => row.kind === 'folder' && row.folderPath?.join('/') === key,
625
+ )
626
+ }
627
+
628
+ function folderPathsEqual(
629
+ left: string[] | null | undefined,
630
+ right: string[] | null | undefined,
631
+ ): boolean {
632
+ if (!left && !right) return true
633
+ if (!left || !right) return false
634
+ return left.join('/') === right.join('/')
635
+ }
636
+
637
+ /** 拖拽项不是目标父节点的直接子笔记(从外部拖入文件夹) */
638
+ export function isDragExternalToParent(
639
+ flat: FlatSidebarRow[],
640
+ dragNoteIndex: string,
641
+ parentNoteIndex: string,
642
+ ): boolean {
643
+ const dragRow = findFlatRowByNoteIndex(flat, dragNoteIndex)
644
+ if (!dragRow || dragNoteIndex === parentNoteIndex) return false
645
+ return dragRow.parentNoteIndex !== parentNoteIndex
646
+ }
647
+
648
+ function buildInsideTarget(parentRow: FlatSidebarRow): DropTarget {
649
+ const base = {
650
+ placement: 'inside' as const,
651
+ projectedDepth: parentRow.depth + 1,
652
+ targetTocLineIndex: parentRow.tocLineIndex ?? undefined,
653
+ }
654
+
655
+ if (parentRow.kind === 'folder' && parentRow.folderPath) {
656
+ return {
657
+ ...base,
658
+ targetFolderPath: parentRow.folderPath,
659
+ }
660
+ }
661
+
662
+ return {
663
+ ...base,
664
+ targetNoteIndex: parentRow.noteIndex ?? undefined,
665
+ }
666
+ }
667
+
668
+ function buildLineIndicator(depth: number): DropIndicatorMeta {
669
+ return {
670
+ mode: 'line',
671
+ depthLevels: [depth],
672
+ activeDepth: depth,
673
+ }
674
+ }
675
+
676
+ function buildInsideIndicator(depth: number): DropIndicatorMeta {
677
+ return {
678
+ mode: 'inside',
679
+ depthLevels: [depth],
680
+ activeDepth: depth,
681
+ }
682
+ }
683
+
684
+ function buildTailIndicator(overDepth: number, activeDepth: number): DropIndicatorMeta {
685
+ return {
686
+ mode: 'tail-rail',
687
+ depthLevels: getTailDepthLevels(overDepth),
688
+ activeDepth,
689
+ }
690
+ }
691
+
692
+ function finalizeProjection(
693
+ tree: SidebarTreeItem[],
694
+ flat: FlatSidebarRow[],
695
+ dragSource: DragSource,
696
+ target: DropTarget,
697
+ indicator: DropIndicatorMeta,
698
+ config: DropProjectionConfig,
699
+ ): DropProjectionResult | null {
700
+ if (isInvalidDrop(tree, flat, dragSource, target, config)) return null
701
+ return { target, indicator }
702
+ }
703
+
704
+ /**
705
+ * 根据 hover 行、垂直落点与指针位置计算 TOC 移动目标。
706
+ */
707
+ export function getDropProjection(
708
+ tree: SidebarTreeItem[],
709
+ flat: FlatSidebarRow[],
710
+ dragSource: DragSource,
711
+ overIndex: number,
712
+ verticalPlacement: 'before' | 'after',
713
+ config: DropProjectionConfig,
714
+ pointer: {
715
+ offsetX: number
716
+ clientX: number
717
+ navLeft: number
718
+ },
719
+ ): DropProjectionResult | null {
720
+ if (overIndex < 0 || overIndex >= flat.length) return null
721
+
722
+ const overRow = flat[overIndex]
723
+ const dragRow = findFlatRowByTocLineIndex(flat, dragSource.tocLineIndex)
724
+ if (!overRow || !dragRow) return null
725
+ if (overRow.tocLineIndex === dragSource.tocLineIndex) return null
726
+
727
+ const dragNoteIndex = dragSource.noteIndex ?? ''
728
+ const maxRootDepth = getMaxRootDepth(config, overRow.depth)
729
+
730
+ const collapsedInside = tryPrependInsideExternalCollapsedParent(
731
+ tree,
732
+ flat,
733
+ dragSource,
734
+ overRow,
735
+ config,
736
+ verticalPlacement,
737
+ )
738
+ if (collapsedInside) return collapsedInside
739
+
740
+ const insideExternalNote = tryPrependInsideExternalNoteRow(
741
+ tree,
742
+ flat,
743
+ dragSource,
744
+ overIndex,
745
+ overRow,
746
+ verticalPlacement,
747
+ config,
748
+ )
749
+ if (insideExternalNote) return insideExternalNote
750
+
751
+ if (
752
+ isTailAfterDrop(
753
+ flat,
754
+ overIndex,
755
+ verticalPlacement,
756
+ dragNoteIndex || null,
757
+ dragSource.tocLineIndex,
758
+ )
759
+ ) {
760
+ const pointerDepth = clamp(
761
+ depthFromClientX(
762
+ pointer.clientX,
763
+ pointer.navLeft,
764
+ config.indentSize,
765
+ overRow.depth,
766
+ ),
767
+ 0,
768
+ Math.min(overRow.depth, maxRootDepth),
769
+ )
770
+
771
+ const parentRow = overRow.parentNoteIndex
772
+ ? findFlatRowByNoteIndex(flat, overRow.parentNoteIndex)
773
+ : overRow.parentFolderPath
774
+ ? findFlatRowByFolderPath(flat, overRow.parentFolderPath)
775
+ : undefined
776
+ const nextRow = flat[overIndex + 1]
777
+ const draggingLastChildFromTail =
778
+ !!parentRow &&
779
+ !!nextRow &&
780
+ nextRow.tocLineIndex === dragSource.tocLineIndex &&
781
+ (parentRow.noteIndex
782
+ ? dragRow.parentNoteIndex === parentRow.noteIndex
783
+ : folderPathsEqual(dragRow.parentFolderPath, parentRow.folderPath))
784
+ const externalSameLevel =
785
+ !!parentRow &&
786
+ isDragExternalToTargetParent(flat, dragRow, parentRow) &&
787
+ dragRow.depth <= parentRow.depth
788
+
789
+ let targetDepth = pointerDepth
790
+ if (draggingLastChildFromTail && parentRow) {
791
+ targetDepth =
792
+ pointerDepth <= parentRow.depth ? parentRow.depth : pointerDepth
793
+ } else if (externalSameLevel && parentRow) {
794
+ targetDepth =
795
+ pointerDepth <= parentRow.depth ? parentRow.depth : overRow.depth
796
+ } else if (
797
+ pointerDepth === overRow.depth &&
798
+ (overRow.parentNoteIndex || overRow.parentFolderPath)
799
+ ) {
800
+ const parentForInside = overRow.parentNoteIndex
801
+ ? findFlatRowByNoteIndex(flat, overRow.parentNoteIndex)
802
+ : overRow.parentFolderPath
803
+ ? findFlatRowByFolderPath(flat, overRow.parentFolderPath)
804
+ : undefined
805
+ if (parentForInside) {
806
+ const insideFirst = tryInsideFirstForExternalDrag(
807
+ tree,
808
+ flat,
809
+ dragSource,
810
+ parentForInside,
811
+ config,
812
+ )
813
+ if (insideFirst) {
814
+ return insideFirst
815
+ }
816
+ }
817
+ }
818
+
819
+ const target = resolveTailDropTarget(flat, overIndex, targetDepth)
820
+ return finalizeProjection(
821
+ tree,
822
+ flat,
823
+ dragSource,
824
+ target,
825
+ buildTailIndicator(overRow.depth, targetDepth),
826
+ config,
827
+ )
828
+ }
829
+
830
+ if (verticalPlacement === 'before') {
831
+ const target: DropTarget =
832
+ overRow.kind === 'folder' && overRow.folderPath
833
+ ? {
834
+ targetFolderPath: overRow.folderPath,
835
+ targetTocLineIndex: overRow.tocLineIndex ?? undefined,
836
+ placement: 'before',
837
+ projectedDepth: overRow.depth,
838
+ }
839
+ : {
840
+ targetNoteIndex: overRow.noteIndex ?? undefined,
841
+ targetTocLineIndex: overRow.tocLineIndex ?? undefined,
842
+ placement: 'before',
843
+ projectedDepth: overRow.depth,
844
+ }
845
+ return finalizeProjection(
846
+ tree,
847
+ flat,
848
+ dragSource,
849
+ target,
850
+ buildLineIndicator(overRow.depth),
851
+ config,
852
+ )
853
+ }
854
+
855
+ const depthDelta = Math.round(pointer.offsetX / config.indentSize)
856
+ const canNestInside =
857
+ depthDelta >= 1 &&
858
+ overRow.depth + 1 <= maxRootDepth &&
859
+ (!hasDepthLimit(config) || overRow.depth + 1 < config.maxDepth)
860
+
861
+ if (canNestInside) {
862
+ const target = buildInsideTarget(overRow)
863
+ return finalizeProjection(
864
+ tree,
865
+ flat,
866
+ dragSource,
867
+ target,
868
+ buildInsideIndicator(target.projectedDepth),
869
+ config,
870
+ )
871
+ }
872
+
873
+ if (
874
+ overRow.hasChildren &&
875
+ depthDelta < 1 &&
876
+ isImmediateNextSibling(flat, overIndex, dragSource)
877
+ ) {
878
+ const insideFirst = tryInsideFirstForExternalDrag(
879
+ tree,
880
+ flat,
881
+ dragSource,
882
+ overRow,
883
+ config,
884
+ )
885
+ if (insideFirst) return insideFirst
886
+ }
887
+
888
+ const target: DropTarget =
889
+ overRow.kind === 'folder' && overRow.folderPath
890
+ ? {
891
+ targetFolderPath: overRow.folderPath,
892
+ targetTocLineIndex: overRow.tocLineIndex ?? undefined,
893
+ placement: 'after',
894
+ projectedDepth: overRow.depth,
895
+ }
896
+ : {
897
+ targetNoteIndex: overRow.noteIndex ?? undefined,
898
+ targetTocLineIndex: overRow.tocLineIndex ?? undefined,
899
+ placement: 'after',
900
+ projectedDepth: overRow.depth,
901
+ }
902
+ return finalizeProjection(
903
+ tree,
904
+ flat,
905
+ dragSource,
906
+ target,
907
+ buildLineIndicator(overRow.depth),
908
+ config,
909
+ )
910
+ }
911
+
912
+ export function toReorderPayload(
913
+ dragSource: DragSource,
914
+ target: DropTarget,
915
+ ): ReorderPayload {
916
+ const isFolderTarget =
917
+ !!target.targetFolderPath?.length && !target.targetNoteIndex
918
+
919
+ return {
920
+ dragType: dragSource.kind,
921
+ dragTocLineIndex: dragSource.tocLineIndex,
922
+ noteIndex: dragSource.noteIndex,
923
+ targetType:
924
+ target.placement === 'inside' || isFolderTarget ? 'group' : 'note',
925
+ targetTocLineIndex: target.targetTocLineIndex,
926
+ targetFolderPath: target.targetFolderPath,
927
+ targetNoteIndex: target.targetNoteIndex,
928
+ placement: target.placement,
929
+ }
930
+ }
931
+
932
+ interface RemoveResult {
933
+ tree: SidebarTreeItem[]
934
+ removed: SidebarTreeItem | null
935
+ }
936
+
937
+ function cloneSidebarTree(items: SidebarTreeItem[]): SidebarTreeItem[] {
938
+ return items.map((item) => ({
939
+ ...item,
940
+ items: item.items ? cloneSidebarTree(item.items) : undefined,
941
+ }))
942
+ }
943
+
944
+ function itemMatchesDropTarget(
945
+ item: SidebarTreeItem,
946
+ target: DropTarget,
947
+ ): boolean {
948
+ if (
949
+ target.targetTocLineIndex !== undefined &&
950
+ item.tocLineIndex === target.targetTocLineIndex
951
+ ) {
952
+ return true
953
+ }
954
+ if (target.targetNoteIndex) {
955
+ return extractNoteIndexFromLink(item.link) === target.targetNoteIndex
956
+ }
957
+ if (target.targetFolderPath?.length && !item.link) {
958
+ const path =
959
+ item.folderPath ??
960
+ [item.text.replace(/^[✅⏰]\s*/, '')]
961
+ return path.join('/') === target.targetFolderPath.join('/')
962
+ }
963
+ return false
964
+ }
965
+
966
+ export function removeFromListByTocLineIndex(
967
+ items: SidebarTreeItem[],
968
+ tocLineIndex: number,
969
+ ): RemoveResult {
970
+ const tree = cloneSidebarTree(items)
971
+
972
+ for (let i = 0; i < tree.length; i++) {
973
+ if (tree[i].tocLineIndex === tocLineIndex) {
974
+ const [removed] = tree.splice(i, 1)
975
+ return { tree, removed }
976
+ }
977
+
978
+ if (tree[i].items?.length) {
979
+ const nested = removeFromListByTocLineIndex(tree[i].items!, tocLineIndex)
980
+ if (nested.removed) {
981
+ tree[i] = { ...tree[i], items: nested.tree }
982
+ return { tree, removed: nested.removed }
983
+ }
984
+ }
985
+ }
986
+
987
+ return { tree, removed: null }
988
+ }
989
+
990
+ function insertRelativeToTarget(
991
+ items: SidebarTreeItem[],
992
+ target: DropTarget,
993
+ subtree: SidebarTreeItem,
994
+ options?: { expandInside?: boolean },
995
+ ): SidebarTreeItem[] | null {
996
+ const tree = cloneSidebarTree(items)
997
+ const expandInside = options?.expandInside !== false
998
+
999
+ for (let i = 0; i < tree.length; i++) {
1000
+ if (itemMatchesDropTarget(tree[i], target)) {
1001
+ if (target.placement === 'inside') {
1002
+ tree[i] = {
1003
+ ...tree[i],
1004
+ items: [subtree, ...(tree[i].items ?? [])],
1005
+ ...(expandInside ? { collapsed: false } : {}),
1006
+ }
1007
+ return tree
1008
+ }
1009
+
1010
+ if (target.placement === 'before') {
1011
+ tree.splice(i, 0, subtree)
1012
+ return tree
1013
+ }
1014
+
1015
+ tree.splice(i + 1, 0, subtree)
1016
+ return tree
1017
+ }
1018
+
1019
+ if (tree[i].items?.length) {
1020
+ const nested = insertRelativeToTarget(
1021
+ tree[i].items!,
1022
+ target,
1023
+ subtree,
1024
+ options,
1025
+ )
1026
+ if (nested) {
1027
+ tree[i] = { ...tree[i], items: nested }
1028
+ return tree
1029
+ }
1030
+ }
1031
+ }
1032
+
1033
+ return null
1034
+ }
1035
+
1036
+ function removeFromList(
1037
+ items: SidebarTreeItem[],
1038
+ noteIndex: string,
1039
+ ): RemoveResult {
1040
+ const tree: SidebarTreeItem[] = items.map((item) => ({
1041
+ ...item,
1042
+ items: item.items ? [...item.items] : undefined,
1043
+ }))
1044
+
1045
+ for (let i = 0; i < tree.length; i++) {
1046
+ const id = extractNoteIndexFromLink(tree[i].link)
1047
+ if (id === noteIndex) {
1048
+ const [removed] = tree.splice(i, 1)
1049
+ return { tree, removed }
1050
+ }
1051
+
1052
+ if (tree[i].items?.length) {
1053
+ const nested = removeFromList(tree[i].items!, noteIndex)
1054
+ if (nested.removed) {
1055
+ tree[i] = { ...tree[i], items: nested.tree }
1056
+ return { tree, removed: nested.removed }
1057
+ }
1058
+ }
1059
+ }
1060
+
1061
+ return { tree, removed: null }
1062
+ }
1063
+
1064
+ function insertIntoList(
1065
+ items: SidebarTreeItem[],
1066
+ targetNoteIndex: string,
1067
+ placement: DropPlacement,
1068
+ subtree: SidebarTreeItem,
1069
+ ): SidebarTreeItem[] | null {
1070
+ const tree: SidebarTreeItem[] = items.map((item) => ({
1071
+ ...item,
1072
+ items: item.items ? [...item.items] : undefined,
1073
+ }))
1074
+
1075
+ for (let i = 0; i < tree.length; i++) {
1076
+ const id = extractNoteIndexFromLink(tree[i].link)
1077
+ if (id === targetNoteIndex) {
1078
+ if (placement === 'inside') {
1079
+ const nextItems = [subtree, ...(tree[i].items ?? [])]
1080
+ tree[i] = { ...tree[i], items: nextItems, collapsed: false }
1081
+ return tree
1082
+ }
1083
+
1084
+ if (placement === 'before') {
1085
+ tree.splice(i, 0, subtree)
1086
+ return tree
1087
+ }
1088
+
1089
+ tree.splice(i + 1, 0, subtree)
1090
+ return tree
1091
+ }
1092
+
1093
+ if (tree[i].items?.length) {
1094
+ const nested = insertIntoList(
1095
+ tree[i].items!,
1096
+ targetNoteIndex,
1097
+ placement,
1098
+ subtree,
1099
+ )
1100
+ if (nested) {
1101
+ tree[i] = { ...tree[i], items: nested }
1102
+ return tree
1103
+ }
1104
+ }
1105
+ }
1106
+
1107
+ return null
1108
+ }
1109
+
1110
+ export function getProjectionFingerprint(
1111
+ projection: DropProjectionResult,
1112
+ ): string {
1113
+ const { target, indicator } = projection
1114
+ return [
1115
+ target.placement,
1116
+ target.projectedDepth,
1117
+ target.targetNoteIndex ?? '',
1118
+ target.targetTocLineIndex ?? '',
1119
+ target.targetFolderPath?.join('/') ?? '',
1120
+ indicator.mode,
1121
+ indicator.activeDepth,
1122
+ indicator.depthLevels.join(','),
1123
+ ].join('|')
1124
+ }
1125
+
1126
+ function findTreeItemByNodeId(
1127
+ items: SidebarTreeItem[],
1128
+ nodeId: string,
1129
+ ): SidebarTreeItem | null {
1130
+ for (const node of items) {
1131
+ const id = node.nodeId ?? computeSidebarNodeId(node)
1132
+ if (id === nodeId) return node
1133
+ if (node.items?.length) {
1134
+ const found = findTreeItemByNodeId(node.items, nodeId)
1135
+ if (found) return found
1136
+ }
1137
+ }
1138
+ return null
1139
+ }
1140
+
1141
+ function findSiblingContext(
1142
+ items: SidebarTreeItem[],
1143
+ tocLineIndex: number,
1144
+ ): { list: SidebarTreeItem[]; index: number } | null {
1145
+ for (let i = 0; i < items.length; i++) {
1146
+ if (items[i].tocLineIndex === tocLineIndex) {
1147
+ return { list: items, index: i }
1148
+ }
1149
+ if (items[i].items?.length) {
1150
+ const found = findSiblingContext(items[i].items!, tocLineIndex)
1151
+ if (found) return found
1152
+ }
1153
+ }
1154
+ return null
1155
+ }
1156
+
1157
+ export function isDropIntentNoOp(
1158
+ tree: SidebarTreeItem[],
1159
+ sourceTocLineIndex: number,
1160
+ intent: DropIntent,
1161
+ ): boolean {
1162
+ if (intent.isRootPrepend) {
1163
+ const sourceCtx = findSiblingContext(tree, sourceTocLineIndex)
1164
+ return !!sourceCtx && sourceCtx.list === tree && sourceCtx.index === 0
1165
+ }
1166
+
1167
+ const sourceCtx = findSiblingContext(tree, sourceTocLineIndex)
1168
+ if (!sourceCtx) return false
1169
+
1170
+ if (!intent.targetNodeId) return false
1171
+
1172
+ const targetItem = findTreeItemByNodeId(tree, intent.targetNodeId)
1173
+ if (!targetItem || targetItem.tocLineIndex === undefined) return false
1174
+
1175
+ if (intent.action === 'prependChild') {
1176
+ const firstChild = targetItem.items?.[0]
1177
+ return firstChild?.tocLineIndex === sourceTocLineIndex
1178
+ }
1179
+
1180
+ const targetCtx = findSiblingContext(tree, targetItem.tocLineIndex)
1181
+ if (!targetCtx || targetCtx.list !== sourceCtx.list) return false
1182
+
1183
+ return sourceCtx.index === targetCtx.index + 1
1184
+ }
1185
+
1186
+ function dropIntentToDropTarget(
1187
+ tree: SidebarTreeItem[],
1188
+ intent: DropIntent,
1189
+ ): DropTarget | null {
1190
+ if (intent.isRootPrepend) return null
1191
+
1192
+ const item = findTreeItemByNodeId(tree, intent.targetNodeId!)
1193
+ if (!item || item.tocLineIndex === undefined) return null
1194
+
1195
+ const noteIndex = extractNoteIndexFromLink(item.link)
1196
+ const base: DropTarget = {
1197
+ targetTocLineIndex: item.tocLineIndex,
1198
+ targetNoteIndex: noteIndex ?? undefined,
1199
+ targetFolderPath:
1200
+ !noteIndex && item.folderPath?.length ? item.folderPath : undefined,
1201
+ placement: intent.action === 'prependChild' ? 'inside' : 'after',
1202
+ projectedDepth: 0,
1203
+ }
1204
+ return base
1205
+ }
1206
+
1207
+ function createDropPlaceholder(sourceTocLineIndex: number): SidebarTreeItem {
1208
+ return {
1209
+ text: '',
1210
+ isDragPlaceholder: true,
1211
+ tocLineIndex: -(sourceTocLineIndex + 1),
1212
+ }
1213
+ }
1214
+
1215
+ function markDragSourceSlot(
1216
+ items: SidebarTreeItem[],
1217
+ tocLineIndex: number,
1218
+ ): SidebarTreeItem[] {
1219
+ return items.map((item) => {
1220
+ const next: SidebarTreeItem = { ...item }
1221
+ if (item.tocLineIndex === tocLineIndex) {
1222
+ next.isDragSourceSlot = true
1223
+ return next
1224
+ }
1225
+ if (item.items?.length) {
1226
+ next.items = markDragSourceSlot(item.items, tocLineIndex)
1227
+ }
1228
+ return next
1229
+ })
1230
+ }
1231
+
1232
+ /**
1233
+ * 拖拽预览树:
1234
+ * - 无落点:源项原位 invisible 占位(总高度不变)
1235
+ * - 有落点:移除源项、在目标处插入占位,兄弟项随拖动方向让位
1236
+ */
1237
+ export function buildSidebarDragPreviewTree(
1238
+ base: SidebarTreeItem[],
1239
+ dragSource: DragSource,
1240
+ intent: DropIntent | null,
1241
+ ): SidebarTreeItem[] {
1242
+ const cloned = cloneSidebarTree(base)
1243
+
1244
+ if (!intent || isDropIntentNoOp(cloned, dragSource.tocLineIndex, intent)) {
1245
+ return markDragSourceSlot(cloned, dragSource.tocLineIndex)
1246
+ }
1247
+
1248
+ const target = dropIntentToDropTarget(cloned, intent)
1249
+ if (!target && !intent.isRootPrepend) {
1250
+ return markDragSourceSlot(cloned, dragSource.tocLineIndex)
1251
+ }
1252
+
1253
+ const { tree: withoutSource } = removeFromListByTocLineIndex(
1254
+ cloned,
1255
+ dragSource.tocLineIndex,
1256
+ )
1257
+ const placeholder = createDropPlaceholder(dragSource.tocLineIndex)
1258
+
1259
+ if (intent.isRootPrepend) {
1260
+ return [placeholder, ...withoutSource]
1261
+ }
1262
+
1263
+ const inserted = insertRelativeToTarget(withoutSource, target!, placeholder, {
1264
+ expandInside: false,
1265
+ })
1266
+ if (!inserted) {
1267
+ return markDragSourceSlot(cloned, dragSource.tocLineIndex)
1268
+ }
1269
+
1270
+ return inserted
1271
+ }
1272
+
1273
+ export function applyMoveInSidebarTreeByTocLine(
1274
+ items: SidebarTreeItem[],
1275
+ dragSource: DragSource,
1276
+ target: DropTarget,
1277
+ ): SidebarTreeItem[] {
1278
+ const { tree: withoutDrag, removed } = removeFromListByTocLineIndex(
1279
+ items,
1280
+ dragSource.tocLineIndex,
1281
+ )
1282
+ if (!removed) return items
1283
+
1284
+ const inserted = insertRelativeToTarget(withoutDrag, target, removed)
1285
+ return inserted ?? items
1286
+ }
1287
+
1288
+ export function applyMoveInSidebarTree(
1289
+ items: SidebarTreeItem[],
1290
+ dragNoteIndex: string,
1291
+ target: DropTarget,
1292
+ ): SidebarTreeItem[] {
1293
+ const { tree: withoutDrag, removed } = removeFromList(items, dragNoteIndex)
1294
+ if (!removed) return items
1295
+
1296
+ const inserted = target.targetNoteIndex
1297
+ ? insertIntoList(
1298
+ withoutDrag,
1299
+ target.targetNoteIndex,
1300
+ target.placement,
1301
+ removed,
1302
+ )
1303
+ : null
1304
+
1305
+ return inserted ?? items
1306
+ }
1307
+