diff --git a/README.md b/README.md index 5566c73..211b880 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ where you step the user through different parts of a screen. Also very useful fo highlighting an element when the user enters the app from a deep link.

- +

### ⚠️ Caveats ⚠️ @@ -48,7 +48,24 @@ import { // Remember to wrap the ROOT of your app in HighlightableElementProvider! return ( - + + + + @@ -60,7 +77,7 @@ return ( */} { // Called when the user clicks outside of the highlighted element. // Set "highlightedElementId" to nullish to hide the overlay. diff --git a/example/src/components/FavoriteList/FavoriteItem.tsx b/example/src/components/FavoriteList/FavoriteItem.tsx index 8888028..4836329 100644 --- a/example/src/components/FavoriteList/FavoriteItem.tsx +++ b/example/src/components/FavoriteList/FavoriteItem.tsx @@ -1,6 +1,6 @@ import React from "react"; import type { ImageSourcePropType, StyleProp, ViewStyle } from "react-native"; -import { StyleSheet, Image, Text, View } from "react-native"; +import { Alert, Pressable, StyleSheet, Image, Text, View } from "react-native"; export const ITEM_HEIGHT = 60; const BORDER_RADIUS = 10; @@ -15,7 +15,10 @@ export type FavoriteItemProps = { function FavoriteItem({ title, artist, duration, imageSource, style }: FavoriteItemProps) { return ( - + Alert.alert("You pressed", title)} + > {title} @@ -24,7 +27,7 @@ function FavoriteItem({ title, artist, duration, imageSource, style }: FavoriteI {duration} - + ); } diff --git a/example/src/components/FavoriteList/FavoriteList.tsx b/example/src/components/FavoriteList/FavoriteList.tsx index 4242da4..5f1b7f0 100644 --- a/example/src/components/FavoriteList/FavoriteList.tsx +++ b/example/src/components/FavoriteList/FavoriteList.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { Fragment } from "react"; import { StyleSheet, View } from "react-native"; import { HighlightableElement } from "react-native-highlight-overlay"; @@ -53,13 +53,22 @@ function FavoriteList({ setHighlightId }: FavoriteListProps) { }} /> - {FAVORITES_LIST.map((favItem, idx, arr) => ( - - - + {FAVORITES_LIST.map((favItem) => ( + + + + + {/* We don't want to highlight the margin, so place it outside */} + + ))} @@ -73,8 +82,8 @@ const styles = StyleSheet.create({ listContainer: { flex: 1, }, - favoriteItem: { - marginBottom: 15, + favoriteItemSpacing: { + height: 15, }, }); diff --git a/example/src/components/SimilarList/SimilarList.tsx b/example/src/components/SimilarList/SimilarList.tsx index 6b97822..93f9fa3 100644 --- a/example/src/components/SimilarList/SimilarList.tsx +++ b/example/src/components/SimilarList/SimilarList.tsx @@ -44,6 +44,10 @@ function SimilarList({ setHighlightId }: SimilarListProps) { diff --git a/package.json b/package.json index 3e8c050..f490c1c 100644 --- a/package.json +++ b/package.json @@ -121,5 +121,8 @@ } ] ] + }, + "dependencies": { + "react-native-svg": "^12.1.1" } } diff --git a/src/HighlightOverlay.tsx b/src/HighlightOverlay.tsx index 1379d74..3f4b04e 100644 --- a/src/HighlightOverlay.tsx +++ b/src/HighlightOverlay.tsx @@ -1,7 +1,10 @@ -import React from "react"; -import { Pressable, StyleSheet, View } from "react-native"; +import React, { useState } from "react"; +import { StyleSheet, View } from "react-native"; +import Svg, { ClipPath, Defs, G, Path, Rect } from "react-native-svg"; +import constructClipPath from "./constructClipPath"; import { useHighlightableElements } from "./context"; +import type { Bounds } from "./context/context"; export type HighlightOverlayProps = { highlightedElementId?: string | null; @@ -13,41 +16,46 @@ function HighlightOverlay({ highlightedElementId, onDismiss }: HighlightOverlayP const highlightedElementData = highlightedElementId != null ? elements[highlightedElementId] : null; + const [parentSize, setParentSize] = useState(); + + const clickthrough = highlightedElementData?.options?.clickthroughHighlight ?? true; + return ( - - {highlightedElementId != null && ( - <> - - {highlightedElementData != null && ( - - {highlightedElementData.node} - - )} - + setParentSize(layout)} + pointerEvents="box-none" + > + {highlightedElementData != null && parentSize != null && ( + + + + + + + + + + )} ); } -const styles = StyleSheet.create({ - underlay: { - ...StyleSheet.absoluteFillObject, - backgroundColor: "black", - opacity: 0.7, - }, - highlightContainer: { - position: "absolute", - }, -}); - export default HighlightOverlay; diff --git a/src/HighlightableElement.tsx b/src/HighlightableElement.tsx index d28a379..8d56033 100644 --- a/src/HighlightableElement.tsx +++ b/src/HighlightableElement.tsx @@ -4,13 +4,16 @@ import type { HostComponent } from "react-native"; import { View } from "react-native"; import { useHighlightableElements } from "./context"; +import type { HighlightOptions } from "./context/context"; export type HighlightableElementProps = PropsWithChildren<{ /** The id used by the HighlightOverlay to find this element. */ id: string; + /** The options that decide how this element should look. If left undefined, it only highlights the element. */ + options?: HighlightOptions; }>; -function HighlightableElement({ id, children }: HighlightableElementProps) { +function HighlightableElement({ id, options, children }: HighlightableElementProps) { const ref = useRef(null); const [_, { addElement, removeElement, rootRef }] = useHighlightableElements(); @@ -26,7 +29,7 @@ function HighlightableElement({ id, children }: HighlightableElementProps) { // This is a typing error on ReactNative's part. 'rootRef' is a valid reference. rootRef as unknown as HostComponent, (x, y, width, height) => { - addElement(id, children, { x, y, width, height }); + addElement(id, children, { x, y, width, height }, options); }, () => { console.error(`Error measuring layout of focused element with id ${id}.`); diff --git a/src/constructClipPath.ts b/src/constructClipPath.ts new file mode 100644 index 0000000..c4d2904 --- /dev/null +++ b/src/constructClipPath.ts @@ -0,0 +1,96 @@ +import type { Bounds, ElementsRecord } from "./context/context"; + +type ElementBounds = { + startX: number; + startY: number; + endX: number; + endY: number; +}; + +const M = (x: number, y: number) => `M ${x} ${y}`; +const L = (x: number, y: number) => `L ${x} ${y}`; +const arc = (toX: number, toY: number, radius: number) => + `A ${radius},${radius} 0 0 0 ${toX},${toY}`; +const z = "z"; + +const constructClipPath = (data: ElementsRecord[string], containerSize: Bounds): string => { + const parentBounds = { + startX: 0, + startY: 0, + endX: containerSize.width, + endY: containerSize.height, + }; + + switch (data.options?.mode) { + case "circle": { + const { + bounds: { x, y, width, height }, + options: { padding = 0 }, + } = data; + const radius = Math.max(width, height) / 2; + return constructCircularPath( + parentBounds, + { cx: x + width / 2, cy: y + height / 2 }, + radius + padding + ); + } + case "rectangle": // Fallthrough + default: { + const padding = data.options?.padding ?? 0; + const borderRadius = data.options?.borderRadius ?? 0; + + const startX = data.bounds.x - padding; + const endX = startX + data.bounds.width + padding * 2; + const startY = data.bounds.y - padding; + const endY = startY + data.bounds.height + padding * 2; + return constructRectangularPath( + parentBounds, + { startX, startY, endX, endY }, + borderRadius + ); + } + } +}; + +const constructRectangularPath = ( + parentBounds: ElementBounds, + { startX, startY, endX, endY }: ElementBounds, + borderRadius: number +): string => { + return [ + M(parentBounds.startX, parentBounds.startY), + L(parentBounds.startX, parentBounds.endY), + L(parentBounds.endX, parentBounds.endY), + L(parentBounds.endX, parentBounds.startY), + z, + M(startX, startY + borderRadius), + L(startX, endY - borderRadius), + arc(startX + borderRadius, endY, borderRadius), + L(endX - borderRadius, endY), + arc(endX, endY - borderRadius, borderRadius), + L(endX, startY + borderRadius), + arc(endX - borderRadius, startY, borderRadius), + L(startX + borderRadius, startY), + arc(startX, startY + borderRadius, borderRadius), + ].join(" "); +}; + +const constructCircularPath = ( + parentBounds: ElementBounds, + { cx, cy }: { cx: number; cy: number }, + radius: number +): string => { + return [ + M(parentBounds.startX, parentBounds.startY), + L(parentBounds.startX, parentBounds.endY), + L(parentBounds.endX, parentBounds.endY), + L(parentBounds.endX, parentBounds.startY), + z, + M(cx, cy), + `m ${-radius} 0`, + `a ${radius},${radius} 0 1,0 ${radius * 2},0`, + `a ${radius},${radius} 0 1,0 ${-radius * 2},0`, + ].join(" "); +}; + +export default constructClipPath; diff --git a/src/context/HighlightableElementProvider.tsx b/src/context/HighlightableElementProvider.tsx index 52e89ef..440024c 100644 --- a/src/context/HighlightableElementProvider.tsx +++ b/src/context/HighlightableElementProvider.tsx @@ -26,8 +26,8 @@ function HighlightableElementProvider({ ); const [elements, setElements] = useState({}); - const addElement = useCallback((id, node, bounds) => { - setElements((oldElements) => ({ ...oldElements, [id]: { node, bounds } })); + const addElement = useCallback((id, node, bounds, options) => { + setElements((oldElements) => ({ ...oldElements, [id]: { node, bounds, options } })); }, []); const removeElement: RemoveElement = useCallback((id) => { diff --git a/src/context/context.ts b/src/context/context.ts index fc34a31..a5511bd 100644 --- a/src/context/context.ts +++ b/src/context/context.ts @@ -1,13 +1,59 @@ import React from "react"; +export type HighlightOptions = CommonOptions & (RectangleOptions | CircleOptions); + +export type CommonOptions = { + /** + * If true, allows the user to click elements inside the highlight. Otherwise clicking inside the + * highlight will act the same as clicking outside the highlight, calling `onDismiss`. + * @default true + */ + clickthroughHighlight?: boolean; +}; + +export type RectangleOptions = { + mode: "rectangle" | undefined; + /** + * The border radius of the rectangle. + * @default 0 + */ + borderRadius?: number; + /** + * The padding of the rectangle. + * @default 0 + */ + padding?: number; +}; + +export type CircleOptions = { + mode: "circle"; + /** + * The padding of the circle. + * @default 0 + */ + padding?: number; +}; + export type Bounds = { x: number; y: number; width: number; height: number; }; -export type ElementsRecord = Record; -export type AddElement = (id: string, node: React.ReactNode, bounds: Bounds) => void; +export type ElementsRecord = Record< + string, + { + node: React.ReactNode; + bounds: Bounds; + options?: HighlightOptions; + } +>; +export type AddElement = ( + id: string, + node: React.ReactNode, + bounds: Bounds, + options?: HighlightOptions +) => void; export type RemoveElement = (id: string) => void; const HighlightableElementContext = React.createContext< diff --git a/yarn.lock b/yarn.lock index 4b8df36..ecca1eb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2686,6 +2686,11 @@ bl@^4.1.0: inherits "^2.0.4" readable-stream "^3.4.0" +boolbase@^1.0.0, boolbase@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" + integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24= + boxen@^5.0.0: version "5.1.2" resolved "https://registry.yarnpkg.com/boxen/-/boxen-5.1.2.tgz#788cb686fc83c1f486dfa8a40c68fc2b831d2b50" @@ -3426,6 +3431,29 @@ crypto-random-string@^2.0.0: resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== +css-select@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-2.1.0.tgz#6a34653356635934a81baca68d0255432105dbef" + integrity sha512-Dqk7LQKpwLoH3VovzZnkzegqNSuAziQyNZUcrdDM401iY+R5NkGBXGmtO05/yaXQziALuPogeG0b7UAgjnTJTQ== + dependencies: + boolbase "^1.0.0" + css-what "^3.2.1" + domutils "^1.7.0" + nth-check "^1.0.2" + +css-tree@^1.0.0-alpha.39: + version "1.1.3" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d" + integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q== + dependencies: + mdn-data "2.0.14" + source-map "^0.6.1" + +css-what@^3.2.1: + version "3.4.2" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-3.4.2.tgz#ea7026fcb01777edbde52124e21f327e7ae950e4" + integrity sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ== + cssom@^0.4.4: version "0.4.4" resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.4.4.tgz#5a66cf93d2d0b661d80bf6a44fb65f5c2e4e0a10" @@ -3684,6 +3712,24 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +dom-serializer@0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51" + integrity sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g== + dependencies: + domelementtype "^2.0.1" + entities "^2.0.0" + +domelementtype@1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f" + integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w== + +domelementtype@^2.0.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.2.0.tgz#9a0b6c2782ed6a1c7323d42267183df9bd8b1d57" + integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A== + domexception@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/domexception/-/domexception-2.0.1.tgz#fb44aefba793e1574b0af6aed2801d057529f304" @@ -3691,6 +3737,14 @@ domexception@^2.0.1: dependencies: webidl-conversions "^5.0.0" +domutils@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a" + integrity sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg== + dependencies: + dom-serializer "0" + domelementtype "1" + dot-prop@^5.1.0, dot-prop@^5.2.0: version "5.3.0" resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88" @@ -3742,6 +3796,11 @@ enquirer@^2.3.5: dependencies: ansi-colors "^4.1.1" +entities@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" + integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== + envinfo@^7.7.2: version "7.8.1" resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.8.1.tgz#06377e3e5f4d379fea7ac592d5ad8927e0c4d475" @@ -6389,6 +6448,11 @@ map-visit@^1.0.0: dependencies: object-visit "^1.0.0" +mdn-data@2.0.14: + version "2.0.14" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" + integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow== + meow@^8.0.0: version "8.1.2" resolved "https://registry.yarnpkg.com/meow/-/meow-8.1.2.tgz#bcbe45bda0ee1729d350c03cffc8395a36c4e897" @@ -6973,6 +7037,13 @@ npm-run-path@^4.0.0, npm-run-path@^4.0.1: dependencies: path-key "^3.0.0" +nth-check@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c" + integrity sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg== + dependencies: + boolbase "~1.0.0" + nullthrows@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/nullthrows/-/nullthrows-1.1.1.tgz#7818258843856ae971eae4208ad7d7eb19a431b1" @@ -7684,6 +7755,14 @@ react-native-codegen@^0.0.7: jscodeshift "^0.11.0" nullthrows "^1.1.1" +react-native-svg@^12.1.1: + version "12.1.1" + resolved "https://registry.yarnpkg.com/react-native-svg/-/react-native-svg-12.1.1.tgz#5f292410b8bcc07bbc52b2da7ceb22caf5bcaaee" + integrity sha512-NIAJ8jCnXGCqGWXkkJ1GTzO4a3Md5at5sagYV8Vh4MXYnL4z5Rh428Wahjhh+LIjx40EE5xM5YtwyJBqOIba2Q== + dependencies: + css-select "^2.1.0" + css-tree "^1.0.0-alpha.39" + react-native@0.66.0: version "0.66.0" resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.66.0.tgz#99bdd83a9a612a71b94242767989d666d445b007"