From 96e8adf0486b7652f46bb5ce3d6ae5cf28d8b86e Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Fri, 17 Apr 2026 19:52:06 -0500 Subject: [PATCH] Rework resizing of the problem editor. The code and rendering panels are now not only vertically resizable, but are horizontally resizable when the window with is at or above the large breakpoint (992 pixels). Furthermore, resizing does not work with the native browser resize via the css `resize` property. Instead it is controlled with JavaScript. The resize grips (which are much more visible now) can be also be focused with the keyboard and when focused the arrow keys can be used to resize the code and render panels. Note that if `Ctrl` is pressed with an arrow key a 1 pixel resize occurs, and if `Alt` is pressed with an arrow key a 50 pixel resize occurs. Without a modifier key the arrow keys perform a 20 pixel resize. In addition, the dimensions are saved to local storage and persist when the page reloads. Unfortunately there will be some flickering of content as the resize occurs after the page loads. Note that the css `resize` property is actually not supported in all browsers, so this actually makes resizing work for those browsers as well. The browsers that do not support the css `resize` property include Firefox for Android, and Safari on IOS. Yeah, those are for mobile devices, and who edits problems on a mobile device? In any case, this makes the resize grips more evident. The native resize grip is rather small in the lower right corner of the CodeMirror editor panel, and many probably don't even know it is there. Note that the code panel has a minmimum width of 400 pixels, and the rendering panel a minimum width of 300 pixels. This works out so that when the window size is 992 pixels the two panels can't really be resized much or at all (when the site navigation menu width of 250 pixels is taken into account) depending on the browser. But at larger window widths resizing can be done. I thought about making it so that the resizing could go all the way to the right and the rendering panel be resized to a width of 0, but decided against it. If that were done, then the rendering would still be occuring even though you can't see it, and that doesn't seem good. I think that this should at least alleviate the request(s) to hide the rendering panel (which I don't think is really a good idea). --- htdocs/js/PGProblemEditor/pgproblemeditor.js | 163 +++++++++++++--- .../js/PGProblemEditor/pgproblemeditor.scss | 174 ++++++++++++++++++ htdocs/js/System/system.scss | 22 +-- .../Instructor/PGProblemEditor.html.ep | 53 ++++-- .../InstructorPGProblemEditor.html.ep | 6 + 5 files changed, 358 insertions(+), 60 deletions(-) create mode 100644 htdocs/js/PGProblemEditor/pgproblemeditor.scss diff --git a/htdocs/js/PGProblemEditor/pgproblemeditor.js b/htdocs/js/PGProblemEditor/pgproblemeditor.js index 9f8f0162d7..c1a2aacf3d 100644 --- a/htdocs/js/PGProblemEditor/pgproblemeditor.js +++ b/htdocs/js/PGProblemEditor/pgproblemeditor.js @@ -395,27 +395,124 @@ } }); - // Synchronize the heights of the render area and the editor area for wide windows. - if (editorArea && renderArea) { - const codeMirrorResizeObserver = new ResizeObserver((entries) => { - if (document.body.clientWidth < 992) return; - - for (const entry of entries) { - if (entry.borderBoxSize) { - // Note that the blockSize is the height (since width is not resizable). - const height = Array.isArray(entry.borderBoxSize) - ? entry.borderBoxSize[0].blockSize - : entry.borderBoxSize.blockSize; - if (window.getComputedStyle(renderArea).getPropertyValue('height') !== `${height}px`) - renderArea.style.height = `${height}px`; - if (window.getComputedStyle(editorArea).getPropertyValue('height') !== `${height}px`) { - editorArea.style.height = `${height}px`; - } - } + const pgEditContainer = document.getElementById('pgedit-container'); + const codePanel = pgEditContainer?.querySelector('.pgedit-panel.code'); + const renderPanel = pgEditContainer?.querySelector('.pgedit-panel.render'); + + if (pgEditContainer && codePanel && renderPanel) { + if (document.body.clientWidth < 992) { + const initialCodePanelHeight = localStorage.getItem('WW.pgedit.codePanelHeight'); + if (initialCodePanelHeight) codePanel.style.height = initialCodePanelHeight; + } else { + const initialResizeContainerHeight = localStorage.getItem('WW.pgedit.containerHeight'); + if (initialResizeContainerHeight) pgEditContainer.style.height = initialResizeContainerHeight; + const initialCodePanelWidth = localStorage.getItem('WW.pgedit.codePanelWidth'); + if (initialCodePanelWidth) codePanel.style.width = initialCodePanelWidth; + } + + const verticalResizer = pgEditContainer.querySelector('.vertical-resizer'); + + verticalResizer?.addEventListener('pointerdown', (e) => { + verticalResizer.setPointerCapture(e.pointerId); + + const startY = e.clientY; + const startHeight = + document.body.clientWidth < 992 + ? codePanel.getBoundingClientRect().height + : pgEditContainer.getBoundingClientRect().height; + + const onPointerMove = + document.body.clientWidth < 992 + ? (moveEvent) => { + codePanel.style.height = `${startHeight + (moveEvent.clientY - startY)}px`; + localStorage.setItem('WW.pgedit.codePanelHeight', codePanel.style.height); + } + : (moveEvent) => { + pgEditContainer.style.height = `${startHeight + (moveEvent.clientY - startY)}px`; + localStorage.setItem('WW.pgedit.containerHeight', pgEditContainer.style.height); + }; + const onPointerUp = () => { + verticalResizer.releasePointerCapture(e.pointerId); + document.removeEventListener('pointermove', onPointerMove); + document.removeEventListener('pointerup', onPointerUp); + }; + + document.addEventListener('pointermove', onPointerMove); + document.addEventListener('pointerup', onPointerUp); + }); + + const updateHeight = (delta) => { + if (document.body.clientWidth < 992) { + codePanel.style.height = `${codePanel.getBoundingClientRect().height + delta}px`; + localStorage.setItem('WW.pgedit.codePanelHeight', codePanel.style.height); + } else { + pgEditContainer.style.height = `${pgEditContainer.getBoundingClientRect().height + delta}px`; + localStorage.setItem('WW.pgedit.containerHeight', pgEditContainer.style.height); + } + }; + + verticalResizer?.addEventListener('keydown', (e) => { + const step = e.ctrlKey ? 1 : e.altKey ? 50 : 20; + if (e.key === 'ArrowUp') { + updateHeight(-step); + e.preventDefault(); + } else if (e.key === 'ArrowDown') { + updateHeight(step); + e.preventDefault(); + } + }); + + const horizontalResizer = pgEditContainer.querySelector('.horizontal-resizer'); + + horizontalResizer?.addEventListener('pointerdown', (e) => { + horizontalResizer.setPointerCapture(e.pointerId); + + const startX = e.clientX; + const startWidth = codePanel.getBoundingClientRect().width; + + const onPointerMove = (moveEvent) => { + codePanel.style.width = `${startWidth + (moveEvent.clientX - startX)}px`; + localStorage.setItem('WW.pgedit.codePanelWidth', codePanel.style.width); + }; + + const onPointerUp = () => { + horizontalResizer.releasePointerCapture(e.pointerId); + document.removeEventListener('pointermove', onPointerMove); + document.removeEventListener('pointerup', onPointerUp); + }; + + document.addEventListener('pointermove', onPointerMove); + document.addEventListener('pointerup', onPointerUp); + }); + + horizontalResizer?.addEventListener('dblclick', () => { + codePanel.style.width = 'calc(50% - 0.5rem + 1px)'; + localStorage.setItem('WW.pgedit.codePanelWidth', codePanel.style.width); + }); + + horizontalResizer?.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + codePanel.style.width = 'calc(50% - 0.5rem + 1px)'; + localStorage.setItem('WW.pgedit.codePanelWidth', codePanel.style.width); + e.preventDefault(); + } + }); + + const updateWidth = (delta) => { + codePanel.style.width = `${codePanel.getBoundingClientRect().width + delta}px`; + localStorage.setItem('WW.pgedit.codePanelWidth', codePanel.style.width); + }; + + horizontalResizer?.addEventListener('keydown', (e) => { + const step = e.ctrlKey ? 1 : e.altKey ? 50 : 20; + if (e.key === 'ArrowLeft') { + updateWidth(-step); + e.preventDefault(); + } else if (e.key === 'ArrowRight') { + updateWidth(step); + e.preventDefault(); } }); - codeMirrorResizeObserver.observe(editorArea); - codeMirrorResizeObserver.observe(renderArea); } // Save the initial placeholder content of the render area so that it can be put back when a problem is reloaded. @@ -425,12 +522,29 @@ iframe.id = 'pgedit-render-iframe'; iframe.style.colorScheme = 'light'; - // Adjust the height of the iframe when the window is resized and when the iframe loads. + // Adjust editor dimensions when the window is resized and when the iframe loads. const adjustIFrameHeight = () => { if (document.body.clientWidth < 992) { - if (iframe.contentDocument) - renderArea.style.height = `${iframe.contentDocument.documentElement.offsetHeight + 2}px`; - } else renderArea.style.height = `${editorArea.offsetHeight}px`; + if (iframe.contentDocument) { + pgEditContainer.style.height = ''; + codePanel.style.width = '100%'; + codePanel.style.height = localStorage.getItem('WW.pgedit.codePanelHeight') ?? ''; + renderArea.style.height = `${ + iframe.contentDocument.documentElement.offsetHeight + + 2 + + (document.getElementById('author-comment')?.offsetHeight ?? 0) + }px`; + renderPanel.style.width = '100%'; + renderPanel.style.height = renderArea.style.height; + } + } else { + pgEditContainer.style.height = localStorage.getItem('WW.pgedit.containerHeight') ?? ''; + codePanel.style.width = localStorage.getItem('WW.pgedit.codePanelWidth') ?? ''; + renderPanel.style.width = ''; + codePanel.style.height = '100%'; + renderPanel.style.height = '100%'; + renderArea.style.height = '100%'; + } }; window.addEventListener('resize', adjustIFrameHeight); @@ -584,7 +698,8 @@ if (data.pg_flags && data.pg_flags.comment) { // The problem has a comment, so show it. const container = document.createElement('div'); - container.classList.add('px-2', 'mb-2'); + container.id = 'author-comment'; + container.classList.add('p-2'); container.innerHTML = data.pg_flags.comment; iframe.after(container); } diff --git a/htdocs/js/PGProblemEditor/pgproblemeditor.scss b/htdocs/js/PGProblemEditor/pgproblemeditor.scss new file mode 100644 index 0000000000..fec708827f --- /dev/null +++ b/htdocs/js/PGProblemEditor/pgproblemeditor.scss @@ -0,0 +1,174 @@ +#pgedit-container { + display: flex; + flex-direction: column; + width: 100%; + height: 600px; + min-height: 400px; + + .pgedit-inner-container { + display: flex; + flex: 1; + width: 100%; + height: calc(100% - 1rem - 2px); + + .pgedit-panel { + overflow: auto; + } + + .code { + width: calc(50% - 0.5rem); + height: 100%; + min-width: 400px; + + .code-mirror-editor { + min-height: unset; + overflow: unset; + height: 100%; + } + + .text-area-editor { + min-height: unset; + overflow: unset; + height: 100%; + resize: unset; + } + } + + .render { + flex: 1; + min-width: 300px; + height: 100%; + + #pgedit-render-area { + border: 1px solid var(--ww-layout-border-color, #ddd); + height: 100%; + display: flex; + flex-direction: column; + + #pgedit-render-iframe { + flex-grow: 1; + border: none; + width: 100%; + } + } + + #author-comment { + border-top: 1px solid var(--ww-layout-border-color, #ddd); + } + } + } + + .pgedit-resizer { + background-color: #fff; + transition: + background 0.2s, + box-shadow 0.2s ease-in-out; + position: relative; + border: 1px solid var(--ww-layout-border-color, #ddd); + user-select: none; + touch-action: none; + display: flex; + justify-content: center; + align-items: center; + color: black; + + &:focus { + z-index: 19; + box-shadow: 0 0 0.2rem 0.25rem #aaa; + outline: none; + } + + &:hover { + z-index: 19; + background-color: #888; + color: white; + box-shadow: 0 0 0.2rem 0.25rem #888; + } + + &::after { + content: ''; + position: absolute; + background: transparent; + } + } + + .vertical-resizer { + height: 1rem; + width: 100%; + cursor: row-resize; + + i { + transform: scale(4, 1); + } + + &::after { + top: -4px; + left: 0; + right: 0; + bottom: -4px; + } + } + + .horizontal-resizer { + height: 100%; + width: 1rem; + cursor: col-resize; + + i { + transform: scale(1, 4); + } + + &::after { + top: 0; + left: -4px; + right: -4px; + bottom: 0; + } + } + + @media (prefers-color-scheme: dark) { + .pgedit-resizer { + background-color: #000; + color: white; + + &:focus { + box-shadow: 0 0 0.2rem 0.25rem #777; + } + + &:hover { + background-color: #bbb; + color: black; + box-shadow: 0 0 0.2rem 0.25rem #999; + } + } + } + + @media (max-width: 991px) { + height: unset; + min-height: unset; + + .pgedit-inner-container { + flex-direction: column; + row-gap: 1rem; + + .code { + width: 100%; + height: 400px; + min-height: 200px; + min-width: unset; + } + + .render { + flex: unset; + height: 400px; + min-height: 200px; + width: 100%; + min-width: unset; + } + + .horizontal-resizer { + display: none; + } + } + } +} diff --git a/htdocs/js/System/system.scss b/htdocs/js/System/system.scss index ff6afbef75..b436d6f98d 100644 --- a/htdocs/js/System/system.scss +++ b/htdocs/js/System/system.scss @@ -836,7 +836,7 @@ input.changed[type='text'] { } } -/* Styles for the PGProblemEditor Page */ +/* Common styles for pages containing an editor. */ #editor { .tab-content { @@ -844,26 +844,6 @@ input.changed[type='text'] { } } -#pgedit-render-area { - border: 1px solid var(--ww-layout-border-color, #ddd); - min-height: 400px; - height: 600px; - resize: vertical; - display: flex; - flex-direction: column; - - @media only screen and (max-width: 992px) { - min-height: 200px; - height: 300px; - } - - #pgedit-render-iframe { - flex-grow: 1; - border: none; - width: 100%; - } -} - // Fix the style of the save file path input group. // It is forced to be ltr, but the bootstrap rtl style makes that look wrong. /* rtl:raw: diff --git a/templates/ContentGenerator/Instructor/PGProblemEditor.html.ep b/templates/ContentGenerator/Instructor/PGProblemEditor.html.ep index 6f38518d4a..7aa1b69ae1 100644 --- a/templates/ContentGenerator/Instructor/PGProblemEditor.html.ep +++ b/templates/ContentGenerator/Instructor/PGProblemEditor.html.ep @@ -12,6 +12,10 @@ <%= javascript getAssetURL($ce, 'js/PGProblemEditor/pgproblemeditor.js'), defer => undef =%> % end % +% content_for css => begin + <%= stylesheet getAssetURL($ce, 'js/PGProblemEditor/pgproblemeditor.css') =%> +% end +% % unless ($authz->hasPermissions(param('user'), 'access_instructor_tools')) {
<%= maketext('You are not authorized to access instructor tools.') %>
% last; @@ -154,24 +158,43 @@ % } % } -
-
- <%= generate_codemirror_html( - $c, - 'problemContents', - $problemContents, - { course_info => 'html', hardcopy_theme => 'xml' }->{ $c->{file_type} } // 'pg' - ) =%> -
-
-
-
-
<%= maketext('Loading...') %>
- +
+
+
+ <%= generate_codemirror_html( + $c, + 'problemContents', + $problemContents, + { course_info => 'html', hardcopy_theme => 'xml' }->{ $c->{file_type} } // 'pg' + ) =%> +
+ +
+
+
+
<%= maketext('Loading...') %>
+ +
+
<%= $fileInfo->() %> % diff --git a/templates/HelpFiles/InstructorPGProblemEditor.html.ep b/templates/HelpFiles/InstructorPGProblemEditor.html.ep index 515baed70b..67f82091d5 100644 --- a/templates/HelpFiles/InstructorPGProblemEditor.html.ep +++ b/templates/HelpFiles/InstructorPGProblemEditor.html.ep @@ -92,6 +92,12 @@ + <%= maketext('There are resize grips that can be moved to resize the editor windows. This can be done by ' + . 'clicking and dragging or by focusing the resize grip and using the arrow keys. If the resize grip is ' + . 'focused and the Ctrl key is held down then the size is only changed by one pixel for each arrow key ' + . 'press, and if the Alt key is held down then the size is changed by 50 pixels for each arrow key press. ' + . 'With no modifier key the size is changed by 20 pixels. Double clicking on a resize grip or pressing ' + . 'Enter or Space while a resize grip is focused resets the windows to their default sizes.') =%>
<%= maketext('Text Editor Options') %>