Add QoL features: preview theme, external resources, shortcuts, mobile layout
- Dark/light preview theme toggle with localStorage persistence and dark CSS injection in preview, export, and embed - External CSS/JS resources modal with per-fiddle persistence in options column, injected as link/script tags - Keyboard shortcuts cheat sheet modal (? button or ? key) - Mobile-responsive CSS with breakpoints at 768px and 480px for both editor and browse pages
This commit is contained in:
@@ -193,3 +193,16 @@ html, body {
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
}
|
||||
|
||||
@@ -210,8 +210,8 @@ body.resizing iframe { pointer-events: none; }
|
||||
/* Dividers */
|
||||
.divider { background: var(--border); transition: background 0.15s; z-index: 2; }
|
||||
|
||||
/* Layout/keybinding selects — match framework select */
|
||||
#layout-mode, #keybinding-mode {
|
||||
/* Layout/keybinding/preview-theme selects — match framework select */
|
||||
#layout-mode, #keybinding-mode, #preview-theme {
|
||||
background: var(--bg); color: var(--text); border: 1px solid var(--border);
|
||||
padding: 4px 6px; border-radius: 4px; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
@@ -284,3 +284,62 @@ body.resizing iframe { pointer-events: none; }
|
||||
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); }
|
||||
}
|
||||
|
||||
@@ -39,6 +39,12 @@
|
||||
<datalist id="tags-datalist"></datalist>
|
||||
<div id="tags-display" class="tags-display"></div>
|
||||
</div>
|
||||
<select id="preview-theme" title="Preview background theme">
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
<button id="btn-resources" class="btn-secondary" title="External CSS/JS resources">Resources</button>
|
||||
<button id="btn-shortcuts" class="btn-secondary" title="Keyboard shortcuts (?)" aria-label="Keyboard shortcuts">?</button>
|
||||
<label class="tailwind-toggle" title="Enable Tailwind CSS">
|
||||
<input type="checkbox" id="tailwind-checkbox">
|
||||
Tailwind
|
||||
@@ -77,6 +83,45 @@
|
||||
</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>
|
||||
<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 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">
|
||||
|
||||
@@ -19,6 +19,7 @@ import { showQrModal } from './qr.js';
|
||||
let currentId = null;
|
||||
let debounceTimer = null;
|
||||
let currentTags = [];
|
||||
let currentResources = [];
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
|
||||
@@ -84,12 +85,14 @@ async function run() {
|
||||
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, '', { tailwind: getTailwindChecked() });
|
||||
renderPreview(html, '', '', mode, '', { tailwind: getTailwindChecked(), previewTheme: getPref('previewTheme'), resources: currentResources });
|
||||
window.postMessage({ type: 'console', method: 'error', args: [`Compile error: ${e.message}`] }, '*');
|
||||
}
|
||||
}
|
||||
@@ -114,7 +117,7 @@ async function save() {
|
||||
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() });
|
||||
const options = JSON.stringify({ tailwind: getTailwindChecked(), resources: currentResources });
|
||||
try {
|
||||
if (currentId) {
|
||||
await updateFiddle(currentId, { title, html, css, css_type, js, js_type, listed, options, tags });
|
||||
@@ -137,7 +140,7 @@ async function fork() {
|
||||
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() });
|
||||
const options = JSON.stringify({ tailwind: getTailwindChecked(), resources: currentResources });
|
||||
try {
|
||||
const result = await createFiddle({ title, html, css, css_type, js, js_type, listed, options, tags });
|
||||
currentId = result.id;
|
||||
@@ -170,10 +173,12 @@ async function loadFromUrl() {
|
||||
currentTags = (fiddle.tags || []).map(t => t.name);
|
||||
renderTags();
|
||||
|
||||
// Restore options (tailwind checkbox)
|
||||
// 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);
|
||||
setTimeout(run, 100);
|
||||
@@ -264,6 +269,18 @@ async 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'));
|
||||
}
|
||||
// ? 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
|
||||
@@ -298,6 +315,8 @@ async function init() {
|
||||
tailwind: getTailwindChecked(),
|
||||
isModule: result.isModule || false,
|
||||
renderedHtml: result.renderedHtml || null,
|
||||
previewTheme: getPref('previewTheme'),
|
||||
resources: currentResources,
|
||||
});
|
||||
} catch (e) {
|
||||
showToast(`Export failed: ${e.message}`);
|
||||
@@ -309,6 +328,40 @@ async function init() {
|
||||
showQrModal(url);
|
||||
});
|
||||
|
||||
// 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'); });
|
||||
|
||||
// Load fiddle from URL if present
|
||||
loadFromUrl();
|
||||
|
||||
@@ -334,6 +387,22 @@ function renderTags() {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -79,11 +79,25 @@ async function init() {
|
||||
? `<script src="https://cdn.tailwindcss.com"><\/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">
|
||||
${tailwindScript}<style>${allCss}</style>
|
||||
${darkCss}${resourceTags}${tailwindScript}<style>${allCss}</style>
|
||||
${importMapTag}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 }) {
|
||||
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;
|
||||
|
||||
@@ -47,13 +47,25 @@ export function exportHtml({ title, html, css, js, mode, extraCss = '', isModule
|
||||
? `<script src="https://cdn.tailwindcss.com"><\/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>
|
||||
${tailwindScript}<style>
|
||||
${darkCss}${resourceTags}${tailwindScript}<style>
|
||||
${allCss}
|
||||
</style>
|
||||
${importMapTag}
|
||||
|
||||
@@ -5,6 +5,7 @@ const DEFAULTS = {
|
||||
layout: 'default',
|
||||
keybindings: 'default',
|
||||
panelSizes: null,
|
||||
previewTheme: 'light',
|
||||
};
|
||||
|
||||
export function getPref(key) {
|
||||
|
||||
@@ -107,12 +107,26 @@ export function renderPreview(html, css, js, mode = 'html-css-js', extraCss = ''
|
||||
? `<script src="https://cdn.tailwindcss.com"><\/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}
|
||||
${tailwindScript}<style>${allCss}</style>
|
||||
${darkCss}${resourceTags}${tailwindScript}<style>${allCss}</style>
|
||||
${importMapTag}
|
||||
</head>
|
||||
<body>
|
||||
@@ -120,5 +134,7 @@ ${bodyContent}
|
||||
${loaderScript}
|
||||
</body>
|
||||
</html>`;
|
||||
// Update iframe bg class
|
||||
frame.classList.toggle('preview-dark', options.previewTheme === 'dark');
|
||||
frame.srcdoc = doc;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user