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 = '';
+}