Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
382 changes: 381 additions & 1 deletion packages/react-grab/e2e/selection.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,141 @@
import { test, expect } from "./fixtures.js";
import { test, expect, type ReactGrabPageObject } from "./fixtures.js";
import { ATTRIBUTE_NAME } from "./constants.js";

interface MixedTextTarget {
position: {
x: number;
y: number;
};
textBounds: {
height: number;
width: number;
x: number;
y: number;
};
containerWidth: number;
}

interface WrappedTextTarget {
position: {
x: number;
y: number;
};
textBounds: {
height: number;
width: number;
x: number;
y: number;
};
}

const createMixedTextTarget = async (reactGrab: ReactGrabPageObject): Promise<MixedTextTarget> => {
return reactGrab.page.evaluate(() => {
const container = document.createElement("div");
container.id = "mixed-text-target";
container.style.cssText =
"position:absolute;left:200px;top:240px;width:500px;padding:12px;background:white;color:black;font:16px sans-serif;z-index:2147483000";
container.append("Grab this text ");

const nestedElement = document.createElement("strong");
nestedElement.textContent = "not the nested element";
container.append(nestedElement);
document.body.append(container);
document.body.style.minHeight = "2000px";

const textNode = container.firstChild;
if (!(textNode instanceof Text)) throw new Error("Missing text node");

const range = document.createRange();
range.selectNodeContents(textNode);
const textBounds = range.getBoundingClientRect();
const containerBounds = container.getBoundingClientRect();

return {
position: {
x: textBounds.left + textBounds.width / 2,
y: textBounds.top + textBounds.height / 2,
},
textBounds: {
height: textBounds.height,
width: textBounds.width,
x: textBounds.x,
y: textBounds.y,
},
containerWidth: containerBounds.width,
};
});
};

const createWrappedTextTarget = async (
reactGrab: ReactGrabPageObject,
): Promise<WrappedTextTarget> => {
return reactGrab.page.evaluate(() => {
const container = document.createElement("div");
container.id = "wrapped-text-target";
container.style.cssText =
"position:absolute;left:200px;top:240px;width:180px;padding:12px;background:white;color:black;font:16px/24px sans-serif;z-index:2147483000";
container.append("Grab this wrapped direct text across several separate lines before ");

const nestedElement = document.createElement("strong");
nestedElement.textContent = "nested sibling";
container.append(nestedElement);
document.body.append(container);

const textNode = container.firstChild;
if (!(textNode instanceof Text)) throw new Error("Missing text node");

const range = document.createRange();
range.selectNodeContents(textNode);
const rects = Array.from(range.getClientRects());
const textBounds = rects[1];
if (!textBounds || rects.length < 2) throw new Error("Text did not wrap");

return {
position: {
x: textBounds.left + textBounds.width / 2,
y: textBounds.top + textBounds.height / 2,
},
textBounds: {
height: textBounds.height,
width: textBounds.width,
x: textBounds.x,
y: textBounds.y,
},
};
});
};

const replaceMixedTextNode = async (
reactGrab: ReactGrabPageObject,
textContent: string,
): Promise<MixedTextTarget["textBounds"]> => {
return reactGrab.page.evaluate((replacementText) => {
const container = document.querySelector("#mixed-text-target");
const previousTextNode = container?.firstChild;
if (!container || !(previousTextNode instanceof Text)) {
throw new Error("Missing mixed text target");
}

const textNode = document.createTextNode(replacementText);
previousTextNode.replaceWith(textNode);
const range = document.createRange();
range.selectNodeContents(textNode);
const bounds = range.getBoundingClientRect();
return {
height: bounds.height,
width: bounds.width,
x: bounds.x,
y: bounds.y,
};
}, textContent);
};

const getSelectionArrowCenterX = async (reactGrab: ReactGrabPageObject): Promise<number | null> => {
const labelBounds = await reactGrab.getSelectionLabelBounds();
const arrowBounds = labelBounds?.arrow;
return arrowBounds ? arrowBounds.x + arrowBounds.width / 2 : null;
};

