[web] Handle scroll in HTML platform views
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart
index 72224d4..6285f304 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart
@@ -478,6 +478,8 @@
 
   external double scrollTop;
   external double scrollLeft;
+  external double get scrollHeight;
+  external double get scrollWidth;
   external DomTokenList get classList;
   external String className;
 
@@ -1920,6 +1922,10 @@
   @JS('changedTouches')
   external _DomList get _changedTouches;
   Iterable<DomTouch> get changedTouches => _createDomListWrapper<DomTouch>(_changedTouches);
+
+  @JS('touches')
+  external _DomList get _touches;
+  Iterable<DomTouch> get touches => _createDomListWrapper<DomTouch>(_touches);
 }
 
 @JS('Touch')
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/platform_views/content_manager.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_views/content_manager.dart
index d27c70c..7d92238 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/platform_views/content_manager.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/platform_views/content_manager.dart
@@ -151,6 +151,13 @@
         ..id = getPlatformViewDomId(viewId)
         ..setAttribute('slot', slotName);
 
+      // Enable touch scrolling inside platform views.
+      // Flutter sets touch-action: none on the body to capture all touch events,
+      // but this prevents native touch scrolling inside platform views.
+      // We override it here to allow touch scrolling for HTML content.
+      // This fixes GitHub issue #179360.
+      setElementStyle(wrapper, 'touch-action', 'pan-y pan-x');
+
       final Function factoryFunction = _factories[viewType]!;
       final DomElement content;
 
@@ -164,12 +171,113 @@
       _ensureContentCorrectlySized(content, viewType);
       wrapper.append(content);
 
+      // For regular HTML elements, set up touch boundary detection.
+      // When the user scrolls to the boundary of the HTML element,
+      // we need to propagate the scroll to Flutter.
+      _setupTouchBoundaryDetection(content, wrapper);
+
       wrapper.setAttribute(_ariaHiddenAttribute, 'true');
 
       return wrapper;
     });
   }
 
+  /// Sets up touch boundary detection for regular HTML elements.
+  /// When the user scrolls to the boundary of the HTML element during touch scrolling,
+  /// we prevent the default to stop the HTML element from rubber-banding and let
+  /// Flutter handle the scroll.
+  void _setupTouchBoundaryDetection(DomElement content, DomElement wrapper) {
+    double? lastTouchY;
+    bool? browserOwnsScroll; // Once browser takes over (cancelable=false), it owns the gesture
+
+    content.addEventListener(
+      'touchstart',
+      createDomEventListener((DomEvent event) {
+        final touchEvent = event as DomTouchEvent;
+        final Iterable<DomTouch> touches = touchEvent.touches;
+        if (touches.isNotEmpty) {
+          lastTouchY = touches.first.clientY;
+          browserOwnsScroll = null; // Reset on new touch
+        }
+      }),
+      <String, bool>{'passive': true}.toJSAnyDeep,
+    );
+
+    content.addEventListener(
+      'touchmove',
+      createDomEventListener((DomEvent event) {
+        final touchEvent = event as DomTouchEvent;
+        final Iterable<DomTouch> touches = touchEvent.touches;
+        if (touches.isEmpty || lastTouchY == null) {
+          return;
+        }
+
+        final double currentY = touches.first.clientY;
+        final double deltaY = lastTouchY! - currentY; // Positive = scrolling down
+        lastTouchY = currentY;
+
+        // Find the scrollable element (could be content itself or a child)
+        final DomElement scrollable = _findScrollableElement(content);
+
+        final double scrollTop = scrollable.scrollTop;
+        final double scrollHeight = scrollable.scrollHeight;
+        final double clientHeight = scrollable.clientHeight;
+        final double maxScroll = scrollHeight - clientHeight;
+
+        // Check if at boundary in the direction of scroll
+        final bool atTop = scrollTop <= 1 && deltaY < 0; // Trying to scroll up at top
+        final bool atBottom =
+            scrollTop >= maxScroll - 1 && deltaY > 0; // Trying to scroll down at bottom
+        final bool atBoundary = atTop || atBottom;
+
+        // Once browser takes over (first non-cancelable event), it owns this gesture
+        if (!event.cancelable) {
+          browserOwnsScroll = true;
+        }
+
+        // Only prevent if:
+        // 1. Event is cancelable (we can still prevent it)
+        // 2. We're at a boundary in the scroll direction
+        // 3. Browser hasn't already taken over this gesture
+        if (event.cancelable && atBoundary && browserOwnsScroll != true) {
+          // At boundary - prevent default to stop rubber-banding
+          // and let Flutter handle the scroll
+          event.preventDefault();
+        }
+      }),
+      <String, bool>{'passive': false}.toJSAnyDeep,
+    );
+  }
+
+  /// Finds the scrollable element within a platform view content.
+  /// Returns the content itself if it's scrollable, or searches for a scrollable child.
+  DomElement _findScrollableElement(DomElement content) {
+    // Check if content itself is scrollable
+    if (_isScrollable(content)) {
+      return content;
+    }
+
+    // Search for a scrollable child
+    final Iterable<DomElement> children = content.querySelectorAll('*');
+    for (final child in children) {
+      if (_isScrollable(child)) {
+        return child;
+      }
+    }
+
+    // Default to content
+    return content;
+  }
+
+  bool _isScrollable(DomElement element) {
+    final String overflowY = element.style.overflowY;
+    final String overflow = element.style.overflow;
+    final bool hasOverflow =
+        overflowY == 'auto' || overflowY == 'scroll' || overflow == 'auto' || overflow == 'scroll';
+    final bool hasContent = element.scrollHeight > element.clientHeight;
+    return hasOverflow && hasContent;
+  }
+
   /// Removes a PlatformView by its `viewId` from the manager, and from the DOM.
   ///
   /// Once a view has been cleared, calls to [knowsViewId] will fail, as if it had
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_binding.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_binding.dart
index b3266f4..c429922 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_binding.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_binding.dart
@@ -772,14 +772,47 @@
     }
 
     assert(event.isA<DomWheelEvent>());
