804 lines
31 KiB
JavaScript
804 lines
31 KiB
JavaScript
import {
|
|
initEditors, switchMode, getEditorValues, setEditorValues,
|
|
setOnChange, setOnTabSwitch, getCurrentMode, getCssType, setCssType,
|
|
setOnFormat, setOnDiff, setEditorTheme, setEditorFont,
|
|
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,
|
|
listVersions, getVersion, revertVersion,
|
|
listCollections, createCollection, addToCollection,
|
|
} 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';
|
|
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, formatAll } 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';
|
|
import { initNpmSearch } from './npm-search.js';
|
|
|
|
let currentId = null;
|
|
let debounceTimer = null;
|
|
let currentTags = [];
|
|
let currentResources = [];
|
|
|
|
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();
|
|
clearNetwork();
|
|
clearElements();
|
|
clearPerformance();
|
|
|
|
// 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,
|
|
previewTheme: getPref('previewTheme'),
|
|
resources: currentResources,
|
|
};
|
|
|
|
renderPreview(html, compiledCss, result.js, mode, result.extraCss || '', options);
|
|
} catch (e) {
|
|
clearConsole();
|
|
renderPreview(html, '', '', mode, '', { tailwind: getTailwindChecked(), previewTheme: getPref('previewTheme'), resources: currentResources });
|
|
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() {
|
|
// Format on save if enabled
|
|
if (getPref('formatOnSave')) {
|
|
try { await formatAll(); } catch (_) { /* best effort */ }
|
|
}
|
|
|
|
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(), resources: currentResources });
|
|
|
|
// Capture screenshot from preview iframe
|
|
let screenshot = undefined;
|
|
try {
|
|
screenshot = await captureScreenshot();
|
|
} catch (_) { /* screenshot is optional */ }
|
|
|
|
try {
|
|
if (currentId) {
|
|
await updateFiddle(currentId, { title, html, css, css_type, js, js_type, listed, options, tags, screenshot });
|
|
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;
|
|
// Capture screenshot for new fiddle too
|
|
if (screenshot) {
|
|
try { await updateFiddle(currentId, { title, html, css, css_type, js, js_type, listed, options, tags, screenshot }); } catch (_) {}
|
|
}
|
|
snapshotValues();
|
|
history.pushState(null, '', `/f/${currentId}`);
|
|
showToast(`Saved! Share: ${location.origin}/f/${currentId}`);
|
|
}
|
|
} catch (e) {
|
|
showToast(`Save failed: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
async function captureScreenshot() {
|
|
const iframe = $('#preview-frame');
|
|
if (!iframe || !iframe.contentDocument) return undefined;
|
|
try {
|
|
// Use html2canvas to capture the iframe content
|
|
if (!window.html2canvas) {
|
|
const script = document.createElement('script');
|
|
script.src = 'https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js';
|
|
await new Promise((resolve, reject) => {
|
|
script.onload = resolve;
|
|
script.onerror = reject;
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
const canvas = await html2canvas(iframe.contentDocument.body, {
|
|
width: 600, height: 400, scale: 1,
|
|
useCORS: true, logging: false, backgroundColor: '#ffffff',
|
|
});
|
|
// Resize to 600x400
|
|
const resized = document.createElement('canvas');
|
|
resized.width = 600;
|
|
resized.height = 400;
|
|
const ctx = resized.getContext('2d');
|
|
ctx.drawImage(canvas, 0, 0, 600, 400);
|
|
return resized.toDataURL('image/jpeg', 0.7);
|
|
} catch (_) {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
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(), resources: currentResources });
|
|
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, resources)
|
|
const opts = JSON.parse(fiddle.options || '{}');
|
|
const twCb = $('#tailwind-checkbox');
|
|
if (twCb) twCb.checked = !!opts.tailwind;
|
|
currentResources = opts.resources || [];
|
|
renderResourceList();
|
|
|
|
setEditorValues(fiddle);
|
|
snapshotValues();
|
|
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();
|
|
|
|
// Register custom Monaco themes before creating editors
|
|
registerCustomThemes();
|
|
|
|
// Configure autocomplete: type defaults + snippet providers
|
|
configureTypeDefaults();
|
|
registerSnippetProviders();
|
|
|
|
initEditors('html-css-js');
|
|
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');
|
|
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));
|
|
|
|
// 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);
|
|
});
|
|
|
|
// Format on save toggle
|
|
const fmtCb = $('#format-save-checkbox');
|
|
fmtCb.checked = getPref('formatOnSave');
|
|
fmtCb.addEventListener('change', (e) => setPref('formatOnSave', e.target.checked));
|
|
|
|
// Editor font selector
|
|
const fontSel = $('#editor-font');
|
|
const savedFont = getPref('editorFont') || 'default';
|
|
fontSel.value = savedFont;
|
|
if (savedFont !== 'default') loadGoogleFont(savedFont);
|
|
setEditorFont(savedFont);
|
|
fontSel.addEventListener('change', (e) => {
|
|
const font = e.target.value;
|
|
if (font !== 'default') loadGoogleFont(font);
|
|
setEditorFont(font);
|
|
setPref('editorFont', font);
|
|
});
|
|
|
|
// 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();
|
|
}
|
|
// Escape closes any open modal
|
|
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;
|
|
if (tag !== 'INPUT' && tag !== 'TEXTAREA' && !document.activeElement?.closest('.editor-area')) {
|
|
e.preventDefault();
|
|
$('#shortcuts-modal').classList.remove('hidden');
|
|
}
|
|
}
|
|
});
|
|
|
|
// 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,
|
|
previewTheme: getPref('previewTheme'),
|
|
resources: currentResources,
|
|
});
|
|
} catch (e) {
|
|
showToast(`Export failed: ${e.message}`);
|
|
}
|
|
});
|
|
|
|
$('#btn-qr').addEventListener('click', () => {
|
|
const url = currentId ? `${location.origin}/f/${currentId}` : location.href;
|
|
showQrModal(url);
|
|
});
|
|
|
|
// Version History modal
|
|
const histModal = $('#history-modal');
|
|
$('#btn-history').addEventListener('click', () => openHistoryModal());
|
|
$('#history-modal-close').addEventListener('click', () => histModal.classList.add('hidden'));
|
|
histModal.addEventListener('click', (e) => { if (e.target === histModal) histModal.classList.add('hidden'); });
|
|
|
|
// Embed modal
|
|
const embedModal = $('#embed-modal');
|
|
$('#btn-embed').addEventListener('click', () => openEmbedModal());
|
|
$('#embed-modal-close').addEventListener('click', () => embedModal.classList.add('hidden'));
|
|
embedModal.addEventListener('click', (e) => { if (e.target === embedModal) embedModal.classList.add('hidden'); });
|
|
['embed-theme', 'embed-tabs', 'embed-autorun', 'embed-width', 'embed-height'].forEach(id => {
|
|
$(`#${id}`).addEventListener('change', updateEmbedCode);
|
|
$(`#${id}`).addEventListener('input', updateEmbedCode);
|
|
});
|
|
$('#embed-copy').addEventListener('click', () => {
|
|
navigator.clipboard.writeText($('#embed-code').textContent).then(() => showToast('Embed code copied!'));
|
|
});
|
|
|
|
// Collection modal
|
|
const colModal = $('#collection-modal');
|
|
$('#btn-collection').addEventListener('click', () => openCollectionModal());
|
|
$('#collection-modal-close').addEventListener('click', () => colModal.classList.add('hidden'));
|
|
colModal.addEventListener('click', (e) => { if (e.target === colModal) colModal.classList.add('hidden'); });
|
|
$('#btn-create-collection').addEventListener('click', async () => {
|
|
const name = $('#new-collection-name').value.trim();
|
|
if (!name) return;
|
|
await createCollection({ name });
|
|
$('#new-collection-name').value = '';
|
|
openCollectionModal(); // refresh list
|
|
});
|
|
|
|
// npm search in resources modal
|
|
initNpmSearch((pkg) => {
|
|
currentResources.push({ type: pkg.type, url: pkg.url });
|
|
renderResourceList();
|
|
scheduleRun();
|
|
});
|
|
|
|
// Preview theme selector
|
|
const themeSel = $('#preview-theme');
|
|
const savedTheme = getPref('previewTheme');
|
|
themeSel.value = savedTheme;
|
|
themeSel.addEventListener('change', (e) => {
|
|
setPref('previewTheme', e.target.value);
|
|
scheduleRun();
|
|
});
|
|
|
|
// Resources modal
|
|
const resModal = $('#resources-modal');
|
|
$('#btn-resources').addEventListener('click', () => resModal.classList.remove('hidden'));
|
|
$('#resources-modal-close').addEventListener('click', () => resModal.classList.add('hidden'));
|
|
resModal.addEventListener('click', (e) => { if (e.target === resModal) resModal.classList.add('hidden'); });
|
|
|
|
$('#btn-add-css').addEventListener('click', () => {
|
|
const input = $('#resource-css-input');
|
|
const url = input.value.trim();
|
|
if (url) { currentResources.push({ type: 'css', url }); input.value = ''; renderResourceList(); scheduleRun(); }
|
|
});
|
|
$('#btn-add-js').addEventListener('click', () => {
|
|
const input = $('#resource-js-input');
|
|
const url = input.value.trim();
|
|
if (url) { currentResources.push({ type: 'js', url }); input.value = ''; renderResourceList(); scheduleRun(); }
|
|
});
|
|
$('#resource-css-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('#btn-add-css').click(); });
|
|
$('#resource-js-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('#btn-add-js').click(); });
|
|
|
|
// Shortcuts modal
|
|
const scModal = $('#shortcuts-modal');
|
|
$('#btn-shortcuts').addEventListener('click', () => scModal.classList.remove('hidden'));
|
|
$('#shortcuts-modal-close').addEventListener('click', () => scModal.classList.add('hidden'));
|
|
scModal.addEventListener('click', (e) => { if (e.target === scModal) scModal.classList.add('hidden'); });
|
|
|
|
// 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">×</button>`;
|
|
pill.querySelector('.tag-remove').addEventListener('click', () => {
|
|
currentTags = currentTags.filter(t => t !== tag);
|
|
renderTags();
|
|
});
|
|
container.appendChild(pill);
|
|
}
|
|
}
|
|
|
|
function renderResourceList() {
|
|
const container = $('#resource-list');
|
|
container.innerHTML = '';
|
|
currentResources.forEach((r, i) => {
|
|
const item = document.createElement('div');
|
|
item.className = 'resource-item';
|
|
item.innerHTML = `<span class="resource-type ${r.type}">${r.type}</span><span class="resource-url" title="${r.url}">${r.url}</span><button class="resource-remove">×</button>`;
|
|
item.querySelector('.resource-remove').addEventListener('click', () => {
|
|
currentResources.splice(i, 1);
|
|
renderResourceList();
|
|
scheduleRun();
|
|
});
|
|
container.appendChild(item);
|
|
});
|
|
}
|
|
|
|
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 */ }
|
|
}
|
|
|
|
// ===================== Version History =====================
|
|
|
|
async function openHistoryModal() {
|
|
if (!currentId) {
|
|
showToast('Save the fiddle first to see history');
|
|
return;
|
|
}
|
|
const modal = $('#history-modal');
|
|
const list = $('#history-list');
|
|
const preview = $('#history-preview');
|
|
preview.classList.add('hidden');
|
|
modal.classList.remove('hidden');
|
|
|
|
try {
|
|
const { versions } = await listVersions(currentId);
|
|
if (!versions.length) {
|
|
list.innerHTML = '<div style="padding:16px;color:var(--text-dim);text-align:center">No versions yet. Versions are created each time you save.</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = versions.map(v => `
|
|
<div class="history-item" data-version="${v.version}">
|
|
<span class="history-version">v${v.version}</span>
|
|
<span class="history-date">${new Date(v.created_at + 'Z').toLocaleString()}</span>
|
|
</div>
|
|
`).join('');
|
|
|
|
list.querySelectorAll('.history-item').forEach(item => {
|
|
item.addEventListener('click', async () => {
|
|
list.querySelectorAll('.history-item').forEach(i => i.classList.remove('active'));
|
|
item.classList.add('active');
|
|
const ver = parseInt(item.dataset.version, 10);
|
|
const vData = await getVersion(currentId, ver);
|
|
showVersionPreview(vData);
|
|
});
|
|
});
|
|
} catch (e) {
|
|
list.innerHTML = `<div style="padding:16px;color:var(--text-dim)">Error: ${e.message}</div>`;
|
|
}
|
|
}
|
|
|
|
function showVersionPreview(vData) {
|
|
const preview = $('#history-preview');
|
|
const label = $('#history-preview-label');
|
|
const diff = $('#history-diff');
|
|
preview.classList.remove('hidden');
|
|
label.textContent = `Version ${vData.version} — ${new Date(vData.created_at + 'Z').toLocaleString()}`;
|
|
|
|
const current = getEditorValues();
|
|
diff.innerHTML = '';
|
|
|
|
const sections = [
|
|
{ label: 'HTML', old: vData.html, cur: current.html },
|
|
{ label: 'CSS', old: vData.css, cur: current.css },
|
|
{ label: 'JS', old: vData.js, cur: current.js },
|
|
];
|
|
|
|
for (const s of sections) {
|
|
if (s.old === s.cur) continue;
|
|
const sec = document.createElement('div');
|
|
sec.className = 'history-diff-section';
|
|
sec.innerHTML = `<div class="history-diff-label">${s.label}</div><pre class="history-diff-code">${escHtml(s.old || '(empty)')}</pre>`;
|
|
diff.appendChild(sec);
|
|
}
|
|
|
|
if (!diff.children.length) {
|
|
diff.innerHTML = '<div style="padding:12px;color:var(--text-dim)">No changes from current version</div>';
|
|
}
|
|
|
|
// Wire restore button
|
|
const restoreBtn = $('#history-restore-btn');
|
|
restoreBtn.onclick = async () => {
|
|
try {
|
|
const result = await revertVersion(currentId, vData.version);
|
|
setEditorValues({ html: result.html, css: result.css, js: result.js });
|
|
snapshotValues();
|
|
$('#history-modal').classList.add('hidden');
|
|
showToast('Restored version ' + vData.version);
|
|
run();
|
|
} catch (e) {
|
|
showToast('Restore failed: ' + e.message);
|
|
}
|
|
};
|
|
}
|
|
|
|
function escHtml(str) {
|
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
// ===================== Embed Modal =====================
|
|
|
|
function openEmbedModal() {
|
|
if (!currentId) {
|
|
showToast('Save the fiddle first to generate embed code');
|
|
return;
|
|
}
|
|
$('#embed-modal').classList.remove('hidden');
|
|
updateEmbedCode();
|
|
}
|
|
|
|
function updateEmbedCode() {
|
|
if (!currentId) return;
|
|
const theme = $('#embed-theme').value;
|
|
const tabs = $('#embed-tabs').value;
|
|
const autorun = $('#embed-autorun').value;
|
|
const width = $('#embed-width').value || '100%';
|
|
const height = $('#embed-height').value || '400';
|
|
|
|
const params = new URLSearchParams();
|
|
if (theme !== 'light') params.set('theme', theme);
|
|
if (tabs === '0') params.set('tabs', '0');
|
|
if (autorun === '0') params.set('run', '0');
|
|
const qs = params.toString();
|
|
|
|
const url = `${location.origin}/embed/${currentId}${qs ? '?' + qs : ''}`;
|
|
const heightVal = /^\d+$/.test(height) ? height + 'px' : height;
|
|
const snippet = `<iframe src="${url}" style="width:${width};height:${heightVal};border:0;border-radius:4px;overflow:hidden" sandbox="allow-scripts allow-same-origin"></iframe>`;
|
|
|
|
$('#embed-code').textContent = snippet;
|
|
$('#embed-preview-frame').src = url;
|
|
}
|
|
|
|
// ===================== Collection Modal =====================
|
|
|
|
async function openCollectionModal() {
|
|
if (!currentId) {
|
|
showToast('Save the fiddle first to add to a collection');
|
|
return;
|
|
}
|
|
const modal = $('#collection-modal');
|
|
const list = $('#collection-list');
|
|
modal.classList.remove('hidden');
|
|
|
|
try {
|
|
const { collections } = await listCollections();
|
|
if (!collections.length) {
|
|
list.innerHTML = '<div style="padding:12px;color:var(--text-dim);text-align:center">No collections yet</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = collections.map(c => `
|
|
<div class="collection-item" data-id="${c.id}">
|
|
<span class="collection-name">${escHtml(c.name)}</span>
|
|
<span class="collection-count">${c.fiddle_count} fiddles</span>
|
|
<button class="btn-small collection-add-btn">Add</button>
|
|
</div>
|
|
`).join('');
|
|
|
|
list.querySelectorAll('.collection-add-btn').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const id = btn.closest('.collection-item').dataset.id;
|
|
try {
|
|
await addToCollection(id, currentId);
|
|
btn.textContent = 'Added';
|
|
btn.disabled = true;
|
|
} catch (e) {
|
|
showToast('Failed to add: ' + e.message);
|
|
}
|
|
});
|
|
});
|
|
} catch (e) {
|
|
list.innerHTML = `<div style="padding:12px;color:var(--text-dim)">Error: ${e.message}</div>`;
|
|
}
|
|
}
|
|
|
|
// ===================== Google Fonts Loader =====================
|
|
|
|
const loadedFonts = new Set();
|
|
function loadGoogleFont(fontName) {
|
|
if (loadedFonts.has(fontName)) return;
|
|
loadedFonts.add(fontName);
|
|
const link = document.createElement('link');
|
|
link.rel = 'stylesheet';
|
|
link.href = `https://fonts.googleapis.com/css2?family=${fontName.replace(/ /g, '+')}:wght@400;500;600;700&display=swap`;
|
|
document.head.appendChild(link);
|
|
}
|
|
|
|
init();
|