| 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 |