Files
fiddle/public/js/app.js
root 77f64d2862 Add Tailwind CSS toggle, Markdown/WASM modes, and npm import resolution
- Tailwind CSS: toolbar checkbox injects Play CDN into preview, persisted
  per-fiddle via new options JSON column
- Markdown mode: uses marked.js CDN, renders markdown to HTML preview with
  CSS tab for custom styling
- WASM mode: starter template with inline WebAssembly add function, supports
  top-level await via module detection
- npm imports: auto-detect bare import specifiers in module code and inject
  importmap pointing to esm.sh CDN
- Module auto-detection for html-css-js mode (import/export statements)
- DB migration adds options column, server passes through all API endpoints
- All features work across preview, export, and embed
2026-02-26 15:15:53 -06:00

351 lines
14 KiB
JavaScript

import {
initEditors, switchMode, getEditorValues, setEditorValues,
setOnChange, getCurrentMode, getCssType, setCssType,
relayoutEditors,
MODE_TABS, MODE_TO_JS_TYPE, JS_TYPE_TO_MODE,
} from './editors.js';
import { renderPreview } from './preview.js';
import { initConsole, clearConsole } from './console-panel.js';
import { compileCss } from './preprocessors.js';
import { compileJs } from './js-preprocessors.js';
import { createFiddle, loadFiddle, updateFiddle, listTags } from './api.js';
import { getPref, setPref } from './preferences.js';
import { initEmmet } from './emmet.js';
import { initKeybindings } from './keybindings.js';
import { initResizer, clearInlineSizes } from './resizer.js';
import { exportHtml } from './export.js';
import { showQrModal } from './qr.js';
let currentId = null;
let debounceTimer = null;
let currentTags = [];
const $ = (sel) => document.querySelector(sel);
const STARTER_TEMPLATES = {
'html-css-js': { html: '', css: '', js: '' },
'typescript': {
html: '<div id="app"></div>',
css: '',
js: `interface Greeting {\n name: string;\n message: string;\n}\n\nconst greet = (g: Greeting): string => \`\${g.message}, \${g.name}!\`;\n\nconst result = greet({ name: "World", message: "Hello" });\ndocument.getElementById("app")!.innerHTML = \`<h1>\${result}</h1>\`;\nconsole.log(result);`,
},
'react': {
js: `const App = () => {\n const [count, setCount] = React.useState(0);\n return (\n <div>\n <h1>Hello React</h1>\n <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>\n </div>\n );\n};\n\nReactDOM.createRoot(document.getElementById('root')).render(<App />);`,
css: '',
html: '',
},
'react-ts': {
js: `const App: React.FC = () => {\n const [count, setCount] = React.useState<number>(0);\n return (\n <div>\n <h1>Hello React + TypeScript</h1>\n <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>\n </div>\n );\n};\n\nReactDOM.createRoot(document.getElementById('root')!).render(<App />);`,
css: '',
html: '',
},
'vue': {
js: `<template>\n <div>\n <h1>{{ msg }}</h1>\n <button @click="count++">Count: {{ count }}</button>\n </div>\n</template>\n\n<script>\nconst component = {\n data() {\n return {\n msg: 'Hello Vue!',\n count: 0,\n };\n },\n};\n</script>`,
css: '',
},
'svelte': {
js: `<script>\n let count = 0;\n</script>\n\n<h1>Hello Svelte</h1>\n<button on:click={() => count++}>Count: {count}</button>\n\n<style>\n h1 { color: #ff3e00; }\n button { padding: 8px 16px; cursor: pointer; }\n</style>`,
css: '',
},
'markdown': {
js: `# Hello Markdown\n\nThis is a **Markdown** fiddle. Write your content here and see it rendered in the preview.\n\n## Features\n\n- Headers, **bold**, *italic*\n- Lists (ordered and unordered)\n- Code blocks with syntax highlighting\n- Links, images, and more\n\n### Code Example\n\n\`\`\`javascript\nconst greeting = "Hello, World!";\nconsole.log(greeting);\n\`\`\`\n\n> Blockquotes work too!\n\n| Column 1 | Column 2 |\n|----------|----------|\n| Cell A | Cell B |`,
css: `body {\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;\n max-width: 720px;\n margin: 0 auto;\n padding: 24px;\n line-height: 1.6;\n color: #1a1a1a;\n}\n\nh1, h2, h3 { margin-top: 1.5em; margin-bottom: 0.5em; }\nh1 { border-bottom: 2px solid #eee; padding-bottom: 0.3em; }\n\ncode {\n background: #f4f4f4;\n padding: 2px 6px;\n border-radius: 3px;\n font-size: 0.9em;\n}\n\npre {\n background: #f4f4f4;\n padding: 16px;\n border-radius: 6px;\n overflow-x: auto;\n}\n\npre code { background: none; padding: 0; }\n\nblockquote {\n border-left: 4px solid #ddd;\n margin: 1em 0;\n padding: 0.5em 1em;\n color: #555;\n}\n\ntable {\n border-collapse: collapse;\n width: 100%;\n margin: 1em 0;\n}\n\nth, td {\n border: 1px solid #ddd;\n padding: 8px 12px;\n text-align: left;\n}\n\nth { background: #f4f4f4; font-weight: 600; }`,
},
'wasm': {
html: '<h1>WebAssembly Demo</h1>\n<div id="output"></div>',
css: `body {\n font-family: monospace;\n padding: 24px;\n background: #1a1a2e;\n color: #0f0;\n}\n\nh1 { color: #00d4ff; margin-bottom: 16px; }\n#output { white-space: pre; font-size: 14px; line-height: 1.8; }`,
js: `// Inline WebAssembly "add" module (no external URL needed)\n// WAT source: (module (func (export "add") (param i32 i32) (result i32) local.get 0 local.get 1 i32.add))\nconst wasmBytes = new Uint8Array([\n 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,\n 0x01, 0x07, 0x01, 0x60, 0x02, 0x7f, 0x7f, 0x01, 0x7f,\n 0x03, 0x02, 0x01, 0x00,\n 0x07, 0x07, 0x01, 0x03, 0x61, 0x64, 0x64, 0x00, 0x00,\n 0x0a, 0x09, 0x01, 0x07, 0x00, 0x20, 0x00, 0x20, 0x01, 0x6a, 0x0b\n]);\n\nconst out = document.getElementById('output');\n\ntry {\n const { instance } = await WebAssembly.instantiate(wasmBytes);\n const add = instance.exports.add;\n\n out.textContent = [\n \`WASM loaded successfully!\`,\n \`\`,\n \`add(2, 3) = \${add(2, 3)}\`,\n \`add(100, 200) = \${add(100, 200)}\`,\n \`add(-5, 10) = \${add(-5, 10)}\`,\n ].join('\\n');\n} catch (e) {\n out.textContent = 'Error: ' + e.message;\n}`,
},
};
function getTailwindChecked() {
const cb = $('#tailwind-checkbox');
return cb ? cb.checked : false;
}
async function run() {
const mode = getCurrentMode();
const { html, css, js } = getEditorValues();
const cssType = getCssType();
try {
const compiledCss = await compileCss(css, cssType);
const result = await compileJs(js, mode);
clearConsole();
// Show warnings from compilation (e.g., Svelte)
if (result.warnings && result.warnings.length) {
result.warnings.forEach((w) => {
window.postMessage({ type: 'console', method: 'warn', args: [w] }, '*');
});
}
const options = {
tailwind: getTailwindChecked(),
isModule: result.isModule || false,
renderedHtml: result.renderedHtml || null,
};
renderPreview(html, compiledCss, result.js, mode, result.extraCss || '', options);
} catch (e) {
clearConsole();
renderPreview(html, '', '', mode, '', { tailwind: getTailwindChecked() });
window.postMessage({ type: 'console', method: 'error', args: [`Compile error: ${e.message}`] }, '*');
}
}
function scheduleRun() {
if (!getPref('autoRun')) return;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(run, 500);
}
function showToast(msg) {
const toast = $('#share-toast');
toast.textContent = msg;
toast.classList.remove('hidden');
setTimeout(() => toast.classList.add('hidden'), 3000);
}
async function save() {
const { html, css, js } = getEditorValues();
const title = $('#title-input').value || 'Untitled';
const css_type = getCssType();
const js_type = MODE_TO_JS_TYPE[getCurrentMode()] || 'javascript';
const listed = $('#listed-checkbox').checked ? 1 : 0;
const tags = currentTags.slice();
const options = JSON.stringify({ tailwind: getTailwindChecked() });
try {
if (currentId) {
await updateFiddle(currentId, { title, html, css, css_type, js, js_type, listed, options, tags });
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;
history.pushState(null, '', `/f/${currentId}`);
showToast(`Saved! Share: ${location.origin}/f/${currentId}`);
}
} catch (e) {
showToast(`Save failed: ${e.message}`);
}
}
async function fork() {
const { html, css, js } = getEditorValues();
const title = ($('#title-input').value || 'Untitled') + ' (fork)';
const css_type = getCssType();
const js_type = MODE_TO_JS_TYPE[getCurrentMode()] || 'javascript';
const listed = $('#listed-checkbox').checked ? 1 : 0;
const tags = currentTags.slice();
const options = JSON.stringify({ tailwind: getTailwindChecked() });
try {
const result = await createFiddle({ title, html, css, css_type, js, js_type, listed, options, tags });
currentId = result.id;
$('#title-input').value = title;
history.pushState(null, '', `/f/${currentId}`);
showToast(`Forked! New URL: ${location.origin}/f/${currentId}`);
} catch (e) {
showToast(`Fork failed: ${e.message}`);
}
}
async function loadFromUrl() {
const match = location.pathname.match(/^\/f\/([a-zA-Z0-9_-]+)$/);
if (!match) return;
try {
const fiddle = await loadFiddle(match[1]);
currentId = fiddle.id;
$('#title-input').value = fiddle.title;
// Restore mode from js_type
const mode = JS_TYPE_TO_MODE[fiddle.js_type] || 'html-css-js';
$('#framework-mode').value = mode;
switchMode(mode);
// Restore CSS type
setCssType(fiddle.css_type || 'css');
// Restore listed/tags
$('#listed-checkbox').checked = fiddle.listed !== 0;
currentTags = (fiddle.tags || []).map(t => t.name);
renderTags();
// Restore options (tailwind checkbox)
const opts = JSON.parse(fiddle.options || '{}');
const twCb = $('#tailwind-checkbox');
if (twCb) twCb.checked = !!opts.tailwind;
setEditorValues(fiddle);
setTimeout(run, 100);
} catch (e) {
showToast(`Failed to load fiddle: ${e.message}`);
}
}
function handleModeChange(newMode) {
const oldMode = getCurrentMode();
if (newMode === oldMode) return;
switchMode(newMode);
// Insert starter template if editors are empty
const { html, css, js } = getEditorValues();
if (!html && !css && !js) {
const template = STARTER_TEMPLATES[newMode];
if (template) {
setEditorValues(template);
}
}
scheduleRun();
}
function applyLayout(layout) {
const grid = $('.grid');
// Remove all layout classes
grid.classList.remove('layout-top-bottom', 'layout-editor-only', 'layout-preview-only');
// Clear resizer inline styles when switching layouts
clearInlineSizes();
if (layout === 'top-bottom') grid.classList.add('layout-top-bottom');
else if (layout === 'editor-only') grid.classList.add('layout-editor-only');
else if (layout === 'preview-only') grid.classList.add('layout-preview-only');
setPref('layout', layout);
// Give DOM time to reflow then relayout editors
requestAnimationFrame(() => relayoutEditors());
}
async function init() {
// Load Emmet before editors so completion providers are registered
await initEmmet();
initEditors('html-css-js');
setOnChange(scheduleRun);
initConsole();
initResizer();
initKeybindings();
// Auto-run checkbox
const autoRunCb = $('#auto-run-checkbox');
autoRunCb.checked = getPref('autoRun');
autoRunCb.addEventListener('change', (e) => setPref('autoRun', e.target.checked));
// Tailwind checkbox
const twCb = $('#tailwind-checkbox');
if (twCb) {
twCb.addEventListener('change', () => scheduleRun());
}
// Layout selector
const layoutSel = $('#layout-mode');
const savedLayout = getPref('layout') || 'default';
layoutSel.value = savedLayout;
if (savedLayout !== 'default') applyLayout(savedLayout);
layoutSel.addEventListener('change', (e) => applyLayout(e.target.value));
// Mode selector
$('#framework-mode').addEventListener('change', (e) => {
handleModeChange(e.target.value);
});
// Toolbar buttons
$('#btn-run').addEventListener('click', run);
$('#btn-save').addEventListener('click', save);
$('#btn-fork').addEventListener('click', fork);
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
save();
}
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
run();
}
});
// Tags input
const tagsInput = $('#tags-input');
tagsInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
const val = tagsInput.value.trim().replace(/,/g, '');
if (val && !currentTags.includes(val)) {
currentTags.push(val);
renderTags();
}
tagsInput.value = '';
}
});
// Load tag suggestions
loadTagSuggestions();
// Export & QR buttons
$('#btn-export').addEventListener('click', async () => {
const mode = getCurrentMode();
const { html, css, js } = getEditorValues();
const cssType = getCssType();
const title = $('#title-input').value || 'Untitled';
try {
const compiledCss = await compileCss(css, cssType);
const result = await compileJs(js, mode);
exportHtml({
title, html, css: compiledCss, js: result.js, mode,
extraCss: result.extraCss,
tailwind: getTailwindChecked(),
isModule: result.isModule || false,
renderedHtml: result.renderedHtml || null,
});
} catch (e) {
showToast(`Export failed: ${e.message}`);
}
});
$('#btn-qr').addEventListener('click', () => {
const url = currentId ? `${location.origin}/f/${currentId}` : location.href;
showQrModal(url);
});
// Load fiddle from URL if present
loadFromUrl();
// Handle browser back/forward
window.addEventListener('popstate', () => {
currentId = null;
loadFromUrl();
});
}
function renderTags() {
const container = $('#tags-display');
container.innerHTML = '';
for (const tag of currentTags) {
const pill = document.createElement('span');
pill.className = 'tag-pill';
pill.innerHTML = `${tag}<button class="tag-remove">&times;</button>`;
pill.querySelector('.tag-remove').addEventListener('click', () => {
currentTags = currentTags.filter(t => t !== tag);
renderTags();
});
container.appendChild(pill);
}
}
async function loadTagSuggestions() {
try {
const { tags } = await listTags();
const datalist = $('#tags-datalist');
datalist.innerHTML = '';
for (const t of tags) {
const opt = document.createElement('option');
opt.value = t.name;
datalist.appendChild(opt);
}
} catch (_) { /* ignore */ }
}
init();