| 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 | « |
| 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 | » |
| 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 |