Compare commits

...

7 Commits

Author SHA1 Message Date
root
26e232fd41 Fix syntax coloring, modernize toolbar UI, and clean up CSS
- Fix Monarch tokenizer loading: await initLinter() before editor creation
  so loadScript() doesn't clobber window.define during lazy tokenizer init
- Fix JSX/TSX coloring: use file URIs with proper extensions (.jsx/.tsx)
  so Monaco enables JSX tokenization via the TypeScript language service
- Modernize toolbar: move settings to gear popover, replace text buttons
  with SVG icons, consolidate toggle checkboxes into compact group
- Clean up CSS: remove duplicate toggle classes, dead selectors, orphaned rules
2026-02-27 15:19:10 -06:00
root
0d84c56008 Add version history, screenshots, embed generator, collections, npm search, format-on-save, and custom fonts 2026-02-27 01:47:16 -06:00
root
6ca8519250 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
2026-02-27 01:22:16 -06:00
root
b18c9c1dc8 Add QoL features: preview theme, external resources, shortcuts, mobile layout
- Dark/light preview theme toggle with localStorage persistence and
  dark CSS injection in preview, export, and embed
- External CSS/JS resources modal with per-fiddle persistence in
  options column, injected as link/script tags
- Keyboard shortcuts cheat sheet modal (? button or ? key)
- Mobile-responsive CSS with breakpoints at 768px and 480px
  for both editor and browse pages
2026-02-26 15:39:16 -06:00
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
root
e41c3e7dc4 Add browse dashboard, tags, visibility control, export, QR sharing, and embed mode
- Browse dashboard at / with search, framework filter, tag pills, and pagination
- Tags system with autocomplete datalist and per-fiddle tag management
- Listed/unlisted toggle for visibility control (unlisted still accessible via direct URL)
- Export standalone HTML with inlined CSS/JS and framework CDN tags
- QR code modal for sharing fiddle URLs
- Embed mode at /embed/:id for minimal preview-only rendering
- Extract shared loadScript() utility from 4 files into utils.js
- Database schema: listed column, tags and fiddle_tags tables with index
2026-02-26 14:19:52 -06:00
root
7f51af17a3 Add editor experience features: auto-run, layouts, resizable panels, Emmet, vim/emacs
- Auto-run toggle with localStorage persistence (toolbar checkbox)
- Layout selector: default, top/bottom, editor-only, preview-only
- Resizable panels via drag dividers with double-click reset
- Emmet abbreviation expansion for HTML, CSS, and JSX
- Vim and Emacs keybinding modes with lazy-loaded CDN libraries
- Shared preferences module for localStorage management
- Editor hooks for tab switch and mode change callbacks
2026-02-26 11:19:14 -06:00
35 changed files with 4645 additions and 112 deletions

183
db.js
View File

