slidev-addon-subtitle 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lee Dogeon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ <p align="center">
2
+ <img src="./assets/logo.svg" width="115" height="115" alt="slidev-addon-subtitle" />
3
+ </p>
4
+
5
+ <h1 align="center">slidev-addon-subtitle</h1>
6
+
7
+ <p align="center">
8
+ Automatic click-synchronized subtitles for <a href="https://sli.dev">Slidev</a> presentations
9
+ </p>
10
+
11
+ <p align="center">
12
+ <a href="https://moreal.github.io/slidev-addon-subtitle/">Documentation</a>
13
+ </p>
14
+
15
+ ## Development workflow
16
+
17
+ ```bash
18
+ mise trust
19
+ mise install
20
+ mise x -- bazelisk build //:ci_all
21
+ mise x -- bazelisk build //docs:site_archive
22
+ ```
23
+
24
+ To build docs into `docs/.vitepress/dist` for local preview:
25
+
26
+ ```bash
27
+ yarn docs:build
28
+ ```
29
+
30
+ ## License
31
+
32
+ [MIT](./LICENSE)
@@ -0,0 +1,72 @@
1
+ <svg width="115" height="115" viewBox="0 0 115 115" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <defs>
3
+ <filter id="shadow" x="-20" y="-20" width="200" height="200" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
4
+ <feFlood flood-opacity="0" result="BackgroundImageFix"/>
5
+ <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
6
+ <feOffset dy="3"/>
7
+ <feGaussianBlur stdDeviation="4"/>
8
+ <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.18 0"/>
9
+ <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
10
+ <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
11
+ </filter>
12
+
13
+ <filter id="shadow_caption" x="-20" y="-20" width="200" height="200" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
14
+ <feFlood flood-opacity="0" result="BackgroundImageFix"/>
15
+ <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
16
+ <feOffset dy="2"/>
17
+ <feGaussianBlur stdDeviation="3"/>
18
+ <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.20 0"/>
19
+ <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
20
+ <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
21
+ </filter>
22
+
23
+ <clipPath id="clip">
24
+ <rect width="115" height="115" rx="20" fill="white"/>
25
+ </clipPath>
26
+ </defs>
27
+
28
+ <g clip-path="url(#clip)">
29
+ <!-- light backdrop -->
30
+ <rect width="115" height="115" fill="#F6F8FA"/>
31
+
32
+ <!-- screen frame -->
33
+ <g filter="url(#shadow)">
34
+ <rect x="14" y="16" width="87" height="62" rx="12" fill="#111827" fill-opacity="0.92"/>
35
+ <rect x="18" y="20" width="79" height="54" rx="10" fill="white"/>
36
+ <rect x="18" y="20" width="79" height="54" rx="10" stroke="#E5E7EB" stroke-width="1"/>
37
+ </g>
38
+
39
+ <!-- slide title: ●■▲ as a lead mark (no top menu bar) -->
40
+ <g>
41
+ <circle cx="28" cy="28" r="3.2" fill="#3ACBD4"/>
42
+ <rect x="33.5" y="24.8" width="6.4" height="6.4" rx="1.6" fill="#2B95B5"/>
43
+ <path d="M46.5 24.8 L50.1 31.2 H42.9 L46.5 24.8 Z" fill="#FFBB13"/>
44
+
45
+ <!-- title line -->
46
+ <rect x="54" y="26" width="33" height="4" rx="2" fill="#CBD5E1"/>
47
+ </g>
48
+
49
+ <!-- slide content -->
50
+ <g>
51
+ <rect x="26" y="36" width="56" height="3" rx="1.5" fill="#E2E8F0"/>
52
+ <rect x="26" y="42" width="50" height="3" rx="1.5" fill="#E2E8F0"/>
53
+ <rect x="26" y="48" width="54" height="3" rx="1.5" fill="#E2E8F0"/>
54
+ <rect x="26" y="54" width="40" height="3" rx="1.5" fill="#E2E8F0"/>
55
+ </g>
56
+
57
+ <!-- caption attached to screen bottom -->
58
+ <g filter="url(#shadow_caption)">
59
+ <rect x="22" y="64" width="71" height="16" rx="7" fill="#2E2E2E" fill-opacity="0.82"/>
60
+ </g>
61
+ <g>
62
+ <rect x="31" y="68.5" width="53" height="3" rx="1.5" fill="white" fill-opacity="0.92"/>
63
+ <rect x="38" y="74" width="39" height="3" rx="1.5" fill="white" fill-opacity="0.92"/>
64
+ </g>
65
+
66
+ <!-- stand (moved slightly down to avoid caption overlap) -->
67
+ <g>
68
+ <rect x="56" y="82" width="3" height="14" rx="1.5" fill="#9CA3AF"/>
69
+ <path d="M40 98 L57.5 86 L75 98" stroke="#9CA3AF" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
70
+ </g>
71
+ </g>
72
+ </svg>
@@ -0,0 +1,17 @@
1
+ //#region src/types.d.ts
2
+ interface SubtitleOptions {
3
+ enabledModes: ("dev" | "build" | "export")[];
4
+ chunkMode: "sentence" | "line";
5
+ sentenceDelimiters: string[];
6
+ maxDisplayWidth: number;
7
+ }
8
+ interface SubtitleEntry {
9
+ start: number;
10
+ text: string;
11
+ }
12
+ declare const defaultOptions: SubtitleOptions;
13
+ //#endregion
14
+ //#region src/chunking.d.ts
15
+ declare function parseNoteToSubtitleTimeline(note: string | undefined, options?: Partial<SubtitleOptions>): SubtitleEntry[];
16
+ //#endregion
17
+ export { type SubtitleEntry, type SubtitleOptions, defaultOptions, parseNoteToSubtitleTimeline };
package/dist/index.mjs ADDED
@@ -0,0 +1,302 @@
1
+ //#region src/types.ts
2
+ const defaultOptions = {
3
+ enabledModes: ["export"],
4
+ chunkMode: "sentence",
5
+ sentenceDelimiters: [
6
+ ".",
7
+ "!",
8
+ "?",
9
+ "。",
10
+ "!",
11
+ "?",
12
+ "…",
13
+ "\n"
14
+ ],
15
+ maxDisplayWidth: 80
16
+ };
17
+
18
+ //#endregion
19
+ //#region src/chunking.ts
20
+ const CLICK_MARKER_RE = /^\[click(?::(\d+))?\]$/i;
21
+ const SUBTITLE_PAUSE_RE = /^\[subtitle:pause\]$/i;
22
+ const COMBINING_MARK_RE = /\p{Mark}/u;
23
+ function parseNoteToSubtitleTimeline(note, options = {}) {
24
+ const opts = resolveOptions(options);
25
+ if (note === void 0 || note === null) return [];
26
+ const normalized = note.replace(/\r\n/g, "\n").trim();
27
+ if (normalized === "") return [];
28
+ return buildTimeline(normalized, opts);
29
+ }
30
+ function buildTimeline(note, options) {
31
+ const entries = [];
32
+ const lines = note.replace(/\[subtitle:pause\]/gi, "\n[subtitle:pause]\n").split("\n");
33
+ let cursor = 0;
34
+ let current = [];
35
+ const flush = () => {
36
+ const text = current.join("\n").trim();
37
+ current = [];
38
+ if (text === "") return 0;
39
+ const chunks = splitGroupText(text, options);
40
+ let emitted = 0;
41
+ for (const chunk of chunks) {
42
+ const trimmed = chunk.trim();
43
+ if (trimmed === "") continue;
44
+ entries.push({
45
+ start: cursor,
46
+ text: trimmed
47
+ });
48
+ cursor += 1;
49
+ emitted += 1;
50
+ }
51
+ return emitted;
52
+ };
53
+ for (const line of lines) {
54
+ const trimmed = line.trim();
55
+ if (SUBTITLE_PAUSE_RE.test(trimmed)) {
56
+ flush();
57
+ entries.push({
58
+ start: cursor,
59
+ text: ""
60
+ });
61
+ cursor += 1;
62
+ continue;
63
+ }
64
+ const match = trimmed.match(CLICK_MARKER_RE);
65
+ if (!match) {
66
+ current.push(line);
67
+ continue;
68
+ }
69
+ const emitted = flush();
70
+ if (match[1] !== void 0) {
71
+ const parsed = Number(match[1]);
72
+ if (Number.isFinite(parsed)) cursor = Math.max(cursor, Math.max(0, Math.trunc(parsed)));
73
+ else cursor += 1;
74
+ } else if (emitted === 0) cursor += 1;
75
+ }
76
+ flush();
77
+ return entries;
78
+ }
79
+ function splitGroupText(text, options) {
80
+ return wrapSegmentsByDisplayWidth(options.chunkMode === "line" ? text.split("\n").map((line) => line.trim()).filter((line) => line !== "") : splitSentences(text, new Set(options.sentenceDelimiters.filter((value) => value !== "\n"))), options.maxDisplayWidth);
81
+ }
82
+ function splitSentences(text, delimiters) {
83
+ const result = [];
84
+ for (const rawLine of text.split("\n")) {
85
+ const line = rawLine.trim();
86
+ if (line === "") continue;
87
+ let current = "";
88
+ const pushCurrent = () => {
89
+ const trimmed = current.trim();
90
+ if (trimmed !== "") result.push(trimmed);
91
+ current = "";
92
+ };
93
+ for (let i = 0; i < line.length; i++) {
94
+ const char = line[i];
95
+ current += char;
96
+ if (!isSentenceBoundary(line, i, char, delimiters)) continue;
97
+ pushCurrent();
98
+ }
99
+ pushCurrent();
100
+ }
101
+ return result;
102
+ }
103
+ function isSentenceBoundary(line, index, char, delimiters) {
104
+ if (!delimiters.has(char)) return false;
105
+ if (char === ".") {
106
+ const prev = line[index - 1] ?? "";
107
+ const next = line[index + 1] ?? "";
108
+ if (isDigit(prev) && isDigit(next)) return false;
109
+ if (next === ".") return false;
110
+ }
111
+ return true;
112
+ }
113
+ function isDigit(value) {
114
+ return value >= "0" && value <= "9";
115
+ }
116
+ function wrapSegmentsByDisplayWidth(segments, maxDisplayWidth) {
117
+ if (!Number.isFinite(maxDisplayWidth) || maxDisplayWidth <= 0) return segments;
118
+ const wrapped = [];
119
+ for (const segment of segments) wrapped.push(...wrapByWords(segment, maxDisplayWidth));
120
+ return wrapped;
121
+ }
122
+ function wrapByWords(text, maxDisplayWidth) {
123
+ const words = text.trim().split(/\s+/).filter((word) => word !== "");
124
+ if (words.length === 0) return [];
125
+ if (words.length === 1) return [words[0]];
126
+ const wordWidths = words.map((word) => displayWidth(word));
127
+ const baseRanges = greedyLineRanges(wordWidths, maxDisplayWidth);
128
+ if (baseRanges.length <= 1) return [words.join(" ")];
129
+ const baseLineCount = baseRanges.length;
130
+ const strictCandidate = optimizeLineRanges(wordWidths, maxDisplayWidth, baseLineCount, 0);
131
+ const candidates = [];
132
+ if (strictCandidate) candidates.push({
133
+ ranges: strictCandidate.ranges,
134
+ score: strictCandidate.score,
135
+ overflow: strictCandidate.overflow
136
+ });
137
+ else candidates.push({
138
+ ranges: baseRanges,
139
+ score: Number.POSITIVE_INFINITY,
140
+ overflow: 0
141
+ });
142
+ if (baseLineCount > 1) {
143
+ const maxOverflow = Math.max(2, Math.floor(maxDisplayWidth * .15));
144
+ const compactCandidate = optimizeLineRanges(wordWidths, maxDisplayWidth, baseLineCount - 1, maxOverflow);
145
+ if (compactCandidate) {
146
+ const droppedLinePenalty = Math.pow(maxDisplayWidth * .45, 2);
147
+ candidates.push({
148
+ ranges: compactCandidate.ranges,
149
+ score: compactCandidate.score + droppedLinePenalty,
150
+ overflow: compactCandidate.overflow
151
+ });
152
+ }
153
+ }
154
+ candidates.sort((a, b) => {
155
+ if (a.score !== b.score) return a.score - b.score;
156
+ if (a.overflow !== b.overflow) return a.overflow - b.overflow;
157
+ return a.ranges.length - b.ranges.length;
158
+ });
159
+ return mergeTinyTrailingWordRange((candidates[0] ?? { ranges: baseRanges }).ranges, wordWidths, maxDisplayWidth).map(([start, end]) => words.slice(start, end).join(" "));
160
+ }
161
+ function mergeTinyTrailingWordRange(ranges, wordWidths, maxDisplayWidth) {
162
+ if (ranges.length < 2) return ranges;
163
+ const last = ranges[ranges.length - 1];
164
+ const prev = ranges[ranges.length - 2];
165
+ if (last[1] - last[0] !== 1) return ranges;
166
+ const prefix = buildPrefixWidths(wordWidths);
167
+ const tailWidth = lineWidthFromPrefix(prefix, last[0], last[1]);
168
+ if (tailWidth > Math.max(2, Math.floor(maxDisplayWidth * .45))) return ranges;
169
+ if (lineWidthFromPrefix(prefix, prev[0], prev[1]) + 1 + tailWidth > maxDisplayWidth + Math.max(2, Math.floor(maxDisplayWidth * .15))) return ranges;
170
+ const merged = ranges.slice(0, -2);
171
+ merged.push([prev[0], last[1]]);
172
+ return merged;
173
+ }
174
+ function greedyLineRanges(wordWidths, maxDisplayWidth) {
175
+ if (wordWidths.length === 0) return [];
176
+ const ranges = [];
177
+ let start = 0;
178
+ let currentWidth = wordWidths[0];
179
+ for (let i = 1; i < wordWidths.length; i++) {
180
+ const nextWidth = currentWidth + 1 + wordWidths[i];
181
+ if (nextWidth <= maxDisplayWidth) {
182
+ currentWidth = nextWidth;
183
+ continue;
184
+ }
185
+ ranges.push([start, i]);
186
+ start = i;
187
+ currentWidth = wordWidths[i];
188
+ }
189
+ ranges.push([start, wordWidths.length]);
190
+ return ranges;
191
+ }
192
+ function optimizeLineRanges(wordWidths, maxDisplayWidth, totalLines, maxOverflow) {
193
+ const totalWords = wordWidths.length;
194
+ if (totalWords === 0 || totalLines <= 0) return {
195
+ ranges: [],
196
+ score: 0,
197
+ overflow: 0
198
+ };
199
+ if (totalLines >= totalWords) return {
200
+ ranges: wordWidths.map((_, index) => [index, index + 1]),
201
+ score: 0,
202
+ overflow: 0
203
+ };
204
+ const prefix = buildPrefixWidths(wordWidths);
205
+ const lineWidth = (start, end) => {
206
+ return lineWidthFromPrefix(prefix, start, end);
207
+ };
208
+ const isValidLine = (start, end) => {
209
+ if (lineWidth(start, end) <= maxDisplayWidth + maxOverflow) return true;
210
+ return end - start === 1;
211
+ };
212
+ const targetWidth = lineWidth(0, totalWords) / totalLines;
213
+ const dp = Array.from({ length: totalLines + 1 }, () => Array(totalWords + 1).fill(Number.POSITIVE_INFINITY));
214
+ const prev = Array.from({ length: totalLines + 1 }, () => Array(totalWords + 1).fill(-1));
215
+ dp[0][0] = 0;
216
+ for (let lineIndex = 1; lineIndex <= totalLines; lineIndex++) {
217
+ const minEnd = lineIndex;
218
+ const maxEnd = totalWords - (totalLines - lineIndex);
219
+ for (let end = minEnd; end <= maxEnd; end++) for (let start = lineIndex - 1; start < end; start++) {
220
+ if (!Number.isFinite(dp[lineIndex - 1][start])) continue;
221
+ if (!isValidLine(start, end)) continue;
222
+ const cost = lineCost(lineWidth(start, end), targetWidth, end - start, lineIndex, totalLines, maxDisplayWidth);
223
+ const nextCost = dp[lineIndex - 1][start] + cost;
224
+ if (nextCost < dp[lineIndex][end]) {
225
+ dp[lineIndex][end] = nextCost;
226
+ prev[lineIndex][end] = start;
227
+ }
228
+ }
229
+ }
230
+ if (!Number.isFinite(dp[totalLines][totalWords])) return null;
231
+ const ranges = [];
232
+ let overflow = 0;
233
+ let end = totalWords;
234
+ for (let lineIndex = totalLines; lineIndex >= 1; lineIndex--) {
235
+ const start = prev[lineIndex][end];
236
+ if (start < 0) return null;
237
+ ranges.push([start, end]);
238
+ const width = lineWidth(start, end);
239
+ overflow += Math.max(0, width - maxDisplayWidth);
240
+ end = start;
241
+ }
242
+ ranges.reverse();
243
+ return {
244
+ ranges,
245
+ score: dp[totalLines][totalWords],
246
+ overflow
247
+ };
248
+ }
249
+ function lineCost(width, targetWidth, wordsInLine, lineIndex, totalLines, maxDisplayWidth) {
250
+ const variance = Math.pow(width - targetWidth, 2);
251
+ const overflow = Math.max(0, width - maxDisplayWidth);
252
+ let penalty = 0;
253
+ if (overflow > 0) penalty += Math.pow(overflow, 2) * 12;
254
+ if (wordsInLine === 1 && totalLines > 1) {
255
+ const ratio = targetWidth > 0 ? width / targetWidth : 1;
256
+ if (lineIndex === totalLines && ratio < .95) penalty += Math.pow(targetWidth * (.95 - ratio), 2) + targetWidth * 4;
257
+ else if (ratio < .6) penalty += Math.pow(targetWidth * (.6 - ratio), 2);
258
+ }
259
+ if (lineIndex === totalLines) {
260
+ const ratio = targetWidth > 0 ? width / targetWidth : 1;
261
+ if (ratio < .55) penalty += Math.pow(targetWidth * (.55 - ratio), 2) * 2.5;
262
+ }
263
+ return variance + penalty;
264
+ }
265
+ function buildPrefixWidths(wordWidths) {
266
+ const prefix = Array(wordWidths.length + 1).fill(0);
267
+ for (let i = 0; i < wordWidths.length; i++) prefix[i + 1] = prefix[i] + wordWidths[i];
268
+ return prefix;
269
+ }
270
+ function lineWidthFromPrefix(prefix, start, end) {
271
+ return prefix[end] - prefix[start] + Math.max(0, end - start - 1);
272
+ }
273
+ function displayWidth(value) {
274
+ let width = 0;
275
+ for (const char of value) width += charDisplayWidth(char);
276
+ return width;
277
+ }
278
+ function charDisplayWidth(char) {
279
+ const codePoint = char.codePointAt(0);
280
+ if (codePoint === void 0) return 0;
281
+ if (codePoint <= 31 || codePoint >= 127 && codePoint <= 159) return 0;
282
+ if (COMBINING_MARK_RE.test(char)) return 0;
283
+ if (isFullWidthCodePoint(codePoint)) return 2;
284
+ return 1;
285
+ }
286
+ function isFullWidthCodePoint(codePoint) {
287
+ if (codePoint < 4352) return false;
288
+ return codePoint <= 4447 || codePoint === 9001 || codePoint === 9002 || codePoint >= 11904 && codePoint <= 12871 && codePoint !== 12351 || codePoint >= 12880 && codePoint <= 19903 || codePoint >= 19968 && codePoint <= 42182 || codePoint >= 43360 && codePoint <= 43388 || codePoint >= 44032 && codePoint <= 55203 || codePoint >= 63744 && codePoint <= 64255 || codePoint >= 65040 && codePoint <= 65049 || codePoint >= 65072 && codePoint <= 65131 || codePoint >= 65281 && codePoint <= 65376 || codePoint >= 65504 && codePoint <= 65510 || codePoint >= 110592 && codePoint <= 110593 || codePoint >= 127488 && codePoint <= 127569 || codePoint >= 131072 && codePoint <= 262141;
289
+ }
290
+ function resolveOptions(options) {
291
+ const rawMaxDisplayWidth = options.maxDisplayWidth ?? defaultOptions.maxDisplayWidth;
292
+ const maxDisplayWidth = Number.isFinite(rawMaxDisplayWidth) && rawMaxDisplayWidth > 0 ? Math.trunc(rawMaxDisplayWidth) : Number.POSITIVE_INFINITY;
293
+ return {
294
+ enabledModes: options.enabledModes ? [...options.enabledModes] : [...defaultOptions.enabledModes],
295
+ chunkMode: options.chunkMode ?? defaultOptions.chunkMode,
296
+ sentenceDelimiters: options.sentenceDelimiters ? [...options.sentenceDelimiters] : [...defaultOptions.sentenceDelimiters],
297
+ maxDisplayWidth
298
+ };
299
+ }
300
+
301
+ //#endregion
302
+ export { defaultOptions, parseNoteToSubtitleTimeline };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "slidev-addon-subtitle",
3
+ "version": "0.1.0",
4
+ "keywords": [
5
+ "slidev",
6
+ "slidev-addon"
7
+ ],
8
+ "license": "MIT",
9
+ "workspaces": [
10
+ "examples/*",
11
+ "docs"
12
+ ],
13
+ "files": [
14
+ "dist",
15
+ "setup",
16
+ "style.css",
17
+ "assets/logo.svg"
18
+ ],
19
+ "type": "module",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.mts",
23
+ "import": "./dist/index.mjs"
24
+ }
25
+ },
26
+ "scripts": {
27
+ "build": "tsdown",
28
+ "test": "vitest run",
29
+ "test:watch": "vitest",
30
+ "lint": "oxlint",
31
+ "fmt": "oxfmt --write .",
32
+ "fmt:check": "oxfmt --check .",
33
+ "check": "tsc --noEmit",
34
+ "docs:dev": "yarn workspace slidev-addon-subtitle-docs run dev",
35
+ "docs:build": "mise x -- bazelisk build //docs:site_archive && rm -rf docs/.vitepress/dist && mkdir -p docs/.vitepress/dist && tar -xf bazel-bin/docs/site.tar -C docs/.vitepress/dist",
36
+ "docs:preview": "yarn workspace slidev-addon-subtitle-docs run preview",
37
+ "bazel:check": "mise x -- bazelisk build //:ci_checks",
38
+ "bazel:build": "mise x -- bazelisk build //:lib_build",
39
+ "bazel:docs": "mise x -- bazelisk build //docs:site_archive",
40
+ "ci": "mise x -- bazelisk build //:ci_all",
41
+ "prepare": "lefthook install"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^22",
45
+ "lefthook": "^1",
46
+ "oxfmt": "latest",
47
+ "oxlint": "^1",
48
+ "tsdown": "^0.20.3",
49
+ "typescript": "^5",
50
+ "vitest": "^3"
51
+ },
52
+ "packageManager": "yarn@4.12.0"
53
+ }
@@ -0,0 +1,101 @@
1
+ <script setup lang="ts">
2
+ import { useSlideContext } from "@slidev/client";
3
+ import {
4
+ defaultOptions,
5
+ parseNoteToSubtitleTimeline,
6
+ type SubtitleEntry,
7
+ } from "slidev-addon-subtitle";
8
+ import { computed, onMounted, onUnmounted, ref, watch } from "vue";
9
+
10
+ const { $clicks, $clicksContext: clicks, $renderContext, $route } = useSlideContext();
11
+
12
+ const registrationId = `slidev-addon-subtitle-${Math.random().toString(36).slice(2, 10)}`;
13
+ const baseOffset = ref(0);
14
+ let stopWatch: (() => void) | undefined;
15
+
16
+ const note = computed(() => $route?.meta.slide?.note ?? "");
17
+
18
+ const timeline = computed<SubtitleEntry[]>(() => {
19
+ return parseNoteToSubtitleTimeline(note.value, defaultOptions);
20
+ });
21
+
22
+ function registerTimeline() {
23
+ clicks.unregister(registrationId);
24
+
25
+ baseOffset.value = clicks.currentOffset;
26
+
27
+ const items = timeline.value;
28
+ const lastStart = items.length > 0 ? items[items.length - 1].start : -1;
29
+ if (lastStart > 0) {
30
+ clicks.register(registrationId, {
31
+ max: baseOffset.value + lastStart,
32
+ delta: 0,
33
+ });
34
+ }
35
+ }
36
+
37
+ onMounted(() => {
38
+ registerTimeline();
39
+ stopWatch = watch(
40
+ timeline,
41
+ () => {
42
+ registerTimeline();
43
+ },
44
+ { deep: true },
45
+ );
46
+ });
47
+
48
+ onUnmounted(() => {
49
+ if (stopWatch) stopWatch();
50
+ clicks.unregister(registrationId);
51
+ });
52
+
53
+ const localClicks = computed(() => $clicks.value - baseOffset.value);
54
+
55
+ const activeSubtitle = computed(() => {
56
+ const items = timeline.value;
57
+ if (items.length === 0) return "";
58
+
59
+ const click = localClicks.value;
60
+ for (let i = 0; i < items.length; i++) {
61
+ const current = items[i];
62
+ const next = items[i + 1];
63
+ const endExclusive = next ? next.start : current.start + 1;
64
+
65
+ if (click >= current.start && click < endExclusive) {
66
+ return current.text;
67
+ }
68
+ }
69
+
70
+ return "";
71
+ });
72
+
73
+ const shouldRender = computed(() => {
74
+ if (!activeSubtitle.value) return false;
75
+ return $renderContext.value === "slide";
76
+ });
77
+ </script>
78
+
79
+ <template>
80
+ <Transition name="subtitle-fade">
81
+ <div v-if="shouldRender" class="pdf-subtitle">
82
+ {{ activeSubtitle }}
83
+ </div>
84
+ </Transition>
85
+ </template>
86
+
87
+ <style scoped>
88
+ .subtitle-fade-enter-active,
89
+ .subtitle-fade-leave-active {
90
+ transition: opacity 0.18s ease;
91
+ }
92
+
93
+ .subtitle-fade-enter-from,
94
+ .subtitle-fade-leave-to {
95
+ opacity: 0;
96
+ }
97
+
98
+ .pdf-subtitle {
99
+ white-space: pre-line;
100
+ }
101
+ </style>
package/setup/main.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { defineAppSetup } from "@slidev/types";
2
+ import SubtitleDisplay from "./components/SubtitleDisplay.vue";
3
+
4
+ export default defineAppSetup(({ app }) => {
5
+ app.component("SubtitleDisplay", SubtitleDisplay);
6
+ });
@@ -0,0 +1,3 @@
1
+ import { definePreparserSetup } from "@slidev/types";
2
+
3
+ export default definePreparserSetup(() => []);
@@ -0,0 +1,5 @@
1
+ declare module "*.vue" {
2
+ import type { DefineComponent } from "vue";
3
+ const component: DefineComponent<Record<string, never>, Record<string, never>, any>;
4
+ export default component;
5
+ }
@@ -0,0 +1,22 @@
1
+ import { defineTransformersSetup, type MarkdownTransformContext } from "@slidev/types";
2
+
3
+ const SUBTITLE_COMPONENT_TAG = "<SubtitleDisplay />";
4
+
5
+ function injectSubtitleDisplay(ctx: MarkdownTransformContext): void {
6
+ if (ctx.options.mode !== "export") return;
7
+
8
+ const note = ctx.slide.note?.trim();
9
+ if (!note) return;
10
+
11
+ const source = ctx.s.toString();
12
+ if (source.includes("<SubtitleDisplay")) return;
13
+
14
+ const prefix = source.startsWith("\n")
15
+ ? `${SUBTITLE_COMPONENT_TAG}\n`
16
+ : `${SUBTITLE_COMPONENT_TAG}\n\n`;
17
+ ctx.s.prepend(prefix);
18
+ }
19
+
20
+ export default defineTransformersSetup(() => ({
21
+ pre: [injectSubtitleDisplay],
22
+ }));
@@ -0,0 +1,3 @@
1
+ import { defineVitePluginsSetup } from "@slidev/types";
2
+
3
+ export default defineVitePluginsSetup(() => []);
package/style.css ADDED
@@ -0,0 +1,18 @@
1
+ .pdf-subtitle {
2
+ position: fixed;
3
+ bottom: 48px;
4
+ left: 0;
5
+ right: 0;
6
+ width: fit-content;
7
+ margin-left: auto;
8
+ margin-right: auto;
9
+ padding: 8px 24px;
10
+ background: rgba(0, 0, 0, 0.75);
11
+ border-radius: 8px;
12
+ color: #ffffff;
13
+ font-size: 1.25rem;
14
+ line-height: 1.6;
15
+ text-align: center;
16
+ z-index: 100;
17
+ pointer-events: none;
18
+ }