Add version history, screenshots, embed generator, collections, npm search, format-on-save, and custom fonts

This commit is contained in:
root
2026-02-27 01:47:16 -06:00
parent 6ca8519250
commit 0d84c56008
14 changed files with 1046 additions and 25 deletions

View File

@@ -39,3 +39,47 @@ export function listFiddles({ q, js_type, tag, page, limit, sort } = {}) {
export function listTags() {
return request('/api/tags');
}
// Version history
export function listVersions(id) {
return request(`${BASE}/${id}/versions`);
}
export function getVersion(id, ver) {
return request(`${BASE}/${id}/versions/${ver}`);
}
export function revertVersion(id, ver) {
return request(`${BASE}/${id}/revert/${ver}`, { method: 'POST' });
}
// Collections
export function createCollection(data) {
return request('/api/collections', { method: 'POST', body: JSON.stringify(data) });
}
export function listCollections() {
return request('/api/collections');
}
export function getCollection(id) {
return request(`/api/collections/${id}`);
}
export function updateCollection(id, data) {
return request(`/api/collections/${id}`, { method: 'PUT', body: JSON.stringify(data) });
}
export function deleteCollection(id) {
return request(`/api/collections/${id}`, { method: 'DELETE' });
}
export function addToCollection(collectionId, fiddleId) {
return request(`/api/collections/${collectionId}/fiddles`, {
method: 'POST', body: JSON.stringify({ fiddle_id: fiddleId }),
});
}
export function removeFromCollection(collectionId, fiddleId) {
return request(`/api/collections/${collectionId}/fiddles/${fiddleId}`, { method: 'DELETE' });
}

View File

@@ -1,7 +1,7 @@
import {
initEditors, switchMode, getEditorValues, setEditorValues,
setOnChange, setOnTabSwitch, getCurrentMode, getCssType, setCssType,
setOnFormat, setOnDiff, setEditorTheme,
setOnFormat, setOnDiff, setEditorTheme, setEditorFont,
relayoutEditors,
MODE_TABS, MODE_TO_JS_TYPE, JS_TYPE_TO_MODE,
} from './editors.js';
@@ -9,7 +9,11 @@ import { renderPreview } from './preview.js';
import { initConsole, clearConsole } from './console-panel.js';
import { compileCss } from './preprocessors.js';
import { compileJs } from './js-preprocessors.js';
import { createFiddle, loadFiddle, updateFiddle, listTags } from './api.js';
import {
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';
@@ -20,12 +24,13 @@ 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 } from './formatter.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;
@@ -125,6 +130,11 @@ 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();
@@ -132,14 +142,25 @@ async function save() {
const listed = $('#listed-checkbox').checked ? 1 : 0;
const tags = currentTags.slice();
const options = JSON.stringify({ tailwind: getTailwindChecked(), resources: currentResources });
// Capture screenshot from preview iframe
let screenshot = undefined;
try {
screenshot = await captureScreenshot();
} catch (_) { /* screenshot is optional */ }
try {
if (currentId) {
await updateFiddle(currentId, { title, html, css, css_type, js, js_type, listed, options, tags });
await updateFiddle(currentId, { title, html, css, css_type, js, js_type, listed, options, tags, screenshot });
snapshotValues();
showToast(`Saved! Share: ${location.origin}/f/${currentId}`);
} else {
const result = await createFiddle({ title, html, css, css_type, js, js_type, listed, options, tags });
currentId = result.id;
// Capture screenshot for new fiddle too
if (screenshot) {
try { await updateFiddle(currentId, { title, html, css, css_type, js, js_type, listed, options, tags, screenshot }); } catch (_) {}
}
snapshotValues();
history.pushState(null, '', `/f/${currentId}`);
showToast(`Saved! Share: ${location.origin}/f/${currentId}`);
@@ -149,6 +170,36 @@ 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)';
@@ -345,6 +396,24 @@ async function init() {
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);
@@ -429,6 +498,45 @@ async function init() {
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');
@@ -517,4 +625,179 @@ async function loadTagSuggestions() {
} catch (_) { /* ignore */ }
}
// ===================== Version History =====================
async function openHistoryModal() {
if (!currentId) {
showToast('Save the fiddle first to see history');
return;
}
const modal = $('#history-modal');
const list = $('#history-list');
const preview = $('#history-preview');
preview.classList.add('hidden');
modal.classList.remove('hidden');
try {
const { versions } = await listVersions(currentId);
if (!versions.length) {
list.innerHTML = '<div style="padding:16px;color:var(--text-dim);text-align:center">No versions yet. Versions are created each time you save.</div>';
return;
}
list.innerHTML = versions.map(v => `
<div class="history-item" data-version="${v.version}">
<span class="history-version">v${v.version}</span>
<span class="history-date">${new Date(v.created_at + 'Z').toLocaleString()}</span>
</div>
`).join('');
list.querySelectorAll('.history-item').forEach(item => {
item.addEventListener('click', async () => {
list.querySelectorAll('.history-item').forEach(i => i.classList.remove('active'));
item.classList.add('active');
const ver = parseInt(item.dataset.version, 10);
const vData = await getVersion(currentId, ver);
showVersionPreview(vData);
});
});
} catch (e) {
list.innerHTML = `<div style="padding:16px;color:var(--text-dim)">Error: ${e.message}</div>`;
}
}
function showVersionPreview(vData) {
const preview = $('#history-preview');
const label = $('#history-preview-label');
const diff = $('#history-diff');
preview.classList.remove('hidden');
label.textContent = `Version ${vData.version}${new Date(vData.created_at + 'Z').toLocaleString()}`;
const current = getEditorValues();
diff.innerHTML = '';
const sections = [
{ label: 'HTML', old: vData.html, cur: current.html },
{ label: 'CSS', old: vData.css, cur: current.css },
{ label: 'JS', old: vData.js, cur: current.js },
];
for (const s of sections) {
if (s.old === s.cur) continue;
const sec = document.createElement('div');
sec.className = 'history-diff-section';
sec.innerHTML = `<div class="history-diff-label">${s.label}</div><pre class="history-diff-code">${escHtml(s.old || '(empty)')}</pre>`;
diff.appendChild(sec);
}
if (!diff.children.length) {
diff.innerHTML = '<div style="padding:12px;color:var(--text-dim)">No changes from current version</div>';
}
// Wire restore button
const restoreBtn = $('#history-restore-btn');
restoreBtn.onclick = async () => {
try {
const result = await revertVersion(currentId, vData.version);
setEditorValues({ html: result.html, css: result.css, js: result.js });
snapshotValues();
$('#history-modal').classList.add('hidden');
showToast('Restored version ' + vData.version);
run();
} catch (e) {
showToast('Restore failed: ' + e.message);
}
};
}
function escHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ===================== Embed Modal =====================
function openEmbedModal() {
if (!currentId) {
showToast('Save the fiddle first to generate embed code');
return;
}
$('#embed-modal').classList.remove('hidden');
updateEmbedCode();
}
function updateEmbedCode() {
if (!currentId) return;
const theme = $('#embed-theme').value;
const tabs = $('#embed-tabs').value;
const autorun = $('#embed-autorun').value;
const width = $('#embed-width').value || '100%';
const height = $('#embed-height').value || '400';
const params = new URLSearchParams();
if (theme !== 'light') params.set('theme', theme);
if (tabs === '0') params.set('tabs', '0');
if (autorun === '0') params.set('run', '0');
const qs = params.toString();
const url = `${location.origin}/embed/${currentId}${qs ? '?' + qs : ''}`;
const heightVal = /^\d+$/.test(height) ? height + 'px' : height;
const snippet = `<iframe src="${url}" style="width:${width};height:${heightVal};border:0;border-radius:4px;overflow:hidden" sandbox="allow-scripts allow-same-origin"></iframe>`;
$('#embed-code').textContent = snippet;
$('#embed-preview-frame').src = url;
}
// ===================== Collection Modal =====================
async function openCollectionModal() {
if (!currentId) {
showToast('Save the fiddle first to add to a collection');
return;
}
const modal = $('#collection-modal');
const list = $('#collection-list');
modal.classList.remove('hidden');
try {
const { collections } = await listCollections();
if (!collections.length) {
list.innerHTML = '<div style="padding:12px;color:var(--text-dim);text-align:center">No collections yet</div>';
return;
}
list.innerHTML = collections.map(c => `
<div class="collection-item" data-id="${c.id}">
<span class="collection-name">${escHtml(c.name)}</span>
<span class="collection-count">${c.fiddle_count} fiddles</span>
<button class="btn-small collection-add-btn">Add</button>
</div>
`).join('');
list.querySelectorAll('.collection-add-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.closest('.collection-item').dataset.id;
try {
await addToCollection(id, currentId);
btn.textContent = 'Added';
btn.disabled = true;
} catch (e) {
showToast('Failed to add: ' + e.message);
}
});
});
} catch (e) {
list.innerHTML = `<div style="padding:12px;color:var(--text-dim)">Error: ${e.message}</div>`;
}
}
// ===================== Google Fonts Loader =====================
const loadedFonts = new Set();
function loadGoogleFont(fontName) {
if (loadedFonts.has(fontName)) return;
loadedFonts.add(fontName);
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = `https://fonts.googleapis.com/css2?family=${fontName.replace(/ /g, '+')}:wght@400;500;600;700&display=swap`;
document.head.appendChild(link);
}
init();

