Compare commits

1 Commits
master ... dev

Author SHA1 Message Date
root
ae8dbafb20 Add Python REPL, instant deploy, Gist import, presentation mode, and CSS visual tools
- Python mode via Pyodide WASM runtime with stdout/stderr console integration
- Publish fiddles to clean /p/:slug URLs as standalone HTML pages
- Import code from GitHub Gist URLs with auto-detection of language/mode
- Presentation mode with slide management, fullscreen viewer, and keyboard nav
- Enable Monaco color decorators for inline CSS color pickers
- Extract reusable generateStandaloneHtml from export module
2026-02-27 15:50:55 -06:00
11 changed files with 666 additions and 6 deletions

43
db.js
View File

@@ -59,6 +59,15 @@ try {
db.exec(`ALTER TABLE fiddles ADD COLUMN screenshot TEXT`); db.exec(`ALTER TABLE fiddles ADD COLUMN screenshot TEXT`);
} catch (_) { /* column already exists */ } } catch (_) { /* column already exists */ }
// Migration: add publishing columns
try {
db.exec(`ALTER TABLE fiddles ADD COLUMN published_slug TEXT`);
} catch (_) { /* column already exists */ }
try {
db.exec(`ALTER TABLE fiddles ADD COLUMN published_html TEXT`);
} catch (_) { /* column already exists */ }
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_fiddles_published_slug ON fiddles(published_slug) WHERE published_slug IS NOT NULL`);
// Version history table // Version history table
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS fiddle_versions ( CREATE TABLE IF NOT EXISTS fiddle_versions (
@@ -95,6 +104,21 @@ db.exec(`
) )
`); `);
// Presentation slides table
db.exec(`
CREATE TABLE IF NOT EXISTS fiddle_slides (
id TEXT PRIMARY KEY,
fiddle_id TEXT NOT NULL,
slide_order INTEGER NOT NULL,
html TEXT DEFAULT '',
css TEXT DEFAULT '',
js TEXT DEFAULT '',
notes TEXT DEFAULT '',
FOREIGN KEY (fiddle_id) REFERENCES fiddles(id) ON DELETE CASCADE
)
`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_slides_fiddle ON fiddle_slides(fiddle_id, slide_order)`);
export const stmts = { export const stmts = {
insert: db.prepare(` insert: db.prepare(`
INSERT INTO fiddles (id, title, html, css, css_type, js, js_type, listed, options) INSERT INTO fiddles (id, title, html, css, css_type, js, js_type, listed, options)
@@ -145,6 +169,12 @@ export const stmts = {
// Screenshot // Screenshot
updateScreenshot: db.prepare('UPDATE fiddles SET screenshot = ? WHERE id = ?'), updateScreenshot: db.prepare('UPDATE fiddles SET screenshot = ? WHERE id = ?'),
// Publishing
publishFiddle: db.prepare('UPDATE fiddles SET published_slug = @slug, published_html = @html WHERE id = @id'),
getPublishedFiddle: db.prepare('SELECT id, title, published_html FROM fiddles WHERE published_slug = ?'),
unpublishFiddle: db.prepare('UPDATE fiddles SET published_slug = NULL, published_html = NULL WHERE id = ?'),
getPublishStatus: db.prepare('SELECT published_slug FROM fiddles WHERE id = ?'),
// Collections // Collections
insertCollection: db.prepare(` insertCollection: db.prepare(`
INSERT INTO collections (id, name, description) VALUES (@id, @name, @description) INSERT INTO collections (id, name, description) VALUES (@id, @name, @description)
@@ -179,6 +209,19 @@ export const stmts = {
JOIN collection_fiddles cf ON cf.collection_id = c.id JOIN collection_fiddles cf ON cf.collection_id = c.id
WHERE cf.fiddle_id = ? WHERE cf.fiddle_id = ?
`), `),
// Slides
insertSlide: db.prepare(`
INSERT INTO fiddle_slides (id, fiddle_id, slide_order, html, css, js, notes)
VALUES (@id, @fiddle_id, @slide_order, @html, @css, @js, @notes)
`),
listSlides: db.prepare('SELECT * FROM fiddle_slides WHERE fiddle_id = ? ORDER BY slide_order'),
getSlide: db.prepare('SELECT * FROM fiddle_slides WHERE id = ?'),
updateSlide: db.prepare(`
UPDATE fiddle_slides SET html = @html, css = @css, js = @js, notes = @notes, slide_order = @slide_order WHERE id = @id
`),
deleteSlide: db.prepare('DELETE FROM fiddle_slides WHERE id = ?'),
getMaxSlideOrder: db.prepare('SELECT COALESCE(MAX(slide_order), 0) as max_order FROM fiddle_slides WHERE fiddle_id = ?'),
}; };
/** /**

View File

@@ -766,3 +766,50 @@ body.resizing iframe { pointer-events: none; }
.npm-result-version { font-size: 10px; color: var(--text-dim); font-weight: 400; } .npm-result-version { font-size: 10px; color: var(--text-dim); font-weight: 400; }
.npm-result-desc { font-size: 11px; color: var(--text-dim); margin-top: 2px; } .npm-result-desc { font-size: 11px; color: var(--text-dim); margin-top: 2px; }
.npm-no-results { padding: 12px; text-align: center; color: var(--text-dim); font-size: 12px; } .npm-no-results { padding: 12px; text-align: center; color: var(--text-dim); font-size: 12px; }
/* Slide list (presentation manager) */
.slide-list { max-height: 300px; overflow-y: auto; }
.slide-item {
display: flex; align-items: center; gap: 8px; padding: 8px 16px;
border-bottom: 1px solid var(--border); font-size: 12px;
}
.slide-item:hover { background: rgba(255,255,255,0.03); }
.slide-number {
width: 24px; height: 24px; border-radius: 50%; background: var(--accent);
color: #fff; display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 600; flex-shrink: 0;
}
.slide-preview-text { flex: 1; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.slide-delete-btn { flex-shrink: 0; }
/* Presentation overlay (fullscreen) */
.presentation-overlay {
position: fixed; inset: 0; z-index: 10000;
background: #111; display: flex; flex-direction: column;
}
.presentation-overlay.hidden { display: none; }
.pres-header {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 16px; background: #1a1a1a; border-bottom: 1px solid #333;
}
.pres-counter { color: #aaa; font-size: 13px; font-weight: 500; }
.pres-exit-btn {
background: transparent; color: #aaa; border: 1px solid #444;
padding: 4px 12px; font-size: 12px; border-radius: 4px;
}
.pres-exit-btn:hover { background: #333; color: #fff; }
.pres-content { flex: 1; display: flex; overflow: hidden; }
.pres-iframe { width: 100%; height: 100%; border: none; background: #fff; }
.pres-footer {
display: flex; align-items: center; gap: 12px;
padding: 8px 16px; background: #1a1a1a; border-top: 1px solid #333;
}
.pres-nav-btn {
background: transparent; color: #aaa; border: 1px solid #444;
padding: 6px 16px; font-size: 12px; border-radius: 4px; flex-shrink: 0;
}
.pres-nav-btn:hover { background: #333; color: #fff; }
.pres-notes {
flex: 1; color: #888; font-size: 12px; font-style: italic;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center;
}

View File

@@ -19,6 +19,7 @@
<option value="svelte">Svelte</option> <option value="svelte">Svelte</option>
<option value="markdown">Markdown</option> <option value="markdown">Markdown</option>
<option value="wasm">WASM</option> <option value="wasm">WASM</option>
<option value="python">Python</option>
</select> </select>
<input type="text" id="title-input" placeholder="Untitled" spellcheck="false"> <input type="text" id="title-input" placeholder="Untitled" spellcheck="false">
<div class="tags-input-wrap"> <div class="tags-input-wrap">
@@ -54,6 +55,9 @@
<button id="btn-templates" class="btn-icon" title="Starter templates"> <button id="btn-templates" class="btn-icon" title="Starter templates">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
</button> </button>
<button id="btn-import-gist" class="btn-icon" title="Import from GitHub Gist">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/></svg>
</button>
<button id="btn-resources" class="btn-icon" title="External resources"> <button id="btn-resources" class="btn-icon" title="External resources">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
</button> </button>
@@ -63,12 +67,18 @@
<button id="btn-embed" class="btn-icon" title="Embed code"> <button id="btn-embed" class="btn-icon" title="Embed code">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
</button> </button>
<button id="btn-publish" class="btn-icon" title="Publish to clean URL">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg>
</button>
<button id="btn-export" class="btn-icon" title="Export HTML"> <button id="btn-export" class="btn-icon" title="Export HTML">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button> </button>
<button id="btn-collection" class="btn-icon" title="Collections"> <button id="btn-collection" class="btn-icon" title="Collections">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
</button> </button>
<button id="btn-presentation" class="btn-icon" title="Presentation mode">
<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"/><path d="M8 21l4-4 4 4"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</button>
<button id="btn-qr" class="btn-icon" title="QR code"> <button id="btn-qr" class="btn-icon" title="QR code">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="8" height="8" rx="1"/><rect x="14" y="2" width="8" height="8" rx="1"/><rect x="2" y="14" width="8" height="8" rx="1"/><rect x="14" y="14" width="4" height="4"/><line x1="22" y1="14" x2="22" y2="22"/><line x1="14" y1="22" x2="22" y2="22"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="8" height="8" rx="1"/><rect x="14" y="2" width="8" height="8" rx="1"/><rect x="2" y="14" width="8" height="8" rx="1"/><rect x="14" y="14" width="4" height="4"/><line x1="22" y1="14" x2="22" y2="22"/><line x1="14" y1="22" x2="22" y2="22"/></svg>
</button> </button>
@@ -306,6 +316,38 @@
</div> </div>
</div> </div>
<div id="presentation-modal" class="modal-overlay hidden">
<div class="modal-content" style="min-width:380px">
<div class="modal-header">
<span>Presentation Mode</span>
<button id="presentation-modal-close" class="btn-small">&times;</button>
</div>
<div style="padding:8px 16px;display:flex;gap:8px;align-items:center">
<button id="btn-add-slide" class="btn-small">+ Add Current State as Slide</button>
<span id="slide-count" style="color:var(--text-dim);font-size:12px"></span>
</div>
<div id="slide-list" class="slide-list"></div>
<div style="padding:8px 16px">
<button id="btn-start-presentation" style="width:100%">Start Presentation</button>
</div>
</div>
</div>
<div id="presentation-overlay" class="presentation-overlay hidden">
<div class="pres-header">
<span id="pres-counter" class="pres-counter"></span>
<button id="pres-exit" class="pres-exit-btn">Exit (Esc)</button>
</div>
<div class="pres-content">
<iframe id="pres-preview-frame" class="pres-iframe" sandbox="allow-scripts allow-same-origin"></iframe>
</div>
<div class="pres-footer">
<button id="pres-prev" class="pres-nav-btn">&larr; Prev</button>
<div id="pres-notes" class="pres-notes"></div>
<button id="pres-next" class="pres-nav-btn">Next &rarr;</button>
</div>
</div>
<div id="share-toast" class="toast hidden"></div> <div id="share-toast" class="toast hidden"></div>
<script> <script>

View File

@@ -53,6 +53,41 @@ export function revertVersion(id, ver) {
return request(`${BASE}/${id}/revert/${ver}`, { method: 'POST' }); return request(`${BASE}/${id}/revert/${ver}`, { method: 'POST' });
} }
// Slides (presentation mode)
export function listSlides(fiddleId) {
return request(`${BASE}/${fiddleId}/slides`);
}
export function createSlide(fiddleId, data) {
return request(`${BASE}/${fiddleId}/slides`, { method: 'POST', body: JSON.stringify(data) });
}
export function updateSlide(slideId, data) {
return request(`/api/slides/${slideId}`, { method: 'PUT', body: JSON.stringify(data) });
}
export function deleteSlide(slideId) {
return request(`/api/slides/${slideId}`, { method: 'DELETE' });
}
// Gist import
export function importGist(url) {
return request('/api/import/gist', { method: 'POST', body: JSON.stringify({ url }) });
}
// Publishing
export function publishFiddle(id, html) {
return request(`${BASE}/${id}/publish`, { method: 'POST', body: JSON.stringify({ html }) });
}
export function unpublishFiddle(id) {
return request(`${BASE}/${id}/publish`, { method: 'DELETE' });
}
export function getPublishStatus(id) {
return request(`${BASE}/${id}/publish`);
}
// Collections // Collections
export function createCollection(data) { export function createCollection(data) {
return request('/api/collections', { method: 'POST', body: JSON.stringify(data) }); return request('/api/collections', { method: 'POST', body: JSON.stringify(data) });

View File

@@ -13,12 +13,14 @@ import {
createFiddle, loadFiddle, updateFiddle, listTags, createFiddle, loadFiddle, updateFiddle, listTags,
listVersions, getVersion, revertVersion, listVersions, getVersion, revertVersion,
listCollections, createCollection, addToCollection, listCollections, createCollection, addToCollection,
publishFiddle, unpublishFiddle, getPublishStatus,
importGist,
} from './api.js'; } from './api.js';
import { getPref, setPref } from './preferences.js'; import { getPref, setPref } from './preferences.js';
import { initEmmet } from './emmet.js'; import { initEmmet } from './emmet.js';
import { initKeybindings } from './keybindings.js'; import { initKeybindings } from './keybindings.js';
import { initResizer, clearInlineSizes } from './resizer.js'; import { initResizer, clearInlineSizes } from './resizer.js';
import { exportHtml } from './export.js'; import { exportHtml, generateStandaloneHtml } from './export.js';
import { showQrModal } from './qr.js'; import { showQrModal } from './qr.js';
import { initDevtools } from './devtools.js'; import { initDevtools } from './devtools.js';
import { initNetwork, clearNetwork } from './network-panel.js'; import { initNetwork, clearNetwork } from './network-panel.js';
@@ -31,6 +33,7 @@ import { registerCustomThemes, THEMES } from './editor-themes.js';
import { GALLERY_TEMPLATES } from './templates.js'; import { GALLERY_TEMPLATES } from './templates.js';
import { configureTypeDefaults, registerSnippetProviders } from './autocomplete.js'; import { configureTypeDefaults, registerSnippetProviders } from './autocomplete.js';
import { initNpmSearch } from './npm-search.js'; import { initNpmSearch } from './npm-search.js';
import { openSlideManager, addCurrentSlide, startPresentation, stopPresentation } from './presentation.js';
let currentId = null; let currentId = null;
let debounceTimer = null; let debounceTimer = null;
@@ -68,6 +71,10 @@ const STARTER_TEMPLATES = {
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 |`, 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; }`, 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; }`,
}, },
'python': {
js: `print("Hello from Python!")\n\nimport math\nprint(f"Pi is {math.pi:.4f}")\n\n# Use the sys module\nimport sys\nprint(f"Python {sys.version}")\n\n# List comprehension\nsquares = [x**2 for x in range(10)]\nprint(f"Squares: {squares}")`,
html: '',
},
'wasm': { 'wasm': {
html: '<h1>WebAssembly Demo</h1>\n<div id="output"></div>', 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; }`, 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; }`,
@@ -494,6 +501,63 @@ async function init() {
} }
}); });
// Publish button
$('#btn-publish').addEventListener('click', async () => {
if (!currentId) {
showToast('Save the fiddle first before publishing');
return;
}
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);
const standaloneHtml = generateStandaloneHtml({
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,
});
const pubResult = await publishFiddle(currentId, standaloneHtml);
const fullUrl = `${location.origin}${pubResult.url}`;
try { await navigator.clipboard.writeText(fullUrl); } catch (_) {}
showToast(`Published! ${fullUrl} (copied)`);
} catch (e) {
showToast(`Publish failed: ${e.message}`);
}
});
// Import Gist button
$('#btn-import-gist').addEventListener('click', async () => {
const gistUrl = prompt('Paste a GitHub Gist URL:');
if (!gistUrl || !gistUrl.trim()) return;
try {
const data = await importGist(gistUrl.trim());
// Switch mode
if (data.mode) {
$('#framework-mode').value = data.mode;
handleModeChange(data.mode);
}
// Set CSS type
if (data.css_type && data.css_type !== 'css') {
setCssType(data.css_type);
}
// Set editor values
setEditorValues({ html: data.html || '', css: data.css || '', js: data.js || '' });
// Set title
if (data.title) $('#title-input').value = data.title;
run();
showToast('Gist imported successfully');
} catch (e) {
showToast(`Import failed: ${e.message}`);
}
});
$('#btn-qr').addEventListener('click', () => { $('#btn-qr').addEventListener('click', () => {
const url = currentId ? `${location.origin}/f/${currentId}` : location.href; const url = currentId ? `${location.origin}/f/${currentId}` : location.href;
showQrModal(url); showQrModal(url);
@@ -531,6 +595,21 @@ async function init() {
openCollectionModal(); // refresh list openCollectionModal(); // refresh list
}); });
// Presentation mode
const presModal = $('#presentation-modal');
$('#btn-presentation').addEventListener('click', () => openSlideManager(currentId));
$('#presentation-modal-close').addEventListener('click', () => presModal.classList.add('hidden'));
presModal.addEventListener('click', (e) => { if (e.target === presModal) presModal.classList.add('hidden'); });
$('#btn-add-slide').addEventListener('click', () => addCurrentSlide(currentId));
$('#btn-start-presentation').addEventListener('click', () => startPresentation(currentId));
$('#pres-exit').addEventListener('click', () => stopPresentation());
$('#pres-prev').addEventListener('click', () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
});
$('#pres-next').addEventListener('click', () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
});
// npm search in resources modal // npm search in resources modal
initNpmSearch((pkg) => { initNpmSearch((pkg) => {
currentResources.push({ type: pkg.type, url: pkg.url }); currentResources.push({ type: pkg.type, url: pkg.url });

View File

@@ -33,6 +33,7 @@ const editorOpts = {
autoClosingQuotes: 'always', autoClosingQuotes: 'always',
autoSurround: 'languageDefined', autoSurround: 'languageDefined',
bracketPairColorization: { enabled: true }, bracketPairColorization: { enabled: true },
colorDecorators: true,
}; };
export const MODE_TABS = { export const MODE_TABS = {
@@ -73,6 +74,10 @@ export const MODE_TABS = {
{ id: 'css', label: 'CSS', lang: 'css', ext: 'css' }, { id: 'css', label: 'CSS', lang: 'css', ext: 'css' },
{ id: 'js', label: 'JavaScript', lang: 'javascript', ext: 'js' }, { id: 'js', label: 'JavaScript', lang: 'javascript', ext: 'js' },
], ],
'python': [
{ id: 'js', label: 'Python', lang: 'python', ext: 'py' },
{ id: 'html', label: 'HTML', lang: 'html', ext: 'html' },
],
}; };
// Map mode names to js_type values stored in DB // Map mode names to js_type values stored in DB
@@ -85,6 +90,7 @@ export const MODE_TO_JS_TYPE = {
'svelte': 'svelte', 'svelte': 'svelte',
'markdown': 'markdown', 'markdown': 'markdown',
'wasm': 'wasm', 'wasm': 'wasm',
'python': 'python',
}; };
export const JS_TYPE_TO_MODE = Object.fromEntries( export const JS_TYPE_TO_MODE = Object.fromEntries(

View File

@@ -2,9 +2,9 @@ import { getFrameworkRuntime } from './js-preprocessors.js';
import { extractBareImports, buildImportMapTag } from './import-map.js'; import { extractBareImports, buildImportMapTag } from './import-map.js';
/** /**
* Export a fiddle as a standalone HTML file and trigger download. * Generate standalone HTML string from fiddle data.
*/ */
export function exportHtml({ title, html, css, js, mode, extraCss = '', isModule = false, tailwind = false, renderedHtml = null, previewTheme = 'light', resources = [] }) { export function generateStandaloneHtml({ title, html, css, js, mode, extraCss = '', isModule = false, tailwind = false, renderedHtml = null, previewTheme = 'light', resources = [] }) {
const runtime = getFrameworkRuntime(mode); const runtime = getFrameworkRuntime(mode);
const allCss = extraCss ? `${css}\n${extraCss}` : css; const allCss = extraCss ? `${css}\n${extraCss}` : css;
@@ -22,7 +22,6 @@ export function exportHtml({ title, html, css, js, mode, extraCss = '', isModule
const effectiveIsModule = isModule || mode === 'svelte'; const effectiveIsModule = isModule || mode === 'svelte';
// Build importmap for module scripts with bare imports
let importMapTag = ''; let importMapTag = '';
if (effectiveIsModule && finalJs) { if (effectiveIsModule && finalJs) {
const bareImports = extractBareImports(finalJs); const bareImports = extractBareImports(finalJs);
@@ -59,7 +58,7 @@ export function exportHtml({ title, html, css, js, mode, extraCss = '', isModule
} }
} }
const doc = `<!DOCTYPE html> return `<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@@ -75,12 +74,18 @@ ${bodyContent}
${scripts} ${scripts}
</body> </body>
</html>`; </html>`;
}
/**
* Export a fiddle as a standalone HTML file and trigger download.
*/
export function exportHtml(opts) {
const doc = generateStandaloneHtml(opts);
const blob = new Blob([doc], { type: 'text/html' }); const blob = new Blob([doc], { type: 'text/html' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `${title.replace(/[^a-zA-Z0-9_-]/g, '_')}.html`; a.download = `${(opts.title || 'fiddle').replace(/[^a-zA-Z0-9_-]/g, '_')}.html`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }

View File

@@ -89,6 +89,9 @@ export async function compileJs(code, mode) {
return { js: code, isModule: hasModuleSyntax || hasTopLevelAwait }; return { js: code, isModule: hasModuleSyntax || hasTopLevelAwait };
} }
case 'python':
return compilePython(code);
default: default:
return { js: code }; return { js: code };
} }
@@ -188,6 +191,34 @@ async function compileSvelte(code) {
return { js, warnings, isModule: true }; return { js, warnings, isModule: true };
} }
function compilePython(code) {
// Wrap Python code in a JS loader that runs it via Pyodide in the iframe
const escapedCode = JSON.stringify(code);
const js = `
(async function() {
const status = document.getElementById('pyodide-status');
try {
if (status) status.textContent = 'Loading Python runtime...';
const pyodide = await loadPyodide();
pyodide.setStdout({ batched: (text) => {
if (window.__fiddle_console) window.__fiddle_console('log', text);
else console.log(text);
}});
pyodide.setStderr({ batched: (text) => {
if (window.__fiddle_console) window.__fiddle_console('error', text);
else console.error(text);
}});
if (status) status.textContent = '';
await pyodide.runPythonAsync(${escapedCode});
} catch(e) {
if (status) status.textContent = '';
console.error('Python error: ' + e.message);
}
})();
`;
return { js, isModule: false };
}
/** /**
* Get runtime scripts and body HTML to inject into the preview iframe. * Get runtime scripts and body HTML to inject into the preview iframe.
*/ */
@@ -223,6 +254,12 @@ export function getFrameworkRuntime(mode) {
case 'wasm': case 'wasm':
return { scripts: [], bodyHtml: '' }; return { scripts: [], bodyHtml: '' };
case 'python':
return {
scripts: ['https://cdn.jsdelivr.net/pyodide/v0.27.2/full/pyodide.js'],
bodyHtml: '<div id="pyodide-status" style="font-family:monospace;color:#888;padding:12px">Loading Python...</div>',
};
default: default:
return { scripts: [], bodyHtml: '' }; return { scripts: [], bodyHtml: '' };
} }

217
public/js/presentation.js Normal file
View File

@@ -0,0 +1,217 @@
import { listSlides, createSlide, deleteSlide } from './api.js';
import { getEditorValues } from './editors.js';
import { renderPreview } from './preview.js';
import { compileCss } from './preprocessors.js';
import { compileJs } from './js-preprocessors.js';
import { getCurrentMode, getCssType } from './editors.js';
import { getPref } from './preferences.js';
let slides = [];
let currentSlideIndex = 0;
let isPresenting = false;
const $ = (sel) => document.querySelector(sel);
function showToast(msg) {
const toast = $('#share-toast');
if (!toast) return;
toast.textContent = msg;
toast.classList.remove('hidden');
setTimeout(() => toast.classList.add('hidden'), 3000);
}
export async function openSlideManager(fiddleId) {
if (!fiddleId) {
showToast('Save the fiddle first to use presentation mode');
return;
}
const modal = $('#presentation-modal');
modal.classList.remove('hidden');
try {
const result = await listSlides(fiddleId);
slides = result.slides || [];
renderSlideList(fiddleId);
} catch (e) {
showToast('Failed to load slides: ' + e.message);
}
}
function renderSlideList(fiddleId) {
const list = $('#slide-list');
list.innerHTML = '';
if (!slides.length) {
list.innerHTML = '<div style="padding:16px;color:var(--text-dim);text-align:center">No slides yet. Add the current editor state as a slide.</div>';
} else {
slides.forEach((slide, i) => {
const item = document.createElement('div');
item.className = 'slide-item';
item.innerHTML = `
<span class="slide-number">${i + 1}</span>
<span class="slide-preview-text">${escHtml(slide.notes || slide.js.slice(0, 60) || slide.html.slice(0, 60) || '(empty)')}</span>
<button class="slide-delete-btn btn-small" data-id="${slide.id}">&times;</button>
`;
list.appendChild(item);
});
list.querySelectorAll('.slide-delete-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
try {
await deleteSlide(btn.dataset.id);
slides = slides.filter(s => s.id !== btn.dataset.id);
renderSlideList(fiddleId);
} catch (err) {
showToast('Delete failed: ' + err.message);
}
});
});
}
// Update slide count
const countEl = $('#slide-count');
if (countEl) countEl.textContent = `${slides.length} slide${slides.length !== 1 ? 's' : ''}`;
}
export async function addCurrentSlide(fiddleId) {
if (!fiddleId) return;
const { html, css, js } = getEditorValues();
const notes = prompt('Slide notes (optional):') || '';
try {
const slide = await createSlide(fiddleId, { html, css, js, notes });
slides.push(slide);
renderSlideList(fiddleId);
showToast(`Slide ${slides.length} added`);
} catch (e) {
showToast('Failed to add slide: ' + e.message);
}
}
export async function startPresentation(fiddleId) {
if (!fiddleId) {
showToast('Save the fiddle first');
return;
}
try {
const result = await listSlides(fiddleId);
slides = result.slides || [];
} catch (e) {
showToast('Failed to load slides: ' + e.message);
return;
}
if (!slides.length) {
showToast('Add at least one slide first');
return;
}
isPresenting = true;
currentSlideIndex = 0;
const overlay = $('#presentation-overlay');
overlay.classList.remove('hidden');
// Close the manager modal if open
$('#presentation-modal').classList.add('hidden');
renderCurrentSlide();
document.addEventListener('keydown', presentationKeyHandler);
}
export function stopPresentation() {
isPresenting = false;
const overlay = $('#presentation-overlay');
overlay.classList.add('hidden');
document.removeEventListener('keydown', presentationKeyHandler);
}
function presentationKeyHandler(e) {
if (!isPresenting) return;
if (e.key === 'Escape') {
e.preventDefault();
stopPresentation();
} else if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ') {
e.preventDefault();
if (currentSlideIndex < slides.length - 1) {
currentSlideIndex++;
renderCurrentSlide();
}
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
if (currentSlideIndex > 0) {
currentSlideIndex--;
renderCurrentSlide();
}
}
}
async function renderCurrentSlide() {
const slide = slides[currentSlideIndex];
if (!slide) return;
const counter = $('#pres-counter');
counter.textContent = `${currentSlideIndex + 1} / ${slides.length}`;
const notes = $('#pres-notes');
notes.textContent = slide.notes || '';
// Render the slide's code into the presentation iframe
const frame = $('#pres-preview-frame');
const mode = getCurrentMode();
const cssType = getCssType();
try {
const compiledCss = await compileCss(slide.css, cssType);
const result = await compileJs(slide.js, mode);
const { getFrameworkRuntime } = await import('./js-preprocessors.js');
const runtime = getFrameworkRuntime(mode);
const allCss = result.extraCss ? `${compiledCss}\n${result.extraCss}` : compiledCss;
const finalHtml = result.renderedHtml || slide.html;
const finalJs = result.renderedHtml ? '' : result.js;
let bodyContent;
if (mode === 'vue' || mode === 'svelte') {
bodyContent = finalHtml ? `${finalHtml}\n${runtime.bodyHtml}` : runtime.bodyHtml;
} else if (runtime.bodyHtml) {
bodyContent = `${finalHtml}\n${runtime.bodyHtml}`;
} else {
bodyContent = finalHtml;
}
const isModule = result.isModule || mode === 'svelte';
let scripts = '';
if (finalJs) {
if (isModule) {
scripts = `<script type="module">\n${finalJs}\n<\/script>`;
} else {
for (const url of runtime.scripts) {
scripts += `<script src="${url}"><\/script>\n`;
}
scripts += `<script>\n${finalJs}\n<\/script>`;
}
}
const previewTheme = getPref('previewTheme');
const darkCss = previewTheme === 'dark'
? `<style>body { background: #1e1e1e; color: #ccc; }</style>\n`
: '';
const doc = `<!DOCTYPE html>
<html><head><meta charset="UTF-8">${darkCss}<style>${allCss}</style></head>
<body>${bodyContent}${scripts}</body></html>`;
frame.srcdoc = doc;
} catch (e) {
frame.srcdoc = `<pre style="color:red;padding:20px">Error: ${escHtml(e.message)}</pre>`;
}
}
function escHtml(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

View File

@@ -76,6 +76,11 @@ const consoleInterceptor = `
if (e.data && e.data.type === 'devtools-request' && e.data.tab === 'elements') sendElements(); if (e.data && e.data.type === 'devtools-request' && e.data.tab === 'elements') sendElements();
}); });
// Global helper for Pyodide stdout/stderr bridging
window.__fiddle_console = function(method, text) {
parent.postMessage({ type: 'console', method: method, args: [String(text)] }, '*');
};
// --- Performance: timing metrics --- // --- Performance: timing metrics ---
window.__fiddle_scriptStart = performance.now(); window.__fiddle_scriptStart = performance.now();
window.addEventListener('load', function() { window.addEventListener('load', function() {

144
server.js
View File

@@ -22,6 +22,13 @@ app.get('/f/:id', (_req, res) => {
res.sendFile('index.html', { root: 'public' }); res.sendFile('index.html', { root: 'public' });
}); });
// Published fiddle (clean URL)
app.get('/p/:slug', (req, res) => {
const row = stmts.getPublishedFiddle.get(req.params.slug);
if (!row || !row.published_html) return res.status(404).send('Not found');
res.type('html').send(row.published_html);
});
app.use(express.static('public', { index: false })); app.use(express.static('public', { index: false }));
// API: Create fiddle // API: Create fiddle
@@ -124,6 +131,109 @@ app.get('/api/tags', (_req, res) => {
res.json({ tags: stmts.listTags.all() }); res.json({ tags: stmts.listTags.all() });
}); });
// ===================== Gist Import API =====================
app.post('/api/import/gist', async (req, res) => {
const { url } = req.body;
if (!url) return res.status(400).json({ error: 'url required' });
// Extract gist ID from URL
const match = url.match(/gist\.github\.com\/(?:[^/]+\/)?([a-f0-9]+)/);
if (!match) return res.status(400).json({ error: 'Invalid Gist URL' });
const gistId = match[1];
try {
const resp = await fetch(`https://api.github.com/gists/${gistId}`, {
headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'Fiddle-App' },
});
if (!resp.ok) throw new Error(`GitHub API returned ${resp.status}`);
const gist = await resp.json();
let html = '', css = '', js = '', mode = 'html-css-js', css_type = 'css';
const files = Object.values(gist.files);
for (const file of files) {
const ext = file.filename.split('.').pop().toLowerCase();
const content = file.content || '';
if (ext === 'html' || ext === 'htm') {
html = content;
} else if (ext === 'css') {
css = content;
} else if (ext === 'scss') {
css = content;
css_type = 'scss';
} else if (ext === 'less') {
css = content;
css_type = 'less';
} else if (ext === 'js' || ext === 'mjs') {
js = content;
} else if (ext === 'ts') {
js = content;
mode = 'typescript';
} else if (ext === 'jsx') {
js = content;
mode = 'react';
} else if (ext === 'tsx') {
js = content;
mode = 'react-ts';
} else if (ext === 'vue') {
js = content;
mode = 'vue';
} else if (ext === 'svelte') {
js = content;
mode = 'svelte';
} else if (ext === 'md' || ext === 'markdown') {
js = content;
mode = 'markdown';
} else if (ext === 'py') {
js = content;
mode = 'python';
}
}
res.json({ html, css, js, mode, css_type, title: gist.description || 'Imported Gist' });
} catch (e) {
res.status(502).json({ error: e.message });
}
});
// ===================== Publish API =====================
app.post('/api/fiddles/:id/publish', (req, res) => {
const fiddle = stmts.get.get(req.params.id);
if (!fiddle) return res.status(404).json({ error: 'Not found' });
// Check if already published
const existing = stmts.getPublishStatus.get(req.params.id);
if (existing && existing.published_slug) {
// Re-publish with updated HTML
const { html } = req.body;
if (html) stmts.publishFiddle.run({ id: req.params.id, slug: existing.published_slug, html });
return res.json({ slug: existing.published_slug, url: `/p/${existing.published_slug}` });
}
const slug = nanoid(8);
const { html } = req.body;
if (!html) return res.status(400).json({ error: 'html body required' });
stmts.publishFiddle.run({ id: req.params.id, slug, html });
res.json({ slug, url: `/p/${slug}` });
});
app.delete('/api/fiddles/:id/publish', (req, res) => {
const fiddle = stmts.get.get(req.params.id);
if (!fiddle) return res.status(404).json({ error: 'Not found' });
stmts.unpublishFiddle.run(req.params.id);
res.json({ ok: true });
});
app.get('/api/fiddles/:id/publish', (req, res) => {
const fiddle = stmts.get.get(req.params.id);
if (!fiddle) return res.status(404).json({ error: 'Not found' });
const status = stmts.getPublishStatus.get(req.params.id);
res.json({ published: !!(status && status.published_slug), slug: status?.published_slug || null });
});
// ===================== Version History API ===================== // ===================== Version History API =====================
app.get('/api/fiddles/:id/versions', (req, res) => { app.get('/api/fiddles/:id/versions', (req, res) => {
@@ -220,5 +330,39 @@ app.delete('/api/collections/:id/fiddles/:fid', (req, res) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
// ===================== Presentation Slides API =====================
app.get('/api/fiddles/:id/slides', (req, res) => {
const fiddle = stmts.get.get(req.params.id);
if (!fiddle) return res.status(404).json({ error: 'Not found' });
const slides = stmts.listSlides.all(req.params.id);
res.json({ slides });
});
app.post('/api/fiddles/:id/slides', (req, res) => {
const fiddle = stmts.get.get(req.params.id);
if (!fiddle) return res.status(404).json({ error: 'Not found' });
const id = nanoid(10);
const { html = '', css = '', js = '', notes = '' } = req.body;
const { max_order } = stmts.getMaxSlideOrder.get(req.params.id);
stmts.insertSlide.run({ id, fiddle_id: req.params.id, slide_order: max_order + 1, html, css, js, notes });
res.json({ id, fiddle_id: req.params.id, slide_order: max_order + 1, html, css, js, notes });
});
app.put('/api/slides/:slideId', (req, res) => {
const slide = stmts.getSlide.get(req.params.slideId);
if (!slide) return res.status(404).json({ error: 'Slide not found' });
const { html = slide.html, css = slide.css, js = slide.js, notes = slide.notes, slide_order = slide.slide_order } = req.body;
stmts.updateSlide.run({ id: req.params.slideId, html, css, js, notes, slide_order });
res.json({ ...slide, html, css, js, notes, slide_order });
});
app.delete('/api/slides/:slideId', (req, res) => {
const slide = stmts.getSlide.get(req.params.slideId);
if (!slide) return res.status(404).json({ error: 'Slide not found' });
stmts.deleteSlide.run(req.params.slideId);
res.json({ ok: true });
});
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`Fiddle server running on http://localhost:${port}`)); app.listen(port, () => console.log(`Fiddle server running on http://localhost:${port}`));