Add version history, screenshots, embed generator, collections, npm search, format-on-save, and custom fonts
This commit is contained in:
291
public/js/app.js
291
public/js/app.js
@@ -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, '&').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();
|
||||
|
||||
Reference in New Issue
Block a user