Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Macos callout text run anchoring #3661

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
46 changes: 33 additions & 13 deletions apps/fluent-tester/src/TestComponents/Callout/CalloutTest.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as React from 'react';
import type { KeyboardMetrics } from 'react-native';
import { Text, View, Switch, ScrollView, Platform } from 'react-native';
import type { KeyboardMetrics, Text as RNText } from 'react-native';
import { View, Switch, ScrollView, Platform } from 'react-native';

import { Button, Callout, Separator, Pressable, StealthButton } from '@fluentui/react-native';
import { Button, Callout, Separator, Pressable, StealthButton, TextV1 as Text } from '@fluentui/react-native';
import type { IFocusable, RestoreFocusEvent, DismissBehaviors, ICalloutProps } from '@fluentui/react-native';

import { E2ECalloutTest } from './CalloutE2ETest';
Expand All @@ -15,13 +15,13 @@ import { Test } from '../Test';
const StandardCallout: React.FunctionComponent = () => {
const [showStandardCallout, setShowStandardCallout] = React.useState(false);
const [isStandardCalloutVisible, setIsStandardCalloutVisible] = React.useState(false);
const [openCalloutOnHoverAnchor, setOpenCalloutOnHoverAnchor] = React.useState(true);
const [openCalloutOnHoverAnchor, setOpenCalloutOnHoverAnchor] = React.useState(false);
const [calloutHovered, setCalloutHovered] = React.useState(false);

const [shouldSetInitialFocus, setShouldSetInitialFocus] = React.useState(true);
const onInitialFocusChange = React.useCallback((value: boolean) => setShouldSetInitialFocus(value), []);

const [customRestoreFocus, setCustomRestoreFocus] = React.useState(false);
const [customRestoreFocus, setCustomRestoreFocus] = React.useState(true);
const onRestoreFocusChange = React.useCallback((value) => setCustomRestoreFocus(value), []);

const [isBeakVisible, setIsBeakVisible] = React.useState(false);
Expand Down Expand Up @@ -61,12 +61,17 @@ const StandardCallout: React.FunctionComponent = () => {
[calloutDismissBehaviors],
);

const textRef = React.useRef<RNText>(null);
const textRefInner1 = React.useRef<RNText>(null);
const textRefInner2 = React.useRef<RNText>(null);
const redTargetRef = React.useRef<View>(null);
const blueTargetRef = React.useRef<View>(null);
const greenTargetRef = React.useRef<View>(null);
const decoyBtn1Ref = React.useRef<IFocusable>(null);
const decoyBtn2Ref = React.useRef<IFocusable>(null);
const [anchorRef, setAnchorRef] = React.useState<React.RefObject<View> | undefined>(redTargetRef);
const [anchorRefIndex, setAnchorRefIndex] = React.useState(0);
const anchorRefCycle = [redTargetRef, greenTargetRef, blueTargetRef, textRef, textRefInner1, textRefInner2];
const [anchorRef, setAnchorRef] = React.useState<React.RefObject<View> | React.RefObject<RNText> | string | undefined>(anchorRefCycle[0]);
const [hoveredTargetsCount, setHoveredTargetsCount] = React.useState(0);
const [displayCountHoveredTargets, setDisplayCountHoveredTargets] = React.useState(0);

Expand Down Expand Up @@ -143,8 +148,9 @@ const StandardCallout: React.FunctionComponent = () => {

const toggleCalloutRef = React.useCallback(() => {
// Cycle the target ref between the RGB target views
setAnchorRef(anchorRef === redTargetRef ? greenTargetRef : anchorRef === greenTargetRef ? blueTargetRef : redTargetRef);
}, [anchorRef]);
setAnchorRefIndex((anchorRefIndex + 1) % anchorRefCycle.length);
setAnchorRef(anchorRefCycle[anchorRefIndex]);
}, [anchorRef, anchorRefIndex]);

