Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/react-grab/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down
17 changes: 17 additions & 0 deletions packages/react-grab/src/core/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,23 @@ export const getElementContext = async (
return getFallbackContext(element);
};

export const getTextNodeContext = async (
textNode: Text,
options: StackContextOptions = {},
): Promise<string> => {
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);

Expand Down
152 changes: 139 additions & 13 deletions packages/react-grab/src/core/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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<Text | null>(
null,
);
const [frozenTextNode, setFrozenTextNode] = createSignal<Text | null>(null);
const [actionCycleItems, setActionCycleItems] = createSignal<
ActionCycleItem[]
>([]);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -896,6 +912,41 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
);
};

const copyTextNodeToClipboard = async (
textNode: Text,
parentElement: Element,
extraPrompt?: string,
): Promise<void> => {
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,
Expand Down Expand Up @@ -941,6 +992,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
extraPrompt?: string;
shouldDeactivateAfter?: boolean;
onComplete?: () => void;
textNode?: Text | null;
dragRect?: {
pageX: number;
pageY: number;
Expand All @@ -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));
Expand All @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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];
Expand All @@ -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);
Expand Down Expand Up @@ -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 = "";
}
Expand Down Expand Up @@ -1645,6 +1738,7 @@ export const init = (rawOptions?: Options): ReactGrabAPI => {
positionX: labelPositionX,
elements,
extraPrompt: prompt || undefined,
textNode: frozenTextNode(),
onComplete: deactivateRenderer,
});
};
Expand Down Expand Up @@ -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;
});
}
Expand Down Expand Up @@ -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();
Expand All @@ -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,
});
};

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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`;
Expand Down Expand Up @@ -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();
};
Expand Down
16 changes: 16 additions & 0 deletions packages/react-grab/src/utils/create-text-node-bounds.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
Loading
Loading