Skip to content
Open
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
231 changes: 231 additions & 0 deletions packages/react-grab/e2e/screenshot-labels.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { test as base, expect, type Page } from "@playwright/test";
import { test } from "./fixtures.js";

interface CanvasInspectResult {
found: boolean;
hasInk: boolean;
inkPixels: number;
width: number;
height: number;
}

const inspectOverlayCanvasInk = async (page: Page): Promise<CanvasInspectResult> =>
page.evaluate(async () => {
const fallback: CanvasInspectResult = {
found: false,
hasInk: false,
inkPixels: 0,
width: 0,
height: 0,
};
const host = document.querySelector("[data-react-grab]");
const shadowRoot = host?.shadowRoot;
if (!shadowRoot) return fallback;
const canvas = shadowRoot.querySelector(
"canvas[data-react-grab-overlay-canvas]",
) as HTMLCanvasElement | null;
if (!canvas) return fallback;

const ctx = canvas.getContext("2d");
if (!ctx) return { ...fallback, found: true, width: canvas.width, height: canvas.height };

const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
let inkPixels = 0;
for (let i = 3; i < data.length; i += 4) {
if (data[i] > 0) inkPixels++;
}
return {
found: true,
hasInk: inkPixels > 0,
inkPixels,
width: canvas.width,
height: canvas.height,
};
});

const waitForOverlayInk = async (page: Page, shouldHaveInk: boolean): Promise<void> => {
await page.waitForFunction(
(expected) => {
const host = document.querySelector("[data-react-grab]");
const shadowRoot = host?.shadowRoot;
if (!shadowRoot) return !expected;
const canvas = shadowRoot.querySelector(
"canvas[data-react-grab-overlay-canvas]",
) as HTMLCanvasElement | null;
if (!canvas) return !expected;
const ctx = canvas.getContext("2d");
if (!ctx) return !expected;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
let hasInk = false;
for (let i = 3; i < data.length; i += 4) {
if (data[i] > 0) {
hasInk = true;
break;
}
}
return expected ? hasInk : !hasInk;
},
shouldHaveInk,
{ timeout: 2000 },
);
};

const withPlatform = (platform: string) =>
base.extend({
page: async ({ page }, use) => {
await page.addInitScript((mockedPlatform) => {
Object.defineProperty(navigator, "platform", {
configurable: true,
get: () => mockedPlatform,
});
}, platform);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(
() => (window as { __REACT_GRAB__?: unknown }).__REACT_GRAB__ !== undefined,
undefined,
{ timeout: 8000 },
);
await use(page);
},
});

const macTest = withPlatform("MacIntel");
const windowsTest = withPlatform("Win32");
const linuxTest = withPlatform("Linux x86_64");

test.describe("Screenshot Labels - smoke", () => {
test("overlay canvas is mounted by default", async ({ reactGrab }) => {
const result = await inspectOverlayCanvasInk(reactGrab.page);
expect(result.found).toBe(true);
expect(result.width).toBeGreaterThan(0);
expect(result.height).toBeGreaterThan(0);
});
});

macTest.describe("Screenshot Labels - macOS (Cmd+Shift)", () => {
macTest("does not activate when react-grab is disabled via API", async ({ page }) => {
await page.evaluate(() => {
const api = (window as { __REACT_GRAB__?: { setEnabled: (enabled: boolean) => void } })
.__REACT_GRAB__;
api?.setEnabled(false);
});

await page.keyboard.down("Meta");
await page.keyboard.down("Shift");
await page.waitForTimeout(200);

const result = await inspectOverlayCanvasInk(page);
expect(result.hasInk).toBe(false);

await page.keyboard.up("Shift");
await page.keyboard.up("Meta");

await page.evaluate(() => {
const api = (window as { __REACT_GRAB__?: { setEnabled: (enabled: boolean) => void } })
.__REACT_GRAB__;
api?.setEnabled(true);
});
});

macTest("Cmd then Shift activates labels", async ({ page }) => {
const before = await inspectOverlayCanvasInk(page);
expect(before.found).toBe(true);
expect(before.hasInk).toBe(false);

await page.keyboard.down("Meta");
await page.keyboard.down("Shift");

await waitForOverlayInk(page, true);
const during = await inspectOverlayCanvasInk(page);
expect(during.inkPixels).toBeGreaterThan(0);

await page.keyboard.up("Shift");
await page.keyboard.up("Meta");

await waitForOverlayInk(page, false);
});

macTest("Shift then Cmd activates labels", async ({ page }) => {
await page.keyboard.down("Shift");
await page.keyboard.down("Meta");

await waitForOverlayInk(page, true);

await page.keyboard.up("Meta");
await page.keyboard.up("Shift");

await waitForOverlayInk(page, false);
});

macTest("Cmd alone does not activate", async ({ page }) => {
await page.keyboard.down("Meta");
await page.waitForTimeout(200);

const result = await inspectOverlayCanvasInk(page);
expect(result.hasInk).toBe(false);

await page.keyboard.up("Meta");
});

macTest("Shift alone does not activate", async ({ page }) => {
await page.keyboard.down("Shift");
await page.waitForTimeout(200);

const result = await inspectOverlayCanvasInk(page);
expect(result.hasInk).toBe(false);

await page.keyboard.up("Shift");
});
});

