RN' +
'' + entry.label + '' +
'' + entry.files.length + 'f';
btn.addEventListener('click', () => activate(entry.id));
li.appendChild(btn);
return li;
}
(function renderProjectList(){
const groups = []; const seen = new Map();
PROJECTS.forEach(p => {
const g = p.group || 'Other';
if(!seen.has(g)){ seen.set(g, { name:g, items:[] }); groups.push(seen.get(g)); }
seen.get(g).items.push(p);
});
groups.forEach(g => {
const head = document.createElement('li');
head.className = 'file-group-head'; head.textContent = g.name;
fileListEl.appendChild(head);
g.items.forEach(p => fileListEl.appendChild(projectButtonNode(p)));
});
})();
searchEl.addEventListener('input', ()=>{
const q = searchEl.value.toLowerCase().trim();
document.querySelectorAll('.file-btn').forEach(b=>{
const id = b.dataset.proj;
const label = b.querySelector('.label').textContent.toLowerCase();
const match = !q || id.toLowerCase().includes(q) || label.includes(q);
b.parentElement.style.display = match ? '' : 'none';
});
document.querySelectorAll('.file-group-head').forEach(head => {
let n = head.nextElementSibling, anyVisible = false;
while(n && !n.classList.contains('file-group-head')){
if(n.style.display !== 'none'){ anyVisible = true; break; }
n = n.nextElementSibling;
}
head.style.display = anyVisible ? '' : 'none';
});
});
function modeForFile(name){
if(name.endsWith('.tsx') || name.endsWith('.jsx') || name.endsWith('.ts') || name.endsWith('.js'))
return { name: 'jsx', base: { name: 'javascript', typescript: name.endsWith('.tsx') || name.endsWith('.ts') } };
return 'jsx';
}
function badgeForFile(name){
if(name.endsWith('.tsx')) return 'TSX'; // short keeps the file-tabs strip wider
if(name.endsWith('.ts')) return 'TS';
if(name.endsWith('.jsx')) return 'JSX';
return 'TXT';
}
function activeProject(){ return PROJECTS.find(p => p.id === state.active); }
function isDirty(proj, file){
const rx = rxKey(proj, file);
return state.buffers[rx] != null && state.originals[rx] != null && state.buffers[rx] !== state.originals[rx];
}
function refreshDirty(){
const proj = activeProject();
if(!proj) return;
const dirty = proj.files.some(f => isDirty(proj, f));
const btn = document.querySelector('.file-btn[data-proj="' + proj.id + '"]');
if(btn) btn.classList.toggle('dirty', dirty);
fileNameEl.classList.toggle('dirty', dirty);
renderFileTabs();
}
function renderFileTabs(){
const proj = activeProject();
if(!proj){ fileTabsEl.innerHTML = ''; return; }
const cur = state.activeFile[proj.id] || proj.entry;
const esc = (s) => String(s).replace(/&/g,'&').replace(/ {
const isEntry = (proj.bundle && proj.bundle[proj.bundle.length-1] === f) || f === proj.entry;
const cls = ['file-tab'];
if(f === cur) cls.push('active');
if(isEntry) cls.push('entry');
if(isDirty(proj, f)) cls.push('dirty');
return '';
}).join('');
fileTabsEl.querySelectorAll('.file-tab').forEach(b => {
b.addEventListener('click', () => switchFile(b.dataset.file));
});
// Bring the active tab into view (so when you switch step or file the
// selected tab is never hidden under the right-edge fade).
const activeBtn = fileTabsEl.querySelector('.file-tab.active');
if(activeBtn && activeBtn.scrollIntoView){
try { activeBtn.scrollIntoView({ block: 'nearest', inline: 'nearest' }); } catch {}
}
}
// Mouse wheel anywhere over the tab strip scrolls horizontally — otherwise
// users have to know to shift+wheel or two-finger-swipe sideways.
fileTabsEl.addEventListener('wheel', (e) => {
if(fileTabsEl.scrollWidth <= fileTabsEl.clientWidth) return; // nothing to scroll
// Translate vertical wheel into horizontal scroll. Keep horizontal wheel as-is.
const delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX;
if(delta === 0) return;
e.preventDefault();
fileTabsEl.scrollLeft += delta;
}, { passive: false });
function loadEditorFor(file){
const proj = activeProject();
if(!proj) return;
ensureLoaded(proj, file);
editor.setOption('mode', modeForFile(file));
editor.setValue(getFileText(proj, file));
editor.clearHistory();
modeBadgeEl.textContent = badgeForFile(file);
fileSubEl.textContent = 'react-native/' + (proj.pathPrefix || '') + file;
fileNameEl.firstChild.textContent = proj.label.replace(/^\d+\s*·\s*/, '');
}
function switchFile(file){
if(state.bundledMode) exitBundledMode();
const proj = activeProject();
if(!proj) return;
const cur = state.activeFile[proj.id] || proj.entry;
state.buffers[rxKey(proj, cur)] = editor.getValue();
state.activeFile[proj.id] = file;
loadEditorFor(file);
refreshDirty();
editor.focus();
}
function activate(projId){
if(state.bundledMode) exitBundledMode();
const prior = activeProject();
if(prior){
const cur = state.activeFile[prior.id] || prior.entry;
state.buffers[rxKey(prior, cur)] = editor.getValue();
refreshDirty();
}
state.active = projId;
document.querySelectorAll('.file-btn').forEach(b => b.classList.toggle('active', b.dataset.proj === projId));
const proj = activeProject();
proj.files.forEach(f => ensureLoaded(proj, f));
if(!state.activeFile[proj.id]) state.activeFile[proj.id] = proj.entry;
loadEditorFor(state.activeFile[proj.id]);
refreshDirty();
editor.focus();
renderPreview();
}
editor.on('change', ()=>{
if(state.bundledMode) return;
const proj = activeProject();
if(!proj) return;
const cur = state.activeFile[proj.id] || proj.entry;
const rx = rxKey(proj, cur);
const value = editor.getValue();
state.buffers[rx] = value;
// Only persist user diffs — `editor.setValue(original)` on every step
// open would otherwise snapshot the lesson and shadow future updates.
if(value !== state.originals[rx]) saveBufferToStorage(rx, value);
else clearBufferFromStorage(rx);
refreshDirty();
});
/* ==========================================================
"Show bundled output" toggle
========================================================== */
const bundledBtn = document.getElementById('bundledToggleBtn');
function enterBundledMode(){
const proj = activeProject(); if(!proj) return;
const cur = state.activeFile[proj.id] || proj.entry;
state.buffers[rxKey(proj, cur)] = editor.getValue();
state.bundledRestore = { file: cur };
state.bundledMode = true;
const bundle = bundleProjectJs(proj);
editor.setOption('mode', { name: 'jsx', base: { name: 'javascript', typescript: false } });
editor.setOption('readOnly', 'nocursor');
editor.setValue('// 🔨 Read-only — this is the JS that actually runs in the iframe\n' +
'// after import-rewrite + concatenation. Toggle off to edit again.\n\n' + bundle);
editor.clearHistory();
modeBadgeEl.textContent = 'BUNDLED · READ-ONLY';
bundledBtn.classList.add('run-btn');
bundledBtn.textContent = '✎ Source';
fileTabsEl.style.opacity = '0.45'; fileTabsEl.style.pointerEvents = 'none';
}
function exitBundledMode(){
state.bundledMode = false;
editor.setOption('readOnly', false);
bundledBtn.classList.remove('run-btn');
bundledBtn.textContent = '🔨 Bundled';
fileTabsEl.style.opacity = ''; fileTabsEl.style.pointerEvents = '';
const proj = activeProject();
if(proj){
const target = (state.bundledRestore && state.bundledRestore.file) || state.activeFile[proj.id] || proj.entry;
state.activeFile[proj.id] = target;
loadEditorFor(target);
}
state.bundledRestore = null;
}
bundledBtn.addEventListener('click', () => { state.bundledMode ? exitBundledMode() : enterBundledMode(); });
/* ==========================================================
Reset / Render / Pop out
========================================================== */
document.getElementById('resetBtn').addEventListener('click', ()=>{
const proj = activeProject(); if(!proj) return;
if(state.bundledMode) exitBundledMode();
proj.files.forEach(f => {
const rx = rxKey(proj, f);
if(state.originals[rx] != null) state.buffers[rx] = state.originals[rx];
clearBufferFromStorage(rx);
});
loadEditorFor(state.activeFile[proj.id] || proj.entry);
refreshDirty();
renderPreview();
toast('Reset ' + proj.id);
});
document.getElementById('runBtn').addEventListener('click', () => renderPreview());
window.addEventListener('keydown', (e)=>{
if((e.metaKey || e.ctrlKey) && e.key === 'Enter'){ e.preventDefault(); renderPreview(); }
});
/* ==========================================================
TSX/JSX bundler
----------------------------------------------------------
Same flat-concat strategy as the React lab, with extras for
React Native's third-party packages and TypeScript:
import { View, Text } from 'react-native'
→ var View = ReactNative.View, Text = ReactNative.Text;
import { Picker } from '@react-native-picker/picker'
→ var Picker = ReactNativePicker.Picker;
import { NavigationContainer } from '@react-navigation/native'
→ var NavigationContainer = ReactNavigationNative.NavigationContainer;
import { createStackNavigator } from '@react-navigation/stack'
→ var createStackNavigator = ReactNavigationStack.createStackNavigator;
import 'react-native-gesture-handler'; → drop (side-effect only)
require('./assets/icon.png') → data-URL placeholder string
========================================================== */
function transformImports(src){
function destructure(names, source){
return names.split(',').map(n => {
n = n.trim();
const aliased = /^(\w+)\s+as\s+(\w+)$/.exec(n);
if(aliased) return aliased[2] + ' = ' + source + '.' + aliased[1];
return n + ' = ' + source + '.' + n;
}).join(', ');
}
const placeholderImg =
"'data:image/svg+xml;utf8,'";
return src
// require('./X.png') → data-URL placeholder string (RN Image accepts a URI)
.replace(/require\(\s*['"](?:\.{0,2}\/)?[^'"]+\.(png|jpg|jpeg|svg|gif|webp)['"]\s*\)/gi, placeholderImg)
// side-effect imports — drop
.replace(/^\s*import\s+['"][^'"]+['"][^\n]*$/gm, '')
// import logo from './assets/x.svg' or '/x.svg'
.replace(/^\s*import\s+(\w+)\s+from\s+['"](?:\.{0,2}\/)([^'"]+\.svg)['"][^\n]*$/gm,
"var $1 = " + placeholderImg + ";")
// import React, { A } from 'react'
.replace(/^\s*import\s+React\s*,\s*\{\s*([^}]+)\}\s+from\s+['"]react['"][^\n]*$/gm,
(_, names) => 'var ' + destructure(names, 'React') + ';')
// import React from 'react'
.replace(/^\s*import\s+React\s+from\s+['"]react['"][^\n]*$/gm, '')
// import { useState, … } from 'react'
.replace(/^\s*import\s+\{\s*([^}]+)\}\s+from\s+['"]react['"][^\n]*$/gm,
(_, names) => 'var ' + destructure(names, 'React') + ';')
// import { View, Text, … } from 'react-native'
.replace(/^\s*import\s+\{\s*([^}]+)\}\s+from\s+['"]react-native['"][^\n]*$/gm,
(_, names) => 'var ' + destructure(names, 'ReactNative') + ';')
// import { Picker } from '@react-native-picker/picker'
.replace(/^\s*import\s+\{\s*([^}]+)\}\s+from\s+['"]@react-native-picker\/picker['"][^\n]*$/gm,
(_, names) => 'var ' + destructure(names, 'ReactNativePicker') + ';')
// import { NavigationContainer } from '@react-navigation/native'
.replace(/^\s*import\s+\{\s*([^}]+)\}\s+from\s+['"]@react-navigation\/native['"][^\n]*$/gm,
(_, names) => 'var ' + destructure(names, 'ReactNavigationNative') + ';')
// import { createStackNavigator } from '@react-navigation/stack'
.replace(/^\s*import\s+\{\s*([^}]+)\}\s+from\s+['"]@react-navigation\/stack['"][^\n]*$/gm,
(_, names) => 'var ' + destructure(names, 'ReactNavigationStack') + ';')
// import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'
.replace(/^\s*import\s+\{\s*([^}]+)\}\s+from\s+['"]@react-navigation\/bottom-tabs['"][^\n]*$/gm,
(_, names) => 'var ' + destructure(names, 'ReactNavigationBottomTabs') + ';')
// import X from './path' (local default import) → drop
.replace(/^\s*import\s+\w+\s+from\s+['"]\.{1,2}\/[^'"]+['"][^\n]*$/gm, '')
// import { … } from './path' (local named) → drop
.replace(/^\s*import\s+\{[^}]+\}\s+from\s+['"]\.{1,2}\/[^'"]+['"][^\n]*$/gm, '')
// export default function NAME(…) → function NAME(…)
.replace(/^\s*export\s+default\s+function\s+(\w+)/gm, 'function $1')
// export default class NAME → class NAME
.replace(/^\s*export\s+default\s+class\s+(\w+)/gm, 'class $1')
// export default NAME; → drop
.replace(/^\s*export\s+default\s+\w+\s*;?\s*$/gm, '')
// export const/let/var/function/class → strip "export"
.replace(/^\s*export\s+(const|let|var|function|class)\s+/gm, '$1 ');
}
// Pull the default-export name out of the entry file (before transformation
// strips it). We auto-append a mount call for it because real RN files don't
// call ReactDOM.render — the framework does that for them.
function detectDefaultExport(src){
let m;
if((m = src.match(/^\s*export\s+default\s+function\s+(\w+)/m))) return m[1];
if((m = src.match(/^\s*export\s+default\s+class\s+(\w+)/m))) return m[1];
if((m = src.match(/^\s*export\s+default\s+(\w+)\s*;?\s*$/m))) return m[1];
return null;
}
function bundleProjectJs(proj){
if(!proj) return '';
const defaultName = detectDefaultExport(getFileText(proj, proj.entry));
const body = (proj.bundle || [proj.entry])
.map(f => {
const safe = f.replace(/[^a-zA-Z0-9]/g, '_');
let src = transformImports(getFileText(proj, f));
// RN files conventionally declare `const styles = StyleSheet.create({...})`.
// Concatenating multiple files in the router project would re-declare a
// const, which is a fatal parse error. Rename `styles` to a per-file
// suffix so each file's stylesheet stays private.
src = src.replace(/\bstyles\b/g, '__styles_' + safe);
return '/* ============== ' + f + ' ============== */\n' + src;
})
.join('\n\n');
const mount = defaultName
? '\n\n/* === auto-mount: real RN does this at framework level === */\n' +
'ReactDOM.createRoot(document.getElementById("root"))' +
'.render(React.createElement(' + defaultName + '));\n'
: '\n\nconsole.error("[react-native-lab] no `export default` found in ' + proj.entry + '");\n';
return body + mount;
}
/* ==========================================================
Live preview — wraps the bundle in an HTML host that loads
React, ReactDOM, react-native-web, optional shims, and a
manual Babel.transform for TSX.
========================================================== */
const previewEl = document.getElementById('preview');
const previewStatusEl = document.getElementById('previewStatus');
const previewStatsEl = document.getElementById('previewStats');
const autoChk = document.getElementById('autoChk');
/* Which device the preview is currently emulating. Drives both the outer
frame (see initDeviceControls) and the in-iframe native theming below. */
let currentDevice = 'phone';
/* Per-device "native" look: system font, ambient colors and an accent.
Injected into the iframe and consumed by the Button override + base CSS so
the same RN code renders with each platform's conventions. */
const DEVICE_THEMES = {
phone: { os:'iOS', font:'-apple-system, "SF Pro Text", system-ui, sans-serif', bg:'#ffffff', text:'#000000', accent:'#007aff' },
tablet: { os:'iPadOS', font:'-apple-system, "SF Pro Text", system-ui, sans-serif', bg:'#f2f2f7', text:'#000000', accent:'#007aff' },
watch: { os:'watchOS', font:'-apple-system, "SF Pro Text", system-ui, sans-serif', bg:'#000000', text:'#ffffff', accent:'#ff2d55' },
tv: { os:'tvOS', font:'-apple-system, "SF Pro Text", system-ui, sans-serif', bg:'#15151c', text:'#f5f5f7', accent:'#0a84ff' },
car: { os:'Android Auto', font:'"Roboto", system-ui, sans-serif', bg:'#0b0e14', text:'#e8eaed', accent:'#8ab4f8' },
};
/* In-iframe override of RN's