+    final wheelEvent = event as DomWheelEvent;
     if (_debugLogPointerEvents) {
       print(event.type);
     }
+
+    // Check if the event target is inside a platform view with regular HTML content
+    // (not a cross-origin iframe). If so, check if the HTML element can scroll.
+    // This fixes GitHub issue #179360.
+    final _HtmlScrollableInfo? htmlScrollInfo = _getHtmlScrollableInfo(event);
+    if (htmlScrollInfo != null) {
+      // We're over a regular HTML element in a platform view.
+      // Check if it can scroll in the direction of the wheel event.
+      final bool canScrollInDirection = _canHtmlElementScroll(
+        htmlScrollInfo.scrollableElement,
+        wheelEvent.deltaX,
+        wheelEvent.deltaY,
+      );
+
+      if (canScrollInDirection) {
+        // The HTML element can scroll in this direction.
+        // We need to programmatically scroll the HTML element because Flutter's
+        // event listener captures wheel events at the glass pane level, preventing
+        // them from naturally propagating to the HTML element.
+        final DomElement scrollable = htmlScrollInfo.scrollableElement;
+        scrollable.scrollTop = scrollable.scrollTop + wheelEvent.deltaY;
+        if (wheelEvent.deltaX != 0) {
+          scrollable.scrollLeft = scrollable.scrollLeft + wheelEvent.deltaX;
+        }
+
+        // Prevent default and stop - we handled the scroll
+        event.preventDefault();
+        return;
+      }
+      // At boundary - fall through to let Flutter handle it
+    }
+
     _lastWheelEventAllowedDefault = false;
     // [ui.PointerData] can set the `_lastWheelEventAllowedDefault` variable
     // to true, when the framework says so. See the implementation of `respond`
     // when creating the PointerData object above.
-    _callback(event, _convertWheelEventToPointerData(event as DomWheelEvent));
+    _callback(event, _convertWheelEventToPointerData(wheelEvent));
     // This works because the `_callback` is handled synchronously in the
     // framework, so it's able to modify `_lastWheelEventAllowedDefault`.
     if (!_lastWheelEventAllowedDefault) {
@@ -787,6 +820,111 @@
     }
   }
 