@@ -26,18 +26,195 @@ try {
db.exec(`ALTER TABLE fiddles ADD COLUMN js_type TEXT NOT NULL DEFAULT 'javascript'`);
} catch (_) { /* column already exists */ }
// Migration: add listed column
try {
db.exec(`ALTER TABLE fiddles ADD COLUMN listed INTEGER NOT NULL DEFAULT 1`);
} catch (_) { /* column already exists */ }
// Migration: add options column (JSON string for per-fiddle settings like tailwind)
try {
db.exec(`ALTER TABLE fiddles ADD COLUMN options TEXT NOT NULL DEFAULT '{}'`);
} catch (_) { /* column already exists */ }
// Tags tables
db.exec(`
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE COLLATE NOCASE
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS fiddle_tags (
fiddle_id TEXT NOT NULL REFERENCES fiddles(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (fiddle_id, tag_id)
)
`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_fiddles_listed_updated ON fiddles(listed, updated_at DESC)`);
// Migration: add screenshot column
try {
db.exec(`ALTER TABLE fiddles ADD COLUMN screenshot TEXT`);
} catch (_) { /* column already exists */ }
// Version history table
db.exec(`
CREATE TABLE IF NOT EXISTS fiddle_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fiddle_id TEXT NOT NULL REFERENCES fiddles(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
html TEXT NOT NULL DEFAULT '',
css TEXT NOT NULL DEFAULT '',
css_type TEXT NOT NULL DEFAULT 'css',
js TEXT NOT NULL DEFAULT '',
js_type TEXT NOT NULL DEFAULT 'javascript',
options TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_versions_fiddle ON fiddle_versions(fiddle_id, version DESC)`);
// Collections tables
db.exec(`
CREATE TABLE IF NOT EXISTS collections (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS collection_fiddles (
collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
fiddle_id TEXT NOT NULL REFERENCES fiddles(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (collection_id, fiddle_id)
)
`);
export const stmts = {
insert: db.prepare(`
INSERT INTO fiddles (id, title, html, css, css_type, js, js_type)
VALUES (@id, @title, @html, @css, @css_type, @js, @js_type)
INSERT INTO fiddles (id, title, html, css, css_type, js, js_type, listed, options)
VALUES (@id, @title, @html, @css, @css_type, @js, @js_type, @listed, @options)
`),
get: db.prepare('SELECT * FROM fiddles WHERE id = ?'),
update: db.prepare(`
UPDATE fiddles SET title = @title, html = @html, css = @css,
css_type = @css_type, js = @js, js_type = @js_type, updated_at = datetime('now')
css_type = @css_type, js = @js, js_type = @js_type, listed = @listed,
options = @options, updated_at = datetime('now')
WHERE id = @id
`),
list: db.prepare('SELECT id, title, css_type, js_type, created_at, updated_at FROM fiddles ORDER BY updated_at DESC LIMIT 50'),
// Tags
getTagsForFiddle: db.prepare(`
SELECT t.id, t.name FROM tags t
JOIN fiddle_tags ft ON ft.tag_id = t.id
WHERE ft.fiddle_id = ?
`),
insertTag: db.prepare('INSERT OR IGNORE INTO tags (name) VALUES (?)'),
getTagByName: db.prepare('SELECT id, name FROM tags WHERE name = ? COLLATE NOCASE'),
insertFiddleTag: db.prepare('INSERT OR IGNORE INTO fiddle_tags (fiddle_id, tag_id) VALUES (?, ?)'),
deleteFiddleTags: db.prepare('DELETE FROM fiddle_tags WHERE fiddle_id = ?'),
listTags: db.prepare(`
SELECT t.id, t.name, COUNT(ft.fiddle_id) as count
FROM tags t
LEFT JOIN fiddle_tags ft ON ft.tag_id = t.id
GROUP BY t.id
HAVING count > 0
ORDER BY count DESC
`),
// Versions
insertVersion: db.prepare(`
INSERT INTO fiddle_versions (fiddle_id, version, html, css, css_type, js, js_type, options)
VALUES (@fiddle_id, @version, @html, @css, @css_type, @js, @js_type, @options)
`),
getMaxVersion: db.prepare('SELECT COALESCE(MAX(version), 0) as max_ver FROM fiddle_versions WHERE fiddle_id = ?'),
listVersions: db.prepare('SELECT id, version, created_at FROM fiddle_versions WHERE fiddle_id = ? ORDER BY version DESC'),
getVersion: db.prepare('SELECT * FROM fiddle_versions WHERE fiddle_id = ? AND version = ?'),
deleteOldVersions: db.prepare(`
DELETE FROM fiddle_versions WHERE fiddle_id = ? AND id NOT IN (
SELECT id FROM fiddle_versions WHERE fiddle_id = ? ORDER BY version DESC LIMIT 50
)
`),
// Screenshot
updateScreenshot: db.prepare('UPDATE fiddles SET screenshot = ? WHERE id = ?'),
// Collections
insertCollection: db.prepare(`
INSERT INTO collections (id, name, description) VALUES (@id, @name, @description)
`),
listCollections: db.prepare(`
SELECT c.*, COUNT(cf.fiddle_id) as fiddle_count
FROM collections c
LEFT JOIN collection_fiddles cf ON cf.collection_id = c.id
GROUP BY c.id
ORDER BY c.updated_at DESC
`),
getCollection: db.prepare('SELECT * FROM collections WHERE id = ?'),
updateCollection: db.prepare(`
UPDATE collections SET name = @name, description = @description, updated_at = datetime('now') WHERE id = @id
`),
deleteCollection: db.prepare('DELETE FROM collections WHERE id = ?'),
addFiddleToCollection: db.prepare(`
INSERT OR IGNORE INTO collection_fiddles (collection_id, fiddle_id, position)
VALUES (@collection_id, @fiddle_id, (SELECT COALESCE(MAX(position), 0) + 1 FROM collection_fiddles WHERE collection_id = @collection_id))
`),
removeFiddleFromCollection: db.prepare('DELETE FROM collection_fiddles WHERE collection_id = ? AND fiddle_id = ?'),
getCollectionFiddles: db.prepare(`
SELECT f.id, f.title, f.css_type, f.js_type, f.created_at, f.updated_at, f.screenshot,
SUBSTR(f.html, 1, 200) as html_preview, SUBSTR(f.js, 1, 200) as js_preview
FROM fiddles f
JOIN collection_fiddles cf ON cf.fiddle_id = f.id
WHERE cf.collection_id = ?
ORDER BY cf.position
`),
getCollectionsForFiddle: db.prepare(`
SELECT c.id, c.name FROM collections c
JOIN collection_fiddles cf ON cf.collection_id = c.id
WHERE cf.fiddle_id = ?
`),
};
/**
* Upsert tags for a fiddle. Accepts an array of tag name strings.
*/
export function setFiddleTags(fiddleId, tagNames) {
stmts.deleteFiddleTags.run(fiddleId);
for (const name of tagNames) {
const trimmed = name.trim();
if (!trimmed) continue;
stmts.insertTag.run(trimmed);
const tag = stmts.getTagByName.get(trimmed);
if (tag) stmts.insertFiddleTag.run(fiddleId, tag.id);
}
}
/**
* Snapshot current fiddle state as a version before overwriting.
*/
export function snapshotVersion(fiddleId) {
const fiddle = stmts.get.get(fiddleId);
if (!fiddle) return;
const { max_ver } = stmts.getMaxVersion.get(fiddleId);
const version = max_ver + 1;
stmts.insertVersion.run({
fiddle_id: fiddleId,
version,
html: fiddle.html,
css: fiddle.css,
css_type: fiddle.css_type,
js: fiddle.js,
js_type: fiddle.js_type,
options: fiddle.options || '{}',
});
// Cap at 50 versions
stmts.deleteOldVersions.run(fiddleId, fiddleId);
}
export default db;

57
public/browse.html Normal file
View File

@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fiddle - Browse</title>
<link rel="stylesheet" href="/css/browse.css">
</head>
<body>
<header class="browse-header">
<a href="/" class="logo">Fiddle</a>
<a href="/new" class="btn-new">+ New Fiddle</a>
</header>
<div class="browse-toolbar">
<input type="text" id="search-input" placeholder="Search fiddles..." spellcheck="false">
<select id="filter-framework">
<option value="">All Frameworks</option>
<option value="javascript">HTML/CSS/JS</option>
<option value="typescript">TypeScript</option>
<option value="react">React (JSX)</option>
<option value="react-ts">React + TS</option>
<option value="vue">Vue</option>
<option value="svelte">Svelte</option>
<option value="markdown">Markdown</option>
<option value="wasm">WASM</option>
</select>
<select id="filter-sort">
<option value="updated">Recently Updated</option>
<option value="created">Recently Created</option>
</select>
</div>
<div class="browse-tabs">
<button class="browse-tab active" data-view="fiddles">Fiddles</button>
<button class="browse-tab" data-view="collections">Collections</button>
</div>
<div id="fiddles-view">
<div id="tags-bar" class="tags-bar"></div>
<main id="fiddle-grid" class="fiddle-grid"></main>
<div id="pagination" class="pagination"></div>
</div>
<div id="collections-view" style="display:none">
<div id="collections-grid" class="collections-grid"></div>
<div id="collection-detail" style="display:none">
<button id="collection-back" class="collection-back-btn">&larr; Back to collections</button>
<div id="collection-header" class="collection-header"></div>
<div id="collection-desc" class="collection-desc"></div>
<div id="collection-fiddles" class="fiddle-grid" style="padding:0 24px 24px"></div>
</div>
</div>
<script type="module" src="/js/browse.js"></script>
</body>
</html>

258
public/css/browse.css Normal file
View File

@@ -0,0 +1,258 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #1e1e1e;
--surface: #252526;
--border: #3c3c3c;
--text: #cccccc;
--text-dim: #888;
--accent: #0078d4;
--accent-hover: #1a8ceb;
}
html, body {
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
}
/* Header */
.browse-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.logo {
font-weight: 700;
font-size: 18px;
color: var(--accent);
text-decoration: none;
}
.btn-new {
background: var(--accent);
color: #fff;
padding: 6px 16px;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
text-decoration: none;
transition: background 0.15s;
}
.btn-new:hover { background: var(--accent-hover); }
/* Toolbar */
.browse-toolbar {
display: flex;
gap: 8px;
padding: 12px 24px;
flex-wrap: wrap;
}
#search-input {
flex: 1;
min-width: 200px;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 12px;
border-radius: 4px;
font-size: 13px;
}
#search-input:focus { border-color: var(--accent); outline: none; }
#filter-framework, #filter-sort {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
padding: 6px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
/* Tags bar */
.tags-bar {
display: flex;
gap: 6px;
padding: 0 24px 12px;
flex-wrap: wrap;
}
.tag-filter {
display: inline-block;
background: var(--surface);
color: var(--text-dim);
border: 1px solid var(--border);
padding: 2px 10px;
border-radius: 12px;
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
text-decoration: none;
}
.tag-filter:hover { color: var(--text); border-color: var(--accent); }
.tag-filter.active { background: var(--accent); color: #fff; border-color: var(--accent); }
/* Grid */
.fiddle-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
padding: 0 24px 24px;
}
/* Cards */
.fiddle-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
text-decoration: none;
color: var(--text);
transition: border-color 0.15s, transform 0.1s;
display: flex;
flex-direction: column;
overflow: hidden;
}
.fiddle-card:hover {
border-color: var(--accent);
transform: translateY(-1px);
}
.card-thumbnail {
width: 100%; height: 160px; object-fit: cover;
background: #2a2a2a; display: block;
}
.card-body { padding: 12px 16px; display: flex; flex-direction: column; gap: 6px; }
.card-title {
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--text-dim);
}
.card-badge {
background: var(--border);
padding: 1px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
}
.card-preview {
font-family: 'Cascadia Code', 'Fira Code', monospace;
font-size: 11px;
color: var(--text-dim);
line-height: 1.4;
max-height: 3.6em;
overflow: hidden;
white-space: pre-wrap;
word-break: break-all;
}
.card-tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.card-tag {
background: var(--border);
color: var(--text-dim);
padding: 1px 6px;
border-radius: 8px;
font-size: 10px;
}
/* Pagination */
.pagination {
display: flex;
justify-content: center;
gap: 6px;
padding: 0 24px 24px;
}
.page-btn {
background: var(--surface);
color: var(--text-dim);
border: 1px solid var(--border);
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
text-decoration: none;
}
.page-btn:hover { color: var(--text); border-color: var(--accent); }
.page-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.page-btn:disabled { opacity: 0.4; cursor: default; }
/* Empty state */
.empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 60px 20px;
color: var(--text-dim);
font-size: 14px;
}
/* Collections */
.browse-tabs {
display: flex; gap: 0; padding: 0 24px; border-bottom: 1px solid var(--border);
margin-bottom: 12px;
}
.browse-tab {
background: transparent; color: var(--text-dim); border: none;
border-bottom: 2px solid transparent; padding: 8px 16px;
font-size: 13px; font-weight: 500; cursor: pointer;
}
.browse-tab:hover { color: var(--text); }
.browse-tab.active { color: var(--text); border-bottom-color: var(--accent); }
.collections-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 12px;
padding: 0 24px 24px;
}
.collection-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 16px;
text-decoration: none;
color: var(--text);
cursor: pointer;
transition: border-color 0.15s;
}
.collection-card:hover { border-color: var(--accent); }
.collection-card-name { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
.collection-card-desc { font-size: 12px; color: var(--text-dim); margin-bottom: 6px; }
.collection-card-count { font-size: 11px; color: var(--text-dim); }
.collection-back-btn {
background: var(--surface); color: var(--text-dim); border: 1px solid var(--border);
padding: 4px 12px; border-radius: 4px; font-size: 12px; cursor: pointer;
margin: 0 24px 12px;
}
.collection-back-btn:hover { color: var(--text); border-color: var(--accent); }
.collection-header {
padding: 0 24px 8px; font-size: 16px; font-weight: 600;
}
.collection-desc {
padding: 0 24px 12px; font-size: 12px; color: var(--text-dim);
}
/* Mobile responsive */
@media (max-width: 768px) {
.browse-header { padding: 10px 16px; }
.browse-toolbar { padding: 10px 16px; }
.tags-bar { padding: 0 16px 10px; }
.fiddle-grid { grid-template-columns: 1fr; padding: 0 16px 16px; gap: 12px; }
.pagination { padding: 0 16px 16px; }
}
@media (max-width: 480px) {
.fiddle-grid { grid-template-columns: 1fr; }
#search-input { min-width: 0; }
}

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);
@@ -47,12 +49,12 @@ button:hover { background: var(--accent-hover); }
}
.btn-small:hover { color: var(--text); background: var(--border); }
/* Grid layout — 2 columns: editor | preview+console */
/* Grid layout — 2 columns with dividers: editor | divider | preview+console */
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
height: calc(100vh - var(--toolbar-h));
grid-template-columns: 1fr 4px 1fr;
grid-template-rows: 1fr 4px 1fr;
flex: 1; min-height: 0;
}
.panel { position: relative; border: 1px solid var(--border); overflow: hidden; display: flex; flex-direction: column; }
.panel-label {
@@ -65,9 +67,39 @@ button:hover { background: var(--accent-hover); }
}
/* Editor panel — full left column */
.panel-editor { grid-column: 1; grid-row: 1 / 3; display: flex; flex-direction: column; }
.panel-preview { grid-column: 2; grid-row: 1; }
.panel-console { grid-column: 2; grid-row: 2; }
.panel-editor { grid-column: 1; grid-row: 1 / 4; display: flex; flex-direction: column; }
.divider-col { grid-column: 2; grid-row: 1 / 4; cursor: col-resize; background: var(--border); }
.divider-col:hover { background: var(--accent); }
.panel-preview { grid-column: 3; grid-row: 1; }
.divider-row { grid-column: 3; grid-row: 2; cursor: row-resize; background: var(--border); }
.divider-row:hover { background: var(--accent); }
.panel-console { grid-column: 3; grid-row: 3; }
/* Disable iframe pointer-events while dragging */
body.resizing iframe { pointer-events: none; }
/* Layout variants */
.layout-top-bottom .panel-editor { grid-column: 1 / 4; grid-row: 1; }
.layout-top-bottom .divider-col { display: none; }
.layout-top-bottom .panel-preview { grid-column: 1; grid-row: 3; }
.layout-top-bottom .divider-row { grid-column: 1 / 4; grid-row: 2; cursor: row-resize; }
.layout-top-bottom .panel-console { grid-column: 2 / 4; grid-row: 3; }
.layout-top-bottom {
grid-template-columns: 1fr 4px 1fr;
grid-template-rows: 1fr 4px 1fr;
}
.layout-editor-only .panel-editor { grid-column: 1 / 4; grid-row: 1 / 4; }
.layout-editor-only .divider-col,
.layout-editor-only .divider-row,
.layout-editor-only .panel-preview,
.layout-editor-only .panel-console { display: none; }
.layout-preview-only .panel-preview { grid-column: 1 / 4; grid-row: 1 / 4; }
.layout-preview-only .divider-col,
.layout-preview-only .divider-row,
.layout-preview-only .panel-editor,
.layout-preview-only .panel-console { display: none; }
/* Tab bar */
.tab-bar {
@@ -105,6 +137,24 @@ button:hover { background: var(--accent-hover); }
background: rgba(255,255,255,0.06);
}
/* Language color indicators on tabs */
.tab-color-dot {
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
}
.tab-btn.active .tab-color-dot { box-shadow: 0 0 4px currentColor; }
.tab-lang-html .tab-color-dot { background: #E44D26; }
.tab-lang-css .tab-color-dot { background: #264DE4; }
.tab-lang-javascript .tab-color-dot { background: #F7DF1E; }
.tab-lang-typescript .tab-color-dot { background: #3178C6; }
.tab-lang-markdown .tab-color-dot { background: #83B; }
/* Active tab border matches language color */
.tab-lang-html.active { border-bottom-color: #E44D26; }
.tab-lang-css.active { border-bottom-color: #264DE4; }
.tab-lang-javascript.active { border-bottom-color: #F7DF1E; }
.tab-lang-typescript.active { border-bottom-color: #3178C6; }
.tab-lang-markdown.active { border-bottom-color: #83B; }
/* CSS type selector inside tab bar */
.tab-css-type {
background: var(--bg);
@@ -125,7 +175,20 @@ button:hover { background: var(--accent-hover); }
}
.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 {
@@ -139,6 +202,154 @@ button:hover { background: var(--accent-hover); }
.console-info { color: #3dc9b0; }
.console-debug { color: #888; }
/* Vim status bar */
.vim-status-bar {
display: none;
height: 24px;
line-height: 24px;
padding: 0 10px;
font-family: 'Cascadia Code', 'Fira Code', monospace;
font-size: 12px;
background: var(--surface);
color: var(--text-dim);
border-top: 1px solid var(--border);
flex-shrink: 0;
}
/* Toolbar toggles (shared) */
.toolbar-toggles {
display: flex;
align-items: center;
gap: 2px;
}
.toolbar-toggle {
display: flex;
align-items: center;
gap: 3px;
font-size: 11px;
color: var(--text-dim);
cursor: pointer;
user-select: none;
padding: 3px 6px;
border-radius: 4px;
transition: background 0.15s, color 0.15s;
}
.toolbar-toggle:hover { background: rgba(255,255,255,0.06); color: var(--text); }
.toolbar-toggle:has(input:checked) { color: var(--accent); }
.toolbar-toggle input { display: none; }
.toolbar-toggle span { pointer-events: none; }
.toolbar-divider {
width: 1px;
height: 20px;
background: var(--border);
margin: 0 4px;
flex-shrink: 0;
}
/* Dividers */
.divider { background: var(--border); transition: background 0.15s; z-index: 2; }
/* Tags input */
.tags-input-wrap {
display: flex;
align-items: center;
gap: 4px;
max-width: 280px;
}
#tags-input {
background: transparent; border: 1px solid var(--border); color: var(--text);
padding: 3px 6px; border-radius: 4px; font-size: 12px; width: 100px;
}
#tags-input:focus { border-color: var(--accent); outline: none; }
.tags-display { display: flex; gap: 3px; flex-wrap: nowrap; overflow: hidden; }
.tag-pill {
display: inline-flex; align-items: center; gap: 2px;
background: var(--border); color: var(--text); padding: 1px 6px;
border-radius: 10px; font-size: 11px; white-space: nowrap;
}
.tag-pill .tag-remove {
cursor: pointer; color: var(--text-dim); font-size: 13px; line-height: 1;
background: none; border: none; padding: 0 0 0 2px;
}
.tag-pill .tag-remove:hover { color: #f44747; }
/* Secondary buttons */
.btn-secondary {
background: var(--surface); color: var(--text-dim); border: 1px solid var(--border);
}
.btn-secondary:hover { color: var(--text); background: var(--border); }
/* Icon buttons */
.btn-icon {
background: transparent;
color: var(--text-dim);
border: none;
padding: 5px 6px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s, color 0.15s;
}
.btn-icon:hover { color: var(--text); background: rgba(255,255,255,0.08); }
.btn-icon svg { display: block; }
/* Settings popover */
.settings-popover-wrap { position: relative; }
.settings-popover {
position: absolute;
top: calc(100% + 6px);
right: 0;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
min-width: 220px;
z-index: 100;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
display: flex;
flex-direction: column;
gap: 10px;
}
.settings-popover.hidden { display: none; }
.settings-group {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.settings-label {
font-size: 12px;
color: var(--text-dim);
white-space: nowrap;
font-weight: 500;
}
.settings-popover select {
background: var(--bg); color: var(--text); border: 1px solid var(--border);
padding: 3px 6px; border-radius: 4px; font-size: 11px; cursor: pointer;
min-width: 120px;
}
/* QR Modal */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
display: flex; align-items: center; justify-content: center; z-index: 1000;
}
.modal-overlay.hidden { display: none; }
.modal-content {
background: var(--surface); border: 1px solid var(--border);
border-radius: 8px; padding: 20px; min-width: 280px; text-align: center;
}
.modal-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 16px; font-size: 14px; font-weight: 600;
}
.modal-header .btn-small { font-size: 18px; padding: 0 4px; }
#qr-canvas { display: flex; justify-content: center; margin-bottom: 12px; }
#qr-canvas img, #qr-canvas canvas { border-radius: 4px; }
.qr-url { font-size: 11px; color: var(--text-dim); word-break: break-all; }
/* Toast */
.toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
@@ -146,3 +357,412 @@ button:hover { background: var(--accent-hover); }
font-size: 13px; z-index: 999; transition: opacity 0.3s;
}
.toast.hidden { opacity: 0; pointer-events: none; }
/* Resources modal */
.resource-inputs { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
.resource-row { display: flex; gap: 6px; align-items: center; }
.resource-row input {
flex: 1; background: var(--bg); border: 1px solid var(--border); color: var(--text);
padding: 5px 8px; border-radius: 4px; font-size: 12px;
}
.resource-row input:focus { border-color: var(--accent); outline: none; }
.resource-list { display: flex; flex-direction: column; gap: 4px; max-height: 200px; overflow-y: auto; }
.resource-item {
display: flex; align-items: center; justify-content: space-between; gap: 6px;
background: var(--bg); padding: 4px 8px; border-radius: 4px; font-size: 11px;
}
.resource-item .resource-type {
font-size: 9px; font-weight: 700; text-transform: uppercase;
padding: 1px 4px; border-radius: 2px; flex-shrink: 0;
}
.resource-item .resource-type.css { background: #264f78; color: #9cdcfe; }
.resource-item .resource-type.js { background: #4d3b00; color: #dcdcaa; }
.resource-item .resource-url { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
.resource-item .resource-remove { cursor: pointer; color: var(--text-dim); background: none; border: none; padding: 0 2px; font-size: 14px; }
.resource-item .resource-remove:hover { color: #f44747; }
/* Shortcuts table */
.shortcuts-table { width: 100%; border-collapse: collapse; text-align: left; }
.shortcuts-table td { padding: 6px 10px; font-size: 13px; border-bottom: 1px solid var(--border); }
.shortcuts-table td:first-child { white-space: nowrap; color: var(--text); }
.shortcuts-table td:last-child { color: var(--text-dim); }
.shortcuts-table kbd {
background: var(--bg); border: 1px solid var(--border); padding: 1px 6px;
border-radius: 3px; font-family: inherit; font-size: 12px;
}
.shortcuts-divider td { font-size: 11px; font-weight: 600; color: var(--text-dim); text-transform: uppercase; padding-top: 12px; border-bottom: none; }
/* Preview dark theme — set iframe bg to match */
#preview-frame.preview-dark { background: #1e1e1e; }
/* Mobile responsive */
@media (max-width: 768px) {
.toolbar { flex-wrap: wrap; height: auto; min-height: var(--toolbar-h); padding: 6px 8px; }
.toolbar-left, .toolbar-right { flex-wrap: wrap; }
.grid {
grid-template-columns: 1fr !important;
grid-template-rows: 1fr 1fr 120px !important;
}
.panel-editor { grid-column: 1 !important; grid-row: 1 !important; }
.panel-preview { grid-column: 1 !important; grid-row: 2 !important; }
.panel-console { grid-column: 1 !important; grid-row: 3 !important; }
.divider-col, .divider-row { display: none !important; }
/* Override all layout variants too */
.layout-top-bottom .panel-editor,
.layout-top-bottom .panel-preview,
.layout-top-bottom .panel-console { grid-column: 1 !important; }
}
@media (max-width: 480px) {
#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; }
}
/* ===================== Version History Modal ===================== */
.history-modal-content { min-width: 500px; max-width: 700px; text-align: left; }
.history-list {
max-height: 200px; overflow-y: auto;
border: 1px solid var(--border); border-radius: 4px;
margin-bottom: 12px;
}
.history-item {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 12px; cursor: pointer; border-bottom: 1px solid var(--border);
transition: background 0.1s;
}
.history-item:last-child { border-bottom: none; }
.history-item:hover { background: rgba(255,255,255,0.04); }
.history-item.active { background: rgba(0,120,212,0.15); }
.history-version {
font-weight: 600; font-size: 13px; color: var(--accent);
font-family: 'Cascadia Code', 'Fira Code', monospace;
}
.history-date { font-size: 11px; color: var(--text-dim); }
.history-preview-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 8px;
}
.history-preview-header span { font-size: 12px; color: var(--text-dim); }
.history-diff-section { margin-bottom: 8px; }
.history-diff-label {
font-size: 11px; font-weight: 600; text-transform: uppercase;
color: var(--accent); margin-bottom: 4px;
}
.history-diff-code {
background: var(--bg); border: 1px solid var(--border);
border-radius: 4px; padding: 8px; font-size: 11px;
font-family: 'Cascadia Code', 'Fira Code', monospace;
max-height: 150px; overflow-y: auto; white-space: pre-wrap;
word-break: break-all; color: var(--text-dim);
}
.history-diff { max-height: 300px; overflow-y: auto; }
/* ===================== Embed Modal ===================== */
.embed-modal-content { min-width: 480px; max-width: 600px; text-align: left; }
.embed-options {
display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 12px;
}
.embed-options label {
display: flex; align-items: center; gap: 4px;
font-size: 12px; color: var(--text-dim);
}
.embed-options select, .embed-options input {
background: var(--bg); color: var(--text); border: 1px solid var(--border);
padding: 3px 6px; border-radius: 4px; font-size: 12px;
}
.embed-code-wrap {
position: relative; margin-bottom: 12px;
}
.embed-code {
background: var(--bg); border: 1px solid var(--border);
border-radius: 4px; padding: 10px; font-size: 11px;
font-family: 'Cascadia Code', 'Fira Code', monospace;
white-space: pre-wrap; word-break: break-all; color: var(--text);
max-height: 80px; overflow-y: auto;
}
.embed-code-wrap .btn-small {
position: absolute; top: 6px; right: 6px;
background: var(--border); padding: 2px 8px;
}
.embed-live-preview { margin-top: 8px; }
/* ===================== Collection Modal ===================== */
.collection-new-row {
display: flex; gap: 6px; margin-bottom: 12px;
}
.collection-new-row input {
flex: 1; background: var(--bg); border: 1px solid var(--border); color: var(--text);
padding: 5px 8px; border-radius: 4px; font-size: 12px;
}
.collection-new-row input:focus { border-color: var(--accent); outline: none; }
.collection-list {
max-height: 250px; overflow-y: auto;
border: 1px solid var(--border); border-radius: 4px;
}
.collection-item {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; border-bottom: 1px solid var(--border);
}
.collection-item:last-child { border-bottom: none; }
.collection-name { flex: 1; font-size: 13px; font-weight: 500; }
.collection-count { font-size: 11px; color: var(--text-dim); }
.collection-add-btn { flex-shrink: 0; }
/* ===================== npm Search ===================== */
.npm-search-row { position: relative; }
#npm-search-input {
flex: 1; background: var(--bg); border: 1px solid var(--border); color: var(--text);
padding: 5px 8px; border-radius: 4px; font-size: 12px; width: 100%;
}
#npm-search-input:focus { border-color: var(--accent); outline: none; }
.npm-search-results {
position: absolute; top: 100%; left: 0; right: 0; z-index: 10;
background: var(--surface); border: 1px solid var(--border); border-radius: 4px;
max-height: 220px; overflow-y: auto; box-shadow: 0 4px 12px rgba(0,0,0,0.4);
}
.npm-search-results.hidden { display: none; }
.npm-result {
padding: 8px 10px; cursor: pointer; border-bottom: 1px solid var(--border);
}
.npm-result:last-child { border-bottom: none; }
.npm-result:hover { background: rgba(0,120,212,0.1); }
.npm-result-name { font-size: 12px; font-weight: 600; color: var(--text); }
.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-no-results { padding: 12px; text-align: center; color: var(--text-dim); font-size: 12px; }

23
public/embed.html Normal file
View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fiddle Embed</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body { background: #fff; }
body.dark { background: #1e1e1e; }
iframe { width: 100%; height: 100%; border: none; }
.embed-error {
display: flex; align-items: center; justify-content: center;
height: 100%; font-family: sans-serif; color: #888; font-size: 14px;
}
</style>
</head>
<body>
<iframe id="preview-frame" sandbox="allow-scripts allow-same-origin"></iframe>
<script type="module" src="/js/embed.js"></script>
</body>
</html>

View File

@@ -9,7 +9,7 @@
<body>
<header class="toolbar">
<div class="toolbar-left">
<a href="/" class="logo">Fiddle</a>
<a href="/" class="logo" title="Browse fiddles">Fiddle</a>
<select id="framework-mode">
<option value="html-css-js">HTML / CSS / JS</option>
<option value="typescript">TypeScript</option>
@@ -17,13 +17,119 @@
<option value="react-ts">React + TS</option>
<option value="vue">Vue</option>
<option value="svelte">Svelte</option>
<option value="markdown">Markdown</option>
<option value="wasm">WASM</option>
</select>
<input type="text" id="title-input" placeholder="Untitled" spellcheck="false">
<div class="tags-input-wrap">
<input type="text" id="tags-input" placeholder="Tags..." list="tags-datalist" spellcheck="false">
<datalist id="tags-datalist"></datalist>
<div id="tags-display" class="tags-display"></div>
</div>
</div>
<div class="toolbar-right">
<div class="toolbar-toggles">
<label class="toolbar-toggle" title="Enable Tailwind CSS">
<input type="checkbox" id="tailwind-checkbox">
<span>TW</span>
</label>
<label class="toolbar-toggle" title="Auto-run on change">
<input type="checkbox" id="auto-run-checkbox" checked>
<span>Auto</span>
</label>
<label class="toolbar-toggle" title="Auto-format on save">
<input type="checkbox" id="format-save-checkbox">
<span>Fmt</span>
</label>
<label class="toolbar-toggle" title="Show in browse page">
<input type="checkbox" id="listed-checkbox" checked>
<span>Listed</span>
</label>
</div>
<div class="toolbar-divider"></div>
<button id="btn-run" title="Run (Ctrl+Enter)">Run</button>
<button id="btn-save" title="Save (Ctrl+S)">Save</button>
<button id="btn-fork" title="Fork">Fork</button>
<button id="btn-fork" class="btn-secondary" title="Fork">Fork</button>
<div class="toolbar-divider"></div>
<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>
</button>
<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>
</button>
<button id="btn-history" class="btn-icon" title="Version history">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</button>
<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>
</button>
<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>
</button>
<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>
</button>
<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>
</button>
<div class="toolbar-divider"></div>
<div class="settings-popover-wrap">
<button id="btn-settings" class="btn-icon" title="Settings">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
<div id="settings-popover" class="settings-popover hidden">
<div class="settings-group">
<label class="settings-label">Layout</label>
<select id="layout-mode">
<option value="default">Default</option>
<option value="top-bottom">Top / Bottom</option>
<option value="editor-only">Editor Only</option>
<option value="preview-only">Preview Only</option>
</select>
</div>
<div class="settings-group">
<label class="settings-label">Theme</label>
<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="settings-group">
<label class="settings-label">Font</label>
<select id="editor-font" title="Editor font">
<option value="default">Default Font</option>
<option value="Fira Code">Fira Code</option>
<option value="JetBrains Mono">JetBrains Mono</option>
<option value="Source Code Pro">Source Code Pro</option>
<option value="IBM Plex Mono">IBM Plex Mono</option>
<option value="Cascadia Code">Cascadia Code</option>
</select>
</div>
<div class="settings-group">
<label class="settings-label">Keybindings</label>
<select id="keybinding-mode">
<option value="default">Default</option>
<option value="vim">Vim</option>
<option value="emacs">Emacs</option>
</select>
</div>
<div class="settings-group">
<label class="settings-label">Preview</label>
<select id="preview-theme" title="Preview background">
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
<div class="settings-group">
<label class="settings-label">Shortcuts</label>
<button id="btn-shortcuts" class="btn-small">View all</button>
</div>
</div>
</div>
</div>
</header>
@@ -31,17 +137,175 @@
<div class="panel panel-editor">
<div class="tab-bar" id="tab-bar"></div>
<div id="editor-area" class="editor-area"></div>
<div id="vim-status-bar" class="vim-status-bar"></div>
</div>
<div class="divider divider-col" id="divider-col"></div>
<div class="panel panel-preview">
<div class="panel-label">Preview</div>
<iframe id="preview-frame" sandbox="allow-scripts allow-same-origin"></iframe>
<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 id="console-output"></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">
<div class="modal-content" style="min-width:360px">
<div class="modal-header">
<span>External Resources</span>
<button id="resources-modal-close" class="btn-small">&times;</button>
</div>
<div class="resource-inputs">
<div class="resource-row">
<input type="text" id="resource-css-input" placeholder="CSS URL (e.g. https://fonts.googleapis.com/...)">
<button id="btn-add-css" class="btn-small">+ CSS</button>
</div>
<div class="resource-row">
<input type="text" id="resource-js-input" placeholder="JS URL (e.g. https://cdn.jsdelivr.net/...)">
<button id="btn-add-js" class="btn-small">+ JS</button>
</div>
<div class="resource-row npm-search-row">
<input type="text" id="npm-search-input" placeholder="Search npm packages..." autocomplete="off">
<div id="npm-search-results" class="npm-search-results hidden"></div>
</div>
</div>
<div id="resource-list" class="resource-list"></div>
</div>
</div>
<div id="shortcuts-modal" class="modal-overlay hidden">
<div class="modal-content" style="min-width:380px">
<div class="modal-header">
<span>Keyboard Shortcuts</span>
<button id="shortcuts-modal-close" class="btn-small">&times;</button>
</div>
<table class="shortcuts-table">
<tbody>
<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>
</tbody>
</table>
</div>
</div>
<div id="qr-modal" class="modal-overlay hidden">
<div class="modal-content">
<div class="modal-header">
<span>Share via QR Code</span>
<button id="qr-modal-close" class="btn-small">&times;</button>
</div>
<div id="qr-canvas"></div>
<div id="qr-url" class="qr-url"></div>
</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="history-modal" class="modal-overlay hidden">
<div class="modal-content history-modal-content">
<div class="modal-header">
<span>Version History</span>
<button id="history-modal-close" class="btn-small">&times;</button>
</div>
<div id="history-list" class="history-list"></div>
<div id="history-preview" class="history-preview hidden">
<div class="history-preview-header">
<span id="history-preview-label"></span>
<button id="history-restore-btn">Restore This Version</button>
</div>
<div id="history-diff" class="history-diff"></div>
</div>
</div>
</div>
<div id="embed-modal" class="modal-overlay hidden">
<div class="modal-content embed-modal-content">
<div class="modal-header">
<span>Embed Code</span>
<button id="embed-modal-close" class="btn-small">&times;</button>
</div>
<div class="embed-options">
<label>Theme <select id="embed-theme"><option value="light">Light</option><option value="dark">Dark</option></select></label>
<label>Show tabs <select id="embed-tabs"><option value="1">Yes</option><option value="0">No</option></select></label>
<label>Auto-run <select id="embed-autorun"><option value="1">Yes</option><option value="0">No</option></select></label>
<label>Width <input type="text" id="embed-width" value="100%" style="width:70px"></label>
<label>Height <input type="text" id="embed-height" value="400" style="width:70px"></label>
</div>
<div class="embed-code-wrap">
<pre id="embed-code" class="embed-code"></pre>
<button id="embed-copy" class="btn-small">Copy</button>
</div>
<div class="embed-live-preview">
<iframe id="embed-preview-frame" style="border:1px solid var(--border);border-radius:4px;width:100%;height:200px"></iframe>
</div>
</div>
</div>
<div id="collection-modal" class="modal-overlay hidden">
<div class="modal-content" style="min-width:340px">
<div class="modal-header">
<span>Add to Collection</span>
<button id="collection-modal-close" class="btn-small">&times;</button>
</div>
<div class="collection-new-row">
<input type="text" id="new-collection-name" placeholder="New collection name...">
<button id="btn-create-collection" class="btn-small">Create</button>
</div>
<div id="collection-list" class="collection-list"></div>
</div>
</div>
<div id="share-toast" class="toast hidden"></div>
<script>

View File

@@ -24,6 +24,62 @@ export function updateFiddle(id, data) {
return request(`${BASE}/${id}`, { method: 'PUT', body: JSON.stringify(data) });
}
export function listFiddles() {
return request(BASE);
export function listFiddles({ q, js_type, tag, page, limit, sort } = {}) {
const params = new URLSearchParams();
if (q) params.set('q', q);
if (js_type) params.set('js_type', js_type);
if (tag) params.set('tag', tag);
if (page) params.set('page', String(page));
if (limit) params.set('limit', String(limit));
if (sort) params.set('sort', sort);
const qs = params.toString();
return request(`${BASE}${qs ? '?' + qs : ''}`);
}
export function listTags() {
return request('/api/tags');
}
// Version history
export function listVersions(id) {
return request(`${BASE}/${id}/versions`);
}
export function getVersion(id, ver) {
return request(`${BASE}/${id}/versions/${ver}`);
}
export function revertVersion(id, ver) {
return request(`${BASE}/${id}/revert/${ver}`, { method: 'POST' });
}
// Collections
export function createCollection(data) {
return request('/api/collections', { method: 'POST', body: JSON.stringify(data) });
}
export function listCollections() {
return request('/api/collections');
}
export function getCollection(id) {
return request(`/api/collections/${id}`);
}
export function updateCollection(id, data) {
return request(`/api/collections/${id}`, { method: 'PUT', body: JSON.stringify(data) });
}
export function deleteCollection(id) {
return request(`/api/collections/${id}`, { method: 'DELETE' });
}
export function addToCollection(collectionId, fiddleId) {
return request(`/api/collections/${collectionId}/fiddles`, {
method: 'POST', body: JSON.stringify({ fiddle_id: fiddleId }),
});
}
export function removeFromCollection(collectionId, fiddleId) {
return request(`/api/collections/${collectionId}/fiddles/${fiddleId}`, { method: 'DELETE' });
}

View File

@@ -1,16 +1,41 @@
import {
initEditors, switchMode, getEditorValues, setEditorValues,
setOnChange, getCurrentMode, getCssType, setCssType,
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 } from './api.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);
@@ -39,8 +64,22 @@ const STARTER_TEMPLATES = {
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();
@@ -50,6 +89,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) {
@@ -58,15 +100,24 @@ async function run() {
});
}
renderPreview(html, compiledCss, result.js, mode, result.extraCss || '');
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);
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);
}
@@ -79,17 +130,38 @@ function showToast(msg) {
}
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 });
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 });
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}`);
}
@@ -98,13 +170,46 @@ async function save() {
}
}
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 });
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}`);
@@ -130,7 +235,20 @@ async function loadFromUrl() {
// 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}`);
@@ -155,16 +273,148 @@ function handleModeChange(newMode) {
scheduleRun();
}
function init() {
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 ALL CDN scripts before editor creation so window.define
// (Monaco's RequireJS) is never clobbered during Monarch tokenizer init
await initEmmet();
await initLinter();
// 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();
// 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);
@@ -180,6 +430,158 @@ function init() {
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'); });
// Settings popover toggle
const settingsPopover = $('#settings-popover');
$('#btn-settings').addEventListener('click', (e) => {
e.stopPropagation();
settingsPopover.classList.toggle('hidden');
});
document.addEventListener('click', (e) => {
if (!settingsPopover.classList.contains('hidden') && !e.target.closest('.settings-popover-wrap')) {
settingsPopover.classList.add('hidden');
}
});
// Load fiddle from URL if present
@@ -192,4 +594,223 @@ function init() {
});
}
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);
}
}
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">&times;</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ===================== 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();

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,
})),
};
},
});
}
}

