@tnotesjs/core 0.1.28 → 0.2.1

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/dist/{chunk-4KG6RXFS.js → chunk-5QN44ES5.js} +2946 -1533
  3. package/dist/{chunk-AGSL3VZE.js → chunk-5Y5IYS6C.js} +7 -0
  4. package/dist/cli/index.js +343 -40
  5. package/dist/index.js +1 -1
  6. package/dist/vitepress/config/index.js +573 -65
  7. package/package.json +4 -1
  8. package/templates/sub-repo/.gitattributes +30 -0
  9. package/templates/sub-repo/.github/workflows/deploy.yml +87 -0
  10. package/templates/sub-repo/.vitepress/config.mts +12 -0
  11. package/templates/sub-repo/.vitepress/env.d.ts +10 -0
  12. package/templates/sub-repo/.vitepress/theme/index.ts +12 -0
  13. package/templates/sub-repo/.vscode/settings.json +330 -0
  14. package/templates/sub-repo/.vscode/tnotes.code-snippets +110 -0
  15. package/templates/sub-repo/public/favicon.ico +0 -0
  16. package/templates/sub-repo/public/logo.png +0 -0
  17. package/templates/sub-repo/tsconfig.json +45 -0
  18. package/vitepress/assets/icons/icon__setting_dark.svg +4 -0
  19. package/vitepress/assets/icons/icon__setting_light.svg +4 -0
  20. package/vitepress/assets/icons/index.ts +2 -2
  21. package/vitepress/client/localSearchIndexBridge.ts +24 -0
  22. package/vitepress/components/Discussions/Discussions.vue +1 -1
  23. package/vitepress/components/Footprints/Footprints.vue +90 -25
  24. package/vitepress/components/Layout/CustomSidebar.vue +489 -117
  25. package/vitepress/components/Layout/Layout.vue +5 -0
  26. package/vitepress/components/Layout/NavBarSettingsTrigger.vue +10 -2
  27. package/vitepress/components/Layout/SidebarItems.vue +517 -342
  28. package/vitepress/components/Layout/SidebarNavBefore.vue +1 -50
  29. package/vitepress/components/Layout/SidebarResizeHandle.vue +6 -23
  30. package/vitepress/components/Layout/composables/docLayoutLogic.ts +59 -0
  31. package/vitepress/components/Layout/composables/useDocLayout.test.ts +69 -0
  32. package/vitepress/components/Layout/composables/useDocLayout.ts +128 -0
  33. package/vitepress/components/Layout/composables/useSidebarDrag.ts +514 -0
  34. package/vitepress/components/Layout/composables/useSidebarLayout.ts +25 -8
  35. package/vitepress/components/Layout/composables/useVSCodeIntegration.ts +8 -3
  36. package/vitepress/components/Layout/sidebarCollapsedState.test.ts +183 -0
  37. package/vitepress/components/Layout/sidebarCollapsedState.ts +241 -0
  38. package/vitepress/components/Layout/sidebarDragLogic.test.ts +1146 -0
  39. package/vitepress/components/Layout/sidebarDragLogic.ts +1307 -0
  40. package/vitepress/components/Layout/sidebarHitTest.test.ts +572 -0
  41. package/vitepress/components/Layout/sidebarHitTest.ts +801 -0
  42. package/vitepress/components/Layout/sidebarItemRenderLogic.test.ts +53 -0
  43. package/vitepress/components/Layout/sidebarItemRenderLogic.ts +50 -0
  44. package/vitepress/components/MarkMap/MarkMap.vue +67 -10
  45. package/vitepress/components/Settings/Settings.vue +78 -74
  46. package/vitepress/components/SidebarCard/FolderTreeItems.vue +268 -0
  47. package/vitepress/components/SidebarCard/SidebarCard.vue +125 -274
  48. package/vitepress/components/constants.ts +18 -6
  49. package/vitepress/components/sidebar.data.ts +13 -11
  50. package/vitepress/components/sidebarTreeHelpers.test.ts +142 -0
  51. package/vitepress/components/sidebarTreeHelpers.ts +168 -0
  52. package/vitepress/components/utils/vscodePaths.test.ts +41 -0
  53. package/vitepress/components/utils/vscodePaths.ts +52 -0
  54. package/vitepress/config/index.ts +2 -0
  55. package/vitepress/plugins/localSearchIndexBuilder.ts +209 -0
  56. package/vitepress/plugins/localSearchReindex.test.ts +46 -0
  57. package/vitepress/plugins/localSearchReindexLogic.ts +50 -0
  58. package/vitepress/plugins/localSearchReindexPatch.test.ts +30 -0
  59. package/vitepress/plugins/localSearchReindexPatch.ts +37 -0
  60. package/vitepress/plugins/localSearchReindexPlugin.ts +318 -0
  61. package/vitepress/plugins/sidebarStructurePlugin.ts +209 -63
  62. package/vitepress/theme/index.ts +8 -0
  63. package/vitepress/theme/styles/base.scss +0 -8
  64. package/vitepress/theme/styles/components/markmap.scss +23 -2
  65. package/vitepress/theme/styles/components/swiper.scss +1 -1
  66. package/vitepress/theme/styles/doc-layout.scss +212 -0
  67. package/vitepress/theme/styles/index.scss +3 -0
  68. package/vitepress/theme/styles/layout.scss +60 -10
  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,1146 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ applyMoveInSidebarTree,
