Add responsive preview, editor themes, template gallery, devtools, and autocomplete
- Device breakpoint toggles (mobile 375px / tablet 768px / desktop 100%) - Editor theme selector with 6 themes (VS Dark/Light, High Contrast, Monokai, Dracula, GitHub Dark) - Starter template gallery with 8 pre-built templates (Todo, API Fetch, CSS Animation, etc.) - Code autocomplete with DOM/React type definitions and snippet completions - Devtools panels: console, network, elements, performance - Code formatter (Prettier), diff view, and linter integration
This commit is contained in:
@@ -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);
|
||||
@@ -52,7 +54,7 @@ button:hover { background: var(--accent-hover); }
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 4px 1fr;
|
||||
grid-template-rows: 1fr 4px 1fr;
|
||||
height: calc(100vh - var(--toolbar-h));
|
||||
flex: 1; min-height: 0;
|
||||
}
|
||||
.panel { position: relative; border: 1px solid var(--border); overflow: hidden; display: flex; flex-direction: column; }
|
||||
.panel-label {
|
||||
@@ -155,7 +157,20 @@ body.resizing iframe { pointer-events: none; }
|
||||
}
|
||||
.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 {
|
||||
@@ -211,7 +226,7 @@ body.resizing iframe { pointer-events: none; }
|
||||
.divider { background: var(--border); transition: background 0.15s; z-index: 2; }
|
||||
|
||||
/* Layout/keybinding/preview-theme selects — match framework select */
|
||||
#layout-mode, #keybinding-mode, #preview-theme {
|
||||
#layout-mode, #keybinding-mode, #preview-theme, #editor-theme {
|
||||
background: var(--bg); color: var(--text); border: 1px solid var(--border);
|
||||
padding: 4px 6px; border-radius: 4px; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
@@ -343,3 +358,240 @@ body.resizing iframe { pointer-events: none; }
|
||||
#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; }
|
||||
}
|
||||
|
||||
@@ -32,6 +32,14 @@
|
||||
<option value="vim">Vim</option>
|
||||
<option value="emacs">Emacs</option>
|
||||
</select>
|
||||
<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="toolbar-right">
|
||||
<div class="tags-input-wrap">
|
||||
@@ -43,6 +51,7 @@
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
<button id="btn-templates" class="btn-secondary" title="Starter templates gallery">Templates</button>
|
||||
<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">
|
||||
@@ -73,13 +82,48 @@
|
||||
</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>
|
||||
|
||||
@@ -114,6 +158,8 @@
|
||||
<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>
|
||||
@@ -133,6 +179,16 @@
|
||||
</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="share-toast" class="toast hidden"></div>
|
||||
|
||||
<script>
|
||||
|
||||
105
public/js/app.js
105
public/js/app.js
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
initEditors, switchMode, getEditorValues, setEditorValues,
|
||||
setOnChange, getCurrentMode, getCssType, setCssType,
|
||||
setOnChange, setOnTabSwitch, getCurrentMode, getCssType, setCssType,
|
||||
setOnFormat, setOnDiff, setEditorTheme,
|
||||
relayoutEditors,
|
||||
MODE_TABS, MODE_TO_JS_TYPE, JS_TYPE_TO_MODE,
|
||||
} from './editors.js';
|
||||
@@ -15,6 +16,16 @@ 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 } 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';
|
||||
|
||||
let currentId = null;
|
||||
let debounceTimer = null;
|
||||
@@ -73,6 +84,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) {
|
||||
@@ -121,10 +135,12 @@ async function save() {
|
||||
try {
|
||||
if (currentId) {
|
||||
await updateFiddle(currentId, { title, html, css, css_type, js, js_type, listed, options, tags });
|
||||
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;
|
||||
snapshotValues();
|
||||
history.pushState(null, '', `/f/${currentId}`);
|
||||
showToast(`Saved! Share: ${location.origin}/f/${currentId}`);
|
||||
}
|
||||
@@ -181,6 +197,7 @@ async function loadFromUrl() {
|
||||
renderResourceList();
|
||||
|
||||
setEditorValues(fiddle);
|
||||
snapshotValues();
|
||||
setTimeout(run, 100);
|
||||
} catch (e) {
|
||||
showToast(`Failed to load fiddle: ${e.message}`);
|
||||
@@ -225,11 +242,26 @@ async function init() {
|
||||
// Load Emmet before editors so completion providers are registered
|
||||
await initEmmet();
|
||||
|
||||
// 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();
|
||||
initLinter();
|
||||
|
||||
// Auto-run checkbox
|
||||
const autoRunCb = $('#auto-run-checkbox');
|
||||
@@ -249,6 +281,65 @@ async function init() {
|
||||
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);
|
||||
@@ -273,6 +364,16 @@ async function init() {
|
||||
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;
|
||||
|
||||
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,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,13 +1,38 @@
|
||||
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 = {
|
||||
@@ -72,8 +97,10 @@ let currentMode = 'html-css-js';
|
||||
let activeTab = null;
|
||||
let cssType = 'css';
|
||||
let onChangeCallback = null;
|
||||
let onTabSwitchCallback = null;
|
||||
let onModeChangeCallback = null;
|
||||
let tabSwitchCallbacks = [];
|
||||
let modeChangeCallbacks = [];
|
||||
let onFormatCallback = null;
|
||||
let onDiffCallback = null;
|
||||
|
||||
const tabBar = () => document.getElementById('tab-bar');
|
||||
const editorArea = () => document.getElementById('editor-area');
|
||||
@@ -83,11 +110,19 @@ export function setOnChange(cb) {
|
||||
}
|
||||
|
||||
export function setOnTabSwitch(cb) {
|
||||
onTabSwitchCallback = cb;
|
||||
tabSwitchCallbacks.push(cb);
|
||||
}
|
||||
|
||||
export function setOnModeChange(cb) {
|
||||
onModeChangeCallback = cb;
|
||||
modeChangeCallbacks.push(cb);
|
||||
}
|
||||
|
||||
export function setOnFormat(cb) {
|
||||
onFormatCallback = cb;
|
||||
}
|
||||
|
||||
export function setOnDiff(cb) {
|
||||
onDiffCallback = cb;
|
||||
}
|
||||
|
||||
export function getActiveEditor() {
|
||||
@@ -124,20 +159,23 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +234,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') {
|
||||
@@ -248,7 +309,7 @@ export function switchMode(mode) {
|
||||
|
||||
switchTab(tabs[0].id);
|
||||
|
||||
if (onModeChangeCallback) onModeChangeCallback(mode);
|
||||
modeChangeCallbacks.forEach(cb => cb(mode));
|
||||
}
|
||||
|
||||
export function switchTab(tabId) {
|
||||
@@ -274,7 +335,7 @@ export function switchTab(tabId) {
|
||||
editors[tabId].focus();
|
||||
}
|
||||
|
||||
if (onTabSwitchCallback) onTabSwitchCallback(tabId, editors[tabId]);
|
||||
tabSwitchCallbacks.forEach(cb => cb(tabId, editors[tabId]));
|
||||
}
|
||||
|
||||
export function getEditorValues() {
|
||||
@@ -294,3 +355,8 @@ export function setEditorValues({ html = '', css = '', js = '' }) {
|
||||
export function getActiveTab() {
|
||||
return activeTab;
|
||||
}
|
||||
|
||||
export function setEditorTheme(themeId) {
|
||||
editorOpts.theme = themeId;
|
||||
monaco.editor.setTheme(themeId);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -76,7 +76,7 @@ async function init() {
|
||||
}
|
||||
|
||||
const tailwindScript = opts.tailwind
|
||||
? `<script src="https://cdn.tailwindcss.com"><\/script>\n`
|
||||
? `<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
|
||||
|
||||
@@ -44,7 +44,7 @@ export function exportHtml({ title, html, css, js, mode, extraCss = '', isModule
|
||||
}
|
||||
|
||||
const tailwindScript = tailwind
|
||||
? `<script src="https://cdn.tailwindcss.com"><\/script>\n`
|
||||
? `<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'
|
||||
|
||||
81
public/js/formatter.js
Normal file
81
public/js/formatter.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { loadScript } from './utils.js';
|
||||
import { getActiveEditor, getActiveTab, getCurrentMode, getCssType } 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);
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,8 @@ const DEFAULTS = {
|
||||
keybindings: 'default',
|
||||
panelSizes: null,
|
||||
previewTheme: 'light',
|
||||
previewDevice: 'desktop',
|
||||
editorTheme: 'vs-dark',
|
||||
};
|
||||
|
||||
export function getPref(key) {
|
||||
|
||||
@@ -28,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>
|
||||
`;
|
||||
@@ -45,15 +109,16 @@ 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;
|
||||
}
|
||||
|
||||
@@ -102,9 +167,9 @@ export function renderPreview(html, css, js, mode = 'html-css-js', extraCss = ''
|
||||
? buildLoaderScript(runtime.scripts, finalJs, isModule)
|
||||
: '';
|
||||
|
||||
// Tailwind CDN injection
|
||||
// Tailwind CDN injection (suppress production warning)
|
||||
const tailwindScript = options.tailwind
|
||||
? `<script src="https://cdn.tailwindcss.com"><\/script>\n`
|
||||
? `<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
|
||||
|
||||
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>`,
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user