@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,801 @@
1
+ /**
2
+ * vitepress/components/Layout/sidebarHitTest.ts
3
+ *
4
+ * 语雀式落点命中:Y 轴行带 + 迟滞,输出 moveAfter / prependChild(含拖到列表顶)。
5
+ */
6
+
7
+ import {
8
+ flattenVisibleSidebar,
9
+ getDropProjection,
10
+ getDepthContentLeft,
11
+ getSubtreeDepthSpan,
12
+ findTreeItem,
13
+ isDragExternalToTargetParent,
14
+ shouldPrependInsideExternalNoteRow,
15
+ SIDEBAR_ARROW_WIDTH,
16
+ SIDEBAR_INDENT_SIZE,
17
+ SIDEBAR_ROW_BASE_PADDING,
18
+ type DragSource,
19
+ type DropIndicatorMeta,
20
+ type DropProjectionResult,
21
+ type FlatSidebarRow,
22
+ type SidebarTreeItem,
23
+ } from './sidebarDragLogic'
24
+ import { computeSidebarNodeId } from '../../../utils/tocNodeId'
25
+
26
+ import type { TocMoveAction } from '../../../utils/tocNodeId'
27
+
28
+
29
+ export type DropIndicatorMode = DropIndicatorMeta['mode']
30
+
31
+ export interface DropIntent {
32
+ action: TocMoveAction
33
+ /** 文件夹 prependChild 时有值;拖到列表顶(prepend 到根容器)时省略 */
34
+ targetNodeId: string | null
35
+ sourceNodeId: string
36
+ indicator: DropIndicatorMeta
37
+ /** 用于 overlay:蓝线相对 targetNodeId 行;moveAfter 恒为 after(目标行下方) */
38
+ linePlacement: 'before' | 'after'
39
+ hoverRowIndex: number
40
+ /** 语雀式:拖到侧栏列表顶,无 target_uuid、无 overlay,仅预览树让位 */
41
+ isRootPrepend?: boolean
42
+ }
43
+
44
+ export interface RowMetric {
45
+ nodeId: string
46
+ tocLineIndex: number | null
47
+ rect: DOMRect
48
+ }
49
+
50
+ export interface HitTestState {
51
+ lastVerticalPlacement: 'before' | 'after' | null
52
+ stickyPreferInside: boolean | null
53
+ /** 父级下首位 prepend 迟滞:避免与 moveAfter 闪烁 */
54
+ stickyPrependParentNodeId: string | null
55
+ lastIntentFingerprint: string | null
56
+ }
57
+
58
+ export interface HitTestInput {
59
+ tree: SidebarTreeItem[]
60
+ flat: FlatSidebarRow[]
61
+ dragSource: DragSource
62
+ sourceNodeId: string
63
+ clientX: number
64
+ clientY: number
65
+ navLeft: number
66
+ navTop: number
67
+ navWidth: number
68
+ rowMetrics: RowMetric[]
69
+ maxDepth: number
70
+ }
71
+
72
+ const VERTICAL_HYSTERESIS_RATIO = 0.35
73
+ /** 同级第一项:扩大上半区,便于 prependChild(父) */
74
+ const FIRST_SIBLING_BEFORE_RATIO = 0.72
75
+ const PREPEND_FIRST_CHILD_ZONE_EXIT_PX = 6
76
+ const INSIDE_ENTER_RATIO = 0.55
77
+ const INSIDE_EXIT_RATIO = 0.3
78
+ const ROW_HIT_PADDING_PX = 8
79
+
80
+ export function createHitTestState(): HitTestState {
81
+ return {
82
+ lastVerticalPlacement: null,
83
+ stickyPreferInside: null,
84
+ stickyPrependParentNodeId: null,
85
+ lastIntentFingerprint: null,
86
+ }
87
+ }
88
+
89
+ export function dropIntentFingerprint(intent: DropIntent): string {
90
+ return `${intent.action}|${intent.targetNodeId ?? ''}|${intent.isRootPrepend ? 1 : 0}|${intent.indicator.mode}|${intent.indicator.activeDepth}|${intent.linePlacement}`
91
+ }
92
+
93
+ function findFirstRootRow(
94
+ flat: FlatSidebarRow[],
95
+ rowMetrics: RowMetric[],
96
+ ): { flatIndex: number; row: FlatSidebarRow; metric: RowMetric } | null {
97
+ const rootRows = flat.filter((row) => row.depth === 0)
98
+ if (!rootRows.length) return null
99
+
100
+ const rootTocIndices = new Set(
101
+ rootRows
102
+ .map((row) => row.tocLineIndex)
103
+ .filter((index): index is number => index !== null),
104
+ )
105
+ const rootMetrics = rowMetrics
106
+ .filter(
107
+ (m) => m.tocLineIndex !== null && rootTocIndices.has(m.tocLineIndex),
108
+ )
109
+ .sort((a, b) => a.rect.top - b.rect.top)
110
+
111
+ const metric = rootMetrics[0]
112
+ if (!metric || metric.tocLineIndex === null) return null
113
+
114
+ const flatIndex = flat.findIndex(
115
+ (row) => row.tocLineIndex === metric.tocLineIndex,
116
+ )
117
+ if (flatIndex < 0) return null
118
+
119
+ return { flatIndex, row: flat[flatIndex], metric }
120
+ }
121
+
122
+ /** 语雀:nav 顶栏与首行之间的空隙 → prepend 到列表顶 */
123
+ function isNavTopGap(
124
+ clientY: number,
125
+ navTop: number,
126
+ firstRowRect: DOMRect,
127
+ ): boolean {
128
+ return clientY >= navTop && clientY < firstRowRect.top + 4
129
+ }
130
+
131
+ /** 首行上半区 → prepend 到列表顶(与 nav 顶空隙语义一致) */
132
+ function isFirstRootUpperPrependZone(
133
+ clientY: number,
134
+ firstRowRect: DOMRect,
135
+ ): boolean {
136
+ const mid = firstRowRect.top + firstRowRect.height / 2
137
+ return clientY >= firstRowRect.top && clientY < mid
138
+ }
139
+
140
+ function isFirstSiblingRow(flat: FlatSidebarRow[], index: number): boolean {
141
+ return flat[index].depth > 0 && findPrevSiblingIndex(flat, index) < 0
142
+ }
143
+
144
+ function findFirstChildRowIndex(
145
+ flat: FlatSidebarRow[],
146
+ parentIndex: number,
147
+ ): number {
148
+ const parentDepth = flat[parentIndex].depth
149
+ for (let i = parentIndex + 1; i < flat.length; i++) {
150
+ if (flat[i].depth <= parentDepth) break
151
+ if (flat[i].depth === parentDepth + 1) return i
152
+ }
153
+ return -1
154
+ }
155
+
156
+ function getMetricForRow(
157
+ rowMetrics: RowMetric[],
158
+ tocLineIndex: number | null,
159
+ ): RowMetric | undefined {
160
+ if (tocLineIndex === null) return undefined
161
+ return rowMetrics.find((m) => m.tocLineIndex === tocLineIndex)
162
+ }
163
+
164
+ function isClientYInPrependFirstChildZone(
165
+ clientY: number,
166
+ parentMetric: RowMetric,
167
+ firstChildMetric: RowMetric,
168
+ sticky: boolean,
169
+ ): boolean {
170
+ const zoneTop = parentMetric.rect.top + parentMetric.rect.height * 0.3
171
+ const zoneBottom = sticky
172
+ ? firstChildMetric.rect.bottom + PREPEND_FIRST_CHILD_ZONE_EXIT_PX
173
+ : firstChildMetric.rect.top +
174
+ firstChildMetric.rect.height * FIRST_SIBLING_BEFORE_RATIO
175
+ return clientY >= zoneTop && clientY <= zoneBottom
176
+ }
177
+
178
+ function tryResolvePrependFirstChildZone(
179
+ tree: SidebarTreeItem[],
180
+ flat: FlatSidebarRow[],
181
+ rowMetrics: RowMetric[],
182
+ dragSource: DragSource,
183
+ sourceNodeId: string,
184
+ clientY: number,
185
+ state: HitTestState,
186
+ ): DropIntent | null {
187
+ if (state.stickyPrependParentNodeId) {
188
+ const parentIndex = flat.findIndex(
189
+ (row) => nodeIdForFlatRow(row, tree) === state.stickyPrependParentNodeId,
190
+ )
191
+ if (parentIndex >= 0) {
192
+ const firstChildIndex = findFirstChildRowIndex(flat, parentIndex)
193
+ if (firstChildIndex >= 0) {
194
+ const parentMetric = getMetricForRow(
195
+ rowMetrics,
196
+ flat[parentIndex].tocLineIndex,
197
+ )
198
+ const childMetric = getMetricForRow(
199
+ rowMetrics,
200
+ flat[firstChildIndex].tocLineIndex,
201
+ )
202
+ if (
203
+ parentMetric &&
204
+ childMetric &&
205
+ isClientYInPrependFirstChildZone(
206
+ clientY,
207
+ parentMetric,
208
+ childMetric,
209
+ true,
210
+ ) &&
211
+ flat[firstChildIndex].tocLineIndex !== dragSource.tocLineIndex
212
+ ) {
213
+ return buildPrependFirstChildIntent(
214
+ tree,
215
+ flat,
216
+ parentIndex,
217
+ sourceNodeId,
218
+ firstChildIndex,
219
+ )
220
+ }
221
+ }
222
+ }
223
+ state.stickyPrependParentNodeId = null
224
+ }
225
+
226
+ for (let parentIndex = 0; parentIndex < flat.length; parentIndex++) {
227
+ const parentRow = flat[parentIndex]
228
+ if (parentRow.kind !== 'folder' || !parentRow.collapsed) {
229
+ continue
230
+ }
231
+ if (parentRow.tocLineIndex === dragSource.tocLineIndex) continue
232
+
233
+ const dragRow = flat.find(
234
+ (row) => row.tocLineIndex === dragSource.tocLineIndex,
235
+ )
236
+ if (!dragRow || !isDragExternalToTargetParent(flat, dragRow, parentRow)) {
237
+ continue
238
+ }
239
+
240
+ const parentMetric = getMetricForRow(rowMetrics, parentRow.tocLineIndex)
241
+ if (!parentMetric) continue
242
+
243
+ const zoneTop = parentRow.hasChildren
244
+ ? parentMetric.rect.top + parentMetric.rect.height * 0.3
245
+ : parentMetric.rect.top + parentMetric.rect.height * 0.5
246
+ if (clientY < zoneTop || clientY > parentMetric.rect.bottom) continue
247
+
248
+ const intent = buildPrependFirstChildIntent(
249
+ tree,
250
+ flat,
251
+ parentIndex,
252
+ sourceNodeId,
253
+ parentIndex,
254
+ )
255
+ if (!intent) continue
256
+
257
+ state.stickyPrependParentNodeId = intent.targetNodeId
258
+ state.lastVerticalPlacement = 'before'
259
+ state.stickyPreferInside = null
260
+ return intent
261
+ }
262
+
263
+ for (let i = 0; i < flat.length; i++) {
264
+ if (!isFirstSiblingRow(flat, i)) continue
265
+
266
+ const parentIndex = findParentRowIndex(flat, i)
267
+ if (parentIndex < 0) continue
268
+
269
+ const parentMetric = getMetricForRow(rowMetrics, flat[parentIndex].tocLineIndex)
270
+ const childMetric = getMetricForRow(rowMetrics, flat[i].tocLineIndex)
271
+ if (!parentMetric || !childMetric) continue
272
+ if (flat[i].tocLineIndex === dragSource.tocLineIndex) continue
273
+
274
+ if (
275
+ !isClientYInPrependFirstChildZone(
276
+ clientY,
277
+ parentMetric,
278
+ childMetric,
279
+ false,
280
+ )
281
+ ) {
282
+ continue
283
+ }
284
+
285
+ const intent = buildPrependFirstChildIntent(
286
+ tree,
287
+ flat,
288
+ parentIndex,
289
+ sourceNodeId,
290
+ i,
291
+ )
292
+ if (!intent) continue
293
+
294
+ state.stickyPrependParentNodeId = intent.targetNodeId
295
+ state.lastVerticalPlacement = 'before'
296
+ state.stickyPreferInside = null
297
+ return intent
298
+ }
299
+
300
+ return null
301
+ }
302
+
303
+ function verticalPlacementForRowHit(
304
+ rect: DOMRect,
305
+ clientY: number,
306
+ ): 'before' | 'after' {
307
+ const mid = rect.top + rect.height / 2
308
+ return clientY < mid ? 'before' : 'after'
309
+ }
310
+
311
+ function tryResolveIntoExternalNoteZone(
312
+ tree: SidebarTreeItem[],
313
+ flat: FlatSidebarRow[],
314
+ rowMetrics: RowMetric[],
315
+ dragSource: DragSource,
316
+ sourceNodeId: string,
317
+ clientY: number,
318
+ ): DropIntent | null {
319
+ const dragIndex = flat.findIndex(
320
+ (row) => row.tocLineIndex === dragSource.tocLineIndex,
321
+ )
322
+ if (dragIndex < 0) return null
323
+
324
+ for (let i = 0; i < flat.length; i++) {
325
+ const overRow = flat[i]
326
+ if (!overRow.noteIndex) continue
327
+ if (overRow.tocLineIndex === dragSource.tocLineIndex) continue
328
+
329
+ const metric = getMetricForRow(rowMetrics, overRow.tocLineIndex)
330
+ if (!metric) continue
331
+
332
+ const hitTop = metric.rect.top - ROW_HIT_PADDING_PX
333
+ const hitBottom = metric.rect.bottom + ROW_HIT_PADDING_PX
334
+ if (clientY < hitTop || clientY > hitBottom) continue
335
+
336
+ const verticalPlacement = verticalPlacementForRowHit(metric.rect, clientY)
337
+ const dragRow = flat[dragIndex]
338
+ const nestIntoNextRowUpperHalf =
339
+ i === dragIndex + 1 &&
340
+ verticalPlacement === 'before' &&
341
+ isDragExternalToTargetParent(flat, dragRow, overRow)
342
+ if (
343
+ !shouldPrependInsideExternalNoteRow(
344
+ flat,
345
+ dragSource,
346
+ i,
347
+ overRow,
348
+ verticalPlacement,
349
+ ) &&
350
+ !nestIntoNextRowUpperHalf
351
+ ) {
352
+ continue
353
+ }
354
+
355
+ const intent = buildPrependFirstChildIntent(
356
+ tree,
357
+ flat,
358
+ i,
359
+ sourceNodeId,
360
+ i,
361
+ )
362
+ if (!intent) continue
363
+
364
+ return intent
365
+ }
366
+
367
+ return null
368
+ }
369
+
370
+ function buildRootPrependIntent(
371
+ sourceNodeId: string,
372
+ hoverRowIndex: number,
373
+ ): DropIntent {
374
+ return {
375
+ action: 'prependChild',
376
+ targetNodeId: null,
377
+ sourceNodeId,
378
+ isRootPrepend: true,
379
+ indicator: { mode: 'root-prepend', depthLevels: [0], activeDepth: 0 },
380
+ linePlacement: 'before',
381
+ hoverRowIndex,
382
+ }
383
+ }
384
+
385
+ function nodeIdForFlatRow(row: FlatSidebarRow, tree: SidebarTreeItem[]): string {
386
+ if (row.nodeId) return row.nodeId
387
+ if (row.tocLineIndex !== null) {
388
+ const item = findTreeItemByTocLine(tree, row.tocLineIndex)
389
+ if (item) return computeSidebarNodeId(item)
390
+ }
391
+ if (row.noteIndex) return `note:${row.noteIndex}`
392
+ if (row.folderPath?.length) return `folder:${row.folderPath.join('/')}`
393
+ return `line:${row.tocLineIndex ?? 0}`
394
+ }
395
+
396
+ function findPrevSiblingIndex(
397
+ flat: FlatSidebarRow[],
398
+ index: number,
399
+ ): number {
400
+ const depth = flat[index].depth
401
+ for (let i = index - 1; i >= 0; i--) {
402
+ if (flat[i].depth === depth) return i
403
+ if (flat[i].depth < depth) break
404
+ }
405
+ return -1
406
+ }
407
+
408
+ function findParentRowIndex(flat: FlatSidebarRow[], index: number): number {
409
+ const depth = flat[index].depth
410
+ if (depth <= 0) return -1
411
+ for (let i = index - 1; i >= 0; i--) {
412
+ if (flat[i].depth < depth) return i
413
+ }
414
+ return -1
415
+ }
416
+
417
+ function buildPrependFirstChildIntent(
418
+ tree: SidebarTreeItem[],
419
+ flat: FlatSidebarRow[],
420
+ parentIndex: number,
421
+ sourceNodeId: string,
422
+ hoverRowIndex: number,
423
+ ): DropIntent | null {
424
+ const parentRow = flat[parentIndex]
425
+ const parentNodeId = nodeIdForFlatRow(parentRow, tree)
426
+ if (!parentNodeId || parentNodeId === sourceNodeId) return null
427
+
428
+ return {
429
+ action: 'prependChild',
430
+ targetNodeId: parentNodeId,
431
+ sourceNodeId,
432
+ indicator: {
433
+ mode: 'inside',
434
+ depthLevels: [parentRow.depth],
435
+ activeDepth: parentRow.depth,
436
+ },
437
+ linePlacement: 'before',
438
+ hoverRowIndex,
439
+ }
440
+ }
441
+
442
+ function findTreeItemByTocLine(
443
+ items: SidebarTreeItem[],
444
+ tocLineIndex: number,
445
+ ): SidebarTreeItem | null {
446
+ for (const node of items) {
447
+ if (node.tocLineIndex === tocLineIndex) return node
448
+ if (node.items?.length) {
449
+ const found = findTreeItemByTocLine(node.items, tocLineIndex)
450
+ if (found) return found
451
+ }
452
+ }
453
+ return null
454
+ }
455
+
456
+ function resolveVerticalPlacement(
457
+ rect: DOMRect,
458
+ clientY: number,
459
+ state: HitTestState,
460
+ isFirstSibling = false,
461
+ ): 'before' | 'after' {
462
+ const beforeRatio = isFirstSibling
463
+ ? FIRST_SIBLING_BEFORE_RATIO
464
+ : VERTICAL_HYSTERESIS_RATIO
465
+ const mid = rect.top + rect.height / 2
466
+ const topBound = rect.top + rect.height * beforeRatio
467
+ const bottomBound = rect.top + rect.height * (1 - VERTICAL_HYSTERESIS_RATIO)
468
+
469
+ if (state.lastVerticalPlacement === 'before' && clientY < bottomBound) {
470
+ return 'before'
471
+ }
472
+ if (state.lastVerticalPlacement === 'after' && clientY > topBound) {
473
+ return 'after'
474
+ }
475
+
476
+ const split = isFirstSibling
477
+ ? rect.top + rect.height * beforeRatio
478
+ : mid
479
+ const next = clientY < split ? 'before' : 'after'
480
+ state.lastVerticalPlacement = next
481
+ return next
482
+ }
483
+
484
+ function findHoverRowIndex(
485
+ flat: FlatSidebarRow[],
486
+ dragSource: DragSource,
487
+ clientY: number,
488
+ rowMetrics: RowMetric[],
489
+ ): number {
490
+ const metricByNode = new Map(rowMetrics.map((m) => [m.tocLineIndex, m]))
491
+
492
+ let bestIndex = -1
493
+ let bestDistance = Number.POSITIVE_INFINITY
494
+
495
+ for (let i = 0; i < flat.length; i++) {
496
+ const row = flat[i]
497
+ if (row.tocLineIndex === dragSource.tocLineIndex) continue
498
+
499
+ const metric =
500
+ row.tocLineIndex !== null
501
+ ? metricByNode.get(row.tocLineIndex)
502
+ : rowMetrics.find(
503
+ (m) =>
504
+ m.nodeId === nodeIdForFlatRow(row, []) ||
505
+ (row.noteIndex && m.nodeId === `note:${row.noteIndex}`),
506
+ )
507
+
508
+ const rect = metric?.rect
509
+ if (!rect) continue
510
+
511
+ const hitTop = rect.top - ROW_HIT_PADDING_PX
512
+ const hitBottom = rect.bottom + ROW_HIT_PADDING_PX
513
+ if (clientY < hitTop || clientY > hitBottom) continue
514
+
515
+ const distance = Math.abs(clientY - (rect.top + rect.height / 2))
516
+ if (distance < bestDistance) {
517
+ bestDistance = distance
518
+ bestIndex = i
519
+ }
520
+ }
521
+
522
+ if (bestIndex >= 0) return bestIndex
523
+
524
+ for (let i = 0; i < flat.length; i++) {
525
+ const row = flat[i]
526
+ if (row.tocLineIndex === dragSource.tocLineIndex) continue
527
+ const metric =
528
+ row.tocLineIndex !== null ? metricByNode.get(row.tocLineIndex) : null
529
+ if (!metric) continue
530
+ const centerY = metric.rect.top + metric.rect.height / 2
531
+ const distance = Math.abs(clientY - centerY)
532
+ if (distance < bestDistance) {
533
+ bestDistance = distance
534
+ bestIndex = i
535
+ }
536
+ }
537
+
538
+ return bestDistance <= 48 ? bestIndex : -1
539
+ }
540
+
541
+ function stabilizePointerOffset(
542
+ offsetX: number,
543
+ indentSize: number,
544
+ overRow: FlatSidebarRow,
545
+ verticalPlacement: 'before' | 'after',
546
+ state: HitTestState,
547
+ ): number {
548
+ const canInside =
549
+ overRow.hasChildren && verticalPlacement === 'after' && offsetX > 0
550
+ if (!canInside) {
551
+ state.stickyPreferInside = null
552
+ return offsetX
553
+ }
554
+
555
+ const enterAt = indentSize * INSIDE_ENTER_RATIO
556
+ const exitAt = indentSize * INSIDE_EXIT_RATIO
557
+
558
+ if (state.stickyPreferInside === null) {
559
+ state.stickyPreferInside = offsetX >= enterAt
560
+ } else if (state.stickyPreferInside) {
561
+ if (offsetX < exitAt) state.stickyPreferInside = false
562
+ } else if (offsetX >= enterAt) {
563
+ state.stickyPreferInside = true
564
+ }
565
+
566
+ return state.stickyPreferInside ? indentSize : Math.min(offsetX, 0)
567
+ }
568
+
569
+ function projectionToIntent(
570
+ tree: SidebarTreeItem[],
571
+ flat: FlatSidebarRow[],
572
+ projection: DropProjectionResult,
573
+ sourceNodeId: string,
574
+ linePlacement: 'before' | 'after',
575
+ hoverRowIndex: number,
576
+ ): DropIntent | null {
577
+ const { target, indicator } = projection
578
+
579
+ if (target.placement === 'inside') {
580
+ const targetNodeId = resolveTargetNodeId(tree, flat, target)
581
+ if (!targetNodeId || targetNodeId === sourceNodeId) return null
582
+ return {
583
+ action: 'prependChild',
584
+ targetNodeId,
585
+ sourceNodeId,
586
+ indicator,
587
+ linePlacement,
588
+ hoverRowIndex,
589
+ }
590
+ }
591
+
592
+ if (target.placement === 'before') {
593
+ const prevIndex = findPrevSiblingIndex(flat, hoverRowIndex)
594
+ if (prevIndex < 0) {
595
+ const overRow = flat[hoverRowIndex]
596
+ if (overRow.depth === 0) {
597
+ return buildRootPrependIntent(sourceNodeId, hoverRowIndex)
598
+ }
599
+ const parentIndex = findParentRowIndex(flat, hoverRowIndex)
600
+ if (parentIndex < 0) return null
601
+ return buildPrependFirstChildIntent(
602
+ tree,
603
+ flat,
604
+ parentIndex,
605
+ sourceNodeId,
606
+ hoverRowIndex,
607
+ )
608
+ }
609
+ const targetNodeId = nodeIdForFlatRow(flat[prevIndex], tree)
610
+ return {
611
+ action: 'moveAfter',
612
+ targetNodeId,
613
+ sourceNodeId,
614
+ indicator,
615
+ linePlacement: 'after',
616
+ hoverRowIndex,
617
+ }
618
+ }
619
+
620
+ const targetNodeId = resolveTargetNodeId(tree, flat, target)
621
+ if (!targetNodeId || targetNodeId === sourceNodeId) return null
622
+
623
+ return {
624
+ action: 'moveAfter',
625
+ targetNodeId,
626
+ sourceNodeId,
627
+ indicator,
628
+ linePlacement: 'after',
629
+ hoverRowIndex,
630
+ }
631
+ }
632
+
633
+ function resolveTargetNodeId(
634
+ tree: SidebarTreeItem[],
635
+ flat: FlatSidebarRow[],
636
+ target: DropProjectionResult['target'],
637
+ ): string | null {
638
+ if (target.targetTocLineIndex !== undefined) {
639
+ const row = flat.find((r) => r.tocLineIndex === target.targetTocLineIndex)
640
+ if (row) return nodeIdForFlatRow(row, tree)
641
+ const item = findTreeItemByTocLine(tree, target.targetTocLineIndex)
642
+ if (item) return computeSidebarNodeId(item)
643
+ }
644
+ if (target.targetNoteIndex) return `note:${target.targetNoteIndex}`
645
+ if (target.targetFolderPath?.length) {
646
+ return `folder:${target.targetFolderPath.join('/')}`
647
+ }
648
+ return null
649
+ }
650
+
651
+ export function collectRowMetrics(nav: HTMLElement): RowMetric[] {
652
+ const metrics: RowMetric[] = []
653
+ const rows = nav.querySelectorAll<HTMLElement>('.sidebar-row[data-toc-line-index]')
654
+ rows.forEach((row) => {
655
+ const raw = row.dataset.tocLineIndex
656
+ if (raw === undefined || raw === '') return
657
+ const tocLineIndex = Number(raw)
658
+ const nodeId =
659
+ row.dataset.nodeId ??
660
+ (row.dataset.noteId
661
+ ? `note:${row.dataset.noteId}`
662
+ : `line:${tocLineIndex}`)
663
+ metrics.push({
664
+ nodeId,
665
+ tocLineIndex,
666
+ rect: row.getBoundingClientRect(),
667
+ })
668
+ })
669
+ return metrics
670
+ }
671
+
672
+ export function resolveDropIntent(
673
+ input: HitTestInput,
674
+ state: HitTestState,
675
+ ): DropIntent | null {
676
+ const {
677
+ tree,
678
+ flat,
679
+ dragSource,
680
+ sourceNodeId,
681
+ clientX,
682
+ clientY,
683
+ navLeft,
684
+ dragSource: source,
685
+ } = input
686
+
687
+ const firstRoot = findFirstRootRow(flat, input.rowMetrics)
688
+ if (
689
+ firstRoot &&
690
+ dragSource.tocLineIndex !== firstRoot.row.tocLineIndex &&
691
+ (isNavTopGap(clientY, input.navTop, firstRoot.metric.rect) ||
692
+ isFirstRootUpperPrependZone(clientY, firstRoot.metric.rect))
693
+ ) {
694
+ state.stickyPrependParentNodeId = null
695
+ return buildRootPrependIntent(sourceNodeId, firstRoot.flatIndex)
696
+ }
697
+
698
+ const prependFirstChild = tryResolvePrependFirstChildZone(
699
+ tree,
700
+ flat,
701
+ input.rowMetrics,
702
+ dragSource,
703
+ sourceNodeId,
704
+ clientY,
705
+ state,
706
+ )
707
+ if (prependFirstChild) {
708
+ return prependFirstChild
709
+ }
710
+
711
+ const intoExternalNote = tryResolveIntoExternalNoteZone(
712
+ tree,
713
+ flat,
714
+ input.rowMetrics,
715
+ dragSource,
716
+ sourceNodeId,
717
+ clientY,
718
+ )
719
+ if (intoExternalNote) {
720
+ return intoExternalNote
721
+ }
722
+
723
+ const hoverRowIndex = findHoverRowIndex(
724
+ flat,
725
+ dragSource,
726
+ clientY,
727
+ input.rowMetrics,
728
+ )
729
+ if (hoverRowIndex < 0) return null
730
+
731
+ const overRow = flat[hoverRowIndex]
732
+ const metric = input.rowMetrics.find(
733
+ (m) => m.tocLineIndex === overRow.tocLineIndex,
734
+ )
735
+ const rect = metric?.rect
736
+ if (!rect) return null
737
+
738
+ const verticalPlacement = resolveVerticalPlacement(
739
+ rect,
740
+ clientY,
741
+ state,
742
+ isFirstSiblingRow(flat, hoverRowIndex),
743
+ )
744
+ const indentSize = SIDEBAR_INDENT_SIZE
745
+ const baseIndentLeft =
746
+ navLeft +
747
+ SIDEBAR_ROW_BASE_PADDING +
748
+ overRow.depth * indentSize +
749
+ SIDEBAR_ARROW_WIDTH
750
+ const rawOffsetX = clientX - baseIndentLeft
751
+ const offsetX = stabilizePointerOffset(
752
+ rawOffsetX,
753
+ indentSize,
754
+ overRow,
755
+ verticalPlacement,
756
+ state,
757
+ )
758
+
759
+ const dragged =
760
+ source.noteIndex !== undefined
761
+ ? findTreeItem(tree, source.noteIndex)
762
+ : findTreeItemByTocLine(tree, source.tocLineIndex)
763
+ const maxDepth =
764
+ input.maxDepth > 0 ? input.maxDepth : Number.POSITIVE_INFINITY
765
+
766
+ const projection = getDropProjection(
767
+ tree,
768
+ flat,
769
+ dragSource,
770
+ hoverRowIndex,
771
+ verticalPlacement,
772
+ {
773
+ maxDepth,
774
+ indentSize,
775
+ draggedSubtreeDepthSpan: dragged ? getSubtreeDepthSpan(dragged) : 0,
776
+ },
777
+ { offsetX, clientX, navLeft },
778
+ )
779
+
780
+ if (!projection) return null
781
+
782
+ return projectionToIntent(
783
+ tree,
784
+ flat,
785
+ projection,
786
+ sourceNodeId,
787
+ verticalPlacement,
788
+ hoverRowIndex,
789
+ )
790
+ }
791
+
792
+ export function buildFlatForHitTest(
793
+ tree: SidebarTreeItem[],
794
+ dragTocLineIndex: number,
795
+ ): FlatSidebarRow[] {
796
+ return flattenVisibleSidebar(tree, 0, null, null, {
797
+ forceCollapsedTocLineIndex: dragTocLineIndex,
798
+ })
799
+ }
800
+
801
+ export { getDepthContentLeft }