test.describe("Element Selection", () => {
test("should show selection box when hovering over element while active", async ({
reactGrab,
Expand Down Expand Up @@ -38,6 +173,251 @@ test.describe("Element Selection", () => {
await expect.poll(() => reactGrab.getClipboardContent()).toContain("Todo List");
});

test("should grab direct text nodes inside mixed-content elements", async ({ reactGrab }) => {
const target = await createMixedTextTarget(reactGrab);

await reactGrab.setupCallbackTracking();
await reactGrab.activate();
await reactGrab.page.mouse.move(target.position.x, target.position.y);

await expect
.poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName)
.toContain("Grab this text");

const callbackHistory = await reactGrab.getCallbackHistory();
const selectionBoxCall = callbackHistory.findLast(
(callback) => callback.name === "onSelectionBox" && callback.args[0] === true,
);
expect(selectionBoxCall?.args[1]).toEqual(
expect.objectContaining({
width: target.textBounds.width,
}),
);
expect(target.textBounds.width).toBeLessThan(target.containerWidth);

await reactGrab.page.mouse.click(target.position.x, target.position.y);
await expect
.poll(
async () => {
const callbackHistory = await reactGrab.getCallbackHistory();
const copySuccessCall = callbackHistory.findLast(
(callback) => callback.name === "onCopySuccess",
);
const copiedContent = copySuccessCall?.args[1];
return (
typeof copiedContent === "string" &&
copiedContent.includes('"Grab this text"') &&
!copiedContent.includes("not the nested element")
);
},
{ timeout: 10_000 },
)
.toBe(true);

const grabbedBoxBeforeScroll = await reactGrab.getGrabbedBoxInfo();
const initialGrabbedBoxY = grabbedBoxBeforeScroll.boxes[0]?.bounds.y;
if (initialGrabbedBoxY === undefined) throw new Error("Missing grabbed box");

const scrollYBefore = await reactGrab.page.evaluate(() => window.scrollY);
await reactGrab.scrollPage(60);
const scrollYAfter = await reactGrab.page.evaluate(() => window.scrollY);
const scrollDeltaY = scrollYAfter - scrollYBefore;
expect(scrollDeltaY).toBeGreaterThan(0);

await expect
.poll(async () => (await reactGrab.getGrabbedBoxInfo()).boxes[0]?.bounds.y)
.toBeCloseTo(initialGrabbedBoxY - scrollDeltaY, 0);
});

test("should select only the hovered line of wrapped direct text", async ({ reactGrab }) => {
const target = await createWrappedTextTarget(reactGrab);

await reactGrab.setupCallbackTracking();
await reactGrab.activate();
await reactGrab.page.mouse.move(target.position.x, target.position.y);

await expect
.poll(async () => {
const callbackHistory = await reactGrab.getCallbackHistory();
const selectionBoxCall = callbackHistory.findLast(
(callback) => callback.name === "onSelectionBox" && callback.args[0] === true,
);
return selectionBoxCall?.args[1];
})
.toEqual(expect.objectContaining(target.textBounds));
});

test("should keep prompt cursor aligned with grabbed text", async ({ reactGrab }) => {
const target = await createMixedTextTarget(reactGrab);

await reactGrab.setupCallbackTracking();
await reactGrab.registerCommentAction();
await reactGrab.activate();
await reactGrab.page.mouse.move(target.position.x, target.position.y);
await expect
.poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName)
.toContain("Grab this text");

await reactGrab.rightClickAtPosition(target.position.x, target.position.y);
await expect
.poll(async () => {
const callbackHistory = await reactGrab.getCallbackHistory();
const selectionBoxCall = callbackHistory.findLast(
(callback) => callback.name === "onSelectionBox" && callback.args[0] === true,
);
return selectionBoxCall?.args[1];
})
.toEqual(expect.objectContaining(target.textBounds));

await reactGrab.clickContextMenuItem("Comment");
await expect.poll(() => reactGrab.isPromptModeActive()).toBe(true);

await expect
.poll(async () => {
const arrowCenterX = await getSelectionArrowCenterX(reactGrab);
return arrowCenterX === null
? Number.POSITIVE_INFINITY
: Math.abs(arrowCenterX - target.position.x);
})
.toBeLessThan(2);
});

test("should keep toolbar comment cursor aligned with grabbed text", async ({ reactGrab }) => {
const target = await createMixedTextTarget(reactGrab);

await reactGrab.clickToolbarAction("comment");
await reactGrab.page.mouse.move(target.position.x, target.position.y);
await expect
.poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName)
.toContain("Grab this text");

await reactGrab.page.mouse.click(target.position.x, target.position.y);
await expect.poll(() => reactGrab.isPromptModeActive()).toBe(true);
await expect
.poll(async () => {
const arrowCenterX = await getSelectionArrowCenterX(reactGrab);
return arrowCenterX === null
? Number.POSITIVE_INFINITY
: Math.abs(arrowCenterX - target.position.x);
})
.toBeLessThan(2);
});

test("should keep text targeting when entering comment mode with Enter", async ({
reactGrab,
}) => {
const target = await createMixedTextTarget(reactGrab);

await reactGrab.setupCallbackTracking();
await reactGrab.registerCommentAction();
await reactGrab.activate();
await reactGrab.page.mouse.move(target.position.x, target.position.y);
await expect
.poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName)
.toContain("Grab this text");

await reactGrab.pressEnter();
await expect.poll(() => reactGrab.isPromptModeActive()).toBe(true);
await reactGrab.typeInInput("Update this copy");
await reactGrab.submitInput();

await expect
.poll(async () => {
const callbackHistory = await reactGrab.getCallbackHistory();
const copySuccessCall = callbackHistory.findLast(
(callback) => callback.name === "onCopySuccess",
);
const copiedContent = copySuccessCall?.args[1];
return (
typeof copiedContent === "string" &&
copiedContent.includes('"Grab this text"') &&
!copiedContent.includes("not the nested element")
);
})
.toBe(true);
});

test("should relink grabbed text after its DOM node is replaced", async ({ reactGrab }) => {
const target = await createMixedTextTarget(reactGrab);

await reactGrab.setupCallbackTracking();
await reactGrab.registerCommentAction();
await reactGrab.activate();
await reactGrab.page.mouse.move(target.position.x, target.position.y);
await expect
.poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName)
.toContain("Grab this text");

await reactGrab.pressEnter();
await expect.poll(() => reactGrab.isPromptModeActive()).toBe(true);

const replacementBounds = await replaceMixedTextNode(reactGrab, "Grab replacement text ");
await expect
.poll(async () => {
const callbackHistory = await reactGrab.getCallbackHistory();
const selectionBoxCall = callbackHistory.findLast(
(callback) => callback.name === "onSelectionBox" && callback.args[0] === true,
);
return selectionBoxCall?.args[1];
})
.toEqual(expect.objectContaining(replacementBounds));

await reactGrab.typeInInput("Update this copy");
await reactGrab.submitInput();
await expect
.poll(async () => {
const callbackHistory = await reactGrab.getCallbackHistory();
const copySuccessCall = callbackHistory.findLast(
(callback) => callback.name === "onCopySuccess",
);
return copySuccessCall?.args[1];
})
.toContain('"Grab replacement text"');

const postCopyBounds = await replaceMixedTextNode(reactGrab, "Short replacement ");
await expect
.poll(async () => (await reactGrab.getGrabbedBoxInfo()).boxes[0]?.bounds)
.toEqual(expect.objectContaining(postCopyBounds));
});

