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:
root
2026-02-26 15:39:16 -06:00
parent 77f64d2862
commit b18c9c1dc8
8 changed files with 239 additions and 10 deletions

View File

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

View File

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

View File

@@ -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">&times;</button>
</div>
<div class="resource-inputs">
<div class="resource-row">
<input type="text" id="resource-css-input" placeholder="CSS URL (e.g. https://fonts.googleapis.com/...)">
<button id="btn-add-css" class="btn-small">+ CSS</button>
</div>
<div class="resource-row">
<input type="text" id="resource-js-input" placeholder="JS URL (e.g. https://cdn.jsdelivr.net/...)">
<button id="btn-add-js" class="btn-small">+ JS</button>
</div>
</div>
<div id="resource-list" class="resource-list"></div>
</div>
</div>
<div id="shortcuts-modal" class="modal-overlay hidden">
<div class="modal-content" style="min-width:380px">
<div class="modal-header">
<span>Keyboard Shortcuts</span>
<button id="shortcuts-modal-close" class="btn-small">&times;</button>
</div>
<table class="shortcuts-table">
<tbody>
<tr><td><kbd>Ctrl/Cmd</kbd> + <kbd>Enter</kbd></td><td>Run code</td></tr>
<tr><td><kbd>Ctrl/Cmd</kbd> + <kbd>S</kbd></td><td>Save fiddle</td></tr>
<tr><td><kbd>?</kbd></td><td>Show shortcuts</td></tr>
<tr 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">

View File

@@ -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">&times;</button>`;
item.querySelector('.resource-remove').addEventListener('click', () => {
currentResources.splice(i, 1);
renderResourceList();
scheduleRun();
});
container.appendChild(item);
});
}
async function loadTagSuggestions() {
try {
const { tags } = await listTags();

View File

@@ -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>

View File

@@ -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}

View File

@@ -5,6 +5,7 @@ const DEFAULTS = {
layout: 'default',
keybindings: 'default',
panelSizes: null,
previewTheme: 'light',
};
export function getPref(key) {

View File

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