返回 slidev
slidesTree.ts
根目录 / packages / vscode / src / views / slidesTree.ts
1 import type { SlidevMarkdown, SourceSlideInfo } from '@slidev/types'
2 import type { TreeViewNode } from 'reactive-vscode'
3 import { isDeepEqual } from '@antfu/utils'
4 import { stringify } from '@slidev/parser/core'
5 import { computed, defineService, shallowRef, useTreeView, watch, watchEffect } from 'reactive-vscode'
6 import { DataTransferItem, ThemeIcon, TreeItemCollapsibleState, Uri, window, workspace } from 'vscode'
7 import { useFocusedSlide } from '../composables/useFocusedSlide'
8 import { activeData } from '../projects'
9 import { getSlidesTitle } from '../utils/getSlidesTitle'
10 import { toRelativePath } from '../utils/toRelativePath'
11
12 export const slideMineType = 'application/slidev.slide'
13
14 const layoutIconMap = {
15 'center': 'symbol-constant', // TODO: a better icon
16 'cover': 'home',
17 'default': 'window',
18 'end': 'primitive-square',
19 'fact': 'comment',
20 'full': 'screen-full',
21 'iframe-left': 'layout-sidebar-left',
22 'iframe-right': 'layout-sidebar-right',
23 'iframe': 'globe',
24 'image-left': 'layout-sidebar-left',
25 'image-right': 'layout-sidebar-right',
26 'image': 'file-media', // TODO: a better icon
27 'intro': 'debug-step-into',
28 'outro': 'debug-step-out',
29 'none': 'layout-statusbar',
30 'quote': 'quote',
31 'section': 'symbol-module', // TODO: a better icon
32 'statement': 'megaphone',
33 'two-cols-header': 'symbol-struct',
34 'two-cols': 'split-horizontal',
35 } as Record<string, string>
36
37 export interface SlidesTreeNode extends TreeViewNode {
38 markdownPath: string
39 slideIndex: number
40 readonly children?: this[]
41 }
42
43 export const useSlidesTree = defineService(() => {
44 const { focusedSourceSlide, gotoSlide } = useFocusedSlide()
45
46 const treeData = computed(() => {
47 const data = activeData.value
48 if (!data)
49 return null
50
51 const sourceToNode = new Map<string, SlidesTreeNode>()
52 const createNode = (slide: SourceSlideInfo) => {
53 const isFirstSlide = data.entry.slides.findIndex(s => s === slide) === 0
54 const layoutName = slide.frontmatter.layout || (isFirstSlide ? 'cover' : 'default')
55 const slideNo = slide.imports ? 0 : data.slides.findIndex(s => s.source === slide) + 1
56 const label = slide.imports ? '' : `${slideNo}. ${slide.title || '(Untitled)'}`
57 const description = slide.imports ? toRelativePath(slide.imports[0].filepath) : undefined
58 const icon = slide.imports ? 'link-external' : layoutIconMap[layoutName] ?? 'window'
59 const collapsibleState = slide.imports ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.None
60
61 const node: SlidesTreeNode = {
62 markdownPath: slide.filepath,
63 slideIndex: slide.index,
64 children: slide.imports?.map(createNode),
65 treeItem: {
66 label,
67 description,
68 iconPath: new ThemeIcon(icon),
69 collapsibleState,
70 command: {
71 command: 'slidev.goto',
72 title: 'Goto',
73 arguments: [slide.filepath, slide.index],
74 },
75 },
76 }
77 sourceToNode.set(`${slide.filepath}:${slide.index}`, node)
78 return node
79 }
80 return {
81 items: data.entry.slides.map(createNode),
82 sourceToNode,
83 }
84 })
85
86 const treeItems = shallowRef<SlidesTreeNode[]>([])
87 const sourceToNode = shallowRef(new Map<string, SlidesTreeNode>())
88 watch(treeData, (treeData) => {
89 const newItems = treeData?.items || []
90 if (!isDeepEqual(treeItems.value, newItems)) {
91 treeItems.value = newItems
92 sourceToNode.value = treeData?.sourceToNode || new Map()
93 }
94 }, { immediate: true })
95
96 const treeView = useTreeView(
97 'slidev-slides-tree',
98 treeItems,
99 {
100 canSelectMany: true,
101 dragAndDropController: {
102 dragMimeTypes: [slideMineType],
103 dropMimeTypes: [slideMineType],
104 handleDrag(source, dataTransfer) {
105 const data = activeData.value
106 if (!data) {
107 window.showErrorMessage(`Cannot drag and drop slides: No active slides project.`)
108 return
109 }
110 const sources = source.map(node => data.markdownFiles[node.markdownPath]?.slides[node.slideIndex]).filter(Boolean)
111 dataTransfer.set(slideMineType, new DataTransferItem(sources))
112 },
113 async handleDrop(target, dataTransfer) {
114 const slides: SourceSlideInfo[] = dataTransfer.get(slideMineType)?.value
115 const data = activeData.value
116 if (!slides?.length || !target || !data)
117 return
118 if (slides.length === 0) {
119 window.showErrorMessage(`Cannot drag and drop slides: None of the selected slides are in the entry Markdown.`)
120 return
121 }
122 const targetIndex = target.slideIndex
123 const targetMarkdown = data.markdownFiles[target.markdownPath]
124 const oldSlides = targetMarkdown.slides.map(s => slides.includes(s) ? null : s)
125 const before = oldSlides.slice(0, targetIndex + 1).filter(Boolean) as SourceSlideInfo[]
126 const after = oldSlides.slice(targetIndex + 1).filter(Boolean) as SourceSlideInfo[]
127 const newTargetMarkdown = {
128 ...targetMarkdown,
129 slides: [
130 ...before,
131 ...slides,
132 ...after,
133 ],
134 }
135
136 const changedMarkdown = new Set<SlidevMarkdown>([newTargetMarkdown])
137 for (const markdown of Object.values(data.markdownFiles)) {
138 if (markdown === targetMarkdown)
139 continue // already handled
140 const newSlides = markdown.slides.filter(s => !slides.includes(s))
141 if (newSlides.length !== markdown.slides.length) {
142 changedMarkdown.add({
143 ...markdown,
144 slides: newSlides,
145 })
146 }
147 }
148
149 for (const markdown of changedMarkdown) {
150 const newContent = stringify(markdown)
151 await workspace.fs.writeFile(
152 Uri.file(markdown.filepath),
153 (new TextEncoder()).encode(newContent),
154 )
155 }
156
157 setTimeout(async () => {
158 await gotoSlide(target.markdownPath, before.length)
159 }, 100)
160 },
161 },
162 showCollapseAll: true,
163 title: () => activeData.value
164 ? `Slides: ${getSlidesTitle(activeData.value)}`
165 : 'Slides',
166 },
167 )
168
169 const focusedNode = computed(() => {
170 if (!focusedSourceSlide.value)
171 return null
172 const { filepath, index } = focusedSourceSlide.value
173 return sourceToNode.value.get(`${filepath}:${index}`)
174 })
175 watchEffect(() => {
176 if (treeView.visible.value && focusedNode.value) {
177 treeView.reveal(focusedNode.value, { select: true })
178 }
179 })
180
181 return {
182 treeView,
183 }
184 })
185
185 lines TYPESCRIPT