返回 marp
Marp.tsx
根目录 / website / components / Marp.tsx
1 import { Marp as MarpCore } from '@marp-team/marp-core'
2 import classNames from 'classnames'
3 import postcss, { Plugin } from 'postcss'
4 import postcssImportUrl from 'postcss-import-url'
5 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
6 import type { Swiper as SwiperClass } from 'swiper'
7 import { Swiper, SwiperSlide } from 'swiper/react'
8 import { useFontFace } from 'utils/hooks/useFontFace'
9
10 export type RenderedMarp = ReturnType<
11 typeof generateRenderedMarp
12 > extends Promise<infer T>
13 ? T
14 : never
15
16 export type MarpProps = {
17 border?: boolean
18 className?: string
19 rendered: Pick<RenderedMarp, 'css' | 'html' | 'fonts'>
20 page?: number
21 }
22
23 const postcssStripFontFace = Object.assign(
24 (): Plugin => ({
25 postcssPlugin: 'marp-strip-font-face',
26 AtRule: (rule, { result }) => {
27 if (rule.name === 'font-face') {
28 result['fonts'] = [...(result['fonts'] || []), rule]
29 rule.remove()
30 }
31 },
32 }),
33 { postcss: true as const }
34 )
35
36 export const generateRenderedMarp = async (markdown: string) => {
37 const marp = new MarpCore({
38 container: false,
39 script: false,
40 printable: false,
41 })
42
43 const { css, html } = marp.render(markdown, { htmlAsArray: true })
44
45 const result = await postcss()
46 .use(postcssImportUrl)
47 .use(postcssStripFontFace)
48 .process(css, { from: undefined })
49
50 const fonts: string[] = (result['fonts'] || []).map((font) => font.toString())
51
52 return { markdown, html, css: result.css, fonts }
53 }
54
55 export const Marp = ({
56 border = true,
57 className,
58 rendered: { css, html, fonts },
59 page = 1,
60 }: MarpProps) => {
61 const element = useRef<HTMLDivElement>(null)
62
63 useFontFace(fonts)
64
65 useEffect(() => {
66 if (!element.current) return
67 if (!element.current.shadowRoot)
68 element.current.attachShadow({ mode: 'open' })
69
70 // Render Marp slide to shadow root (tailwind default styles will break Marp slide CSS)
71 const root = element.current.shadowRoot as ShadowRoot
72
73 root.innerHTML =
74 html[page - 1] +
75 `<style>${css}</style><style>:host{all:initial;}:host>[data-marpit-svg]{vertical-align:top;}</style>`
76
77 // eslint-disable-next-line @typescript-eslint/no-var-requires
78 return require('@marp-team/marp-core/browser').browser(root)
79 }, [css, html, page])
80
81 return (
82 <div className={classNames(border && 'border shadow-lg', className)}>
83 <span ref={element} />
84 </div>
85 )
86 }
87
88 export const MarpSlides = (props) => {
89 const htmlRaw: string = props['data-html']
90 const css: string = props['data-css']
91 const fontsRaw: string = props['data-fonts']
92
93 const [activePageIdx, setActivePageIdx] = useState(0)
94 const swiper = useRef<SwiperClass>()
95 const html = useMemo(() => JSON.parse(htmlRaw) as string[], [htmlRaw])
96 const multiple = html.length > 1
97 const fonts = useMemo(() => JSON.parse(fontsRaw) as string[], [fontsRaw])
98
99 const handleActiveIndexChange = useCallback((instance: SwiperClass) => {
100 setActivePageIdx(instance.activeIndex)
101 }, [])
102
103 const handleSwiper = useCallback(
104 (instance: SwiperClass) => {
105 swiper.current = instance
106 handleActiveIndexChange(instance)
107 },
108 [handleActiveIndexChange]
109 )
110
111 return (
112 <section className={classNames('marp-slides', multiple && 'multiple')}>
113 {multiple && (
114 <button
115 aria-label="Prev"
116 className="marp-navigation left-0"
117 disabled={activePageIdx <= 0}
118 onClick={() => swiper.current?.slidePrev()}
119 translate="no"
120 >
121 &laquo;
122 </button>
123 )}
124 <Swiper
125 enabled={multiple.toString() as any}
126 allowTouchMove={multiple}
127 speed={200}
128 onActiveIndexChange={handleActiveIndexChange}
129 onSwiper={handleSwiper}
130 >
131 {html.map((h, i) => (
132 <SwiperSlide key={h}>
133 <div inert={activePageIdx === i ? undefined : ''}>
134 <Marp
135 border={false}
136 rendered={{ html, css, fonts }}
137 page={i + 1}
138 />
139 </div>
140 </SwiperSlide>
141 ))}
142 </Swiper>
143 {multiple && (
144 <button
145 aria-label="Next"
146 className="marp-navigation right-0"
147 disabled={activePageIdx >= html.length - 1}
148 onClick={() => swiper.current?.slideNext()}
149 translate="no"
150 >
151 &raquo;
152 </button>
153 )}
154 <style jsx>{`
155 .marp-slides {
156 @apply relative my-6 mx-auto w-full max-w-sm border bg-gray-200 shadow-lg lg:max-w-lg;
157 }
158 .marp-slides.multiple {
159 @apply px-8;
160 }
161
162 .marp-navigation {
163 @apply absolute inset-y-0 z-10 w-8 appearance-none bg-gray-300 text-4xl text-gray-600 outline-none;
164
165 user-select: none;
166 }
167 .marp-navigation:hover:not(:disabled) {
168 @apply bg-gray-400;
169 }
170 .marp-navigation:hover:active {
171 @apply text-gray-700;
172 }
173 .marp-navigation:disabled {
174 @apply pointer-events-none text-opacity-30;
175 }
176 .marp-navigation:focus-visible {
177 @apply ring-marp-brand ring-2;
178 }
179 `}</style>
180 </section>
181 )
182 }
183
183 lines Plain Text