From 4ee8175b7cb446b67a70610155a3376e26e2f126 Mon Sep 17 00:00:00 2001 From: PPatBoyd Date: Wed, 19 Jun 2024 11:00:02 -0700 Subject: [PATCH 01/11] Preview of CalloutView MacOS changes for text anchoring to text runs --- .../Callout/macos/CalloutView.swift | 167 ++++++++++++++++-- 1 file changed, 156 insertions(+), 11 deletions(-) diff --git a/packages/components/Callout/macos/CalloutView.swift b/packages/components/Callout/macos/CalloutView.swift index c660b896b4..9e26bf4089 100644 --- a/packages/components/Callout/macos/CalloutView.swift +++ b/packages/components/Callout/macos/CalloutView.swift @@ -6,12 +6,7 @@ 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() + updateCalloutFrameToAnchor() } } @@ -172,14 +167,164 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { isCalloutWindowShown = false } + private func getLengthOfTextShadowNode(shadowView: RCTShadowView) -> Int { + if let rawTextView = shadowView as? RCTRawTextShadowView { + if let rawTextLength = rawTextView.text?.count { + return rawTextLength + } + + return 0 + } + + var sumTextLength = 0 + if let baseTextView = shadowView as? RCTBaseTextShadowView { + baseTextView.reactSubviews().forEach { subview in + sumTextLength += getLengthOfTextShadowNode(shadowView: subview) + } + } + + return sumTextLength + } + + private func getStartGlyphRangeForTag(tag: NSNumber, subviews: [RCTShadowView], startGlyphRange: inout Int) -> Bool { + var foundSubviewTag = false + subviews.forEach { subview in + if (foundSubviewTag || subview.reactTag == tag) { + foundSubviewTag = true + return + } + + if let textSubview = subview as? RCTRawTextShadowView { + if let rawStringLength = textSubview.text?.count { + startGlyphRange += rawStringLength + } + } else { + foundSubviewTag = getStartGlyphRangeForTag(tag: tag, subviews: subview.reactSubviews(), startGlyphRange: &startGlyphRange) + } + } + + return foundSubviewTag + } + + private func getBoundsForSubShadowOfLeafShadow(subshadow: RCTShadowView, leafshadow: RCTShadowView) -> NSRect? { + guard leafshadow.isYogaLeafNode() && subshadow.viewIsDescendant(of: leafshadow) else { + preconditionFailure("leafshadow is not a leaf node or subshadow not a descendant of the leafshadow") + } + + 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 here + guard let textShadowView = leafshadow as? RCTTextShadowView else { + return nil + } + + var startGlyphRange = 0 + if (!getStartGlyphRangeForTag(tag: subshadow.reactTag, subviews: textShadowView.reactSubviews(), startGlyphRange: &startGlyphRange)) { + // Did not find our reactTag + return nil + } + + + // Get the NSView corresponding to the yoga leaf view + guard let leafview = uiManager.view(forReactTag: leafshadow.reactTag) else { + return nil + } + + // This concern is currently specialized to NSTextViews; in theory it could be expanded to other + // types of shadow nodes that are subviews of yoga leaf nodes e.g. react-native-svg + guard let textView = leafview as? RCTTextView else { + return nil + } + + return textView.getRectForGlyphRange(NSRange(location: startGlyphRange, length: getLengthOfTextShadowNode(shadowView: subshadow))) + } + + private func getBoundsForShadowView(shadow: RCTShadowView?) -> NSRect? { + guard let uiManager = bridge?.uiManager else { + return nil + } + + guard let shadowView = shadow else { + return nil + } + + var shadowParentIter = shadow + while let shadowViewIter = shadowParentIter { + if (shadowViewIter.isYogaLeafNode()) { + return getBoundsForSubShadowOfLeafShadow(subshadow: shadowView, leafshadow: shadowViewIter) + } + shadowParentIter = shadowViewIter.superview + } + + return nil + } + + private func getLeafViewForShadowView(shadowView: RCTShadowView?) -> NSView? { + guard let reactBridge = bridge else { + return nil + } + var shadowParentIter = shadowView + while let shadowViewIter = shadowParentIter { + if (shadowViewIter.isYogaLeafNode()) { + return reactBridge.uiManager.view(forReactTag: shadowViewIter.reactTag) + } + shadowParentIter = shadowViewIter.superview + } + + return nil + } + + // Return the anchor rect for the target prop if available + private func getAnchorRectForTarget() -> NSRect? { + guard let reactTag = target else { + return nil + } + + guard let reactBridge = bridge else { + return nil + } + + let zeroRect = CGRect(x: 0, y: 0, width: 0, height: 0) + let targetView = reactBridge.uiManager.view(forReactTag: reactTag) + + // If the targetView is backed by an NSView and has a representative rect, return it as the anchor rect for the target + if let targetViewBounds = targetView?.bounds, !targetViewBounds.equalTo(zeroRect) { + return calculateAnchorViewScreenRect(anchorView: targetView, anchorBounds: targetViewBounds) + } + + // 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 shadowTargetView = reactBridge.uiManager.shadowView(forReactTag: target) else { + return nil + } + + if let targetViewBounds = getBoundsForShadowView(shadow: shadowTargetView), !targetViewBounds.equalTo(zeroRect) { + return calculateAnchorViewScreenRect(anchorView: getLeafViewForShadowView(shadowView: shadowTargetView), anchorBounds: targetViewBounds) + } + + return nil + } + + private func getAnchorScreenRect() -> NSRect? { + // First attempt to resolve a provided target property + if let validTarget = target { + 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 { + return + } + let calloutScreenRect = bestCalloutRect(relativeTo: anchorScreenRect) // Because we immediately update the rect as props come in, there's a possibility that we have neither @@ -221,7 +366,7 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { } /// Calculates the NSRect of the anchorView in the coordinate space of the current screen - private func calculateAnchorViewScreenRect() -> NSRect { + private func calculateAnchorViewScreenRect(anchorView: NSView?, anchorBounds: NSRect) -> NSRect { guard let anchorView = anchorView else { preconditionFailure("No anchor view provided to position the Callout") } @@ -230,7 +375,7 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { 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 @@ -363,7 +508,7 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { // MARK: Private variables /// The view the Callout is presented from. - private var anchorView: NSView? + private var anchorReactTag: NSNumber? /// 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. From 90a826bb15d1bb05cc91119b20d55acc3123ae21 Mon Sep 17 00:00:00 2001 From: PPatBoyd Date: Thu, 20 Jun 2024 12:41:35 -0700 Subject: [PATCH 02/11] Clean up implementation of CalloutView anchoring to nested text runs --- .../Callout/macos/CalloutView.swift | 280 +++++++++--------- 1 file changed, 147 insertions(+), 133 deletions(-) diff --git a/packages/components/Callout/macos/CalloutView.swift b/packages/components/Callout/macos/CalloutView.swift index 42e7be7565..10a790b2d9 100644 --- a/packages/components/Callout/macos/CalloutView.swift +++ b/packages/components/Callout/macos/CalloutView.swift @@ -1,15 +1,72 @@ 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 { + // 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 { - updateCalloutFrameToAnchor() + updateCalloutFrameToAnchor() } } @@ -170,153 +227,110 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { isCalloutWindowShown = false } - private func getLengthOfTextShadowNode(shadowView: RCTShadowView) -> Int { - if let rawTextView = shadowView as? RCTRawTextShadowView { - if let rawTextLength = rawTextView.text?.count { - return rawTextLength - } - - return 0 - } - - var sumTextLength = 0 - if let baseTextView = shadowView as? RCTBaseTextShadowView { - baseTextView.reactSubviews().forEach { subview in - sumTextLength += getLengthOfTextShadowNode(shadowView: subview) - } - } - - return sumTextLength - } - - private func getStartGlyphRangeForTag(tag: NSNumber, subviews: [RCTShadowView], startGlyphRange: inout Int) -> Bool { - var foundSubviewTag = false - subviews.forEach { subview in - if (foundSubviewTag || subview.reactTag == tag) { - foundSubviewTag = true - return - } - - if let textSubview = subview as? RCTRawTextShadowView { - if let rawStringLength = textSubview.text?.count { - startGlyphRange += rawStringLength - } - } else { - foundSubviewTag = getStartGlyphRangeForTag(tag: tag, subviews: subview.reactSubviews(), startGlyphRange: &startGlyphRange) - } - } - - return foundSubviewTag - } - - private func getBoundsForSubShadowOfLeafShadow(subshadow: RCTShadowView, leafshadow: RCTShadowView) -> NSRect? { - guard leafshadow.isYogaLeafNode() && subshadow.viewIsDescendant(of: leafshadow) else { - preconditionFailure("leafshadow is not a leaf node or subshadow not a descendant of the leafshadow") - } - - 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 here - guard let textShadowView = leafshadow as? RCTTextShadowView else { - return nil - } - - var startGlyphRange = 0 - if (!getStartGlyphRangeForTag(tag: subshadow.reactTag, subviews: textShadowView.reactSubviews(), startGlyphRange: &startGlyphRange)) { - // Did not find our reactTag - return nil - } + /// 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 leafShadowView.isYogaLeafNode() && subShadowView.viewIsDescendant(of: leafShadowView) else { + preconditionFailure("leafshadow is not a leaf TextView or subshadow not a descendant of the leafshadow") + } + // 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 + } - // Get the NSView corresponding to the yoga leaf view - guard let leafview = uiManager.view(forReactTag: leafshadow.reactTag) 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 + } - // This concern is currently specialized to NSTextViews; in theory it could be expanded to other - // types of shadow nodes that are subviews of yoga leaf nodes e.g. react-native-svg - guard let textView = leafview as? RCTTextView 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 + } - return textView.getRectForGlyphRange(NSRange(location: startGlyphRange, length: getLengthOfTextShadowNode(shadowView: subshadow))) - } + // 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") + } - private func getBoundsForShadowView(shadow: RCTShadowView?) -> NSRect? { - guard let uiManager = bridge?.uiManager 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 + } - guard let shadowView = shadow else { - 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))) + } - var shadowParentIter = shadow - while let shadowViewIter = shadowParentIter { - if (shadowViewIter.isYogaLeafNode()) { - return getBoundsForSubShadowOfLeafShadow(subshadow: shadowView, leafshadow: shadowViewIter) - } - shadowParentIter = shadowViewIter.superview - } + /// 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 nil + } - private func getLeafViewForShadowView(shadowView: RCTShadowView?) -> NSView? { - guard let reactBridge = bridge else { - return nil - } - var shadowParentIter = shadowView - while let shadowViewIter = shadowParentIter { - if (shadowViewIter.isYogaLeafNode()) { - return reactBridge.uiManager.view(forReactTag: shadowViewIter.reactTag) - } - shadowParentIter = shadowViewIter.superview - } + // Return the anchor rect for the target prop if available + private func getAnchorRectForTarget() -> NSRect? { + guard let reactTag = target, let reactBridge = bridge else { + return nil + } - return nil - } + let zeroRect = CGRect(x: 0, y: 0, width: 0, height: 0) + let targetView = reactBridge.uiManager.view(forReactTag: reactTag) - // Return the anchor rect for the target prop if available - private func getAnchorRectForTarget() -> NSRect? { - guard let reactTag = target else { - return nil - } + // If the targetView is backed by an NSView and has a representative rect, return it as the anchor rect for the target + if let targetViewBounds = targetView?.bounds, !targetViewBounds.equalTo(zeroRect) { + return calculateAnchorViewScreenRect(anchorView: targetView, anchorBounds: targetViewBounds) + } - guard let reactBridge = bridge else { - return nil - } + // 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 + } - let zeroRect = CGRect(x: 0, y: 0, width: 0, height: 0) - let targetView = reactBridge.uiManager.view(forReactTag: reactTag) + // Find the leaf ShadowView of our targetView + guard let leafShadowView = getLeafShadowViewForShadowView(shadowView: targetShadowView) else { + return nil + } - // If the targetView is backed by an NSView and has a representative rect, return it as the anchor rect for the target - if let targetViewBounds = targetView?.bounds, !targetViewBounds.equalTo(zeroRect) { - return calculateAnchorViewScreenRect(anchorView: targetView, anchorBounds: targetViewBounds) - } + // Ensure we have a real NSView for our leaf ShadowView + guard let leafNSView = reactBridge.uiManager.view(forReactTag: leafShadowView.reactTag) else { + return nil + } - // 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 shadowTargetView = reactBridge.uiManager.shadowView(forReactTag: target) 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 let targetViewBounds = getBoundsForShadowView(shadow: shadowTargetView), !targetViewBounds.equalTo(zeroRect) { - return calculateAnchorViewScreenRect(anchorView: getLeafViewForShadowView(shadowView: shadowTargetView), anchorBounds: targetViewBounds) - } + // 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) + } - return nil - } + // Unfortunately our efforts could not determine a valid anchor rect for our target prop + return nil + } - private func getAnchorScreenRect() -> NSRect? { - // First attempt to resolve a provided target property - if let validTarget = target { - return getAnchorRectForTarget(); - } else { - return calculateAnchorRectScreenRect(); - } - } + /// Get the AnchorScreenRect to use for Callout anchoring, prioritizing the target prop is prioritized over th 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() { @@ -369,7 +383,7 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { } /// Calculates the NSRect of the anchorView in the coordinate space of the current screen - private func calculateAnchorViewScreenRect(anchorView: NSView?, anchorBounds: NSRect) -> NSRect { + private func calculateAnchorViewScreenRect(anchorView: NSView?, anchorBounds: NSRect) -> NSRect { guard let anchorView = anchorView else { preconditionFailure("No anchor view provided to position the Callout") } From ddcb94c4ea5cd42e70acb87df224a93979b9bc2b Mon Sep 17 00:00:00 2001 From: PPatBoyd Date: Thu, 20 Jun 2024 12:42:02 -0700 Subject: [PATCH 03/11] Improve test page for CalloutTest for cross-plat text run anchor testing --- .../TestComponents/Callout/CalloutTest.tsx | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/apps/fluent-tester/src/TestComponents/Callout/CalloutTest.tsx b/apps/fluent-tester/src/TestComponents/Callout/CalloutTest.tsx index 2788a6c3d7..d38bf17326 100644 --- a/apps/fluent-tester/src/TestComponents/Callout/CalloutTest.tsx +++ b/apps/fluent-tester/src/TestComponents/Callout/CalloutTest.tsx @@ -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 { 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'; @@ -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); @@ -61,12 +61,17 @@ const StandardCallout: React.FunctionComponent = () => { [calloutDismissBehaviors], ); + const textRef = React.useRef(null); + const textRefInner1 = React.useRef(null); + const textRefInner2 = React.useRef(null); const redTargetRef = React.useRef(null); const blueTargetRef = React.useRef(null); const greenTargetRef = React.useRef(null); const decoyBtn1Ref = React.useRef(null); const decoyBtn2Ref = React.useRef(null); - const [anchorRef, setAnchorRef] = React.useState | undefined>(redTargetRef); + const [anchorRefIndex, setAnchorRefIndex] = React.useState(0); + const anchorRefCycle = [redTargetRef, greenTargetRef, blueTargetRef, textRef, textRefInner1, textRefInner2]; + const [anchorRef, setAnchorRef] = React.useState | React.RefObject | string | undefined>(anchorRefCycle[0]); const [hoveredTargetsCount, setHoveredTargetsCount] = React.useState(0); const [displayCountHoveredTargets, setDisplayCountHoveredTargets] = React.useState(0); @@ -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 @@ -312,7 +318,7 @@ const StandardCallout: React.FunctionComponent = () => { - + updateCalloutTargetsHoverState(true, redTargetRef)} onHoverOut={() => updateCalloutTargetsHoverState(false, redTargetRef)} @@ -337,6 +343,16 @@ const StandardCallout: React.FunctionComponent = () => { {anchorRefsInfo.isCurrentAnchor[2] && {anchorRefsInfo.hoverCount}} + + {'Complex'} + + {' text tree'} + {' [twiceNested]'} + {' with multiple nested text runs'} + + {' [onceNested]'} + {' and subtext runs'} + @@ -468,11 +484,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.'; From 35a926861c2b7a5e7c67191957157c305da43425 Mon Sep 17 00:00:00 2001 From: PPatBoyd Date: Thu, 20 Jun 2024 12:43:27 -0700 Subject: [PATCH 04/11] Change files --- ...ative-callout-c2d819dd-99cd-41c7-b08a-f6b7f8c9157f.json | 7 +++++++ ...native-tester-9448ae2c-94f1-43cc-a790-e35a32b2a80e.json | 7 +++++++ 2 files changed, 14 insertions(+) create mode 100644 change/@fluentui-react-native-callout-c2d819dd-99cd-41c7-b08a-f6b7f8c9157f.json create mode 100644 change/@fluentui-react-native-tester-9448ae2c-94f1-43cc-a790-e35a32b2a80e.json diff --git a/change/@fluentui-react-native-callout-c2d819dd-99cd-41c7-b08a-f6b7f8c9157f.json b/change/@fluentui-react-native-callout-c2d819dd-99cd-41c7-b08a-f6b7f8c9157f.json new file mode 100644 index 0000000000..bfa6169b1d --- /dev/null +++ b/change/@fluentui-react-native-callout-c2d819dd-99cd-41c7-b08a-f6b7f8c9157f.json @@ -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": "ppatboyd@outlook.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-native-tester-9448ae2c-94f1-43cc-a790-e35a32b2a80e.json b/change/@fluentui-react-native-tester-9448ae2c-94f1-43cc-a790-e35a32b2a80e.json new file mode 100644 index 0000000000..01d2fe8d1b --- /dev/null +++ b/change/@fluentui-react-native-tester-9448ae2c-94f1-43cc-a790-e35a32b2a80e.json @@ -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": "ppatboyd@outlook.com", + "dependentChangeType": "patch" +} From 61181fba3339b7645e8c7900d07dcf69d4f94509 Mon Sep 17 00:00:00 2001 From: PPatBoyd Date: Fri, 21 Jun 2024 13:09:39 -0700 Subject: [PATCH 05/11] Fix ref type used for Textv1 --- .../src/TestComponents/Callout/CalloutTest.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/fluent-tester/src/TestComponents/Callout/CalloutTest.tsx b/apps/fluent-tester/src/TestComponents/Callout/CalloutTest.tsx index d38bf17326..53d7ee7140 100644 --- a/apps/fluent-tester/src/TestComponents/Callout/CalloutTest.tsx +++ b/apps/fluent-tester/src/TestComponents/Callout/CalloutTest.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import type { KeyboardMetrics } 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, TextV1 as Text } from '@fluentui/react-native'; @@ -61,9 +61,9 @@ const StandardCallout: React.FunctionComponent = () => { [calloutDismissBehaviors], ); - const textRef = React.useRef(null); - const textRefInner1 = React.useRef(null); - const textRefInner2 = React.useRef(null); + const textRef = React.useRef(null); + const textRefInner1 = React.useRef(null); + const textRefInner2 = React.useRef(null); const redTargetRef = React.useRef(null); const blueTargetRef = React.useRef(null); const greenTargetRef = React.useRef(null); @@ -71,7 +71,7 @@ const StandardCallout: React.FunctionComponent = () => { const decoyBtn2Ref = React.useRef(null); const [anchorRefIndex, setAnchorRefIndex] = React.useState(0); const anchorRefCycle = [redTargetRef, greenTargetRef, blueTargetRef, textRef, textRefInner1, textRefInner2]; - const [anchorRef, setAnchorRef] = React.useState | React.RefObject | string | undefined>(anchorRefCycle[0]); + const [anchorRef, setAnchorRef] = React.useState | React.RefObject | string | undefined>(anchorRefCycle[0]); const [hoveredTargetsCount, setHoveredTargetsCount] = React.useState(0); const [displayCountHoveredTargets, setDisplayCountHoveredTargets] = React.useState(0); From 9861139e763e0e0436202480bfccb49ea85f4fa6 Mon Sep 17 00:00:00 2001 From: PPatBoyd Date: Fri, 21 Jun 2024 13:10:07 -0700 Subject: [PATCH 06/11] Bump react-native-macos to use latest 0.73.32 version that includes getRectForCharRange used by CalloutView.swift --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 35b2a48cc3..d9e026056a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" @@ -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 From b00627d6f78be3950c4c8789e7dcc5d95a0ce832 Mon Sep 17 00:00:00 2001 From: PPatBoyd Date: Fri, 21 Jun 2024 13:52:53 -0700 Subject: [PATCH 07/11] address PR feedback and some other nit cleanup --- .../Callout/macos/CalloutView.swift | 56 +++++++++++-------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/packages/components/Callout/macos/CalloutView.swift b/packages/components/Callout/macos/CalloutView.swift index 10a790b2d9..8afc54557a 100644 --- a/packages/components/Callout/macos/CalloutView.swift +++ b/packages/components/Callout/macos/CalloutView.swift @@ -46,7 +46,7 @@ func getStartCharRangeForTag(reactTag: NSNumber, shadowView: RCTShadowView) -> ( } 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 + var startCharRange = 0 for subview in shadowView.reactSubviews() { let subviewSearch = getStartCharRangeForTag(reactTag:reactTag, shadowView: subview) startCharRange += subviewSearch.startCharRange @@ -54,10 +54,10 @@ func getStartCharRangeForTag(reactTag: NSNumber, shadowView: RCTShadowView) -> ( return (found: subviewSearch.found, startCharRange: startCharRange) } } - + return (found: false, startCharRange: startCharRange) } - + return (found: false, startCharRange: 0) } @@ -226,13 +226,12 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { isCalloutWindowShown = false } - - /// 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? { + + // 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() && subShadowView.viewIsDescendant(of: leafShadowView) else { - preconditionFailure("leafshadow is not a leaf TextView or subshadow not a descendant of the leafshadow") + guard leafShadowView.isYogaLeafNode() else { + preconditionFailure("leafshadow is not a leaf node") } // Do not proceed if we don't have a UI Manager; we won't be able to determine the bounding rect @@ -255,6 +254,22 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { guard let textView = leafView as? RCTTextView else { preconditionFailure("it should not be possible for the RCTTextShadowView to not be represented by an RCTTextView") } + + 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) @@ -279,19 +294,20 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { return nil } - - // Return the anchor rect for the target prop if available + + /// Return the anchor rect for the target prop if available private func getAnchorRectForTarget() -> NSRect? { - guard let reactTag = target, let reactBridge = bridge else { + guard let reactTag = target, let reactBridge = bridge else { return nil } let zeroRect = CGRect(x: 0, y: 0, width: 0, height: 0) - let targetView = reactBridge.uiManager.view(forReactTag: reactTag) - + // If the targetView is backed by an NSView and has a representative rect, return it as the anchor rect for the target - if let targetViewBounds = targetView?.bounds, !targetViewBounds.equalTo(zeroRect) { - return calculateAnchorViewScreenRect(anchorView: targetView, anchorBounds: targetViewBounds) + 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 @@ -323,7 +339,7 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { return nil } - /// Get the AnchorScreenRect to use for Callout anchoring, prioritizing the target prop is prioritized over th anchorRect prop + /// 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(); @@ -383,11 +399,7 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { } /// Calculates the NSRect of the anchorView in the coordinate space of the current screen - private func calculateAnchorViewScreenRect(anchorView: NSView?, anchorBounds: NSRect) -> NSRect { - guard let anchorView = anchorView else { - preconditionFailure("No anchor view provided to position the Callout") - } - + private func calculateAnchorViewScreenRect(anchorView: NSView, anchorBounds: NSRect) -> NSRect { guard let window = window else { preconditionFailure("No window found") } From 5e2111f971bed7da5dd98a4c139b4e06a600e57c Mon Sep 17 00:00:00 2001 From: PPatBoyd Date: Fri, 21 Jun 2024 14:17:49 -0700 Subject: [PATCH 08/11] yarn prettier-fix --- .../TestComponents/Callout/CalloutTest.tsx | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/fluent-tester/src/TestComponents/Callout/CalloutTest.tsx b/apps/fluent-tester/src/TestComponents/Callout/CalloutTest.tsx index 53d7ee7140..a1c67e1f9f 100644 --- a/apps/fluent-tester/src/TestComponents/Callout/CalloutTest.tsx +++ b/apps/fluent-tester/src/TestComponents/Callout/CalloutTest.tsx @@ -69,8 +69,8 @@ const StandardCallout: React.FunctionComponent = () => { const greenTargetRef = React.useRef(null); const decoyBtn1Ref = React.useRef(null); const decoyBtn2Ref = React.useRef(null); - const [anchorRefIndex, setAnchorRefIndex] = React.useState(0); - const anchorRefCycle = [redTargetRef, greenTargetRef, blueTargetRef, textRef, textRefInner1, textRefInner2]; + const [anchorRefIndex, setAnchorRefIndex] = React.useState(0); + const anchorRefCycle = [redTargetRef, greenTargetRef, blueTargetRef, textRef, textRefInner1, textRefInner2]; const [anchorRef, setAnchorRef] = React.useState | React.RefObject | string | undefined>(anchorRefCycle[0]); const [hoveredTargetsCount, setHoveredTargetsCount] = React.useState(0); const [displayCountHoveredTargets, setDisplayCountHoveredTargets] = React.useState(0); @@ -149,7 +149,7 @@ const StandardCallout: React.FunctionComponent = () => { const toggleCalloutRef = React.useCallback(() => { // Cycle the target ref between the RGB target views setAnchorRefIndex((anchorRefIndex + 1) % anchorRefCycle.length); - setAnchorRef(anchorRefCycle[anchorRefIndex]); + setAnchorRef(anchorRefCycle[anchorRefIndex]); }, [anchorRef, anchorRefIndex]); const switchTargetRefOrRect = React.useCallback(() => { @@ -343,14 +343,18 @@ const StandardCallout: React.FunctionComponent = () => { {anchorRefsInfo.isCurrentAnchor[2] && {anchorRefsInfo.hoverCount}} - - {'Complex'} + + {'Complex'} - {' text tree'} - {' [twiceNested]'} - {' with multiple nested text runs'} + {' text tree'} + + {' [twiceNested]'} + + {' with multiple nested text runs'} + + + {' [onceNested]'} - {' [onceNested]'} {' and subtext runs'} From e621f6668ae887fa3b6da7cc2956cad14a7cdec2 Mon Sep 17 00:00:00 2001 From: PPatBoyd Date: Wed, 26 Jun 2024 10:35:18 -0700 Subject: [PATCH 09/11] nit fix whitespaces --- .../components/Callout/macos/CalloutView.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/components/Callout/macos/CalloutView.swift b/packages/components/Callout/macos/CalloutView.swift index 8afc54557a..cbdcb6aa81 100644 --- a/packages/components/Callout/macos/CalloutView.swift +++ b/packages/components/Callout/macos/CalloutView.swift @@ -121,7 +121,7 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { override func updateLayer() { if let layer = calloutWindow.contentView?.layer { - layer.borderColor = borderColor.cgColor + layer.borderColor = borderColor.cgColor layer.borderWidth = borderWidth layer.backgroundColor = backgroundColor.cgColor layer.cornerRadius = borderRadius @@ -159,7 +159,7 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { updateCalloutFrameToAnchor() calloutWindow.orderFront(self) if (setInitialFocus) { - calloutWindow.makeKey() + calloutWindow.makeKey() } // Dismiss the Callout if the window is no longer active. @@ -265,7 +265,7 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { 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 @@ -294,7 +294,7 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { return nil } - + /// Return the anchor rect for the target prop if available private func getAnchorRectForTarget() -> NSRect? { guard let reactTag = target, let reactBridge = bridge else { @@ -354,9 +354,9 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { return } - guard let anchorScreenRect = getAnchorScreenRect() else { - return - } + guard let anchorScreenRect = getAnchorScreenRect() else { + return + } let calloutScreenRect = bestCalloutRect(relativeTo: anchorScreenRect) @@ -372,7 +372,7 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { /// Calculates the NSRect of the Anchor Rect in screen coordinates private func calculateAnchorRectScreenRect() -> NSRect { - guard let window = window else { + guard let window = window else { preconditionFailure("No window found") } @@ -400,7 +400,7 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { /// Calculates the NSRect of the anchorView in the coordinate space of the current screen private func calculateAnchorViewScreenRect(anchorView: NSView, anchorBounds: NSRect) -> NSRect { - guard let window = window else { + guard let window = window else { preconditionFailure("No window found") } From 3495b9d747262ca28c275d78d6a4127559b1435e Mon Sep 17 00:00:00 2001 From: PPatBoyd Date: Wed, 26 Jun 2024 10:36:10 -0700 Subject: [PATCH 10/11] better stacked guard usage --- .../Callout/macos/CalloutView.swift | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/packages/components/Callout/macos/CalloutView.swift b/packages/components/Callout/macos/CalloutView.swift index cbdcb6aa81..b826116de0 100644 --- a/packages/components/Callout/macos/CalloutView.swift +++ b/packages/components/Callout/macos/CalloutView.swift @@ -234,28 +234,21 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { preconditionFailure("leafshadow is not a leaf node") } - // 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 { + + // Do not proceed if we: + // - don't have a UI Manager + // - don't have the NSView corresponding to the yoga leaf view + // - the leafView is somehow not an RCTTextView (should not be possible for an RCTTextShadowView) + guard let leafTextView = bridge?.uiManager?.view(forReactTag: leafShadowView.reactTag) as? RCTTextView 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") - } - - return (textView, textShadowView) + return (leafTextView, textShadowView) } /// Return the boundingRect for a shadowView that is a subview of a Yoga leaf node From 829883482ddd37324e64633126cc86ba08c2a75f Mon Sep 17 00:00:00 2001 From: PPatBoyd Date: Wed, 26 Jun 2024 10:36:34 -0700 Subject: [PATCH 11/11] Swift-ier function signatures and CGRect.zero --- .../Callout/macos/CalloutView.swift | 29 ++++++++----------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/packages/components/Callout/macos/CalloutView.swift b/packages/components/Callout/macos/CalloutView.swift index b826116de0..9d7d3d8341 100644 --- a/packages/components/Callout/macos/CalloutView.swift +++ b/packages/components/Callout/macos/CalloutView.swift @@ -226,9 +226,9 @@ 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)? { + + /// Return the TextView and TextShadowView of a Leaf node ShadowView for specialized nested TextView anchoring + private func getTextViews(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") @@ -260,7 +260,7 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { } // Get the TextView and TextShadowView we need to calculate the bounds of the subview - guard let (textView, textShadowView) = getTextViewsForLeafShadow(leafShadowView: leafShadowView) else { + guard let (textView, textShadowView) = getTextViews(leafShadowView: leafShadowView) else { return nil } @@ -294,12 +294,10 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { return nil } - let zeroRect = CGRect(x: 0, y: 0, width: 0, height: 0) - // 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 !targetView.bounds.equalTo(CGRect.zero) { + return calculateAnchorViewScreenRect(anchorView: targetView) } } @@ -324,8 +322,8 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { } // 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) + if !targetViewBounds.equalTo(CGRect.zero) { + return calculateAnchorViewScreenRect(anchorView: leafNSView, subviewAnchorBounds: targetViewBounds) } // Unfortunately our efforts could not determine a valid anchor rect for our target prop @@ -392,12 +390,12 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { } /// Calculates the NSRect of the anchorView in the coordinate space of the current screen - private func calculateAnchorViewScreenRect(anchorView: NSView, anchorBounds: NSRect) -> NSRect { + private func calculateAnchorViewScreenRect(anchorView: NSView, subviewAnchorBounds: NSRect? = nil) -> NSRect { guard let window = window else { preconditionFailure("No window found") } - let anchorBoundsInWindow = anchorView.convert(anchorBounds, to: nil) + let anchorBoundsInWindow = anchorView.convert(subviewAnchorBounds ?? anchorView.bounds, to: nil) let anchorFrameInScreenCoordinates = window.convertToScreen(anchorBoundsInWindow) return anchorFrameInScreenCoordinates @@ -526,17 +524,14 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate { } } - // The app's main menu bar is active while callout is shown, dismiss. + /// The app's main menu bar is active while callout is shown, dismiss. @objc private func menuDidBeginTracking() { self.dismissCallout() } // MARK: Private variables - /// The view the Callout is presented from. - private var anchorReactTag: NSNumber? - - /// The view we forward Callout's Children to. It's hosted within the CalloutWindow's + /// 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. private lazy var proxyView: NSView = { let visualEffectView = FlippedVisualEffectView()