Skip to content

Commit

Permalink
Merge pull request #425 from marp-team/configuring-transitions
Browse files Browse the repository at this point in the history
[Experimental] Make transition configurable
  • Loading branch information
yhatt authored Feb 12, 2022
2 parents ed0b462 + 01d165c commit ef89a7d
Show file tree
Hide file tree
Showing 4 changed files with 273 additions and 25 deletions.
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

0 comments on commit ef89a7d

Please sign in to comment.