View File

@@ -1,9 +1,10 @@
import { listFiddles, listTags } from './api.js';
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',
@@ -27,8 +28,8 @@ function relativeTime(dateStr) {
return new Date(dateStr).toLocaleDateString();
}
function renderCards(fiddles) {
const grid = $('#fiddle-grid');
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;
@@ -37,15 +38,20 @@ function renderCards(fiddles) {
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">
<div class="card-title">${esc(f.title)}</div>
<div class="card-meta">
<span class="card-badge">${badge}</span>
<span>${relativeTime(f.updated_at)}</span>
${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>
${preview ? `<div class="card-preview">${esc(preview)}</div>` : ''}
${tags ? `<div class="card-tags">${tags}</div>` : ''}
</a>
`;
}).join('');
@@ -130,6 +136,64 @@ $('#filter-sort').addEventListener('change', () => {
fetchAndRender();
});
// Browse tabs
document.querySelectorAll('.browse-tab').forEach(tab => {
tab.addEventListener('click', () => {
currentView = tab.dataset.view;
document.querySelectorAll('.browse-tab').forEach(t => t.classList.toggle('active', t === tab));
$('#fiddles-view').style.display = currentView === 'fiddles' ? '' : 'none';
$('#collections-view').style.display = currentView === 'collections' ? '' : 'none';
if (currentView === 'collections') renderCollections();
});
});
async function renderCollections() {
const grid = $('#collections-grid');
const detail = $('#collection-detail');
grid.style.display = '';
detail.style.display = 'none';
try {
const { collections } = await listCollections();
if (!collections.length) {
grid.innerHTML = '<div class="empty-state">No collections yet</div>';
return;
}
grid.innerHTML = collections.map(c => `
<div class="collection-card" data-id="${c.id}">
<div class="collection-card-name">${esc(c.name)}</div>
<div class="collection-card-desc">${esc(c.description || '')}</div>
<div class="collection-card-count">${c.fiddle_count} fiddle${c.fiddle_count !== 1 ? 's' : ''}</div>
</div>
`).join('');
grid.querySelectorAll('.collection-card').forEach(card => {
card.addEventListener('click', () => showCollectionDetail(card.dataset.id));
});
} catch (e) {
grid.innerHTML = `<div class="empty-state">Error: ${esc(e.message)}</div>`;
}
}
async function showCollectionDetail(id) {
const grid = $('#collections-grid');
const detail = $('#collection-detail');
grid.style.display = 'none';
detail.style.display = '';
try {
const col = await getCollection(id);
$('#collection-header').textContent = col.name;
$('#collection-desc').textContent = col.description || '';
renderCards(col.fiddles || [], '#collection-fiddles');
} catch (e) {
$('#collection-header').textContent = 'Error';
$('#collection-desc').textContent = e.message;
}
}
$('#collection-back').addEventListener('click', () => renderCollections());
// Init
renderTagsBar();
fetchAndRender();

View File

@@ -204,9 +204,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') {
@@ -360,3 +367,13 @@ export function setEditorTheme(themeId) {
editorOpts.theme = themeId;
monaco.editor.setTheme(themeId);
}
export function setEditorFont(fontFamily) {
const family = fontFamily === 'default' ? "'Cascadia Code', 'Fira Code', monospace" : `'${fontFamily}', monospace`;
editorOpts.fontFamily = family;
const tabs = MODE_TABS[currentMode];
if (!tabs) return;
tabs.forEach((tab) => {
if (editors[tab.id]) editors[tab.id].updateOptions({ fontFamily: family });
});
}

View File

@@ -21,9 +21,11 @@ async function init() {
return;
}
// Theme
// 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]);

View File

@@ -1,5 +1,5 @@
import { loadScript } from './utils.js';
import { getActiveEditor, getActiveTab, getCurrentMode, getCssType } from './editors.js';
import { getActiveEditor, getActiveTab, getCurrentMode, getCssType, switchTab, getEditorValues } from './editors.js';
const PRETTIER_CDN = 'https://cdn.jsdelivr.net/npm/prettier@3';
const PLUGINS = [
@@ -79,3 +79,31 @@ export async function formatActiveEditor() {
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 */ }
}
}

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

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

View File

@@ -8,6 +8,8 @@ const DEFAULTS = {
previewTheme: 'light',
previewDevice: 'desktop',
editorTheme: 'vs-dark',
formatOnSave: false,
editorFont: 'default',
};
export function getPref(key) {