Compare commits
7 Commits
463b563423
...
26e232fd41
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26e232fd41 | ||
|
|
0d84c56008 | ||
|
|
6ca8519250 | ||
|
|
b18c9c1dc8 | ||
|
|
77f64d2862 | ||
|
|
e41c3e7dc4 | ||
|
|
7f51af17a3 |
183
db.js
183
db.js
@@ -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
57
public/browse.html
Normal 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">← 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
258
public/css/browse.css
Normal 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; }
|
||||
}
|
||||
@@ -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
23
public/embed.html
Normal 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>
|
||||
@@ -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">×</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">×</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">×</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">×</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">×</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">×</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">×</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>
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
639
public/js/app.js
639
public/js/app.js
@@ -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">×</button>`;
|
||||
pill.querySelector('.tag-remove').addEventListener('click', () => {
|
||||
currentTags = currentTags.filter(t => t !== tag);
|
||||
renderTags();
|
||||
});
|
||||
container.appendChild(pill);
|
||||
}
|
||||
}
|
||||
|
||||
function renderResourceList() {
|
||||
const container = $('#resource-list');
|
||||
container.innerHTML = '';
|
||||
currentResources.forEach((r, i) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'resource-item';
|
||||
item.innerHTML = `<span class="resource-type ${r.type}">${r.type}</span><span class="resource-url" title="${r.url}">${r.url}</span><button class="resource-remove">×</button>`;
|
||||
item.querySelector('.resource-remove').addEventListener('click', () => {
|
||||
currentResources.splice(i, 1);
|
||||
renderResourceList();
|
||||
scheduleRun();
|
||||
});
|
||||
container.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadTagSuggestions() {
|
||||
try {
|
||||
const { tags } = await listTags();
|
||||
const datalist = $('#tags-datalist');
|
||||
datalist.innerHTML = '';
|
||||
for (const t of tags) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t.name;
|
||||
datalist.appendChild(opt);
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
// ===================== Version History =====================
|
||||
|
||||
async function openHistoryModal() {
|
||||
if (!currentId) {
|
||||
showToast('Save the fiddle first to see history');
|
||||
return;
|
||||
}
|
||||
const modal = $('#history-modal');
|
||||
const list = $('#history-list');
|
||||
const preview = $('#history-preview');
|
||||
preview.classList.add('hidden');
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const { versions } = await listVersions(currentId);
|
||||
if (!versions.length) {
|
||||
list.innerHTML = '<div style="padding:16px;color:var(--text-dim);text-align:center">No versions yet. Versions are created each time you save.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = versions.map(v => `
|
||||
<div class="history-item" data-version="${v.version}">
|
||||
<span class="history-version">v${v.version}</span>
|
||||
<span class="history-date">${new Date(v.created_at + 'Z').toLocaleString()}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
list.querySelectorAll('.history-item').forEach(item => {
|
||||
item.addEventListener('click', async () => {
|
||||
list.querySelectorAll('.history-item').forEach(i => i.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
const ver = parseInt(item.dataset.version, 10);
|
||||
const vData = await getVersion(currentId, ver);
|
||||
showVersionPreview(vData);
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
list.innerHTML = `<div style="padding:16px;color:var(--text-dim)">Error: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function showVersionPreview(vData) {
|
||||
const preview = $('#history-preview');
|
||||
const label = $('#history-preview-label');
|
||||
const diff = $('#history-diff');
|
||||
preview.classList.remove('hidden');
|
||||
label.textContent = `Version ${vData.version} — ${new Date(vData.created_at + 'Z').toLocaleString()}`;
|
||||
|
||||
const current = getEditorValues();
|
||||
diff.innerHTML = '';
|
||||
|
||||
const sections = [
|
||||
{ label: 'HTML', old: vData.html, cur: current.html },
|
||||
{ label: 'CSS', old: vData.css, cur: current.css },
|
||||
{ label: 'JS', old: vData.js, cur: current.js },
|
||||
];
|
||||
|
||||
for (const s of sections) {
|
||||
if (s.old === s.cur) continue;
|
||||
const sec = document.createElement('div');
|
||||
sec.className = 'history-diff-section';
|
||||
sec.innerHTML = `<div class="history-diff-label">${s.label}</div><pre class="history-diff-code">${escHtml(s.old || '(empty)')}</pre>`;
|
||||
diff.appendChild(sec);
|
||||
}
|
||||
|
||||
if (!diff.children.length) {
|
||||
diff.innerHTML = '<div style="padding:12px;color:var(--text-dim)">No changes from current version</div>';
|
||||
}
|
||||
|
||||
// Wire restore button
|
||||
const restoreBtn = $('#history-restore-btn');
|
||||
restoreBtn.onclick = async () => {
|
||||
try {
|
||||
const result = await revertVersion(currentId, vData.version);
|
||||
setEditorValues({ html: result.html, css: result.css, js: result.js });
|
||||
snapshotValues();
|
||||
$('#history-modal').classList.add('hidden');
|
||||
showToast('Restored version ' + vData.version);
|
||||
run();
|
||||
} catch (e) {
|
||||
showToast('Restore failed: ' + e.message);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// ===================== Embed Modal =====================
|
||||
|
||||
function openEmbedModal() {
|
||||
if (!currentId) {
|
||||
showToast('Save the fiddle first to generate embed code');
|
||||
return;
|
||||
}
|
||||
$('#embed-modal').classList.remove('hidden');
|
||||
updateEmbedCode();
|
||||
}
|
||||
|
||||
function updateEmbedCode() {
|
||||
if (!currentId) return;
|
||||
const theme = $('#embed-theme').value;
|
||||
const tabs = $('#embed-tabs').value;
|
||||
const autorun = $('#embed-autorun').value;
|
||||
const width = $('#embed-width').value || '100%';
|
||||
const height = $('#embed-height').value || '400';
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (theme !== 'light') params.set('theme', theme);
|
||||
if (tabs === '0') params.set('tabs', '0');
|
||||
if (autorun === '0') params.set('run', '0');
|
||||
const qs = params.toString();
|
||||
|
||||
const url = `${location.origin}/embed/${currentId}${qs ? '?' + qs : ''}`;
|
||||
const heightVal = /^\d+$/.test(height) ? height + 'px' : height;
|
||||
const snippet = `<iframe src="${url}" style="width:${width};height:${heightVal};border:0;border-radius:4px;overflow:hidden" sandbox="allow-scripts allow-same-origin"></iframe>`;
|
||||
|
||||
$('#embed-code').textContent = snippet;
|
||||
$('#embed-preview-frame').src = url;
|
||||
}
|
||||
|
||||
// ===================== Collection Modal =====================
|
||||
|
||||
async function openCollectionModal() {
|
||||
if (!currentId) {
|
||||
showToast('Save the fiddle first to add to a collection');
|
||||
return;
|
||||
}
|
||||
const modal = $('#collection-modal');
|
||||
const list = $('#collection-list');
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const { collections } = await listCollections();
|
||||
if (!collections.length) {
|
||||
list.innerHTML = '<div style="padding:12px;color:var(--text-dim);text-align:center">No collections yet</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = collections.map(c => `
|
||||
<div class="collection-item" data-id="${c.id}">
|
||||
<span class="collection-name">${escHtml(c.name)}</span>
|
||||
<span class="collection-count">${c.fiddle_count} fiddles</span>
|
||||
<button class="btn-small collection-add-btn">Add</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
list.querySelectorAll('.collection-add-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = btn.closest('.collection-item').dataset.id;
|
||||
try {
|
||||
await addToCollection(id, currentId);
|
||||
btn.textContent = 'Added';
|
||||
btn.disabled = true;
|
||||
} catch (e) {
|
||||
showToast('Failed to add: ' + e.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
list.innerHTML = `<div style="padding:12px;color:var(--text-dim)">Error: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== Google Fonts Loader =====================
|
||||
|
||||
const loadedFonts = new Set();
|
||||
function loadGoogleFont(fontName) {
|
||||
if (loadedFonts.has(fontName)) return;
|
||||
loadedFonts.add(fontName);
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = `https://fonts.googleapis.com/css2?family=${fontName.replace(/ /g, '+')}:wght@400;500;600;700&display=swap`;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
142
public/js/autocomplete.js
Normal file
142
public/js/autocomplete.js
Normal 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
199
public/js/browse.js
Normal 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}">« 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 »</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();
|
||||
@@ -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
31
public/js/devtools.js
Normal 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
114
public/js/diff-view.js
Normal 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');
|
||||
}
|
||||
}
|
||||
90
public/js/editor-themes.js
Normal file
90
public/js/editor-themes.js
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
90
public/js/elements-panel.js
Normal file
90
public/js/elements-panel.js
Normal 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><<span class="el-tag">${escapeHtml(node.tag)}</span>${attrs}></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></<span class="el-tag">${escapeHtml(node.tag)}</span>></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"></<span class="el-tag">${escapeHtml(node.tag)}</span>></div>`;
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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
121
public/js/embed.js
Normal 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
18
public/js/emmet.js
Normal 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
90
public/js/export.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
109
public/js/formatter.js
Normal file
109
public/js/formatter.js
Normal 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
43
public/js/import-map.js
Normal 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>`;
|
||||
}
|
||||
@@ -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
97
public/js/keybindings.js
Normal 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
172
public/js/linter.js
Normal 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();
|
||||
}
|
||||
70
public/js/network-panel.js
Normal file
70
public/js/network-panel.js
Normal 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
74
public/js/npm-search.js
Normal 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;
|
||||
}
|
||||
54
public/js/performance-panel.js
Normal file
54
public/js/performance-panel.js
Normal 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
27
public/js/preferences.js
Normal 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);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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
42
public/js/qr.js
Normal 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
91
public/js/resizer.js
Normal 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
338
public/js/templates.js
Normal 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">×</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
15
public/js/utils.js
Normal 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
206
server.js
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user