1/** 2 * History-panel subsystem â the floating frosted list of labeled 3 * undo/redo entries that hangs off the topbar History button. 4 * 5 * Same docking pattern as the FX adjustment popups: drag the head to 6 * reposition, click the minimise button to dock into the modalManager 7 * chip chain, click the chip to restore. Esc closes. 8 * 9 * @param {{ 10 * undo: () => void, 11 * redo: () => void, 12 * }} deps 13 * 14 * @returns {{ 15 * toggleHistoryPanel: () => void, 16 * refreshHistoryPanelIfOpen: () => void, 17 * jumpToHistory: (offset: number) => void, 18 * }} 19 */ 20import { state } from './state.js'; 21import modalManager from '../modalManager.js'; 22import { HISTORY_ICON, relTime } from './layer-helpers.js'; 23import { historyPanelHTML } from './build/popups.js'; 24 25export function createHistoryPanel({ undo, redo }) { 26 function jumpToHistory(offset) { 27 if (offset === 0) return; 28 if (offset < 0) { 29 for (let i = 0; i < -offset; i++) undo(); 30 } else { 31 for (let i = 0; i < offset; i++) redo(); 32 } 33 } 34 35 function closeHistoryPanel() { 36 if (state.historyPanelEl) { 37 if (state.historyPanelEl._escHandler) { 38 document.removeEventListener('keydown', state.historyPanelEl._escHandler, true); 39 } 40 if (state.historyPanelEl._awayHandler) { 41 document.removeEventListener('pointerdown', state.historyPanelEl._awayHandler, true); 42 } 43 state.historyPanelEl.remove(); 44 state.historyPanelEl = null; 45 } 46 } 47 48 function minimiseHistoryPanel() { 49 if (!state.historyPanelEl) return; 50 const panel = state.historyPanelEl; 51 const r = panel.getBoundingClientRect(); 52 panel._stashLeft = r.left; 53 panel._stashTop = r.top; 54 panel.style.display = 'none'; 55 state.historyPanelEl = null; 56 const modalId = panel._modalId || 'ge-history-panel-min'; 57 panel._modalId = modalId; 58 modalManager.register(modalId, { 59 label: 'History', 60 icon: HISTORY_ICON, 61 restoreFn: () => { 62 panel.style.left = panel._stashLeft + 'px'; 63 panel.style.top = panel._stashTop + 'px'; 64 panel.style.display = ''; 65 state.historyPanelEl = panel; 66 refreshHistoryPanelIfOpen(); 67 }, 68 closeFn: () => { 69 panel.remove(); 70 modalManager.unregister(modalId); 71 }, 72 }); 73 modalManager.minimize(modalId); 74 } 75 76 function toggleHistoryPanel() { 77 if (state.historyPanelEl) { closeHistoryPanel(); return; } 78 const panel = document.createElement('div'); 79 panel.id = 'ge-history-panel'; 80 panel.className = 'ge-frosted'; 81 panel.innerHTML = historyPanelHTML(HISTORY_ICON); 82 document.body.appendChild(panel); 83 state.historyPanelEl = panel; 84 const btn = document.getElementById('ge-history-btn'); 85 if (btn) { 86 const r = btn.getBoundingClientRect(); 87 panel.style.top = (r.bottom + 6) + 'px'; 88 panel.style.left = Math.max(8, r.left) + 'px'; 89 } 90 panel.querySelector('.ge-adj-min').addEventListener('click', minimiseHistoryPanel); 91 // Click anywhere outside the panel (or trigger button) closes it. 92 setTimeout(() => { 93 const onAway = (ev) => { 94 if (!state.historyPanelEl) return; 95 if (state.historyPanelEl.contains(ev.target)) return; 96 if (btn && (ev.target === btn || btn.contains(ev.target))) return; 97 closeHistoryPanel(); 98 document.removeEventListener('pointerdown', onAway, true); 99 }; 100 document.addEventListener('pointerdown', onAway, true); 101 panel._awayHandler = onAway; 102 }, 0); 103 104 const head = panel.querySelector('[data-history-drag]'); 105 head.addEventListener('pointerdown', (e) => { 106 if (e.target.closest('button')) return; 107 e.preventDefault(); 108 const startX = e.clientX, startY = e.clientY; 109 const r0 = panel.getBoundingClientRect(); 110 head.setPointerCapture(e.pointerId); 111 head.style.cursor = 'grabbing'; 112 const onMove = (ev) => { 113 const nx = Math.max(0, Math.min(window.innerWidth - 60, r0.left + (ev.clientX - startX))); 114 const ny = Math.max(0, Math.min(window.innerHeight - 30, r0.top + (ev.clientY - startY))); 115 panel.style.left = nx + 'px'; 116 panel.style.top = ny + 'px'; 117 }; 118 const onUp = () => { 119 head.releasePointerCapture(e.pointerId); 120 head.style.cursor = ''; 121 head.removeEventListener('pointermove', onMove); 122 head.removeEventListener('pointerup', onUp); 123 }; 124 head.addEventListener('pointermove', onMove); 125 head.addEventListener('pointerup', onUp); 126 }); 127 128 const onKey = (ev) => { 129 if (ev.key === 'Escape') { 130 ev.preventDefault(); 131 ev.stopPropagation(); 132 closeHistoryPanel(); 133 } 134 }; 135 document.addEventListener('keydown', onKey, true); 136 panel._escHandler = onKey; 137 138 refreshHistoryPanelIfOpen(); 139 } 140 141 function refreshHistoryPanelIfOpen() { 142 if (!state.historyPanelEl) return; 143 const list = state.historyPanelEl.querySelector('#ge-history-list'); 144 if (!list) return; 145 // Chronological order â oldest at top, latest at bottom. Past 146 // (undo) states first, then Current, then future (redo) states. 147 const rows = []; 148 for (let i = 0; i < state.undoStack.length; i++) { 149 const s = state.undoStack[i]; 150 rows.push({ offset: -(state.undoStack.length - i), label: s._label || 'Edit', ts: s._ts }); 151 } 152 rows.push({ offset: 0, label: 'Current', ts: Date.now(), current: true }); 153 for (let i = state.redoStack.length - 1; i >= 0; i--) { 154 const s = state.redoStack[i]; 155 rows.push({ offset: (state.redoStack.length - i), label: s._label || 'Edit', ts: s._ts, future: true }); 156 } 157 list.innerHTML = rows.map(r => ` 158 <button class="ge-history-row${r.current ? ' current' : ''}${r.future ? ' future' : ''}" data-offset="${r.offset}">
159 <span class="ge-history-row-dot"></span> 160 <span class="ge-history-row-label">${(r.label || '').replace(/[<>&]/g,'')}</span> 161 <span class="ge-history-row-time">${relTime(r.ts)}</span> 162 </button> 163 `).join(''); 164 list.querySelectorAll('.ge-history-row').forEach(btn => { 165 btn.addEventListener('click', () => { 166 const off = parseInt(btn.dataset.offset, 10); 167 jumpToHistory(off); 168 }); 169 }); 170 // Scroll the current marker into view. 171 const cur = list.querySelector('.current'); 172 if (cur) cur.scrollIntoView({ block: 'center' }); 173 } 174 175 return { toggleHistoryPanel, refreshHistoryPanelIfOpen, jumpToHistory }; 176}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.