66 < title > Penpot API REPL</ title >
77 < script src ="https://code.jquery.com/jquery-3.7.1.min.js "> </ script >
88 < style >
9+ html , body {
10+ height : 100% ;
11+ margin : 0 ;
12+ padding : 0 ;
13+ overflow : hidden;
14+ }
15+
916 body {
1017 font-family : "Consolas" , "Monaco" , "Lucida Console" , monospace;
11- max-width : 1200px ;
12- margin : 0 auto;
13- padding : 20px ;
1418 background-color : # f5f5f5 ;
15- }
16-
17- h1 {
18- color : # 333 ;
19- text-align : center;
20- margin-bottom : 30px ;
19+ display : flex;
20+ flex-direction : column;
21+ padding : 15px ;
22+ box-sizing : border-box;
2123 }
2224
2325 .repl-container {
2426 background-color : white;
2527 border : 1px solid # ddd ;
2628 border-radius : 4px ;
2729 padding : 15px ;
28- min-height : 400px ;
29- max-height : 80vh ;
30+ flex : 1 ;
3031 overflow-y : auto;
32+ max-width : 1200px ;
33+ width : 100% ;
34+ margin : 0 auto;
35+ box-sizing : border-box;
3136 }
3237
3338 .repl-entry {
5560
5661 .code-input {
5762 width : 100% ;
58- min-height : 60 px ;
63+ min-height : 80 px ;
5964 font-family : "Consolas" , "Monaco" , "Lucida Console" , monospace;
6065 font-size : 14px ;
6166 border : 1px solid # ddd ;
159164 text-align : center;
160165 color : # 666 ;
161166 font-size : 12px ;
162- margin-top : 15 px ;
167+ padding : 10 px 0 ;
163168 font-style : italic;
169+ flex-shrink : 0 ;
164170 }
165171
166172 .entry-number {
167173 color : # 666 ;
168174 font-size : 12px ;
169175 margin-bottom : 5px ;
170176 }
177+
178+ .history-indicator {
179+ background-color : # fff3cd ;
180+ border : 1px solid # ffc107 ;
181+ border-radius : 4px ;
182+ padding : 4px 10px ;
183+ font-size : 12px ;
184+ color : # 856404 ;
185+ display : inline-block;
186+ margin-bottom : 8px ;
187+ }
171188 </ style >
172189 </ head >
173190 < body >
174- < h1 > Penpot API REPL</ h1 >
175-
176191 < div class ="repl-container " id ="repl-container ">
177192 <!-- REPL entries will be dynamically added here -->
178193 </ div >
179194
180- < div class ="controls-hint "> Press Ctrl+Enter to execute code • Shift+Enter for new line </ div >
195+ < div class ="controls-hint "> Ctrl+Enter to execute • Arrow up/down for command history </ div >
181196
182197 < script >
183198 $ ( document ) . ready ( function ( ) {
184199 let isExecuting = false ;
185200 let entryCounter = 1 ;
186- let lastCode = "" ; // store the last executed code
201+ let commandHistory = [ ] ; // full history of executed commands
202+ let historyIndex = 0 ; // current position in history
203+ let isBrowsingHistory = false ; // whether we are currently browsing history
204+ let tempInput = "" ; // temporary storage for current input when browsing history
187205
188206 // create the initial input entry
189207 createNewEntry ( ) ;
190208
191209 function createNewEntry ( ) {
192210 const entryId = `entry-${ entryCounter } ` ;
193- const defaultCode = lastCode || "" ;
211+ const isFirstEntry = entryCounter === 1 ;
212+ const placeholder = isFirstEntry
213+ ? `// Enter your JavaScript code here...
214+ console.log('Hello from Penpot!');
215+ return 'This will be the result';`
216+ : "" ;
194217 const entryHtml = `
195218 <div class="repl-entry" id="${ entryId } ">
196219 <div class="entry-number">In [${ entryCounter } ]:</div>
197220 <div class="input-section">
198221 <textarea class="code-input" id="code-input-${ entryCounter } "
199- placeholder="// Enter your JavaScript code here...
200- console.log('Hello from Penpot!');
201- return 'This will be the result';">${ escapeHtml ( defaultCode ) } </textarea>
222+ placeholder="${ placeholder } "></textarea>
202223 <button class="execute-btn" id="execute-btn-${ entryCounter } ">Execute Code</button>
203224 </div>
204225 </div>
@@ -209,30 +230,197 @@ <h1>Penpot API REPL</h1>
209230 // bind events for this entry
210231 bindEntryEvents ( entryCounter ) ;
211232
212- // focus on the new input
213- $ ( `#code-input-${ entryCounter } ` ) . focus ( ) ;
233+ // focus on the new input without scrolling
234+ const $input = $ ( `#code-input-${ entryCounter } ` ) ;
235+ $input [ 0 ] . focus ( { preventScroll : true } ) ;
214236
215- // auto-resize textarea
216- $ ( `#code-input-${ entryCounter } ` ) . on ( "input" , function ( ) {
217- this . style . height = "auto" ;
218- this . style . height = Math . max ( 60 , this . scrollHeight ) + "px" ;
237+ // auto-resize textarea on input
238+ $input . on ( "input" , function ( ) {
239+ autoResizeTextarea ( this ) ;
219240 } ) ;
220241
221242 entryCounter ++ ;
222243 }
223244
245+ /**
246+ * Resizes a textarea to fit its content, with a minimum height.
247+ * Adds border height since scrollHeight excludes borders but box-sizing: border-box includes them.
248+ */
249+ function autoResizeTextarea ( textarea ) {
250+ textarea . style . height = "auto" ;
251+ // add 2px for top and bottom border (1px each)
252+ textarea . style . height = Math . max ( 80 , textarea . scrollHeight + 2 ) + "px" ;
253+ }
254+
255+ /**
256+ * Checks if the cursor is at the beginning of a textarea (position 0 with no selection).
257+ */
258+ function isCursorAtBeginning ( textarea ) {
259+ return textarea . selectionStart === 0 && textarea . selectionEnd === 0 ;
260+ }
261+
262+ /**
263+ * Checks if the cursor is at the end of a textarea (position at text length with no selection).
264+ */
265+ function isCursorAtEnd ( textarea ) {
266+ const len = textarea . value . length ;
267+ return textarea . selectionStart === len && textarea . selectionEnd === len ;
268+ }
269+
270+ /**
271+ * Navigates through command history for the given entry's textarea.
272+ * @param direction -1 for previous (up), +1 for next (down)
273+ * @param entryNum the entry number
274+ */
275+ function navigateHistory ( direction , entryNum ) {
276+ const $codeInput = $ ( `#code-input-${ entryNum } ` ) ;
277+ const textarea = $codeInput [ 0 ] ;
278+
279+ if ( commandHistory . length === 0 ) return ;
280+
281+ if ( direction === - 1 ) {
282+ // going back in history (arrow up)
283+ if ( ! isBrowsingHistory ) {
284+ // starting to browse history: save current input
285+ tempInput = $codeInput . val ( ) ;
286+ isBrowsingHistory = true ;
287+ historyIndex = commandHistory . length - 1 ;
288+ } else if ( historyIndex > 0 ) {
289+ // go further back in history
290+ historyIndex -- ;
291+ } else {
292+ // already at oldest entry, do nothing
293+ return ;
294+ }
295+ $codeInput . val ( commandHistory [ historyIndex ] ) ;
296+ autoResizeTextarea ( textarea ) ;
297+ // keep cursor at beginning for continued history navigation
298+ textarea . setSelectionRange ( 0 , 0 ) ;
299+ // show history position (1 = most recent)
300+ const position = commandHistory . length - historyIndex ;
301+ showHistoryIndicator ( entryNum , position , commandHistory . length ) ;
302+ } else {
303+ // going forward in history (arrow down)
304+ if ( ! isBrowsingHistory ) {
305+ // not browsing history, do nothing
306+ return ;
307+ } else if ( historyIndex >= commandHistory . length - 1 ) {
308+ // at most recent entry, return to original input
309+ isBrowsingHistory = false ;
310+ $codeInput . val ( tempInput ) ;
311+ autoResizeTextarea ( textarea ) ;
312+ // cursor at beginning (same as when we entered history)
313+ textarea . setSelectionRange ( 0 , 0 ) ;
314+ hideHistoryIndicator ( ) ;
315+ } else {
316+ // go forward in history
317+ historyIndex ++ ;
318+ $codeInput . val ( commandHistory [ historyIndex ] ) ;
319+ autoResizeTextarea ( textarea ) ;
320+ // keep cursor at beginning
321+ textarea . setSelectionRange ( 0 , 0 ) ;
322+ // update history position indicator
323+ const position = commandHistory . length - historyIndex ;
324+ showHistoryIndicator ( entryNum , position , commandHistory . length ) ;
325+ }
326+ }
327+ }
328+
329+ /**
330+ * Exits history browsing mode, keeping current content in the input.
331+ * Moves cursor to end of input.
332+ * @param entryNum the entry number (optional, cursor not moved if not provided)
333+ */
334+ function exitHistoryBrowsing ( entryNum ) {
335+ if ( isBrowsingHistory ) {
336+ isBrowsingHistory = false ;
337+ hideHistoryIndicator ( ) ;
338+ if ( entryNum !== undefined ) {
339+ const textarea = $ ( `#code-input-${ entryNum } ` ) [ 0 ] ;
340+ const len = textarea . value . length ;
341+ textarea . setSelectionRange ( len , len ) ;
342+ }
343+ }
344+ }
345+
346+ /**
347+ * Scrolls the repl container to show the output section of the given entry.
348+ */
349+ function scrollToOutput ( $entry ) {
350+ const $container = $ ( "#repl-container" ) ;
351+ const $outputSection = $entry . find ( ".output-section" ) ;
352+ if ( $outputSection . length ) {
353+ const containerTop = $container . offset ( ) . top ;
354+ const outputTop = $outputSection . offset ( ) . top ;
355+ const scrollTop = $container . scrollTop ( ) ;
356+ $container . animate ( {
357+ scrollTop : scrollTop + ( outputTop - containerTop )
358+ } , 300 ) ;
359+ }
360+ }
361+
362+ /**
363+ * Shows or updates the history indicator for the current entry.
364+ * @param entryNum the entry number
365+ * @param position 1-based position from most recent (1 = most recent)
366+ * @param total total number of history items
367+ */
368+ function showHistoryIndicator ( entryNum , position , total ) {
369+ const $entry = $ ( `#entry-${ entryNum } ` ) ;
370+ let $indicator = $entry . find ( ".history-indicator" ) ;
371+
372+ if ( $indicator . length === 0 ) {
373+ $entry . find ( ".input-section" ) . before ( '<div class="history-indicator"></div>' ) ;
374+ $indicator = $entry . find ( ".history-indicator" ) ;
375+ }
376+
377+ $indicator . text ( `History item ${ position } /${ total } ` ) ;
378+ }
379+
380+ /**
381+ * Hides the history indicator.
382+ */
383+ function hideHistoryIndicator ( ) {
384+ $ ( ".history-indicator" ) . remove ( ) ;
385+ }
386+
224387 function bindEntryEvents ( entryNum ) {
225388 const $executeBtn = $ ( `#execute-btn-${ entryNum } ` ) ;
226389 const $codeInput = $ ( `#code-input-${ entryNum } ` ) ;
227390
228391 // bind execute button click
229392 $executeBtn . on ( "click" , ( ) => executeCode ( entryNum ) ) ;
230393
231- // bind Ctrl+Enter keyboard shortcut
394+ // bind keyboard shortcuts
232395 $codeInput . on ( "keydown" , function ( e ) {
396+ // Ctrl+Enter to execute
233397 if ( e . ctrlKey && e . key === "Enter" ) {
234398 e . preventDefault ( ) ;
399+ exitHistoryBrowsing ( entryNum ) ;
235400 executeCode ( entryNum ) ;
401+ return ;
402+ }
403+
404+ // arrow up at beginning of input (or while browsing history): navigate to previous history entry
405+ if ( e . key === "ArrowUp" && ( isBrowsingHistory || isCursorAtBeginning ( this ) ) ) {
406+ e . preventDefault ( ) ;
407+ navigateHistory ( - 1 , entryNum ) ;
408+ return ;
409+ }
410+
411+ // arrow down at end of input (or while browsing history): navigate to next history entry
412+ if ( e . key === "ArrowDown" && ( isBrowsingHistory || isCursorAtEnd ( this ) ) ) {
413+ e . preventDefault ( ) ;
414+ navigateHistory ( + 1 , entryNum ) ;
415+ return ;
416+ }
417+
418+ // any key except pure modifier keys exits history browsing
419+ if ( isBrowsingHistory ) {
420+ const isModifierOnly = [ "Shift" , "Control" , "Alt" , "Meta" ] . includes ( e . key ) ;
421+ if ( ! isModifierOnly ) {
422+ exitHistoryBrowsing ( ) ;
423+ }
236424 }
237425 } ) ;
238426 }
@@ -328,15 +516,16 @@ <h1>Penpot API REPL</h1>
328516 $codeInput . prop ( "readonly" , true ) ;
329517 $ ( `#execute-btn-${ entryNum } ` ) . remove ( ) ;
330518
331- // store the code for the next entry
332- lastCode = code ;
519+ // store the code in history
520+ commandHistory . push ( code ) ;
521+ isBrowsingHistory = false ; // reset history navigation
522+ tempInput = "" ; // clear temporary input
333523
334524 // create a new entry for the next input
335525 createNewEntry ( ) ;
336526
337- // scroll to the new entry
338- const $container = $ ( "#repl-container" ) ;
339- $container . scrollTop ( $container [ 0 ] . scrollHeight ) ;
527+ // scroll to the output section of the executed entry
528+ scrollToOutput ( $entry ) ;
340529 } ,
341530 error : function ( xhr ) {
342531 let errorData ;
@@ -346,6 +535,9 @@ <h1>Penpot API REPL</h1>
346535 errorData = { error : "Network error or invalid response" } ;
347536 }
348537 displayResult ( entryNum , errorData , true ) ;
538+
539+ // scroll to the error output
540+ scrollToOutput ( $entry ) ;
349541 } ,
350542 complete : function ( ) {
351543 setExecuting ( entryNum , false ) ;
0 commit comments