Add responsive preview, editor themes, template gallery, devtools, and autocomplete

- Device breakpoint toggles (mobile 375px / tablet 768px / desktop 100%)
- Editor theme selector with 6 themes (VS Dark/Light, High Contrast, Monokai, Dracula, GitHub Dark)
- Starter template gallery with 8 pre-built templates (Todo, API Fetch, CSS Animation, etc.)
- Code autocomplete with DOM/React type definitions and snippet completions
- Devtools panels: console, network, elements, performance
- Code formatter (Prettier), diff view, and linter integration
This commit is contained in:
root
2026-02-27 01:22:01 -06:00
parent b18c9c1dc8
commit 6ca8519250
19 changed files with 1755 additions and 29 deletions

View File

@@ -13,20 +13,22 @@
--label-h: 26px;
}
html, body { height: 100%; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); }
html, body { height: 100%; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); display: flex; flex-direction: column; }
/* Toolbar */
.toolbar {
height: var(--toolbar-h);
min-height: var(--toolbar-h);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
padding: 4px 12px;
background: var(--surface);
border-bottom: 1px solid var(--border);
gap: 8px;
flex-wrap: wrap;
}
.toolbar-left, .toolbar-right { display: flex; align-items: center; gap: 8px; }
.toolbar-left, .toolbar-right { display: flex; align-items: center; gap: 8px; flex-shrink: 1; min-width: 0; }
.toolbar-right { flex-wrap: wrap; }
.logo { font-weight: 700; font-size: 16px; color: var(--accent); text-decoration: none; }
#title-input {
background: transparent; border: 1px solid transparent; color: var(--text);
@@ -52,7 +54,7 @@ button:hover { background: var(--accent-hover); }
display: grid;
grid-template-columns: 1fr 4px 1fr;
grid-template-rows: 1fr 4px 1fr;
height: calc(100vh - var(--toolbar-h));
flex: 1; min-height: 0;
}
.panel { position: relative; border: 1px solid var(--border); overflow: hidden; display: flex; flex-direction: column; }
.panel-label {
@@ -155,7 +157,20 @@ body.resizing iframe { pointer-events: none; }
}
.editor-container.active { display: block; }
#preview-frame { flex: 1; border: none; background: #fff; width: 100%; }
/* Preview viewport + responsive device toggles */
.preview-viewport { flex: 1; min-height: 0; display: flex; justify-content: center; overflow: auto; background: inherit; }
#preview-frame { border: none; background: #fff; width: 100%; height: 100%; }
.preview-viewport.device-tablet #preview-frame { max-width: 768px; }
.preview-viewport.device-mobile #preview-frame { max-width: 375px; }
.preview-viewport.device-tablet, .preview-viewport.device-mobile { background: #111; }
.device-toggles { display: flex; gap: 2px; }
.device-btn {
background: transparent; border: none; color: var(--text-dim); padding: 2px 5px;
cursor: pointer; border-radius: 3px; display: flex; align-items: center; justify-content: center;
}
.device-btn:hover { color: var(--text); background: rgba(255,255,255,0.06); }
.device-btn.active { color: var(--accent); background: rgba(0,120,212,0.12); }
/* Console */
#console-output {
@@ -211,7 +226,7 @@ body.resizing iframe { pointer-events: none; }
.divider { background: var(--border); transition: background 0.15s; z-index: 2; }
/* Layout/keybinding/preview-theme selects — match framework select */
#layout-mode, #keybinding-mode, #preview-theme {
#layout-mode, #keybinding-mode, #preview-theme, #editor-theme {
background: var(--bg); color: var(--text); border: 1px solid var(--border);
padding: 4px 6px; border-radius: 4px; font-size: 12px; cursor: pointer;
}
@@ -343,3 +358,240 @@ body.resizing iframe { pointer-events: none; }
#title-input, .tags-input-wrap { display: none; }
.modal-content { margin: 8px; min-width: unset !important; width: calc(100% - 16px); }
}
/* ===================== Devtools Tabs ===================== */
.devtools-tabs {
height: var(--label-h);
display: flex;
align-items: center;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
gap: 0;
padding: 0 4px;
}
.devtools-tab {
background: transparent;
color: var(--text-dim);
border: none;
border-bottom: 2px solid transparent;
padding: 0 10px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
cursor: pointer;
height: 100%;
border-radius: 0;
display: flex;
align-items: center;
position: relative;
}
.devtools-tab:hover { color: var(--text); background: rgba(255,255,255,0.04); }
.devtools-tab.active { color: var(--text); border-bottom-color: var(--accent); }
.devtools-tab .badge {
background: #f44747;
color: #fff;
font-size: 9px;
font-weight: 700;
padding: 0 4px;
border-radius: 8px;
margin-left: 4px;
min-width: 14px;
text-align: center;
line-height: 14px;
}
.devtools-spacer { flex: 1; }
.devtools-panels { flex: 1; overflow: hidden; position: relative; min-height: 0; }
.devtools-panel {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
display: none;
flex-direction: column;
overflow: hidden;
}
.devtools-panel.active { display: flex; }
/* Console output (unchanged, just nested deeper now) */
#console-output { flex: 1; overflow-y: auto; }
/* ===================== Network Tab ===================== */
.network-table {
width: 100%;
border-collapse: collapse;
font-family: 'Cascadia Code', 'Fira Code', monospace;
font-size: 11px;
}
.network-table th {
position: sticky;
top: 0;
background: var(--surface);
color: var(--text-dim);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
text-align: left;
padding: 4px 8px;
border-bottom: 1px solid var(--border);
}
.network-table td {
padding: 3px 8px;
border-bottom: 1px solid #2a2a2a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
}
.network-table tr:hover { background: rgba(255,255,255,0.03); }
.net-type {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
padding: 1px 4px;
border-radius: 2px;
display: inline-block;
}
.net-type-script { background: #4d3b00; color: #dcdcaa; }
.net-type-link, .net-type-css { background: #264f78; color: #9cdcfe; }
.net-type-img { background: #1e4620; color: #6a9955; }
.net-type-fetch, .net-type-xmlhttprequest { background: #3b1f6e; color: #c586c0; }
.net-type-other { background: var(--border); color: var(--text-dim); }
.network-footer {
position: sticky;
bottom: 0;
background: var(--surface);
padding: 4px 8px;
font-size: 10px;
color: var(--text-dim);
border-top: 1px solid var(--border);
display: flex;
gap: 16px;
}
#network-output {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
/* ===================== Elements Tab ===================== */
#elements-output {
flex: 1;
overflow: auto;
padding: 6px 0;
font-family: 'Cascadia Code', 'Fira Code', monospace;
font-size: 12px;
line-height: 1.5;
}
.el-node { padding-left: 16px; }
.el-node-header {
display: flex;
align-items: center;
gap: 2px;
cursor: pointer;
padding: 1px 4px;
border-radius: 2px;
}
.el-node-header:hover { background: rgba(255,255,255,0.04); }
.el-toggle {
width: 14px;
font-size: 10px;
color: var(--text-dim);
flex-shrink: 0;
text-align: center;
user-select: none;
}
.el-tag { color: #c586c0; }
.el-attr-name { color: #9cdcfe; }
.el-attr-value { color: #ce9178; }
.el-text { color: var(--text-dim); font-style: italic; }
.el-children { display: none; }
.el-children.expanded { display: block; }
.el-refresh {
margin-left: 8px;
font-size: 10px;
padding: 1px 6px;
}
/* ===================== Performance Tab ===================== */
#performance-output {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-wrap: wrap;
gap: 10px;
align-content: flex-start;
}
.perf-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px 16px;
min-width: 140px;
flex: 1;
}
.perf-card-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 4px;
}
.perf-card-value {
font-family: 'Cascadia Code', 'Fira Code', monospace;
font-size: 20px;
font-weight: 700;
}
.perf-green { color: #6a9955; }
.perf-yellow { color: #cca700; }
.perf-red { color: #f44747; }
.perf-neutral { color: var(--text); }
/* ===================== Format / Diff Buttons ===================== */
.tab-bar-spacer { flex: 1; }
.tab-bar-btn {
background: transparent;
color: var(--text-dim);
border: none;
border-bottom: 2px solid transparent;
padding: 0 10px;
font-size: 11px;
font-weight: 500;
cursor: pointer;
border-radius: 0;
display: flex;
align-items: center;
white-space: nowrap;
}
.tab-bar-btn:hover { color: var(--text); background: rgba(255,255,255,0.04); }
.tab-bar-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
/* ===================== Templates Gallery ===================== */
.templates-modal-content { min-width: 560px; max-width: 640px; }
.templates-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
max-height: 420px;
overflow-y: auto;
padding: 4px;
}
.template-card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
text-align: left;
}
.template-card:hover { border-color: var(--accent); background: rgba(0,120,212,0.06); }
.template-card-icon { font-size: 22px; margin-bottom: 6px; }
.template-card-title { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 4px; }
.template-card-desc { font-size: 11px; color: var(--text-dim); line-height: 1.4; }
.template-card-mode { font-size: 9px; font-weight: 600; text-transform: uppercase; color: var(--accent); margin-top: 6px; }
@media (max-width: 480px) {
.templates-modal-content { min-width: unset !important; }
.templates-grid { grid-template-columns: 1fr; }
}

View File

@@ -32,6 +32,14 @@
<option value="vim">Vim</option>
<option value="emacs">Emacs</option>
</select>
<select id="editor-theme">
<option value="vs-dark">VS Dark</option>
<option value="vs">VS Light</option>
<option value="hc-black">High Contrast</option>
<option value="monokai">Monokai</option>
<option value="dracula">Dracula</option>
<option value="github-dark">GitHub Dark</option>
</select>
</div>
<div class="toolbar-right">
<div class="tags-input-wrap">
@@ -43,6 +51,7 @@
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<button id="btn-templates" class="btn-secondary" title="Starter templates gallery">Templates</button>
<button id="btn-resources" class="btn-secondary" title="External CSS/JS resources">Resources</button>
<button id="btn-shortcuts" class="btn-secondary" title="Keyboard shortcuts (?)" aria-label="Keyboard shortcuts">?</button>
<label class="tailwind-toggle" title="Enable Tailwind CSS">
@@ -73,14 +82,49 @@
</div>
<div class="divider divider-col" id="divider-col"></div>
<div class="panel panel-preview">
<div class="panel-label">Preview</div>
<div class="panel-label">
<span>Preview</span>
<span class="device-toggles">
<button class="device-btn active" data-device="desktop" title="Desktop (100%)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</button>
<button class="device-btn" data-device="tablet" title="Tablet (768px)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="12" y1="18" x2="12" y2="18" stroke-linecap="round"/></svg>
</button>
<button class="device-btn" data-device="mobile" title="Mobile (375px)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12" y2="18" stroke-linecap="round"/></svg>
</button>
</span>
</div>
<div class="preview-viewport" id="preview-viewport">
<iframe id="preview-frame" sandbox="allow-scripts allow-same-origin"></iframe>
</div>
</div>
<div class="divider divider-row" id="divider-row"></div>
<div class="panel panel-console">
<div class="panel-label">Console <button id="btn-clear-console" class="btn-small">Clear</button></div>
<div class="devtools-tabs">
<button class="devtools-tab active" data-tab="console">Console</button>
<button class="devtools-tab" data-tab="network">Network</button>
<button class="devtools-tab" data-tab="elements">Elements</button>
<button class="devtools-tab" data-tab="performance">Performance</button>
<span class="devtools-spacer"></span>
<button id="btn-clear-devtools" class="btn-small">Clear</button>
</div>
<div class="devtools-panels">
<div class="devtools-panel active" id="panel-console">
<div id="console-output"></div>
</div>
<div class="devtools-panel" id="panel-network">
<div id="network-output"></div>
</div>
<div class="devtools-panel" id="panel-elements">
<div id="elements-output"></div>
</div>
<div class="devtools-panel" id="panel-performance">
<div id="performance-output"></div>
</div>
</div>
</div>
</main>
<div id="resources-modal" class="modal-overlay hidden">
@@ -114,6 +158,8 @@
<tr><td><kbd>Ctrl/Cmd</kbd> + <kbd>Enter</kbd></td><td>Run code</td></tr>
<tr><td><kbd>Ctrl/Cmd</kbd> + <kbd>S</kbd></td><td>Save fiddle</td></tr>
<tr><td><kbd>?</kbd></td><td>Show shortcuts</td></tr>
<tr><td><kbd>Shift</kbd> + <kbd>Alt</kbd> + <kbd>F</kbd></td><td>Format code (Prettier)</td></tr>
<tr><td><kbd>Ctrl/Cmd</kbd> + <kbd>D</kbd></td><td>Toggle diff view</td></tr>
<tr class="shortcuts-divider"><td colspan="2">Keybinding Modes</td></tr>
<tr><td><kbd>Vim</kbd></td><td>Full vim keybindings (select in toolbar)</td></tr>
<tr><td><kbd>Emacs</kbd></td><td>Full emacs keybindings (select in toolbar)</td></tr>
@@ -133,6 +179,16 @@
</div>
</div>
<div id="templates-modal" class="modal-overlay hidden">
<div class="modal-content templates-modal-content">
<div class="modal-header">
<span>Starter Templates</span>
<button id="templates-modal-close" class="btn-small">&times;</button>
</div>
<div class="templates-grid" id="templates-grid"></div>
</div>
</div>
<div id="share-toast" class="toast hidden"></div>
<script>

View File

@@ -1,6 +1,7 @@
import {
initEditors, switchMode, getEditorValues, setEditorValues,
setOnChange, getCurrentMode, getCssType, setCssType,
setOnChange, setOnTabSwitch, getCurrentMode, getCssType, setCssType,
setOnFormat, setOnDiff, setEditorTheme,
relayoutEditors,
MODE_TABS, MODE_TO_JS_TYPE, JS_TYPE_TO_MODE,
} from './editors.js';
@@ -15,6 +16,16 @@ import { initKeybindings } from './keybindings.js';
import { initResizer, clearInlineSizes } from './resizer.js';
import { exportHtml } from './export.js';
import { showQrModal } from './qr.js';
import { initDevtools } from './devtools.js';
import { initNetwork, clearNetwork } from './network-panel.js';
import { initElements, clearElements } from './elements-panel.js';
import { initPerformance, clearPerformance } from './performance-panel.js';
import { formatActiveEditor } from './formatter.js';
import { initLinter, lintOnChange } from './linter.js';
import { toggleDiff, snapshotValues, onTabSwitch as diffOnTabSwitch } from './diff-view.js';
import { registerCustomThemes, THEMES } from './editor-themes.js';
import { GALLERY_TEMPLATES } from './templates.js';
import { configureTypeDefaults, registerSnippetProviders } from './autocomplete.js';
let currentId = null;
let debounceTimer = null;
@@ -73,6 +84,9 @@ async function run() {
const compiledCss = await compileCss(css, cssType);
const result = await compileJs(js, mode);
clearConsole();
clearNetwork();
clearElements();
clearPerformance();
// Show warnings from compilation (e.g., Svelte)
if (result.warnings && result.warnings.length) {
@@ -121,10 +135,12 @@ async function save() {
try {
if (currentId) {
await updateFiddle(currentId, { title, html, css, css_type, js, js_type, listed, options, tags });
snapshotValues();
showToast(`Saved! Share: ${location.origin}/f/${currentId}`);
} else {
const result = await createFiddle({ title, html, css, css_type, js, js_type, listed, options, tags });
currentId = result.id;
snapshotValues();
history.pushState(null, '', `/f/${currentId}`);
showToast(`Saved! Share: ${location.origin}/f/${currentId}`);
}
@@ -181,6 +197,7 @@ async function loadFromUrl() {
renderResourceList();
setEditorValues(fiddle);
snapshotValues();
setTimeout(run, 100);
} catch (e) {
showToast(`Failed to load fiddle: ${e.message}`);
@@ -225,11 +242,26 @@ async function init() {
// Load Emmet before editors so completion providers are registered
await initEmmet();
// Register custom Monaco themes before creating editors
registerCustomThemes();
// Configure autocomplete: type defaults + snippet providers
configureTypeDefaults();
registerSnippetProviders();
initEditors('html-css-js');
setOnChange(scheduleRun);
setOnChange(() => { scheduleRun(); lintOnChange(); });
setOnFormat(() => formatActiveEditor());
setOnDiff(() => toggleDiff());
setOnTabSwitch(diffOnTabSwitch);
initConsole();
initDevtools();
initNetwork();
initElements();
initPerformance();
initResizer();
initKeybindings();
initLinter();
// Auto-run checkbox
const autoRunCb = $('#auto-run-checkbox');
@@ -249,6 +281,65 @@ async function init() {
if (savedLayout !== 'default') applyLayout(savedLayout);
layoutSel.addEventListener('change', (e) => applyLayout(e.target.value));
// Editor theme selector
const themeSel2 = $('#editor-theme');
const savedEditorTheme = getPref('editorTheme') || 'vs-dark';
themeSel2.value = savedEditorTheme;
themeSel2.addEventListener('change', (e) => {
setEditorTheme(e.target.value);
setPref('editorTheme', e.target.value);
});
// Device preview toggles
const viewport = $('#preview-viewport');
const savedDevice = getPref('previewDevice') || 'desktop';
if (savedDevice !== 'desktop') {
viewport.classList.add(`device-${savedDevice}`);
document.querySelectorAll('.device-btn').forEach(b => {
b.classList.toggle('active', b.dataset.device === savedDevice);
});
}
document.querySelectorAll('.device-btn').forEach(btn => {
btn.addEventListener('click', () => {
const device = btn.dataset.device;
viewport.classList.remove('device-tablet', 'device-mobile');
if (device !== 'desktop') viewport.classList.add(`device-${device}`);
document.querySelectorAll('.device-btn').forEach(b => b.classList.toggle('active', b === btn));
setPref('previewDevice', device);
});
});
// Templates gallery
const tplModal = $('#templates-modal');
const tplGrid = $('#templates-grid');
$('#btn-templates').addEventListener('click', () => tplModal.classList.remove('hidden'));
$('#templates-modal-close').addEventListener('click', () => tplModal.classList.add('hidden'));
tplModal.addEventListener('click', (e) => { if (e.target === tplModal) tplModal.classList.add('hidden'); });
// Render template cards
tplGrid.innerHTML = '';
for (const tpl of GALLERY_TEMPLATES) {
const card = document.createElement('div');
card.className = 'template-card';
card.innerHTML = `
<div class="template-card-icon">${tpl.icon}</div>
<div class="template-card-title">${tpl.title}</div>
<div class="template-card-desc">${tpl.description}</div>
<div class="template-card-mode">${tpl.mode}</div>
`;
card.addEventListener('click', () => {
// Switch mode
$('#framework-mode').value = tpl.mode;
handleModeChange(tpl.mode);
// Set editor values
setEditorValues({ html: tpl.html || '', css: tpl.css || '', js: tpl.js || '' });
// Close modal and run
tplModal.classList.add('hidden');
run();
});
tplGrid.appendChild(card);
}
// Mode selector
$('#framework-mode').addEventListener('change', (e) => {
handleModeChange(e.target.value);
@@ -273,6 +364,16 @@ async function init() {
if (e.key === 'Escape') {
document.querySelectorAll('.modal-overlay:not(.hidden)').forEach(m => m.classList.add('hidden'));
}
// Shift+Alt+F — format code
if (e.key === 'F' && e.shiftKey && e.altKey) {
e.preventDefault();
formatActiveEditor();
}
// Ctrl/Cmd+D — toggle diff
if ((e.ctrlKey || e.metaKey) && e.key === 'd') {
e.preventDefault();
toggleDiff();
}
// ? key opens shortcuts (only when not typing in an input/editor)
if (e.key === '?' && !e.ctrlKey && !e.metaKey) {
const tag = document.activeElement?.tagName;

142
public/js/autocomplete.js Normal file
View File

@@ -0,0 +1,142 @@
// Code autocomplete: type definitions + snippet completions
let reactTypesAdded = false;
// Add DOM lib so document.*, window.*, etc. autocomplete in JS/TS
export function configureTypeDefaults() {
const jsOpts = {
target: monaco.languages.typescript.ScriptTarget.ESNext,
allowNonTsExtensions: true,
allowJs: true,
checkJs: false,
noEmit: true,
lib: ['esnext', 'dom', 'dom.iterable'],
};
monaco.languages.typescript.javascriptDefaults.setCompilerOptions(jsOpts);
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
...jsOpts,
allowJs: undefined,
checkJs: undefined,
});
// Relax diagnostics for playground context
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
noSemanticValidation: true,
noSyntaxValidation: false,
});
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
noSemanticValidation: false,
noSyntaxValidation: false,
});
}
// Add React/ReactDOM type stubs for IntelliSense
export function addReactTypes() {
if (reactTypesAdded) return;
reactTypesAdded = true;
const reactTypes = `
declare namespace React {
type ReactNode = string | number | boolean | null | undefined | ReactElement | ReactNode[];
interface ReactElement { type: any; props: any; key: string | null; }
interface RefObject<T> { current: T | null; }
type FC<P = {}> = (props: P) => ReactElement | null;
type ChangeEvent<T = Element> = { target: T; currentTarget: T; preventDefault(): void; stopPropagation(): void; };
type FormEvent<T = Element> = ChangeEvent<T>;
type MouseEvent<T = Element> = ChangeEvent<T> & { clientX: number; clientY: number; pageX: number; pageY: number; };
type KeyboardEvent<T = Element> = ChangeEvent<T> & { key: string; code: string; altKey: boolean; ctrlKey: boolean; shiftKey: boolean; metaKey: boolean; };
type CSSProperties = { [key: string]: string | number | undefined; };
type Dispatch<A> = (value: A) => void;
type SetStateAction<S> = S | ((prevState: S) => S);
function createElement(type: any, props?: any, ...children: any[]): ReactElement;
function useState<S>(initialState: S | (() => S)): [S, Dispatch<SetStateAction<S>>];
function useState<S = undefined>(): [S | undefined, Dispatch<SetStateAction<S | undefined>>];
function useEffect(effect: () => void | (() => void), deps?: any[]): void;
function useRef<T>(initialValue: T): RefObject<T>;
function useRef<T = undefined>(): RefObject<T | undefined>;
function useMemo<T>(factory: () => T, deps: any[]): T;
function useCallback<T extends (...args: any[]) => any>(callback: T, deps: any[]): T;
function useContext<T>(context: React.Context<T>): T;
function useReducer<S, A>(reducer: (state: S, action: A) => S, initialState: S): [S, Dispatch<A>];
function useId(): string;
function memo<P>(component: FC<P>): FC<P>;
function forwardRef<T, P = {}>(render: (props: P, ref: RefObject<T>) => ReactElement | null): FC<P>;
function createContext<T>(defaultValue: T): Context<T>;
function Fragment(props: { children?: ReactNode }): ReactElement;
interface Context<T> { Provider: FC<{ value: T; children?: ReactNode }>; Consumer: FC<{ children: (value: T) => ReactNode }>; }
}
declare namespace ReactDOM {
function createRoot(container: Element | null): { render(element: any): void; unmount(): void; };
function render(element: any, container: Element | null): void;
}
`;
monaco.languages.typescript.javascriptDefaults.addExtraLib(reactTypes, 'react-global.d.ts');
monaco.languages.typescript.typescriptDefaults.addExtraLib(reactTypes, 'react-global.d.ts');
}
// Register JS snippet completion provider
export function registerSnippetProviders() {
const jsSnippets = [
{ label: 'log', insert: "console.log($1);", doc: 'console.log()' },
{ label: 'qs', insert: "document.querySelector('$1')", doc: 'document.querySelector()' },
{ label: 'qsa', insert: "document.querySelectorAll('$1')", doc: 'document.querySelectorAll()' },
{ label: 'gid', insert: "document.getElementById('$1')", doc: 'document.getElementById()' },
{ label: 'ael', insert: "$1.addEventListener('$2', ($3) => {\n\t$4\n});", doc: 'addEventListener' },
{ label: 'afn', insert: "($1) => {\n\t$2\n}", doc: 'Arrow function' },
{ label: 'afni', insert: "($1) => $2", doc: 'Arrow function (inline)' },
{ label: 'fn', insert: "function $1($2) {\n\t$3\n}", doc: 'Function declaration' },
{ label: 'forof', insert: "for (const $1 of $2) {\n\t$3\n}", doc: 'for...of loop' },
{ label: 'forin', insert: "for (const $1 in $2) {\n\t$3\n}", doc: 'for...in loop' },
{ label: 'fore', insert: "$1.forEach(($2) => {\n\t$3\n});", doc: 'forEach loop' },
{ label: 'map', insert: "$1.map(($2) => $3)", doc: 'Array.map()' },
{ label: 'filter', insert: "$1.filter(($2) => $3)", doc: 'Array.filter()' },
{ label: 'reduce', insert: "$1.reduce(($2, $3) => $4, $5)", doc: 'Array.reduce()' },
{ label: 'fetch', insert: "const res = await fetch('$1');\nconst data = await res.json();", doc: 'Fetch API' },
{ label: 'promise', insert: "new Promise((resolve, reject) => {\n\t$1\n})", doc: 'New Promise' },
{ label: 'timeout', insert: "setTimeout(() => {\n\t$1\n}, $2);", doc: 'setTimeout' },
{ label: 'interval', insert: "setInterval(() => {\n\t$1\n}, $2);", doc: 'setInterval' },
{ label: 'raf', insert: "requestAnimationFrame($1);", doc: 'requestAnimationFrame' },
{ label: 'trycatch', insert: "try {\n\t$1\n} catch (err) {\n\t$2\n}", doc: 'try/catch block' },
{ label: 'class', insert: "class $1 {\n\tconstructor($2) {\n\t\t$3\n\t}\n}", doc: 'Class declaration' },
{ label: 'imp', insert: "import { $2 } from '$1';", doc: 'Import statement' },
{ label: 'cel', insert: "document.createElement('$1')", doc: 'createElement' },
];
const reactSnippets = [
{ label: 'ustate', insert: "const [$1, set${1/(.*)/${1:/capitalize}/}] = React.useState($2);", doc: 'React.useState' },
{ label: 'ueffect', insert: "React.useEffect(() => {\n\t$1\n\treturn () => { $2 };\n}, [$3]);", doc: 'React.useEffect' },
{ label: 'uref', insert: "const $1 = React.useRef($2);", doc: 'React.useRef' },
{ label: 'umemo', insert: "const $1 = React.useMemo(() => $2, [$3]);", doc: 'React.useMemo' },
{ label: 'ucallback', insert: "const $1 = React.useCallback(($2) => {\n\t$3\n}, [$4]);", doc: 'React.useCallback' },
{ label: 'comp', insert: "const $1 = ($2) => {\n\treturn (\n\t\t<div>\n\t\t\t$3\n\t\t</div>\n\t);\n};", doc: 'React component' },
];
// JS/TS snippets
for (const lang of ['javascript', 'typescript']) {
monaco.languages.registerCompletionItemProvider(lang, {
provideCompletionItems(model, position) {
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
const all = [...jsSnippets, ...reactSnippets];
return {
suggestions: all.map(s => ({
label: s.label,
kind: monaco.languages.CompletionItemKind.Snippet,
documentation: s.doc,
insertText: s.insert,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
range,
})),
};
},
});
}
}

View File

@@ -1,6 +1,10 @@
import { registerClearHandler } from './devtools.js';
const output = () => document.getElementById('console-output');
export function initConsole() {
registerClearHandler('console', clearConsole);
window.addEventListener('message', (e) => {
if (!e.data || e.data.type !== 'console') return;
if (e.data.method === 'clear') {
@@ -9,8 +13,6 @@ export function initConsole() {
}
appendLine(e.data.method, (e.data.args || []).join(' '));
});
document.getElementById('btn-clear-console').addEventListener('click', clearConsole);
}
function appendLine(method, text) {

31
public/js/devtools.js Normal file
View File

@@ -0,0 +1,31 @@
let activeTab = 'console';
const clearHandlers = {};
export function registerClearHandler(tabId, fn) {
clearHandlers[tabId] = fn;
}
export function switchDevtoolsTab(tabId) {
activeTab = tabId;
document.querySelectorAll('.devtools-tab').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tabId);
});
document.querySelectorAll('.devtools-panel').forEach(panel => {
panel.classList.toggle('active', panel.id === `panel-${tabId}`);
});
}
export function getActiveDevtoolsTab() {
return activeTab;
}
export function initDevtools() {
document.querySelectorAll('.devtools-tab').forEach(btn => {
btn.addEventListener('click', () => switchDevtoolsTab(btn.dataset.tab));
});
document.getElementById('btn-clear-devtools').addEventListener('click', () => {
const handler = clearHandlers[activeTab];
if (handler) handler();
});
}

114
public/js/diff-view.js Normal file
View File

@@ -0,0 +1,114 @@
import { getActiveEditor, getActiveTab, getEditorValues, getCurrentMode, MODE_TABS } from './editors.js';
let diffEditor = null;
let isActive = false;
let savedValues = { html: '', css: '', js: '' };
export function snapshotValues() {
const vals = getEditorValues();
savedValues = { ...vals };
}
export function isDiffActive() {
return isActive;
}
export function toggleDiff() {
if (isActive) {
deactivateDiff();
} else {
activateDiff();
}
// Update button state
const btn = document.querySelector('.tab-bar-btn.diff-btn');
if (btn) btn.classList.toggle('active', isActive);
}
function activateDiff() {
const tabId = getActiveTab();
const editor = getActiveEditor();
if (!editor) return;
const container = document.getElementById(`editor-${tabId}`);
if (!container) return;
// Hide the regular editor
const regularEditorNode = container.querySelector('.monaco-editor');
if (regularEditorNode) regularEditorNode.style.display = 'none';
// Create diff editor container
const diffContainer = document.createElement('div');
diffContainer.id = 'diff-editor-container';
diffContainer.style.cssText = 'position:absolute;top:0;left:0;right:0;bottom:0;';
container.appendChild(diffContainer);
const currentCode = editor.getValue();
const originalCode = savedValues[tabId] || '';
// Determine language from mode tabs
const mode = getCurrentMode();
const tabs = MODE_TABS[mode];
const tabDef = tabs.find(t => t.id === tabId);
const lang = tabDef ? tabDef.lang : 'plaintext';
const originalModel = monaco.editor.createModel(originalCode, lang);
const modifiedModel = monaco.editor.createModel(currentCode, lang);
diffEditor = monaco.editor.createDiffEditor(diffContainer, {
automaticLayout: true,
readOnly: false,
originalEditable: false,
renderSideBySide: false, // inline diff
theme: 'vs-dark',
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
});
diffEditor.setModel({ original: originalModel, modified: modifiedModel });
isActive = true;
}
function deactivateDiff() {
const tabId = getActiveTab();
const editor = getActiveEditor();
if (diffEditor) {
// Sync changes back
const modified = diffEditor.getModifiedEditor();
const newCode = modified.getValue();
if (editor) editor.setValue(newCode);
// Dispose models and diff editor
const model = diffEditor.getModel();
if (model) {
if (model.original) model.original.dispose();
if (model.modified) model.modified.dispose();
}
diffEditor.dispose();
diffEditor = null;
}
// Remove diff container
const diffContainer = document.getElementById('diff-editor-container');
if (diffContainer) diffContainer.remove();
// Show regular editor
const container = document.getElementById(`editor-${tabId}`);
if (container) {
const regularEditorNode = container.querySelector('.monaco-editor');
if (regularEditorNode) regularEditorNode.style.display = '';
}
if (editor) editor.layout();
isActive = false;
}
// Deactivate diff when switching tabs (to avoid stale state)
export function onTabSwitch() {
if (isActive) {
deactivateDiff();
const btn = document.querySelector('.tab-bar-btn.diff-btn');
if (btn) btn.classList.remove('active');
}
}

View File

@@ -0,0 +1,90 @@
// Custom Monaco editor themes + theme list
export const THEMES = [
{ id: 'vs-dark', label: 'VS Dark' },
{ id: 'vs', label: 'VS Light' },
{ id: 'hc-black', label: 'High Contrast' },
{ id: 'monokai', label: 'Monokai' },
{ id: 'dracula', label: 'Dracula' },
{ id: 'github-dark', label: 'GitHub Dark' },
];
export function registerCustomThemes() {
monaco.editor.defineTheme('monokai', {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'comment', foreground: '75715E', fontStyle: 'italic' },
{ token: 'keyword', foreground: 'F92672' },
{ token: 'string', foreground: 'E6DB74' },
{ token: 'number', foreground: 'AE81FF' },
{ token: 'type', foreground: '66D9EF', fontStyle: 'italic' },
{ token: 'function', foreground: 'A6E22E' },
{ token: 'variable', foreground: 'F8F8F2' },
{ token: 'tag', foreground: 'F92672' },
{ token: 'attribute.name', foreground: 'A6E22E' },
{ token: 'attribute.value', foreground: 'E6DB74' },
{ token: 'delimiter', foreground: 'F8F8F2' },
],
colors: {
'editor.background': '#272822',
'editor.foreground': '#F8F8F2',
'editor.lineHighlightBackground': '#3E3D32',
'editor.selectionBackground': '#49483E',
'editorCursor.foreground': '#F8F8F0',
'editorWhitespace.foreground': '#464741',
},
});
monaco.editor.defineTheme('dracula', {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'comment', foreground: '6272A4', fontStyle: 'italic' },
{ token: 'keyword', foreground: 'FF79C6' },
{ token: 'string', foreground: 'F1FA8C' },
{ token: 'number', foreground: 'BD93F9' },
{ token: 'type', foreground: '8BE9FD', fontStyle: 'italic' },
{ token: 'function', foreground: '50FA7B' },
{ token: 'variable', foreground: 'F8F8F2' },
{ token: 'tag', foreground: 'FF79C6' },
{ token: 'attribute.name', foreground: '50FA7B' },
{ token: 'attribute.value', foreground: 'F1FA8C' },
{ token: 'delimiter', foreground: 'F8F8F2' },
],
colors: {
'editor.background': '#282A36',
'editor.foreground': '#F8F8F2',
'editor.lineHighlightBackground': '#44475A',
'editor.selectionBackground': '#44475A',
'editorCursor.foreground': '#F8F8F2',
'editorWhitespace.foreground': '#424450',
},
});
monaco.editor.defineTheme('github-dark', {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'comment', foreground: '8B949E', fontStyle: 'italic' },
{ token: 'keyword', foreground: 'FF7B72' },
{ token: 'string', foreground: 'A5D6FF' },
{ token: 'number', foreground: '79C0FF' },
{ token: 'type', foreground: 'FFA657' },
{ token: 'function', foreground: 'D2A8FF' },
{ token: 'variable', foreground: 'C9D1D9' },
{ token: 'tag', foreground: '7EE787' },
{ token: 'attribute.name', foreground: '79C0FF' },
{ token: 'attribute.value', foreground: 'A5D6FF' },
{ token: 'delimiter', foreground: 'C9D1D9' },
],
colors: {
'editor.background': '#0D1117',
'editor.foreground': '#C9D1D9',
'editor.lineHighlightBackground': '#161B22',
'editor.selectionBackground': '#264F78',
'editorCursor.foreground': '#C9D1D9',
'editorWhitespace.foreground': '#21262D',
},
});
}

View File

@@ -1,13 +1,38 @@
import { getPref } from './preferences.js';
const editorOpts = {
minimap: { enabled: false },
automaticLayout: true,
fontSize: 13,
lineNumbers: 'on',
scrollBeyondLastLine: false,
theme: 'vs-dark',
theme: getPref('editorTheme') || 'vs-dark',
tabSize: 2,
renderWhitespace: 'none',
padding: { top: 6 },
quickSuggestions: { other: true, comments: false, strings: true },
suggestOnTriggerCharacters: true,
acceptSuggestionOnEnter: 'on',
parameterHints: { enabled: true },
wordBasedSuggestions: 'currentDocument',
suggest: {
snippetsPreventQuickSuggestions: false,
showSnippets: true,
showWords: true,
showKeywords: true,
showMethods: true,
showFunctions: true,
showVariables: true,
showClasses: true,
showInterfaces: true,
showProperties: true,
showEvents: true,
showConstants: true,
},
autoClosingBrackets: 'always',
autoClosingQuotes: 'always',
autoSurround: 'languageDefined',
bracketPairColorization: { enabled: true },
};
export const MODE_TABS = {
@@ -72,8 +97,10 @@ let currentMode = 'html-css-js';
let activeTab = null;
let cssType = 'css';
let onChangeCallback = null;
let onTabSwitchCallback = null;
let onModeChangeCallback = null;
let tabSwitchCallbacks = [];
let modeChangeCallbacks = [];
let onFormatCallback = null;
let onDiffCallback = null;
const tabBar = () => document.getElementById('tab-bar');
const editorArea = () => document.getElementById('editor-area');
@@ -83,11 +110,19 @@ export function setOnChange(cb) {
}
export function setOnTabSwitch(cb) {
onTabSwitchCallback = cb;
tabSwitchCallbacks.push(cb);
}
export function setOnModeChange(cb) {
onModeChangeCallback = cb;
modeChangeCallbacks.push(cb);
}
export function setOnFormat(cb) {
onFormatCallback = cb;
}
export function setOnDiff(cb) {
onDiffCallback = cb;
}
export function getActiveEditor() {
@@ -124,20 +159,23 @@ export function setCssType(type) {
function configureJsxSupport(mode) {
if (mode === 'react' || mode === 'react-ts') {
// Enable JSX in JavaScript defaults
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
target: monaco.languages.typescript.ScriptTarget.ESNext,
jsx: monaco.languages.typescript.JsxEmit.React,
jsxFactory: 'React.createElement',
allowNonTsExtensions: true,
allowJs: true,
lib: ['esnext', 'dom', 'dom.iterable'],
});
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
target: monaco.languages.typescript.ScriptTarget.ESNext,
jsx: monaco.languages.typescript.JsxEmit.React,
jsxFactory: 'React.createElement',
allowNonTsExtensions: true,
lib: ['esnext', 'dom', 'dom.iterable'],
});
// Add React type definitions for IntelliSense
import('./autocomplete.js').then(m => m.addReactTypes());
}
}
@@ -196,6 +234,29 @@ function renderTabBar(tabs) {
btn.addEventListener('click', () => switchTab(tab.id));
bar.appendChild(btn);
});
// Spacer + Format + Diff buttons
const spacer = document.createElement('span');
spacer.className = 'tab-bar-spacer';
bar.appendChild(spacer);
const fmtBtn = document.createElement('button');
fmtBtn.className = 'tab-bar-btn format-btn';
fmtBtn.textContent = 'Format';
fmtBtn.title = 'Format code (Shift+Alt+F)';
fmtBtn.addEventListener('click', () => {
if (onFormatCallback) onFormatCallback();
});
bar.appendChild(fmtBtn);
const diffBtn = document.createElement('button');
diffBtn.className = 'tab-bar-btn diff-btn';
diffBtn.textContent = 'Diff';
diffBtn.title = 'Toggle diff view (Ctrl+D)';
diffBtn.addEventListener('click', () => {
if (onDiffCallback) onDiffCallback();
});
bar.appendChild(diffBtn);
}
export function initEditors(mode = 'html-css-js') {
@@ -248,7 +309,7 @@ export function switchMode(mode) {
switchTab(tabs[0].id);
if (onModeChangeCallback) onModeChangeCallback(mode);
modeChangeCallbacks.forEach(cb => cb(mode));
}
export function switchTab(tabId) {
@@ -274,7 +335,7 @@ export function switchTab(tabId) {
editors[tabId].focus();
}
if (onTabSwitchCallback) onTabSwitchCallback(tabId, editors[tabId]);
tabSwitchCallbacks.forEach(cb => cb(tabId, editors[tabId]));
}
export function getEditorValues() {
@@ -294,3 +355,8 @@ export function setEditorValues({ html = '', css = '', js = '' }) {
export function getActiveTab() {
return activeTab;
}
export function setEditorTheme(themeId) {
editorOpts.theme = themeId;
monaco.editor.setTheme(themeId);
}

View File

@@ -0,0 +1,90 @@
import { registerClearHandler } from './devtools.js';
function renderNode(node, depth, maxExpand) {
if (node.type === 'text') {
const text = node.text.trim();
if (!text) return '';
return `<div class="el-node"><span class="el-text">${escapeHtml(text)}</span></div>`;
}
const hasChildren = node.children && node.children.length > 0;
const expanded = depth < maxExpand;
let attrs = '';
if (node.attrs) {
for (const a of node.attrs) {
attrs += ` <span class="el-attr-name">${escapeHtml(a.name)}</span>=<span class="el-attr-value">"${escapeHtml(a.value)}"</span>`;
}
}
let html = '<div class="el-node">';
html += `<div class="el-node-header" data-has-children="${hasChildren}">`;
html += `<span class="el-toggle">${hasChildren ? (expanded ? '▼' : '▶') : ' '}</span>`;
html += `<span>&lt;<span class="el-tag">${escapeHtml(node.tag)}</span>${attrs}&gt;</span>`;
// Inline short text content
if (hasChildren && node.children.length === 1 && node.children[0].type === 'text' && node.children[0].text.trim().length < 60) {
html += `<span class="el-text">${escapeHtml(node.children[0].text.trim())}</span>`;
html += `<span>&lt;/<span class="el-tag">${escapeHtml(node.tag)}</span>&gt;</span>`;
html += '</div></div>';
return html;
}
html += '</div>';
if (hasChildren) {
html += `<div class="el-children ${expanded ? 'expanded' : ''}">`;
for (const child of node.children) {
html += renderNode(child, depth + 1, maxExpand);
}
html += `<div style="padding-left:16px">&lt;/<span class="el-tag">${escapeHtml(node.tag)}</span>&gt;</div>`;
html += '</div>';
}
html += '</div>';
return html;
}
function escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function bindToggleHandlers(container) {
container.querySelectorAll('.el-node-header[data-has-children="true"]').forEach(header => {
header.addEventListener('click', () => {
const children = header.nextElementSibling;
if (!children || !children.classList.contains('el-children')) return;
const toggle = header.querySelector('.el-toggle');
const isExpanded = children.classList.contains('expanded');
children.classList.toggle('expanded');
toggle.textContent = isExpanded ? '▶' : '▼';
});
});
}
let lastTree = null;
function render(tree) {
lastTree = tree;
const out = document.getElementById('elements-output');
if (!tree) {
out.innerHTML = '<div style="padding:12px;color:var(--text-dim);font-size:11px;">No elements captured. Run your code to see the DOM tree.</div>';
return;
}
out.innerHTML = renderNode(tree, 0, 2);
bindToggleHandlers(out);
}
export function clearElements() {
lastTree = null;
document.getElementById('elements-output').innerHTML = '';
}
export function initElements() {
registerClearHandler('elements', clearElements);
window.addEventListener('message', (e) => {
if (!e.data || e.data.type !== 'devtools' || e.data.tab !== 'elements') return;
if (e.data.tree) render(e.data.tree);
});
}

View File

@@ -76,7 +76,7 @@ async function init() {
}
const tailwindScript = opts.tailwind
? `<script src="https://cdn.tailwindcss.com"><\/script>\n`
? `<script>var _tw=console.warn;console.warn=function(){if(typeof arguments[0]==='string'&&arguments[0].indexOf('cdn.tailwindcss.com')!==-1)return;_tw.apply(console,arguments)}<\/script>\n<script src="https://cdn.tailwindcss.com"><\/script>\n<script>console.warn=_tw<\/script>\n`
: '';
// Dark preview theme — from fiddle options or URL param

View File

@@ -44,7 +44,7 @@ export function exportHtml({ title, html, css, js, mode, extraCss = '', isModule
}
const tailwindScript = tailwind
? `<script src="https://cdn.tailwindcss.com"><\/script>\n`
? `<script>var _tw=console.warn;console.warn=function(){if(typeof arguments[0]==='string'&&arguments[0].indexOf('cdn.tailwindcss.com')!==-1)return;_tw.apply(console,arguments)}<\/script>\n<script src="https://cdn.tailwindcss.com"><\/script>\n<script>console.warn=_tw<\/script>\n`
: '';
const darkCss = previewTheme === 'dark'

81
public/js/formatter.js Normal file
View File

@@ -0,0 +1,81 @@
import { loadScript } from './utils.js';
import { getActiveEditor, getActiveTab, getCurrentMode, getCssType } from './editors.js';
const PRETTIER_CDN = 'https://cdn.jsdelivr.net/npm/prettier@3';
const PLUGINS = [
'/standalone.min.js',
'/plugins/babel.min.js',
'/plugins/html.min.js',
'/plugins/postcss.min.js',
'/plugins/typescript.min.js',
'/plugins/estree.min.js',
];
let loaded = false;
async function ensurePrettier() {
if (loaded) return;
for (const plugin of PLUGINS) {
await loadScript(PRETTIER_CDN + plugin);
}
loaded = true;
}
function getParser(tabId, mode, cssType) {
if (tabId === 'html') return { parser: 'html', plugins: [prettierPlugins.html] };
if (tabId === 'css') {
return { parser: 'css', plugins: [prettierPlugins.postcss] };
}
// JS tab — depends on mode
if (mode === 'typescript' || mode === 'react-ts') {
return { parser: 'typescript', plugins: [prettierPlugins.typescript, prettierPlugins.estree] };
}
if (mode === 'markdown') {
return null; // Prettier markdown plugin not loaded
}
if (mode === 'vue' || mode === 'svelte') {
return { parser: 'html', plugins: [prettierPlugins.html] };
}
return { parser: 'babel', plugins: [prettierPlugins.babel, prettierPlugins.estree] };
}
export async function formatActiveEditor() {
const editor = getActiveEditor();
if (!editor) return;
await ensurePrettier();
const tabId = getActiveTab();
const mode = getCurrentMode();
const cssType = getCssType();
const config = getParser(tabId, mode, cssType);
if (!config) return;
const code = editor.getValue();
if (!code.trim()) return;
try {
// Save cursor position ratio
const pos = editor.getPosition();
const offset = editor.getModel().getOffsetAt(pos);
const ratio = offset / Math.max(code.length, 1);
const formatted = await prettier.format(code, {
parser: config.parser,
plugins: config.plugins,
singleQuote: true,
trailingComma: 'all',
printWidth: 100,
});
editor.setValue(formatted);
// Restore cursor approximately
const newOffset = Math.round(ratio * formatted.length);
const newPos = editor.getModel().getPositionAt(newOffset);
editor.setPosition(newPos);
editor.revealPositionInCenter(newPos);
} catch (e) {
console.warn('Prettier format error:', e.message);
}
}

172
public/js/linter.js Normal file
View File

@@ -0,0 +1,172 @@
import { loadScript } from './utils.js';
import { getActiveEditor, getActiveTab, getCurrentMode, setOnTabSwitch, setOnModeChange } from './editors.js';
const ESLINT_CDN = 'https://cdn.jsdelivr.net/npm/eslint-linter-browserify@9/linter.min.js';
let linterInstance = null;
let loaded = false;
let debounceTimer = null;
async function ensureESLint() {
if (loaded) return;
await loadScript(ESLINT_CDN);
if (window.eslint && window.eslint.Linter) {
linterInstance = new window.eslint.Linter();
} else if (window.Linter) {
linterInstance = new window.Linter();
}
loaded = true;
}
function isLintableMode(mode) {
return ['html-css-js', 'typescript', 'react', 'react-ts', 'wasm'].includes(mode);
}
function isLintableTab(tabId) {
return tabId === 'js';
}
const BROWSER_GLOBALS = {
document: 'readonly', window: 'readonly', console: 'readonly',
setTimeout: 'readonly', clearTimeout: 'readonly', setInterval: 'readonly', clearInterval: 'readonly',
requestAnimationFrame: 'readonly', cancelAnimationFrame: 'readonly',
fetch: 'readonly', URL: 'readonly', URLSearchParams: 'readonly',
HTMLElement: 'readonly', Event: 'readonly', CustomEvent: 'readonly',
MutationObserver: 'readonly', IntersectionObserver: 'readonly', ResizeObserver: 'readonly',
localStorage: 'readonly', sessionStorage: 'readonly', navigator: 'readonly', location: 'readonly',
history: 'readonly', performance: 'readonly', alert: 'readonly', confirm: 'readonly', prompt: 'readonly',
WebAssembly: 'readonly', Uint8Array: 'readonly', ArrayBuffer: 'readonly',
Map: 'readonly', Set: 'readonly', WeakMap: 'readonly', WeakSet: 'readonly',
Promise: 'readonly', Symbol: 'readonly', Proxy: 'readonly', Reflect: 'readonly',
globalThis: 'readonly', self: 'readonly', queueMicrotask: 'readonly',
};
const REACT_GLOBALS = {
React: 'readonly', ReactDOM: 'readonly',
};
function getEslintConfig(mode) {
const parserOptions = {
ecmaVersion: 'latest',
sourceType: 'module',
};
const globals = { ...BROWSER_GLOBALS };
if (mode === 'react' || mode === 'react-ts') {
parserOptions.ecmaFeatures = { jsx: true };
Object.assign(globals, REACT_GLOBALS);
}
return {
languageOptions: { globals, parserOptions },
rules: {
'no-constant-condition': 'warn',
'no-debugger': 'warn',
'no-empty': 'warn',
'no-extra-semi': 'warn',
'no-unreachable': 'warn',
'no-duplicate-case': 'warn',
},
};
}
function severityToMonaco(severity) {
// ESLint: 1=warn, 2=error
if (severity === 2) return monaco.MarkerSeverity.Error;
return monaco.MarkerSeverity.Warning;
}
function lintEditor(editor, mode) {
if (!linterInstance || !editor) return 0;
const model = editor.getModel();
if (!model) return 0;
const code = editor.getValue();
if (!code.trim()) {
monaco.editor.setModelMarkers(model, 'eslint', []);
return 0;
}
try {
const config = getEslintConfig(mode);
const messages = linterInstance.verify(code, config);
const markers = messages.map(m => ({
startLineNumber: m.line || 1,
startColumn: m.column || 1,
endLineNumber: m.endLine || m.line || 1,
endColumn: m.endColumn || (m.column || 1) + 1,
message: `${m.message} (${m.ruleId || 'syntax'})`,
severity: severityToMonaco(m.severity),
source: 'eslint',
}));
monaco.editor.setModelMarkers(model, 'eslint', markers);
return markers.length;
} catch (e) {
monaco.editor.setModelMarkers(model, 'eslint', []);
return 0;
}
}
function updateBadge(count) {
// Lint feedback is shown as editor squiggles only — no badge needed
}
function scheduleLint() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
const mode = getCurrentMode();
const tabId = getActiveTab();
if (!isLintableMode(mode) || !isLintableTab(tabId)) {
clearMarkers();
updateBadge(0);
return;
}
const editor = getActiveEditor();
const count = lintEditor(editor, mode);
updateBadge(count);
}, 800);
}
function clearMarkers() {
const editor = getActiveEditor();
if (editor && editor.getModel()) {
monaco.editor.setModelMarkers(editor.getModel(), 'eslint', []);
}
}
export async function initLinter() {
try {
await ensureESLint();
} catch (e) {
console.warn('ESLint failed to load:', e.message);
return;
}
if (!linterInstance) return;
// Lint on tab switch
setOnTabSwitch((tabId, editor) => {
const mode = getCurrentMode();
if (isLintableMode(mode) && isLintableTab(tabId) && editor) {
const count = lintEditor(editor, mode);
updateBadge(count);
} else {
updateBadge(0);
}
});
// Clear markers on mode change
setOnModeChange((mode) => {
if (!isLintableMode(mode)) {
updateBadge(0);
}
});
}
export function lintOnChange() {
scheduleLint();
}

View File

@@ -0,0 +1,70 @@
import { registerClearHandler } from './devtools.js';
let entries = [];
function formatSize(bytes) {
if (bytes === 0 || bytes === undefined) return '-';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function typeClass(initiatorType) {
switch (initiatorType) {
case 'script': return 'net-type-script';
case 'link': case 'css': return 'net-type-link';
case 'img': return 'net-type-img';
case 'fetch': return 'net-type-fetch';
case 'xmlhttprequest': return 'net-type-xmlhttprequest';
default: return 'net-type-other';
}
}
function truncateUrl(url) {
try {
const u = new URL(url);
const path = u.pathname.split('/').pop() || u.pathname;
return path + u.search;
} catch { return url; }
}
function render() {
const out = document.getElementById('network-output');
if (!entries.length) {
out.innerHTML = '<div style="padding:12px;color:var(--text-dim);font-size:11px;">No network requests captured. Run your code to see resources.</div>';
return;
}
let totalSize = 0;
let html = '<table class="network-table"><thead><tr><th>Name</th><th>Type</th><th>Size</th><th>Time</th></tr></thead><tbody>';
for (const e of entries) {
totalSize += e.transferSize || 0;
html += `<tr>
<td title="${e.name}">${truncateUrl(e.name)}</td>
<td><span class="net-type ${typeClass(e.initiatorType)}">${e.initiatorType}</span></td>
<td>${formatSize(e.transferSize)}</td>
<td>${Math.round(e.duration)}ms</td>
</tr>`;
}
html += '</tbody></table>';
html += `<div class="network-footer"><span>${entries.length} request${entries.length !== 1 ? 's' : ''}</span><span>${formatSize(totalSize)} transferred</span></div>`;
out.innerHTML = html;
}
export function clearNetwork() {
entries = [];
const out = document.getElementById('network-output');
out.innerHTML = '';
}
export function initNetwork() {
registerClearHandler('network', clearNetwork);
window.addEventListener('message', (e) => {
if (!e.data || e.data.type !== 'devtools' || e.data.tab !== 'network') return;
if (e.data.entries) {
entries.push(...e.data.entries);
render();
}
});
}

View File

@@ -0,0 +1,54 @@
import { registerClearHandler } from './devtools.js';
function timeColor(ms, thresholds) {
if (ms < thresholds[0]) return 'perf-green';
if (ms < thresholds[1]) return 'perf-yellow';
return 'perf-red';
}
function renderCard(label, value, unit, colorClass) {
return `<div class="perf-card">
<div class="perf-card-label">${label}</div>
<div class="perf-card-value ${colorClass}">${value}<span style="font-size:12px;font-weight:400;margin-left:2px">${unit}</span></div>
</div>`;
}
function render(metrics) {
const out = document.getElementById('performance-output');
let html = '';
if (metrics.scriptDuration !== undefined) {
html += renderCard('Script Execution', Math.round(metrics.scriptDuration), 'ms',
timeColor(metrics.scriptDuration, [50, 200]));
}
if (metrics.domContentLoaded !== undefined) {
html += renderCard('DOM Content Loaded', Math.round(metrics.domContentLoaded), 'ms',
timeColor(metrics.domContentLoaded, [100, 500]));
}
if (metrics.loadEvent !== undefined) {
html += renderCard('Page Load', Math.round(metrics.loadEvent), 'ms',
timeColor(metrics.loadEvent, [200, 1000]));
}
if (metrics.domNodes !== undefined) {
html += renderCard('DOM Nodes', metrics.domNodes, '',
metrics.domNodes > 1500 ? 'perf-red' : metrics.domNodes > 800 ? 'perf-yellow' : 'perf-neutral');
}
if (metrics.resourceCount !== undefined) {
html += renderCard('Resources Loaded', metrics.resourceCount, '', 'perf-neutral');
}
out.innerHTML = html;
}
export function clearPerformance() {
document.getElementById('performance-output').innerHTML = '';
}
export function initPerformance() {
registerClearHandler('performance', clearPerformance);
window.addEventListener('message', (e) => {
if (!e.data || e.data.type !== 'devtools' || e.data.tab !== 'performance') return;
if (e.data.metrics) render(e.data.metrics);
});
}

View File

@@ -6,6 +6,8 @@ const DEFAULTS = {
keybindings: 'default',
panelSizes: null,
previewTheme: 'light',
previewDevice: 'desktop',
editorTheme: 'vs-dark',
};
export function getPref(key) {

View File

@@ -28,6 +28,70 @@ const consoleInterceptor = `
window.onerror = function(msg, url, line, col) {
parent.postMessage({ type: 'console', method: 'error', args: ['Error: ' + msg + ' (line ' + line + ')'] }, '*');
};
// --- Network: PerformanceObserver for resource loads ---
try {
var netObserver = new PerformanceObserver(function(list) {
var entries = list.getEntries().map(function(e) {
return { name: e.name, initiatorType: e.initiatorType, duration: e.duration, transferSize: e.transferSize || 0, startTime: e.startTime };
});
if (entries.length) parent.postMessage({ type: 'devtools', tab: 'network', entries: entries }, '*');
});
netObserver.observe({ type: 'resource', buffered: true });
} catch(e) {}
// --- Elements: serialize DOM tree on DOMContentLoaded ---
function serializeNode(node, depth) {
if (depth > 15) return null;
if (node.nodeType === 3) {
var t = node.textContent;
if (!t.trim()) return null;
return { type: 'text', text: t };
}
if (node.nodeType !== 1) return null;
var tag = node.tagName.toLowerCase();
if (tag === 'script' || tag === 'style') {
return { type: 'element', tag: tag, attrs: getAttrs(node), children: [] };
}
var children = [];
for (var i = 0; i < node.childNodes.length; i++) {
var c = serializeNode(node.childNodes[i], depth + 1);
if (c) children.push(c);
}
return { type: 'element', tag: tag, attrs: getAttrs(node), children: children };
}
function getAttrs(el) {
var arr = [];
for (var i = 0; i < el.attributes.length; i++) {
arr.push({ name: el.attributes[i].name, value: el.attributes[i].value });
}
return arr;
}
function sendElements() {
var tree = serializeNode(document.documentElement, 0);
parent.postMessage({ type: 'devtools', tab: 'elements', tree: tree }, '*');
}
document.addEventListener('DOMContentLoaded', function() { setTimeout(sendElements, 50); });
window.addEventListener('message', function(e) {
if (e.data && e.data.type === 'devtools-request' && e.data.tab === 'elements') sendElements();
});
// --- Performance: timing metrics ---
window.__fiddle_scriptStart = performance.now();
window.addEventListener('load', function() {
var scriptEnd = window.__fiddle_scriptEnd || performance.now();
var metrics = {
scriptDuration: scriptEnd - window.__fiddle_scriptStart,
domNodes: document.getElementsByTagName('*').length,
resourceCount: performance.getEntriesByType('resource').length
};
var nav = performance.getEntriesByType('navigation');
if (nav && nav.length) {
metrics.domContentLoaded = nav[0].domContentLoadedEventEnd;
metrics.loadEvent = nav[0].loadEventEnd || performance.now();
}
parent.postMessage({ type: 'devtools', tab: 'performance', metrics: metrics }, '*');
});
})();
<\/script>
`;
@@ -45,15 +109,16 @@ function escapeScriptClose(code) {
* followed by an inline <script> for user code.
*/
function buildLoaderScript(runtimeUrls, userJs, isModule) {
const endMarker = 'window.__fiddle_scriptEnd = performance.now();';
if (isModule) {
return `\n<script type="module">\n${escapeScriptClose(userJs)}\n<\/script>`;
return `\n<script type="module">\n${escapeScriptClose(userJs)}\n${endMarker}\n<\/script>`;
}
let parts = '';
for (const url of runtimeUrls) {
parts += `<script src="${url}"><\/script>\n`;
}
parts += `<script>\n${escapeScriptClose(userJs)}\n<\/script>`;
parts += `<script>\n${escapeScriptClose(userJs)}\n${endMarker}\n<\/script>`;
return parts;
}
@@ -102,9 +167,9 @@ export function renderPreview(html, css, js, mode = 'html-css-js', extraCss = ''
? buildLoaderScript(runtime.scripts, finalJs, isModule)
: '';
// Tailwind CDN injection
// Tailwind CDN injection (suppress production warning)
const tailwindScript = options.tailwind
? `<script src="https://cdn.tailwindcss.com"><\/script>\n`
? `<script>var _tw=console.warn;console.warn=function(){if(typeof arguments[0]==='string'&&arguments[0].indexOf('cdn.tailwindcss.com')!==-1)return;_tw.apply(console,arguments)}<\/script>\n<script src="https://cdn.tailwindcss.com"><\/script>\n<script>console.warn=_tw<\/script>\n`
: '';
// Dark preview theme

338
public/js/templates.js Normal file
View File

@@ -0,0 +1,338 @@
// Starter template gallery data
export const GALLERY_TEMPLATES = [
{
id: 'todo-app',
title: 'Todo App',
description: 'Classic todo list with add, complete, and delete',
mode: 'html-css-js',
icon: '\u2713',
html: `<div class="todo-app">
<h1>Todo List</h1>
<form id="todo-form">
<input type="text" id="todo-input" placeholder="Add a task..." autofocus>
<button type="submit">Add</button>
</form>
<ul id="todo-list"></ul>
</div>`,
css: `body { font-family: -apple-system, sans-serif; background: #f0f2f5; display: flex; justify-content: center; padding: 40px 20px; }
.todo-app { background: #fff; border-radius: 12px; padding: 24px; width: 100%; max-width: 400px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
h1 { margin: 0 0 16px; font-size: 24px; color: #1a1a1a; }
#todo-form { display: flex; gap: 8px; margin-bottom: 16px; }
#todo-input { flex: 1; padding: 10px 12px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px; outline: none; }
#todo-input:focus { border-color: #0078d4; }
button { background: #0078d4; color: #fff; border: none; padding: 10px 20px; border-radius: 8px; cursor: pointer; font-size: 14px; }
button:hover { background: #106ebe; }
ul { list-style: none; padding: 0; }
li { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid #f0f0f0; }
li.done span { text-decoration: line-through; color: #999; }
li span { flex: 1; font-size: 14px; }
li .delete { background: none; color: #e74c3c; padding: 4px 8px; font-size: 18px; }
li .delete:hover { background: #fde8e8; border-radius: 4px; }`,
js: `const form = document.getElementById('todo-form');
const input = document.getElementById('todo-input');
const list = document.getElementById('todo-list');
form.addEventListener('submit', (e) => {
e.preventDefault();
const text = input.value.trim();
if (!text) return;
addTodo(text);
input.value = '';
});
function addTodo(text) {
const li = document.createElement('li');
li.innerHTML = \`<input type="checkbox"><span>\${text}</span><button class="delete">&times;</button>\`;
li.querySelector('input').addEventListener('change', () => li.classList.toggle('done'));
li.querySelector('.delete').addEventListener('click', () => li.remove());
list.appendChild(li);
}`,
},
{
id: 'api-fetch',
title: 'API Fetch',
description: 'Fetch data from a REST API and display results',
mode: 'html-css-js',
icon: '\u21C5',
html: `<div class="container">
<h1>Random Users</h1>
<button id="btn-fetch">Fetch Users</button>
<div id="users" class="users"></div>
</div>`,
css: `body { font-family: -apple-system, sans-serif; background: #f5f5f5; padding: 40px 20px; }
.container { max-width: 600px; margin: 0 auto; }
h1 { margin: 0 0 16px; color: #333; }
button { background: #0078d4; color: #fff; border: none; padding: 10px 24px; border-radius: 8px; cursor: pointer; font-size: 14px; margin-bottom: 20px; }
button:hover { background: #106ebe; }
.users { display: flex; flex-direction: column; gap: 12px; }
.user-card { display: flex; align-items: center; gap: 14px; background: #fff; padding: 14px; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
.user-card img { width: 50px; height: 50px; border-radius: 50%; }
.user-info h3 { margin: 0; font-size: 15px; color: #1a1a1a; }
.user-info p { margin: 4px 0 0; font-size: 13px; color: #666; }`,
js: `document.getElementById('btn-fetch').addEventListener('click', fetchUsers);
async function fetchUsers() {
const container = document.getElementById('users');
container.innerHTML = '<p>Loading...</p>';
try {
const res = await fetch('https://randomuser.me/api/?results=5');
const data = await res.json();
container.innerHTML = data.results.map(u => \`
<div class="user-card">
<img src="\${u.picture.medium}" alt="\${u.name.first}">
<div class="user-info">
<h3>\${u.name.first} \${u.name.last}</h3>
<p>\${u.email}</p>
</div>
</div>
\`).join('');
} catch (e) {
container.innerHTML = '<p>Error fetching users.</p>';
}
}
fetchUsers();`,
},
{
id: 'css-animation',
title: 'CSS Animation',
description: 'Animated shapes with pure CSS keyframes',
mode: 'html-css-js',
icon: '\u25CF',
html: `<div class="scene">
<div class="orbit">
<div class="planet"></div>
</div>
<div class="sun"></div>
<div class="stars"></div>
</div>`,
css: `body { margin: 0; overflow: hidden; background: #0a0a2e; display: flex; align-items: center; justify-content: center; height: 100vh; }
.scene { position: relative; width: 300px; height: 300px; }
.sun { position: absolute; top: 50%; left: 50%; width: 60px; height: 60px; margin: -30px 0 0 -30px; background: radial-gradient(circle, #ffd700, #ff8c00); border-radius: 50%; box-shadow: 0 0 40px #ffd700, 0 0 80px #ff8c0066; animation: pulse 2s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.08); } }
.orbit { position: absolute; top: 50%; left: 50%; width: 200px; height: 200px; margin: -100px 0 0 -100px; border: 1px solid rgba(255,255,255,0.1); border-radius: 50%; animation: spin 6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.planet { position: absolute; top: -10px; left: 50%; margin-left: -10px; width: 20px; height: 20px; background: radial-gradient(circle, #4fc3f7, #0288d1); border-radius: 50%; box-shadow: 0 0 12px #4fc3f766; }
.stars { position: absolute; inset: -50px; background-image: radial-gradient(2px 2px at 20px 30px, #fff, transparent), radial-gradient(2px 2px at 80px 120px, #fff, transparent), radial-gradient(1px 1px at 160px 60px, #ddd, transparent), radial-gradient(2px 2px at 240px 180px, #fff, transparent), radial-gradient(1px 1px at 50px 200px, #ccc, transparent), radial-gradient(1px 1px at 190px 20px, #eee, transparent); background-repeat: repeat; animation: twinkle 4s ease-in-out infinite alternate; }
@keyframes twinkle { from { opacity: 0.6; } to { opacity: 1; } }`,
js: `// Pure CSS animation - no JS needed!
console.log('CSS animation running');`,
},
{
id: 'form-validation',
title: 'Form Validation',
description: 'Client-side form validation with live feedback',
mode: 'html-css-js',
icon: '\u2611',
html: `<div class="form-wrap">
<h1>Sign Up</h1>
<form id="signup" novalidate>
<div class="field">
<label>Name</label>
<input type="text" id="name" placeholder="Your name" required>
<span class="error" id="name-error"></span>
</div>
<div class="field">
<label>Email</label>
<input type="email" id="email" placeholder="you@example.com" required>
<span class="error" id="email-error"></span>
</div>
<div class="field">
<label>Password</label>
<input type="password" id="password" placeholder="Min 8 characters" required>
<span class="error" id="password-error"></span>
</div>
<button type="submit">Create Account</button>
</form>
<div id="success" class="success hidden">Account created!</div>
</div>`,
css: `body { font-family: -apple-system, sans-serif; background: #f0f2f5; display: flex; justify-content: center; padding: 40px 20px; }
.form-wrap { background: #fff; padding: 32px; border-radius: 12px; width: 100%; max-width: 380px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
h1 { margin: 0 0 20px; font-size: 22px; }
.field { margin-bottom: 16px; }
label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 4px; color: #333; }
input { width: 100%; padding: 10px 12px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 14px; outline: none; box-sizing: border-box; }
input:focus { border-color: #0078d4; }
input.invalid { border-color: #e74c3c; }
input.valid { border-color: #27ae60; }
.error { font-size: 12px; color: #e74c3c; min-height: 18px; display: block; }
button { width: 100%; background: #0078d4; color: #fff; border: none; padding: 12px; border-radius: 8px; font-size: 15px; cursor: pointer; }
button:hover { background: #106ebe; }
.success { text-align: center; color: #27ae60; font-size: 16px; font-weight: 600; padding: 16px 0; }
.hidden { display: none; }`,
js: `const form = document.getElementById('signup');
const fields = {
name: { el: document.getElementById('name'), err: document.getElementById('name-error'), validate: v => v.length >= 2 ? '' : 'Name must be at least 2 characters' },
email: { el: document.getElementById('email'), err: document.getElementById('email-error'), validate: v => /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(v) ? '' : 'Enter a valid email' },
password: { el: document.getElementById('password'), err: document.getElementById('password-error'), validate: v => v.length >= 8 ? '' : 'Password must be at least 8 characters' },
};
Object.values(fields).forEach(f => {
f.el.addEventListener('input', () => {
const msg = f.validate(f.el.value);
f.err.textContent = msg;
f.el.classList.toggle('invalid', !!msg);
f.el.classList.toggle('valid', !msg && f.el.value.length > 0);
});
});
form.addEventListener('submit', (e) => {
e.preventDefault();
let valid = true;
Object.values(fields).forEach(f => {
const msg = f.validate(f.el.value);
f.err.textContent = msg;
f.el.classList.toggle('invalid', !!msg);
f.el.classList.toggle('valid', !msg && f.el.value.length > 0);
if (msg) valid = false;
});
if (valid) {
form.classList.add('hidden');
document.getElementById('success').classList.remove('hidden');
}
});`,
},
{
id: 'react-counter',
title: 'React Counter',
description: 'Stateful React component with hooks',
mode: 'react',
icon: '\u269B',
html: '',
css: `body { font-family: -apple-system, sans-serif; background: #f0f2f5; display: flex; justify-content: center; padding: 60px 20px; }
.counter { background: #fff; padding: 40px; border-radius: 16px; text-align: center; box-shadow: 0 2px 12px rgba(0,0,0,0.1); min-width: 280px; }
h1 { margin: 0 0 8px; font-size: 22px; color: #333; }
.count { font-size: 64px; font-weight: 700; color: #0078d4; margin: 20px 0; }
.buttons { display: flex; gap: 12px; justify-content: center; }
.buttons button { font-size: 20px; width: 48px; height: 48px; border-radius: 50%; border: 2px solid #0078d4; background: #fff; color: #0078d4; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.buttons button:hover { background: #0078d4; color: #fff; }
.reset { margin-top: 16px; font-size: 13px; background: none; border: none; color: #999; cursor: pointer; text-decoration: underline; }`,
js: `const App = () => {
const [count, setCount] = React.useState(0);
return (
<div className="counter">
<h1>React Counter</h1>
<div className="count">{count}</div>
<div className="buttons">
<button onClick={() => setCount(c => c - 1)}>\u2212</button>
<button onClick={() => setCount(c => c + 1)}>+</button>
</div>
<button className="reset" onClick={() => setCount(0)}>Reset</button>
</div>
);
};
ReactDOM.createRoot(document.getElementById('root')).render(<App />);`,
},
{
id: 'canvas-game',
title: 'Canvas Game',
description: 'Simple bouncing ball canvas animation',
mode: 'html-css-js',
icon: '\u25B6',
html: `<canvas id="canvas" width="400" height="300"></canvas>`,
css: `body { margin: 0; background: #1a1a2e; display: flex; align-items: center; justify-content: center; height: 100vh; }
canvas { border-radius: 8px; background: #16213e; box-shadow: 0 4px 20px rgba(0,0,0,0.3); }`,
js: `const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const balls = Array.from({ length: 8 }, () => ({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
r: 8 + Math.random() * 16,
dx: (Math.random() - 0.5) * 4,
dy: (Math.random() - 0.5) * 4,
color: \`hsl(\${Math.random() * 360}, 70%, 60%)\`,
}));
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const b of balls) {
b.x += b.dx;
b.y += b.dy;
if (b.x - b.r < 0 || b.x + b.r > canvas.width) b.dx *= -1;
if (b.y - b.r < 0 || b.y + b.r > canvas.height) b.dy *= -1;
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
ctx.fillStyle = b.color;
ctx.fill();
ctx.shadowColor = b.color;
ctx.shadowBlur = 12;
}
ctx.shadowBlur = 0;
requestAnimationFrame(draw);
}
draw();`,
},
{
id: 'analog-clock',
title: 'Analog Clock',
description: 'Real-time analog clock with CSS and JS',
mode: 'html-css-js',
icon: '\u23F0',
html: `<div class="clock">
<div class="hand hour" id="hour"></div>
<div class="hand minute" id="minute"></div>
<div class="hand second" id="second"></div>
<div class="center"></div>
</div>`,
css: `body { margin: 0; background: #1a1a2e; display: flex; align-items: center; justify-content: center; height: 100vh; }
.clock { position: relative; width: 200px; height: 200px; border: 4px solid #e0e0e0; border-radius: 50%; background: #fff; }
.hand { position: absolute; bottom: 50%; left: 50%; transform-origin: bottom center; border-radius: 4px; }
.hour { width: 4px; height: 50px; background: #333; margin-left: -2px; }
.minute { width: 3px; height: 70px; background: #555; margin-left: -1.5px; }
.second { width: 1.5px; height: 80px; background: #e74c3c; margin-left: -0.75px; }
.center { position: absolute; top: 50%; left: 50%; width: 10px; height: 10px; margin: -5px 0 0 -5px; background: #333; border-radius: 50%; z-index: 2; }`,
js: `function tick() {
const now = new Date();
const s = now.getSeconds() + now.getMilliseconds() / 1000;
const m = now.getMinutes() + s / 60;
const h = (now.getHours() % 12) + m / 60;
document.getElementById('second').style.transform = \`rotate(\${s * 6}deg)\`;
document.getElementById('minute').style.transform = \`rotate(\${m * 6}deg)\`;
document.getElementById('hour').style.transform = \`rotate(\${h * 30}deg)\`;
requestAnimationFrame(tick);
}
tick();`,
},
{
id: 'svelte-reactive',
title: 'Svelte Reactive',
description: 'Svelte reactivity with bound inputs and computed values',
mode: 'svelte',
icon: '\u26A1',
html: '',
css: '',
js: `<script>
let name = 'World';
let count = 0;
$: doubled = count * 2;
$: greeting = \`Hello, \${name}!\`;
</script>
<div class="app">
<h1>{greeting}</h1>
<label>
Name: <input bind:value={name}>
</label>
<div class="counter">
<button on:click={() => count--}>\u2212</button>
<span>{count} (doubled: {doubled})</span>
<button on:click={() => count++}>+</button>
</div>
</div>
<style>
.app { font-family: -apple-system, sans-serif; max-width: 400px; margin: 40px auto; text-align: center; }
h1 { color: #ff3e00; }
label { display: block; margin: 16px 0; font-size: 14px; }
input { padding: 8px 12px; border: 2px solid #ddd; border-radius: 6px; font-size: 14px; }
input:focus { border-color: #ff3e00; outline: none; }
.counter { display: flex; align-items: center; justify-content: center; gap: 16px; margin-top: 20px; }
button { width: 40px; height: 40px; border-radius: 50%; border: 2px solid #ff3e00; background: #fff; color: #ff3e00; font-size: 20px; cursor: pointer; }
button:hover { background: #ff3e00; color: #fff; }
span { font-size: 18px; font-weight: 600; }
</style>`,
},
];