5
+ applyMoveInSidebarTreeByTocLine,
6
+ buildSidebarDragPreviewTree,
7
+ depthFromClientX,
8
+ flattenVisibleSidebar,
9
+ getDropProjection,
10
+ getSubtreeDepthSpan,
11
+ isDescendantInTree,
12
+ isInvalidDrop,
13
+ isTailAfterDrop,
14
+ SIDEBAR_ARROW_WIDTH,
15
+ SIDEBAR_ROW_BASE_PADDING,
16
+ toReorderPayload,
17
+ type DragSource,
18
+ type SidebarTreeItem,
19
+ } from './sidebarDragLogic'
20
+
21
+ function note(
22
+ index: string,
23
+ title: string,
24
+ children: SidebarTreeItem[] = [],
25
+ collapsed = false,
26
+ tocLineIndex = 0,
27
+ ): SidebarTreeItem {
28
+ return {
29
+ text: `${index}. ${title}`,
30
+ link: `/notes/${index}. ${title}/README`,
31
+ items: children.length ? children : undefined,
32
+ collapsed,
33
+ tocLineIndex,
34
+ }
35
+ }
36
+
37
+ function dragNote(index: string, tocLineIndex: number): DragSource {
38
+ return { kind: 'note', noteIndex: index, tocLineIndex }
39
+ }
40
+
41
+ const sampleTree: SidebarTreeItem[] = [
42
+ note('0001', 'A', [
43
+ note('0002', 'A1', [], false, 1),
44
+ note('0003', 'A2', [], false, 2),
45
+ ], false, 0),
46
+ note('0004', 'B', [], false, 3),
47
+ ]
48
+
49
+ const config = { maxDepth: 3, indentSize: 24, draggedSubtreeDepthSpan: 0 }
50
+ const navLeft = 100
51
+
52
+ function project(
53
+ dragNoteIndex: string,
54
+ dragTocLineIndex: number,
55
+ overIndex: number,
56
+ verticalPlacement: 'before' | 'after',
57
+ offsetX = 0,
58
+ clientX = navLeft + SIDEBAR_ROW_BASE_PADDING + SIDEBAR_ARROW_WIDTH,
59
+ ) {
60
+ const flat = flattenVisibleSidebar(sampleTree)
61
+ return getDropProjection(
62
+ sampleTree,
63
+ flat,
64
+ dragNote(dragNoteIndex, dragTocLineIndex),
65
+ overIndex,
66
+ verticalPlacement,
67
+ config,
68
+ { offsetX, clientX, navLeft },
69
+ )
70
+ }
71
+
72
+ describe('sidebarDragLogic', () => {
73
+ it('flattenVisibleSidebar respects collapsed state', () => {
74
+ const tree = [
75
+ note('0001', 'A', [note('0002', 'A1', [], false, 1)], true, 0),
76
+ ]
77
+ const flat = flattenVisibleSidebar(tree)
78
+ expect(flat.map((row) => row.noteIndex)).toEqual(['0001'])
79
+ })
80
+
81
+ it('flattenVisibleSidebar expands nested items', () => {
82
+ const flat = flattenVisibleSidebar(sampleTree)
83
+ expect(flat.map((row) => row.noteIndex)).toEqual([
84
+ '0001',
85
+ '0002',
86
+ '0003',
87
+ '0004',
88
+ ])
89
+ expect(flat[2].depth).toBe(1)
90
+ })
91
+
92
+ it('flattenVisibleSidebar includes empty pure folder with folderPath', () => {
93
+ const tree: SidebarTreeItem[] = [
94
+ {
95
+ text: '评论功能的技术实现(Giscus)',
96
+ folderPath: ['评论功能的技术实现(Giscus)'],
97
+ items: [],
98
+ collapsed: true,
99
+ tocLineIndex: 20,
100
+ },
101
+ ]
102
+ const flat = flattenVisibleSidebar(tree)
103
+ expect(flat).toHaveLength(1)
104
+ expect(flat[0]).toMatchObject({
105
+ kind: 'folder',
106
+ folderPath: ['评论功能的技术实现(Giscus)'],
107
+ hasChildren: false,
108
+ collapsed: true,
109
+ tocLineIndex: 20,
110
+ })
111
+ })
112
+
113
+ it('isDescendantInTree detects child targets', () => {
114
+ expect(isDescendantInTree(sampleTree, '0001', '0002')).toBe(true)
115
+ expect(isDescendantInTree(sampleTree, '0001', '0004')).toBe(false)
116
+ })
117
+
118
+ it('isTailAfterDrop detects middle vs tail rows', () => {
119
+ const flat = flattenVisibleSidebar(sampleTree)
120
+
121
+ expect(isTailAfterDrop(flat, 1, 'after')).toBe(false)
122
+ expect(isTailAfterDrop(flat, 2, 'after')).toBe(true)
123
+ expect(isTailAfterDrop(flat, 2, 'before')).toBe(false)
124
+ expect(isTailAfterDrop(flat, 3, 'after')).toBe(true)
125
+ expect(isTailAfterDrop(flat, 1, 'after', '0003')).toBe(true)
126
+
127
+ const collapsedAdjacent: SidebarTreeItem[] = [
128
+ note('0019', 'Parent', [note('0035', 'C', [], false, 1)], true, 0),
129
+ note('0039', 'Sibling', [], false, 2),
130
+ ]
131
+ const collapsedFlat = flattenVisibleSidebar(collapsedAdjacent)
132
+ expect(isTailAfterDrop(collapsedFlat, 0, 'after', '0039')).toBe(false)
133
+ })
134
+
135
+ it('depthFromClientX maps pointer to depth levels', () => {
136
+ const base = navLeft + SIDEBAR_ROW_BASE_PADDING + SIDEBAR_ARROW_WIDTH
137
+ expect(depthFromClientX(base, navLeft, 24, 2)).toBe(0)
138
+ expect(depthFromClientX(base + 24, navLeft, 24, 2)).toBe(1)
139
+ expect(depthFromClientX(base + 48, navLeft, 24, 2)).toBe(2)
140
+ })
141
+
142
+ it('getDropProjection supports before/after on same level', () => {
143
+ const base = navLeft + SIDEBAR_ROW_BASE_PADDING + SIDEBAR_ARROW_WIDTH
144
+ const before = project('0003', 2, 3, 'before')
145
+ expect(before?.target).toMatchObject({
146
+ targetNoteIndex: '0004',
147
+ placement: 'before',
148
+ projectedDepth: 0,
149
+ })
150
+ expect(before?.indicator.mode).toBe('line')
151
+
152
+ const after = project('0003', 2, 1, 'after', 0, base)
153
+ expect(after?.target).toMatchObject({
154
+ targetNoteIndex: '0001',
155
+ placement: 'after',
156
+ projectedDepth: 0,
157
+ })
158
+ expect(after?.indicator.mode).toBe('tail-rail')
159
+ })
160
+
161
+ it('getDropProjection reorders middle child via sibling before line', () => {
162
+ const reorder = project('0002', 1, 2, 'before')
163
+ expect(reorder?.target).toMatchObject({
164
+ targetNoteIndex: '0003',
165
+ placement: 'before',
166
+ projectedDepth: 1,
167
+ })
168
+ expect(reorder?.indicator.mode).toBe('line')
169
+ })
170
+
171
+ it('getDropProjection outdents last child when tail rail selects parent depth', () => {
172
+ const flat = flattenVisibleSidebar(sampleTree)
173
+ const base = navLeft + SIDEBAR_ROW_BASE_PADDING + SIDEBAR_ARROW_WIDTH
174
+ const leftDrag = getDropProjection(
175
+ sampleTree,
176
+ flat,
177
+ dragNote('0003', 2),
178
+ 1,
179
+ 'after',
180
+ config,
181
+ {
182
+ offsetX: -48,
183
+ clientX: base,
184
+ navLeft,
185
+ },
186
+ )
187
+ expect(leftDrag?.target).toMatchObject({
188
+ targetNoteIndex: '0001',
189
+ placement: 'after',
190
+ projectedDepth: 0,
191
+ })
192
+ expect(leftDrag?.indicator.mode).toBe('tail-rail')
193
+ })
194
+
195
+ it('getDropProjection supports inside when offset to the right on non-tail row', () => {
196
+ const inside = project('0004', 3, 0, 'after', 30)
197
+ expect(inside?.target).toMatchObject({
198
+ targetNoteIndex: '0001',
199
+ placement: 'inside',
200
+ projectedDepth: 1,
201
+ })
202
+ expect(inside?.indicator.mode).toBe('inside')
203
+ })
204
+
205
+ it('getDropProjection uses tail-rail on last child after for same-folder reorder', () => {
206
+ const base = navLeft + SIDEBAR_ROW_BASE_PADDING + SIDEBAR_ARROW_WIDTH
207
+ const flat = flattenVisibleSidebar(sampleTree)
208
+
209
+ const tailSameDepth = getDropProjection(
210
+ sampleTree,
211
+ flat,
212
+ dragNote('0002', 1),
213
+ 2,
214
+ 'after',
215
+ config,
216
+ { offsetX: 0, clientX: base + 24, navLeft },
217
+ )
218
+ expect(tailSameDepth?.indicator.mode).toBe('tail-rail')
219
+ expect(tailSameDepth?.indicator.depthLevels).toEqual([0, 1])
220
+ expect(tailSameDepth?.target).toMatchObject({
221
+ targetNoteIndex: '0003',
222
+ placement: 'after',
223
+ projectedDepth: 1,
224
+ })
225
+
226
+ const tailOutdent = getDropProjection(
227
+ sampleTree,
228
+ flat,
229
+ dragNote('0004', 3),
230
+ 2,
231
+ 'after',
232
+ config,
233
+ { offsetX: 0, clientX: base, navLeft },
234
+ )
235
+ expect(tailOutdent?.indicator.mode).toBe('tail-rail')
236
+ expect(tailOutdent?.target).toMatchObject({
237
+ targetNoteIndex: '0001',
238
+ placement: 'after',
239
+ projectedDepth: 0,
240
+ })
241
+ })
242
+
243
+ it('getDropProjection routes external drag into parent via tail or right offset', () => {
244
+ const folderTree: SidebarTreeItem[] = [
245
+ note('0001', 'Parent', [
246
+ note('0002', 'C1', [], false, 1),
247
+ note('0003', 'C2', [], false, 2),
248
+ ], false, 0),
249
+ note('0004', 'External', [], false, 3),
250
+ ]
251
+ const flat = flattenVisibleSidebar(folderTree)
252
+ const base = navLeft + SIDEBAR_ROW_BASE_PADDING + SIDEBAR_ARROW_WIDTH
253
+ const external = dragNote('0004', 3)
254
+
255
+ const onParentRow = getDropProjection(
256
+ folderTree,
257
+ flat,
258
+ external,
259
+ 0,
260
+ 'after',
261
+ config,
262
+ { offsetX: 0, clientX: base, navLeft },
263
+ )
264
+ expect(onParentRow?.target).toMatchObject({
265
+ targetNoteIndex: '0001',
266
+ placement: 'inside',
267
+ projectedDepth: 1,
268
+ })
269
+
270
+ const onParentRowInside = getDropProjection(
271
+ folderTree,
272
+ flat,
273
+ external,
274
+ 0,
275
+ 'after',
276
+ config,
277
+ { offsetX: 24, clientX: base + 24, navLeft },
278
+ )
279
+ expect(onParentRowInside?.target).toMatchObject({
280
+ targetNoteIndex: '0001',
281
+ placement: 'inside',
282
+ projectedDepth: 1,
283
+ })
284
+
285
+ const onLastChildTail = getDropProjection(
286
+ folderTree,
287
+ flat,
288
+ external,
289
+ 2,
290
+ 'after',
291
+ config,
292
+ { offsetX: 0, clientX: base, navLeft },
293
+ )
294
+ expect(onLastChildTail?.target).toMatchObject({
295
+ targetNoteIndex: '0001',
296
+ placement: 'after',
297
+ projectedDepth: 0,
298
+ })
299
+
300
+ const onLastChildTailAppend = getDropProjection(
301
+ folderTree,
302
+ flat,
303
+ external,
304
+ 2,
305
+ 'after',
306
+ config,
307
+ { offsetX: 0, clientX: base + 24, navLeft },
308
+ )
309
+ expect(onLastChildTailAppend?.target).toMatchObject({
310
+ targetNoteIndex: '0003',
311
+ placement: 'after',
312
+ projectedDepth: 1,
313
+ })
314
+
315
+ const reorderWithinFolder = getDropProjection(
316
+ folderTree,
317
+ flat,
318
+ dragNote('0002', 1),
319
+ 2,
320
+ 'after',
321
+ config,
322
+ { offsetX: 0, clientX: base + 24, navLeft },
323
+ )
324
+ expect(reorderWithinFolder?.target).toMatchObject({
325
+ targetNoteIndex: '0003',
326
+ placement: 'after',
327
+ projectedDepth: 1,
328
+ })
329
+ })
330
+
331
+ it('getDropProjection lets external note drop as sibling below expanded parent', () => {
332
+ const tree: SidebarTreeItem[] = [
333
+ note('0019', 'Author', [
334
+ note('0036', 'Workflow', [], false, 1),
335
+ note('0035', 'Norm', [], false, 2),
336
+ ], false, 0),
337
+ note('0039', 'External', [], false, 3),
338
+ ]
339
+ const flat = flattenVisibleSidebar(tree)
340
+ const base = navLeft + SIDEBAR_ROW_BASE_PADDING + SIDEBAR_ARROW_WIDTH
341
+ const parentIndex = flat.findIndex((row) => row.noteIndex === '0019')
342
+ const lastChildIndex = flat.findIndex((row) => row.noteIndex === '0035')
343
+ const external = dragNote('0039', 3)
344
+
345
+ const belowParentRow = getDropProjection(
346
+ tree,
347
+ flat,
348
+ external,
349
+ parentIndex,
350
+ 'after',
351
+ config,
352
+ { offsetX: 0, clientX: base, navLeft },
353
+ )
354
+ expect(belowParentRow?.target).toMatchObject({
355
+ targetNoteIndex: '0019',
356
+ placement: 'inside',
357
+ projectedDepth: 1,
358
+ })
359
+
360
+ const belowParentTail = getDropProjection(
361
+ tree,
362
+ flat,
363
+ external,
364
+ lastChildIndex,
365
+ 'after',
366
+ config,
367
+ { offsetX: 0, clientX: base, navLeft },
368
+ )
369
+ expect(belowParentTail?.target).toMatchObject({
370
+ targetNoteIndex: '0019',
371
+ placement: 'after',
372
+ projectedDepth: 0,
373
+ })
374
+
375
+ const appendToFolderEnd = getDropProjection(
376
+ tree,
377
+ flat,
378
+ external,
379
+ lastChildIndex,
380
+ 'after',
381
+ config,
382
+ { offsetX: 0, clientX: base + 24, navLeft },
383
+ )
384
+ expect(appendToFolderEnd?.target).toMatchObject({
385
+ targetNoteIndex: '0035',
386
+ placement: 'after',
387
+ projectedDepth: 1,
388
+ })
389
+ })
390
+
391
+ it('getDropProjection drops non-adjacent sibling into collapsed folder on parent row', () => {
392
+ const tree: SidebarTreeItem[] = [
393
+ note('0019', 'Author', [note('0035', 'Norm', [], false, 1)], true, 0),
394
+ note('0004', 'Other', [], false, 2),
395
+ note('0039', 'new', [], false, 3),
396
+ ]
397
+ const flat = flattenVisibleSidebar(tree)
398
+ const base = navLeft + SIDEBAR_ROW_BASE_PADDING + SIDEBAR_ARROW_WIDTH
399
+ const parentIndex = flat.findIndex((row) => row.noteIndex === '0019')
400
+
401
+ expect(flat.map((row) => row.noteIndex)).toEqual(['0019', '0004', '0039'])
402
+
403
+ const inside = getDropProjection(
404
+ tree,
405
+ flat,
406
+ dragNote('0039', 3),
407
+ parentIndex,
408
+ 'after',
409
+ config,
410
+ { offsetX: 0, clientX: base, navLeft },
411
+ )
412
+ expect(inside?.target).toMatchObject({
413
+ targetNoteIndex: '0019',
414
+ placement: 'inside',
415
+ projectedDepth: 1,
416
+ })
417
+ })
418
+
419
+ it('flattenVisibleSidebar force-collapses dragged subtree during preview', () => {
420
+ const tree: SidebarTreeItem[] = [
421
+ note('0019', 'Author', [note('0035', 'Norm', [], false, 1)], false, 0),
422
+ ]
423
+ const flat = flattenVisibleSidebar(tree, 0, null, null, {
424
+ forceCollapsedTocLineIndex: 0,
425
+ })
426
+ expect(flat.map((row) => row.noteIndex)).toEqual(['0019'])
427
+ })
428
+
429
+ it('buildSidebarDragPreviewTree keeps source slot in place', () => {
430
+ const tree: SidebarTreeItem[] = [
431
+ note('0034', 'A', [], false, 0),
432
+ note('0035', 'B', [], false, 1),
433
+ ]
434
+
435
+ const preview = buildSidebarDragPreviewTree(tree, dragNote('0034', 0), null)
436
+
437
+ expect(extractIndexes(preview)).toEqual(['0034', '0035'])
438
+ expect(preview[0].isDragSourceSlot).toBe(true)
439
+ expect(preview[1].isDragSourceSlot).toBeFalsy()
440
+ })
441
+
442
+ it('buildSidebarDragPreviewTree prepends inside folder when intent is prependChild', () => {
443
+ const tree: SidebarTreeItem[] = [
444
+ {
445
+ text: 'Group',
446
+ folderPath: ['Group'],
447
+ nodeId: 'folder:Group',
448
+ tocLineIndex: 0,
449
+ collapsed: false,
450
+ items: [
451
+ note('0037', 'A', [], false, 1),
452
+ note('0027', 'B', [], false, 2),
453
+ ],
454
+ },
455
+ ]
456
+
457
+ const preview = buildSidebarDragPreviewTree(tree, dragNote('0027', 2), {
458
+ action: 'prependChild',
459
+ targetNodeId: 'folder:Group',
460
+ sourceNodeId: 'note:0027',
461
+ indicator: { mode: 'inside', depthLevels: [0], activeDepth: 0 },
462
+ linePlacement: 'before',
463
+ hoverRowIndex: 1,
464
+ })
465
+
466
+ expect(preview[0].items?.[0].isDragPlaceholder).toBe(true)
467
+ expect(preview[0].items?.[1].link).toContain('0037')
468
+ expect(preview[0].items?.some((item) => item.link?.includes('0027'))).toBe(
469
+ false,
470
+ )
471
+ })
472
+
473
+ it('buildSidebarDragPreviewTree prepends placeholder at root when intent is root prepend', () => {
474
+ const tree: SidebarTreeItem[] = [
475
+ note('0034', 'A', [], false, 0),
476
+ note('0035', 'B', [], false, 1),
477
+ note('0036', 'C', [], false, 2),
478
+ ]
479
+
480
+ const preview = buildSidebarDragPreviewTree(tree, dragNote('0036', 2), {
481
+ action: 'prependChild',
482
+ targetNodeId: null,
483
+ sourceNodeId: 'note:0036',
484
+ isRootPrepend: true,
485
+ indicator: { mode: 'root-prepend', depthLevels: [0], activeDepth: 0 },
486
+ linePlacement: 'before',
487
+ hoverRowIndex: 0,
488
+ })
489
+
490
+ expect(extractIndexes(preview)).toEqual(['0034', '0035'])
491
+ expect(preview[0].isDragPlaceholder).toBe(true)
492
+ })
493
+
494
+ it('buildSidebarDragPreviewTree reorders siblings when intent is moveAfter', () => {
495
+ const tree: SidebarTreeItem[] = [
496
+ note('0034', 'A', [], false, 0),
497
+ note('0035', 'B', [], false, 1),
498
+ note('0036', 'C', [], false, 2),
499
+ ]
500
+
501
+ const preview = buildSidebarDragPreviewTree(tree, dragNote('0034', 0), {
502
+ action: 'moveAfter',
503
+ targetNodeId: 'note:0035',
504
+ sourceNodeId: 'note:0034',
505
+ indicator: { mode: 'line', depthLevels: [0], activeDepth: 0 },
506
+ linePlacement: 'after',
507
+ hoverRowIndex: 1,
508
+ })
509
+
510
+ expect(extractIndexes(preview)).toEqual(['0035', '0036'])
511
+ expect(preview.some((item) => item.isDragPlaceholder)).toBe(true)
512
+ })
513
+
514
+ it('buildSidebarDragPreviewTree keeps source slot when intent is noop', () => {
515
+ const tree: SidebarTreeItem[] = [
516
+ note('0034', 'A', [], false, 0),
517
+ note('0035', 'B', [], false, 1),
518
+ ]
519
+
520
+ const preview = buildSidebarDragPreviewTree(tree, dragNote('0034', 0), {
521
+ action: 'moveAfter',
522
+ targetNodeId: 'note:0034',
523
+ sourceNodeId: 'note:0034',
524
+ indicator: { mode: 'line', depthLevels: [0], activeDepth: 0 },
525
+ linePlacement: 'after',
526
+ hoverRowIndex: 0,
527
+ })
528
+ expect(preview[0].isDragSourceSlot).toBe(true)
529
+ })
530
+
531
+ it('applyMoveInSidebarTreeByTocLine reorders siblings during drag preview', () => {
532
+ const tree: SidebarTreeItem[] = [
533
+ note('0034', 'A', [], false, 0),
534
+ note('0035', 'B', [], false, 1),
535
+ ]
536
+
537
+ const moved = applyMoveInSidebarTreeByTocLine(
538
+ tree,
539
+ dragNote('0034', 0),
540
+ {
541
+ targetNoteIndex: '0035',
542
+ targetTocLineIndex: 1,
543
+ placement: 'before',
544
+ projectedDepth: 0,
545
+ },
546
+ )
547
+
548
+ expect(extractIndexes(moved)).toEqual(['0034', '0035'])
549
+ })
550
+
551
+ it('applyMoveInSidebarTreeByTocLine moves note inside collapsed parent as first child', () => {
552
+ const tree: SidebarTreeItem[] = [
553
+ note('0019', 'Author', [note('0035', 'Norm', [], false, 1)], true, 0),
554
+ note('0039', 'new', [], false, 2),
555
+ ]
556
+
557
+ const moved = applyMoveInSidebarTreeByTocLine(
558
+ tree,
559
+ dragNote('0039', 2),
560
+ {
561
+ targetNoteIndex: '0019',
562
+ targetTocLineIndex: 0,
563
+ placement: 'inside',
564
+ projectedDepth: 1,
565
+ },
566
+ )
567
+
568
+ expect(extractIndexes(moved)).toEqual(['0019', '0039', '0035'])
569
+ expect(moved[0].collapsed).toBe(false)
570
+ })
571
+
572
+ it('getDropProjection drops adjacent sibling into collapsed folder on parent row', () => {
573
+ const tree: SidebarTreeItem[] = [
574
+ note('0019', 'Author', [note('0035', 'Norm', [], false, 1)], true, 0),
575
+ note('0039', 'new', [], false, 2),
576
+ ]
577
+ const flat = flattenVisibleSidebar(tree)
578
+ const base = navLeft + SIDEBAR_ROW_BASE_PADDING + SIDEBAR_ARROW_WIDTH
579
+ const parentIndex = flat.findIndex((row) => row.noteIndex === '0019')
580
+
581
+ expect(flat.map((row) => row.noteIndex)).toEqual(['0019', '0039'])
582
+
583
+ const inside = getDropProjection(
584
+ tree,
585
+ flat,
586
+ dragNote('0039', 2),
587
+ parentIndex,
588
+ 'after',
589
+ config,
590
+ { offsetX: 0, clientX: base, navLeft },
591
+ )
592
+ expect(inside?.target).toMatchObject({
593
+ targetNoteIndex: '0019',
594
+ placement: 'inside',
595
+ projectedDepth: 1,
596
+ })
597
+ expect(inside?.indicator.mode).toBe('inside')
598
+ })
599
+
600
+ it('getDropProjection appends external note to folder end after last child', () => {
601
+ const tree: SidebarTreeItem[] = [
602
+ note('0019', 'Author', [
603
+ note('0036', 'Workflow', [], false, 1),
604
+ note('0035', 'Norm', [], false, 2),
605
+ ], false, 0),
606
+ note('0039', 'new', [], false, 3),
607
+ ]
608
+ const flat = flattenVisibleSidebar(tree)
609
+ const base = navLeft + SIDEBAR_ROW_BASE_PADDING + SIDEBAR_ARROW_WIDTH
610
+ const lastChildIndex = flat.findIndex((row) => row.noteIndex === '0035')
611
+
612
+ const append = getDropProjection(
613
+ tree,
614
+ flat,
615
+ dragNote('0039', 3),
616
+ lastChildIndex,
617
+ 'after',
618
+ config,
619
+ { offsetX: 0, clientX: base + 24, navLeft },
620
+ )
621
+ expect(append?.indicator.mode).toBe('tail-rail')
622
+ expect(append?.target).toMatchObject({
623
+ targetNoteIndex: '0035',
624
+ placement: 'after',
625
+ projectedDepth: 1,
626
+ })
627
+ })
628
+
629
+ it('getDropProjection outdents last child via previous sibling tail at parent depth', () => {
630
+ const tree: SidebarTreeItem[] = [
631
+ note('0019', 'Author', [
632
+ note('0035', 'Norm', [], false, 1),
633
+ note('0039', 'new', [], false, 2),
634
+ ], false, 0),
635
+ ]
636
+ const flat = flattenVisibleSidebar(tree)
637
+ const base = navLeft + SIDEBAR_ROW_BASE_PADDING + SIDEBAR_ARROW_WIDTH
638
+ const prevSiblingIndex = flat.findIndex((row) => row.noteIndex === '0035')
639
+ const dragging = dragNote('0039', 2)
640
+
641
+ const outdent = getDropProjection(
642
+ tree,
643
+ flat,
644
+ dragging,
645
+ prevSiblingIndex,
646
+ 'after',
647
+ config,
648
+ { offsetX: 0, clientX: base, navLeft },
649
+ )
650
+ expect(outdent?.indicator.mode).toBe('tail-rail')
651
+ expect(outdent?.target).toMatchObject({
652
+ targetNoteIndex: '0019',
653
+ placement: 'after',
654
+ projectedDepth: 0,
655
+ })
656
+
657
+ const reorder = getDropProjection(
658
+ tree,
659
+ flat,
660
+ dragging,
661
+ prevSiblingIndex,
662
+ 'after',
663
+ config,
664
+ { offsetX: 0, clientX: base + 24, navLeft },
665
+ )
666
+ expect(reorder?.target).toMatchObject({
667
+ targetNoteIndex: '0035',
668
+ placement: 'after',
669
+ projectedDepth: 1,
670
+ })
671
+ })
672
+
673
+ it('getDropProjection drops adjacent sibling into collapsed pure folder', () => {
674
+ function folder(
675
+ title: string,
676
+ children: SidebarTreeItem[] = [],
677
+ collapsed = false,
678
+ tocLineIndex?: number,
679
+ ): SidebarTreeItem {
680
+ return {
681
+ text: title,
682
+ folderPath: [title],
683
+ tocLineIndex,
684
+ items: children.length ? children : undefined,
685
+ collapsed,
686
+ }
687
+ }
688
+
689
+ const tree: SidebarTreeItem[] = [
690
+ folder('Author', [note('0035', 'Norm', [], false, 13)], true, 12),
691
+ note('0039', 'new', [], false, 14),
692
+ ]
693
+ const flat = flattenVisibleSidebar(tree)
694
+ const base = navLeft + SIDEBAR_ROW_BASE_PADDING + SIDEBAR_ARROW_WIDTH
695
+ const folderIndex = flat.findIndex((row) => row.kind === 'folder')
696
+
697
+ expect(flat.map((row) => row.noteIndex)).toEqual([null, '0039'])
698
+ expect(flat[0].tocLineIndex).toBe(12)
699
+
700
+ const inside = getDropProjection(
701
+ tree,
702
+ flat,
703
+ dragNote('0039', 14),
704
+ folderIndex,
705
+ 'after',
706
+ config,
707
+ { offsetX: 0, clientX: base, navLeft },
708
+ )
709
+ expect(inside?.target).toMatchObject({
710
+ targetFolderPath: ['Author'],
711
+ targetTocLineIndex: 12,
712
+ placement: 'inside',
713
+ projectedDepth: 1,
714
+ })
715
+ expect(inside?.indicator.mode).toBe('inside')
716
+ })
717
+
718
+ it('getDropProjection drops external note into collapsed folder on upper half', () => {
719
+ function folder(
720
+ title: string,
721
+ children: SidebarTreeItem[] = [],
722
+ collapsed = false,
723
+ tocLineIndex?: number,
724
+ ): SidebarTreeItem {
725
+ return {
726
+ text: title,
727
+ folderPath: [title],
728
+ tocLineIndex,
729
+ items: children.length ? children : undefined,
730
+ collapsed,
731
+ }
732
+ }
733
+
734
+ const tree: SidebarTreeItem[] = [
735
+ folder(
736
+ 'TNotes 笔记书写规范',
737
+ [note('0027', 'emoji', [], false, 42), note('0037', 'Writing', [], false, 43)],
738
+ true,
739
+ 41,
740
+ ),
741
+ note('0039', 'new', [], false, 45),
742
+ ]
743
+ const flat = flattenVisibleSidebar(tree)
744
+ const base = navLeft + SIDEBAR_ROW_BASE_PADDING + SIDEBAR_ARROW_WIDTH
745
+ const folderIndex = flat.findIndex((row) => row.kind === 'folder')
746
+
747
+ const inside = getDropProjection(
748
+ tree,
749
+ flat,
750
+ dragNote('0039', 45),
751
+ folderIndex,
752
+ 'before',
753
+ config,
754
+ { offsetX: 0, clientX: base, navLeft },
755
+ )
756
+ expect(inside?.target).toMatchObject({
757
+ targetFolderPath: ['TNotes 笔记书写规范'],
758
+ targetTocLineIndex: 41,
759
+ placement: 'inside',
760
+ projectedDepth: 1,
761
+ })
762
+ expect(inside?.indicator.mode).toBe('inside')
763
+ })
764
+
765
+ it('getDropProjection keeps internal child reorder on expanded folder upper half', () => {
766
+ function folder(
767
+ title: string,
768
+ children: SidebarTreeItem[] = [],
769
+ collapsed = false,
770
+ tocLineIndex?: number,
771
+ ): SidebarTreeItem {
772
+ return {
773
+ text: title,
774
+ folderPath: [title],
775
+ tocLineIndex,
776
+ items: children.length ? children : undefined,
777
+ collapsed,
778
+ }
779
+ }
780
+
781
+ const tree: SidebarTreeItem[] = [
782
+ folder(
783
+ 'Spec',
784
+ [note('0038', 'A', [], false, 42), note('0037', 'B', [], false, 43)],
785
+ false,
786
+ 41,
787
+ ),
788
+ ]
789
+ const flat = flattenVisibleSidebar(tree)
790
+ const base = navLeft + SIDEBAR_ROW_BASE_PADDING + SIDEBAR_ARROW_WIDTH
791
+ const folderIndex = flat.findIndex((row) => row.kind === 'folder')
792
+
793
+ const before = getDropProjection(
794
+ tree,
795
+ flat,
796
+ dragNote('0037', 43),
797
+ folderIndex,
798
+ 'before',
799
+ config,
800
+ { offsetX: 0, clientX: base, navLeft },
801
+ )
802
+ expect(before?.target).toMatchObject({
803
+ targetFolderPath: ['Spec'],
804
+ placement: 'before',
805
+ })
806
+ expect(before?.indicator.mode).toBe('line')
807
+ })
808
+
809
+ it('getDropProjection drops folder into external leaf note row', () => {
810
+ function folder(
811
+ title: string,
812
+ children: SidebarTreeItem[] = [],
813
+ collapsed = false,
814
+ tocLineIndex?: number,
815
+ ): SidebarTreeItem {
816
+ return {
817
+ text: title,
818
+ folderPath: [title],
819
+ tocLineIndex,
820
+ items: children.length ? children : undefined,
821
+ collapsed,
822
+ }
823
+ }
824
+
825
+ const tree: SidebarTreeItem[] = [
826
+ folder(
827
+ 'TNotes 笔记书写规范',
828
+ [note('0027', 'emoji', [], false, 42)],
829
+ true,
830
+ 41,
831
+ ),
832
+ note('0039', 'new', [], false, 45),
833
+ ]
834
+ const flat = flattenVisibleSidebar(tree)
835
+ const base = navLeft + SIDEBAR_ROW_BASE_PADDING + SIDEBAR_ARROW_WIDTH
836
+ const noteIndex = flat.findIndex((row) => row.noteIndex === '0039')
837
+
838
+ const inside = getDropProjection(
839
+ tree,
840
+ flat,
841
+ { kind: 'folder', tocLineIndex: 41 },
842
+ noteIndex,
843
+ 'after',
844
+ config,
845
+ { offsetX: 0, clientX: base, navLeft },
846
+ )
847
+ expect(inside?.target).toMatchObject({
848
+ targetNoteIndex: '0039',
849
+ targetTocLineIndex: 45,
850
+ placement: 'inside',
851
+ projectedDepth: 1,
852
+ })
853
+ expect(inside?.indicator.mode).toBe('inside')
854
+ })
855
+
856
+ it('getDropProjection drops folder into note row when offsetX is in title area', () => {
857
+ function folder(
858
+ title: string,
859
+ children: SidebarTreeItem[] = [],
860
+ collapsed = false,
861
+ tocLineIndex?: number,
862
+ ): SidebarTreeItem {
863
+ return {
864
+ text: title,
865
+ folderPath: [title],
866
+ tocLineIndex,
867
+ items: children.length ? children : undefined,
868
+ collapsed,
869
+ }
870
+ }
871
+
872
+ const tree: SidebarTreeItem[] = [
873
+ folder(
874
+ 'TNotes 笔记书写规范',
875
+ [note('0027', 'emoji', [], false, 42)],
876
+ true,
877
+ 41,
878
+ ),
879
+ note('0039', 'new', [], false, 45),
880
+ ]
881
+ const flat = flattenVisibleSidebar(tree)
882
+ const base = navLeft + SIDEBAR_ROW_BASE_PADDING + SIDEBAR_ARROW_WIDTH
883
+ const noteIndex = flat.findIndex((row) => row.noteIndex === '0039')
884
+
885
+ const inside = getDropProjection(
886
+ tree,
887
+ flat,
888
+ { kind: 'folder', tocLineIndex: 41 },
889
+ noteIndex,
890
+ 'after',
891
+ config,
892
+ { offsetX: 40, clientX: base + 40, navLeft },
893
+ )
894
+ expect(inside?.target).toMatchObject({
895
+ targetNoteIndex: '0039',
896
+ targetTocLineIndex: 45,
897
+ placement: 'inside',
898
+ projectedDepth: 1,
899
+ })
900
+ expect(inside?.indicator.mode).toBe('inside')
901
+ })
902
+
903
+ it('getDropProjection drops note into external note row when offsetX is in title area', () => {
904
+ const tree: SidebarTreeItem[] = [
905
+ note('0027', 'emoji', [], false, 44),
906
+ note('0039', 'new', [], false, 45),
907
+ ]
908
+ const flat = flattenVisibleSidebar(tree)
909
+ const base = navLeft + SIDEBAR_ROW_BASE_PADDING + SIDEBAR_ARROW_WIDTH
910
+ const targetIndex = flat.findIndex((row) => row.noteIndex === '0027')
911
+
912
+ const inside = getDropProjection(
913
+ tree,
914
+ flat,
915
+ dragNote('0039', 45),
916
+ targetIndex,
917
+ 'after',
918
+ config,
919
+ { offsetX: 40, clientX: base + 40, navLeft },
920
+ )
921
+ expect(inside?.target).toMatchObject({
922
+ targetNoteIndex: '0027',
923
+ targetTocLineIndex: 44,
924
+ placement: 'inside',
925
+ projectedDepth: 1,
926
+ })
927
+ expect(inside?.indicator.mode).toBe('inside')
928
+ })
929
+
930
+ it('getDropProjection keeps sibling before when dragging onto next row with before placement', () => {
931
+ const tree: SidebarTreeItem[] = [
932
+ note('0023', 'git log', [], false, 43),
933
+ note('0027', 'emoji', [], false, 44),
934
+ ]
935
+ const flat = flattenVisibleSidebar(tree)
936
+ const base = navLeft + SIDEBAR_ROW_BASE_PADDING + SIDEBAR_ARROW_WIDTH
937
+ const targetIndex = flat.findIndex((row) => row.noteIndex === '0027')
938
+
939
+ const before = getDropProjection(
940
+ tree,
941
+ flat,
942
+ dragNote('0023', 43),
943
+ targetIndex,
944
+ 'before',
945
+ config,
946
+ { offsetX: 40, clientX: base + 40, navLeft },
947
+ )
948
+ expect(before?.target).toMatchObject({
949
+ targetNoteIndex: '0027',
950
+ targetTocLineIndex: 44,
951
+ placement: 'before',
952
+ projectedDepth: 0,
953
+ })
954
+ })
955
+
956
+ it('getDropProjection drops external note into empty collapsed folder', () => {
957
+ const tree: SidebarTreeItem[] = [
958
+ {
959
+ text: '评论功能的技术实现(Giscus)',
960
+ folderPath: ['评论功能的技术实现(Giscus)'],
961
+ items: [],
962
+ collapsed: true,
963
+ tocLineIndex: 20,
964
+ },
965
+ note('0039', 'new', [], false, 45),
966
+ ]
967
+ const flat = flattenVisibleSidebar(tree)
968
+ const base = navLeft + SIDEBAR_ROW_BASE_PADDING + SIDEBAR_ARROW_WIDTH
969
+ const folderIndex = flat.findIndex((row) => row.kind === 'folder')
970
+
971
+ const inside = getDropProjection(
972
+ tree,
973
+ flat,
974
+ dragNote('0039', 45),
975
+ folderIndex,
976
+ 'after',
977
+ config,
978
+ { offsetX: 0, clientX: base, navLeft },
979
+ )
980
+ expect(inside?.target).toMatchObject({
981
+ targetFolderPath: ['评论功能的技术实现(Giscus)'],
982
+ targetTocLineIndex: 20,
983
+ placement: 'inside',
984
+ projectedDepth: 1,
985
+ })
986
+ expect(inside?.indicator.mode).toBe('inside')
987
+ })
988
+
989
+ it('toReorderPayload prefers targetTocLineIndex for folder inside drops', () => {
990
+ expect(
991
+ toReorderPayload(dragNote('0039', 14), {
992
+ targetFolderPath: ['Author'],
993
+ targetTocLineIndex: 12,
994
+ placement: 'inside',
995
+ projectedDepth: 1,
996
+ }),
997
+ ).toEqual({
998
+ dragType: 'note',
999
+ dragTocLineIndex: 14,
1000
+ noteIndex: '0039',
1001
+ targetType: 'group',
1002
+ targetTocLineIndex: 12,
1003
+ targetFolderPath: ['Author'],
1004
+ placement: 'inside',
1005
+ })
1006
+ })
1007
+
1008
+ it('toReorderPayload emits folder dragType for pure folder source', () => {
1009
+ expect(
1010
+ toReorderPayload(
1011
+ { kind: 'folder', tocLineIndex: 12 },
1012
+ {
1013
+ targetNoteIndex: '0039',
1014
+ targetTocLineIndex: 14,
1015
+ placement: 'after',
1016
+ projectedDepth: 0,
1017
+ },
1018
+ ),
1019
+ ).toEqual({
1020
+ dragType: 'folder',
1021
+ dragTocLineIndex: 12,
1022
+ targetType: 'note',
1023
+ targetTocLineIndex: 14,
1024
+ targetNoteIndex: '0039',
1025
+ placement: 'after',
1026
+ })
1027
+ })
1028
+
1029
+ it('getDropProjection rejects dropping into own subtree', () => {
1030
+ const invalid = project('0001', 0, 1, 'after', 0, navLeft)
1031
+ expect(invalid).toBeNull()
1032
+ })
1033
+
1034
+ it('isInvalidDrop enforces max depth for dragged subtree', () => {
1035
+ const flat = flattenVisibleSidebar(sampleTree)
1036
+ const parent = findNote(sampleTree, '0001')!
1037
+ const span = getSubtreeDepthSpan(parent)
1038
+ expect(span).toBe(1)
1039
+
1040
+ expect(
1041
+ isInvalidDrop(
1042
+ sampleTree,
1043
+ flat,
1044
+ dragNote('0001', 0),
1045
+ { targetNoteIndex: '0004', placement: 'inside', projectedDepth: 2 },
1046
+ { maxDepth: 3, indentSize: 24, draggedSubtreeDepthSpan: span },
1047
+ ),
1048
+ ).toBe(true)
1049
+ })
1050
+
1051
+ it('isInvalidDrop uses deepest indent for inside drops with nested folder subtree', () => {
1052
+ const flat = flattenVisibleSidebar(sampleTree)
1053
+ const target = {
1054
+ targetNoteIndex: '0039',
1055
+ placement: 'inside' as const,
1056
+ projectedDepth: 1,
1057
+ }
1058
+
1059
+ expect(
1060
+ isInvalidDrop(
1061
+ sampleTree,
1062
+ flat,
1063
+ { kind: 'folder', tocLineIndex: 41 },
1064
+ target,
1065
+ { maxDepth: 4, indentSize: 24, draggedSubtreeDepthSpan: 2 },
1066
+ ),
1067
+ ).toBe(false)
1068
+
1069
+ expect(
1070
+ isInvalidDrop(
1071
+ sampleTree,
1072
+ flat,
1073
+ { kind: 'folder', tocLineIndex: 41 },
1074
+ target,
1075
+ { maxDepth: 3, indentSize: 24, draggedSubtreeDepthSpan: 2 },
1076
+ ),
1077
+ ).toBe(true)
1078
+ })
1079
+
1080
+ it('applyMoveInSidebarTree moves note after sibling', () => {
1081
+ const moved = applyMoveInSidebarTree(sampleTree, '0004', {
1082
+ targetNoteIndex: '0001',
1083
+ placement: 'after',
1084
+ projectedDepth: 0,
1085
+ })
1086
+
1087
+ expect(extractIndexes(moved)).toEqual(['0001', '0002', '0003', '0004'])
1088
+ })
1089
+
1090
+ it('applyMoveInSidebarTree moves external note inside parent as first child', () => {
1091
+ const folderTree: SidebarTreeItem[] = [
1092
+ note('0001', 'Parent', [
1093
+ note('0002', 'C1', [], false, 1),
1094
+ note('0003', 'C2', [], false, 2),
1095
+ ], false, 0),
1096
+ note('0004', 'External', [], false, 3),
1097
+ ]
1098
+
1099
+ const moved = applyMoveInSidebarTree(folderTree, '0004', {
1100
+ targetNoteIndex: '0001',
1101
+ placement: 'inside',
1102
+ projectedDepth: 1,
1103
+ })
1104
+
1105
+ expect(extractIndexes(moved)).toEqual(['0001', '0004', '0002', '0003'])
1106
+ })
1107
+
1108
+ it('applyMoveInSidebarTree moves note inside parent', () => {
1109
+ const moved = applyMoveInSidebarTree(sampleTree, '0004', {
1110
+ targetNoteIndex: '0001',
1111
+ placement: 'inside',
1112
+ projectedDepth: 1,
1113
+ })
1114
+
1115
+ expect(extractIndexes(moved)).toEqual(['0001', '0004', '0002', '0003'])
1116
+ expect(moved[0].items?.[0]?.link).toBe('/notes/0004. B/README')
1117
+ })
1118
+ })
1119
+
1120
+ function findNote(
1121
+ items: SidebarTreeItem[],
1122
+ noteIndex: string,
1123
+ ): SidebarTreeItem | null {
1124
+ for (const item of items) {
1125
+ const match = item.link?.match(/\/notes\/(\d{4})\./)
1126
+ if (match?.[1] === noteIndex) return item
1127
+ if (item.items?.length) {
1128
+ const found = findNote(item.items, noteIndex)
1129
+ if (found) return found
1130
+ }
1131
+ }
1132
+ return null
1133
+ }
1134
+
1135
+ function extractIndexes(items: SidebarTreeItem[]): string[] {
1136
+ const result: string[] = []
1137
+ const walk = (nodes: SidebarTreeItem[]) => {
1138
+ for (const node of nodes) {
1139
+ const match = node.link?.match(/\/notes\/(\d{4})\./)
1140
+ if (match) result.push(match[1])
1141
+ if (node.items?.length) walk(node.items)
1142
+ }
1143
+ }
1144
+ walk(items)
1145
+ return result
1146
+ }