const switchTargetRefOrRect = React.useCallback(() => {
// Switch between RGB views or a fixed anchor
Expand Down Expand Up @@ -312,7 +318,7 @@ const StandardCallout: React.FunctionComponent = () => {

<Separator vertical />

<View style={{ flexDirection: 'column', paddingHorizontal: 5 }}>
<View style={{ flexDirection: 'column', paddingHorizontal: 5, maxWidth: 250 }}>
<Pressable
onHoverIn={() => updateCalloutTargetsHoverState(true, redTargetRef)}
onHoverOut={() => updateCalloutTargetsHoverState(false, redTargetRef)}
Expand All @@ -337,6 +343,20 @@ const StandardCallout: React.FunctionComponent = () => {
{anchorRefsInfo.isCurrentAnchor[2] && <Text style={{ color: 'white' }}>{anchorRefsInfo.hoverCount}</Text>}
</View>
</Pressable>
<Text componentRef={textRef}>
{'Complex'}
<Text>
{' text tree'}
<Text style={{ fontSize: 16 }} componentRef={textRefInner1}>
{' [twiceNested]'}
</Text>
{' with multiple nested text runs'}
</Text>
<Text style={{ fontSize: 20 }} componentRef={textRefInner2}>
{' [onceNested]'}
</Text>
{' and subtext runs'}
</Text>
</View>
</View>

Expand Down Expand Up @@ -468,11 +488,11 @@ const e2eSections: TestSection[] = [

export const CalloutTest: React.FunctionComponent = () => {
const status: PlatformStatus = {
win32Status: 'Production',
win32Status: 'Beta',
uwpStatus: 'Backlog',
iosStatus: 'N/A',
macosStatus: 'Production',
androidStatus: 'N/A',
iosStatus: 'Backlog',
macosStatus: 'Beta',
androidStatus: 'Backlog',
};

const description = 'A callout is an anchored tip that can be used to teach people or guide them through the app without blocking them.';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "CalloutView native changes for MacOS support of anchoring Callouts to nested text runs",
"packageName": "@fluentui-react-native/callout",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Improve test page for CalloutTest for cross-plat text run anchor testing",
"packageName": "@fluentui-react-native/tester",
"email": "[email protected]",
"dependentChangeType": "patch"
}
203 changes: 187 additions & 16 deletions packages/components/Callout/macos/CalloutView.swift
Original file line number Diff line number Diff line change
@@ -1,19 +1,71 @@
import AppKit
import Foundation
#if USE_REACT_AS_MODULE
import React
#endif // USE_REACT_AS_MODULE

/// Return the text length of a provided RCTShadowView
func getLengthOfTextShadowNode(shadowView: RCTShadowView) -> Int {
PPatBoyd marked this conversation as resolved.
Show resolved Hide resolved
// If it's a RawTextShadowView, the length is simply its text length
if let rawTextView = shadowView as? RCTRawTextShadowView {
if let rawTextLength = rawTextView.text?.count {
return rawTextLength
}

return 0
}

// If it's a BaseTextShadowView, it may have multiple nested texts that
// should be summed together
var sumTextLength = 0
if let baseTextView = shadowView as? RCTBaseTextShadowView {
baseTextView.reactSubviews().forEach { subview in
sumTextLength += getLengthOfTextShadowNode(shadowView: subview)
}
}

return sumTextLength
}

/// Search for the provided reactTag in the provided ShadowView's subtree
/// When successfully found, returns:
/// found: true
/// startCharRange: number of characters in the text string before the reactTag subrange
///
/// When not found, returns:
/// found: false
/// startCharRange: 0
func getStartCharRangeForTag(reactTag: NSNumber, shadowView: RCTShadowView) -> (found: Bool, startCharRange: Int) {
if shadowView.reactTag == reactTag {
// If this shadowView is our target, we're done; return that we found it
return (found: true, startCharRange: 0)
} else if let rawTextShadowView = shadowView as? RCTRawTextShadowView {
// If this shadowView is a rawText view, it has no subviews; return the length of the text to be added
// to the startCharRange index
if let rawTextLength = rawTextShadowView.text?.count {
return (found: false, startCharRange: rawTextLength)
}
} else {
// Otherwise our target view may be a subview; sum the character range for each subview subtree
// before and including the subview that contains our target view
var startCharRange = 0
for subview in shadowView.reactSubviews() {
let subviewSearch = getStartCharRangeForTag(reactTag:reactTag, shadowView: subview)
startCharRange += subviewSearch.startCharRange
if (subviewSearch.found) {
return (found: subviewSearch.found, startCharRange: startCharRange)
}
}

return (found: false, startCharRange: startCharRange)
}

return (found: false, startCharRange: 0)
}

@objc(FRNCalloutView)
class CalloutView: RCTView, CalloutWindowLifeCycleDelegate {

@objc public var target: NSNumber? {
didSet {
let targetView = bridge?.uiManager.view(forReactTag: target)
if (targetView == nil && target != nil) {
preconditionFailure("Invalid target")
}
anchorView = targetView
updateCalloutFrameToAnchor()
}
}
Expand Down Expand Up @@ -174,15 +226,138 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate {

isCalloutWindowShown = false
}

// Return the TextView and TextShadowView of a Leaf node ShadowView for specialized nested TextView anchoring
private func getTextViewsForLeafShadow(leafShadowView: RCTShadowView) -> (textView: RCTTextView, textShadowView: RCTTextShadowView)? {
// Do not proceed if the preconditions of this function are not met
guard leafShadowView.isYogaLeafNode() else {
preconditionFailure("leafshadow is not a leaf node")
PPatBoyd marked this conversation as resolved.
Show resolved Hide resolved
}

// Do not proceed if we don't have a UI Manager; we won't be able to determine the bounding rect
guard let uiManager = bridge?.uiManager else {
return nil
}

// Do not proceed if the leaf shadow is not a root text shadow view, which we're specializing for right now
// In the future this could be generalized for other complex yoga leaf nodes with subviews, such as react-native-svg
guard let textShadowView = leafShadowView as? RCTTextShadowView else {
return nil
}

// Do not proceed if we don't have the NSView corresponding to the yoga leaf view
guard let leafView = uiManager.view(forReactTag: leafShadowView.reactTag) else {
return nil
}

// Do not proceed if somehow the leafView is not an RCTTextView
guard let textView = leafView as? RCTTextView else {
preconditionFailure("it should not be possible for the RCTTextShadowView to not be represented by an RCTTextView")
}
PPatBoyd marked this conversation as resolved.
Show resolved Hide resolved

return (textView, textShadowView)
}

/// Return the boundingRect for a shadowView that is a subview of a Yoga leaf node
/// The boundingRect is returned relative to the Yoga leaf node
private func getBoundsForSubShadowOfLeafShadow(subShadowView: RCTShadowView, leafShadowView: RCTShadowView) -> NSRect? {
// Do not proceed if the preconditions of this function are not met
guard subShadowView.viewIsDescendant(of: leafShadowView) else {
preconditionFailure("subShadowView is not a descendant of the leaf node ShadowView")
}

// Get the TextView and TextShadowView we need to calculate the bounds of the subview
guard let (textView, textShadowView) = getTextViewsForLeafShadow(leafShadowView: leafShadowView) else {
return nil
}

// Search for our target tag and get its startCharRange index in the overall Text view
let startCharRangeSearch = getStartCharRangeForTag(reactTag: subShadowView.reactTag, shadowView: textShadowView)
if (!startCharRangeSearch.found) {
// Did not find our reactTag
return nil
}

// Having found our target, return the bounding rect for its corresponding character range
return textView.getRectForCharRange(NSRange(location: startCharRangeSearch.startCharRange, length: getLengthOfTextShadowNode(shadowView: subShadowView)))
}

/// Get the leaf shadow view corresponding to a leaf Yoga node for the provided ShadowView
private func getLeafShadowViewForShadowView(shadowView: RCTShadowView?) -> RCTShadowView? {
var shadowParentIter = shadowView
while let shadowViewIter = shadowParentIter {
if (shadowViewIter.isYogaLeafNode()) {
return shadowViewIter
}
shadowParentIter = shadowViewIter.superview
}

return nil
}

/// Return the anchor rect for the target prop if available
private func getAnchorRectForTarget() -> NSRect? {
guard let reactTag = target, let reactBridge = bridge else {
return nil
}

let zeroRect = CGRect(x: 0, y: 0, width: 0, height: 0)
PPatBoyd marked this conversation as resolved.
Show resolved Hide resolved

// If the targetView is backed by an NSView and has a representative rect, return it as the anchor rect for the target
if let targetView = reactBridge.uiManager.view(forReactTag: reactTag) {
if !targetView.bounds.equalTo(zeroRect) {
return calculateAnchorViewScreenRect(anchorView: targetView, anchorBounds: targetView.bounds)
}
}

// If the targetView could not be found or was not a representative rect, it may be a child of a yoga leaf node e.g. virtualized text
guard let targetShadowView = reactBridge.uiManager.shadowView(forReactTag: target) else {
return nil
}

// Find the leaf ShadowView of our targetView
guard let leafShadowView = getLeafShadowViewForShadowView(shadowView: targetShadowView) else {
return nil
}

// Ensure we have a real NSView for our leaf ShadowView
guard let leafNSView = reactBridge.uiManager.view(forReactTag: leafShadowView.reactTag) else {
return nil
}

// Find the bounding rect of our targetView relative to the leafShadowView
guard let targetViewBounds = getBoundsForSubShadowOfLeafShadow(subShadowView: targetShadowView, leafShadowView: leafShadowView) else {
return nil
}

// If we could find the bounding rect of our target view and it's a representative rect, return it as the anchor rect for the target
if !targetViewBounds.equalTo(zeroRect) {
return calculateAnchorViewScreenRect(anchorView: leafNSView, anchorBounds: targetViewBounds)
}

// Unfortunately our efforts could not determine a valid anchor rect for our target prop
return nil
}

/// Get the AnchorScreenRect to use for Callout anchoring, prioritizing the target prop over the anchorRect prop
private func getAnchorScreenRect() -> NSRect? {
if target != nil {
return getAnchorRectForTarget();
} else {
return calculateAnchorRectScreenRect();
}
}

/// Sets the frame of the Callout Window (in screen coordinates to be off of the Anchor on the preferred edge
private func updateCalloutFrameToAnchor() {
guard window != nil else {
return
}

// Prefer anchorView over anchorRect if available
let anchorScreenRect = anchorView != nil ? calculateAnchorViewScreenRect() : calculateAnchorRectScreenRect()
guard let anchorScreenRect = getAnchorScreenRect() else {
PPatBoyd marked this conversation as resolved.
Show resolved Hide resolved
return
}

let calloutScreenRect = bestCalloutRect(relativeTo: anchorScreenRect)

// Because we immediately update the rect as props come in, there's a possibility that we have neither
Expand Down Expand Up @@ -224,16 +399,12 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate {
}

/// Calculates the NSRect of the anchorView in the coordinate space of the current screen
private func calculateAnchorViewScreenRect() -> NSRect {
guard let anchorView = anchorView else {
preconditionFailure("No anchor view provided to position the Callout")
}

private func calculateAnchorViewScreenRect(anchorView: NSView, anchorBounds: NSRect) -> NSRect {
PPatBoyd marked this conversation as resolved.
Show resolved Hide resolved
guard let window = window else {
preconditionFailure("No window found")
}

let anchorBoundsInWindow = anchorView.convert(anchorView.bounds, to: nil)
let anchorBoundsInWindow = anchorView.convert(anchorBounds, to: nil)
let anchorFrameInScreenCoordinates = window.convertToScreen(anchorBoundsInWindow)

return anchorFrameInScreenCoordinates
Expand Down Expand Up @@ -370,7 +541,7 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate {
// MARK: Private variables

/// The view the Callout is presented from.
private var anchorView: NSView?
private var anchorReactTag: NSNumber?
PPatBoyd marked this conversation as resolved.
Show resolved Hide resolved

/// The view we forward Callout's Children to. It's hosted within the CalloutWindow's
/// view hierarchy, ensuring our React Views are not placed in the main window.
Expand Down
6 changes: 3 additions & 3 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -19503,8 +19503,8 @@ __metadata:
linkType: hard

"react-native-macos@npm:^0.73.0":
version: 0.73.30
resolution: "react-native-macos@npm:0.73.30"
version: 0.73.32
resolution: "react-native-macos@npm:0.73.32"
dependencies:
"@jest/create-cache-key-function": "npm:^29.6.3"
"@react-native-community/cli": "npm:12.3.6"
Expand Down Expand Up @@ -19548,7 +19548,7 @@ __metadata:
react: 18.2.0
bin:
react-native-macos: cli.js
checksum: 10c0/d5978b272f6c793449d2d7d48fb34f166b9fbb3694a02a48b76e46c63b70c5e5c2998883d7682f73cb99964095b8b76045c7a2ba748ed1bb33d623e9cf101f7b
checksum: 10c0/40e5e9623743aa1caeded6ac3dd5167ec920c7da58cdd187d3522e2fbb2d2f1a8b0208b3126acf2a10f875693ec801e522e8058fdacce2e63648afeef88ab6ab
languageName: node
linkType: hard

Expand Down
Loading