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 = `
+
+ `;
+
+ 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);
+});