Skip to content

Commit 3e527fa

Browse files
mbreiserclaude
andcommitted
feat: undo/redo and reset for Experiment Designer (#54)
- Snapshot-based undo/redo history (max 50 steps) for all edits - Ctrl+Z/Cmd+Z undo, Ctrl+Y/Cmd+Y/Ctrl+Shift+Z redo (outside text inputs) - Header buttons with proper disabled states - Text inputs snapshot on focus (one undo step per field visit) - All structural mutations instrumented (conditions, commands, plugins, phases) - YAML import is undoable - Reset button (red, in tab bar) clears protocol but keeps settings - Reset shows confirmation dialog, clears undo/redo stacks - Fix: syncSettingsToUI now restores randomization seed value - Fix: selection clamped on restore to prevent out-of-bounds crash - Fix: _restoring guard with try/finally prevents focus events from polluting redo stack during snapshot restore Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 0a5c5a2 commit 3e527fa

1 file changed

Lines changed: 163 additions & 5 deletions

File tree

experiment_designer.html

Lines changed: 163 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,22 @@
121121
padding: 0.3rem 0.6rem;
122122
}
123123

124+
.btn-undo { font-size: 0.75rem; padding: 0.3rem 0.6rem; }
125+
.btn-undo:disabled { opacity: 0.35; cursor: default; border-color: var(--border); color: var(--text-dim); }
126+
.btn-undo:disabled:hover { border-color: var(--border); color: var(--text-dim); }
127+
128+
.btn-reset {
129+
margin-left: auto;
130+
padding: 0.2rem 0.6rem;
131+
font-size: 0.75rem;
132+
background: transparent;
133+
color: #ff6b6b;
134+
border: 1px solid #ff6b6b;
135+
border-radius: 4px;
136+
cursor: pointer;
137+
}
138+
.btn-reset:hover { background: rgba(255,107,107,0.15); }
139+
124140
/* ── Main Content ────────────────────────── */
125141
.main-content {
126142
display: flex;
@@ -989,6 +1005,8 @@
9891005
<h1>Experiment Designer</h1>
9901006
</div>
9911007
<div class="top-bar-right">
1008+
<button class="btn btn-undo" id="undoBtn" disabled title="Undo last edit (Ctrl+Z)">&#x21a9; Undo</button>
1009+
<button class="btn btn-undo" id="redoBtn" disabled title="Redo (Ctrl+Y / Ctrl+Shift+Z)">&#x21aa; Redo</button>
9921010
<a href="experiment_designer_quickstart.html" target="_blank" class="btn" title="Step-by-step guide for using the Experiment Designer">Quick Start</a>
9931011
<button class="btn" id="importBtn" title="Load an existing YAML protocol file">Import YAML</button>
9941012
<button class="btn btn-primary" id="exportBtn" title="Export experiment as YAML protocol file (Ctrl+E)">Export YAML</button>
@@ -1107,6 +1125,7 @@ <h1>Experiment Designer</h1>
11071125
<div class="editor-tab-bar" id="editorTabBar">
11081126
<button class="editor-tab active" data-tab="commands" title="Command card editor for the selected condition/phase">Commands</button>
11091127
<button class="editor-tab" data-tab="table" title="Spreadsheet view of all commands across all sections">Table</button>
1128+
<button class="btn-reset" id="resetBtn" title="Clear all conditions, phases, and plugins">Reset</button>
11101129
</div>
11111130
<div class="editor-tab-content" id="editorTabCommands">
11121131
<div class="editor-placeholder" id="editorPlaceholder">
@@ -1150,7 +1169,7 @@ <h1>Experiment Designer</h1>
11501169

11511170
<!-- Footer -->
11521171
<div class="app-footer">
1153-
<p><a href="https://github.com/reiserlab/webDisplayTools" target="_blank">Reiser Lab</a> | Experiment Designer v0.8.3 | 2026-04-09 23:57 ET</p>
1172+
<p><a href="https://github.com/reiserlab/webDisplayTools" target="_blank">Reiser Lab</a> | Experiment Designer v0.9 | 2026-04-10 16:31 ET</p>
11541173
</div>
11551174

11561175
<!-- Hidden file input for import -->
@@ -1216,6 +1235,79 @@ <h1>Experiment Designer</h1>
12161235
// Timeline zoom: pixels per second
12171236
let pxPerSecond = 40;
12181237

1238+
// Undo/redo history (#54)
1239+
var undoStack = [];
1240+
var redoStack = [];
1241+
var MAX_HISTORY = 50;
1242+
var _restoring = false;
1243+
1244+
function saveSnapshot() {
1245+
if (_restoring) return;
1246+
undoStack.push(JSON.stringify(experiment));
1247+
if (undoStack.length > MAX_HISTORY) undoStack.shift();
1248+
redoStack = [];
1249+
updateUndoRedoUI();
1250+
}
1251+
1252+
function restoreSnapshot(json) {
1253+
_restoring = true;
1254+
try {
1255+
experiment = JSON.parse(json);
1256+
// Clamp selection to valid bounds
1257+
if (selection && selection.type === 'condition') {
1258+
if (selection.index >= experiment.conditions.length) {
1259+
selection = experiment.conditions.length > 0
1260+
? { type: 'condition', index: experiment.conditions.length - 1 }
1261+
: null;
1262+
}
1263+
}
1264+
syncSettingsToUI();
1265+
renderTimeline();
1266+
renderEditor();
1267+
updateSummary();
1268+
} finally {
1269+
_restoring = false;
1270+
}
1271+
}
1272+
1273+
function undo() {
1274+
if (!undoStack.length) return;
1275+
redoStack.push(JSON.stringify(experiment));
1276+
restoreSnapshot(undoStack.pop());
1277+
updateUndoRedoUI();
1278+
}
1279+
1280+
function redo() {
1281+
if (!redoStack.length) return;
1282+
undoStack.push(JSON.stringify(experiment));
1283+
restoreSnapshot(redoStack.pop());
1284+
updateUndoRedoUI();
1285+
}
1286+
1287+
function updateUndoRedoUI() {
1288+
var undoBtn = document.getElementById('undoBtn');
1289+
var redoBtn = document.getElementById('redoBtn');
1290+
if (undoBtn) undoBtn.disabled = undoStack.length === 0;
1291+
if (redoBtn) redoBtn.disabled = redoStack.length === 0;
1292+
}
1293+
1294+
function resetProtocol() {
1295+
if (!confirm('Reset protocol?\n\nThis will clear all conditions, phases, and plugins. Settings (name, author, arena) will be kept.\n\nThis action cannot be undone.')) return;
1296+
experiment.plugins = [];
1297+
experiment.conditions = [{ id: 'condition_1', commands: cloneCommands(DEFAULT_CONDITION.commands) }];
1298+
experiment.pretrial = { include: false, commands: cloneCommands(DEFAULT_PHASE.commands) };
1299+
experiment.intertrial = { include: false, commands: cloneCommands(DEFAULT_PHASE.commands) };
1300+
experiment.posttrial = { include: false, commands: cloneCommands(DEFAULT_PHASE.commands) };
1301+
undoStack = [];
1302+
redoStack = [];
1303+
selection = null;
1304+
syncSettingsToUI();
1305+
renderTimeline();
1306+
renderEditor();
1307+
updateSummary();
1308+
updateUndoRedoUI();
1309+
}
1310+
12191311
// ════════════════════════════════════════════════════
12201312
// Command Array Helpers
12211313
// ════════════════════════════════════════════════════
@@ -1401,6 +1493,8 @@ <h1>Experiment Designer</h1>
14011493
document.getElementById('randomize').checked = experiment.experiment_structure.randomization.enabled;
14021494
document.getElementById('seedField').style.display =
14031495
experiment.experiment_structure.randomization.enabled ? '' : 'none';
1496+
document.getElementById('seed').value =
1497+
experiment.experiment_structure.randomization.seed != null ? experiment.experiment_structure.randomization.seed : '';
14041498
document.getElementById('pretrialEnabled').checked = experiment.pretrial.include;
14051499
document.getElementById('intertrialEnabled').checked = experiment.intertrial.include;
14061500
document.getElementById('posttrialEnabled').checked = experiment.posttrial.include;
@@ -1443,6 +1537,12 @@ <h1>Experiment Designer</h1>
14431537
// ════════════════════════════════════════════════════
14441538

14451539
function bindSettingsEvents() {
1540+
// Snapshot on focus for text inputs — one undo step per field visit
1541+
['expName', 'expAuthor', 'patternLibrary', 'rigPath', 'repetitions', 'seed',
1542+
'backlightPort', 'cameraIp', 'cameraPort', 'cameraFrameRate'].forEach(function(id) {
1543+
document.getElementById(id).addEventListener('focus', saveSnapshot);
1544+
});
1545+
14461546
document.getElementById('expName').addEventListener('input', function(e) {
14471547
experiment.experiment_info.name = e.target.value;
14481548
});
@@ -1454,6 +1554,7 @@ <h1>Experiment Designer</h1>
14541554
});
14551555

