11import { cdnImports , importFromCdnWithFallback } from './cdn.js'
22import { createCodeMirrorEditor } from './editor-codemirror.js'
3+ import { defaultCss , defaultJsx } from './defaults.js'
34
45const statusNode = document . getElementById ( 'status' )
56const renderMode = document . getElementById ( 'render-mode' )
67const autoRenderToggle = document . getElementById ( 'auto-render' )
78const renderButton = document . getElementById ( 'render-button' )
9+ const copyComponentButton = document . getElementById ( 'copy-component' )
10+ const clearComponentButton = document . getElementById ( 'clear-component' )
811const styleMode = document . getElementById ( 'style-mode' )
12+ const copyStylesButton = document . getElementById ( 'copy-styles' )
13+ const clearStylesButton = document . getElementById ( 'clear-styles' )
914const shadowToggle = document . getElementById ( 'shadow-toggle' )
1015const jsxEditor = document . getElementById ( 'jsx-editor' )
1116const cssEditor = document . getElementById ( 'css-editor' )
12- const previewHost = document . getElementById ( 'preview-host' )
1317const styleWarning = document . getElementById ( 'style-warning' )
1418const cdnLoading = document . getElementById ( 'cdn-loading' )
15-
16- const defaultJsx = [
17- 'const Button = ({ onClick }) => {' ,
18- ' return <button onClick={onClick}>click me</button>' ,
19- '}' ,
20- '' ,
21- 'const App = () => {' ,
22- ' const onClick = () => {' ,
23- " alert('clicked!')" ,
24- ' }' ,
25- '' ,
26- ' return <Button onClick={onClick} />' ,
27- '}' ,
28- '' ,
29- ] . join ( '\n' )
30-
31- const defaultCss = `button {
32- appearance: none;
33- border: 1px solid rgba(122, 107, 255, 0.55);
34- background: linear-gradient(135deg, #7a6bff, #5f4dff);
35- color: #fff;
36- padding: 10px 16px;
37- border-radius: 10px;
38- font-weight: 700;
39- letter-spacing: 0.01em;
40- cursor: pointer;
41- transition:
42- transform 120ms ease,
43- box-shadow 120ms ease,
44- filter 120ms ease;
45- box-shadow:
46- 0 8px 20px rgba(95, 77, 255, 0.28),
47- inset 0 1px 0 rgba(255, 255, 255, 0.18);
48- }
49-
50- button:hover {
51- transform: translateY(-1px);
52- filter: brightness(1.06);
53- }
54-
55- button:active {
56- transform: translateY(0);
57- filter: brightness(0.98);
58- }
59-
60- button:focus-visible {
61- outline: 2px solid #9d91ff;
62- outline-offset: 2px;
63- }
64- `
19+ const previewBgColorInput = document . getElementById ( 'preview-bg-color' )
6520
6621jsxEditor . value = defaultJsx
6722cssEditor . value = defaultCss
6823
24+ let previewHost = document . getElementById ( 'preview-host' )
6925let jsxCodeEditor = null
7026let cssCodeEditor = null
7127let getJsxSource = ( ) => jsxEditor . value
@@ -82,6 +38,8 @@ let compiledStylesCache = {
8238 value : null ,
8339}
8440let hasCompletedInitialRender = false
41+ let previewBackgroundColor = null
42+ const clipboardSupported = Boolean ( navigator . clipboard ?. writeText )
8543
8644const styleLabels = {
8745 css : 'Native CSS' ,
@@ -196,7 +154,140 @@ const debounceRender = () => {
196154 scheduled = setTimeout ( renderPreview , 200 )
197155}
198156
199- const getShadowRoot = ( ) => {
157+ const setJsxSource = value => {
158+ if ( jsxCodeEditor ) {
159+ jsxCodeEditor . setValue ( value )
160+ }
161+ jsxEditor . value = value
162+ }
163+
164+ const setCssSource = value => {
165+ if ( cssCodeEditor ) {
166+ cssCodeEditor . setValue ( value )
167+ }
168+ cssEditor . value = value
169+ }
170+
171+ const clearComponentSource = ( ) => {
172+ setJsxSource ( '' )
173+ if ( ! jsxCodeEditor ) {
174+ maybeRender ( )
175+ }
176+ }
177+
178+ const clearStylesSource = ( ) => {
179+ setCssSource ( '' )
180+ if ( ! cssCodeEditor ) {
181+ maybeRender ( )
182+ }
183+ }
184+
185+ const copyTextToClipboard = async text => {
186+ if ( ! clipboardSupported ) {
187+ throw new Error ( 'Clipboard API is not available in this browser context.' )
188+ }
189+
190+ await navigator . clipboard . writeText ( text )
191+ }
192+
193+ const copyComponentSource = async ( ) => {
194+ try {
195+ await copyTextToClipboard ( getJsxSource ( ) )
196+ setStatus ( 'Component copied' )
197+ } catch {
198+ setStatus ( 'Copy failed' )
199+ }
200+ }
201+
202+ const copyStylesSource = async ( ) => {
203+ try {
204+ await copyTextToClipboard ( getCssSource ( ) )
205+ setStatus ( 'Styles copied' )
206+ } catch {
207+ setStatus ( 'Copy failed' )
208+ }
209+ }
210+
211+ const toHexChannel = value => value . toString ( 16 ) . padStart ( 2 , '0' )
212+
213+ const normalizeColorToHex = colorValue => {
214+ if ( typeof colorValue !== 'string' || colorValue . length === 0 ) {
215+ return '#12141c'
216+ }
217+
218+ if ( / ^ # [ \d a - f ] { 6 } $ / i. test ( colorValue ) ) {
219+ return colorValue . toLowerCase ( )
220+ }
221+
222+ if ( / ^ # [ \d a - f ] { 3 } $ / i. test ( colorValue ) ) {
223+ return colorValue
224+ . slice ( 1 )
225+ . split ( '' )
226+ . map ( channel => channel + channel )
227+ . join ( '' )
228+ . replace ( / ^ / , '#' )
229+ . toLowerCase ( )
230+ }
231+
232+ const channels = colorValue . match ( / \d + / g)
233+ if ( ! channels || channels . length < 3 ) {
234+ return '#12141c'
235+ }
236+
237+ const [ red , green , blue ] = channels . slice ( 0 , 3 ) . map ( value => Number . parseInt ( value , 10 ) )
238+ if ( [ red , green , blue ] . some ( value => Number . isNaN ( value ) ) ) {
239+ return '#12141c'
240+ }
241+
242+ return `#${ toHexChannel ( red ) } ${ toHexChannel ( green ) } ${ toHexChannel ( blue ) } `
243+ }
244+
245+ const applyPreviewBackgroundColor = color => {
246+ if ( ! previewHost ) {
247+ return
248+ }
249+
250+ previewHost . style . backgroundColor = color
251+ }
252+
253+ const initializePreviewBackgroundPicker = ( ) => {
254+ if ( ! previewBgColorInput || ! previewHost ) {
255+ return
256+ }
257+
258+ const initialColor = normalizeColorToHex ( getComputedStyle ( previewHost ) . backgroundColor )
259+ previewBackgroundColor = initialColor
260+ previewBgColorInput . value = initialColor
261+ applyPreviewBackgroundColor ( initialColor )
262+
263+ previewBgColorInput . addEventListener ( 'input' , ( ) => {
264+ previewBackgroundColor = previewBgColorInput . value
265+ applyPreviewBackgroundColor ( previewBackgroundColor )
266+ } )
267+ }
268+
269+ const recreatePreviewHost = ( ) => {
270+ const nextHost = document . createElement ( 'div' )
271+ nextHost . id = 'preview-host'
272+ nextHost . className = previewHost . className
273+ previewHost . replaceWith ( nextHost )
274+ previewHost = nextHost
275+
276+ if ( previewBackgroundColor ) {
277+ applyPreviewBackgroundColor ( previewBackgroundColor )
278+ }
279+ }
280+
281+ const getRenderTarget = ( ) => {
282+ if ( ! shadowToggle . checked && previewHost . shadowRoot ) {
283+ /* ShadowRoot cannot be detached, so recreate the host for light DOM mode. */
284+ if ( reactRoot ) {
285+ reactRoot . unmount ( )
286+ reactRoot = null
287+ }
288+ recreatePreviewHost ( )
289+ }
290+
200291 if ( shadowToggle . checked ) {
201292 if ( ! previewHost . shadowRoot ) {
202293 previewHost . attachShadow ( { mode : 'open' } )
@@ -234,7 +325,10 @@ const applyStyles = (target, cssText) => {
234325 if ( ! target ) return
235326
236327 const styleTag = document . createElement ( 'style' )
237- styleTag . textContent = cssText
328+ const isShadowTarget = target instanceof ShadowRoot
329+ styleTag . textContent = isShadowTarget
330+ ? cssText
331+ : `@scope (#preview-host) {\n${ cssText } \n}`
238332 target . append ( styleTag )
239333}
240334
@@ -554,15 +648,33 @@ const evaluateUserModule = async (helpers = {}) => {
554648 throw error
555649 }
556650
557- const transpiledUserCode = transpileJsxSource ( userCode , {
558- sourceType : 'script' ,
559- } ) . code
651+ const transpileMode = helpers . React && helpers . reactJsx ? 'react' : 'dom'
652+ const transpileOptionsByMode = {
653+ dom : {
654+ sourceType : 'script' ,
655+ createElement : 'jsx.createElement' ,
656+ fragment : 'jsx.Fragment' ,
657+ } ,
658+ react : {
659+ sourceType : 'script' ,
660+ createElement : 'React.createElement' ,
661+ fragment : 'React.Fragment' ,
662+ } ,
663+ }
664+ const transpiledUserCode = transpileJsxSource (
665+ userCode ,
666+ transpileOptionsByMode [ transpileMode ] ,
667+ ) . code
560668 const moduleFactory = createUserModuleFactory ( transpiledUserCode )
561669
562670 if ( helpers . React && helpers . reactJsx ) {
563671 return moduleFactory ( helpers . jsx ?? jsx , helpers . reactJsx , helpers . React )
564672 }
565673
674+ if ( transpileMode === 'dom' ) {
675+ return moduleFactory ( helpers . jsx ?? jsx , helpers . reactJsx , helpers . React )
676+ }
677+
566678 const { React, reactJsx } = await ensureReactRuntime ( )
567679 return moduleFactory ( helpers . jsx ?? jsx , helpers . reactJsx ?? reactJsx , React )
568680 }
@@ -605,7 +717,7 @@ const ensureReactRuntime = async () => {
605717
606718const renderDom = async ( ) => {
607719 const { jsx } = await ensureCoreRuntime ( )
608- const target = getShadowRoot ( )
720+ const target = getRenderTarget ( )
609721 clearTarget ( target )
610722 const compiledStyles = await compileStyles ( )
611723 applyStyles ( target , compiledStyles . css )
@@ -627,13 +739,13 @@ const renderDom = async () => {
627739}
628740
629741const renderReact = async ( ) => {
630- const target = getShadowRoot ( )
742+ const target = getRenderTarget ( )
631743 clearTarget ( target )
632744 const compiledStyles = await compileStyles ( )
633745 applyStyles ( target , compiledStyles . css )
634746
635747 const { reactJsx, createRoot, React } = await ensureReactRuntime ( )
636- const renderFn = await evaluateUserModule ( { jsx : reactJsx , reactJsx } )
748+ const renderFn = await evaluateUserModule ( { jsx : reactJsx , reactJsx, React } )
637749 if ( ! renderFn ) {
638750 throw new Error ( 'Expected a render() function or a component named App/View.' )
639751 }
@@ -666,7 +778,7 @@ const renderPreview = async () => {
666778 setStatus ( 'Rendered' )
667779 } catch ( error ) {
668780 setStatus ( 'Error' )
669- const target = getShadowRoot ( )
781+ const target = getRenderTarget ( )
670782 clearTarget ( target )
671783 const message = document . createElement ( 'pre' )
672784 message . textContent = error instanceof Error ? error . message : String ( error )
@@ -686,6 +798,10 @@ const maybeRender = () => {
686798 }
687799}
688800
801+ const updateRenderButtonVisibility = ( ) => {
802+ renderButton . hidden = autoRenderToggle . checked
803+ }
804+
689805renderMode . addEventListener ( 'change' , maybeRender )
690806styleMode . addEventListener ( 'change' , ( ) => {
691807 if ( cssCodeEditor ) {
@@ -695,15 +811,31 @@ styleMode.addEventListener('change', () => {
695811} )
696812shadowToggle . addEventListener ( 'change' , maybeRender )
697813autoRenderToggle . addEventListener ( 'change' , ( ) => {
814+ updateRenderButtonVisibility ( )
698815 if ( autoRenderToggle . checked ) {
699816 renderPreview ( )
700817 }
701818} )
702819renderButton . addEventListener ( 'click' , renderPreview )
820+ if ( clipboardSupported ) {
821+ copyComponentButton . addEventListener ( 'click' , ( ) => {
822+ void copyComponentSource ( )
823+ } )
824+ copyStylesButton . addEventListener ( 'click' , ( ) => {
825+ void copyStylesSource ( )
826+ } )
827+ } else {
828+ copyComponentButton . hidden = true
829+ copyStylesButton . hidden = true
830+ }
831+ clearComponentButton . addEventListener ( 'click' , clearComponentSource )
832+ clearStylesButton . addEventListener ( 'click' , clearStylesSource )
703833jsxEditor . addEventListener ( 'input' , maybeRender )
704834cssEditor . addEventListener ( 'input' , maybeRender )
705835
836+ updateRenderButtonVisibility ( )
706837setStyleCompiling ( false )
707838setCdnLoading ( true )
839+ initializePreviewBackgroundPicker ( )
708840void initializeCodeEditors ( )
709841renderPreview ( )
0 commit comments