From 9372acecf73119280a6a043c4e2582be4f88a624 Mon Sep 17 00:00:00 2001 From: Sem Bauke Date: Thu, 21 May 2026 11:27:47 +0200 Subject: [PATCH] fix: restore mobile upper jaw touch gestures (#66752) --- .../classic/content-widget-events.test.tsx | 386 ++++++++++++++++++ .../classic/content-widget-events.ts | 273 +++++++++++++ .../templates/Challenges/classic/editor.css | 7 + .../templates/Challenges/classic/editor.tsx | 24 +- .../src/templates/Challenges/classic/show.tsx | 37 -- e2e/upper-jaw-scroll-mobile.spec.ts | 241 +++++++++++ 6 files changed, 927 insertions(+), 41 deletions(-) create mode 100644 client/src/templates/Challenges/classic/content-widget-events.test.tsx create mode 100644 client/src/templates/Challenges/classic/content-widget-events.ts create mode 100644 e2e/upper-jaw-scroll-mobile.spec.ts diff --git a/client/src/templates/Challenges/classic/content-widget-events.test.tsx b/client/src/templates/Challenges/classic/content-widget-events.test.tsx new file mode 100644 index 00000000000..34cb792120a --- /dev/null +++ b/client/src/templates/Challenges/classic/content-widget-events.test.tsx @@ -0,0 +1,386 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { attachContentWidgetEvents } from './content-widget-events'; + +const createPointerEvent = ( + type: 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel', + x: number, + y: number, + options?: { + pointerId?: number; + pointerType?: 'touch' | 'mouse' | 'pen'; + } +): PointerEvent => { + const pointerId = options?.pointerId ?? 1; + const pointerType = options?.pointerType ?? 'touch'; + + if (typeof PointerEvent === 'function') { + return new PointerEvent(type, { + bubbles: true, + cancelable: true, + pointerId, + pointerType, + clientX: x, + clientY: y + }); + } + + const event = new Event(type, { + bubbles: true, + cancelable: true + }); + + Object.defineProperties(event, { + pointerId: { value: pointerId }, + pointerType: { value: pointerType }, + clientX: { value: x }, + clientY: { value: y } + }); + + return event as PointerEvent; +}; + +const createTouchEvent = ( + type: 'touchstart' | 'touchmove' | 'touchend' | 'touchcancel', + x: number, + y: number, + identifier = 7 +): TouchEvent => { + const touch = { + identifier, + clientX: x, + clientY: y + }; + const event = new Event(type, { + bubbles: true, + cancelable: true + }); + Object.defineProperty(event, 'changedTouches', { + value: { + item: () => touch + } + }); + return event as TouchEvent; +}; + +const createDomTree = () => { + document.body.innerHTML = ` +
+
+
+
+ +

paragraph

+
+ Example Code +
example code
+
+
+
+
+ `; + + const monacoEditor = document.querySelector('.monaco-editor'); + const scrollable = document.querySelector( + '.monaco-scrollable-element.editor-scrollable' + ); + const upperJaw = document.querySelector('.editor-upper-jaw'); + const paragraph = document.querySelector('.description-container p'); + const breadcrumbLink = document.querySelector('.description-container a'); + const summary = document.querySelector( + '.editor-upper-jaw details.code-details summary.code-details-summary' + ); + const pre = document.querySelector( + '.editor-upper-jaw details.code-details pre' + ); + + if ( + !(monacoEditor instanceof HTMLElement) || + !(scrollable instanceof HTMLElement) || + !(upperJaw instanceof HTMLElement) || + !(paragraph instanceof HTMLElement) || + !(breadcrumbLink instanceof HTMLAnchorElement) || + !(summary instanceof HTMLElement) || + !(pre instanceof HTMLElement) + ) { + throw new Error('Failed to construct test DOM'); + } + + Object.defineProperty(pre, 'scrollWidth', { value: 500, configurable: true }); + Object.defineProperty(pre, 'clientWidth', { value: 200, configurable: true }); + + return { + monacoEditor, + scrollable, + upperJaw, + paragraph, + breadcrumbLink, + summary, + pre + }; +}; + +describe('content widget event handling', () => { + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('stops bubbling for contextmenu while preserving default behavior', () => { + const { monacoEditor, upperJaw, paragraph } = createDomTree(); + const onParentContextMenu = vi.fn(); + monacoEditor.addEventListener('contextmenu', onParentContextMenu); + const detachListeners = attachContentWidgetEvents(upperJaw); + + const contextMenu = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true + }); + paragraph.dispatchEvent(contextMenu); + + expect(onParentContextMenu).not.toHaveBeenCalled(); + expect(contextMenu.defaultPrevented).toBe(false); + detachListeners(); + }); + + it('dispatches wheel scroll for vertical touch drags and stops propagation', () => { + const { monacoEditor, scrollable, upperJaw, paragraph } = createDomTree(); + const wheelEvents: WheelEvent[] = []; + scrollable.addEventListener('wheel', e => { + wheelEvents.push(e); + }); + const onParentPointerMove = vi.fn(); + monacoEditor.addEventListener('pointermove', onParentPointerMove); + const detachListeners = attachContentWidgetEvents(upperJaw); + + paragraph.dispatchEvent(createPointerEvent('pointerdown', 100, 100)); + const pointerMove = createPointerEvent('pointermove', 100, 140); + paragraph.dispatchEvent(pointerMove); + + expect(wheelEvents).toHaveLength(1); + expect(wheelEvents[0]?.deltaY).toBe(100); + expect(pointerMove.defaultPrevented).toBe(true); + expect(onParentPointerMove).not.toHaveBeenCalled(); + + detachListeners(); + }); + + it('does not force vertical wheel scroll for horizontal code-region drags', () => { + const { monacoEditor, scrollable, upperJaw, pre } = createDomTree(); + const wheelEvents: WheelEvent[] = []; + scrollable.addEventListener('wheel', e => { + wheelEvents.push(e); + }); + const onParentPointerMove = vi.fn(); + monacoEditor.addEventListener('pointermove', onParentPointerMove); + const detachListeners = attachContentWidgetEvents(upperJaw); + + pre.dispatchEvent(createPointerEvent('pointerdown', 100, 100)); + const pointerMove = createPointerEvent('pointermove', 160, 112); + pre.dispatchEvent(pointerMove); + + expect(wheelEvents).toHaveLength(0); + expect(pointerMove.defaultPrevented).toBe(false); + expect(onParentPointerMove).not.toHaveBeenCalled(); + + detachListeners(); + }); + + it('treats non-scrollable pre drags as vertical gestures', () => { + const { scrollable, upperJaw, pre } = createDomTree(); + pre.removeAttribute('role'); + Object.defineProperty(pre, 'scrollWidth', { + value: 200, + configurable: true + }); + Object.defineProperty(pre, 'clientWidth', { + value: 200, + configurable: true + }); + const wheelEvents: WheelEvent[] = []; + scrollable.addEventListener('wheel', e => { + wheelEvents.push(e); + }); + const detachListeners = attachContentWidgetEvents(upperJaw); + + pre.dispatchEvent(createPointerEvent('pointerdown', 100, 100)); + const pointerMove = createPointerEvent('pointermove', 160, 130); + pre.dispatchEvent(pointerMove); + + expect(wheelEvents).toHaveLength(1); + expect(pointerMove.defaultPrevented).toBe(true); + + detachListeners(); + }); + + it('keeps breadcrumb touches from bubbling to parent handlers', () => { + const { monacoEditor, scrollable, upperJaw, breadcrumbLink } = + createDomTree(); + const wheelEvents: WheelEvent[] = []; + scrollable.addEventListener('wheel', e => { + wheelEvents.push(e); + }); + const onParentPointerMove = vi.fn(); + monacoEditor.addEventListener('pointermove', onParentPointerMove); + const detachListeners = attachContentWidgetEvents(upperJaw); + + breadcrumbLink.dispatchEvent(createPointerEvent('pointerdown', 100, 100)); + const pointerMove = createPointerEvent('pointermove', 100, 140); + breadcrumbLink.dispatchEvent(pointerMove); + + expect(wheelEvents).toHaveLength(0); + expect(pointerMove.defaultPrevented).toBe(false); + expect(onParentPointerMove).not.toHaveBeenCalled(); + + detachListeners(); + }); + + it('keeps example-code summary touches from bubbling to parent handlers', () => { + const { monacoEditor, scrollable, upperJaw, summary } = createDomTree(); + const wheelEvents: WheelEvent[] = []; + scrollable.addEventListener('wheel', e => { + wheelEvents.push(e); + }); + const onParentPointerMove = vi.fn(); + monacoEditor.addEventListener('pointermove', onParentPointerMove); + const detachListeners = attachContentWidgetEvents(upperJaw); + + summary.dispatchEvent(createPointerEvent('pointerdown', 100, 100)); + const pointerMove = createPointerEvent('pointermove', 100, 140); + summary.dispatchEvent(pointerMove); + + expect(wheelEvents).toHaveLength(0); + expect(pointerMove.defaultPrevented).toBe(false); + expect(onParentPointerMove).not.toHaveBeenCalled(); + + detachListeners(); + }); + + it('does not dispatch wheel for tiny movements below threshold', () => { + const { monacoEditor, scrollable, upperJaw, paragraph } = createDomTree(); + const wheelEvents: WheelEvent[] = []; + scrollable.addEventListener('wheel', e => { + wheelEvents.push(e); + }); + const onParentPointerMove = vi.fn(); + monacoEditor.addEventListener('pointermove', onParentPointerMove); + const detachListeners = attachContentWidgetEvents(upperJaw); + + paragraph.dispatchEvent(createPointerEvent('pointerdown', 100, 100)); + const pointerMove = createPointerEvent('pointermove', 103, 104); + paragraph.dispatchEvent(pointerMove); + + expect(wheelEvents).toHaveLength(0); + expect(pointerMove.defaultPrevented).toBe(false); + expect(onParentPointerMove).not.toHaveBeenCalled(); + + detachListeners(); + }); + + it('resets touch state on pointerup', () => { + const { monacoEditor, scrollable, upperJaw, paragraph } = createDomTree(); + const wheelEvents: WheelEvent[] = []; + scrollable.addEventListener('wheel', e => { + wheelEvents.push(e); + }); + const onParentPointerMove = vi.fn(); + monacoEditor.addEventListener('pointermove', onParentPointerMove); + const detachListeners = attachContentWidgetEvents(upperJaw); + + paragraph.dispatchEvent(createPointerEvent('pointerdown', 100, 100)); + paragraph.dispatchEvent(createPointerEvent('pointermove', 100, 140)); + paragraph.dispatchEvent(createPointerEvent('pointerup', 100, 140)); + paragraph.dispatchEvent(createPointerEvent('pointermove', 100, 180)); + + expect(wheelEvents).toHaveLength(1); + expect(onParentPointerMove).toHaveBeenCalledTimes(1); + + detachListeners(); + }); + + it('detaches all listeners cleanly', () => { + const { monacoEditor, scrollable, upperJaw, paragraph } = createDomTree(); + const wheelEvents: WheelEvent[] = []; + scrollable.addEventListener('wheel', e => { + wheelEvents.push(e); + }); + const onParentPointerMove = vi.fn(); + const onParentContextMenu = vi.fn(); + monacoEditor.addEventListener('pointermove', onParentPointerMove); + monacoEditor.addEventListener('contextmenu', onParentContextMenu); + const detachListeners = attachContentWidgetEvents(upperJaw); + detachListeners(); + + paragraph.dispatchEvent(createPointerEvent('pointerdown', 100, 100)); + paragraph.dispatchEvent(createPointerEvent('pointermove', 100, 140)); + paragraph.dispatchEvent( + new MouseEvent('contextmenu', { bubbles: true, cancelable: true }) + ); + + expect(wheelEvents).toHaveLength(0); + expect(onParentPointerMove).toHaveBeenCalledTimes(1); + expect(onParentContextMenu).toHaveBeenCalledTimes(1); + }); + + it('ignores non-touch pointer events', () => { + const { scrollable, upperJaw, paragraph } = createDomTree(); + const wheelEvents: WheelEvent[] = []; + scrollable.addEventListener('wheel', e => { + wheelEvents.push(e); + }); + const detachListeners = attachContentWidgetEvents(upperJaw); + + paragraph.dispatchEvent( + createPointerEvent('pointerdown', 100, 100, { pointerType: 'mouse' }) + ); + paragraph.dispatchEvent( + createPointerEvent('pointermove', 100, 160, { pointerType: 'mouse' }) + ); + + expect(wheelEvents).toHaveLength(0); + + detachListeners(); + }); + + it('supports touch events when pointer events are not emitted', () => { + const { monacoEditor, scrollable, upperJaw, paragraph } = createDomTree(); + const wheelEvents: WheelEvent[] = []; + scrollable.addEventListener('wheel', e => { + wheelEvents.push(e); + }); + const onParentTouchMove = vi.fn(); + monacoEditor.addEventListener('touchmove', onParentTouchMove); + const detachListeners = attachContentWidgetEvents(upperJaw); + + paragraph.dispatchEvent(createTouchEvent('touchstart', 100, 100, 11)); + const touchMove = createTouchEvent('touchmove', 100, 140, 11); + paragraph.dispatchEvent(touchMove); + paragraph.dispatchEvent(createTouchEvent('touchend', 100, 140, 11)); + + expect(wheelEvents).toHaveLength(1); + expect(wheelEvents[0]?.deltaY).toBe(100); + expect(touchMove.defaultPrevented).toBe(true); + expect(onParentTouchMove).not.toHaveBeenCalled(); + + detachListeners(); + }); + + it('keeps pointer gesture state when compatibility touch events also fire', () => { + const { scrollable, upperJaw, paragraph } = createDomTree(); + const wheelEvents: WheelEvent[] = []; + scrollable.addEventListener('wheel', e => { + wheelEvents.push(e); + }); + const detachListeners = attachContentWidgetEvents(upperJaw); + + paragraph.dispatchEvent(createPointerEvent('pointerdown', 100, 100)); + paragraph.dispatchEvent(createTouchEvent('touchstart', 95, 95, 22)); + paragraph.dispatchEvent(createPointerEvent('pointermove', 100, 140)); + + expect(wheelEvents).toHaveLength(1); + expect(wheelEvents[0]?.deltaY).toBe(100); + + detachListeners(); + }); +}); diff --git a/client/src/templates/Challenges/classic/content-widget-events.ts b/client/src/templates/Challenges/classic/content-widget-events.ts new file mode 100644 index 00000000000..eae62278a08 --- /dev/null +++ b/client/src/templates/Challenges/classic/content-widget-events.ts @@ -0,0 +1,273 @@ +type TouchGestureMode = 'pending' | 'vertical' | 'horizontal'; +type TouchSource = 'pointer' | 'touch'; + +interface TouchGestureState { + source: TouchSource; + pointerId: number; + mode: TouchGestureMode; + startX: number; + startY: number; + lastY: number; + horizontalScroller: HTMLElement | null; +} + +interface InteractiveTouchState { + source: TouchSource; + pointerId: number; +} + +const MOVE_THRESHOLD = 8; +const TOUCH_VERTICAL_SCROLL_MULTIPLIER = 2.5; +const INTERACTIVE_JAW_SELECTOR = + 'a, summary.code-details-summary, pre[role="region"]'; + +const isInteractiveTarget = (target: EventTarget | null): boolean => + target instanceof Element && !!target.closest(INTERACTIVE_JAW_SELECTOR); + +const findHorizontalScroller = ( + target: EventTarget | null +): HTMLElement | null => { + if (!(target instanceof Element)) return null; + const pre = target.closest('pre'); + if (!(pre instanceof HTMLElement)) return null; + return pre.scrollWidth > pre.clientWidth ? pre : null; +}; + +const dispatchVerticalScrollWheel = ( + upperJawNode: HTMLElement, + deltaY: number +): void => { + if (deltaY === 0) return; + const editorScrollable = upperJawNode + .closest('.monaco-editor') + ?.querySelector('.monaco-scrollable-element.editor-scrollable'); + + if (!(editorScrollable instanceof HTMLElement)) return; + + editorScrollable.dispatchEvent( + new WheelEvent('wheel', { + deltaY, + bubbles: true, + cancelable: true + }) + ); +}; + +const updateGestureMode = ( + touchState: TouchGestureState, + clientX: number, + clientY: number +): void => { + const deltaX = clientX - touchState.startX; + const deltaY = clientY - touchState.startY; + + if ( + touchState.mode === 'pending' && + Math.max(Math.abs(deltaX), Math.abs(deltaY)) >= MOVE_THRESHOLD + ) { + touchState.mode = + touchState.horizontalScroller && Math.abs(deltaX) > Math.abs(deltaY) + ? 'horizontal' + : 'vertical'; + } +}; + +const handleVerticalScroll = ( + e: Event, + upperJawNode: HTMLElement, + touchState: TouchGestureState, + clientY: number +): void => { + if (touchState.mode !== 'vertical') return; + + if (e.cancelable) { + e.preventDefault(); + } + + dispatchVerticalScrollWheel( + upperJawNode, + (clientY - touchState.lastY) * TOUCH_VERTICAL_SCROLL_MULTIPLIER + ); + touchState.lastY = clientY; +}; + +export const attachContentWidgetEvents = ( + upperJawNode: HTMLElement +): (() => void) => { + let touchState: TouchGestureState | null = null; + let interactiveTouchState: InteractiveTouchState | null = null; + + const onContextMenu = (e: MouseEvent): void => { + if (isInteractiveTarget(e.target)) return; + e.stopPropagation(); + }; + + const onPointerDown = (e: PointerEvent): void => { + if (e.pointerType !== 'touch') return; + if (isInteractiveTarget(e.target)) { + interactiveTouchState = { + source: 'pointer', + pointerId: e.pointerId + }; + e.stopPropagation(); + return; + } + + touchState = { + source: 'pointer', + pointerId: e.pointerId, + mode: 'pending', + startX: e.clientX, + startY: e.clientY, + lastY: e.clientY, + horizontalScroller: findHorizontalScroller(e.target) + }; + e.stopPropagation(); + }; + + const onPointerMove = (e: PointerEvent): void => { + if ( + interactiveTouchState && + interactiveTouchState.source === 'pointer' && + e.pointerId === interactiveTouchState.pointerId + ) { + e.stopPropagation(); + return; + } + + if ( + e.pointerType !== 'touch' || + !touchState || + touchState.source !== 'pointer' || + e.pointerId !== touchState.pointerId + ) { + return; + } + + updateGestureMode(touchState, e.clientX, e.clientY); + handleVerticalScroll(e, upperJawNode, touchState, e.clientY); + e.stopPropagation(); + }; + + const onPointerUp = (e: PointerEvent): void => { + if ( + interactiveTouchState && + interactiveTouchState.source === 'pointer' && + e.pointerId === interactiveTouchState.pointerId + ) { + interactiveTouchState = null; + e.stopPropagation(); + return; + } + + if ( + !touchState || + touchState.source !== 'pointer' || + e.pointerId !== touchState.pointerId + ) { + return; + } + + touchState = null; + e.stopPropagation(); + }; + + const onTouchStart = (e: TouchEvent): void => { + if (touchState?.source === 'pointer') return; + + const touch = e.changedTouches.item(0); + if (!touch) return; + + if (isInteractiveTarget(e.target)) { + interactiveTouchState = { + source: 'touch', + pointerId: touch.identifier + }; + e.stopPropagation(); + return; + } + + touchState = { + source: 'touch', + pointerId: touch.identifier, + mode: 'pending', + startX: touch.clientX, + startY: touch.clientY, + lastY: touch.clientY, + horizontalScroller: findHorizontalScroller(e.target) + }; + e.stopPropagation(); + }; + + const onTouchMove = (e: TouchEvent): void => { + if (interactiveTouchState?.source === 'touch') { + const interactiveTouch = e.changedTouches.item(0); + if ( + interactiveTouch && + interactiveTouch.identifier === interactiveTouchState.pointerId + ) { + e.stopPropagation(); + return; + } + } + + if (!touchState || touchState.source !== 'touch') return; + + const touch = e.changedTouches.item(0); + if (!touch || touch.identifier !== touchState.pointerId) return; + + updateGestureMode(touchState, touch.clientX, touch.clientY); + handleVerticalScroll(e, upperJawNode, touchState, touch.clientY); + e.stopPropagation(); + }; + + const onTouchEnd = (e: TouchEvent): void => { + if (interactiveTouchState?.source === 'touch') { + const interactiveTouch = e.changedTouches.item(0); + if ( + interactiveTouch && + interactiveTouch.identifier === interactiveTouchState.pointerId + ) { + interactiveTouchState = null; + e.stopPropagation(); + return; + } + } + + if (!touchState || touchState.source !== 'touch') return; + + const touch = e.changedTouches.item(0); + if (!touch || touch.identifier !== touchState.pointerId) return; + + touchState = null; + e.stopPropagation(); + }; + + upperJawNode.addEventListener('contextmenu', onContextMenu); + upperJawNode.addEventListener('pointerdown', onPointerDown, { + passive: true + }); + upperJawNode.addEventListener('pointermove', onPointerMove, { + passive: false + }); + upperJawNode.addEventListener('pointerup', onPointerUp, { passive: true }); + upperJawNode.addEventListener('pointercancel', onPointerUp, { + passive: true + }); + upperJawNode.addEventListener('touchstart', onTouchStart, { passive: true }); + upperJawNode.addEventListener('touchmove', onTouchMove, { passive: false }); + upperJawNode.addEventListener('touchend', onTouchEnd, { passive: true }); + upperJawNode.addEventListener('touchcancel', onTouchEnd, { passive: true }); + + return () => { + upperJawNode.removeEventListener('contextmenu', onContextMenu); + upperJawNode.removeEventListener('pointerdown', onPointerDown); + upperJawNode.removeEventListener('pointermove', onPointerMove); + upperJawNode.removeEventListener('pointerup', onPointerUp); + upperJawNode.removeEventListener('pointercancel', onPointerUp); + upperJawNode.removeEventListener('touchstart', onTouchStart); + upperJawNode.removeEventListener('touchmove', onTouchMove); + upperJawNode.removeEventListener('touchend', onTouchEnd); + upperJawNode.removeEventListener('touchcancel', onTouchEnd); + }; +}; diff --git a/client/src/templates/Challenges/classic/editor.css b/client/src/templates/Challenges/classic/editor.css index c45f40ceb00..789e8ce3172 100644 --- a/client/src/templates/Challenges/classic/editor.css +++ b/client/src/templates/Challenges/classic/editor.css @@ -53,6 +53,8 @@ .editor-upper-jaw { max-width: unset !important; + -webkit-user-select: text; + user-select: text; } .editor-upper-jaw code, @@ -60,6 +62,11 @@ white-space: pre-wrap; } +.editor-upper-jaw pre { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + .editor-file-name { font-size: 18px; font-weight: bold; diff --git a/client/src/templates/Challenges/classic/editor.tsx b/client/src/templates/Challenges/classic/editor.tsx index 9a5a5aac84a..4aa31210fd8 100644 --- a/client/src/templates/Challenges/classic/editor.tsx +++ b/client/src/templates/Challenges/classic/editor.tsx @@ -64,6 +64,7 @@ import { getScrollbarWidth } from '../../../utils/scrollbar-width'; import { isProjectBased } from '../../../utils/curriculum-layout'; import envConfig from '../../../../config/env.json'; import LowerJaw from './lower-jaw'; +import { attachContentWidgetEvents } from './content-widget-events'; // Direct from npm, license in react-types-licence import reactTypes from './react-types.json'; @@ -315,6 +316,7 @@ const Editor = (props: EditorProps): JSX.Element => { const submitChallenge = useSubmit(); + const detachUpperJawEventsRef = useRef<(() => void) | null>(null); const player = useRef<{ // eslint-disable-next-line @typescript-eslint/no-explicit-any sampler: any; @@ -334,6 +336,13 @@ const Editor = (props: EditorProps): JSX.Element => { const attemptsRef = useRef(0); attemptsRef.current = props.attempts; + useEffect(() => { + return () => { + detachUpperJawEventsRef.current?.(); + detachUpperJawEventsRef.current = null; + }; + }, []); + const challengeFile = challengeFiles?.find( challengeFile => challengeFile.fileKey === fileKey ); @@ -367,7 +376,8 @@ const Editor = (props: EditorProps): JSX.Element => { verticalScrollbarSize: getScrollbarWidth(), // this helps the scroll bar fit properly between the arrows, // but doesn't do anything for the arrows themselves - arrowSize: getScrollbarWidth() + arrowSize: getScrollbarWidth(), + alwaysConsumeMouseWheel: false }, parameterHints: { enabled: false @@ -880,6 +890,8 @@ const Editor = (props: EditorProps): JSX.Element => { descContainer.classList.add('mathjax-support'); } domNode.classList.add('editor-upper-jaw'); + detachUpperJawEventsRef.current?.(); + detachUpperJawEventsRef.current = attachContentWidgetEvents(domNode); domNode.appendChild(descContainer); if (isMobileLayout) descContainer.appendChild(createBreadcrumb()); descContainer.appendChild(jawHeading); @@ -899,6 +911,7 @@ const Editor = (props: EditorProps): JSX.Element => { obs.observe(domNode); domNode.style.userSelect = 'text'; + domNode.style.webkitUserSelect = 'text'; domNode.style.left = `${editor.getLayoutInfo().contentLeft}px`; domNode.style.width = `${getEditorContentWidth(editor)}px`; @@ -1151,7 +1164,8 @@ const Editor = (props: EditorProps): JSX.Element => { domNode: HTMLDivElement, // If getTop function is not provided then no positioning will be done here. // This allows scroll gutter to do its positioning elsewhere. - getTop?: () => string + getTop?: () => string, + suppressMouseDown = false ) => { const getId = () => id; const getDomNode = () => domNode; @@ -1175,7 +1189,8 @@ const Editor = (props: EditorProps): JSX.Element => { getId, getDomNode, getPosition, - afterRender + afterRender, + suppressMouseDown }; }; @@ -1192,7 +1207,8 @@ const Editor = (props: EditorProps): JSX.Element => { editor, 'description.widget', descriptionNode, - getDescriptionZoneTop + getDescriptionZoneTop, + true ); // this order (add widget, change zone) is necessary, since the zone // relies on the domnode being in the DOM to calculate its height - that diff --git a/client/src/templates/Challenges/classic/show.tsx b/client/src/templates/Challenges/classic/show.tsx index 6a5bc3028ba..96e41e2c608 100644 --- a/client/src/templates/Challenges/classic/show.tsx +++ b/client/src/templates/Challenges/classic/show.tsx @@ -156,15 +156,6 @@ const BASE_LAYOUT = { testsPane: { flex: 0.3 } }; -// Used to prevent monaco from stealing mouse/touch events on the upper jaw -// content widget so they can trigger their default actions. (Issue #46166) -const handleContentWidgetEvents = (e: MouseEvent | TouchEvent): void => { - const target = e.target as HTMLElement; - if (target?.closest('.editor-upper-jaw')) { - e.stopPropagation(); - } -}; - const StepPreview = ({ dimensions, disableIframe, @@ -332,13 +323,6 @@ function ShowClassic({ useEffect(() => { initializeComponent(title); - // Bug fix for the monaco content widget and touch devices/right mouse - // click. (Issue #46166) - document.addEventListener('mousedown', handleContentWidgetEvents, true); - document.addEventListener('contextmenu', handleContentWidgetEvents, true); - document.addEventListener('touchstart', handleContentWidgetEvents, true); - document.addEventListener('touchmove', handleContentWidgetEvents, true); - document.addEventListener('touchend', handleContentWidgetEvents, true); window.addEventListener('resize', setHtmlHeight); setHtmlHeight(); @@ -346,27 +330,6 @@ function ShowClassic({ return () => { createFiles([]); cancelTests(); - document.removeEventListener( - 'mousedown', - handleContentWidgetEvents, - true - ); - document.removeEventListener( - 'contextmenu', - handleContentWidgetEvents, - true - ); - document.removeEventListener( - 'touchstart', - handleContentWidgetEvents, - true - ); - document.removeEventListener( - 'touchmove', - handleContentWidgetEvents, - true - ); - document.removeEventListener('touchend', handleContentWidgetEvents, true); window.removeEventListener('resize', setHtmlHeight); }; // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/e2e/upper-jaw-scroll-mobile.spec.ts b/e2e/upper-jaw-scroll-mobile.spec.ts new file mode 100644 index 00000000000..535ee03cadd --- /dev/null +++ b/e2e/upper-jaw-scroll-mobile.spec.ts @@ -0,0 +1,241 @@ +import { test, expect, type Locator, type Page } from '@playwright/test'; + +const upperJawSelector = '.editor-upper-jaw'; +const challengeWithExampleUrl = + '/learn/responsive-web-design-v9/workshop-cat-photo-app/step-4'; +const challengeWithHorizontalExampleCodeUrl = + '/learn/responsive-web-design-v9/workshop-cat-photo-app/step-11'; +const challengeWithLongContentUrl = + '/learn/responsive-web-design-v9/workshop-cat-photo-app/step-42'; + +const openChallenge = async (page: Page, challengeUrl: string) => { + await page.addInitScript(() => { + window.localStorage.setItem('hideMobileAppModal', 'true'); + window.localStorage.setItem( + 'mobileAppModalDismissedAt', + JSON.stringify(Date.now()) + ); + }); + + await page.goto(challengeUrl); + await page.locator(upperJawSelector).waitFor({ state: 'visible' }); +}; + +const getTouchPoint = async ( + page: Page, + locator: Locator, + options: { xRatio?: number; yRatio?: number } = {} +): Promise<{ x: number; y: number }> => { + const box = await locator.boundingBox(); + const viewport = page.viewportSize(); + const { xRatio = 0.5, yRatio = 0.4 } = options; + + if (!box || !viewport) { + throw new Error('Touch geometry unavailable'); + } + + const x = Math.round( + Math.min(Math.max(box.x + box.width * xRatio, 10), viewport.width - 10) + ); + const y = Math.round( + Math.min(Math.max(box.y + box.height * yRatio, 10), viewport.height - 10) + ); + + return { x, y }; +}; + +const dragWithHeldTouch = async ( + page: Page, + locator: Locator, + options: { + startXRatio?: number; + startYRatio?: number; + moveByX?: number; + moveByY?: number; + } +) => { + const { + startXRatio = 0.5, + startYRatio = 0.4, + moveByX = 0, + moveByY = -80 + } = options; + const { x, y } = await getTouchPoint(page, locator, { + xRatio: startXRatio, + yRatio: startYRatio + }); + + await locator.dispatchEvent('pointerdown', { + pointerId: 1, + pointerType: 'touch', + isPrimary: true, + clientX: x, + clientY: y + }); + + const dragSteps = 6; + for (let step = 1; step <= dragSteps; step++) { + await locator.dispatchEvent('pointermove', { + pointerId: 1, + pointerType: 'touch', + isPrimary: true, + clientX: x + (moveByX * step) / dragSteps, + clientY: y + (moveByY * step) / dragSteps + }); + await page.waitForTimeout(16); + } + + await locator.dispatchEvent('pointerup', { + pointerId: 1, + pointerType: 'touch', + isPrimary: true, + clientX: x + moveByX, + clientY: y + moveByY + }); +}; + +const expectScrollWhileHolding = async (page: Page) => { + const upperJaw = page.locator(upperJawSelector); + const firstParagraph = page.locator('#description p').first(); + + await expect(upperJaw).toBeVisible(); + await expect(firstParagraph).toBeVisible(); + + const getTop = async () => + await firstParagraph.evaluate(el => el.getBoundingClientRect().top); + + const topBefore = await getTop(); + + await dragWithHeldTouch(page, firstParagraph, { moveByY: -120 }); + + const topDuringDrag = await getTop(); + + expect(topDuringDrag).toBeLessThan(topBefore - 20); +}; + +const expectUpperJawDragScroll = async (page: Page) => { + const upperJaw = page.locator(upperJawSelector); + const firstParagraph = page.locator('#description p').first(); + + await expect(upperJaw).toBeVisible(); + await expect(firstParagraph).toBeVisible(); + + const getTop = async () => + await firstParagraph.evaluate(el => el.getBoundingClientRect().top); + + const topBefore = await getTop(); + + await dragWithHeldTouch(page, upperJaw, { + startXRatio: 0.25, + startYRatio: 0.55, + moveByY: -140 + }); + + const topAfterDrag = await getTop(); + + expect(topAfterDrag).toBeLessThan(topBefore - 20); +}; + +test.use({ + viewport: { width: 393, height: 851 }, + isMobile: true, + hasTouch: true +}); + +test('upper jaw scrolls while touch is held on a challenge with example content', async ({ + page +}) => { + await openChallenge(page, challengeWithExampleUrl); + await expect(page.locator('#description details.code-details')).toHaveCount( + 1 + ); + + await expectScrollWhileHolding(page); +}); + +test('upper jaw scrolls while touch is held on long challenge content', async ({ + page +}) => { + await openChallenge(page, challengeWithLongContentUrl); + await expect(page.locator('#description p')).toHaveCount(4); + + await expectScrollWhileHolding(page); +}); + +test('upper jaw drag gesture scrolls when drag starts on upper jaw container', async ({ + page +}) => { + await openChallenge(page, challengeWithLongContentUrl); + + await expectUpperJawDragScroll(page); +}); + +test('breadcrumb links and example code dropdown are interactive on mobile', async ({ + page +}) => { + await openChallenge(page, challengeWithExampleUrl); + + const details = page.locator('#description details.code-details').first(); + const summary = details.locator('summary.code-details-summary'); + await expect(details).toBeVisible(); + await expect(summary).toBeVisible(); + + const initiallyOpen = await details.evaluate(el => el.hasAttribute('open')); + await summary.tap(); + await expect + .poll(async () => await details.evaluate(el => el.hasAttribute('open'))) + .toBe(!initiallyOpen); + await summary.tap(); + await expect + .poll(async () => await details.evaluate(el => el.hasAttribute('open'))) + .toBe(initiallyOpen); + + const mobileBreadcrumb = page.getByTestId('breadcrumb-mobile'); + await expect(mobileBreadcrumb).toBeVisible(); + await mobileBreadcrumb + .getByRole('link', { name: 'Responsive Web Design Certification' }) + .tap(); + await expect(page).toHaveURL('/learn/responsive-web-design-v9'); +}); + +test('example code horizontal gesture does not vertically scroll the upper jaw', async ({ + page +}) => { + await openChallenge(page, challengeWithHorizontalExampleCodeUrl); + + const firstParagraph = page.locator('#description p').first(); + const details = page.locator('#description details.code-details').first(); + const summary = details.locator('summary.code-details-summary'); + await expect(details).toBeVisible(); + + const isOpen = await details.evaluate(el => el.hasAttribute('open')); + if (!isOpen) { + await summary.tap(); + } + + const codeRegion = details.locator('pre[role="region"]').first(); + await expect(codeRegion).toBeVisible(); + + const overflowMetrics = await codeRegion.evaluate(el => ({ + scrollWidth: el.scrollWidth, + clientWidth: el.clientWidth + })); + expect(overflowMetrics.scrollWidth).toBeGreaterThan( + overflowMetrics.clientWidth + ); + + const topBefore = await firstParagraph.evaluate( + el => el.getBoundingClientRect().top + ); + await dragWithHeldTouch(page, codeRegion, { + startXRatio: 0.8, + startYRatio: 0.5, + moveByX: -120, + moveByY: 0 + }); + const topAfter = await firstParagraph.evaluate( + el => el.getBoundingClientRect().top + ); + + expect(Math.abs(topAfter - topBefore)).toBeLessThan(10); +});