diff --git a/change/react-native-windows-8d2055e1-81e4-41ad-8d20-8eec501733b3.json b/change/react-native-windows-8d2055e1-81e4-41ad-8d20-8eec501733b3.json
new file mode 100644
index 00000000000..7a14ce8521d
--- /dev/null
+++ b/change/react-native-windows-8d2055e1-81e4-41ad-8d20-8eec501733b3.json
@@ -0,0 +1,7 @@
+{
+ "type": "prerelease",
+ "comment": "Allow pointer events on selectable Text",
+ "packageName": "react-native-windows",
+ "email": "erozell@outlook.com",
+ "dependentChangeType": "patch"
+}
diff --git a/packages/@react-native-windows/tester/src/js/examples-win/LegacyTests/SelectableTextTestPage.tsx b/packages/@react-native-windows/tester/src/js/examples-win/LegacyTests/SelectableTextTestPage.tsx
new file mode 100644
index 00000000000..125ad20d1e3
--- /dev/null
+++ b/packages/@react-native-windows/tester/src/js/examples-win/LegacyTests/SelectableTextTestPage.tsx
@@ -0,0 +1,74 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ * Licensed under the MIT License.
+ * @format
+ */
+
+import React from 'react';
+import {Button, StyleSheet, Text, View} from 'react-native';
+
+interface IPressableTextState {
+ count: number;
+ isSelectable: boolean;
+}
+
+const styles = StyleSheet.create({
+ container: {
+ alignItems: 'flex-start',
+ },
+ pressable: {
+ color: 'blue',
+ textDecorationLine: 'underline',
+ },
+});
+
+export class SelectableTextTests extends React.Component<
+ {},
+ IPressableTextState
+> {
+ constructor(props: any) {
+ super(props);
+ this.state = {
+ count: 0,
+ isSelectable: false,
+ };
+ }
+ public render() {
+ const increment = () => this.setState({count: this.state.count + 1});
+ return (
+
+ Pressed: {this.state.count} times.
+
+ Text before{' '}
+
+ click here
+ {' '}
+ text after
+
+
+ );
+ }
+}
+
+export const displayName = (_undefined?: string) => {};
+export const title = 'LegacySelectableTextTest';
+export const description = 'Legacy e2e test for selectable Text hit testing';
+export const examples = [
+ {
+ render: function(): JSX.Element {
+ return ;
+ },
+ },
+];
diff --git a/packages/@react-native-windows/tester/src/js/utils/RNTesterList.windows.js b/packages/@react-native-windows/tester/src/js/utils/RNTesterList.windows.js
index e3b2ffb67a7..b51679aaaac 100644
--- a/packages/@react-native-windows/tester/src/js/utils/RNTesterList.windows.js
+++ b/packages/@react-native-windows/tester/src/js/utils/RNTesterList.windows.js
@@ -150,6 +150,10 @@ const Components: Array = [
key: 'LegacyImageTest',
module: require('../examples-win/LegacyTests/ImageTestPage'),
},
+ {
+ key: 'LegacySelectableTextTest',
+ module: require('../examples-win/LegacyTests/SelectableTextTestPage'),
+ },
{
key: 'LegacyTextHitTestTest',
module: require('../examples-win/LegacyTests/TextHitTestPage'),
diff --git a/packages/e2e-test-app/test/LegacySelectableTextTest.test.ts b/packages/e2e-test-app/test/LegacySelectableTextTest.test.ts
new file mode 100644
index 00000000000..52db0cb7b17
--- /dev/null
+++ b/packages/e2e-test-app/test/LegacySelectableTextTest.test.ts
@@ -0,0 +1,62 @@
+/**
+ * Copyright (c) Microsoft Corporation.
+ * Licensed under the MIT License.
+ *
+ * @format
+ */
+
+import {app} from '@react-native-windows/automation';
+import {dumpVisualTree} from '@react-native-windows/automation-commands';
+import {goToComponentExample} from './RNTesterNavigation';
+
+beforeAll(async () => {
+ await goToComponentExample('LegacySelectableTextTest');
+});
+
+describe('LegacySelectableTextTest', () => {
+ beforeEach(async () => {
+ await clearState();
+ });
+
+ test('PressableWhenNotSelectable', async () => {
+ const textExample = await app.findElementByTestID('text-example');
+ await textExample.click();
+ const dump = await dumpVisualTree('pressed-state');
+ expect(dump).toMatchSnapshot();
+ });
+
+ test('DoubleClickWhenNotSelectable', async () => {
+ const textExample = await app.findElementByTestID('text-example');
+ await textExample.doubleClick();
+ const dump = await dumpVisualTree('pressed-state');
+ expect(dump).toMatchSnapshot();
+ });
+
+ test('PressableWhenSelectable', async () => {
+ await toggleSelectable();
+ const textExample = await app.findElementByTestID('text-example');
+ await textExample.click();
+ const dump = await dumpVisualTree('pressed-state');
+ expect(dump).toMatchSnapshot();
+ });
+
+ test('DoubleClickWhenSelectable', async () => {
+ await toggleSelectable();
+ const textExample = await app.findElementByTestID('text-example');
+ await textExample.doubleClick();
+ const dump = await dumpVisualTree('pressed-state');
+ expect(dump).toMatchSnapshot();
+ });
+});
+
+async function clearState() {
+ const clearButton = await app.findElementByTestID('clear-state-button');
+ await clearButton.click();
+}
+
+async function toggleSelectable() {
+ const toggleButton = await app.findElementByTestID(
+ 'toggle-selectable-button',
+ );
+ await toggleButton.click();
+}
diff --git a/packages/e2e-test-app/test/__snapshots__/LegacySelectableTextTest.test.ts.snap b/packages/e2e-test-app/test/__snapshots__/LegacySelectableTextTest.test.ts.snap
new file mode 100644
index 00000000000..f2fc421d556
--- /dev/null
+++ b/packages/e2e-test-app/test/__snapshots__/LegacySelectableTextTest.test.ts.snap
@@ -0,0 +1,81 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`LegacySelectableTextTest DoubleClickWhenNotSelectable 1`] = `
+Object {
+ "AutomationId": "pressed-state",
+ "Clip": null,
+ "FlowDirection": "LeftToRight",
+ "Foreground": "#E4000000",
+ "Height": 19,
+ "HorizontalAlignment": "Stretch",
+ "Left": 0,
+ "Margin": "0,0,0,0",
+ "Padding": "0,0,0,0",
+ "Text": "Pressed: 2 times.",
+ "Top": 0,
+ "VerticalAlignment": "Stretch",
+ "Visibility": "Visible",
+ "Width": 103,
+ "XamlType": "Windows.UI.Xaml.Controls.TextBlock",
+}
+`;
+
+exports[`LegacySelectableTextTest DoubleClickWhenSelectable 1`] = `
+Object {
+ "AutomationId": "pressed-state",
+ "Clip": null,
+ "FlowDirection": "LeftToRight",
+ "Foreground": "#E4000000",
+ "Height": 19,
+ "HorizontalAlignment": "Stretch",
+ "Left": 0,
+ "Margin": "0,0,0,0",
+ "Padding": "0,0,0,0",
+ "Text": "Pressed: 1 times.",
+ "Top": 0,
+ "VerticalAlignment": "Stretch",
+ "Visibility": "Visible",
+ "Width": 103,
+ "XamlType": "Windows.UI.Xaml.Controls.TextBlock",
+}
+`;
+
+exports[`LegacySelectableTextTest PressableWhenNotSelectable 1`] = `
+Object {
+ "AutomationId": "pressed-state",
+ "Clip": null,
+ "FlowDirection": "LeftToRight",
+ "Foreground": "#E4000000",
+ "Height": 19,
+ "HorizontalAlignment": "Stretch",
+ "Left": 0,
+ "Margin": "0,0,0,0",
+ "Padding": "0,0,0,0",
+ "Text": "Pressed: 1 times.",
+ "Top": 0,
+ "VerticalAlignment": "Stretch",
+ "Visibility": "Visible",
+ "Width": 103,
+ "XamlType": "Windows.UI.Xaml.Controls.TextBlock",
+}
+`;
+
+exports[`LegacySelectableTextTest PressableWhenSelectable 1`] = `
+Object {
+ "AutomationId": "pressed-state",
+ "Clip": null,
+ "FlowDirection": "LeftToRight",
+ "Foreground": "#E4000000",
+ "Height": 19,
+ "HorizontalAlignment": "Stretch",
+ "Left": 0,
+ "Margin": "0,0,0,0",
+ "Padding": "0,0,0,0",
+ "Text": "Pressed: 1 times.",
+ "Top": 0,
+ "VerticalAlignment": "Stretch",
+ "Visibility": "Visible",
+ "Width": 103,
+ "XamlType": "Windows.UI.Xaml.Controls.TextBlock",
+}
+`;
diff --git a/vnext/Microsoft.ReactNative/ReactPointerEventArgs.cpp b/vnext/Microsoft.ReactNative/ReactPointerEventArgs.cpp
index e6cc11273cd..83f903eccf8 100644
--- a/vnext/Microsoft.ReactNative/ReactPointerEventArgs.cpp
+++ b/vnext/Microsoft.ReactNative/ReactPointerEventArgs.cpp
@@ -15,6 +15,12 @@ PointerEventKind ReactPointerEventArgs::Kind() const noexcept {
return m_kind;
}
+void ReactPointerEventArgs::Kind(PointerEventKind kind) noexcept {
+ // The only event type change that is supported is CaptureLost to End.
+ assert(kind == PointerEventKind::End && m_kind == PointerEventKind::CaptureLost);
+ m_kind = kind;
+}
+
winrt::IInspectable ReactPointerEventArgs::Target() const noexcept {
return m_target;
}
diff --git a/vnext/Microsoft.ReactNative/ReactPointerEventArgs.h b/vnext/Microsoft.ReactNative/ReactPointerEventArgs.h
index d79a54510eb..907b85e0d13 100644
--- a/vnext/Microsoft.ReactNative/ReactPointerEventArgs.h
+++ b/vnext/Microsoft.ReactNative/ReactPointerEventArgs.h
@@ -11,6 +11,7 @@ struct ReactPointerEventArgs : ReactPointerEventArgsT {
xaml::Input::PointerRoutedEventArgs Args() const noexcept;
PointerEventKind Kind() const noexcept;
+ void Kind(PointerEventKind kind) noexcept;
winrt::IInspectable Target() const noexcept;
void Target(winrt::IInspectable const &target) noexcept;
diff --git a/vnext/Microsoft.ReactNative/ReactPointerEventArgs.idl b/vnext/Microsoft.ReactNative/ReactPointerEventArgs.idl
index f299638945a..00d2c424017 100644
--- a/vnext/Microsoft.ReactNative/ReactPointerEventArgs.idl
+++ b/vnext/Microsoft.ReactNative/ReactPointerEventArgs.idl
@@ -48,9 +48,15 @@ namespace Microsoft.ReactNative {
XAML_NAMESPACE.Input.PointerRoutedEventArgs Args {
get;
};
- DOC_STRING("Gets the pointer event kind.")
+ DOC_STRING(
+ "Gets or sets the pointer event kind. The only valid override is "
+ "@PointerEventKind.CaptureLost to @PointerEventKind.End to handle cases "
+ "where PointerCaptureLost events on ReactRootView can be safely treated "
+ "as PointerReleased events, e.g., for pointer events on selectable text."
+ )
PointerEventKind Kind {
get;
+ set;
};
DOC_STRING("Gets or sets the React target for the pointer event.")
Object Target {
diff --git a/vnext/Microsoft.ReactNative/Views/TextViewManager.cpp b/vnext/Microsoft.ReactNative/Views/TextViewManager.cpp
index 4623e89c750..b979e7967ef 100644
--- a/vnext/Microsoft.ReactNative/Views/TextViewManager.cpp
+++ b/vnext/Microsoft.ReactNative/Views/TextViewManager.cpp
@@ -4,6 +4,7 @@
#include "pch.h"
#include "TextViewManager.h"
+#include "TouchEventHandler.h"
#include "Utils/ShadowNodeTypeUtils.h"
#include "Utils/XamlIslandUtils.h"
@@ -44,6 +45,8 @@ class TextShadowNode final : public ShadowNodeBase {
bool m_hasDescendantPressable{false};
std::optional m_backgroundColor{};
std::optional m_foregroundColor{};
+ std::unique_ptr m_touchEventHandler = nullptr;
+ winrt::event_revoker m_selectionChangedRevoker;
public:
TextShadowNode() {
@@ -133,7 +136,41 @@ class TextShadowNode final : public ShadowNodeBase {
}
}
+ void ToggleTouchEvents(XamlView xamlView, bool selectable) {
+ if (selectable) {
+ if (m_touchEventHandler == nullptr) {
+ m_touchEventHandler = std::make_unique(GetViewManager()->GetReactContext(), false);
+ }
+
+ m_selectionChangedRevoker = xamlView.as().SelectionChanged(
+ winrt::auto_revoke, [selectionChanged = this->selectionChanged](const auto &sender, auto &&) {
+ const auto textBlock = sender.as();
+ *selectionChanged =
+ *selectionChanged || textBlock.SelectionStart().Offset() != textBlock.SelectionEnd().Offset();
+ });
+
+ m_touchEventHandler->AddTouchHandlers(xamlView, GetRootView(), true);
+ } else {
+ if (m_touchEventHandler != nullptr) {
+ m_touchEventHandler->RemoveTouchHandlers();
+ m_selectionChangedRevoker.revoke();
+ }
+ }
+ }
+
+ XamlView GetRootView() {
+ if (auto uiManager = GetNativeUIManager(GetViewManager()->GetReactContext()).lock()) {
+ auto shadowNode = uiManager->getHost()->FindShadowNodeForTag(m_rootTag);
+ if (!shadowNode)
+ return nullptr;
+
+ return static_cast<::Microsoft::ReactNative::ShadowNodeBase *>(shadowNode)->GetView();
+ }
+ return nullptr;
+ }
+
TextTransform textTransform{TextTransform::Undefined};
+ std::shared_ptr selectionChanged = std::make_shared(false);
};
TextViewManager::TextViewManager(const Mso::React::IReactContext &context) : Super(context) {}
@@ -207,14 +244,17 @@ bool TextViewManager::UpdateProperty(
textBlock.ClearValue(xaml::Controls::TextBlock::LineStackingStrategyProperty());
}
} else if (propertyName == "selectable") {
+ const auto node = static_cast(nodeToUpdate);
if (propertyValue.Type() == winrt::Microsoft::ReactNative::JSValueType::Boolean) {
const auto selectable = propertyValue.AsBoolean();
textBlock.IsTextSelectionEnabled(selectable);
+ node->ToggleTouchEvents(textBlock, selectable);
if (selectable) {
EnsureUniqueTextFlyoutForXamlIsland(textBlock);
}
} else if (propertyValue.IsNull()) {
textBlock.ClearValue(xaml::Controls::TextBlock::IsTextSelectionEnabledProperty());
+ node->ToggleTouchEvents(textBlock, false);
ClearUniqueTextFlyoutForXamlIsland(textBlock);
}
} else if (propertyName == "allowFontScaling") {
@@ -307,6 +347,13 @@ void TextViewManager::OnPointerEvent(
}
}
+ if (args.Kind() == winrt::Microsoft::ReactNative::PointerEventKind::CaptureLost) {
+ if (!*textNode->selectionChanged) {
+ args.Kind(winrt::Microsoft::ReactNative::PointerEventKind::End);
+ }
+ *textNode->selectionChanged = false;
+ }
+
Super::OnPointerEvent(node, args);
}
diff --git a/vnext/Microsoft.ReactNative/Views/TouchEventHandler.cpp b/vnext/Microsoft.ReactNative/Views/TouchEventHandler.cpp
index af0955f51b5..359987b2294 100644
--- a/vnext/Microsoft.ReactNative/Views/TouchEventHandler.cpp
+++ b/vnext/Microsoft.ReactNative/Views/TouchEventHandler.cpp
@@ -35,6 +35,7 @@ std::vector GetTagsForBranch(INativeUIManagerHost *host, int64_t tag, i
TouchEventHandler::TouchEventHandler(const Mso::React::IReactContext &context, bool fabric)
: m_xamlView(nullptr),
+ m_rootView(nullptr),
m_context(&context),
m_fabric(fabric),
m_batchingEventEmitter{
@@ -44,33 +45,49 @@ TouchEventHandler::~TouchEventHandler() {
RemoveTouchHandlers();
}
-void TouchEventHandler::AddTouchHandlers(XamlView xamlView) {
+void TouchEventHandler::AddTouchHandlers(XamlView xamlView, XamlView rootView, bool handledEventsToo) {
auto uiElement(xamlView.as());
if (uiElement == nullptr) {
assert(false);
return;
}
- m_xamlView = xamlView;
-
RemoveTouchHandlers();
- m_pressedRevoker = uiElement.PointerPressed(winrt::auto_revoke, {this, &TouchEventHandler::OnPointerPressed});
- m_releasedRevoker = uiElement.PointerReleased(winrt::auto_revoke, {this, &TouchEventHandler::OnPointerReleased});
- m_canceledRevoker = uiElement.PointerCanceled(winrt::auto_revoke, {this, &TouchEventHandler::OnPointerCanceled});
- m_captureLostRevoker =
- uiElement.PointerCaptureLost(winrt::auto_revoke, {this, &TouchEventHandler::OnPointerCaptureLost});
- m_exitedRevoker = uiElement.PointerExited(winrt::auto_revoke, {this, &TouchEventHandler::OnPointerExited});
- m_movedRevoker = uiElement.PointerMoved(winrt::auto_revoke, {this, &TouchEventHandler::OnPointerMoved});
+ m_xamlView = xamlView;
+ m_rootView = rootView;
+ m_pressedHandler = winrt::box_value(winrt::PointerEventHandler{this, &TouchEventHandler::OnPointerPressed});
+ m_releasedHandler = winrt::box_value(winrt::PointerEventHandler{this, &TouchEventHandler::OnPointerReleased});
+ m_canceledHandler = winrt::box_value(winrt::PointerEventHandler{this, &TouchEventHandler::OnPointerCanceled});
+ m_captureLostHandler = winrt::box_value(winrt::PointerEventHandler{this, &TouchEventHandler::OnPointerCaptureLost});
+ m_exitedHandler = winrt::box_value(winrt::PointerEventHandler{this, &TouchEventHandler::OnPointerExited});
+ m_movedHandler = winrt::box_value(winrt::PointerEventHandler{this, &TouchEventHandler::OnPointerMoved});
+ uiElement.AddHandler(xaml::UIElement::PointerPressedEvent(), m_pressedHandler, handledEventsToo);
+ uiElement.AddHandler(xaml::UIElement::PointerReleasedEvent(), m_releasedHandler, handledEventsToo);
+ uiElement.AddHandler(xaml::UIElement::PointerCanceledEvent(), m_canceledHandler, handledEventsToo);
+ uiElement.AddHandler(xaml::UIElement::PointerCaptureLostEvent(), m_captureLostHandler, handledEventsToo);
+ uiElement.AddHandler(xaml::UIElement::PointerExitedEvent(), m_exitedHandler, handledEventsToo);
+ uiElement.AddHandler(xaml::UIElement::PointerMovedEvent(), m_movedHandler, handledEventsToo);
}
void TouchEventHandler::RemoveTouchHandlers() {
- m_pressedRevoker.revoke();
- m_releasedRevoker.revoke();
- m_canceledRevoker.revoke();
- m_captureLostRevoker.revoke();
- m_exitedRevoker.revoke();
- m_movedRevoker.revoke();
+ if (m_xamlView) {
+ auto uiElement(m_xamlView.as());
+ uiElement.RemoveHandler(xaml::UIElement::PointerPressedEvent(), m_pressedHandler);
+ uiElement.RemoveHandler(xaml::UIElement::PointerReleasedEvent(), m_releasedHandler);
+ uiElement.RemoveHandler(xaml::UIElement::PointerCanceledEvent(), m_canceledHandler);
+ uiElement.RemoveHandler(xaml::UIElement::PointerCaptureLostEvent(), m_captureLostHandler);
+ uiElement.RemoveHandler(xaml::UIElement::PointerExitedEvent(), m_exitedHandler);
+ uiElement.RemoveHandler(xaml::UIElement::PointerMovedEvent(), m_movedHandler);
+ m_pressedHandler = nullptr;
+ m_releasedHandler = nullptr;
+ m_canceledHandler = nullptr;
+ m_captureLostHandler = nullptr;
+ m_exitedHandler = nullptr;
+ m_movedHandler = nullptr;
+ m_rootView = nullptr;
+ m_xamlView = nullptr;
+ }
}
winrt::Microsoft::ReactNative::BatchingEventEmitter &TouchEventHandler::BatchingEmitter() noexcept {
@@ -197,7 +214,12 @@ void TouchEventHandler::OnPointerConcluded(TouchEventType eventType, const winrt
UpdateReactPointer(m_pointers[*optPointerIndex], args, sourceElement);
if (m_pointers[*optPointerIndex].isLeftButton) {
- DispatchTouchEvent(eventType, *optPointerIndex);
+ // In case a PointerCaptureLost event should be treated as an "end" event,
+ // check the ReactPointerEventArgs Kind property before emitting the event.
+ const auto adjustedEventType = reactArgs.Kind() == winrt::Microsoft::ReactNative::PointerEventKind::End
+ ? TouchEventType::End
+ : TouchEventType::Cancel;
+ DispatchTouchEvent(adjustedEventType, *optPointerIndex);
}
m_pointers.erase(cbegin(m_pointers) + *optPointerIndex);
@@ -250,7 +272,7 @@ void TouchEventHandler::UpdateReactPointer(
ReactPointer &pointer,
const winrt::PointerRoutedEventArgs &args,
xaml::UIElement sourceElement) {
- auto rootPoint = args.GetCurrentPoint(m_xamlView.as());
+ auto rootPoint = args.GetCurrentPoint(m_rootView.as());
auto point = args.GetCurrentPoint(sourceElement);
auto props = point.Properties();
auto keyModifiers = static_cast(args.KeyModifiers());
diff --git a/vnext/Microsoft.ReactNative/Views/TouchEventHandler.h b/vnext/Microsoft.ReactNative/Views/TouchEventHandler.h
index e12cc4a73ec..2a17366542d 100644
--- a/vnext/Microsoft.ReactNative/Views/TouchEventHandler.h
+++ b/vnext/Microsoft.ReactNative/Views/TouchEventHandler.h
@@ -32,7 +32,7 @@ class TouchEventHandler {
TouchEventHandler(const Mso::React::IReactContext &context, bool fabric);
virtual ~TouchEventHandler();
- void AddTouchHandlers(XamlView xamlView);
+ void AddTouchHandlers(XamlView xamlView, XamlView rootView = nullptr, bool handledEventsToo = false);
void RemoveTouchHandlers();
winrt::Microsoft::ReactNative::BatchingEventEmitter &BatchingEmitter() noexcept;
@@ -43,12 +43,12 @@ class TouchEventHandler {
void OnPointerCaptureLost(const winrt::IInspectable &, const winrt::PointerRoutedEventArgs &args);
void OnPointerExited(const winrt::IInspectable &, const winrt::PointerRoutedEventArgs &args);
void OnPointerMoved(const winrt::IInspectable &, const winrt::PointerRoutedEventArgs &args);
- winrt::event_revoker m_pressedRevoker;
- winrt::event_revoker m_releasedRevoker;
- winrt::event_revoker m_canceledRevoker;
- winrt::event_revoker m_captureLostRevoker;
- winrt::event_revoker m_exitedRevoker;
- winrt::event_revoker m_movedRevoker;
+ winrt::IInspectable m_pressedHandler;
+ winrt::IInspectable m_releasedHandler;
+ winrt::IInspectable m_canceledHandler;
+ winrt::IInspectable m_captureLostHandler;
+ winrt::IInspectable m_exitedHandler;
+ winrt::IInspectable m_movedHandler;
struct ReactPointer {
int64_t target = 0;
@@ -109,6 +109,7 @@ class TouchEventHandler {
xaml::UIElement *pSourceElement);
XamlView m_xamlView;
+ XamlView m_rootView;
Mso::CntPtr m_context;
bool m_fabric;
std::shared_ptr m_batchingEventEmitter;