Add editor experience features: auto-run, layouts, resizable panels, Emmet, vim/emacs
- Auto-run toggle with localStorage persistence (toolbar checkbox) - Layout selector: default, top/bottom, editor-only, preview-only - Resizable panels via drag dividers with double-click reset - Emmet abbreviation expansion for HTML, CSS, and JSX - Vim and Emacs keybinding modes with lazy-loaded CDN libraries - Shared preferences module for localStorage management - Editor hooks for tab switch and mode change callbacks
This commit is contained in:
@@ -47,11 +47,11 @@ button:hover { background: var(--accent-hover); }
|
|||||||
}
|
}
|
||||||
.btn-small:hover { color: var(--text); background: var(--border); }
|
.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 {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 4px 1fr;
|
||||||
grid-template-rows: 1fr 1fr;
|
grid-template-rows: 1fr 4px 1fr;
|
||||||
height: calc(100vh - var(--toolbar-h));
|
height: calc(100vh - var(--toolbar-h));
|
||||||
}
|
}
|
||||||
.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; }
|
||||||
@@ -65,9 +65,39 @@ button:hover { background: var(--accent-hover); }
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Editor panel — full left column */
|
/* Editor panel — full left column */
|
||||||
.panel-editor { grid-column: 1; grid-row: 1 / 3; display: flex; flex-direction: column; }
|
.panel-editor { grid-column: 1; grid-row: 1 / 4; display: flex; flex-direction: column; }
|
||||||
.panel-preview { grid-column: 2; grid-row: 1; }
|
.divider-col { grid-column: 2; grid-row: 1 / 4; cursor: col-resize; background: var(--border); }
|
||||||
.panel-console { grid-column: 2; grid-row: 2; }
|
.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 */
|
||||||
.tab-bar {
|
.tab-bar {
|
||||||
@@ -139,6 +169,41 @@ button:hover { background: var(--accent-hover); }
|
|||||||
.console-info { color: #3dc9b0; }
|
.console-info { color: #3dc9b0; }
|
||||||
.console-debug { color: #888; }
|
.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 */
|
||||||
.toast {
|
.toast {
|
||||||
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
|
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
|
||||||
|
|||||||
@@ -19,8 +19,23 @@
|
|||||||
<option value="svelte">Svelte</option>
|
<option value="svelte">Svelte</option>
|
||||||
</select>
|
</select>
|
||||||
<input type="text" id="title-input" placeholder="Untitled" spellcheck="false">
|
<input type="text" id="title-input" placeholder="Untitled" spellcheck="false">
|
||||||
|
<select id="layout-mode">
|
||||||
|
<option value="default">Default</option>
|
||||||
|
<option value="top-bottom">Top / Bottom</option>
|
||||||
|
<option value="editor-only">Editor Only</option>
|
||||||
|
<option value="preview-only">Preview Only</option>
|
||||||
|
</select>
|
||||||
|
<select id="keybinding-mode">
|
||||||
|
<option value="default">Default</option>
|
||||||
|
<option value="vim">Vim</option>
|
||||||
|
<option value="emacs">Emacs</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-right">
|
<div class="toolbar-right">
|
||||||
|
<label class="auto-run-toggle" title="Auto-run on change">
|
||||||
|
<input type="checkbox" id="auto-run-checkbox" checked>
|
||||||
|
Auto
|
||||||
|
</label>
|
||||||
<button id="btn-run" title="Run (Ctrl+Enter)">Run</button>
|
<button id="btn-run" title="Run (Ctrl+Enter)">Run</button>
|
||||||
<button id="btn-save" title="Save (Ctrl+S)">Save</button>
|
<button id="btn-save" title="Save (Ctrl+S)">Save</button>
|
||||||
<button id="btn-fork" title="Fork">Fork</button>
|
<button id="btn-fork" title="Fork">Fork</button>
|
||||||
@@ -31,11 +46,14 @@
|
|||||||
<div class="panel panel-editor">
|
<div class="panel panel-editor">
|
||||||
<div class="tab-bar" id="tab-bar"></div>
|
<div class="tab-bar" id="tab-bar"></div>
|
||||||
<div id="editor-area" class="editor-area"></div>
|
<div id="editor-area" class="editor-area"></div>
|
||||||
|
<div id="vim-status-bar" class="vim-status-bar"></div>
|
||||||
</div>
|
</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">Preview</div>
|
||||||
<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 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="panel-label">Console <button id="btn-clear-console" class="btn-small">Clear</button></div>
|
||||||
<div id="console-output"></div>
|
<div id="console-output"></div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
initEditors, switchMode, getEditorValues, setEditorValues,
|
initEditors, switchMode, getEditorValues, setEditorValues,
|
||||||
setOnChange, getCurrentMode, getCssType, setCssType,
|
setOnChange, getCurrentMode, getCssType, setCssType,
|
||||||
|
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';
|
||||||
import { renderPreview } from './preview.js';
|
import { renderPreview } from './preview.js';
|
||||||
@@ -8,6 +9,10 @@ import { initConsole, clearConsole } from './console-panel.js';
|
|||||||
import { compileCss } from './preprocessors.js';
|
import { compileCss } from './preprocessors.js';
|
||||||
import { compileJs } from './js-preprocessors.js';
|
import { compileJs } from './js-preprocessors.js';
|
||||||
import { createFiddle, loadFiddle, updateFiddle } from './api.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 currentId = null;
|
||||||
let debounceTimer = null;
|
let debounceTimer = null;
|
||||||
@@ -67,6 +72,7 @@ async function run() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scheduleRun() {
|
function scheduleRun() {
|
||||||
|
if (!getPref('autoRun')) return;
|
||||||
clearTimeout(debounceTimer);
|
clearTimeout(debounceTimer);
|
||||||
debounceTimer = setTimeout(run, 500);
|
debounceTimer = setTimeout(run, 500);
|
||||||
}
|
}
|
||||||
@@ -155,10 +161,43 @@ function handleModeChange(newMode) {
|
|||||||
scheduleRun();
|
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');
|
initEditors('html-css-js');
|
||||||
setOnChange(scheduleRun);
|
setOnChange(scheduleRun);
|
||||||
initConsole();
|
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
|
// Mode selector
|
||||||
$('#framework-mode').addEventListener('change', (e) => {
|
$('#framework-mode').addEventListener('change', (e) => {
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ 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 onModeChangeCallback = 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');
|
||||||
@@ -69,6 +71,27 @@ export function setOnChange(cb) {
|
|||||||
onChangeCallback = 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() {
|
export function getCurrentMode() {
|
||||||
return currentMode;
|
return currentMode;
|
||||||
}
|
}
|
||||||
@@ -213,6 +236,8 @@ export function switchMode(mode) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
switchTab(tabs[0].id);
|
switchTab(tabs[0].id);
|
||||||
|
|
||||||
|
if (onModeChangeCallback) onModeChangeCallback(mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function switchTab(tabId) {
|
export function switchTab(tabId) {
|
||||||
@@ -237,6 +262,8 @@ export function switchTab(tabId) {
|
|||||||
editors[tabId].layout();
|
editors[tabId].layout();
|
||||||
editors[tabId].focus();
|
editors[tabId].focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (onTabSwitchCallback) onTabSwitchCallback(tabId, editors[tabId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEditorValues() {
|
export function getEditorValues() {
|
||||||
|
|||||||
29
public/js/emmet.js
Normal file
29
public/js/emmet.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
109
public/js/keybindings.js
Normal file
109
public/js/keybindings.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
public/js/preferences.js
Normal file
22
public/js/preferences.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
91
public/js/resizer.js
Normal file
91
public/js/resizer.js
Normal file
@@ -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 = '';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user