Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Experimental] Make transition configurable #425

Merged
merged 4 commits into from
Feb 12, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Added

- Experimental `transition` directive for bespoke template is now configurable by YAML object ([#382](https://github.com/marp-team/marp-cli/issues/382), [#425](https://github.com/marp-team/marp-cli/pull/425))

### Fixed

- Disable automation flag in preview window ([#421](https://github.com/marp-team/marp-cli/pull/421))
Expand Down
134 changes: 120 additions & 14 deletions src/engine/transition-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
import { Marpit } from '@marp-team/marpit'
import type MarkdownIt from 'markdown-it'
import { warn } from '../cli'

const hasOwnProperty = Object.prototype.hasOwnProperty

interface TransitionMetaConfig {
type: keyof typeof transitions
duration?: number
delay?: number
}

interface TransitionMeta {
transition?: TransitionMetaConfig
transitionBack?: TransitionMetaConfig
}

const inverted = {
'reveal-left': 'reveal-right',
Expand All @@ -13,26 +27,99 @@ const inverted = {
fade: 'fade',
explode: 'implode',
implode: 'explode',
}
} as const

const transitions: Record<string, string> = {
...Object.keys(inverted).reduce<Record<string, string>>(
(acc, transition) => ({ ...acc, [transition]: transition }),
{}
),
const transitions = {
reveal: 'reveal-left',
'reveal-left': 'reveal-left',
'reveal-right': 'reveal-right',
'reveal-up': 'reveal-up',
'reveal-down': 'reveal-down',
cover: 'cover-left',
'cover-left': 'cover-left',
'cover-right': 'cover-right',
'cover-up': 'cover-up',
'cover-down': 'cover-down',
fade: 'fade',
explode: 'implode',
implode: 'explode',
} as const

const asTransitionLength = (target: string, value: number) => {
const integer = Math.floor(value)
const clamped = Math.max(0, Math.min(5000, integer))

if (integer !== clamped) {
warn(
`The length of ${target} must be between 0 to 5000ms. ${integer}ms is clamped to ${clamped}ms.`
)
}

return clamped
}

export default function transitionPlugin(md: MarkdownIt & { marpit: Marpit }) {
md.marpit.customDirectives.local.transition = (value) => {
if (typeof value !== 'string') return {}
const parseMs = (value: unknown): number => {
if (typeof value === 'number') return value
if (typeof value !== 'string') return Number.NaN

const normalized = value.trim()

const msUnitMatcher = normalized.match(/^(-?\d+)ms$/)
if (msUnitMatcher) return Number.parseInt(msUnitMatcher[1], 10)

const secondUnitMatcher = normalized.match(/^(-?(?:\d*\.)?\d+)s$/)
if (secondUnitMatcher) return Number.parseFloat(secondUnitMatcher[1]) * 1000

return Number.parseFloat(normalized)
}

const parseTransitionMetaConfig = (
value: unknown
): TransitionMetaConfig | undefined => {
if (typeof value === 'string') {
const transition = transitions[value]
if (!transition) return { transition: undefined, transitionBack: undefined }
if (transition) return { type: transition }
} else if (value && typeof value === 'object') {
const obj = value as TransitionMetaConfig
const transition = transitions[obj.type]

if (transition) {
const ret: TransitionMetaConfig = { type: transition }

const duration = parseMs(obj.duration)
if (!Number.isNaN(duration)) {
ret.duration = asTransitionLength('duration', duration)
}

const delay = parseMs(obj.delay)
if (!Number.isNaN(delay)) ret.delay = asTransitionLength('delay', delay)

return ret
}
}
return undefined
}

const transitionBack: string | undefined = inverted[transition]
return { transition, ...(transitionBack ? { transitionBack } : {}) }
export default function transitionPlugin(md: MarkdownIt & { marpit: Marpit }) {
md.marpit.customDirectives.local.transition = (value): TransitionMeta => {
if (typeof value === 'string' || (value && typeof value === 'object')) {
const transition = parseTransitionMetaConfig(value)

if (transition) {
const transitionBackType = inverted[transition.type]

return {
transition,
...(transitionBackType
? { transitionBack: { ...transition, type: transitionBackType } }
: {}),
}
} else {
return { transition: undefined, transitionBack: undefined }
}
}

return {}
}

md.core.ruler.after(
Expand All @@ -45,10 +132,29 @@ export default function transitionPlugin(md: MarkdownIt & { marpit: Marpit }) {
const { marpitDirectives } = token.meta || {}

if (marpitDirectives?.transition) {
token.attrSet(`data-transition`, marpitDirectives.transition)
const { transition } = marpitDirectives
token.attrSet(`data-transition`, transition.type)

if (hasOwnProperty.call(transition, 'duration')) {
token.attrSet(`data-transition-duration`, transition.duration)
}
if (hasOwnProperty.call(transition, 'delay')) {
token.attrSet(`data-transition-delay`, transition.delay)
}
}
if (marpitDirectives?.transitionBack) {
token.attrSet(`data-transition-back`, marpitDirectives.transitionBack)
const { transitionBack } = marpitDirectives
token.attrSet(`data-transition-back`, transitionBack.type)

if (hasOwnProperty.call(transitionBack, 'duration')) {
token.attrSet(
`data-transition-back-duration`,
transitionBack.duration
)
}
if (hasOwnProperty.call(transitionBack, 'delay')) {
token.attrSet(`data-transition-back-delay`, transitionBack.delay)
}
}
}

Expand Down
24 changes: 19 additions & 5 deletions src/templates/bespoke/transition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ const bespokeTransition = (deck) => {
(fn: (e: any) => void, { back, cond }: TransitionCallbackOption) =>
(e: any) => {
const current = deck.slides[deck.slide()]
const section = current.querySelector('section[data-transition]')
const section: HTMLElement = current.querySelector(
'section[data-transition]'
)

if (!section) return true

Expand All @@ -44,12 +46,24 @@ const bespokeTransition = (deck) => {
} else {
if (!cond(e)) return true

const target = `transition${e.back || back ? 'Back' : ''}` as const
const duration = Number.parseInt(
section.dataset[`${target}Duration`] ?? '',
10
)
const delay = Number.parseInt(
section.dataset[`${target}Delay`] ?? '',
10
)

const rootConfig: Record<string, string> = {}
if (!Number.isNaN(duration)) rootConfig.duration = duration.toString()
if (!Number.isNaN(delay)) rootConfig.delay = delay.toString()

deck[transitionPreparing] = documentTransition
.prepare({
rootTransition:
e.back || back
? section.dataset.transitionBack
: section.dataset.transition,
rootTransition: section.dataset[target],
rootConfig,
sharedElements,
})
.then(() => fn(e))
Expand Down
136 changes: 130 additions & 6 deletions test/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,24 +336,148 @@ describe('Converter', () => {
expect(enabledResult).toContain('data-transition="cover-left"')
expect(enabledResult).toContain('data-transition-back="cover-right"')

// Invalid value
const { result: invalidResult } = await instance({
template: 'bespoke',
templateOption: { transition: true },
}).convert('<!-- transition: inavlid -->')

expect(invalidResult).not.toContain('data-transition')

// Turn on and off
const { result: toggleResult } = await instance({
template: 'bespoke',
templateOption: { transition: true },
}).convert(
'<!-- transition: reveal -->\n\n---\n\n<!-- transition: {"invalid-format":"will-be-ignored"} -->\n\n---\n\n<!-- transition: false -->'
'<!-- transition: reveal -->\n\n---\n\n<!-- transition: false -->'
)

const $ = cheerio.load(toggleResult)
const sections = $('section')

expect(sections).toHaveLength(3)
expect(sections).toHaveLength(2)
expect($(sections[0]).attr('data-transition')).toBe('reveal-left')
expect($(sections[0]).attr('data-transition-back')).toBe('reveal-right')
expect($(sections[1]).attr('data-transition')).toBe('reveal-left')
expect($(sections[1]).attr('data-transition-back')).toBe('reveal-right')
expect($(sections[2]).attr('data-transition')).toBeUndefined()
expect($(sections[2]).attr('data-transition-back')).toBeUndefined()
expect($(sections[1]).attr('data-transition')).toBeUndefined()
expect($(sections[1]).attr('data-transition-back')).toBeUndefined()
})
})

describe('with option object', () => {
it('defines configured values', async () => {
const converter = instance({
template: 'bespoke',
templateOption: { transition: true },
})

const { result } = await converter.convert(
`
<!--
transition:
type: reveal
duration: 500
delay: 1234
-->
`.trim()
)

expect(result).toContain('data-transition="reveal-left"')
expect(result).toContain('data-transition-duration="500"')
expect(result).toContain('data-transition-delay="1234"')
expect(result).toContain('data-transition-back-duration="500"')
expect(result).toContain('data-transition-back-delay="1234"')

// "type" is required
const { result: noTypeResult } = await converter.convert(
`
<!--
transition:
duration: 123
delay: 456
-->
`.trim()
)

expect(noTypeResult).not.toContain('data-transition-duration="123"')
expect(noTypeResult).not.toContain('data-transition-delay="456"')

// Invalid numbers
const { result: invalidResult } = await converter.convert(
`
<!--
transition:
type: fade
duration: invalid
delay: true
-->
`.trim()
)

expect(invalidResult).toContain('data-transition="fade"')
expect(invalidResult).not.toContain('data-transition-duration')
expect(invalidResult).not.toContain('data-transition-delay')
})

it('allows ms and s unit for duration and delay', async () => {
const converter = instance({
template: 'bespoke',
templateOption: { transition: true },
})

const { result } = await converter.convert(
`
<!--
transition:
type: reveal
duration: 1.5s
delay: 500ms
-->
`.trim()
)

expect(result).toContain('data-transition-duration="1500"')
expect(result).toContain('data-transition-delay="500"')

// Omitted leading zero in seconds unit
const { result: noLeadingZeroResult } = await converter.convert(
`
<!--
transition:
type: reveal
duration: .123s
delay: .4567s
-->
`.trim()
)

expect(noLeadingZeroResult).toContain('data-transition-duration="123"')
expect(noLeadingZeroResult).toContain('data-transition-delay="456"')
})

it('outputs warning if duration and delay were out of range in API (0 to 5000)', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation()

const { result } = await instance({
template: 'bespoke',
templateOption: { transition: true },
}).convert(
`
<!--
transition:
type: fade
duration: -123
delay: 5.001s
-->
`.trim()
)

expect(warn).toHaveBeenCalledWith(
expect.stringContaining('must be between 0 to 5000ms')
)

// Set clamped values
expect(result).toContain('data-transition-duration="0"')
expect(result).toContain('data-transition-delay="5000"')
})
})
})
Expand Down