fix: restore mobile upper jaw touch gestures (#66752)

This commit is contained in:
Sem Bauke
2026-05-21 11:27:47 +02:00
committed by GitHub
parent bd3ae45814
commit 9372acecf7
6 changed files with 927 additions and 41 deletions
@@ -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 = `
<div class="monaco-editor">
<div class="monaco-scrollable-element editor-scrollable"></div>
<div class="editor-upper-jaw">
<div class="description-container">
<nav>
<a href="/learn/responsive-web-design-v9">Responsive Web Design</a>
</nav>
<p>paragraph</p>
<details class="code-details" open>
<summary class="code-details-summary">Example Code</summary>
<pre role="region">example code</pre>
</details>
</div>
</div>
</div>
`;
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();
});
});
@@ -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);
};
};
@@ -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;
@@ -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<number>(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
@@ -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
+241
View File
@@ -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);
});