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;
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) "> ↩ Undo</ button >
1009+ < button class ="btn btn-undo " id ="redoBtn " disabled title ="Redo (Ctrl+Y / Ctrl+Shift+Z) "> ↪ 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
12171236let 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
14451539function 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
16501756function 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
16621769function 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
16771785function 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 */
23832494function 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
33523487document . 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
33713525init ( ) ;
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
33733530selection = { type : 'condition' , index : 0 } ;
33743531renderTimeline ( ) ;
33753532renderEditor ( ) ;
3533+
33763534</ script >
33773535</ body >
33783536</ html >
0 commit comments