windowsTest.describe("Screenshot Labels - Windows (Win+Shift)", () => {
windowsTest("Win+Shift activates labels", async ({ page }) => {
await page.keyboard.down("Meta");
await page.keyboard.down("Shift");

await waitForOverlayInk(page, true);

await page.keyboard.up("Shift");
await page.keyboard.up("Meta");

await waitForOverlayInk(page, false);
});

windowsTest("Ctrl+Shift does not activate", async ({ page }) => {
await page.keyboard.down("Control");
await page.keyboard.down("Shift");
await page.waitForTimeout(200);

const result = await inspectOverlayCanvasInk(page);
expect(result.hasInk).toBe(false);

await page.keyboard.up("Shift");
await page.keyboard.up("Control");
});
});

linuxTest.describe("Screenshot Labels - Linux (PrintScreen)", () => {
linuxTest("PrintScreen activates labels", async ({ page }) => {
await page.keyboard.down("PrintScreen");

await waitForOverlayInk(page, true);

await page.keyboard.up("PrintScreen");

await waitForOverlayInk(page, false);
});

linuxTest("Meta+Shift does not activate on Linux", async ({ page }) => {
await page.keyboard.down("Meta");
await page.keyboard.down("Shift");
await page.waitForTimeout(200);

const result = await inspectOverlayCanvasInk(page);
expect(result.hasInk).toBe(false);

await page.keyboard.up("Shift");
await page.keyboard.up("Meta");
});
});
106 changes: 105 additions & 1 deletion packages/react-grab/src/components/overlay-canvas.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createEffect, on, onCleanup, onMount, type Component } from "solid-js";
import type { OverlayBounds, SelectionLabelInstance } from "../types.js";
import type { OverlayBounds, ScreenshotLabel, SelectionLabelInstance } from "../types.js";
import { lerp } from "../utils/lerp.js";
import {
SELECTION_LERP_FACTOR,
Expand All @@ -15,6 +15,17 @@ import {
OVERLAY_BORDER_COLOR_DEFAULT,
OVERLAY_FILL_COLOR_DEFAULT,
BASELINE_FRAME_DURATION_MS,
SCREENSHOT_LABEL_BACKGROUND_COLOR,
SCREENSHOT_LABEL_COLLISION_PADDING_PX,
SCREENSHOT_LABEL_CORNER_RADIUS_PX,
SCREENSHOT_LABEL_FONT_FAMILY,
SCREENSHOT_LABEL_FONT_SIZE_PX,
SCREENSHOT_LABEL_MIDDOT,
SCREENSHOT_LABEL_PADDING_X_PX,
SCREENSHOT_LABEL_PADDING_Y_PX,
SCREENSHOT_LABEL_SECONDARY_COLOR,
SCREENSHOT_LABEL_TEXT_COLOR,
SCREENSHOT_LABEL_VERTICAL_OFFSET_PX,
} from "../constants.js";
import { nativeCancelAnimationFrame, nativeRequestAnimationFrame } from "../utils/native-raf.js";
import { supportsDisplayP3 } from "../utils/supports-display-p3.js";
Expand Down Expand Up @@ -72,6 +83,8 @@ interface OverlayCanvasProps {
}>;

labelInstances?: SelectionLabelInstance[];

screenshotLabels?: ScreenshotLabel[];
}

export const OverlayCanvas: Component<OverlayCanvasProps> = (props) => {
Expand Down Expand Up @@ -288,6 +301,86 @@ export const OverlayCanvas: Component<OverlayCanvasProps> = (props) => {
}
};

