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/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..5b530fa34 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"; @@ -51,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, @@ -74,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"; @@ -497,6 +502,15 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const [resolvedComponentName, setResolvedComponentName] = createSignal< string | undefined >(undefined); + const getTextNodeTagName = (textNode: Text): string => { + const content = textNode.textContent?.trim() ?? ""; + return `"${truncateString(content, TEXT_NODE_TAG_DISPLAY_MAX_LENGTH)}"`; + }; + + const [detectedTextNode, setDetectedTextNode] = createSignal( + null, + ); + const [frozenTextNode, setFrozenTextNode] = createSignal(null); const [actionCycleItems, setActionCycleItems] = createSignal< ActionCycleItem[] >([]); @@ -533,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); @@ -896,6 +912,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)); + } + + 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: getTextNodeTagName(textNode), + 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 +992,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { extraPrompt?: string; shouldDeactivateAfter?: boolean; onComplete?: () => void; + textNode?: Text | null; dragRect?: { pageX: number; pageY: number; @@ -956,13 +1008,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,31 +1031,40 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { ? overlayBounds.x + overlayBounds.width / 2 : positionX; - const tagName = getTagName(element); + const tagName = isTextNodeSelection + ? 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; 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, - element, + element: labelInstanceElement, shouldDeactivateAfter, elements, existingInstanceId: labelInstanceId, @@ -1019,6 +1086,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 +1199,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 +1216,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 +1603,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 +1738,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { positionX: labelPositionX, elements, extraPrompt: prompt || undefined, + textNode: frozenTextNode(), onComplete: deactivateRenderer, }); }; @@ -1814,6 +1908,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 +2071,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 +2086,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 +3162,8 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { }); const selectionTagName = createMemo(() => { + const currentTextNode = activeTextNode(); + if (currentTextNode) return getTextNodeTagName(currentTextNode); const element = selectionElement(); if (!element) return undefined; return getTagName(element) || undefined; @@ -3181,6 +3300,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 +3316,9 @@ export const init = (rawOptions?: Options): ReactGrabAPI => { const contextMenuTagName = createMemo(() => { const element = store.contextMenuElement; if (!element) return undefined; + const currentFrozenTextNode = frozenTextNode(); + if (currentFrozenTextNode) + return getTextNodeTagName(currentFrozenTextNode); const frozenCount = store.frozenElements.length; if (frozenCount > 1) { return `${frozenCount} elements`; @@ -3359,6 +3484,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; +};