From 0dd8282cf8bc8180004329a47e0e0073248ebefe Mon Sep 17 00:00:00 2001 From: Bruce Blaser Date: Tue, 18 Apr 2023 05:36:03 -0700 Subject: [PATCH] feat(client): adjustable scrollbar width in code editor (#49975) --- client/i18n/locales/english/translations.json | 1 + client/src/components/settings/about.tsx | 2 + .../settings/keyboard-shortcuts.tsx | 2 + .../components/settings/scrollbar-width.css | 153 ++++++++++++++++++ .../components/settings/scrollbar-width.tsx | 87 ++++++++++ .../templates/Challenges/classic/editor.tsx | 18 ++- client/src/utils/scrollbar-width.ts | 6 + .../e2e/default/settings/scrollbar-width.ts | 41 +++++ 8 files changed, 304 insertions(+), 6 deletions(-) create mode 100644 client/src/components/settings/scrollbar-width.css create mode 100644 client/src/components/settings/scrollbar-width.tsx create mode 100644 client/src/utils/scrollbar-width.ts create mode 100644 cypress/e2e/default/settings/scrollbar-width.ts diff --git a/client/i18n/locales/english/translations.json b/client/i18n/locales/english/translations.json index 56e504ea0f4..9e5b05b9c18 100644 --- a/client/i18n/locales/english/translations.json +++ b/client/i18n/locales/english/translations.json @@ -114,6 +114,7 @@ "for": "Account Settings for {{username}}", "sound-mode": "This adds the pleasant sound of acoustic guitar throughout the website. You'll get musical feedback as you type in the editor, complete challenges, claim certifications, and more.", "sound-volume": "Campfire Volume:", + "scrollbar-width": "Editor Scrollbar Width", "username": { "contains invalid characters": "Username \"{{username}}\" contains invalid characters", "is too short": "Username \"{{username}}\" is too short", diff --git a/client/src/components/settings/about.tsx b/client/src/components/settings/about.tsx index d35c096b18f..74aa49179b2 100644 --- a/client/src/components/settings/about.tsx +++ b/client/src/components/settings/about.tsx @@ -18,6 +18,7 @@ import ThemeSettings, { Themes } from './theme'; import UsernameSettings from './username'; import KeyboardShortcutsSettings from './keyboard-shortcuts'; import SectionHeader from './section-header'; +import ScrollbarWidthSettings from './scrollbar-width'; type FormValues = { name: string; @@ -288,6 +289,7 @@ class AboutSettings extends Component { keyboardShortcuts={keyboardShortcuts} toggleKeyboardShortcuts={toggleKeyboardShortcuts} /> + ); diff --git a/client/src/components/settings/keyboard-shortcuts.tsx b/client/src/components/settings/keyboard-shortcuts.tsx index cf603080436..e9c0a5d4fcf 100644 --- a/client/src/components/settings/keyboard-shortcuts.tsx +++ b/client/src/components/settings/keyboard-shortcuts.tsx @@ -1,6 +1,7 @@ import { Form } from '@freecodecamp/react-bootstrap'; import React from 'react'; import { useTranslation } from 'react-i18next'; +import { Spacer } from '../helpers'; import ToggleButtonSetting from './toggle-button-setting'; @@ -31,6 +32,7 @@ export default function KeyboardShortcutsSettings({ toggleKeyboardShortcuts(keyboardShortcuts ? false : true); }} /> + ); } diff --git a/client/src/components/settings/scrollbar-width.css b/client/src/components/settings/scrollbar-width.css new file mode 100644 index 00000000000..80abd605cd5 --- /dev/null +++ b/client/src/components/settings/scrollbar-width.css @@ -0,0 +1,153 @@ +label[for='scrollbar-width-slider'] { + margin-bottom: 1rem; +} + +input.scrollbar-width[type='range'] { + -webkit-appearance: none; + appearance: none; + border: 0; + background: transparent; + position: relative; + height: calc(1rem + 6px); +} + +input.scrollbar-width[type='range']::-webkit-slider-runnable-track { + width: 100%; + height: 5px; + background: var(--secondary-color); +} + +input.scrollbar-width[type='range']::-webkit-slider-thumb { + /* box-shadow is needed to allow left hand side of slider track to have + same color as right side. */ + box-shadow: 1px 1px 1px #3d3d3d; + background: var(--secondary-color); + -webkit-appearance: none; + width: calc(1rem + 6px); + height: calc(1rem + 6px); + margin-top: -0.5rem; +} + +input.scrollbar-width[type='range']:focus-visible::-webkit-slider-thumb { + outline: 3px solid var(--focus-outline-color); + outline-offset: -3px; +} + +input.scrollbar-width[type='range']::-moz-range-track { + width: 100%; + height: 5px; + background: var(--secondary-color); +} + +input.scrollbar-width[type='range']::-moz-range-progress { + background: var(--secondary-color); + height: 5px; +} + +input.scrollbar-width[type='range']::-moz-range-thumb { + box-shadow: 1px 1px 1px #3d3d3d; + border: 3px solid var(--secondary-color); + height: 1rem; + width: 1rem; + background: var(--secondary-color); + position: relative; + z-index: 400; +} + +input.scrollbar-width[type='range']:focus-visible::-moz-range-thumb { + outline: 3px solid var(--focus-outline-color); + outline-offset: -3px; +} + +.scrollbar-width-numbers { + display: flex; + width: 100%; + justify-content: space-between; +} + +.scrollbar-width-numbers span:nth-child(1) { + position: relative; + inset-inline-start: 0.375rem; +} + +.scrollbar-width-numbers span:nth-child(2) { + position: relative; + inset-inline-start: 0.5rem; +} + +.scrollbar-width-numbers span:nth-child(3) { + position: relative; + inset-inline-start: 0.325rem; +} + +.scrollbar-width-numbers span:nth-child(4) { + position: relative; + inset-inline-start: 0.15rem; +} + +.scrollbar-width-numbers span:nth-child(5) { + position: relative; + inset-inline-start: -0.05rem; +} + +.scrollbar-width-numbers .selected { + font-weight: 900; + background-color: transparent; +} + +.scrollbar-width-container .scrollbar-width-ticks { + position: relative; + display: flex; +} + +.scrollbar-width-container .tick { + height: 1rem; + width: 1rem; + border: 2px solid var(--secondary-color); + background: var(--secondary-background); + position: absolute; + z-index: 100; + top: 3px; + display: block; +} + +.scrollbar-width-container .tick[data-current='true'] { + z-index: -1; +} + +.scrollbar-width-ticks .tick:nth-child(1), +[dir='rtl'] .scrollbar-width-ticks .tick:nth-child(5) { + left: 3px; +} + +.scrollbar-width-ticks .tick:nth-child(2), +[dir='rtl'] .scrollbar-width-ticks .tick:nth-child(4) { + left: calc(25% - 0.25rem + 2px); +} + +.scrollbar-width-ticks .tick:nth-child(3) { + left: calc(50% - 0.5rem + 0.5px); +} + +.scrollbar-width-ticks .tick:nth-child(4), +[dir='rtl'] .scrollbar-width-ticks .tick:nth-child(2) { + left: calc(75% - 0.5rem - 5px); +} + +.scrollbar-width-ticks .tick:nth-child(5), +[dir='rtl'] .scrollbar-width-ticks .tick:nth-child(1) { + left: calc(100% - 1rem - 2px); +} + +.scrollbar-width-ticks .tick:hover, +.scrollbar-width-numbers span:hover { + cursor: pointer; +} + +.scrollbar-width-preview { + display: inline-block; + background: var(--secondary-color); + height: 1.5rem; + position: absolute; + margin: 0 0.5rem; +} diff --git a/client/src/components/settings/scrollbar-width.tsx b/client/src/components/settings/scrollbar-width.tsx new file mode 100644 index 00000000000..3403f6aa3b7 --- /dev/null +++ b/client/src/components/settings/scrollbar-width.tsx @@ -0,0 +1,87 @@ +import { Form } from '@freecodecamp/react-bootstrap'; +import React, { ChangeEvent, useState, useRef } from 'react'; +import store from 'store'; +import { useTranslation } from 'react-i18next'; +import { Spacer } from '../helpers'; +import { getScrollbarWidth } from '../../utils/scrollbar-width'; +import './scrollbar-width.css'; + +const ticks = [5, 10, 15, 20, 25]; + +export default function ScrollbarWidthSettings(): JSX.Element { + const { t } = useTranslation(); + const [scrollbarWidth, setScrollbarWidth] = useState(getScrollbarWidth()); + const rangeRef = useRef(null); + + function handleChange(event: ChangeEvent) { + const inputValue = Number(event.target.value); + setScrollbarWidth(inputValue); + store.set('monacoScrollbarWidth', inputValue); + } + + function handleClick(event: React.MouseEvent) { + const target = event.target as HTMLSpanElement; + const newScrollbarWidth = Number(target.dataset.value); + + if (!rangeRef?.current || !ticks.includes(newScrollbarWidth)) return; + rangeRef.current.focus(); + + if (newScrollbarWidth === scrollbarWidth) return; + rangeRef.current.value = String(newScrollbarWidth); + setScrollbarWidth(newScrollbarWidth); + store.set('monacoScrollbarWidth', newScrollbarWidth); + } + + /* eslint-disable jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */ + + return ( +
e.preventDefault()}> + +
+ + + +
+ +
+ ); +} + +ScrollbarWidthSettings.displayName = 'ScrollbarWidthSettings'; diff --git a/client/src/templates/Challenges/classic/editor.tsx b/client/src/templates/Challenges/classic/editor.tsx index a238c4b64ff..73b889f5d39 100644 --- a/client/src/templates/Challenges/classic/editor.tsx +++ b/client/src/templates/Challenges/classic/editor.tsx @@ -61,6 +61,7 @@ import { } from '../redux/selectors'; import GreenPass from '../../../assets/icons/green-pass'; import { enhancePrismAccessibility } from '../utils/index'; +import { getScrollbarWidth } from '../../../utils/scrollbar-width'; import LowerJaw from './lower-jaw'; import './editor.css'; @@ -286,7 +287,7 @@ const Editor = (props: EditorProps): JSX.Element => { vertical: 'visible', verticalHasArrows: false, useShadows: false, - verticalScrollbarSize: 5 + verticalScrollbarSize: getScrollbarWidth() }, parameterHints: { enabled: false @@ -623,7 +624,7 @@ const Editor = (props: EditorProps): JSX.Element => { // make sure the overlayWidget has resized before using it to set the height - domNode.style.width = `${editor.getLayoutInfo().contentWidth}px`; + domNode.style.width = `${getEditorContentWidth(editor)}px`; // We have to wait for the viewZone to finish rendering before adjusting the // position of the content widget (i.e. trigger it via onDomNodeTop). If @@ -693,7 +694,7 @@ const Editor = (props: EditorProps): JSX.Element => { editor: editor.IStandaloneCodeEditor ) => { // make sure the overlayWidget has resized before using it to set the height - outputNode.style.width = `${editor.getLayoutInfo().contentWidth}px`; + outputNode.style.width = `${getEditorContentWidth(editor)}px`; // We have to wait for the viewZone to finish rendering before adjusting the // position of the overlayWidget (i.e. trigger it via onComputedHeight). If // not the editor may report the wrong value for position of the lines. @@ -753,20 +754,25 @@ const Editor = (props: EditorProps): JSX.Element => { domNode.style.userSelect = 'text'; domNode.style.left = `${editor.getLayoutInfo().contentLeft}px`; - domNode.style.width = `${editor.getLayoutInfo().contentWidth}px`; + domNode.style.width = `${getEditorContentWidth(editor)}px`; domNode.style.top = getDescriptionZoneTop(); dataRef.current.descriptionNode = domNode; return domNode; } + // Take the current scrollbar width into account + function getEditorContentWidth(editor: editor.IStandaloneCodeEditor) { + return editor.getLayoutInfo().contentWidth - getScrollbarWidth(); + } + function createOutputNode(editor: editor.IStandaloneCodeEditor) { if (dataRef.current.outputNode) return dataRef.current.outputNode; const outputNode = document.createElement('div'); outputNode.classList.add('editor-lower-jaw'); outputNode.setAttribute('id', 'editor-lower-jaw'); outputNode.style.left = `${editor.getLayoutInfo().contentLeft}px`; - outputNode.style.width = `${editor.getLayoutInfo().contentWidth}px`; + outputNode.style.width = `${getEditorContentWidth(editor)}px`; outputNode.style.top = getOutputZoneTop(); dataRef.current.outputNode = outputNode; return outputNode; @@ -974,7 +980,7 @@ const Editor = (props: EditorProps): JSX.Element => { const getDomNode = () => domNode; const getPosition = () => { if (getTop) { - domNode.style.width = `${editor.getLayoutInfo().contentWidth}px`; + domNode.style.width = `${getEditorContentWidth(editor)}px`; domNode.style.top = getTop(); } // must return null, so that Monaco knows the widget will position diff --git a/client/src/utils/scrollbar-width.ts b/client/src/utils/scrollbar-width.ts new file mode 100644 index 00000000000..186ed53450e --- /dev/null +++ b/client/src/utils/scrollbar-width.ts @@ -0,0 +1,6 @@ +import store from 'store'; + +export function getScrollbarWidth(): number { + const storedWidth = parseInt(store.get('monacoScrollbarWidth')); + return storedWidth >= 5 || storedWidth <= 25 ? storedWidth : 5; +} diff --git a/cypress/e2e/default/settings/scrollbar-width.ts b/cypress/e2e/default/settings/scrollbar-width.ts new file mode 100644 index 00000000000..c6fbe943f27 --- /dev/null +++ b/cypress/e2e/default/settings/scrollbar-width.ts @@ -0,0 +1,41 @@ +describe('Editor scrollbar width', () => { + beforeEach(() => { + cy.login(); + cy.visit('/settings'); + }); + + let upperJawWidth; + + it('Default editor scrollbar width should be 5px', () => { + cy.get('#scrollbar-width-slider').should('have.value', '5'); + cy.visit( + '/learn/2022/responsive-web-design/learn-html-by-building-a-cat-photo-app/step-2' + ); + cy.get('.editor-upper-jaw').then($editorUpperJaw => { + upperJawWidth = Number($editorUpperJaw.outerWidth()); + }); + cy.get('#editor-lower-jaw').should($editorLowerJaw => { + expect(upperJawWidth).to.equal(Number($editorLowerJaw.outerWidth())); + }); + cy.get('.monaco-scrollable-element').should($scrollable => { + expect(upperJawWidth).to.equal(Number($scrollable.outerWidth()) - 5); + }); + }); + + it('Should allow you to change editor scrollbar width to 25px', () => { + cy.get('.scrollbar-width-numbers > [data-value="25"]').click(); + cy.get('#scrollbar-width-slider').should('have.value', '25'); + cy.visit( + '/learn/2022/responsive-web-design/learn-html-by-building-a-cat-photo-app/step-2' + ); + cy.get('.editor-upper-jaw').then($editorUpperJaw => { + upperJawWidth = Number($editorUpperJaw.outerWidth()); + }); + cy.get('#editor-lower-jaw').should($editorLowerJaw => { + expect(upperJawWidth).to.equal(Number($editorLowerJaw.outerWidth())); + }); + cy.get('.monaco-scrollable-element').should($scrollable => { + expect(upperJawWidth).to.equal(Number($scrollable.outerWidth()) - 25); + }); + }); +});