66 < title > Penpot API REPL</ title >
77 < script src ="https://code.jquery.com/jquery-3.7.1.min.js "> </ script >
88 < style >
9+ html ,
910 body {
10- font-family : "Consolas" , "Monaco" , "Lucida Console" , monospace;
11- max-width : 1200px ;
12- margin : 0 auto;
13- padding : 20px ;
14- background-color : # f5f5f5 ;
11+ height : 100% ;
12+ margin : 0 ;
13+ padding : 0 ;
14+ overflow : hidden;
1515 }
1616
17- h1 {
18- color : # 333 ;
19- text-align : center;
20- margin-bottom : 30px ;
17+ body {
18+ font-family : "Consolas" , "Monaco" , "Lucida Console" , monospace;
19+ background-color : # f5f5f5 ;
20+ display : flex;
21+ flex-direction : column;
22+ padding : 15px ;
23+ box-sizing : border-box;
2124 }
2225
2326 .repl-container {
2427 background-color : white;
2528 border : 1px solid # ddd ;
2629 border-radius : 4px ;
2730 padding : 15px ;
28- min-height : 400px ;
29- max-height : 80vh ;
31+ flex : 1 ;
3032 overflow-y : auto;
33+ max-width : 1200px ;
34+ width : 100% ;
35+ margin : 0 auto;
36+ box-sizing : border-box;
3137 }
3238
3339 .repl-entry {
5561
5662 .code-input {
5763 width : 100% ;
58- min-height : 60 px ;
64+ min-height : 80 px ;
5965 font-family : "Consolas" , "Monaco" , "Lucida Console" , monospace;
6066 font-size : 14px ;
6167 border : 1px solid # ddd ;
159165 text-align : center;
160166 color : # 666 ;
161167 font-size : 12px ;
162- margin-top : 15 px ;
168+ padding : 10 px 0 ;
163169 font-style : italic;
170+ flex-shrink : 0 ;
164171 }
165172
166173 .entry-number {
167174 color : # 666 ;
168175 font-size : 12px ;
169176 margin-bottom : 5px ;
170177 }
178+
179+ .history-indicator {
180+ background-color : # fff3cd ;
181+ border : 1px solid # ffc107 ;
182+ border-radius : 4px ;
183+ padding : 4px 10px ;
184+ font-size : 12px ;
185+ color : # 856404 ;
186+ display : inline-block;
187+ margin-bottom : 8px ;
188+ }
171189 </ style >
172190 </ head >
173191 < body >
174- < h1 > Penpot API REPL</ h1 >
175-
176192 < div class ="repl-container " id ="repl-container ">
177193 <!-- REPL entries will be dynamically added here -->
178194 </ div >
179195
180- < div class ="controls-hint "> Press Ctrl+Enter to execute code • Shift+Enter for new line </ div >
196+ < div class ="controls-hint "> Ctrl+Enter to execute • Arrow up/down for command history </ div >
181197
182198 < script >
183199 $ ( document ) . ready ( function ( ) {
184200 let isExecuting = false ;
185201 let entryCounter = 1 ;
186- let lastCode = "" ; // store the last executed code
202+ let commandHistory = [ ] ; // full history of executed commands
203+ let historyIndex = 0 ; // current position in history
204+ let isBrowsingHistory = false ; // whether we are currently browsing history
205+ let tempInput = "" ; // temporary storage for current input when browsing history
187206
188207 // create the initial input entry
189208 createNewEntry ( ) ;
190209
191210 function createNewEntry ( ) {
192211 const entryId = `entry-${ entryCounter } ` ;
193- const defaultCode = lastCode || "" ;
212+ const isFirstEntry = entryCounter === 1 ;
213+ const placeholder = isFirstEntry
214+ ? `// Enter your JavaScript code here...
215+ console.log('Hello from Penpot!');
216+ return 'This will be the result';`
217+ : "" ;
194218 const entryHtml = `
195219 <div class="repl-entry" id="${ entryId } ">
196220 <div class="entry-number">In [${ entryCounter } ]:</div>
197221 <div class="input-section">
198222 <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>
223+ placeholder="${ placeholder } "></textarea>
202224 <button class="execute-btn" id="execute-btn-${ entryCounter } ">Execute Code</button>
203225 </div>
204226 </div>
@@ -209,30 +231,200 @@ <h1>Penpot API REPL</h1>
209231 // bind events for this entry
210232 bindEntryEvents ( entryCounter ) ;
211233
212- // focus on the new input
213- $ ( `#code-input-${ entryCounter } ` ) . focus ( ) ;
234+ // focus on the new input without scrolling
235+ const $input = $ ( `#code-input-${ entryCounter } ` ) ;
236+ $input [ 0 ] . focus ( { preventScroll : true } ) ;
214237
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" ;
238+ // auto-resize textarea on input
239+ $input . on ( "input" , function ( ) {
240+ autoResizeTextarea ( this ) ;
219241 } ) ;
220242
221243 entryCounter ++ ;
222244 }
223245
246+ /**
247+ * Resizes a textarea to fit its content, with a minimum height.
248+ * Adds border height since scrollHeight excludes borders but box-sizing: border-box includes them.
249+ */
250+ function autoResizeTextarea ( textarea ) {
251+ textarea . style . height = "auto" ;
252+ // add 2px for top and bottom border (1px each)
253+ textarea . style . height = Math . max ( 80 , textarea . scrollHeight + 2 ) + "px" ;
254+ }
255+
256+ /**
257+ * Checks if the cursor is at the beginning of a textarea (position 0 with no selection).
258+ */
259+ function isCursorAtBeginning ( textarea ) {
260+ return textarea . selectionStart === 0 && textarea . selectionEnd === 0 ;
261+ }
262+
263+ /**
264+ * Checks if the cursor is at the end of a textarea (position at text length with no selection).
265+ */
266+ function isCursorAtEnd ( textarea ) {
267+ const len = textarea . value . length ;
268+ return textarea . selectionStart === len && textarea . selectionEnd === len ;
269+ }
270+
271+ /**
272+ * Navigates through command history for the given entry's textarea.
273+ * @param direction -1 for previous (up), +1 for next (down)
274+ * @param entryNum the entry number
275+ */
276+ function navigateHistory ( direction , entryNum ) {
277+ const $codeInput = $ ( `#code-input-${ entryNum } ` ) ;
278+ const textarea = $codeInput [ 0 ] ;
279+
280+ if ( commandHistory . length === 0 ) return ;
281+
282+ if ( direction === - 1 ) {
283+ // going back in history (arrow up)
284+ if ( ! isBrowsingHistory ) {
285+ // starting to browse history: save current input
286+ tempInput = $codeInput . val ( ) ;
287+ isBrowsingHistory = true ;
288+ historyIndex = commandHistory . length - 1 ;
289+ } else if ( historyIndex > 0 ) {
290+ // go further back in history
291+ historyIndex -- ;
292+ } else {
293+ // already at oldest entry, do nothing
294+ return ;
295+ }
296+ $codeInput . val ( commandHistory [ historyIndex ] ) ;
297+ autoResizeTextarea ( textarea ) ;
298+ // keep cursor at beginning for continued history navigation
299+ textarea . setSelectionRange ( 0 , 0 ) ;
300+ // show history position (1 = most recent)
301+ const position = commandHistory . length - historyIndex ;
302+ showHistoryIndicator ( entryNum , position , commandHistory . length ) ;
303+ } else {
304+ // going forward in history (arrow down)
305+ if ( ! isBrowsingHistory ) {
306+ // not browsing history, do nothing
307+ return ;
308+ } else if ( historyIndex >= commandHistory . length - 1 ) {
309+ // at most recent entry, return to original input
310+ isBrowsingHistory = false ;
311+ $codeInput . val ( tempInput ) ;
312+ autoResizeTextarea ( textarea ) ;
313+ // cursor at beginning (same as when we entered history)
314+ textarea . setSelectionRange ( 0 , 0 ) ;
315+ hideHistoryIndicator ( ) ;
316+ } else {
317+ // go forward in history
318+ historyIndex ++ ;
319+ $codeInput . val ( commandHistory [ historyIndex ] ) ;
320+ autoResizeTextarea ( textarea ) ;
321+ // keep cursor at beginning
322+ textarea . setSelectionRange ( 0 , 0 ) ;
323+ // update history position indicator
324+ const position = commandHistory . length - historyIndex ;
325+ showHistoryIndicator ( entryNum , position , commandHistory . length ) ;
326+ }
327+ }
328+ }
329+
330+ /**
331+ * Exits history browsing mode, keeping current content in the input.
332+ * Moves cursor to end of input.
333+ * @param entryNum the entry number (optional, cursor not moved if not provided)
334+ */
335+ function exitHistoryBrowsing ( entryNum ) {
336+ if ( isBrowsingHistory ) {
337+ isBrowsingHistory = false ;
338+ hideHistoryIndicator ( ) ;
339+ if ( entryNum !== undefined ) {
340+ const textarea = $ ( `#code-input-${ entryNum } ` ) [ 0 ] ;
341+ const len = textarea . value . length ;
342+ textarea . setSelectionRange ( len , len ) ;
343+ }
344+ }
345+ }
346+
347+ /**
348+ * Scrolls the repl container to show the output section of the given entry.
349+ */
350+ function scrollToOutput ( $entry ) {
351+ const $container = $ ( "#repl-container" ) ;
352+ const $outputSection = $entry . find ( ".output-section" ) ;
353+ if ( $outputSection . length ) {
354+ const containerTop = $container . offset ( ) . top ;
355+ const outputTop = $outputSection . offset ( ) . top ;
356+ const scrollTop = $container . scrollTop ( ) ;
357+ $container . animate (
358+ {
359+ scrollTop : scrollTop + ( outputTop - containerTop ) ,
360+ } ,
361+ 300
362+ ) ;
363+ }
364+ }
365+
366+ /**
367+ * Shows or updates the history indicator for the current entry.
368+ * @param entryNum the entry number
369+ * @param position 1-based position from most recent (1 = most recent)
370+ * @param total total number of history items
371+ */
372+ function showHistoryIndicator ( entryNum , position , total ) {
373+ const $entry = $ ( `#entry-${ entryNum } ` ) ;
374+ let $indicator = $entry . find ( ".history-indicator" ) ;
375+
376+ if ( $indicator . length === 0 ) {
377+ $entry . find ( ".input-section" ) . before ( '<div class="history-indicator"></div>' ) ;
378+ $indicator = $entry . find ( ".history-indicator" ) ;
379+ }
380+
381+ $indicator . text ( `History item ${ position } /${ total } ` ) ;
382+ }
383+
384+ /**
385+ * Hides the history indicator.
386+ */
387+ function hideHistoryIndicator ( ) {
388+ $ ( ".history-indicator" ) . remove ( ) ;
389+ }
390+
224391 function bindEntryEvents ( entryNum ) {
225392 const $executeBtn = $ ( `#execute-btn-${ entryNum } ` ) ;
226393 const $codeInput = $ ( `#code-input-${ entryNum } ` ) ;
227394
228395 // bind execute button click
229396 $executeBtn . on ( "click" , ( ) => executeCode ( entryNum ) ) ;
230397
231- // bind Ctrl+Enter keyboard shortcut
398+ // bind keyboard shortcuts
232399 $codeInput . on ( "keydown" , function ( e ) {
400+ // Ctrl+Enter to execute
233401 if ( e . ctrlKey && e . key === "Enter" ) {
234402 e . preventDefault ( ) ;
403+ exitHistoryBrowsing ( entryNum ) ;
235404 executeCode ( entryNum ) ;
405+ return ;
406+ }
407+
408+ // arrow up at beginning of input (or while browsing history): navigate to previous history entry
409+ if ( e . key === "ArrowUp" && ( isBrowsingHistory || isCursorAtBeginning ( this ) ) ) {
410+ e . preventDefault ( ) ;
411+ navigateHistory ( - 1 , entryNum ) ;
412+ return ;
413+ }
414+
415+ // arrow down at end of input (or while browsing history): navigate to next history entry
416+ if ( e . key === "ArrowDown" && ( isBrowsingHistory || isCursorAtEnd ( this ) ) ) {
417+ e . preventDefault ( ) ;
418+ navigateHistory ( + 1 , entryNum ) ;
419+ return ;
420+ }
421+
422+ // any key except pure modifier keys exits history browsing
423+ if ( isBrowsingHistory ) {
424+ const isModifierOnly = [ "Shift" , "Control" , "Alt" , "Meta" ] . includes ( e . key ) ;
425+ if ( ! isModifierOnly ) {
426+ exitHistoryBrowsing ( ) ;
427+ }
236428 }
237429 } ) ;
238430 }
@@ -328,15 +520,16 @@ <h1>Penpot API REPL</h1>
328520 $codeInput . prop ( "readonly" , true ) ;
329521 $ ( `#execute-btn-${ entryNum } ` ) . remove ( ) ;
330522
331- // store the code for the next entry
332- lastCode = code ;
523+ // store the code in history
524+ commandHistory . push ( code ) ;
525+ isBrowsingHistory = false ; // reset history navigation
526+ tempInput = "" ; // clear temporary input
333527
334528 // create a new entry for the next input
335529 createNewEntry ( ) ;
336530
337- // scroll to the new entry
338- const $container = $ ( "#repl-container" ) ;
339- $container . scrollTop ( $container [ 0 ] . scrollHeight ) ;
531+ // scroll to the output section of the executed entry
532+ scrollToOutput ( $entry ) ;
340533 } ,
341534 error : function ( xhr ) {
342535 let errorData ;
@@ -346,6 +539,9 @@ <h1>Penpot API REPL</h1>
346539 errorData = { error : "Network error or invalid response" } ;
347540 }
348541 displayResult ( entryNum , errorData , true ) ;
542+
543+ // scroll to the error output
544+ scrollToOutput ( $entry ) ;
349545 } ,
350546 complete : function ( ) {
351547 setExecuting ( entryNum , false ) ;
0 commit comments