test("should keep text targeting when opening the context menu from the keyboard", async ({
reactGrab,
}) => {
const target = await createMixedTextTarget(reactGrab);

await reactGrab.activate();
await reactGrab.page.mouse.move(target.position.x, target.position.y);
await expect
.poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName)
.toContain("Grab this text");

await reactGrab.pressKey("ContextMenu");
await expect.poll(() => reactGrab.isContextMenuVisible()).toBe(true);

const copyPayloadPromise = reactGrab.captureNextClipboardWrites();
await reactGrab.clickContextMenuItem("Copy");
const copyPayload = await copyPayloadPromise;

expect(copyPayload["text/plain"]).toContain('"Grab this text"');
expect(copyPayload["text/plain"]).not.toContain("not the nested element");
});

test("should keep text-only elements as element targets", async ({ reactGrab }) => {
await reactGrab.page.evaluate(() => {
const target = document.createElement("div");
target.id = "text-only-target";
target.textContent = "Text-only target";
target.style.cssText =
"position:fixed;left:40px;top:40px;padding:12px;background:white;color:black;font:16px sans-serif;z-index:2147483000";
document.body.append(target);
});

await reactGrab.activate();
await reactGrab.hoverUntilSelected("#text-only-target");

await expect.poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName).toBe("div");
});

test("should write React Grab clipboard metadata on copy", async ({ reactGrab }) => {
await reactGrab.activate();
await reactGrab.hoverUntilSelected("[data-testid='todo-list'] h1");
Expand Down
Loading
Loading