Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "Allow pointer events on selectable Text",
"packageName": "react-native-windows",
"email": "erozell@outlook.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -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 (
<View style={styles.container}>
<Text testID="pressed-state">Pressed: {this.state.count} times.</Text>
<Text testID="text-example" selectable={this.state.isSelectable}>
Text before{' '}
<Text onPress={increment} style={styles.pressable}>
click here
</Text>{' '}
text after
</Text>
<Button
testID="toggle-selectable-button"
title="Toggle Selectable"
onPress={() =>
this.setState({isSelectable: !this.state.isSelectable})
}
/>
<Button
testID="clear-state-button"
title="Clear State"
onPress={() => this.setState({count: 0, isSelectable: false})}
/>
</View>
);
}
}

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 <SelectableTextTests />;
},
},
];
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ const Components: Array<RNTesterModuleInfo> = [
key: 'LegacyImageTest',
module: require('../examples-win/LegacyTests/ImageTestPage'),
},
{
key: 'LegacySelectableTextTest',
module: require('../examples-win/LegacyTests/SelectableTextTestPage'),
},
{
key: 'LegacyTextHitTestTest',
module: require('../examples-win/LegacyTests/TextHitTestPage'),
Expand Down
62 changes: 62 additions & 0 deletions packages/e2e-test-app/test/LegacySelectableTextTest.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
@@ -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",
}
`;
6 changes: 6 additions & 0 deletions vnext/Microsoft.ReactNative/ReactPointerEventArgs.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions vnext/Microsoft.ReactNative/ReactPointerEventArgs.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ struct ReactPointerEventArgs : ReactPointerEventArgsT<ReactPointerEventArgs> {
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;
Expand Down
8 changes: 7 additions & 1 deletion vnext/Microsoft.ReactNative/ReactPointerEventArgs.idl
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
47 changes: 47 additions & 0 deletions vnext/Microsoft.ReactNative/Views/TextViewManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include "pch.h"

#include "TextViewManager.h"
#include "TouchEventHandler.h"
#include "Utils/ShadowNodeTypeUtils.h"
#include "Utils/XamlIslandUtils.h"

Expand Down Expand Up @@ -44,6 +45,8 @@ class TextShadowNode final : public ShadowNodeBase {
bool m_hasDescendantPressable{false};
std::optional<winrt::Windows::UI::Color> m_backgroundColor{};
std::optional<winrt::Windows::UI::Color> m_foregroundColor{};
std::unique_ptr<TouchEventHandler> m_touchEventHandler = nullptr;
winrt::event_revoker<xaml::Controls::ITextBlock> m_selectionChangedRevoker;

public:
TextShadowNode() {
Expand Down Expand Up @@ -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<TouchEventHandler>(GetViewManager()->GetReactContext(), false);
}

m_selectionChangedRevoker = xamlView.as<xaml::Controls::TextBlock>().SelectionChanged(
Comment thread
rozele marked this conversation as resolved.
winrt::auto_revoke, [selectionChanged = this->selectionChanged](const auto &sender, auto &&) {
const auto textBlock = sender.as<xaml::Controls::TextBlock>();
*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<bool> selectionChanged = std::make_shared<bool>(false);
};

TextViewManager::TextViewManager(const Mso::React::IReactContext &context) : Super(context) {}
Expand Down Expand Up @@ -207,14 +244,17 @@ bool TextViewManager::UpdateProperty(
textBlock.ClearValue(xaml::Controls::TextBlock::LineStackingStrategyProperty());
}
} else if (propertyName == "selectable") {
const auto node = static_cast<TextShadowNode *>(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") {
Expand Down Expand Up @@ -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);
}

Expand Down
Loading