1
1
mirror of https://github.com/jxnblk/mdx-deck.git synced 2024-11-29 13:58:02 +03:00

Merge pull request #40 from jxnblk/presenter-mode

Presenter mode
This commit is contained in:
Brent Jackson 2018-08-02 20:35:54 -04:00 committed by GitHub
commit 4df577d062
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 466 additions and 178 deletions

View File

@ -251,6 +251,17 @@ export { default as Provider } from './Provider'
# Hello
```
## Presenter Mode
mdx-deck includes a built-in presenter mode, with a preview of the next slide and a timer.
To use presenter mode:
- Open two windows in the same browser, with the same URL on two different screens. (this should work in both development and exported presentations)
- In your window press the `p` key to enter presenter mode.
- Display the other window on the screen for the audience to see.
- Control the presentation from your window by using the left and right arrow keys; the other window should stay in sync
## Exporting
Add a `build` script to your `package.json` to export a presentation as HTML with a JS bundle.

View File

@ -24,6 +24,7 @@
"chalk": "^2.4.1",
"clipboardy": "^1.2.3",
"gray-matter": "^4.0.1",
"hhmmss": "^1.0.0",
"loader-utils": "^1.1.0",
"lodash.debounce": "^4.0.8",
"meow": "^5.0.0",

13
src/Box.js Normal file
View File

@ -0,0 +1,13 @@
import styled from 'styled-components'
import { width, space, color } from 'styled-system'
const Box = styled.div([], {
flex: 'none'
},
props => props.css,
width,
space,
color
)
export default Box

34
src/Carousel.js Normal file
View File

@ -0,0 +1,34 @@
import React from 'react'
import styled from 'styled-components'
const CarouselRoot = styled.div([], {
overflowX: 'hidden',
width: '100%',
height: '100%',
'@media print': {
height: 'auto',
overflowX: 'visible'
}
})
const CarouselInner = styled.div([], {
display: 'flex',
width: '100%',
height: '100%',
transitionProperty: 'transform',
transitionTimingFunction: 'ease-out',
transitionDuration: '.3s',
'@media print': {
height: 'auto',
display: 'block'
}
}, props => ({
transform: `translateX(${-100 * props.index}%)`
}))
export const Carousel = props =>
<CarouselRoot>
<CarouselInner {...props} />
</CarouselRoot>
export default Carousel

50
src/Dots.js Normal file
View File

@ -0,0 +1,50 @@
import React from 'react'
import styled from 'styled-components'
import { space, color } from 'styled-system'
import Flex from './Flex'
const Dot = styled.button([], {
appearance: 'none',
border: '4px solid transparent',
backgroundClip: 'padding-box',
borderRadius: '9999px',
width: '8px',
height: '8px',
color: 'inherit',
'&:focus': {
outline: 'none',
boxShadow: '0 0 0 1px'
}
},
props => ({
opacity: props.active ? 0.5 : 0.125
}),
space,
color
)
Dot.defaultProps = {
m: 0,
p: 1,
bg: 'currentcolor',
}
export const Dots = ({
index,
length,
onClick,
...props
}) =>
<Flex {...props}>
{Array.from({ length }).map((n, i) => (
<Dot
key={i}
active={i <= index}
title={'go to: ' + i}
onClick={e => {
onClick(i)
}}
/>
))}
</Flex>
export default Dots

12
src/Flex.js Normal file
View File

@ -0,0 +1,12 @@
import styled from 'styled-components'
import { space, color } from 'styled-system'
const Flex = styled.div([], {
display: 'flex',
justifyContent: 'center',
'@media print': {
display: 'none'
}
}, props => props.css, space, color)
export default Flex

24
src/GoogleFonts.js Normal file
View File

@ -0,0 +1,24 @@
import React from 'react'
import webfont from '@compositor/webfont'
import { withTheme } from 'styled-components'
export const GoogleFonts = withTheme(({ theme }) => {
const links = [
webfont.getURL(theme.font || ''),
webfont.getURL(theme.monospace || '')
].filter(Boolean)
if (!links.length) return false
return (
<React.Fragment>
{links.map((href, i) => (
<link
key={i}
href={href}
rel='stylesheet'
/>
))}
</React.Fragment>
)
})
export default GoogleFonts

10
src/Mono.js Normal file
View File

@ -0,0 +1,10 @@
import React from 'react'
import Box from './Box'
export default props =>
<Box
{...props}
css={{
fontFamily: 'Menlo, monospace'
}}
/>

66
src/Presenter.js Normal file
View File

@ -0,0 +1,66 @@
import React from 'react'
import Box from './Box'
import Flex from './Flex'
import Zoom from './Zoom'
import Slide from './Slide'
import Root from './Root'
import Timer from './Timer'
import Mono from './Mono'
export const Presenter = ({
index,
length,
slides = [],
mode,
...props
}) => {
const Next = slides[index + 1]
return (
<Flex
color='white' bg='black'
css={{
flexDirection: 'column',
height: '100vh'
}}
>
<Flex
my='auto'
css={{ alignItems: 'flex-start' }}>
<Box
mx='auto'
css={{
border: '1px solid rgba(128, 128, 128, 0.25)'
}}>
<Zoom zoom={5/8}>
<Root {...props}>
{props.children}
</Root>
</Zoom>
</Box>
<Box
mx='auto'
css={{
border: '1px solid rgba(128, 128, 128, 0.25)'
}}>
<Zoom zoom={1/4}>
<Root {...props}>
{Next && (
<Slide>
<Next />
</Slide>
)}
</Root>
</Zoom>
</Box>
</Flex>
<Flex mt='auto' px={3} py={3}>
<Mono>Slide {index} of {length}</Mono>
<Box mx='auto' />
<Timer />
</Flex>
</Flex>
)
}
export default Presenter

27
src/Root.js Normal file
View File

@ -0,0 +1,27 @@
import styled from 'styled-components'
import {
width,
height,
color
} from 'styled-system'
export const Root = styled.div([], {
'@media print': {
fontSize: '24px',
height: 'auto'
},
},
props => props.theme.font ? ({
fontFamily: props.theme.font
}) : null,
props => props.theme.css,
width,
height,
color
)
Root.defaultProps = {
color: 'text',
bg: 'background'
}
export default Root

26
src/Slide.js Normal file
View File

@ -0,0 +1,26 @@
import styled from 'styled-components'
import { space, color } from 'styled-system'
export const Slide = styled.div([], {
flex: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
overflow: 'hidden',
width: '100%',
height: '100%',
'@media print': {
width: '100vw',
height: '100vh',
pageBreakAfter: 'always',
pageBreakInside: 'avoid',
WebkitPrintColorAdjust: 'exact'
}
}, space, color)
Slide.defaultProps = {
px: [ 4, 5, 6 ]
}
export default Slide

87
src/Timer.js Normal file
View File

@ -0,0 +1,87 @@
import React from 'react'
import hhmmss from 'hhmmss'
import styled from 'styled-components'
import { space, color } from 'styled-system'
import Flex from './Flex'
import Box from './Box'
import Mono from './Mono'
const Button = styled.button([], {
appearance: 'none',
fontFamily: 'inherit',
fontSize: '12px',
fontWeight: 'bold',
borderRadius: '4px',
border: 'none'
}, space, color)
Button.defaultProps = {
m: 0,
px: 2,
py: 1,
color: 'white',
bg: '#333'
}
class Timer extends React.Component {
state = {
on: false,
time: new Date().toLocaleTimeString(),
seconds: 0
}
toggle = () => {
this.setState(state => ({ on: !state.on }))
}
reset = () => {
this.setState({ seconds: 0 })
}
tick = () => {
const now = new Date()
this.setState(state => ({
time: now.toLocaleTimeString(),
seconds: state.on
? state.seconds + 1
: state.seconds
}))
}
componentDidMount () {
this.timer = setInterval(this.tick, 1000)
}
componentWillUnmount () {
if (!this.timer) return
clearInterval(this.timer)
}
render () {
const { time, seconds, on } = this.state
const elapsed = hhmmss(seconds)
return (
<Flex css={{ alignItems: 'center' }}>
{!on && seconds > 0 && (
<Button mr={1} onClick={this.reset}>
reset
</Button>
)}
<Button
bg={on ? '#600' : '#060'}
onClick={this.toggle}>
{on ? 'stop' : 'start'}
</Button>
<Mono px={2}>
{elapsed} |
</Mono>
<Mono>
{time}
</Mono>
</Flex>
)
}
}
export default Timer

29
src/Zoom.js Normal file
View File

@ -0,0 +1,29 @@
import React from 'react'
import styled from 'styled-components'
const ZoomRoot = styled.div([], {
backgroundColor: 'white',
},
props => ({
width: (100 * props.zoom) + 'vw',
height: (100 * props.zoom) + 'vh',
})
)
const ZoomInner = styled.div([],
props => ({
transformOrigin: '0 0',
transform: `scale(${props.zoom})`
})
)
const Zoom = ({ zoom, ...props }) =>
<ZoomRoot zoom={zoom}>
<ZoomInner zoom={zoom} {...props} />
</ZoomRoot>
Zoom.defaultProps = {
zoom: 1
}
export default Zoom

View File

@ -1,10 +1,15 @@
import React from 'react'
import PropTypes from 'prop-types'
import { MDXProvider } from '@mdx-js/tag'
import styled, { ThemeProvider, withTheme } from 'styled-components'
import { space, width, height, color } from 'styled-system'
import { ThemeProvider } from 'styled-components'
import debounce from 'lodash.debounce'
import webfont from '@compositor/webfont'
import Carousel from './Carousel'
import Slide from './Slide'
import Dots from './Dots'
import Root from './Root'
import Presenter from './Presenter'
import GoogleFonts from './GoogleFonts'
import defaultTheme from './themes'
import defaultComponents from './components'
@ -21,148 +26,12 @@ export const dec = state => state.index > 0
? ({ index: (state.index - 1) % state.length })
: null
const CarouselRoot = styled.div([], {
overflowX: 'hidden',
width: '100%',
height: '100%',
'@media print': {
height: 'auto',
overflowX: 'visible'
}
})
const CarouselInner = styled.div([], {
display: 'flex',
width: '100%',
height: '100%',
transitionProperty: 'transform',
transitionTimingFunction: 'ease-out',
transitionDuration: '.3s',
'@media print': {
height: 'auto',
display: 'block'
}
}, props => ({
transform: `translateX(${-100 * props.index}%)`
}))
export const Carousel = props =>
<CarouselRoot>
<CarouselInner {...props} />
</CarouselRoot>
export const Slide = styled.div([], {
flex: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
overflow: 'hidden',
width: '100%',
height: '100%',
'@media print': {
width: '100vw',
height: '100vh',
pageBreakAfter: 'always',
pageBreakInside: 'avoid',
WebkitPrintColorAdjust: 'exact'
}
}, space, color)
Slide.defaultProps = {
px: [ 4, 5, 6 ]
const modes = {
normal: 'NORMAL',
presenter: 'PRESENTER',
}
const Dot = styled.button([], {
appearance: 'none',
border: '4px solid transparent',
backgroundClip: 'padding-box',
borderRadius: '9999px',
width: '8px',
height: '8px',
color: 'inherit',
'&:focus': {
outline: 'none',
boxShadow: '0 0 0 1px'
}
},
props => ({
opacity: props.active ? 0.5 : 0.125
}),
space,
color
)
Dot.defaultProps = {
m: 0,
p: 1,
bg: 'currentcolor',
}
const Flex = styled.div([], {
display: 'flex',
justifyContent: 'center',
'@media print': {
display: 'none'
}
}, space)
export const Dots = ({
index,
length,
onClick,
...props
}) =>
<Flex {...props}>
{Array.from({ length }).map((n, i) => (
<Dot
key={i}
active={i <= index}
title={'go to: ' + i}
onClick={e => {
onClick(i)
}}
/>
))}
</Flex>
export const Root = styled.div([], {
'@media print': {
fontSize: '24px',
height: 'auto'
}
},
props => props.theme.font ? ({
fontFamily: props.theme.font
}) : null,
props => props.theme.css,
width,
height,
color
)
Root.defaultProps = {
color: 'text',
bg: 'background'
}
export const GoogleFonts = withTheme(({ theme }) => {
const links = [
webfont.getURL(theme.font || ''),
webfont.getURL(theme.monospace || '')
].filter(Boolean)
if (!links.length) return false
return (
<React.Fragment>
{links.map((href, i) => (
<link
key={i}
href={href}
rel='stylesheet'
/>
))}
</React.Fragment>
)
})
export class SlideDeck extends React.Component {
static propTypes = {
slides: PropTypes.array.isRequired,
@ -179,7 +48,8 @@ export class SlideDeck extends React.Component {
state = {
length: this.props.slides.length,
index: 0
index: 0,
mode: modes.normal
}
update = fn => this.setState(fn)
@ -200,6 +70,10 @@ export class SlideDeck extends React.Component {
e.preventDefault()
this.update(dec)
break
case 'p':
this.update(state => ({
mode: state.mode === modes.presenter ? modes.normal : modes.presenter
}))
}
}
@ -215,15 +89,33 @@ export class SlideDeck extends React.Component {
this.setState({ index })
}
getMode = () => {
const { search } = window.location
const presenter = search.includes('presenter')
if (presenter) {
this.setState({
mode: modes.presenter
})
}
}
handleStorageChange = e => {
const index = parseInt(e.newValue, 10)
this.setState({ index })
}
componentDidMount () {
document.body.addEventListener('keydown', this.handleKeyDown)
window.addEventListener('hashchange', this.handleHashChange)
window.addEventListener('storage', this.handleStorageChange)
this.hashToState()
this.getMode()
}
componentWillUnmount () {
document.body.removeEventListener('keydown', this.handleKeyDown)
window.removeEventListener('hashchange', this.handleHashChange)
window.removeEventListener('storage', this.handleStorageChange)
}
componentDidUpdate () {
@ -233,6 +125,7 @@ export class SlideDeck extends React.Component {
}
const { index } = this.state
history.pushState(null, null, '#' + index)
localStorage.setItem('mdx-slide', index)
}
render () {
@ -243,7 +136,11 @@ export class SlideDeck extends React.Component {
width,
height
} = this.props
const { index, length } = this.state
const { index, length, mode } = this.state
const Wrapper = mode === modes.presenter
? Presenter
: Root
return (
<ThemeProvider theme={theme}>
@ -252,7 +149,11 @@ export class SlideDeck extends React.Component {
...defaultComponents,
...components
}}>
<Root width={width} height={height}>
<Wrapper
{...this.state}
slides={slides}
width={width}
height={height}>
<GoogleFonts />
<Carousel index={index}>
{slides.map((Component, i) => (
@ -270,7 +171,7 @@ export class SlideDeck extends React.Component {
this.setState({ index })
}}
/>
</Root>
</Wrapper>
</MDXProvider>
</ThemeProvider>
)

View File

@ -2,16 +2,12 @@ import React from 'react'
import { create as render } from 'react-test-renderer'
import { renderIntoDocument, Simulate } from 'react-dom/test-utils'
import 'jest-styled-components'
import {
inc,
dec,
SlideDeck,
Carousel,
Slide,
Dots,
Root,
GoogleFonts
} from '../src'
import { inc, dec, SlideDeck } from '../src'
import Carousel from '../src/Carousel'
import Slide from '../src/Slide'
import Dots from '../src/Dots'
import Root from '../src/Root'
import GoogleFonts from '../src/GoogleFonts'
const renderJSON = el => render(el).toJSON()
@ -139,6 +135,17 @@ describe('components', () => {
test('renders', () => {
const json = renderJSON(<Dots index={0} length={1} />)
expect(json).toMatchInlineSnapshot(`
.c0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
}
.c1 {
-webkit-appearance: none;
-moz-appearance: none;
@ -160,17 +167,6 @@ describe('components', () => {
box-shadow: 0 0 0 1px;
}
.c0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
}
@media print {
.c0 {
display: none;
@ -192,6 +188,17 @@ describe('components', () => {
test('renders with index', () => {
const json = renderJSON(<Dots index={3} length={8} />)
expect(json).toMatchInlineSnapshot(`
.c0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
}
.c1 {
-webkit-appearance: none;
-moz-appearance: none;
@ -234,17 +241,6 @@ describe('components', () => {
box-shadow: 0 0 0 1px;
}
.c0 {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
}
@media print {
.c0 {
display: none;
@ -466,6 +462,7 @@ describe('components', () => {
className="c0"
color="text"
height="100vh"
mode="NORMAL"
width="100vw"
>
<div