diff --git a/docs/public/editor/editor.js b/docs/public/editor/editor.js index bd78d3bf4c4..220168570fb 100644 --- a/docs/public/editor/editor.js +++ b/docs/public/editor/editor.js @@ -125,6 +125,9 @@ const divider = $('divider'); const panelEditor = $('panelEditor'); const panelOutput = $('panelOutput'); const panels = $('panels'); +const tabBar = $('tabBar'); +const tabStatusDot = $('tabStatusDot'); +const fabCompile = $('fabCompile'); // --------------------------------------------------------------- // State @@ -136,6 +139,10 @@ let isCompiling = false; let compileTimer = null; let currentYaml = ''; let pendingCompile = false; +let activeTab = 'editor'; // 'editor' | 'output' +let outputIsStale = false; // true when editor changed since last compile +let lastCompileStatus = 'ok'; // 'ok' | 'error' +let isDragging = false; // divider drag state (used by both divider + swipe logic) // --------------------------------------------------------------- // Theme — follows browser's prefers-color-scheme automatically. @@ -182,6 +189,9 @@ const editorView = new EditorView({ if (update.docChanged) { try { localStorage.setItem(STORAGE_KEY, update.state.doc.toString()); } catch (_) { /* localStorage full or unavailable */ } + // Mark output as stale (editor changed since last compile) + outputIsStale = true; + updateTabStatusDot(); if (isReady) { scheduleCompile(); } else { @@ -356,6 +366,7 @@ async function doCompile() { isCompiling = true; setStatus('compiling', 'Compiling...'); + if (fabCompile) fabCompile.classList.add('compiling'); // Hide old banners errorBanner.classList.add('d-none'); @@ -366,10 +377,15 @@ async function doCompile() { if (result.error) { setStatus('error', 'Error'); + lastCompileStatus = 'error'; + updateTabStatusDot(); errorText.textContent = result.error; errorBanner.classList.remove('d-none'); } else { setStatus('ready', 'Ready'); + lastCompileStatus = 'ok'; + outputIsStale = false; + updateTabStatusDot(); currentYaml = result.yaml; // Update output CodeMirror view @@ -387,10 +403,13 @@ async function doCompile() { } } catch (err) { setStatus('error', 'Error'); + lastCompileStatus = 'error'; + updateTabStatusDot(); errorText.textContent = err.message || String(err); errorBanner.classList.remove('d-none'); } finally { isCompiling = false; + if (fabCompile) fabCompile.classList.remove('compiling'); } } @@ -401,10 +420,112 @@ $('errorClose').addEventListener('click', () => errorBanner.classList.add('d-non $('warningClose').addEventListener('click', () => warningBanner.classList.add('d-none')); // --------------------------------------------------------------- -// Draggable divider +// Mobile: Tab-based layout // --------------------------------------------------------------- -let isDragging = false; +const mobileMq = window.matchMedia('(max-width: 767px)'); + +/** Check if currently in mobile layout */ +function isMobileLayout() { + return mobileMq.matches; +} + +/** Switch the active mobile tab */ +function switchTab(tab) { + activeTab = tab; + + // Update tab button states + tabBar.querySelectorAll('.tab-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.panel === tab); + }); + + // Show/hide panels + if (tab === 'editor') { + panelEditor.style.display = ''; + panelOutput.style.display = 'none'; + } else { + panelEditor.style.display = 'none'; + panelOutput.style.display = ''; + } +} + +/** Update the status dot on the Output tab */ +function updateTabStatusDot() { + if (!tabStatusDot) return; + if (lastCompileStatus === 'error') { + tabStatusDot.setAttribute('data-stale', 'error'); + } else if (outputIsStale) { + tabStatusDot.setAttribute('data-stale', 'true'); + } else { + tabStatusDot.removeAttribute('data-stale'); + } +} + +/** Apply or revert mobile layout depending on viewport width */ +function applyResponsiveLayout() { + if (isMobileLayout()) { + // Enter mobile mode: show only the active tab's panel + switchTab(activeTab); + } else { + // Exit mobile mode: show both panels, restore flex + panelEditor.style.display = ''; + panelOutput.style.display = ''; + panelEditor.style.flex = ''; + panelOutput.style.flex = ''; + } +} + +// Tab button click handlers +tabBar.addEventListener('click', (e) => { + const btn = e.target.closest('.tab-btn'); + if (!btn || !isMobileLayout()) return; + switchTab(btn.dataset.panel); +}); + +// FAB compile button +fabCompile.addEventListener('click', () => { + doCompile(); +}); +// Swipe gesture support on panels container +let touchStartX = 0; +let touchStartY = 0; +let touchStartTime = 0; + +panels.addEventListener('touchstart', (e) => { + // Only handle swipe gestures in mobile tab mode and when not dragging the divider + if (!isMobileLayout() || isDragging) return; + touchStartX = e.touches[0].clientX; + touchStartY = e.touches[0].clientY; + touchStartTime = Date.now(); +}, { passive: true }); + +panels.addEventListener('touchend', (e) => { + if (!isMobileLayout() || isDragging) return; + const dx = e.changedTouches[0].clientX - touchStartX; + const dy = e.changedTouches[0].clientY - touchStartY; + const dt = Date.now() - touchStartTime; + + // Require: horizontal distance > 50px, more horizontal than vertical, within 500ms + if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 && dt < 500) { + if (dx < 0 && activeTab === 'editor') { + // Swipe left: go to Output + switchTab('output'); + } else if (dx > 0 && activeTab === 'output') { + // Swipe right: go to Editor + switchTab('editor'); + } + } +}, { passive: true }); + +// Listen for viewport changes (e.g., device rotation, window resize) +mobileMq.addEventListener('change', () => applyResponsiveLayout()); + +// Apply on initial load +applyResponsiveLayout(); + +// --------------------------------------------------------------- +// Draggable divider +// --------------------------------------------------------------- divider.addEventListener('mousedown', (e) => { isDragging = true; divider.classList.add('dragging'); diff --git a/docs/public/editor/index.html b/docs/public/editor/index.html index 52363521c92..7ef0bddc406 100644 --- a/docs/public/editor/index.html +++ b/docs/public/editor/index.html @@ -115,12 +115,103 @@ 50% { opacity: 0.35; } } +/* Mobile tab bar */ +.tab-bar { + border-bottom: 1px solid var(--borderColor-default, var(--color-border-default)); + background: var(--bgColor-default, var(--color-canvas-default)); + z-index: 9; +} +.tab-btn { + flex: 1; + padding: 8px 12px; + border: none; + background: none; + font-size: 13px; + font-weight: 600; + color: var(--fgColor-muted, var(--color-fg-muted)); + cursor: pointer; + position: relative; + text-transform: uppercase; + letter-spacing: 0.5px; + transition: color 150ms ease; +} +.tab-btn.active { + color: var(--fgColor-accent, var(--color-accent-fg)); +} +.tab-btn.active::after { + content: ''; + position: absolute; + bottom: 0; + left: 12px; + right: 12px; + height: 2px; + background: var(--fgColor-accent, var(--color-accent-fg)); + border-radius: 2px 2px 0 0; +} + +/* Tab status dot (on Output tab) */ +.tab-status-dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + margin-left: 6px; + vertical-align: middle; + background: var(--fgColor-success, #1a7f37); + transition: background 200ms ease; +} +.tab-status-dot[data-stale="true"] { + background: var(--fgColor-attention, #9a6700); +} +.tab-status-dot[data-stale="error"] { + background: var(--fgColor-danger, #cf222e); +} + +/* Floating Action Button for compile */ +.fab-compile { + position: fixed; + bottom: 20px; + right: 20px; + width: 52px; + height: 52px; + border-radius: 50%; + border: none; + background: var(--bgColor-accent-emphasis, var(--color-accent-emphasis, #0969da)); + color: var(--fgColor-onEmphasis, #ffffff); + font-size: 20px; + cursor: pointer; + box-shadow: 0 3px 12px rgba(0,0,0,0.28); + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + transition: transform 120ms ease, box-shadow 120ms ease; + -webkit-tap-highlight-color: transparent; +} +.fab-compile:active { + transform: scale(0.92); + box-shadow: 0 1px 6px rgba(0,0,0,0.25); +} +.fab-compile.compiling { + opacity: 0.6; + pointer-events: none; +} + /* Responsive */ @media (max-width: 767px) { .panels-container { flex-direction: column !important; } - .divider { width: 100%; height: 4px; cursor: row-resize; } + .divider { display: none !important; } .header-bar { gap: 8px !important; padding: 8px 12px !important; flex-wrap: wrap; height: auto !important; min-height: 48px !important; } .header-separator { display: none !important; } + .tab-bar { display: flex !important; } + .fab-compile { display: flex !important; } + footer { display: none !important; } + /* Panel headers are redundant when tabs are visible */ + .mobile-hidden-header { display: none !important; } +} +@media (min-width: 768px) { + .tab-bar { display: none !important; } + .fab-compile { display: none !important; } } @@ -178,11 +269,20 @@ + +
+ + +
+
-
+
@@ -197,7 +297,7 @@
-
+
@@ -234,6 +334,11 @@ Security
+ + +