mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-28 10:22:16 +00:00
fix: restore mobile upper jaw touch gestures (#66752)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user