199
public/js/browse.js Normal file
View File

@@ -0,0 +1,199 @@
import { listFiddles, listTags, listCollections, getCollection } from './api.js';
const $ = (sel) => document.querySelector(sel);
let debounceTimer = null;
let currentPage = 1;
let activeTag = '';
let currentView = 'fiddles';
const JS_TYPE_LABELS = {
javascript: 'JS',
typescript: 'TS',
react: 'React',
'react-ts': 'React+TS',
vue: 'Vue',
svelte: 'Svelte',
markdown: 'MD',
wasm: 'WASM',
};
function relativeTime(dateStr) {
const now = Date.now();
const then = new Date(dateStr + 'Z').getTime();
const diff = Math.floor((now - then) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 2592000) return `${Math.floor(diff / 86400)}d ago`;
return new Date(dateStr).toLocaleDateString();
}
function renderCards(fiddles, gridSelector = '#fiddle-grid') {
const grid = $(gridSelector);
if (!fiddles.length) {
grid.innerHTML = '<div class="empty-state">No fiddles found. <a href="/new" style="color: var(--accent)">Create one!</a></div>';
return;
}
grid.innerHTML = fiddles.map(f => {
const preview = f.js_preview || f.html_preview || '';
const badge = JS_TYPE_LABELS[f.js_type] || 'JS';
const tags = (f.tags || []).map(t => `<span class="card-tag">${esc(t.name)}</span>`).join('');
const thumb = f.screenshot
? `<img class="card-thumbnail" src="${f.screenshot}" alt="" loading="lazy">`
: (preview ? `<div class="card-preview" style="padding:12px 16px">${esc(preview)}</div>` : '');
return `
<a href="/f/${f.id}" class="fiddle-card">
${thumb}
<div class="card-body">
<div class="card-title">${esc(f.title)}</div>
<div class="card-meta">
<span class="card-badge">${badge}</span>
<span>${relativeTime(f.updated_at)}</span>
</div>
${tags ? `<div class="card-tags">${tags}</div>` : ''}
</div>
</a>
`;
}).join('');
}
function renderPagination(total, page, limit) {
const pages = Math.ceil(total / limit);
if (pages <= 1) { $('#pagination').innerHTML = ''; return; }
let html = '';
if (page > 1) html += `<button class="page-btn" data-page="${page - 1}">&laquo; Prev</button>`;
for (let i = 1; i <= pages; i++) {
if (pages > 7 && i > 2 && i < pages - 1 && Math.abs(i - page) > 1) {
if (i === 3 || i === pages - 2) html += '<span style="color:var(--text-dim)">...</span>';
continue;
}
html += `<button class="page-btn${i === page ? ' active' : ''}" data-page="${i}">${i}</button>`;
}
if (page < pages) html += `<button class="page-btn" data-page="${page + 1}">Next &raquo;</button>`;
const el = $('#pagination');
el.innerHTML = html;
el.querySelectorAll('.page-btn').forEach(btn => {
btn.addEventListener('click', () => {
currentPage = parseInt(btn.dataset.page, 10);
fetchAndRender();
});
});
}
async function renderTagsBar() {
try {
const { tags } = await listTags();
const bar = $('#tags-bar');
bar.innerHTML = tags.map(t =>
`<span class="tag-filter${activeTag === t.name ? ' active' : ''}" data-tag="${esc(t.name)}">${esc(t.name)} (${t.count})</span>`
).join('');
bar.querySelectorAll('.tag-filter').forEach(el => {
el.addEventListener('click', () => {
activeTag = activeTag === el.dataset.tag ? '' : el.dataset.tag;
currentPage = 1;
fetchAndRender();
renderTagsBar();
});
});
} catch (_) { /* ignore */ }
}
async function fetchAndRender() {
const q = $('#search-input').value.trim();
const js_type = $('#filter-framework').value;
const sort = $('#filter-sort').value;
try {
const { fiddles, total, page, limit } = await listFiddles({ q, js_type, tag: activeTag, page: currentPage, sort });
renderCards(fiddles);
renderPagination(total, page, limit);
} catch (e) {
$('#fiddle-grid').innerHTML = `<div class="empty-state">Error loading fiddles: ${esc(e.message)}</div>`;
}
}
function esc(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Event listeners
$('#search-input').addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
currentPage = 1;
fetchAndRender();
}, 300);
});
$('#filter-framework').addEventListener('change', () => {
currentPage = 1;
fetchAndRender();
});
$('#filter-sort').addEventListener('change', () => {
currentPage = 1;
fetchAndRender();
});
// Browse tabs
document.querySelectorAll('.browse-tab').forEach(tab => {
tab.addEventListener('click', () => {
currentView = tab.dataset.view;
document.querySelectorAll('.browse-tab').forEach(t => t.classList.toggle('active', t === tab));
$('#fiddles-view').style.display = currentView === 'fiddles' ? '' : 'none';
$('#collections-view').style.display = currentView === 'collections' ? '' : 'none';
if (currentView === 'collections') renderCollections();
});
});
async function renderCollections() {
const grid = $('#collections-grid');
const detail = $('#collection-detail');
grid.style.display = '';
detail.style.display = 'none';
try {
const { collections } = await listCollections();
if (!collections.length) {
grid.innerHTML = '<div class="empty-state">No collections yet</div>';
return;
}
grid.innerHTML = collections.map(c => `
<div class="collection-card" data-id="${c.id}">
<div class="collection-card-name">${esc(c.name)}</div>
<div class="collection-card-desc">${esc(c.description || '')}</div>
<div class="collection-card-count">${c.fiddle_count} fiddle${c.fiddle_count !== 1 ? 's' : ''}</div>
</div>
`).join('');
grid.querySelectorAll('.collection-card').forEach(card => {
card.addEventListener('click', () => showCollectionDetail(card.dataset.id));
});
} catch (e) {
grid.innerHTML = `<div class="empty-state">Error: ${esc(e.message)}</div>`;
}
}
async function showCollectionDetail(id) {
const grid = $('#collections-grid');
const detail = $('#collection-detail');
grid.style.display = 'none';
detail.style.display = '';
try {
const col = await getCollection(id);
$('#collection-header').textContent = col.name;
$('#collection-desc').textContent = col.description || '';
renderCards(col.fiddles || [], '#collection-fiddles');
} catch (e) {
$('#collection-header').textContent = 'Error';
$('#collection-desc').textContent = e.message;
}
}
$('#collection-back').addEventListener('click', () => renderCollections());
// Init
renderTagsBar();
fetchAndRender();

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,43 +1,77 @@
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 = {
'html-css-js': [
{ id: 'html', label: 'HTML', lang: 'html' },
{ id: 'css', label: 'CSS', lang: 'css' },
{ id: 'js', label: 'JavaScript', lang: 'javascript' },
{ id: 'html', label: 'HTML', lang: 'html', ext: 'html' },
{ id: 'css', label: 'CSS', lang: 'css', ext: 'css' },
{ id: 'js', label: 'JavaScript', lang: 'javascript', ext: 'js' },
],
'typescript': [
{ id: 'html', label: 'HTML', lang: 'html' },
{ id: 'css', label: 'CSS', lang: 'css' },
{ id: 'js', label: 'TypeScript', lang: 'typescript' },
{ id: 'html', label: 'HTML', lang: 'html', ext: 'html' },
{ id: 'css', label: 'CSS', lang: 'css', ext: 'css' },
{ id: 'js', label: 'TypeScript', lang: 'typescript', ext: 'ts' },
],
'react': [
{ id: 'js', label: 'JSX', lang: 'javascript' },
{ id: 'css', label: 'CSS', lang: 'css' },
{ id: 'html', label: 'HTML', lang: 'html' },
{ id: 'js', label: 'JSX', lang: 'javascript', ext: 'jsx' },
{ id: 'css', label: 'CSS', lang: 'css', ext: 'css' },
{ id: 'html', label: 'HTML', lang: 'html', ext: 'html' },
],
'react-ts': [
{ id: 'js', label: 'TSX', lang: 'typescript' },
{ id: 'css', label: 'CSS', lang: 'css' },
{ id: 'html', label: 'HTML', lang: 'html' },
{ id: 'js', label: 'TSX', lang: 'typescript', ext: 'tsx' },
{ id: 'css', label: 'CSS', lang: 'css', ext: 'css' },
{ id: 'html', label: 'HTML', lang: 'html', ext: 'html' },
],
'vue': [
{ id: 'js', label: 'Vue SFC', lang: 'html' },
{ id: 'css', label: 'CSS', lang: 'css' },
{ id: 'js', label: 'Vue SFC', lang: 'html', ext: 'vue' },
{ id: 'css', label: 'CSS', lang: 'css', ext: 'css' },
],
'svelte': [
{ id: 'js', label: 'Svelte', lang: 'html' },
{ id: 'css', label: 'CSS', lang: 'css' },
{ id: 'js', label: 'Svelte', lang: 'html', ext: 'svelte' },
{ id: 'css', label: 'CSS', lang: 'css', ext: 'css' },
],
'markdown': [
{ id: 'js', label: 'Markdown', lang: 'markdown', ext: 'md' },
{ id: 'css', label: 'CSS', lang: 'css', ext: 'css' },
],
'wasm': [
{ id: 'html', label: 'HTML', lang: 'html', ext: 'html' },
{ id: 'css', label: 'CSS', lang: 'css', ext: 'css' },
{ id: 'js', label: 'JavaScript', lang: 'javascript', ext: 'js' },
],
};
@@ -49,6 +83,8 @@ export const MODE_TO_JS_TYPE = {
'react-ts': 'tsx',
'vue': 'vue',
'svelte': 'svelte',
'markdown': 'markdown',
'wasm': 'wasm',
};
export const JS_TYPE_TO_MODE = Object.fromEntries(
@@ -61,6 +97,10 @@ let currentMode = 'html-css-js';
let activeTab = null;
let cssType = 'css';
let onChangeCallback = null;
let tabSwitchCallbacks = [];
let modeChangeCallbacks = [];
let onFormatCallback = null;
let onDiffCallback = null;
const tabBar = () => document.getElementById('tab-bar');
const editorArea = () => document.getElementById('editor-area');
@@ -69,6 +109,35 @@ export function setOnChange(cb) {
onChangeCallback = cb;
}
export function setOnTabSwitch(cb) {
tabSwitchCallbacks.push(cb);
}
export function setOnModeChange(cb) {
modeChangeCallbacks.push(cb);
}
export function setOnFormat(cb) {
onFormatCallback = cb;
}
export function setOnDiff(cb) {
onDiffCallback = cb;
}
export function getActiveEditor() {
if (activeTab && editors[activeTab]) return editors[activeTab];
return null;
}
export function relayoutEditors() {
const tabs = MODE_TABS[currentMode];
if (!tabs) return;
tabs.forEach((tab) => {
if (editors[tab.id]) editors[tab.id].layout();
});
}
export function getCurrentMode() {
return currentMode;
}
@@ -90,33 +159,40 @@ 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());
}
}
let modelCounter = 0;
function createEditor(tabDef) {
const container = document.createElement('div');
container.className = 'editor-container';
container.id = `editor-${tabDef.id}`;
editorArea().appendChild(container);
const uri = monaco.Uri.parse(`file:///fiddle-${++modelCounter}.${tabDef.ext}`);
const model = monaco.editor.createModel('', tabDef.lang, uri);
const editor = monaco.editor.create(container, {
...editorOpts,
language: tabDef.lang,
value: '',
model,
});
if (onChangeCallback) {
@@ -132,9 +208,16 @@ function renderTabBar(tabs) {
tabs.forEach((tab) => {
const btn = document.createElement('button');
btn.className = 'tab-btn';
btn.className = `tab-btn tab-lang-${tab.lang}`;
btn.dataset.tab = tab.id;
btn.textContent = tab.label;
const dot = document.createElement('span');
dot.className = 'tab-color-dot';
btn.appendChild(dot);
const label = document.createElement('span');
label.textContent = tab.label;
btn.appendChild(label);
// Add CSS type selector inside the CSS tab
if (tab.id === 'css') {
@@ -162,6 +245,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') {
@@ -187,11 +293,13 @@ export function initEditors(mode = 'html-css-js') {
export function switchMode(mode) {
if (mode === currentMode) return;
// Dispose existing editors
// Dispose existing editors and their models
const oldTabs = MODE_TABS[currentMode];
oldTabs.forEach((tab) => {
if (editors[tab.id]) {
const model = editors[tab.id].getModel();
editors[tab.id].dispose();
if (model) model.dispose();
}
const container = editors[`_container_${tab.id}`];
if (container && container.parentNode) {
@@ -213,6 +321,8 @@ export function switchMode(mode) {
});
switchTab(tabs[0].id);
modeChangeCallbacks.forEach(cb => cb(mode));
}
export function switchTab(tabId) {
@@ -237,6 +347,8 @@ export function switchTab(tabId) {
editors[tabId].layout();
editors[tabId].focus();
}
tabSwitchCallbacks.forEach(cb => cb(tabId, editors[tabId]));
}
export function getEditorValues() {
@@ -256,3 +368,18 @@ export function setEditorValues({ html = '', css = '', js = '' }) {
export function getActiveTab() {
return activeTab;
}
export function setEditorTheme(themeId) {
editorOpts.theme = themeId;
monaco.editor.setTheme(themeId);
}
export function setEditorFont(fontFamily) {
const family = fontFamily === 'default' ? "'Cascadia Code', 'Fira Code', monospace" : `'${fontFamily}', monospace`;
editorOpts.fontFamily = family;
const tabs = MODE_TABS[currentMode];
if (!tabs) return;
tabs.forEach((tab) => {
if (editors[tab.id]) editors[tab.id].updateOptions({ fontFamily: family });
});
}

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);
});
}

121
public/js/embed.js Normal file
View File

@@ -0,0 +1,121 @@
import { loadFiddle } from './api.js';
import { getFrameworkRuntime } from './js-preprocessors.js';
import { extractBareImports, buildImportMapTag } from './import-map.js';
const MODE_MAP = {
javascript: 'html-css-js',
typescript: 'typescript',
react: 'react',
'react-ts': 'react-ts',
vue: 'vue',
svelte: 'svelte',
markdown: 'markdown',
wasm: 'wasm',
};
async function init() {
// Extract fiddle ID from URL: /embed/:id
const match = location.pathname.match(/^\/embed\/([a-zA-Z0-9_-]+)$/);
if (!match) {
document.body.innerHTML = '<div class="embed-error">Invalid embed URL</div>';
return;
}
// URL params
const params = new URLSearchParams(location.search);
if (params.get('theme') === 'dark') document.body.classList.add('dark');
if (params.get('tabs') === '0') document.body.classList.add('no-tabs');
const autoRun = params.get('run') !== '0';
try {
const fiddle = await loadFiddle(match[1]);
const mode = MODE_MAP[fiddle.js_type] || 'html-css-js';
const runtime = getFrameworkRuntime(mode);
const opts = JSON.parse(fiddle.options || '{}');
// For embed, we compile client-side using the same preprocessors
const { compileCss } = await import('./preprocessors.js');
const { compileJs } = await import('./js-preprocessors.js');
const compiledCss = await compileCss(fiddle.css, fiddle.css_type || 'css');
const result = await compileJs(fiddle.js, mode);
const allCss = result.extraCss ? `${compiledCss}\n${result.extraCss}` : compiledCss;
const finalHtml = result.renderedHtml || fiddle.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';
// Build importmap for module scripts with bare imports
let importMapTag = '';
if (isModule && finalJs) {
const bareImports = extractBareImports(finalJs);
if (bareImports.length) {
importMapTag = buildImportMapTag(bareImports);
}
}
let scripts = '';
if (finalJs) {
if (isModule) {
scripts = `<script type="module">\n${escapeScriptClose(finalJs)}\n<\/script>`;
} else {
for (const url of runtime.scripts) {
scripts += `<script src="${url}"><\/script>\n`;
}
scripts += `<script>\n${escapeScriptClose(finalJs)}\n<\/script>`;
}
}
const tailwindScript = opts.tailwind
? `<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
const previewTheme = params.get('theme') || opts.previewTheme || 'light';
const darkCss = previewTheme === 'dark'
? `<style>body { background: #1e1e1e; color: #ccc; }</style>\n`
: '';
// External resources
let resourceTags = '';
const resources = opts.resources || [];
for (const r of resources) {
if (r.type === 'css') resourceTags += `<link rel="stylesheet" href="${r.url}">\n`;
else if (r.type === 'js') resourceTags += `<script src="${r.url}"><\/script>\n`;
}
const doc = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
${darkCss}${resourceTags}${tailwindScript}<style>${allCss}</style>
${importMapTag}
</head>
<body>
${bodyContent}
${scripts}
</body>
</html>`;
document.getElementById('preview-frame').srcdoc = doc;
} catch (e) {
document.body.innerHTML = `<div class="embed-error">Failed to load fiddle: ${e.message}</div>`;
}
}
function escapeScriptClose(code) {
return code.replace(/<\/script/gi, '<\\/script');
}
init();

18
public/js/emmet.js Normal file
View File

@@ -0,0 +1,18 @@
import { loadScript } from './utils.js';
let loaded = false;
export async function initEmmet() {
if (loaded) return;
try {
await loadScript('https://unpkg.com/emmet-monaco-es/dist/emmet-monaco.min.js');
if (window.emmetMonaco) {
emmetMonaco.emmetHTML(monaco);
emmetMonaco.emmetCSS(monaco);
emmetMonaco.emmetJSX(monaco);
loaded = true;
}
} catch (e) {
console.warn('Emmet load failed:', e.message);
}
}

90
public/js/export.js Normal file
View File

@@ -0,0 +1,90 @@
import { getFrameworkRuntime } from './js-preprocessors.js';
import { extractBareImports, buildImportMapTag } from './import-map.js';
/**
* Export a fiddle as a standalone HTML file and trigger download.
*/
export function exportHtml({ title, html, css, js, mode, extraCss = '', isModule = false, tailwind = false, renderedHtml = null, previewTheme = 'light', resources = [] }) {
const runtime = getFrameworkRuntime(mode);
const allCss = extraCss ? `${css}\n${extraCss}` : css;
const finalHtml = renderedHtml || html;
const finalJs = renderedHtml ? '' : 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 effectiveIsModule = isModule || mode === 'svelte';
// Build importmap for module scripts with bare imports
let importMapTag = '';
if (effectiveIsModule && finalJs) {
const bareImports = extractBareImports(finalJs);
if (bareImports.length) {
importMapTag = buildImportMapTag(bareImports);
}
}
let scripts = '';
if (finalJs) {
if (effectiveIsModule) {
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 tailwindScript = tailwind
? `<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'
? `<style>body { background: #1e1e1e; color: #ccc; }</style>\n`
: '';
let resourceTags = '';
if (resources && resources.length) {
for (const r of resources) {
if (r.type === 'css') resourceTags += `<link rel="stylesheet" href="${r.url}">\n`;
else if (r.type === 'js') resourceTags += `<script src="${r.url}"><\/script>\n`;
}
}
const doc = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escHtml(title)}</title>
${darkCss}${resourceTags}${tailwindScript}<style>
${allCss}
</style>
${importMapTag}
</head>
<body>
${bodyContent}
${scripts}
</body>
</html>`;
const blob = new Blob([doc], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title.replace(/[^a-zA-Z0-9_-]/g, '_')}.html`;
a.click();
URL.revokeObjectURL(url);
}
function escHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

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

@@ -0,0 +1,109 @@
import { loadScript } from './utils.js';
import { getActiveEditor, getActiveTab, getCurrentMode, getCssType, switchTab, getEditorValues } 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);
}
}
export async function formatAll() {
await ensurePrettier();
const mode = getCurrentMode();
const cssType = getCssType();
const tabIds = ['html', 'css', 'js'];
for (const tabId of tabIds) {
const config = getParser(tabId, mode, cssType);
if (!config) continue;
// Temporarily switch to this tab to get its editor
switchTab(tabId);
const editor = getActiveEditor();
if (!editor) continue;
const code = editor.getValue();
if (!code.trim()) continue;
try {
const formatted = await prettier.format(code, {
parser: config.parser,
plugins: config.plugins,
singleQuote: true,
trailingComma: 'all',
printWidth: 100,
});
editor.setValue(formatted);
} catch (_) { /* skip tabs that fail */ }
}
}

43
public/js/import-map.js Normal file
View File

@@ -0,0 +1,43 @@
/**
* Extract bare import specifiers from JS code.
* Matches `from 'pkg'`, `from "pkg"`, `import 'pkg'`, `import "pkg"`
* where the specifier doesn't start with `.`, `/`, `http:`, or `https:`.
*/
export function extractBareImports(jsCode) {
const imports = new Set();
const re = /(?:import\s+(?:[\s\S]*?\s+from\s+)?['"])([^'"\s][^'"]*?)(?=['"])/g;
let match;
while ((match = re.exec(jsCode)) !== null) {
const spec = match[1];
// Skip relative, absolute, and URL imports
if (spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('http:') || spec.startsWith('https:')) {
continue;
}
// Skip svelte internal imports (already rewritten by the compiler)
if (spec.startsWith('svelte')) continue;
imports.add(spec);
}
return Array.from(imports);
}
/**
* Build an HTML <script type="importmap"> tag from bare import specifiers.
* Maps each bare import to its esm.sh URL.
*/
export function buildImportMapTag(bareImports) {
if (!bareImports.length) return '';
const imports = {};
for (const spec of bareImports) {
// Get the package name (handle scoped packages like @scope/pkg)
const parts = spec.split('/');
const pkgName = spec.startsWith('@') ? `${parts[0]}/${parts[1]}` : parts[0];
// Map the full specifier (e.g. 'lodash/fp' -> 'https://esm.sh/lodash/fp')
imports[spec] = `https://esm.sh/${spec}`;
// Also map the base package if it's different
if (spec !== pkgName) {
imports[pkgName] = `https://esm.sh/${pkgName}`;
}
}
const map = JSON.stringify({ imports }, null, 2);
return `<script type="importmap">\n${map}\n<\/script>`;
}

View File

@@ -1,21 +1,9 @@
import { loadScript } from './utils.js';
let tsLoaded = false;
let babelLoaded = false;
let svelteLoaded = false;
function loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; }
// Temporarily hide AMD define so UMD scripts register as globals
// instead of AMD modules (Monaco's RequireJS sets window.define)
const savedDefine = window.define;
window.define = undefined;
const s = document.createElement('script');
s.src = src;
s.onload = () => { window.define = savedDefine; resolve(); };
s.onerror = () => { window.define = savedDefine; reject(new Error(`Failed to load ${src}`)); };
document.head.appendChild(s);
});
}
let markedLoaded = false;
async function ensureTypeScript() {
if (tsLoaded) return;
@@ -44,6 +32,19 @@ async function ensureBabel() {
babelLoaded = true;
}
async function ensureMarked() {
if (markedLoaded) return;
await loadScript('https://cdnjs.cloudflare.com/ajax/libs/marked/15.0.7/marked.min.js');
if (typeof marked === 'undefined') {
await new Promise((resolve) => {
const check = setInterval(() => {
if (typeof marked !== 'undefined') { clearInterval(check); resolve(); }
}, 50);
});
}
markedLoaded = true;
}
async function ensureSvelte() {
if (svelteLoaded) return;
await loadScript('https://unpkg.com/svelte@4.2.19/compiler.cjs');
@@ -58,8 +59,10 @@ export async function compileJs(code, mode) {
if (!code.trim()) return { js: '' };
switch (mode) {
case 'html-css-js':
return { js: code };
case 'html-css-js': {
const isModule = /(?:^|\n)\s*(?:import\s|export\s)/m.test(code);
return { js: code, isModule };
}
case 'typescript':
return compileTypeScript(code);
@@ -76,6 +79,16 @@ export async function compileJs(code, mode) {
case 'svelte':
return compileSvelte(code);
case 'markdown':
return compileMarkdown(code);
case 'wasm': {
// WASM starter uses top-level await, always treat as module
const hasModuleSyntax = /(?:^|\n)\s*(?:import\s|export\s)/m.test(code);
const hasTopLevelAwait = /(?:^|\n)\s*(?:const|let|var)\s+.*=\s*await\b/m.test(code) || /(?:^|\n)\s*await\s/m.test(code);
return { js: code, isModule: hasModuleSyntax || hasTopLevelAwait };
}
default:
return { js: code };
}
@@ -138,6 +151,12 @@ function compileVue(code) {
return { js, extraCss };
}
async function compileMarkdown(code) {
await ensureMarked();
const renderedHtml = marked.parse(code);
return { js: '', renderedHtml };
}
async function compileSvelte(code) {
await ensureSvelte();
const result = svelte.compile(code, {
@@ -198,6 +217,12 @@ export function getFrameworkRuntime(mode) {
bodyHtml: '<div id="app"></div>',
};
case 'markdown':
return { scripts: [], bodyHtml: '' };
case 'wasm':
return { scripts: [], bodyHtml: '' };
default:
return { scripts: [], bodyHtml: '' };
}

97
public/js/keybindings.js Normal file
View File

@@ -0,0 +1,97 @@
import { getPref, setPref } from './preferences.js';
import { getActiveEditor, setOnTabSwitch, setOnModeChange } from './editors.js';
import { loadScript } from './utils.js';
let currentMode = 'default'; // 'default' | 'vim' | 'emacs'
let activeAdapter = null; // vim or emacs adapter instance
let vimLoaded = false;
let emacsLoaded = false;
const statusBar = () => document.getElementById('vim-status-bar');
async function ensureVim() {
if (vimLoaded) return;
window.monaco = monaco;
await loadScript('https://unpkg.com/monaco-vim@0.4.2/dist/monaco-vim.js');
vimLoaded = true;
}
async function ensureEmacs() {
if (emacsLoaded) return;
window.monaco = monaco;
await loadScript('https://unpkg.com/monaco-emacs/dist/monaco-emacs.js');
emacsLoaded = true;
}
function disposeAdapter() {
if (activeAdapter) {
activeAdapter.dispose();
activeAdapter = null;
}
const bar = statusBar();
if (bar) {
bar.style.display = 'none';
bar.textContent = '';
}
}
async function attachToEditor(editor) {
if (!editor) return;
disposeAdapter();
if (currentMode === 'vim') {
await ensureVim();
const bar = statusBar();
if (bar) bar.style.display = 'block';
activeAdapter = MonacoVim.initVimMode(editor, bar);
} else if (currentMode === 'emacs') {
await ensureEmacs();
const EmacsExtension = MonacoEmacs.EmacsExtension;
activeAdapter = new EmacsExtension(editor);
activeAdapter.start();
}
}
export async function setKeybindingMode(mode) {
disposeAdapter();
currentMode = mode;
setPref('keybindings', mode);
if (mode === 'default') return;
const editor = getActiveEditor();
if (editor) await attachToEditor(editor);
}
export function initKeybindings() {
currentMode = getPref('keybindings') || 'default';
const select = document.getElementById('keybinding-mode');
if (select) {
select.value = currentMode;
select.addEventListener('change', (e) => setKeybindingMode(e.target.value));
}
// Reattach on tab switch
setOnTabSwitch((_tabId, editor) => {
if (currentMode !== 'default' && editor) {
attachToEditor(editor);
}
});
// Reattach on mode change
setOnModeChange(() => {
if (currentMode !== 'default') {
setTimeout(() => {
const editor = getActiveEditor();
if (editor) attachToEditor(editor);
}, 50);
}
});
// Initial attach if non-default
if (currentMode !== 'default') {
const editor = getActiveEditor();
if (editor) attachToEditor(editor);
}
}

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();
}
});
}

74
public/js/npm-search.js Normal file
View File

@@ -0,0 +1,74 @@
let debounceTimer = null;
export function initNpmSearch(onSelect) {
const input = document.getElementById('npm-search-input');
const results = document.getElementById('npm-search-results');
if (!input || !results) return;
input.addEventListener('input', () => {
clearTimeout(debounceTimer);
const q = input.value.trim();
if (q.length < 2) {
results.classList.add('hidden');
results.innerHTML = '';
return;
}
debounceTimer = setTimeout(() => searchNpm(q, results, input, onSelect), 300);
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
results.classList.add('hidden');
}
});
document.addEventListener('click', (e) => {
if (!input.contains(e.target) && !results.contains(e.target)) {
results.classList.add('hidden');
}
});
}
async function searchNpm(query, container, input, onSelect) {
try {
const res = await fetch(`https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(query)}&size=8`);
if (!res.ok) return;
const data = await res.json();
const packages = data.objects || [];
if (!packages.length) {
container.innerHTML = '<div class="npm-no-results">No packages found</div>';
container.classList.remove('hidden');
return;
}
container.innerHTML = packages.map(p => {
const pkg = p.package;
const desc = (pkg.description || '').slice(0, 80);
return `<div class="npm-result" data-name="${esc(pkg.name)}" data-version="${esc(pkg.version)}">
<div class="npm-result-name">${esc(pkg.name)} <span class="npm-result-version">${esc(pkg.version)}</span></div>
<div class="npm-result-desc">${esc(desc)}</div>
</div>`;
}).join('');
container.querySelectorAll('.npm-result').forEach(el => {
el.addEventListener('click', () => {
const name = el.dataset.name;
const url = `https://esm.sh/${name}`;
onSelect({ name, url, type: 'js' });
input.value = '';
container.classList.add('hidden');
});
});
container.classList.remove('hidden');
} catch (_) {
container.classList.add('hidden');
}
}
function esc(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}

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);
});
}