+  /// Information about a scrollable HTML element inside a platform view.
+  _HtmlScrollableInfo? _getHtmlScrollableInfo(DomEvent event) {
+    final target = event.target as DomElement?;
+    if (target == null) {
+      return null;
+    }
+
+    // Walk up the DOM tree to find the scrollable element and platform view
+    DomElement? scrollableElement;
+    DomElement? current = target;
+
+    while (current != null) {
+      final String tagName = current.tagName.toLowerCase();
+
+      // Check if this element is scrollable
+      if (scrollableElement == null) {
+        final String overflow = current.style.overflow;
+        final String overflowY = current.style.overflowY;
+        if (overflow == 'auto' ||
+            overflow == 'scroll' ||
+            overflowY == 'auto' ||
+            overflowY == 'scroll') {
+          scrollableElement = current;
+        }
+      }
+
+      // Check if we've reached the flt-platform-view wrapper
+      if (tagName == 'flt-platform-view') {
+        // Found a platform view - check if its first child is a cross-origin iframe
+        final DomElement? content = current.firstElementChild;
+        if (content == null) {
+          return null;
+        }
+
+        // If the content is an iframe, we have the wheel overlay handling it
+        if (content.tagName.toLowerCase() == 'iframe') {
+          return null;
+        }
+
+        // It's a regular HTML element - return the scrollable info
+        // Use the scrollable element we found, or the content itself
+        return _HtmlScrollableInfo(scrollableElement: scrollableElement ?? content);
+      }
+
+      // Stop searching if we've exited the flutter-view
+      if (tagName == 'flutter-view') {
+        break;
+      }
+
+      current = current.parentElement;
+    }
+
+    return null;
+  }
+
+  /// Checks if an HTML element can scroll in the given direction.
+  /// Returns true if the element can handle the scroll, false if it's at boundary.
+  bool _canHtmlElementScroll(DomElement element, num deltaX, num deltaY) {
+    final double scrollTop = element.scrollTop;
+    final double scrollLeft = element.scrollLeft;
+    final double scrollHeight = element.scrollHeight;
+    final double scrollWidth = element.scrollWidth;
+    final double clientHeight = element.clientHeight;
+    final double clientWidth = element.clientWidth;
+
+    // Check if the element is actually scrollable (has overflow content)
+    final bool hasVerticalOverflow = scrollHeight > clientHeight + 1;
+    final bool hasHorizontalOverflow = scrollWidth > clientWidth + 1;
+
+    // Check vertical scrolling
+    if (deltaY != 0 && hasVerticalOverflow) {
+      if (deltaY > 0) {
+        // Scrolling down - can scroll if not at bottom
+        final bool canScrollDown = scrollTop + clientHeight < scrollHeight - 1;
+        if (canScrollDown) {
+          return true;
+        }
+      } else {
+        // Scrolling up - can scroll if not at top
+        final bool canScrollUp = scrollTop > 1;
+        if (canScrollUp) {
+          return true;
+        }
+      }
+    }
+
+    // Check horizontal scrolling
+    if (deltaX != 0 && hasHorizontalOverflow) {
+      if (deltaX > 0) {
+        // Scrolling right - can scroll if not at right edge
+        if (scrollLeft + clientWidth < scrollWidth - 1) {
+          return true;
+        }
+      } else {
+        // Scrolling left - can scroll if not at left edge
+        if (scrollLeft > 1) {
+          return true;
+        }
+      }
+    }
+
+    // Element is at boundary in the scroll direction
+    return false;
+  }
+
   /// For browsers that report delta line instead of pixels such as FireFox
   /// compute line height using the default font size.
   ///
@@ -820,6 +958,17 @@
   String toString() => '$runtimeType(change: $change, buttons: $buttons)';
 }
 
+/// Information about a scrollable HTML element inside a platform view.
+/// Used to determine if wheel events should be handled by the HTML element
+/// or passed to Flutter.
+@immutable
+class _HtmlScrollableInfo {
+  const _HtmlScrollableInfo({required this.scrollableElement});
+
+  /// The scrollable HTML element (has overflow: auto/scroll).
+  final DomElement scrollableElement;
+}
+
 class _ButtonSanitizer {
   int _pressedButtons = 0;
 
diff --git a/engine/src/flutter/lib/web_ui/test/engine/platform_views/content_manager_test.dart b/engine/src/flutter/lib/web_ui/test/engine/platform_views/content_manager_test.dart
index e9e9b31..7f623f4 100644
--- a/engine/src/flutter/lib/web_ui/test/engine/platform_views/content_manager_test.dart
+++ b/engine/src/flutter/lib/web_ui/test/engine/platform_views/content_manager_test.dart
@@ -309,5 +309,32 @@
         expect(() => contentManager.updatePlatformViewAccessibility(999, false), returnsNormally);
       });
     });
