diff --git a/public/css/style.css b/public/css/style.css index 0d3543e..608f8c8 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -47,11 +47,11 @@ button:hover { background: var(--accent-hover); } } .btn-small:hover { color: var(--text); background: var(--border); } -/* Grid layout — 2 columns: editor | preview+console */ +/* Grid layout — 2 columns with dividers: editor | divider | preview+console */ .grid { display: grid; - grid-template-columns: 1fr 1fr; - grid-template-rows: 1fr 1fr; + grid-template-columns: 1fr 4px 1fr; + grid-template-rows: 1fr 4px 1fr; height: calc(100vh - var(--toolbar-h)); } .panel { position: relative; border: 1px solid var(--border); overflow: hidden; display: flex; flex-direction: column; } @@ -65,9 +65,39 @@ button:hover { background: var(--accent-hover); } } /* Editor panel — full left column */ -.panel-editor { grid-column: 1; grid-row: 1 / 3; display: flex; flex-direction: column; } -.panel-preview { grid-column: 2; grid-row: 1; } -.panel-console { grid-column: 2; grid-row: 2; } +.panel-editor { grid-column: 1; grid-row: 1 / 4; display: flex; flex-direction: column; } +.divider-col { grid-column: 2; grid-row: 1 / 4; cursor: col-resize; background: var(--border); } +.divider-col:hover { background: var(--accent); } +.panel-preview { grid-column: 3; grid-row: 1; } +.divider-row { grid-column: 3; grid-row: 2; cursor: row-resize; background: var(--border); } +.divider-row:hover { background: var(--accent); } +.panel-console { grid-column: 3; grid-row: 3; } + +/* Disable iframe pointer-events while dragging */ +body.resizing iframe { pointer-events: none; } + +/* Layout variants */ +.layout-top-bottom .panel-editor { grid-column: 1 / 4; grid-row: 1; } +.layout-top-bottom .divider-col { display: none; } +.layout-top-bottom .panel-preview { grid-column: 1; grid-row: 3; } +.layout-top-bottom .divider-row { grid-column: 1 / 4; grid-row: 2; cursor: row-resize; } +.layout-top-bottom .panel-console { grid-column: 2 / 4; grid-row: 3; } +.layout-top-bottom { + grid-template-columns: 1fr 4px 1fr; + grid-template-rows: 1fr 4px 1fr; +} + +.layout-editor-only .panel-editor { grid-column: 1 / 4; grid-row: 1 / 4; } +.layout-editor-only .divider-col, +.layout-editor-only .divider-row, +.layout-editor-only .panel-preview, +.layout-editor-only .panel-console { display: none; } + +.layout-preview-only .panel-preview { grid-column: 1 / 4; grid-row: 1 / 4; } +.layout-preview-only .divider-col, +.layout-preview-only .divider-row, +.layout-preview-only .panel-editor, +.layout-preview-only .panel-console { display: none; } /* Tab bar */ .tab-bar { @@ -139,6 +169,41 @@ button:hover { background: var(--accent-hover); } .console-info { color: #3dc9b0; } .console-debug { color: #888; } +/* Vim status bar */ +.vim-status-bar { + display: none; + height: 24px; + line-height: 24px; + padding: 0 10px; + font-family: 'Cascadia Code', 'Fira Code', monospace; + font-size: 12px; + background: var(--surface); + color: var(--text-dim); + border-top: 1px solid var(--border); + flex-shrink: 0; +} + +/* Auto-run toggle */ +.auto-run-toggle { + display: flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: var(--text-dim); + cursor: pointer; + user-select: none; +} +.auto-run-toggle input { cursor: pointer; } + +/* Dividers */ +.divider { background: var(--border); transition: background 0.15s; z-index: 2; } + +/* Layout/keybinding selects — match framework select */ +#layout-mode, #keybinding-mode { + background: var(--bg); color: var(--text); border: 1px solid var(--border); + padding: 4px 6px; border-radius: 4px; font-size: 12px; cursor: pointer; +} + /* Toast */ .toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); diff --git a/public/index.html b/public/index.html index c7f5d34..8f46ebe 100644 --- a/public/index.html +++ b/public/index.html @@ -19,8 +19,23 @@ + +
+ @@ -31,11 +46,14 @@
+
+
Preview
+
Console
diff --git a/public/js/app.js b/public/js/app.js index 0c08e65..a2c53d3 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1,6 +1,7 @@ import { initEditors, switchMode, getEditorValues, setEditorValues, setOnChange, getCurrentMode, getCssType, setCssType, + relayoutEditors, MODE_TABS, MODE_TO_JS_TYPE, JS_TYPE_TO_MODE, } from './editors.js'; import { renderPreview } from './preview.js'; @@ -8,6 +9,10 @@ import { initConsole, clearConsole } from './console-panel.js'; import { compileCss } from './preprocessors.js'; import { compileJs } from './js-preprocessors.js'; import { createFiddle, loadFiddle, updateFiddle } from './api.js'; +import { getPref, setPref } from './preferences.js'; +import { initEmmet } from './emmet.js'; +import { initKeybindings } from './keybindings.js'; +import { initResizer, clearInlineSizes } from './resizer.js'; let currentId = null; let debounceTimer = null; @@ -67,6 +72,7 @@ async function run() { } function scheduleRun() { + if (!getPref('autoRun')) return; clearTimeout(debounceTimer); debounceTimer = setTimeout(run, 500); } @@ -155,10 +161,43 @@ function handleModeChange(newMode) { scheduleRun(); } -function init() { +function applyLayout(layout) { + const grid = $('.grid'); + // Remove all layout classes + grid.classList.remove('layout-top-bottom', 'layout-editor-only', 'layout-preview-only'); + // Clear resizer inline styles when switching layouts + clearInlineSizes(); + + if (layout === 'top-bottom') grid.classList.add('layout-top-bottom'); + else if (layout === 'editor-only') grid.classList.add('layout-editor-only'); + else if (layout === 'preview-only') grid.classList.add('layout-preview-only'); + + setPref('layout', layout); + // Give DOM time to reflow then relayout editors + requestAnimationFrame(() => relayoutEditors()); +} + +async function init() { + // Load Emmet before editors so completion providers are registered + await initEmmet(); + initEditors('html-css-js'); setOnChange(scheduleRun); initConsole(); + initResizer(); + initKeybindings(); + + // Auto-run checkbox + const autoRunCb = $('#auto-run-checkbox'); + autoRunCb.checked = getPref('autoRun'); + autoRunCb.addEventListener('change', (e) => setPref('autoRun', e.target.checked)); + + // Layout selector + const layoutSel = $('#layout-mode'); + const savedLayout = getPref('layout') || 'default'; + layoutSel.value = savedLayout; + if (savedLayout !== 'default') applyLayout(savedLayout); + layoutSel.addEventListener('change', (e) => applyLayout(e.target.value)); // Mode selector $('#framework-mode').addEventListener('change', (e) => { diff --git a/public/js/editors.js b/public/js/editors.js index b4521cb..b48dda8 100644 --- a/public/js/editors.js +++ b/public/js/editors.js @@ -61,6 +61,8 @@ let currentMode = 'html-css-js'; let activeTab = null; let cssType = 'css'; let onChangeCallback = null; +let onTabSwitchCallback = null; +let onModeChangeCallback = null; const tabBar = () => document.getElementById('tab-bar'); const editorArea = () => document.getElementById('editor-area'); @@ -69,6 +71,27 @@ export function setOnChange(cb) { onChangeCallback = cb; } +export function setOnTabSwitch(cb) { + onTabSwitchCallback = cb; +} + +export function setOnModeChange(cb) { + onModeChangeCallback = cb; +} + +export function getActiveEditor() { + if (activeTab && editors[activeTab]) return editors[activeTab]; + return null; +} + +export function relayoutEditors() { + const tabs = MODE_TABS[currentMode]; + if (!tabs) return; + tabs.forEach((tab) => { + if (editors[tab.id]) editors[tab.id].layout(); + }); +} + export function getCurrentMode() { return currentMode; } @@ -213,6 +236,8 @@ export function switchMode(mode) { }); switchTab(tabs[0].id); + + if (onModeChangeCallback) onModeChangeCallback(mode); } export function switchTab(tabId) { @@ -237,6 +262,8 @@ export function switchTab(tabId) { editors[tabId].layout(); editors[tabId].focus(); } + + if (onTabSwitchCallback) onTabSwitchCallback(tabId, editors[tabId]); } export function getEditorValues() { diff --git a/public/js/emmet.js b/public/js/emmet.js new file mode 100644 index 0000000..1b187bb --- /dev/null +++ b/public/js/emmet.js @@ -0,0 +1,29 @@ +let loaded = false; + +function loadScript(src) { + return new Promise((resolve, reject) => { + if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; } + const savedDefine = window.define; + window.define = undefined; + const s = document.createElement('script'); + s.src = src; + s.onload = () => { window.define = savedDefine; resolve(); }; + s.onerror = () => { window.define = savedDefine; reject(new Error(`Failed to load ${src}`)); }; + document.head.appendChild(s); + }); +} + +export async function initEmmet() { + if (loaded) return; + try { + await loadScript('https://unpkg.com/emmet-monaco-es/dist/emmet-monaco.min.js'); + if (window.emmetMonaco) { + emmetMonaco.emmetHTML(monaco); + emmetMonaco.emmetCSS(monaco); + emmetMonaco.emmetJSX(monaco); + loaded = true; + } + } catch (e) { + console.warn('Emmet load failed:', e.message); + } +} diff --git a/public/js/keybindings.js b/public/js/keybindings.js new file mode 100644 index 0000000..21ae58c --- /dev/null +++ b/public/js/keybindings.js @@ -0,0 +1,109 @@ +import { getPref, setPref } from './preferences.js'; +import { getActiveEditor, setOnTabSwitch, setOnModeChange } from './editors.js'; + +let currentMode = 'default'; // 'default' | 'vim' | 'emacs' +let activeAdapter = null; // vim or emacs adapter instance +let vimLoaded = false; +let emacsLoaded = false; + +const statusBar = () => document.getElementById('vim-status-bar'); + +function loadScript(src) { + return new Promise((resolve, reject) => { + if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; } + const savedDefine = window.define; + window.define = undefined; + const s = document.createElement('script'); + s.src = src; + s.onload = () => { window.define = savedDefine; resolve(); }; + s.onerror = () => { window.define = savedDefine; reject(new Error(`Failed to load ${src}`)); }; + document.head.appendChild(s); + }); +} + +async function ensureVim() { + if (vimLoaded) return; + window.monaco = monaco; + await loadScript('https://unpkg.com/monaco-vim@0.4.2/dist/monaco-vim.js'); + vimLoaded = true; +} + +async function ensureEmacs() { + if (emacsLoaded) return; + window.monaco = monaco; + await loadScript('https://unpkg.com/monaco-emacs/dist/monaco-emacs.js'); + emacsLoaded = true; +} + +function disposeAdapter() { + if (activeAdapter) { + activeAdapter.dispose(); + activeAdapter = null; + } + const bar = statusBar(); + if (bar) { + bar.style.display = 'none'; + bar.textContent = ''; + } +} + +async function attachToEditor(editor) { + if (!editor) return; + disposeAdapter(); + + if (currentMode === 'vim') { + await ensureVim(); + const bar = statusBar(); + if (bar) bar.style.display = 'block'; + activeAdapter = MonacoVim.initVimMode(editor, bar); + } else if (currentMode === 'emacs') { + await ensureEmacs(); + const EmacsExtension = MonacoEmacs.EmacsExtension; + activeAdapter = new EmacsExtension(editor); + activeAdapter.start(); + } +} + +export async function setKeybindingMode(mode) { + disposeAdapter(); + currentMode = mode; + setPref('keybindings', mode); + + if (mode === 'default') return; + + const editor = getActiveEditor(); + if (editor) await attachToEditor(editor); +} + +export function initKeybindings() { + currentMode = getPref('keybindings') || 'default'; + + const select = document.getElementById('keybinding-mode'); + if (select) { + select.value = currentMode; + select.addEventListener('change', (e) => setKeybindingMode(e.target.value)); + } + + // Reattach on tab switch + setOnTabSwitch((_tabId, editor) => { + if (currentMode !== 'default' && editor) { + attachToEditor(editor); + } + }); + + // Reattach on mode change + setOnModeChange(() => { + if (currentMode !== 'default') { + setTimeout(() => { + const editor = getActiveEditor(); + if (editor) attachToEditor(editor); + }, 50); + } + }); + + // Initial attach if non-default + if (currentMode !== 'default') { + const editor = getActiveEditor(); + if (editor) attachToEditor(editor); + } +} diff --git a/public/js/preferences.js b/public/js/preferences.js new file mode 100644 index 0000000..5d98e1a --- /dev/null +++ b/public/js/preferences.js @@ -0,0 +1,22 @@ +const PREFIX = 'fiddle_'; + +const DEFAULTS = { + autoRun: true, + layout: 'default', + keybindings: 'default', + panelSizes: null, +}; + +export function getPref(key) { + const raw = localStorage.getItem(PREFIX + key); + if (raw === null) return DEFAULTS[key] ?? null; + try { return JSON.parse(raw); } catch { return raw; } +} + +export function setPref(key, value) { + localStorage.setItem(PREFIX + key, JSON.stringify(value)); +} + +export function removePref(key) { + localStorage.removeItem(PREFIX + key); +} diff --git a/public/js/resizer.js b/public/js/resizer.js new file mode 100644 index 0000000..f1865b9 --- /dev/null +++ b/public/js/resizer.js @@ -0,0 +1,91 @@ +import { getPref, setPref } from './preferences.js'; +import { relayoutEditors } from './editors.js'; + +let grid = null; +let colDivider = null; +let rowDivider = null; +let dragging = null; // 'col' | 'row' | null + +export function initResizer() { + grid = document.querySelector('.grid'); + colDivider = document.getElementById('divider-col'); + rowDivider = document.getElementById('divider-row'); + + if (!grid || !colDivider || !rowDivider) return; + + // Restore saved sizes + const saved = getPref('panelSizes'); + if (saved) applySizes(saved); + + // Column divider (horizontal drag) + colDivider.addEventListener('mousedown', (e) => startDrag(e, 'col')); + colDivider.addEventListener('dblclick', () => resetSizes()); + + // Row divider (vertical drag) + rowDivider.addEventListener('mousedown', (e) => startDrag(e, 'row')); + rowDivider.addEventListener('dblclick', () => resetSizes()); + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); +} + +function startDrag(e, type) { + e.preventDefault(); + dragging = type; + document.body.classList.add('resizing'); +} + +function onMouseMove(e) { + if (!dragging) return; + + const rect = grid.getBoundingClientRect(); + + if (dragging === 'col') { + const x = e.clientX - rect.left; + const total = rect.width; + const pct = Math.max(0.15, Math.min(0.85, x / total)); + const left = pct; + const right = 1 - pct; + grid.style.gridTemplateColumns = `${left}fr 4px ${right}fr`; + } else if (dragging === 'row') { + const y = e.clientY - rect.top; + const total = rect.height; + const pct = Math.max(0.15, Math.min(0.85, y / total)); + const top = pct; + const bottom = 1 - pct; + grid.style.gridTemplateRows = `${top}fr 4px ${bottom}fr`; + } + + relayoutEditors(); +} + +function onMouseUp() { + if (!dragging) return; + dragging = null; + document.body.classList.remove('resizing'); + + // Persist current sizes + const sizes = { + cols: grid.style.gridTemplateColumns || null, + rows: grid.style.gridTemplateRows || null, + }; + setPref('panelSizes', sizes); + relayoutEditors(); +} + +function applySizes(sizes) { + if (sizes.cols) grid.style.gridTemplateColumns = sizes.cols; + if (sizes.rows) grid.style.gridTemplateRows = sizes.rows; +} + +export function resetSizes() { + grid.style.gridTemplateColumns = ''; + grid.style.gridTemplateRows = ''; + setPref('panelSizes', null); + relayoutEditors(); +} + +export function clearInlineSizes() { + grid.style.gridTemplateColumns = ''; + grid.style.gridTemplateRows = ''; +}