27
public/js/preferences.js Normal file
View File

@@ -0,0 +1,27 @@
const PREFIX = 'fiddle_';
const DEFAULTS = {
autoRun: true,
layout: 'default',
keybindings: 'default',
panelSizes: null,
previewTheme: 'light',
previewDevice: 'desktop',
editorTheme: 'vs-dark',
formatOnSave: false,
editorFont: 'default',
};
export function getPref(key) {
const raw = localStorage.getItem(PREFIX + key);
if (raw === null) return DEFAULTS[key] ?? null;
try { return JSON.parse(raw); } catch { return raw; }
}
export function setPref(key, value) {
localStorage.setItem(PREFIX + key, JSON.stringify(value));
}
export function removePref(key) {
localStorage.removeItem(PREFIX + key);
}

View File

@@ -1,19 +1,8 @@
import { loadScript } from './utils.js';
let sassLoaded = false;
let lessLoaded = false;
function loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; }
const savedDefine = window.define;
window.define = undefined;
const s = document.createElement('script');
s.src = src;
s.onload = () => { window.define = savedDefine; resolve(); };
s.onerror = () => { window.define = savedDefine; reject(new Error(`Failed to load ${src}`)); };
document.head.appendChild(s);
});
}
async function ensureSass() {
if (sassLoaded) return;
await loadScript('https://cdnjs.cloudflare.com/ajax/libs/sass.js/0.11.1/sass.sync.min.js');

View File

@@ -1,4 +1,5 @@
import { getFrameworkRuntime } from './js-preprocessors.js';
import { extractBareImports, buildImportMapTag } from './import-map.js';
const consoleInterceptor = `
<script>
@@ -27,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>
`;
@@ -44,51 +109,97 @@ 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;
}
export function renderPreview(html, css, js, mode = 'html-css-js', extraCss = '') {
/**
* Render compiled code into the preview iframe.
* @param {string} html - HTML content
* @param {string} css - Compiled CSS
* @param {string} js - Compiled JS
* @param {string} mode - Framework mode
* @param {string} extraCss - Extra CSS (e.g. from Vue/Svelte)
* @param {object} options - { tailwind, isModule, renderedHtml }
*/
export function renderPreview(html, css, js, mode = 'html-css-js', extraCss = '', options = {}) {
const frame = document.getElementById('preview-frame');
const runtime = getFrameworkRuntime(mode);
// Combine CSS
const allCss = extraCss ? `${css}\n${extraCss}` : css;
// If renderedHtml is provided (e.g. Markdown), use it as the body and skip JS
const finalHtml = options.renderedHtml || html;
const finalJs = options.renderedHtml ? '' : js;
// Determine body content
let bodyContent;
if (mode === 'vue' || mode === 'svelte') {
bodyContent = html ? `${html}\n${runtime.bodyHtml}` : runtime.bodyHtml;
bodyContent = finalHtml ? `${finalHtml}\n${runtime.bodyHtml}` : runtime.bodyHtml;
} else if (runtime.bodyHtml) {
bodyContent = `${html}\n${runtime.bodyHtml}`;
bodyContent = `${finalHtml}\n${runtime.bodyHtml}`;
} else {
bodyContent = html;
bodyContent = finalHtml;
}
const isModule = mode === 'svelte';
const loaderScript = js
? buildLoaderScript(runtime.scripts, js, isModule)
const isModule = options.isModule || mode === 'svelte';
// Build importmap for module scripts with bare imports
let importMapTag = '';
if (isModule && finalJs) {
const bareImports = extractBareImports(finalJs);
if (bareImports.length) {
importMapTag = buildImportMapTag(bareImports);
}
}
const loaderScript = finalJs
? buildLoaderScript(runtime.scripts, finalJs, isModule)
: '';
// Tailwind CDN injection (suppress production warning)
const tailwindScript = options.tailwind
? `<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
const darkCss = options.previewTheme === 'dark'
? `<style>body { background: #1e1e1e; color: #ccc; }</style>\n`
: '';
// External resources
let resourceTags = '';
if (options.resources && options.resources.length) {
for (const r of options.resources) {
if (r.type === 'css') resourceTags += `<link rel="stylesheet" href="${r.url}">\n`;
else if (r.type === 'js') resourceTags += `<script src="${r.url}"><\/script>\n`;
}
}
const doc = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
${consoleInterceptor}
<style>${allCss}</style>
${darkCss}${resourceTags}${tailwindScript}<style>${allCss}</style>
${importMapTag}
</head>
<body>
${bodyContent}
${loaderScript}
</body>
</html>`;
// Update iframe bg class
frame.classList.toggle('preview-dark', options.previewTheme === 'dark');
frame.srcdoc = doc;
}

42
public/js/qr.js Normal file
View File

@@ -0,0 +1,42 @@
import { loadScript } from './utils.js';
let qrLoaded = false;
async function ensureQrLib() {
if (qrLoaded) return;
await loadScript('https://cdnjs.cloudflare.com/ajax/libs/qrcode-generator/1.4.4/qrcode.min.js');
qrLoaded = true;
}
/**
* Show a QR code modal for the given URL.
*/
export async function showQrModal(url) {
await ensureQrLib();
const modal = document.getElementById('qr-modal');
const canvas = document.getElementById('qr-canvas');
const urlDisplay = document.getElementById('qr-url');
// Generate QR
const qr = qrcode(0, 'M');
qr.addData(url);
qr.make();
canvas.innerHTML = qr.createSvgTag({ cellSize: 4, margin: 4 });
// Make QR SVG white on dark background
const svg = canvas.querySelector('svg');
if (svg) {
svg.style.background = '#fff';
svg.style.borderRadius = '4px';
svg.style.padding = '8px';
}
urlDisplay.textContent = url;
modal.classList.remove('hidden');
// Close handlers
const close = () => modal.classList.add('hidden');
document.getElementById('qr-modal-close').onclick = close;
modal.onclick = (e) => { if (e.target === modal) close(); };
}

91
public/js/resizer.js Normal file
View File

@@ -0,0 +1,91 @@
import { getPref, setPref } from './preferences.js';
import { relayoutEditors } from './editors.js';
let grid = null;
let colDivider = null;
let rowDivider = null;
let dragging = null; // 'col' | 'row' | null
export function initResizer() {
grid = document.querySelector('.grid');
colDivider = document.getElementById('divider-col');
rowDivider = document.getElementById('divider-row');
if (!grid || !colDivider || !rowDivider) return;
// Restore saved sizes
const saved = getPref('panelSizes');
if (saved) applySizes(saved);
// Column divider (horizontal drag)
colDivider.addEventListener('mousedown', (e) => startDrag(e, 'col'));
colDivider.addEventListener('dblclick', () => resetSizes());
// Row divider (vertical drag)
rowDivider.addEventListener('mousedown', (e) => startDrag(e, 'row'));
rowDivider.addEventListener('dblclick', () => resetSizes());
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
function startDrag(e, type) {
e.preventDefault();
dragging = type;
document.body.classList.add('resizing');
}
function onMouseMove(e) {
if (!dragging) return;
const rect = grid.getBoundingClientRect();
if (dragging === 'col') {
const x = e.clientX - rect.left;
const total = rect.width;
const pct = Math.max(0.15, Math.min(0.85, x / total));
const left = pct;
const right = 1 - pct;
grid.style.gridTemplateColumns = `${left}fr 4px ${right}fr`;
} else if (dragging === 'row') {
const y = e.clientY - rect.top;
const total = rect.height;
const pct = Math.max(0.15, Math.min(0.85, y / total));
const top = pct;
const bottom = 1 - pct;
grid.style.gridTemplateRows = `${top}fr 4px ${bottom}fr`;
}
relayoutEditors();
}
function onMouseUp() {
if (!dragging) return;
dragging = null;
document.body.classList.remove('resizing');
// Persist current sizes
const sizes = {
cols: grid.style.gridTemplateColumns || null,
rows: grid.style.gridTemplateRows || null,
};
setPref('panelSizes', sizes);
relayoutEditors();
}
function applySizes(sizes) {
if (sizes.cols) grid.style.gridTemplateColumns = sizes.cols;
if (sizes.rows) grid.style.gridTemplateRows = sizes.rows;
}
export function resetSizes() {
grid.style.gridTemplateColumns = '';
grid.style.gridTemplateRows = '';
setPref('panelSizes', null);
relayoutEditors();
}
export function clearInlineSizes() {
grid.style.gridTemplateColumns = '';
grid.style.gridTemplateRows = '';
}

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>`,
},
];

15
public/js/utils.js Normal file
View File

@@ -0,0 +1,15 @@
/**
* Load an external script, hiding AMD define to avoid conflicts with Monaco's RequireJS.
*/
export function loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; }
const savedDefine = window.define;
window.define = undefined;
const s = document.createElement('script');
s.src = src;
s.onload = () => { window.define = savedDefine; resolve(); };
s.onerror = () => { window.define = savedDefine; reject(new Error(`Failed to load ${src}`)); };
document.head.appendChild(s);
});
}

