diff --git a/task-launcher/src/index.html b/task-launcher/src/index.html new file mode 100644 index 000000000..3a382337f --- /dev/null +++ b/task-launcher/src/index.html @@ -0,0 +1,56 @@ + + + + + + + + + + + LEVANTE core tasks + + + +
+ + diff --git a/task-launcher/src/index.ts b/task-launcher/src/index.ts index 142db85b1..a697b5e9b 100644 --- a/task-launcher/src/index.ts +++ b/task-launcher/src/index.ts @@ -15,6 +15,7 @@ import { setTaskStore } from './taskStore'; import { taskStore } from './taskStore'; import { InitPageSetup, Logger } from './utils'; import { getBucketName } from './tasks/shared/helpers/getBucketName'; +import { initScrollRefreshPrevention } from './utils/scrollRefreshPrevention'; export let mediaAssets: MediaAssetsType; let languageAudioAssets: MediaAssetsType; @@ -95,6 +96,9 @@ export class TaskLauncher { } async run() { + // Initialize scroll-to-refresh prevention for tablets + initScrollRefreshPrevention(); + showLevanteLogoLoading(); const { jsPsych, timeline } = await this.init(); hideLevanteLogoLoading(); diff --git a/task-launcher/src/styles/base/_jspsych.scss b/task-launcher/src/styles/base/_jspsych.scss index b6cfedca5..06140581a 100644 --- a/task-launcher/src/styles/base/_jspsych.scss +++ b/task-launcher/src/styles/base/_jspsych.scss @@ -18,6 +18,16 @@ background-color: $bg-workspace-base; background-image: url('https://storage.googleapis.com/tasks-shared/levante-background.png'); background-size: cover; + + /* Prevent scroll-to-refresh and overscroll behavior */ + overscroll-behavior: none; + overscroll-behavior-y: none; + -webkit-overflow-scrolling: touch; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; .jspsych-content-wrapper { height: 100%; diff --git a/task-launcher/src/styles/index.scss b/task-launcher/src/styles/index.scss index 57a064bb0..4b34d3198 100644 --- a/task-launcher/src/styles/index.scss +++ b/task-launcher/src/styles/index.scss @@ -3,3 +3,46 @@ @forward 'base'; @forward 'layout'; @forward 'pages'; + +/* Global scroll-to-refresh prevention */ +html, body { + height: 100%; + width: 100%; + margin: 0; + padding: 0; + overflow: hidden; + overscroll-behavior: none; + overscroll-behavior-y: none; + -webkit-overflow-scrolling: touch; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + touch-action: manipulation; +} + +/* Prevent pull-to-refresh on all mobile browsers */ +* { + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +/* Prevent zoom on double tap */ +input, textarea, select { + font-size: 16px; +} + +/* Additional mobile-specific prevention */ +@media (hover: none) and (pointer: coarse) { + body { + overscroll-behavior: none; + -webkit-overflow-scrolling: touch; + touch-action: manipulation; + } +} diff --git a/task-launcher/src/utils/scrollRefreshPrevention.ts b/task-launcher/src/utils/scrollRefreshPrevention.ts new file mode 100644 index 000000000..b139e524c --- /dev/null +++ b/task-launcher/src/utils/scrollRefreshPrevention.ts @@ -0,0 +1,233 @@ +/** + * Utility to prevent scroll-to-refresh functionality on tablets and mobile devices + * This prevents users from accidentally refreshing the tasks by pulling down + */ + +export class ScrollRefreshPrevention { + private startY: number = 0; + private isAtTop: boolean = false; + private isPulling: boolean = false; + private pullThreshold: number = 50; // Minimum pull distance to trigger prevention + private isEnabled: boolean = true; + + constructor() { + this.init(); + } + + private init(): void { + if (typeof window === 'undefined') return; + + // Prevent default touch behaviors that could trigger refresh + this.preventTouchBehaviors(); + + // Add touch event listeners + this.addTouchListeners(); + + // Prevent context menu on long press + this.preventContextMenu(); + + // Prevent zoom gestures + this.preventZoom(); + + // Add scroll event listener to track position + this.addScrollListener(); + } + + private preventTouchBehaviors(): void { + // Prevent default touch behaviors + document.addEventListener('touchstart', (e) => { + if (this.isEnabled) { + e.preventDefault(); + } + }, { passive: false }); + + document.addEventListener('touchmove', (e) => { + if (this.isEnabled) { + e.preventDefault(); + } + }, { passive: false }); + + document.addEventListener('touchend', (e) => { + if (this.isEnabled) { + e.preventDefault(); + } + }, { passive: false }); + } + + private addTouchListeners(): void { + let startY = 0; + let currentY = 0; + let isPulling = false; + + document.addEventListener('touchstart', (e) => { + if (!this.isEnabled) return; + + startY = e.touches[0].clientY; + currentY = startY; + isPulling = false; + + // Check if we're at the top of the page + this.isAtTop = window.scrollY === 0; + }, { passive: true }); + + document.addEventListener('touchmove', (e) => { + if (!this.isEnabled || !this.isAtTop) return; + + currentY = e.touches[0].clientY; + const pullDistance = currentY - startY; + + // If pulling down from the top, prevent the default behavior + if (pullDistance > 0) { + isPulling = true; + e.preventDefault(); + + // Add visual feedback if needed (optional) + if (pullDistance > this.pullThreshold) { + this.showPullFeedback(); + } + } + }, { passive: false }); + + document.addEventListener('touchend', () => { + if (!this.isEnabled) return; + + if (isPulling && this.isAtTop) { + // Reset any visual feedback + this.hidePullFeedback(); + } + + isPulling = false; + }, { passive: true }); + } + + private addScrollListener(): void { + let ticking = false; + + const updateScrollPosition = () => { + this.isAtTop = window.scrollY === 0; + ticking = false; + }; + + window.addEventListener('scroll', () => { + if (!ticking) { + requestAnimationFrame(updateScrollPosition); + ticking = true; + } + }, { passive: true }); + } + + private preventContextMenu(): void { + // Prevent context menu on long press + document.addEventListener('contextmenu', (e) => { + if (this.isEnabled) { + e.preventDefault(); + } + }); + } + + private preventZoom(): void { + // Prevent zoom gestures + document.addEventListener('gesturestart', (e) => { + if (this.isEnabled) { + e.preventDefault(); + } + }); + + document.addEventListener('gesturechange', (e) => { + if (this.isEnabled) { + e.preventDefault(); + } + }); + + document.addEventListener('gestureend', (e) => { + if (this.isEnabled) { + e.preventDefault(); + } + }); + } + + private showPullFeedback(): void { + // Optional: Add visual feedback when user tries to pull to refresh + // This could be a subtle indicator that refresh is disabled + const body = document.body; + if (!body.classList.contains('pull-feedback')) { + body.classList.add('pull-feedback'); + } + } + + private hidePullFeedback(): void { + // Remove visual feedback + const body = document.body; + body.classList.remove('pull-feedback'); + } + + public enable(): void { + this.isEnabled = true; + } + + public disable(): void { + this.isEnabled = false; + } + + public destroy(): void { + // Remove all event listeners if needed + this.isEnabled = false; + } +} + +// CSS for pull feedback (optional visual indicator) +export const pullFeedbackCSS = ` + .pull-feedback { + position: relative; + } + + .pull-feedback::before { + content: "Pull to refresh disabled"; + position: fixed; + top: 20px; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 8px 16px; + border-radius: 4px; + font-size: 14px; + z-index: 10000; + pointer-events: none; + animation: fadeInOut 2s ease-in-out; + } + + @keyframes fadeInOut { + 0% { opacity: 0; } + 20% { opacity: 1; } + 80% { opacity: 1; } + 100% { opacity: 0; } + } +`; + +// Initialize the prevention system +let scrollRefreshPrevention: ScrollRefreshPrevention | null = null; + +export function initScrollRefreshPrevention(): ScrollRefreshPrevention { + if (typeof window === 'undefined') { + return null as any; + } + + if (!scrollRefreshPrevention) { + scrollRefreshPrevention = new ScrollRefreshPrevention(); + + // Add CSS for feedback + const style = document.createElement('style'); + style.textContent = pullFeedbackCSS; + document.head.appendChild(style); + } + + return scrollRefreshPrevention; +} + +export function destroyScrollRefreshPrevention(): void { + if (scrollRefreshPrevention) { + scrollRefreshPrevention.destroy(); + scrollRefreshPrevention = null; + } +} diff --git a/task-launcher/test-scroll-refresh.html b/task-launcher/test-scroll-refresh.html new file mode 100644 index 000000000..51cfbc2e9 --- /dev/null +++ b/task-launcher/test-scroll-refresh.html @@ -0,0 +1,273 @@ + + + + + + + + + + + Scroll-to-Refresh Prevention Test + + + +
+
+

🔄 Scroll-to-Refresh Prevention Test

+ +
+

Test Status

+

Ready to test scroll-to-refresh prevention

+

Touch events will be logged here

+
+ +
+

Instructions:

+
    +
  1. Try to pull down from the top of the screen to refresh
  2. +
  3. The page should NOT refresh
  4. +
  5. You should see a red message if pull-to-refresh is attempted
  6. +
  7. Try swiping in different directions
  8. +
  9. Try double-tapping to zoom (should be prevented)
  10. +
+
+ +
+ + +
+ +
+

Expected Behavior:

+
    +
  • ✅ No page refresh when pulling down
  • +
  • ✅ No zoom on double tap
  • +
  • ✅ No context menu on long press
  • +
  • ✅ Smooth touch interactions for buttons
  • +
+
+
+
+ + + + diff --git a/task-launcher/webpack.config.cjs b/task-launcher/webpack.config.cjs index aa8bcb39a..6a5723199 100644 --- a/task-launcher/webpack.config.cjs +++ b/task-launcher/webpack.config.cjs @@ -113,6 +113,15 @@ const webConfig = merge(commonConfig, { plugins: [ new HtmlWebpackPlugin({ title: 'LEVANTE core tasks', + template: 'src/index.html', + meta: { + viewport: 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover', + 'mobile-web-app-capable': 'yes', + 'apple-mobile-web-app-capable': 'yes', + 'apple-mobile-web-app-status-bar-style': 'black-translucent', + 'format-detection': 'telephone=no', + 'msapplication-tap-highlight': 'no', + }, }), ], });