From 86ebda9523a4b68bfd82a9a575a8e29156c1788b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 27 Feb 2026 07:30:12 +0000 Subject: [PATCH 1/2] feat: add text node selection and copy support When hovering over text within elements that have mixed content (both text nodes and child elements), the tool now selects the specific text node rather than the entire parent element. This provides more precise selections. Changes: - Add get-text-node-at-position utility using caretRangeFromPoint API - Add create-text-node-bounds utility using Range API for text node bounds - Add getTextNodeContext in context.ts for text node copy content - Integrate text node detection into hover, selection bounds, tag name, copy flow, and context menu in core/index.tsx - Text nodes show as #text in the selection label Co-authored-by: Aiden Bai --- packages/react-grab/src/core/context.ts | 17 +++ packages/react-grab/src/core/index.tsx | 129 ++++++++++++++++-- .../src/utils/create-text-node-bounds.ts | 16 +++ .../src/utils/get-text-node-at-position.ts | 31 +++++ 4 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 packages/react-grab/src/utils/create-text-node-bounds.ts create mode 100644 packages/react-grab/src/utils/get-text-node-at-position.ts diff --git a/packages/react-grab/src/core/context.ts b/packages/react-grab/src/core/context.ts index 36ce4b0a4..6ad0a2cc8 100644 --- a/packages/react-grab/src/core/context.ts +++ b/packages/react-grab/src/core/context.ts @@ -510,6 +510,23 @@ export const getElementContext = async ( return getFallbackContext(element); }; +export const getTextNodeContext = async ( + textNode: Text, + options: StackContextOptions = {}, +): Promise => { + const parentElement = textNode.parentElement; + const textContent = textNode.textContent?.trim() ?? ""; + + if (!parentElement) return textContent; + + const stackContext = await getStackContext(parentElement, options); + if (stackContext) { + return `${textContent}${stackContext}`; + } + + return textContent; +}; + const getFallbackContext = (element: Element): string => { const tagName = getTagName(element); diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 192354ea0..9f91b6b24 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -32,12 +32,15 @@ import { getComponentDisplayName, resolveSourceFromStack, checkIsNextProject, + getTextNodeContext, } from "./context.js"; import { isSourceFile, normalizeFileName } from "bippy/source"; import { createNoopApi } from "./noop-api.js"; import { createEventListenerManager } from "./events.js"; import { tryCopyWithFallback } from "./copy.js"; import { getElementAtPosition } from "../utils/get-element-at-position.js"; +import { getTextNodeAtPosition } from "../utils/get-text-node-at-position.js"; +import { createTextNodeBounds } from "../utils/create-text-node-bounds.js"; import { isValidGrabbableElement } from "../utils/is-valid-grabbable-element.js"; import { isRootElement } from "../utils/is-root-element.js"; import { isElementConnected } from "../utils/is-element-connected.js"; @@ -497,6 +500,11 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const [resolvedComponentName, setResolvedComponentName] = createSignal< string | undefined >(undefined); + const TEXT_NODE_TAG_NAME = "#text"; + const [detectedTextNode, setDetectedTextNode] = createSignal( + null, + ); + const [frozenTextNode, setFrozenTextNode] = createSignal(null); const [actionCycleItems, setActionCycleItems] = createSignal< ActionCycleItem[] >([]); @@ -896,6 +904,41 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { ); }; + const copyTextNodeToClipboard = async ( + textNode: Text, + parentElement: Element, + extraPrompt?: string, + ): Promise => { + if (pluginRegistry.store.theme.grabbedBoxes.enabled) { + showTemporaryGrabbedBox(createTextNodeBounds(textNode), parentElement); + } + + await waitUntilNextFrame(); + + const context = await getTextNodeContext(textNode, { + maxLines: pluginRegistry.store.options.maxContextLines, + }); + if (!context.trim()) return; + + const content = extraPrompt ? `${extraPrompt}\n\n${context}` : context; + + const didCopy = copyContent(content, { + componentName: getComponentDisplayName(parentElement) ?? undefined, + entries: [ + { + tagName: TEXT_NODE_TAG_NAME, + content: context, + commentText: extraPrompt, + }, + ], + }); + + if (didCopy) { + pluginRegistry.hooks.onCopySuccess([parentElement], content); + } + pluginRegistry.hooks.onAfterCopy([parentElement], didCopy); + }; + const copyElementsToClipboard = async ( targetElements: Element[], extraPrompt?: string, @@ -941,6 +984,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { extraPrompt?: string; shouldDeactivateAfter?: boolean; onComplete?: () => void; + textNode?: Text | null; dragRect?: { pageX: number; pageY: number; @@ -956,13 +1000,19 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { extraPrompt, shouldDeactivateAfter, onComplete, + textNode: selectedTextNode, dragRect: passedDragRect, }: CopyWithLabelOptions) => { const allElements = elements ?? [element]; const dragRect = passedDragRect ?? store.frozenDragRect; + const isTextNodeSelection = Boolean(selectedTextNode); let overlayBounds: OverlayBounds; - if (dragRect && allElements.length > 1) { + if (isTextNodeSelection && selectedTextNode) { + overlayBounds = createFlatOverlayBounds( + createTextNodeBounds(selectedTextNode), + ); + } else if (dragRect && allElements.length > 1) { overlayBounds = createBoundsFromDragRect(dragRect); } else { overlayBounds = createFlatOverlayBounds(createElementBounds(element)); @@ -973,7 +1023,9 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { ? overlayBounds.x + overlayBounds.width / 2 : positionX; - const tagName = getTagName(element); + const tagName = isTextNodeSelection + ? TEXT_NODE_TAG_NAME + : getTagName(element); inToggleFeedbackPeriod = false; actions.startCopy(); @@ -986,14 +1038,19 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { : null; void getNearestComponentName(element).then((componentName) => { + const operation = isTextNodeSelection + ? () => + copyTextNodeToClipboard(selectedTextNode!, element, extraPrompt) + : () => + copyElementsToClipboard( + allElements, + extraPrompt, + componentName ?? undefined, + ); + void executeCopyOperation({ positionX: labelPositionX, - operation: () => - copyElementsToClipboard( - allElements, - extraPrompt, - componentName ?? undefined, - ), + operation, bounds: overlayBounds, tagName, componentName: componentName ?? undefined, @@ -1019,6 +1076,21 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { () => store.frozenElement || (isToggleFrozen() ? null : targetElement()), ); + const activeTextNode = createMemo( + () => frozenTextNode() ?? (isToggleFrozen() ? null : detectedTextNode()), + ); + + createEffect( + on( + () => store.frozenElement, + (frozenElement) => { + if (!frozenElement) { + setFrozenTextNode(null); + } + }, + ), + ); + createEffect(() => { const element = store.detectedElement; if (!element) return; @@ -1117,6 +1189,10 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const frozenElements = store.frozenElements; if (frozenElements.length > 0) { + const currentFrozenTextNode = frozenTextNode(); + if (frozenElements.length === 1 && currentFrozenTextNode) { + return createTextNodeBounds(currentFrozenTextNode); + } const frozenBounds = frozenElementsBounds(); if (frozenElements.length === 1) { const firstBounds = frozenBounds[0]; @@ -1130,6 +1206,11 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { return createFlatOverlayBounds(combineBounds(frozenBounds)); } + const currentTextNode = activeTextNode(); + if (currentTextNode) { + return createTextNodeBounds(currentTextNode); + } + const element = selectionElement(); if (!element) return undefined; return createElementBounds(element); @@ -1512,6 +1593,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { arrowNavigator.clearHistory(); keyboardSelectedElement = null; isPendingContextMenuSelect = false; + setDetectedTextNode(null); + setFrozenTextNode(null); if (wasDragging) { document.body.style.userSelect = ""; } @@ -1645,6 +1728,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { positionX: labelPositionX, elements, extraPrompt: prompt || undefined, + textNode: frozenTextNode(), onComplete: deactivateRenderer, }); }; @@ -1814,6 +1898,18 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { if (candidate !== store.detectedElement) { actions.setDetectedElement(candidate); } + + const textNodeCandidate = candidate + ? getTextNodeAtPosition( + latestDetectionX, + latestDetectionY, + candidate, + ) + : null; + if (textNodeCandidate !== detectedTextNode()) { + setDetectedTextNode(textNodeCandidate); + } + pendingDetectionScheduledAt = 0; }); } @@ -1965,6 +2061,9 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { freezeAllAnimations([element]); actions.setFrozenElement(element); + const contextTextNode = + activeTextNode() ?? getTextNodeAtPosition(clientX, clientY, element); + setFrozenTextNode(contextTextNode); const position = { x: positionX, y: positionY }; actions.setPointer(position); actions.freeze(); @@ -1977,10 +2076,18 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { actions.setLastGrabbed(element); + const clickTextNode = + frozenTextNode() ?? + activeTextNode() ?? + getTextNodeAtPosition(clientX, clientY, element); + + setFrozenTextNode(clickTextNode); + performCopyWithLabel({ element, positionX, shouldDeactivateAfter, + textNode: clickTextNode, }); }; @@ -3045,6 +3152,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }); const selectionTagName = createMemo(() => { + if (activeTextNode()) return TEXT_NODE_TAG_NAME; const element = selectionElement(); if (!element) return undefined; return getTagName(element) || undefined; @@ -3181,6 +3289,9 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const contextMenuBounds = createMemo((): OverlayBounds | null => { void store.viewportVersion; + const currentFrozenTextNode = frozenTextNode(); + if (currentFrozenTextNode) + return createTextNodeBounds(currentFrozenTextNode); const element = store.contextMenuElement; if (!element) return null; return createElementBounds(element); @@ -3194,6 +3305,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const contextMenuTagName = createMemo(() => { const element = store.contextMenuElement; if (!element) return undefined; + if (frozenTextNode()) return TEXT_NODE_TAG_NAME; const frozenCount = store.frozenElements.length; if (frozenCount > 1) { return `${frozenCount} elements`; @@ -3359,6 +3471,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { positionX: position.x, elements: elements.length > 1 ? elements : undefined, shouldDeactivateAfter: store.wasActivatedByToggle, + textNode: frozenTextNode(), }); hideContextMenuAction(); }; diff --git a/packages/react-grab/src/utils/create-text-node-bounds.ts b/packages/react-grab/src/utils/create-text-node-bounds.ts new file mode 100644 index 000000000..9c360eec4 --- /dev/null +++ b/packages/react-grab/src/utils/create-text-node-bounds.ts @@ -0,0 +1,16 @@ +import type { OverlayBounds } from "../types.js"; + +export const createTextNodeBounds = (textNode: Text): OverlayBounds => { + const range = document.createRange(); + range.selectNodeContents(textNode); + const rect = range.getBoundingClientRect(); + + return { + borderRadius: "0px", + height: rect.height, + transform: "none", + width: rect.width, + x: rect.left, + y: rect.top, + }; +}; diff --git a/packages/react-grab/src/utils/get-text-node-at-position.ts b/packages/react-grab/src/utils/get-text-node-at-position.ts new file mode 100644 index 000000000..26278cda1 --- /dev/null +++ b/packages/react-grab/src/utils/get-text-node-at-position.ts @@ -0,0 +1,31 @@ +const hasElementChild = (parent: Element): boolean => { + const childNodes = parent.childNodes; + for (let index = 0; index < childNodes.length; index++) { + if (childNodes[index].nodeType === Node.ELEMENT_NODE) { + return true; + } + } + return false; +}; + +export const getTextNodeAtPosition = ( + clientX: number, + clientY: number, + parentElement: Element, +): Text | null => { + const range = document.caretRangeFromPoint(clientX, clientY); + if (!range) return null; + + const container = range.startContainer; + if (container.nodeType !== Node.TEXT_NODE) return null; + + const textNode = container as Text; + if (textNode.parentElement !== parentElement) return null; + + const trimmedContent = textNode.textContent?.trim(); + if (!trimmedContent || trimmedContent.length === 0) return null; + + if (!hasElementChild(parentElement)) return null; + + return textNode; +}; From b7c12fba1ac2ed1bbbcd1c80cf175c02e92e2120 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 27 Feb 2026 10:24:04 +0000 Subject: [PATCH 2/2] fix: show truncated text content in label and fix Copied! bounds for text nodes - Tag display now shows Component."truncated text..." instead of #text - Prevent parent element bounds from overriding text node bounds in the Copied! feedback label and grabbed box highlight - Add TEXT_NODE_TAG_DISPLAY_MAX_LENGTH constant (20 chars) Co-authored-by: Aiden Bai --- packages/react-grab/src/constants.ts | 1 + packages/react-grab/src/core/index.tsx | 35 ++++++++++++++++++-------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/packages/react-grab/src/constants.ts b/packages/react-grab/src/constants.ts index b5f4026b4..869397cb9 100644 --- a/packages/react-grab/src/constants.ts +++ b/packages/react-grab/src/constants.ts @@ -60,6 +60,7 @@ export const ARROW_CENTER_PERCENT = 50; export const ARROW_LABEL_MARGIN_PX = 16; export const LABEL_GAP_PX = 4; export const PREVIEW_TEXT_MAX_LENGTH = 100; +export const TEXT_NODE_TAG_DISPLAY_MAX_LENGTH = 20; export const PREVIEW_ATTR_VALUE_MAX_LENGTH = 15; export const PREVIEW_MAX_ATTRS = 3; export const PREVIEW_PRIORITY_ATTRS: readonly string[] = [ diff --git a/packages/react-grab/src/core/index.tsx b/packages/react-grab/src/core/index.tsx index 9f91b6b24..5b530fa34 100644 --- a/packages/react-grab/src/core/index.tsx +++ b/packages/react-grab/src/core/index.tsx @@ -54,6 +54,7 @@ import { createPageRectFromBounds, } from "../utils/create-bounds-from-drag-rect.js"; import { getTagName } from "../utils/get-tag-name.js"; +import { truncateString } from "../utils/truncate-string.js"; import { ARROW_KEYS, FEEDBACK_DURATION_MS, @@ -77,6 +78,7 @@ import { WINDOW_REFOCUS_GRACE_PERIOD_MS, DROPDOWN_HOVER_OPEN_DELAY_MS, PREVIEW_TEXT_MAX_LENGTH, + TEXT_NODE_TAG_DISPLAY_MAX_LENGTH, DEFERRED_EXECUTION_DELAY_MS, NEXTJS_REVALIDATION_DELAY_MS, } from "../constants.js"; @@ -500,7 +502,11 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const [resolvedComponentName, setResolvedComponentName] = createSignal< string | undefined >(undefined); - const TEXT_NODE_TAG_NAME = "#text"; + const getTextNodeTagName = (textNode: Text): string => { + const content = textNode.textContent?.trim() ?? ""; + return `"${truncateString(content, TEXT_NODE_TAG_DISPLAY_MAX_LENGTH)}"`; + }; + const [detectedTextNode, setDetectedTextNode] = createSignal( null, ); @@ -541,14 +547,16 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const showTemporaryGrabbedBox = ( bounds: OverlayBounds, - element: Element, + element?: Element, ) => { const boxId = `grabbed-${Date.now()}-${Math.random()}`; const createdAt = Date.now(); const newBox: GrabbedBox = { id: boxId, bounds, createdAt, element }; actions.addGrabbedBox(newBox); - pluginRegistry.hooks.onGrabbedBox(bounds, element); + if (element) { + pluginRegistry.hooks.onGrabbedBox(bounds, element); + } const timeoutId = window.setTimeout(() => { grabbedBoxTimeouts.delete(boxId); @@ -910,7 +918,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { extraPrompt?: string, ): Promise => { if (pluginRegistry.store.theme.grabbedBoxes.enabled) { - showTemporaryGrabbedBox(createTextNodeBounds(textNode), parentElement); + showTemporaryGrabbedBox(createTextNodeBounds(textNode)); } await waitUntilNextFrame(); @@ -926,7 +934,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { componentName: getComponentDisplayName(parentElement) ?? undefined, entries: [ { - tagName: TEXT_NODE_TAG_NAME, + tagName: getTextNodeTagName(textNode), content: context, commentText: extraPrompt, }, @@ -1024,16 +1032,18 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { : positionX; const tagName = isTextNodeSelection - ? TEXT_NODE_TAG_NAME + ? getTextNodeTagName(selectedTextNode!) : getTagName(element); inToggleFeedbackPeriod = false; actions.startCopy(); + const labelInstanceElement = isTextNodeSelection ? undefined : element; + const labelInstanceId = tagName ? createLabelInstance(overlayBounds, tagName, undefined, "copying", { - element, + element: labelInstanceElement, mouseX: labelPositionX, - elements, + elements: isTextNodeSelection ? undefined : elements, }) : null; @@ -1054,7 +1064,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { bounds: overlayBounds, tagName, componentName: componentName ?? undefined, - element, + element: labelInstanceElement, shouldDeactivateAfter, elements, existingInstanceId: labelInstanceId, @@ -3152,7 +3162,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }); const selectionTagName = createMemo(() => { - if (activeTextNode()) return TEXT_NODE_TAG_NAME; + const currentTextNode = activeTextNode(); + if (currentTextNode) return getTextNodeTagName(currentTextNode); const element = selectionElement(); if (!element) return undefined; return getTagName(element) || undefined; @@ -3305,7 +3316,9 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const contextMenuTagName = createMemo(() => { const element = store.contextMenuElement; if (!element) return undefined; - if (frozenTextNode()) return TEXT_NODE_TAG_NAME; + const currentFrozenTextNode = frozenTextNode(); + if (currentFrozenTextNode) + return getTextNodeTagName(currentFrozenTextNode); const frozenCount = store.frozenElements.length; if (frozenCount > 1) { return `${frozenCount} elements`;