| 1 | import type { SourceSlideInfo } from '@slidev/types' |
| 2 | import type { DecorationOptions } from 'vscode' |
| 3 | import { clamp, ensurePrefix } from '@antfu/utils' |
| 4 | import { computed, defineService, useActiveTextEditor, watch } from 'reactive-vscode' |
| 5 | import { Position, Range, ThemeColor, window } from 'vscode' |
| 6 | import { useProjectFromDoc } from '../composables/useProjectFromDoc' |
| 7 | import { config } from '../configs' |
| 8 | import { activeProject } from '../projects' |
| 9 | import { toRelativePath } from '../utils/toRelativePath' |
| 10 | |
| 11 | const RE_NEWLINE = /\r?\n/ |
| 12 | const RE_LEADING_BACKTICKS = /^\s*`+/ |
| 13 | const RE_FRONTMATTER_BLOCK = /^---[\s\S]*?\n---/ |
| 14 | |
| 15 | const frontmatterBgOptions = { |
| 16 | isWholeLine: true, |
| 17 | backgroundColor: '#8881', |
| 18 | } |
| 19 | |
| 20 | const firstLineDecoration = window.createTextEditorDecorationType({}) |
| 21 | const dividerDecoration = window.createTextEditorDecorationType({ |
| 22 | ...frontmatterBgOptions, |
| 23 | color: new ThemeColor('panelTitle.inactiveForeground'), |
| 24 | borderStyle: 'solid', |
| 25 | borderWidth: '1px 0 0 0', |
| 26 | borderColor: new ThemeColor('panelTitle.activeBorder'), |
| 27 | }) |
| 28 | const frontmatterContentDecoration = window.createTextEditorDecorationType(frontmatterBgOptions) |
| 29 | const frontmatterEndDecoration = window.createTextEditorDecorationType({ |
| 30 | ...frontmatterBgOptions, |
| 31 | color: new ThemeColor('panelTitle.inactiveForeground'), |
| 32 | }) |
| 33 | const errorDecoration = window.createTextEditorDecorationType({ |
| 34 | isWholeLine: true, |
| 35 | }) |
| 36 | const codeBlockLineNumberDecoration = window.createTextEditorDecorationType({ |
| 37 | isWholeLine: false, |
| 38 | rangeBehavior: 1, |
| 39 | }) |
| 40 | |
| 41 | function mergeSlideNumbers(slides: { index: number }[]): string { |
| 42 | const indexes = slides.map(s => s.index + 1) |
| 43 | const merged = [[indexes[0], indexes[0]]] |
| 44 | for (let i = 1; i < indexes.length; i++) { |
| 45 | if (merged[merged.length - 1][1] + 1 === indexes[i]) |
| 46 | merged[merged.length - 1][1] = indexes[i] |
| 47 | else |
| 48 | merged.push([indexes[i], indexes[i]]) |
| 49 | } |
| 50 | return merged.map(([start, end]) => start === end ? `#${start}` : `#${start}-${end}`).join(', ') |
| 51 | } |
| 52 | |
| 53 | interface CodeBlockInfo { |
| 54 | startLine: number |
| 55 | endLine: number |
| 56 | indent: number |
| 57 | } |
| 58 | |
| 59 | function findCodeBlocks(docText: string): CodeBlockInfo[] { |
| 60 | const lines = docText.split(RE_NEWLINE) |
| 61 | const codeBlocks: CodeBlockInfo[] = [] |
| 62 | |
| 63 | for (let i = 0; i < lines.length; i++) { |
| 64 | const line = lines[i] |
| 65 | const trimmedLine = line.trimStart() |
| 66 | |
| 67 | if (trimmedLine.startsWith('```')) { |
| 68 | const indent = line.length - trimmedLine.length |
| 69 | const codeBlockLevel = line.match(RE_LEADING_BACKTICKS)![0] |
| 70 | const backtickCount = codeBlockLevel.trim().length |
| 71 | const startLine = i |
| 72 | |
| 73 | if (backtickCount !== 3) { |
| 74 | continue |
| 75 | } |
| 76 | |
| 77 | let endLine = i |
| 78 | for (let j = i + 1; j < lines.length; j++) { |
| 79 | if (lines[j].startsWith(codeBlockLevel)) { |
| 80 | endLine = j |
| 81 | break |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | if (endLine > startLine) { |
| 86 | codeBlocks.push({ |
| 87 | startLine: startLine + 1, |
| 88 | endLine, |
| 89 | indent, |
| 90 | }) |
| 91 | } |
| 92 | |
| 93 | i = endLine |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | return codeBlocks |
| 98 | } |
| 99 | |
| 100 | function updateCodeBlockLineNumbers(editor: ReturnType<typeof useActiveTextEditor>['value'], docText: string) { |
| 101 | if (!editor || !config['annotations-line-numbers']) |
| 102 | return |
| 103 | |
| 104 | const codeBlockLineNumbers: DecorationOptions[] = [] |
| 105 | const codeBlocks = findCodeBlocks(docText) |
| 106 | |
| 107 | for (const block of codeBlocks) { |
| 108 | const lineCount = block.endLine - block.startLine |
| 109 | const maxLineNumber = lineCount |
| 110 | const numberWidth = String(maxLineNumber).length |
| 111 | |
| 112 | for (let i = 0; i < lineCount; i++) { |
| 113 | const lineNumber = i + 1 |
| 114 | const currentLine = block.startLine + i |
| 115 | |
| 116 | if (currentLine >= editor.document.lineCount) |
| 117 | continue |
| 118 | |
| 119 | // \u2800 renders as a space but won't be trimmed |
| 120 | const paddedNumber = String(lineNumber).padStart(numberWidth, '\u2800') |
| 121 | |
| 122 | const position = new Position(currentLine, block.indent) |
| 123 | codeBlockLineNumbers.push({ |
| 124 | range: new Range(position, position), |
| 125 | renderOptions: { |
| 126 | before: { |
| 127 | contentText: `${paddedNumber}│`, |
| 128 | color: new ThemeColor('editorLineNumber.foreground'), |
| 129 | fontWeight: 'normal', |
| 130 | fontStyle: 'normal', |
| 131 | margin: '0 0.5em 0 0', |
| 132 | }, |
| 133 | }, |
| 134 | }) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | editor.setDecorations(codeBlockLineNumberDecoration, codeBlockLineNumbers) |
| 139 | } |
| 140 | |
| 141 | export const useAnnotations = defineService(() => { |
| 142 | const editor = useActiveTextEditor() |
| 143 | const doc = computed(() => editor.value?.document) |
| 144 | const projectInfo = useProjectFromDoc(doc) |
| 145 | |
| 146 | watch( |
| 147 | [editor, doc, projectInfo, activeProject, () => config.annotations, () => config['annotations-line-numbers']], |
| 148 | ([editor, doc, projectInfo, activeProject, enabled, lineNumbersEnabled]) => { |
| 149 | if (!editor || !doc) |
| 150 | return |
| 151 | |
| 152 | if (!projectInfo || !enabled) { |
| 153 | editor.setDecorations(firstLineDecoration, []) |
| 154 | editor.setDecorations(dividerDecoration, []) |
| 155 | editor.setDecorations(frontmatterContentDecoration, []) |
| 156 | editor.setDecorations(frontmatterEndDecoration, []) |
| 157 | editor.setDecorations(errorDecoration, []) |
| 158 | editor.setDecorations(codeBlockLineNumberDecoration, []) |
| 159 | return |
| 160 | } |
| 161 | |
| 162 | const { project, md } = projectInfo |
| 163 | const isActive = project === activeProject |
| 164 | const docText = doc.getText() |
| 165 | |
| 166 | function getTextContent(source: SourceSlideInfo) { |
| 167 | const slides = project.data.slides.filter( |
| 168 | s => s.source === source || s.importChain?.includes(source), |
| 169 | ) |
| 170 | const posInfo = slides?.length |
| 171 | ? mergeSlideNumbers(slides) |
| 172 | : isActive ? '(hidden)' : '' |
| 173 | const entryInfo = source.index === 0 && project.data.entry !== md |
| 174 | ? ` (entry: ${toRelativePath(project.entry)})` |
| 175 | : '' |
| 176 | const activeInfo = source.index === 0 && !isActive |
| 177 | ? ' (inactive)' |
| 178 | : '' |
| 179 | return ` ${posInfo}${entryInfo}${activeInfo}` |
| 180 | } |
| 181 | |
| 182 | const firstLineRanges: DecorationOptions[] = [] |
| 183 | const dividerRanges: DecorationOptions[] = [] |
| 184 | const frontmatterContentRanges: DecorationOptions[] = [] |
| 185 | const frontmatterEndRanges: DecorationOptions[] = [] |
| 186 | for (const slide of md.slides) { |
| 187 | const lineNo = slide.frontmatterStyle === 'frontmatter' ? slide.start : slide.start - 1 |
| 188 | const line = doc.lineAt(clamp(lineNo, 0, doc.lineCount)) |
| 189 | |
| 190 | const start = new Position(line.lineNumber, 0) |
| 191 | const startDividerRange = new Range(start, new Position(line.lineNumber, line.text.length)) |
| 192 | |
| 193 | // If a markdown has no headmatter, we should set `isWholeLine` to `false` for the first line |
| 194 | const isSpecialCase = slide.index === 0 && !slide.frontmatterStyle |
| 195 | const ranges = isSpecialCase ? firstLineRanges : dividerRanges |
| 196 | ranges.push({ |
| 197 | range: startDividerRange, |
| 198 | renderOptions: { |
| 199 | after: { |
| 200 | contentText: getTextContent(slide), |
| 201 | fontWeight: 'bold', |
| 202 | color: new ThemeColor('panelTitle.activeBorder'), |
| 203 | }, |
| 204 | }, |
| 205 | }) |
| 206 | |
| 207 | if (slide.frontmatterRaw != null) { |
| 208 | const range = docText.slice(doc.offsetAt(start)) |
| 209 | const match = range.match(RE_FRONTMATTER_BLOCK) |
| 210 | if (match && match.index != null) { |
| 211 | const endLine = doc.positionAt(doc.offsetAt(start) + match.index + match[0].length).line |
| 212 | frontmatterContentRanges.push({ |
| 213 | range: new Range(new Position(line.lineNumber + 1, 0), new Position(endLine - 1, 0)), |
| 214 | }) |
| 215 | if (endLine !== start.line) { |
| 216 | frontmatterEndRanges.push({ |
| 217 | range: new Range(new Position(endLine, 0), new Position(endLine, 0)), |
| 218 | }) |
| 219 | } |
| 220 | } |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | editor.setDecorations(firstLineDecoration, firstLineRanges) |
| 225 | editor.setDecorations(dividerDecoration, dividerRanges) |
| 226 | editor.setDecorations(frontmatterContentDecoration, frontmatterContentRanges) |
| 227 | editor.setDecorations(frontmatterEndDecoration, frontmatterEndRanges) |
| 228 | |
| 229 | const errors: DecorationOptions[] = [] |
| 230 | for (const error of md.errors ?? []) { |
| 231 | errors.push({ |
| 232 | range: new Range(error.row, 0, error.row, 0), |
| 233 | renderOptions: { |
| 234 | after: { |
| 235 | contentText: ensurePrefix(error.message, ' '), |
| 236 | color: new ThemeColor('editorError.foreground'), |
| 237 | backgroundColor: new ThemeColor('editorError.background'), |
| 238 | }, |
| 239 | }, |
| 240 | }) |
| 241 | } |
| 242 | editor.setDecorations(errorDecoration, errors) |
| 243 | |
| 244 | if (lineNumbersEnabled) { |
| 245 | updateCodeBlockLineNumbers(editor, docText) |
| 246 | } |
| 247 | else { |
| 248 | editor.setDecorations(codeBlockLineNumberDecoration, []) |
| 249 | } |
| 250 | }, |
| 251 | { immediate: true }, |
| 252 | ) |
| 253 | }) |
| 254 |