14561556
document.getElementById('arenaConfig').addEventListener('change', function(e) {
1557+
saveSnapshot();
14571558
experiment.arena_config_key = e.target.value;
14581559
var config = getConfig(e.target.value);
14591560
if (config) {
@@ -1475,6 +1576,7 @@ <h1>Experiment Designer</h1>
14751576
});
14761577

14771578
document.getElementById('randomize').addEventListener('change', function(e) {
1579+
saveSnapshot();
14781580
experiment.experiment_structure.randomization.enabled = e.target.checked;
14791581
document.getElementById('seedField').style.display = e.target.checked ? '' : 'none';
14801582
});
@@ -1487,6 +1589,7 @@ <h1>Experiment Designer</h1>
14871589
var phases = ['pretrial', 'intertrial', 'posttrial'];
14881590
phases.forEach(function(phase) {
14891591
document.getElementById(phase + 'Enabled').addEventListener('change', function(e) {
1592+
saveSnapshot();
14901593
experiment[phase].include = e.target.checked;
14911594
updatePhaseToggleStyle(phase + 'Toggle', e.target.checked);
14921595
renderTimeline();
@@ -1505,10 +1608,12 @@ <h1>Experiment Designer</h1>
15051608

15061609
// Plugin checkboxes
15071610
document.getElementById('pluginBacklight').addEventListener('change', function(e) {
1611+
saveSnapshot();
15081612
togglePlugin('backlight', e.target.checked);
15091613
document.getElementById('pluginBacklightConfig').style.display = e.target.checked ? '' : 'none';
15101614
});
15111615
document.getElementById('pluginCamera').addEventListener('change', function(e) {
1616+
saveSnapshot();
15121617
togglePlugin('camera', e.target.checked);
15131618
document.getElementById('pluginCameraConfig').style.display = e.target.checked ? '' : 'none';
15141619
});
@@ -1538,6 +1643,7 @@ <h1>Experiment Designer</h1>
15381643
setPluginConfig('camera', 'port', val === '' ? '' : (parseInt(val) || ''));
15391644
});
15401645
document.getElementById('cameraFormat').addEventListener('change', function(e) {
1646+
saveSnapshot();
15411647
setPluginConfig('camera', 'video_format', e.target.value);
15421648
});
15431649
document.getElementById('cameraFrameRate').addEventListener('input', function(e) {
@@ -1648,6 +1754,7 @@ <h1>Experiment Designer</h1>
16481754
// ════════════════════════════════════════════════════
16491755

16501756
function addCondition() {
1757+
saveSnapshot();
16511758
var n = experiment.conditions.length + 1;
16521759
experiment.conditions.push({
16531760
id: 'condition_' + n,
@@ -1661,6 +1768,7 @@ <h1>Experiment Designer</h1>
16611768

16621769
function removeCondition(index) {
16631770
if (experiment.conditions.length <= 1) return;
1771+
saveSnapshot();
16641772
experiment.conditions.splice(index, 1);
16651773
if (selection && selection.type === 'condition') {
16661774
if (selection.index === index) {
@@ -1675,6 +1783,7 @@ <h1>Experiment Designer</h1>
16751783
}
16761784

16771785
function reorderConditions(fromIndex, toIndex) {
1786+
saveSnapshot();
16781787
var moved = experiment.conditions.splice(fromIndex, 1)[0];
16791788
experiment.conditions.splice(toIndex, 0, moved);
16801789
if (selection && selection.type === 'condition') {
@@ -2286,7 +2395,8 @@ <h1>Experiment Designer</h1>
22862395

22872396
content.innerHTML = html;
22882397

2289-
// Bind ID
2398+
// Bind ID — snapshot on focus
2399+
document.getElementById('condId').addEventListener('focus', saveSnapshot);
22902400
document.getElementById('condId').addEventListener('input', function(e) {
22912401
cond.id = e.target.value;
22922402
renderTimeline();
@@ -2302,6 +2412,7 @@ <h1>Experiment Designer</h1>
23022412
// Bind Add Command
23032413
document.getElementById('addCommandSelect').addEventListener('change', function(e) {
23042414
if (!e.target.value) return;
2415+
saveSnapshot();
23052416
var newCmd = createCommandFromSelectValue(e.target.value);
23062417
if (newCmd) cond.commands.push(newCmd);
23072418
e.target.value = '';
@@ -2381,6 +2492,14 @@ <h1>Experiment Designer</h1>
23812492
* Bind events for command card fields.
23822493
*/
23832494
function bindCommandCardEvents(commands, onChange) {
2495+
// Snapshot on focus for text/number fields
2496+
document.querySelectorAll('.cmd-field').forEach(function(el) {
2497+
if (el.tagName !== 'SELECT') el.addEventListener('focus', saveSnapshot);
2498+
});
2499+
document.querySelectorAll('.cmd-param-field').forEach(function(el) {
2500+
el.addEventListener('focus', saveSnapshot);
2501+
});
2502+
23842503
// Field edits
23852504
document.querySelectorAll('.cmd-field').forEach(function(el) {
23862505
var eventType = el.tagName === 'SELECT' ? 'change' : 'input';
@@ -2391,6 +2510,7 @@ <h1>Experiment Designer</h1>
23912510
if (!cmd) return;
23922511

23932512
if (field === 'mode') {
2513+
saveSnapshot();
23942514
cmd.mode = parseInt(this.value);
23952515
if (cmd.mode === 2) { cmd.gain = 0; } else { cmd.frame_rate = 0; }
23962516
renderEditor(); // re-render to update disabled fields
@@ -2433,6 +2553,7 @@ <h1>Experiment Designer</h1>
24332553
e.stopPropagation();
24342554
var ci = parseInt(this.dataset.cmd);
24352555
if (commands.length > 1) {
2556+
saveSnapshot();
24362557
commands.splice(ci, 1);
24372558
renderEditor();
24382559
if (onChange) onChange();
@@ -2448,6 +2569,7 @@ <h1>Experiment Designer</h1>
24482569
var dir = this.dataset.dir;
24492570
var target = dir === 'up' ? ci - 1 : ci + 1;
24502571
if (target >= 0 && target < commands.length) {
2572+
saveSnapshot();
24512573
var tmp = commands[ci];
24522574
commands[ci] = commands[target];
24532575
commands[target] = tmp;
@@ -2490,6 +2612,7 @@ <h1>Experiment Designer</h1>
24902612
// Bind Add Command
24912613
document.getElementById('addCommandSelect').addEventListener('change', function(e) {
24922614
if (!e.target.value) return;
2615+
saveSnapshot();
24932616
var newCmd = createCommandFromSelectValue(e.target.value);
24942617
if (newCmd) phase.commands.push(newCmd);
24952618
e.target.value = '';
@@ -2829,7 +2952,14 @@ <h1>Experiment Designer</h1>
28292952
});
28302953
});
28312954

2832-
// Editable table fields
2955+
// Editable table fields — snapshot on focus for text/number inputs
2956+
document.querySelectorAll('.tbl-field').forEach(function(el) {
2957+
if (el.tagName !== 'SELECT') el.addEventListener('focus', saveSnapshot);
2958+
});
2959+
document.querySelectorAll('.tbl-param').forEach(function(el) {
2960+
el.addEventListener('focus', saveSnapshot);
2961+
});
2962+
28332963
document.querySelectorAll('.tbl-field').forEach(function(el) {
28342964
var eventType = el.tagName === 'SELECT' ? 'change' : 'input';
28352965
el.addEventListener(eventType, function(e) {
@@ -2843,6 +2973,7 @@ <h1>Experiment Designer</h1>
28432973
if (!cmd) return;
28442974

28452975
if (field === 'mode') {
2976+
saveSnapshot();
28462977
cmd.mode = parseInt(this.value);
28472978
if (cmd.mode === 2) { cmd.gain = 0; } else { cmd.frame_rate = 0; }
28482979
renderTableView(); // re-render to swap FR/gain field
@@ -2894,6 +3025,7 @@ <h1>Experiment Designer</h1>
28943025
var rowIndex = parseInt(this.getAttribute('data-row'));
28953026
var section = sections.find(function(s) { return s.key === sectionKey; });
28963027
if (section && section.commands.length > 1 && rowIndex < section.commands.length) {
3028+
saveSnapshot();
28973029
section.commands.splice(rowIndex, 1);
28983030
renderTableView();
28993031
renderTimeline();
@@ -2912,6 +3044,7 @@ <h1>Experiment Designer</h1>
29123044
var target = dir === 'up' ? rowIndex - 1 : rowIndex + 1;
29133045
var section = sections.find(function(s) { return s.key === sectionKey; });
29143046
if (section && target >= 0 && target < section.commands.length) {
3047+
saveSnapshot();
29153048
var tmp = section.commands[rowIndex];
29163049
section.commands[rowIndex] = section.commands[target];
29173050
section.commands[target] = tmp;
@@ -2931,6 +3064,7 @@ <h1>Experiment Designer</h1>
29313064
if (section) {
29323065
var newCmd = createCommandFromSelectValue(e.target.value);
29333066
if (newCmd) {
3067+
saveSnapshot();
29343068
section.commands.push(newCmd);
29353069
renderTableView();
29363070
renderTimeline();
@@ -3198,6 +3332,7 @@ <h1>Experiment Designer</h1>
31983332
var reader = new FileReader();
31993333
reader.onload = function(evt) {
32003334
try {
3335+
saveSnapshot();
32013336
parseAndLoadYAML(evt.target.result);
32023337
} catch (err) {
32033338
alert('Failed to parse YAML: ' + err.message);
@@ -3350,15 +3485,34 @@ <h1>Experiment Designer</h1>
33503485
// ════════════════════════════════════════════════════
33513486

33523487
document.addEventListener('keydown', function(e) {
3488+
var inInput = document.activeElement.tagName === 'INPUT' ||
3489+
document.activeElement.tagName === 'TEXTAREA' ||
3490+
document.activeElement.tagName === 'SELECT';
3491+
3492+
// Undo: Ctrl+Z / Cmd+Z (only when not in a text input)
3493+
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey && !inInput) {
3494+
e.preventDefault();
3495+
undo();
3496+
return;
3497+
}
3498+
// Redo: Ctrl+Y / Cmd+Y / Ctrl+Shift+Z / Cmd+Shift+Z (only when not in a text input)
3499+
if (!inInput && (((e.ctrlKey || e.metaKey) && e.key === 'y') ||
3500+
((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z') ||
3501+
((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'Z'))) {
3502+
e.preventDefault();
3503+
redo();
3504+
return;
3505+
}
3506+
33533507
// Delete/Backspace: remove selected condition
33543508
if ((e.key === 'Delete' || e.key === 'Backspace') && selection && selection.type === 'condition') {
3355-
if (document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'SELECT') {
3509+
if (!inInput) {
33563510
e.preventDefault();
33573511
removeCondition(selection.index);
33583512
}
33593513
}
33603514
// Ctrl+E: export YAML
3361-
if (e.ctrlKey && e.key === 'e') {
3515+
if ((e.ctrlKey || e.metaKey) && e.key === 'e') {
33623516
e.preventDefault();
33633517
downloadYAML();
33643518
}
@@ -3369,10 +3523,14 @@ <h1>Experiment Designer</h1>
33693523
// ════════════════════════════════════════════════════
33703524

33713525
init();
3526+
document.getElementById('undoBtn').addEventListener('click', undo);
3527+
document.getElementById('redoBtn').addEventListener('click', redo);
3528+
document.getElementById('resetBtn').addEventListener('click', resetProtocol);
33723529
// Auto-select first condition so editor is immediately useful
33733530
selection = { type: 'condition', index: 0 };
33743531
renderTimeline();
33753532
renderEditor();
3533+
33763534
</script>
33773535
</body>
33783536
</html>

0 commit comments

Comments
 (0)