| 1 | import type { SlidevProject } from '../projects' |
| 2 | import { computed, defineService, useActiveTextEditor, useTextEditorSelection, useTextEditorVisibleRanges, useVscodeContext } from 'reactive-vscode' |
| 3 | import { Position, Range, TextEditorRevealType, Uri, window, workspace } from 'vscode' |
| 4 | import { activeData } from '../projects' |
| 5 | import { getFirstDisplayedChild } from '../utils/getFirstDisplayedChild' |
| 6 | import { useDebouncedComputed } from './useDebouncedComputed' |
| 7 | import { useProjectFromDoc } from './useProjectFromDoc' |
| 8 | |
| 9 | interface OverviewAnchor { |
| 10 | line: number |
| 11 | no: number |
| 12 | } |
| 13 | |
| 14 | export const useFocusedSlide = defineService(() => { |
| 15 | const editor = useActiveTextEditor() |
| 16 | const selection = useTextEditorSelection(editor) |
| 17 | const visibleRanges = useTextEditorVisibleRanges(editor) |
| 18 | const debouncedEditor = useDebouncedComputed(() => editor.value, val => val ? null : 150) |
| 19 | const projectInfo = useProjectFromDoc(() => debouncedEditor.value?.document) |
| 20 | const focusedMarkdown = computed(() => projectInfo.value?.md) |
| 21 | useVscodeContext('slidev:editing-markdown', () => !!focusedMarkdown.value) |
| 22 | |
| 23 | const focusedSourceSlide = useDebouncedComputed( |
| 24 | () => { |
| 25 | const md = focusedMarkdown.value |
| 26 | if (!md || !debouncedEditor.value || !selection.value) { |
| 27 | return null |
| 28 | } |
| 29 | const line = selection.value.active.line + 1 |
| 30 | const slide = md.slides.find(s => line <= s.end) |
| 31 | return slide || md.slides.at(-1)! |
| 32 | }, |
| 33 | (newVal, oldVal) => newVal?.filepath === oldVal?.filepath ? 30 : 150, |
| 34 | ) |
| 35 | |
| 36 | const displayedSourceSlide = computed(() => { |
| 37 | if (!focusedSourceSlide.value) |
| 38 | return null |
| 39 | return getFirstDisplayedChild(focusedSourceSlide.value) |
| 40 | }) |
| 41 | const focusedSlideNo = computed(() => { |
| 42 | const slides = projectInfo.value?.project.data.slides |
| 43 | if (!slides || !displayedSourceSlide.value) |
| 44 | return null |
| 45 | const index = slides.findIndex(s => s.source === displayedSourceSlide.value) |
| 46 | return index < 0 ? null : index + 1 |
| 47 | }) |
| 48 | const overviewAnchors = computed(() => { |
| 49 | const info = projectInfo.value |
| 50 | if (!info) |
| 51 | return [] |
| 52 | const slides = info.project.data.slides |
| 53 | return info.md.slides |
| 54 | .map((source): OverviewAnchor | null => { |
| 55 | const displayedSource = getFirstDisplayedChild(source) |
| 56 | const index = slides.findIndex(s => s.source === displayedSource) |
| 57 | if (index < 0) |
| 58 | return null |
| 59 | return { |
| 60 | line: source.contentStart, |
| 61 | no: index + 1, |
| 62 | } |
| 63 | }) |
| 64 | .filter((anchor): anchor is OverviewAnchor => anchor != null) |
| 65 | .sort((a, b) => a.no - b.no) |
| 66 | }) |
| 67 | const viewportSlideNo = computed(() => { |
| 68 | const range = visibleRanges.value[0] |
| 69 | if (!range) |
| 70 | return null |
| 71 | const start = getFractionalVisibleLine(range.start) |
| 72 | const end = getFractionalVisibleLine(range.end) |
| 73 | const visibleLines = Math.max(1, end - start) |
| 74 | return interpolate(overviewAnchors.value.map(anchor => ({ |
| 75 | x: (anchor.line - start) / visibleLines, |
| 76 | y: anchor.no, |
| 77 | })), 0.5) |
| 78 | }) |
| 79 | |
| 80 | function getFractionalVisibleLine(position: Position) { |
| 81 | const document = editor.value?.document |
| 82 | const line = position.line |
| 83 | if (!document || line < 0 || line >= document.lineCount) |
| 84 | return line |
| 85 | return line + position.character / (document.lineAt(line).text.length + 2) |
| 86 | } |
| 87 | |
| 88 | function interpolate(points: { x: number, y: number }[], x: number) { |
| 89 | if (points.length === 0) |
| 90 | return null |
| 91 | if (points.length === 1) |
| 92 | return points[0].y |
| 93 | const sorted = [...points].sort((a, b) => a.x - b.x) |
| 94 | const minY = Math.min(...sorted.map(point => point.y)) |
| 95 | const maxY = Math.max(...sorted.map(point => point.y)) |
| 96 | |
| 97 | let previous = sorted[0] |
| 98 | let next = sorted[1] |
| 99 | if (x > sorted.at(-1)!.x) { |
| 100 | previous = sorted.at(-2)! |
| 101 | next = sorted.at(-1)! |
| 102 | } |
| 103 | else { |
| 104 | for (let i = 1; i < sorted.length; i++) { |
| 105 | if (sorted[i].x >= x) { |
| 106 | previous = sorted[i - 1] |
| 107 | next = sorted[i] |
| 108 | break |
| 109 | } |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | const span = next.x - previous.x |
| 114 | const y = span === 0 |
| 115 | ? previous.y |
| 116 | : previous.y + (x - previous.x) / span * (next.y - previous.y) |
| 117 | return Math.min(Math.max(y, minY), maxY) |
| 118 | } |
| 119 | |
| 120 | async function gotoSlide(filepath: string, index: number) { |
| 121 | if (focusedMarkdown.value?.filepath === filepath && focusedSourceSlide.value?.index === index) |
| 122 | return |
| 123 | const slide = activeData.value?.markdownFiles[filepath]?.slides[index] |
| 124 | if (!slide) |
| 125 | return |
| 126 | const document = await workspace.openTextDocument(Uri.file(filepath)) |
| 127 | const cursorPos = new Position(slide.contentStart, 0) |
| 128 | await window.showTextDocument(document, { |
| 129 | selection: new Range(cursorPos, cursorPos), |
| 130 | }) |
| 131 | } |
| 132 | |
| 133 | async function focusSlide(project: SlidevProject, no: number) { |
| 134 | const source = project.data.slides[no - 1]?.source |
| 135 | if (!source || displayedSourceSlide.value === source) |
| 136 | return |
| 137 | await gotoSlide(source.filepath, source.index) |
| 138 | } |
| 139 | |
| 140 | function revealViewportSlide(no: number) { |
| 141 | const textEditor = editor.value |
| 142 | if (!textEditor) |
| 143 | return |
| 144 | const line = interpolate(overviewAnchors.value.map(anchor => ({ |
| 145 | x: anchor.no, |
| 146 | y: anchor.line, |
| 147 | })), no) |
| 148 | if (line == null) |
| 149 | return |
| 150 | const position = new Position(Math.round(line), 0) |
| 151 | textEditor.revealRange(new Range(position, position), TextEditorRevealType.InCenter) |
| 152 | } |
| 153 | |
| 154 | return { |
| 155 | focusedMarkdown, |
| 156 | focusedSourceSlide, |
| 157 | focusedSlideNo, |
| 158 | viewportSlideNo, |
| 159 | gotoSlide, |
| 160 | focusSlide, |
| 161 | revealViewportSlide, |
| 162 | } |
| 163 | }) |
| 164 |