+
+    group('touch scrolling support', () {
+      setUp(() {
+        contentManager.registerFactory(viewType, (int id) => createDomHTMLDivElement());
+      });
+
+      test('sets touch-action style to enable native touch scrolling', () {
+        final DomElement wrapper = contentManager.renderContent(viewType, viewId, null);
+
+        // Browser may return 'pan-x pan-y' or 'pan-y pan-x' depending on implementation
+        final String touchAction = wrapper.style.touchAction;
+        expect(
+          touchAction.contains('pan-x') && touchAction.contains('pan-y'),
+          isTrue,
+          reason: 'Platform views should have touch-action set to enable native touch scrolling',
+        );
+      });
+
+      test('sets up touch event listeners on content element', () {
+        final DomElement wrapper = contentManager.renderContent(viewType, viewId, null);
+        final DomElement content = wrapper.querySelector('div')!;
+
+        // Verify content element exists and is properly nested
+        expect(content, isNotNull);
+        expect(content.parentElement, equals(wrapper));
+      });
+    });
   });
 }
diff --git a/engine/src/flutter/lib/web_ui/test/engine/pointer_binding_test.dart b/engine/src/flutter/lib/web_ui/test/engine/pointer_binding_test.dart
index 72b6583..b03449e 100644
--- a/engine/src/flutter/lib/web_ui/test/engine/pointer_binding_test.dart
+++ b/engine/src/flutter/lib/web_ui/test/engine/pointer_binding_test.dart
@@ -1193,6 +1193,54 @@
     ui_web.browser.debugOperatingSystemOverride = null;
   });
 
+  group('wheel event over HTML platform view', () {
+    test('wheel event dispatched to framework when not over platform view', () {
+      // When wheel event target is the root element (not inside platform view),
+      // _getHtmlScrollableInfo returns null and event is dispatched to framework.
+      final _ButtonedEventMixin context = _PointerEventContext();
+      final packets = <ui.PointerDataPacket>[];
+      ui.PlatformDispatcher.instance.onPointerDataPacket = (ui.PointerDataPacket packet) {
+        packets.add(packet);
+      };
+
+      final DomEvent event = context.wheel(
+        buttons: 0,
+        clientX: 10,
+        clientY: 10,
+        deltaX: 0,
+        deltaY: 100,
+      );
+      rootElement.dispatchEvent(event);
+
+      // Event should be handled by Flutter (dispatched to framework)
+      // First wheel event generates 2 data items: synthesized add + scroll
+      expect(packets, hasLength(1));
+      expect(packets[0].data, hasLength(2));
+      expect(packets[0].data[0].change, equals(ui.PointerChange.add));
+      expect(packets[0].data[1].signalKind, equals(ui.PointerSignalKind.scroll));
+      expect(packets[0].data[1].scrollDeltaY, equals(100.0));
+      // And preventDefault should be called (since no widget allowed platform default)
+      expect(event.defaultPrevented, isTrue);
+    });
+
+    test('wheel event preventDefault is called when framework does not allow platform default', () {
+      final _ButtonedEventMixin context = _PointerEventContext();
+
+      // Don't set onPointerDataPacket - framework doesn't respond
+      final DomEvent event = context.wheel(
+        buttons: 0,
+        clientX: 10,
+        clientY: 10,
+        deltaX: 0,
+        deltaY: 100,
+      );
+      rootElement.dispatchEvent(event);
+
+      // preventDefault should be called since no widget allowed platform default
+      expect(event.defaultPrevented, isTrue);
+    });
+  });
+
   test('does calculate delta and pointer identifier correctly', () {
     final _ButtonedEventMixin context = _PointerEventContext();
     final packets = <ui.PointerDataPacket>[];