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;
|
--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 */
|
||||||
.toolbar {
|
.toolbar {
|
||||||
height: var(--toolbar-h);
|
min-height: var(--toolbar-h);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 12px;
|
padding: 4px 12px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
gap: 8px;
|
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; }
|
.logo { font-weight: 700; font-size: 16px; color: var(--accent); text-decoration: none; }
|
||||||
#title-input {
|
#title-input {
|
||||||
background: transparent; border: 1px solid transparent; color: var(--text);
|
background: transparent; border: 1px solid transparent; color: var(--text);
|
||||||
@@ -52,7 +54,7 @@ button:hover { background: var(--accent-hover); }
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 4px 1fr;
|
grid-template-columns: 1fr 4px 1fr;
|
||||||
grid-template-rows: 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 { position: relative; border: 1px solid var(--border); overflow: hidden; display: flex; flex-direction: column; }
|
||||||
.panel-label {
|
.panel-label {
|
||||||
@@ -155,7 +157,20 @@ body.resizing iframe { pointer-events: none; }
|
|||||||
}
|
}
|
||||||
.editor-container.active { display: block; }
|
.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 */
|
||||||
#console-output {
|
#console-output {
|
||||||
@@ -211,7 +226,7 @@ body.resizing iframe { pointer-events: none; }
|
|||||||
.divider { background: var(--border); transition: background 0.15s; z-index: 2; }
|
.divider { background: var(--border); transition: background 0.15s; z-index: 2; }
|
||||||
|
|
||||||
/* Layout/keybinding/preview-theme selects — match framework select */
|
/* 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);
|
background: var(--bg); color: var(--text); border: 1px solid var(--border);
|
||||||
padding: 4px 6px; border-radius: 4px; font-size: 12px; cursor: pointer;
|
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; }
|
#title-input, .tags-input-wrap { display: none; }
|
||||||
.modal-content { margin: 8px; min-width: unset !important; width: calc(100% - 16px); }
|
.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="vim">Vim</option>
|
||||||
<option value="emacs">Emacs</option>
|
<option value="emacs">Emacs</option>
|
||||||
</select>
|
</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>
|
||||||
<div class="toolbar-right">
|
<div class="toolbar-right">
|
||||||
<div class="tags-input-wrap">
|
<div class="tags-input-wrap">
|
||||||
@@ -43,6 +51,7 @@
|
|||||||
<option value="light">Light</option>
|
<option value="light">Light</option>
|
||||||
<option value="dark">Dark</option>
|
<option value="dark">Dark</option>
|
||||||
</select>
|
</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-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>
|
<button id="btn-shortcuts" class="btn-secondary" title="Keyboard shortcuts (?)" aria-label="Keyboard shortcuts">?</button>
|
||||||
<label class="tailwind-toggle" title="Enable Tailwind CSS">
|
<label class="tailwind-toggle" title="Enable Tailwind CSS">
|
||||||
@@ -73,14 +82,49 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="divider divider-col" id="divider-col"></div>
|
<div class="divider divider-col" id="divider-col"></div>
|
||||||
<div class="panel panel-preview">
|
<div class="panel panel-preview">
|
||||||
<div class="panel-label">Preview</div>
|
<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>
|
<iframe id="preview-frame" sandbox="allow-scripts allow-same-origin"></iframe>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="divider divider-row" id="divider-row"></div>
|
<div class="divider divider-row" id="divider-row"></div>
|
||||||
<div class="panel panel-console">
|
<div class="panel panel-console">
|
||||||
<div class="panel-label">Console <button id="btn-clear-console" class="btn-small">Clear</button></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 id="console-output"></div>
|
||||||
</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>
|
</main>
|
||||||
|
|
||||||
<div id="resources-modal" class="modal-overlay hidden">
|
<div id="resources-modal" class="modal-overlay hidden">
|
||||||
@@ -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>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>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>?</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 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>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>
|
<tr><td><kbd>Emacs</kbd></td><td>Full emacs keybindings (select in toolbar)</td></tr>
|
||||||
@@ -133,6 +179,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div id="share-toast" class="toast hidden"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
105
public/js/app.js
105
public/js/app.js
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
initEditors, switchMode, getEditorValues, setEditorValues,
|
initEditors, switchMode, getEditorValues, setEditorValues,
|
||||||
setOnChange, getCurrentMode, getCssType, setCssType,
|
setOnChange, setOnTabSwitch, getCurrentMode, getCssType, setCssType,
|
||||||
|
setOnFormat, setOnDiff, setEditorTheme,
|
||||||
relayoutEditors,
|
relayoutEditors,
|
||||||
MODE_TABS, MODE_TO_JS_TYPE, JS_TYPE_TO_MODE,
|
MODE_TABS, MODE_TO_JS_TYPE, JS_TYPE_TO_MODE,
|
||||||
} from './editors.js';
|
} from './editors.js';
|
||||||
@@ -15,6 +16,16 @@ import { initKeybindings } from './keybindings.js';
|
|||||||
import { initResizer, clearInlineSizes } from './resizer.js';
|
import { initResizer, clearInlineSizes } from './resizer.js';
|
||||||
import { exportHtml } from './export.js';
|
import { exportHtml } from './export.js';
|
||||||
import { showQrModal } from './qr.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 currentId = null;
|
||||||
let debounceTimer = null;
|
let debounceTimer = null;
|
||||||
@@ -73,6 +84,9 @@ async function run() {
|
|||||||
const compiledCss = await compileCss(css, cssType);
|
const compiledCss = await compileCss(css, cssType);
|
||||||
const result = await compileJs(js, mode);
|
const result = await compileJs(js, mode);
|
||||||
clearConsole();
|
clearConsole();
|
||||||
|
clearNetwork();
|
||||||
|
clearElements();
|
||||||
|
clearPerformance();
|
||||||
|
|
||||||
// Show warnings from compilation (e.g., Svelte)
|
// Show warnings from compilation (e.g., Svelte)
|
||||||
if (result.warnings && result.warnings.length) {
|
if (result.warnings && result.warnings.length) {
|
||||||
@@ -121,10 +135,12 @@ async function save() {
|
|||||||
try {
|
try {
|
||||||
if (currentId) {
|
if (currentId) {
|
||||||
await updateFiddle(currentId, { title, html, css, css_type, js, js_type, listed, options, tags });
|
await updateFiddle(currentId, { title, html, css, css_type, js, js_type, listed, options, tags });
|
||||||
|
snapshotValues();
|
||||||
showToast(`Saved! Share: ${location.origin}/f/${currentId}`);
|
showToast(`Saved! Share: ${location.origin}/f/${currentId}`);
|
||||||
} else {
|
} else {
|
||||||
const result = await createFiddle({ title, html, css, css_type, js, js_type, listed, options, tags });
|
const result = await createFiddle({ title, html, css, css_type, js, js_type, listed, options, tags });
|
||||||
currentId = result.id;
|
currentId = result.id;
|
||||||
|
snapshotValues();
|
||||||
history.pushState(null, '', `/f/${currentId}`);
|
history.pushState(null, '', `/f/${currentId}`);
|
||||||
showToast(`Saved! Share: ${location.origin}/f/${currentId}`);
|
showToast(`Saved! Share: ${location.origin}/f/${currentId}`);
|
||||||
}
|
}
|
||||||
@@ -181,6 +197,7 @@ async function loadFromUrl() {
|
|||||||
renderResourceList();
|
renderResourceList();
|
||||||
|
|
||||||
setEditorValues(fiddle);
|
setEditorValues(fiddle);
|
||||||
|
snapshotValues();
|
||||||
setTimeout(run, 100);
|
setTimeout(run, 100);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(`Failed to load fiddle: ${e.message}`);
|
showToast(`Failed to load fiddle: ${e.message}`);
|
||||||
@@ -225,11 +242,26 @@ async function init() {
|
|||||||
// Load Emmet before editors so completion providers are registered
|
// Load Emmet before editors so completion providers are registered
|
||||||
await initEmmet();
|
await initEmmet();
|
||||||
|
|
||||||
|
// Register custom Monaco themes before creating editors
|
||||||
|
registerCustomThemes();
|
||||||
|
|
||||||
|
// Configure autocomplete: type defaults + snippet providers
|
||||||
|
configureTypeDefaults();
|
||||||
|
registerSnippetProviders();
|
||||||
|
|
||||||
initEditors('html-css-js');
|
initEditors('html-css-js');
|
||||||
setOnChange(scheduleRun);
|
setOnChange(() => { scheduleRun(); lintOnChange(); });
|
||||||
|
setOnFormat(() => formatActiveEditor());
|
||||||
|
setOnDiff(() => toggleDiff());
|
||||||
|
setOnTabSwitch(diffOnTabSwitch);
|
||||||
initConsole();
|
initConsole();
|
||||||
|
initDevtools();
|
||||||
|
initNetwork();
|
||||||
|
initElements();
|
||||||
|
initPerformance();
|
||||||
initResizer();
|
initResizer();
|
||||||
initKeybindings();
|
initKeybindings();
|
||||||
|
initLinter();
|
||||||
|
|
||||||
// Auto-run checkbox
|
// Auto-run checkbox
|
||||||
const autoRunCb = $('#auto-run-checkbox');
|
const autoRunCb = $('#auto-run-checkbox');
|
||||||
@@ -249,6 +281,65 @@ async function init() {
|
|||||||
if (savedLayout !== 'default') applyLayout(savedLayout);
|
if (savedLayout !== 'default') applyLayout(savedLayout);
|
||||||
layoutSel.addEventListener('change', (e) => applyLayout(e.target.value));
|
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
|
// Mode selector
|
||||||
$('#framework-mode').addEventListener('change', (e) => {
|
$('#framework-mode').addEventListener('change', (e) => {
|
||||||
handleModeChange(e.target.value);
|
handleModeChange(e.target.value);
|
||||||
@@ -273,6 +364,16 @@ async function init() {
|
|||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
document.querySelectorAll('.modal-overlay:not(.hidden)').forEach(m => m.classList.add('hidden'));
|
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)
|
// ? key opens shortcuts (only when not typing in an input/editor)
|
||||||
if (e.key === '?' && !e.ctrlKey && !e.metaKey) {
|
if (e.key === '?' && !e.ctrlKey && !e.metaKey) {
|
||||||
const tag = document.activeElement?.tagName;
|
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');
|
const output = () => document.getElementById('console-output');
|
||||||
|
|
||||||
export function initConsole() {
|
export function initConsole() {
|
||||||
|
registerClearHandler('console', clearConsole);
|
||||||
|
|
||||||
window.addEventListener('message', (e) => {
|
window.addEventListener('message', (e) => {
|
||||||
if (!e.data || e.data.type !== 'console') return;
|
if (!e.data || e.data.type !== 'console') return;
|
||||||
if (e.data.method === 'clear') {
|
if (e.data.method === 'clear') {
|
||||||
@@ -9,8 +13,6 @@ export function initConsole() {
|
|||||||
}
|
}
|
||||||
appendLine(e.data.method, (e.data.args || []).join(' '));
|
appendLine(e.data.method, (e.data.args || []).join(' '));
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('btn-clear-console').addEventListener('click', clearConsole);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendLine(method, text) {
|
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 = {
|
const editorOpts = {
|
||||||
minimap: { enabled: false },
|
minimap: { enabled: false },
|
||||||
automaticLayout: true,
|
automaticLayout: true,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
lineNumbers: 'on',
|
lineNumbers: 'on',
|
||||||
scrollBeyondLastLine: false,
|
scrollBeyondLastLine: false,
|
||||||
theme: 'vs-dark',
|
theme: getPref('editorTheme') || 'vs-dark',
|
||||||
tabSize: 2,
|
tabSize: 2,
|
||||||
renderWhitespace: 'none',
|
renderWhitespace: 'none',
|
||||||
padding: { top: 6 },
|
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 = {
|
export const MODE_TABS = {
|
||||||
@@ -72,8 +97,10 @@ let currentMode = 'html-css-js';
|
|||||||
let activeTab = null;
|
let activeTab = null;
|
||||||
let cssType = 'css';
|
let cssType = 'css';
|
||||||
let onChangeCallback = null;
|
let onChangeCallback = null;
|
||||||
let onTabSwitchCallback = null;
|
let tabSwitchCallbacks = [];
|
||||||
let onModeChangeCallback = null;
|
let modeChangeCallbacks = [];
|
||||||
|
let onFormatCallback = null;
|
||||||
|
let onDiffCallback = null;
|
||||||
|
|
||||||
const tabBar = () => document.getElementById('tab-bar');
|
const tabBar = () => document.getElementById('tab-bar');
|
||||||
const editorArea = () => document.getElementById('editor-area');
|
const editorArea = () => document.getElementById('editor-area');
|
||||||
@@ -83,11 +110,19 @@ export function setOnChange(cb) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setOnTabSwitch(cb) {
|
export function setOnTabSwitch(cb) {
|
||||||
onTabSwitchCallback = cb;
|
tabSwitchCallbacks.push(cb);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setOnModeChange(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() {
|
export function getActiveEditor() {
|
||||||
@@ -124,20 +159,23 @@ export function setCssType(type) {
|
|||||||
|
|
||||||
function configureJsxSupport(mode) {
|
function configureJsxSupport(mode) {
|
||||||
if (mode === 'react' || mode === 'react-ts') {
|
if (mode === 'react' || mode === 'react-ts') {
|
||||||
// Enable JSX in JavaScript defaults
|
|
||||||
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
|
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
|
||||||
target: monaco.languages.typescript.ScriptTarget.ESNext,
|
target: monaco.languages.typescript.ScriptTarget.ESNext,
|
||||||
jsx: monaco.languages.typescript.JsxEmit.React,
|
jsx: monaco.languages.typescript.JsxEmit.React,
|
||||||
jsxFactory: 'React.createElement',
|
jsxFactory: 'React.createElement',
|
||||||
allowNonTsExtensions: true,
|
allowNonTsExtensions: true,
|
||||||
allowJs: true,
|
allowJs: true,
|
||||||
|
lib: ['esnext', 'dom', 'dom.iterable'],
|
||||||
});
|
});
|
||||||
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
|
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
|
||||||
target: monaco.languages.typescript.ScriptTarget.ESNext,
|
target: monaco.languages.typescript.ScriptTarget.ESNext,
|
||||||
jsx: monaco.languages.typescript.JsxEmit.React,
|
jsx: monaco.languages.typescript.JsxEmit.React,
|
||||||
jsxFactory: 'React.createElement',
|
jsxFactory: 'React.createElement',
|
||||||
allowNonTsExtensions: true,
|
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));
|
btn.addEventListener('click', () => switchTab(tab.id));
|
||||||
bar.appendChild(btn);
|
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') {
|
export function initEditors(mode = 'html-css-js') {
|
||||||
@@ -248,7 +309,7 @@ export function switchMode(mode) {
|
|||||||
|
|
||||||
switchTab(tabs[0].id);
|
switchTab(tabs[0].id);
|
||||||
|
|
||||||
if (onModeChangeCallback) onModeChangeCallback(mode);
|
modeChangeCallbacks.forEach(cb => cb(mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function switchTab(tabId) {
|
export function switchTab(tabId) {
|
||||||
@@ -274,7 +335,7 @@ export function switchTab(tabId) {
|
|||||||
editors[tabId].focus();
|
editors[tabId].focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onTabSwitchCallback) onTabSwitchCallback(tabId, editors[tabId]);
|
tabSwitchCallbacks.forEach(cb => cb(tabId, editors[tabId]));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEditorValues() {
|
export function getEditorValues() {
|
||||||
@@ -294,3 +355,8 @@ export function setEditorValues({ html = '', css = '', js = '' }) {
|
|||||||
export function getActiveTab() {
|
export function getActiveTab() {
|
||||||
return activeTab;
|
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
|
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
|
// 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
|
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'
|
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',
|
keybindings: 'default',
|
||||||
panelSizes: null,
|
panelSizes: null,
|
||||||
previewTheme: 'light',
|
previewTheme: 'light',
|
||||||
|
previewDevice: 'desktop',
|
||||||
|
editorTheme: 'vs-dark',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getPref(key) {
|
export function getPref(key) {
|
||||||
|
|||||||
@@ -28,6 +28,70 @@ const consoleInterceptor = `
|
|||||||
window.onerror = function(msg, url, line, col) {
|
window.onerror = function(msg, url, line, col) {
|
||||||
parent.postMessage({ type: 'console', method: 'error', args: ['Error: ' + msg + ' (line ' + line + ')'] }, '*');
|
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>
|
<\/script>
|
||||||
`;
|
`;
|
||||||
@@ -45,15 +109,16 @@ function escapeScriptClose(code) {
|
|||||||
* followed by an inline <script> for user code.
|
* followed by an inline <script> for user code.
|
||||||
*/
|
*/
|
||||||
function buildLoaderScript(runtimeUrls, userJs, isModule) {
|
function buildLoaderScript(runtimeUrls, userJs, isModule) {
|
||||||
|
const endMarker = 'window.__fiddle_scriptEnd = performance.now();';
|
||||||
if (isModule) {
|
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 = '';
|
let parts = '';
|
||||||
for (const url of runtimeUrls) {
|
for (const url of runtimeUrls) {
|
||||||
parts += `<script src="${url}"><\/script>\n`;
|
parts += `<script src="${url}"><\/script>\n`;
|
||||||
}
|
}
|
||||||
parts += `<script>\n${escapeScriptClose(userJs)}\n<\/script>`;
|
parts += `<script>\n${escapeScriptClose(userJs)}\n${endMarker}\n<\/script>`;
|
||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,9 +167,9 @@ export function renderPreview(html, css, js, mode = 'html-css-js', extraCss = ''
|
|||||||
? buildLoaderScript(runtime.scripts, finalJs, isModule)
|
? buildLoaderScript(runtime.scripts, finalJs, isModule)
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// Tailwind CDN injection
|
// Tailwind CDN injection (suppress production warning)
|
||||||
const tailwindScript = options.tailwind
|
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
|
// 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