diff --git a/packages/client/state/shared.ts b/packages/client/state/shared.ts
index f0de911449..abb510d995 100644
--- a/packages/client/state/shared.ts
+++ b/packages/client/state/shared.ts
@@ -6,9 +6,16 @@ export interface SharedState {
page: number
clicks: number
clicksTotal: number
- timerStatus: 'stopped' | 'running' | 'paused'
- timerStartedAt: number
- timerPausedAt: number
+
+ timer: {
+ status: 'stopped' | 'running' | 'paused'
+ slides: Record
+ startedAt: number
+ pausedAt: number
+ }
cursor?: {
x: number
@@ -26,9 +33,12 @@ const { init, onPatch, onUpdate, patch, state } = createSyncState(s
page: 1,
clicks: 0,
clicksTotal: 0,
- timerStatus: 'stopped',
- timerStartedAt: 0,
- timerPausedAt: 0,
+ timer: {
+ status: 'stopped',
+ slides: {},
+ startedAt: 0,
+ pausedAt: 0,
+ },
})
export {
diff --git a/packages/parser/package.json b/packages/parser/package.json
index 7de9a59e9f..30bb377113 100644
--- a/packages/parser/package.json
+++ b/packages/parser/package.json
@@ -12,26 +12,15 @@
},
"bugs": "https://github.com/slidevjs/slidev/issues",
"exports": {
- ".": {
- "types": "./dist/index.d.mts",
- "import": "./dist/index.mjs"
- },
- "./core": {
- "types": "./dist/core.d.mts",
- "import": "./dist/core.mjs"
- },
- "./fs": {
- "types": "./dist/fs.d.mts",
- "import": "./dist/fs.mjs"
- },
- "./utils": {
- "types": "./dist/utils.d.mts",
- "import": "./dist/utils.mjs"
- }
+ ".": "./dist/index.mjs",
+ "./core": "./dist/core.mjs",
+ "./fs": "./dist/fs.mjs",
+ "./utils": "./dist/utils.mjs",
+ "./package.json": "./package.json"
},
- "main": "dist/index.mjs",
- "module": "dist/index.mjs",
- "types": "dist/index.d.mts",
+ "main": "./dist/index.mjs",
+ "module": "./dist/index.mjs",
+ "types": "./dist/index.d.mts",
"files": [
"*.d.ts",
"dist"
@@ -40,7 +29,7 @@
"node": ">=18.0.0"
},
"scripts": {
- "build": "tsdown src/index.ts src/core.ts src/fs.ts src/utils.ts",
+ "build": "tsdown",
"dev": "nr build --watch",
"prepublishOnly": "npm run build"
},
diff --git a/packages/parser/src/config.ts b/packages/parser/src/config.ts
index 4754785c4d..809762014f 100644
--- a/packages/parser/src/config.ts
+++ b/packages/parser/src/config.ts
@@ -46,6 +46,9 @@ export function getDefaultConfig(): SlidevConfig {
remote: false,
mdc: false,
seoMeta: {},
+ notesAutoRuby: {},
+ duration: '30min',
+ timer: 'stopwatch',
}
}
diff --git a/packages/parser/src/timesplit/index.ts b/packages/parser/src/timesplit/index.ts
new file mode 100644
index 0000000000..604e5c9251
--- /dev/null
+++ b/packages/parser/src/timesplit/index.ts
@@ -0,0 +1,2 @@
+export * from './timesplit'
+export * from './timestring'
diff --git a/packages/parser/src/timesplit/timesplit.test.ts b/packages/parser/src/timesplit/timesplit.test.ts
new file mode 100644
index 0000000000..d88af47c58
--- /dev/null
+++ b/packages/parser/src/timesplit/timesplit.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest'
+import { parseTimesplits } from './timesplit'
+
+describe('parseTimesplits', () => {
+ it('should parse timestamp into seconds', () => {
+ expect(parseTimesplits([
+ { no: 1, timesplit: '+10s' },
+ { no: 5, timesplit: '10:10' },
+ { no: 3, timesplit: '15m' },
+ { no: 7, timesplit: '1h10m30s' },
+ ])).toMatchInlineSnapshot(`
+ [
+ {
+ "noEnd": 1,
+ "noStart": 0,
+ "timestampEnd": 10,
+ "timestampStart": 0,
+ "title": "[start]",
+ },
+ {
+ "noEnd": 5,
+ "noStart": 1,
+ "timestampEnd": 610,
+ "timestampStart": 10,
+ },
+ {
+ "noEnd": 3,
+ "noStart": 5,
+ "timestampEnd": 900,
+ "timestampStart": 610,
+ },
+ {
+ "noEnd": 7,
+ "noStart": 3,
+ "timestampEnd": 4230,
+ "timestampStart": 900,
+ },
+ {
+ "noEnd": 7,
+ "noStart": 7,
+ "timestampEnd": 4230,
+ "timestampStart": 4230,
+ },
+ ]
+ `)
+ })
+})
diff --git a/packages/parser/src/timesplit/timesplit.ts b/packages/parser/src/timesplit/timesplit.ts
new file mode 100644
index 0000000000..b1b5634a0e
--- /dev/null
+++ b/packages/parser/src/timesplit/timesplit.ts
@@ -0,0 +1,51 @@
+import { parseTimeString } from './timestring'
+
+export interface TimesplitInput {
+ no: number
+ timesplit: string
+ title?: string
+}
+
+export interface TimesplitOutput {
+ timestampStart: number
+ timestampEnd: number
+ noStart: number
+ noEnd: number
+ title?: string
+}
+
+export function parseTimesplits(inputs: TimesplitInput[]): TimesplitOutput[] {
+ let ts = 0
+ const outputs: TimesplitOutput[] = []
+ let current: TimesplitOutput = {
+ timestampStart: ts,
+ timestampEnd: ts,
+ noStart: 0,
+ noEnd: 0,
+ title: '[start]',
+ }
+ outputs.push(current)
+ for (const input of inputs) {
+ const time = parseTimeString(input.timesplit)
+ const end = time.relative
+ ? ts + time.seconds
+ : time.seconds
+ if (end < ts) {
+ throw new Error(`Timesplit end ${end} is before start ${ts}`)
+ }
+ current.timestampEnd = end
+ current.noEnd = input.no
+ if (input.title) {
+ current.title = input.title
+ }
+ ts = end
+ current = {
+ timestampStart: end,
+ timestampEnd: end,
+ noStart: input.no,
+ noEnd: input.no,
+ }
+ outputs.push(current)
+ }
+ return outputs
+}
diff --git a/packages/parser/src/timesplit/timestring.test.ts b/packages/parser/src/timesplit/timestring.test.ts
new file mode 100644
index 0000000000..44d6c3dfd2
--- /dev/null
+++ b/packages/parser/src/timesplit/timestring.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest'
+import { parseTimeString } from './timestring'
+
+describe('parseTimeString', () => {
+ it('should parse timestamp into seconds', () => {
+ expect(parseTimeString('10:50.1')).toEqual({ seconds: 650.1, relative: false })
+ expect(parseTimeString('10s')).toEqual({ seconds: 10, relative: false })
+ expect(parseTimeString('5m')).toEqual({ seconds: 300, relative: false })
+ expect(parseTimeString('3min')).toEqual({ seconds: 180, relative: false })
+ expect(parseTimeString('3mins 5secs')).toEqual({ seconds: 185, relative: false })
+ expect(parseTimeString('10.5m3s')).toEqual({ seconds: 633, relative: false })
+ expect(parseTimeString('+10s')).toEqual({ seconds: 10, relative: true })
+ expect(parseTimeString('1h10m30s')).toEqual({ seconds: 4230, relative: false })
+ expect(parseTimeString('1h4s')).toEqual({ seconds: 3604, relative: false })
+ expect(parseTimeString('1:1:1')).toEqual({ seconds: 3661, relative: false })
+ expect(parseTimeString('0.5years')).toEqual({ seconds: 15778476, relative: false })
+ })
+
+ it('should throw an error for invalid timestamp', () => {
+ expect(() => parseTimeString('10x')).toThrow('Invalid timestamp unit: x')
+ expect(() => parseTimeString('10h:10m:10s')).toThrow('Invalid timestamp format')
+ expect(() => parseTimeString('hello 1s world')).toThrow('Unknown timestamp remaining: hello world')
+ })
+})
diff --git a/packages/parser/src/timesplit/timestring.ts b/packages/parser/src/timesplit/timestring.ts
new file mode 100644
index 0000000000..99ed110983
--- /dev/null
+++ b/packages/parser/src/timesplit/timestring.ts
@@ -0,0 +1,107 @@
+/**
+ * Parse timestamp into seconds
+ *
+ * Accepts:
+ * - 10:50.1
+ * - 10s
+ * - 5m
+ * - 3min
+ * - 3mins 5secs
+ * - 10.5m3s
+ * - +10s
+ * - 1h10m30s
+ * - 1h4s
+ * - 1:1:1
+ */
+export function parseTimeString(timestamp: string | number): {
+ seconds: number
+ relative: boolean
+} {
+ if (typeof timestamp === 'number') {
+ return {
+ seconds: timestamp,
+ relative: false,
+ }
+ }
+
+ const relative = timestamp.startsWith('+')
+ if (relative) {
+ timestamp = timestamp.slice(1)
+ }
+ let seconds = 0
+ if (timestamp.includes(':')) {
+ const parts = timestamp.split(':').map(Number)
+ let h = 0
+ let m = 0
+ let s = 0
+ if (parts.length === 3) {
+ h = parts[0]
+ m = parts[1]
+ s = parts[2]
+ }
+ else if (parts.length === 2) {
+ m = parts[0]
+ s = parts[1]
+ }
+ else if (parts.length === 1) {
+ s = parts[0]
+ }
+ else {
+ throw new TypeError('Invalid timestamp format')
+ }
+ if (Number.isNaN(h) || Number.isNaN(m) || Number.isNaN(s)) {
+ throw new TypeError('Invalid timestamp format')
+ }
+ seconds = (h || 0) * 3600 + (m || 0) * 60 + (s || 0)
+ }
+ else if (!timestamp.match(/[a-z]/i)) {
+ seconds = Number(timestamp)
+ }
+ else {
+ const unitMap: Record = {
+ s: 1,
+ sec: 1,
+ secs: 1,
+ m: 60,
+ min: 60,
+ mins: 60,
+ h: 3600,
+ hr: 3600,
+ hrs: 3600,
+ hour: 3600,
+ hours: 3600,
+ day: 86400,
+ days: 86400,
+ week: 604800,
+ weeks: 604800,
+ month: 2629746,
+ months: 2629746,
+ year: 31556952,
+ years: 31556952,
+ }
+ const regex = /([\d.]+)([a-z]+)/gi
+ const matches = timestamp.matchAll(regex)
+ if (matches) {
+ for (const match of matches) {
+ const value = Number(match[1])
+ if (Number.isNaN(value)) {
+ throw new TypeError(`Invalid timestamp value: ${match[1]}`)
+ }
+ const unit = match[2].toLowerCase()
+ if (!(unit in unitMap)) {
+ throw new TypeError(`Invalid timestamp unit: ${unit}`)
+ }
+ seconds += value * unitMap[unit]
+ }
+ }
+ const remaining = timestamp.replace(regex, '').trim()
+ if (remaining) {
+ throw new TypeError(`Unknown timestamp remaining: ${remaining}`)
+ }
+ }
+
+ return {
+ seconds,
+ relative,
+ }
+}
diff --git a/packages/parser/src/utils.ts b/packages/parser/src/utils.ts
index 0586f3b28d..cbdcc4f697 100644
--- a/packages/parser/src/utils.ts
+++ b/packages/parser/src/utils.ts
@@ -1,5 +1,7 @@
import { isNumber, range, uniq } from '@antfu/utils'
+export * from './timesplit'
+
/**
* 1,3-5,8 => [1, 3, 4, 5, 8]
*/
diff --git a/packages/parser/tsdown.config.ts b/packages/parser/tsdown.config.ts
index b67bd3cfca..12ad1bcf10 100644
--- a/packages/parser/tsdown.config.ts
+++ b/packages/parser/tsdown.config.ts
@@ -1 +1,12 @@
-export { default } from '../../tsdown.config.ts'
+import { defineConfig } from 'tsdown'
+import baseConfig from '../../tsdown.config.ts'
+
+export default defineConfig({
+ ...baseConfig,
+ entry: {
+ index: 'src/index.ts',
+ core: 'src/core.ts',
+ fs: 'src/fs.ts',
+ utils: 'src/utils.ts',
+ },
+})
diff --git a/packages/slidev/node/vite/serverRef.ts b/packages/slidev/node/vite/serverRef.ts
index bd4268b801..a3d8c4e074 100644
--- a/packages/slidev/node/vite/serverRef.ts
+++ b/packages/slidev/node/vite/serverRef.ts
@@ -14,9 +14,12 @@ export async function createServerRefPlugin(
nav: {
page: 0,
clicks: 0,
- timerStatus: 'stopped',
- timerStartedAt: 0,
- timerPausedAt: 0,
+ timer: {
+ status: 'stopped',
+ slides: {},
+ startedAt: 0,
+ pausedAt: 0,
+ },
},
drawings: await loadDrawings(options),
snapshots: await loadSnapshots(options),
diff --git a/packages/types/src/frontmatter.ts b/packages/types/src/frontmatter.ts
index 15620abf8d..3c15db45f9 100644
--- a/packages/types/src/frontmatter.ts
+++ b/packages/types/src/frontmatter.ts
@@ -282,6 +282,23 @@ export interface HeadmatterConfig extends TransitionOptions {
* ```
*/
notesAutoRuby?: Record
+ /**
+ * The expected duration of the slide
+ *
+ * @example
+ * ```yaml
+ * duration: 35min
+ * ```
+ *
+ * @default '30min'
+ */
+ duration?: string | number
+ /**
+ * Timer mode
+ *
+ * @default 'stopwatch'
+ */
+ timer?: 'stopwatch' | 'countdown'
}
export interface Frontmatter extends TransitionOptions {
@@ -360,6 +377,21 @@ export interface Frontmatter extends TransitionOptions {
* See https://sli.dev/guide/syntax.html#importing-slides
*/
src?: string
+ // /**
+ // * Set time split for the end of the slide
+ // *
+ // * Accepts:
+ // * - 10:05
+ // * - 10m5s
+ // * - +10s (relative to the previous point)
+ // */
+ // timesplit?: string
+ // /**
+ // * Set title for the time split
+ // *
+ // * Default to slide title
+ // */
+ // timesplitTitle?: string
}
export interface DrawingsOptions {
diff --git a/packages/vscode/schema/headmatter.json b/packages/vscode/schema/headmatter.json
index d6fffb87ce..d18ec08bf0 100644
--- a/packages/vscode/schema/headmatter.json
+++ b/packages/vscode/schema/headmatter.json
@@ -520,6 +520,25 @@
"markdownDescription": "Auto replace words with `` tags in notes",
"default": {}
},
+ "duration": {
+ "type": [
+ "string",
+ "number"
+ ],
+ "description": "The expected duration of the slide",
+ "markdownDescription": "The expected duration of the slide",
+ "default": "30min"
+ },
+ "timer": {
+ "type": "string",
+ "enum": [
+ "stopwatch",
+ "countdown"
+ ],
+ "description": "Timer mode",
+ "markdownDescription": "Timer mode",
+ "default": "stopwatch"
+ },
"defaults": {
"$ref": "#/definitions/Frontmatter",
"description": "Default frontmatter options applied to all slides",