206
server.js
View File

@@ -1,32 +1,94 @@
import express from 'express';
import { nanoid } from 'nanoid';
import { stmts } from './db.js';
import db, { stmts, setFiddleTags, snapshotVersion } from './db.js';
const app = express();
app.use(express.json());
app.use(express.static('public'));
app.use(express.json({ limit: '2mb' }));
// HTML routes must be defined before static middleware (which would serve index.html for /)
app.get('/', (_req, res) => {
res.sendFile('browse.html', { root: 'public' });
});
app.get('/new', (_req, res) => {
res.sendFile('index.html', { root: 'public' });
});
app.get('/embed/:id', (_req, res) => {
res.sendFile('embed.html', { root: 'public' });
});
app.get('/f/:id', (_req, res) => {
res.sendFile('index.html', { root: 'public' });
});
app.use(express.static('public', { index: false }));
// API: Create fiddle
app.post('/api/fiddles', (req, res) => {
const id = nanoid(10);
const { title = 'Untitled', html = '', css = '', css_type = 'css', js = '', js_type = 'javascript' } = req.body;
const { title = 'Untitled', html = '', css = '', css_type = 'css', js = '', js_type = 'javascript', listed = 1, options = '{}', tags = [] } = req.body;
try {
stmts.insert.run({ id, title, html, css, css_type, js, js_type });
res.json({ id, title, html, css, css_type, js, js_type });
stmts.insert.run({ id, title, html, css, css_type, js, js_type, listed: listed ? 1 : 0, options });
if (tags.length) setFiddleTags(id, tags);
const fiddleTags = stmts.getTagsForFiddle.all(id);
res.json({ id, title, html, css, css_type, js, js_type, listed, options, tags: fiddleTags });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// API: List recent fiddles
app.get('/api/fiddles', (_req, res) => {
res.json(stmts.list.all());
// API: List/search fiddles
app.get('/api/fiddles', (req, res) => {
const { q = '', js_type = '', tag = '', page = '1', limit = '20', sort = 'updated' } = req.query;
const pageNum = Math.max(1, parseInt(page, 10) || 1);
const limitNum = Math.min(100, Math.max(1, parseInt(limit, 10) || 20));
const offset = (pageNum - 1) * limitNum;
let where = 'WHERE f.listed = 1';
const params = {};
if (q) {
where += ' AND f.title LIKE @q';
params.q = `%${q}%`;
}
if (js_type) {
where += ' AND f.js_type = @js_type';
params.js_type = js_type;
}
if (tag) {
where += ' AND EXISTS (SELECT 1 FROM fiddle_tags ft2 JOIN tags t2 ON t2.id = ft2.tag_id WHERE ft2.fiddle_id = f.id AND t2.name = @tag COLLATE NOCASE)';
params.tag = tag;
}
const orderBy = sort === 'created' ? 'f.created_at DESC' : 'f.updated_at DESC';
try {
const countRow = db.prepare(`SELECT COUNT(*) as total FROM fiddles f ${where}`).get(params);
const fiddles = db.prepare(`
SELECT f.id, f.title, f.css_type, f.js_type, f.created_at, f.updated_at, f.screenshot,
SUBSTR(f.html, 1, 200) as html_preview, SUBSTR(f.js, 1, 200) as js_preview
FROM fiddles f ${where}
ORDER BY ${orderBy}
LIMIT @limit OFFSET @offset
`).all({ ...params, limit: limitNum, offset });
// Attach tags to each fiddle
for (const f of fiddles) {
f.tags = stmts.getTagsForFiddle.all(f.id);
}
res.json({ fiddles, total: countRow.total, page: pageNum, limit: limitNum });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// API: Get fiddle
app.get('/api/fiddles/:id', (req, res) => {
const fiddle = stmts.get.get(req.params.id);
if (!fiddle) return res.status(404).json({ error: 'Not found' });
fiddle.tags = stmts.getTagsForFiddle.all(fiddle.id);
res.json(fiddle);
});
@@ -34,14 +96,128 @@ app.get('/api/fiddles/:id', (req, res) => {
app.put('/api/fiddles/:id', (req, res) => {
const existing = stmts.get.get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Not found' });
const { title = existing.title, html = existing.html, css = existing.css, css_type = existing.css_type, js = existing.js, js_type = existing.js_type || 'javascript' } = req.body;
stmts.update.run({ id: req.params.id, title, html, css, css_type, js, js_type });
res.json({ id: req.params.id, title, html, css, css_type, js, js_type });
// Snapshot current state as a version before overwriting
snapshotVersion(req.params.id);
const {
title = existing.title,
html = existing.html,
css = existing.css,
css_type = existing.css_type,
js = existing.js,
js_type = existing.js_type || 'javascript',
listed = existing.listed,
options = existing.options || '{}',
screenshot,
tags,
} = req.body;
stmts.update.run({ id: req.params.id, title, html, css, css_type, js, js_type, listed: listed ? 1 : 0, options });
if (screenshot !== undefined) stmts.updateScreenshot.run(screenshot, req.params.id);
if (Array.isArray(tags)) setFiddleTags(req.params.id, tags);
const fiddleTags = stmts.getTagsForFiddle.all(req.params.id);
res.json({ id: req.params.id, title, html, css, css_type, js, js_type, listed, options, tags: fiddleTags });
});
// SPA route: serve index.html for /f/:id
app.get('/f/:id', (_req, res) => {
res.sendFile('index.html', { root: 'public' });
// API: List tags
app.get('/api/tags', (_req, res) => {
res.json({ tags: stmts.listTags.all() });
});
// ===================== Version History API =====================
app.get('/api/fiddles/:id/versions', (req, res) => {
const fiddle = stmts.get.get(req.params.id);
if (!fiddle) return res.status(404).json({ error: 'Not found' });
const versions = stmts.listVersions.all(req.params.id);
res.json({ versions });
});
app.get('/api/fiddles/:id/versions/:ver', (req, res) => {
const ver = parseInt(req.params.ver, 10);
const version = stmts.getVersion.get(req.params.id, ver);
if (!version) return res.status(404).json({ error: 'Version not found' });
res.json(version);
});
app.post('/api/fiddles/:id/revert/:ver', (req, res) => {
const existing = stmts.get.get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Not found' });
const ver = parseInt(req.params.ver, 10);
const version = stmts.getVersion.get(req.params.id, ver);
if (!version) return res.status(404).json({ error: 'Version not found' });
// Snapshot current state before reverting
snapshotVersion(req.params.id);
stmts.update.run({
id: req.params.id,
title: existing.title,
html: version.html,
css: version.css,
css_type: version.css_type,
js: version.js,
js_type: version.js_type,
listed: existing.listed,
options: version.options || existing.options || '{}',
});
const updated = stmts.get.get(req.params.id);
updated.tags = stmts.getTagsForFiddle.all(req.params.id);
res.json(updated);
});
// ===================== Collections API =====================
app.post('/api/collections', (req, res) => {
const id = nanoid(10);
const { name = 'Untitled Collection', description = '' } = req.body;
try {
stmts.insertCollection.run({ id, name, description });
res.json({ id, name, description, fiddle_count: 0 });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/collections', (_req, res) => {
res.json({ collections: stmts.listCollections.all() });
});
app.get('/api/collections/:id', (req, res) => {
const col = stmts.getCollection.get(req.params.id);
if (!col) return res.status(404).json({ error: 'Not found' });
const fiddles = stmts.getCollectionFiddles.all(req.params.id);
for (const f of fiddles) f.tags = stmts.getTagsForFiddle.all(f.id);
res.json({ ...col, fiddles });
});
app.put('/api/collections/:id', (req, res) => {
const col = stmts.getCollection.get(req.params.id);
if (!col) return res.status(404).json({ error: 'Not found' });
const { name = col.name, description = col.description } = req.body;
stmts.updateCollection.run({ id: req.params.id, name, description });
res.json({ id: req.params.id, name, description });
});
app.delete('/api/collections/:id', (req, res) => {
const col = stmts.getCollection.get(req.params.id);
if (!col) return res.status(404).json({ error: 'Not found' });
stmts.deleteCollection.run(req.params.id);
res.json({ ok: true });
});
app.post('/api/collections/:id/fiddles', (req, res) => {
const col = stmts.getCollection.get(req.params.id);
if (!col) return res.status(404).json({ error: 'Collection not found' });
const { fiddle_id } = req.body;
if (!fiddle_id) return res.status(400).json({ error: 'fiddle_id required' });
stmts.addFiddleToCollection.run({ collection_id: req.params.id, fiddle_id });
res.json({ ok: true });
});
app.delete('/api/collections/:id/fiddles/:fid', (req, res) => {
stmts.removeFiddleFromCollection.run(req.params.id, req.params.fid);
res.json({ ok: true });
});
const port = process.env.PORT || 3000;