PluginProbe
Extendify / 0.11.1
Extendify v0.11.1
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / src / Onboarding / components / StyledPreview.js

StyledPreview.js in Extendify 0.11.1, at src/Onboarding/components/StyledPreview.js

306 lines 11.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { BlockPreview, transformStyles } from '@wordpress/block-editor'
2 import { rawHandler } from '@wordpress/blocks'
3 import {
4 useState,
5 useRef,
6 useEffect,
7 useMemo,
8 useLayoutEffect,
9 } from '@wordpress/element'
10 import { __ } from '@wordpress/i18n'
11 import classNames from 'classnames'
12 import { getThemeVariations, parseThemeJson } from '@onboarding/api/WPApi'
13 import { SkeletonLoader } from '@onboarding/components/SkeletonLoader'
14 import { useFetch } from '@onboarding/hooks/useFetch'
15 import { useIsMounted } from '@onboarding/hooks/useIsMounted'
16 import { capitalize, lowerImageQuality } from '@onboarding/lib/util'
17 import { useUserSelectionStore } from '@onboarding/state/UserSelections'
18
19 const fetcher = async (themeJson) => {
20 if (!themeJson) return '{}'
21 const res = await parseThemeJson(JSON.stringify(themeJson))
22 if (!res?.styles) {
23 throw new Error('Invalid theme json')
24 }
25 return { data: res.styles }
26 }
27 export const StylePreview = ({
28 style,
29 onSelect,
30 blockHeight,
31 context,
32 active = false,
33 onHover = null,
34 }) => {
35 const siteType = useUserSelectionStore((state) => state.siteType)
36 const isMounted = useIsMounted()
37 const [code, setCode] = useState('')
38 const [loaded, setLoaded] = useState(false)
39 const [inView, setInView] = useState(false)
40 const [hoverCleanup, setHoverCleanup] = useState(null)
41 const [variation, setVariation] = useState()
42 const previewContainer = useRef(null)
43 const content = useRef(null)
44 const blockRef = useRef(null)
45 const observer = useRef(null)
46 const startTime = useRef(null)
47 const loadTime = useRef(false)
48 const { data: themeJson } = useFetch(
49 inView && variation ? variation : null,
50 fetcher,
51 )
52 const { data: variations } = useFetch('variations', getThemeVariations)
53 const theme = variation?.settings?.color?.palette?.theme
54
55 const blocks = useMemo(
56 () => rawHandler({ HTML: lowerImageQuality(code) }),
57 [code],
58 )
59 const transformedStyles = useMemo(
60 () =>
61 themeJson
62 ? transformStyles(
63 [{ css: themeJson }],
64 '.editor-styles-wrapper',
65 )
66 : null,
67 [themeJson],
68 )
69
70 useLayoutEffect(() => {
71 if (!inView || !context.measure) return
72 const key = `${context.type}-${context.detail}`
73 // If the component is in view, start the timer
74 if (!loaded && !loadTime.current) {
75 loadTime.current = 0
76 startTime.current = performance.now()
77 return
78 }
79 let time
80 try {
81 time = performance.measure(key, {
82 start: startTime.current,
83 // The extendify key is used to filter only our measurements
84 detail: { context, extendify: true },
85 })
86 } catch (e) {
87 console.error(e)
88 }
89
90 loadTime.current = time?.duration ?? 0
91 const q = new URLSearchParams(window.location.search)
92 if (q?.has('performance') && loadTime.current) {
93 console.info(
94 `🚀 ${capitalize(context.type)} (${
95 context.detail
96 }) in ${loadTime.current.toFixed()}ms`,
97 )
98 }
99 }, [loaded, context, inView])
100
101 useEffect(() => {
102 if (!variations?.length) return
103
104 // Grab the styles from the theme.json variation
105 const variation = variations.find(
106 (theme) => theme.title === style.label,
107 )
108 setVariation(variation)
109 }, [style, variations])
110
111 useEffect(() => {
112 if (!themeJson || !style?.code) return
113 const code = [style?.headerCode, style?.code, style?.footerCode]
114 .filter(Boolean)
115 .join('')
116 .replace(
117 // <!-- wp:navigation --> <!-- /wp:navigation -->
118 /<!-- wp:navigation[.\S\s]*?\/wp:navigation -->/g,
119 '<!-- wp:paragraph {"className":"tmp-nav"} --><p class="tmp-nav">Home | About | Contact</p ><!-- /wp:paragraph -->',
120 )
121 .replace(
122 // <!-- wp:navigation /-->
123 /<!-- wp:navigation.*\/-->/g,
124 '<!-- wp:paragraph {"className":"tmp-nav"} --><p class="tmp-nav">Home | About | Contact</p ><!-- /wp:paragraph -->',
125 )
126 setCode(code)
127 }, [siteType?.slug, themeJson, style])
128
129 useEffect(() => {
130 let raf1, raf2
131 if (!content.current || !loaded) return
132 const p = previewContainer.current
133 const scale = p.offsetWidth / 1400
134 const iframe = content.current
135 const body = iframe.contentDocument.body
136 if (body?.style) {
137 body.style.transitionProperty = 'all'
138 body.style.top = 0
139 }
140
141 const handleIn = () => {
142 if (!body?.offsetHeight) return
143 const dynBlockHeight =
144 (blockRef?.current?.offsetHeight ?? blockHeight) - 32
145 const bodyHeight =
146 body.getBoundingClientRect().height - dynBlockHeight / scale
147 body.style.transitionDuration =
148 Math.max(bodyHeight * 2, 3000) + 'ms'
149 raf1 = window.requestAnimationFrame(() => {
150 body.style.top = Math.max(0, bodyHeight) * -1 + 'px'
151 })
152 }
153 const handleOut = () => {
154 if (!body?.offsetHeight) return
155 const dynBlockHeight =
156 (blockRef?.current?.offsetHeight ?? blockHeight) - 32
157 const bodyHeight = body.offsetHeight - dynBlockHeight / scale
158 body.style.transitionDuration = bodyHeight + 'ms'
159 raf2 = window.requestAnimationFrame(() => {
160 body.style.top = 0
161 })
162 }
163
164 p.addEventListener('focus', handleIn)
165 p.addEventListener('mouseenter', handleIn)
166 p.addEventListener('blur', handleOut)
167 p.addEventListener('mouseleave', handleOut)
168 return () => {
169 window.cancelAnimationFrame(raf1)
170 window.cancelAnimationFrame(raf2)
171 p.removeEventListener('focus', handleIn)
172 p.removeEventListener('mouseenter', handleIn)
173 p.removeEventListener('blur', handleOut)
174 p.removeEventListener('mouseleave', handleOut)
175 }
176 }, [blockHeight, loaded])
177
178 useEffect(() => {
179 if (!blocks?.length || !inView) return
180 let iframe
181
182 // Inserts theme styles after iframe is loaded
183 const handle = () => {
184 if (!iframe.contentDocument?.getElementById('ext-tj')) {
185 iframe.contentDocument?.head?.insertAdjacentHTML(
186 'beforeend',
187 `<style id="ext-tj">${transformedStyles}</style>`,
188 )
189 }
190 content.current = iframe
191 setTimeout(() => {
192 if (isMounted.current) setLoaded(true)
193 }, 100)
194 }
195 // The callback will attach a load event to the iframe
196 const observer = new MutationObserver(() => {
197 iframe = previewContainer.current.querySelector('iframe[title]')
198 iframe.addEventListener('load', handle)
199 setTimeout(() => {
200 // In some cases, the load event doesn't fire.
201 handle()
202 }, 2000)
203 observer.disconnect()
204 })
205 // observe the ref for when the iframe is injected by wp
206 observer.observe(previewContainer.current, {
207 attributes: false,
208 childList: true,
209 subtree: false,
210 })
211 return () => {
212 observer.disconnect()
213 iframe?.removeEventListener('load', handle)
214 }
215 }, [blocks, transformedStyles, isMounted, inView])
216
217 useEffect(() => {
218 // Only trigger the mutation observer if we are in view
219 if (!observer.current) {
220 observer.current = new IntersectionObserver((entries) => {
221 if (entries[0].isIntersecting) {
222 setInView(true)
223 }
224 })
225 }
226 observer.current.observe(blockRef.current)
227 return () => {
228 observer.current.disconnect()
229 }
230 }, [])
231
232 return (
233 <>
234 {loaded && code ? null : (
235 <>
236 <div className="absolute inset-0 z-20 flex items-center justify-center">
237 <SkeletonLoader
238 context="style"
239 theme={{
240 color: theme?.find(
241 (c) => c.slug === 'foreground',
242 )?.color,
243 bgColor: theme?.find(
244 (c) => c.slug === 'background',
245 )?.color,
246 }}
247 />
248 </div>
249 </>
250 )}
251 <div
252 ref={blockRef}
253 role={onSelect ? 'button' : undefined}
254 tabIndex={onSelect ? 0 : undefined}
255 aria-label={
256 onSelect ? __('Press to select', 'extendify') : undefined
257 }
258 className={classNames(
259 'group w-full overflow-hidden bg-transparent z-10',
260 {
261 'relative min-h-full': loaded,
262 'absolute opacity-0': !loaded,
263 'button-focus button-card p-2': onSelect,
264 'ring-partner-primary-bg ring-offset-2 ring-offset-white ring-wp':
265 active,
266 },
267 )}
268 onKeyDown={(e) => {
269 if (['Enter', 'Space', ' '].includes(e.key)) {
270 onSelect && onSelect({ ...style, variation })
271 }
272 }}
273 onMouseEnter={() => {
274 if (!onHover) return
275 setHoverCleanup(onHover)
276 }}
277 onMouseLeave={() => {
278 if (hoverCleanup) {
279 hoverCleanup()
280 setHoverCleanup(null)
281 }
282 }}
283 onClick={
284 onSelect
285 ? () => onSelect({ ...style, variation })
286 : () => {}
287 }>
288 {window?.extOnbData?.devbuild ? (
289 <div className="-m-px absolute bg-gray-900 border border-t border-white bottom-0 group-hover:opacity-100 left-0 opacity-0 p-1 px-4 text-left text-sm text-white z-30 transition duration-300">
290 {style?.label} - {Number(loadTime.current).toFixed(2)}ms
291 </div>
292 ) : null}
293 <div ref={previewContainer} className="relative rounded-lg">
294 {inView ? (
295 <BlockPreview
296 blocks={blocks}
297 viewportWidth={1400}
298 live={false}
299 />
300 ) : null}
301 </div>
302 </div>
303 </>
304 )
305 }
306