diff --git a/packages/react-grab/e2e/activation-key-config.spec.ts b/packages/react-grab/e2e/activation-key-config.spec.ts index 40584b4f5..a7d3c62e6 100644 --- a/packages/react-grab/e2e/activation-key-config.spec.ts +++ b/packages/react-grab/e2e/activation-key-config.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "./fixtures.js"; +import { NON_ACTIVATING_KEY_HOLD_DURATION_MS } from "./constants.js"; test.describe("Activation Key Configuration", () => { test.describe.configure({ mode: "serial" }); @@ -154,10 +155,12 @@ test.describe("Activation Key Configuration", () => { }); test("should not activate without holding long enough", async ({ reactGrab }) => { + await reactGrab.reinitialize({ + keyHoldDuration: NON_ACTIVATING_KEY_HOLD_DURATION_MS, + }); await reactGrab.page.click("body"); await reactGrab.page.keyboard.down(reactGrab.modifierKey); await reactGrab.page.keyboard.down("c"); - await reactGrab.page.waitForTimeout(50); await reactGrab.page.keyboard.up("c"); await reactGrab.page.keyboard.up(reactGrab.modifierKey); @@ -169,13 +172,8 @@ test.describe("Activation Key Configuration", () => { test("should activate in input by default", async ({ reactGrab }) => { await reactGrab.page.click("[data-testid='test-input']"); - await reactGrab.page.keyboard.down(reactGrab.modifierKey); - await reactGrab.page.keyboard.down("c"); - await reactGrab.page.waitForTimeout(500); - await reactGrab.page.keyboard.up("c"); - await reactGrab.page.keyboard.up(reactGrab.modifierKey); - - await expect.poll(() => reactGrab.isOverlayVisible(), { timeout: 1000 }).toBe(true); + await reactGrab.activateViaKeyboardFromFocusedInput(); + expect(await reactGrab.isOverlayVisible()).toBe(true); }); test("should not activate in input when disabled", async ({ reactGrab }) => { diff --git a/packages/react-grab/e2e/activation.spec.ts b/packages/react-grab/e2e/activation.spec.ts index 9f6e89545..c2629a59e 100644 --- a/packages/react-grab/e2e/activation.spec.ts +++ b/packages/react-grab/e2e/activation.spec.ts @@ -99,25 +99,15 @@ test.describe("Activation Mode Configuration", () => { test("should activate when focused on input element", async ({ reactGrab }) => { await reactGrab.page.click("[data-testid='test-input']"); - await reactGrab.page.keyboard.down(reactGrab.modifierKey); - await reactGrab.page.keyboard.down("c"); - await reactGrab.page.waitForTimeout(500); - await reactGrab.page.keyboard.up("c"); - await reactGrab.page.keyboard.up(reactGrab.modifierKey); - - await expect.poll(() => reactGrab.isOverlayVisible(), { timeout: 1000 }).toBe(true); + await reactGrab.activateViaKeyboardFromFocusedInput(); + expect(await reactGrab.isOverlayVisible()).toBe(true); }); test("should activate when focused on textarea", async ({ reactGrab }) => { await reactGrab.page.click("[data-testid='test-textarea']"); - await reactGrab.page.keyboard.down(reactGrab.modifierKey); - await reactGrab.page.keyboard.down("c"); - await reactGrab.page.waitForTimeout(500); - await reactGrab.page.keyboard.up("c"); - await reactGrab.page.keyboard.up(reactGrab.modifierKey); - - await expect.poll(() => reactGrab.isOverlayVisible(), { timeout: 1000 }).toBe(true); + await reactGrab.activateViaKeyboardFromFocusedInput(); + expect(await reactGrab.isOverlayVisible()).toBe(true); }); test("activation should work after clicking outside input", async ({ reactGrab }) => { diff --git a/packages/react-grab/e2e/constants.ts b/packages/react-grab/e2e/constants.ts index 8322a07c6..09f1b54f5 100644 --- a/packages/react-grab/e2e/constants.ts +++ b/packages/react-grab/e2e/constants.ts @@ -13,6 +13,8 @@ export const HOST_STYLE_DRAG_DISTANCE_PX = 100; export const HOST_STYLE_DRAG_STEP_COUNT = 4; export const SHIFT_PENDING_HOVER_STEP_COUNT = 8; export const POINTER_SETTLE_DELAY_MS = 32; +export const FOCUSED_INPUT_KEY_HOLD_DURATION_MS = 700; +export const NON_ACTIVATING_KEY_HOLD_DURATION_MS = 10_000; export const IFRAME_TEST_POINTER_ID = 1; export const IFRAME_SCROLL_SETTLE_DELAY_MS = 150; export const IFRAME_SCROLL_DELTA_Y_PX = 200; diff --git a/packages/react-grab/e2e/edit-panel.spec.ts b/packages/react-grab/e2e/edit-panel.spec.ts index b60573b92..68d7ce3d7 100644 --- a/packages/react-grab/e2e/edit-panel.spec.ts +++ b/packages/react-grab/e2e/edit-panel.spec.ts @@ -1178,19 +1178,14 @@ test.describe("Style Panel", () => { test("type-to-edit: hover + type m then t → margin-top focused", async ({ reactGrab }) => { await reactGrab.activate(); await reactGrab.hoverUntilSelected(BUTTON_SELECTOR); - await reactGrab.page.keyboard.type("mt", { delay: 50 }); + await reactGrab.page.keyboard.type("m"); await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(true); - await reactGrab.page.waitForTimeout(80); - const searchValue = await reactGrab.page.evaluate( - ({ attrName, inputAttr }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - const input = shadowRoot?.querySelector(`[${inputAttr}]`); - return input?.value ?? null; - }, - { attrName: ATTRIBUTE_NAME, inputAttr: SEARCH_INPUT_ATTR }, - ); - expect(searchValue).toBe("mt"); + const searchInput = reactGrab.page + .locator(`[${ATTRIBUTE_NAME}]`) + .locator(`[${SEARCH_INPUT_ATTR}]`); + await expect(searchInput).toBeFocused(); + await searchInput.pressSequentially("t"); + await expect(searchInput).toHaveValue("mt"); const activeKey = await getActivePropertyKey(reactGrab.page); expect(activeKey).toBe("margin-top"); }); @@ -1200,19 +1195,16 @@ test.describe("Style Panel", () => { }) => { await reactGrab.activate(); await reactGrab.hoverUntilSelected(BUTTON_SELECTOR); - await reactGrab.page.keyboard.type("mt-", { delay: 50 }); + await reactGrab.page.keyboard.type("m"); await expect.poll(() => isEditPanelVisible(reactGrab.page)).toBe(true); - await reactGrab.page.waitForTimeout(80); - const searchValue = await reactGrab.page.evaluate( - ({ attrName, inputAttr }) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - const input = shadowRoot?.querySelector(`[${inputAttr}]`); - return input?.value ?? null; - }, - { attrName: ATTRIBUTE_NAME, inputAttr: SEARCH_INPUT_ATTR }, - ); - expect(searchValue).toBe("mt-"); + const searchInput = reactGrab.page + .locator(`[${ATTRIBUTE_NAME}]`) + .locator(`[${SEARCH_INPUT_ATTR}]`); + await searchInput.click(); + await searchInput.press("t"); + await expect(searchInput).toHaveValue("mt"); + await searchInput.press("-"); + await expect(searchInput).toHaveValue("mt-"); const activeKey = await getActivePropertyKey(reactGrab.page); expect(activeKey).toBe("margin-top"); }); diff --git a/packages/react-grab/e2e/fixtures.ts b/packages/react-grab/e2e/fixtures.ts index 45ba12dba..fece67195 100644 --- a/packages/react-grab/e2e/fixtures.ts +++ b/packages/react-grab/e2e/fixtures.ts @@ -1,5 +1,5 @@ import { test as base, expect, Page, Locator } from "@playwright/test"; -import { ATTRIBUTE_NAME } from "./constants.js"; +import { ATTRIBUTE_NAME, FOCUSED_INPUT_KEY_HOLD_DURATION_MS } from "./constants.js"; import { COVERAGE_RAW_DIR } from "./coverage-config.js"; const COVERAGE_ENABLED = Boolean(process.env.COVERAGE); @@ -104,6 +104,7 @@ export interface ReactGrabPageObject { feedbackModifierKey: ModifierKey; activate: () => Promise; activateViaKeyboard: () => Promise; + activateViaKeyboardFromFocusedInput: () => Promise; deactivate: () => Promise; isOverlayVisible: () => Promise; getOverlayHost: () => Locator; @@ -300,6 +301,15 @@ const createReactGrabPageObject = ( await waitForActive(true); }; + const activateViaKeyboardFromFocusedInput = async () => { + await page.keyboard.down(activationModifierKey); + await page.keyboard.down("c"); + await page.waitForTimeout(FOCUSED_INPUT_KEY_HOLD_DURATION_MS); + await page.keyboard.up("c"); + await page.keyboard.up(activationModifierKey); + await waitForActive(true); + }; + const deactivate = async () => { await page.keyboard.press("Escape"); await waitForActive(false); @@ -654,32 +664,13 @@ const createReactGrabPageObject = ( }; const typeInInput = async (text: string) => { - await page.evaluate((attrName) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - if (!shadowRoot) return; - const root = shadowRoot.querySelector(`[${attrName}]`); - if (!root) return; - const textarea = root.querySelector("[data-react-grab-input]"); - if (textarea) { - textarea.focus(); - } - }, ATTRIBUTE_NAME); + const promptInput = getOverlayHost().locator("textarea[data-react-grab-input]"); + await promptInput.focus(); await page.keyboard.insertText(text); }; const getInputValue = async (): Promise => { - return page.evaluate((attrName) => { - const host = document.querySelector(`[${attrName}]`); - const shadowRoot = host?.shadowRoot; - if (!shadowRoot) return ""; - const root = shadowRoot.querySelector(`[${attrName}]`); - if (!root) return ""; - const textarea = root.querySelector( - "textarea[data-react-grab-ignore-events]", - ) as HTMLTextAreaElement; - return textarea?.value ?? ""; - }, ATTRIBUTE_NAME); + return getOverlayHost().locator("textarea[data-react-grab-input]").inputValue(); }; const submitInput = async () => { @@ -1705,6 +1696,7 @@ const createReactGrabPageObject = ( feedbackModifierKey, activate, activateViaKeyboard, + activateViaKeyboardFromFocusedInput, deactivate, isOverlayVisible, getOverlayHost, diff --git a/packages/react-grab/e2e/prompt-mode.spec.ts b/packages/react-grab/e2e/prompt-mode.spec.ts index 2449d418f..6b524506c 100644 --- a/packages/react-grab/e2e/prompt-mode.spec.ts +++ b/packages/react-grab/e2e/prompt-mode.spec.ts @@ -137,7 +137,11 @@ test.describe("Prompt Mode", () => { await reactGrab.registerCommentAction(); await reactGrab.enterPromptMode("li:first-child"); - await reactGrab.pressEscape(); + const promptInput = reactGrab.page.getByRole("textbox", { + name: "Add context for selected element", + }); + await promptInput.click(); + await promptInput.press("Escape"); await expect.poll(() => reactGrab.isPromptModeActive()).toBe(false); }); @@ -150,7 +154,11 @@ test.describe("Prompt Mode", () => { await reactGrab.typeInInput("Some unsaved text"); - await reactGrab.pressEscape(); + const promptInput = reactGrab.page.getByRole("textbox", { + name: "Add context for selected element", + }); + await expect(promptInput).toHaveValue("Some unsaved text"); + await promptInput.press("Escape"); await expect.poll(() => reactGrab.isPromptModeActive()).toBe(false); }); diff --git a/packages/react-grab/e2e/selection.spec.ts b/packages/react-grab/e2e/selection.spec.ts index dee258e28..94d191c47 100644 --- a/packages/react-grab/e2e/selection.spec.ts +++ b/packages/react-grab/e2e/selection.spec.ts @@ -1,5 +1,234 @@ -import { test, expect } from "./fixtures.js"; -import { ATTRIBUTE_NAME } from "./constants.js"; +import { test, expect, type ReactGrabPageObject } from "./fixtures.js"; +import { ATTRIBUTE_NAME, POINTER_SETTLE_DELAY_MS } from "./constants.js"; + +interface MixedTextTarget { + paddingPosition: { + x: number; + y: number; + }; + position: { + x: number; + y: number; + }; + textBounds: { + height: number; + width: number; + x: number; + y: number; + }; + containerWidth: number; +} + +interface WrappedTextTarget { + position: { + x: number; + y: number; + }; + rectCount: number; + rectIndex: number; + textBounds: { + height: number; + width: number; + x: number; + y: number; + }; +} + +interface WrappedTextLineTarget { + position: { + x: number; + y: number; + }; + textBounds: { + height: number; + width: number; + x: number; + y: number; + }; +} + +const createMixedTextTarget = async (reactGrab: ReactGrabPageObject): Promise => { + 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 { + paddingPosition: { + x: containerBounds.right - 4, + y: containerBounds.top + containerBounds.height / 2, + }, + 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 => { + 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, + }, + rectCount: rects.length, + rectIndex: 1, + textBounds: { + height: textBounds.height, + width: textBounds.width, + x: textBounds.x, + y: textBounds.y, + }, + }; + }); +}; + +const getWrappedTextLineTarget = async ( + reactGrab: ReactGrabPageObject, + rectIndex: number, +): Promise => { + return reactGrab.page.evaluate((targetRectIndex) => { + const container = document.querySelector("#wrapped-text-target"); + const textNode = container?.firstChild; + if (!(textNode instanceof Text)) throw new Error("Missing wrapped text target"); + + const range = document.createRange(); + range.selectNodeContents(textNode); + const textBounds = range.getClientRects()[targetRectIndex]; + if (!textBounds) throw new Error("Missing wrapped text line"); + + 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, + }, + }; + }, rectIndex); +}; + +const reflowWrappedTextTarget = async ( + reactGrab: ReactGrabPageObject, + target: WrappedTextTarget, +): Promise => { + return reactGrab.page.evaluate( + ({ previousRectCount, previousRectIndex }) => { + const container = document.querySelector("#wrapped-text-target"); + const textNode = container?.firstChild; + if (!(container instanceof HTMLElement) || !(textNode instanceof Text)) { + throw new Error("Missing wrapped text target"); + } + + container.style.width = "110px"; + const range = document.createRange(); + range.selectNodeContents(textNode); + const rects = Array.from(range.getClientRects()); + const previousMaximumIndex = Math.max(0, previousRectCount - 1); + const currentMaximumIndex = rects.length - 1; + const normalizedRectIndex = + previousMaximumIndex === 0 ? 0 : previousRectIndex / previousMaximumIndex; + const currentRectIndex = Math.round(normalizedRectIndex * currentMaximumIndex); + const bounds = rects[currentRectIndex]; + if (!bounds || rects.length === previousRectCount) { + throw new Error("Text did not reflow"); + } + + return { + height: bounds.height, + width: bounds.width, + x: bounds.x, + y: bounds.y, + }; + }, + { + previousRectCount: target.rectCount, + previousRectIndex: target.rectIndex, + }, + ); +}; + +const replaceMixedTextNode = async ( + reactGrab: ReactGrabPageObject, + textContent: string, +): Promise => { + 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 => { + 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 ({ @@ -38,6 +267,462 @@ 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 edit the parent element of a direct text node", 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.rightClickAtPosition(target.position.x, target.position.y); + await expect + .poll(async () => (await reactGrab.getContextMenuInfo()).tagBadgeText) + .toContain("Grab this text"); + + await reactGrab.clickContextMenuItem("Style"); + await expect + .poll(() => + reactGrab.page.evaluate((attrName) => { + const host = document.querySelector(`[${attrName}]`); + const panel = host?.shadowRoot?.querySelector("[data-react-grab-edit-panel]"); + return panel?.textContent?.trim() ?? null; + }, ATTRIBUTE_NAME), + ) + .toMatch(/^div/); + }); + + test("should clear a text target when shift-dragging a multi-selection", 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.page.keyboard.down("Shift"); + await reactGrab.dragSelect( + "[data-testid='todo-list'] li:first-child", + "[data-testid='todo-list'] li:nth-child(2)", + ); + + const labelTexts = await reactGrab.page.evaluate(() => { + const host = document.querySelector("[data-react-grab]"); + const labels = host?.shadowRoot?.querySelectorAll("[data-react-grab-selection-label]"); + return Array.from(labels ?? []).map((label) => label.textContent?.trim() ?? ""); + }); + + expect(labelTexts.length).toBeGreaterThanOrEqual(2); + expect(labelTexts.join(" ")).not.toContain("Grab this text"); + + await reactGrab.page.keyboard.up("Shift"); + }); + + test("should use element bounds when a click misses its direct text", 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"); + + await reactGrab.page.mouse.click(target.paddingPosition.x, target.paddingPosition.y); + await expect + .poll(async () => { + const callbackHistory = await reactGrab.getCallbackHistory(); + const copySuccessCall = callbackHistory.findLast( + (callback) => callback.name === "onCopySuccess", + ); + return copySuccessCall?.args[1]; + }) + .toContain("selector: #mixed-text-target"); + await expect + .poll(async () => (await reactGrab.getGrabbedBoxInfo()).boxes[0]?.bounds.width) + .toBeCloseTo(target.containerWidth, 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 update selection when hovering another line of the same text", async ({ + reactGrab, + }) => { + const target = await createWrappedTextTarget(reactGrab); + const firstLineTarget = await getWrappedTextLineTarget(reactGrab, 0); + + 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)); + + await reactGrab.page.waitForTimeout(POINTER_SETTLE_DELAY_MS); + await reactGrab.page.mouse.move(firstLineTarget.position.x, firstLineTarget.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(firstLineTarget.textBounds)); + }); + + test("should keep the hovered wrapped line targeted after reflow", 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)); + + const reflowedBounds = await reflowWrappedTextTarget(reactGrab, target); + 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(reflowedBounds)); + }); + + 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 keep text targeting while prompt text is disconnected", 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.page.evaluate(() => { + const textNode = document.querySelector("#mixed-text-target")?.firstChild; + if (!(textNode instanceof Text)) throw new Error("Missing mixed text target"); + textNode.remove(); + window.dispatchEvent(new Event("resize")); + }); + + await expect + .poll(async () => { + const callbackHistory = await reactGrab.getCallbackHistory(); + return callbackHistory.findLast( + (callback) => callback.name === "onSelectionBox" && callback.args[0] === true, + )?.args[1]; + }) + .toEqual(expect.objectContaining(target.textBounds)); + + 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("selector: #mixed-text-target") && + !copiedContent.includes("not the nested element") + ); + }) + .toBe(true); + }); + + test("should clear text targeting when the hovered tree is disconnected", 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.page.evaluate(() => { + document.querySelector("#mixed-text-target")?.remove(); + window.dispatchEvent(new Event("resize")); + }); + + await expect + .poll(async () => (await reactGrab.getSelectionLabelInfo()).tagName) + .not.toContain("Grab this text"); + }); + + 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"); diff --git a/packages/react-grab/e2e/toolbar-selection-hover.spec.ts b/packages/react-grab/e2e/toolbar-selection-hover.spec.ts index 43eaacd3c..5720a8ab2 100644 --- a/packages/react-grab/e2e/toolbar-selection-hover.spec.ts +++ b/packages/react-grab/e2e/toolbar-selection-hover.spec.ts @@ -42,7 +42,7 @@ test.describe("Toolbar Selection Hover", () => { await expect.poll(() => reactGrab.isSelectionBoxVisible(), { timeout: 2000 }).toBe(false); - await hoverAwayFromToolbar(reactGrab.page); + await reactGrab.hoverUntilSelected("li"); await expect.poll(() => reactGrab.isSelectionBoxVisible(), { timeout: 2000 }).toBe(true); }); diff --git a/packages/react-grab/e2e/touch-mode.spec.ts b/packages/react-grab/e2e/touch-mode.spec.ts index 14030b291..05d25c393 100644 --- a/packages/react-grab/e2e/touch-mode.spec.ts +++ b/packages/react-grab/e2e/touch-mode.spec.ts @@ -113,7 +113,12 @@ test.describe("Touch Mode", () => { await reactGrab.activate(); - await reactGrab.page.waitForTimeout(100); + await reactGrab.page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }), + ); expect(await reactGrab.isSelectionBoxVisible()).toBe(false); }); diff --git a/packages/react-grab/src/components/edit-panel/index.tsx b/packages/react-grab/src/components/edit-panel/index.tsx index a80d67bc8..1d61e2ccf 100644 --- a/packages/react-grab/src/components/edit-panel/index.tsx +++ b/packages/react-grab/src/components/edit-panel/index.tsx @@ -108,6 +108,7 @@ const EditPanelBody: Component = (props) => { let searchInputRef: HTMLTextAreaElement | undefined; const preview = props.state.preview; + const [searchInputElement, setSearchInputElement] = createSignal(); const [searchQuery, setSearchQuery] = createSignal(props.state.initialSearchQuery ?? ""); const [inlineNumericSearchQuery, setInlineNumericSearchQuery] = createSignal(null); const [activeKey, setActiveKey] = createSignal<"left" | "right" | null>(null); @@ -157,6 +158,16 @@ const EditPanelBody: Component = (props) => { const dropdown = createAnchoredDropdown(() => containerRef, props.position); + createEffect(() => { + const searchInput = searchInputElement(); + if (!dropdown.shouldMount() || !searchInput) return; + queueMicrotask(() => { + focusInOverlay(searchInput, { preventScroll: true }); + const searchInputLength = searchInput.value.length; + searchInput.setSelectionRange(searchInputLength, searchInputLength); + }); + }); + const flashActiveKey = (direction: "left" | "right") => { setActiveKey(direction); clearTimeout(activeKeyTimerId); @@ -485,13 +496,6 @@ const EditPanelBody: Component = (props) => { }; onMount(() => { - queueMicrotask(() => { - focusInOverlay(searchInputRef, { preventScroll: true }); - if (searchInputRef) { - const length = searchInputRef.value.length; - searchInputRef.setSelectionRange(length, length); - } - }); dropdown.measure(); const initialQuery = searchQuery(); if (initialQuery) autoApply.applyTailwindClass(initialQuery); @@ -676,6 +680,7 @@ const EditPanelBody: Component = (props) => {