const renderScreenshotLabels = () => {
if (!mainContext) return;
const labels = props.screenshotLabels;
if (!labels || labels.length === 0) return;

const context = mainContext;
context.save();
context.font = `${SCREENSHOT_LABEL_FONT_SIZE_PX}px ${SCREENSHOT_LABEL_FONT_FAMILY}`;
context.textBaseline = "top";

const textHeight = SCREENSHOT_LABEL_FONT_SIZE_PX;
const paddingX = SCREENSHOT_LABEL_PADDING_X_PX;
const paddingY = SCREENSHOT_LABEL_PADDING_Y_PX;
const rectHeight = textHeight + paddingY * 2;
const cornerRadius = SCREENSHOT_LABEL_CORNER_RADIUS_PX;
const collisionPadding = SCREENSHOT_LABEL_COLLISION_PADDING_PX;
const middotSegment = ` ${SCREENSHOT_LABEL_MIDDOT} `;

const sortedLabels = labels.slice().sort((labelA, labelB) => labelB.area - labelA.area);
const placedRects: Array<{ x: number; y: number; width: number; height: number }> = [];

for (const label of sortedLabels) {
const componentText = label.componentName;
const fileText = label.fileBaseName ?? "";
const componentWidth = context.measureText(componentText).width;
const middotWidth = fileText ? context.measureText(middotSegment).width : 0;
const fileWidth = fileText ? context.measureText(fileText).width : 0;
const totalTextWidth = componentWidth + middotWidth + fileWidth;
const rectWidth = totalTextWidth + paddingX * 2;

let rectX = Math.round(label.x);
let rectY = Math.round(label.y - rectHeight - SCREENSHOT_LABEL_VERTICAL_OFFSET_PX);
if (rectY < 0) {
rectY = Math.round(label.y + SCREENSHOT_LABEL_VERTICAL_OFFSET_PX);
}
if (rectX + rectWidth > canvasWidth) {
rectX = Math.max(0, canvasWidth - rectWidth);
}
if (rectX < 0) rectX = 0;

let collidesWithPlaced = false;
for (const placed of placedRects) {
const horizontallyApart =
rectX + rectWidth + collisionPadding <= placed.x ||
placed.x + placed.width + collisionPadding <= rectX;
const verticallyApart =
rectY + rectHeight + collisionPadding <= placed.y ||
placed.y + placed.height + collisionPadding <= rectY;
if (!horizontallyApart && !verticallyApart) {
collidesWithPlaced = true;
break;
}
}
if (collidesWithPlaced) continue;

placedRects.push({ x: rectX, y: rectY, width: rectWidth, height: rectHeight });

context.fillStyle = SCREENSHOT_LABEL_BACKGROUND_COLOR;
context.beginPath();
context.roundRect(rectX, rectY, rectWidth, rectHeight, cornerRadius);
context.fill();

let textCursorX = rectX + paddingX;
const textCursorY = rectY + paddingY;

context.fillStyle = SCREENSHOT_LABEL_TEXT_COLOR;
context.fillText(componentText, textCursorX, textCursorY);
textCursorX += componentWidth;

if (fileText) {
context.fillStyle = SCREENSHOT_LABEL_SECONDARY_COLOR;
context.fillText(middotSegment, textCursorX, textCursorY);
textCursorX += middotWidth;
context.fillText(fileText, textCursorX, textCursorY);
}
}

context.restore();
};

const compositeAllLayers = () => {
if (!mainContext || !canvasRef) return;

Expand All @@ -306,6 +399,8 @@ export const OverlayCanvas: Component<OverlayCanvasProps> = (props) => {
mainContext.drawImage(layer.canvas, 0, 0, canvasWidth, canvasHeight);
}
}

renderScreenshotLabels();
};

const interpolateBounds = (
Expand Down Expand Up @@ -522,6 +617,15 @@ export const OverlayCanvas: Component<OverlayCanvasProps> = (props) => {
),
);

createEffect(
on(
() => props.screenshotLabels,
() => {
scheduleAnimationFrame();
},
),
);

createEffect(
on(
() => [props.grabbedBoxes, props.labelInstances] as const,
Expand Down
1 change: 1 addition & 0 deletions packages/react-grab/src/components/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const ReactGrabRenderer: Component<ReactGrabRendererProps> = (props) => {
dragBounds={props.dragBounds}
grabbedBoxes={props.grabbedBoxes}
labelInstances={props.labelInstances}
screenshotLabels={props.screenshotLabels}
/>
{/* translateZ(0) promotes to its own compositor layer so opacity
transitions skip main-thread repaints; contain:strict with
Expand Down
Loading
Loading